Skip to content

feat(sticker): 为 bot 建立可搜索的贴纸库,并让贴纸能发出去 - #1245

Open
chen-ran wants to merge 6 commits into
mainfrom
feat/sticker-library-20260914
Open

chen-ran wants to merge 6 commits into
mainfrom
feat/sticker-library-20260914

Conversation

@chen-ran

@chen-ran chen-ran commented Sep 14, 2026

Copy link
Copy Markdown
Member

Author

  • Human
  • Agent

Type

  • bug
  • feat
  • test

Summary

一个群聊 bot 会见到很多贴纸,但看过就没了:贴纸只是某条消息的附件,没有任何地方记下"这张是什么、以后还能不能再发"。这个 PR 给 bot 一份自己的贴纸库,并补上把贴纸真的发出去的那条路。

库在工作区,不在 Postgres。internal/memory/storefs 同一套 bridge 写文件机制:/data/stickers/<包名>-<id>.md 一张贴纸一个文件,YAML frontmatter 记身份,正文是描述;/data/STICKERS.md 是按贴纸包分组的索引。描述是正文而不是 frontmatter 字段,因为它是人和 agent 会直接改的那部分——多行 prose 塞进 YAML 早晚在引号上出事。手改过正文的文件仍然读得回来。

身份分两列存。 ref 是可发送的 file_id(绑 bot token),unique_id 跨 bot 稳定但发不出去。只留前者,换 token 之后整库作废且无从合并;只留后者,一张也发不出去。

发送需要一个新的附件类型。 贴纸的 file_id 走 sendPhoto 会被 Telegram 拒掉,所以新增 sticker 类型走 sendSticker。它只在出站存在——入站仍然是 image,因为到达模型的是一张能看的预览。sendSticker 不收 caption,配文另发一条,而不是静悄悄丢掉模型本来要说的话。

描述由 agent 自己写。 save_sticker 省略 platform_key 时绑定本会话最近一张贴纸;search_stickers 返回可以直接填进 sendplatform_key。发送刻意没有并进这两个工具:贴纸走现成的 send,继续受同一套目标校验和投递路径管辖。

#1239 的关系

#1239sticker_file_id 收窄成"存进媒体库的字节不是贴纸本身"的标记,并删掉了当时没有读者的 sticker_emoji。这两条都保留:

  • 可发送引用的取法据此确定——有 sticker_file_id 用它,否则用附件自己的 file_id。写反了的后果是把一张动图的静态截图发出去。
  • "刚才那张贴纸"的查询按 sticker_unique_id 过滤而不是 sticker_file_id,后者会漏掉全部静态和动画贴纸。
  • sticker_emoji 加回来了,因为现在 internal/sticker 把它作为搜索词和索引显示项,读者是实打实的。

没有做的

  • 只接 Telegram。其它平台在能力检查处以 channel does not support stickers 失败,不会退化成发一张静态图。
  • 没有自动采集,只有 agent 主动保存才入库。
  • content_hash 记下来了但兜底重传还没实现;而且那条路只救静态贴纸,Telegram 不接受重传的 .tgs.webm
  • 群聊里让门禁能判"该发贴纸"是后续 PR,依赖 feat(agent): 为群聊讨论增加 probe 发言门禁 #1237

Related Issues

Review 后的修正

首轮 review 报了五个问题,都已修复并各自带回归测试:

问题 修法
P1 保存成功却搜不到:ListDir 给的是相对名,直接拿去读会读到 /data/<name>,读失败被跳过,库表现为空 统一解析成库内路径并正确填 entry.Path;回归测试用 bufconn 起了一个按真实契约行为的 bridge 假实现
P1 文件名对身份做有损转换(小写、去符号、截断),两张贴纸可落到同一文件,写入时静默覆盖 改为对"平台+完整身份"取摘要,并保留同名检查
P2 跨 Channel 拒绝没落实:裸 platform_key 被归一化成目标平台的原生引用,飞书会按 file_key 发出去 新增 Stickers 能力位,在 prepareOutboundForSend 的能力校验处拒绝;搜索结果同时带上 source_platform
P2 "最近一张"在执行时才查库,群里他人消息即时入库,描述可能绑到错的贴纸上 查询加 visible_until 边界,可见范围在工具装配时定下
P2 工作区读取无上限:递归列目录 + 无界 io.ReadAll 改用 bounded listing 与带根、不跟随软链的读取,单文件与整库各有字节上限;超限报错而不是少返回几条

