"use client"; // Node page "Discord radio" panel + picker (server-26#213 item 3, #109 item 13). // Replaces the raw Guild ID / Voice Channel ID modal. The picker lists only // the Discord servers linked to this node's org and the voice channels the // DRB bot reported in them; a pick becomes a radio channel pinned to this node // (desired state — the node reconciles and reports back, shown per row). // A voice channel can carry several radio streams, each its own bot playing // its own system (server-26#213 follow-up): picking a channel that already // plays another system ADDS this node's system beside it; only a stream of // this node's own system there is restarted / moved here. // Layout follows design-canvas/discord-system-mockup.html "Radio channels". // Each voice channel shows whether a radio bot can actually stream there // (server-26#225): Ready / Needs fix (which permission, what removed it, where // to fix it) / Couldn't check / Limit N. Start is disabled only on a definite // deny; an unanswered check fails open. import { useCallback, useEffect, useMemo, useState } from "react"; import { c2api } from "@/lib/c2api"; import { blocksStart, channelVerdict, normalizeVoiceCheck, parseC2Error, type DiscordGuildOption, type DiscordInvite, type DiscordJoinOptions, type DiscordStreamView, type ParsedC2Error, type StreamState, type VoiceCheck, } from "@/lib/discordPicker"; import { BotDetails, CheckFooter, FixText, VerdictBadge } from "@/components/discord/VoiceCheckBits"; const POLL_MS = 10_000; const STATE: Record = { pending: { label: "on → waiting for node", dot: "bg-sev-moderate" }, connecting: { label: "on → connecting", dot: "bg-sev-moderate" }, connected: { label: "on → connected", dot: "bg-green-500" }, error: { label: "on → error", dot: "bg-sev-major" }, stopping: { label: "off → stopping", dot: "bg-ink-muted" }, stopped: { label: "off", dot: "bg-ink-muted" }, node_offline: { label: "on → node offline", dot: "bg-sev-major" }, paused_locally: { label: "on → paused on node", dot: "bg-sev-moderate" }, }; function StatePill({ s }: { s: DiscordStreamView }) { const st = STATE[s.actual?.state ?? "pending"] ?? STATE.pending; return ( {st.label} ); } function InviteLinks({ invite }: { invite: DiscordInvite[] }) { if (!invite.length) return null; return ( {invite.map((i) => ( Invite {i.name} ))} ); } function modeLabel(mode: DiscordStreamView["mode"]) { return mode === "persistent" ? "persistent" : "ad-hoc"; } function streamLabel(s: DiscordStreamView) { const sys = s.system_name ?? "radio"; return s.token_name ? `${sys} (${s.token_name})` : sys; } // --------------------------------------------------------------------------- // Picker // --------------------------------------------------------------------------- function DiscordPicker({ nodeId, options, onClose, onStarted, }: { nodeId: string; options: DiscordJoinOptions; onClose: () => void; onStarted: () => void; }) { const usable = options.guilds; const [guildId, setGuildId] = useState(usable.length === 1 ? usable[0].guild_id : ""); const [channelId, setChannelId] = useState(""); const [mode, setMode] = useState<"adhoc" | "persistent">("adhoc"); const [sending, setSending] = useState(false); const [error, setError] = useState(null); // Permission check per server: the result, whether a load is in flight, and // why the last load failed (shown under the footer; the badges fail open). const [checks, setChecks] = useState< Record >({}); const guild: DiscordGuildOption | undefined = usable.find((g) => g.guild_id === guildId); const channel = guild?.voice_channels.find((c) => c.channel_id === channelId); const atNodeCap = options.limits.node_used >= options.limits.node_max; const channelOnHere = channel?.stream?.node_id === nodeId; const check = guildId ? checks[guildId] : undefined; const checkOn = check?.data?.enabled !== false; const verdictOf = (c: DiscordGuildOption["voice_channels"][number], data: VoiceCheck | null | undefined = check?.data) => { const row = data && data.enabled !== false ? data.channels[c.channel_id] : undefined; const v = channelVerdict(row); // This system already plays here: c2-core counts its bot as busy (it holds // the channel), so the channel can read "Needs fix"/"Bots busy" for a NEW // bot while a restart/move keeps the one that works. Judge by that bot. const own = c.stream?.token_name ? row?.bots.find((b) => b.name === c.stream?.token_name) : undefined; if (v && own?.ok === true && v.kind !== "ready") return { kind: "ready" as const, limit: v.limit }; return v; }; const chosenVerdict = channel ? verdictOf(channel) : null; const definiteDeny = blocksStart(chosenVerdict); // "Not a voice channel" can't change, so Start stays off. "Needs fix" can: // Start re-checks with Discord first (server-26#256), so fixing it in // Discord and pressing Start again just works — no hunting for Re-check. const startOff = chosenVerdict?.kind === "not_voice"; // Not for "invalid": that bot is already in the server; inviting won't help (#257). const showInvite = chosenVerdict?.kind === "fix" || chosenVerdict?.kind === "no_bot" || chosenVerdict?.kind === "busy"; const runCheck = useCallback( async (gid: string, refresh: boolean): Promise => { setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: true, error: null, limited: false } })); try { const data = normalizeVoiceCheck(await c2api.getNodeVoiceCheck(nodeId, gid, refresh)); setChecks((m) => ({ ...m, [gid]: { data, busy: false, error: null, limited: refresh && data.refreshed === false } })); return data; } catch (err) { const e = parseC2Error(err); // 404 = route not deployed yet or server not visible: no badges (fail-open). const msg = e.status === 404 ? null : e.message; setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: false, error: msg, limited: false } })); return null; } }, [nodeId], ); // First look at a server's channels: load its check (cached server-side). useEffect(() => { if (guild?.synced && !checks[guild.guild_id]) runCheck(guild.guild_id, false); }, [guild, checks, runCheck]); const grouped = useMemo(() => { const out: { category: string | null; channels: DiscordGuildOption["voice_channels"] }[] = []; for (const c of guild?.voice_channels ?? []) { const last = out[out.length - 1]; if (last && last.category === c.category) last.channels.push(c); else out.push({ category: c.category, channels: [c] }); } return out; }, [guild]); async function start() { if (!guild || !channel) return; setSending(true); setError(null); try { if (definiteDeny) { // Fresh answer from Discord; still refused = stay here, the row shows why. // A failed check fails open, like everywhere else in the picker. const fresh = await runCheck(guild.guild_id, true); if (fresh && blocksStart(verdictOf(channel, fresh))) return; } await c2api.startNodeDiscordStream(nodeId, { guild_id: guild.guild_id, channel_id: channel.channel_id, mode }); onStarted(); onClose(); } catch (err) { const e = parseC2Error(err); setError(e); // The refusal may be newer than the badges (C2 drops its cache entry on // a permission refusal) — reload them so the row matches the error. if (e.code === "missing_permissions") runCheck(guild.guild_id, false); } finally { setSending(false); } } return (

