fix(im): 个人 Telegram bot 补齐受保护群内容的隐私边界 - #2103
Conversation
官方 bot 的服务端对 has_protected_content 有完整 fail-closed 处理(带标消息 不中继群窗口、被引消息受保护则原文与附件都不外传);个人 bot 全仓一个字都没 处理 —— 开了「禁止保存内容」的群, 消息照样进本地群历史池, 而群历史检索工具 合并后这些内容还能被检索出来转述到别处。这是两栈行为差异里唯一的隐私逃逸。 - TgMessage 补 has_protected_content 字段声明(Telegram 只在保护开启时下发); - emitGroupWindow 作为入窗唯一出口执行判据: 带标即整条不落池。判据放在唯一 出口而非各调用点, 让将来新增的入窗路径默认受同一条边界约束; - 两个入站 emit 点与自回流(bot 自己在受保护群的回复)一并透传消息对象; - replyContextOf 与引用附件采集: 被引消息或触发消息受保护时, 原文与附件都不 进 prompt。 触发判定不受影响: 受保护群里 owner @ 机器人照常起一轮, 只是不留历史、引用不 带原文。官方 bot、Slack/X 与其余 6 个个人渠道零改动。 Refs makecindy#1855 Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
|
| Filename | Overview |
|---|---|
| packages/lizi-im/src/telegram/index.ts | 将原始 Telegram 消息传入统一群窗口出口,并在该出口按保护标志阻止入窗。 |
| packages/lizi-im/src/telegram/inbound.ts | 过滤受保护的引用正文与附件,并把保护状态传播到统一 IM 消息事件。 |
| apps/desktop/src/main/im/shared/turnRunner.ts | 在保持 agent turn 正常执行的同时,阻止受保护触发消息进入 transcript 和会话标题。 |
| apps/desktop/src/main/im/shared/messageHandler.ts | 将渠道事件携带的 protectedContent 状态传入 turn runner。 |
| packages/lizi-im/src/types.ts | 为跨层 IM 消息事件声明受保护内容标志及其非持久化语义。 |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[Telegram 受保护群消息] --> B[normalizeMessage]
B --> C[IMMessageEvent protectedContent]
C --> D[createMessageHandler]
D --> E[runAgentTurn]
E --> F[正常派发给 Agent]
E --> G[跳过 transcript 持久化]
E --> H[跳过会话标题生成]
A --> I[emitGroupWindow]
I --> J[跳过本地群历史]
A --> K[replyContextOf / 引用附件采集]
K --> L[跳过受保护引用内容]
Reviews (7): Last reviewed commit: "Merge remote-tracking branch 'upstream/m..." | Re-trigger Greptile
There was a problem hiding this comment.
Pull request overview
本 PR 为个人 Telegram bot 补齐 has_protected_content(群“禁止保存内容”)信号的隐私边界处理,使个人侧与官方 bot(服务端)在“受保护内容不进入群历史/不外传引用原文与附件”语义上对齐,修复受保护群内容可能落入本地群历史池并被检索/转述的隐私逃逸路径。
Changes:
- 在个人 Telegram 栈中声明
TgMessage.has_protected_content,并将群窗口入窗的保护判据集中到emitGroupWindow(唯一入窗出口)执行。 replyContextOf与引用附件采集增加保护判据:受保护群内引用原文与引用附件不进入 prompt。- 新增/补充单测覆盖:受保护群消息不入窗但仍可触发 turn;受保护引用不进 prompt。
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/lizi-im/src/telegram/index.ts | emitGroupWindow 增加保护判据并在入站与自回流路径传入 source,用于阻断受保护内容入群窗口历史 |
| packages/lizi-im/src/telegram/inbound.ts | 引用上下文与引用附件采集增加受保护判据,避免引用原文/附件外传进 prompt |
| packages/lizi-im/src/telegram/api.ts | 声明 has_protected_content 字段并补充其语义注释 |
| packages/lizi-im/src/telegram/tests/telegramIM.test.ts | 增加受保护群“不入窗但可触发”的行为用例 |
| packages/lizi-im/src/telegram/tests/inbound.test.ts | 增加受保护引用“不进 prompt”的用例 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
与同批 PR 的合并顺序(已实测)本 PR 与 #2103(受保护群隐私边界)、#2111(离线积压时效闸)改同一个文件。三方串行合并时有一处一行的冲突,位置固定:
实测:三个分支合到一起后 合并顺序自由,先合哪个都行。 |
原注释写「带标的内容不得落进本地群历史池、不得进 prompt」, 会被读成触发消息 本身也被 fail-closed —— 而实际行为是照常起 turn。 拦的是「内容留存」不是「响应」, 分三条写清: - 带标的群消息不落本地历史池, bot 自己的出站回复也不回流进窗口; - 被**引用**的消息带标时, 它的正文与附件不进 reply_context; - owner @ 机器人仍照常起 turn —— 触发消息是用户此刻说给 bot 的话, 不是要被 留存的群内容, 它的正文照常进 prompt。 只改注释, 无行为变更。 Refs: makecindy#1855 Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b7fdc35c8c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
packages/lizi-im/src/telegram/tests/telegramIM.test.ts:352
- 新增用例覆盖了“入站受保护群消息不入窗但仍触发 turn”,但 PR 描述里同样强调“bot 自己的出站回复也不回流进窗口”。目前测试只覆盖入站侧,没有覆盖出站回声(recordOwnEcho → emitGroupWindow)在受保护群里也会被挡住;建议补一条用例:在受保护群触发后让 bot 出站一次,并断言窗口里不会出现 isBot 条目。
it('受保护群的消息一个字都不落本地窗口, 但仍可照常触发一轮', async () => {
// 「禁止保存内容」的群: 与官方 bot 服务端「has_protected_content 的消息
// 不中继」同一条边界 —— 本地池是个人 bot 的记忆, 不能成为绕过它的通道。
// 触发判定不受影响: owner @ 机器人照常起 turn, 只是不留历史。
const events: IMMessageEvent[] = [];
const windowEntries: TelegramGroupWindowEntry[] = [];
im.onMessage((e) => events.push(e));
im.onGroupWindowMessage((e) => windowEntries.push(e));
await connect();
api.pushUpdates([
groupMessage({ text: '机密闲聊', fromId: 222, messageId: 40, hasProtectedContent: true }),
groupMessage({
text: '看一下',
fromId: 111,
messageId: 41,
mentionBot: true,
hasProtectedContent: true,
}),
// 同一用例里放一条未保护消息, 证明判据只挡带标的那些。
groupMessage({ text: '普通闲聊', fromId: 222, messageId: 42 }),
]);
await vi.waitFor(() => expect(events).toHaveLength(1));
await vi.waitFor(() => expect(windowEntries).toHaveLength(1));
expect(events[0]).toMatchObject({ senderId: 'g/-100200', text: '看一下' });
expect(windowEntries[0]).toMatchObject({ messageId: '42', text: '普通闲聊' });
expect(windowEntries.some((e) => e.text.includes('机密'))).toBe(false);
});
packages/lizi-im/src/telegram/index.ts:1470
- 这里把受保护内容的判据集中在 emitGroupWindow 里是对的,但 source 参数目前是可选的(source?: ...)。因为 emitGroupWindow 是“唯一出口/唯一执行点”,把 source 设为可选会让未来新增调用点在漏传 source 时静默退化为“未保护”,从而绕过隐私边界。建议把 source 改成必传(允许显式传 undefined),用类型强制所有调用点做出选择。
private emitGroupWindow(
entry: Omit<TelegramGroupWindowEntry, 'botId'>,
source?: Pick<TgMessage, 'has_protected_content'>,
): void {
if (source?.has_protected_content === true) return;
群历史池已在渠道侧(emitGroupWindow)拦下, 但那只是**第一条**路径 —— 触发消息 仍沿完整事件链走到 persistUserMessage, 正文与附件照样进长期会话存档。保护边界 被从旁边绕过去了。 沿 inbound → messageHandler → turnRunner 补上「不持久化」语义: - `IMMessageEvent.protectedContent` 可选标记, 只有 Telegram 会置(其它渠道不 设置, 行为逐字节不变); - turn **照常起、照常派发给 agent** —— 用户 @ 机器人说的话是他此刻要说给 bot 的, 不留存不等于不响应; - 落库点跳过 persistUserMessage, 正文与附件都不进存档。 新增 2 条回归: 受保护消息不落库但照常派发; 未受保护消息照常落库(既有行为不变)。 仍在本 PR 范围外(引用非目标): 媒体仓的临时化 —— 受保护附件当前仍会为本轮 turn 下载落盘(agent 要看图), 让它随 turn 结束回收需要一套临时文件机制, 属「不改群 历史检索工具、消息池 schema、保留策略」的邻域, 单独一个 PR。本 PR 只保证它不 进会话存档。 验证: apps/desktop 与 @cindy/im typecheck 干净; packages/lizi-im 435 项、 apps/desktop IM 层 448 项全绿; 仓库根 pnpm test:unit 全绿。 Refs: makecindy#1855 Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cb7f9189aa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
packages/lizi-im/src/telegram/index.ts:1469
- emitGroupWindow 作为受保护内容的“唯一出口/唯一执行点”,但 source 参数仍是可选;一旦未来新增调用点忘记传 source,就会在受保护群里绕过 has_protected_content 判据写入本地窗口。既然当前所有调用点都能提供 TgMessage,建议把 source 改为必填,让编译期强制携带判据输入,避免隐私边界被无意打穿。
private emitGroupWindow(
entry: Omit<TelegramGroupWindowEntry, 'botId'>,
source?: Pick<TgMessage, 'has_protected_content'>,
): void {
|
@zqchris 👋 这个 PR 还有 7 条 review conversation 没 resolve(packages/lizi-im/src/telegram/api.ts / packages/lizi-im/src/telegram/index.ts / packages/lizi-im/src/telegram/inbound.ts / apps/desktop/src/main/im/shared/turnRunner.ts),auto-review 因此暂时跳过、没法继续审查 / 合并。 如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。 |
…content Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> # Conflicts: # apps/desktop/src/main/im/shared/__tests__/turnRunnerSendOutcome.test.ts # apps/desktop/src/main/im/shared/turnRunner.ts
|
@zqchris 👋 这个 PR 目前与 请在本地 merge 最新的 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/lizi-im/src/telegram/inbound.ts:307
- 目前 protectedContent 只阻断了「群窗口入窗」与「会话消息落库」两条路径,但附件在 normalizeMessage 阶段已被 collectMedia/downloadTelegramFile 写入持久目录:图片会走 host.media.cacheImage 进入 cindy-media 总仓,非图文件会落到 apps/desktop 的 userData/cc-agent/telegram-media。对“禁止保存内容”的群来说,这仍然是本地长期留存,可能与本 PR 强调的隐私边界不一致。建议在检测到 has_protected_content 时,对入站媒体改用可回收的暂存/临时目录并在 turn 完成后清理,或至少避免把受保护内容写入 cindy-media/telegram-media 持久缓存。
// 受保护群的消息照常起 turn, 但不得进任何长期存档 —— 群历史池已在
// emitGroupWindow 处拦下, 这个标记是给业务层会话存档的第二道。
...(m.has_protected_content === true ? { protectedContent: true } : {}),
review 指出的旁路: 受保护消息虽然不落 transcript, 但如果它是建会话或 /new 后 的首条消息, 渠道声明了 generatedTitlePrefix 时会先把原文交给标题生成、再把结果 持久化成会话标题。 标题是**长期记录**, 会一直挂在侧边栏上 —— 把「禁止保存内容」的正文摘要留在 那里, 与把它写进 transcript 是同一条边界被绕过; 而且主 turn 最终没被 provider 接受时照样会留下。两个标题生成分支(generateAndPersistFbotTitle 与 maybeGenerateImSessionTitle)共用同一个判据, 宁可停在草稿标题。 新增 1 条回归: 受保护的首条消息不触发起名。 同批 review 指出的另外三条(provider 会话上下文与输出落库、受保护附件落盘、 变更集锚点)超出本 PR 目标或需要产品决策, 已在各自线程说明并另立跟进 —— 本 PR 不假装它们已解决。 验证: apps/desktop typecheck 干净; src/main/im 453 项全绿。 Refs: makecindy#1855 Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com>
…content Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> # Conflicts: # packages/lizi-im/src/telegram/__tests__/telegramIM.test.ts
MagicLizi
left a comment
There was a problem hiding this comment.
Auto-review: 零问题,审查通过。
这次改了什么
摘要
个人 Telegram bot 此前完全没有
has_protected_content处理:开了「禁止保存内容」的群,消息照样落进本地群历史池;群历史检索工具合并后,这些内容还能被检索出来、转述到别的对话。官方 bot 的服务端对同一信号有完整 fail-closed 处理(带标消息不中继群窗口、被引消息受保护则原文与附件都不外传,见docs/telegram-hook-server.md「群消息中继」节)。本 PR 把这条边界补到个人侧,两栈行为对齐。这是 #1855 两栈功能盘点里唯一一条隐私逃逸级差异。
变更类型
fix缺陷修复范围
TgMessage.has_protected_content字段声明;emitGroupWindow作为入窗唯一出口执行判据;两个入站 emit 点与 bot 自回流透传消息对象;replyContextOf与引用附件采集的保护判据。这条边界关上了哪几条路径,哪几条还没有
review 沿着「受保护正文还能从哪流到长期存储」把整条链路走了一遍。下表是本 PR 的真实覆盖范围 —— 没关上的那几条不当作已解决,各自另立 PR:
hook_group_messages)persistUserMessage)sdkSessionId恢复)与该轮 assistant / tool 输出落库media.cacheImage/telegramMediaDir)实现要点
判据放在
emitGroupWindow这个唯一入窗出口,而不是各调用点:将来新增的入窗路径默认受同一条边界约束,不需要每处都记得加。字段缺失按未保护处理——Telegram 只在保护开启时下发它。怎么验证的
自动验证
IM 多渠道共享层逐渠道覆盖(本 PR 触及
packages/lizi-im,按硬门禁逐条列明):packages/lizi-im/src/telegram/**,官方链路走hook-control,其保护判据在服务端。src/main/hook-control全量回归绿。emitGroupWindow、replyContextOf、引用附件采集)都是 Telegram 专属,其它渠道各有自己的实现。@cindy/im全量 + desktopsrc/main/im全量回归绿。未执行的验证
真机受保护群的端到端目检未做——需要一个开启了内容保护的真实 Telegram 群。以 Bot API
has_protected_content字段语义 + 单测夹具(带标/不带标混合投递)覆盖代替。若要真机验证:建一个群 → 群设置里开「限制保存内容」→ 拉机器人进群 → 群里随便说几句 → 确认这些消息不出现在后续对话的群上下文里,且 @ 机器人仍能正常回答。风险
packages/lizi-im的既有文件本就不符合apps/desktop/.prettierrc(未改动的基线同样--check失败),因此本 PR 刻意不对这些文件跑 prettier——跑一次会把 37 行的改动变成 800 行无关重排。Refs #1855