Files
server-26/drb-frontend/lib/discord.ts
Logan CusanoandClaude Opus 5.5 f16f0a9f93 fix(frontend): readable API errors and no more silent action failures (#284, #286)
#286: request() now throws C2ApiError — message is FastAPI's `detail` or a
plain sentence for the status (401/403/404/429/5xx), with toString() so the
many String(e) call sites read cleanly. status/body stay on the error; the
Discord parsers rebuild the legacy "C2 API error N: body" text via
legacyErrorText(), so their invite/code parsing is unchanged.

#284: Resolve, Regenerate summary, alert-rule toggle/delete/load,
Acknowledge, trip deletes, system delete and Restart OP25 now surface
failures (ErrorBanner where the page has one, alert() where the page
already uses it). Rules tab no longer says "No alert rules configured"
when the load failed. Confirms added for Resolve (no reopen path exists),
rule delete and trip delete. Reopen deliberately not built — manual
resolve doesn't set resolved_at, so reopening is a lifecycle change.

Frontend unverified: no Node on the authoring box (#207).

Closes #286

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:38:13 -04:00

188 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).
import { legacyErrorText } from "@/lib/c2error";
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 = legacyErrorText(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" });
}