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
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()
|