Files
server-26/drb-frontend/lib/discord.ts
T
Logan CusanoandClaude Opus 5.5 8af410aede Discord: several radio streams per voice channel; adding never overwrites (#213)
Owner-reported on live: adding a radio channel for a server + voice channel
that already had one (different label, system, even bot) overwrote the
existing stream and made its bot leave, because stream_id was guild:channel.

- New streams get guild:channel:<12 hex> ids (leases.new_stream_id); old
  guild:channel docs keep working for read/stop/remove/node reports. Route
  validators accept both (leases.STREAM_ID_PATTERN).
- start_stream: no stream_id = ADD. Same system already on in that channel
  is idempotent (already_on, label/mode untouched); a stopped one of that
  system is restarted; anything else is a new doc. stream_id = explicit
  restart/edit (admin Start button, reconciler), incl. moving to another bot.
- preferred_token_id is strict: bot already in this server -> 409
  preferred_busy naming the stream; on another node -> preferred_other_node;
  unusable -> preferred_unavailable. No silent swap. pool_exhausted now says
  one-bot-per-server and to add + invite another bot.
- One-token-per-guild was already enforced inside the lease transaction;
  now covered by race tests. Per-guild asyncio lock serialises the
  already-on / server-cap check with the create (guild half of #223).
- Stop by stream_id (POST /discord/guilds/{id}/stop {stream_id}); admin UI
  Stop/Start/Remove act on the row's stream; node picker lists every stream
  in a channel and adds beside other systems.
- Bot: /leave stream: (autocomplete) stops one; /leave channel: with several
  streams asks which unless all:True; bare /leave still stops the server.
  /status and /join name each stream's bot.

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

166 lines
5.8 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;
/** 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;
}
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" });
}