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
+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)