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
15 changes: 15 additions & 0 deletions src/channels/telegram/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function topicHelpText(): string {
"/expires - 查看当前会话销毁策略",
"/whoami - 查看你的 Telegram 管理员 ID",
"/history [数量] - 查看最近消息摘要,默认 10,最多 30",
"/search <关键词> - 在当前会话历史消息中搜索,最多返回 20 条",
"",
"备注与标签:",
"/note <内容> - 保存内部备注,不会外发",
Expand Down Expand Up @@ -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);
Expand Down
70 changes: 70 additions & 0 deletions src/domain/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): ConversationListItem {
return {
id: Number(row.id),
Expand All @@ -41,6 +52,19 @@ function conversationListItemFromRow(row: Record<string, unknown>): Conversation
};
}

function messageSearchResultFromRow(row: Record<string, unknown>): 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;
Expand Down Expand Up @@ -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<Record<string, unknown>>;
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<string | number> = [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<Record<string, unknown>>;
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 };
}
}
25 changes: 25 additions & 0 deletions src/runtime/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
78 changes: 77 additions & 1 deletion src/runtime/web-console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -270,6 +281,7 @@ export interface WebConsoleOptions {
listFailedDeliveries: (opts: { page: number; pageSize: number }) => { items: DeliveryView[]; total: number };
scheduleRetry: (deliveryId: number) => Promise<void>;
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";
Expand Down Expand Up @@ -395,6 +407,18 @@ export async function startWebConsole(options: WebConsoleOptions): Promise<Serve
return;
}

if (url.pathname === "/operations/search" && req.method === "GET") {
const query = url.searchParams.get("q") ?? "";
const pageNum = Math.max(1, Number(url.searchParams.get("page") ?? "1"));
if (!query.trim()) {
renderSearch(res, { items: [], total: 0 }, query, pageNum);
return;
}
const data = options.searchMessages({ query: query.trim(), page: pageNum, pageSize: 50 });
renderSearch(res, data, query, pageNum);
return;
}

if (url.pathname === "/" && req.method === "GET") {
renderDashboard(res, options.settings, options.getStatus(), url.searchParams.get("saved") === "1");
return;
Expand Down Expand Up @@ -842,14 +866,15 @@ body{margin:0;font-family:var(--font-sans);background:var(--color-bg);color:var(
.ops-section-title{font-size:20px;font-weight:600;margin:0 0 16px}
`;

function opsPage(title: string, body: string, activeNav: "overview" | "conversations" | "deliveries" | "audit"): string {
function opsPage(title: string, body: string, activeNav: "overview" | "conversations" | "deliveries" | "audit" | "search"): string {
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title><style>${opsBaseCss}</style></head><body>
<header class="ops-header">
<a href="/">配置</a>
<a href="/operations" class="${activeNav === "overview" ? "active" : ""}">概览</a>
<a href="/operations/conversations" class="${activeNav === "conversations" ? "active" : ""}">会话</a>
<a href="/operations/deliveries" class="${activeNav === "deliveries" ? "active" : ""}">投递</a>
<a href="/operations/audit" class="${activeNav === "audit" ? "active" : ""}">审计</a>
<a href="/operations/search" class="${activeNav === "search" ? "active" : ""}">搜索</a>
</header>
<main class="ops-main">${body}</main>
</body></html>`;
Expand Down Expand Up @@ -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) => `<tr>
<td>${m.id}</td>
<td>${escapeHtml(m.createdAt)}</td>
<td>${m.conversationId}</td>
<td>${escapeHtml(m.topicName ?? "-")}</td>
<td>${escapeHtml(m.contactDisplayName ?? "-")}</td>
<td><span class="ops-badge">${escapeHtml(m.direction)}</span></td>
<td style="max-width:400px;overflow:hidden;text-overflow:ellipsis">${escapeHtml(m.text ?? `[${m.messageType}]`)}</td>
</tr>`,
)
.join("")
: query.trim()
? `<tr><td colspan="7" class="ops-empty">未找到匹配的消息。</td></tr>`
: `<tr><td colspan="7" class="ops-empty">输入关键词开始搜索。</td></tr>`;
const pager = Array.from({ length: Math.min(totalPages, 10) }, (_, i) => i + 1)
.map((p) => `<a href="/operations/search${buildQs(p)}" class="${p === page ? "current" : ""}">${p}</a>`)
.join("");
const body = `
<h2 class="ops-section-title">消息搜索${query.trim() ? ` (${data.total})` : ""}</h2>
<form method="get" action="/operations/search" style="display:flex;gap:12px;margin-bottom:16px">
<input type="text" name="q" value="${queryValue}" placeholder="搜索消息内容..." style="flex:1;padding:8px 12px;border:1px solid var(--color-border);border-radius:8px">
<button type="submit" class="ops-btn">搜索</button>
</form>
<table class="ops-table">
<thead><tr><th>ID</th><th>时间</th><th>会话</th><th>Topic</th><th>联系人</th><th>方向</th><th>内容</th></tr></thead>
<tbody>${rows}</tbody>
</table>
${totalPages > 1 ? `<div class="ops-pager">${pager}</div>` : ""}
`;
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);
Expand Down
Loading