Files
Logan CusanoandClaude Opus 5.5 9c124d4c8b S8: main DRB bot optional end to end + owner-flow integration tests (server-26#225)
- Copy: picker, admin Servers/Streams/How-it-works, org Discord card, bot
  /help and the server-limit refusal no longer imply the DRB server bot is
  required; a radio bot alone is enough.
- Admin overview: per-server main_bot_present, radio_bots, linked_via,
  directory_source; Servers tab gets a Bots column.
- discord_web docstring: states the reversed "never calls Discord" rule
  (plan finding 5) and the optional server bot.
- Server bot directory cog: re-push (which drops C2's cached permission
  answers) on overwrite changes, role permission changes and role deletes.
- tests/test_discord_owner_flows.py: one-bot org (BYO bot, owner-set
  preferred, OAuth link, picker, start, reconciler) and full setup (server
  bot + 4 radio bots: placement, shared channel, every refusal explained),
  against a modelled Discord over httpx.MockTransport.
- storage.download_audio removed: no caller on main since #233.

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

176 lines
7.7 KiB
TypeScript
Raw Permalink 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";
// Settings › Discord — the admin page from design-canvas/discord-system-mockup.html
// ("DRB admin" view), server-26#213 item 2. Desired state lives here; nodes
// reconcile and report back. Platform admin only for now: every route behind
// it (GET /discord/admin/overview, /tokens, PUT /discord/guilds/{id}/link) is
// require_admin_token. The mockup's "Alert routes" tab, incident-forum /
// roles / plan columns and per-channel talkgroup chips have no backend yet and
// are left out rather than shown as dead controls.
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { type DiscordAdminOverview, type RadioBot, parseC2Error, streamHealth } from "@/lib/discord";
import { ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonCard } from "@/components/ui/Skeleton";
import { StreamsPanel } from "@/components/discord/StreamsPanel";
import { ServersPanel } from "@/components/discord/ServersPanel";
import { RadioBotsPanel } from "@/components/discord/RadioBotsPanel";
import { Panel } from "@/components/discord/ui";
type Tab = "streams" | "guilds" | "bots" | "how";
const TABS: { key: Tab; label: string }[] = [
{ key: "streams", label: "Radio channels" },
{ key: "guilds", label: "Servers" },
{ key: "bots", label: "Radio bots" },
{ key: "how", label: "How it works" },
];
const POLL_MS = 10_000;
function Kpi({ k, v, s }: { k: string; v: string; s: string }) {
return (
<div className="bg-surface border border-line rounded-xl p-3">
<div className="text-xs text-ink-muted">{k}</div>
<div className="text-xl font-semibold text-ink mt-0.5">{v}</div>
<div className="text-xs text-ink-muted">{s}</div>
</div>
);
}
function HowItWorks() {
const steps = [
["1 · Desired", "A node page's picker, this page, or /join (DRB server bot) writes a discord_streams doc."],
["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server that Discord lets View, Connect and Speak in the channel — the system's preferred bot first — in one transaction."],
["3 · Reconcile", "A retained MQTT message tells the node its full channel list (token ids, never token strings)."],
["4 · Voice", "The node logs in as the leased bot and plays its PulseAudio capture — never Icecast."],
["5 · Actual", "The node reports each channel's real state; this page and the Discord reply show it."],
];
return (
<Panel
title="How a radio channel stays up"
note="Node reboot, C2 sweep, Discord voice drop: the node re-reads its desired state and reconnects; persistent channels whose node went away are re-placed on another node. A channel desired-on but not connected for 2 minutes turns red on the Radio channels tab."
>
<p className="px-4 pt-4 text-xs text-ink-2">
<b className="text-ink">The DRB server bot is optional.</b> One radio bot in a Discord server is enough: adding it
through <i>Connect a Discord server</i> (Settings → Organization) links the server, and C2 reads its voice
channels and permissions with that bot. The server bot adds the slash commands (/join, /leave, /status,
/drb link) and pushes its own channel list; both ways keep working side by side.
</p>
<div className="grid gap-2 p-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
{steps.map(([t, d]) => (
<div key={t} className="bg-raised border border-line rounded-lg p-3 text-xs text-ink-muted">
<b className="block text-ink text-sm mb-1">{t}</b>
{d}
</div>
))}
</div>
</Panel>
);
}
export default function DiscordSettingsPage() {
const { isAdmin, loading: authLoading } = useAuth();
const router = useRouter();
const [tab, setTab] = useState<Tab>("streams");
const [overview, setOverview] = useState<DiscordAdminOverview | null>(null);
const [bots, setBots] = useState<RadioBot[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!authLoading && !isAdmin) router.replace("/settings/organization");
}, [authLoading, isAdmin, router]);
useEffect(() => {
const h = window.location.hash.slice(1);
if (TABS.some((t) => t.key === h)) setTab(h as Tab);
}, []);
const load = useCallback(async () => {
try {
const [o, t] = await Promise.all([c2api.getDiscordAdmin(), c2api.getTokens()]);
setOverview(o);
setBots(t as RadioBot[]);
setError(null);
} catch (err) {
setError(parseC2Error(err).message);
}
}, []);
useEffect(() => {
if (!isAdmin) return;
load();
// Actual state is reported by nodes; poll while the tab is visible.
const id = setInterval(() => {
if (document.visibilityState === "visible") load();
}, POLL_MS);
return () => clearInterval(id);
}, [isAdmin, load]);
// Every hook runs before this guard (React error #310 — see app/nodes/page.tsx).
if (authLoading || !isAdmin) return null;
const linked = overview?.guilds.filter((g) => g.linked).length ?? 0;
const unlinked = (overview?.guilds.length ?? 0) - linked;
const live = overview?.streams.filter((s) => s.desired === "on") ?? [];
const connected = live.filter((s) => streamHealth(s).health === "ok").length;
const stuck = live.filter((s) => streamHealth(s).health === "bad").length;
const leased = bots.filter((b) => b.lease).length;
const byo = bots.filter((b) => b.org_id).length;
const attention = bots.filter((b) => b.valid === false || b.guild_ids == null).length;
return (
<div className="space-y-4">
<div>
<h2 className="text-ink font-semibold text-lg">Discord</h2>
<p className="text-ink-muted text-sm mt-0.5">
Desired state lives here; nodes reconcile and report back. Voice audio comes from each node&apos;s PulseAudio
monitor, never Icecast.
</p>
</div>
{error && <ErrorBanner message={error} />}
{!overview && !error ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)}
</div>
) : overview ? (
<>
<div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
<Kpi k="Linked servers" v={String(linked)} s={unlinked ? `${unlinked} seen but unlinked` : "all linked"} />
<Kpi k="Radio channels" v={`${connected} / ${live.length}`} s={stuck ? `connected / desired · ${stuck} failing` : "connected / desired"} />
<Kpi k="Radio bots leased" v={`${leased} / ${bots.length}`} s={byo ? `pool + ${byo} BYO` : "platform pool"} />
<Kpi k="Bots needing a check" v={String(attention)} s="rejected or membership unknown" />
</div>
<div className="flex gap-1 border-b border-line overflow-x-auto">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => {
setTab(t.key);
history.replaceState(null, "", `#${t.key}`);
}}
className={`px-3 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${
tab === t.key ? "border-accent text-ink" : "border-transparent text-ink-muted hover:text-ink"
}`}
>
{t.label}
</button>
))}
</div>
{tab === "streams" && <StreamsPanel overview={overview} bots={bots} onChanged={load} />}
{tab === "guilds" && <ServersPanel overview={overview} onChanged={load} />}
{tab === "bots" && <RadioBotsPanel bots={bots} overview={overview} onChanged={load} />}
{tab === "how" && <HowItWorks />}
</>
) : null}
</div>
);
}