gangvy e198763a7d Merge branch 'master' of https://git.fmode.cn/18307996893/openclaw-wx-skills 4 bulan lalu
..
scripts 4e7d8200f5 update 插件 4 bulan lalu
src e198763a7d Merge branch 'master' of https://git.fmode.cn/18307996893/openclaw-wx-skills 4 bulan lalu
.gitignore 4e7d8200f5 update 插件 4 bulan lalu
LICENSE 4e7d8200f5 update 插件 4 bulan lalu
README.md 707327fbe8 :群聊 v2 4 bulan lalu
index.ts 4e7d8200f5 update 插件 4 bulan lalu
openclaw.plugin.json e198763a7d Merge branch 'master' of https://git.fmode.cn/18307996893/openclaw-wx-skills 4 bulan lalu
package-lock.json 707327fbe8 :群聊 v2 4 bulan lalu
package.json 6d9ba6da4e update 4 bulan lalu
tsconfig.json 4e7d8200f5 update 插件 4 bulan lalu

README.md

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

# 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. The customer does not need to run npm install or configure NODE_PATH / symlinks.

b. From a local checkout (development / iteration)

git clone <repo-url> 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)

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.jsonapiBase, 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

# 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

# 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 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

{
  "channels": {
    "wechat-agent": {
      "enabled": true,
      "apiBase": "http://8.138.37.248/api/wechat-agent"
    }
  }
}

Recommended — safer for production

{
  "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.

# 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:

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). If you hit this, you are running an old tgz; rebuild with npm pack and re-install.

Development

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

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 (scripts/build.mjs), not by tsc.

Why we bundle

OpenClaw installs third-party plugins into ~/.openclaw/extensions/<plugin>/. 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/<user>/.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