diff --git a/src/channels/telegram/commands.ts b/src/channels/telegram/commands.ts index d2a44db..458bcb8 100644 --- a/src/channels/telegram/commands.ts +++ b/src/channels/telegram/commands.ts @@ -32,6 +32,7 @@ export function topicHelpText(): string { "/expires - 查看当前会话销毁策略", "/whoami - 查看你的 Telegram 管理员 ID", "/history [数量] - 查看最近消息摘要,默认 10,最多 30", + "/search <关键词> - 在当前会话历史消息中搜索,最多返回 20 条", "", "备注与标签:", "/note <内容> - 保存内部备注,不会外发", @@ -412,6 +413,20 @@ export async function handleTopicCommand( await ctx.reply("已对该会话关闭 AI 草稿。该会话不再自动生成回复草稿。"); return true; } + case "search": { + if (!args) { + await ctx.reply("用法:/search <关键词>,在当前会话历史消息中搜索。"); + return true; + } + const results = deps.conversations.searchMessagesInConversation(conversation.id, args, 20); + if (results.length === 0) { + await ctx.reply("未找到匹配的消息。"); + return true; + } + const lines = results.map(messagePreview); + await ctx.reply([`找到 ${results.length} 条匹配消息:`, ...lines].join("\n")); + return true; + } case "audit": { const limit = parseLimit(args, 20, 50); const logs = deps.audit.listByConversation(conversation.id, limit); diff --git a/src/domain/conversations.ts b/src/domain/conversations.ts index 05144d6..21faa6f 100644 --- a/src/domain/conversations.ts +++ b/src/domain/conversations.ts @@ -26,6 +26,17 @@ export interface ConversationListItem { messageThreadId: number | null; } +export interface MessageSearchResult { + id: number; + conversationId: number; + direction: "inbound" | "outbound" | "internal"; + messageType: string; + text: string | null; + createdAt: string; + contactDisplayName: string | null; + topicName: string | null; +} + function conversationListItemFromRow(row: Record): ConversationListItem { return { id: Number(row.id), @@ -41,6 +52,19 @@ function conversationListItemFromRow(row: Record): Conversation }; } +function messageSearchResultFromRow(row: Record): MessageSearchResult { + return { + id: Number(row.id), + conversationId: Number(row.conversation_id), + direction: row.direction as MessageSearchResult["direction"], + messageType: String(row.message_type), + text: row.text === null ? null : String(row.text), + createdAt: String(row.created_at), + contactDisplayName: row.display_name === null ? null : String(row.display_name), + topicName: row.topic_name === null ? null : String(row.topic_name), + }; +} + export interface AdminNote { id: number; conversationId: number; @@ -565,4 +589,50 @@ export class ConversationService { .get(...params) as { cnt: number }; return { items: rows.map(conversationListItemFromRow), total: countRow.cnt }; } + + searchMessagesInConversation(conversationId: number, query: string, limit: number): Message[] { + const pattern = `%${query.replace(/[%_]/g, (m) => "\\" + m)}%`; + const rows = this.db + .prepare( + `SELECT * FROM messages + WHERE conversation_id = ? AND text LIKE ? ESCAPE '\\' + ORDER BY created_at DESC + LIMIT ?`, + ) + .all(conversationId, pattern, limit) as Array>; + return rows.map(messageFromRow); + } + + searchMessages(opts: { + query: string; + conversationId?: number; + limit: number; + offset: number; + }): { items: MessageSearchResult[]; total: number } { + const pattern = `%${opts.query.replace(/[%_]/g, (m) => "\\" + m)}%`; + const conditions = ["m.text LIKE ? ESCAPE '\\'"]; + const params: Array = [pattern]; + if (opts.conversationId !== undefined) { + conditions.push("m.conversation_id = ?"); + params.push(opts.conversationId); + } + const where = `WHERE ${conditions.join(" AND ")}`; + const rows = this.db + .prepare( + `SELECT m.id, m.conversation_id, m.direction, m.message_type, m.text, m.created_at, + ct.display_name, t.topic_name + FROM messages m + LEFT JOIN conversations c ON c.id = m.conversation_id + LEFT JOIN contacts ct ON ct.id = c.contact_id + LEFT JOIN telegram_topics t ON t.conversation_id = m.conversation_id + ${where} + ORDER BY m.created_at DESC + LIMIT ? OFFSET ?`, + ) + .all(...params, opts.limit, opts.offset) as Array>; + const countRow = this.db + .prepare(`SELECT COUNT(*) AS cnt FROM messages m ${where}`) + .get(...params) as { cnt: number }; + return { items: rows.map(messageSearchResultFromRow), total: countRow.cnt }; + } } diff --git a/src/runtime/main.ts b/src/runtime/main.ts index 4489508..7b460ab 100644 --- a/src/runtime/main.ts +++ b/src/runtime/main.ts @@ -397,6 +397,31 @@ await startWebConsole({ total: result.total, }; }, + searchMessages: (opts) => { + const conversations = new ConversationService( + handle.db, + loadConfigFromSources(settings.all()).MESSAGE_RETENTION_DAYS, + loadConfigFromSources(settings.all()).DEFAULT_CONVERSATION_RETENTION_DAYS, + ); + const result = conversations.searchMessages({ + query: opts.query, + limit: opts.pageSize, + offset: (opts.page - 1) * opts.pageSize, + }); + return { + items: result.items.map((m) => ({ + id: m.id, + conversationId: m.conversationId, + direction: m.direction, + messageType: m.messageType, + text: m.text, + createdAt: m.createdAt, + contactDisplayName: m.contactDisplayName, + topicName: m.topicName, + })), + total: result.total, + }; + }, }); logger.info({ port: databaseConfig.WEB_CONSOLE_PORT }, "InboxBridge web console started."); await restartRuntime(); diff --git a/src/runtime/web-console.ts b/src/runtime/web-console.ts index 373dd78..175cde1 100644 --- a/src/runtime/web-console.ts +++ b/src/runtime/web-console.ts @@ -237,6 +237,17 @@ export interface AuditLogView { createdAt: string; } +export interface MessageSearchView { + id: number; + conversationId: number; + direction: string; + messageType: string; + text: string | null; + createdAt: string; + contactDisplayName: string | null; + topicName: string | null; +} + export const auditActionOptions = [ "ban", "unban", @@ -270,6 +281,7 @@ export interface WebConsoleOptions { listFailedDeliveries: (opts: { page: number; pageSize: number }) => { items: DeliveryView[]; total: number }; scheduleRetry: (deliveryId: number) => Promise; listAuditLogs: (opts: { page: number; adminId?: string; action?: string; pageSize: number }) => { items: AuditLogView[]; total: number }; + searchMessages: (opts: { query: string; page: number; pageSize: number }) => { items: MessageSearchView[]; total: number }; } type SessionKind = "password" | "setup"; @@ -395,6 +407,18 @@ export async function startWebConsole(options: WebConsoleOptions): Promise${escapeHtml(title)}
配置 @@ -850,6 +874,7 @@ function opsPage(title: string, body: string, activeNav: "overview" | "conversat 会话 投递 审计 +搜索
${body}
`; @@ -1028,6 +1053,57 @@ function renderAuditLogs( send(res, 200, "text/html; charset=utf-8", opsPage("审计日志 - InboxBridge", body, "audit")); } +function renderSearch( + res: ServerResponse, + data: { items: MessageSearchView[]; total: number }, + query: string, + page: number, +): void { + const pageSize = 50; + const totalPages = Math.max(1, Math.ceil(data.total / pageSize)); + const queryValue = escapeHtml(query); + const buildQs = (p: number): string => { + const params = new URLSearchParams(); + if (query) params.set("q", query); + if (p > 1) params.set("page", String(p)); + const qs = params.toString(); + return qs ? `?${qs}` : ""; + }; + const rows = data.items.length + ? data.items + .map( + (m) => ` + ${m.id} + ${escapeHtml(m.createdAt)} + ${m.conversationId} + ${escapeHtml(m.topicName ?? "-")} + ${escapeHtml(m.contactDisplayName ?? "-")} + ${escapeHtml(m.direction)} + ${escapeHtml(m.text ?? `[${m.messageType}]`)} + `, + ) + .join("") + : query.trim() + ? `未找到匹配的消息。` + : `输入关键词开始搜索。`; + const pager = Array.from({ length: Math.min(totalPages, 10) }, (_, i) => i + 1) + .map((p) => `${p}`) + .join(""); + const body = ` +

消息搜索${query.trim() ? ` (${data.total})` : ""}

+
+ + +
+ + + ${rows} +
ID时间会话Topic联系人方向内容
+ ${totalPages > 1 ? `
${pager}
` : ""} + `; + send(res, 200, "text/html; charset=utf-8", opsPage("消息搜索 - InboxBridge", body, "search")); +} + function redirect(res: ServerResponse, location: string): void { res.statusCode = 302; res.setHeader("location", location); diff --git a/test/core.test.ts b/test/core.test.ts index b2e5bcf..fdb15eb 100644 --- a/test/core.test.ts +++ b/test/core.test.ts @@ -46,6 +46,7 @@ const stubListConversations = () => ({ items: [], total: 0 }); const stubListFailedDeliveries = () => ({ items: [], total: 0 }); const stubScheduleRetry = async () => {}; const stubListAuditLogs = () => ({ items: [], total: 0 }); +const stubSearchMessages = () => ({ items: [], total: 0 }); beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "inboxbridge-")); @@ -185,6 +186,7 @@ describe("web console", () => { listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, listAuditLogs: stubListAuditLogs, + searchMessages: stubSearchMessages, }); const port = (server.address() as AddressInfo).port; @@ -230,6 +232,7 @@ describe("web console", () => { listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, listAuditLogs: stubListAuditLogs, + searchMessages: stubSearchMessages, }); const port = (server.address() as AddressInfo).port; @@ -260,6 +263,7 @@ describe("web console", () => { listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, listAuditLogs: stubListAuditLogs, + searchMessages: stubSearchMessages, telegramWebhook: async () => { throw new Error("webhook failed"); }, @@ -298,6 +302,7 @@ describe("web console", () => { listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, listAuditLogs: stubListAuditLogs, + searchMessages: stubSearchMessages, }), /EADDRINUSE/, ); @@ -321,6 +326,7 @@ describe("web console", () => { listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, listAuditLogs: stubListAuditLogs, + searchMessages: stubSearchMessages, }); const port = (server.address() as AddressInfo).port; try { @@ -350,6 +356,7 @@ describe("web console", () => { listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, listAuditLogs: stubListAuditLogs, + searchMessages: stubSearchMessages, }); const port = (server.address() as AddressInfo).port; try { @@ -377,6 +384,7 @@ describe("web console", () => { listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, listAuditLogs: stubListAuditLogs, + searchMessages: stubSearchMessages, }); const port = (server.address() as AddressInfo).port; try { @@ -410,6 +418,7 @@ describe("web console", () => { listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, listAuditLogs: stubListAuditLogs, + searchMessages: stubSearchMessages, }); const port = (server.address() as AddressInfo).port; try { @@ -447,6 +456,7 @@ describe("web console", () => { listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, listAuditLogs: stubListAuditLogs, + searchMessages: stubSearchMessages, }); const port = (server.address() as AddressInfo).port; try { @@ -479,6 +489,7 @@ describe("web console", () => { listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, listAuditLogs: stubListAuditLogs, + searchMessages: stubSearchMessages, }); const port = (server.address() as AddressInfo).port; try { @@ -1014,6 +1025,111 @@ describe("AI draft lifecycle", () => { }); }); +describe("message search", () => { + it("searches messages within a conversation", async () => { + const conversations = new ConversationService(handle.db, 30); + const bundle = await conversations.getOrCreateConversation({ + platform: "telegram", + externalUserId: "444", + displayName: "SearchUser", + }); + const convId = bundle.conversation.id; + handle.db + .prepare( + `INSERT INTO messages (conversation_id, contact_id, direction, platform, message_type, text, created_at) + VALUES (?, NULL, 'inbound', 'telegram', 'text', ?, ?)`, + ) + .run(convId, "hello world", "2026-06-29T10:00:00.000Z"); + handle.db + .prepare( + `INSERT INTO messages (conversation_id, contact_id, direction, platform, message_type, text, created_at) + VALUES (?, NULL, 'inbound', 'telegram', 'text', ?, ?)`, + ) + .run(convId, "goodbye world", "2026-06-29T11:00:00.000Z"); + handle.db + .prepare( + `INSERT INTO messages (conversation_id, contact_id, direction, platform, message_type, text, created_at) + VALUES (?, NULL, 'outbound', 'telegram', 'text', ?, ?)`, + ) + .run(convId, "no match here", "2026-06-29T12:00:00.000Z"); + + const results = conversations.searchMessagesInConversation(convId, "world", 20); + assert.equal(results.length, 2); + assert.equal(results[0].text, "goodbye world"); + assert.equal(results[1].text, "hello world"); + }); + + it("searches messages globally with pagination", async () => { + const conversations = new ConversationService(handle.db, 30); + const bundle = await conversations.getOrCreateConversation({ + platform: "telegram", + externalUserId: "555", + displayName: "GlobalUser", + }); + for (let i = 0; i < 5; i++) { + handle.db + .prepare( + `INSERT INTO messages (conversation_id, contact_id, direction, platform, message_type, text, created_at) + VALUES (?, NULL, 'inbound', 'telegram', 'text', ?, ?)`, + ) + .run(bundle.conversation.id, `urgent issue ${i}`, `2026-06-29T${10 + i}:00:00.000Z`); + } + + const result = conversations.searchMessages({ query: "urgent", limit: 2, offset: 0 }); + assert.equal(result.total, 5); + assert.equal(result.items.length, 2); + assert.ok(result.items[0].text?.includes("urgent")); + + const page2 = conversations.searchMessages({ query: "urgent", limit: 2, offset: 2 }); + assert.equal(page2.items.length, 2); + }); + + it("renders search page behind auth", async () => { + const settings = new AppSettingsService(handle.db); + settings.setMany({ WEB_CONSOLE_SETUP_TOKEN: "setup-token" }); + const server = await startWebConsole({ + settings, + port: 0, + getStatus: () => ({ bot: "stopped", issues: [] }), + onConfigSaved: async () => {}, + dbHealthCheck: noopDbHealthCheck, + collectMetrics: stubMetrics, + collectOperationsOverview: stubOpsOverview, + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, + listAuditLogs: stubListAuditLogs, + searchMessages: () => ({ + items: [{ + id: 1, + conversationId: 1, + direction: "inbound", + messageType: "text", + text: "matching text", + createdAt: "2026-06-29T00:00:00.000Z", + contactDisplayName: "TestUser", + topicName: "TestTopic", + }], + total: 1, + }), + }); + const port = (server.address() as AddressInfo).port; + const loginRes = await fetch(`http://127.0.0.1:${port}/login`, { + method: "POST", + body: new URLSearchParams({ setupToken: "setup-token" }), + redirect: "manual", + }); + const cookie = loginRes.headers.get("set-cookie")?.split(";")[0] ?? ""; + const res = await fetch(`http://127.0.0.1:${port}/operations/search?q=matching`, { + headers: { cookie }, + }); + const html = await res.text(); + server.close(); + assert.ok(html.includes("消息搜索")); + assert.ok(html.includes("matching text")); + }); +}); + describe("audit log", () => { it("writes and retrieves audit entries", async () => { const conversations = new ConversationService(handle.db, 30); @@ -1080,6 +1196,7 @@ describe("audit log", () => { listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, listAuditLogs: () => ({ items: [], total: 0 }), + searchMessages: stubSearchMessages, }); const port = (server.address() as AddressInfo).port; const res = await fetch(`http://127.0.0.1:${port}/operations/audit`); @@ -1123,6 +1240,7 @@ describe("audit log", () => { }], total: 1, }), + searchMessages: stubSearchMessages, }); const port = (server.address() as AddressInfo).port; const loginRes = await fetch(`http://127.0.0.1:${port}/login`, {