# 图片、表情包与感知 > 让模型真的能「看」图、能发表情包、能把别人发的表情存下来;链接 / 视频 / 语音 / 戳一戳各有确定的下场。 > 出站图片用 `base64://` 而不是本地路径,原因也在这页。 ## 图片与表情包:它能真的「看」图 QQ 那边发来的图,在 OneBot 里只是一个**会过期的下载链接**:NapCat 上报的 `image` 段长这样 ```json {"type":"image","data":{ "summary":"[动画表情]", "file":"695E1A06A7D44ECF81DE064CCF51674E.jpg", "sub_type":1, "url":"https://multimedia.nt.qq.com.cn/download?appid=1407&fileid=…&rkey=…", "file_size":"11279"}} ``` 而模型要「看见」图只能靠 `read_image` 读**本地文件**。所以插件在消息入队前多了一步: 取字节 → 按魔数认出真正的格式 → 以内容哈希命名落进 `mediaDir` → 把路径写进正文。 ``` QQ: [一张表情包] 你看这个 ↓ Agent 收到的正文: [图片(动画表情): .qq-media/6ab290670daf12d2688c6364efca45d5.jpg] 你看这个 ↓ Agent 调 read_image(".qq-media/6ab290670daf12d2688c6364efca45d5.jpg") —— 真的看到了 ``` 几个要点: - **为什么必须马上下载**:那个 `rkey` 是有时效的签名,过一会儿同一个 URL 就 403 了。 发给机器人的那条在消息入队时下载(同一会话串行,所以顺序是「先落盘、再交给模型」, 被引用的那条也一样);群里闲聊的那条则在后台下载,不挡任何人的消息。 抓不动时还有一层兜底:把 `file` 段丢回 OneBot 的 **`get_image`**,让它把图(可能以 base64 形式)给我们。 - **`mediaDir` 下的文件**按内容哈希命名、扩展名来自**字节里的魔数**(腾讯的链接根本不带 扩展名)。同一张表情包被反复发只会存一份;超过 `mediaCacheFiles` 就按最近使用时间淘汰 最旧的;单个文件超过 `mediaMaxBytes` 连请求都不发。`mediaDir` 必须在 `cwd` 里 —— 写到工作区外面 Agent 反而读不到,所以这种情况下插件会整体停用下载并打一条 warn。 - **正文里可能出现三种形态**,含义不同: - `[图片(动画表情): .qq-media/…jpg]` —— 已落盘,路径相对 `cwd`,可以直接 `read_image`; - `[图片(动画表情) 没能取到文件: HTTP 403]` —— 抓失败了(rkey 过期、CDN 抽风),别去读 一个不存在的路径,可以直说图挂了; - `[图片(动画表情)]` —— 没有本地路径的纯占位符:下载整体关掉(`mediaDownload: false`)、 闲聊里的图还没在后台落完(`ambientMedia`)、或者这张连字节都没认出来时会是这样。 - **群聊环境上下文里的图也下载**(`ambientMessages` 那一段,`ambientMedia: true` 默认)。 「别人刚才发的那张表情包」是最常被接着问的东西,所以闲聊里的图也会落盘,只是下载在 后台进行:等提示词渲染时那一行已经变成 `kaguya: [图片(动画表情): .qq-media/…jpg]`。 同一张表情包按内容哈希去重,磁盘只存一份;但每次发出来仍会重新下载一遍字节 —— 腾讯的下载链接每次都带新的 `rkey`,没法只靠 URL 判断「这张我见过」。 不想为闲聊花带宽就设 `ambientMedia: false`,那时上下文里只有 `kaguya: [图片(动画表情)]`。 ### 被引用的那条消息也能看见 QQ 的「引用回复」在 OneBot 里只有一个 `reply` 段,里面**只有被引用消息的 id** —— 没有正文、没有图。所以「引用一张表情包再 @ 它问这是啥」这种最常见的用法,光看入站事件 是什么也看不到的。插件因此在入队时多取一趟: ``` QQ: [引用 kaguya 的表情包] @bot 这啥 ↓ reply 段只有一个 message_id → get_msg Agent 收到的正文:(引用 kaguya: [图片(动画表情): .qq-media/6ab290670daf12d2688c6364efca45d5.jpg]) 这啥 ↓ Agent 调 read_image(".qq-media/6ab290670daf12d2688c6364efca45d5.jpg") ``` - 引用的**正文**也会带进来(最多 200 字,够当锚点又不挤上下文);引用段本身那个 `[回复]` 占位符会被替换掉,不会出现「一半有内容、一半是噪音」的正文。 - `get_msg` 拿不到时(实现不支持、消息已撤回、网络抖动)只记一条 warn,正文里保留 `[回复]`,消息照常投递 —— 引用只是锦上添花,不该拖垮聊天。 - 只取**一层**:被引用的那条自己再引用别的东西,不会跟着递归下去。 - 关掉:`quoteMedia: false`(回到「引用段就是一句 `[回复]`」,一次 `get_msg` 都不发)。 - 合并转发(`forward`)不吃这一套,仍是 `[合并转发]`。 ### 商城表情 / 系统表情 / CQ 码,各是什么下场 | QQ 里的东西 | OneBot 上报成 | 插件怎么处理 | |---|---|---| | 普通图片、图片表情包(含动图) | `image`(带 `url`) | 下载 + 落盘,模型能看 | | 商城表情 / 收藏表情包 | **NapCat 上报成 `image`**(带腾讯 CDN 的 `url` 和表情名 `summary`) | 同上;表情名会进占位符的括号里 | | 商城表情(其它实现,如 Lagrange / go-cqhttp) | `mface`:只有 `emoji_id` + `summary`,没有可下载的 URL | 只渲染 `[表情包(表情名)]`,不下图 | | QQ 系统小表情 | `face`:只有数字 id | 仍是 `[表情]` —— 这个段里没有图、也没有语义表,要「读懂」得配一张 id→含义的表,属于另一件事 | | 引用回复 | `reply`:只有被引用消息的 `message_id` | 多调一次 `get_msg` 把被引用的那条取回来:正文与图都进正文,见[被引用的那条消息也能看见](#被引用的那条消息也能看见) | > 上面这张表是拿真实 NapCat(`mlikiowa/napcat-docker`)的事件实测出来的,不是照着协议 > 猜的:QQ 的 marketFaceElement 在 NapCat 里会被直接转成 `image` 段并附上 > `https://gxh.vip.qq.com/…` 之外的 `multimedia.nt.qq.com.cn` 下载链接。 ### 前提:模型得支持读图,工具得放开 - `read_image` 只有当前模型声明了 **image 输入**才可用(部署默认的 `deepseek-flash` 声明了 `["text","image"]`)。换成纯文本模型时 `read_image` 会直接报 `model "…" does not declare image input` —— 那时落盘仍在发生,只是模型读不到, 它在正文里看到的仍是一句带路径的占位符。 - 部署的附件服务只接受 **PNG / JPEG / WebP / GIF**,所以插件只认这四种(认不出就不落盘)。 - `qq` 预设里有 `tool-fs`(含 `read_image`),`allowTools` 里也放开了它。**如果白名单把 `read_image` 挡掉**,插件不会再在提示词里推荐它(免得模型去调一个看不见的工具), 图也就只能停在占位符上。 - 关掉整个功能:`mediaDownload: false`。此时一条网络请求都不会发,正文里只有朴素占位符。 排查用 `/reset` 一样清不掉的东西就是缓存本身(它按内容去重,与哪个会话、哪一轮都无关);想手动清: ```bash rm -rf ~/Projects/qqbot/.qq-media journalctl --user -u qqbot -f | grep 'qq-bridge: media:' # 每张图的落盘/失败都会记一行 ``` ## 它也能发表情包 上面那一大节是「QQ 的图 → 本地文件」,让模型能**看**图;这一节是反方向:让模型能**发**图。 ``` 模型想接个梗 / 想回一张图 ↓ 回复正文里写一行「表情包标记」:[[图片: .qq-stickers/笑死.gif]] 插件读字节 → 认格式 → 校验大小(只认 PNG/JPEG/WebP/GIF,默认 ≤ 8MB) ↓ send_group_msg / send_private_msg 的 image 段: {"type":"image","data":{"file":"base64://…","sub_type":1}} NapCat 解码 → 上传 → 图出现在 QQ 里(`sub_type: 1` = 按「大表情」渲染,见下) ``` ### 只发表情、一个字都不说 这是最要紧的一条:**整条回复只写一行 `[[图片: 路径]]`,就只会发出那一张图,一条文字都没有。** ``` 模型的回复正文: [[图片: .qq-stickers/笑死.gif]] ↓ 群里只多了一张表情包 —— 没有「给你发张表情包」这种话 ``` 为什么不用工具做这件事:工具调用发生在整轮文字**之前**,而且「不说话」在纯文本回复里 根本表达不出来(模型总得写点什么)。把表情写成正文里的一处标记,位置就跟着正文走: ``` 模型的回复正文: 群里依次看到: 今天真累啊 今天真累啊 [[图片: .qq-stickers/摸摸头.gif]] [一张摸头图] 早点睡 早点睡 ``` 标记里的路径也可以**只写文件名**(`[[图片: 笑死]]`、`[[图片: 笑死.png]]`): 在 `.qq-stickers/` 里找得到就用那一张(带不带扩展名都行),找不到才按工作目录的相对路径算。 关键字给几种写法(`图片` / `图片表情` / `表情包` / `表情` / `贴图` / `sticker` / `image` / `img`),全角冒号也认。**认不出来的方括号一律当普通文字发出去** —— 宁可群里露出一行 `[[音频: x]]`, 也不要静默吞掉模型说的话。**标记只认 `.qq-stickers/` 里的文件**:`.qq-media/<哈希>` 这类缓存路径、工作目录里的其它文件 都会被直接拒绝,并回一句「(那张表情我这儿没有,发不出去)」+ 记一条 warn。失败原因还会 写回下一轮提示词 —— 否则模型会照着自己编的路径再编一个(真实事故:连编两轮,用户连收两遍 「没发出去」)。只发表情的那条回复要是静默失败,看起来就是「机器人没理人」。 ### `qq_send_image` 工具(显式动作,可以发给别人) 标记只能作用于「正在回的这条会话」;要给**别的**群/好友发图,或者就是要一个明确的 「把这张图发过去」,用 `qq_send_image` 工具。它接受同样的来源,另外还接受 http(s) 图片链接(插件先在**宿主机**把它下下来,顺便校验格式与大小,而不是让容器里的 NapCat 去够那个链接)。它是立刻发出去的,不跟在正文的行序里。 要点: - **按「大表情」发,而不是普通图片**:从 `.qq-stickers/` 发出去的表情,`image` 段带 `sub_type: 1`(NapCat 会原样写进 QQ 的 `picSubType`;真人从表情面板发收藏表情时那里 就是非 0 的)。不带这个标记,QQ 只当它是一张照片:带边框、按原图像素铺开,群里一眼 就看得出不是人发的。反过来,`.qq-media/<哈希>` 里的截图、URL、工作目录里的照片都 **不**带这个标记 —— 被 QQ 当成表情裁成小图更糟。**复读按链上出现过的类型发**:同一张图 只要这条复读链里有一条是表情(`sub_type` 非 0 的收藏表情 / 带 `emoji_id` 的商城表情) 就带标记,后面被另存、转发成普通图片再发也仍按表情;链里从没出现过表情的截图/照片仍是 普通图片,判据见 `isStickerSegment`。 - **表情包放哪儿**:`stickerDir`(默认 `.qq-stickers/`,插件启动时会自动建好)。 往里丢几张图就能用 —— 文件名会列进「QQ 渠道」提示,所以**名字起得有意义**最划算 (`笑死.gif`、`摸摸头.png`);名字看不出来的,模型可以先 `read_image` 看一眼再挑。 目录里超过 40 张时只列前 40 个(按文件名字典序),其余靠它自己 `glob`。 - **别人发的图想发回去,先存一次**:入站那张在 `.qq-media/…`(LRU 缓存,迟早被淘汰), 标记不能直接用它。用 `qq_save_sticker` 存进 `.qq-stickers/`(顺手起个名字、写一句含义), 下一轮就能挑它 —— 这也是「这张图以后还能再用」的唯一可靠办法。 `qq_send_image` 工具仍然可以发 `.qq-media` 里的图(那是显式动作,不是「发表情」, 所以按普通图片发;只有落在收藏目录里的路径才带「大表情」标记)。 - **权限边界没变**:本地路径只允许 `cwd` 里的文件,而且必须真的是 PNG/JPEG/WebP/GIF (按魔数判断,`.txt` 改名成 `.png` 也蒙不过去)—— QQ 那头是不可信输入,没理由为了 发张图把工作区捅开。标记还**跟着 `allowTools` 走**:白名单里没有 `qq_send_image` 时,回复里也不能用标记发图(那时它就是一行普通文字)。 - **提示词里已经写了这件事**:模型知道标记怎么写、知道「整条只写这一行就是只发表情」、 也知道表情包目录里有哪几张、还知道「一次最多一张、别每句都配一张」。想让它更爱发或 更克制,用 `persona` / `instructions` 补两句就行 —— 比「多发表情」这种态度更有效的是 写清**什么时候**发:「表达无语 / 委屈 / 破防时先用表情,别用文字描述情绪」。 - **关掉**:`imageSend: false` 一次关掉标记与工具(工具调用会明确报错 `sending images is disabled`,提示词里也不再提标记);`imageMaxBytes` 调小可以进一步 限制它能发多大。 ### `qq_save_sticker` 工具(把别人发的表情留下来) 群里最常见的一句话是「存一下这个表情」。但 QQ 会话是按「不可信输入」配的:**没有 write / edit、也没有 bash**,所以模型自己没有任何办法把那张图留下来。不给它出口时它会 假装做到了 —— 实测它 `read_image` 看完、`glob .qq-stickers/**` 发现是空的,只能把含义 追加进 `COGNITION.md`,key 写成 `.qq-media/<内容哈希>` 这个**会被淘汰的缓存路径**, 然后回一句「存好了」。那张图一被 LRU 淘汰,这条记忆就指向一个不存在的文件 —— 现在这种路径会被 直接拒发(「(那张表情我这儿没有,发不出去)」),不会再默默失败。 `qq_save_sticker` 就是为这一句话开的窄通道: ``` [[图片: .qq-media/ab12….gif]] 模型在正文里看到的路径 ↓ qq_save_sticker(source: ".qq-media/ab12….gif", name: "委屈") 魔数判格式 + 大小上限 + 必须在 cwd 内 → 名字清洗成单层文件名 → 同名不覆盖 ↓ .qq-stickers/委屈.gif 下一轮提示词里就列出来了,以后随手能挑 ``` 边界(全部在 `index.js` 的 `saveSticker` 里,工具只负责参数与报错): - **只写 `stickerDir` 之内**,文件名由纯函数 `stickerFileName` 洗成单层名字:`../../etc/passwd` 这类只会剩最后一段,控制字符与 `:*?"<>|` 被去掉,开头的点也去掉(隐藏文件列不出来, 等于存了看不见)。 - **扩展名以真实格式为准**(按魔数嗅,不看模型写的那个):否则会出现 `笑死.png` 里装着 GIF 字节这种「发得出去、但看名字以为是别的格式」的坑。名字长度封顶 60 字符 —— 它要进每轮提示词,太长的名字是纯 token 开销。 - **同名不覆盖**:覆盖等于群里任何一个人一句「存一下」就能把现有表情换掉。想换就先删掉 旧的那张(或改个名字)。 - **张数上限 `stickerMaxFiles`**(默认 60,`0` = 不限制):QQ 那头是不可信输入,群里谁都 能让它一直存,磁盘不该被这么撑大。 - **不可用就不注册**:`stickerDir` 没配 / 配到 `cwd` 外、`imageSend: false`、或者 `allowTools` 里没有 `qq_save_sticker` 时,这个工具**根本不出现**(与 `qq_transcript` / `qq_memory` 同一个规矩:不留「一调就报错」的壳),提示词里也不会提它。 ### `qq_rename_sticker` 工具(给已收藏的表情改名) 改名是「同一个文件换个名字」,不是「再存一张」。在这之前模型想改名字只有一个办法 —— 用新名字再 `qq_save_sticker` 存一遍 —— 于是 `.qq-stickers/` 里多出一张**同画面、不同名**的 重复表情,`STICKERS.md` 里旧名字那条还留着,以后两张都会被挑中;而 QQ 会话没有删除文件的 通道,重复了就再也清不掉(真实事故,用户报的就是这个)。 ``` qq_rename_sticker(from: "委屈", to: "难过") ↓ 同一个 stickerFileName 清洗;扩展名沿用旧文件 .qq-stickers/委屈.gif ──rename──▶ .qq-stickers/难过.gif STICKERS.md: - 委屈|画面|含义|场景|.qq-stickers/委屈.gif|日期|置信 ↳ - 难过|画面|含义|场景|.qq-stickers/难过.gif|日期|置信 ``` 边界(在 `index.js` 的 `renameSticker` + `memory.js` 的 `renameStickerEntry` 里): - **只认已收藏的文件**:来源名字按发表情同一套解析(裸名字、写错扩展名都找得回来), 找不到就报错并列出目录里现有的名字。 - **新名字走同一个清洗函数**,扩展名**沿用旧文件的真格式**(这里没有新字节,不重新嗅)。 - **目标名已被占用就拒绝**:与「同名不覆盖」同一条理由 —— 别让群里任何一个人一句 「改个名字」把别人的表情挤掉。 - **目录跟着搬**:`STICKERS.md` 那一条按旧名字找人,画面 / 含义 / 场景 / 日期 / 置信整行搬到 新名字下(同一个区里是**原地替换**,条目不挪到末尾);只在改名时顺手给了新含义 / 场景 / 画面才覆盖对应字段。 - **失败不留半套**:目录那一步写不进去时,文件改名会回滚 —— 只改一半比不改更糟(目录指着一个 不存在的路径,发出去就是「表情包没发出去」)。 - **白名单按名字算**:`allowTools` 里给了 `qq_rename_sticker` 就能用,不必同时给 `qq_save_sticker`;目录不可用 / `imageSend: false` 时同样**根本不注册**。 只改**含义**不用改名:`qq_memory` 的 `upsert` 按原来的名字改那一行就行,不要再存一张。 顺带一句:如果你更愿意自己管这个目录,就把图直接丢进 `.qq-stickers/`、名字起成它的意思 —— **文件名本身就是最便宜的那张「含义表」**,它每轮都在提示词里,而且不花一个 token 去查。 `COGNITION.md` 适合记「这张图是什么梗」(给看懂用),不适合当「挑一张发」的目录, 而且里面的 key 千万不要用 `.qq-media/<哈希>` 这种会过期的路径。 ### 为什么是 base64,而不是把路径丢给 NapCat 现实是这样的:NapCat 跑在**容器**里、插件跑在**宿主机**上,容器的文件系统里没有 `.qq-media/xxxx.jpg`。给 `upload_*_file` 传本地路径实测会回 `识别URL失败`(它的 `enableLocalFile2Url` 默认还是关的),image 段里的本地路径同理。 所以这里用 OneBot image 段的 **`base64://`**:字节直接跟在 `file` 字段里发过去, NapCat 自己解码落进它的临时目录(实测:同样一段 base64 喂给 NapCat 的 `download_file`, 容器里落出来的文件与宿主机的图 md5 完全一致),之后的链路与手动发图一模一样。 好处是不依赖任何「容器能不能连到宿主机」的假设 —— 另一种常见做法(把文件放在本机、 给 NapCat 一个 URL 让它自己来拉)必须绑在 docker 网桥上,这里不需要;代价是体积膨胀 约 4/3,所以有 `imageMaxBytes` 上限。 NapCat 的 WebSocket 服务端 `maxPayload` 实测是 **50MB**,默认 8MB 离它还很远。 真发超大图时唯一的代价是慢:`timeout.uploadSpeedKBps` 默认 256 KB/s,八兆的图要等 半分钟左右,所以默认值刻意压在「表情包 / 截图」的量级。 排查: ```bash journalctl --user -u qqbot -f | grep 'qq-bridge: →' # 每张发出去的图都记一行(目标、格式、字节数、来源) ls -la ~/Projects/qqbot/.qq-stickers # 表情包目录:丢几张图进去就能用 ``` ## 感知与互动(链接 / 视频 / 语音 / 戳一戳) 除文字与图片之外,消息里的其它东西现在都有确定的下场(`modalities.js` 把每一段变成 `Observation` 再渲染成占位符)。默认值全部偏保守 —— 这些输入都来自 QQ,是不可信输入。 | 消息里是什么 | 它看到什么 | 默认 | |---|---|---| | 图片 / 商城表情 | 下载到本地,正文给 `[图片: .qq-media/…]`,模型可以 `read_image` | 开 | | 系统表情 `face` | `[表情: 名称]`(有名字时),没有就 `[表情]` | 开 | | 语音 | `[语音 3 秒]` —— **只占位**:不下载、不转写、不起任何外部进程 | 开 | | 视频 | `[视频 12 秒 · 封面: .qq-media/…]`:只下封面(`videoCover`),整段不下载(`videoMaxBytes: 0`) | 开 | | 文件 | `[文件 report.pdf(1.2 MB)]`,不自动下载 | 开 | | 合并转发 | `[合并转发 8 条]`(占位,不展开) | 占位 | | 卡片消息(`json` / `xml`) | 解析出标题 / 摘要 / 域名 / 链接,渲染成 `[链接: 【标题】 — example.com https://…]`;解析失败退回 `[卡片消息]` | 开 | | 正文里的裸 URL | 可选地抓一次**标题**(`linkPreview`) | **关** | | 戳一戳 | 通知事件,按 `pokeMode` 回戳或走一轮模型 | 开 | ### 为什么链接预览默认关 卡片消息(`json` / `xml`)自带标题与链接,**不联网**就能解析 —— 群友分享文章时已经够用。 而「正文里一个裸 URL 就去访问它」等于:替群里任何人访问任意地址、把部署机器的出口 IP 暴露给发链接的人,还留下一次可被用来探测内网的请求。要开就显式开,并且它带着这些边界: - 只认 `http(s)`;拒绝 `localhost` / `*.local` / `*.internal` / 单标签主机; - 拒绝私有与保留网段(`10/8`、`172.16/12`、`192.168/16`、`127/8`、`169.254/16`、`100.64/10`、 `224/4` 等),以及 `::1`、`fc00::/7`、`fe80::/10`、`::ffff:<私有 v4>`; - 拒绝十进制 / 十六进制 / 八进制写法的 IPv4(`http://2130706433/` 这类); - **每一跳重定向都重新校验**(重定向到内网是最常见的绕过方式),最多 3 跳; - 超时 `linkPreviewTimeoutMs`、最多读 `linkPreviewMaxBytes`、只收 `text/html` / `text/plain`; - 一条消息最多抓 2 个链接;抓到的标题作为**数据**渲染,不是指令。 - 自建 wiki 之类确实在内网的,用 `linkAllowPrivateHosts: true` 显式放开(scheme 与重定向 校验不会因此放松)。 ### 戳一戳(`pokeMode`) 以前 `handleEvent` 第一行就把非 `message` 的事件全丢了,所以「有人戳了它一下」它完全收不到。 现在只认**戳机器人自己**的(戳别人一律不记:群里互相戳跟它无关): - `pokeMode: random`(默认)—— 按 `pokeReplyChance`(0.7)决定**回戳**还是**走一轮模型**; - `pokeMode: poke` —— 总是回戳(最快、最像群友);`text` —— 总走一轮模型(正文是「X 戳了 你一下」,允许 SKIP);`ignore` —— 不回; - 每个会话 `pokeCooldownMs`(30 秒)冷却 + `pokeDailyMax`(20 次)每日上限 —— **防回戳 死循环**:两个机器人互相戳起来时,冷却那条是唯一真正管用的闸;对方回戳不再触发连锁。 - 回戳只打给**戳它的人**(`target_id` 来自 QQ,是不可信输入,但不会拿去戳第三人)。 - 走模型的那一轮 `intent = poke`,不攒批、不引用、不消耗活跃对话轮数,但会更新「最后活动 时间」—— 有人在跟它互动,群就不算安静。 - 它自己也能戳人:`qq_poke` 工具(群里必须显式指定戳谁,不做「最近跟我说话的人」这种推断)。