diff --git a/src/channels/telegram/commands.ts b/src/channels/telegram/commands.ts index 9bf3a02..e2e2c62 100644 --- a/src/channels/telegram/commands.ts +++ b/src/channels/telegram/commands.ts @@ -49,6 +49,7 @@ export function topicHelpText(): string { "/ban [原因] - 封禁联系人,后续消息会被拒收", "/unban - 解除封禁", "/delete confirm - 删除当前 Topic 并清理数据库会话信息", + "/reset confirm - 清空会话消息和草稿,保留联系人映射和 Topic", "/draft - 重新生成 AI 回复草稿", "/draft view - 查看当前草稿", "/draft send - 发送当前草稿给外部用户", @@ -254,8 +255,8 @@ export async function handleTopicCommand( return true; } case "assign": { - if (!args) { - await ctx.reply("用法:/assign "); + if (!args || !/^\d+$/.test(args)) { + await ctx.reply("用法:/assign ,ID 必须为数字。"); return true; } await deps.conversations.assign(conversation.id, args); @@ -281,6 +282,15 @@ export async function handleTopicCommand( await deps.conversations.deleteConversationData(conversation.id); return true; } + case "reset": { + if (args !== "confirm") { + await ctx.reply("危险操作:将清空当前会话的所有消息、草稿和备注。确认请发送 /reset confirm"); + return true; + } + await deps.conversations.resetConversation(conversation.id); + await ctx.reply("会话已重置。联系人映射和 Topic 已保留。"); + return true; + } case "close": { await deps.conversations.setConversationStatus(conversation.id, "closed"); await ctx.reply("会话已关闭。"); diff --git a/src/channels/telegram/menu.ts b/src/channels/telegram/menu.ts index de17ed1..eae919e 100644 --- a/src/channels/telegram/menu.ts +++ b/src/channels/telegram/menu.ts @@ -4,6 +4,7 @@ import type { AppConfig } from "../../runtime/config.js"; export const privateBotCommands: BotCommand[] = [ { command: "start", description: "开始使用 InboxBridge" }, + { command: "help", description: "查看使用帮助" }, { command: "info", description: "汇总联系人、会话、Topic 和负责人信息" }, { command: "profile", description: "查看联系人资料" }, { command: "status", description: "查看会话状态" }, @@ -24,12 +25,16 @@ export const privateBotCommands: BotCommand[] = [ { command: "ban", description: "封禁联系人" }, { command: "unban", description: "解除封禁" }, { command: "delete", description: "删除当前 Topic 并清理数据库,需 /delete confirm" }, + { command: "reset", description: "清空会话消息和草稿,需 /reset confirm" }, { command: "draft", description: "AI 回复草稿:/draft view|send|discard 或 /draft 重新生成" }, + { command: "ai_on", description: "开启当前会话的 AI 草稿" }, + { command: "ai_off", description: "关闭当前会话的 AI 草稿" }, { command: "export", description: "导出当前会话最近 200 条消息" }, { command: "id", description: "查看当前聊天和用户 ID" }, ]; export const adminBotCommands: BotCommand[] = [ + { command: "help", description: "查看可用命令列表" }, { command: "info", description: "汇总联系人、会话、Topic 和负责人信息" }, { command: "profile", description: "查看联系人资料" }, { command: "status", description: "查看会话状态" }, @@ -50,7 +55,10 @@ export const adminBotCommands: BotCommand[] = [ { command: "ban", description: "封禁联系人" }, { command: "unban", description: "解除封禁" }, { command: "delete", description: "删除当前 Topic 并清理数据库,需 /delete confirm" }, + { command: "reset", description: "清空会话消息和草稿,需 /reset confirm" }, { command: "draft", description: "AI 回复草稿:/draft view|send|discard 或 /draft 重新生成" }, + { command: "ai_on", description: "开启当前会话的 AI 草稿" }, + { command: "ai_off", description: "关闭当前会话的 AI 草稿" }, { command: "export", description: "导出当前会话最近 200 条消息" }, { command: "id", description: "查看当前聊天、Topic 和用户 ID" }, ]; diff --git a/src/channels/telegram/messages.ts b/src/channels/telegram/messages.ts index 6374f6f..12f2c2f 100644 --- a/src/channels/telegram/messages.ts +++ b/src/channels/telegram/messages.ts @@ -44,6 +44,13 @@ function contactInputFromTelegramUser(user: { }; } +const MAX_MESSAGE_LENGTH = 4000; + +function truncateText(text: string, max = MAX_MESSAGE_LENGTH): string { + if (text.length <= max) return text; + return text.slice(0, max - 3) + "..."; +} + function fallbackText(input: { rawMessage: Record; prefix: string; @@ -52,7 +59,7 @@ function fallbackText(input: { const type = detectMessageType(input.rawMessage); const text = extractText(input.rawMessage); const body = text?.trim() || `[${type}] 暂无法复制该类型的原始消息,请在 Telegram 中查看原消息记录。`; - return [input.prefix, body, input.copyError ? `复制失败原因:${input.copyError}` : undefined].filter(Boolean).join("\n\n"); + return truncateText([input.prefix, body, input.copyError ? `复制失败原因:${input.copyError}` : undefined].filter(Boolean).join("\n\n")); } function isMessageThreadNotFound(error: unknown): boolean { @@ -148,7 +155,8 @@ export async function handlePrivateMessage(ctx: Context, deps: TelegramMessageDe return; } - if (bundle.conversation.status === "closed") { + const wasClosed = bundle.conversation.status === "closed"; + if (wasClosed) { await deps.conversations.setConversationStatus(bundle.conversation.id, "open"); } @@ -177,6 +185,18 @@ export async function handlePrivateMessage(ctx: Context, deps: TelegramMessageDe throw error; } + if (wasClosed) { + try { + await ctx.api.sendMessage( + deps.config.TELEGRAM_MANAGEMENT_CHAT_ID, + "会话已自动重开(用户发送了新消息)。", + { message_thread_id: topic.messageThreadId }, + ); + } catch { + // 通知失败不影响主流程 + } + } + try { await copyWithDelivery({ ctx, @@ -316,7 +336,7 @@ export async function handleManagementMessage(ctx: Context, deps: TelegramMessag const text = extractText(rawMessage); if (text?.startsWith("/")) { const handled = await handleTopicCommand(ctx, deps, { topic, conversation, contact }, text); - if (!handled) await ctx.reply("未知命令。"); + if (!handled) await ctx.reply("未知命令。发送 /help 查看可用命令列表。"); return; } diff --git a/src/domain/conversations.ts b/src/domain/conversations.ts index 268d96f..4ee2deb 100644 --- a/src/domain/conversations.ts +++ b/src/domain/conversations.ts @@ -285,6 +285,26 @@ export class ConversationService { } } + async resetConversation(conversationId: number): Promise { + this.db.exec("BEGIN"); + try { + this.db + .prepare( + `DELETE FROM deliveries + WHERE source_message_id IN (SELECT id FROM messages WHERE conversation_id = ?)`, + ) + .run(conversationId); + this.db.prepare("DELETE FROM ai_drafts WHERE conversation_id = ?").run(conversationId); + this.db.prepare("DELETE FROM conversation_tags WHERE conversation_id = ?").run(conversationId); + this.db.prepare("DELETE FROM admin_notes WHERE conversation_id = ?").run(conversationId); + this.db.prepare("DELETE FROM messages WHERE conversation_id = ?").run(conversationId); + this.db.exec("COMMIT"); + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; + } + } + async expiredConversations(now = nowIso()): Promise> { const rows = this.db .prepare( diff --git a/test/core.test.ts b/test/core.test.ts index 6967ed4..eb7d35a 100644 --- a/test/core.test.ts +++ b/test/core.test.ts @@ -632,6 +632,7 @@ describe("telegram helpers", () => { assert.match(help, /\/history/); assert.match(help, /\/notes/); assert.match(help, /\/delete confirm/); + assert.match(help, /\/reset confirm/); assert.match(help, /\/export/); assert.match(help, /普通消息会默认转发/); }); @@ -640,13 +641,50 @@ describe("telegram helpers", () => { assert.ok(privateBotCommands.some((command) => command.command === "start")); assert.ok(!privateBotCommands.some((command) => command.command === "menu")); assert.ok(privateBotCommands.some((command) => command.command === "export")); - assert.ok(!privateBotCommands.some((command) => command.command === "help")); + assert.ok(privateBotCommands.some((command) => command.command === "help")); assert.ok(!adminBotCommands.some((command) => command.command === "menu")); assert.ok(adminBotCommands.some((command) => command.command === "history")); assert.ok(adminBotCommands.some((command) => command.command === "delete")); + assert.ok(adminBotCommands.some((command) => command.command === "reset")); + assert.ok(adminBotCommands.some((command) => command.command === "ai_on")); + assert.ok(adminBotCommands.some((command) => command.command === "ai_off")); + assert.ok(adminBotCommands.some((command) => command.command === "help")); assert.ok(adminBotCommands.every((command) => !command.command.startsWith("/"))); }); + it("resetConversation clears messages, drafts, notes, tags but keeps conversation", async () => { + const service = new ConversationService(handle.db, 30); + const bundle = await service.getOrCreateConversation({ + platform: "telegram", + externalUserId: "42", + displayName: "Test", + }); + await service.createMessage({ + conversationId: bundle.conversation.id, + contactId: bundle.contact.id, + direction: "inbound", + platform: "telegram", + messageType: "text", + text: "hello", + }); + handle.db + .prepare("INSERT INTO ai_drafts (conversation_id, status, created_at, updated_at) VALUES (?, 'ready', ?, ?)") + .run(bundle.conversation.id, new Date().toISOString(), new Date().toISOString()); + handle.db + .prepare("INSERT INTO admin_notes (conversation_id, admin_user_id, note, created_at) VALUES (?, '1', 'note', ?)") + .run(bundle.conversation.id, new Date().toISOString()); + + await service.resetConversation(bundle.conversation.id); + + assert.ok(await service.getConversation(bundle.conversation.id)); + assert.ok(await service.getContact(bundle.contact.id)); + assert.equal((await service.recentMessages(bundle.conversation.id, 10)).length, 0); + const draftCount = handle.db.prepare("SELECT COUNT(*) AS c FROM ai_drafts WHERE conversation_id = ?").get(bundle.conversation.id) as { c: number }; + assert.equal(draftCount.c, 0); + const noteCount = handle.db.prepare("SELECT COUNT(*) AS c FROM admin_notes WHERE conversation_id = ?").get(bundle.conversation.id) as { c: number }; + assert.equal(noteCount.c, 0); + }); + it("aggregates delivery stats by status", async () => { const deliveries = new DeliveryService(handle.db); const id1 = await deliveries.createPending(undefined, "telegram-user:1");