🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

QQ 通道增强 xfgil/dsh-onebot-companion

DeepSeek Harness
xfgil18 天前有提交已提供中文文档扫描:含敏感能力

补回 QQ 通道的沉默守卫、节奏与人设语音能力

29.9/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/14 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add xfgil/dsh-onebot-companion
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:待复验
是什么
dsh 原生插件 · platform
装得上吗
曾在 dsh 0.2.0-rc.2 下真实安装成功,但当前 dsh 已更新至 0.2.1-rc.5,本站尚未在新版下复验
L4 · 真实安装等级说明 →
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 18 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

可执行外部命令读写/删除本地文件发起外部网络请求
查看扫描证据(共 30 处,展示前 30 条)
  • 发起网络请求
    lib/client.js:62
    const res = await fetch(`${BASE}/physical`, { cache: 'no-store' });
  • 读取本地文件
    lib/core/jsonstore.js:103
    raw = await readFile(this.file, 'utf8');
  • 读取本地文件
    lib/core/jsonstore.js:127
    raw = readFileSync(this.file, 'utf8');
  • 写入本地文件
    lib/core/jsonstore.js:233
    await mkdir(dirname(this.file), { recursive: true });
  • 写入本地文件
    lib/core/jsonstore.js:234
    await writeFile(tmp, JSON.stringify(out, null, 2), 'utf8');
  • 写入本地文件
    lib/core/jsonstore.js:235
    await rename(tmp, this.file);
  • 发起网络请求
    lib/modules/voice.js:194
    const res = await fetch(url, {
  • 执行外部命令来自配置/变量
    lib/modules/voice.js:214
    const child = spawn(program, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
  • 写入本地文件
    lib/modules/voice.js:317
    if (this.backend !== 'local') await mkdir(this.dir, { recursive: true });
  • 读取本地文件
    lib/modules/voice.js:376
    const info = await stat(payload.path).catch(() => undefined);
  • 读取本地文件
    lib/modules/voice.js:403
    const info = await stat(mediaPath).catch(() => undefined);
  • 删除本地文件
    lib/modules/voice.js:405
    await rm(mediaPath, { force: true });
  • 删除本地文件
    lib/modules/voice.js:415
    await rm(mediaPath, { force: true });
  • 删除本地文件
    lib/modules/voice.js:547
    await rm(result.path, { force: true }).catch(() => undefined);
  • 读取本地文件
    lib/modules/emoji-media.js:109
    await stat(target);
  • 写入本地文件
    lib/modules/emoji-media.js:116
    await rename(path, target);
  • 写入本地文件
    lib/modules/sticker-lib.js:273
    dirReady = mkdir(dir, { recursive: true })
  • 读取本地文件
    lib/modules/sticker-lib.js:350
    const info = await stat(path);
  • 写入本地文件
    lib/modules/sticker-lib.js:384
    await copyFile(path, target);
  • 读取本地文件
    lib/modules/sticker-lib.js:423
    await stat(entry.path);
  • 发起网络请求
    lib/modules/comfy.js:373
    const res = await fetch(`${base()}/system_stats`, {
  • 发起网络请求
    lib/modules/comfy.js:428
    const res = await fetch(`${base()}/prompt`, {
  • 发起网络请求
    lib/modules/comfy.js:458
    const res = await fetch(`${base()}/history/${encodeURIComponent(promptId)}`, {
  • 发起网络请求
    lib/modules/comfy.js:478
    const res = await fetch(`${base()}${viewPathOf(image)}`, {
  • 读取本地文件
    lib/core/profile.js:94
    for (const nm of readdirSync(join(home, 'profiles'))) {
  • 读取本地文件
    lib/core/profile.js:97
    if (existsSync(join(dir, 'node_modules', cfg.packageName)) && !seen.has(dir)) {
  • 读取本地文件
    lib/core/profile.js:113
    return existsSync(join(d, 'node_modules', cfg.packageName));
  • 发起网络请求
    lib/modules/intimate.js:167
    const res = await fetch(full, { method: 'POST', headers, body: JSON.stringify(body) });
  • 读取本地文件
    _gen_faces_in.mjs:3
    const raw = JSON.parse(readFileSync('D:/ruanjian/NapCatShell/_diag/_archive-debug-20261014/sysface-full.json', 'utf8'));
  • 写入本地文件
    _gen_faces_in.mjs:53
    writeFileSync('D:/Plugins/dsh-onebot-companion/lib/modules/face-names.js', body, 'utf8');
扫描于 2026/10/31(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
展开完整介绍(137 字)↓

QQ 通道一体化插件:出站沉默守卫、每轮行为规则、对话节奏(入站合并/出站分段)、人设(情绪+主动发言+风格学习)、语音(qq_say: 文字→edge-tts→语音消息)。为 dsh-onebot-qq 做运行时原型补丁,升级不受影响,无需改动 node_modules。

README

由 DeepSeek 最新模型翻译生成
dsh-onebot-companion 一个插件,把 QQ 通道该有的四件事都装回来:出站沉默守卫 + 每轮行为规则 + 对话节奏 + 人设 + 语音。 中文 · English DSH 插件 · 为 dsh-onebot-qq 补上四项能力,这些能力被 0.9.0 升级冲掉了。 同系列:dsh-hot-reloader(热重载)· dsh-session-clock(会话时钟)· dsh-session-coop(多会话协同)· dsh-proxy-on-demand(按需代理) 本仓库取代 dsh-onebot-guard / dsh-onebot-prompt / dsh-onebot-rhythm 三个独立插件(它们的能力已全部并入本插件的五个模块)。已装过旧插件的请先卸载再装这个,不要同时装——两套代码会各自包装同一条原型链,导致重复分条、重复计数。 目录 - 为什么需要它 - 它做什么 - 环境要求 - 安装 - 配置 - HTTP 观测面 - 工作原理 - 已知边界 - License 为什么需要它 dsh-onebot-qq 在 2026-10-12 23:43 升到 0.9.0 时,把此前在 node_modules 里手改出来的一批行为约定一次性冲掉了——pnpm 换了目录,改动不复存在。
展开完整文档(README 剩余 1 节)
dsh-onebot-companion

一个插件,把 QQ 通道该有的四件事都装回来:出站沉默守卫 + 每轮行为规则 + 对话节奏 + 人设 + 语音。

中文 · English

DSH 插件 · 为 dsh-onebot-qq 补上四项能力,这些能力被 0.9.0 升级冲掉了。

同系列:dsh-hot-reloader(热重载)· dsh-session-clock(会话时钟)· dsh-session-coop(多会话协同)· dsh-proxy-on-demand(按需代理)

本仓库取代 dsh-onebot-guard / dsh-onebot-prompt / dsh-onebot-rhythm 三个独立插件(它们的能力已全部并入本插件的五个模块)。已装过旧插件的请先卸载再装这个,不要同时装——两套代码会各自包装同一条原型链,导致重复分条、重复计数。

目录

- 为什么需要它
- 它做什么
- 环境要求
- 安装
- 配置
- HTTP 观测面
- 工作原理
- 已知边界
- License

为什么需要它

dsh-onebot-qq 在 2026-10-12 23:43 升到 0.9.0 时,把此前在 node_modules 里手改出来的一批行为约定一次性冲掉了——pnpm 换了目录,改动不复存在。被冲掉的包括:

| 能力 | 原实现 | 0.9.0 之后 |
| 沉默协议([[silent]]) | prompt.js + split.js + outbound.js 三层 | 全没了 |
| 括号旁白剥离 | split.js 的 stripAsideLines | 没了 |
| 出站分段(一条长回复分几次发) | split.js burst 段 + outbound.js 的 burstChunks | 没了 |
| 入站合并(连打三行算一个回合) | coalesce.js | 整个文件不存在 |
| 情绪 / 主动发言 / 风格学习 | mood.js / proactive.js / style.js | 全没了 |
| 5 条行为规则 | prompt.js 的 29 次 edit | 全没了 |

手改 node_modules 正是被冲掉的那套做法,同样的坑会再踩一遍。 所以本插件一行都不改 node_modules,只从外面给原型挂包装 —— 升级不会再把功能冲掉。

为什么是一个插件而不是四个

这四个能力最初被拆成 guard / prompt / rhythm / persona 四个独立包。合并的理由很实在:

- 四份代码里重复的东西太多 —— resolveProfileDirs()(探测 profile 目录)逐字重复两份、
patchMethod() 两份、HTTP 观测面三份、loopback 判定三份、状态落盘两份。
- 四个包各自 import() 同一个 dsh-onebot-qq,各自起一个 3 秒扫描定时器,各自维护一份统计。
- 用户只装一个、只配一块,比装四个再对着四份文档调参好得多。

合并后:一个包、一份配置、一个 HTTP 面,内部按职责分成五个模块,各自可独立开关。

它做什么

| 模块 | 干什么 | 挂在哪 |
| guard | 出站闸:拦 [[silent]]、剥整行括号旁白;整条为空就不发 | OutboundPipeline.prototype.sendToChat / sendMsg |
| prompt | 每轮注入 6 条行为规则 + 情绪行 + 风格行 | 宿主 agent/pre-step(不挂原型) |
| rhythm | 入站合并(连打三行 = 一个回合)+ 出站分段(一条长回复分几次发) | ChatBridge.prototype.dispatchFollowup / sendToChat |
| persona | 情绪四变量 + 随机主动发言 + 风格学习 | ChatBridge.prototype.handleInbound / onSessionEvent |
| voice | qq_say 工具:文字 → edge-tts 合成 → 作为 QQ 语音发出 | 只注册工具,不挂原型 |

五个模块挂在不同的方法上,靠 core/patch.js 的模块身份标记共存,互不吞并。

6 条行为规则

| id | 规则 | 默认 |
| silent | 沉默就用 [[silent]],不许用文字或括号表示沉默 | 开 |
| image | 图片可主动发(qq_send_image),但严禁编造本地路径 | 开 |
| quarrel | 群里有人争执/对线时默认不接,被 @ 也可沉默 | 开 |
| face | 表情善用但别滥用,别拿表情代替说话 | 开 |
| tone | 短句口语、别写文档、绝不暴露运行状态 | 开 |
| voice | 话一长就用 qq_say 说出来,但别滥用 | 开 |

silent 这条是与 guard 模块的契约:规则层明令禁止用括号表达沉默,只许用
[[silent]] 一个写法,这样 guard 的判别器才能 100% 命中且不留残余。改这条措辞前
请先同步 guard 的判别逻辑。

节奏参数

| 方向 | 行为 | 默认 |
| 入站 | 同一个 chat 连发的消息攒进安静窗口,合成一个回合 | 私聊 8s / 群聊 3s |
| 入站 | 被 @ 立刻穿透窗口 | 开 |
| 入站 | 攒批硬上限 | 30s |
| 出站 | 模型最终回复切成几条短消息,带打字间隔发出 | ≤36 字/条,≤4 条 |
| 出站 | 分条期间脉冲「正在输入」(仅私聊) | 开 |

人设四变量

| 变量 | 管什么 | 时间尺度 | 初始 |
| mood | 此刻的感觉(8 态) | 分钟级 | calm |
| energy | 此刻的电量 | 小时级 | 80 |
| affinity | 关系有多深 | 天级 | 20 |
| warmth | 当下多来劲、话多不多 | 小时级 | 50 |

主动发言没有 cron:只有两处随机 —— 下一次评估的间隔是区间随机的,评估时每个候选 chat 各掷一次概率门。默认只对私聊主动(群里主动开口是打扰所有人)。

语音(qq_say)

对应需求:「能不能回复我也尽量用一句话(尽量20字内),跟群聊一样,长的就发语音」+ 「语音声音能改吗?改成熟点」。

提供 qq_say 工具:给一段文字,插件用 edge-tts 合成 mp3,再作为 QQ 语音消息发出去。
配合第 6 条规则,长内容她会用说的。

为什么不做成「超长自动转语音」:哪些话适合说、哪些适合打字是聊天判断的一部分(一句吐槽用念的很怪)。自动替换会抢走这个判断,所以只提供能力 + 讲清何时用。

音色可选(默认 zh-CN-XiaoxiaoNeural 晓晓,成熟取向):

| 音色 id | 名字 | 风格 |
| zh-CN-XiaoxiaoNeural | 晓晓 | 成熟温柔(默认) |
| zh-CN-XiaoyiNeural | 晓伊 | 活泼少女 |
| zh-CN-liaoning-XiaobeiNeural | 晓北 | 东北口音,直爽 |
| zh-CN-shaanxi-XiaoniNeural | 晓妮 | 陕西口音,爽利 |
| zh-CN-YunxiNeural | 云希 | 清朗少年 |
| zh-CN-YunyangNeural | 云扬 | 沉稳播音腔 |
| zh-CN-YunjianNeural | 云健 | 浑厚有力 |

依赖:Python 装了 edge-tts(pip install edge-tts)。插件不额外引入 Node 依赖。

两个已踩过的坑(插件内已处理)

1. 上游 host 失效:speech.platform.bing.com 自 2026-10 起在很多地区对合成 WebSocket 直接回 403(上游 issue rany2/edge-tts#490)。
本机实测 7.2.7 / 7.2.8 都 403,且与系统时钟无关(手动补 ±5s/±10s 偏差仍然 403)。
修法见 PR #491:改用 api.msedgeservices.com/tts/cognitiveservices/websocket/v1,且 token 要作为 Ocp-Apim-Subscription-Key 查询参数传入(不是旧的 TrustedClientToken)。
插件用运行时 shim(内联 python -c)完成替换,不改 site-packages (pip 升级会覆盖回去,与 node_modules 同一个坑)。shim 带幂等保护:上游发版后旧 host 消失,替换自动跳过。

⚠️ 只改 constants.WSS_URL 无效 —— communicate.py 用 from .constants import (... WSS_URL ...) 把值绑成了模块级常量,两处都要改。

2. 偶发失败要重试:语音服务有瞬时抖动(实测同一段文本在同一台机器上连续 11 次里失败 2 次,随后单独复测 12/12 全成功)。插件内置退避重试(默认 3 次,基数 800ms)。
语音是用户直接看到的东西,失败一次就是「她突然说不出话」,重试比报错友好。

3. 剥代理变量:edge-tts 直连微软,而宿主环境常带 HTTP(S)_PROXY(给 LLM 上游用)。
插件从子进程环境里删掉这六个键(HTTP_PROXY / HTTPS_PROXY / ALL_PROXY 及其小写)。
实测本机带代理时也能成功,所以它不是必然失败项 —— 但剥掉没有副作用,作为防御保留。

环境要求

- DSH(DeepSeek Harness)宿主,已装并启用 dsh-onebot-qq(目标包,默认按 0.9.x 的内部结构挂载)。
- Node.js ≥ 22(用到 node:zlib 之外的现代 ESM 特性;实测在 v24 上开发)。
- 本插件不额外依赖任何第三方包 —— 全部用 Node 内置模块。

安装

1. 放进插件目录

git clone https://github.com/xfgil/dsh-onebot-companion.git D:\path\to\plugins\dsh-onebot-companion

(任何目录都行,下称 <插件目录>。)

2. 在 profile 里注册

编辑 <DSH_HOME>\profiles\<profile>\package.json,加两处:

{
"dependencies": {
"dsh-onebot-companion": "link:D:/path/to/plugins/dsh-onebot-companion"
},
"dsh": {
"profile": {
"bundles": [
"dsh-onebot-companion" // ← 加进 bundles,bundle 会自注册 ] } } } 本插件自带 cordis.patch.yml(dsh.bundle.patch 指向它),bundle 自注册——不要再往 profile 的 cordis.patch.yml 里手写 insert,重复 insert 会起两个实例。

3. 建链接并让宿主加载

cd <DSH_HOME>\profiles\<profile>
pnpm install

⚠️ 顺序很重要:先建 node_modules 链接,再改 profile 清单。
宿主的热更新 refresh(manifestOnly) 里有一行短路:
if (manifestOnly && bundles === lastBundles) return; // 清单没变就直接返回

如果先改清单、后建链接,那次 reconcile 解析不到包就跳过了,而 lastBundles 已经被更新 —— 之后再怎么改 package.json 都被这行短路,永不重试。
表现是:插件管理页显示 enabled: true,但那一行没有 entryId,功能完全不生效。

补救:在插件管理页把它关掉再打开(让清单内容真的变一次),即可绕过短路。

改完不用重启宿主:宿主的热更新会重新加载。若你装了 dsh-hot-reloader,它也会自动跟进。

4. 确认它在跑

curl.exe http://127.0.0.1:<端口>/dsh-onebot-companion

或者直接看状态文件有没有落盘:

Get-Content "$env:USERPROFILE\.dsh\onebot-companion\state.json"

配置

配置写在 profile 的 bundle 配置段里(宿主会把它透传给 apply(ctx, config))。
本插件刻意不导出 Config schema —— 沿用实测结论:profile 侧解析不到宿主的 schemastery,导出非 schema 对象会让 loader 投影 config 时报错。

dsh-onebot-companion:
enabled: true
packageName: dsh-onebot-qq # 要挂的 QQ 通道插件 profileDir: '' # 留空则多路自动探测 pollMs: 3000 # 补挂扫描周期;目标包被升级换目录后靠它自愈 stateFile: '' # 留空用 $DSH_HOME/onebot-companion/state.json

guard:
enabled: true
stripAside: true # 是否连「整行一对括号」的旁白一起剥 logHits: true

prompt:
enabled: true
rules: {} # 按 id 单独开关,如 { quarrel: false };缺省全开
firstStepOnly: true # 仅第一步注入(推荐);关掉会撑大上下文 sourceName: onebot-prompt

rhythm:
enabled: true
inbound:
enabled: true
quietMs: 8000 # 私聊安静窗口 groupQuietMs: 3000 # 群聊安静窗口 maxWaitMs: 30000 # 攒批硬上限 mentionPierces: true # 被 @ 立刻穿透 burst: enabled: true maxChars: 36 # 每条消息字数预算 maxChunks: 4 # 一次回复最多几条(尾巴折叠进最后一条) delayMs: 300 # 分条基础停顿 perCharMs: 45 # 按上一条字数折算的打字时间 typing: true

persona:
enabled: true
mediaDir: '' # 情绪/风格状态目录;留空从 bridge 配置取 moodEnabled: true styleEnabled: true proactiveEnabled: true moodDriftMinutes: 20 energyDecayPerHour: 7 nightRecoveryPerHour: 14 nightStartHour: 0 nightEndHour: 8 affinityDecayPerHour: 0.8 affinityGraceHours: 6 warmthBaseline: 30 warmthRegressPerHour: 10 warmthJitter: 8 proactiveChance: 0.45 proactiveMinMinutes: 25 proactiveMaxMinutes: 150 proactiveIdleMinutes: 20 proactiveMaxPerDay: 6 proactiveQuietStart: 1 proactiveQuietEnd: 8 「主动只对我,我才是管理员」——allowAllUsers: true 时陌生人也能私聊,不设白名单她就会去主动搭话陌生人。写纯 QQ 号或完整 chatId 都认。
proactiveOnlyChats: []

voice:
enabled: true
program: python # 装了 edge-tts 的解释器(可写绝对路径) voice: zh-CN-XiaoxiaoNeural # 音色,见上表 rate: -5% # 语速(edge-tts 语法) pitch: -10Hz # 音调 maxChars: 120 # 每次最多念多少字 timeoutMs: 45000 retries: 3 # 偶发失败重试次数(含首次) retryDelayMs: 800 # 重试退避基数 mediaDir: '' # 语音文件目录;留空用 config.mediaDir

packageName / profileDir 会自动下发到五个模块段,不需要在每个段里重复写。

状态文件放在哪

| 文件 | 内容 | 位置 |
| state.json | 本插件运行态(挂了哪些闸、扫了几次、命中多少) | $DSH_HOME/onebot-companion/ |
| mood-state.json | 情绪四变量(每 chat 一条) | config.persona.mediaDir |
| style-state.json | 风格画像(每 chat 一条) | config.persona.mediaDir |
| media_.mp3 | qq_say 合成的语音 | config.voice.mediaDir |

情绪与风格刻意不放在 state.json 里:mediaDir 是那批历史数据本来的家,换插件/重写插件都能继承,亲密度和风格画像攒了几天的东西不该因为换插件清零。

HTTP 观测面

全部 loopback only(非 127.0.0.1 一律 403)。

| 方法 | 路径 | 作用 |
| GET | /dsh-onebot-companion | 总状态:配置、统计、五个模块挂载情况 |
| GET | /dsh-onebot-companion/hits?limit= | 最近 200 条 guard 命中(原文 + 清理后文本) |
| GET | /dsh-onebot-companion/mood | 情绪快照(每 chat) |
| GET | /dsh-onebot-companion/style | 风格画像快照(每 chat) |
| GET | /dsh-onebot-companion/voice | 语音配置、可用音色表、上次失败原因 |
| POST | /dsh-onebot-companion/toggle | {"enabled": bool} 总开关 |
| POST | /dsh-onebot-companion/module | {"module":"guard\|prompt\|rhythm\|persona\|voice","enabled":bool} |
| POST | /dsh-onebot-companion/rule | {"id":"silent","enabled":false} 单条规则开关 |
| POST | /dsh-onebot-companion/rescan | 立刻补挂一次 |
| POST | /dsh-onebot-companion/selftest | 跑 guard 自测(喂真实文本看会怎么处理) |
| POST | /dsh-onebot-companion/burst | {"text":"…","maxChars":36} 看分段结果 |
| POST | /dsh-onebot-companion/say | {"text":"…"} 试合成语音(只落盘,不发送) |
| POST | /dsh-onebot-companion/reset-hits | 清空命中记录与计数 |

工作原理

挂载:运行时包装原型,不落盘

core/patch.js 用 pathToFileURL() 定位目标类的绝对路径并 import(),然后包装原型方法。为什么不用 require.resolve('dsh-onebot-qq/lib/outbound.js'):

Package subpath './lib/outbound.js' is not defined by "exports"

上游 exports 字段没暴露子路径,所以直接拼绝对路径更省事。

模块同一性(这是整套做法的前提,已实测):pathToFileURL 规范化后的 URL
import() 回来的命名空间对象,与宿主正在实例化的那一个是同一个 —— 因为 ESM
模块缓存按解析后的 URL 命中。包装原型方法因此能影响宿主真实的实例。

升级抗性

包装发生在运行时,不落地任何文件。pnpm 下次把 lib/.js 覆盖回原版,只是让闸消失而已 —— 插件的 pollMs 扫描(默认 3 秒)会重新 import() 并检查原型上还有没有本模块的标记,没有就补挂。目标包被升级换目录也不会失效。

脱链语义:多个模块互不吞并

同一个原型方法可能被多个模块包装(例如 sendToChat 同时被 guard 和 rhythm 关注)。
朴素的 unwrap() 会剥到链底,把别的模块的包装一起丢掉。core/patch.js 因此给每个包装打上 Symbol.for 模块标记,脱链时只剥自己那一层:

// 只脱本模块的层,链上其他模块的包装原样保留

同时它保留 originalOf()(取链底原方法)给需要「绕过所有包装直接发」的路径用。

失败降级

- guard 清洗异常 → 按原文放行。一个守卫把用户消息吃掉,比漏一条标记严重得多。
- 入站合并异常 → 按原文直投(原方法照常调用)。
- 出站分段异常 → 按原文整条发;分条中途失败 → 剩余内容合并补发。
- 情绪/风格记录异常 → 只记 lastError,绝不影响消息投递。
- 所有计时器 unref(),停着的批绝不把宿主进程钉住。

已知边界

- 挂载点绑定 0.9.x 的内部结构。 上游若改名 sendToChat / sendMsg / dispatchFollowup / handleInbound / onSessionEvent,对应模块会静默失效。
pollMs 扫描会把丢失记进 lost,但不会自动适配。
- agent/pre-step 每轮多一条消息。 这是实打实的 token 成本,长聊场景按天算可观。
所以 prompt.firstStepOnly 默认为 true(只在每轮第一步注入一次)。
- 提示词层压不过历史惯性。 如果会话历史里已经堆了几百条 [[silent]] 示范,注入一句新规则未必压得过;guard 的硬拦才是主防线,提示词层是辅助。
- 合并器只在 dispatchFollowup 这一层工作。 插话(steer)与语音转写走另一条路径,不经过合并器 —— 这是刻意的,插话本来就该立刻生效。
- 分条会改变消息计数。 依赖「一条回复 = 一条消息」的下游逻辑会看到多条。
- 文字图卡片不分条:textImageThreshold 生效时整条走卡片,卡片本来就是一条。
- 主动发言是拉取式的:宿主没给插件「替某个空闲会话发起一轮」的公开接口,调度器只在有活跃 chat 表时才工作。
- 情绪变量按时间积分,落盘值会有 IEEE-754 浮点尾差(56 可能是 55.99999722222222)。这是正常的,不是 bug。
- 语音依赖外部服务:qq_say 起 Python + edge-tts,连的是微软一个非官方端点,而且已经变过一次(见语音章节)。它坏掉时 qq_say 只返回一个普通错误、不影响别的功能,/voice 端点会显示上次失败原因。
- 每次合成都起一个 Python 进程(几百毫秒 + 网络)。调用是串行的,所以连着几次 qq_say 会排队而不是并发。
- 本插件为自用工具,不保证与其他第三方 OneBot 实现的兼容性。

开发

node test/selftest.mjs

252 项断言,三层覆盖:

1. 纯函数 —— mood / style / burst / coalesce / aside / rules,无 IO、无宿主。
2. 模块接口 —— 五个模块的 createXxx 返回值与配置语义。
3. 真实集成 —— 造一个假 profile(含 dsh-onebot-qq/lib/{bridge,outbound}.js),让五个模块的 scan() 真的 import() 并给原型挂包装,然后真的调用被包装的方法并断言行为。

第 3 层是这个测试文件存在的理由:前两层全绿也可能「插件说挂好了但功能完全不生效」,那种 bug 只有真挂真调才抓得到。

License

MIT © 2026 xfgil

自用插件,按「够用就好」的标准写。生产环境请自行评估。
预览分享卡片长什么样 ↓xfgil/dsh-onebot-companion
分享 · DeepSeek Harness Hub
xfgil/dsh-onebot-companion

补回 QQ 通道的沉默守卫、节奏与人设语音能力

回到顶部