Files
server-26/drb-frontend/lib/discord.ts
T
Logan CusanoandClaude Opus 5.5 c92bdfe841 Discord: log node-reported stream state; remove a radio channel or a server
- handle_actual logs every node-reported state TRANSITION (not heartbeats)
  with stream/node/guild/channel/token ids and the node's error text: info
  for connected/connecting/stopped, warning for error, and a dedicated
  "radio bot X failed to log in on node N: <reason>" line for login_failed.
  The node's reason code is now stored in actual.code and shown next to the
  error on Settings > Discord and the node page.
- Remove a radio channel permanently: streams.delete_stream stops it through
  desired state first (node republished without it, token -> releasing per
  #216, freed on the node's ack), then deletes the doc in a transaction that
  refuses if the channel was restarted in between. DELETE
  /discord/streams/{id} (platform admin) and DELETE
  /discord/nodes/{node}/streams/{id} (admin or org owner, same org + node
  only; operators can still stop, not remove). Audited.
- Remove a server: DELETE /discord/guilds/{id} (admin) deletes every radio
  channel in it the same way, then tombstones discord_guilds (org_id None +
  unlinked_at, #218 semantics, plus removed_at) so it never falls back to
  DISCORD_UNLINKED_GUILD_ORG_ID; the admin page hides it, and /drb link or the
  admin link brings it back. Optional leave_radio_bots makes pool bots and the
  server's org's own BYO bots leave via Discord REST; the DRB server bot stays
  until kicked, and the UI says so. Audited.
- UI: Remove (with confirm) on Radio channels, Servers and the node page list.

Refs #213, #221

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:40:43 -04:00

164 lines
5.7 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;
}
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;
}
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;
}
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;
};
}
/** 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" });
}