Files
Logan CusanoandClaude Opus 5.5 9c124d4c8b S8: main DRB bot optional end to end + owner-flow integration tests (server-26#225)
- Copy: picker, admin Servers/Streams/How-it-works, org Discord card, bot
  /help and the server-limit refusal no longer imply the DRB server bot is
  required; a radio bot alone is enough.
- Admin overview: per-server main_bot_present, radio_bots, linked_via,
  directory_source; Servers tab gets a Bots column.
- discord_web docstring: states the reversed "never calls Discord" rule
  (plan finding 5) and the optional server bot.
- Server bot directory cog: re-push (which drops C2's cached permission
  answers) on overwrite changes, role permission changes and role deletes.
- tests/test_discord_owner_flows.py: one-bot org (BYO bot, owner-set
  preferred, OAuth link, picker, start, reconciler) and full setup (server
  bot + 4 radio bots: placement, shared channel, every refusal explained),
  against a modelled Discord over httpx.MockTransport.
- storage.download_audio removed: no caller on main since #233.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:24:44 -04:00

452 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { c2api } from "@/lib/c2api";
import {
type DiscordAdminOverview,
type DiscordStream,
type RadioBot,
SNOWFLAKE,
guildLabel,
parseC2Error,
shortId,
streamHealth,
} from "@/lib/discord";
import {
blocksStart,
channelVerdict,
normalizeVoiceCheck,
parseC2Error as parseStartError,
type ParsedC2Error,
type VoiceCheck,
type VoiceCheckBot,
type VoiceCheckChannel,
} 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,
// POST /discord/guilds/{id}/stop), which accept a platform-admin token.
// Remove is DELETE /discord/streams/{id} (admin): c2-core stops the channel
// through desired state first, then deletes it.
//
// One row per STREAM, not per voice channel: a channel can carry several,
// 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 }[];
function AddChannelForm({
overview,
bots,
onDone,
onCancel,
}: {
overview: DiscordAdminOverview;
bots: RadioBot[];
onDone: () => void;
onCancel: () => void;
}) {
// Only servers that act for an org can carry radio (start_stream refuses the rest).
const guilds = overview.guilds.filter((g) => g.acting_org_id);
const [guildId, setGuildId] = useState(guilds[0]?.guild_id ?? "");
const [channelId, setChannelId] = useState("");
const [channelName, setChannelName] = useState("");
const [mode, setMode] = useState<"persistent" | "adhoc">("persistent");
const [systemId, setSystemId] = useState("");
const [botId, setBotId] = useState("");
const [saving, setSaving] = useState(false);
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): Promise<VoiceCheck | null> => {
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));
return data;
} 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,
);
return null;
}
}, []);
// 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, row: VoiceCheckChannel | undefined = checked): VoiceCheckBot | undefined =>
row?.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);
const usableBots = bots.filter((b) => b.valid !== false && (b.org_id == null || b.org_id === org));
// A bot can be in one voice channel per server: name the stream holding it.
const busyHere = new Map<string, DiscordStream>();
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(),
);
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: [], code: null, status: null });
return;
}
setSaving(true);
setError(null);
try {
if (checkKey && (verdict?.kind === "fix" || chosenBotDenied)) {
// "Needs fix" can change: ask Discord again before refusing (server-26#256).
// Still refused = stay here, the row shows why; a failed check fails open.
const fresh = await runCheck(checkKey, true);
const row = fresh && fresh.enabled !== false ? fresh.channels[cid] : undefined;
if (row && (blocksStart(channelVerdict(row)) || (chosenBot && botCheck(chosenBot, row)?.ok === false))) return;
}
const res = await c2api.startDiscordStream({
guild_id: guildId,
channel_id: channelId.trim(),
system_id: systemId,
mode,
channel_name: channelName.trim() || undefined,
preferred_token_id: botId || undefined,
});
if (res?.already_on) {
// Same system re-added to a channel that already plays it: c2-core
// returns that stream untouched (label, mode and bot kept). Say so
// instead of closing as if a stream had been added.
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) {
const e = parseStartError(err);
setError({ ...e, invite: parseC2Error(err).invite });
if (e.code === "missing_permissions" && checkKey) runCheck(checkKey, false);
} finally {
setSaving(false);
}
}
if (!guilds.length) {
return (
<div className="px-4 py-4 border-b border-line bg-raised text-xs text-ink-muted">
No Discord server acts for an organization yet. Link one under <b className="text-ink">Servers</b> first.
<button className="ml-3 text-accent hover:underline" onClick={onCancel}>Close</button>
</div>
);
}
return (
<form onSubmit={submit} className="px-4 py-4 border-b border-line grid gap-3 sm:grid-cols-3 bg-raised">
<Field label="Server">
<select value={guildId} onChange={(e) => { setGuildId(e.target.value); setSystemId(""); setBotId(""); }} className={INPUT}>
{guilds.map((g) => (
<option key={g.guild_id} value={g.guild_id}>
{guildLabel(g)}{g.linked ? "" : " (unlinked)"}
</option>
))}
</select>
</Field>
<Field label="Voice channel ID" hint="Discord: right-click the voice channel → Copy Channel ID.">
<input value={channelId} onChange={(e) => setChannelId(e.target.value)} required inputMode="numeric" className={`${INPUT} font-mono`} />
</Field>
<Field label="Label (optional)">
<input value={channelName} onChange={(e) => setChannelName(e.target.value)} className={INPUT} placeholder="County Dispatch" />
</Field>
<Field label="Radio system">
<select value={systemId} onChange={(e) => setSystemId(e.target.value)} required className={INPUT}>
<option value="" disabled>Choose a system…</option>
{systems.map((s) => (
<option key={s.system_id} value={s.system_id}>{s.name || s.system_id}</option>
))}
</select>
</Field>
<Field label="Mode" hint={mode === "persistent" ? "Comes back by itself after a node reboot or loss." : "Stopped, not moved, when its node goes away."}>
<select value={mode} onChange={(e) => setMode(e.target.value as "persistent" | "adhoc")} className={INPUT}>
<option value="persistent">Persistent</option>
<option value="adhoc">Ad-hoc</option>
</select>
</Field>
<Field label="Radio bot">
<select value={botId} onChange={(e) => setBotId(e.target.value)} className={INPUT}>
<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 || denied}>
{busy
? `${b.name} — in use here (${busy.system_name || busy.system_id || "radio"}${busy.channel_name ? ` in ${busy.channel_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 press Start again.</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{" "}
{inChannel.map((s) => `${s.system_name || s.system_id}${s.token_name ? ` (${s.token_name})` : ""}`).join(", ")}.
A new system is added beside it with another bot; nothing that is playing is replaced.
</div>
)}
{error && (
<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 || verdict?.kind === "not_voice"}
title={
verdict?.kind === "not_voice"
? "That is not a voice channel. Pick a voice channel."
: blocksStart(verdict) || chosenBotDenied
? "Fixed the permission in Discord? Start checks again first."
: undefined
}
>
{saving ? (blocksStart(verdict) || chosenBotDenied ? "Checking…" : "Starting…") : "Start radio channel"}
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
</div>
</form>
);
}
function source(s: DiscordStream) {
const sys = s.system_name || s.system_id || "—";
return s.node_name ? `${sys} · ${s.node_name}` : sys;
}
export function StreamsPanel({
overview,
bots,
onChanged,
}: {
overview: DiscordAdminOverview;
bots: RadioBot[];
onChanged: () => Promise<void> | void;
}) {
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [rowError, setRowError] = useState<{ id: string; message: string; invite: Invite } | null>(null);
const guildsById = useMemo(() => new Map(overview.guilds.map((g) => [g.guild_id, g])), [overview.guilds]);
// Streams sharing a voice channel (c2-core sorts them next to each other).
const perChannel = useMemo(() => {
const m = new Map<string, number>();
for (const s of overview.streams) {
const k = `${s.guild_id}:${s.channel_id}`;
m.set(k, (m.get(k) ?? 0) + 1);
}
return m;
}, [overview.streams]);
async function act(s: DiscordStream, fn: () => Promise<unknown>) {
setBusy(s.stream_id);
setRowError(null);
try {
await fn();
await onChanged();
} catch (err) {
setRowError({ id: s.stream_id, ...parseC2Error(err) });
} finally {
setBusy(null);
}
}
return (
<Panel
title="Radio channels"
description="One row per radio stream — a voice channel can carry several, each with its own bot and system. Persistent streams come back by themselves."
action={
<Button size="sm" onClick={() => setAdding((v) => !v)}>+ Add radio channel</Button>
}
note={
<>
<b className="text-ink">Every talkgroup the node follows is played.</b> A per-channel talkgroup filter is a
later phase (design-canvas/discord-system-notes.md, phase 2). A channel that stays desired-on without
connecting for 2 minutes turns red here.
</>
}
>
{adding && (
<AddChannelForm
overview={overview}
bots={bots}
onCancel={() => setAdding(false)}
onDone={async () => {
setAdding(false);
await onChanged();
}}
/>
)}
{overview.streams.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-muted">
No radio channels yet. Start one here or from a node&apos;s Discord radio panel, or run{" "}
<span className="font-mono">/join</span> where the DRB server bot is in a linked Discord server.
</p>
) : (
<Table head={["Server · channel", "Source", "Talkgroups", "Mode", "Desired → actual", "Bot", ""]}>
{overview.streams.map((s) => {
const { health, label } = streamHealth(s);
const g = guildsById.get(s.guild_id);
const err = rowError?.id === s.stream_id ? rowError : null;
const isOn = s.desired === "on";
const shared = (perChannel.get(`${s.guild_id}:${s.channel_id}`) ?? 1) - 1;
const name = s.channel_name || "this channel";
const what = `${s.system_name || s.system_id || "radio"} in ${name}`;
return (
<tr key={s.stream_id}>
<td className={TD}>
<b>{s.channel_name ? `🔊 ${s.channel_name}` : `channel ${shortId(s.channel_id)}`}</b>
<div className={DIM}>{g ? guildLabel(g) : guildLabel({ guild_id: s.guild_id, guild_name: s.guild_name })}</div>
{shared > 0 && (
<div className={DIM}>
+ {shared} other stream{shared === 1 ? "" : "s"} in this voice channel
</div>
)}
</td>
<td className={TD}>{source(s)}</td>
<td className={TD}><span className={DIM}>All talkgroups</span></td>
<td className={TD}>{s.mode === "adhoc" ? "ad-hoc" : s.mode ?? "—"}</td>
<td className={TD}>
<Status health={health}>{label}</Status>
{isOn && s.actual?.error && (
<div className={`${DIM} mt-1 max-w-[16rem]`}>
{s.actual.error}
{s.actual.code && <span className="font-mono"> [{s.actual.code}]</span>}
</div>
)}
{!isOn && s.stop_reason && <div className={`${DIM} mt-1`}>{s.stop_reason}</div>}
{err && (
<div className="text-xs text-sev-major mt-1 max-w-[16rem]">
{err.message}
<InviteLinks invite={err.invite} />
</div>
)}
</td>
<td className={TD}>{s.token_name ?? <span className={DIM}>—</span>}</td>
<td className={`${TD} text-right whitespace-nowrap`}>
{isOn ? (
<Button
size="sm"
variant="secondary"
disabled={busy !== null}
onClick={() => {
if (!confirm(`Stop ${what}?`)) return;
act(s, () => c2api.stopDiscordStream(s.guild_id, s.stream_id));
}}
>
{busy === s.stream_id ? "Stopping…" : "Stop"}
</Button>
) : (
<Button
size="sm"
variant="secondary"
disabled={busy !== null || !s.system_id}
onClick={() =>
act(s, () =>
c2api.startDiscordStream({
guild_id: s.guild_id,
channel_id: s.channel_id,
system_id: s.system_id as string,
mode: s.mode ?? "persistent",
stream_id: s.stream_id,
}),
)
}
>
{busy === s.stream_id ? "Starting…" : "Start"}
</Button>
)}
<Button
size="sm"
variant="danger"
className="ml-1"
disabled={busy !== null}
onClick={() => {
const msg = isOn
? `Remove ${what} permanently? It is stopped first (the node logs its radio bot out), then deleted from DRB.${shared > 0 ? " The other streams in this voice channel keep playing." : ""}`
: `Remove ${what} from DRB permanently?`;
if (!confirm(msg)) return;
act(s, () => c2api.deleteDiscordStream(s.stream_id));
}}
>
Remove
</Button>
</td>
</tr>
);
})}
</Table>
)}
</Panel>
);
}