From 62e6d69bfa7c9f0a3a4f381d096655ff88d23342 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 4 Oct 2026 20:02:16 -0400 Subject: [PATCH] Node page Discord picker: org's linked servers + voice channels, not raw IDs (#213 item 3, #109 item 13) - c2-core routers/discord_web.py: PUT /discord/guilds/{id}/directory (service key): the server bot pushes each server's name + voice channels; merged onto discord_guilds, never touches org_id. GET /discord/nodes/{id}/join-options: the node org's servers (same resolve rule as the bot), their voice channels with any live stream, radio-bot readiness + invite links, and this node's streams. No tokens. POST /discord/nodes/{id}/streams: stream pinned to the node; server must be the node org's, channel must be in the bot's directory, guild + node caps. POST /discord/nodes/{id}/streams/stop: one stream on this node, or all. Control = platform admin, org owner, or operator owning the node. - nodes.py: legacy raw-ID discord_join now refuses a server outside the node's org. - discord bot: DirectorySync cog pushes on ready (15-min loop), join/leave, rename, voice/category channel changes (debounced). - frontend: NodeDiscordRadio panel + picker replaces DiscordJoinModal and the admin-only Join/Leave buttons. Co-Authored-By: Claude Opus 5.5 --- drb-c2-core/app/main.py | 4 + drb-c2-core/app/routers/discord_web.py | 283 +++++++++++ drb-c2-core/app/routers/nodes.py | 5 + drb-c2-core/tests/test_discord_web_picker.py | 203 ++++++++ drb-frontend/app/nodes/[id]/page.tsx | 108 +---- drb-frontend/components/NodeDiscordRadio.tsx | 451 ++++++++++++++++++ drb-frontend/lib/c2api.ts | 15 + drb-frontend/lib/discordPicker.ts | 90 ++++ drb-server-discord-bot/app/bot.py | 1 + .../app/commands/directory.py | 132 +++++ .../app/internal/c2_client.py | 13 + 11 files changed, 1201 insertions(+), 104 deletions(-) create mode 100644 drb-c2-core/app/routers/discord_web.py create mode 100644 drb-c2-core/tests/test_discord_web_picker.py create mode 100644 drb-frontend/components/NodeDiscordRadio.tsx create mode 100644 drb-frontend/lib/discordPicker.ts create mode 100644 drb-server-discord-bot/app/commands/directory.py diff --git a/drb-c2-core/app/main.py b/drb-c2-core/app/main.py index 4725942..7fe041b 100644 --- a/drb-c2-core/app/main.py +++ b/drb-c2-core/app/main.py @@ -20,6 +20,7 @@ from app.internal.auth import ( from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic, cameras from app.routers import discord as discord_router +from app.routers import discord_web from app.internal import dynsec from app.internal import firestore as fstore @@ -158,6 +159,9 @@ app.include_router(media.router) # auth is per-endpoint: bot routes take the service key (or a platform admin); # the token-fetch route takes the calling node's own API key. app.include_router(discord_router.router) +# auth is per-endpoint: directory push takes the service key; the node-page +# picker takes a Firebase token scoped to the node's org (server-26#213). +app.include_router(discord_web.router) # NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP # backend for the mosquitto-go-auth plugin). That plugin's upstream project # is archived (no CVE patches) and was rejected for an internet-facing diff --git a/drb-c2-core/app/routers/discord_web.py b/drb-c2-core/app/routers/discord_web.py new file mode 100644 index 0000000..3b6bbcb --- /dev/null +++ b/drb-c2-core/app/routers/discord_web.py @@ -0,0 +1,283 @@ +""" +Discord radio channels from the web node page (server-26#213 item 3, #109 item 13). + +The node page used to take raw guild/channel IDs. It now picks from: + + * the Discord servers the caller's org owns — ``discord_guilds`` docs that + resolve to the node's org (an explicit link, or the unlinked-server + fallback org, same rule the bot uses: streams.resolve_guild_org), and + * their voice channels, from a directory the DRB server bot pushes + (``PUT /discord/guilds/{id}/directory``). The bot already holds its + gateway cache of every server it is in; C2 never calls Discord with a + token on the browser's behalf and no token string ever reaches the browser. + +A pick becomes a ``discord_streams`` desired-state doc **pinned to this node** +(streams.start_stream(node_id=...)), exactly what the old raw-ID path did, with +the checks that path skipped: the server must belong to the node's org, the +channel must be a voice channel the bot reported in that server, and the +per-server and per-node stream caps apply. + +Auth: + * PUT /discord/guilds/{id}/directory — service key (the server bot) only. + * GET /discord/nodes/{node_id}/join-options — any signed-in member of the node's org + (or a platform admin); ``can_control`` says + whether the caller may start/stop. + * POST /discord/nodes/{node_id}/streams — platform admin, the org's owner, or an + * POST /discord/nodes/{node_id}/streams/stop operator whose owned_node_ids lists the node. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from app.config import settings +from app.internal import discord_leases as leases +from app.internal import discord_streams as streams +from app.internal import firestore as fstore +from app.internal.auth import ( + get_org_role, + get_role, + require_firebase_token, + require_service_key, +) +from app.internal.logger import logger + +router = APIRouter(prefix="/discord", tags=["discord"]) + +MAX_CHANNELS = 200 +_NAME_MAX = 100 + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +# --------------------------------------------------------------------------- +# Directory — pushed by the DRB server bot from its gateway cache +# --------------------------------------------------------------------------- + +class VoiceChannelIn(BaseModel): + channel_id: str = Field(..., pattern=r"^[0-9]{1,25}$") + name: str = Field(..., max_length=_NAME_MAX) + category: Optional[str] = Field(None, max_length=_NAME_MAX) + position: int = 0 + + +class GuildDirectory(BaseModel): + name: Optional[str] = Field(None, max_length=_NAME_MAX) + present: bool = True + voice_channels: list[VoiceChannelIn] = Field(default_factory=list, max_length=MAX_CHANNELS) + + +@router.put("/guilds/{guild_id}/directory") +async def put_guild_directory(guild_id: str, body: GuildDirectory, _: dict = Depends(require_service_key)): + """The server bot's view of one Discord server: its name and voice channels. + Merged onto ``discord_guilds/{id}`` — never touches ``org_id`` (the link).""" + if not guild_id.isdigit(): + raise HTTPException(400, "guild_id must be a Discord snowflake.") + data: dict = {"guild_id": guild_id, "bot_present": body.present, "directory_at": _now_iso()} + if body.present: + data["voice_channels"] = [c.model_dump() for c in body.voice_channels] + if body.name: + data["name"] = body.name + await fstore.doc_set(streams.GUILDS, guild_id, data) + return {"ok": True, "guild_id": guild_id, "voice_channels": len(body.voice_channels)} + + +# --------------------------------------------------------------------------- +# Node page — picker +# --------------------------------------------------------------------------- + +async def _node_for_caller(node_id: str, decoded: dict) -> tuple[dict, bool]: + """The node, and whether this caller may start/stop its radio channels. + A node outside the caller's org is a 404, never a 403 (no existence leak).""" + node = await fstore.doc_get("nodes", node_id) + if not node: + raise HTTPException(404, f"Node '{node_id}' not found.") + if get_role(decoded) == "admin": + return node, True + org_id = decoded.get("org_id") + if not org_id or node.get("org_id") != org_id: + raise HTTPException(404, f"Node '{node_id}' not found.") + can = get_org_role(decoded) == "owner" or ( + get_role(decoded) == "operator" and node_id in (decoded.get("owned_node_ids") or []) + ) + return node, can + + +def _require_control(can: bool) -> None: + if not can: + raise HTTPException(403, "Only the organization owner, an admin, or this node's operator can do that.") + + +async def _org_guilds(org_id: str) -> list[dict]: + """``discord_guilds`` docs this org may stream into, bot still present.""" + fallback = settings.discord_unlinked_guild_org_id or None + out = [] + for g in await fstore.collection_list(streams.GUILDS): + gid = str(g.get("guild_id") or "") + if not gid or g.get("bot_present") is False: + continue + owner = g.get("org_id") or fallback + if owner and owner == org_id: + out.append(g) + out.sort(key=lambda g: ((g.get("name") or "").lower(), g["guild_id"])) + return out + + +def _bot_status(tokens: dict, *, node_id: str, guild_id: str, org_id: str) -> dict: + res = leases.pick_token(tokens, node_id=node_id, guild_id=guild_id, org_id=org_id) + if res.ok: + return {"ok": True, "code": None, "message": None, "invite": []} + return {"ok": False, "code": res.error, "message": streams.ERROR_TEXT.get(res.error or "", res.error), + "invite": [i for i in res.invite if i.get("invite_url")][:5]} + + +def _stream_view(s: dict, node_names: dict) -> dict: + return { + "stream_id": s.get("stream_id"), + "guild_id": s.get("guild_id"), + "channel_id": s.get("channel_id"), + "guild_name": s.get("guild_name"), + "channel_name": s.get("channel_name"), + "system_name": s.get("system_name"), + "mode": s.get("mode"), + "node_id": s.get("node_id"), + "node_name": node_names.get(s.get("node_id") or "") or s.get("node_id"), + "actual": s.get("actual"), + "updated_at": s.get("updated_at"), + } + + +@router.get("/nodes/{node_id}/join-options") +async def join_options(node_id: str, decoded: dict = Depends(require_firebase_token)): + node, can_control = await _node_for_caller(node_id, decoded) + org_id = node.get("org_id") + system_id = streams._effective_system(node) + system = (await fstore.doc_get_cached("systems", system_id)) if system_id else None + + guilds = await _org_guilds(org_id) if org_id else [] + live = [s for s in await fstore.collection_list(streams.STREAMS, desired="on") + if s.get("org_id") == org_id] + nodes = {n.get("node_id"): n.get("name") for n in await fstore.collection_list("nodes", org_id=org_id) + if n.get("node_id")} if org_id else {} + by_channel = {s.get("stream_id"): s for s in live} + tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")} + + out_guilds = [] + for g in guilds: + gid = g["guild_id"] + channels = [] + for c in sorted(g.get("voice_channels") or [], + key=lambda c: (c.get("position") or 0, (c.get("name") or "").lower())): + s = by_channel.get(leases.stream_id_for(gid, str(c.get("channel_id")))) + channels.append({ + "channel_id": str(c.get("channel_id")), + "name": c.get("name"), + "category": c.get("category"), + "stream": _stream_view(s, nodes) if s else None, + }) + out_guilds.append({ + "guild_id": gid, + "name": g.get("name"), + "linked": bool(g.get("org_id")), + "synced": g.get("voice_channels") is not None, + "synced_at": g.get("directory_at"), + "voice_channels": channels, + "streams_on": sum(1 for s in live if s.get("guild_id") == gid), + "bot": _bot_status(tokens, node_id=node_id, guild_id=gid, org_id=org_id), + }) + + here = [s for s in live if s.get("node_id") == node_id] + here.sort(key=lambda s: (s.get("guild_name") or "", s.get("channel_name") or "", s.get("stream_id") or "")) + return { + "node_id": node_id, + "node_name": node.get("name"), + "node_online": node.get("status") in streams.ONLINE, + "system_id": system_id, + "system_name": (system or {}).get("name") or system_id, + "can_control": can_control, + "limits": { + "node_max": settings.discord_node_max_streams, + "node_used": len(here), + "guild_max": settings.discord_guild_max_streams, + }, + "guilds": out_guilds, + "streams": [_stream_view(s, nodes) for s in here], + } + + +class NodeStreamStart(BaseModel): + guild_id: str + channel_id: str + mode: str = "adhoc" + + +@router.post("/nodes/{node_id}/streams") +async def start_node_stream(node_id: str, body: NodeStreamStart, decoded: dict = Depends(require_firebase_token)): + node, can_control = await _node_for_caller(node_id, decoded) + _require_control(can_control) + org_id = node.get("org_id") + if not org_id: + raise HTTPException(409, "This node is not in an organization yet.") + if body.mode not in ("persistent", "adhoc"): + raise HTTPException(400, "mode must be persistent or adhoc.") + system_id = streams._effective_system(node) + if not system_id: + raise HTTPException(409, "This node is not listening to a system yet.") + + guild_id, channel_id = str(body.guild_id), str(body.channel_id) + guild = next((g for g in await _org_guilds(org_id) if g["guild_id"] == guild_id), None) + if not guild: + raise HTTPException(404, "That Discord server is not linked to this organization.") + channel = next((c for c in guild.get("voice_channels") or [] if str(c.get("channel_id")) == channel_id), None) + if not channel: + raise HTTPException(400, "That is not a voice channel DRB can see in this server. " + "Refresh the list and pick again.") + + stream_id = leases.stream_id_for(guild_id, channel_id) + existing = await fstore.doc_get(streams.STREAMS, stream_id) or {} + already_here = existing.get("desired") == "on" and existing.get("node_id") == node_id + if not already_here: + used = sum(1 for s in await fstore.collection_list(streams.STREAMS, node_id=node_id) + if s.get("desired") == "on") + if used >= settings.discord_node_max_streams: + raise HTTPException(409, f"This node already carries {used} radio channels, its maximum. " + "Stop one first.") + + try: + doc = await streams.start_stream( + org_id=org_id, guild_id=guild_id, channel_id=channel_id, system_id=system_id, + node_id=node_id, mode=body.mode, + channel_name=channel.get("name"), guild_name=guild.get("name"), + created_by=f"web:{decoded.get('uid') or 'unknown'}", + max_streams=settings.discord_guild_max_streams, + ) + except streams.StreamError as e: + return JSONResponse(status_code=e.status, + content={"detail": e.message, "code": e.code, "invite": e.invite}) + logger.info(f"Web: radio channel {stream_id} pinned to node {node_id} ({body.mode})") + return {"ok": True, "stream_id": doc["stream_id"], "token_id": doc.get("token_id"), + "delivered": doc.get("delivered"), "reused": doc.get("reused"), "actual": doc.get("actual")} + + +class NodeStreamStop(BaseModel): + stream_id: Optional[str] = None + + +@router.post("/nodes/{node_id}/streams/stop") +async def stop_node_streams(node_id: str, body: NodeStreamStop, decoded: dict = Depends(require_firebase_token)): + """Stop one radio channel this node carries, or (no stream_id) all of them.""" + _, can_control = await _node_for_caller(node_id, decoded) + _require_control(can_control) + if body.stream_id: + doc = await fstore.doc_get(streams.STREAMS, body.stream_id) + if not doc or doc.get("node_id") != node_id or doc.get("desired") != "on": + raise HTTPException(404, "This node is not carrying that radio channel.") + await streams.stop_stream(body.stream_id, "stopped_from_web") + return {"ok": True, "stopped": [body.stream_id]} + return {"ok": True, "stopped": await streams.stop_node(node_id, "stopped_from_web")} diff --git a/drb-c2-core/app/routers/nodes.py b/drb-c2-core/app/routers/nodes.py index 84190d5..ed9e965 100644 --- a/drb-c2-core/app/routers/nodes.py +++ b/drb-c2-core/app/routers/nodes.py @@ -168,6 +168,11 @@ async def send_command( if cmd.action == "discord_join": if not cmd.guild_id or not cmd.channel_id: raise HTTPException(400, "guild_id and channel_id are required.") + # The raw-ID path never checked the server belongs to the node's org + # (server-26#213): one org's node could be streamed into another org's + # Discord server. The web picker uses /discord/nodes/{id}/streams now. + if await discord_streams.resolve_guild_org(cmd.guild_id) != node.get("org_id"): + raise HTTPException(404, "That Discord server is not linked to this node's organization.") system_id = discord_streams._effective_system(node) if not system_id: raise HTTPException(409, "This node is not listening to a system yet.") diff --git a/drb-c2-core/tests/test_discord_web_picker.py b/drb-c2-core/tests/test_discord_web_picker.py new file mode 100644 index 0000000..78cdcc6 --- /dev/null +++ b/drb-c2-core/tests/test_discord_web_picker.py @@ -0,0 +1,203 @@ +""" +Node-page Discord picker (server-26#213 item 3, #109 item 13). + +The web no longer takes raw guild/channel IDs: it picks from the org's +Discord servers (discord_guilds resolving to the node's org) and the voice +channels the server bot pushed, and the pick becomes a stream pinned to the +node. These tests pin the org boundary, the directory-only channel rule, the +caps, and that the directory push never clobbers a guild's org link. +""" +import asyncio + +import pytest +from fastapi import HTTPException + +from app.config import settings +from app.internal import discord_leases as leases +from app.internal import discord_streams as streams +from app.models import CommandPayload +from app.routers import discord_web as web +from app.routers import nodes as nodes_router +from tests.fake_firestore import FakeFirestore + +ORG, OTHER, SYS = "org-a", "org-b", "sys-1" +ADMIN = {"uid": "u-admin", "role": "admin"} +OWNER = {"uid": "u-own", "org_id": ORG, "org_role": "owner"} +MEMBER = {"uid": "u-mem", "org_id": ORG, "org_role": "member"} +OPERATOR = {"uid": "u-op", "org_id": ORG, "org_role": "member", "role": "operator", "owned_node_ids": ["n1"]} +STRANGER = {"uid": "u-x", "org_id": OTHER, "org_role": "owner"} + + +def run(coro): + return asyncio.run(coro) + + +@pytest.fixture +def fs(monkeypatch): + fake = FakeFirestore.install(monkeypatch) + 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) + streams._publish_locks.clear() + + async def _no_discord(token_ids): + return 0 + monkeypatch.setattr(leases, "refresh_membership", _no_discord) + monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "") + fake.published = published + + for nid, org in (("n1", ORG), ("n2", ORG), ("nx", OTHER)): + fake.put("nodes", nid, {"node_id": nid, "name": f"Node {nid}", "org_id": org, "status": "online", + "approval_status": "approved", "assigned_system_id": SYS}) + fake.put("systems", SYS, {"system_id": SYS, "name": "County P25", "org_id": ORG}) + fake.put("discord_guilds", "100", {"guild_id": "100", "org_id": ORG, "name": "Fire Co. 7", + "bot_present": True, + "voice_channels": [{"channel_id": "1001", "name": "Dispatch", + "category": "Radio", "position": 1}, + {"channel_id": "1002", "name": "Fireground", + "category": "Radio", "position": 2}]}) + fake.put("discord_guilds", "200", {"guild_id": "200", "org_id": OTHER, "name": "Other Org Server", + "voice_channels": [{"channel_id": "2001", "name": "Theirs"}]}) + fake.put("bot_tokens", "t1", {"token_id": "t1", "name": "DRB Radio 1", "token": "secret-t1", + "org_id": None, "guild_ids": ["100", "200"], "valid": True, + "application_id": "app1", "lease": None}) + return fake + + +# --------------------------------------------------------------------------- +# Directory push +# --------------------------------------------------------------------------- + +def test_directory_push_keeps_org_link(fs): + body = web.GuildDirectory(name="Fire Co. 7 (renamed)", + voice_channels=[web.VoiceChannelIn(channel_id="1003", name="Night")]) + run(web.put_guild_directory("100", body, {"service": True})) + g = fs.get("discord_guilds", "100") + assert g["org_id"] == ORG + assert g["name"] == "Fire Co. 7 (renamed)" + assert [c["channel_id"] for c in g["voice_channels"]] == ["1003"] + + +def test_directory_bot_removed_hides_guild(fs): + run(web.put_guild_directory("100", web.GuildDirectory(present=False), {"service": True})) + opts = run(web.join_options("n1", OWNER)) + assert opts["guilds"] == [] + + +def test_directory_rejects_non_snowflake(fs): + with pytest.raises(HTTPException) as e: + run(web.put_guild_directory("abc", web.GuildDirectory(), {"service": True})) + assert e.value.status_code == 400 + + +# --------------------------------------------------------------------------- +# Options — org-scoped +# --------------------------------------------------------------------------- + +def test_options_only_this_orgs_servers(fs): + opts = run(web.join_options("n1", OWNER)) + assert [g["guild_id"] for g in opts["guilds"]] == ["100"] + g = opts["guilds"][0] + assert [c["name"] for c in g["voice_channels"]] == ["Dispatch", "Fireground"] + assert g["bot"]["ok"] is True + assert opts["can_control"] is True + assert opts["system_name"] == "County P25" + assert "secret" not in repr(opts) + + +def test_options_unlinked_fallback_follows_bot_rule(fs, monkeypatch): + fs.put("discord_guilds", "300", {"guild_id": "300", "name": "Unlinked", "voice_channels": []}) + assert "300" not in [g["guild_id"] for g in run(web.join_options("n1", OWNER))["guilds"]] + monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", ORG) + got = {g["guild_id"]: g for g in run(web.join_options("n1", OWNER))["guilds"]} + assert got["300"]["linked"] is False + assert "200" not in got + + +def test_options_other_org_node_is_404(fs): + with pytest.raises(HTTPException) as e: + run(web.join_options("nx", OWNER)) + assert e.value.status_code == 404 + + +def test_member_reads_but_cannot_control(fs): + assert run(web.join_options("n1", MEMBER))["can_control"] is False + with pytest.raises(HTTPException) as e: + run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), MEMBER)) + assert e.value.status_code == 403 + + +def test_operator_controls_only_owned_node(fs): + assert run(web.join_options("n1", OPERATOR))["can_control"] is True + assert run(web.join_options("n2", OPERATOR))["can_control"] is False + + +def test_options_reports_missing_bot_with_invite(fs): + fs.put("bot_tokens", "t1", {**fs.get("bot_tokens", "t1"), "guild_ids": ["200"]}) + g = run(web.join_options("n1", OWNER))["guilds"][0] + assert g["bot"]["ok"] is False and g["bot"]["code"] == "no_bot_in_guild" + assert g["bot"]["invite"][0]["invite_url"].startswith("https://discord.com/oauth2/authorize") + + +# --------------------------------------------------------------------------- +# Start / stop +# --------------------------------------------------------------------------- + +def test_start_pins_stream_to_node_with_names(fs): + r = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), OWNER)) + assert r["ok"] and r["stream_id"] == "100:1001" and r["token_id"] == "t1" + doc = fs.get("discord_streams", "100:1001") + assert doc["node_id"] == "n1" and doc["org_id"] == ORG and doc["system_id"] == SYS + assert doc["channel_name"] == "Dispatch" and doc["guild_name"] == "Fire Co. 7" + assert doc["created_by_discord_id"] == "web:u-own" + assert fs.published and fs.published[-1][0] == "n1" + opts = run(web.join_options("n1", OWNER)) + assert [s["stream_id"] for s in opts["streams"]] == ["100:1001"] + assert opts["guilds"][0]["voice_channels"][0]["stream"]["node_name"] == "Node n1" + + +def test_start_refuses_other_orgs_server(fs): + with pytest.raises(HTTPException) as e: + run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="200", channel_id="2001"), OWNER)) + assert e.value.status_code == 404 + assert fs.get("discord_streams", "200:2001") is None + + +def test_start_refuses_channel_not_in_directory(fs): + with pytest.raises(HTTPException) as e: + run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="9999"), OWNER)) + assert e.value.status_code == 400 + + +def test_start_respects_node_cap(fs, monkeypatch): + monkeypatch.setattr(settings, "discord_node_max_streams", 1) + run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), OWNER)) + # Re-picking the same channel is idempotent, not a second slot. + run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), OWNER)) + with pytest.raises(HTTPException) as e: + run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1002"), OWNER)) + assert e.value.status_code == 409 + + +def test_start_lease_refusal_carries_invite(fs): + fs.put("bot_tokens", "t1", {**fs.get("bot_tokens", "t1"), "guild_ids": ["200"]}) + r = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), ADMIN)) + assert r.status_code == 409 + assert b"no_bot_in_guild" in r.body and b"secret" not in r.body + + +def test_stop_one_and_scoped_to_node(fs): + run(web.start_node_stream("n1", web.NodeStreamStart(guild_id="100", channel_id="1001"), OWNER)) + with pytest.raises(HTTPException) as e: + run(web.stop_node_streams("n2", web.NodeStreamStop(stream_id="100:1001"), ADMIN)) + assert e.value.status_code == 404 + r = run(web.stop_node_streams("n1", web.NodeStreamStop(stream_id="100:1001"), OWNER)) + assert r["stopped"] == ["100:1001"] + assert fs.get("discord_streams", "100:1001")["desired"] == "off" + + +def test_legacy_raw_id_join_refuses_other_orgs_server(fs): + cmd = CommandPayload(action="discord_join", guild_id="200", channel_id="2001") + with pytest.raises(HTTPException) as e: + run(nodes_router.send_command("n1", cmd, ADMIN)) + assert e.value.status_code == 404 diff --git a/drb-frontend/app/nodes/[id]/page.tsx b/drb-frontend/app/nodes/[id]/page.tsx index dc92948..5b09c10 100644 --- a/drb-frontend/app/nodes/[id]/page.tsx +++ b/drb-frontend/app/nodes/[id]/page.tsx @@ -10,6 +10,7 @@ import { StatusBadge } from "@/components/StatusBadge"; import { NodeConfigModal } from "@/components/NodeConfigModal"; import { SdrSettings } from "@/components/SdrSettings"; import { RadioChannels } from "@/components/RadioChannels"; +import { NodeDiscordRadio } from "@/components/NodeDiscordRadio"; import { CallRow } from "@/components/CallRow"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { useAuth } from "@/components/AuthProvider"; @@ -28,96 +29,11 @@ function ApprovalBadge({ status }: { status: string | null }) { ); } -function DiscordJoinModal({ - nodeId, - onClose, -}: { - nodeId: string; - onClose: () => void; -}) { - const [guildId, setGuildId] = useState(""); - const [channelId, setChannelId] = useState(""); - const [sending, setSending] = useState(false); - const [error, setError] = useState(null); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setSending(true); - setError(null); - try { - await c2api.sendCommand(nodeId, { - action: "discord_join", - guild_id: guildId, - channel_id: channelId, - }); - onClose(); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to send join command."); - } finally { - setSending(false); - } - } - - return ( -
-
-

