Merge pull request 'Node page Discord picker: org's servers + voice channels, not raw IDs (#213 item 3)' (#220) from feat/213-node-join-picker into main
Build & Deploy / Build & push images (push) Successful in 4m58s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 29s
Build & Deploy / Deploy to VM (push) Successful in 2m37s
Build & Deploy / Report a failed deploy (push) Skipped

Reviewed-on: #220
This commit was merged in pull request #220.
This commit is contained in:
2026-10-04 22:05:49 -04:00
11 changed files with 1230 additions and 104 deletions
+4
View File
@@ -23,6 +23,7 @@ from app.routers import discord as discord_router
from app.routers import discord_link as discord_link_router from app.routers import discord_link as discord_link_router
from app.internal import dynsec from app.internal import dynsec
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.routers import discord_web
async def _clear_legacy_token_flags(): async def _clear_legacy_token_flags():
@@ -156,6 +157,9 @@ app.include_router(waitlist.router) # public — no auth, source-IP rate limi
# public by necessity — an <audio src> can't send a bearer token, so the # public by necessity — an <audio src> can't send a bearer token, so the
# short-lived HMAC in the URL is the credential. Checked inline in media.py. # short-lived HMAC in the URL is the credential. Checked inline in media.py.
app.include_router(media.router) app.include_router(media.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)
# auth is per-endpoint: bot routes take the service key (or a platform admin); # 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. # the token-fetch route takes the calling node's own API key.
app.include_router(discord_router.router) app.include_router(discord_router.router)
+293
View File
@@ -0,0 +1,293 @@
"""
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:
# Same field the /drb link flow writes (feat/213-guild-link, PR #218).
data["guild_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 _guild_name(g: dict) -> Optional[str]:
return g.get("guild_name") or g.get("name")
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.
Mirrors streams.resolve_guild_org: explicit link, else the unlinked-server
fallback org — except a server an org explicitly unlinked (``unlinked_at``,
PR #218), which gets no fallback."""
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 (None if g.get("unlinked_at") else fallback)
if owner and owner == org_id:
out.append(g)
out.sort(key=lambda g: ((_guild_name(g) 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": _guild_name(g),
"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_name(guild),
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):
# guild:channel snowflakes (leases.stream_id_for). Validated so a malformed
# id (e.g. one with "/") is a 422, not a Firestore path error / 500.
stream_id: Optional[str] = Field(None, pattern=r"^[0-9]{1,25}:[0-9]{1,25}$")
@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")}
+5
View File
@@ -168,6 +168,11 @@ async def send_command(
if cmd.action == "discord_join": if cmd.action == "discord_join":
if not cmd.guild_id or not cmd.channel_id: if not cmd.guild_id or not cmd.channel_id:
raise HTTPException(400, "guild_id and channel_id are required.") 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) system_id = discord_streams._effective_system(node)
if not system_id: if not system_id:
raise HTTPException(409, "This node is not listening to a system yet.") raise HTTPException(409, "This node is not listening to a system yet.")
@@ -0,0 +1,217 @@
"""
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["guild_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
# An org that explicitly unlinked its server (PR #218) never falls back.
fs.put("discord_guilds", "300", {"guild_id": "300", "org_id": None, "unlinked_at": "2026-10-04T00:00:00+00:00",
"guild_name": "Unlinked", "voice_channels": []})
assert "300" not in [g["guild_id"] for g in run(web.join_options("n1", OWNER))["guilds"]]
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
def test_stop_rejects_malformed_stream_id():
# "a/b" would otherwise reach Firestore as a document path and 500.
from pydantic import ValidationError
for bad in ("100/1001", "../x", "100:1001/extra", ""):
with pytest.raises(ValidationError):
web.NodeStreamStop(stream_id=bad)
assert web.NodeStreamStop(stream_id="100:1001").stream_id == "100:1001"
assert web.NodeStreamStop().stream_id is None
+4 -104
View File
@@ -10,6 +10,7 @@ import { StatusBadge } from "@/components/StatusBadge";
import { NodeConfigModal } from "@/components/NodeConfigModal"; import { NodeConfigModal } from "@/components/NodeConfigModal";
import { SdrSettings } from "@/components/SdrSettings"; import { SdrSettings } from "@/components/SdrSettings";
import { RadioChannels } from "@/components/RadioChannels"; import { RadioChannels } from "@/components/RadioChannels";
import { NodeDiscordRadio } from "@/components/NodeDiscordRadio";
import { CallRow } from "@/components/CallRow"; import { CallRow } from "@/components/CallRow";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAuth } from "@/components/AuthProvider"; 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<string | null>(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 (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<form
onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 space-y-4 font-mono w-full max-w-sm max-h-[90vh] overflow-y-auto"
>
<h3 className="text-white font-semibold">Join Discord Voice</h3>
<div>
<label className="text-xs text-gray-400 block mb-1">Server ID (Guild)</label>
<input
value={guildId}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Voice Channel ID</label>
<input
value={channelId}
onChange={(e) => 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"
/>
</div>
<p className="text-xs text-gray-600">
A token will be drawn from the pool automatically. Make sure the bot is a member of the server.
</p>
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={sending}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{sending ? "Joining…" : "Join"}
</button>
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
);
}
export default function NodeDetailPage() { export default function NodeDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const router = useRouter(); const router = useRouter();
const [node, setNode] = useState<NodeRecord | null>(null); const [node, setNode] = useState<NodeRecord | null>(null);
const [showConfig, setShowConfig] = useState(false); const [showConfig, setShowConfig] = useState(false);
const [showDiscordJoin, setShowDiscordJoin] = useState(false);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [approving, setApproving] = useState(false); const [approving, setApproving] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -347,19 +263,6 @@ export default function NodeDetailPage() {
Update Node Software Update Node Software
</button> </button>
)} )}
<button
onClick={() => setShowDiscordJoin(true)}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors"
>
Join Discord
</button>
<button
disabled={sending}
onClick={() => sendCommand("discord_leave")}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
>
Leave Discord
</button>
{isAdmin && ( {isAdmin && (
<button <button
disabled={deleting} disabled={deleting}
@@ -371,6 +274,9 @@ export default function NodeDetailPage() {
)} )}
</div> </div>
{/* server-26#213 item 3: picker over the org's linked servers, not raw IDs. */}
<NodeDiscordRadio nodeId={id} />
<RadioChannels channels={node.radio_channels} /> <RadioChannels channels={node.radio_channels} />
<SdrSettings node={node} canEdit={isAdmin} /> <SdrSettings node={node} canEdit={isAdmin} />
@@ -413,12 +319,6 @@ export default function NodeDetailPage() {
/> />
)} )}
{showDiscordJoin && (
<DiscordJoinModal
nodeId={id}
onClose={() => setShowDiscordJoin(false)}
/>
)}
</div> </div>
); );
} }
@@ -0,0 +1,456 @@
"use client";
// Node page "Discord radio" panel + picker (server-26#213 item 3, #109 item 13).
// Replaces the raw Guild ID / Voice Channel ID modal. The picker lists only
// the Discord servers linked to this node's org and the voice channels the
// DRB bot reported in them; a pick becomes a radio channel pinned to this node
// (desired state — the node reconciles and reports back, shown per row).
// Layout follows design-canvas/discord-system-mockup.html "Radio channels".
import { useCallback, useEffect, useMemo, useState } from "react";
import { c2api } from "@/lib/c2api";
import {
parseC2Error,
type DiscordGuildOption,
type DiscordInvite,
type DiscordJoinOptions,
type DiscordStreamView,
type StreamState,
} from "@/lib/discordPicker";
const POLL_MS = 10_000;
const STATE: Record<StreamState, { label: string; dot: string }> = {
pending: { label: "on → waiting for node", dot: "bg-sev-moderate" },
connecting: { label: "on → connecting", dot: "bg-sev-moderate" },
connected: { label: "on → connected", dot: "bg-green-500" },
error: { label: "on → error", dot: "bg-sev-major" },
stopping: { label: "off → stopping", dot: "bg-ink-muted" },
stopped: { label: "off", dot: "bg-ink-muted" },
node_offline: { label: "on → node offline", dot: "bg-sev-major" },
paused_locally: { label: "on → paused on node", dot: "bg-sev-moderate" },
};
function StatePill({ s }: { s: DiscordStreamView }) {
const st = STATE[s.actual?.state ?? "pending"] ?? STATE.pending;
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium whitespace-nowrap text-ink">
<i className={`inline-block w-2 h-2 rounded-full ${st.dot}`} />
{st.label}
</span>
);
}
function InviteLinks({ invite }: { invite: DiscordInvite[] }) {
if (!invite.length) return null;
return (
<span className="flex flex-wrap gap-x-3 gap-y-1 mt-1">
{invite.map((i) => (
<a
key={i.invite_url}
href={i.invite_url}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
Invite {i.name}
</a>
))}
</span>
);
}
function modeLabel(mode: DiscordStreamView["mode"]) {
return mode === "persistent" ? "persistent" : "ad-hoc";
}
// ---------------------------------------------------------------------------
// Picker
// ---------------------------------------------------------------------------
function DiscordPicker({
nodeId,
options,
onClose,
onStarted,
}: {
nodeId: string;
options: DiscordJoinOptions;
onClose: () => void;
onStarted: () => void;
}) {
const usable = options.guilds;
const [guildId, setGuildId] = useState<string>(usable.length === 1 ? usable[0].guild_id : "");
const [channelId, setChannelId] = useState("");
const [mode, setMode] = useState<"adhoc" | "persistent">("adhoc");
const [sending, setSending] = useState(false);
const [error, setError] = useState<{ message: string; invite: DiscordInvite[] } | null>(null);
const guild: DiscordGuildOption | undefined = usable.find((g) => g.guild_id === guildId);
const channel = guild?.voice_channels.find((c) => c.channel_id === channelId);
const atNodeCap = options.limits.node_used >= options.limits.node_max;
const channelOnHere = channel?.stream?.node_id === nodeId;
const grouped = useMemo(() => {
const out: { category: string | null; channels: DiscordGuildOption["voice_channels"] }[] = [];
for (const c of guild?.voice_channels ?? []) {
const last = out[out.length - 1];
if (last && last.category === c.category) last.channels.push(c);
else out.push({ category: c.category, channels: [c] });
}
return out;
}, [guild]);
async function start() {
if (!guild || !channel) return;
setSending(true);
setError(null);
try {
await c2api.startNodeDiscordStream(nodeId, { guild_id: guild.guild_id, channel_id: channel.channel_id, mode });
onStarted();
onClose();
} catch (err) {
setError(parseC2Error(err));
} finally {
setSending(false);
}
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" role="dialog" aria-modal="true" aria-labelledby="discord-picker-title">
<div className="bg-surface border border-line rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col text-sm">
<div className="px-5 pt-5 pb-3 border-b border-line">
<h3 id="discord-picker-title" className="text-ink font-semibold text-base">Add radio channel</h3>
<p className="text-ink-muted text-xs mt-1">
Source: <span className="text-ink-2">{options.system_name ?? "no system"}</span> ·{" "}
<span className="text-ink-2">{options.node_name ?? nodeId}</span>. Audio comes from this node&apos;s
PulseAudio monitor.
</p>
</div>
<div className="px-5 py-4 space-y-4 overflow-y-auto">
{/* 1 — server */}
<fieldset>
<legend className="text-xs text-ink-muted mb-2">Discord server</legend>
{usable.length === 0 ? (
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
No Discord server is linked to your organization yet. Add the DRB bot to your Discord server and
link it to your organization; it shows up here once the bot reports its channels.
</p>
) : (
<div className="space-y-1.5">
{usable.map((g) => (
<label
key={g.guild_id}
className={`flex items-start gap-3 rounded-lg border px-3 py-2 cursor-pointer ${
g.guild_id === guildId ? "border-accent bg-accent/10" : "border-line bg-raised hover:border-line-strong"
}`}
>
<input
type="radio"
name="guild"
className="mt-1 accent-[var(--accent)]"
checked={g.guild_id === guildId}
onChange={() => {
setGuildId(g.guild_id);
setChannelId("");
setError(null);
}}
/>
<span className="flex-1 min-w-0">
<span className="text-ink font-medium block truncate">{g.name ?? `Server ${g.guild_id}`}</span>
<span className="text-xs text-ink-muted block">
{g.linked ? "Linked" : "Default organization (not linked yet)"} · {g.streams_on} /{" "}
{options.limits.guild_max} radio channels on
</span>
{!g.bot.ok && (
<span className="text-xs text-sev-moderate block mt-1">
{g.bot.message}
<InviteLinks invite={g.bot.invite} />
</span>
)}
</span>
</label>
))}
</div>
)}
</fieldset>
{/* 2 — voice channel */}
{guild && (
<fieldset>
<legend className="text-xs text-ink-muted mb-2">Voice channel</legend>
{!guild.synced ? (
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
The DRB bot has not reported this server&apos;s channels yet. It does when it starts and every 15
minutes — check back shortly.
</p>
) : guild.voice_channels.length === 0 ? (
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
This server has no voice channels the DRB bot can see.
</p>
) : (
<div className="space-y-2">
{grouped.map((grp, gi) => (
<div key={`${grp.category ?? ""}-${gi}`}>
{grp.category && (
<div className="text-[11px] uppercase tracking-wider text-ink-muted mb-1">{grp.category}</div>
)}
<div className="space-y-1">
{grp.channels.map((c) => {
const here = c.stream?.node_id === nodeId;
return (
<label
key={c.channel_id}
className={`flex items-start gap-3 rounded-lg border px-3 py-1.5 cursor-pointer ${
c.channel_id === channelId ? "border-accent bg-accent/10" : "border-line bg-raised hover:border-line-strong"
}`}
>
<input
type="radio"
name="channel"
className="mt-1 accent-[var(--accent)]"
checked={c.channel_id === channelId}
onChange={() => {
setChannelId(c.channel_id);
setError(null);
}}
/>
<span className="flex-1 min-w-0">
<span className="text-ink block truncate">🔊 {c.name}</span>
{c.stream && (
<span className="text-xs text-ink-muted block">
{here
? "Already on this node"
: `Carried by ${c.stream.node_name ?? "another node"} — picking it moves it to this node`}
</span>
)}
</span>
</label>
);
})}
</div>
</div>
))}
</div>
)}
</fieldset>
)}
{/* 3 — mode */}
{channel && (
<fieldset>
<legend className="text-xs text-ink-muted mb-2">Mode</legend>
<div className="grid grid-cols-2 gap-2">
{(
[
["adhoc", "Ad-hoc", "Stops if this node goes offline or switches system."],
["persistent", "Persistent", `Stays up: if this node drops, DRB moves it to another node on ${options.system_name ?? "this system"}.`],
] as const
).map(([value, label, hint]) => (
<label
key={value}
className={`rounded-lg border px-3 py-2 cursor-pointer ${
mode === value ? "border-accent bg-accent/10" : "border-line bg-raised hover:border-line-strong"
}`}
>
<span className="flex items-center gap-2">
<input
type="radio"
name="mode"
className="accent-[var(--accent)]"
checked={mode === value}
onChange={() => setMode(value)}
/>
<span className="text-ink font-medium">{label}</span>
</span>
<span className="text-xs text-ink-muted block mt-1">{hint}</span>
</label>
))}
</div>
</fieldset>
)}
{!options.system_id && (
<p className="text-xs text-sev-moderate">
This node is not listening to a system yet. Assign it one before adding a radio channel.
</p>
)}
{atNodeCap && !channelOnHere && (
<p className="text-xs text-sev-moderate">
This node already carries {options.limits.node_used} of {options.limits.node_max} radio channels. Stop one
first.
</p>
)}
{error && (
<p role="alert" className="text-xs text-sev-major">
{error.message}
<InviteLinks invite={error.invite} />
</p>
)}
</div>
<div className="flex gap-3 px-5 py-4 border-t border-line">
<button
type="button"
onClick={start}
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere)}
className="flex-1 bg-accent hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-semibold"
>
{sending ? "Starting…" : channelOnHere ? "Update" : "Start radio"}
</button>
<button
type="button"
onClick={onClose}
className="flex-1 bg-raised border border-line hover:brightness-110 text-ink-2 rounded-lg py-2 text-sm"
>
Cancel
</button>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Panel
// ---------------------------------------------------------------------------
export function NodeDiscordRadio({ nodeId }: { nodeId: string }) {
const [options, setOptions] = useState<DiscordJoinOptions | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [picking, setPicking] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(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 (
<section className="bg-surface border border-line rounded-lg overflow-hidden">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 border-b border-line">
<h2 className="text-sm font-semibold text-ink">Discord radio</h2>
<span className="text-xs text-ink-muted">
Voice channels this node streams into. They reconnect by themselves; state is reported by the node.
</span>
{canControl && (
<span className="ml-auto flex gap-2">
{streams.length > 1 && (
<button
type="button"
disabled={busy !== null}
onClick={() => stop()}
className="bg-raised border border-line hover:brightness-110 disabled:opacity-50 text-ink-2 rounded-md px-2.5 py-1 text-xs"
>
{busy === "all" ? "Stopping…" : "Stop all"}
</button>
)}
<button
type="button"
disabled={!options}
onClick={() => setPicking(true)}
className="bg-accent hover:brightness-110 disabled:opacity-50 text-white rounded-md px-2.5 py-1 text-xs font-semibold"
>
+ Add radio channel
</button>
</span>
)}
</div>
{loadError ? (
<p role="alert" className="px-4 py-3 text-xs text-sev-major">Couldn&apos;t load Discord radio: {loadError}</p>
) : !options ? (
<p className="px-4 py-3 text-xs text-ink-muted">Loading…</p>
) : streams.length === 0 ? (
<p className="px-4 py-3 text-xs text-ink-muted">
Not streaming into any Discord voice channel.
{!canControl && " The organization owner or this node's operator can add one."}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-ink-muted border-b border-line">
<th className="px-4 py-2 text-left font-medium">Server · channel</th>
<th className="px-4 py-2 text-left font-medium">Mode</th>
<th className="px-4 py-2 text-left font-medium">Desired → actual</th>
{canControl && <th className="px-4 py-2" />}
</tr>
</thead>
<tbody>
{streams.map((s) => (
<tr key={s.stream_id} className="border-b border-line last:border-0 align-top">
<td className="px-4 py-2.5">
<div className="text-ink font-medium">🔊 {s.channel_name ?? s.channel_id}</div>
<div className="text-xs text-ink-muted">{s.guild_name ?? s.guild_id}</div>
</td>
<td className="px-4 py-2.5 text-ink-2">{modeLabel(s.mode)}</td>
<td className="px-4 py-2.5">
<StatePill s={s} />
{s.actual?.error && (
<div className="text-xs text-ink-muted mt-1 max-w-xs">{s.actual.error}</div>
)}
</td>
{canControl && (
<td className="px-4 py-2.5 text-right">
<button
type="button"
disabled={busy !== null}
onClick={() => stop(s.stream_id)}
className="bg-raised border border-line hover:brightness-110 disabled:opacity-50 text-ink-2 rounded-md px-2 py-0.5 text-xs"
>
{busy === s.stream_id ? "Stopping…" : "Stop"}
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{actionError && <p role="alert" className="px-4 py-2 text-xs text-sev-major border-t border-line">{actionError}</p>}
{options && !options.node_online && streams.length > 0 && (
<p className="px-4 py-2 text-xs text-ink-muted bg-raised border-t border-line">
This node is offline — its channels resume if it reconnects shortly. If it stays down, ad-hoc ones stop and persistent ones move to another node on the same system.
</p>
)}
{picking && options && (
<DiscordPicker nodeId={nodeId} options={options} onClose={() => setPicking(false)} onStarted={load} />
)}
</section>
);
}
+15
View File
@@ -101,6 +101,21 @@ export const c2api = {
), ),
rejectNode: (id: string) => rejectNode: (id: string) =>
request(`/nodes/${id}/reject`, { method: "POST" }), request(`/nodes/${id}/reject`, { method: "POST" }),
// Node-page Discord picker (server-26#213 item 3) — org-scoped server side.
getNodeDiscordOptions: (nodeId: string) =>
request<import("@/lib/discordPicker").DiscordJoinOptions>(`/discord/nodes/${nodeId}/join-options`),
startNodeDiscordStream: (nodeId: string, body: { guild_id: string; channel_id: string; mode: "adhoc" | "persistent" }) =>
request<import("@/lib/discordPicker").DiscordStartResult>(`/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) => deleteNode: (id: string) =>
request(`/nodes/${id}`, { method: "DELETE" }), request(`/nodes/${id}`, { method: "DELETE" }),
+90
View File
@@ -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 <status>: <body>`. 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: [] };
}
+1
View File
@@ -16,6 +16,7 @@ class DRBBot(commands.Bot):
await self.load_extension("app.commands.radio") await self.load_extension("app.commands.radio")
await self.load_extension("app.commands.drb") await self.load_extension("app.commands.drb")
await self.load_extension("app.commands.trips") await self.load_extension("app.commands.trips")
await self.load_extension("app.commands.directory")
if settings.dev_guild_id: if settings.dev_guild_id:
guild = discord.Object(id=settings.dev_guild_id) guild = discord.Object(id=settings.dev_guild_id)
@@ -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))
@@ -30,6 +30,19 @@ class C2Client:
return {"guild_id": str(guild_id), "org_id": None, "systems": [], "nodes": [], return {"guild_id": str(guild_id), "org_id": None, "systems": [], "nodes": [],
"streams": [], "bots": [], "error": True} "streams": [], "bots": [], "error": True}
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
async def start_stream(self, payload: dict) -> tuple[bool, dict]: async def start_stream(self, payload: dict) -> tuple[bool, dict]:
"""Returns (ok, body). On a refusal body carries detail/code/invite.""" """Returns (ok, body). On a refusal body carries detail/code/invite."""
try: try: