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
|
import uuid
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
@@ -12,6 +12,7 @@ from app.internal.auth import (
|
|||||||
get_role,
|
get_role,
|
||||||
require_admin_token,
|
require_admin_token,
|
||||||
require_firebase_token,
|
require_firebase_token,
|
||||||
|
resolve_org_scope,
|
||||||
)
|
)
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
|
|
||||||
@@ -101,6 +102,16 @@ async def flush_tokens(_: dict = Depends(require_admin_token)):
|
|||||||
return {"released": released}
|
return {"released": released}
|
||||||
|
|
||||||
|
|
||||||
|
def _forget_cached_system(system_id: str) -> None:
|
||||||
|
"""start_stream reads systems through fstore.doc_get_cached (5 min TTL):
|
||||||
|
drop the entry so a new preference applies to the next start instead of
|
||||||
|
up to five minutes later (server-26#225 S4)."""
|
||||||
|
cache = getattr(fstore, "_doc_cache", None)
|
||||||
|
path = getattr(fstore, "_path", None)
|
||||||
|
if isinstance(cache, dict) and callable(path):
|
||||||
|
cache.pop(f"{path('systems')}/{system_id}", None)
|
||||||
|
|
||||||
|
|
||||||
def _may_edit_system(decoded: dict, system: Optional[dict]) -> bool:
|
def _may_edit_system(decoded: dict, system: Optional[dict]) -> bool:
|
||||||
"""Platform admin: any system. Org owner: only their own org's systems —
|
"""Platform admin: any system. Org owner: only their own org's systems —
|
||||||
a shared system (no org_id) serves every org, so a preference there is
|
a shared system (no org_id) serves every org, so a preference there is
|
||||||
@@ -167,6 +178,7 @@ async def set_preferred_system(
|
|||||||
raise HTTPException(403, "That system is shared by every organization; only a platform admin "
|
raise HTTPException(403, "That system is shared by every organization; only a platform admin "
|
||||||
"can change its radio bot.")
|
"can change its radio bot.")
|
||||||
await fstore.doc_set("systems", old_id, {"preferred_token_id": None})
|
await fstore.doc_set("systems", old_id, {"preferred_token_id": None})
|
||||||
|
_forget_cached_system(old_id)
|
||||||
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": None})
|
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": None})
|
||||||
await write_audit(uid, email, "discord.token.prefer", details={
|
await write_audit(uid, email, "discord.token.prefer", details={
|
||||||
"token_id": token_id, "system_id": None, "previous_system_id": old_id or None})
|
"token_id": token_id, "system_id": None, "previous_system_id": old_id or None})
|
||||||
@@ -183,6 +195,7 @@ async def set_preferred_system(
|
|||||||
old_sys = await fstore.doc_get("systems", old_sys_id)
|
old_sys = await fstore.doc_get("systems", old_sys_id)
|
||||||
if old_sys and old_sys.get("preferred_token_id") == token_id and _may_edit_system(decoded, old_sys):
|
if old_sys and old_sys.get("preferred_token_id") == token_id and _may_edit_system(decoded, old_sys):
|
||||||
await fstore.doc_set("systems", old_sys_id, {"preferred_token_id": None})
|
await fstore.doc_set("systems", old_sys_id, {"preferred_token_id": None})
|
||||||
|
_forget_cached_system(old_sys_id)
|
||||||
old_tok_id = system_doc.get("preferred_token_id")
|
old_tok_id = system_doc.get("preferred_token_id")
|
||||||
if old_tok_id and old_tok_id != token_id:
|
if old_tok_id and old_tok_id != token_id:
|
||||||
old_tok = await fstore.doc_get("bot_tokens", old_tok_id)
|
old_tok = await fstore.doc_get("bot_tokens", old_tok_id)
|
||||||
@@ -190,6 +203,7 @@ async def set_preferred_system(
|
|||||||
await fstore.doc_set("bot_tokens", old_tok_id, {"preferred_for_system_id": None})
|
await fstore.doc_set("bot_tokens", old_tok_id, {"preferred_for_system_id": None})
|
||||||
|
|
||||||
await fstore.doc_set("systems", system_id, {"preferred_token_id": token_id})
|
await fstore.doc_set("systems", system_id, {"preferred_token_id": token_id})
|
||||||
|
_forget_cached_system(system_id)
|
||||||
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": system_id})
|
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": system_id})
|
||||||
await write_audit(uid, email, "discord.token.prefer", details={
|
await write_audit(uid, email, "discord.token.prefer", details={
|
||||||
"token_id": token_id, "system_id": system_id, "previous_token_id": old_tok_id,
|
"token_id": token_id, "system_id": system_id, "previous_token_id": old_tok_id,
|
||||||
@@ -197,6 +211,52 @@ async def set_preferred_system(
|
|||||||
return {"ok": True, "preferred_for_system_id": system_id}
|
return {"ok": True, "preferred_for_system_id": system_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/org/preferred")
|
||||||
|
async def org_preferred_bots(
|
||||||
|
org_id: Optional[str] = Query(None, description="Platform-admin only: act for this org."),
|
||||||
|
decoded: dict = Depends(require_firebase_token),
|
||||||
|
):
|
||||||
|
"""What an org owner needs to choose a preferred radio bot per system
|
||||||
|
(server-26#225 S4, owner decision Q2): the org's own systems, each with
|
||||||
|
its preferred bot, and the org's own bring-your-own bots — the only bots
|
||||||
|
an owner may choose (PUT /tokens/{id}/prefer/{system_id}). GET /tokens
|
||||||
|
stays platform-admin only; this returns no pool bots and no token strings.
|
||||||
|
A pool bot a platform admin chose for one of these systems is flagged,
|
||||||
|
not named."""
|
||||||
|
if get_role(decoded) != "admin" and get_org_role(decoded) != "owner":
|
||||||
|
raise HTTPException(403, "Only the organization owner or a platform admin can choose a system's radio bot.")
|
||||||
|
org = resolve_org_scope(decoded, org_id)
|
||||||
|
systems = sorted(await fstore.collection_list("systems", org_id=org),
|
||||||
|
key=lambda s: str(s.get("name") or s.get("system_id") or "").lower())
|
||||||
|
bots = sorted((t for t in await fstore.collection_list("bot_tokens", org_id=org) if t.get("token_id")),
|
||||||
|
key=lambda t: str(t.get("name") or "").lower())
|
||||||
|
names = {t["token_id"]: t.get("name") or t["token_id"] for t in bots}
|
||||||
|
out_systems = []
|
||||||
|
for s in systems:
|
||||||
|
if not s.get("system_id"):
|
||||||
|
continue
|
||||||
|
pref = s.get("preferred_token_id")
|
||||||
|
out_systems.append({
|
||||||
|
"system_id": s["system_id"],
|
||||||
|
"name": s.get("name") or s["system_id"],
|
||||||
|
"preferred_token_id": pref if pref in names else None,
|
||||||
|
"preferred_is_platform_bot": bool(pref) and pref not in names,
|
||||||
|
"preferred_name": names.get(pref) if pref in names else None,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"org_id": org,
|
||||||
|
"systems": out_systems,
|
||||||
|
"bots": [{
|
||||||
|
"token_id": t["token_id"],
|
||||||
|
"name": names[t["token_id"]],
|
||||||
|
"valid": t.get("valid"),
|
||||||
|
"server_count": len(t["guild_ids"]) if isinstance(t.get("guild_ids"), list) else None,
|
||||||
|
"preferred_for_system_id": t.get("preferred_for_system_id"),
|
||||||
|
"invite_url": leases.invite_url(t.get("application_id")),
|
||||||
|
} for t in bots],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{token_id}", status_code=204)
|
@router.delete("/{token_id}", status_code=204)
|
||||||
async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
|
async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
|
||||||
existing = await fstore.doc_get("bot_tokens", token_id)
|
existing = await fstore.doc_get("bot_tokens", token_id)
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
Org-owner "radio bot per system" card (server-26#225 S4, owner decision Q2):
|
||||||
|
GET /tokens/org/preferred lists the owner's own systems and own BYO bots (no
|
||||||
|
pool bots, no token strings); a preference set through
|
||||||
|
PUT /tokens/{id}/prefer/{system} (S3) applies to the very next start, not after
|
||||||
|
the 5-minute systems cache.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
|
||||||
|
from app.main import app
|
||||||
|
from tests.fake_firestore import FakeFirestore
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
ORG, OTHER = "org-a", "org-b"
|
||||||
|
OWNER = {"uid": "u-own", "org_id": ORG, "org_role": "owner"}
|
||||||
|
MEMBER = {"uid": "u-mem", "org_id": ORG, "org_role": "member", "role": "operator"}
|
||||||
|
STRANGER = {"uid": "u-x", "org_id": OTHER, "org_role": "owner"}
|
||||||
|
ADMIN_NO_ORG = {"uid": "u-admin", "role": "admin"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fs(monkeypatch):
|
||||||
|
fake = FakeFirestore.install(monkeypatch)
|
||||||
|
fake.put("systems", "s-a", {"system_id": "s-a", "name": "County P25", "org_id": ORG})
|
||||||
|
fake.put("systems", "s-a2", {"system_id": "s-a2", "name": "City Fire", "org_id": ORG})
|
||||||
|
fake.put("systems", "s-b", {"system_id": "s-b", "name": "Other County", "org_id": OTHER})
|
||||||
|
fake.put("systems", "s-shared", {"system_id": "s-shared", "name": "Shared", "org_id": None})
|
||||||
|
fake.put("bot_tokens", "byo-a", {"token_id": "byo-a", "name": "Alpha Radio", "token": "secret-a",
|
||||||
|
"org_id": ORG, "valid": True, "guild_ids": ["100", "101"],
|
||||||
|
"application_id": "555"})
|
||||||
|
fake.put("bot_tokens", "byo-a-bad", {"token_id": "byo-a-bad", "name": "Broken", "token": "secret-bad",
|
||||||
|
"org_id": ORG, "valid": False})
|
||||||
|
fake.put("bot_tokens", "byo-b", {"token_id": "byo-b", "name": "Bravo Radio", "token": "secret-b",
|
||||||
|
"org_id": OTHER, "valid": True})
|
||||||
|
fake.put("bot_tokens", "pool-1", {"token_id": "pool-1", "name": "DRB Radio 1", "token": "secret-p",
|
||||||
|
"org_id": None, "valid": True})
|
||||||
|
yield fake
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def as_(who):
|
||||||
|
# /tokens is mounted behind require_service_or_firebase_token (main.py).
|
||||||
|
app.dependency_overrides[require_service_or_firebase_token] = lambda: who
|
||||||
|
app.dependency_overrides[require_firebase_token] = lambda: who
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_lists_own_systems_and_own_bots_only(fs):
|
||||||
|
as_(OWNER)
|
||||||
|
r = client.get("/tokens/org/preferred")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["org_id"] == ORG
|
||||||
|
assert [s["system_id"] for s in body["systems"]] == ["s-a2", "s-a"] # own systems, by name
|
||||||
|
assert {b["token_id"] for b in body["bots"]} == {"byo-a", "byo-a-bad"} # no pool, no other org
|
||||||
|
alpha = next(b for b in body["bots"] if b["token_id"] == "byo-a")
|
||||||
|
assert alpha["server_count"] == 2 and alpha["invite_url"] and alpha["valid"] is True
|
||||||
|
assert "secret" not in repr(body) # never a token string
|
||||||
|
|
||||||
|
|
||||||
|
def test_member_is_refused(fs):
|
||||||
|
as_(MEMBER)
|
||||||
|
assert client.get("/tokens/org/preferred").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_without_org_gets_403_not_every_org(fs):
|
||||||
|
as_(ADMIN_NO_ORG)
|
||||||
|
assert client.get("/tokens/org/preferred").status_code == 403
|
||||||
|
# With ?org_id= a platform admin may act for that org (resolve_org_scope).
|
||||||
|
body = client.get(f"/tokens/org/preferred?org_id={OTHER}").json()
|
||||||
|
assert [s["system_id"] for s in body["systems"]] == ["s-b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_org_override_is_ignored(fs):
|
||||||
|
as_(STRANGER)
|
||||||
|
body = client.get(f"/tokens/org/preferred?org_id={ORG}").json()
|
||||||
|
assert body["org_id"] == OTHER
|
||||||
|
assert [s["system_id"] for s in body["systems"]] == ["s-b"]
|
||||||
|
assert [b["token_id"] for b in body["bots"]] == ["byo-b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_platform_pick_on_owner_system_is_flagged_not_named(fs):
|
||||||
|
fs.put("systems", "s-a", {"system_id": "s-a", "name": "County P25", "org_id": ORG,
|
||||||
|
"preferred_token_id": "pool-1"})
|
||||||
|
as_(OWNER)
|
||||||
|
s = next(x for x in client.get("/tokens/org/preferred").json()["systems"] if x["system_id"] == "s-a")
|
||||||
|
assert s["preferred_token_id"] is None and s["preferred_name"] is None
|
||||||
|
assert s["preferred_is_platform_bot"] is True
|
||||||
|
assert "DRB Radio 1" not in repr(s)
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_set_then_list_round_trip_and_cache_is_dropped(fs):
|
||||||
|
stale = (1e18, {"system_id": "s-a", "org_id": ORG}) # what start_stream would read for 5 min
|
||||||
|
fstore._doc_cache[f"{fstore._path('systems')}/s-a"] = stale
|
||||||
|
fstore._doc_cache[f"{fstore._path('systems')}/s-a2"] = stale
|
||||||
|
as_(OWNER)
|
||||||
|
assert client.put("/tokens/byo-a/prefer/s-a").status_code == 200
|
||||||
|
assert f"{fstore._path('systems')}/s-a" not in fstore._doc_cache
|
||||||
|
s = next(x for x in client.get("/tokens/org/preferred").json()["systems"] if x["system_id"] == "s-a")
|
||||||
|
assert s["preferred_token_id"] == "byo-a" and s["preferred_name"] == "Alpha Radio"
|
||||||
|
|
||||||
|
# Moving the bot to another system drops both systems' cached copies.
|
||||||
|
fstore._doc_cache[f"{fstore._path('systems')}/s-a"] = stale
|
||||||
|
assert client.put("/tokens/byo-a/prefer/s-a2").status_code == 200
|
||||||
|
assert f"{fstore._path('systems')}/s-a" not in fstore._doc_cache
|
||||||
|
assert f"{fstore._path('systems')}/s-a2" not in fstore._doc_cache
|
||||||
|
|
||||||
|
fstore._doc_cache[f"{fstore._path('systems')}/s-a2"] = stale
|
||||||
|
assert client.put("/tokens/byo-a/prefer/_none").status_code == 200
|
||||||
|
assert f"{fstore._path('systems')}/s-a2" not in fstore._doc_cache
|
||||||
|
assert fs.get("systems", "s-a2")["preferred_token_id"] is None
|
||||||
@@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/Badge";
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Skeleton } from "@/components/ui/Skeleton";
|
import { Skeleton } from "@/components/ui/Skeleton";
|
||||||
import { DiscordServersCard } from "@/components/DiscordServersCard";
|
import { DiscordServersCard } from "@/components/DiscordServersCard";
|
||||||
|
import { PreferredBotsCard } from "@/components/discord/PreferredBotsCard";
|
||||||
|
|
||||||
function StatTile({ label, value }: { label: string; value: string | number }) {
|
function StatTile({ label, value }: { label: string; value: string | number }) {
|
||||||
return (
|
return (
|
||||||
@@ -121,6 +122,8 @@ export default function OrganizationSettingsPage() {
|
|||||||
|
|
||||||
<DiscordServersCard canEdit={canEdit} isAdmin={isAdmin} />
|
<DiscordServersCard canEdit={canEdit} isAdmin={isAdmin} />
|
||||||
|
|
||||||
|
<PreferredBotsCard canEdit={canEdit} />
|
||||||
|
|
||||||
<div className="bg-gray-900 border border-red-800/60 rounded-xl p-5">
|
<div className="bg-gray-900 border border-red-800/60 rounded-xl p-5">
|
||||||
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
|
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
|
|||||||
@@ -10,17 +10,27 @@
|
|||||||
// plays another system ADDS this node's system beside it; only a stream of
|
// plays another system ADDS this node's system beside it; only a stream of
|
||||||
// this node's own system there is restarted / moved here.
|
// this node's own system there is restarted / moved here.
|
||||||
// Layout follows design-canvas/discord-system-mockup.html "Radio channels".
|
// Layout follows design-canvas/discord-system-mockup.html "Radio channels".
|
||||||
|
// Each voice channel shows whether a radio bot can actually stream there
|
||||||
|
// (server-26#225): Ready / Needs fix (which permission, what removed it, where
|
||||||
|
// to fix it) / Couldn't check / Limit N. Start is disabled only on a definite
|
||||||
|
// deny; an unanswered check fails open.
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import {
|
import {
|
||||||
|
blocksStart,
|
||||||
|
channelVerdict,
|
||||||
|
normalizeVoiceCheck,
|
||||||
parseC2Error,
|
parseC2Error,
|
||||||
type DiscordGuildOption,
|
type DiscordGuildOption,
|
||||||
type DiscordInvite,
|
type DiscordInvite,
|
||||||
type DiscordJoinOptions,
|
type DiscordJoinOptions,
|
||||||
type DiscordStreamView,
|
type DiscordStreamView,
|
||||||
|
type ParsedC2Error,
|
||||||
type StreamState,
|
type StreamState,
|
||||||
|
type VoiceCheck,
|
||||||
} from "@/lib/discordPicker";
|
} from "@/lib/discordPicker";
|
||||||
|
import { BotDetails, CheckFooter, FixText, VerdictBadge } from "@/components/discord/VoiceCheckBits";
|
||||||
|
|
||||||
const POLL_MS = 10_000;
|
const POLL_MS = 10_000;
|
||||||
|
|
||||||
@@ -93,12 +103,53 @@ function DiscordPicker({
|
|||||||
const [channelId, setChannelId] = useState("");
|
const [channelId, setChannelId] = useState("");
|
||||||
const [mode, setMode] = useState<"adhoc" | "persistent">("adhoc");
|
const [mode, setMode] = useState<"adhoc" | "persistent">("adhoc");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [error, setError] = useState<{ message: string; invite: DiscordInvite[] } | null>(null);
|
const [error, setError] = useState<ParsedC2Error | null>(null);
|
||||||
|
// Permission check per server: the result, whether a load is in flight, and
|
||||||
|
// why the last load failed (shown under the footer; the badges fail open).
|
||||||
|
const [checks, setChecks] = useState<
|
||||||
|
Record<string, { data: VoiceCheck | null; busy: boolean; error: string | null; limited: boolean }>
|
||||||
|
>({});
|
||||||
|
|
||||||
const guild: DiscordGuildOption | undefined = usable.find((g) => g.guild_id === guildId);
|
const guild: DiscordGuildOption | undefined = usable.find((g) => g.guild_id === guildId);
|
||||||
const channel = guild?.voice_channels.find((c) => c.channel_id === channelId);
|
const channel = guild?.voice_channels.find((c) => c.channel_id === channelId);
|
||||||
const atNodeCap = options.limits.node_used >= options.limits.node_max;
|
const atNodeCap = options.limits.node_used >= options.limits.node_max;
|
||||||
const channelOnHere = channel?.stream?.node_id === nodeId;
|
const channelOnHere = channel?.stream?.node_id === nodeId;
|
||||||
|
const check = guildId ? checks[guildId] : undefined;
|
||||||
|
const checkOn = check?.data?.enabled !== false;
|
||||||
|
const verdictOf = (c: DiscordGuildOption["voice_channels"][number]) => {
|
||||||
|
const row = check?.data && checkOn ? check.data.channels[c.channel_id] : undefined;
|
||||||
|
const v = channelVerdict(row);
|
||||||
|
// This system already plays here: c2-core counts its bot as busy (it holds
|
||||||
|
// the channel), so the channel can read "Needs fix"/"Bots busy" for a NEW
|
||||||
|
// bot while a restart/move keeps the one that works. Judge by that bot.
|
||||||
|
const own = c.stream?.token_name ? row?.bots.find((b) => b.name === c.stream?.token_name) : undefined;
|
||||||
|
if (v && own?.ok === true && v.kind !== "ready") return { kind: "ready" as const, limit: v.limit };
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
const chosenVerdict = channel ? verdictOf(channel) : null;
|
||||||
|
const definiteDeny = blocksStart(chosenVerdict);
|
||||||
|
const showInvite = chosenVerdict?.kind === "fix" || chosenVerdict?.kind === "no_bot" || chosenVerdict?.kind === "busy";
|
||||||
|
|
||||||
|
const runCheck = useCallback(
|
||||||
|
async (gid: string, refresh: boolean) => {
|
||||||
|
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: true, error: null, limited: false } }));
|
||||||
|
try {
|
||||||
|
const data = normalizeVoiceCheck(await c2api.getNodeVoiceCheck(nodeId, gid, refresh));
|
||||||
|
setChecks((m) => ({ ...m, [gid]: { data, busy: false, error: null, limited: refresh && data.refreshed === false } }));
|
||||||
|
} catch (err) {
|
||||||
|
const e = parseC2Error(err);
|
||||||
|
// 404 = route not deployed yet or server not visible: no badges (fail-open).
|
||||||
|
const msg = e.status === 404 ? null : e.message;
|
||||||
|
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: false, error: msg, limited: false } }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[nodeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// First look at a server's channels: load its check (cached server-side).
|
||||||
|
useEffect(() => {
|
||||||
|
if (guild?.synced && !checks[guild.guild_id]) runCheck(guild.guild_id, false);
|
||||||
|
}, [guild, checks, runCheck]);
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const out: { category: string | null; channels: DiscordGuildOption["voice_channels"] }[] = [];
|
const out: { category: string | null; channels: DiscordGuildOption["voice_channels"] }[] = [];
|
||||||
@@ -119,7 +170,11 @@ function DiscordPicker({
|
|||||||
onStarted();
|
onStarted();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(parseC2Error(err));
|
const e = parseC2Error(err);
|
||||||
|
setError(e);
|
||||||
|
// The refusal may be newer than the badges (C2 drops its cache entry on
|
||||||
|
// a permission refusal) — reload them so the row matches the error.
|
||||||
|
if (e.code === "missing_permissions") runCheck(guild.guild_id, false);
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
@@ -209,6 +264,7 @@ function DiscordPicker({
|
|||||||
{grp.channels.map((c) => {
|
{grp.channels.map((c) => {
|
||||||
const here = c.stream?.node_id === nodeId;
|
const here = c.stream?.node_id === nodeId;
|
||||||
const others = (c.streams ?? []).filter((x) => x.stream_id !== c.stream?.stream_id);
|
const others = (c.streams ?? []).filter((x) => x.stream_id !== c.stream?.stream_id);
|
||||||
|
const verdict = verdictOf(c);
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={c.channel_id}
|
key={c.channel_id}
|
||||||
@@ -227,7 +283,11 @@ function DiscordPicker({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="flex-1 min-w-0">
|
<span className="flex-1 min-w-0">
|
||||||
<span className="text-ink block truncate">🔊 {c.name}</span>
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-ink truncate">🔊 {c.name}</span>
|
||||||
|
{checkOn && <VerdictBadge verdict={verdict} loading={check?.busy} />}
|
||||||
|
</span>
|
||||||
|
{verdict && c.channel_id === channelId && <FixText verdict={verdict} channelName={c.name} />}
|
||||||
{c.stream && (
|
{c.stream && (
|
||||||
<span className="text-xs text-ink-muted block">
|
<span className="text-xs text-ink-muted block">
|
||||||
{here
|
{here
|
||||||
@@ -241,6 +301,9 @@ function DiscordPicker({
|
|||||||
{!c.stream && ` — this adds ${options.system_name ?? "this node's system"} beside it with another bot`}
|
{!c.stream && ` — this adds ${options.system_name ?? "this node's system"} beside it with another bot`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{c.channel_id === channelId && checkOn && (
|
||||||
|
<BotDetails channel={check?.data?.channels[c.channel_id]} channelName={c.name} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@@ -248,6 +311,15 @@ function DiscordPicker({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<CheckFooter
|
||||||
|
checkedAt={check?.data?.checked_at ?? null}
|
||||||
|
busy={!!check?.busy}
|
||||||
|
error={check?.error ?? null}
|
||||||
|
disabled={!checkOn}
|
||||||
|
limited={!!check?.limited}
|
||||||
|
invite={showInvite ? check?.data?.invite : undefined}
|
||||||
|
onRecheck={() => runCheck(guild.guild_id, true)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -299,10 +371,10 @@ function DiscordPicker({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-xs text-sev-major">
|
<div role="alert" className="text-xs text-sev-major whitespace-pre-line">
|
||||||
{error.message}
|
{error.message}
|
||||||
<InviteLinks invite={error.invite} />
|
<InviteLinks invite={error.invite} />
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -310,7 +382,8 @@ function DiscordPicker({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={start}
|
onClick={start}
|
||||||
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere)}
|
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || definiteDeny}
|
||||||
|
title={definiteDeny ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined}
|
||||||
className="flex-1 bg-accent hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-semibold"
|
className="flex-1 bg-accent hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-semibold"
|
||||||
>
|
>
|
||||||
{sending ? "Starting…" : channelOnHere ? "Update" : "Start radio"}
|
{sending ? "Starting…" : channelOnHere ? "Update" : "Start radio"}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Settings → Organization → "Radio bot per system" (server-26#225, owner
|
||||||
|
// ruling Q2). The org owner picks which of the org's OWN radio bots plays each
|
||||||
|
// of the org's systems in Discord. C2 tries that bot first for every new
|
||||||
|
// radio channel of the system (discord_streams.start_stream); when it can't
|
||||||
|
// take the stream (busy in that server, no permission, not invited) another
|
||||||
|
// free bot is used. Shared systems and DRB pool bots stay platform-admin only,
|
||||||
|
// so they are not offered here. Backend: GET /tokens/org/preferred (list),
|
||||||
|
// PUT /tokens/{token_id}/prefer/{system_id} (set; "_none" clears).
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { parseC2Error, type OrgPreferredBots } from "@/lib/discordPicker";
|
||||||
|
import { Card, CardHeader } from "@/components/ui/Card";
|
||||||
|
|
||||||
|
const SELECT =
|
||||||
|
"bg-page border border-line rounded-lg px-2.5 py-1.5 text-ink text-sm focus:outline-none focus:border-accent disabled:opacity-50 max-w-full";
|
||||||
|
|
||||||
|
export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
|
||||||
|
const [data, setData] = useState<OrgPreferredBots | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
|
const [rowError, setRowError] = useState<{ id: string; message: string } | null>(null);
|
||||||
|
// A platform admin with no org of their own gets a 403 here: nothing to show
|
||||||
|
// (shared systems are set on /systems), so the card hides instead of erroring.
|
||||||
|
const [hidden, setHidden] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setData(await c2api.getOrgPreferredBots());
|
||||||
|
setLoadError(null);
|
||||||
|
} catch (err) {
|
||||||
|
const e = parseC2Error(err);
|
||||||
|
if (e.status === 403) setHidden(true);
|
||||||
|
else setLoadError(e.message);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canEdit) load();
|
||||||
|
}, [canEdit, load]);
|
||||||
|
|
||||||
|
async function choose(systemId: string, tokenId: string, currentId: string | null) {
|
||||||
|
if (tokenId === (currentId ?? "")) return;
|
||||||
|
setSaving(systemId);
|
||||||
|
setRowError(null);
|
||||||
|
try {
|
||||||
|
if (tokenId) await c2api.setPreferredToken(tokenId, systemId);
|
||||||
|
else if (currentId) await c2api.setPreferredToken(currentId, "_none");
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setRowError({ id: systemId, message: parseC2Error(err).message });
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canEdit || hidden) return null;
|
||||||
|
const bots = data?.bots ?? [];
|
||||||
|
const botsById = new Map(bots.map((b) => [b.token_id, b]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="Radio bot per system"
|
||||||
|
subtitle="Which of your radio bots plays each system in Discord. DRB tries it first for every new radio channel of that system; if it can't (busy in that server, missing a permission, or not invited), another free bot is used."
|
||||||
|
/>
|
||||||
|
{loadError ? (
|
||||||
|
<p role="alert" className="text-sm text-sev-major">Couldn't load radio bots: {loadError}</p>
|
||||||
|
) : !data ? (
|
||||||
|
<p className="text-sm text-ink-muted">Loading…</p>
|
||||||
|
) : data.systems.length === 0 ? (
|
||||||
|
<p className="text-sm text-ink-muted">Your organization has no radio systems yet.</p>
|
||||||
|
) : bots.length === 0 ? (
|
||||||
|
<p className="text-sm text-ink-muted">
|
||||||
|
Your organization has no radio bots of its own, so DRB picks a free DRB radio bot for each channel. To use
|
||||||
|
your own bot for a system, ask DRB support to add it to your organization.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-line">
|
||||||
|
{data.systems.map((s) => {
|
||||||
|
const current = s.preferred_token_id ? botsById.get(s.preferred_token_id) : undefined;
|
||||||
|
const err = rowError?.id === s.system_id ? rowError.message : null;
|
||||||
|
const usedElsewhere = (tokenId: string) => {
|
||||||
|
const other = botsById.get(tokenId)?.preferred_for_system_id;
|
||||||
|
return other && other !== s.system_id ? data.systems.find((x) => x.system_id === other)?.name ?? "another system" : null;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div key={s.system_id} className="py-3 first:pt-0 last:pb-0 flex flex-wrap items-start gap-x-4 gap-y-1.5">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm text-ink font-medium truncate">{s.name}</div>
|
||||||
|
<div className="text-xs text-ink-muted">
|
||||||
|
{current
|
||||||
|
? `${current.name} is tried first.`
|
||||||
|
: s.preferred_is_platform_bot
|
||||||
|
? "A DRB radio bot chosen by DRB support is tried first. Pick one of yours to replace it."
|
||||||
|
: "No preference — any free radio bot."}
|
||||||
|
{current?.valid === false && " Discord rejected this bot's token — DRB support has to replace it."}
|
||||||
|
{current && current.server_count === 0 && " It isn't in any Discord server yet — invite it first."}
|
||||||
|
</div>
|
||||||
|
{current?.invite_url && (
|
||||||
|
<a
|
||||||
|
href={current.invite_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-accent hover:underline"
|
||||||
|
>
|
||||||
|
Invite {current.name} to a Discord server
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{err && <div role="alert" className="text-xs text-sev-major mt-1">{err}</div>}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-ink-muted">
|
||||||
|
<span className="sr-only">Preferred radio bot for {s.name}</span>
|
||||||
|
<select
|
||||||
|
value={s.preferred_token_id ?? ""}
|
||||||
|
disabled={saving !== null}
|
||||||
|
onChange={(e) => choose(s.system_id, e.target.value, s.preferred_token_id)}
|
||||||
|
className={SELECT}
|
||||||
|
>
|
||||||
|
<option value="">{s.preferred_is_platform_bot ? "DRB support's choice" : "Any free radio bot"}</option>
|
||||||
|
{bots.map((b) => {
|
||||||
|
const elsewhere = usedElsewhere(b.token_id);
|
||||||
|
return (
|
||||||
|
<option key={b.token_id} value={b.token_id} disabled={b.valid === false}>
|
||||||
|
{b.name}
|
||||||
|
{b.valid === false ? " — token rejected" : elsewhere ? ` — moves it off ${elsewhere}` : ""}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
{saving === s.system_id && <span>Saving…</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import {
|
import {
|
||||||
type DiscordAdminOverview,
|
type DiscordAdminOverview,
|
||||||
@@ -12,8 +12,18 @@ import {
|
|||||||
shortId,
|
shortId,
|
||||||
streamHealth,
|
streamHealth,
|
||||||
} from "@/lib/discord";
|
} from "@/lib/discord";
|
||||||
|
import {
|
||||||
|
blocksStart,
|
||||||
|
channelVerdict,
|
||||||
|
normalizeVoiceCheck,
|
||||||
|
parseC2Error as parseStartError,
|
||||||
|
type ParsedC2Error,
|
||||||
|
type VoiceCheck,
|
||||||
|
type VoiceCheckBot,
|
||||||
|
} from "@/lib/discordPicker";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
|
import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
|
||||||
|
import { BotDetails, CheckFooter, FixText, VerdictBadge } from "./VoiceCheckBits";
|
||||||
|
|
||||||
// Radio channels = discord_streams docs, desired vs actual. Start/Stop/Add go
|
// Radio channels = discord_streams docs, desired vs actual. Start/Stop/Add go
|
||||||
// through the same routes the Discord bot uses (POST /discord/streams,
|
// through the same routes the Discord bot uses (POST /discord/streams,
|
||||||
@@ -25,6 +35,11 @@ import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
|
|||||||
// each with its own radio bot and system (server-26#213 follow-up). Stop,
|
// each with its own radio bot and system (server-26#213 follow-up). Stop,
|
||||||
// Start and Remove all act on the row's stream_id, so the other streams in
|
// Start and Remove all act on the row's stream_id, so the other streams in
|
||||||
// the same channel keep playing. Add never replaces an existing stream.
|
// the same channel keep playing. Add never replaces an existing stream.
|
||||||
|
//
|
||||||
|
// The Add form checks the typed channel with Discord (server-26#225:
|
||||||
|
// GET /discord/guilds/{id}/voice-check, admin): per-bot status, bots that
|
||||||
|
// definitely can't stream there are disabled in the bot list, and Start is
|
||||||
|
// disabled only when every bot is definitely denied (unknown fails open).
|
||||||
|
|
||||||
type Invite = { name: string; invite_url: string | null }[];
|
type Invite = { name: string; invite_url: string | null }[];
|
||||||
|
|
||||||
@@ -48,7 +63,43 @@ function AddChannelForm({
|
|||||||
const [systemId, setSystemId] = useState("");
|
const [systemId, setSystemId] = useState("");
|
||||||
const [botId, setBotId] = useState("");
|
const [botId, setBotId] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<{ message: string; invite: Invite } | null>(null);
|
const [error, setError] = useState<(Omit<ParsedC2Error, "invite"> & { invite: Invite }) | null>(null);
|
||||||
|
const [check, setCheck] = useState<
|
||||||
|
{ key: string; data: VoiceCheck | null; busy: boolean; error: string | null; limited: boolean } | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const cid = channelId.trim();
|
||||||
|
const checkKey = SNOWFLAKE.test(cid) && guildId ? `${guildId}:${cid}` : "";
|
||||||
|
const runCheck = useCallback(async (key: string, refresh: boolean) => {
|
||||||
|
const [gid, ch] = key.split(":");
|
||||||
|
setCheck((c) => ({ key, data: c?.key === key ? c.data : null, busy: true, error: null, limited: false }));
|
||||||
|
try {
|
||||||
|
const data = normalizeVoiceCheck(await c2api.getGuildVoiceCheck(gid, ch, refresh));
|
||||||
|
setCheck((c) => (c?.key === key ? { key, data, busy: false, error: null, limited: refresh && data.refreshed === false } : c));
|
||||||
|
} catch (err) {
|
||||||
|
const e = parseStartError(err);
|
||||||
|
setCheck((c) =>
|
||||||
|
c?.key === key ? { key, data: c.data, busy: false, error: e.status === 404 ? null : e.message, limited: false } : c,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
// Check the channel once its ID looks complete (debounced while typing).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!checkKey) {
|
||||||
|
setCheck(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = setTimeout(() => runCheck(checkKey, false), 400);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [checkKey, runCheck]);
|
||||||
|
const liveCheck = check && check.key === checkKey ? check : null;
|
||||||
|
const checkOn = liveCheck?.data?.enabled !== false;
|
||||||
|
const checked = liveCheck?.data && checkOn ? liveCheck.data.channels[cid] : undefined;
|
||||||
|
const verdict = liveCheck?.data && checkOn ? channelVerdict(checked) : null;
|
||||||
|
const shownName = channelName.trim() || checked?.name || null;
|
||||||
|
// Per-bot result, by token id (the admin route sends ids) or else by name.
|
||||||
|
const botCheck = (b: RadioBot): VoiceCheckBot | undefined =>
|
||||||
|
checked?.bots.find((x) => (x.token_id ? x.token_id === b.token_id : x.name === b.name || x.name === b.bot_name));
|
||||||
|
|
||||||
const org = guilds.find((g) => g.guild_id === guildId)?.acting_org_id ?? null;
|
const org = guilds.find((g) => g.guild_id === guildId)?.acting_org_id ?? null;
|
||||||
const systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org);
|
const systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org);
|
||||||
@@ -58,6 +109,8 @@ function AddChannelForm({
|
|||||||
for (const s of overview.streams) {
|
for (const s of overview.streams) {
|
||||||
if (s.desired === "on" && s.guild_id === guildId && s.token_id) busyHere.set(s.token_id, s);
|
if (s.desired === "on" && s.guild_id === guildId && s.token_id) busyHere.set(s.token_id, s);
|
||||||
}
|
}
|
||||||
|
const chosenBot = botId ? bots.find((b) => b.token_id === botId) : undefined;
|
||||||
|
const chosenBotDenied = chosenBot ? botCheck(chosenBot)?.ok === false : false;
|
||||||
const inChannel = overview.streams.filter(
|
const inChannel = overview.streams.filter(
|
||||||
(s) => s.desired === "on" && s.guild_id === guildId && s.channel_id === channelId.trim(),
|
(s) => s.desired === "on" && s.guild_id === guildId && s.channel_id === channelId.trim(),
|
||||||
);
|
);
|
||||||
@@ -65,7 +118,7 @@ function AddChannelForm({
|
|||||||
async function submit(e: React.FormEvent) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!SNOWFLAKE.test(channelId.trim())) {
|
if (!SNOWFLAKE.test(channelId.trim())) {
|
||||||
setError({ message: "Voice channel ID is the 17–20 digit number from Discord (right-click the channel → Copy Channel ID).", invite: [] });
|
setError({ message: "Voice channel ID is the 17–20 digit number from Discord (right-click the channel → Copy Channel ID).", invite: [], code: null, status: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -86,12 +139,16 @@ function AddChannelForm({
|
|||||||
setError({
|
setError({
|
||||||
message: `${res.system_name || "That system"} is already playing in this voice channel, so nothing was added or changed. Pick another system to add a second stream, or Remove the existing row to change its label or bot.`,
|
message: `${res.system_name || "That system"} is already playing in this voice channel, so nothing was added or changed. Pick another system to add a second stream, or Remove the existing row to change its label or bot.`,
|
||||||
invite: [],
|
invite: [],
|
||||||
|
code: "already_on",
|
||||||
|
status: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onDone();
|
onDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(parseC2Error(err));
|
const e = parseStartError(err);
|
||||||
|
setError({ ...e, invite: parseC2Error(err).invite });
|
||||||
|
if (e.code === "missing_permissions" && checkKey) runCheck(checkKey, false);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -142,16 +199,42 @@ function AddChannelForm({
|
|||||||
<option value="">Any free bot in this server</option>
|
<option value="">Any free bot in this server</option>
|
||||||
{usableBots.map((b) => {
|
{usableBots.map((b) => {
|
||||||
const busy = busyHere.get(b.token_id);
|
const busy = busyHere.get(b.token_id);
|
||||||
|
const bc = botCheck(b);
|
||||||
|
const denied = bc?.ok === false;
|
||||||
return (
|
return (
|
||||||
<option key={b.token_id} value={b.token_id} disabled={!!busy}>
|
<option key={b.token_id} value={b.token_id} disabled={!!busy || denied}>
|
||||||
{busy
|
{busy
|
||||||
? `${b.name} — in use here (${busy.system_name || busy.system_id || "radio"}${busy.channel_name ? ` in ${busy.channel_name}` : ""})`
|
? `${b.name} — in use here (${busy.system_name || busy.system_id || "radio"}${busy.channel_name ? ` in ${busy.channel_name}` : ""})`
|
||||||
|
: denied
|
||||||
|
? `${b.name} — can't ${bc.missing.length ? bc.missing.join(", ") : "join"} in this channel`
|
||||||
: `Use ${b.name}`}
|
: `Use ${b.name}`}
|
||||||
</option>
|
</option>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
|
{checkKey && (
|
||||||
|
<div className="sm:col-span-3 space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-ink-muted">
|
||||||
|
<span>Permissions in {shownName ? `#${shownName.replace(/^#/, "")}` : "this channel"}:</span>
|
||||||
|
{checkOn && <VerdictBadge verdict={verdict} loading={liveCheck?.busy} />}
|
||||||
|
</div>
|
||||||
|
{verdict && <FixText verdict={verdict} channelName={shownName} />}
|
||||||
|
{chosenBotDenied && (
|
||||||
|
<div className="text-xs text-sev-major">The bot you picked can't stream into this channel — pick another, or fix it in Discord and Re-check.</div>
|
||||||
|
)}
|
||||||
|
{checkOn && <BotDetails channel={checked} channelName={shownName} />}
|
||||||
|
<CheckFooter
|
||||||
|
checkedAt={liveCheck?.data?.checked_at ?? null}
|
||||||
|
busy={!!liveCheck?.busy}
|
||||||
|
error={liveCheck?.error ?? null}
|
||||||
|
disabled={!checkOn}
|
||||||
|
limited={!!liveCheck?.limited}
|
||||||
|
invite={verdict && ["fix", "no_bot", "busy"].includes(verdict.kind) ? liveCheck?.data?.invite : undefined}
|
||||||
|
onRecheck={() => runCheck(checkKey, true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{inChannel.length > 0 && (
|
{inChannel.length > 0 && (
|
||||||
<div className="sm:col-span-3 text-xs text-ink-muted">
|
<div className="sm:col-span-3 text-xs text-ink-muted">
|
||||||
This voice channel already plays{" "}
|
This voice channel already plays{" "}
|
||||||
@@ -160,13 +243,20 @@ function AddChannelForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="sm:col-span-3 text-xs text-sev-major">
|
<div role="alert" className="sm:col-span-3 text-xs text-sev-major whitespace-pre-line">
|
||||||
{error.message}
|
{error.message}
|
||||||
<InviteLinks invite={error.invite} />
|
<InviteLinks invite={error.invite} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="sm:col-span-3 flex gap-2">
|
<div className="sm:col-span-3 flex gap-2">
|
||||||
<Button type="submit" size="sm" disabled={saving || !systemId}>{saving ? "Starting…" : "Start radio channel"}</Button>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={saving || !systemId || blocksStart(verdict) || chosenBotDenied}
|
||||||
|
title={blocksStart(verdict) ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined}
|
||||||
|
>
|
||||||
|
{saving ? "Starting…" : "Start radio channel"}
|
||||||
|
</Button>
|
||||||
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
|
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Per-bot voice-channel permission status (server-26#225 S4). Shared by the
|
||||||
|
// node-page picker (NodeDiscordRadio) and the admin Add form (StreamsPanel),
|
||||||
|
// both fed by c2-core routers/discord_checks.py. Start is disabled only on a
|
||||||
|
// DEFINITE refusal ("Needs fix", "Not a voice channel"); "Couldn't check"
|
||||||
|
// fails open — a Discord outage must never block a start.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
agoText,
|
||||||
|
botLine,
|
||||||
|
deniedSentence,
|
||||||
|
fixSentence,
|
||||||
|
type ChannelVerdict,
|
||||||
|
type DiscordInvite,
|
||||||
|
type VoiceCheckChannel,
|
||||||
|
} from "@/lib/discordPicker";
|
||||||
|
|
||||||
|
const PILL = "inline-flex items-center gap-1 rounded px-1.5 py-px text-[11px] font-medium whitespace-nowrap border";
|
||||||
|
|
||||||
|
const BADGE: Record<ChannelVerdict["kind"], { label: string; cls: string; title?: string }> = {
|
||||||
|
ready: { label: "Ready", cls: "border-green-500/40 text-green-500" },
|
||||||
|
fix: { label: "Needs fix", cls: "border-sev-major/50 text-sev-major" },
|
||||||
|
not_voice: { label: "Not a voice channel", cls: "border-sev-major/50 text-sev-major" },
|
||||||
|
busy: {
|
||||||
|
label: "Bots busy",
|
||||||
|
cls: "border-sev-moderate/50 text-sev-moderate",
|
||||||
|
title: "A bot could stream here, but each one that can is already in another channel of this server or on another node.",
|
||||||
|
},
|
||||||
|
no_bot: { label: "No radio bot", cls: "border-sev-moderate/50 text-sev-moderate", title: "No radio bot is in this Discord server yet." },
|
||||||
|
unknown: { label: "Couldn't check", cls: "border-line text-ink-muted", title: "Discord didn't answer the check — DRB will still try." },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ready / Needs fix / Couldn't check / …, plus "Limit N" when the channel has a user limit. */
|
||||||
|
export function VerdictBadge({ verdict, loading }: { verdict: ChannelVerdict | null; loading?: boolean }) {
|
||||||
|
if (!verdict) {
|
||||||
|
return loading ? <span className={`${PILL} border-line text-ink-muted`}>Checking…</span> : null;
|
||||||
|
}
|
||||||
|
const b = BADGE[verdict.kind];
|
||||||
|
// Name the missing permission right on the badge: "Needs fix · can't Speak".
|
||||||
|
const label =
|
||||||
|
verdict.kind === "fix" && verdict.bot.missing.length
|
||||||
|
? `${b.label} · can't ${verdict.bot.missing.join(", ")}`
|
||||||
|
: b.label;
|
||||||
|
const title = verdict.kind === "fix" ? `${verdict.bot.name}: ${verdict.bot.reason ?? "missing permission"}` : b.title;
|
||||||
|
return (
|
||||||
|
<span className="inline-flex gap-1 flex-wrap">
|
||||||
|
<span className={`${PILL} ${b.cls}`} title={title}>{label}</span>
|
||||||
|
{verdict.limit > 0 && (
|
||||||
|
<span
|
||||||
|
className={`${PILL} border-line text-ink-muted`}
|
||||||
|
title="Discord user limit. DRB can't see who is connected, so a full channel only shows up when the bot tries to join."
|
||||||
|
>
|
||||||
|
Limit {verdict.limit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sentence under a "Needs fix" / "Not a voice channel" channel. */
|
||||||
|
export function FixText({ verdict, channelName }: { verdict: ChannelVerdict; channelName: string | null }) {
|
||||||
|
if (verdict.kind === "not_voice") {
|
||||||
|
return <span className="block text-xs text-sev-major mt-1">That is not a voice channel. Radio can only play in a voice channel.</span>;
|
||||||
|
}
|
||||||
|
if (verdict.kind !== "fix") return null;
|
||||||
|
return (
|
||||||
|
<span className="block text-xs text-sev-major mt-1">
|
||||||
|
{deniedSentence(verdict.bot, channelName)} {fixSentence(verdict.bot)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE: Record<"ok" | "bad" | "warn" | "muted", string> = {
|
||||||
|
ok: "text-green-500",
|
||||||
|
bad: "text-sev-major",
|
||||||
|
warn: "text-sev-moderate",
|
||||||
|
muted: "text-ink-muted",
|
||||||
|
};
|
||||||
|
const MARK: Record<"ok" | "bad" | "warn" | "muted", string> = { ok: "✓", bad: "✕", warn: "•", muted: "?" };
|
||||||
|
|
||||||
|
/** Expandable per-bot detail for one channel. */
|
||||||
|
export function BotDetails({ channel, channelName }: { channel: VoiceCheckChannel | null | undefined; channelName: string | null }) {
|
||||||
|
if (!channel || channel.bots.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<details className="text-xs mt-1">
|
||||||
|
<summary className="cursor-pointer text-ink-muted hover:text-ink select-none">
|
||||||
|
Radio bots in this server ({channel.bots.length})
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-1 space-y-0.5 pl-1">
|
||||||
|
{channel.bots.map((b, i) => {
|
||||||
|
const l = botLine(b, channelName);
|
||||||
|
return (
|
||||||
|
<li key={`${b.token_id ?? b.name}-${i}`} className={TONE[l.tone]}>
|
||||||
|
<span aria-hidden className="inline-block w-3">{MARK[l.tone]}</span> {l.text}
|
||||||
|
{b.status === "denied" && <span className="block pl-3 text-ink-muted">{fixSentence(b)}</span>}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Permissions checked 40s ago · Re-check". Ticks on its own so the age stays honest. */
|
||||||
|
export function CheckFooter({
|
||||||
|
checkedAt,
|
||||||
|
busy,
|
||||||
|
error,
|
||||||
|
disabled,
|
||||||
|
limited,
|
||||||
|
invite,
|
||||||
|
onRecheck,
|
||||||
|
}: {
|
||||||
|
checkedAt: string | null;
|
||||||
|
busy: boolean;
|
||||||
|
error: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** A Re-check was rate limited server-side (once per 10 s); the cached answer is shown. */
|
||||||
|
limited?: boolean;
|
||||||
|
invite?: DiscordInvite[];
|
||||||
|
onRecheck: () => void;
|
||||||
|
}) {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setNow(Date.now()), 10_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
const ago = agoText(checkedAt, now);
|
||||||
|
if (disabled) {
|
||||||
|
return <div className="text-xs text-ink-muted">Permission check is off — DRB tries any free radio bot.</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-ink-muted flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span>{busy ? "Checking permissions…" : ago ? `Permissions checked ${ago}` : "Permissions not checked yet"}</span>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<button type="button" onClick={onRecheck} disabled={busy} className="text-accent hover:underline disabled:opacity-50">
|
||||||
|
Re-check
|
||||||
|
</button>
|
||||||
|
{limited && !busy && (
|
||||||
|
<span className="basis-full">Re-check runs at most once every 10 seconds — this is the last result. Try again in a moment.</span>
|
||||||
|
)}
|
||||||
|
{error && <span className="basis-full text-sev-moderate">Couldn'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)}`,
|
`/discord/nodes/${nodeId}/streams/${encodeURIComponent(streamId)}`,
|
||||||
{ method: "DELETE" },
|
{ method: "DELETE" },
|
||||||
),
|
),
|
||||||
|
/** Per-bot permission check for every voice channel in one server (server-26#225).
|
||||||
|
* refresh=true bypasses the cache (rate limited server-side: 1 per 10 s). Raw
|
||||||
|
* body — run it through discordPicker.normalizeVoiceCheck. */
|
||||||
|
getNodeVoiceCheck: (nodeId: string, guildId: string, refresh = false) =>
|
||||||
|
request<unknown>(
|
||||||
|
`/discord/nodes/${encodeURIComponent(nodeId)}/guilds/${encodeURIComponent(guildId)}/voice-check?refresh=${refresh ? 1 : 0}`,
|
||||||
|
),
|
||||||
|
/** Org owner: this org's systems + its own radio bots, for "preferred bot per system" (server-26#225 Q2). */
|
||||||
|
getOrgPreferredBots: () =>
|
||||||
|
request<import("@/lib/discordPicker").OrgPreferredBots>("/tokens/org/preferred"),
|
||||||
|
/** Admin: the same check for one channel (the Add form takes a raw channel ID). */
|
||||||
|
getGuildVoiceCheck: (guildId: string, channelId: string, refresh = false) =>
|
||||||
|
request<unknown>(
|
||||||
|
`/discord/guilds/${encodeURIComponent(guildId)}/voice-check?channel_id=${encodeURIComponent(channelId)}&refresh=${refresh ? 1 : 0}`,
|
||||||
|
),
|
||||||
deleteNode: (id: string) =>
|
deleteNode: (id: string) =>
|
||||||
request(`/nodes/${id}`, { method: "DELETE" }),
|
request(`/nodes/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
@@ -344,7 +359,8 @@ export const c2api = {
|
|||||||
getReplayIncidents: (runId: string) =>
|
getReplayIncidents: (runId: string) =>
|
||||||
request<import("@/lib/types").ReplayIncidents>(`/admin/replay/${runId}/incidents`),
|
request<import("@/lib/types").ReplayIncidents>(`/admin/replay/${runId}/incidents`),
|
||||||
|
|
||||||
// Preferred bot token per system
|
// Preferred bot token per system. Platform admin: any bot, any system. Org
|
||||||
|
// owner: their own org's bots for their own org's systems (server-26#225 Q2).
|
||||||
setPreferredToken: (tokenId: string, systemId: string) =>
|
setPreferredToken: (tokenId: string, systemId: string) =>
|
||||||
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),
|
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,27 @@ export interface DiscordJoinOptions {
|
|||||||
streams: DiscordStreamView[];
|
streams: DiscordStreamView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /tokens/org/preferred — the org's own systems and its own (bring-your-own) radio bots. */
|
||||||
|
export interface OrgPreferredBots {
|
||||||
|
org_id: string;
|
||||||
|
systems: {
|
||||||
|
system_id: string;
|
||||||
|
name: string;
|
||||||
|
preferred_token_id: string | null;
|
||||||
|
/** A platform admin pinned a DRB pool bot for this system. */
|
||||||
|
preferred_is_platform_bot: boolean;
|
||||||
|
preferred_name: string | null;
|
||||||
|
}[];
|
||||||
|
bots: {
|
||||||
|
token_id: string;
|
||||||
|
name: string;
|
||||||
|
valid: boolean | null;
|
||||||
|
server_count: number | null;
|
||||||
|
preferred_for_system_id: string | null;
|
||||||
|
invite_url: string | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiscordStartResult {
|
export interface DiscordStartResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
stream_id: string;
|
stream_id: string;
|
||||||
@@ -77,10 +98,38 @@ export interface DiscordStartResult {
|
|||||||
reused: boolean | null;
|
reused: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParsedC2Error {
|
||||||
|
message: string;
|
||||||
|
invite: DiscordInvite[];
|
||||||
|
/** start_stream's code: missing_permissions, preferred_busy, preferred_other_node, bad_channel, … */
|
||||||
|
code: string | null;
|
||||||
|
status: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStrings(v: unknown): string[] {
|
||||||
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bot's trace sentence: ``reason`` as a string, else the ``reasons`` map
|
||||||
|
* (label → sentence, discord_perms.check_voice) joined. */
|
||||||
|
function reasonOf(b: Record<string, unknown>): string | null {
|
||||||
|
if (typeof b.reason === "string" && b.reason) return b.reason;
|
||||||
|
const r = b.reasons;
|
||||||
|
if (r && typeof r === "object") {
|
||||||
|
const parts = Object.entries(r as Record<string, unknown>)
|
||||||
|
.filter(([, v]) => typeof v === "string" && v)
|
||||||
|
.map(([k, v]) => `${k}: ${v as string}`);
|
||||||
|
if (parts.length) return parts.join("; ");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** c2api's request() throws `C2 API error <status>: <body>`. Pull the
|
/** c2api's request() throws `C2 API error <status>: <body>`. Pull the
|
||||||
* detail + invite links back out of a JSON body when there is one. */
|
* detail, code and invite links back out of a JSON body. */
|
||||||
export function parseC2Error(err: unknown): { message: string; invite: DiscordInvite[] } {
|
export function parseC2Error(err: unknown): ParsedC2Error {
|
||||||
const raw = err instanceof Error ? err.message : String(err);
|
const raw = err instanceof Error ? err.message : String(err);
|
||||||
|
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
|
||||||
|
const status = m ? Number(m[1]) : null;
|
||||||
const idx = raw.indexOf(": ");
|
const idx = raw.indexOf(": ");
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
try {
|
try {
|
||||||
@@ -89,10 +138,225 @@ export function parseC2Error(err: unknown): { message: string; invite: DiscordIn
|
|||||||
const invite = Array.isArray(body?.invite)
|
const invite = Array.isArray(body?.invite)
|
||||||
? (body.invite as DiscordInvite[]).filter((i) => i && typeof i.invite_url === "string")
|
? (body.invite as DiscordInvite[]).filter((i) => i && typeof i.invite_url === "string")
|
||||||
: [];
|
: [];
|
||||||
if (detail) return { message: detail, invite };
|
// missing_permissions: detail already names each bot, the permission,
|
||||||
|
// what removed it and the fix (discord_streams._missing_permissions_error).
|
||||||
|
if (detail) return { message: detail, invite, code: typeof body?.code === "string" ? body.code : null, status };
|
||||||
} catch {
|
} catch {
|
||||||
/* not JSON — fall through */
|
/* not JSON — fall through */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { message: raw, invite: [] };
|
return { message: raw, invite: [], code: null, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Voice-channel permission check (server-26#225 S3/S4) — the JSON contract of
|
||||||
|
// c2-core routers/discord_checks.py:
|
||||||
|
// GET /discord/nodes/{node}/guilds/{guild}/voice-check (node picker, org-scoped)
|
||||||
|
// GET /discord/guilds/{guild}/voice-check?channel_id= (admin Add form; rows carry token_id)
|
||||||
|
// Bot names only for non-admins — never token strings.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Per bot: ok | denied | unknown | not_member | invalid | bad_channel | unchecked. */
|
||||||
|
export type BotAccess = "ok" | "denied" | "unknown" | "not_member" | "invalid" | "bad_channel" | "unchecked";
|
||||||
|
|
||||||
|
/** Per channel, as c2-core sums it up over the bots that are free. */
|
||||||
|
export type ChannelState = "ready" | "needs_fix" | "unknown" | "busy" | "no_bot" | "bad_channel" | "unchecked";
|
||||||
|
|
||||||
|
export interface VoiceCheckBot {
|
||||||
|
/** Admin route only. */
|
||||||
|
token_id?: string | null;
|
||||||
|
name: string;
|
||||||
|
status: BotAccess;
|
||||||
|
/** true = can stream there, false = DEFINITE deny, null = anything else (unknown fails open). */
|
||||||
|
ok: boolean | null;
|
||||||
|
missing: string[];
|
||||||
|
/** What removed the first missing permission, e.g. "denied by #dispatch's override for @Muted". */
|
||||||
|
reason: string | null;
|
||||||
|
/** What to change in Discord (server-written). */
|
||||||
|
fix: string | null;
|
||||||
|
/** false = this bot can't take a new stream here right now. */
|
||||||
|
available: boolean | null;
|
||||||
|
unavailable_reason: "busy_here" | "other_node" | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceCheckChannel {
|
||||||
|
channel_id: string;
|
||||||
|
name: string | null;
|
||||||
|
state: ChannelState | null;
|
||||||
|
/** Advisory: REST can't see who is connected (0 = no limit). */
|
||||||
|
user_limit: number;
|
||||||
|
any_ok: boolean | null;
|
||||||
|
bots: VoiceCheckBot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceCheck {
|
||||||
|
guild_id: string | null;
|
||||||
|
checked_at: string | null;
|
||||||
|
/** false when DISCORD_PERM_CHECK_ENABLED is off server-side — no badges then. */
|
||||||
|
enabled: boolean;
|
||||||
|
/** false when a Re-check was asked for but rate limited (cached answer served). */
|
||||||
|
refreshed: boolean | null;
|
||||||
|
channels: Record<string, VoiceCheckChannel>;
|
||||||
|
/** Radio bots that could be invited to this server. */
|
||||||
|
invite: DiscordInvite[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOT_ACCESS: readonly string[] = ["ok", "denied", "unknown", "not_member", "invalid", "bad_channel", "unchecked"];
|
||||||
|
const CHANNEL_STATES: readonly string[] = ["ready", "needs_fix", "unknown", "busy", "no_bot", "bad_channel", "unchecked"];
|
||||||
|
|
||||||
|
function normBot(b: Record<string, unknown>): VoiceCheckBot {
|
||||||
|
const status = (typeof b.status === "string" && BOT_ACCESS.includes(b.status)
|
||||||
|
? b.status
|
||||||
|
: b.ok === true ? "ok" : "unknown") as BotAccess;
|
||||||
|
return {
|
||||||
|
token_id: typeof b.token_id === "string" ? b.token_id : null,
|
||||||
|
name: typeof b.name === "string" && b.name ? b.name : "Radio bot",
|
||||||
|
status,
|
||||||
|
ok: status === "ok" ? true : status === "denied" ? false : null,
|
||||||
|
missing: asStrings(b.missing),
|
||||||
|
reason: reasonOf(b),
|
||||||
|
fix: typeof b.fix === "string" && b.fix ? b.fix : null,
|
||||||
|
available: b.available === true ? true : b.available === false ? false : null,
|
||||||
|
unavailable_reason: typeof b.unavailable_reason === "string" ? b.unavailable_reason : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normChannel(c: Record<string, unknown>, fallbackId: string): VoiceCheckChannel {
|
||||||
|
return {
|
||||||
|
channel_id: String(c.channel_id ?? fallbackId),
|
||||||
|
name: typeof c.name === "string" ? c.name : null,
|
||||||
|
state: typeof c.state === "string" && CHANNEL_STATES.includes(c.state) ? (c.state as ChannelState) : null,
|
||||||
|
user_limit: Number(c.user_limit) || 0,
|
||||||
|
any_ok: c.any_ok === true ? true : c.any_ok === false ? false : null,
|
||||||
|
bots: Array.isArray(c.bots) ? (c.bots as Record<string, unknown>[]).filter(Boolean).map(normBot) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeVoiceCheck(raw: unknown): VoiceCheck {
|
||||||
|
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||||||
|
const channels: Record<string, VoiceCheckChannel> = {};
|
||||||
|
if (Array.isArray(r.channels)) {
|
||||||
|
for (const c of r.channels as Record<string, unknown>[]) {
|
||||||
|
if (c && c.channel_id != null) channels[String(c.channel_id)] = normChannel(c, String(c.channel_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
guild_id: r.guild_id != null ? String(r.guild_id) : null,
|
||||||
|
checked_at: typeof r.checked_at === "string" ? r.checked_at : null,
|
||||||
|
enabled: r.enabled !== false,
|
||||||
|
refreshed: r.refreshed === true ? true : r.refreshed === false ? false : null,
|
||||||
|
channels,
|
||||||
|
invite: Array.isArray(r.invite)
|
||||||
|
? (r.invite as DiscordInvite[]).filter((i) => i && typeof i.invite_url === "string")
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChannelVerdict =
|
||||||
|
/** A free radio bot can stream there. */
|
||||||
|
| { kind: "ready"; limit: number }
|
||||||
|
/** A bot could stream there, but every such bot is busy. */
|
||||||
|
| { kind: "busy"; limit: number }
|
||||||
|
/** Every free bot is DEFINITELY denied — Start is disabled. */
|
||||||
|
| { kind: "fix"; limit: number; bot: VoiceCheckBot }
|
||||||
|
/** Discord didn't answer for some bot — Start stays enabled (fail-open). */
|
||||||
|
| { kind: "unknown"; limit: number }
|
||||||
|
/** No radio bot is in this server. */
|
||||||
|
| { kind: "no_bot"; limit: number }
|
||||||
|
/** The channel exists but is not a voice channel — Start is disabled. */
|
||||||
|
| { kind: "not_voice"; limit: number };
|
||||||
|
|
||||||
|
/** Derive the badge. c2-core's ``state`` wins; the bot rows are the fallback.
|
||||||
|
* null = no badge (check off, or no data for that channel). */
|
||||||
|
export function channelVerdict(c: VoiceCheckChannel | null | undefined): ChannelVerdict | null {
|
||||||
|
if (!c) return null;
|
||||||
|
const limit = c.user_limit;
|
||||||
|
const free = c.bots.filter((b) => b.available !== false);
|
||||||
|
const denied = free.filter((b) => b.ok === false);
|
||||||
|
const firstDenied = denied[0] ?? c.bots.find((b) => b.ok === false);
|
||||||
|
switch (c.state) {
|
||||||
|
case "ready": return { kind: "ready", limit };
|
||||||
|
case "busy": return { kind: "busy", limit };
|
||||||
|
case "unknown": return { kind: "unknown", limit };
|
||||||
|
case "no_bot": return { kind: "no_bot", limit };
|
||||||
|
case "bad_channel": return { kind: "not_voice", limit };
|
||||||
|
case "unchecked": return null;
|
||||||
|
case "needs_fix": return firstDenied ? { kind: "fix", limit, bot: firstDenied } : { kind: "unknown", limit };
|
||||||
|
}
|
||||||
|
// No state (older server): same rules as discord_checks._channel_state.
|
||||||
|
if (!c.bots.length) return { kind: "no_bot", limit };
|
||||||
|
if (free.some((b) => b.ok === true)) return { kind: "ready", limit };
|
||||||
|
if (free.some((b) => b.ok === null)) return { kind: "unknown", limit };
|
||||||
|
if (denied.length) return { kind: "fix", limit, bot: denied[0] };
|
||||||
|
if (c.bots.some((b) => b.ok === true)) return { kind: "busy", limit };
|
||||||
|
return { kind: "unknown", limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A definite refusal: Start would only get a 409. Everything else fails open. */
|
||||||
|
export function blocksStart(v: ChannelVerdict | null | undefined): boolean {
|
||||||
|
return v?.kind === "fix" || v?.kind === "not_voice";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "#dispatch" for a channel name, else "this channel". */
|
||||||
|
export function channelRef(name: string | null | undefined): string {
|
||||||
|
return name ? `#${name.replace(/^#/, "")}` : "this channel";
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinLabels(labels: string[]): string {
|
||||||
|
if (labels.length <= 1) return labels.join("");
|
||||||
|
return `${labels.slice(0, -1).join(", ")} or ${labels[labels.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Radio bot X can't Speak in #dispatch: denied by #dispatch's override for @Muted." */
|
||||||
|
export function deniedSentence(bot: { name: string; missing: string[]; reason: string | null }, channelName?: string | null): string {
|
||||||
|
const what = bot.missing.length ? joinLabels(bot.missing) : "join";
|
||||||
|
const why = bot.reason ? `: ${bot.reason}` : "";
|
||||||
|
return `${bot.name} can't ${what} in ${channelRef(channelName)}${why}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where in Discord to fix it — c2-core's own sentence when it sent one. */
|
||||||
|
export function fixSentence(bot: { name: string; missing: string[]; reason: string | null; fix?: string | null }): string {
|
||||||
|
if (bot.fix) return bot.fix;
|
||||||
|
if (bot.reason && /timed out/i.test(bot.reason)) return `In Discord: remove ${bot.name}'s timeout, then Re-check.`;
|
||||||
|
const perms = bot.missing.length ? bot.missing.join(" and ") : "View Channel, Connect and Speak";
|
||||||
|
return `In Discord: Edit Channel → Permissions → add ${bot.name} → allow ${perms}, then Re-check.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "40s ago" / "3 min ago" for the Re-check footer. */
|
||||||
|
export function agoText(iso: string | null | undefined, now = Date.now()): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const t = Date.parse(iso);
|
||||||
|
if (Number.isNaN(t)) return null;
|
||||||
|
const s = Math.max(0, Math.round((now - t) / 1000));
|
||||||
|
if (s < 60) return `${s}s ago`;
|
||||||
|
const m = Math.round(s / 60);
|
||||||
|
return m < 60 ? `${m} min ago` : `${Math.round(m / 60)} h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line per bot for the expandable details. */
|
||||||
|
export function botLine(b: VoiceCheckBot, channelName?: string | null): { tone: "ok" | "bad" | "warn" | "muted"; text: string } {
|
||||||
|
const busy =
|
||||||
|
b.unavailable_reason === "busy_here"
|
||||||
|
? "already in another voice channel in this server (one per server)"
|
||||||
|
: b.unavailable_reason === "other_node"
|
||||||
|
? "carrying radio on another node"
|
||||||
|
: "busy";
|
||||||
|
switch (b.status) {
|
||||||
|
case "ok":
|
||||||
|
return b.available === false
|
||||||
|
? { tone: "warn", text: `${b.name}: can stream here, but is ${busy}` }
|
||||||
|
: { tone: "ok", text: `${b.name}: ready` };
|
||||||
|
case "denied":
|
||||||
|
return { tone: "bad", text: deniedSentence(b, channelName) + (b.available === false ? ` (also ${busy})` : "") };
|
||||||
|
case "not_member":
|
||||||
|
return { tone: "warn", text: `${b.name}: Discord says it is not in this server — invite it again` };
|
||||||
|
case "invalid":
|
||||||
|
return { tone: "bad", text: `${b.name}: its token was rejected by Discord — a platform admin has to replace it` };
|
||||||
|
case "bad_channel":
|
||||||
|
return { tone: "bad", text: `${b.name}: ${channelRef(channelName)} is not a voice channel` };
|
||||||
|
case "unchecked":
|
||||||
|
return { tone: "muted", text: `${b.name}: not checked (permission check is off)` };
|
||||||
|
default:
|
||||||
|
return { tone: "muted", text: `${b.name}: couldn't check${b.reason ? ` (${b.reason})` : ""} — DRB will still try it` };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user