diff --git a/drb-c2-core/app/routers/discord.py b/drb-c2-core/app/routers/discord.py index 3b010b6..3ae3258 100644 --- a/drb-c2-core/app/routers/discord.py +++ b/drb-c2-core/app/routers/discord.py @@ -107,6 +107,9 @@ class StreamStart(BaseModel): @router.post("/streams") async def start_stream(body: StreamStart, _: dict = Depends(require_service_key_or_admin)): + # The bot always sends snowflakes; the web admin page types them in. + if not (str(body.guild_id).isdigit() and str(body.channel_id).isdigit()): + raise HTTPException(400, "guild_id and channel_id must be Discord ids (digits only).") org_id = await _org_or_403(body.guild_id) system = await fstore.doc_get_cached("systems", body.system_id) if not system or system.get("org_id") not in (None, org_id): @@ -144,18 +147,103 @@ async def stop_guild(guild_id: str, body: GuildStop, _: dict = Depends(require_s class GuildLink(BaseModel): org_id: str + # Display label only (the admin page); Discord never reads it. + guild_name: Optional[str] = None @router.put("/guilds/{guild_id}/link") async def link_guild(guild_id: str, body: GuildLink, decoded: dict = Depends(require_admin_token)): + if not guild_id.isdigit(): + raise HTTPException(400, "guild_id must be a Discord server id (digits only).") org = await fstore.doc_get("organizations", body.org_id) if not org: raise HTTPException(404, "Organization not found.") - await fstore.doc_set(streams.GUILDS, guild_id, { + previous_org = await streams.resolve_guild_org(guild_id) + doc = { "guild_id": guild_id, "org_id": body.org_id, "linked_by": decoded.get("uid"), "linked_at": datetime.now(timezone.utc).isoformat(), - }) - return {"ok": True, "guild_id": guild_id, "org_id": body.org_id} + } + if body.guild_name and body.guild_name.strip(): + doc["guild_name"] = body.guild_name.strip()[:100] + await fstore.doc_set(streams.GUILDS, guild_id, doc) + # Live channels in this server were placed on the previous org's nodes; + # left running, the reconciler would keep re-placing them there, so one + # org's equipment would keep serving a server that now belongs to another. + stopped: list[str] = [] + if previous_org and previous_org != body.org_id: + stopped = await streams.stop_guild(guild_id, "server linked to another organization") + return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": stopped} + + +# --------------------------------------------------------------------------- +# Admin view — the web Discord page (server-26#213 item 2) +# --------------------------------------------------------------------------- + +@router.get("/admin/overview") +async def admin_overview(_: dict = Depends(require_admin_token)): + """Every Discord server DRB knows about, every radio channel (desired vs + actual) and what the page needs to act on them. Platform admin only. + + A server is "known" when it is linked (``discord_guilds``) or when it has + ever had a radio channel — an unlinked server that ran ``/join`` acts for + ``DISCORD_UNLINKED_GUILD_ORG_ID`` and should be visible so it can be linked. + Radio bots come from ``GET /tokens``; no token string is returned here. + Discord user ids (``created_by_discord_id``) and the linking admin's uid + are deliberately left out. + """ + links = {str(g.get("guild_id")): g for g in await fstore.collection_list(streams.GUILDS) + if g.get("guild_id")} + all_streams = await fstore.collection_list(streams.STREAMS) + orgs = {o.get("org_id"): o for o in await fstore.collection_list("organizations") if o.get("org_id")} + nodes = {n.get("node_id"): n for n in await fstore.collection_list("nodes") if n.get("node_id")} + tokens = {t.get("token_id"): t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")} + fallback = settings.discord_unlinked_guild_org_id or None + + guilds: dict[str, dict] = {} + for gid, link in links.items(): + guilds[gid] = {"guild_id": gid, "guild_name": link.get("guild_name"), "linked": bool(link.get("org_id")), + "org_id": link.get("org_id"), "linked_at": link.get("linked_at"), + # Explicitly unlinked servers get no fallback org (mirrors + # streams.resolve_guild_org once server-26#218 lands). + "_no_fallback": bool(link.get("unlinked_at"))} + rows = [] + for s in all_streams: + gid = str(s.get("guild_id") or leases.guild_of(s.get("stream_id") or "")) + g = guilds.setdefault(gid, {"guild_id": gid, "guild_name": None, "linked": False, + "org_id": None, "linked_at": None}) + if s.get("guild_name") and not g.get("guild_name"): + g["guild_name"] = s["guild_name"] + node = nodes.get(s.get("node_id")) or {} + tok = tokens.get(s.get("token_id")) or {} + rows.append({**_public_stream(s), "guild_id": gid, "org_id": s.get("org_id"), "guild_name": s.get("guild_name"), + "created_at": s.get("created_at"), "node_name": node.get("name"), + "node_status": node.get("status"), "token_name": tok.get("name")}) + for g in guilds.values(): + no_fallback = g.pop("_no_fallback", False) + acting = g["org_id"] if g["linked"] else (None if no_fallback else fallback) + g["acting_org_id"] = acting + g["acting_org_name"] = (orgs.get(acting) or {}).get("name") if acting else None + g["live_streams"] = sum(1 for r in rows if r.get("guild_id") == g["guild_id"] and r.get("desired") == "on") + + rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "", + r.get("channel_name") or r.get("channel_id") or "")) + systems = await fstore.collection_list("systems") + # Names for every node a stream or a radio-bot lease points at. + referenced = {r.get("node_id") for r in rows} | {(t.get("lease") or {}).get("node_id") for t in tokens.values()} + node_names = {nid: nodes[nid].get("name") or nid for nid in referenced if nid in nodes} + return { + "guilds": sorted(guilds.values(), key=lambda g: (not g["linked"], g.get("guild_name") or g["guild_id"])), + "streams": rows, + "orgs": sorted(({"org_id": oid, "name": o.get("name") or oid} for oid, o in orgs.items()), + key=lambda o: o["name"].lower()), + "node_names": node_names, + "systems": [{"system_id": s.get("system_id"), "name": s.get("name"), "org_id": s.get("org_id")} + for s in systems if s.get("system_id")], + "limits": {"guild_max_streams": settings.discord_guild_max_streams, + "node_max_streams": settings.discord_node_max_streams, + "unlinked_guild_org_id": fallback, + "unlinked_guild_org_name": (orgs.get(fallback) or {}).get("name") if fallback else None}, + } # --------------------------------------------------------------------------- diff --git a/drb-c2-core/tests/test_discord_admin.py b/drb-c2-core/tests/test_discord_admin.py new file mode 100644 index 0000000..e935988 --- /dev/null +++ b/drb-c2-core/tests/test_discord_admin.py @@ -0,0 +1,129 @@ +""" +Web Discord admin page backend (server-26#213 item 2): GET /discord/admin/overview, +plus the id validation the page relies on for PUT /discord/guilds/{id}/link and +POST /discord/streams. +""" +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.config import settings +from app.internal.auth import require_admin_token, require_service_key_or_admin +from app.main import app +from tests.fake_firestore import FakeFirestore + +client = TestClient(app) +ADMIN = {"uid": "admin-1", "role": "admin"} + + +@pytest.fixture +def fs(monkeypatch): + fake = FakeFirestore.install(monkeypatch) + monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding") + yield fake + app.dependency_overrides.clear() + + +def _seed(fake): + fake.put("organizations", "founding", {"org_id": "founding", "name": "Founding"}) + fake.put("organizations", "org-b", {"org_id": "org-b", "name": "Bravo Fire"}) + fake.put("discord_guilds", "111", {"guild_id": "111", "org_id": "org-b", "linked_by": "uid-x", + "linked_at": "2026-10-01T00:00:00+00:00", "guild_name": "Bravo Server"}) + fake.put("nodes", "n1", {"node_id": "n1", "name": "Node One", "status": "online", "org_id": "org-b"}) + fake.put("bot_tokens", "t1", {"token_id": "t1", "name": "Radio 1", "token": "secret-t1"}) + fake.put("systems", "s1", {"system_id": "s1", "name": "County P25", "org_id": "org-b"}) + fake.put("discord_streams", "111:900", { + "stream_id": "111:900", "guild_id": "111", "channel_id": "900", "channel_name": "Dispatch", + "org_id": "org-b", "system_id": "s1", "mode": "persistent", "desired": "on", "node_id": "n1", + "token_id": "t1", "actual": {"state": "connected"}, "created_by_discord_id": "4242", + }) + # A server that ran /join before anyone linked it — acts for the fallback org. + fake.put("discord_streams", "222:901", { + "stream_id": "222:901", "guild_id": "222", "channel_id": "901", "guild_name": "Unlinked Server", + "org_id": "founding", "system_id": "s1", "mode": "adhoc", "desired": "off", + "actual": {"state": "stopping"}, + }) + + +def test_overview_requires_platform_admin(fs): + def deny(): + raise HTTPException(403, "Admin access required") + app.dependency_overrides[require_admin_token] = deny + assert client.get("/discord/admin/overview").status_code == 403 + + +def test_overview_lists_linked_and_seen_guilds_streams_and_limits(fs): + _seed(fs) + app.dependency_overrides[require_admin_token] = lambda: ADMIN + body = client.get("/discord/admin/overview").json() + + guilds = {g["guild_id"]: g for g in body["guilds"]} + assert guilds["111"]["linked"] is True and guilds["111"]["acting_org_name"] == "Bravo Fire" + assert guilds["111"]["live_streams"] == 1 and guilds["111"]["guild_name"] == "Bravo Server" + # unlinked but seen: shown, acting for the fallback org, name taken from its stream + assert guilds["222"]["linked"] is False and guilds["222"]["acting_org_id"] == "founding" + assert guilds["222"]["guild_name"] == "Unlinked Server" and guilds["222"]["live_streams"] == 0 + assert [g["guild_id"] for g in body["guilds"]] == ["111", "222"] # linked first + + s = body["streams"][0] + assert s["stream_id"] == "111:900" and s["node_name"] == "Node One" and s["token_name"] == "Radio 1" + assert s["actual"]["state"] == "connected" + assert body["streams"][1]["desired"] == "off" # live channels first + + text = repr(body) + assert "secret-t1" not in text # no token strings + assert "4242" not in text # no Discord user ids + assert "uid-x" not in text # no linking-admin uid + assert body["limits"]["unlinked_guild_org_name"] == "Founding" + assert {o["org_id"] for o in body["orgs"]} == {"founding", "org-b"} + assert body["node_names"] == {"n1": "Node One"} + assert body["systems"] == [{"system_id": "s1", "name": "County P25", "org_id": "org-b"}] + + +def test_link_rejects_non_snowflake_and_stores_label(fs): + _seed(fs) + app.dependency_overrides[require_admin_token] = lambda: ADMIN + assert client.put("/discord/guilds/abc/link", json={"org_id": "org-b"}).status_code == 400 + r = client.put("/discord/guilds/333/link", json={"org_id": "org-b", "guild_name": " Charlie "}) + assert r.status_code == 200 + assert fs.get("discord_guilds", "333")["guild_name"] == "Charlie" + assert client.put("/discord/guilds/333/link", json={"org_id": "nope"}).status_code == 404 + + +def test_start_stream_rejects_non_snowflake_ids(fs): + app.dependency_overrides[require_service_key_or_admin] = lambda: ADMIN + r = client.post("/discord/streams", json={"guild_id": "111", "channel_id": "general", "system_id": "s1"}) + assert r.status_code == 400 + + +def test_relinking_a_server_to_another_org_stops_its_live_channels(fs, monkeypatch): + from app.internal import discord_streams as streams + calls = [] + + async def _stop_guild(guild_id, reason, channel_id=None): + calls.append((guild_id, reason)) + return [f"{guild_id}:900"] + monkeypatch.setattr(streams, "stop_guild", _stop_guild) + _seed(fs) + app.dependency_overrides[require_admin_token] = lambda: ADMIN + + # same org again: nothing stopped + assert client.put("/discord/guilds/111/link", json={"org_id": "org-b"}).json()["stopped"] == [] + # unlinked server (acting for the fallback org) linked to that same org: nothing stopped + assert client.put("/discord/guilds/222/link", json={"org_id": "founding"}).json()["stopped"] == [] + assert calls == [] + # moved to a different org: its live channels are stopped + r = client.put("/discord/guilds/111/link", json={"org_id": "founding"}).json() + assert r["stopped"] == ["111:900"] and calls == [("111", "server linked to another organization")] + + +def test_explicitly_unlinked_server_gets_no_fallback_org(fs): + # server-26#218 unlinks by clearing org_id and stamping unlinked_at; + # resolve_guild_org then refuses the fallback, so the page must not offer it. + _seed(fs) + fs.put("discord_guilds", "444", {"guild_id": "444", "org_id": None, "unlinked_at": "2026-10-02T00:00:00+00:00"}) + app.dependency_overrides[require_admin_token] = lambda: ADMIN + guilds = {g["guild_id"]: g for g in client.get("/discord/admin/overview").json()["guilds"]} + assert guilds["444"]["linked"] is False and guilds["444"]["acting_org_id"] is None + assert "_no_fallback" not in guilds["444"] + assert guilds["222"]["acting_org_id"] == "founding" # never-linked server keeps the fallback diff --git a/drb-frontend/app/network/page.tsx b/drb-frontend/app/network/page.tsx index f207686..d4d90f6 100644 --- a/drb-frontend/app/network/page.tsx +++ b/drb-frontend/app/network/page.tsx @@ -91,13 +91,17 @@ export default function NetworkPage() { count={systemsLoading ? null : systems.length} countLabel={systemsLoading ? "" : "configured"} /> - + {/* The radio-bot pool is platform-admin only (server-26#204 item 6); + an operator following this card only hit a 403 (server-26#213). */} + {isAdmin && ( + + )} ); diff --git a/drb-frontend/app/settings/discord/page.tsx b/drb-frontend/app/settings/discord/page.tsx new file mode 100644 index 0000000..067da9f --- /dev/null +++ b/drb-frontend/app/settings/discord/page.tsx @@ -0,0 +1,169 @@ +"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 ( +
+
{k}
+
{v}
+
{s}
+
+ ); +} + +function HowItWorks() { + const steps = [ + ["1 · Desired", "/join in Discord or this page writes a discord_streams doc."], + ["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server, 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 ( + +
+ {steps.map(([t, d]) => ( +
+ {t} + {d} +
+ ))} +
+
+ ); +} + +export default function DiscordSettingsPage() { + const { isAdmin, loading: authLoading } = useAuth(); + const router = useRouter(); + const [tab, setTab] = useState("streams"); + const [overview, setOverview] = useState(null); + const [bots, setBots] = useState([]); + const [error, setError] = useState(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 ( +
+
+

Discord

+

+ Desired state lives here; nodes reconcile and report back. Voice audio comes from each node's PulseAudio + monitor, never Icecast. +

+
+ + {error && } + + {!overview && !error ? ( +
+ {Array.from({ length: 4 }).map((_, i) => )} +
+ ) : overview ? ( + <> +
+ + + + +
+ +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === "streams" && } + {tab === "guilds" && } + {tab === "bots" && } + {tab === "how" && } + + ) : null} +
+ ); +} diff --git a/drb-frontend/app/settings/layout.tsx b/drb-frontend/app/settings/layout.tsx index 2b16ed7..adb46bd 100644 --- a/drb-frontend/app/settings/layout.tsx +++ b/drb-frontend/app/settings/layout.tsx @@ -14,6 +14,10 @@ const TABS = [ { href: "/settings/billing", label: "Billing" }, ]; +// Platform-admin-only tabs: every route behind them is require_admin_token, +// so an org owner would only see 403s (server-26#213 item 2). +const ADMIN_TABS = [{ href: "/settings/discord", label: "Discord" }]; + export default function SettingsLayout({ children }: { children: React.ReactNode }) { // SAAS_PLAN.md B7: this used to gate on isAdmin (platform admin) alone, // which meant a paying customer who is their own org's owner couldn't @@ -40,7 +44,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode />
- {TABS.map((t) => ( + {[...TABS, ...(isAdmin ? ADMIN_TABS : [])].map((t) => ( (null); const [saving, setSaving] = useState(false); const [open, setOpen] = useState(false); + const [error, setError] = useState(null); async function load() { if (tokens !== null) return; try { const data = await c2api.getTokens(); setTokens(data as TokenOption[]); - } catch { + } catch (err) { + // An error used to render as "No tokens in pool." — a false empty state. + setError(err instanceof Error ? err.message : "Could not load radio bots."); setTokens([]); } } @@ -1241,6 +1244,8 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i try { await c2api.setPreferredToken(tokenId, systemId); setPreferredId(tokenId); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not save the preference."); } finally { setSaving(false); } @@ -1252,6 +1257,8 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i try { await c2api.setPreferredToken(preferredId, "_none"); setPreferredId(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not clear the preference."); } finally { setSaving(false); } @@ -1276,6 +1283,8 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
{tokens === null ? (

Loading tokens…

+ ) : error ? ( +

{error}

) : tokens.length === 0 ? (

No tokens in pool.

) : ( @@ -2016,7 +2025,10 @@ export default function SystemsPage() { Delete
- + {/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only + (server-26#204 item 6), so for anyone else this panel could + neither list the pool nor save a choice (server-26#213 item 4). */} + {isAdmin && } diff --git a/drb-frontend/app/tokens/page.tsx b/drb-frontend/app/tokens/page.tsx index 9a6611f..16295d8 100644 --- a/drb-frontend/app/tokens/page.tsx +++ b/drb-frontend/app/tokens/page.tsx @@ -1,181 +1,62 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +// The radio-bot pool (bot_tokens). Same table as Settings › Discord › Radio +// bots: validity, server membership, invite links, lease, and the +// POST /tokens/{id}/refresh re-check (server-26#213 item 2). Platform admin +// only: every /tokens route is require_admin_token since server-26#204 item 6. + +import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { c2api } from "@/lib/c2api"; import { useAuth } from "@/components/AuthProvider"; - -interface TokenRecord { - token_id: string; - name: string; - token: string; // masked server-side - in_use: boolean; - assigned_node_id: string | null; - assigned_at: string | null; -} +import { type DiscordAdminOverview, type RadioBot, parseC2Error } from "@/lib/discord"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { LinkButton } from "@/components/ui/Button"; +import { ErrorBanner } from "@/components/ui/EmptyState"; +import { SkeletonCard } from "@/components/ui/Skeleton"; +import { RadioBotsPanel } from "@/components/discord/RadioBotsPanel"; export default function TokensPage() { - const { isAdmin, isOperator, loading: authLoading } = useAuth(); + const { isAdmin, loading: authLoading } = useAuth(); const router = useRouter(); - const [tokens, setTokens] = useState([]); - const [loading, setLoading] = useState(true); - const [showAdd, setShowAdd] = useState(false); - const [name, setName] = useState(""); - const [token, setToken] = useState(""); - const [saving, setSaving] = useState(false); + const [bots, setBots] = useState(null); + const [overview, setOverview] = useState(null); const [error, setError] = useState(null); useEffect(() => { - if (!authLoading && !isAdmin && !isOperator) router.replace("/"); - }, [authLoading, isAdmin, isOperator, router]); + if (!authLoading && !isAdmin) router.replace("/network"); + }, [authLoading, isAdmin, router]); const refresh = useCallback(async () => { try { - const data = await c2api.getTokens(); - setTokens(data as TokenRecord[]); - } finally { - setLoading(false); + const t = await c2api.getTokens(); + setBots(t as RadioBot[]); + setError(null); + } catch (err) { + setError(parseC2Error(err).message); + setBots((b) => b ?? []); + } + // Server names for the membership column; the table still works without them. + try { + setOverview(await c2api.getDiscordAdmin()); + } catch { + /* membership falls back to raw server ids */ } }, []); - useEffect(() => { refresh(); }, [refresh]); + useEffect(() => { if (isAdmin) refresh(); }, [isAdmin, refresh]); - async function handleAdd(e: React.FormEvent) { - e.preventDefault(); - setSaving(true); - setError(null); - try { - await c2api.addToken({ name, token }); - setName(""); - setToken(""); - setShowAdd(false); - await refresh(); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to add token."); - } finally { - setSaving(false); - } - } - - async function handleDelete(id: string) { - if (!confirm("Remove this token from the pool?")) return; - try { - await c2api.deleteToken(id); - await refresh(); - } catch (err) { - alert(err instanceof Error ? err.message : "Delete failed."); - } - } - - if (authLoading || (!isAdmin && !isOperator)) return null; + if (authLoading || !isAdmin) return null; return (
-
-
-

Bot Token Pool

-

- Discord bot tokens assigned to nodes when they join a voice channel. -

-
- -
- - {showAdd && ( -
-

Add Token

-
- - setName(e.target.value)} - required - className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" - placeholder="DRB Bot 1" - /> -
-
- - setToken(e.target.value)} - required - type="password" - className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" - placeholder="MTxxxxxxxxxx.Gxxxxx.xxxxxxxxxx" - /> -
- {error &&

{error}

} -
- - -
-
- )} - - {loading ? ( -

Loading…

- ) : tokens.length === 0 ? ( -

No tokens in the pool. Add one to enable Discord voice streaming.

- ) : ( -
- - - - - - - - - - - - {tokens.map((t) => ( - - - - - - - - ))} - -
LabelTokenStatusNode
{t.name}{t.token} - {t.in_use ? ( - In use - ) : ( - Free - )} - - {t.assigned_node_id ?? "—"} - - -
-
- )} + Discord settings} + /> + {error && } + {bots === null ? : }
); } diff --git a/drb-frontend/components/discord/RadioBotsPanel.tsx b/drb-frontend/components/discord/RadioBotsPanel.tsx new file mode 100644 index 0000000..17d4616 --- /dev/null +++ b/drb-frontend/components/discord/RadioBotsPanel.tsx @@ -0,0 +1,325 @@ +"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 + + )} + +
+
+ )} +
+ ); +} diff --git a/drb-frontend/components/discord/ServersPanel.tsx b/drb-frontend/components/discord/ServersPanel.tsx new file mode 100644 index 0000000..b7932b6 --- /dev/null +++ b/drb-frontend/components/discord/ServersPanel.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState } from "react"; +import { c2api } from "@/lib/c2api"; +import { type DiscordAdminOverview, type DiscordGuild, SNOWFLAKE, fmtWhen, guildLabel, parseC2Error } from "@/lib/discord"; +import { Button } from "@/components/ui/Button"; +import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui"; + +// Linked Discord servers = discord_guilds docs, plus any server that has had a +// radio channel without being linked (it acts for DISCORD_UNLINKED_GUILD_ORG_ID). +// Linking is PUT /discord/guilds/{id}/link (platform admin). The owner-side +// "/drb link code:" flow is server-26#213 item 1 and is not built here. + +function LinkForm({ + overview, + guild, + onDone, + onCancel, +}: { + overview: DiscordAdminOverview; + guild?: DiscordGuild; + onDone: () => void; + onCancel: () => void; +}) { + const [guildId, setGuildId] = useState(guild?.guild_id ?? ""); + const [label, setLabel] = useState(guild?.guild_name ?? ""); + const [orgId, setOrgId] = useState(guild?.org_id ?? guild?.acting_org_id ?? ""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!SNOWFLAKE.test(guildId.trim())) { + setError("Server ID is the 17–20 digit number from Discord (right-click the server icon → Copy Server ID)."); + return; + } + const actingNow = guild?.acting_org_id ?? null; + if (guild && guild.live_streams > 0 && actingNow && actingNow !== orgId) { + const n = guild.live_streams; + if (!confirm(`${guildLabel(guild)} has ${n} live radio channel${n === 1 ? "" : "s"} on the current organization's nodes. Moving it stops ${n === 1 ? "it" : "them"}. Continue?`)) return; + } + setSaving(true); + setError(null); + try { + await c2api.linkDiscordGuild(guildId.trim(), { org_id: orgId, guild_name: label.trim() || undefined }); + onDone(); + } catch (err) { + setError(parseC2Error(err).message); + } finally { + setSaving(false); + } + } + + return ( +
+ + setGuildId(e.target.value)} + required + readOnly={!!guild} + inputMode="numeric" + className={`${INPUT} font-mono ${guild ? "opacity-70" : ""}`} + /> + + + setLabel(e.target.value)} className={INPUT} placeholder="Fire Co. 7" /> + + + + + {error &&

{error}

} +
+ + +
+
+ ); +} + +export function ServersPanel({ + overview, + onChanged, +}: { + overview: DiscordAdminOverview; + onChanged: () => Promise | void; +}) { + // "new" = blank link form; a guild id = editing that row's link. + const [editing, setEditing] = useState(null); + const fallback = overview.limits.unlinked_guild_org_id; + const fallbackName = overview.limits.unlinked_guild_org_name || fallback; + const max = overview.limits.guild_max_streams; + + const done = async () => { + setEditing(null); + await onChanged(); + }; + + return ( + setEditing(editing === "new" ? null : "new")}>+ Link a server} + note={ + fallback ? ( + <> + Unlinked servers act for {fallbackName} (DISCORD_UNLINKED_GUILD_ORG_ID). Any + server the DRB bot is invited to can use that org's systems until it is linked here. Set the variable + to an empty string once every real server is linked. + + ) : ( + <>Unlinked servers can't use radio commands until they are linked here. + ) + } + > + {editing === "new" && setEditing(null)} />} + {overview.guilds.length === 0 ? ( +

No Discord servers yet. Link one, or run a radio command in a server the DRB bot is in.

+ ) : ( + + {overview.guilds.flatMap((g) => { + const row = ( + + + + + + + + + ); + if (editing !== g.guild_id) return [row]; + return [ + row, + + + , + ]; + })} +
+ {guildLabel(g)} +
{g.guild_id}
+
+ {g.acting_org_name || g.acting_org_id || nobody} + {!g.linked && g.acting_org_id &&
fallback, not linked
} +
+ {g.live_streams} / {max} + {g.linked ? fmtWhen(g.linked_at) : —} + {g.linked ? ( + Linked + ) : g.acting_org_id ? ( + Unlinked · fallback org + ) : ( + Unlinked + )} + + +
+ setEditing(null)} /> +
+ )} +
+ ); +} diff --git a/drb-frontend/components/discord/StreamsPanel.tsx b/drb-frontend/components/discord/StreamsPanel.tsx new file mode 100644 index 0000000..7acb8d9 --- /dev/null +++ b/drb-frontend/components/discord/StreamsPanel.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { 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 { Button } from "@/components/ui/Button"; +import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui"; + +// 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. + +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<{ message: string; invite: Invite } | null>(null); + + 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)); + + 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: [] }); + return; + } + setSaving(true); + setError(null); + try { + await c2api.startDiscordStream({ + guild_id: guildId, + channel_id: channelId.trim(), + system_id: systemId, + mode, + channel_name: channelName.trim() || undefined, + preferred_token_id: botId || undefined, + }); + onDone(); + } catch (err) { + setError(parseC2Error(err)); + } 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" /> + + + + + + + + + + + {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]); + + 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"; + 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 })}
+
{source(s)}All talkgroups{s.mode === "adhoc" ? "ad-hoc" : s.mode ?? "—"} + {label} + {isOn && s.actual?.error &&
{s.actual.error}
} + {!isOn && s.stop_reason &&
{s.stop_reason}
} + {err && ( +
+ {err.message} + +
+ )} +
{s.token_name ?? —} + {isOn ? ( + + ) : ( + + )} +
+ )} +
+ ); +} diff --git a/drb-frontend/components/discord/ui.tsx b/drb-frontend/components/discord/ui.tsx new file mode 100644 index 0000000..ffe468b --- /dev/null +++ b/drb-frontend/components/discord/ui.tsx @@ -0,0 +1,98 @@ +import type { ReactNode } from "react"; +import type { Health } from "@/lib/discord"; + +// Shared pieces for the Discord admin page (design-canvas/discord-system-mockup.html, +// "DRB admin" view): panels with a title row, dense tables, status dots. + +const DOT: Record = { + ok: "bg-green-500", + warn: "bg-sev-moderate", + bad: "bg-sev-major", + off: "bg-ink-muted", +}; + +export function Status({ health, children }: { health: Health; children: ReactNode }) { + return ( + + + {children} + + ); +} + +export function Panel({ + title, + description, + action, + note, + children, +}: { + title: string; + description?: ReactNode; + action?: ReactNode; + note?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+

