Bundle
dsh-onebot-qq
给 dsh(DeepSeek Harness)加 QQ 机器人通道:OneBot 11 协议,兼容 NapCat,支持私聊群聊、图片语音、t2i 卡片与设置页配置。QQ bot channel plugin for deepseek harness via OneBot 11.
- Source
- mario841859784
- stars
- 1 stars
- License
- BSD-3-Clause
- Updated
- Updated 5 days ago
Readme
# dsh-onebot
> **[English](README.en.md) | 中文**
给 DeepSeek Harness 加上 QQ 通道。A QQ channel for dsh.
本插件把 dsh 变成一个 QQ 机器人(**OneBot 11 协议**,兼容 NapCat / Lagrange / LLOneBot / go-cqhttp),
与 [dsh-vision](https://github.com/dsh-external/dsh-vision) 同款的外部插件形态:**零 Python、纯 TS、
原生 Cordis 插件**,挂载进 dsh 宿主进程,不改任何核心代码。
```
用户(QQ) ←→ NapCat ←→ dsh-onebot 插件 ←→ dsh Agent(每个会话一个)
├─ 反向 WS 服务器 / 正向 WS 客户端(自动重连)
├─ 入站:CQ 解析、图片下载、语音转写(STT)、引用/合并转发展开
└─ 出站:单条/t2i 卡片发送、Markdown 剥离、[[qq_forward]]、图片/语音/视频/文件工具
```
## 架构

> 可交互版本(暗/亮主题切换 + 引导视图):[docs/dsh-onebot-architecture.html](docs/dsh-onebot-architecture.html);矢量版:[docs/dsh-onebot-architecture.svg](docs/dsh-onebot-architecture.svg)。
## 功能
| 类别 | 能力 |
|---|---|
| 连接 | 反向 WS(NapCat ws-reverse 拨入,默认端口 8643)或正向 WS(拨出,默认 ws://127.0.0.1:3001);断线自动重连(2s→60s 退避) |
| 入站 | 私聊/群聊、段数组优先解析(CQ 字符串回退)、CQ 反转义、@/回复触发检测(fail-closed;回复仅认机器人自己的消息)、图片四路解析(url/base64/file/hash)、大图自动压缩(长边 ≤`inboundImageMaxPx`,GIF 不压)、文件段双通道接收(CDN 直链 get_private_file_url + get_file base64/url 回退)、表情 id→emoji/卡片/戳一戳段类型、引用消息自动取原文(get_msg)、合并转发自动展开(get_forward_msg) |
| 语音 | ffmpeg 转 16kHz WAV + whisper 转写(openai-whisper / whisper.cpp / 自定义命令);**非阻塞**:语音消息先以 [语音] 占位进入回合(不阻塞回复),转写完成后以「(语音转写:…)」补递(默认超时 60s);转写失败/超时保留 [语音] 占位 |
| 文字图 | t2i 卡片渲染器(@napi-rs/canvas):标题/粗斜体/删除线/引用/列表/代码块/表格/行内 code 胶囊/彩色 emoji/中文标点禁则;与 Hermes 原版同款数值(800px/26px/禁则集合/右缘 790) |
| 出站 | 正文 ≤ `textImageThreshold`(默认 150)字符单条发送、**超过阈值渲染 t2i 文字图卡片**(AstrBot 风格:标题/引用/列表/表格/代码块/彩色 emoji;渲染失败、PNG 超 `outboundImageMaxBytes` 或 `<=0` 禁用卡片时回退单条纯文本)、Markdown 剥离为 QQ 纯文本、[[qq_forward]] 合并转发(群/私聊)、**实时中间消息**(interimMessages:每条中间文本立即发出、实时可见;各自在 `interimRecallMs`(默认 90s)后自动单独撤回;回合结束时先把整轮中间消息渲染成一张 **t2i 小结卡**、立即撤回仍在屏幕上的原文、再发送最终回复——不用回合末合并转发,避免长回合「原文超 2 分钟撤不回+转发卡重复」;`interimRecall: false` 时降级为只发不撤(无小结卡、不撤回))、**宿主「计划书/提问卡」自动中继**(模型调用 exit_plan_mode / ask_user_question 时把计划全文/问题选项发到 QQ)、正在输入提示(set_input_status,仅私聊) |
| 命令 | 斜杠命令(仅管理员):`/new` 开新会话、`/stop` 停止生成、`/model` 查看或切换当前会话模型(`--default` 修改部署默认;无参输出两级序号列表,回复序号选 provider → 再回复序号选模型)、`/workspace` 查看或切换工作区(无参编号列表,回复序号即选)、`/preset` 查看或切换 agent 预设(无参编号列表,回复序号即选)、`/session` 查看可切回历史会话并按序号切回(`/new`、`/workspace`、`/preset` 切换下来的旧会话进列表,可来回切,历史上下文恢复)、`/status` 会话全景、`/healthcheck [export]` 运行体检(export 导出脱敏诊断包 qq-diagnostics-*.zip)、`/retry` 重跑上一条、`/id` 会话标识、`/ver` 版本、`/ocr` 识别最近图片、`/mode` 切换出站模式(跨重启持久化)、`/plan` 计划模式、`/permission` 切换宿主权限预设(QQ 别名 `w`=工作区可写+需审批、`f`=全权+免审批;完整预设名照传,未知名由宿主报错并回显可用列表;无参查看当前与可用列表,序号按 available 顺序)、`/goal` 目标记录(跨重启持久化)、`/help` 帮助;未知斜杠命令默认拦截并提示相近命令(`unknownCommand: passthrough` 改为透传给模型) |
| 工具 | `qq_send_image`(≤9 张,路径或 URL)、`qq_send_voice`、`qq_send_video`、`qq_send_file`、`qq_send_forward`、`qq_napcat_api`(14 个白名单 action)、`qq_group_history`(文件编辑工具 `code_safe_edit` 等已拆至独立插件 dsh-safe-edit,见下文「安全编辑」) |
| 权限 | 管理员白名单(`ONEBOT_ALLOWED_USERS`)、dm/group 策略(open/allowlist/disabled)、群聊 @提及 gating、受限用户 [受限用户:仅问答] 软限制、出站敏感内容审计 |
| 会话 | 每个 QQ 会话一个持久 Agent(session id 稳定派生),重启后自动 resume;按 `agentPreset`/`workspacePath` 挂载到 preset 与工作区;每轮结束 flush 落盘 |
| 运维 | 热加载:profile 层 patch 的 **config 覆盖**与 **insert 新增/删除**均热生效——插件 fiber 秒级原地重启(宿主进程不重启;代价:QQ 桥一次秒级重连、在途回合中断);touch/内容未变不触发任何动作;bundle 自带 patch、仓库模板、部署副本的 patch 文件与根配置 `cordis.yml` 的改动**不**热生效;临时媒体 TTL 过期清理 |
| 提示词 | 自动注入 QQ 平台说明(纯文本输出、图片/语音标注 `[图片]`/`[语音]` 占位、工具与命令指引、禁宿主交互卡);按**每个 QQ 会话 agent 自身作用域**注入,Web 会话不可见 |
## 兼容性
| 项 | 要求 |
|---|---|
| dsh | ≥ 0.1.5-rc.1(`engines.dsh`:0.1.5/0.1.6/0.1.7 全线 + 0.2.0 线 ≥0.2.0-rc.1;@deepseek-ai/* peer 依赖同范围,JsonValue 由 dsh-util-values 提供) |
| Node.js | ≥ 22 |
| OneBot 11 实现 | NapCat / Lagrange / LLOneBot / go-cqhttp(reverse 或 forward WebSocket) |
| 可选依赖 | 语音转写需 ffmpeg + whisper CLI;t2i 文字图在 Linux 需 Noto CJK 字体 |
最后验证:2026-09-12(M4 交互与持久化:未知命令拦截 / 序号选择 / workspace 持久化——vitest 306 用例全绿、构建通过;M0 安全语义保持:reverse 空 token 拒绝启动、默认仅监听 127.0.0.1 为 BREAKING 变更,见下方配置表说明)。
## 安装
**前置**:dsh(≥0.1.5-rc.1)在 PATH 上;NapCat 或其他 OneBot 11 实现已运行。
### npm 安装(发布通道,与下方源码安装二选一,勿混装)
```sh
dsh plugin --profile web add dsh-onebot-qq
```
(或用 Web 界面的 Plugins 页安装。)随后:
1. 在 profile 目录 `<DSH_HOME>/profiles/web/` 的 `pnpm-workspace.yaml` 中写入两行
(实测 pnpm 12 不读项目级 `.npmrc`,见 `docs/npm-e2e-report.md` D1):
```yaml
autoInstallPeers: false
hoist: false
```
——防止 pnpm 自动拉入 registry 上陈旧的 `@deepseek-ai/*` 独立副本。npm/pnpm 版本差异:
此配置仅 pnpm 12 用户必须(pnpm 12 已不读 `.npmrc`);npm 用户无此问题——实测 npm 默认
peer 自动安装拉入的 registry 现版本满足 peer 范围(`docs/npm-e2e-report.md` §1 #2)。
运行期 peer 统一由宿主解析层完成:插件 import 的 `@deepseek-ai/*`
只要声明在该包 peerDependencies 里,就会被宿主拦截并重定向到宿主安装树的同一实例,
无需任何 npm override。
2. 把包内 `cordis.patch.yml` 模板的两行 insert 合并进 profile 层
`<DSH_HOME>/profiles/web/cordis.patch.yml`:主条目 `name: 'dsh-onebot-qq'`,
host-plane 行 `name: 'dsh-onebot-qq/settings-remote'`;config 覆盖须挂
patch 顶层同 id 行(详见模板头注释)。
3. **不要**在 profile 的 package.json 手写任何 `@deepseek-ai/*` 依赖——手写副本会
覆盖宿主安装树成为拦截目标,造成版本错位双包。
4. 若安装被兼容门拒绝(dsh 版本不在 peer 范围内),按官方口径用
`dsh plugin allow-version ... --accept-risk` 显式豁免。
### 方式二:git clone 源码安装
```sh
git clone <repo> ~/dsh-plugins/dsh-onebot
cd ~/dsh-plugins/dsh-onebot
npm install --include=dev
./scripts/build.sh # 链接宿主 @deepseek-ai 包 + tsc 编译 src/ → lib/
```
> **部署副本必须带完整 node_modules 链接集**(`scripts/link-host.sh` 产物):`files` 只打包 `lib/`,
> 但运行时 peer 依赖 `@deepseek-ai/*` 是链接到宿主安装的——只复制 `lib/` 会挂载失败(插件入口会
> 显式报「peer 依赖解析失败」错误)。宿主无 `dsh` 在 PATH 上(如 fnOS 应用形态)时用逃生门:
> `DSH_ROOT=<宿主node_modules路径> ./scripts/build.sh`。
**fnOS 应用形态宿主构建**:fnOS 应用中心安装的 dsh 没有 `dsh` CLI 在 PATH 上,`build.sh` 的自动定位
(PATH 二进制 → npx store)会失败并显式报 `cannot locate the dsh install`。此时用 `DSH_ROOT` 显式指定
宿主 node_modules 根(该目录下须有 `@deepseek-ai/`,否则报错退出而非静默回退):
```sh
DSH_ROOT=<fnOS 应用数据目录>/node_modules ./scripts/build.sh
```
构建完成后把部署副本(`lib/` + 完整 node_modules 链接集)放到目标位置再挂载;运行副本缺链接集同样会挂载失败(见上方说明)。
挂载到 ~/.dsh/config.yaml(没有就新建):
```yaml
- insert:
- id: dsh-onebot
name: '$HOME/dsh-plugins/dsh-onebot/lib/index.js'
config:
mode: reverse # reverse = NapCat 拨入;forward = 插件拨出
port: 8643
# accessToken: '与NapCat一致的token' # 必配:reverse 模式留空将拒绝启动(M0 安全加固)
# botQQ: '' # 留空自动从 meta 事件学习
adminUsers: ['你的QQ号'] # 必配:至少一个管理员,否则私聊/斜杠命令不可用
```
> ⚠️ **首次配置必须设置至少一个管理员**(`adminUsers` 或环境变量 `ONEBOT_ALLOWED_USERS`):
> `dmPolicy: open`(默认)只允许管理员私聊,斜杠命令也仅管理员可用;不设置则无人能对话。
> 开发调试可临时 `allowAllUsers: true`(或 `ONEBOT_ALLOW_ALL_USERS=true`)放行所有用户。
重启 dsh(`dsh web` 或你的启动方式),日志出现 `[dsh-onebot] mounted` 即挂载成功。
**NapCat 侧(必须配置,两种模式二选一)**:
- **reverse 模式(NapCat 拨入 dsh,推荐)**:NapCat 网络设置里新增「**WebSocket 客户端**」,
「上报地址」填 dsh 的 WS 地址 `ws://<dsh 所在机器 IP>:<port>/ws`(如 `ws://192.168.1.100:8643/ws`),
「token」填插件 `accessToken` 相同的值;dsh 与 NapCat 不同机时不能用 `127.0.0.1`。
- **forward 模式(dsh 拨出到 NapCat)**:NapCat 网络设置里启用「**WebSocket 服务端**」(默认监听
`0.0.0.0:3001`),插件 `url` 配置为 `ws://<NapCat 所在机器 IP>:3001`(同机可用
`ws://127.0.0.1:3001`),token 两边一致。
两侧 token 必须一致;消息上报格式建议选「**数组**」(插件段数组优先解析,CQ 字符串仅回退)。
配置完成后重启 dsh,日志出现 `[dsh-onebot] mounted` 且 NapCat 显示连接成功即就绪。
**部署位置要求**:NapCat 必须部署在 dsh **可达的局域网**内(同一网段/能互通),
WS 连接、图片下载、文件解析都依赖这条网络通路;NapCat 与 dsh 不在同一台机器时,
需要在 NapCat 侧**开启「文件转 URL」开关**,`get_file` 才会返回可下载的 http(s) url
(否则返回容器内路径,本插件无法访问)。
## 配置
完整配置项见 [src/index.ts](src/index.ts) 的 `Config` schema(schemastery 校验,均有默认值)。常用:
| 键 | 默认 | 说明 |
|---|---|---|
| `mode` | `reverse` | `reverse`/`forward` |
| `host` / `port` | `127.0.0.1` / `8643` | reverse 监听;跨机部署(NapCat 从其他机器拨入)需显式改为 `0.0.0.0` |
| `url` | `ws://127.0.0.1:3001` | forward 目标 |
| `reconnectMaxAttempts` | `100` | 自动重连放弃上限:连续失败达到该次数后停止重连,日志输出上限值与恢复指引;`0` = 无限重连(退避封顶 60s) |
| `accessToken` | 空 | OneBot token;**reverse 模式必填**,留空插件拒绝启动(fail-closed);forward 可为空 |
| `botQQ` | 空 | 机器人 QQ(空=自动学习) |
| `ignoreSelf` | `true` | 忽略机器人自己发出的消息(防自循环) |
| `requireMention` | `true` | 群聊需 @ 或回复机器人的消息才响应(回复他人消息不触发;被回复消息无法判定时回落视为提及,fail-open) |
| `rateLimitPerMinute` | `30` | 每会话每分钟普通消息上限(60s 滑动窗口):超限跳过处理并限流提示(每窗口至多一条),命令不受限;`0` = 禁用 |
| `dedupWindowSeconds` | `300` | 入站消息去重窗口(秒,W2-②):同一会话内相同 message_id 在该窗口内重投(断线重连重放等)只处理第一条,后续静默跳过并记 debug;仅对会触发回复处理的消息事件生效(notice/meta 类不经过此闸);`0` = 禁用去重 |
| `actionRatePerMinute` | `20` | 主动写操作每分钟全桥合计上限(W2-③):约束 qq_send_image/voice/video/file/segments 与 qq_send_forward 等工具发起的主动发送(回合内的被动回复/interim/通知不受此闸,由 rateLimitPerMinute 与离线队列上限约束);超限拒发并记 warn 与审计;`0` = 禁用分钟限额 |
| `actionRatePerDay` | `500` | 主动写操作每日(本地自然日)全桥合计上限(W2-③):口径同 `actionRatePerMinute`;超限拒发并记 warn 与审计,次日自动恢复;`0` = 禁用日限额 |
| `actionAuditEnabled` | `true` | 主动写审计(W2-③):每次主动写(成功/失败)与拒发事件追加写一行 JSON 到 `mediaDir/qq-actions.log`(含 ts/chatId/action/ok/reason);写失败仅 warn 不影响发送 |
| `traceEnabled` | `false` | 链路追踪(W1):为每条入站消息生成 traceId 并贯穿入站→模型回合→出站,决策事件(含限流/去重/白名单/@ 门控/闸门拒发与每个静默丢弃分支的中文 reason)逐行 JSON 落盘 `mediaDir/qq-trace.jsonl`(4MiB 改名轮转,保留 `qq-trace.1/2.jsonl`;同一原因 5 分钟限频;异步队列写不阻塞管线);默认关闭——关闭时不生成 traceId、不落盘、决策行为与 0.6.0 完全一致 |
| `traceLevel` | `debug` | 追踪事件级别(W1):`debug` 记录全部决策事件;`warn` 仅记录 ok:false(被拒/失败/丢弃)事件 |
| `logLevel` | `info` | 控制台日志级别门槛(debug < info < warn < error):低于该级别的日志不输出。`debug` 全部输出(含「session 事件早退」等调试行);`info` 常规信息(启动、连接建立、meta 事件等,默认);`warn` 仅警告与错误;`error` 仅错误 |
| `recordInbound` | `false` | 入站录制(W1):把收到的每条入站事件(含被门控跳过的,附当时决策 reason)按可回放形状逐行 JSON 追加写 `mediaDir/qq-inbox.jsonl`(2MiB 改名轮转,保留 `qq-inbox.1/2.jsonl`;注入的帧不会被二次录制,杜绝「注入→录制→回放→注入」自激);纯旁路:写失败仅 warn 限频,绝不影响回复管线 |
| `inboxRedact` | `false` | 录制脱敏(W1):落盘前把帧中 6 位以上的数字串(QQ 号/群号等)脱敏为前 3 位+`****`(数字型 id 字段一并处理),便于把录制文件发给别人离线回放(`npm run replay`) |
| `injectEnabled` | `false` | 事件注入通道(W1,**仅调试用**):开启后按 `injectIntervalMs` 轮询 `mediaDir/qq-inject.jsonl`,把新行(每行一个 OneBot 消息事件 JSON)喂进**真实入站管线**(trace 事件标 `stage=inject`);启动时已存在的历史行跳过并记一条说明。信任边界:注入是调试通道,默认全关,勿在生产开启 |
| `injectDryRun` | `true` | 注入干跑(W1,**强烈建议保持 `true`**):注入回合触发的全部出站写操作(发消息/撤回等,**含异步模型回合的最终回复**——拦截点在出站动作层)被拦截计数、原文进 trace 事件、绝不真发 QQ;真实消息到达同一会话立即解除拦截(不连坐真人);`false` = 注入内容会真实发送,仅限隔离环境实验 |
| `injectIntervalMs` | `2000` | 注入队列轮询间隔(毫秒,W1;最小 500) |
| `unknownCommand` | `intercept` | 未知斜杠命令处置:`intercept`(默认)拦截并提示相近命令(前缀匹配优先,编辑距离 ≤2 兜底且仅输入长度 ≥4 时启用,至多 3 个候选;无候选提示发 `/help` 或去掉开头 `/` 重发);`passthrough` 维持旧行为透传给模型。非 `/纯单词` 开头的文本(如路径 `/tmp/x`)不受影响 |
| `dmPolicy` | `open` | 私聊策略:`open`(仅管理员)/`allowlist`(白名单)/`disabled` |
| `groupPolicy` | `open` | 群聊策略:`open`(所有人)/`allowlist`/`disabled` |
| `restrictedMemberPrefix` | `true` | 群聊非管理员消息注入 `[受限用户:仅问答]` 前缀(软限制) |
| `adminUsers` | `[]` | 管理员 QQ;也可用 `ONEBOT_ALLOWED_USERS` 环境变量。**必须至少设置一个**,否则私聊(dmPolicy=open)与斜杠命令无人可用 |
| `allowFrom` / `groupAllowFrom` | `[]` | 白名单用户/群 |
| `interimMessages` | `true` | 工具调用之间的中间文本是否立即发送;`false` 只发最终回复 |
| `interimRecall` | `true` | 中间消息撤回与回合末小结卡开关;`false` = 只发不撤(降级:无小结卡、不撤回,中间消息留在屏幕上) |
| `sendErrorNotice` | `true` | 一轮运行出错时向用户发送 ⚠️ 错误提示 |
| `sensitivePatterns` | `[]` | 出站敏感内容审计正则(内置 `rm -rf`/关机/删库/密钥 等模式;留空用内置默认) |
| `sttEnabled` | `true` | 语音转写(需 ffmpeg + whisper CLI) |
| `sttEngine` | `auto` | STT 引擎:`auto`(自动探测 whisper-cli/whisper/mlx_whisper)/`openai`/`whisper-cpp`/`custom` |
| `sttModel` | `small` | whisper 模型 |
| `sttCommand` | 空 | `custom` 引擎的程序名/路径 |
| `sttArgs` | `[]` | `custom` 引擎参数模板,`{file}` 与 `{out}` 会被替换 |
| `sttTimeoutMs` | `60000` | 语音转写超时(毫秒;v0.4.0 起默认 60s,此前 300s):超时保留 [语音] 占位;`<=0` 回落内置 60s |
| `textImageThreshold` | `150` | t2i 卡片阈值:正文长度 ≤ 该值单条发送;> 该值渲染为文字图卡片。渲染失败、PNG 超 `outboundImageMaxBytes` 或 `<=0` 禁用卡片时回退单条纯文本 |
| `cardFooter` | `dsh` | 卡片页脚品牌("Powered by <brand>") |
| `fontFiles` / `fontFamilies` | `[]` | t2i 字体文件/家族覆盖(Linux 部署必看:需安装 Noto CJK) |
| `mediaDir` | `<dsh-home>/media/onebot` | 入站媒体/映射文件目录 |
| `tempTtlHours` | `6` | 入站临时媒体文件保留时长(小时),到期自动清理 |
| `inboundImageMaxPx` | `2048` | 入站图片长边上限(px):超过则等比压缩后交给视觉模型(透明 PNG 保留、GIF 不压);`<=0` 禁用(旧名 `imageMaxSize` deprecated,本版兼容读取) |
| `outboundImageMaxBytes` | `8388608` | 出站图片大小上限(字节):t2i 小结卡等出站图片超限时自动回退纯文本(旧名 `maxImageBytes` deprecated,本版兼容读取) |
| `maxVoiceBytes` | `15728640` | 出站语音大小上限(字节) |
| `maxFileBytes` | `20971520` | 出站视频/文件大小上限(字节) |
| `inboundFileMaxBytes` | `20971520` | QQ 入站文件大小上限(字节):超限文件拒收并提示(旧名 `maxInboundFileBytes` deprecated,本版兼容读取) |
| `allowPrivateHosts` | `false` | 下载媒体 URL 时允许私网/环回地址(仅跳过私网检查,协议白名单与限长仍生效);仅本机反代等可信场景开启,公网部署保持 `false` |
| `agentPreset` | 空 | 会话挂载的 agent preset(留空=默认) |
| `workspacePath` | 空 | 会话挂载的工作区(留空=宿主进程 cwd;未配置时插件启动会 warn 一次建议显式配置——宿主未提供可编程查询的默认工作区)。`/workspace` 的 per-chat 覆盖已持久化进映射文件,重启不丢 |
| `chatIdleEvictDays` | `7` | 会话空闲淘汰天数:chat 超过该天数无任何活动时,在下一条入站消息处理前清理其内存态 agent(会话先 flush 落盘、映射保留,同一 chat 之后再发消息可 resume 恢复原会话);`0` = 禁用 |
> ⚠️ **BREAKING(M0 安全加固)**:reverse 模式下 `accessToken` 留空会拒绝启动(fail-closed);`host` 默认从 `0.0.0.0` 改为 `127.0.0.1`(仅本机监听),跨机部署需显式配置 `host: 0.0.0.0`。
环境变量:`ONEBOT_ALLOWED_USERS`(逗号分隔管理员)、`ONEBOT_ALLOW_ALL_USERS=true`(开发用)。
## 录制 · 回放 · 注入 · 体检(W1)
| 能力 | 开关(默认) | 用法 |
|---|---|---|
| 入站录制 | `recordInbound`(**false**)+ `inboxRedact`(false) | 开启后每条入站事件逐行 JSON 追加写 `mediaDir/qq-inbox.jsonl`(含被门控跳过的事件与当时决策 reason;2MiB 改名轮转);纯旁路,写失败仅 warn 限频 |
| 离线回放 | 无需开关 | `npm run build` 后 `npm run replay -- <inbox.jsonl> [--media-dir <dir>] [--limit <n>] [--require-mention] [--bot-qq <qq>] [--admin <qq>] [--out report.json]`:读录制文件喂**真实入站管线**(强制 dry-run:不启 WebSocket、出站写全部拦截计数),输出每条消息的 traceId+决策链汇总;不依赖宿主运行。回放门控取自命令行参数(默认全放行),需复现线上门控请传相同策略 |
| 事件注入 | `injectEnabled`(**false**)+ `injectDryRun`(**true**)+ `injectIntervalMs`(2000,最小 500) | **仅调试用**:往 `mediaDir/qq-inject.jsonl` 追加一行 OneBot 消息事件 JSON(可直接复制 `qq-inbox.jsonl` 的行),桥按间隔轮询喂进真实管线;dry-run 下该回合全部出站写(含异步模型回复)被拦截计数、原文进 trace(`stage=inject`),绝不真发 QQ。启动时已存在的历史行跳过并记一条说明 |
| 体检+诊断包 | 无需开关 | QQ 里发 `/healthcheck`(仅管理员):连接态/重试与自愈/去重与写闸门计数/trace·inbox·audit 文件大小/最近 5 条 ok:false 事件;`/healthcheck export` 打包 trace+inbox+actions log+脱敏配置快照为 `mediaDir/qq-diagnostics-<ts>.zip`(token/accessToken 强制脱敏,含最终字节扫描 fail-closed) |
## 设置页
dsh Web GUI 的设置页提供本插件的可视化配置(host-plane Remote `onebotSettings`,写入 profile 层
`cordis.patch.yml` 中 `dsh-onebot` 条目的 config 覆盖行——与手改 patch、宿主原生设置页是同一个持久层)。
### 三组 19 键
| 组 | 键 |
|---|---|
| connection(连接) | `mode`、`host`、`port`、`url`、`accessToken`、`botQQ` |
| permissions(权限) | `requireMention`、`adminUsers`、`dmPolicy`、`groupPolicy`、`allowAllUsers`、`allowFrom`、`groupAllowFrom` |
| behavior(行为) | `interimMessages`、`interimRecall`、`interimRecallMs`、`sendErrorNotice`、`unknownCommand`、`rateLimitPerMinute` |
`accessToken` 在设置页做密码型输入,快照返回侧脱敏(不回显明文);写入侧仍在 patch 文件明文落盘。
### 与 patch config 的优先级
**schema 默认值 ← bundle/底层 patch(继承层) ← profile 覆盖行(设置页写这里)**;同一层内后写的 patch
条目胜出。设置页键与手改 patch 是同一层的同一行,不存在两套优先级。三组 19 键之外的键
(media/STT/性能/`agentPreset` 等低频键)不设 UI,仍在同一行 config 手改 patch。
### 生效方式
每次保存(设置页或手改同一覆盖行)= 插件 fiber 秒级原地重启生效,宿主进程与 Web GUI 不重启;
代价:QQ 桥一次秒级重连、在途回合中断、频控窗口与中间消息缓冲清零(会话映射/历史不受影响)。
与当前覆盖行全等的保存是 no-op,不触发重启;写入导致插件激活失败时 patch 文件自动回滚、旧配置继续运行。
## 会话工作区(workspace)选择
每个 QQ 会话创建时按以下优先级确定工作目录(写入 session meta,**创建时冻结**,不随配置变化):
1. 该会话的 `/workspace` 覆盖(per-chat 记录,跨 `/new` 保留)
2. 配置 `workspacePath`
3. 宿主进程 cwd(`process.cwd()`)
`/workspace <目录>` 切换(realpath + 目录校验):记录覆盖后会 retire 当前 agent,
下一条消息以新目录重建会话,旧会话保留在磁盘。`/workspace` 无参数输出编号列表
(当前目录标「← 当前」),回复 `/workspace <序号>` 即可切换;`/workspace list` 列出全部 workspace 记录。
`/workspace` 的 per-chat 覆盖已持久化进映射文件(chat-sessions.json,加法格式、兼容旧文件):
重启、会话恢复失败均不丢失,对 `/new` 同样保持——只要该 chat 用的是非默认目录,
重启后 `/workspace` 与新会话都会继续使用原目录。
会话自动挂载到 GUI 工作区:仅当会话 cwd 等于配置的 `workspacePath`(未配置时为宿主 cwd)
才自动创建 workspace;沿用旧 cwd 的遗留会话只在已有 workspace 拥有该路径时挂载,不会自动新建。
## 斜杠命令速查(全部仅管理员)
| 命令 | 作用 |
|---|---|
| `/new` | 开启新会话(清空上下文,旧会话保留在磁盘) |
| `/stop` | 停止当前生成 |
| `/model [--default] <provider> <model>` | 查看或切换当前会话模型;`--default` 修改部署默认。无参输出两级序号列表:回复序号选 provider → 再回复序号选模型并切换当前会话 |
| `/workspace [路径\|序号\|list]` | 查看或切换工作区;无参输出编号列表(标「← 当前」),回复 `/workspace <序号>` 即切换;`/workspace list` 列出全部记录 |
| `/preset [id\|序号]` | 查看当前/可用预设(无参编号列表,回复序号即选),或切换 preset(重建会话,新会话 header 记录) |
| `/session [序号]` | 查看本会话可切回的历史会话(无参编号列表,每项含内容预览、建立时间与退休时间,id 截短显示;日志不可读的项显示「(内容不可读)」且不影响其余项),回复序号切回:当前会话退休进列表、目标会话恢复其历史上下文(含 per-chat 设置),支持来回切换;列表跨重启保留(switchable-sessions.json,每 chat 上限 20 条) |
| `/status` | 会话全景:chat/session/preset/model/cwd/出站模式/agent/可切回数 状态 |
| `/retry` | 重跑上一条用户消息(上一轮出错后重试) |
| `/id` | 只看 chat/session/cwd(排查用) |
| `/ver` | 插件版本 + git commit |
| `/ocr` | 识别本会话最近一张入站图片(NapCat ocr_image) |
| `/mode [interim\|instant]` | 切换本会话出站模式(per-chat 覆盖,跨重启持久化) |
| `/plan [off\|内容]` | 宿主计划模式(`/plan` 进入;`/plan off` 直接退出,无 Web 审批卡;`/plan <内容>` 进入并处理该内容) |
| `/permission [w\|f\|预设名\|序号]` | 切换宿主权限 preset(沙箱模式+审批策略,切换立即生效):`w`/`ws`/`write`/`工作区`→workspace-write(工作区内可写+需审批)、`f`/`full`/`danger`/`全权`→danger-full-access(全盘读写+免审批);完整预设名照原样转发(未知名由宿主报错并回显可用列表);无参渲染中文权限菜单(当前权限+可用预设编号列表+用法行,未知预设名标「部署自定义」;宿主回文格式变化时回退转发原文+快捷提示),纯数字序号按 available 顺序(现场解析,不落快照) |
| `/goal [目标\|clear]` | 记录/更新本会话目标(每轮自动附带提醒,跨重启持久化) |
| `/help` | 分组命令卡片(▍会话/输出/查询/操作/其他),每条带参数用法;报错提示均附用法或下一步动作 |
`/preset` 切换为进程内 per-chat 覆盖(跨 `/new` 保留):下一条消息重建会话并以新 preset
写入 header,重启后 resume 按记录恢复;`/mode`、`/goal` 的 per-chat 状态已持久化进映射文件(v0.4.0 起,跨重启
保留),`/workspace` 的覆盖同样持久化;`/plan` 仍为进程内覆盖,重启回退到默认。
`/session` 的可切回列表按 chat 持久化在媒体目录的 switchable-sessions.json(读写纪律与 retired-sessions.json
一致:文件缺失=全新、损坏保留内存、原子写;每 chat 上限 20 条、去重、最新在前)。列表每项渲染一条内容预览
(该会话事件日志第一条真实用户输入:kind 'user' 或本插件署名的 QQ 消息优先,缺失时回退任何 user/message;
提取全部 text 块拼接、剥去 QQ 入站的 <user_message> 包裹与拼接在边界外的可信前缀([受限用户:仅问答] / [HH:MM 昵称(QQ)],包裹不完整或空正文则原样回退)后换行折叠、按码点截断 ≤40 字,emoji 不拆半;找不到用户输入显示「(无对话内容)」)、
建立时间(会话 header 的 createdAt,取不到则省略)、退休时间与截短 id(前 8+…+后 8 字符,保留两侧便于辨认)。
预览经 sessionPersistence 的 read handle 冷读日志前 24 个事件取得(不开写所有权,读完即 close);单会话日志
缺失/损坏/读取失败只降级该项为「(内容不可读)」,其余项与切回功能不受影响。切换成功后目标会话解除退休
标记,重启 resume 与空闲淘汰后的再激活都走常规路径找回它;切回目标 resume 失败时该 id 立即硬退休并移出列表,
chat 回退到下一条消息新建会话,不会卡死。损坏(碰撞/恢复失败)退休的会话绝不进列表、不可切回。
序号选择基于命令输出列表的快照,5 分钟内有效(过期提示重新查看);纯数字参数仅在有对应有效列表时
按序号解释,否则维持原参数语义。例外:`/permission` 的纯数字序号不落快照,每次现场取宿主 available 列表解释(1/2),越界或不可解析时回退用法提示。未知斜杠命令默认拦截并提示相近命令,配置 `unknownCommand: passthrough`
可改为透传给模型。
## 安全编辑(code_safe_edit)
受守卫的宿主文件编辑由**独立插件 dsh-safe-edit** 提供(`~/dsh-plugins/dsh-safe-edit/`,2026-08-18 从本插件拆出,对所有通道全局注册)。三个工具:
思路借鉴 [irmia_devkit_open](https://github.com/irmia2026/irmia_devkit_open) 的 safe_edit(AGPL-3.0,独立 TypeScript 清洁实现)。工具与边界见 dsh-safe-edit 仓库/说明:
- `code_safe_edit`:read → 路径白名单 → **自动备份** → 匹配(精确 → 剥行号前缀 → 空白对齐/Aider 式)→ 替换 → 语法检查(js/cjs/mjs 走 `node --check`)→ **失败自动回滚**
- `code_safe_rollback` / `code_list_backups`
- 边界随会话 sandbox 策略:`danger-full-access` 无限制、`workspace-write` 限会话工作区、`read-only` 拒绝;无策略服务回落 `safeEditRoot`(默认 `/Users/mario/workspace`)
- 模型可优先使用此工具(`code-safe-edit` skill 全渠道引导;QQ 平台提示词引导内置 read/edit 惯例,不绑定具体工具名)
## dm / group 访问策略(初始化必选)
私聊(`dmPolicy`)与群聊(`groupPolicy`)各自三选一,选项含义如下:
| 选项 | dmPolicy(私聊) | groupPolicy(群聊) |
|---|---|---|
| `open` | **仅管理员**可私聊(adminUsers/`ONEBOT_ALLOWED_USERS`;设 `allowAllUsers: true` 则所有人可) | **所有群**可聊(群内消息受 `requireMention` 控制:需 @ 或回复机器人的消息才触发;群成员带 [受限用户:仅问答] 软限制) |
| `allowlist` | 仅 **`allowFrom`** 白名单 QQ 可私聊(不要求是管理员) | 仅 **`groupAllowFrom`** 白名单群可聊 |
| `disabled` | 私聊全部拒绝 | 群聊全部拒绝 |
**初始化建议**:
- 只想自己用 → `dmPolicy: open` + 配好 `adminUsers`(私聊只有你能发);
- 想开放给几个熟人 → `dmPolicy: allowlist` + `allowFrom: ['QQ1','QQ2']`;
- 群聊专用机器人 → `groupPolicy: open`(配合默认 `requireMention: true`,群成员需 @ 才触发);
- 只允许特定群 → `groupPolicy: allowlist` + `groupAllowFrom`。
## t2i 字体依赖
文字图卡片需要三类字体(CJK / 等宽 / 彩色 emoji),插件启动时从系统与固定路径自动注册,
缺失时对应字符渲染为豆腐块。
- **macOS**:零安装——自动使用系统自带 Hiragino Sans GB / Songti SC、Menlo、Apple Color Emoji。
- **Linux**(Debian/Ubuntu,一条命令补齐):
```sh
sudo apt install fonts-noto-cjk fonts-dejavu-core fonts-noto-color-emoji
```
| 依赖 | 提供文件(插件自动注册路径) | 用途 |
|---|---|---|
| `fonts-noto-cjk` | `/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc` | 中文正文/标题(ttc 自动提取 SC 面,回退 JP/Mono 面) |
| `fonts-dejavu-core` | `/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf` | 代码块/行内 code 等宽 |
| `fonts-noto-color-emoji` | `/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf` | 彩色 emoji |
| 可选 `fonts-wqy-zenhei` / `fonts-wqy-microhei` | `/usr/share/fonts/truetype/wqy/*.ttc` | CJK 备选(Noto 缺失时) |
| 可选 `fonts-unifont` | `/usr/share/fonts/opentype/unifont/*.otf` | 最后兜底 |
- **自定义**:`fontFiles` 指定额外字体文件(重启生效);`fontFamilies` 指定优先使用的家族名。
渲染器带墨水自检——缺字体的家族会被自动剔除并回退,不会静默出豆腐块卡片。
## 权限与数据
- **网络**:与 OneBot 11 网关建立 WebSocket 连接(reverse 监听或 forward 拨出);入站图片/文件从 QQ CDN 下载。
- **文件**:入站媒体与会话映射写入 `<dsh-home>/media/onebot/`(`mediaDir`,6 小时过期清理);会话数据由 dsh 宿主持久化。
- **系统调用**:语音转写调用本机 ffmpeg 与 whisper CLI(可 `sttEnabled: false` 关闭)。
- **敏感信息**:`accessToken` 与管理员白名单存于 dsh 配置,不写入日志;出站内容经敏感信息审计。
- **不收集**:无遥测,除用户配置的 OneBot 网关与图片 CDN 外不调用任何第三方服务。
## 给模型的平台说明(自动注入)
- QQ 不渲染 Markdown → 输出纯文本(编号/短横线列表、行内反引号)。
- 发图/文件/语音/视频用 `qq_send_*` 工具;合并转发用 `qq_send_forward`。
- 用户发来的图片/语音/视频在文本中标注为 `[图片]`/`[语音]`/`[视频]` 占位(路径不进入文本);无可用看图工具时如实告知用户。
- 群聊消息带 `[HH:MM 昵称(QQ)]` 前缀;受限用户消息带 `[受限用户:仅问答]` 前缀(仅回答,禁止文件/终端/配置操作)。
- 本通道为 QQ,宿主无 Web 交互卡:禁止调用 `ask_user_question` / `exit_plan_mode`(确认卡仅 Web 可用,会阻塞对话),提问/确认走纯文本;宿主计划模式下输出纯文本计划并提示「/plan off 退出」。
- 斜杠命令由插件拦截(17 个,仅管理员,`/help` 查看);未知斜杠命令默认拦截并提示相近命令,配置 `unknownCommand: passthrough` 时透传给模型;非 `/纯单词` 开头的文本(如路径)仍正常交给模型。
- 修改宿主文件走内置 read/edit(行级 hash 锚点、dsh-better-edit 自动 undo),勿用 write 整文件覆盖(清空 undo 历史)。
## 卸载
1. 从 `~/.dsh/profiles/<profile>/cordis.patch.yml` 删除 dsh-onebot 的 insert 条目;
2. 重启 dsh,日志不再出现 `[dsh-onebot] mounted` 即卸载完成;
3. 可选:删除插件目录与 `<dsh-home>/media/onebot/` 残留媒体。
## 开发
```sh
./scripts/build.sh # 编译 src/ → lib/
./node_modules/.bin/vitest run # 306 个测试:单元 + 真实 WS 对端 + 全管线
```
要点(来自移植源 DEVLOG 的教训):
- **CQ 反转义**:NapCat 会把 URL 里的 `&` 转成 `&`,下载前必须反转义(CDN 403 的根因)。
- **@ 检测 fail-closed**:不知道机器人 QQ 时,群消息一律视为未 @,不自动回复。
- **断线即失败 pending**:WS 断开时立即 reject 所有未完成 action,避免 10-30s 干等与泄漏。
- **重连去重**:并发断线只允许一个重连任务,防止双 WS 连接。
- **int(target) 兜底**:chat_id 解析进 try/catch,坏目标不能炸掉宿主。
- **临时媒体 6h 过期清理**:只写不删会无限堆积。
- **t2i 按码点迭代**:JS 字符串索引会拆开 emoji 代理对(高代理位被分类成 CJK → 渲染成黑色字形),绘制/测量必须用 `Array.from`/for...of。
- **t2i 度量=绘制**:换行/列宽统一走 `segWidth`(胶囊/粗体/斜体附加宽),像素级右缘验证 ≤790(非白判定 `not(r>245&&g>245&&b>245)`)。
## 故障排查
| 症状 | 原因与处理 |
|---|---|
| 群聊不响应 | `requireMention: true` 时需 @ 或回复机器人的消息才触发;@ 检测 fail-closed——确认 botQQ 已从 meta 事件学习,或显式配置 |
| 图片下载 403 | NapCat 会把 URL 中的 `&` 转成 `&`(解析已自动反转义);仍失败可查日志中 media 下载行 |
| 文件接收失败 | 非本机部署 NapCat 时需开启「文件转 URL」开关,否则 get_file 返回容器内路径不可达;确认 dsh 与 NapCat 网络互通 |
| 文字图中文豆腐块 | Linux 未装 CJK 字体:`apt install fonts-noto-cjk`,并用 `fontFiles` 指定 SC 字体文件 |
| 崩溃循环 / 工具注册冲突 | 同一插件文件被 insert 两次(双实例)——检查 patch 无重复条目 |
| 语音一直只有 [语音] 占位、没有转写补递 | ffmpeg 或 whisper 不可用,或转写超时:安装后重启、调大 `sttTimeoutMs`,或 `sttEnabled: false` 关闭 |
| 插件挂载失败 / peer 依赖解析失败 | 部署副本缺完整 node_modules 链接集:只复制 `lib/` 不够,运行时 peer 依赖 `@deepseek-ai/*` 需经 `scripts/link-host.sh` 链接到宿主安装;插件入口会 fail-loud 显式报「peer 依赖解析失败」,按报错补齐链接集后重启 |
| `build.sh` 报 `cannot locate the dsh install` | 宿主无 `dsh` 在 PATH(如 fnOS 应用形态)且未找到 npx store:设 `DSH_ROOT=<宿主 node_modules 路径>` 后重跑 `./scripts/build.sh`(该目录下须有 `@deepseek-ai/`,设错路径会显式报错而非静默回退) |
| 改了设置页 / patch 但不生效 | 按顺序排查:① 确认改的是**被监视的文件**——profile 层 `<DSH_HOME>/profiles/web/cordis.patch.yml` 或 home 层 `<DSH_HOME>/cordis.patch.yml`(仓库 patch 模板、部署副本、bundle 自带 patch 均不被监视,需同步到上述文件);② 内容须真的变化(touch 同内容文件是 no-op);③ 看宿主日志有无激活失败——insert 新增条目激活失败(如 peer 缺失)会显式报错、条目失活,等待重启重试;④ 设置页的持久层是 profile 层 `dsh-onebot` 覆盖行,改到别处(如已被宿主废弃改名的 settings.yaml)不会生效 |
| 日志在哪 | dsh 宿主日志;插件历史根因与修复见 [DEVLOG.md](DEVLOG.md) |
## 开发记录
完整时间线/根因/修复见 [DEVLOG.md](DEVLOG.md)(移植自 Hermes onebot 插件的 DEVLOG 惯例)。
## License
BSD-3-Clause
Install
dsh plugin --profile web add github:mario841859784/dsh-onebot
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-onebot-qq from the hub
- This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.