Files
server-26/drb-frontend/lib/discordPicker.ts
T
Logan CusanoandClaude Opus 5.5 32d223e556 S7: OAuth start/options accept system_id; Discord picker QA fixes (server-26#225, #256 #257 #258)
- discord_oauth: POST /discord/oauth/start and GET /options take an optional
  system_id. The system must be the org's own or shared (another org's or
  unknown = 404). Its preferred bot is strict: one that can't connect is a
  404, never a quiet swap to Automatic. No preference = the usual order.
  The picker's "bot for a system" now always sends system_id.
- #257: _channel_state gives a bot whose token Discord rejected its own
  "invalid" state; the picker shows "Radio bot needs replacing" with no
  invite links, instead of "No radio bot".
- #256: Start no longer stays disabled on "Needs fix". It re-checks with
  Discord first (refresh=1) and starts only if the fresh answer allows it.
  "Not a voice channel" still disables Start.
- #258: own tooltip for "Not a voice channel"; "platform admin" -> "DRB
  support"; PreferredBotsCard says plainly that adding your own bot isn't
  self-serve (no support address exists yet, see #47), and a load failure
  gets a plain message + Try again instead of the raw C2 error.

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

371 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.
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 = 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" | "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` };
}
}