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:
Logan Cusano
2026-10-05 03:24:44 -04:00
co-authored by Claude Opus 5.5
parent f49dc86111
commit 9c124d4c8b
13 changed files with 652 additions and 60 deletions
+3 -1
View File
@@ -441,7 +441,9 @@ ERROR_TEXT = {
"pool_exhausted": ("Every radio bot that can join this server is already busy — a bot can be in only one voice "
"channel per server, and a bot carrying radio on another node can't be shared. Add another "
"radio bot to DRB and invite it to this server, or stop a radio channel here."),
"limit": "This server already has its maximum number of radio channels. Stop one with /leave first.",
# Reaches the web picker and /join alike: name both ways to stop one.
"limit": "This server already has its maximum number of radio channels. Stop one first (Stop on a node's "
"Discord radio panel, or /leave in Discord).",
"preferred_unavailable": "That radio bot can't be used here (it was removed, failed validation, or belongs to "
"another organization). Pick another bot, or let DRB choose any free one.",
"pool_full": ("No radio bot is in this server, and every DRB radio bot that could be invited is already in its "
+4 -24
View File
@@ -100,31 +100,11 @@ async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optiona
return gcs_uri
async def download_audio(gcs_uri: str) -> Optional[bytes]:
"""Fetch an object back out of GCS. Server-side read — no signing involved."""
bucket_name, blob_path = split_gcs_uri(gcs_uri)
if not bucket_name:
return None
def _download() -> bytes:
from google.cloud import storage
if settings.gcp_credentials_path:
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
else:
client = storage.Client()
return client.bucket(bucket_name).blob(blob_path).download_as_bytes()
try:
return await asyncio.to_thread(_download)
except Exception as e:
logger.warning(f"GCS download failed for {gcs_uri}: {e}")
return None
# ── Ranged reads for /media playback (server-26#233) ────────────────────────
# A 600 s FLAC call is ~13 MB. download_audio() above pulls the whole object
# into memory, so /media used to make the browser wait for all of it before
# the first sample played. These two read only what a request asks for: the
# A 600 s FLAC call is ~13 MB. /media used to pull the whole object into
# memory (the old download_audio(), removed once nothing called it — #225 S8),
# so the browser waited for all of it before the first sample played. These
# two read only what a request asks for: the
# object size (one metadata GET) and then an inclusive byte span, which the
# router streams out in chunks. One cached client, because the router issues
# several span reads per playback and a fresh Client() per read re-resolves
+17 -1
View File
@@ -269,6 +269,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
Radio bots come from ``GET /tokens``; no token string is returned here.
Discord user ids (``created_by_discord_id``) and the linking admin's uid
are deliberately left out.
Per server, ``main_bot_present`` (the DRB server bot — optional since
server-26#225: True/False as it last reported, None = never heard from it
there) and ``radio_bots`` (names of radio bots Discord last said are in
it). A server with radio bots and no server bot is a normal setup.
"""
links = {str(g.get("guild_id")): g for g in await fstore.collection_list(streams.GUILDS)
if g.get("guild_id")}
@@ -284,6 +289,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
continue # removed from DRB (DELETE /discord/guilds/{id}); re-listed if it gets a channel again
guilds[gid] = {"guild_id": gid, "guild_name": link.get("guild_name"), "linked": bool(link.get("org_id")),
"org_id": link.get("org_id"), "linked_at": link.get("linked_at"),
"linked_via": link.get("linked_via"),
# The DRB server bot is optional (server-26#225 S8): True/False
# = it said it is / is not in the server, None = never heard.
"main_bot_present": link.get("bot_present"),
"directory_source": link.get("directory_source"),
# Explicitly unlinked servers get no fallback org (mirrors
# streams.resolve_guild_org once server-26#218 lands).
"_no_fallback": bool(link.get("unlinked_at"))}
@@ -291,7 +301,8 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
for s in all_streams:
gid = str(s.get("guild_id") or leases.guild_of(s.get("stream_id") or ""))
g = guilds.setdefault(gid, {"guild_id": gid, "guild_name": None, "linked": False,
"org_id": None, "linked_at": None})
"org_id": None, "linked_at": None, "linked_via": None,
"main_bot_present": None, "directory_source": None})
if s.get("guild_name") and not g.get("guild_name"):
g["guild_name"] = s["guild_name"]
node = nodes.get(s.get("node_id")) or {}
@@ -305,6 +316,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
g["acting_org_id"] = acting
g["acting_org_name"] = (orgs.get(acting) or {}).get("name") if acting else None
g["live_streams"] = sum(1 for r in rows if r.get("guild_id") == g["guild_id"] and r.get("desired") == "on")
# Radio bots Discord last said are in the server (names only). Any one
# of them is enough to list channels, check permissions and stream.
g["radio_bots"] = sorted(str(t.get("name") or tid) for tid, t in tokens.items()
if t.get("valid") is not False and isinstance(t.get("guild_ids"), list)
and g["guild_id"] in t["guild_ids"])
# Streams sharing a voice channel sit together, oldest first.
rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "",
+14 -3
View File
@@ -21,9 +21,20 @@ Where the directory comes from (server-26#225 S5 — the main bot is optional):
stored list is missing (inline, inside the permission-check time budget)
or older than ``discord_directory_refresh_seconds`` (in the background).
So C2 does call Discord when a signed-in user opens the picker — with a
radio bot's token, for a server already linked to the caller's org, through
the rate-limited, cached client. No token string ever reaches the browser.
This reverses the old rule "C2 never calls Discord on the browser's behalf"
on purpose (server-26#225 plan finding 5): C2 does call Discord when a
signed-in user opens the picker, presses Start or Re-check — with a radio
bot's token, only for a server already linked to the caller's org, through
discord_rest's rate-limited, cached, breaker-guarded client (refresh=1 at
most once per 10 s per user and server). No token string ever reaches the
browser, and REST answers never create or re-link a ``discord_guilds`` doc.
The DRB server bot is optional end to end (server-26#225 S8). One radio bot
in the server is enough to link it (per-bot OAuth, routers/discord_oauth.py),
list its voice channels (here), check permissions (routers/discord_checks.py)
and stream (streams.start_stream). The server bot adds its slash commands
(/join, /leave, /status, /drb link) and a gateway-fed channel list; every
path it uses keeps working.
Which servers an org sees (``_org_guilds``):
@@ -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