Files
server-26/drb-c2-core/app/internal/discord_streams.py
T
Logan CusanoandClaude Opus 5.5 2c2cf9d9be Discord: check bot permissions before handing a token to a node (server-26#225 S3)
start_stream now asks Discord (cached REST snapshots, S2) whether each bot
it could pick may View, Connect and Speak in the voice channel, before the
lease. Covers all four entry points: bot POST /discord/streams, web picker,
nodes discord_join, and the reconciler's re-place.

- pick_token: denied bots skipped; confirmed-ok sorts before unknown;
  unknown is allowed (fail open); all denied -> missing_permissions naming
  each bot, missing permission, trace reason and fix. The reuse branch
  falls through when its token is denied.
- Bounded: discord_perm_check_concurrency at once, within
  discord_perm_check_budget_seconds; unanswered = unknown.
- Non-voice channel -> 400 bad_channel (not missing_permissions).
- systems/{id}.preferred_token_id is now read (plan finding 1), as a hint.
- Pool bots at discord_pool_bot_guild_cap are never offered as invites;
  pool_full when nothing else is left (owner Q3).
- handle_actual drops cached access on missing_permissions, channel_full,
  not_in_guild, bad_channel.
- New GET /discord/nodes/{node_id}/guilds/{guild_id}/voice-check (org
  member, names only) and GET /discord/guilds/{guild_id}/voice-check
  (service key or admin).
- PUT /tokens/{id}/prefer/{system_id}: org owners for their own systems
  and own BYO bots; shared systems and pool bots stay admin-only (owner Q2).
  Keeps both pointers single-valued; audited.
- Kill switch: DISCORD_PERM_CHECK_ENABLED=false.
- tests/conftest.py: autouse fixture so no test ever reaches discord.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:22:45 -04:00

966 lines
48 KiB
Python

"""
Discord radio channels as desired state (server-26#203, #204; node-26#25).
One ``discord_streams/{stream_id}`` doc per radio stream. A Discord voice
channel may carry SEVERAL streams (server-26#213 follow-up, owner decision
2026-10-05): each has its own radio bot and plays its own system, so a channel
can hold e.g. County P25 on one bot and City Fire on another. ``stream_id`` is
``{guild_id}:{channel_id}:{suffix}`` (``discord_leases.new_stream_id``); docs
from before are ``{guild_id}:{channel_id}`` and are read, stopped and removed
exactly the same way. Adding a stream never reuses another stream's doc except
the same system in the same channel (idempotent re-add / restart). Fields:
org_id, guild_id, channel_id, channel_name, guild_name,
system_id, system_name,
mode: "persistent" | "adhoc"
persistent — stays connected and is re-placed by the reconciler (node
reboot, node lost, C2 restart) until someone stops it.
adhoc — stopped instead of re-placed when its node goes away.
(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, 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,
missing_permissions, channel_full) 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
Before a token is leased, ``start_stream`` asks Discord (REST, cached —
discord_rest.guild_snapshot) whether each bot it could pick may View, Connect
and Speak in the voice channel (server-26#225 S3). A bot that is denied is
never handed to a node; when every bot is denied the start is refused with
the bot, the channel, the missing permission and what removed it. A check
that can't be completed is "unknown" and allowed (fail open). Kill switch:
``DISCORD_PERM_CHECK_ENABLED=false``. The system's preferred bot
(``systems/{id}.preferred_token_id``) is tried first when nobody named one.
Flow: ``start_stream`` picks a node + leases a token -> ``publish_desired``
retains ``nodes/{node}/discord/desired`` (full list for that node, with a
monotonically increasing ``seq``) -> the edge reconciles and publishes
``nodes/{node}/discord/actual`` -> ``handle_actual`` updates ``actual`` and
settles token releases. ``reconcile_loop`` re-places persistent streams whose
node went away and retries desired-state publishes that never reached the
broker.
The desired payload carries token **ids**, never token strings: the edge
fetches the string over HTTPS (GET /discord/node/{node_id}/tokens/{id}) with
its own node API key, and C2 hands it out only to the node holding the lease.
A retained MQTT message therefore never stores a bot credential on the broker.
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone, timedelta
from typing import Optional
from app.config import settings
from app.internal import firestore as fstore
from app.internal import discord_leases as leases
from app.internal import discord_perms
from app.internal import discord_rest
from app.internal.logger import logger
STREAMS = leases.STREAMS
GUILDS = "discord_guilds" # discord_guilds/{guild_id}: {org_id, linked_by, linked_at}
NODE_STATE = "discord_nodes" # discord_nodes/{node_id}: {seq, published, applied_seq, actual_at}
ONLINE = ("online", "recording")
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _effective_system(node: dict) -> Optional[str]:
if node.get("is_overridden") and node.get("override_system_id"):
return node["override_system_id"]
return node.get("assigned_system_id")
def _parse_ts(value) -> Optional[datetime]:
try:
ts = datetime.fromisoformat(value) if isinstance(value, str) else value
except (TypeError, ValueError):
return None
if isinstance(ts, datetime) and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
return ts if isinstance(ts, datetime) else None
# ---------------------------------------------------------------------------
# Guild -> org
# ---------------------------------------------------------------------------
async def resolve_guild_org(guild_id: str) -> Optional[str]:
"""The org a Discord server acts for: its ``discord_guilds`` link, else
``settings.discord_unlinked_guild_org_id`` (None when that is empty).
A server an org explicitly unlinked (``unlinked_at`` set, server-26#213)
gets no fallback: otherwise unlinking org B's server would quietly hand it
to the founding org."""
link = await fstore.doc_get(GUILDS, str(guild_id))
if link and link.get("org_id"):
return link["org_id"]
if link and link.get("unlinked_at"):
return None
return settings.discord_unlinked_guild_org_id or None
async def _node_stream_counts(exclude_stream_id: Optional[str] = None) -> dict[str, int]:
streams = await fstore.collection_list(STREAMS, desired="on")
counts: dict[str, int] = {}
for s in streams:
if s.get("node_id") and s.get("stream_id") != exclude_stream_id:
counts[s["node_id"]] = counts.get(s["node_id"], 0) + 1
return counts
def _node_usable(n: dict, org_id: str, system_id: str) -> bool:
return (
n.get("org_id") == org_id
and n.get("status") in ONLINE
and n.get("approval_status") in (None, "approved")
and _effective_system(n) == system_id
)
async def candidate_nodes(org_id: str, system_id: str, exclude_stream_id: Optional[str] = None) -> list[dict]:
"""Online, approved nodes of this org currently listening to ``system_id``,
best first: nodes already carrying streams (one gateway session can serve
several guilds) but under the per-node cap, then the rest."""
nodes = await fstore.collection_list("nodes", org_id=org_id)
counts = await _node_stream_counts(exclude_stream_id)
cap = settings.discord_node_max_streams
out = [n for n in nodes
if n.get("node_id") and _node_usable(n, org_id, system_id) and counts.get(n["node_id"], 0) < cap]
out.sort(key=lambda n: (-counts.get(n["node_id"], 0), n.get("name") or n["node_id"]))
return out
# ---------------------------------------------------------------------------
# Desired state -> node
# ---------------------------------------------------------------------------
# One c2-core process publishes desired state. Serialising bump->build->publish
# per node is what keeps the RETAINED message the newest one: two unserialised
# publishes could land seq 7 then seq 6, and a reconnecting edge would then be
# handed the stale list.
_publish_locks: dict[str, asyncio.Lock] = {}
def _lock_for(node_id: str) -> asyncio.Lock:
lock = _publish_locks.get(node_id)
if lock is None:
lock = _publish_locks[node_id] = asyncio.Lock()
return lock
async def _bump_seq(node_id: str) -> int:
db = fstore.db
def _txn(txn):
ref = db.collection(NODE_STATE).document(node_id)
snap = ref.get(transaction=txn)
seq = int(((snap.to_dict() or {}) if snap.exists else {}).get("seq") or 0) + 1
txn.set(ref, {"node_id": node_id, "seq": seq}, merge=True)
return seq
return await fstore.run_transaction(_txn)
async def desired_for_node(node_id: str) -> list[dict]:
streams = await fstore.collection_list(STREAMS, node_id=node_id)
out = []
for s in streams:
if s.get("desired") != "on" or not s.get("token_id"):
continue
out.append({
"stream_id": s["stream_id"],
"guild_id": s["guild_id"],
"channel_id": s["channel_id"],
"token_id": s["token_id"],
"lease_id": s.get("lease_id"),
"system_name": s.get("system_name"),
"mode": s.get("mode", "adhoc"),
# Phase-2 seam: the edge ignores this today (no TG gate yet).
"tg_filter": s.get("tg_filter"),
})
out.sort(key=lambda d: d["stream_id"])
return out
async def publish_desired(node_id: str) -> bool:
"""Retain the node's full stream list under a fresh ``seq``.
Releasing tokens are stamped with that seq BEFORE the publish, so the
earliest ack that can free them is one for a list that already lacks them.
Returns whether the broker took the message; a miss is recorded and the
reconciler retries it (nothing is lost — the streams stay desired)."""
from app.internal.mqtt_handler import mqtt_handler
async with _lock_for(node_id):
seq = await _bump_seq(node_id)
# Stamp BEFORE building the list: a stream detached between the two
# (stop_stream does not hold this lock) must not get this seq, because
# the list built here could still contain it — an ack for it would then
# free a token the node was still told to use.
await leases.stamp_release_seq(node_id, seq)
payload = {"seq": seq, "streams": await desired_for_node(node_id), "issued_at": _now_iso()}
ok = mqtt_handler.publish_discord_desired(node_id, payload)
await fstore.doc_set(NODE_STATE, node_id, {"published": bool(ok), "published_at": _now_iso()})
if not ok:
logger.warning(f"Discord desired state for {node_id} (seq {seq}) not delivered — reconciler will retry")
return bool(ok)
# ---------------------------------------------------------------------------
# Discord permission check (server-26#225 S3)
# ---------------------------------------------------------------------------
# Trace sentences (discord_perms) that only say a permission went with
# another one. The fix is for the other one, so they are not what we report.
_IMPLIED = ("without View Channel", "without Connect")
def _join_labels(labels: list) -> str:
if len(labels) <= 1:
return "".join(labels)
return ", ".join(labels[:-1]) + " or " + labels[-1]
def _fix_text(bot: str, missing: list, reason: str) -> str:
if "timed out" in reason:
return (f"In Discord: remove {bot}'s timeout (right-click it in the member list → Remove Timeout), "
"then try again.")
allow = " and ".join(missing) if len(missing) <= 2 else ", ".join(missing[:-1]) + " and " + missing[-1]
return f"In Discord: Edit Channel → Permissions → add {bot} → allow {allow}, then try again."
def evaluate_access(snap: "discord_rest.GuildSnapshot", channel_id: str, *, bot: str) -> dict:
"""One bot's verdict for one voice channel, from its cached snapshot.
``status``: ok | denied | unknown | not_member | invalid | bad_channel
(the channel exists but is not a voice channel). ``missing`` lists the
edge's labels ("View Channel", "Connect", "Speak"); ``reason`` is the
trace sentence for the first one that isn't just implied by another;
``fix`` is what to change in Discord. Never raises."""
out: dict = {"status": discord_rest.UNKNOWN, "missing": [], "reason": None, "fix": None,
"user_limit": 0, "limit_bypass": False, "channel_name": None}
if snap.status != discord_rest.OK:
out["status"] = snap.status
out["error"] = snap.error
return out
channel = next((c for c in snap.channels or []
if isinstance(c, dict) and str(c.get("id")) == str(channel_id)), None)
if channel is None:
# Deleted, or hidden from the bot's list: the edge's join is the judge.
out["error"] = "channel not in the server's channel list"
return out
out["channel_name"] = channel.get("name")
try:
bits, trace = discord_perms.effective_permissions(snap.guild or {}, snap.member or {}, channel)
except (ValueError, TypeError, AttributeError) as e:
out["error"] = f"could not read permissions ({type(e).__name__})"
return out
verdict = discord_perms.check_voice(bits, channel, trace)
out["user_limit"] = verdict["user_limit"]
out["limit_bypass"] = verdict["limit_bypass"]
if not verdict["is_voice"]:
out["status"] = "bad_channel"
return out
if verdict["ok"]:
out["status"] = leases.PERM_OK
return out
missing = verdict["missing"]
reasons = verdict["reasons"]
primary = [m for m in missing if not any(x in (reasons.get(m) or "") for x in _IMPLIED)] or missing
out.update(status=leases.PERM_DENIED, missing=missing, reasons=reasons,
reason=reasons.get(primary[0]) or "not granted",
fix=_fix_text(bot, primary, reasons.get(primary[0]) or ""))
return out
def tokens_to_check(tokens: dict[str, dict], *, guild_id: str, org_id: Optional[str],
node_ids: Optional[set] = None, exclude_stream_id: Optional[str] = None,
preferred_token_id: Optional[str] = None) -> list[str]:
"""The bots ``pick_token`` could hand this stream to, best first — the
only ones worth asking Discord about. ``node_ids`` None = any node."""
out = []
for tok_id, tok in tokens.items():
if tok.get("valid") is False or not tok.get("token"):
continue
if tok.get("org_id") is not None and tok.get("org_id") != org_id:
continue # another org's bot is never asked about this org's server
members = tok.get("guild_ids")
if members is not None and guild_id not in members:
continue
if preferred_token_id:
if tok_id == preferred_token_id:
out.append(tok_id)
continue
lease_node = (tok.get("lease") or {}).get("node_id")
if lease_node and node_ids is not None and lease_node not in node_ids:
continue
if any(leases.guild_of(s) == guild_id
for s in leases._lease_streams(tok) if s != exclude_stream_id):
continue
out.append(tok_id)
out.sort(key=lambda t: leases._candidate_order(t, tokens[t], "", org_id, None))
return out
def _unanswered() -> dict:
return {"status": discord_rest.UNKNOWN, "missing": [], "reason": None, "fix": None,
"error": "Discord did not answer in time"}
async def gather_snapshots(tokens: dict[str, dict], token_ids: list[str], guild_id: str, *,
refresh: bool = False, budget: Optional[float] = None) -> dict:
"""``discord_rest.guild_snapshot`` for each bot, at most
``discord_perm_check_concurrency`` at a time, all within
``discord_perm_check_budget_seconds``. A bot not answered in time maps
to None — unknown (its load keeps running and fills the cache for next
time: the cache shields it from this cancellation)."""
out: dict = {}
if not token_ids:
return out
sem = asyncio.Semaphore(max(1, settings.discord_perm_check_concurrency))
async def _one(tok_id: str) -> None:
async with sem:
out[tok_id] = await discord_rest.guild_snapshot({**tokens[tok_id], "token_id": tok_id}, guild_id,
refresh=refresh)
tasks = [asyncio.create_task(_one(t)) for t in token_ids]
_, pending = await asyncio.wait(tasks, timeout=budget if budget is not None
else settings.discord_perm_check_budget_seconds)
for t in pending:
t.cancel()
for t in tasks:
if t.done() and not t.cancelled() and t.exception() is not None:
logger.warning(f"Discord permission check failed: {type(t.exception()).__name__}")
return {t: out.get(t) for t in token_ids}
async def check_bots(tokens: dict[str, dict], token_ids: list[str], guild_id: str, channel_id: str,
*, refresh: bool = False, budget: Optional[float] = None) -> dict[str, dict]:
"""``evaluate_access`` for each bot in one voice channel. Never raises."""
snaps = await gather_snapshots(tokens, token_ids, guild_id, refresh=refresh, budget=budget)
return {t: (evaluate_access(snaps[t], channel_id, bot=tokens[t].get("name") or t) if snaps.get(t)
else _unanswered())
for t in token_ids}
async def _perm_check(*, org_id: str, guild_id: str, channel_id: str, stream_id: str,
preferred_token_id: Optional[str], node_ids: set) -> Optional[dict]:
if not settings.discord_perm_check_enabled:
return None
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
ids = tokens_to_check(tokens, guild_id=guild_id, org_id=org_id, node_ids=node_ids,
exclude_stream_id=stream_id, preferred_token_id=preferred_token_id)
return await check_bots(tokens, ids, guild_id, channel_id)
def _channel_ref(perms: Optional[dict], channel_name: Optional[str]) -> str:
name = next((p.get("channel_name") for p in (perms or {}).values() if p.get("channel_name")), None)
name = name or channel_name
return f"#{name}" if name else "that voice channel"
def _missing_permissions_error(denied: list, invite: list, chan: str) -> "StreamError":
def line(d: dict) -> str:
return f"{d['name']} can't {_join_labels(d.get('missing') or ['Speak'])} in {chan}: {d.get('reason')}."
if len(denied) == 1:
d = denied[0]
text = f"{line(d)} {d.get('fix') or ''}".strip()
else:
shown = denied[:3]
text = f"No radio bot can play in {chan}:\n" + "\n".join(f"- {line(d)}" for d in shown)
if len(denied) > len(shown):
text += f"\n- …and {len(denied) - len(shown)} more."
text += "\nIn Discord: Edit Channel → Permissions → add one of these bots → allow the missing " \
"permission, then try again."
if invite:
text += " Or invite another radio bot with a link below."
return StreamError("missing_permissions", text, invite=invite, extra={"denied": denied})
# ---------------------------------------------------------------------------
# Start / stop
# ---------------------------------------------------------------------------
class StreamError(Exception):
def __init__(self, code: str, message: str, invite: Optional[list] = None, status: int = 409,
extra: Optional[dict] = None):
super().__init__(message)
self.code = code
self.message = message
self.invite = invite or []
self.status = status
self.extra = extra or {}
ERROR_TEXT = {
"no_org": "This Discord server is not linked to a DRB organization yet.",
"no_node": "No online node in your organization is listening to that system right now.",
"pool_empty": "No radio bots have been added to DRB yet. A platform admin adds them under Radio bots.",
"no_bot_in_guild": "No free radio bot is a member of this server. Invite one with the link below, then try again.",
"pool_exhausted": ("Every radio bot that can join this server is already busy — a bot can be in only one voice "
"channel per server, and a bot carrying radio on another node can't be shared. Add another "
"radio bot to DRB and invite it to this server, or stop a radio channel here."),
"limit": "This server already has its maximum number of radio channels. Stop one with /leave first.",
"preferred_unavailable": "That radio bot can't be used here (it was removed, failed validation, or belongs to "
"another organization). Pick another bot, or let DRB choose any free one.",
"pool_full": ("No radio bot is in this server, and every DRB radio bot that could be invited is already in its "
"maximum number of servers. A platform admin needs to add another radio bot."),
"missing_permissions": "No radio bot has the Discord permissions it needs in that voice channel.",
"bad_channel": "That is not a voice channel. Radio can only play in a voice channel.",
"not_found": "That radio channel no longer exists.",
}
# One c2-core process. Serialising start_stream per guild makes "is this
# system already playing in this channel?" + "is the server at its cap?" +
# create a real check-then-write: two concurrent adds of the same system to
# one channel land on ONE stream, and two adds can't both take the last slot.
_guild_locks: dict[str, asyncio.Lock] = {}
def _guild_lock(guild_id: str) -> asyncio.Lock:
lock = _guild_locks.get(guild_id)
if lock is None:
lock = _guild_locks[guild_id] = asyncio.Lock()
return lock
def _stream_label(s: dict) -> str:
where = f"#{s['channel_name']}" if s.get("channel_name") else f"channel {s.get('channel_id')}"
return f"{s.get('system_name') or s.get('system_id') or 'radio'} in {where}"
async def find_channel_stream(guild_id: str, channel_id: str, system_id: str) -> Optional[dict]:
"""The stream already carrying ``system_id`` in this voice channel: a live
one first, else the most recently updated stopped one (restarted instead
of piling up a new doc per /join + /leave). None if there is neither."""
same = [s for s in await fstore.collection_list(STREAMS, guild_id=str(guild_id))
if str(s.get("channel_id")) == str(channel_id) and s.get("system_id") == system_id and s.get("stream_id")]
live = sorted((s for s in same if s.get("desired") == "on"), key=lambda s: s.get("created_at") or "")
if live:
return live[0]
same.sort(key=lambda s: s.get("updated_at") or "", reverse=True)
return same[0] if same else None
async def _lease_error(res: leases.LeaseResult, chan: str = "that voice channel") -> "StreamError":
code = res.error or "no_node"
d = res.detail or {}
bot = d.get("token_name") or "That radio bot"
if code == "missing_permissions" and d.get("denied"):
return _missing_permissions_error(d["denied"], res.invite, chan)
if code == "preferred_busy":
busy = (await fstore.doc_get(STREAMS, d["busy_stream_id"]) if d.get("busy_stream_id") else None) or {}
what = _stream_label(busy) if busy else "another radio channel"
return StreamError(code, f"{bot} is already playing {what} in this server. A bot can be in only one voice "
"channel per server — pick another bot, or let DRB choose any free one.",
extra={"busy_stream_id": d.get("busy_stream_id")})
if code == "preferred_other_node":
node = (await fstore.doc_get("nodes", d["node_id"]) if d.get("node_id") else None) or {}
return StreamError(code, f"{bot} is carrying radio on another node ({node.get('name') or d.get('node_id')}) "
"that can't serve this channel. Pick another bot, or let DRB choose any free one.")
return StreamError(code, ERROR_TEXT.get(code, code), invite=res.invite)
async def start_stream(
*,
org_id: str,
guild_id: Optional[str] = None,
channel_id: Optional[str] = None,
system_id: str,
mode: str = "adhoc",
channel_name: Optional[str] = None,
guild_name: Optional[str] = None,
created_by: Optional[str] = None,
node_id: Optional[str] = None,
preferred_token_id: Optional[str] = None,
max_streams: Optional[int] = None,
stream_id: Optional[str] = None,
) -> dict:
"""Put ``system_id`` on a voice channel.
* ``stream_id`` given — restart / edit THAT stream (the reconciler, the
admin page's Start button). Its guild and channel come from the doc.
* otherwise — ADD a stream to ``guild_id:channel_id``. If this system is
already on in that channel the existing stream is returned (idempotent
re-add, ``already_on``); a stopped one with this system is restarted;
anything else gets a brand-new stream id, so another system's stream in
the same channel is never touched.
``preferred_token_id`` is strict: a bot already in this server, on another
node, or unusable is refused with a reason, never silently swapped. With
none given, the system's ``preferred_token_id`` is tried first (a hint:
another bot is used when it can't take the stream).
Every bot that could be picked is permission-checked against the voice
channel first (``_perm_check``; server-26#225). Every entry point — the
bot's /join, the web picker, the node command and the reconciler —
comes through here, so none can hand a node a bot Discord will refuse.
"""
if mode not in ("persistent", "adhoc"):
raise StreamError("bad_mode", "mode must be persistent or adhoc", status=400)
if not org_id:
raise StreamError("no_org", ERROR_TEXT["no_org"], status=403)
existing: dict = {}
if stream_id:
existing = await fstore.doc_get(STREAMS, stream_id) or {}
if not existing:
raise StreamError("not_found", ERROR_TEXT["not_found"], status=404)
doc_guild, doc_channel = str(existing.get("guild_id")), str(existing.get("channel_id"))
if (guild_id and str(guild_id) != doc_guild) or (channel_id and str(channel_id) != doc_channel):
raise StreamError("bad_stream", "That radio channel belongs to a different server or voice channel.",
status=400)
guild_id, channel_id = doc_guild, doc_channel
if not guild_id or not channel_id:
raise StreamError("bad_stream", "guild_id and channel_id are required.", status=400)
guild_id, channel_id = str(guild_id), str(channel_id)
async with _guild_lock(guild_id):
return await _start_locked(
org_id=org_id, guild_id=guild_id, channel_id=channel_id, system_id=system_id, mode=mode,
channel_name=channel_name, guild_name=guild_name, created_by=created_by, node_id=node_id,
preferred_token_id=preferred_token_id, max_streams=max_streams, stream_id=stream_id,
existing=existing,
)
async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_id: str, mode: str,
channel_name: Optional[str], guild_name: Optional[str], created_by: Optional[str],
node_id: Optional[str], preferred_token_id: Optional[str], max_streams: Optional[int],
stream_id: Optional[str], existing: dict) -> dict:
already_on = False
if stream_id:
existing = await fstore.doc_get(STREAMS, stream_id) or existing # re-read under the lock
else:
existing = await find_channel_stream(guild_id, channel_id, system_id) or {}
if existing.get("desired") == "on":
already_on = True
if preferred_token_id and existing.get("token_id") and existing["token_id"] != preferred_token_id:
tok = await fstore.doc_get(leases.TOKENS, existing["token_id"]) or {}
raise StreamError("duplicate", f"{_stream_label(existing)} is already playing on radio bot "
f"{tok.get('name') or existing['token_id']}. Remove that one "
"first, or pick another system.",
extra={"busy_stream_id": existing["stream_id"]})
# A re-add is not an edit: keep the running stream's label and mode.
channel_name, mode = None, existing.get("mode") or mode
stream_id = existing.get("stream_id") or leases.new_stream_id(guild_id, channel_id)
if max_streams is not None and existing.get("desired") != "on":
live = [s for s in await fstore.collection_list(STREAMS, guild_id=guild_id) if s.get("desired") == "on"]
if len(live) >= max_streams:
raise StreamError("limit", ERROR_TEXT["limit"])
system = await fstore.doc_get_cached("systems", system_id) or {}
# Finding 1 (#225 plan): "preferred for this system" was written by
# PUT /tokens/{id}/prefer/{system} and never read. It is a hint, not a
# demand — an explicit preferred_token_id still wins and stays strict.
# Read uncached: the systems cache lives 5 min, and a preference the
# owner just set should apply to the next start.
soft_pref = None
if not preferred_token_id:
soft_pref = ((await fstore.doc_get("systems", system_id)) or {}).get("preferred_token_id") or None
if node_id:
node = await fstore.doc_get("nodes", node_id)
# A pinned node still has to be this org's and approved; it may be
# listening to anything (the web "Join Discord" button pins a node).
if not node or node.get("org_id") != org_id or node.get("approval_status") not in (None, "approved"):
raise StreamError("no_node", ERROR_TEXT["no_node"])
candidates = [node]
else:
candidates = await candidate_nodes(org_id, system_id, exclude_stream_id=stream_id)
cur = existing.get("node_id") if existing.get("system_id") == system_id else None
held_on = None
if preferred_token_id or soft_pref:
# A bot can only run on the node it is logged in on.
tok = await fstore.doc_get(leases.TOKENS, preferred_token_id or soft_pref) or {}
held_on = (tok.get("lease") or {}).get("node_id")
# Keep the current node when it is still a valid choice (reuse the
# lease) — unless a bot was demanded, whose node then comes first.
order = [held_on, cur] if preferred_token_id else [cur, held_on]
for nid in reversed(order):
if nid:
candidates.sort(key=lambda n, nid=nid: 0 if n["node_id"] == nid else 1)
if not candidates:
raise StreamError("no_node", ERROR_TEXT["no_node"])
now = _now_iso()
fields = {
"stream_id": stream_id, "org_id": org_id, "guild_id": guild_id, "channel_id": channel_id,
"system_id": system_id, "system_name": system.get("name") or system_id,
"mode": mode, "desired": "on", "tg_filter": None, "stop_reason": None,
"updated_at": now,
}
prev_actual = existing.get("actual") or {}
if existing.get("desired") != "on" or prev_actual.get("state") not in ("connected", "connecting"):
fields["actual"] = {"state": "pending", "error": None, "since": now, "at": now}
if channel_name:
fields["channel_name"] = channel_name
if guild_name:
fields["guild_name"] = guild_name
if not existing:
fields["created_at"] = now
fields["created_by_discord_id"] = created_by
fields["now_playing_message_id"] = None
node_ids = {n["node_id"] for n in candidates}
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id, stream_id=stream_id,
preferred_token_id=preferred_token_id, node_ids=node_ids)
if perms and any(p.get("status") == "bad_channel" for p in perms.values()):
raise StreamError("bad_channel", f"{_channel_ref(perms, channel_name)} is not a voice channel. "
"Radio can only play in a voice channel — pick one from the list.",
status=400)
last: Optional[leases.LeaseResult] = None
perm_refusal: Optional[leases.LeaseResult] = None
prev_node = existing.get("node_id")
refreshed = False
for node in candidates:
res = await leases.lease_for_stream(stream_id, node_id=node["node_id"], org_id=org_id,
preferred_token_id=preferred_token_id, stream_fields=fields,
soft_preferred_token_id=soft_pref, perms=perms)
if not res.ok and res.error == "no_bot_in_guild" and not refreshed:
# Membership is cached on the token doc; someone may have just
# invited a bot. Re-read it from Discord once, then retry.
refreshed = True
tokens = await fstore.collection_list(leases.TOKENS)
stale = [t["token_id"] for t in tokens
if t.get("token_id") and t.get("guild_ids") is not None and guild_id not in t["guild_ids"]]
if await discord_rest.refresh_membership(stale):
if perms is not None:
# Bots that just turned out to be members get checked too.
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id,
stream_id=stream_id, preferred_token_id=preferred_token_id,
node_ids=node_ids)
res = await leases.lease_for_stream(stream_id, node_id=node["node_id"], org_id=org_id,
preferred_token_id=preferred_token_id, stream_fields=fields,
soft_preferred_token_id=soft_pref, perms=perms)
if res.ok:
delivered = await publish_desired(node["node_id"])
if prev_node and prev_node != node["node_id"]:
await publish_desired(prev_node)
logger.info(f"Discord stream {stream_id} -> node {node['node_id']} token {res.token_id}"
f" ({'reused' if res.reused else 'new'} lease{', already on' if already_on else ''})")
return {**existing, **fields, "node_id": node["node_id"], "node_name": node.get("name"),
"token_id": res.token_id, "lease_id": res.lease_id, "reused": res.reused,
"delivered": delivered, "already_on": already_on}
chan = _channel_ref(perms, channel_name or existing.get("channel_name"))
if res.error in ("preferred_busy", "preferred_unavailable") or (
preferred_token_id and res.error == "missing_permissions"):
raise await _lease_error(res, chan) # true on every node — no point trying the rest
if res.error == "missing_permissions":
perm_refusal = res # more useful than another node's "all busy"
last = res
if last is None:
raise StreamError("no_node", ERROR_TEXT["no_node"])
raise await _lease_error(perm_refusal or last,
_channel_ref(perms, channel_name or existing.get("channel_name")))
async def stop_stream(stream_id: str, reason: str = "stopped") -> bool:
existing = await fstore.doc_get(STREAMS, stream_id)
if not existing:
return False
now = _now_iso()
node_id = await leases.detach_stream(stream_id, {
"desired": "off", "updated_at": now, "stop_reason": reason,
"actual": {**(existing.get("actual") or {}), "state": "stopping", "at": now},
})
if node_id:
await publish_desired(node_id)
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):
try:
if s.get("stream_id") and await delete_stream(s["stream_id"], "server_removed"):
removed.append(s["stream_id"])
except StreamError as e:
# A channel restarted mid-removal: stop here, leave the server
# linked, and tell the caller which channels are already gone so
# the partial removal is still audited.
e.removed = removed
raise
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,
stream_id: Optional[str] = None) -> list[str]:
"""Stop live streams in ONE Discord server: all of them, every stream in
one voice channel, or one stream by id. The /leave fix for server-26#203:
never touches another server's streams (a ``stream_id`` from another
server simply matches nothing)."""
stopped = []
for s in await fstore.collection_list(STREAMS, guild_id=str(guild_id)):
if s.get("desired") != "on":
continue
if channel_id and str(s.get("channel_id")) != str(channel_id):
continue
if stream_id and s.get("stream_id") != stream_id:
continue
await stop_stream(s["stream_id"], reason)
stopped.append(s["stream_id"])
return stopped
async def stop_node(node_id: str, reason: str) -> list[str]:
stopped = []
for s in await fstore.collection_list(STREAMS, node_id=node_id):
if s.get("desired") == "on":
await stop_stream(s["stream_id"], reason)
stopped.append(s["stream_id"])
return stopped
# ---------------------------------------------------------------------------
# Actual state <- node
# ---------------------------------------------------------------------------
_VALID_STATES = {"connecting", "connected", "error", "stopped", "paused_locally"}
# Edge error codes that mean the cached permission/membership view is stale.
_INVALIDATE_ON = {"missing_permissions", "channel_full", "not_in_guild", "bad_channel"}
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
cannot rewrite another node's (or org's) stream state."""
if not isinstance(payload, dict):
return
reported = payload.get("streams") or {}
now = _now_iso()
recheck: set = set()
if isinstance(reported, dict):
for stream_id, st in list(reported.items())[:50]:
if not isinstance(st, dict) or "/" in str(stream_id):
continue # "/" would be a Firestore sub-path, never a stream id
doc = await fstore.doc_get(STREAMS, str(stream_id))
if not doc or doc.get("node_id") != node_id or doc.get("desired") != "on":
continue
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
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"])
if code in _INVALIDATE_ON and doc.get("token_id"):
# The edge saw Discord refuse; a cached "ok" must not keep
# handing this bot the same channel (server-26#225).
discord_rest.invalidate(token_id=doc["token_id"], guild_id=str(doc.get("guild_id")))
prev = doc.get("actual") or {}
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, "code": code, "since": since, "at": now},
})
if recheck:
await discord_rest.refresh_membership(sorted(recheck))
if "sessions" in payload:
sessions = {str(t) for t in (payload.get("sessions") or [])}
applied = payload.get("applied_seq")
applied = int(applied) if isinstance(applied, (int, float)) and not isinstance(applied, bool) else None
freed = await leases.settle_releases(node_id, sessions, applied)
for tok in freed:
logger.info(f"Radio bot token {tok} released — node {node_id} confirmed it logged out")
await fstore.doc_set(NODE_STATE, node_id, {"applied_seq": applied, "actual_at": now})
# ---------------------------------------------------------------------------
# Self-healing: re-place persistent streams whose node is gone
# ---------------------------------------------------------------------------
async def release_offline_node(node_id: str) -> None:
"""Node offline past the lease grace: free its tokens (the edge already
logged them out — see release_node_leases) and unplace its streams."""
orphaned = await leases.release_node_leases(node_id)
if not orphaned:
return
now = _now_iso()
for sid in orphaned:
await fstore.doc_set(STREAMS, sid, {
"token_id": None, "lease_id": None,
"actual": {"state": "node_offline", "error": "The node carrying this channel went offline.",
"since": now, "at": now},
})
await publish_desired(node_id) # retained: the node sees an accurate (empty) list when it returns
logger.warning(f"Node {node_id} offline past lease grace — released {len(orphaned)} radio stream(s)")
async def _unplace(s: dict, why: str) -> None:
"""Take a stream off its node (token detached, node republished) but leave
it desired, so the reconciler can place it somewhere valid."""
now = _now_iso()
old = await leases.detach_stream(s["stream_id"], {
"node_id": None,
"actual": {"state": "error", "error": why, "since": now, "at": now},
})
if old:
await publish_desired(old)
async def reconcile_once() -> None:
nodes = {n["node_id"]: n for n in await fstore.collection_list("nodes") if n.get("node_id")}
grace = timedelta(seconds=settings.discord_lease_offline_release_seconds)
now = datetime.now(timezone.utc)
released: set = set()
for s in await fstore.collection_list(STREAMS, desired="on"):
node = nodes.get(s.get("node_id") or "")
if node and s.get("token_id"):
if node.get("status") in ONLINE:
if _effective_system(node) == s.get("system_id"):
continue # healthy placement
# Node retuned to another system under a live stream.
if s.get("mode") != "persistent":
await stop_stream(s["stream_id"], "node_retuned")
continue
await _unplace(s, "The node carrying this channel switched systems; finding another.")
else:
ls = _parse_ts(node.get("last_seen"))
if ls and now - ls < grace:
await _mark(s, "node_offline", "The node carrying this channel is offline; waiting for it.")
continue # hold the lease through the grace window
if node["node_id"] not in released:
await release_offline_node(node["node_id"])
released.add(node["node_id"])
if s.get("mode") != "persistent":
await stop_stream(s["stream_id"], "node_lost")
continue
org_id = s.get("org_id")
try:
await start_stream(org_id=org_id, stream_id=s["stream_id"], system_id=s["system_id"],
mode="persistent")
except StreamError as e:
await _mark(s, "error", e.message)
# Leases with NO live stream left (``releasing``) on a node that is gone:
# /leave while the node was down, or the node was deleted. The pass above
# only walks desired=on streams, so without this the token would stay out
# of the pool until that node came back — forever for a dead node.
for tok in await fstore.collection_list(leases.TOKENS):
nid = (tok.get("lease") or {}).get("node_id")
if not nid or nid in released:
continue
node = nodes.get(nid)
if node and node.get("status") in ONLINE:
continue
if node is not None:
heard = _parse_ts(node.get("last_seen"))
else:
# Deleted node (its MQTT client is revoked, so the edge fail-safe
# logs out). Last heard = its last actual report, else lease start.
st = await fstore.doc_get(NODE_STATE, nid) or {}
heard = _parse_ts(st.get("actual_at")) or _parse_ts((tok.get("lease") or {}).get("since"))
if heard and now - heard < grace:
continue
await release_offline_node(nid)
released.add(nid)
# Desired-state publishes the broker never took (MQTT was down).
for st in await fstore.collection_list(NODE_STATE, published=False):
if st.get("node_id"):
await publish_desired(st["node_id"])
async def _mark(s: dict, state: str, error: str) -> None:
prev = s.get("actual") or {}
if prev.get("state") == state and prev.get("error") == error:
return
now = _now_iso()
await fstore.doc_set(STREAMS, s["stream_id"], {"actual": {"state": state, "error": error,
"since": now, "at": now}})
async def reconcile_loop() -> None:
logger.info("Discord stream reconciler started.")
while True:
await asyncio.sleep(settings.discord_reconcile_interval_seconds)
try:
await reconcile_once()
except Exception as e:
logger.error(f"Discord reconcile error: {e}")