"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 & { 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 => { 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(); 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 (
No Discord server acts for an organization yet. Link one under Servers first.
); } return (
setChannelId(e.target.value)} required inputMode="numeric" className={`${INPUT} font-mono`} /> setChannelName(e.target.value)} className={INPUT} placeholder="County Dispatch" /> {checkKey && (
Permissions in {shownName ? `#${shownName.replace(/^#/, "")}` : "this channel"}: {checkOn && }
{verdict && } {chosenBotDenied && (
The bot you picked can't stream into this channel — pick another, or fix it in Discord and press Start again.
)} {checkOn && } runCheck(checkKey, true)} />
)} {inChannel.length > 0 && (
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.
)} {error && (
{error.message}
)}
); } 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; }) { const [adding, setAdding] = useState(false); const [busy, setBusy] = useState(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(); 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) { setBusy(s.stream_id); setRowError(null); try { await fn(); await onChanged(); } catch (err) { setRowError({ id: s.stream_id, ...parseC2Error(err) }); } finally { setBusy(null); } } return ( setAdding((v) => !v)}>+ Add radio channel } note={ <> Every talkgroup the node follows is played. 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 && ( setAdding(false)} onDone={async () => { setAdding(false); await onChanged(); }} /> )} {overview.streams.length === 0 ? (

No radio channels yet. Start one here, or run /join in a linked Discord server.

) : ( {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 ( ); })}
{s.channel_name ? `🔊 ${s.channel_name}` : `channel ${shortId(s.channel_id)}`}
{g ? guildLabel(g) : guildLabel({ guild_id: s.guild_id, guild_name: s.guild_name })}
{shared > 0 && (
+ {shared} other stream{shared === 1 ? "" : "s"} in this voice channel
)}
{source(s)} All talkgroups {s.mode === "adhoc" ? "ad-hoc" : s.mode ?? "—"} {label} {isOn && s.actual?.error && (
{s.actual.error} {s.actual.code && [{s.actual.code}]}
)} {!isOn && s.stop_reason &&
{s.stop_reason}
} {err && (
{err.message}
)}
{s.token_name ?? —} {isOn ? ( ) : ( )}
)}
); }