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:
@@ -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"
|
||||
@@ -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"] == []
|
||||
|
||||
Reference in New Issue
Block a user