Files
server-26/drb-c2-core/app/routers/discord_checks.py
T
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

228 lines
11 KiB
Python

"""
Discord voice-channel permission status for the picker (server-26#225 S3).
Read-only. For one Discord server and its voice channels, says for each radio
bot that could serve it whether Discord lets that bot View, Connect and Speak
there, and when not, which permission is missing, what removed it and what to
change. Built on the same cached REST snapshots ``start_stream`` checks before
it hands a bot to a node (discord_streams.gather_snapshots / evaluate_access),
so the badge the UI shows and the refusal a start gets agree.
Auth:
* GET /discord/nodes/{node_id}/guilds/{guild_id}/voice-check
any signed-in member of the node's org, or a platform admin
(``discord_web._node_for_caller``); the server must be one of the org's
(``discord_web._org_guilds``). Anything else is a 404. Bot NAMES only —
no token ids for non-admins, never a token string.
* GET /discord/guilds/{guild_id}/voice-check
the Discord bot's service key or a platform admin; rows carry token ids.
``refresh=1`` skips the cache, at most once per 10 s per caller and server;
past that the cached answer is served with ``refreshed: false``.
User limits are advisory: REST can't see who is connected, so the edge's
``channel_full`` stays the enforcement.
"""
from __future__ import annotations
import time
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from app.config import settings
from app.internal import discord_leases as leases
from app.internal import discord_rest
from app.internal import discord_streams as streams
from app.internal import firestore as fstore
from app.internal.auth import get_role, require_firebase_token, require_service_key_or_admin
from app.routers.discord_web import _guild_name, _node_for_caller, _org_guilds
router = APIRouter(prefix="/discord", tags=["discord"])
REFRESH_EVERY_SECONDS = 10.0
_SNOWFLAKE = r"^[0-9]{1,25}$"
_last_refresh: dict[tuple[str, str], float] = {}
def _refresh_allowed(caller: str, guild_id: str) -> bool:
now = time.monotonic()
key = (caller, guild_id)
if now - _last_refresh.get(key, -1e9) < REFRESH_EVERY_SECONDS:
return False
_last_refresh[key] = now
if len(_last_refresh) > 5000: # bounded: drop entries past the window
for k in [k for k, t in _last_refresh.items() if now - t >= REFRESH_EVERY_SECONDS]:
_last_refresh.pop(k, None)
return True
def _availability(tok: dict, *, guild_id: str, node_id: Optional[str]) -> Optional[str]:
"""None when the bot could take a new stream here, else why not."""
if any(leases.guild_of(s) == guild_id for s in leases._lease_streams(tok)):
return "busy_here" # one voice connection per bot per server
lease_node = (tok.get("lease") or {}).get("node_id")
if node_id and lease_node and lease_node != node_id:
return "other_node" # logged in on another node
return None
def _channel_state(rows: list[dict], enabled: bool) -> str:
if not enabled:
return "unchecked"
if any(r["status"] == "bad_channel" for r in rows):
return "bad_channel"
if not rows:
return "no_bot"
free = [r for r in rows if r["available"]]
if any(r["ok"] for r in free):
return "ready"
if any(r["status"] == discord_rest.UNKNOWN for r in free):
return "unknown" # fail open: a start is still tried
if any(r["status"] == leases.PERM_DENIED for r in free):
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"
def _checked_at(snaps: dict) -> str:
"""When Discord was last actually asked: the OLDEST cached snapshot behind
this answer (a denied result is cached for 10 min), so the picker's
"Permissions checked N ago" never calls a stale answer fresh (server-26#225
S4). No snapshot (check off, no member bots) = now."""
times = [s.fetched_at for s in snaps.values() if s is not None and getattr(s, "fetched_at", None)]
ts = min(times) if times else time.time()
return datetime.fromtimestamp(ts, timezone.utc).isoformat()
async def voice_check(*, guild_id: str, org_id: Optional[str], node_id: Optional[str],
channels: list[dict], channel_id: Optional[str], refresh: bool,
with_token_ids: bool) -> dict:
"""The JSON contract (see the PR for server-26#225 S3). ``channels`` is
the server's directory (``[{channel_id, name}]``); when it is empty the
voice channels a bot's snapshot lists are used instead."""
enabled = settings.discord_perm_check_enabled
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
eligible = {tid: t for tid, t in tokens.items()
if t.get("valid") is not False and t.get("org_id") in (None, org_id)}
members = [tid for tid, t in eligible.items()
if t.get("guild_ids") is None or guild_id in t["guild_ids"]]
members.sort(key=lambda t: leases._candidate_order(t, eligible[t], node_id or "", org_id, None))
invite = [{"name": t.get("name") or tid, "invite_url": leases.invite_url(t.get("application_id"), guild_id)}
for tid, t in sorted(eligible.items(), key=lambda kv: str(kv[1].get("name") or kv[0]))
if tid not in members and leases.invitable(t) and t.get("application_id")][:5]
snaps = (await streams.gather_snapshots(tokens, [m for m in members if tokens[m].get("token")], guild_id,
refresh=refresh)
if enabled else {})
picked = [{"channel_id": str(c.get("channel_id")), "name": c.get("name")} for c in channels]
if not picked:
seen = next((s for s in snaps.values() if s is not None and s.status == discord_rest.OK), None)
picked = [{"channel_id": str(c.get("id")), "name": c.get("name")}
for c in sorted((seen.channels if seen else []) or [],
key=lambda c: (c.get("position") or 0, str(c.get("name") or "")))
if isinstance(c, dict) and c.get("type") == 2]
if channel_id:
picked = [c for c in picked if c["channel_id"] == channel_id] or [{"channel_id": channel_id, "name": None}]
# Bots whose rows say "not a member" after the check move to the invite list.
newly_out = {t for t, s in snaps.items() if s is not None and s.status == discord_rest.NOT_MEMBER}
out_channels = []
for c in picked:
rows = []
for tid in members:
if tid in newly_out:
continue
tok = tokens[tid]
name = tok.get("name") or tid
if not enabled:
res = {"status": "unchecked", "missing": [], "reason": None, "fix": None}
elif snaps.get(tid) is None:
res = streams._unanswered()
else:
res = streams.evaluate_access(snaps[tid], c["channel_id"], bot=name)
why = _availability(tok, guild_id=guild_id, node_id=node_id)
row = {
"name": name,
"ok": res["status"] == leases.PERM_OK,
"status": res["status"],
"missing": res.get("missing") or [],
"reason": res.get("reason") or (res.get("error") if res["status"] == discord_rest.UNKNOWN else None),
"fix": res.get("fix"),
"available": why is None,
"unavailable_reason": why,
"user_limit": res.get("user_limit") or 0,
"limit_bypass": bool(res.get("limit_bypass")),
}
if with_token_ids:
row["token_id"] = tid
rows.append(row)
if not c["name"] and res.get("channel_name"):
c["name"] = res["channel_name"]
limit = max((r["user_limit"] for r in rows), default=0)
out_channels.append({
"channel_id": c["channel_id"],
"name": c["name"],
"state": _channel_state(rows, enabled),
"any_ok": any(r["ok"] for r in rows),
"user_limit": limit,
"limit_bypass": any(r["ok"] and r["limit_bypass"] for r in rows),
"bots": [{k: v for k, v in r.items() if k not in ("user_limit", "limit_bypass")} for r in rows],
})
for tid in sorted(newly_out):
t = tokens[tid]
if leases.invitable(t) and t.get("application_id") and len(invite) < 5:
invite.append({"name": t.get("name") or tid,
"invite_url": leases.invite_url(t.get("application_id"), guild_id)})
return {
"guild_id": guild_id,
"node_id": node_id,
"enabled": enabled,
"checked_at": _checked_at(snaps),
"refreshed": refresh,
"channels": out_channels,
"invite": invite,
}
@router.get("/nodes/{node_id}/guilds/{guild_id}/voice-check")
async def node_voice_check(node_id: str, guild_id: str,
refresh: int = Query(0, ge=0, le=1),
channel_id: Optional[str] = Query(None, pattern=_SNOWFLAKE),
decoded: dict = Depends(require_firebase_token)):
node, _ = await _node_for_caller(node_id, decoded)
org_id = node.get("org_id")
guild = next((g for g in (await _org_guilds(org_id) if org_id else []) if g["guild_id"] == str(guild_id)), None)
if not guild:
raise HTTPException(404, "That Discord server is not linked to this organization.")
do_refresh = bool(refresh) and _refresh_allowed(f"uid:{decoded.get('uid') or '?'}", guild["guild_id"])
out = await voice_check(guild_id=guild["guild_id"], org_id=org_id, node_id=node_id,
channels=guild.get("voice_channels") or [], channel_id=channel_id,
refresh=do_refresh, with_token_ids=get_role(decoded) == "admin")
out["guild_name"] = _guild_name(guild)
return out
@router.get("/guilds/{guild_id}/voice-check")
async def guild_voice_check(guild_id: str,
channel_id: Optional[str] = Query(None, pattern=_SNOWFLAKE),
refresh: int = Query(0, ge=0, le=1),
decoded: dict = Depends(require_service_key_or_admin)):
if not guild_id.isdigit():
raise HTTPException(400, "guild_id must be a Discord server id (digits only).")
org_id = await streams.resolve_guild_org(guild_id)
link = await fstore.doc_get(streams.GUILDS, guild_id) or {}
caller = "service" if decoded.get("service") else f"uid:{decoded.get('uid') or '?'}"
do_refresh = bool(refresh) and _refresh_allowed(caller, guild_id)
out = await voice_check(guild_id=guild_id, org_id=org_id, node_id=None,
channels=link.get("voice_channels") or [], channel_id=channel_id,
refresh=do_refresh, with_token_ids=True)
out["guild_name"] = _guild_name(link)
out["org_id"] = org_id
return out