Add radio channel

Source: {options.system_name ?? "no system"} ·{" "} {options.node_name ?? nodeId}. Audio comes from this node's PulseAudio monitor.

{/* 1 — server */}
Discord server {usable.length === 0 ? (

No Discord server is linked to your organization yet. Add the DRB bot to your Discord server and link it to your organization; it shows up here once the bot reports its channels.

) : (
{usable.map((g) => ( ))}
)}
{/* 2 — voice channel */} {guild && (
Voice channel {!guild.synced ? (

The DRB bot has not reported this server's channels yet. It does when it starts and every 15 minutes — check back shortly.

) : guild.voice_channels.length === 0 ? (

This server has no voice channels the DRB bot can see.

) : (
{grouped.map((grp, gi) => (
{grp.category && (
{grp.category}
)}
{grp.channels.map((c) => { const here = c.stream?.node_id === nodeId; const others = (c.streams ?? []).filter((x) => x.stream_id !== c.stream?.stream_id); const verdict = verdictOf(c); return ( ); })}
))} runCheck(guild.guild_id, true)} />
)}
)} {/* 3 — mode */} {channel && (
Mode
{( [ ["adhoc", "Ad-hoc", "Stops if this node goes offline or switches system."], ["persistent", "Persistent", `Stays up: if this node drops, DRB moves it to another node on ${options.system_name ?? "this system"}.`], ] as const ).map(([value, label, hint]) => ( ))}
)} {!options.system_id && (

This node is not listening to a system yet. Assign it one before adding a radio channel.

)} {atNodeCap && !channelOnHere && (

This node already carries {options.limits.node_used} of {options.limits.node_max} radio channels. Stop one first.

)} {error && (
{error.message}
)}
); } // --------------------------------------------------------------------------- // Panel // --------------------------------------------------------------------------- export function NodeDiscordRadio({ nodeId }: { nodeId: string }) { const [options, setOptions] = useState(null); const [loadError, setLoadError] = useState(null); const [picking, setPicking] = useState(false); const [busy, setBusy] = useState(null); const [actionError, setActionError] = useState(null); const load = useCallback(async () => { try { setOptions(await c2api.getNodeDiscordOptions(nodeId)); setLoadError(null); } catch (err) { setLoadError(parseC2Error(err).message); } }, [nodeId]); useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === "visible") load(); }, POLL_MS); return () => clearInterval(t); }, [load]); async function stop(streamId?: string) { if (!streamId && !confirm("Stop every Discord radio channel this node carries?")) return; setBusy(streamId ?? "all"); setActionError(null); try { await c2api.stopNodeDiscordStreams(nodeId, streamId); await load(); } catch (err) { setActionError(parseC2Error(err).message); } finally { setBusy(null); } } async function remove(s: DiscordStreamView) { const name = s.channel_name ?? "this channel"; if (!confirm(`Remove ${streamLabel(s)} from ${name} permanently? It is stopped first (the node logs its radio bot out), then deleted from DRB. Other streams in that voice channel keep playing.`)) return; setBusy(`rm:${s.stream_id}`); setActionError(null); try { await c2api.removeNodeDiscordStream(nodeId, s.stream_id); await load(); } catch (err) { setActionError(parseC2Error(err).message); } finally { setBusy(null); } } const streams = options?.streams ?? []; const canControl = options?.can_control ?? false; const canRemove = options?.can_remove ?? false; return (

Discord radio

Voice channels this node streams into. They reconnect by themselves; state is reported by the node. {canControl && ( {streams.length > 1 && ( )} )}
{loadError ? (

Couldn't load Discord radio: {loadError}

) : !options ? (

Loading…

) : streams.length === 0 ? (

Not streaming into any Discord voice channel. {!canControl && " The organization owner or this node's operator can add one."}

) : (
{(canControl || canRemove) && {streams.map((s) => ( {(canControl || canRemove) && ( )} ))}
Server · channel Mode Desired → actual}
🔊 {s.channel_name ?? s.channel_id}
{s.guild_name ?? s.guild_id} · {streamLabel(s)}
{modeLabel(s.mode)} {s.actual?.error && (
{s.actual.error} {s.actual.code && [{s.actual.code}]}
)}
{canControl && ( )} {canRemove && ( )}
)} {actionError &&

{actionError}

} {options && !options.node_online && streams.length > 0 && (

This node is offline — its channels resume if it reconnects shortly. If it stays down, ad-hoc ones stop and persistent ones move to another node on the same system.

)} {picking && options && ( setPicking(false)} onStarted={load} /> )}
); }