discord_radio: desired-state streams, logout on release, no mention controls (node-26#25)
CI / lint (push) Successful in 8s
CI / test-op25 (push) Successful in 18s
CI / test (push) Successful in 58s
CI / lint (pull_request) Successful in 7s
CI / test-op25 (pull_request) Successful in 13s
CI / test (pull_request) Successful in 44s

- Rewritten as RadioManager reconciling C2's retained
  nodes/{id}/discord/desired (token ids) and publishing
  nodes/{id}/discord/actual {applied_seq, sessions, streams}.
- One gateway session per leased token, one voice connection per
  (token, guild); a token no stream needs is logged OUT (client.close()),
  which is what lets C2 free it safely (item 1).
- Watchdog is a separate reconcile loop; recovering a dead session can no
  longer cancel it (item 2). Presence kept across rejoins.
- No message-content intent and no @bot leave/join mention controls (item 3).
- Local dashboard: /api/discord/join returns 409 with an explanation instead
  of a TypeError 500; /api/discord/leave pauses locally; GET /api/discord
  shows state (item 4). Join/leave results are reported to C2 (item 5).
- Token strings fetched over HTTPS from C2 (lease holder only), memory only.
- MQTT-loss fail-safe: logs every bot out after
  DISCORD_MQTT_LOSS_STOP_SECONDS (300, below C2's 600s lease grace).
- Legacy discord_join/discord_leave MQTT commands are ignored.
- Audio path unchanged: FFmpeg -f pulse on settings.pulse_source per
  connection, never Icecast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 18:09:29 -04:00
co-authored by Claude Opus 5.5
parent 80a0e88fc4
commit 419d7c4d8c
6 changed files with 766 additions and 297 deletions
+7 -15
View File
@@ -144,20 +144,11 @@ async def on_command(payload: dict):
action = payload.get("action")
logger.info(f"Command received: {action}")
if action == "discord_join":
token = payload.get("token")
if not token:
logger.error("discord_join command missing token — ignoring.")
return
await radio_bot.join(
guild_id=int(payload["guild_id"]),
channel_id=int(payload["channel_id"]),
token=token,
call_active=metadata_watcher.is_active,
system_name=payload.get("system_name"),
)
elif action == "discord_leave":
await radio_bot.leave()
if action in ("discord_join", "discord_leave"):
# Pre-desired-state C2. Discord streams now arrive on
# nodes/{id}/discord/desired (node-26#25); a raw-token join is refused
# so a bot token is never taken from an MQTT command again.
logger.warning(f"Ignoring legacy {action} command — Discord radio is driven by desired state now.")
elif action in ("set_sdr_config", "set_secondary_priority"):
from app.internal.sdr_settings import set_sdr_settings
try:
@@ -300,8 +291,9 @@ async def lifespan(app: FastAPI):
mqtt_manager.on_command = on_command
mqtt_manager.on_config_push = on_config_push
mqtt_manager.on_api_key = on_api_key
mqtt_manager.on_discord_desired = radio_bot.apply_desired
# Start services (radio_bot starts on-demand when a discord_join command arrives)
# Start services (radio_bot starts when C2's retained discord/desired arrives)
await mqtt_manager.connect()
await metadata_watcher.start()
await call_recorder.start() # persistent PulseAudio ring buffer