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>
158 lines
6.4 KiB
TypeScript
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'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>
|
|
);
|
|
}
|