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 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 20:02:16 -04:00
co-authored by Claude Opus 5.5
parent dc1df72c86
commit 62e6d69bfa
11 changed files with 1201 additions and 104 deletions
@@ -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