// 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; 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 : `. 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" }); }