start_stream now asks Discord (cached REST snapshots, S2) whether each bot
it could pick may View, Connect and Speak in the voice channel, before the
lease. Covers all four entry points: bot POST /discord/streams, web picker,
nodes discord_join, and the reconciler's re-place.
- pick_token: denied bots skipped; confirmed-ok sorts before unknown;
unknown is allowed (fail open); all denied -> missing_permissions naming
each bot, missing permission, trace reason and fix. The reuse branch
falls through when its token is denied.
- Bounded: discord_perm_check_concurrency at once, within
discord_perm_check_budget_seconds; unanswered = unknown.
- Non-voice channel -> 400 bad_channel (not missing_permissions).
- systems/{id}.preferred_token_id is now read (plan finding 1), as a hint.
- Pool bots at discord_pool_bot_guild_cap are never offered as invites;
pool_full when nothing else is left (owner Q3).
- handle_actual drops cached access on missing_permissions, channel_full,
not_in_guild, bad_channel.
- New GET /discord/nodes/{node_id}/guilds/{guild_id}/voice-check (org
member, names only) and GET /discord/guilds/{guild_id}/voice-check
(service key or admin).
- PUT /tokens/{id}/prefer/{system_id}: org owners for their own systems
and own BYO bots; shared systems and pool bots stay admin-only (owner Q2).
Keeps both pointers single-valued; audited.
- Kill switch: DISCORD_PERM_CHECK_ENABLED=false.
- tests/conftest.py: autouse fixture so no test ever reaches discord.com.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
10 KiB
Python
216 lines
10 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"
|
|
return "no_bot"
|
|
|
|
|
|
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": datetime.now(timezone.utc).isoformat(),
|
|
"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
|