Merge pull request 'Discord: main DRB bot optional end to end + owner-flow integration tests (#225 S8)' (#271) from feat/225-s8-main-bot-optional into main
This commit was merged in pull request #271.
This commit is contained in:
@@ -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 "
|
"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 "
|
"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."),
|
"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 "
|
"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.",
|
"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 "
|
"pool_full": ("No radio bot is in this server, and every DRB radio bot that could be invited is already in its "
|
||||||
|
|||||||
@@ -100,31 +100,11 @@ async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optiona
|
|||||||
return gcs_uri
|
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) ────────────────────────
|
# ── Ranged reads for /media playback (server-26#233) ────────────────────────
|
||||||
# A 600 s FLAC call is ~13 MB. download_audio() above pulls the whole object
|
# A 600 s FLAC call is ~13 MB. /media used to pull the whole object into
|
||||||
# into memory, so /media used to make the browser wait for all of it before
|
# memory (the old download_audio(), removed once nothing called it — #225 S8),
|
||||||
# the first sample played. These two read only what a request asks for: the
|
# 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
|
# 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
|
# 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
|
# several span reads per playback and a fresh Client() per read re-resolves
|
||||||
|
|||||||
@@ -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.
|
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
|
Discord user ids (``created_by_discord_id``) and the linking admin's uid
|
||||||
are deliberately left out.
|
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)
|
links = {str(g.get("guild_id")): g for g in await fstore.collection_list(streams.GUILDS)
|
||||||
if g.get("guild_id")}
|
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
|
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")),
|
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"),
|
"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
|
# Explicitly unlinked servers get no fallback org (mirrors
|
||||||
# streams.resolve_guild_org once server-26#218 lands).
|
# streams.resolve_guild_org once server-26#218 lands).
|
||||||
"_no_fallback": bool(link.get("unlinked_at"))}
|
"_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:
|
for s in all_streams:
|
||||||
gid = str(s.get("guild_id") or leases.guild_of(s.get("stream_id") or ""))
|
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,
|
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"):
|
if s.get("guild_name") and not g.get("guild_name"):
|
||||||
g["guild_name"] = s["guild_name"]
|
g["guild_name"] = s["guild_name"]
|
||||||
node = nodes.get(s.get("node_id")) or {}
|
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_id"] = acting
|
||||||
g["acting_org_name"] = (orgs.get(acting) or {}).get("name") if acting else None
|
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")
|
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.
|
# 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 "",
|
rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "",
|
||||||
|
|||||||
@@ -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)
|
stored list is missing (inline, inside the permission-check time budget)
|
||||||
or older than ``discord_directory_refresh_seconds`` (in the background).
|
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
|
This reverses the old rule "C2 never calls Discord on the browser's behalf"
|
||||||
radio bot's token, for a server already linked to the caller's org, through
|
on purpose (server-26#225 plan finding 5): C2 does call Discord when a
|
||||||
the rate-limited, cached client. No token string ever reaches the browser.
|
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``):
|
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
|
||||||
@@ -43,8 +43,8 @@ function Kpi({ k, v, s }: { k: string; v: string; s: string }) {
|
|||||||
|
|
||||||
function HowItWorks() {
|
function HowItWorks() {
|
||||||
const steps = [
|
const steps = [
|
||||||
["1 · Desired", "/join in Discord or this page writes a discord_streams doc."],
|
["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, in one transaction."],
|
["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)."],
|
["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."],
|
["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."],
|
["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"
|
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."
|
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">
|
<div className="grid gap-2 p-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
{steps.map(([t, d]) => (
|
{steps.map(([t, d]) => (
|
||||||
<div key={t} className="bg-raised border border-line rounded-lg p-3 text-xs text-ink-muted">
|
<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">
|
<div className="px-5 py-3 border-t border-line bg-raised text-xs text-ink-muted">
|
||||||
{restricted ? (
|
{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 ? (
|
) : isAdmin ? (
|
||||||
<>Platform setting: unlinked servers still act for the default organization (<span className="font-mono">DISCORD_UNLINKED_GUILD_ORG_ID</span>) until linked. </>
|
<>Platform setting: unlinked servers still act for the default organization (<span className="font-mono">DISCORD_UNLINKED_GUILD_ORG_ID</span>) until linked. </>
|
||||||
) : null}
|
) : null}
|
||||||
Every command and autocomplete is scoped to the linked organization — no server sees another
|
Radio channels, and every DRB server bot command and autocomplete, are scoped to the linked organization — no
|
||||||
organization's nodes, systems or bots.
|
server sees another organization's nodes, systems or bots. The DRB server bot is optional: a radio bot
|
||||||
|
alone is enough to stream.
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
// Node page "Discord radio" panel + picker (server-26#213 item 3, #109 item 13).
|
// 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
|
// 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
|
// the Discord servers linked to this node's org and their voice channels, as
|
||||||
// DRB bot reported in them; a pick becomes a radio channel pinned to this node
|
// read by a radio bot in the server or pushed by the DRB server bot — either
|
||||||
// (desired state — the node reconciles and reports back, shown per row).
|
// 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
|
// 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
|
// 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
|
// 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>
|
<legend className="text-xs text-ink-muted mb-2">Discord server</legend>
|
||||||
{usable.length === 0 ? (
|
{usable.length === 0 ? (
|
||||||
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
|
<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
|
No Discord server is linked to your organization yet. The organization owner connects one under
|
||||||
link it to your organization; it shows up here once the bot reports its channels.
|
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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -259,12 +262,13 @@ function DiscordPicker({
|
|||||||
<legend className="text-xs text-ink-muted mb-2">Voice channel</legend>
|
<legend className="text-xs text-ink-muted mb-2">Voice channel</legend>
|
||||||
{!guild.synced ? (
|
{!guild.synced ? (
|
||||||
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
|
<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's channels yet. It does when it starts and every 15
|
DRB hasn't read this server's voice channels yet. A radio bot in the server lists them
|
||||||
minutes — check back shortly.
|
(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.
|
||||||
</p>
|
</p>
|
||||||
) : guild.voice_channels.length === 0 ? (
|
) : guild.voice_channels.length === 0 ? (
|
||||||
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
|
<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's bots can see.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<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
|
// 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).
|
// 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
|
// Linking here is PUT /discord/guilds/{id}/link (platform admin, support).
|
||||||
// "/drb link code:" flow is server-26#213 item 1 and is not built here.
|
// 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
|
// Remove is DELETE /discord/guilds/{id}: its radio channels are stopped then
|
||||||
// deleted, and the server is unlinked with no fallback org.
|
// 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 }[] };
|
type RemoveResult = { guild: string; streams: number; bots: { name: string; left: boolean; error: string | null }[] };
|
||||||
|
|
||||||
function RemoveForm({
|
function RemoveForm({
|
||||||
@@ -131,7 +159,7 @@ function RemoveForm({
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-ink-muted">
|
<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>
|
</p>
|
||||||
{error && <p className="text-sev-major">{error}</p>}
|
{error && <p className="text-sev-major">{error}</p>}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -171,8 +199,9 @@ export function ServersPanel({
|
|||||||
fallback ? (
|
fallback ? (
|
||||||
<>
|
<>
|
||||||
<b className="text-ink">Unlinked servers act for {fallbackName}</b> (DISCORD_UNLINKED_GUILD_ORG_ID). Any
|
<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's systems until it is linked here. Set the variable
|
server the DRB server bot is invited to can use that org's systems until it is linked here (a radio
|
||||||
to an empty string once every real server is linked.
|
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.</>
|
<>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("; ")}.
|
{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>
|
<button className="ml-3 text-accent hover:underline" onClick={() => setRemoved(null)}>Dismiss</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{overview.guilds.length === 0 ? (
|
{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) => {
|
{overview.guilds.flatMap((g) => {
|
||||||
const row = (
|
const row = (
|
||||||
<tr key={g.guild_id}>
|
<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.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>}
|
{!g.linked && g.acting_org_id && <div className={DIM}>fallback, not linked</div>}
|
||||||
</td>
|
</td>
|
||||||
|
<td className={TD}>
|
||||||
|
<BotsCell guild={g} />
|
||||||
|
</td>
|
||||||
<td className={TD}>
|
<td className={TD}>
|
||||||
{g.live_streams} / {max}
|
{g.live_streams} / {max}
|
||||||
</td>
|
</td>
|
||||||
@@ -251,7 +285,7 @@ export function ServersPanel({
|
|||||||
return [
|
return [
|
||||||
row,
|
row,
|
||||||
<tr key={`${g.guild_id}-remove`}>
|
<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
|
<RemoveForm
|
||||||
guild={g}
|
guild={g}
|
||||||
onCancel={() => setRemoving(null)}
|
onCancel={() => setRemoving(null)}
|
||||||
@@ -269,7 +303,7 @@ export function ServersPanel({
|
|||||||
return [
|
return [
|
||||||
row,
|
row,
|
||||||
<tr key={`${g.guild_id}-edit`}>
|
<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)} />
|
<LinkForm overview={overview} guild={g} onDone={done} onCancel={() => setEditing(null)} />
|
||||||
</td>
|
</td>
|
||||||
</tr>,
|
</tr>,
|
||||||
|
|||||||
@@ -348,7 +348,8 @@ export function StreamsPanel({
|
|||||||
)}
|
)}
|
||||||
{overview.streams.length === 0 ? (
|
{overview.streams.length === 0 ? (
|
||||||
<p className="px-4 py-6 text-sm text-ink-muted">
|
<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's Discord radio panel, or run{" "}
|
||||||
|
<span className="font-mono">/join</span> where the DRB server bot is in a linked Discord server.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<Table head={["Server · channel", "Source", "Talkgroups", "Mode", "Desired → actual", "Bot", ""]}>
|
<Table head={["Server · channel", "Source", "Talkgroups", "Mode", "Desired → actual", "Bot", ""]}>
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ export interface DiscordGuild {
|
|||||||
acting_org_id: string | null;
|
acting_org_id: string | null;
|
||||||
acting_org_name: string | null;
|
acting_org_name: string | null;
|
||||||
live_streams: number;
|
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 {
|
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
|
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
|
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 in its gateway cache (default intents include ``guilds``), so it pushes
|
||||||
them to C2 (PUT /discord/guilds/{id}/directory, service key). C2 never calls
|
them to C2 (PUT /discord/guilds/{id}/directory, service key).
|
||||||
Discord on the browser's behalf and no token reaches the browser.
|
|
||||||
|
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
|
Pushed: server name, and per voice channel its id, name, category name and
|
||||||
order. Nothing about members, messages or roles.
|
order. Nothing about members, messages or roles.
|
||||||
|
|
||||||
When: every server on ready, on join/leave/rename, on any voice-channel or
|
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
|
category create/delete/update — including a permission-overwrite change — and
|
||||||
15 minutes in case C2 missed one (it was down, or redeployed).
|
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
|
import asyncio
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -123,10 +130,22 @@ class DirectorySync(commands.Cog):
|
|||||||
|
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_guild_channel_update(self, before: discord.abc.GuildChannel, after: discord.abc.GuildChannel):
|
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
|
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)
|
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):
|
async def setup(bot: commands.Bot):
|
||||||
await bot.add_cog(DirectorySync(bot))
|
await bot.add_cog(DirectorySync(bot))
|
||||||
|
|||||||
@@ -284,7 +284,9 @@ class RadioCommands(commands.Cog):
|
|||||||
value=(
|
value=(
|
||||||
"Link this server to your DRB organization — needed before anything below works. "
|
"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; "
|
"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,
|
inline=False,
|
||||||
)
|
)
|
||||||
@@ -314,7 +316,9 @@ class RadioCommands(commands.Cog):
|
|||||||
name="Radio bots",
|
name="Radio bots",
|
||||||
value=(
|
value=(
|
||||||
"A radio bot has to be a member of this server before it can join a channel — "
|
"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."
|
"The bot's green speaking ring lights up only while radio is transmitting."
|
||||||
),
|
),
|
||||||
inline=False,
|
inline=False,
|
||||||
|
|||||||
Reference in New Issue
Block a user