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/__main__.py ADDED
@@ -0,0 +1,882 @@
1
+ """Main Entry Point for Kryten CyTube Connector.
2
+
3
+ This module provides the application orchestration logic for running Kryten
4
+ as a standalone service. It coordinates component initialization, handles
5
+ signals for graceful shutdown, and manages the application lifecycle.
6
+
7
+ Usage:
8
+ python -m kryten
9
+ python -m kryten --config /path/to/config.json
10
+ python -m kryten --version
11
+ python -m kryten --help
12
+
13
+ Default config locations (searched in order):
14
+ - /etc/kryten/kryten-robot/config.json
15
+ - ./config.json
16
+
17
+ Exit Codes:
18
+ 0: Clean shutdown
19
+ 1: Error occurred (configuration, connection, or runtime error)
20
+ """
21
+
22
+ import argparse
23
+ import asyncio
24
+ import logging
25
+ import signal
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ from . import (
30
+ CommandSubscriber,
31
+ ConnectionWatchdog,
32
+ CytubeConnector,
33
+ CytubeEventSender,
34
+ EventPublisher,
35
+ HealthMonitor,
36
+ LifecycleEventPublisher,
37
+ LoggingConfig,
38
+ NatsClient,
39
+ StateManager,
40
+ StateQueryHandler,
41
+ __version__,
42
+ load_config,
43
+ setup_logging,
44
+ )
45
+ from .application_state import ApplicationState
46
+ from .audit_logger import create_audit_logger
47
+ from .errors import ConnectionError as KrytenConnectionError
48
+
49
+ # Module logger (configured after logging setup)
50
+ logger: logging.Logger | None = None
51
+
52
+
53
+ def print_startup_banner(config_path: str) -> None:
54
+ """Print startup banner with version and configuration info.
55
+
56
+ Args:
57
+ config_path: Path to configuration file.
58
+ """
59
+ from . import load_config
60
+ from .raw_event import RawEvent
61
+ from .subject_builder import build_event_subject
62
+
63
+ try:
64
+ config = load_config(config_path)
65
+
66
+ # Build example subjects to show what we'll subscribe/publish to
67
+ from .subject_builder import normalize_token
68
+
69
+ example_event = RawEvent(
70
+ event_name="chatMsg",
71
+ payload={},
72
+ channel=config.cytube.channel,
73
+ domain=config.cytube.domain
74
+ )
75
+ event_subject = build_event_subject(example_event)
76
+ # Build command subject base without wildcards (normalized channel removes special chars)
77
+ command_base = f"kryten.commands.cytube.{normalize_token(config.cytube.channel)}"
78
+
79
+ print("=" * 60)
80
+ print(f"Kryten CyTube Connector v{__version__}")
81
+ print("=" * 60)
82
+ print(f"Config: {Path(config_path).resolve()}")
83
+ print(f"Domain: {config.cytube.domain}")
84
+ print(f"Channel: {config.cytube.channel}")
85
+ print(f"NATS: {config.nats.servers[0] if config.nats.servers else 'N/A'}")
86
+ print("=" * 60)
87
+ print("NATS Subjects:")
88
+ print(f" Publishing: {event_subject.rsplit('.', 1)[0]}.*")
89
+ print(f" Subscribing: {command_base}.*")
90
+ print("=" * 60)
91
+ print()
92
+ except Exception as e:
93
+ print("=" * 60)
94
+ print(f"Kryten CyTube Connector v{__version__}")
95
+ print("=" * 60)
96
+ print(f"Config: {config_path}")
97
+ print(f"Error loading config: {e}")
98
+ print("=" * 60)
99
+ print()
100
+
101
+
102
+ async def main(config_path: str) -> int:
103
+ """Main orchestration function.
104
+
105
+ Coordinates component initialization and lifecycle:
106
+ 1. Load configuration
107
+ 2. Initialize logging
108
+ 3. Connect to NATS
109
+ 4. Connect to CyTube
110
+ 5. Start event publisher
111
+ 6. Wait for shutdown signal
112
+ 7. Cleanup in reverse order
113
+
114
+ Args:
115
+ config_path: Path to JSON configuration file.
116
+
117
+ Returns:
118
+ Exit code (0=success, 1=error).
119
+ """
120
+ global logger
121
+
122
+ # Variables to track initialized components for cleanup
123
+ nats_client: NatsClient | None = None
124
+ connector: CytubeConnector | None = None
125
+ publisher: EventPublisher | None = None
126
+ sender: CytubeEventSender | None = None
127
+ cmd_subscriber: CommandSubscriber | None = None
128
+ health_monitor: HealthMonitor | None = None
129
+ watchdog: ConnectionWatchdog | None = None
130
+ app_state: ApplicationState | None = None # Created after config load
131
+
132
+ def signal_handler(signum: int, frame) -> None:
133
+ """Handle shutdown signals (SIGINT, SIGTERM).
134
+
135
+ Sets shutdown event to trigger graceful shutdown.
136
+ Signal handlers must not perform async operations directly.
137
+ """
138
+ signame = signal.Signals(signum).name
139
+ if logger:
140
+ logger.info(f"Received {signame}, initiating graceful shutdown")
141
+ else:
142
+ print(f"\nReceived {signame}, initiating graceful shutdown...")
143
+ if app_state:
144
+ app_state.shutdown_event.set()
145
+
146
+ try:
147
+ # REQ-002: Load configuration
148
+ if logger:
149
+ logger.info(f"Loading configuration from {config_path}")
150
+
151
+ config = load_config(config_path)
152
+
153
+ # REQ-003: Initialize logging before any other components
154
+ logging_config = LoggingConfig(
155
+ level=config.log_level,
156
+ format="text", # Text format for console readability
157
+ output="console"
158
+ )
159
+ setup_logging(logging_config)
160
+
161
+ # Get logger after setup
162
+ logger = logging.getLogger("bot.kryten.main")
163
+
164
+ logger.info(f"Starting Kryten CyTube Connector v{__version__}")
165
+ logger.info(f"Configuration loaded from {config_path}")
166
+ logger.info(f"Log level: {config.log_level}")
167
+
168
+ # Initialize audit logger
169
+ audit_logger = create_audit_logger(
170
+ base_path=config.logging.base_path,
171
+ filenames={
172
+ "admin_operations": config.logging.admin_operations,
173
+ "playlist_operations": config.logging.playlist_operations,
174
+ "chat_messages": config.logging.chat_messages,
175
+ "command_audit": config.logging.command_audit
176
+ }
177
+ )
178
+ logger.info(f"Audit logging initialized: {config.logging.base_path}")
179
+ logger.info(f" - Admin operations: {config.logging.admin_operations}")
180
+ logger.info(f" - Playlist operations: {config.logging.playlist_operations}")
181
+ logger.info(f" - Chat messages: {config.logging.chat_messages}")
182
+ logger.info(f" - Command audit: {config.logging.command_audit}")
183
+
184
+ # Create ApplicationState for system management
185
+ app_state = ApplicationState(config_path=config_path, config=config)
186
+ logger.info("Application state initialized")
187
+
188
+ # REQ-006: Register signal handlers for graceful shutdown
189
+ signal.signal(signal.SIGINT, signal_handler)
190
+ signal.signal(signal.SIGTERM, signal_handler)
191
+ logger.debug("Signal handlers registered (SIGINT, SIGTERM)")
192
+
193
+ # REQ-004: Connect to NATS before CyTube (event sink must be ready)
194
+ logger.info(f"Connecting to NATS: {config.nats.servers}")
195
+ nats_client = NatsClient(config.nats, logger)
196
+
197
+ try:
198
+ await nats_client.connect()
199
+ app_state.nats_client = nats_client
200
+ logger.info("Successfully connected to NATS")
201
+ except Exception as e:
202
+ # AC-003: NATS connection failure exits with code 1
203
+ logger.error(f"Failed to connect to NATS: {e}", exc_info=True)
204
+ return 1
205
+
206
+ # Start lifecycle event publisher
207
+ lifecycle = LifecycleEventPublisher(
208
+ service_name="robot",
209
+ nats_client=nats_client,
210
+ logger=logger,
211
+ version=__version__
212
+ )
213
+ await lifecycle.start()
214
+
215
+ # Publish startup event
216
+ await lifecycle.publish_startup()
217
+
218
+ # Register restart handler
219
+ async def handle_restart_notice(data: dict):
220
+ """Handle groupwide restart notice."""
221
+ delay = data.get('delay_seconds', 5)
222
+ logger.warning(f"Restart notice received, shutting down in {delay}s")
223
+ await asyncio.sleep(delay)
224
+ app_state.shutdown_event.set()
225
+
226
+ lifecycle.on_restart_notice(handle_restart_notice)
227
+
228
+ # Publish NATS connection event
229
+ await lifecycle.publish_connected("NATS", servers=config.nats.servers)
230
+
231
+ # Start service registry to track microservices
232
+ from .service_registry import ServiceRegistry
233
+ service_registry = ServiceRegistry(nats_client, logger)
234
+
235
+ # Register callbacks for service events
236
+ def on_service_registered(service_info):
237
+ logger.info(
238
+ f"🔵 Service registered: {service_info.name} v{service_info.version} "
239
+ f"on {service_info.hostname}"
240
+ )
241
+
242
+ def on_service_heartbeat(service_info):
243
+ # Only log every 10th heartbeat to avoid spam
244
+ if service_info.heartbeat_count % 10 == 0:
245
+ logger.debug(
246
+ f"💓 Heartbeat from {service_info.name} "
247
+ f"(count: {service_info.heartbeat_count})"
248
+ )
249
+
250
+ def on_service_shutdown(service_name):
251
+ logger.warning(f"🔴 Service shutdown: {service_name}")
252
+
253
+ service_registry.on_service_registered(on_service_registered)
254
+ service_registry.on_service_heartbeat(on_service_heartbeat)
255
+ service_registry.on_service_shutdown(on_service_shutdown)
256
+
257
+ await service_registry.start()
258
+ app_state.service_registry = service_registry
259
+
260
+ # REQ-XXX: Start state manager BEFORE connecting to CyTube
261
+ # This ensures callbacks are ready when initial state events arrive
262
+ try:
263
+ logger.info("Starting state manager")
264
+ state_manager = StateManager(
265
+ nats_client,
266
+ config.cytube.channel,
267
+ logger,
268
+ counting_config=config.state_counting
269
+ )
270
+ await state_manager.start()
271
+ app_state.state_manager = state_manager
272
+ logger.info("State manager started - ready to persist channel state")
273
+ except RuntimeError as e:
274
+ logger.error(f"Failed to start state manager: {e}")
275
+ logger.warning("Continuing without state persistence")
276
+ state_manager = None
277
+
278
+ # Connect to CyTube (automatically joins channel and authenticates)
279
+ logger.info(f"Connecting to CyTube: {config.cytube.domain}/{config.cytube.channel}")
280
+ connector = CytubeConnector(config.cytube, logger)
281
+ app_state.connector = connector
282
+
283
+ # Register state callbacks BEFORE connecting
284
+ # so initial state events from _request_initial_state() are captured
285
+ if state_manager:
286
+ def handle_state_event(event_name: str, payload: dict) -> None:
287
+ """Handle events that affect channel state."""
288
+ async def update_state():
289
+ try:
290
+ logger.debug(f"State callback triggered: {event_name}")
291
+
292
+ if event_name == "emoteList":
293
+ await state_manager.update_emotes(payload)
294
+ elif event_name == "playlist":
295
+ # Full playlist or empty list
296
+ await state_manager.set_playlist(payload)
297
+ elif event_name == "queue":
298
+ # Single item added
299
+ item = payload.get("item", {})
300
+ after = payload.get("after")
301
+ await state_manager.add_playlist_item(item, after)
302
+ elif event_name == "delete":
303
+ uid = payload.get("uid")
304
+ if uid:
305
+ await state_manager.remove_playlist_item(uid)
306
+ elif event_name == "moveMedia":
307
+ from_uid = payload.get("from")
308
+ after = payload.get("after")
309
+ if from_uid:
310
+ await state_manager.move_playlist_item(from_uid, after)
311
+ elif event_name == "userlist":
312
+ await state_manager.set_userlist(payload)
313
+ elif event_name == "addUser":
314
+ await state_manager.add_user(payload)
315
+ elif event_name == "userLeave":
316
+ name = payload.get("name")
317
+ if name:
318
+ await state_manager.remove_user(name)
319
+ elif event_name == "setUserRank":
320
+ # User rank changed - update user
321
+ name = payload.get("name")
322
+ rank = payload.get("rank")
323
+ if name is not None:
324
+ await state_manager.update_user({"name": name, "rank": rank})
325
+ except Exception as e:
326
+ logger.error(f"Error handling state event {event_name}: {e}", exc_info=True)
327
+
328
+ # Schedule the async task
329
+ asyncio.create_task(update_state())
330
+
331
+ # Register state callbacks for relevant events
332
+ state_events = [
333
+ "emoteList", "playlist", "queue", "delete", "moveMedia",
334
+ "userlist", "addUser", "userLeave", "setUserRank"
335
+ ]
336
+ for event in state_events:
337
+ connector.on_event(event, handle_state_event)
338
+
339
+ logger.info("State callbacks registered")
340
+
341
+ # Register chat message logging
342
+ def handle_chat_message(event_name: str, payload: dict) -> None:
343
+ """Log chat messages to audit log."""
344
+ try:
345
+ username = payload.get("username", "Unknown")
346
+ message = payload.get("msg", "")
347
+ # Use server timestamp if available, otherwise current time
348
+ from datetime import datetime
349
+ timestamp = datetime.now()
350
+ audit_logger.log_chat_message(username, message, timestamp)
351
+ except Exception as e:
352
+ logger.error(f"Error logging chat message: {e}", exc_info=True)
353
+
354
+ connector.on_event("chatMsg", handle_chat_message)
355
+ logger.info("Chat message logging registered")
356
+
357
+ # Start connection watchdog to detect stale connections
358
+ async def handle_watchdog_timeout():
359
+ """Handle connection watchdog timeout by initiating shutdown."""
360
+ logger.error("Connection watchdog timeout - no events received")
361
+ logger.info("Initiating reconnection via graceful shutdown")
362
+ app_state.shutdown_event.set()
363
+
364
+ watchdog = ConnectionWatchdog(
365
+ timeout=120.0, # 2 minutes without events triggers reconnection
366
+ on_timeout=handle_watchdog_timeout,
367
+ logger=logger,
368
+ enabled=True
369
+ )
370
+ await watchdog.start()
371
+ logger.info("Connection watchdog monitoring CyTube health")
372
+
373
+ # Feed events to watchdog to keep it alive
374
+ def pet_watchdog(event_name: str, payload: dict) -> None:
375
+ """Pet watchdog on any received event."""
376
+ if watchdog:
377
+ watchdog.pet()
378
+
379
+ # Register watchdog feeder for common periodic events
380
+ # Media events happen regularly as videos play
381
+ for event in ["changeMedia", "mediaUpdate", "setCurrent", "chatMsg", "usercount"]:
382
+ connector.on_event(event, pet_watchdog)
383
+
384
+ try:
385
+ await connector.connect()
386
+ logger.info("Successfully connected to CyTube")
387
+
388
+ # Publish CyTube connection event
389
+ await lifecycle.publish_connected(
390
+ "CyTube",
391
+ domain=config.cytube.domain,
392
+ channel=config.cytube.channel
393
+ )
394
+ except KrytenConnectionError as e:
395
+ # AC-004: CyTube connection failure
396
+ logger.error(f"Failed to connect to CyTube: {e}", exc_info=True)
397
+ # Cleanup
398
+ await lifecycle.publish_shutdown(reason="Failed to connect to CyTube")
399
+ await lifecycle.stop()
400
+ if nats_client:
401
+ await nats_client.disconnect()
402
+ return 1
403
+
404
+ # REQ-005: Start EventPublisher after both connector and NATS connected
405
+ logger.info("Starting event publisher")
406
+ publisher = EventPublisher(
407
+ connector=connector,
408
+ nats_client=nats_client,
409
+ logger=logger,
410
+ batch_size=1,
411
+ retry_attempts=3,
412
+ retry_delay=1.0
413
+ )
414
+ app_state.event_publisher = publisher
415
+
416
+ # Track reconnect task to avoid multiple concurrent reconnects
417
+ reconnect_task: asyncio.Task | None = None
418
+
419
+ async def attempt_reconnect():
420
+ """Attempt to reconnect to CyTube after being kicked."""
421
+ nonlocal reconnect_task
422
+ try:
423
+ # Wait a bit before reconnecting to avoid rapid reconnect loops
424
+ reconnect_delay = 5.0
425
+ logger.info(f"Waiting {reconnect_delay}s before attempting reconnect...")
426
+ await asyncio.sleep(reconnect_delay)
427
+
428
+ # Stop the publisher first
429
+ logger.info("Stopping event publisher for reconnect...")
430
+ await publisher.stop()
431
+
432
+ # Disconnect from CyTube
433
+ logger.info("Disconnecting from CyTube for reconnect...")
434
+ await connector.disconnect()
435
+
436
+ # Attempt to reconnect
437
+ logger.info("Attempting to reconnect to CyTube...")
438
+ await connector.connect()
439
+ logger.info("Successfully reconnected to CyTube")
440
+
441
+ # Publish reconnection event via lifecycle
442
+ if lifecycle and nats_client and nats_client.is_connected:
443
+ await lifecycle.publish_connected(
444
+ "CyTube",
445
+ domain=config.cytube.domain,
446
+ channel=config.cytube.channel,
447
+ note="Reconnected after kick"
448
+ )
449
+
450
+ # Restart the publisher
451
+ nonlocal publisher_task
452
+ publisher_task = asyncio.create_task(publisher.run())
453
+ logger.info("Event publisher restarted after reconnect")
454
+
455
+ except Exception as e:
456
+ logger.error(f"Failed to reconnect to CyTube: {e}", exc_info=True)
457
+ logger.warning("Falling back to graceful shutdown for systemd restart")
458
+ app_state.shutdown_event.set()
459
+ finally:
460
+ reconnect_task = None
461
+
462
+ # Register kick handler - behavior depends on aggressive_reconnect setting
463
+ def handle_kicked():
464
+ """Handle being kicked from channel."""
465
+ nonlocal reconnect_task
466
+ if config.cytube.aggressive_reconnect:
467
+ logger.warning("Kicked from channel - aggressive_reconnect enabled, attempting reconnect")
468
+ if reconnect_task is None or reconnect_task.done():
469
+ reconnect_task = asyncio.create_task(attempt_reconnect())
470
+ else:
471
+ logger.warning("Reconnect already in progress, ignoring duplicate kick")
472
+ else:
473
+ logger.warning("Kicked from channel - initiating graceful shutdown for systemd restart")
474
+ app_state.shutdown_event.set()
475
+
476
+ publisher.on_kicked(handle_kicked)
477
+
478
+ # Start publisher task
479
+ publisher_task = asyncio.create_task(publisher.run())
480
+ logger.info("Event publisher started")
481
+
482
+ # Start command subscriber for bidirectional bridge
483
+ if config.commands.enabled:
484
+ logger.info("Starting command subscriber")
485
+ sender = CytubeEventSender(connector, logger, audit_logger)
486
+ cmd_subscriber = CommandSubscriber(sender, nats_client, logger, config.cytube.domain, config.cytube.channel, audit_logger)
487
+ await cmd_subscriber.start()
488
+ logger.info("Command subscriber started")
489
+ else:
490
+ logger.info("Command subscriptions disabled in configuration")
491
+
492
+ # Start state query handler for NATS queries (if state manager exists)
493
+ if state_manager:
494
+ try:
495
+ logger.info("Starting state query handler")
496
+ state_query_handler = StateQueryHandler(
497
+ state_manager=state_manager,
498
+ nats_client=nats_client,
499
+ logger=logger,
500
+ domain=config.cytube.domain,
501
+ channel=config.cytube.channel,
502
+ app_state=app_state
503
+ )
504
+ await state_query_handler.start()
505
+ except Exception as e:
506
+ logger.error(f"Failed to start state query handler: {e}", exc_info=True)
507
+ logger.warning("Continuing without state query endpoint")
508
+ state_query_handler = None
509
+ else:
510
+ state_query_handler = None
511
+
512
+ # Start user level query handler
513
+ user_level_subscription = None
514
+ try:
515
+ import json
516
+
517
+ async def handle_user_level_query(msg):
518
+ """Handle NATS queries for logged-in user's level/rank."""
519
+ try:
520
+ response = {
521
+ "success": True,
522
+ "rank": connector.user_rank,
523
+ "username": config.cytube.user or "guest"
524
+ }
525
+
526
+ if msg.reply:
527
+ response_bytes = json.dumps(response).encode('utf-8')
528
+ await nats_client.publish(msg.reply, response_bytes)
529
+ logger.debug(f"Sent user level response: rank={connector.user_rank}")
530
+
531
+ except Exception as e:
532
+ logger.error(f"Error handling user level query: {e}", exc_info=True)
533
+ if msg.reply:
534
+ try:
535
+ error_response = {"success": False, "error": str(e)}
536
+ response_bytes = json.dumps(error_response).encode('utf-8')
537
+ await nats_client.publish(msg.reply, response_bytes)
538
+ except Exception as reply_error:
539
+ logger.error(f"Failed to send error response: {reply_error}")
540
+
541
+ user_level_subject = f"kryten.user_level.{config.cytube.domain}.{config.cytube.channel}"
542
+ user_level_subscription = await nats_client.subscribe(
543
+ subject=user_level_subject,
544
+ callback=handle_user_level_query
545
+ )
546
+ logger.info(f"User level query handler listening on: {user_level_subject}")
547
+
548
+ except Exception as e:
549
+ logger.error(f"Failed to start user level query handler: {e}", exc_info=True)
550
+ logger.warning("Continuing without user level query endpoint")
551
+
552
+ # Start health monitor if enabled
553
+ if config.health.enabled:
554
+ logger.info(f"Starting health monitor on {config.health.host}:{config.health.port}")
555
+ health_monitor = HealthMonitor(
556
+ connector=connector,
557
+ nats_client=nats_client,
558
+ publisher=publisher,
559
+ logger=logger,
560
+ command_subscriber=cmd_subscriber, # Pass command subscriber for metrics
561
+ host=config.health.host,
562
+ port=config.health.port
563
+ )
564
+ health_monitor.start()
565
+ logger.info(f"Health endpoint available at http://{config.health.host}:{config.health.port}/health")
566
+ else:
567
+ logger.info("Health monitoring disabled in configuration")
568
+
569
+ # REQ-009: Log ready message
570
+ logger.info("=" * 60)
571
+ logger.info("Kryten is ready and processing events")
572
+ if config.commands.enabled:
573
+ logger.info("Bidirectional bridge active - can send and receive")
574
+ else:
575
+ logger.info("Receive-only mode - commands disabled")
576
+ if config.health.enabled:
577
+ logger.info(f"Health checks: http://{config.health.host}:{config.health.port}/health")
578
+ logger.info("Press Ctrl+C to stop")
579
+ logger.info("=" * 60)
580
+
581
+ # Publish startup complete event
582
+ await lifecycle.publish_startup(
583
+ domain=config.cytube.domain,
584
+ channel=config.cytube.channel,
585
+ commands_enabled=config.commands.enabled,
586
+ health_enabled=config.health.enabled
587
+ )
588
+
589
+ # Wait for shutdown signal
590
+ await app_state.shutdown_event.wait()
591
+
592
+ # REQ-007: Shutdown sequence (reverse order)
593
+ logger.info("Beginning graceful shutdown")
594
+
595
+ # 0. Stop watchdog first to prevent triggering during shutdown
596
+ if watchdog:
597
+ logger.info("Stopping connection watchdog")
598
+ try:
599
+ await watchdog.stop()
600
+ logger.info("Connection watchdog stopped")
601
+ except Exception as e:
602
+ logger.error(f"Error stopping watchdog: {e}")
603
+
604
+ # 1. Stop health monitor
605
+ if health_monitor:
606
+ logger.info("Stopping health monitor")
607
+ try:
608
+ health_monitor.stop()
609
+ logger.info("Health monitor stopped")
610
+ except Exception as e:
611
+ logger.error(f"Error stopping health monitor: {e}")
612
+
613
+ # 2. Stop service registry
614
+ if service_registry:
615
+ logger.info("Stopping service registry")
616
+ try:
617
+ await service_registry.stop()
618
+ logger.info("Service registry stopped")
619
+ except Exception as e:
620
+ logger.error(f"Error stopping service registry: {e}")
621
+
622
+ # 3. Stop state query handler
623
+ if state_query_handler:
624
+ logger.info("Stopping state query handler")
625
+ try:
626
+ await state_query_handler.stop()
627
+ logger.info("State query handler stopped")
628
+ except Exception as e:
629
+ logger.error(f"Error stopping state query handler: {e}")
630
+
631
+ # 3b. Unsubscribe user level query handler
632
+ if user_level_subscription:
633
+ try:
634
+ logger.info("Stopping user level query handler")
635
+ if nats_client and nats_client.is_connected:
636
+ await nats_client.unsubscribe(user_level_subscription)
637
+ logger.info("User level query handler stopped")
638
+ except Exception as e:
639
+ logger.error(f"Error stopping user level query handler: {e}")
640
+
641
+ # 4. Stop state manager
642
+ if state_manager:
643
+ logger.info("Stopping state manager")
644
+ try:
645
+ await state_manager.stop()
646
+ logger.info("State manager stopped")
647
+ except Exception as e:
648
+ logger.error(f"Error stopping state manager: {e}")
649
+
650
+ # 5. Stop command subscriber
651
+ if cmd_subscriber:
652
+ logger.info("Stopping command subscriber")
653
+ try:
654
+ await cmd_subscriber.stop()
655
+ logger.info("Command subscriber stopped")
656
+ except Exception as e:
657
+ logger.error(f"Error stopping command subscriber: {e}")
658
+
659
+ # 6. Stop publisher (completes current event processing)
660
+ if publisher:
661
+ logger.info("Stopping event publisher")
662
+ try:
663
+ await publisher.stop()
664
+
665
+ # Wait for publisher task to complete
666
+ try:
667
+ await asyncio.wait_for(publisher_task, timeout=5.0)
668
+ logger.info("Event publisher stopped")
669
+ except TimeoutError:
670
+ logger.warning("Event publisher did not stop within timeout, cancelling")
671
+ publisher_task.cancel()
672
+ try:
673
+ await publisher_task
674
+ except asyncio.CancelledError:
675
+ pass
676
+ except Exception as e:
677
+ logger.error(f"Error stopping publisher: {e}")
678
+
679
+ # 6. Disconnect from CyTube
680
+ if connector:
681
+ logger.info("Disconnecting from CyTube")
682
+ try:
683
+ await connector.disconnect()
684
+ if lifecycle and nats_client and nats_client.is_connected:
685
+ await lifecycle.publish_disconnected("CyTube", reason="Graceful shutdown")
686
+ logger.info("Disconnected from CyTube")
687
+ except Exception as e:
688
+ logger.error(f"Error disconnecting from CyTube: {e}")
689
+
690
+ # 7. Publish shutdown event and stop lifecycle publisher
691
+ if lifecycle:
692
+ try:
693
+ if nats_client and nats_client.is_connected:
694
+ await lifecycle.publish_shutdown(reason="Normal shutdown")
695
+ await lifecycle.stop()
696
+ logger.info("Lifecycle event publisher stopped")
697
+ except Exception as e:
698
+ logger.error(f"Error stopping lifecycle publisher: {e}")
699
+
700
+ # 8. Disconnect from NATS
701
+ if nats_client:
702
+ logger.info("Disconnecting from NATS")
703
+ try:
704
+ await nats_client.disconnect()
705
+ logger.info("Disconnected from NATS")
706
+ except Exception as e:
707
+ logger.error(f"Error disconnecting from NATS: {e}")
708
+
709
+ logger.info("Graceful shutdown complete")
710
+
711
+ # REQ-008: Exit with code 0 on clean shutdown
712
+ return 0
713
+
714
+ except FileNotFoundError:
715
+ # AC-006: Config file missing
716
+ if logger:
717
+ logger.error(f"Configuration file not found: {config_path}")
718
+ else:
719
+ print(f"ERROR: Configuration file not found: {config_path}", file=sys.stderr)
720
+ return 1
721
+
722
+ except json.JSONDecodeError as e:
723
+ # Configuration JSON parse error
724
+ if logger:
725
+ logger.error(f"Invalid JSON in configuration file: {e}")
726
+ else:
727
+ print(f"ERROR: Invalid JSON in configuration file: {e}", file=sys.stderr)
728
+ return 1
729
+
730
+ except Exception as e:
731
+ # AC-007: Unhandled exception
732
+ if logger:
733
+ logger.critical(f"Unhandled exception: {e}", exc_info=True)
734
+ else:
735
+ print(f"CRITICAL: Unhandled exception: {e}", file=sys.stderr)
736
+ import traceback
737
+ traceback.print_exc()
738
+
739
+ # Cleanup any initialized components
740
+ try:
741
+ if watchdog:
742
+ try:
743
+ await watchdog.stop()
744
+ except Exception as e:
745
+ logger.error(f"Error stopping watchdog during cleanup: {e}")
746
+ if health_monitor:
747
+ try:
748
+ health_monitor.stop()
749
+ except Exception as e:
750
+ logger.error(f"Error stopping health monitor during cleanup: {e}")
751
+ if state_manager:
752
+ try:
753
+ await state_manager.stop()
754
+ except Exception as e:
755
+ logger.error(f"Error stopping state manager during cleanup: {e}")
756
+ if cmd_subscriber:
757
+ try:
758
+ await cmd_subscriber.stop()
759
+ except Exception as e:
760
+ logger.error(f"Error stopping command subscriber during cleanup: {e}")
761
+ if publisher:
762
+ try:
763
+ await publisher.stop()
764
+ except Exception as e:
765
+ logger.error(f"Error stopping publisher during cleanup: {e}")
766
+ if connector:
767
+ try:
768
+ await connector.disconnect()
769
+ except Exception as e:
770
+ logger.error(f"Error disconnecting connector during cleanup: {e}")
771
+ if nats_client:
772
+ try:
773
+ await nats_client.disconnect()
774
+ except Exception as e:
775
+ logger.error(f"Error disconnecting NATS during cleanup: {e}")
776
+ except Exception as cleanup_error:
777
+ if logger:
778
+ logger.error(f"Error during cleanup: {cleanup_error}", exc_info=True)
779
+
780
+ # REQ-008: Exit with code 1 on errors
781
+ return 1
782
+
783
+
784
+ def cli() -> None:
785
+ """Command-line interface entry point.
786
+
787
+ Parses arguments and runs main orchestration function.
788
+ Handles --version and --help flags.
789
+ """
790
+ # PAT-001: Use argparse for CLI
791
+ parser = argparse.ArgumentParser(
792
+ prog="python -m kryten",
793
+ description="Kryten CyTube Connector - Bridges CyTube chat to NATS event bus",
794
+ formatter_class=argparse.RawDescriptionHelpFormatter,
795
+ epilog="""
796
+ Examples:
797
+ python -m kryten # Use default config locations
798
+ python -m kryten --config /path/to/config.json
799
+ python -m kryten --version
800
+ python -m kryten --help
801
+
802
+ Default config locations (searched in order):
803
+ - /etc/kryten/kryten-robot/config.json
804
+ - ./config.json
805
+
806
+ Signals:
807
+ SIGINT (Ctrl+C): Graceful shutdown
808
+ SIGTERM: Graceful shutdown (for container orchestration)
809
+
810
+ Exit Codes:
811
+ 0: Clean shutdown
812
+ 1: Error (configuration, connection, or runtime)
813
+ """
814
+ )
815
+
816
+ # GUD-001: Version flag
817
+ parser.add_argument(
818
+ "--version",
819
+ action="version",
820
+ version=f"Kryten v{__version__}"
821
+ )
822
+
823
+ # REQ-002: Configuration file argument (optional with default)
824
+ parser.add_argument(
825
+ "--config",
826
+ "-c",
827
+ type=str,
828
+ help="Path to JSON configuration file (default: /etc/kryten/kryten-robot/config.json or ./config.json)"
829
+ )
830
+
831
+ args = parser.parse_args()
832
+
833
+ # GUD-002: Determine configuration file path
834
+ if args.config:
835
+ config_path = Path(args.config)
836
+ else:
837
+ # Try default locations in order
838
+ default_paths = [
839
+ Path("/etc/kryten/kryten-robot/config.json"),
840
+ Path("config.json")
841
+ ]
842
+
843
+ config_path = None
844
+ for path in default_paths:
845
+ if path.exists() and path.is_file():
846
+ config_path = path
847
+ break
848
+
849
+ if not config_path:
850
+ print("ERROR: No configuration file found.", file=sys.stderr)
851
+ print(" Searched:", file=sys.stderr)
852
+ for path in default_paths:
853
+ print(f" - {path}", file=sys.stderr)
854
+ print(" Use --config to specify a custom path.", file=sys.stderr)
855
+ sys.exit(1)
856
+
857
+ # Validate configuration file exists
858
+ if not config_path.exists():
859
+ print(f"ERROR: Configuration file not found: {config_path}", file=sys.stderr)
860
+ sys.exit(1)
861
+
862
+ if not config_path.is_file():
863
+ print(f"ERROR: Configuration path is not a file: {config_path}", file=sys.stderr)
864
+ sys.exit(1)
865
+
866
+ # Print startup banner
867
+ print_startup_banner(str(config_path))
868
+
869
+ # REQ-001: Run async main function
870
+ try:
871
+ exit_code = asyncio.run(main(str(config_path)))
872
+ except KeyboardInterrupt:
873
+ # Handle Ctrl+C during startup (before signal handlers registered)
874
+ print("\nInterrupted during startup")
875
+ exit_code = 1
876
+
877
+ sys.exit(exit_code)
878
+
879
+
880
+ # PAT-001: Module execution guard
881
+ if __name__ == "__main__":
882
+ cli()