Files
Logan CusanoandClaude Opus 5.5 32d223e556 S7: OAuth start/options accept system_id; Discord picker QA fixes (server-26#225, #256 #257 #258)
- 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>
2026-10-05 03:09:28 -04:00

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&apos;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&apos;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>
);
}