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/socket_io.py ADDED
@@ -0,0 +1,903 @@
1
+ """Standalone Socket.IO Transport Layer for Kryten.
2
+
3
+ This module provides a self-contained asynchronous Socket.IO v2 client
4
+ implementation tuned for CyTube communication. It handles the Socket.IO
5
+ handshake, websocket upgrade, heartbeat management, and event marshaling
6
+ without depending on legacy shared modules.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import re
13
+ from collections.abc import Callable
14
+ from time import time
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ if TYPE_CHECKING:
18
+ from websockets.client import WebSocketClientProtocol
19
+ else:
20
+ WebSocketClientProtocol = object
21
+
22
+ import websockets
23
+ from websockets.exceptions import (
24
+ ConnectionClosed as WebSocketConnectionClosed,
25
+ )
26
+ from websockets.exceptions import (
27
+ InvalidHandshake,
28
+ InvalidState,
29
+ PayloadTooBig,
30
+ WebSocketProtocolError,
31
+ )
32
+
33
+ # ============================================================================
34
+ # Exception Classes
35
+ # ============================================================================
36
+
37
+
38
+ class SocketIOError(Exception):
39
+ """Base class for all Socket.IO transport exceptions."""
40
+
41
+
42
+ class ConnectionFailed(SocketIOError):
43
+ """Exception raised when connection to server fails."""
44
+
45
+
46
+ class ConnectionClosed(SocketIOError):
47
+ """Exception raised when connection to server is closed."""
48
+
49
+
50
+ class PingTimeout(ConnectionClosed):
51
+ """Exception raised when server fails to respond to ping within timeout."""
52
+
53
+
54
+ # ============================================================================
55
+ # Utility Functions
56
+ # ============================================================================
57
+
58
+
59
+ async def default_get(url: str) -> str:
60
+ """Default HTTP GET implementation using aiohttp.
61
+
62
+ Parameters
63
+ ----------
64
+ url : str
65
+ URL to fetch.
66
+
67
+ Returns
68
+ -------
69
+ str
70
+ Response text.
71
+ """
72
+ try:
73
+ import aiohttp
74
+ except ImportError as ex:
75
+ raise ImportError(
76
+ "aiohttp is required for HTTP polling. "
77
+ "Install with: pip install aiohttp"
78
+ ) from ex
79
+
80
+ async with aiohttp.ClientSession() as session:
81
+ async with session.get(url) as response:
82
+ return await response.text()
83
+
84
+
85
+ def _current_task(loop: asyncio.AbstractEventLoop) -> asyncio.Task | None:
86
+ """Get the current task, compatible with Python 3.6+.
87
+
88
+ Parameters
89
+ ----------
90
+ loop : asyncio.AbstractEventLoop
91
+ Event loop.
92
+
93
+ Returns
94
+ -------
95
+ asyncio.Task or None
96
+ Current task if available.
97
+ """
98
+ try:
99
+ return asyncio.current_task(loop)
100
+ except AttributeError:
101
+ # Python 3.6 compatibility
102
+ return asyncio.Task.current_task(loop) # type: ignore[attr-defined]
103
+
104
+
105
+ # ============================================================================
106
+ # Response Matching
107
+ # ============================================================================
108
+
109
+
110
+ class SocketIOResponse:
111
+ """Socket.IO event response tracker.
112
+
113
+ Tracks a pending response to an emitted event, matching incoming
114
+ events against a predicate function and resolving a future when matched.
115
+
116
+ Attributes
117
+ ----------
118
+ id : int
119
+ Unique response identifier.
120
+ match : Callable[[str, Any], bool]
121
+ Predicate function matching (event_name, data) tuples.
122
+ future : asyncio.Future
123
+ Future resolved when matching response arrives.
124
+ """
125
+
126
+ MAX_ID = 2**32
127
+ last_id = 0
128
+
129
+ def __init__(self, match: Callable[[str, Any], bool]):
130
+ """Initialize response tracker.
131
+
132
+ Parameters
133
+ ----------
134
+ match : Callable[[str, Any], bool]
135
+ Predicate function that returns True for matching events.
136
+ """
137
+ self.id = (self.__class__.last_id + 1) % self.MAX_ID
138
+ self.__class__.last_id = self.id
139
+ self.match = match
140
+ self.future: asyncio.Future = asyncio.Future()
141
+
142
+ def __eq__(self, other: object) -> bool:
143
+ """Check equality based on ID or identity.
144
+
145
+ Parameters
146
+ ----------
147
+ other : object
148
+ Object to compare.
149
+
150
+ Returns
151
+ -------
152
+ bool
153
+ True if equal.
154
+ """
155
+ if isinstance(other, SocketIOResponse):
156
+ return self is other
157
+ if isinstance(other, int):
158
+ return self.id == other
159
+ return False
160
+
161
+ def __str__(self) -> str:
162
+ """String representation."""
163
+ return f"<SocketIOResponse #{self.id}>"
164
+
165
+ __repr__ = __str__
166
+
167
+ def set(self, value: tuple[str, Any]) -> None:
168
+ """Set the future result.
169
+
170
+ Parameters
171
+ ----------
172
+ value : Tuple[str, Any]
173
+ Event name and data tuple.
174
+ """
175
+ if not self.future.done():
176
+ self.future.set_result(value)
177
+
178
+ def cancel(self, ex: Exception | None = None) -> None:
179
+ """Cancel the future or set exception.
180
+
181
+ Parameters
182
+ ----------
183
+ ex : Exception or None, optional
184
+ Exception to set on future. If None, cancels the future.
185
+ """
186
+ if not self.future.done():
187
+ if ex is None:
188
+ self.future.cancel()
189
+ else:
190
+ self.future.set_exception(ex)
191
+
192
+ @staticmethod
193
+ def match_event(
194
+ ev: str | None = None, data: dict | None = None
195
+ ) -> Callable[[str, Any], bool]:
196
+ """Create a matcher for specific event name and/or data.
197
+
198
+ Parameters
199
+ ----------
200
+ ev : str or None, optional
201
+ Event name regex pattern. If None, matches any event.
202
+ data : dict or None, optional
203
+ Dictionary of key-value pairs that must match in response data.
204
+
205
+ Returns
206
+ -------
207
+ Callable[[str, Any], bool]
208
+ Matcher function.
209
+
210
+ Examples
211
+ --------
212
+ >>> matcher = SocketIOResponse.match_event(r'^login$')
213
+ >>> matcher('login', {'success': True})
214
+ True
215
+ >>> matcher('logout', {})
216
+ False
217
+
218
+ >>> matcher = SocketIOResponse.match_event(r'^chat', {'user': 'bot'})
219
+ >>> matcher('chatMsg', {'user': 'bot', 'msg': 'hello'})
220
+ True
221
+ """
222
+
223
+ def match(ev_: str, data_: Any) -> bool:
224
+ """Match event name and data."""
225
+ # Check event name pattern
226
+ if ev is not None:
227
+ if not re.match(ev, ev_):
228
+ return False
229
+
230
+ # Check data constraints
231
+ if data is not None:
232
+ if not isinstance(data_, dict):
233
+ return False
234
+ for key, value in data.items():
235
+ if data_.get(key) != value:
236
+ return False
237
+
238
+ return True
239
+
240
+ return match
241
+
242
+
243
+ # ============================================================================
244
+ # Socket.IO Client
245
+ # ============================================================================
246
+
247
+
248
+ class SocketIO:
249
+ """Asynchronous Socket.IO v2 client.
250
+
251
+ Provides low-level Socket.IO transport with automatic reconnection,
252
+ heartbeat management, and event queue buffering. Designed for CyTube
253
+ but usable with any Socket.IO v2 server.
254
+
255
+ Attributes
256
+ ----------
257
+ websocket : websockets.client.WebSocketClientProtocol
258
+ Underlying websocket connection.
259
+ ping_interval : float
260
+ Ping interval in seconds.
261
+ ping_timeout : float
262
+ Ping timeout in seconds.
263
+ error : Exception or None
264
+ Set when connection encounters fatal error.
265
+ events : asyncio.Queue
266
+ Queue of incoming (event_name, data) tuples.
267
+ response : list of SocketIOResponse
268
+ Pending response matchers.
269
+ response_lock : asyncio.Lock
270
+ Lock protecting response list.
271
+ ping_task : asyncio.Task
272
+ Background task sending periodic pings.
273
+ recv_task : asyncio.Task
274
+ Background task receiving and parsing frames.
275
+ close_task : asyncio.Task or None
276
+ Active close operation task.
277
+ closing : asyncio.Event
278
+ Set when close initiated.
279
+ closed : asyncio.Event
280
+ Set when close completed.
281
+ ping_response : asyncio.Event
282
+ Set when pong received.
283
+ loop : asyncio.AbstractEventLoop
284
+ Event loop.
285
+ """
286
+
287
+ logger = logging.getLogger(__name__)
288
+
289
+ def __init__(
290
+ self,
291
+ websocket: "WebSocketClientProtocol",
292
+ config: dict,
293
+ qsize: int,
294
+ loop: asyncio.AbstractEventLoop,
295
+ ):
296
+ """Initialize Socket.IO client (use connect() instead).
297
+
298
+ Parameters
299
+ ----------
300
+ websocket : WebSocketClientProtocol
301
+ Connected websocket.
302
+ config : dict
303
+ Socket.IO configuration from handshake.
304
+ qsize : int
305
+ Event queue max size (0 = unbounded).
306
+ loop : asyncio.AbstractEventLoop
307
+ Event loop.
308
+ """
309
+ self.websocket = websocket
310
+ self.loop = loop
311
+ self._error: Exception | None = None
312
+ self.closing = asyncio.Event()
313
+ self.closed = asyncio.Event()
314
+ self.ping_response = asyncio.Event()
315
+ self.events: asyncio.Queue = asyncio.Queue(maxsize=qsize)
316
+ self.response: list[SocketIOResponse] = []
317
+ self.response_lock = asyncio.Lock()
318
+
319
+ # Parse ping configuration from handshake
320
+ self.ping_interval = max(1.0, config.get("pingInterval", 10000) / 1000.0)
321
+ self.ping_timeout = max(1.0, config.get("pingTimeout", 10000) / 1000.0)
322
+
323
+ # Start background tasks
324
+ self.ping_task = self.loop.create_task(self._ping())
325
+ self.recv_task = self.loop.create_task(self._recv())
326
+ self.close_task: asyncio.Task | None = None
327
+
328
+ @property
329
+ def error(self) -> Exception | None:
330
+ """Get current error state."""
331
+ return self._error
332
+
333
+ @error.setter
334
+ def error(self, ex: Exception | None) -> None:
335
+ """Set error state and initiate close if not already set.
336
+
337
+ Parameters
338
+ ----------
339
+ ex : Exception or None
340
+ Error that occurred.
341
+ """
342
+ if self._error is not None:
343
+ self.logger.debug("error already set: %r", self._error)
344
+ return
345
+
346
+ self.logger.info("set error: %r", ex)
347
+ self._error = ex
348
+
349
+ if ex is not None and self.close_task is None:
350
+ self.logger.debug("creating close task")
351
+ self.close_task = self.loop.create_task(self.close())
352
+
353
+ async def close(self) -> None:
354
+ """Close the connection gracefully.
355
+
356
+ Drains pending events, cancels background tasks, and closes
357
+ the websocket. Safe to call multiple times.
358
+ """
359
+ self.logger.debug("close() called")
360
+
361
+ # If already have close task and it's not us, wait for it
362
+ if self.close_task is not None:
363
+ if self.close_task is not _current_task(self.loop):
364
+ self.logger.debug("waiting for existing close task")
365
+ await self.close_task
366
+ return
367
+
368
+ # If already closed, return immediately
369
+ if self.closed.is_set():
370
+ self.logger.debug("already closed")
371
+ return
372
+
373
+ # If closing in progress, wait for completion
374
+ if self.closing.is_set():
375
+ self.logger.debug("already closing, waiting")
376
+ await self.closed.wait()
377
+ return
378
+
379
+ self.closing.set()
380
+
381
+ try:
382
+ # Set error if not already set
383
+ if self._error is None:
384
+ self.logger.debug("setting default error")
385
+ self._error = ConnectionClosed()
386
+
387
+ # Signal event queue consumers
388
+ self.logger.debug("queuing null event")
389
+ try:
390
+ self.events.put_nowait(None)
391
+ except asyncio.QueueFull:
392
+ pass
393
+
394
+ # Cancel all pending responses
395
+ self.logger.debug("cancelling %d pending responses", len(self.response))
396
+ for res in self.response:
397
+ res.cancel(self.error)
398
+ self.response = []
399
+
400
+ # Cancel background tasks
401
+ self.logger.debug("cancelling background tasks")
402
+ self.ping_task.cancel()
403
+ self.recv_task.cancel()
404
+
405
+ # Wait for tasks to finish
406
+ self.logger.debug("waiting for task cancellation")
407
+ await asyncio.gather(
408
+ self.ping_task, self.recv_task, return_exceptions=True
409
+ )
410
+
411
+ # Clear ping state
412
+ self.ping_response.clear()
413
+
414
+ # Close websocket
415
+ if self.websocket is not None:
416
+ self.logger.debug("closing websocket")
417
+ await self.websocket.close()
418
+
419
+ # Drain event queue
420
+ self.logger.debug("draining event queue")
421
+ while not self.events.empty():
422
+ try:
423
+ self.events.get_nowait()
424
+ self.events.task_done()
425
+ except asyncio.QueueEmpty:
426
+ break
427
+
428
+ finally:
429
+ # Clean up references
430
+ self.ping_task = None # type: ignore[assignment]
431
+ self.recv_task = None # type: ignore[assignment]
432
+ self.websocket = None # type: ignore[assignment]
433
+ self.closed.set()
434
+ self.logger.info("close complete")
435
+
436
+ async def recv(self) -> tuple[str, Any]:
437
+ """Receive next event from queue.
438
+
439
+ Returns
440
+ -------
441
+ Tuple[str, Any]
442
+ Event name and data.
443
+
444
+ Raises
445
+ ------
446
+ ConnectionClosed
447
+ If connection is closed or closing.
448
+ """
449
+ if self.error is not None:
450
+ raise self.error
451
+
452
+ ev = await self.events.get()
453
+ self.events.task_done()
454
+
455
+ if ev is None:
456
+ # Null sentinel indicates connection closed
457
+ raise self.error or ConnectionClosed()
458
+
459
+ return ev
460
+
461
+ async def emit(
462
+ self,
463
+ event: str,
464
+ data: Any,
465
+ match_response: Callable[[str, Any], bool] | None = None,
466
+ response_timeout: float | None = None,
467
+ ) -> tuple[str, Any] | None:
468
+ """Send an event, optionally waiting for response.
469
+
470
+ Parameters
471
+ ----------
472
+ event : str
473
+ Event name.
474
+ data : Any
475
+ Event data (must be JSON-serializable).
476
+ match_response : Callable[[str, Any], bool] or None, optional
477
+ Predicate function to match response event. If None, returns
478
+ immediately after send.
479
+ response_timeout : float or None, optional
480
+ Timeout in seconds for response. If None, waits indefinitely.
481
+
482
+ Returns
483
+ -------
484
+ Tuple[str, Any] or None
485
+ Matched response (event_name, data) or None if timeout.
486
+
487
+ Raises
488
+ ------
489
+ SocketIOError
490
+ If send fails.
491
+ ConnectionClosed
492
+ If connection is closed.
493
+ asyncio.CancelledError
494
+ If operation is cancelled.
495
+
496
+ Examples
497
+ --------
498
+ >>> # Fire and forget
499
+ >>> await io.emit('chatMsg', {'msg': 'Hello'})
500
+
501
+ >>> # Wait for response
502
+ >>> matcher = SocketIOResponse.match_event(r'^login$')
503
+ >>> response = await io.emit('login', {'name': 'bot'}, matcher, 3.0)
504
+ """
505
+ if self.error is not None:
506
+ raise self.error
507
+
508
+ # Encode Socket.IO frame: "42" + JSON array
509
+ frame = "42" + json.dumps([event, data])
510
+ self.logger.debug("emit: %s", frame)
511
+
512
+ release = False
513
+ response: SocketIOResponse | None = None
514
+
515
+ try:
516
+ # If waiting for response, register matcher
517
+ if match_response is not None:
518
+ await self.response_lock.acquire()
519
+ release = True
520
+ response = SocketIOResponse(match_response)
521
+ self.logger.debug("registered response %s", response)
522
+ self.response.append(response)
523
+
524
+ # Send frame
525
+ await self.websocket.send(frame)
526
+
527
+ # If not waiting for response, done
528
+ if match_response is None:
529
+ return None
530
+
531
+ # Release lock before waiting
532
+ self.response_lock.release()
533
+ release = False
534
+
535
+ # Wait for response with optional timeout
536
+ try:
537
+ if response_timeout is not None:
538
+ res = await asyncio.wait_for(response.future, response_timeout)
539
+ else:
540
+ res = await response.future
541
+
542
+ self.logger.debug("response received: %r", res)
543
+ return res
544
+
545
+ except asyncio.CancelledError:
546
+ self.logger.info("response cancelled for %s", event)
547
+ raise
548
+
549
+ except TimeoutError:
550
+ self.logger.info("response timeout for %s", event)
551
+ response.cancel()
552
+ return None
553
+
554
+ finally:
555
+ # Clean up response from list
556
+ async with self.response_lock:
557
+ try:
558
+ self.response.remove(response)
559
+ except ValueError:
560
+ pass
561
+
562
+ except asyncio.CancelledError:
563
+ self.logger.error("emit cancelled")
564
+ raise
565
+
566
+ except Exception as ex:
567
+ self.logger.error("emit error: %r", ex)
568
+ if not isinstance(ex, SocketIOError):
569
+ ex = SocketIOError(str(ex))
570
+ raise ex
571
+
572
+ finally:
573
+ if release:
574
+ self.response_lock.release()
575
+
576
+ async def _ping(self) -> None:
577
+ """Background task: Send periodic ping frames.
578
+
579
+ Sends Engine.IO ping (frame "2") at intervals and expects pong
580
+ (frame "3") within timeout. Sets error and initiates close on timeout.
581
+ """
582
+ try:
583
+ dt = 0.0
584
+ while self.error is None:
585
+ # Sleep until next ping time
586
+ await asyncio.sleep(max(self.ping_interval - dt, 0))
587
+
588
+ self.logger.debug("sending ping")
589
+ self.ping_response.clear()
590
+ start_time = time()
591
+
592
+ # Send ping frame
593
+ await self.websocket.send("2")
594
+
595
+ # Wait for pong
596
+ await asyncio.wait_for(self.ping_response.wait(), self.ping_timeout)
597
+
598
+ # Calculate actual round-trip time
599
+ dt = max(time() - start_time, 0.0)
600
+ self.logger.debug("pong received in %.3fs", dt)
601
+
602
+ except asyncio.CancelledError:
603
+ self.logger.debug("ping task cancelled")
604
+
605
+ except TimeoutError:
606
+ self.logger.error("ping timeout")
607
+ self.error = PingTimeout()
608
+
609
+ except (OSError, WebSocketConnectionClosed, InvalidState, PayloadTooBig, WebSocketProtocolError) as ex:
610
+ self.logger.error("ping error: %r", ex)
611
+ self.error = ConnectionClosed(str(ex))
612
+
613
+ async def _recv(self) -> None: # noqa: C901 (protocol complexity)
614
+ """Background task: Receive and parse Socket.IO frames.
615
+
616
+ Parses Engine.IO and Socket.IO protocol frames, handles ping/pong,
617
+ queues events, and matches responses. Sets error on protocol violations
618
+ or connection closure.
619
+ """
620
+ try:
621
+ while self.error is None:
622
+ # Receive raw frame
623
+ data = await self.websocket.recv()
624
+ self.logger.debug("recv: %s", data)
625
+
626
+ # Parse Engine.IO frame type
627
+ if data.startswith("2"):
628
+ # Ping from server - respond with pong
629
+ payload = data[1:]
630
+ self.logger.debug("ping from server: %s", payload)
631
+ await self.websocket.send("3" + payload)
632
+
633
+ elif data.startswith("3"):
634
+ # Pong from server
635
+ self.logger.debug("pong: %s", data[1:])
636
+ self.ping_response.set()
637
+
638
+ elif data.startswith("4"):
639
+ # Socket.IO packet
640
+ await self._handle_socketio_packet(data)
641
+
642
+ else:
643
+ self.logger.warning("unknown frame type: %s", data)
644
+
645
+ except asyncio.CancelledError:
646
+ self.logger.debug("recv task cancelled")
647
+ if self.error is None:
648
+ self.error = ConnectionClosed()
649
+
650
+ except (OSError, WebSocketConnectionClosed, InvalidState, PayloadTooBig, WebSocketProtocolError) as ex:
651
+ self.logger.error("recv error: %r", ex)
652
+ self.error = ConnectionClosed(str(ex))
653
+
654
+ except Exception as ex:
655
+ self.logger.exception("unexpected recv error")
656
+ self.error = ConnectionClosed(str(ex))
657
+ raise
658
+
659
+ async def _handle_socketio_packet(self, data: str) -> None:
660
+ """Parse and handle Socket.IO packet.
661
+
662
+ Parameters
663
+ ----------
664
+ data : str
665
+ Raw frame starting with "4".
666
+ """
667
+ try:
668
+ packet_type = data[1] if len(data) > 1 else ""
669
+
670
+ if packet_type == "0":
671
+ # Connect packet
672
+ self.logger.debug("socket.io connect")
673
+ event = ""
674
+ event_data = None
675
+
676
+ elif packet_type == "1":
677
+ # Disconnect packet
678
+ self.logger.debug("socket.io disconnect: %s", data[2:])
679
+ event = data[2:]
680
+ event_data = None
681
+
682
+ elif packet_type == "2":
683
+ # Event packet: "42[event, data, ...]"
684
+ payload = json.loads(data[2:])
685
+
686
+ if not isinstance(payload, list):
687
+ raise ValueError(f"event payload not array: {payload}")
688
+ if len(payload) == 0:
689
+ raise ValueError("empty event array")
690
+
691
+ # Parse event name and data
692
+ if len(payload) == 1:
693
+ event, event_data = payload[0], None
694
+ elif len(payload) == 2:
695
+ event, event_data = payload
696
+ else:
697
+ event = payload[0]
698
+ event_data = payload[1:]
699
+
700
+ else:
701
+ self.logger.warning("unknown socket.io packet type: %s", data)
702
+ return
703
+
704
+ # Queue event for consumers
705
+ self.logger.debug("event: %s %r", event, event_data)
706
+ await self.events.put((event, event_data))
707
+
708
+ # Check if any pending response matches
709
+ for response in self.response:
710
+ if response.match(event, event_data):
711
+ self.logger.debug("matched response %s", response)
712
+ response.set((event, event_data))
713
+ break
714
+
715
+ except (ValueError, json.JSONDecodeError) as ex:
716
+ self.logger.error("invalid socket.io packet %s: %r", data, ex)
717
+
718
+ @classmethod
719
+ async def _get_config(cls, url: str, get: Callable) -> dict:
720
+ """Perform Socket.IO handshake to get session ID and config.
721
+
722
+ Parameters
723
+ ----------
724
+ url : str
725
+ Base Socket.IO URL (e.g., https://cytu.be/socket.io/).
726
+ get : Callable
727
+ HTTP GET coroutine.
728
+
729
+ Returns
730
+ -------
731
+ dict
732
+ Handshake response with 'sid', 'pingInterval', 'pingTimeout'.
733
+
734
+ Raises
735
+ ------
736
+ InvalidHandshake
737
+ If handshake response is invalid.
738
+ """
739
+ handshake_url = url + "?EIO=3&transport=polling"
740
+ cls.logger.info("handshake GET: %s", handshake_url)
741
+
742
+ response = await get(handshake_url)
743
+
744
+ try:
745
+ # Parse JSON from response (may have prefix)
746
+ json_start = response.index("{")
747
+ config = json.loads(response[json_start:])
748
+
749
+ if "sid" not in config:
750
+ raise ValueError(f"no sid in response: {config}")
751
+
752
+ cls.logger.info("handshake sid=%s", config["sid"])
753
+ return config
754
+
755
+ except (ValueError, json.JSONDecodeError) as ex:
756
+ raise InvalidHandshake(f"invalid handshake response: {response}") from ex
757
+
758
+ @classmethod
759
+ async def _connect(
760
+ cls,
761
+ url: str,
762
+ qsize: int,
763
+ loop: asyncio.AbstractEventLoop,
764
+ get: Callable,
765
+ connect: Callable,
766
+ ) -> "SocketIO":
767
+ """Establish Socket.IO connection (internal).
768
+
769
+ Performs handshake, upgrades to websocket, and completes probe exchange.
770
+
771
+ Parameters
772
+ ----------
773
+ url : str
774
+ Base Socket.IO URL.
775
+ qsize : int
776
+ Event queue size.
777
+ loop : asyncio.AbstractEventLoop
778
+ Event loop.
779
+ get : Callable
780
+ HTTP GET coroutine.
781
+ connect : Callable
782
+ Websocket connect coroutine.
783
+
784
+ Returns
785
+ -------
786
+ SocketIO
787
+ Connected client.
788
+
789
+ Raises
790
+ ------
791
+ InvalidHandshake
792
+ If handshake or upgrade fails.
793
+ """
794
+ # Get session ID from handshake
795
+ config = await cls._get_config(url, get)
796
+ sid = config["sid"]
797
+
798
+ # Construct websocket URL
799
+ ws_url = url.replace("http", "ws", 1) + f"?EIO=3&transport=websocket&sid={sid}"
800
+ cls.logger.info("websocket connect: %s", ws_url)
801
+
802
+ # Connect websocket
803
+ websocket = await connect(ws_url)
804
+
805
+ try:
806
+ # Send probe
807
+ cls.logger.debug("sending probe")
808
+ await websocket.send("2probe")
809
+
810
+ # Expect probe response
811
+ response = await websocket.recv()
812
+ if response != "3probe":
813
+ raise InvalidHandshake(
814
+ f'invalid probe response: "{response}" != "3probe"'
815
+ )
816
+
817
+ # Send upgrade
818
+ cls.logger.debug("sending upgrade")
819
+ await websocket.send("5")
820
+
821
+ # Create client instance
822
+ return SocketIO(websocket, config, qsize, loop)
823
+
824
+ except Exception:
825
+ await websocket.close()
826
+ raise
827
+
828
+ @classmethod
829
+ async def connect(
830
+ cls,
831
+ url: str,
832
+ retry: int = -1,
833
+ retry_delay: float = 1.0,
834
+ qsize: int = 0,
835
+ loop: asyncio.AbstractEventLoop | None = None,
836
+ get: Callable = default_get,
837
+ connect: Callable = websockets.connect,
838
+ ) -> "SocketIO":
839
+ """Create a Socket.IO connection with retry logic.
840
+
841
+ Parameters
842
+ ----------
843
+ url : str
844
+ Base Socket.IO URL (e.g., https://cytu.be/socket.io/).
845
+ retry : int, optional
846
+ Maximum retry attempts (-1 = infinite). Default: -1.
847
+ retry_delay : float, optional
848
+ Delay between retries in seconds. Default: 1.0.
849
+ qsize : int, optional
850
+ Event queue max size (0 = unbounded). Default: 0.
851
+ loop : asyncio.AbstractEventLoop or None, optional
852
+ Event loop to use. If None, uses current loop.
853
+ get : Callable, optional
854
+ HTTP GET coroutine. Default: default_get (aiohttp).
855
+ connect : Callable, optional
856
+ Websocket connect coroutine. Default: websockets.connect.
857
+
858
+ Returns
859
+ -------
860
+ SocketIO
861
+ Connected client ready for emit/recv.
862
+
863
+ Raises
864
+ ------
865
+ ConnectionFailed
866
+ If all retry attempts fail.
867
+ asyncio.CancelledError
868
+ If connection attempt is cancelled.
869
+
870
+ Examples
871
+ --------
872
+ >>> io = await SocketIO.connect('https://cytu.be/socket.io/')
873
+ >>> await io.emit('joinChannel', {'name': 'test'})
874
+ >>> event, data = await io.recv()
875
+ >>> await io.close()
876
+ """
877
+ loop = loop or asyncio.get_event_loop()
878
+ attempt = 0
879
+
880
+ while True:
881
+ try:
882
+ io = await cls._connect(url, qsize, loop, get, connect)
883
+ cls.logger.info("connected successfully")
884
+ return io
885
+
886
+ except asyncio.CancelledError:
887
+ cls.logger.error(
888
+ "connect(%s) attempt %d/%d: cancelled", url, attempt + 1, retry + 1
889
+ )
890
+ raise
891
+
892
+ except Exception as ex:
893
+ cls.logger.error(
894
+ "connect(%s) attempt %d/%d: %r", url, attempt + 1, retry + 1, ex
895
+ )
896
+
897
+ # Check if exceeded retry limit
898
+ if attempt == retry:
899
+ raise ConnectionFailed(str(ex)) from ex
900
+
901
+ # Increment attempt and wait before retry
902
+ attempt += 1
903
+ await asyncio.sleep(retry_delay)