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
+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}