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,1476 @@
|
|
|
1
|
+
"""CyTube Event Sender - Send events to CyTube channels.
|
|
2
|
+
|
|
3
|
+
This module provides a high-level interface for sending events to CyTube,
|
|
4
|
+
wrapping the Socket.IO connection with convenient methods for common actions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CytubeEventSender:
|
|
12
|
+
"""Send events to CyTube channel.
|
|
13
|
+
|
|
14
|
+
Wraps CytubeConnector to provide action-oriented methods for sending
|
|
15
|
+
events like chat messages, playlist changes, and moderation actions.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
connector: CytubeConnector instance to use for sending.
|
|
19
|
+
logger: Logger for structured output.
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
>>> sender = CytubeEventSender(connector, logger)
|
|
23
|
+
>>> await sender.send_chat("Hello, world!")
|
|
24
|
+
>>> await sender.add_video("https://youtu.be/dQw4w9WgXcQ")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, connector, logger: logging.Logger, audit_logger=None):
|
|
28
|
+
"""Initialize event sender.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
connector: Connected CytubeConnector instance.
|
|
32
|
+
logger: Logger for structured output.
|
|
33
|
+
audit_logger: Optional AuditLogger for operation tracking.
|
|
34
|
+
"""
|
|
35
|
+
self._connector = connector
|
|
36
|
+
self._logger = logger
|
|
37
|
+
self._audit_logger = audit_logger
|
|
38
|
+
|
|
39
|
+
# ========================================================================
|
|
40
|
+
# Chat Methods
|
|
41
|
+
# ========================================================================
|
|
42
|
+
|
|
43
|
+
async def send_chat(self, message: str, metadata: dict[str, Any] | None = None) -> bool:
|
|
44
|
+
"""Send a public chat message.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
message: Chat message text.
|
|
48
|
+
metadata: Optional metadata (emotes, styling, etc.).
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
True if sent successfully, False otherwise.
|
|
52
|
+
|
|
53
|
+
Examples:
|
|
54
|
+
>>> await sender.send_chat("Hello!")
|
|
55
|
+
>>> await sender.send_chat("Hello", {"emote": "wave"})
|
|
56
|
+
"""
|
|
57
|
+
if not self._connector.is_connected:
|
|
58
|
+
self._logger.error("Cannot send chat: not connected")
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
payload = {"msg": message}
|
|
63
|
+
if metadata:
|
|
64
|
+
payload.update(metadata)
|
|
65
|
+
|
|
66
|
+
self._logger.debug(f"Sending chat: {message}")
|
|
67
|
+
await self._connector._socket.emit("chatMsg", payload)
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
except Exception as e:
|
|
71
|
+
self._logger.error(f"Failed to send chat: {e}", exc_info=True)
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
async def send_pm(self, to: str, message: str) -> bool:
|
|
75
|
+
"""Send a private message to a user.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
to: Target username.
|
|
79
|
+
message: Private message text.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
True if sent successfully, False otherwise.
|
|
83
|
+
|
|
84
|
+
Examples:
|
|
85
|
+
>>> await sender.send_pm("alice", "Secret message")
|
|
86
|
+
"""
|
|
87
|
+
if not self._connector.is_connected:
|
|
88
|
+
self._logger.error("Cannot send PM: not connected")
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
payload = {"to": to, "msg": message}
|
|
93
|
+
|
|
94
|
+
self._logger.debug(f"Sending PM to {to}: {message}")
|
|
95
|
+
await self._connector._socket.emit("pm", payload)
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
except Exception as e:
|
|
99
|
+
self._logger.error(f"Failed to send PM: {e}", exc_info=True)
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
# ========================================================================
|
|
103
|
+
# Playlist Methods
|
|
104
|
+
# ========================================================================
|
|
105
|
+
|
|
106
|
+
def _transform_grindhouse_url(self, url: str) -> tuple[str, str, str]:
|
|
107
|
+
"""Transform 420grindhouse.com view URLs to custom media format.
|
|
108
|
+
|
|
109
|
+
Converts URLs like:
|
|
110
|
+
https://www.420grindhouse.com/view?m=CcrJn8WAa
|
|
111
|
+
To:
|
|
112
|
+
type="cm", id="https://www.420grindhouse.com/api/v1/media/cytube/CcrJn8WAa.json?format=json"
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
url: Original URL to transform.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Tuple of (media_type, media_id, original_url).
|
|
119
|
+
If not a grindhouse URL, returns (None, None, url).
|
|
120
|
+
"""
|
|
121
|
+
import re
|
|
122
|
+
|
|
123
|
+
# Match 420grindhouse.com view URLs with m= parameter
|
|
124
|
+
pattern = r'https?://(?:www\.)?420grindhouse\.com/view\?m=([A-Za-z0-9_-]+)'
|
|
125
|
+
match = re.match(pattern, url)
|
|
126
|
+
|
|
127
|
+
if match:
|
|
128
|
+
media_id = match.group(1)
|
|
129
|
+
json_url = f"https://www.420grindhouse.com/api/v1/media/cytube/{media_id}.json?format=json"
|
|
130
|
+
self._logger.info(f"Transformed grindhouse URL: {url} -> type=cm, id={json_url}")
|
|
131
|
+
return ("cm", json_url, url)
|
|
132
|
+
|
|
133
|
+
return (None, None, url)
|
|
134
|
+
|
|
135
|
+
async def add_video(
|
|
136
|
+
self,
|
|
137
|
+
url: str = None,
|
|
138
|
+
media_type: str = None,
|
|
139
|
+
media_id: str = None,
|
|
140
|
+
position: str = "end",
|
|
141
|
+
temp: bool = False,
|
|
142
|
+
) -> bool:
|
|
143
|
+
"""Add video to playlist.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
url: Video URL (legacy format: "yt:abc123" or full URL).
|
|
147
|
+
media_type: Media type ("yt", "vm", "dm", "cu", etc.).
|
|
148
|
+
media_id: Media ID or URL.
|
|
149
|
+
position: Position to add ("end", "next", or media UID).
|
|
150
|
+
temp: Mark as temporary (removed after playing).
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
True if sent successfully, False otherwise.
|
|
154
|
+
|
|
155
|
+
Examples:
|
|
156
|
+
>>> await sender.add_video(url="https://youtu.be/dQw4w9WgXcQ")
|
|
157
|
+
>>> await sender.add_video(media_type="cu", media_id="https://example.com/video.mp4")
|
|
158
|
+
"""
|
|
159
|
+
if not self._connector.is_connected:
|
|
160
|
+
self._logger.error("Cannot queue video: not connected")
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
# Build payload based on provided parameters
|
|
165
|
+
if media_type is not None and media_id is not None:
|
|
166
|
+
# New format: type + id
|
|
167
|
+
payload = {
|
|
168
|
+
"type": media_type,
|
|
169
|
+
"id": media_id,
|
|
170
|
+
"pos": position,
|
|
171
|
+
"temp": temp,
|
|
172
|
+
}
|
|
173
|
+
elif url is not None:
|
|
174
|
+
# Check if URL needs transformation (420grindhouse.com)
|
|
175
|
+
transformed_type, transformed_id, original_url = self._transform_grindhouse_url(url)
|
|
176
|
+
|
|
177
|
+
self._logger.info(f"URL transformation result: type={transformed_type}, id={transformed_id}, original={original_url}")
|
|
178
|
+
|
|
179
|
+
if transformed_type:
|
|
180
|
+
# Use custom media format for transformed URLs
|
|
181
|
+
payload = {
|
|
182
|
+
"type": transformed_type,
|
|
183
|
+
"id": transformed_id,
|
|
184
|
+
"pos": position,
|
|
185
|
+
"temp": temp,
|
|
186
|
+
}
|
|
187
|
+
else:
|
|
188
|
+
# Legacy format: url (will be parsed by CyTube)
|
|
189
|
+
payload = {
|
|
190
|
+
"id": original_url,
|
|
191
|
+
"pos": position,
|
|
192
|
+
"temp": temp,
|
|
193
|
+
}
|
|
194
|
+
else:
|
|
195
|
+
self._logger.error("Must provide either url or (media_type + media_id)")
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
self._logger.info(f"Queueing video with payload: {payload}")
|
|
199
|
+
await self._connector._socket.emit("queue", payload)
|
|
200
|
+
|
|
201
|
+
# Audit log playlist operation
|
|
202
|
+
if self._audit_logger:
|
|
203
|
+
self._audit_logger.log_playlist_operation(
|
|
204
|
+
operation="queue",
|
|
205
|
+
media_title=url, # URL will be replaced with title when available
|
|
206
|
+
details={"position": position, "temp": temp}
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
except Exception as e:
|
|
212
|
+
self._logger.error(f"Failed to queue video: {e}", exc_info=True)
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
async def delete_video(self, uid: str) -> bool:
|
|
216
|
+
"""Remove video from playlist.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
uid: Unique ID of video to remove.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
True if sent successfully, False otherwise.
|
|
223
|
+
|
|
224
|
+
Examples:
|
|
225
|
+
>>> await sender.delete_video("abc123")
|
|
226
|
+
"""
|
|
227
|
+
if not self._connector.is_connected:
|
|
228
|
+
self._logger.error("Cannot delete video: not connected")
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
# CyTube expects the UID directly as a number, not wrapped in an object
|
|
233
|
+
# See: src/channel/playlist.js handleDelete expects typeof data !== "number"
|
|
234
|
+
uid_num = int(uid) if isinstance(uid, str) else uid
|
|
235
|
+
|
|
236
|
+
self._logger.info(f"Deleting video with UID: {uid_num}")
|
|
237
|
+
await self._connector._socket.emit("delete", uid_num)
|
|
238
|
+
|
|
239
|
+
# Audit log playlist operation
|
|
240
|
+
if self._audit_logger:
|
|
241
|
+
self._audit_logger.log_playlist_operation(
|
|
242
|
+
operation="delete",
|
|
243
|
+
details={"uid": uid}
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
except Exception as e:
|
|
249
|
+
self._logger.error(f"Failed to delete video: {e}", exc_info=True)
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
async def move_video(self, uid: str, after: str) -> bool:
|
|
253
|
+
"""Reorder playlist by moving video.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
uid: UID of video to move.
|
|
257
|
+
after: UID of video to place after (or "prepend"/"append").
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
True if sent successfully, False otherwise.
|
|
261
|
+
|
|
262
|
+
Examples:
|
|
263
|
+
>>> await sender.move_video("abc123", "xyz789")
|
|
264
|
+
>>> await sender.move_video("abc123", "prepend")
|
|
265
|
+
"""
|
|
266
|
+
if not self._connector.is_connected:
|
|
267
|
+
self._logger.error("Cannot move video: not connected")
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
# CyTube TYPE_MOVE_MEDIA: {from: "number", after: "string,number"}
|
|
272
|
+
# Convert 'from' to integer, keep 'after' as-is (can be string like "prepend" or number)
|
|
273
|
+
from_uid = int(uid) if isinstance(uid, str) and uid.isdigit() else uid
|
|
274
|
+
after_val = int(after) if isinstance(after, str) and after.isdigit() else after
|
|
275
|
+
|
|
276
|
+
payload = {"from": from_uid, "after": after_val}
|
|
277
|
+
|
|
278
|
+
self._logger.debug(f"Moving video {from_uid} after {after_val}")
|
|
279
|
+
await self._connector._socket.emit("moveMedia", payload)
|
|
280
|
+
|
|
281
|
+
# Audit log playlist operation
|
|
282
|
+
if self._audit_logger:
|
|
283
|
+
self._audit_logger.log_playlist_operation(
|
|
284
|
+
operation="moveMedia",
|
|
285
|
+
details={"uid": uid, "after": after}
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return True
|
|
289
|
+
|
|
290
|
+
except Exception as e:
|
|
291
|
+
self._logger.error(f"Failed to move video: {e}", exc_info=True)
|
|
292
|
+
return False
|
|
293
|
+
|
|
294
|
+
async def jump_to(self, uid: str) -> bool:
|
|
295
|
+
"""Jump to specific video in playlist.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
uid: UID of video to jump to.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
True if sent successfully, False otherwise.
|
|
302
|
+
|
|
303
|
+
Examples:
|
|
304
|
+
>>> await sender.jump_to("abc123")
|
|
305
|
+
"""
|
|
306
|
+
if not self._connector.is_connected:
|
|
307
|
+
self._logger.error("Cannot jump to video: not connected")
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
# CyTube expects the UID directly (string or number), not wrapped in an object
|
|
312
|
+
# See: src/channel/playlist.js handleJumpTo checks typeof data !== "string" && typeof data !== "number"
|
|
313
|
+
# Client code: socket.emit("jumpTo", li.data("uid"))
|
|
314
|
+
uid_val = int(uid) if uid.isdigit() else uid
|
|
315
|
+
|
|
316
|
+
self._logger.debug(f"Jumping to video: {uid_val}")
|
|
317
|
+
await self._connector._socket.emit("jumpTo", uid_val)
|
|
318
|
+
|
|
319
|
+
# Audit log playlist operation
|
|
320
|
+
if self._audit_logger:
|
|
321
|
+
self._audit_logger.log_playlist_operation(
|
|
322
|
+
operation="jumpTo",
|
|
323
|
+
details={"uid": uid}
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return True
|
|
327
|
+
|
|
328
|
+
except Exception as e:
|
|
329
|
+
self._logger.error(f"Failed to jump to video: {e}", exc_info=True)
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
async def clear_playlist(self) -> bool:
|
|
333
|
+
"""Clear entire playlist.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
True if sent successfully, False otherwise.
|
|
337
|
+
|
|
338
|
+
Examples:
|
|
339
|
+
>>> await sender.clear_playlist()
|
|
340
|
+
"""
|
|
341
|
+
if not self._connector.is_connected:
|
|
342
|
+
self._logger.error("Cannot clear playlist: not connected")
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
self._logger.debug("Clearing playlist")
|
|
347
|
+
await self._connector._socket.emit("clearPlaylist", {})
|
|
348
|
+
|
|
349
|
+
# Audit log playlist operation
|
|
350
|
+
if self._audit_logger:
|
|
351
|
+
self._audit_logger.log_playlist_operation(
|
|
352
|
+
operation="clearPlaylist"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
return True
|
|
356
|
+
|
|
357
|
+
except Exception as e:
|
|
358
|
+
self._logger.error(f"Failed to clear playlist: {e}", exc_info=True)
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
async def shuffle_playlist(self) -> bool:
|
|
362
|
+
"""Shuffle playlist order.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
True if sent successfully, False otherwise.
|
|
366
|
+
|
|
367
|
+
Examples:
|
|
368
|
+
>>> await sender.shuffle_playlist()
|
|
369
|
+
"""
|
|
370
|
+
if not self._connector.is_connected:
|
|
371
|
+
self._logger.error("Cannot shuffle playlist: not connected")
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
self._logger.debug("Shuffling playlist")
|
|
376
|
+
await self._connector._socket.emit("shufflePlaylist", {})
|
|
377
|
+
|
|
378
|
+
# Audit log playlist operation
|
|
379
|
+
if self._audit_logger:
|
|
380
|
+
self._audit_logger.log_playlist_operation(
|
|
381
|
+
operation="shufflePlaylist"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
return True
|
|
385
|
+
|
|
386
|
+
except Exception as e:
|
|
387
|
+
self._logger.error(f"Failed to shuffle playlist: {e}", exc_info=True)
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
async def set_temp(self, uid: str, temp: bool = True) -> bool:
|
|
391
|
+
"""Mark video as temporary.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
uid: UID of video.
|
|
395
|
+
temp: True for temporary, False for permanent.
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
True if sent successfully, False otherwise.
|
|
399
|
+
|
|
400
|
+
Examples:
|
|
401
|
+
>>> await sender.set_temp("abc123", True)
|
|
402
|
+
"""
|
|
403
|
+
if not self._connector.is_connected:
|
|
404
|
+
self._logger.error("Cannot set temp status: not connected")
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
# CyTube TYPE_SET_TEMP: {uid: "number", temp: "boolean"}
|
|
409
|
+
uid_num = int(uid) if isinstance(uid, str) and uid.isdigit() else uid
|
|
410
|
+
payload = {"uid": uid_num, "temp": temp}
|
|
411
|
+
|
|
412
|
+
self._logger.debug(f"Setting temp={temp} for video: {uid_num}")
|
|
413
|
+
await self._connector._socket.emit("setTemp", payload)
|
|
414
|
+
|
|
415
|
+
# Audit log playlist operation
|
|
416
|
+
if self._audit_logger:
|
|
417
|
+
self._audit_logger.log_playlist_operation(
|
|
418
|
+
operation="setTemp",
|
|
419
|
+
details={"uid": uid, "temp": temp}
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
return True
|
|
423
|
+
|
|
424
|
+
except Exception as e:
|
|
425
|
+
self._logger.error(f"Failed to set temp status: {e}", exc_info=True)
|
|
426
|
+
return False
|
|
427
|
+
|
|
428
|
+
# ========================================================================
|
|
429
|
+
# Playback Control Methods
|
|
430
|
+
# ========================================================================
|
|
431
|
+
|
|
432
|
+
async def pause(self) -> bool:
|
|
433
|
+
"""Pause current video.
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
True if sent successfully, False otherwise.
|
|
437
|
+
|
|
438
|
+
Examples:
|
|
439
|
+
>>> await sender.pause()
|
|
440
|
+
"""
|
|
441
|
+
if not self._connector.is_connected:
|
|
442
|
+
self._logger.error("Cannot pause: not connected")
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
self._logger.debug("Pausing playback")
|
|
447
|
+
await self._connector._socket.emit("pause", {})
|
|
448
|
+
return True
|
|
449
|
+
|
|
450
|
+
except Exception as e:
|
|
451
|
+
self._logger.error(f"Failed to pause: {e}", exc_info=True)
|
|
452
|
+
return False
|
|
453
|
+
|
|
454
|
+
async def play(self) -> bool:
|
|
455
|
+
"""Resume playback.
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
True if sent successfully, False otherwise.
|
|
459
|
+
|
|
460
|
+
Examples:
|
|
461
|
+
>>> await sender.play()
|
|
462
|
+
"""
|
|
463
|
+
if not self._connector.is_connected:
|
|
464
|
+
self._logger.error("Cannot play: not connected")
|
|
465
|
+
return False
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
self._logger.debug("Resuming playback")
|
|
469
|
+
await self._connector._socket.emit("play", {})
|
|
470
|
+
return True
|
|
471
|
+
|
|
472
|
+
except Exception as e:
|
|
473
|
+
self._logger.error(f"Failed to play: {e}", exc_info=True)
|
|
474
|
+
return False
|
|
475
|
+
|
|
476
|
+
async def seek_to(self, time: float) -> bool:
|
|
477
|
+
"""Seek to timestamp.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
time: Target time in seconds.
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
True if sent successfully, False otherwise.
|
|
484
|
+
|
|
485
|
+
Examples:
|
|
486
|
+
>>> await sender.seek_to(120.5) # Seek to 2:00.5
|
|
487
|
+
"""
|
|
488
|
+
if not self._connector.is_connected:
|
|
489
|
+
self._logger.error("Cannot seek: not connected")
|
|
490
|
+
return False
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
payload = {"time": time}
|
|
494
|
+
|
|
495
|
+
self._logger.debug(f"Seeking to {time}s")
|
|
496
|
+
await self._connector._socket.emit("seekTo", payload)
|
|
497
|
+
return True
|
|
498
|
+
|
|
499
|
+
except Exception as e:
|
|
500
|
+
self._logger.error(f"Failed to seek: {e}", exc_info=True)
|
|
501
|
+
return False
|
|
502
|
+
|
|
503
|
+
# ========================================================================
|
|
504
|
+
# Moderation Methods
|
|
505
|
+
# ========================================================================
|
|
506
|
+
|
|
507
|
+
async def kick_user(self, username: str, reason: str | None = None) -> bool:
|
|
508
|
+
"""Kick user from channel.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
username: Username to kick.
|
|
512
|
+
reason: Optional kick reason.
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
True if sent successfully, False otherwise.
|
|
516
|
+
|
|
517
|
+
Examples:
|
|
518
|
+
>>> await sender.kick_user("spammer")
|
|
519
|
+
>>> await sender.kick_user("spammer", "Excessive spam")
|
|
520
|
+
"""
|
|
521
|
+
if not self._connector.is_connected:
|
|
522
|
+
self._logger.error("Cannot kick user: not connected")
|
|
523
|
+
return False
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
payload = {"name": username}
|
|
527
|
+
if reason:
|
|
528
|
+
payload["reason"] = reason
|
|
529
|
+
|
|
530
|
+
self._logger.debug(f"Kicking user: {username}")
|
|
531
|
+
await self._connector._socket.emit("kick", payload)
|
|
532
|
+
return True
|
|
533
|
+
|
|
534
|
+
except Exception as e:
|
|
535
|
+
self._logger.error(f"Failed to kick user: {e}", exc_info=True)
|
|
536
|
+
return False
|
|
537
|
+
|
|
538
|
+
async def ban_user(self, username: str, reason: str | None = None) -> bool:
|
|
539
|
+
"""Ban user from channel.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
username: Username to ban.
|
|
543
|
+
reason: Optional ban reason.
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
True if sent successfully, False otherwise.
|
|
547
|
+
|
|
548
|
+
Examples:
|
|
549
|
+
>>> await sender.ban_user("troll")
|
|
550
|
+
>>> await sender.ban_user("troll", "Harassment")
|
|
551
|
+
"""
|
|
552
|
+
if not self._connector.is_connected:
|
|
553
|
+
self._logger.error("Cannot ban user: not connected")
|
|
554
|
+
return False
|
|
555
|
+
|
|
556
|
+
try:
|
|
557
|
+
payload = {"name": username}
|
|
558
|
+
if reason:
|
|
559
|
+
payload["reason"] = reason
|
|
560
|
+
|
|
561
|
+
self._logger.debug(f"Banning user: {username}")
|
|
562
|
+
await self._connector._socket.emit("ban", payload)
|
|
563
|
+
|
|
564
|
+
# Audit log admin operation
|
|
565
|
+
if self._audit_logger:
|
|
566
|
+
self._audit_logger.log_admin_operation(
|
|
567
|
+
operation="ban",
|
|
568
|
+
target=username,
|
|
569
|
+
details={"reason": reason} if reason else {}
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
return True
|
|
573
|
+
|
|
574
|
+
except Exception as e:
|
|
575
|
+
self._logger.error(f"Failed to ban user: {e}", exc_info=True)
|
|
576
|
+
return False
|
|
577
|
+
|
|
578
|
+
async def voteskip(self) -> bool:
|
|
579
|
+
"""Vote to skip current media.
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
True if sent successfully, False otherwise.
|
|
583
|
+
|
|
584
|
+
Examples:
|
|
585
|
+
>>> await sender.voteskip()
|
|
586
|
+
"""
|
|
587
|
+
if not self._connector.is_connected:
|
|
588
|
+
self._logger.error("Cannot voteskip: not connected")
|
|
589
|
+
return False
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
self._logger.debug("Voting to skip")
|
|
593
|
+
await self._connector._socket.emit("voteskip", {})
|
|
594
|
+
return True
|
|
595
|
+
|
|
596
|
+
except Exception as e:
|
|
597
|
+
self._logger.error(f"Failed to voteskip: {e}", exc_info=True)
|
|
598
|
+
return False
|
|
599
|
+
|
|
600
|
+
async def assign_leader(self, username: str) -> bool:
|
|
601
|
+
"""Assign or remove leader status.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
username: Username to give leader, or empty string to remove leader.
|
|
605
|
+
|
|
606
|
+
Returns:
|
|
607
|
+
True if sent successfully, False otherwise.
|
|
608
|
+
|
|
609
|
+
Examples:
|
|
610
|
+
>>> await sender.assign_leader("alice") # Give leader
|
|
611
|
+
>>> await sender.assign_leader("") # Remove leader
|
|
612
|
+
"""
|
|
613
|
+
if not self._connector.is_connected:
|
|
614
|
+
self._logger.error("Cannot assign leader: not connected")
|
|
615
|
+
return False
|
|
616
|
+
|
|
617
|
+
try:
|
|
618
|
+
payload = {"name": username}
|
|
619
|
+
|
|
620
|
+
action = "Assigning" if username else "Removing"
|
|
621
|
+
self._logger.debug(f"{action} leader: {username or '(none)'}")
|
|
622
|
+
await self._connector._socket.emit("assignLeader", payload)
|
|
623
|
+
return True
|
|
624
|
+
|
|
625
|
+
except Exception as e:
|
|
626
|
+
self._logger.error(f"Failed to assign leader: {e}", exc_info=True)
|
|
627
|
+
return False
|
|
628
|
+
|
|
629
|
+
async def mute_user(self, username: str) -> bool:
|
|
630
|
+
"""Mute user (prevents them from chatting).
|
|
631
|
+
|
|
632
|
+
This is a direct Socket.IO event, not a chat command.
|
|
633
|
+
The user will see a notification that they've been muted.
|
|
634
|
+
|
|
635
|
+
Args:
|
|
636
|
+
username: Username to mute.
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
True if sent successfully, False otherwise.
|
|
640
|
+
|
|
641
|
+
Examples:
|
|
642
|
+
>>> await sender.mute_user("spammer")
|
|
643
|
+
"""
|
|
644
|
+
if not self._connector.is_connected:
|
|
645
|
+
self._logger.error("Cannot mute user: not connected")
|
|
646
|
+
return False
|
|
647
|
+
|
|
648
|
+
try:
|
|
649
|
+
# Mute is handled via chat command on the server side
|
|
650
|
+
# Send as chat message with /mute command
|
|
651
|
+
payload = {"msg": f"/mute {username}", "meta": {}}
|
|
652
|
+
|
|
653
|
+
self._logger.debug(f"Muting user: {username}")
|
|
654
|
+
await self._connector._socket.emit("chatMsg", payload)
|
|
655
|
+
return True
|
|
656
|
+
|
|
657
|
+
except Exception as e:
|
|
658
|
+
self._logger.error(f"Failed to mute user: {e}", exc_info=True)
|
|
659
|
+
return False
|
|
660
|
+
|
|
661
|
+
async def shadow_mute_user(self, username: str) -> bool:
|
|
662
|
+
"""Shadow mute user (they can chat but only they and mods see it).
|
|
663
|
+
|
|
664
|
+
Shadow muted users don't know they're muted - their messages
|
|
665
|
+
appear normal to them but are only visible to moderators.
|
|
666
|
+
|
|
667
|
+
Args:
|
|
668
|
+
username: Username to shadow mute.
|
|
669
|
+
|
|
670
|
+
Returns:
|
|
671
|
+
True if sent successfully, False otherwise.
|
|
672
|
+
|
|
673
|
+
Examples:
|
|
674
|
+
>>> await sender.shadow_mute_user("subtle_troll")
|
|
675
|
+
"""
|
|
676
|
+
if not self._connector.is_connected:
|
|
677
|
+
self._logger.error("Cannot shadow mute user: not connected")
|
|
678
|
+
return False
|
|
679
|
+
|
|
680
|
+
try:
|
|
681
|
+
# Shadow mute is handled via chat command on the server side
|
|
682
|
+
payload = {"msg": f"/smute {username}", "meta": {}}
|
|
683
|
+
|
|
684
|
+
self._logger.debug(f"Shadow muting user: {username}")
|
|
685
|
+
await self._connector._socket.emit("chatMsg", payload)
|
|
686
|
+
return True
|
|
687
|
+
|
|
688
|
+
except Exception as e:
|
|
689
|
+
self._logger.error(f"Failed to shadow mute user: {e}", exc_info=True)
|
|
690
|
+
return False
|
|
691
|
+
|
|
692
|
+
async def unmute_user(self, username: str) -> bool:
|
|
693
|
+
"""Unmute user (removes both regular and shadow mute).
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
username: Username to unmute.
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
True if sent successfully, False otherwise.
|
|
700
|
+
|
|
701
|
+
Examples:
|
|
702
|
+
>>> await sender.unmute_user("reformed_user")
|
|
703
|
+
"""
|
|
704
|
+
if not self._connector.is_connected:
|
|
705
|
+
self._logger.error("Cannot unmute user: not connected")
|
|
706
|
+
return False
|
|
707
|
+
|
|
708
|
+
try:
|
|
709
|
+
# Unmute is handled via chat command on the server side
|
|
710
|
+
payload = {"msg": f"/unmute {username}", "meta": {}}
|
|
711
|
+
|
|
712
|
+
self._logger.debug(f"Unmuting user: {username}")
|
|
713
|
+
await self._connector._socket.emit("chatMsg", payload)
|
|
714
|
+
return True
|
|
715
|
+
|
|
716
|
+
except Exception as e:
|
|
717
|
+
self._logger.error(f"Failed to unmute user: {e}", exc_info=True)
|
|
718
|
+
return False
|
|
719
|
+
|
|
720
|
+
async def play_next(self) -> bool:
|
|
721
|
+
"""Skip to next video in playlist.
|
|
722
|
+
|
|
723
|
+
Unlike voteskip, this immediately skips without voting.
|
|
724
|
+
Requires appropriate permissions.
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
True if sent successfully, False otherwise.
|
|
728
|
+
|
|
729
|
+
Examples:
|
|
730
|
+
>>> await sender.play_next()
|
|
731
|
+
"""
|
|
732
|
+
if not self._connector.is_connected:
|
|
733
|
+
self._logger.error("Cannot play next: not connected")
|
|
734
|
+
return False
|
|
735
|
+
|
|
736
|
+
try:
|
|
737
|
+
self._logger.debug("Playing next video")
|
|
738
|
+
await self._connector._socket.emit("playNext", {})
|
|
739
|
+
return True
|
|
740
|
+
|
|
741
|
+
except Exception as e:
|
|
742
|
+
self._logger.error(f"Failed to play next: {e}", exc_info=True)
|
|
743
|
+
return False
|
|
744
|
+
|
|
745
|
+
# PHASE 2: Admin Functions (Rank 3+)
|
|
746
|
+
|
|
747
|
+
async def set_motd(self, motd: str) -> bool:
|
|
748
|
+
"""
|
|
749
|
+
Set channel message of the day (MOTD).
|
|
750
|
+
Requires rank 3+ (admin).
|
|
751
|
+
|
|
752
|
+
Args:
|
|
753
|
+
motd: Message of the day HTML content
|
|
754
|
+
|
|
755
|
+
Returns:
|
|
756
|
+
True if successful, False otherwise
|
|
757
|
+
"""
|
|
758
|
+
if not self._connector.is_connected:
|
|
759
|
+
self._logger.error("Cannot set MOTD: not connected")
|
|
760
|
+
return False
|
|
761
|
+
|
|
762
|
+
try:
|
|
763
|
+
payload = {"motd": motd}
|
|
764
|
+
self._logger.debug(f"Setting MOTD: {len(motd)} chars")
|
|
765
|
+
await self._connector._socket.emit("setMotd", payload)
|
|
766
|
+
|
|
767
|
+
# Audit log admin operation
|
|
768
|
+
if self._audit_logger:
|
|
769
|
+
self._audit_logger.log_admin_operation(
|
|
770
|
+
operation="setMotd",
|
|
771
|
+
details={"length": len(motd)}
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
return True
|
|
775
|
+
|
|
776
|
+
except Exception as e:
|
|
777
|
+
self._logger.error(f"Failed to set MOTD: {e}", exc_info=True)
|
|
778
|
+
return False
|
|
779
|
+
|
|
780
|
+
async def set_channel_css(self, css: str) -> bool:
|
|
781
|
+
"""
|
|
782
|
+
Set channel custom CSS.
|
|
783
|
+
Requires rank 3+ (admin).
|
|
784
|
+
CyTube has a 20KB limit on CSS content.
|
|
785
|
+
|
|
786
|
+
Args:
|
|
787
|
+
css: CSS content (max ~20KB)
|
|
788
|
+
|
|
789
|
+
Returns:
|
|
790
|
+
True if successful, False otherwise
|
|
791
|
+
"""
|
|
792
|
+
if not self._connector.is_connected:
|
|
793
|
+
self._logger.error("Cannot set CSS: not connected")
|
|
794
|
+
return False
|
|
795
|
+
|
|
796
|
+
try:
|
|
797
|
+
# Check size (20KB = 20480 bytes)
|
|
798
|
+
css_bytes = len(css.encode('utf-8'))
|
|
799
|
+
if css_bytes > 20480:
|
|
800
|
+
self._logger.warning(f"CSS size {css_bytes} bytes exceeds 20KB limit, may be rejected")
|
|
801
|
+
|
|
802
|
+
payload = {"css": css}
|
|
803
|
+
self._logger.debug(f"Setting channel CSS: {css_bytes} bytes")
|
|
804
|
+
await self._connector._socket.emit("setChannelCSS", payload)
|
|
805
|
+
|
|
806
|
+
# Audit log admin operation
|
|
807
|
+
if self._audit_logger:
|
|
808
|
+
self._audit_logger.log_admin_operation(
|
|
809
|
+
operation="setChannelCSS",
|
|
810
|
+
details={"size_bytes": css_bytes}
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
return True
|
|
814
|
+
|
|
815
|
+
except Exception as e:
|
|
816
|
+
self._logger.error(f"Failed to set channel CSS: {e}", exc_info=True)
|
|
817
|
+
return False
|
|
818
|
+
|
|
819
|
+
async def set_channel_js(self, js: str) -> bool:
|
|
820
|
+
"""
|
|
821
|
+
Set channel custom JavaScript.
|
|
822
|
+
Requires rank 3+ (admin).
|
|
823
|
+
CyTube has a 20KB limit on JS content.
|
|
824
|
+
|
|
825
|
+
Args:
|
|
826
|
+
js: JavaScript content (max ~20KB)
|
|
827
|
+
|
|
828
|
+
Returns:
|
|
829
|
+
True if successful, False otherwise
|
|
830
|
+
"""
|
|
831
|
+
if not self._connector.is_connected:
|
|
832
|
+
self._logger.error("Cannot set JS: not connected")
|
|
833
|
+
return False
|
|
834
|
+
|
|
835
|
+
try:
|
|
836
|
+
# Check size (20KB = 20480 bytes)
|
|
837
|
+
js_bytes = len(js.encode('utf-8'))
|
|
838
|
+
if js_bytes > 20480:
|
|
839
|
+
self._logger.warning(f"JS size {js_bytes} bytes exceeds 20KB limit, may be rejected")
|
|
840
|
+
|
|
841
|
+
payload = {"js": js}
|
|
842
|
+
self._logger.debug(f"Setting channel JS: {js_bytes} bytes")
|
|
843
|
+
await self._connector._socket.emit("setChannelJS", payload)
|
|
844
|
+
|
|
845
|
+
# Audit log admin operation
|
|
846
|
+
if self._audit_logger:
|
|
847
|
+
self._audit_logger.log_admin_operation(
|
|
848
|
+
operation="setChannelJS",
|
|
849
|
+
details={"size_bytes": js_bytes}
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
return True
|
|
853
|
+
|
|
854
|
+
except Exception as e:
|
|
855
|
+
self._logger.error(f"Failed to set channel JS: {e}", exc_info=True)
|
|
856
|
+
return False
|
|
857
|
+
|
|
858
|
+
async def set_options(self, options: dict[str, Any]) -> bool:
|
|
859
|
+
"""
|
|
860
|
+
Update channel options.
|
|
861
|
+
Requires rank 3+ (admin).
|
|
862
|
+
|
|
863
|
+
Common options include:
|
|
864
|
+
- allow_voteskip: bool - Enable voteskip
|
|
865
|
+
- voteskip_ratio: float - Ratio needed to skip (0.0-1.0)
|
|
866
|
+
- afk_timeout: int - AFK timeout in seconds
|
|
867
|
+
- pagetitle: str - Channel page title
|
|
868
|
+
- maxlength: int - Max video length in seconds (0 = unlimited)
|
|
869
|
+
- externalcss: str - External CSS URL
|
|
870
|
+
- externaljs: str - External JS URL
|
|
871
|
+
- chat_antiflood: bool - Enable chat antiflood
|
|
872
|
+
- chat_antiflood_params: dict - Antiflood parameters
|
|
873
|
+
- burst: int - Max messages in burst
|
|
874
|
+
- sustained: int - Max sustained rate
|
|
875
|
+
- cooldown: int - Cooldown in seconds
|
|
876
|
+
- show_public: bool - Show in public channel list
|
|
877
|
+
- enable_link_regex: bool - Enable link filtering
|
|
878
|
+
- password: str - Channel password (empty = no password)
|
|
879
|
+
|
|
880
|
+
Args:
|
|
881
|
+
options: Dictionary of option key-value pairs
|
|
882
|
+
|
|
883
|
+
Returns:
|
|
884
|
+
True if successful, False otherwise
|
|
885
|
+
"""
|
|
886
|
+
if not self._connector.is_connected:
|
|
887
|
+
self._logger.error("Cannot set options: not connected")
|
|
888
|
+
return False
|
|
889
|
+
|
|
890
|
+
try:
|
|
891
|
+
self._logger.debug(f"Setting channel options: {list(options.keys())}")
|
|
892
|
+
await self._connector._socket.emit("setOptions", options)
|
|
893
|
+
|
|
894
|
+
# Audit log admin operation
|
|
895
|
+
if self._audit_logger:
|
|
896
|
+
self._audit_logger.log_admin_operation(
|
|
897
|
+
operation="setOptions",
|
|
898
|
+
details={"option_count": len(options)}
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
return True
|
|
902
|
+
|
|
903
|
+
except Exception as e:
|
|
904
|
+
self._logger.error(f"Failed to set options: {e}", exc_info=True)
|
|
905
|
+
return False
|
|
906
|
+
|
|
907
|
+
async def set_permissions(self, permissions: dict[str, int]) -> bool:
|
|
908
|
+
"""
|
|
909
|
+
Update channel permissions.
|
|
910
|
+
Requires rank 3+ (admin).
|
|
911
|
+
|
|
912
|
+
Permissions map actions to minimum rank required.
|
|
913
|
+
Common permission keys:
|
|
914
|
+
- seeplaylist: View playlist
|
|
915
|
+
- playlistadd: Add videos to playlist
|
|
916
|
+
- playlistnext: Add videos to play next
|
|
917
|
+
- playlistmove: Move videos in playlist
|
|
918
|
+
- playlistdelete: Delete videos from playlist
|
|
919
|
+
- playlistjump: Jump to video in playlist
|
|
920
|
+
- playlistshuffle: Shuffle playlist
|
|
921
|
+
- playlistclear: Clear playlist
|
|
922
|
+
- pollctl: Control polls
|
|
923
|
+
- pollvote: Vote in polls
|
|
924
|
+
- viewhiddenpoll: View hidden poll results
|
|
925
|
+
- voteskip: Vote to skip
|
|
926
|
+
- playlistaddlist: Add multiple videos
|
|
927
|
+
- oekaki: Use drawing feature
|
|
928
|
+
- shout: Use shout feature
|
|
929
|
+
- kick: Kick users
|
|
930
|
+
- ban: Ban users
|
|
931
|
+
- mute: Mute users
|
|
932
|
+
- settemp: Set temporary rank
|
|
933
|
+
- filteradd: Add chat filters
|
|
934
|
+
- filteredit: Edit chat filters
|
|
935
|
+
- filterdelete: Delete chat filters
|
|
936
|
+
- emoteupdaute: Update emotes
|
|
937
|
+
- emotedelete: Delete emotes
|
|
938
|
+
- exceedmaxlength: Add videos exceeding max length
|
|
939
|
+
- addnontemp: Add non-temporary media
|
|
940
|
+
|
|
941
|
+
Args:
|
|
942
|
+
permissions: Dictionary mapping permission names to rank levels
|
|
943
|
+
|
|
944
|
+
Returns:
|
|
945
|
+
True if successful, False otherwise
|
|
946
|
+
"""
|
|
947
|
+
if not self._connector.is_connected:
|
|
948
|
+
self._logger.error("Cannot set permissions: not connected")
|
|
949
|
+
return False
|
|
950
|
+
|
|
951
|
+
try:
|
|
952
|
+
self._logger.debug(f"Setting permissions: {list(permissions.keys())}")
|
|
953
|
+
await self._connector._socket.emit("setPermissions", permissions)
|
|
954
|
+
|
|
955
|
+
# Audit log admin operation
|
|
956
|
+
if self._audit_logger:
|
|
957
|
+
self._audit_logger.log_admin_operation(
|
|
958
|
+
operation="setPermissions",
|
|
959
|
+
details={"permission_count": len(permissions)}
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
return True
|
|
963
|
+
|
|
964
|
+
except Exception as e:
|
|
965
|
+
self._logger.error(f"Failed to set permissions: {e}", exc_info=True)
|
|
966
|
+
return False
|
|
967
|
+
|
|
968
|
+
async def update_emote(self, name: str, image: str, source: str = "imgur") -> bool:
|
|
969
|
+
"""
|
|
970
|
+
Add or update a channel emote.
|
|
971
|
+
Requires rank 3+ (admin).
|
|
972
|
+
|
|
973
|
+
Args:
|
|
974
|
+
name: Emote name (without colons, e.g. "Kappa")
|
|
975
|
+
image: Image URL or ID (depends on source)
|
|
976
|
+
source: Image source ("imgur", "url", etc.)
|
|
977
|
+
|
|
978
|
+
Returns:
|
|
979
|
+
True if successful, False otherwise
|
|
980
|
+
"""
|
|
981
|
+
if not self._connector.is_connected:
|
|
982
|
+
self._logger.error("Cannot update emote: not connected")
|
|
983
|
+
return False
|
|
984
|
+
|
|
985
|
+
try:
|
|
986
|
+
payload = {
|
|
987
|
+
"name": name,
|
|
988
|
+
"image": image,
|
|
989
|
+
"source": source
|
|
990
|
+
}
|
|
991
|
+
self._logger.debug(f"Updating emote: {name} from {source}")
|
|
992
|
+
await self._connector._socket.emit("updateEmote", payload)
|
|
993
|
+
|
|
994
|
+
# Audit log admin operation
|
|
995
|
+
if self._audit_logger:
|
|
996
|
+
self._audit_logger.log_admin_operation(
|
|
997
|
+
operation="updateEmote",
|
|
998
|
+
target=name,
|
|
999
|
+
details={"image": image, "source": source}
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
return True
|
|
1003
|
+
|
|
1004
|
+
except Exception as e:
|
|
1005
|
+
self._logger.error(f"Failed to update emote: {e}", exc_info=True)
|
|
1006
|
+
return False
|
|
1007
|
+
|
|
1008
|
+
async def remove_emote(self, name: str) -> bool:
|
|
1009
|
+
"""
|
|
1010
|
+
Remove a channel emote.
|
|
1011
|
+
Requires rank 3+ (admin).
|
|
1012
|
+
|
|
1013
|
+
Args:
|
|
1014
|
+
name: Emote name to remove
|
|
1015
|
+
|
|
1016
|
+
Returns:
|
|
1017
|
+
True if successful, False otherwise
|
|
1018
|
+
"""
|
|
1019
|
+
if not self._connector.is_connected:
|
|
1020
|
+
self._logger.error("Cannot remove emote: not connected")
|
|
1021
|
+
return False
|
|
1022
|
+
|
|
1023
|
+
try:
|
|
1024
|
+
payload = {"name": name}
|
|
1025
|
+
self._logger.debug(f"Removing emote: {name}")
|
|
1026
|
+
await self._connector._socket.emit("removeEmote", payload)
|
|
1027
|
+
|
|
1028
|
+
# Audit log admin operation
|
|
1029
|
+
if self._audit_logger:
|
|
1030
|
+
self._audit_logger.log_admin_operation(
|
|
1031
|
+
operation="removeEmote",
|
|
1032
|
+
target=name
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
return True
|
|
1036
|
+
|
|
1037
|
+
except Exception as e:
|
|
1038
|
+
self._logger.error(f"Failed to remove emote: {e}", exc_info=True)
|
|
1039
|
+
return False
|
|
1040
|
+
|
|
1041
|
+
async def add_filter(
|
|
1042
|
+
self,
|
|
1043
|
+
name: str,
|
|
1044
|
+
source: str,
|
|
1045
|
+
flags: str,
|
|
1046
|
+
replace: str,
|
|
1047
|
+
filterlinks: bool = False,
|
|
1048
|
+
active: bool = True
|
|
1049
|
+
) -> bool:
|
|
1050
|
+
"""
|
|
1051
|
+
Add a chat filter.
|
|
1052
|
+
Requires rank 3+ (admin).
|
|
1053
|
+
|
|
1054
|
+
Args:
|
|
1055
|
+
name: Filter name
|
|
1056
|
+
source: Regex pattern to match
|
|
1057
|
+
flags: Regex flags (e.g., "gi" for global case-insensitive)
|
|
1058
|
+
replace: Replacement text
|
|
1059
|
+
filterlinks: Whether to filter links
|
|
1060
|
+
active: Whether filter is active
|
|
1061
|
+
|
|
1062
|
+
Returns:
|
|
1063
|
+
True if successful, False otherwise
|
|
1064
|
+
"""
|
|
1065
|
+
if not self._connector.is_connected:
|
|
1066
|
+
self._logger.error("Cannot add filter: not connected")
|
|
1067
|
+
return False
|
|
1068
|
+
|
|
1069
|
+
try:
|
|
1070
|
+
payload = {
|
|
1071
|
+
"name": name,
|
|
1072
|
+
"source": source,
|
|
1073
|
+
"flags": flags,
|
|
1074
|
+
"replace": replace,
|
|
1075
|
+
"filterlinks": filterlinks,
|
|
1076
|
+
"active": active
|
|
1077
|
+
}
|
|
1078
|
+
self._logger.debug(f"Adding chat filter: {name}")
|
|
1079
|
+
await self._connector._socket.emit("addFilter", payload)
|
|
1080
|
+
|
|
1081
|
+
# Audit log admin operation
|
|
1082
|
+
if self._audit_logger:
|
|
1083
|
+
self._audit_logger.log_admin_operation(
|
|
1084
|
+
operation="addFilter",
|
|
1085
|
+
target=name,
|
|
1086
|
+
details={"source": source, "flags": flags, "replace": replace}
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
return True
|
|
1090
|
+
|
|
1091
|
+
except Exception as e:
|
|
1092
|
+
self._logger.error(f"Failed to add filter: {e}", exc_info=True)
|
|
1093
|
+
return False
|
|
1094
|
+
|
|
1095
|
+
async def update_filter(
|
|
1096
|
+
self,
|
|
1097
|
+
name: str,
|
|
1098
|
+
source: str,
|
|
1099
|
+
flags: str,
|
|
1100
|
+
replace: str,
|
|
1101
|
+
filterlinks: bool = False,
|
|
1102
|
+
active: bool = True
|
|
1103
|
+
) -> bool:
|
|
1104
|
+
"""
|
|
1105
|
+
Update an existing chat filter.
|
|
1106
|
+
Requires rank 3+ (admin).
|
|
1107
|
+
|
|
1108
|
+
Args:
|
|
1109
|
+
name: Filter name
|
|
1110
|
+
source: Regex pattern to match
|
|
1111
|
+
flags: Regex flags (e.g., "gi" for global case-insensitive)
|
|
1112
|
+
replace: Replacement text
|
|
1113
|
+
filterlinks: Whether to filter links
|
|
1114
|
+
active: Whether filter is active
|
|
1115
|
+
|
|
1116
|
+
Returns:
|
|
1117
|
+
True if successful, False otherwise
|
|
1118
|
+
"""
|
|
1119
|
+
if not self._connector.is_connected:
|
|
1120
|
+
self._logger.error("Cannot update filter: not connected")
|
|
1121
|
+
return False
|
|
1122
|
+
|
|
1123
|
+
try:
|
|
1124
|
+
payload = {
|
|
1125
|
+
"name": name,
|
|
1126
|
+
"source": source,
|
|
1127
|
+
"flags": flags,
|
|
1128
|
+
"replace": replace,
|
|
1129
|
+
"filterlinks": filterlinks,
|
|
1130
|
+
"active": active
|
|
1131
|
+
}
|
|
1132
|
+
self._logger.debug(f"Updating chat filter: {name}")
|
|
1133
|
+
await self._connector._socket.emit("updateFilter", payload)
|
|
1134
|
+
|
|
1135
|
+
# Audit log admin operation
|
|
1136
|
+
if self._audit_logger:
|
|
1137
|
+
self._audit_logger.log_admin_operation(
|
|
1138
|
+
operation="updateFilter",
|
|
1139
|
+
target=name,
|
|
1140
|
+
details={"source": source, "flags": flags, "replace": replace}
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
return True
|
|
1144
|
+
|
|
1145
|
+
except Exception as e:
|
|
1146
|
+
self._logger.error(f"Failed to update filter: {e}", exc_info=True)
|
|
1147
|
+
return False
|
|
1148
|
+
|
|
1149
|
+
async def remove_filter(self, name: str) -> bool:
|
|
1150
|
+
"""
|
|
1151
|
+
Remove a chat filter.
|
|
1152
|
+
Requires rank 3+ (admin).
|
|
1153
|
+
|
|
1154
|
+
Args:
|
|
1155
|
+
name: Filter name to remove
|
|
1156
|
+
|
|
1157
|
+
Returns:
|
|
1158
|
+
True if successful, False otherwise
|
|
1159
|
+
"""
|
|
1160
|
+
if not self._connector.is_connected:
|
|
1161
|
+
self._logger.error("Cannot remove filter: not connected")
|
|
1162
|
+
return False
|
|
1163
|
+
|
|
1164
|
+
try:
|
|
1165
|
+
payload = {"name": name}
|
|
1166
|
+
self._logger.debug(f"Removing chat filter: {name}")
|
|
1167
|
+
await self._connector._socket.emit("removeFilter", payload)
|
|
1168
|
+
|
|
1169
|
+
# Audit log admin operation
|
|
1170
|
+
if self._audit_logger:
|
|
1171
|
+
self._audit_logger.log_admin_operation(
|
|
1172
|
+
operation="removeFilter",
|
|
1173
|
+
target=name
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
return True
|
|
1177
|
+
|
|
1178
|
+
except Exception as e:
|
|
1179
|
+
self._logger.error(f"Failed to remove filter: {e}", exc_info=True)
|
|
1180
|
+
return False
|
|
1181
|
+
|
|
1182
|
+
# PHASE 3: Advanced Admin Functions (Rank 2-4+)
|
|
1183
|
+
|
|
1184
|
+
async def new_poll(
|
|
1185
|
+
self,
|
|
1186
|
+
title: str,
|
|
1187
|
+
options: list[str],
|
|
1188
|
+
obscured: bool = False,
|
|
1189
|
+
timeout: int = 0
|
|
1190
|
+
) -> bool:
|
|
1191
|
+
"""
|
|
1192
|
+
Create a new poll.
|
|
1193
|
+
Requires rank 2+ (moderator).
|
|
1194
|
+
|
|
1195
|
+
Args:
|
|
1196
|
+
title: Poll question
|
|
1197
|
+
options: List of poll options
|
|
1198
|
+
obscured: Whether to hide results until poll closes
|
|
1199
|
+
timeout: Auto-close timeout in seconds (0 = no timeout)
|
|
1200
|
+
|
|
1201
|
+
Returns:
|
|
1202
|
+
True if successful, False otherwise
|
|
1203
|
+
"""
|
|
1204
|
+
if not self._connector.is_connected:
|
|
1205
|
+
self._logger.error("Cannot create poll: not connected")
|
|
1206
|
+
return False
|
|
1207
|
+
|
|
1208
|
+
try:
|
|
1209
|
+
payload = {
|
|
1210
|
+
"title": title,
|
|
1211
|
+
"opts": options,
|
|
1212
|
+
"obscured": obscured,
|
|
1213
|
+
"timeout": timeout
|
|
1214
|
+
}
|
|
1215
|
+
self._logger.debug(f"Creating poll: {title} with {len(options)} options")
|
|
1216
|
+
await self._connector._socket.emit("newPoll", payload)
|
|
1217
|
+
|
|
1218
|
+
# Audit log admin operation
|
|
1219
|
+
if self._audit_logger:
|
|
1220
|
+
self._audit_logger.log_admin_operation(
|
|
1221
|
+
operation="newPoll",
|
|
1222
|
+
details={"title": title, "options": len(options)}
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
return True
|
|
1226
|
+
|
|
1227
|
+
except Exception as e:
|
|
1228
|
+
self._logger.error(f"Failed to create poll: {e}", exc_info=True)
|
|
1229
|
+
return False
|
|
1230
|
+
|
|
1231
|
+
async def vote(self, option: int) -> bool:
|
|
1232
|
+
"""
|
|
1233
|
+
Vote in the active poll.
|
|
1234
|
+
Requires rank 0+ (guest).
|
|
1235
|
+
|
|
1236
|
+
Args:
|
|
1237
|
+
option: Option index to vote for (0-based)
|
|
1238
|
+
|
|
1239
|
+
Returns:
|
|
1240
|
+
True if successful, False otherwise
|
|
1241
|
+
"""
|
|
1242
|
+
if not self._connector.is_connected:
|
|
1243
|
+
self._logger.error("Cannot vote: not connected")
|
|
1244
|
+
return False
|
|
1245
|
+
|
|
1246
|
+
try:
|
|
1247
|
+
payload = {"option": option}
|
|
1248
|
+
self._logger.debug(f"Voting for option {option}")
|
|
1249
|
+
await self._connector._socket.emit("vote", payload)
|
|
1250
|
+
return True
|
|
1251
|
+
|
|
1252
|
+
except Exception as e:
|
|
1253
|
+
self._logger.error(f"Failed to vote: {e}", exc_info=True)
|
|
1254
|
+
return False
|
|
1255
|
+
|
|
1256
|
+
async def close_poll(self) -> bool:
|
|
1257
|
+
"""
|
|
1258
|
+
Close the active poll.
|
|
1259
|
+
Requires rank 2+ (moderator).
|
|
1260
|
+
|
|
1261
|
+
Returns:
|
|
1262
|
+
True if successful, False otherwise
|
|
1263
|
+
"""
|
|
1264
|
+
if not self._connector.is_connected:
|
|
1265
|
+
self._logger.error("Cannot close poll: not connected")
|
|
1266
|
+
return False
|
|
1267
|
+
|
|
1268
|
+
try:
|
|
1269
|
+
self._logger.debug("Closing active poll")
|
|
1270
|
+
await self._connector._socket.emit("closePoll", {})
|
|
1271
|
+
|
|
1272
|
+
# Audit log admin operation
|
|
1273
|
+
if self._audit_logger:
|
|
1274
|
+
self._audit_logger.log_admin_operation(
|
|
1275
|
+
operation="closePoll"
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
return True
|
|
1279
|
+
|
|
1280
|
+
except Exception as e:
|
|
1281
|
+
self._logger.error(f"Failed to close poll: {e}", exc_info=True)
|
|
1282
|
+
return False
|
|
1283
|
+
|
|
1284
|
+
async def set_channel_rank(self, username: str, rank: int) -> bool:
|
|
1285
|
+
"""
|
|
1286
|
+
Set a user's permanent channel rank.
|
|
1287
|
+
Requires rank 4+ (owner).
|
|
1288
|
+
|
|
1289
|
+
Args:
|
|
1290
|
+
username: User to modify
|
|
1291
|
+
rank: Rank level (0-4+)
|
|
1292
|
+
0: Guest
|
|
1293
|
+
1: Registered
|
|
1294
|
+
2: Moderator
|
|
1295
|
+
3: Admin
|
|
1296
|
+
4+: Owner
|
|
1297
|
+
|
|
1298
|
+
Returns:
|
|
1299
|
+
True if successful, False otherwise
|
|
1300
|
+
"""
|
|
1301
|
+
if not self._connector.is_connected:
|
|
1302
|
+
self._logger.error("Cannot set channel rank: not connected")
|
|
1303
|
+
return False
|
|
1304
|
+
|
|
1305
|
+
try:
|
|
1306
|
+
payload = {"name": username, "rank": rank}
|
|
1307
|
+
self._logger.debug(f"Setting {username} to rank {rank}")
|
|
1308
|
+
await self._connector._socket.emit("setChannelRank", payload)
|
|
1309
|
+
|
|
1310
|
+
# Audit log admin operation
|
|
1311
|
+
if self._audit_logger:
|
|
1312
|
+
self._audit_logger.log_admin_operation(
|
|
1313
|
+
operation="setChannelRank",
|
|
1314
|
+
target=username,
|
|
1315
|
+
details={"rank": rank}
|
|
1316
|
+
)
|
|
1317
|
+
|
|
1318
|
+
return True
|
|
1319
|
+
|
|
1320
|
+
except Exception as e:
|
|
1321
|
+
self._logger.error(f"Failed to set channel rank: {e}", exc_info=True)
|
|
1322
|
+
return False
|
|
1323
|
+
|
|
1324
|
+
async def request_channel_ranks(self) -> bool:
|
|
1325
|
+
"""
|
|
1326
|
+
Request list of users with elevated channel ranks.
|
|
1327
|
+
Requires rank 4+ (owner).
|
|
1328
|
+
Server will respond with channelRankFail or channelRanks event.
|
|
1329
|
+
|
|
1330
|
+
Returns:
|
|
1331
|
+
True if request sent, False otherwise
|
|
1332
|
+
"""
|
|
1333
|
+
if not self._connector.is_connected:
|
|
1334
|
+
self._logger.error("Cannot request channel ranks: not connected")
|
|
1335
|
+
return False
|
|
1336
|
+
|
|
1337
|
+
try:
|
|
1338
|
+
self._logger.debug("Requesting channel ranks")
|
|
1339
|
+
await self._connector._socket.emit("requestChannelRanks", {})
|
|
1340
|
+
return True
|
|
1341
|
+
|
|
1342
|
+
except Exception as e:
|
|
1343
|
+
self._logger.error(f"Failed to request channel ranks: {e}", exc_info=True)
|
|
1344
|
+
return False
|
|
1345
|
+
|
|
1346
|
+
async def request_banlist(self) -> bool:
|
|
1347
|
+
"""
|
|
1348
|
+
Request channel ban list.
|
|
1349
|
+
Requires rank 3+ (admin).
|
|
1350
|
+
Server will respond with banlist event.
|
|
1351
|
+
|
|
1352
|
+
Returns:
|
|
1353
|
+
True if request sent, False otherwise
|
|
1354
|
+
"""
|
|
1355
|
+
if not self._connector.is_connected:
|
|
1356
|
+
self._logger.error("Cannot request banlist: not connected")
|
|
1357
|
+
return False
|
|
1358
|
+
|
|
1359
|
+
try:
|
|
1360
|
+
self._logger.debug("Requesting ban list")
|
|
1361
|
+
await self._connector._socket.emit("requestBanlist", {})
|
|
1362
|
+
return True
|
|
1363
|
+
|
|
1364
|
+
except Exception as e:
|
|
1365
|
+
self._logger.error(f"Failed to request banlist: {e}", exc_info=True)
|
|
1366
|
+
return False
|
|
1367
|
+
|
|
1368
|
+
async def unban(self, ban_id: int) -> bool:
|
|
1369
|
+
"""
|
|
1370
|
+
Remove a ban.
|
|
1371
|
+
Requires rank 3+ (admin).
|
|
1372
|
+
|
|
1373
|
+
Args:
|
|
1374
|
+
ban_id: ID of the ban to remove (from banlist event)
|
|
1375
|
+
|
|
1376
|
+
Returns:
|
|
1377
|
+
True if successful, False otherwise
|
|
1378
|
+
"""
|
|
1379
|
+
if not self._connector.is_connected:
|
|
1380
|
+
self._logger.error("Cannot unban: not connected")
|
|
1381
|
+
return False
|
|
1382
|
+
|
|
1383
|
+
try:
|
|
1384
|
+
payload = {"id": ban_id}
|
|
1385
|
+
self._logger.debug(f"Unbanning ID {ban_id}")
|
|
1386
|
+
await self._connector._socket.emit("unban", payload)
|
|
1387
|
+
return True
|
|
1388
|
+
|
|
1389
|
+
except Exception as e:
|
|
1390
|
+
self._logger.error(f"Failed to unban: {e}", exc_info=True)
|
|
1391
|
+
return False
|
|
1392
|
+
|
|
1393
|
+
async def read_chan_log(self, count: int = 100) -> bool:
|
|
1394
|
+
"""
|
|
1395
|
+
Request channel event log.
|
|
1396
|
+
Requires rank 3+ (admin).
|
|
1397
|
+
Server will respond with readChanLog event.
|
|
1398
|
+
|
|
1399
|
+
Args:
|
|
1400
|
+
count: Number of log entries to retrieve (default 100)
|
|
1401
|
+
|
|
1402
|
+
Returns:
|
|
1403
|
+
True if request sent, False otherwise
|
|
1404
|
+
"""
|
|
1405
|
+
if not self._connector.is_connected:
|
|
1406
|
+
self._logger.error("Cannot read channel log: not connected")
|
|
1407
|
+
return False
|
|
1408
|
+
|
|
1409
|
+
try:
|
|
1410
|
+
payload = {"count": count}
|
|
1411
|
+
self._logger.debug(f"Requesting {count} channel log entries")
|
|
1412
|
+
await self._connector._socket.emit("readChanLog", payload)
|
|
1413
|
+
return True
|
|
1414
|
+
|
|
1415
|
+
except Exception as e:
|
|
1416
|
+
self._logger.error(f"Failed to read channel log: {e}", exc_info=True)
|
|
1417
|
+
return False
|
|
1418
|
+
|
|
1419
|
+
async def search_library(
|
|
1420
|
+
self,
|
|
1421
|
+
query: str,
|
|
1422
|
+
source: str = "library"
|
|
1423
|
+
) -> bool:
|
|
1424
|
+
"""
|
|
1425
|
+
Search channel library.
|
|
1426
|
+
Requires appropriate rank based on channel permissions.
|
|
1427
|
+
Server will respond with searchResults event.
|
|
1428
|
+
|
|
1429
|
+
Args:
|
|
1430
|
+
query: Search query
|
|
1431
|
+
source: Search source ("library" or media provider like "yt", "vm")
|
|
1432
|
+
|
|
1433
|
+
Returns:
|
|
1434
|
+
True if request sent, False otherwise
|
|
1435
|
+
"""
|
|
1436
|
+
if not self._connector.is_connected:
|
|
1437
|
+
self._logger.error("Cannot search library: not connected")
|
|
1438
|
+
return False
|
|
1439
|
+
|
|
1440
|
+
try:
|
|
1441
|
+
payload = {"query": query, "source": source}
|
|
1442
|
+
self._logger.debug(f"Searching library: {query} in {source}")
|
|
1443
|
+
await self._connector._socket.emit("searchMedia", payload)
|
|
1444
|
+
return True
|
|
1445
|
+
|
|
1446
|
+
except Exception as e:
|
|
1447
|
+
self._logger.error(f"Failed to search library: {e}", exc_info=True)
|
|
1448
|
+
return False
|
|
1449
|
+
|
|
1450
|
+
async def delete_from_library(self, media_id: str) -> bool:
|
|
1451
|
+
"""
|
|
1452
|
+
Delete item from channel library.
|
|
1453
|
+
Requires rank 2+ (moderator).
|
|
1454
|
+
|
|
1455
|
+
Args:
|
|
1456
|
+
media_id: ID of media item to delete
|
|
1457
|
+
|
|
1458
|
+
Returns:
|
|
1459
|
+
True if successful, False otherwise
|
|
1460
|
+
"""
|
|
1461
|
+
if not self._connector.is_connected:
|
|
1462
|
+
self._logger.error("Cannot delete from library: not connected")
|
|
1463
|
+
return False
|
|
1464
|
+
|
|
1465
|
+
try:
|
|
1466
|
+
payload = {"id": media_id}
|
|
1467
|
+
self._logger.debug(f"Deleting library item: {media_id}")
|
|
1468
|
+
await self._connector._socket.emit("uncache", payload)
|
|
1469
|
+
return True
|
|
1470
|
+
|
|
1471
|
+
except Exception as e:
|
|
1472
|
+
self._logger.error(f"Failed to delete from library: {e}", exc_info=True)
|
|
1473
|
+
return False
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
__all__ = ["CytubeEventSender"]
|