feat(channels)!: remove in-process telegram adapter (now sidecar-only)#5241
Conversation
BREAKING: the `[channels.telegram]` config block is no longer recognised. Telegram now runs as a `[[sidecar_channels]]` adapter (`python3 -m librefang.sidecar.adapters.telegram`, channel_type = "telegram"); the Python adapter reached full parity in the preceding commits of this PR. Same pattern as the ntfy #5224 migration, scaled to telegram's deeper integration. Removed in-process telegram (full cascade): - librefang-channels: src/telegram.rs (deleted, 6231 lines), lib.rs mod, Cargo.toml `channel-telegram` feature + all-channels, channels-allowlist.txt entry (so `cargo xtask channel-policy` permanently blocks any in-process telegram reintroduction), commands/mod.rs stale doc ref. - librefang-types: `TelegramConfig` struct + Default + impl, the `channels.telegram` field + default, validation hook, the orphaned `default_telegram_long_poll_timeout_secs`. The shared deny_unknown_fields / proxy doc anchor moved from `TelegramConfig` to `DiscordConfig` (it was the canonical cross-reference for ~7 other channel structs). - librefang-api: `channel-telegram` feature + membership in core-channels / all-channels / all-channels-no-email / mini; channel_bridge use + Duration cfg + coalesce-window arm + check_channel + spawn block + default-empty test; routes/channels.rs ChannelMeta + the 5 match arms; routes/config.rs `ch!(telegram)`. - librefang-kernel: channel_sender `for_each_channel_field!` macro + name list; config_reload hot-reload test. - librefang-cli: TUI ChannelDef, interactive `channel setup` wizard arm + picker entry + health list, Cargo.toml/help-text examples. - OpenClaw / openfang migrators: telegram is no longer emitted as `[channels.telegram]` (the kernel would reject it); the bot token is still migrated to secrets.env and the channel is reported as skipped with guidance to declare a `[[sidecar_channels]]` block. - `ChannelType::Telegram`, the shared bot-command registry, the bridge channel-type->string map and `[channel_role_mapping.telegram]` RBAC mapping are deliberately kept (shared core / different feature); the sidecar telegram still flows through them. - Tests that used telegram as the arbitrary example channel migrated to discord/slack; kernel-config golden regenerated (no residual TelegramConfig). Verification: cargo check --workspace --lib clean; cargo xtask channel-policy Passed (telegram delisted, allowlist only shrinks); cargo test -p librefang-types / -p librefang-channels / -p librefang-migrate all pass; -p librefang-api routes::channels + channels_routes_test + config_schema_golden pass; clippy (touched crates, --lib) clean. Two failures verified unrelated (zero telegram coupling, files unmodified by this branch, paths orthogonal to the cascade): hooks_agent_dispatches_to_default_agent e2e and the kernel/tests.rs:8972 compact_agent_session_with_id arity drift — same class #5224 documented as pre-existing.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fdafedb882
ℹ️ 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".
| reason: "Telegram is now an out-of-process sidecar adapter. \ | ||
| The bot token was migrated to secrets.env; add a \ | ||
| [[sidecar_channels]] block running `python3 -m \ | ||
| librefang.sidecar.adapters.telegram` with \ | ||
| channel_type = \"telegram\" to enable it (see \ | ||
| docs/architecture/sidecar-channels.md)." |
There was a problem hiding this comment.
Emit the Telegram sidecar during migration
When an OpenClaw workspace has Telegram enabled, this branch now only writes TELEGRAM_BOT_TOKEN to secrets and records the channel as skipped; it never adds a [[sidecar_channels]] entry. The bridge only starts sidecar adapters from config.sidecar_channels, so a migrated Telegram install that previously produced [channels.telegram] is left with no running Telegram channel until the operator hand-edits config, which is a regression for the migration path. Please generate the sidecar entry (with name/channel_type set to telegram) or otherwise treat Telegram as not migrated.
Useful? React with 👍 / 👎.
| /// Manage channel integrations (setup, test, enable, disable) [*]. | ||
| #[command( | ||
| subcommand, | ||
| long_about = "Manage messaging channel integrations (Telegram, Discord, Slack, etc.).\n\nChannels connect your agents to external messaging platforms.\n\nExamples:\n librefang channel list # Show configured channels\n librefang channel setup telegram # Interactive Telegram setup\n librefang channel setup # Interactive channel picker\n librefang channel test telegram # Send a test message\n librefang channel enable telegram # Enable a channel\n librefang channel disable telegram # Disable without removing config" | ||
| long_about = "Manage messaging channel integrations (Discord, Slack, etc.).\n\nChannels connect your agents to external messaging platforms.\n\nExamples:\n librefang channel list # Show configured channels\n librefang channel setup discord # Interactive Discord setup\n librefang channel setup # Interactive channel picker\n librefang channel test discord # Send a test message\n librefang channel enable discord # Enable a channel\n librefang channel disable discord # Disable without removing config" |
There was a problem hiding this comment.
Revert unauthorized librefang-cli edits
The repo-root AGENTS.md for this scope explicitly says, "Do not modify librefang-cli without explicit instruction" because it is under active development. This commit changes CLI help/setup behavior in librefang-cli; unless a maintainer specifically requested CLI edits for this change, this violates the scoped repository instructions and risks conflicting with the active CLI workstream.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7e1889961d
ℹ️ 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".
| report.skipped.push(SkippedItem { | ||
| kind: ItemKind::Channel, | ||
| name: "telegram".to_string(), |
There was a problem hiding this comment.
Preserve legacy Telegram sidecar configs
For legacy OpenClaw workspaces that have messaging/telegram.yaml, this branch now only records Telegram as skipped and never emits a [[sidecar_channels]] entry, even though this migration path still has the old bot_token_env/default_agent available. Since the in-process [channels.telegram] config was removed, the generated LibreFang config contains no Telegram adapter at all, so a previously working Telegram channel is silently lost until the operator hand-edits the config.
Useful? React with 👍 / 👎.
Replace every copy-pasteable [channels.telegram] config block in EN and ZH docs with the equivalent [[sidecar_channels]] snippet. Illustrative overrides examples that used [channels.telegram.overrides] as a placeholder are updated to [channels.discord.overrides]. Config templates (librefang.toml.example, init_default_config.toml) are updated to match.
…e docs (#5241) Remove "telegram" from CLI help suggestions (channel setup doc-comment, channel test long_about examples), Fluent locale channel-unknown-fix lists (EN and zh-CN), and the orphan section-setup-telegram key. Update librefang-channels AGENTS.md and README.md to reflect that telegram joined ntfy as a sidecar-only adapter.
…rdConfig (#5241) Three with_proxy() doc-comments in discord.rs, slack.rs, and mattermost.rs cross-referenced the deleted TelegramAdapter::with_proxy for the URL contract. Re-anchor to DiscordConfig::proxy, which is the canonical proxy doc since the telegram removal. Also update two bridge_integration_test.rs doc-comments that named TelegramAdapter as an example. Update ChannelsConfig field docstring in types.rs to remove "Telegram, etc." and sync the golden schema fixture.
Add [Unreleased] BREAKING entry for the telegram adapter migration: explains that [channels.telegram] is no longer accepted under deny_unknown_fields, what operators must do (re-declare as [[sidecar_channels]]), and what is preserved (TELEGRAM_BOT_TOKEN, channel_type=telegram, RBAC mapping).
Deploying librefang-docs with
|
| Latest commit: |
fd0ff92
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://8ab9e9b3.librefang-docs-web.pages.dev |
| Branch Preview URL: | https://feat-remove-inprocess-telegr.librefang-docs-web.pages.dev |
…d (no 404 actions) (#5249) * fix(api): surface configured sidecar channels on /api/channels Regression from the sidecar migration: when telegram (#5241) and ntfy (#5224) moved out-of-process, their ChannelMeta entries were removed from CHANNEL_REGISTRY — which silently dropped them from the dashboard channels page (/dashboard/channels reads /api/channels, which only enumerated CHANNEL_REGISTRY). An operator who had telegram/ntfy working saw them vanish with no indication of where they went; the operator experience was no longer consistent across in-process vs sidecar adapters. Fix (backend-only, dashboard renders it unchanged): /api/channels and channels_snapshot now also synthesize a row per configured [[sidecar_channels]] entry — name/display, configured=true, category/setup_type="sidecar", instance_count, no editable `fields` (config.toml-managed), and setup_steps pointing the operator at Config -> Sidecar Channels. The ChannelsPage already conditionally hides empty fields/setup_steps, so these render as normal configured/online cards with zero frontend changes. configured_count counts them. A sidecar entry never shadows or duplicates an in-process CHANNEL_REGISTRY row of the same name. Tests (crates/librefang-api/tests/channels_routes_test.rs): - channels_list_includes_configured_sidecar_channels: a configured [[sidecar_channels]] telegram appears as a configured sidecar row, exactly once, counted in configured_count, with no editable fields. - channels_list_without_sidecar_has_no_sidecar_rows: negative control — no synthetic rows leak when none are configured. Verification: cargo check -p librefang-api --lib clean; cargo test -p librefang-api --test channels_routes_test 30 passed; cargo clippy -p librefang-api --lib --tests clean. * fix(dashboard): sidecar channel cards are read-only (no 404 actions) Self-review of the previous commit found a half-fix: surfacing sidecar channels on /api/channels made the cards appear, but every interactive affordance 404s for a sidecar name — `get_channel` / `configure_channel` / `test_channel` are all CHANNEL_REGISTRY-only (find_channel_meta → None → 404). So the card showed up but "Configure" / "Test" were broken. ChannelsPage now treats a `category === "sidecar"` row as read-only: - the inline Configure (Settings) affordance on the card is suppressed (whole-card click still opens the read-only details drawer); - the DetailsModal's Configure/Test action buttons are replaced with a note: runs as an out-of-process sidecar, manage it in config.toml ([[sidecar_channels]]) under Config → Sidecar Channels. DetailsModal renders purely from the list-row data (no per-channel fetch), so opening details on a sidecar row was already safe; only the two mutating/registry actions needed gating. The selection checkbox is inert (no bulk action consumes it), so no other path 404s. Net: sidecar channels (telegram / ntfy / …) are visible AND have no broken interactions — the migration is transparent to operators and the card honestly points at where the channel is actually managed. Frontend-only; `category` is already a typed field used elsewhere in this file.
…mple (#5248) #5241 deleted crates/librefang-channels/src/telegram.rs (telegram is now a sidecar adapter) but the "reference adapters" table in the custom-channel example still listed telegram.rs as an existing in-process implementation to study — a dangling file reference that landed on main with the squash-merge. Swap it for matrix.rs (a real in-process adapter not already in the table), exactly as #5224 swapped ntfy's row for discord when it removed ntfy.rs.
…age after sidecar migration (#5250) Follow-up to #5241 (telegram) and #5224 (ntfy). #5249 fixed the configured case — once an operator declared [[sidecar_channels]], a configured/online card appeared. But operators who had never configured them saw nothing: the Add picker (`pickerChannels` filters on `configured: false` from /api/channels) had no telegram/ntfy entry because both were removed from CHANNEL_REGISTRY, and the new `sidecar_channel_rows` synthesizer only emits rows for entries that already exist in config.toml. Net effect: the only way to discover the two first-party SDK sidecar adapters was to read source or release notes. Fix: add a small SIDECAR_CATALOG (telegram, ntfy) next to CHANNEL_REGISTRY and emit `configured: false, category: "sidecar"` rows for catalog entries not covered by any [[sidecar_channels]]. A catalog entry is "covered" when ANY existing sidecar entry has either `name == entry` or `channel_type == entry`, so a user-aliased `name = "my_alerts", channel_type = "telegram"` correctly suppresses the telegram discovery row instead of double-rendering. `webhook` is deliberately excluded from the catalog — it still has an in-process CHANNEL_REGISTRY entry, and surfacing both would render two cards with the same name. Dashboard: - ChannelItem gains `config_template?: string` (backend already emits it). - `handlePick` routes `category === "sidecar"` to the read-only details modal instead of opening the configure form (which would 404 against /api/channels/{name}/configure — that endpoint is CHANNEL_REGISTRY-only). - DetailsModal renders the TOML snippet for unconfigured sidecar rows so operators can copy it into config.toml directly. Tests (crates/librefang-api/tests/channels_routes_test.rs): - Replaced `channels_list_without_sidecar_has_no_sidecar_rows` (which asserted the now-wrong "no rows when unconfigured" contract) with `channels_list_without_sidecar_surfaces_discovery_catalog`, which asserts both telegram and ntfy appear as unconfigured catalog rows with `category="sidecar"`, no editable fields, and a display_name; also asserts discovery rows don't bump configured_count. - Added `channels_list_discovery_row_hidden_when_kind_configured`: a configured `name="my_alerts", channel_type="telegram"` must suppress the telegram discovery row while leaving the ntfy one in place. Verification: - cargo check -p librefang-api --lib --tests: clean - cargo clippy -p librefang-api --lib --tests -- -D warnings: clean - cargo test -p librefang-api --test channels_routes_test: 31/31 pass - pnpm typecheck: clean - pnpm test --run: 650/650 pass - pnpm build: success
Self-review follow-up to the gotify sidecar migration. The main commit deleted the in-process `GotifyConfig` / `GotifyAdapter` but left several docs pages still showing the old `[channels.gotify]` block and pointing at the deleted Rust adapter. Telegram's #5241 follow-up landed the same kind of redirect — mirror that pattern. - `docs/.../configuration/page.mdx` (en + zh): replace the old `[channels.gotify]` field reference with a redirect-style `[[sidecar_channels]]` example, mirroring the existing Telegram redirect. Also drop the Gotify row from the "validator-checked env vars" table — the validation hook was removed with `GotifyConfig`. - `docs/.../configuration/channels/page.mdx` (en + zh): same redirect. - `docs/.../integrations/channels/integrations/page.mdx` (en + zh): the "## Gotify" section was already stale before this PR — it used hypothetical `GOTIFY_URL` / `GOTIFY_TOKEN` env vars and a `[channels.gotify]` block whose `url_env` / `token_env` keys never matched the actual `GotifyConfig` shape. Rewritten to point at the sidecar with the correct `GOTIFY_SERVER_URL` / `GOTIFY_APP_TOKEN` / `GOTIFY_CLIENT_TOKEN` env var names. - `docs/.../security/integrity/page.mdx`, `docs/.../architecture/security/page.mdx` (en + zh): drop the `GotifyAdapter` row from the "Zeroizing<String> usage in in-process channel adapters" table — the struct is gone with this PR; the env vars themselves are now read by the Python sidecar process, which inherits the daemon's environment without needing in-Rust zeroizing. - `examples/custom-channel/README.md`: drop the `gotify` row from the "study these existing adapters" comparison table — `gotify.rs` no longer exists. The env-var tables under "Required Environment Variables" at `configuration/page.mdx` and `configuration/security/page.mdx` are deliberately kept — the env vars themselves are still in use, just read by the sidecar process rather than the deleted in-process adapter. No code changes; docs-only follow-up to #5263.
…5263) * feat(channels)!: migrate gotify from in-process adapter to sidecar BREAKING: the `[channels.gotify]` config block is no longer recognised. Gotify now runs as a `[[sidecar_channels]]` adapter (`python3 -m librefang.sidecar.adapters.gotify`, channel_type = "gotify"); the new Python adapter ships in `librefang-sdk` with full parity for inbound WebSocket subscribe + outbound REST publish. Same pattern as the ntfy (#5224) and telegram (#5241) migrations. Added sidecar: - `sdk/python/librefang/sidecar/adapters/gotify.py` — preserves the Rust adapter's runtime semantics. WebSocket `/stream` subscribe with the client token in the query string; JSON frames decoded to `(id, message, title, priority, appid)`; empty `message` skipped; `/`-prefixed text → `Command`, otherwise `Text`; sender derived from `title` (fallback `app-{appid}`); metadata carries title / priority / app_id; reconnect with exponential backoff (1s → 60s). REST `/message` publish posts `{title:"LibreFang", message, priority:5}` with the app token in the query string; chunked at 65535 chars suffixing `(i/N)` to the title. Optional `GOTIFY_ACCOUNT_ID` env var surfaces as the multi-bot routing key. - The WebSocket client is a hand-rolled RFC 6455 reader on `socket` + `ssl` — no third-party WS dependency, keeping the SDK zero-dep (matches ntfy / telegram). Server frames are read unmasked; client→server frames (only pong / close) carry a fresh 4-byte mask per frame as the spec requires. Fragmented messages are reassembled before yielding. - `sdk/python/tests/test_gotify_adapter.py` — 22 unit tests: URL/scheme normalization, required-env enforcement, JSON frame parsing including empty-message skip and bad-JSON, command vs text routing, sender fallback to app-id, message chunking with numbered titles, HTTP 500 / HTTPError surfacing, and a loopback-server end-to-end test that performs a real RFC 6455 handshake and asserts both text frames are yielded as decoded strings. Removed in-process gotify (full cascade): - `librefang-channels`: `src/gotify.rs` deleted (649 lines), `lib.rs` mod, `Cargo.toml` `channel-gotify` feature + `all-channels` membership, `channels-allowlist.txt` entry (so `cargo xtask channel-policy` permanently blocks any in-process gotify reintroduction). Stale comment references in `discord.rs` / `slack.rs` / `teams.rs` ("the gotify slice (PR #4447)") generalised to "the wiremock pattern" — the pattern is still in use, just no longer rooted in a gotify example. - `librefang-types`: `GotifyConfig` struct + Default + the `channels.gotify` field + default, validation hook. - `librefang-api`: `channel-gotify` feature + membership in `all-channels` / `all-channels-no-email`; channel_bridge `use` + spawn block + `check_channel` arm + `find_channel_info` arm + default-empty test assertion; `routes/channels.rs` ChannelMeta + the 4 match arms (`gotify_some` / `serde_to_value` / `len` / `ser`); `routes/config.rs` `ch!(gotify)`. - `librefang-kernel`: `channel_sender` `for_each_channel_field!` macro entry + name list. - `librefang-cli`: TUI `ChannelDef`, with the Notifications-section header generalised since gotify joins ntfy off-tree. - `docs/architecture/sidecar-channels.md`: update the long-tail backlog note to record gotify joining ntfy + telegram. The separate gotify push-notification provider (`push_provider = "gotify"`, used by device pairing under `crates/librefang-kernel/ src/pairing.rs`) is **unaffected** — different feature, deliberately left intact. Operator action required: # config.toml: remove [channels.gotify] server_url = "..." app_token_env = "GOTIFY_APP_TOKEN" client_token_env = "GOTIFY_CLIENT_TOKEN" # config.toml: add [[sidecar_channels]] name = "gotify" command = "python3" args = ["-m", "librefang.sidecar.adapters.gotify"] channel_type = "gotify" [sidecar_channels.env] GOTIFY_SERVER_URL = "https://gotify.example.com" GOTIFY_APP_TOKEN = "A..." # secrets.env via secrets system GOTIFY_CLIENT_TOKEN = "C..." # secrets.env via secrets system Verification: - `cd sdk/python && pytest`: 97 tests pass (22 new gotify, 75 pre-existing ntfy/telegram/runtime/protocol all green). - Loopback WebSocket handshake test exercises real RFC 6455 Sec-WebSocket-Accept computation and frame parsing. - `cargo check / clippy / test` deferred to CI — the host's cargo is a Docker wrapper with `rust:latest` not cached locally, and `cargo test --workspace` is forbidden by repo policy. - `kernel_config_schema.golden.json` left with 12 residual gotify refs — the matching test is `#[ignore]`'d on `main` (golden has drifted across earlier migrations); regen is a follow-up via `cargo test -p librefang-api --test config_schema_golden -- --ignored regenerate_golden --nocapture`. * docs(channels): redirect [channels.gotify] examples to sidecar pattern Self-review follow-up to the gotify sidecar migration. The main commit deleted the in-process `GotifyConfig` / `GotifyAdapter` but left several docs pages still showing the old `[channels.gotify]` block and pointing at the deleted Rust adapter. Telegram's #5241 follow-up landed the same kind of redirect — mirror that pattern. - `docs/.../configuration/page.mdx` (en + zh): replace the old `[channels.gotify]` field reference with a redirect-style `[[sidecar_channels]]` example, mirroring the existing Telegram redirect. Also drop the Gotify row from the "validator-checked env vars" table — the validation hook was removed with `GotifyConfig`. - `docs/.../configuration/channels/page.mdx` (en + zh): same redirect. - `docs/.../integrations/channels/integrations/page.mdx` (en + zh): the "## Gotify" section was already stale before this PR — it used hypothetical `GOTIFY_URL` / `GOTIFY_TOKEN` env vars and a `[channels.gotify]` block whose `url_env` / `token_env` keys never matched the actual `GotifyConfig` shape. Rewritten to point at the sidecar with the correct `GOTIFY_SERVER_URL` / `GOTIFY_APP_TOKEN` / `GOTIFY_CLIENT_TOKEN` env var names. - `docs/.../security/integrity/page.mdx`, `docs/.../architecture/security/page.mdx` (en + zh): drop the `GotifyAdapter` row from the "Zeroizing<String> usage in in-process channel adapters" table — the struct is gone with this PR; the env vars themselves are now read by the Python sidecar process, which inherits the daemon's environment without needing in-Rust zeroizing. - `examples/custom-channel/README.md`: drop the `gotify` row from the "study these existing adapters" comparison table — `gotify.rs` no longer exists. The env-var tables under "Required Environment Variables" at `configuration/page.mdx` and `configuration/security/page.mdx` are deliberately kept — the env vars themselves are still in use, just read by the sidecar process rather than the deleted in-process adapter. No code changes; docs-only follow-up to #5263. * fix(sidecar/gotify): cap inbound WS frame payload + harden coverage Round-2 self-review follow-up. Cap inbound WebSocket frame payloads at 1 MiB (MAX_FRAME_PAYLOAD). RFC 6455 allows up to 2^63 bytes via the 64-bit extended-length form; the previous unbounded implementation would faithfully try to read any length the server announced. A hostile or buggy gotify server could send a 64-bit length and force the sidecar to spin in `_recv_exact` allocating multi-exabyte buffers. The cap is well over Gotify's largest realistic payload (a 65 535-char message body wrapped in a JSON envelope sits under 100 KiB), so legitimate traffic is unaffected. Cap breach raises a RuntimeError, which the producer loop catches and treats as a transport error → reconnect with the existing exponential backoff. Three new tests close earlier coverage gaps: - `test_websocket_reader_responds_to_server_ping_with_pong`: drives the reader against a loopback server that sends `text → ping → text → close`. Asserts both text frames are yielded AND that the client sent back exactly one masked pong carrying the same payload as the ping, plus the close echo. Exercises the previously-untested control-frame branches. - `test_websocket_reader_rejects_oversized_frame`: server announces a 1 GiB 64-bit-length text frame; client must raise RuntimeError matching "exceeds cap" instead of trying to allocate. Locks in the new MAX_FRAME_PAYLOAD guard. - `test_account_id_surfaced_via_ready_event` / `test_no_account_id_when_unset`: assert that `GOTIFY_ACCOUNT_ID` reaches LibreFang via the base SidecarAdapter.ready_event() multi-bot routing key (and that an empty env var emits no `account_id` field) — closes the gap noted in the first review. Verification: `cd sdk/python && pytest` — 101 tests pass (4 new gotify + 22 prior gotify + 75 unchanged ntfy/telegram/runtime).
…5264) * feat(channels)!: migrate mastodon from in-process adapter to sidecar BREAKING: the `[channels.mastodon]` config block is no longer recognised. Mastodon now runs as a `[[sidecar_channels]]` adapter (`python3 -m librefang.sidecar.adapters.mastodon`, channel_type = "mastodon"); the new Python adapter ships in `librefang-sdk` with full parity for SSE primary + REST polling fallback inbound and form-encoded REST outbound. Same pattern as the ntfy (#5224), telegram (#5241), and gotify (#5263) migrations. Added sidecar: - `sdk/python/librefang/sidecar/adapters/mastodon.py` — preserves the Rust adapter's runtime semantics. SSE subscribe to `{instance}/api/v1/streaming/user` with Bearer auth; filter `event: notification` payloads to `type == "mention"`; strip HTML from `status.content` (`<br>` / `</p>` / `</div>` / `</li>` insert newlines, entities decoded via stdlib `html.unescape`); `/cmd args` → Command, else Text; sender from `display_name` fallback `username`; metadata carries status_id / notification_id / acct / visibility / in_reply_to_id; `thread_id` = in_reply_to_id; `verify_credentials` at startup discovers the bot's own account id so self-mentions are skipped; REST polling fallback when SSE fails (newest-first ordering, high-water-mark before iteration); exponential-backoff reconnect (1s → 60s). - Outbound: POST `/api/v1/statuses` form-encoded with `status`, `visibility="unlisted"`, optional `in_reply_to_id`. Chunked at `MAX_MESSAGE_LEN` (default 500); chained chunks thread under each prior response id so a long reply stays one conversational thread. Replies to inbound mentions use `cmd.thread_id` (in_reply_to_id). `suppress_error_responses = True` — Mastodon posts are public. - New env-var knobs beyond the in-process equivalents: `MASTODON_VISIBILITY` (public/unlisted/private/direct, default unlisted), `MASTODON_MAX_MESSAGE_LEN` (default 500, raise for longer-toot instances), optional `MASTODON_ACCOUNT_ID` for multi-bot routing surfaced via `ready_event`. - `sdk/python/tests/test_mastodon_adapter.py` — 32 unit tests: URL/scheme normalization, required-env enforcement, visibility allowlist, max_len validation, account_id surfacing, HTML stripper edge cases (plain passthrough, basic tags, block-close newlines, entity decoding, typical mention anchor shape), notification parsing (full shape, thread_id from in_reply_to, non-mention skip, self-mention skip, empty text skip, slash command, display_name fallback), message chunking, REST publish (bearer auth + form + visibility, chunked thread chaining, reply to inbound thread, HTTPError surfaced, 5xx surfaced, custom visibility). Removed in-process mastodon (full cascade): - `librefang-channels`: `src/mastodon.rs` deleted (850 lines), `lib.rs` mod, `Cargo.toml` `channel-mastodon` feature + `all-channels` membership, `channels-allowlist.txt` entry (so `cargo xtask channel-policy` permanently blocks any in-process mastodon reintroduction). - `librefang-types`: `MastodonConfig` struct + Default + the `channels.mastodon` field + default, validation hook. - `librefang-api`: `channel-mastodon` feature + membership in `all-channels` / `all-channels-no-email`; channel_bridge `use` + spawn block + `check_channel` arm + `find_channel_info` arm + default-empty test assertion; `routes/channels.rs` ChannelMeta + 4 match arms (`mastodon_some` / `serde_to_value` / `len` / `ser`); `routes/config.rs` `ch!(mastodon)`. Added Mastodon to SIDECAR_CATALOG so the dashboard Channels page surfaces a configurable discovery row. - `librefang-kernel`: `channel_sender` `for_each_channel_field!` macro entry + name list. - `librefang-cli`: TUI `ChannelDef`. - `docs/src/app/*`: redirect both English and Chinese config / channels / integrations pages from `[channels.mastodon]` to `[[sidecar_channels]]`. Operator action required: # config.toml: remove [channels.mastodon] instance_url = "..." access_token_env = "MASTODON_ACCESS_TOKEN" # config.toml: add [[sidecar_channels]] name = "mastodon" command = "python3" args = ["-m", "librefang.sidecar.adapters.mastodon"] channel_type = "mastodon" [sidecar_channels.env] MASTODON_INSTANCE_URL = "https://mastodon.social" # secrets.env via secrets system: # MASTODON_ACCESS_TOKEN=... Verification: - `cd sdk/python && pytest`: 133 tests pass (32 new mastodon, 26 prior gotify, 75 unchanged ntfy/telegram/runtime/protocol). - `cargo check / clippy / test` deferred to CI — the host's cargo is a Docker wrapper with `rust:latest` not cached locally, and workspace-wide `cargo test` is forbidden by repo policy. - `kernel_config_schema.golden.json`: the matching test is `#[ignore]`'d on main (the fixture has drifted across earlier migrations); regen is a follow-up via `cargo test -p librefang-api --test config_schema_golden -- --ignored regenerate_golden`. * fix(sidecar/mastodon): call _verify_credentials before SSE/poll loop Self-review follow-up. The Rust adapter called `validate()` at the start of `ChannelAdapter::start()` to (a) validate the OAuth token and (b) discover the bot's own account id used by the self-mention guard in `parse_mastodon_notification`. My port defined `_verify_credentials` but never actually called it from `_producer_blocking`, so `self.own_account_id` stayed `None` and the guard: if self.own_account_id and account_id == self.own_account_id: return None silently short-circuited — a self-mention from the bot's own toots would be delivered back to the agent as an inbound event, potentially looping if the agent's reply itself triggers another mention. Same parity bug the in-process adapter explicitly defended against. Fix: `_producer_blocking` now runs verify with exponential backoff (1s → 60s) before entering the SSE-with-polling-fallback loop. The loop refuses to emit any event until verify has succeeded at least once. Transient verify failures (instance returning 5xx, network flake) retry instead of taking the sidecar down; persistent auth failures keep retrying — the supervisor can kill the subprocess if the operator wants a hard fail. Three new tests close coverage gaps the original PR missed: - `test_verify_credentials_sets_own_account_id`: monkeypatch urlopen, assert the request hits `/api/v1/accounts/verify_credentials` with the right Bearer header AND that the response `id` is stored on `own_account_id`. Locks in the discovery contract. - `test_verify_credentials_raises_on_bad_token`: a 401 response surfaces the HTTPError; `own_account_id` stays None so the self-mention guard does not match anything by accident. - `test_self_mention_skipped_only_after_verify`: the failing case — without verify, a self-mention IS delivered (proving the latent bug shape); after `own_account_id` is set the same payload is filtered. Catches a future regression where someone removes the verify call again. Verification: `cd sdk/python && pytest` — 35 mastodon tests pass (+3 new), full SDK suite 136 passed.
…bound routing pin (closes librefang#5294) Sidecar adapter migration (librefang#5241) dropped the per-channel default_agent field that in-process configs (TelegramConfig, DiscordConfig, etc.) used to seed AgentRouter.channel_defaults at boot. The router-population loop in channel_bridge.rs pushed sidecar adapters into the adapters vec with None for the default-agent slot, so the per-channel set_channel_default_with_name call never fired for sidecar channels. Result: inbound messages on a sidecar channel with no explicit binding fell through to the non-deterministic 'first available agent' branch in resolve_or_fallback. Spawning a new agent (without restricting its channels allowlist) silently redirected inbound traffic to it. Fix: add default_agent: Option<String> to SidecarChannelConfig and propagate it through the adapter-push so the existing population loop seeds the router. No behavioural change when the field is absent. Test: sidecar_default_agent_roundtrip_5294.
…bound routing pin (closes #5294) (#5295) Sidecar adapter migration (#5241) dropped the per-channel default_agent field that in-process configs (TelegramConfig, DiscordConfig, etc.) used to seed AgentRouter.channel_defaults at boot. The router-population loop in channel_bridge.rs pushed sidecar adapters into the adapters vec with None for the default-agent slot, so the per-channel set_channel_default_with_name call never fired for sidecar channels. Result: inbound messages on a sidecar channel with no explicit binding fell through to the non-deterministic 'first available agent' branch in resolve_or_fallback. Spawning a new agent (without restricting its channels allowlist) silently redirected inbound traffic to it. Fix: add default_agent: Option<String> to SidecarChannelConfig and propagate it through the adapter-push so the existing population loop seeds the router. No behavioural change when the field is absent. Test: sidecar_default_agent_roundtrip_5294.
…5297) Replaces the 535-line in-process librefang-channels::twitch adapter with the Python sidecar librefang.sidecar.adapters.twitch. Same pattern as ntfy #5224, telegram #5241, gotify #5263, mastodon #5264, bluesky #5277, reddit #5281. Behaviour parity: - OAuth PASS / NICK handshake (oauth: prefix auto-added when absent) - JOIN #channel for each configured channel - PRIVMSG -> ChannelMessage with case-insensitive self-skip - /cmd / !cmd -> Content::Command, else Content::Text - PING -> PONG keepalive - MAX_MESSAGE_LEN = 500 chunking - Exponential reconnect backoff (1s -> 60s) - TWITCH_ACCOUNT_ID multi-bot routing Three improvements over the Rust adapter (file/line evidence): 1. TLS by default. The Rust adapter hard-coded plaintext port 6667 (TWITCH_IRC_PORT in crates/librefang-channels/src/twitch.rs:24) and sent the OAuth token in cleartext on every connect. The sidecar defaults to irc.chat.twitch.tv:6697 with ssl.create_default_context(). Plaintext is reachable only via TWITCH_PLAINTEXT=1 (tests/mock listeners). Credential-leak-on-wire fixed automatically on upgrade. 2. Per-message reply threading via IRCv3 tags. The Rust adapter never requested any capability and discarded any @-tag block (parse_privmsg in twitch.rs:100 only handled the bare ":nick!u@h PRIVMSG #ch :text" shape), so chunked replies arrived as a flat sequence with no link back. The sidecar issues CAP REQ :twitch.tv/tags twitch.tv/commands, parses @id, surfaces it as thread_id, and attaches @reply-parent-msg-id=<id> on outbound PRIVMSG. 3. Token-bucket send rate-limiter. Twitch drops the bot above 20 msgs / 30 s (100 / 30 s if mod). The Rust adapter shipped zero throttling (send() in twitch.rs:312 wrote PRIVMSG frames straight to the socket). Sidecar gates every outbound chunk; tunable via TWITCH_RATE_LIMIT_MSGS / TWITCH_RATE_LIMIT_SECS. Cascade removal: crates/librefang-channels/src/twitch.rs deleted; lib.rs mod decl, channels-allowlist.txt entry, channel-twitch feature in librefang-channels + librefang-api (incl. all-channels / all-channels-no-email membership); TwitchConfig + ChannelsConfig.twitch field + Default; validation env-var hook; channel_bridge import + spawn block + find_channel_info! arm + check_channel! arm + default- empty test assertion; routes/channels.rs ChannelMeta + 4 match arms; routes/config.rs ch!(twitch); kernel channel_sender macro entry + EXPECTED name; CLI TUI ChannelDef; types/config/mod.rs unit tests referencing TwitchConfig; docs (en + zh): configuration / channels / integrations / architecture / communication pages -- [channels.twitch] blocks replaced with [[sidecar_channels]] redirect; architecture-row moves twitch into the sidecar list. New env-var knobs: TWITCH_NICK, TWITCH_CHANNELS (comma-separated, no '#'), optional TWITCH_ACCOUNT_ID, optional TWITCH_RATE_LIMIT_MSGS / TWITCH_RATE_LIMIT_SECS, optional TWITCH_PLAINTEXT / TWITCH_HOST / TWITCH_PORT (test escape hatches). Operator action required: an existing [channels.twitch] block is no longer recognised -- re-declare as a [[sidecar_channels]] running python3 -m librefang.sidecar.adapters.twitch with env vars TWITCH_NICK, TWITCH_CHANNELS (in [sidecar_channels.env]) and TWITCH_OAUTH_TOKEN (~/.librefang/secrets.env). Verification: - cargo check --workspace --lib: clean - cargo clippy -p librefang-types -p librefang-channels -p librefang-kernel -p librefang-api -p librefang-cli --all-targets -- -D warnings: clean - cargo test -p librefang-types -p librefang-channels -p librefang-kernel --lib: 843 passed - cargo test -p librefang-api --lib: 686 passed - cargo test -p librefang-api --test channels_routes_test --test sidecar_describe_test: 38 + 2 passed - cd sdk/python && python -m pytest tests/test_twitch_adapter.py: 68 passed
Replace the 1747-line in-process librefang-channels::discord adapter (Gateway WS v10 + REST v10) with an out-of-process Python sidecar at sdk/python/librefang/sidecar/adapters/discord.py (stdlib-only). Behaviour parity: - GET /gateway/bot URL discovery + WSS connect with ?v=10&encoding=json - HELLO -> IDENTIFY/RESUME -> DISPATCH state machine (READY captures bot_user_id / session_id / resume_gateway_url; INVALID_SESSION resumable preserves state, non-resumable clears; RECONNECT raises) - MESSAGE_CREATE / MESSAGE_UPDATE -> message event with self-skip, ignore_bots, allowed_users / allowed_guilds filters - Attachment-takes-priority-over-slash-command (Image / Video / Voice / File by MIME prefix; audio/file warn-and-drop on companion text) - Discriminator-aware display name; mention detection via mentions[] array + <@id> / <@!id> tags + case-insensitive mention_patterns - POST /channels/{id}/messages with 2000-UTF-16-unit chunking - POST /channels/{id}/typing for typing indicators - account_id injection into message metadata Three improvements over the Rust adapter: 1. **Periodic client-side heartbeats**. The Rust adapter captured heartbeat_interval from HELLO but never sent its own heartbeats — connections silently dropped after ~45s with code=4000 and re-IDENTIFY'd, losing the session every minute. The sidecar runs proper periodic heartbeats with the RFC-mandated random jitter on the first beat, plus a server-initiated heartbeat slides the scheduler's next-deadline so we never double-beat back-to-back. 2. **429 retry-with-Retry-After**. The Rust api_send_message warned on 429 and returned Ok(()) (fail-open silent message loss); the sidecar honours Retry-After and retries once before logging-and- continuing on the second 429. 3. **select-gated frame waits**. The WebSocket reader uses select() to wait for the socket to become readable BEFORE consuming a frame, so mid-frame stalls can't race a heartbeat tick. Known-fatal close codes (4004 / 4013 / 4014) raise rather than reconnect so the supervisor's circuit-breaker stops a hard config error instead of looping. Two regressions to call out (both match the telegram-sidecar #5241 precedent; flagged so operators aren't surprised): - **Live Discord-guild-role RBAC is gone.** ChannelRoleQuery is a Rust trait the sidecar process cannot implement, so role_query is None for Discord post-migration, the kernel's resolve_role_for_sender falls through to the default-deny Viewer branch, and [channel_role_mapping.discord] is no longer consulted. Workaround: enumerate authorised operators under [users] with channel_bindings = { discord = [...] } and an explicit role. - **Per-channel proxy override (#4795) is not wired through.** The sidecar honours HTTP_PROXY / HTTPS_PROXY / ALL_PROXY via stdlib but has no DISCORD_PROXY_URL env var yet. Filed as a follow-up. Cascade removal: - src/discord.rs deleted; lib.rs mod decl, channels-allowlist.txt entry, cargo features in librefang-channels + librefang-api (incl. membership in all-channels / all-channels-no-email / core-channels / mini) removed - DiscordConfig struct + Default impl + canonical deny_unknown_fields rustdoc anchor moved to SlackConfig; ChannelsConfig.discord field + default removed; validation hook removed - channel_bridge.rs: import, spawn block, find_channel_info! arm, check_channel! arm, default-empty test assertion removed - routes/channels.rs: ChannelMeta entry + 5 match arms removed; live-test discord branch (POST to discord.com/api/v10) removed; SIDECAR_CATALOG discord entry added; instance_helper_tests reanchored on slack; downstream_send_failure_returns_502 retired - routes/config.rs ch!(discord) call removed; is_writable_config_path test fixture switched to slack - kernel channel_sender for_each_channel_field! macro entry + EXPECTED name-list entry removed - CLI: librefang setup discord wizard arm + channel list row removed; TUI ChannelDef removed - openclaw migrator: discord channel input now produces a SkippedItem with a sidecar-redirect reason rather than writing [channels.discord] to the output (mirrors how telegram was handled in #5241); test_policy_migration retargeted at slack - openfang test fixtures reanchored on [channels.slack] - librefang.toml.example + cli init_default_config.toml: [channels.discord] block replaced with a commented [[sidecar_channels]] template - docs (en + zh): configuration/page.mdx, configuration/channels/page.mdx, integrations/channels/page.mdx, integrations/channels/core/page.mdx — [channels.discord] blocks replaced with [[sidecar_channels]] templates + a per-channel summary of what the sidecar preserves vs. what changed - ChannelType::Discord enum variant kept (still used by the bridge / router; same pattern as ChannelType::Telegram after #5241) New env-var knobs (read by the sidecar from [sidecar_channels.env]): DISCORD_ALLOWED_GUILDS, DISCORD_ALLOWED_USERS, DISCORD_INTENTS (default 37376), DISCORD_IGNORE_BOTS (default true), DISCORD_MENTION_PATTERNS, DISCORD_ACCOUNT_ID. DISCORD_BOT_TOKEN goes to ~/.librefang/secrets.env. Operator action required: an existing [channels.discord] block is no longer recognised — re-declare as a [[sidecar_channels]] running python3 -m librefang.sidecar.adapters.discord. Verification: - cargo check --workspace --lib: clean - cargo check --workspace --features librefang-api/all-channels: clean - cargo clippy --workspace --all-targets --features librefang-api/all-channels -- -D warnings: zero warnings - cargo test -p librefang-channels --features all-channels --lib: 824/824 - cargo test -p librefang-channels --features all-channels --test bridge_integration_test: 27/27 - cargo test -p librefang-types --lib: 842/842 - cargo test -p librefang-kernel --lib: 1085/1085 - cargo test -p librefang-migrate --lib: 51/51 - cargo test -p librefang-api --lib --features all-channels: 692/692 - cargo test -p librefang-api --test channels_routes_test --features all-channels: 38/38 - cd sdk/python && pytest: 293/293 (69 new for discord including test_handle_payload_dispatch_does_not_signal_heartbeat_sent contract test for #3)
…5299) Replace the 1747-line in-process librefang-channels::discord adapter (Gateway WS v10 + REST v10) with an out-of-process Python sidecar at sdk/python/librefang/sidecar/adapters/discord.py (stdlib-only). Behaviour parity: - GET /gateway/bot URL discovery + WSS connect with ?v=10&encoding=json - HELLO -> IDENTIFY/RESUME -> DISPATCH state machine (READY captures bot_user_id / session_id / resume_gateway_url; INVALID_SESSION resumable preserves state, non-resumable clears; RECONNECT raises) - MESSAGE_CREATE / MESSAGE_UPDATE -> message event with self-skip, ignore_bots, allowed_users / allowed_guilds filters - Attachment-takes-priority-over-slash-command (Image / Video / Voice / File by MIME prefix; audio/file warn-and-drop on companion text) - Discriminator-aware display name; mention detection via mentions[] array + <@id> / <@!id> tags + case-insensitive mention_patterns - POST /channels/{id}/messages with 2000-UTF-16-unit chunking - POST /channels/{id}/typing for typing indicators - account_id injection into message metadata Three improvements over the Rust adapter: 1. **Periodic client-side heartbeats**. The Rust adapter captured heartbeat_interval from HELLO but never sent its own heartbeats — connections silently dropped after ~45s with code=4000 and re-IDENTIFY'd, losing the session every minute. The sidecar runs proper periodic heartbeats with the RFC-mandated random jitter on the first beat, plus a server-initiated heartbeat slides the scheduler's next-deadline so we never double-beat back-to-back. 2. **429 retry-with-Retry-After**. The Rust api_send_message warned on 429 and returned Ok(()) (fail-open silent message loss); the sidecar honours Retry-After and retries once before logging-and- continuing on the second 429. 3. **select-gated frame waits**. The WebSocket reader uses select() to wait for the socket to become readable BEFORE consuming a frame, so mid-frame stalls can't race a heartbeat tick. Known-fatal close codes (4004 / 4013 / 4014) raise rather than reconnect so the supervisor's circuit-breaker stops a hard config error instead of looping. Two regressions to call out (both match the telegram-sidecar #5241 precedent; flagged so operators aren't surprised): - **Live Discord-guild-role RBAC is gone.** ChannelRoleQuery is a Rust trait the sidecar process cannot implement, so role_query is None for Discord post-migration, the kernel's resolve_role_for_sender falls through to the default-deny Viewer branch, and [channel_role_mapping.discord] is no longer consulted. Workaround: enumerate authorised operators under [users] with channel_bindings = { discord = [...] } and an explicit role. - **Per-channel proxy override (#4795) is not wired through.** The sidecar honours HTTP_PROXY / HTTPS_PROXY / ALL_PROXY via stdlib but has no DISCORD_PROXY_URL env var yet. Filed as a follow-up. Cascade removal: - src/discord.rs deleted; lib.rs mod decl, channels-allowlist.txt entry, cargo features in librefang-channels + librefang-api (incl. membership in all-channels / all-channels-no-email / core-channels / mini) removed - DiscordConfig struct + Default impl + canonical deny_unknown_fields rustdoc anchor moved to SlackConfig; ChannelsConfig.discord field + default removed; validation hook removed - channel_bridge.rs: import, spawn block, find_channel_info! arm, check_channel! arm, default-empty test assertion removed - routes/channels.rs: ChannelMeta entry + 5 match arms removed; live-test discord branch (POST to discord.com/api/v10) removed; SIDECAR_CATALOG discord entry added; instance_helper_tests reanchored on slack; downstream_send_failure_returns_502 retired - routes/config.rs ch!(discord) call removed; is_writable_config_path test fixture switched to slack - kernel channel_sender for_each_channel_field! macro entry + EXPECTED name-list entry removed - CLI: librefang setup discord wizard arm + channel list row removed; TUI ChannelDef removed - openclaw migrator: discord channel input now produces a SkippedItem with a sidecar-redirect reason rather than writing [channels.discord] to the output (mirrors how telegram was handled in #5241); test_policy_migration retargeted at slack - openfang test fixtures reanchored on [channels.slack] - librefang.toml.example + cli init_default_config.toml: [channels.discord] block replaced with a commented [[sidecar_channels]] template - docs (en + zh): configuration/page.mdx, configuration/channels/page.mdx, integrations/channels/page.mdx, integrations/channels/core/page.mdx — [channels.discord] blocks replaced with [[sidecar_channels]] templates + a per-channel summary of what the sidecar preserves vs. what changed - ChannelType::Discord enum variant kept (still used by the bridge / router; same pattern as ChannelType::Telegram after #5241) New env-var knobs (read by the sidecar from [sidecar_channels.env]): DISCORD_ALLOWED_GUILDS, DISCORD_ALLOWED_USERS, DISCORD_INTENTS (default 37376), DISCORD_IGNORE_BOTS (default true), DISCORD_MENTION_PATTERNS, DISCORD_ACCOUNT_ID. DISCORD_BOT_TOKEN goes to ~/.librefang/secrets.env. Operator action required: an existing [channels.discord] block is no longer recognised — re-declare as a [[sidecar_channels]] running python3 -m librefang.sidecar.adapters.discord. Verification: - cargo check --workspace --lib: clean - cargo check --workspace --features librefang-api/all-channels: clean - cargo clippy --workspace --all-targets --features librefang-api/all-channels -- -D warnings: zero warnings - cargo test -p librefang-channels --features all-channels --lib: 824/824 - cargo test -p librefang-channels --features all-channels --test bridge_integration_test: 27/27 - cargo test -p librefang-types --lib: 842/842 - cargo test -p librefang-kernel --lib: 1085/1085 - cargo test -p librefang-migrate --lib: 51/51 - cargo test -p librefang-api --lib --features all-channels: 692/692 - cargo test -p librefang-api --test channels_routes_test --features all-channels: 38/38 - cd sdk/python && pytest: 293/293 (69 new for discord including test_handle_payload_dispatch_does_not_signal_heartbeat_sent contract test for #3)
Replaces the 640-line in-process librefang-channels::nextcloud adapter with the Python sidecar librefang.sidecar.adapters.nextcloud. Same pattern as ntfy #5224, telegram #5241, gotify #5263, mastodon #5264, bluesky #5277, reddit #5281, twitch #5297, rocketchat #5298. Behaviour parity: - GET /ocs/v2.php/cloud/user startup credential probe - Per-room chat/<token>?lookIntoFuture=1 polling at 3 s default - Empty allowlist auto-discovers via apps/spreed/api/v4/room - Same Bearer + OCS-APIRequest: true headers - Slash-command (/cmd args) routing - Multi-bot account_id metadata injection - 32000-char message chunking (MAX_MESSAGE_LEN) - Per-room transport-error isolation - 304-as-no-op handling of Talk's long-poll-expired response - suppress_error_responses (Talk rooms are multi-participant) Three improvements over the Rust adapter (file/line evidence in CHANGELOG): 1. Outbound threading via replyTo is now actually wired. The Rust api_send_message (nextcloud.rs:130-160) posted just {"message": ...} — Talk's replyTo form parameter was never sent and chunked/threaded replies always landed at the room root. The sidecar surfaces the inbound id (or parentMessage.id when inside a thread) as thread_id, and on_send posts replyTo so replies thread correctly. Mirrors reddit/bluesky/mastodon/ rocketchat. 2. Self-skip on (actorType, actorId) == ("users", own_user_id) rather than actorId alone. The Rust adapter compared on actorId (nextcloud.rs:338) without inspecting actorType, so a Talk guest/federated_users actor whose id happened to match the bot's user id silently spoofed self-skip. Parallels the rocketchat #5298 fix. 3. Dedupe set on id, bounded SEEN_MESSAGES_MAX=10000 / SEEN_MESSAGES_EVICT=5000. The Rust adapter only relied on the server-side lastKnownMessageId cursor (nextcloud.rs:347-354); under retry/re-poll boundaries Talk can resend the same id. Same policy as reddit/rocketchat. Cascade removal: crates/librefang-channels/src/nextcloud.rs deleted; lib.rs mod decl, channels-allowlist.txt entry, channel-nextcloud feature in librefang-channels + librefang-api (incl. all-channels / all-channels-no-email membership); NextcloudConfig + ChannelsConfig.nextcloud field + Default; validation env-var hook; channel_bridge import + spawn block + find_channel_info! arm + check_channel! arm + default-empty test assertion; routes/channels.rs ChannelMeta + 4 match arms; routes/config.rs ch!(nextcloud); kernel channel_sender macro entry + EXPECTED name; CLI TUI ChannelDef; docs (en + zh): configuration / channels / integrations / architecture / communication / security pages — [channels.nextcloud] blocks replaced with [[sidecar_channels]] redirect; architecture-row moves Nextcloud Talk into the sidecar list. routes/channels.rs SIDECAR_CATALOG gains a nextcloud entry. Golden fixture regenerated. New env-var knobs: NEXTCLOUD_SERVER_URL (replaces server_url), optional NEXTCLOUD_ROOMS (comma-separated room tokens, empty = auto-discover), optional NEXTCLOUD_ACCOUNT_ID for multi-bot routing, optional NEXTCLOUD_POLL_INTERVAL_SECS (default 3, floor 1). Operator action required: an existing [channels.nextcloud] block is no longer recognised — re-declare as a [[sidecar_channels]] running python3 -m librefang.sidecar.adapters.nextcloud with env var NEXTCLOUD_SERVER_URL (in [sidecar_channels.env]) and NEXTCLOUD_TOKEN (in ~/.librefang/secrets.env). Verification: - cargo check --workspace --lib: clean - cargo clippy --workspace --all-targets -- -D warnings: clean - cargo test -p librefang-types -p librefang-channels -p librefang-kernel --lib: 842 passed - cargo test -p librefang-api --lib: 686 passed - cargo test -p librefang-api --test channels_routes_test --test sidecar_describe_test: 38 + 2 passed - cd sdk/python && pytest tests/test_nextcloud_adapter.py: 58 passed
…#5301) Replaces the 640-line in-process librefang-channels::nextcloud adapter with the Python sidecar librefang.sidecar.adapters.nextcloud. Same pattern as ntfy #5224, telegram #5241, gotify #5263, mastodon #5264, bluesky #5277, reddit #5281, twitch #5297, rocketchat #5298. Behaviour parity: - GET /ocs/v2.php/cloud/user startup credential probe - Per-room chat/<token>?lookIntoFuture=1 polling at 3 s default - Empty allowlist auto-discovers via apps/spreed/api/v4/room - Same Bearer + OCS-APIRequest: true headers - Slash-command (/cmd args) routing - Multi-bot account_id metadata injection - 32000-char message chunking (MAX_MESSAGE_LEN) - Per-room transport-error isolation - 304-as-no-op handling of Talk's long-poll-expired response - suppress_error_responses (Talk rooms are multi-participant) Three improvements over the Rust adapter (file/line evidence in CHANGELOG): 1. Outbound threading via replyTo is now actually wired. The Rust api_send_message (nextcloud.rs:130-160) posted just {"message": ...} — Talk's replyTo form parameter was never sent and chunked/threaded replies always landed at the room root. The sidecar surfaces the inbound id (or parentMessage.id when inside a thread) as thread_id, and on_send posts replyTo so replies thread correctly. Mirrors reddit/bluesky/mastodon/ rocketchat. 2. Self-skip on (actorType, actorId) == ("users", own_user_id) rather than actorId alone. The Rust adapter compared on actorId (nextcloud.rs:338) without inspecting actorType, so a Talk guest/federated_users actor whose id happened to match the bot's user id silently spoofed self-skip. Parallels the rocketchat #5298 fix. 3. Dedupe set on id, bounded SEEN_MESSAGES_MAX=10000 / SEEN_MESSAGES_EVICT=5000. The Rust adapter only relied on the server-side lastKnownMessageId cursor (nextcloud.rs:347-354); under retry/re-poll boundaries Talk can resend the same id. Same policy as reddit/rocketchat. Cascade removal: crates/librefang-channels/src/nextcloud.rs deleted; lib.rs mod decl, channels-allowlist.txt entry, channel-nextcloud feature in librefang-channels + librefang-api (incl. all-channels / all-channels-no-email membership); NextcloudConfig + ChannelsConfig.nextcloud field + Default; validation env-var hook; channel_bridge import + spawn block + find_channel_info! arm + check_channel! arm + default-empty test assertion; routes/channels.rs ChannelMeta + 4 match arms; routes/config.rs ch!(nextcloud); kernel channel_sender macro entry + EXPECTED name; CLI TUI ChannelDef; docs (en + zh): configuration / channels / integrations / architecture / communication / security pages — [channels.nextcloud] blocks replaced with [[sidecar_channels]] redirect; architecture-row moves Nextcloud Talk into the sidecar list. routes/channels.rs SIDECAR_CATALOG gains a nextcloud entry. Golden fixture regenerated. New env-var knobs: NEXTCLOUD_SERVER_URL (replaces server_url), optional NEXTCLOUD_ROOMS (comma-separated room tokens, empty = auto-discover), optional NEXTCLOUD_ACCOUNT_ID for multi-bot routing, optional NEXTCLOUD_POLL_INTERVAL_SECS (default 3, floor 1). Operator action required: an existing [channels.nextcloud] block is no longer recognised — re-declare as a [[sidecar_channels]] running python3 -m librefang.sidecar.adapters.nextcloud with env var NEXTCLOUD_SERVER_URL (in [sidecar_channels.env]) and NEXTCLOUD_TOKEN (in ~/.librefang/secrets.env). Verification: - cargo check --workspace --lib: clean - cargo clippy --workspace --all-targets -- -D warnings: clean - cargo test -p librefang-types -p librefang-channels -p librefang-kernel --lib: 842 passed - cargo test -p librefang-api --lib: 686 passed - cargo test -p librefang-api --test channels_routes_test --test sidecar_describe_test: 38 + 2 passed - cd sdk/python && pytest tests/test_nextcloud_adapter.py: 58 passed
Replace the 1890-line in-process librefang-channels::slack adapter
(Socket Mode WS + Web API) with an out-of-process Python sidecar at
sdk/python/librefang/sidecar/adapters/slack.py (stdlib-only).
Behaviour parity:
- POST /api/auth.test startup probe to discover bot_user_id
- POST /api/apps.connections.open to mint Socket Mode WSS URL
- Envelope-id ACK loop for events_api / interactive
- message + app_mention event handling with message_changed subtype
extraction; all-other-subtype skip
- Self-skip on bot_id presence OR user == bot_user_id
- allowed_channels filter with DM exemption (channels starting with D)
- Slash-command routing on /cmd args -> Command (text otherwise)
- thread_ts captured as thread_id
- DM detection via channel-id prefix; is_group = !channel.startswith("D")
- block_actions interactive payload -> ButtonCallback content with
action_id / trigger_id / block_action metadata
- chat.postMessage send with optional thread_ts + unfurl_links + Block
Kit blocks; 3000-char chunking matches SLACK_MSG_LIMIT
- eyes reaction on receive flipped to white_check_mark on send-complete
(opt-out via SLACK_REACTIONS=false)
- force_flat_replies knob posts replies as top-level channel messages
instead of threads
- sender_user_id metadata key matches Rust SENDER_USER_ID_KEY parity
- account_id injection for multi-bot routing
One improvement over the Rust adapter: pending-reaction map is now
bounded at MAX_PENDING_REACTIONS=2000 entries with oldest-eviction.
The Rust adapter used an unbounded RwLock<HashMap> so a flood of
inbound messages followed by a hang in the agent loop would grow
the map without bound — a small but real memory-leak surface.
Two regressions to call out (both match the telegram-#5241 and
discord-#5299 precedents; flagged so operators aren't surprised):
- **Live Slack workspace-role RBAC is gone.** ChannelRoleQuery is a
Rust trait the sidecar process cannot implement, so role_query is
None for Slack post-migration; the kernel's resolve_role_for_sender
falls through to the default-deny Viewer branch and
[channel_role_mapping.slack] is no longer consulted. Workaround:
enumerate authorised operators under [users] with
channel_bindings = { slack = ["<slack_user_id>"] } and an explicit
role. The parse_users_info precedence parser is preserved in the
sidecar so a future sidecar-protocol query/response pair can
re-use it.
- **Per-channel proxy override (#4795) is not wired through.** The
sidecar honours HTTP_PROXY / HTTPS_PROXY / ALL_PROXY via stdlib but
has no SLACK_PROXY_URL env var yet. Filed as a follow-up.
Cascade removal:
- src/slack.rs deleted; lib.rs mod decl, channels-allowlist.txt
entry, cargo features in librefang-channels + librefang-api (incl.
membership in all-channels / all-channels-no-email / core-channels
/ mini) removed
- SlackConfig struct + Default impl + canonical deny_unknown_fields
rustdoc anchor moved to WhatsAppConfig; ChannelsConfig.slack field
+ default removed; validation hook removed
- channel_bridge.rs: import, spawn block, find_channel_info! arm,
check_channel! arm, default-empty test assertion removed
- routes/channels.rs: ChannelMeta entry + 5 match arms removed;
live-test slack branch (POST to slack.com/api/chat.postMessage)
retired with a comment explaining why; SIDECAR_CATALOG slack
entry added; instance_helper_tests reanchored on matrix;
test_channel tests reanchored on matrix
- routes/config.rs ch!(slack) call removed; is_writable_config_path
test fixtures switched to matrix
- kernel channel_sender for_each_channel_field! macro entry +
EXPECTED name-list entry removed
- CLI: librefang setup slack wizard arm + channel list row removed;
TUI ChannelDef removed
- channels_routes_test: SlackConfig fixtures retargeted to
MatrixConfig (bot_token_env -> access_token_env), test names /
display_name assertions updated, "other rows must not flip" check
uses mattermost instead
- routes/skills.rs tests: channels.slack -> channels.matrix bulk
rename across all TOML write/read tests; ad-hoc local Slack
struct renamed to Matrix
- types/config/mod.rs: test_slack_config_defaults +
test_slack_config_unfurl_links_deserialization tests removed
(covered by slack.rs unit tests which were also deleted);
validate_missing_env_vars retargeted to WhatsAppConfig;
test_one_or_many_single_toml_table + test_one_or_many_array_of_tables
retargeted to MatrixConfig; well_formed_repeated_channel_table_still_parses_5130
drops the slack fixture (slack was the last working anchor — now
only whatsapp + mattermost remain as deny_unknown_fields drift
sentinels)
- openclaw migrator: slack channel input now produces a SkippedItem
with a sidecar-redirect reason rather than writing [channels.slack]
(mirrors telegram + discord); test_policy_migration retargeted at
mattermost (slack and discord both sidecar-skipped); test_full_migration
legacy YAML fixture now includes mattermost.yaml so the
"report.imported.iter().any(Channel)" assertion still has a witness;
test_json5_channel_extraction adds mattermost and asserts all three
of telegram/discord/slack are sidecar-skipped
- openfang test fixtures reanchored on [channels.whatsapp]
- librefang.toml.example + cli init_default_config.toml:
[channels.slack] block replaced with commented [[sidecar_channels]]
template
- docs (en + zh): configuration/page.mdx, configuration/channels/page.mdx,
integrations/channels/page.mdx, integrations/channels/core/page.mdx
— [channels.slack] blocks replaced with [[sidecar_channels]] templates
- ChannelType::Slack enum variant kept (still used by the bridge /
router; same pattern as ChannelType::Telegram / ChannelType::Discord)
New env-var knobs (read by the sidecar from [sidecar_channels.env]):
SLACK_ALLOWED_CHANNELS, SLACK_UNFURL_LINKS (tri-state),
SLACK_FORCE_FLAT_REPLIES, SLACK_REACTIONS, SLACK_ACCOUNT_ID.
SLACK_APP_TOKEN and SLACK_BOT_TOKEN go to ~/.librefang/secrets.env.
Operator action required: an existing [channels.slack] block is no
longer recognised — re-declare as a [[sidecar_channels]] running
python3 -m librefang.sidecar.adapters.slack.
Verification:
- cargo check --workspace --lib: clean
- cargo check --workspace --features librefang-api/all-channels: clean
- cargo clippy --workspace --all-targets --features
librefang-api/all-channels -- -D warnings: zero warnings
- cargo test -p librefang-channels --features all-channels --lib:
766/766
- cargo test -p librefang-types --lib: 838/838
- cargo test -p librefang-kernel --lib: 1085/1085
- cargo test -p librefang-migrate --lib: 51/51
- cargo test -p librefang-api --lib --features all-channels: 692/692
- cargo test -p librefang-api --test channels_routes_test --features
all-channels: 38/38
- cargo test -p librefang-api --test config_routes_integration
--features all-channels: 31/31
- cd sdk/python && pytest: 488/488 (72 new for slack)
…nd mirror `resolve_channel_owner` only scanned the in-process `cfg.channels` (via `for_each_channel_field!`), so once a channel migrated to a sidecar (`[[sidecar_channels]]`) it returned None and the agent's outbound `channel_send` was no longer mirrored back into the channel-owning agent's session (#4824). This silently affected every migrated sidecar channel — telegram (#5241), discord (#5299), nextcloud, rocketchat, reddit, bluesky, mastodon, and now slack — not just the one this PR migrates. Consult `cfg.sidecar_channels[*].default_agent` too (the same field that seeds inbound routing via `AgentRouter.channel_defaults`), keyed by `channel_type` falling back to `name`, so the mirror resolves an owner uniformly across in-process and sidecar channels. First matching entry with a non-empty default_agent wins (deterministic Vec order). Surfaced by automated review on this PR. Extracted the matching into a pure `sidecar_default_agent` helper with unit coverage (`sidecar_default_agent_matches_by_channel_type_then_name`, `sidecar_default_agent_skips_entries_without_agent_and_is_first_match`).
) * feat(channels)!: migrate slack from in-process adapter to sidecar Replace the 1890-line in-process librefang-channels::slack adapter (Socket Mode WS + Web API) with an out-of-process Python sidecar at sdk/python/librefang/sidecar/adapters/slack.py (stdlib-only). Behaviour parity: - POST /api/auth.test startup probe to discover bot_user_id - POST /api/apps.connections.open to mint Socket Mode WSS URL - Envelope-id ACK loop for events_api / interactive - message + app_mention event handling with message_changed subtype extraction; all-other-subtype skip - Self-skip on bot_id presence OR user == bot_user_id - allowed_channels filter with DM exemption (channels starting with D) - Slash-command routing on /cmd args -> Command (text otherwise) - thread_ts captured as thread_id - DM detection via channel-id prefix; is_group = !channel.startswith("D") - block_actions interactive payload -> ButtonCallback content with action_id / trigger_id / block_action metadata - chat.postMessage send with optional thread_ts + unfurl_links + Block Kit blocks; 3000-char chunking matches SLACK_MSG_LIMIT - eyes reaction on receive flipped to white_check_mark on send-complete (opt-out via SLACK_REACTIONS=false) - force_flat_replies knob posts replies as top-level channel messages instead of threads - sender_user_id metadata key matches Rust SENDER_USER_ID_KEY parity - account_id injection for multi-bot routing One improvement over the Rust adapter: pending-reaction map is now bounded at MAX_PENDING_REACTIONS=2000 entries with oldest-eviction. The Rust adapter used an unbounded RwLock<HashMap> so a flood of inbound messages followed by a hang in the agent loop would grow the map without bound — a small but real memory-leak surface. Two regressions to call out (both match the telegram-#5241 and discord-#5299 precedents; flagged so operators aren't surprised): - **Live Slack workspace-role RBAC is gone.** ChannelRoleQuery is a Rust trait the sidecar process cannot implement, so role_query is None for Slack post-migration; the kernel's resolve_role_for_sender falls through to the default-deny Viewer branch and [channel_role_mapping.slack] is no longer consulted. Workaround: enumerate authorised operators under [users] with channel_bindings = { slack = ["<slack_user_id>"] } and an explicit role. The parse_users_info precedence parser is preserved in the sidecar so a future sidecar-protocol query/response pair can re-use it. - **Per-channel proxy override (#4795) is not wired through.** The sidecar honours HTTP_PROXY / HTTPS_PROXY / ALL_PROXY via stdlib but has no SLACK_PROXY_URL env var yet. Filed as a follow-up. Cascade removal: - src/slack.rs deleted; lib.rs mod decl, channels-allowlist.txt entry, cargo features in librefang-channels + librefang-api (incl. membership in all-channels / all-channels-no-email / core-channels / mini) removed - SlackConfig struct + Default impl + canonical deny_unknown_fields rustdoc anchor moved to WhatsAppConfig; ChannelsConfig.slack field + default removed; validation hook removed - channel_bridge.rs: import, spawn block, find_channel_info! arm, check_channel! arm, default-empty test assertion removed - routes/channels.rs: ChannelMeta entry + 5 match arms removed; live-test slack branch (POST to slack.com/api/chat.postMessage) retired with a comment explaining why; SIDECAR_CATALOG slack entry added; instance_helper_tests reanchored on matrix; test_channel tests reanchored on matrix - routes/config.rs ch!(slack) call removed; is_writable_config_path test fixtures switched to matrix - kernel channel_sender for_each_channel_field! macro entry + EXPECTED name-list entry removed - CLI: librefang setup slack wizard arm + channel list row removed; TUI ChannelDef removed - channels_routes_test: SlackConfig fixtures retargeted to MatrixConfig (bot_token_env -> access_token_env), test names / display_name assertions updated, "other rows must not flip" check uses mattermost instead - routes/skills.rs tests: channels.slack -> channels.matrix bulk rename across all TOML write/read tests; ad-hoc local Slack struct renamed to Matrix - types/config/mod.rs: test_slack_config_defaults + test_slack_config_unfurl_links_deserialization tests removed (covered by slack.rs unit tests which were also deleted); validate_missing_env_vars retargeted to WhatsAppConfig; test_one_or_many_single_toml_table + test_one_or_many_array_of_tables retargeted to MatrixConfig; well_formed_repeated_channel_table_still_parses_5130 drops the slack fixture (slack was the last working anchor — now only whatsapp + mattermost remain as deny_unknown_fields drift sentinels) - openclaw migrator: slack channel input now produces a SkippedItem with a sidecar-redirect reason rather than writing [channels.slack] (mirrors telegram + discord); test_policy_migration retargeted at mattermost (slack and discord both sidecar-skipped); test_full_migration legacy YAML fixture now includes mattermost.yaml so the "report.imported.iter().any(Channel)" assertion still has a witness; test_json5_channel_extraction adds mattermost and asserts all three of telegram/discord/slack are sidecar-skipped - openfang test fixtures reanchored on [channels.whatsapp] - librefang.toml.example + cli init_default_config.toml: [channels.slack] block replaced with commented [[sidecar_channels]] template - docs (en + zh): configuration/page.mdx, configuration/channels/page.mdx, integrations/channels/page.mdx, integrations/channels/core/page.mdx — [channels.slack] blocks replaced with [[sidecar_channels]] templates - ChannelType::Slack enum variant kept (still used by the bridge / router; same pattern as ChannelType::Telegram / ChannelType::Discord) New env-var knobs (read by the sidecar from [sidecar_channels.env]): SLACK_ALLOWED_CHANNELS, SLACK_UNFURL_LINKS (tri-state), SLACK_FORCE_FLAT_REPLIES, SLACK_REACTIONS, SLACK_ACCOUNT_ID. SLACK_APP_TOKEN and SLACK_BOT_TOKEN go to ~/.librefang/secrets.env. Operator action required: an existing [channels.slack] block is no longer recognised — re-declare as a [[sidecar_channels]] running python3 -m librefang.sidecar.adapters.slack. Verification: - cargo check --workspace --lib: clean - cargo check --workspace --features librefang-api/all-channels: clean - cargo clippy --workspace --all-targets --features librefang-api/all-channels -- -D warnings: zero warnings - cargo test -p librefang-channels --features all-channels --lib: 766/766 - cargo test -p librefang-types --lib: 838/838 - cargo test -p librefang-kernel --lib: 1085/1085 - cargo test -p librefang-migrate --lib: 51/51 - cargo test -p librefang-api --lib --features all-channels: 692/692 - cargo test -p librefang-api --test channels_routes_test --features all-channels: 38/38 - cargo test -p librefang-api --test config_routes_integration --features all-channels: 31/31 - cd sdk/python && pytest: 488/488 (72 new for slack) * docs(changelog): add missing attribution and prune stale history Two CHANGELOG fixes: 1. Add the missing `(@houko)` attribution to the Slack and Discord RBAC-regression bullets under [Unreleased] — the only thing failing PR 5302's "CHANGELOG Attribution" / "CHANGELOG Attribution (full [Unreleased])" CI checks. 2. Remove a stale, misplaced second `## [Unreleased]` block (wedged between the 2026.5.2 and 2026.4.28 dated releases, an orphan left by the #4240 dual-section merge) and the pre-CalVer 0.x version history (`[0.7.0]` … `[0.1.0]`). The duplicate [Unreleased] was tripping the pre-commit duplicate-section guard; the 0.x block is long-dead release history still recoverable from git. All 2026.x CalVer history is preserved. * fix(kernel): resolve channel owner for sidecar channels in channel_send mirror `resolve_channel_owner` only scanned the in-process `cfg.channels` (via `for_each_channel_field!`), so once a channel migrated to a sidecar (`[[sidecar_channels]]`) it returned None and the agent's outbound `channel_send` was no longer mirrored back into the channel-owning agent's session (#4824). This silently affected every migrated sidecar channel — telegram (#5241), discord (#5299), nextcloud, rocketchat, reddit, bluesky, mastodon, and now slack — not just the one this PR migrates. Consult `cfg.sidecar_channels[*].default_agent` too (the same field that seeds inbound routing via `AgentRouter.channel_defaults`), keyed by `channel_type` falling back to `name`, so the mirror resolves an owner uniformly across in-process and sidecar channels. First matching entry with a non-empty default_agent wins (deterministic Vec order). Surfaced by automated review on this PR. Extracted the matching into a pure `sidecar_default_agent` helper with unit coverage (`sidecar_default_agent_matches_by_channel_type_then_name`, `sidecar_default_agent_skips_entries_without_agent_and_is_first_match`). * fix(slack): thread top-level replies and finalize 👀 on the right message `parse_slack_event` set `thread_id = thread_ts` only, so a top-level message carried `thread_id = None`. Two consequences: 1. The bot's reply posted at the channel root instead of threading under the triggering message. `force_flat_replies` exists to opt *out* of threading, so threaded-by-default is the intended behaviour (and what the Rust adapter did). 2. `on_send` finalized the `👀` → `✅` reaction using the posting `thread_ts` (forced to None under SLACK_FORCE_FLAT_REPLIES, and None anyway for top-level), so it fell back to "first pending reaction in the channel" and flipped the wrong message under concurrency, leaving the real request stuck on `👀`. `thread_id` now falls back to the message's own `ts` (mirroring rocketchat / nextcloud's `thread_id = parent or own_id`), and `on_send` finalizes against the inbound thread id rather than the force-flattened posting `thread_ts`. The eyes is tracked by the message's own ts, so the top-level case (the dominant @-mention pattern) now finalizes exactly. In-thread replies stay best-effort — the `Send` protocol carries no inbound `message_id` to key on. Surfaced by automated review on PR #5302. Tests: test_parse_event_top_level_thread_id_falls_back_to_ts, test_on_send_force_flat_finalizes_correct_message. Full sdk/python suite: 548 passed.
) * feat(channels)!: migrate webex from in-process adapter to sidecar Replaces the 645-line in-process librefang-channels::webex adapter (Cisco Mercury WebSocket gateway for activity events + GET /messages/<id> REST follow-up + POST /messages publish) with the Python sidecar librefang.sidecar.adapters.webex. 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. Behaviour parity: - GET /people/me startup credential probe (bot id + display name) - Hard-coded Mercury WSS URL with Bearer auth on upgrade request (no device-registration handshake — the Rust adapter never did one either) - data.activity envelope parsing; verb=="post" filter; actor-id self-skip - GET /messages/<id> REST follow-up for the full message body - Room filter (empty allowlist = all rooms the bot is in) - Slash-command (/cmd args) → Command routing - roomType == "group" → is_group mapping - Multi-bot account_id metadata injection - 7439-char message chunking (WEBEX_MSG_LIMIT parity with the Rust MAX_MESSAGE_LEN) - Exponential reconnect backoff (1s → 60s) - ChannelType::Custom("webex") preserved as channel_type = "webex" on the sidecar entry — existing routing / channel_role_mapping keys that reference webex continue to resolve. Four improvements over the Rust adapter (file/line evidence in CHANGELOG): 1. parentId outbound threading wired. The Rust api_send_message (webex.rs:171-201) posted just {"roomId", "text"} — Webex's parentId field was never sent and chunked replies always landed at the room root regardless of inbound context; the inbound side dropped the message id entirely (thread_id: None at webex.rs:438). The sidecar surfaces the inbound id (or inbound parentId when inside a thread) as thread_id, and on_send posts parentId populated so replies thread correctly. Mirrors reddit / rocketchat / nextcloud / mastodon / bluesky. 2. 429 Retry-After honoured on both GET /messages/<id> fetch and POST /messages send. The Rust adapter had no 429 handling at either call site, so a server-side rate-limit either lost the inbound fetch or returned Err and dropped the outbound. The sidecar parses Retry-After (default 30 s fallback, floor 1 s, cap MAX_BACKOFF_SECS), sleeps, and retries once before logging-and-continuing on the second 429. Same shape as #5303. 3. Mercury activity-id dedupe. Mercury can re-deliver an activity.object.id on reconnect (Rust adapter had no dedupe, see unconditional emit at webex.rs:459). Bounded local set on activity.object.id with SEEN_MESSAGES_MAX=10000 / SEEN_MESSAGES_EVICT=5000. 4. Explicit HTTP timeouts on every urlopen call (urllib has no default timeout; a hung Webex API would hang the producer thread forever). Cascade removal: - src/webex.rs deleted; lib.rs mod decl, channels-allowlist.txt entry, cargo features in librefang-channels + librefang-api (incl. membership in all-channels / all-channels-no-email) removed - WebexConfig struct + Default impl + ChannelsConfig.webex field + matching Default entry removed; validation hook removed - channel_bridge.rs: import, spawn block, find_channel_info! arm, check_channel! arm, default-empty test assertion removed - routes/channels.rs: ChannelMeta entry + 4 match arms removed; SIDECAR_CATALOG webex entry added - routes/config.rs ch!(webex) call removed - kernel channel_sender for_each_channel_field! macro entry + EXPECTED name-list entry removed - CLI: TUI ChannelDef row removed, migrated-to-sidecar comment grown - README / AGENTS for librefang-channels updated to list webex among the migrated-to-sidecar adapters - Docs (EN + ZH): configuration/page.mdx, configuration/channels/page.mdx, integrations/channels/enterprise/page.mdx, configuration/security/page.mdx, architecture/page.mdx — [channels.webex] blocks replaced with [[sidecar_channels]] redirect blocks (fence language `bash` per Shiki); architecture wave table moves webex out of Wave 3 in-process and into the sidecar list New env-var knobs (read from [sidecar_channels.env]): - WEBEX_ALLOWED_ROOMS (comma-separated room IDs, empty = allow all) - WEBEX_ACCOUNT_ID (optional, multi-bot routing key) Secret in ~/.librefang/secrets.env: WEBEX_BOT_TOKEN. Operator action required: an existing [channels.webex] block is no longer recognised — re-declare as a [[sidecar_channels]] running python3 -m librefang.sidecar.adapters.webex with WEBEX_BOT_TOKEN (in ~/.librefang/secrets.env) and any of the optional knobs above (in [sidecar_channels.env]). Verification: - cargo check --workspace --lib: clean - cargo clippy --workspace --all-targets -- -D warnings: clean - cargo test -p librefang-types -p librefang-channels -p librefang-kernel --lib: 420 + 838 + 1087 passed - cargo test -p librefang-api --lib: 685 passed - cargo test -p librefang-api --test channels_routes_test --test sidecar_describe_test: 38 + 2 passed - cd sdk/python && pytest tests/test_webex_adapter.py: 78 passed * fix(channels/webex): apply review nits — display name, suppress flag, dead code, redundant filter Self-review pass on top of the migration commit; addresses 4 nits from the pre-merge review without changing the migration's core behaviour: 1. **personDisplayName drives user_name** (`webex.py:281-291, 326-330`). The migration commit preserved the Rust adapter's `webex.rs:431` behaviour of using `personEmail` unconditionally, which leaks emails into bot logs and dashboard UI. Now `parse_webex_message` prefers `personDisplayName` from the `/messages/<id>` body and falls back to `personEmail` when the field is absent (older Webex orgs / some service-account principals) or empty; `personEmail` stays in metadata for routing / audit. 3 new tests (`test_parse_prefers_person_display_name_for_user_name`, `…_falls_back_to_email_when_display_name_missing`, `…_falls_back_to_email_when_display_name_empty`). 2. **Explicit `suppress_error_responses = False`** on `WebexAdapter` with a precedent comment naming the discord / slack / twitch chat-room default vs. the mastodon / bluesky / reddit / nextcloud public-broadcast default. The migration left this implicit; an explicit class-level attr matches how `twitch.py:312` documents the choice so the next reader doesn't have to reconstruct the reasoning. 3. **`register_webhook` dead-code drop documented in CHANGELOG**. The Rust adapter carried a never-wired `register_webhook` helper at `webex.rs:137-168` (marked `#[allow(dead_code)]`) for an HTTP-webhook delivery alternative the channel-bridge never enabled; the sidecar drops it. The CHANGELOG now names this explicitly and points operators who want webhook delivery at the canonical generic `[[sidecar_channels]]` running `librefang.sidecar.adapters.webhook`. 4. **Skip redundant room filter inside `parse_webex_message`** when called from `_handle_envelope` (`webex.py:867-878`). `_handle_envelope` already filtered against `self.allowed_rooms` at line 855 before paying for the REST fetch; passing `allowed_rooms=[]` into the parser drops one redundant list-membership check per inbound message. The parser keeps the parameter for direct callers (tests; future code paths). Verification (run inside the worktree): * `cd sdk/python && python3 -m pytest tests/test_webex_adapter.py -q` → **81 passed** (was 78; +3 personDisplayName tests). No existing test modified. * `cargo check` / `clippy` not re-run — this commit is Python + CHANGELOG only, no Rust touched. Diff: +73 / -5 across `CHANGELOG.md`, `sdk/python/librefang/sidecar/adapters/webex.py`, `sdk/python/tests/test_webex_adapter.py`.
* feat(channels)!: migrate line from in-process adapter to sidecar Replaces the 881-line in-process librefang-channels::line adapter (axum webhook route at /channels/line/webhook on the shared API server, X-Line-Signature HMAC-SHA256 verification, POST /v2/bot/message/push for outbound) with the Python sidecar librefang.sidecar.adapters.line. 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. Behaviour parity: - GET /v2/bot/info startup credential probe (bot display name) - HTTP webhook server (BaseHTTPRequestHandler over ThreadingTCPServer) on LINE_WEBHOOK_PORT (default 9090) + LINE_WEBHOOK_PATH (default /webhook) - X-Line-Signature HMAC-SHA256 verification over the raw wire bytes (constant-time compare, line.rs:229-250 parity, incl. the wire-bytes-vs-JSON-roundtrip regression) - Only message events with type==text (other event types and non-text message types dropped, line.rs:256-273) - Source-type -> reply_to mapping (user -> userId, group -> groupId, room -> roomId; group/room -> is_group=true, line.rs:280-290) - Slash-command routing (/cmd args -> Command) - Metadata preservation: user_id / reply_to / reply_token / source_type - Multi-bot account_id injection via LINE_ACCOUNT_ID - 5000-char chunking (LINE_MSG_LIMIT parity with MAX_MESSAGE_LEN at line.rs:39) - Image-branch wire shape (originalContentUrl + previewImageUrl both set to the caller URL, caption sent as follow-up text push, line.rs:464-490) - ChannelType::Custom("line") preserved as channel_type = "line" on the sidecar entry so existing routing / channel_role_mapping keys continue to resolve Three improvements over the Rust adapter: 1. 429 Retry-After honoured on outbound. The Rust api_push_message (line.rs:148-184) had no 429 handling; throttled push returned Err and the chunked reply dropped on the floor. Sidecar parses Retry-After (default 30 s fallback, floor 1 s, cap MAX_BACKOFF_SECS), sleeps, retries once, then logs-and-continues on the second 429. Same shape as #5303. 2. Inbound dedupe on message.id. LINE redelivers webhook events when the operator's endpoint fails; the Rust handler at line.rs:413-427 emitted every event unconditionally, causing duplicate agent invocations on transient failures. Bounded local set on message.id with SEEN_MESSAGES_MAX=10000 / SEEN_MESSAGES_EVICT=5000 (matches reddit / rocketchat / nextcloud / webex policy). 3. Explicit HTTP timeouts on every urlopen call (urllib has no default timeout; a hung LINE API would otherwise hang the worker thread forever). Cascade removal: - src/line.rs deleted; lib.rs mod decl, channels-allowlist.txt entry, cargo features in librefang-channels + librefang-api (incl. membership in all-channels / all-channels-no-email) removed - LineConfig struct + Default impl + ChannelsConfig.line field + matching Default entry removed; validation hook removed - channel_bridge.rs: import, spawn block, find_channel_info! arm, check_channel! arm, default-empty test assertion removed - routes/channels.rs: ChannelMeta entry + 5 match arms removed (is_some / serialize / len / ser / is_channel_configured); webhook_route_suffix allowlist entry removed (sidecar runs its own webhook listener, not on the shared API port); SIDECAR_CATALOG line entry added - routes/config.rs ch!(line) call removed - kernel channel_sender for_each_channel_field! entry + EXPECTED name-list entry removed - CLI: TUI ChannelDef row removed, migrated-to-sidecar comment grown - README / AGENTS for librefang-channels updated; the mandatory- HMAC paragraph now lists Teams + DingTalk only (LINE moves to the sidecar but keeps the same X-Line-Signature contract inside the new process) - Docs (EN + ZH): configuration/page.mdx, configuration/channels/page.mdx, configuration/security/page.mdx, integrations/channels/integrations/page.mdx - [channels.line] blocks replaced with [[sidecar_channels]] redirect blocks; security env-var table flags both LINE_CHANNEL_SECRET and LINE_CHANNEL_ACCESS_TOKEN as sidecar-only; validation table drops the LINE row New env-var knobs (read from [sidecar_channels.env]): - LINE_WEBHOOK_PORT (default 9090) - LINE_WEBHOOK_PATH (default /webhook) - LINE_BIND_HOST (default 0.0.0.0) - LINE_ACCOUNT_ID (optional, multi-bot routing key) Secrets in ~/.librefang/secrets.env: LINE_CHANNEL_SECRET + LINE_CHANNEL_ACCESS_TOKEN. Operator action required (substantive): the sidecar runs its OWN HTTP webhook server - it is no longer mounted on the LibreFang API port - so the webhook URL registered at the LINE Developers Console must be updated to point at the sidecar host (typical pattern: an HTTPS reverse proxy in front of the sidecar's LINE_WEBHOOK_PORT). An existing [channels.line] block is no longer recognised - re-declare as a [[sidecar_channels]] running python3 -m librefang.sidecar.adapters.line with LINE_CHANNEL_SECRET + LINE_CHANNEL_ACCESS_TOKEN (in ~/.librefang/secrets.env) and any of the optional knobs above (in [sidecar_channels.env]). Verification: - cargo check --workspace --lib: clean - cargo clippy --workspace --all-targets -- -D warnings: clean - cargo fmt --check: clean - cargo test --lib -p librefang-types -p librefang-channels -p librefang-kernel: 420 + 838 + 1087 passed - cargo test --lib -p librefang-api: 685 passed - cargo test -p librefang-api --test channels_routes_test --test sidecar_describe_test: 38 + 2 passed - cargo xtask channel-policy: passed - cd sdk/python && pytest tests/test_line_adapter.py: 68 passed * docs(channels): correct line sidecar HTTP server class references The module docstring claimed `ThreadingHTTPServer` but the implementation uses `socketserver.ThreadingTCPServer` (intentional: skips `HTTPServer.server_bind()`'s `socket.getfqdn()` DNS lookup on startup). Align the three doc references with the actual class and spell out why we don't use `ThreadingHTTPServer` so the next reader doesn't "fix" it back.
PR review on #5321 caught the entry saying 'bridge stayed dead until daemon restarted — the regression the issue reports' immediately followed by 'The post-#5241 sidecar already wraps...'. The original bug WAS in the Rust adapter and WAS fixed by the #5241 sidecar migration; this PR is the observability + cap-alignment + regression coverage that should have shipped alongside #5241, not the bug fix. Rewrite is explicit on this point and also documents the deliberate deviation from the issue's 'ERROR only after N consecutive failures' suggestion: every sibling polling sidecar reserves log.error for fatal startup-config issues, none escalate during steady-state backoff; adding it here alone would diverge from the family.
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]>
… channels (#5931) * feat(channels): port command-policy and message coalescing to sidecar channels Ports two per-channel overrides that the in-process Telegram adapter had but the sidecar architecture dropped (#5241), so sidecar operators can replicate them again (closes #5841). disable_commands / allowed_commands / blocked_commands command policy is re-exposed on `[[sidecar_channels]]` as `command_policy` (allow / disable / allowlist / blocklist) plus `allowed_commands` / `blocked_commands`. A rejected command is forwarded to the agent as plain text, never answered with an "unknown command" error — matching the #2063 behaviour public-facing bots rely on. message_coalesce_window_ms re-exposes the inbound debounce window (#4441): messages from one sender arriving within the window are buffered and dispatched as a single batched context, so a user forwarding several messages in rapid succession produces one agent invocation instead of racing concurrent ones on the same session. Implementation reuses the gating the bridge already has. `SidecarAdapter` builds a `ChannelOverrides` from its `[[sidecar_channels]]` block and exposes it via a new `ChannelAdapter::channel_overrides()` default trait method; the bridge prefers that per-instance override over the kernel-level channel-type lookup, which cannot tell two sidecars sharing a `channel_type` (e.g. two Telegram bots) apart. `command_policy` maps onto the existing `is_command_allowed` boolean/list fields and `message_coalesce_window_ms` onto the debouncer's `message_debounce_ms`, so no Python sidecar change is needed — gating happens uniformly kernel-side for every adapter type. Tests: - librefang-types: serde defaults are backward-compatible (allow / coalescing-off); disable / allowlist / blocklist parse. - librefang-channels: `overrides_from_sidecar_config` maps each policy variant and the coalesce window correctly, returns None for a plain config, and the adapter surfaces the built overrides. Verified in the sanctioned Docker dev image (named volumes, host target untouched): - cargo test -p librefang-types -p librefang-channels --lib (1333 pass) - cargo clippy -p librefang-types -p librefang-channels --all-targets -- -D warnings (clean) - cargo fmt -p librefang-types -p librefang-channels -- --check (clean) The kernel-config golden fixture was updated by hand (the regen test lives in librefang-api, whose build hits a pre-existing aarch64-Linux `libc::SYS_getpgrp` break in librefang-runtime unrelated to this change; the golden assertion is currently `#[ignore]`d on main for separate pre-existing drift, so CI does not gate on it yet). * fix(channels): fail closed when allowlist policy has no commands `command_policy = "allowlist"` is an explicit default-deny intent, but with an empty (omitted or `[]`) `allowed_commands` the sidecar produced `ChannelOverrides { allowed_commands: [] }`. The bridge's `is_command_allowed` treats an empty allowlist as "no allowlist configured" and falls through to the empty-blocklist branch, which allows everything — so selecting allowlist silently became fail-open, the exact public-bot scenario the policy is meant to lock down. Map an empty allowlist onto `disable_commands = true` (the highest-precedence deny `is_command_allowed` honours) so it denies all commands. A non-empty allowlist still allows only its listed commands; `disable` and `blocklist` are unchanged. An empty blocklist keeps its intuitive "block nothing, allow all" meaning but now logs a WARN since it usually signals a forgotten list. Tests: - sidecar unit test asserts allowlist + empty list yields `disable_commands == true`. - new bridge integration test builds the override through the real `SidecarAdapter` and drives `/agent coder` through the live dispatch path, asserting the command is gated (forwarded to the agent as the plain text `/agent coder`, router unchanged) rather than honoured. Verified via the repo Docker dev image: `cargo test -p librefang-channels` (479 lib + 28 integration + 4 conformance + 3 doctests, all pass) and `cargo clippy -p librefang-channels --all-targets -- -D warnings` clean. --------- Co-authored-by: Evan <[email protected]>
Follow-on to #5232 (telegram full sidecar parity, merged). Now that the
Python sidecar adapter is at full functional parity with the in-process
Rust adapter, this PR removes the in-process
crates/librefang-channels/ src/telegram.rsand its full cascade — the second half of thetelegram→sidecar migration the user asked to land in one go (#5232
merged before this could be appended to it, so it is necessarily its
own PR; the diff here is only the removal cascade).
Same pattern as the ntfy #5224 migration, scaled to telegram's deeper
integration.
BREAKING
[channels.telegram]is no longer recognised. Re-declare telegram as a[[sidecar_channels]]adapter:Removed (full cascade)
src/telegram.rs(deleted, 6231 lines),lib.rsmod,Cargo.tomlchannel-telegramfeature +all-channels,channels-allowlist.txtentry (socargo xtask channel-policypermanently blocks any in-process telegram reintroduction),
commands/mod.rsstale doc ref.TelegramConfigstruct/Default/impl, thechannels.telegramfield + default, validation hook, the orphaneddefault_telegram_long_poll_timeout_secs. The shareddeny_unknown_fields/ proxy doc anchor moved fromTelegramConfigto
DiscordConfig(it was the canonical cross-reference for ~7 otherchannel structs).
channel-telegramfeature + membership incore-channels / all-channels / all-channels-no-email / mini;
channel_bridgeuse + Duration cfg + coalesce-window arm +check_channel + spawn block + default-empty test;
routes/channels.rsChannelMeta + the 5 match arms;
routes/config.rsch!(telegram).channel_senderfor_each_channel_field!macro + name list;
config_reloadhot-reload test.channel setupwizard arm + picker entry + health list, Cargo.toml/help examples.
[channels.telegram](the kernel would reject it); the bot token isstill migrated to
secrets.envand the channel is reported asskipped with guidance to declare a
[[sidecar_channels]]block.Deliberately kept (shared core / different feature)
ChannelType::Telegram, the shared bot-command registry, the bridgechannel-type→string map, and the
[channel_role_mapping.telegram]RBAC mapping — the sidecar telegram still flows through all of these.
Verification
cargo check --workspace --lib: cleancargo xtask channel-policy: Passed (telegram delisted; theallowlist only shrinks)
cargo test -p librefang-types/-p librefang-channels/-p librefang-migrate: all pass (tests that used telegram as thearbitrary example channel migrated to discord/slack; kernel-config
golden regenerated, no residual
TelegramConfig)cargo test -p librefang-api:routes::channels+channels_routes_test+config_schema_goldenpasscargo clippy(touched crates,--lib): cleanOut-of-scope, verified unrelated
Two failures have zero telegram coupling, sit in files unmodified
by this branch, and exercise code paths orthogonal to the cascade
(evidence:
git log origin/main..HEAD -- <file>empty; no telegram refsin-file;
--libclean): thehooks_agent_dispatches_to_default_agentagent-dispatch e2e and the
kernel/tests.rs:8972 compact_agent_session_with_idarity drift. Same class #5224documented as pre-existing-unrelated.