- 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>
171 lines
6.9 KiB
TypeScript
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't play radio. Inviting it again won'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'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>
|
|
);
|
|
}
|