- 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>
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
"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's organization'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'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'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>
|
||
);
|
||
}
|