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
+79 -6
View File
@@ -10,17 +10,27 @@
// plays another system ADDS this node's system beside it; only a stream of
// this node's own system there is restarted / moved here.
// Layout follows design-canvas/discord-system-mockup.html "Radio channels".
// Each voice channel shows whether a radio bot can actually stream there
// (server-26#225): Ready / Needs fix (which permission, what removed it, where
// to fix it) / Couldn't check / Limit N. Start is disabled only on a definite
// deny; an unanswered check fails open.
import { useCallback, useEffect, useMemo, useState } from "react";
import { c2api } from "@/lib/c2api";
import {
blocksStart,
channelVerdict,
normalizeVoiceCheck,
parseC2Error,
type DiscordGuildOption,
type DiscordInvite,
type DiscordJoinOptions,
type DiscordStreamView,
type ParsedC2Error,
type StreamState,
type VoiceCheck,
} from "@/lib/discordPicker";
import { BotDetails, CheckFooter, FixText, VerdictBadge } from "@/components/discord/VoiceCheckBits";
const POLL_MS = 10_000;
@@ -93,12 +103,53 @@ function DiscordPicker({
const [channelId, setChannelId] = useState("");
const [mode, setMode] = useState<"adhoc" | "persistent">("adhoc");
const [sending, setSending] = useState(false);
const [error, setError] = useState<{ message: string; invite: DiscordInvite[] } | null>(null);
const [error, setError] = useState<ParsedC2Error | null>(null);
// Permission check per server: the result, whether a load is in flight, and
// why the last load failed (shown under the footer; the badges fail open).
const [checks, setChecks] = useState<
Record<string, { data: VoiceCheck | null; busy: boolean; error: string | null; limited: boolean }>
>({});
const guild: DiscordGuildOption | undefined = usable.find((g) => g.guild_id === guildId);
const channel = guild?.voice_channels.find((c) => c.channel_id === channelId);
const atNodeCap = options.limits.node_used >= options.limits.node_max;
const channelOnHere = channel?.stream?.node_id === nodeId;
const check = guildId ? checks[guildId] : undefined;
const checkOn = check?.data?.enabled !== false;
const verdictOf = (c: DiscordGuildOption["voice_channels"][number]) => {
const row = check?.data && checkOn ? check.data.channels[c.channel_id] : undefined;
const v = channelVerdict(row);
// This system already plays here: c2-core counts its bot as busy (it holds
// the channel), so the channel can read "Needs fix"/"Bots busy" for a NEW
// bot while a restart/move keeps the one that works. Judge by that bot.
const own = c.stream?.token_name ? row?.bots.find((b) => b.name === c.stream?.token_name) : undefined;
if (v && own?.ok === true && v.kind !== "ready") return { kind: "ready" as const, limit: v.limit };
return v;
};
const chosenVerdict = channel ? verdictOf(channel) : null;
const definiteDeny = blocksStart(chosenVerdict);
const showInvite = chosenVerdict?.kind === "fix" || chosenVerdict?.kind === "no_bot" || chosenVerdict?.kind === "busy";
const runCheck = useCallback(
async (gid: string, refresh: boolean) => {
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: true, error: null, limited: false } }));
try {
const data = normalizeVoiceCheck(await c2api.getNodeVoiceCheck(nodeId, gid, refresh));
setChecks((m) => ({ ...m, [gid]: { data, busy: false, error: null, limited: refresh && data.refreshed === false } }));
} catch (err) {
const e = parseC2Error(err);
// 404 = route not deployed yet or server not visible: no badges (fail-open).
const msg = e.status === 404 ? null : e.message;
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: false, error: msg, limited: false } }));
}
},
[nodeId],
);
// First look at a server's channels: load its check (cached server-side).
useEffect(() => {
if (guild?.synced && !checks[guild.guild_id]) runCheck(guild.guild_id, false);
}, [guild, checks, runCheck]);
const grouped = useMemo(() => {
const out: { category: string | null; channels: DiscordGuildOption["voice_channels"] }[] = [];
@@ -119,7 +170,11 @@ function DiscordPicker({
onStarted();
onClose();
} catch (err) {
setError(parseC2Error(err));
const e = parseC2Error(err);
setError(e);
// The refusal may be newer than the badges (C2 drops its cache entry on
// a permission refusal) — reload them so the row matches the error.
if (e.code === "missing_permissions") runCheck(guild.guild_id, false);
} finally {
setSending(false);
}
@@ -209,6 +264,7 @@ function DiscordPicker({
{grp.channels.map((c) => {
const here = c.stream?.node_id === nodeId;
const others = (c.streams ?? []).filter((x) => x.stream_id !== c.stream?.stream_id);
const verdict = verdictOf(c);
return (
<label
key={c.channel_id}
@@ -227,7 +283,11 @@ function DiscordPicker({
}}
/>
<span className="flex-1 min-w-0">
<span className="text-ink block truncate">🔊 {c.name}</span>
<span className="flex items-center gap-2 min-w-0">
<span className="text-ink truncate">🔊 {c.name}</span>
{checkOn && <VerdictBadge verdict={verdict} loading={check?.busy} />}
</span>
{verdict && c.channel_id === channelId && <FixText verdict={verdict} channelName={c.name} />}
{c.stream && (
<span className="text-xs text-ink-muted block">
{here
@@ -241,6 +301,9 @@ function DiscordPicker({
{!c.stream && ` — this adds ${options.system_name ?? "this node's system"} beside it with another bot`}
</span>
)}
{c.channel_id === channelId && checkOn && (
<BotDetails channel={check?.data?.channels[c.channel_id]} channelName={c.name} />
)}
</span>
</label>
);
@@ -248,6 +311,15 @@ function DiscordPicker({
</div>
</div>
))}
<CheckFooter
checkedAt={check?.data?.checked_at ?? null}
busy={!!check?.busy}
error={check?.error ?? null}
disabled={!checkOn}
limited={!!check?.limited}
invite={showInvite ? check?.data?.invite : undefined}
onRecheck={() => runCheck(guild.guild_id, true)}
/>
</div>
)}
</fieldset>
@@ -299,10 +371,10 @@ function DiscordPicker({
</p>
)}
{error && (
<p role="alert" className="text-xs text-sev-major">
<div role="alert" className="text-xs text-sev-major whitespace-pre-line">
{error.message}
<InviteLinks invite={error.invite} />
</p>
</div>
)}
</div>
@@ -310,7 +382,8 @@ function DiscordPicker({
<button
type="button"
onClick={start}
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere)}
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || definiteDeny}
title={definiteDeny ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined}
className="flex-1 bg-accent hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-semibold"
>
{sending ? "Starting…" : channelOnHere ? "Update" : "Start radio"}