feat(bindings): add ACP thread binding adapter for WeChat (fixes #55)#56
Open
lyfuci wants to merge 4 commits intoTencent:mainfrom
Open
feat(bindings): add ACP thread binding adapter for WeChat (fixes #55)#56lyfuci wants to merge 4 commits intoTencent:mainfrom
lyfuci wants to merge 4 commits intoTencent:mainfrom
Conversation
The ChannelPlugin type in 2026.3.23 did not yet expose the conversationBindings seam that the new ACP thread binding adapter plugs into. Bumping the dev dependency so typecheck can validate the new wiring. Runtime host requirements are unchanged (the plugin still follows the openclaw.plugin.json min host version window).
pairing.ts imports withFileLock from the infra-runtime subpath, but the test only mocked the root `openclaw/plugin-sdk` barrel. With openclaw 2026.4.x the subpath is a distinct module graph node, so the root mock no longer intercepts the import and the concurrency-safety test fails with 'expected spy to be called 1 times, but got 0 times'. Add a parallel vi.mock() for the subpath so the real file-lock import path is stubbed regardless of which barrel the production code picks.
runtime.ts was previously uncovered by any test. Add lightweight unit tests for setWeixinRuntime / getWeixinRuntime / waitForWeixinRuntime / resolveWeixinChannelRuntime, including the 'no ctx, no global yet, wait for register' fallback path. Also raises overall branch coverage ahead of the thread-bindings addition.
Lets `sessions_spawn(runtime="acp", mode="session", thread=true)` actually work on the WeChat channel instead of failing with `thread_binding_invalid: Thread bindings are unavailable for openclaw-weixin`. What this unlocks: - Persistent multi-turn Claude Code / Codex / Gemini CLI sessions bound to a WeChat conversation. Follow-up prompts can now reuse the same ACP session via sessions_send instead of cold-starting a fresh harness every turn. - The runtime picks up the adapter via `conversationBindings` on the weixin channel plugin; the openclaw ACP runtime dispatches bind / resolve / touch / unbind through it the same way it does for Telegram, Discord, LINE, iMessage, and BlueBubbles. Implementation notes: - src/thread-bindings.ts is a slimmed-down port of the Telegram adapter in openclaw/openclaw (extensions/telegram/src/thread-bindings.ts). Group / forum topic handling is removed because openclaw-weixin currently treats all inbound traffic as 1:1 direct chat; only the `current` placement is advertised. - Conversation ids are just the sender's WeChat id (`xxx@im.wechat`) scoped per bot accountId. This matches the existing messaging layer, which uses `from_user_id` as both From and To. - Binding state persists to $STATE_DIR/openclaw-weixin/thread-bindings-<accountId>.json via writeJsonFileAtomically, and a 60s sweeper removes idle- or max-age-expired bindings. Both defaults mirror Telegram's (24h idle timeout, no max age cap). - Eager per-account init hooks in startAccount so the persistent store is loaded and the sweeper starts before any inbound traffic arrives. The conversationBindings.createManager hook dedupes subsequent framework-driven calls to the same manager instance. Coverage: - src/thread-bindings.test.ts adds 27 unit tests covering the adapter's bind / resolve / touch / unbind flows, metadata merge semantics, on-disk store load/reload, version / bad-entry rejection, idle-expiry sweeping, and the 'non-expired bindings are left alone' path. - All existing tests still pass; overall vitest coverage stays above the repo's 90% line / branch / statement threshold. Remaining work for a follow-up (called out so reviewers know what is intentionally out of scope): - `setIdleTimeoutBySessionKey` / `setMaxAgeBySessionKey` are not wired into conversationBindings. Plumbing those through matches Telegram's runtime knob but was skipped here to keep the PR focused on the 'make the feature available at all' path. - A 'farewell message on unbind' hook is not implemented; WeChat delivery semantics are different from Telegram's and warrant a separate design conversation with maintainers. - Group / chat-room awareness: if openclaw-weixin grows first-class group support, the adapter should learn a `child` placement that maps to room-level conversation ids.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds ACP thread binding support to the WeChat channel so
sessions_spawn(runtime="acp", mode="session", thread=true)actually worksinstead of failing with
thread_binding_invalid: Thread bindings are unavailable for openclaw-weixin.Closes #55.
Why
Today every
sessions_spawn(runtime="acp")from a WeChat conversation has tofall back to
mode="run"(one-shot). Each call spawns a fresh Claude Code /Codex / Gemini CLI process, runs one turn, and loses all state:
acpxcloses the ACPconnection
The fix is to register a
SessionBindingAdapterfor the WeChat channel sopersistent ACP sessions can be tied to a WeChat 1:1 conversation, matching
what Telegram / Discord / LINE / iMessage / BlueBubbles already do.
What's in the change
Four commits, each scoped so reviewers can read them independently:
chore(deps): bump openclaw devDep to ^2026.4.9The
ChannelPlugin.conversationBindingsseam was added after 2026.3.23,so typecheck needs a newer dev dependency. Runtime host requirements are
unchanged (still governed by
openclaw.plugin.jsonminHostVersion).test(pairing): also mock openclaw/plugin-sdk/infra-runtime subpathPre-existing test regression exposed by the dev dep bump.
pairing.tsimportswithFileLockfrom theinfra-runtimesubpath, butpairing.test.tsonly mocked the rootopenclaw/plugin-sdkbarrel. With2026.4.x the subpath is a distinct module graph node so the root mock no
longer intercepts. One-line fix: add a parallel
vi.mock()for thesubpath. Not a behavior change — just a test infrastructure fix for
the bump.
test(runtime): add unit coverage for weixin runtime singleton helperssrc/runtime.tswas previously at 0% coverage. Added 6 thin unit testsfor
setWeixinRuntime/getWeixinRuntime/waitForWeixinRuntime/resolveWeixinChannelRuntime. This lifts baseline coverage enough toabsorb the new file in commit 4 without tripping the 90% threshold.
feat(bindings): add ACP thread binding adapter for WeChatThe actual feature:
src/thread-bindings.ts(~580 LOC) — per-account binding manager +SessionBindingAdapter+ persistent JSON store + 60s idle/max-agesweeper. Structurally a slimmed-down port of
extensions/telegram/src/thread-bindings.tsfrom the openclawmonorepo, with forum-topic / group handling removed.
src/thread-bindings.test.ts(~400 LOC, 27 tests) — bind / resolve /touch / unbind adapter flow, metadata merge, persistent store load /
reload, version rejection, bad-entry rejection, sweeper expiry, sweeper
no-op on non-expired records.
src/channel.ts— importcreateWeixinThreadBindingManager, eagerlyinitialize it in
gateway.startAccount(once per account), and add theconversationBindingsblock toweixinPluginso the openclaw ACPruntime discovers the adapter.
Design decisions (call out for the reviewer)
<from_user_id>(e.g.xxx@im.wechat) scoped perbot
accountId. This matches howweixinMessageToMsgContextalreadyroutes inbound traffic —
From,To, andOriginatingToare all set tofrom_user_id, andgroup_idis defined inWeixinMessagebut neverconsumed anywhere in the plugin. Treating the plugin as 1:1-only keeps
this PR small. If group / chat-room support lands later, the adapter
should learn a
childplacement that maps to a room-level conversationid, similar to Telegram's forum topics.
$STATE_DIR/openclaw-weixin/ thread-bindings-<accountId>.json, using the existingwriteJsonFileAtomicallyhelper fromopenclaw/plugin-sdk/json-storetoavoid torn writes. Load errors are downgraded to
logVerboseso acorrupt file doesn't brick the plugin.
DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS/MAX_AGE_MS, so operatorswho already know Telegram get the same behavior.
currentplacement is advertised inSessionBindingAdapter.capabilities.placements. Thebindmethodrejects
placement: "child"requests outright rather than silentlytreating them as
current.Out of scope (happy to follow up)
setIdleTimeoutBySessionKey/setMaxAgeBySessionKey—conversationBindingsaccepts these as optional hooks and Telegram wiresthem to runtime knobs. Left out to keep the PR focused; trivial to add
once the approach here is accepted.
hint to the chat). WeChat delivery semantics are different enough that
this warrants a separate design conversation.
of whether openclaw-weixin will handle
group_idtraffic at all.Test plan
npm run typechecknpm test— 370 tests passing, coverage 91.38% lines / 90.1% branches/ 96.29% funcs (above the repo's 90% threshold)
npm run buildpersistent ACP Claude Code session from a WeChat conversation with
mode="session", thread=truewithout hittingthread_binding_invalid, and a follow-upsessions_sendis routedback to the same harness session.
One pre-existing unrelated note:
src/auth/pairing.test.tswas failing onmainimmediately after theopenclaw dev dep bump due to a mock subpath mismatch (see commit 2). I
included a single-line fix for it so CI stays green. If you would rather
land that separately I'm happy to cherry-pick it into its own PR.