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 支持”混入第一版承诺。先完成兼容性验证,通过后再实施功能。