Merge pull request 'Discord: Connect a Discord server — OAuth UI (#225 S7) — merge after S6' (#259) from feat/225-s7-oauth-ui into main
This commit was merged in pull request #259.
This commit is contained in:
@@ -4,14 +4,66 @@
|
||||
// Matches design-canvas/discord-system-mockup.html's Servers panel, minus the
|
||||
// plan / incident-forum / roles / limits columns: none of those exist
|
||||
// server-side yet, and a column with nothing behind it is a dead surface.
|
||||
//
|
||||
// Two ways to link (server-26#225 S7):
|
||||
// 1. Connect a Discord server (primary): pick a radio bot, add it in
|
||||
// Discord, and the same click links the server (per-bot OAuth, S6).
|
||||
// No main bot needed. Discord sends the browser back to
|
||||
// /discord/connected, which finishes the link.
|
||||
// 2. Link code (secondary, collapsed): /drb link, run by someone with
|
||||
// Manage Server. Needs the main DRB bot in that server.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { Card, CardHeader } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { parseC2Error, type OrgPreferredBots } from "@/lib/discordPicker";
|
||||
import { type OAuthStartRequest, OAuthNotConfigured, startDiscordOAuth } from "@/lib/discordOAuth";
|
||||
|
||||
type Guild = { guild_id: string; guild_name: string | null; linked_at: string | null; linked_via: string };
|
||||
|
||||
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";
|
||||
|
||||
// Option values for the bot picker: "" = let C2 choose, "tok:<id>" = one of
|
||||
// the org's own bots, "sys:<id>" = whichever bot is preferred for that system.
|
||||
function startRequest(choice: string): OAuthStartRequest {
|
||||
const req: OAuthStartRequest = { return_to: "/settings/organization" };
|
||||
if (choice.startsWith("tok:")) req.token_id = choice.slice(4);
|
||||
// c2-core resolves the system's preferred bot itself, including a pool bot
|
||||
// a platform admin pinned (whose token_id the org never sees).
|
||||
else if (choice.startsWith("sys:")) req.system_id = choice.slice(4);
|
||||
return req;
|
||||
}
|
||||
|
||||
function BotChoices({ data }: { data: OrgPreferredBots | null }) {
|
||||
const systems = (data?.systems ?? []).filter((s) => s.preferred_token_id || s.preferred_is_platform_bot);
|
||||
const bots = data?.bots ?? [];
|
||||
return (
|
||||
<>
|
||||
<option value="">Automatic: your own bot, else a DRB radio bot</option>
|
||||
{systems.length > 0 && (
|
||||
<optgroup label="The bot for a system">
|
||||
{systems.map((s) => (
|
||||
<option key={s.system_id} value={`sys:${s.system_id}`}>
|
||||
{s.name}: {s.preferred_name ?? "DRB radio bot"}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{bots.length > 0 && (
|
||||
<optgroup label="Your organization's bots">
|
||||
{bots.map((b) => (
|
||||
<option key={b.token_id} value={`tok:${b.token_id}`} disabled={b.valid === false}>
|
||||
{b.name}{b.valid === false ? " (token rejected)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
type Code = { code: string; expires_at: string; command: string };
|
||||
|
||||
const POLL_MS = 5000;
|
||||
@@ -41,6 +93,14 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const known = useRef<Set<string> | null>(null);
|
||||
const [choices, setChoices] = useState<OrgPreferredBots | null>(null);
|
||||
const [choice, setChoice] = useState("");
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
// Set after /discord/oauth/start answers 404: no bot that fits has Connect
|
||||
// turned on yet (a platform admin sets its client secret). The link code is
|
||||
// then the only way, so it opens.
|
||||
const [oauthOff, setOauthOff] = useState(false);
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -58,8 +118,35 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
|
||||
useEffect(() => {
|
||||
if (!canEdit) return;
|
||||
load().then((g) => { if (g) known.current = new Set(g.map((x) => x.guild_id)); });
|
||||
// Bots to offer. A 403 (platform admin with no org) just leaves "Automatic".
|
||||
c2api.getOrgPreferredBots().then(setChoices).catch(() => setChoices(null));
|
||||
}, [canEdit, load]);
|
||||
|
||||
async function connect() {
|
||||
setConnecting(true);
|
||||
setActionError(null);
|
||||
setJustLinked(null);
|
||||
try {
|
||||
const { url } = await startDiscordOAuth(startRequest(choice));
|
||||
window.location.assign(url);
|
||||
// Leave the button in its busy state: the page is navigating to Discord.
|
||||
} catch (err) {
|
||||
if (err instanceof OAuthNotConfigured) {
|
||||
setShowCode(true);
|
||||
if (choice) {
|
||||
// Only this bot is off: keep Connect and the picker so Automatic can be tried.
|
||||
setActionError("That bot can't connect servers yet. Try Automatic, or link with a code below.");
|
||||
} else {
|
||||
setOauthOff(true);
|
||||
setActionError("Connecting with Discord isn't set up yet. Link with a code below instead.");
|
||||
}
|
||||
} else {
|
||||
setActionError(parseC2Error(err).message);
|
||||
}
|
||||
setConnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const msLeft = code ? new Date(code.expires_at).getTime() - now : 0;
|
||||
const live = !!code && msLeft > 0;
|
||||
|
||||
@@ -126,14 +213,47 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
|
||||
<div className="p-5 pb-0">
|
||||
<CardHeader
|
||||
title="Linked Discord servers"
|
||||
subtitle={<>A link code is run as <span className="font-mono">/drb link</span> by someone with Manage Server in that Discord server.</>}
|
||||
subtitle={<>Add a radio bot to your Discord server. Adding it links the server to this organization.</>}
|
||||
action={
|
||||
<Button size="sm" onClick={generate} disabled={!canEdit || busy}
|
||||
title={!canEdit ? "Only the organization owner can link Discord servers" : undefined}>
|
||||
{busy ? "Generating…" : live ? "New code" : "Generate link code"}
|
||||
</Button>
|
||||
!oauthOff ? (
|
||||
<Button size="sm" onClick={connect} disabled={!canEdit || connecting}
|
||||
title={!canEdit ? "Only the organization owner can link Discord servers" : undefined}>
|
||||
{connecting ? "Opening Discord…" : "Connect a Discord server"}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{canEdit && !oauthOff && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4 text-xs text-ink-muted">
|
||||
<label htmlFor="drb-connect-bot">Radio bot</label>
|
||||
<select id="drb-connect-bot" value={choice} onChange={(e) => setChoice(e.target.value)} disabled={connecting} className={SELECT}>
|
||||
<BotChoices data={choices} />
|
||||
</select>
|
||||
<span>Discord asks you to pick the server and confirm View Channel, Connect and Speak.</span>
|
||||
</div>
|
||||
)}
|
||||
{canEdit && (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCode((v) => !v)}
|
||||
aria-expanded={showCode}
|
||||
className="text-xs text-ink-muted hover:text-ink"
|
||||
>
|
||||
{showCode ? "▾" : "▸"} Or link with a code (<span className="font-mono">/drb link</span>, needs the main DRB bot in the server)
|
||||
</button>
|
||||
{showCode && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant={oauthOff ? "primary" : "secondary"} onClick={generate} disabled={busy}>
|
||||
{busy ? "Generating…" : live ? "New code" : "Generate link code"}
|
||||
</Button>
|
||||
<span className="text-xs text-ink-muted">
|
||||
Someone with Manage Server runs it as <span className="font-mono">/drb link</span> in that Discord server.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(code || justLinked || actionError) && (
|
||||
@@ -189,7 +309,10 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
|
||||
<div className="text-ink font-medium">{g.guild_name || "Unnamed server"}</div>
|
||||
<div className="text-xs text-ink-muted font-mono" title={g.guild_id}>guild {shortId(g.guild_id)}</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-ink-2 hidden sm:table-cell">{fmtDate(g.linked_at)}</td>
|
||||
<td className="px-5 py-3 text-xs text-ink-2 hidden sm:table-cell">
|
||||
{fmtDate(g.linked_at)}
|
||||
{g.linked_via === "oauth" && <div className="text-ink-muted">by adding a radio bot</div>}
|
||||
</td>
|
||||
<td className="px-5 py-3"><Badge tone="success">Linked</Badge></td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
{canEdit && (
|
||||
|
||||
@@ -116,8 +116,8 @@ function DiscordPicker({
|
||||
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 verdictOf = (c: DiscordGuildOption["voice_channels"][number], data: VoiceCheck | null | undefined = check?.data) => {
|
||||
const row = data && data.enabled !== false ? 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
|
||||
@@ -128,19 +128,26 @@ function DiscordPicker({
|
||||
};
|
||||
const chosenVerdict = channel ? verdictOf(channel) : null;
|
||||
const definiteDeny = blocksStart(chosenVerdict);
|
||||
// "Not a voice channel" can't change, so Start stays off. "Needs fix" can:
|
||||
// Start re-checks with Discord first (server-26#256), so fixing it in
|
||||
// Discord and pressing Start again just works — no hunting for Re-check.
|
||||
const startOff = chosenVerdict?.kind === "not_voice";
|
||||
// Not for "invalid": that bot is already in the server; inviting won't help (#257).
|
||||
const showInvite = chosenVerdict?.kind === "fix" || chosenVerdict?.kind === "no_bot" || chosenVerdict?.kind === "busy";
|
||||
|
||||
const runCheck = useCallback(
|
||||
async (gid: string, refresh: boolean) => {
|
||||
async (gid: string, refresh: boolean): Promise<VoiceCheck | null> => {
|
||||
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 } }));
|
||||
return data;
|
||||
} 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 } }));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[nodeId],
|
||||
@@ -166,6 +173,12 @@ function DiscordPicker({
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (definiteDeny) {
|
||||
// Fresh answer from Discord; still refused = stay here, the row shows why.
|
||||
// A failed check fails open, like everywhere else in the picker.
|
||||
const fresh = await runCheck(guild.guild_id, true);
|
||||
if (fresh && blocksStart(verdictOf(channel, fresh))) return;
|
||||
}
|
||||
await c2api.startNodeDiscordStream(nodeId, { guild_id: guild.guild_id, channel_id: channel.channel_id, mode });
|
||||
onStarted();
|
||||
onClose();
|
||||
@@ -382,11 +395,17 @@ function DiscordPicker({
|
||||
<button
|
||||
type="button"
|
||||
onClick={start}
|
||||
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}
|
||||
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || startOff}
|
||||
title={
|
||||
startOff
|
||||
? "That is not a voice channel. Pick a voice channel."
|
||||
: definiteDeny
|
||||
? "Fixed the permission in Discord? Start checks again first."
|
||||
: 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"
|
||||
>
|
||||
{sending ? "Starting…" : channelOnHere ? "Update" : "Start radio"}
|
||||
{sending ? (definiteDeny ? "Checking…" : "Starting…") : channelOnHere ? "Update" : "Start radio"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -33,7 +33,8 @@ export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
|
||||
} catch (err) {
|
||||
const e = parseC2Error(err);
|
||||
if (e.status === 403) setHidden(true);
|
||||
else setLoadError(e.message);
|
||||
// A server error's raw body ("C2 API error 500: ...") means nothing to an owner.
|
||||
else setLoadError(e.status !== null && e.status >= 500 ? "DRB couldn't load them just now." : e.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -67,15 +68,20 @@ export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
|
||||
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>
|
||||
<p role="alert" className="text-sm text-sev-major">
|
||||
Couldn't load radio bots: {loadError}{" "}
|
||||
<button type="button" onClick={() => load()} className="text-accent hover:underline">
|
||||
Try again
|
||||
</button>
|
||||
</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.
|
||||
Your organization has no radio bots of its own, so DRB picks a free DRB radio bot for each channel. Adding
|
||||
your own bot isn't self-serve yet: DRB support adds it for you, so ask whoever set up your DRB account.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-line">
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useState } from "react";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import {
|
||||
DISCORD_UNVERIFIED_GUILD_LIMIT,
|
||||
POOL_BOT_GUILD_CAP,
|
||||
type DiscordAdminOverview,
|
||||
type RadioBot,
|
||||
type RadioBotAddResult,
|
||||
@@ -13,12 +15,13 @@ import {
|
||||
shortId,
|
||||
} from "@/lib/discord";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { clearBotOAuthSecret, setBotOAuthSecret } from "@/lib/discordOAuth";
|
||||
import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui";
|
||||
|
||||
// Radio bots = the bot_tokens pool (routers/tokens.py). Used by the Discord
|
||||
// admin page's "Radio bots" tab and by /tokens. Every action here is a real
|
||||
// platform-admin route: POST /tokens, POST /tokens/{id}/refresh,
|
||||
// DELETE /tokens/{id}, POST /tokens/flush.
|
||||
// DELETE /tokens/{id}, POST /tokens/flush, PUT /tokens/{id}/oauth.
|
||||
|
||||
function Checks({ t }: { t: RadioBot }) {
|
||||
if (t.valid === false) {
|
||||
@@ -46,6 +49,102 @@ function Checks({ t }: { t: RadioBot }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** "N of 100 servers" — Discord caps an unverified bot at 100; Connect stops
|
||||
* picking a pool bot at the cap (owner ruling Q3), so a full one needs a sibling. */
|
||||
function ServerCount({ t, cap }: { t: RadioBot; cap: number }) {
|
||||
if (t.guild_ids == null) return null;
|
||||
const n = t.guild_ids.length;
|
||||
const limit = t.server_cap ?? cap;
|
||||
const full = t.org_id == null && n >= limit;
|
||||
return (
|
||||
<div className={`text-xs mb-1 ${full || n >= DISCORD_UNVERIFIED_GUILD_LIMIT ? "text-sev-moderate" : "text-ink-muted"}`}>
|
||||
{n} of {DISCORD_UNVERIFIED_GUILD_LIMIT} servers
|
||||
{full && ` · full at ${limit}: Connect skips it, add another pool bot`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bot app's OAuth client secret, which turns on "Connect a Discord server"
|
||||
* for it (server-26#225 S6/S7, owner ruling Q1: per-bot OAuth). Write-only:
|
||||
* c2-core checks it with Discord and stores it server-side; this form never
|
||||
* reads it back, and the field is cleared as soon as it is sent.
|
||||
*/
|
||||
function ConnectSetting({ t, onChanged }: { t: RadioBot; onChanged: () => Promise<void> | void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [secret, setSecret] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const on = !!t.oauth_enabled;
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const value = secret.trim();
|
||||
setSecret("");
|
||||
if (!value) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await setBotOAuthSecret(t.token_id, value);
|
||||
setOpen(false);
|
||||
await onChanged();
|
||||
} catch (err) {
|
||||
setError(parseC2Error(err).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function turnOff() {
|
||||
if (!confirm(`Turn off Connect for ${t.name}? Its stored client secret is deleted. Plain invite links keep working.`)) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await clearBotOAuthSecret(t.token_id);
|
||||
await onChanged();
|
||||
} catch (err) {
|
||||
setError(parseC2Error(err).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Status health={on ? "ok" : "off"}>{on ? "Connect on" : "Connect off"}</Status>
|
||||
<button type="button" className="text-xs text-accent hover:underline" onClick={() => { setOpen((v) => !v); setError(null); }}>
|
||||
{open ? "Cancel" : on ? "Replace secret" : "Set client secret"}
|
||||
</button>
|
||||
{on && !open && (
|
||||
<button type="button" disabled={saving} className="text-xs text-sev-major hover:underline disabled:opacity-40" onClick={turnOff}>
|
||||
Turn off
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<form onSubmit={save} className="mt-1.5 flex flex-wrap items-center gap-1.5 max-w-[18rem]">
|
||||
<input
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-label={`OAuth client secret for ${t.name}`}
|
||||
placeholder="Client secret"
|
||||
className={`${INPUT} font-mono flex-1 min-w-0`}
|
||||
/>
|
||||
<Button type="submit" size="sm" disabled={saving || !secret.trim()}>{saving ? "Checking…" : "Save"}</Button>
|
||||
<p className={`${DIM} w-full`}>
|
||||
From the Discord Developer Portal → this bot's app → OAuth2. Checked with Discord, stored server-side, never shown again.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
{error && <div className="text-xs text-sev-major mt-1 max-w-[18rem]">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Membership({ t, overview }: { t: RadioBot; overview: DiscordAdminOverview | null }) {
|
||||
if (t.guild_ids == null) return <span className={DIM}>unknown — refresh</span>;
|
||||
const known = new Map((overview?.guilds ?? []).map((g) => [g.guild_id, g]));
|
||||
@@ -54,34 +153,38 @@ function Membership({ t, overview }: { t: RadioBot; overview: DiscordAdminOvervi
|
||||
const missing = (overview?.guilds ?? []).filter(
|
||||
(g) => !members.includes(g.guild_id) && (t.org_id == null || g.acting_org_id === t.org_id),
|
||||
);
|
||||
const cap = overview?.limits.pool_bot_guild_cap ?? POOL_BOT_GUILD_CAP;
|
||||
if (!members.length && !missing.length) return <span className={DIM}>no servers</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 max-w-[22rem]">
|
||||
{members.map((gid) => (
|
||||
<span key={gid} className="text-[11px] rounded px-1.5 py-px bg-raised border border-line text-ink-2">
|
||||
{known.has(gid) ? guildLabel(known.get(gid)!) : `server ${shortId(gid)}`}
|
||||
</span>
|
||||
))}
|
||||
{missing.map((g) => {
|
||||
const href = inviteForGuild(t.invite_url, g.guild_id);
|
||||
const label = `${guildLabel(g)} ✕`;
|
||||
return href ? (
|
||||
<a
|
||||
key={g.guild_id}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Not in ${guildLabel(g)} — invite it`}
|
||||
className="text-[11px] rounded px-1.5 py-px border border-dashed border-sev-moderate text-sev-moderate hover:underline"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<span key={g.guild_id} className="text-[11px] rounded px-1.5 py-px border border-dashed border-sev-moderate text-sev-moderate">
|
||||
{label}
|
||||
<div className="max-w-[22rem]">
|
||||
<ServerCount t={t} cap={cap} />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{members.map((gid) => (
|
||||
<span key={gid} className="text-[11px] rounded px-1.5 py-px bg-raised border border-line text-ink-2">
|
||||
{known.has(gid) ? guildLabel(known.get(gid)!) : `server ${shortId(gid)}`}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
{missing.map((g) => {
|
||||
const href = inviteForGuild(t.invite_url, g.guild_id);
|
||||
const label = `${guildLabel(g)} ✕`;
|
||||
return href ? (
|
||||
<a
|
||||
key={g.guild_id}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Not in ${guildLabel(g)} — invite it`}
|
||||
className="text-[11px] rounded px-1.5 py-px border border-dashed border-sev-moderate text-sev-moderate hover:underline"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<span key={g.guild_id} className="text-[11px] rounded px-1.5 py-px border border-dashed border-sev-moderate text-sev-moderate">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -286,6 +389,7 @@ export function RadioBotsPanel({
|
||||
</td>
|
||||
<td className={TD}>
|
||||
<Checks t={t} />
|
||||
<ConnectSetting t={t} onChanged={onChanged} />
|
||||
</td>
|
||||
<td className={`${TD} text-right whitespace-nowrap`}>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type ParsedC2Error,
|
||||
type VoiceCheck,
|
||||
type VoiceCheckBot,
|
||||
type VoiceCheckChannel,
|
||||
} from "@/lib/discordPicker";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
|
||||
@@ -70,17 +71,19 @@ function AddChannelForm({
|
||||
|
||||
const cid = channelId.trim();
|
||||
const checkKey = SNOWFLAKE.test(cid) && guildId ? `${guildId}:${cid}` : "";
|
||||
const runCheck = useCallback(async (key: string, refresh: boolean) => {
|
||||
const runCheck = useCallback(async (key: string, refresh: boolean): Promise<VoiceCheck | null> => {
|
||||
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));
|
||||
return data;
|
||||
} 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,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
// Check the channel once its ID looks complete (debounced while typing).
|
||||
@@ -98,8 +101,8 @@ function AddChannelForm({
|
||||
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 botCheck = (b: RadioBot, row: VoiceCheckChannel | undefined = checked): VoiceCheckBot | undefined =>
|
||||
row?.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 systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org);
|
||||
@@ -124,6 +127,13 @@ function AddChannelForm({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (checkKey && (verdict?.kind === "fix" || chosenBotDenied)) {
|
||||
// "Needs fix" can change: ask Discord again before refusing (server-26#256).
|
||||
// Still refused = stay here, the row shows why; a failed check fails open.
|
||||
const fresh = await runCheck(checkKey, true);
|
||||
const row = fresh && fresh.enabled !== false ? fresh.channels[cid] : undefined;
|
||||
if (row && (blocksStart(channelVerdict(row)) || (chosenBot && botCheck(chosenBot, row)?.ok === false))) return;
|
||||
}
|
||||
const res = await c2api.startDiscordStream({
|
||||
guild_id: guildId,
|
||||
channel_id: channelId.trim(),
|
||||
@@ -221,7 +231,7 @@ function AddChannelForm({
|
||||
</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>
|
||||
<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 press Start again.</div>
|
||||
)}
|
||||
{checkOn && <BotDetails channel={checked} channelName={shownName} />}
|
||||
<CheckFooter
|
||||
@@ -252,10 +262,16 @@ function AddChannelForm({
|
||||
<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}
|
||||
disabled={saving || !systemId || verdict?.kind === "not_voice"}
|
||||
title={
|
||||
verdict?.kind === "not_voice"
|
||||
? "That is not a voice channel. Pick a voice channel."
|
||||
: blocksStart(verdict) || chosenBotDenied
|
||||
? "Fixed the permission in Discord? Start checks again first."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{saving ? "Starting…" : "Start radio channel"}
|
||||
{saving ? (blocksStart(verdict) || chosenBotDenied ? "Checking…" : "Starting…") : "Start radio channel"}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,11 @@ const BADGE: Record<ChannelVerdict["kind"], { label: string; cls: string; title?
|
||||
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." },
|
||||
};
|
||||
|
||||
@@ -59,11 +64,19 @@ export function VerdictBadge({ verdict, loading }: { verdict: ChannelVerdict | n
|
||||
);
|
||||
}
|
||||
|
||||
/** The sentence under a "Needs fix" / "Not a voice channel" channel. */
|
||||
/** 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">
|
||||
|
||||
Reference in New Issue
Block a user