Merge pull request 'Discord: channel list and presence without the main bot (#225 S5, fixes #251)' (#262) from feat/225-s5-channels into main
Build & Deploy / Build & push images (push) Successful in 5m37s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 5s
Build & Deploy / Deploy to VM (push) Successful in 12s
Build & Deploy / Report a failed deploy (push) Skipped

This commit was merged in pull request #262.
This commit is contained in:
2026-10-05 03:01:07 -04:00
8 changed files with 746 additions and 25 deletions
+97
View File
@@ -655,3 +655,100 @@ async def guild_snapshot(token_doc: dict, guild_id: str, *, refresh: bool = Fals
error=f"check failed ({type(e).__name__})")
return await access_cache.get_or_load(key, _load, lambda s: ttl_for(s.status), refresh=refresh)
# ---------------------------------------------------------------------------
# Voice-channel directory from a radio bot's view (server-26#225 S5)
# ---------------------------------------------------------------------------
DIRECTORY_MAX_CHANNELS = 200 # same cap as PUT /discord/guilds/{id}/directory
_DIRECTORY_NAME_MAX = 100
VOICE_CHANNEL = 2
CATEGORY_CHANNEL = 4
def _int_or(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def directory_from_snapshot(snap: Optional[GuildSnapshot]) -> Optional[dict]:
"""A server's name and voice channels as one radio bot's snapshot sees
them, in the shape the DRB server bot pushes (``PUT
/discord/guilds/{id}/directory``): ``{name, voice_channels: [{channel_id,
name, category, position}]}``, in Discord's sidebar order (channels with
no category first, then each category by position; channels by position,
then id). None unless the snapshot is OK.
Every voice channel in the list is kept, including any this bot can't
View, exactly like the server bot's gateway list: whether a bot may join
is the permission check's answer (discord_streams.evaluate_access), and
the picker shows it per channel. Stage channels are not voice channels
here either."""
if snap is None or snap.status != OK or not isinstance(snap.channels, list):
return None
chans = [c for c in snap.channels if isinstance(c, dict) and c.get("id") is not None]
cats = {str(c["id"]): c for c in chans if c.get("type") == CATEGORY_CHANNEL}
def parent(c: dict) -> Optional[dict]:
return cats.get(str(c.get("parent_id") or ""))
def order(c: dict) -> tuple:
p = parent(c)
top = (0, 0, 0) if p is None else (1, _int_or(p.get("position")), _int_or(p.get("id")))
return (*top, _int_or(c.get("position")), _int_or(c.get("id")))
out: list[dict] = []
for c in sorted((c for c in chans if c.get("type") == VOICE_CHANNEL), key=order)[:DIRECTORY_MAX_CHANNELS]:
p = parent(c)
category = str(p.get("name") or "")[:_DIRECTORY_NAME_MAX] if p else ""
out.append({
"channel_id": str(c["id"]),
"name": str(c.get("name") or "")[:_DIRECTORY_NAME_MAX],
"category": category or None,
"position": len(out),
})
name = snap.guild.get("name") if isinstance(snap.guild, dict) else None
return {"name": str(name)[:_DIRECTORY_NAME_MAX] if name else None, "voice_channels": out}
@dataclass
class DirectoryResult:
"""``guild_directory``'s answer. ``status``: OK (``name`` and
``voice_channels`` from ``token_id``'s snapshot), NOT_MEMBER (every bot
asked turned out not to be in the server, or its token is invalid) or
UNKNOWN (no usable answer — keep what is stored)."""
guild_id: str
status: str
name: Optional[str] = None
voice_channels: Optional[list] = None
token_id: Optional[str] = None
fetched_at: Optional[float] = None
async def guild_directory(token_docs: list[dict], guild_id: str, *, refresh: bool = False,
max_bots: int = 3) -> DirectoryResult:
"""The voice-channel list of one server, read from the first of
``token_docs`` (radio bots believed to be in it, best first) whose cached
snapshot is OK. It is the same snapshot the permission check reads, so
listing channels costs no extra Discord request. At most ``max_bots``
bots are asked, one after another. Never raises for a Discord failure.
The caller decides which server and which bots: this never looks a
server up by itself, so it can't discover one (a server DRB doesn't
already know is never listed from a radio bot's membership)."""
guild_id = str(guild_id)
statuses: list[str] = []
for doc in token_docs[:max(1, max_bots)]:
snap = await guild_snapshot(doc, guild_id, refresh=refresh)
statuses.append(snap.status)
d = directory_from_snapshot(snap)
if d is not None:
return DirectoryResult(guild_id=guild_id, status=OK, name=d["name"],
voice_channels=d["voice_channels"], token_id=snap.token_id,
fetched_at=snap.fetched_at)
if statuses and all(s in (NOT_MEMBER, INVALID) for s in statuses):
return DirectoryResult(guild_id=guild_id, status=NOT_MEMBER)
return DirectoryResult(guild_id=guild_id, status=UNKNOWN)
+40 -6
View File
@@ -53,6 +53,7 @@ A retained MQTT message therefore never stores a bot credential on the broker.
from __future__ import annotations
import asyncio
import time
from datetime import datetime, timezone, timedelta
from typing import Optional
@@ -352,14 +353,39 @@ async def check_bots(tokens: dict[str, dict], token_ids: list[str], guild_id: st
for t in token_ids}
# server-26#251: an interactive start (a person pressed Start / ran /join)
# re-reads a bot's snapshot that says "denied" once it is this old, so a
# permission fixed in Discord a moment ago is seen without waiting out the
# cache. The reconciler never does (plan finding 4: no REST loop).
RECHECK_DENIED_AFTER_SECONDS = 10.0
async def _perm_check(*, org_id: str, guild_id: str, channel_id: str, stream_id: str,
preferred_token_id: Optional[str], node_ids: set) -> Optional[dict]:
preferred_token_id: Optional[str], node_ids: set,
recheck_denied: bool = False) -> Optional[dict]:
if not settings.discord_perm_check_enabled:
return None
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
ids = tokens_to_check(tokens, guild_id=guild_id, org_id=org_id, node_ids=node_ids,
exclude_stream_id=stream_id, preferred_token_id=preferred_token_id)
return await check_bots(tokens, ids, guild_id, channel_id)
snaps = await gather_snapshots(tokens, ids, guild_id)
def verdict(t: str) -> dict:
return evaluate_access(snaps[t], channel_id, bot=tokens[t].get("name") or t) if snaps.get(t) \
else _unanswered()
out = {t: verdict(t) for t in ids}
if recheck_denied:
now = time.time()
stale = [t for t in ids if out[t]["status"] == leases.PERM_DENIED
and now - (getattr(snaps.get(t), "fetched_at", None) or now) >= RECHECK_DENIED_AFTER_SECONDS]
if stale:
fresh = await gather_snapshots(tokens, stale, guild_id, refresh=True)
for t, snap in fresh.items():
if snap is not None: # not answered in time: keep the cached verdict
snaps[t] = snap
out[t] = verdict(t)
return out
def _channel_ref(perms: Optional[dict], channel_name: Optional[str]) -> str:
@@ -484,6 +510,7 @@ async def start_stream(
preferred_token_id: Optional[str] = None,
max_streams: Optional[int] = None,
stream_id: Optional[str] = None,
interactive: bool = False,
) -> dict:
"""Put ``system_id`` on a voice channel.
@@ -504,6 +531,12 @@ async def start_stream(
channel first (``_perm_check``; server-26#225). Every entry point — the
bot's /join, the web picker, the node command and the reconciler —
comes through here, so none can hand a node a bot Discord will refuse.
``interactive``: a person asked for this start (the routes pass True,
the reconciler never does). A bot whose cached check says "denied" is
then asked again if that answer is more than
``RECHECK_DENIED_AFTER_SECONDS`` old (server-26#251), so "fix it in
Discord, then try again" works on the next try.
"""
if mode not in ("persistent", "adhoc"):
raise StreamError("bad_mode", "mode must be persistent or adhoc", status=400)
@@ -527,14 +560,14 @@ async def start_stream(
org_id=org_id, guild_id=guild_id, channel_id=channel_id, system_id=system_id, mode=mode,
channel_name=channel_name, guild_name=guild_name, created_by=created_by, node_id=node_id,
preferred_token_id=preferred_token_id, max_streams=max_streams, stream_id=stream_id,
existing=existing,
existing=existing, interactive=interactive,
)
async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_id: str, mode: str,
channel_name: Optional[str], guild_name: Optional[str], created_by: Optional[str],
node_id: Optional[str], preferred_token_id: Optional[str], max_streams: Optional[int],
stream_id: Optional[str], existing: dict) -> dict:
stream_id: Optional[str], existing: dict, interactive: bool = False) -> dict:
already_on = False
if stream_id:
existing = await fstore.doc_get(STREAMS, stream_id) or existing # re-read under the lock
@@ -612,7 +645,8 @@ async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_i
node_ids = {n["node_id"] for n in candidates}
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id, stream_id=stream_id,
preferred_token_id=preferred_token_id, node_ids=node_ids)
preferred_token_id=preferred_token_id, node_ids=node_ids,
recheck_denied=interactive)
if perms and any(p.get("status") == "bad_channel" for p in perms.values()):
raise StreamError("bad_channel", f"{_channel_ref(perms, channel_name)} is not a voice channel. "
"Radio can only play in a voice channel — pick one from the list.",
@@ -638,7 +672,7 @@ async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_i
# Bots that just turned out to be members get checked too.
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id,
stream_id=stream_id, preferred_token_id=preferred_token_id,
node_ids=node_ids)
node_ids=node_ids, recheck_denied=interactive)
res = await leases.lease_for_stream(stream_id, node_id=node["node_id"], org_id=org_id,
preferred_token_id=preferred_token_id, stream_fields=fields,
soft_preferred_token_id=soft_pref, perms=perms)