Files
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

373 lines
15 KiB
TypeScript

// Node-page Discord picker (server-26#213 item 3, #109 item 13).
// Shapes returned by c2-core app/routers/discord_web.py. No token strings —
// the server never sends them to the browser.
import { legacyErrorText } from "@/lib/c2error";
export type StreamState =
| "pending"
| "connecting"
| "connected"
| "error"
| "stopping"
| "stopped"
| "node_offline"
| "paused_locally";
export interface DiscordStreamView {
stream_id: string;
guild_id: string;
channel_id: string;
guild_name: string | null;
channel_name: string | null;
system_id?: string | null;
system_name: string | null;
/** The radio bot carrying it — tells apart several streams in one voice channel. */
token_name?: string | null;
mode: "persistent" | "adhoc" | null;
node_id: string | null;
node_name: string | null;
actual: { state: StreamState; error: string | null; code?: string | null; since?: string; at?: string } | null;
updated_at: string | null;
}
export interface DiscordInvite {
name: string;
invite_url: string;
}
export interface DiscordVoiceChannel {
channel_id: string;
name: string;
category: string | null;
/** This node's system's stream in the channel, if any — what a pick restarts or moves. */
stream: DiscordStreamView | null;
/** Every live stream in the channel (several systems can share it, each its own bot). */
streams?: DiscordStreamView[];
}
export interface DiscordGuildOption {
guild_id: string;
name: string | null;
linked: boolean;
synced: boolean;
synced_at: string | null;
/** Who listed the channels (server-26#225 S5): the DRB server bot, or a radio bot read over REST. */
directory_source?: "main_bot" | "radio_bot" | null;
/** The DRB server bot is in this server. Not required: a radio bot alone is enough. */
main_bot_present?: boolean;
voice_channels: DiscordVoiceChannel[];
streams_on: number;
bot: { ok: boolean; code: string | null; message: string | null; invite: DiscordInvite[] };
}
export interface DiscordJoinOptions {
node_id: string;
node_name: string | null;
node_online: boolean;
system_id: string | null;
system_name: string | null;
can_control: boolean;
/** Permanent removal: platform admin or the org owner only (operators can stop, not remove). */
can_remove?: boolean;
limits: { node_max: number; node_used: number; guild_max: number };
guilds: DiscordGuildOption[];
streams: DiscordStreamView[];
}
/** GET /tokens/org/preferred — the org's own systems and its own (bring-your-own) radio bots. */
export interface OrgPreferredBots {
org_id: string;
systems: {
system_id: string;
name: string;
preferred_token_id: string | null;
/** A platform admin pinned a DRB pool bot for this system. */
preferred_is_platform_bot: boolean;
preferred_name: string | null;
}[];
bots: {
token_id: string;
name: string;
valid: boolean | null;
server_count: number | null;
preferred_for_system_id: string | null;
invite_url: string | null;
}[];
}
export interface DiscordStartResult {
ok: boolean;
stream_id: string;
token_id: string | null;
delivered: boolean | null;
reused: boolean | null;
}
export interface ParsedC2Error {
message: string;
invite: DiscordInvite[];
/** start_stream's code: missing_permissions, preferred_busy, preferred_other_node, bad_channel, … */
code: string | null;
status: number | null;
}
function asStrings(v: unknown): string[] {
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
}
/** A bot's trace sentence: ``reason`` as a string, else the ``reasons`` map
* (label → sentence, discord_perms.check_voice) joined. */
function reasonOf(b: Record<string, unknown>): string | null {
if (typeof b.reason === "string" && b.reason) return b.reason;
const r = b.reasons;
if (r && typeof r === "object") {
const parts = Object.entries(r as Record<string, unknown>)
.filter(([, v]) => typeof v === "string" && v)
.map(([k, v]) => `${k}: ${v as string}`);
if (parts.length) return parts.join("; ");
}
return null;
}
/** c2api's request() throws `C2 API error <status>: <body>`. Pull the
* detail, code and invite links back out of a JSON body. */
export function parseC2Error(err: unknown): ParsedC2Error {
const raw = legacyErrorText(err);
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
const status = m ? Number(m[1]) : null;
const idx = raw.indexOf(": ");
if (idx >= 0) {
try {
const body = JSON.parse(raw.slice(idx + 2));
const detail = typeof body?.detail === "string" ? body.detail : null;
const invite = Array.isArray(body?.invite)
? (body.invite as DiscordInvite[]).filter((i) => i && typeof i.invite_url === "string")
: [];
// missing_permissions: detail already names each bot, the permission,
// what removed it and the fix (discord_streams._missing_permissions_error).
if (detail) return { message: detail, invite, code: typeof body?.code === "string" ? body.code : null, status };
} catch {
/* not JSON — fall through */
}
}
return { message: raw, invite: [], code: null, status };
}
// ---------------------------------------------------------------------------
// Voice-channel permission check (server-26#225 S3/S4) — the JSON contract of
// c2-core routers/discord_checks.py:
// GET /discord/nodes/{node}/guilds/{guild}/voice-check (node picker, org-scoped)
// GET /discord/guilds/{guild}/voice-check?channel_id= (admin Add form; rows carry token_id)
// Bot names only for non-admins — never token strings.
// ---------------------------------------------------------------------------
/** Per bot: ok | denied | unknown | not_member | invalid | bad_channel | unchecked. */
export type BotAccess = "ok" | "denied" | "unknown" | "not_member" | "invalid" | "bad_channel" | "unchecked";
/** Per channel, as c2-core sums it up over the bots that are free. */
export type ChannelState = "ready" | "needs_fix" | "unknown" | "busy" | "no_bot" | "invalid" | "bad_channel" | "unchecked";
export interface VoiceCheckBot {
/** Admin route only. */
token_id?: string | null;
name: string;
status: BotAccess;
/** true = can stream there, false = DEFINITE deny, null = anything else (unknown fails open). */
ok: boolean | null;
missing: string[];
/** What removed the first missing permission, e.g. "denied by #dispatch's override for @Muted". */
reason: string | null;
/** What to change in Discord (server-written). */
fix: string | null;
/** false = this bot can't take a new stream here right now. */
available: boolean | null;
unavailable_reason: "busy_here" | "other_node" | string | null;
}
export interface VoiceCheckChannel {
channel_id: string;
name: string | null;
state: ChannelState | null;
/** Advisory: REST can't see who is connected (0 = no limit). */
user_limit: number;
any_ok: boolean | null;
bots: VoiceCheckBot[];
}
export interface VoiceCheck {
guild_id: string | null;
checked_at: string | null;
/** false when DISCORD_PERM_CHECK_ENABLED is off server-side — no badges then. */
enabled: boolean;
/** false when a Re-check was asked for but rate limited (cached answer served). */
refreshed: boolean | null;
channels: Record<string, VoiceCheckChannel>;
/** Radio bots that could be invited to this server. */
invite: DiscordInvite[];
}
const BOT_ACCESS: readonly string[] = ["ok", "denied", "unknown", "not_member", "invalid", "bad_channel", "unchecked"];
const CHANNEL_STATES: readonly string[] = ["ready", "needs_fix", "unknown", "busy", "no_bot", "invalid", "bad_channel", "unchecked"];
function normBot(b: Record<string, unknown>): VoiceCheckBot {
const status = (typeof b.status === "string" && BOT_ACCESS.includes(b.status)
? b.status
: b.ok === true ? "ok" : "unknown") as BotAccess;
return {
token_id: typeof b.token_id === "string" ? b.token_id : null,
name: typeof b.name === "string" && b.name ? b.name : "Radio bot",
status,
ok: status === "ok" ? true : status === "denied" ? false : null,
missing: asStrings(b.missing),
reason: reasonOf(b),
fix: typeof b.fix === "string" && b.fix ? b.fix : null,
available: b.available === true ? true : b.available === false ? false : null,
unavailable_reason: typeof b.unavailable_reason === "string" ? b.unavailable_reason : null,
};
}
function normChannel(c: Record<string, unknown>, fallbackId: string): VoiceCheckChannel {
return {
channel_id: String(c.channel_id ?? fallbackId),
name: typeof c.name === "string" ? c.name : null,
state: typeof c.state === "string" && CHANNEL_STATES.includes(c.state) ? (c.state as ChannelState) : null,
user_limit: Number(c.user_limit) || 0,
any_ok: c.any_ok === true ? true : c.any_ok === false ? false : null,
bots: Array.isArray(c.bots) ? (c.bots as Record<string, unknown>[]).filter(Boolean).map(normBot) : [],
};
}
export function normalizeVoiceCheck(raw: unknown): VoiceCheck {
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
const channels: Record<string, VoiceCheckChannel> = {};
if (Array.isArray(r.channels)) {
for (const c of r.channels as Record<string, unknown>[]) {
if (c && c.channel_id != null) channels[String(c.channel_id)] = normChannel(c, String(c.channel_id));
}
}
return {
guild_id: r.guild_id != null ? String(r.guild_id) : null,
checked_at: typeof r.checked_at === "string" ? r.checked_at : null,
enabled: r.enabled !== false,
refreshed: r.refreshed === true ? true : r.refreshed === false ? false : null,
channels,
invite: Array.isArray(r.invite)
? (r.invite as DiscordInvite[]).filter((i) => i && typeof i.invite_url === "string")
: [],
};
}
export type ChannelVerdict =
/** A free radio bot can stream there. */
| { kind: "ready"; limit: number }
/** A bot could stream there, but every such bot is busy. */
| { kind: "busy"; limit: number }
/** Every free bot is DEFINITELY denied — Start is disabled. */
| { kind: "fix"; limit: number; bot: VoiceCheckBot }
/** Discord didn't answer for some bot — Start stays enabled (fail-open). */
| { kind: "unknown"; limit: number }
/** No radio bot is in this server. */
| { kind: "no_bot"; limit: number }
/** A radio bot IS in this server, but Discord rejected its token (server-26#257). No invite: it is already there. */
| { kind: "invalid"; limit: number }
/** The channel exists but is not a voice channel — Start is disabled. */
| { kind: "not_voice"; limit: number };
/** Derive the badge. c2-core's ``state`` wins; the bot rows are the fallback.
* null = no badge (check off, or no data for that channel). */
export function channelVerdict(c: VoiceCheckChannel | null | undefined): ChannelVerdict | null {
if (!c) return null;
const limit = c.user_limit;
const free = c.bots.filter((b) => b.available !== false);
const denied = free.filter((b) => b.ok === false);
const firstDenied = denied[0] ?? c.bots.find((b) => b.ok === false);
switch (c.state) {
case "ready": return { kind: "ready", limit };
case "busy": return { kind: "busy", limit };
case "unknown": return { kind: "unknown", limit };
case "no_bot": return { kind: "no_bot", limit };
case "invalid": return { kind: "invalid", limit };
case "bad_channel": return { kind: "not_voice", limit };
case "unchecked": return null;
case "needs_fix": return firstDenied ? { kind: "fix", limit, bot: firstDenied } : { kind: "unknown", limit };
}
// No state (older server): same rules as discord_checks._channel_state.
if (!c.bots.length) return { kind: "no_bot", limit };
if (free.some((b) => b.ok === true)) return { kind: "ready", limit };
if (free.some((b) => b.ok === null)) return { kind: "unknown", limit };
if (denied.length) return { kind: "fix", limit, bot: denied[0] };
if (c.bots.some((b) => b.ok === true)) return { kind: "busy", limit };
if (c.bots.some((b) => b.status === "invalid")) return { kind: "invalid", limit };
return { kind: "unknown", limit };
}
/** A definite refusal: Start would only get a 409. Everything else fails open. */
export function blocksStart(v: ChannelVerdict | null | undefined): boolean {
return v?.kind === "fix" || v?.kind === "not_voice";
}
/** "#dispatch" for a channel name, else "this channel". */
export function channelRef(name: string | null | undefined): string {
return name ? `#${name.replace(/^#/, "")}` : "this channel";
}
function joinLabels(labels: string[]): string {
if (labels.length <= 1) return labels.join("");
return `${labels.slice(0, -1).join(", ")} or ${labels[labels.length - 1]}`;
}
/** "Radio bot X can't Speak in #dispatch: denied by #dispatch's override for @Muted." */
export function deniedSentence(bot: { name: string; missing: string[]; reason: string | null }, channelName?: string | null): string {
const what = bot.missing.length ? joinLabels(bot.missing) : "join";
const why = bot.reason ? `: ${bot.reason}` : "";
return `${bot.name} can't ${what} in ${channelRef(channelName)}${why}.`;
}
/** Where in Discord to fix it — c2-core's own sentence when it sent one. */
export function fixSentence(bot: { name: string; missing: string[]; reason: string | null; fix?: string | null }): string {
if (bot.fix) return bot.fix;
if (bot.reason && /timed out/i.test(bot.reason)) return `In Discord: remove ${bot.name}'s timeout, then try again.`;
const perms = bot.missing.length ? bot.missing.join(" and ") : "View Channel, Connect and Speak";
return `In Discord: Edit Channel → Permissions → add ${bot.name} → allow ${perms}, then try again.`;
}
/** "40s ago" / "3 min ago" for the Re-check footer. */
export function agoText(iso: string | null | undefined, now = Date.now()): string | null {
if (!iso) return null;
const t = Date.parse(iso);
if (Number.isNaN(t)) return null;
const s = Math.max(0, Math.round((now - t) / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.round(s / 60);
return m < 60 ? `${m} min ago` : `${Math.round(m / 60)} h ago`;
}
/** One line per bot for the expandable details. */
export function botLine(b: VoiceCheckBot, channelName?: string | null): { tone: "ok" | "bad" | "warn" | "muted"; text: string } {
const busy =
b.unavailable_reason === "busy_here"
? "already in another voice channel in this server (one per server)"
: b.unavailable_reason === "other_node"
? "carrying radio on another node"
: "busy";
switch (b.status) {
case "ok":
return b.available === false
? { tone: "warn", text: `${b.name}: can stream here, but is ${busy}` }
: { tone: "ok", text: `${b.name}: ready` };
case "denied":
return { tone: "bad", text: deniedSentence(b, channelName) + (b.available === false ? ` (also ${busy})` : "") };
case "not_member":
return { tone: "warn", text: `${b.name}: Discord says it is not in this server — invite it again` };
case "invalid":
return { tone: "bad", text: `${b.name}: Discord rejected its token — DRB support has to replace it` };
case "bad_channel":
return { tone: "bad", text: `${b.name}: ${channelRef(channelName)} is not a voice channel` };
case "unchecked":
return { tone: "muted", text: `${b.name}: not checked (permission check is off)` };
default:
return { tone: "muted", text: `${b.name}: couldn't check${b.reason ? ` (${b.reason})` : ""} — DRB will still try it` };
}
}