Files
server-26/drb-frontend/components/discord/PreferredBotsCard.tsx
T
Logan CusanoandClaude Opus 5.5 1ee1560383 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>
2026-10-05 02:28:36 -04:00

143 lines
6.6 KiB
TypeScript

"use client";
// Settings → Organization → "Radio bot per system" (server-26#225, owner
// ruling Q2). The org owner picks which of the org's OWN radio bots plays each
// of the org's systems in Discord. C2 tries that bot first for every new
// radio channel of the system (discord_streams.start_stream); when it can't
// take the stream (busy in that server, no permission, not invited) another
// free bot is used. Shared systems and DRB pool bots stay platform-admin only,
// so they are not offered here. Backend: GET /tokens/org/preferred (list),
// PUT /tokens/{token_id}/prefer/{system_id} (set; "_none" clears).
import { useCallback, useEffect, useState } from "react";
import { c2api } from "@/lib/c2api";
import { parseC2Error, type OrgPreferredBots } from "@/lib/discordPicker";
import { Card, CardHeader } from "@/components/ui/Card";
const SELECT =
"bg-page border border-line rounded-lg px-2.5 py-1.5 text-ink text-sm focus:outline-none focus:border-accent disabled:opacity-50 max-w-full";
export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
const [data, setData] = useState<OrgPreferredBots | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState<string | null>(null);
const [rowError, setRowError] = useState<{ id: string; message: string } | null>(null);
// A platform admin with no org of their own gets a 403 here: nothing to show
// (shared systems are set on /systems), so the card hides instead of erroring.
const [hidden, setHidden] = useState(false);
const load = useCallback(async () => {
try {
setData(await c2api.getOrgPreferredBots());
setLoadError(null);
} catch (err) {
const e = parseC2Error(err);
if (e.status === 403) setHidden(true);
else setLoadError(e.message);
}
}, []);
useEffect(() => {
if (canEdit) load();
}, [canEdit, load]);
async function choose(systemId: string, tokenId: string, currentId: string | null) {
if (tokenId === (currentId ?? "")) return;
setSaving(systemId);
setRowError(null);
try {
if (tokenId) await c2api.setPreferredToken(tokenId, systemId);
else if (currentId) await c2api.setPreferredToken(currentId, "_none");
await load();
} catch (err) {
setRowError({ id: systemId, message: parseC2Error(err).message });
} finally {
setSaving(null);
}
}
if (!canEdit || hidden) return null;
const bots = data?.bots ?? [];
const botsById = new Map(bots.map((b) => [b.token_id, b]));
return (
<Card>
<CardHeader
title="Radio bot per system"
subtitle="Which of your radio bots plays each system in Discord. DRB tries it first for every new radio channel of that system; if it can't (busy in that server, missing a permission, or not invited), another free bot is used."
/>
{loadError ? (
<p role="alert" className="text-sm text-sev-major">Couldn&apos;t load radio bots: {loadError}</p>
) : !data ? (
<p className="text-sm text-ink-muted">Loading…</p>
) : data.systems.length === 0 ? (
<p className="text-sm text-ink-muted">Your organization has no radio systems yet.</p>
) : bots.length === 0 ? (
<p className="text-sm text-ink-muted">
Your organization has no radio bots of its own, so DRB picks a free DRB radio bot for each channel. To use
your own bot for a system, ask DRB support to add it to your organization.
</p>
) : (
<div className="divide-y divide-line">
{data.systems.map((s) => {
const current = s.preferred_token_id ? botsById.get(s.preferred_token_id) : undefined;
const err = rowError?.id === s.system_id ? rowError.message : null;
const usedElsewhere = (tokenId: string) => {
const other = botsById.get(tokenId)?.preferred_for_system_id;
return other && other !== s.system_id ? data.systems.find((x) => x.system_id === other)?.name ?? "another system" : null;
};
return (
<div key={s.system_id} className="py-3 first:pt-0 last:pb-0 flex flex-wrap items-start gap-x-4 gap-y-1.5">
<div className="min-w-0 flex-1">
<div className="text-sm text-ink font-medium truncate">{s.name}</div>
<div className="text-xs text-ink-muted">
{current
? `${current.name} is tried first.`
: s.preferred_is_platform_bot
? "A DRB radio bot chosen by DRB support is tried first. Pick one of yours to replace it."
: "No preference — any free radio bot."}
{current?.valid === false && " Discord rejected this bot's token — DRB support has to replace it."}
{current && current.server_count === 0 && " It isn't in any Discord server yet — invite it first."}
</div>
{current?.invite_url && (
<a
href={current.invite_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-accent hover:underline"
>
Invite {current.name} to a Discord server
</a>
)}
{err && <div role="alert" className="text-xs text-sev-major mt-1">{err}</div>}
</div>
<label className="flex items-center gap-2 text-xs text-ink-muted">
<span className="sr-only">Preferred radio bot for {s.name}</span>
<select
value={s.preferred_token_id ?? ""}
disabled={saving !== null}
onChange={(e) => choose(s.system_id, e.target.value, s.preferred_token_id)}
className={SELECT}
>
<option value="">{s.preferred_is_platform_bot ? "DRB support's choice" : "Any free radio bot"}</option>
{bots.map((b) => {
const elsewhere = usedElsewhere(b.token_id);
return (
<option key={b.token_id} value={b.token_id} disabled={b.valid === false}>
{b.name}
{b.valid === false ? " — token rejected" : elsewhere ? ` — moves it off ${elsewhere}` : ""}
</option>
);
})}
</select>
{saving === s.system_id && <span>Saving…</span>}
</label>
</div>
);
})}
</div>
)}
</Card>
);
}