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:
co-authored by
Claude Opus 5.5
parent
fabdf5839e
commit
c92bdfe841
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user