Discord: channel list and presence without the main bot (#225 S5, fixes #251) #262

Merged
logan merged 1 commits from feat/225-s5-channels into main 2026-10-05 03:01:08 -04:00
Owner

Slice S5 of #225 (plan comment 3108), built on main e54191a (S1-S4 merged, plus #226/#227 multi-stream). Do not merge from here — the main session merges after review.

Fixes #251

What changed

Which servers an org sees (discord_web._org_guilds)

  • Linked to the org: shown unless the DRB server bot said it left and no usable radio bot is in the server. "Usable" means a valid pool bot or the org's own bot, with the server in its guild_ids. Unknown membership (guild_ids: None) does not count.
  • Acting for the org only through the unlinked-server fallback: still needs the server bot, same as before. A radio bot's membership never discovers a server, and REST never creates a discord_guilds doc (tested).

Where the voice-channel list comes from

  • New discord_rest.guild_directory() and directory_from_snapshot() (appended only). They build the same {name, voice_channels:[{channel_id, name, category, position}]} the server bot pushes, from the radio bot's cached /channels snapshot. That is the snapshot the permission check already reads, so listing channels costs no extra Discord request. It tries up to 3 member bots, best first.
  • join_options:
    • No list stored: read inline, within discord_perm_check_budget_seconds.
    • List older than discord_directory_refresh_seconds (300 s): refreshed in the background, deduplicated per server.
    • Writes voice_channels, guild_name, directory_at, directory_source: "radio_bot", radio_bot_present, radio_bot_checked_at. On not-member it writes only radio_bot_present: false and keeps the list.
    • Never writes org_id or bot_present. Never writes onto a server that was unlinked or moved meanwhile (re-read before doc_update, which never creates a doc).
  • The server bot's list still wins while it is fresh (back-compat). If it is more than 45 min old (three missed 15-min resyncs, so the bot is presumably down), a radio bot's view takes over.
  • PUT …/directory now stamps directory_source: "main_bot" and calls discord_rest.invalidate(guild_id=…).
  • join_options guilds also carry directory_source and main_bot_present. These are added to the TS type as optional fields; no UI change.
  • start_node_stream checks the channel against the merged list. When the channel is missing and the list isn't the server bot's fresh one, it reads the list once more (cached snapshot, bounded) before returning 400.
  • discord_web module docstring updated: C2 now does call Discord on a picker view, with a radio bot's token, for a server already linked to the caller's org (plan finding 5).

#251 (fits naturally, so fixed here; option 2 from the issue)

  • start_stream(interactive=True):
    • A bot whose cached verdict is denied and is ≥10 s old (RECHECK_DENIED_AFTER_SECONDS) is re-read once with refresh=True before the start is refused.
    • Passed by POST /discord/streams (/join and the admin page), POST /discord/nodes/{id}/streams and nodes.py discord_join.
    • The reconciler never passes it, so there is no REST loop (plan finding 4).
    • Spam is bounded: at most one refresh per bot+server per 10 s.

Not done / for review

  • Spike not run: does /guilds/{g}/channels list channels the bot can't View? There is no test server or token here. The code is correct either way. The list keeps every type-2 channel, the same as the server bot's gateway list, and the permission badge already says "can't View Channel". If REST hides them, they simply don't show. No copy change depends on it.
  • Picker copy that still says "The DRB bot has not reported this server's channels" is S8's (plan: S8 removes main-bot copy). With this PR, a radio-bot-only server reaches that text only when Discord doesn't answer within the budget.
  • The admin Add form needs no change. Its GET /discord/guilds/{id}/voice-check already falls back to the snapshot's channels, and now also sees the stored REST list.

Tests

  • New tests/test_discord_directory.py (27 tests):
    • shape and order
    • not-member and unknown
    • fallback never discovered and never read over REST
    • no doc created
    • main bot wins while fresh, a radio bot takes over when it is stale
    • the background refresh
    • unlinked-meanwhile → no write
    • the budget is honoured
    • start validates against the REST list
    • #251: interactive re-check on all three routes, a fresh denial is not re-asked, and the reconciler never re-checks
  • Mutation check: disabling the re-check or the fallback guard fails 4 of them.
  • test_discord_web_picker::test_directory_bot_removed_hides_guild became ..._without_a_radio_bot. Its pool bot is in the server, which is now meant to keep the server visible.
~/venvs/drb-5c/bin/python -m pytest -q   (drb-c2-core)
837 passed, 4 warnings in 10.93s
tsc --noEmit (drb-frontend)  -> exit 0

🤖 Generated with Claude Code

Slice S5 of #225 (plan comment 3108), built on main e54191a (S1-S4 merged, plus #226/#227 multi-stream). **Do not merge from here — the main session merges after review.** Fixes #251 ## What changed **Which servers an org sees (`discord_web._org_guilds`)** - Linked to the org: shown unless the DRB server bot said it left **and** no usable radio bot is in the server. "Usable" means a valid pool bot or the org's own bot, with the server in its `guild_ids`. Unknown membership (`guild_ids: None`) does not count. - Acting for the org only through the unlinked-server fallback: still needs the server bot, same as before. A radio bot's membership never discovers a server, and REST never creates a `discord_guilds` doc (tested). **Where the voice-channel list comes from** - New `discord_rest.guild_directory()` and `directory_from_snapshot()` (appended only). They build the same `{name, voice_channels:[{channel_id, name, category, position}]}` the server bot pushes, from the radio bot's cached `/channels` snapshot. That is the snapshot the permission check already reads, so listing channels costs no extra Discord request. It tries up to 3 member bots, best first. - `join_options`: - No list stored: read inline, within `discord_perm_check_budget_seconds`. - List older than `discord_directory_refresh_seconds` (300 s): refreshed in the background, deduplicated per server. - Writes `voice_channels`, `guild_name`, `directory_at`, `directory_source: "radio_bot"`, `radio_bot_present`, `radio_bot_checked_at`. On not-member it writes only `radio_bot_present: false` and keeps the list. - Never writes `org_id` or `bot_present`. Never writes onto a server that was unlinked or moved meanwhile (re-read before `doc_update`, which never creates a doc). - The server bot's list still wins while it is fresh (back-compat). If it is more than 45 min old (three missed 15-min resyncs, so the bot is presumably down), a radio bot's view takes over. - `PUT …/directory` now stamps `directory_source: "main_bot"` and calls `discord_rest.invalidate(guild_id=…)`. - `join_options` guilds also carry `directory_source` and `main_bot_present`. These are added to the TS type as optional fields; no UI change. - `start_node_stream` checks the channel against the merged list. When the channel is missing and the list isn't the server bot's fresh one, it reads the list once more (cached snapshot, bounded) before returning 400. - `discord_web` module docstring updated: C2 now does call Discord on a picker view, with a radio bot's token, for a server already linked to the caller's org (plan finding 5). **#251 (fits naturally, so fixed here; option 2 from the issue)** - `start_stream(interactive=True)`: - A bot whose cached verdict is `denied` and is ≥10 s old (`RECHECK_DENIED_AFTER_SECONDS`) is re-read once with `refresh=True` before the start is refused. - Passed by `POST /discord/streams` (/join and the admin page), `POST /discord/nodes/{id}/streams` and `nodes.py` `discord_join`. - The reconciler never passes it, so there is no REST loop (plan finding 4). - Spam is bounded: at most one refresh per bot+server per 10 s. ## Not done / for review - **Spike not run:** does `/guilds/{g}/channels` list channels the bot can't View? There is no test server or token here. The code is correct either way. The list keeps every type-2 channel, the same as the server bot's gateway list, and the permission badge already says "can't View Channel". If REST hides them, they simply don't show. No copy change depends on it. - Picker copy that still says "The DRB bot has not reported this server's channels" is S8's (plan: S8 removes main-bot copy). With this PR, a radio-bot-only server reaches that text only when Discord doesn't answer within the budget. - The admin Add form needs no change. Its `GET /discord/guilds/{id}/voice-check` already falls back to the snapshot's channels, and now also sees the stored REST list. ## Tests - New `tests/test_discord_directory.py` (27 tests): - shape and order - not-member and unknown - fallback never discovered and never read over REST - no doc created - main bot wins while fresh, a radio bot takes over when it is stale - the background refresh - unlinked-meanwhile → no write - the budget is honoured - start validates against the REST list - #251: interactive re-check on all three routes, a fresh denial is not re-asked, and the reconciler never re-checks - Mutation check: disabling the re-check or the fallback guard fails 4 of them. - `test_discord_web_picker::test_directory_bot_removed_hides_guild` became `..._without_a_radio_bot`. Its pool bot is in the server, which is now meant to keep the server visible. ``` ~/venvs/drb-5c/bin/python -m pytest -q (drb-c2-core) 837 passed, 4 warnings in 10.93s tsc --noEmit (drb-frontend) -> exit 0 ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan added 1 commit 2026-10-05 02:54:40 -04:00
A server linked to an org now shows in the node picker while a radio bot the
org may use is in it, even when the DRB server bot is absent or left. Its
voice-channel list is read from that radio bot's cached REST snapshot
(discord_rest.guild_directory, the same snapshot the permission check reads,
so no extra Discord request): inline when no list is stored, in the
background when the list is older than discord_directory_refresh_seconds.
The server bot's own fresh list still wins (back-compat) and its directory
push now drops the server's cached snapshots.

Guards: a server acting only through the unlinked-server fallback org still
needs the server bot; REST never creates a discord_guilds doc, never writes
org_id or bot_present, and never writes onto a server unlinked meanwhile.

Also fixes server-26#251: an interactive start (/join, admin page, web
picker, node discord_join) re-reads a cached "denied" snapshot older than
10 s once before refusing, so "fix it in Discord, then try again" works on
the next try. The reconciler never re-reads (no REST loop).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
logan merged commit c30b2ca33e into main 2026-10-05 03:01:08 -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#262