dsh-onebot-channel
Manifest validConnects QQ private chats and groups to DSH over OneBot 11, adding qq_send and other tools so the agent can reply, send stickers and wait for follow-ups.
dsh-onebot-channel
把 QQ 私聊与群聊接进 DeepSeek Harness 会话。 基于 OneBot 11 协议:入站走 WebSocket 事件推送,出站用
qq_send工具回话。
让「在 QQ 里发消息」变成 DSH 会话里的一个回合,agent 直接回话。
QQ 客户端
↓ OneBot 11 协议端(SnowLuma / LLOneBot 等)
↓ 本插件:WS 事件推送为主,HTTP 轮询兜底
路由表:这个 QQ 号 / 群绑的是哪个会话
↓ agent.followup() 唤醒会话
会话跑一个回合(agent 看到「(2026-11-01 12:09)(QQ 私聊·用户本人 QQ …)…」)
↓ agent 调 qq_send 工具
OneBot send_private_msg / send_group_msg
↓ 按换行切条(她自己写几行就几条),带随机延时
回到 QQ
安装
dsh plugin --profile desktop add dsh-onebot-channel
装完去 设置 → QQ 桥接 填协议端地址和 token,把 QQ 号 / 群绑到会话上。
配置存在
~/.dsh/qq-bridge.config.json,不在插件目录里 —— 重装插件不会丢。
前置条件:需要先跑一个 OneBot 11 协议端,并把插件的 onebotHttp / token 指过去。
插件不读你的 QQ 密码,只跟协议端说话。
工具
装好之后 agent 有 10 个工具:
| 工具 | 干什么 | 参数 |
|---|---|---|
qq_send | 发消息、发表情;想自己决定分几条就传 segments;想明确引用某条就传 reply_to;想 @ 人就传 at | text segments emoji custom_emoji user_id group_id reply_to at |
qq_emoji_search | 搜 QQ 官方表情目录 | query all |
qq_msg_history | 查群 / 好友历史消息(返回的图能看见,0.12.0) | group_id user_id count before_message_id |
qq_collect_face | 把收到的表情包存进 QQ 收藏夹 | index recent file desc dir list delete |
qq_face_preview | 看收到的表情包(结果直接带图片) | index from count |
qq_qzone_post | 发表 QQ 空间说说(必须带 confirm: true) | content visibility target_uins confirm |
qq_wait | 等他把话说完,返回这期间新到的消息(默认 8s,上限 60s) | ms user_id group_id |
qq_pass | 划走:这一回合不开口(正文里已经写了真话的,仍然会发出去 —— 写了就算说出口) | why user_id group_id |
qq_close | 收摊:双方都收尾时结束会话,记下聊到哪儿、下次怎么接 | summary next user_id group_id |
qq_remember | 记住关于他的一件事 / 一个称呼,下次还认得 | fact nick user_id group_id |
// 发消息 + 经典小表情
{ "text": "搞定了", "emoji": "OK" }
// 发收藏夹里的商城大表情
{ "text": "看这个", "custom_emoji": "猫" }
// 查官方表情目录
{ "query": "比心" } // → 比心(编号 319)
{ "all": true } // → 列出全部目录(398 个,10 个包)
// 查历史消息(默认 20 条,上限 100)
{ "group_id": "1046436553", "count": 20 }
// 看表情包 —— 文件名是 MD5 哈希,只有看图才起得了名
{ "index": 1 }
// 收藏表情包
{ "index": 1, "desc": "猫脑过载" }
// 发说说(对外发布)
{ "content": "今天天气不错", "visibility": 1 }
说说可见性:1 所有人 · 4 仅好友 · 16 仅自己 · 64 指定好友 · 128 分组。
你不一定非要回话(沉默权)
QQ 桥接和真人聊天,最不像的地方是「真人会不回、会晾着、会说完了就走」。0.7.1 给模型补上了这三种不说话的出口,让对话有节奏、不粘人:
| 场景 | 怎么做 | 工具 |
|---|---|---|
| 对方明显还没说完(「我跟你说」「等下」「还有」) | 等一会儿,看下文再说 | qq_wait |
| 没话说 / 不想接 / 话题翻篇了 | 划走 —— 这一回合什么都不发 | qq_pass |
| 双方都收尾了(嗯 / 好 / 哈哈 / 晚安),再补一句就多余 | 收摊,记下聊到哪儿、下次怎么接 | qq_close |
划走 ≠ 删掉刚写的话(0.9.3 修正):模型经常「先写一句正文 → 再觉得太催了 → 调 qq_pass」,
早先的实现会把正文一起吞掉,于是会话里她明明说了一句话、QQ 上却什么都没有(用户报的「她发消息了我没收到」)。
现在:正文里有真话就照发,只有纯动作(「(尾巴摆了摆)」)或纯标点时 qq_pass 才真的按下不发。
qq_close 有硬约束:如果对方还有提问没回答,桥接会拒绝收摊,逼模型先把话接完再走。qq_close 写下的「下次怎么接」会在下次开场时自动带回,避免冷启动尬聊。
更细的判断逻辑(收尾评分、节奏底座、自动静默)见 docs/QQ对话说明.md。
@ 人(0.14.0)
群里回话时点名叫人。跟引用一样,@ 只挂在第一条消息上 —— 分条发送时后面几条不带 @, 否则同一个人会被连点 N 次,群里看到的就是刷屏。
| 场景 | 行为 |
|---|---|
| 群里有人 @ 她 | 自动 @ 回那个人(引用 + @ 一起带) |
| 群里没 @ 她(其他触发) | 不 @ |
| 模型想 @ 别人 | 传 qq_send 的 at 参数(QQ 号) |
| 私聊 | at 参数被忽略(私聊没有 @ 这个概念) |
段顺序实测(SnowLuma 2.2.1,回读确认服务端原样保留):
reply → at → text
也就是先引用、再 @、最后正文,跟 QQ 客户端自己的排列一致。
不 @ 自己:at 填本机号会被忽略并记日志 —— 那是无意义动作,还容易写错成「@ 机器人自己」。
为什么不给 @ 单独做白名单:这条消息本来就只能发到已放行的群,群里所有人都看得见它;
@ 只是多一次提醒,不构成新的越权面。真正的越权面在「发给谁」,那道闸由 allowlist.js 管。
引用回复(0.13.0)
QQ 的引用是逐条消息的属性,所以插件按这个规则来:
- 默认自动引用「触发这次回复的那条」——被 @ 的那句,或私聊里最新那句。
- 模型想明确引用别的,就传
reply_to(填正文里[#数字]的那个数字)。 - 分条发送时只引用第一条,后面几条是裸消息 —— 跟 QQ 客户端原生行为一致, 也避免刷屏。
正文里的锚点长这样(每条消息一个,紧跟在时间戳后面):
(2026-10-31 20:15)[#-2123597213](QQ群 123456·群友「张三」(QQ 789,不是用户本人))帮我看看这个
群上下文里如果某条消息引用了别人,会再标一个箭头:
[#114105673]群友「李四」: 把个人战改一改就是狼人杀 → #1519605856
→ #1519605856 表示李四这句话是引用 1519605856 那条说的。
为什么用 [#id] 而不是整句说明:这段文字每回合都进上下文,
写长了就是纯烧 token(当年「表情标记说明」被重复 92 次的教训)。
所以只放一个极短标记,含义在 qq_send 的工具描述和
docs/QQ对话说明.md 里各说一次。
装饰段失败不会吞掉回复:引用的那条可能已被撤回,@ 的号可能已退群, 表情 id 可能协议端不认 —— 任何一种都可能让整条消息发送失败。
出站按「装饰性从强到弱」逐级剥离,每一级只去掉一种段再试一次:
reply + at + text ← 原样
at + text ← 去掉 @
text ← 再去掉引用
(表情也在这条链上,排在最后。)每一级都记日志。 宁可少一个 @ 或引用,也不能让回复凭空消失 —— 这是「表情幽灵 id」那次踩出来的原则。
⚠️ 踩过的坑:最初把三级降级写成串行的 if,判断条件读的是已经剥过的变量,
结果 @ 和引用在同一次重试里被一起去掉 —— 既试不出到底是哪种段把消息搞挂的,
也白白丢了本来能用的引用。现在按原始段逐级剥离,tests/at-sim.mjs 钉住了这个行为。
分几条,也由她定
不传 segments 时按句末标点自动切(老行为);传了就按她给的分段原样发 —— 真人会自己决定「这句分三条还是一条说完」,而不是被标点机械切开。
// 一句话拆成三条,逐条发出、中间带停顿
{ "segments": ["我跟你说", "那个事搞定了", "不过还差一点"] }
传 segments 时 text 被忽略;空字符串段自动丢掉;段内的 [表情:呲牙] 挂在该段末尾。
她记得你是谁(关系持久化)
称呼和「关于他的几条事实」落在 ~/.dsh/qq-bridge-memory.json,重启不丢:入站自动记昵称和活跃度,模型也能用 qq_remember 主动记(「他喜欢猫」「别叫他全名」)。下次被叫醒时,这些会以一小段 (关于他:称呼:…;记得:…) 带进正文,所以她不会每次都从零认识你。
一张卡最多 8 条事实、注入正文约 180 字上限 —— 它是每回合常驻的,不控长度就是烧 token。不想要这段就设 memoryEnabled: false。
先记账,再决定什么时候回(标记已读的中间态)
原来 WS 通路是「来一条投一条」:对方连发三句,就开三个回合、被连问三遍。
replyQuietMs 治不了这个 —— 它只是把每一个回合各推迟一遍,三条消息仍然是三个回合。
现在私聊多了一层合并窗口(mergeWindowMs,默认 2500,设 0 关闭):
来信 → 立刻记账(markSeen + 游标推进,等于「已读」)→ 入池攒着
→ 窗口到点(或攒够 mergeMaxMessages / 到达 mergeMaxWaitMs)→ 一批放行 → 一个回合
- 「已读」指的是内部记账,不是推给 QQ 的回执(OneBot 没有已读回执接口)。 它的作用是不让轮询把同一条再拎一遍,同时保证「攒着没回」这件事在状态里是明确的。
- 投递失败会把记账撤回去(
mergeSnapshots),轮询或下一批还能重试 —— 不是静默丢消息。 唯一的代价:进程在窗口内被强杀(不是正常卸载)时,那一两条「已读」记账已经落盘,会丢掉。 窗口只有 2.5 秒,正常卸载会记日志并保留痕迹。 - 只作用于私聊。群里是 @ 才投递,一条 @ 就是一件事,攒起来反而怪。
- 跟
replyQuietMs会叠加(2.5s 窗口 + 5s 静默 = 7.5s),两个都想开时建议只留一个。
没人叫她,她自己想起来找你(主动搭话)
默认关(proactiveEnabled)。打开后每 60 秒看一次该不该开口,
真正开口还要过四道闸:
| 闸 | 默认 | 配置 |
|---|---|---|
| 静默时段 | 23:00–08:00 不打扰 | proactiveQuietFrom / proactiveQuietTo |
| 空闲够久 | 6 小时 | proactiveIdleHours |
| 当日配额 | 1 次 | proactiveDailyMax |
| 没被回应的退避 | 2ⁿ 倍,封顶 4 倍 | proactiveBackoffMax |
配额按自然日算:跨过零点,昨天的次数不再计入,设置页的「今天几次」也随之归零(0.11.1 修;之前的版本只更新日期不清计数,数字会一直涨)。
「空闲够久」的起算点是上次来信和上次主动里较晚的那个 —— 她自己刚说完一句,也要重新等满这个时长。 之前只按「你上次说话」算,于是静默时段一结束就一分钟一条,几分钟把当日额度烧光(已修,并补了回归用例)。
关键点:这一层不自己写文案。它只是往同一个会话里投一条内部来信:
(主动搭话:距上次说话已经 7 小时。上次聊到「…」,可以从「…」接着聊。
想说什么就说……但这事你自己定:没什么想说的就 qq_pass,不用硬找话。)
于是「说」还是「不说」仍然由模型判断,转发 / 分条 / 抑制标记全部复用原链路。 硬拼一句「在吗」发出去,就退化成定时问候机器人了 —— 这是这条设计的红线。
状态落在 ~/.dsh/qq-bridge-proactive.json(重启不丢,否则每次重启都重新「攒够 6 小时」或把日配额清零)。
设置页有「现在试一次」,跳过所有闸立刻投一次(开关关着也能试)。
对谁开口:proactiveTargets 是白名单,元素形如 private:1000000001。
- 空数组 = 所有已绑会话的私聊(默认,也是升级上来的老行为);
- 非空 = 只对名单里的人算,名单外的连「该不该说」都不判;
- 只认私聊,群里不主动(
candidates()只取routes.private,且必须有 sessionId)。
设置页里是「所有已绑私聊 / 只对勾选的人」两选一 + 每人一行勾选框。从「所有人」取消一个人会自动展开成显式名单 ——
因为空数组的语义是「全员」,直接把「取消某个人」写成空数组会得到完全相反的结果。
每行还有「对TA试一次」(POST /proactive-now {only}),可以只对某个人手动开一次口,不用来回改名单。
手动触发会轮转:候选按「最久没被主动过」排序(从没主动过的排最前), 所以连点「现在试一次」会依次换人,不会三次都砸在同一个人身上(0.9.2 修的就是这个: 之前固定打候选列表第一个,另一个人永远轮不到)。按钮旁边会写明这次轮到谁。
只回动作 = 沉默:主动搭话回合里,如果她只回了「(尾巴摆了摆)」这类纯动作描写, 转发链会把它当沉默丢掉(日志记一笔),不会真的发到他手机上 —— 普通聊天里「(笑)好呀」这种带真话的照常发。
能力
| 能力 | 说明 |
|---|---|
| 私聊 / 群聊投递 | 每个号各绑一个会话,没绑定的号一律忽略(不会乱投到别的会话) |
| WebSocket 实时推送 | 延迟接近 0;断线自动退回轮询补漏 |
| 区分本人与群友 | 逐条打标「用户本人」/「群友(不是用户本人)」,agent 不会张冠李戴 |
| 群聊带上下文 | 被 @ 时附带上次投递之后的未读群消息 |
| 发表情 | 正文写 [表情:呲牙],或 qq_send 带 emoji |
| 商城大表情 | qq_send 带 custom_emoji(需先收藏进账号收藏夹) |
| 图片理解 | QQ 图片下载后进 DSH 附件库,作为 image 块投进会话 —— 模型真能看见图。三条路都通:入站投递、群 @ 的上下文、qq_msg_history 翻历史(后两条 0.12.0 补,该工具目前默认关闭) |
| 正文带时间 | 每条投进去的消息前面带「(2026-11-01 12:09)」,取的是消息自身的时间(不是轮询到的时刻),隔了几小时才看到也判断得出来 |
| 分条由她决定 | 正文换行 = 另起一条(splitMode: paragraph,0.10.0 默认);想回「一句一条」切成 sentence。带随机延时,模型也能用 segments 直接指定 |
| 连发合并 | 私聊连发几条先攒一会儿再一起回(mergeWindowMs,0 = 关),不再被连问三遍 |
| 主动搭话 | 没人叫她时自己想起来找你(默认关,四道闸 + 退避;可选只对指定的人开口;设置页可「现在试一次」/「对TA试一次」;「今天几次」按自然日,跨天自动清零) |
| 设置页 UI | 设置 → QQ 桥接:顶部一排功能分区(概览 / 绑定 / 对话节奏 / 主动搭话 / 关系记忆 / 推送 / 高级),点进去只看那一块 |
设置页能调什么
顶上一排是功能分区,一次只显示一块;「保存」和「重新载入」始终在右上角。
| 分区 | 内容 |
|---|---|
| 概览 | 连接状态 + 统计块(私聊/群绑定、今天主动几次、记住几个人、待合并条数、推送是否连通),以及「现在在跑什么」的自然语言摘要;数字都可点进对应分区 |
| 绑定 | 私聊 / 群聊各自一行:QQ 号、名字、绑定会话、正文前缀 |
| 对话节奏 | replyQuietMs 回复前静默、mergeWindowMs 合并窗口(+ 上限 / 条数)、waitMaxMs 等待上限、closureEnabled 允许判断收尾、autoForwardReply 自动转发、backfillOnStart 上线补历史(+ 页数 / 投递条数)、splitMode 怎么分条:按换行(她自己决定说几条)/ 按句末标点(一句一条) |
| 主动搭话 | 上卡:proactiveEnabled 开关、proactiveIdleHours 空闲门槛、proactiveDailyMax 日上限、静默时段、退避上限。下卡「对谁开口」:所有人 / 只对勾选的人 + 每人上次主动时间与「为什么现在还不说」 |
| 关系记忆 | memoryEnabled 开关、记住了谁(称呼 / 事实条数 / 说过几句)、一键清空 |
| 配置 | 两条连接链路同页:HTTP API(onebotHttp 地址 + token,留空 = 保持原样,自动发现读 httpServers)、WebSocket 推送(wsEnabled、WS 地址与 token,自动发现读 wsServers)。两套 token 相互独立,混用会 401 |
| 高级 | 分条(字数 / 条数 / 碎片阈值 / 间隔)、轮询间隔、补漏间隔、正文前缀、图片副本目录、只在 @ 时响应、冷启动会话、说说必须带 confirm |
概览里「现在在跑什么」的第一行是这版代码的版本号 + 进程 pid + 启动时间(0.10.1 补)。 改完代码发现行为没变,先看一眼这行:版本没涨、启动时间还是上午 —— 说明主进程仍在跑旧代码,重启即可,不用怀疑改错了。 第二行「连接」会同时列出 HTTP 地址与 WS 连接状态(0.15.0 补),排查「消息收不到」时先看它。
不给控件的:token / wsToken(凭据,前端不回显;留空即保持原样)、fallbackToLiveSession(会绕过路由表)、
allowSendToAny(安全逃生阀)、enabled(只在加载时读一次,放按钮会误导成即时生效 —— 页面只在它为 false 时显示告警)。
这批「故意不给控件」的字段在 tests/ui-wiring.mjs 里有白名单和理由,新加配置忘了接 UI 会直接测试失败。
不接:群信息检索、撤回消息、表情回应、发图片到空间。
发表情
两条路都通:
① 正文里写标记(推荐,一句话能带多个)
搞定了[表情:OK][表情:庆祝]
→ QQ 上收到的是 搞定了 + 两个真表情(标记本身不出现)。支持中文名和编号,
表情附在它前面那句话的后面。这条路不依赖工具调用,自动转发也生效。
② qq_send 的参数
{ "text": "搞定了", "emoji": "OK" } // 经典小表情
{ "text": "看这个", "custom_emoji": "猫" } // 商城大表情
区别:经典小表情能跟文本拼在同一条消息里;商城大表情协议端只能单独发一条。
⚠️ 收藏表情走的是协议端扩展 action(SnowLuma 的 send_custom_face),不是 OneBot 11 标准 ——
换协议端(NapCat / LLOneBot)可能没有,届时经典表情照常能用。
表情编号的坑
内置表情表共 275 个(编号 0~348),但它是 OneBot 生态的社区通用编号,不是 QQ 官方编号, 两者对不上(2026-10-27 实测):
- 275 条里有 109 条在 QQ 官方目录(398 个)里查无此 id —— 「幽灵 id」
- 另有 11 条名字对得上但 id 错位(「晕」内置记 17、官方实际是 34)
拿幽灵 id 去发,协议端报 OIDB error 11000: id N sysemojis cfg not found,
整条消息发不出去 —— 不是丢个表情,是连文字一起没。
所以出站做了三层防护:查 QQ 官方目录解析 → 校验 id 真的存在 → 校验不过就跳过表情、文字照发 → 发送层再兜一层(带表情失败则去掉表情重发文字)。
宁可少发一个表情,也不能让整条回复凭空消失。
配置
~/.dsh/qq-bridge.config.json(UTF-8 无 BOM)。改完不用重启,下一轮轮询就生效。
{
"enabled": true,
// ---- 连接 ----
"onebotHttp": "http://127.0.0.1:3000/", // 协议端 HTTP 端口
"token": "…", // config/onebot_<QQ号>.json 里的 httpServers.accessToken
"selfUin": 1000000002, // 机器人自己的 QQ 号
// ---- WebSocket(主通路)----
"wsEnabled": true, // 关掉就退回纯 HTTP 轮询
"wsUrl": "ws://127.0.0.1:3001", // 留空 = 自动去协议端配置里发现
"wsToken": "…", // ⚠️ 与上面的 http token 是两套,不能混用
"wsFallbackPollMs": 30000, // WS 通着时,轮询降频到多少毫秒一次
// ---- 私聊 ----
"peerUin": 1000000001, // 监听的好友 QQ 号
"ownerUin": 1000000001, // 用户本人(留 0 = 用 peerUin)。群聊靠它区分本人和群友
"pollMs": 2000,
"textPrefix": "(QQ 私聊)",
// ---- 群聊 ----
"groupUins": [], // 监听哪些群;设置页绑群时自动同步
"groupOnlyWhenAt": true, // 只在 @ 本号时投递
"groupContextCount": 5, // 被 @ 时带几条未读群消息
"groupPollMs": 3000,
// ---- 路由表(核心)----
"routes": {
"private": { "1000000001": { "sessionId": "…" } },
"group": { "2000000002": { "sessionId": "…" } }
},
// ---- 会话 ----
"workspaceCwd": "D:\\DSH workspace\\QQ对话",
"resumeColdSession": true, // 会话没开着时冷启动它
"fallbackToLiveSession": false, // 首选会话没 agent 时改投别的会话
// ⚠️ 这会绕过路由表,默认关着
"agentOptions": { "provider": "deepseek-account", "model": "deepseek-flash" },
// ---- 分条发送 ----
"splitReply": true,
"splitMode": "paragraph", // paragraph = 换行分条(默认)/ sentence = 按句末标点
"splitMaxChars": 120,
"splitMinChars": 4,
"splitMaxParts": 8,
"splitDelayMinMs": 200,
"splitDelayMaxMs": 700,
"requestTimeoutMs": 8000 // 协议端挂死时的超时
}
没有 routes 时按旧逻辑走(私聊用 sessionId、群聊用 groupSessionId)。
新配置建议只用 routes。
安全边界
13 个高风险 action 物理拦截 —— 拦截点在 callOneBot(),所有协议端调用的唯一出口。
不是「提示词里写别调用」,而是调用即抛错、连 HTTP 都不发。
| 类别 | action |
|---|---|
| 交出账号凭证 | get_cookies get_credentials get_csrf_token get_clientkey get_rkey_server request_decrypt_key |
| 踢下线 / 改状态 | bot_exit set_online_status set_diy_online_status set_input_status |
| 越权社交动作 | send_like set_qzone_ban |
| 删说说 | delete_qzone_msg(发说说允许,删不允许) |
清单在 lib/blocked.js,要放开哪项改那个集合 —— 但凭据那六项别动。
add_custom_face原先在封禁清单里,2026-10-27 放开了: 协议端没有「收到图片自动收藏」的逻辑,收藏夹不显式写就是空的,custom_emoji那条路永远走不通。现在改成走受控工具qq_collect_face—— 只允许收藏插件自己收到过的图。
出站两道闸门(2026-10-28) —— 都挂在 callOneBot() 这个唯一出口上,绕不过去:
| 闸门 | 管什么 | 实现 | 拦住了什么 |
|---|---|---|---|
| 目标白名单 | 发给谁 | lib/allowlist.js | qq_send 的 user_id/group_id 是 agent 随便填的 → 群里一句 prompt injection 就能让她往任意 QQ 号发消息 |
| 内容审计 | 发什么 | lib/sensitive.js | 回复里顺嘴写出 C:\Users\...、token=xxx → 本机路径和凭据直接进群 |
白名单从已有的配置派生,不新增一份(避免两处漂移):
私聊 = peerUin ∪ ownerUin ∪ routes.private 的键;群 = groupUins ∪ routes.group 的键。
自动转发的目标全部来自路由表,所以永远不会被误伤。
- 想临时放开:
allowSendToAny: true(⚠️ 等于把上面那个风险还回去,只在排障时开)。 - 白名单全空时(还没配
peerUin)不拦,只记一条警告 —— 闸门的语义是「限制已配置的服务范围」,不是「没配好就把插件锁死」。 - 内容审计故意不匹配裸的 32 位十六进制串:表情包文件名就是 MD5,
加了这条会把正常表情和消息整条拦掉。详见
lib/sensitive.js的注释。
发说说确认闸 —— qq_qzone_post 必须传 confirm: true 才真的发。
说说是对外发布,而 delete_qzone_msg 在封禁清单里(发得出去、删不掉),
所以这道闸不是防恶意,是逼 agent 在发之前把「这是对外发布」过一遍脑子。
关掉用 qzoneRequireConfirm: false(不推荐)。
HTTP 接口
插件自带六个同源接口(设置页用的也是它们):
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /api/qq-bridge/state | 配置(不含 token)+ 路由表 + 会话列表 + 好友/群列表 + 机器人信息 |
| POST | /api/qq-bridge/save | 保存 routes 与高级设置(含 onebotHttp / token,留空不覆盖) |
| POST | /api/qq-bridge/detect-ws | 自动发现协议端的 WS 接入点 |
| POST | /api/qq-bridge/detect-http | 自动发现协议端的 HTTP 接入点(读 httpServers,0.15.0) |
| POST | /api/qq-bridge/memory-clear | 清空整张关系卡表(不可撤销,设置页里有二次确认) |
| POST | /api/qq-bridge/proactive-now | 跳过闸门立刻试一次主动搭话;{ "only": "private:1000000001" } 可指定对象,不传按轮转选人 |
另有一个独立的主动发消息接口(端口默认 39273),给 agent 之外的程序用:
curl -X POST http://127.0.0.1:39273/send \
-H "Content-Type: application/json" \
-d '{"text":"内容","user_id":"对方QQ号"}'
健康检查:curl http://127.0.0.1:39273/health
排障
~/.dsh/qq-bridge-load.log 插件日志(超 2 MB 自动轮转)
~/.dsh/qq-bridge-state.json 游标
~/.dsh/qq-bridge.config.json 配置
grep "\[config\]" ~/.dsh/qq-bridge-load.log | tail -1 # owner= 有没有 → 新版加载了吗
grep "\[poll\]" ~/.dsh/qq-bridge-load.log | tail -3 # 群在监听吗
grep "\[group\]" ~/.dsh/qq-bridge-load.log | tail -5 # skip (no at)? 未配置会话? seeded?
grep "error" ~/.dsh/qq-bridge-load.log | tail -5 # fetch failed = 协议端挂了
收不到消息:① 协议端在跑吗 ② selfUin / peerUin 对吗 ③ 这个号在 routes.private 里吗
④ 会话开着吗(没开靠 resumeColdSession 冷启动)
撞到白名单 —— 工具报「出站被白名单拦截:好友 xxx 不在允许范围内」:
grep "\[allow\]" ~/.dsh/qq-bridge-load.log | tail -3 # 白名单为空时会有警告
这是硬闸,不是 bug:qq_send 只能发给「插件正在服务的对象」
(routes 里的好友/群 + peerUin/ownerUin + groupUins)。
要么把目标加进设置页的会话绑定,要么临时 allowSendToAny: true(不推荐)。
日志里写着「收到消息事件」但会话里没动静 —— 看下一行是不是「重复消息(已处理),跳过」。
这是去重误杀:旧版本拿私聊的 message_seq 判新旧,但私聊的 seq 是两套编号混着的
(自己发的 71xx 连续、对方发的 50xxx/20xxx 跳号且不单调),会把新消息当成旧消息丢掉。
0.6.1 起私聊改用时间戳判新旧(群聊仍用 seq),这个问题不会再有。
grep "收到消息事件" ~/.dsh/qq-bridge-load.log | tail -3 # 消息进来了吗
grep "重复消息" ~/.dsh/qq-bridge-load.log | tail -3 # 被去重误杀了吗
群里没反应:① 监听列表里有这个群吗(日志 [poll] group loop started, watching [...])
② groupOnlyWhenAt 开着的话必须先 @ ③ 首次启动会 seed,启动前发的消息不会被回复,再 @ 一次
同一条回复连着发了好几遍:日志里搜「已转发过,跳过重复发送」。
这是「没等她发完就发下一条」触发的 —— agent 跑回合期间 session.seq 不推进,
两条消息拿到同一个游标 → 认到同一个回合 → 同一条回复发两遍。0.6.2 起按
「属于我自己的那条 user/message」定位回合,同一个回合只发一次。
重复收到同一条:删掉 qq-bridge-state.json,游标归零后会重新对齐(不会重放)。
设置页找不到「QQ 桥接」入口
服务端 API 和工具都正常、但设置页没入口 —— 通常是插件没进 boot graph, 浏览器侧拿不到 client bundle。
# DSH 的 web 端口(Electron,端口不固定)
Get-NetTCPConnection -State Listen | Where-Object { $pids -contains $_.OwningProcess }
# 拉 boot manifest —— 这条 SSE 直接吐出完整的插件清单
curl -s http://127.0.0.1:<port>/plugins/events | head -1
# → data: {"type":"graph","graph":{"entries":[{"id":"...",...}]}}
entries 里没有自己的插件 id = 没进 graph。常见原因是 package.json 的 name
跟插件的安装目录名不一致(DSH 按目录名找包,并校验 name 相等)。
开发
npm test # 跑全部离线自测
node tests/ui-wiring.mjs # 设置页 ↔ 配置项一致性(死配置 / 漏接 UI / 过时文案)
node tests/merge-sim.mjs # 合并窗口(窗口内合并 / 上限截断 / 失败回滚接线)
node tests/proactive-sim.mjs # 主动搭话的四道闸(静默 / 空闲 / 配额 / 退避)+ 白名单选人
node tests/client-render.mjs # 设置页真渲染(每个分区都过一遍,含选人名单)
node tests/pass-forward-sim.mjs # qq_pass 但有真话 → 照发(沙箱 HOME,不碰真实 ~/.dsh)
node tests/sync-sim.mjs # 检查运行副本的模块完整性 + 工具注册 + 包名一致性
node tests/assert.mjs # 断言套件(分条 / 出站队列竞态 / 增量扫描 / 群缓冲)
模拟脚本会临时借用真实的 ~/.dsh/qq-bridge.*,测试期间真 DSH 别在跑(两边会互相覆盖游标)。
想完全隔离:
mkdir -p /tmp/dsh-sandbox/.dsh && cp ~/.dsh/qq-bridge.config.json /tmp/dsh-sandbox/.dsh/
USERPROFILE=/tmp/dsh-sandbox node tests/identity-sim.mjs
⚠️ 模拟脚本必须显式设置 cfg.selfUin:群消息的 @ 段要跟 selfUin 比对,
继承真实配置的话 @ 认不出来,测试会「一条都没投递」。
模块划分
39 个模块,按职责分四层(数据流单向:QQ → 消息处理 → 投递 → 会话 → 出站 → QQ):
| 层 | 模块 |
|---|---|
| 入口 | index.js(装配各模块) |
| 基础设施 | env.js(数据目录/日志/宿主服务) config.js(配置) state.js(游标) onebot.js(协议调用) health.js(探活门控 + 错误分级) instance.js(单实例锁) |
| 消息处理 | poll.js(轮询 + WS 事件入口) ws.js(WS 客户端) merge.js(私聊合并窗口) backfill.js(上线补历史) snowluma.js(WS 自动发现) images.js(图片入库) emoticons.js(表情段渲染) emoji-mark.js(正文表情标记) |
| 投递与出站 | deliver.js(路由 + 投递) sessions.js(会话列表 + 冷启动) archive.js(识别已归档会话,不往收起来的会话里投) outbound.js(回发队列) split.js(分条) title.js(会话标题) history.js(消息历史检索) |
| 能力与 UI | tool.js(10 个工具注册,含沉默权三件套 qq_wait/qq_pass/qq_close 与 qq_remember) api.js(设置页 API) client.js(设置页前端) sendapi.js(主动发消息接口) closure.js(收尾评分) rhythm.js(节奏底座 + 自动静默) memory.js(关系卡落盘) proactive.js(主动搭话的钟 + 四道闸) |
| 表情与说说 | sys-faces.js(官方目录) face-resolve.js(id 解析) custom-faces.js(收藏表情) collect.js(收藏表情包) preview.js(看表情包) qzone.js(发说说) |
| 安全 | blocked.js(13 个 action 物理封禁) allowlist.js(出站目标白名单) sensitive.js(出站内容审计) |
各模块的详细职责和踩坑见 docs/开发笔记.md。
本地改了源码怎么推进 DSH
node sync.mjs
⚠️ 插件在 DSH 里有两份,sync.mjs 两个都要同步:
| 目录 | 是什么 |
|---|---|
~/.dsh/plugins/dsh-onebot-channel/ | pnpm file: 依赖的源,重装时从这里取 |
~/.dsh/profiles/desktop/node_modules/dsh-onebot-channel/ | DSH 运行时真正 import 的目录 |
之前只同步前者 → 运行副本一直是旧的 → 现象是「日志写着工具注册成功,agent 那边只有 qq_send」。
sync.mjs 还会反向把工作区里的说明文档拉回仓库 docs/(工作区那份才是 agent 真正读到的那份)。
跑完 重启 DSH 才生效 —— 插件是进程启动时加载的。
⚠️ 改
package.json的name时必须跟安装目录名一致,否则设置页入口会消失。sync.mjs会在同步前校验,对不上直接拒绝同步。
启动顺序:为什么入站要等一等
SnowLuma 的 OneBot 端口(HTTP 3000 / WS 3001)是等 QQ 登录成功之后才 listen 的。 而 DSH 常常比 QQ 先起来(开机自启的典型顺序),中间会差上 1~2 分钟。
这段窗口里如果直接起轮询和 WS,就是对着没开的门反复敲 —— 满屏 timeout 和 1006 断连,
真正出问题时反而在日志里找不到线索。
所以插件的启动分成两段:
| 阶段 | 时机 | 做什么 |
|---|---|---|
| 立即 | 插件加载时 | 注册 10 个工具、设置页 API、主动发消息接口、回复转发队列 |
| 延后 | OneBot 探活通过后 | 启动私聊/群聊轮询、WS 事件推送、主动搭话的钟 |
- 工具先注册:工具是出站能力,跟 QQ 登录无关。早给 agent,QQ 迟迟不登录时它也不至于「一个工具都看不到」。
- 探活用
get_login_info:它要真的进了 QQ 才应答,是「能干活了」的准信儿。退避 1s→5s,总超时 150s。 - 超时就照样启动:与其让桥接彻底不动,不如让轮询自己重试(错误分级保证不吵)。
- 错误分级:
ECONNREFUSED/fetch failed/ 超时这类「门还没开」的错误, 不写state.lastError(设置页那个字段是给「插件坏了」看的)、不逐条刷日志,只在状态翻转时记一条;retcode不对、HTTP 5xx 这类真问题照旧上报。
文档
- docs/QQ对话说明.md —— 装好后发给 agent 的说明: 她是谁、消息从哪来、哪些工具能干什么、什么做不了
- docs/踩坑记录.md —— 踩过的坑与真因,改代码前先扫一遍
- docs/开发笔记.md —— 实现细节与工程经验
致谢与边界
MIT © 2026 单树见月
- 协议端不在本仓库范围内 —— 本插件只跟 OneBot 11 协议端说话,不读 QQ 密码、不注入 QQ 进程
- 插件跑在 DSH 进程内,拥有 DSH 能触及的一切权限 —— 安装前请自行审阅代码
最后更新:2026-10-31 · v0.15.0
Comments
Loading…
From the same category
DeepSeek Harness plugin for Reactive Resume: bridges your resumes and job applications into a Harness session over MCP.
★ 41.7k
↓ 215/wk
MIT
Aug 24, 2026
dsh plugin --profile web add dsh-plugin-reactive-resumeby dsh-market
The plugin market inside DeepSeek Harness — browse, search, one-click install · DSH 可视化插件市场
★ 10.1k
↓ 361.7k/wk
MIT
TypeScript
Nov 1, 2026
dsh plugin --profile web add dshmarketby Tencent
Let AI agents use your real, logged-in browser without interrupting your work. CLI + extension for browser automation across any shell-capable AI agent.
★ 9.9k
↓ 12.3k/wk
MIT
TypeScript
Nov 1, 2026
dsh plugin --profile terminal add @wxg-prc-cpg/browser-skill-dsh-pluginby yjh051108
dsh-routing-suite — injector + router-standard kit: install the runtime injector first, then the task-aware reasoning-mode router preset (measured P1-P23).
★ 7k
MIT
JavaScript
Sep 18, 2026
dsh plugin --profile web add @dsh-external/dsh-super-injectorby superdesigndev
OpenRouter for agent tools. Join community here: https://discord.gg/6mQYYfFMAn
★ 7k
NOASSERTION
Python
Nov 2, 2026
dsh plugin --profile web add treg-dshby Q00
Agent OS: the agent gets smarter on its own. We just hold the line: Interview-gated, staged evaluation, budgeted evolution loop. MCP server, 14 runtimes: Claude Code, Codex CLI, Gemini CLI, OpenCode,
★ 6.3k
MIT
Python
Nov 1, 2026