Validation

  • go build ./...go test ./internal/... ./cmd/... 全绿(在 rebase 到 fix(telegram): 动画贴纸保留原始 Lottie,渲染器才看得到它 #1239 之后)。
  • golangci-lint run 覆盖改动包无新增告警(internal/agent/application/service_run_lifecycle.go 的 nilness 是既有的)。
  • 新增测试:保存后搜得到(bufconn bridge,按真实路径契约行为)、同一张贴纸再保存合并进原文件、两个会在旧实现下撞名的 id 各自成文件、超限文件跳过而整库超界报错、贴纸在不支持的 Channel 上被能力检查拒绝、本轮之后到达的贴纸不参与"最近一张"、frontmatter 往返与手改正文、同一张贴纸再次出现时描述不被覆盖、与匹配搜索、文件名跨包名改名保持稳定、工具四条解析路径(最近一张/库内改写/编造 key 拒绝/无贴纸时的说明)、可发送引用在"有无预览替换"两种情况下的取法、sendSticker 与 caption 另发、准备阶段保持原生引用不重传。
  • 没有跑真机:这条链路没有连过真 Telegram 或真 Postgres。新增的那条 sqlc 查询只在单测层面验证了元数据解析,没有对真库执行过;bridge 写文件也没有在真容器里跑过。
  • ESLint 与 check-ui-contract.mjs 没跑(worktree 里没有 node_modules、packages/ui 子模块未拉取)。TS 改动是 tool-call-registry.ts 里两个数组项,三个 locale JSON 已验证可解析。

Screenshots / Recordings

后端改动为主。前端只动了工具名的 i18n 文案和 tool-call 的分类/图标映射,没有新增界面,因此没有截图。

Human QA

  • 已通过真人 QA

chen-ran and others added 3 commits September 14, 2026 15:52
一个群聊 bot 会见到很多贴纸,但看过就没了:贴纸只是某条消息的附件,没有任
何地方记下"这张是什么、以后还能不能再发"。想让 bot 主动发贴纸,缺的是一份
它自己的贴纸库。

库放在 bot 工作区里,不进 Postgres,跟 internal/memory/storefs 走同一套 bridge
写文件的机制:

- /data/stickers/<包名>-<id>.md 一张贴纸一个文件,YAML frontmatter 记身份,
  正文是描述
- /data/STICKERS.md 是按贴纸包分组的索引,每次保存重建

描述之所以是正文而不是 frontmatter 字段,是因为它是人和 agent 会直接改的那
部分——多行 prose 塞进 YAML 早晚会在引号上出事。手改过正文的文件仍然能被
读回来,前提只是 frontmatter 还在;读不动或解析不了的单个条目只跳过并告警,
不会连累整个库,因为这些文件本来就允许被手改坏。

身份上 ref(可发送的 file_id)和 unique_id(跨 bot 稳定但发不出去)分两列存:
只留前者,换 bot token 之后整库作废且无从合并;只留后者,一张也发不出去。
content_hash 一并记下,作为 file_id 失效时重传的落点——只对静态贴纸有效,
Telegram 不接受重传的 .tgs 和 .webm。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
两件事让"再发一次刚才那张贴纸"发不出来:

一是身份不全。#1239 之后 sticker_file_id 收窄成"存进媒体库的字节不是贴纸本
身"的标记,这是对的,但除此之外附件上没有任何东西能认出这是一张贴纸、属于哪
个包、是哪一张。补上 sticker_unique_id(跨 bot 稳定,贴纸库据此认条目)、
sticker_set、sticker_kind,并把 #1239 删掉的 sticker_emoji 加回来——删它的理
由是"全仓库没有读者",现在 internal/sticker 把 emoji 作为搜索词和索引里的显
示项,读者是实打实的。可发送引用的取法随之确定:有 sticker_file_id 用它,否则
用附件自己的 file_id。

二是发送。贴纸的 file_id 走 sendPhoto 会被 Telegram 拒掉,所以新增 sticker 附
件类型走 sendSticker。它只在出站存在:入站仍然是 image,因为到达模型的是一张
能看的预览。sendSticker 不收 caption,配文另发一条,而不是静悄悄丢掉模型本来
要说的话。其它平台没有原生贴纸语义,准备阶段会以"attachment reference is
required"失败,而不是退化成发一张静态图。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
贴纸库要长起来,得有人往里写:描述由 agent 自己写,它在对话里看到了那张贴纸,
知道它表达什么。save_sticker 省略 platform_key 时绑定本会话最近一张贴纸,
search_stickers 按描述、emoji、标签、包名做与匹配,返回可以直接填进 send 的
platform_key。

发送刻意没有并进这两个工具:贴纸走现成的 send,就继续受同一套目标校验和投递
路径管辖,而不是另起一条只有贴纸能走的旁路。

"刚才那张贴纸"是从已有的消息附件里查出来的,没有为此新建任何表——贴纸库本身
在工作区,这条查询只回答"这个会话刚看到哪张贴纸"。它按 sticker_unique_id 过
滤而不是 sticker_file_id:后者只在预览替换发生时才写,用它会漏掉全部静态和动
画贴纸。

带 platform_key 时先查会话再查库:改写几周前存的描述是普通编辑,不该因为"最近
没见过"被拒;但两边都找不到的 key 会当场拒绝,存进去就是一个发出去必然失败的
引用。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@chen-ran
chen-ran requested review from a team as code owners September 14, 2026 07:54
@github-actions github-actions Bot added change:server Changes backend code, configuration, or API contracts change:web Changes web frontend or shared frontend packages feat Adds or improves functionality size:L PR size uses the larger of added or deleted lines, excluding generated files labels Sep 14, 2026
chen-ran and others added 3 commits September 14, 2026 16:16
三个问题都在库自己这一层:

**保存成功但搜不到。** ListDir 报的是相对于所列目录的名字,代码直接把它当路径
去读,于是读的是 /data/misc-xxx.md,而文件在 /data/stickers/ 下。读失败被当成
"这个条目坏了"跳过,所以搜索若无其事地返回空库——历史条目合并和索引重建也一
起失效了。internal/memory/storefs 每次读之前都过一遍 memoryEntryPath,这层我漏
掉了。现在列出的名字统一解析成库内路径,entry.Path 也据此填。

回归测试用 bufconn 起了一个按真实契约行为的 bridge 假实现(列表给相对名、写入
和带根读取用绝对路径),断言"保存之后搜得到"。纯函数测试挡不住这个 bug:写测试
的人会带着和实现同一个错误假设。

**文件名对身份做有损转换。** 原来把 unique id 小写、去符号、截前十位当文件名。
平台 id 是大小写敏感且含符号的,这么一转两张不同的贴纸可以得到同一个名字:查找
按完整身份判定为新条目,写入却覆盖了另一张贴纸的文件。改成对"平台+完整身份"取
摘要,并保留同名检查——手工建的同名文件也不该被直接盖掉。

**工作区读取没有边界。** 库目录 agent 和用户都能写。现在列表走 ListDirBounded、
读取走带根不跟随软链的接口,单文件、整库各有字节上限。超限的单个文件跳过(它本
来就不是条目),整库超预算则报错而不是少返回几条:静默截断的库和空库在调用方看
来没有区别,而这正是上面那个 bug 的形态。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
原来以为其它平台会在准备阶段以"attachment reference is required"失败,不成立:
搜索结果给的是裸 platform_key,而出站归一化会把空的 source_platform 补成目标
平台,于是一个 Telegram 的 file_id 到了飞书和钉钉就被当成它们自己的原生句柄
收下——飞书随后按 file_key 发出去,钉钉要到 adapter 才拒绝。

补一个 Stickers 能力位,在已有的能力校验里明确拒绝。它和 Media 分开:贴纸引用
和媒体不可互换,没有贴纸概念的平台会把这个引用当成自己的某种文件句柄,发出去
的是另一个东西。校验点在 prepareOutboundForSend,所有 adapter 的发送都经过它,
在任何原生引用解析之前就失败。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
搜索结果补上 source_platform。留空会被出站归一化补成目标平台,等于把一个平台
的句柄冒充成另一个平台自己的——能力检查是兜底,但引用本来就该带着它的来源走。

"最近一张贴纸"改成在工具装配时定下可见边界,而不是执行到那一刻再问数据库最新
的是哪张。群聊里其他人发的消息不触发 agent 也会立即持久化,原来的写法有一个真
实的窗口:模型正在描述 A,B 到达,保存把 A 的描述绑到了 B 上。查询因此多一个
visible_until 边界,而不是取回来再过滤——过滤会让 limit 提前被本轮看不见的行
吃掉。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:server Changes backend code, configuration, or API contracts change:web Changes web frontend or shared frontend packages feat Adds or improves functionality size:L PR size uses the larger of added or deleted lines, excluding generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant