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>
This commit is contained in:
Logan Cusano
2026-10-05 03:09:28 -04:00
co-authored by Claude Opus 5.5
parent 13a3525e4c
commit 32d223e556
11 changed files with 191 additions and 44 deletions
@@ -84,6 +84,8 @@ def _channel_state(rows: list[dict], enabled: bool) -> str:
return "needs_fix" # a start is refused with missing_permissions return "needs_fix" # a start is refused with missing_permissions
if any(r["ok"] for r in rows): if any(r["ok"] for r in rows):
return "busy" return "busy"
if any(r["status"] == discord_rest.INVALID for r in rows):
return "invalid" # the bot IS here, but Discord rejected its token (#257)
return "no_bot" return "no_bot"
+28 -7
View File
@@ -14,7 +14,9 @@ Every route is a 404 while ``discord_oauth_redirect_uri`` is empty or no
eligible bot has an OAuth secret (PUT /tokens/{id}/oauth). eligible bot has an OAuth secret (PUT /tokens/{id}/oauth).
Which bot (``_pick_bot``): an explicit ``token_id`` the org may use; else the Which bot (``_pick_bot``): an explicit ``token_id`` the org may use; else the
radio bot preferred for the node's system (``node_id``); else the org's own radio bot preferred for ``system_id`` (the org's own system or a shared one;
strict: a preferred bot that can't connect is a 404, not a quiet swap); else
the radio bot preferred for the node's system (``node_id``); else the org's own
bring-your-own bot; else the pool bot with the fewest servers that is under bring-your-own bot; else the pool bot with the fewest servers that is under
``discord_pool_bot_guild_cap`` (owner decision Q3). Only bots with an OAuth ``discord_pool_bot_guild_cap`` (owner decision Q3). Only bots with an OAuth
secret set count. A bot the caller's org can't use is a 404, never a 403. secret set count. A bot the caller's org can't use is a 404, never a 403.
@@ -30,7 +32,7 @@ application's credentials, after the org-scope checks and rate limits here.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from typing import Optional from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -81,7 +83,7 @@ def _usable(tok: dict, org_id: str, oauth_ids: set) -> bool:
async def _pick_bot(org_id: str, *, node_id: Optional[str] = None, async def _pick_bot(org_id: str, *, node_id: Optional[str] = None,
token_id: Optional[str] = None) -> tuple[dict, str]: token_id: Optional[str] = None, system_id: Optional[str] = None) -> tuple[dict, str]:
"""(token doc, why) — why is ``chosen`` | ``preferred`` | ``org_bot`` | """(token doc, why) — why is ``chosen`` | ``preferred`` | ``org_bot`` |
``pool``. Raises HTTPException 404 when nothing fits.""" ``pool``. Raises HTTPException 404 when nothing fits."""
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")} tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
@@ -96,6 +98,21 @@ async def _pick_bot(org_id: str, *, node_id: Optional[str] = None,
"right now.") "right now.")
return tok, "chosen" return tok, "chosen"
if system_id:
# Same visibility as a node's system: the org's own or a shared one
# (no org_id). Another org's system is a 404, never a 403.
system = await fstore.doc_get("systems", system_id)
if not system or system.get("org_id") not in (None, org_id):
raise HTTPException(404, "System not found.")
pref_id = system.get("preferred_token_id") or ""
if pref_id:
# The caller asked for THIS system's bot by name: inviting a
# different one instead would be the silent swap this exists to stop.
pref = tokens.get(pref_id)
if not pref or not _usable(pref, org_id, oauth_ids):
raise HTTPException(404, "That system's radio bot can't be used to connect a server right now.")
return pref, "preferred"
if node_id: if node_id:
node = await fstore.doc_get("nodes", node_id) node = await fstore.doc_get("nodes", node_id)
if not node or node.get("org_id") != org_id: if not node or node.get("org_id") != org_id:
@@ -133,17 +150,19 @@ def _bot_summary(tok: dict, why: str) -> dict:
async def oauth_options(node_id: Optional[str] = Query(None, pattern=_ID), async def oauth_options(node_id: Optional[str] = Query(None, pattern=_ID),
token_id: Optional[str] = Query(None, pattern=_ID), token_id: Optional[str] = Query(None, pattern=_ID),
org_id: Optional[str] = None, org_id: Optional[str] = None,
decoded: dict = Depends(require_org_owner_token)): decoded: dict = Depends(require_org_owner_token),
system_id: Annotated[Optional[str], Query(pattern=_ID)] = None):
"""Whether Connect is offered here, and which bot it would invite.""" """Whether Connect is offered here, and which bot it would invite."""
_require_enabled() _require_enabled()
scope = resolve_org_scope(decoded, org_id) scope = resolve_org_scope(decoded, org_id)
tok, why = await _pick_bot(scope, node_id=node_id, token_id=token_id) tok, why = await _pick_bot(scope, node_id=node_id, token_id=token_id, system_id=system_id)
return {"available": True, "bot": _bot_summary(tok, why)} return {"available": True, "bot": _bot_summary(tok, why)}
class StartBody(BaseModel): class StartBody(BaseModel):
node_id: Optional[str] = Field(None, pattern=_ID) node_id: Optional[str] = Field(None, pattern=_ID)
token_id: Optional[str] = Field(None, pattern=_ID) token_id: Optional[str] = Field(None, pattern=_ID)
system_id: Optional[str] = Field(None, pattern=_ID)
return_to: Optional[str] = None return_to: Optional[str] = None
@@ -154,12 +173,14 @@ async def oauth_start(body: StartBody, org_id: Optional[str] = None,
scope = resolve_org_scope(decoded, org_id) scope = resolve_org_scope(decoded, org_id)
uid, email = describe_actor(decoded) uid, email = describe_actor(decoded)
start_limiter.check(f"{uid}:discord_oauth_start") start_limiter.check(f"{uid}:discord_oauth_start")
tok, why = await _pick_bot(scope, node_id=body.node_id, token_id=body.token_id) tok, why = await _pick_bot(scope, node_id=body.node_id, token_id=body.token_id,
system_id=body.system_id)
return_to = oauth.safe_return_to(body.return_to) return_to = oauth.safe_return_to(body.return_to)
nonce, expires_at = await oauth.create_state(org_id=scope, uid=uid, token_id=tok["token_id"], nonce, expires_at = await oauth.create_state(org_id=scope, uid=uid, token_id=tok["token_id"],
node_id=body.node_id, return_to=return_to) node_id=body.node_id, return_to=return_to)
await write_audit(uid, email, "discord.oauth.start", details={ await write_audit(uid, email, "discord.oauth.start", details={
"org_id": scope, "token_id": tok["token_id"], "reason": why, "node_id": body.node_id}) "org_id": scope, "token_id": tok["token_id"], "reason": why, "node_id": body.node_id,
"system_id": body.system_id})
return {"authorize_url": oauth.authorize_url(str(tok["application_id"]), nonce), return {"authorize_url": oauth.authorize_url(str(tok["application_id"]), nonce),
"expires_at": expires_at.isoformat(), "bot": _bot_summary(tok, why)} "expires_at": expires_at.isoformat(), "bot": _bot_summary(tok, why)}
+52
View File
@@ -187,6 +187,58 @@ async def test_preferred_bot_for_the_nodes_system_wins(fs):
assert e.value.status_code == 404 assert e.value.status_code == 404
async def test_system_id_invites_that_systems_preferred_bot(fs):
"""The S7 picker's "bot for a system" choice: a platform-pinned pool bot
is invited by system_id, never quietly swapped for Automatic."""
fs.put("systems", "s1", {"system_id": "s1", "org_id": "org-a", "preferred_token_id": "pool-1"})
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id="s1")
assert out["bot"] == {"name": "DRB Radio 1", "reason": "preferred", "own_bot": False}
out, q = await _start(system_id="s1", return_to="/settings")
assert q["client_id"] == "201" and out["bot"]["reason"] == "preferred"
assert _audit(fs, "discord.oauth.start")[-1]["details"]["system_id"] == "s1"
# a shared system (no org_id) serves every org, as on the node path
fs.put("systems", "shared", {"system_id": "shared", "org_id": None, "preferred_token_id": "pool-1"})
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_B,
system_id="shared")
assert out["bot"]["name"] == "DRB Radio 1"
# system_id outranks node_id
fs.put("nodes", "n1", {"node_id": "n1", "org_id": "org-a", "assigned_system_id": "s2"})
fs.put("systems", "s2", {"system_id": "s2", "org_id": "org-a", "preferred_token_id": "own-a"})
out = await router.oauth_options(node_id="n1", token_id=None, org_id=None, decoded=OWNER_A, system_id="s1")
assert out["bot"]["name"] == "DRB Radio 1"
async def test_system_id_of_another_org_or_unknown_is_404(fs):
fs.put("systems", "sb", {"system_id": "sb", "org_id": "org-b", "preferred_token_id": "pool-1"})
for sid in ("sb", "nope"):
with pytest.raises(HTTPException) as e:
await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id=sid)
assert e.value.status_code == 404 and e.value.detail == "System not found."
with pytest.raises(HTTPException) as e:
await _start(system_id=sid)
assert e.value.status_code == 404
assert not _audit(fs, "discord.oauth.start")
# a platform admin acting for org-b may use it
out = await router.oauth_options(node_id=None, token_id=None, org_id="org-b", decoded=ADMIN, system_id="sb")
assert out["bot"]["name"] == "DRB Radio 1"
async def test_system_id_whose_bot_cannot_connect_is_404_not_a_swap(fs):
# pool-2 is at the cap; another org's bot pinned (bad data); no secret
fs.put("systems", "s1", {"system_id": "s1", "org_id": "org-a", "preferred_token_id": "pool-2"})
fs.put("systems", "s2", {"system_id": "s2", "org_id": "org-a", "preferred_token_id": "own-b"})
fs.put("systems", "s3", {"system_id": "s3", "org_id": "org-a", "preferred_token_id": "pool-1"})
fs.data[oauth.OAUTH].pop("pool-1")
for sid in ("s1", "s2", "s3"):
with pytest.raises(HTTPException) as e:
await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id=sid)
assert e.value.status_code == 404 and "Harbor" not in str(e.value.detail)
# no preferred bot at all: the usual order (org's own bot first)
fs.put("systems", "s4", {"system_id": "s4", "org_id": "org-a"})
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id="s4")
assert out["bot"]["reason"] == "org_bot"
async def test_explicit_bot_must_be_usable_by_the_org(fs): async def test_explicit_bot_must_be_usable_by_the_org(fs):
out = await router.oauth_options(node_id=None, token_id="pool-1", org_id=None, decoded=OWNER_A) out = await router.oauth_options(node_id=None, token_id="pool-1", org_id=None, decoded=OWNER_A)
assert out["bot"]["reason"] == "chosen" assert out["bot"]["reason"] == "chosen"
+20 -1
View File
@@ -84,7 +84,7 @@ def fs(monkeypatch):
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "") monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
monkeypatch.setattr(settings, "discord_perm_check_enabled", True) monkeypatch.setattr(settings, "discord_perm_check_enabled", True)
# Per-token snapshot behaviour: "ok" | "muted" | "unknown" | "slow" | "not_member" # Per-token snapshot behaviour: "ok" | "muted" | "unknown" | "slow" | "not_member" | "invalid"
fake.snap_mode = {} fake.snap_mode = {}
fake.snap_calls = [] fake.snap_calls = []
@@ -99,6 +99,8 @@ def fs(monkeypatch):
return _snap(tid, status=discord_rest.UNKNOWN) return _snap(tid, status=discord_rest.UNKNOWN)
if mode == "not_member": if mode == "not_member":
return _snap(tid, status=discord_rest.NOT_MEMBER) return _snap(tid, status=discord_rest.NOT_MEMBER)
if mode == "invalid":
return _snap(tid, status=discord_rest.INVALID)
return _snap(tid, muted=(mode == "muted")) return _snap(tid, muted=(mode == "muted"))
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot) monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
@@ -413,6 +415,23 @@ def test_voice_check_needs_fix_when_every_free_bot_is_denied(fs):
assert out["channels"][0]["state"] == "unknown" # can't tell -> start still allowed assert out["channels"][0]["state"] == "unknown" # can't tell -> start still allowed
def test_voice_check_invalid_token_is_not_no_bot(fs):
# server-26#257: the bot is in the server but Discord rejected its token.
# "No radio bot" (and an invite to a bot already there) would be wrong.
_token(fs, "t1")
fs.snap_mode["t1"] = "invalid"
out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id="1001", decoded=OWNER))
assert out["channels"][0]["state"] == "invalid"
assert out["channels"][0]["bots"][0]["status"] == "invalid"
# a working bot elsewhere in the list still wins
_token(fs, "t2")
discord_rest.invalidate()
out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id="1001", decoded=OWNER))
assert out["channels"][0]["state"] == "ready"
# no bot at all is still no_bot
assert checks._channel_state([], True) == "no_bot"
def test_voice_check_is_org_scoped(fs): def test_voice_check_is_org_scoped(fs):
_token(fs, "t1") _token(fs, "t1")
for node, guild, who in (("n1", "200", OWNER), ("nx", GUILD, OWNER), ("n1", GUILD, STRANGER)): for node, guild, who in (("n1", "200", OWNER), ("nx", GUILD, OWNER), ("n1", GUILD, STRANGER)):
+5 -10
View File
@@ -28,17 +28,12 @@ const SELECT =
// Option values for the bot picker: "" = let C2 choose, "tok:<id>" = one of // Option values for the bot picker: "" = let C2 choose, "tok:<id>" = one of
// the org's own bots, "sys:<id>" = whichever bot is preferred for that system. // the org's own bots, "sys:<id>" = whichever bot is preferred for that system.
function startRequest(choice: string, data: OrgPreferredBots | null): OAuthStartRequest { function startRequest(choice: string): OAuthStartRequest {
const req: OAuthStartRequest = { return_to: "/settings/organization" }; const req: OAuthStartRequest = { return_to: "/settings/organization" };
if (choice.startsWith("tok:")) req.token_id = choice.slice(4); if (choice.startsWith("tok:")) req.token_id = choice.slice(4);
else if (choice.startsWith("sys:")) { // c2-core resolves the system's preferred bot itself, including a pool bot
const id = choice.slice(4); // a platform admin pinned (whose token_id the org never sees).
// The org's own bot preferred for this system: name it by token_id, which else if (choice.startsWith("sys:")) req.system_id = choice.slice(4);
// S6 already accepts. Only a platform-pinned pool bot needs system_id.
const pref = data?.systems.find((s) => s.system_id === id)?.preferred_token_id;
if (pref) req.token_id = pref;
else req.system_id = id;
}
return req; return req;
} }
@@ -132,7 +127,7 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
setActionError(null); setActionError(null);
setJustLinked(null); setJustLinked(null);
try { try {
const { url } = await startDiscordOAuth(startRequest(choice, choices)); const { url } = await startDiscordOAuth(startRequest(choice));
window.location.assign(url); window.location.assign(url);
// Leave the button in its busy state: the page is navigating to Discord. // Leave the button in its busy state: the page is navigating to Discord.
} catch (err) { } catch (err) {
+25 -6
View File
@@ -116,8 +116,8 @@ function DiscordPicker({
const channelOnHere = channel?.stream?.node_id === nodeId; const channelOnHere = channel?.stream?.node_id === nodeId;
const check = guildId ? checks[guildId] : undefined; const check = guildId ? checks[guildId] : undefined;
const checkOn = check?.data?.enabled !== false; const checkOn = check?.data?.enabled !== false;
const verdictOf = (c: DiscordGuildOption["voice_channels"][number]) => { const verdictOf = (c: DiscordGuildOption["voice_channels"][number], data: VoiceCheck | null | undefined = check?.data) => {
const row = check?.data && checkOn ? check.data.channels[c.channel_id] : undefined; const row = data && data.enabled !== false ? data.channels[c.channel_id] : undefined;
const v = channelVerdict(row); const v = channelVerdict(row);
// This system already plays here: c2-core counts its bot as busy (it holds // 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 // the channel), so the channel can read "Needs fix"/"Bots busy" for a NEW
@@ -128,19 +128,26 @@ function DiscordPicker({
}; };
const chosenVerdict = channel ? verdictOf(channel) : null; const chosenVerdict = channel ? verdictOf(channel) : null;
const definiteDeny = blocksStart(chosenVerdict); const definiteDeny = blocksStart(chosenVerdict);
// "Not a voice channel" can't change, so Start stays off. "Needs fix" can:
// Start re-checks with Discord first (server-26#256), so fixing it in
// Discord and pressing Start again just works — no hunting for Re-check.
const startOff = chosenVerdict?.kind === "not_voice";
// Not for "invalid": that bot is already in the server; inviting won't help (#257).
const showInvite = chosenVerdict?.kind === "fix" || chosenVerdict?.kind === "no_bot" || chosenVerdict?.kind === "busy"; const showInvite = chosenVerdict?.kind === "fix" || chosenVerdict?.kind === "no_bot" || chosenVerdict?.kind === "busy";
const runCheck = useCallback( const runCheck = useCallback(
async (gid: string, refresh: boolean) => { async (gid: string, refresh: boolean): Promise<VoiceCheck | null> => {
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: true, error: null, limited: false } })); setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: true, error: null, limited: false } }));
try { try {
const data = normalizeVoiceCheck(await c2api.getNodeVoiceCheck(nodeId, gid, refresh)); const data = normalizeVoiceCheck(await c2api.getNodeVoiceCheck(nodeId, gid, refresh));
setChecks((m) => ({ ...m, [gid]: { data, busy: false, error: null, limited: refresh && data.refreshed === false } })); setChecks((m) => ({ ...m, [gid]: { data, busy: false, error: null, limited: refresh && data.refreshed === false } }));
return data;
} catch (err) { } catch (err) {
const e = parseC2Error(err); const e = parseC2Error(err);
// 404 = route not deployed yet or server not visible: no badges (fail-open). // 404 = route not deployed yet or server not visible: no badges (fail-open).
const msg = e.status === 404 ? null : e.message; const msg = e.status === 404 ? null : e.message;
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: false, error: msg, limited: false } })); setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: false, error: msg, limited: false } }));
return null;
} }
}, },
[nodeId], [nodeId],
@@ -166,6 +173,12 @@ function DiscordPicker({
setSending(true); setSending(true);
setError(null); setError(null);
try { try {
if (definiteDeny) {
// Fresh answer from Discord; still refused = stay here, the row shows why.
// A failed check fails open, like everywhere else in the picker.
const fresh = await runCheck(guild.guild_id, true);
if (fresh && blocksStart(verdictOf(channel, fresh))) return;
}
await c2api.startNodeDiscordStream(nodeId, { guild_id: guild.guild_id, channel_id: channel.channel_id, mode }); await c2api.startNodeDiscordStream(nodeId, { guild_id: guild.guild_id, channel_id: channel.channel_id, mode });
onStarted(); onStarted();
onClose(); onClose();
@@ -382,11 +395,17 @@ function DiscordPicker({
<button <button
type="button" type="button"
onClick={start} onClick={start}
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || definiteDeny} disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || startOff}
title={definiteDeny ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined} title={
startOff
? "That is not a voice channel. Pick a voice channel."
: definiteDeny
? "Fixed the permission in Discord? Start checks again first."
: 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" 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"} {sending ? (definiteDeny ? "Checking…" : "Starting…") : channelOnHere ? "Update" : "Start radio"}
</button> </button>
<button <button
type="button" type="button"
@@ -33,7 +33,8 @@ export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
} catch (err) { } catch (err) {
const e = parseC2Error(err); const e = parseC2Error(err);
if (e.status === 403) setHidden(true); if (e.status === 403) setHidden(true);
else setLoadError(e.message); // 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);
} }
}, []); }, []);
@@ -67,15 +68,20 @@ export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
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." 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 ? ( {loadError ? (
<p role="alert" className="text-sm text-sev-major">Couldn&apos;t load radio bots: {loadError}</p> <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 ? ( ) : !data ? (
<p className="text-sm text-ink-muted">Loading…</p> <p className="text-sm text-ink-muted">Loading…</p>
) : data.systems.length === 0 ? ( ) : data.systems.length === 0 ? (
<p className="text-sm text-ink-muted">Your organization has no radio systems yet.</p> <p className="text-sm text-ink-muted">Your organization has no radio systems yet.</p>
) : bots.length === 0 ? ( ) : bots.length === 0 ? (
<p className="text-sm text-ink-muted"> <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 organization has no radio bots of its own, so DRB picks a free DRB radio bot for each channel. Adding
your own bot for a system, ask DRB support to add it to your organization. your own bot isn&apos;t self-serve yet: DRB support adds it for you, so ask whoever set up your DRB account.
</p> </p>
) : ( ) : (
<div className="divide-y divide-line"> <div className="divide-y divide-line">
@@ -20,6 +20,7 @@ import {
type ParsedC2Error, type ParsedC2Error,
type VoiceCheck, type VoiceCheck,
type VoiceCheckBot, type VoiceCheckBot,
type VoiceCheckChannel,
} from "@/lib/discordPicker"; } from "@/lib/discordPicker";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui"; import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
@@ -70,17 +71,19 @@ function AddChannelForm({
const cid = channelId.trim(); const cid = channelId.trim();
const checkKey = SNOWFLAKE.test(cid) && guildId ? `${guildId}:${cid}` : ""; const checkKey = SNOWFLAKE.test(cid) && guildId ? `${guildId}:${cid}` : "";
const runCheck = useCallback(async (key: string, refresh: boolean) => { const runCheck = useCallback(async (key: string, refresh: boolean): Promise<VoiceCheck | null> => {
const [gid, ch] = key.split(":"); const [gid, ch] = key.split(":");
setCheck((c) => ({ key, data: c?.key === key ? c.data : null, busy: true, error: null, limited: false })); setCheck((c) => ({ key, data: c?.key === key ? c.data : null, busy: true, error: null, limited: false }));
try { try {
const data = normalizeVoiceCheck(await c2api.getGuildVoiceCheck(gid, ch, refresh)); const data = normalizeVoiceCheck(await c2api.getGuildVoiceCheck(gid, ch, refresh));
setCheck((c) => (c?.key === key ? { key, data, busy: false, error: null, limited: refresh && data.refreshed === false } : c)); setCheck((c) => (c?.key === key ? { key, data, busy: false, error: null, limited: refresh && data.refreshed === false } : c));
return data;
} catch (err) { } catch (err) {
const e = parseStartError(err); const e = parseStartError(err);
setCheck((c) => setCheck((c) =>
c?.key === key ? { key, data: c.data, busy: false, error: e.status === 404 ? null : e.message, limited: false } : c, c?.key === key ? { key, data: c.data, busy: false, error: e.status === 404 ? null : e.message, limited: false } : c,
); );
return null;
} }
}, []); }, []);
// Check the channel once its ID looks complete (debounced while typing). // Check the channel once its ID looks complete (debounced while typing).
@@ -98,8 +101,8 @@ function AddChannelForm({
const verdict = liveCheck?.data && checkOn ? channelVerdict(checked) : null; const verdict = liveCheck?.data && checkOn ? channelVerdict(checked) : null;
const shownName = channelName.trim() || checked?.name || null; const shownName = channelName.trim() || checked?.name || null;
// Per-bot result, by token id (the admin route sends ids) or else by name. // Per-bot result, by token id (the admin route sends ids) or else by name.
const botCheck = (b: RadioBot): VoiceCheckBot | undefined => const botCheck = (b: RadioBot, row: VoiceCheckChannel | undefined = checked): VoiceCheckBot | undefined =>
checked?.bots.find((x) => (x.token_id ? x.token_id === b.token_id : x.name === b.name || x.name === b.bot_name)); row?.bots.find((x) => (x.token_id ? x.token_id === b.token_id : x.name === b.name || x.name === b.bot_name));
const org = guilds.find((g) => g.guild_id === guildId)?.acting_org_id ?? null; const org = guilds.find((g) => g.guild_id === guildId)?.acting_org_id ?? null;
const systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org); const systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org);
@@ -124,6 +127,13 @@ function AddChannelForm({
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
if (checkKey && (verdict?.kind === "fix" || chosenBotDenied)) {
// "Needs fix" can change: ask Discord again before refusing (server-26#256).
// Still refused = stay here, the row shows why; a failed check fails open.
const fresh = await runCheck(checkKey, true);
const row = fresh && fresh.enabled !== false ? fresh.channels[cid] : undefined;
if (row && (blocksStart(channelVerdict(row)) || (chosenBot && botCheck(chosenBot, row)?.ok === false))) return;
}
const res = await c2api.startDiscordStream({ const res = await c2api.startDiscordStream({
guild_id: guildId, guild_id: guildId,
channel_id: channelId.trim(), channel_id: channelId.trim(),
@@ -221,7 +231,7 @@ function AddChannelForm({
</div> </div>
{verdict && <FixText verdict={verdict} channelName={shownName} />} {verdict && <FixText verdict={verdict} channelName={shownName} />}
{chosenBotDenied && ( {chosenBotDenied && (
<div className="text-xs text-sev-major">The bot you picked can&apos;t stream into this channel — pick another, or fix it in Discord and Re-check.</div> <div className="text-xs text-sev-major">The bot you picked can&apos;t stream into this channel — pick another, or fix it in Discord and press Start again.</div>
)} )}
{checkOn && <BotDetails channel={checked} channelName={shownName} />} {checkOn && <BotDetails channel={checked} channelName={shownName} />}
<CheckFooter <CheckFooter
@@ -252,10 +262,16 @@ function AddChannelForm({
<Button <Button
type="submit" type="submit"
size="sm" size="sm"
disabled={saving || !systemId || blocksStart(verdict) || chosenBotDenied} disabled={saving || !systemId || verdict?.kind === "not_voice"}
title={blocksStart(verdict) ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined} title={
verdict?.kind === "not_voice"
? "That is not a voice channel. Pick a voice channel."
: blocksStart(verdict) || chosenBotDenied
? "Fixed the permission in Discord? Start checks again first."
: undefined
}
> >
{saving ? "Starting…" : "Start radio channel"} {saving ? (blocksStart(verdict) || chosenBotDenied ? "Checking…" : "Starting…") : "Start radio channel"}
</Button> </Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button> <Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
</div> </div>
@@ -29,6 +29,11 @@ const BADGE: Record<ChannelVerdict["kind"], { label: string; cls: string; title?
title: "A bot could stream here, but each one that can is already in another channel of this server or on another node.", title: "A bot could stream here, but each one that can is already in another channel of this server or on another node.",
}, },
no_bot: { label: "No radio bot", cls: "border-sev-moderate/50 text-sev-moderate", title: "No radio bot is in this Discord server yet." }, no_bot: { label: "No radio bot", cls: "border-sev-moderate/50 text-sev-moderate", title: "No radio bot is in this Discord server yet." },
invalid: {
label: "Radio bot needs replacing",
cls: "border-sev-major/50 text-sev-major",
title: "A radio bot is in this server, but Discord rejected its token. Inviting it again won't help — DRB support has to replace it.",
},
unknown: { label: "Couldn't check", cls: "border-line text-ink-muted", title: "Discord didn't answer the check — DRB will still try." }, unknown: { label: "Couldn't check", cls: "border-line text-ink-muted", title: "Discord didn't answer the check — DRB will still try." },
}; };
@@ -59,11 +64,19 @@ export function VerdictBadge({ verdict, loading }: { verdict: ChannelVerdict | n
); );
} }
/** The sentence under a "Needs fix" / "Not a voice channel" channel. */ /** The sentence under a "Needs fix" / "Not a voice channel" / "Radio bot needs replacing" channel. */
export function FixText({ verdict, channelName }: { verdict: ChannelVerdict; channelName: string | null }) { export function FixText({ verdict, channelName }: { verdict: ChannelVerdict; channelName: string | null }) {
if (verdict.kind === "not_voice") { if (verdict.kind === "not_voice") {
return <span className="block text-xs text-sev-major mt-1">That is not a voice channel. Radio can only play in a voice channel.</span>; return <span className="block text-xs text-sev-major mt-1">That is not a voice channel. Radio can only play in a voice channel.</span>;
} }
if (verdict.kind === "invalid") {
return (
<span className="block text-xs text-sev-major mt-1">
Discord rejected the radio bot in this server, so it can&apos;t play radio. Inviting it again won&apos;t help —
DRB support has to replace it.
</span>
);
}
if (verdict.kind !== "fix") return null; if (verdict.kind !== "fix") return null;
return ( return (
<span className="block text-xs text-sev-major mt-1"> <span className="block text-xs text-sev-major mt-1">
+3 -3
View File
@@ -35,9 +35,9 @@ const post = <T,>(path: string, body: object) => send<T>("POST", path, body);
export interface OAuthStartRequest { export interface OAuthStartRequest {
/** One of the org's own (BYO) bots, by token_id. */ /** One of the org's own (BYO) bots, by token_id. */
token_id?: string; token_id?: string;
/** The bot marked preferred for this system, when it is a DRB pool bot a platform /** The bot marked preferred for this system (the org's own system or a shared one),
* admin pinned (its token_id is not shown to the org). NEEDS S6 to accept * including a DRB pool bot a platform admin pinned, whose token_id the org never
* system_id — until it does, c2-core ignores it and picks Automatic. */ * sees. 404 if that bot can't connect servers; c2-core never swaps in another. */
system_id?: string; system_id?: string;
/** Kept for the plan's node-page entry point: the preferred bot of this node's system. */ /** Kept for the plan's node-page entry point: the preferred bot of this node's system. */
node_id?: string; node_id?: string;
+9 -5
View File
@@ -164,7 +164,7 @@ export function parseC2Error(err: unknown): ParsedC2Error {
export type BotAccess = "ok" | "denied" | "unknown" | "not_member" | "invalid" | "bad_channel" | "unchecked"; export type BotAccess = "ok" | "denied" | "unknown" | "not_member" | "invalid" | "bad_channel" | "unchecked";
/** Per channel, as c2-core sums it up over the bots that are free. */ /** Per channel, as c2-core sums it up over the bots that are free. */
export type ChannelState = "ready" | "needs_fix" | "unknown" | "busy" | "no_bot" | "bad_channel" | "unchecked"; export type ChannelState = "ready" | "needs_fix" | "unknown" | "busy" | "no_bot" | "invalid" | "bad_channel" | "unchecked";
export interface VoiceCheckBot { export interface VoiceCheckBot {
/** Admin route only. */ /** Admin route only. */
@@ -206,7 +206,7 @@ export interface VoiceCheck {
} }
const BOT_ACCESS: readonly string[] = ["ok", "denied", "unknown", "not_member", "invalid", "bad_channel", "unchecked"]; const BOT_ACCESS: readonly string[] = ["ok", "denied", "unknown", "not_member", "invalid", "bad_channel", "unchecked"];
const CHANNEL_STATES: readonly string[] = ["ready", "needs_fix", "unknown", "busy", "no_bot", "bad_channel", "unchecked"]; const CHANNEL_STATES: readonly string[] = ["ready", "needs_fix", "unknown", "busy", "no_bot", "invalid", "bad_channel", "unchecked"];
function normBot(b: Record<string, unknown>): VoiceCheckBot { function normBot(b: Record<string, unknown>): VoiceCheckBot {
const status = (typeof b.status === "string" && BOT_ACCESS.includes(b.status) const status = (typeof b.status === "string" && BOT_ACCESS.includes(b.status)
@@ -267,6 +267,8 @@ export type ChannelVerdict =
| { kind: "unknown"; limit: number } | { kind: "unknown"; limit: number }
/** No radio bot is in this server. */ /** No radio bot is in this server. */
| { kind: "no_bot"; limit: number } | { kind: "no_bot"; limit: number }
/** A radio bot IS in this server, but Discord rejected its token (server-26#257). No invite: it is already there. */
| { kind: "invalid"; limit: number }
/** The channel exists but is not a voice channel — Start is disabled. */ /** The channel exists but is not a voice channel — Start is disabled. */
| { kind: "not_voice"; limit: number }; | { kind: "not_voice"; limit: number };
@@ -283,6 +285,7 @@ export function channelVerdict(c: VoiceCheckChannel | null | undefined): Channel
case "busy": return { kind: "busy", limit }; case "busy": return { kind: "busy", limit };
case "unknown": return { kind: "unknown", limit }; case "unknown": return { kind: "unknown", limit };
case "no_bot": return { kind: "no_bot", limit }; case "no_bot": return { kind: "no_bot", limit };
case "invalid": return { kind: "invalid", limit };
case "bad_channel": return { kind: "not_voice", limit }; case "bad_channel": return { kind: "not_voice", limit };
case "unchecked": return null; case "unchecked": return null;
case "needs_fix": return firstDenied ? { kind: "fix", limit, bot: firstDenied } : { kind: "unknown", limit }; case "needs_fix": return firstDenied ? { kind: "fix", limit, bot: firstDenied } : { kind: "unknown", limit };
@@ -293,6 +296,7 @@ export function channelVerdict(c: VoiceCheckChannel | null | undefined): Channel
if (free.some((b) => b.ok === null)) return { kind: "unknown", limit }; if (free.some((b) => b.ok === null)) return { kind: "unknown", limit };
if (denied.length) return { kind: "fix", limit, bot: denied[0] }; if (denied.length) return { kind: "fix", limit, bot: denied[0] };
if (c.bots.some((b) => b.ok === true)) return { kind: "busy", limit }; if (c.bots.some((b) => b.ok === true)) return { kind: "busy", limit };
if (c.bots.some((b) => b.status === "invalid")) return { kind: "invalid", limit };
return { kind: "unknown", limit }; return { kind: "unknown", limit };
} }
@@ -321,9 +325,9 @@ export function deniedSentence(bot: { name: string; missing: string[]; reason: s
/** Where in Discord to fix it — c2-core's own sentence when it sent one. */ /** Where in Discord to fix it — c2-core's own sentence when it sent one. */
export function fixSentence(bot: { name: string; missing: string[]; reason: string | null; fix?: string | null }): string { export function fixSentence(bot: { name: string; missing: string[]; reason: string | null; fix?: string | null }): string {
if (bot.fix) return bot.fix; if (bot.fix) return bot.fix;
if (bot.reason && /timed out/i.test(bot.reason)) return `In Discord: remove ${bot.name}'s timeout, then Re-check.`; if (bot.reason && /timed out/i.test(bot.reason)) return `In Discord: remove ${bot.name}'s timeout, then try again.`;
const perms = bot.missing.length ? bot.missing.join(" and ") : "View Channel, Connect and Speak"; const perms = bot.missing.length ? bot.missing.join(" and ") : "View Channel, Connect and Speak";
return `In Discord: Edit Channel → Permissions → add ${bot.name} → allow ${perms}, then Re-check.`; return `In Discord: Edit Channel → Permissions → add ${bot.name} → allow ${perms}, then try again.`;
} }
/** "40s ago" / "3 min ago" for the Re-check footer. */ /** "40s ago" / "3 min ago" for the Re-check footer. */
@@ -355,7 +359,7 @@ export function botLine(b: VoiceCheckBot, channelName?: string | null): { tone:
case "not_member": case "not_member":
return { tone: "warn", text: `${b.name}: Discord says it is not in this server — invite it again` }; return { tone: "warn", text: `${b.name}: Discord says it is not in this server — invite it again` };
case "invalid": case "invalid":
return { tone: "bad", text: `${b.name}: its token was rejected by Discord — a platform admin has to replace it` }; return { tone: "bad", text: `${b.name}: Discord rejected its token — DRB support has to replace it` };
case "bad_channel": case "bad_channel":
return { tone: "bad", text: `${b.name}: ${channelRef(channelName)} is not a voice channel` }; return { tone: "bad", text: `${b.name}: ${channelRef(channelName)} is not a voice channel` };
case "unchecked": case "unchecked":