dsh-wechat-media
Personal WeChat for DeepSeek Harness (dsh) — scan a QR
code once, then talk to your agent from WeChat: one DSH conversation per contact or group, and two-way
text, images, video, documents and archives. Several WeChat accounts can be paired side by side.
给 DeepSeek Harness 的个人微信桥:扫一次码,之后就能在微信里和 agent 对话 —— 每个联系人/群各自 一个独立会话,文字、图片、视频、文档、压缩包双向互通,支持多个微信号同时在线(小号 + 大号)。
Current release: v1.24.13 · 52 test suites / 832 cases · every capability below has been exercised against a real service or real hardware at least once (see What was actually verified).
What it gives you / 它能做什么
| Capability | Detail |
|---|---|
| QR pairing | Cards in the Plugins panel. The login token is stored under $DSH_HOME, so restarting dsh does not need a new scan; re-pairing is one click. |
| One conversation per chat | Every WeChat contact (and group) gets its own DSH session with its own context, created or resumed automatically. The same holds for a WeCom or Feishu chat. |
| Text both ways | Your message becomes a turn; the answer is chunked back to WeChat. |
| Media both ways | Images, video, documents and archives. Inbound files are downloaded and decrypted to $DSH_HOME/wechat-inbox/<date>/<chat>/; the agent gets the path, so a text model can read images through read_image. Outbound files are uploaded and sent by the agent through its tools. |
| Multiple accounts | Pair a spare account and your main account; each keeps its own token, cursor and session map. |
| WeCom (企业微信) | Group-robot webhook, a self-built app, or the intelligent-robot long connection (receiving without a public address); streaming previews work on this channel. |
| Feishu (飞书 / Lark) | Custom webhook bot or app bot with a long connection; documents, spreadsheets and bitables through tools - and, as you, knowledge bases (知识库) plus the official CLI's whole 200+ command surface (calendar, mail, tasks, meetings, OKR, approval…), because an app is a member of nothing. |
| Editor bridge | A VS Code extension: ask from the panel, stream the answer, let the AI edit the folder you have open (/dsh-vscode/*, with a 可写 switch) - and flash the board from the editor, because the turn runs in that folder and the serial tools take it from there. |
| Automation tools | Browser automation over the DevTools protocol (own profile, your Chrome, or the extension), Office read/write/convert, desktop control, clipboard, SSH, serial flashing (ESP-IDF / esptool / avrdude / STC via stcgal), web search, scheduled tasks, and an RPA-style flow runner. |
| Scraping | Scrapling: browser-shaped TLS fingerprint and headers, so a page that rejects a plain fetch (403, a Cloudflare challenge, a UA check) can still be read - CSS selectors, link extraction, per-call proxy, and a real-browser mode that drives the Chrome you already have (no 1.25 GB download) with Cloudflare solving. |
| Lead collection & outreach | collect_leads turns a goal and a market (de, us, ae…) into a scored, de-duplicated list with contacts, reading the page rather than trusting the search snippet, and telling a buyer from a seller. outreach_plan / outreach_send turn that list into messages over email, WeChat or WeCom - with a cooldown per address, a human confirmation slot, and a stop at the first hard channel failure. |
| Embedded build | Keil uVision5 through the UV4.exe already installed: keil_build runs build / rebuild / clean / flash, and returns every error as file(line): error C141: message plus the Program Size and the .hex - so a failed build says where. The same module also installs a Tools-menu entry, so the same build is one click inside uVision. |
| Firmware: any board | project_build reads the folder and picks the toolchain: .uvprojx → Keil (C51 through UV4, or MDK-ARM), sdkconfig → idf.py, CMakeLists.txt / Makefile → cmake --build / make with arm-none-eabi-gcc. Flashing follows the same logic: stcgal for an STC part, UV4 -f for an MDK project, openocd / st-flash for an ELF, and flasher="uart" (stm32loader) for a board with no debugger at all. toolchain and flasher override the guess. |
| Multi-agent | With one switch, each conversation can mount its own agent preset - its own prompt, tools and model wiring - instead of every chat sharing the host default. Off by default; /multiagent on, then /agent <preset> per chat. |
| Integrations | POST /inbound lets a NAS/CI/monitor speak into a conversation, an outbound webhook posts every finished turn (HMAC-signed), and /dashboard shows sessions, failures and token spend. |
| Persistence | Accounts, receive cursor, per-peer context tokens, chat→session routes, allowlist and preferences all live under $DSH_HOME/dsh-wechat-media/. |
Requirements / 环境要求
- dsh 0.2.0-rc.2 or newer with the standard agent loop and tools (the shipped
dsh web/ desktop profiles qualify), Node.js >= 22 (Node 24 tested), Windows / macOS / Linux. - A WeChat account that can scan a QR code. A spare account is strongly recommended: personal WeChat automation is against WeChat's terms of service and carries a ban risk, whichever library you use.
- Outbound access to
ilinkai.weixin.qq.comandnovac2c.cdn.weixin.qq.com. - Optional, for firmware work: Keil (C51 and/or MDK-ARM) for
.uvprojxprojects; ESP-IDF forsdkconfigones;arm-none-eabi-gcc+cmake+ninjafor CMake/Make projects;openocdorst-flashto download them over SWD; andpythonwithpyserial+stm32loaderfor a board that only has a UART. Each one is detected by path, so a portable install under%USERPROFILE%\tools\<pkg>\binis found even by a long-running process that never re-read the PATH. - Optional, for the "act as you" half of Feishu: the official
lark-cli(npx @larksuite/cli@latest install, thenlark-cli config init --newandlark-cli auth login --recommendonce). Without it the app-identity tools (messaging, docs, sheets, bitables) work normally, and the knowledge-base tools andfeishu_clisay what is missing instead of failing obscurely.
Install
On a fresh Windows machine, one script does the environment and the plugin:
# 先拿脚本。jsDelivr 这条一般最快;GitHub 原地址有时会断流,见 docs/deploy.md 第零节
irm https://cdn.jsdelivr.net/gh/wjh-ux/dsh-wechat-media@v1.24.13/scripts/deploy-windows.ps1 -OutFile deploy.ps1
# 体检:只看缺什么,不改任何东西
.\deploy.ps1 -Version # 先问一句:这是哪一版
.\deploy.ps1 -CheckOnly
# 部署,十一步依次是
# 1 前置(Node.js 缺则装 / DSH profile) 2 Python 本体(winget 或官方安装包)
# 3 Python 包(pyserial / stcgal / stm32loader,并把 pip 的 Scripts 加进 PATH)
# 4 VS Code + ms-python.python 扩展 5 CH340 串口驱动(验签后静默装)
# 6 arduino-cli + 核心 7 arm-none-eabi-gcc / cmake / ninja / openocd
# 8 Keil 与 ESP-IDF(只检测) 9 插件装进 profile
# 10 插件自带的 VS Code 扩展(vsix) 11 自检(真编一个 STM32 固件 + 冒烟测试)
.\deploy.ps1
# 只要插件(环境已就绪)
.\deploy.ps1 -SkipTools -LocalPath D:\src\dsh-wechat-media
# GitHub 慢 / 完全离线:用已经下好的 zip
.\deploy.ps1 -ToolsArchiveDir D:\downloads\tools
It is safe to re-run: installed tools are skipped, an existing package.json is backed up before it is merged, and a
node_modules link that points somewhere else is replaced while a real directory is refused rather than deleted.
Verification is never "the file exists" - every tool is run (--version), and the last step asks the plugin itself
(scripts/probe-tools.mjs calls the same which() / serialSupport() the plugin uses at runtime) what it can actually
reach, then runs the smoke test.
完整说明(先决条件、十步各做什么、虚拟机怎么装、离线模式、卸载、排错):docs/deploy.md。 仓库里直接有 PDF:docs/deploy.pdf。要自己重新生成:
python scripts/md-to-docx.py docs/deploy.md deploy.docx # 需要 python-docx
# 再用 Word / LibreOffice 导出 PDF,或 office_convert deploy.docx
What it cannot install for you, and why:
| 原因 | |
|---|---|
| Keil (C51 / MDK-ARM) | 要注册账号下载,且许可按商业/非商业区分 - 见 docs/keil-arm-install.md(含直链、SHA256、四个坑) |
| ESP-IDF | Espressif 的安装器要选目标芯片与组件;检测已有安装,缺了只提示 |
| DSH 本身 | 插件是 DSH 的扩展,不是独立程序 |
Machine without the script? The three things it does are small enough to do by hand: put the plugin's checkout on disk,
link it as <profile>\node_modules\dsh-wechat-media, and add "dsh-wechat-media": "link:<path>" to the profile's
package.json plus dsh-wechat-media to dsh.profile.bundles.
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
Restart dsh, then open Plugins → dsh-wechat-media and press Add an account (QR code).
重启 dsh,然后在 插件 → dsh-wechat-media 点「添加账号(扫码)」。
Host key verification failed / Could not read from remote repository on install. pnpm resolves a github: spec
over SSH (git+ssh://git@github.com/...), so a machine without GitHub's host key in known_hosts — and without an
SSH key that can read the repo — cannot install it, even though the repository is public. Two ways out:
# 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 / 配对
- The card shows a QR code (it polls its own status and refreshes an expired code automatically).
- Scan it with WeChat on the phone, confirm on the phone.
- If WeChat asks for digits, type them into the card and submit.
- The account appears with a status dot:
monitoringmeans the long poll is running. - Add an account (QR code) stays available at all times — scanning a different WeChat account adds it as another account, so a spare and your main account can run side by side. Scanning the account you already have simply refreshes its login. Each row also has Pair again and Unpair.
- Opened the QR by mistake? Press Cancel — the attempt disappears immediately instead of waiting for the code to expire. (A QR lives five minutes; an attempt gives up after eight, and at most three QR codes are issued per attempt.)
The QR encodes the landing-page URL the platform hands out — scanning it is what completes the binding.
Each account is stored under its normalized ilink_bot_id (b0f5860fdecb@im.bot → b0f5860fdecb-im-bot),
with 0600 permissions on the files that hold a token; accounts, cursors and conversation routes are keyed
per account, so several can poll at the same time.
Where inbound files go / 收件目录
By default inbound media is decrypted into $DSH_HOME/wechat-inbox/<date>/<chat>/. That directory is
configurable, and the change takes effect without a restart:
- From the card - the field under Inbound files are saved in submits a plain GET form (a navigation, not a fetch), so it works even in browsers where the card's requests never reach the plugin.
- From the control panel -
/dsh-wechat-media/panelhas the same field and a restore default button. - With the system folder dialog - Browse… / 浏览… (card and control panel) or
GET /dsh-wechat-media/pick-folderopens Windows' own picker on the desktop and saves the choice. A browser cannot hand over an absolute path (a directory input only exposes relative names), which is why the dialog is opened by the host (powershell -STA) rather than in the page. - From the plugin config -
inboxDir: "D:\\wechat-files"in the plugin options, used when no runtime preference is set.
The order is preference (card/panel) → config → $DSH_HOME/wechat-inbox. Only an absolute path is accepted: a
relative one is refused with a message instead of being resolved against the process working directory, because a
silently different directory is worse than the documented default. GET /dsh-wechat-media/state reports the
effective value as inboxRoot, with inboxRootDefault and inboxRootConfig for comparison.
Using it / 使用
Just message the bot from WeChat. A few things the agent can do from inside the conversation:
- answer through the normal reply channel (the bridge relays its final text automatically);
- send extra messages or files with its tools:
| Tool | Purpose |
|---|---|
wechat_send_text |
An extra text message, or a message to a different chat (to), pre-empting the automatic relay for that turn. |
wechat_send_file |
Send a local file back to WeChat. Images (.jpg/.png/.webp/.gif) travel as pictures, videos (.mp4/.mov) as videos, everything else (pdf, docx, xlsx, zip, 7z, …) as a file attachment. |
wechat_status |
Which accounts are paired and which conversations exist. |
Every incoming message is prefixed with [微信 <name> <wxid>] so the agent can pass the right to when it
answers a specific chat. Group ids look like 12345678@chatroom.
Remote approval / 微信里远程审批
When a WeChat-driven session asks for approval — a tool call the permission preset wants confirmed — the prompt goes to the same WeChat chat, not only to the GUI:
⚠️ 需要你批准一次操作(远程审批)
工具:pwsh
原因:write outside the workspace
回 1 = 批准一次 · 回 2 = 拒绝
Answer 1 / y / 同意 / 批准 / /approve to allow that one call, or 2 / n / 拒绝 / /reject to
refuse it. The answer is consumed — it never becomes a new turn. If nothing arrives within
approvalTimeoutSec (default 120 s), or the prompt cannot be delivered, or the turn is aborted, the request is
handed back to the GUI, so an approval is never silently dropped. Set approval: "ignore" in the plugin config
to keep approvals in the GUI only.
Contact allowlist / 联系人白名单
Every chat that writes to the bot is remembered, and the card lists them under Contacts & groups / 联系人 / 群, with Allow / Ignore / Clear per row and an Only accept allowlisted chats switch.
- The switch is off by default — otherwise a freshly paired account would look broken to everyone who writes.
- With it on, only chats marked Allow reach the agent; Ignore and undecided chats are dropped and logged.
- A decision takes effect immediately (no restart) and is stored in
peers.jsonbeside the accounts.
Friends only / 只处理好友私聊
Only answer friends (1:1 chats) — the card's second checkbox, or /friends on from a chat. The protocol
carries no "is a friend" flag, so friends means 1:1 chats: when the switch is on, every group message
is dropped, whatever the allowlist says about that group. Direct chats are unaffected.
- Off by default: an upgrade must not change who the bot answers.
- Group messages are ignored silently (that is the point) but never invisibly: each one bumps
counters.groupsSkipped,/statusprints已静默忽略群消息 N 条, and the card shows the same number. - It is one preference (
prefs.json), so the checkbox and/friendscan never disagree, and it applies to personal WeChat and WeCom alike. - Switching it on while a group is waiting answers once in that group to explain the coming silence. Turning it off works from a 1:1 chat or the card — a group message is skipped before it could be read as a command, which is the honest consequence of "silent".
WeCom (企业微信) / 企业微信
WeCom works alongside the personal-WeChat bridge, outbound through two paths and inbound through a third:
| Path | What you need | Direction |
|---|---|---|
| Group robot webhook | one webhook URL from a WeCom group (群设置 → 群机器人 → 添加) | out: text, markdown, images |
| Self-built app | corpid + corpsecret + agentid of an app (应用管理 → 自建) | out: text, markdown, image, file, voice, video |
| Intelligent robot, long connection | the robot's BotID + Secret (智能机器人 → API 模式 → 长连接) | both: messages in, answers back in the same chat |
Configure any of them on the plain page http://127.0.0.1:19387/dsh-wechat-media/wecom (also linked from the card
and the control panel), which is where you send a test message from as well. The long connection needs no public
address: the plugin opens a WebSocket to wss://openws.work.weixin.qq.com and answers each message through the
frame the platform sent, so a WeCom chat becomes an ordinary DSH conversation - the same session machinery,
allowlist, approvals and reply relay as personal WeChat. Approval requests reach a WeCom chat as a proactive
long-connection message and are answered there with 1 (allow once) or 2 (reject); anything else stays in the GUI,
exactly as on the personal-WeChat side. The agent gets two extra tools:
| Tool | Purpose |
|---|---|
wecom_send_text |
Send text or markdown through the configured robot, long connection or app (to overrides the recipients). |
wecom_send_file |
Send a local file as image / file / voice / video. Over the long connection (or through a self-built app) any type works; with only a group-robot webhook, images do. |
Files travel the other way too: a file sent to the robot is uploaded as a temporary WeCom asset over the long
connection (init -> 512 KiB chunks -> finish -> media_id) and then sent, so no app credentials are needed. Uploads
are capped at 100 chunks (~50 MB) and each chunk is retried twice, which is what the platform tolerates; a failure is
reported back instead of being swallowed.
Settings live in $DSH_HOME/dsh-wechat-media/wecom.json with mode 0600 - they contain a webhook key, a corpsecret
and the robot secret, so they never go into prefs.json or a cordis patch. GET /dsh-wechat-media/state reports
whether a secret is set, never its value.
What stays impossible is the callback API. It insists on a public HTTPS URL that the enterprise registers, so a bridge running on your own machine cannot receive that way; the long connection above is what makes two-way WeCom possible without one.
Media arrives too. Images, files and videos sent to the robot are downloaded, decrypted and written to the same
inbox the WeChat side uses ($DSH_HOME/wechat-inbox/<date>/<chat>/), then handed to the model as a path plus a
[企业微信图片] …用 read_image 查看 hint, so a text-only model with a vision plugin can actually look at them. The
scheme is the official one for the long connection (@wecom/aibot-node-sdk): aeskey is base64 of a 32-byte AES-256
key, the bytes are AES-256-CBC with the iv taken from the key's first 16 bytes, PKCS#7 padded to 32 bytes, and the
file name comes from Content-Disposition because the payload carries none. Note this is not the personal-WeChat CDN
scheme (AES-128-ECB). A download that fails says so in the chat instead of dropping the attachment silently, and
/state counts both outcomes (counters.mediaDownloaded / counters.mediaFailed).
Feishu (飞书 / Lark) / 飞书机器人
Two kinds of Feishu bot exist, and both are supported, because they solve different problems.
| Custom webhook bot | App bot | |
|---|---|---|
| Credentials | the webhook URL (and its signing secret, if set) | app_id + app_secret |
| Can send | text and rich text | text, rich text, images, files, audio |
| Can receive | no | yes - over the long connection, with no public URL |
| Config | feishu.webhookUrl, feishu.secret |
feishu.appId, feishu.appSecret |
Feishu shows up in three layers, and the layer decides what a tool can reach:
| Layer | Identity | Reaches |
|---|---|---|
feishu_send_*, feishu_doc_*, feishu_sheet_*, feishu_bitable_* |
the app (tenant_access_token) |
messages, its own documents/sheets/bitables |
feishu_wiki_* |
the person (user_access_token via lark-cli) |
your knowledge bases, written as you |
feishu_cli |
the person | everything else the official CLI can do - 200+ commands, 18 domains |
The first layer needs nothing but an app; the last two need lark-cli authorised once (see Requirements).
Configuring it from the panel / 在面板里配置(不用 setx,不用重启)
The Web UI panel has a Feishu block: App ID, App Secret, the custom-webhook URL and its signing secret, the
platform (feishu / lark), a default receive id, and the long-connection switch. Saving reconnects immediately -
there is no setx and no DSH restart, which used to be the only way in.
Values are resolved in this order, so a field the panel leaves alone keeps whatever config or environment provides:
- what the panel saved (
prefs.feishu), config.feishuin the plugin config,- the environment:
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.
Two rules make that safe:
- A blank field is never sent by the panel's Save button, so saving something else cannot wipe a stored secret. Clearing is a separate, deliberate action (the Clear button sends explicit empties).
- Secrets never come back.
/stateand/prefsreport whether a secret is set, never its value - one redaction helper covers every response that carries prefs (a test found the leak that made this rule necessary).
feishu.domain is feishu (mainland China) or lark (international); an explicit URL also works. /state reports
savedFields and, per field, whether the value in force came from the panel, the config or the environment.
Tools, in one place: feishu_send_text (text or markdown, to + toType optional - chat_id by default,
open_id/user_id/email also accepted) and feishu_send_file (path, kind = file/image/audio/video) for talking;
feishu_doc_create / feishu_doc_read / feishu_doc_append for documents; feishu_sheet_create / feishu_sheet_read /
feishu_sheet_write for spreadsheets; feishu_bitable_create / feishu_bitable_write / feishu_bitable_read for
bitables; feishu_wiki_spaces / feishu_wiki_new / feishu_wiki_upload for knowledge bases (see below - those three
speak as you, the rest speak as the app); and feishu_cli / feishu_cli_help, which reach the entire official CLI -
every other Feishu feature, as you. A send always says which channel it used and gives the message_id, so "sent" is
not a guess.
A webhook-only setup cannot send files, and says exactly that rather than reporting a success that never arrived -
the custom bot API has no file message at all. Configure appId/appSecret for that.
/feishu (/飞书, /lark) reports what is configured - never the secret itself, only whether one is set - and
/feishu test [id] [type] sends a self-test.
Knowledge base (知识库): written as you, not as the app / 知识库:以「你本人」的身份写入
Every feishu_* tool speaks with a tenant_access_token, which is the application's identity. Feishu treats an app as a principal of its own: it owns its own cloud space, it is a member of no knowledge base, and nothing of a person's is visible to it. Measured on a real tenant:
- a document created with the app token lands in the app's private drive space, where no person can open it;
- a 知识库 refuses writes from a non-member, so
move_docs_to_wikicannot work at all; - adding an app to a space is an administrator action most individuals cannot perform.
So writes that must land in your space go through the official larksuite/cli instead, which holds a user_access_token - the same calls run as you, with your knowledge bases, folders and permissions already in scope. It is a different principal, not a different API.
| Tool | What it does |
|---|---|
feishu_wiki_spaces |
What this account can write to, and whether the user identity is authorised yet |
feishu_wiki_new |
Create a knowledge base |
feishu_wiki_upload |
One file, or a whole local folder tree: one node per top-level folder, skipping nodes and files already present, so a retry never duplicates |
One-time setup. The plugin stores no Feishu credentials of its own - lark-cli keeps them in the OS keychain, and lark.js only locates the binary, runs one command and parses the JSON it prints:
npx @larksuite/cli@latest install
lark-cli config init --new # creates a CLI-only app in your tenant; two clicks in the browser
lark-cli auth login --recommend # scan once - this is the "as you" identity
Uploads are idempotent by design: re-running feishu_wiki_upload over the same folder adds only what is missing, so a half-finished run is retried rather than duplicated. Lesson folders are flattened into their chapter on purpose - a course download of 5 chapter folders / 14 lessons / 49 documents becomes five nodes you can open, not a three-level tree you have to click through.
These tools complement the rest rather than replace them: sending messages, reading documents and editing sheets work fine as the app. Anything that must be owned by a person goes through the three above.
The whole lark-cli surface / 全部 lark-cli 能力
The dedicated feishu_* tools are the common paths, not the ceiling. The official CLI ships 200+ commands across 18 domains - messaging, docs, base, sheets, slides, calendar, mail, tasks, meetings, minutes, wiki, OKR, approval, attendance, contacts, drive, apps, markdown - and two tools make all of it reachable:
| Tool | What it does |
|---|---|
feishu_cli_help |
Prints the CLI's own help: no argument for the domain list, docs for a domain, wiki +node-create for one command's flags. This is how a command is discovered rather than guessed. |
feishu_cli |
Runs any command as the person who authorised the CLI, e.g. args="calendar +agenda" or args="docs +search --query 周报" or args="task +get-my-tasks". --json is added automatically; raw=true returns text. |
Why a bridge and not 200 tools: 200 schemas would ride in every prompt, and the list would be wrong the first time lark-cli ships a domain. The CLI stays the authority on what exists; the plugin only adds the identity, the error envelope and the guard rails.
Three behaviours worth knowing:
- No shell anywhere. The command line is tokenised in-process and passed as argv, so a title with a space, an
&or an empty string reaches the CLI exactly as typed. - Interactive commands are refused, not run:
auth login,config init/removeand friends wait on a browser round-trip, which inside a tool call is a hang until the timeout. Run those once in a terminal. - Destructive commands need a second word. Anything carrying
--yes(deletes) is refused unlessconfirmDanger: trueis passed, because a model that types--yesis not the same thing as a person who agreed to it.
Every endpoint and algorithm here was read from the official @larksuiteoapi/node-sdk 1.75.0 source, not recalled:
messages are POST /open-apis/im/v1/messages?receive_id_type=…, the token is
POST /open-apis/auth/v3/tenant_access_token/internal, media is POST /open-apis/im/v1/images and /files, an event's
AES key is sha256(encryptKey) with the first 16 bytes of the ciphertext as the IV, and the event signature is
sha256(timestamp + nonce + encryptKey + body) in hex (sha1 with the verification token when the event is
unencrypted). The custom-webhook signature is the trap of that API - HMAC-SHA256(key = timestamp + "\n" + secret, data = ""), base64 - and it is checked against an independent implementation's published test vector.
QQ bot (QQ 开放平台) / QQ 机器人
An official QQ bot, over the open platform's WebSocket gateway - outbound only, so it works from a plugin on your own machine with no public URL, exactly like the WeCom and Feishu long connections.
/qq # switch, credentials presence, connection state, last error
/qq on | off # connect / disconnect
Credentials go in the card's QQ block (or POST /prefs with {"qq":{...}}): appId, clientSecret, and sandbox for a
sandbox bot (which also uses a different API host). The secret is stored, never echoed back.
Three details that decide whether a QQ bot works at all, all handled here:
- The reply must quote the message it answers.
sendMessagesends the inboundidasmsg_id(plus amsg_seq), or the platform treats it as a proactive message - a different permission, usually a rejection. - C2C and group chats are different endpoints:
/v2/users/{user_openid}/messagesvs/v2/groups/{group_openid}/messages, and the openid comes from the event, not from anything you can guess. - The intent matters: group and C2C messages are gated behind
1 << 25; asking for more than the bot was granted is what makes an identify fail with 4014.
Verified against a live bot (appId 1903374412, display name PawDesk): the gateway answered READY, a private
chat message produced an answer through /v2/users/{openid}/messages, and an @ in a real group produced one through
/v2/groups/{group_openid}/messages. The allowlist blocked both the first private message and the first group message until
the chat was allowed - which is what it is for, and it is visible in the counters (blocked) rather than silent.
Multi-agent: one agent per conversation / 多 agent
Every conversation already had its own session and its own agent object; what this switch changes is what that agent mounts. With it on, a chat can mount its own agent preset - its own prompt, tools and model wiring - remembered on the peer record. With it off (the default) every chat mounts the host default, exactly as before.
/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> pins the model for that one conversation, and /agent model reset clears it.
With nothing set, the chat uses the host's own selection - there is no built-in default override, because one would move
every conversation onto a model nobody chose and show up as a billing surprise rather than a setting. The override obeys the
same switch as the presets, and /state's conversations[].agentModel proves which model a chat actually asked for.
`/agent model` with no argument prints the host's own selection, because that value is known to work - change the model half and keep the provider half. Switching provider is warned about when it is set, since the new route needs its own API key.
/agent model list asks the host's llm service and prints what is actually selectable. On this machine it reports two
providers with the same two models:
deepseek-official deepseek-account · 宿主在用
· deepseek-flash · deepseek-flash ← 宿主默认
· deepseek-v4-pro · deepseek-v4-pro
So the safe edit is a model name inside the provider the host already uses (deepseek-account/deepseek-v4-pro); the other
provider route needs its own DEEPSEEK_API_KEY.
On this machine the host's own selection is deepseek-account/deepseek-flash, and that is the value to copy: the provider
half is the part that carries credentials. Pinning deepseek-official/deepseek-chat instead fails with
no API key for provider route "deepseek-official" - a different provider route is a different account, not a different
model name. /agent model prints the host's value for exactly that reason, and switching provider is warned about when it is
set.
`/agent model list` asks the host's own `llm` service for every provider and its models and marks the one in use, so a model name never has to be copied from the Models page or guessed; `/agent model list ` narrows it.
Three statements the commands make rather than hide:
- A preset change is not a live swap. It applies when the session next creates an agent, and the reply says so.
- With the switch off,
/agent <id>refuses to look like it worked. It names the switch that is off, because storing a value that will never be read is worse than declining. - The preset list is read defensively (
listorall, ids or names). If a host exposes neither, setting by id still works but stays unverified until an agent is built - and the command prints that sentence itself.
The card carries the matching checkbox: 每个会话可挂自己的 agent 预设.
On this machine the host declares four presets - standard, ptc, minimal, cordis - and /agent minimal followed by
/new put "minimal" into GET /state's conversations[].agentPreset once the next message created the agent. That is the
whole point of the switch: two conversations can be running different presets at the same time, and the selection
survives restarts because it lives on the peer record.
Receiving: the long connection / 用长连接接收(不需要公网地址)
An app bot receives events over Feishu's long connection - a WebSocket the bot opens itself - so no public URL, no tunnel and no port forwarding are needed. The event route (POSTing events to a URL Feishu calls) is deliberately not implemented: it is the option that requires a public address.
Turn it on in the Feishu console, then restart DSH:
| Step | Where |
|---|---|
| Enable the bot | 开发者后台 → 你的应用 → 应用功能 → 机器人 → 启用 |
| Subscribe over the long connection | 开发者后台 → 事件与回调 → 订阅方式 → 使用长连接接收事件 |
| Permissions | at least im:message (send and receive); add the receive scopes the console asks for |
| Publish | 创建版本并发布 - changes do not take effect until a version is published |
Configuration: feishu.longConnection (default on; feishu.longConnection = false or FEISHU_LONG_CONNECTION=false
turns it off). It only connects when feishu.appId and feishu.appSecret are configured - a webhook-only setup has
nothing to connect with, and says so.
What happens to an inbound message: text and rich-text messages become a prompt for the model, prefixed like the other
channels; the answer is posted as a reply under the question (message_id), falling back to an ordinary send if the
reply is refused, and long answers are chunked. Two things are refused on purpose: the bot ignores its own messages
(sender_type: "app"), because answering them would wake itself in a loop, and a re-delivered event is answered only
once.
The protocol facts were read from the official SDK, not recalled - the handshake is POST {domain}/callback/ws/endpoint
(request {AppID, AppSecret}, response {code, data:{URL, ClientConfig}}, whose keys are capitalised and whose
intervals are in seconds), the frames are protobuf in the pbbp2 namespace (Frame 1 SeqID / 2 LogID /
3 service / 4 method / 5 headers / 6 payloadEncoding / 7 payloadType / 8 payload / 9 LogIDNew, Header
1 key / 2 value), an event is acknowledged by sending the received frame back with a biz_rt header and
{"code":200}, and a fragmented event is reassembled from message_id/sum/seq/trace_id with malformed metadata
rejected rather than silently corrupting the payload.
GET /state carries a feishu block (configuration, connection state, lastError, lastEventAt and the counters
feishuEvents / feishuAnswered / feishuIgnored / feishuErrors), and /feishu shows the same in a chat, including
why the long connection is not up when it is not.
Scheduled tasks / 定时任务
A task is a sentence plus a chat: it fires on its own and sends its result there. Three actions, four triggers, and one switch for the dangerous action:
/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)
| Action | What happens at the fire time |
|---|---|
message |
sends fixed text - no model call, no tokens |
prompt |
runs one ordinary agent turn in that chat and delivers the answer |
command |
runs a local command and sends its output (needs /task command on) |
Triggers accept English and Chinese interchangeably: daily 08:30 / 每天 08:30, weekly mon 09:00 / 每周一 09:00,
every 30m / 每 30 分钟, monthly 15 09:00 / 每月15日 09:00 (clamped to short months), once 2026-10-20 09:00. Tasks live in tasks.json under $DSH_HOME/dsh-wechat-media/,
separate from prefs; a task that has run once never fires again for the same window, and a once task switches
itself off afterwards.
Ask for one in a chat - "每天 8 点提醒我喝水" - and the agent creates it with task_add, targeting the chat that
asked. The three tools are the same three operations the /task commands type: task_add (trigger + action + text),
task_list (next run time and last result), task_remove (add disableOnly to pause instead of delete); /state
reports tasks: { count, enabled, nextAt, commandEnabled, ran, failed, items }.
What people actually schedule / 实际用法
| You want | The schedule | Action |
|---|---|---|
| A weather line before work | 每天 07:30 |
prompt: "用一句话说今天厦门天气,再给一句提醒" |
| A digest of a group at noon | daily 12:00 |
prompt: "总结今天群里值得看的消息,五条以内" |
| A weekly report on Monday | 每周一 09:00 |
prompt: "汇总上周完成的事,写成三条" |
| A monthly reminder | 每月1日 10:00 |
message: "该交房租了" |
| A one-off, then again next week | once 2026-10-20 09:00 + [+1周] [再次运行] |
any |
| Watch a folder every half hour | every 30m |
command (needs /task command on) |
prompt is the interesting one: it runs a normal agent turn, so every tool the agent has is available to it -
web search, files, Feishu. A scheduled prompt is therefore "ask me this every morning and do the work", not a
canned message.
The card / 面板
The Web UI card is the management half; creation works both there and in a chat.
| Control | When it appears | What it does |
|---|---|---|
time + action + text + 添加 |
always | create a task targeting the chat that last wrote |
[暂停] / [恢复] |
recurring tasks | stop and restart the schedule |
[+1 天 ▾] [再次运行] |
finished one-offs | move it forward one day / week / month and re-enable |
☐ 完成后再排一次 |
one-offs | re-arm automatically after each run (same clock time, next day) |
[删除] |
always | remove it |
☐ 允许 command 任务执行本机命令 |
always | the switch behind command tasks (default off) |
A finished one-off shows ✅ 已跑完(一次性任务,不能暂停) rather than a pause button that would do nothing: dueTasks
compares the last run against that occurrence's atMs, so re-arming is meaningful and a plain resume is not.
Two more behaviours worth knowing:
- Missed runs are skipped, not replayed. A task more than 60 minutes late (a laptop that slept, a bridge that was
stopped) is recorded as missed and the schedule moves on - the alternative is every missed
prompttask firing in the same second when the machine wakes.taskGraceMinutesin prefs tunes it;0means "always run, however late"./stateshowstasks.skippednext toranandfailed. - Any chat can be targeted.
task_addtakestransport(wechat/wecom/feishu) withto, so a task can be created for a group you have not just spoken to; without it, the default is still "the chat that asked".
Office documents / 办公文档
Four tools read, create and convert .docx / .xlsx / .pptx, and export PDF or page images. They are a thin,
honest surface over two programs already on this machine - the runtime's own 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./statereportsdesktop: { 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.FormsandSystem.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 offis 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 makesSetText/GetTextreliable from a service-like process. macOS usespbcopy/pbpaste, Linuxwl-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
?, becausepowershell.exewrites 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:nowould accept a man-in-the-middle,yeswould 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.
Firmware: Keil, ESP-IDF or CMake — pick by folder, override when you disagree / 固件编译与烧录
project_build is the one entry for three families. It reads what is actually in the folder and prints what it found;
toolchain and flasher override the guess, because detection is a default and not a verdict.
| Folder says | Build | Flash |
|---|---|---|
.uvprojx + <Vendor>STC</Vendor> |
UV4 -b |
stcgal (serial ISP - uVision has no driver for it) |
.uvprojx + ARM (MDK installed) |
UV4 -b |
UV4 -f through the project's own ST-Link/J-Link |
sdkconfig (or CMakeLists.txt + main/) |
idf.py -C <dir> build |
idf.py -C <dir> -p COMx flash (builds first) |
CMakeLists.txt |
cmake --build build |
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program x.elf verify reset exit" |
Makefile |
make -C <dir> -j |
same as CMake; st-flash write x.bin 0x8000000 when stlink-tools is what is installed |
| any of the above, no debugger at all | as above | flasher="uart" → python -m stm32loader -p COMx -b 115200 -f F1 -e -w x.bin -v, with BOOT0=1 and a reset first |
.ino (Arduino sketch) |
arduino-cli compile --fqbn arduino:avr:uno <dir> |
arduino-cli compile --fqbn … --upload -p COMx <dir> (one command: a bare upload needs a sketch compiled first) |
project_build(dir="D:\\work\\my_f4", action="build")
project_build(action="flash", flasher="openocd", target="stm32f4")
project_build(dir="D:\\work\\my_esp32", action="flash", port="COM3")
/prefs takes a build block ({"toolchain":"idf","flasher":"openocd","dir":"…"}) and an unknown value is refused
rather than silently stored as auto, so "which compiler does this use" has one answer a script can read.
Arduino is the fourth family, and it needs one fact the others do not: the board. arduino-cli names a board with an
FQBN (arduino:avr:uno), and which FQBN applies is read from, in order: sketch.yaml (default_fqbn:, which is what
arduino-cli sketch new writes), the board argument, or arduino-cli board list --format json - the last one only
answers for a board that is actually plugged in, which is exactly right for upload and useless for compile. A sketch
is recognised the way arduino-cli recognises one: a folder containing a .ino of the same name.
Verified here without any board: the same Blink sketch compiled for two boards - arduino:avr:uno → Sketch uses 924 bytes (2%) and esp32:esp32:esp32 → Sketch uses 282984 bytes (21%) - with the AVR core (arduino:avr@1.8.8)
installed by this README's instructions and the ESP32 core that was already on the machine. The upload leg is the same
arduino-cli upload call and needs a board on a COM port.
Install notes are in docs/keil-arm-install.md: the download page and its direct link, the installer's size and SHA256, the device pack URL, the licence activation, and the four traps this install produced.
ESP-IDF, on the same board (v1.19.6): an unmodified examples/get-started/hello_world, copied out of the IDF tree.
project_build(action="build") → Project build complete in 162 s; action="flash", port="COM6", baud=115200 →
Hash of data verified three times, Hard resetting via RTS pin. The board then printed its own banner:
Hello world!
This is esp32 chip with 2 CPU core(s), WiFi/BT/BLE, silicon revision v3.1, 2MB external flash
Minimum free heap size: 300936 bytes
Four things this cost, all now handled in idfWrap():
idf.pycannot be spawned at all - it is a.pyfile, sospawnfails withEFTYPE. It also needs cmake, ninja and the xtensa toolchain on PATH, which only ESP-IDF'sexport.ps1provides, so the command runs inside one PowerShell session that sources it first.- The script must be the interpreter's first argument.
python -C dir buildmakes python read-Cas its own option (Unknown option: -C); it has to bepython <idf.py> -C dir build. - Do not clear PATH to get rid of the "Not using an unsupported version of tool cmake found in PATH" lines.
export.ps1callspythonitself, so emptying PATH makes the export fail and then idf.py says'cmake' must be available on the PATH. Those warnings are harmless: export.ps1 prepends ESP-IDF's own tools. - 460800 baud is too fast for this CP210x board. The first flash wrote the app and failed on the next image with
Invalid head of packet (0x78): Possible serial noise or corruption;-b 115200completed. The very first attempt also hitWrong boot mode detected (0x13)and simply needed a retry.
ESP32, on the real board (v1.19.5): a CP210x board on COM6, project_build(action="flash", port="COM6") →
arduino-cli compile --upload → Wrote 298160 bytes (158774 compressed) at 0x00010000 in 2.4 seconds,
Hash of data verified, Hard resetting via RTS pin. The sketch printed its own evidence over the same port:
ESP32 alive #15 (uptime 14s)
ESP32 alive #26 (uptime 25s)
Three things that board taught:
arduino-cli board listcannot identify a CP210x ESP32. The port reports onlyvid 0x10C4 pid 0xEA60and nomatching_boards, so the FQBN has to come fromsketch.yaml- which is why that is consulted before the port scan.- Opening the port resets the board (
rst:0x1 (POWERON_RESET)), and the banner is in the first 300 ms - read immediately, do not discard the buffer.DtrEnable = false; RtsEnable = falsekeeps the auto-reset circuit quiet. arduino-cli uploadalone fails withCompiled sketch not found in ...sketches\<hash>;compile --uploadis the command that means "flash this project".
Verified with Keil MDK-ARM itself (v1.19.0): MDK-ARM 5.43a was installed next to the existing C51 install, and the
project in scripts/arm-keil-template/ built through keil_build in 1.9 s:
Program Size: Code=444 RO-data=252 RW-data=0 ZI-data=1632, .axf and .hex produced, 0 Error(s). Deliberately
breaking one line gave exit code 2 and main.c(19): error: use of undeclared identifier 'notDeclared' - the ARMCC6
dialect parsed with a file and a line, not a wall of log. Three things that only a real install could teach:
- A machine can hold two Keil installs and they do not share toolchains. C51 lives in
D:\Keil_v5, MDK-ARM in%LOCALAPPDATA%\Keil_v5(the 5.43 installer's default). Handing an ARM project to the C51UV4.exeis not a warning, it is a build that fails saying the toolchain is missing - souv4ForFamily()now picks by the project's device family and the answer names theUV4.exeit used. - The scatter file must be explicit. A hand-written minimal project failed with
*** Scatter Error: no default 'Read/Write' range selectedand thenL6031U: Could not open .sct; the fix is a checked-in.sct, not a dialog. - Do not copy a pack example. The STM32F1 pack's
Blinkyuses Keil RTX, so uVision aborts the build with "uses RTOS support that is part of a Legacy Pack" - a download unrelated to what the example was being used for.
Verified on a real board (STM32F103C8T6 clone, no ST-Link): wired PA9/PA10 to the CH340 that was already on COM3,
BOOT0=1 + reset, flasher="uart" wrote 212 bytes, Verification OK, the 212 bytes were read back and hashed
identical, and the LED blinked after BOOT0=0 + reset. Two things that board taught that no datasheet would have:
- The silkscreen lied about the memory.
C8T6means 64 KiB flash / 20 KiB RAM, but the chip reportsChip id 0x412(low density) withFlash size: 32 KiB— a clone. A 20 KiB stack top (0x20005000) is past the end of 10 KiB RAM, so the firmware flashed cleanly, verified, and then hard-faulted in a reset loop with no LED. The linker script must be sized from what the chip says (0x412→ 32K/10K), not from what is printed on it. - A cloned bootloader needs the reset, not just the jumper. Moving BOOT0 to 1 does nothing until the chip is reset;
stm32loaderreportsCan't init into bootloaderduring that window, which reads like a wiring fault and is not one.
scripts/stm32-template/ is that project: a 212-byte bare-metal blink (C vector table, register-level GPIO, 32K/10K
linker script) with cmake/mcu.cmake holding the three things a different chip needs.
What this machine can do right now is part of the answer, not something to discover by failing: this box has
ESP-IDF v4.4.8 and Keil C51 only (no ARM/ folder), and no arm-none-eabi-gcc, openocd or st-flash - so an
STM32 project needs one of those installed first, and the tool says which.
Keil uVision5: build from the chat, and from uVision itself / Keil 编译
keil_build drives the UV4.exe already installed - C51 or MDK - and reads the log it writes, because that log is
the only place a uVision build says which file, which line. It does not reimplement a compiler, for the same reason
serial_flash does not reimplement a bootloader.
/keil # status: is UV4 found, which project, which target
/keil on # arm it (default off)
/keil project D:\work\blinky.uvprojx # remember the project (its targets are listed back)
/keil target "MCBx51: 8xC51Rx" # pick a target, or leave it to the project default
/keil build # build; rebuild / clean / flash as well
On the card (Plugins → dsh-wechat-media) the same switch sits next to the serial one: a checkbox for /keil on, a
Project field with a Browse… button (the native file dialog, because a page cannot learn an absolute path), a
Target dropdown filled from the project's own <TargetName> entries, and one line saying which UV4.exe was found
- or why none was. Everything saves through
/prefs, so the panel, a script and a chat command are three ways to write one preference rather than three sources of truth.
The tool is the same four actions: keil_build(project?, action?, target?, timeoutMs?) with
action = build | rebuild | clean | flash. flash is UV4 -f, which downloads through the project's own debugger
settings - it writes the chip, which is why the whole thing is off until /keil on.
What comes back is a verdict, not a wall of log:
❌ 编译失败(退出码 2,1714ms)
工程:D:\work\blinky.uvprojx
Target:MCBx51: 8xC51Rx
✖ BLINKY.C(38): error C141: syntax error near '{', expected 'bit'
(Target not created:没有产出可下载的文件)
UV4 退出码 2:有错误
Both dialects are parsed, because one uVision ships both: .\main.c(12): error C141: ... (C51 / ARMCC5) and
../src/main.c:23:5: error: ... (ARMCC6 / GCC), plus the tools' own *** ERROR: .... A warning-only build exits 1
and is reported as a success; a log that says Target not created is a failure even when the counts read
0 Error(s) - both facts are in the text, so both are read.
The same code, from inside uVision
Two entry points, one module (keil.js): if the chat and the IDE disagreed about what a build was, one of them would be
lying. For the IDE side there is a Tools-menu entry, and a script that writes it:
# adds "Keil: 编译 (DSH)" and "Keil: 编译+下载 (DSH)" to Tools → Customize Tools Menu
powershell -ExecutionPolicy Bypass -File scripts\keil\install-tools-menu.ps1 -WithFlash
# and to take them back out
powershell -ExecutionPolicy Bypass -File scripts\keil\install-tools-menu.ps1 -Remove
uVision stores those entries in HKCU\Software\Keil\<product key>\ToolM as five values per entry - Mtx (name), Mex
(command), Mag (arguments), Mid (initial folder), Mfg (option bits, 0x900 = run independent). The installer
writes exactly that, so it is idempotent and reversible: it exports the key to a .reg under
$DSH_HOME\dsh-wechat-media\ first, and -Remove deletes only the entries it created.
The arguments are cmd.exe /c "<plugin>\scripts\keil\dsh-keil.cmd" "#P" - #P is uVision's own substitution for the
open project, so one menu item works on every project without per-project setup. Exit codes are meant for scripts:
0 built (warnings fine), 2 build failed, 3 could not run at all. The log of each run goes to <project folder>\.dsh-logs\<output>.<action>.log: Objects\ belongs to Keil and the folder beside main.c belongs to you, so the tool keeps its own evidence somewhere it can be found without pretending to be part of the build.
What was actually verified. Against a copy of Keil's own BLINKY example (MCBx51: 8xC51Rx, C51 V9.61): the chat
tool ran UV4 -b and got Program Size: data=9.0 xdata=0 code=82 in 3.8s, and the same build through
scripts\keil\dsh-keil.cmd exited 0. Adding one broken line to BLINKY.C produced
BLINKY.C(38): error C141: syntax error near '{', expected 'bit' and exit code 2 on both paths, with
Target not created reported as a failure. Pointing the CLI at a .c file instead of a project exited 3 with
不是 uVision 工程文件. The Tools-menu entries were written to the registry and read back intact.
action=flash picks the tool the chip actually needs. For an STC part - detected from the project's
<Vendor>STC</Vendor>, which is the only thing that decides it - UV4 -f is skipped entirely and the same hex is
written by stcgal over the serial ISP protocol (the protocol comes from the device name: STC8x → stc8g,
STC89 → stc89, and so on). Anything else still goes through uVision's own download. In one session:
keil_build(action=build) → code=3129, 0 Error(s), hex beside the project
keil_build(action=flash) → stcgal, COM3, 3129 bytes, UID F734C3FC31F408
serial_monitor(port=COM3) → TEMP:27.2C
action=flash is honest about doing nothing. UV4 -f exits 0 having programmed nothing when the project has no
download driver configured - the log is 0 bytes and Keil is content. That is exactly what happened on a real STC
project whose chip was untouched, so the verdict now requires evidence in the log (Erase Done / Programming Done /
Flash Load finished), and says so when there is none: STC parts are normally programmed through STC-ISP or stcgal
over the serial ISP protocol, and serial_flash(flasher=stcgal, chip=stc8g, …) is the path that works.
Limits. Keil Lite caps a build at 32K of code and says so through exit code 20 - the message is passed through rather than guessed at. Only the C51 toolchain was exercised on this machine; the parsing for ARMCC5/AC6 logs is written against their documented formats and covered by tests, but has not been run against an MDK install here.
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=9222and never close it./browser launchstarts such a Chrome on its own profile and switches to this mode;extension- the one that reaches your real Chrome. Loadextension/as an unpacked extension (chrome://extensions → developer mode), put the address and token from the panel (or/browser ext) into its options, and everybrowser_*tool runs throughchrome.debuggerinside your own browser - your logins, your tabs, no debugging port. Edge works the same way: the folder loads unchanged fromedge://extensions. The panel's 一键安装扩展 button packs the folder into$DSH_HOME/exports, opens the settings page of the configured channel (chrome://extensionsoredge://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.debuggerattaches, 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) orscript(chrome.scriptingin the page, no banner ever, andcaptureVisibleTabstill gives you a screenshot of the visible viewport - what it gives up is native input and full-page capture), orhybrid(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;autofollows 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 the panel in the activity bar,
DSH: 提问… / DSH: 用选中内容提问, DSH: 编译当前 Keil 工程 / DSH: 烧录到板子, and a status-bar item.
The panel is laid out for a sidebar. It is ~300px wide and often short, and the first version put a textarea, two
attach buttons and Send on one line - which left the input about 90px wide and unreadable. Now the log takes every pixel
that is left and scrolls, the composer is pinned to the bottom, the input has its own row and grows up to 40% of the
window height, Enter sends (Shift+Enter breaks the line), and the buttons sit underneath: 项目 +文件 编译 烧录
… and 发送 on a full-width row of its own. The footer shows 可写, the project, and a clickable Keil:<project> · <target> chip that opens main.c next
to the .uvprojx - "edit" starts with getting to the file you actually change.
编译 / 烧录 are one HTTP call, not a model turn. Both post to /dsh-wechat-media/keil-run, which runs the same
keil_build the chat uses - so pressing 编译 spends no tokens, and the plugin's gates still decide (/keil on,
/serial on). An STC project is flashed by stcgal (see above); the result appears as a monospace bubble with the
exit code, the file/line errors, and the size of what was built.
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.6.1.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>-<time>.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.
Lead collection / 获客采集
collect_leads turns a goal into a scored list instead of a page of results. Give it goal="户外家具 进口商"(或
outdoor furniture importer)and a market; it searches, opens each company's site and its contact page, extracts
emails / phones / WhatsApp, scores every lead, merges into a local lead store, and writes a CSV on request.
The method is the part worth reading, because each rule exists to kill a specific kind of junk:
| Rule | Why |
|---|---|
| A role word is a recall hint, not proof | importer / distributor / 进口商 widen the net; the verdict comes later from the site's own contacts and evidence |
| Search terms are generated in the market's language | importeur/großhandel for de, importateur for fr - an English-only query finds exporters, not local buyers |
| Directories are filtered out by host | Alibaba, Amazon, LinkedIn, Europages, company registers: high in results, never the company |
| Two de-duplication axes | ACME GmbH = acme gmbh co kg (name normalised) and acme.de = www.acme.de (registrable host) |
| The contact page is fetched, not just linked | half the emails on a site live at /kontakt, /impressum, /contact |
| Noise is filtered from contacts | logo@2x.png, name@example.com, @wixpress.com and HTML-entity artefacts all look exactly like a mailbox |
| Every score carries its reasons | a lead at 60 with "has email, no contact page" is actionable; a bare 60 is a mystery nobody can audit |
The budgets are settings, not constants - a page that never finishes loading used to cost five minutes because the transport's own ceilings fired on every call:
| Setting | Default | Meaning |
|---|---|---|
step |
12 s | how long one page gets before it is abandoned |
search |
15 s | how long one search page gets |
deadline |
3 min | the whole run; reaching it stops cleanly and keeps what it has |
limit |
10 | how many companies an unadorned run keeps |
searches |
4 | how many queries are tried |
Change them with /collect step 12 in the chat, or in the control panel's 获客采集 block. /collect alone prints the
effective values, and /state reports the same numbers the tool actually uses.
Two discovery sources / 两条发现路线
sources |
Where the companies come from | Needs |
|---|---|---|
search (default) |
Tavily, queried with the market's own role words | nothing |
maps |
OpenStreetMap places around a city - name, website, phone, address |
a city name |
both |
the two lists merged, de-duplicated on the same two axes | a city name |
They find different companies. A search asks "who writes about outdoor furniture in German"; a map asks "who sells it
at an address". Measured on Munich: 119 places, 73 with a website, from one Overpass query - shops that never rank for
the query. No geocoder is involved: Nominatim is unreachable from this network (UND_ERR_CONNECT_TIMEOUT), while
Overpass resolves the city itself with area["name"="München"].
Buyer or competitor? / 买家还是同行
Every lead carries a role verdict - buyer / seller / unclear - read from the company's own words, in both
directions:
| Reads like a buyer | Reads like a seller |
|---|---|
| "we are an importer", Großhandel, Einkauf, procurement, private label | "we are a manufacturer", our factory, OEM/ODM, exporter, Hersteller |
It is weighted, not a keyword count: "we are a manufacturer" is not the same statement as the noun appearing in a
footer. The verdict moves the score (+15 / −25) and is stored with the lead, so a person sees why. buyerOnly: true
drops sellers entirely - a competitor in a customer list is worse than a gap in it.
Measured on a live run: the top result was a Chinese exporter with a perfect score, because every signal the scorer
had (email, phone, contact page, product words) is satisfied by a seller too. After the check: nbymoutdoor.com →
seller (ODM / MANUFACTURER), denova-gartenmoebel.ch → seller (Hersteller), tica.nl → buyer (Groothandel). Two out
of three were never prospects.
The lead store lives at $DSH_HOME/dsh-wechat-media/leads.json and is merged, not overwritten - running the same goal
twice adds only what is new. Exports go through the same save rules as browser_flow.
Two things it deliberately does not do: it never treats a search hit as a qualified buyer, and it stops instead of retrying when the engine shows a captcha - burning the address for a few more rows is the worse trade.
Outreach / 触达
outreach_plan then outreach_send. Two steps because the plan is the thing worth reviewing: it lists every recipient,
the exact text, and why each skipped target was skipped.
| Channel | Can reach | Note |
|---|---|---|
email |
a stranger | the address is on the company's own website - the only genuinely cold channel |
wechat |
an existing conversation | a contact who wrote to the bot; there is no "message this phone number" API |
wecom |
an existing conversation | same, via the intelligent robot |
Rules that protect the account and the reader:
- A delivered message starts a 30-day cooldown per address; a bounced one does not - a bounce is a reason to try again tomorrow, not to lock the address out for a month.
- No address is repeated inside one batch, and the plan refuses a template whose placeholders it cannot fill
(
{{contact}}stays visible in the preview rather than going out blank). outreach_sendneedsconfirm: true, sends one at a time with a delay, caps at 10 per call, and stops at the first hard channel failure instead of hammering a broken transport.- History lives in
$DSH_HOME/dsh-wechat-media/outreach.json; the pending plan inoutreach-plan.json.
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 |
/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. |
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) anIMAP UID SEARCH UNSEENplusBODY.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:
SMTPwithAUTH LOGIN; a server that does not offerSTARTTLSis refused rather than handed a password in the clear. Replies carryIn-Reply-To/References, so they land in the same thread. - Who may write:
allowSendersempty means nobody, not everybody.*@example.comdomain 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 <address> / remove <address> 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/lastResetAtonly 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),
/tokensin 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 ownagent.steer(), so it lands in that turn'snext-stepinbox and the model sees it at its next step rather than after the answer. (followup()- the queueing path - puts it innext-turninstead.) - 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".
/statusshows 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'sretfor 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.inboundErrorandstate.wecom.sessionIssueanswer "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,retryResumeand any replacement id). Both are also shown on the/dsh-wechat-media/wecompage.GET /dsh-wechat-media/wecom/inbound-test?chatId=…&text=…&chatType=grouppushes one synthetic callback through the real inbound path (allowlist,friends only,/commands, media, the agent).chatTypedefaults tosingle;groupis how a group-only rule is checked without a second person in a real group. The JSON answer echoes thechatTypeit used, plusinboundErrorwhen handling failed. The personal-WeChat twin isGET /dsh-wechat-media/debug/inbound?chatId=…&text=…, where a chat id ending in@chatroommeans 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_textorwechat_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.
qrcode_img_contentis 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 vendorsqrcode-generator(MIT, © Kazuhiko Arase) to emit an SVG.- The media key has two encodings. Outbound
aes_keyisbase64(32-character hex text)— base64 of the hex string, not of the 16 raw bytes. Inbound accepts both that andbase64(16 raw bytes), so a client must handle both. - The upload is AES-128-ECB ciphertext in the body (
Content-Type: application/octet-stream), and the result is thex-encrypted-paramresponse header. The reportedfilesizeis the padded ciphertext size (ceil((rawsize + 1) / 16) * 16), whilefile_item.lenis the plaintext size as a decimal string. - A
getupdatesclient-side timeout is normal control flow, not a failure: the server holds the request for its full window. Only real errors back off.-14inretorerrcodemeans the token is stale: re-pair.HTTP 403means a second poller is using the same token — one consumer per token. ctx.webServerprefix routes must be registered without a trailing slash. Its matcher testspathname.startsWith(prefix + "/"), so/x/would look for a double slash and never match.- 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. - A
link:-installed plugin cannotimport "@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, {})). - A session created by a plugin needs
agentPresets.mountandmeta.agentPreset. Without the mount the agent publishes against the empty global layer: no tools, no prompt, nothing it could act with. - The plugin-panel card needs a client module that registers the slots itself (
plugins.bundle.configkeyed by package name,plugins.row.configkeyed<package>#<row id>), wrapped inwindow.__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 |
| Keil: real project | DS18B20_TM1650 (STC8H1K08, C51 V9.61) rebuilt: data=76.2 xdata=0 code=3129, 1.5 s, creating hex file from ".\Objects\…"; one broken line produced BLINKY.C(38): error C141 with exit code 2 on both the chat tool and the Keil-side CLI |
| Keil: flash honesty | UV4 -f on that STC project exits 0 in 1.4 s with a 0-byte log and touches nothing - which is why the verdict now requires Erase Done / Programming Done in the log, and why an STC part is sent to stcgal instead |
| STM32: build | arm-none-eabi-gcc 15.2.1 + CMake + Ninja built scripts/stm32-template to text 212 B (Chip id 0x412, 32 KiB flash, 10 KiB RAM) |
| STM32: flash over UART | No ST-Link. PA9/PA10 wired to the CH340, BOOT0=1 + reset, stm32loader wrote 212 bytes at 0x8000000, Verification OK, and the bytes read back hashed identical to the source. BOOT0=0 + reset → the PC13 LED blinked |
| STM32: the cost of no debugger | Moving BOOT0 to 1 does nothing until the chip is reset (Can't init into bootloader during that window); sized from the silkscreen (64K/20K) instead of the chip's own report, the firmware flashed, verified, and hard-faulted in a silent reset loop |
| 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.14.1 is 260 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 |
| 1.2 | Every secret sealed at rest, including the two that arrived late |
| 1.3 – 1.4 | Multi-agent: a preset per conversation, and a model per conversation when you ask for one |
| 1.5 | /help as groups instead of 26 flat lines, and a security pass over the whole access-control surface |
| 1.6 | A fourth channel: email (IMAP in, SMTP out), on the card and in the dashboard, with a CA override for this machine's TLS |
| 1.7 – 1.8 | Feishu as you: knowledge bases, docs/sheets/bitable tools, and the official CLI's 200+ commands - an app is a member of nothing |
| 1.9 – 1.12 | Lead collection: a sentence into a scored list, buyers told apart from sellers, budgets as preferences, shops that never rank (maps), and a market that constrains the search instead of only scoring it |
| 1.13 | Outreach: outreach_plan / outreach_send over three channels, with cooldown, a confirmation step, and a stop at the first hard failure |
| 1.14 | Lead quality: a company name from the domain when the headline is a sentence, a twenty-country phone prefix table, and one human confirmation slot before a cold email leaves |
| 1.15 | Keil uVision5: keil_build drives the installed UV4 (build / rebuild / clean / flash) and answers with file(line): error, plus a Tools-menu entry so the same module is one click inside uVision |
| 1.16 | The same build from VS Code: Browse for the project, Target as a dropdown read from the .uvprojx, one line naming the UV4.exe that was found - and a sidebar layout that works at 300 px |
| 1.17 | project_build: one entry for Keil / ESP-IDF / CMake+Make, with toolchain and flasher overrides. Verified on a real STM32F103C8T6 clone flashed over its UART bootloader with no debugger attached |
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_idalone — the official client, the Go library (daemon365/weixin-clawbot, whoseTargetholds onlyToUserIDand whoseChatTypeis inbound metadata hardcoded todirect) and the Python port (SiverKing/weixin-ClawBot-API, which has nochat_typefield 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_idorsession_id; the host logsinbound group chat=… session_id=… group_id=… room=…when a group first writes, which settles it, and if the answer issession_idthe fix is one line indescribeEnvelope. - Stickers and unknown item types are dropped, but visibly. A message that carries neither text nor a
downloadable file is counted (
counters.unusablein/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
.silkpayload 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|fileand lands beside the WeChat and WeCom arrivals, so the model gets a path it can open. Thetypeparameter is required - the live API answers400 code 99992402without 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=streamsends in whole parts on Feishu. Word-by-word preview is a WeCom feature and is not implemented here./feishustates 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_idis 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
/stateand/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 officialFrame/Headerfield 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/(mode0600where 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 asdpapi:…, DPAPI ciphertext bound to the Windows account that wrote it. A copy of the file is worthless on another machine or account, and/statereturns***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 presetor one POST, plus the two boundaries people trip on: a PAC only governs a browser the plugin launched itself (anextensionsession 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'silink.jswas 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 upstreamlubaiUwU/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.
No comments yet. Be the first to write one.