Files
server-26/drb-frontend/components/discord/VoiceCheckBits.tsx
T
Logan CusanoandClaude Opus 5.5 1ee1560383 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>
2026-10-05 02:28:36 -04:00

158 lines
6.4 KiB
TypeScript

"use client";
// Per-bot voice-channel permission status (server-26#225 S4). Shared by the
// node-page picker (NodeDiscordRadio) and the admin Add form (StreamsPanel),
// both fed by c2-core routers/discord_checks.py. Start is disabled only on a
// DEFINITE refusal ("Needs fix", "Not a voice channel"); "Couldn't check"
// fails open — a Discord outage must never block a start.
import { useEffect, useState } from "react";
import {
agoText,
botLine,
deniedSentence,
fixSentence,
type ChannelVerdict,
type DiscordInvite,
type VoiceCheckChannel,
} from "@/lib/discordPicker";
const PILL = "inline-flex items-center gap-1 rounded px-1.5 py-px text-[11px] font-medium whitespace-nowrap border";
const BADGE: Record<ChannelVerdict["kind"], { label: string; cls: string; title?: string }> = {
ready: { label: "Ready", cls: "border-green-500/40 text-green-500" },
fix: { label: "Needs fix", cls: "border-sev-major/50 text-sev-major" },
not_voice: { label: "Not a voice channel", cls: "border-sev-major/50 text-sev-major" },
busy: {
label: "Bots busy",
cls: "border-sev-moderate/50 text-sev-moderate",
title: "A bot could stream here, but each one that can is already in another channel of this server or on another node.",
},
no_bot: { label: "No radio bot", cls: "border-sev-moderate/50 text-sev-moderate", title: "No radio bot is in this Discord server yet." },
unknown: { label: "Couldn't check", cls: "border-line text-ink-muted", title: "Discord didn't answer the check — DRB will still try." },
};
/** Ready / Needs fix / Couldn't check / …, plus "Limit N" when the channel has a user limit. */
export function VerdictBadge({ verdict, loading }: { verdict: ChannelVerdict | null; loading?: boolean }) {
if (!verdict) {
return loading ? <span className={`${PILL} border-line text-ink-muted`}>Checking…</span> : null;
}
const b = BADGE[verdict.kind];
// Name the missing permission right on the badge: "Needs fix · can't Speak".
const label =
verdict.kind === "fix" && verdict.bot.missing.length
? `${b.label} · can't ${verdict.bot.missing.join(", ")}`
: b.label;
const title = verdict.kind === "fix" ? `${verdict.bot.name}: ${verdict.bot.reason ?? "missing permission"}` : b.title;
return (
<span className="inline-flex gap-1 flex-wrap">
<span className={`${PILL} ${b.cls}`} title={title}>{label}</span>
{verdict.limit > 0 && (
<span
className={`${PILL} border-line text-ink-muted`}
title="Discord user limit. DRB can't see who is connected, so a full channel only shows up when the bot tries to join."
>
Limit {verdict.limit}
</span>
)}
</span>
);
}
/** The sentence under a "Needs fix" / "Not a voice channel" channel. */
export function FixText({ verdict, channelName }: { verdict: ChannelVerdict; channelName: string | null }) {
if (verdict.kind === "not_voice") {
return <span className="block text-xs text-sev-major mt-1">That is not a voice channel. Radio can only play in a voice channel.</span>;
}
if (verdict.kind !== "fix") return null;
return (
<span className="block text-xs text-sev-major mt-1">
{deniedSentence(verdict.bot, channelName)} {fixSentence(verdict.bot)}
</span>
);
}
const TONE: Record<"ok" | "bad" | "warn" | "muted", string> = {
ok: "text-green-500",
bad: "text-sev-major",
warn: "text-sev-moderate",
muted: "text-ink-muted",
};
const MARK: Record<"ok" | "bad" | "warn" | "muted", string> = { ok: "✓", bad: "✕", warn: "•", muted: "?" };
/** Expandable per-bot detail for one channel. */
export function BotDetails({ channel, channelName }: { channel: VoiceCheckChannel | null | undefined; channelName: string | null }) {
if (!channel || channel.bots.length === 0) return null;
return (
<details className="text-xs mt-1">
<summary className="cursor-pointer text-ink-muted hover:text-ink select-none">
Radio bots in this server ({channel.bots.length})
</summary>
<ul className="mt-1 space-y-0.5 pl-1">
{channel.bots.map((b, i) => {
const l = botLine(b, channelName);
return (
<li key={`${b.token_id ?? b.name}-${i}`} className={TONE[l.tone]}>
<span aria-hidden className="inline-block w-3">{MARK[l.tone]}</span> {l.text}
{b.status === "denied" && <span className="block pl-3 text-ink-muted">{fixSentence(b)}</span>}
</li>
);
})}
</ul>
</details>
);
}
/** "Permissions checked 40s ago · Re-check". Ticks on its own so the age stays honest. */
export function CheckFooter({
checkedAt,
busy,
error,
disabled,
limited,
invite,
onRecheck,
}: {
checkedAt: string | null;
busy: boolean;
error: string | null;
disabled?: boolean;
/** A Re-check was rate limited server-side (once per 10 s); the cached answer is shown. */
limited?: boolean;
invite?: DiscordInvite[];
onRecheck: () => void;
}) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 10_000);
return () => clearInterval(t);
}, []);
const ago = agoText(checkedAt, now);
if (disabled) {
return <div className="text-xs text-ink-muted">Permission check is off — DRB tries any free radio bot.</div>;
}
return (
<div className="text-xs text-ink-muted flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{busy ? "Checking permissions…" : ago ? `Permissions checked ${ago}` : "Permissions not checked yet"}</span>
<span aria-hidden>·</span>
<button type="button" onClick={onRecheck} disabled={busy} className="text-accent hover:underline disabled:opacity-50">
Re-check
</button>
{limited && !busy && (
<span className="basis-full">Re-check runs at most once every 10 seconds — this is the last result. Try again in a moment.</span>
)}
{error && <span className="basis-full text-sev-moderate">Couldn&apos;t check permissions: {error}</span>}
{invite && invite.length > 0 && (
<span className="basis-full flex flex-wrap gap-x-3 gap-y-1">
<span>Add another radio bot to this server:</span>
{invite.map((i) => (
<a key={i.invite_url} href={i.invite_url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">
Invite {i.name}
</a>
))}
</span>
)}
</div>
);
}