Join Discord Voice

-
- - setGuildId(e.target.value)} - required - pattern="[0-9]+" - className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" - placeholder="123456789012345678" - /> -
-
- - setChannelId(e.target.value)} - required - pattern="[0-9]+" - className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" - placeholder="123456789012345678" - /> -
-

- A token will be drawn from the pool automatically. Make sure the bot is a member of the server. -

- {error &&

{error}

} -
- - -
-
-
- ); -} - export default function NodeDetailPage() { const { id } = useParams<{ id: string }>(); const router = useRouter(); const [node, setNode] = useState(null); const [showConfig, setShowConfig] = useState(false); - const [showDiscordJoin, setShowDiscordJoin] = useState(false); const [sending, setSending] = useState(false); const [approving, setApproving] = useState(false); const [deleting, setDeleting] = useState(false); @@ -347,19 +263,6 @@ export default function NodeDetailPage() { Update Node Software )} - - {isAdmin && ( + + + + + ); +} + +// --------------------------------------------------------------------------- +// Panel +// --------------------------------------------------------------------------- + +export function NodeDiscordRadio({ nodeId }: { nodeId: string }) { + const [options, setOptions] = useState(null); + const [loadError, setLoadError] = useState(null); + const [picking, setPicking] = useState(false); + const [busy, setBusy] = useState(null); + const [actionError, setActionError] = useState(null); + + const load = useCallback(async () => { + try { + setOptions(await c2api.getNodeDiscordOptions(nodeId)); + setLoadError(null); + } catch (err) { + setLoadError(parseC2Error(err).message); + } + }, [nodeId]); + + useEffect(() => { + load(); + const t = setInterval(() => { + if (document.visibilityState === "visible") load(); + }, POLL_MS); + return () => clearInterval(t); + }, [load]); + + async function stop(streamId?: string) { + if (!streamId && !confirm("Stop every Discord radio channel this node carries?")) return; + setBusy(streamId ?? "all"); + setActionError(null); + try { + await c2api.stopNodeDiscordStreams(nodeId, streamId); + await load(); + } catch (err) { + setActionError(parseC2Error(err).message); + } finally { + setBusy(null); + } + } + + const streams = options?.streams ?? []; + const canControl = options?.can_control ?? false; + + return ( +
+
+

