Live symptom: the radio bot shows "Listening to <system>" but is not in the
voice channel.
- Presence was set once on the first successful join and never cleared, so a
bot whose voice dropped (or whose rejoin kept failing) still looked healthy.
Presence is now synced at the end of every reconcile: set only while a voice
connection for that bot is up, cleared otherwise.
- Pre-check the bot's permissions (View Channel, Connect, Speak) and the
channel's user limit before joining. Discord silently ignores a join the bot
may not make; discord.py then times out after 30s and the admin page read
"Could not connect to the voice channel (TimeoutError: )". Now: code
missing_permissions / channel_full with the missing permission named.
- A connect timeout gets its own code (connect_timeout) and readable text.
- requirements: discord.py[voice]>=2.7 — Discord has required DAVE E2EE voice
since 2026-03-01 and 2.7 is the first discord.py with it (the extra pulls
davey). Today's resolution was already 2.7.1 + davey 0.1.6; this stops an
older resolution from ever being accepted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>