Skip to content

feat(channels)!: migrate mattermost from in-process adapter to sidecar#5315

Merged
houko merged 2 commits into
mainfrom
feat/channels-mattermost-sidecar
May 20, 2026
Merged

feat(channels)!: migrate mattermost from in-process adapter to sidecar#5315
houko merged 2 commits into
mainfrom
feat/channels-mattermost-sidecar

Conversation

@houko

@houko houko commented May 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the 954-line in-process librefang-channels::mattermost (WebSocket gateway to wss://<host>/api/v4/websocket with authentication_challenge JSON frame after the upgrade, posted event parsing, REST POST /api/v4/posts outbound, POST /api/v4/users/me/typing typing indicators, Bearer auth) with an out-of-process Python sidecar at sdk/python/librefang/sidecar/adapters/mattermost.py (stdlib-only — urllib.request for REST, hand-rolled RFC 6455 WS client over socket + ssl). Same pattern as twitch (#5297), rocketchat (#5298), discord (#5299), nextcloud (#5301), slack (#5302), webex (#5309), line (#5312), zulip (#5310).

Behaviour parity

  • HTTP Basic-less Bearer auth on every REST call (mattermost.rs:107-128)
  • GET /api/v4/users/me startup probe + bot-id self-skip (mattermost.rs:206-210)
  • WebSocket authentication_challenge JSON frame after the upgrade (mattermost.rs:335-353)
  • posted event filter with double-decoded data.post JSON parse (mattermost.rs:186-202)
  • DM vs group from data.channel_type == "D" (mattermost.rs:222-223)
  • Channel-allowlist filter (mattermost.rs:212-215)
  • Slash-command routing /cmd argsCommand (text otherwise)
  • 16383-char chunking matches MAX_MESSAGE_LEN (mattermost.rs:22)
  • Multi-bot account_id injection (mattermost.rs:419-424, fix(channels): override account_id() in non-Telegram multi-bot adapters #5003)
  • Exponential 1 s → 60 s reconnect backoff (mattermost.rs:306-438)

Improvements over the Rust adapter (with file/line evidence)

  1. Outbound root_id round-trip via thread_id — Rust send (mattermost.rs:446-462) dropped root_id, so the bot's reply lost its thread for the common case. Sidecar surfaces inbound post.root_id (or post.id for thread-root) as thread_id and on_send posts root_id populated. Mirrors reddit / rocketchat / nextcloud / webex.
  2. 429 Retry-After honoured on every REST path — Rust had no 429 handling, so a throttled /posts lost the chunk (mattermost.rs:165-169 only logs at WARN). Same shape as fix(channels): honour Retry-After across sidecar polling adapters #5303.
  3. Bounded post.id dedupe — Rust emit at mattermost.rs:425 was unconditional; a WS reconnect double-delivered. SEEN_MESSAGES_MAX = 10 000 / EVICT = 5 000.
  4. Explicit 15 s urlopen timeouts — Rust relied on reqwest's default (none). A hung Mattermost REST endpoint would otherwise hang the producer thread forever.

Cascade removal

  • src/mattermost.rs deleted; lib.rs mod, channels-allowlist.txt entry, both Cargo.toml features (incl. all-channels / all-channels-no-email / mini) removed
  • MattermostConfig struct + Default + ChannelsConfig.mattermost field + Default + validation hook + test_mattermost_config_defaults / _serde / _channel_proxy_roundtrips unit tests gone
  • channel_bridge.rs import / spawn block / find_channel_info! / check_channel! / default-empty assertion removed
  • routes/channels.rs ChannelMeta entry + 4 match arms removed; SIDECAR_CATALOG mattermost entry added
  • routes/config.rs ch!(mattermost) removed
  • channel_sender.rs for_each_channel_field! macro entry + EXPECTED name-list entry removed
  • CLI TUI ChannelDef removed
  • librefang-channels/README.md + AGENTS.md "migrated to sidecars" sentence updated
  • librefang-channels/http_client.rs warn_ws_proxy_bypass_smoke test removed (only remaining caller was mattermost)
  • librefang-migrate/openclaw.rs — legacy [channels.mattermost] block recorded as a skipped sidecar channel (same shape as the IRC removal) instead of emitting TOML the kernel would refuse to deserialize
  • Docs EN + ZH (8 files): [channels.mattermost] blocks replaced with [[sidecar_channels]] redirects; env-var table marked (sidecar)

New env-var knobs

From [sidecar_channels.env]: MATTERMOST_SERVER_URL (required), MATTERMOST_ALLOWED_CHANNELS (optional), MATTERMOST_ACCOUNT_ID (optional).

From ~/.librefang/secrets.env: MATTERMOST_TOKEN (required).

Operator action required

An existing [channels.mattermost] block is no longer recognised — re-declare as [[sidecar_channels]] running python3 -m librefang.sidecar.adapters.mattermost. See sdk/python/librefang/sidecar/adapters/mattermost.py header for the exact config.

ChannelType::Custom("mattermost") is preserved via channel_type = "mattermost" on the sidecar entry, so existing routing and channel_role_mapping keys that reference mattermost continue to resolve.

Verification

  • cargo check -p librefang-types -p librefang-channels -p librefang-api -p librefang-kernel -p librefang-cli -p librefang-migrate --lib — clean
  • cargo xtask channel-policy — passed (mattermost removed from allowlist)
  • cargo test --lib -p librefang-types -p librefang-channels -p librefang-kernel — passed
  • cd sdk/python && python3 -m pytest tests/test_mattermost_adapter.py -q73 passed

Diff

29 files changed, +2051 / −1257.

Replaces the 954-line in-process `librefang-channels::mattermost`
(WebSocket gateway to `wss://<host>/api/v4/websocket` with an
`authentication_challenge` JSON frame after the upgrade, `posted` event
parsing, REST `POST /api/v4/posts` outbound, `POST /api/v4/users/me/typing`
typing indicators, Bearer auth) with an out-of-process Python sidecar
at `sdk/python/librefang/sidecar/adapters/mattermost.py` (stdlib-only —
`urllib.request` for REST, hand-rolled RFC 6455 WS client over `socket`
+ `ssl`, same pattern as webex/discord/slack #5302/5299/5309). Same
pattern as zulip #5310 / line #5312.

Behaviour parity (each citation against the pre-migration tree):

- `GET /api/v4/users/me` startup credential probe + bot-id self-skip
  (mattermost.rs:107-128, 206-210)
- WebSocket `authentication_challenge` JSON frame after upgrade
  (mattermost.rs:335-353)
- `posted` event filter with double-decoded `data.post` JSON parse
  (mattermost.rs:186-202)
- DM vs group from `data.channel_type == "D"` (mattermost.rs:222-223)
- Channel-allowlist filter (mattermost.rs:212-215)
- Slash-command routing `/cmd args` → Command (text otherwise)
- 16383-char chunking matches MAX_MESSAGE_LEN (mattermost.rs:22)
- Multi-bot `account_id` injection (#5003, mattermost.rs:419-424)
- Exponential 1 s → 60 s reconnect backoff (mattermost.rs:306-438)
- ChannelType::Custom("mattermost") preserved as `channel_type =
  "mattermost"` so existing routing / channel_role_mapping keys resolve

Improvements over the Rust adapter:

1. Outbound `root_id` round-trip via `thread_id` — Rust `send` at
   mattermost.rs:446-462 hard-coded no `root_id`, dropping the thread
   even when the inbound message was in one. Sidecar surfaces inbound
   `post.root_id` (or post.id when the inbound was a thread root) as
   `thread_id` and `on_send` posts `root_id` populated.
2. 429 `Retry-After` honoured on every REST path (Rust had none —
   chunked sends just lost the throttled chunk).
3. Bounded inbound dedupe on `post.id`
   (SEEN_MESSAGES_MAX=10000 / EVICT=5000) — Rust emit at
   mattermost.rs:425 was unconditional, so a WS reconnect double-
   delivered.
4. Explicit 15 s `urlopen` timeouts on every REST call.

Cascade removal:

- `src/mattermost.rs` deleted; `lib.rs` `pub mod mattermost`,
  `channels-allowlist.txt` entry, cargo `channel-mattermost` features in
  both `librefang-channels` and `librefang-api` (incl. membership in
  `all-channels` / `all-channels-no-email` / `mini`)
- `MattermostConfig` struct + Default impl + `ChannelsConfig.mattermost`
  field + matching Default entry; validation hook; mod.rs serde tests
- `channel_bridge.rs`: `MattermostAdapter` import, spawn block,
  `find_channel_info!` arm, `check_channel!` arm, default-empty test
  assertion
- `routes/channels.rs`: `ChannelMeta` entry + 4 match arms
  (`is_some` / serialize / `len` / `ser`); `SIDECAR_CATALOG` entry added
- `routes/config.rs` `ch!(mattermost)` call
- kernel `channel_sender` `for_each_channel_field!` entry +
  `EXPECTED` name-list entry
- CLI TUI `ChannelDef` row; migrated-to-sidecar comment grown
- `librefang-channels` README / AGENTS updated
- `librefang-channels/http_client.rs` warn_ws_proxy_bypass smoke test
  removed (only caller was mattermost)
- `librefang-migrate` openclaw importer: legacy `[channels.mattermost]`
  block recorded as a skipped sidecar channel (same shape as the IRC
  removal) instead of emitting TOML the kernel would refuse to
  deserialize
- Docs (EN + ZH): `configuration/page.mdx`,
  `configuration/channels/page.mdx`, `configuration/security/page.mdx`,
  `integrations/channels/enterprise/page.mdx` — `[channels.mattermost]`
  blocks replaced with `[[sidecar_channels]]` redirects

Operator action required: an existing `[channels.mattermost]` block is
no longer recognised — re-declare as `[[sidecar_channels]]` running
`python3 -m librefang.sidecar.adapters.mattermost`. `MATTERMOST_TOKEN`
moves to `~/.librefang/secrets.env`.

Test plan:

- [x] `cargo check -p librefang-types -p librefang-channels -p librefang-api -p librefang-kernel -p librefang-cli -p librefang-migrate --lib` — clean
- [x] `cargo xtask channel-policy` — passed (`mattermost` removed from allowlist)
- [x] `cargo test --lib -p librefang-types -p librefang-channels -p librefang-kernel` — passed
- [x] `cd sdk/python && pytest tests/test_mattermost_adapter.py` — 73 passed
- [ ] Live Mattermost WS smoke test (operator)
@github-actions github-actions Bot added size/XL 1000+ lines changed area/docs Documentation and guides area/channels Messaging channel adapters area/kernel Core kernel (scheduling, RBAC, workflows) area/sdk JavaScript and Python SDKs labels May 20, 2026
@cloudflare-workers-and-pages

Copy link
Copy Markdown

@github-actions github-actions Bot added the ready-for-review PR is ready for maintainer review label May 20, 2026
The adapter declared `typing` in `capabilities` so the daemon will
route `TypingCmd` to us, but `on_command` was inherited from the
base `SidecarAdapter` which only handles `send`. Result: every typing
ping from the daemon was silently dropped — a regression vs the Rust
adapter at mattermost.rs:464-485 which actually called the
`/api/v4/users/me/typing` endpoint.

Implement `on_command` to dispatch both `Send` (the existing
`on_send` path) and `TypingCmd` (the new `_post_typing` helper),
matching the discord precedent at discord.py:1126-1135.

Tests: 73 → 77 passing (added 4 covering capabilities declaration,
Send routing, TypingCmd routing, and unknown-command no-op).

@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: da8b604bf7

ℹ️ 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 +940 to +943
with self._make_ws(self.ws_url, headers={}) as ws:
self._run_session(ws, emit)
backoff = INITIAL_BACKOFF_SECS
except Exception as e: # noqa: BLE001 — transport varies

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Apply reconnect backoff after normal websocket drops

When _run_session() returns normally (e.g., EOF/close/socket drop paths at lines 900-908), this loop immediately reconnects because backoff is only applied in the except branch. That bypasses the documented exponential reconnect behavior and can create a tight reconnect storm against Mattermost during outages or server-initiated disconnects.

Useful? React with 👍 / 👎.



class MattermostAdapter(SidecarAdapter):
capabilities: list = ["thread", "typing"]

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 Handle typing commands when advertising typing support

This adapter advertises the typing capability, so the daemon can route TypingCmd commands to it, but the class never overrides on_command; the base SidecarAdapter.on_command only dispatches Send. As a result, typing commands are silently ignored and _post_typing() is never called, so users lose typing-indicator behavior that existed in the Rust adapter.

Useful? React with 👍 / 👎.

@houko
houko merged commit 2f7d6f9 into main May 20, 2026
28 of 33 checks passed
@houko
houko deleted the feat/channels-mattermost-sidecar branch May 20, 2026 11:46
houko added a commit that referenced this pull request May 20, 2026
…5317)

* feat(channels)!: migrate signal from in-process adapter to sidecar

Replaces the 975-line in-process `librefang-channels::signal` (polling
loop against signal-cli-rest-api with SSRF guard rejecting
loopback/RFC-1918/link-local/CGNAT/IPv6-ULA, `POST /v2/send` outbound,
optional Bearer API key, optional base64 attachments) with an
out-of-process Python sidecar at
`sdk/python/librefang/sidecar/adapters/signal.py` (stdlib-only —
`urllib.request` for REST, `socket` + `ipaddress` for the SSRF
classifier).

Same pattern as line #5312 / zulip #5310 / mattermost #5315.

Behaviour parity (each citation against the pre-migration tree):

- SSRF guard: scheme + DNS-resolved address validation matches
  signal.rs:27-98. Default-deny on private/loopback unless
  `SIGNAL_ALLOW_LOCAL=1` is set explicitly.
- `GET /v1/receive/{phone}` polling with default 2 s cadence
  (signal.rs:171, 332).
- Self / allowlist / empty-text filters (signal.rs:361-376).
- Slash-command routing `/cmd args` → Command (signal.rs:383-396).
- `POST /v2/send` with `{message, number, recipients}`
  (signal.rs:204-244).
- Multi-bot `account_id` injection (#5003, signal.rs:417-422).
- Optional Bearer auth via `SIGNAL_API_KEY`.

Improvements over the Rust adapter:

1. Inbound dedupe on `envelope.timestamp` with
   `SEEN_MESSAGES_MAX=10000`/`EVICT=5000` — Rust emit at
   signal.rs:398-415 was unconditional, so a retry redelivered
   duplicates.
2. 429 `Retry-After` honoured on both poll and send paths
   (Rust had no 429 handling at all).
3. Explicit 15 s `urlopen` timeouts on every REST call.
4. 1–60 s exponential backoff on transport / non-2xx errors —
   Rust just `continue`-d on every error and spun at
   `poll_interval` against a wedged daemon.

Known gap: the Rust adapter's inline base64 attachment support
(`Image`/`Voice`/`Video`/`Audio`/`Animation`/`File`/`FileData`/
`MediaGroup`) is not wired through the sidecar yet — non-text content
falls back to a `(Unsupported content type)` placeholder. Follow-up
will restore the base64 round-trip end-to-end.

Cascade removal:

- `src/signal.rs` deleted; `lib.rs` mod, `channels-allowlist.txt`
  entry, both `Cargo.toml` features (incl. `all-channels` /
  `all-channels-no-email` / `mini`)
- `SignalConfig` struct + Default + `ChannelsConfig.signal` field +
  `default_signal_poll_interval_secs` helper + 3 unit tests
- `channel_bridge.rs` import / spawn block / `find_channel_info!` /
  `check_channel!` / default-empty assertion
- `routes/channels.rs` `ChannelMeta` entry + 4 match arms;
  `SIDECAR_CATALOG` entry added
- `routes/config.rs` `ch!(signal)` removed
- `channel_sender.rs` macro entry + EXPECTED list entry
- CLI TUI `ChannelDef`
- `librefang-channels` README / AGENTS sentence
- `librefang-migrate` openclaw importer (both JSON-block and
  YAML-file paths) — Signal now records a `report.skipped` entry
  (mirrors IRC / Mattermost)
- Docs EN + ZH (6 files)

Test plan:

- [x] `cargo check -p librefang-types -p librefang-channels -p librefang-api -p librefang-kernel -p librefang-cli -p librefang-migrate --lib` — clean
- [x] `cargo xtask channel-policy` — passed (`signal` removed)
- [x] `pytest sdk/python/tests/test_signal_adapter.py` — 61 passed
- [ ] Live signal-cli-rest-api smoke test (operator)

* fix(channels): make signal SSRF guard fail-closed on unparseable addr

`_is_private_or_loopback` returned `False` (= "public, allow") when
`ipaddress.ip_address` rejected the input string. In production
`socket.getaddrinfo` always hands back a well-formed IPv4/IPv6
literal so the path was unreachable, but the SSRF guard's contract
is default-deny — a future change that lets a scoped IPv6 literal
(`fe80::1%eth0`) through `getaddrinfo` would have silently allowed
link-local addresses.

Fail-CLOSED: any address the classifier cannot interpret is treated
as private. Tests cover the contract directly + the scoped-literal
case.

Tests: 61 → 62 (1 new for fail-closed on garbage inputs).

* docs(channels): correct Signal sidecar override + attachment notes

The two Signal-specific sections in the channels overview were
authored against the in-process Rust adapter and still claimed:

1. `Signal Plain-Text Default` — that you can override
   `output_format` via `[channels.signal.overrides]`. The
   `[channels.signal]` block was deleted in this migration, and
   `SidecarChannelConfig` does not expose an `overrides` field, so
   the snippet was inaccurate. The PlainText default itself still
   applies because it keys off the sidecar entry's
   `channel_type = "signal"` (`default_output_format_for_channel`
   in `crates/librefang-channels/src/formatter.rs`); only the
   *override mechanism* is currently absent on sidecar channels.

2. `Signal Media Attachments` — that base64 attachment delivery
   for `Image / Voice / Video / Audio / Animation / File /
   FileData / MediaGroup` works. The sidecar currently routes
   anything other than text to `(Unsupported content type)`; the
   PR description already lists base64 attachment parity as a
   known follow-up.

Updates EN + ZH overview pages plus the two cross-reference
sentences in EN + ZH `core/page.mdx` that pointed at these
anchors.

* fix(channels): drop stale signal refs missed by sidecar migration

Two leftovers from the in-process Signal removal broke 4 CI lanes
(Quality, Workspace coverage, Test/Windows shard 1, Test/macOS),
all with the same compile error:

    error[E0609]: no field `signal` on type `ChannelsConfig`

1. `librefang-migrate/src/openclaw.rs` — the
   `test_roundtrip_migrate_output_into_real_structs` test was still
   reading `cfg.channels.signal` even though the field is gone. The
   migrator now records the legacy `signal:` JSON block as a
   `report.skipped` entry (already covered by
   `test_signal_block_records_skipped_after_sidecar_migration`), so
   the inline read-back has no struct to land on. Replaced with a
   pointer to the dedicated skipped-channel test.

2. `librefang-cli/src/main.rs` — `cmd_channel_setup("signal")` still
   wrote a `[channels.signal]` block with `phone_env`, `socket_path`,
   `default_agent` fields the kernel would refuse to deserialize, and
   the `cmd_channel_list` summary still listed `signal` as a
   configurable in-process channel. Dropped the `"signal"` match arm
   and removed `signal` from both the list-view vector and the
   interactive picker — same shape the previous discord / slack /
   telegram migrations used.

Orphan i18n keys `section-setup-signal` in `locales/{en,zh-CN}/main.ftl`
are left in place to match the precedent set by discord/slack
(`section-setup-discord`, `section-setup-slack` also kept as orphans);
a single sweep across migrated channels is a separate cleanup.

---------

Co-authored-by: Evan <[email protected]>
houko added a commit that referenced this pull request May 20, 2026
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).

Co-authored-by: Evan <[email protected]>
houko added a commit that referenced this pull request May 20, 2026
…migration (closes #5316)

Four `librefang-migrate::openclaw::tests` cases used mattermost as their
in-process channel witness, but #5315 migrated mattermost to a sidecar
and the production migrate code now pushes a SkippedItem instead of
emitting [channels.mattermost]. With no other in-process channel in the
small fixtures, migrate_channels_from_json returned None, the report
had zero ItemKind::Channel imports, and the JSON5 full-migration
imported-count fell from 7 to 6 — turning main red.

- create_legacy_yaml_workspace: add messaging/whatsapp.yaml as the new
  in-process witness (telegram/discord/slack/mattermost are all
  sidecar-skipped). test_scan_workspace now asserts channels.len() == 5
  with whatsapp membership.
- test_json5_channel_extraction: add whatsapp to the inline JSON5;
  mattermost joins the sidecar-skip loop; ch_table.contains_key flips
  to whatsapp; secret-count stays at 5 (MATTERMOST_TOKEN still extracted
  via the sidecar-skipped path) and the new MATTERMOST_TOKEN=mm-token
  assertion against secrets.env makes that explicit.
- test_json5_full_migration: assertion 7 -> 6; count-comment rewritten to
  enumerate the 7 skips (telegram, discord, slack, irc, mattermost,
  imessage, bluebubbles) and the 6 in-process imports (whatsapp,
  signal, matrix, feishu, google_chat, msteams).
- test_policy_migration: replace mattermost witness with signal (still
  in-process, also accepts dm_policy); mattermost added to the
  sidecar-skip loop alongside discord/slack.

Verified: 36 openclaw::tests + 51 lib tests + 6 idempotency integration
tests green; `cargo clippy -p librefang-migrate --all-targets -- -D
warnings` clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/channels Messaging channel adapters area/docs Documentation and guides area/kernel Core kernel (scheduling, RBAC, workflows) area/sdk JavaScript and Python SDKs ready-for-review PR is ready for maintainer review size/XL 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant