Discord picker: per-bot permission status, owner's preferred bot (server-26#225 S4)

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>
This commit is contained in:
Logan Cusano
2026-10-05 02:28:36 -04:00
co-authored by Claude Opus 5.5
parent 2c2cf9d9be
commit 1ee1560383
9 changed files with 938 additions and 20 deletions
+17 -1
View File
@@ -148,6 +148,21 @@ export const c2api = {
`/discord/nodes/${nodeId}/streams/${encodeURIComponent(streamId)}`,
{ method: "DELETE" },
),
/** Per-bot permission check for every voice channel in one server (server-26#225).
* refresh=true bypasses the cache (rate limited server-side: 1 per 10 s). Raw
* body — run it through discordPicker.normalizeVoiceCheck. */
getNodeVoiceCheck: (nodeId: string, guildId: string, refresh = false) =>
request<unknown>(
`/discord/nodes/${encodeURIComponent(nodeId)}/guilds/${encodeURIComponent(guildId)}/voice-check?refresh=${refresh ? 1 : 0}`,
),
/** Org owner: this org's systems + its own radio bots, for "preferred bot per system" (server-26#225 Q2). */
getOrgPreferredBots: () =>
request<import("@/lib/discordPicker").OrgPreferredBots>("/tokens/org/preferred"),
/** Admin: the same check for one channel (the Add form takes a raw channel ID). */
getGuildVoiceCheck: (guildId: string, channelId: string, refresh = false) =>
request<unknown>(
`/discord/guilds/${encodeURIComponent(guildId)}/voice-check?channel_id=${encodeURIComponent(channelId)}&refresh=${refresh ? 1 : 0}`,
),
deleteNode: (id: string) =>
request(`/nodes/${id}`, { method: "DELETE" }),
@@ -344,7 +359,8 @@ export const c2api = {
getReplayIncidents: (runId: string) =>
request<import("@/lib/types").ReplayIncidents>(`/admin/replay/${runId}/incidents`),
// Preferred bot token per system
// Preferred bot token per system. Platform admin: any bot, any system. Org
// owner: their own org's bots for their own org's systems (server-26#225 Q2).
setPreferredToken: (tokenId: string, systemId: string) =>
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),