Discord picker: per-bot permission status, owner's preferred bot (server-26#225 S4)

Node-page picker and the admin Add form now show, per voice channel, whether
a radio bot can stream there, from S3's voice-check routes:
- Badge per channel: Ready / Needs fix (names the missing permission) /
  Couldn't check / Bots busy / No radio bot / Not a voice channel / Limit N.
- The chosen channel shows "<bot> can't Speak in #dispatch: <what removed it>"
  plus c2-core's fix sentence, and an expandable per-bot list.
- Footer: "Permissions checked 40s ago · Re-check" (refresh=1, rate limited
  server-side; a limited re-check says so). Invite links when no bot fits.
- Start is disabled only on a definite refusal (needs_fix, bad_channel);
  unknown fails open. A channel this system already plays in is judged by
  the bot that carries it, so Update/move is never blocked by the other bots.
- Admin Add form disables bots that are definitely denied in the bot list.
- 409 refusals (missing_permissions, preferred_busy, preferred_other_node,
  bad_channel) are shown verbatim, multi-line.

Org owners (owner decision Q2) get "Radio bot per system" on
Settings > Organization: their own systems, their own BYO bots, set/clear
through S3's PUT /tokens/{id}/prefer/{system}. New GET /tokens/org/preferred
lists only the caller org's systems and bots (no pool bots, no token
strings; a pool bot an admin chose is flagged, not named).

The prefer route now drops the 5-minute doc_get_cached entry for every
system it rewrites, so a new preference applies to the next start.

Stacked on feat/225-s3-perm-check: must merge after S3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 02:28:36 -04:00
co-authored by Claude Opus 5.5
parent 2c2cf9d9be
commit 1ee1560383
9 changed files with 938 additions and 20 deletions
+61 -1
View File
@@ -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)
@@ -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
@@ -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() {
<DiscordServersCard canEdit={canEdit} isAdmin={isAdmin} />
<PreferredBotsCard canEdit={canEdit} />
<div className="bg-gray-900 border border-red-800/60 rounded-xl p-5">
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
<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
// 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<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 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 (
<label
key={c.channel_id}
@@ -227,7 +283,11 @@ function DiscordPicker({
}}
/>
<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 && (
<span className="text-xs text-ink-muted block">
{here
@@ -241,6 +301,9 @@ function DiscordPicker({
{!c.stream && ` — this adds ${options.system_name ?? "this node's system"} beside it with another bot`}
</span>
)}
{c.channel_id === channelId && checkOn && (
<BotDetails channel={check?.data?.channels[c.channel_id]} channelName={c.name} />
)}
</span>
</label>
);
@@ -248,6 +311,15 @@ function DiscordPicker({
</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>
)}
</fieldset>
@@ -299,10 +371,10 @@ function DiscordPicker({
</p>
)}
{error && (
<p role="alert" className="text-xs text-sev-major">
<div role="alert" className="text-xs text-sev-major whitespace-pre-line">
{error.message}
<InviteLinks invite={error.invite} />
</p>
</div>
)}
</div>
@@ -310,7 +382,8 @@ function DiscordPicker({
<button
type="button"
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"
>
{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";
import { useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { c2api } from "@/lib/c2api";
import {
type DiscordAdminOverview,
@@ -12,8 +12,18 @@ import {
shortId,
streamHealth,
} 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 { 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
// 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,
// 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 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 }[];
@@ -48,7 +63,43 @@ function AddChannelForm({
const [systemId, setSystemId] = useState("");
const [botId, setBotId] = useState("");
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 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) {
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(
(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) {
e.preventDefault();
if (!SNOWFLAKE.test(channelId.trim())) {
setError({ message: "Voice channel ID is the 17–20 digit number from Discord (right-click the channel → Copy Channel ID).", invite: [] });
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;
}
setSaving(true);
@@ -86,12 +139,16 @@ function AddChannelForm({
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.`,
invite: [],
code: "already_on",
status: null,
});
return;
}
onDone();
} 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 {
setSaving(false);
}
@@ -142,16 +199,42 @@ function AddChannelForm({
<option value="">Any free bot in this server</option>
{usableBots.map((b) => {
const busy = busyHere.get(b.token_id);
const bc = botCheck(b);
const denied = bc?.ok === false;
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
? `${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>
);
})}
</select>
</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 && (
<div className="sm:col-span-3 text-xs text-ink-muted">
This voice channel already plays{" "}
@@ -160,13 +243,20 @@ function AddChannelForm({
</div>
)}
{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}
<InviteLinks invite={error.invite} />
</div>
)}
<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>
</div>
</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)}`,
{ 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) =>
request(`/nodes/${id}`, { method: "DELETE" }),
@@ -344,7 +359,8 @@ export const c2api = {
getReplayIncidents: (runId: string) =>
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) =>
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[];
}
/** 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, 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
* 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<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` };
}
}