- 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:
co-authored by
Claude Opus 5.5
parent
13a3525e4c
commit
32d223e556
@@ -84,6 +84,8 @@ def _channel_state(rows: list[dict], enabled: bool) -> str:
|
||||
return "needs_fix" # a start is refused with missing_permissions
|
||||
if any(r["ok"] for r in rows):
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
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
|
||||
``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.
|
||||
@@ -30,7 +32,7 @@ application's credentials, after the org-scope checks and rate limits here.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
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,
|
||||
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`` |
|
||||
``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")}
|
||||
@@ -96,6 +98,21 @@ async def _pick_bot(org_id: str, *, node_id: Optional[str] = None,
|
||||
"right now.")
|
||||
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:
|
||||
node = await fstore.doc_get("nodes", node_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),
|
||||
token_id: Optional[str] = Query(None, pattern=_ID),
|
||||
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."""
|
||||
_require_enabled()
|
||||
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)}
|
||||
|
||||
|
||||
class StartBody(BaseModel):
|
||||
node_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
|
||||
|
||||
|
||||
@@ -154,12 +173,14 @@ async def oauth_start(body: StartBody, org_id: Optional[str] = None,
|
||||
scope = resolve_org_scope(decoded, org_id)
|
||||
uid, email = describe_actor(decoded)
|
||||
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)
|
||||
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)
|
||||
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),
|
||||
"expires_at": expires_at.isoformat(), "bot": _bot_summary(tok, why)}
|
||||
|
||||
|
||||
@@ -187,6 +187,58 @@ async def test_preferred_bot_for_the_nodes_system_wins(fs):
|
||||
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):
|
||||
out = await router.oauth_options(node_id=None, token_id="pool-1", org_id=None, decoded=OWNER_A)
|
||||
assert out["bot"]["reason"] == "chosen"
|
||||
|
||||
@@ -84,7 +84,7 @@ def fs(monkeypatch):
|
||||
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
|
||||
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_calls = []
|
||||
|
||||
@@ -99,6 +99,8 @@ def fs(monkeypatch):
|
||||
return _snap(tid, status=discord_rest.UNKNOWN)
|
||||
if mode == "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"))
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
_token(fs, "t1")
|
||||
for node, guild, who in (("n1", "200", OWNER), ("nx", GUILD, OWNER), ("n1", GUILD, STRANGER)):
|
||||
|
||||
@@ -28,17 +28,12 @@ const SELECT =
|
||||
|
||||
// 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.
|
||||
function startRequest(choice: string, data: OrgPreferredBots | null): OAuthStartRequest {
|
||||
function startRequest(choice: string): OAuthStartRequest {
|
||||
const req: OAuthStartRequest = { return_to: "/settings/organization" };
|
||||
if (choice.startsWith("tok:")) req.token_id = choice.slice(4);
|
||||
else if (choice.startsWith("sys:")) {
|
||||
const id = choice.slice(4);
|
||||
// The org's own bot preferred for this system: name it by token_id, which
|
||||
// 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;
|
||||
}
|
||||
// c2-core resolves the system's preferred bot itself, including a pool bot
|
||||
// a platform admin pinned (whose token_id the org never sees).
|
||||
else if (choice.startsWith("sys:")) req.system_id = choice.slice(4);
|
||||
return req;
|
||||
}
|
||||
|
||||
@@ -132,7 +127,7 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
|
||||
setActionError(null);
|
||||
setJustLinked(null);
|
||||
try {
|
||||
const { url } = await startDiscordOAuth(startRequest(choice, choices));
|
||||
const { url } = await startDiscordOAuth(startRequest(choice));
|
||||
window.location.assign(url);
|
||||
// Leave the button in its busy state: the page is navigating to Discord.
|
||||
} catch (err) {
|
||||
|
||||
@@ -116,8 +116,8 @@ function DiscordPicker({
|
||||
const channelOnHere = channel?.stream?.node_id === nodeId;
|
||||
const check = guildId ? checks[guildId] : undefined;
|
||||
const checkOn = check?.data?.enabled !== false;
|
||||
const verdictOf = (c: DiscordGuildOption["voice_channels"][number]) => {
|
||||
const row = check?.data && checkOn ? check.data.channels[c.channel_id] : undefined;
|
||||
const verdictOf = (c: DiscordGuildOption["voice_channels"][number], data: VoiceCheck | null | undefined = check?.data) => {
|
||||
const row = data && data.enabled !== false ? data.channels[c.channel_id] : undefined;
|
||||
const v = channelVerdict(row);
|
||||
// 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
|
||||
@@ -128,19 +128,26 @@ function DiscordPicker({
|
||||
};
|
||||
const chosenVerdict = channel ? verdictOf(channel) : null;
|
||||
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 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 } }));
|
||||
try {
|
||||
const data = normalizeVoiceCheck(await c2api.getNodeVoiceCheck(nodeId, gid, refresh));
|
||||
setChecks((m) => ({ ...m, [gid]: { data, busy: false, error: null, limited: refresh && data.refreshed === false } }));
|
||||
return data;
|
||||
} catch (err) {
|
||||
const e = parseC2Error(err);
|
||||
// 404 = route not deployed yet or server not visible: no badges (fail-open).
|
||||
const msg = e.status === 404 ? null : e.message;
|
||||
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: false, error: msg, limited: false } }));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[nodeId],
|
||||
@@ -166,6 +173,12 @@ function DiscordPicker({
|
||||
setSending(true);
|
||||
setError(null);
|
||||
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 });
|
||||
onStarted();
|
||||
onClose();
|
||||
@@ -382,11 +395,17 @@ function DiscordPicker({
|
||||
<button
|
||||
type="button"
|
||||
onClick={start}
|
||||
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || definiteDeny}
|
||||
title={definiteDeny ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined}
|
||||
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || startOff}
|
||||
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"
|
||||
>
|
||||
{sending ? "Starting…" : channelOnHere ? "Update" : "Start radio"}
|
||||
{sending ? (definiteDeny ? "Checking…" : "Starting…") : channelOnHere ? "Update" : "Start radio"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -33,7 +33,8 @@ export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
|
||||
} catch (err) {
|
||||
const e = parseC2Error(err);
|
||||
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."
|
||||
/>
|
||||
{loadError ? (
|
||||
<p role="alert" className="text-sm text-sev-major">Couldn't load radio bots: {loadError}</p>
|
||||
<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. To use
|
||||
your own bot for a system, ask DRB support to add it to your organization.
|
||||
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">
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type ParsedC2Error,
|
||||
type VoiceCheck,
|
||||
type VoiceCheckBot,
|
||||
type VoiceCheckChannel,
|
||||
} from "@/lib/discordPicker";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
|
||||
@@ -70,17 +71,19 @@ function AddChannelForm({
|
||||
|
||||
const cid = channelId.trim();
|
||||
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(":");
|
||||
setCheck((c) => ({ key, data: c?.key === key ? c.data : null, busy: true, error: null, limited: false }));
|
||||
try {
|
||||
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));
|
||||
return data;
|
||||
} catch (err) {
|
||||
const e = parseStartError(err);
|
||||
setCheck((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).
|
||||
@@ -98,8 +101,8 @@ function AddChannelForm({
|
||||
const verdict = liveCheck?.data && checkOn ? channelVerdict(checked) : null;
|
||||
const shownName = channelName.trim() || checked?.name || null;
|
||||
// Per-bot result, by token id (the admin route sends ids) or else by name.
|
||||
const botCheck = (b: RadioBot): VoiceCheckBot | undefined =>
|
||||
checked?.bots.find((x) => (x.token_id ? x.token_id === b.token_id : x.name === b.name || x.name === b.bot_name));
|
||||
const botCheck = (b: RadioBot, row: VoiceCheckChannel | undefined = checked): VoiceCheckBot | undefined =>
|
||||
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 systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org);
|
||||
@@ -124,6 +127,13 @@ function AddChannelForm({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
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({
|
||||
guild_id: guildId,
|
||||
channel_id: channelId.trim(),
|
||||
@@ -221,7 +231,7 @@ function AddChannelForm({
|
||||
</div>
|
||||
{verdict && <FixText verdict={verdict} channelName={shownName} />}
|
||||
{chosenBotDenied && (
|
||||
<div className="text-xs text-sev-major">The bot you picked can'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't stream into this channel — pick another, or fix it in Discord and press Start again.</div>
|
||||
)}
|
||||
{checkOn && <BotDetails channel={checked} channelName={shownName} />}
|
||||
<CheckFooter
|
||||
@@ -252,10 +262,16 @@ function AddChannelForm({
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={saving || !systemId || blocksStart(verdict) || chosenBotDenied}
|
||||
title={blocksStart(verdict) ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined}
|
||||
disabled={saving || !systemId || verdict?.kind === "not_voice"}
|
||||
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 type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
|
||||
</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.",
|
||||
},
|
||||
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." },
|
||||
};
|
||||
|
||||
@@ -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 }) {
|
||||
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>;
|
||||
}
|
||||
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't play radio. Inviting it again won't help —
|
||||
DRB support has to replace it.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (verdict.kind !== "fix") return null;
|
||||
return (
|
||||
<span className="block text-xs text-sev-major mt-1">
|
||||
|
||||
@@ -35,9 +35,9 @@ const post = <T,>(path: string, body: object) => send<T>("POST", path, body);
|
||||
export interface OAuthStartRequest {
|
||||
/** One of the org's own (BYO) bots, by token_id. */
|
||||
token_id?: string;
|
||||
/** The bot marked preferred for this system, when it is a DRB pool bot a platform
|
||||
* admin pinned (its token_id is not shown to the org). NEEDS S6 to accept
|
||||
* system_id — until it does, c2-core ignores it and picks Automatic. */
|
||||
/** The bot marked preferred for this system (the org's own system or a shared one),
|
||||
* including a DRB pool bot a platform admin pinned, whose token_id the org never
|
||||
* sees. 404 if that bot can't connect servers; c2-core never swaps in another. */
|
||||
system_id?: string;
|
||||
/** Kept for the plan's node-page entry point: the preferred bot of this node's system. */
|
||||
node_id?: string;
|
||||
|
||||
@@ -164,7 +164,7 @@ export function parseC2Error(err: unknown): ParsedC2Error {
|
||||
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. */
|
||||
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 {
|
||||
/** 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 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 {
|
||||
const status = (typeof b.status === "string" && BOT_ACCESS.includes(b.status)
|
||||
@@ -267,6 +267,8 @@ export type ChannelVerdict =
|
||||
| { kind: "unknown"; limit: number }
|
||||
/** No radio bot is in this server. */
|
||||
| { 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. */
|
||||
| { kind: "not_voice"; limit: number };
|
||||
|
||||
@@ -283,6 +285,7 @@ export function channelVerdict(c: VoiceCheckChannel | null | undefined): Channel
|
||||
case "busy": return { kind: "busy", limit };
|
||||
case "unknown": return { kind: "unknown", limit };
|
||||
case "no_bot": return { kind: "no_bot", limit };
|
||||
case "invalid": return { kind: "invalid", limit };
|
||||
case "bad_channel": return { kind: "not_voice", limit };
|
||||
case "unchecked": return null;
|
||||
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 (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.status === "invalid")) return { kind: "invalid", 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. */
|
||||
export function fixSentence(bot: { name: string; missing: string[]; reason: string | null; fix?: string | null }): string {
|
||||
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";
|
||||
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. */
|
||||
@@ -355,7 +359,7 @@ export function botLine(b: VoiceCheckBot, channelName?: string | null): { tone:
|
||||
case "not_member":
|
||||
return { tone: "warn", text: `${b.name}: Discord says it is not in this server — invite it again` };
|
||||
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":
|
||||
return { tone: "bad", text: `${b.name}: ${channelRef(channelName)} is not a voice channel` };
|
||||
case "unchecked":
|
||||
|
||||
Reference in New Issue
Block a user