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
+392
View File
@@ -0,0 +1,392 @@
"""
Channel list and presence without the main bot (server-26#225 S5), plus the
interactive re-check of a cached "denied" answer (server-26#251).
* discord_rest.directory_from_snapshot / guild_directory: a radio bot's REST
snapshot becomes the same directory the DRB server bot pushes.
* _org_guilds: a linked server stays visible with only a radio bot in it; a
fallback-org server still needs the server bot; REST never discovers a
server or creates a ``discord_guilds`` doc.
* join_options / start_node_stream: a missing list is read inline from a
member radio bot, a stale one in the background; the server bot's fresh
list is never overwritten; nothing is written onto a server that was
unlinked meanwhile.
* start_stream(interactive=True) re-reads a "denied" snapshot older than
RECHECK_DENIED_AFTER_SECONDS once; the reconciler never does.
"""
import asyncio
import time
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import HTTPException
from app.config import settings
from app.internal import discord_rest
from app.internal import discord_streams as streams
from app.routers import discord as discord_router
from app.models import CommandPayload
from app.routers import discord_web as web
from app.routers import nodes as nodes_router
from tests.test_discord_perm_check import ( # noqa: F401 (fs = fixture)
ADMIN, GUILD, ORG, OTHER, OWNER, SYS, _guild, _snap, _token, fs, run,
)
LINKED = "300" # linked to ORG, never seen by the server bot
def _ago(seconds: float) -> str:
return (datetime.now(timezone.utc) - timedelta(seconds=seconds)).isoformat()
def _rest_channels():
return [
{"id": "9", "name": "Radio", "type": 4, "position": 1},
{"id": "8", "name": "Admin", "type": 4, "position": 0},
{"id": "3001", "name": "dispatch", "type": 2, "position": 1, "parent_id": "9", "permission_overwrites": []},
{"id": "3002", "name": "fireground", "type": 2, "position": 0, "parent_id": "9", "permission_overwrites": []},
{"id": "3003", "name": "staff", "type": 2, "position": 0, "parent_id": "8", "permission_overwrites": []},
{"id": "3004", "name": "lobby", "type": 2, "position": 5, "permission_overwrites": []},
{"id": "3005", "name": "general", "type": 0, "position": 0, "permission_overwrites": []},
{"id": "3006", "name": "town hall", "type": 13, "position": 0, "permission_overwrites": []},
]
def _rest_snap(tok_id, guild_id=LINKED, status=discord_rest.OK):
if status != discord_rest.OK:
return discord_rest.GuildSnapshot(token_id=tok_id, guild_id=guild_id, status=status, error="x")
return discord_rest.GuildSnapshot(
token_id=tok_id, guild_id=guild_id, status=discord_rest.OK, bot_user_id=f"u-{tok_id}",
guild={**_guild(), "id": guild_id, "name": "Station 9"}, member={"roles": []},
channels=_rest_channels())
@pytest.fixture
def dfs(fs, monkeypatch):
"""The perm-check fixture plus a linked server only radio bots know."""
fs.put("discord_guilds", LINKED, {"guild_id": LINKED, "org_id": ORG, "linked_at": _ago(60)})
fs.dir_mode = {} # token_id -> "ok" | "not_member" | "unknown"
async def _snapshot(token_doc, guild_id, *, refresh=False):
tid = token_doc["token_id"]
fs.snap_calls.append((tid, guild_id, refresh))
if guild_id != LINKED:
mode = fs.snap_mode.get(tid, "ok")
return _snap(tid, muted=(mode == "muted"))
mode = fs.dir_mode.get(tid, "ok")
status = {"ok": discord_rest.OK, "not_member": discord_rest.NOT_MEMBER}.get(mode, discord_rest.UNKNOWN)
return _rest_snap(tid, status=status)
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
web._directory_tasks.clear()
return fs
async def _drain():
while web._directory_tasks:
await asyncio.gather(*list(web._directory_tasks.values()), return_exceptions=True)
def _ids(opts):
return [g["guild_id"] for g in opts["guilds"]]
# ---------------------------------------------------------------------------
# discord_rest — directory from a snapshot
# ---------------------------------------------------------------------------
def test_directory_from_snapshot_matches_the_server_bots_shape_and_order():
d = discord_rest.directory_from_snapshot(_rest_snap("t1"))
assert d["name"] == "Station 9"
assert [(c["channel_id"], c["category"], c["position"]) for c in d["voice_channels"]] == [
("3004", None, 0), # no category first
("3003", "Admin", 1), # categories by position
("3002", "Radio", 2), # channels by position inside one
("3001", "Radio", 3),
] # text and stage channels are not listed
assert set(d["voice_channels"][0]) == {"channel_id", "name", "category", "position"}
def test_directory_from_snapshot_is_none_unless_ok():
assert discord_rest.directory_from_snapshot(None) is None
for status in (discord_rest.NOT_MEMBER, discord_rest.UNKNOWN, discord_rest.INVALID):
assert discord_rest.directory_from_snapshot(_rest_snap("t1", status=status)) is None
def test_directory_caps_channels_and_name_length():
snap = _rest_snap("t1")
snap.channels = [{"id": str(5000 + i), "name": "x" * 300, "type": 2, "position": i} for i in range(250)]
d = discord_rest.directory_from_snapshot(snap)
assert len(d["voice_channels"]) == discord_rest.DIRECTORY_MAX_CHANNELS
assert len(d["voice_channels"][0]["name"]) == 100
async def test_guild_directory_tries_the_next_bot_and_reports_not_member(dfs):
_token(dfs, "a", guilds=(LINKED,))
_token(dfs, "b", guilds=(LINKED,))
docs = [{**dfs.get("bot_tokens", t)} for t in ("a", "b")]
dfs.dir_mode["a"] = "not_member"
got = await discord_rest.guild_directory(docs, LINKED)
assert got.status == discord_rest.OK and got.token_id == "b" and len(got.voice_channels) == 4
dfs.dir_mode["b"] = "not_member"
assert (await discord_rest.guild_directory(docs, LINKED)).status == discord_rest.NOT_MEMBER
dfs.dir_mode["b"] = "unknown"
assert (await discord_rest.guild_directory(docs, LINKED)).status == discord_rest.UNKNOWN
assert (await discord_rest.guild_directory([], LINKED)).status == discord_rest.UNKNOWN
# ---------------------------------------------------------------------------
# Which servers an org sees
# ---------------------------------------------------------------------------
def test_linked_server_with_only_a_radio_bot_is_listed_with_its_channels(dfs):
"""Owner requirement 1: invite only the radio bot, no main bot."""
_token(dfs, "byo", org=ORG, guilds=(LINKED,))
opts = run(web.join_options("n1", OWNER))
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
assert g["synced"] is True and g["directory_source"] == "radio_bot" and g["main_bot_present"] is False
assert [c["name"] for c in g["voice_channels"]] == ["lobby", "staff", "fireground", "dispatch"]
assert g["name"] == "Station 9" and g["linked"] is True and g["bot"]["ok"] is True
doc = dfs.get("discord_guilds", LINKED)
assert doc["org_id"] == ORG and "bot_present" not in doc # never the server bot's field
assert doc["radio_bot_present"] is True and doc["directory_source"] == "radio_bot"
assert doc["guild_name"] == "Station 9" and len(doc["voice_channels"]) == 4
assert "secret" not in repr(opts)
def test_linked_server_without_any_radio_bot_keeps_todays_behaviour(dfs):
opts = run(web.join_options("n1", OWNER))
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
assert g["synced"] is False and g["bot"]["ok"] is False # invite links, as before
assert not [c for c in dfs.snap_calls if c[1] == LINKED]
@pytest.mark.parametrize("tok", [
{"org": OTHER}, # another org's branded bot
{"guilds": None}, # membership never checked
{"valid": False},
])
def test_only_a_usable_member_bot_keeps_a_server_without_the_main_bot(dfs, tok):
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "bot_present": False})
kw = {"guilds": (LINKED,), **tok}
_token(dfs, "x", **kw)
assert LINKED not in _ids(run(web.join_options("n1", OWNER)))
assert not [c for c in dfs.snap_calls if c[1] == LINKED]
def test_server_bot_leaving_keeps_server_while_a_radio_bot_is_in_it(dfs):
_token(dfs, "t1", guilds=(GUILD,))
run(web.put_guild_directory(GUILD, web.GuildDirectory(present=False), {"service": True}))
opts = run(web.join_options("n1", OWNER))
assert GUILD in _ids(opts)
g = next(g for g in opts["guilds"] if g["guild_id"] == GUILD)
assert g["main_bot_present"] is False
def test_fallback_server_is_never_discovered_by_a_radio_bot(dfs, monkeypatch):
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", ORG)
dfs.put("discord_guilds", "400", {"guild_id": "400", "bot_present": False, "voice_channels": []})
_token(dfs, "t1", guilds=("400", "401", LINKED)) # also in a server DRB has no doc for
ids = _ids(run(web.join_options("n1", OWNER)))
assert "400" not in ids and "401" not in ids
assert dfs.get("discord_guilds", "401") is None # REST never creates a doc
assert not [c for c in dfs.snap_calls if c[1] in ("400", "401")]
def test_fallback_server_list_is_never_read_over_rest(dfs, monkeypatch):
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", ORG)
dfs.put("discord_guilds", "400", {"guild_id": "400", "bot_present": True, "voice_channels": None,
"directory_at": _ago(99999)})
_token(dfs, "t1", guilds=("400",))
assert "400" in _ids(run(web.join_options("n1", OWNER)))
assert not [c for c in dfs.snap_calls if c[1] == "400"]
# ---------------------------------------------------------------------------
# When the list is read, and who wins
# ---------------------------------------------------------------------------
def test_fresh_main_bot_list_is_never_read_over_rest(dfs):
_token(dfs, "t1", guilds=(LINKED,))
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "bot_present": True,
"directory_source": "main_bot", "directory_at": _ago(600),
"voice_channels": [{"channel_id": "3001", "name": "dispatch"}]})
opts = run(web.join_options("n1", OWNER))
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
assert g["directory_source"] == "main_bot" and len(g["voice_channels"]) == 1
assert not [c for c in dfs.snap_calls if c[1] == LINKED]
def test_stale_list_is_refreshed_in_the_background(dfs):
_token(dfs, "t1", guilds=(LINKED,))
old = [{"channel_id": "3001", "name": "old name"}]
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "voice_channels": old,
"directory_source": "radio_bot",
"directory_at": _ago(settings.discord_directory_refresh_seconds + 5)})
async def go():
opts = await web.join_options("n1", OWNER)
await _drain()
return opts
opts = run(go())
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
assert [c["name"] for c in g["voice_channels"]] == ["old name"] # stored list shown meanwhile
assert len(dfs.get("discord_guilds", LINKED)["voice_channels"]) == 4 # and replaced right after
def test_dead_main_bot_list_is_taken_over_by_a_radio_bot(dfs):
_token(dfs, "t1", guilds=(LINKED,))
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "bot_present": True,
"directory_at": _ago(web.MAIN_BOT_STALE_SECONDS + 60),
"voice_channels": [{"channel_id": "3001", "name": "dispatch"}]})
async def go():
await web.join_options("n1", OWNER)
await _drain()
run(go())
doc = dfs.get("discord_guilds", LINKED)
assert doc["directory_source"] == "radio_bot" and len(doc["voice_channels"]) == 4
assert doc["bot_present"] is True # not ours to change
def test_not_member_marks_presence_and_keeps_the_list(dfs):
_token(dfs, "t1", guilds=(LINKED,))
keep = [{"channel_id": "3001", "name": "dispatch"}]
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "voice_channels": keep,
"directory_source": "radio_bot", "directory_at": _ago(99999)})
dfs.dir_mode["t1"] = "not_member"
async def go():
await web.join_options("n1", OWNER)
await _drain()
run(go())
doc = dfs.get("discord_guilds", LINKED)
assert doc["radio_bot_present"] is False and doc["voice_channels"] == keep
def test_refresh_never_writes_onto_a_server_unlinked_meanwhile(dfs, monkeypatch):
_token(dfs, "t1", guilds=(LINKED,))
real = discord_rest.guild_directory
async def _unlink_then_answer(docs, guild_id, **kw):
dfs.put("discord_guilds", LINKED, {"guild_id": LINKED, "org_id": None, "unlinked_at": _ago(0)})
return await real(docs, guild_id, **kw)
monkeypatch.setattr(discord_rest, "guild_directory", _unlink_then_answer)
g = dict(dfs.get("discord_guilds", LINKED), org_id=ORG)
assert run(web._refresh_directory(g, ORG, {"t1": dfs.get("bot_tokens", "t1")})) is None
assert "voice_channels" not in dfs.get("discord_guilds", LINKED)
def test_slow_discord_never_holds_the_picker_past_the_budget(dfs, monkeypatch):
_token(dfs, "t1", guilds=(LINKED,))
monkeypatch.setattr(settings, "discord_perm_check_budget_seconds", 0.05)
async def _slow(docs, guild_id, **kw):
await asyncio.sleep(5)
monkeypatch.setattr(discord_rest, "guild_directory", _slow)
async def go():
t0 = time.monotonic()
opts = await web.join_options("n1", OWNER)
return opts, time.monotonic() - t0
opts, took = run(go())
assert took < 1.0
assert next(g for g in opts["guilds"] if g["guild_id"] == LINKED)["synced"] is False
def test_directory_push_marks_main_bot_and_drops_cached_snapshots(dfs, monkeypatch):
dropped = []
monkeypatch.setattr(discord_rest, "invalidate", lambda token_id=None, guild_id=None: dropped.append(
(token_id, guild_id)) or 0)
run(web.put_guild_directory(LINKED, web.GuildDirectory(name="S9", voice_channels=[
web.VoiceChannelIn(channel_id="3001", name="dispatch")]), {"service": True}))
doc = dfs.get("discord_guilds", LINKED)
assert doc["directory_source"] == "main_bot" and doc["bot_present"] is True and doc["org_id"] == ORG
assert dropped == [(None, LINKED)]
# ---------------------------------------------------------------------------
# Starting from the picker without the main bot
# ---------------------------------------------------------------------------
def test_start_checks_the_channel_against_the_radio_bots_list(dfs):
_token(dfs, "byo", org=ORG, guilds=(LINKED,))
doc = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=LINKED, channel_id="3001"), OWNER))
assert doc["ok"] is True and doc["token_id"] == "byo"
s = dfs.get("discord_streams", doc["stream_id"])
assert s["channel_name"] == "dispatch" and s["guild_name"] == "Station 9" and s["node_id"] == "n1"
def test_start_refuses_a_channel_the_radio_bot_does_not_list(dfs):
_token(dfs, "byo", org=ORG, guilds=(LINKED,))
for bad in ("3005", "3999"): # a text channel; no such channel
with pytest.raises(HTTPException) as e:
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=LINKED, channel_id=bad), OWNER))
assert e.value.status_code == 400
# ---------------------------------------------------------------------------
# server-26#251 — an interactive start re-reads a stale "denied"
# ---------------------------------------------------------------------------
@pytest.fixture
def aging(fs, monkeypatch):
"""Snapshots that are ``fs.age`` seconds old unless refreshed; ``muted``
until ``fs.fixed`` (the owner fixed the permission in Discord)."""
fs.age, fs.fixed = 60.0, False
async def _snapshot(token_doc, guild_id, *, refresh=False):
tid = token_doc["token_id"]
fs.snap_calls.append((tid, guild_id, refresh))
s = _snap(tid, muted=not (refresh and fs.fixed))
s.fetched_at = time.time() - (0 if refresh else fs.age)
return s
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
_token(fs, "t1", name="DRB Radio 2")
return fs
def _join():
return discord_router.start_stream(discord_router.StreamStart(guild_id=GUILD, channel_id="1001",
system_id=SYS), {"service": True})
def test_join_after_fixing_the_permission_succeeds_at_once(aging):
aging.fixed = True
r = run(_join())
assert r["token_id"] == "t1"
assert ("t1", GUILD, True) in aging.snap_calls
def test_still_denied_after_the_recheck_is_refused(aging):
r = run(_join())
assert r.status_code == 409 and b"can't Speak in #dispatch" in r.body
assert [c for c in aging.snap_calls if c[2]] == [("t1", GUILD, True)] # once
def test_a_fresh_denied_answer_is_not_asked_again(aging):
aging.age = 1.0
aging.fixed = True
r = run(_join())
assert r.status_code == 409
assert not [c for c in aging.snap_calls if c[2]]
async def test_reconciler_never_rechecks(aging):
aging.fixed = True
with pytest.raises(streams.StreamError) as e:
await streams.start_stream(org_id=ORG, guild_id=GUILD, channel_id="1001", system_id=SYS)
assert e.value.code == "missing_permissions"
assert not [c for c in aging.snap_calls if c[2]]
def test_web_picker_start_is_interactive(aging):
aging.fixed = True
doc = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=GUILD, channel_id="1001"), OWNER))
assert doc["ok"] is True and doc["token_id"] == "t1"
def test_node_discord_join_command_is_interactive(aging):
aging.fixed = True
out = run(nodes_router.send_command("n1", CommandPayload(action="discord_join", guild_id=GUILD,
channel_id="1001"), ADMIN))
assert out["ok"] is True and out["token_id"] == "t1"
+4 -1
View File
@@ -80,7 +80,10 @@ def test_directory_push_keeps_org_link(fs):
assert [c["channel_id"] for c in g["voice_channels"]] == ["1003"]
def test_directory_bot_removed_hides_guild(fs):
def test_directory_bot_removed_hides_guild_without_a_radio_bot(fs):
# server-26#225 S5: the server bot leaving hides a linked server only when
# no radio bot the org may use is in it either.
fs.put("bot_tokens", "t1", {**fs.get("bot_tokens", "t1"), "guild_ids": ["200"]})
run(web.put_guild_directory("100", web.GuildDirectory(present=False), {"service": True}))
opts = run(web.join_options("n1", OWNER))
assert opts["guilds"] == []