discord_radio: desired-state streams, logout on release, no mention controls (node-26#25)
- 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:
co-authored by
Claude Opus 5.5
parent
80a0e88fc4
commit
419d7c4d8c
@@ -11,6 +11,7 @@ from app.internal import credentials, key_pickup
|
||||
CommandCallback = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||
ConfigCallback = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||
ApiKeyCallback = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||
DesiredCallback = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||
|
||||
# CONNACK codes meaning "your credentials are wrong", as paho reports them:
|
||||
# MQTT 3.1.1 return codes 4/5, and the MQTT 5 reason codes paho maps them to
|
||||
@@ -46,6 +47,7 @@ class MQTTManager:
|
||||
self.on_command: Optional[CommandCallback] = None
|
||||
self.on_config_push: Optional[ConfigCallback] = None
|
||||
self.on_api_key: Optional[ApiKeyCallback] = None
|
||||
self.on_discord_desired: Optional[DesiredCallback] = None
|
||||
|
||||
self._offline_buffer = deque(maxlen=settings.offline_call_buffer_size)
|
||||
|
||||
@@ -55,6 +57,10 @@ class MQTTManager:
|
||||
self._t_metadata = f"nodes/{nid}/metadata"
|
||||
self._t_commands = f"nodes/{nid}/commands"
|
||||
self._t_config = f"nodes/{nid}/config"
|
||||
# Discord radio desired state (retained by C2) and our actual state —
|
||||
# node-26#25. Token IDs only; the strings come over HTTPS.
|
||||
self._t_discord_desired = f"nodes/{nid}/discord/desired"
|
||||
self._t_discord_actual = f"nodes/{nid}/discord/actual"
|
||||
# TODO(mqtt-cutover): dead once enrollment lands client-side. This
|
||||
# was the pre-dynsec key-delivery path (server retain-publishes the
|
||||
# api_key here after admin approval; node asks for redelivery via
|
||||
@@ -131,6 +137,7 @@ class MQTTManager:
|
||||
self._connected = True
|
||||
client.subscribe(self._t_commands, qos=1)
|
||||
client.subscribe(self._t_config, qos=1)
|
||||
client.subscribe(self._t_discord_desired, qos=1)
|
||||
client.subscribe(self._t_api_key, qos=2) # TODO(mqtt-cutover): see _t_api_key comment above
|
||||
client.subscribe(self._t_discovery, qos=0)
|
||||
logger.info("MQTT connected.")
|
||||
@@ -159,6 +166,8 @@ class MQTTManager:
|
||||
asyncio.run_coroutine_threadsafe(self.on_command(payload), self._loop)
|
||||
elif msg.topic == self._t_config and self.on_config_push:
|
||||
asyncio.run_coroutine_threadsafe(self.on_config_push(payload), self._loop)
|
||||
elif msg.topic == self._t_discord_desired and self.on_discord_desired:
|
||||
asyncio.run_coroutine_threadsafe(self.on_discord_desired(payload), self._loop)
|
||||
elif msg.topic == self._t_api_key and self.on_api_key:
|
||||
asyncio.run_coroutine_threadsafe(self.on_api_key(payload), self._loop)
|
||||
elif msg.topic == self._t_discovery:
|
||||
@@ -285,6 +294,12 @@ class MQTTManager:
|
||||
else:
|
||||
self._publish(self._t_metadata, payload, qos=1)
|
||||
|
||||
def publish_discord_actual(self, payload: dict) -> None:
|
||||
"""What the Discord radio manager is actually doing. Not buffered while
|
||||
offline: a fresh report is sent after every change and periodically,
|
||||
and only the newest one means anything."""
|
||||
self._publish(self._t_discord_actual, payload, qos=1)
|
||||
|
||||
async def _flush_offline_buffer(self):
|
||||
if not self._offline_buffer:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user