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
@@ -10,17 +10,27 @@
|
||||
// plays another system ADDS this node's system beside it; only a stream of
|
||||
// this node's own system there is restarted / moved here.
|
||||
// Layout follows design-canvas/discord-system-mockup.html "Radio channels".
|
||||
// Each voice channel shows whether a radio bot can actually stream there
|
||||
// (server-26#225): Ready / Needs fix (which permission, what removed it, where
|
||||
// to fix it) / Couldn't check / Limit N. Start is disabled only on a definite
|
||||
// deny; an unanswered check fails open.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import {
|
||||
blocksStart,
|
||||
channelVerdict,
|
||||
normalizeVoiceCheck,
|
||||
parseC2Error,
|
||||
type DiscordGuildOption,
|
||||
type DiscordInvite,
|
||||
type DiscordJoinOptions,
|
||||
type DiscordStreamView,
|
||||
type ParsedC2Error,
|
||||
type StreamState,
|
||||
type VoiceCheck,
|
||||
} from "@/lib/discordPicker";
|
||||
import { BotDetails, CheckFooter, FixText, VerdictBadge } from "@/components/discord/VoiceCheckBits";
|
||||
|
||||
const POLL_MS = 10_000;
|
||||
|
||||
@@ -93,12 +103,53 @@ function DiscordPicker({
|
||||
const [channelId, setChannelId] = useState("");
|
||||
const [mode, setMode] = useState<"adhoc" | "persistent">("adhoc");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<{ message: string; invite: DiscordInvite[] } | null>(null);
|
||||
const [error, setError] = useState<ParsedC2Error | null>(null);
|
||||
// Permission check per server: the result, whether a load is in flight, and
|
||||
// why the last load failed (shown under the footer; the badges fail open).
|
||||
const [checks, setChecks] = useState<
|
||||
Record<string, { data: VoiceCheck | null; busy: boolean; error: string | null; limited: boolean }>
|
||||
>({});
|
||||
|
||||
const guild: DiscordGuildOption | undefined = usable.find((g) => g.guild_id === guildId);
|
||||
const channel = guild?.voice_channels.find((c) => c.channel_id === channelId);
|
||||
const atNodeCap = options.limits.node_used >= options.limits.node_max;
|
||||
const channelOnHere = channel?.stream?.node_id === nodeId;
|
||||
const check = guildId ? checks[guildId] : undefined;
|
||||
const checkOn = check?.data?.enabled !== false;
|
||||
const verdictOf = (c: DiscordGuildOption["voice_channels"][number]) => {
|
||||
const row = check?.data && checkOn ? check.data.channels[c.channel_id] : undefined;
|
||||
const v = channelVerdict(row);
|
||||
// This system already plays here: c2-core counts its bot as busy (it holds
|
||||
// the channel), so the channel can read "Needs fix"/"Bots busy" for a NEW
|
||||
// bot while a restart/move keeps the one that works. Judge by that bot.
|
||||
const own = c.stream?.token_name ? row?.bots.find((b) => b.name === c.stream?.token_name) : undefined;
|
||||
if (v && own?.ok === true && v.kind !== "ready") return { kind: "ready" as const, limit: v.limit };
|
||||
return v;
|
||||
};
|
||||
const chosenVerdict = channel ? verdictOf(channel) : null;
|
||||
const definiteDeny = blocksStart(chosenVerdict);
|
||||
const showInvite = chosenVerdict?.kind === "fix" || chosenVerdict?.kind === "no_bot" || chosenVerdict?.kind === "busy";
|
||||
|
||||
const runCheck = useCallback(
|
||||
async (gid: string, refresh: boolean) => {
|
||||
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: true, error: null, limited: false } }));
|
||||
try {
|
||||
const data = normalizeVoiceCheck(await c2api.getNodeVoiceCheck(nodeId, gid, refresh));
|
||||
setChecks((m) => ({ ...m, [gid]: { data, busy: false, error: null, limited: refresh && data.refreshed === false } }));
|
||||
} catch (err) {
|
||||
const e = parseC2Error(err);
|
||||
// 404 = route not deployed yet or server not visible: no badges (fail-open).
|
||||
const msg = e.status === 404 ? null : e.message;
|
||||
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: false, error: msg, limited: false } }));
|
||||
}
|
||||
},
|
||||
[nodeId],
|
||||
);
|
||||
|
||||
// First look at a server's channels: load its check (cached server-side).
|
||||
useEffect(() => {
|
||||
if (guild?.synced && !checks[guild.guild_id]) runCheck(guild.guild_id, false);
|
||||
}, [guild, checks, runCheck]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const out: { category: string | null; channels: DiscordGuildOption["voice_channels"] }[] = [];
|
||||
@@ -119,7 +170,11 @@ function DiscordPicker({
|
||||
onStarted();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(parseC2Error(err));
|
||||
const e = parseC2Error(err);
|
||||
setError(e);
|
||||
// The refusal may be newer than the badges (C2 drops its cache entry on
|
||||
// a permission refusal) — reload them so the row matches the error.
|
||||
if (e.code === "missing_permissions") runCheck(guild.guild_id, false);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -209,6 +264,7 @@ function DiscordPicker({
|
||||
{grp.channels.map((c) => {
|
||||
const here = c.stream?.node_id === nodeId;
|
||||
const others = (c.streams ?? []).filter((x) => x.stream_id !== c.stream?.stream_id);
|
||||
const verdict = verdictOf(c);
|
||||
return (
|
||||
<label
|
||||
key={c.channel_id}
|
||||
@@ -227,7 +283,11 @@ function DiscordPicker({
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="text-ink block truncate">🔊 {c.name}</span>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-ink truncate">🔊 {c.name}</span>
|
||||
{checkOn && <VerdictBadge verdict={verdict} loading={check?.busy} />}
|
||||
</span>
|
||||
{verdict && c.channel_id === channelId && <FixText verdict={verdict} channelName={c.name} />}
|
||||
{c.stream && (
|
||||
<span className="text-xs text-ink-muted block">
|
||||
{here
|
||||
@@ -241,6 +301,9 @@ function DiscordPicker({
|
||||
{!c.stream && ` — this adds ${options.system_name ?? "this node's system"} beside it with another bot`}
|
||||
</span>
|
||||
)}
|
||||
{c.channel_id === channelId && checkOn && (
|
||||
<BotDetails channel={check?.data?.channels[c.channel_id]} channelName={c.name} />
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
@@ -248,6 +311,15 @@ function DiscordPicker({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<CheckFooter
|
||||
checkedAt={check?.data?.checked_at ?? null}
|
||||
busy={!!check?.busy}
|
||||
error={check?.error ?? null}
|
||||
disabled={!checkOn}
|
||||
limited={!!check?.limited}
|
||||
invite={showInvite ? check?.data?.invite : undefined}
|
||||
onRecheck={() => runCheck(guild.guild_id, true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
@@ -299,10 +371,10 @@ function DiscordPicker({
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-sev-major">
|
||||
<div role="alert" className="text-xs text-sev-major whitespace-pre-line">
|
||||
{error.message}
|
||||
<InviteLinks invite={error.invite} />
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -310,7 +382,8 @@ function DiscordPicker({
|
||||
<button
|
||||
type="button"
|
||||
onClick={start}
|
||||
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere)}
|
||||
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || definiteDeny}
|
||||
title={definiteDeny ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined}
|
||||
className="flex-1 bg-accent hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-semibold"
|
||||
>
|
||||
{sending ? "Starting…" : channelOnHere ? "Update" : "Start radio"}
|
||||
|
||||
Reference in New Issue
Block a user