diff --git a/docs/方案/Issue-65-84-botId-targetId稳定主动投递方案.md b/docs/方案/Issue-65-84-botId-targetId稳定主动投递方案.md new file mode 100644 index 0000000..7ca27b4 --- /dev/null +++ b/docs/方案/Issue-65-84-botId-targetId稳定主动投递方案.md @@ -0,0 +1,636 @@ +# Issue #65 / #84:基于 `botId + targetId` 的九渠道稳定主动投递方案 + +日期:2026-08-30。代码基线:v4.0.1 / `92ec91b`。状态:待实施。 + +需求来源:[Issue #65](https://github.com/xmanrui/dsh-im/issues/65)、[Issue #84](https://github.com/xmanrui/dsh-im/issues/84)。本文是实现设计和验收依据,不代表功能已经上线。 + +## 1. 最终决定 + +两项需求一起实现,共用一套主动投递核心,只保留两个不同入口: + +| 场景 | 调用入口 | 适用调用方 | +| --- | --- | --- | +| #65 | Host 进程内 Cordis 服务 `ctx.dshIm` | 与 dsh-im 运行在同一 Host 的 cron、提醒、看板等插件 | +| #84 | Connection RPC 通道 `/dsh-im-delivery` | Host 进程外的本机程序、编排器和管理页面 | + +两个入口都只使用 `botId + targetId` 定位投递位置,再加本次要发送的 `text`。它们必须调用同一个 `DeliveryService.send()`,不能各自解析路由、维护目标或连接渠道。 + +```text +路由地址 = botId + targetId +消息内容 = text +``` + +本方案不把 `sessionId`、`chatRef`、入站消息 ID、回复目标或临时 Webhook 当作主动投递地址,也不引入 `deliveryHandle`。dsh-im 只持久化用户明确配置的投递目标,不保存主动投递历史、业务任务、幂等键或重试队列。 + +## 2. 要解决的问题 + +### 2.1 两种使用场景 + +- #65 需要让同一个 Host 内的其他 Cordis 插件复用 dsh-im 已有的机器人凭据、连接和发送能力,而不是再接一套机器人。 +- #84 需要让回复链之外的进程调用主动投递,例如长任务到达审批点后主动提醒用户。 +- 两种场景的差异只是调用边界,目标选择、渠道适配、错误语义和实际发送都相同。 + +### 2.2 现有候选标识为什么不合适 + +| 标识 | 结论 | 原因 | +| --- | --- | --- | +| `sessionId` | 不使用 | 表示 Harness 会话,不一定绑定机器人或聊天;切换、重建后也不能代表投递地址 | +| `chatRef` | 不作为公共协议 | 九渠道内容不统一,容易混入消息 ID、线程上下文或临时 Webhook;调用方还要理解渠道内部格式 | +| 入站 `replyTarget` | 不使用 | 依赖最近一条入站消息,只适合回复链,不能长期保存为公共地址 | +| 钉钉 `sessionWebhook` | 不使用 | 有效期和会话上下文有限,不适合长期主动投递 | +| 平台原生用户或群 ID | 只存入内部路由 | 各渠道字段数量和类型不同,部分目标还包含话题或线程字段,不适合作为统一接口结构 | +| `botId + targetId` | 采用 | 两个值都由 dsh-im 展示和持久化;渠道路由变化时可保持公共调用参数不变 | + +### 2.3 稳定性的边界 + +- `botId` 沿用现有机器人配置记录中的真实 ID。它在该机器人配置记录的生命周期内稳定,Host 重启、断线重连和凭据刷新不改变它。 +- 删除机器人再重新接入视为新机器人;此时不保证沿用旧 `botId`,旧目标也随旧机器人一起删除。 +- `targetId` 是某个机器人下由用户填写的稳定、不透明标识,例如 `daily-report`、`ops-oncall`。同一机器人内唯一、区分大小写,创建后不能改名。 +- 如果某个平台原生主 ID 本身适合作为稳定标识,用户也可以直接把它填写为 `targetId`;dsh-im 仍将它当作不透明键,不从字符串猜测渠道或目标类型。 +- 平台原生地址保存在该目标的 `route` 中。用户可以修改 `route` 而不改变外部系统使用的 `botId + targetId`。 + +### 2.4 `botId` 直接复用现有身份 + +本期不重新发明 Bot ID。九渠道当前都已经把 `botId` 写入机器人配置并在状态响应中返回: + +| 渠道 | 当前 `botId` 来源 | 本方案处理 | +| --- | --- | --- | +| 飞书 | 首次接入生成 `bot_...` 并持久化 | 原样展示和使用 | +| 微信 | 由平台 `accountId` 稳定派生 | 原样展示和使用 | +| 钉钉 | 由应用 `clientId` 稳定派生 | 原样展示和使用 | +| 企业微信 | 由平台机器人 ID 稳定派生 | 原样展示和使用 | +| QQ | 由 `appId` 稳定派生 | 原样展示和使用 | +| Slack | 由当前 Bot 的平台身份稳定派生 | 原样展示和使用 | +| Telegram | 由 Telegram Bot 平台 ID 稳定派生 | 原样展示和使用 | +| Discord | 由 Discord Bot 平台 ID 稳定派生 | 原样展示和使用 | +| WhatsApp | 由已连接账号 JID 稳定派生 | 原样展示和使用 | + +因此设置页只需把状态模型中已经存在的真实 `botId` 显示出来,不增加“获取 Bot ID”请求,也不把左上角当前展示的遮罩平台身份误当成 `botId`。 + +## 3. 需求范围 + +### 3.1 必须实现 + +1. 九个 IM 渠道统一支持文字主动投递:微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp。 +2. 一个机器人可以配置零个、一个或多个投递目标。 +3. 目标配置在 Host 重启后仍然存在;机器人删除时一并清理。 +4. #65 和 #84 的发送请求经过同一个核心服务和同一个渠道适配器。 +5. 机器人卡片右上角增加设置齿轮;卡片已有身份、状态、工作区、Agent Preset、上下文增强、连接检查和移除入口保持不变。 +6. 设置页展示可复制的真实 `botId`,并提供目标的新建、编辑、删除、复制调用参数和发送测试消息。 +7. 目标配置不依赖机器人在线;实际发送和测试要求机器人当前已连接。 +8. 用户负责从对应平台取得原生用户、群、频道、话题或线程标识;页面提供字段名和格式提示。 +9. 严格校验 RPC 端点、字段和渠道路由,不把平台原始错误、凭据或临时回复上下文返回给调用方。 +10. 保持所有现有入站回复、连接检查和文件发送行为不变。 + +### 3.2 本期明确不做 + +- 不自动扫描最近聊天,不提供 `listChats`,不从连接检查记忆或会话映射自动导入目标。 +- 不提供绑定码、认领流程、用户目录同步或跨渠道身份合并。 +- 不接受 `sessionId`、`chatRef`、`sessionWebhook`、消息 ID 或 `replyToMessageId` 作为公共发送参数。 +- 不支持图片、文件、卡片、Markdown 类型选择;首期公共能力只有文字,渠道内部继续复用现有文字分段逻辑。 +- 不实现定时任务、消息队列、离线补发、自动重试、回执查询、已读状态或发送历史。 +- 不接收或生成 `idempotencyKey`;调用方重试造成的重复消息由调用方负责。 +- 不新增 API Key、签名或用户权限模型。本期只沿用现有 Connection RPC 的 `loopback` / `trusted-host` 可达性边界;该边界不是业务鉴权。 +- 不为 AI Office 增加主动投递。本文“九渠道”不包含实验性的 AI Office。 +- 不保证平台原生目标永久有效;被平台删除、机器人无权限或用户屏蔽后,发送应明确失败。 + +## 4. 领域模型与不变量 + +### 4.1 数据关系 + +```text +Bot(现有机器人) +└── DeliveryTarget(投递目标,0..n) + ├── targetId:对调用方稳定的键 + ├── name:可选显示名称 + ├── kind:渠道内的目标类型 + └── route:可更新的渠道原生路由 +``` + +投递目标的唯一键是 `(botId, targetId)`,不是全局 `targetId`。两个机器人可以各自拥有名为 `daily-report` 的目标,互不影响。 + +### 4.2 不变量 + +- `botId` 必须对应一个仍然存在的机器人配置。 +- `targetId` 长度为 1~128,只允许 ASCII 字母、数字、`.`、`_`、`:`、`@`、`-`,不得有首尾空白;保存时不静默改写大小写。 +- `name` 可省略;填写时去除首尾空白后长度为 1~80。 +- `kind` 和 `route` 必须通过所属渠道的严格校验,未知字段一律拒绝。 +- `targetId` 创建后不可修改。改名操作等价于新建目标、切换调用方、再删除旧目标,避免外部调用方在不知情时失效。 +- 更新目标时完整替换 `name + kind + route`,不做深层 PATCH,避免残留旧类型字段。 +- 发送开始时读取一份目标快照。并发更新可能使正在发送的那一次使用旧路由,但更新完成后的下一次发送必须使用新路由。 +- 删除目标不取消已经交给渠道的发送;删除完成后的新请求返回 `unknown-target`。 + +## 5. 总体架构 + +```mermaid +flowchart LR + A[同 Host Cordis 插件
Issue #65] -->|ctx.dshIm.send| C[DeliveryService] + B[进程外调用方
Issue #84] -->|Connection RPC| R[Delivery RPC] + U[机器人设置页] -->|目标管理 / 测试| R + R --> C + C -->|按 botId 选择| G[九渠道 Adapter Registry] + G --> T[BotWorkspaceStore
目标配置] + G --> S[未包装 coreController
现有 Runtime / BotClient] + S --> P[IM 平台] +``` + +| 组件 | 职责 | 不负责 | +| --- | --- | --- | +| `DeliveryService` | 参数校验、按 `botId` 选择适配器、目标 CRUD、统一发送和错误码 | 不理解九渠道原生字段,不保存消息 | +| 渠道适配器 | 判断是否拥有机器人、校验本渠道 `kind/route`、调用对应核心 controller | 不暴露 RPC,不管理调用方业务 | +| `BotWorkspaceStore` | 持久化机器人下的目标,复用现有原子写入、机器人队列和删除清理 | 不发送消息,不发现聊天 | +| Cordis 服务 | 把 #65 调用转发给 `DeliveryService` | 不复制渠道选择逻辑 | +| Delivery RPC | 把 #84 和设置页请求转发给 `DeliveryService`,转换安全错误包络 | 不直接调用 Runtime | +| 设置页 | 展示 `botId`、编辑渠道路由、调用测试 | 不推断或抓取目标 ID | + +### 5.1 为什么使用单独 RPC 通道 + +现有 `/dsh-im` 通道只承载更新功能,并固定为 `loopback`。如果把主动投递直接塞进该通道,想让 #84 使用现有 `trusted-host` 配置时会同时改变更新接口的暴露边界。 + +因此新增 `/dsh-im-delivery`,但继续复用现有 `ctx.connection.rpc.handle/call`、统一结果包络、取消信号和 `resolveRpcAuthority()`,不新增 HTTP Server,也不改动 `/dsh-im` 更新接口。 + +## 6. 目标持久化 + +### 6.1 复用现有 `workspaces.json` + +每个渠道已经有一份 `workspaces.json`,由 `BotWorkspaceStore` 保存工作区、Agent Preset 和上下文增强设置,并具备: + +- 机器人粒度的写入队列; +- 临时文件加 `rename` 的原子落盘; +- 机器人删除事务和启动时 `reconcile()`; +- 九渠道生产组合已经统一创建该 Store。 + +目标是机器人级设置,直接为这个文档增加 `deliveryTargets`,不再创建九份独立 Store、数据库或消息表。 + +```json +{ + "version": 2, + "workspaces": { + "bot_abc": "/Users/example/project" + }, + "agentPresets": {}, + "contextEnhancement": {}, + "deliveryTargets": { + "bot_abc": { + "daily-report": { + "name": "每日汇报群", + "kind": "group", + "route": { + "chatId": "oc_xxx" + } + } + } + } +} +``` + +`route` 仅为示意;真实字段由渠道决定。`targetId` 已经是对象键,不在记录内重复保存。不增加创建时间、最后发送时间、状态、计数或幂等信息。 + +### 6.2 Store 最小扩展 + +在 `src/channels/shared/bot-workspace-store.mjs` 增加: + +```js +listDeliveryTargets(botId) +deliveryTargetFor(botId, targetId) +createDeliveryTarget(botId, target) +updateDeliveryTarget(botId, targetId, replacement) +deleteDeliveryTarget(botId, targetId) +``` + +实现规则: + +1. 文档 v1 读取为“没有投递目标”,第一次目标变更时按 v2 写回;现有工作区、Preset 和上下文增强原样保留。 +2. v2 严格校验目标对象的基本结构;渠道适配器在创建、更新和发送前再校验具体路由。 +3. CRUD 复用已有 `#enqueue(botId, operation)` 和写入回滚模式,不增加第二把锁。 +4. `reconcile()` 的候选集合、`#retireCurrentIncarnation()`、`#persist()` 和空文档删除判断同时包含 `deliveryTargets`。 +5. 机器人配置删除成功后,目标与工作区在同一删除事务中清理;配置删除回滚时目标也保留。 +6. 返回值使用结构化副本,调用方不能直接修改 Store 内部状态。 +7. 继续使用现有目录 `0700`、文件 `0600` 的落盘权限;路由中的用户或群标识不写日志、不进入发送历史。 + +## 7. 共用主动投递核心 + +### 7.1 对内服务形状 + +建议新增 `plugin-src/host/delivery-service.mjs`: + +```js +service.registerAdapter(adapter) // 返回 unregister +service.listTargets(botId) +service.createTarget(botId, target) +service.updateTarget(botId, targetId, replacement) +service.deleteTarget(botId, targetId) +service.send(botId, targetId, text, { signal } = {}) +``` + +`send()` 的唯一流程: + +1. 校验 `botId`、`targetId` 和非空 `text`;空白文本拒绝,但发送时保留原文格式。 +2. 按现有九渠道启动顺序查找第一个 `adapter.ownsBot(botId)` 为真的适配器。最多检查九个内存对象,不建立另一份机器人注册数据库。 +3. 从该适配器所属的 `BotWorkspaceStore` 读取 `(botId, targetId)` 快照。 +4. 再次用渠道定义校验持久化的 `kind/route`,防止手工损坏文件后向错误地址发送。 +5. 调用未包装 `coreController.sendProactiveText(botId, route, text, { signal })`。 +6. 渠道接受发送后只返回 `{ sent: true }`;不创建投递句柄、不落发送记录。 + +### 7.2 渠道适配器契约 + +```js +{ + channel: 'feishu', + ownsBot(botId), + listTargets(botId), + createTarget(botId, target), + updateTarget(botId, targetId, replacement), + deleteTarget(botId, targetId), + sendText(botId, target, text, { signal }) +} +``` + +- `ownsBot()` 使用同一生产组合中的 `workspaces.has(botId)`,删除中的机器人不会继续接受新发送。 +- 公共服务不从 `botId` 前缀猜渠道;前缀只是现有实现细节。 +- 每个生产组合把自身 `workspaces` 和未包装 `coreController` 闭包进适配器。 +- 适配器只在对应渠道成功启动后注册,关闭渠道时注销。 + +### 7.3 必须绕过 Workspace RPC 装饰层 + +#65 已指出 `createWorkspaceAwareController()` 会装饰 controller 的所有返回值,数组方法可能因此改变形状。主动投递适配器必须使用生产组合中已有的未包装 `coreController`;渠道管理 RPC 继续使用包装后的 `controller`。 + +不在 `createWorkspaceAwareController()` 中加入主动投递特例,也不把目标列表塞进机器人状态响应,这样现有管理接口不会扩大或改变。 + +### 7.4 统一错误 + +| code | 含义 | +| --- | --- | +| `bad-request` | 接口、字段、文本或基本 ID 格式不合法 | +| `unknown-bot` | 没有已注册适配器拥有该 `botId` | +| `unknown-target` | 机器人存在,但找不到该 `targetId` | +| `target-conflict` | 同一机器人下创建了重复 `targetId` | +| `invalid-target` | `kind/route` 不符合该渠道约束,或持久化内容已损坏 | +| `bot-not-connected` | 机器人存在,但 Runtime 当前不能发送 | +| `target-rejected` | 平台明确拒绝该地址或机器人没有权限 | +| `delivery-failed` | 网络、平台异常或其他不可安全细分的发送失败 | +| `cancelled` | 调用在交给平台前被取消 | + +进程内服务抛出带 `code` 的错误;RPC 转成 `{ ok: false, error: { code, message } }`。RPC 不返回平台原始响应、Webhook、Token、堆栈或内部文件路径。 + +成功只表示平台发送接口已接受或当前 SDK 已成功返回,不承诺对方已读。dsh-im 不主动重试;调用方自行重试可能产生重复消息。 + +## 8. #65:Host 进程内 Cordis 服务 + +Host 启动时创建唯一的 `DeliveryService`,并通过现有 Cordis 机制提供: + +```js +ctx.provide('dshIm', Object.freeze({ + send: (botId, targetId, text, options) => + deliveryService.send(botId, targetId, text, options), + listTargets: (botId) => deliveryService.listTargets(botId), +})) +``` + +消费方示例: + +```js +export const inject = ['dshIm']; + +await ctx.dshIm.send( + 'bot_7f4c...', + 'daily-report', + '今日构建已经完成。', +); +``` + +约束: + +- 服务名沿用 #65 建议的 `dshIm`,方法名保持最小,只暴露 `send` 和 `listTargets`。 +- 目标 CRUD 由设置页/管理 RPC 完成,避免 Host 插件顺便承担配置 UI。 +- `listTargets()` 返回 `{ targetId, name, kind, route }[]`,不再提供会话发现语义的 `listChats()`。 +- Cordis 服务和 RPC 持有的是同一个 `DeliveryService` 实例;测试必须证明两者不是两套 registry。 +- Host/渠道关闭时用现有 `ctx.effect()` 注销服务、RPC 和渠道适配器,不留下失效 Runtime 引用。 + +## 9. #84:进程外 Connection RPC + +### 9.1 通道和端点 + +新增通道常量: + +```js +export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery'; +``` + +| endpoint | payload | 成功 value | +| --- | --- | --- | +| `message.send` | `{ botId, targetId, text }` | `{ sent: true }` | +| `target.list` | `{ botId }` | `{ botId, channel, targets }` | +| `target.create` | `{ botId, target: { targetId, name?, kind, route } }` | 新目标完整记录 | +| `target.update` | `{ botId, targetId, target: { name?, kind, route } }` | 更新后的完整记录 | +| `target.delete` | `{ botId, targetId }` | `{ deleted: true }` | +| `target.test` | `{ botId, targetId }` | `{ sent: true }` | + +`target.test` 调用同一个 `DeliveryService.send()`,内容使用固定本地化文案“DSH-IM 主动投递测试成功。”。它与现有“检查连接”是两个功能:检查连接验证机器人连接和默认测试目标,目标测试验证用户配置的指定路由。 + +### 9.2 调用示例 + +```js +const result = await connection.rpc.call( + '/dsh-im-delivery', + 'message.send', + { + botId: 'bot_7f4c...', + targetId: 'daily-report', + text: '流水线正在等待审批。', + }, + signal, +); +``` + +结果继续使用仓库已有包络: + +```json +{ "ok": true, "value": { "sent": true } } +``` + +调用方只需要长期保存 `botId` 和 `targetId`。没有 `deliveryHandle`、`sessionId`、`chatRef` 或 `idempotencyKey`。 + +### 9.3 RPC 边界 + +- 默认 `authority: 'loopback'`,满足同机进程外调用和管理页面。 +- 顶层已有 `rpcAuthority: 'trusted-host'` 配置时沿用现有解析机制;不新增第二个同义配置。 +- `target.*` 和 `message.send` 使用相同可达性边界。本期不进一步区分管理员和发送者权限。 +- 每个端点只接受表中列出的键;缺字段、额外字段、数组冒充对象或已取消请求均拒绝。 +- 该 RPC 是 #84 的对外程序接口;不再另建 Express 路由、REST Server 或 Webhook 接收器。 + +## 10. 九渠道适配 + +### 10.1 目标字段和现有发送链路 + +| 渠道 | `kind` | 设置页要求用户填写的 `route` | 复用的现有发送链路 | +| --- | --- | --- | --- | +| 微信 | `user` | `{ toUserId }` | `createWeixinApi().sendText()`;不带 `contextToken/runId` | +| 飞书 | `user` / `group` | 用户 `{ openId }`;群 `{ chatId }` | `FeishuRuntime` 当前 `im.v1.message.create` 文字发送 | +| 钉钉 | `user` / `group` | 用户 `{ userId }`;群 `{ openConversationId }` | 在 `dingtalk-api.mjs` 增加稳定机器人文字发送,复用现有 Access Token 和机器人主动文件消息端点 | +| 企业微信 | `user` / `group` | `{ chatId }`;私聊填用户 ID,群聊填群 `chatid` | 已连接客户端的 `sendMessage(chatId, markdown)` | +| QQ | `user` / `group` | 用户 `{ userOpenId }`;群 `{ groupOpenId }` | `QqRuntime` 中的 `QQBot.sendText()`,适配为 SDK 的 `{ scope, targetId }` | +| Slack | `conversation` / `thread` | `{ channelId }`;线程再加 `{ threadTs }` | `SlackBotClient.sendText()` | +| Telegram | `chat` / `topic` | `{ chatId }`;话题再加 `{ messageThreadId }` | `TelegramBotClient.sendText()`,不带回复消息 ID | +| Discord | `channel` | `{ channelId }`;私信、频道和 Thread 都使用可发消息的 Channel ID | `DiscordBotClient.sendText()`,不带回复消息 ID/notice | +| WhatsApp | `user` / `group` | `{ jid }`,分别接受用户 JID 或群 JID | `WhatsappBotClient.sendText()`,不带 `quoted` | + +注意:表中的 `route` 是内部持久化结构,#65/#84 的发送调用都不传这些字段。 + +### 10.2 各渠道的实现要点 + +1. **微信**:只支持用户目标。复用现有 API 的无上下文文字发送能力;不把入站 `contextToken` 或当前 `runId` 保存进目标。 +2. **飞书**:`kind` 决定 `receive_id_type` 为 `open_id` 或 `chat_id`。把连接检查中的局部发送函数提取为 Runtime 的稳定文字发送方法,继续使用同一 SDK Client。 +3. **钉钉**:即时回复链仍可保留 `sessionWebhook`,但主动投递绝不能使用它。新增 `sendRobotText()`,用户目标调用 `v1.0/robot/oToMessages/batchSend`,群目标调用 `v1.0/robot/groupMessages/send`;`robotCode` 从当前机器人配置取得,不让用户重复填写。请求复用现有 Access Token Header,文字体为 `msgKey: 'sampleText'`、`msgParam: JSON.stringify({ content: text })`,目标字段分别为 `userIds` 或 `openConversationId`。为路径、Header、消息体和拒绝响应增加契约测试。 +4. **企业微信**:直接复用 Runtime 当前连接检查使用的客户端;私聊的 `chatId` 是用户 ID,群聊为平台 `chatid`,类型只用于校验和页面说明。 +5. **QQ**:公共 `targetId` 与 QQ SDK 内部字段 `targetId` 不是一回事。适配器将 `userOpenId/groupOpenId` 映射为 `{ scope: 'c2c'|'group', targetId: 原生 ID }`,避免调用方接触 SDK 结构。 +6. **Slack**:用户必须提供会话 Channel ID,不能只填 Member ID。线程目标额外保存稳定的根消息 `threadTs`。 +7. **Telegram**:`chatId` 以十进制字符串持久化,调用 API 前校验并转换;Topic 使用可选整数 `messageThreadId`,不保存最近入站消息 ID。 +8. **Discord**:DM 也要求已经存在且机器人可访问的 Channel ID;Thread 本身同样是 Channel ID,因此无需额外线程字段。 +9. **WhatsApp**:只接受当前 SDK 支持的用户或群 JID,拒绝 Status/Broadcast 等特殊 JID。发送目标只有 `jid`,不保存引用消息对象或 `selfChat` 状态。 + +### 10.3 共用已有代码,但不复用临时目标 + +- Slack、Telegram、Discord 已共用 `TextHarnessBridge`。为它增加一个只委托 `#bot.sendText()` 的公共方法,三个 Runtime 再统一暴露 `sendProactiveText()`,不复制分段代码。 +- Telegram 和 Discord 继续通过 `TokenBotController` 统一委托 Runtime;其他 controller 各增加同名薄方法。 +- 渠道的 `sendText()` 继续负责自身长度切分和平台错误分类;`DeliveryService` 不做统一切段。 +- 现有 `sendConnectionTest()` 可以复用底层发送函数,但它记住的测试目标不会自动成为投递目标。 +- 所有主动路由明确拒绝 `sessionId`、`sessionWebhook`、`contextToken`、`runId`、`messageId`、`replyToMessageId`、`quoted` 等瞬时字段。 + +## 11. 机器人卡片与设置页 + +### 11.1 卡片入口 + +机器人卡片原有内容和顺序不变,仅在右上角状态区域增加齿轮: + +```text +┌────────────────────────────────────────────────────┐ +│ [渠道图标] 机器人名称 ● 运行正常 [⚙] │ +│ 现有平台身份 最近检查 10:30 │ +│ │ +│ 原有设置内容全部保持不变 │ +│ [检查连接] [移除接入] │ +└────────────────────────────────────────────────────┘ +``` + +- 在 `channel-card-meta.js` 新增共享 `BotSettingsButton`,九种卡片都使用它。 +- 右侧使用 `dim-botCardTools` 包住现有 `BotStatusMeta` 和齿轮,不修改健康状态本身。 +- 按钮为 32×32 px、16 px 齿轮、8 px 圆角;默认透明,Hover 使用现有浅灰交互底色,键盘焦点使用现有蓝色 focus ring。 +- `aria-label` 和 Tooltip 都是“机器人设置”。按钮不显示 `botId` 或目标数量,避免继续挤占卡片。 +- 机器人离线时仍可进入设置;仅“发送测试”不可用。 + +### 11.2 独立设置页 + +点击齿轮后,在当前渠道右侧面板内从机器人列表切换到独立设置页,不使用弹窗,也不增加左侧导航项: + +```text +← 返回机器人列表 飞书机器人 + +调用标识 +Bot ID +bot_7f4c9d... [复制] + +投递目标 [新建目标] +每日汇报群 +targetId: daily-report 群聊 · chat_id oc_xxx + [复制调用参数] [测试] [编辑] [删除] + +告警负责人 +targetId: ops-oncall 私聊 · open_id ou_xxx + [复制调用参数] [测试] [编辑] [删除] +``` + +页面规则: + +- Bot ID 显示 `status.bots[].botId` 的真实值,使用等宽字体,可单独复制;不显示卡片上经过遮罩的平台应用 ID 来冒充。 +- “复制调用参数”复制 JSON:`{ "botId": "...", "targetId": "..." }`,不包含路由和消息文本。 +- 空状态说明“尚未配置投递目标,请从对应平台取得用户、群、频道或话题标识后新建”。 +- 目标行展示名称、`targetId`、目标类型和一行渠道路由摘要;原生 ID 过长时视觉省略,复制值必须完整。 +- 删除先在页面内确认,并提示“使用这个 targetId 的外部调用将返回 unknown-target”。 +- 返回机器人列表后保留当前渠道;尽量保留此前滚动位置,不重新切换渠道。 + +### 11.3 新建和编辑表单 + +共享表单字段: + +1. `targetId`:必填;编辑时只读。 +2. 显示名称:可选。 +3. 目标类型:仅展示该渠道支持的 `kind`。 +4. 渠道路由字段:按第 10 节表格动态显示。 + +实现一个共享 `DeliveryTargetSettingsPage`,用一份九渠道字段定义驱动标签、占位说明、类型选项和路由摘要,不复制九个页面。渠道自己的 `SettingsTab` 只维护 `selectedBot` 并传入 `channel/account/deliveryRpcCall/onBack`。 + +本期不提供“从最近聊天选择”“使用检查连接目标”“自动读取群列表”等快捷入口。用户负责取得平台原生 ID;页面只负责清楚说明当前字段需要哪一种 ID。 + +## 12. 最小代码改动建议 + +### 12.1 新增共享文件 + +| 文件 | 内容 | +| --- | --- | +| `plugin-src/host/delivery-service.mjs` | Adapter registry、目标 CRUD、统一 `send()`、Cordis 服务对象 | +| `plugin-src/host/delivery-rpc.mjs` | `/dsh-im-delivery` 端点、严格 payload 校验、安全错误包络 | +| `plugin-src/client/delivery-settings.js` | 设置页、目标表单、九渠道字段定义和调用参数复制 | + +不需要新增第三方依赖。 + +### 12.2 修改现有共享文件 + +| 文件 | 改动 | +| --- | --- | +| `src/channels/shared/bot-workspace-store.mjs` | v2 文档、`deliveryTargets` CRUD、迁移和机器人删除清理 | +| `src/channels/shared/text-harness-bridge.mjs` | 为 Slack/Telegram/Discord 增加薄的文字发送委托 | +| `src/channels/shared/token-bot-controller.mjs` | 委托 Telegram/Discord Runtime 的主动文字发送 | +| `plugin-src/host/index.mjs` | 创建唯一服务、提供 `dshIm`、安装 Delivery RPC、向九渠道传注册回调 | +| `plugin-src/host/channels/shared/production.mjs` | 返回由 `coreController + workspaces` 构成的适配器 | +| `plugin-src/client/index.js` | 建立 `deliveryRpcCall` 并传给九渠道设置页 | +| `plugin-src/client/loopback-recovery.js` | 将 Delivery RPC 纳入现有 loopback 恢复包装 | +| `plugin-src/client/channel-card-meta.js` | 共享齿轮按钮 | +| `plugin-src/client/styles.js` | 齿轮、设置页、目标列表和响应式样式 | + +### 12.3 渠道文件 + +- 共享 token 生产组合覆盖 Telegram、Discord;Slack 使用自己的生产组合。 +- 飞书、微信、钉钉、企业微信、QQ、Slack、WhatsApp 的 production 返回同形适配器。 +- 九渠道现有 controller/runtime 各增加一个薄的 `sendProactiveText()`;底层继续调用现有 BotClient/API。 +- 钉钉额外修改 `src/channels/dingtalk/dingtalk-api.mjs`,增加稳定用户/群文字发送方法。 +- 九个客户端卡片把现有 `BotStatusMeta` 与共享齿轮放进右侧工具组;卡片其余 JSX 不搬迁、不重排。 + +### 12.4 推荐实施顺序 + +1. 先扩展 `BotWorkspaceStore` 和迁移测试。 +2. 实现 `DeliveryService`、适配器契约、Cordis 服务和 Delivery RPC,用假适配器打通双入口。 +3. 接入九渠道 Runtime/controller;先做现有共用程度最高的 Telegram/Discord/Slack,再完成其余渠道和钉钉稳定发送。 +4. 实现共享设置页和九张卡片的齿轮入口。 +5. 补充 README/README.en、CHANGELOG、接口示例和九渠道真实环境验收记录。 + +每一步都使用同一公共契约,不先合入只支持飞书的公共 API,避免一经发布就形成“接口声称九渠道、实际只有一个渠道”的兼容包袱。 + +## 13. 测试方案 + +### 13.1 Store 与生命周期 + +| 编号 | 用例 | 预期 | +| --- | --- | --- | +| S01 | 读取现有 v1 `workspaces.json` | 工作区等设置不变,目标为空;首次目标变更写为 v2 | +| S02 | 新建两个目标后重建 Store | 完整恢复 `targetId/name/kind/route` | +| S03 | 两个机器人使用相同 `targetId` | 各自读取自己的目标,不冲突 | +| S04 | 同一机器人重复创建 `targetId` | `target-conflict`,原记录不变 | +| S05 | 更新名称、类型和路由 | `targetId` 不变,下一次读取是完整新快照 | +| S06 | 落盘失败 | 内存回滚到提交前状态,不返回虚假成功 | +| S07 | 同一机器人并发增删改 | 经过已有机器人队列串行化,文件合法且不丢其他目标 | +| S08 | 删除机器人成功/失败回滚 | 成功时目标清理;配置删除失败时目标保留 | +| S09 | 启动 `reconcile()` 遇到已不存在机器人 | 清理该机器人的孤儿目标 | + +### 13.2 共用核心与双入口 + +| 编号 | 用例 | 预期 | +| --- | --- | --- | +| C01 | 两个适配器分别拥有不同 `botId` | 只调用拥有该机器人的适配器一次 | +| C02 | 未知机器人/目标 | 分别返回 `unknown-bot` / `unknown-target`,不尝试平台发送 | +| C03 | 目标路由损坏 | `invalid-target`,不把损坏数据交给 Runtime | +| C04 | 机器人离线 | `bot-not-connected`,不排队、不落消息记录 | +| C05 | 发送过程中更新目标 | 当前调用使用一个完整快照;后续调用使用新路由 | +| C06 | 调用取消 | 交给平台前返回 `cancelled`;已交给平台后不承诺撤回 | +| C07 | 平台拒绝和网络失败 | 映射为安全公共错误,不泄露原始响应 | +| C08 | Cordis `send` 与 RPC `message.send` 使用同一 pair | 命中同一个 service spy、同一个适配器和同一返回语义 | +| C09 | Host/渠道关闭 | 适配器注销,旧 Runtime 不再可调用 | +| C10 | 发送成功 | 只返回 `{ sent: true }`,没有 handle、历史或幂等状态 | + +建议新增 `test/delivery-service.test.mjs`、`test/delivery-rpc.test.mjs`,并扩展 `test/host.test.mjs` 验证 `ctx.provide('dshIm')` 和清理生命周期。 + +### 13.3 RPC 契约 + +- 六个端点分别覆盖成功、缺字段、额外字段、错误类型、未知端点和取消。 +- `target.update` 不能提交新的 `targetId`;`target.create` 不能覆盖已有记录。 +- 默认 authority 为 `loopback`,显式 `trusted-host` 走现有解析器;`/dsh-im` 更新 RPC 仍固定 loopback。 +- `message.send` 只接受 `{ botId, targetId, text }`,显式验证 `sessionId/chatRef/idempotencyKey/route` 等额外字段会被拒绝。 +- 错误包络只含允许的 code 和安全文案。 + +### 13.4 九渠道适配器契约 + +每个渠道至少有以下自动化用例: + +1. 每种受支持 `kind` 的合法路由准确映射到现有 sender。 +2. 缺字段、空字段、错误类型和未知字段均为 `invalid-target`。 +3. Runtime 离线时不调用 SDK。 +4. 长文本仍由该渠道现有分段函数处理,核心服务不重复切段。 +5. 主动发送不携带回复消息、引用对象或最近会话状态。 +6. 平台 sender 只调用一次业务发送入口;其内部分段次数按现有规则。 + +重点断言: + +| 渠道 | 必须断言 | +| --- | --- | +| 微信 | 请求没有 `contextToken`、`runId` | +| 飞书 | user/group 分别使用 `open_id/chat_id` | +| 钉钉 | 使用稳定 OpenAPI 路径;任何主动发送都没有读取或调用 `sessionWebhook` | +| 企业微信 | user/group 的 `chatId` 原样传给当前 Client | +| QQ | user/group 分别映射 `c2c/group`,不混淆公共 `targetId` | +| Slack | `threadTs` 仅在线程目标出现 | +| Telegram | Topic 映射 `messageThreadId`,始终没有 `replyToMessageId` | +| Discord | 只传 `channelId + content`,没有 reply/notice | +| WhatsApp | 只传 `jid + text`,没有 `quoted` | + +这些测试加入各渠道现有 runtime/controller/production 测试文件,不为同一行为新建九套测试框架。 + +### 13.5 客户端测试 + +| 编号 | 用例 | 预期 | +| --- | --- | --- | +| U01 | 九种机器人卡片渲染 | 状态右侧均有可访问齿轮,原有内容和操作仍在 | +| U02 | 点击齿轮和返回 | 在当前面板切换设置页/机器人列表,不改变渠道 | +| U03 | Bot ID 复制 | 复制真实完整 `botId`,不是遮罩平台 ID | +| U04 | 目标空状态及新建 | 按渠道显示正确字段,保存后出现在列表 | +| U05 | 编辑目标 | `targetId` 只读,名称/类型/路由可完整替换 | +| U06 | 删除目标 | 先确认,成功后移除;失败时保留并显示安全错误 | +| U07 | 复制调用参数 | JSON 只含正确 `botId + targetId` | +| U08 | 在线/离线测试 | 离线禁用测试但可编辑;在线测试调用 `target.test` | +| U09 | 窄屏和键盘操作 | 不横向溢出,焦点顺序、Tooltip、状态播报可用 | +| U10 | Delivery RPC 不可用 | 只影响目标设置,不破坏机器人列表和现有连接操作 | + +复用现有 `test/client-ui.test.mjs` 和九渠道 `client-ui.test.mjs` 的渲染方式;共享设置页可新增 `test/client-delivery-settings.test.mjs`。 + +### 13.6 回归和真实环境 + +自动化必须通过 `npm run check`,并特别回归:机器人接入/删除、断线重连、检查连接、工作区切换、Agent Preset、上下文增强、入站回复、Telegram 长轮询和 WhatsApp 回声过滤。 + +发布前为九渠道各配置至少一个真实目标并执行 `target.test`;支持群/线程的渠道再各验证一个非私聊目标。验收记录应保存渠道、目标类型、时间、结果和脱敏错误,不保存凭据或完整用户 ID。 + +## 14. 验收标准 + +以下条件全部满足才能关闭 #65 和 #84: + +1. 同 Host 插件可以注入 `dshIm`,使用 `send(botId, targetId, text)` 完成投递。 +2. 进程外调用方可以通过 `/dsh-im-delivery` 的 `message.send` 使用同一组参数完成投递。 +3. 自动化测试证明两个入口调用同一个 `DeliveryService`,不存在两套路由解析或渠道连接。 +4. 九渠道均有生产适配器、自动化契约测试和一次真实环境成功记录。 +5. Host 重启后同一 `botId + targetId` 仍可使用;编辑渠道路由后公共 pair 不变且下一次发送走新路由。 +6. 公共发送接口没有 `sessionId`、`chatRef`、`sessionWebhook`、`deliveryHandle` 或 `idempotencyKey`。 +7. 钉钉主动投递只使用稳定用户/群接口;临时 `sessionWebhook` 仅保留在原即时回复链。 +8. 每种机器人卡片右上角都有齿轮,原卡片内容、顺序和连接检查功能没有退化。 +9. 设置页可复制真实 Bot ID,并完整支持目标的新建、编辑、删除、复制 pair 和测试。 +10. 一个机器人可配置多个目标;相同 `targetId` 在不同机器人下隔离。 +11. 机器人离线时仍可管理目标;发送返回明确的 `bot-not-connected`,不建立隐式队列。 +12. 删除机器人会清理其全部目标;删除失败回滚不会丢失目标。 +13. dsh-im 不落主动发送历史、不自动重试、不管理幂等状态。 +14. `npm run check` 全部通过,九渠道现有接入、回复和连接检查回归通过。 + +## 15. 风险与控制 + +| 风险 | 控制方式 | +| --- | --- | +| 用户填错平台 ID | 渠道严格校验、字段级提示和目标测试;不尝试从字符串猜测 | +| 平台目标以后失效 | 保持 `targetId` 不变,用户只更新内部 route | +| 配置文件升级损坏原设置 | v1→v2 迁移、原子写入、失败回滚和迁移测试 | +| #65 与 #84 行为逐渐分叉 | 两个入口只做协议转换,测试直接断言同一 service 实例 | +| 九渠道复制实现 | 目标 Store、Service、RPC 和设置页共享;渠道层只保留路由校验和薄发送委托 | +| 钉钉误用临时 Webhook | 主动发送 API 不接受该字段,并以负向测试固定 | +| 对外 RPC 被误认为已有完整鉴权 | 文档明确本期只复用可达性边界;真正远程开放前另行设计鉴权 | +| 调用方超时后重试导致重复 | 不伪造“恰好一次”;文档明确调用方负责幂等,dsh-im 不落盘去重 | + +## 16. 方案收口 + +本方案只有一个需要长期维护的公共概念:投递目标。外部调用方认 `botId + targetId`,dsh-im 在内部把它解析成当前渠道路由并复用已有发送连接。 + +后续即使增加富文本或文件,也应在 `DeliveryService` 上扩展消息内容类型,而不是再引入 `sessionId`、`chatRef` 或新的地址体系;本期不提前实现这些扩展点。 diff --git a/docs/方案/Issue-85-Telegram长轮询与发送连接竞争修复方案.md b/docs/方案/Issue-85-Telegram长轮询与发送连接竞争修复方案.md new file mode 100644 index 0000000..24c81bb --- /dev/null +++ b/docs/方案/Issue-85-Telegram长轮询与发送连接竞争修复方案.md @@ -0,0 +1,467 @@ +# Issue #85:Telegram 长轮询与发送连接竞争修复方案 + +日期:2026-08-30。代码基线:v4.0.1 / `92ec91b`。状态:待实施。 + +需求来源:[Issue #85](https://github.com/xmanrui/dsh-im/issues/85)。本文记录风险分析后的最终实施方案和验收要求;其中列出的测试均为待补测试,不能视为已经通过。 + +## 1. 结论 + +本问题不通过增大发送超时、关闭 keep-alive 或重试最终消息解决。最终方案是: + +- 每个 Telegram Runtime 创建一个私有、代理感知的 Undici dispatcher。 +- 同一 Bot 的长轮询、普通消息、富消息、草稿、文件上传和文件下载共用该 dispatcher。 +- 每个 Telegram Bot 对同一来源最多使用 4 条连接;一条长轮询最多占用其中一条,至少保留三条并发发送能力。 +- `fetch`、dispatcher 和 `FormData` 使用同一个固定版本的 `undici` 实现。 +- Runtime 明确拥有并释放 dispatcher,不修改进程全局 dispatcher。 +- 不增加发送重试,不修改现有超时和 `CHANNEL_DELIVERY_UNCERTAIN` 语义。 + +这个方案只增加一个 Telegram 专用 HTTP 工厂、一个 Runtime 生命周期资源和一个 `FormData` 注入点,不引入通用 Transport 框架、双连接池或用户配置项。 + +## 2. 根因边界 + +### 2.1 已确认的错误链路 + +当前 Telegram Runtime 只创建一个 `TelegramApi`,长轮询和所有出站调用都通过它使用默认全局 `fetch`: + +1. Runtime 启动 `getUpdates(timeout: 25)` 长轮询。 +2. 收到消息后,`bridge.accept()` 以异步方式继续处理,poller 会立即进入下一次长轮询。 +3. 发送消息、草稿、正在输入状态、文件上传等操作因此会与下一次长轮询并发。 +4. 如果宿主进程的全局 dispatcher、代理或连接路径把这些请求串行化,发送调用会排在长轮询后面。 +5. 普通发送默认 15 秒超时,而长轮询可持续 25 秒,发送先发生超时。 +6. Telegram API 将超时标记为 `deliveryOutcome = unknown`,共享错误链最终转换为 `CHANNEL_DELIVERY_UNCERTAIN`。 + +因此,`CHANNEL_DELIVERY_UNCERTAIN` 是发送结果确实无法确认时的正确下游表现,不应通过改错误分类掩盖。 + +### 2.2 Issue 中不能直接当成根因的部分 + +“Node 默认 Undici 只有一条 HTTP/1.1 连接”并不是正常默认配置。Undici 默认 Agent 会为同一来源使用 Pool,并可在已有连接繁忙时建立新连接。独立 Agent 能改善报告环境中的表现,说明问题位于 HTTP 传输路径,但不能单独证明默认连接池固定为一条连接。 + +更准确的工程根因是: + +> dsh-im 明知 Telegram 长轮询与出站请求会并发,却让两类请求共同依赖进程中可变、不可控的全局 HTTP 传输策略。 + +实际触发因素可能是宿主安装过自定义全局 dispatcher、代理连接池受限、连接器异常,或特定网络路径发生串行化。修复目标不是猜测报告环境的唯一配置,而是让 Telegram 的并发能力不再依赖该全局状态。 + +## 3. 范围与非目标 + +| 项目 | 决定 | +| --- | --- | +| 修复范围 | Telegram Bot API、Telegram 文件上传和平台文件下载 | +| dispatcher 作用域 | 每个 Telegram Runtime / Bot 一个私有实例 | +| 同源连接数 | 固定 4 条,不新增用户设置 | +| 代理 | 支持 `HTTP_PROXY`、`HTTPS_PROXY`、`NO_PROXY` 及对应小写变量 | +| 全局 dispatcher | 不读取、不替换、不修改 | +| 发送超时 | 保持现有默认值 | +| 长轮询超时 | 保持现有 25 秒及现有超时余量 | +| 发送重试 | 不新增,防止消息重复 | +| 错误分类 | 保持现有 uncertain 语义 | +| 双连接池 | 不做;一个 4 连接池已经能隔离全局状态并满足并发 | +| 通用 Transport 抽象 | 不做;只增加 Telegram 专用工厂函数 | +| UI 和配置迁移 | 不涉及 | + +## 4. HTTP 实现 + +### 4.1 固定同一份 Undici 实现 + +增加精确版本运行时依赖 `undici@7.29.0`。该版本要求 Node `>=20.18.1`,低于项目当前的 Node `>=22.19` 下限。 + +不能采用以下组合: + +```js +globalThis.fetch(url, { dispatcher: npmUndiciAgent }); +``` + +也不能把全局 `FormData` 直接交给 `undici.fetch()`。Undici 明确要求 `fetch` 与 `FormData` 来自同一个实现;混用不同版本或不同实现创建的 Web API 对象可能抛错,文件上传是最容易受影响的路径。 + +### 4.2 新增 Telegram 专用工厂 + +新增 `src/channels/telegram/telegram-http.mjs`,只提供一个小型工厂函数: + +```js +import { + EnvHttpProxyAgent, + FormData as UndiciFormData, + fetch as undiciFetch, +} from 'undici'; + +const TELEGRAM_HTTP_CONNECTIONS = 4; + +export function createTelegramHttpTransport() { + const dispatcher = new EnvHttpProxyAgent({ + connections: TELEGRAM_HTTP_CONNECTIONS, + }); + + return { + fetchImpl: (url, options = {}) => undiciFetch(url, { + ...options, + dispatcher, + }), + FormDataImpl: UndiciFormData, + destroy: () => dispatcher.destroy(), + }; +} +``` + +约束: + +- `dispatcher` 必须在展开 `options` 后写入,调用方不能意外覆盖 Runtime 所拥有的 dispatcher。 +- 不调用 `setGlobalDispatcher()`。 +- 不设置 `keepAliveTimeout` 或 `keepAliveMaxTimeout`;Issue 建议中的 `30`、`60` 在 Undici 中是毫秒,不是秒,而且本问题不需要靠调整它们解决。 +- transport 不记录请求 URL;Telegram Token 位于 URL path 中。 +- `destroy()` 由资源所有者调用,transport 本身不实现重试或超时。 + +### 4.3 为什么使用 EnvHttpProxyAgent + +裸 `Agent` 会绕过宿主通过标准代理环境变量表达的网络策略。`EnvHttpProxyAgent` 会读取: + +- `HTTP_PROXY` / `http_proxy` +- `HTTPS_PROXY` / `https_proxy` +- `NO_PROXY` / `no_proxy` + +没有代理变量时直接连接;存在代理变量时复用 Undici 已有代理机制,不在项目内自行解析代理地址或实现 CONNECT。 + +任意通过代码安装的全局 dispatcher、拦截器或自定义 connector 不会自动继承。这是有意的隔离边界,否则会重新引入 Issue #85 的全局耦合。特殊嵌入环境通过现有 `createApi`、`fetchImpl` 以及新增的 `createHttpTransport` 注入点覆盖,不新增用户界面配置。 + +## 5. TelegramApi 调整 + +修改 `src/channels/telegram/telegram-api.mjs`: + +1. 构造函数增加 `FormDataImpl = globalThis.FormData`。 +2. 校验 `FormDataImpl` 可构造并保存为私有字段。 +3. `#sendArtifact()` 使用注入的实现创建 multipart body。 +4. 直接构造 `new TelegramApi({ token })` 时仍使用全局 `fetch + FormData`,保持已有调用兼容。 +5. Runtime 注入时使用 `undici.fetch + undici.FormData`,保证实现匹配。 + +示意: + +```js +constructor({ + token, + fetchImpl = fetch, + FormDataImpl = FormData, + baseUrl = DEFAULT_BASE_URL, + fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS, +}) { + // 现有校验保持不变 + this.#fetch = fetchImpl; + this.#FormDataImpl = FormDataImpl; +} + +async #sendArtifact(/* ... */) { + const payload = new this.#FormDataImpl(); + // 沿用现有 append 和错误映射 +} +``` + +不改变请求 payload、超时、redirect 策略、文件大小处理和 provider 错误映射。 + +## 6. TelegramRuntime 调整 + +### 6.1 创建和注入 + +修改 `src/channels/telegram/telegram-runtime.mjs`: + +- 增加 `#createHttpTransport` 和 `#httpTransport`。 +- 构造函数默认 `createHttpTransport = createTelegramHttpTransport`,作为生命周期测试和特殊嵌入环境的最小注入点。 +- `#start()` 在 Harness 就绪后创建 transport,并复用现有 `createApi`: + +```js +const transport = this.#createHttpTransport(); +this.#httpTransport = transport; + +const api = this.#createApi({ + token: this.#token, + fetchImpl: transport.fetchImpl, + FormDataImpl: transport.FormDataImpl, +}); +this.#api = api; +``` + +transport 必须在调用 `createApi` 前登记到 Runtime;这样 `createApi` 或随后任意启动步骤抛错时,统一的 `stop()` 都能释放它。 + +### 6.2 正常停止顺序 + +`stop()` 按以下顺序执行: + +1. 捕获当前 `pollTask`、bridge 和 transport。 +2. 立即把 `#httpTransport` 清空,防止重复销毁或旧周期销毁新周期资源。 +3. abort 当前 Runtime 的 `AbortController`。 +4. 清空 `#abortController`、`#pollTask`、`#api`、`#bridge`。 +5. 沿用现有有界等待,等待 poll 和 bridge 退出。 +6. 调用捕获的 `transport.destroy()`。 +7. transport 清理失败只记录脱敏 warning,不覆盖原始启动或运行错误,也不让 `stop()` 失败。 +8. 最后进入 `idle`。 + +这里使用 `destroy()` 而不是 `close()`:停止 Runtime 本身就是取消语义,`close()` 可能继续等待尚未返回的 25 秒长轮询。先 abort、再有界等待、最后 destroy,可以保留现有清理窗口,又不会让停止过程无限挂起。 + +### 6.3 启动失败和竞态 + +以下路径全部复用同一清理逻辑: + +- `getMe` 失败。 +- Token 身份不匹配。 +- Webhook 已配置。 +- 命令菜单以外的启动步骤失败。 +- transport 或 `createApi` 创建失败。 +- 启动过程中外部调用 `stop()`。 +- 重连时先停止旧 Runtime,再创建新 transport。 + +异步回调必须捕获本次启动使用的 controller 和 transport,并在操作字段前比较身份。旧 poll 或旧清理任务不能销毁重启后新建的 transport。 + +### 6.4 poll 意外终止 + +非主动 abort 导致 poll 退出时: + +1. 立即把状态标记为 `failed`,保留原始错误。 +2. 停止接受新的发送测试。 +3. 给已开始的 bridge 工作保留现有有界清理窗口。 +4. 随后 abort 本启动周期并销毁它拥有的 transport。 +5. 清理过程不能把 `failed` 状态覆盖成 `idle`;后续由现有连接 supervisor 触发重建。 + +这部分可提取一个 Runtime 私有清理函数复用,但不新增公共生命周期抽象。 + +## 7. Token 检查的一致性 + +`inspectTelegramToken()` 是接入机器人前的 `getMe` 调用。如果 Runtime 使用环境代理,而 Token 检查仍使用另一套全局网络策略,可能出现检查和运行行为不一致。 + +调整规则: + +- 调用者显式传入 `fetchImpl` 时继续使用它,函数不拥有也不释放该 fetch 的资源。 +- 未传入 `fetchImpl` 时,临时创建 `createTelegramHttpTransport()`。 +- 使用其 `fetchImpl` 和 `FormDataImpl` 创建一次性 `TelegramApi`。 +- 无论成功或失败,都在 `finally` 中销毁临时 transport。 +- 不能把 Bot Token 或完整请求 URL写入错误和日志。 + +Token 检查不与已运行 Bot 共享 dispatcher,避免短生命周期检查错误地关闭 Runtime 连接。 + +## 8. 依赖、构建和文档改动 + +| 文件 | 计划改动 | +| --- | --- | +| `package.json` | 增加精确运行时依赖 `undici: 7.29.0` | +| `package-lock.json` | 更新锁文件 | +| `src/channels/telegram/telegram-http.mjs` | 新增 Telegram 专用 transport 工厂 | +| `src/channels/telegram/telegram-api.mjs` | 增加匹配的 `FormDataImpl` 注入和一次性 Token 检查 transport | +| `src/channels/telegram/telegram-runtime.mjs` | 创建、持有并释放私有 transport | +| `plugin-src/host/build.mjs` | 把 `undici` 加入 `externalRuntimePackages`,不打入 Host bundle | +| `scripts/verify-package.mjs` | 校验 `undici` 为精确直接依赖,并验证 bundle 外置约束 | +| `THIRD_PARTY_NOTICES.md` | 增加 Undici 许可信息 | +| `README.md`、`README.en.md` | 简述 Telegram 私有连接池及标准代理变量支持 | +| `CHANGELOG.md` | 记录 Issue #85 修复、网络策略边界和无自动重试 | +| `lib/index.js` | 通过现有构建命令重新生成,不手工编辑 | + +## 9. 自动化测试方案 + +新增 `test/channels/telegram/telegram-http.test.mjs`,并在现有 `test/channels/telegram/telegram.test.mjs` 补充 API 和 Runtime 测试。所有网络测试只使用本地 server,不访问真实 Telegram。 + +### 9.1 核心回归:长轮询不能阻塞发送 + +测试步骤: + +1. 启动本地 HTTP Server。 +2. 构造使用真实 Telegram transport 的 `TelegramApi`,把 `baseUrl` 指向本地 server。 +3. server 收到 `/getUpdates` 后记录事件并保持响应未完成。 +4. 确认长轮询已经开始后调用 `sendChatAction`。 +5. server 立即响应发送请求。 +6. 断言 `sendChatAction` 在释放 `/getUpdates` 前已经完成。 +7. 最后释放长轮询并清理 server 和 transport。 + +断言依据是 Promise 和服务端事件的先后顺序,不使用“必须在 500ms 内完成”之类容易抖动的性能阈值;只保留一个较宽的失败截止时间防止测试挂死。 + +### 9.2 连接容量 + +同时阻塞: + +- 1 个 `getUpdates`。 +- 3 个短 API 请求。 + +断言四个请求都已经到达 server,再统一释放。这证明长轮询占用一条连接时仍有三条独立发送连接。 + +不要求第 5 个请求必须排队,避免测试绑定 Undici 不必要的内部调度细节。 + +### 9.3 全局 dispatcher 隔离 + +在独立子进程中: + +1. 把全局 dispatcher 设置为 `connections: 1`。 +2. 通过全局 fetch 发起一个不会立即返回的请求,占住全局连接。 +3. 创建 Telegram 私有 transport。 +4. 断言 Telegram 请求仍然到达并完成。 + +该测试必须使用子进程,不能在主测试进程中修改全局 dispatcher 后依赖恢复,以免并行测试互相污染。 + +### 9.4 代理与 NO_PROXY + +使用独立子进程、本地目标 server 和本地 HTTP 代理: + +- 设置 `HTTP_PROXY` 后,请求必须经过代理。 +- 设置 `NO_PROXY` 匹配目标地址后,请求必须直连。 +- transport 销毁后代理侧连接最终关闭。 +- 测试环境变量不泄漏到其他测试。 + +不在测试中实现企业 TLS 中间人或连接池;只验证项目选择的标准代理语义。 + +### 9.5 真实 multipart 上传 + +不能只保留 fake fetch 对 `FormData` 字段的检查。新增通过真实 `undici.fetch` 发往本地 server 的集成测试: + +- `sendDocument` 的 `Content-Type` 含合法 multipart boundary。 +- 正确包含 `chat_id`、`message_thread_id` 和 `reply_parameters`。 +- 文件名、媒体类型和二进制内容正确。 +- `sendPhoto` 覆盖相同路径。 +- 请求能够完成,不出现跨实现 `FormData` 错误。 + +现有注入 fake fetch 的单元测试继续保留,用于检查 API payload;新测试只补真实序列化和传输风险。 + +### 9.6 Runtime 生命周期 + +通过 `createHttpTransport` 注入带计数器的 fake transport,覆盖: + +| 场景 | 必须断言 | +| --- | --- | +| 正常 `start → stop` | transport 只销毁一次 | +| 连续两次 `stop()` | 不重复销毁、不抛错 | +| `getMe` 失败 | 已创建 transport 被销毁 | +| Bot 身份不匹配 | transport 被销毁,原错误保留 | +| Webhook 已配置 | transport 被销毁,原错误保留 | +| `createApi` 抛错 | transport 仍被销毁 | +| 启动期间调用 `stop()` | 启动被取消,无未处理 rejection | +| `start → stop → start` | 旧 transport 在新周期前销毁,两者互不影响 | +| poll 非主动失败 | 状态保持 `failed`,对应 transport 最终销毁 | +| 两个 Runtime 并存 | 停止其中一个不会销毁另一个的 transport | + +测试不能通过读取活跃句柄数量推测资源是否释放;应直接验证注入资源的所有权和调用次数。 + +### 9.7 Token 检查生命周期 + +- 默认路径成功时销毁一次临时 transport。 +- `getMe` 失败时仍销毁一次。 +- 显式传入 `fetchImpl` 时不创建默认 transport。 +- Token 检查和 Runtime 使用独立资源,关闭前者不影响后者。 + +### 9.8 错误语义和重复投递 + +保留并加强现有超时测试: + +- transport timeout 继续映射为 `telegram-timeout`。 +- `deliveryOutcome` 继续为 `unknown`。 +- 上层继续得到 `CHANNEL_DELIVERY_UNCERTAIN`。 +- 一次发送只调用一次 fetch。 +- 不自动重试 `sendMessage`、`sendRichMessage`、`editMessageText` 或文件上传。 +- 不改变现有 15 秒发送超时和 120 秒文件上传超时。 + +### 9.9 Token 泄漏 + +分别模拟: + +- 连接失败。 +- 代理失败。 +- poll 失败。 +- 文件上传失败。 +- dispatcher 销毁失败。 + +检查 error message、stack、cause 的安全格式和 logger 参数序列化结果,均不能包含完整 Bot Token 或 `/bot/...` URL。 + +### 9.10 发布包和 Node 版本 + +至少执行: + +```text +Node 22.19:npm run check +Node 24:npm run check +npm pack +在空临时目录安装生成的 tarball +导入发布包 lib/index.js +通过发布包执行一次本地 Telegram transport 请求 +``` + +发布包 smoke test 用于发现: + +- `undici` 未声明为运行时依赖。 +- `undici` 被错误打入 Host bundle。 +- lockfile 与清单版本不一致。 +- 生成文件没有更新。 +- 源码环境能运行、干净安装后却无法解析依赖。 + +## 10. 风险与闭环 + +| 风险 | 等级 | 方案内处理 | 验证方式 | +| --- | --- | --- | --- | +| 私有 Agent 绕过代理 | 高 | 使用 `EnvHttpProxyAgent` | 代理与 `NO_PROXY` 子进程测试 | +| 不继承自定义全局 dispatcher | 中 | 明确隔离边界,保留注入点,不偷偷回退全局 | 自定义 transport 注入测试和文档 | +| Node 内置 fetch 与 npm Agent 不兼容 | 高 | fetch、dispatcher、FormData 全部来自同一固定依赖 | Node 22/24 与真实 multipart 测试 | +| 固定 4 连接仍可能排队 | 中 | 一条 poll + 三条发送;不无限开连接 | 一加三并发屏障测试 | +| 多 Bot 增加 socket 上限 | 低 | 每 Bot 固定最多 4 条且按需建立 | 双 Runtime 所有权测试 | +| 启动失败泄漏 dispatcher | 中 | 创建后立即登记,所有失败统一清理 | 各启动失败测试 | +| stop/start 销毁错代资源 | 中 | 捕获本周期资源并做身份比较 | 启停竞态和重启测试 | +| destroy 导致在途发送 uncertain | 中 | 先 abort 和有界等待;只在停止或运行周期失败时 destroy | 停止期间在途请求测试 | +| 自动重试产生重复消息 | 高 | 本期明确不增加重试 | fetch 调用次数断言 | +| Token 出现在 Undici 诊断中 | 高 | 不记录 URL,增加日志脱敏验收 | 多错误路径 Token 泄漏测试 | +| 修改全局 dispatcher 的测试污染套件 | 中 | 使用独立子进程 | 并行运行完整测试套件 | +| 外部代理本身只允许单路并发 | 残余 | 应用无法绕过外部基础设施限制,保持明确超时和诊断 | 文档说明;不承诺代码内解决 | + +## 11. 明确不采用的替代方案 + +### 11.1 只增加测试、不调整设计 + +测试只能暴露问题,不能消除代理绕过、跨 Undici 实现混用和生命周期泄漏,因此不足以作为最终方案。 + +### 11.2 增大发送超时 + +只会延迟失败;如果请求仍排在长轮询后面,无法保证任何固定值足够,还会让用户更晚看到错误。 + +### 11.3 自动重试最终发送 + +超时发生在请求结果未知的阶段,Telegram 可能已经收到消息。盲目重试会产生重复消息或重复文件。 + +### 11.4 `Connection: close` + +会牺牲所有请求的连接复用,增加 TLS 建连成本,不能表达清晰的资源所有权,也不能可靠继承代理策略。 + +### 11.5 等待 bridge 完成后再继续 poll + +会破坏当前交互能力:Harness 等待用户回答时,Telegram 必须继续拉取下一条 update 才能收到答案。 + +### 11.6 poll 和 send 分成两套连接池 + +会增加两份代理配置、生命周期和测试面。一个 4 连接的私有 dispatcher 已经保证长轮询不会独占所有连接,没有必要增加第二套资源。 + +### 11.7 修改全局 dispatcher + +会影响其他 IM 渠道、更新检查和宿主自身网络调用,并可能覆盖宿主已有代理或安全策略,风险明显大于局部修复。 + +## 12. 实施顺序 + +1. 增加并外置固定版本的 `undici` 依赖,更新清单、锁文件、校验器和第三方声明。 +2. 新增 Telegram HTTP 工厂及其直接并发、代理和全局隔离测试。 +3. 为 `TelegramApi` 增加匹配的 `FormDataImpl`,补真实 multipart 测试。 +4. 在 Runtime 中接入 transport 所有权和正常停止清理,补启动失败和重启测试。 +5. 处理 poll 非主动失败的有界清理和代际竞态测试。 +6. 让默认 Token 检查使用一次性同策略 transport,补成功和失败清理测试。 +7. 运行 Telegram 现有测试,确认错误分类和交互轮询行为没有变化。 +8. 更新中英文文档和 changelog,通过 Node 22.19、Node 24、`npm run check` 与发布包 smoke test。 + +## 13. 验收标准 + +以下条件全部满足才能认为 Issue #85 已彻底修复: + +- `getUpdates` 被无限期阻塞时,短 Telegram API 请求仍能在释放 poll 前完成。 +- 一条 poll 与三条并发发送可以同时到达服务端。 +- 即使全局 dispatcher 被限制为一条连接,Telegram 私有 transport 仍可正常请求。 +- 标准代理和 `NO_PROXY` 行为正确。 +- 文档、图片上传使用真实 multipart 成功,不存在混用 `FormData`。 +- 正常停止、启动失败、重连和 poll 崩溃均不泄漏或误销毁 transport。 +- 现有 Harness 交互场景继续在原 Turn 未结束时拉取回答 update。 +- 现有 timeout、unknown delivery 和 `CHANNEL_DELIVERY_UNCERTAIN` 语义保持不变。 +- 没有新增最终消息重试或重复投递。 +- 任何日志和错误中都没有完整 Telegram Token。 +- Node 22.19、Node 24、完整测试、构建校验和干净发布包安装全部通过。 + +## 14. 参考资料 + +- [Issue #85](https://github.com/xmanrui/dsh-im/issues/85) +- [Undici Agent](https://github.com/nodejs/undici/blob/main/docs/docs/api/Agent.md) +- [Undici EnvHttpProxyAgent v7.29.0](https://github.com/nodejs/undici/blob/v7.29.0/docs/docs/api/EnvHttpProxyAgent.md) +- [Undici Fetch v7.29.0](https://github.com/nodejs/undici/blob/v7.29.0/docs/docs/api/Fetch.md) +- [Undici v7.29.0 package.json](https://github.com/nodejs/undici/blob/v7.29.0/package.json) +- [Node.js fetch 自定义 dispatcher](https://nodejs.org/dist/latest/docs/api/globals.html#custom-dispatcher) diff --git a/docs/方案/WhatsApp群聊记录与按日总结方案.md b/docs/方案/WhatsApp群聊记录与按日总结方案.md new file mode 100644 index 0000000..b560734 --- /dev/null +++ b/docs/方案/WhatsApp群聊记录与按日总结方案.md @@ -0,0 +1,499 @@ +# WhatsApp 群聊记录与按日总结方案 + +> 状态:方案草案,尚未实施。 +> +> 日期:2026-08-28。 +> +> 硬约束:只修改 dsh-im;不修改、打补丁或重新构建 Harness 核心;优先复用 Harness 的会话存储,不新增聊天内容数据库。 + +## 1. 方案结论 + +机器人账号加入指定 WhatsApp 群后,dsh-im 持续记录实际收到的群消息。记录过程不调用模型;授权成员发送 `@机器人 总结今天` 时,插件从已保存的记录中取出当天内容,生成总结并发回原群。已经启用的普通问答规则独立生效,不因记录功能被改写。 + +建议采用下面的存储分工: + +| 对象 | 保存内容 | 是否运行 AI | +| --- | --- | --- | +| 群记录会话 | 群成员的原始消息、收到的修订事件、记录状态 | 否 | +| 摘要会话 | 一次总结所选的消息、摘要提示词和 AI 回复 | 是,仅收到明确指令后 | +| 现有普通对话会话 | 原有机器人问答 | 保持现状 | + +三者都可以使用 Harness 已有的会话设施,但不能混用其写入方式。群记录会话由 dsh-im 独占,通过宿主公开的 `sessionPersistence` 服务读写;摘要会话通过现有 `session.create`、`session.prompt` 等接口运行。普通对话的绑定、访问模式和命令保持原有行为。 + +**“保存到会话记录”在本方案中指保存到 Harness 的会话持久化后端,不意味着每条群消息自动变成现有聊天窗口中的用户气泡。** 第一版由 dsh-im 提供群记录查看入口。要求原有聊天窗口原样显示全部群消息,属于另一个展示需求,不能在不改核心的前提下直接承诺。 + +该路径有源码和已有后端测试支持,但尚未完成目标安装版本的插件集成验证。必须先通过第 15 节的兼容性验证,再进入功能开发;不能把“能调用一个方法”当作整条链路已经可用。 + +## 2. 目标、范围与明确不做的事情 + +### 2.1 第一版目标 + +1. 只记录用户在设置页明确启用的群,默认关闭。 +2. 未 @ 机器人的文字消息也能入库;记录操作本身不触发 AI、已读提示、输入状态或群内回复。 +3. 支持按群时区总结今天、昨天、指定日期及日期内的明确时间段。 +4. 记录保存发言人、原始消息时间、收到时间和消息 ID,能够去重、排序和说明来源。 +5. 重启后能够读取已持久化记录;中断、缺失、未解析媒体等情况在总结中说明。 +6. 全部实现位于 dsh-im,使用 Harness 的公开插件服务和现有 RPC。 + +### 2.2 第一版不承诺 + +- 不修改 Harness 源码、事件白名单、RPC 协议、内置聊天 UI 或存储表结构。 +- 不直接编辑 Harness 的 JSONL、压缩日志或 SQLite 文件。 +- 不保证获取入群前、启用前、设备离线期间的完整历史。 +- 不启用全量历史同步,不把手机上可见的全部旧聊天等同于程序已保存的数据。 +- 不转写语音,不识别图片,不读取文件正文,不下载媒体。 +- 不记录阅后即焚内容;第一版不支持启用了消息自动消失的群。 +- 不自动每天发送总结,不增加定时任务。 +- 不把普通群聊内容作为需要执行的命令、审批或工具指令。 +- 不承诺自动物理删除过期记录,原因见第 12 节。 +- 不在这一版本扩展其他八个 IM 渠道。 + +## 3. 当前代码与核验发现 + +### 3.1 dsh-im 已有能力 + +| 位置 | 已有行为 | 对本需求的影响 | +| --- | --- | --- | +| `src/channels/whatsapp/whatsapp-web-session.mjs` | 监听 `messages.upsert`;关闭历史同步;对旧 `append` 消息设置时间过滤 | 能收到实时消息,但不能直接宣称完整历史已接入 | +| `src/channels/whatsapp/whatsapp-runtime.mjs` | 区分群聊、私聊、自己发出的消息、提及和回复 | 可复用账号接入和归一化基础;需要补原始时间、来源及记录策略 | +| `src/channels/shared/text-harness-bridge.mjs` | 未被寻址的群消息不会进入 AI 处理 | 记录必须在回复过滤之前拥有独立的授权和写入流程 | +| `src/channels/shared/harness-client.mjs` | 创建会话、读取历史、提交提示词 | 可复用摘要执行链路;没有现成的静默群记录方法 | +| `plugin-src/host/harness-connection.mjs` | 默认同 Host 进程内接入,显式远程地址使用 HTTP | 第一版限定同 Host 的插件模式 | +| `src/channels/shared/conversation-state-store.mjs` | 保存会话绑定和最近消息 ID,不保存群聊正文 | 不能把当前 `state.json` 当作群聊天记录 | + +当前 `addressed` 还包含 `fromMe`,不能简单把它等同于“发生了真正的 @”。新增逻辑必须分别保留实际提及、回复、本人发送和机器人出站回声的信息。 + +### 3.2 对前期讨论的一项修正 + +前期讨论提出过直接使用 `session.append()` 写自定义群消息事件。进一步检查发现,这条路径不能直接作为可上线方案: + +- 当前 Harness 的已知事件集合是构建时生成的;仓库外插件新增的事件类型不在其中。 +- 未携带顶层 `ignorable: true` 的未知事件,会在持久化读取时被拒绝。写入成功并不证明重启后能恢复。 +- 所检查版本的 `Session.append()` 不提供设置该顶层标记的参数,返回的事件又是冻结对象。 +- 把聊天正文伪装成标题、待办或其他已有事件,或者修改内部白名单,均不采用。 + +因此,主方案调整为:**使用公开的 `sessionPersistence.create / append / inspect / load / readFrom` 管理插件独占的群记录会话,并为纯信息记录设置 `ignorable: true`。不向正在运行的普通 Agent 会话旁路写入事件。** + +这里的标记表示:Harness 不认识这些事件时,可以不把它们解释为 Agent 执行状态。原始事件仍会被保存和读回,由 dsh-im 校验并解释;不能用这个标记掩盖会改变正常模型历史或执行状态的事件。 + +### 3.3 其他已确认限制 + +| 限制 | 设计处理 | +| --- | --- | +| 普通 HTTP RPC 没有静默追加接口 | 远程模式不开放本功能,不增加自定义 Harness RPC | +| 仅包含日志事件、没有执行轮次的会话可能被原生会话列表视为未开始 | dsh-im 管理自己的记录目录和预览入口,不伪造执行轮次使其显示 | +| 现有全文检索不为未知插件事件生成正文索引 | 按插件目录和时间分段读取原始事件,不依赖现有全文搜索 | +| JSONL 的 `readFrom` 仍可能解析整个日志文件 | 记录按日期和大小分段,避免无限增长的单个会话 | +| 统一持久化服务没有删除接口 | 不提供会让人误以为已物理删除的保留期限或清空按钮 | + +## 4. 总体结构 + +```mermaid +flowchart TD + A[WhatsApp 群消息] --> B[来源识别与归一化] + B --> C{是否有该群的记录授权} + C -->|有| D[串行去重与记录队列] + D --> E[Harness SessionPersistence] + E --> F[插件独占的群记录会话] + B --> G{是否为本功能接管的总结指令} + G -->|是| L{群与请求者是否获准} + L -->|是| H[固定群和日期范围并读取快照] + L -->|否| M[拒绝总结且不转入普通问答] + F --> H + H --> I[受限的摘要会话] + I --> J[生成总结并回复原群] + G -->|否| K[继续原有普通对话策略] +``` + +记录和总结分别授权。未通过记录授权的消息不落入记录队列;被接管但未通过总结授权的指令不读取历史、不调用模型,也不转入普通问答。功能关闭时的原有行为保持不变,普通对话策略不能因为启用了记录而被整体放开。 + +### 4.1 会话路由与归属 + +记录归属由 `channel + botId + groupId + workspaceScope + recordingEpoch` 确定,不使用群名或昵称作为身份。 + +- 每个群独立记录,不跨机器人或跨群合并数据。 +- 归档会话 ID 使用插件生成的随机标识,不把电话号码或群 JID 放进文件名。 +- 一组归档会话只属于一个记录归属,不接受用户从聊天正文指定任意 Session ID。 +- 原有 `/new` 和 `/session` 只影响普通对话,不清除、改绑或接管群记录。 +- 摘要会话单独创建,不复用可能已经绑定其他群或私聊的普通会话。 +- 工作区切换时暂停记录,要求用户确认新的作用域后开始新记录阶段;旧记录不自动复制到新作用域。 + +## 5. 设置、权限与用户体验 + +### 5.1 设置入口 + +在 WhatsApp 机器人卡片下新增“群记录与总结”区域,保存以下配置: + +| 设置 | 第一版规则 | +| --- | --- | +| 启用群记录 | 默认关闭,老配置缺少此项也按关闭处理 | +| 允许记录的群 | 必须明确选择;空列表不记录任何群 | +| 群时区 | 启用时明确确认 IANA 时区,例如 `Asia/Taipei`,保存后不跟随机器时区漂移 | +| 可发起总结的成员 | 默认仅绑定账号本人;其他成员需要在此功能的白名单中明确添加 | +| 摘要 Agent Preset | 选择经验证的受限组合;不能直接假定普通编程 Agent 适合处理不可信群记录 | +| 运行与容量限制 | 使用插件配置字段;达到限制时暂停或拒绝,不静默丢弃后宣称完整 | + +选择群时,可以由当前 WhatsApp 连接查询账号已加入的群,只返回群名、脱敏标识和插件内部选择标识,不返回成员名单、聊天内容或关联设备凭据。也可提供管理员手工输入群标识的高级入口,但必须验证该账号确实已加入。 + +启用前明确说明:会复制群消息到宿主会话存储;生成总结时所选内容会交给配置的模型;关闭功能不删除已保存记录。需由使用者确认有权记录并已告知群成员。不能仅因“账号已经在群里”就默认允许归档。 + +### 5.2 与现有访问模式的关系 + +“普通对话访问”和“群记录与总结”是两个显式授权项。界面文案必须体现这一点,不能继续让用户把“仅自己模式”理解为整个插件完全不会处理群消息。 + +| 情形 | 记录 | 总结 | 普通问答和命令 | +| --- | --- | --- | --- | +| 功能关闭或群未授权 | 不记录 | 不处理 | 完全沿用现状 | +| 群已授权,成员未 @ | 保存允许的消息类型 | 不触发 | 沿用现状 | +| 群已授权,白名单成员 @ 并发总结指令 | 保存该请求的记录 | 处理 | 本条不再重复进入普通问答 | +| 群已授权,非白名单成员请求总结 | 可按群记录授权保存普通文字 | 不读取历史、不调用 AI | 不能把被拒绝的总结请求转交普通 Agent | +| 群已授权,成员发其他 @ 消息 | 保存允许的消息类型 | 不处理 | 仍受原有访问模式控制 | + +无需把账号切换到“开放响应模式”才能使用指定群的总结功能;新增授权只开放该群的记录和规定的总结指令,不顺带开放所有私聊、系统命令或工作区操作。 + +成员校验使用平台事件中的发送者身份。电话号码与 LID 只使用 Baileys 已确认的对应关系;映射缺失时拒绝,不能用昵称、消息中的号码或字符串后缀猜测身份。 + +### 5.3 查看入口 + +每个启用的群展示记录状态、开始时间、最近成功保存时间、今日文字条数、已知中断和错误。点击“查看群记录”后,才调用独立的授权 RPC 分页读取正文;普通机器人状态轮询不返回聊天内容。 + +预览展示日期、发言人、内容及修订状态。摘要运行时,管理员可以在此取消本次摘要;不改写原有 `/stop` 对普通对话的控制语义。摘要会话可以提供普通会话链接;原始记录是否出现在 Harness 原生列表中不作为本功能的依赖,也不伪造标题或轮次改变该行为。 + +## 6. 保存位置与事件格式 + +### 6.1 保存在哪里 + +原始聊天内容交给当前 Host 已挂载的 `sessionPersistence` 服务: + +- 使用 JSONL 后端时,由 Harness 保存为它已有的会话文件,可能是压缩格式。 +- 使用 SQLite 后端时,使用 Harness 当前的数据库,不新增 dsh-im 聊天数据库。 +- 具体位置由该后端配置决定,不硬编码到代码仓库或某个固定 `.dsh` 子目录。 +- 如果后端支持 `locate(meta)`,管理员界面可以展示定位结果;不支持独立文件定位时,说明由当前会话后端管理。 + +dsh-im 自己只保存群授权、记录阶段、归档会话 ID、摘要任务状态等管理元数据,不再保存第二份原始聊天正文。摘要提示词仍会把所选内容复制进普通摘要会话,这是 Harness 正常保存模型输入的结果,不能承诺整个后端只有一份聊天内容。 + +### 6.2 分段策略 + +按机器人、群、作用域及消息的 UTC 日期建立记录分段。群时区只用于用户查询,不用于修改原始 UTC 时间。一个本地自然日可能需要读取两个 UTC 日期分段。 + +单段达到配置的事件数或字节上限后继续写下一段;同一天可有多个段。迟到消息写入其原始日期对应的段,不假装在收到时才发出。超出允许的接收范围或时间无法解析时,记录缺失状态,不擅自用当前时间冒充发送时间。 + +分段只限制单次读取和恢复成本,不等于删除旧记录。日期、字节数、最早与最晚消息时间等可重建索引写入插件管理元数据,正文仍以 Harness 保存的事件为准。 + +### 6.3 事件示例 + +下面是拟新增的插件事件,不是当前已经存在的 Harness 事件类型: + +```json +{ + "type": "dsh-im/whatsapp-group-message", + "seq": 12, + "time": 1787878801000, + "ignorable": true, + "data": { + "schemaVersion": 1, + "routeId": "opaque-group-route", + "recordingEpoch": "opaque-epoch", + "messageId": "provider-message-id", + "sentAt": 1787878800000, + "receivedAt": 1787878801000, + "senderId": "provider-participant-id", + "senderName": "群成员甲", + "kind": "text", + "text": "周五前完成接口联调。" + } +} +``` + +约束如下: + +- `seq` 是该归档会话内连续序号,由唯一写入队列分配;不使用 WhatsApp 消息 ID 代替。 +- `time` 表示记录事件发生时间;日期查询使用 `data.sentAt`,不能混淆。 +- 昵称可缺省;原始身份保存在本地记录中,默认不把电话号码作为摘要展示名称。 +- 消息事件不带 `surfaceOp`,不伪装成普通 `user/message`,不产生模型输入。 +- 接口边界做 JSON、类型、长度与时间校验;不保存整个 Baileys 原始对象、认证信息、下载密钥或媒体载荷。 +- 启用、暂停、断连、修订、出站消息预留和摘要任务状态也使用明确的插件信息事件;所有事件携带插件 schema 版本。 +- 会话 Header 使用目标版本公开导出的格式版本与合法字段,不硬编码当前版本号,不添加未经支持的 Header 字段。 + +## 7. 持久化写入与恢复 + +### 7.1 专用归档会话的写入规则 + +1. 获取当前 Host 的公开 `sessionPersistence` 服务,校验后端能力与配置。 +2. 生成并保存待创建分段的 ID 和归属,再调用 `create(meta)`;首批事件包含归属声明。 +3. 通过 `append(id, events)` 提交连续批次,等待持久化成功后再更新已保存计数和游标。 +4. 插件重启时使用公开的 `load` 或读取接口恢复专用分段、校验归属并重建去重索引;不通过 `session.prompt` 激活它。 +5. 归档会话不创建 Agent,不进入普通会话绑定,也不允许经 dsh-im 的 `/session` 被选作可执行会话。 + +这是公开持久化服务的消费方式,不是修改数据库文件或绕过宿主的序号校验。`Session.append + sessions.flush` 仍适用于正常 live Session;本方案不把两条写入路径混在同一个会话里。 + +归档 Session ID 必须由插件独占。如果发现同 ID 已成为 live Session、归属不符或已有外部写入,立即暂停该分段并报告冲突,不向活跃 Agent 的持久化日志旁路追加。第一版只支持一个 Host 写入同一套记录;不声称支持多进程共享写入。 + +### 7.2 去重与异常写入 + +- 主身份使用机器人、群和平台消息 ID。相同消息重放不重复计数;不同正文的同 ID 不能直接当成新消息。 +- 去重信息可从对应日期的归档事件重建,不能只依赖当前有限长度的 `seenMessageIds` 或进程内缓存。 +- 同群、同段写入串行;批量写入有时间、条数和内存上限。其他群不被一个群的失败无限阻塞。 +- 写入返回不确定结果时,先读取并核对已持久化事件,确认成功前缀,再补剩余部分;不能盲目重试整个批次。 +- 创建和管理元数据之间中断时,依据预留 ID、Header 及首条归属事件恢复;无法证明归属的日志不自动接管。 +- 队列溢出、磁盘错误和无法恢复的记录失败应暂停或标注缺口;不得显示为正常完整记录。 +- 每群总归档容量也设上限;达到上限时暂停新正文采集并告警,不通过删除旧 Harness 记录腾出空间。 + +程序或机器在批次真正持久化前崩溃,仍可能丢失已收到但尚未保存的消息。该方案不作端到端零丢失承诺,也不依赖 WhatsApp 必然重发。 + +## 8. 消息接收、修订与媒体 + +### 8.1 接入位置 + +在 WhatsApp 事件接入层增加仅供已授权群使用的记录回调,先进行记录授权和归一化,再进入记录队列。原有普通问答回调和访问控制继续保留。 + +记录入口需要看见 `notify` 和允许处理的 `append` 事件。现有对旧 `append` 的过滤不能原样套用到归档,否则重连后的消息会在到达存储前丢失;新增记录分支仅接收启用阶段内、时间范围合法且未保存过的消息。它不触发这些补到消息的旧命令或旧总结请求。 + +启用记录的生效时刻、连接代际和作用域代际必须明确保存。尚未准备好归档服务时不报告“正在记录”;启动过渡期使用有界队列或明确报告缺口。 + +### 8.2 内容处理 + +| 类型 | 第一版处理 | +| --- | --- | +| 普通文字、富文本中的文字 | 保存正文与必要元数据 | +| 图片或文件说明文字 | 可保存可见说明,标注未解析媒体 | +| 无说明的图片、语音、视频、文件 | 仅保存类型占位和统计信息,不下载正文 | +| 引用消息 | 保存引用 ID;不重复复制引用正文,不借引用导入其他聊天 | +| 表情反应、状态广播、频道消息 | 不作为群聊正文收集 | +| 阅后即焚、自动消失消息 | 不保存内容;启用自动消失的群不允许开启记录 | +| 机器人自己发送的总结或其他回复 | 根据出站消息身份排除,避免回声和重复总结 | + +不能把 `fromMe` 全部排除,因为用户可能直接使用绑定账号在群中发言。机器人出站 ID 要在发送前预留并持久化,重启后仍能区分自动回复和人的发言。 + +隐私类型判断必须在拆解消息包装和提取说明文字之前完成,不能只排除图片载荷却保存阅后即焚图片的说明。启用时检查群的自动消失设置,并监听设置与成员变化;机器人被移出群或群开启自动消失后暂停采集与发送。无法可靠确认这些状态时不报告可以安全记录。 + +### 8.3 编辑与撤回 + +记录收到的编辑、撤回事件,使用修订事件关联原消息。查询时按快照内的最后有效版本生成摘要,已撤回的内容不再参与新摘要。离线期间未收到的修订无法凭空恢复,需要说明记录基于实际观察。 + +Harness 日志是追加式存储,修订事件不会物理擦除旧正文,已经生成的摘要也不会自动撤回。若使用场景要求撤回即彻底删除,本方案不满足这一要求,不能通过隐藏 UI 或加一条删除标记来宣称已删除。 + +## 9. 总结指令、日期与快照 + +### 9.1 支持的入口 + +```text +@机器人 总结今天 +@机器人 总结昨天 +@机器人 总结 2026-08-28 +@机器人 /summary 2026-08-28 09:00-12:00 +``` + +第一版使用明确的命令解析和中文别名,不先调用模型判断任意自然语言是否要总结。其他成员必须通过真实提及或回复已确认的机器人消息触发;绑定账号本人可使用相同的明确命令,不要求能 @ 自己。 + +未来日期、非法日期、跨日时间段和存在歧义的本地时间返回清晰用法说明。夏令时地区按 IANA 时区计算,不把自然日固定当成 24 小时;重复或不存在的小时不能静默猜测。 + +### 9.2 固定查询范围 + +1. 从真实入站事件固定机器人、群、请求者、请求 ID 和收到时间。 +2. 校验该群记录授权及请求者总结权限。 +3. 在同群队列设置屏障,等待请求之前已接收的消息得到保存或明确失败结果。 +4. 固定分段列表及各段已保存序号,形成不会随新消息增长的快照。 +5. 将群时区的日期范围转换为 UTC,按原始消息时间筛选,再折叠快照内收到的修订。 +6. 排除本条总结命令、已知机器人回声和未支持的正文类型;按发送时间和稳定次序排列。 + +今天的上限是本次请求时刻,指定过去日期使用该日结束边界。查询既受时间范围限制,也受快照序号限制;请求后才补到的旧消息留给下一次总结,不让本次结果不断变化。 + +没有记录、当天中途启用、已知离线区间或保存失败,都由程序生成说明。全日持续显示连接正常也不能证明绝无漏收,结果统一表述为“基于已收录消息”。 + +## 10. 摘要执行、费用与输出 + +### 10.1 调用方式 + +每次摘要任务创建新的、属于本群作用域的摘要会话,通过现有 Harness 客户端提交固定说明和该次快照。这样不会无意带入其他群、其他日期或普通问答的上下文。 + +读取和筛选由 dsh-im 完成,模型不需要获得“查询任意会话”的工具。群消息作为不可信资料提供,明确禁止把其中的要求当作当前任务指令。作者、时间和引用标签由程序装配,外部昵称不能破坏数据边界。 + +### 10.2 工具执行限制 + +优先使用经过验证的无工具摘要 Preset,并通过现有 `ctx.tools.guard()` 对本功能的摘要会话设置拒绝工具执行的规则。是否属于摘要会话根据插件创建记录及真实执行上下文判定,不根据模型参数或提示词中的标记判定。 + +保护必须在首次 prompt 前生效,不能只要求模型“不要调用工具”。Shell、文件读写、跨会话查询、浏览、发消息和创建自动任务等均不需要由摘要模型执行。正常机器人会话不受这个规则影响。 + +插件卸载、热重载或关闭功能时,先取消并等待摘要任务退出,再释放保护。原有摘要会话重新打开或恢复时的权限也必须在兼容性验证中覆盖;若目标 Host 无法可靠保证这一点,就不能以有工具的普通 Agent 作为默认实现。 + +### 10.3 长群聊 + +小记录集一次总结;超出可靠上下文预算时,按时间顺序分块提取,再汇总。每条已纳入的文字消息必须覆盖到某个分块,不能悄悄只取最近若干条。 + +每个分块使用独立受限会话,最终汇总使用本次摘要会话。归档原文不因分块而改变。若无法获得准确 token 计数,使用保守输入预算并处理模型的上下文超限错误,不伪称精确计数。 + +分块数、总输入预算、最大输出、运行超时和并发必须设上限。超出上限时提示缩短时间范围或由管理员调整预算;不能把部分结果标成全天完整总结。普通记录不消耗模型 token,总结时按实际输入、分块及输出消耗。 + +### 10.4 输出格式 + +```text +群聊总结|2026-08-28 00:00–18:30|Asia/Taipei +基于已收录的 186 条文字消息;另有 7 条媒体消息未解析。 +记录说明:今天 09:15 开始记录,无法覆盖此前讨论。 + +一、主要讨论 +二、已经达成的结论 +三、待办事项:事项 / 明确提到的负责人 / 明确提到的截止时间 +四、待确认问题或分歧 +``` + +条数、时间和覆盖说明由程序计算,不让模型编造。负责人、期限和结论必须来自记录;没有明确说明就标注未明确。可附发言时间、显示名称和短消息引用,不虚构 WhatsApp 消息链接。摘要仍是模型生成内容,重要事项应回查原文确认。 + +发送前再次核验群、作用域和授权仍有效。只回复发起请求的原群,不允许正文中的群名、号码或“转发给某人”改变目标。 + +## 11. 并发、发送与状态 + +| 情况 | 处理规则 | +| --- | --- | +| 同一请求重复投递 | 以平台请求 ID 识别已创建任务,不重复调用模型 | +| 同群同时请求多次 | 第一版同群只执行一个摘要;后续请求返回忙碌提示,不无限排队 | +| 生成期间群里继续聊天 | 继续记录;当前摘要保持原快照 | +| 跨群并发 | 使用独立队列和会话,并受机器人级并发预算约束 | +| 模型超时或失败 | 保留原记录,报告失败;重试前核验任务状态,避免重复运行 | +| 发送失败且明确未发送 | 可以使用已生成结果有限重试,不再调用模型 | +| 发送结果不确定 | 标注待确认,不盲目重复发送;不承诺平台级严格一次交付 | +| 插件重启 | 恢复记录索引和任务状态;未完成摘要标记中断,不自动重新付费生成或补发 | +| 记录服务失效 | 状态进入异常,不能用内存数据冒充已持久化历史 | + +出站拆分复用 WhatsApp 已有的长度限制和发送方式。任务状态至少区分读取、生成、发送、成功、失败、中断和发送待确认,后台错误不泄露群正文或凭据。 + +## 12. 隐私、保存期限和关闭行为 + +这是复用 Harness 会话存储必须接受的限制:当前统一接口没有删除记录的能力。 + +| 用户操作或要求 | 第一版的真实效果 | +| --- | --- | +| 关闭记录 | 停止新消息采集,不删除旧记录 | +| 删除机器人 | 移除账号接入与授权;历史会话仍在 Harness 后端中 | +| 消息被撤回 | 新查询不再采用原文,但旧原文及已生成摘要仍可能保留 | +| 只查询最近 30 天 | 仅限制可查询范围,不是物理保留期限 | +| 要求 30 天后自动彻底删除 | 当前主方案不支持,不能包装成已支持 | +| 使用云端模型 | 被选中的原文或分块内容会发送给该模型服务商,并进入摘要会话 | + +因此,第一版不显示“自动删除”“清空全部记录”等无法兑现的按钮。可以提供停止记录、容量告警和管理员定位说明,但不通过直接修改 Harness 文件实现删除。若业务必须有严格的删除期限,需另选插件自管存储方案,不能继续声称这两种方案完全等价。 + +关闭操作先停止新入队,处理或明确取消已接收队列,等待正在进行的持久化操作结束,再返回成功;生成和发送任务同时取消。已提交的记录不会因关闭而回滚。 + +插件管理元数据使用严格文件权限;Harness 后端的权限与加密由其实际配置决定,不因“本地保存”就宣称已加密。dsh-im 的按群授权不等于为整个 Harness 新增行级访问控制:本机管理员和其他具有宿主权限的组件仍属于受信范围。 + +发起者白名单限制的是谁能请求总结,输出仍对原群当前成员可见。后来加入的成员也可能通过摘要看到此前的讨论;第一版不模拟每位成员的历史可见范围,启用时需确认这种分享方式适合该群。 + +## 13. 拟修改的 dsh-im 文件 + +以下是实施时的范围,不代表本次已经修改: + +| 文件或目录 | 职责 | +| --- | --- | +| `src/channels/whatsapp/whatsapp-web-session.mjs` | 授权记录分支、原始时间、重连事件、编辑与撤回订阅 | +| `src/channels/whatsapp/whatsapp-runtime.mjs` | 记录与普通响应分流、真实提及判断、出站回声识别 | +| `src/channels/whatsapp/whatsapp-controller.mjs` | 群选择、启停、状态及生命周期 | +| `plugin-src/host/channels/whatsapp/production.mjs` | 注入同 Host 的持久化、摘要和权限依赖 | +| `plugin-src/host/channels/whatsapp/rpc.mjs` | 独立的群配置、状态和按需预览 RPC,沿用现有 RPC 授权 | +| 新增插件侧群记录服务 | 配置和元数据、归档会话所有权、批量写入与恢复 | +| 新增共享的群记录与摘要逻辑 | 最小消息语义、日期范围、快照、去重和摘要格式;不先搭建九渠道框架 | +| 新增摘要执行适配 | 创建专用摘要会话、工具保护、分块、取消和结果发送 | +| `plugin-src/client/channels/whatsapp/` | 群授权设置、状态、记录预览及风险说明 | +| `test/channels/whatsapp/` 及相关共享测试 | 优先扩展现有用例;独立存储与摘要模块在没有自然归属时新建测试文件 | +| `README.md`、`README.en.md`、`CHANGELOG.md` | 实现后同步配置、使用方式、限制及升级说明 | + +若需要调用 Harness 包导出的常量或类型,使用公开导出并在构建时保留正确的宿主依赖关系;不导入本地源码路径,不捆绑另一份宿主状态服务。对运行版本的支持范围以实际验证结果列出,不能仅靠方法存在就宣称兼容。 + +## 14. 与旧版本及其他功能的关系 + +- 旧机器人和新机器人都默认关闭,升级不会自动收集群消息。 +- 配置保存与启用失败不改变普通聊天访问模式,不把失败的功能退化为开放访问。 +- 现有 `/history` 仍查看普通对话历史,不改变其私聊范围和条数语义。 +- 原有普通问答、模型切换、审批、文件与图片收发、工作区和会话命令保持现状。 +- 无兼容持久化服务、没有可靠工具限制或显式远程 HTTP 接入时,设置页显示具体不可用原因;其他 WhatsApp 功能继续运行。 +- 停用或卸载 dsh-im 后,兼容的 Harness 读取器能够忽略纯信息事件;只有 dsh-im 理解其群记录含义。 +- 不自动迁移已生成的群记录到其他后端,也不在回滚时删除用户数据。 +- 管理元数据保留已删除账号的必要历史归属信息,但不保留认证材料,避免旧记录变成无法定位的孤立日志。 + +## 15. 实施顺序与必须先通过的验证 + +### 阶段 0:验证“不改 Harness”路径 + +在目标安装版本和临时数据目录中验证,不能在用户真实会话里试写: + +1. dsh-im 作为已构建插件可以获取所需公开服务及格式版本,不依赖源码别名。 +2. 创建仅有插件信息事件的独占归档会话,通过服务写入后可读回。 +3. 关闭整个临时 Host,再启动新 Host,原记录、顺序和去重信息仍能恢复并继续追加。 +4. JSONL 与 SQLite 后端均覆盖;缺少对应后端的版本不宣称已测。 +5. 插件不加载时,带兼容标记的记录不会使宿主拒绝其他正常会话;原生列表及日志预览表现明确。 +6. 自定义记录不进入模型消息历史,已有全文检索不会被错误当作归档检索入口。 +7. 摘要执行与恢复时,工具保护在真正的执行入口生效;测试恶意群文字不能引发工具调用副作用。 +8. 专用归档被误激活、多个写入者、重载和尾部异常均能停止或安全恢复,不污染普通 Agent 会话。 + +任何关键项不通过,都先报告限制,不修改 Harness 核心、不伪装事件、不直接改存储文件,也不未经确认切换成另一套聊天数据库。 + +### 阶段 1:授权与记录闭环 + +实现群配置、入站分流、纯文字归档、时间字段、分段、去重、编辑撤回处理、覆盖状态和管理员预览。先证明记录正确与重启恢复,再接模型。 + +### 阶段 2:总结闭环 + +实现明确指令、发送者白名单、按时区读取快照、受限摘要执行、长文本分块、引用与覆盖说明、原群发送及失败状态。 + +### 阶段 3:真实群验证与发布 + +在知情同意的测试群中验证未 @ 记录、@ 总结、重启、跨日、断连、重复消息和多群隔离。先限定已测试的同 Host 安装范围,完善帮助与发布说明,再开放使用。 + +## 16. 验收清单 + +| 场景 | 必须观察到的结果 | +| --- | --- | +| 未启用、群未授权 | 不保存正文、不创建摘要、不产生额外回复 | +| 仅自己普通访问模式,另行授权指定群 | 该群可记录与授权总结,其他私聊和群权限未扩大 | +| 其他成员未提及或回复机器人的普通群文字 | 保存一次,不调用模型、不发送聊天状态反馈 | +| 白名单成员 @ 总结 | 基于本群指定日期已收录记录回复 | +| 非白名单成员、伪造昵称或号码 | 不读取记录、不调用模型,不能绕到普通 Agent 总结 | +| PN/LID 身份混用 | 仅可信映射通过;未知映射拒绝,@ 判断不靠昵称 | +| 重复投递、重连补到消息 | 原始记录去重,历史总结命令不被重新执行 | +| 用户本人发言与机器人出站回声 | 前者正常记录,后者不触发循环,重启后仍成立 | +| 日期边界、跨 UTC 日期、夏令时 | 按保存的群时区查询,不混入相邻日期 | +| 一天中途启用或出现断连 | 说明真实记录范围,不冒充全天覆盖 | +| 编辑、撤回、媒体、自动消失设置变化 | 使用已观察的有效版本,媒体标注,受限内容停止采集 | +| 模型上下文不足 | 分块完整覆盖或明确拒绝,不静默截断 | +| 群文字包含执行命令或泄露其他群的要求 | 仅作为资料,不执行工具,不扩大读取范围 | +| 摘要期间继续收消息 | 新消息继续保存,本次快照不变化 | +| 切换工作区、关闭或删除机器人 | 取消旧范围任务,不向错误群发送,不声称已删除历史 | +| 磁盘错误、队列溢出、写入返回不确定 | 状态可见,恢复核对已提交前缀,无盲目重试 | +| 进程重启、插件热重载 | 可恢复记录,不重复生成或补发,不丢失工具保护 | +| 远程 HTTP 或不兼容宿主 | 仅新功能不可用,原因明确,原有功能不受影响 | +| 回归 | 既有 WhatsApp 私聊、群聊及其他渠道行为未改变 | + +实现后先运行受影响的存储、时间、权限、队列和 WhatsApp 测试,再运行仓库现有构建与包校验。真实 WhatsApp、真实安装版本和完整重启验证分别报告,不能用 mock 或现有后端测试代替整体验收。 + +## 17. 本次方案的证据与未完成项 + +本次只新增方案文档,没有修改 dsh-im 功能代码或 Harness 代码。检查来源为本机工作区: + +- dsh-im 基线提交:`8c6c31a1b668f307b36686c78a1d51ea5b0e04d0`。工作区已有其他未提交改动,本方案未修改它们。 +- Harness 所检查的关键源码基线:`47f943859bef60e4160492346772ded9b24f765a`;关键读取文件无本地改动。它不自动代表当前已安装运行版本。 +- 持久化公开 API:`packages/session/session-persistence/src/index.ts`。 +- 未知事件兼容校验:`packages/core/session/src/known-event-types.ts`、`packages/session/session-persistence/src/coordinator.ts`。 +- Session 追加行为:`packages/core/session/src/index.ts`。 +- 原生列表的未开始会话语义:`packages/host/apiproxy/src/api/sessions.ts`。 +- 自定义事件检索限制:`packages/session-query/session-query/README.md`。 +- 工具保护接口:`packages/core/tools/README.md`。 +- 后端格式、恢复和删除限制:`packages/session/session-persistence-jsonl/README.md`、`packages/session/session-persistence-sqlite/README.md`。 + +已在本机 Harness 源码工作区运行现有定向测试: + +```sh +pnpm exec vitest run \ + packages/session/session-persistence-jsonl/tests/jsonl.spec.ts \ + packages/session/session-persistence-sqlite/tests/sqlite.spec.ts \ + -t 'rejects malformed persisted message events|unknown event type' +``` + +结果为 **2 个测试文件通过,4 条测试通过,247 条跳过**。这验证了两种后端已有的未知事件兼容规则,以及无执行轮次的独立插件记录可经 `inspect` 和 `readFrom` 读回,**没有验证本方案尚未实现的插件、真实群接入、整机重启、摘要质量或已安装版本兼容性**。 + +WhatsApp 接入仍使用非官方 Baileys;账号限制、连接变化和平台兼容性风险继续存在,不能因为记录放进 Harness 就消除这些风险。使用范围应符合群成员授权与平台要求,参见 [Baileys 项目声明](https://github.com/WhiskeySockets/Baileys#disclaimer)。 + +## 18. 建议采用的第一版决策 + +采用“同 Host 插件 + 明确群授权 + Harness 持久化服务中的专用群记录会话 + 受限摘要会话 + dsh-im 记录预览”。普通聊天与原有命令保持原样。 + +该方案满足“不改 Harness 核心”和“聊天原文保存在 Harness 会话存储中”。它不把“与原有问答共用同一个可执行 Session”“在原生聊天窗口逐条显示”“自动物理清除”和“远程 HTTP 支持”混入第一版承诺。先完成兼容性验证,通过后再实施功能。