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