Skip to content
dsh.fish
Bundle

wechat-clawbot

Connect DeepSeek Harness to WeChat via the official WeChat ClawBot (Tencent iLink) bridge — a DSH plugin bundle.

Source
Alphainfix
stars
1 stars
License
MIT
Updated
Updated yesterday

Readme

# wechat-clawbot

Connect a [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) profile to your WeChat through the official **WeChat ClawBot** bridge (Tencent iLink API). Once bound, you can message your DSH agent from WeChat while you are away from the computer — ask it to look at files, edit them, run commands — and it replies (including permission/approval questions) in the WeChat conversation.

> **Status: v0.9.0** — text messages both ways; **native vision** (an inbound photo rides in the model's own message, and `look_at_image` hands a local one to it directly — no separate vision model, no second round trip); agent → WeChat file/image delivery; quoted (引用) message resolution; long-term memory; scheduled reminders; a **settings card** on 设置 → 插件 whose fields apply live; and a per-plugin model pin so the web UI's model picker no longer moves the bot. Group chats are not supported.

## How it works

```
Phone WeChat ──► Tencent iLink cloud ──► this plugin (long-poll monitor)
        ◄────────────────────────────────  (text replies, approval questions)
                          │
                          ▼
              one fixed DSH agent session ("wechat-main")
              (persisted, auto-compacted like any DSH session)
```

- The plugin reuses the protocol client from the official, MIT-licensed
  [`@tencent-weixin/openclaw-weixin`](https://www.npmjs.com/package/@tencent-weixin/openclaw-weixin)
  package (see [`NOTICE`](NOTICE)); only the OpenClaw integration layer was
  replaced with a DeepSeek Harness integration.
- The WeChat conversation maps to **one fixed session id** (default
  `wechat-main`), so context is shared, history survives restarts, and
  automatic context compaction keeps token usage low.
- The WeChat session's approval policy is forced to `ask`: permission
  requests (file access, command execution, sandbox escalation) are forwarded
  to WeChat and you answer with `同意` / `拒绝` (or `yes` / `no`).
- **File/image delivery**: the WeChat agent has a `send_wechat_file` tool —
  just ask it (e.g. *"把 README.md 发给我"* or *"生成一张图表发给我"*). Files
  travel through Tencent's CDN with the official AES-128-ECB encrypted upload
  pipeline; images, PDFs, Office documents, archives etc. are supported.
- The plugin runs **inside the profile process**: as long as the profile is
  running, the bridge is up. Login is manual (scan a QR code), once per
  binding; the token is stored under `$DSH_HOME/clawbot` and reused on
  restart.

## Requirements

- Node.js >= 22
- A DSH profile (e.g. the `web` profile) with a configured model provider
- The WeChat mobile app with the official **微信ClawBot** plugin available
  (设置 → 插件; WeChat may prompt you to update the app the first time you scan)

## Install

```bash
# 1. Install the plugin into a profile (pnpm is required)
dsh plugin --profile web add wechat-clawbot

# 2. Restart the profile
#    (stop `dsh web`, then start it again)
dsh web
```

The plugin starts automatically at boot (`autoStart: true`). To change its
configuration, add a `clawbot` row to the profile's `cordis.patch.yml`:

```yaml
- id: clawbot
  config:
    allowFrom: []          # allowlist of WeChat user ids; [] = only the binder
    sessionId: wechat-main # the fixed DSH session for all WeChat messages
    forwardQuestions: false
    approvalTimeoutMs: 0   # 0 = wait indefinitely for approval answers
    logLevel: info
```

## Connect (one-time, manual)

```bash
# From the profile directory (or anywhere; the state dir is shared):
clawbot login
```

A QR code is printed in the terminal. Scan it with **WeChat → 扫一扫** and
confirm. Afterwards:

- WeChat shows a new contact **「微信ClawBot」** — open it and start chatting.
- The running profile picks up the credentials automatically within a second
  (it watches the state directory) — no restart needed.

Other commands:

```bash
clawbot status   # show the bound account
clawbot logout   # unbind and delete credentials
```

Environment:

| Variable | Default | Meaning |
|---|---|---|
| `CLAWBOT_STATE_DIR` | `$DSH_HOME/clawbot` | credential/state directory |
| `CLAWBOT_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |

## Security

- **Allowlist**: by default only the WeChat user who scanned the QR code may
  message the agent. Add more users via `allowFrom` (their WeChat user ids
  appear in the logs / `clawbot status`).
- **Approvals**: the WeChat session runs with approval policy `ask`, so
  sensitive operations still require your explicit reply in WeChat. Other
  sessions (e.g. the local web GUI) keep their own policy.
- Unknown senders are ignored silently.

## Configuration reference

| Key | Type | Default | Meaning |
|---|---|---|---|
| `autoStart` | boolean | `true` | start the monitor at boot when a bound account exists |
| `allowFrom` | string[] | `[]` | allowed sender ids; empty = binder only |
| `sessionId` | string | `wechat-main` | fixed DSH session for all WeChat traffic |
| `cwd` | string | `process.cwd()` | workspace for a freshly created session |
| `forwardQuestions` | boolean | `false` | forward `ask_user_question` / plan-review to WeChat (see note) |
| `approvalTimeoutMs` | number | `0` | approval answer timeout; `0` = wait forever |
| `botAgent` | string | `DSH-ClawBot/0.1.0 …` | `bot_agent` reported to iLink |
| `maxImageEdge` | number | `2048` | long-edge pixel cap for compressed images |
| `imageQuality` | number | `80` | JPEG quality for compressed images |
| `compressThresholdBytes` | number | `1048576` | only compress images larger than this |
| `apiBaseUrl` | string | `https://ilinkai.weixin.qq.com` | iLink API base (override for testing) |
| `logLevel` | `debug\|info\|warn\|error` | `info` | protocol log level |
| `stripEmoji` | boolean | `true` | strip Unicode emoji from outbound text |
| `noticeMinBytes` | number | `2097152` | announce a file before upload only above this size; `0` = always |
| `attachImages` | boolean | `true` | give images to the model directly when the route accepts them |
| `provider` | string | — | pin this session's provider; empty = follow the deployment default |
| `model` | string | — | pin the model; empty = follow the deployment default |
| `reasoningEffort` | string | — | pin the effort; empty = the model's own default |
| `mcpBridge` | boolean | `true` | answer the `/plugins/clawbot/mcp/*` routes (see **The Claude bridge**) |

> `forwardQuestions`: only one `userQuestions` provider can exist per DSH
> context. The Web UI registers one while a browser is attached, so this
> option only takes effect in deployments without an active Web provider.
> Approval forwarding (`approval/request`) is independent and always on for
> the WeChat session.

## Settings

Most of the table above is editable at **设置 → 插件 → 微信 Bot**, and a change
lands on the next message with no restart and no dropped WeChat connection: the
plugin diffs the committed settings and copies the *hot* fields into its live
config, which every collaborator holds by reference and reads at the moment it
is used.

The card writes into the settings document's `clawbot` section, which is an
**override layer over the plugin's cordis row** — `cordis.patch.yml` stays the
default, and the file only ever stores fields you actually changed. Each row
shows a 已覆盖 badge with a 重置 that drops back to the composed value.

Cold fields (`sessionId`, `autoStart`, `forwardQuestions`, `botAgent`,
`apiBaseUrl`, `cwd`) are deliberately **absent from the card**. They are stored
but not applied in place — `apiBaseUrl` would redirect a connected client
mid-flight and `sessionId` would point the bridge at a session it is not bound
to — so they live in `cordis.patch.yml`, where changing them already means a
reload.

### Pinning the model

Leaving `provider`/`model` empty means the session follows the deployment
default, which is also why picking a model in the web UI used to move the bot:
that picker writes through to the global `agent-default-model`. Setting them
here breaks the coupling. A model id that will not resolve is refused rather
than applied, so a typo cannot brick the next message.

## The Claude bridge

Two directions, and they are separate mechanisms with separate failure modes.

### Outward: `/plugins/clawbot/mcp/*` — Claude Code drives DSH

Four routes, consumed by [dsh-mcp-bridge](../dsh-mcp-bridge) (an MCP server, not
a DSH plugin), which is what gives a Claude Code session tools to list/read/drive
DSH sessions and to reach the owner on WeChat.

| Route | Method | Does |
|---|---|---|
| `mcp/sessions` | GET | list sessions: id, title, cwd, live, running |
| `mcp/read` | POST | tail one session's user/assistant turns |
| `mcp/send` | POST | steer or follow up a **live** agent |
| `mcp/notify` | POST | one WeChat message to the owner |
| `mcp/status` | GET | is the bridge on, has anything called it |

The privileged half lives here rather than in the MCP server for a concrete
reason: `ctx.agents` holds the live agent objects, and `steer()`/`followup()` are
in-memory methods an out-of-process caller cannot reach. The WeChat send needs
the account token plus per-peer context tokens, so duplicating it outside would
mean a second copy of the credential handling.

Three properties are load-bearing and covered by tests:

- **`notify` takes no recipient.** It sends to `account.userId` — the id linked by
  QR login — so messaging anyone but the owner is *unrepresentable*, not merely
  discouraged.
- **`send` does not reach WeChat.** Injected messages carry source plugin
  `clawbot-mcp`, and the bridge's session listener treats any non-`schedule`
  plugin turn as GUI-only (leaves `currentSender` unset). So a message pushed
  into `wechat-main` is answered in the web UI only. `notify` is the sole route
  to the phone. **Changing that source string breaks the isolation.**
- **Auth.** Every route needs `Authorization: Bearer <token>` from
  `<state>/mcp-token` (0600, minted on first boot). `mcp/status` is deliberately
  unauthenticated — a browser page cannot hold a secret — so it reports the
  token's *path*, never its value.

`mcpBridge: false` makes all four return 403 while leaving them registered: the
flag is read per request, so it is a live kill switch in both directions.

### Inward: three tools — DSH sees and steers Claude Code

Registered on the WeChat agent (`restrictTools` is a **deny** list, so they need
no allowlisting): `list_claude_sessions`, `read_claude_session`,
`send_to_claude_session`.

Listing and reading go straight to files, which is the stable half:
`~/.claude/sessions/<pid>.json` is world-readable, and
`~/.claude/projects/<slug>/<sessionId>.jsonl` is the transcript (found by
globbing for the id — deriving the directory name flattens both separators *and*
dots, which is easy to get subtly wrong).

Sending is the interesting half. There is **no documented CLI** for reaching a
live session: `claude` has no send/message subcommand, and `--resume -p` starts a
new run from history instead of the session the user is watching. The real
mechanism is `peerProtocol 1` over `/tmp/cc-socks/<pid>.sock` with a 0600 key,
and `claude.exe` is a native binary. So rather than reverse-engineer it, this
shells out to a short-lived

```sh
claude -p "<deliver X to session Y>" --allowed-tools ListAgents,SendMessage --model haiku
```

and lets Claude Code do the socket work with its own implementation — protocol
changes stay Anthropic's to keep working. Verified end to end.

Four things learned the hard way, all of them now pinned by tests:

- **Session names drift.** One session was observed renaming itself
  `harvard-96` → `harvard-35` → `harvard-ef` inside a day. So the name is
  resolved fresh on every send, never cached, and an ambiguous handle is an
  error rather than a guess.
- **Success needs an anchored match.** A bare `/sent/i` scores Claude Code's own
  "Session X is not reachable" and the plausible "was not sent" as a delivery.
- **State the run state and the queue explicitly, including when zero.** The
  first version omitted both, and the bot answered 「都没有正在运行的任务」about a
  session that was mid-tool-call. An absent field does not read as *unknown* to a
  model — it reads as *nothing*, and the gap gets filled by invention.
- **Queue accounting has three operations, not two.** `enqueue` adds, `dequeue`
  delivers the head, and `remove` withdraws a specific item **by content**. A
  real session measured 15/9/6 — counting only enqueue/dequeue calls that 6
  pending when it is 0.

Run state is an *inference*: a turn is in flight when the newest main-thread
entry is an assistant message holding a `tool_use`, or a user entry that is
nothing but `tool_result`. Gated on a 180s freshness window, because a session
killed mid-tool-call keeps an unfinished last entry forever.

Known limit: a message **currently waiting** in the queue does not appear in the
transcript. Verified, not assumed — a session had one queued while
enqueue/dequeue/remove balanced to zero, with the file 7s fresh and writes shown
to be prompt. A live answer would need the peer socket.

> Timestamps in the transcript are **UTC**. Render them through
> `toLocaleTimeString`, never by slicing the ISO string — `"…T20:33:05Z"
> .slice(11,16)` reported a 16:33 message as 20:33.

## Limitations

- Group chats are not supported (official channel behaviour).
- Voice messages require a silk transcoder and are not supported.
- One bound WeChat account per state directory. Multiple accounts are tracked
  in the store but only the first is monitored.
- Scheduled reminders are session-local: one that comes due while the profile
  is not running is delivered, late, at the next start.
- The bridge runs only while the DSH profile process is running. For 24/7
  operation keep `dsh web` up (a login item or launchd unit).
- WeChat's own bracket emoji codes (`[捂脸]`) are stripped unconditionally from
  outbound text: they are WeChat-internal notation and arrive through this
  bridge as literal brackets.

## Development

```bash
npm install
npm run typecheck   # tsc --noEmit
npm run build       # tsc → lib/, then copies src/client.js → lib/client.js

node test/regression.mjs     # 55 offline checks, no model calls, no WeChat
node test/claude-peer.mjs    # 29 checks against the real ~/.claude
node scripts/bot-sim.mjs     # reply-style harness against the real prompt
```

Both suites are offline and cost nothing: no model calls, no network, no WeChat.
They exit non-zero, so either can gate a build.

`test/regression.mjs` covers the things that used to fail silently: a strip rule
that ate a markdown link, a settings field declared "hot" but only read at boot,
an output schema that rejects a field the attachment store just started
returning, and the bridge invariants above (that `notify` reads no recipient,
that injected messages keep the `clawbot-mcp` source tag, that `mcp/status`
never echoes the token).

**A hot field must appear as `config.X` in one of the source files that suite
greps** — a field only read inside `apply()` is not hot, whatever `HOT_FIELDS`
claims, and the check enforces that. When adding a source file that reads
settings, add it to that list too.

`test/claude-peer.mjs` runs the peer tools against the real `~/.claude`, because
the failure mode there is not "it crashed" but "it silently returned nothing" —
a renamed registry key, a changed transcript shape, a stale pid counted as live.
Queue accounting, the idle path and the clock are asserted against a *synthetic*
transcript with known answers, so they do not depend on what the machine happens
to be doing. `send_to_claude_session` is registered but never invoked by the
suite: it spawns a process and delivers into a real live session.

`scripts/bot-sim.mjs` drives the **real** system prompt and tool list against
scripted turns and prints what WeChat would have received — so a prompt change
can be judged without messaging yourself and without a session reset. It flags
empty-response endings, stacked vision calls, and bracket emoji codes. Add
`--allow-emoji` to exercise the `stripEmoji: false` branch.

The vendored iLink protocol layer lives in `src/ilink/` (MIT, Tencent); the
DSH integration is `src/bridge.ts`, `src/inbound.ts`, `src/approvals.ts`,
`src/monitor.ts`, `src/questions.ts`, `src/index.ts`.

## License

MIT. The vendored iLink client is MIT © Tencent — see [`NOTICE`](NOTICE).

Install

dsh plugin --profile web add github:Alphainfix/wechat-clawbot

Profile: web

  • This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source