# @fmode/openclaw-wechat-agent OpenClaw channel plugin that bridges a third-party **WeChat HTTP bot** (xbot / wcf / PC-hook class) into the OpenClaw agent pipeline. Inbound messages arrive via polling, get normalized + filtered + batched, then dispatched through OpenClaw's standard reply pipeline so the agent can respond using its LLM + memory + skills. Replies are sent back via the bot's `send-text` endpoint. Ships as the successor to the standalone `auto-reply-daemon.js` that lived in the companion skill pack (v1.x). The plugin supersedes that daemon once installed — see "Migrate from the v1.2.x daemon" below. ## Status **v0.2.0**. Private-chat + group-chat (mention/keyword trigger) text inbound are supported. Media and multi-account land in later versions. | Feature | v0.2.0 | |----------------------------------|:----------:| | 1-to-1 text inbound / outbound | ✓ | | System / official-account filter | ✓ | | Message dedupe (`msgId`) | ✓ | | Self-message echo filter | ✓ | | FAQ plugin-layer fast path | ✓ | | Batched agent turns (merge rapid messages) | ✓ | | Per-wxid reply cooldown | ✓ | | Agent-driven replies (LLM + skills + memory) | ✓ | | DM allowlist policy | ✓ | | Pairing flow | ✓ (core-owned) | | Group chat + `@mention`/keyword gating | ✓ | | Image / voice / file inbound | planned v0.2 | | Media outbound | planned v0.2 | | Multi-account | planned v0.3 | | Webhook push mode | planned v0.3 | ## Install ### Requirements - OpenClaw host pinned to **`2026.4.15`** (see `peerDependencies.openclaw`). Older hosts are not supported — the plugin is built and tested against this exact version. - Node.js >= 22 (inherited from OpenClaw's own `engines.node`). ### a. From a local tarball (customer delivery) ```bash # on your build machine: typecheck + esbuild bundle + pack npm install npm pack # runs prepack (typecheck + build), produces fmode-openclaw-wechat-agent-0.1.1.tgz # ship the .tgz to the customer server, then on that server: openclaw plugins install ./fmode-openclaw-wechat-agent-0.1.1.tgz openclaw gateway restart ``` The tgz ships a pre-bundled `dist/index.js` with **zero runtime dependency on the host's `openclaw` package resolution tree** — see [Build & release](#build--release). The customer does not need to run `npm install` or configure `NODE_PATH` / symlinks. ### b. From a local checkout (development / iteration) ```bash git clone openclaw-wechat-agent cd openclaw-wechat-agent npm install npm run build # produces dist/index.js openclaw plugins install --link . openclaw gateway restart ``` ### c. From npm (when published) ```bash openclaw plugins install @fmode/openclaw-wechat-agent openclaw gateway restart ``` ## Upgrading / re-installing > **⚠️ Upgrading wipes your business config — back up first.** > > OpenClaw v2026.4.x's `openclaw plugins uninstall wechat-agent` deletes, per > its own printed summary, *"config entry, install record, allowlist, **channel > config**, directory"*. That includes everything under > `channels.wechat-agent.*` in `~/.openclaw/openclaw.json` — > `apiBase`, `faq`, `allowFrom`, `selfWxid`, `ignoreWxidExact`, the lot. > > The `.bak` file the CLI creates during the **next** `install` is a snapshot > of the *post-uninstall* state, so it's **not** a recovery source for your > business settings. You have to back up **before** uninstalling. ### Before you touch the plugin ```bash # 1) Snapshot the current wechat-agent business config into a dated file cp ~/.openclaw/openclaw.json ~/.openclaw/openclaw.json.pre-upgrade-$(date +%F).bak jq '.channels["wechat-agent"]' ~/.openclaw/openclaw.json \ > ~/.openclaw/wechat-agent.config.$(date +%F).json # 2) Verify the snapshot actually captured something (not `null`) cat ~/.openclaw/wechat-agent.config.$(date +%F).json ``` ### Upgrade flow ```bash # 3) Uninstall old + install new (CLI will NOT overwrite an existing directory) openclaw plugins uninstall wechat-agent openclaw plugins install ./fmode-openclaw-wechat-agent-X.Y.Z.tgz # 4) Verify install picked up the new bundle openclaw plugins inspect wechat-agent | head -10 # expect: Version: X.Y.Z, Shape: plain-capability, # Source: ~/.openclaw/extensions/wechat-agent/dist/index.js # 5) Restore the business config you backed up in step 1 tmp=$(mktemp) && jq --slurpfile cfg ~/.openclaw/wechat-agent.config.$(date +%F).json \ '.channels["wechat-agent"] = $cfg[0]' \ ~/.openclaw/openclaw.json > "$tmp" && mv "$tmp" ~/.openclaw/openclaw.json # 6) Sanity-check jq '.channels["wechat-agent"] | {enabled, apiBase, pollIntervalMs, dmPolicy}' \ ~/.openclaw/openclaw.json # 7) Restart gateway systemctl --user restart openclaw-gateway.service # if the gateway is a user unit # or: openclaw gateway restart # 8) Tail the log for a fresh "starting poller" line tail -f /tmp/openclaw/openclaw-*.log \ | grep --line-buffered -iE 'wechat-agent.*(starting poller|inbound|delivered)' ``` You should see `[wechat-agent:default] starting poller apiBase=… interval=…ms` within a few seconds, then the first `poll ok (N new)` on the next `pollIntervalMs` tick. ### Known-good migration: **0.1.0 → 0.1.1** Plugins installed as **`0.1.0`** are the pre-bundle tsc-emit build and fail at runtime on any customer host where OpenClaw lives outside the plugin's `node_modules` tree (typical Linux installs; see [Build & release](#build--release) for the exact error). **Everyone still running 0.1.0 should upgrade to 0.1.1 using the flow above.** The bundled `dist/index.js` in 0.1.1 has no `openclaw/plugin-sdk*` runtime imports left to resolve, so the `Cannot find package 'openclaw'` restart loop goes away without any manual `node_modules/openclaw` symlink. ## Configure In the OpenClaw UI: **Channels → WeChat (Agent Backend) → Edit**. Or edit `~/.openclaw/config.json` (or wherever your config lives): ### Minimal — opens the channel to anyone ```json { "channels": { "wechat-agent": { "enabled": true, "apiBase": "http://8.138.37.248/api/wechat-agent" } } } ``` ### Recommended — safer for production ```json { "channels": { "wechat-agent": { "enabled": true, "apiBase": "http://8.138.37.248/api/wechat-agent", "pollIntervalMs": 10000, "batchWindowMs": 3000, "replyCooldownSecPerWxid": 5, "dmPolicy": "allowlist", "allowFrom": ["wxid_customer1", "wxid_vip_lead"], "ignoreWxidPrefixes": ["gh_"], "ignoreWxidExact": [ "weixin", "fmessage", "medianote", "filehelper", "floatbottle" ], "faq": [ { "id": "greet", "keywords": ["你好", "您好", "在吗", "hello", "hi"], "reply": "您好~我是 AI 助理,请问有什么可以帮您?" }, { "id": "price", "keywords": ["价格", "报价", "多少钱"], "reply": "价格请参考官网 https://example.com/pricing ,或稍候由人工跟进。" } ] } } } ``` ### Config reference | Field | Default | Description | |---|---|---| | `enabled` | `true` | Master switch. | | `apiBase` | `http://127.0.0.1:8138` | Root URL of the wechat-agent HTTP bot. | | `pollIntervalMs` | `10000` | How often to pull new messages (3s-120s). | | `batchWindowMs` | `3000` | Wait this long before dispatching, so rapid-fire messages from one wxid merge into ONE agent turn. | | `replyCooldownSecPerWxid` | `5` | Min seconds between two replies to the same wxid. Prevents reply bursts. | | `groupEnabled` | `true` | Enable group-chat handling. | | `groupKeywords` | `["帮我","请问"]` | Group reply trigger keywords (substring match). | | `groupMentionTokens` | `["@bot","@助手"]` | Extra text tokens treated as mention triggers in group messages. | | `groupReplyAtSender` | `false` | When true, group replies @-mention sender wxid via `ats`. | | `dmPolicy` | `"open"` | `"open"` \| `"allowlist"` \| `"pairing"`. | | `allowFrom` | `[]` | Whitelist wxids (used when `dmPolicy="allowlist"`). | | `ignoreWxidPrefixes` | `["gh_"]` | Drop messages from wxids starting with any prefix (e.g. `gh_` = official accounts). | | `ignoreWxidExact` | system list | Drop messages from system contacts (`weixin`, `filehelper`, etc.). | | `ignoreMessageTypes` | `["system","emoji","voice","image","video","location","file"]` | MVP only handles `text`; others are ignored. | | `faq` | `[]` | Plugin-layer FAQ fast path. If a message contains any keyword, reply without calling the agent. Each rule: `{ keywords: string[], reply: string, stop?: boolean }`. | | `defaultTo` | — | Default wxid when agent replies without an explicit target. | | `selfWxid` | auto-detect | Bot's own wxid. If unset, plugin probes `/login/check-online`. | FAQ rules are evaluated **in order**; first keyword hit wins. `stop: false` means: send the FAQ reply AND still hand the message to the agent (useful for instant acknowledgement + follow-up reasoning). ## How it works ``` polling loop (pollIntervalMs) ├── checkOnline() ├── GET /messages?direction=received └── for each new msg (dedupe by msgId): ├── drop: system contacts / official accounts / self-echoes ├── drop: non-text (MVP) / empty ├── if group: require @mention / keyword trigger ├── drop: not in allowlist (when dmPolicy=allowlist) │ ├── FAQ match? → sendText(reply) directly ← fast path, no LLM │ cooldown.noteReply() │ (if stop=false, also fall through to agent) │ └── cooldown.enqueue(msg) │ └── (after batchWindowMs, or once cooldown expires) → dispatchBatchToAgent(batch): resolveAgentRoute → activity.record enqueueSystemEvent formatInboundEnvelope → finalizeInboundContext updateLastRoute createReplyDispatcherWithTyping + deliver cb dispatchReplyFromConfig ← LLM runs here └── deliver cb → POST /message/send-text ``` The `deliver` callback is where the LLM's reply actually lands on WeChat — it chunks long markdown, calls the bot's `send-text` endpoint, and bumps the cooldown timestamp for this wxid. ## Migrate from the v1.2.x daemon The companion skill pack (`openclaw-wechat-skill-v1.2.2.zip`) ships a standalone `auto-reply-daemon.js` and three management skills (`wechat-auto-reply-start`/`-stop`/`-status`). This plugin replaces all four of them. ```bash # 1. Stop and remove the old daemon pid=$(cat ~/.openclaw/wechat-auto-reply.pid 2>/dev/null || true) [ -n "$pid" ] && kill "$pid" 2>/dev/null || true rm -f ~/.openclaw/auto-reply-daemon.js \ ~/.openclaw/wechat-auto-reply.pid \ ~/.openclaw/wechat-auto-reply-config.json \ ~/.openclaw/wechat-auto-reply-config.template.json # (Optional) remove the 3 daemon-control skills so the agent stops # seeing "start/stop/status" options. rm -rf ~/.openclaw/skills/wechat-auto-reply-start \ ~/.openclaw/skills/wechat-auto-reply-stop \ ~/.openclaw/skills/wechat-auto-reply-status # 2. Install the plugin openclaw plugins install ./fmode-openclaw-wechat-agent-0.1.1.tgz openclaw gateway restart # 3. Port your config # Old daemon's ~/.openclaw/wechat-auto-reply-config.json → # channels.wechat-agent.* in your OpenClaw config (see "Configure" above). # The FAQ array copies over 1:1 into channels.wechat-agent.faq. ``` ### Rollback If something misbehaves, disable the channel and bring the daemon back: ```bash openclaw plugins disable wechat-agent # ... reinstall the v1.2.2 daemon from the old skill pack ... bash /path/to/v1.2.2/quick-start.sh ``` Nothing is destructive — config and the plugin files live separately. ## Operational signals After startup, the OpenClaw status page shows: - `running` — is the poller alive - `online` — last probe of `/login/check-online` - `lastPollAt` — timestamp of last successful poll - `pollsOk` / `pollsFail` — cumulative counters - `inboundCount` / `outboundCount` — messages seen / sent - `faqHits` — how many replies came from the FAQ fast path - `selfWxid` — the bot's own wxid (used for self-echo filter) Logs flow into OpenClaw's unified log stream under the `[wechat-agent]` prefix. ## Troubleshooting **"no messages are being picked up"** Check `channels.wechat-agent.apiBase` is reachable from the OpenClaw host (not just from your laptop). Inside a container, `127.0.0.1` is the container itself — use the host's IP. **"online=false but the bot service is up"** Our `checkOnline` expects `{ ret: 200, data: { online: true } }`. Some forks return `{ code: 0 }` or `{ status: "ok" }` instead. Either normalize in the upstream service or open an issue. **"agent never replies to real messages, only to FAQ keywords"** FAQ matched on a very short keyword (e.g. `"在"` matches any message containing "在"). Tighten keywords or set `stop: false` so the agent also gets a chance. **"agent replies once then goes silent"** `replyCooldownSecPerWxid` may be too high. Drop it to 0 during debugging. **"plugin fails to load with 'runtime not initialized'"** Something in the channel object referenced `getRuntime()` at module-evaluation time (not inside a function). All usages must be lazy — see `outbound.ts` and `monitor.ts` for the pattern. **`channel exited: Cannot find package 'openclaw' imported from .../dist/src/sdk.js` / `health-monitor: restarting (reason: stopped)` loop** Symptom of an older build that was shipped as per-file `tsc` output. The customer's `~/.openclaw/extensions/wechat-agent/` tree is not a descendant of the host `openclaw` package tree, so Node's ESM resolver cannot find `openclaw/plugin-sdk/*` subpaths at load time. Fixed in the current build pipeline — the whole plugin ships as a single self-contained `dist/index.js` with no runtime `openclaw/*` imports (see [Build & release](#build--release)). If you hit this, you are running an old tgz; rebuild with `npm pack` and re-install. ## Development ```bash npm install npm run typecheck # tsc --noEmit npm test # vitest run npm run build # esbuild -> dist/index.js npm run pack:tgz # typecheck + build + npm pack ``` Run against a real OpenClaw instance (after `npm run build`): ```bash openclaw plugins install --link $(pwd) openclaw gateway restart openclaw plugins list | grep wechat-agent ``` Source layout mirrors `@openclaw/zalo` and `knowall-ai/openclaw-msteams`: ``` channel-plugin/ ├── index.ts entry — register() hook ├── openclaw.plugin.json manifest (config schema) ├── package.json npm + openclaw.channel metadata ├── scripts/ │ └── build.mjs esbuild bundle config └── src/ ├── channel.ts ChannelPlugin object (all adapters) ├── config.ts config readers + normalization ├── client.ts HTTP client for wechat-agent backend ├── monitor.ts polling loop + dispatch-to-agent ├── outbound.ts ChannelOutboundAdapter ├── faq.ts FAQ fast-path matcher ├── cooldown.ts per-wxid batch window + cooldown ├── runtime.ts PluginRuntime singleton ├── sdk.ts SDK barrel (subpath re-exports) └── types.ts shared types ``` ## Build & release This plugin ships as a **single self-contained `dist/index.js`** produced by [esbuild](https://esbuild.github.io/) (`scripts/build.mjs`), not by `tsc`. ### Why we bundle OpenClaw installs third-party plugins into `~/.openclaw/extensions//`. That directory is **not** a descendant of the host's `openclaw` package tree (which lives under `/usr/lib/node_modules/openclaw` on a typical Linux host), so Node's standard ESM resolver cannot resolve bare specifiers like `openclaw/plugin-sdk/channel-plugin-common` from inside the plugin code. Per-file `tsc` output therefore fails at runtime with: ``` channel exited: Cannot find package 'openclaw' imported from /home//.openclaw/extensions/wechat-agent/dist/src/sdk.js health-monitor: restarting (reason: stopped) ``` …in a 5-second restart loop, and the channel never actually starts polling. Symlinking `openclaw` into the plugin's `node_modules` works around it on a developer box but is not something a Linux customer should have to do. Bundling at build time against the dev-time `openclaw` install folds every `openclaw/plugin-sdk*` runtime symbol into the plugin tgz. The host's `openclaw` package is no longer required on the module resolution path; only the `PluginRuntime` object that OpenClaw passes into `register(api)` is — and that does not go through package resolution. ### What is bundled - Everything reachable from `index.ts` — our own sources plus every runtime import of `openclaw/plugin-sdk` (root) and `openclaw/plugin-sdk/*` (subpaths: `channel-plugin-common`, `status-helpers`, `channel-reply-pipeline`). - Type-only imports (`import type { … }`) are erased by esbuild's TS loader and contribute zero runtime weight. ### What stays external - Node built-ins (`node:*`). - A small list of heavy optional/native SDK transitive deps (`@napi-rs/canvas`, `sharp`, `node-llama-cpp`, `playwright-core`, etc.) that are only reachable via SDK side-effect imports we never execute from this channel. They are left external so esbuild does not try to parse `.node` binaries or platform-specific install stubs. If a future feature brings us into those code paths, the resulting `Cannot find package` will be immediate and loud. ### Build-check `scripts/build.mjs` greps its own output for any surviving `from "openclaw…"` / `require("openclaw…")` and fails the build if it finds one. This is the single strongest regression guard for the bug above. ## License MIT © fmode ## Acknowledgements - Plugin shape and dispatch pipeline learned from [`@openclaw/zalo`](https://www.npmjs.com/package/@openclaw/zalo) and [`knowall-ai/openclaw-msteams`](https://github.com/knowall-ai/openclaw-msteams). - OpenClaw Plugin SDK docs: .