Files
server-26/drb-frontend/components/discord/ServersPanel.tsx
T
Logan CusanoandClaude Opus 5.5 9c124d4c8b S8: main DRB bot optional end to end + owner-flow integration tests (server-26#225)
- Copy: picker, admin Servers/Streams/How-it-works, org Discord card, bot
  /help and the server-limit refusal no longer imply the DRB server bot is
  required; a radio bot alone is enough.
- Admin overview: per-server main_bot_present, radio_bots, linked_via,
  directory_source; Servers tab gets a Bots column.
- discord_web docstring: states the reversed "never calls Discord" rule
  (plan finding 5) and the optional server bot.
- Server bot directory cog: re-push (which drops C2's cached permission
  answers) on overwrite changes, role permission changes and role deletes.
- tests/test_discord_owner_flows.py: one-bot org (BYO bot, owner-set
  preferred, OAuth link, picker, start, reconciler) and full setup (server
  bot + 4 radio bots: placement, shared channel, every refusal explained),
  against a modelled Discord over httpx.MockTransport.
- storage.download_audio removed: no caller on main since #233.

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

317 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { c2api } from "@/lib/c2api";
import { type DiscordAdminOverview, type DiscordGuild, SNOWFLAKE, fmtWhen, guildLabel, parseC2Error } from "@/lib/discord";
import { Button } from "@/components/ui/Button";
import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui";
// Linked Discord servers = discord_guilds docs, plus any server that has had a
// radio channel without being linked (it acts for DISCORD_UNLINKED_GUILD_ORG_ID).
// Linking here is PUT /discord/guilds/{id}/link (platform admin, support).
// Org owners link their own from Settings → Organization: "Connect a Discord
// server" (a radio bot's OAuth invite, no DRB server bot needed) or a
// /drb link code (needs the server bot) — server-26#213 item 1, #225.
// The Bots column says which bots are in each server: the DRB server bot is
// optional (server-26#225 S8), one radio bot is enough to stream.
// Remove is DELETE /discord/guilds/{id}: its radio channels are stopped then
// deleted, and the server is unlinked with no fallback org.
function LinkForm({
overview,
guild,
onDone,
onCancel,
}: {
overview: DiscordAdminOverview;
guild?: DiscordGuild;
onDone: () => void;
onCancel: () => void;
}) {
const [guildId, setGuildId] = useState(guild?.guild_id ?? "");
const [label, setLabel] = useState(guild?.guild_name ?? "");
const [orgId, setOrgId] = useState(guild?.org_id ?? guild?.acting_org_id ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!SNOWFLAKE.test(guildId.trim())) {
setError("Server ID is the 17–20 digit number from Discord (right-click the server icon → Copy Server ID).");
return;
}
const actingNow = guild?.acting_org_id ?? null;
if (guild && guild.live_streams > 0 && actingNow && actingNow !== orgId) {
const n = guild.live_streams;
if (!confirm(`${guildLabel(guild)} has ${n} live radio channel${n === 1 ? "" : "s"} on the current organization's nodes. Moving it stops ${n === 1 ? "it" : "them"}. Continue?`)) return;
}
setSaving(true);
setError(null);
try {
await c2api.linkDiscordGuild(guildId.trim(), { org_id: orgId, guild_name: label.trim() || undefined });
onDone();
} catch (err) {
setError(parseC2Error(err).message);
} finally {
setSaving(false);
}
}
return (
<form onSubmit={submit} className="px-4 py-4 border-b border-line grid gap-3 sm:grid-cols-3 bg-raised">
<Field label="Discord server ID">
<input
value={guildId}
onChange={(e) => setGuildId(e.target.value)}
required
readOnly={!!guild}
inputMode="numeric"
className={`${INPUT} font-mono ${guild ? "opacity-70" : ""}`}
/>
</Field>
<Field label="Label (optional)">
<input value={label} onChange={(e) => setLabel(e.target.value)} className={INPUT} placeholder="Fire Co. 7" />
</Field>
<Field label="Acts for organization">
<select value={orgId} onChange={(e) => setOrgId(e.target.value)} required className={INPUT}>
<option value="" disabled>Choose an organization…</option>
{overview.orgs.map((o) => (
<option key={o.org_id} value={o.org_id}>{o.name}</option>
))}
</select>
</Field>
{error && <p className="sm:col-span-3 text-sev-major text-xs">{error}</p>}
<div className="sm:col-span-3 flex gap-2">
<Button type="submit" size="sm" disabled={saving || !orgId}>{saving ? "Saving…" : guild?.linked ? "Save link" : "Link server"}</Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
</div>
</form>
);
}
// Which bots are in the server. The DRB server bot is optional (server-26#225):
// "not there" is only a problem when no radio bot is either.
function BotsCell({ guild }: { guild: DiscordGuild }) {
const radio = guild.radio_bots ?? [];
const main = guild.main_bot_present;
return (
<>
<div>
{radio.length > 0 ? (
<span title={radio.join(", ")}>
{radio.length} radio bot{radio.length === 1 ? "" : "s"}
</span>
) : (
<span className={main ? DIM : "text-sev-moderate"}>no radio bot</span>
)}
</div>
<div className={DIM}>
DRB server bot: {main === true ? "present" : main === false ? "not there" : "never seen"}
{guild.directory_source === "radio_bot" && " · channels via radio bot"}
</div>
</>
);
}
type RemoveResult = { guild: string; streams: number; bots: { name: string; left: boolean; error: string | null }[] };
function RemoveForm({
guild,
onDone,
onCancel,
}: {
guild: DiscordGuild;
onDone: (r: RemoveResult) => void;
onCancel: () => void;
}) {
const [leaveBots, setLeaveBots] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const n = guild.live_streams;
async function submit() {
setSaving(true);
setError(null);
try {
const r = await c2api.removeDiscordGuild(guild.guild_id, leaveBots);
onDone({ guild: guildLabel(guild), streams: r.streams_removed.length, bots: r.radio_bots });
} catch (err) {
setError(parseC2Error(err).message);
} finally {
setSaving(false);
}
}
return (
<div className="px-4 py-4 border-b border-line bg-raised text-xs text-ink-2 space-y-3">
<p>
<b className="text-ink">Remove {guildLabel(guild)} from DRB?</b>{" "}
{n > 0
? `Its ${n} live radio channel${n === 1 ? " is" : "s are"} stopped first, then every radio channel in it is deleted.`
: "Every radio channel in it is deleted."}{" "}
It is unlinked and will not fall back to the default organization; linking it again brings it back.
</p>
<label className="flex items-start gap-2 cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={leaveBots} onChange={(e) => setLeaveBots(e.target.checked)} />
<span>
Also make the radio bots leave this Discord server (the platform pool and this server&apos;s organization&apos;s
own bots). They need a new invite to come back.
</span>
</label>
<p className="text-ink-muted">
The DRB server bot, if it is in the Discord server, stays until someone kicks it there.
</p>
{error && <p className="text-sev-major">{error}</p>}
<div className="flex gap-2">
<Button size="sm" variant="danger" disabled={saving} onClick={submit}>{saving ? "Removing…" : "Remove server"}</Button>
<Button size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
</div>
</div>
);
}
export function ServersPanel({
overview,
onChanged,
}: {
overview: DiscordAdminOverview;
onChanged: () => Promise<void> | void;
}) {
// "new" = blank link form; a guild id = editing that row's link.
const [editing, setEditing] = useState<string | null>(null);
const [removing, setRemoving] = useState<string | null>(null);
const [removed, setRemoved] = useState<RemoveResult | null>(null);
const fallback = overview.limits.unlinked_guild_org_id;
const fallbackName = overview.limits.unlinked_guild_org_name || fallback;
const max = overview.limits.guild_max_streams;
const done = async () => {
setEditing(null);
await onChanged();
};
return (
<Panel
title="Linked Discord servers"
description="Every bot command in a server is scoped to the organization it is linked to."
action={<Button size="sm" onClick={() => setEditing(editing === "new" ? null : "new")}>+ Link a server</Button>}
note={
fallback ? (
<>
<b className="text-ink">Unlinked servers act for {fallbackName}</b> (DISCORD_UNLINKED_GUILD_ORG_ID). Any
server the DRB server bot is invited to can use that org&apos;s systems until it is linked here (a radio
bot alone never puts an unlinked server here). Set the variable to an empty string once every real server
is linked.
</>
) : (
<>Unlinked servers can&apos;t use radio commands until they are linked here.</>
)
}
>
{editing === "new" && <LinkForm overview={overview} onDone={done} onCancel={() => setEditing(null)} />}
{removed && (
<div className="px-4 py-3 border-b border-line text-xs text-ink-2">
Removed {removed.guild}: {removed.streams} radio channel{removed.streams === 1 ? "" : "s"} deleted.
{removed.bots.length > 0 && (
<>
{" "}Radio bots:{" "}
{removed.bots.map((b) => `${b.name} ${b.left ? "left" : `did not leave (${b.error ?? "unknown error"})`}`).join("; ")}.
</>
)}{" "}
The DRB server bot, if it was there, stays in that Discord server until someone kicks it.
<button className="ml-3 text-accent hover:underline" onClick={() => setRemoved(null)}>Dismiss</button>
</div>
)}
{overview.guilds.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-muted">
No Discord servers yet. Link one here, or an organization owner connects one under Settings → Organization.
</p>
) : (
<Table head={["Server", "Acts for", "Bots", "Radio channels", "Linked", "Status", ""]}>
{overview.guilds.flatMap((g) => {
const row = (
<tr key={g.guild_id}>
<td className={TD}>
<b>{guildLabel(g)}</b>
<div className={`${DIM} font-mono`}>{g.guild_id}</div>
</td>
<td className={TD}>
{g.acting_org_name || g.acting_org_id || <span className={DIM}>nobody</span>}
{!g.linked && g.acting_org_id && <div className={DIM}>fallback, not linked</div>}
</td>
<td className={TD}>
<BotsCell guild={g} />
</td>
<td className={TD}>
{g.live_streams} / {max}
</td>
<td className={TD}>{g.linked ? fmtWhen(g.linked_at) : <span className={DIM}>—</span>}</td>
<td className={TD}>
{g.linked ? (
<Status health="ok">Linked</Status>
) : g.acting_org_id ? (
<Status health="warn">Unlinked · fallback org</Status>
) : (
<Status health="bad">Unlinked</Status>
)}
</td>
<td className={`${TD} text-right whitespace-nowrap`}>
<Button
size="sm"
variant="secondary"
onClick={() => {
setRemoving(null);
setEditing(editing === g.guild_id ? null : g.guild_id);
}}
>
{g.linked ? "Change org" : "Link"}
</Button>
<Button
size="sm"
variant="danger"
className="ml-1"
onClick={() => {
setEditing(null);
setRemoved(null);
setRemoving(removing === g.guild_id ? null : g.guild_id);
}}
>
Remove
</Button>
</td>
</tr>
);
if (removing === g.guild_id) {
return [
row,
<tr key={`${g.guild_id}-remove`}>
<td colSpan={7} className="p-0 border-b border-line">
<RemoveForm
guild={g}
onCancel={() => setRemoving(null)}
onDone={async (r) => {
setRemoving(null);
setRemoved(r);
await onChanged();
}}
/>
</td>
</tr>,
];
}
if (editing !== g.guild_id) return [row];
return [
row,
<tr key={`${g.guild_id}-edit`}>
<td colSpan={7} className="p-0 border-b border-line">
<LinkForm overview={overview} guild={g} onDone={done} onCancel={() => setEditing(null)} />
</td>
</tr>,
];
})}
</Table>
)}
</Panel>
);
}