From 12449bdbf844dca142e0e146f306090e834d89f8 Mon Sep 17 00:00:00 2001 From: one-ea Date: Mon, 29 Jun 2026 18:39:24 +0000 Subject: [PATCH] docs: add batch 2 feature specs (stability, ai-draft, ops-dashboard, cmd-refinement) Four feature directions for batch 2: - stability-foundation: unhandled rejection/exception handlers, pino logger injection, /healthz and /metrics endpoints, graceful shutdown timeout - ai-draft-lifecycle: /draft send|discard|view commands, draft retention cleanup, stale pending recovery, AI fetch timeout+retry - operations-dashboard: web console ops views (overview, conversations, deliveries) with manual retry, independent dashboard styling - command-message-refinement: /reset command, /assign validation, unknown command /help hint, menu completion, closed-reopen notification, message length truncation Co-authored-by: monkeycode-ai --- .../specs/ai-draft-lifecycle/design.md | 320 +++++++++++++++++ .../specs/ai-draft-lifecycle/requirements.md | 93 +++++ .../command-message-refinement/design.md | 214 ++++++++++++ .../requirements.md | 87 +++++ .../specs/operations-dashboard/design.md | 270 +++++++++++++++ .../operations-dashboard/requirements.md | 90 +++++ .../specs/stability-foundation/design.md | 322 ++++++++++++++++++ .../stability-foundation/requirements.md | 85 +++++ 8 files changed, 1481 insertions(+) create mode 100644 .monkeycode/specs/ai-draft-lifecycle/design.md create mode 100644 .monkeycode/specs/ai-draft-lifecycle/requirements.md create mode 100644 .monkeycode/specs/command-message-refinement/design.md create mode 100644 .monkeycode/specs/command-message-refinement/requirements.md create mode 100644 .monkeycode/specs/operations-dashboard/design.md create mode 100644 .monkeycode/specs/operations-dashboard/requirements.md create mode 100644 .monkeycode/specs/stability-foundation/design.md create mode 100644 .monkeycode/specs/stability-foundation/requirements.md diff --git a/.monkeycode/specs/ai-draft-lifecycle/design.md b/.monkeycode/specs/ai-draft-lifecycle/design.md new file mode 100644 index 0000000..e1040e5 --- /dev/null +++ b/.monkeycode/specs/ai-draft-lifecycle/design.md @@ -0,0 +1,320 @@ +# AI Draft Lifecycle + +Feature Name: ai-draft-lifecycle +Updated: 2026-06-29 + +## Description + +补齐 AI 草稿的完整生命周期:新增 `/draft send`、`/draft discard`、`/draft view` 命令;草稿状态扩展 `sent` 和 `discarded`;草稿过期清理(混合策略:终态草稿硬删除,活跃草稿软清理);stale pending 草稿回收;AI fetch 加 15 秒超时 + 1 次重试。草稿发送复用 Delivery 记录路径,享受投递重试 worker。 + +## Architecture + +```mermaid +graph TD + A["管理员 /draft send"] --> B["AiDraftService.findReady"] + B --> C{找到 ready 草稿?} + C -->|是| D["创建出站 Message + Delivery"] + D --> E["copyWithDelivery 投递"] + E --> F{投递成功?} + F -->|是| G["草稿标记 sent"] + F -->|否| H["草稿保持 ready"] + C -->|否| I["回复无草稿"] + + J["管理员 /draft discard"] --> K["AiDraftService.findReady"] + K --> L{找到?} + L -->|是| M["标记 discarded"] + L -->|否| N["回复无草稿"] + + O["RetentionService 清理"] --> P["DELETE 终态草稿"] + O --> Q["软清理活跃草稿字段"] + O --> R["回收 stale pending"] +``` + +## Components and Interfaces + +### 1. AiDraftService 新增方法 + +```typescript +export class AiDraftService { + // 现有: generate + + async findReady(conversationId: number): Promise { + const row = this.db + .prepare( + `SELECT * FROM ai_drafts + WHERE conversation_id = ? AND status = 'ready' + ORDER BY created_at DESC LIMIT 1`, + ) + .get(conversationId) as Record | undefined; + return row ? draftFromRow(row) : undefined; + } + + async markSent(draftId: number): Promise { + this.db + .prepare("UPDATE ai_drafts SET status = 'sent', updated_at = ? WHERE id = ?") + .run(nowIso(), draftId); + } + + async markDiscarded(draftId: number): Promise { + this.db + .prepare("UPDATE ai_drafts SET status = 'discarded', updated_at = ? WHERE id = ?") + .run(nowIso(), draftId); + } +} +``` + +### 2. Draft 类型扩展 + +```typescript +export interface DraftRow { + id: number; + conversationId: number; + sourceMessageId: number | null; + status: "pending" | "ready" | "failed" | "sent" | "discarded"; + draftText: string | null; + error: string | null; + createdAt: string; + updatedAt: string; +} +``` + +`schema.ts` 的 `Delivery.status` 无需变更(`sent` 和 `discarded` 是 ai_drafts 表的新值,不影响 deliveries 表)。 + +### 3. /draft 命令子命令路由 + +`commands.ts` 的 `case "draft"` 改为支持子命令: + +```typescript +case "draft": { + const subCommand = args.trim().split(/\s+/)[0]?.toLowerCase(); + if (subCommand === "send") { + return handleDraftSend(ctx, deps, topic, conversation, contact); + } + if (subCommand === "discard") { + return handleDraftDiscard(ctx, deps, conversation); + } + if (subCommand === "view") { + return handleDraftView(ctx, deps, conversation); + } + // 无子命令:保持现有行为(重新生成) + const result = await deps.aiDrafts.generate(conversation.id); + // ... 现有逻辑 + return true; +} +``` + +### 4. handleDraftSend 实现 + +```typescript +async function handleDraftSend( + ctx: Context, + deps: CommandDeps & { deliveries: DeliveryService }, + topic: TelegramTopic, + conversation: Conversation, + contact: Contact, +): Promise { + const draft = await deps.aiDrafts.findReady(conversation.id); + if (!draft) { + await ctx.reply("当前没有可发送的草稿,使用 /draft 生成。"); + return true; + } + try { + await sendOutboundMessage({ + ctx, + deps, + topic, + conversation, + contact, + text: draft.draftText!, + }); + await deps.aiDrafts.markSent(draft.id); + await ctx.reply("草稿已发送给外部用户。"); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + await ctx.reply(`草稿发送失败:${msg}。草稿保留,可重试 /draft send。`); + } + return true; +} +``` + +`sendOutboundMessage` 需要从 `messages.ts` 提取的共享投递逻辑。当前 `handleManagementMessage` 中普通文本回复的投递路径包含:创建出站 Message → createPending Delivery → copyWithDelivery → markSent/markFailed。需将这段逻辑提取为独立函数供 commands.ts 复用。 + +### 5. CommandDeps 扩展 + +`commands.ts` 的 `CommandDeps` 需要新增 `deliveries: DeliveryService` 和 `logger: Logger`(依赖稳定性地基的 logger 注入): + +```typescript +export interface CommandDeps { + config: AppConfig; + conversations: ConversationService; + aiDrafts: AiDraftService; + deliveries: DeliveryService; + logger: Logger; +} +``` + +`bot.ts` 的 `createTelegramBot` 在构造 deps 时传入 `deliveries` 和 `logger`。`messages.ts` 的 `handleTopicCommand` 调用处也需传入更新后的 deps。 + +### 6. AI fetch 超时与重试 + +`ai-drafts.ts` 的 `generate` 方法中 fetch 调用改为: + +```typescript +const startTime = Date.now(); +let lastError: Error | undefined; +let attempts = 0; + +while (attempts < 2) { + attempts += 1; + try { + const response = await fetch(url, { + method: "POST", + headers: { ... }, + body: JSON.stringify({ ... }), + signal: AbortSignal.timeout(15_000), + }); + + if (response.status >= 400 && response.status < 500) { + // 4xx 不重试 + throw new Error(`AI provider returned HTTP ${response.status}`); + } + + if (!response.ok) { + throw new Error(`AI provider returned HTTP ${response.status}`); + } + + // ... 解析响应 + const elapsed = Date.now() - startTime; + logger.info({ draftId, attempts, elapsedMs: elapsed }, "AI draft generated."); + // ... 更新为 ready + return { status: "ready", text }; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + if (attempts < 2) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } +} + +// 重试耗尽 +const message = lastError?.message ?? "AI draft generation failed."; +// ... 更新为 failed +return { status: "failed", error: message }; +``` + +### 7. 草稿过期清理(RetentionService 扩展) + +`retention.ts` 的 `cleanupExpired` 新增草稿清理逻辑: + +```typescript +async cleanupExpired(): Promise { + let cleaned = 0; + + // 1. 回收 stale pending(超过 5 分钟) + const staleCutoff = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + const staleResult = this.db + .prepare( + `UPDATE ai_drafts + SET status = 'failed', error = 'Draft generation timed out (process may have restarted)', updated_at = ? + WHERE status = 'pending' AND created_at < ?`, + ) + .run(nowIso(), staleCutoff); + cleaned += staleResult.changes; + + // 2. 硬删除终态草稿(sent/discarded/failed 且超过保留期) + const retentionCutoff = new Date(Date.now() - this.retentionDays * 86400 * 1000).toISOString(); + const deleted = this.db + .prepare( + `DELETE FROM ai_drafts + WHERE status IN ('sent', 'discarded', 'failed') AND created_at < ?`, + ) + .run(retentionCutoff); + cleaned += deleted.changes; + + // 3. 软清理 ready/pending 草稿的文本字段(保留行用于审计) + this.db + .prepare( + `UPDATE ai_drafts + SET draft_text = NULL, error = NULL, updated_at = ? + WHERE created_at < ? AND (draft_text IS NOT NULL OR error IS NOT NULL)`, + ) + .run(nowIso(), retentionCutoff); + + // 4. 现有的消息正文清理... + return cleaned; +} +``` + +### 8. 帮助文本更新 + +`commands.ts` 的 `topicHelpText` 中 `/draft` 行更新为: + +``` +/draft - 重新生成 AI 回复草稿 +/draft view - 查看当前草稿 +/draft send - 发送当前草稿给外部用户 +/draft discard - 丢弃当前草稿 +``` + +## Data Models + +### ai_drafts 表 status 值扩展 + +| status | 含义 | 清理策略 | +|--------|------|----------| +| pending | 生成中 | 超过 5 分钟回收为 failed | +| ready | 可用 | 超过保留期软清理字段 | +| failed | 生成失败 | 超过保留期硬删除 | +| sent | 已发送 | 超过保留期硬删除 | +| discarded | 已丢弃 | 超过保留期硬删除 | + +无需 schema 迁移:`status` 列已是 TEXT 类型,新增值不需要 ALTER TABLE。 + +## Correctness Properties + +1. `/draft send` 必须复用 Delivery 记录路径,确保投递失败时有重试 worker 兜底。 +2. 草稿发送成功后必须标记为 `sent`,防止重复发送。 +3. 草稿发送失败时必须保持 `ready`,允许重试 `/draft send`。 +4. `findReady` 必须按 `created_at DESC` 排序,返回最新的 ready 草稿。 +5. stale pending 回收的 5 分钟阈值必须大于 AI fetch 的 15 秒超时 + 2 秒重试间隔,避免误回收正在生成中的草稿。 +6. 草稿清理必须复用 `MESSAGE_RETENTION_SWEEP_INTERVAL_MINUTES` 间隔,不新增定时器。 + +## Error Handling + +| 场景 | 处理 | +|------|------| +| `/draft send` 时外部用户 chatId 不可达 | Delivery 标记 failed,草稿保持 ready,回复管理员失败原因 | +| `/draft send` 时 bot.api 抛异常 | catch 后回复管理员,草稿保持 ready | +| AI fetch 超时 | 重试 1 次,仍失败则标记草稿 failed | +| AI fetch 4xx | 不重试,直接标记 failed | +| stale pending 回收时 DB 错误 | RetentionService 的调用方(main.ts 定时器)已 catch 并 logger.error | + +## Test Strategy + +### 单元测试 + +1. **findReady 返回最新 ready 草稿**:插入 3 条草稿(1 ready 旧 + 1 ready 新 + 1 sent),断言返回最新的 ready。 +2. **markSent 更新状态**:插入 ready 草稿,调用 markSent,断言 status 变为 sent。 +3. **markDiscarded 更新状态**:同上。 +4. **cleanupExpired 回收 stale pending**:插入 pending 草稿,created_at 设为 6 分钟前,运行清理,断言变为 failed。 +5. **cleanupExpired 硬删除终态草稿**:插入 sent 草稿,created_at 设为 31 天前,运行清理,断言行被删除。 +6. **cleanupExpired 软清理 ready 草稿字段**:插入 ready 草稿,created_at 设为 31 天前,运行清理,断言 draft_text 变为 NULL 但行存在。 +7. **AI fetch 超时后重试**:mock fetch 第一次 timeout,第二次成功,断言草稿为 ready 且 attempts=2。 +8. **AI fetch 4xx 不重试**:mock fetch 返回 401,断言草稿为 failed 且 attempts=1。 + +### 验证命令 + +```bash +npm run verify +``` + +## References + +[^1]: (src/domain/ai-drafts.ts#L19) - AiDraftService.generate 当前实现 +[^2]: (src/domain/ai-drafts.ts#L45) - fetch 调用无超时 +[^3]: (src/channels/telegram/commands.ts#L300) - /draft 命令当前实现 +[^4]: (src/channels/telegram/messages.ts#L272) - 草稿生成后展示逻辑 +[^5]: (src/domain/retention.ts) - RetentionService.cleanupExpired 当前实现 +[^6]: (src/storage/migrations/0001_initial.ts#L96) - ai_drafts 表定义 +[^7]: (src/domain/deliveries.ts#L24) - DeliveryService.createPending +[^8]: (src/channels/telegram/messages.ts#L88) - copyWithDelivery 投递逻辑 diff --git a/.monkeycode/specs/ai-draft-lifecycle/requirements.md b/.monkeycode/specs/ai-draft-lifecycle/requirements.md new file mode 100644 index 0000000..743a08a --- /dev/null +++ b/.monkeycode/specs/ai-draft-lifecycle/requirements.md @@ -0,0 +1,93 @@ +# Requirements Document + +## Introduction + +InboxBridge 的 AI 草稿功能当前只实现了 `generate`(生成草稿)。存在四个缺口:(1) 草稿永久留存,无过期清理机制,`ai_drafts` 表会无限增长;(2) 管理员看到草稿后只能手动复制粘贴发送,没有 `/draft send` 命令直接投递;(3) 无法丢弃不满意的草稿,`/draft discard` 缺失;(4) `ai-drafts.ts` 的 `fetch` 调用无超时、无重试,AI 接口慢响应会阻塞消息处理主链路;若 `generate` 在 INSERT 后 UPDATE 前进程崩溃,草稿永远卡在 `pending` 状态。 + +本功能补齐草稿的完整生命周期:生成 → 查看 → 发送/丢弃 → 过期清理,并增强 fetch 的健壮性。 + +## Glossary + +- **Draft**: `ai_drafts` 表中的一条记录,状态为 `pending`/`ready`/`failed`/`discarded`/`sent`。 +- **Active draft**: 会话中最近一条 `ready` 状态的草稿,是 `/draft send` 和 `/draft discard` 的操作对象。 +- **Draft generation**: 调用 AI 接口生成回复文本的过程,对应 `AiDraftService.generate`。 +- **Draft dispatch**: 将草稿文本作为出站消息投递给外部用户的过程。 +- **Draft retention**: 过期草稿的清理策略,由定时器驱动。 +- **Stale pending draft**: `pending` 状态超过阈值时间的草稿,视为进程崩溃残留。 + +## Requirements + +### Requirement 1: 草稿发送命令 + +**User Story:** AS 管理员, I want 用 /draft send 直接发送当前草稿给外部用户, so that 不需要手动复制粘贴 + +#### Acceptance Criteria + +1. WHEN 管理员在 Topic 内发送 `/draft send`, the System SHALL 查找该会话最近一条 `ready` 状态的草稿 +2. WHEN 找到 ready 草稿, the System SHALL 将草稿文本作为出站消息投递给外部用户(通过 bot.api.sendMessage 到外部用户 chatId) +3. WHEN 投递成功, the System SHALL 将草稿状态更新为 `sent` 并记录投递时间 +4. WHEN 投递失败, the System SHALL 回复管理员失败原因,草稿状态保持 `ready`(可重试发送) +5. IF 未找到 ready 草稿, the System SHALL 回复"当前没有可发送的草稿,使用 /draft 生成。" + +### Requirement 2: 草稿丢弃命令 + +**User Story:** AS 管理员, I want 用 /draft discard 丢弃不满意的草稿, so that 避免误发且保持草稿列表整洁 + +#### Acceptance Criteria + +1. WHEN 管理员在 Topic 内发送 `/draft discard`, the System SHALL 查找该会话最近一条 `ready` 状态的草稿 +2. WHEN 找到 ready 草稿, the System SHALL 将草稿状态更新为 `discarded` 并回复"草稿已丢弃。" +3. IF 未找到 ready 草稿, the System SHALL 回复"当前没有可丢弃的草稿。" + +### Requirement 3: 草稿查看命令 + +**User Story:** AS 管理员, I want 用 /draft view 查看当前草稿而不重新生成, so that 避免浪费 AI 调用 + +#### Acceptance Criteria + +1. WHEN 管理员在 Topic 内发送 `/draft view`, the System SHALL 查找该会话最近一条 `ready` 状态的草稿 +2. WHEN 找到 ready 草稿, the System SHALL 回复草稿文本(带"不会自动发送"提示) +3. IF 未找到 ready 草稿, the System SHALL 回复"当前没有草稿,使用 /draft 生成。" +4. WHEN 管理员在 Topic 内发送 `/draft`(无参数), the System SHALL 保持现有行为(重新生成草稿) + +### Requirement 4: 草稿过期清理 + +**User Story:** AS 运维人员, I want 过期草稿被自动清理, so that ai_drafts 表不会无限增长 + +#### Acceptance Criteria + +1. WHILE 消息正文清理定时器运行(复用 MESSAGE_RETENTION_SWEEP_INTERVAL_MINUTES 间隔), the System SHALL 清理 `created_at` 早于 MESSAGE_RETENTION_DAYS 的草稿 +2. WHEN 清理草稿, the System SHALL 删除草稿行的 `draft_text` 和 `error` 字段(软清理,保留行用于审计),并将状态为 `pending` 的残留草稿标记为 `failed` +3. IF MESSAGE_RETENTION_DAYS 为 0, the System SHALL 跳过草稿清理(表示永久保留) + +### Requirement 5: Stale pending 草稿回收 + +**User Story:** AS 运维人员, I want 卡在 pending 状态的草稿被自动标记为 failed, so that 进程崩溃残留的草稿不会永远占用 pending 状态 + +#### Acceptance Criteria + +1. WHILE 草稿清理定时器运行, the System SHALL 将 `pending` 状态且 `created_at` 超过 5 分钟的草稿标记为 `failed`,error 字段记录"Draft generation timed out (process may have restarted)" +2. WHEN stale pending 草稿被标记为 failed, the System SHALL 记录一条 warn 日志包含草稿 ID 和会话 ID + +### Requirement 6: AI fetch 超时与重试 + +**User Story:** AS 运维人员, I want AI 接口调用有超时和重试保护, so that 慢响应不会阻塞消息处理主链路 + +#### Acceptance Criteria + +1. WHEN 调用 AI 接口, the System SHALL 设置 15 秒超时(通过 `AbortSignal.timeout(15000)`) +2. IF AI 接口首次调用因超时或 5xx 错误失败, the System SHALL 等待 2 秒后重试一次 +3. IF 重试仍失败, the System SHALL 将草稿标记为 `failed` 并返回错误信息 +4. IF AI 接口返回 4xx 错误(非 5xx), the System SHALL 不重试,直接标记为 `failed` +5. WHILE AI fetch 执行, the System SHALL 在 logger 中记录请求耗时和重试次数 + +### Requirement 7: 草稿状态扩展 + +**User Story:** AS 开发者, I want 草稿状态包含 sent 和 discarded, so that 能区分已发送、已丢弃和生成失败的草稿 + +#### Acceptance Criteria + +1. WHEN 草稿被发送, the status SHALL 更新为 `sent` +2. WHEN 草稿被丢弃, the status SHALL 更新为 `discarded` +3. WHILE 查询 ready 草稿, the System SHALL 只返回 `status = 'ready'` 的草稿 +4. WHILE 统计草稿(metrics 端点), the System SHALL 按 status 分组计数,包含 sent 和 discarded diff --git a/.monkeycode/specs/command-message-refinement/design.md b/.monkeycode/specs/command-message-refinement/design.md new file mode 100644 index 0000000..adc1e16 --- /dev/null +++ b/.monkeycode/specs/command-message-refinement/design.md @@ -0,0 +1,214 @@ +# Command and Message Refinement + +Feature Name: command-message-refinement +Updated: 2026-06-29 + +## Description + +补齐命令系统和消息流的缺口:新增 `/reset` 命令(清空会话数据但保留映射);`/assign` 参数校验;未知命令加 `/help` 提示;命令菜单补齐 `/help` `/ai_on` `/ai_off`;closed 会话重开时发 bot 通知;出站消息和 AI 草稿超长自动截断;AI 上下文拼接加长度上限。 + +## Architecture + +```mermaid +graph TD + A["/reset confirm"] --> B["ConversationService.resetConversation"] + B --> C["DELETE messages WHERE conversation_id"] + B --> D["DELETE ai_drafts WHERE conversation_id"] + B --> E["DELETE admin_notes WHERE conversation_id"] + B --> F["DELETE message_tags WHERE conversation_id"] + + G["外部用户消息"] --> H{会话 closed?} + H -->|是| I["改为 open"] + I --> J["bot.api.sendMessage 通知 Topic"] + + K["出站消息文本"] --> L{长度 > 4000?} + L -->|是| M["截断为 3997 + ..."] + L -->|否| N["原样发送"] +``` + +## Components and Interfaces + +### 1. ConversationService.resetConversation + +```typescript +async resetConversation(conversationId: number): Promise { + this.db.prepare("DELETE FROM messages WHERE conversation_id = ?").run(conversationId); + this.db.prepare("DELETE FROM ai_drafts WHERE conversation_id = ?").run(conversationId); + this.db.prepare("DELETE FROM admin_notes WHERE conversation_id = ?").run(conversationId); + this.db.prepare("DELETE FROM message_tags WHERE conversation_id = ?").run(conversationId); +} +``` + +### 2. /reset 命令 + +```typescript +case "reset": { + if (args !== "confirm") { + await ctx.reply("危险操作:将清空当前会话的所有消息、草稿和备注。确认请发送 /reset confirm"); + return true; + } + await deps.conversations.resetConversation(conversation.id); + await ctx.reply("会话已重置。联系人映射和 Topic 已保留。"); + return true; +} +``` + +### 3. /assign 参数校验 + +```typescript +case "assign": { + if (!args) { + await ctx.reply("用法:/assign ,ID 必须为数字。"); + return true; + } + if (!/^\d+$/.test(args)) { + await ctx.reply("用法:/assign ,ID 必须为数字。"); + return true; + } + await deps.conversations.assign(conversation.id, args); + await ctx.reply(`已分配给 ${args}。`); + return true; +} +``` + +### 4. 未知命令提示 + +`messages.ts` 的 `handleManagementMessage` 中: + +```typescript +if (text?.startsWith("/")) { + const handled = await handleTopicCommand(ctx, deps, { topic, conversation, contact }, text); + if (!handled) await ctx.reply("未知命令。发送 /help 查看可用命令列表。"); + return; +} +``` + +### 5. 命令菜单补齐 + +`menu.ts` 的 `adminBotCommands` 新增: + +```typescript +{ command: "help", description: "查看可用命令列表" }, +{ command: "ai_on", description: "开启当前会话的 AI 草稿" }, +{ command: "ai_off", description: "关闭当前会话的 AI 草稿" }, +{ command: "reset", description: "清空会话消息和草稿,需 /reset confirm" }, +``` + +`privateBotCommands` 新增: + +```typescript +{ command: "help", description: "查看使用帮助" }, +``` + +### 6. Closed 会话重开通知 + +`messages.ts` 的 `handlePrivateMessage` 中,在 `setConversationStatus` 之后: + +```typescript +if (bundle.conversation.status === "closed") { + await deps.conversations.setConversationStatus(bundle.conversation.id, "open"); + const topic = await ensureTelegramTopic({ ... }); + try { + await ctx.api.sendMessage( + deps.config.TELEGRAM_MANAGEMENT_CHAT_ID, + "会话已自动重开(用户发送了新消息)。", + { message_thread_id: topic.messageThreadId }, + ); + } catch { + // 通知失败不影响主流程 + } +} +``` + +注意:当前代码在 `setConversationStatus` 之后才 `ensureTelegramTopic`,需要调整顺序——先 ensure topic 再发通知。实际上 topic 已在后续代码中 ensure,只需将通知逻辑移到 topic ensure 之后。 + +### 7. 消息长度截断 + +新增工具函数: + +```typescript +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) + "..."; +} +``` + +在 `handleManagementMessage` 的出站投递前调用: +```typescript +const truncatedText = text ? truncateText(text) : text; +// 使用 truncatedText 作为 fallbackText 和 text +``` + +在 `ai-drafts.ts` 的草稿生成后调用: +```typescript +const text = truncateText(rawText); +``` + +### 8. AI 上下文长度限制 + +`ai-drafts.ts` 的上下文拼接: + +```typescript +const MAX_CONTEXT_LENGTH = 12000; + +function buildContext(messages: Message[]): string { + let context = ""; + for (let i = messages.length - 1; i >= 0; i--) { + const line = `${messages[i].direction}: ${messages[i].text ?? `[${messages[i].messageType}]`}`; + if (context.length + line.length + 1 > MAX_CONTEXT_LENGTH) break; + context = line + "\n" + context; + } + return context; +} +``` + +### 9. 帮助文本更新 + +`commands.ts` 的 `topicHelpText` 新增: + +``` +/reset confirm - 清空会话消息和草稿,保留联系人映射和 Topic +``` + +## Data Models + +无新增数据库表或列。`/reset` 通过 DELETE 操作清理现有表数据。 + +## Correctness Properties + +1. `/reset confirm` 必须删除 4 张表的该会话数据,但保留 conversation、contact、telegram_topics 记录。 +2. `/assign` 的正则校验必须只接受纯数字。 +3. closed 重开通知必须在 topic ensure 之后发送,确保 message_thread_id 可用。 +4. 消息截断必须在投递前执行,截断后的长度(含"...")不超过 4000 字符。 +5. AI 上下文构建从最新消息向前拼接,超限时丢弃最旧消息。 + +## Error Handling + +| 场景 | 处理 | +|------|------| +| `/reset` 时 DB DELETE 失败 | 抛异常,grammy catch 捕获,管理员看到 bot.catch 的默认错误 | +| 重开通知 sendMessage 失败 | catch 吞错,不影响消息投递主流程 | +| 消息截断时 text 为 null | 不截断(null 表示无文本,如纯媒体消息) | + +## Test Strategy + +1. **resetConversation 清空 4 表**:插入消息+草稿+备注+标签,调用 resetConversation,断言 4 表为空但 conversation 存在。 +2. **/assign 非数字拒绝**:发送 `/assign abc`,断言回复用法提示。 +3. **/assign 数字成功**:发送 `/assign 123`,断言 assigned_admin_id 更新。 +4. **未知命令提示 /help**:发送 `/foobar`,断言回复包含"/help"。 +5. **truncateText 不超 4000**:传入 5000 字符,断言输出长度为 4000 且以"..."结尾。 +6. **truncateText 短文本不变**:传入 100 字符,断言原样返回。 +7. **buildContext 长度限制**:传入总长 15000 的消息数组,断言拼接结果不超过 12000。 +8. **菜单包含新命令**:检查 adminBotCommands 包含 help/ai_on/ai_off/reset。 + +## References + +[^1]: (src/channels/telegram/commands.ts#L251) - /assign 当前实现 +[^2]: (src/channels/telegram/commands.ts#L279) - /close 当前实现 +[^3]: (src/channels/telegram/messages.ts#L149) - closed 重开逻辑 +[^4]: (src/channels/telegram/messages.ts#L309) - 未知命令处理 +[^5]: (src/channels/telegram/menu.ts#L32) - adminBotCommands 数组 +[^6]: (src/domain/ai-drafts.ts#L40) - AI 上下文拼接 +[^7]: (src/domain/conversations.ts#L275) - deleteConversationData 参考 diff --git a/.monkeycode/specs/command-message-refinement/requirements.md b/.monkeycode/specs/command-message-refinement/requirements.md new file mode 100644 index 0000000..f15fa89 --- /dev/null +++ b/.monkeycode/specs/command-message-refinement/requirements.md @@ -0,0 +1,87 @@ +# Requirements Document + +## Introduction + +InboxBridge 的命令系统和消息流存在多处体验和安全缺口:(1) 缺少 `/reset` 命令(重置会话但保留联系人映射);(2) `/assign` 不校验参数是否为数字,会写入非法值;(3) 未知命令只回复"未知命令"而不提示 `/help`;(4) `/help`、`/ai_on`、`/ai_off` 未注册到 Telegram 命令菜单,管理员只能手敲;(5) closed 会话被用户发消息自动重开时,管理员无通知;(6) 超长消息(>4096 字符)无长度校验,AI 草稿或拼接上下文可能超限导致 sendMessage 失败。 + +本功能补齐这些缺口,提升命令系统的完整性和消息流的健壮性。 + +## Glossary + +- **Command menu**: Telegram bot 的命令补全菜单,通过 `setMyCommands` 注册。 +- **Session reset**: 清空会话的消息历史但保留联系人映射和 Topic,与 `/delete`(删除一切)和 `/close`(仅关闭状态)区分。 +- **Closed conversation reopen**: closed 状态的会话收到外部用户消息时自动改为 open 的行为。 +- **Message length limit**: Telegram sendMessage 的文本上限 4096 字符。 + +## Requirements + +### Requirement 1: /reset 命令 + +**User Story:** AS 管理员, I want 用 /reset 重置会话消息历史但保留联系人映射, so that 能清理上下文而不丢失 Topic 和联系人关系 + +#### Acceptance Criteria + +1. WHEN 管理员在 Topic 内发送 `/reset confirm`, the System SHALL 删除该会话的所有 messages、ai_drafts、admin_notes、message_tags 记录,保留 conversation、contact 和 telegram_topics 记录 +2. WHEN 管理员发送 `/reset`(无 confirm 参数), the System SHALL 回复"危险操作:将清空当前会话的所有消息、草稿和备注。确认请发送 /reset confirm" +3. WHEN reset 完成, the System SHALL 回复"会话已重置。联系人映射和 Topic 已保留。" + +### Requirement 2: /assign 参数校验 + +**User Story:** AS 管理员, I want /assign 拒绝非数字参数, so that assigned_admin_id 字段不会写入非法值 + +#### Acceptance Criteria + +1. WHEN 管理员发送 `/assign abc`(非数字), the System SHALL 回复"用法:/assign ,ID 必须为数字。" +2. WHEN 管理员发送 `/assign 123456789`, the System SHALL 写入 assigned_admin_id 并回复"已分配给 123456789。" +3. WHEN 管理员发送 `/assign`(无参数), the System SHALL 回复用法提示 + +### Requirement 3: 未知命令提示 + +**User Story:** AS 管理员, I want 未知命令提示包含 /help 引导, so that 能快速发现可用命令 + +#### Acceptance Criteria + +1. WHEN 管理员在 Topic 内发送以 `/` 开头但未识别的命令, the System SHALL 回复"未知命令。发送 /help 查看可用命令列表。" + +### Requirement 4: 命令菜单补齐 + +**User Story:** AS 管理员, I want /help /ai_on /ai_off 出现在命令菜单, so that 不需要手敲命令名 + +#### Acceptance Criteria + +1. WHEN bot 启动并注册菜单, the System SHALL 将 `help`、`ai_on`、`ai_off` 加入 adminBotCommands 数组 +2. WHILE 注册菜单, the description SHALL 分别为"查看可用命令"、"开启当前会话的 AI 草稿"、"关闭当前会话的 AI 草稿" +3. WHEN privateBotCommands 注册, the System SHALL 加入 `help` 命令(描述"查看使用帮助"),不加 `ai_on`/`ai_off`(外部用户无权操作) + +### Requirement 5: Closed 会话重开通知 + +**User Story:** AS 管理员, I want closed 会话被用户消息重开时收到通知, so that 不会误以为会话仍处于关闭状态 + +#### Acceptance Criteria + +1. WHEN 外部用户在 closed 会话中发送消息, the System SHALL 将会话状态改为 open +2. WHEN 会话从 closed 重开, the System SHALL 在该会话的 Topic 内发送一条 internal 消息"会话已自动重开(用户发送了新消息)。" +3. WHILE 发送重开通知, the System SHALL 使用 `message_thread_id` 确保通知发送到正确的 Topic + +### Requirement 6: 消息长度校验 + +**User Story:** AS 运维人员, I want 超长消息被截断或拒绝, so that 不会因 Telegram 4096 字符限制导致投递失败 + +#### Acceptance Criteria + +1. WHEN 出站消息文本超过 4000 字符, the System SHALL 截断为 3997 字符并追加"..."(总计 4000 字符) +2. WHEN AI 草稿生成结果超过 4000 字符, the System SHALL 截断草稿文本为 3997 字符并追加"..." +3. WHILE 拼接 AI 上下文, the System SHALL 限制上下文总长度为 12000 字符(从最近的 messages 中截取,超限时丢弃最旧的消息) + +## Non-Functional Requirements + +### Requirement 7: 帮助文本同步 + +1. WHILE 更新命令列表, the topicHelpText 函数和 menu.ts 的命令数组 SHALL 保持一致,新增 /reset 命令的帮助文本 + +## References + +- 命令实现:`src/channels/telegram/commands.ts` +- 菜单注册:`src/channels/telegram/menu.ts` +- 消息流:`src/channels/telegram/messages.ts` +- 更新处理:`src/channels/telegram/updates.ts` diff --git a/.monkeycode/specs/operations-dashboard/design.md b/.monkeycode/specs/operations-dashboard/design.md new file mode 100644 index 0000000..7666f40 --- /dev/null +++ b/.monkeycode/specs/operations-dashboard/design.md @@ -0,0 +1,270 @@ +# Operations Dashboard + +Feature Name: operations-dashboard +Updated: 2026-06-29 + +## Description + +在 Web 控制台新增运维视图区域,采用独立仪表盘风格(更宽向的卡片、数据表格、筛选器、图表元素)。包含三个页面:统计概览(`/operations/overview`)、会话列表(`/operations/conversations`)、失败投递队列(`/operations/deliveries`)。运维页面与配置页面共享认证和布局壳,但使用独立的仪表盘 CSS 命名空间。失败投递支持手动重置重试时间(permanent_failure 不可重试)。 + +## Architecture + +```mermaid +graph TD + A["GET /operations"] --> B{子路径} + B -->|/overview 或空| C["renderOverview"] + B -->|/conversations| D["renderConversations"] + B -->|/deliveries| E["renderDeliveries"] + C --> F["ConversationService.stats + DeliveryService.stats + AiDraftService.stats"] + D --> G["ConversationService.listConversations"] + E --> H["DeliveryService.listFailedDeliveries"] + I["POST /operations/deliveries/retry"] --> J["DeliveryService.scheduleRetry"] + J --> K["重置 next_retry_at"] +``` + +## Components and Interfaces + +### 1. 路由层扩展(web-console.ts) + +在 session 认证之后新增运维路由组: + +```typescript +if (url.pathname === "/operations" || url.pathname === "/operations/overview") { + const data = options.collectOperationsOverview(); + renderOperationsOverview(res, data, url.searchParams.get("action") === "retryed"); + return; +} + +if (url.pathname === "/operations/conversations") { + const page = Math.max(1, Number(url.searchParams.get("page") ?? "1")); + const status = url.searchParams.get("status") ?? undefined; + const data = options.listConversations({ page, status, pageSize: 50 }); + renderConversations(res, data, page, status); + return; +} + +if (url.pathname === "/operations/deliveries") { + const page = Math.max(1, Number(url.searchParams.get("page") ?? "1")); + const data = options.listFailedDeliveries({ page, pageSize: 50 }); + renderDeliveries(res, data, page); + return; +} + +if (url.pathname === "/operations/deliveries/retry" && req.method === "POST") { + const form = await readForm(req); + const deliveryId = Number(form.get("delivery_id")); + await options.scheduleRetry(deliveryId); + redirect(res, "/operations/deliveries?action=retryed"); + return; +} +``` + +### 2. WebConsoleOptions 扩展 + +```typescript +interface WebConsoleOptions { + // 现有字段... + collectOperationsOverview: () => OperationsOverview; + listConversations: (opts: { page: number; status?: string; pageSize: number }) => ConversationListResult; + listFailedDeliveries: (opts: { page: number; pageSize: number }) => DeliveryListResult; + scheduleRetry: (deliveryId: number) => Promise; +} +``` + +### 3. Domain Service 新增方法 + +**ConversationService.listConversations**: +```typescript +async listConversations(opts: { + status?: "open" | "closed"; + limit: number; + offset: number; +}): Promise<{ items: ConversationListItem[]; total: number }> { + const where = opts.status ? "WHERE c.status = ?" : ""; + const params = opts.status ? [opts.status] : []; + const rows = this.db + .prepare( + `SELECT c.*, ct.display_name, ct.username, t.topic_name, t.message_thread_id + FROM conversations c + LEFT JOIN contacts ct ON c.contact_id = ct.id + LEFT JOIN telegram_topics t ON t.conversation_id = c.id + ${where} + ORDER BY c.last_message_at DESC NULLS LAST + LIMIT ? OFFSET ?`, + ) + .all(...params, opts.limit, opts.offset) as Record[]; + const countRow = this.db + .prepare(`SELECT COUNT(*) AS cnt FROM conversations c ${where}`) + .get(...params) as { cnt: number }; + return { items: rows.map(conversationListItemFromRow), total: countRow.cnt }; +} +``` + +**DeliveryService.listFailedDeliveries**: +```typescript +async listFailedDeliveries(opts: { + limit: number; + offset: number; +}): Promise<{ items: Delivery[]; total: number }> { + const rows = this.db + .prepare( + `SELECT * FROM deliveries + WHERE status IN ('failed', 'permanent_failure') + ORDER BY created_at ASC + LIMIT ? OFFSET ?`, + ) + .all(opts.limit, opts.offset) as Record[]; + const countRow = this.db + .prepare(`SELECT COUNT(*) AS cnt FROM deliveries WHERE status IN ('failed', 'permanent_failure')`) + .get() as { cnt: number }; + return { items: rows.map(deliveryFromRow), total: countRow.cnt }; +} +``` + +**DeliveryService.scheduleRetry**: +```typescript +async scheduleRetry(deliveryId: number): Promise { + this.db + .prepare( + `UPDATE deliveries + SET next_retry_at = ?, updated_at = ? + WHERE id = ? AND status = 'failed'`, + ) + .run(nowIso(), nowIso(), deliveryId); +} +``` + +注意 `WHERE status = 'failed'` 确保 permanent_failure 不可重试。 + +### 4. 仪表盘 CSS 命名空间 + +运维页面使用 `ops-` 前缀的 CSS 类名,与配置页的样式隔离。在 `page()` 函数中根据页面类型注入不同的 CSS: + +```typescript +function opsPage(title: string, body: string, activeNav: "overview" | "conversations" | "deliveries"): string { + return ` + ...... + +
+ 配置 + 概览 + 会话 + 投递 +
+
${body}
+ `; +} +``` + +### 5. 概览页渲染 + +```typescript +function renderOperationsOverview(res, data: OperationsOverview, showRetryBanner: boolean): void { + const cards = [ + statCard("消息总量", [ + metric("入站", data.messages.inboundTotal), + metric("出站", data.messages.outboundTotal), + ]), + statCard("投递状态", [ + metric("pending", data.deliveries.pending, "warn"), + metric("sent", data.deliveries.sent, "ok"), + metric("failed", data.deliveries.failed, data.deliveries.failed > 0 ? "danger" : "neutral"), + metric("permanent_failure", data.deliveries.permanentFailure, data.deliveries.permanentFailure > 0 ? "danger" : "neutral"), + ]), + statCard("会话", [ + metric("open", data.conversations.open, "ok"), + metric("closed", data.conversations.closed), + ]), + statCard("AI 草稿", [ + metric("pending", data.aiDrafts.pending), + metric("ready", data.aiDrafts.ready, "ok"), + metric("failed", data.aiDrafts.failed), + metric("sent", data.aiDrafts.sent), + metric("discarded", data.aiDrafts.discarded), + ]), + ]; + // ... HTML 渲染 +} +``` + +### 6. 会话列表渲染 + +表格形式,列:Topic 名称、联系人、状态(badge)、优先级(badge)、负责人、最后消息时间、创建时间。底部分页导航。 + +### 7. 投递队列渲染 + +表格形式,列:ID、目标、状态(badge)、尝试次数(x/8)、最后错误(截断)、创建时间、下次重试时间、操作(重试按钮,仅 failed 显示)。 + +重试按钮为内联 form: +```html +
+ + +
+``` + +## Data Models + +### OperationsOverview + +```typescript +interface OperationsOverview { + messages: { inboundTotal: number; outboundTotal: number; internalTotal: number }; + deliveries: { pending: number; sent: number; failed: number; permanentFailure: number }; + conversations: { open: number; closed: number }; + aiDrafts: { pending: number; ready: number; failed: number; sent: number; discarded: number }; + uptimeSeconds: number; +} +``` + +### ConversationListItem + +```typescript +interface ConversationListItem { + id: number; + status: "open" | "closed"; + priority: "low" | "normal" | "high" | "urgent"; + assignedAdminId: string | null; + createdAt: string; + lastMessageAt: string | null; + contactDisplayName: string | null; + contactUsername: string | null; + topicName: string | null; + messageThreadId: number | null; +} +``` + +## Correctness Properties + +1. `scheduleRetry` 必须包含 `WHERE status = 'failed'` 条件,permanent_failure 不可重试。 +2. 分页参数必须做下界校验(page >= 1),避免负 OFFSET。 +3. 会话列表必须 LEFT JOIN contacts 和 telegram_topics,确保即使关联数据缺失也能显示会话。 +4. 运维页面必须在 session 认证之后,未认证用户重定向到 /login。 +5. `listConversations` 的排序必须使用 `last_message_at DESC NULLS LAST`,确保无消息的会话排在最后。 + +## Error Handling + +| 场景 | 处理 | +|------|------| +| collectOperationsOverview 查询失败 | 抛异常,web-console 的 try/catch 返回 500 | +| listConversations 分页参数非法 | page 强制 >= 1,offset = (page-1)*pageSize | +| scheduleRetry 的 deliveryId 不存在 | UPDATE 影响 0 行,不报错,重定向后列表不变 | +| scheduleRetry 的 delivery 已是 permanent_failure | WHERE 条件过滤,0 行更新,不报错 | + +## Test Strategy + +1. **未认证访问 /operations 重定向 /login**:不带 cookie 请求,断言 302。 +2. **概览页返回统计卡片**:带 cookie 请求,断言 HTML 包含 "消息总量"、"投递状态" 等。 +3. **会话列表分页**:插入 55 条会话,请求 page=2,断言返回 5 条。 +4. **会话列表状态筛选**:插入 open 和 closed 会话,请求 status=closed,断言只返回 closed。 +5. **投递队列只返回 failed 和 permanent_failure**:插入各状态投递,断言列表只有 failed 和 permanent_failure。 +6. **scheduleRetry 重置 next_retry_at**:插入 failed 投递,调用 scheduleRetry,断言 next_retry_at 更新。 +7. **scheduleRetry 对 permanent_failure 无效**:插入 permanent_failure 投递,调用 scheduleRetry,断言 next_retry_at 不变。 + +## References + +[^1]: (src/runtime/web-console.ts#L194) - startWebConsole 路由入口 +[^2]: (src/runtime/web-console.ts#L303) - renderDashboard 当前实现 +[^3]: (src/domain/conversations.ts#L110) - ConversationService 类 +[^4]: (src/domain/deliveries.ts#L21) - DeliveryService 类 +[^5]: (src/runtime/main.ts#L182) - startWebConsole 调用处 diff --git a/.monkeycode/specs/operations-dashboard/requirements.md b/.monkeycode/specs/operations-dashboard/requirements.md new file mode 100644 index 0000000..b9d66e9 --- /dev/null +++ b/.monkeycode/specs/operations-dashboard/requirements.md @@ -0,0 +1,90 @@ +# Requirements Document + +## Introduction + +InboxBridge 的 Web 控制台当前只有配置编辑功能,缺少运维视图。运维人员必须进 Telegram 翻 Topic 才能看会话状态,失败投递没有可视化入口只能 SQL 查库,没有消息量趋势无法判断 bot 是否正常工作。本功能在 Web 控制台新增三个运维页面:会话列表、失败投递队列、消息统计概览,使运维人员能在浏览器内完成日常监控和干预。 + +## Glossary + +- **Operations dashboard**: Web 控制台中的运维视图区域,包含会话列表、投递队列、统计概览。 +- **Conversation list**: 分页展示所有会话的列表,支持按状态筛选。 +- **Delivery queue**: 失败投递(status=failed 或 permanent_failure)的列表视图。 +- **Stats overview**: 消息计数、投递状态分布、会话活跃度的聚合卡片。 +- **Manual retry**: 运维人员在 Web 端手动触发某条失败投递的重试。 + +## Requirements + +### Requirement 1: 运维导航入口 + +**User Story:** AS 运维人员, I want 在控制台顶部有运维视图入口, so that 能快速切换到会话列表和投递监控 + +#### Acceptance Criteria + +1. WHEN 已登录用户访问控制台首页, the Web Console SHALL 在顶部导航栏显示"配置"和"运维"两个入口 +2. WHEN 用户点击"运维"入口, the Web Console SHALL 跳转到 `/operations` 页面 +3. WHILE 显示运维页面, the Web Console SHALL 在侧边栏显示"概览"、"会话"、"投递"三个子导航 + +### Requirement 2: 统计概览页 + +**User Story:** AS 运维人员, I want 在概览页看到关键指标卡片, so that 一眼判断系统是否正常 + +#### Acceptance Criteria + +1. WHEN 用户访问 `/operations` 或 `/operations/overview`, the Web Console SHALL 显示统计概览页 +2. WHILE 显示概览页, the Web Console SHALL 展示以下卡片:消息总量(入站/出站)、投递状态分布(pending/sent/failed/permanent_failure)、会话状态分布(open/closed)、AI 草稿状态分布(pending/ready/failed/sent/discarded)、进程运行时长 +3. WHEN 统计数据查询失败, the Web Console SHALL 在对应卡片位置显示"数据不可用"而非空白 +4. WHILE 展示投递状态, the Web Console SHALL 对 failed 和 permanent_failure 数量用警示色高亮(非零时) + +### Requirement 3: 会话列表页 + +**User Story:** AS 运维人员, I want 在 Web 端查看所有会话列表, so that 不用进 Telegram 翻 Topic + +#### Acceptance Criteria + +1. WHEN 用户访问 `/operations/conversations`, the Web Console SHALL 显示会话列表页 +2. WHILE 显示会话列表, the Web Console SHALL 展示每条会话的:Topic 名称、联系人显示名、状态(open/closed)、优先级、负责人、最后消息时间、创建时间 +3. WHEN 会话数量超过 50 条, the Web Console SHALL 分页展示,每页 50 条,底部显示分页导航 +4. WHEN 用户点击状态筛选器(全部/open/closed), the Web Console SHALL 按筛选条件重新查询并展示 +5. WHILE 排序会话列表, the Web Console SHALL 默认按最后消息时间倒序排列(最新的在前) + +### Requirement 4: 失败投递队列页 + +**User Story:** AS 运维人员, I want 在 Web 端查看失败投递队列, so that 能发现积压的未投递消息 + +#### Acceptance Criteria + +1. WHEN 用户访问 `/operations/deliveries`, the Web Console SHALL 显示失败投递队列页 +2. WHILE 显示投递队列, the Web Console SHALL 展示每条失败投递的:ID、目标、状态(failed/permanent_failure)、尝试次数、最后错误、创建时间、下次重试时间 +3. WHEN 投递状态为 failed, the Web Console SHALL 在该行显示"重试"按钮 +4. WHEN 用户点击"重试"按钮, the Web Console SHALL 将该投递的 next_retry_at 重置为当前时间,触发 worker 在下次扫描时立即重试 +5. WHEN 手动重试触发后, the Web Console SHALL 回复确认消息并刷新列表 +6. WHILE 排序投递队列, the Web Console SHALL 默认按创建时间正序排列(最早的在前) + +### Requirement 5: 运维数据查询方法 + +**User Story:** AS 开发者, I want Domain Service 提供分页查询方法, so that Web 控制台能高效获取运维数据 + +#### Acceptance Criteria + +1. WHEN Web 控制台请求会话列表, the ConversationService SHALL 提供 `listConversations(options: { status?: string; limit: number; offset: number })` 方法,返回会话列表和总数 +2. WHEN Web 控制台请求失败投递列表, the DeliveryService SHALL 提供 `listFailedDeliveries(options: { limit: number; offset: number })` 方法,返回失败投递列表和总数 +3. WHEN Web 控制台请求手动重试, the DeliveryService SHALL 提供 `scheduleRetry(deliveryId: number)` 方法,将 next_retry_at 重置为当前时间 +4. WHILE 执行分页查询, the Domain Service SHALL 使用 LIMIT 和 OFFSET,避免全表加载 + +## Data Models + +无新增数据库表。运维视图完全基于现有 `conversations`、`deliveries`、`messages`、`ai_drafts` 表的查询。 + +## Non-Functional Requirements + +### Requirement 6: 查询性能 + +1. WHILE 会话列表查询, the Domain Service SHALL 使用 `conversations` 表的 `last_message_at` 索引排序 +2. WHILE 失败投递查询, the Domain Service SHALL 使用 `deliveries` 表的 `status` 索引筛选 +3. WHEN 运维页面加载, the Web Console SHALL 在 2 秒内返回完整 HTML(含数据查询) + +## References + +- 现有 Web 控制台:`src/runtime/web-console.ts` +- 现有 Domain Service:`src/domain/conversations.ts`、`src/domain/deliveries.ts` +- 稳定性地基的 metrics 方法:本批次 stability-foundation 功能 diff --git a/.monkeycode/specs/stability-foundation/design.md b/.monkeycode/specs/stability-foundation/design.md new file mode 100644 index 0000000..d9b2021 --- /dev/null +++ b/.monkeycode/specs/stability-foundation/design.md @@ -0,0 +1,322 @@ +# Stability Foundation + +Feature Name: stability-foundation +Updated: 2026-06-29 + +## Description + +补齐 InboxBridge 的三个稳定性基础设施:(1) 全局未捕获异常和 Promise rejection 兜底;(2) 将 13 处 `console.error`/`console.log` 热路径调用统一为 pino 结构化日志;(3) 在 Web 控制台新增 `/healthz` 和 `/metrics` HTTP 端点。同时为 SIGINT/SIGTERM 优雅关闭增加 10 秒超时保护。 + +## Architecture + +```mermaid +graph TD + A["main.ts 启动"] --> B["注册 process 全局 handler"] + A --> C["创建 root logger pino 实例"] + C --> D["logger 注入 TelegramMessageDeps"] + C --> E["logger 注入 sweepExpiredConversations"] + C --> F["logger 注入 bot.catch"] + A --> G["startWebConsole 携带 healthCheck 和 metrics 回调"] + G --> H["GET /healthz 公开端点"] + G --> I["GET /metrics 认证端点"] + A --> J["SIGINT/SIGTERM 加 10s 超时"] + B --> K["unhandledRejection 去重记录"] + B --> L["uncaughtException 受控退出"] +``` + +全局 handler 和 logger 在 `main.ts` 顶部注册,logger 通过现有的 deps 注入链路传递给消息处理、定时器和 grammy catch。健康检查和 metrics 端点复用 `startWebConsole` 的 server 实例,在路由层新增两个 path。 + +## Components and Interfaces + +### 1. 全局异常 handler(main.ts) + +在 `main.ts` 模块顶层、`startWebConsole` 调用之前注册: + +```typescript +const rejectionWindow = new Map(); +const REJECTION_DEDUP_WINDOW_MS = 60_000; +const REJECTION_DEDUP_THRESHOLD = 5; + +process.on("unhandledRejection", (reason) => { + const key = reason instanceof Error ? reason.message : String(reason); + const now = Date.now(); + const entry = rejectionWindow.get(key); + if (entry) { + entry.count += 1; + if (entry.count > REJECTION_DEDUP_THRESHOLD && now - entry.firstAt < REJECTION_DEDUP_WINDOW_MS) { + return; + } + } else { + rejectionWindow.set(key, { count: 1, firstAt: now }); + } + if (entry && entry.count === REJECTION_DEDUP_THRESHOLD) { + logger.warn({ key, count: entry.count }, "Unhandled rejection repeated; suppressing further logs for 60s."); + } else { + logger.error({ reason }, "Unhandled promise rejection."); + } + cleanupRejectionWindow(now); +}); +``` + +`uncaughtException` handler: +```typescript +let shuttingDown = false; +process.on("uncaughtException", (error) => { + logger.fatal({ error }, "Uncaught exception; initiating controlled shutdown."); + if (shuttingDown) return; + shuttingDown = true; + void stopRuntime() + .catch(() => {}) + .finally(() => { + handle.client.close(); + process.exit(1); + }); + setTimeout(() => { + logger.warn("Graceful shutdown timed out after 10s; forcing exit."); + handle.client.close(); + process.exit(1); + }, 10_000).unref(); +}); +``` + +### 2. Logger 依赖注入 + +**TelegramMessageDeps** 新增 `logger: Logger` 字段: + +```typescript +export interface TelegramMessageDeps { + config: AppConfig; + conversations: ConversationService; + deliveries: DeliveryService; + permissions: PermissionService; + rateLimit: RateLimitService; + aiDrafts: AiDraftService; + logger: Logger; +} +``` + +`bot.ts` 的 `createTelegramBot` 签名新增 `logger` 参数,从 `main.ts` 传入根 logger 的 child: + +```typescript +export function createTelegramBot(config: AppConfig, db: Database, logger: Logger): Bot { + const deps = { + // ... + logger: logger.child({ module: "telegram.messages" }), + }; + registerTelegramUpdates(bot, deps); + return bot; +} +``` + +`registerTelegramUpdates` 内部的 `bot.catch` 改用 deps.logger: + +```typescript +bot.catch((error) => { + deps.logger.error( + { updateId: error.ctx.update.update_id, err: error.error }, + "Telegram update processing failed.", + ); +}); +``` + +### 3. sweepExpiredConversations 日志注入 + +`sweepExpiredConversations` 的 input 接口新增 `logger: Logger`: + +```typescript +export async function sweepExpiredConversations(input: { + api: Api; + db: Database; + messageRetentionDays: number; + defaultConversationRetentionDays: number | null; + logger: Logger; +}): Promise { +``` + +`main.ts` 调用处传入 `logger.child({ module: "conversation-expiry" })`。 + +### 4. /healthz 端点 + +在 `web-console.ts` 的路由层,**在 session 认证检查之前**(公开端点)新增: + +```typescript +if (url.pathname === "/healthz" && req.method === "GET") { + const dbReachable = checkDbReachable(); + const botRunning = options.getStatus().bot === "running"; + const healthy = dbReachable && botRunning; + res.statusCode = healthy ? 200 : 503; + send(res, healthy ? 200 : 503, "application/json", JSON.stringify({ + status: healthy ? "ok" : "degraded", + bot: botRunning ? "running" : "stopped", + db: dbReachable ? "reachable" : "unreachable", + uptime_seconds: Math.round(process.uptime()), + timestamp: new Date().toISOString(), + })); + return; +} +``` + +`checkDbReachable` 通过 `options.dbHealthCheck()` 回调执行 `SELECT 1`。`WebConsoleOptions` 新增 `dbHealthCheck: () => boolean` 字段。 + +### 5. /metrics 端点 + +在 session 认证检查**之后**(需认证)新增: + +```typescript +if (url.pathname === "/metrics" && req.method === "GET") { + try { + const metrics = options.collectMetrics(); + send(res, 200, "application/json", JSON.stringify(metrics)); + } catch (error) { + send(res, 500, "application/json", JSON.stringify({ error: "metrics query failed" })); + } + return; +} +``` + +`WebConsoleOptions` 新增 `collectMetrics: () => MetricsSnapshot` 回调。`MetricsSnapshot` 类型: + +```typescript +interface MetricsSnapshot { + messages: { + inbound_total: number; + outbound_total: number; + internal_total: number; + pending_deliveries: number; + failed_deliveries: number; + permanent_failure_deliveries: number; + }; + conversations: { + active_total: number; + closed_total: number; + }; + ai_drafts: { + pending_total: number; + ready_total: number; + failed_total: number; + }; + uptime_seconds: number; + timestamp: string; +} +``` + +### 6. Domain Service stats 方法 + +**DeliveryService** 新增: +```typescript +async stats(): Promise<{ + pending: number; sent: number; failed: number; permanentFailure: number; +}> { + const rows = this.db + .prepare("SELECT status, COUNT(*) AS cnt FROM deliveries GROUP BY status") + .all() as Array<{ status: string; cnt: number }>; + // 聚合为对象 +} +``` + +**ConversationService** 新增: +```typescript +async stats(): Promise<{ open: number; closed: number }> { + const rows = this.db + .prepare("SELECT status, COUNT(*) AS cnt FROM conversations GROUP BY status") + .all() as Array<{ status: string; cnt: number }>; +} +``` + +**AiDraftService** 新增: +```typescript +async stats(): Promise<{ pending: number; ready: number; failed: number }> { + const rows = this.db + .prepare("SELECT status, COUNT(*) AS cnt FROM ai_drafts GROUP BY status") + .all() as Array<{ status: string; cnt: number }>; +} +``` + +**MessageService**(新建或在 ConversationService 内)新增按 direction 聚合的 COUNT 查询。 + +### 7. 优雅关闭超时 + +`SIGINT`/`SIGTERM` handler 改为: + +```typescript +function gracefulShutdown(signal: string): void { + logger.info({ signal }, "Shutting down InboxBridge."); + let exited = false; + const forceExit = () => { + if (exited) return; + exited = true; + logger.warn("Graceful shutdown timed out after 10s; forcing exit."); + handle.client.close(); + process.exit(1); + }; + const timer = setTimeout(forceExit, 10_000); + timer.unref(); + stopRuntime() + .catch((error) => { + logger.error({ error }, "Error during graceful shutdown."); + }) + .finally(() => { + if (exited) return; + exited = true; + clearTimeout(timer); + handle.client.close(); + process.exit(0); + }); +} +process.once("SIGINT", () => gracefulShutdown("SIGINT")); +process.once("SIGTERM", () => gracefulShutdown("SIGTERM")); +``` + +## Data Models + +本功能不新增数据库表或列。所有 stats 查询基于现有表的现有列。 + +`metrics` 端点的 `active_total` 对应 `conversations.status = 'open'`,`closed_total` 对应 `conversations.status = 'closed'`。`expired_total` 不单独统计(过期会话被删除后行不存在)。 + +## Correctness Properties + +1. `/healthz` 必须在 session 认证之前匹配,确保容器编排无需凭据即可探测。 +2. `/metrics` 必须在 session 认证之后匹配,防止数据泄漏。 +3. `unhandledRejection` handler 永不调用 `process.exit`,避免因单个异步错误杀死进程。 +4. `uncaughtException` handler 使用 `shuttingDown` flag 保证 `stopRuntime` 只执行一次。 +5. 优雅关闭超时使用 `.unref()`,确保 timer 不阻止进程在 `stopRuntime` 完成后退出。 +6. 去重窗口的 Map 在每次 `unhandledRejection` 时惰性清理过期 entry,避免内存泄漏。 + +## Error Handling + +| 场景 | 处理 | +|------|------| +| `/healthz` 的 `SELECT 1` 抛异常 | 返回 `db: "unreachable"`, HTTP 503 | +| `/metrics` 的 stats 查询抛异常 | 返回 HTTP 500 + `{"error":"metrics query failed"}` | +| `stopRuntime` 在 uncaughtException handler 中失败 | `.catch()` 吞错,仍然 `process.exit(1)` | +| `bot.stop()` 在 SIGTERM 中 hang | 10 秒超时强制退出 | + +## Test Strategy + +### 单元测试(test/core.test.ts 扩展) + +1. **/healthz 无需认证返回 200**:启动 web console,不携带 cookie 请求 `/healthz`,断言 HTTP 200 和 JSON 字段存在。 +2. **/healthz bot stopped 时返回 503**:`getStatus` 返回 `bot: "stopped"`,断言 HTTP 503。 +3. **/metrics 未认证重定向 /login**:不带 cookie 请求 `/metrics`,断言 302 → `/login`。 +4. **/metrics 认证后返回指标**:带 session cookie 请求 `/metrics`,断言 JSON 包含 `messages`、`conversations`、`ai_drafts` 字段。 +5. **DeliveryService.stats 聚合正确**:插入 3 条 pending + 2 条 sent + 1 条 failed,断言 stats 返回正确计数。 +6. **unhandledRejection 去重**:模拟同一 message 触发 6 次,断言第 6 次被抑制。 + +### 验证命令 + +```bash +npm run verify +``` + +## References + +[^1]: (src/runtime/main.ts#L168) - SIGINT/SIGTERM handler 当前实现 +[^2]: (src/runtime/main.ts#L21) - pino logger 实例 +[^3]: (src/runtime/web-console.ts#L194) - startWebConsole 路由入口 +[^4]: (src/channels/telegram/bot.ts#L14) - createTelegramBot 签名 +[^5]: (src/channels/telegram/messages.ts#L12) - TelegramMessageDeps 接口 +[^6]: (src/channels/telegram/updates.ts#L40) - bot.catch 当前实现 +[^7]: (src/domain/conversation-expiry.ts#L5) - sweepExpiredConversations 签名 +[^8]: (src/domain/deliveries.ts#L21) - DeliveryService 类 +[^9]: (src/domain/ai-drafts.ts#L12) - AiDraftService 类 +[^10]: (src/domain/conversations.ts#L110) - ConversationService 类 diff --git a/.monkeycode/specs/stability-foundation/requirements.md b/.monkeycode/specs/stability-foundation/requirements.md new file mode 100644 index 0000000..5f1ea62 --- /dev/null +++ b/.monkeycode/specs/stability-foundation/requirements.md @@ -0,0 +1,85 @@ +# Requirements Document + +## Introduction + +InboxBridge 当前缺少三个稳定性基础设施:(1) 未捕获的 Promise rejection 和未捕获异常没有兜底,进程可能静默吞错或崩溃;(2) 关键热路径(消息投递失败、过期 Topic 删除、grammy update 错误)使用 `console.error` 绕过 pino,丢失结构化字段、日志级别和 JSON 输出能力;(3) Web 控制台没有健康检查和 metrics 端点,容器编排无法探测进程存活,运维无法监控积压的失败投递、活跃会话数等关键指标。 + +本功能补齐这三个基础设施层,为后续所有功能提供稳定的可观测性基础。 + +## Glossary + +- **Process**: 运行中的 InboxBridge Node.js 进程,入口为 `src/runtime/main.ts`。 +- **Logger**: 全局 pino 实例(`main.ts:21`),所有模块应通过依赖注入或模块级实例使用。 +- **Unhandled rejection**: 被 Promise 拒绝但未在 await 链路中被 `.catch()` 捕获的 rejection。 +- **Uncaught exception**: 同步代码中未被 try/catch 捕获的异常。 +- **Health check**: HTTP 端点 `/healthz`,返回进程存活状态和关键依赖可达性。 +- **Metrics**: HTTP 端点 `/metrics`,返回进程运行指标(消息计数、投递状态、会话数等)。 +- **Hot path**: 消息投递、定时器回调、grammy update 处理等高频执行路径。 +- **Runtime**: 由 `restartRuntime` / `stopRuntime` 管理的 bot 生命周期,包括 bot 实例、定时器和 worker。 + +## Requirements + +### Requirement 1: Unhandled Rejection 兜底 + +**User Story:** AS 运维人员, I want 未捕获的 Promise rejection 被记录到结构化日志, so that 排障时能在日志系统中检索到完整的错误上下文 + +#### Acceptance Criteria + +1. WHEN Node.js 进程触发 `unhandledRejection` 事件, the Process SHALL 通过 pino logger 以 error 级别记录错误对象和 rejection 原因 +2. WHEN `unhandledRejection` 被记录后, the Process SHALL 继续运行(不退出进程),保持 bot 和定时器正常工作 +3. IF 同一错误消息在 60 秒内重复触发 `unhandledRejection` 超过 5 次, the Process SHALL 记录一条 warn 级别的去重日志并在该 60 秒窗口内抑制后续重复记录 + +### Requirement 2: Uncaught Exception 兜底 + +**User Story:** AS 运维人员, I want 未捕获的同步异常被记录后触发受控退出, so that 进程管理器(systemd/PM2/容器编排)能自动重启进程并恢复一致状态 + +#### Acceptance Criteria + +1. WHEN Node.js 进程触发 `uncaughtException` 事件, the Process SHALL 通过 pino logger 以 fatal 级别记录错误对象、堆栈和触发上下文 +2. WHEN `uncaughtException` 被记录后, the Process SHALL 调用 `stopRuntime()` 执行受控关闭(停止 bot、清理定时器和 worker) +3. WHEN `stopRuntime()` 完成或超过 10 秒(取先到者), the Process SHALL 以退出码 1 调用 `process.exit(1)` +4. IF `uncaughtException` 发生在 `stopRuntime()` 执行期间, the Process SHALL 跳过重复的 `stopRuntime()` 调用,直接等待退出超时 + +### Requirement 3: 热路径日志统一到 pino + +**User Story:** AS 运维人员, I want 所有关键路径的错误日志通过 pino 输出, so that 日志格式统一、可被日志采集器正确解析、支持结构化字段检索 + +#### Acceptance Criteria + +1. WHEN `src/channels/telegram/messages.ts` 中的投递失败路径执行, the Process SHALL 通过注入的 pino logger 以 error 级别记录 conversationId、deliveryId、target、error.message 和 error.stack +2. WHEN `src/domain/conversation-expiry.ts` 中的 Topic 删除失败, the Process SHALL 通过注入的 pino logger 以 error 级别记录 conversationId、threadId、error.message +3. WHEN `src/channels/telegram/updates.ts` 中的 grammy update 处理失败, the Process SHALL 通过注入的 pino logger 以 error 级别记录 update_id、error.message 和 error.stack +4. WHILE 替换 console.error 为 pino logger, the Process SHALL 保持原有的用户可见行为不变(例如不向 Telegram 用户暴露内部错误细节) + +### Requirement 4: 健康检查端点 + +**User Story:** AS 容器编排系统, I want 通过 HTTP 端点探测进程存活和依赖可达性, so that 能实现 liveness 和 readiness 探测 + +#### Acceptance Criteria + +1. WHEN 收到 `GET /healthz` 请求, the Web Console SHALL 返回 HTTP 200 和 JSON 格式的健康状态 +2. WHILE 返回健康状态, the JSON SHALL 包含 `status`("ok" 或 "degraded")、`bot`("running" 或 "stopped")、`db`("reachable" 或 "unreachable")、`uptime_seconds`(进程运行秒数)、`timestamp`(ISO 8601) +3. IF bot 处于 stopped 状态或数据库不可达, the Web Console SHALL 返回 HTTP 503 和 `status: "degraded"` +4. WHEN 收到 `GET /healthz` 请求, the Web Console SHALL 不要求认证(公开端点) + +### Requirement 5: Metrics 端点 + +**User Story:** AS 运维人员, I want 通过 HTTP 端点获取运行指标, so that 能监控消息吞吐量、投递积压和会话活跃度 + +#### Acceptance Criteria + +1. WHEN 收到 `GET /metrics` 请求, the Web Console SHALL 返回 HTTP 200 和 JSON 格式的指标快照 +2. WHILE 返回指标, the JSON SHALL 包含以下字段:`messages`(含 `inbound_total`、`outbound_total`、`pending_deliveries`、`failed_deliveries`、`permanent_failure_deliveries`)、`conversations`(含 `active_total`、`closed_total`、`expired_total`)、`ai_drafts`(含 `pending_total`、`ready_total`、`failed_total`)、`uptime_seconds`、`timestamp` +3. WHEN 收到 `GET /metrics` 请求, the Web Console SHALL 要求与 Dashboard 相同的 session 认证(非公开端点,防止数据泄漏) +4. IF 指标查询过程中数据库报错, the Web Console SHALL 返回 HTTP 500 和 `{"error": "metrics query failed"}` +5. WHILE 计算投递和会话计数, the Web Console SHALL 通过单次 SQL 查询聚合,避免全表扫描 + +### Requirement 6: 优雅关闭超时 + +**User Story:** AS 运维人员, I want 进程在收到 SIGINT/SIGTERM 后在有限时间内退出, so that 不会因为 bot.stop() 卡住而无法重启 + +#### Acceptance Criteria + +1. WHEN 进程收到 SIGINT 或 SIGTERM, the Process SHALL 启动 `stopRuntime()` 并设置 10 秒超时 +2. IF `stopRuntime()` 在 10 秒内完成, the Process SHALL 关闭数据库连接并以退出码 0 退出 +3. IF `stopRuntime()` 超过 10 秒未完成, the Process SHALL 记录一条 warn 日志,关闭数据库连接并以退出码 1 强制退出