Discord: log node-reported stream state; remove a radio channel or a server (#213, #221) #224

Merged
logan merged 2 commits from feat/discord-stream-logging-remove into main 2026-10-04 22:55:29 -04:00
Owner

Follow-up to #213 / #221 on top of live fabdf58 (#216/#218/#219/#220).

1. Server-side logging of node-reported stream state

discord_streams.handle_actual stored each node report in Firestore but never logged it. It now logs every state transition (state, error text or reason code changed; never the ~60s heartbeat) with stream, node, guild, channel, token ids and the node's error text:

  • info: Discord stream pending -> connected (stream=... node=... guild=... channel=... token=...) (also connecting / stopped / paused_locally)
  • warning: Discord stream connected -> error [connect_failed]: <node's text> (...)
  • warning, login failures: Radio bot <token_id> failed to log in on node <node>: <node's reason> (...). C2 does receive this: the edge reports state=error, code=login_failed with the discord.py exception text.
  • C2-side stop_stream now logs node/guild/channel ids too.

The node's reason code (login_failed, connect_failed, not_in_guild, bad_channel, no_audio) was dropped before; it is now stored as actual.code. The error text was already shown on Settings > Discord > Radio channels and on the node page list; both now also show [code].

2. Remove a radio channel

streams.delete_stream: stop first, through desired state. The token is detached (-> releasing) and the node's list is republished without the channel. Then the doc is deleted, in a transaction that refuses with 409 if the channel was restarted in between (deleting then would strand a lease). Token release follows #216 exactly: the token is freed only when the node acks a list without it and has logged the bot out.

  • DELETE /discord/streams/{id}: platform admin (Settings > Discord > Radio channels, Remove + confirm).
  • DELETE /discord/nodes/{node}/streams/{id}: admin or the org owner. The stream must be on that node and in that org. Operators can still stop but not remove. Node page list, Remove + confirm; join-options gains can_remove.
  • Both write discord.stream.delete to audit_log.

3. Remove a server

DELETE /discord/guilds/{id} (admin, Servers tab, inline confirm):

  • Every radio channel in the server is deleted the same way (stop first, then delete).
  • discord_guilds/{id} is not deleted, because a missing doc would fall back to DISCORD_UNLINKED_GUILD_ORG_ID. It gets #218's unlink (org_id None + unlinked_at, so no fallback) plus removed_at/removed_by, and the admin page hides it.
  • /drb link redeem and the admin link both clear removed_at, so linking it again brings it back.
  • Audited as discord.guild.remove.
  • Optional checkbox "also make the radio bots leave": C2 calls Discord REST DELETE /users/@me/guilds/{id} with each bot's own token. This covers platform-pool bots plus that server's org's own BYO bots, never another org's. Bots whose cached membership says they are not in the server are skipped. Results are shown per bot. The DRB server bot is not in the pool; the UI says it stays in the Discord server until someone kicks it.

Tests

New tests/test_discord_remove_and_logging.py (15 tests):

  • transitions logged once and heartbeats not logged; login-failure line; spoofed reports from other nodes neither stored nor logged
  • stop is published while the doc still exists, then the doc is deleted
  • lease stays releasing until the ack, then is freed; no republish for an already-stopped stream
  • 409 when the stream is restarted in between
  • admin route 403/404/422 + audit; node route owner/admin allowed, operator 403, other org 404, other node 404, + audit; can_remove
  • remove server: streams deleted, no fallback, leases freed on ack, hidden from overview, re-link (admin and code) restores it, leave-bots scoping + partial failure + audit

tests/fake_firestore.py learned transactional delete.

  • c2-core: 662 passed, 4 warnings
  • frontend: npx tsc --noEmit exit 0 (portable Node v20.20.2)

Not in this PR

  • The live symptom (radio bot online with the right presence but never in the voice channel) is not fixed here. The edge only sets presence after channel.connect() returned / vc.is_connected(), so the node believes it joined. This PR's logs will show what the node reports. Filed as node-26#37.

Refs #213, #221

🤖 Generated with Claude Code

Follow-up to #213 / #221 on top of live `fabdf58` (#216/#218/#219/#220). ## 1. Server-side logging of node-reported stream state `discord_streams.handle_actual` stored each node report in Firestore but never logged it. It now logs every state **transition** (state, error text or reason code changed; never the ~60s heartbeat) with `stream`, `node`, `guild`, `channel`, `token` ids and the node's error text: - info: `Discord stream pending -> connected (stream=... node=... guild=... channel=... token=...)` (also connecting / stopped / paused_locally) - warning: `Discord stream connected -> error [connect_failed]: <node's text> (...)` - warning, login failures: `Radio bot <token_id> failed to log in on node <node>: <node's reason> (...)`. C2 does receive this: the edge reports `state=error, code=login_failed` with the discord.py exception text. - C2-side `stop_stream` now logs node/guild/channel ids too. The node's reason code (`login_failed`, `connect_failed`, `not_in_guild`, `bad_channel`, `no_audio`) was dropped before; it is now stored as `actual.code`. The error text was already shown on Settings > Discord > Radio channels and on the node page list; both now also show `[code]`. ## 2. Remove a radio channel `streams.delete_stream`: **stop first, through desired state.** The token is detached (-> `releasing`) and the node's list is republished without the channel. **Then** the doc is deleted, in a transaction that refuses with 409 if the channel was restarted in between (deleting then would strand a lease). Token release follows #216 exactly: the token is freed only when the node acks a list without it and has logged the bot out. - `DELETE /discord/streams/{id}`: platform admin (Settings > Discord > Radio channels, Remove + confirm). - `DELETE /discord/nodes/{node}/streams/{id}`: admin or the org **owner**. The stream must be on that node and in that org. Operators can still stop but not remove. Node page list, Remove + confirm; `join-options` gains `can_remove`. - Both write `discord.stream.delete` to audit_log. ## 3. Remove a server `DELETE /discord/guilds/{id}` (admin, Servers tab, inline confirm): - Every radio channel in the server is deleted the same way (stop first, then delete). - `discord_guilds/{id}` is **not** deleted, because a missing doc would fall back to `DISCORD_UNLINKED_GUILD_ORG_ID`. It gets #218's unlink (`org_id` None + `unlinked_at`, so no fallback) plus `removed_at`/`removed_by`, and the admin page hides it. - `/drb link` redeem and the admin link both clear `removed_at`, so linking it again brings it back. - Audited as `discord.guild.remove`. - Optional checkbox **"also make the radio bots leave"**: C2 calls Discord REST `DELETE /users/@me/guilds/{id}` with each bot's own token. This covers platform-pool bots plus that server's org's own BYO bots, never another org's. Bots whose cached membership says they are not in the server are skipped. Results are shown per bot. The DRB **server** bot is not in the pool; the UI says it stays in the Discord server until someone kicks it. ## Tests New `tests/test_discord_remove_and_logging.py` (15 tests): - transitions logged once and heartbeats not logged; login-failure line; spoofed reports from other nodes neither stored nor logged - stop is published while the doc still exists, then the doc is deleted - lease stays releasing until the ack, then is freed; no republish for an already-stopped stream - 409 when the stream is restarted in between - admin route 403/404/422 + audit; node route owner/admin allowed, operator 403, other org 404, other node 404, + audit; `can_remove` - remove server: streams deleted, no fallback, leases freed on ack, hidden from overview, re-link (admin and code) restores it, leave-bots scoping + partial failure + audit `tests/fake_firestore.py` learned transactional `delete`. - c2-core: `662 passed, 4 warnings` - frontend: `npx tsc --noEmit` exit 0 (portable Node v20.20.2) ## Not in this PR - The live symptom (radio bot online with the right presence but never in the voice channel) is not fixed here. The edge only sets presence after `channel.connect()` returned / `vc.is_connected()`, so the node believes it joined. This PR's logs will show what the node reports. Filed as node-26#37. Refs #213, #221 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan added 1 commit 2026-10-04 22:42:11 -04:00
- handle_actual logs every node-reported state TRANSITION (not heartbeats)
  with stream/node/guild/channel/token ids and the node's error text: info
  for connected/connecting/stopped, warning for error, and a dedicated
  "radio bot X failed to log in on node N: <reason>" line for login_failed.
  The node's reason code is now stored in actual.code and shown next to the
  error on Settings > Discord and the node page.
- Remove a radio channel permanently: streams.delete_stream stops it through
  desired state first (node republished without it, token -> releasing per
  #216, freed on the node's ack), then deletes the doc in a transaction that
  refuses if the channel was restarted in between. DELETE
  /discord/streams/{id} (platform admin) and DELETE
  /discord/nodes/{node}/streams/{id} (admin or org owner, same org + node
  only; operators can still stop, not remove). Audited.
- Remove a server: DELETE /discord/guilds/{id} (admin) deletes every radio
  channel in it the same way, then tombstones discord_guilds (org_id None +
  unlinked_at, #218 semantics, plus removed_at) so it never falls back to
  DISCORD_UNLINKED_GUILD_ORG_ID; the admin page hides it, and /drb link or the
  admin link brings it back. Optional leave_radio_bots makes pool bots and the
  server's org's own BYO bots leave via Discord REST; the DRB server bot stays
  until kicked, and the UI says so. Audited.
- UI: Remove (with confirm) on Radio channels, Servers and the node page list.

Refs #213, #221

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
logan added 1 commit 2026-10-04 22:48:47 -04:00
DELETE /discord/guilds/{id} deletes channels one by one. If a channel was
restarted mid-removal, delete_stream raised "restarted" and the route
returned 409 with no audit entry, though the channels before it were
already deleted for good. The error now carries the channels already
removed and the route writes discord.guild.remove with outcome
"incomplete" before returning the 409. The server stays linked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Review: pushed 25553fb. DELETE /discord/guilds/{id} returned 409 with no audit entry when a channel was restarted mid-removal, though earlier channels were already deleted. It now audits discord.guild.remove with outcome: incomplete and the channels removed, then returns the 409 (server stays linked). New test included; c2-core 663 passed, frontend tsc exit 0.

Note: a node-reported stopped is never logged by handle_actual (it skips streams whose desired is off); the stop itself is logged by stop_stream.

Review: pushed 25553fb. `DELETE /discord/guilds/{id}` returned 409 with no audit entry when a channel was restarted mid-removal, though earlier channels were already deleted. It now audits `discord.guild.remove` with `outcome: incomplete` and the channels removed, then returns the 409 (server stays linked). New test included; c2-core `663 passed`, frontend `tsc` exit 0. Note: a node-reported `stopped` is never logged by `handle_actual` (it skips streams whose desired is off); the stop itself is logged by `stop_stream`.
logan merged commit 16a609e35b into main 2026-10-04 22:55:29 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#224