fix(channels/qq-sidecar): recover passive-reply msg_id via ChannelUser.librefang_user#5431
Conversation
…r.librefang_user QQ Bot OpenAPI v2 routes any POST to /channels/.../messages or /groups/.../messages WITHOUT msg_id as a 主动 (proactive) message. Proactive sends are blocked for unlicensed bots and quota-capped hard for licensed bots — reactive bot conversations require the inbound message's msg_id be sent back as the passive-reply correlation key. PR #5325 (the QQ sidecar landing) tried to carry the inbound msg_id through cmd.thread_id, expecting the daemon's bridge to round-trip it back to on_send. But the bridge only sets cmd.thread_id when BOTH: * the channel config has [channels.qq.overrides] threading = true (defaults to false; verified at crates/librefang-channels/src/bridge.rs:2968-2973), AND * the sidecar declares the "thread" capability (verified at crates/librefang-channels/src/sidecar.rs:1372-1395 — without it send_in_thread degrades to plain send which hardcodes thread_id: None at line 1203). QQ sidecar declares `capabilities = []` (qq.py:665), so neither preorder fires for the default operator. Net effect: every QQ reply in production goes out missing msg_id, gets classified as proactive, and either fails outright or burns the daily quota silently. Operator never sees the failure because on_send returns Ok and the QQ API returns a 200 with a quiet rate-limit notice. The bug was not caught by the test suite because `_make_send` defaulted `thread_id="src-1"` (test_qq_adapter.py:695-698), so every test fabricated the correlation the daemon would never produce. Structurally identical to the dingtalk on_send bug fixed in #5423. Fix: - parse_qq_event also stores msg_id_raw in ChannelUser.librefang_user (which the bridge round-trips bytewise through serde regardless of capabilities/overrides — verified at sidecar.rs:766 inbound, :1204 outbound). - on_send reads cmd.user["librefang_user"] FIRST, falls back to cmd.thread_id for the forward-compat threading=true path. Rejects URL-shaped or whitespace-bearing values (librefang_user is shared across channels — dingtalk puts a sessionWebhook URL there, telegram puts the @username string — we must not POST replies with garbage in msg_id). Tests: - New test_on_send_recovers_msg_id_from_user_librefang_user drives the realistic daemon shape (thread_id=None, librefang_user=msg_id) and asserts the outbound body contains msg_id. Fails on the original PR #5325 code — regression guard. - New test_on_send_ignores_url_shaped_librefang_user pins the cross-channel-pollution guard. - New test_on_send_thread_id_fallback_still_works keeps the forward-compat fallback alive. - cd sdk/python && pytest tests/test_qq_adapter.py — 80 passed (was 77; +3 regression guards). Related: dingtalk had the structurally identical bug fixed by ChannelUser.librefang_user routing in #5423. Six other sidecars (mastodon, nextcloud, reddit, twitch, bluesky, rocketchat) also read cmd.thread_id for outbound correlation while declaring no "thread" capability — they're either affected to varying degrees or have already worked around it differently. Audit pending in a follow-up.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5a69aa0876
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (isinstance(candidate, str) and candidate | ||
| and "/" not in candidate | ||
| and " " not in candidate | ||
| and "\t" not in candidate | ||
| and not candidate.startswith(("http://", "https://"))): |
There was a problem hiding this comment.
Reject username-like librefang_user before using as msg_id
The new msg_id recovery path accepts any non-empty string that lacks /, spaces, tabs, and http(s)://, so values like @alice still pass validation and are sent as QQ msg_id. This matters in the exact cross-channel pollution case described in the comments (e.g., Telegram-style usernames in librefang_user): because msg_id is set from that username, the thread_id fallback is skipped and QQ receives a non-message-id correlation key, which can cause passive replies to be rejected or reclassified as proactive sends.
Useful? React with 👍 / 👎.
…nnelUser.librefang_user across mastodon, nextcloud, reddit, twitch, bluesky, rocketchat (#5439) Same root cause as the dingtalk (#5423) and qq (#5431) fixes. All six sidecars read cmd.thread_id in on_send to recover a per-message correlation key (mastodon: status_id; nextcloud: msg id; reddit: parent fullname; twitch: tag-id; bluesky: at-URI; rocketchat: tmid). But the daemon's bridge only sets cmd.thread_id when BOTH: * the channel config has [channels.X.overrides] threading = true (defaults to false; verified at crates/librefang-channels/src/bridge.rs:2968-2973), AND * the sidecar declares the "thread" capability (verified at crates/librefang-channels/src/sidecar.rs:1372-1395 — without it, send_in_thread degrades to plain send which hardcodes thread_id: None at line 1203). All six declare `capabilities: list = []`. Production operators with default `threading = false` (and even with threading=true, because the cap-gate fails first) get cmd.thread_id = None on every reply. Net effect, by severity (ranked by user-visible damage): HIGH: reddit — every reply RAISED RuntimeError("missing parent fullname"); RuntimeError caught by SDK's bare-except on_command wrapper and logged to stderr only. Operator saw no message land. Bot looked healthy. MED: nextcloud / rocketchat — bot's "thread reply" posted at channel root with no link back to the trigger. The docstrings of both modules explicitly advertise this as their headline improvement over Rust parity; the modules in fact shipped the same bug. MED: bluesky — reply intent became top-level skeet, no thread context. Visible to all followers' feeds (not just the thread participants). LOW: mastodon — reply degraded to top-level toot; message still landed but UI lost the in-reply-to context. Bonus pre-existing semantic bug ALSO fixed: parse surfaced status.in_reply_to_id (the PARENT the mention was responding to) as the reply target instead of status.id (the mention itself); the bot would have replied to the wrong account even if the round-trip worked. LOW: twitch — chat UI lost the @reply-parent-msg-id badge; plain PRIVMSG still delivered the text. The bugs were not caught by the test suites because every on_send test fabricated cmd.thread_id="..." on a stub — the realistic daemon shape (thread_id=None, user.librefang_user=<id>) was uncovered. Fix (uniform across all six): - parse_X_event: also stash the correlation key in ChannelUser.librefang_user (which the bridge round-trips bytewise through serde regardless of capabilities/overrides — verified at sidecar.rs:766 inbound, :1204 outbound). Keep the existing thread_id assignment for the forward-compat threading=true path. - on_send: read cmd.user["librefang_user"] FIRST, fall back to cmd.thread_id. Sanity guard — reject URL-shaped or whitespace- bearing values (librefang_user is shared across channels; dingtalk puts a sessionWebhook URL there, telegram puts an @username — we must not POST replies with cross-channel garbage as the correlation key). - Reddit gets the strongest guard: enforce t{1,3,4,5}_ prefix. - Bluesky gets the strongest guard: enforce at:// prefix. Tests (per adapter — at minimum one regression guard each): - test_on_send_recovers_X_from_user_librefang_user drives the realistic daemon shape and asserts the outbound payload contains the correlation key. Each fails on the pre-fix code. - reddit gets a second test pinning the prefix-guard (rejects URL-shaped librefang_user, falls back to thread_id). - mastodon parse-test rewritten — the pre-fix test pinned the buggy behaviour (asserted thread_id == in_reply_to_id which was the wrong target). Verification: cd sdk/python && pytest tests/test_{mastodon,nextcloud, reddit,twitch,bluesky,rocketchat}_adapter.py — 343 passed (was 336; +7 regression guards across 6 adapters, -1 pre-fix wrong-behaviour assertion deleted via rewrite). Pre-existing structural issue NOT fixed here (out of scope): - SDK runtime.py:245-247 has a bare-except on on_command that swallowed Reddit's RuntimeError silently. The immediate fix here is to not raise in the first place (no production path now reaches the missing-parent branch), but the bare-except itself is a footgun — future raises from on_send will also vanish to stderr only. Worth a follow-up: re-raise after logging so the bridge can record the failure and trigger the agent's error-handling path. Same investigation pattern: dingtalk #5423 first surfaced this bug class via post-PR self-review; qq #5431 confirmed it generalised; this PR closes out the remaining six known cases.
…r across sidecar restart (#5448) The in-process `_user_context_tokens[user_id]` cache populated by inbound iLink callbacks vanishes on sidecar restart (any operator upgrade, debug pass, or supervisor respawn). Without the context_token, the bot's first reply after restart posts with an empty token — iLink may reject it or post out-of-thread. Fix: parse_wechat_msg also surfaces `context_token` via `ChannelUser.librefang_user`, which the bridge round-trips bytewise through serde (verified at crates/librefang-channels/src/sidecar.rs:766 inbound, :1204 outbound) so it survives serde + restart cleanly. The in-memory cache stays as the freshness signal (the latest token Lark/ iLink issued is more current than whichever inbound the daemon happens to round-trip back) and takes precedence in on_send. Tests: - New test_on_send_recovers_context_token_from_user_librefang_user_when_cache_cold simulates post-restart (cache empty, cmd.user.librefang_user carries the token) and asserts on_send recovers it. - New test_on_send_ignores_url_shaped_librefang_user pins the cross- channel pollution guard (librefang_user is shared across channels — dingtalk puts a sessionWebhook URL there, telegram puts @username). - cd sdk/python && pytest tests/test_wechat_adapter.py — 63 passed (was 61; +2 regression guards). Surfaces from the #5439-followup audit chain (dingtalk #5423 → qq #5431 → omnibus #5439 → THIS). Same library_user routing pattern, distinct symptom — restart-survivability rather than cap-gate stripping.
The reader() coroutine in librefang.sidecar.runtime swallows exceptions from `adapter.on_command(cmd)` with a bare-except that logged only `error=str(e)`. This silence let four consecutive production reply-routing regressions ship with no warning sign: - #5417 dingtalk (sessionWebhook cache key mismatch) - #5431 qq (cmd.thread_id never set without `thread` cap) - #5439 reddit (raised RuntimeError on every reply; vanished here) - #5439 nextcloud/rocketchat/twitch/bluesky/mastodon (same class) Each took at least one post-merge self-review pass to surface because the original failure logged as `on_command failed error="missing parent fullname"` with no clue about WHICH adapter or WHICH line raised. Operators didn't notice because the bot silently looked healthy — every adapter just stopped replying. The fix is structural-but-conservative: keep the bare-except (we cannot raise out of reader() without taking down the entire sidecar process for one bad command) but add traceback + error_type + cmd_type as structured fields so debuggers can grep the offending adapter / line directly. The richer long-term fix — surfacing on_command failures as a structured JSON-RPC error response so the bridge correlates the failure back to the original cmd id and the agent's error-handling lane fires — needs a protocol change (Response.error field) and is tracked in a follow-up issue. No tests added: the change is purely log-content shape — it doesn't alter exception handling behaviour, just adds context. The protocol-level fix in the follow-up issue is what needs new test coverage.
…gs (#5450) * fix(sdk): include traceback + cmd_type when on_command bare-except logs The reader() coroutine in librefang.sidecar.runtime swallows exceptions from `adapter.on_command(cmd)` with a bare-except that logged only `error=str(e)`. This silence let four consecutive production reply-routing regressions ship with no warning sign: - #5417 dingtalk (sessionWebhook cache key mismatch) - #5431 qq (cmd.thread_id never set without `thread` cap) - #5439 reddit (raised RuntimeError on every reply; vanished here) - #5439 nextcloud/rocketchat/twitch/bluesky/mastodon (same class) Each took at least one post-merge self-review pass to surface because the original failure logged as `on_command failed error="missing parent fullname"` with no clue about WHICH adapter or WHICH line raised. Operators didn't notice because the bot silently looked healthy — every adapter just stopped replying. The fix is structural-but-conservative: keep the bare-except (we cannot raise out of reader() without taking down the entire sidecar process for one bad command) but add traceback + error_type + cmd_type as structured fields so debuggers can grep the offending adapter / line directly. The richer long-term fix — surfacing on_command failures as a structured JSON-RPC error response so the bridge correlates the failure back to the original cmd id and the agent's error-handling lane fires — needs a protocol change (Response.error field) and is tracked in a follow-up issue. No tests added: the change is purely log-content shape — it doesn't alter exception handling behaviour, just adds context. The protocol-level fix in the follow-up issue is what needs new test coverage. * chore(codegen): auto-regenerate openapi.json + sdk + schema baselines [skip ci] * chore(codegen): auto-regenerate openapi.json + sdk + schema baselines [skip ci] --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Summary
Runtime hotfix for #5325 (QQ sidecar) — QQ Bot OpenAPI v2 was silently rejecting every reply.
QQ Bot OpenAPI v2 routes any POST to `/channels/.../messages` or `/groups/.../messages` without msg_id as a 主动 (proactive) message. Proactive sends are blocked for unlicensed bots and quota-capped hard for licensed bots — reactive bot conversations REQUIRE the inbound message's `msg_id` be sent back as the passive-reply correlation key.
PR #5325 tried to carry the inbound `msg_id` through `cmd.thread_id`, expecting the daemon's bridge to round-trip it back to `on_send`. But the bridge only sets `cmd.thread_id` when BOTH:
QQ sidecar declares `capabilities = []` (`qq.py:665`), so neither precondition fires for the default operator. Net effect: every QQ reply in production goes out missing `msg_id`, gets classified as proactive, and either fails outright or burns the daily quota silently. Operator never sees the failure because `on_send` returns `Ok` and the QQ API returns a 200 with a quiet rate-limit notice.
Why the original tests didn't catch it
`_make_send` defaulted `thread_id="src-1"` (`test_qq_adapter.py:695-698`), so every `on_send` test fabricated a correlation key the daemon would never actually produce. Structurally identical to the dingtalk on_send bug fixed in #5423.
Fix
Mirror the dingtalk fix:
Tests
Wider sidecar audit pending
Six other sidecars also read `cmd.thread_id` for outbound correlation while declaring no `thread` capability — they're either affected to varying degrees or have already worked around it differently:
Worth a dedicated audit PR — same bug class, similar severity range.
How this slipped past the original PR's review
The wecom/dingtalk/qq pattern of "store the per-message correlation somewhere" was carried over from each other, but each platform has a different routing key (`req_id` / `sessionWebhook` / `msg_id`) and the daemon's round-trip only honours specific carriers (`librefang_user` always; `thread_id` only under threading capability + opt-in). The sidecars cargo-culted `thread_id` without re-deriving whether the daemon would actually populate it. The fix is uniform across all of them: use `librefang_user` as the always-honoured per-message channel.