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:
co-authored by
Claude Opus 5.5
parent
2c2cf9d9be
commit
1ee1560383
@@ -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">
|
||||
|
||||
@@ -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'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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" }),
|
||||
|
||||
|
||||
@@ -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` };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user