Skip to main content
W

dsh-wechat-media

wjh-ux/dsh-wechat-media

Personal WeChat and WeCom (企业微信) bridge: QR pairing, one DSH conversation per chat, two-way text/image/video/document/archive transfer, chat commands (/help, /status, /new, /stop, /friends) answered by the bridge itself without spending a model turn, a switch that restricts the bot to 1:1 chats (group messages are dropped silently and counted), remote approval by replying 1 or 2 in the chat, and a two-way WeCom intelligent robot over its long connection (no public URL needed) that also receives and sends images, files and videos.

Install

dsh plugin --profile web add github:wjh-ux/dsh-wechat-media

README

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.4.4 · 44 test suites / 692 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 / 它能做什么

CapabilityDetail
QR pairingCards 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 chatEvery 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 waysYour message becomes a turn; the answer is chunked back to WeChat.
Media both waysImages, 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 accountsPair 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.
Editor bridgeA 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 toolsBrowser 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.
ScrapingScrapling: 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.
Multi-agentWith 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.
IntegrationsPOST /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.
PersistenceAccounts, 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.com and novac2c.cdn.weixin.qq.com.

Install / 安装

# from GitHub
dsh plugin --profile web add github:wjh-ux/dsh-wechat-media

# or from a checkout
dsh plugin --profile web add link:/absolute/path/to/dsh-wechat-media

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 / 配对

  1. The card shows a QR code (it polls its own status and refreshes an expired code automatically).
  2. Scan it with WeChat on the phone, confirm on the phone.
  3. If WeChat asks for digits, type them into the card and submit.
  4. The account appears with a status dot: monitoring means the long poll is running.
  5. 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.
  6. 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:

  1. 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.
  2. From the control panel - /dsh-wechat-media/panel has the same field and a restore default button.
  3. With the system folder dialog - Browse… / 浏览… (card and control panel) or GET /dsh-wechat-media/pick-folder opens 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.
  4. 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:
ToolPurpose
wechat_send_textAn extra text message, or a message to a different chat (to), pre-empting the automatic relay for that turn.
wechat_send_fileSend 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_statusWhich 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.json beside 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, /status prints 已静默忽略群消息 N 条, and the card shows the same number.
  • It is one preference (prefs.json), so the checkbox and /friends can 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:

PathWhat you needDirection
Group robot webhookone webhook URL from a WeCom group (群设置 → 群机器人 → 添加)out: text, markdown, images
Self-built appcorpid + corpsecret + agentid of an app (应用管理 → 自建)out: text, markdown, image, file, voice, video
Intelligent robot, long connectionthe 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:

ToolPurpose
wecom_send_textSend text or markdown through the configured robot, long connection or app (to overrides the recipients).
wecom_send_fileSend 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 botApp bot
Credentialsthe webhook URL (and its signing secret, if set)app_id + app_secret
Can sendtext and rich texttext, rich text, images, files, audio
Can receivenoyes - over the long connection, with no public URL
Configfeishu.webhookUrl, feishu.secretfeishu.appId, feishu.appSecret

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:

  1. what the panel saved (prefs.feishu),
  2. config.feishu in the plugin config,
  3. 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. /state and /prefs report 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: 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). The reply 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.

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. sendMessage sends the inbound id as msg_id (plus a msg_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}/messages vs /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 (list or all, 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:

StepWhere
Enable the bot开发者后台 → 你的应用 → 应用功能 → 机器人 → 启用
Subscribe over the long connection开发者后台 → 事件与回调 → 订阅方式 → 使用长连接接收事件
Permissionsat 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)
ActionWhat happens at the fire time
messagesends fixed text - no model call, no tokens
promptruns one ordinary agent turn in that chat and delivers the answer
commandruns 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; /state reports tasks: { count, enabled, nextAt, commandEnabled, ran, failed, items }.

What people actually schedule / 实际用法

