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,98 @@
|
|
|
1
|
+
"""Application State - Shared runtime state for system management.
|
|
2
|
+
|
|
3
|
+
This module provides the ApplicationState class that holds references to all
|
|
4
|
+
major components and runtime state needed for system management commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import time
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
# Use TYPE_CHECKING to avoid circular imports
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .command_subscriber import CommandSubscriber
|
|
14
|
+
from .config import KrytenConfig
|
|
15
|
+
from .cytube_connector import CytubeConnector
|
|
16
|
+
from .event_publisher import EventPublisher
|
|
17
|
+
from .nats_client import NatsClient
|
|
18
|
+
from .service_registry import ServiceRegistry
|
|
19
|
+
from .state_manager import StateManager
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ApplicationState:
|
|
23
|
+
"""Shared application state for runtime operations and system management.
|
|
24
|
+
|
|
25
|
+
Holds references to all major components and runtime state needed for
|
|
26
|
+
stats collection, configuration reload, and graceful shutdown.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
config_path: Path to the configuration file.
|
|
30
|
+
config: Loaded configuration object.
|
|
31
|
+
shutdown_event: Event to signal graceful shutdown.
|
|
32
|
+
start_time: Unix timestamp when application started.
|
|
33
|
+
event_publisher: EventPublisher instance (set after initialization).
|
|
34
|
+
command_subscriber: CommandSubscriber instance (set after initialization).
|
|
35
|
+
connector: CytubeConnector instance (set after initialization).
|
|
36
|
+
nats_client: NatsClient instance (set after initialization).
|
|
37
|
+
state_manager: StateManager instance (set after initialization).
|
|
38
|
+
service_registry: ServiceRegistry instance (set after initialization).
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
>>> from .config import load_config
|
|
42
|
+
>>> config = load_config("config.json")
|
|
43
|
+
>>> app_state = ApplicationState("config.json", config)
|
|
44
|
+
>>>
|
|
45
|
+
>>> # Later, after component initialization
|
|
46
|
+
>>> app_state.connector = connector
|
|
47
|
+
>>> app_state.nats_client = nats_client
|
|
48
|
+
>>>
|
|
49
|
+
>>> # Check uptime
|
|
50
|
+
>>> uptime = time.time() - app_state.start_time
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, config_path: str, config: 'KrytenConfig'):
|
|
54
|
+
"""Initialize application state.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
config_path: Path to configuration file.
|
|
58
|
+
config: Loaded configuration object.
|
|
59
|
+
"""
|
|
60
|
+
self.config_path = config_path
|
|
61
|
+
self.config = config
|
|
62
|
+
self.shutdown_event = asyncio.Event()
|
|
63
|
+
self.start_time = time.time()
|
|
64
|
+
|
|
65
|
+
# Component references (set by main after initialization)
|
|
66
|
+
self.event_publisher: EventPublisher | None = None
|
|
67
|
+
self.command_subscriber: CommandSubscriber | None = None
|
|
68
|
+
self.connector: CytubeConnector | None = None
|
|
69
|
+
self.nats_client: NatsClient | None = None
|
|
70
|
+
self.state_manager: StateManager | None = None
|
|
71
|
+
self.service_registry: ServiceRegistry | None = None
|
|
72
|
+
|
|
73
|
+
def get_uptime(self) -> float:
|
|
74
|
+
"""Get application uptime in seconds.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Seconds since application started.
|
|
78
|
+
|
|
79
|
+
Examples:
|
|
80
|
+
>>> uptime = app_state.get_uptime()
|
|
81
|
+
>>> print(f"Uptime: {uptime / 3600:.1f} hours")
|
|
82
|
+
"""
|
|
83
|
+
return time.time() - self.start_time
|
|
84
|
+
|
|
85
|
+
def request_shutdown(self, reason: str = "Unknown") -> None:
|
|
86
|
+
"""Request graceful shutdown of the application.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
reason: Reason for shutdown (for logging).
|
|
90
|
+
|
|
91
|
+
Examples:
|
|
92
|
+
>>> app_state.request_shutdown("User requested")
|
|
93
|
+
"""
|
|
94
|
+
if not self.shutdown_event.is_set():
|
|
95
|
+
self.shutdown_event.set()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["ApplicationState"]
|
kryten/audit_logger.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Audit logging for admin, playlist, chat, and command operations.
|
|
2
|
+
|
|
3
|
+
This module provides specialized loggers for tracking CyTube operations:
|
|
4
|
+
- Admin operations (rank changes, bans, filters, emotes, etc.)
|
|
5
|
+
- Playlist operations (queue, delete, move, shuffle, etc.)
|
|
6
|
+
- Chat messages (timestamped chat log)
|
|
7
|
+
- Command audit (all received commands with username and arguments)
|
|
8
|
+
|
|
9
|
+
All logs are written in UTF-8 encoding.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuditLogger:
|
|
19
|
+
"""Manages specialized audit loggers for different operation types.
|
|
20
|
+
|
|
21
|
+
Creates and manages file handlers for:
|
|
22
|
+
- Admin operations
|
|
23
|
+
- Playlist operations
|
|
24
|
+
- Chat messages
|
|
25
|
+
- Command audit
|
|
26
|
+
|
|
27
|
+
All log files are UTF-8 encoded and use append mode.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, base_path: str, filenames: dict[str, str]):
|
|
31
|
+
"""Initialize audit logger.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
base_path: Base directory for all log files.
|
|
35
|
+
filenames: Dictionary mapping log types to filenames:
|
|
36
|
+
- admin_operations
|
|
37
|
+
- playlist_operations
|
|
38
|
+
- chat_messages
|
|
39
|
+
- command_audit
|
|
40
|
+
"""
|
|
41
|
+
self.base_path = Path(base_path)
|
|
42
|
+
self.filenames = filenames
|
|
43
|
+
|
|
44
|
+
# Create log directory if it doesn't exist
|
|
45
|
+
self.base_path.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
# Create specialized loggers
|
|
48
|
+
self.admin_logger = self._create_logger("admin", filenames["admin_operations"])
|
|
49
|
+
self.playlist_logger = self._create_logger("playlist", filenames["playlist_operations"])
|
|
50
|
+
self.chat_logger = self._create_logger("chat", filenames["chat_messages"])
|
|
51
|
+
self.command_logger = self._create_logger("command", filenames["command_audit"])
|
|
52
|
+
|
|
53
|
+
def _create_logger(self, name: str, filename: str) -> logging.Logger:
|
|
54
|
+
"""Create a specialized logger with file handler.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
name: Logger name (used as suffix).
|
|
58
|
+
filename: Log filename.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Configured logger instance.
|
|
62
|
+
"""
|
|
63
|
+
logger_name = f"bot.kryten.audit.{name}"
|
|
64
|
+
logger = logging.getLogger(logger_name)
|
|
65
|
+
logger.setLevel(logging.INFO)
|
|
66
|
+
logger.propagate = False # Don't propagate to root logger
|
|
67
|
+
|
|
68
|
+
# Remove existing handlers to avoid duplicates
|
|
69
|
+
logger.handlers.clear()
|
|
70
|
+
|
|
71
|
+
# Create file handler with UTF-8 encoding
|
|
72
|
+
log_path = self.base_path / filename
|
|
73
|
+
handler = logging.FileHandler(log_path, mode='a', encoding='utf-8')
|
|
74
|
+
handler.setLevel(logging.INFO)
|
|
75
|
+
|
|
76
|
+
# Simple format: timestamp + message
|
|
77
|
+
formatter = logging.Formatter('%(asctime)s %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
|
|
78
|
+
handler.setFormatter(formatter)
|
|
79
|
+
|
|
80
|
+
logger.addHandler(handler)
|
|
81
|
+
|
|
82
|
+
return logger
|
|
83
|
+
|
|
84
|
+
def log_admin_operation(
|
|
85
|
+
self,
|
|
86
|
+
operation: str,
|
|
87
|
+
username: str | None = None,
|
|
88
|
+
target: str | None = None,
|
|
89
|
+
details: dict[str, Any] | None = None
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Log an admin operation.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
operation: Operation type (e.g., "setMotd", "ban", "setChannelRank").
|
|
95
|
+
username: User who performed the operation (bot username).
|
|
96
|
+
target: Target of the operation (username, filter name, etc.).
|
|
97
|
+
details: Additional operation details.
|
|
98
|
+
|
|
99
|
+
Example:
|
|
100
|
+
>>> audit.log_admin_operation("ban", "BotAdmin", "SpamUser", {"duration": 3600})
|
|
101
|
+
"""
|
|
102
|
+
parts = [f"[{operation}]"]
|
|
103
|
+
if username:
|
|
104
|
+
parts.append(f"by={username}")
|
|
105
|
+
if target:
|
|
106
|
+
parts.append(f"target={target}")
|
|
107
|
+
if details:
|
|
108
|
+
detail_str = " ".join(f"{k}={v}" for k, v in details.items())
|
|
109
|
+
parts.append(detail_str)
|
|
110
|
+
|
|
111
|
+
message = " ".join(parts)
|
|
112
|
+
self.admin_logger.info(message)
|
|
113
|
+
|
|
114
|
+
def log_playlist_operation(
|
|
115
|
+
self,
|
|
116
|
+
operation: str,
|
|
117
|
+
username: str | None = None,
|
|
118
|
+
media_title: str | None = None,
|
|
119
|
+
details: dict[str, Any] | None = None
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Log a playlist operation.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
operation: Operation type (e.g., "queue", "delete", "moveMedia").
|
|
125
|
+
username: User who performed the operation.
|
|
126
|
+
media_title: Title of media item.
|
|
127
|
+
details: Additional operation details (position, duration, etc.).
|
|
128
|
+
|
|
129
|
+
Example:
|
|
130
|
+
>>> audit.log_playlist_operation("queue", "Alice", "Cool Video", {"duration": 300})
|
|
131
|
+
"""
|
|
132
|
+
parts = [f"[{operation}]"]
|
|
133
|
+
if username:
|
|
134
|
+
parts.append(f"user={username}")
|
|
135
|
+
if media_title:
|
|
136
|
+
# Truncate long titles
|
|
137
|
+
title = media_title[:100]
|
|
138
|
+
parts.append(f"title=\"{title}\"")
|
|
139
|
+
if details:
|
|
140
|
+
detail_str = " ".join(f"{k}={v}" for k, v in details.items())
|
|
141
|
+
parts.append(detail_str)
|
|
142
|
+
|
|
143
|
+
message = " ".join(parts)
|
|
144
|
+
self.playlist_logger.info(message)
|
|
145
|
+
|
|
146
|
+
def log_chat_message(self, username: str, message: str, timestamp: datetime | None = None) -> None:
|
|
147
|
+
"""Log a chat message in IRC-style format.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
username: Username of the message sender.
|
|
151
|
+
message: Chat message text.
|
|
152
|
+
timestamp: Optional timestamp (defaults to now).
|
|
153
|
+
|
|
154
|
+
Format: HH:MM:SS <username>: message
|
|
155
|
+
|
|
156
|
+
Example:
|
|
157
|
+
>>> audit.log_chat_message("Alice", "Hello everyone!")
|
|
158
|
+
# Output: 14:35:22 <Alice>: Hello everyone!
|
|
159
|
+
"""
|
|
160
|
+
if timestamp is None:
|
|
161
|
+
timestamp = datetime.now()
|
|
162
|
+
|
|
163
|
+
time_str = timestamp.strftime("%H:%M:%S")
|
|
164
|
+
formatted = f"{time_str} <{username}>: {message}"
|
|
165
|
+
|
|
166
|
+
# Use a plain handler without timestamp prefix (we're adding it manually)
|
|
167
|
+
# Remove all handlers temporarily
|
|
168
|
+
handlers = self.chat_logger.handlers[:]
|
|
169
|
+
self.chat_logger.handlers.clear()
|
|
170
|
+
|
|
171
|
+
# Add handler without timestamp
|
|
172
|
+
log_path = self.base_path / self.filenames["chat_messages"]
|
|
173
|
+
handler = logging.FileHandler(log_path, mode='a', encoding='utf-8')
|
|
174
|
+
formatter = logging.Formatter('%(message)s')
|
|
175
|
+
handler.setFormatter(formatter)
|
|
176
|
+
self.chat_logger.addHandler(handler)
|
|
177
|
+
|
|
178
|
+
self.chat_logger.info(formatted)
|
|
179
|
+
|
|
180
|
+
# Restore original handlers
|
|
181
|
+
self.chat_logger.handlers.clear()
|
|
182
|
+
self.chat_logger.handlers.extend(handlers)
|
|
183
|
+
|
|
184
|
+
def log_command(
|
|
185
|
+
self,
|
|
186
|
+
command: str,
|
|
187
|
+
username: str | None = None,
|
|
188
|
+
arguments: dict[str, Any] | None = None,
|
|
189
|
+
source: str = "NATS"
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Log a received command.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
command: Command name (e.g., "sendChat", "setMotd").
|
|
195
|
+
username: Username associated with the command.
|
|
196
|
+
arguments: Command arguments.
|
|
197
|
+
source: Source of the command (e.g., "NATS", "internal").
|
|
198
|
+
|
|
199
|
+
Example:
|
|
200
|
+
>>> audit.log_command("sendChat", "bot", {"message": "Hello"}, "NATS")
|
|
201
|
+
"""
|
|
202
|
+
parts = [f"[{source}]", f"command={command}"]
|
|
203
|
+
if username:
|
|
204
|
+
parts.append(f"user={username}")
|
|
205
|
+
if arguments:
|
|
206
|
+
# Sanitize sensitive data
|
|
207
|
+
safe_args = {k: "***" if "password" in k.lower() else v for k, v in arguments.items()}
|
|
208
|
+
args_str = " ".join(f"{k}={v}" for k, v in safe_args.items())
|
|
209
|
+
parts.append(f"args=({args_str})")
|
|
210
|
+
|
|
211
|
+
message = " ".join(parts)
|
|
212
|
+
self.command_logger.info(message)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def create_audit_logger(base_path: str, filenames: dict[str, str]) -> AuditLogger:
|
|
216
|
+
"""Factory function to create audit logger.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
base_path: Base directory for log files.
|
|
220
|
+
filenames: Dictionary mapping log types to filenames.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Configured AuditLogger instance.
|
|
224
|
+
|
|
225
|
+
Example:
|
|
226
|
+
>>> filenames = {
|
|
227
|
+
... "admin_operations": "admin-ops.log",
|
|
228
|
+
... "playlist_operations": "playlist-ops.log",
|
|
229
|
+
... "chat_messages": "chat.log",
|
|
230
|
+
... "command_audit": "commands.log"
|
|
231
|
+
... }
|
|
232
|
+
>>> audit = create_audit_logger("/var/log/kryten", filenames)
|
|
233
|
+
"""
|
|
234
|
+
return AuditLogger(base_path, filenames)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
__all__ = ["AuditLogger", "create_audit_logger"]
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""NATS Command Subscriber - Listen for commands and execute on CyTube.
|
|
2
|
+
|
|
3
|
+
This module subscribes to NATS command subjects and routes them to the
|
|
4
|
+
CytubeEventSender to execute on CyTube channels.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .cytube_event_sender import CytubeEventSender
|
|
12
|
+
from .nats_client import NatsClient
|
|
13
|
+
from .stats_tracker import StatsTracker
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CommandSubscriber:
|
|
17
|
+
"""Subscribe to NATS commands and execute them on CyTube.
|
|
18
|
+
|
|
19
|
+
Routes commands from NATS to CytubeEventSender methods, handling
|
|
20
|
+
JSON parsing, validation, and error logging.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
sender: CyTube event sender instance.
|
|
24
|
+
nats_client: NATS client for subscriptions.
|
|
25
|
+
logger: Logger instance.
|
|
26
|
+
channel: CyTube channel name for subject filtering.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
>>> subscriber = CommandSubscriber(sender, nats_client, logger, "cytu.be", "mychannel")
|
|
30
|
+
>>> await subscriber.start()
|
|
31
|
+
>>> # Commands sent to cytube.commands.cytu.be.mychannel.* will be executed
|
|
32
|
+
>>> await subscriber.stop()
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
sender: CytubeEventSender,
|
|
38
|
+
nats_client: NatsClient,
|
|
39
|
+
logger: logging.Logger,
|
|
40
|
+
domain: str,
|
|
41
|
+
channel: str,
|
|
42
|
+
audit_logger=None,
|
|
43
|
+
):
|
|
44
|
+
"""Initialize command subscriber.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
sender: CyTube event sender instance.
|
|
48
|
+
nats_client: NATS client for subscriptions.
|
|
49
|
+
logger: Logger for structured output.
|
|
50
|
+
domain: CyTube domain name.
|
|
51
|
+
channel: CyTube channel name.
|
|
52
|
+
audit_logger: Optional AuditLogger for command tracking.
|
|
53
|
+
"""
|
|
54
|
+
self._sender = sender
|
|
55
|
+
self._nats = nats_client
|
|
56
|
+
self._logger = logger
|
|
57
|
+
self._domain = domain
|
|
58
|
+
self._channel = channel
|
|
59
|
+
self._audit_logger = audit_logger
|
|
60
|
+
self._running = False
|
|
61
|
+
self._subscription = None
|
|
62
|
+
|
|
63
|
+
# Metrics tracking
|
|
64
|
+
self._commands_processed = 0
|
|
65
|
+
self._commands_succeeded = 0
|
|
66
|
+
self._commands_failed = 0
|
|
67
|
+
|
|
68
|
+
# Rate tracking
|
|
69
|
+
self._stats_tracker = StatsTracker()
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def stats(self) -> dict[str, Any]:
|
|
73
|
+
"""Get command processing statistics.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Dictionary with commands_processed, commands_succeeded,
|
|
77
|
+
commands_failed counts, and rate information.
|
|
78
|
+
|
|
79
|
+
Examples:
|
|
80
|
+
>>> stats = subscriber.stats
|
|
81
|
+
>>> print(f"Processed: {stats['commands_processed']}")
|
|
82
|
+
>>> print(f"Success rate: {stats['commands_succeeded'] / stats['commands_processed']}")
|
|
83
|
+
>>> print(f"Rate (1m): {stats['rate_1min']:.2f}/sec")
|
|
84
|
+
"""
|
|
85
|
+
last_time, last_type = self._stats_tracker.get_last()
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"commands_processed": self._commands_processed,
|
|
89
|
+
"commands_succeeded": self._commands_succeeded,
|
|
90
|
+
"commands_failed": self._commands_failed,
|
|
91
|
+
"rate_1min": self._stats_tracker.get_rate(60),
|
|
92
|
+
"rate_5min": self._stats_tracker.get_rate(300),
|
|
93
|
+
"last_command_time": last_time,
|
|
94
|
+
"last_command_type": last_type,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def is_running(self) -> bool:
|
|
99
|
+
"""Check if subscriber is running.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
True if subscribed and processing commands, False otherwise.
|
|
103
|
+
"""
|
|
104
|
+
return self._running
|
|
105
|
+
|
|
106
|
+
async def start(self) -> None:
|
|
107
|
+
"""Start subscribing to command subjects.
|
|
108
|
+
|
|
109
|
+
Subscribes to kryten.commands.cytube.{channel}.> to receive all commands
|
|
110
|
+
for this channel.
|
|
111
|
+
"""
|
|
112
|
+
if self._running:
|
|
113
|
+
self._logger.warning("Command subscriber already running")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
self._running = True
|
|
117
|
+
|
|
118
|
+
# Import here to use the updated function
|
|
119
|
+
from .subject_builder import normalize_token
|
|
120
|
+
|
|
121
|
+
# Subscribe to all commands for this channel (domain normalized out)
|
|
122
|
+
channel_normalized = normalize_token(self._channel)
|
|
123
|
+
subject = f"kryten.commands.cytube.{channel_normalized}.>"
|
|
124
|
+
self._subscription = await self._nats.subscribe(subject, self._handle_command)
|
|
125
|
+
|
|
126
|
+
self._logger.info(f"Command subscriber started on subject: {subject}")
|
|
127
|
+
|
|
128
|
+
async def stop(self) -> None:
|
|
129
|
+
"""Stop command subscriptions."""
|
|
130
|
+
if not self._running:
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
self._running = False
|
|
134
|
+
|
|
135
|
+
if self._subscription:
|
|
136
|
+
await self._nats.unsubscribe(self._subscription)
|
|
137
|
+
self._subscription = None
|
|
138
|
+
|
|
139
|
+
self._logger.info("Command subscriber stopped")
|
|
140
|
+
|
|
141
|
+
async def _handle_command(self, subject: str, data: bytes) -> None:
|
|
142
|
+
"""Handle incoming command message.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
subject: NATS subject the command was received on.
|
|
146
|
+
data: Message payload (JSON).
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
# Parse command JSON
|
|
150
|
+
command = json.loads(data.decode())
|
|
151
|
+
action = command.get("action")
|
|
152
|
+
params = command.get("data", {})
|
|
153
|
+
|
|
154
|
+
if not action:
|
|
155
|
+
self._logger.warning(f"Command missing 'action' field on {subject}")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
self._logger.info(f"Received command '{action}' on {subject}")
|
|
159
|
+
|
|
160
|
+
# Audit log the command
|
|
161
|
+
if self._audit_logger:
|
|
162
|
+
# Extract username from params if available
|
|
163
|
+
username = params.get("username") or params.get("name") or "system"
|
|
164
|
+
self._audit_logger.log_command(
|
|
165
|
+
command=action,
|
|
166
|
+
username=username,
|
|
167
|
+
arguments=params,
|
|
168
|
+
source="NATS"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Route to appropriate sender method
|
|
172
|
+
success = await self._route_command(action, params)
|
|
173
|
+
|
|
174
|
+
# Update metrics
|
|
175
|
+
self._commands_processed += 1
|
|
176
|
+
if success:
|
|
177
|
+
self._commands_succeeded += 1
|
|
178
|
+
self._stats_tracker.record(action)
|
|
179
|
+
self._logger.debug(f"Command '{action}' executed successfully")
|
|
180
|
+
else:
|
|
181
|
+
self._commands_failed += 1
|
|
182
|
+
self._logger.warning(f"Command '{action}' execution failed")
|
|
183
|
+
|
|
184
|
+
except json.JSONDecodeError as e:
|
|
185
|
+
self._commands_failed += 1
|
|
186
|
+
self._logger.error(f"Invalid JSON in command on {subject}: {e}")
|
|
187
|
+
except Exception as e:
|
|
188
|
+
self._commands_failed += 1
|
|
189
|
+
self._logger.error(f"Error handling command on {subject}: {e}", exc_info=True)
|
|
190
|
+
|
|
191
|
+
async def _route_command(self, action: str, params: dict[str, Any]) -> bool:
|
|
192
|
+
"""Route command to appropriate sender method.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
action: Action name (e.g., "chat", "queue", "kick").
|
|
196
|
+
params: Action parameters.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
True if command executed successfully, False otherwise.
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
# Chat actions
|
|
203
|
+
if action == "chat":
|
|
204
|
+
return await self._sender.send_chat(**params)
|
|
205
|
+
elif action == "pm":
|
|
206
|
+
return await self._sender.send_pm(**params)
|
|
207
|
+
|
|
208
|
+
# Playlist actions
|
|
209
|
+
elif action == "queue" or action == "add_video":
|
|
210
|
+
# Handle both old format (url) and new format (type + id)
|
|
211
|
+
if "type" in params and "id" in params:
|
|
212
|
+
# New format from kryten-py: {"type": "yt", "id": "abc123", "pos": "end"}
|
|
213
|
+
# Check if this is a grindhouse URL that needs transformation
|
|
214
|
+
media_id = params.get("id")
|
|
215
|
+
media_type = params.get("type")
|
|
216
|
+
|
|
217
|
+
# If type is "cu" and id looks like a grindhouse view URL, transform it
|
|
218
|
+
if media_type == "cu" and isinstance(media_id, str) and "420grindhouse.com/view?m=" in media_id:
|
|
219
|
+
# Transform to custom media format with JSON API URL
|
|
220
|
+
import re
|
|
221
|
+
pattern = r'https?://(?:www\.)?420grindhouse\.com/view\?m=([A-Za-z0-9_-]+)'
|
|
222
|
+
match = re.match(pattern, media_id)
|
|
223
|
+
if match:
|
|
224
|
+
media_id_code = match.group(1)
|
|
225
|
+
media_id = f"https://www.420grindhouse.com/api/v1/media/cytube/{media_id_code}.json?format=json"
|
|
226
|
+
media_type = "cm"
|
|
227
|
+
self._logger.info(f"Transformed grindhouse URL in command subscriber: type={media_type}, id={media_id}")
|
|
228
|
+
|
|
229
|
+
return await self._sender.add_video(
|
|
230
|
+
media_type=media_type,
|
|
231
|
+
media_id=media_id,
|
|
232
|
+
position=params.get("pos", "end"),
|
|
233
|
+
temp=params.get("temp", False)
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
# Old format: {"url": "yt:abc123", "position": "end", "temp": False}
|
|
237
|
+
return await self._sender.add_video(**params)
|
|
238
|
+
elif action == "delete" or action == "delete_video":
|
|
239
|
+
# CyTube expects UID as raw number, not wrapped in object
|
|
240
|
+
# The event sender will handle conversion to int
|
|
241
|
+
return await self._sender.delete_video(**params)
|
|
242
|
+
elif action == "move" or action == "move_video":
|
|
243
|
+
# Map 'from' to 'uid' if needed (event sender expects 'uid' parameter)
|
|
244
|
+
if "from" in params and "uid" not in params:
|
|
245
|
+
params["uid"] = params.pop("from")
|
|
246
|
+
# Event sender will handle UID type conversions
|
|
247
|
+
return await self._sender.move_video(**params)
|
|
248
|
+
elif action == "jump" or action == "jump_to":
|
|
249
|
+
# Event sender will handle UID type conversion
|
|
250
|
+
return await self._sender.jump_to(**params)
|
|
251
|
+
elif action == "clear" or action == "clear_playlist":
|
|
252
|
+
return await self._sender.clear_playlist()
|
|
253
|
+
elif action == "shuffle" or action == "shuffle_playlist":
|
|
254
|
+
return await self._sender.shuffle_playlist()
|
|
255
|
+
elif action == "set_temp" or action == "setTemp":
|
|
256
|
+
# Event sender expects uid parameter and handles type conversion
|
|
257
|
+
return await self._sender.set_temp(**params)
|
|
258
|
+
|
|
259
|
+
# Playback actions
|
|
260
|
+
elif action == "pause":
|
|
261
|
+
return await self._sender.pause()
|
|
262
|
+
elif action == "play":
|
|
263
|
+
return await self._sender.play()
|
|
264
|
+
elif action == "seek" or action == "seek_to":
|
|
265
|
+
return await self._sender.seek_to(**params)
|
|
266
|
+
|
|
267
|
+
# Moderation actions
|
|
268
|
+
elif action == "kick" or action == "kick_user":
|
|
269
|
+
return await self._sender.kick_user(**params)
|
|
270
|
+
elif action == "ban" or action == "ban_user":
|
|
271
|
+
return await self._sender.ban_user(**params)
|
|
272
|
+
elif action == "voteskip":
|
|
273
|
+
return await self._sender.voteskip()
|
|
274
|
+
elif action == "assignLeader" or action == "assign_leader":
|
|
275
|
+
return await self._sender.assign_leader(**params)
|
|
276
|
+
elif action == "mute" or action == "mute_user":
|
|
277
|
+
return await self._sender.mute_user(**params)
|
|
278
|
+
elif action == "smute" or action == "shadow_mute" or action == "shadow_mute_user":
|
|
279
|
+
return await self._sender.shadow_mute_user(**params)
|
|
280
|
+
elif action == "unmute" or action == "unmute_user":
|
|
281
|
+
return await self._sender.unmute_user(**params)
|
|
282
|
+
elif action == "playNext" or action == "play_next":
|
|
283
|
+
return await self._sender.play_next()
|
|
284
|
+
|
|
285
|
+
# Phase 2: Admin commands (rank 3+)
|
|
286
|
+
elif action == "setMotd" or action == "set_motd":
|
|
287
|
+
return await self._sender.set_motd(**params)
|
|
288
|
+
elif action == "setChannelCSS" or action == "set_channel_css":
|
|
289
|
+
return await self._sender.set_channel_css(**params)
|
|
290
|
+
elif action == "setChannelJS" or action == "set_channel_js":
|
|
291
|
+
return await self._sender.set_channel_js(**params)
|
|
292
|
+
elif action == "setOptions" or action == "set_options":
|
|
293
|
+
return await self._sender.set_options(**params)
|
|
294
|
+
elif action == "setPermissions" or action == "set_permissions":
|
|
295
|
+
return await self._sender.set_permissions(**params)
|
|
296
|
+
elif action == "updateEmote" or action == "update_emote":
|
|
297
|
+
return await self._sender.update_emote(**params)
|
|
298
|
+
elif action == "removeEmote" or action == "remove_emote":
|
|
299
|
+
return await self._sender.remove_emote(**params)
|
|
300
|
+
elif action == "addFilter" or action == "add_filter":
|
|
301
|
+
return await self._sender.add_filter(**params)
|
|
302
|
+
elif action == "updateFilter" or action == "update_filter":
|
|
303
|
+
return await self._sender.update_filter(**params)
|
|
304
|
+
elif action == "removeFilter" or action == "remove_filter":
|
|
305
|
+
return await self._sender.remove_filter(**params)
|
|
306
|
+
|
|
307
|
+
# Phase 3: Advanced admin commands (rank 2-4+)
|
|
308
|
+
elif action == "newPoll" or action == "new_poll":
|
|
309
|
+
return await self._sender.new_poll(**params)
|
|
310
|
+
elif action == "vote":
|
|
311
|
+
return await self._sender.vote(**params)
|
|
312
|
+
elif action == "closePoll" or action == "close_poll":
|
|
313
|
+
return await self._sender.close_poll()
|
|
314
|
+
elif action == "setChannelRank" or action == "set_channel_rank":
|
|
315
|
+
return await self._sender.set_channel_rank(**params)
|
|
316
|
+
elif action == "requestChannelRanks" or action == "request_channel_ranks":
|
|
317
|
+
return await self._sender.request_channel_ranks()
|
|
318
|
+
elif action == "requestBanlist" or action == "request_banlist":
|
|
319
|
+
return await self._sender.request_banlist()
|
|
320
|
+
elif action == "unban":
|
|
321
|
+
return await self._sender.unban(**params)
|
|
322
|
+
elif action == "readChanLog" or action == "read_chan_log":
|
|
323
|
+
return await self._sender.read_chan_log(**params)
|
|
324
|
+
elif action == "searchLibrary" or action == "search_library":
|
|
325
|
+
return await self._sender.search_library(**params)
|
|
326
|
+
elif action == "deleteFromLibrary" or action == "delete_from_library":
|
|
327
|
+
return await self._sender.delete_from_library(**params)
|
|
328
|
+
|
|
329
|
+
else:
|
|
330
|
+
self._logger.warning(f"Unknown action: {action}")
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
except TypeError as e:
|
|
334
|
+
self._logger.error(f"Invalid parameters for action '{action}': {e}")
|
|
335
|
+
return False
|
|
336
|
+
except Exception as e:
|
|
337
|
+
self._logger.error(f"Error executing action '{action}': {e}", exc_info=True)
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
__all__ = ["CommandSubscriber"]
|