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,754 @@
1
+ """CyTube Connector Core Lifecycle Management.
2
+
3
+ This module provides the asynchronous CytubeConnector responsible for connecting,
4
+ authenticating, and joining CyTube channels with proper lifecycle management.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ import re
10
+ from collections.abc import AsyncIterator, Callable
11
+ from typing import Any
12
+
13
+ import aiohttp
14
+
15
+ from .config import CytubeConfig
16
+ from .errors import AuthenticationError, ConnectionError, NotConnectedError
17
+ from .socket_io import SocketIO, SocketIOResponse
18
+
19
+
20
+ class CytubeConnector:
21
+ """Asynchronous CyTube connector with lifecycle management.
22
+
23
+ Orchestrates Socket.IO connection, authentication, and channel joining
24
+ with exponential backoff retry logic and rate limiting handling.
25
+
26
+ Attributes:
27
+ config: CyTube connection configuration.
28
+ logger: Logger instance for structured logging.
29
+ is_connected: Whether connector is currently connected.
30
+
31
+ Examples:
32
+ >>> config = CytubeConfig(domain="cytu.be", channel="test")
33
+ >>> async with CytubeConnector(config, logger) as connector:
34
+ ... # connector is connected
35
+ ... pass
36
+ >>> # connector is automatically disconnected
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ config: CytubeConfig,
42
+ logger: logging.Logger,
43
+ socket_factory: Callable | None = None,
44
+ ):
45
+ """Initialize CyTube connector.
46
+
47
+ Args:
48
+ config: CyTube connection configuration.
49
+ logger: Logger for structured output.
50
+ socket_factory: Optional factory for creating SocketIO instances
51
+ (for dependency injection in tests).
52
+ """
53
+ self.config = config
54
+ self.logger = logger
55
+ self._socket_factory = socket_factory or SocketIO.connect
56
+ self._socket: SocketIO | None = None
57
+ self._connected = False
58
+ self._user_rank: int = 0 # Track logged-in user's rank (0=guest, 1=registered, 2+=moderator/admin)
59
+
60
+ # Connection tracking
61
+ self._connected_since: float | None = None
62
+ self._reconnect_count: int = 0
63
+ self._last_event_time: float | None = None
64
+
65
+ # Event streaming support
66
+ self._event_queue: asyncio.Queue[tuple[str, dict[str, Any]]] = asyncio.Queue(maxsize=1000)
67
+ self._event_callbacks: dict[str, list[Callable[[str, dict], None]]] = {}
68
+ self._messages_received = 0
69
+ self._events_processed = 0
70
+ self._consumer_task: asyncio.Task | None = None
71
+
72
+ @property
73
+ def is_connected(self) -> bool:
74
+ """Check if currently connected to CyTube.
75
+
76
+ Returns:
77
+ True if connected and socket is active, False otherwise.
78
+ """
79
+ return self._connected and self._socket is not None
80
+
81
+ @property
82
+ def user_rank(self) -> int:
83
+ """Get the rank of the logged-in user.
84
+
85
+ Returns:
86
+ User rank: 0=guest, 1=registered, 2=moderator, 3+=admin.
87
+ """
88
+ return self._user_rank
89
+
90
+ @property
91
+ def connected_since(self) -> float | None:
92
+ """Get timestamp when connection was established.
93
+
94
+ Returns:
95
+ Unix timestamp of connection time, or None if not connected.
96
+ """
97
+ return self._connected_since
98
+
99
+ @property
100
+ def reconnect_count(self) -> int:
101
+ """Get number of reconnection attempts.
102
+
103
+ Returns:
104
+ Count of reconnection attempts since instance creation.
105
+ """
106
+ return self._reconnect_count
107
+
108
+ @property
109
+ def last_event_time(self) -> float | None:
110
+ """Get timestamp of last received event.
111
+
112
+ Returns:
113
+ Unix timestamp of last event, or None if no events received.
114
+ """
115
+ return self._last_event_time
116
+
117
+ @property
118
+ def stats(self) -> dict[str, int]:
119
+ """Get event streaming statistics.
120
+
121
+ Returns:
122
+ Dictionary with 'messages_received' and 'events_processed' counts.
123
+
124
+ Examples:
125
+ >>> stats = connector.stats
126
+ >>> assert 'messages_received' in stats
127
+ >>> assert 'events_processed' in stats
128
+ """
129
+ return {
130
+ 'messages_received': self._messages_received,
131
+ 'events_processed': self._events_processed,
132
+ }
133
+
134
+ async def connect(self) -> None:
135
+ """Establish connection and authenticate with CyTube.
136
+
137
+ Performs the following sequence:
138
+ 1. Fetch Socket.IO configuration from CyTube REST API
139
+ 2. Establish Socket.IO connection with exponential backoff retry
140
+ 3. Join channel (with password if configured)
141
+ 4. Authenticate user (registered or guest)
142
+
143
+ Raises:
144
+ ConnectionError: If connection cannot be established after retries.
145
+ AuthenticationError: If authentication or channel join fails.
146
+ asyncio.CancelledError: If connection is cancelled.
147
+
148
+ Examples:
149
+ >>> connector = CytubeConnector(config, logger)
150
+ >>> await connector.connect()
151
+ >>> assert connector.is_connected
152
+ """
153
+ if self._connected:
154
+ self.logger.warning("Already connected, ignoring connect() call")
155
+ return
156
+
157
+ self.logger.info(
158
+ "Connecting to CyTube",
159
+ extra={"domain": self.config.domain, "channel": self.config.channel},
160
+ )
161
+
162
+ try:
163
+ # Fetch Socket.IO endpoint configuration
164
+ socket_config = await self._get_socket_config()
165
+ socket_url = socket_config["url"]
166
+
167
+ # Establish Socket.IO connection with retry
168
+ self._socket = await self._socket_factory(
169
+ url=socket_url,
170
+ retry=0, # We'll handle retries at this level
171
+ qsize=100,
172
+ )
173
+
174
+ # Join channel
175
+ await self._join_channel()
176
+
177
+ # Authenticate user
178
+ await self._authenticate_user()
179
+
180
+ self._connected = True
181
+
182
+ # Track connection timing
183
+ import time
184
+ if self._connected_since is not None:
185
+ # This is a reconnection
186
+ self._reconnect_count += 1
187
+ self._connected_since = time.time()
188
+
189
+ # Start event consumer task
190
+ self._consumer_task = asyncio.create_task(self._consume_socket_events())
191
+
192
+ # Request initial state from CyTube
193
+ # This ensures KV stores are populated with complete state
194
+ # even if bot starts after channel is already active
195
+ await self._request_initial_state()
196
+
197
+ self.logger.info(
198
+ "Connected to CyTube",
199
+ extra={
200
+ "domain": self.config.domain,
201
+ "channel": self.config.channel,
202
+ "user": self.config.user or "guest",
203
+ },
204
+ )
205
+
206
+ except asyncio.CancelledError:
207
+ self.logger.warning("Connection cancelled")
208
+ await self._cleanup()
209
+ raise
210
+
211
+ except Exception as e:
212
+ self.logger.error(
213
+ "Failed to connect to CyTube",
214
+ extra={
215
+ "domain": self.config.domain,
216
+ "channel": self.config.channel,
217
+ "error": str(e),
218
+ },
219
+ )
220
+ await self._cleanup()
221
+ raise
222
+
223
+ async def disconnect(self) -> None:
224
+ """Close connection gracefully.
225
+
226
+ Closes the Socket.IO connection and cleans up resources.
227
+ Safe to call multiple times or when not connected.
228
+
229
+ Examples:
230
+ >>> await connector.connect()
231
+ >>> await connector.disconnect()
232
+ >>> assert not connector.is_connected
233
+ """
234
+ if not self._connected:
235
+ self.logger.debug("Not connected, disconnect() is a no-op")
236
+ return
237
+
238
+ self.logger.info(
239
+ "Disconnecting from CyTube",
240
+ extra={"domain": self.config.domain, "channel": self.config.channel},
241
+ )
242
+
243
+ await self._cleanup()
244
+
245
+ self.logger.info("Disconnected from CyTube")
246
+
247
+ async def _cleanup(self) -> None:
248
+ """Clean up resources and reset state.
249
+
250
+ Internal method for closing socket and resetting connection state.
251
+ """
252
+ self._connected = False
253
+ self._connected_since = None
254
+
255
+ # Cancel consumer task if running
256
+ if self._consumer_task and not self._consumer_task.done():
257
+ self._consumer_task.cancel()
258
+ try:
259
+ await self._consumer_task
260
+ except asyncio.CancelledError:
261
+ pass
262
+ self._consumer_task = None
263
+
264
+ if self._socket:
265
+ try:
266
+ await self._socket.close()
267
+ except Exception as e:
268
+ self.logger.warning(f"Error closing socket: {e}")
269
+ finally:
270
+ self._socket = None
271
+
272
+ async def _get_socket_config(self) -> dict[str, Any]:
273
+ """Fetch Socket.IO configuration from CyTube REST API.
274
+
275
+ Queries the CyTube server for Socket.IO connection details including
276
+ the WebSocket URL to use.
277
+
278
+ Returns:
279
+ Dictionary containing socket configuration with 'url' key.
280
+
281
+ Raises:
282
+ ConnectionError: If configuration cannot be fetched.
283
+
284
+ Examples:
285
+ >>> config = await connector._get_socket_config()
286
+ >>> assert "url" in config
287
+ """
288
+ config_url = f"https://{self.config.domain}/socketconfig/{self.config.channel}.json"
289
+
290
+ self.logger.debug(f"Fetching socket config from {config_url}")
291
+
292
+ try:
293
+ async with aiohttp.ClientSession() as session:
294
+ async with session.get(config_url, timeout=aiohttp.ClientTimeout(total=10)) as response:
295
+ response.raise_for_status()
296
+ data = await response.json()
297
+
298
+ # Extract first server URL
299
+ if "servers" not in data or not data["servers"]:
300
+ raise ConnectionError("No servers in socket configuration")
301
+
302
+ server = data["servers"][0]
303
+ base_url = server["url"]
304
+
305
+ # Construct Socket.IO URL
306
+ socket_url = f"{base_url}/socket.io/"
307
+
308
+ self.logger.debug(f"Socket.IO URL: {socket_url}")
309
+
310
+ return {"url": socket_url}
311
+
312
+ except aiohttp.ClientError as e:
313
+ raise ConnectionError(f"Failed to fetch socket config: {e}") from e
314
+ except (KeyError, IndexError) as e:
315
+ raise ConnectionError(f"Invalid socket config format: {e}") from e
316
+
317
+ async def _join_channel(self) -> None:
318
+ """Send joinChannel event to enter the channel.
319
+
320
+ Joins the configured channel, optionally providing channel password
321
+ if one is configured.
322
+
323
+ Raises:
324
+ NotConnectedError: If socket is not connected.
325
+ AuthenticationError: If channel password is incorrect.
326
+
327
+ Examples:
328
+ >>> await connector._join_channel()
329
+ """
330
+ if not self._socket:
331
+ raise NotConnectedError("Socket not connected")
332
+
333
+ self.logger.debug(f"Joining channel: {self.config.channel}")
334
+
335
+ join_data = {"name": self.config.channel}
336
+
337
+ # Add channel password if configured
338
+ if self.config.channel_password:
339
+ join_data["pw"] = self.config.channel_password
340
+
341
+ # Send joinChannel event
342
+ await self._socket.emit("joinChannel", join_data)
343
+
344
+ # Wait for acknowledgment or error
345
+ # CyTube sends 'needPassword' if password is wrong, or continues with channel data
346
+ try:
347
+ # Use a short timeout to see if we get immediate rejection
348
+ response = await asyncio.wait_for(
349
+ self._wait_for_channel_response(),
350
+ timeout=2.0
351
+ )
352
+
353
+ if response and response[0] == "needPassword":
354
+ raise AuthenticationError(
355
+ f"Invalid or missing password for channel: {self.config.channel}"
356
+ )
357
+
358
+ except TimeoutError:
359
+ # No immediate rejection means join was accepted
360
+ pass
361
+
362
+ self.logger.debug(f"Joined channel: {self.config.channel}")
363
+
364
+ async def _wait_for_channel_response(self) -> tuple[str, Any] | None:
365
+ """Wait for channel-related response from server.
366
+
367
+ Returns:
368
+ Tuple of (event_name, data) if received, None on timeout.
369
+ """
370
+ if not self._socket:
371
+ return None
372
+
373
+ try:
374
+ event, data = await self._socket.recv()
375
+ return (event, data)
376
+ except Exception:
377
+ return None
378
+
379
+ async def _authenticate_user(self) -> None:
380
+ """Authenticate as registered user or guest.
381
+
382
+ Sends login event with credentials (registered user) or just a name
383
+ (guest user). Handles rate limiting for guest logins.
384
+
385
+ Raises:
386
+ NotConnectedError: If socket is not connected.
387
+ AuthenticationError: If authentication fails.
388
+
389
+ Examples:
390
+ >>> await connector._authenticate_user()
391
+ """
392
+ if not self._socket:
393
+ raise NotConnectedError("Socket not connected")
394
+
395
+ if self.config.user and self.config.password:
396
+ # Registered user authentication
397
+ await self._authenticate_registered()
398
+ else:
399
+ # Guest authentication
400
+ await self._authenticate_guest()
401
+
402
+ async def _authenticate_registered(self) -> None:
403
+ """Authenticate with username and password.
404
+
405
+ Raises:
406
+ AuthenticationError: If credentials are invalid.
407
+ """
408
+ self.logger.debug(f"Authenticating as registered user: {self.config.user}")
409
+
410
+ login_data = {
411
+ "name": self.config.user,
412
+ "pw": self.config.password,
413
+ }
414
+
415
+ # Create matcher for login response
416
+ matcher = SocketIOResponse.match_event(r"^login$")
417
+
418
+ # Send login event and wait for response
419
+ response = await self._socket.emit("login", login_data, matcher, response_timeout=5.0)
420
+
421
+ if not response:
422
+ raise AuthenticationError("Login timeout - no response from server")
423
+
424
+ event, data = response
425
+
426
+ # Check if login was successful
427
+ if not isinstance(data, dict) or not data.get("success"):
428
+ error_msg = data.get("error", "Unknown error") if isinstance(data, dict) else "Login failed"
429
+ raise AuthenticationError(f"Login failed: {error_msg}")
430
+
431
+ # Store user rank from login response
432
+ self._user_rank = data.get("rank", 1) # Default to 1 (registered) if not provided
433
+ self.logger.debug(f"Authenticated as: {self.config.user} (rank: {self._user_rank})")
434
+
435
+ async def _authenticate_guest(self) -> None:
436
+ """Authenticate as guest user.
437
+
438
+ Handles rate limiting by parsing delay from error messages and
439
+ automatically retrying after the specified wait period.
440
+
441
+ Raises:
442
+ AuthenticationError: If guest login fails after retries.
443
+ """
444
+ guest_name = self.config.user or "Guest"
445
+ self.logger.debug(f"Authenticating as guest: {guest_name}")
446
+
447
+ max_retries = 3
448
+ retry_count = 0
449
+
450
+ while retry_count < max_retries:
451
+ login_data = {"name": guest_name}
452
+
453
+ # Create matcher for login response
454
+ matcher = SocketIOResponse.match_event(r"^login$")
455
+
456
+ # Send login event and wait for response
457
+ response = await self._socket.emit("login", login_data, matcher, response_timeout=5.0)
458
+
459
+ if not response:
460
+ raise AuthenticationError("Guest login timeout - no response from server")
461
+
462
+ event, data = response
463
+
464
+ # Check if login was successful
465
+ if isinstance(data, dict):
466
+ if data.get("success"):
467
+ # Store user rank from login response (guests typically have rank 0)
468
+ self._user_rank = data.get("rank", 0)
469
+ self.logger.debug(f"Authenticated as guest: {guest_name} (rank: {self._user_rank})")
470
+ return
471
+
472
+ # Check for rate limiting
473
+ error_msg = data.get("error", "")
474
+ if "restricted" in error_msg.lower() or "wait" in error_msg.lower():
475
+ # Try to parse delay from message
476
+ delay = self._parse_rate_limit_delay(error_msg)
477
+
478
+ if delay and retry_count < max_retries - 1:
479
+ self.logger.warning(
480
+ f"Guest login rate limited, waiting {delay}s before retry"
481
+ )
482
+ await asyncio.sleep(delay)
483
+ retry_count += 1
484
+ continue
485
+
486
+ raise AuthenticationError(f"Guest login failed: {error_msg}")
487
+
488
+ retry_count += 1
489
+
490
+ raise AuthenticationError("Guest login failed after maximum retries")
491
+
492
+ def _parse_rate_limit_delay(self, error_message: str) -> int | None:
493
+ """Parse rate limit delay from error message.
494
+
495
+ Args:
496
+ error_message: Error message from server (e.g., "restricted for 15 seconds").
497
+
498
+ Returns:
499
+ Delay in seconds if found, None otherwise.
500
+
501
+ Examples:
502
+ >>> delay = connector._parse_rate_limit_delay("restricted for 15 seconds")
503
+ >>> assert delay == 15
504
+ """
505
+ # Pattern: "restricted for X seconds" or "wait X seconds"
506
+ pattern = r"(?:restricted for|wait)\s+(\d+)\s+seconds?"
507
+ match = re.search(pattern, error_message, re.IGNORECASE)
508
+
509
+ if match:
510
+ return int(match.group(1))
511
+
512
+ return None
513
+
514
+ async def _request_initial_state(self) -> None:
515
+ """Request initial channel state from CyTube.
516
+
517
+ Requests complete channel state after connecting:
518
+ - Emits 'requestPlaylist' to get full playlist
519
+ - Emits 'playerReady' to get currently playing media
520
+
521
+ Note: CyTube automatically sends 'userlist' and 'emoteList' when
522
+ joining a channel, so we don't need to explicitly request those.
523
+
524
+ This ensures KV stores are populated with complete state even if
525
+ the bot starts after the channel is already active, preventing
526
+ gaps in state that would occur if we only relied on delta events.
527
+
528
+ Raises:
529
+ SocketIOError: If requests fail.
530
+ """
531
+ if not self._socket:
532
+ self.logger.warning("Cannot request initial state: socket not connected")
533
+ return
534
+
535
+ self.logger.debug("Requesting initial state from CyTube")
536
+ try:
537
+ # Only request playlist if user has moderator+ permissions (rank >= 2)
538
+ # CyTube restricts full playlist access to moderators and above
539
+ if self._user_rank >= 2:
540
+ await self._socket.emit("requestPlaylist", {})
541
+ self.logger.debug(f"Playlist request sent (rank: {self._user_rank})")
542
+ else:
543
+ self.logger.debug(f"Skipping playlist request - insufficient permissions (rank: {self._user_rank}, need >= 2)")
544
+
545
+ # Request current media state (available to all users)
546
+ # CyTube will respond with 'changeMedia' event
547
+ await self._socket.emit("playerReady", {})
548
+ self.logger.debug("Player ready signal sent")
549
+
550
+ # Give CyTube a moment to respond with state
551
+ # The responses will be handled by the event consumer task
552
+ await asyncio.sleep(0.5)
553
+
554
+ self.logger.info("Initial state requested from CyTube")
555
+
556
+ except Exception as e:
557
+ # Don't fail connection if state request fails
558
+ # We'll still get delta updates going forward
559
+ self.logger.warning(f"Failed to request initial state: {e}")
560
+
561
+ async def __aenter__(self):
562
+ """Async context manager entry.
563
+
564
+ Automatically connects when entering the context.
565
+
566
+ Returns:
567
+ Self for use in context.
568
+
569
+ Examples:
570
+ >>> async with CytubeConnector(config, logger) as connector:
571
+ ... assert connector.is_connected
572
+ """
573
+ await self.connect()
574
+ return self
575
+
576
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
577
+ """Async context manager exit.
578
+
579
+ Ensures disconnect is called even if an exception occurs.
580
+
581
+ Args:
582
+ exc_type: Exception type if raised.
583
+ exc_val: Exception value if raised.
584
+ exc_tb: Exception traceback if raised.
585
+
586
+ Returns:
587
+ False to propagate exceptions.
588
+ """
589
+ await self.disconnect()
590
+ return False
591
+
592
+ async def _consume_socket_events(self) -> None:
593
+ """Internal task that consumes events from socket and queues them.
594
+
595
+ Runs as a background task from connect() until disconnect().
596
+ Handles queue overflow by dropping oldest events with warnings.
597
+ """
598
+ try:
599
+ while self._connected and self._socket:
600
+ try:
601
+ event_name, payload = await self._socket.recv()
602
+ self._messages_received += 1
603
+
604
+ # Track last event time
605
+ import time
606
+ self._last_event_time = time.time()
607
+
608
+ # Try to queue event, drop oldest if full
609
+ try:
610
+ self._event_queue.put_nowait((event_name, payload))
611
+ except asyncio.QueueFull:
612
+ # Drop oldest event and log warning
613
+ try:
614
+ self._event_queue.get_nowait()
615
+ self.logger.warning(
616
+ "Event queue full, dropping oldest event",
617
+ extra={"queue_size": self._event_queue.qsize()}
618
+ )
619
+ except asyncio.QueueEmpty:
620
+ pass
621
+ # Try to add new event again
622
+ try:
623
+ self._event_queue.put_nowait((event_name, payload))
624
+ except asyncio.QueueFull:
625
+ self.logger.error("Failed to queue event after drop")
626
+
627
+ # Fire registered callbacks
628
+ self._fire_callbacks(event_name, payload)
629
+
630
+ except asyncio.CancelledError:
631
+ break
632
+ except Exception as e:
633
+ self.logger.error(f"Error consuming socket event: {e}")
634
+ # Continue processing other events unless disconnected
635
+ if not self._connected:
636
+ break
637
+
638
+ except asyncio.CancelledError:
639
+ pass
640
+ except Exception as e:
641
+ self.logger.error(f"Consumer task error: {e}")
642
+ finally:
643
+ self.logger.debug("Event consumer task stopped")
644
+
645
+ def _fire_callbacks(self, event_name: str, payload: dict) -> None:
646
+ """Execute registered callbacks for an event.
647
+
648
+ Args:
649
+ event_name: Name of the event.
650
+ payload: Event payload data.
651
+ """
652
+ callbacks = self._event_callbacks.get(event_name, [])
653
+
654
+ for callback in callbacks:
655
+ try:
656
+ callback(event_name, payload)
657
+ except Exception as e:
658
+ self.logger.error(
659
+ f"Error in event callback for '{event_name}': {e}",
660
+ exc_info=True
661
+ )
662
+
663
+ def on_event(self, event_name: str, callback: Callable[[str, dict], None]) -> None:
664
+ """Register callback for specific event type.
665
+
666
+ The callback will be invoked synchronously for each matching event
667
+ before it is yielded by recv_events(). Exceptions in callbacks are
668
+ logged but do not affect event processing.
669
+
670
+ Args:
671
+ event_name: Name of event to listen for (e.g., 'chatMsg').
672
+ callback: Function accepting (event_name, payload).
673
+
674
+ Examples:
675
+ >>> def on_chat(event, data):
676
+ ... print(f"Chat: {data.get('msg')}")
677
+ >>> connector.on_event('chatMsg', on_chat)
678
+ """
679
+ if event_name not in self._event_callbacks:
680
+ self._event_callbacks[event_name] = []
681
+
682
+ if callback not in self._event_callbacks[event_name]:
683
+ self._event_callbacks[event_name].append(callback)
684
+ self.logger.debug(f"Registered callback for event: {event_name}")
685
+
686
+ def off_event(self, event_name: str, callback: Callable[[str, dict], None]) -> None:
687
+ """Unregister callback for specific event type.
688
+
689
+ Args:
690
+ event_name: Name of event to stop listening for.
691
+ callback: Previously registered callback function.
692
+
693
+ Examples:
694
+ >>> connector.off_event('chatMsg', on_chat)
695
+ """
696
+ if event_name in self._event_callbacks:
697
+ try:
698
+ self._event_callbacks[event_name].remove(callback)
699
+ self.logger.debug(f"Unregistered callback for event: {event_name}")
700
+
701
+ # Clean up empty callback lists
702
+ if not self._event_callbacks[event_name]:
703
+ del self._event_callbacks[event_name]
704
+ except ValueError:
705
+ self.logger.warning(f"Callback not found for event: {event_name}")
706
+
707
+ async def recv_events(self) -> AsyncIterator[tuple[str, dict[str, Any]]]:
708
+ """Async generator yielding raw CyTube events.
709
+
710
+ Yields events in FIFO order as (event_name, payload) tuples.
711
+ Stops cleanly when disconnect() is called or connection is lost.
712
+
713
+ Yields:
714
+ Tuple of (event_name, payload) for each received event.
715
+
716
+ Raises:
717
+ ConnectionError: If socket disconnects unexpectedly.
718
+ NotConnectedError: If called before connect().
719
+
720
+ Examples:
721
+ >>> async for event_name, payload in connector.recv_events():
722
+ ... print(f"{event_name}: {payload}")
723
+ """
724
+ if not self._connected:
725
+ raise NotConnectedError("Must connect before receiving events")
726
+
727
+ try:
728
+ while self._connected:
729
+ try:
730
+ # Wait for event with timeout to check connection status
731
+ event_name, payload = await asyncio.wait_for(
732
+ self._event_queue.get(),
733
+ timeout=1.0
734
+ )
735
+ self._events_processed += 1
736
+ yield event_name, payload
737
+
738
+ except TimeoutError:
739
+ # No event received, check if still connected
740
+ if not self._connected:
741
+ break
742
+ continue
743
+
744
+ except asyncio.CancelledError:
745
+ self.logger.debug("Event iteration cancelled")
746
+ raise
747
+ except Exception as e:
748
+ self.logger.error(f"Error in event iteration: {e}")
749
+ raise ConnectionError(f"Event stream error: {e}") from e
750
+ finally:
751
+ self.logger.debug("Event iteration stopped")
752
+
753
+
754
+ __all__ = ["CytubeConnector"]