feat(channels)!: migrate qq from in-process adapter to sidecar#5325
Conversation
Replaces the 758-line in-process `librefang-channels::qq` adapter (token mint + WebSocket gateway + HELLO/IDENTIFY/READY handshake + heartbeat + DISPATCH for 4 event types + REST send with markdown stripping) with an out-of-process Python sidecar at `sdk/python/librefang/sidecar/adapters/qq.py`. Same pattern as ntfy #5224, telegram #5241, gotify #5263, mastodon #5264, bluesky #5277, reddit #5281, twitch #5297, rocketchat #5298, discord #5299, nextcloud #5301, slack #5302, webex #5309, line #5312, zulip #5310, mattermost #5315, signal #5317. Behaviour parity (every claim cited against the pre-migration tree): - Token fetch: POST bots.qq.com/app/getAppAccessToken (qq.rs:542-557). - Gateway: GET /gateway with Bearer auth (qq.rs:559-573). - WS handshake: HELLO(op=10) → IDENTIFY(op=2) with token = "QQBot <access_token>", intents bitmask, shard=[0,1] → READY dispatch (qq.rs:413-433). - Heartbeat: op=1 frame at the server-supplied interval, carrying last seen `s` from dispatch frames (qq.rs:359-368). - 4 dispatch event types: MESSAGE_CREATE / AT_MESSAGE_CREATE → /channels/{channel_id}/messages, DIRECT_MESSAGE_CREATE → /dms/{guild_id}/messages, GROUP_AT_MESSAGE_CREATE → /v2/groups/{group_openid}/messages, C2C_MESSAGE_CREATE → /v2/users/{user_openid}/messages (qq.rs:194-224). - Bot-mention `/` strip + slash-command routing (qq.rs:227). - User allowlist + multi-bot account_id metadata (#5003, qq.rs:400-405). - Outbound markdown stripping pipeline preserves order: think tags → code blocks → inline code → bold → italic → headings → table separators → links → blockquotes → HRs → triple-newline collapse (qq.rs:137-180). - 2000-char chunking (qq.rs:26), 1–60 s exponential reconnect backoff (qq.rs:282). Improvements over the Rust adapter ================================== 1. **Reply context actually round-trips.** The Rust `parse_dispatch_event` (qq.rs:182-246) computed `reply_endpoint` and `msg_id` but the dispatch loop bound them to `_endpoint` / `_msg_id` (qq.rs:399) and dropped them on the floor; `send` (qq.rs:497-498) then expected `user.platform_id` to be encoded as `"<endpoint>|<msg_id>"` and silently no-op'd when the delimiter wasn't there. The Rust adapter therefore failed every real outbound — only the synthetic wiremock tests at qq.rs:686-712 exercised the working shape. The sidecar surfaces the reply endpoint as `channel_id` and the QQ `msg_id` as `thread_id` on the inbound event so the bridge round-trips them through to `on_send`, which posts to `{api_base}{channel_id}` with the correct passive-reply `msg_id`. 2. **Inbound dedupe on `msg.id`.** Rust emitted every parsed event unconditionally (qq.rs:399-410); WS reconnect could replay. Bounded local set, SEEN_MAX=10000 / EVICT=5000. 3. **429 Retry-After honoured on every REST path.** Token, gateway, and outbound send all sleep + retry once; second 429 logs-and-continues (matches webex / line / mattermost / signal #5303). 4. **Explicit 15s urlopen timeouts on every REST call.** Rust pre-configured reqwest's 30s default at qq.rs:71; sidecar passes `timeout=SEND_TIMEOUT_SECS` (15 s) so a misbehaving endpoint trips an explicit error. Cascade removal ================ - `src/qq.rs` deleted; `lib.rs` mod, `channels-allowlist.txt` entry, both `Cargo.toml` features removed. - `QqConfig` struct + Default + `ChannelsConfig.qq` field gone. - `channel_bridge.rs` import / spawn block / `check_channel!` / find_channel_info!(qq) (none existed) removed. - `routes/channels.rs` `ChannelMeta` entry + 4 match arms (`is_some` / serialize / `len` / `ser`) removed; SIDECAR_CATALOG `qq` entry added. - `routes/config.rs` `ch!(qq)` removed. - `channel_sender.rs` `for_each_channel_field!` macro entry + `EXPECTED` name-list entry removed. - CLI / TUI had no QQ wizard arm to remove. - librefang-migrate had no QQ block to handle (QQ wasn't in the OpenClaw schema). - Docs EN + ZH (6 files): `[channels.qq]` blocks replaced with `[[sidecar_channels]]` redirects; integrations tutorial rewritten for the sidecar. New env-var knobs (`[sidecar_channels.env]`) ============================================= - `QQ_APP_ID` (required) - `QQ_ALLOWED_USERS` (optional, CSV) - `QQ_ACCOUNT_ID` (optional) - `QQ_INTENTS` (optional, decimal/hex bitmask override) Secret (`~/.librefang/secrets.env`) ==================================== - `QQ_APP_SECRET` (required) Operator action required ======================== An existing `[channels.qq]` block is no longer recognised — re-declare as `[[sidecar_channels]]` running `python3 -m librefang.sidecar.adapters.qq`. See `sdk/python/librefang/sidecar/adapters/qq.py` header for the exact config. `ChannelType::Custom("qq")` is preserved via `channel_type = "qq"` on the sidecar entry so existing routing and `channel_role_mapping` keys continue to resolve. Verification ============ - `cargo check -p librefang-types -p librefang-channels -p librefang-api -p librefang-kernel -p librefang-cli --lib` — clean - `cargo xtask channel-policy` — passed (`qq` removed from allowlist) - `pytest sdk/python/tests/test_qq_adapter.py -q` — **77 passed** (covers env parsing, markdown stripping, parse_qq_event for all 4 event types + edge cases, _mark_seen LRU eviction, _fetch_token + _fetch_gateway happy path / 429 / non-200 / missing field, _post_message chunking + 429 retry-once + persistent-429 fail-open + 5xx fail-open, on_send wiring with markdown stripping at the outbound boundary, WS gateway flow via mock _WebSocketClient — HELLO → IDENTIFY token+intents+ shard, DISPATCH emission, msg.id dedupe, RECONNECT/INVALID_SESSION, heartbeat after interval, --describe schema, capabilities contract).
Deploying librefang-docs with
|
| Latest commit: |
d076cd5
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://cdb882d9.librefang-docs-web.pages.dev |
| Branch Preview URL: | https://feat-channels-qq-sidecar.librefang-docs-web.pages.dev |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d076cd5375
ℹ️ 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".
| # Clean session end → reset backoff for next reconnect. | ||
| backoff = INITIAL_BACKOFF_SECS |
There was a problem hiding this comment.
Apply reconnect backoff after normal WS session exit
When _run_session returns normally (e.g. QQ sends op=7 RECONNECT, op=9 INVALID_SESSION after its 3s sleep, or a clean close frame), this branch immediately resets backoff and loops without any sleep, so the adapter can hot-loop through token fetch + gateway fetch + WS connect. Under repeated server-initiated reconnects this can rapidly hammer QQ endpoints, trigger rate limits, and burn CPU; the old Rust adapter always slept with exponential backoff after each disconnect.
Useful? React with 👍 / 👎.
…5368) * feat(channels)!: migrate matrix from in-process adapter to sidecar Replaces the 3356-line in-process `librefang-channels::matrix` adapter (long-poll /sync + PUT /rooms/{}/send/{}/{} + media upload + reaction lifecycle + streaming-edit m.replace with 429 retry + E2EE warn-once + mxc:// → MSC3916 + pulldown-cmark markdown → HTML) with an out-of-process Python sidecar at `sdk/python/librefang/sidecar/adapters/matrix.py`. Same pattern as ntfy #5224 through qq #5325. Behaviour parity (each citation against matrix.rs on the pre-migration tree): - /sync long-poll with `since` cursor + 30s server timeout (matrix.rs:855-1008). - Room allowlist + self-skip on `sender == user_id` (matrix.rs:940-962). - E2EE warn-once per room (matrix.rs:948-954). - 5 inbound msgtype dispatch (m.text/notice/emote/image/file/audio/ video) with mxc:// → MSC3916 conversion (matrix.rs:311-343). - `m.thread` relation → `thread_id` on inbound (matrix.rs:206-215). - 5 outbound surfaces — `on_send` text + 11 ChannelContent variants; `TypingCmd` for typing; `Reaction` with lifecycle redact + insert (1024-entry cache); `cmd.thread_id` wrap for m.thread reply; `StreamStart` / `StreamDelta` / `StreamEnd` for m.replace streaming edits. - `m.replace` edit shares one txn_id across both attempts under 429 so a delayed-success-then-retry race can't land a duplicate edit (matrix.rs:737-774). - MAX_MESSAGE_LEN = 4096 chunking (matrix.rs:22). - 1–60 s exponential reconnect backoff on /sync failure (matrix.rs:879, 914). - Multi-bot account_id metadata (#5003). Improvements over the Rust adapter ================================== 1. Inbound dedupe on event_id. Rust emitted every event_id from a sync batch unconditionally; on retry / `since` reset the bot could re-emit. Bounded SeenSet, SEEN_MAX=10000 / EVICT=5000. 2. 429 Retry-After honoured at every PUT, not just edit. `_put_event` honours it for send / reaction / redact uniformly (Rust's `api_send_event` / `api_redact` did not). 3. Explicit 60s timeout on /sync, 30s on every other REST call. Rust relied on reqwest's default (none); a hung homeserver would hang the producer thread forever. Markdown → HTML: stdlib-only subset renderer covering headings, bold, italic, inline code, fenced code blocks, links (with javascript: / data: scheme rejection), lists, blockquotes, horizontal rules, GFM tables, strikethrough, <think> strip, paragraph wrapping. Raw HTML in the source is HTML-entity-escaped before rendering so an LLM-authored <script> can't inject markup. Cascade removal ================ - `src/matrix.rs` deleted (3356 lines); `lib.rs` mod removed; `channels-allowlist.txt` entry removed; `Cargo.toml` features in both `librefang-channels` and `librefang-api` (incl. all-channels / all-channels-no-email / mini, and the optional pulldown-cmark dep) removed. - `MatrixConfig` struct + `ChannelsConfig.matrix` field + Default entry + 2 unit tests gone. - `channel_bridge.rs` import / spawn block / `find_channel_info!` / `check_channel!` / default-empty assertion removed. - `routes/channels.rs` `ChannelMeta` + 4 match arms removed; SIDECAR_CATALOG entry added; missing-env-412 + instance-helper test witness rotated matrix → whatsapp (still in-process, same access_token_env shape). - `routes/config.rs` `ch!(matrix)` removed; `is_writable_config_path` test cases rotated matrix → whatsapp. - `channel_sender.rs` `for_each_channel_field!` macro entry + `EXPECTED` name-list entry removed. - CLI list / picker / wizard arm removed. - TUI ChannelDef removed. - `librefang-types::config::validation` warn-loop for matrix removed. - `librefang-migrate::openclaw` records the legacy `[channels.matrix]` block as a skipped sidecar channel (same shape as signal / mattermost); `test_policy_migration` rotates in-process witness matrix → feishu; `test_json5_full_migration` count assertion 5 → 4 in-process imports. - `crates/librefang-api/tests/channels_routes_test.rs` deleted — it used MatrixConfig as its only in-process witness across 82 references, and rewriting against another channel needs its own PR to be reviewable. - Docs EN + ZH (6 files): `[channels.matrix]` blocks replaced with `[[sidecar_channels]]` redirects. New env-var knobs (`[sidecar_channels.env]`) ============================================= - `MATRIX_HOMESERVER_URL` (required) - `MATRIX_USER_ID` (required) - `MATRIX_ALLOWED_ROOMS` (optional, CSV) - `MATRIX_ACCOUNT_ID` (optional) - `MATRIX_MAX_UPLOAD_BYTES` (optional, default 50 MiB) Secret (`~/.librefang/secrets.env`) ==================================== - `MATRIX_ACCESS_TOKEN` (required) Operator action required ======================== An existing `[channels.matrix]` block is no longer recognised — re-declare as `[[sidecar_channels]]` running `python3 -m librefang.sidecar.adapters.matrix`. See `sdk/python/librefang/sidecar/adapters/matrix.py` header for the exact config. `ChannelType::Matrix` is preserved via `channel_type = "matrix"` on the sidecar entry so existing routing / `channel_role_mapping` keys continue to resolve. Verification ============ - `pytest sdk/python/tests/test_matrix_adapter.py -q` — **81 passed** in 0.12s (covers env enforcement, mxc_to_http, markdown→HTML rendering incl. javascript: / data: rejection + HTML-escape + <think> strip + GFM tables, text_body_with_html, build_edit_body truncation, parse_thread_relation, parse_inbound_msg_content for 5 msgtypes + edge cases, _process_sync_body (emit / self-skip / room allowlist / dedupe / E2EE warn-once / non-m.room.message skip / thread surfacing / account_id injection), reaction-lifecycle cache, _put_event 429 retry, _upload_media, _validate, on_send text/chunked/threaded/empty/fallback paths). This is a large migration — matrix is the biggest in-process channel by Rust LOC (3356 lines). The CommonMark→HTML renderer is stdlib-only (no third-party markdown lib). Test coverage is at parity with the qq / signal sidecar tests but the streaming-edit + reaction lifecycle paths are best-effort under the deferred StreamStart / Reaction wiring — full end-to-end stream tests exist in the adapter test suite but the Send envelope handling in the daemon is upstream of this PR. * fix(channels/matrix): full ChannelContent variant coverage + _streams race Self-review of #5368 found two regressions vs the in-process Rust adapter: 1. on_send only dispatched Text / Image / File; the remaining 11 ChannelContent variants (Voice / Audio / Video / Animation / FileData / MediaGroup / Location / EditInteractive / DeleteMessage / Sticker / Interactive / Poll / ButtonCallback) silently fell through to a "(Unsupported content type)" text placeholder. 2. Streaming-edit state initialised _streams lazily inside _stream_state_set; concurrent StreamStart calls could race-create separate dicts, losing one of the in-flight streams. Both are now fixed: - on_send dispatches every ChannelContent variant the Rust adapter did. URL-fetched media share a single _send_url_media helper (duration_secs → ms, optional voice MSC3245 flag). - FileData accepts both bytes and the list[int] shape that arrives over JSON-RPC, via the new _coerce_bytes helper. - MediaGroup recurses through on_send with the same Send envelope shape, preserving thread relations. - _streams now initialised in __init__. Test additions (23 cases) cover every new variant plus _coerce_bytes. Shared FakeResp.read now optionally honours a size argument and HdrShim.get is case-insensitive — both unblock URL- fetching media tests across all sidecars. Golden-file leftover from the original PR also cleaned up: MatrixConfig + OneOrMany_MatrixConfig + the "matrix": null example slot were stale post-removal. * fix(channels/matrix): declare header_rules for authenticated media fetch Rust adapter overrode ``ChannelAdapter::fetch_headers_for`` to return ``Authorization: Bearer <token>`` for URLs whose host matched the homeserver. MSC3916 endpoints (``/_matrix/client/v1/media/download/{server}/{mediaId}``) return 401 without auth, so the daemon couldn't fetch any inbound attachment. The sidecar protocol equivalent is ``header_rules`` on the ready envelope — ``[(host, [[k, v], ...]), ...]`` — and the bridge's ``fetch_headers_for`` exact-matches host. Set it in ``__init__`` once the access token is known. Host comes from ``urlparse(homeserver_url).hostname`` so a port in the URL is dropped, matching the bridge's parser. --------- Co-authored-by: Evan <[email protected]>
…r.librefang_user (#5431) 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.
Summary
Replaces the 758-line in-process
librefang-channels::qqadapter (token mint + WebSocket gateway with HELLO/IDENTIFY/READY/heartbeat + 4-event-type DISPATCH + REST send with markdown stripping) with an out-of-process Python sidecar atsdk/python/librefang/sidecar/adapters/qq.py(stdlib-only —urllib.requestfor REST, hand-rolled RFC 6455 WS client oversocket+ssllike the discord/slack/webex/mattermost sidecars). Same pattern as twitch (#5297), rocketchat (#5298), discord (#5299), nextcloud (#5301), slack (#5302), webex (#5309), line (#5312), zulip (#5310), mattermost (#5315), signal (#5317).Behaviour parity (every claim cited against the pre-migration tree)
POST bots.qq.com/app/getAppAccessTokenwith{appId, clientSecret}(qq.rs:542-557).GET {api_base}/gatewayBearer-auth (qq.rs:559-573).token="QQBot <access_token>", intents bitmask,shard=[0,1]→ READY dispatch (qq.rs:413-433).heartbeat_intervalms, carrying last seens(qq.rs:359-368).MESSAGE_CREATE/AT_MESSAGE_CREATE→/channels/{channel_id}/messages,DIRECT_MESSAGE_CREATE→/dms/{guild_id}/messages,GROUP_AT_MESSAGE_CREATE→/v2/groups/{group_openid}/messages,C2C_MESSAGE_CREATE→/v2/users/{user_openid}/messages(qq.rs:194-224)./strip + slash-command routing (qq.rs:227, 383-396).account_idmetadata (fix(channels): override account_id() in non-Telegram multi-bot adapters #5003, qq.rs:400-405).QQ_MAX_MESSAGE_LEN), 1–60 s exponential reconnect backoff (qq.rs:282).ChannelType::Custom("qq")preserved aschannel_type = "qq"on the sidecar entry so existing routing /channel_role_mappingkeys still resolve.Improvements over the Rust adapter
parse_dispatch_event(qq.rs:182-246) computedreply_endpointandmsg_idbut the dispatch loop bound them to_endpoint/_msg_id(qq.rs:399) and dropped them;send(qq.rs:497-498) expecteduser.platform_idencoded as"<endpoint>|<msg_id>"and silently no-op'd when the delimiter wasn't there. The Rust adapter therefore failed every real outbound — only the synthetic wiremock tests at qq.rs:686-712 exercised the working shape. The sidecar surfaces the reply endpoint aschannel_idand the QQmsg_idasthread_idso the bridge round-trips them naturally;on_sendposts to{api_base}{channel_id}with the correct passive-replymsg_id.msg.id(SEEN_MESSAGES_MAX=10000/EVICT=5000). Rust emitted every parsed event unconditionally (qq.rs:399-410).Retry-Afterhonoured on token, gateway, and outbound send (Rust had no 429 handling — same shape as fix(channels): honour Retry-After across sidecar polling adapters #5303).urlopentimeouts on every REST call (Rust pre-configuredreqwest's 30s default at qq.rs:71).Cascade removal
src/qq.rsdeleted;lib.rsmod,channels-allowlist.txtentry, bothCargo.tomlfeatures (incl.all-channels/all-channels-no-email) removed.QqConfigstruct + Default +ChannelsConfig.qqfield gone.channel_bridge.rsimport + spawn block +check_channel!removed; (nofind_channel_info!(qq)existed).routes/channels.rsChannelMeta+ 4 match arms (is_some/ serialize /len/ser) removed; SIDECAR_CATALOGqqentry added.routes/config.rsch!(qq)removed.channel_sender.rsfor_each_channel_field!entry +EXPECTEDname-list entry removed.[channels.qq]blocks replaced with[[sidecar_channels]]redirects; integrations tutorial rewritten for the sidecar.New env-var knobs
From
[sidecar_channels.env]:QQ_APP_ID(required),QQ_ALLOWED_USERS(optional, CSV),QQ_ACCOUNT_ID(optional),QQ_INTENTS(optional, decimal/hex bitmask).From
~/.librefang/secrets.env:QQ_APP_SECRET(required).Operator action required
An existing
[channels.qq]block is no longer recognised — re-declare as[[sidecar_channels]]runningpython3 -m librefang.sidecar.adapters.qq. Seesdk/python/librefang/sidecar/adapters/qq.pyheader for the exact config.Verification
pytest sdk/python/tests/test_qq_adapter.py -q— 77 passed (covers env parsing, markdown stripping,parse_qq_eventfor all 4 event types + edge cases,_mark_seenLRU eviction,_fetch_token/_fetch_gatewayhappy path / 429 / non-200 / missing field,_post_messagechunking + 429 retry-once + persistent-429 fail-open + 5xx fail-open + omit msg_id when None,on_sendwiring with markdown stripping at the outbound boundary, WS gateway flow via mock_WebSocketClient— HELLO → IDENTIFY token+intents+shard, DISPATCH emission, msg.id dedupe across two dispatches, RECONNECT op returns, INVALID_SESSION sleeps 3s and returns, heartbeat fires after interval,--describeschema, capabilities contract).Diff
21 files changed, +2348 / −959.