{title}

+ {description && {description}} + {action &&
{action}
} +
+ {children} + {note &&
{note}
} +
+ ); +} + +export const TH = "text-left font-medium text-ink-muted text-xs px-4 py-2 border-b border-line whitespace-nowrap"; +export const TD = "px-4 py-2.5 border-b border-line align-top text-sm text-ink"; +export const DIM = "text-xs text-ink-muted"; + +export function Table({ head, children }: { head: string[]; children: ReactNode }) { + return ( +
+ + + + {head.map((h, i) => ( + + ))} + + + {children} +
{h}
+
+ ); +} + +export function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: ReactNode }) { + return ( + + ); +} + +export const INPUT = + "w-full bg-page border border-line rounded-lg px-3 py-2 text-ink text-sm focus:outline-none focus:border-accent"; + +export function InviteLinks({ invite }: { invite: { name: string; invite_url: string | null }[] }) { + if (!invite.length) return null; + return ( + + {invite.map((b) => + b.invite_url ? ( + + Invite {b.name} + + ) : ( + {b.name} (no invite link — refresh it under Radio bots) + ), + )} + + ); +} diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index c1dd9a3..1f86af2 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -1,5 +1,6 @@ import { auth } from "@/lib/firebase"; import type { AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, TalkgroupPending } from "@/lib/types"; +import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord"; const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000"; @@ -55,12 +56,39 @@ export const c2api = { deleteSystem: (id: string) => request(`/systems/${id}`, { method: "DELETE" }), - // Tokens + // Tokens (radio bots) — every route is platform-admin only (server-26#204 item 6) getTokens: () => request("/tokens"), - addToken: (body: { name: string; token: string }) => - request("/tokens", { method: "POST", body: JSON.stringify(body) }), + addToken: (body: { name: string; token: string; org_id?: string | null }) => + request("/tokens", { method: "POST", body: JSON.stringify(body) }), deleteToken: (id: string) => request(`/tokens/${id}`, { method: "DELETE" }), + /** Re-check one radio bot with Discord: token valid? which servers is it in? */ + refreshToken: (id: string) => + request(`/tokens/${id}/refresh`, { method: "POST" }), + /** Admin last resort: force-release every radio-bot lease. */ + flushTokens: () => + request<{ released: number }>("/tokens/flush", { method: "POST" }), + + // Discord radio channels — web admin page (server-26#213 item 2) + getDiscordAdmin: () => request("/discord/admin/overview"), + linkDiscordGuild: (guildId: string, body: { org_id: string; guild_name?: string }) => + request<{ ok: boolean; stopped: string[] }>(`/discord/guilds/${encodeURIComponent(guildId)}/link`, { + method: "PUT", + body: JSON.stringify(body), + }), + startDiscordStream: (body: { + guild_id: string; + channel_id: string; + system_id: string; + mode: "persistent" | "adhoc"; + channel_name?: string; + preferred_token_id?: string; + }) => request("/discord/streams", { method: "POST", body: JSON.stringify(body) }), + stopDiscordStream: (guildId: string, channelId: string) => + request<{ stopped: string[] }>(`/discord/guilds/${encodeURIComponent(guildId)}/stop`, { + method: "POST", + body: JSON.stringify({ channel_id: channelId, reason: "stopped from web admin" }), + }), // Node approval approveNode: (id: string) => diff --git a/drb-frontend/lib/discord.ts b/drb-frontend/lib/discord.ts new file mode 100644 index 0000000..e86639d --- /dev/null +++ b/drb-frontend/lib/discord.ts @@ -0,0 +1,161 @@ +// Discord radio channels + radio-bot pool — shapes returned by c2-core +// (routers/discord.py GET /discord/admin/overview, routers/tokens.py), and +// small helpers shared by /settings/discord and /tokens (server-26#213). + +export interface RadioBotLease { + node_id: string; + lease_id?: string; + since?: string; + streams?: string[]; + releasing?: boolean; +} + +/** One bot_tokens doc as GET /tokens returns it — token string masked server-side. */ +export interface RadioBot { + token_id: string; + name: string; + token: string; + org_id?: string | null; // null = platform pool, else that org's BYO bot + valid?: boolean | null; // null/undefined = never checked (Discord unreachable) + guild_ids?: string[] | null; // null = membership unknown + check_error?: string | null; + validated_at?: string | null; + application_id?: string | null; + bot_name?: string | null; + invite_url?: string | null; + lease?: RadioBotLease | null; + in_use?: boolean; + assigned_node_id?: string | null; + preferred_for_system_id?: string | null; +} + +export interface RadioBotAddResult { + token_id: string; + name: string; + valid: boolean | null; + guild_ids: string[] | null; + check_error: string | null; + invite_url: string | null; +} + +export interface DiscordStreamActual { + state?: string; + error?: string | null; + since?: string | null; + at?: string | null; +} + +export interface DiscordStream { + stream_id: string; + guild_id: string; + channel_id: string; + channel_name?: string | null; + guild_name?: string | null; + org_id?: string | null; + system_id?: string; + system_name?: string | null; + mode?: "persistent" | "adhoc"; + desired?: "on" | "off"; + node_id?: string | null; + node_name?: string | null; + node_status?: string | null; + token_id?: string | null; + token_name?: string | null; + actual?: DiscordStreamActual | null; + stop_reason?: string | null; + updated_at?: string | null; + created_at?: string | null; +} + +export interface DiscordGuild { + guild_id: string; + guild_name: string | null; + linked: boolean; + org_id: string | null; + linked_at: string | null; + acting_org_id: string | null; + acting_org_name: string | null; + live_streams: number; +} + +export interface DiscordAdminOverview { + guilds: DiscordGuild[]; + streams: DiscordStream[]; + orgs: { org_id: string; name: string }[]; + node_names: Record; + systems: { system_id: string; name: string | null; org_id: string | null }[]; + limits: { + guild_max_streams: number; + node_max_streams: number; + unlinked_guild_org_id: string | null; + unlinked_guild_org_name: string | null; + }; +} + +/** A desired-on channel that has not reached "connected" for this long turns red. */ +export const STUCK_AFTER_MS = 2 * 60 * 1000; + +export type Health = "ok" | "warn" | "bad" | "off"; + +export function streamHealth(s: DiscordStream, now = Date.now()): { health: Health; label: string } { + const state = s.actual?.state ?? "pending"; + if (s.desired !== "on") { + return { health: "off", label: state === "stopping" ? "off → stopping" : "off" }; + } + if (state === "connected") return { health: "ok", label: "on → connected" }; + if (state === "error" || state === "node_offline") return { health: "bad", label: `on → ${state.replace("_", " ")}` }; + const since = s.actual?.since ? Date.parse(s.actual.since) : NaN; + if (!Number.isNaN(since) && now - since > STUCK_AFTER_MS) { + const mins = Math.round((now - since) / 60000); + return { health: "bad", label: `on → ${state} for ${mins} min` }; + } + return { health: "warn", label: `on → ${state.replace("_", " ")}` }; +} + +/** The pool bot's invite link pinned to one server (same params c2-core's invite_url(app, guild) adds). */ +export function inviteForGuild(inviteUrl: string | null | undefined, guildId: string): string | null { + if (!inviteUrl) return null; + return `${inviteUrl}&guild_id=${encodeURIComponent(guildId)}&disable_guild_select=true`; +} + +export function guildLabel(g: { guild_id: string; guild_name?: string | null }): string { + return g.guild_name || `server ${shortId(g.guild_id)}`; +} + +/** 1152…4402 — enough to tell snowflakes apart without a 19-digit column. */ +export function shortId(id: string | null | undefined): string { + if (!id) return "—"; + return id.length > 10 ? `${id.slice(0, 4)}…${id.slice(-4)}` : id; +} + +export const SNOWFLAKE = /^\d{15,21}$/; + +/** + * c2api's request() throws `C2 API error : `. Discord routes + * answer with `{detail, code, invite: [{name, invite_url}]}` — pull those out + * so "no bot in this server" can render its invite links. + */ +export function parseC2Error(err: unknown): { message: string; invite: { name: string; invite_url: string | null }[] } { + const raw = err instanceof Error ? err.message : String(err); + const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/); + if (m) { + try { + const body = JSON.parse(m[2]); + const detail = typeof body?.detail === "string" ? body.detail : Array.isArray(body?.detail) ? body.detail.map((d: { msg?: string }) => d.msg).join("; ") : null; + return { message: detail || raw, invite: Array.isArray(body?.invite) ? body.invite : [] }; + } catch { + return { message: m[2] || raw, invite: [] }; + } + } + return { message: raw, invite: [] }; +} + +export function fmtWhen(iso: string | null | undefined): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + const sameDay = d.toDateString() === new Date().toDateString(); + return sameDay + ? d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) + : d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +}