Discord: main DRB bot optional end to end + owner-flow integration tests (#225 S8) #271

Merged
logan merged 1 commits from feat/225-s8-main-bot-optional into main 2026-10-05 03:29:56 -04:00
13 changed files with 652 additions and 60 deletions
Showing only changes of commit 9c124d4c8b - Show all commits
+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
+8 -2
View File
@@ -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."
>
<p className="px-4 pt-4 text-xs text-ink-2">
<b className="text-ink">The DRB server bot is optional.</b> One radio bot in a Discord server is enough: adding it
through <i>Connect a Discord server</i> (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.
</p>
<div className="grid gap-2 p-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
{steps.map(([t, d]) => (
<div key={t} className="bg-raised border border-line rounded-lg p-3 text-xs text-ink-muted">
@@ -329,12 +329,13 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
<div className="px-5 py-3 border-t border-line bg-raised text-xs text-ink-muted">
{restricted ? (
<>Unlinked servers see only <b className="text-ink">/drb link</b> and <b className="text-ink">/help</b>. </>
<>Where the DRB server bot is present, an unlinked server sees only <b className="text-ink">/drb link</b> and <b className="text-ink">/help</b>. </>
) : isAdmin ? (
<>Platform setting: unlinked servers still act for the default organization (<span className="font-mono">DISCORD_UNLINKED_GUILD_ORG_ID</span>) until linked. </>
) : null}
Every command and autocomplete is scoped to the linked organization — no server sees another
organization&apos;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&apos;s nodes, systems or bots. The DRB server bot is optional: a radio bot
alone is enough to stream.
</div>
</Card>
);
+12 -8
View File
@@ -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({
<legend className="text-xs text-ink-muted mb-2">Discord server</legend>
{usable.length === 0 ? (
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
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 → <b className="text-ink">Connect a Discord server</b>: adding a radio bot
to the server links it, and its voice channels show up here.
</p>
) : (
<div className="space-y-1.5">
@@ -259,12 +262,13 @@ function DiscordPicker({
<legend className="text-xs text-ink-muted mb-2">Voice channel</legend>
{!guild.synced ? (
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
The DRB bot has not reported this server&apos;s channels yet. It does when it starts and every 15
minutes — check back shortly.
DRB hasn&apos;t read this server&apos;s voice channels yet. A radio bot in the server lists them
(or the DRB server bot, if it&apos;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.
</p>
) : guild.voice_channels.length === 0 ? (
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
This server has no voice channels the DRB bot can see.
This server has no voice channels DRB&apos;s bots can see.
</p>
) : (
<div className="space-y-2">
@@ -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 (
<>
<div>
{radio.length > 0 ? (
<span title={radio.join(", ")}>
{radio.length} radio bot{radio.length === 1 ? "" : "s"}
</span>
) : (
<span className={main ? DIM : "text-sev-moderate"}>no radio bot</span>
)}
</div>
<div className={DIM}>
DRB server bot: {main === true ? "present" : main === false ? "not there" : "never seen"}
{guild.directory_source === "radio_bot" && " · channels via radio bot"}
</div>
</>
);
}
type RemoveResult = { guild: string; streams: number; bots: { name: string; left: boolean; error: string | null }[] };
function RemoveForm({
@@ -131,7 +159,7 @@ function RemoveForm({
</span>
</label>
<p className="text-ink-muted">
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.
</p>
{error && <p className="text-sev-major">{error}</p>}
<div className="flex gap-2">
@@ -171,8 +199,9 @@ export function ServersPanel({
fallback ? (
<>
<b className="text-ink">Unlinked servers act for {fallbackName}</b> (DISCORD_UNLINKED_GUILD_ORG_ID). Any
server the DRB bot is invited to can use that org&apos;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&apos;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&apos;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.
<button className="ml-3 text-accent hover:underline" onClick={() => setRemoved(null)}>Dismiss</button>
</div>
)}
{overview.guilds.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-muted">No Discord servers yet. Link one, or run a radio command in a server the DRB bot is in.</p>
<p className="px-4 py-6 text-sm text-ink-muted">
No Discord servers yet. Link one here, or an organization owner connects one under Settings → Organization.
</p>
) : (
<Table head={["Server", "Acts for", "Radio channels", "Linked", "Status", ""]}>
<Table head={["Server", "Acts for", "Bots", "Radio channels", "Linked", "Status", ""]}>
{overview.guilds.flatMap((g) => {
const row = (
<tr key={g.guild_id}>
@@ -208,6 +239,9 @@ export function ServersPanel({
{g.acting_org_name || g.acting_org_id || <span className={DIM}>nobody</span>}
{!g.linked && g.acting_org_id && <div className={DIM}>fallback, not linked</div>}
</td>
<td className={TD}>
<BotsCell guild={g} />
</td>
<td className={TD}>
{g.live_streams} / {max}
</td>
@@ -251,7 +285,7 @@ export function ServersPanel({
return [
row,
<tr key={`${g.guild_id}-remove`}>
<td colSpan={6} className="p-0 border-b border-line">
<td colSpan={7} className="p-0 border-b border-line">
<RemoveForm
guild={g}
onCancel={() => setRemoving(null)}
@@ -269,7 +303,7 @@ export function ServersPanel({
return [
row,
<tr key={`${g.guild_id}-edit`}>
<td colSpan={6} className="p-0 border-b border-line">
<td colSpan={7} className="p-0 border-b border-line">
<LinkForm overview={overview} guild={g} onDone={done} onCancel={() => setEditing(null)} />
</td>
</tr>,
@@ -348,7 +348,8 @@ export function StreamsPanel({
)}
{overview.streams.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-muted">
No radio channels yet. Start one here, or run <span className="font-mono">/join</span> in a linked Discord server.
No radio channels yet. Start one here or from a node&apos;s Discord radio panel, or run{" "}
<span className="font-mono">/join</span> where the DRB server bot is in a linked Discord server.
</p>
) : (
<Table head={["Server · channel", "Source", "Talkgroups", "Mode", "Desired → actual", "Bot", ""]}>
+8
View File
@@ -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 {
@@ -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))
+6 -2
View File
@@ -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,