Skip to content

feat(remote-connect): push weixin bot output from non-inbound turns - #3163

Merged
bobleer merged 4 commits into
GCWing:mainfrom
TunaTung:feat/weixin-proactive-push
Sep 22, 2026
Merged

bobleer merged 4 commits into
GCWing:mainfrom
TunaTung:feat/weixin-proactive-push

Conversation

@TunaTung

@TunaTung TunaTung commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Problem and behavior

The Weixin bot replies to turns started by incoming WeChat messages, but did not deliver results when a scheduled job completed in its bound session—even when the WeChat reply context was still valid. Subscribe to the persisted-turn completion fence and push the final text to the peers bound to that local session. Desktop-started turns in the same session are included; bot-owned turns keep their existing reply path to avoid duplicate replies. Failed turns and empty output are skipped.

Delivery behavior

  • Wait for SessionHistoryChanged with settled_turn_id, emitted after the final result is persisted; the earlier DialogTurnCompleted event can precede that write. Reuse the existing turn projection on persisted messages, ignoring empty or partial active overlays, and retain the existing provider send path. A single worker drains per-peer backlogs without blocking the event router.
  • Merge pending results into one message, favoring newer results. Each queued item and merged payload is bounded to 4,000 UTF-8 bytes; oversized text ends with an ellipsis.
  • Respect the provider's reply context and quota. There is no extra hard-coded three-push daily quota: a fourth scheduled result can be delivered while the channel permits it. Proactive pushes and ordinary replies share the platform quota.
  • Missing reply context or provider failures retain the backlog for retry; incoming messages refresh the context and wake the worker. Retain at most 20 entries per peer for 24 hours. This is a best-effort in-memory queue: overflow/expiry, a bot restart/replacement, or changing the selected session/device can discard pending output. It is not a durable inbox or exactly-once delivery guarantee.
  • Acknowledge stable message IDs, not a snapshot's length. New messages enqueued while an HTTP send is in flight cannot be incorrectly removed when capacity eviction changes the queue's positions.
  • Revalidate session/device binding before sending, discard stale selections, and fence retired bot instances.

Remote scope

The bot and session runtime must be on the same host. When a user switches WeChat to another account device, the existing reply explicitly states that proactive scheduled-job/desktop-result delivery is unavailable there; ordinary inbound requests retain their existing remote reply path. An SSH workspace on the bot's host is distinct from switching to another account device. This PR does not add cross-host Peer Device or Detached Dispatch result delivery.

The Desktop README documents the scope, quota sharing, truncation, retention, and restart behavior. A dedicated session is recommended when desktop activity should not be pushed to WeChat.

Validation

  • cargo test --locked -p openbitfun-core --no-default-features --features remote-connect --lib service::remote_connect::bot:: — 68 passed.
  • cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-connect --lib remote_connect::bot::weixin::tests — 22 passed.
  • pnpm run i18n:audit — passed (using the existing local Node dependency installation).
  • pnpm run fmt:rs and git diff --check — passed.

The new loopback HTTP tests exercise concurrent overflow during an in-flight send, missing context, transient provider failure and retry, and four sequential proactive replies. Additional tests cover the completion/persistence event ordering, empty/partial live overlays versus final persisted text, legacy assistant message IDs, failed/cancelled turns, byte/age bounds, session/remote-device switching, all bound peers, retired-instance fencing, and the remote limitation notice in English, Simplified Chinese, and Traditional Chinese.

Remote-control behavior was exercised with a loopback Weixin provider and simulated local/remote bindings. No live WeChat scheduled-job end-to-end run, real SSH workspace, Peer Device, or Detached Dispatch deployment was exercised locally. The original author manually verified that the live iLink endpoint accepts a send with a valid cached context token; that does not constitute end-to-end validation of this patch. The latest CI run remains the cross-platform compile/test gate.

This change was authored and subsequently reviewed/updated with AI assistance.

The Weixin iLink bot only ever answered messages it received: the sole send
path was the inbound forward (handle_incoming_message -> execute_forwarded_turn
-> send_text). Assistant output produced by a turn the bot did not start - a
scheduled job, the desktop window, another controller - was never delivered, so
a scheduled job in the peer's bound session could run to completion while the
user's phone stayed silent.

Subscribe the bot to AgenticEvent::DialogTurnCompleted and deliver the turn's
text to the peer bound to that session. A turn is skipped when this bot started
it, because the inbound path already answered it and pushing it again would
deliver every inbound message twice. Failed turns are skipped too; whether there
is anything to send is decided by the turn's own text, so a degraded turn that
still carries an explanation is not lost. The text is read through the existing
replay-safe observe_turn projection, so bot delivery agrees with what the other
remote surfaces show.

Delivery respects the channel reply quota. The OpenBitFun product copy for this
channel records that WeChat ClawBot allows the bot at most 10 replies in the 24
hours after the user sends a message, and that a split long reply spends one
reply per part. That quota is also what answers the user's own messages, so this
path:

- merges a peer's whole backlog into a single reply instead of spending one
  reply per turn, keeps the turns in time order, and separates them readably;
- caps the merged payload at one channel part, dropping the oldest output first
  and truncating the newest on a character boundary only when it alone exceeds
  the cap;
- keeps its own share of 3 replies per rolling 24 hour window, which it can
  never overspend, leaving the rest of the channel quota untouched;
- counts the replies a push will actually spend - the parts the channel splits
  it into - and refuses to send when the share cannot cover them, holding the
  backlog until the window rolls instead;
- still queues when no context_token is available and keeps the backlog when a
  send fails on a stale token, so output waits for the next inbound message
  rather than being dropped.

The backlog is bounded per peer, by age, and by merged payload size. One worker
per bot drains it, which preserves order and keeps two triggers from delivering
the same queued message. The subscriber spawns its work instead of running it
inline: the event router awaits every subscriber while this path reads the
session and sends over the network. is_lifecycle_current() is checked on entry,
after the session read, and in the worker loop, so a replaced bot cannot push,
and the subscription id is unique per instance so a retired bot unsubscribes
only its own hook.

Reuses send_text / send_text_chunks and the existing context_tokens cache; no
protocol change. weixin_reply_count() is added next to chunk_text_for_weixin so
the predicted reply count cannot drift from what send_text_chunks sends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TunaTung
TunaTung force-pushed the feat/weixin-proactive-push branch from 42a9b4d to 44c62d3 Compare September 21, 2026 00:56
@GCWing
GCWing requested a review from bobleer September 22, 2026 00:54
@bobleer
bobleer marked this pull request as ready for review September 22, 2026 02:19
@bobleer
bobleer merged commit 1449995 into GCWing:main Sep 22, 2026
13 checks passed
@TunaTung
TunaTung deleted the feat/weixin-proactive-push branch October 2, 2026 07:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants