Discord picker: per-bot permission status, owner's preferred bot (server-26#225 S4)

Node-page picker and the admin Add form now show, per voice channel, whether
a radio bot can stream there, from S3's voice-check routes:
- Badge per channel: Ready / Needs fix (names the missing permission) /
  Couldn't check / Bots busy / No radio bot / Not a voice channel / Limit N.
- The chosen channel shows "<bot> can't Speak in #dispatch: <what removed it>"
  plus c2-core's fix sentence, and an expandable per-bot list.
- Footer: "Permissions checked 40s ago · Re-check" (refresh=1, rate limited
  server-side; a limited re-check says so). Invite links when no bot fits.
- Start is disabled only on a definite refusal (needs_fix, bad_channel);
  unknown fails open. A channel this system already plays in is judged by
  the bot that carries it, so Update/move is never blocked by the other bots.
- Admin Add form disables bots that are definitely denied in the bot list.
- 409 refusals (missing_permissions, preferred_busy, preferred_other_node,
  bad_channel) are shown verbatim, multi-line.

Org owners (owner decision Q2) get "Radio bot per system" on
Settings > Organization: their own systems, their own BYO bots, set/clear
through S3's PUT /tokens/{id}/prefer/{system}. New GET /tokens/org/preferred
lists only the caller org's systems and bots (no pool bots, no token
strings; a pool bot an admin chose is flagged, not named).

The prefer route now drops the 5-minute doc_get_cached entry for every
system it rewrites, so a new preference applies to the next start.

Stacked on feat/225-s3-perm-check: must merge after S3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 02:28:36 -04:00
co-authored by Claude Opus 5.5
parent 2c2cf9d9be
commit 1ee1560383
9 changed files with 938 additions and 20 deletions
+17 -1
View File
@@ -148,6 +148,21 @@ export const c2api = {
`/discord/nodes/${nodeId}/streams/${encodeURIComponent(streamId)}`,
{ method: "DELETE" },
),
/** Per-bot permission check for every voice channel in one server (server-26#225).
* refresh=true bypasses the cache (rate limited server-side: 1 per 10 s). Raw
* body — run it through discordPicker.normalizeVoiceCheck. */
getNodeVoiceCheck: (nodeId: string, guildId: string, refresh = false) =>
request<unknown>(
`/discord/nodes/${encodeURIComponent(nodeId)}/guilds/${encodeURIComponent(guildId)}/voice-check?refresh=${refresh ? 1 : 0}`,
),
/** Org owner: this org's systems + its own radio bots, for "preferred bot per system" (server-26#225 Q2). */
getOrgPreferredBots: () =>
request<import("@/lib/discordPicker").OrgPreferredBots>("/tokens/org/preferred"),
/** Admin: the same check for one channel (the Add form takes a raw channel ID). */
getGuildVoiceCheck: (guildId: string, channelId: string, refresh = false) =>
request<unknown>(
`/discord/guilds/${encodeURIComponent(guildId)}/voice-check?channel_id=${encodeURIComponent(channelId)}&refresh=${refresh ? 1 : 0}`,
),
deleteNode: (id: string) =>
request(`/nodes/${id}`, { method: "DELETE" }),
@@ -344,7 +359,8 @@ export const c2api = {
getReplayIncidents: (runId: string) =>
request<import("@/lib/types").ReplayIncidents>(`/admin/replay/${runId}/incidents`),
// Preferred bot token per system
// Preferred bot token per system. Platform admin: any bot, any system. Org
// owner: their own org's bots for their own org's systems (server-26#225 Q2).
setPreferredToken: (tokenId: string, systemId: string) =>
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),
+268 -4
View File
@@ -69,6 +69,27 @@ export interface DiscordJoinOptions {
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;
@@ -77,10 +98,38 @@ export interface DiscordStartResult {
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 + invite links back out of a JSON body when there is one. */
export function parseC2Error(err: unknown): { message: string; invite: DiscordInvite[] } {
* 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 {
@@ -89,10 +138,225 @@ export function parseC2Error(err: unknown): { message: string; invite: DiscordIn
const invite = Array.isArray(body?.invite)
? (body.invite as DiscordInvite[]).filter((i) => i && typeof i.invite_url === "string")
: [];
if (detail) return { message: detail, invite };
// 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: [] };
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<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", "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 }
/** 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` };
}
}