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>
This commit is contained in:
Logan Cusano
2026-10-05 03:24:44 -04:00
co-authored by Claude Opus 5.5
parent f49dc86111
commit 9c124d4c8b
13 changed files with 652 additions and 60 deletions
@@ -8,8 +8,12 @@ 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 is PUT /discord/guilds/{id}/link (platform admin). The owner-side
// "/drb link code:" flow is server-26#213 item 1 and is not built here.
// 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.
@@ -85,6 +89,30 @@ function LinkForm({
);
}
// 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({
@@ -131,7 +159,7 @@ function RemoveForm({
</span>
</label>
<p className="text-ink-muted">
The DRB bot itself stays in the Discord server until someone kicks it there.
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">
@@ -171,8 +199,9 @@ export function ServersPanel({
fallback ? (
<>
<b className="text-ink">Unlinked servers act for {fallbackName}</b> (DISCORD_UNLINKED_GUILD_ORG_ID). Any
server the DRB bot is invited to can use that org&apos;s systems until it is linked here. Set the variable
to an empty string once every real server is linked.
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.</>
@@ -189,14 +218,16 @@ export function ServersPanel({
{removed.bots.map((b) => `${b.name} ${b.left ? "left" : `did not leave (${b.error ?? "unknown error"})`}`).join("; ")}.
</>
)}{" "}
The DRB bot is still in that Discord server until someone kicks it.
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, or run a radio command in a server the DRB bot is in.</p>
<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", "Radio channels", "Linked", "Status", ""]}>
<Table head={["Server", "Acts for", "Bots", "Radio channels", "Linked", "Status", ""]}>
{overview.guilds.flatMap((g) => {
const row = (
<tr key={g.guild_id}>
@@ -208,6 +239,9 @@ export function ServersPanel({
{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>
@@ -251,7 +285,7 @@ export function ServersPanel({
return [
row,
<tr key={`${g.guild_id}-remove`}>
<td colSpan={6} className="p-0 border-b border-line">
<td colSpan={7} className="p-0 border-b border-line">
<RemoveForm
guild={g}
onCancel={() => setRemoving(null)}
@@ -269,7 +303,7 @@ export function ServersPanel({
return [
row,
<tr key={`${g.guild_id}-edit`}>
<td colSpan={6} className="p-0 border-b border-line">
<td colSpan={7} className="p-0 border-b border-line">
<LinkForm overview={overview} guild={g} onDone={done} onCancel={() => setEditing(null)} />
</td>
</tr>,
@@ -348,7 +348,8 @@ export function StreamsPanel({
)}
{overview.streams.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-muted">
No radio channels yet. Start one here, or run <span className="font-mono">/join</span> in a linked Discord server.
No radio channels yet. Start one here or from a node&apos;s Discord radio panel, or run{" "}
<span className="font-mono">/join</span> where the DRB server bot is in a linked Discord server.
</p>
) : (
<Table head={["Server · channel", "Source", "Talkgroups", "Mode", "Desired → actual", "Bot", ""]}>