- discord_oauth: POST /discord/oauth/start and GET /options take an optional system_id. The system must be the org's own or shared (another org's or unknown = 404). Its preferred bot is strict: one that can't connect is a 404, never a quiet swap to Automatic. No preference = the usual order. The picker's "bot for a system" now always sends system_id. - #257: _channel_state gives a bot whose token Discord rejected its own "invalid" state; the picker shows "Radio bot needs replacing" with no invite links, instead of "No radio bot". - #256: Start no longer stays disabled on "Needs fix". It re-checks with Discord first (refresh=1) and starts only if the fresh answer allows it. "Not a voice channel" still disables Start. - #258: own tooltip for "Not a voice channel"; "platform admin" -> "DRB support"; PreferredBotsCard says plainly that adding your own bot isn't self-serve (no support address exists yet, see #47), and a load failure gets a plain message + Try again instead of the raw C2 error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
7.0 KiB
TypeScript
149 lines
7.0 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);
|
|
// A server error's raw body ("C2 API error 500: ...") means nothing to an owner.
|
|
else setLoadError(e.status !== null && e.status >= 500 ? "DRB couldn't load them just now." : 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}{" "}
|
|
<button type="button" onClick={() => load()} className="text-accent hover:underline">
|
|
Try again
|
|
</button>
|
|
</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. Adding
|
|
your own bot isn't self-serve yet: DRB support adds it for you, so ask whoever set up your DRB account.
|
|
</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>
|
|
);
|
|
}
|