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:
co-authored by
Claude Opus 5.5
parent
f49dc86111
commit
9c124d4c8b
@@ -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'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's nodes, systems or bots. The DRB server bot is optional: a radio bot
|
||||
alone is enough to stream.
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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's channels yet. It does when it starts and every 15
|
||||
minutes — check back shortly.
|
||||
DRB hasn't read this server's voice channels yet. A radio bot in the server lists them
|
||||
(or the DRB server bot, if it'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'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'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'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.</>
|
||||
@@ -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'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", ""]}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user