Merge pull request 'Discord: channel list and presence without the main bot (#225 S5, fixes #251)' (#262) from feat/225-s5-channels into main
This commit was merged in pull request #262.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user