Discord radio channels: wire leases + desired-state streams (server-26#203, #204)

c2-core
- discord_streams/discord_leases wired in: POST /discord/streams,
  POST /discord/guilds/{id}/stop, GET /discord/guilds/{id}/overview,
  GET /discord/streams/{id}, PUT /discord/guilds/{id}/link (admin),
  GET /discord/node/{node}/tokens/{id} (node key, lease holder only).
- Retained nodes/{id}/discord/desired (token ids, never token strings),
  nodes/+/discord/actual subscribed and handled; per-node publish lock keeps
  the retained list the newest; failed publishes retried by the reconciler.
- Tokens freed only after the node acks logout (or offline past
  discord_lease_offline_release_seconds) - sweeper and C2 startup no longer
  release leases.
- /nodes/{id}/command discord_join/leave map onto streams; CommandPayload
  keeps preferred_token_id. /tokens: admin-only list, validate on add against
  Discord, refresh, flush via transactional force release.
- Guild -> org via discord_guilds, unlinked guilds fall back to
  DISCORD_UNLINKED_GUILD_ORG_ID (default "founding").
- tests/fake_firestore.py: optimistic-transaction fake; 27 lease/race tests.

discord bot
- /join, /leave, /status, autocomplete scoped to the calling guild; /leave
  never touches another server; Manage Channels default permission;
  /join edits its reply with the node's real result; /help copy fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 18:05:26 -04:00
co-authored by Claude Opus 5.5
parent 0a724a064f
commit a31a8df17c
15 changed files with 1428 additions and 359 deletions
+28 -5
View File
@@ -35,6 +35,8 @@ class MQTTHandler:
client.subscribe("nodes/+/checkin", qos=1)
client.subscribe("nodes/+/status", qos=1)
client.subscribe("nodes/+/metadata", qos=1)
# Edge -> C2 actual state of its Discord radio streams (node-26#25).
client.subscribe("nodes/+/discord/actual", qos=1)
# TODO(mqtt-cutover): drop this subscribe once the enrollment/HTTP
# credentials flow (routers/enrollment.py) is stable in prod and
# node-26 (the one live node) has been migrated. See
@@ -61,6 +63,14 @@ class MQTTHandler:
async def _dispatch(self, topic: str, payload: dict):
parts = topic.split("/")
# nodes/{node_id}/discord/actual — the one four-level node topic.
if len(parts) == 4 and parts[0] == "nodes" and parts[2:] == ["discord", "actual"]:
from app.internal import discord_streams
try:
await discord_streams.handle_actual(parts[1], payload)
except Exception as e:
logger.error(f"MQTT dispatch error [discord/actual] from {parts[1]}: {e}")
return
# Expected: nodes/{node_id}/{type}
if len(parts) != 3 or parts[0] != "nodes":
return
@@ -179,11 +189,11 @@ class MQTTHandler:
await fstore.doc_update("nodes", node_id, updates)
# NOTE: discord_connected in checkins is informational only — do NOT release the
# token here. The bot watchdog reconnects on transient Discord drops, so a single
# checkin with discord_connected=False during a brief reconnect window would
# incorrectly free the token while the bot is still active. Token release is
# handled exclusively by the discord_leave command and the node offline sweeper.
# NOTE: discord_connected in checkins is informational only — never release a
# token from it. Radio-bot tokens are freed only when the node acks (over
# nodes/{id}/discord/actual) that it logged the bot out, or by the
# reconciler once the node has been offline past the lease grace — see
# app/internal/discord_leases.py.
# ------------------------------------------------------------------
# Status update
@@ -335,6 +345,19 @@ class MQTTHandler:
else:
logger.warning(f"MQTT not connected — could not push config to {node_id}")
def publish_discord_desired(self, node_id: str, payload: dict) -> bool:
"""Retained full list of the Discord streams this node should carry
(app/internal/discord_streams.py). Carries token IDs, never token
strings — the broker never stores a bot credential."""
topic = f"nodes/{node_id}/discord/desired"
if self._client and self._connected:
info = self._client.publish(topic, json.dumps(payload), qos=1, retain=True)
if getattr(info, "rc", 0) == mqtt.MQTT_ERR_SUCCESS:
logger.info(f"Discord desired state seq {payload.get('seq')} -> {node_id}")
return True
logger.warning(f"MQTT not connected — Discord desired state for {node_id} not published")
return False
def publish_node_key(self, node_id: str, api_key: str):
"""Publish the provisioned API key to the node (retained so it survives reconnects).
TODO(mqtt-cutover): dead once nodes.py's callers switch to the HTTP