- 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>
371 lines
15 KiB
TypeScript
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` };
|
|
}
|
|
}
|