Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/channels/telegram/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export function topicHelpText(): string {
"/ban [原因] - 封禁联系人,后续消息会被拒收",
"/unban - 解除封禁",
"/delete confirm - 删除当前 Topic 并清理数据库会话信息",
"/reset confirm - 清空会话消息和草稿,保留联系人映射和 Topic",
"/draft - 重新生成 AI 回复草稿",
"/draft view - 查看当前草稿",
"/draft send - 发送当前草稿给外部用户",
Expand Down Expand Up @@ -254,8 +255,8 @@ export async function handleTopicCommand(
return true;
}
case "assign": {
if (!args) {
await ctx.reply("用法:/assign <telegram_user_id>");
if (!args || !/^\d+$/.test(args)) {
await ctx.reply("用法:/assign <telegram_user_id>,ID 必须为数字。");
return true;
}
await deps.conversations.assign(conversation.id, args);
Expand All @@ -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("会话已关闭。");
Expand Down
8 changes: 8 additions & 0 deletions src/channels/telegram/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "查看会话状态" },
Expand All @@ -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: "查看会话状态" },
Expand All @@ -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" },
];
Expand Down
26 changes: 23 additions & 3 deletions src/channels/telegram/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
prefix: string;
Expand All @@ -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 {
Expand Down Expand Up @@ -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");
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down
20 changes: 20 additions & 0 deletions src/domain/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,26 @@ export class ConversationService {
}
}

async resetConversation(conversationId: number): Promise<void> {
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<Array<{ conversation: Conversation; topic: TelegramTopic }>> {
const rows = this.db
.prepare(
Expand Down
40 changes: 39 additions & 1 deletion test/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, /普通消息会默认转发/);
});
Expand All @@ -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");
Expand Down