Files
server-26/drb-frontend/lib/discord.ts
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

186 lines
6.9 KiB
TypeScript

// Discord radio channels + radio-bot pool — shapes returned by c2-core
// (routers/discord.py GET /discord/admin/overview, routers/tokens.py), and
// small helpers shared by /settings/discord and /tokens (server-26#213).
export interface RadioBotLease {
node_id: string;
lease_id?: string;
since?: string;
streams?: string[];
releasing?: boolean;
}
/** One bot_tokens doc as GET /tokens returns it — token string masked server-side. */
export interface RadioBot {
token_id: string;
name: string;
token: string;
org_id?: string | null; // null = platform pool, else that org's BYO bot
valid?: boolean | null; // null/undefined = never checked (Discord unreachable)
guild_ids?: string[] | null; // null = membership unknown
check_error?: string | null;
validated_at?: string | null;
application_id?: string | null;
bot_name?: string | null;
invite_url?: string | null;
lease?: RadioBotLease | null;
in_use?: boolean;
assigned_node_id?: string | null;
preferred_for_system_id?: string | null;
/** "Connect a Discord server" is on for this bot: its OAuth client secret is
* set server-side (server-26#225 S6). The secret itself is never sent. */
oauth_enabled?: boolean | null;
/** GET /tokens (S6): the pool cap for a DRB pool bot, null for a BYO bot. */
server_cap?: number | null;
}
export interface RadioBotAddResult {
token_id: string;
name: string;
valid: boolean | null;
guild_ids: string[] | null;
check_error: string | null;
invite_url: string | null;
}
export interface DiscordStreamActual {
state?: string;
error?: string | null;
/** The node's reason code on error: login_failed, connect_failed, not_in_guild, bad_channel, no_audio. */
code?: string | null;
since?: string | null;
at?: string | null;
}
export interface DiscordStream {
stream_id: string;
guild_id: string;
channel_id: string;
channel_name?: string | null;
guild_name?: string | null;
org_id?: string | null;
system_id?: string;
system_name?: string | null;
mode?: "persistent" | "adhoc";
desired?: "on" | "off";
node_id?: string | null;
node_name?: string | null;
node_status?: string | null;
token_id?: string | null;
token_name?: string | null;
actual?: DiscordStreamActual | null;
stop_reason?: string | null;
updated_at?: string | null;
created_at?: string | null;
/** POST /discord/streams: this system was already on in that channel — nothing was replaced. */
already_on?: boolean;
}
export interface DiscordGuild {
guild_id: string;
guild_name: string | null;
linked: boolean;
org_id: string | null;
linked_at: string | null;
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 {
guilds: DiscordGuild[];
streams: DiscordStream[];
orgs: { org_id: string; name: string }[];
node_names: Record<string, string>;
systems: { system_id: string; name: string | null; org_id: string | null }[];
limits: {
guild_max_streams: number;
node_max_streams: number;
unlinked_guild_org_id: string | null;
unlinked_guild_org_name: string | null;
/** Connect stops picking a DRB pool bot at this many servers (owner ruling Q3). */
pool_bot_guild_cap?: number;
};
}
/** Discord's hard limit for an unverified bot. */
export const DISCORD_UNVERIFIED_GUILD_LIMIT = 100;
/** settings.discord_pool_bot_guild_cap's default — used when the overview doesn't send it. */
export const POOL_BOT_GUILD_CAP = 90;
/** A desired-on channel that has not reached "connected" for this long turns red. */
export const STUCK_AFTER_MS = 2 * 60 * 1000;
export type Health = "ok" | "warn" | "bad" | "off";
export function streamHealth(s: DiscordStream, now = Date.now()): { health: Health; label: string } {
const state = s.actual?.state ?? "pending";
if (s.desired !== "on") {
return { health: "off", label: state === "stopping" ? "off → stopping" : "off" };
}
if (state === "connected") return { health: "ok", label: "on → connected" };
if (state === "error" || state === "node_offline") return { health: "bad", label: `on → ${state.replace("_", " ")}` };
const since = s.actual?.since ? Date.parse(s.actual.since) : NaN;
if (!Number.isNaN(since) && now - since > STUCK_AFTER_MS) {
const mins = Math.round((now - since) / 60000);
return { health: "bad", label: `on → ${state} for ${mins} min` };
}
return { health: "warn", label: `on → ${state.replace("_", " ")}` };
}
/** The pool bot's invite link pinned to one server (same params c2-core's invite_url(app, guild) adds). */
export function inviteForGuild(inviteUrl: string | null | undefined, guildId: string): string | null {
if (!inviteUrl) return null;
return `${inviteUrl}&guild_id=${encodeURIComponent(guildId)}&disable_guild_select=true`;
}
export function guildLabel(g: { guild_id: string; guild_name?: string | null }): string {
return g.guild_name || `server ${shortId(g.guild_id)}`;
}
/** 1152…4402 — enough to tell snowflakes apart without a 19-digit column. */
export function shortId(id: string | null | undefined): string {
if (!id) return "—";
return id.length > 10 ? `${id.slice(0, 4)}…${id.slice(-4)}` : id;
}
export const SNOWFLAKE = /^\d{15,21}$/;
/**
* c2api's request() throws `C2 API error <status>: <body>`. Discord routes
* answer with `{detail, code, invite: [{name, invite_url}]}` — pull those out
* so "no bot in this server" can render its invite links.
*/
export function parseC2Error(err: unknown): { message: string; invite: { name: string; invite_url: string | null }[] } {
const raw = err instanceof Error ? err.message : String(err);
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
if (m) {
try {
const body = JSON.parse(m[2]);
const detail = typeof body?.detail === "string" ? body.detail : Array.isArray(body?.detail) ? body.detail.map((d: { msg?: string }) => d.msg).join("; ") : null;
return { message: detail || raw, invite: Array.isArray(body?.invite) ? body.invite : [] };
} catch {
return { message: m[2] || raw, invite: [] };
}
}
return { message: raw, invite: [] };
}
export function fmtWhen(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
const sameDay = d.toDateString() === new Date().toDateString();
return sameDay
? d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
: d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}