Files
server-26/drb-frontend/components/discord/StreamsPanel.tsx
T
Logan CusanoandClaude Opus 5.5 32d223e556 S7: OAuth start/options accept system_id; Discord picker QA fixes (server-26#225, #256 #257 #258)
- discord_oauth: POST /discord/oauth/start and GET /options take an optional
  system_id. The system must be the org's own or shared (another org's or
  unknown = 404). Its preferred bot is strict: one that can't connect is a
  404, never a quiet swap to Automatic. No preference = the usual order.
  The picker's "bot for a system" now always sends system_id.
- #257: _channel_state gives a bot whose token Discord rejected its own
  "invalid" state; the picker shows "Radio bot needs replacing" with no
  invite links, instead of "No radio bot".
- #256: Start no longer stays disabled on "Needs fix". It re-checks with
  Discord first (refresh=1) and starts only if the fresh answer allows it.
  "Not a voice channel" still disables Start.
- #258: own tooltip for "Not a voice channel"; "platform admin" -> "DRB
  support"; PreferredBotsCard says plainly that adding your own bot isn't
  self-serve (no support address exists yet, see #47), and a load failure
  gets a plain message + Try again instead of the raw C2 error.

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

451 lines
19 KiB
TypeScript
Raw 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 run <span className="font-mono">/join</span> 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>
);
}