Discord: log node-reported stream state; remove a radio channel or a server

- handle_actual logs every node-reported state TRANSITION (not heartbeats)
  with stream/node/guild/channel/token ids and the node's error text: info
  for connected/connecting/stopped, warning for error, and a dedicated
  "radio bot X failed to log in on node N: <reason>" line for login_failed.
  The node's reason code is now stored in actual.code and shown next to the
  error on Settings > Discord and the node page.
- Remove a radio channel permanently: streams.delete_stream stops it through
  desired state first (node republished without it, token -> releasing per
  #216, freed on the node's ack), then deletes the doc in a transaction that
  refuses if the channel was restarted in between. DELETE
  /discord/streams/{id} (platform admin) and DELETE
  /discord/nodes/{node}/streams/{id} (admin or org owner, same org + node
  only; operators can still stop, not remove). Audited.
- Remove a server: DELETE /discord/guilds/{id} (admin) deletes every radio
  channel in it the same way, then tombstones discord_guilds (org_id None +
  unlinked_at, #218 semantics, plus removed_at) so it never falls back to
  DISCORD_UNLINKED_GUILD_ORG_ID; the admin page hides it, and /drb link or the
  admin link brings it back. Optional leave_radio_bots makes pool bots and the
  server's org's own BYO bots leave via Discord REST; the DRB server bot stays
  until kicked, and the UI says so. Audited.
- UI: Remove (with confirm) on Radio channels, Servers and the node page list.

Refs #213, #221

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 22:40:43 -04:00
co-authored by Claude Opus 5.5
parent fabdf5839e
commit c92bdfe841
13 changed files with 836 additions and 25 deletions
@@ -132,6 +132,9 @@ def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_use
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso, "linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
"unlinked_at": None, "unlinked_at": None,
"unlinked_by": None, "unlinked_by": None,
# A server an admin removed (streams.remove_guild) comes back on link.
"removed_at": None,
"removed_by": None,
}, merge=True) }, merge=True)
return {"org_id": org_id, "already_linked": current == org_id, return {"org_id": org_id, "already_linked": current == org_id,
"created_by_uid": doc.get("created_by_uid")} "created_by_uid": doc.get("created_by_uid")}
@@ -257,6 +257,30 @@ async def detach_stream(stream_id: str, stream_updates: Optional[dict] = None) -
return await fstore.run_transaction(_txn) return await fstore.run_transaction(_txn)
async def delete_stopped_stream(stream_id: str) -> bool:
"""Delete a stream doc, but only while it is stopped and holds no token.
True when the doc is gone (deleted now, or already absent). False when it
is desired-on again or names a token — a concurrent start re-leased it,
and deleting it would leave that token's lease listing a stream nobody can
stop. ``lease_for_stream`` writes the stream doc in its own transaction,
so reading it here is enough to serialise against it."""
db = fstore.db
def _txn(txn):
ref = db.collection(STREAMS).document(stream_id)
snap = ref.get(transaction=txn)
if not snap.exists:
return True
stream = snap.to_dict() or {}
if stream.get("desired") == "on" or stream.get("token_id"):
return False
txn.delete(ref)
return True
return await fstore.run_transaction(_txn)
async def stamp_release_seq(node_id: str, seq: int) -> None: async def stamp_release_seq(node_id: str, seq: int) -> None:
"""Record the desired-state version that dropped each releasing token, so a """Record the desired-state version that dropped each releasing token, so a
stale actual report (from before the node saw the drop) can never free it.""" stale actual report (from before the node saw the drop) can never free it."""
@@ -411,3 +435,42 @@ async def refresh_membership(token_ids: list[str]) -> int:
changed += 1 changed += 1
await fstore.doc_update(TOKENS, tok_id, updates) await fstore.doc_update(TOKENS, tok_id, updates)
return changed return changed
async def leave_guild(guild_id: str, org_id: Optional[str]) -> list[dict]:
"""Make the radio bots DRB holds tokens for leave one Discord server
(Discord REST ``DELETE /users/@me/guilds/{id}``, the bot's own token).
Only platform-pool bots and ``org_id``'s own BYO bots — never another
org's branded bot. Bots whose cached membership says they are not in the
server are skipped; unknown membership is tried (404 = not a member).
The DRB *server* bot (the discord-bot service) is not in this pool, so it
stays in the server until someone kicks it. Returns one row per bot tried:
``{token_id, name, left, error}``. Never raises for a Discord failure."""
import httpx
guild_id = str(guild_id)
out: list[dict] = []
for tok in await fstore.collection_list(TOKENS):
tok_id = tok.get("token_id")
if not tok_id or not tok.get("token"):
continue
if tok.get("org_id") is not None and tok.get("org_id") != org_id:
continue
members = tok.get("guild_ids")
if members is not None and guild_id not in members:
continue
row = {"token_id": tok_id, "name": tok.get("name") or tok_id, "left": False, "error": None}
try:
async with httpx.AsyncClient(timeout=10, base_url=DISCORD_API,
headers={"Authorization": f"Bot {tok['token']}"}) as client:
r = await client.delete(f"/users/@me/guilds/{guild_id}")
if r.status_code in (200, 204, 404): # 404: Unknown Guild — not a member
row["left"] = True
else:
row["error"] = f"Discord answered {r.status_code}"
except Exception as e: # network — report, do not fail the removal
row["error"] = str(e)[:200]
if row["left"] and members is not None:
await fstore.doc_update(TOKENS, tok_id, {"guild_ids": [g for g in members if g != guild_id]})
out.append(row)
return out
+88 -5
View File
@@ -13,9 +13,11 @@ that should carry radio. Fields:
(Idle auto-dismiss is not built yet — see the PR.) (Idle auto-dismiss is not built yet — see the PR.)
desired: "on" | "off" desired: "on" | "off"
node_id, token_id, lease_id — placement (written with the lease, atomically) node_id, token_id, lease_id — placement (written with the lease, atomically)
actual: {state, error, since, at} — reported by the node, never guessed here actual: {state, error, code, since, at} — reported by the node, never guessed here
state: pending | connecting | connected | error | stopping | state: pending | connecting | connected | error | stopping |
node_offline | paused_locally node_offline | paused_locally
code: the node's machine-readable reason on error (login_failed,
connect_failed, not_in_guild, bad_channel, no_audio) or None
tg_filter: None — phase-2 seam (per-channel talkgroup gate); always None now tg_filter: None — phase-2 seam (per-channel talkgroup gate); always None now
now_playing_message_id, created_by_discord_id, created_at, updated_at now_playing_message_id, created_by_discord_id, created_at, updated_at
@@ -324,10 +326,68 @@ async def stop_stream(stream_id: str, reason: str = "stopped") -> bool:
}) })
if node_id: if node_id:
await publish_desired(node_id) await publish_desired(node_id)
logger.info(f"Discord stream {stream_id} stopped ({reason})") logger.info(f"Discord stream {stream_id} stopped ({reason}) node={node_id or existing.get('node_id')}"
f" guild={existing.get('guild_id')} channel={existing.get('channel_id')}")
return True return True
async def delete_stream(stream_id: str, reason: str = "removed") -> Optional[dict]:
"""Permanently remove a radio channel (server-26#213 follow-up).
Order matters (server-26#216 lease semantics): the stream is first stopped
through the normal desired-state path — detached from its token (token ->
``releasing``) and the node's list republished without it — and only then
is the doc deleted. The token returns to the pool exactly as for /leave,
when the node acks a list without it and no longer has a session on it
(``settle_releases`` reads the token lease, never the stream doc).
The delete itself is a transaction that refuses if the stream was started
again in between (desired on, or a token re-leased onto it): deleting it
then would strand that token's lease on a stream that no longer exists.
Returns the doc as it was before removal, or None if it did not exist.
"""
existing = await fstore.doc_get(STREAMS, stream_id)
if not existing:
return None
if existing.get("desired") == "on" or existing.get("token_id"):
await stop_stream(stream_id, reason)
if not await leases.delete_stopped_stream(stream_id):
raise StreamError("restarted", "That radio channel was started again while it was being removed. "
"Stop it and try again.")
logger.info(f"Discord stream {stream_id} removed ({reason}) node={existing.get('node_id')}"
f" guild={existing.get('guild_id')} channel={existing.get('channel_id')}"
f" org={existing.get('org_id')}")
return existing
async def remove_guild(guild_id: str, by: str) -> dict:
"""Forget a Discord server: remove every radio channel in it (stop first,
then delete — ``delete_stream``) and tombstone its ``discord_guilds`` doc.
The doc is NOT deleted: with no doc, ``resolve_guild_org`` would hand the
server to ``DISCORD_UNLINKED_GUILD_ORG_ID`` the next time anyone typed a
command there. It is unlinked the way PR #218's unlink does it
(``org_id`` None + ``unlinked_at``, so no fallback) and stamped
``removed_at`` so the admin page stops listing it. Linking it again (``/drb
link`` or the admin link) clears ``removed_at``."""
guild_id = str(guild_id)
link = await fstore.doc_get(GUILDS, guild_id) or {}
removed = []
for s in await fstore.collection_list(STREAMS, guild_id=guild_id):
if s.get("stream_id") and await delete_stream(s["stream_id"], "server_removed"):
removed.append(s["stream_id"])
now = _now_iso()
await fstore.doc_set(GUILDS, guild_id, {
"guild_id": guild_id, "org_id": None,
"unlinked_at": now, "unlinked_by": by,
"removed_at": now, "removed_by": by,
}, merge=True)
logger.info(f"Discord guild {guild_id} removed by {by}: {len(removed)} radio channel(s) deleted,"
f" was org={link.get('org_id')!r}")
return {"guild_id": guild_id, "previous_org_id": link.get("org_id"), "streams_removed": removed,
"guild_name": link.get("guild_name")}
async def stop_guild(guild_id: str, reason: str, channel_id: Optional[str] = None) -> list[str]: async def stop_guild(guild_id: str, reason: str, channel_id: Optional[str] = None) -> list[str]:
"""Stop every live stream in ONE Discord server (optionally one channel). """Stop every live stream in ONE Discord server (optionally one channel).
The /leave fix for server-26#203: never touches another server's streams.""" The /leave fix for server-26#203: never touches another server's streams."""
@@ -358,6 +418,26 @@ async def stop_node(node_id: str, reason: str) -> list[str]:
_VALID_STATES = {"connecting", "connected", "error", "stopped", "paused_locally"} _VALID_STATES = {"connecting", "connected", "error", "stopped", "paused_locally"}
def _log_transition(node_id: str, doc: dict, prev_state: Optional[str], state: str,
error: Optional[str], code: Optional[str]) -> None:
"""One log line per node-reported state CHANGE (never per heartbeat — the
edge re-reports every ~60s). Errors are warnings so they surface in the
c2-core log without turning debug on; a radio bot that cannot log in gets
its own line naming the token, since that is the one failure that takes
every channel on the token down at once."""
where = (f"stream={doc.get('stream_id')} node={node_id} guild={doc.get('guild_id')}"
f" channel={doc.get('channel_id')} token={doc.get('token_id')}")
if state == "error":
if code == "login_failed":
logger.warning(f"Radio bot {doc.get('token_id')} failed to log in on node {node_id}: {error} ({where})")
else:
logger.warning(f"Discord stream {prev_state or 'pending'} -> error [{code or 'unknown'}]:"
f" {error} ({where})")
else:
logger.info(f"Discord stream {prev_state or 'pending'} -> {state} ({where})"
+ (f": {error}" if error else ""))
async def handle_actual(node_id: str, payload: dict) -> None: async def handle_actual(node_id: str, payload: dict) -> None:
"""``nodes/{node_id}/discord/actual``. A node may only report on streams """``nodes/{node_id}/discord/actual``. A node may only report on streams
currently placed on it — anything else is ignored, so a compromised node currently placed on it — anything else is ignored, so a compromised node
@@ -377,15 +457,18 @@ async def handle_actual(node_id: str, payload: dict) -> None:
state = st.get("state") if st.get("state") in _VALID_STATES else "error" state = st.get("state") if st.get("state") in _VALID_STATES else "error"
error = st.get("error") error = st.get("error")
error = str(error)[:300] if error else None error = str(error)[:300] if error else None
if st.get("code") == "not_in_guild" and doc.get("token_id"): code = st.get("code")
code = str(code)[:40] if code else None
if code == "not_in_guild" and doc.get("token_id"):
recheck.add(doc["token_id"]) recheck.add(doc["token_id"])
prev = doc.get("actual") or {} prev = doc.get("actual") or {}
if prev.get("state") == state and prev.get("error") == error: if prev.get("state") == state and prev.get("error") == error and prev.get("code") == code:
since = prev.get("since") or now since = prev.get("since") or now
else: else:
since = now since = now
_log_transition(node_id, doc, prev.get("state"), state, error, code)
await fstore.doc_update(STREAMS, str(stream_id), { await fstore.doc_update(STREAMS, str(stream_id), {
"actual": {"state": state, "error": error, "since": since, "at": now}, "actual": {"state": state, "error": error, "code": code, "since": since, "at": now},
}) })
if recheck: if recheck:
await leases.refresh_membership(sorted(recheck)) await leases.refresh_membership(sorted(recheck))
+65 -2
View File
@@ -9,6 +9,8 @@ system/node/bot lists a server sees are its own org's.
Auth: Auth:
* bot routes — the Discord bot's service key, or a platform admin. * bot routes — the Discord bot's service key, or a platform admin.
* PUT /discord/guilds/{id}/link — platform admin only. * PUT /discord/guilds/{id}/link — platform admin only.
* DELETE /discord/streams/{stream_id}, DELETE /discord/guilds/{id} — platform
admin only: permanently remove a radio channel / a whole server.
* GET /discord/node/{node_id}/tokens/{token_id} — that node's own API key; * GET /discord/node/{node_id}/tokens/{token_id} — that node's own API key;
the token string is handed only to the node currently holding its lease. the token string is handed only to the node currently holding its lease.
""" """
@@ -18,7 +20,7 @@ import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Security from fastapi import APIRouter, Depends, HTTPException, Path, Security
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel from pydantic import BaseModel
@@ -27,11 +29,15 @@ from app.config import settings
from app.internal import discord_leases as leases from app.internal import discord_leases as leases
from app.internal import discord_streams as streams from app.internal import discord_streams as streams
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import require_admin_token, require_service_key_or_admin from app.internal.audit import write_audit
from app.internal.auth import describe_actor, require_admin_token, require_service_key_or_admin
from app.internal.logger import logger from app.internal.logger import logger
router = APIRouter(prefix="/discord", tags=["discord"]) router = APIRouter(prefix="/discord", tags=["discord"])
_bearer = HTTPBearer(auto_error=False) _bearer = HTTPBearer(auto_error=False)
# guild:channel snowflakes (leases.stream_id_for) — anything else is a 422,
# never a Firestore path.
STREAM_ID = r"^[0-9]{1,25}:[0-9]{1,25}$"
def _error(e: streams.StreamError) -> JSONResponse: def _error(e: streams.StreamError) -> JSONResponse:
@@ -162,6 +168,8 @@ async def link_guild(guild_id: str, body: GuildLink, decoded: dict = Depends(req
doc = { doc = {
"guild_id": guild_id, "org_id": body.org_id, "guild_id": guild_id, "org_id": body.org_id,
"linked_by": decoded.get("uid"), "linked_at": datetime.now(timezone.utc).isoformat(), "linked_by": decoded.get("uid"), "linked_at": datetime.now(timezone.utc).isoformat(),
# Re-linking a server that was unlinked or removed brings it back.
"unlinked_at": None, "unlinked_by": None, "removed_at": None, "removed_by": None,
} }
if body.guild_name and body.guild_name.strip(): if body.guild_name and body.guild_name.strip():
doc["guild_name"] = body.guild_name.strip()[:100] doc["guild_name"] = body.guild_name.strip()[:100]
@@ -175,6 +183,59 @@ async def link_guild(guild_id: str, body: GuildLink, decoded: dict = Depends(req
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": stopped} return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": stopped}
@router.delete("/streams/{stream_id}")
async def delete_stream(stream_id: str = Path(..., pattern=STREAM_ID),
decoded: dict = Depends(require_admin_token)):
"""Permanently remove one radio channel: stopped through the desired-state
path first (node logs the bot out, lease released on its ack), then the
doc is deleted. Platform admin only; org owners use the node page route."""
try:
doc = await streams.delete_stream(stream_id, "removed_from_web_admin")
except streams.StreamError as e:
return _error(e)
if not doc:
raise HTTPException(404, "Radio channel not found.")
uid, email = describe_actor(decoded)
await write_audit(uid, email, "discord.stream.delete", details={
"stream_id": stream_id, "guild_id": doc.get("guild_id"), "channel_id": doc.get("channel_id"),
"org_id": doc.get("org_id"), "node_id": doc.get("node_id"), "was_desired": doc.get("desired"),
"via": "settings_discord",
})
return {"ok": True, "stream_id": stream_id}
@router.delete("/guilds/{guild_id}")
async def remove_guild(guild_id: str, leave_radio_bots: bool = False,
decoded: dict = Depends(require_admin_token)):
"""Remove a Discord server from DRB: delete its radio channels (each
stopped first), then unlink it with no fallback org (streams.remove_guild).
``leave_radio_bots`` also makes the pool radio bots (and the server's org's
own BYO bots) leave it via Discord's REST API. The DRB server bot is not
one of those and stays until someone kicks it in Discord."""
if not guild_id.isdigit():
raise HTTPException(400, "guild_id must be a Discord server id (digits only).")
known = await fstore.doc_get(streams.GUILDS, guild_id)
if not known and not await fstore.collection_list(streams.STREAMS, guild_id=guild_id):
raise HTTPException(404, "Discord server not found.")
uid, email = describe_actor(decoded)
acting_org = await streams.resolve_guild_org(guild_id)
try:
out = await streams.remove_guild(guild_id, uid)
except streams.StreamError as e:
return _error(e)
left: list = []
if leave_radio_bots:
left = await leases.leave_guild(guild_id, out["previous_org_id"] or acting_org)
await write_audit(uid, email, "discord.guild.remove", details={
"guild_id": guild_id, "previous_org_id": out["previous_org_id"], "acting_org_id": acting_org,
"streams_removed": out["streams_removed"], "leave_radio_bots": leave_radio_bots,
"radio_bots_left": [r["token_id"] for r in left if r["left"]],
"radio_bots_failed": [r["token_id"] for r in left if not r["left"]],
})
return {"ok": True, "guild_id": guild_id, "streams_removed": out["streams_removed"],
"radio_bots": [{"name": r["name"], "left": r["left"], "error": r["error"]} for r in left]}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Admin view — the web Discord page (server-26#213 item 2) # Admin view — the web Discord page (server-26#213 item 2)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -201,6 +262,8 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
guilds: dict[str, dict] = {} guilds: dict[str, dict] = {}
for gid, link in links.items(): for gid, link in links.items():
if link.get("removed_at"):
continue # removed from DRB (DELETE /discord/guilds/{id}); re-listed if it gets a channel again
guilds[gid] = {"guild_id": gid, "guild_name": link.get("guild_name"), "linked": bool(link.get("org_id")), guilds[gid] = {"guild_id": gid, "guild_name": link.get("guild_name"), "linked": bool(link.get("org_id")),
"org_id": link.get("org_id"), "linked_at": link.get("linked_at"), "org_id": link.get("org_id"), "linked_at": link.get("linked_at"),
# Explicitly unlinked servers get no fallback org (mirrors # Explicitly unlinked servers get no fallback org (mirrors
+41 -1
View File
@@ -24,13 +24,15 @@ Auth:
whether the caller may start/stop. 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 — platform admin, the org's owner, or an
* POST /discord/nodes/{node_id}/streams/stop operator whose owned_node_ids lists the node. * POST /discord/nodes/{node_id}/streams/stop operator whose owned_node_ids lists the node.
* DELETE /discord/nodes/{node_id}/streams/{id} — platform admin or the org's owner only
(permanent: stop first, then delete).
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -38,7 +40,9 @@ from app.config import settings
from app.internal import discord_leases as leases from app.internal import discord_leases as leases
from app.internal import discord_streams as streams from app.internal import discord_streams as streams
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.audit import write_audit
from app.internal.auth import ( from app.internal.auth import (
describe_actor,
get_org_role, get_org_role,
get_role, get_role,
require_firebase_token, require_firebase_token,
@@ -110,6 +114,13 @@ async def _node_for_caller(node_id: str, decoded: dict) -> tuple[dict, bool]:
return node, can return node, can
def _can_remove(decoded: dict) -> bool:
"""Permanent removal is narrower than start/stop: platform admin or the
org's owner (``_node_for_caller`` has already pinned the org), never a
node operator."""
return get_role(decoded) == "admin" or get_org_role(decoded) == "owner"
def _guild_name(g: dict) -> Optional[str]: def _guild_name(g: dict) -> Optional[str]:
return g.get("guild_name") or g.get("name") return g.get("guild_name") or g.get("name")
@@ -209,6 +220,7 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
"system_id": system_id, "system_id": system_id,
"system_name": (system or {}).get("name") or system_id, "system_name": (system or {}).get("name") or system_id,
"can_control": can_control, "can_control": can_control,
"can_remove": _can_remove(decoded),
"limits": { "limits": {
"node_max": settings.discord_node_max_streams, "node_max": settings.discord_node_max_streams,
"node_used": len(here), "node_used": len(here),
@@ -291,3 +303,31 @@ async def stop_node_streams(node_id: str, body: NodeStreamStop, decoded: dict =
await streams.stop_stream(body.stream_id, "stopped_from_web") await streams.stop_stream(body.stream_id, "stopped_from_web")
return {"ok": True, "stopped": [body.stream_id]} return {"ok": True, "stopped": [body.stream_id]}
return {"ok": True, "stopped": await streams.stop_node(node_id, "stopped_from_web")} return {"ok": True, "stopped": await streams.stop_node(node_id, "stopped_from_web")}
@router.delete("/nodes/{node_id}/streams/{stream_id}")
async def remove_node_stream(node_id: str,
stream_id: str = Path(..., pattern=r"^[0-9]{1,25}:[0-9]{1,25}$"),
decoded: dict = Depends(require_firebase_token)):
"""Permanently remove a radio channel this node carries (or last carried):
stopped through desired state first so the node logs the bot out and the
lease is released on its ack (server-26#216), then the doc is deleted."""
node, _ = await _node_for_caller(node_id, decoded)
if not _can_remove(decoded):
raise HTTPException(403, "Only the organization owner or an admin can remove a radio channel.")
doc = await fstore.doc_get(streams.STREAMS, stream_id)
# Same org AND this node: an owner can never reach another org's channel
# through their own node's URL.
if not doc or doc.get("node_id") != node_id or doc.get("org_id") != node.get("org_id"):
raise HTTPException(404, "This node is not carrying that radio channel.")
try:
await streams.delete_stream(stream_id, "removed_from_web")
except streams.StreamError as e:
return JSONResponse(status_code=e.status, content={"detail": e.message, "code": e.code})
uid, email = describe_actor(decoded)
await write_audit(uid, email, "discord.stream.delete", details={
"stream_id": stream_id, "guild_id": doc.get("guild_id"), "channel_id": doc.get("channel_id"),
"org_id": doc.get("org_id"), "node_id": node_id, "was_desired": doc.get("desired"),
"via": "node_page",
})
return {"ok": True, "stream_id": stream_id}
+7
View File
@@ -75,6 +75,9 @@ class _Txn:
def update(self, ref: _DocRef, data: dict): def update(self, ref: _DocRef, data: dict):
self.writes.append(("update", ref.coll, ref.id, copy.deepcopy(data), True)) self.writes.append(("update", ref.coll, ref.id, copy.deepcopy(data), True))
def delete(self, ref: _DocRef):
self.writes.append(("delete", ref.coll, ref.id, {}, False))
class FakeFirestore: class FakeFirestore:
def __init__(self): def __init__(self):
@@ -91,6 +94,10 @@ class FakeFirestore:
def _write(self, kind: str, coll: str, doc_id: str, data: dict, merge: bool): def _write(self, kind: str, coll: str, doc_id: str, data: dict, merge: bool):
docs = self.data.setdefault(coll, {}) docs = self.data.setdefault(coll, {})
if kind == "delete":
docs.pop(doc_id, None)
self.versions[(coll, doc_id)] = self.versions.get((coll, doc_id), 0) + 1
return
if kind == "update" and doc_id not in docs: if kind == "update" and doc_id not in docs:
raise KeyError(f"No document to update: {coll}/{doc_id}") raise KeyError(f"No document to update: {coll}/{doc_id}")
if merge and doc_id in docs: if merge and doc_id in docs:
@@ -0,0 +1,362 @@
"""
Discord radio channels: server-side state logging, permanent removal of a
radio channel, and removal of a whole Discord server (server-26#213 / #221
follow-up).
Removal must go through desired state first — the node is told to drop the
channel (and logs the bot out), the token is only ``releasing`` until the node
acks — and only then is the doc deleted. Deleting first would leave the
token's lease naming a stream nobody can stop.
"""
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app.config import settings
from app.internal import discord_guild_link as glink
from app.internal import discord_leases as leases
from app.internal import discord_streams as streams
from app.internal.auth import require_admin_token, require_firebase_token
from app.main import app
from app.routers import discord_web as web
from tests.fake_firestore import FakeFirestore
ORG, OTHER, SYS = "org-a", "org-b", "sys-1"
ADMIN = {"uid": "u-admin", "role": "admin", "email": "admin@example.test"}
OWNER = {"uid": "u-own", "org_id": ORG, "org_role": "owner"}
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"}
client = TestClient(app)
class _Log:
def __init__(self):
self.lines: list[tuple[str, str]] = []
def __getattr__(self, level):
return lambda msg, *a, **k: self.lines.append((level, str(msg)))
@pytest.fixture
def fs(monkeypatch):
fake = FakeFirestore.install(monkeypatch)
fake.published = []
fake.on_publish = None
def _publish(node_id, payload):
if fake.on_publish:
fake.on_publish(node_id, payload)
fake.published.append((node_id, payload))
return True
from app.internal.mqtt_handler import mqtt_handler
monkeypatch.setattr(mqtt_handler, "publish_discord_desired", _publish)
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", "")
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("organizations", ORG, {"org_id": ORG, "name": "Alpha"})
fake.put("discord_guilds", "100", {"guild_id": "100", "org_id": ORG, "guild_name": "Fire Co. 7",
"bot_present": True,
"voice_channels": [{"channel_id": "1001", "name": "Dispatch"},
{"channel_id": "1002", "name": "Fireground"}]})
for tid, org in (("t1", None), ("t2", None)):
fake.put("bot_tokens", tid, {"token_id": tid, "name": f"Radio {tid}", "token": f"secret-{tid}",
"org_id": org, "guild_ids": ["100", "200"], "valid": True,
"application_id": f"app-{tid}", "lease": None})
yield fake
app.dependency_overrides.clear()
async def _start(channel="1001", node_id="n1", guild="100", org=ORG):
return await streams.start_stream(org_id=org, guild_id=guild, channel_id=channel, system_id=SYS,
node_id=node_id)
def _lease(fs, tid):
return (fs.get("bot_tokens", tid) or {}).get("lease")
def _audit(fs, action):
return [a for a in (fs.data.get("audit_log") or {}).values() if a["action"] == action]
# ---------------------------------------------------------------------------
# 1. Logging node-reported state transitions
# ---------------------------------------------------------------------------
async def test_actual_logs_transitions_not_heartbeats_and_keeps_the_code(fs, monkeypatch):
log = _Log()
monkeypatch.setattr(streams, "logger", log)
doc = await _start()
sid, tok = doc["stream_id"], doc["token_id"]
report = {"streams": {sid: {"state": "connected", "error": None, "code": None}}}
await streams.handle_actual("n1", report)
await streams.handle_actual("n1", report) # heartbeat: same state
infos = [m for lvl, m in log.lines if lvl == "info" and "pending -> connected" in m]
assert len(infos) == 1
assert f"stream={sid}" in infos[0] and "node=n1" in infos[0]
assert "guild=100" in infos[0] and "channel=1001" in infos[0] and f"token={tok}" in infos[0]
err = {"streams": {sid: {"state": "error", "code": "connect_failed",
"error": "Could not connect to the voice channel (TimeoutError: )."}}}
await streams.handle_actual("n1", err)
await streams.handle_actual("n1", err)
warns = [m for lvl, m in log.lines if lvl == "warning"]
assert len(warns) == 1
assert "connected -> error [connect_failed]" in warns[0] and "TimeoutError" in warns[0]
actual = fs.get("discord_streams", sid)["actual"]
assert actual["code"] == "connect_failed" and actual["error"].startswith("Could not connect")
async def test_login_failure_reason_is_logged_against_the_token(fs, monkeypatch):
log = _Log()
monkeypatch.setattr(streams, "logger", log)
doc = await _start()
await streams.handle_actual("n1", {"streams": {doc["stream_id"]: {
"state": "error", "code": "login_failed",
"error": "The radio bot could not log in to Discord (LoginFailure: Improper token has been passed.)."}}})
warns = [m for lvl, m in log.lines if lvl == "warning"]
assert len(warns) == 1
assert warns[0].startswith(f"Radio bot {doc['token_id']} failed to log in on node n1:")
assert "Improper token" in warns[0]
async def test_actual_from_a_node_not_carrying_the_stream_is_neither_stored_nor_logged(fs, monkeypatch):
log = _Log()
monkeypatch.setattr(streams, "logger", log)
doc = await _start(node_id="n1")
await streams.handle_actual("n2", {"streams": {doc["stream_id"]: {"state": "error", "error": "spoof"}}})
assert fs.get("discord_streams", doc["stream_id"])["actual"]["state"] == "pending"
assert not [m for _, m in log.lines if "spoof" in m]
# ---------------------------------------------------------------------------
# 2. Remove a radio channel
# ---------------------------------------------------------------------------
async def test_delete_stops_through_desired_state_before_deleting(fs):
doc = await _start()
sid, tok = doc["stream_id"], doc["token_id"]
seen = []
def _on_publish(node_id, payload):
# The node must be told to drop the channel while the doc still exists.
cur = fs.get("discord_streams", sid)
seen.append((node_id, [s["stream_id"] for s in payload["streams"]], cur and cur["desired"]))
fs.on_publish = _on_publish
before = await streams.delete_stream(sid, "removed")
assert before["stream_id"] == sid
assert seen == [("n1", [], "off")] # stop published first, doc still there
assert fs.get("discord_streams", sid) is None # then deleted
# Lease follows #216: releasing until the node acks a list without it.
lease = _lease(fs, tok)
assert lease["releasing"] is True and lease["streams"] == []
await streams.handle_actual("n1", {"applied_seq": lease["release_seq"], "sessions": [tok], "streams": {}})
assert _lease(fs, tok) is not None # still logged in on the node
await streams.handle_actual("n1", {"applied_seq": lease["release_seq"], "sessions": [], "streams": {}})
assert _lease(fs, tok) is None # node logged out -> back in the pool
async def test_delete_of_an_already_stopped_stream_does_not_republish(fs):
doc = await _start()
await streams.stop_stream(doc["stream_id"])
n = len(fs.published)
assert await streams.delete_stream(doc["stream_id"])
assert len(fs.published) == n
assert fs.get("discord_streams", doc["stream_id"]) is None
assert await streams.delete_stream(doc["stream_id"]) is None
async def test_delete_refuses_when_the_stream_is_restarted_in_between(fs, monkeypatch):
doc = await _start()
real_stop = streams.stop_stream
async def _stop_then_restart(stream_id, reason="stopped"):
await real_stop(stream_id, reason)
await _start() # /join lands between stop and delete
monkeypatch.setattr(streams, "stop_stream", _stop_then_restart)
with pytest.raises(streams.StreamError) as e:
await streams.delete_stream(doc["stream_id"])
assert e.value.code == "restarted" and e.value.status == 409
cur = fs.get("discord_streams", doc["stream_id"])
assert cur["desired"] == "on" and doc["stream_id"] in _lease(fs, cur["token_id"])["streams"]
def test_admin_route_deletes_and_audits(fs):
import asyncio
doc = asyncio.run(_start())
app.dependency_overrides[require_admin_token] = lambda: ADMIN
assert client.delete("/discord/streams/not-a-stream").status_code == 422
assert client.delete("/discord/streams/100:9999").status_code == 404
r = client.delete(f"/discord/streams/{doc['stream_id']}")
assert r.status_code == 200 and r.json()["ok"] is True
assert fs.get("discord_streams", doc["stream_id"]) is None
(entry,) = _audit(fs, "discord.stream.delete")
assert entry["actor_uid"] == "u-admin"
assert entry["details"]["stream_id"] == doc["stream_id"] and entry["details"]["was_desired"] == "on"
assert entry["details"]["org_id"] == ORG and entry["details"]["node_id"] == "n1"
def test_admin_route_requires_platform_admin(fs):
def deny():
raise HTTPException(403, "Admin access required")
app.dependency_overrides[require_admin_token] = deny
assert client.delete("/discord/streams/100:1001").status_code == 403
assert client.delete("/discord/guilds/100").status_code == 403
async def test_node_page_remove_is_owner_or_admin_and_org_scoped(fs):
a = await _start("1001", node_id="n1")
b = await _start("1002", node_id="n2")
# An operator can stop but not permanently remove.
with pytest.raises(HTTPException) as e:
await web.remove_node_stream("n1", a["stream_id"], OPERATOR)
assert e.value.status_code == 403
# Another org's owner cannot even see the node.
with pytest.raises(HTTPException) as e:
await web.remove_node_stream("n1", a["stream_id"], STRANGER)
assert e.value.status_code == 404
# A stream on another node of the same org is not reachable through n1's URL.
with pytest.raises(HTTPException) as e:
await web.remove_node_stream("n1", b["stream_id"], OWNER)
assert e.value.status_code == 404
assert (await web.remove_node_stream("n1", a["stream_id"], OWNER))["ok"] is True
assert fs.get("discord_streams", a["stream_id"]) is None
assert fs.get("discord_streams", b["stream_id"])["desired"] == "on" # untouched
(entry,) = _audit(fs, "discord.stream.delete")
assert entry["actor_uid"] == "u-own" and entry["details"]["via"] == "node_page"
assert (await web.remove_node_stream("n2", b["stream_id"], ADMIN))["ok"] is True
async def test_join_options_says_who_may_remove(fs):
assert (await web.join_options("n1", OWNER))["can_remove"] is True
assert (await web.join_options("n1", ADMIN))["can_remove"] is True
opts = await web.join_options("n1", OPERATOR)
assert opts["can_control"] is True and opts["can_remove"] is False
# ---------------------------------------------------------------------------
# 3. Remove a Discord server
# ---------------------------------------------------------------------------
async def test_remove_guild_deletes_streams_and_unlinks_without_fallback(fs, monkeypatch):
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", ORG)
a = await _start("1001", node_id="n1")
b = await _start("1002", node_id="n1")
await streams.stop_stream(b["stream_id"]) # one already stopped
out = await streams.remove_guild("100", "u-admin")
assert sorted(out["streams_removed"]) == sorted([a["stream_id"], b["stream_id"]])
assert fs.data["discord_streams"] == {}
g = fs.get("discord_guilds", "100")
assert g["org_id"] is None and g["unlinked_at"] and g["removed_at"] and g["removed_by"] == "u-admin"
# No fallback even though DISCORD_UNLINKED_GUILD_ORG_ID is set.
assert await streams.resolve_guild_org("100") is None
# Leases follow #216: releasing, freed on the node's ack.
toks = {a["token_id"], b["token_id"]}
assert len(toks) == 2 # one bot per guild voice connection
leased = [_lease(fs, t) for t in toks]
assert all(lease["releasing"] for lease in leased)
seq = max(lease["release_seq"] for lease in leased)
await streams.handle_actual("n1", {"applied_seq": seq, "sessions": [], "streams": {}})
assert [_lease(fs, t) for t in toks] == [None, None]
def test_remove_guild_route_hides_server_and_relink_restores_it(fs):
import asyncio
asyncio.run(_start("1001"))
app.dependency_overrides[require_admin_token] = lambda: ADMIN
assert client.delete("/discord/guilds/abc").status_code == 400
assert client.delete("/discord/guilds/999").status_code == 404
r = client.delete("/discord/guilds/100")
assert r.status_code == 200 and r.json()["streams_removed"] == ["100:1001"]
assert r.json()["radio_bots"] == [] # not asked to leave
body = client.get("/discord/admin/overview").json()
assert body["guilds"] == [] and body["streams"] == []
(entry,) = _audit(fs, "discord.guild.remove")
assert entry["details"]["previous_org_id"] == ORG and entry["details"]["streams_removed"] == ["100:1001"]
# Admin link brings it back.
assert client.put("/discord/guilds/100/link", json={"org_id": ORG}).status_code == 200
g = fs.get("discord_guilds", "100")
assert g["removed_at"] is None and g["unlinked_at"] is None
assert [x["guild_id"] for x in client.get("/discord/admin/overview").json()["guilds"]] == ["100"]
async def test_redeeming_a_link_code_restores_a_removed_server(fs):
await streams.remove_guild("100", "u-admin")
minted = await glink.mint_code(ORG, "u-own")
await glink.redeem_code(minted["code"], "100", "Fire Co. 7", "4242")
g = fs.get("discord_guilds", "100")
assert g["org_id"] == ORG and g["removed_at"] is None
def test_remove_guild_can_make_pool_and_own_radio_bots_leave(fs, monkeypatch):
import httpx
fs.put("bot_tokens", "byo-mine", {"token_id": "byo-mine", "name": "Ours", "token": "secret-mine",
"org_id": ORG, "guild_ids": ["100"], "valid": True})
fs.put("bot_tokens", "byo-theirs", {"token_id": "byo-theirs", "name": "Theirs", "token": "secret-theirs",
"org_id": OTHER, "guild_ids": ["100"], "valid": True})
fs.put("bot_tokens", "elsewhere", {"token_id": "elsewhere", "name": "Elsewhere", "token": "secret-else",
"org_id": None, "guild_ids": ["300"], "valid": True})
calls = []
class _Resp:
def __init__(self, code):
self.status_code = code
class _Client:
def __init__(self, *, headers, **kw):
self.auth = headers["Authorization"]
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def delete(self, url):
calls.append((self.auth, url))
return _Resp(500 if self.auth == "Bot secret-t2" else 204)
monkeypatch.setattr(httpx, "AsyncClient", _Client)
app.dependency_overrides[require_admin_token] = lambda: ADMIN
r = client.delete("/discord/guilds/100?leave_radio_bots=true")
assert r.status_code == 200
tried = sorted(a for a, _ in calls)
# pool bots in the server + this org's BYO; never another org's bot, never a bot not in it
assert tried == ["Bot secret-mine", "Bot secret-t1", "Bot secret-t2"]
assert all(u == "/users/@me/guilds/100" for _, u in calls)
rows = {x["name"]: x for x in r.json()["radio_bots"]}
assert rows["Radio t1"]["left"] is True and rows["Radio t2"]["left"] is False
assert "secret" not in r.text
assert fs.get("bot_tokens", "t1")["guild_ids"] == ["200"]
assert fs.get("bot_tokens", "t2")["guild_ids"] == ["100", "200"] # failed: membership unchanged
assert fs.get("bot_tokens", "byo-theirs")["guild_ids"] == ["100"]
(entry,) = _audit(fs, "discord.guild.remove")
assert sorted(entry["details"]["radio_bots_left"]) == ["byo-mine", "t1"]
assert entry["details"]["radio_bots_failed"] == ["t2"]
def test_node_page_routes_need_a_signed_in_user(fs):
def deny():
raise HTTPException(401, "Missing authorization")
app.dependency_overrides[require_firebase_token] = deny
assert client.delete("/discord/nodes/n1/streams/100:1001").status_code == 401
+43 -12
View File
@@ -354,8 +354,24 @@ export function NodeDiscordRadio({ nodeId }: { nodeId: string }) {
} }
} }
async function remove(s: DiscordStreamView) {
const name = s.channel_name ?? "this channel";
if (!confirm(`Remove radio from ${name} permanently? It is stopped first (the node logs its radio bot out), then deleted from DRB.`)) return;
setBusy(`rm:${s.stream_id}`);
setActionError(null);
try {
await c2api.removeNodeDiscordStream(nodeId, s.stream_id);
await load();
} catch (err) {
setActionError(parseC2Error(err).message);
} finally {
setBusy(null);
}
}
const streams = options?.streams ?? []; const streams = options?.streams ?? [];
const canControl = options?.can_control ?? false; const canControl = options?.can_control ?? false;
const canRemove = options?.can_remove ?? false;
return ( return (
<section className="bg-surface border border-line rounded-lg overflow-hidden"> <section className="bg-surface border border-line rounded-lg overflow-hidden">
@@ -405,7 +421,7 @@ export function NodeDiscordRadio({ nodeId }: { nodeId: string }) {
<th className="px-4 py-2 text-left font-medium">Server · channel</th> <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">Mode</th>
<th className="px-4 py-2 text-left font-medium">Desired → actual</th> <th className="px-4 py-2 text-left font-medium">Desired → actual</th>
{canControl && <th className="px-4 py-2" />} {(canControl || canRemove) && <th className="px-4 py-2" />}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -419,19 +435,34 @@ export function NodeDiscordRadio({ nodeId }: { nodeId: string }) {
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<StatePill s={s} /> <StatePill s={s} />
{s.actual?.error && ( {s.actual?.error && (
<div className="text-xs text-ink-muted mt-1 max-w-xs">{s.actual.error}</div> <div className="text-xs text-ink-muted mt-1 max-w-xs">
{s.actual.error}
{s.actual.code && <span className="font-mono"> [{s.actual.code}]</span>}
</div>
)} )}
</td> </td>
{canControl && ( {(canControl || canRemove) && (
<td className="px-4 py-2.5 text-right"> <td className="px-4 py-2.5 text-right whitespace-nowrap">
<button {canControl && (
type="button" <button
disabled={busy !== null} type="button"
onClick={() => stop(s.stream_id)} disabled={busy !== null}
className="bg-raised border border-line hover:brightness-110 disabled:opacity-50 text-ink-2 rounded-md px-2 py-0.5 text-xs" 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> {busy === s.stream_id ? "Stopping…" : "Stop"}
</button>
)}
{canRemove && (
<button
type="button"
disabled={busy !== null}
onClick={() => remove(s)}
className="ml-1 bg-sev-major hover:brightness-110 disabled:opacity-50 text-white rounded-md px-2 py-0.5 text-xs"
>
{busy === `rm:${s.stream_id}` ? "Removing…" : "Remove"}
</button>
)}
</td> </td>
)} )}
</tr> </tr>
@@ -10,6 +10,8 @@ import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui";
// radio channel without being linked (it acts for DISCORD_UNLINKED_GUILD_ORG_ID). // radio channel without being linked (it acts for DISCORD_UNLINKED_GUILD_ORG_ID).
// Linking is PUT /discord/guilds/{id}/link (platform admin). The owner-side // Linking is PUT /discord/guilds/{id}/link (platform admin). The owner-side
// "/drb link code:" flow is server-26#213 item 1 and is not built here. // "/drb link code:" flow is server-26#213 item 1 and is not built here.
// Remove is DELETE /discord/guilds/{id}: its radio channels are stopped then
// deleted, and the server is unlinked with no fallback org.
function LinkForm({ function LinkForm({
overview, overview,
@@ -83,6 +85,63 @@ function LinkForm({
); );
} }
type RemoveResult = { guild: string; streams: number; bots: { name: string; left: boolean; error: string | null }[] };
function RemoveForm({
guild,
onDone,
onCancel,
}: {
guild: DiscordGuild;
onDone: (r: RemoveResult) => void;
onCancel: () => void;
}) {
const [leaveBots, setLeaveBots] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const n = guild.live_streams;
async function submit() {
setSaving(true);
setError(null);
try {
const r = await c2api.removeDiscordGuild(guild.guild_id, leaveBots);
onDone({ guild: guildLabel(guild), streams: r.streams_removed.length, bots: r.radio_bots });
} catch (err) {
setError(parseC2Error(err).message);
} finally {
setSaving(false);
}
}
return (
<div className="px-4 py-4 border-b border-line bg-raised text-xs text-ink-2 space-y-3">
<p>
<b className="text-ink">Remove {guildLabel(guild)} from DRB?</b>{" "}
{n > 0
? `Its ${n} live radio channel${n === 1 ? " is" : "s are"} stopped first, then every radio channel in it is deleted.`
: "Every radio channel in it is deleted."}{" "}
It is unlinked and will not fall back to the default organization; linking it again brings it back.
</p>
<label className="flex items-start gap-2 cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={leaveBots} onChange={(e) => setLeaveBots(e.target.checked)} />
<span>
Also make the radio bots leave this Discord server (the platform pool and this server&apos;s organization&apos;s
own bots). They need a new invite to come back.
</span>
</label>
<p className="text-ink-muted">
The DRB bot itself stays in the Discord server until someone kicks it there.
</p>
{error && <p className="text-sev-major">{error}</p>}
<div className="flex gap-2">
<Button size="sm" variant="danger" disabled={saving} onClick={submit}>{saving ? "Removing…" : "Remove server"}</Button>
<Button size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
</div>
</div>
);
}
export function ServersPanel({ export function ServersPanel({
overview, overview,
onChanged, onChanged,
@@ -92,6 +151,8 @@ export function ServersPanel({
}) { }) {
// "new" = blank link form; a guild id = editing that row's link. // "new" = blank link form; a guild id = editing that row's link.
const [editing, setEditing] = useState<string | null>(null); const [editing, setEditing] = useState<string | null>(null);
const [removing, setRemoving] = useState<string | null>(null);
const [removed, setRemoved] = useState<RemoveResult | null>(null);
const fallback = overview.limits.unlinked_guild_org_id; const fallback = overview.limits.unlinked_guild_org_id;
const fallbackName = overview.limits.unlinked_guild_org_name || fallback; const fallbackName = overview.limits.unlinked_guild_org_name || fallback;
const max = overview.limits.guild_max_streams; const max = overview.limits.guild_max_streams;
@@ -119,6 +180,19 @@ export function ServersPanel({
} }
> >
{editing === "new" && <LinkForm overview={overview} onDone={done} onCancel={() => setEditing(null)} />} {editing === "new" && <LinkForm overview={overview} onDone={done} onCancel={() => setEditing(null)} />}
{removed && (
<div className="px-4 py-3 border-b border-line text-xs text-ink-2">
Removed {removed.guild}: {removed.streams} radio channel{removed.streams === 1 ? "" : "s"} deleted.
{removed.bots.length > 0 && (
<>
{" "}Radio bots:{" "}
{removed.bots.map((b) => `${b.name} ${b.left ? "left" : `did not leave (${b.error ?? "unknown error"})`}`).join("; ")}.
</>
)}{" "}
The DRB bot is still in that Discord server until someone kicks it.
<button className="ml-3 text-accent hover:underline" onClick={() => setRemoved(null)}>Dismiss</button>
</div>
)}
{overview.guilds.length === 0 ? ( {overview.guilds.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-muted">No Discord servers yet. Link one, or run a radio command in a server the DRB bot is in.</p> <p className="px-4 py-6 text-sm text-ink-muted">No Discord servers yet. Link one, or run a radio command in a server the DRB bot is in.</p>
) : ( ) : (
@@ -147,13 +221,50 @@ export function ServersPanel({
<Status health="bad">Unlinked</Status> <Status health="bad">Unlinked</Status>
)} )}
</td> </td>
<td className={`${TD} text-right`}> <td className={`${TD} text-right whitespace-nowrap`}>
<Button size="sm" variant="secondary" onClick={() => setEditing(editing === g.guild_id ? null : g.guild_id)}> <Button
size="sm"
variant="secondary"
onClick={() => {
setRemoving(null);
setEditing(editing === g.guild_id ? null : g.guild_id);
}}
>
{g.linked ? "Change org" : "Link"} {g.linked ? "Change org" : "Link"}
</Button> </Button>
<Button
size="sm"
variant="danger"
className="ml-1"
onClick={() => {
setEditing(null);
setRemoved(null);
setRemoving(removing === g.guild_id ? null : g.guild_id);
}}
>
Remove
</Button>
</td> </td>
</tr> </tr>
); );
if (removing === g.guild_id) {
return [
row,
<tr key={`${g.guild_id}-remove`}>
<td colSpan={6} className="p-0 border-b border-line">
<RemoveForm
guild={g}
onCancel={() => setRemoving(null)}
onDone={async (r) => {
setRemoving(null);
setRemoved(r);
await onChanged();
}}
/>
</td>
</tr>,
];
}
if (editing !== g.guild_id) return [row]; if (editing !== g.guild_id) return [row];
return [ return [
row, row,
@@ -18,6 +18,8 @@ import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
// Radio channels = discord_streams docs, desired vs actual. Start/Stop/Add go // Radio channels = discord_streams docs, desired vs actual. Start/Stop/Add go
// through the same routes the Discord bot uses (POST /discord/streams, // through the same routes the Discord bot uses (POST /discord/streams,
// POST /discord/guilds/{id}/stop), which accept a platform-admin token. // POST /discord/guilds/{id}/stop), which accept a platform-admin token.
// Remove is DELETE /discord/streams/{id} (admin): c2-core stops the channel
// through desired state first, then deletes it.
type Invite = { name: string; invite_url: string | null }[]; type Invite = { name: string; invite_url: string | null }[];
@@ -214,7 +216,12 @@ export function StreamsPanel({
<td className={TD}>{s.mode === "adhoc" ? "ad-hoc" : s.mode ?? "—"}</td> <td className={TD}>{s.mode === "adhoc" ? "ad-hoc" : s.mode ?? "—"}</td>
<td className={TD}> <td className={TD}>
<Status health={health}>{label}</Status> <Status health={health}>{label}</Status>
{isOn && s.actual?.error && <div className={`${DIM} mt-1 max-w-[16rem]`}>{s.actual.error}</div>} {isOn && s.actual?.error && (
<div className={`${DIM} mt-1 max-w-[16rem]`}>
{s.actual.error}
{s.actual.code && <span className="font-mono"> [{s.actual.code}]</span>}
</div>
)}
{!isOn && s.stop_reason && <div className={`${DIM} mt-1`}>{s.stop_reason}</div>} {!isOn && s.stop_reason && <div className={`${DIM} mt-1`}>{s.stop_reason}</div>}
{err && ( {err && (
<div className="text-xs text-sev-major mt-1 max-w-[16rem]"> <div className="text-xs text-sev-major mt-1 max-w-[16rem]">
@@ -224,7 +231,7 @@ export function StreamsPanel({
)} )}
</td> </td>
<td className={TD}>{s.token_name ?? <span className={DIM}>—</span>}</td> <td className={TD}>{s.token_name ?? <span className={DIM}>—</span>}</td>
<td className={`${TD} text-right`}> <td className={`${TD} text-right whitespace-nowrap`}>
{isOn ? ( {isOn ? (
<Button <Button
size="sm" size="sm"
@@ -256,6 +263,22 @@ export function StreamsPanel({
{busy === s.stream_id ? "Starting…" : "Start"} {busy === s.stream_id ? "Starting…" : "Start"}
</Button> </Button>
)} )}
<Button
size="sm"
variant="danger"
className="ml-1"
disabled={busy !== null}
onClick={() => {
const name = s.channel_name || "this channel";
const msg = isOn
? `Remove radio from ${name} permanently? It is stopped first (the node logs its radio bot out), then deleted from DRB.`
: `Remove ${name} from DRB permanently?`;
if (!confirm(msg)) return;
act(s, () => c2api.deleteDiscordStream(s.stream_id));
}}
>
Remove
</Button>
</td> </td>
</tr> </tr>
); );
+21
View File
@@ -84,6 +84,21 @@ export const c2api = {
channel_name?: string; channel_name?: string;
preferred_token_id?: string; preferred_token_id?: string;
}) => request<DiscordStream>("/discord/streams", { method: "POST", body: JSON.stringify(body) }), }) => request<DiscordStream>("/discord/streams", { method: "POST", body: JSON.stringify(body) }),
/** Permanently remove a radio channel (stopped through desired state first). Admin only. */
deleteDiscordStream: (streamId: string) =>
request<{ ok: boolean; stream_id: string }>(`/discord/streams/${encodeURIComponent(streamId)}`, {
method: "DELETE",
}),
/** Remove a Discord server from DRB: its radio channels are deleted and it is unlinked with no fallback org. */
removeDiscordGuild: (guildId: string, leaveRadioBots: boolean) =>
request<{
ok: boolean;
guild_id: string;
streams_removed: string[];
radio_bots: { name: string; left: boolean; error: string | null }[];
}>(`/discord/guilds/${encodeURIComponent(guildId)}?leave_radio_bots=${leaveRadioBots ? "true" : "false"}`, {
method: "DELETE",
}),
stopDiscordStream: (guildId: string, channelId: string) => stopDiscordStream: (guildId: string, channelId: string) =>
request<{ stopped: string[] }>(`/discord/guilds/${encodeURIComponent(guildId)}/stop`, { request<{ stopped: string[] }>(`/discord/guilds/${encodeURIComponent(guildId)}/stop`, {
method: "POST", method: "POST",
@@ -116,6 +131,12 @@ export const c2api = {
method: "POST", method: "POST",
body: JSON.stringify(streamId ? { stream_id: streamId } : {}), body: JSON.stringify(streamId ? { stream_id: streamId } : {}),
}), }),
/** Permanently remove one radio channel from this node (org owner or admin). */
removeNodeDiscordStream: (nodeId: string, streamId: string) =>
request<{ ok: boolean; stream_id: string }>(
`/discord/nodes/${nodeId}/streams/${encodeURIComponent(streamId)}`,
{ method: "DELETE" },
),
deleteNode: (id: string) => deleteNode: (id: string) =>
request(`/nodes/${id}`, { method: "DELETE" }), request(`/nodes/${id}`, { method: "DELETE" }),
+2
View File
@@ -41,6 +41,8 @@ export interface RadioBotAddResult {
export interface DiscordStreamActual { export interface DiscordStreamActual {
state?: string; state?: string;
error?: string | null; error?: string | null;
/** The node's reason code on error: login_failed, connect_failed, not_in_guild, bad_channel, no_audio. */
code?: string | null;
since?: string | null; since?: string | null;
at?: string | null; at?: string | null;
} }
+3 -1
View File
@@ -22,7 +22,7 @@ export interface DiscordStreamView {
mode: "persistent" | "adhoc" | null; mode: "persistent" | "adhoc" | null;
node_id: string | null; node_id: string | null;
node_name: string | null; node_name: string | null;
actual: { state: StreamState; error: string | null; since?: string; at?: string } | null; actual: { state: StreamState; error: string | null; code?: string | null; since?: string; at?: string } | null;
updated_at: string | null; updated_at: string | null;
} }
@@ -56,6 +56,8 @@ export interface DiscordJoinOptions {
system_id: string | null; system_id: string | null;
system_name: string | null; system_name: string | null;
can_control: boolean; can_control: boolean;
/** Permanent removal: platform admin or the org owner only (operators can stop, not remove). */
can_remove?: boolean;
limits: { node_max: number; node_used: number; guild_max: number }; limits: { node_max: number; node_used: number; guild_max: number };
guilds: DiscordGuildOption[]; guilds: DiscordGuildOption[];
streams: DiscordStreamView[]; streams: DiscordStreamView[];