Two WeChat surfaces for oh-my-pi (omp), built on the same Tencent iLink
bot client that Tencent's official @tencent-weixin/openclaw-weixin OpenClaw
plugin talks to:
- An omp plugin —
/wechattakes over the session you are already in. WeChat messages arrive as user turns in that session ([微信] …), and the assistant's reply text goes back to WeChat. One chat, one session, no second process. This is the recommended surface; see Install. - A standalone bridge — one persistent
ompagent session per chat, streamed and rate-limited replies, pluggable transports (console,weixin,wecom-aibot). See Run the bridge.
Research behind those choices, the full iLink wire protocol, and how omp can
instead cooperate with an OpenClaw-hosted WeChat bot over ACP/MCP:
docs/wechat-integration.md.
- omp ≥ 18.1 (
omp --version). The plugin is a normal omp extension package. - Bun ≥ 1.3.14 — omp is Bun-only, and this package ships TypeScript sources unbundled. Verified on 1.4.2.
- A model with credentials already configured for
omp(~/.omp/agent/agent.db), or a keyless local provider.
bun run buildThat produces one self-contained file: dist/omp-weixin.js (~70 KB). Its
dependencies are inlined, so deploying it is a plain copy — no bun install, no
node_modules, no symlink, no manifest, no config edit:
cp dist/omp-weixin.js ~/.omp/agent/extensions/omp-weixin.js # every session, every project
cp dist/omp-weixin.js <project>/.omp/extensions/omp-weixin.js # this project only
omp -e dist/omp-weixin.js # one run onlyomp scans <agentDir>/extensions/ by name, so a file dropped there is picked up
on the next start with nothing else to configure. bun run install:agent does
the build and that first copy in one step:
bun run install:agent # -> ~/.omp/agent/extensions/omp-weixin.js
bun run install:agent --dir <path> # -> somewhere else (directory or .js path)Rebuild + recopy after editing the sources; remove the file to uninstall.
@oh-my-pi/pi-coding-agent and @oh-my-pi/pi-ai are deliberately left out of
the bundle: omp resolves those imports to the copies bundled inside the running
binary, so the artifact never carries a second agent runtime.
If you would rather ship the sources than a bundle, copy the tree plus its one runtime dependency instead — same discovery, no bundle:
DEST=~/.omp/agent/extensions/omp-weixin
mkdir -p "$DEST" && cp -r src package.json "$DEST"/
cd "$DEST" && bun install --productionomp --profile <name> uses ~/.omp/profiles/<name>/agent as its agent dir, so
copy once per profile, or set PI_CODING_AGENT_DIR to a shared one.
(build --install resolves the agent dir the same way omp does, and accepts the
same environment overrides.)
Use this when you want omp to own the install (lockfile state, enable/disable, upgrades) and symlinks are available:
# from npm (after the package is published — see Publish this package)
omp plugin install omp-weixin
# or straight from git, no publish required
omp plugin install github:<owner>/omp-weixin
# or from this repository as a marketplace catalog
omp plugin marketplace add <owner>/omp-weixin
omp plugin install omp-weixin@omp-weixinomp install is an alias for omp plugin install. Installs land in
~/.omp/plugins (or $XDG_DATA_HOME/omp/plugins), are enabled by default, and
survive restarts. The installer validates the manifest entry before committing:
it resolves omp.extensions, imports the module, and runs the factory against a
throwaway registration surface, rolling the whole install back if that fails.
In the TUI the same operations are /marketplace add, /marketplace discover,
and /marketplace install. Newly installed extension modules are picked up on
the next omp start.
Manage it with omp plugin list, omp plugin disable omp-weixin,
omp plugin enable omp-weixin, and omp plugin uninstall omp-weixin.
For development, or to always run the live checkout instead of a copy:
bun install
omp -e ./src/extension/index.ts # load for this run onlyTo keep it loaded, add the absolute path to your omp config:
# ~/.omp/agent/config.yml
extensions:
- C:/path/to/omp-weixin/src/extension/index.tsUnlike the bundle, this always runs the current working tree — but it breaks if the checkout moves, and having both at once loads the extension twice.
omp plugin install, omp plugin link, and marketplace installs symlink the
package into the scope's node_modules. On Windows that needs Developer Mode (or
an elevated shell); without it the install fails with:
EPERM: operation not permitted, symlink ... -> ~/.omp/plugins/node_modules/omp-weixin
The bundle and checkout routes need no privilege.
bun run install:agent # rebuild the bundle and recopy it
rm ~/.omp/agent/extensions/omp-weixin.js # remove it (bundle route)
omp plugin install omp-weixin@0.2.0 # plugin route: update is install-driven
omp plugin uninstall omp-weixin # plugin route: removeStart omp anywhere and run:
/wechat # scan the QR once, then WeChat drives this session
/wechat stop # give the session back to the terminal
On the first run the QR code is rendered into the conversation — the payload
is a session message, so it stays in scrollback while you pick up your phone. The
bot token is then stored under <agentDir>/weixin/, and later /wechat calls
reuse it without asking again.
- Inbound WeChat messages enter the current session as user turns prefixed
[微信]; images are carried through as image content. - The assistant's reply text is streamed back as discrete WeChat messages. Thinking blocks and tool activity never leave the terminal; text is forwarded verbatim (no markdown stripping).
- Only the WeChat account that scanned the code can drive the session; messages from anyone else are ignored.
- One takeover at a time, last one wins: running
/wechatin another session displaces the previous one, which notices on its next heartbeat and stops. - Terminal input still works, and the reply to it goes to WeChat too — the takeover owns the conversation stream, not individual turns.
- The takeover ends when you
/wechat stop, switch sessions (/new,/resume), are displaced, or exit omp. - The iLink API is 1:1 only: no group chats, and no streaming message state.
State lives in <agentDir>/weixin/ (account.json, sync.json,
context-tokens.json), so each omp profile logs in separately. The weixin:login
CLI writes to <BRIDGE_DATA_DIR>/weixin/ (default .data/weixin/) instead — to
reuse that token with the plugin, point the two at the same directory
(BRIDGE_DATA_DIR=~/.omp/agent bun run weixin:login) or copy it once:
mkdir -p ~/.omp/agent/weixin && cp .data/weixin/account.json ~/.omp/agent/weixin/Do not run the standalone weixin bridge and the plugin at the same time —
the takeover lock only arbitrates between extension instances, and two pollers for
the same account race for the same get_updates_buf cursor.
From a checkout (bun install first):
cp .env.example .env # optional; defaults work
bun run start # interactive console transport
# Personal WeChat (scan the QR with your phone once):
bun run weixin:login
BRIDGE_TRANSPORT=weixin bun run start
# 企业微信 智能机器人:
BRIDGE_TRANSPORT=wecom-aibot WECOM_BOT_ID=... WECOM_BOT_SECRET=... bun run startThe transport is pluggable:
console— local stdio, no WeChat account needed (default; used by the smoke test)weixin— personal WeChat through Tencent's iLink bot APIwecom-aibot— 企业微信 智能机器人 over the WebSocket long connection (official, streaming-native, no public IP, no message encryption)
Smoke test without an interactive terminal:
printf '用中文写三句话介绍微信机器人。\n现在问我一个问题来确认你记得上下文。\n' | bun run src/index.tsbun install
bun run test # bun test — offline, no credentials needed
bun run check # lint + format check + typecheck (the gate)| Script | What it does |
|---|---|
bun run build |
Bundle the plugin into one self-contained file (dist/omp-weixin.js); see Install. |
bun run install:agent |
build --install: bundle it and copy it into <agentDir>/extensions/. |
bun run check |
oxlint . + oxfmt --check + tsc --noEmit; must pass before a commit. |
bun run lint |
oxlint (same rule set omp itself runs). |
bun run fmt |
oxfmt with the repo's .oxfmtrc.json (printWidth: 120). |
bun run check:types |
tsc -p tsconfig.json --noEmit, covering src/, test/, and scripts/. |
bun run test |
bun test. |
bun run start / weixin:login / demo:quickstart |
Bridge entry points. |
Extensions are loaded from TypeScript sources directly, so there is no build step
for development and nothing to publish besides src/. dist/ holds only the
generated bundle (bun run build); it is gitignored — git add -f dist/omp-weixin.js
if you want the artifact committed too.
The manifest is already publishable (omp.extensions, files, license,
engines). Before your first publish, fill in the metadata npm cannot infer —
repository, homepage, and bugs — in package.json, and rename the package
if omp-weixin is taken on the registry (a scope, @you/omp-weixin, is the usual
answer; the omp.extensions manifest path stays the same).
npm login
npm publish # add --access public for a scoped nameThen anyone can install it with omp plugin install omp-weixin.
Git installs need no registry at all — a public git remote is enough, since
omp plugin install github:<owner>/omp-weixin clones the repo and installs its
dependencies:
git init && git add -A && git commit -m "omp-weixin"
git remote add origin git@github.com:<owner>/omp-weixin.git
git push -u origin mainMarketplace: .omp-plugin/marketplace.json
already describes this repository as a one-plugin catalog, so
omp plugin marketplace add <owner>/omp-weixin works as soon as the repo is
public. Bump version in both package.json and the catalog when you tag a
release; the plugin manager compares catalog versions for upgrades.
flowchart LR
WX[WeChat] -->|inbound| T[Transport adapter]
T -->|InboundMessage| R[MessageRouter]
R -->|per-chat queue| P[ChatAgentPool]
P -->|1 session per chatId| S[AgentSession]
S -->|message_update text_delta| C[DeltaCoalescer]
C -->|bounded chunks| T
T -->|outbound| WX
The plugin is the same pipeline shortened: src/extension/takeover.ts owns the
poll loop and its own DeltaCoalescer, but skips the router and the session pool
— it talks to the one session you are in.
| Module | Responsibility |
|---|---|
src/transport/types.ts |
The only contract an adapter must satisfy: InboundMessage / OutboundMessage / Transport. |
src/transport/console.ts |
Local stdio transport. |
src/transport/weixin/* |
Personal WeChat over Tencent's iLink bot API: protocol.ts (wire types, uint64-safe JSON), client.ts (HTTP + QR login), inbound.ts (item → model content), login-flow.ts (QR login state machine), store.ts (durable token / cursor / context tokens), transport.ts (long-poll loop). |
src/cli/weixin-login.ts |
One-time QR login; writes the bot token into the bridge state directory. |
src/extension/* |
The in-process omp extension: index.ts (registration), takeover.ts (session takeover runtime), lock.ts (single-takeover lock), qr-message.ts (QR rendered as a session message). |
scripts/build-plugin.ts |
Bundles the plugin into one self-contained file (deps inlined, @oh-my-pi/* external) and can copy it into <agentDir>/extensions/. |
src/transport/wecom-aibot.ts |
企业微信 智能机器人 long-connection transport (streaming replies). |
src/bridge/agent-pool.ts |
One AgentSession per chatId, persisted to a session file, LRU + idle eviction, per-chat model/tool config. |
src/bridge/chunker.ts |
DeltaCoalescer: turns a token stream into discrete, rate-limited updates. |
src/bridge/router.ts |
Ordering, de-duplication, turn timeout/abort, error replies. |
src/config.ts |
Env config + durable chatId → session file index. |
test/*.test.ts |
Offline unit tests for the two contracts that are easy to break silently: inbound mapping and the takeover lock. |
OutboundMessage carries both the new delta and the whole reply text so each
transport reads the field its protocol wants:
- discrete (console,
weixin, and any future 自建应用 / 公众号 adapter): senddeltaas a new chat message per update. The iLink bot protocol has no streaming state — the reference client only ever sendsFINISH. - streaming (
wecom-aibot): sendtextas a replacement update, then close the stream onfinal.
For wecom-aibot the protocol drops an intermediate update while an ack is
outstanding (replyStreamNonBlocking → "skipped"); nothing is lost, because
the next update carries the whole reply. The final update always goes out on the
blocking path so a stream can never be left open.
- One session per chat. A session carries the conversation; sharing one
across chats leaks context between users. Sessions are file-backed so a
restart resumes each chat's history (
ChatSessionIndex), and are built lazily because construction costs ≈1 s (extension/skill/tool discovery). - Streamed replies must be re-paced.
text_deltaevents are not messages; the coalescer flushes only on a sentence/paragraph boundary once the buffer is long enough, and never sends twice withinBRIDGE_MIN_SEND_INTERVAL_MS— a fast model would otherwise trip a transport's anti-flood limit (wecom-aibot: 30 messages/minute per session). - Strict per-chat ordering. A session cannot run two turns at once, so messages queue per chat. Different chats run in parallel.
- De-duplication. Transports retry; agents are not idempotent, so inbound
ids are dropped on re-delivery within a bounded window.
wecom-aibot'smsgidis already the protocol's de-dup key. - Bounded turns.
BRIDGE_TURN_TIMEOUT_MSaborts a stuck turn (session.abort()) and answers the chat instead of leaving it silent forever. - Restricted tool surface. Sessions run with
restrictToolNames: true, which also disables extension/MCP/LSP discovery — a chat bot must not inherit a developer machine's ambient tooling.
- It owns the conversation, not the turn. The extension subscribes to
message_update/agent_start/agent_endfor the session it was started in, so any reply produced there — including the one to something you typed — goes to WeChat. Filtering by turn origin would need a second session, which is the bridge's model. - Inbound turns queue, never steer. A WeChat message arriving mid-turn is
delivered as
deliverAs: "followUp"; omitting it would be treated as a steer and cut the running turn short, so the takeover tracks its own busy flag rather than asking the session whether it is idle. - Only reply text crosses over. Forwards
text_deltaonly, so thinking blocks and tool output stay in the terminal. The coalescer, chunk caps, and send interval are the bridge'sDeltaCoalescerand its defaults, because the anti-flood limits are properties of the chat channel, not of the host. - Timers go through
ctx.setInterval. Extension code shares the host process, so a stray timer that throws would surface as an uncaught exception and take the session down with it. - Last takeover wins. The lock file is claimed unconditionally so
/wechatalways takes over the session you are looking at; the displaced instance sees the new owner on its next heartbeat and stops itself. A missing or corrupt lock is re-claimed rather than treated as fatal. - Shutdown is best-effort and idempotent.
stop()aborts the poll, clears timers, tells the server the bot is offline, and releases the lock — from the command, a heartbeat, a session switch, or process teardown. - No bundled copy of the agent runtime.
@oh-my-pi/pi-coding-agentand@oh-my-pi/pi-aiare optional peers: omp rewrites those imports to the copy bundled in the running binary, so the plugin never ships a second runtime. Only genuinely external dependencies (qrcode-terminal, the WeCom SDK) aredependencies.
| Env | Default | Meaning |
|---|---|---|
BRIDGE_TRANSPORT |
console |
Transport adapter. |
OMP_MODEL |
opencode-go/deepseek-v4.1-flash |
Model selector or glob. |
OMP_CWD |
process cwd | Directory the agent's tools operate on. |
OMP_SYSTEM_PROMPT |
chat-persona default | Appended to the rendered system prompt. |
OMP_TOOLS |
read,grep,glob |
Model-visible tools (restricted allowlist). |
BRIDGE_MIN_FLUSH_CHARS |
24 |
Minimum mid-turn chunk size. |
BRIDGE_MAX_CHUNK_CHARS |
1200 |
Hard per-message cap. |
BRIDGE_MIN_SEND_INTERVAL_MS |
800 |
Minimum gap between sends in one reply. |
BRIDGE_IDLE_FLUSH_MS |
2500 |
Flush without a boundary after this quiet period. |
BRIDGE_TURN_TIMEOUT_MS |
180000 |
Abort a turn after this. |
BRIDGE_MAX_CHATS |
32 |
Live session cap (LRU eviction). |
BRIDGE_IDLE_MS |
1800000 |
Idle session eviction. |
BRIDGE_DATA_DIR |
.data |
Session index + session files. |
WECOM_BOT_ID / WECOM_BOT_SECRET |
— | Required by wecom-aibot; from 企业微信后台 → 智能机器人. |
WECOM_WS_URL |
SDK default | Long-connection endpoint override. |
WEIXIN_BOT_AGENT |
omp-weixin/1 |
bot_agent product token reported to the weixin API. |
WEIXIN_CHANNEL_VERSION |
1.0.0 |
channel_version / iLink-App-ClientVersion, as 0x00MMNNPP. |
The weixin bot token is not an env var: bun run weixin:login writes it to
<BRIDGE_DATA_DIR>/weixin/account.json (alongside sync.json and
context-tokens.json). The extension keeps the same three files under
<agentDir>/weixin/, so WEIXIN_BOT_AGENT and WEIXIN_CHANNEL_VERSION apply to
both surfaces.
Implement three methods — see src/transport/types.ts:
class MyTransport implements Transport {
readonly name = "my-wechat";
async start(handler: (m: InboundMessage) => void) { /* wire callbacks → handler */ }
async send(m: OutboundMessage) {
// Discrete transport: deliver `m.delta` as a new message.
// Streaming transport: publish `m.text` as a replacement and close on `m.final`.
}
async stop() { /* unsubscribe, close sockets */ }
}Then register it in src/transport/index.ts. Nothing in src/bridge/ changes.
Rules an adapter must honour:
- Give every inbound message a stable
idacross retries. - Use a stable
chatIdper conversation — it selects the agent session. - Strip the bot's own @-mention from
textbefore callinghandler. - Never throw from
handler; it returns before the turn runs. - A discrete transport must ignore an update whose
deltais empty: the router always emits a closing update (final: true), and for a streaming transport that update carries no new text — it exists only to close the stream.