Discord picker: per-bot permission status + owner's preferred bot (#225 S4) — merge after S3 #250

Merged
logan merged 2 commits from feat/225-s4-picker-ui into main 2026-10-05 02:38:56 -04:00
Owner

Must merge after S3 (#249). This branch is stacked on feat/225-s3-perm-check (commit 2c2cf9d), so until #249 merges the diff below also shows S3. S4's own change is commit 1ee1560.

Slice S4 of #225 (picker UI, MVP frontend), built against S3's actual routers/discord_checks.py contract and its widened PUT /tokens/{id}/prefer/{system}.

What changed

Node page picker (NodeDiscordRadio.tsx) and admin Add form (discord/StreamsPanel.tsx)

  • A badge on each voice channel: Ready / Needs fix · can't Speak / Couldn't check / Bots busy / No radio bot / Not a voice channel / Limit N. The badge comes from S3's state; the bot rows are only a fallback.
  • The selected channel shows c2-core's sentence plus its fix, for example: "DRB Radio 2 can't Speak in #dispatch: denied by #dispatch's override for @Muted. In Discord: Edit Channel → Permissions → add DRB Radio 2 → allow Speak, then try again." Details per bot expand below it (busy here, on another node, not a member, token rejected, couldn't check).
  • Footer: "Permissions checked 40s ago · Re-check". Re-check sends refresh=1. When the server rate-limits it (refreshed:false), the footer says so. Invite links appear when no bot fits.
  • Start is disabled only on a definite refusal (needs_fix, bad_channel). Unknown fails open. A 404 from the check route (not deployed yet) shows no badges.
  • A channel this system already plays in is judged by the bot that carries it. S3 counts that bot as busy, so without this, Update/move could have been blocked by the other bots.
  • Admin Add form: bots that are definitely denied for the typed channel are disabled in the bot list, with the reason.
  • 409 refusals (missing_permissions, preferred_busy, preferred_other_node, bad_channel) are shown verbatim and multi-line (S3's text uses \n). A missing_permissions refusal reloads the badges.
  • New shared file components/discord/VoiceCheckBits.tsx. Types and helpers are in lib/discordPicker.ts. lib/c2api.ts gets getNodeVoiceCheck, getGuildVoiceCheck and getOrgPreferredBots.

Org owner: preferred bot per system (owner decision Q2)

  • Settings › Organization gets a "Radio bot per system" card. It lists the owner's own systems and their own bring-your-own bots, and sets or clears the preferred bot through S3's PUT /tokens/{id}/prefer/{system} (_none clears).
  • Owners can't reach /systems: it needs a platform role of admin or operator, and a self-serve owner is a viewer. That is why the card lives on Settings.
  • New GET /tokens/org/preferred (owner or admin; admin may pass ?org_id=). It returns only the caller's own org's systems and its BYO bots: no pool bots, no token strings. A pool bot that an admin chose for one of the org's systems is flagged but not named.

Fix found while wiring Q2: start_stream reads systems through doc_get_cached, which has a 5-minute TTL. A new preference therefore did not apply to starts for up to 5 minutes. The prefer route now drops the cached entry for every system it rewrites.

Tests

  • c2-core: ~/venvs/drb-5c/bin/python -m pytest -q → 806 passed, 4 warnings
  • New tests: tests/test_tokens_org_preferred.py (6): scoping, member refused, admin without an org, ?org_id ignored for non-admins, pool pick flagged and not named, and cache drop on set / move / clear.
  • Frontend: tsc --noEmit → exit 0. The voice-check helpers were also run in Node on a sample S3 payload: verdicts, blocksStart, sentences and parseC2Error behaved as expected.
  • Not run: drb-qa-review. This subagent has no agent tool, so the main session should run it before merging.

Smoke test after deploy (plan rollout)

  1. Deny Speak to @everyone on one channel in a test server.
  2. The picker shows "Needs fix · can't Speak" and Start is disabled.
  3. Fix it in Discord, press Re-check (wait more than 10 s after the last check), and the badge shows Ready.

🤖 Generated with Claude Code

**Must merge after S3 (#249).** This branch is stacked on `feat/225-s3-perm-check` (commit 2c2cf9d), so until #249 merges the diff below also shows S3. S4's own change is commit 1ee1560. Slice S4 of #225 (picker UI, MVP frontend), built against S3's actual `routers/discord_checks.py` contract and its widened `PUT /tokens/{id}/prefer/{system}`. ## What changed **Node page picker (`NodeDiscordRadio.tsx`) and admin Add form (`discord/StreamsPanel.tsx`)** - A badge on each voice channel: Ready / Needs fix · can't Speak / Couldn't check / Bots busy / No radio bot / Not a voice channel / Limit N. The badge comes from S3's `state`; the bot rows are only a fallback. - The selected channel shows c2-core's sentence plus its fix, for example: "DRB Radio 2 can't Speak in #dispatch: denied by #dispatch's override for @Muted. In Discord: Edit Channel → Permissions → add DRB Radio 2 → allow Speak, then try again." Details per bot expand below it (busy here, on another node, not a member, token rejected, couldn't check). - Footer: "Permissions checked 40s ago · Re-check". Re-check sends `refresh=1`. When the server rate-limits it (`refreshed:false`), the footer says so. Invite links appear when no bot fits. - **Start is disabled only on a definite refusal** (`needs_fix`, `bad_channel`). Unknown fails open. A 404 from the check route (not deployed yet) shows no badges. - A channel this system already plays in is judged by the bot that carries it. S3 counts that bot as busy, so without this, Update/move could have been blocked by the other bots. - Admin Add form: bots that are definitely denied for the typed channel are disabled in the bot list, with the reason. - 409 refusals (`missing_permissions`, `preferred_busy`, `preferred_other_node`, `bad_channel`) are shown verbatim and multi-line (S3's text uses `\n`). A `missing_permissions` refusal reloads the badges. - New shared file `components/discord/VoiceCheckBits.tsx`. Types and helpers are in `lib/discordPicker.ts`. `lib/c2api.ts` gets `getNodeVoiceCheck`, `getGuildVoiceCheck` and `getOrgPreferredBots`. **Org owner: preferred bot per system (owner decision Q2)** - Settings › Organization gets a "Radio bot per system" card. It lists the owner's own systems and their own bring-your-own bots, and sets or clears the preferred bot through S3's `PUT /tokens/{id}/prefer/{system}` (`_none` clears). - Owners can't reach `/systems`: it needs a platform role of admin or operator, and a self-serve owner is a viewer. That is why the card lives on Settings. - New `GET /tokens/org/preferred` (owner or admin; admin may pass `?org_id=`). It returns only the caller's own org's systems and its BYO bots: no pool bots, no token strings. A pool bot that an admin chose for one of the org's systems is flagged but not named. **Fix found while wiring Q2:** `start_stream` reads `systems` through `doc_get_cached`, which has a 5-minute TTL. A new preference therefore did not apply to starts for up to 5 minutes. The prefer route now drops the cached entry for every system it rewrites. ## Tests - c2-core: `~/venvs/drb-5c/bin/python -m pytest -q` → `806 passed, 4 warnings` - New tests: `tests/test_tokens_org_preferred.py` (6): scoping, member refused, admin without an org, `?org_id` ignored for non-admins, pool pick flagged and not named, and cache drop on set / move / clear. - Frontend: `tsc --noEmit` → exit 0. The voice-check helpers were also run in Node on a sample S3 payload: verdicts, `blocksStart`, sentences and `parseC2Error` behaved as expected. - Not run: `drb-qa-review`. This subagent has no agent tool, so the main session should run it before merging. ## Smoke test after deploy (plan rollout) 1. Deny Speak to @everyone on one channel in a test server. 2. The picker shows "Needs fix · can't Speak" and Start is disabled. 3. Fix it in Discord, press Re-check (wait more than 10 s after the last check), and the badge shows Ready. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan added 2 commits 2026-10-05 02:29:56 -04:00
start_stream now asks Discord (cached REST snapshots, S2) whether each bot
it could pick may View, Connect and Speak in the voice channel, before the
lease. Covers all four entry points: bot POST /discord/streams, web picker,
nodes discord_join, and the reconciler's re-place.

- pick_token: denied bots skipped; confirmed-ok sorts before unknown;
  unknown is allowed (fail open); all denied -> missing_permissions naming
  each bot, missing permission, trace reason and fix. The reuse branch
  falls through when its token is denied.
- Bounded: discord_perm_check_concurrency at once, within
  discord_perm_check_budget_seconds; unanswered = unknown.
- Non-voice channel -> 400 bad_channel (not missing_permissions).
- systems/{id}.preferred_token_id is now read (plan finding 1), as a hint.
- Pool bots at discord_pool_bot_guild_cap are never offered as invites;
  pool_full when nothing else is left (owner Q3).
- handle_actual drops cached access on missing_permissions, channel_full,
  not_in_guild, bad_channel.
- New GET /discord/nodes/{node_id}/guilds/{guild_id}/voice-check (org
  member, names only) and GET /discord/guilds/{guild_id}/voice-check
  (service key or admin).
- PUT /tokens/{id}/prefer/{system_id}: org owners for their own systems
  and own BYO bots; shared systems and pool bots stay admin-only (owner Q2).
  Keeps both pointers single-valued; audited.
- Kill switch: DISCORD_PERM_CHECK_ENABLED=false.
- tests/conftest.py: autouse fixture so no test ever reaches discord.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node-page picker and the admin Add form now show, per voice channel, whether
a radio bot can stream there, from S3's voice-check routes:
- Badge per channel: Ready / Needs fix (names the missing permission) /
  Couldn't check / Bots busy / No radio bot / Not a voice channel / Limit N.
- The chosen channel shows "<bot> can't Speak in #dispatch: <what removed it>"
  plus c2-core's fix sentence, and an expandable per-bot list.
- Footer: "Permissions checked 40s ago · Re-check" (refresh=1, rate limited
  server-side; a limited re-check says so). Invite links when no bot fits.
- Start is disabled only on a definite refusal (needs_fix, bad_channel);
  unknown fails open. A channel this system already plays in is judged by
  the bot that carries it, so Update/move is never blocked by the other bots.
- Admin Add form disables bots that are definitely denied in the bot list.
- 409 refusals (missing_permissions, preferred_busy, preferred_other_node,
  bad_channel) are shown verbatim, multi-line.

Org owners (owner decision Q2) get "Radio bot per system" on
Settings > Organization: their own systems, their own BYO bots, set/clear
through S3's PUT /tokens/{id}/prefer/{system}. New GET /tokens/org/preferred
lists only the caller org's systems and bots (no pool bots, no token
strings; a pool bot an admin chose is flagged, not named).

The prefer route now drops the 5-minute doc_get_cached entry for every
system it rewrites, so a new preference applies to the next start.

Stacked on feat/225-s3-perm-check: must merge after S3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
logan added 1 commit 2026-10-05 02:37:14 -04:00
The picker footer reads "Permissions checked N ago" from checked_at, but
voice_check stamped it with the response time. A denied result is cached
for 10 min, so a user who fixed the permission in Discord and reopened the
picker saw "checked 2s ago · Needs fix" from a stale snapshot. checked_at
now carries the oldest fetched_at behind the answer (now when there is none).

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

S4 review (not merged). Pushed ec90a3f: voice-check checked_at now carries the oldest cached snapshot's fetched_at instead of the response time, so the footer "Permissions checked N ago" can't call a 10-min-cached denied result fresh. New tests/test_voice_check_age.py (2 tests).

Verified: c2-core pytest 808 passed, 4 warnings; frontend tsc --noEmit exit 0. Merges cleanly over S3's 8bdcfca. Still owed before merge: drb-qa-review (the reviewer subagent has no agent tool). Must merge after #249.

S4 review (not merged). Pushed ec90a3f: voice-check `checked_at` now carries the oldest cached snapshot's `fetched_at` instead of the response time, so the footer "Permissions checked N ago" can't call a 10-min-cached denied result fresh. New `tests/test_voice_check_age.py` (2 tests). Verified: c2-core pytest `808 passed, 4 warnings`; frontend `tsc --noEmit` exit 0. Merges cleanly over S3's 8bdcfca. Still owed before merge: `drb-qa-review` (the reviewer subagent has no agent tool). Must merge after #249.
logan merged commit e54191a5d0 into main 2026-10-05 02:38:56 -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#250