From 9c124d4c8b8fa815bfe8f4ef1ecf3db946b96bff Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 5 Oct 2026 03:24:44 -0400 Subject: [PATCH] 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 --- drb-c2-core/app/internal/discord_streams.py | 4 +- drb-c2-core/app/internal/storage.py | 28 +- drb-c2-core/app/routers/discord.py | 18 +- drb-c2-core/app/routers/discord_web.py | 17 +- drb-c2-core/tests/test_discord_owner_flows.py | 506 ++++++++++++++++++ drb-frontend/app/settings/discord/page.tsx | 10 +- .../components/DiscordServersCard.tsx | 7 +- drb-frontend/components/NodeDiscordRadio.tsx | 20 +- .../components/discord/ServersPanel.tsx | 54 +- .../components/discord/StreamsPanel.tsx | 3 +- drb-frontend/lib/discord.ts | 8 + .../app/commands/directory.py | 29 +- drb-server-discord-bot/app/commands/radio.py | 8 +- 13 files changed, 652 insertions(+), 60 deletions(-) create mode 100644 drb-c2-core/tests/test_discord_owner_flows.py diff --git a/drb-c2-core/app/internal/discord_streams.py b/drb-c2-core/app/internal/discord_streams.py index 7f27239..dac1c4f 100644 --- a/drb-c2-core/app/internal/discord_streams.py +++ b/drb-c2-core/app/internal/discord_streams.py @@ -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 " diff --git a/drb-c2-core/app/internal/storage.py b/drb-c2-core/app/internal/storage.py index deea51f..5c820ea 100644 --- a/drb-c2-core/app/internal/storage.py +++ b/drb-c2-core/app/internal/storage.py @@ -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 diff --git a/drb-c2-core/app/routers/discord.py b/drb-c2-core/app/routers/discord.py index 4851249..f6a5a7e 100644 --- a/drb-c2-core/app/routers/discord.py +++ b/drb-c2-core/app/routers/discord.py @@ -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 "", diff --git a/drb-c2-core/app/routers/discord_web.py b/drb-c2-core/app/routers/discord_web.py index 6d09b76..37b72f9 100644 --- a/drb-c2-core/app/routers/discord_web.py +++ b/drb-c2-core/app/routers/discord_web.py @@ -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``): diff --git a/drb-c2-core/tests/test_discord_owner_flows.py b/drb-c2-core/tests/test_discord_owner_flows.py new file mode 100644 index 0000000..20f0e50 --- /dev/null +++ b/drb-c2-core/tests/test_discord_owner_flows.py @@ -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 diff --git a/drb-frontend/app/settings/discord/page.tsx b/drb-frontend/app/settings/discord/page.tsx index 067da9f..84652f1 100644 --- a/drb-frontend/app/settings/discord/page.tsx +++ b/drb-frontend/app/settings/discord/page.tsx @@ -43,8 +43,8 @@ function Kpi({ k, v, s }: { k: string; v: string; s: string }) { function HowItWorks() { const steps = [ - ["1 · Desired", "/join in Discord or this page writes a discord_streams doc."], - ["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server, in one transaction."], + ["1 · Desired", "A node page's picker, this page, or /join (DRB server bot) writes a discord_streams doc."], + ["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server that Discord lets View, Connect and Speak in the channel — the system's preferred bot first — in one transaction."], ["3 · Reconcile", "A retained MQTT message tells the node its full channel list (token ids, never token strings)."], ["4 · Voice", "The node logs in as the leased bot and plays its PulseAudio capture — never Icecast."], ["5 · Actual", "The node reports each channel's real state; this page and the Discord reply show it."], @@ -54,6 +54,12 @@ function HowItWorks() { title="How a radio channel stays up" note="Node reboot, C2 sweep, Discord voice drop: the node re-reads its desired state and reconnects; persistent channels whose node went away are re-placed on another node. A channel desired-on but not connected for 2 minutes turns red on the Radio channels tab." > +

+ The DRB server bot is optional. One radio bot in a Discord server is enough: adding it + through Connect a Discord server (Settings → Organization) links the server, and C2 reads its voice + channels and permissions with that bot. The server bot adds the slash commands (/join, /leave, /status, + /drb link) and pushes its own channel list; both ways keep working side by side. +

