kryten-robot 0.6.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kryten/CONFIG.md +504 -0
- kryten/__init__.py +127 -0
- kryten/__main__.py +882 -0
- kryten/application_state.py +98 -0
- kryten/audit_logger.py +237 -0
- kryten/command_subscriber.py +341 -0
- kryten/config.example.json +35 -0
- kryten/config.py +510 -0
- kryten/connection_watchdog.py +209 -0
- kryten/correlation.py +241 -0
- kryten/cytube_connector.py +754 -0
- kryten/cytube_event_sender.py +1476 -0
- kryten/errors.py +161 -0
- kryten/event_publisher.py +416 -0
- kryten/health_monitor.py +482 -0
- kryten/lifecycle_events.py +274 -0
- kryten/logging_config.py +314 -0
- kryten/nats_client.py +468 -0
- kryten/raw_event.py +165 -0
- kryten/service_registry.py +371 -0
- kryten/shutdown_handler.py +383 -0
- kryten/socket_io.py +903 -0
- kryten/state_manager.py +711 -0
- kryten/state_query_handler.py +698 -0
- kryten/state_updater.py +314 -0
- kryten/stats_tracker.py +108 -0
- kryten/subject_builder.py +330 -0
- kryten_robot-0.6.9.dist-info/METADATA +469 -0
- kryten_robot-0.6.9.dist-info/RECORD +32 -0
- kryten_robot-0.6.9.dist-info/WHEEL +4 -0
- kryten_robot-0.6.9.dist-info/entry_points.txt +3 -0
- kryten_robot-0.6.9.dist-info/licenses/LICENSE +21 -0
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)
|