Files
server-26/drb-frontend/components/discord/VoiceCheckBits.tsx
T
Logan CusanoandClaude Opus 5.5 32d223e556 S7: OAuth start/options accept system_id; Discord picker QA fixes (server-26#225, #256 #257 #258)
- discord_oauth: POST /discord/oauth/start and GET /options take an optional
  system_id. The system must be the org's own or shared (another org's or
  unknown = 404). Its preferred bot is strict: one that can't connect is a
  404, never a quiet swap to Automatic. No preference = the usual order.
  The picker's "bot for a system" now always sends system_id.
- #257: _channel_state gives a bot whose token Discord rejected its own
  "invalid" state; the picker shows "Radio bot needs replacing" with no
  invite links, instead of "No radio bot".
- #256: Start no longer stays disabled on "Needs fix". It re-checks with
  Discord first (refresh=1) and starts only if the fresh answer allows it.
  "Not a voice channel" still disables Start.
- #258: own tooltip for "Not a voice channel"; "platform admin" -> "DRB
  support"; PreferredBotsCard says plainly that adding your own bot isn't
  self-serve (no support address exists yet, see #47), and a load failure
  gets a plain message + Try again instead of the raw C2 error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:09:28 -04:00

171 lines
6.9 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." },
invalid: {
label: "Radio bot needs replacing",
cls: "border-sev-major/50 text-sev-major",
title: "A radio bot is in this server, but Discord rejected its token. Inviting it again won't help — DRB support has to replace it.",
},
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" / "Radio bot needs replacing" 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 === "invalid") {
return (
<span className="block text-xs text-sev-major mt-1">
Discord rejected the radio bot in this server, so it can&apos;t play radio. Inviting it again won&apos;t help —
DRB support has to replace it.
</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>
);
}