{steps.map(([t, d]) => (
diff --git a/drb-frontend/components/DiscordServersCard.tsx b/drb-frontend/components/DiscordServersCard.tsx index 47f435c..960ca58 100644 --- a/drb-frontend/components/DiscordServersCard.tsx +++ b/drb-frontend/components/DiscordServersCard.tsx @@ -329,12 +329,13 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
{restricted ? ( - <>Unlinked servers see only /drb link and /help. + <>Where the DRB server bot is present, an unlinked server sees only /drb link and /help. ) : isAdmin ? ( <>Platform setting: unlinked servers still act for the default organization (DISCORD_UNLINKED_GUILD_ORG_ID) until linked. ) : null} - Every command and autocomplete is scoped to the linked organization — no server sees another - organization's nodes, systems or bots. + Radio channels, and every DRB server bot command and autocomplete, are scoped to the linked organization — no + server sees another organization's nodes, systems or bots. The DRB server bot is optional: a radio bot + alone is enough to stream.
); diff --git a/drb-frontend/components/NodeDiscordRadio.tsx b/drb-frontend/components/NodeDiscordRadio.tsx index bdb148e..06bcc8b 100644 --- a/drb-frontend/components/NodeDiscordRadio.tsx +++ b/drb-frontend/components/NodeDiscordRadio.tsx @@ -2,9 +2,11 @@ // Node page "Discord radio" panel + picker (server-26#213 item 3, #109 item 13). // Replaces the raw Guild ID / Voice Channel ID modal. The picker lists only -// the Discord servers linked to this node's org and the voice channels the -// DRB bot reported in them; a pick becomes a radio channel pinned to this node -// (desired state — the node reconciles and reports back, shown per row). +// the Discord servers linked to this node's org and their voice channels, as +// read by a radio bot in the server or pushed by the DRB server bot — either +// is enough; the server bot is optional (server-26#225). A pick becomes a +// radio channel pinned to this node (desired state — the node reconciles and +// reports back, shown per row). // A voice channel can carry several radio streams, each its own bot playing // its own system (server-26#213 follow-up): picking a channel that already // plays another system ADDS this node's system beside it; only a stream of @@ -211,8 +213,9 @@ function DiscordPicker({ Discord server {usable.length === 0 ? (

- No Discord server is linked to your organization yet. Add the DRB bot to your Discord server and - link it to your organization; it shows up here once the bot reports its channels. + No Discord server is linked to your organization yet. The organization owner connects one under + Settings → Organization → Connect a Discord server: adding a radio bot + to the server links it, and its voice channels show up here.

) : (
@@ -259,12 +262,13 @@ function DiscordPicker({ Voice channel {!guild.synced ? (

- The DRB bot has not reported this server's channels yet. It does when it starts and every 15 - minutes — check back shortly. + DRB hasn't read this server's voice channels yet. A radio bot in the server lists them + (or the DRB server bot, if it's there) — close this and open it again in a moment. If it + stays empty, no radio bot is in this server yet — invite one with the link under the server above.

) : guild.voice_channels.length === 0 ? (

- This server has no voice channels the DRB bot can see. + This server has no voice channels DRB's bots can see.

) : (
diff --git a/drb-frontend/components/discord/ServersPanel.tsx b/drb-frontend/components/discord/ServersPanel.tsx index 351f8a6..fcf1ea5 100644 --- a/drb-frontend/components/discord/ServersPanel.tsx +++ b/drb-frontend/components/discord/ServersPanel.tsx @@ -8,8 +8,12 @@ import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui"; // Linked Discord servers = discord_guilds docs, plus any server that has had a // radio channel without being linked (it acts for DISCORD_UNLINKED_GUILD_ORG_ID). -// Linking is PUT /discord/guilds/{id}/link (platform admin). The owner-side -// "/drb link code:" flow is server-26#213 item 1 and is not built here. +// Linking here is PUT /discord/guilds/{id}/link (platform admin, support). +// Org owners link their own from Settings → Organization: "Connect a Discord +// server" (a radio bot's OAuth invite, no DRB server bot needed) or a +// /drb link code (needs the server bot) — server-26#213 item 1, #225. +// The Bots column says which bots are in each server: the DRB server bot is +// optional (server-26#225 S8), one radio bot is enough to stream. // Remove is DELETE /discord/guilds/{id}: its radio channels are stopped then // deleted, and the server is unlinked with no fallback org. @@ -85,6 +89,30 @@ function LinkForm({ ); } +// Which bots are in the server. The DRB server bot is optional (server-26#225): +// "not there" is only a problem when no radio bot is either. +function BotsCell({ guild }: { guild: DiscordGuild }) { + const radio = guild.radio_bots ?? []; + const main = guild.main_bot_present; + return ( + <> +
+ {radio.length > 0 ? ( + + {radio.length} radio bot{radio.length === 1 ? "" : "s"} + + ) : ( + no radio bot + )} +
+
+ DRB server bot: {main === true ? "present" : main === false ? "not there" : "never seen"} + {guild.directory_source === "radio_bot" && " · channels via radio bot"} +
+ + ); +} + type RemoveResult = { guild: string; streams: number; bots: { name: string; left: boolean; error: string | null }[] }; function RemoveForm({ @@ -131,7 +159,7 @@ function RemoveForm({

- The DRB bot itself stays in the Discord server until someone kicks it there. + The DRB server bot, if it is in the Discord server, stays until someone kicks it there.

{error &&

{error}

}
@@ -171,8 +199,9 @@ export function ServersPanel({ fallback ? ( <> Unlinked servers act for {fallbackName} (DISCORD_UNLINKED_GUILD_ORG_ID). Any - server the DRB bot is invited to can use that org's systems until it is linked here. Set the variable - to an empty string once every real server is linked. + server the DRB server bot is invited to can use that org's systems until it is linked here (a radio + bot alone never puts an unlinked server here). Set the variable to an empty string once every real server + is linked. ) : ( <>Unlinked servers can't use radio commands until they are linked here. @@ -189,14 +218,16 @@ export function ServersPanel({ {removed.bots.map((b) => `${b.name} ${b.left ? "left" : `did not leave (${b.error ?? "unknown error"})`}`).join("; ")}. )}{" "} - The DRB bot is still in that Discord server until someone kicks it. + The DRB server bot, if it was there, stays in that Discord server until someone kicks it.
)} {overview.guilds.length === 0 ? ( -

No Discord servers yet. Link one, or run a radio command in a server the DRB bot is in.

+

+ No Discord servers yet. Link one here, or an organization owner connects one under Settings → Organization. +

) : ( - +
{overview.guilds.flatMap((g) => { const row = ( @@ -208,6 +239,9 @@ export function ServersPanel({ {g.acting_org_name || g.acting_org_id || nobody} {!g.linked && g.acting_org_id &&
fallback, not linked
} + @@ -251,7 +285,7 @@ export function ServersPanel({ return [ row, - - , diff --git a/drb-frontend/components/discord/StreamsPanel.tsx b/drb-frontend/components/discord/StreamsPanel.tsx index 4b5a03a..41e9a82 100644 --- a/drb-frontend/components/discord/StreamsPanel.tsx +++ b/drb-frontend/components/discord/StreamsPanel.tsx @@ -348,7 +348,8 @@ export function StreamsPanel({ )} {overview.streams.length === 0 ? (

- No radio channels yet. Start one here, or run /join in a linked Discord server. + No radio channels yet. Start one here or from a node's Discord radio panel, or run{" "} + /join where the DRB server bot is in a linked Discord server.

) : (
+ + {g.live_streams} / {max}
+ setRemoving(null)} @@ -269,7 +303,7 @@ export function ServersPanel({ return [ row,
+ setEditing(null)} />
diff --git a/drb-frontend/lib/discord.ts b/drb-frontend/lib/discord.ts index 24b8c7a..53867f3 100644 --- a/drb-frontend/lib/discord.ts +++ b/drb-frontend/lib/discord.ts @@ -85,6 +85,14 @@ export interface DiscordGuild { acting_org_id: string | null; acting_org_name: string | null; live_streams: number; + /** How it was linked: "admin" | "code" | "oauth" (absent on older links). */ + linked_via?: string | null; + /** The DRB server bot (optional, server-26#225): true/false as it last reported, null = never heard. */ + main_bot_present?: boolean | null; + /** Who listed its voice channels: "main_bot" | "radio_bot". */ + directory_source?: string | null; + /** Radio bots Discord last said are in this server (names). */ + radio_bots?: string[]; } export interface DiscordAdminOverview { diff --git a/drb-server-discord-bot/app/commands/directory.py b/drb-server-discord-bot/app/commands/directory.py index cb680b7..843488e 100644 --- a/drb-server-discord-bot/app/commands/directory.py +++ b/drb-server-discord-bot/app/commands/directory.py @@ -4,15 +4,22 @@ Server directory push (server-26#213 item 3). The web node page picks a Discord server + voice channel instead of taking raw IDs. It needs each server's name and voice channels, and this bot already has them in its gateway cache (default intents include ``guilds``), so it pushes -them to C2 (PUT /discord/guilds/{id}/directory, service key). C2 never calls -Discord on the browser's behalf and no token reaches the browser. +them to C2 (PUT /discord/guilds/{id}/directory, service key). + +This bot is optional (server-26#225): in a server it is not in, C2 reads the +same list over Discord REST with a radio bot's token (routers/discord_web.py). +Where it is present, its push wins while fresh and also tells C2 to drop its +cached Discord answers for the server, so a permission fixed in Discord shows +up on the next check instead of after the cache expires. No token ever +reaches the browser either way. Pushed: server name, and per voice channel its id, name, category name and order. Nothing about members, messages or roles. When: every server on ready, on join/leave/rename, on any voice-channel or -category create/delete/update (debounced per server), and a full re-push every -15 minutes in case C2 missed one (it was down, or redeployed). +category create/delete/update — including a permission-overwrite change — and +on a role permission change or role delete (debounced per server), and a full +re-push every 15 minutes in case C2 missed one (it was down, or redeployed). """ import asyncio from typing import Optional @@ -123,10 +130,22 @@ class DirectorySync(commands.Cog): @commands.Cog.listener() async def on_guild_channel_update(self, before: discord.abc.GuildChannel, after: discord.abc.GuildChannel): + # Overwrites too: the push makes C2 drop its cached permission answers + # for this server (server-26#225), so "Re-check" sees the fix at once. if _relevant(after) and (before.name != after.name or before.position != after.position - or getattr(before, "category_id", None) != getattr(after, "category_id", None)): + or getattr(before, "category_id", None) != getattr(after, "category_id", None) + or before.overwrites != after.overwrites): self.schedule(after.guild) + @commands.Cog.listener() + async def on_guild_role_update(self, before: discord.Role, after: discord.Role): + if before.permissions != after.permissions: + self.schedule(after.guild) + + @commands.Cog.listener() + async def on_guild_role_delete(self, role: discord.Role): + self.schedule(role.guild) + async def setup(bot: commands.Bot): await bot.add_cog(DirectorySync(bot)) diff --git a/drb-server-discord-bot/app/commands/radio.py b/drb-server-discord-bot/app/commands/radio.py index 23e3ec5..4914d04 100644 --- a/drb-server-discord-bot/app/commands/radio.py +++ b/drb-server-discord-bot/app/commands/radio.py @@ -284,7 +284,9 @@ class RadioCommands(commands.Cog): value=( "Link this server to your DRB organization — needed before anything below works. " "An organization owner gets the code in DRB under Settings → Organization → Discord servers; " - "someone with Manage Server runs this." + "someone with Manage Server runs this.\n" + "Or skip this bot entirely: **Connect a Discord server** on that same page adds a radio bot " + "and links the server in one step, and radio channels can be started from a node's page in DRB." ), inline=False, ) @@ -314,7 +316,9 @@ class RadioCommands(commands.Cog): name="Radio bots", value=( "A radio bot has to be a member of this server before it can join a channel — " - "if none is, `/join` replies with an invite link.\n" + "if none is, `/join` replies with an invite link. It needs View Channel, Connect and Speak " + "there; if it can't, `/join` says which permission is missing and where to allow it.\n" + "Radio bots work without this bot: it only adds these commands.\n" "The bot's green speaking ring lights up only while radio is transmitting." ), inline=False,