"use client"; import { useState } from "react"; import { c2api } from "@/lib/c2api"; import { type DiscordAdminOverview, type RadioBot, type RadioBotAddResult, fmtWhen, guildLabel, inviteForGuild, parseC2Error, shortId, } from "@/lib/discord"; import { Button } from "@/components/ui/Button"; import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui"; // Radio bots = the bot_tokens pool (routers/tokens.py). Used by the Discord // admin page's "Radio bots" tab and by /tokens. Every action here is a real // platform-admin route: POST /tokens, POST /tokens/{id}/refresh, // DELETE /tokens/{id}, POST /tokens/flush. function Checks({ t }: { t: RadioBot }) { if (t.valid === false) { return (
Token rejected by Discord {t.check_error &&
{t.check_error}
}
); } if (t.valid) { return (
Token valid
checked {fmtWhen(t.validated_at)}
{t.check_error &&
last check failed: {t.check_error}
}
); } return (
Not checked yet {t.check_error &&
{t.check_error}
}
); } function Membership({ t, overview }: { t: RadioBot; overview: DiscordAdminOverview | null }) { if (t.guild_ids == null) return unknown — refresh; const known = new Map((overview?.guilds ?? []).map((g) => [g.guild_id, g])); const members = t.guild_ids; // Servers this bot could serve (its own org's for a BYO bot, every one for the pool) but is not in. const missing = (overview?.guilds ?? []).filter( (g) => !members.includes(g.guild_id) && (t.org_id == null || g.acting_org_id === t.org_id), ); if (!members.length && !missing.length) return no servers; return (
{members.map((gid) => ( {known.has(gid) ? guildLabel(known.get(gid)!) : `server ${shortId(gid)}`} ))} {missing.map((g) => { const href = inviteForGuild(t.invite_url, g.guild_id); const label = `${guildLabel(g)} ✕`; return href ? ( {label} ) : ( {label} ); })}
); } function AddBotForm({ overview, onDone, onCancel, }: { overview: DiscordAdminOverview | null; onDone: (r: RadioBotAddResult) => void; onCancel: () => void; }) { const [name, setName] = useState(""); const [token, setToken] = useState(""); const [owner, setOwner] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); async function submit(e: React.FormEvent) { e.preventDefault(); setSaving(true); setError(null); try { const r = await c2api.addToken({ name: name.trim(), token: token.trim(), org_id: owner || null }); onDone(r); } catch (err) { setError(parseC2Error(err).message); } finally { setSaving(false); } } return (
setName(e.target.value)} required className={INPUT} placeholder="DRB Radio 2" /> setToken(e.target.value)} required type="password" autoComplete="off" className={`${INPUT} font-mono`} /> {error &&

{error}

}
); } export function RadioBotsPanel({ bots, overview, onChanged, }: { bots: RadioBot[]; overview: DiscordAdminOverview | null; onChanged: () => Promise | void; }) { const [adding, setAdding] = useState(false); const [added, setAdded] = useState(null); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const orgName = (id: string | null | undefined) => (overview?.orgs ?? []).find((o) => o.org_id === id)?.name ?? id ?? ""; const nodeName = (id: string) => overview?.node_names?.[id] ?? id; const systemName = (id: string) => (overview?.systems ?? []).find((s) => s.system_id === id)?.name ?? id; const fanOut = overview?.limits.node_max_streams ?? 4; async function act(key: string, fn: () => Promise) { setBusy(key); setError(null); try { await fn(); await onChanged(); } catch (err) { setError(parseC2Error(err).message); } finally { setBusy(null); } } const leased = bots.filter((b) => b.lease).length; return ( {leased > 0 && ( )} } note={ <> Release is ordered: the node stops the bot's gateway session, then acks, then the token returns to the pool — a token is never live on two nodes. Invite links ask for View Channel, Connect and Speak only. } > {adding && ( setAdding(false)} onDone={async (r) => { setAdding(false); setAdded(r); await onChanged(); }} /> )} {added && (
Added {added.name} {added.valid ? ` · token valid · in ${added.guild_ids?.length ?? 0} server(s)` : " · Discord could not be reached, membership unknown"} {added.check_error ? ` (${added.check_error})` : ""}. {added.invite_url && ( Invite it to a server )}
)} {error &&

{error}

} {bots.length === 0 ? (

No radio bots yet. Add one to let nodes carry radio into Discord voice channels.

) : ( {bots.map((t) => { const streamsHere = t.lease?.streams?.length ?? 0; return ( ); })}
{t.name}
{t.org_id ? `BYO · ${orgName(t.org_id)} only` : "platform pool"}
{t.token}
{t.preferred_for_system_id && (
preferred for {systemName(t.preferred_for_system_id)}
)}
{t.lease ? ( <> {nodeName(t.lease.node_id)}
{t.lease.releasing ? "releasing — waiting for the node to log out" : `since ${fmtWhen(t.lease.since)}`}
) : ( free )}
{t.lease ? ( <> {streamsHere} server{streamsHere === 1 ? "" : "s"}
{streamsHere} / {fanOut} per node
) : ( — )}
{t.invite_url && ( Invite link )}
)}
); }