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,
"unlinked_at": None,
"unlinked_by": None,
# A server an admin removed (streams.remove_guild) comes back on link.
"removed_at": None,
"removed_by": None,
}, merge=True)
return {"org_id": org_id, "already_linked": current == org_id,
"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)
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:
"""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."""
@@ -411,3 +435,42 @@ async def refresh_membership(token_ids: list[str]) -> int:
changed += 1
await fstore.doc_update(TOKENS, tok_id, updates)
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.)
desired: "on" | "off"
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 |
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
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:
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
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]:
"""Stop every live stream in ONE Discord server (optionally one channel).
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"}
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:
"""``nodes/{node_id}/discord/actual``. A node may only report on streams
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"
error = st.get("error")
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"])
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
else:
since = now
_log_transition(node_id, doc, prev.get("state"), state, error, code)
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:
await leases.refresh_membership(sorted(recheck))