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
@@ -0,0 +1,142 @@
|
||||
"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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user