Skip to content

About

一个通过qq与oh-my-pi聊天的插件此,采样bun构建

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

omp-weixin

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 — /wechat takes 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 omp agent 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.

Install

Requirements

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

Build once, then copy one file (simplest)

bun run build

That 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 only

omp 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 --production

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

As an omp plugin

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

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

From a checkout (no copy at all)

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 only

To keep it loaded, add the absolute path to your omp config:

# ~/.omp/agent/config.yml
extensions:
  - C:/path/to/omp-weixin/src/extension/index.ts

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

Windows: symlink privilege

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.

Updating and removing

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

Use it from WeChat

Start 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 /wechat in 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.

Run the bridge

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 start

The transport is pluggable:

  • console — local stdio, no WeChat account needed (default; used by the smoke test)
  • weixin — personal WeChat through Tencent's iLink bot API
  • wecom-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.ts

Development

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

Publish this package

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 name

Then 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 main

Marketplace: .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.

Architecture

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
Loading

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.

Two delivery styles

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): send delta as a new chat message per update. The iLink bot protocol has no streaming state — the reference client only ever sends FINISH.
  • streaming (wecom-aibot): send text as a replacement update, then close the stream on final.

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.

Why the bridge is shaped this way

  • 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_delta events are not messages; the coalescer flushes only on a sentence/paragraph boundary once the buffer is long enough, and never sends twice within BRIDGE_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's msgid is already the protocol's de-dup key.
  • Bounded turns. BRIDGE_TURN_TIMEOUT_MS aborts 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.

Why the takeover is shaped this way

  • It owns the conversation, not the turn. The extension subscribes to message_update/agent_start/agent_end for 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_delta only, so thinking blocks and tool output stay in the terminal. The coalescer, chunk caps, and send interval are the bridge's DeltaCoalescer and 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 /wechat always 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-agent and @oh-my-pi/pi-ai are 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) are dependencies.

Configuration

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.

Writing a transport adapter

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 id across retries.
  • Use a stable chatId per conversation — it selects the agent session.
  • Strip the bot's own @-mention from text before calling handler.
  • Never throw from handler; it returns before the turn runs.
  • A discrete transport must ignore an update whose delta is 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.

About

一个通过qq与oh-my-pi聊天的插件此,采样bun构建

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages