Skip to content

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

Draft
TunaTung wants to merge 1 commit into
GCWing:mainfrom
TunaTung:feat/weixin-proactive-push
Draft

TunaTung wants to merge 1 commit into
GCWing:mainfrom
TunaTung:feat/weixin-proactive-push

Conversation

@TunaTung

Copy link
Copy Markdown
Contributor

Problem

The Weixin iLink bot only ever answered messages it received. The single send path was the inbound forward:

handle_incoming_message -> execute_forwarded_turn -> send_text

A turn the bot did not start — a scheduled job, the desktop window, another controller — produced assistant output that no code path delivered. A scheduled job in the peer's bound session ran to completion (lastRunStatus: "ok") while the user's phone stayed silent, and the local relay showed the transcript fully drained, so the loss was not a transport failure.

The channel constrains how much we may send

The OpenBitFun product copy for this channel records the restriction (i18n key botWeixinRestriction):

WeChat ClawBot limits the bot to 10 replies within 24 hours after the user sends a message. Split long replies also consume the quota. When the window or quota ends, send another message in WeChat to reactivate it.

This is a product-level channel restriction, not a wire-protocol field: Tencent/openclaw-weixin documents only that the inbound context_token must be passed back on a reply. The same quota answers the user's own messages, so a proactive push that spends it freely would break interactive replies. That constraint shapes the design below.

Change

Subscribe the bot to AgenticEvent::DialogTurnCompleted and deliver the completed turn's text to the peer bound to that session.

A turn is skipped when this bot started it. The bot records the turn_id it generates for an inbound forward (ForwardRequest::turn_id) before submitting, and the same id appears on completion, so the inbound path keeps owning its own reply and nothing is delivered twice. Failed turns are skipped as well; 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 comes from the existing replay-safe observe_turn projection, so bot delivery agrees with what the other remote surfaces show.

Saving the reply quota

  • A backlog becomes one reply, not one reply per turn. Each peer's queued output is merged into a single message in time order, separated readably, and handed to the existing send_text, which still splits at one channel part.
  • The merged payload is capped at one part, so one push spends exactly one reply. The oldest outputs are dropped first; when the newest output alone exceeds the cap its head is sent, because the newest answer is the one the user is waiting for.
  • This path keeps its own share of 3 replies per rolling 24 hour window, which it can never overspend. A rolling window (rather than a fixed one) means at most 3 proactive replies in any 24 hours, so no burst is possible at a window boundary. The remaining 7 stay available for the replies the user's own messages receive.
  • The replies a push will spend are predicted before sending, through weixin_reply_count(), which is defined on top of the same splitter send_text_chunks uses so the count cannot drift from what is actually sent. A push that does not fit the share waits instead of overspending, and the backlog is held until the window rolls.
  • Nothing is dropped while waiting. Output is queued rather than sent directly, so it survives both a missing context_token (remember_context_token wakes the worker as soon as the user next talks to the bot) and a send that fails on a stale token (send_text still drops the stale token exactly as before, and the retry succeeds once a fresh one arrives).

Bounds

Per peer: at most 20 queued entries, at most 1 hour old, merged payload capped at one channel part. One worker per bot drains the backlog, which preserves order and keeps two triggers from delivering the same queued message. The subscriber spawns its work instead of running it inline, because 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; the subscription id is unique per instance, so a retired bot unsubscribes only its own hook.

Before / after

Before After
Scheduled job in the bound session Turn completes; text discarded Turn completes; text pushed to the peer
Desktop-window turn in the bound session Never delivered Pushed to the peer
Inbound message Replied once by the forward path Unchanged: replied once (the proactive path skips it)
Several turns finish while no reply is possible n/a Merged into one reply, spending one reply of the quota
Reply share spent (3 in 24h) n/a Backlog held and delivered when the window rolls; one warning
No context_token cached Send fails; content lost Queued; delivered when remember_context_token next fires
Send fails on a stale token Send fails; content lost Stale token dropped as before; content retried
One long answer larger than one part Sent as-is Truncated to one part on a character boundary

Scope

  • src/crates/assembly/core/src/service/remote_connect/bot/weixin.rs — the subscription, the merge, the quota accounting, the backlog.
  • src/crates/services/services-integrations/src/remote_connect/bot/weixin.rsMAX_TEXT_CHUNK becomes pub and weixin_reply_count() is added next to the existing splitter.

Purely additive on the provider side: send_text, send_text_chunks and the context_tokens cache are unchanged.

Note for reviewers

Because the proactive push covers the whole bound session, turns the user drives from the desktop window are pushed too. That follows from "output of the session this peer is bound to", but it does change desktop behaviour, so binding the bot to a dedicated assistant session is recommended. A follow-up could gate this behind a per-connection setting.

The proactive share is self-imposed and does not observe what the inbound reply path already spent, which is exactly why it is small. If the platform rejects a push because the shared quota is exhausted, it surfaces as the existing context_token send failure, and the content stays queued rather than being lost.

The main runtime assumption worth a second pair of eyes: observe_turn must already see the turn's assistant message when DialogTurnCompleted fires. The inbound path relies on the same ordering, and the projection is the one it uses, but this is the piece that decides whether the feature has anything to send.

Testing

Unit tests cover the ownership/failure predicate, the pre-merge and retention bounds, merging (order, separator, blank entries), the byte cap (drop-oldest and single-oversized truncation, character boundary safety), the reply-share budget (sufficient, insufficient, exactly-spent, oversized single push) and the rolling window. Two tests in services-integrations cover the reply-count prediction at the 4000/4001 byte boundary and the byte-vs-character semantics. The text projection reuses the existing tested observe_turn.

AI assistance and testing level

This change was authored with AI assistance and reviewed by a human before submission.

Testing level: not compiled or executed locally. The machine used to write this patch has no Rust toolchain and no MSVC linker available, so cargo check and cargo test could not be run; the unit tests above have not been executed. Please treat the first CI run as the compile gate, and expect a possible format/borrow-check follow-up.

One part of the premise was verified manually against the live channel: a direct POST /ilink/bot/sendmessage using the official wire shape ({"msg": {...}, "base_info": {...}} plus the iLink-App-Id / iLink-App-ClientVersion headers) and a cached context_token returned HTTP 200 {"message_id": ...}, confirming that a non-reply send is accepted inside an open window. That verifies the channel, not this code path. An end-to-end run of this patch on a bound session with a scheduled job has not been performed.

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

1 participant