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 -2
View File
@@ -43,8 +43,8 @@ function Kpi({ k, v, s }: { k: string; v: string; s: string }) {
function HowItWorks() {
const steps = [
["1 · Desired", "/join in Discord or this page writes a discord_streams doc."],
["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server, in one transaction."],
["1 · Desired", "A node page's picker, this page, or /join (DRB server bot) writes a discord_streams doc."],
["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server that Discord lets View, Connect and Speak in the channel — the system's preferred bot first — in one transaction."],
["3 · Reconcile", "A retained MQTT message tells the node its full channel list (token ids, never token strings)."],
["4 · Voice", "The node logs in as the leased bot and plays its PulseAudio capture — never Icecast."],
["5 · Actual", "The node reports each channel's real state; this page and the Discord reply show it."],
@@ -54,6 +54,12 @@ function HowItWorks() {
title="How a radio channel stays up"
note="Node reboot, C2 sweep, Discord voice drop: the node re-reads its desired state and reconnects; persistent channels whose node went away are re-placed on another node. A channel desired-on but not connected for 2 minutes turns red on the Radio channels tab."
>
<p className="px-4 pt-4 text-xs text-ink-2">
<b className="text-ink">The DRB server bot is optional.</b> One radio bot in a Discord server is enough: adding it
through <i>Connect a Discord server</i> (Settings → Organization) links the server, and C2 reads its voice
channels and permissions with that bot. The server bot adds the slash commands (/join, /leave, /status,
/drb link) and pushes its own channel list; both ways keep working side by side.
</p>
<div className="grid gap-2 p-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
{steps.map(([t, d]) => (
<div key={t} className="bg-raised border border-line rounded-lg p-3 text-xs text-ink-muted">
@@ -329,12 +329,13 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
<div className="px-5 py-3 border-t border-line bg-raised text-xs text-ink-muted">
{restricted ? (
<>Unlinked servers see only <b className="text-ink">/drb link</b> and <b className="text-ink">/help</b>. </>
<>Where the DRB server bot is present, an unlinked server sees only <b className="text-ink">/drb link</b> and <b className="text-ink">/help</b>. </>
) : isAdmin ? (
<>Platform setting: unlinked servers still act for the default organization (<span className="font-mono">DISCORD_UNLINKED_GUILD_ORG_ID</span>) until linked. </>
) : null}
Every command and autocomplete is scoped to the linked organization — no server sees another
organization&apos;s nodes, systems or bots.
Radio channels, and every DRB server bot command and autocomplete, are scoped to the linked organization — no
server sees another organization&apos;s nodes, systems or bots. The DRB server bot is optional: a radio bot
alone is enough to stream.
</div>
</Card>
);
+12 -8
View File
@@ -2,9 +2,11 @@
// Node page "Discord radio" panel + picker (server-26#213 item 3, #109 item 13).
// Replaces the raw Guild ID / Voice Channel ID modal. The picker lists only
// the Discord servers linked to this node's org and the voice channels the
// DRB bot reported in them; a pick becomes a radio channel pinned to this node
// (desired state — the node reconciles and reports back, shown per row).
// the Discord servers linked to this node's org and their voice channels, as
// read by a radio bot in the server or pushed by the DRB server bot — either
// is enough; the server bot is optional (server-26#225). A pick becomes a
// radio channel pinned to this node (desired state — the node reconciles and
// reports back, shown per row).
// A voice channel can carry several radio streams, each its own bot playing
// its own system (server-26#213 follow-up): picking a channel that already
// plays another system ADDS this node's system beside it; only a stream of
@@ -211,8 +213,9 @@ function DiscordPicker({
<legend className="text-xs text-ink-muted mb-2">Discord server</legend>
{usable.length === 0 ? (
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
No Discord server is linked to your organization yet. Add the DRB bot to your Discord server and
link it to your organization; it shows up here once the bot reports its channels.
No Discord server is linked to your organization yet. The organization owner connects one under
Settings → Organization → <b className="text-ink">Connect a Discord server</b>: adding a radio bot
to the server links it, and its voice channels show up here.
</p>
) : (
<div className="space-y-1.5">
@@ -259,12 +262,13 @@ function DiscordPicker({
<legend className="text-xs text-ink-muted mb-2">Voice channel</legend>
{!guild.synced ? (
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
The DRB bot has not reported this server&apos;s channels yet. It does when it starts and every 15
minutes — check back shortly.
DRB hasn&apos;t read this server&apos;s voice channels yet. A radio bot in the server lists them
(or the DRB server bot, if it&apos;s there) — close this and open it again in a moment. If it
stays empty, no radio bot is in this server yet — invite one with the link under the server above.
</p>
) : guild.voice_channels.length === 0 ? (
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
This server has no voice channels the DRB bot can see.
This server has no voice channels DRB&apos;s bots can see.
</p>
) : (
<div className="space-y-2">
@@ -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", ""]}>
+8
View File
@@ -85,6 +85,14 @@ export interface DiscordGuild {
acting_org_id: string | null;
acting_org_name: string | null;
live_streams: number;
/** How it was linked: "admin" | "code" | "oauth" (absent on older links). */
linked_via?: string | null;
/** The DRB server bot (optional, server-26#225): true/false as it last reported, null = never heard. */
main_bot_present?: boolean | null;
/** Who listed its voice channels: "main_bot" | "radio_bot". */
directory_source?: string | null;
/** Radio bots Discord last said are in this server (names). */
radio_bots?: string[];
}
export interface DiscordAdminOverview {