You wantThe scheduleAction
A weather line before work每天 07:30prompt: "用一句话说今天厦门天气,再给一句提醒"
A digest of a group at noondaily 12:00prompt: "总结今天群里值得看的消息,五条以内"
A weekly report on Monday每周一 09:00prompt: "汇总上周完成的事,写成三条"
A monthly reminder每月1日 10:00message: "该交房租了"
A one-off, then again next weekonce 2026-10-20 09:00 + [+1周] [再次运行]any
Watch a folder every half hourevery 30mcommand (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.

ControlWhen it appearsWhat it does
time + action + text + 添加alwayscreate a task targeting the chat that last wrote
[暂停] / [恢复]recurring tasksstop and restart the schedule
[+1 天 ▾] [再次运行]finished one-offsmove it forward one day / week / month and re-enable
☐ 完成后再排一次one-offsre-arm automatically after each run (same clock time, next day)
[删除]alwaysremove it
☐ 允许 command 任务执行本机命令alwaysthe 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 prompt task firing in the same second when the machine wakes. taskGraceMinutes in prefs tunes it; 0 means "always run, however late". /state shows tasks.skipped next to ran and failed.
  • Any chat can be targeted. task_add takes transport (wechat / wecom / feishu) with to, 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:

ToolDoes
office_readtext and structure: paragraphs, tables, sheets with rows, slide texts
office_writecreate from a JSON spec (below)
office_convert.docx/.xlsx/.pptx → PDF, source untouched
office_renderpages (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
ToolWhat it does
desktop_screenvirtual screen origin/size in physical pixels, monitor count, pointer position - call it first so coordinates are real pixels
desktop_windowsvisible top-level windows: title, pid, rectangle, minimised
desktop_focusbring the first window whose title contains a string to the front, restoring it if minimised
desktop_screenshotPNG of the whole screen or a region into the shared inbox, path returned so it can be sent to the chat
desktop_mousemove / click / double_click / right_click / middle_click / drag / scroll at physical pixels
desktop_typetype literal text (Unicode scan codes, so Chinese arrives as typed)
desktop_keypress a chord such as ctrl+s, alt+tab, up

Three things are deliberate:

  • Off unless armed. The switch is checked in one wrapper (desktopTool) that is the only way to define one of these tools, so a new desktop tool cannot forget it. /state reports desktop: { enabled, supported, reason }.
  • Nothing is interpolated into PowerShell. Coordinates are integers; text and window titles travel as base64 and are decoded by PowerShell, so a window title containing '; Remove-Item … is data, not code.
  • Windows only, for now. The scripts use user32.dll, System.Windows.Forms and System.Drawing; on any other platform the tools refuse and say which platform they found.

desktop_screen also reports the size of the INPUT struct the C# compiled, so a wrong layout (which makes SendInput silently do nothing) is visible rather than guessed at.

Clipboard / 剪贴板

Two tools move things through the clipboard, which is how "我复制了一段…" becomes something the model can read, and how a result ends up where the next Ctrl+V will find it:

ToolWhat it does
clipboard_readthe copied text (capped at 200k characters) and the file list currently on the clipboard
clipboard_writeput text on the clipboard, or paths (comma / newline separated) as a file-drop list (Explorer pastes those as files)
/clipboard on|off|status     # on by default; /clip 也行
  • On by default, with an off switch. A clipboard can hold a password, so /clipboard off is one command away and both tools refuse immediately once it is set. The switch is checked in one place, before any script runs.
  • Files win over text when both are given: a clipboard holds one thing, and a file has no other way to be pasted.
  • Windows goes through WinForms on an STA thread (powershell.exe -STA), which is what makes SetText/GetText reliable from a service-like process. macOS uses pbcopy/pbpaste, Linux wl-copy/wl-paste; a file list is Windows-only and the other platforms say so instead of silently copying nothing.
  • The protocol is base64, and that is not decoration. The first version printed JSON straight to stdout and every Chinese character came back as ?, because powershell.exe writes stdout in the console codepage. Each script now emits the base64 of its UTF-8 JSON and the runner decodes it - 剪贴板自检 round-trips unchanged, and a test drives the fake runner with exactly that string.
  • The size cap is applied inside the PowerShell script, so a 20 MB clipboard never travels through stdout to then be thrown away.

SSH / 远程命令

Three tools drive the platform's own OpenSSH client - the binaries ship with Windows 10/11, honour ~/.ssh/config and known_hosts, and keep the private key exactly where it already is. Nothing here reads a key's contents; only its path.

ToolWhat it does
ssh_execrun one command on a host, return exit code + stdout + stderr + timing
ssh_uploadcopy a local file (or directory, recursive) to the host, via scp
ssh_downloadcopy a file (or directory) from the host
/ssh                      # status: switch + host list
/ssh on | off             # arm / disarm  (off by default)
/ssh add nas root@192.168.1.10:22 [C:\Users\me\.ssh\id_ed25519]
/ssh remove nas

Hosts live in one prefs block (prefs.ssh), so the card, /state and the tools read the same list; a tool also accepts an ad-hoc user@host[:port] without saving it.

Three decisions, each because the alternative fails silently:

  • Key-based only, always BatchMode=yes. A password cannot be typed into a service-like process, so an auth problem must fail immediately with the client's own message instead of hanging the turn. No password is ever stored.
  • StrictHostKeyChecking=accept-new. A new host key is learned once and a changed one is refused: no would accept a man-in-the-middle, yes would make the first connection impossible.
  • Off until /ssh on. Running commands on someone's servers is not a default-on capability, and the gate is checked before any process starts.

scp gets -P for the port (its -p means "preserve times"), the command is placed after -- so a remote command starting with - is not read as an option, and captured output is capped at 40k characters with an explicit "已截断" marker rather than a silent cut.

Serial: flash a board and read it back / 串口烧录

Three tools drive the toolchain people already have - ESP-IDF's idf.py/esptool, or avrdude for AVR - instead of reimplementing a bootloader protocol, because that is how a tool ends up bricking a board.

ToolWhat it does
serial_portsthe COM ports with the device name behind each (COM3 vs COM4 means nothing without "Silicon Labs CP210x"), plus which flashers were found
serial_flashflash: 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_monitorread what the board prints for N seconds (default 8) and return it

An STC (8051) board uses flasher=stcgal: STC speaks its own ISP protocol, so esptool cannot touch it. Pass chip=stc8g (or leave it auto); stcgal passes -A dtr, which cold-starts the MCU over DTR - measured on an STC8H1K08, it handshakes without unplugging the board. stcgal is found on PATH or inside any ESP-IDF python env, because that is where pip install stcgal puts it on a machine that already has ESP-IDF. AVR boards use flasher=avrdude, and an ESP-IDF project uses flasher=idf - which runs inside export.ps1, because idf.py cannot see cmake otherwise.

/serial                 # switch, baud, default port, ports, toolchain
/serial on | off        # off by default: flashing writes a board's flash
/serial baud 460800
/serial port COM3

Then, from the VS Code panel with the project open: "把这个项目烧到 COM3" - the turn runs in the open folder, so serial_flash with flasher=idf and projectDir set to that folder flashes the project's own build.

From the VS Code panel, with the project open:

COM3 上插的是什么板子?                 -> serial_ports
把这个项目烧到 COM3                     -> serial_flash (flasher=idf, projectDir = the open folder)
把 Objects\app.hex 烧到 COM3           -> serial_flash (flasher=stcgal / avrdude, by file type)
读 10 秒串口看看它在打印什么              -> serial_monitor

Four rules, each because the alternative is silent damage:

  • Off until /serial on - writing a board's flash is not a default-on capability.
  • Never guess the port: a port must be named, or there must be exactly one. Two ports and no answer is "say which one", not "pick the first" - and a named port that is not plugged in is refused with the list of the ones that are.
  • The exact command is echoed with every result, so a failed flash can be repeated by hand instead of guessed at.
  • One owner per port: the monitor opens the port exclusively, so a flash and a monitor cannot overlap - the second one fails with a reason rather than stealing the port mid-flash.

A board answers on real hardware, and that is where the interesting bugs were. On an STC8H1K08 behind a CH340, serial_ports reported COM3 — USB-SERIAL CH340 (COM3); serial_monitor read TEMP:28.1C twice a second; serial_flash wrote DS18B20_TM1650.hex (3129 bytes, Target UID: F734C3FC31F408) and the board printed TEMP:27.6C straight afterwards. Three fixes came out of that test rather than out of reading the docs: /prefs dropped the serial block silently, idf.py could not see cmake without export.ps1, and stcgal's tqdm bar arrived as mojibake and hid the verdict.

The flasher's verdict lives in its last lines ("Hash of data verified", "avrdude done"), so output is tailed rather than capped from the start. Discovery is real: on this machine it found D:\Espressif\frameworks\esp-idf-v4.4.8 with its python_env interpreter - and reported "no serial ports" when no board was plugged in, rather than inventing one.

Scrapling: fetch what a plain client cannot / 抓取引擎

Scrapling is a python scraping library with a browser-shaped TLS fingerprint, real-browser headers and adaptive selectors, so it reads pages that reject a plain fetch (403, Cloudflare's challenge, a UA check). scrapling_fetch exposes it as one tool:

ArgumentWhat it does
urlthe http(s) page (http and https only - a file:// path is refused)
selectorCSS selector; each match becomes one item with its text (span.text, h2 a::text)
linksalso return the page's anchors
modetext (default) or html
limit / textLimitcaps (items, characters) - a page can be megabytes, the transcript should not be
stealthrun a real browser; it drives the Chrome you already have (no 1.25 GB download)
cloudflarestealth only: solve a Cloudflare challenge first (~11 s on a protected page)
exestealth only: which browser binary to drive
proxyper-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:

CaseResult
https://example.com, no selector200, 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: true200, the page text plus eight anchors in 0.9 s
https://news.ycombinator.com/ without a proxyhard 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.

ParameterMeaning
querywhat to search for (required)
maxResults1-20; defaults to the configured value
searchDepthbasic (default), advanced or fast
includeDomains / excludeDomainscomma-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.

ToolDoes
browser_openopen a URL and return the page title
browser_textthe page's title, URL and visible text
browser_clickclick an element by CSS selector (a real mouse event)
browser_typetype into an element by CSS selector (real key input)
browser_screenshotPNG of the page into the shared inbox, path returned so it can be sent to the chat
browser_evalrun JavaScript in the page and return its value (the escape hatch when selectors are not enough)
browser_flowreplay a list of steps and return the rows they collected (see below)
browser_recordrecord the person's own clicks/typing/Enter into a replayable flow
browser_closeclose the browser this plugin started

It starts its own instance with a profile under $DSH_HOME/browser-profile, so the browser a person actually browses with is never touched; a browser already listening on the port is attached to instead of duplicated. The "自动测试软件正在控制 Chrome" bar is hidden by default: --enable-automation is passed only when the panel's browserHideAutomationBar box is unticked, and the single CDP call that required the flag (Browser.getBrowserCommandLine) is no longer used - a leftover browser is found by port, not by its command line. Verified live against Chrome 155: a page received typed input (17 and 25) and a real mouse click and computed 42 by itself, and a screenshot came back as a 68,561-byte PNG and was delivered to Feishu. Every failure states its reason (browser_click 失败:nothing on the page matches the selector "…").

Which browser? (会话 / browserProfileMode) - four answers, because "same Chrome as mine" has four readings:

  • own (default) - the plugin's own profile above;

  • chrome - import bookmarks and preferences from the real Chrome. Cookies and saved passwords cannot be brought across: Chrome 127+ encrypts them with an app-bound key that dies with the profile (measured: 114 cookie rows in, 29 after one start). Kept for the files that do survive;

  • attach - drive a Chrome you started with --remote-debugging-port=9222 and never close it. /browser launch starts such a Chrome on its own profile and switches to this mode;

  • extension - the one that reaches your real Chrome. Load extension/ as an unpacked extension (chrome://extensions → developer mode), put the address and token from the panel (or /browser ext) into its options, and every browser_* tool runs through chrome.debugger inside your own browser - your logins, your tabs, no debugging port. Edge works the same way: the folder loads unchanged from edge://extensions. The panel's 一键安装扩展 button packs the folder into $DSH_HOME/exports, opens the settings page of the configured channel (chrome://extensions or edge://extensions) and puts the token on the clipboard; Chrome does not allow a page to install an extension silently, so those last two clicks stay yours.

    扩展驱动 (the debugging banner). Chrome draws "已开始调试此浏览器" whenever chrome.debugger attaches, and no extension can hide it - so the panel offers the choice instead: debugger (real input, full-page captures, any frame; the banner flashes while a command runs) or script (chrome.scripting in the page, no banner ever, and captureVisibleTab still gives you a screenshot of the visible viewport - what it gives up is native input and full-page capture), or hybrid (script first, and the debugger only for the command that scripting cannot finish - a click that found nothing, a frame-scoped operation - so routine work leaves no trace in Chrome's UI). The switch is stored and delivered before the next command runs.

    两个浏览器同时开着. Every poll names its browser (chrome / edge) and installation, so the bridge knows both. 目标浏览器 (/browser ext target auto|chrome|edge) picks which one receives commands; auto follows the channel setting. The other browser keeps a standby poll and never gets work - without that, two open browsers race for every command and the answer comes from whichever asked first.

网络层 / The network layer

A rendered list is the last copy of the data, and the smallest. Four tools reach the source instead - all of them on the extension transport, because a page cannot see its own traffic or its own cookie store:

ToolDoes
browser_capturestart / 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_cookiesthe login state, for one URL or the whole browser
browser_downloaddownload through the browser (its cookie jar applies), wait for completion, report the path
browser_tabslist / open / select - a second tab becomes a choice, not a hope

Flow step waitIdle uses the same machinery: it resolves when nothing is in flight and nothing new has happened for idleMs, which is what "the page has settled" actually means.

Typical shape: browser_capture start pattern=/api/ → drive the page → waitIdle → browser_capture stop.

Why not simply drive the real profile? Chrome 136+ silently ignores --remote-debugging-port when --user-data-dir is the default one - measured on Chrome 155: process up, window visible, port never listening. That is also why the extension exists.

VS Code / 编辑器桥

vscode-extension/ is a small editor extension - plain CommonJS, no build step - with three ways in: a chat panel in the activity bar, DSH: 提问… / DSH: 用选中内容提问, and a status-bar item showing what the plugin is connected to.

It talks to three loopback routes: /dsh-vscode/status, /dsh-vscode/ask and /dsh-vscode/stream. All of them are a real agent turn - the same ensureAgent + collector path a chat uses - so the editor gets the same model, tools and context rather than a second, weaker implementation. /vscode on|off gates it.

The AI edits the project you have open. The request carries the workspace folder and the 可写 switch under the input box, and the plugin runs the turn in that folder with workspace-write as its boundary - so edit/write land in your project, and anything wider still fails closed (approval is never, because nobody in an editor panel can answer a prompt). Turn 可写 off and the same session runs read-only on the next request: the switch is re-applied every time, not remembered from the first turn. One folder is one conversation (project:<folder>), so two open projects never share a history. The panel prints the project it is talking about, 项目:<folder>(可改 / 只读).

Verified end to end: "新建 note.txt" created a 31-byte UTF-8 file in the open folder; with 可写 off, the follow-up edit was refused by the sandbox ([sandbox: file access denied under read-only mode]) and the file was left untouched.

The endpoint is discovered from gui-port.json in the plugin's state directory, because the GUI port changes on every DSH restart. Install: Extensions → ... → Install from VSIX (dsh-wechat-media-vscode-0.5.0.vsix in this repo), or code --install-extension vscode-extension/. The extension's own version is what VS Code uses to decide whether to replace it - a frozen version makes an install a no-op that still prints "successfully installed".

Flashing from the editor / 在编辑器里烧板子

The editor bridge sends the folder you have open as the turn's working directory, which is exactly what a flash needs: idf.py finds the project's sdkconfig (and therefore the offsets, flash size and partition table), and a plain .hex/.bin is resolved relative to that folder. So "烧一下" from the panel is one sentence, not a terminal session with the right environment - and the answer comes back in the same panel, with the exact command that ran.

Flow automation / 流程自动化(影刀式)

browser_flow is the RPA part: a task is written once as JSON, and replayed on demand - open, search, click, scroll, read the list, loop over the rows, export. The same shape a 影刀 flow has, without the recorder UI: the steps are a text file, so they can be reviewed, edited and handed to somebody else.

StepFieldsDoes
gotourl, ms?open a page and wait for load
clickselectora real mouse click on the element's centre
typeselector, text, clear?focus and type (clears first unless clear: false)
presskeyEnter, Tab, Escape, ArrowDown, PageDown, Space
waitmsa fixed pause (default 500)
waitForselector, ms?poll until the element appears (default 15 s), fail if it never does
scrollto, times?, selector?, pauseMs?scroll a container or the window to bottom/top
extractfields, selector?, single?, into?read one row (or the page) into a table
eachselector, fields, limit?, pauseMs?walk the matched elements, one row each, scrolled into view
evalexpression, into?run JavaScript and keep the value
assertselector, 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.

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).

CommandAliasesWhat 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/飞书 /larkReports 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/多agentThe 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.
/qq/QQThe QQ bot's switch and status: `/qq on
/scrapling/抓取, /scrapeScrapling's defaults: /scrapling proxy <url>, `/scrapling stealth on
/serial/串口, /flashArms or disarms the serial tools and sets their defaults: `/serial on
/ssh/远程Arms or disarms the SSH tools and manages the host list: `/ssh on
/stop/cancel, /停止Interrupts the turn running in this chat's session, and says so honestly when nothing is running.

Anything else starting with / gets the command list back rather than a guess; a bare / or // is an ordinary message. Commands are counted (counters.commands) and recorded in /state.recentRoutes.

Integrations: in, out, and a dashboard / 集成:进、出、看板

Three routes turn the bridge into something other systems can drive and watch. All of them live on the same loopback server as the rest of the panel; the inbound one is the exception that is meant to be reachable from another machine.

1. Inbound: POST /dsh-wechat-media/inbound — let a NAS, CI or a monitor "shout at" the agent

curl -s http://127.0.0.1:19387/dsh-wechat-media/inbound \
  -H 'content-type: application/json' \
  -H "x-dsh-token: <面板/看板上的入站 token>" \
  -d '{"peer":"<wxid 或群 id>","text":"把今天的备份报告整理成三句话","id":"ci-2026-10-23-1","reply":true}'
字段必填说明
peer是要说话的会话 id:微信 wxid / 群 id、企微会话 id、飞书 receive id。不是昵称 —— 昵称会重名,会话 id 不会
text是触发这一轮的话;上限 8000 字符
id否幂等键。同一个 id 再发一次不会重跑(CI 重试安全)
reply否true(默认)= 回答也发到那个会话;false = 只在 HTTP 响应里返回
transport否wechat(默认)/ wecom / feishu;后两者目前只能把回答放进 HTTP 响应
accountId否多个微信账号时指定用哪个;默认第一个已配对的

响应里带 sessionId、answer、stopReason、delivered 和 ms,所以 CI 不需要再去微信里看结果。token 是唯一的门 —— 这条路由不要求来源是本机(否则它就没有意义),所以 token 泄露等于接口泄露:面板/看板上可以一键换一个。

2. Outbound webhook — every finished turn, POSTed to your address

curl -s -X POST http://127.0.0.1:19387/dsh-wechat-media/prefs \
  -H 'content-type: application/json' \
  -d '{"webhookUrl":"https://example.com/dsh-hook","webhookSecret":"<随便一段足够长的随机串>"}'

# 先证明接收端是对的,再让真实轮次依赖它
curl -s -X POST http://127.0.0.1:19387/dsh-wechat-media/webhook \
  -H 'content-type: application/json' -d '{"action":"test"}'

每轮结束(微信 / 企微 / 飞书 / VS Code / 入站触发的都算)POST 一份 JSON:

{"event":"turn.end","at":"…","sessionId":"…","accountId":"…","chatId":"…","peerName":"…",
 "transport":"wechat","answer":"…","answerChars":123,"stopReason":"end_turn","error":null,"version":"0.68.0"}
  • 配了 webhookSecret 时带 x-dsh-signature: sha256=<hex>,是对原始 body 的 HMAC-SHA256(不是拼接哈希), 接收端算一遍比对即可;
  • 失败重试 3 次(300/600ms 退避),4xx 不重试 —— 那是「地址错了」,再发两次也没用;
  • 接收端挂了、慢了、超时(10s)都不会拖慢或影响聊天里的回答:发送是 fire-and-forget,结果记在 /dsh-wechat-media/webhook 的 last 和看板上;
  • webhookUrl 置空 = 关闭。

3. Dashboard — GET /dsh-wechat-media/dashboard

浏览器打开 http://127.0.0.1:19387/dsh-wechat-media/dashboard(30 秒自动刷新,无脚本,仅本机可访问):

  • 卡片:会话数、失败合计、token 总量、入站请求数、webhook 已发数;
  • 失败与丢弃:重试失败、无法使用的消息、媒体失败、被白名单/群规则拦下的、飞书错误、入站失败、webhook 失败、 token 记账失败(如果记账本身坏了,这一项会红);
  • Token 用量:按账号/会话拆开,带清零窗口;
  • 最近活动:最后入站、webhook 的最后一次投递(成功/失败 + HTTP 状态)、最近路由、最后一帧流式数据;
  • 配置:入站 token、webhook 地址、签名是否已设置。

排障时先看这一页,比翻会话记录快。

Token usage / Token 统计

The bridge records what each account spends, per model call, from the usage the harness reports on the assistant/message session event - the numbers the model adapter returned, so nothing is estimated. Inside an account the spend is also split per chat.

  • A window you choose, 1 to 30 days (default 30). The counters clear themselves at the end of the window. The reset is applied lazily whenever the ledger is read or written, so a host that was switched off for a week starts a fresh window on its next breath instead of reporting a stale one - and resets / lastResetAt only move when there was actually something to zero.
  • Three ways to see it: the card's Token usage section (one row per account, a window picker and Clear now), /tokens in a chat, and a single line inside /status.
  • Where it lives: $DSH_HOME/dsh-wechat-media/tokens.json. Only counters are stored - no prompts, no replies, nothing the model said.

Interjection / 插话

A message that arrives while the agent is still answering can either join that turn or wait for its own.

  • Off by default - the message queues as its own turn, which is what every chat already does.
  • On (the card's checkbox, or /interject on): the message is handed to the running turn with the harness's own agent.steer(), so it lands in that turn's next-step inbox and the model sees it at its next step rather than after the answer. (followup() - the queueing path - puts it in next-turn instead.)
  • The switch only steers when there really is a turn in flight for that chat and a live agent to steer; otherwise the message queues, because "interject" must never mean "dropped on the floor". /status shows the state and how many messages have been interjected so far.
  • Personal WeChat and WeCom behave identically, and the relay keeps a queue of waiters per session - a second message can no longer replace the first message's waiter (which is what used to lose a reply).

Authorization / 授权档位

What this plugin does with an approval request it receives is a plugin decision, and it has four levels - the card's menu, or /auth:

LevelWhat the plugin does
严格 strictAsk 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.
完全权限 fullNever ask here: approvals are left to DSH (with a session policy that asks nothing, that means actions simply proceed).
仅查看 readonlyAsk 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:

ModeWhat the chat sees
整段 whole (default)The finished answer, sent once. What every earlier release did.
逐字 streamThe 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:

ToolWhat it does
wechat_send_voiceSends audio through personal WeChat - a voice bubble only when the file is SILK, otherwise an audio file (see the rule below).
wecom_send_voiceSends 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:

TargetAudioWhat arrives
WeComamr (default), mp3, wava voice bubble (verified by ear)
personal WeChatanythingan audio file - it arrives and it plays

A voice bubble is not deliverable on personal WeChat, and that is measured rather than assumed: Tencent's own OpenClaw WeChat channel never sends the VOICE media type at all (image/video/file only), the equivalent issue in another implementation has been open and labelled blocked for months, and two live attempts here - mp3 inside an open conversation window, then SILK with playtime - were each accepted by the API and then dropped by the client. So the tool says what actually happens instead of promising a bubble.

The vendored codec is used for the other direction, which does work: WeChat sends voice as SILK, and nothing else on a normal machine can play it, so an incoming voice message is decoded to a playable WAV in the inbox (silkToWav(), falling back to the raw .silk if decoding fails). That is the same use Tencent's own channel makes of silk-wasm 3.7.1 (MIT), vendored under vendor/silk-wasm/ with its licence and the hash it was verified against; see THIRD-PARTY.md.

playtime is read from the finished file with ffprobe (0 when it cannot be read, and the item then falls back to its minimum - never an invented number). The tool's reply says which of the two happened, so "sent" never quietly means "dropped".

Diagnostics / 排障

  • The host logs one line per inbound message with the envelope's identifying fields: inbound group chat=… session_id=… group_id=… room=… from=… type=… — for a group that settles which id the platform expects as the send target.
  • node scripts/probe-group-send.mjs "<chatId>" sends a labelled test message to one chat, prints the platform's ret for each addressing variant, and then lists the chats the bridge already knows.
  • GET /dsh-wechat-media/state (loopback only) returns accounts, monitors, preferences, peers and live pairing attempts. state.wecom.inboundError and state.wecom.sessionIssue answer "why did the bot stop answering this chat?": the first is the last failure while handling an inbound WeCom message, the second records what had to be done when a stored conversation could not simply be resumed (resume, create, retryResume and any replacement id). Both are also shown on the /dsh-wechat-media/wecom page.
  • GET /dsh-wechat-media/wecom/inbound-test?chatId=…&text=…&chatType=group pushes one synthetic callback through the real inbound path (allowlist, friends only, /commands, media, the agent). chatType defaults to single; group is how a group-only rule is checked without a second person in a real group. The JSON answer echoes the chatType it used, plus inboundError when handling failed. The personal-WeChat twin is GET /dsh-wechat-media/debug/inbound?chatId=…&text=…, where a chat id ending in @chatroom means a group and a paired account is required.

Where things live / 文件位置

$DSH_HOME/dsh-wechat-media/            accounts.json, prefs.json, routes.json, peers.json
$DSH_HOME/dsh-wechat-media/accounts/   <account>.json (token, 0600) · <account>.sync.json (cursor)
                                       <account>.context-tokens.json (proactive-send windows)
$DSH_HOME/wechat-inbox/<date>/<chat>/  inbound media, decrypted, with the real extension
$DSH_HOME/wechat-inbox/frames/         frame grabs and other working files the agent creates

Delivery semantics / 发送语义

  • The agent's final answer is relayed automatically, chunked at ~2000 characters with a short delay between parts. If the turn already used wechat_send_text or wechat_send_file, the relay stands down, so an answer is never delivered twice.
  • Replies settle on that turn's turn/end, not on whole-agent idle: a session shared with the GUI never goes idle, which is the classic way a bridge "loses" replies it actually received.
  • Messages are deduplicated per account by message_id, and the bot's own messages (message_type: 2) are ignored.

Implementation notes / 实现要点

Everything here was learned against the live platform; each item broke something first.

  1. qrcode_img_content is a landing-page URL, not an image. The QR has to be rendered locally from that URL — which is exactly what the official client encodes. This package vendors qrcode-generator (MIT, © Kazuhiko Arase) to emit an SVG.
  2. The media key has two encodings. Outbound aes_key is base64(32-character hex text) — base64 of the hex string, not of the 16 raw bytes. Inbound accepts both that and base64(16 raw bytes), so a client must handle both.
  3. The upload is AES-128-ECB ciphertext in the body (Content-Type: application/octet-stream), and the result is the x-encrypted-param response header. The reported filesize is the padded ciphertext size (ceil((rawsize + 1) / 16) * 16), while file_item.len is the plaintext size as a decimal string.
  4. A getupdates client-side timeout is normal control flow, not a failure: the server holds the request for its full window. Only real errors back off. -14 in ret or errcode means the token is stale: re-pair. HTTP 403 means a second poller is using the same token — one consumer per token.
  5. ctx.webServer prefix routes must be registered without a trailing slash. Its matcher tests pathname.startsWith(prefix + "/"), so /x/ would look for a double slash and never match.
  6. Tool parameters must not carry required: false. The harness rejects the schema and the whole plugin fails to load; an optional parameter simply omits the field.
  7. A link:-installed plugin cannot import "@deepseek-ai/…". Node resolves from the plugin's own directory, which has no such dependency; the Cordis loader can resolve it against the harness root (ctx.loader.internal.import(name, baseUrl, {})).
  8. A session created by a plugin needs agentPresets.mount and meta.agentPreset. Without the mount the agent publishes against the empty global layer: no tools, no prompt, nothing it could act with.
  9. The plugin-panel card needs a client module that registers the slots itself (plugins.bundle.config keyed by package name, plugins.row.config keyed <package>#<row id>), wrapped in window.__ModuleLoader__.load({ id, factory }). Exporting a schema is not enough, and a missing wrapper fails the web boot.

What was actually verified / 真机验证过什么

A plugin README can claim anything; this is the list of things that were run while building it, with the numbers that came back. Anything not on this list is covered by tests but not by a real device.

CapabilityHow it was verified
WeChat text, files, imagesLive account, two-way messages and file transfers into $DSH_HOME/wechat-inbox
WeCom / FeishuLong connection connected; a real message answered (answer:"收到", 1721 ms)
VS Code editor bridgeAn edit applied in the open folder: 46 delta frames over 8.5 s, app.js became 你好,世界
Desktop controlFocus, click, screenshots of the DSH window on this machine
ClipboardChinese round-trip (剪贴板自检 中文 ✔), plus the file-list shape
SSHssh -V 56 ms; a named-but-absent port refused with the list of the present ones
Serial: STCDS18B20_TM1650.hex written to an STC8H1K08 over COM3 - 3129 bytes loaded, 3648 written, Target UID: F734C3FC31F408 - then TEMP:27.6C read back
Serial: ESP-IDFhello_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
Scraplingexample.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 stealthReal Chrome UA; nowsecure.nl (Cloudflare) -> 200 in 11 s with cloudflare: true
OfficeWord/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 restA 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 chatLive bot PawDesk (appId 1903374412): C2C_MESSAGE_CREATE -> session -> reply through /v2/users/{openid}/messages; received 2 / forwarded 1 / answered 1
QQ bot: group @GROUP_AT_MESSAGE_CREATE from a real group -> reply through /v2/groups/{group_openid}/messages; received 4 / forwarded 2 / answered 2, and the first two attempts were correctly blocked by the allowlist

Release history / 版本历程

0.1.0 to 1.1.4 is 198 releases - this plugin was built in public, one verified change at a time. Grouped by what each era added, with the releases that mattered most:

EraWhat it added
0.1 – 0.29The 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.32Reliability and secrets: retries, monthly token accounting, and credentials on disk that are no longer readable credentials
0.33 – 0.38Office documents from the chat, an office output folder, the first-run wizard, and Feishu scopes copied as the minimum payload
0.39 – 0.47A 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.56Flow 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.67The 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.63The 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.69Integrations: POST /inbound, a signed outbound webhook, the dashboard - and the clipboard as two tools
0.70SSH: ssh_exec, ssh_upload, ssh_download, key-based only, host key learnt once, off until /ssh on
0.71Serial: list ports, flash (ESP-IDF `idf.py`, esptool, avrdude, STC via stcgal), and read the board back - verified on an STC8H1K08
0.72Scrapling, including a real-browser mode that drives the Chrome you already have
1.0The stability statement: same features, winget-installed tools found, projectDir reaching idf.py, and the README's verification table
1.1QQ bot over the open platform's WebSocket gateway - private chats and group @, verified against a live bot

Each era ended with the same discipline: a release only goes out after the suite passes, and a claim only goes in the README after something real produced the number.

Limitations / 已知限制

  • Group sending is unverified but well-founded. Receiving from a group works today. Every reference implementation addresses a chat through to_user_id alone — the official client, the Go library (daemon365/weixin-clawbot, whose Target holds only ToUserID and whose ChatType is inbound metadata hardcoded to direct) and the Python port (SiverKing/weixin-ClawBot-API, which has no chat_type field at all). This package sends the same way. What is not confirmed against the platform is which inbound field carries a group's addressable id — group_id or session_id; the host logs inbound group chat=… session_id=… group_id=… room=… when a group first writes, which settles it, and if the answer is session_id the fix is one line in describeEnvelope.
  • Stickers and unknown item types are dropped, but visibly. A message that carries neither text nor a downloadable file is counted (counters.unusable in /dsh-wechat-media/state) and logged with its item types instead of vanishing silently.
  • Voice is not bridged as audio. WeChat's own transcript rides along as text when present; the .silk payload is downloaded but not transcoded.
  • Proactive sends need an open conversation window. Without a stored context token for that peer the server answers ret=-2; write to the bot first and the window opens.
  • One poller per account token. Two processes using the same token get HTTP 403.
  • No message history API exists on this platform; only live messages are seen.
  • Media is capped at 100 MiB inbound by default (maxMediaMiB).

Feishu / 飞书

Nine additional tools work with Feishu documents, spreadsheets and bitables. All of them were built against shapes measured from the live API:

ToolDoes
feishu_doc_createCreates a document and writes the body, then returns a link you can open
feishu_doc_readReads a document's text from a /docx/<id> link (or a bare id)
feishu_doc_appendAppends paragraphs to an existing document
feishu_sheet_create / _read / _writeCreates a spreadsheet, reads a range, writes a range
feishu_bitable_create / _write / _readCreates a bitable, writes a record, reads records (by id to confirm a write)

Document links need your tenant host. https://open.feishu.cn/docx/<id> is the API host and answers a generic page, so the tools will not build a link from it. Set tenantHost (panel or /prefs?feishuTenantHost=…) or the FEISHU_TENANT_HOST environment variable to the host you see in any of your own Feishu document URLs, e.g. ccnje3t5tkro.feishu.cn. Without it the tool returns the document id and says why there is no link.

  • Attachments are downloaded into the same inbox as the other two transports. An image, file, audio or video message is fetched with GET /open-apis/im/v1/messages/:message_id/resources/:file_key?type=image|file and lands beside the WeChat and WeCom arrivals, so the model gets a path it can open. The type parameter is required - the live API answers 400 code 99992402 without it - and that was settled against the API rather than guessed. A fetch that fails says so in the prompt ([飞书image未保存] …) instead of leaving the model to assume there was no attachment.
  • delivery=stream sends in whole parts on Feishu. Word-by-word preview is a WeCom feature and is not implemented here. /feishu states this instead of leaving the setting looking effective.
  • Group messages are not filtered by whether the bot was mentioned, because the bot's own open_id is not readable from the SDK source either. Groups behave like WeCom: messages in an allowlisted chat are handled.
  • A long connection that cannot start does not break the plugin. The reason is recorded and shown by /state and /feishu; the plugin keeps working for every other channel.
  • The long connection has not been verified against the live platform yet. The endpoint, the request shape and the error path were exercised for real (Feishu answered a handshake attempt with code 1000040345, and the message did not leak the secret), and the protocol is pinned by tests against the official Frame/Header field numbers - but a real inbound message still needs the console switched to "使用长连接接收事件", a published version and a valid App Secret. Until that first message arrives, treat this as built and tested, not as proven in production.

Security / 安全

  • No secret is stored in this repository, and none is written into a cordis patch. The bot token lives in $DSH_HOME/dsh-wechat-media/accounts/ (mode 0600 where the OS supports it).
  • The plugin never puts the token in a URL. Traffic goes to the two WeChat hosts above and nowhere else.
  • Secrets are sealed at rest. Everything sensitive in prefs.json - the Feishu app secret, the Tavily key, the WeCom bot secret, the QQ client secret and the outbound webhook secret - is stored as dpapi:…, DPAPI ciphertext bound to the Windows account that wrote it. A copy of the file is worthless on another machine or account, and /state returns *** for all of them rather than the value. A path added after a file was written is sealed on the next activation (sealExistingSecrets), and a value that cannot be opened says so instead of pretending the credential is gone.
  • The card's HTTP routes refuse anything that does not arrive over loopback.
  • Use a spare WeChat account if you can. Personal-account automation is at your own risk.

Cookbook / 实战文档

  • Proxy and the host list / 代理与「走代理的域名」 — the 26-domain preset that says which sites go through a local proxy and which stay direct, applied with /browser hosts preset or one POST, plus the two boundaries people trip on: a PAC only governs a browser the plugin launched itself (an extension session uses your own Chrome's proxy), and an empty list means everything goes through the proxy rather than nothing.

    哪些网站走代理、哪些直连:一份 26 个域名的预设,一条命令套用;顺带说明两个边界 —— PAC 只对插件自己拉起的浏览器生效, 列表为空其实是「全部走代理」而不是关闭。

  • Turning a Word question bank into a Feishu bitable — the whole pipeline behind one real job: a 997-paragraph Word question bank (312 questions, no tables) parsed into structured data, deduplicated to 285, corrected in 64 places against the law and the ministries' own wording, uploaded to a Feishu bitable, verified field by field, and then used to answer questions inside the chat. It records the mistakes as well as the method: soft line breaks that shift paragraph numbers, short-answer blocks that defeat a "ends with ?" test, a bitable that ships with four default fields and no tool to add more, and absolute wording ("all", "only", "every") as the best detector of a question whose premise does not hold.

    一份 Word 题库从解析、查重、并行事实复核、写入飞书多维表格到逐字段回读校验的完整记录,附带 9 个可复用的 脚本骨架和这次踩过的坑。

Prior art / 相关工作

This is not the first WeChat bridge for dsh, and the protocol work stands on Tencent's own MIT-licensed client. Credit where it is due:

  • @tencent-weixin/openclaw-weixin (MIT, Tencent) — the authoritative implementation of the iLink bot protocol this package's ilink.js was ported from.
  • BiBoyang/dsh-im-bridge — turn/approval push, in-chat approval, persistent dedup.
  • Jesse-njx/dsh-chatnode-wechat — conversation-node routing and inbound image handling.
  • zhengjy01/dsh-wechat-clawbot (and its upstream lubaiUwU/DSH-WeChatClawBot) — the floating-ball QR panel and the gateway split.

Compared with those, this package concentrates on media: the others move text (one of them images) and none send video, documents or archives. Pick one bridge and stay with it — two of them polling the same bot token will collide.

License / 许可

MIT — see LICENSE. The vendored QR encoder and the ported protocol client keep their own notices, listed in THIRD-PARTY.md.

WeChat and Weixin are trademarks of Tencent. This project is unofficial and is not affiliated with Tencent or DeepSeek.

Related plugins