kryten-robot 0.6.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kryten/CONFIG.md +504 -0
- kryten/__init__.py +127 -0
- kryten/__main__.py +882 -0
- kryten/application_state.py +98 -0
- kryten/audit_logger.py +237 -0
- kryten/command_subscriber.py +341 -0
- kryten/config.example.json +35 -0
- kryten/config.py +510 -0
- kryten/connection_watchdog.py +209 -0
- kryten/correlation.py +241 -0
- kryten/cytube_connector.py +754 -0
- kryten/cytube_event_sender.py +1476 -0
- kryten/errors.py +161 -0
- kryten/event_publisher.py +416 -0
- kryten/health_monitor.py +482 -0
- kryten/lifecycle_events.py +274 -0
- kryten/logging_config.py +314 -0
- kryten/nats_client.py +468 -0
- kryten/raw_event.py +165 -0
- kryten/service_registry.py +371 -0
- kryten/shutdown_handler.py +383 -0
- kryten/socket_io.py +903 -0
- kryten/state_manager.py +711 -0
- kryten/state_query_handler.py +698 -0
- kryten/state_updater.py +314 -0
- kryten/stats_tracker.py +108 -0
- kryten/subject_builder.py +330 -0
- kryten_robot-0.6.9.dist-info/METADATA +469 -0
- kryten_robot-0.6.9.dist-info/RECORD +32 -0
- kryten_robot-0.6.9.dist-info/WHEEL +4 -0
- kryten_robot-0.6.9.dist-info/entry_points.txt +3 -0
- kryten_robot-0.6.9.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
"""State Query Handler - Respond to NATS queries for channel state.
|
|
2
|
+
|
|
3
|
+
This module provides a NATS request/reply endpoint that returns the current
|
|
4
|
+
channel state (emotes, playlist, userlist) as JSON.
|
|
5
|
+
|
|
6
|
+
Follows the unified command pattern: kryten.robot.command
|
|
7
|
+
Commands are dispatched based on the 'command' field in the request.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import psutil
|
|
17
|
+
PSUTIL_AVAILABLE = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
PSUTIL_AVAILABLE = False
|
|
20
|
+
|
|
21
|
+
from .application_state import ApplicationState
|
|
22
|
+
from .nats_client import NatsClient
|
|
23
|
+
from .state_manager import StateManager
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StateQueryHandler:
|
|
27
|
+
"""Handle NATS queries for channel state via unified command pattern.
|
|
28
|
+
|
|
29
|
+
Subscribes to kryten.robot.command and responds to state queries.
|
|
30
|
+
|
|
31
|
+
Supported commands:
|
|
32
|
+
- state.emotes: Get emote list
|
|
33
|
+
- state.playlist: Get playlist
|
|
34
|
+
- state.userlist: Get user list
|
|
35
|
+
- state.all: Get all state (emotes, playlist, userlist)
|
|
36
|
+
- state.user: Get specific user info
|
|
37
|
+
- state.profiles: Get all user profiles
|
|
38
|
+
- system.health: Get service health status
|
|
39
|
+
- system.channels: Get list of connected channels
|
|
40
|
+
- system.version: Get Kryten-Robot version
|
|
41
|
+
- system.stats: Get comprehensive runtime statistics
|
|
42
|
+
- system.config: Get current configuration (passwords redacted)
|
|
43
|
+
- system.ping: Simple alive check
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
state_manager: StateManager instance to query.
|
|
47
|
+
nats_client: NATS client for subscriptions.
|
|
48
|
+
logger: Logger instance.
|
|
49
|
+
app_state: ApplicationState for runtime information.
|
|
50
|
+
|
|
51
|
+
Examples:
|
|
52
|
+
>>> handler = StateQueryHandler(state_manager, nats_client, logger, "cytu.be", "mychannel", app_state)
|
|
53
|
+
>>> await handler.start()
|
|
54
|
+
>>> # Send command to kryten.robot.command with {"service": "robot", "command": "state.emotes"}
|
|
55
|
+
>>> await handler.stop()
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
state_manager: StateManager,
|
|
61
|
+
nats_client: NatsClient,
|
|
62
|
+
logger: logging.Logger,
|
|
63
|
+
domain: str,
|
|
64
|
+
channel: str,
|
|
65
|
+
app_state: ApplicationState | None = None,
|
|
66
|
+
):
|
|
67
|
+
"""Initialize state query handler.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
state_manager: StateManager instance.
|
|
71
|
+
nats_client: NATS client for subscriptions.
|
|
72
|
+
logger: Logger for structured output.
|
|
73
|
+
domain: CyTube domain name.
|
|
74
|
+
channel: CyTube channel name.
|
|
75
|
+
app_state: ApplicationState for system management features.
|
|
76
|
+
"""
|
|
77
|
+
self._state_manager = state_manager
|
|
78
|
+
self._nats = nats_client
|
|
79
|
+
self._logger = logger
|
|
80
|
+
self._domain = domain
|
|
81
|
+
self._channel = channel
|
|
82
|
+
self._app_state = app_state
|
|
83
|
+
self._running = False
|
|
84
|
+
self._subscription = None
|
|
85
|
+
|
|
86
|
+
# Metrics
|
|
87
|
+
self._queries_processed = 0
|
|
88
|
+
self._queries_failed = 0
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def stats(self) -> dict:
|
|
92
|
+
"""Get query processing statistics."""
|
|
93
|
+
return {
|
|
94
|
+
"queries_processed": self._queries_processed,
|
|
95
|
+
"queries_failed": self._queries_failed,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def is_running(self) -> bool:
|
|
100
|
+
"""Check if handler is running."""
|
|
101
|
+
return self._running
|
|
102
|
+
|
|
103
|
+
async def start(self) -> None:
|
|
104
|
+
"""Start listening for commands on unified subject."""
|
|
105
|
+
if self._running:
|
|
106
|
+
self._logger.warning("State query handler already running")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
self._running = True
|
|
110
|
+
|
|
111
|
+
# Subscribe to unified command subject using request-reply pattern
|
|
112
|
+
subject = "kryten.robot.command"
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
self._subscription = await self._nats.subscribe_request_reply(
|
|
116
|
+
subject,
|
|
117
|
+
callback=self._handle_command_msg
|
|
118
|
+
)
|
|
119
|
+
self._logger.info(f"State query handler listening on: {subject}")
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
self._logger.error(f"Failed to subscribe to command subject: {e}", exc_info=True)
|
|
123
|
+
self._running = False
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
async def stop(self) -> None:
|
|
127
|
+
"""Stop listening for queries."""
|
|
128
|
+
if not self._running:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
self._logger.info("Stopping state query handler")
|
|
132
|
+
|
|
133
|
+
if self._subscription:
|
|
134
|
+
try:
|
|
135
|
+
await self._subscription.unsubscribe()
|
|
136
|
+
except Exception as e:
|
|
137
|
+
self._logger.warning(f"Error unsubscribing: {e}")
|
|
138
|
+
|
|
139
|
+
self._subscription = None
|
|
140
|
+
self._running = False
|
|
141
|
+
self._logger.info("State query handler stopped")
|
|
142
|
+
|
|
143
|
+
async def _handle_command_msg(self, msg) -> None:
|
|
144
|
+
"""Handle incoming command message (actual implementation).
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
msg: NATS message object with data and reply subject.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
# Parse request
|
|
151
|
+
request = {}
|
|
152
|
+
if msg.data:
|
|
153
|
+
try:
|
|
154
|
+
request = json.loads(msg.data.decode('utf-8'))
|
|
155
|
+
except json.JSONDecodeError as e:
|
|
156
|
+
raise ValueError(f"Invalid JSON: {e}") from e
|
|
157
|
+
|
|
158
|
+
command = request.get('command')
|
|
159
|
+
if not command:
|
|
160
|
+
raise ValueError("Missing 'command' field")
|
|
161
|
+
|
|
162
|
+
# Check service field for routing (other services can ignore)
|
|
163
|
+
service = request.get('service')
|
|
164
|
+
if service and service != 'robot':
|
|
165
|
+
# Not for us, ignore silently
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
# Dispatch to handler
|
|
169
|
+
handler_map = {
|
|
170
|
+
"state.emotes": self._handle_state_emotes,
|
|
171
|
+
"state.playlist": self._handle_state_playlist,
|
|
172
|
+
"state.userlist": self._handle_state_userlist,
|
|
173
|
+
"state.all": self._handle_state_all,
|
|
174
|
+
"state.user": self._handle_state_user,
|
|
175
|
+
"state.profiles": self._handle_state_profiles,
|
|
176
|
+
"system.health": self._handle_system_health,
|
|
177
|
+
"system.channels": self._handle_system_channels,
|
|
178
|
+
"system.version": self._handle_system_version,
|
|
179
|
+
"system.stats": self._handle_system_stats,
|
|
180
|
+
"system.config": self._handle_system_config,
|
|
181
|
+
"system.ping": self._handle_system_ping,
|
|
182
|
+
"system.shutdown": self._handle_system_shutdown,
|
|
183
|
+
"system.reload": self._handle_system_reload,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
handler = handler_map.get(command)
|
|
187
|
+
if not handler:
|
|
188
|
+
raise ValueError(f"Unknown command: {command}")
|
|
189
|
+
|
|
190
|
+
# Execute handler
|
|
191
|
+
result = await handler(request)
|
|
192
|
+
|
|
193
|
+
# Build success response
|
|
194
|
+
response = {
|
|
195
|
+
"service": "robot",
|
|
196
|
+
"command": command,
|
|
197
|
+
"success": True,
|
|
198
|
+
"data": result
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# Send response
|
|
202
|
+
if msg.reply:
|
|
203
|
+
response_bytes = json.dumps(response).encode('utf-8')
|
|
204
|
+
await self._nats.publish(msg.reply, response_bytes)
|
|
205
|
+
self._logger.debug(f"Sent response for command '{command}'")
|
|
206
|
+
|
|
207
|
+
self._queries_processed += 1
|
|
208
|
+
|
|
209
|
+
except Exception as e:
|
|
210
|
+
self._logger.error(f"Error handling command: {e}", exc_info=True)
|
|
211
|
+
self._queries_failed += 1
|
|
212
|
+
|
|
213
|
+
# Send error response if reply subject provided
|
|
214
|
+
if msg.reply:
|
|
215
|
+
try:
|
|
216
|
+
command = request.get('command', 'unknown')
|
|
217
|
+
error_response = {
|
|
218
|
+
"service": "robot",
|
|
219
|
+
"command": command,
|
|
220
|
+
"success": False,
|
|
221
|
+
"error": str(e)
|
|
222
|
+
}
|
|
223
|
+
response_bytes = json.dumps(error_response).encode('utf-8')
|
|
224
|
+
await self._nats.publish(msg.reply, response_bytes)
|
|
225
|
+
except Exception as reply_error:
|
|
226
|
+
self._logger.error(f"Failed to send error response: {reply_error}")
|
|
227
|
+
|
|
228
|
+
async def _handle_state_emotes(self, request: dict) -> dict:
|
|
229
|
+
"""Get emote list."""
|
|
230
|
+
return {"emotes": self._state_manager.get_emotes()}
|
|
231
|
+
|
|
232
|
+
async def _handle_state_playlist(self, request: dict) -> dict:
|
|
233
|
+
"""Get playlist."""
|
|
234
|
+
return {"playlist": self._state_manager.get_playlist()}
|
|
235
|
+
|
|
236
|
+
async def _handle_state_userlist(self, request: dict) -> dict:
|
|
237
|
+
"""Get user list."""
|
|
238
|
+
return {"userlist": self._state_manager.get_userlist()}
|
|
239
|
+
|
|
240
|
+
async def _handle_state_all(self, request: dict) -> dict:
|
|
241
|
+
"""Get all state (emotes, playlist, userlist)."""
|
|
242
|
+
return {
|
|
243
|
+
"emotes": self._state_manager.get_emotes(),
|
|
244
|
+
"playlist": self._state_manager.get_playlist(),
|
|
245
|
+
"userlist": self._state_manager.get_userlist(),
|
|
246
|
+
"stats": self._state_manager.stats
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async def _handle_state_user(self, request: dict) -> dict:
|
|
250
|
+
"""Get specific user info."""
|
|
251
|
+
username = request.get('username')
|
|
252
|
+
if not username:
|
|
253
|
+
raise ValueError("username required")
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
"user": self._state_manager.get_user(username),
|
|
257
|
+
"profile": self._state_manager.get_user_profile(username)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async def _handle_state_profiles(self, request: dict) -> dict:
|
|
261
|
+
"""Get all user profiles."""
|
|
262
|
+
return {"profiles": self._state_manager.get_all_profiles()}
|
|
263
|
+
|
|
264
|
+
async def _handle_system_health(self, request: dict) -> dict:
|
|
265
|
+
"""Get service health status."""
|
|
266
|
+
return {
|
|
267
|
+
"service": "robot",
|
|
268
|
+
"status": "healthy" if self._running else "unhealthy",
|
|
269
|
+
"domain": self._domain,
|
|
270
|
+
"channel": self._channel,
|
|
271
|
+
"nats_connected": self._nats.is_connected,
|
|
272
|
+
"queries_processed": self._queries_processed,
|
|
273
|
+
"queries_failed": self._queries_failed,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async def _handle_system_channels(self, request: dict) -> dict:
|
|
277
|
+
"""Get list of connected channels.
|
|
278
|
+
|
|
279
|
+
Returns information about all channels this robot instance is connected to.
|
|
280
|
+
Currently supports single-channel mode, but structured for future multi-channel support.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Dictionary with 'channels' key containing list of channel info dicts.
|
|
284
|
+
Each channel dict contains: domain, channel, connected status.
|
|
285
|
+
"""
|
|
286
|
+
return {
|
|
287
|
+
"channels": [
|
|
288
|
+
{
|
|
289
|
+
"domain": self._domain,
|
|
290
|
+
"channel": self._channel,
|
|
291
|
+
"connected": self._nats.is_connected
|
|
292
|
+
}
|
|
293
|
+
]
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async def _handle_system_version(self, request: dict) -> dict:
|
|
297
|
+
"""Get Kryten-Robot version information.
|
|
298
|
+
|
|
299
|
+
Returns version string for client applications to check compatibility.
|
|
300
|
+
Clients can use this to enforce minimum server version requirements.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Dictionary with 'version' key containing semantic version string.
|
|
304
|
+
"""
|
|
305
|
+
from . import __version__
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
"version": __version__
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async def _handle_system_stats(self, request: dict) -> dict:
|
|
312
|
+
"""Get comprehensive runtime statistics.
|
|
313
|
+
|
|
314
|
+
Returns detailed statistics including event rates, command execution,
|
|
315
|
+
connection status, memory usage, and state information.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Dictionary containing nested statistics:
|
|
319
|
+
- uptime_seconds: Total uptime
|
|
320
|
+
- events: Published events and rates
|
|
321
|
+
- commands: Command execution stats
|
|
322
|
+
- queries: Query handler stats
|
|
323
|
+
- connections: CyTube and NATS connection info
|
|
324
|
+
- state: Channel state (users, playlist, emotes)
|
|
325
|
+
- memory: Process memory usage
|
|
326
|
+
"""
|
|
327
|
+
if not self._app_state:
|
|
328
|
+
raise ValueError("ApplicationState not available for stats")
|
|
329
|
+
|
|
330
|
+
# Calculate uptime
|
|
331
|
+
uptime = self._app_state.get_uptime()
|
|
332
|
+
|
|
333
|
+
# Get event publisher stats
|
|
334
|
+
events_stats = {}
|
|
335
|
+
if self._app_state.event_publisher:
|
|
336
|
+
pub_stats = self._app_state.event_publisher.stats
|
|
337
|
+
last_time = pub_stats.get('last_event_time')
|
|
338
|
+
last_type = pub_stats.get('last_event_type')
|
|
339
|
+
events_stats = {
|
|
340
|
+
"published": pub_stats.get('events_published', 0),
|
|
341
|
+
"failed": pub_stats.get('publish_errors', 0),
|
|
342
|
+
"rate_1min": pub_stats.get('rate_1min', 0.0),
|
|
343
|
+
"rate_5min": pub_stats.get('rate_5min', 0.0),
|
|
344
|
+
"last_event_time": datetime.fromtimestamp(last_time, tz=UTC).isoformat() if last_time else None,
|
|
345
|
+
"last_event_type": last_type
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
# Get command subscriber stats
|
|
349
|
+
commands_stats = {}
|
|
350
|
+
if self._app_state.command_subscriber:
|
|
351
|
+
cmd_stats = self._app_state.command_subscriber.stats
|
|
352
|
+
last_time = cmd_stats.get('last_command_time')
|
|
353
|
+
last_type = cmd_stats.get('last_command_type')
|
|
354
|
+
commands_stats = {
|
|
355
|
+
"received": cmd_stats.get('commands_processed', 0),
|
|
356
|
+
"executed": cmd_stats.get('commands_succeeded', 0),
|
|
357
|
+
"failed": cmd_stats.get('commands_failed', 0),
|
|
358
|
+
"rate_1min": cmd_stats.get('rate_1min', 0.0),
|
|
359
|
+
"last_command_time": datetime.fromtimestamp(last_time, tz=UTC).isoformat() if last_time else None,
|
|
360
|
+
"last_command_type": last_type
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
# Get query handler stats (self)
|
|
364
|
+
queries_stats = {
|
|
365
|
+
"processed": self._queries_processed,
|
|
366
|
+
"failed": self._queries_failed,
|
|
367
|
+
"rate_1min": 0.0 # Could add StatsTracker here too if needed
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
# Get connection stats
|
|
371
|
+
connections_stats = {}
|
|
372
|
+
|
|
373
|
+
# CyTube connection
|
|
374
|
+
cytube_stats = {"connected": False, "uptime_seconds": 0, "last_event_time": None, "reconnect_count": 0}
|
|
375
|
+
if self._app_state.connector:
|
|
376
|
+
connector = self._app_state.connector
|
|
377
|
+
connected_since = connector.connected_since
|
|
378
|
+
last_event = connector.last_event_time
|
|
379
|
+
cytube_stats = {
|
|
380
|
+
"connected": connector.is_connected,
|
|
381
|
+
"uptime_seconds": (datetime.now(UTC).timestamp() - connected_since) if connected_since else 0,
|
|
382
|
+
"last_event_time": datetime.fromtimestamp(last_event, tz=UTC).isoformat() if last_event else None,
|
|
383
|
+
"reconnect_count": connector.reconnect_count
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
# NATS connection
|
|
387
|
+
nats_stats = {"connected": False, "uptime_seconds": 0, "server": None, "reconnect_count": 0}
|
|
388
|
+
if self._app_state.nats_client:
|
|
389
|
+
nats = self._app_state.nats_client
|
|
390
|
+
connected_since = nats.connected_since
|
|
391
|
+
nats_stats = {
|
|
392
|
+
"connected": nats.is_connected,
|
|
393
|
+
"uptime_seconds": (datetime.now(UTC).timestamp() - connected_since) if connected_since else 0,
|
|
394
|
+
"server": nats.connected_url,
|
|
395
|
+
"reconnect_count": nats.reconnect_count
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
connections_stats = {
|
|
399
|
+
"cytube": cytube_stats,
|
|
400
|
+
"nats": nats_stats
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
# Get state counts
|
|
404
|
+
state_stats = {}
|
|
405
|
+
if self._app_state.state_manager:
|
|
406
|
+
sm = self._app_state.state_manager
|
|
407
|
+
state_stats = {
|
|
408
|
+
"users_online": sm.users_count(),
|
|
409
|
+
"playlist_items": sm.playlist_count(),
|
|
410
|
+
"emotes_count": sm.emotes_count()
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
# Get memory stats (if psutil available)
|
|
414
|
+
memory_stats = {}
|
|
415
|
+
if PSUTIL_AVAILABLE:
|
|
416
|
+
try:
|
|
417
|
+
process = psutil.Process()
|
|
418
|
+
mem_info = process.memory_info()
|
|
419
|
+
memory_stats = {
|
|
420
|
+
"rss_mb": mem_info.rss / (1024 * 1024),
|
|
421
|
+
"vms_mb": mem_info.vms / (1024 * 1024)
|
|
422
|
+
}
|
|
423
|
+
except Exception as e:
|
|
424
|
+
self._logger.warning(f"Failed to get memory stats: {e}")
|
|
425
|
+
memory_stats = {"error": str(e)}
|
|
426
|
+
else:
|
|
427
|
+
memory_stats = {"error": "psutil not available"}
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
"uptime_seconds": uptime,
|
|
431
|
+
"events": events_stats,
|
|
432
|
+
"commands": commands_stats,
|
|
433
|
+
"queries": queries_stats,
|
|
434
|
+
"connections": connections_stats,
|
|
435
|
+
"state": state_stats,
|
|
436
|
+
"memory": memory_stats
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async def _handle_system_config(self, request: dict) -> dict:
|
|
440
|
+
"""Get current effective configuration.
|
|
441
|
+
|
|
442
|
+
Returns the running configuration with sensitive fields redacted.
|
|
443
|
+
Useful for debugging and verifying configuration changes.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Dictionary containing configuration sections with passwords redacted.
|
|
447
|
+
"""
|
|
448
|
+
if not self._app_state:
|
|
449
|
+
raise ValueError("ApplicationState not available for config")
|
|
450
|
+
|
|
451
|
+
config = self._app_state.config
|
|
452
|
+
|
|
453
|
+
# Build sanitized config response
|
|
454
|
+
return {
|
|
455
|
+
"cytube": {
|
|
456
|
+
"domain": config.cytube.domain,
|
|
457
|
+
"channel": config.cytube.channel,
|
|
458
|
+
"user": config.cytube.user,
|
|
459
|
+
"password": "***REDACTED***"
|
|
460
|
+
},
|
|
461
|
+
"nats": {
|
|
462
|
+
"servers": config.nats.servers,
|
|
463
|
+
"user": config.nats.user,
|
|
464
|
+
"password": "***REDACTED***" if config.nats.password else None,
|
|
465
|
+
"max_reconnect_attempts": config.nats.max_reconnect_attempts,
|
|
466
|
+
"reconnect_time_wait": config.nats.reconnect_time_wait
|
|
467
|
+
},
|
|
468
|
+
"health": {
|
|
469
|
+
"enabled": config.health.enabled,
|
|
470
|
+
"host": config.health.host,
|
|
471
|
+
"port": config.health.port
|
|
472
|
+
},
|
|
473
|
+
"commands": {
|
|
474
|
+
"enabled": config.commands.enabled
|
|
475
|
+
},
|
|
476
|
+
"logging": {
|
|
477
|
+
"base_path": config.logging.base_path,
|
|
478
|
+
"admin_operations": config.logging.admin_operations,
|
|
479
|
+
"playlist_operations": config.logging.playlist_operations,
|
|
480
|
+
"chat_messages": config.logging.chat_messages,
|
|
481
|
+
"command_audit": config.logging.command_audit
|
|
482
|
+
},
|
|
483
|
+
"log_level": config.log_level
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async def _handle_system_ping(self, request: dict) -> dict:
|
|
487
|
+
"""Simple alive check.
|
|
488
|
+
|
|
489
|
+
Ultra-lightweight health check that proves NATS connectivity and
|
|
490
|
+
responsiveness. Faster than full health check.
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
Dictionary with pong=True, timestamp, uptime, service, and version.
|
|
494
|
+
"""
|
|
495
|
+
from . import __version__
|
|
496
|
+
|
|
497
|
+
uptime = self._app_state.get_uptime() if self._app_state else 0
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
"pong": True,
|
|
501
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
502
|
+
"uptime_seconds": uptime,
|
|
503
|
+
"service": "robot",
|
|
504
|
+
"version": __version__
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async def _handle_system_shutdown(self, request: dict) -> dict:
|
|
508
|
+
"""Initiate graceful shutdown.
|
|
509
|
+
|
|
510
|
+
Schedules a graceful shutdown after optional delay. The shutdown is
|
|
511
|
+
performed by setting the application's shutdown_event, which triggers
|
|
512
|
+
the normal cleanup sequence in __main__.py.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
request: Request dict with optional 'delay_seconds' (0-300) and 'reason'
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
Dictionary containing:
|
|
519
|
+
- success: Always True (if validation passes)
|
|
520
|
+
- message: Acknowledgment message
|
|
521
|
+
- delay_seconds: Actual delay applied
|
|
522
|
+
- shutdown_time: ISO8601 timestamp when shutdown will occur
|
|
523
|
+
|
|
524
|
+
Raises:
|
|
525
|
+
ValueError: If delay is invalid or ApplicationState not available
|
|
526
|
+
"""
|
|
527
|
+
if not self._app_state:
|
|
528
|
+
raise ValueError("ApplicationState not available for shutdown")
|
|
529
|
+
|
|
530
|
+
# Parse and validate delay
|
|
531
|
+
delay_seconds = request.get('delay_seconds', 0)
|
|
532
|
+
|
|
533
|
+
# Convert to int/float if needed
|
|
534
|
+
try:
|
|
535
|
+
delay_seconds = float(delay_seconds)
|
|
536
|
+
except (TypeError, ValueError) as e:
|
|
537
|
+
raise ValueError("delay_seconds must be a number") from e
|
|
538
|
+
|
|
539
|
+
# Validate range
|
|
540
|
+
if delay_seconds < 0 or delay_seconds > 300:
|
|
541
|
+
raise ValueError("delay_seconds must be between 0 and 300")
|
|
542
|
+
|
|
543
|
+
delay_seconds = int(delay_seconds)
|
|
544
|
+
reason = request.get('reason', 'Remote shutdown via system.shutdown')
|
|
545
|
+
|
|
546
|
+
# Calculate shutdown time
|
|
547
|
+
shutdown_time = datetime.now(UTC).timestamp() + delay_seconds
|
|
548
|
+
shutdown_time_iso = datetime.fromtimestamp(shutdown_time, tz=UTC).isoformat()
|
|
549
|
+
|
|
550
|
+
# Log the shutdown request
|
|
551
|
+
self._logger.warning(
|
|
552
|
+
f"Shutdown requested: delay={delay_seconds}s, reason={reason}, "
|
|
553
|
+
f"scheduled_time={shutdown_time_iso}"
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# Schedule shutdown
|
|
557
|
+
async def trigger_shutdown():
|
|
558
|
+
"""Wait for delay then trigger shutdown event."""
|
|
559
|
+
if delay_seconds > 0:
|
|
560
|
+
self._logger.info(f"Waiting {delay_seconds}s before shutdown...")
|
|
561
|
+
await asyncio.sleep(delay_seconds)
|
|
562
|
+
|
|
563
|
+
self._logger.warning(f"Triggering shutdown: {reason}")
|
|
564
|
+
self._app_state.shutdown_event.set()
|
|
565
|
+
|
|
566
|
+
# Create task to handle shutdown (non-blocking)
|
|
567
|
+
asyncio.create_task(trigger_shutdown())
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
"success": True,
|
|
571
|
+
"message": "Shutdown initiated",
|
|
572
|
+
"delay_seconds": delay_seconds,
|
|
573
|
+
"shutdown_time": shutdown_time_iso,
|
|
574
|
+
"reason": reason
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async def _handle_system_reload(self, request: dict) -> dict:
|
|
578
|
+
"""Reload configuration.
|
|
579
|
+
|
|
580
|
+
Attempts to reload configuration from file. Only "safe" changes are
|
|
581
|
+
applied - settings that can be updated without breaking connections.
|
|
582
|
+
|
|
583
|
+
Safe changes:
|
|
584
|
+
- log_level: Immediately updates logging level
|
|
585
|
+
- nats.user/password: Will apply on next reconnect
|
|
586
|
+
- health settings: Would require health server restart (not implemented)
|
|
587
|
+
|
|
588
|
+
Unsafe changes (rejected):
|
|
589
|
+
- cytube.domain, cytube.channel, cytube.user: Would break connection
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
request: Request dict with optional 'config_path'
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
Dictionary containing:
|
|
596
|
+
- success: Whether reload succeeded
|
|
597
|
+
- message: Human-readable result message
|
|
598
|
+
- changes: Dict of what changed (key: "old -> new")
|
|
599
|
+
- errors: List of validation errors if failed
|
|
600
|
+
|
|
601
|
+
Raises:
|
|
602
|
+
ValueError: If ApplicationState not available
|
|
603
|
+
"""
|
|
604
|
+
if not self._app_state:
|
|
605
|
+
raise ValueError("ApplicationState not available for reload")
|
|
606
|
+
|
|
607
|
+
from .config import load_config
|
|
608
|
+
|
|
609
|
+
# Determine config path
|
|
610
|
+
config_path = request.get('config_path', self._app_state.config_path)
|
|
611
|
+
|
|
612
|
+
try:
|
|
613
|
+
# Load new configuration
|
|
614
|
+
self._logger.info(f"Loading configuration from {config_path}")
|
|
615
|
+
new_config = load_config(config_path)
|
|
616
|
+
|
|
617
|
+
# Track changes
|
|
618
|
+
changes = {}
|
|
619
|
+
errors = []
|
|
620
|
+
old_config = self._app_state.config
|
|
621
|
+
|
|
622
|
+
# Check for unsafe changes
|
|
623
|
+
if new_config.cytube.domain != old_config.cytube.domain:
|
|
624
|
+
errors.append("Cannot change cytube.domain without restart")
|
|
625
|
+
|
|
626
|
+
if new_config.cytube.channel != old_config.cytube.channel:
|
|
627
|
+
errors.append("Cannot change cytube.channel without restart")
|
|
628
|
+
|
|
629
|
+
if new_config.cytube.user != old_config.cytube.user:
|
|
630
|
+
errors.append("Cannot change cytube.user without restart")
|
|
631
|
+
|
|
632
|
+
# If there are errors, reject the reload
|
|
633
|
+
if errors:
|
|
634
|
+
self._logger.warning(f"Configuration reload rejected: {errors}")
|
|
635
|
+
return {
|
|
636
|
+
"success": False,
|
|
637
|
+
"message": "Configuration validation failed",
|
|
638
|
+
"changes": {},
|
|
639
|
+
"errors": errors
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
# Apply safe changes
|
|
643
|
+
|
|
644
|
+
# 1. Log level change
|
|
645
|
+
if new_config.log_level != old_config.log_level:
|
|
646
|
+
old_level = old_config.log_level
|
|
647
|
+
new_level = new_config.log_level
|
|
648
|
+
changes["log_level"] = f"{old_level} -> {new_level}"
|
|
649
|
+
|
|
650
|
+
# Update logging level
|
|
651
|
+
numeric_level = getattr(logging, new_level.upper(), None)
|
|
652
|
+
if numeric_level:
|
|
653
|
+
logging.getLogger().setLevel(numeric_level)
|
|
654
|
+
self._logger.info(f"Updated log level to {new_level}")
|
|
655
|
+
|
|
656
|
+
# 2. NATS credentials (will apply on next reconnect)
|
|
657
|
+
if (new_config.nats.user != old_config.nats.user or
|
|
658
|
+
new_config.nats.password != old_config.nats.password):
|
|
659
|
+
changes["nats.credentials"] = "updated (will apply on next reconnect)"
|
|
660
|
+
|
|
661
|
+
# 3. NATS servers
|
|
662
|
+
if new_config.nats.servers != old_config.nats.servers:
|
|
663
|
+
changes["nats.servers"] = "updated (will apply on next reconnect)"
|
|
664
|
+
|
|
665
|
+
# Update stored config
|
|
666
|
+
self._app_state.config = new_config
|
|
667
|
+
|
|
668
|
+
self._logger.info(f"Configuration reloaded successfully: {changes}")
|
|
669
|
+
|
|
670
|
+
return {
|
|
671
|
+
"success": True,
|
|
672
|
+
"message": "Configuration reloaded successfully",
|
|
673
|
+
"changes": changes,
|
|
674
|
+
"errors": []
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
except FileNotFoundError:
|
|
678
|
+
error_msg = f"Configuration file not found: {config_path}"
|
|
679
|
+
self._logger.error(error_msg)
|
|
680
|
+
return {
|
|
681
|
+
"success": False,
|
|
682
|
+
"message": "Configuration reload failed",
|
|
683
|
+
"changes": {},
|
|
684
|
+
"errors": [error_msg]
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
except Exception as e:
|
|
688
|
+
error_msg = f"Failed to load configuration: {str(e)}"
|
|
689
|
+
self._logger.error(f"Configuration reload error: {e}", exc_info=True)
|
|
690
|
+
return {
|
|
691
|
+
"success": False,
|
|
692
|
+
"message": "Configuration reload failed",
|
|
693
|
+
"changes": {},
|
|
694
|
+
"errors": [error_msg]
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
__all__ = ["StateQueryHandler"]
|