# 自动复读(`repeat`) > 群里刷同一个字 / 同一张表情包时,插件**不经过模型**跟着发一次。 > 它不建会话、不占记忆,也不花额外的下载带宽 —— 用的是群聊上下文那次落盘。 群里刷「6」的时候,机器人该做的不是想一句俏皮话 —— 是跟着刷「6」。 ``` kaguya: 6 iroha: 6 小助手: 6 ← 插件自己接上的,模型完全没参与 ``` 表情包同理:连着两张一模一样的表情包/图片,它就把那张原样发回去。 ``` kaguya: [一张表情包] iroha: [同一张表情包] 小助手: [同一张表情包] ← 也是插件自己发的(图片走 base64 出站) ``` 这条链路**不经过 Agent**:没有 user message、没有 turn、不建会话,也就没有「等模型回话」 那几秒的延迟,更不会占用会话记忆。判定规则都在插件里: - **只在群里**(私聊不谈复读),并且仍然受 `groupChat` / `allowGroups` 约束。 - **只跟没 @ 机器人的消息**:被 @ 的那条会走 Agent 去回答,再复读一遍等于同一句话回两遍。 被 @ 的消息照样会**打断**「连着」——群里插了别的话,前后两条就不再是连续的。 于是 `groupTrigger: all` 时每条都算被 @,复读自然不会触发(那种配置下它本来就在逐条回话)。 - **只有两种消息跟得动**: - **一条纯文字**:比较时只做 trim + 折叠空白,所以 `6` 和 `6 `、多打了几个空格算同一句; 真正复读出去的是**后一条的原样正文**(复读机不改人家的字)。超过 `repeatMaxChars` (默认 100 字)的长段落不跟。 - **单独一张表情包**:整条消息就是一张图(`image` / 商城表情 `mface`)或者一个系统表情 (`face`,也就是 `[表情]`)。图片认「同一张」分两层:**字节完全相同**,或者**画面指纹 足够近**(`repeatImageTolerance`,默认 0.08)—— 因为 QQ 会把同一张表情包**按发送者 重新编码**,同一张图在群里出现过 94657 字节(progressive+Exif) / 131307 字节 (baseline) / 79284 字节三种形态,画面 SSIM ≥ 0.985 却没有两份字节相同;只看字节的话 表情包复读在真实群里几乎永远不会触发。指纹怎么算、怎么校准见下一小节。 图 + 文字、一条里两张图、带引用的消息都不算候选(复读要的是「原样接上一张/一句」, 把图和话拆开就不是那回事了);`repeatMedia: false` 只保留文字复读。其余消息(语音、 卡片、`@某人`、取不到字节的图)一律不跟,但**同样会打断链** —— 群里插了别的话, 前后两条就不再是连续的。 - **默认要有两个人说**(`repeatDistinct`):一个人自己连发两遍是手滑,不是复读。三个人一起刷时, 第一个人说、第二个人接、第三句由它来接。`repeatCount: 3` 则是等第三个人接上才跟。 - **中间隔太久不算连着**(`repeatWindowMs`,默认 5 分钟,`0` = 不限):早上有人说过的一句,晚上不该再被跟一遍。 - **跟一次就断链**:跟完这句/这张图链就清空,下一条相同的消息要**重新**攒够人数。否则群里一直刷 同一个字时,它会一句不落地跟着刷成一台真·复读机。 - `repeatChance`(默认 1)是「每次都跟」之外的另一种玩法:调成 0.5 就成了偶尔跟一句的群友; 这次没跟的话链还留着,下一条相同的消息会再掷一次。 发出去的是**原样的东西**:文字就是那条文字,图就是那张图——连「它是表情还是普通图片」都照 原消息,表情复读出去带的还是 QQ 的大表情标记(见[表情包那一节](media.md#qq_send_image-工具显式动作可以发给别人)), 截图/照片仍是普通图片。这条类型在链上还会**累加**:同一张图只要这条链上有一条是表情 (`sub_type` 非 0 / 带 `emoji_id`),后面哪怕被另存、转发成普通图片再发一次,复读也按表情发; 链里从没出现过表情的截图/照片不会被标成表情。都不带引用、不带 @、不加打字延迟(复读机的笑点 就在「原样、立刻」)。它挂在同一个会话的发送队列上,所以不会插到一轮正在分条发出的回复中间。 ## 表情包那半边的账要算清 判「是不是同一张」需要先拿到字节,而字节得先下载下来 —— 这笔带宽**不额外花**:复读用的正是 群聊环境上下文那次落盘(见[像真实群友一样说话](speaking.md#像真实群友一样说话)里的 `ambientMessages` / `ambientMedia`;`mediaFor` 按消息对象共用同一次下载)。于是: - 默认配置下,表情包复读的带宽成本是 **0**(环境上下文本来就要下这张图); - `ambientMedia: false` 或 `ambientMessages: 0` 时群里的图根本不会下载,表情包复读就自动 退化成「只复读文字」—— 「不为闲聊花带宽」这个承诺不会被复读偷偷破坏; - `imageSend: false` 时一律不发图(那是「不往 QQ 发图」的总开关),`repeatMedia: false` 则是 只停掉复读里的图; - 比 `imageMaxBytes` 还大的图不跟(跟了也发不出去),取不到字节的图也不跟。 **画面指纹是怎么算的**:只在真的要拿两条**字节不同**的图比一比时,插件才读一次刚落盘的那张图, 用纯 JS(零依赖)把它缩成 16×16 灰度再取 dHash(16×15 = 240 位);两条消息的位差比例 ≤ `repeatImageTolerance`(默认 0.08)就算同一张。指纹按内容哈希路径缓存(最多 64 张), 所以同一张图刷来刷去只解一次;绝大多数表情包消息(旁边没人接、链是空的)一次解码都不做。 几条实现上的取舍: - **JPEG 只解 DC 系数**(它正好是 8×8 块的平均亮度,本身就是一张 1/8 缩略图),PNG 走 `node:zlib`,GIF 手写 LZW 取第一帧;不认识的格式(WebP、CMYK 的四分量 JPEG)与解不动的 (隔行 PNG)直接不给指纹 —— 这时退化成「只认字节完全相同」,宁可漏跟一次也不跟错一张图。 - **三条路都先降到 1/8 块平均再比**:否则同一张画面「一次以 JPEG 发、一次以 PNG 发」时平均 窗口对不齐,实测能差 5%~26%,跨格式就认不出来了。 - **阈值是量出来的**:同一张重新编码差 0%~2%(跨格式 2%~4%),同一套画风只换了文字的表情 差 16%~23%(实测同一套图里只换了文字的一对也有 23%),0.08 两边都有 2 倍以上余量。 - **透明像素按白底合成**(GIF 的透明索引、PNG 的 alpha),否则同一个透明背景的贴纸在 GIF 里 是黑底、在 JPEG 里是白底,指纹根本对不上。 - 画面短边不到约 128 像素、或者整张几乎一个颜色的图不给指纹(1/8 缩略图撑不起 16×16 网格, 纯色图则会让「任何两张纯色图都算同一张」)。 - 指纹解码走的是**群里的不可信字节**:所有尺寸、块数、扫描数、解压长度都有硬上限,任何畸形 输入都只返回「没有指纹」这一个结果,绝不抛异常。 代价是「真要跟的时候」多一次本地读 + 一次纯 JS 解码(JPEG 只解 DC,几毫秒;大 PNG/GIF 也就 一百多毫秒,且算过就缓存);`repeatImageTolerance: 0` 时连读盘都不做,回到「只认字节完全相同」。 > 复读的内容**全部来自不可信的群消息**,所以这条路径一步都不多做:文字原样发出, > `[[图片: …]]` 这种标记在文字里只是字面文字,不会被当成发文件的指令(标记只解析模型写的 > 回复);图片只可能来自 `.qq-media` 里刚落盘的那一张(内容哈希命名),不会去读工作目录里的 > 其它文件;系统表情只认纯数字 id,不把 OneBot 给的字段原样转发出去。 相关配置:`repeat` / `repeatMedia` / `repeatCount` / `repeatDistinct` / `repeatWindowMs` / `repeatMaxChars` / `repeatChance` / `repeatImageTolerance`;`/reset` 会连同该群的复读链一起清掉(那是「刚才」的事,不属于哪一轮会话)。