S8: main DRB bot optional end to end + owner-flow integration tests (server-26#225)
- Copy: picker, admin Servers/Streams/How-it-works, org Discord card, bot /help and the server-limit refusal no longer imply the DRB server bot is required; a radio bot alone is enough. - Admin overview: per-server main_bot_present, radio_bots, linked_via, directory_source; Servers tab gets a Bots column. - discord_web docstring: states the reversed "never calls Discord" rule (plan finding 5) and the optional server bot. - Server bot directory cog: re-push (which drops C2's cached permission answers) on overwrite changes, role permission changes and role deletes. - tests/test_discord_owner_flows.py: one-bot org (BYO bot, owner-set preferred, OAuth link, picker, start, reconciler) and full setup (server bot + 4 radio bots: placement, shared channel, every refusal explained), against a modelled Discord over httpx.MockTransport. - storage.download_audio removed: no caller on main since #233. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f49dc86111
commit
9c124d4c8b
@@ -0,0 +1,506 @@
|
||||
"""
|
||||
The two owner setups from server-26#225, end to end (S8).
|
||||
|
||||
1. One-bot org: no DRB server bot anywhere. A platform admin adds the org's
|
||||
own radio bot and its OAuth secret; the owner marks it preferred for the
|
||||
org's system, connects the Discord server through that bot's invite,
|
||||
opens the node page's picker and starts a stream.
|
||||
2. Full setup: the DRB server bot is in the server (directory push, /drb
|
||||
link, /join) beside several radio bots. C2 places each stream by
|
||||
membership, Discord permissions on the voice channel, one voice channel
|
||||
per bot per server and the system's preferred bot, lets several bots
|
||||
share one voice channel, and explains when nothing fits.
|
||||
|
||||
Every route handler here is the real one, and so is every module under it
|
||||
(discord_streams, discord_leases, discord_rest, discord_oauth,
|
||||
discord_guild_link, discord_perms). Only the edges are fake: Firestore is
|
||||
tests/fake_firestore.py, MQTT records what would be published, and Discord is
|
||||
``DiscordWorld`` behind httpx.MockTransport — a small model of servers, roles,
|
||||
channel overrides and bot applications that answers the same REST and OAuth2
|
||||
calls Discord does.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import discord_perms as perms
|
||||
from app.internal import discord_rest
|
||||
from app.internal import discord_streams as streams
|
||||
from app.routers import discord as discord_router
|
||||
from app.routers import discord_checks as checks
|
||||
from app.routers import discord_link as link_router
|
||||
from app.routers import discord_oauth as oauth_router
|
||||
from app.routers import discord_web as web
|
||||
from app.routers import tokens as tokens_router
|
||||
from tests.fake_firestore import FakeFirestore
|
||||
|
||||
ORG = "org-a"
|
||||
VOICE = perms.VIEW_CHANNEL | perms.CONNECT | perms.SPEAK
|
||||
REDIRECT = "https://drb.example.test/discord/connected"
|
||||
ADMIN = {"uid": "admin-1", "email": "admin@example.test", "role": "admin", "org_id": "founding"}
|
||||
OWNER = {"uid": "owner-a", "email": "owner@example.test", "org_id": ORG, "org_role": "owner"}
|
||||
SERVICE = {"service": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A small Discord
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class BotApp:
|
||||
name: str
|
||||
token: str
|
||||
user_id: str
|
||||
app_id: str
|
||||
secret: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Guild:
|
||||
gid: str
|
||||
name: str
|
||||
roles: dict = field(default_factory=dict) # role id -> permission int (@everyone id = gid)
|
||||
role_names: dict = field(default_factory=dict)
|
||||
channels: list = field(default_factory=list)
|
||||
members: dict = field(default_factory=dict) # user id -> [role ids]
|
||||
|
||||
|
||||
class DiscordWorld:
|
||||
"""Answers Discord REST (``Authorization: Bot …``) and OAuth2
|
||||
(``Authorization: Basic client_id:secret``) for the bots and servers in
|
||||
it. Adding a bot through the OAuth code exchange makes it a member, like
|
||||
the real "Authorize" click does."""
|
||||
|
||||
def __init__(self):
|
||||
self.bots: dict[str, BotApp] = {} # token -> app
|
||||
self.guilds: dict[str, Guild] = {}
|
||||
self.pending: dict[str, tuple] = {} # oauth code -> (app_id, guild_id)
|
||||
self.requests: list[httpx.Request] = []
|
||||
|
||||
# --- world building ---------------------------------------------------
|
||||
def bot(self, name: str, n: int) -> BotApp:
|
||||
b = BotApp(name=name, token=f"bot-token-{n}", user_id=f"9000{n}", app_id=f"7000{n}",
|
||||
secret=f"client-secret-{n}-0123456789abcdef")
|
||||
self.bots[b.token] = b
|
||||
return b
|
||||
|
||||
def guild(self, gid: str, name: str) -> Guild:
|
||||
g = Guild(gid=gid, name=name, roles={gid: VOICE}, role_names={gid: "@everyone"})
|
||||
self.guilds[gid] = g
|
||||
return g
|
||||
|
||||
def authorize(self, app: BotApp, gid: str) -> str:
|
||||
"""The owner clicks Authorize in Discord for ``gid``: Discord sends
|
||||
the browser back with a code."""
|
||||
code = f"code-{app.app_id}-{gid}"
|
||||
self.pending[code] = (app.app_id, gid)
|
||||
return code
|
||||
|
||||
def auth_kinds(self) -> list[str]:
|
||||
return [r.headers.get("authorization", "").split(" ", 1)[0] for r in self.requests]
|
||||
|
||||
def bot_tokens_used(self) -> set[str]:
|
||||
return {r.headers["authorization"].split(" ", 1)[1] for r in self.requests
|
||||
if r.headers.get("authorization", "").startswith("Bot ")}
|
||||
|
||||
# --- transport --------------------------------------------------------
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
self.requests.append(request)
|
||||
path = request.url.path.removeprefix("/api/v10")
|
||||
auth = request.headers.get("authorization", "")
|
||||
if path.startswith("/oauth2/token"):
|
||||
return self._oauth(request, path, auth)
|
||||
if not auth.startswith("Bot "):
|
||||
return httpx.Response(401, json={"code": 0, "message": "401: Unauthorized"})
|
||||
app = self.bots.get(auth[4:])
|
||||
if app is None:
|
||||
return httpx.Response(401, json={"code": 0, "message": "401: Unauthorized"})
|
||||
return self._rest(app, request, path)
|
||||
|
||||
def _oauth(self, request: httpx.Request, path: str, auth: str) -> httpx.Response:
|
||||
if not auth.startswith("Basic "):
|
||||
return httpx.Response(401, json={"error": "invalid_client"})
|
||||
client_id, _, secret = base64.b64decode(auth[6:]).decode().partition(":")
|
||||
app = next((a for a in self.bots.values() if a.app_id == client_id), None)
|
||||
if app is None or app.secret != secret:
|
||||
return httpx.Response(401, json={"error": "invalid_client"})
|
||||
if path == "/oauth2/token/revoke":
|
||||
return httpx.Response(200, json={})
|
||||
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
|
||||
if form.get("grant_type") == "client_credentials":
|
||||
return httpx.Response(200, json={"access_token": "cc-token", "token_type": "Bearer"})
|
||||
if form.get("grant_type") != "authorization_code" or form.get("redirect_uri") != REDIRECT:
|
||||
return httpx.Response(400, json={"error": "invalid_grant"})
|
||||
pending = self.pending.pop(form.get("code"), None)
|
||||
if not pending or pending[0] != client_id:
|
||||
return httpx.Response(400, json={"error": "invalid_grant"})
|
||||
g = self.guilds[pending[1]]
|
||||
g.members.setdefault(app.user_id, []) # the bot joins the server
|
||||
return httpx.Response(200, json={"access_token": "user-access-token", "token_type": "Bearer",
|
||||
"scope": "bot", "guild": {"id": g.gid, "name": g.name}})
|
||||
|
||||
def _rest(self, app: BotApp, request: httpx.Request, path: str) -> httpx.Response:
|
||||
parts = path.strip("/").split("/")
|
||||
if path == "/users/@me":
|
||||
return httpx.Response(200, json={"id": app.user_id, "username": app.name, "bot": True})
|
||||
if path == "/oauth2/applications/@me":
|
||||
return httpx.Response(200, json={"id": app.app_id, "name": app.name})
|
||||
if path == "/users/@me/guilds":
|
||||
after = int(request.url.params.get("after") or 0)
|
||||
mine = sorted((g for g in self.guilds.values() if app.user_id in g.members), key=lambda g: int(g.gid))
|
||||
return httpx.Response(200, json=[{"id": g.gid, "name": g.name} for g in mine if int(g.gid) > after])
|
||||
if parts[0] == "guilds" and len(parts) >= 2:
|
||||
g = self.guilds.get(parts[1])
|
||||
if g is None or app.user_id not in g.members:
|
||||
return httpx.Response(404, json={"code": discord_rest.UNKNOWN_GUILD, "message": "Unknown Guild"})
|
||||
if len(parts) == 2:
|
||||
return httpx.Response(200, json={
|
||||
"id": g.gid, "name": g.name, "owner_id": "human-owner",
|
||||
"roles": [{"id": rid, "name": g.role_names.get(rid, rid), "permissions": str(p)}
|
||||
for rid, p in g.roles.items()]})
|
||||
if parts[2] == "members" and len(parts) == 4:
|
||||
if parts[3] not in g.members:
|
||||
return httpx.Response(404, json={"code": discord_rest.UNKNOWN_MEMBER})
|
||||
return httpx.Response(200, json={"user": {"id": parts[3]}, "roles": g.members[parts[3]]})
|
||||
if parts[2] == "channels":
|
||||
return httpx.Response(200, json=g.channels)
|
||||
return httpx.Response(404, json={"code": 0, "message": "404: Not Found"})
|
||||
|
||||
|
||||
def voice(cid: str, name: str, position: int, *overwrites: dict, parent: Optional[str] = None) -> dict:
|
||||
return {"id": cid, "name": name, "type": 2, "position": position, "user_limit": 0,
|
||||
"parent_id": parent, "permission_overwrites": list(overwrites)}
|
||||
|
||||
|
||||
def deny(role_id: str, bits: int) -> dict:
|
||||
return {"id": role_id, "type": 0, "allow": "0", "deny": str(bits)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture: fake Firestore + MQTT + Discord, one org
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def env(monkeypatch):
|
||||
fake = FakeFirestore.install(monkeypatch)
|
||||
world = DiscordWorld()
|
||||
discord_rest.reset()
|
||||
monkeypatch.setattr(discord_rest, "_transport", httpx.MockTransport(world))
|
||||
published: list = []
|
||||
from app.internal.mqtt_handler import mqtt_handler
|
||||
monkeypatch.setattr(mqtt_handler, "publish_discord_desired", lambda n, p: published.append((n, p)) or True)
|
||||
monkeypatch.setattr(settings, "discord_oauth_redirect_uri", REDIRECT)
|
||||
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
|
||||
monkeypatch.setattr(settings, "discord_perm_check_enabled", True)
|
||||
streams._publish_locks.clear()
|
||||
streams._guild_locks.clear()
|
||||
checks._last_refresh.clear()
|
||||
web._directory_tasks.clear()
|
||||
for lim in (oauth_router.start_limiter, oauth_router.complete_limiter, link_router.mint_limiter,
|
||||
link_router.redeem_guild_limiter, link_router.redeem_user_limiter):
|
||||
lim._log.clear()
|
||||
fake.put("organizations", ORG, {"org_id": ORG, "name": "Riverton"})
|
||||
fake.put("org_members", OWNER["uid"], {"uid": OWNER["uid"], "org_id": ORG, "org_role": "owner"})
|
||||
yield fake, world, published
|
||||
discord_rest.reset()
|
||||
|
||||
|
||||
def _node(fake, nid: str, system_id: str):
|
||||
fake.put("nodes", nid, {"node_id": nid, "name": f"Node {nid}", "org_id": ORG, "status": "online",
|
||||
"approval_status": "approved", "assigned_system_id": system_id})
|
||||
|
||||
|
||||
def _body(resp):
|
||||
return json.loads(resp.body) if hasattr(resp, "body") else resp
|
||||
|
||||
|
||||
def _status(resp):
|
||||
return getattr(resp, "status_code", 200)
|
||||
|
||||
|
||||
def _no_secrets(world: DiscordWorld, *payloads) -> None:
|
||||
text = json.dumps(payloads, default=str)
|
||||
for app in world.bots.values():
|
||||
assert app.token not in text and app.secret not in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. One-bot org — no DRB server bot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_one_bot_org_links_lists_checks_and_streams_without_the_server_bot(env):
|
||||
fake, world, published = env
|
||||
fake.put("systems", "sys-a", {"system_id": "sys-a", "name": "Riverton P25", "org_id": ORG})
|
||||
_node(fake, "n1", "sys-a")
|
||||
fake.put("node_keys", "n1", {"api_key": "node-key-n1"})
|
||||
g = world.guild("400000000000000001", "Riverton FD")
|
||||
g.channels = [
|
||||
{"id": "400000000000000010", "name": "Radio", "type": 4, "position": 0, "permission_overwrites": []},
|
||||
voice("400000000000000011", "dispatch", 0, parent="400000000000000010"),
|
||||
{"id": "400000000000000012", "name": "general", "type": 0, "position": 1, "permission_overwrites": []},
|
||||
]
|
||||
riverton = world.bot("Riverton Radio", 1)
|
||||
# A pool bot exists too (no OAuth secret, not in the server): never chosen here.
|
||||
fake.put("bot_tokens", "pool-1", {"token_id": "pool-1", "name": "DRB Radio 1", "token": "pool-token",
|
||||
"org_id": None, "application_id": "60001", "guild_ids": [], "valid": True,
|
||||
"lease": None})
|
||||
|
||||
# Platform admin: add the org's own bot, then its OAuth client secret.
|
||||
added = await tokens_router.add_token(
|
||||
tokens_router.TokenCreate(name="Riverton Radio", token=riverton.token, org_id=ORG), ADMIN)
|
||||
tid = added["token_id"]
|
||||
assert added["valid"] is True and added["guild_ids"] == []
|
||||
out = await tokens_router.set_oauth_secret(tid, tokens_router.OAuthSecret(client_secret=riverton.secret), ADMIN)
|
||||
assert out["oauth_enabled"] is True
|
||||
|
||||
# Owner (Q2): their own bot, preferred for their own system.
|
||||
await tokens_router.set_preferred_system(tid, "sys-a", OWNER)
|
||||
assert fake.get("systems", "sys-a")["preferred_token_id"] == tid
|
||||
|
||||
# Owner: Connect a Discord server from the node — the preferred bot is invited.
|
||||
opts = await oauth_router.oauth_options(node_id="n1", token_id=None, org_id=None, decoded=OWNER)
|
||||
assert opts["bot"] == {"name": "Riverton Radio", "reason": "preferred", "own_bot": True}
|
||||
start = await oauth_router.oauth_start(oauth_router.StartBody(node_id="n1", return_to="/nodes/n1"),
|
||||
org_id=None, decoded=OWNER)
|
||||
q = {k: v[0] for k, v in parse_qs(urlparse(start["authorize_url"]).query).items()}
|
||||
assert q["client_id"] == riverton.app_id and q["scope"] == "bot" and q["redirect_uri"] == REDIRECT
|
||||
|
||||
# In Discord: pick Riverton FD, Authorize. Discord sends the browser back.
|
||||
code = world.authorize(riverton, g.gid)
|
||||
done = _body(await oauth_router.oauth_complete(oauth_router.CompleteBody(code=code, state=q["state"]),
|
||||
decoded=OWNER))
|
||||
assert done["ok"] is True and done["guild_id"] == g.gid and done["guild_name"] == "Riverton FD"
|
||||
assert done["return_to"] == "/nodes/n1" and done["bot_name"] == "Riverton Radio"
|
||||
# Permissions were checked in the same request, from the radio bot's view.
|
||||
assert done["checked"] is True and done["channels_ready"] == 1 and done["channels_blocked"] == 0
|
||||
assert [c["name"] for c in done["channels"]] == ["dispatch"]
|
||||
|
||||
link = fake.get("discord_guilds", g.gid)
|
||||
assert link["org_id"] == ORG and link["linked_via"] == "oauth"
|
||||
assert "bot_present" not in link # the server bot never said anything
|
||||
assert g.gid in fake.get("bot_tokens", tid)["guild_ids"]
|
||||
|
||||
# Node page: the server is listed, its channels read through the radio bot.
|
||||
opts = await web.join_options("n1", OWNER)
|
||||
[gopt] = opts["guilds"]
|
||||
assert gopt["guild_id"] == g.gid and gopt["linked"] is True
|
||||
assert gopt["main_bot_present"] is False and gopt["directory_source"] == "radio_bot"
|
||||
assert [c["name"] for c in gopt["voice_channels"]] == ["dispatch"]
|
||||
assert gopt["bot"]["ok"] is True
|
||||
vc = await checks.node_voice_check("n1", g.gid, refresh=0, channel_id=None, decoded=OWNER)
|
||||
[row] = vc["channels"]
|
||||
assert row["state"] == "ready" and [b["name"] for b in row["bots"]] == ["Riverton Radio"]
|
||||
|
||||
# Start: the preferred bot carries it, on this node.
|
||||
started = await web.start_node_stream(
|
||||
"n1", web.NodeStreamStart(guild_id=g.gid, channel_id="400000000000000011", mode="persistent"), OWNER)
|
||||
assert _status(started) == 200, _body(started)
|
||||
assert started["token_id"] == tid and started["delivered"] is True
|
||||
stream = fake.get("discord_streams", started["stream_id"])
|
||||
assert stream["node_id"] == "n1" and stream["guild_name"] == "Riverton FD"
|
||||
assert stream["channel_name"] == "dispatch" and stream["mode"] == "persistent"
|
||||
node_id, payload = published[-1]
|
||||
assert node_id == "n1"
|
||||
assert [(s["stream_id"], s["token_id"]) for s in payload["streams"]] == [(started["stream_id"], tid)]
|
||||
|
||||
# The node picks the token string up with its own key — the only place it leaves C2.
|
||||
got = await discord_router.node_token(
|
||||
"n1", tid, HTTPAuthorizationCredentials(scheme="Bearer", credentials="node-key-n1"))
|
||||
assert got["token"] == riverton.token
|
||||
|
||||
# Nothing but this org's radio bot ever talked to Discord, and nothing a
|
||||
# browser saw carries a token or a client secret.
|
||||
assert world.bot_tokens_used() == {riverton.token}
|
||||
assert set(world.auth_kinds()) <= {"Bot", "Basic"}
|
||||
_no_secrets(world, opts, vc, done, started, start, await tokens_router.list_tokens(ADMIN))
|
||||
|
||||
|
||||
async def test_one_bot_org_reconciler_replaces_the_stream_without_the_server_bot(env):
|
||||
"""Persistent stream, node lost: the reconciler re-places it on another
|
||||
node of the same system with the same radio bot — no server bot involved."""
|
||||
fake, world, published = env
|
||||
fake.put("systems", "sys-a", {"system_id": "sys-a", "name": "Riverton P25", "org_id": ORG})
|
||||
_node(fake, "n1", "sys-a")
|
||||
_node(fake, "n2", "sys-a")
|
||||
g = world.guild("400000000000000001", "Riverton FD")
|
||||
g.channels = [voice("400000000000000011", "dispatch", 0)]
|
||||
riverton = world.bot("Riverton Radio", 1)
|
||||
g.members[riverton.user_id] = []
|
||||
fake.put("bot_tokens", "own", {"token_id": "own", "name": "Riverton Radio", "token": riverton.token,
|
||||
"org_id": ORG, "application_id": riverton.app_id, "bot_user_id": riverton.user_id,
|
||||
"guild_ids": [g.gid], "valid": True, "lease": None})
|
||||
fake.put("discord_guilds", g.gid, {"guild_id": g.gid, "org_id": ORG, "linked_via": "oauth"})
|
||||
|
||||
started = await web.start_node_stream(
|
||||
"n1", web.NodeStreamStart(guild_id=g.gid, channel_id="400000000000000011", mode="persistent"), OWNER)
|
||||
sid = started["stream_id"]
|
||||
# n1 goes away for good (past the lease grace).
|
||||
fake.put("nodes", "n1", {**fake.get("nodes", "n1"), "status": "offline",
|
||||
"last_seen": "2026-01-01T00:00:00+00:00"})
|
||||
await streams.reconcile_once()
|
||||
s = fake.get("discord_streams", sid)
|
||||
assert s["desired"] == "on" and s["node_id"] == "n2" and s["token_id"] == "own"
|
||||
assert world.bot_tokens_used() == {riverton.token}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Full setup — DRB server bot + several radio bots
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GID = "500000000000000001"
|
||||
DISPATCH, FIREGROUND, LOBBY = "500000000000000011", "500000000000000012", "500000000000000013"
|
||||
MUTED = "500000000000000099"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def full(env):
|
||||
fake, world, published = env
|
||||
for sid, name in (("sys-a", "County P25"), ("sys-b", "City Fire"), ("sys-c", "EMS")):
|
||||
fake.put("systems", sid, {"system_id": sid, "name": name, "org_id": ORG})
|
||||
_node(fake, "n1", "sys-a")
|
||||
_node(fake, "n2", "sys-b")
|
||||
_node(fake, "n3", "sys-c")
|
||||
g = world.guild(GID, "Station 7")
|
||||
g.roles[MUTED], g.role_names[MUTED] = 0, "Muted"
|
||||
g.channels = [
|
||||
voice(DISPATCH, "dispatch", 0, deny(MUTED, perms.SPEAK)), # @Muted can't Speak here
|
||||
voice(FIREGROUND, "fireground", 1, deny(GID, perms.CONNECT)), # nobody can Connect here
|
||||
voice(LOBBY, "lobby", 2),
|
||||
]
|
||||
bots = {}
|
||||
for n, (tid, name, org, member, roles) in enumerate((
|
||||
("own-a", "Riverton Radio", ORG, True, [MUTED]),
|
||||
("pool-1", "DRB Radio 1", None, True, []),
|
||||
("pool-2", "DRB Radio 2", None, True, []),
|
||||
("pool-3", "DRB Radio 3", None, False, []),
|
||||
), start=1):
|
||||
app = world.bot(name, n)
|
||||
if member:
|
||||
g.members[app.user_id] = roles
|
||||
fake.put("bot_tokens", tid, {"token_id": tid, "name": name, "token": app.token, "org_id": org,
|
||||
"application_id": app.app_id, "bot_user_id": app.user_id,
|
||||
"guild_ids": [GID] if member else [], "valid": True, "lease": None})
|
||||
bots[tid] = app
|
||||
# A platform admin pinned DRB Radio 2 to City Fire.
|
||||
fake.put("systems", "sys-b", {**fake.get("systems", "sys-b"), "preferred_token_id": "pool-2"})
|
||||
fake.bots = bots
|
||||
return fake, world, published
|
||||
|
||||
|
||||
async def _server_bot_sets_up(fake):
|
||||
"""The DRB server bot is in Station 7: it pushes its channel list, and
|
||||
someone with Manage Server runs /drb link with the owner's code."""
|
||||
await web.put_guild_directory(GID, web.GuildDirectory(name="Station 7", voice_channels=[
|
||||
web.VoiceChannelIn(channel_id=DISPATCH, name="dispatch", category="Radio", position=0),
|
||||
web.VoiceChannelIn(channel_id=FIREGROUND, name="fireground", category="Radio", position=1),
|
||||
web.VoiceChannelIn(channel_id=LOBBY, name="lobby", category="Radio", position=2),
|
||||
]), SERVICE)
|
||||
minted = await link_router.create_link_code(org_id=None, decoded=OWNER)
|
||||
out = _body(await link_router.redeem_link_code(
|
||||
GID, link_router.RedeemBody(code=minted["code"], discord_user_id="123456", guild_name="Station 7"),
|
||||
SERVICE))
|
||||
assert out["ok"] is True and out["org_id"] == ORG
|
||||
|
||||
|
||||
async def test_full_setup_places_each_stream_and_explains_every_refusal(full):
|
||||
fake, world, published = full
|
||||
await _server_bot_sets_up(fake)
|
||||
link = fake.get("discord_guilds", GID)
|
||||
assert link["org_id"] == ORG and link["bot_present"] is True and link["directory_source"] == "main_bot"
|
||||
|
||||
# Before anything plays: nobody may Connect in #fireground. Every member
|
||||
# bot is named with what removed the permission; the bot that isn't in
|
||||
# the server yet is offered as an invite.
|
||||
r = await web.start_node_stream("n1", web.NodeStreamStart(guild_id=GID, channel_id=FIREGROUND), OWNER)
|
||||
body = _body(r)
|
||||
assert _status(r) == 409 and body["code"] == "missing_permissions"
|
||||
assert sorted(d["name"] for d in body["denied"]) == ["DRB Radio 1", "DRB Radio 2", "Riverton Radio"]
|
||||
assert all(d["missing"][0] == "Connect" for d in body["denied"])
|
||||
assert body["detail"].startswith("No radio bot can play in #fireground:")
|
||||
assert "#fireground's override for @everyone" in body["detail"]
|
||||
assert [i["name"] for i in body["invite"]] == ["DRB Radio 3"]
|
||||
assert fake.data.get("discord_streams") in (None, {})
|
||||
|
||||
# /join County P25 in #dispatch (DRB server bot): the org's own bot comes
|
||||
# first but @Muted can't Speak there, so DRB Radio 1 takes it.
|
||||
a = _body(await discord_router.start_stream(discord_router.StreamStart(
|
||||
guild_id=GID, channel_id=DISPATCH, system_id="sys-a", channel_name="dispatch", created_by="111"), SERVICE))
|
||||
assert a["token_id"] == "pool-1" and a["node_id"] == "n1"
|
||||
|
||||
# /join City Fire in the SAME channel: its preferred bot, beside the first.
|
||||
b = _body(await discord_router.start_stream(discord_router.StreamStart(
|
||||
guild_id=GID, channel_id=DISPATCH, system_id="sys-b", channel_name="dispatch", created_by="111"), SERVICE))
|
||||
assert b["token_id"] == "pool-2" and b["node_id"] == "n2" and b["stream_id"] != a["stream_id"]
|
||||
on_dispatch = [s for s in fake.data["discord_streams"].values()
|
||||
if s["channel_id"] == DISPATCH and s["desired"] == "on"]
|
||||
assert sorted(s["token_id"] for s in on_dispatch) == ["pool-1", "pool-2"]
|
||||
|
||||
# EMS on #dispatch from the web picker: both pool bots already hold a
|
||||
# voice channel in this server, and the org's own bot is muted there.
|
||||
vc = await checks.node_voice_check("n3", GID, refresh=0, channel_id=DISPATCH, decoded=OWNER)
|
||||
[row] = vc["channels"]
|
||||
assert row["state"] == "needs_fix"
|
||||
by_name = {b["name"]: b for b in row["bots"]}
|
||||
assert by_name["DRB Radio 1"]["unavailable_reason"] == "busy_here"
|
||||
assert by_name["Riverton Radio"]["missing"] == ["Speak"]
|
||||
assert "@Muted" in by_name["Riverton Radio"]["reason"]
|
||||
r = await web.start_node_stream("n3", web.NodeStreamStart(guild_id=GID, channel_id=DISPATCH), OWNER)
|
||||
body = _body(r)
|
||||
assert _status(r) == 409 and body["code"] == "missing_permissions"
|
||||
assert body["detail"].startswith("Riverton Radio can't Speak in #dispatch:")
|
||||
assert "Edit Channel → Permissions → add Riverton Radio → allow Speak" in body["detail"]
|
||||
|
||||
# EMS on #lobby: nothing overrides @Muted there, so the org's own bot plays it.
|
||||
c = await web.start_node_stream("n3", web.NodeStreamStart(guild_id=GID, channel_id=LOBBY), OWNER)
|
||||
assert _status(c) == 200 and c["token_id"] == "own-a"
|
||||
|
||||
# Naming a bot that already holds a channel here is refused, not swapped.
|
||||
r = await discord_router.start_stream(discord_router.StreamStart(
|
||||
guild_id=GID, channel_id=LOBBY, system_id="sys-a", preferred_token_id="pool-1"), SERVICE)
|
||||
body = _body(r)
|
||||
assert _status(r) == 409 and body["code"] == "preferred_busy"
|
||||
assert "DRB Radio 1 is already playing County P25 in #dispatch" in body["detail"]
|
||||
|
||||
# Each node was told only about its own stream, by token id.
|
||||
last = {n: p for n, p in published}
|
||||
assert {n: [s["token_id"] for s in p["streams"]] for n, p in last.items()} == {
|
||||
"n1": ["pool-1"], "n2": ["pool-2"], "n3": ["own-a"]}
|
||||
|
||||
# /status and the admin page see both kinds of bot.
|
||||
ov = await discord_router.guild_overview(GID, SERVICE)
|
||||
assert ov["linked"] is True and len(ov["streams"]) == 3
|
||||
adm = await discord_router.admin_overview(ADMIN)
|
||||
[ga] = [x for x in adm["guilds"] if x["guild_id"] == GID]
|
||||
assert ga["main_bot_present"] is True and ga["linked_via"] == "code"
|
||||
assert ga["radio_bots"] == ["DRB Radio 1", "DRB Radio 2", "Riverton Radio"]
|
||||
# Only radio bots ever called Discord; the server bot works over the gateway.
|
||||
assert world.bot_tokens_used() <= {app.token for app in fake.bots.values()}
|
||||
_no_secrets(world, ov, adm, vc)
|
||||
|
||||
|
||||
async def test_full_setup_server_bot_leaving_keeps_radio_working(full):
|
||||
"""The DRB server bot is kicked: the server stays usable through its
|
||||
radio bots (picker, permission check, start) — the main bot is optional."""
|
||||
fake, world, published = full
|
||||
await _server_bot_sets_up(fake)
|
||||
await web.put_guild_directory(GID, web.GuildDirectory(present=False), SERVICE)
|
||||
assert fake.get("discord_guilds", GID)["bot_present"] is False
|
||||
|
||||
opts = await web.join_options("n1", OWNER)
|
||||
[gopt] = opts["guilds"]
|
||||
assert gopt["main_bot_present"] is False
|
||||
assert [c["name"] for c in gopt["voice_channels"]] == ["dispatch", "fireground", "lobby"]
|
||||
c = await web.start_node_stream("n1", web.NodeStreamStart(guild_id=GID, channel_id=LOBBY), OWNER)
|
||||
assert _status(c) == 200 and c["token_id"] == "own-a"
|
||||
adm = await discord_router.admin_overview(ADMIN)
|
||||
[ga] = [x for x in adm["guilds"] if x["guild_id"] == GID]
|
||||
assert ga["main_bot_present"] is False and len(ga["radio_bots"]) == 3
|
||||
Reference in New Issue
Block a user