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:
co-authored by
Claude Opus 5.5
parent
dc1df72c86
commit
62e6d69bfa
@@ -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 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 enrollment, media, org, waitlist, telemetry, replay, traffic, cameras
|
||||||
from app.routers import discord as discord_router
|
from app.routers import discord as discord_router
|
||||||
|
from app.routers import discord_web
|
||||||
from app.internal import dynsec
|
from app.internal import dynsec
|
||||||
from app.internal import firestore as fstore
|
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);
|
# 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)
|
||||||
|
# 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
|
# 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
|
# backend for the mosquitto-go-auth plugin). That plugin's upstream project
|
||||||
# is archived (no CVE patches) and was rejected for an internet-facing
|
# is archived (no CVE patches) and was rejected for an internet-facing
|
||||||
|
|||||||
@@ -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")}
|
||||||
@@ -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,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
|
||||||
@@ -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,451 @@
|
|||||||
|
"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'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 server; a platform
|
||||||
|
admin then links it to your organization.
|
||||||
|
</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'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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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'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 when it reconnects. If it stays down, persistent ones move to another node on the same system.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{picking && options && (
|
||||||
|
<DiscordPicker nodeId={nodeId} options={options} onClose={() => setPicking(false)} onStarted={load} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -73,6 +73,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" }),
|
||||||
|
|
||||||
|
|||||||
@@ -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: [] };
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ class DRBBot(commands.Bot):
|
|||||||
async def setup_hook(self):
|
async def setup_hook(self):
|
||||||
await self.load_extension("app.commands.radio")
|
await self.load_extension("app.commands.radio")
|
||||||
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))
|
||||||
@@ -68,6 +68,19 @@ class C2Client:
|
|||||||
logger.error(f"C2 stop_guild failed: {e}")
|
logger.error(f"C2 stop_guild failed: {e}")
|
||||||
return None
|
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
|
# Trips
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user