Discord radio

+ + Voice channels this node streams into. They reconnect by themselves; state is reported by the node. + + {canControl && ( + + {streams.length > 1 && ( + + )} + + + )} +
+ + {loadError ? ( +

Couldn't load Discord radio: {loadError}

+ ) : !options ? ( +

Loading…

+ ) : streams.length === 0 ? ( +

+ Not streaming into any Discord voice channel. + {!canControl && " The organization owner or this node's operator can add one."} +

+ ) : ( +
+ + + + + + + {canControl && + + + {streams.map((s) => ( + + + + + {canControl && ( + + )} + + ))} + +
Server · channelModeDesired → actual} +
+
🔊 {s.channel_name ?? s.channel_id}
+
{s.guild_name ?? s.guild_id}
+
{modeLabel(s.mode)} + + {s.actual?.error && ( +
{s.actual.error}
+ )} +
+ +
+
+ )} + + {actionError &&

{actionError}

} + {options && !options.node_online && streams.length > 0 && ( +

+ This node is offline — its channels resume when it reconnects. If it stays down, persistent ones move to another node on the same system. +

+ )} + + {picking && options && ( + setPicking(false)} onStarted={load} /> + )} +
+ ); +} diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index da9c7bc..5e6042d 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -73,6 +73,21 @@ export const c2api = { ), rejectNode: (id: string) => request(`/nodes/${id}/reject`, { method: "POST" }), + + // Node-page Discord picker (server-26#213 item 3) — org-scoped server side. + getNodeDiscordOptions: (nodeId: string) => + request(`/discord/nodes/${nodeId}/join-options`), + startNodeDiscordStream: (nodeId: string, body: { guild_id: string; channel_id: string; mode: "adhoc" | "persistent" }) => + request(`/discord/nodes/${nodeId}/streams`, { + method: "POST", + body: JSON.stringify(body), + }), + /** Omit streamId to stop every radio channel this node carries. */ + stopNodeDiscordStreams: (nodeId: string, streamId?: string) => + request<{ ok: boolean; stopped: string[] }>(`/discord/nodes/${nodeId}/streams/stop`, { + method: "POST", + body: JSON.stringify(streamId ? { stream_id: streamId } : {}), + }), deleteNode: (id: string) => request(`/nodes/${id}`, { method: "DELETE" }), diff --git a/drb-frontend/lib/discordPicker.ts b/drb-frontend/lib/discordPicker.ts new file mode 100644 index 0000000..025cbc6 --- /dev/null +++ b/drb-frontend/lib/discordPicker.ts @@ -0,0 +1,90 @@ +// Node-page Discord picker (server-26#213 item 3, #109 item 13). +// Shapes returned by c2-core app/routers/discord_web.py. No token strings — +// the server never sends them to the browser. + +export type StreamState = + | "pending" + | "connecting" + | "connected" + | "error" + | "stopping" + | "stopped" + | "node_offline" + | "paused_locally"; + +export interface DiscordStreamView { + stream_id: string; + guild_id: string; + channel_id: string; + guild_name: string | null; + channel_name: string | null; + system_name: string | null; + mode: "persistent" | "adhoc" | null; + node_id: string | null; + node_name: string | null; + actual: { state: StreamState; error: string | null; since?: string; at?: string } | null; + updated_at: string | null; +} + +export interface DiscordInvite { + name: string; + invite_url: string; +} + +export interface DiscordVoiceChannel { + channel_id: string; + name: string; + category: string | null; + stream: DiscordStreamView | null; +} + +export interface DiscordGuildOption { + guild_id: string; + name: string | null; + linked: boolean; + synced: boolean; + synced_at: string | null; + voice_channels: DiscordVoiceChannel[]; + streams_on: number; + bot: { ok: boolean; code: string | null; message: string | null; invite: DiscordInvite[] }; +} + +export interface DiscordJoinOptions { + node_id: string; + node_name: string | null; + node_online: boolean; + system_id: string | null; + system_name: string | null; + can_control: boolean; + limits: { node_max: number; node_used: number; guild_max: number }; + guilds: DiscordGuildOption[]; + streams: DiscordStreamView[]; +} + +export interface DiscordStartResult { + ok: boolean; + stream_id: string; + token_id: string | null; + delivered: boolean | null; + reused: boolean | null; +} + +/** c2api's request() throws `C2 API error : `. Pull the + * detail + invite links back out of a JSON body when there is one. */ +export function parseC2Error(err: unknown): { message: string; invite: DiscordInvite[] } { + const raw = err instanceof Error ? err.message : String(err); + const idx = raw.indexOf(": "); + if (idx >= 0) { + try { + const body = JSON.parse(raw.slice(idx + 2)); + const detail = typeof body?.detail === "string" ? body.detail : null; + const invite = Array.isArray(body?.invite) + ? (body.invite as DiscordInvite[]).filter((i) => i && typeof i.invite_url === "string") + : []; + if (detail) return { message: detail, invite }; + } catch { + /* not JSON — fall through */ + } + } + return { message: raw, invite: [] }; +} diff --git a/drb-server-discord-bot/app/bot.py b/drb-server-discord-bot/app/bot.py index 4679d81..8fd42ed 100644 --- a/drb-server-discord-bot/app/bot.py +++ b/drb-server-discord-bot/app/bot.py @@ -13,6 +13,7 @@ class DRBBot(commands.Bot): async def setup_hook(self): await self.load_extension("app.commands.radio") await self.load_extension("app.commands.trips") + await self.load_extension("app.commands.directory") if settings.dev_guild_id: guild = discord.Object(id=settings.dev_guild_id) diff --git a/drb-server-discord-bot/app/commands/directory.py b/drb-server-discord-bot/app/commands/directory.py new file mode 100644 index 0000000..cb680b7 --- /dev/null +++ b/drb-server-discord-bot/app/commands/directory.py @@ -0,0 +1,132 @@ +""" +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. + +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). +""" +import asyncio +from typing import Optional + +import discord +from discord.ext import commands, tasks + +from app.internal.c2_client import c2 +from app.internal.logger import logger + +DEBOUNCE_SECONDS = 3.0 +MAX_CHANNELS = 200 + + +def directory_payload(guild: discord.Guild) -> dict: + channels = [] + for category, chans in guild.by_category(): + for ch in chans: + if isinstance(ch, discord.VoiceChannel): + channels.append({ + "channel_id": str(ch.id), + "name": ch.name[:100], + "category": category.name[:100] if category else None, + "position": len(channels), + }) + return {"name": guild.name[:100], "present": True, "voice_channels": channels[:MAX_CHANNELS]} + + +def _relevant(channel: discord.abc.GuildChannel) -> bool: + return isinstance(channel, (discord.VoiceChannel, discord.CategoryChannel)) + + +class DirectorySync(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self._pending: dict[int, asyncio.Task] = {} + + async def cog_load(self): + self.resync.start() + + async def cog_unload(self): + self.resync.cancel() + for t in self._pending.values(): + t.cancel() + + async def push(self, guild: discord.Guild) -> bool: + return await c2.put_guild_directory(guild.id, directory_payload(guild)) + + def schedule(self, guild: Optional[discord.Guild]) -> None: + if guild is None: + return + prev = self._pending.get(guild.id) + if prev and not prev.done(): + prev.cancel() + + async def _later(): + try: + await asyncio.sleep(DEBOUNCE_SECONDS) + live = self.bot.get_guild(guild.id) + if live is not None: + await self.push(live) + except asyncio.CancelledError: + pass + finally: + if self._pending.get(guild.id) is asyncio.current_task(): + self._pending.pop(guild.id, None) + + self._pending[guild.id] = asyncio.create_task(_later()) + + async def push_all(self) -> None: + ok = 0 + for guild in list(self.bot.guilds): + ok += bool(await self.push(guild)) + logger.info(f"Server directory pushed to C2: {ok}/{len(self.bot.guilds)} servers") + + @tasks.loop(minutes=15) + async def resync(self): + await self.push_all() + + @resync.before_loop + async def _before_resync(self): + await self.bot.wait_until_ready() + + # on_ready can fire again after a gateway resume-failure; the first full + # push comes from resync's first iteration right after the first ready. + @commands.Cog.listener() + async def on_guild_join(self, guild: discord.Guild): + await self.push(guild) + + @commands.Cog.listener() + async def on_guild_remove(self, guild: discord.Guild): + await c2.put_guild_directory(guild.id, {"present": False}) + + @commands.Cog.listener() + async def on_guild_update(self, before: discord.Guild, after: discord.Guild): + if before.name != after.name: + self.schedule(after) + + @commands.Cog.listener() + async def on_guild_channel_create(self, channel: discord.abc.GuildChannel): + if _relevant(channel): + self.schedule(channel.guild) + + @commands.Cog.listener() + async def on_guild_channel_delete(self, channel: discord.abc.GuildChannel): + if _relevant(channel): + self.schedule(channel.guild) + + @commands.Cog.listener() + async def on_guild_channel_update(self, before: discord.abc.GuildChannel, after: discord.abc.GuildChannel): + if _relevant(after) and (before.name != after.name or before.position != after.position + or getattr(before, "category_id", None) != getattr(after, "category_id", None)): + self.schedule(after.guild) + + +async def setup(bot: commands.Bot): + await bot.add_cog(DirectorySync(bot)) diff --git a/drb-server-discord-bot/app/internal/c2_client.py b/drb-server-discord-bot/app/internal/c2_client.py index b623df7..7bc5a7e 100644 --- a/drb-server-discord-bot/app/internal/c2_client.py +++ b/drb-server-discord-bot/app/internal/c2_client.py @@ -68,6 +68,19 @@ class C2Client: logger.error(f"C2 stop_guild failed: {e}") return None + async def put_guild_directory(self, guild_id: int | str, payload: dict) -> bool: + """This server's name + voice channels, for the web node-page picker + (server-26#213). Names only — no members, no messages, no tokens.""" + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.put(f"{self.base}/discord/guilds/{guild_id}/directory", + json=payload, headers=self._headers()) + r.raise_for_status() + return True + except Exception as e: + logger.error(f"C2 put_guild_directory({guild_id}) failed: {e}") + return False + # ------------------------------------------------------------------ # Trips # ------------------------------------------------------------------