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