dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
QQ 通道一体化插件:出站沉默守卫、每轮行为规则、对话节奏(入站合并/出站分段)、人设(情绪+主动发言+风格学习)、语音(qq_say: 文字→edge-tts→语音消息)。Runtime prototype patching for dsh-onebot-qq — upgrade-proof, no node_modules edits.
一个插件,把 QQ 通道该有的都装回来:出站沉默守卫 + 每轮行为规则 + 对话节奏 + 人设 + 语音 + 会话换代防护 + 时间戳。
中文 · English
DSH 插件 · 为 dsh-onebot-qq 补上被 0.9.0 升级冲掉的能力。
同系列:dsh-hot-reloader(热重载)· dsh-session-clock(会话时钟)· dsh-proxy-on-demand(按需代理) · dsh-second-opinion(第二意见)
本仓库取代dsh-onebot-guard/dsh-onebot-prompt/dsh-onebot-rhythm三个独立插件
(它们的能力已全部并入本插件的模块)。已装过旧插件的请先卸载再装这个,不要同时装——
两套代码会各自包装同一条原型链,导致重复分条、重复计数。
dsh-onebot-qq 只负责把 QQ 通道接上,聊天本身该有的东西它不管:沉默怎么表达、括号里的 旁白算不算话、一条长回复要不要分几次发、对方连打三行算几个回合、她此刻什么心情、 要不要主动开口、消息之间隔了多久。这些都得由外面补上 —— 本插件补的就是这一整套:
能力 · 现状 沉默协议([[silent]]) · 由 guard 拦下 括号旁白剥离 · 由 guard 剥掉 出站分段(一条长回复分几次发) · 由 rhythm 完成 入站合并(连打三行算一个回合) · 由 rhythm 完成 情绪 / 主动发言 / 风格学习 · 由 persona 提供 14 条行为规则 · 由 prompt 每轮注入 QQ 分享卡片的标题与链接 · linkcard 模块(上游只留一个 [卡片]) 会话换代防护 · adopt 模块 时间戳与间隔(time=/gap=) · stamp 模块 群里喊名字也算被叫到 · mention 模块 群里聊到兴趣时插一句 · mention 模块的 topics 群友回合禁用工具(硬隔离) · isolation 模块(ctx.tools.guard()) 她自己的小本子(三档遗忘) · diary 模块 + qq_diary 工具 答应过的事到点想起来 · promise 模块 + qq_promise 工具 表情包图库(收到的贴纸自己攒起来) · stickers 模块 + qq_sticker 工具 她此刻在忙别的(刷手机/打游戏) · focus 模块 他不在的时候,她自己在过 · solitude 模块 这一阵过得好不好,会改她的底子 · temper 模块(按天记事实 → 乘心情权重) 她的作息跟着你的作息走 · circadian 模块(24 小时桶学「你几点在」) 关系阶段(不只是个数字) · tier 模块(把 affinity 投影成台阶) 「她最后一次开口是什么时候」 · lastword 模块 + /physical 体检表
为什么不手改 `node_modules`:把那批约定直接写进 node_modules 里,dsh-onebot-qq 一升级(pnpm 换目录)就会整个被冲掉,同样的坑会再踩一遍。所以本插件一行都不改 node_modules,只从外面给原型挂包装 —— 升级不会再把功能冲掉。
这四个能力最初被拆成 guard / prompt / rhythm / persona 四个独立包。合并的理由很实在:
resolveProfileDirs()(探测 profile 目录)逐字重复两份、patchMethod() 两份、HTTP 观测面三份、loopback 判定三份、状态落盘两份。
import() 同一个 dsh-onebot-qq,各自起一个 3 秒扫描定时器,各自维护一份统计。合并后:一个包、一份配置、一个 HTTP 面,内部按职责分成十七个模块,各自可独立开关。
模块 · 干什么 · 挂在哪 guard · 出站闸:拦 [[silent]]、剥整行括号旁白、吞「⏳ 排队提示」「该命令仅管理员可用。」与自述/报幕(「我在等他回话」「我已经发了一句过去」这类回合进度,2026-10-19 加)、复述(刚说完的话又用第三人称讲一遍,2026-10-20 加);整条为空就不发 · OutboundPipeline.prototype.sendToChat / sendMsg adopt · 会话换代防护:裸 id 还有磁盘日志时接管(resume)而不是把它永久退休、另开一个新会话 · ChatRegistry.prototype.createChat stamp · 给注入边界补回 time="..."(本条消息的时刻)与 gap="..."(距上一条的间隔,如 3m47s),让模型不必自己做时间戳减法 · ChatBridge.prototype.handleInbound(记账)+ ChatBridge.prototype.dispatchFollowup(注入) poke · 让「拍一拍」看得见:上游把 post_type: notice 整个丢掉,这里在进管道前把「有人拍我」改写成一条消息(正文 [戳了你一下])再放行,下游的门禁/@ 判定/分段全部照旧。同一会话 3 秒内连点只放行第一条 · ChatBridge.prototype.handleInbound(最外层) prompt · 每轮注入 14 条行为规则 + 情绪行 + 风格行 · 宿主 agent/pre-step(不挂原型) rhythm · 入站合并(连打三行 = 一个回合)+ 出站分段(一条长回复分几次发) · ChatBridge.prototype.dispatchFollowup / sendToChat persona · 情绪四变量 + 随机主动发言 + 风格学习 · ChatBridge.prototype.handleInbound / onSessionEvent mention · 群里喊她名字算被叫到(diving 潜水续话)+ 聊到她感兴趣的方向时插一句(topics) · InboundPipeline.prototype.processInbound isolation · 群聊里非管理员回合禁用一切工具(只留发声 + 白名单内的 read_image + 本轮聊天里出现过的链接) · 宿主 ctx.tools.guard()(不挂原型) linkcard · 把 QQ 分享卡片的正文与跳转链接捞回来:[卡片] → [卡片:标题 https://…](直链优先 qqdocurl,否则 jumpUrl) · InboundPipeline.prototype.processInbound emoji · 把上游翻错的 emoji 换成 [表情:名字];把被丢弃的商城表情包补成可读图片;认不出的原生表情整条不回 · InboundPipeline.prototype.processInbound sleep · 睡意闸:说了「去睡/晚安」之后,群里只有被 @ 或喊名字才接,私聊照旧 · InboundPipeline.prototype.processInbound(优先级 30,在 mention 外层) hands · 「社交的手」:qq_poke(戳一戳)/ qq_react(贴表情回应)/ qq_like(名片赞)/ qq_requests(查申请);qq_approve 不直接生效,生成审批单发管理员私聊,等他回 /同意 编号 · InboundPipeline.prototype.processInbound(拦管理员的审批命令) voice · qq_say 工具:文字 → 合成(edge-tts 云端 / 本机 IndexTTS)→ 作为 QQ 语音发出 · 只注册工具,不挂原型 lastword · 记一笔「她最后一次真正开口是什么时候」,含原文片段与累计条数。不改参数、不抛错 · OutboundPipeline.prototype.sendToChat / sendMsg
十七个模块中有十一个挂在原型上,且存在共享挂载点(dispatchFollowup 上是 stamp + rhythm, handleInbound 上是 stamp + persona,processInbound 上是 sleep + mention + emoji + linkcard + hands)。它们靠 core/patch.js 的模块身份标记共存、互不吞并,链上的先后由模块优先级唯一决定,与挂载时序无关。
sleep的优先级是 30(高于stamp的 20),必须排在mention外层:
mention会给「没 @ 但叫了名字」的消息补一个假 at 段,如果sleep在内层,
那道闸看到的就已经是「被 @ 了」,永远拦不住。
adopt 修的是什么dsh-onebot-qq 原生的 createChat 里有这么一段:
let sessionId = makeSessionId(sessionIdForChat(chatId));
if (this.isSessionIdBlocked(sessionId)) {
sessionId = this.freshSessionId(chatId);
}
else if (await this.hasPersistedLog(sessionId)) {
this.retireSession(sessionId); // ← 永久退休,写进 retired-sessions.json
sessionId = this.freshSessionId(chatId);
}裸 id(如 onebot-group-900000002)在磁盘上还有历史日志时,它不接管,而是把裸 id 永久退休、换一个带时间戳后缀的新 id。retireSession 是永久的,此后每次 createChat 都从第一个分支掉进 freshSessionId —— 表现就是每条消息开一个新会话,上下文全丢。
关键区别:只有 `agents.create()` 会和已存在的日志撞车;`agents.resume()` 正是「接管这份 日志」的那个调用。 所以本模块从外面包住 createChat,在进入原方法之前先做决定。 三种情况明确放行给原方法:① evictedChats 里有这个 chat(快照 id 比裸 id 更权威) ② 裸 id 已被退休(尊重记录)③ 裸 id 没有持久化日志(没有可接管的)。
会话 id 的推导直接用上游自己的 `chat.js#sessionIdForChat`,不在本模块里复刻那个 正则 —— 抄一份就等于埋「上游改了规则而我们还按老规则算 id」的坑,算错 id 的后果是 「采纳一个不存在的会话」,比不采纳更糟。拿不到该函数时直接放弃挂载,不退化成猜。
stamp 修的是什么上游的 ` 边界上**一点时间信息都没有**。lib/chat.js` 两个构造器都是 3 参,签名里没有时刻:
export function wrapUserMessage(content, userId, nickname) { … } // lib/chat.js:133
export function buildGroupMessagePrefix(nickname, userId, mentioned) { … } // lib/chat.js:109lib/inbound.js 的 normalizeOneBot11(event) 返回的对象里同样没有 time 字段 —— 它把 event.time 丢掉了。所以模型看到的每条消息都是「刚才」,两个时间戳都没有。
为什么需要:模型不会可靠地做两个时间戳的减法,很容易把「隔了两小时」说成 「过了一整天」。这正是加这两个属性的全部动机 —— 把已经算好的间隔直接递过去,等于把 那道算术从模型手里拿走,让它没有算术可做错。
本模块往开标签上补回两个属性:
time="2026-10-08T15:15:23+08:00" —— 对方按下发送的时刻(本地时区、带秒;保留秒是为了同一分钟内的两条也能排序、能被可靠相减)。
gap="3m47s" —— 距他上一条消息隔了多久,已经算好。写法是紧凑、不含空格、最多两个最高整单位的时长:47s / 3m47s / 15h35m / 2d4h。不含空格是为了原样 穿过下面那道字符白名单,短到一眼能读完。
首次没有基线时 `gap` 留空,不猜 —— 省略属性好过印一个模型会当真的间隔。跨宿主重启的 「上一条入站时刻」因此要落盘(stamp-state.json,与 mood-state.json / style-state.json 同目录同范式,debounce 3 秒原子替换)。不落盘的话,重启后基线就是空的,第一条只能省略 gap,模型随即去读周期旁白里的 Elapsed 行,又把「两小时」说成「过了一整天」。 基线表最多 500 个 chat(沿用同一套 MAX_CHATS),超出时按「最久没来消息」淘汰 —— 没有这道闸,那张表会随这辈子收过消息的 chat 数单调增长。
字符白名单:两个值都用正则洗过 —— 时间戳只留数字与 T:+- (replace(/[^0-9T:+-]/g, '')),间隔只留数字与 dhms (replace(/[^0-9dhms]/g, ''))。保证这两个值永远不可能闭合标签、也永远不可能从 `` 边界里逃出去。文本改写只动开标签,块内内容一字不碰;同名属性 先摘掉再写,重复注入 / 热重载重入都不会堆出两个 time=。
为什么挂两个点:职责不同,两张表语义也必须分开。
挂载点 · 干什么 · 记的什么 ChatBridge.prototype.handleInbound · 记账:把 OneBot 事件的 time(unix 秒)连同它属于哪个 chat 记下来 · seenAt:每次入站都覆盖,含后来被门禁吞掉的未 @ 消息、去重、限流、命令 —— 只回答「这个 chat 最近一次入站是什么时候」 ChatBridge.prototype.dispatchFollowup · 注入:把属性写进交给模型的文本 · baseline:注入成功后才写,是 gap 的基线 —— 只对真正到达模型的消息推进,命令回执之类的投递不污染它
dispatchFollowup 已被 rhythm 挂着(入站合并)。core/patch.js 的语义是后挂的在外层, 所以本模块必须排在 scanAll() 里 rhythm 之后:这样每条消息各自单独经过本模块(各自注入 自己那一刻的 time/gap),再由 rhythm 把已注入的几条合并成一批。换过来(本模块在内层) 只会在合并后拿到一整批文本,只能贴一个时间。
chatId 的推导直接用上游自己的 `chat.js#buildChatId`,不复刻那个拼法 —— 抄一份就是埋 「上游改了规则而我们还按老规则算」的坑,算错的后果是把时刻记到别的 chat 名下。 拿不到该函数时直接放弃挂载,不退化成猜。入站时刻还要求像样(2001–2286 年的 unix 秒), 坏值退回当前时刻 —— 一个坏值绝不该给消息盖上胡说八道的钟。
已知未补回的一处:群消息前缀 [HH:MM 昵称(QQ)] 不受本模块影响 —— 它用的是处理时刻,与 time 属性只差几秒,而改写群前缀要啃昵称里的方括号,不划算。mention 修的是什么上游 dsh-onebot-qq/lib/cq.js 的 detectMention 只认两件事:at 段指向机器人、或 reply 段(引用)指向机器人。在群里直接喊「云玥」不算。 这跟真人群聊的习惯不一样 —— 真人被叫名字就知道在叫自己。本模块在 InboundPipeline.prototype.processInbound 外面加三条判据:
判据 · 干什么 · 默认 喊名字 · 正文里出现 names 里的任一个(默认 云玥 / 玥玥 / Yara)→ 算被叫到 · 开 diving · 被叫醒之后的一小段时间里,群里不 @ 也会搭话,然后自己潜回去 · 关 topics · 群里聊到她感兴趣的方向时自己接一句(见下) · 关
怎么表达「她听见了」:往 inbound.segments 追加一个 { type: 'at', data: { qq: bot } } 段 —— 复用上游「被 @」这条现成语义,detectMention 的 at 分支返回 true,requireMention 闸放行,群前缀盖上 [@我],模型看到的就是「有人点名找你」。不自己造第二套「被叫」语义, 是为了将来上游改了 @ 的处理能跟着一起变。
已经核过:inbound.segments在整个包里只有一个消费者 ——inbound.js:119的
detectMention。所以往segments里追加段只影响那一个判据,不污染正文、媒体或引用展开
(正文早在normalizeOneBot11里就算进inbound.text了)。
顺手修的一个坑:cq.js 把 at 段渲染成裸 QQ 号(@100000002),于是她看到的是 一串陌生号。更糟的是规则层原本写着「@ 后面的数字是被 @ 那个人的号」,反而让她把自己那个号 也划进「别人」——2026-10-16 23:10 那次就是:群前缀明明盖着 [@我],她内心独白里却写 「没有 @我」,整场沉默。现在正文里指向她自己的裸号会被改写成 @你(rewriteSelfMention, 别人的号保持原样),规则层也补了一句「@你 指的就是你自己」。
话题插话(`topics`):用户 2026-10-17 要求「设置几个云玥可能感兴趣的方向,群里有人 聊到了可以一块聊」。这是在群里自己开口,所以与 diving 一样默认关。打开后也不是 「放进所有群聊消息」,要连过六道闸:
闸 · 作用 · 默认 关键词命中 · 正文里出现她感兴趣的具体字眼 · 7 组,见 DEFAULT_TOPIC_GROUPS 消息长度 · 短于 minChars 的不参与匹配 · 4 字 掷骰子 · 命中不等于必答 · chance 0.4 每群冷却 · 同一个群两次插话的最小间隔 · 30 分钟 每日上限 · 每个群每天最多几次 · 3 次 安静时段 · 这段时间完全不插话 · 1:00–8:00 群白名单 · chats 非空时只在这些群生效 · [] = 不限
命中后放行的那一条消息会在正文末尾多一行说明(topicHint): [群里在聊「猫」,你正好感兴趣就自然接一句;不想接就回 [[silent]]]。必须说清楚 —— 因为闸门借用的是「被 @」语义,群前缀会盖上 [@我],不解释她会反问「叫我干嘛」。
关键词组合并而非替换(normalizeTopics):用户写 mention: { topics: { enabled: true } } 时,mergeConfig 的浅合并会顶掉整个 topics 对象,所以本模块自己把缺失的键补回默认值; groups 则额外做一层合并,加一组不影响默认那几组,某组写 [] 就关掉它。
窗口分两张表:awake 只管 diving,topicAwake 只管话题插话。混在一张表里就分不清 「这个窗口是谁开的」,于是「潜水关着也不该吭声」会被话题窗口悄悄破坏(自测里抓到过)。 同理,一次真 @ 只推潜水窗口、不推话题窗口 —— 否则一次 @ 就等于给这个群开 10 分钟免检, 又退回用户明确否掉的「群里随意搭话」。
本模块不做的事:不碰引用段。上游 reply 分支是 fail-open(const qq = seg.data.qq ?? ''; if (qq === '' || qq === bot) return true;),而 NapCat 的 reply 段只有 id、从不带 qq, 于是「群里甲引用乙」也会被判成 @ 了机器人。修它要么联网查 get_msg(用户明确否掉), 要么收紧成「引用谁都不算」(会漏掉「引用云玥」)—— 取舍留给用户。也不联网:全部判据 都是本地字符串匹配与内存状态,零 RPC、零依赖。
id · 规则 · 默认 silent · 沉默就用 [[silent]],不许用文字或括号表示沉默 · 开 speak · 群里什么时候该开口:被点名/被问到/有兴趣/有情绪时自然接,别把「跟我无关」当默认答案,但也绝不硬接 · 开 ability · 你能做什么、不能做什么:能发消息 + 四件社交小事(戳/贴表情/赞/查申请),其余不行;qq_approve 只是「去问一声」,绝不能说「已经同意了」;看不到对方屏幕;要帮忙就给步骤但不知道就别指挥;需要登录的页面直说打不开 · 开 aside · 正文只写要说的话,旁白整行括号、且注明「说给对方」;发完就停,别汇报「我在等他回话」这类回合进度 · 开 image · 图片可主动发(qq_send_image),但严禁编造本地路径 · 开 link · 别人发的链接想接话就先 web_fetch 看一眼;没读别装读过、读不到就说读不到,绝不编内容;群里那条链接也允许读 · 开 quarrel · 群里有人争执/对线时默认不接,被 @ 也可沉默 · 开 face · 表情善用但别滥用,别拿表情代替说话 · 开 tone · 短句口语、别写文档、绝不暴露运行状态;含真人统计刻度(中位 6 字 / 九成 ≤20 字)与「想说的多就分几条发」 · 开 voice · 话一长就用 qq_say 说出来,但别滥用;发语音前先把话「说人话」 · 开 time · 心里有时间数,但不报时间、更不编造隔了多久 · 开 channel · 分得清私聊/群聊与发言人,别把 A 的话当 B 说的;@你 指的是她自己 · 开 batch · 对方连着发的几句合起来一起回,别一句回一次 · 开 topic · 被放进群里看话题时不必说话,不想接就 [[silent]] · 开
silent这条是与guard模块的契约:规则层明令禁止用括号表达沉默,只许用
[[silent]] 一个写法,这样 guard 的判别器才能 100% 命中且不留残余。改这条措辞前请先同步 guard 的判别逻辑。
speak是 2026-10-17 补的缺口:requireMention: false(群里每条消息都进模型、
回不回由她自己定)之后,原提示词里「怎么说别出声」有 5 条、「什么时候该出声」
有 0 条,于是最省力的策略就是继续沉默——实测该群 19 个回合里沉默 4 次(21.1%)。
这条给的是具体可指认的开口情形(被点名 / 被问到 / 有兴趣 / 有情绪),
同时保留「绝不硬接」,避免从一个极端滑到刷屏 bot。它排在 silent 之后,让「怎么沉默」和「何时开口」相邻。
ability 是 2026-10-19 补的边界(起因是对方指出这不对,并附了聊天截图):示例用户发来一张「和我成为情侣」的 QQ 卡片,她连发数条,内容是「带你一步步来」
「把链接发我就行」「已经发出去了」「在旁边等回话」——**既没点开、也没发过
邀请、更看不到对方屏幕**。最刺眼的是 reasoning 里已经写明「无法真的操作
(需要登录)」,正文却输出「已经点开了」: