Discord: channel list and presence without the main bot (server-26#225 S5)

A server linked to an org now shows in the node picker while a radio bot the
org may use is in it, even when the DRB server bot is absent or left. Its
voice-channel list is read from that radio bot's cached REST snapshot
(discord_rest.guild_directory, the same snapshot the permission check reads,
so no extra Discord request): inline when no list is stored, in the
background when the list is older than discord_directory_refresh_seconds.
The server bot's own fresh list still wins (back-compat) and its directory
push now drops the server's cached snapshots.

Guards: a server acting only through the unlinked-server fallback org still
needs the server bot; REST never creates a discord_guilds doc, never writes
org_id or bot_present, and never writes onto a server unlinked meanwhile.

Also fixes server-26#251: an interactive start (/join, admin page, web
picker, node discord_join) re-reads a cached "denied" snapshot older than
10 s once before refusing, so "fix it in Discord, then try again" works on
the next try. The reconciler never re-reads (no REST loop).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 02:53:57 -04:00
co-authored by Claude Opus 5.5
parent e54191a5d0
commit a976464e72
8 changed files with 746 additions and 25 deletions
+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)