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
@@ -0,0 +1,142 @@
"use client";
// Settings → Organization → "Radio bot per system" (server-26#225, owner
// ruling Q2). The org owner picks which of the org's OWN radio bots plays each
// of the org's systems in Discord. C2 tries that bot first for every new
// radio channel of the system (discord_streams.start_stream); when it can't
// take the stream (busy in that server, no permission, not invited) another
// free bot is used. Shared systems and DRB pool bots stay platform-admin only,
// so they are not offered here. Backend: GET /tokens/org/preferred (list),
// PUT /tokens/{token_id}/prefer/{system_id} (set; "_none" clears).
import { useCallback, useEffect, useState } from "react";
import { c2api } from "@/lib/c2api";
import { parseC2Error, type OrgPreferredBots } from "@/lib/discordPicker";
import { Card, CardHeader } from "@/components/ui/Card";
const SELECT =
"bg-page border border-line rounded-lg px-2.5 py-1.5 text-ink text-sm focus:outline-none focus:border-accent disabled:opacity-50 max-w-full";
export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
const [data, setData] = useState<OrgPreferredBots | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState<string | null>(null);
const [rowError, setRowError] = useState<{ id: string; message: string } | null>(null);
// A platform admin with no org of their own gets a 403 here: nothing to show
// (shared systems are set on /systems), so the card hides instead of erroring.
const [hidden, setHidden] = useState(false);
const load = useCallback(async () => {
try {
setData(await c2api.getOrgPreferredBots());
setLoadError(null);
} catch (err) {
const e = parseC2Error(err);
if (e.status === 403) setHidden(true);
else setLoadError(e.message);
}
}, []);
useEffect(() => {
if (canEdit) load();
}, [canEdit, load]);
async function choose(systemId: string, tokenId: string, currentId: string | null) {
if (tokenId === (currentId ?? "")) return;
setSaving(systemId);
setRowError(null);
try {
if (tokenId) await c2api.setPreferredToken(tokenId, systemId);
else if (currentId) await c2api.setPreferredToken(currentId, "_none");
await load();
} catch (err) {
setRowError({ id: systemId, message: parseC2Error(err).message });
} finally {
setSaving(null);
}
}
if (!canEdit || hidden) return null;
const bots = data?.bots ?? [];
const botsById = new Map(bots.map((b) => [b.token_id, b]));
return (
<Card>
<CardHeader
title="Radio bot per system"
subtitle="Which of your radio bots plays each system in Discord. DRB tries it first for every new radio channel of that system; if it can't (busy in that server, missing a permission, or not invited), another free bot is used."
/>
{loadError ? (
<p role="alert" className="text-sm text-sev-major">Couldn&apos;t load radio bots: {loadError}</p>
) : !data ? (
<p className="text-sm text-ink-muted">Loading…</p>
) : data.systems.length === 0 ? (
<p className="text-sm text-ink-muted">Your organization has no radio systems yet.</p>
) : bots.length === 0 ? (
<p className="text-sm text-ink-muted">
Your organization has no radio bots of its own, so DRB picks a free DRB radio bot for each channel. To use
your own bot for a system, ask DRB support to add it to your organization.
</p>
) : (
<div className="divide-y divide-line">
{data.systems.map((s) => {
const current = s.preferred_token_id ? botsById.get(s.preferred_token_id) : undefined;
const err = rowError?.id === s.system_id ? rowError.message : null;
const usedElsewhere = (tokenId: string) => {
const other = botsById.get(tokenId)?.preferred_for_system_id;
return other && other !== s.system_id ? data.systems.find((x) => x.system_id === other)?.name ?? "another system" : null;
};
return (
<div key={s.system_id} className="py-3 first:pt-0 last:pb-0 flex flex-wrap items-start gap-x-4 gap-y-1.5">
<div className="min-w-0 flex-1">
<div className="text-sm text-ink font-medium truncate">{s.name}</div>
<div className="text-xs text-ink-muted">
{current
? `${current.name} is tried first.`
: s.preferred_is_platform_bot
? "A DRB radio bot chosen by DRB support is tried first. Pick one of yours to replace it."
: "No preference — any free radio bot."}
{current?.valid === false && " Discord rejected this bot's token — DRB support has to replace it."}
{current && current.server_count === 0 && " It isn't in any Discord server yet — invite it first."}
</div>
{current?.invite_url && (
<a
href={current.invite_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-accent hover:underline"
>
Invite {current.name} to a Discord server
</a>
)}
{err && <div role="alert" className="text-xs text-sev-major mt-1">{err}</div>}
</div>
<label className="flex items-center gap-2 text-xs text-ink-muted">
<span className="sr-only">Preferred radio bot for {s.name}</span>
<select
value={s.preferred_token_id ?? ""}
disabled={saving !== null}
onChange={(e) => choose(s.system_id, e.target.value, s.preferred_token_id)}
className={SELECT}
>
<option value="">{s.preferred_is_platform_bot ? "DRB support's choice" : "Any free radio bot"}</option>
{bots.map((b) => {
const elsewhere = usedElsewhere(b.token_id);
return (
<option key={b.token_id} value={b.token_id} disabled={b.valid === false}>
{b.name}
{b.valid === false ? " — token rejected" : elsewhere ? ` — moves it off ${elsewhere}` : ""}
</option>
);
})}
</select>
{saving === s.system_id && <span>Saving…</span>}
</label>
</div>
);
})}
</div>
)}
</Card>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { c2api } from "@/lib/c2api";
import {
type DiscordAdminOverview,
@@ -12,8 +12,18 @@ import {
shortId,
streamHealth,
} from "@/lib/discord";
import {
blocksStart,
channelVerdict,
normalizeVoiceCheck,
parseC2Error as parseStartError,
type ParsedC2Error,
type VoiceCheck,
type VoiceCheckBot,
} from "@/lib/discordPicker";
import { Button } from "@/components/ui/Button";
import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
import { BotDetails, CheckFooter, FixText, VerdictBadge } from "./VoiceCheckBits";
// Radio channels = discord_streams docs, desired vs actual. Start/Stop/Add go
// through the same routes the Discord bot uses (POST /discord/streams,
@@ -25,6 +35,11 @@ import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
// each with its own radio bot and system (server-26#213 follow-up). Stop,
// Start and Remove all act on the row's stream_id, so the other streams in
// the same channel keep playing. Add never replaces an existing stream.
//
// The Add form checks the typed channel with Discord (server-26#225:
// GET /discord/guilds/{id}/voice-check, admin): per-bot status, bots that
// definitely can't stream there are disabled in the bot list, and Start is
// disabled only when every bot is definitely denied (unknown fails open).
type Invite = { name: string; invite_url: string | null }[];
@@ -48,7 +63,43 @@ function AddChannelForm({
const [systemId, setSystemId] = useState("");
const [botId, setBotId] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<{ message: string; invite: Invite } | null>(null);
const [error, setError] = useState<(Omit<ParsedC2Error, "invite"> & { invite: Invite }) | null>(null);
const [check, setCheck] = useState<
{ key: string; data: VoiceCheck | null; busy: boolean; error: string | null; limited: boolean } | null
>(null);
const cid = channelId.trim();
const checkKey = SNOWFLAKE.test(cid) && guildId ? `${guildId}:${cid}` : "";
const runCheck = useCallback(async (key: string, refresh: boolean) => {
const [gid, ch] = key.split(":");
setCheck((c) => ({ key, data: c?.key === key ? c.data : null, busy: true, error: null, limited: false }));
try {
const data = normalizeVoiceCheck(await c2api.getGuildVoiceCheck(gid, ch, refresh));
setCheck((c) => (c?.key === key ? { key, data, busy: false, error: null, limited: refresh && data.refreshed === false } : c));
} catch (err) {
const e = parseStartError(err);
setCheck((c) =>
c?.key === key ? { key, data: c.data, busy: false, error: e.status === 404 ? null : e.message, limited: false } : c,
);
}
}, []);
// Check the channel once its ID looks complete (debounced while typing).
useEffect(() => {
if (!checkKey) {
setCheck(null);
return;
}
const t = setTimeout(() => runCheck(checkKey, false), 400);
return () => clearTimeout(t);
}, [checkKey, runCheck]);
const liveCheck = check && check.key === checkKey ? check : null;
const checkOn = liveCheck?.data?.enabled !== false;
const checked = liveCheck?.data && checkOn ? liveCheck.data.channels[cid] : undefined;
const verdict = liveCheck?.data && checkOn ? channelVerdict(checked) : null;
const shownName = channelName.trim() || checked?.name || null;
// Per-bot result, by token id (the admin route sends ids) or else by name.
const botCheck = (b: RadioBot): VoiceCheckBot | undefined =>
checked?.bots.find((x) => (x.token_id ? x.token_id === b.token_id : x.name === b.name || x.name === b.bot_name));
const org = guilds.find((g) => g.guild_id === guildId)?.acting_org_id ?? null;
const systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org);
@@ -58,6 +109,8 @@ function AddChannelForm({
for (const s of overview.streams) {
if (s.desired === "on" && s.guild_id === guildId && s.token_id) busyHere.set(s.token_id, s);
}
const chosenBot = botId ? bots.find((b) => b.token_id === botId) : undefined;
const chosenBotDenied = chosenBot ? botCheck(chosenBot)?.ok === false : false;
const inChannel = overview.streams.filter(
(s) => s.desired === "on" && s.guild_id === guildId && s.channel_id === channelId.trim(),
);
@@ -65,7 +118,7 @@ function AddChannelForm({
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!SNOWFLAKE.test(channelId.trim())) {
setError({ message: "Voice channel ID is the 17–20 digit number from Discord (right-click the channel → Copy Channel ID).", invite: [] });
setError({ message: "Voice channel ID is the 17–20 digit number from Discord (right-click the channel → Copy Channel ID).", invite: [], code: null, status: null });
return;
}
setSaving(true);
@@ -86,12 +139,16 @@ function AddChannelForm({
setError({
message: `${res.system_name || "That system"} is already playing in this voice channel, so nothing was added or changed. Pick another system to add a second stream, or Remove the existing row to change its label or bot.`,
invite: [],
code: "already_on",
status: null,
});
return;
}
onDone();
} catch (err) {
setError(parseC2Error(err));
const e = parseStartError(err);
setError({ ...e, invite: parseC2Error(err).invite });
if (e.code === "missing_permissions" && checkKey) runCheck(checkKey, false);
} finally {
setSaving(false);
}
@@ -142,16 +199,42 @@ function AddChannelForm({
<option value="">Any free bot in this server</option>
{usableBots.map((b) => {
const busy = busyHere.get(b.token_id);
const bc = botCheck(b);
const denied = bc?.ok === false;
return (
<option key={b.token_id} value={b.token_id} disabled={!!busy}>
<option key={b.token_id} value={b.token_id} disabled={!!busy || denied}>
{busy
? `${b.name} — in use here (${busy.system_name || busy.system_id || "radio"}${busy.channel_name ? ` in ${busy.channel_name}` : ""})`
: `Use ${b.name}`}
: denied
? `${b.name} — can't ${bc.missing.length ? bc.missing.join(", ") : "join"} in this channel`
: `Use ${b.name}`}
</option>
);
})}
</select>
</Field>
{checkKey && (
<div className="sm:col-span-3 space-y-1">
<div className="flex items-center gap-2 text-xs text-ink-muted">
<span>Permissions in {shownName ? `#${shownName.replace(/^#/, "")}` : "this channel"}:</span>
{checkOn && <VerdictBadge verdict={verdict} loading={liveCheck?.busy} />}
</div>
{verdict && <FixText verdict={verdict} channelName={shownName} />}
{chosenBotDenied && (
<div className="text-xs text-sev-major">The bot you picked can&apos;t stream into this channel — pick another, or fix it in Discord and Re-check.</div>
)}
{checkOn && <BotDetails channel={checked} channelName={shownName} />}
<CheckFooter
checkedAt={liveCheck?.data?.checked_at ?? null}
busy={!!liveCheck?.busy}
error={liveCheck?.error ?? null}
disabled={!checkOn}
limited={!!liveCheck?.limited}
invite={verdict && ["fix", "no_bot", "busy"].includes(verdict.kind) ? liveCheck?.data?.invite : undefined}
onRecheck={() => runCheck(checkKey, true)}
/>
</div>
)}
{inChannel.length > 0 && (
<div className="sm:col-span-3 text-xs text-ink-muted">
This voice channel already plays{" "}
@@ -160,13 +243,20 @@ function AddChannelForm({
</div>
)}
{error && (
<div className="sm:col-span-3 text-xs text-sev-major">
<div role="alert" className="sm:col-span-3 text-xs text-sev-major whitespace-pre-line">
{error.message}
<InviteLinks invite={error.invite} />
</div>
)}
<div className="sm:col-span-3 flex gap-2">
<Button type="submit" size="sm" disabled={saving || !systemId}>{saving ? "Starting…" : "Start radio channel"}</Button>
<Button
type="submit"
size="sm"
disabled={saving || !systemId || blocksStart(verdict) || chosenBotDenied}
title={blocksStart(verdict) ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined}
>
{saving ? "Starting…" : "Start radio channel"}
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
</div>
</form>
@@ -0,0 +1,157 @@
"use client";
// Per-bot voice-channel permission status (server-26#225 S4). Shared by the
// node-page picker (NodeDiscordRadio) and the admin Add form (StreamsPanel),
// both fed by c2-core routers/discord_checks.py. Start is disabled only on a
// DEFINITE refusal ("Needs fix", "Not a voice channel"); "Couldn't check"
// fails open — a Discord outage must never block a start.
import { useEffect, useState } from "react";
import {
agoText,
botLine,
deniedSentence,
fixSentence,
type ChannelVerdict,
type DiscordInvite,
type VoiceCheckChannel,
} from "@/lib/discordPicker";
const PILL = "inline-flex items-center gap-1 rounded px-1.5 py-px text-[11px] font-medium whitespace-nowrap border";
const BADGE: Record<ChannelVerdict["kind"], { label: string; cls: string; title?: string }> = {
ready: { label: "Ready", cls: "border-green-500/40 text-green-500" },
fix: { label: "Needs fix", cls: "border-sev-major/50 text-sev-major" },
not_voice: { label: "Not a voice channel", cls: "border-sev-major/50 text-sev-major" },
busy: {
label: "Bots busy",
cls: "border-sev-moderate/50 text-sev-moderate",
title: "A bot could stream here, but each one that can is already in another channel of this server or on another node.",
},
no_bot: { label: "No radio bot", cls: "border-sev-moderate/50 text-sev-moderate", title: "No radio bot is in this Discord server yet." },
unknown: { label: "Couldn't check", cls: "border-line text-ink-muted", title: "Discord didn't answer the check — DRB will still try." },
};
/** Ready / Needs fix / Couldn't check / …, plus "Limit N" when the channel has a user limit. */
export function VerdictBadge({ verdict, loading }: { verdict: ChannelVerdict | null; loading?: boolean }) {
if (!verdict) {
return loading ? <span className={`${PILL} border-line text-ink-muted`}>Checking…</span> : null;
}
const b = BADGE[verdict.kind];
// Name the missing permission right on the badge: "Needs fix · can't Speak".
const label =
verdict.kind === "fix" && verdict.bot.missing.length
? `${b.label} · can't ${verdict.bot.missing.join(", ")}`
: b.label;
const title = verdict.kind === "fix" ? `${verdict.bot.name}: ${verdict.bot.reason ?? "missing permission"}` : b.title;
return (
<span className="inline-flex gap-1 flex-wrap">
<span className={`${PILL} ${b.cls}`} title={title}>{label}</span>
{verdict.limit > 0 && (
<span
className={`${PILL} border-line text-ink-muted`}
title="Discord user limit. DRB can't see who is connected, so a full channel only shows up when the bot tries to join."
>
Limit {verdict.limit}
</span>
)}
</span>
);
}
/** The sentence under a "Needs fix" / "Not a voice channel" channel. */
export function FixText({ verdict, channelName }: { verdict: ChannelVerdict; channelName: string | null }) {
if (verdict.kind === "not_voice") {
return <span className="block text-xs text-sev-major mt-1">That is not a voice channel. Radio can only play in a voice channel.</span>;
}
if (verdict.kind !== "fix") return null;
return (
<span className="block text-xs text-sev-major mt-1">
{deniedSentence(verdict.bot, channelName)} {fixSentence(verdict.bot)}
</span>
);
}
const TONE: Record<"ok" | "bad" | "warn" | "muted", string> = {
ok: "text-green-500",
bad: "text-sev-major",
warn: "text-sev-moderate",
muted: "text-ink-muted",
};
const MARK: Record<"ok" | "bad" | "warn" | "muted", string> = { ok: "✓", bad: "✕", warn: "•", muted: "?" };
/** Expandable per-bot detail for one channel. */
export function BotDetails({ channel, channelName }: { channel: VoiceCheckChannel | null | undefined; channelName: string | null }) {
if (!channel || channel.bots.length === 0) return null;
return (
<details className="text-xs mt-1">
<summary className="cursor-pointer text-ink-muted hover:text-ink select-none">
Radio bots in this server ({channel.bots.length})
</summary>
<ul className="mt-1 space-y-0.5 pl-1">
{channel.bots.map((b, i) => {
const l = botLine(b, channelName);
return (
<li key={`${b.token_id ?? b.name}-${i}`} className={TONE[l.tone]}>
<span aria-hidden className="inline-block w-3">{MARK[l.tone]}</span> {l.text}
{b.status === "denied" && <span className="block pl-3 text-ink-muted">{fixSentence(b)}</span>}
</li>
);
})}
</ul>
</details>
);
}
/** "Permissions checked 40s ago · Re-check". Ticks on its own so the age stays honest. */
export function CheckFooter({
checkedAt,
busy,
error,
disabled,
limited,
invite,
onRecheck,
}: {
checkedAt: string | null;
busy: boolean;
error: string | null;
disabled?: boolean;
/** A Re-check was rate limited server-side (once per 10 s); the cached answer is shown. */
limited?: boolean;
invite?: DiscordInvite[];
onRecheck: () => void;
}) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 10_000);
return () => clearInterval(t);
}, []);
const ago = agoText(checkedAt, now);
if (disabled) {
return <div className="text-xs text-ink-muted">Permission check is off — DRB tries any free radio bot.</div>;
}
return (
<div className="text-xs text-ink-muted flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{busy ? "Checking permissions…" : ago ? `Permissions checked ${ago}` : "Permissions not checked yet"}</span>
<span aria-hidden>·</span>
<button type="button" onClick={onRecheck} disabled={busy} className="text-accent hover:underline disabled:opacity-50">
Re-check
</button>
{limited && !busy && (
<span className="basis-full">Re-check runs at most once every 10 seconds — this is the last result. Try again in a moment.</span>
)}
{error && <span className="basis-full text-sev-moderate">Couldn&apos;t check permissions: {error}</span>}
{invite && invite.length > 0 && (
<span className="basis-full flex flex-wrap gap-x-3 gap-y-1">
<span>Add another radio bot to this server:</span>
{invite.map((i) => (
<a key={i.invite_url} href={i.invite_url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">
Invite {i.name}
</a>
))}
</span>
)}
</div>
);
}