# QQ 官方机器人 > 能力边界、配置步骤、用起来什么样,以及菜单里的「屏幕」与「会话」。 **为什么用官方平台**:NapCat / Lagrange 那类需要**真实 QQ 账号登录**(扫码/小号), 违反协议、有封号风险。官方机器人是平台分配的独立身份,只要 AppID + ClientSecret, 你用 QQ 把它加为好友即可。 **为什么不需要 frp / 公网 IP / 回调地址**:收事件走 **WebSocket 长连接**(纯出站), 注入会话走 **本机 `127.0.0.1:19387/api`**。两端都不需要被外部访问。 ``` ┌──────────── 你的电脑(全部跑在这)────────────┐ │ DSH 进程 │ │ └ dsh-remote-qqbot 插件 │ │ ├─ QQ 机器人(WebSocket 出站长连接) │ │ ├─ 任务完成/提问/出错 → 主动私聊你 │ │ └─ 你的回复 → session/prompt 注入 │ └───────────────────────────────────────────────┘ ↕ 纯出站(无公网、无 frp、无服务器) QQ 服务器 ↕ 你手机上的 QQ ``` ## 能力边界(官方文档 2026-09 版) | 类型 | 条件 | 限额 | |---|---|---| | **主动消息**(不要 `msg_id`) | **无任何条件** | 个人认证 20 条/分钟·每关系,**1000 条/用户/天** | | 被动回复(带 `msg_id`) | 收到消息后 | 单聊 **60 分钟**内,每条最多回 **4 次** | 三个容易踩的前提(都有明确错误码): - `40054004 无好友关系` → 先用你的 QQ 把机器人**加为好友** - `40054013 用户拒收消息` → QQ 客户端里被关掉了「允许主动发送」 - `40034105 主动消息无权限` → 开放平台里机器人权限没开单聊主动消息 ## 配置步骤 1. 到 [q.qq.com](https://q.qq.com) 建机器人,拿 **AppID + ClientSecret** 2. 用你的 QQ **把机器人加为好友**,然后给它随便发一句(这一步不是可选的: 官方不提供"按 QQ 号查 openid"的接口,**open_id 只能从你发来的消息里被动学到**) 3. 写配置: ```yaml dsh-remote-qqbot: qqEnabled: true qqAppId: '你的 AppID' qqClientSecret: '你的 ClientSecret' qqCwd: 'D:\cyanproject\agenttool' # 专属会话的工作目录 ``` 4. 重启 DSH。启动日志**只会出现两行状态**(其余都挪进了设置面板,见下): ``` [remote-qqbot] QQ 提醒:已开(AppID 102xxxx…,专属会话 cwd=D:\cyanproject\agenttool) [remote-qqbot] 协作:已开(范围 workspace,写冲突 warn) [QQ] 已启动(AppID 102xxx…) [QQ] QQ 机器人已就绪:xxx机器人(session=…) ``` ⚠️ **出故障仍然会打日志**(`⚠️ 读不到 ~/.dsh/settings.yaml`、`qqAppId 没配齐`、 提问中继没接上…)—— 那些不是"日常状态",藏着只会让人白排查一轮。 想知道"中枢 / 云端 / agentmd / 界面改过的配置"落在哪、值是什么, 去 **设置 → QQ 远程提醒与跨会话记忆 → 顶部「运行状态」**(只读)。 插件还在 `lib/index.js` 导出了 `lastStatusLines()`,返回的就是这份快照,方便脚本排查。 若出现 `还不知道你的 open_id`,说明第 2 步还没做。 ## 用起来是什么样 **出站**(机器人 → 你): - **任务完成 / 出错 / 需要提问** → 机器人主动私聊你; - 完成通知里带你那句"这轮做了什么"的摘要 + 会话名;提问通知里带选项,引用它回个数字就行。 **入站**(你 → 机器人):**看有没有引用消息**,这是唯一的分流依据。 | 你的操作 | 结果 | |---|---| | **引用**机器人发的「任务完成」通知再回复 | 回到**那条通知所属的会话**继续说话 —— **默认排队**(`queue`),也就是它在你 DSH 会话里就是一条**正常的用户消息**,跟你自己打字完全一样 | | **引用**机器人发的「提问」通知再回复 | 当作**那个提问的答案**(多个提问并存时也精确对应,不会答错) | | **不引用**,直接发一句话 | 进**独立的「闲聊会话」**——只是和机器人聊天,**不会**污染任何工作会话 | | **引用**一条认不出来的消息 | 若**最近 5 分钟**内刚推过通知,就按那一条兜底(宁可送回最近的会话,也不让你的提问凭空消失);否则明确告诉你认不出来 | | 有提问在等时**不引用**直接回 | 仍按"回答"处理(否则 agent 会永远卡在那条提问上) | | 引用一条**已经结束**的提问再回 | 明确提示提问已结束,**不会**把「1」当成消息注入工作会话 | > **「不引用」那条路是"只读 + 直接回话"的**(`qqChatReadOnly` / `qqChatReply`,默认都开): > 投递前插件会把闲聊会话切到 `read-only` 权限 —— 它能读文件、能跑查看类命令, > 但**任何写入 / 修改 / 删除都会被 DSH 拒绝**;跑完之后,回答**正文直接发在 QQ 聊天框里** > (纯文本,不折叠成卡片,也**不**上传「完整回答」笔记)。所以闲聊不会在服务器上留下任何文件。 > 超长回答按 `qqChatAnswerChars`(默认 1500 字)截断。 > ⚠️ 已知边界:`read-only` 保护的是**本机文件系统**,管不住 `shell` 里去动**远程主机** > (例如 `ssh 服务器 rm ...`)—— 那要用远程那侧的权限来兜。 > **为什么默认是「排队」而不是「插话」**(依据 DSH 源码,不是口味问题): > `client/runtime/src/client/sessions/session.ts` 写明 *"queue appends after the current > turn; steer **interrupts** it"*,而 `client/ui-conversation/src/client/conversation-nodes/ > message.ts` 把两者落成**不同的节点类型** —— > `queue` → `user`(正常用户消息气泡)、`steer` → `steering`(「插话」节点,样式完全不同)。 > 之前会话一在跑就用 steer,结果**从 QQ 发来的提问在 DSH 里根本不像自己打的字**。 > 想恢复即时打断:`qqPromptMode: steer`。 > 机器人处理你的提问后,只回一句 `✅ 收到,我这就开始(<会话名>)`, > **不再复述你发的那句话** —— QQ 聊天记录里上一条就是你自己的原话,再抄一遍只是噪音。 > 会话名仍然保留:同时开多个会话时,它告诉你这句话进了哪一个。 **指令**(发在聊天框里、以 `/` 开头的话): | 指令 | 作用 | |---|---| | `/task <内容>` | 强制当新任务送进专属会话(压过上面所有判断) | | `/sessions`(也认 `/会话`、`/list`) | **列出最近在聊的几个会话**(默认 6 个,见 `qqRecentCount`),回个数字就切过去 | | `/use `(也认 `/切 `) | 直接切到第 N 个;`/use 0` = 取消指定。`/sessions 3` 与 `/use 3` 等价 | | `/screen`(也认 `/screenshot`、`/shot`、`/截图`、`/屏幕`) | **抓一张电脑屏幕发给你** —— 见下节 | | `/status` | 看状态:列出**正在跑哪些会话**(标题 · 项目 · 第几轮,按最近活动排序)以及另外还有多少闲着 | | `/update`(也认 `/更新`) | **把插件更新到最新版**(默认装完自动重启 DSH);`/update check` 只查不装 | | `/help` | 打印指令清单 | ### 有新版本时:`/update` 一句话装好 插件定期查版本索引(默认 6 小时一次,启动时也查一次)。只有远端版本号**更高**时才在 QQ 里提醒一次; 回一个 `/update` 就从索引里的压缩包地址装上新版,装完**自动重启 DSH**,起来之后还会再回一条 `✅ 重启完成`。那句话里带着兜底:**过了一分钟还没回来,手动打开一次就行** —— 插件会被这次重启杀掉, 没法当场汇报结果。 索引格式、自己搭通道、失败排查(含 Windows 上继承来的 `ELECTRON_RUN_AS_NODE` 这个坑), 见 [UPDATE.md](UPDATE.md)。 ## 菜单里的「屏幕」:手机收到一张电脑截图 **怎么用**:QQ 聊天窗口底部(机器人菜单)→ 点「屏幕」→ 输入框里出现 `/screen` → **按发送**。 > ⚠️ **要说清楚:这是两次点击,不是一次。** 官方自定义菜单的 `send_message` 类型只是把文本 > **填进输入框**,平台不代你发送(`switch` 类型才会自动发消息,但它只能表达"开关", > 表达不了"截一张图",所以没有采用)。所以"点一下菜单就有图"这个动作,实际是 > **点菜单 → 按发送**;输入框里已经是 `/screen` 了,不用再打字。 > 想省掉那一下也行:直接把 `/screen` 当普通消息发出来,效果完全一样。 **发过来的是什么**: - **整块物理桌面**(`SystemInformation.VirtualScreen`)—— 双屏/多屏合成一张,不用自己拼; - 宽度超过 `qqScreenMaxWidth`(默认 1600)就等比缩小; **JPEG 质量固定用系统默认值**。本机实测:原始 **2880x1800** → 缩到 **1600x1000**, 约 **211–212 KB**(实测 **217,204 字节 / 523 ms**); `scripts/verify-installed.mjs` 每次装完都会真抓一张,被拦就报红; - 脚本与图片都在临时目录里,**成功失败都会删**(`finally`),不在你机器上留图。 > ★ **抓之前必须先认领 DPI 感知(否则只会截到左上角)** > `Graphics.CopyFromScreen` 按**物理像素**搬,但一个 DPI unaware 的进程看到的 > `VirtualScreen` 是**被缩放过的逻辑尺寸**。本机 2880x1800、200% 缩放 ⇒ 不认领时 > VirtualScreen 报 **1440x900** ⇒ 存下来的图正好是**真实桌面的左上四分之一**。 > 所以脚本里 `SetProcessDPIAware()` 必须在读 `VirtualScreen` **之前**调用 —— > **顺序就是修复的全部**,`tests/screen.test.mjs` 用 `indexOf` 比较两条语句的先后把它钉住了。 > 像素级取证:认领前 1440x900、认领后 2880x1800,对同一块区域网格采样 12960 点, > 两图**平均差值 0.00**(仅 1 点超过容差 24)⇒ 小图确实就是大图的左上角,不是另一张画面。 > 抓完脚本会额外打印一行 `RAW 2880x1800`(缩放前的原始尺寸), > `parseRawOutput()` 把它透出成 `rawWidth/rawHeight`,验收脚本再拿它去比对 > `Win32_VideoController` 里**任意一块**真实显示器的分辨率(本机有两块虚拟/物理适配器, > 所以判的是"命中任意一块",不能只看第一条 —— 量具本身也会骗人)。 **引用这张截图再说话** → 机器人会明确告诉你"这条截图不属于任何会话", 不会把它错当成某次通知、把话投进不相干的会话。 **隐私总闸**:截图会把**你屏幕上的一切**发出去(聊天记录、密码、别的窗口都可能在里面), 所以有 `qqScreenEnabled`(默认开)。关掉后 `/screen` 只回一句"被我关掉了",并告诉你去哪儿打开。 **⚠️ 已知坑(2026-10-02 实测,改抓屏脚本前务必先读)**:Windows Defender 的 AMSI 会拦截 **"抓屏 + 枚举/构造 JPEG 编码器参数"**这种组合(判定为屏幕窃取类脚本的经典指纹), 把**整份脚本**毙掉,而且报错位置故意指向第 1 行注释 (`ParserError … ScriptContainedMaliciousContent`),极具误导性 —— 改注释改一万遍也没用。 所以现在的实现**不碰 `GetImageEncoders()` / `EncoderParameters`**,改用 `$bmp.Save($Out, [System.Drawing.Imaging.ImageFormat]::Jpeg)`。 代价是**质量不可调**,于是原来的 `qqScreenQuality` 配置项被**删掉**了 —— 留一个点了没用的开关比没有这个开关更糟。 万一将来又被拦:QQ 里收到的是**中文提示**(不是英文报错),照提示在 「Windows 安全中心 → 病毒和威胁防护 → 排除项」放行,或临时关掉实时保护。 完整二分表格见 [`src/screenshot.js`](../src/screenshot.js) 文件头。 **其他平台**:macOS 走 `screencapture`(写了,但**没在 mac 上跑过**,属于"顺手写上"); Linux **故意不支持**,会明确报错而不是假装成功。 ## 菜单里的「会话」:点一下,挑一个会话说话 **怎么用**:QQ 聊天窗口底部(机器人菜单)→ 点「会话」→ 输入框里出现 `/sessions` → **按发送** (和「屏幕」一样是**两次点击**:菜单只把文本填进输入框,平台不代发)。 机器人列出**最近在聊的几个会话**,逐字长这样: ``` 🗂 最近在聊的 6 个会话(回个数字切过去) 1. 主会话 · agenttool · 正在跑 · 刚刚 2. 插件优化 · agenttool · 闲着 · 12 分钟前 3. 手机端 · mobile · 闲着 · 2 天前 回 0 = 不指定(直接说话就进闲聊会话) 引用我某条通知说话,还是优先回到那条通知的会话 ``` 你**回一个数字**(比如 `2`),机器人答一句 `✅ 以后你说的话都进「插件优化」了。想换回来再发 /sessions。` —— 从此**不引用消息直接发的每一句话都进它**,直到你换(或回 `0` 取消指定)。 **为什么设计成"选一次、记住"而不是"每条消息都问一遍"**: 你要的是「能挑最近的会话聊天」,而挑会话这件事**天生是有状态的** —— 记住一个指针,之后每句话都省掉一次选择;换会话时才需要再说一句。 设计上必须处理好的五件事(都写进了 `tests/session-picker.test.mjs`): 1. **数字不能抢走"答案"**。agent 卡在提问上等你回一个 `1` 时,那个 `1` 是在**回答它**, 不是在说"切到第 1 个会话"。所以优先级是 **引用 > 有提问在等 > 选会话 > 闲聊**; 而且裸数字只在**名单刚发出去的 5 分钟内**才算"选择",之后又变回普通文本。 2. **名单每次现拉**,从不缓存。菜单里的文字是**静态**的(平台那边有客户端缓存、刷新有延迟), 所以菜单只放一个固定入口 `/sessions`,会话名单永远现算 —— 不会让你"点了 2 却切到别的会话"。 3. **当前会话看得见**。成功切换有回执;`/status` 里也多一行 `🎯 你说的话现在进:插件优化(闲着)(想换就发 /sessions)`。 4. **指定的会话不在了,绝不静默换一个**。会明确回 `⚠️ 第 2 个(插件优化)已经不在了 —— 发 /sessions 重新看一次吧。` 万一它是在你说完话、投递前才没的,那句话会进闲聊会话,并**额外补一句说明** (悄悄投进另一个会话,是这个功能最坏的失败方式)。 5. **排序按最近活动倒序**,正在跑的打「正在跑」标记 —— 同时开几个会话时, 一眼能看出机器在忙哪个。 **指定的会话有完整权限**(不是闲聊那种只读):它就是你的工作会话,和你在 DSH 里直接打开它说话完全一样。 > **三个会话是不同的东西**:**专属会话**(`/task` 用,固定一个)、**闲聊会话**(不引用时用,固定一个)、 > 以及**各个真实工作会话**(引用对应通知时用)。互相不干扰,id 都记在 `~/.dsh/qq-bot-state.json`。 > > **引用是怎么实现的**:机器人每发一条消息,插件都把响应里的 `ext_info.ref_idx` 与"这属于哪个会话" > 一起记进状态文件(`sentRefs`,最多留 300 条)。你引用时 QQ 事件会带上 `ref_msg_idx`, > 插件据此反查回原会话 —— 全程纯出站,不需要公网、不需要回调地址、不需要 frp。 > (已实测:主动消息响应确实返回 `ext_info.ref_idx`。) ## 两个实现约束(都有代价,已写在代码注释里) 1. **提问是"包装"而不是"注册 provider"**:`userQuestions` 的 provider 全局唯一 (重复注册抛 `DUPLICATE_PROVIDER`),桌面 UI 已经占住了。所以插件包装 `ask()`。 2. **QQ 优先,桌面只是"兜底"**(0.7.7 起,之前是 `Promise.race`):顺序是 先往 QQ 发;**发失败**(通道没连、没 openId、异常)立刻改在 DSH 界面里提问; **发成功**就等 QQ 的回答,只有当 QQ 那边 `qqAskFallbackSec`(默认 90 秒)**一直没回**, 才补一张 DSH 界面的提问卡片。 为什么要这样:DSH 桌面的提问卡片只能由 DSH 自己收起(api-proxy 的 `claimQuestion()`), 插件侧答完 `ask()` 也**清不掉那张卡** —— 旧版 `Promise.race` 一开头就在桌面挂了一条 pending 记录,于是"在 QQ 里答完了,DSH 那张卡还一直挂着"。现在正常路径**根本不创建** 桌面卡片,自然没有东西会变陈旧。 兜底卡片真出现、你又回到 QQ 里回答时:答案照样算数,回执会多一行提示 「(另外,DSH 窗口里那张提问卡片已经作废了 —— 点它一下选个选项,或者按取消就能收掉)」。 3. **QQ 那条路永不 reject**:失败时返回一个永不兑现的 Promise,让提问自然落到桌面那条。 否则 QQ 一断就会把桌面 UI 的提问一起弄崩。 --- [← 回到 README](../README.md)