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.
@@ -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"]