给 DeepSeek Harnessdsh用的个人微信桥 ——…
30.7/100
综合分
1
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/10/26 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add wjh-ux/dsh-wechat-media该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:已验证
是什么
dsh 原生插件 · vision
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 7 天前
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码v2.7.0🔵 扫描:含敏感能力尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。
可执行外部命令读写/删除本地文件读取剪贴板或密钥环境变量写入剪贴板(不计风险)发起外部网络请求
查看扫描证据(共 96 处,展示前 86 条)
- 执行外部命令固定命令index.js:74
execFile( - 读取本地文件index.js:252
return JSON.parse(readFileSync(join(PLUGIN_ROOT, "package.json"), "utf8")); - 发起网络请求index.js:1660
const response = await fetch(url, { - 读取本地文件index.js:1800
entries = await readdir(profiles, { withFileTypes: true }); - 读取本地文件index.js:1812
const text = await readFile(file, "utf8"); - 写入本地文件index.js:1842
await writeFile(`${item.file}.before-model-repair-${Date.now()}.bak`, item.text, "utf8"); - 写入本地文件index.js:1843
await writeFile(item.file, insertModelBlock(item.text, selection), "utf8"); - 执行外部命令固定命令index.js:3568
execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-STA", "-Command", serial.portsScript()], { windowsHide: true, timeout: 20000 }, - 写入本地文件index.js:4000
await mkdir(dir, { recursive: true }); - 写入本地文件index.js:4001
await appendFile(join(dir, "interjections.jsonl"), `${JSON.stringify(entry)}\n`, "utf8"); - 读取本地文件index.js:4259
const info = statSync(path); - 读取本地文件index.js:4261
const pem = readFileSync(path, "utf8"); - 读取密钥环境变量index.js:4867
apiKey: pick(saved.apiKey, given.apiKey, process.env.TAVILY_API_KEY), - 发起网络请求index.js:5529
await fetch(`http://127.0.0.1:${Number(port)}/json/version`, { signal: AbortSignal.timeout(700) }); - 读取本地文件index.js:5792
const bytes = await readFile(prepared.path); - 写入本地文件index.js:6150
await writeFile(target, got.bytes); - 写入本地文件index.js:6657
await writeFile(wavPath, decoded.wav); - 执行外部命令来自配置/变量index.js:6959
execFile(shell, args, { timeout: 120_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => { - 发起网络请求index.js:8660
const response = await fetch(url, { - 请求外部主机overpass-api.deindex.js:8769
fetch("https://overpass-api.de/api/interpreter", { - 发起网络请求index.js:8769
fetch("https://overpass-api.de/api/interpreter", { - 发起网络请求index.js:10749
const response = await fetch(url, { - 发起网络请求client.js:121
const response = await fetch(`${BASE}${path}`, { - 写入剪贴板client.js:1147
await navigator.clipboard.writeText(text); - 写入剪贴板client.js:1253
try { await navigator.clipboard.writeText(answer.token); copied = true; } catch { } - 读取本地文件browser.js:161
for (const path of list) if (existsSync(path)) return path; - 写入本地文件browser.js:436
try { mkdirSync(profile, { recursive: true }); } catch (error) { throw new Error(`could not create the browser profile directory ${profile}: ${String( - 发起网络请求ilink.js:333
const res = await fetch(url, { - 发起网络请求ilink.js:691
const res = await fetch(cdnUrl, { - 发起网络请求ilink.js:861
const res = await fetch(url, { method: 'GET', ...(link.signal ? { signal: link.signal } : {}) }); - 写入本地文件media.js:134
await rename(filePath, target); - 写入本地文件media.js:203
await mkdir(dir, { recursive: true }); - 读取本地文件media.js:210
const info = await stat(renamed); - 删除本地文件media.js:212
await rm(renamed, { force: true }); - 读取本地文件media.js:224
const info = await stat(filePath); - 写入本地文件wecom-media.js:175
await mkdir(destDir, { recursive: true }); - 写入本地文件wecom-media.js:177
await writeFile(filePath, bytes); - 删除本地文件递归voice.js:204
const cleanup = async () => { await rm(dir, { recursive: true, force: true }).catch(() => {}); }; - 读取本地文件voice.js:213
const wavBytes = (await stat(wavPath).catch(() => ({ size: 0 }))).size; - 读取本地文件voice.js:235
const encoded = await encode(await readFile(source), 0); - 写入本地文件voice.js:237
await writeFile(target, encoded.data); - 读取本地文件voice.js:254
const bytes = (await stat(target)).size; - 读取本地文件voice.js:362
return readFile(path); - 读取本地文件desktop.js:483
const size = statSync(info.path).size; - 读取剪贴板clipboard.js:129
export async function readClipboard({ maxChars = CLIPBOARD_MAX_CHARS, exec = execFile, platform = process.platform, timeoutMs = CLIPBOARD_TIMEOUT_MS } - 写入剪贴板clipboard.js:153
export async function writeClipboard({ text = "", paths = [], exec = execFile, platform = process.platform, timeoutMs = CLIPBOARD_TIMEOUT_MS } = {}) { - 读取本地文件ssh.js:27
return existsSync(client) - 读取本地文件ssh.js:83
if (identity && !existsSync(identity)) throw new Error(`主机 ${name} 的 identity 文件不存在:${identity}`); - 写入本地文件keil.js:392
const ready = logPath ? mkdir(dirname(logPath), { recursive: true }).catch(() => {}) : Promise.resolve(); - 读取本地文件keil.js:399
log = readFileSync(logPath, "utf8"); - 读取本地文件office.js:260
for (const name of readdirSync(runtimes)) { - 读取本地文件office.js:276
python: [...new Set(python.filter((p) => p && existsSync(p)))], - 读取本地文件office.js:277
kit: [...new Set(kit.filter((p) => p && existsSync(p)))], - 读取本地文件office.js:291
ready: Boolean(python && existsSync(python)), - 读取本地文件office.js:292
canConvert: Boolean(python && kit && existsSync(kit)), - 读取本地文件wecom.js:187
const bytes = await readFile(filePath); - 读取本地文件lark.js:49
if (existsSync(script)) return { node: execPath || "node", script }; - 发起网络请求wecom-bot.js:402
return this.request(wire.cmd, wire.body, { timeoutMs, reqId: wire.headers?.req_id }); - 发起网络请求wecom-bot.js:470
const init = await this.request( - 发起网络请求wecom-bot.js:483
await this.request( - 发起网络请求wecom-bot.js:514
const finish = await this.request(CMDS.UPLOAD_FINISH, { upload_id: uploadId }, { timeoutMs }); - 发起网络请求wecom-bot.js:527
return this.request(CMDS.RESPONSE, mediaBody(mediaType, mediaId, videoOptions), { reqId, timeoutMs }); - 发起网络请求wecom-bot.js:537
return this.request(CMDS.SEND_MSG, { chatid: chat, ...mediaBody(mediaType, mediaId, videoOptions) }, { timeoutMs }); - 发起网络请求qq-bot.js:52
constructor({ fetchImpl = (...args) => fetch(...args), now = () => Date.now() } = {}) { - 发起网络请求qq-bot.js:68
const response = await this.fetch(QQ_TOKEN_URL, { - 发起网络请求qq-bot.js:157
fetchImpl = (...args) => fetch(...args), - 发起网络请求qq-bot.js:193
fetchImpl = (...args) => fetch(...args), - 发起网络请求qq-bot.js:220
const response = await this.fetch(`${qqApiBase({ sandbox: this.sandbox })}/gateway`, { - 执行外部命令来自配置/变量state.js:47
execFile(file, args, { timeout: options?.timeoutMs ?? 15000, windowsHide: true, maxBuffer: 1 << 20 }, (error, stdout) => { - 读取本地文件state.js:85
return JSON.parse(await readFile(path, "utf8")); - 写入本地文件state.js:94
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8"); - 写入本地文件state.js:97
await chmod(temporary, 0o600); - 写入本地文件state.js:102
await rename(temporary, path); - 写入本地文件state.js:118
await mkdir(this.accountsDir, { recursive: true }); - 删除本地文件state.js:176
await unlink(path); - 发起网络请求extension/background.js:540
response = await fetch(url, { headers: headers(token), cache: "no-store" }); - 发起网络请求extension/background.js:557
await fetch(`${endpoint.replace(/ $/, "")}/result`, { - 发起网络请求extension/options.js:20
const response = await fetch(`${endpoint}/status`, { - 读取本地文件vscode-extension/extension.js:49
const saved = JSON.parse(fs.readFileSync(path.join(stateDir(), "gui-port.json"), "utf8")); - 发起网络请求vscode-extension/extension.js:68
const req = http.request( - 发起网络请求vscode-extension/extension.js:175
const req = http.request( - 读取本地文件vscode-extension/extension.js:289
const stat = await vscode.workspace.fs.stat(uri); - 读取本地文件vscode-extension/extension.js:291
const text = Buffer.from(await vscode.workspace.fs.readFile(uri)).toString("utf8"); - 读取本地文件vscode-extension/extension.js:350
const stat = await vscode.workspace.fs.stat(uri); - 读取本地文件vscode-extension/extension.js:352
content = Buffer.from(await vscode.workspace.fs.readFile(uri)).toString("utf8"); - 读取本地文件vscode-extension/extension.js:462
await vscode.workspace.fs.stat(main);
扫描于 2026/11/1(0 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库
涉及外部主机:overpass-api.de
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
个人微信和企业微信接入 DeepSeek Harness:扫码配对,每个聊天对应一个 DSH 会话,双向传输文字、图片、视频、文档和压缩包,还带一个双向企业微信智能机器人(长连接),同样能接收图片、文件和视频
README
由 DeepSeek 最新模型翻译生成dsh-wechat-media
给 DeepSeek Harness(dsh)用的个人微信桥 —— 扫一次二维码,之后就能在微信里和智能体对话:每个联系人或群各自一个 DSH 会话,文字、图片、视频、文档、压缩包双向互通。多个微信号可以并排配对。
给 DeepSeek Harness 的个人微信桥:扫一次码,之后就能在微信里和智能体对话 —— 每个联系人/群各自一个独立会话,文字、图片、视频、文档、压缩包双向互通,支持多个微信号同时在线(小号 + 大号)。
当前版本:v1.4.4 · 44 个测试套件 / 692 个用例 · 下面每一项能力都至少对着真实服务或真实硬件跑过一次(见 实际验证过的内容)。
它能做什么 / What it gives you
| 能力 | 说明 |
| 扫码配对 | 插件面板里有卡片。登录 token 存在 $DSH_HOME 下,所以重启 dsh 不用重新扫码;重新配对点一下就行。 |
| 每个聊天一个会话 | 每个微信联系人(和群)都有独立的 DSH 会话和独立上下文,自动创建或恢复。企业微信、飞书的聊天也一样。 |
| 文字双向 | 你的消息变成一轮对话,回答分块发回微信。 |
| 媒体双向 | 图片、视频、文档、压缩包。
展开完整文档(README 剩余 1 节)
dsh-wechat-media
给 DeepSeek Harness(dsh)用的个人微信桥 —— 扫一次二维码,之后就能在微信里和智能体对话:每个联系人或群各自一个 DSH 会话,文字、图片、视频、文档、压缩包双向互通。多个微信号可以并排配对。
给 DeepSeek Harness 的个人微信桥:扫一次码,之后就能在微信里和智能体对话 —— 每个联系人/群各自一个独立会话,文字、图片、视频、文档、压缩包双向互通,支持多个微信号同时在线(小号 + 大号)。
当前版本:v1.4.4 · 44 个测试套件 / 692 个用例 · 下面每一项能力都至少对着真实服务或真实硬件跑过一次(见 实际验证过的内容)。
它能做什么 / What it gives you
| 能力 | 说明 |
| 扫码配对 | 插件面板里有卡片。登录 token 存在 $DSH_HOME 下,所以重启 dsh 不用重新扫码;重新配对点一下就行。 |
| 每个聊天一个会话 | 每个微信联系人(和群)都有独立的 DSH 会话和独立上下文,自动创建或恢复。企业微信、飞书的聊天也一样。 |
| 文字双向 | 你的消息变成一轮对话,回答分块发回微信。 |
| 媒体双向 | 图片、视频、文档、压缩包。收到的文件下载并解密到 $DSH_HOME/wechat-inbox/<date>/<chat>/;智能体拿到路径,纯文本模型也能通过 read_image 读图。发出的文件由智能体通过工具上传并发送。 |
| 多账号 | 小号和大号都能配对,各自保留自己的 token、游标和会话映射。 |
| 企业微信 | 群机器人 webhook、自建应用,或智能机器人长连接(不用公网地址就能收消息);这个通道支持流式预览。 |
| 飞书(Lark) | 自定义 webhook 机器人,或带长连接的应用机器人;文档、表格、多维表格通过工具操作。 |
| 编辑器桥 | 一个 VS Code 扩展:在面板里提问,回答流式返回,让 AI 编辑你打开的文件夹(/dsh-vscode/,带可写开关)—— 还能从编辑器里烧录开发板,因为这一轮对话就在那个文件夹里跑,串口工具直接接手。 |
| 自动化工具 | 基于 DevTools 协议的浏览器自动化(独立配置档、你自己的 Chrome,或扩展)、Office 读写转换、桌面控制、剪贴板、SSH、串口烧录(ESP-IDF / esptool / avrdude / STC 走 stcgal)、网页搜索、定时任务,以及 RPA 风格流程执行器。 |
| 抓取 | Scrapling:模拟浏览器的 TLS 指纹和请求头,所以拒绝普通 fetch 的页面(403、Cloudflare 挑战、UA 检查)照样能读 —— CSS 选择器、链接提取、按次代理,还有真实浏览器模式,直接驱动你现有的 Chrome(不用下载 1.25 GB),能过 Cloudflare。 |
| 多智能体 | 打开一个开关,每个会话都能挂载自己的智能体预设 —— 自己的提示词、工具和模型接线 —— 不必所有聊天共用宿主默认值。默认关闭;先 /multiagent on,再按会话执行 /agent <preset>。 |
| 集成 | POST /inbound 让 NAS/CI/监控向会话里说话,出站 webhook 在每轮结束后推送(HMAC 签名),/dashboard 展示会话、失败和 token 消耗。 |
| 持久化 | 账号、接收游标、每个对端的上下文 token、chat→session 路由、允许列表和偏好,全部存放在 $DSH_HOME/dsh-wechat-media/ 下。 |
Requirements / 环境要求
- dsh 0.2.0-rc.2 或更新版本,带标准智能体循环和工具(随包发布的 dsh web / 桌面配置档即可),Node.js >= 22(Node 24 实测通过),Windows / macOS / Linux。
- 一个能扫码的微信账号。 强烈建议用小号:个人微信自动化违反微信服务条款,无论用哪个库都有封号风险。
- 能出站访问 ilinkai.weixin.qq.com 和 novac2c.cdn.weixin.qq.com。
Install / 安装
from GitHub
dsh plugin --profile web add github:wjh-ux/dsh-wechat-media
or from a checkout
dsh plugin --profile web add link:/absolute/path/to/dsh-wechat-media
重启 dsh,然后在 插件 → dsh-wechat-media 点「添加账号(扫码)」。
重启 dsh,然后在 插件 → dsh-wechat-media 点「添加账号(扫码)」。
安装时报 Host key verification failed / Could not read from remote repository。 pnpm 会把 github: 规格解析成 SSH(git+ssh://git@github.com/...),所以机器上 known_hosts 里没有 GitHub 的主机密钥、也没有能读这个仓库的 SSH key 时,就装不上 —— 尽管仓库是公开的。两条路:
1. make git rewrite GitHub SSH URLs to HTTPS (no SSH key needed; the repo is public)
git config --global url."https://github.com/".insteadOf "git@github.com:"
2. or install a release tarball instead of the shorthand
dsh plugin --profile web add https://codeload.github.com/wjh-ux/dsh-wechat-media/tar.gz/refs/tags/v0.67.3
装不上、报 Host key verification failed?因为 pnpm 把 github: 解析成 SSH 地址,而这台机器既没有 GitHub 的主机密钥也没有能读这个仓库的 SSH key —— 仓库其实是公开的。两条路:把 GitHub 的 SSH 地址重写成 HTTPS(上面第 1 条),或者直接装 release 的 tarball(第 2 条,把版本号换成你要的那一版)。
Pairing / 配对
1. 卡片上显示二维码(它自己轮询状态,过期码自动刷新)。
2. 用手机微信扫码,在手机上确认。
3. 微信要是让你填数字,把数字填进卡片提交。
4. 账号出现,带一个状态点:monitoring 表示长轮询正在跑。
5. 添加账号(扫码) 一直都在 —— 扫另一个微信账号,就把它加成新账号,小号和主号可以并行跑。扫已有的账号只是刷新它的登录。每行还有 重新配对 和 解除配对。
6. 误打开了二维码?按 Cancel —— 这次尝试会立刻消失,不用等二维码过期。(二维码有效期五分钟;一次尝试八分钟后放弃,每次尝试最多签发三个二维码。)
二维码编码的是平台下发的落地页 URL —— 扫码即可完成绑定。每个账号都存放在规范化后的 ilink_bot_id 下(b0f5860fdecb@im.bot → b0f5860fdecb-im-bot),保存 token 的文件权限为 0600;账号、游标和会话路由都按账号分别记录,因此多个账号可以同时轮询。
Where inbound files go / 收件目录
默认情况下,收到的媒体文件会解密到 $DSH_HOME/wechat-inbox/<date>/<chat>/。这个目录可以配置,改动无需重启即可生效:
1. 从卡片设置 —— Inbound files are saved in* 下面的输入框提交的是普通 GET 表单(一次页面跳转,不是 fetch),所以即使浏览器里卡片的请求到不了插件,这种方式也能用。
2. 从控制面板 —— /dsh-wechat-media/panel 有同样的输入框,还有一个 restore default 按钮。
3. 用系统文件夹对话框 —— Browse… / 浏览…(卡片和控制面板都有),或者 GET /dsh-wechat-media/pick-folder,会在桌面上打开 Windows 自带的文件夹选择器并保存选择。浏览器无法交出绝对路径(目录输入框只暴露相对名称),所以对话框由宿主(powershell -STA)打开,而不是在页面里打开。
4. 从插件配置 —— 在插件选项里写 inboxDir: "D:\\wechat-files",没有设置运行时偏好时使用它。
优先级顺序是:偏好设置(卡片/面板)→ 配置 → $DSH_HOME/wechat-inbox。只接受绝对路径:相对路径会被拒绝并给出提示,而不会按进程工作目录解析 —— 因为悄悄换到另一个目录,比用文档里写明的默认值更糟。GET /dsh-wechat-media/state 会把生效值报告为 inboxRoot,并附带 inboxRootDefault 和 inboxRootConfig 供对比。
Using it / 使用
直接从微信给机器人发消息就行。智能体在会话里能做这几件事:
- 通过普通回复通道作答(桥接会自动转发它的最终文本);
- 用自己的工具发送额外消息或文件:
| 工具 | 用途 |
| wechat_send_text | 发一条额外文本消息,或发到另一个聊天(to),并抢占该轮次的自动转发。 |
| wechat_send_file | 把本地文件发回微信。图片(.jpg/.png/.webp/.gif)按图片发送,视频(.mp4/.mov)按视频发送,其余(pdf、docx、xlsx、zip、7z 等)按文件附件发送。 |
| wechat_status | 查看哪些账号已配对、存在哪些会话。 |
每条收到的消息都会加上前缀 [微信 <name> <wxid>],这样智能体回复某个具体聊天时能传对 to。群聊 id 形如 12345678@chatroom。
Remote approval / 微信里远程审批
当微信驱动的会话请求批准时——权限预设要求确认某次工具调用——提示词会发到同一个微信聊天,而不只是 GUI:
⚠️ 需要你批准一次操作(远程审批)工具:pwsh 原因:write outside the workspace
回 1 = 批准一次 · 回 2 = 拒绝
回复 1 / y / 同意 / 批准 / /approve 批准这一次调用,回复 2 / n / 拒绝 / /reject 拒绝。回复会被消费掉——不会变成新的对话轮次。如果 approvalTimeoutSec(默认 120 秒)内没有收到回复,或者提示词无法送达,或者对话轮次被中止,请求会交回 GUI,批准请求绝不会被悄悄丢弃。在插件配置里设置 approval: "ignore",批准就只留在 GUI 里。
Contact allowlist / 联系人白名单
每个给机器人发过消息的聊天都会被记住,卡片会在 Contacts & groups / 联系人 / 群 下列出它们,每行有 Allow / Ignore / Clear,还有一个 Only accept allowlisted chats 开关。
- 开关默认关闭——否则刚配对的账号在所有人眼里都像是坏了。
- 打开后,只有标记为 Allow 的聊天能到达智能体;Ignore 和未决定的聊天会被丢弃并记录日志。
- 决定立即生效(无需重启),保存在账号旁边的 peers.json 里。
Friends only / 只处理好友私聊
Only answer friends (1:1 chats)——卡片上的第二个复选框,或者在聊天里发 /friends on。协议本身不带「是否好友」标志,所以好友指的是1:1 聊天:开关打开后,所有群消息都会被丢弃,无论白名单对该群怎么设置。私聊不受影响。
- 默认关闭:升级不能改变机器人回复谁。
- 群消息会被静默忽略(这正是目的),但绝不无声无息:每条都会让 counters.groupsSkipped 加一,/status 会打印 已静默忽略群消息 N 条,卡片上显示同样的数字。
- 这是一个偏好设置(prefs.json),所以复选框和 /friends 永远不会不一致,个人微信和企业微信同样适用。
- 群正在等待回复时打开它,会在该群回复一次,说明即将到来的沉默。关闭它可以从 1:1 聊天或卡片操作——群消息在被读成命令之前就被跳过,这是「静默」的诚实后果。
WeCom (企业微信) / 企业微信
企业微信与个人微信桥接并存,出站走两条路径,入站走第三条:
| 路径 | 你需要什么 | 方向 |
| 群机器人 webhook | 企业微信群的一个 webhook URL(群设置 → 群机器人 → 添加) | 出:文本、markdown、图片 |
| 自建应用 | 应用的 corpid + corpsecret + agentid(应用管理 → 自建) | 出:文本、markdown、图片、文件、语音、视频 |
| 智能机器人,长连接 | 机器人的 BotID + Secret(智能机器人 → API 模式 → 长连接) | 双向:消息进来,回答回到同一聊天 |
在普通页面 http://127.0.0.1:19387/dsh-wechat-media/wecom 配置其中任意一种(卡片上也有链接控制面板也是你发送测试消息的地方。长连接不需要公网地址:插件会向 wss://openws.work.weixin.qq.com 打开 WebSocket,并通过平台发来的帧回复每条消息,因此企业微信聊天就变成了普通的 DSH 会话——和个人微信一样,使用同一套会话机制、白名单、审批和回复中继。审批请求会以主动长连接消息的形式送达企业微信聊天,你在那里用 1(允许一次)或 2(拒绝)回复;其他内容仍留在 GUI 中,和个人微信侧完全一致。智能体还会获得两个额外工具:
| 工具 | 用途 |
| wecom_send_text | 通过配置好的机器人、长连接或应用发送文本或 markdown(to 会覆盖收件人)。 |
| wecom_send_file | 把本地文件作为图片 / 文件 / 语音 / 视频发送。走长连接(或通过自建应用)时任何类型都可以;只有群机器人 webhook 时,图片可以。 |
文件也能反向传输:发给机器人的文件会通过长连接上传为临时企业微信素材(init -> 512 KiB 分块 -> finish -> media_id),然后再发送,因此不需要应用凭证。上传上限为 100 个分块(约 50 MB),每个分块重试两次,这是平台能容忍的程度;失败会报告回来,而不是被吞掉。
设置存放在 $DSH_HOME/dsh-wechat-media/wecom.json,权限为 0600——其中包含 webhook key、corpsecret 和机器人 secret,因此绝不会写进 prefs.json 或 cordis 补丁。GET /dsh-wechat-media/state 会报告是否已设置 secret,但绝不报告其值。
仍然做不到的是回调 API。 它要求企业注册一个公网 HTTPS URL,因此运行在你自己机器上的桥接无法通过这种方式接收;上面的长连接正是让双向企业微信无需公网 URL 成为可能的原因。
媒体也能进来。 发给机器人的图片、文件和视频会被下载、解密,并写入微信侧使用的同一个收件箱($DSH_HOME/wechat-inbox/<date>/<chat>/),然后以路径加 [企业微信图片] …用 read_image 查看 提示的形式交给模型,因此纯文本模型配上视觉插件就能真正查看它们。这套方案是长连接的官方方案(@wecom/aibot-node-sdk):aeskey 是 32 字节 AES-256 密钥的 base64,字节采用 AES-256-CBC,iv 取自密钥的前 16 字节,PKCS#7 填充到 32 字节,文件名来自 Content-Disposition,因为载荷本身不携带文件名。注意这不是个人微信 CDN 方案(AES-128-ECB)。下载失败会在聊天中说明,而不是静默丢弃附件,/state 会统计两种结果(counters.mediaDownloaded / counters.mediaFailed)。
Feishu (飞书 / Lark) / 飞书机器人
飞书机器人有两种,两种都支持,因为它们解决不同的问题。
| | 自定义 webhook 机器人 | 应用机器人 |
| 凭证 | webhook URL(以及签名密钥,如果设置了) | app_id + app_secret |
| 能发送 | 文本和富文本 | 文本、富文本、图片、文件、音频 |
| 能接收 | 否 | 是 —— 走长连接,不需要公网 URL |
| 配置 | feishu.webhookUrl、feishu.secret | feishu.appId、feishu.appSecret |
在面板里配置(不用 setx,不用重启)
Web UI 面板里有 Feishu 区块:App ID、App Secret、自定义 webhook URL 及其签名密钥、平台(feishu / lark)、默认接收 id,以及长连接开关。保存后立即重连 —— 不用 setx,也不用重启 DSH,而这两样以前是唯一的办法。
取值按下面的顺序解析,所以面板没填的字段会沿用配置或环境变量提供的值:
1. 面板保存的值(prefs.feishu),
2. 插件配置里的 config.feishu,
3. 环境变量:FEISHU_APP_ID / FEISHU_APP_SECRET / FEISHU_WEBHOOK_URL / FEISHU_WEBHOOK_SECRET / FEISHU_DOMAIN / FEISHU_TO / FEISHU_LONG_CONNECTION / FEISHU_VERIFICATION_TOKEN / FEISHU_ENCRYPT_KEY。
有两条规则保证这样做是安全的:
- 空字段永远不会被发送 —— 面板的 Save 按钮不会发空值,所以保存别的字段不会抹掉已存的密钥。清空是单独的、明确的动作(Clear 按钮会显式发送空值)。
- 密钥永远不会回传。 /state 和 /prefs 只报告密钥是否已设置,从不报告它的值 —— 一个脱敏辅助函数覆盖了所有携带 prefs 的响应(这条规则之所以存在,是因为测试发现过一处泄漏)。
feishu.domain 取 feishu(中国大陆)或 lark(国际版);直接写 URL 也可以。/state 会报告 savedFields,以及每个字段当前生效的值来自面板、配置还是环境变量。
工具:feishu_send_text(文本或 markdown,to + toType 可选 —— 默认 chat_id,也接受 open_id/user_id/email)和 feishu_send_file(path,kind = file/image/audio/video)。回复总会说明用了哪个通道,并给出 message_id,所以「已发送」不是猜的。
只配 webhook 的方案发不了文件,而且会明确这么说,不会报告一个根本没送达的成功 —— 自定义机器人 API 压根没有文件消息。要发文件请配置 appId/appSecret。
/feishu(/飞书、/lark)报告配置了什么 —— 绝不报告密钥本身,只报告是否已设置 —— /feishu test [id] [type] 发送一条自检消息。
这里的每个端点和算法都读自官方 @larksuiteoapi/node-sdk 1.75.0 源码,不是凭记忆:消息是 POST /open-apis/im/v1/messages?receive_id_type=…,token 是 POST /open-apis/auth/v3/tenant_access_token/internal,媒体是 POST /open-apis/im/v1/images 和 /files,事件的 AES 密钥是 sha256(encryptKey),密文的前 16 字节作为 IV,事件签名是
sha256(timestamp + nonce + encryptKey + body),取十六进制(事件未加密时用 sha1 加验证 token)。自定义 webhook 的签名是这套 API 的坑——HMAC-SHA256(key = timestamp + "\n" + secret, data = ""),base64——并且会拿独立实现公布的测试向量来校验。
QQ bot(QQ 开放平台)/ QQ 机器人
官方 QQ 机器人,走开放平台的 WebSocket 网关——只出不进,所以在你自己的机器上跑插件就能用,不需要公网 URL,和 WeCom、Feishu 的长连接完全一样。
/qq # switch, credentials presence, connection state, last error
/qq on | off # connect / disconnect
凭据填在卡片的 QQ 块里(或 POST /prefs 带 {"qq":{...}}):appId、clientSecret,沙箱机器人再加 sandbox(沙箱还走另一个 API host)。secret 只存不回显。
三个细节决定 QQ 机器人能不能跑起来,这里都处理了:
- 回复必须引用它回答的那条消息。 sendMessage 把入站 id 作为 msg_id 发出去(外加一个 msg_seq),否则平台会当成主动消息——那是另一种权限,通常直接被拒。
- C2C 和群聊是不同的端点:/v2/users/{user_openid}/messages 对 /v2/groups/{group_openid}/messages,openid 来自事件,猜不出来。
- intent 要对:群消息和 C2C 消息挂在 1 << 25 后面;申请的权限超过机器人实际拿到的,identify 就会以 4014 失败。
已对真实机器人验证(appId 1903374412,显示名 PawDesk):网关回了 READY,私聊消息经 /v2/users/{openid}/messages 得到了回答,真实群里的 @ 经 /v2/groups/{group_openid}/messages 也得到了回答。白名单在聊天被放行之前,把第一条私聊和第一条群消息都拦下了——这正是它的用途,而且能在计数器(blocked)里看到,不是悄无声息。
多智能体:每个会话一个智能体 / 多 agent
每个会话本来就有自己的 session 和自己的 agent 对象;这个开关改变的是那个 agent 挂载什么。打开后,聊天可以挂载自己的智能体预设——自己的提示词、工具和模型接线——记在对端记录上。关掉(默认)时每个聊天挂载宿主默认,和以前一模一样。
/multiagent # the switch and this chat's preset
/multiagent on | off # off by default
/agent # the presets the host exposes, and which one this chat uses
/agent <preset id> # set it for this chat
/agent reset # back to the default
/agent model <provider>/<model> 给那一个会话钉住模型,/agent model reset 清除。什么都没设时,聊天用宿主自己的选择——没有内置的默认覆盖,因为那会挪动每个会话都落到没人选过的模型上,账单上冒出一笔意外,而不是变成一个设置项。覆盖项和预设受同一个开关控制,/state 的 conversations[].agentModel 能证明某个对话实际请求的是哪个模型。
/agent model 不带参数时,打印宿主自己的选择,因为这个值已知可用——改模型那一半,保留服务商那一半。切换服务商时,设置那一刻会给出警告,因为新路由需要自己的 API key。
/agent model list 向宿主的 llm 服务查询,打印实际可选项。在这台机器上,它报告两个服务商,各自带同样的两个模型:
deepseek-official deepseek-account · 宿主在用
· deepseek-flash · deepseek-flash ← 宿主默认
· deepseek-v4-pro · deepseek-v4-pro
所以安全的改法是在宿主已在用的服务商里换模型名(deepseek-account/deepseek-v4-pro);另一条服务商路由需要自己的 DEEPSEEK_API_KEY。
在这台机器上,宿主自己的选择是 deepseek-account/deepseek-flash,要复制的就是这个值:服务商那一半才是携带凭据的部分。改成钉住 deepseek-official/deepseek-chat 会失败,报 no API key for provider route "deepseek-official"——不同的服务商路由就是不同的账号,不是不同的模型名。/agent model 打印宿主的值,正是这个原因;切换服务商时,设置那一刻会给出警告。
/agent model list 向宿主自己的 llm 服务查询每个服务商及其模型,并标出正在使用的那个,这样模型名永远不必从 Models 页面复制或靠猜;/agent model list <provider> 可以缩小范围。
命令会明说而不是藏起来的三件事:
- 改预设不是热切换。 它在会话下次创建智能体时生效,回复里会说明这一点。
- 开关关着时,/agent <id> 拒绝装作成功。 它会点名是哪个开关关着,因为存下一个永远不会被读取的值,比直接拒绝更糟。
- 预设列表按防御性方式读取(list 或 all,id 或名称)。如果宿主两者都不暴露,按 id 设置仍然可用,但在智能体建出来之前一直处于未验证状态——命令自己会打印这句话。
卡片上带着对应的复选框:每个会话可挂自己的 agent 预设。
在这台机器上,宿主声明了四个预设——standard、ptc、minimal、cordis——执行 /agent minimal 再执行 /new,等下一条消息创建出智能体后,GET /state 的 conversations[].agentPreset 里就出现了 "minimal"。这正是这个开关的意义:两个对话可以同时运行不同的预设,而且选择在重启后依然保留,因为它存在对端记录上。
Receiving: the long connection / 用长连接接收(不需要公网地址)
应用机器人通过飞书的长连接接收事件——一条由机器人自己打开的 WebSocket——所以不需要公网 URL,不需要隧道,也不需要端口转发。事件路由(把事件 POST 到飞书回调的 URL)是刻意不实现的:它正是需要公网地址的那个选项。
在飞书控制台开启,然后重启 DSH:
| 步骤 | 位置 |
| 启用机器人 | 开发者后台 → 你的应用 → 应用功能 → 机器人 → 启用 |
| 用长连接订阅 | 开发者后台 → 事件与回调 → 订阅方式 → 使用长连接接收事件 |
| 权限 | 至少 im:message(收发消息);再按控制台提示补上接收所需的 scope |
| 发布 | 创建版本并发布 - 不发布版本,改动不会生效 |
配置项:feishu.longConnection(默认开启;feishu.longConnection = false 或 FEISHU_LONG_CONNECTION=false 可关闭)。只有配好 feishu.appId 和 feishu.appSecret 才会建立连接——只配 webhook 的部署没有可连接的东西,日志里也会说明这一点。
收到消息后怎么处理:文本和富文本消息会变成模型的提示词,前缀与其他渠道一致;回答以回复的形式挂在问题下面(message_id),回复被拒就退回普通发送,过长的回答会分块。有两件事是刻意拒绝的:机器人忽略自己发的消息(sender_type: "app"),因为回答它们会让它自己陷入循环;重复投递的事件只回答一次。
协议细节是从官方 SDK 里读出来的,不是凭记忆——握手是 POST {domain}/callback/ws/endpoint(请求 {AppID, AppSecret},响应 {code, data:{URL, ClientConfig}},键名首字母大写,间隔单位是秒),帧是 pbbp2 命名空间下的 protobuf(Frame 1 SeqID / 2 LogID / 3 service / 4 method / 5 headers / 6 payloadEncoding / 7 payloadType / 8 payload / 9 LogIDNew,Header 1 key / 2 value),确认事件靠把收到的帧原样发回,带上 biz_rt 头和 {"code":200};分片事件按 message_id/sum/seq/trace_id 重组,元数据不合法就直接拒绝,而不是悄悄把 payload 弄坏。
GET /state 里有一个 feishu 块(配置、连接状态、lastError、lastEventAt,以及计数器 feishuEvents / feishuAnswered / feishuIgnored / feishuErrors),/feishu 在聊天里显示同样的内容,包括长连接没起来时为什么没起来。
Scheduled tasks / 定时任务
一个任务就是一句话加一个聊天:到点自己触发,把结果发到那个聊天。三个动作、四种触发器,外加一个控制危险动作的开关:
/task # list, with next run time and last result
/task rm t3k9m2 # delete
/task off t3k9m2 # pause (/task on ... resumes)
/task command on # allow command tasks to run locally (default off)
| 动作 | 触发时会发生什么 |
| message | 发送固定文本,不调用模型,不消耗 token |
| prompt | 在该会话中运行一轮普通智能体回合,并发送回答 |
| command | 运行本机命令并发送输出(需要 /task command on) |
触发器接受中英文,可互换:daily 08:30 / 每天 08:30、weekly mon 09:00 / 每周一 09:00、 every 30m / 每 30 分钟、monthly 15 09:00 / 每月15日 09:00(短月份自动截断)、once 2026-10-20 09:00。任务存放在 $DSH_HOME/dsh-wechat-media/ 下的 tasks.json 中,与 prefs 分开;任务跑过一次后,同一时间窗口内不再触发;once 任务跑完会自动关闭。
在会话里提一句就行——「每天 8 点提醒我喝水」——智能体会用 task_add 创建任务,目标就是发起请求的那个会话;/state 会报告 tasks: { count, enabled, nextAt, commandEnabled, ran, failed, items }。
What people actually schedule / 实际用法
| 你想要 | 调度 | 动作 |
| 上班前一条天气 | 每天 07:30 | prompt:「用一句话说今天厦门天气,再给一句提醒」 |
| 中午汇总群消息 | daily 12:00 | prompt:「总结今天群里值得看的消息,五条以内」 |
| 周一交周报 | 每周一 09:00 | prompt:「汇总上周完成的事,写成三条」 |
| 每月提醒 | 每月1日 10:00 | message:「该交房租了」 |
| 一次性,下周再来一次 | once 2026-10-20 09:00 + [+1周] [再次运行] | 任意 |
| 每半小时盯一个文件夹 | every 30m | command(需要 /task command on) |
prompt 最有意思:它运行的是普通智能体回合,所以智能体手上的工具全都能用——网页搜索、文件、飞书。因此定时 prompt 是「每天早上问我这件事并把活干了」,不是一条预制消息。
The card / 面板
Web UI 卡片负责管理那一半;创建在卡片和会话里都能做。
| 控件 | 出现时机 | 作用 |
| 时间 + 动作 + 文本 + 添加 | 始终 | 创建任务,目标是最后写入的那个会话 |
| [暂停] / [恢复] | 循环任务 | 停止和重启调度 |
| [+1 天 ▾] [再次运行] | 已跑完的一次性任务 | 往后推一天 / 一周 / 一个月并重新启用 |
| ☐ 完成后再排一次 | 一次性任务 | 每次跑完自动重新排期(同一时刻,次日) |
| [删除] | 始终 | 删除任务 |
| ☐ 允许 command 任务执行本机命令 | 始终 | command 任务背后的开关(默认关闭) |
已跑完的一次性任务显示 ✅ 已跑完(一次性任务,不能暂停),而不是一个按了也没反应的暂停按钮:dueTasks 拿上次运行时间对比那一次触发的 atMs,所以重新排期有意义,单纯恢复没有。
还有两个行为值得知道:
- 错过的运行会跳过,不会补跑。 任务迟到超过 60 分钟(笔记本休眠、桥接停掉),记为错过,调度继续往前走——否则机器一唤醒,所有错过的 prompt 任务会在同一秒一起触发。prefs 里的 taskGraceMinutes 可以调;0 表示「不管多晚都跑」。/state 会在 ran 和 failed 旁边显示 tasks.skipped。
- 任意会话都能作为目标。 task_add 接收 transport(wechat / wecom / feishu)和 to,因此可以给一个你刚聊过的群之外的目标建任务;不传这两个参数时,默认仍然是「发起请求的那个会话」。
Office documents / 办公文档
四个工具负责读取、创建和转换 .docx / .xlsx / .pptx,并导出 PDF 或页面图片。它们只是薄薄一层封装,底下是这台机器上已有的两个程序——运行时自带的 Python(python-docx、openpyxl、
python-pptx) and the LibreOffice Kit CLI - so nothing is bundled here and nothing is installed at run time:
| Tool | Does |
| office_read | text and structure: paragraphs, tables, sheets with rows, slide texts |
| office_write | create from a JSON spec (below) |
| office_convert | .docx/.xlsx/.pptx → PDF, source untouched |
| office_render | pages (or a sheet range) → PNG, for a visual check or to send as a picture |
// .docx
{"title": "季度报告", "blocks": [ {"type": "heading", "level": 1, "text": "一、概述"}, {"type": "paragraph", "text": "正文"}, {"type": "bullets", "items": ["要点一", "要点二"]}, {"type": "table", "rows": [["项目", "数值"], ["收入", "120"]], "header": true} ]}
// .xlsx
{"sheets": [{"name": "报表", "rows": [["月份", "收入"], ["1月", 120]], "widths": [12, 10], "header": true}]}
// .pptx
{"slides": [{"title": "第一页", "bullets": ["背景", "结论"]}]}
/office prints which program each capability needs and whether it was found; /state.office carries the same
answer for a script. A missing program is named (找不到 Python(设置 officePython…)) rather than reported as a broken document - the toolchain and the file are different problems and must not look alike.
Create a file, then send it: the bridge already has send_file for WeChat, WeCom and Feishu, so "做个 Excel 发我" is office_write then send_file.
Relative paths land in officeDir, printed by /office:
/office # toolchain + the directory relative paths use
/office dir D:\报表\2026 # set it (also /prefs?officeDir=…, or DSH_OFFICE_DIR)
Empty means "the bridge's working directory" - workable, but forgettable, which is why /office always prints the
effective path instead of expecting anyone to remember it.
Desktop control (mouse, keyboard, screen) / 桌面控制
Seven tools can drive this machine's own mouse and keyboard, read the screen and manage windows. They are
off by default and every one of them refuses until you arm the switch, because they are the only tools here
that act on your screen instead of on the bridge:
/desktop on # arm (also: POST /dsh-wechat-media/prefs?desktopControl=true)
/desktop off # disarm
/desktop # toggle
| Tool | What it does |
| desktop_screen | virtual screen origin/size in physical pixels, monitor count, pointer position - call it first so coordinates are real pixels |
| desktop_windows | visible top-level windows: title, pid, rectangle, minimised |
| desktop_focus | bring the first window whose title contains a string to the front, restoring it if minimised |
| desktop_screenshot | PNG of the whole screen or a region into the shared inbox, path returned so it can be sent to the chat |
| desktop_mouse | move / click / double_click / right_click / middle_click / drag / scroll at physical pixels |
| desktop_type | type literal text (Unicode scan codes, so Chinese arrives as typed) |
| desktop_key | press a chord such as ctrl+s, alt+tab, up |
Three things are deliberate:
- Off unless armed. The switch is checked in one wrapper (desktopTool) that is the only way to define one
of these tools, so a new desktop tool cannot forget it. /state reports
desktop: { enabled, supported, reason }.
- Nothing is interpolated into PowerShell. Coordinates are integers; text and window titles travel as
base64 and are decoded by PowerShell, so a window title containing '; Remove-Item … is data, not code.
- Windows only, for now. The scripts use user32.dll, System.Windows.Forms and System.Drawing;
on any other platform the tools refuse and say which platform they found.
desktop_screen also reports the size of the INPUT struct the C# compiled, so a wrong layout (which makes
SendInput silently do nothing) is visible rather than guessed at.
Clipboard / 剪贴板
Two tools move things through the clipboard, which is how "我复制了一段…" becomes something the model can read, and how a result ends up where the next Ctrl+V will find it:
| Tool | What it does |
| clipboard_read | the copied text (capped at 200k characters) and the file list currently on the clipboard |
| clipboard_write | put text on the clipboard, or paths (comma / newline separated) as a file-drop list (Explorer pastes those as files) |
/clipboard on|off|status # on by default; /clip 也行
- On by default, with an off switch. A clipboard can hold a password, so /clipboard off is one command away and
both tools refuse immediately once it is set. The switch is checked in one place, before any script runs.
- Files win over text when both are given: a clipboard holds one thing, and a file has no other way to be pasted.
- Windows goes through WinForms on an STA thread (powershell.exe -STA), which is what makes SetText/GetText
reliable from a service-like process. macOS uses pbcopy/pbpaste, Linux wl-copy/wl-paste; a file list is
Windows-only and the other platforms say so instead of silently copying nothing.
- The protocol is base64, and that is not decoration. The first version printed JSON straight to stdout and every
Chinese character came back as ?, because powershell.exe writes stdout in the console codepage. Each script now
emits the base64 of its UTF-8 JSON and the runner decodes it - 剪贴板自检 round-trips unchanged, and a test drives the fake runner with exactly that string.
- The size cap is applied inside the PowerShell script, so a 20 MB clipboard never travels through stdout to then be
thrown away.
SSH / 远程命令
Three tools drive the platform's own OpenSSH client - the binaries ship with Windows 10/11, honour ~/.ssh/config and
known_hosts, and keep the private key exactly where it already is. Nothing here reads a key's contents; only its path.
| Tool | What it does |
| ssh_exec | run one command on a host, return exit code + stdout + stderr + timing |
| ssh_upload | copy a local file (or directory, recursive) to the host, via scp |
| ssh_download | copy a file (or directory) from the host |
/ssh # status: switch + host list
/ssh on | off # arm / disarm (off by default)
/ssh add nas root@192.168.1.10:22 [C:\Users\me\.ssh\id_ed25519]
/ssh remove nas
Hosts live in one prefs block (prefs.ssh), so the card, /state and the tools read the same list; a tool also accepts
an ad-hoc user@host[:port] without saving it.
Three decisions, each because the alternative fails silently:
- Key-based only, always BatchMode=yes. A password cannot be typed into a service-like process, so an auth problem
must fail immediately with the client's own message instead of hanging the turn. No password is ever stored.
- StrictHostKeyChecking=accept-new. A new host key is learned once and a changed one is refused: no would
accept a man-in-the-middle, yes would make the first connection impossible.
- Off until /ssh on. Running commands on someone's servers is not a default-on capability, and the gate is checked
before any process starts.
scp gets -P for the port (its -p means "preserve times"), the command is placed after -- so a remote command
starting with - is not read as an option, and captured output is capped at 40k characters with an explicit
"已截断" marker rather than a silent cut.
Serial: flash a board and read it back / 串口烧录
Three tools drive the toolchain people already have - ESP-IDF's idf.py/esptool, or avrdude for AVR - instead of
reimplementing a bootloader protocol, because that is how a tool ends up bricking a board.
| Tool | What it does |
| serial_ports | the COM ports with the device name behind each (COM3 vs COM4 means nothing without "Silicon Labs CP210x"), plus which flashers were found |
| serial_flash | flash: flasher=idf runs idf.py -p COMx flash in an ESP-IDF project (offsets come from its sdkconfig); esptool writes one .bin at an offset; avrdude flashes a .hex |
| serial_monitor | read what the board prints for N seconds (default 8) and return it |
An STC (8051) board uses flasher=stcgal: STC speaks its own ISP protocol, so esptool cannot touch it. Pass
chip=stc8g (or leave it auto); stcgal passes -A dtr, which cold-starts the MCU over DTR - measured on an STC8H1K08,
it handshakes without unplugging the board. stcgal is found on PATH or inside any ESP-IDF python env, because that is
where pip install stcgal puts it on a machine that already has ESP-IDF. AVR boards use
flasher=avrdude, and an ESP-IDF project uses flasher=idf - which runs inside export.ps1, because idf.py cannot see
cmake otherwise.
/serial # switch, baud, default port, ports, toolchain
/serial on | off # off by default: flashing writes a board's flash
/serial baud 460800
/serial port COM3
Then, from the VS Code panel with the project open: "把这个项目烧到 COM3" - the turn runs in the open folder, so serial_flash with flasher=idf and projectDir set to that folder flashes the project's own build.
From the VS Code panel, with the project open:
COM3 上插的是什么板子? -> serial_ports 把这个项目烧到 COM3 -> serial_flash (flasher=idf, projectDir = the open folder) 把 Objects\app.hex 烧到 COM3 -> serial_flash (flasher=stcgal / avrdude, by file type) 读 10 秒串口看看它在打印什么 -> serial_monitor
Four rules, each because the alternative is silent damage:
- Off until /serial on - writing a board's flash is not a default-on capability.
- Never guess the port: a port must be named, or there must be exactly one. Two ports and no answer is "say which
one", not "pick the first" - and a named port that is not plugged in is refused with the list of the ones that are.
- The exact command is echoed with every result, so a failed flash can be repeated by hand instead of guessed at.
- One owner per port: the monitor opens the port exclusively, so a flash and a monitor cannot overlap - the second
one fails with a reason rather than stealing the port mid-flash.
A board answers on real hardware, and that is where the interesting bugs were. On an STC8H1K08 behind a CH340, serial_ports reported COM3 — USB-SERIAL CH340 (COM3); serial_monitor read TEMP:28.1C twice a second; serial_flash wrote DS18B20_TM1650.hex (3129 bytes, Target UID: F734C3FC31F408) and the board printed TEMP:27.6C straight afterwards. Three fixes came out of that test rather than out of reading the docs: /prefs dropped the serial block silently, idf.py could not see cmake without export.ps1, and stcgal's tqdm bar arrived as mojibake and hid the verdict.
The flasher's verdict lives in its last lines ("Hash of data verified", "avrdude done"), so output is tailed rather
than capped from the start. Discovery is real: on this machine it found D:\Espressif\frameworks\esp-idf-v4.4.8 with
its python_env interpreter - and reported "no serial ports" when no board was plugged in, rather than inventing one.
Scrapling: fetch what a plain client cannot / 抓取引擎
Scrapling is a python scraping library with a browser-shaped TLS fingerprint, real-browser headers and adaptive selectors,
so it reads pages that reject a plain fetch (403, Cloudflare's challenge, a UA check). scrapling_fetch exposes it as
one tool:
| Argument | What it does |
| url | the http(s) page (http and https only - a file:// path is refused) |
| selector | CSS selector; each match becomes one item with its text (span.text, h2 a::text) |
| links | also return the page's anchors |
| mode | text (default) or html |
| limit / textLimit | caps (items, characters) - a page can be megabytes, the transcript should not be |
| stealth | run a real browser; it drives the Chrome you already have (no 1.25 GB download) |
| cloudflare | stealth only: solve a Cloudflare challenge first (~11 s on a protected page) |
| exe | stealth only: which browser binary to drive |
| proxy | per-call proxy, or set a default with /scrapling proxy … |
/scrapling # which python has scrapling, the proxy and stealth defaults
/scrapling proxy http://127.0.0.1:7897
/scrapling stealth on|off # off by default: a real browser is slow and needs its deps
Measured on this machine (scrapling 0.5, python 3.11), four cases worth keeping apart:
| Case | Result |
| https://example.com, no selector | 200, body in 2.4 s |
| https://quotes.toscrape.com/ with selector: "span.text" | 200, three quotes as separate items in 1.7 s |
| https://www.baidu.com, links: true | 200, the page text plus eight anchors in 0.9 s |
| https://news.ycombinator.com/ without a proxy | hard failure after 21 s: curl: (28) Failed to connect |
| the same URL with proxy: "http://127.0.0.1:7897" | 200 in 1.8 s, five headlines |
The last two are the point: an unreachable host is reported with curl's own words after a bounded wait, rather than
looking like a hang - and the same call answers in under two seconds once a proxy is given. Two more failure modes are
reported rather than guessed: a selector that matches nothing still returns the page text and a warning, and a missing
dependency comes back as the exception's own words (ModuleNotFoundError: No module named 'msgspec' was the real one
here, fixed by installing it).
Stealth needs a browser, and it uses the one you already have: the session is handed the machine's own Chrome (then
Edge) through executable_path, because camoufox's bundled browser is a 1.25 GB download (measured ~150 KB/s
single-stream, ~620 KB/s with eight ranges - which stalls in practice). If no browser is found the tool says so instead of
quietly falling back to the plain fetcher: a silent downgrade would make an anti-bot failure look like a selector problem.
cloudflare: true lets the browser solve a challenge before reading - measured on https://nowsecure.nl: 200 in 11 s.
Behind it, the python side is one self-contained script embedded in scrapling.js - not a second file, because a plugin
that installs and then fails on a missing helper is worse than one that does not install.
Web search (Tavily) / 联网搜索
A tavily_search tool lets a WeChat, WeCom or Feishu conversation ask about something current and get real titles,
links and snippets. The shapes were copied from the user's own working plugin (wjh-ux/dsh-mcp-tavily, cited line by
line in tavily.js) rather than guessed, and the live API was called to check them: with a key, three results came
back in about three seconds - and keyless mode works too, so search functions before a key is configured.
The key is set in the panel's Tavily block and takes effect on the next search with no restart, because the tool
re-reads the saved settings before every call. It is never echoed back: /state reports keySet and nothing more,
including for the case that used to leak - a Tavily-only prefs object with no Feishu block. A failure states its
reason (Tavily 搜索失败:…) instead of returning an empty answer.
| Parameter | Meaning |
| query | what to search for (required) |
| maxResults | 1-20; defaults to the configured value |
| searchDepth | basic (default), advanced or fast |
| includeDomains / excludeDomains | comma-separated domains to restrict to, or leave out |
Browser automation / 浏览器自动化
Nine tools drive a real browser - the Chrome or Edge already on the machine, over the Chrome DevTools
Protocol - with no third-party dependency: Node's own global WebSocket carries CDP. There is nothing to
install: no npx, no npm install, no config file. The panel chooses the channel, the debugging port and whether it
runs headless.
| Tool | Does |
| browser_open | open a URL and return the page title |
| browser_text | the page's title, URL and visible text |
| browser_click | click an element by CSS selector (a real mouse event) |
| browser_type | type into an element by CSS selector (real key input) |
| browser_screenshot | PNG of the page into the shared inbox, path returned so it can be sent to the chat |
| browser_eval | run JavaScript in the page and return its value (the escape hatch when selectors are not enough) |
| browser_flow | replay a list of steps and return the rows they collected (see below) |
| browser_record | record the person's own clicks/typing/Enter into a replayable flow |
| browser_close | close the browser this plugin started |
It starts its own instance with a profile under $DSH_HOME/browser-profile, so the browser a person actually
browses with is never touched; a browser already listening on the port is attached to instead of duplicated.
The "自动测试软件正在控制 Chrome" bar is hidden by default: --enable-automation is passed only when the panel's browserHideAutomationBar box is unticked, and the single CDP call that required the flag (Browser.getBrowserCommandLine) is no longer used - a leftover browser is found by port, not by its command line. Verified live against Chrome 155: a page received typed input (17 and 25) and a real mouse click and computed 42 by itself, and a screenshot came back as a 68,561-byte PNG and was delivered to Feishu. Every failure states its reason (browser_click 失败:nothing on the page matches the selector "…").
Which browser? (会话 / browserProfileMode) - four answers, because "same Chrome as mine" has four readings:
- own (default) - the plugin's own profile above;
- chrome - import bookmarks and preferences from the real Chrome. Cookies and saved passwords cannot be brought
across: Chrome 127+ encrypts them with an app-bound key that dies with the profile (measured: 114 cookie rows in, 29
after one start). Kept for the files that do survive;
- attach - drive a Chrome you started with --remote-debugging-port=9222 and never close it. /browser launch
starts such a Chrome on its own profile and switches to this mode;
- extension - the one that reaches your real Chrome. Load extension/ as an unpacked extension
(chrome://extensions → developer mode), put the address and token from the panel (or /browser ext) into its
options, and every browser_ tool runs through chrome.debugger inside your own browser - your logins, your tabs,
no debugging port. Edge works the same way: the folder loads unchanged from edge://extensions. The panel's
一键安装扩展 button packs the folder into $DSH_HOME/exports, opens the settings page of the configured channel (chrome://extensions or edge://extensions) and puts the token on the clipboard; Chrome does not allow a page to install an extension silently, so those last two clicks stay yours.
扩展驱动 (the debugging banner). Chrome draws "已开始调试此浏览器" whenever chrome.debugger attaches, and no
extension can hide it - so the panel offers the choice instead: debugger (real input, full-page captures, any
frame; the banner flashes while a command runs) or script (chrome.scripting in the page, no banner ever, and
captureVisibleTab still gives you a screenshot of the visible viewport - what it gives up is native input and
full-page capture), or hybrid (script first, and the debugger only for the command that scripting cannot finish -
a click that found nothing, a frame-scoped operation - so routine work leaves no trace in Chrome's UI). The switch is
stored and delivered before the next command runs.
两个浏览器同时开着. Every poll names its browser (chrome / edge) and installation, so the bridge knows both.
目标浏览器 (/browser ext target auto|chrome|edge) picks which one receives commands; auto follows the channel
setting. The other browser keeps a standby poll and never gets work - without that, two open browsers race for every
command and the answer comes from whichever asked first.
网络层 / The network layer
A rendered list is the last copy of the data, and the smallest. Four tools reach the source instead - all of them on the
extension transport, because a page cannot see its own traffic or its own cookie store:
| Tool | Does |
| browser_capture | start / stop / list: Network.responseReceived + Network.getResponseBody, so the JSON the page fetched comes back whole. pattern keeps only the API, limit bounds memory, bodies cap at 1 MB, images/fonts are skipped |
| browser_cookies | the login state, for one URL or the whole browser |
| browser_download | download through the browser (its cookie jar applies), wait for completion, report the path |
| browser_tabs | list / open / select - a second tab becomes a choice, not a hope |
Flow step waitIdle uses the same machinery: it resolves when nothing is in flight and nothing new has happened for
idleMs, which is what "the page has settled" actually means.
Typical shape: browser_capture start pattern=/api/ → drive the page → waitIdle → browser_capture stop.
Why not simply drive the real profile? Chrome 136+ silently ignores --remote-debugging-port when --user-data-dir is
the default one - measured on Chrome 155: process up, window visible, port never listening. That is also why the
extension exists.
VS Code / 编辑器桥
vscode-extension/ is a small editor extension - plain CommonJS, no build step - with three ways in: a chat panel in the
activity bar, DSH: 提问… / DSH: 用选中内容提问, and a status-bar item showing what the plugin is connected to.
It talks to three loopback routes: /dsh-vscode/status, /dsh-vscode/ask and /dsh-vscode/stream. All of them are a
real agent turn - the same ensureAgent + collector path a chat uses - so the editor gets the same model, tools and
context rather than a second, weaker implementation. /vscode on|off gates it.
The AI edits the project you have open. The request carries the workspace folder and the 可写 switch under the input box, and the plugin runs the turn in that folder with workspace-write as its boundary - so edit/write land in your project, and anything wider still fails closed (approval is never, because nobody in an editor panel can answer a prompt). Turn 可写 off and the same session runs read-only on the next request: the switch is re-applied every time, not remembered from the first turn. One folder is one conversation (project:<folder>), so two open projects never share a history. The panel prints the project it is talking about, 项目:<folder>(可改 / 只读).
Verified end to end: "新建 note.txt" created a 31-byte UTF-8 file in the open folder; with 可写 off, the follow-up edit was refused by the sandbox ([sandbox: file access denied under read-only mode]) and the file was left untouched.
The endpoint is discovered from gui-port.json in the plugin's state directory, because the GUI port changes on every
DSH restart. Install: Extensions → ... → Install from VSIX (dsh-wechat-media-vscode-0.5.0.vsix in this repo), or
code --install-extension vscode-extension/. The extension's own version is what VS Code uses to decide whether to
replace it - a frozen version makes an install a no-op that still prints "successfully installed".
Flashing from the editor / 在编辑器里烧板子
The editor bridge sends the folder you have open as the turn's working directory, which is exactly what a flash needs:
idf.py finds the project's sdkconfig (and therefore the offsets, flash size and partition table), and a plain
.hex/.bin is resolved relative to that folder. So "烧一下" from the panel is one sentence, not a terminal session with the right environment - and the answer comes back in the same panel, with the exact command that ran.
Flow automation / 流程自动化(影刀式)
browser_flow is the RPA part: a task is written once as JSON, and replayed on demand - open, search, click,
scroll, read the list, loop over the rows, export. The same shape a 影刀 flow has, without the recorder UI: the steps are a text file, so they can be reviewed, edited and handed to somebody else.
| Step | Fields | Does |
| goto | url, ms? | open a page and wait for load |
| click | selector | a real mouse click on the element's centre |
| type | selector, text, clear? | focus and type (clears first unless clear: false) |
| press | key | Enter, Tab, Escape, ArrowDown, PageDown, Space |
| wait | ms | a fixed pause (default 500) |
| waitFor | selector, ms? | poll until the element appears (default 15 s), fail if it never does |
| scroll | to, times?, selector?, pauseMs? | scroll a container or the window to bottom/top |
| extract | fields, selector?, single?, into? | read one row (or the page) into a table |
| each | selector, fields, limit?, pauseMs? | walk the matched elements, one row each, scrolled into view |
| eval | expression, into? | run JavaScript and keep the value |
| assert | selector, text? | stop the flow unless the page shows what was promised |
A field value is ".title" (that element's text inside the row), ".link@href" (an attribute), or "@text" (the
row's own text). A column that matches nothing is "", never a missing key.
{
"name": "google-maps-cafes",
"url": "https://www.google.com/maps/search/coffee",
"steps": [
{ "type": "waitFor", "selector": "[role=feed]" },
{ "type": "scroll", "to": "bottom", "times": 3, "pauseMs": 800 },
{ "type": "each", "selector": "[role=feed] > div", "limit": 20,
"fields": { "name": "h3", "rating": "span[role=img]@aria-label", "href": "a@href" } }
]
}
browser_flow({ flow: "<the JSON>", save: "out.csv" }) returns a per-step report (✓/✗, timing, what was read) plus
the rows; save writes them as .csv (UTF-8 with a BOM, so Excel opens Chinese correctly) or .json. A failing
step stops the run and says which step it was - and the rows collected before it are kept.
save is a place, not just a name: a full path (D:\导出\门店.csv) is written as given, a folder (D:\导出, or a path with no extension) gets <flow>-.csv inside it, and a bare name lands in the export directory, $DSH_HOME/exports unless /browser exportdir D:\导出 says otherwise. ~ expands and the folder is created; the report always shows the absolute path that was written.
The panel's 流程导出目录 row has 浏览…, which opens the real Windows folder dialog on this machine and saves the choice as soon as it is made. The dialog runs in the host, not the page, because a browser cannot learn an absolute path: showDirectoryPicker() hides it and webkitdirectory reports only relative names.
To build a flow without writing selectors by hand, browser_record({action:"start"}), do the task once, then
browser_record({action:"stop"}) returns the steps as JSON. The recorder captures clicks on links/buttons/inputs,
value changes, Enter, and a debounced scroll (one step per pause, not one per pixel), and picks selectors by #id,
then data-, then a short tag:nth-of-type path - never a generated class name, which changes between two loads.
Chat commands / 聊天里的命令
A message that starts with / is addressed to the bridge, not to the agent - it is answered immediately and
never becomes a turn. This works in personal WeChat and in WeCom, and only in chats your allowlist lets
through (a blocked chat cannot run commands either).
| Command | Aliases | What it does |
| /help | /h, /?, /帮助 | Lists the commands. |
| /status | /状态 | Transport, account, chat, DSH session (and how many times /new restarted it), the WeCom long connection's state including its last error, the counters, approvals waiting, the last message time and the media directory. |
| /new | /reset, /新会话 | Starts a new conversation: the chat moves to a new session id, so the old history is neither resumed nor carried over (the transcript stays on disk). The reply says which segment you are on. |
| /friends | /friend, /好友 | Turns only answer friends (1:1 chats) on or off: /friends on, /friends off, or no argument to toggle. Group messages are then dropped silently and counted. |
| /delivery | /发送方式 | How a reply is sent: whole (the finished answer, once) or stream (word by word, WeCom only - personal WeChat cannot edit a sent message). No argument shows the current mode. |
| /auth | /授权 | Authorization level for approvals: /auth 严格 / 适中 / 完全权限 / 仅查看 (also strict, moderate, full, readonly). No argument lists them. |
| /interject | /插话 | Turns interjection on or off: with it on, a message sent while the agent is still answering joins that turn instead of queueing its own. No argument toggles. |
| /feishu | /飞书 /lark | Reports the Feishu channels that are configured (never the secret itself - only whether one is set), and /feishu test [id] [type] sends a self-test through the available channel. |
| /retry | /重试 | Retries a turn once when it fails in the transport (a timeout, a reset connection, a rate limit, a 5xx). A deterministic failure is never retried - that would only spend a second call to fail the same way. On by default, and the same switch is a checkbox in the Web UI panel. |
| /tokens | /token, /用量 | Token usage per account (and per chat inside it). /tokens reset zeroes it; /tokens 7 sets the clearing window to 1-30 days. |
| /help | /h, /?, /帮助 | /help lists every command grouped by purpose (会话 / 发送 / 授权与联系人 / 工具 / 通道 / 多 agent / 运维) with its aliases; /help <命令> (or an alias, e.g. /help 预设) shows one command's usage, aliases, summary, 可用值 and 现状 - the current switch, preset, model, baud, connection - plus the sub-forms worth knowing. |
| /desktop | /桌面 | Arms or disarms the desktop tools (mouse, keyboard, screenshot, windows). Off by default - they act on your own screen. |
| /clipboard | /clip, /剪贴板 | Arms or disarms the clipboard tools. On by default: a clipboard can hold a password, so one command turns reading off. |
| /multiagent | /多agent | The switch for one agent per conversation: /multiagent on|off, or no argument for the switch and this chat's preset. Off by default. |
| /agent | /预设 | Lists the agent presets the host exposes, shows which one this chat mounts, /agent <id> sets it and /agent reset returns to the default. Only takes effect with /multiagent on. |
| /name | /命名 | Renames the chat you are talking in: /name 老板的群. A QQ chat's identity is an openid (A32A7C20…), so this is the only way a list of chats reads like something a person recognises. POST /peers takes the same name for any chat. |
Email
Two directions, two protocols, both written out in email-wire.js - the plugin still has no runtime dependencies, because adding
a mail sender and an IMAP client to a process that already holds chat credentials is a large trade for one channel. The clients are
tested against fake SMTP and IMAP servers in the suite.
- Receive: every pollSeconds (15–600, default 60) an IMAP UID SEARCH UNSEEN plus BODY.PEEK[]; the decision is made first and
the message is marked read after the turn, so a crash leaves it unread and it comes back next round instead of vanishing.
- Send: SMTP with AUTH LOGIN; a server that does not offer STARTTLS is refused rather than handed a password in the clear.
Replies carry In-Reply-To/References, so they land in the same thread.
- Who may write: allowSenders empty means nobody, not everybody. @example.com domain wildcards work.
- Attachments: named, never downloaded - writing a stranger's file to disk is not this module's decision.
- Decoding: multipart / base64 / quoted-printable / UTF-8 and GB charsets, each with a fallback, so a malformed mail cannot take a
turn down.
Passwords are sealed like every other credential (DPAPI on Windows) and never travel back to the card.
| /email | /邮件, /mail | SMTP sends, IMAP receives. /email prints the state, on/off toggles it, check fetches once, test [address] sends a self-test mail, allow / remove maintain the allowed senders. An empty list means nobody is answered (email addresses are spoofable, so the default is closed). The card holds SMTP/IMAP hosts, both passwords and the list. |
| /qq | /QQ | The QQ bot's switch and status: /qq on|off, or no argument for credentials presence, connection state and the last error. Credentials live in the card. |
| /scrapling | /抓取, /scrape | Scrapling's defaults: /scrapling proxy <url>, /scrapling stealth on|off, or no argument for the interpreter, proxy and stealth status. The fetching itself is the scrapling_fetch tool. |
| /serial | /串口, /flash | Arms or disarms the serial tools and sets their defaults: /serial on|off, /serial baud 460800, /serial port COM3, and with no argument it reports the switch, the toolchain it found and the ports that are plugged in. Off by default - flashing writes a board's flash. |
| /ssh | /远程 | Arms or disarms the SSH tools and manages the host list: /ssh on|off, /ssh add <name> <user@host[:port]> [identity], /ssh remove <name>. Off by default - these run commands on other machines. |
| /stop | /cancel, /停止 | Interrupts the turn running in this chat's session, and says so honestly when nothing is running. |
Anything else starting with / gets the command list back rather than a guess; a bare / or // is an
ordinary message. Commands are counted (counters.commands) and recorded in /state.recentRoutes.
Integrations: in, out, and a dashboard / 集成:进、出、看板
Three routes turn the bridge into something other systems can drive and watch. All of them live on the same loopback
server as the rest of the panel; the inbound one is the exception that is meant* to be reachable from another machine.
1. Inbound: POST /dsh-wechat-media/inbound — let a NAS, CI or a monitor "shout at" the agent
curl -s http://127.0.0.1:19387/dsh-wechat-media/inbound \
-H 'content-type: application/json' \
-H "x-dsh-token: <面板/看板上的入站 token>" \
-d '{"peer":"<wxid 或群 id>","text":"把今天的备份报告整理成三句话","id":"ci-2026-10-23-1","reply":true}'
| 字段 | 必填 | 说明 |
| peer | 是 | 要说话的会话 id:微信 wxid / 群 id、企微会话 id、飞书 receive id。不是昵称 —— 昵称会重名,会话 id 不会 |
| text | 是 | 触发这一轮的话;上限 8000 字符 |
| id | 否 | 幂等键。同一个 id 再发一次不会重跑(CI 重试安全) |
| reply | 否 | true(默认)= 回答也发到那个会话;false = 只在 HTTP 响应里返回 |
| transport | 否 | wechat(默认)/ wecom / feishu;后两者目前只能把回答放进 HTTP 响应 |
| accountId | 否 | 多个微信账号时指定用哪个;默认第一个已配对的 |
响应里带 sessionId、answer、stopReason、delivered 和 ms,所以 CI 不需要再去微信里看结果。token 是唯一的门 ——这条路由不要求来源是本机(否则它就没有意义),所以 token 泄露等于接口泄露:面板/看板上可以一键换一个。
2. Outbound webhook — every finished turn, POSTed to your address
curl -s -X POST http://127.0.0.1:19387/dsh-wechat-media/prefs \
-H 'content-type: application/json' \
-d '{"webhookUrl":"https://example.com/dsh-hook","webhookSecret":"<随便一段足够长的随机串>"}'
先证明接收端是对的,再让真实轮次依赖它
curl -s -X POST http://127.0.0.1:19387/dsh-wechat-media/webhook \
-H 'content-type: application/json' -d '{"action":"test"}'
每轮结束(微信 / 企微 / 飞书 / VS Code / 入站触发的都算)POST 一份 JSON:
{"event":"turn.end","at":"…","sessionId":"…","accountId":"…","chatId":"…","peerName":"…", "transport":"wechat","answer":"…","answerChars":123,"stopReason":"end_turn","error":null,"version":"0.68.0"}
- 配了 webhookSecret 时带 x-dsh-signature: sha256=<hex>,是对原始 body 的 HMAC-SHA256(不是拼接哈希),接收端算一遍比对即可;
- 失败重试 3 次(300/600ms 退避),4xx 不重试 —— 那是「地址错了」,再发两次也没用;
- 接收端挂了、慢了、超时(10s)都不会拖慢或影响聊天里的回答:发送是 fire-and-forget,结果记在 /dsh-wechat-media/webhook 的 last 和看板上;
- webhookUrl 置空 = 关闭。
3. Dashboard — GET /dsh-wechat-media/dashboard
浏览器打开 http://127.0.0.1:19387/dsh-wechat-media/dashboard(30 秒自动刷新,无脚本,仅本机可访问):
- 卡片:会话数、失败合计、token 总量、入站请求数、webhook 已发数;
- 失败与丢弃:重试失败、无法使用的消息、媒体失败、被白名单/群规则拦下的、飞书错误、入站失败、webhook 失败、 token 记账失败(如果记账本身坏了,这一项会红);
- Token 用量:按账号/会话拆开,带清零窗口;
- 最近活动:最后入站、webhook 的最后一次投递(成功/失败 + HTTP 状态)、最近路由、最后一帧流式数据;
- 配置:入站 token、webhook 地址、签名是否已设置。
排障时先看这一页,比翻会话记录快。
Token usage / Token 统计
The bridge records what each account spends, per model call, from the usage the harness reports on the
assistant/message session event - the numbers the model adapter returned, so nothing is estimated. Inside an
account the spend is also split per chat.
- A window you choose, 1 to 30 days (default 30). The counters clear themselves at the end of the window.
The reset is applied lazily whenever the ledger is read or written, so a host that was switched off for a week
starts a fresh window on its next breath instead of reporting a stale one - and resets / lastResetAt only
move when there was actually something to zero.
- Three ways to see it: the card's Token usage section (one row per account, a window picker and
Clear now), /tokens in a chat, and a single line inside /status.
- Where it lives: $DSH_HOME/dsh-wechat-media/tokens.json. Only counters are stored - no prompts, no
replies, nothing the model said.
Interjection / 插话
A message that arrives while the agent is still answering can either join that turn or wait for its own.
- Off by default - the message queues as its own turn, which is what every chat already does.
- On (the card's checkbox, or /interject on): the message is handed to the running turn with the harness's
own agent.steer(), so it lands in that turn's next-step inbox and the model sees it at its next step rather
than after the answer. (followup() - the queueing path - puts it in next-turn instead.)
- The switch only steers when there really is a turn in flight for that chat and a live agent to steer;
otherwise the message queues, because "interject" must never mean "dropped on the floor". /status shows the
state and how many messages have been interjected so far.
- Personal WeChat and WeCom behave identically, and the relay keeps a queue of waiters per session - a second
message can no longer replace the first message's waiter (which is what used to lose a reply).
Authorization / 授权档位
What this plugin does with an approval request it receives is a plugin decision, and it has four levels - the
card's menu, or /auth:
| Level | What the plugin does |
| 严格 strict | Ask in the chat for every request, and treat silence as no - it never silently falls back to the GUI. |
| 适中 moderate (default) | Ask in the chat; if nobody answers, the GUI's own prompt stays in charge. This is exactly what earlier releases did. |
| 完全权限 full | Never ask here: approvals are left to DSH (with a session policy that asks nothing, that means actions simply proceed). |
| 仅查看 readonly | Ask nobody and refuse everything that needs an approval - fail closed. |
Honest scope. DSH's own session policy - the file sandbox (read-only / workspace-write / danger-full-access) and whether approvals are asked at all - belongs to the DSH client, not to a plugin. A plugin can only decide what it does with a request it is given. So with a session policy that never asks, 仅查看 has nothing to refuse: the card and this table say so plainly, and the real read-only switch lives in DSH's own session settings.
Delivery / 发送方式
How an answer leaves the bridge - the card's Sending menu, or /delivery:
| Mode | What the chat sees |
| 整段 whole (default) | The finished answer, sent once. What every earlier release did. |
| 逐字 stream | The answer as it is written: WeCom's stream message is incremental, so the same req_id is sent repeatedly with finish: false and the final send (which carries the whole answer) closes it with finish: true. |
Only WeCom gets the preview, and that is a platform fact rather than a shortcut: personal WeChat has no way to edit a
message that was already sent, so a WeChat chat keeps receiving whole answers whatever the setting says - /delivery
and the card both say so instead of leaving the option looking broken. Deltas are throttled (roughly 24 characters or
600 ms, whichever comes first) because one message per token would be absurd; a preview that no longer fits a single
stream body is skipped rather than truncated, so the final answer is never cut short. counters.streamChunks counts
what was sent, and /status shows the current mode.
Voice / 语音
Understanding a voice message. Both platforms transcribe voice themselves, and it is their transcript that reaches
the model - labelled, so the model knows it is reading speech rather than something typed:
[微信语音(平台转写)] 帮我把这周的日程整理一下 [企业微信语音(平台转写)] 明天上午十点提醒我开会
When the platform sends no transcript, that is said plainly ((平台未提供转写)) instead of being papered over. Personal WeChat's audio is saved as well, raw .silk - that is the format the protocol hands over, and nothing on a normal machine can decode it.
Speaking. Two tools, one per platform:
| Tool | What it does |
| wechat_send_voice | Sends audio through personal WeChat - a voice bubble only when the file is SILK, otherwise an audio file (see the rule below). |
| wecom_send_voice | Sends a voice bubble through WeCom (uploaded over the long connection, or the self-built app). |
Either tool takes text - which is spoken on this machine - or path to an audio file that already exists. The
speech is made locally: the system's own engine (SAPI on Windows) writes a WAV, and ffmpeg converts it to a chat
format (mp3 by default, amr which is what WeCom specifies for voice, or wav which needs no conversion at all).
No cloud service, no API key, nothing leaves the machine. If either piece is missing the tool names it
(windows speech (System.Speech) or ffmpeg) rather than failing silently, and a message it delivered counts as the
answer being delivered (DELIVERY_TOOLS), so the relay does not repeat it.
A voice bubble needs SILK on WeChat - so the rule is explicit
WeChat renders a voice bubble only for Tencent's SILK codec, and its API additionally requires
voice_item.playtime (the duration in seconds). An mp3/amr/wav item is accepted by the API and then dropped by the
client - measured here (the text arrived, the voice never did), and corroborated by an independent implementation
with the same symptom (hermes-agent#9971). ffmpeg cannot
encode SILK at all, and this plugin deliberately bundles no dependencies - so voiceDeliveryFor() promises only what
the platform can actually show:
| Target | Audio | What arrives |
| WeCom | amr (default), mp3, wav | a voice bubble (verified by ear) |
| personal WeChat | anything | an audio file - it arrives and it plays |
A voice bubble is not deliverable on personal WeChat, and that is measured rather than assumed: Tencent's own
OpenClaw WeChat channel never sends the VOICE media type at all (image/video/file only), the equivalent issue in
another implementation has been open and labelled blocked for months, and two live attempts here - mp3 inside an
open conversation window, then SILK with playtime - were each accepted by the API and then dropped by the client.
So the tool says what actually happens instead of promising a bubble.
The vendored codec is used for the other direction, which does work: WeChat sends voice as SILK, and nothing
else on a normal machine can play it, so an incoming voice message is decoded to a playable WAV in the inbox
(silkToWav(), falling back to the raw .silk if decoding fails). That is the same use Tencent's own channel makes
of silk-wasm 3.7.1 (MIT), vendored under vendor/silk-wasm/ with its
licence and the hash it was verified against; see THIRD-PARTY.md.
playtime is read from the finished file with ffprobe (0 when it cannot be read, and the item then falls back to
its minimum - never an invented number). The tool's reply says which of the two happened, so "sent" never quietly
means "dropped".
Diagnostics / 排障
- The host logs one line per inbound message with the envelope's identifying fields:
inbound group chat=… session_id=… group_id=… room=… from=… type=… — for a group that settles which id the platform expects as the send target.
- node scripts/probe-group-send.mjs "<chatId>" sends a labelled test message to one chat, prints the
platform's ret for each addressing variant, and then lists the chats the bridge already knows.
- GET /dsh-wechat-media/state (loopback only) returns accounts, monitors, preferences, peers and live
pairing attempts. state.wecom.inboundError and state.wecom.sessionIssue answer "why did the bot stop
answering this chat?": the first is the last failure while handling an inbound WeCom message, the second records
what had to be done when a stored conversation could not simply be resumed (resume, create, retryResume and
any replacement id). Both are also shown on the /dsh-wechat-media/wecom page.
- GET /dsh-wechat-media/wecom/inbound-test?chatId=…&text=…&chatType=group pushes one synthetic callback through the real inbound path (allowlist, friends only, /commands, media, the agent). chatType defaults to single; group is how a group-only rule is checked without a second person in a real group. The JSON answer echoes the chatType it used, plus inboundError when handling failed. The personal-WeChat twin is GET /dsh-wechat-media/debug/inbound?chatId=…&text=…, where a chat id ending in @chatroom means a group and a paired account is required.
Where things live / 文件位置
$DSH_HOME/dsh-wechat-media/ accounts.json, prefs.json, routes.json, peers.json
$DSH_HOME/dsh-wechat-media/accounts/ <account>.json (token, 0600) · <account>.sync.json (cursor)
<account>.context-tokens.json (proactive-send windows)
$DSH_HOME/wechat-inbox/<date>/<chat>/ inbound media, decrypted, with the real extension
$DSH_HOME/wechat-inbox/frames/ frame grabs and other working files the agent creates
Delivery semantics / 发送语义
- The agent's final answer is relayed automatically, chunked at ~2000 characters with a short delay
between parts. If the turn already used wechat_send_text or wechat_send_file, the relay stands down,
so an answer is never delivered twice.
- Replies settle on that turn's turn/end, not on whole-agent idle: a session shared with the GUI never
goes idle, which is the classic way a bridge "loses" replies it actually received.
- Messages are deduplicated per account by message_id, and the bot's own messages (message_type: 2) are
ignored.
Implementation notes / 实现要点
Everything here was learned against the live platform; each item broke something first.
1. qrcode_img_content is a landing-page URL, not an image. The QR has to be rendered locally from that
URL — which is exactly what the official client encodes. This package vendors
qrcode-generator (MIT, © Kazuhiko Arase) to emit an SVG.
2. The media key has two encodings. Outbound aes_key is base64(32-character hex text) — base64 of the hex string, not of the 16 raw bytes. Inbound accepts both that and base64(16 raw bytes), so a client must handle both.
3. The upload is AES-128-ECB ciphertext in the body (Content-Type: application/octet-stream), and the
result is the x-encrypted-param response header. The reported filesize is the padded ciphertext
size (ceil((rawsize + 1) / 16) * 16), while file_item.len is the plaintext size as a decimal string.
4. A getupdates client-side timeout is normal control flow, not a failure: the server holds the request
for its full window. Only real errors back off. -14 in ret or errcode means the token is stale:
re-pair. HTTP 403 means a second poller is using the same token — one consumer per token.
5. ctx.webServer prefix routes must be registered without a trailing slash. Its matcher tests
pathname.startsWith(prefix + "/"), so /x/ would look for a double slash and never match.
6. Tool parameters must not carry required: false. The harness rejects the schema and the whole plugin
fails to load; an optional parameter simply omits the field.
7. A link:-installed plugin cannot import "@deepseek-ai/…". Node resolves from the plugin's own directory, which has no such dependency; the Cordis loader can resolve it against the harness root
(ctx.loader.internal.import(name, baseUrl, {})).
8. A session created by a plugin needs agentPresets.mount and meta.agentPreset. Without the mount
the agent publishes against the empty global layer: no tools, no prompt, nothing it could act with.
9. The plugin-panel card needs a client module that registers the slots itself (plugins.bundle.config
keyed by package name, plugins.row.config keyed <package>#<row id>), wrapped in
window.__ModuleLoader__.load({ id, factory }). Exporting a schema is not enough, and a missing wrapper
fails the web boot.
What was actually verified / 真机验证过什么
A plugin README can claim anything; this is the list of things that were run while building it, with the numbers that
came back. Anything not on this list is covered by tests but not by a real device.
| Capability | How it was verified |
| WeChat text, files, images | Live account, two-way messages and file transfers into $DSH_HOME/wechat-inbox |
| WeCom / Feishu | Long connection connected; a real message answered (answer:"收到", 1721 ms) |
| VS Code editor bridge | An edit applied in the open folder: 46 delta frames over 8.5 s, app.js became 你好,世界 |
| Desktop control | Focus, click, screenshots of the DSH window on this machine |
| Clipboard | Chinese round-trip (剪贴板自检 中文 ✔), plus the file-list shape |
| SSH | ssh -V 56 ms; a named-but-absent port refused with the list of the present ones |
| Serial: STC | DS18B20_TM1650.hex written to an STC8H1K08 over COM3 - 3129 bytes loaded, 3648 written, Target UID: F734C3FC31F408 - then TEMP:27.6C read back |
| Serial: ESP-IDF | hello_world built through the same export.ps1 wrapper the flash uses (hello_world.bin, 175 kB, 83% partition free). The write itself needs the board |
| Scrapling | example.com 200; quotes.toscrape.com + span.text -> three items; baidu.com + links -> eight anchors; HN fails in 21 s without a proxy and answers in 1.8 s with one |
| Scrapling stealth | Real Chrome UA; nowsecure.nl (Cloudflare) -> 200 in 11 s with cloudflare: true |
| Office | Word/Excel/PowerPoint written, converted to PDF, rendered to PNG |
| Integrations | /inbound 401 without the token and 200 with it; webhook turn.end HMAC verified; dashboard screenshot |
| Multi-agent | /agent minimal on a WeCom chat, then /new: after the next message, GET /state reported conversations[].agentPreset = "minimal" and the reply arrived - the preset is applied when the agent is created, not just stored |
| Multi-agent: model | /agent model deepseek-account/deepseek-flash then /new: GET /state reported conversations[].agentModel = "deepseek-account/deepseek-flash" and the turn answered - and deepseek-official/deepseek-chat was shown to fail with no API key, which is why /agent model prints the host's own value |
| Multi-agent: swap model | /agent model deepseek-account/deepseek-v4-pro, then /new: GET /state reported conversations[].agentModel = "deepseek-account/deepseek-v4-pro" (not the default deepseek-flash) and the turn answered |
| Secrets at rest | A 32-character QQ secret became a 358-character dpapi:… value on disk, /state returned , and the bot stayed ready - so the seal neither breaks the connection nor leaks the value |
| QQ bot: private chat | Live bot PawDesk (appId 1903374412): C2C_MESSAGE_CREATE -> session -> reply through /v2/users/{openid}/messages; received 2 / forwarded 1 / answered 1 |
| QQ bot: group @ | GROUP_AT_MESSAGE_CREATE from a real group -> reply through /v2/groups/{group_openid}/messages; received 4 / forwarded 2 / answered 2, and the first two attempts were correctly blocked by the allowlist |
Release history / 版本历程
0.1.0 to 1.1.4 is 198 releases - this plugin was built in public, one verified change at a time. Grouped by what
each era added, with the releases that mattered most:
| Era | What it added |
| 0.1 – 0.29 | The bridge itself: QR pairing, one session per chat, media both ways, multiple accounts, the command set, and the card in the Plugins panel |
| 0.30 – 0.32 | Reliability and secrets: retries, monthly token accounting, and credentials on disk that are no longer readable credentials |
| 0.33 – 0.38 | Office documents from the chat, an office output folder, the first-run wizard, and Feishu scopes copied as the minimum payload |
| 0.39 – 0.47 | A browser a person can watch, a proxy for it, /doctor, proxy host lists, and a long run of crash fixes (a slow page could take the app down) |
| 0.48 – 0.56 | Flow automation, three browser sessions, the Chrome extension (own Chrome / attach / Edge), the debugging banner as a choice, and a visible click cursor |
| 0.57 – 0.67 | The VS Code bridge: ask from the editor, streaming answers, the open project as context, and finally the AI editing the folder it was opened on |
| 0.58 – 0.63 | The network layer: capture the JSON behind a page, turn it into rows, save it, and a Maps place collector that scrolls past the first screen |
| 0.68 – 0.69 | Integrations: POST /inbound, a signed outbound webhook, the dashboard - and the clipboard as two tools |
| 0.70 | SSH: ssh_exec, ssh_upload, ssh_download, key-based only, host key learnt once, off until /ssh on |
| 0.71 | Serial: list ports, flash (ESP-IDF \idf.py\, esptool, avrdude, STC via stcgal), and read the board back - verified on an STC8H1K08 |
| 0.72 | Scrapling, including a real-browser mode that drives the Chrome you already have |
| 1.0 | The stability statement: same features, winget-installed tools found, projectDir reaching idf.py, and the README's verification table |
| 1.1 | QQ bot over the open platform's WebSocket gateway - private chats and group @, verified against a live bot |
Each era ended with the same discipline: a release only goes out after the suite passes, and a claim only goes in the README
after something real produced the number.
Limitations / 已知限制
- Group sending is unverified but well-founded. Receiving from a group works today. Every reference
implementation addresses a chat through to_user_id alone — the official client, the Go library (daemon365/weixin-clawbot, whose Target holds only ToUserID and whose ChatType is inbound metadata hardcoded to direct) and the Python port (SiverKing/weixin-ClawBot-API, which has no chat_type field at all). This package sends the same way. What is not confirmed against the platform is which inbound field carries a group's addressable id — group_id or session_id; the host logs inbound group chat=… session_id=… group_id=… room=… when a group first writes, which settles it, and if the answer is session_id the fix is one line in describeEnvelope.
- Stickers and unknown item types are dropped, but visibly. A message that carries neither text nor a
downloadable file is counted (counters.unusable in /dsh-wechat-media/state) and logged with its item
types instead of vanishing silently.
- Voice is not bridged as audio. WeChat's own transcript rides along as text when present; the .silk
payload is downloaded but not transcoded.
- Proactive sends need an open conversation window. Without a stored context token for that peer the
server answers ret=-2; write to the bot first and the window opens.
- One poller per account token. Two processes using the same token get HTTP 403.
- No message history API exists on this platform; only live messages are seen.
- Media is capped at 100 MiB inbound by default (maxMediaMiB).
Feishu / 飞书
Nine additional tools work with Feishu documents, spreadsheets and bitables. All of them were built against shapes
measured from the live API:
| Tool | Does |
| feishu_doc_create | Creates a document and writes the body, then returns a link you can open |
| feishu_doc_read | Reads a document's text from a /docx/<id> link (or a bare id) |
| feishu_doc_append | Appends paragraphs to an existing document |
| feishu_sheet_create / _read / _write | Creates a spreadsheet, reads a range, writes a range |
| feishu_bitable_create / _write / _read | Creates a bitable, writes a record, reads records (by id to confirm a write) |
Document links need your tenant host. https://open.feishu.cn/docx/<id> is the API host and answers a generic
page, so the tools will not build a link from it. Set tenantHost (panel or /prefs?feishuTenantHost=…) or the FEISHU_TENANT_HOST environment variable to the host you see in any of your own Feishu document URLs, e.g. ccnje3t5tkro.feishu.cn. Without it the tool returns the document id and says why there is no link.
- Attachments are downloaded into the same inbox as the other two transports. An image, file, audio or video
message is fetched with GET /open-apis/im/v1/messages/:message_id/resources/:file_key?type=image|file and lands
beside the WeChat and WeCom arrivals, so the model gets a path it can open. The type parameter is required - the
live API answers 400 code 99992402 without it - and that was settled against the API rather than guessed. A fetch
that fails says so in the prompt ([飞书image未保存] …) instead of leaving the model to assume there was no attachment.
- delivery=stream sends in whole parts on Feishu. Word-by-word preview is a WeCom feature and is not implemented
here. /feishu states this instead of leaving the setting looking effective.
- Group messages are not filtered by whether the bot was mentioned, because the bot's own open_id is not
readable from the SDK source either. Groups behave like WeCom: messages in an allowlisted chat are handled.
- A long connection that cannot start does not break the plugin. The reason is recorded and shown by /state and
/feishu; the plugin keeps working for every other channel.
- The long connection has not been verified against the live platform yet. The endpoint, the request shape and the
error path were exercised for real (Feishu answered a handshake attempt with code 1000040345, and the message did
not leak the secret), and the protocol is pinned by tests against the official Frame/Header field numbers - but a
real inbound message still needs the console switched to "使用长连接接收事件", a published version and a valid App Secret. Until that first message arrives, treat this as built and tested, not as proven in production.
Security / 安全
- No secret is stored in this repository, and none is written into a cordis patch. The bot token lives in
$DSH_HOME/dsh-wechat-media/accounts/ (mode 0600 where the OS supports it).
- The plugin never puts the token in a URL. Traffic goes to the two WeChat hosts above and nowhere else.
- Secrets are sealed at rest. Everything sensitive in prefs.json - the Feishu app secret, the Tavily key, the WeCom
bot secret, the QQ client secret and the outbound webhook secret - is stored as dpapi:…, DPAPI ciphertext bound to the Windows account that wrote it. A copy of the file is worthless on another machine or account, and /state returns * for all of them rather than the value. A path added after a file was written is sealed on the next activation (sealExistingSecrets), and a value that cannot be opened says so instead of pretending the credential is gone.
- The card's HTTP routes refuse anything that does not arrive over loopback.
- Use a spare WeChat account if you can. Personal-account automation is at your own risk.
Cookbook / 实战文档
- Proxy and the host list / 代理与「走代理的域名」 — the 26-domain preset that says which sites go through a local proxy and which stay direct, applied with /browser hosts preset or one POST, plus the two boundaries people trip on: a PAC only governs a browser the plugin launched itself (an extension session uses your own Chrome's proxy), and an empty list means everything* goes through the proxy rather than nothing.
哪些网站走代理、哪些直连:一份 26 个域名的预设,一条命令套用;顺带说明两个边界 —— PAC 只对插件自己拉起的浏览器生效,列表为空其实是「全部走代理」而不是关闭。
- Turning a Word question bank into a Feishu bitable — the whole pipeline behind one real job: a 997-paragraph Word question bank (312 questions, no tables) parsed into structured data, deduplicated to 285, corrected in 64 places against the law and the ministries' own wording, uploaded to a Feishu bitable, verified field by field, and then used to answer questions inside the chat. It records the mistakes as well as the method: soft line breaks that shift paragraph numbers, short-answer blocks that defeat a "ends with ?" test, a bitable that ships with four default fields and no tool to add more, and absolute wording ("all", "only", "every") as the best detector of a question whose premise does not hold.
一份 Word 题库从解析、查重、并行事实复核、写入飞书多维表格到逐字段回读校验的完整记录,附带 9 个可复用的脚本骨架和这次踩过的坑。
Prior art / 相关工作
This is not the first WeChat bridge for dsh, and the protocol work stands on Tencent's own MIT-licensed
client. Credit where it is due:
- @tencent-weixin/openclaw-weixin (MIT, Tencent) — the authoritative implementation of the iLink bot protocol this package's ilink.js was ported from.
- BiBoyang/dsh-im-bridge — turn/approval push, in-chat approval, persistent dedup.
- Jesse-njx/dsh-chatnode-wechat — conversation-node routing and inbound image handling.
- zhengjy01/dsh-wechat-clawbot (and its upstream
lubaiUwU/DSH-WeChatClawBot) — the floating-ball QR panel and the gateway split.
Compared with those, this package concentrates on media: the others move text (one of them images) and
none send video, documents or archives. Pick one bridge and stay with it — two of them polling the same bot token will collide.
License / 许可
MIT — see LICENSE. The vendored QR encoder and the ported protocol client keep their own notices, listed in THIRD-PARTY.md.
WeChat and Weixin are trademarks of Tencent. This project is unofficial and is not affiliated with Tencent
or DeepSeek.预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
wjh-ux/dsh-wechat-media
给 DeepSeek Harnessdsh用的个人微信桥 ——…
相关插件 · 视觉能力
智能体工作台Devin-AXIS/iPolloWork
企业级、本地优先的智能体工作台,面向个人和智能体团队——一个工作区,容纳多个智能体引擎;一套统一体系,管理插件和技能;支…
★ 7,332已汉化DSH
72
开源设计工作台nexu-io/open-design
OpenDesign:首个协作式 AI 设计智能体工作区
★ 105,153已汉化DSH
71.8
多智能体编码工作区spacering-net/codeg
多智能体协作的 AI 编程工作区:汇总 Claude Code、Codex、OpenCode、Pi、Grok Build 等来源的会话。支持桌面应用、自托管服务器或 Docker。
★ 4,244已汉化DSH
71.6
GPT 图像提示词库freestylefly/awesome-gpt-image-2
Prompt as Code | GPT Image 2 / 2.5 提示词与案例库,530+ 个案例、20+…
★ 34,534已汉化DSH
71.5
架构图生成器tt-a1i/archify
把你想理解、规划或分享的事,变成可以互动的可视化作品。
★ 92,046已汉化DSH
71.1
AI 用量桌面小部件Javis603/token-monitor
EN | 简 | 繁 | KO | JA | PT
★ 3,480已汉化DSH
71