- 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>
451 lines
19 KiB
TypeScript
451 lines
19 KiB
TypeScript
"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'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>
|
||
);
|
||
}
|