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:
co-authored by
Claude Opus 5.5
parent
2c2cf9d9be
commit
1ee1560383
@@ -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" }),
|
||||
|
||||
|
||||
@@ -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` };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user