// 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. 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; 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 | 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) .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 : `. Pull the * detail, code and invite links back out of a JSON body. */ export function parseC2Error(err: unknown): ParsedC2Error { const raw = err instanceof Error ? err.message : String(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" | "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; /** 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", "bad_channel", "unchecked"]; function normBot(b: Record): 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, 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[]).filter(Boolean).map(normBot) : [], }; } export function normalizeVoiceCheck(raw: unknown): VoiceCheck { const r = (raw && typeof raw === "object" ? raw : {}) as Record; const channels: Record = {}; if (Array.isArray(r.channels)) { for (const c of r.channels as Record[]) { 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 } /** 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 "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 }; 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 Re-check.`; 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 Re-check.`; } /** "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}: its token was rejected by Discord — a platform admin 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` }; } }