Skip to content

fix(channels/qq-sidecar): recover passive-reply msg_id via ChannelUser.librefang_user#5431

Merged
houko merged 1 commit into
mainfrom
fix/qq-sidecar-reply-msg-id
May 21, 2026
Merged

fix(channels/qq-sidecar): recover passive-reply msg_id via ChannelUser.librefang_user#5431
houko merged 1 commit into
mainfrom
fix/qq-sidecar-reply-msg-id

Conversation

@houko

@houko houko commented May 21, 2026

Copy link
Copy Markdown
Contributor

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:

  1. The channel config has `[channels.qq.overrides] threading = true` (defaults to false; verified at `crates/librefang-channels/src/bridge.rs:2968-2973`), AND
  2. 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 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:

  • `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`). Body MUST contain `msg_id`. Fails on PR feat(channels)!: migrate qq from in-process adapter to sidecar #5325 code; passes here. 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 (matters when a future QQ rewrite gains the `thread` capability).
  • `cd sdk/python && pytest tests/test_qq_adapter.py` — 80 passed (was 77; +3 regression guards).
  • Pre-push hook (clippy zero-warnings + OpenAPI drift) — passed.

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:

  • mastodon — `in_reply_to_id` (optional, falls back to top-level toot — degradation but no API rejection)
  • nextcloud — needs review
  • reddit — parent fullname; REQUIRED for comment vs new post — comment becomes top-level. Likely-broken.
  • twitch — `@user` mention prepend (optional)
  • bluesky — reply reconstruction; REQUIRED for reply — reply becomes top-level. Likely-broken.
  • rocketchat — thread reply (optional)

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.

…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.
@houko
houko merged commit 5714d56 into main May 21, 2026
15 checks passed
@houko
houko deleted the fix/qq-sidecar-reply-msg-id branch May 21, 2026 01:02

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +893 to +897
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://"))):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

houko added a commit that referenced this pull request May 21, 2026
…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.
@github-actions github-actions Bot added the area/sdk JavaScript and Python SDKs label May 21, 2026
houko added a commit that referenced this pull request May 21, 2026
…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.
@github-actions github-actions Bot added the size/M 50-249 lines changed label May 21, 2026
houko added a commit that referenced this pull request May 21, 2026
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.
houko added a commit that referenced this pull request May 22, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sdk JavaScript and Python SDKs size/M 50-249 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant