makecindy / makecindy/cindy

Telegram 双 Bot 统一消息桥:消息池收敛 + TurnPresenter + 内容面/投递面重切

Open
#1,855 9 comments 0 reactions 1 assignee Claimed by @zqchris View on GitHub
Dominant language
TypeScript
Stars
2.7k
Forks
395
Avg merge
21h 48m
Merged PRs (30d)
776

Description

# Telegram 双 Bot 统一消息桥:消息池收敛 + TurnPresenter + 内容面/投递面重切

## 背景与结论

个人 Telegram bot 与官方 Telegram bot 是两条几乎平行的产品栈,呈现能力互有先后但不共享:个人 bot 的消息打印体验(#1636 的 429/编辑失败转新发/图片锚点迁移)回灌不到官方;官方 bot 的终稿投递缺生命周期层(#1733 终稿静默丢失)。逐层核验代码后确认:

1. **重复不是弥漫性的,是三个有注释自首的点**(正文累积语义、xdtRefs 副本、交互卡合成),收敛已经开始(hook turnObserver 已复用 `im/shared/turnActivity` / `turnRetryNotice`)。
2. **消息池的存储层已经统一**:两个 bot 写同一张 `hook_group_messages` 表,靠 provider 命名空间隔离(官方 `telegram:` / 个人 `telegram-personal:`)。重复的是池上面的拼装/游标逻辑(两份约 200 行、80% 同构、GC 政策已漂移),且这是 `im/telegram/groupWindow.ts` 头注写明的"沙盒先分叉、收敛后回灌"计划——现在到收敛时刻。
3. **官方 bot 的切分轴用错了**:hook 协议按"turn 阶段"切(客户端产文本快照、服务端管消息形态),导致任何呈现改进都要协议+服务端+客户端三仓联发。正确的轴是**内容面(客户端) vs 投递面(服务端)**。服务端今天揽了 4694 行 controller、386 行产品文案 i18n、独立的 markdown/chunk 渲染栈(全产品第三套 Telegram 渲染)——这些该上收客户端;授权、全局限速、终稿必达、离线自治四样投递面能力留服务端。

目标:官方与个人 bot 共享同一个"呈现大脑",消息形态(删草稿重建、嵌套卡片回复、锚点、reaction、媒体组)由客户端全权决定;此后呈现迭代只发客户端版本。

## 现状证据(代码锚点)

| 事实 | 位置 |
|---|---|
| 正文累积/收口语义两份实现(isFinal 逐条契约、定稿段按消息切开、claude fallbackTail 自成段) | `apps/desktop/src/main/hook-control/turnObserver.ts:207` vs `apps/desktop/src/main/im/shared/turnRunner.ts`(2895 行) |
| xdtRefs 正则副本,注释明言"两处同步" | `apps/desktop/src/main/hook-control/outbound.ts:12` vs `packages/lizi-im/src/xdtRefs.ts` |
| 交互卡合成人肉对齐 | `apps/desktop/src/main/hook-control/interactions.ts:15` vs `apps/desktop/src/main/im/shared/cardBuilders.ts` |
| 消息池同表不同逻辑,GC 政策相反(官方 500/群+1万/身份;个人永久保留) | `apps/desktop/src/main/hook-control/groupWindow.ts` vs `apps/desktop/src/main/im/telegram/groupWindow.ts`;表 `apps/desktop/src/main/localDb/schema.ts:1492` |
| 池无全文检索;入库即截断 500 字(与"永久记忆"目标冲突);游标内存态重启即丢 | 同上两个 groupWindow + `ENTRY_TEXT_MAX_CHARS` |
| 协议把消息形态钉死在服务端:turn.progress 只是纯文本快照 | `cindy-protocol/packages/slack-hook-protocol/src/types.ts`(TurnProgressPayload) |
| 服务端呈现业务密度:controller 4694 行(progress stream 状态机/消息路由锚点/占位消息生命周期,消息操作调用点 117 处)、i18n 386 行、markdown+chunk 独立渲染栈 | `cindy-server/telegram-hook-server/src/telegram/{controller,i18n,markdown,chunk}.ts` |
| 已做对、要保留的模式:交互决策语义留客户端("新 kind 不需要 server 升级")、群中继零内容驻留、HOOK_FEATURE_* 能力协商(#1383) | `hook-control/interactions.ts` 头注、`groupWindow.ts` 头注 |

## 目标架构(四层)

```text
┌────────────────────────────────────────────────────────┐
│ L0 InboundPool 统一消息池(表已共享,收敛逻辑) │
│ 单模块参数化命名空间策略{来源适配/GC/reply注入/lane} │
│ 入库存全文(截断只在注入 prompt 时) + FTS5 检索 │
│ + agent 检索工具 + 游标落库 │
├────────────────────────────────────────────────────────┤
│ L1 TurnPresenter 呈现大脑(纯逻辑,客户端) │
│ AgentEvent → 正文累积/过程区时间线/重试提示/ │
│ 节流快照/终稿文档(正文+附件清单+图片锚点) │
├────────────────────────────────────────────────────────┤
│ L2 DeliveryDriver 每渠道一只手(共契约不共实现) │
│ 个人: 直连 Bot API(send/edit/delete/rebuild/react) │
│ 官方: msg.op 协议帧 → 服务端哑执行 │
├────────────────────────────────────────────────────────┤
│ L3 交互与会话语义 卡片合成/决策映射/会话生命周期策略收敛 │
└────────────────────────────────────────────────────────┘
```

服务端瘦身后只保留投递面:① lane 授权校验(多租户,op 的 chat 必须落在该设备绑定内);② 全局限速队列 + retry_after 退避(单 bot token 跨租户共享预算,必须中心化);③ turn.final durable outbox(终稿文档必达,修 #1733);④ 离线自治固定模板。**过程可丢、终稿必达**:过程操作走轻代理,终稿走 outbox,两通道语义不同不合并。

## 实施切分(两刀)

### 第一刀:纯客户端,一个版本带完,零服务端/协议改动

- [ ] L0:两份 groupWindow 合并为单模块 + 命名空间策略参数;入库存全文;FTS5 索引 + 检索工具;游标落库。本地 DB 迁移为纯增量(加列/加 FTS 表),老数据照读。
- [ ] L1:抽取 TurnPresenter,合并 turnObserver 与 turnRunner 的累积/节流/快照语义。**搬运式抽取,不重写**——#1272/#1636 的实踩教训全部嵌在现有注释与测试里,每搬一块带测试走。
- [ ] L3:交互卡合成收敛为一份;xdtRefs 从 `@cindy/im` 导出,删 hook 侧副本。
- [ ] 前置独立项:#1733 的服务端 outbox 可先行(服务端内部实现,wire 不可见,不等本计划)。

### 第二刀:协议动词 + 服务端瘦身(服务端最后一次大版本)

- [ ] 协议:新增带作用域的消息操作动词(msg.send/edit/delete/media/react/card),终稿仍为文档交接;按 HOOK_FEATURE_* 能力协商,**仅 telegram provider 先行**。
- [ ] 服务端:op 执行器(授权+限速) + durable outbox;**保留旧 turn.progress 渲染路径**给老客户端,双路径并存。
- [ ] 客户端:官方 driver 协商到新能力走直控,协商不到降级旧路径。
- [ ] 发布顺序:服务端先发(对老客户端纯增量)→ 观察 → 客户端跟发。双路径期结束后服务端删渲染栈(第二次发版,纯删除)。

## 红线(防跑偏,违反任何一条即阻断)

1. **Slack / X 行为零回归。** 服务端 Slack 渲染路径不动;新动词不向 slack/x provider 下发;客户端 L1 抽取路过 hook 共用代码时必须行为保持,X 的 finalSegment(一条公开回帖)与 Slack 卡片语义以现有测试为锚,回归即回滚。不迁移、不顺手优化 Dash 重构过的 Slack 消息层。
2. **老版本不出大问题。** 所有协议改动走能力协商;服务端任何版本必须同时服务新旧客户端;客户端遇老服务端必须可降级。
3. **搬运不重写。** turnRunner(2895 行)/cardActionHandler(1396 行)里的实踩注释是资产,抽取时保留语义与注释出处。
4. **消息池隔离不放松。** provider 命名空间隔离从"约定"升级为"模块入参",任何一边的 GC/清理逻辑不得触碰另一边命名空间。
5. **群消息零服务端驻留**架构不变(内容只落用户设备)。
6. 涉及 wire protocol 的改动先读 `docs/dev-rules/protocol-and-submodules.md`,与服务端仓协调发版。

## 关联

- #1733 官方 bot 终稿静默丢失(P0,outbox 为其修复位)
- #1636 个人 bot 429/编辑失败恢复(已合并,能力将经 L2 契约共享给官方)
- #843 group-relay-v1(消息池起源)
- #1272 hook turnObserver 收口语义(L1 抽取的语义基线)
- #1383 provider hello 能力协商(第二刀的兼容轨道)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.