diff --git a/drb-c2-core/app/routers/tokens.py b/drb-c2-core/app/routers/tokens.py index bfb6f50..9c23005 100644 --- a/drb-c2-core/app/routers/tokens.py +++ b/drb-c2-core/app/routers/tokens.py @@ -1,5 +1,5 @@ import uuid -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, Query from pydantic import BaseModel from typing import Optional from app.internal import firestore as fstore @@ -12,6 +12,7 @@ from app.internal.auth import ( get_role, require_admin_token, require_firebase_token, + resolve_org_scope, ) from app.internal.logger import logger @@ -101,6 +102,16 @@ async def flush_tokens(_: dict = Depends(require_admin_token)): return {"released": released} +def _forget_cached_system(system_id: str) -> None: + """start_stream reads systems through fstore.doc_get_cached (5 min TTL): + drop the entry so a new preference applies to the next start instead of + up to five minutes later (server-26#225 S4).""" + cache = getattr(fstore, "_doc_cache", None) + path = getattr(fstore, "_path", None) + if isinstance(cache, dict) and callable(path): + cache.pop(f"{path('systems')}/{system_id}", None) + + def _may_edit_system(decoded: dict, system: Optional[dict]) -> bool: """Platform admin: any system. Org owner: only their own org's systems — a shared system (no org_id) serves every org, so a preference there is @@ -167,6 +178,7 @@ async def set_preferred_system( raise HTTPException(403, "That system is shared by every organization; only a platform admin " "can change its radio bot.") await fstore.doc_set("systems", old_id, {"preferred_token_id": None}) + _forget_cached_system(old_id) await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": None}) await write_audit(uid, email, "discord.token.prefer", details={ "token_id": token_id, "system_id": None, "previous_system_id": old_id or None}) @@ -183,6 +195,7 @@ async def set_preferred_system( old_sys = await fstore.doc_get("systems", old_sys_id) if old_sys and old_sys.get("preferred_token_id") == token_id and _may_edit_system(decoded, old_sys): await fstore.doc_set("systems", old_sys_id, {"preferred_token_id": None}) + _forget_cached_system(old_sys_id) old_tok_id = system_doc.get("preferred_token_id") if old_tok_id and old_tok_id != token_id: old_tok = await fstore.doc_get("bot_tokens", old_tok_id) @@ -190,6 +203,7 @@ async def set_preferred_system( await fstore.doc_set("bot_tokens", old_tok_id, {"preferred_for_system_id": None}) await fstore.doc_set("systems", system_id, {"preferred_token_id": token_id}) + _forget_cached_system(system_id) await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": system_id}) await write_audit(uid, email, "discord.token.prefer", details={ "token_id": token_id, "system_id": system_id, "previous_token_id": old_tok_id, @@ -197,6 +211,52 @@ async def set_preferred_system( return {"ok": True, "preferred_for_system_id": system_id} +@router.get("/org/preferred") +async def org_preferred_bots( + org_id: Optional[str] = Query(None, description="Platform-admin only: act for this org."), + decoded: dict = Depends(require_firebase_token), +): + """What an org owner needs to choose a preferred radio bot per system + (server-26#225 S4, owner decision Q2): the org's own systems, each with + its preferred bot, and the org's own bring-your-own bots — the only bots + an owner may choose (PUT /tokens/{id}/prefer/{system_id}). GET /tokens + stays platform-admin only; this returns no pool bots and no token strings. + A pool bot a platform admin chose for one of these systems is flagged, + not named.""" + if get_role(decoded) != "admin" and get_org_role(decoded) != "owner": + raise HTTPException(403, "Only the organization owner or a platform admin can choose a system's radio bot.") + org = resolve_org_scope(decoded, org_id) + systems = sorted(await fstore.collection_list("systems", org_id=org), + key=lambda s: str(s.get("name") or s.get("system_id") or "").lower()) + bots = sorted((t for t in await fstore.collection_list("bot_tokens", org_id=org) if t.get("token_id")), + key=lambda t: str(t.get("name") or "").lower()) + names = {t["token_id"]: t.get("name") or t["token_id"] for t in bots} + out_systems = [] + for s in systems: + if not s.get("system_id"): + continue + pref = s.get("preferred_token_id") + out_systems.append({ + "system_id": s["system_id"], + "name": s.get("name") or s["system_id"], + "preferred_token_id": pref if pref in names else None, + "preferred_is_platform_bot": bool(pref) and pref not in names, + "preferred_name": names.get(pref) if pref in names else None, + }) + return { + "org_id": org, + "systems": out_systems, + "bots": [{ + "token_id": t["token_id"], + "name": names[t["token_id"]], + "valid": t.get("valid"), + "server_count": len(t["guild_ids"]) if isinstance(t.get("guild_ids"), list) else None, + "preferred_for_system_id": t.get("preferred_for_system_id"), + "invite_url": leases.invite_url(t.get("application_id")), + } for t in bots], + } + + @router.delete("/{token_id}", status_code=204) async def delete_token(token_id: str, _: dict = Depends(require_admin_token)): existing = await fstore.doc_get("bot_tokens", token_id) diff --git a/drb-c2-core/tests/test_tokens_org_preferred.py b/drb-c2-core/tests/test_tokens_org_preferred.py new file mode 100644 index 0000000..5194104 --- /dev/null +++ b/drb-c2-core/tests/test_tokens_org_preferred.py @@ -0,0 +1,113 @@ +""" +Org-owner "radio bot per system" card (server-26#225 S4, owner decision Q2): +GET /tokens/org/preferred lists the owner's own systems and own BYO bots (no +pool bots, no token strings); a preference set through +PUT /tokens/{id}/prefer/{system} (S3) applies to the very next start, not after +the 5-minute systems cache. +""" +import pytest +from fastapi.testclient import TestClient + +from app.internal import firestore as fstore +from app.internal.auth import require_firebase_token, require_service_or_firebase_token +from app.main import app +from tests.fake_firestore import FakeFirestore + +client = TestClient(app) +ORG, OTHER = "org-a", "org-b" +OWNER = {"uid": "u-own", "org_id": ORG, "org_role": "owner"} +MEMBER = {"uid": "u-mem", "org_id": ORG, "org_role": "member", "role": "operator"} +STRANGER = {"uid": "u-x", "org_id": OTHER, "org_role": "owner"} +ADMIN_NO_ORG = {"uid": "u-admin", "role": "admin"} + + +@pytest.fixture +def fs(monkeypatch): + fake = FakeFirestore.install(monkeypatch) + fake.put("systems", "s-a", {"system_id": "s-a", "name": "County P25", "org_id": ORG}) + fake.put("systems", "s-a2", {"system_id": "s-a2", "name": "City Fire", "org_id": ORG}) + fake.put("systems", "s-b", {"system_id": "s-b", "name": "Other County", "org_id": OTHER}) + fake.put("systems", "s-shared", {"system_id": "s-shared", "name": "Shared", "org_id": None}) + fake.put("bot_tokens", "byo-a", {"token_id": "byo-a", "name": "Alpha Radio", "token": "secret-a", + "org_id": ORG, "valid": True, "guild_ids": ["100", "101"], + "application_id": "555"}) + fake.put("bot_tokens", "byo-a-bad", {"token_id": "byo-a-bad", "name": "Broken", "token": "secret-bad", + "org_id": ORG, "valid": False}) + fake.put("bot_tokens", "byo-b", {"token_id": "byo-b", "name": "Bravo Radio", "token": "secret-b", + "org_id": OTHER, "valid": True}) + fake.put("bot_tokens", "pool-1", {"token_id": "pool-1", "name": "DRB Radio 1", "token": "secret-p", + "org_id": None, "valid": True}) + yield fake + app.dependency_overrides.clear() + + +def as_(who): + # /tokens is mounted behind require_service_or_firebase_token (main.py). + app.dependency_overrides[require_service_or_firebase_token] = lambda: who + app.dependency_overrides[require_firebase_token] = lambda: who + + +def test_owner_lists_own_systems_and_own_bots_only(fs): + as_(OWNER) + r = client.get("/tokens/org/preferred") + assert r.status_code == 200, r.text + body = r.json() + assert body["org_id"] == ORG + assert [s["system_id"] for s in body["systems"]] == ["s-a2", "s-a"] # own systems, by name + assert {b["token_id"] for b in body["bots"]} == {"byo-a", "byo-a-bad"} # no pool, no other org + alpha = next(b for b in body["bots"] if b["token_id"] == "byo-a") + assert alpha["server_count"] == 2 and alpha["invite_url"] and alpha["valid"] is True + assert "secret" not in repr(body) # never a token string + + +def test_member_is_refused(fs): + as_(MEMBER) + assert client.get("/tokens/org/preferred").status_code == 403 + + +def test_admin_without_org_gets_403_not_every_org(fs): + as_(ADMIN_NO_ORG) + assert client.get("/tokens/org/preferred").status_code == 403 + # With ?org_id= a platform admin may act for that org (resolve_org_scope). + body = client.get(f"/tokens/org/preferred?org_id={OTHER}").json() + assert [s["system_id"] for s in body["systems"]] == ["s-b"] + + +def test_non_admin_org_override_is_ignored(fs): + as_(STRANGER) + body = client.get(f"/tokens/org/preferred?org_id={ORG}").json() + assert body["org_id"] == OTHER + assert [s["system_id"] for s in body["systems"]] == ["s-b"] + assert [b["token_id"] for b in body["bots"]] == ["byo-b"] + + +def test_platform_pick_on_owner_system_is_flagged_not_named(fs): + fs.put("systems", "s-a", {"system_id": "s-a", "name": "County P25", "org_id": ORG, + "preferred_token_id": "pool-1"}) + as_(OWNER) + s = next(x for x in client.get("/tokens/org/preferred").json()["systems"] if x["system_id"] == "s-a") + assert s["preferred_token_id"] is None and s["preferred_name"] is None + assert s["preferred_is_platform_bot"] is True + assert "DRB Radio 1" not in repr(s) + + +def test_owner_set_then_list_round_trip_and_cache_is_dropped(fs): + stale = (1e18, {"system_id": "s-a", "org_id": ORG}) # what start_stream would read for 5 min + fstore._doc_cache[f"{fstore._path('systems')}/s-a"] = stale + fstore._doc_cache[f"{fstore._path('systems')}/s-a2"] = stale + as_(OWNER) + assert client.put("/tokens/byo-a/prefer/s-a").status_code == 200 + assert f"{fstore._path('systems')}/s-a" not in fstore._doc_cache + s = next(x for x in client.get("/tokens/org/preferred").json()["systems"] if x["system_id"] == "s-a") + assert s["preferred_token_id"] == "byo-a" and s["preferred_name"] == "Alpha Radio" + + # Moving the bot to another system drops both systems' cached copies. + fstore._doc_cache[f"{fstore._path('systems')}/s-a"] = stale + assert client.put("/tokens/byo-a/prefer/s-a2").status_code == 200 + assert f"{fstore._path('systems')}/s-a" not in fstore._doc_cache + assert f"{fstore._path('systems')}/s-a2" not in fstore._doc_cache + + fstore._doc_cache[f"{fstore._path('systems')}/s-a2"] = stale + assert client.put("/tokens/byo-a/prefer/_none").status_code == 200 + assert f"{fstore._path('systems')}/s-a2" not in fstore._doc_cache + assert fs.get("systems", "s-a2")["preferred_token_id"] is None diff --git a/drb-frontend/app/settings/organization/page.tsx b/drb-frontend/app/settings/organization/page.tsx index c5d4d97..ac7d2a2 100644 --- a/drb-frontend/app/settings/organization/page.tsx +++ b/drb-frontend/app/settings/organization/page.tsx @@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { Skeleton } from "@/components/ui/Skeleton"; import { DiscordServersCard } from "@/components/DiscordServersCard"; +import { PreferredBotsCard } from "@/components/discord/PreferredBotsCard"; function StatTile({ label, value }: { label: string; value: string | number }) { return ( @@ -121,6 +122,8 @@ export default function OrganizationSettingsPage() { + +
diff --git a/drb-frontend/components/NodeDiscordRadio.tsx b/drb-frontend/components/NodeDiscordRadio.tsx index 2cc019e..d1dc128 100644 --- a/drb-frontend/components/NodeDiscordRadio.tsx +++ b/drb-frontend/components/NodeDiscordRadio.tsx @@ -10,17 +10,27 @@ // 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; @@ -93,12 +103,53 @@ function DiscordPicker({ const [channelId, setChannelId] = useState(""); const [mode, setMode] = useState<"adhoc" | "persistent">("adhoc"); const [sending, setSending] = useState(false); - const [error, setError] = useState<{ message: string; invite: DiscordInvite[] } | null>(null); + 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]) => { + const row = check?.data && checkOn ? check.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); + const showInvite = chosenVerdict?.kind === "fix" || chosenVerdict?.kind === "no_bot" || chosenVerdict?.kind === "busy"; + + const runCheck = useCallback( + async (gid: string, refresh: boolean) => { + 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 } })); + } 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 } })); + } + }, + [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"] }[] = []; @@ -119,7 +170,11 @@ function DiscordPicker({ onStarted(); onClose(); } catch (err) { - setError(parseC2Error(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); } @@ -209,6 +264,7 @@ function DiscordPicker({ {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 ( ); @@ -248,6 +311,15 @@ function DiscordPicker({
))} + runCheck(guild.guild_id, true)} + /> )} @@ -299,10 +371,10 @@ function DiscordPicker({

)} {error && ( -

+

{error.message} -

+
)} @@ -310,7 +382,8 @@ function DiscordPicker({ + diff --git a/drb-frontend/components/discord/VoiceCheckBits.tsx b/drb-frontend/components/discord/VoiceCheckBits.tsx new file mode 100644 index 0000000..e93b8f6 --- /dev/null +++ b/drb-frontend/components/discord/VoiceCheckBits.tsx @@ -0,0 +1,157 @@ +"use client"; + +// Per-bot voice-channel permission status (server-26#225 S4). Shared by the +// node-page picker (NodeDiscordRadio) and the admin Add form (StreamsPanel), +// both fed by c2-core routers/discord_checks.py. Start is disabled only on a +// DEFINITE refusal ("Needs fix", "Not a voice channel"); "Couldn't check" +// fails open — a Discord outage must never block a start. + +import { useEffect, useState } from "react"; +import { + agoText, + botLine, + deniedSentence, + fixSentence, + type ChannelVerdict, + type DiscordInvite, + type VoiceCheckChannel, +} from "@/lib/discordPicker"; + +const PILL = "inline-flex items-center gap-1 rounded px-1.5 py-px text-[11px] font-medium whitespace-nowrap border"; + +const BADGE: Record = { + ready: { label: "Ready", cls: "border-green-500/40 text-green-500" }, + fix: { label: "Needs fix", cls: "border-sev-major/50 text-sev-major" }, + not_voice: { label: "Not a voice channel", cls: "border-sev-major/50 text-sev-major" }, + busy: { + label: "Bots busy", + cls: "border-sev-moderate/50 text-sev-moderate", + title: "A bot could stream here, but each one that can is already in another channel of this server or on another node.", + }, + no_bot: { label: "No radio bot", cls: "border-sev-moderate/50 text-sev-moderate", title: "No radio bot is in this Discord server yet." }, + unknown: { label: "Couldn't check", cls: "border-line text-ink-muted", title: "Discord didn't answer the check — DRB will still try." }, +}; + +/** Ready / Needs fix / Couldn't check / …, plus "Limit N" when the channel has a user limit. */ +export function VerdictBadge({ verdict, loading }: { verdict: ChannelVerdict | null; loading?: boolean }) { + if (!verdict) { + return loading ? Checking… : null; + } + const b = BADGE[verdict.kind]; + // Name the missing permission right on the badge: "Needs fix · can't Speak". + const label = + verdict.kind === "fix" && verdict.bot.missing.length + ? `${b.label} · can't ${verdict.bot.missing.join(", ")}` + : b.label; + const title = verdict.kind === "fix" ? `${verdict.bot.name}: ${verdict.bot.reason ?? "missing permission"}` : b.title; + return ( + + {label} + {verdict.limit > 0 && ( + + Limit {verdict.limit} + + )} + + ); +} + +/** The sentence under a "Needs fix" / "Not a voice channel" channel. */ +export function FixText({ verdict, channelName }: { verdict: ChannelVerdict; channelName: string | null }) { + if (verdict.kind === "not_voice") { + return That is not a voice channel. Radio can only play in a voice channel.; + } + if (verdict.kind !== "fix") return null; + return ( + + {deniedSentence(verdict.bot, channelName)} {fixSentence(verdict.bot)} + + ); +} + +const TONE: Record<"ok" | "bad" | "warn" | "muted", string> = { + ok: "text-green-500", + bad: "text-sev-major", + warn: "text-sev-moderate", + muted: "text-ink-muted", +}; +const MARK: Record<"ok" | "bad" | "warn" | "muted", string> = { ok: "✓", bad: "✕", warn: "•", muted: "?" }; + +/** Expandable per-bot detail for one channel. */ +export function BotDetails({ channel, channelName }: { channel: VoiceCheckChannel | null | undefined; channelName: string | null }) { + if (!channel || channel.bots.length === 0) return null; + return ( +
+ + Radio bots in this server ({channel.bots.length}) + +
    + {channel.bots.map((b, i) => { + const l = botLine(b, channelName); + return ( +
  • + {MARK[l.tone]} {l.text} + {b.status === "denied" && {fixSentence(b)}} +
  • + ); + })} +
+
+ ); +} + +/** "Permissions checked 40s ago · Re-check". Ticks on its own so the age stays honest. */ +export function CheckFooter({ + checkedAt, + busy, + error, + disabled, + limited, + invite, + onRecheck, +}: { + checkedAt: string | null; + busy: boolean; + error: string | null; + disabled?: boolean; + /** A Re-check was rate limited server-side (once per 10 s); the cached answer is shown. */ + limited?: boolean; + invite?: DiscordInvite[]; + onRecheck: () => void; +}) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 10_000); + return () => clearInterval(t); + }, []); + const ago = agoText(checkedAt, now); + if (disabled) { + return
Permission check is off — DRB tries any free radio bot.
; + } + return ( +
+ {busy ? "Checking permissions…" : ago ? `Permissions checked ${ago}` : "Permissions not checked yet"} + · + + {limited && !busy && ( + Re-check runs at most once every 10 seconds — this is the last result. Try again in a moment. + )} + {error && Couldn't check permissions: {error}} + {invite && invite.length > 0 && ( + + Add another radio bot to this server: + {invite.map((i) => ( + + Invite {i.name} + + ))} + + )} +
+ ); +} diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index 0cf554b..001c3ec 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -148,6 +148,21 @@ export const c2api = { `/discord/nodes/${nodeId}/streams/${encodeURIComponent(streamId)}`, { method: "DELETE" }, ), + /** Per-bot permission check for every voice channel in one server (server-26#225). + * refresh=true bypasses the cache (rate limited server-side: 1 per 10 s). Raw + * body — run it through discordPicker.normalizeVoiceCheck. */ + getNodeVoiceCheck: (nodeId: string, guildId: string, refresh = false) => + request( + `/discord/nodes/${encodeURIComponent(nodeId)}/guilds/${encodeURIComponent(guildId)}/voice-check?refresh=${refresh ? 1 : 0}`, + ), + /** Org owner: this org's systems + its own radio bots, for "preferred bot per system" (server-26#225 Q2). */ + getOrgPreferredBots: () => + request("/tokens/org/preferred"), + /** Admin: the same check for one channel (the Add form takes a raw channel ID). */ + getGuildVoiceCheck: (guildId: string, channelId: string, refresh = false) => + request( + `/discord/guilds/${encodeURIComponent(guildId)}/voice-check?channel_id=${encodeURIComponent(channelId)}&refresh=${refresh ? 1 : 0}`, + ), deleteNode: (id: string) => request(`/nodes/${id}`, { method: "DELETE" }), @@ -344,7 +359,8 @@ export const c2api = { getReplayIncidents: (runId: string) => request(`/admin/replay/${runId}/incidents`), - // Preferred bot token per system + // Preferred bot token per system. Platform admin: any bot, any system. Org + // owner: their own org's bots for their own org's systems (server-26#225 Q2). setPreferredToken: (tokenId: string, systemId: string) => request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }), diff --git a/drb-frontend/lib/discordPicker.ts b/drb-frontend/lib/discordPicker.ts index 18e495a..0e259ae 100644 --- a/drb-frontend/lib/discordPicker.ts +++ b/drb-frontend/lib/discordPicker.ts @@ -69,6 +69,27 @@ export interface DiscordJoinOptions { streams: DiscordStreamView[]; } +/** GET /tokens/org/preferred — the org's own systems and its own (bring-your-own) radio bots. */ +export interface OrgPreferredBots { + org_id: string; + systems: { + system_id: string; + name: string; + preferred_token_id: string | null; + /** A platform admin pinned a DRB pool bot for this system. */ + preferred_is_platform_bot: boolean; + preferred_name: string | null; + }[]; + bots: { + token_id: string; + name: string; + valid: boolean | null; + server_count: number | null; + preferred_for_system_id: string | null; + invite_url: string | null; + }[]; +} + export interface DiscordStartResult { ok: boolean; stream_id: string; @@ -77,10 +98,38 @@ export interface DiscordStartResult { reused: boolean | null; } +export interface ParsedC2Error { + message: string; + invite: DiscordInvite[]; + /** start_stream's code: missing_permissions, preferred_busy, preferred_other_node, bad_channel, … */ + code: string | null; + status: number | null; +} + +function asStrings(v: unknown): string[] { + return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; +} + +/** A bot's trace sentence: ``reason`` as a string, else the ``reasons`` map + * (label → sentence, discord_perms.check_voice) joined. */ +function reasonOf(b: Record): string | null { + if (typeof b.reason === "string" && b.reason) return b.reason; + const r = b.reasons; + if (r && typeof r === "object") { + const parts = Object.entries(r as Record) + .filter(([, v]) => typeof v === "string" && v) + .map(([k, v]) => `${k}: ${v as string}`); + if (parts.length) return parts.join("; "); + } + return null; +} + /** c2api's request() throws `C2 API error : `. Pull the - * detail + invite links back out of a JSON body when there is one. */ -export function parseC2Error(err: unknown): { message: string; invite: DiscordInvite[] } { + * detail, code and invite links back out of a JSON body. */ +export function parseC2Error(err: unknown): ParsedC2Error { const raw = err instanceof Error ? err.message : String(err); + const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/); + const status = m ? Number(m[1]) : null; const idx = raw.indexOf(": "); if (idx >= 0) { try { @@ -89,10 +138,225 @@ export function parseC2Error(err: unknown): { message: string; invite: DiscordIn const invite = Array.isArray(body?.invite) ? (body.invite as DiscordInvite[]).filter((i) => i && typeof i.invite_url === "string") : []; - if (detail) return { message: detail, invite }; + // missing_permissions: detail already names each bot, the permission, + // what removed it and the fix (discord_streams._missing_permissions_error). + if (detail) return { message: detail, invite, code: typeof body?.code === "string" ? body.code : null, status }; } catch { /* not JSON — fall through */ } } - return { message: raw, invite: [] }; + return { message: raw, invite: [], code: null, status }; +} + +// --------------------------------------------------------------------------- +// Voice-channel permission check (server-26#225 S3/S4) — the JSON contract of +// c2-core routers/discord_checks.py: +// GET /discord/nodes/{node}/guilds/{guild}/voice-check (node picker, org-scoped) +// GET /discord/guilds/{guild}/voice-check?channel_id= (admin Add form; rows carry token_id) +// Bot names only for non-admins — never token strings. +// --------------------------------------------------------------------------- + +/** Per bot: ok | denied | unknown | not_member | invalid | bad_channel | unchecked. */ +export type BotAccess = "ok" | "denied" | "unknown" | "not_member" | "invalid" | "bad_channel" | "unchecked"; + +/** Per channel, as c2-core sums it up over the bots that are free. */ +export type ChannelState = "ready" | "needs_fix" | "unknown" | "busy" | "no_bot" | "bad_channel" | "unchecked"; + +export interface VoiceCheckBot { + /** Admin route only. */ + token_id?: string | null; + name: string; + status: BotAccess; + /** true = can stream there, false = DEFINITE deny, null = anything else (unknown fails open). */ + ok: boolean | null; + missing: string[]; + /** What removed the first missing permission, e.g. "denied by #dispatch's override for @Muted". */ + reason: string | null; + /** What to change in Discord (server-written). */ + fix: string | null; + /** false = this bot can't take a new stream here right now. */ + available: boolean | null; + unavailable_reason: "busy_here" | "other_node" | string | null; +} + +export interface VoiceCheckChannel { + channel_id: string; + name: string | null; + state: ChannelState | null; + /** Advisory: REST can't see who is connected (0 = no limit). */ + user_limit: number; + any_ok: boolean | null; + bots: VoiceCheckBot[]; +} + +export interface VoiceCheck { + guild_id: string | null; + checked_at: string | null; + /** false when DISCORD_PERM_CHECK_ENABLED is off server-side — no badges then. */ + enabled: boolean; + /** false when a Re-check was asked for but rate limited (cached answer served). */ + refreshed: boolean | null; + channels: Record; + /** Radio bots that could be invited to this server. */ + invite: DiscordInvite[]; +} + +const BOT_ACCESS: readonly string[] = ["ok", "denied", "unknown", "not_member", "invalid", "bad_channel", "unchecked"]; +const CHANNEL_STATES: readonly string[] = ["ready", "needs_fix", "unknown", "busy", "no_bot", "bad_channel", "unchecked"]; + +function normBot(b: Record): VoiceCheckBot { + const status = (typeof b.status === "string" && BOT_ACCESS.includes(b.status) + ? b.status + : b.ok === true ? "ok" : "unknown") as BotAccess; + return { + token_id: typeof b.token_id === "string" ? b.token_id : null, + name: typeof b.name === "string" && b.name ? b.name : "Radio bot", + status, + ok: status === "ok" ? true : status === "denied" ? false : null, + missing: asStrings(b.missing), + reason: reasonOf(b), + fix: typeof b.fix === "string" && b.fix ? b.fix : null, + available: b.available === true ? true : b.available === false ? false : null, + unavailable_reason: typeof b.unavailable_reason === "string" ? b.unavailable_reason : null, + }; +} + +function normChannel(c: Record, fallbackId: string): VoiceCheckChannel { + return { + channel_id: String(c.channel_id ?? fallbackId), + name: typeof c.name === "string" ? c.name : null, + state: typeof c.state === "string" && CHANNEL_STATES.includes(c.state) ? (c.state as ChannelState) : null, + user_limit: Number(c.user_limit) || 0, + any_ok: c.any_ok === true ? true : c.any_ok === false ? false : null, + bots: Array.isArray(c.bots) ? (c.bots as Record[]).filter(Boolean).map(normBot) : [], + }; +} + +export function normalizeVoiceCheck(raw: unknown): VoiceCheck { + const r = (raw && typeof raw === "object" ? raw : {}) as Record; + const channels: Record = {}; + if (Array.isArray(r.channels)) { + for (const c of r.channels as Record[]) { + if (c && c.channel_id != null) channels[String(c.channel_id)] = normChannel(c, String(c.channel_id)); + } + } + return { + guild_id: r.guild_id != null ? String(r.guild_id) : null, + checked_at: typeof r.checked_at === "string" ? r.checked_at : null, + enabled: r.enabled !== false, + refreshed: r.refreshed === true ? true : r.refreshed === false ? false : null, + channels, + invite: Array.isArray(r.invite) + ? (r.invite as DiscordInvite[]).filter((i) => i && typeof i.invite_url === "string") + : [], + }; +} + +export type ChannelVerdict = + /** A free radio bot can stream there. */ + | { kind: "ready"; limit: number } + /** A bot could stream there, but every such bot is busy. */ + | { kind: "busy"; limit: number } + /** Every free bot is DEFINITELY denied — Start is disabled. */ + | { kind: "fix"; limit: number; bot: VoiceCheckBot } + /** Discord didn't answer for some bot — Start stays enabled (fail-open). */ + | { kind: "unknown"; limit: number } + /** No radio bot is in this server. */ + | { kind: "no_bot"; limit: number } + /** The channel exists but is not a voice channel — Start is disabled. */ + | { kind: "not_voice"; limit: number }; + +/** Derive the badge. c2-core's ``state`` wins; the bot rows are the fallback. + * null = no badge (check off, or no data for that channel). */ +export function channelVerdict(c: VoiceCheckChannel | null | undefined): ChannelVerdict | null { + if (!c) return null; + const limit = c.user_limit; + const free = c.bots.filter((b) => b.available !== false); + const denied = free.filter((b) => b.ok === false); + const firstDenied = denied[0] ?? c.bots.find((b) => b.ok === false); + switch (c.state) { + case "ready": return { kind: "ready", limit }; + case "busy": return { kind: "busy", limit }; + case "unknown": return { kind: "unknown", limit }; + case "no_bot": return { kind: "no_bot", limit }; + case "bad_channel": return { kind: "not_voice", limit }; + case "unchecked": return null; + case "needs_fix": return firstDenied ? { kind: "fix", limit, bot: firstDenied } : { kind: "unknown", limit }; + } + // No state (older server): same rules as discord_checks._channel_state. + if (!c.bots.length) return { kind: "no_bot", limit }; + if (free.some((b) => b.ok === true)) return { kind: "ready", limit }; + if (free.some((b) => b.ok === null)) return { kind: "unknown", limit }; + if (denied.length) return { kind: "fix", limit, bot: denied[0] }; + if (c.bots.some((b) => b.ok === true)) return { kind: "busy", limit }; + return { kind: "unknown", limit }; +} + +/** A definite refusal: Start would only get a 409. Everything else fails open. */ +export function blocksStart(v: ChannelVerdict | null | undefined): boolean { + return v?.kind === "fix" || v?.kind === "not_voice"; +} + +/** "#dispatch" for a channel name, else "this channel". */ +export function channelRef(name: string | null | undefined): string { + return name ? `#${name.replace(/^#/, "")}` : "this channel"; +} + +function joinLabels(labels: string[]): string { + if (labels.length <= 1) return labels.join(""); + return `${labels.slice(0, -1).join(", ")} or ${labels[labels.length - 1]}`; +} + +/** "Radio bot X can't Speak in #dispatch: denied by #dispatch's override for @Muted." */ +export function deniedSentence(bot: { name: string; missing: string[]; reason: string | null }, channelName?: string | null): string { + const what = bot.missing.length ? joinLabels(bot.missing) : "join"; + const why = bot.reason ? `: ${bot.reason}` : ""; + return `${bot.name} can't ${what} in ${channelRef(channelName)}${why}.`; +} + +/** Where in Discord to fix it — c2-core's own sentence when it sent one. */ +export function fixSentence(bot: { name: string; missing: string[]; reason: string | null; fix?: string | null }): string { + if (bot.fix) return bot.fix; + if (bot.reason && /timed out/i.test(bot.reason)) return `In Discord: remove ${bot.name}'s timeout, then Re-check.`; + const perms = bot.missing.length ? bot.missing.join(" and ") : "View Channel, Connect and Speak"; + return `In Discord: Edit Channel → Permissions → add ${bot.name} → allow ${perms}, then Re-check.`; +} + +/** "40s ago" / "3 min ago" for the Re-check footer. */ +export function agoText(iso: string | null | undefined, now = Date.now()): string | null { + if (!iso) return null; + const t = Date.parse(iso); + if (Number.isNaN(t)) return null; + const s = Math.max(0, Math.round((now - t) / 1000)); + if (s < 60) return `${s}s ago`; + const m = Math.round(s / 60); + return m < 60 ? `${m} min ago` : `${Math.round(m / 60)} h ago`; +} + +/** One line per bot for the expandable details. */ +export function botLine(b: VoiceCheckBot, channelName?: string | null): { tone: "ok" | "bad" | "warn" | "muted"; text: string } { + const busy = + b.unavailable_reason === "busy_here" + ? "already in another voice channel in this server (one per server)" + : b.unavailable_reason === "other_node" + ? "carrying radio on another node" + : "busy"; + switch (b.status) { + case "ok": + return b.available === false + ? { tone: "warn", text: `${b.name}: can stream here, but is ${busy}` } + : { tone: "ok", text: `${b.name}: ready` }; + case "denied": + return { tone: "bad", text: deniedSentence(b, channelName) + (b.available === false ? ` (also ${busy})` : "") }; + case "not_member": + return { tone: "warn", text: `${b.name}: Discord says it is not in this server — invite it again` }; + case "invalid": + return { tone: "bad", text: `${b.name}: its token was rejected by Discord — a platform admin has to replace it` }; + case "bad_channel": + return { tone: "bad", text: `${b.name}: ${channelRef(channelName)} is not a voice channel` }; + case "unchecked": + return { tone: "muted", text: `${b.name}: not checked (permission check is off)` }; + default: + return { tone: "muted", text: `${b.name}: couldn't check${b.reason ? ` (${b.reason})` : ""} — DRB will still try it` }; + } }