# 消息闸门(群聊) > 状态:**定案(2026-10-18,用户确认);P2.1/P2.2 已落地,P2.3 jev 分类器已接(OpenRouter > `typesafe/jev-1.14`)**——调度 / 去抖 / 最小间隔 / 慢路定时 / 状态持久化 / 发送队列 / 决策日志 > 已实现并有单测;v0.10.0 起去抖、最小间隔、阈值可在配置页调。v0.11.0 起 > `/new` / `/stop` / `/status` / `/help` 由命令层在直通之前截获(见 [commands.md](./commands.md)), > 不进闸门。**v0.18.0** 修正触发边界:引用**别人**的消息不再直通、@ 别人 / @全体成员的 > 消息不再进闸门;并新增可选的「代答」(§2.6)。目标:决定群里什么时候该由 bot 接话, > 控制刷屏与模型成本。协议端事实见 [onebot-channel.md](./onebot-channel.md), > 整体架构见 [architecture.md](./architecture.md)。 ## 1. 边界 - 闸门只回答「要不要接话」,**不生成回复内容**;内容交给 DSH agent。 - 只处理群聊普通消息;**@机器人 / 引用机器人自己的消息 / 显式命令直通**,不参与判断; relay 命令层(`/new` 等)在直通之前处理,同样不进闸门。 - **@ 别人(含 `@全体成员`)不是在跟 bot 说话**:这类消息只落聊天日志,不进闸门、 不起 turn(v0.18.0 起;例外是 §2.6 的代答目标)。 - 判断由专用分类器模型 **jev** 完成,不是通用对话模型。 ## 2. 三条路径 ### 2.1 直通(mention bypass) - 命中:消息段含 at self_id、引用 **bot 自己发的** 消息、显式命令前缀。 - **引用别人的消息不算**(v0.18.0)。手机 QQ 里 @ 某人常会带一个 `reply` 段;旧实现 「有引用就算回复 bot」会让 @ 别人也起一轮。现在 relay 记录 bot 发出的 `message_id` (每群内存 100 条,聊天日志兜底),只有引用这些 id 才按「回复」直通。 - 处理:立即交给 DSH,**不调用 jev**、不受 10 分钟窗口与 2 小时冷却限制。 - 仍会落盘,并刷新 lastBotSpeakAt / 重置 2h 定时器。 ### 2.2 快路(bot 最近说过话) - 条件:now - lastBotSpeakAt < 10 分钟(机器人最近 10 分钟内发过言)。 - 新消息进入 **5 秒去抖窗口**,同批消息合并后调用一次 jev。 - jev 判定需要接话 → 交给 DSH;判定不需要 → 丢弃本批(仍保留聊天日志)。 ### 2.3 慢路(2 小时定时批量) - 条件:机器人 10 分钟内没说过话。 - 消息不立即判断,累积到 pending。 - **2 小时定时器**到点:取最近 2 小时的消息交给 jev,判定是否需要主动接话。 - 需要 → 交给 DSH,发言成功后重置定时器;不需要 → 清空本批评估游标,等下一次。 ### 2.4 发言之后 任一路发言成功: - lastBotSpeakAt = 发言时间; - cooldownUntil = 发言时间 + 2h(慢路定时器重置); - 清空 pending; - 若距上次发言不足 **minReplyInterval(默认 45 秒)**,本次不发言,消息并入下一批。 ### 2.5 回复通道 - 真实 QQ 号(OneBot)**没有平台被动回复窗口**:DSH 跑多久都行,turn 结束直接 send_group_msg 发送。 - 仍受 QQ 自身风控与频率限制;发送失败按 retcode 分类退避重试,不吞错。 - WebSocket 断线时发送进入队列,重连后按序发送;超过保留时限的旧回复丢弃并记日志。 ### 2.6 代答(可选,默认关闭,v0.18.0) - 命中:消息 **@ 了配置名单里的人**(QQ 号,或群名片 / 昵称),且该成员最近 `proxyReply.silenceWindow`(默认 10 分钟)内**没在本群发过言**。 - 处理:直通起一轮(reason `proxy`),prompt 头部 `(直通:代答)`、行内 `(@目标)`, 末尾追加一行 `[代答:…请代 TA 回应(回复以机器人身份发出)]`,让 agent 代 TA 回答。 - 目标最近发过言 → 不代答(TA 在,会自己回);名单外的 @ 仍然只记聊天日志。 - `silenceWindow: 0` = 不检查沉默(只要 @ 目标就代答)。 - 目标按昵称匹配时会补查 `get_group_member_info`(10 分钟缓存);查不到就退化为 QQ 号判定。 - 配置:`proxyReply.enabled` / `targets` / `silenceWindow`,三项都能在配置页「行为」改。 ## 3. 流程 ~~~text 收到群消息 ├─ 落盘(JSONL) ├─ 去重(message_id) / 忽略 message_sent / 忽略机器人自己 ├─ 直通(@ bot / 引用 bot 的消息 / 命令)────────→ dispatch ├─ 代答(@ 名单内的人且 TA 最近没发言)─────────→ dispatch(reason=proxy) ├─ @ 别人 / @全体成员 ──────────────────────→ 只记日志,结束 └─ 普通消息 ├─ now - lastBotSpeakAt < 10m │ ├─ 是 → debounce 5s → jev(本批) │ │ ├─ 不需要 → 丢弃 │ │ └─ 需要 → dispatch │ └─ 否 → 追加 pending └─ 每 2h → jev(pending 中最近 2h) ├─ 不需要 → 丢弃游标 └─ 需要 → dispatch dispatch 成功 → lastBotSpeakAt = now;cooldownUntil = now + 2h;清空 pending ~~~ ## 4. jev 契约(已实现,P2.3) - **模型**:TypeSafe 的 System One 决策模型 **jev**,经 OpenRouter Decisions API 调用 (`POST https://openrouter.ai/api/alpha/decisions`),默认 `typesafe/jev-1.14`。 jev 不是对话模型:它返回带概率的类型化答案(Noul = 是 / 否问题),不返回文本或推理。 **v0.25.6 起模型 id 与地址都能在配置页「行为」页改**(`gate.model` / `gate.jevEndpoint`): 想换决策模型、或走兼容 Decisions 协议的代理时不用改 profile patch;地址必须是 `https://`, 保存后分类器立即换实现(不重建 relay)。 - **输入(`state`)**:群名、触发来源(`fast` / `slow`)、bot 标签、聊天日志里最近 60 条消息 `{ speaker, role, text }`(每条正文截到 500 字符;**包含 bot 自己的发言**,`role: "bot"`, 由 relay 在发送成功后写回聊天日志);`questions.reply` 是一个 Noul 问题: 「现在要不要在这个群里发一条消息」,`criteria.true / false` 说明边界(有人对它说话 / 需要接话 vs 普通闲聊 / 回复会打扰)。 - **输出**:响应 `answers.reply.noul` 是「是」的概率(0..1);按 `gate.jevThreshold` (默认 0.7)判定是否接话,决策形状 `{ reply, confidence: 概率, reason, targetMsgIds: [] }`。 - **失败策略**:网络错误 / 超时(默认 8s)/ HTTP 非 2xx / 响应缺 `answers.reply.noul` 或概率越界 → 一律 reply=false(fail closed),原因记 `jev-unavailable` / `jev-http-error` / `jev-invalid-response`,只写日志。 - **凭证**:API key **优先读配置** `gate.jevApiKey`(v0.24.0:配置页「行为」页填,只写不读), 为空时回退环境变量 `gate.jevApiKeyEnv`(默认 `OPENROUTER_API_KEY`);两者都没有时不构造分类器, 闸门按 fail closed 运行(只接 @ / 引用 / 命令)。key 变化只换分类器实现,不重建 relay 连接。 - **决策日志**:时间、批次、输入摘要、输出、耗时;用于调阈值和回放。 ## 5. 状态与持久化 每个 group_id 一份状态: ~~~json { "groupId": 123456, "lastBotSpeakAt": 1780000000000, "cooldownUntil": 1780007200000, "pending": [ {"messageId": 987, "userId": 111, "username": "张三", "at": 1780000000000, "text": "..."} ], "evaluating": false, "dshBusy": false, "seenMessageIds": ["..."], "lastReplyAt": 1780000000000 } ~~~ - 路径:**~/.dsh/storages/dsh-qq-relay/state/(group_id).json**,写入用临时文件 + rename(原子)。 - 聊天日志(P4):**\<群项目\>/.qq-relay/chat/(group_id)/YYYY-MM-DD.jsonl**,只追加; 默认**永不自动删除**(`chatRetentionDays: 0`,配了天数才会清理);给 jev 与 DSH prompt 提供上下文, agent 也可直接 read(见 [chat-context.md](./chat-context.md))。旧全局 `chat/` 启动时一次性迁移。 - 单实例锁:**~/.dsh/storages/dsh-qq-relay/lock**,第二个实例拒绝启动。 ## 6. 幂等与并发 1. **重复事件**:按 message_id 去重(保留 1 小时); 2. **批内合并**:去抖窗口内的多条消息只调一次 jev、只起一个 DSH turn; 3. **DSH 忙**:dshBusy 为真时新消息进 pending,串行处理,不并发起第二个 turn; 4. **重启恢复**:启动时读 state,恢复 lastBotSpeakAt / cooldownUntil;不立即发言; 5. **发送失败**:只有 send_group_msg 返回成功才更新 lastBotSpeakAt、重置 2h 定时器; 6. **断线重连**:OneBot 正向 WS 无事件回放,断线期间消息可能丢;靠 message_id 去重与 本地状态兜底,重连后不重放已处理批次; 7. **多实例**:文件锁保证同一 bot 只有一个进程连接。 ## 7. 出向 - turn 结束即发送,无平台回复窗口限制。 - 长回复按 QQ 实际限制切分为多条(上限待实测);保留发送顺序。 - 发送失败:按 retcode 分类(频率 / 权限 / 格式)退避重试;多次失败记日志并降级。 - WebSocket 不在线:回复进队列,重连后发送;超过保留时限丢弃。 - 回复文本以纯文本为主;Markdown 仅在确认渲染效果后启用。 ## 8. 配置(草案) ~~~yaml gate: enabled: true mentionBypass: true # @ bot / 引用 bot 的消息 / 命令直通 fastWindow: 10m # bot 最近发言窗口 fastMinReplyInterval: 45s # 两次发言的最小间隔 debounce: 5s # 快路批合并窗口 slowTimer: 2h # 慢路定时器 slowWindow: 2h # jev 查看的消息范围 model: typesafe/jev-1.14 # jev 模型 id jevEndpoint: https://openrouter.ai/api/alpha/decisions jevApiKeyEnv: OPENROUTER_API_KEY # 兜底:从环境变量取 key jevApiKey: '' # v0.24.0:设置页保存的 key(只写不读;留空回退环境变量) jevThreshold: 0.7 # Noul 概率 >= 该值才接话 classifierTimeoutMs: 8s # 单次 jev 请求超时 retentionDays: 7 maxBatchMessages: 100 # 代答(v0.18.0,默认关闭) proxyReply: enabled: false # 是否启用代答 targets: [] # QQ 号或群名片 / 昵称,≤20 项 silenceWindow: 10m # 目标多久没在本群发言才代答;0 = 不检查沉默 ~~~ - **配置页可改(v0.10.0 起,v0.24.0 / v0.25.6 扩展)**:`debounce` / `fastMinReplyInterval` / `jevThreshold` / `jevApiKey` / `model` / `jevEndpoint` 六个参数在插件面板 → dsh-qq-relay 组合包页 → 配置页「行为」里编辑,保存后立即生效(无需重启):前三个重建闸门;`jevThreshold` 由分类器每次判定时读取;`jevApiKey` / `model` / `jevEndpoint` 直接换分类器实现——**不重建 relay 连接**(v0.25.6 起模型与地址也可改,页面回填当前生效值;地址只接受 `https://`)。 其余 gate 字段仍只读(改 profile patch + 重启)。 - 时长可写 `500ms` / `3s` / `10m` / `2h`;配置页保存的值叠加在 patch 之上(同名字段覆盖)。 ## 9. 测试要点 - 冷启动与重启后的冷却恢复(不立即发言)。 - 同一 message_id 重复事件只处理一次。 - 快路连续三条消息只产生一次 jev 调用与一次回复。 - 慢路 2h 到点、无消息时 no-op;有消息时只评估最近 2h。 - @ 直通在冷却期内仍然工作。 - @ 别人的消息 / 引用别人消息 → 不触发会话(v0.18.0)。 - 代答:开启 + 目标沉默 → 起一轮且 prompt 带代答说明;目标最近说过话 → 不代答;关闭 → 不触发。 - DSH 单轮超过 5 分钟仍能正常回复(真实 QQ 号无窗口)。 - 发送失败 / retcode 频率限制下的退避与降级。 - WS 断线重连后不重复回复、不丢队列。 - 机器人自己发出的 message_sent 被忽略。 - @ 消息与普通消息的区分。 ## 10. 待定 - ~~jev 的部署形态与模型选择~~ 已定:OpenRouter Decisions API + `typesafe/jev-1.14`(P2.3)。 - 慢路评估游标:按「最近 2 小时」还是「距上次评估以来」。 - 是否对 @ 直通增加「处理中」即时回执(默认关闭)。 - 队列保留时限与丢弃策略。 ## 11. 实现备注(P2.1,2026-10-19) - **代码位置**:纯逻辑 `lib/model/gate.js`(配置解析 / 决策 fail closed / 批次 / 时间窗); 调度 `lib/controller/gate.js`(去抖、最小间隔、慢路定时、分类器调用、决策日志); 接入 `lib/controller/relay.js`(直通判定、批次派发、发送队列、dshBusy 标记); 状态 `lib/model/state.js`;决策日志 `lib/model/gate-log.js`。 - **配置键**(`config.gate`,见 architecture §2.1):`enabled`(默认 false)、`mentionBypass`、 `fastWindow`(10m)、`fastMinReplyInterval`(45s)、`debounce`(5s)、`slowTimer`(2h)、 `slowWindow`(2h)、`maxBatchMessages`(100)、`classifierTimeoutMs`(8s)、`model`、 `jevEndpoint`、`jevApiKeyEnv`、`jevApiKey`(v0.24.0,只写不读)、`jevThreshold`。 时长字段接受 `500ms` / `45s` / `10m` / `2h` 或毫秒数字,内部统一为毫秒;未知键 / 非法值直接 进 `problems`(配置整体 fail closed)。 - **分类器接口**:`createRelay({ classifier })` 注入 `{ classify({ groupId, groupName, trigger, messages }) } → { reply, confidence, reason, targetMsgIds }`, `trigger` 为 `fast` / `slow`。缺省 `createNullClassifier()` 永远返回 `reply: false, reason: 'classifier-not-configured'`;超时或抛错按 `reply: false` 处理,且每次都写 一条决策日志(批次条数 / reply / confidence / reason / 耗时)。 - **jev 实现(P2.3;v0.24.0 key 可配置)**:`lib/model/jev.js` 提供 `createJevClassifier` (OpenRouter Decisions API,全局 `fetch`,零依赖);`model/gate.js` 的 `resolveJevApiKey` 按「配置 `jevApiKey` > 环境变量 `jevApiKeyEnv`」取 key。组合根把分类器包成稳定代理, key / 阈值变化时只替换内部实现(不重建 relay);gate 关闭或无 key 时退化为 fail closed。 请求 / 解析 / 阈值 / 超时的单测在 `test/jev.test.js`,key 解析与热切换在 model / apply 测试。 - **状态与恢复**:每群一个 `state/.json`;pending 随消息增量落盘,重启后重新装载; 但**不重建慢路定时器**——重启后第一条消息才会重新起 2 小时计时(避免一启动就说话)。 - **慢路时间窗(P2.2)**:慢路评估只取 `time >= now - slowWindow` 的 pending,更早的丢弃 (仍保留在聊天日志里);窗口内无消息则 no-op。 - **DSH 忙(P2.2)**:relay 派发批次时置 `dshBusy=true`,本轮 turn/end 后置 false;闸门评估发现 本群 `dshBusy` 就延后(pending 保留),避免同一群并发起两个 turn。 - **决策日志(P2.2)**:每次决策落一条 `gate/YYYY-MM-DD.jsonl`(time / groupId / trigger / batch / reply / confidence / reason / costMs / messageIds),**不存正文**;保留 7 天(插件内部诊断数据, 与聊天记录的保留策略分开),由组合根启动时和每天清理一次。 - **上下文与派发分离(v0.7.1)**:分类器看的是聊天日志最近 60 条(含 bot 发言),派发 DSH 的 prompt 只包含 pending 本批消息;这样 jev 能判断「刚被 @ 过 / 刚回过一句」这类语境, 又不会把旧消息重复喂给 agent。 - **prompt 拼接(P4)**:派发文本由 `lib/model/prompt.js` 统一生成——群名 / 群号 / 本地时间 / 条数 / 每行显示名与 @、引用标记,末尾给聊天日志目录的相对路径指针;直通与闸门共用。 格式与示例见 [chat-context.md §5](./chat-context.md)。 - **媒体(P4.1)**:pending / 聊天日志里的消息可带 `media` 数组;派发 prompt 里显示 `[图片:<相对路径>]` / `[表情:…]` / `[媒体下载失败]`。纯媒体消息仍不进闸门(没有文本), 但 @ 带图走直通时会带上媒体路径,见 [media.md](./media.md)。 - **发送队列**:`relay` 内存队列(不落盘,进程重启即丢),断线 / 发送失败入队,`client open` 时按序补发;超过 30 分钟丢弃并记日志,上限 50 条。失败分类:文本命中频率 / 风控关键词 → 退避重试, 其余 → 不重试(避免刷错误日志)。 - **无文本消息(v0.8.1)**:纯图片 / 表情等 `text` 为空的消息只写聊天日志,`ingest` 直接返回, 不进 pending、不调分类器——此前会白调一次 jev。 - **触发边界(v0.18.0)**:codec 给群消息补 `atTargets` / `mentionsOthers`;relay 在 `proceedInbound` 里用「bot 消息 id 集合」判定 `replyToSelf`,@ 别人的消息不进 `gate.ingest`; 代答在闸门之前判定(`scheduleBypass(reason='proxy')`,prompt 用 `notes` / `atNames` 标记)。 - **成员昵称**:直接用 OneBot 事件自带的 `sender.card || sender.nickname` 与 `sender.role`, 不额外调 `get_group_member_info`(事件缺字段时才需要补查,暂未实现)。 - **尚未实现**:真正的「群成员信息补查」、多群资源配额;jev 阈值调优(先用 0.7,按决策日志再调)。 - **单测**:`test/gate.test.js`(假时钟:合并 / 最小间隔 / 慢路 / fail closed / 重启恢复)、 `test/model.test.js`(配置 / 决策 / 批次 / 发送失败分类 / jev 配置)、 `test/jev.test.js`(请求构造 / 响应解析 / 阈值 / 各类失败 fail closed)、 `test/prompt.test.js`(拼接格式 / 标记 / 历史指针)、 `test/relay.test.js`(直通不受闸门影响、断线队列补发、prompt 拼接)。