# dsh-onebot-qq v0.1.2 · 开发指南(架构 + 子插件 SDK) > 读者:想读懂 `dsh-onebot-qq`、改内核、或加子插件的人。 > **上半(§〇~§五、§十)是整体架构与装配契约;下半(§六~§九、§十一~§十二)是子插件 SDK、五阶段与红线。** > > 改动以本文件 + `contracts.js` + `test/` 为准;**不要直接修改正在运行的实例数据目录**。 > 只想用插件 / 装插件:看 [使用说明书](使用说明书.md) / [安装与卸载](安装与卸载.md)。 ## 〇、改动原则(硬约束) 1. **能删就不改,能改就不加,有必要才加**;同一功能只留一处实现、一个数据源,不铺冗余分支。 2. **不破坏原有 Linux 支持**:改代码前先想两个平台;**有破坏风险就加保护层**(放 `platform/` 或用已有的 `normForCompare`/`os.tmpdir()`/`path.*` 抽象),宁可多一行保护,也不赌平台一致。 ## 一、目标 把一个只在某台 Linux 机器上跑得起来的 QQ 机器人,提取成**可分发、零私有数据、Linux/Windows 双平台**的通用 DSH 插件包: - 安装:DSH `plugin_manager install_bundle`(tgz / GitHub / 本地绝对路径); - 运行:in-process 挂进 dsh-web(**没有独立进程 / systemd**); - 配置:实例 `config.json`(0600,不进包)+ 少量 bundle 引导配置; - 子插件:`plugins//index.js`,内核自动发现; - 控制台:DSH 设置页「QQ 控制台」(host 半 `connection.rpc` + client 半单文件); - 工具:内核原生注册 `mcp__qq__*`(**没有 MCP server**); - 平台:所有 OS 差异只在 `platform/`;Windows 无 symlink/无 bash 也能跑。 ## 二、目录与职责 ```text index.js 宿主入口:apply(ctx, config),装配一切 contracts.js 冻结契约 v1(事件/五阶段/子插件接口/脱敏种类/withDefaults) client.js 浏览器半边(现网 QQ 控制台 UI;factory id = 包名 'dsh-onebot-qq') kernel/ 消息框架(不涉具体业务) format.js 协议归一/CQ/[AT:]/6 项脱敏/截断/prompt(escapeCq 从 platform 再导出) onebot-adapter.js OneBot v11 适配器(任意 OneBot v11 实现通用):WS+HTTP、重连、getFile、头像 state-pipeline.js ChatState 串行锁、5 阶段管线、唯一出站 deliver()/deliver.media() pacing.js 等待槽(私聊合批/群聊排队)+ 群私聊共享随机窗主动调度(state/proactive.json) dsh-driver.js **只保留 in-process**:ctx.sessionController 直连(删 rpc 半条) compat-server.js 可选 :3971 兼容网关(默认不启动) config-store.js 实例配置唯一真源(读/校验/迁移/原子写/路径解析) admin/ 控制面(仅本包内使用) schema.js 核心开关表 + 子插件 configSchema 汇总 → controls/groups/modules store.js 写实例配置(备份→原子写→回滚→热重载) endpoints.js 9 端点实现:schema/status/set/module/group/memory/persona/action/monitor host.js connection.rpc.handle('/qq-console')(可选 webServer 兜底) ops/maintenance.js 内置离线自检、受限临时缓存清理与配置备份去重(预演令牌 + 确认重验) platform/ 唯一 OS 适配层:paths/cq-file/atomic/persona-ref plugins//index.js 12 个业务子插件(契约 v1;其中 2 个可选:防刷屏 / 私聊好友门禁) plugins/stickers/ 表情包:index.js(采集/提醒/工具)+ shelf.js(库存储:索引/去重/挑图,自己的实现) bin/dsh-onebot-qq.mjs CLI:init / doctor / import-legacy / persona examples/ 示例配置 / patch / preset / QQ 线指令模板 / 人设模板 / Windows 脚本 docs/ 用户文档(总览/使用说明书/安装与卸载/Windows安装/配置参考/排错/开发指南/运维接口) test/ 跨平台测试(fixture 化,不依赖生产机路径) scripts/ 打包守卫(Node 实现,禁止 bash) ``` ### 协议端差异(OneBot v11 各实现) 适配器只依赖 **OneBot v11**,不认某个具体实现;但各实现在细节上有差异,写代码/加能力时注意: | 维度 | 多数实现 | SnowLuma | 适配器怎么处理 | |---|---|---|---| | `message_id` | 一般为正整数 | **有符号 int32,可为负** | 只有 `0` 表示「不挂引用」,负值照挂;`parseWire` 正则 `-?\d+` | | 群历史锚点 | 多认 `message_seq` | 认 `message_id`(`0`=最新) | `groupHistory` **两个都发**,两端都忽略未知键 | | 在线/链路健康 | 部分实现 `get_status` 命中缓存不可靠 | `get_status` 给 `{online, good}`,`good=false`=收不进 | 先看 `get_status`,`online/good=false` 判异常;拿不到字段时**回退** `get_stranger_info` 往返 + `get_login_info` | | 鉴权 | token 多可空 | **3000/3001 强制 accessToken** | 配置层解决(`wsToken`/`httpToken`),适配器无差别 | | `get_file` | 一般覆盖面较全 | **只覆盖图片/语音缓存** | 入站群文件/视频溯源可能拿不到 → 调用方按「拿不到就降级」写,别当必成功 | > 加新能力时的自检:这条调用在不同协议端都会返回同样的字段吗?不会就写兼容分支,并用 `test/fake-onebot.mjs` 打桩覆盖多种实现形状。 ## 三、入口装配(index.js) ```js export const name = 'dsh-onebot-qq' export const inject = ['tools', 'sessionController'] export const Config = z.object({ dataDir: z.string().default(''), // '' = $DSH_HOME/dsh-onebot-qq configPath: z.string().default(''), autoConnect: z.boolean().default(true), compatServer: z.boolean().default(false), compatPort: z.number().default(3971), }) export async function apply(ctx, bundleConfig = {}) { // 1. 加载实例配置(kernel/config-store.js) const { dataDir, configPath, raw, runtime, migrated, created } = loadConfig({...}) const errors = validateConfig(runtime) // 新用户首次安装:默认配置只缺 adminQq → 进入「待配置模式」(不 throw、不连协议端, // 控制台/工具照常挂载,用户在控制台新手向导里填完即可自动连接)。 // 其它必填错误仍 throw。 if (errors.length && !onlyAdminQqMissing(errors)) throw new Error(...) // 2. 装配:OneBotAdapter(runtime.onebot) / ChatState / Pipeline / pacing(runtime.pacing) // archiver(runtime.archive) / createDeliver(...) / createDshDriver({mode:'in-process', ...}) // 3. 加载子插件:readdir(plugins/) → import(pathToFileURL(plugins//index.js)) // 每个插件 cfg = withDefaults(plugin.configSchema, runtime.pluginConfig[id]) // → resolveRuntimePaths(相对 dataDir)→ setup(bot) // 4. 注册内核工具(send_message/recall + 插件 registerTool)到 ctx.tools // 5. admin/host.js:注册 /qq-console RPC(直接调 kernel.handleAdminRpc) // 6. compatServer=true 时才 startCompatServer // 7. autoConnect 时 onebot.connect() // 8. ctx.effect(() => cleanup) 逆序回收 } ``` 关键要求: - **单例保护**:用 `ctx.get('qqBot')` 判断已装载(真 Cordis 下不能直接读写 `ctx.qqBotKernel`,未 inject 的属性会抛);服务随 fiber 自动回收。 - **待配置模式**:`kernel.configReady` 表示必填配置是否齐;`false` 时控制台仍可用、不连协议端;保存配置后热重载 → `configReady=true` 并自动连接。 - **配置热重载**:`kernel.reloadConfig()` 重新 `loadConfig` + 原地同步插件 cfg;常规参数下次回调/轮次读取,初始化资源须显式更新,启动装配项需重载插件/重启(见 [配置参考 §六](配置参考.md#六控制台新手向导与热改)),不承诺所有字段立即生效。 - **引用绑定**:模型工具回复归属本轮 TurnContext,自动引用以入站序号差计算触发消息之后的插话(覆盖管线及建会话等待),达到阈值时引用该轮 triggerMsgId,不读可挤出的锚队首;活跃上下文不以五分钟 TTL 提前丢弃。 - 没有独立进程 / 额外 RPC 层:内核跑在 DSH 进程内,插件只认 `bot` 能力对象。 ## 四、配置系统 - **bundle Config**:只放 `dataDir/configPath/autoConnect/compatServer/compatPort`(见上)。 - **实例配置**:`loadConfig()` 返回 `{dataDir, configPath, raw, runtime, migrated, created}`: - `raw`:文件原样(v2 结构,见 `kernel/config-store.js` 头注释); - `runtime`:`raw` 深拷贝 + 所有路径字段按 dataDir 解析为绝对路径 + 环境变量凭据覆盖; - 旧格式(含 `bridge` 段)自动 `migrateLegacyConfig()`;默认 `createIfMissing=true` 时先备份为 `*.bak-legacy-<时间戳>` 再落盘,`false` 时只在内存转换;显式导入可用 `bin import-legacy`; - 文件不存在时 `created=true` 写入默认配置(`adminQq: 0`)→ 进入待配置模式(控制台新手向导填 adminQq / 协议端地址)。 - **控制台写配置**:`admin/store.js` 用 `saveConfig()`(备份 `*.bak-console-` → 原子写 → 失败回滚),然后 `kernel.reloadConfig()`;热重载成功且从待配置转正时自动连接协议端。 - **生图凭据**:`credentials.gptImage.{protocol,baseUrl,apiKey,model}`(控制台「生图」页可填,环境变量 `QQ_IMAGEGEN_*` 优先;每次调用现取 → 热改免重启)。 - **路径字段解析规则**(config-store 已实现):键名以 `dir/file/path` 结尾的字符串 → 相对 dataDir 解析;键名以 `whitelist/roots/dirs/paths` 结尾的字符串数组 → 逐项解析。新加配置项时按此命名。 主动接话只保留 `pacing.reply.interval` 五项共享毫秒参数(默认正常 1–4h、未回 4–10h、退避 cap24h);schema 写侧与配置文件共用 `validateReplyInterval` 验证有限正数、窗排序和 cap。各会话共享状态格式(`nextAt` / `unanswered` 等)保存到 `state/proactive.json`,旧私聊状态迁入;普通群发言不重置未回,只有指向机器人的真人唤醒互动才算回应。配置或免打扰策略变更重排一次,相同配置应用和 stats 刷新不得调用随机数。连续对话保留独立频控,详情见[配置参考](<配置参考.md>)。 ## 五、platform API(唯一 OS 适配层) ```js // paths.js isWin, resolveDshHome(env), resolveDataDir(configured, env), resolveTmpDir(...sub), isFullAbsolute(p), resolveDataPath(value, dataDir), canonicalPath(p), normForCompare(p), isInsidePath(root, candidate), ensureDir(dir) // cq-file.js escapeCq(s)(`& [ ] ,` 四项), mediaRefMode(env), toOneBotFileRef(file, opts), fromOneBotFileRef(ref), cqMedia(kind, file, opts), cqImage(file, opts), cqVideo(file, opts) // atomic.js stripBom(s), readJsonFile(file), atomicWriteFileSync(file, data, opts), writeJsonFileSync(file, value), backupFileSync(file, tag) // persona-ref.js canSymlink(dir), listPersonas(personaDir), personaSources(personaDir, name), readPersonaPointer(workspaceDir), activePersona({configured, workspaceDir, personaDir}), beginPersonaSwitch({workspaceDir, personaDir, name, mode}) // {result, commit, rollback} switchPersona({workspaceDir, personaDir, name, mode}) // 同步兼容入口,直接提交 ``` 人设投递用 `beginPersonaSwitch` 保留原文件 inode / 软链 / pointer 的精确备份:控制台内置切换作为 `store.update` 的 effect 纳入同配置路径事务,CLI 在配置保存成功后才 commit,失败 rollback 恢复原投递,不重读旧人设源再投。不再提供本机运维扩展的人设切换入口。 > 业务代码怎么用这些函数、禁止做什么,统一见 [§十一 红线](#十一红线code-review-必查);不要另起一套。 ## 六、子插件契约(v1) - 内核是**微内核**:只做协议接入(OneBot v11,任意实现通用)、五阶段管线、唯一出站口、会话驱动、工具注册。 - 业务能力全部是 `plugins//index.js` 里的**子插件**;内核启动时 `readdir(plugins/)` 自动发现, 按 `order` 稳定排序装配。 - 子插件只认内核注入的 `bot` 能力对象,**不 import 内核内部实现**;契约版本 `CONTRACT_VERSION = 1`。 - 单个子插件 import/setup 失败**只记日志**,不影响其他插件与主链路。 现有 12 个子插件(order 即装配顺序): ``` plugins/ ├── gate-wake/index.js # order 10 门禁与唤醒 ├── anti-repeat/index.js # order 15 防刷屏(可选) ├── commands/index.js # order 20 基础命令 ├── private-friend-gate/index.js # order 20 私聊好友门禁(可选) ├── file-transfer/index.js # order 30 文件与发图 ├── persona-voice/index.js # order 40 人设与句池 ├── message-monitor/index.js # order 55 消息监测(规则匹配与附加指令) ├── group-welcome/index.js # order 55 入群欢迎 ├── imagegen/index.js # order 60 生图与头像 ├── link-parser/index.js # order 70 链接解析(默认关) ├── stickers/index.js # order 80 表情包 └── group-memory/ # order 90 群记忆(index/daily/storage/model 四个文件,见 §十·一) ``` 一个子插件 = **一个 kebab-case 目录 + `index.js`**,默认导出: ```js export default { id: 'weather', // 必须等于目录名 title: '天气查询', // 控制台「模块」里显示的名字 order: 65, // 同一轮内排序,小在前;缺省 100 configSchema: { // 声明式配置 → 自动默认值 + 控制台表单 + 校验 enabled: { type: 'bool', default: true, group: '天气', label: '天气插件' }, }, setup(bot) { // 在这里挂能力:bot.on / onOutbound / registerCommand / registerTool }, } ``` **不要**返回任何东西;setup 里也**尽量不要**启动定时器(用 `bot.on` 与轮次上下文表达行为)。确实需要长驻定时器(如群记忆每日整理)时,必须把清理函数交给 `bot.onDispose()`,否则插件重载会叠出第二个实例。 `config.plugins[id] === false` 表示完全不加载该插件。 ### `bot` 能力表 | 成员 | 说明 | |---|---| | `bot.id` | 本插件 id | | `bot.cfg` | 本插件配置(已套 `configSchema` 默认值)。**每次回调里现读**,控制台热改会就地改它 | | `bot.log` | `{ info, warn, error, debug }`,自动带 `[onebot-qq:]` 前缀 | | `bot.contractVersion` | 契约版本(当前 `1`) | | `bot.on(stage, fn, opts?)` | 挂入站阶段钩子,`fn(ev)`;`opts.order` 覆盖插件 order | | `bot.onOutbound(fn)` | 发送前装饰器,`fn(out)`;可改 `out.text / out.ats / out.replyTo / out.mode`;不是发送成功通知 | | `bot.onAfterSend(fn)` | `fn(rec)` 成功出站只读通知,返回取消订阅函数;文本、媒体、forward 每次成功 deliver 调用通知一次(不是逐分片);失败不通知,回调异常隔离、不改变发送结果 | | `bot.registerCommand(name, def)` | 注册斜杠命令 `{ permission: 'any'\|'master', description, handler(ev, args) }`;自动进 `/help`;唯一分发入口统一校验:默认 any,master 仅主人可进入 handler,未知权限拒绝 | | `bot.registerTool(name, def)` | 注册模型原生工具 `{ description, parameters, execute(args) }`;自动加 `toolPrefix` 前缀 | | `bot.deliver(target, text, opts?)` | **唯一文本出站口**;`opts = { replyTo, ats, turnCtx }` | | `bot.sendMedia(target, cqBody, caption?)` | **唯一媒体出站口**(发图/视频;内部走脱敏 + 留档) | | `bot.state` | ChatState 访问器:`{ get(chatKey) }`;跨轮状态存 `st._scratch[':key']` | | `bot.pacing` | 回复节奏(内核级):配置与统计;主动接话由内核调度,插件只读 | | `bot.currentTurn(target?)` | 取本轮 TurnContext(工具执行期可用;拿不到返回 `null`,自己报错让模型重试) | | `bot.onebot` | 只读适配器:`groupHistory / groupList / friendList / memberInfo / msgById / getFile / poke / recall / fetchAvatar`;**没有裸 send** | | `bot.dsh` | 会话驱动:`{ ensureSession, prompt, cancel, rename, syncSessionTitles, … }`;后台任务 timeout/close 会请求取消,但维护工具隔离身份保留到真实 turn/end;不能以归档失败/等待超时替代终止确认,真实结束后才撤销身份 | | `bot.core` | 只读核心配置快照:`{ adminQq, outbound, archive, pacing, toolPrefix, credentials }` | | `bot.shared` | 内核级共享槽(句池/唤醒词来源等,供控制台状态展示) | | `bot.onDispose(fn)` | 注册 fiber 卸载回调(定时器/订阅等资源必须走这里;内核会逆序执行) | > 两条结构性纪律:**插件永远拿不到裸 send**(出站只能 `deliver`/`sendMedia`); > **插件永远不自己开全局 Map**(跨轮状态进 ChatState / TurnContext)。 ## 七、入站五阶段(`STAGES`) 一条消息严格按顺序流转;每阶段的语义决定你的钩子该怎么写: | 阶段 | 语义 | 失败行为 | 典型用途 | |---|---|---|---| | `pre-gate` | 硬门禁 | **fail-closed**:抛错 = 拦下这条消息(连留档都不写) | 屏蔽群、黑名单 | | `passive-tap` | 旁路采集 | **fail-open**:抛错只记日志 | 入站文件落盘、表情包入库 | | `intercept` | 免模型拦截 | **fail-open** | `/命令`、链接解析、被戳回戳、引用**本机**消息说「撤回」 | | `wake` | 唤醒判定(写 `ev.wake`) | **fail-open** | @/引用/唤醒词、主动接话候选 | | `before-turn` | 拼 prompt 前改写(`ev.hint.push(...)`) | **fail-open** | 群记忆指针、表情包提醒、图片提示 | 铁律: 1. **`wake` 阶段没判定唤醒就只 `return`,绝不要设 `ev.stopped = true`** —— 否则会掐死后面 order 更大的唤醒插件。 2. **`before-turn` 对未唤醒消息也会执行**(内核在 5 阶段之后才判断 `!ev.wake`)→ 钩子第一行自己 `if (!ev.wake) return`。 3. **不要每轮改静态系统提示**(会毁 KV cache);动态内容一律 `ev.hint`。 4. `intercept` 处理完不需要模型回话就设 `ev.stopped = true`(零 token);需要模型接着聊就保持放行。 5. `passive-tap` 里**不要 `await` 慢操作**长时间阻塞主链(要做长活就排队/异步)。 `ev` 是普通可变对象(见 `contracts.js` 的 `createEvent`),主要字段: ``` chatKey / isGroup / groupId / messageId / sender / time text / rawSegments / mentionedSelf / replyToId / images / files / cards wake(阶段写入)/ hint / imageParts / imagePaths / historyBlock rawText / stopped / stopReason / _scratch / turnCtx ``` ## 八、三步加一个子插件 ### 第 1 步:建目录 + `index.js` 复制下面的模板改(`plugins/weather/index.js`): ```js export default { id: 'weather', title: '天气查询', order: 65, configSchema: { enabled: { type: 'bool', default: true, group: '天气', label: '天气查询' }, defaultCity: { type: 'str', default: '北京', group: '天气', label: '默认城市' }, cooldownSec: { type: 'int', unit: '秒', scale: 1000, default: 10, min: 1, max: 300, group: '天气', label: '查询冷却' }, }, setup(bot) { // 模式 A:斜杠命令(零 token) bot.registerCommand('weather', { permission: 'any', description: '查天气', async handler(ev, args) { if (!bot.cfg.enabled) return const city = args[0] || bot.cfg.defaultCity await bot.deliver(ev.chatKey, `🌤 ${city}:晴,25℃(模拟数据)`) ev.stopped = true ev.stopReason = 'weather:command' }, }) // 模式 B:给模型的原生工具(自动加 mcp__qq__ 前缀) bot.registerTool('weather', { description: '查询城市天气', parameters: { target: { type: 'string', required: true, description: '当前会话 group:xxx / private:xxx' }, city: { type: 'string', description: '城市名,默认用配置' }, }, async execute(args = {}) { const turn = bot.currentTurn(args.target) return { ok: true, city: args.city || bot.cfg.defaultCity, weather: '晴 25℃(模拟)', turn: Boolean(turn) } }, }) // 模式 C:before-turn 注入本轮提示 bot.on('before-turn', (ev) => { if (!bot.cfg.enabled || !ev.wake) return ev.hint.push('[天气插件已就绪]') }) }, } ``` ### 第 2 步:重启 dsh-web 内核启动时自动发现新目录,**不需要**在任何清单里登记(v2 起没有 `PLUGIN_IDS` 硬编码)。 日志里会看到 `插件 14/14` 之类的计数(N = 实际发现的插件数)与零加载错误。 ### 第 3 步:跑测试 + 补文档 ```bash node --check plugins/weather/index.js node --test ``` - 配置项会自动出现在控制台(`group` = 页内标签名;写一个新 group 名就自动多一页); - 若注册了新模型工具:在 `/workspace/AGENTS.md`(QQ 线指令)里加一句「什么时候调它」; - 若有新的平台差异(路径/临时目录/CQ/软链),**只允许**加在 `platform/`,业务代码里用它的函数。 > 在 fork 的源码仓库 `plugins/` 下开发,通过本地 link 安装测试,改后显式重载插件或重启;不要直接修改运行实例 / `node_modules`(升级会覆盖)。当前没有受控的外部子插件加载目录。 ## 九、`configSchema` 字段类型 | `type` | 控件 | 额外字段 | |---|---|---| | `bool` | 开关 | — | | `int` | 数字 | `unit`(显示单位)、`scale`(子插件仅说明内部单位换算,存储仍为面板单位)、`min`/`max`/`step` | | `float` | 小数 | `unit`/`scale`/`min`/`max`/`step` | | `str` | 单行文本 | `hint` | | `text` | 多行 textarea | `hint` | | `enum` | 下拉 | `options: [{value,label}]` | | `group` | 字符串数组 | 群号列表等 | 每个字段还可带 `group`(控制台页)、`label`、`hint`、`default`。 **两套单位不可混用**:核心控制项在 readValue/writeValue 边界做「面板值 × scale = 存储值」;子插件 raw / bot.cfg 存人话单位,scale 不自动换算。例如上面的 cooldownSec 存 10 秒,插件使用时自己乘 1000,分钟乘 60000,且只换一次。 **默认值不许是机器绝对路径**:路径类默认用相对 `dataDir` 的名字(如 `"personas"`)。 **隐式约定(容易踩)**:`resolveRuntimePaths` 会按**键名**自动把字符串解析成绝对路径 —— 以 `dir` / `path` / `file` 结尾的键、以及以 `whitelist` / `roots` / `dirs` / `paths` 结尾的数组, 都会相对 `dataDir` 解析。起字段名时注意别误伤(例如 `profile` 不算路径,但 `avatarPath` 算)。 ## 十、控制台与运维面 - `admin/host.js`:`ctx.connection.rpc.handle('/qq-console', async (endpoint, payload) => ({ ok:true, value }))`;失败时 `{ok:false,error}`;注册必须在 `apply` 的 effect 里。可选 `ctx.webServer` 兜底路由(照现网 console host 注释里的信封坑)。 - `admin/schema.js`:生成 `{controls:[{key,group,type,label,unit,scale,min,max,step,options,value,hint}], groups, modules}`; - 核心控制项(`core.` 前缀,映射到顶层配置):adminQq / onebot.* / dsh.* / outbound.* / archive.* / toolPrefix / pacing.reply.* / pacing.merge.*; - 子插件控制项(key = `.`,映射到 `pluginConfig..`); - `modules` 是子插件列表(内核已加载状态)。 - `admin/store.js`:按 canonical 配置路径在**进程内**串行整个 save/update 事务(update 在队列内读最新配置→修改→校验→备份/写→重载→失败回滚);整份 save 仍是替换语义,不合并过期副本,也不是跨进程锁。核心 scale 在面板边界换算,子插件不换。 - `admin/diagnostics.js`:`runDiagnostics({kernel, store, log})` 生成**脱敏运维报告**并落盘 `/reports/diagnose-.md`,返回 `{checks, summary, report, reportPath, fixPrompt}`;报告末尾给「交给 DSH 本体修复」的现成提示词(token / API Key / 完整 QQ 号均脱敏)。 - `admin/endpoints.js`:实现 9 端点(含 `monitor` 消息监测规则),**响应形状必须与现网 `client.js` 期望一致**(实现前先通读 `client.js` 的调用与字段读取);`action` 额外支持 `diagnose`(一键诊断/导出报告): - `schema`、`status`、`set`、`module`、`group`(ignore/allowlist/blocklist/keywords/spont/welcome 的 add/remove/ensure)、`memory`(list/read/save/config/setcfg/refresh/model-catalog/model-select)、`persona`(list/switch)、`action`(reply-preset/reset-session/welcome-images/welcome-test;**QQ 登录用协议端自带 WebUI**——本机在用的 SnowLuma 默认 `:5099`,其它实现见其文档——不进控制台)。 - [`ops/maintenance.js`](<../ops/maintenance.js>):包内维护实现,不加载本机扩展、不注入任意运维对象。`selfcheck` 使用离线诊断(不联网、不发 QQ、不写报告);`cleanup-tmp` / `dedup-baks` 先预演返回短期单次 `previewToken`,确认必须携带原令牌,执行只处理原计划并重验变化,在用/变化对象跳过。清理仅限 `incoming` / `imagegen` 专用临时缓存的直接普通文件(超过 24 小时),不递归或跟随软链;备份仅限当前配置旁 17 位数字时间戳控制台备份,合法 JSON 且字节完全相同才去重,至少保留最新 3 份。具体约束见[内置运维](<运维接口.md>)。群记忆积累和人设切换只走已有内置能力,不再提供扩展兜底。 **可选兼容网关**(`kernel/compat-server.js`): - 只有 `bundleConfig.compatServer === true` 才启动,默认关; - 端口 `bundleConfig.compatPort`;图片白名单根来自 `runtime.compat.imageRoots` + tmp; - 保留 `/health`、`/send`、`/send-image`、`/ignore`、`/admin` 等旧脚本可能用到的端点;**删除工具路由**(工具走内核原生注册,不再经 HTTP)。 ### 十·一、群记忆子系统(唯一实现:每日整理) `plugins/group-memory/` 只有四个文件,职责不重叠: | 文件 | 职责 | |---|---| | `index.js` | 子插件入口:读取侧(before-turn 指针、`read_memory` 工具)、`memory_append` 工具、接线与 dispose | | `daily.js` | 每日整理:调度、素材筛选、提示词、结构化操作、记忆文件解析/渲染、状态持久化 | | `storage.js` | 唯一写盘入口:备份到 `.revisions/` → 原子写 → 失效缓存 → 按数量清理历史版本 | | `model.js` | 专用分析模型解析(`{provider, model}`,两项都空 = 继承默认) | **数据流** ``` QQ 留档 logs/qq_chat/_.jsonl(in/out,含 triggerMsgId / interactionMessageIds / replyTo) → selectInteractions():只取机器人参与的互动 + 引用祖先,不整群回灌 → 过滤 baselineAt 之前、state.groups[key].seen 与记忆页脚 processed 里已处理的来源 → 每会话拼一份提示词(现有记忆 + 待处理互动 + 固定规则 + batchId) → runIsolatedTask(一次性后台会话,只放行 memory_append) → 模型提交 operations JSON(add/update/remove + sourceIds + batchId) → applyOperations():校验来源、ID、分节、近期到期 → renderMemory() → storage.writeMemoryDocument():版本备份 + 原子写 + 清缓存 ``` **必须保持的不变量** 1. **一次会话一次调用**:不把多个会话塞进同一个模型上下文,避免串线;单轮会话数由 `accumulateMaxGroupsPerRound` 限制,游标轮转。 2. **输入不设业务预算**:本机器人参与的互动整份交给模型(只有 400 万字符的崩溃保护);**预算约束记忆文件本体**(`maxBytes`)。结果超限先带提示重做一次压缩,仍超才 `pending`;从不按条数采样丢弃。素材按 60 条/8000 字分块顺序提交,块间以最新文件为基准。 3. **不信任模型输出**:`operations` 逐条校验(来源必须属于本任务、ID 必须存在、`section` 必须合法、文本单行);`batchId` 必须等于当前任务令牌,否则拒绝。旧任务超时后不能提交到新批次。 4. **进度与写入同源**:成功提交才推进 `state.groups[key].seen` 与页脚 `processed`;失败/超时/冲突都不推进,下轮重试。`operations=[]` 是成功,表示「已处理、无值得记的内容」。 5. **写盘只有一处**:`storage.writeMemoryDocument()`。写前比对原文(`rawOf(key) !== original` 视为人工编辑冲突,保留待办),写后清 `index.js` 的 rawCache。 6. **文件格式即契约**:三个分节标题固定;条目 `- [数字ID] 文本 (更新:时间[;到期:时间])`;页脚 `` 只存 `id/section/updatedAt/expiresAt/h`(`h` = 正文前 8 位 sha256,用来判断人工是否真的改过内容,避免每次保存都刷新时间);**正文是唯一事实来源**,解析时永远按可见行重建条目。**回执不进正文文件**(进度只在 state);控制台保存与每日整理共用 `normalizeManagedDocument()` 重建页脚,缺分节自动补齐,缺元数据/格式非法则**失败待办**,绝不静默覆盖。 7. **读取侧与写盘分离**:`read_memory` 只读、按 `maxBytes` 整行截断,并剥掉条目 ID、时间戳与页脚(记账信息不进模型上下文);读取侧永不写盘。 8. **淡忘**:`applyForgetting()` 只作用于 stable/recent,超过 3 天后每天按概率删除(上限 60%),`promises` 永不遗忘;遗忘也走 `commit`,留在 `.revisions` 可追溯。 9. **调度失败要软**:时间/时区非法、状态文件损坏时不重置基线、不写盘、状态里报错;首次启用建立基线,不回灌启动前历史。 **ID 规则**:新条目由本地分配递增短数字(`nextId` 持久化,删除后不复用);历史 `legacy-`/`m-` 前缀 ID 仍合法。 **配置**(完整键见[配置参考 §五](<配置参考.md#group-memoryorder-90群记忆>)):`accumulateEnabled`、`accumulateDailyTime`、`accumulateTimezone`、`accumulateRecentDays`、`accumulateModelProvider`/`accumulateModel`、`accumulateMaxGroupsPerRound`、`accumulateRetainRevisions`、`mutedChats`、`maxBytes`(记忆文件上限)、`dir`。 **已移除的旧机制(不要再加回来)**:2026-11-12 删除 `interval` 间隔积累、`digest`/`rewrite`/`per-group` 三种模型、`<<>>` 文本输出解析、`points` Markdown 合并、采样与水位轮询、原始留档车轮清理。旧的 `accumulateSchedule`/`accumulateMode`/`accumulateLookbackHours`/`accumulateKeepDays`/`accumulateKeepRows`/`accumulateMinNewMsgs`/`accumulateSectionMaxItems` 键不再读取,`config.json` 残留会被忽略。**同一概念只保留一份实现**:新增记忆能力改 `daily.js`,不要恢复 `accumulate.js` 或并行调度。 **测试入口**:`test/daily-memory.test.js`(调度、素材筛选、预算、ID、渲染)、`test/daily-memory-safety.test.js`(批次令牌、状态损坏、人工编辑、旧实例覆盖)、`test/memory-interaction-integration.test.js`(留档关联、读取剥离)、`test/memory-ui.test.js`(控制台控件)、`test/driver-isolated-model.test.js`(专用模型只影响后台会话)。 ## 十一、红线(Code Review 必查) ### A. 业务纪律 1. **禁止裸发消息**:出站只走 `bot.deliver()` / `bot.sendMedia()`,绝不自己请求协议端发送接口 —— 否则绕过六项脱敏与留档。 2. **禁止自建全局状态 Map**:跨轮状态进 `bot.state.get(chatKey)._scratch`(`/reset` 能干净清理、群间不串线);所有出站只走 `deliver()`/`deliver.media()`。 3. **每次回调现读 `bot.cfg`**:不要在 setup 顶部解构成常量,否则控制台热改不生效。 4. **外部依赖必须降级**:HTTP/侧车调用加超时 + `try/catch`;挂了只 `bot.log.warn` 并放行,绝不能让机器人变哑巴(例:链接解析侧车挂了就交给模型)。 5. **只 import 契约、纯函数和明确许可的无状态共享 I/O 工具**:允许 `contracts.js`、`kernel/format.js` 的纯函数及 `kernel/media-bytes.js` 的受限下载/本地读取(显式字节上限与超时,不持有会话状态);禁止 import `kernel/onebot-adapter.js`、`kernel/dsh-driver.js` 等内核实现,禁止读旧 `bridge` 配置、禁止读生产机私有目录。 ### B. 跨平台与工程纪律(Windows 必须能跑) 1. 内部路径一律 `path.join` / `path.resolve`;禁止硬编码 `/` 拼接;**禁止默认值出现 `/opt`、`/root`、`/var`、`/tmp`**。 2. 白名单/包含判定只用 `platform/paths.js` 的 `isInsidePath(root, candidate)`;**禁止** `candidate.startsWith(root + '/')`。 3. 对外媒体引用只用 `platform/cq-file.js` 的 `toOneBotFileRef()` / `cqImage()` / `cqMedia()`;入站只认 `fromOneBotFileRef()`;URL 只用 `pathToFileURL()`。 4. 临时文件只用 `resolveTmpDir()`;JSON/配置读写只用 `readJsonFile()` / `atomicWriteFileSync()`;人设投递只用 `persona-ref`(symlink→copy 降级)。 5. 运行时业务代码禁止 `child_process` / signal / systemd / cron / bash;内置运维只做受限文件系统操作,不运行外部脚本,业务代码禁止直接 `process.platform`(开发检查脚本不属于运行时业务)。 6. 新代码必须同时通过 Linux/Windows 测试;测试用 `os.tmpdir()`,symlink 用例在 win32 skip。 ### C. 发布前检查 1. 不出现生产机绝对路径字面量(系统安装目录 / 用户家目录 / 系统日志目录 / 临时目录);示例配置一律占位或相对 dataDir。 2. 不出现真实 QQ 号/群号、真实人名/人设名、真实 apiKey/token;测试数据全部合成(用 `123456789` 这类明显占位值)。 3. 不出现配置备份文件、独立进程入口或 `legacy` 运行时回落。 4. 注释里不要写私有机器路径/历史流水账;写「为什么」即可。 ## 十二、测试与打包 - `npm test` → `node --test`(包根自动发现,Linux/Windows 通用); - `scripts/check-syntax.mjs`:对包内所有 `.js/.mjs` 跑 `node --check`; - `scripts/check-no-secrets.mjs`:扫描真实 QQ 号段/密钥/私有绝对路径; - `scripts/check-pack.mjs`:`npm pack --dry-run --json` 断言清单(无 bak/config/sessions/data/参考件); - CI:`.github/workflows/ci.yml`(ubuntu-latest + windows-latest,Node 22,`npm test` + 两个 check)。 写测试的建议: - 用临时目录(`fs.mkdtempSync(path.join(os.tmpdir(), 'qqbot-'))`)做 `dataDir`,不要碰真实数据; - 装配冒烟可以只测 `import()` 与契约字段(`id` 等于目录名、`configSchema` 可渲染、`setup` 是函数); - 行为测试优先打桩 `bot` 能力对象(无需真实 DSH/协议端)。