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,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"]