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
+65 -2
View File
@@ -9,6 +9,8 @@ system/node/bot lists a server sees are its own org's.
Auth:
* bot routes — the Discord bot's service key, or a platform admin.
* 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;
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 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.security import HTTPAuthorizationCredentials, HTTPBearer
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_streams as streams
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
router = APIRouter(prefix="/discord", tags=["discord"])
_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:
@@ -162,6 +168,8 @@ async def link_guild(guild_id: str, body: GuildLink, decoded: dict = Depends(req
doc = {
"guild_id": guild_id, "org_id": body.org_id,
"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():
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}
@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)
# ---------------------------------------------------------------------------
@@ -201,6 +262,8 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
guilds: dict[str, dict] = {}
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")),
"org_id": link.get("org_id"), "linked_at": link.get("linked_at"),
# Explicitly unlinked servers get no fallback org (mirrors
+41 -1
View File
@@ -24,13 +24,15 @@ Auth:
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.
* 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 datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi.responses import JSONResponse
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_streams as streams
from app.internal import firestore as fstore
from app.internal.audit import write_audit
from app.internal.auth import (
describe_actor,
get_org_role,
get_role,
require_firebase_token,
@@ -110,6 +114,13 @@ async def _node_for_caller(node_id: str, decoded: dict) -> tuple[dict, bool]:
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]:
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_name": (system or {}).get("name") or system_id,
"can_control": can_control,
"can_remove": _can_remove(decoded),
"limits": {
"node_max": settings.discord_node_max_streams,
"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")
return {"ok": True, "stopped": [body.stream_id]}
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}