Discord picker: per-bot permission status + owner's preferred bot (#225 S4) — merge after S3 #250

Merged
logan merged 2 commits from feat/225-s4-picker-ui into main 2026-10-05 02:38:56 -04:00
11 changed files with 986 additions and 21 deletions
+11 -1
View File
@@ -87,6 +87,16 @@ def _channel_state(rows: list[dict], enabled: bool) -> str:
return "no_bot" return "no_bot"
def _checked_at(snaps: dict) -> str:
"""When Discord was last actually asked: the OLDEST cached snapshot behind
this answer (a denied result is cached for 10 min), so the picker's
"Permissions checked N ago" never calls a stale answer fresh (server-26#225
S4). No snapshot (check off, no member bots) = now."""
times = [s.fetched_at for s in snaps.values() if s is not None and getattr(s, "fetched_at", None)]
ts = min(times) if times else time.time()
return datetime.fromtimestamp(ts, timezone.utc).isoformat()
async def voice_check(*, guild_id: str, org_id: Optional[str], node_id: Optional[str], async def voice_check(*, guild_id: str, org_id: Optional[str], node_id: Optional[str],
channels: list[dict], channel_id: Optional[str], refresh: bool, channels: list[dict], channel_id: Optional[str], refresh: bool,
with_token_ids: bool) -> dict: with_token_ids: bool) -> dict:
@@ -171,7 +181,7 @@ async def voice_check(*, guild_id: str, org_id: Optional[str], node_id: Optional
"guild_id": guild_id, "guild_id": guild_id,
"node_id": node_id, "node_id": node_id,
"enabled": enabled, "enabled": enabled,
"checked_at": datetime.now(timezone.utc).isoformat(), "checked_at": _checked_at(snaps),
"refreshed": refresh, "refreshed": refresh,
"channels": out_channels, "channels": out_channels,
"invite": invite, "invite": invite,
+61 -1
View File
@@ -1,5 +1,5 @@
import uuid import uuid
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from app.internal import firestore as fstore from app.internal import firestore as fstore
@@ -12,6 +12,7 @@ from app.internal.auth import (
get_role, get_role,
require_admin_token, require_admin_token,
require_firebase_token, require_firebase_token,
resolve_org_scope,
) )
from app.internal.logger import logger from app.internal.logger import logger
@@ -101,6 +102,16 @@ async def flush_tokens(_: dict = Depends(require_admin_token)):
return {"released": released} 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: def _may_edit_system(decoded: dict, system: Optional[dict]) -> bool:
"""Platform admin: any system. Org owner: only their own org's systems — """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 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 " raise HTTPException(403, "That system is shared by every organization; only a platform admin "
"can change its radio bot.") "can change its radio bot.")
await fstore.doc_set("systems", old_id, {"preferred_token_id": None}) 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 fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": None})
await write_audit(uid, email, "discord.token.prefer", details={ await write_audit(uid, email, "discord.token.prefer", details={
"token_id": token_id, "system_id": None, "previous_system_id": old_id or None}) "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) 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): 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}) 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") old_tok_id = system_doc.get("preferred_token_id")
if old_tok_id and old_tok_id != token_id: if old_tok_id and old_tok_id != token_id:
old_tok = await fstore.doc_get("bot_tokens", old_tok_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("bot_tokens", old_tok_id, {"preferred_for_system_id": None})
await fstore.doc_set("systems", system_id, {"preferred_token_id": token_id}) 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 fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": system_id})
await write_audit(uid, email, "discord.token.prefer", details={ await write_audit(uid, email, "discord.token.prefer", details={
"token_id": token_id, "system_id": system_id, "previous_token_id": old_tok_id, "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} 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) @router.delete("/{token_id}", status_code=204)
async def delete_token(token_id: str, _: dict = Depends(require_admin_token)): async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("bot_tokens", token_id) existing = await fstore.doc_get("bot_tokens", token_id)
@@ -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
+37
View File
@@ -0,0 +1,37 @@
"""
voice-check's ``checked_at`` is when Discord was last asked — the oldest
cached snapshot behind the answer — not the time of the HTTP response, so the
picker's "Permissions checked N ago" footer never calls a 10-minute-old
denied result fresh (server-26#225 S4).
"""
import time
from datetime import datetime
from app.internal import discord_rest
from app.routers import discord_checks as checks
from tests.test_discord_perm_check import GUILD, OWNER, _snap, _token, fs, run # noqa: F401 (fs = fixture)
def _age(out) -> float:
return time.time() - datetime.fromisoformat(out["checked_at"]).timestamp()
def test_checked_at_is_the_oldest_snapshot_not_now(fs, monkeypatch):
_token(fs, "t1"); _token(fs, "t2")
ages = {"t1": 30, "t2": 420}
async def _old(token_doc, guild_id, *, refresh=False):
s = _snap(token_doc["token_id"])
s.fetched_at = time.time() - (0 if refresh else ages[token_doc["token_id"]])
return s
monkeypatch.setattr(discord_rest, "guild_snapshot", _old)
out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id=None, decoded=OWNER))
assert 415 <= _age(out) <= 430
out = run(checks.node_voice_check("n1", GUILD, refresh=1, channel_id=None, decoded=OWNER))
assert _age(out) < 5
def test_checked_at_without_snapshots_is_now(fs):
out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id=None, decoded=OWNER)) # no bots
assert _age(out) < 5
@@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Skeleton } from "@/components/ui/Skeleton"; import { Skeleton } from "@/components/ui/Skeleton";
import { DiscordServersCard } from "@/components/DiscordServersCard"; import { DiscordServersCard } from "@/components/DiscordServersCard";
import { PreferredBotsCard } from "@/components/discord/PreferredBotsCard";
function StatTile({ label, value }: { label: string; value: string | number }) { function StatTile({ label, value }: { label: string; value: string | number }) {
return ( return (
@@ -121,6 +122,8 @@ export default function OrganizationSettingsPage() {
<DiscordServersCard canEdit={canEdit} isAdmin={isAdmin} /> <DiscordServersCard canEdit={canEdit} isAdmin={isAdmin} />
<PreferredBotsCard canEdit={canEdit} />
<div className="bg-gray-900 border border-red-800/60 rounded-xl p-5"> <div className="bg-gray-900 border border-red-800/60 rounded-xl p-5">
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." /> <CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
+79 -6
View File
@@ -10,17 +10,27 @@
// plays another system ADDS this node's system beside it; only a stream of // plays another system ADDS this node's system beside it; only a stream of
// this node's own system there is restarted / moved here. // this node's own system there is restarted / moved here.
// Layout follows design-canvas/discord-system-mockup.html "Radio channels". // 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 { useCallback, useEffect, useMemo, useState } from "react";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { import {
blocksStart,
channelVerdict,
normalizeVoiceCheck,
parseC2Error, parseC2Error,
type DiscordGuildOption, type DiscordGuildOption,
type DiscordInvite, type DiscordInvite,
type DiscordJoinOptions, type DiscordJoinOptions,
type DiscordStreamView, type DiscordStreamView,
type ParsedC2Error,
type StreamState, type StreamState,
type VoiceCheck,
} from "@/lib/discordPicker"; } from "@/lib/discordPicker";
import { BotDetails, CheckFooter, FixText, VerdictBadge } from "@/components/discord/VoiceCheckBits";
const POLL_MS = 10_000; const POLL_MS = 10_000;
@@ -93,12 +103,53 @@ function DiscordPicker({
const [channelId, setChannelId] = useState(""); const [channelId, setChannelId] = useState("");
const [mode, setMode] = useState<"adhoc" | "persistent">("adhoc"); const [mode, setMode] = useState<"adhoc" | "persistent">("adhoc");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [error, setError] = useState<{ message: string; invite: DiscordInvite[] } | null>(null); const [error, setError] = useState<ParsedC2Error | null>(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<string, { data: VoiceCheck | null; busy: boolean; error: string | null; limited: boolean }>
>({});
const guild: DiscordGuildOption | undefined = usable.find((g) => g.guild_id === guildId); const guild: DiscordGuildOption | undefined = usable.find((g) => g.guild_id === guildId);
const channel = guild?.voice_channels.find((c) => c.channel_id === channelId); const channel = guild?.voice_channels.find((c) => c.channel_id === channelId);
const atNodeCap = options.limits.node_used >= options.limits.node_max; const atNodeCap = options.limits.node_used >= options.limits.node_max;
const channelOnHere = channel?.stream?.node_id === nodeId; 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 grouped = useMemo(() => {
const out: { category: string | null; channels: DiscordGuildOption["voice_channels"] }[] = []; const out: { category: string | null; channels: DiscordGuildOption["voice_channels"] }[] = [];
@@ -119,7 +170,11 @@ function DiscordPicker({
onStarted(); onStarted();
onClose(); onClose();
} catch (err) { } 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 { } finally {
setSending(false); setSending(false);
} }
@@ -209,6 +264,7 @@ function DiscordPicker({
{grp.channels.map((c) => { {grp.channels.map((c) => {
const here = c.stream?.node_id === nodeId; const here = c.stream?.node_id === nodeId;
const others = (c.streams ?? []).filter((x) => x.stream_id !== c.stream?.stream_id); const others = (c.streams ?? []).filter((x) => x.stream_id !== c.stream?.stream_id);
const verdict = verdictOf(c);
return ( return (
<label <label
key={c.channel_id} key={c.channel_id}
@@ -227,7 +283,11 @@ function DiscordPicker({
}} }}
/> />
<span className="flex-1 min-w-0"> <span className="flex-1 min-w-0">
<span className="text-ink block truncate">🔊 {c.name}</span> <span className="flex items-center gap-2 min-w-0">
<span className="text-ink truncate">🔊 {c.name}</span>
{checkOn && <VerdictBadge verdict={verdict} loading={check?.busy} />}
</span>
{verdict && c.channel_id === channelId && <FixText verdict={verdict} channelName={c.name} />}
{c.stream && ( {c.stream && (
<span className="text-xs text-ink-muted block"> <span className="text-xs text-ink-muted block">
{here {here
@@ -241,6 +301,9 @@ function DiscordPicker({
{!c.stream && ` — this adds ${options.system_name ?? "this node's system"} beside it with another bot`} {!c.stream && ` — this adds ${options.system_name ?? "this node's system"} beside it with another bot`}
</span> </span>
)} )}
{c.channel_id === channelId && checkOn && (
<BotDetails channel={check?.data?.channels[c.channel_id]} channelName={c.name} />
)}
</span> </span>
</label> </label>
); );
@@ -248,6 +311,15 @@ function DiscordPicker({
</div> </div>
</div> </div>
))} ))}
<CheckFooter
checkedAt={check?.data?.checked_at ?? null}
busy={!!check?.busy}
error={check?.error ?? null}
disabled={!checkOn}
limited={!!check?.limited}
invite={showInvite ? check?.data?.invite : undefined}
onRecheck={() => runCheck(guild.guild_id, true)}
/>
</div> </div>
)} )}
</fieldset> </fieldset>
@@ -299,10 +371,10 @@ function DiscordPicker({
</p> </p>
)} )}
{error && ( {error && (
<p role="alert" className="text-xs text-sev-major"> <div role="alert" className="text-xs text-sev-major whitespace-pre-line">
{error.message} {error.message}
<InviteLinks invite={error.invite} /> <InviteLinks invite={error.invite} />
</p> </div>
)} )}
</div> </div>
@@ -310,7 +382,8 @@ function DiscordPicker({
<button <button
type="button" type="button"
onClick={start} onClick={start}
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere)} disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || definiteDeny}
title={definiteDeny ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined}
className="flex-1 bg-accent hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-semibold" className="flex-1 bg-accent hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-semibold"
> >
{sending ? "Starting…" : channelOnHere ? "Update" : "Start radio"} {sending ? "Starting…" : channelOnHere ? "Update" : "Start radio"}
@@ -0,0 +1,142 @@
"use client";
// Settings → Organization → "Radio bot per system" (server-26#225, owner
// ruling Q2). The org owner picks which of the org's OWN radio bots plays each
// of the org's systems in Discord. C2 tries that bot first for every new
// radio channel of the system (discord_streams.start_stream); when it can't
// take the stream (busy in that server, no permission, not invited) another
// free bot is used. Shared systems and DRB pool bots stay platform-admin only,
// so they are not offered here. Backend: GET /tokens/org/preferred (list),
// PUT /tokens/{token_id}/prefer/{system_id} (set; "_none" clears).
import { useCallback, useEffect, useState } from "react";
import { c2api } from "@/lib/c2api";
import { parseC2Error, type OrgPreferredBots } from "@/lib/discordPicker";
import { Card, CardHeader } from "@/components/ui/Card";
const SELECT =
"bg-page border border-line rounded-lg px-2.5 py-1.5 text-ink text-sm focus:outline-none focus:border-accent disabled:opacity-50 max-w-full";
export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
const [data, setData] = useState<OrgPreferredBots | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState<string | null>(null);
const [rowError, setRowError] = useState<{ id: string; message: string } | null>(null);
// A platform admin with no org of their own gets a 403 here: nothing to show
// (shared systems are set on /systems), so the card hides instead of erroring.
const [hidden, setHidden] = useState(false);
const load = useCallback(async () => {
try {
setData(await c2api.getOrgPreferredBots());
setLoadError(null);
} catch (err) {
const e = parseC2Error(err);
if (e.status === 403) setHidden(true);
else setLoadError(e.message);
}
}, []);
useEffect(() => {
if (canEdit) load();
}, [canEdit, load]);
async function choose(systemId: string, tokenId: string, currentId: string | null) {
if (tokenId === (currentId ?? "")) return;
setSaving(systemId);
setRowError(null);
try {
if (tokenId) await c2api.setPreferredToken(tokenId, systemId);
else if (currentId) await c2api.setPreferredToken(currentId, "_none");
await load();
} catch (err) {
setRowError({ id: systemId, message: parseC2Error(err).message });
} finally {
setSaving(null);
}
}
if (!canEdit || hidden) return null;
const bots = data?.bots ?? [];
const botsById = new Map(bots.map((b) => [b.token_id, b]));
return (
<Card>
<CardHeader
title="Radio bot per system"
subtitle="Which of your radio bots plays each system in Discord. DRB tries it first for every new radio channel of that system; if it can't (busy in that server, missing a permission, or not invited), another free bot is used."
/>
{loadError ? (
<p role="alert" className="text-sm text-sev-major">Couldn&apos;t load radio bots: {loadError}</p>
) : !data ? (
<p className="text-sm text-ink-muted">Loading…</p>
) : data.systems.length === 0 ? (
<p className="text-sm text-ink-muted">Your organization has no radio systems yet.</p>
) : bots.length === 0 ? (
<p className="text-sm text-ink-muted">
Your organization has no radio bots of its own, so DRB picks a free DRB radio bot for each channel. To use
your own bot for a system, ask DRB support to add it to your organization.
</p>
) : (
<div className="divide-y divide-line">
{data.systems.map((s) => {
const current = s.preferred_token_id ? botsById.get(s.preferred_token_id) : undefined;
const err = rowError?.id === s.system_id ? rowError.message : null;
const usedElsewhere = (tokenId: string) => {
const other = botsById.get(tokenId)?.preferred_for_system_id;
return other && other !== s.system_id ? data.systems.find((x) => x.system_id === other)?.name ?? "another system" : null;
};
return (
<div key={s.system_id} className="py-3 first:pt-0 last:pb-0 flex flex-wrap items-start gap-x-4 gap-y-1.5">
<div className="min-w-0 flex-1">
<div className="text-sm text-ink font-medium truncate">{s.name}</div>
<div className="text-xs text-ink-muted">
{current
? `${current.name} is tried first.`
: s.preferred_is_platform_bot
? "A DRB radio bot chosen by DRB support is tried first. Pick one of yours to replace it."
: "No preference — any free radio bot."}
{current?.valid === false && " Discord rejected this bot's token — DRB support has to replace it."}
{current && current.server_count === 0 && " It isn't in any Discord server yet — invite it first."}
</div>
{current?.invite_url && (
<a
href={current.invite_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-accent hover:underline"
>
Invite {current.name} to a Discord server
</a>
)}
{err && <div role="alert" className="text-xs text-sev-major mt-1">{err}</div>}
</div>
<label className="flex items-center gap-2 text-xs text-ink-muted">
<span className="sr-only">Preferred radio bot for {s.name}</span>
<select
value={s.preferred_token_id ?? ""}
disabled={saving !== null}
onChange={(e) => choose(s.system_id, e.target.value, s.preferred_token_id)}
className={SELECT}
>
<option value="">{s.preferred_is_platform_bot ? "DRB support's choice" : "Any free radio bot"}</option>
{bots.map((b) => {
const elsewhere = usedElsewhere(b.token_id);
return (
<option key={b.token_id} value={b.token_id} disabled={b.valid === false}>
{b.name}
{b.valid === false ? " — token rejected" : elsewhere ? ` — moves it off ${elsewhere}` : ""}
</option>
);
})}
</select>
{saving === s.system_id && <span>Saving…</span>}
</label>
</div>
);
})}
</div>
)}
</Card>
);
}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { import {
type DiscordAdminOverview, type DiscordAdminOverview,
@@ -12,8 +12,18 @@ import {
shortId, shortId,
streamHealth, streamHealth,
} from "@/lib/discord"; } from "@/lib/discord";
import {
blocksStart,
channelVerdict,
normalizeVoiceCheck,
parseC2Error as parseStartError,
type ParsedC2Error,
type VoiceCheck,
type VoiceCheckBot,
} from "@/lib/discordPicker";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui"; import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
import { BotDetails, CheckFooter, FixText, VerdictBadge } from "./VoiceCheckBits";
// Radio channels = discord_streams docs, desired vs actual. Start/Stop/Add go // Radio channels = discord_streams docs, desired vs actual. Start/Stop/Add go
// through the same routes the Discord bot uses (POST /discord/streams, // through the same routes the Discord bot uses (POST /discord/streams,
@@ -25,6 +35,11 @@ import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
// each with its own radio bot and system (server-26#213 follow-up). Stop, // each with its own radio bot and system (server-26#213 follow-up). Stop,
// Start and Remove all act on the row's stream_id, so the other streams in // Start and Remove all act on the row's stream_id, so the other streams in
// the same channel keep playing. Add never replaces an existing stream. // the same channel keep playing. Add never replaces an existing stream.
//
// The Add form checks the typed channel with Discord (server-26#225:
// GET /discord/guilds/{id}/voice-check, admin): per-bot status, bots that
// definitely can't stream there are disabled in the bot list, and Start is
// disabled only when every bot is definitely denied (unknown fails open).
type Invite = { name: string; invite_url: string | null }[]; type Invite = { name: string; invite_url: string | null }[];
@@ -48,7 +63,43 @@ function AddChannelForm({
const [systemId, setSystemId] = useState(""); const [systemId, setSystemId] = useState("");
const [botId, setBotId] = useState(""); const [botId, setBotId] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<{ message: string; invite: Invite } | null>(null); const [error, setError] = useState<(Omit<ParsedC2Error, "invite"> & { invite: Invite }) | null>(null);
const [check, setCheck] = useState<
{ key: string; data: VoiceCheck | null; busy: boolean; error: string | null; limited: boolean } | null
>(null);
const cid = channelId.trim();
const checkKey = SNOWFLAKE.test(cid) && guildId ? `${guildId}:${cid}` : "";
const runCheck = useCallback(async (key: string, refresh: boolean) => {
const [gid, ch] = key.split(":");
setCheck((c) => ({ key, data: c?.key === key ? c.data : null, busy: true, error: null, limited: false }));
try {
const data = normalizeVoiceCheck(await c2api.getGuildVoiceCheck(gid, ch, refresh));
setCheck((c) => (c?.key === key ? { key, data, busy: false, error: null, limited: refresh && data.refreshed === false } : c));
} catch (err) {
const e = parseStartError(err);
setCheck((c) =>
c?.key === key ? { key, data: c.data, busy: false, error: e.status === 404 ? null : e.message, limited: false } : c,
);
}
}, []);
// Check the channel once its ID looks complete (debounced while typing).
useEffect(() => {
if (!checkKey) {
setCheck(null);
return;
}
const t = setTimeout(() => runCheck(checkKey, false), 400);
return () => clearTimeout(t);
}, [checkKey, runCheck]);
const liveCheck = check && check.key === checkKey ? check : null;
const checkOn = liveCheck?.data?.enabled !== false;
const checked = liveCheck?.data && checkOn ? liveCheck.data.channels[cid] : undefined;
const verdict = liveCheck?.data && checkOn ? channelVerdict(checked) : null;
const shownName = channelName.trim() || checked?.name || null;
// Per-bot result, by token id (the admin route sends ids) or else by name.
const botCheck = (b: RadioBot): VoiceCheckBot | undefined =>
checked?.bots.find((x) => (x.token_id ? x.token_id === b.token_id : x.name === b.name || x.name === b.bot_name));
const org = guilds.find((g) => g.guild_id === guildId)?.acting_org_id ?? 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 systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org);
@@ -58,6 +109,8 @@ function AddChannelForm({
for (const s of overview.streams) { for (const s of overview.streams) {
if (s.desired === "on" && s.guild_id === guildId && s.token_id) busyHere.set(s.token_id, s); if (s.desired === "on" && s.guild_id === guildId && s.token_id) busyHere.set(s.token_id, s);
} }
const chosenBot = botId ? bots.find((b) => b.token_id === botId) : undefined;
const chosenBotDenied = chosenBot ? botCheck(chosenBot)?.ok === false : false;
const inChannel = overview.streams.filter( const inChannel = overview.streams.filter(
(s) => s.desired === "on" && s.guild_id === guildId && s.channel_id === channelId.trim(), (s) => s.desired === "on" && s.guild_id === guildId && s.channel_id === channelId.trim(),
); );
@@ -65,7 +118,7 @@ function AddChannelForm({
async function submit(e: React.FormEvent) { async function submit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!SNOWFLAKE.test(channelId.trim())) { 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: [] }); setError({ message: "Voice channel ID is the 17–20 digit number from Discord (right-click the channel → Copy Channel ID).", invite: [], code: null, status: null });
return; return;
} }
setSaving(true); setSaving(true);
@@ -86,12 +139,16 @@ function AddChannelForm({
setError({ setError({
message: `${res.system_name || "That system"} is already playing in this voice channel, so nothing was added or changed. Pick another system to add a second stream, or Remove the existing row to change its label or bot.`, message: `${res.system_name || "That system"} is already playing in this voice channel, so nothing was added or changed. Pick another system to add a second stream, or Remove the existing row to change its label or bot.`,
invite: [], invite: [],
code: "already_on",
status: null,
}); });
return; return;
} }
onDone(); onDone();
} catch (err) { } catch (err) {
setError(parseC2Error(err)); const e = parseStartError(err);
setError({ ...e, invite: parseC2Error(err).invite });
if (e.code === "missing_permissions" && checkKey) runCheck(checkKey, false);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -142,16 +199,42 @@ function AddChannelForm({
<option value="">Any free bot in this server</option> <option value="">Any free bot in this server</option>
{usableBots.map((b) => { {usableBots.map((b) => {
const busy = busyHere.get(b.token_id); const busy = busyHere.get(b.token_id);
const bc = botCheck(b);
const denied = bc?.ok === false;
return ( return (
<option key={b.token_id} value={b.token_id} disabled={!!busy}> <option key={b.token_id} value={b.token_id} disabled={!!busy || denied}>
{busy {busy
? `${b.name} — in use here (${busy.system_name || busy.system_id || "radio"}${busy.channel_name ? ` in ${busy.channel_name}` : ""})` ? `${b.name} — in use here (${busy.system_name || busy.system_id || "radio"}${busy.channel_name ? ` in ${busy.channel_name}` : ""})`
: `Use ${b.name}`} : denied
? `${b.name} — can't ${bc.missing.length ? bc.missing.join(", ") : "join"} in this channel`
: `Use ${b.name}`}
</option> </option>
); );
})} })}
</select> </select>
</Field> </Field>
{checkKey && (
<div className="sm:col-span-3 space-y-1">
<div className="flex items-center gap-2 text-xs text-ink-muted">
<span>Permissions in {shownName ? `#${shownName.replace(/^#/, "")}` : "this channel"}:</span>
{checkOn && <VerdictBadge verdict={verdict} loading={liveCheck?.busy} />}
</div>
{verdict && <FixText verdict={verdict} channelName={shownName} />}
{chosenBotDenied && (
<div className="text-xs text-sev-major">The bot you picked can&apos;t stream into this channel — pick another, or fix it in Discord and Re-check.</div>
)}
{checkOn && <BotDetails channel={checked} channelName={shownName} />}
<CheckFooter
checkedAt={liveCheck?.data?.checked_at ?? null}
busy={!!liveCheck?.busy}
error={liveCheck?.error ?? null}
disabled={!checkOn}
limited={!!liveCheck?.limited}
invite={verdict && ["fix", "no_bot", "busy"].includes(verdict.kind) ? liveCheck?.data?.invite : undefined}
onRecheck={() => runCheck(checkKey, true)}
/>
</div>
)}
{inChannel.length > 0 && ( {inChannel.length > 0 && (
<div className="sm:col-span-3 text-xs text-ink-muted"> <div className="sm:col-span-3 text-xs text-ink-muted">
This voice channel already plays{" "} This voice channel already plays{" "}
@@ -160,13 +243,20 @@ function AddChannelForm({
</div> </div>
)} )}
{error && ( {error && (
<div className="sm:col-span-3 text-xs text-sev-major"> <div role="alert" className="sm:col-span-3 text-xs text-sev-major whitespace-pre-line">
{error.message} {error.message}
<InviteLinks invite={error.invite} /> <InviteLinks invite={error.invite} />
</div> </div>
)} )}
<div className="sm:col-span-3 flex gap-2"> <div className="sm:col-span-3 flex gap-2">
<Button type="submit" size="sm" disabled={saving || !systemId}>{saving ? "Starting…" : "Start radio channel"}</Button> <Button
type="submit"
size="sm"
disabled={saving || !systemId || blocksStart(verdict) || chosenBotDenied}
title={blocksStart(verdict) ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined}
>
{saving ? "Starting…" : "Start radio channel"}
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button> <Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
</div> </div>
</form> </form>
@@ -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<ChannelVerdict["kind"], { label: string; cls: string; title?: string }> = {
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 ? <span className={`${PILL} border-line text-ink-muted`}>Checking…</span> : 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 (
<span className="inline-flex gap-1 flex-wrap">
<span className={`${PILL} ${b.cls}`} title={title}>{label}</span>
{verdict.limit > 0 && (
<span
className={`${PILL} border-line text-ink-muted`}
title="Discord user limit. DRB can't see who is connected, so a full channel only shows up when the bot tries to join."
>
Limit {verdict.limit}
</span>
)}
</span>
);
}
/** 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 <span className="block text-xs text-sev-major mt-1">That is not a voice channel. Radio can only play in a voice channel.</span>;
}
if (verdict.kind !== "fix") return null;
return (
<span className="block text-xs text-sev-major mt-1">
{deniedSentence(verdict.bot, channelName)} {fixSentence(verdict.bot)}
</span>
);
}
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 (
<details className="text-xs mt-1">
<summary className="cursor-pointer text-ink-muted hover:text-ink select-none">
Radio bots in this server ({channel.bots.length})
</summary>
<ul className="mt-1 space-y-0.5 pl-1">
{channel.bots.map((b, i) => {
const l = botLine(b, channelName);
return (
<li key={`${b.token_id ?? b.name}-${i}`} className={TONE[l.tone]}>
<span aria-hidden className="inline-block w-3">{MARK[l.tone]}</span> {l.text}
{b.status === "denied" && <span className="block pl-3 text-ink-muted">{fixSentence(b)}</span>}
</li>
);
})}
</ul>
</details>
);
}
/** "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 <div className="text-xs text-ink-muted">Permission check is off — DRB tries any free radio bot.</div>;
}
return (
<div className="text-xs text-ink-muted flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{busy ? "Checking permissions…" : ago ? `Permissions checked ${ago}` : "Permissions not checked yet"}</span>
<span aria-hidden>·</span>
<button type="button" onClick={onRecheck} disabled={busy} className="text-accent hover:underline disabled:opacity-50">
Re-check
</button>
{limited && !busy && (
<span className="basis-full">Re-check runs at most once every 10 seconds — this is the last result. Try again in a moment.</span>
)}
{error && <span className="basis-full text-sev-moderate">Couldn&apos;t check permissions: {error}</span>}
{invite && invite.length > 0 && (
<span className="basis-full flex flex-wrap gap-x-3 gap-y-1">
<span>Add another radio bot to this server:</span>
{invite.map((i) => (
<a key={i.invite_url} href={i.invite_url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">
Invite {i.name}
</a>
))}
</span>
)}
</div>
);
}
+17 -1
View File
@@ -148,6 +148,21 @@ export const c2api = {
`/discord/nodes/${nodeId}/streams/${encodeURIComponent(streamId)}`, `/discord/nodes/${nodeId}/streams/${encodeURIComponent(streamId)}`,
{ method: "DELETE" }, { 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<unknown>(
`/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<import("@/lib/discordPicker").OrgPreferredBots>("/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<unknown>(
`/discord/guilds/${encodeURIComponent(guildId)}/voice-check?channel_id=${encodeURIComponent(channelId)}&refresh=${refresh ? 1 : 0}`,
),
deleteNode: (id: string) => deleteNode: (id: string) =>
request(`/nodes/${id}`, { method: "DELETE" }), request(`/nodes/${id}`, { method: "DELETE" }),
@@ -344,7 +359,8 @@ export const c2api = {
getReplayIncidents: (runId: string) => getReplayIncidents: (runId: string) =>
request<import("@/lib/types").ReplayIncidents>(`/admin/replay/${runId}/incidents`), request<import("@/lib/types").ReplayIncidents>(`/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) => setPreferredToken: (tokenId: string, systemId: string) =>
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }), request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),
+268 -4
View File
@@ -69,6 +69,27 @@ export interface DiscordJoinOptions {
streams: DiscordStreamView[]; 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 { export interface DiscordStartResult {
ok: boolean; ok: boolean;
stream_id: string; stream_id: string;
@@ -77,10 +98,38 @@ export interface DiscordStartResult {
reused: boolean | null; 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, unknown>): 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<string, unknown>)
.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 <status>: <body>`. Pull the /** c2api's request() throws `C2 API error <status>: <body>`. Pull the
* detail + invite links back out of a JSON body when there is one. */ * detail, code and invite links back out of a JSON body. */
export function parseC2Error(err: unknown): { message: string; invite: DiscordInvite[] } { export function parseC2Error(err: unknown): ParsedC2Error {
const raw = err instanceof Error ? err.message : String(err); 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(": "); const idx = raw.indexOf(": ");
if (idx >= 0) { if (idx >= 0) {
try { try {
@@ -89,10 +138,225 @@ export function parseC2Error(err: unknown): { message: string; invite: DiscordIn
const invite = Array.isArray(body?.invite) const invite = Array.isArray(body?.invite)
? (body.invite as DiscordInvite[]).filter((i) => i && typeof i.invite_url === "string") ? (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 { } catch {
/* not JSON — fall through */ /* 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<string, VoiceCheckChannel>;
/** 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<string, unknown>): 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<string, unknown>, 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<string, unknown>[]).filter(Boolean).map(normBot) : [],
};
}
export function normalizeVoiceCheck(raw: unknown): VoiceCheck {
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
const channels: Record<string, VoiceCheckChannel> = {};
if (Array.isArray(r.channels)) {
for (const c of r.channels as Record<string, unknown>[]) {
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` };
}
} }