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
@@ -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