The picker footer reads "Permissions checked N ago" from checked_at, but voice_check stamped it with the response time. A denied result is cached for 10 min, so a user who fixed the permission in Discord and reopened the picker saw "checked 2s ago · Needs fix" from a stale snapshot. checked_at now carries the oldest fetched_at behind the answer (now when there is none). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
226 lines
11 KiB
Python
226 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"
|
|
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
|