butterbot-python 3.1.0.dev1__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.
Files changed (79) hide show
  1. butterbot/__init__.py +15 -0
  2. butterbot/app/__init__.py +41 -0
  3. butterbot/app/bot_app.py +374 -0
  4. butterbot/app/config.py +156 -0
  5. butterbot/app/source_manager.py +367 -0
  6. butterbot/core/__init__.py +46 -0
  7. butterbot/core/api/__init__.py +9 -0
  8. butterbot/core/api/base_api.py +32 -0
  9. butterbot/core/context/README.md +1 -0
  10. butterbot/core/context/__init__.py +9 -0
  11. butterbot/core/context/api_registry.py +109 -0
  12. butterbot/core/context/app_context.py +51 -0
  13. butterbot/core/context/config_provider.py +14 -0
  14. butterbot/core/data/__init__.py +15 -0
  15. butterbot/core/data/base_data.py +29 -0
  16. butterbot/core/data/base_model.py +197 -0
  17. butterbot/core/event/README.md +1 -0
  18. butterbot/core/event/__init__.py +10 -0
  19. butterbot/core/event/event.py +27 -0
  20. butterbot/core/event/event_bus.py +276 -0
  21. butterbot/core/event/subscriber.py +130 -0
  22. butterbot/core/exceptions.py +90 -0
  23. butterbot/core/filter/__init__.py +11 -0
  24. butterbot/core/filter/base_filter.py +74 -0
  25. butterbot/core/source/README.md +1 -0
  26. butterbot/core/source/__init__.py +9 -0
  27. butterbot/core/source/base_source.py +134 -0
  28. butterbot/core/types/__init__.py +9 -0
  29. butterbot/core/types/base_type.py +74 -0
  30. butterbot/sources/__init__.py +1 -0
  31. butterbot/sources/bilibili/README.md +10 -0
  32. butterbot/sources/bilibili/__init__.py +20 -0
  33. butterbot/sources/bilibili/api/__init__.py +5 -0
  34. butterbot/sources/bilibili/api/bili_api.py +123 -0
  35. butterbot/sources/bilibili/data/__init__.py +52 -0
  36. butterbot/sources/bilibili/data/danmaku_gift_data.py +135 -0
  37. butterbot/sources/bilibili/data/danmaku_guard_data.py +54 -0
  38. butterbot/sources/bilibili/data/danmaku_msg_data.py +71 -0
  39. butterbot/sources/bilibili/data/dto/__init__.py +59 -0
  40. butterbot/sources/bilibili/data/dto/danmaku_gift_dto.py +193 -0
  41. butterbot/sources/bilibili/data/dto/danmaku_guard_buy_dto.py +54 -0
  42. butterbot/sources/bilibili/data/dto/danmaku_msg_dto.py +123 -0
  43. butterbot/sources/bilibili/data/dto/dynamic_dto.py +276 -0
  44. butterbot/sources/bilibili/data/dto/live_room_dto.py +169 -0
  45. butterbot/sources/bilibili/data/dto/video_part_dto.py +18 -0
  46. butterbot/sources/bilibili/data/dynamic_data.py +362 -0
  47. butterbot/sources/bilibili/data/live_room_data.py +162 -0
  48. butterbot/sources/bilibili/data/video_part.py +46 -0
  49. butterbot/sources/bilibili/source/__init__.py +15 -0
  50. butterbot/sources/bilibili/source/base_polling_source.py +130 -0
  51. butterbot/sources/bilibili/source/bili_danmaku_source.py +230 -0
  52. butterbot/sources/bilibili/source/bili_dynamic_source.py +135 -0
  53. butterbot/sources/bilibili/source/bili_live_source.py +137 -0
  54. butterbot/sources/bilibili/types/__init__.py +11 -0
  55. butterbot/sources/bilibili/types/bili_type.py +32 -0
  56. butterbot/sources/napcat/README.md +10 -0
  57. butterbot/sources/napcat/__init__.py +20 -0
  58. butterbot/sources/napcat/api/__init__.py +3 -0
  59. butterbot/sources/napcat/api/napcat_api.py +316 -0
  60. butterbot/sources/napcat/data/__init__.py +179 -0
  61. butterbot/sources/napcat/data/event_data.py +441 -0
  62. butterbot/sources/napcat/data/segment_data.py +432 -0
  63. butterbot/sources/napcat/events.py +91 -0
  64. butterbot/sources/napcat/filters/__init__.py +19 -0
  65. butterbot/sources/napcat/filters/filters.py +202 -0
  66. butterbot/sources/napcat/source/__init__.py +5 -0
  67. butterbot/sources/napcat/source/napcat_source.py +58 -0
  68. butterbot/sources/napcat/types/__init__.py +5 -0
  69. butterbot/sources/napcat/types/napcat_type.py +61 -0
  70. butterbot/utils/README.md +15 -0
  71. butterbot/utils/__init__.py +11 -0
  72. butterbot/utils/data_pair.py +27 -0
  73. butterbot/utils/logging_config.py +521 -0
  74. butterbot/utils/terminal.py +308 -0
  75. butterbot/utils/websocket.py +1270 -0
  76. butterbot_python-3.1.0.dev1.dist-info/METADATA +769 -0
  77. butterbot_python-3.1.0.dev1.dist-info/RECORD +79 -0
  78. butterbot_python-3.1.0.dev1.dist-info/WHEEL +4 -0
  79. butterbot_python-3.1.0.dev1.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,1270 @@
1
+ # @Author Fish.zh@outlook.com
2
+ # @Version 1.1
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import random
7
+ import threading
8
+ import time
9
+ import uuid
10
+ from asyncio import QueueFull
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Any, NewType
14
+
15
+ import aiohttp
16
+ from aiohttp import ClientSession, ClientWebSocketResponse, WSMsgType
17
+
18
+ ListenerId = NewType("ListenerId", str)
19
+
20
+
21
+ class MessageType(Enum):
22
+ """WebSocket 消息类型枚举"""
23
+
24
+ Text = "text"
25
+ Binary = "binary"
26
+ Ping = "ping"
27
+ Pong = "pong"
28
+ Close = "close"
29
+ Error = "error"
30
+ NONE = "none"
31
+
32
+
33
+ class WebSocketState(Enum):
34
+ """WebSocket 连接状态枚举"""
35
+
36
+ Disconnected = "disconnected"
37
+ Connecting = "connecting"
38
+ CONNECTED = "connected"
39
+ Rconnecting = "reconnecting"
40
+ Closing = "closing"
41
+ Closed = "closed"
42
+
43
+
44
+ class WebSocketError(Exception):
45
+ """WebSocket 基础异常"""
46
+
47
+ pass
48
+
49
+
50
+ class ConnectionError(WebSocketError):
51
+ """连接相关异常"""
52
+
53
+ pass
54
+
55
+
56
+ class ListenerEvictedError(WebSocketError):
57
+ """监听器被驱逐异常"""
58
+
59
+ pass
60
+
61
+
62
+ class ListenerClosedError(WebSocketError):
63
+ """监听器已关闭异常"""
64
+
65
+ pass
66
+
67
+
68
+ @dataclass
69
+ class WebSocketConfig:
70
+ """WebSocket 配置类
71
+
72
+ 包含 WebSocket 连接的所有配置参数,包括连接超时、重连策略、
73
+ 压缩设置、监听器限制等。
74
+
75
+ Attributes:
76
+ uri: WebSocket 服务器地址,必须以 ws:// 或 wss:// 开头
77
+ headers: 连接请求头字典
78
+ heartbeat: 心跳间隔(秒),默认 30.0
79
+ receive_timeout: 接收消息超时时间(秒),默认 60.0
80
+ reconnect_attempts: 最大重连次数,默认 5,0 表示无限重连
81
+ connect_timeout: 连接超时时间(秒),默认 20.0
82
+ send_queue_size: 发送队列大小,默认 1024
83
+ session_timeout: 会话总超时时间(秒),默认 300.0
84
+ backoff_base: 退避基数(秒),默认 1.0
85
+ backoff_max: 最大退避时间(秒),默认 600.0
86
+ jitter_factor: 抖动因子,默认 5.0
87
+ compression: 压缩级别 0-15,默认 15,0 表示不压缩
88
+ verify_ssl: 是否验证 SSL 证书,默认 True
89
+ max_listeners: 最大监听器数量,默认 1000
90
+ listener_buffer_size: 每个监听器的缓冲区大小,默认 100
91
+ """
92
+
93
+ uri: str
94
+ headers: dict[str, str] = field(default_factory=dict)
95
+ heartbeat: float = 30.0
96
+ receive_timeout: float = 60.0
97
+ reconnect_attempts: int = 5
98
+ connect_timeout: float = 20.0
99
+ send_queue_size: int = 1024
100
+ session_timeout: float = 300.0
101
+ backoff_base: float = 1.0
102
+ backoff_max: float = 600.0
103
+ jitter_factor: float = 5
104
+ compression: int = 15
105
+ verify_ssl: bool = True
106
+ max_listeners: int = 1000
107
+ listener_buffer_size: int = 100
108
+
109
+ def __post_init__(self):
110
+ """配置验证
111
+
112
+ Raises:
113
+ ValueError: URI 格式不正确或配置参数无效时抛出
114
+ """
115
+ if not self.uri.startswith(("ws://", "wss://")):
116
+ raise ValueError("URI must start with ws:// or wss://")
117
+ if self.heartbeat <= 0:
118
+ raise ValueError("Heartbeat must be positive")
119
+ if self.reconnect_attempts < 0:
120
+ raise ValueError("Reconnect attempts cannot be negative")
121
+
122
+
123
+ _CLOSE_SENTINEL = object()
124
+ """放入监听器队列的关闭哨兵.
125
+
126
+ ``close()`` 需要唤醒已经阻塞在 ``queue.get()`` 上的消费者——只置标志位的话,
127
+ 等待者会永远挂在那里等一条永不到来的消息。哨兵被 ``get()`` 识别后转换为
128
+ ``ListenerClosedError``,因此不会被误当成业务消息。
129
+ """
130
+
131
+
132
+ class WebSocketListener:
133
+ """WebSocket 消息监听器
134
+
135
+ 为每个订阅者提供独立的消息队列,支持异步迭代和超时控制。
136
+ 当队列满时自动丢弃最旧的消息。
137
+
138
+ Attributes:
139
+ id: 监听器唯一标识符
140
+ queue: 消息队列
141
+ created_at: 创建时间戳
142
+ """
143
+
144
+ def __init__(self, buffer_size: int = 100):
145
+ """初始化监听器
146
+
147
+ Args:
148
+ buffer_size: 消息队列缓冲区大小,默认 100
149
+ """
150
+ self.id = ListenerId(str(uuid.uuid4()))
151
+ self.queue = asyncio.Queue(maxsize=buffer_size)
152
+ self.created_at = time.time()
153
+ self._closed = False
154
+
155
+ async def put(self, message: Any, msg_type: MessageType) -> bool:
156
+ """放入消息到队列
157
+
158
+ 如果队列已满,自动丢弃最旧的消息以腾出空间。
159
+
160
+ Args:
161
+ message: 消息内容
162
+ msg_type: 消息类型
163
+
164
+ Returns:
165
+ bool: 放入成功返回 True,失败返回 False
166
+ """
167
+ if self._closed:
168
+ return False
169
+
170
+ try:
171
+ self.queue.put_nowait((message, msg_type))
172
+ return True
173
+ except QueueFull:
174
+ # 队列满时丢弃最旧的消息
175
+ try:
176
+ self.queue.get_nowait() # 丢弃一个旧消息
177
+ self.queue.put_nowait((message, msg_type)) # 放入新消息
178
+ return True
179
+ except QueueFull:
180
+ return False
181
+
182
+ async def get(self, timeout: float | None = None) -> tuple[Any, MessageType]:
183
+ """获取消息(阻塞)
184
+
185
+ Args:
186
+ timeout: 超时时间(秒),None 表示无限等待
187
+
188
+ Returns:
189
+ Tuple[Any, MessageType]: 消息内容和类型
190
+
191
+ Raises:
192
+ ListenerClosedError: 监听器已关闭时抛出
193
+ asyncio.TimeoutError: 超时时抛出
194
+ asyncio.CancelledError: 任务被取消时抛出
195
+ """
196
+ if self._closed:
197
+ raise ListenerClosedError("Listener %s is closed" % self.id)
198
+
199
+ try:
200
+ if timeout is None:
201
+ item = await self.queue.get()
202
+ else:
203
+ item = await asyncio.wait_for(self.queue.get(), timeout=timeout)
204
+ except asyncio.CancelledError:
205
+ raise
206
+ except Exception as e:
207
+ if self._closed:
208
+ raise ListenerClosedError("Listener %s is closed" % self.id) from e
209
+ raise
210
+
211
+ if item[0] is _CLOSE_SENTINEL:
212
+ # 等待期间监听器被关闭:把哨兵放回去,让其他等待者也能醒
213
+ self._requeue_close_sentinel()
214
+ raise ListenerClosedError("Listener %s is closed" % self.id)
215
+ return item
216
+
217
+ def get_nowait(self) -> tuple[Any, MessageType] | None:
218
+ """非阻塞获取消息
219
+
220
+ Returns:
221
+ Optional[Tuple[Any, MessageType]]: 消息内容和类型,无数据时返回 None
222
+
223
+ Raises:
224
+ ListenerClosedError: 监听器已关闭时抛出
225
+ """
226
+ if self._closed:
227
+ raise ListenerClosedError("Listener %s is closed" % self.id)
228
+
229
+ try:
230
+ item = self.queue.get_nowait()
231
+ except asyncio.QueueEmpty:
232
+ return None
233
+
234
+ if item[0] is _CLOSE_SENTINEL:
235
+ self._requeue_close_sentinel()
236
+ raise ListenerClosedError("Listener %s is closed" % self.id)
237
+ return item
238
+
239
+ def _requeue_close_sentinel(self) -> None:
240
+ """把关闭哨兵放回队列,使后续/并发的等待者同样会醒来."""
241
+ try:
242
+ self.queue.put_nowait((_CLOSE_SENTINEL, MessageType.Close))
243
+ except QueueFull:
244
+ pass
245
+
246
+ def close(self) -> None:
247
+ """关闭监听器,清空队列并唤醒所有等待者
248
+
249
+ 幂等。清空积压消息后放入关闭哨兵——只置 ``_closed`` 标志的话,
250
+ 已经阻塞在 ``get()`` 上的消费者不会被唤醒,会一直挂着。
251
+ """
252
+ if self._closed:
253
+ return
254
+ self._closed = True
255
+ # 清空队列以释放等待的消费者
256
+ while not self.queue.empty():
257
+ try:
258
+ self.queue.get_nowait()
259
+ except asyncio.QueueEmpty:
260
+ break
261
+ self._requeue_close_sentinel()
262
+
263
+ @property
264
+ def is_closed(self) -> bool:
265
+ """检查监听器是否已关闭
266
+
267
+ Returns:
268
+ bool: 已关闭返回 True,否则返回 False
269
+ """
270
+ return self._closed
271
+
272
+ def __aiter__(self):
273
+ """返回异步迭代器"""
274
+ return self
275
+
276
+ async def __anext__(self):
277
+ """异步迭代下一个消息
278
+
279
+ Returns:
280
+ Tuple[Any, MessageType]: 消息内容和类型
281
+
282
+ Raises:
283
+ StopAsyncIteration: 监听器关闭时抛出
284
+ """
285
+ try:
286
+ return await self.get()
287
+ except ListenerClosedError:
288
+ raise StopAsyncIteration
289
+
290
+
291
+ class AioHttpWebSocketConnection:
292
+ """基于 aiohttp 的 WebSocket 连接管理
293
+
294
+ 负责底层的 WebSocket 连接建立、消息收发和资源清理。
295
+ 自动处理压缩协商失败等边界情况。
296
+
297
+ Attributes:
298
+ config: WebSocket 配置对象
299
+ logger: 日志记录器
300
+ websocket: aiohttp WebSocket 响应对象
301
+ session: aiohttp 客户端会话
302
+ state: 当前连接状态
303
+ metrics: 连接指标统计
304
+ """
305
+
306
+ def __init__(self, config: WebSocketConfig, logger: logging.Logger):
307
+ """初始化连接管理器
308
+
309
+ Args:
310
+ config: WebSocket 配置对象
311
+ logger: 日志记录器实例
312
+ """
313
+ self.config = config
314
+ self.logger = logger
315
+
316
+ self.websocket: ClientWebSocketResponse | None = None
317
+ self.session: ClientSession | None = None
318
+ self.state = WebSocketState.Disconnected
319
+
320
+ # 指标
321
+ self.metrics = {
322
+ "connection_attempts": 0,
323
+ "successful_connections": 0,
324
+ "failed_connections": 0,
325
+ "messages_sent": 0,
326
+ "messages_received": 0,
327
+ "bytes_sent": 0,
328
+ "bytes_received": 0,
329
+ "errors": 0,
330
+ }
331
+
332
+ async def connect(self) -> None:
333
+ """建立 WebSocket 连接
334
+
335
+ 如果已经处于连接中或已连接状态,直接返回。
336
+ 支持自动处理压缩协商失败的情况。
337
+
338
+ Raises:
339
+ ConnectionError: 连接失败时抛出
340
+ """
341
+ if self.state in [WebSocketState.Connecting, WebSocketState.CONNECTED]:
342
+ return
343
+
344
+ self.state = WebSocketState.Connecting
345
+ self.metrics["connection_attempts"] += 1
346
+ self.logger.info("Connecting to %s", self.config.uri)
347
+
348
+ try:
349
+ # 创建 aiohttp 会话
350
+ timeout = aiohttp.ClientTimeout(
351
+ total=self.config.session_timeout,
352
+ connect=self.config.connect_timeout,
353
+ sock_connect=self.config.connect_timeout,
354
+ sock_read=self.config.receive_timeout,
355
+ )
356
+
357
+ self.session = ClientSession(timeout=timeout)
358
+
359
+ # 建立 WebSocket 连接
360
+ self.websocket = await self.session.ws_connect(
361
+ self.config.uri,
362
+ headers=self.config.headers,
363
+ heartbeat=self.config.heartbeat,
364
+ compress=self.config.compression,
365
+ verify_ssl=self.config.verify_ssl,
366
+ )
367
+
368
+ self.state = WebSocketState.CONNECTED
369
+ self.metrics["successful_connections"] += 1
370
+ self.logger.info("Connected to %s", self.config.uri)
371
+
372
+ except Exception as e:
373
+ self.state = WebSocketState.Disconnected
374
+ self.metrics["failed_connections"] += 1
375
+
376
+ # 清理资源
377
+ if self.session:
378
+ await self.session.close()
379
+ self.session = None
380
+
381
+ self.logger.error("Connection failed: %s, error: %s", self.config.uri, e)
382
+ if "wbits=" in str(e):
383
+ self.logger.error("Detected zlib wbits compression error")
384
+ if self.config.compression > 0:
385
+ self.logger.info("Enabled compression reconnection")
386
+ self.config.compression = 15
387
+ else:
388
+ self.logger.info("Disable compression reconnection")
389
+ self.config.compression = 0
390
+
391
+ raise ConnectionError("Connection failed: %s" % e) from e
392
+
393
+ async def close(self) -> None:
394
+ """关闭 WebSocket 连接并清理资源"""
395
+ if self.state == WebSocketState.Closed:
396
+ return
397
+
398
+ self.state = WebSocketState.Closing
399
+ self.logger.debug("Closing connection")
400
+
401
+ try:
402
+ if self.websocket:
403
+ await self.websocket.close()
404
+ except Exception as e:
405
+ self.logger.error("WebSocket close error: %s", e)
406
+
407
+ try:
408
+ if self.session:
409
+ await self.session.close()
410
+ except Exception as e:
411
+ self.logger.error("Session close error: %s", e)
412
+ finally:
413
+ self.websocket = None
414
+ self.session = None
415
+ self.state = WebSocketState.Closed
416
+ self.logger.info("Connection closed")
417
+
418
+ async def send(self, message: str | bytes | dict) -> None:
419
+ """发送消息
420
+
421
+ Args:
422
+ message: 要发送的消息,支持字符串、字节或字典(自动转为 JSON)
423
+
424
+ Raises:
425
+ ConnectionError: 未连接时抛出
426
+ Exception: 发送失败时抛出
427
+ """
428
+ if self.state != WebSocketState.CONNECTED or not self.websocket:
429
+ raise ConnectionError("Not connected")
430
+
431
+ try:
432
+ # 格式化消息
433
+ if isinstance(message, dict):
434
+ formatted = json.dumps(message)
435
+ elif isinstance(message, bytes):
436
+ formatted = message
437
+ else:
438
+ formatted = str(message)
439
+
440
+ # 发送消息
441
+ if isinstance(formatted, str):
442
+ await self.websocket.send_str(formatted)
443
+ else:
444
+ await self.websocket.send_bytes(formatted)
445
+
446
+ self.metrics["messages_sent"] += 1
447
+ self.metrics["bytes_sent"] += len(formatted)
448
+
449
+ except Exception as e:
450
+ self.metrics["errors"] += 1
451
+ self.logger.error("Send error: %s", e)
452
+ raise
453
+
454
+ async def receive(self) -> tuple[Any, MessageType]:
455
+ """接收消息
456
+
457
+ Returns:
458
+ Tuple[Any, MessageType]: 消息数据和类型
459
+
460
+ Raises:
461
+ ConnectionError: 未连接时抛出
462
+ asyncio.TimeoutError: 接收超时时抛出
463
+ Exception: 接收失败时抛出
464
+ """
465
+ if self.state != WebSocketState.CONNECTED or not self.websocket:
466
+ raise ConnectionError("Not connected")
467
+
468
+ try:
469
+ # 接收消息
470
+ msg = await self.websocket.receive(timeout=self.config.receive_timeout)
471
+
472
+ # 处理不同类型的消息
473
+ if msg.type == WSMsgType.TEXT:
474
+ self.metrics["messages_received"] += 1
475
+ self.metrics["bytes_received"] += len(msg.data)
476
+ return msg.data, MessageType.Text
477
+
478
+ elif msg.type == WSMsgType.BINARY:
479
+ self.metrics["messages_received"] += 1
480
+ self.metrics["bytes_received"] += len(msg.data)
481
+ return msg.data, MessageType.Binary
482
+
483
+ elif msg.type == WSMsgType.PING:
484
+ return msg.data, MessageType.Ping
485
+
486
+ elif msg.type == WSMsgType.PONG:
487
+ return msg.data, MessageType.Pong
488
+
489
+ elif msg.type == WSMsgType.CLOSE:
490
+ return msg.data, MessageType.Close
491
+
492
+ elif msg.type == WSMsgType.ERROR:
493
+ self.metrics["errors"] += 1
494
+ self.logger.error("WebSocket error: %s", msg.data)
495
+ return msg.data, MessageType.Error
496
+
497
+ else:
498
+ # 未知消息类型
499
+ return msg.data, MessageType.NONE
500
+
501
+ except TimeoutError:
502
+ raise
503
+ except Exception as e:
504
+ self.metrics["errors"] += 1
505
+ self.logger.error("Receive error: %s", e)
506
+ raise
507
+
508
+ def is_connected(self) -> bool:
509
+ """检查连接是否处于活动状态
510
+
511
+ Returns:
512
+ bool: 已连接且 WebSocket 未关闭返回 True
513
+ """
514
+ return (
515
+ self.state == WebSocketState.CONNECTED
516
+ and self.websocket is not None
517
+ and not self.websocket.closed
518
+ )
519
+
520
+
521
+ class ReconnectionStrategy:
522
+ """重连策略管理
523
+
524
+ 实现指数退避 + 随机抖动的重连策略。
525
+
526
+ Attributes:
527
+ config: WebSocket 配置对象
528
+ attempt_count: 当前重连尝试次数
529
+ last_attempt_time: 上次尝试时间戳
530
+ """
531
+
532
+ def __init__(self, config: WebSocketConfig):
533
+ """初始化重连策略
534
+
535
+ Args:
536
+ config: WebSocket 配置对象
537
+ """
538
+ self.config = config
539
+ self.attempt_count = 0
540
+ self.last_attempt_time = 0.0
541
+
542
+ def should_reconnect(self) -> bool:
543
+ """检查是否应该继续重连
544
+
545
+ Returns:
546
+ bool: 未达到最大重连次数返回 True,``reconnect_attempts=0`` 表示无限重连
547
+ """
548
+ if self.config.reconnect_attempts == 0:
549
+ # 配置文档一直声明 0 = 无限重连,而原实现是 `0 < 0` → 立即放弃,
550
+ # 恰好把"永不停止"配成了"一次都不试"。
551
+ return True
552
+ return self.attempt_count < self.config.reconnect_attempts
553
+
554
+ def get_delay(self) -> float:
555
+ """计算下次重连的延迟时间
556
+
557
+ 使用指数退避算法:delay = min(base * 2^(n-1), max) + jitter
558
+
559
+ Returns:
560
+ float: 延迟时间(秒)
561
+ """
562
+ if self.attempt_count == 0:
563
+ return 0.0
564
+
565
+ # 指数退避
566
+ delay = min(
567
+ self.config.backoff_base * (2 ** (self.attempt_count - 1)),
568
+ self.config.backoff_max,
569
+ )
570
+
571
+ # 随机抖动
572
+ jitter = random.uniform(0, self.config.jitter_factor)
573
+ return delay + jitter
574
+
575
+ def on_attempt(self) -> None:
576
+ """记录一次重连尝试"""
577
+ self.attempt_count += 1
578
+ self.last_attempt_time = time.time()
579
+
580
+ def on_success(self) -> None:
581
+ """重连成功,重置计数器"""
582
+ self.attempt_count = 0
583
+
584
+ def get_state(self) -> dict[str, Any]:
585
+ """获取当前重连状态
586
+
587
+ Returns:
588
+ Dict[str, Any]: 包含尝试次数、上次尝试时间、最大次数的字典
589
+ """
590
+ return {
591
+ "attempt_count": self.attempt_count,
592
+ "last_attempt_time": self.last_attempt_time,
593
+ "max_attempts": self.config.reconnect_attempts,
594
+ }
595
+
596
+
597
+ class AsyncWebSocketClient:
598
+ """异步 WebSocket 客户端 - 使用监听器模式
599
+
600
+ 高性能异步 WebSocket 客户端,支持多监听器、自动重连、
601
+ 消息广播和完善的指标统计。
602
+
603
+ Attributes:
604
+ config: WebSocket 配置对象
605
+ logger: 日志记录器
606
+ connection: 底层连接管理器
607
+ reconnection: 重连策略管理器
608
+ _listeners: 监听器字典
609
+ _running: 运行状态标志
610
+ """
611
+
612
+ def __init__(
613
+ self,
614
+ uri: str,
615
+ logger: logging.Logger | None = None,
616
+ headers: dict[str, str] | None = None,
617
+ heartbeat: float = 30.0,
618
+ receive_timeout: float = 60.0,
619
+ reconnect_attempts: int = 5,
620
+ connect_timeout: float = 20.0,
621
+ send_queue_size: int = 1024,
622
+ session_timeout: float = 300.0,
623
+ backoff_base: float = 1.0,
624
+ backoff_max: float = 60.0,
625
+ jitter_factor: float = 0.5,
626
+ compression: int = 15,
627
+ verify_ssl: bool = True,
628
+ max_listeners: int = 1000,
629
+ listener_buffer_size: int = 100,
630
+ ):
631
+ """初始化异步 WebSocket 客户端
632
+
633
+ Args:
634
+ uri: WebSocket 服务器地址
635
+ logger: 可选的日志记录器,默认使用模块日志
636
+ headers: 可选的连接请求头
637
+ heartbeat: 心跳间隔(秒),默认 30.0
638
+ receive_timeout: 接收超时(秒),默认 60.0
639
+ reconnect_attempts: 最大重连次数,默认 5
640
+ connect_timeout: 连接超时(秒),默认 20.0
641
+ send_queue_size: 发送队列大小,默认 1024
642
+ session_timeout: 会话超时(秒),默认 300.0
643
+ backoff_base: 退避基数(秒),默认 1.0
644
+ backoff_max: 最大退避(秒),默认 60.0
645
+ jitter_factor: 抖动因子,默认 0.5
646
+ compression: 压缩级别,默认 15
647
+ verify_ssl: 验证 SSL,默认 True
648
+ max_listeners: 最大监听器数,默认 1000
649
+ listener_buffer_size: 监听器缓冲区大小,默认 100
650
+ """
651
+ # 创建配置
652
+ self.config = WebSocketConfig(
653
+ uri=uri,
654
+ headers=headers or {},
655
+ heartbeat=heartbeat,
656
+ receive_timeout=receive_timeout,
657
+ reconnect_attempts=reconnect_attempts,
658
+ connect_timeout=connect_timeout,
659
+ send_queue_size=send_queue_size,
660
+ session_timeout=session_timeout,
661
+ backoff_base=backoff_base,
662
+ backoff_max=backoff_max,
663
+ jitter_factor=jitter_factor,
664
+ compression=compression,
665
+ verify_ssl=verify_ssl,
666
+ max_listeners=max_listeners,
667
+ listener_buffer_size=listener_buffer_size,
668
+ )
669
+
670
+ # 设置日志
671
+ self.logger = logger or logging.getLogger(__name__)
672
+
673
+ # 核心组件
674
+ self.connection = AioHttpWebSocketConnection(self.config, self.logger)
675
+ self.reconnection = ReconnectionStrategy(self.config)
676
+
677
+ # 监听器管理
678
+ self._listeners: dict[ListenerId, WebSocketListener] = {}
679
+ self._listeners_lock = threading.Lock()
680
+
681
+ # 状态控制
682
+ self._running = False
683
+ self._main_task: asyncio.Task | None = None
684
+
685
+ # 发送队列
686
+ self._send_queue = asyncio.Queue(maxsize=self.config.send_queue_size)
687
+
688
+ @property
689
+ def running(self) -> bool:
690
+ """检查客户端是否正在运行
691
+
692
+ Returns:
693
+ bool: 正在运行返回 True
694
+ """
695
+ return self._running
696
+
697
+ async def __aenter__(self):
698
+ """异步上下文管理器入口
699
+
700
+ Returns:
701
+ AsyncWebSocketClient: 客户端实例
702
+ """
703
+ await self.start()
704
+ return self
705
+
706
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
707
+ """异步上下文管理器出口
708
+
709
+ Args:
710
+ exc_type: 异常类型
711
+ exc_val: 异常值
712
+ exc_tb: 异常追踪
713
+ """
714
+ await self.stop()
715
+
716
+ async def start(self) -> None:
717
+ """启动客户端
718
+
719
+ 启动主事件循环,开始处理连接、发送和接收。
720
+ 如果已经在运行,直接返回。
721
+ """
722
+ if self._running:
723
+ return
724
+
725
+ self._running = True
726
+ self._main_task = asyncio.create_task(self._main_loop())
727
+ self.logger.info("WebSocket client started")
728
+
729
+ async def stop(self) -> None:
730
+ """停止客户端(外部入口)
731
+
732
+ 优雅地取消主任务,然后清理监听器与连接。幂等。
733
+
734
+ 主任务自身**不得**走这条路径:``await self._main_task`` 在主任务里执行
735
+ 会抛 ``RuntimeError``(Task 不能 await 自己),而此时 ``_running``
736
+ 已被置 False,异常传出后 finally 里的再次 stop() 会被开头的早退挡掉,
737
+ 于是监听器与 aiohttp session 全部泄漏。内部收尾请用 :meth:`_shutdown`。
738
+ """
739
+ self._running = False
740
+ main_task = self._main_task
741
+ self._main_task = None
742
+
743
+ if main_task is not None and main_task is not asyncio.current_task():
744
+ self.logger.debug("WebSocket client stopping")
745
+ main_task.cancel()
746
+ try:
747
+ await main_task
748
+ except asyncio.CancelledError:
749
+ pass
750
+ except Exception as e:
751
+ self.logger.error("Main task ended with error: %s", e)
752
+
753
+ await self._shutdown()
754
+
755
+ async def _shutdown(self) -> None:
756
+ """清理监听器与底层连接(幂等,不触碰主任务)
757
+
758
+ 供 :meth:`stop` 和 :meth:`_main_loop` 的 finally 共用:
759
+ 无论从外部停止还是主循环自己退出,资源释放都走同一条路径。
760
+ """
761
+ self._running = False
762
+
763
+ # 关闭所有监听器
764
+ with self._listeners_lock:
765
+ listeners = list(self._listeners.values())
766
+ self._listeners.clear()
767
+ for listener in listeners:
768
+ listener.close()
769
+
770
+ # 关闭连接
771
+ await self.connection.close()
772
+
773
+ self.logger.info("WebSocket client stopped")
774
+
775
+ async def create_listener(self, buffer_size: int | None = None) -> ListenerId:
776
+ """创建消息监听器
777
+
778
+ 如果监听器数量达到上限,自动淘汰最旧的监听器。
779
+
780
+ Args:
781
+ buffer_size: 缓冲区大小,默认使用配置值
782
+
783
+ Returns:
784
+ ListenerId: 新监听器的唯一标识符
785
+ """
786
+ if buffer_size is None:
787
+ buffer_size = self.config.listener_buffer_size
788
+
789
+ listener = WebSocketListener(buffer_size)
790
+ evicted: WebSocketListener | None = None
791
+
792
+ with self._listeners_lock:
793
+ # 检查监听器数量限制。淘汰只在锁内做 dict 操作,
794
+ # 关闭动作留到临界区之外——在持有非重入锁时调用会重入同一把锁的
795
+ # remove_listener,是一条确定的死锁路径。
796
+ if len(self._listeners) >= self.config.max_listeners:
797
+ evicted = self._pop_oldest_listener_locked()
798
+
799
+ self._listeners[listener.id] = listener
800
+
801
+ if evicted is not None:
802
+ evicted.close()
803
+ self.logger.warning(
804
+ "Evicted oldest listener due to max listeners: %s", evicted.id
805
+ )
806
+
807
+ self.logger.debug("Listener created: %s", listener.id)
808
+ return listener.id
809
+
810
+ async def remove_listener(self, listener_id: ListenerId) -> None:
811
+ """移除指定监听器
812
+
813
+ Args:
814
+ listener_id: 要移除的监听器 ID
815
+ """
816
+ with self._listeners_lock:
817
+ listener = self._listeners.pop(listener_id, None)
818
+
819
+ if listener:
820
+ listener.close()
821
+ self.logger.debug("Listener removed: %s", listener_id)
822
+
823
+ async def get_message(
824
+ self, listener_id: ListenerId, timeout: float | None = None
825
+ ) -> tuple[Any, MessageType]:
826
+ """从指定监听器获取消息(阻塞)
827
+
828
+ Args:
829
+ listener_id: 监听器 ID
830
+ timeout: 超时时间(秒),None 表示无限等待
831
+
832
+ Returns:
833
+ Tuple[Any, MessageType]: 消息内容和类型
834
+
835
+ Raises:
836
+ ListenerEvictedError: 监听器不存在时抛出
837
+ ListenerClosedError: 监听器已关闭时抛出
838
+ asyncio.TimeoutError: 超时时抛出
839
+ """
840
+ with self._listeners_lock:
841
+ listener = self._listeners.get(listener_id)
842
+
843
+ if not listener:
844
+ raise ListenerEvictedError("Listener %s not found" % listener_id)
845
+
846
+ return await listener.get(timeout)
847
+
848
+ def get_message_nowait(
849
+ self, listener_id: ListenerId
850
+ ) -> tuple[Any, MessageType] | None:
851
+ """从指定监听器非阻塞获取消息
852
+
853
+ Args:
854
+ listener_id: 监听器 ID
855
+
856
+ Returns:
857
+ Optional[Tuple[Any, MessageType]]: 消息内容和类型,无数据返回 None
858
+
859
+ Raises:
860
+ ListenerEvictedError: 监听器不存在时抛出
861
+ ListenerClosedError: 监听器已关闭时抛出
862
+ """
863
+ with self._listeners_lock:
864
+ listener = self._listeners.get(listener_id)
865
+
866
+ if not listener:
867
+ raise ListenerEvictedError("Listener %s not found" % listener_id)
868
+
869
+ return listener.get_nowait()
870
+
871
+ async def send(self, message: str | bytes | dict) -> None:
872
+ """发送消息到 WebSocket 服务器
873
+
874
+ 消息会被放入发送队列,由后台任务异步发送。
875
+
876
+ Args:
877
+ message: 要发送的消息,支持字符串、字节或字典
878
+
879
+ Raises:
880
+ ConnectionError: 客户端未运行时抛出
881
+ WebSocketError: 发送队列满时抛出
882
+ """
883
+ if not self._running:
884
+ raise ConnectionError("Client not running")
885
+
886
+ try:
887
+ self._send_queue.put_nowait(message)
888
+ except QueueFull:
889
+ raise WebSocketError("Send queue is full")
890
+
891
+ def _pop_oldest_listener_locked(self) -> WebSocketListener | None:
892
+ """摘除创建时间最早的监听器并返回它(**调用方必须已持有锁**)
893
+
894
+ 只做字典操作、不做任何 ``await``,也不调用 ``close()``——
895
+ 关闭动作由调用方在释放锁之后执行。
896
+
897
+ Returns:
898
+ 被摘除的监听器,没有监听器时返回 None
899
+ """
900
+ if not self._listeners:
901
+ return None
902
+
903
+ oldest_id = min(
904
+ self._listeners, key=lambda lid: self._listeners[lid].created_at
905
+ )
906
+ return self._listeners.pop(oldest_id, None)
907
+
908
+ async def _broadcast_message(self, message: Any, msg_type: MessageType) -> None:
909
+ """广播消息到所有监听器
910
+
911
+ 如果某个监听器队列满,该监听器会被自动移除。
912
+
913
+ Args:
914
+ message: 消息内容
915
+ msg_type: 消息类型
916
+ """
917
+ listeners_to_remove = []
918
+
919
+ with self._listeners_lock:
920
+ listeners = list(self._listeners.values())
921
+
922
+ for listener in listeners:
923
+ if not await listener.put(message, msg_type):
924
+ # 监听器队列满,标记为移除
925
+ listeners_to_remove.append(listener.id)
926
+
927
+ # 移除无法处理消息的监听器
928
+ for listener_id in listeners_to_remove:
929
+ await self.remove_listener(listener_id)
930
+ self.logger.warning("Listener evicted due to buffer full: %s", listener_id)
931
+
932
+ def get_metrics(self) -> dict[str, Any]:
933
+ """获取客户端运行指标
934
+
935
+ Returns:
936
+ Dict[str, Any]: 包含连接指标、重连状态、监听器统计的字典
937
+ """
938
+ connection_metrics = self.connection.metrics.copy()
939
+ reconnection_state = self.reconnection.get_state()
940
+
941
+ with self._listeners_lock:
942
+ active_listeners = len(self._listeners)
943
+
944
+ return {
945
+ "connection": connection_metrics,
946
+ "reconnection": reconnection_state,
947
+ "listeners": {
948
+ "active": active_listeners,
949
+ "max": self.config.max_listeners,
950
+ },
951
+ "running": self._running,
952
+ }
953
+
954
+ async def _main_loop(self) -> None:
955
+ """主事件循环
956
+
957
+ 管理连接生命周期,协调发送和接收任务。
958
+ 处理连接断开、重连和异常恢复。
959
+
960
+ **首次连接也在循环内**,与重连走同一条 :meth:`_handle_disconnected`
961
+ 路径(首次的退避为 0,立即尝试)。原实现把首连放在 while 之前,
962
+ 首连失败会直接落进 finally —— 重连策略对"服务端还没起来"这种最常见的
963
+ 场景完全不生效。
964
+ """
965
+ self.logger.debug("Main loop started")
966
+
967
+ try:
968
+ while self._running:
969
+ # 处理连接状态(含首次连接)
970
+ if not self.connection.is_connected():
971
+ await self._handle_disconnected()
972
+ continue
973
+
974
+ # 并行处理发送和接收
975
+ send_task = asyncio.create_task(self._process_send_queue())
976
+ recv_task = asyncio.create_task(self._process_receive())
977
+
978
+ done, pending = await asyncio.wait(
979
+ [send_task, recv_task], return_when=asyncio.FIRST_COMPLETED
980
+ )
981
+
982
+ # 取消未完成的任务,并等它们真正结束(否则退出时会出现
983
+ # "Task was destroyed but it is pending!" 告警)
984
+ for task in pending:
985
+ task.cancel()
986
+ if pending:
987
+ await asyncio.gather(*pending, return_exceptions=True)
988
+
989
+ # 处理异常
990
+ for task in done:
991
+ if task.cancelled():
992
+ continue
993
+ exc = task.exception()
994
+ if exc is not None:
995
+ self.logger.error("Task error: %s", exc)
996
+
997
+ except asyncio.CancelledError:
998
+ pass
999
+ except Exception as e:
1000
+ self.logger.error("Main loop error: %s", e)
1001
+ finally:
1002
+ # 只做资源清理,不要 await 主任务自己
1003
+ await self._shutdown()
1004
+ self.logger.debug("Main loop ended")
1005
+
1006
+ async def _handle_disconnected(self) -> None:
1007
+ """处理连接(首次或断线重连)
1008
+
1009
+ 根据重连策略决定是否继续尝试,并执行指数退避等待。
1010
+ 超过最大次数时只置 ``_running=False``,让 :meth:`_main_loop` 自然退出、
1011
+ 由它的 finally 统一清理——不能在这里 ``await self.stop()``,
1012
+ 那等于在主任务内部 await 主任务自己。
1013
+ """
1014
+ if not self.reconnection.should_reconnect():
1015
+ self.logger.error("Max reconnection attempts reached")
1016
+ self._running = False
1017
+ return
1018
+
1019
+ delay = self.reconnection.get_delay()
1020
+ if delay > 0:
1021
+ self.logger.info("Reconnection delay: %.2fs", delay)
1022
+ await asyncio.sleep(delay)
1023
+
1024
+ self.reconnection.on_attempt()
1025
+ self.logger.info("Connection attempt: %s", self.reconnection.attempt_count)
1026
+
1027
+ try:
1028
+ await self.connection.connect()
1029
+ self.reconnection.on_success()
1030
+ except ConnectionError as e:
1031
+ self.logger.error("Connection failed: %s", e)
1032
+
1033
+ async def _process_send_queue(self) -> None:
1034
+ """处理发送队列
1035
+
1036
+ 持续从队列取出消息并发送,处理连接中断时的消息回退。
1037
+ """
1038
+ while self._running:
1039
+ if not self.connection.is_connected():
1040
+ # 直接返回,交还控制权给 _main_loop 去走重连。
1041
+ # 原来这里 sleep(0.1) 后 continue:两个子任务都永不结束,
1042
+ # _main_loop 一直卡在 asyncio.wait 上,断线后既 100% CPU 忙等
1043
+ # 又永远不会重连。
1044
+ return
1045
+ try:
1046
+ message = await asyncio.wait_for(self._send_queue.get(), timeout=0.1)
1047
+ except TimeoutError:
1048
+ continue
1049
+
1050
+ try:
1051
+ await self.connection.send(message)
1052
+ except asyncio.CancelledError:
1053
+ # 将消息放回队列并重新抛出
1054
+ await self._send_queue.put(message)
1055
+ raise
1056
+ except ConnectionError:
1057
+ # 连接不可用,将消息重新入队并退出,触发重连
1058
+ await self._send_queue.put(message)
1059
+ break
1060
+ except Exception as e:
1061
+ self.logger.error("Send processing error: %s", e)
1062
+ finally:
1063
+ try:
1064
+ self._send_queue.task_done()
1065
+ except Exception:
1066
+ pass
1067
+
1068
+ async def _process_receive(self) -> None:
1069
+ """处理接收消息
1070
+
1071
+ 持续接收消息并广播到所有监听器,处理超时和连接错误。
1072
+ """
1073
+ while self._running:
1074
+ if not self.connection.is_connected():
1075
+ # 同 _process_send_queue:返回主循环触发重连,而不是原地忙等
1076
+ return
1077
+ try:
1078
+ message, msg_type = await self.connection.receive()
1079
+
1080
+ # 广播消息到所有监听器
1081
+ await self._broadcast_message(message, msg_type)
1082
+
1083
+ if msg_type == MessageType.Close:
1084
+ # 服务端主动关闭:连接已经不可用,必须回到主循环走重连。
1085
+ # 原实现只是广播完继续 while,而 is_connected() 已为 False,
1086
+ # 于是掉进上面那个 0.1s 忙等分支,永不重连。
1087
+ self.logger.warning("Received CLOSE frame from server")
1088
+ await self.connection.close()
1089
+ return
1090
+
1091
+ except TimeoutError:
1092
+ # 只是没有消息,继续等待
1093
+ continue
1094
+ except asyncio.CancelledError:
1095
+ raise
1096
+ except Exception as e:
1097
+ self.logger.error("Receive processing error: %s", e)
1098
+ # 接收错误通常意味着连接问题,关闭连接触发重连
1099
+ try:
1100
+ await self.connection.close()
1101
+ except Exception:
1102
+ pass
1103
+ break
1104
+
1105
+
1106
+ class SyncWebSocketClient:
1107
+ """同步 WebSocket 客户端包装器
1108
+
1109
+ 为同步代码提供 WebSocket 客户端功能,内部在后台线程运行异步事件循环。
1110
+ 所有方法都是线程安全的。
1111
+
1112
+ Attributes:
1113
+ _client: 底层异步客户端实例
1114
+ _loop: 事件循环
1115
+ _thread: 后台线程
1116
+ _running: 运行状态
1117
+ """
1118
+
1119
+ def __init__(self, *args, **kwargs):
1120
+ """初始化同步客户端
1121
+
1122
+ Args:
1123
+ *args: 传递给 AsyncWebSocketClient 的位置参数
1124
+ **kwargs: 传递给 AsyncWebSocketClient 的关键字参数
1125
+ """
1126
+ self._client = AsyncWebSocketClient(*args, **kwargs)
1127
+ self._loop = asyncio.new_event_loop()
1128
+ self._thread = None
1129
+ self._running = False
1130
+
1131
+ def start(self) -> None:
1132
+ """启动客户端(在后台线程中运行事件循环)
1133
+
1134
+ 如果已经在运行,直接返回。启动后会等待客户端真正就绪。
1135
+ """
1136
+ if self._running:
1137
+ return
1138
+
1139
+ self._running = True
1140
+
1141
+ def run_loop():
1142
+ asyncio.set_event_loop(self._loop)
1143
+ try:
1144
+ self._loop.run_until_complete(self._client.start())
1145
+ self._loop.run_forever()
1146
+ finally:
1147
+ self._loop.close()
1148
+
1149
+ self._thread = threading.Thread(target=run_loop, daemon=True)
1150
+ self._thread.start()
1151
+
1152
+ # 等待客户端真正启动
1153
+ for i in range(10):
1154
+ if self._client._running:
1155
+ break
1156
+ time.sleep(0.1)
1157
+
1158
+ def stop(self) -> None:
1159
+ """停止客户端
1160
+
1161
+ 优雅地停止事件循环和后台线程,清理资源。
1162
+ 如果已经停止,直接返回。
1163
+ """
1164
+ if not self._running:
1165
+ return
1166
+
1167
+ self._running = False
1168
+
1169
+ # 在事件循环线程中停止客户端
1170
+ future = asyncio.run_coroutine_threadsafe(self._client.stop(), self._loop)
1171
+ future.result(timeout=10) # 等待停止完成
1172
+
1173
+ # 停止事件循环
1174
+ self._loop.call_soon_threadsafe(self._loop.stop)
1175
+
1176
+ if self._thread:
1177
+ self._thread.join(timeout=5)
1178
+
1179
+ def create_listener(self, buffer_size: int | None = None) -> str:
1180
+ """创建消息监听器(同步)
1181
+
1182
+ Args:
1183
+ buffer_size: 缓冲区大小,默认使用配置值
1184
+
1185
+ Returns:
1186
+ str: 监听器 ID 字符串
1187
+ """
1188
+ future = asyncio.run_coroutine_threadsafe(
1189
+ self._client.create_listener(buffer_size), self._loop
1190
+ )
1191
+ return future.result(timeout=10)
1192
+
1193
+ def remove_listener(self, listener_id: ListenerId) -> None:
1194
+ """移除监听器(同步)
1195
+
1196
+ Args:
1197
+ listener_id: 要移除的监听器 ID
1198
+ """
1199
+ future = asyncio.run_coroutine_threadsafe(
1200
+ self._client.remove_listener(listener_id), self._loop
1201
+ )
1202
+ future.result(timeout=10)
1203
+
1204
+ def get_message(
1205
+ self, listener_id: ListenerId, timeout: float | None = None
1206
+ ) -> tuple[Any, MessageType]:
1207
+ """获取消息(同步阻塞)
1208
+
1209
+ Args:
1210
+ listener_id: 监听器 ID
1211
+ timeout: 超时时间(秒),包含线程调度时间
1212
+
1213
+ Returns:
1214
+ Tuple[Any, MessageType]: 消息内容和类型
1215
+ """
1216
+ future = asyncio.run_coroutine_threadsafe(
1217
+ self._client.get_message(listener_id, timeout), self._loop
1218
+ )
1219
+ return future.result(timeout=timeout)
1220
+
1221
+ def get_message_nowait(
1222
+ self, listener_id: ListenerId
1223
+ ) -> tuple[Any, MessageType] | None:
1224
+ """非阻塞获取消息(同步)
1225
+
1226
+ Args:
1227
+ listener_id: 监听器 ID
1228
+
1229
+ Returns:
1230
+ Optional[Tuple[Any, MessageType]]: 消息内容和类型,无数据返回 None
1231
+ """
1232
+ return self._client.get_message_nowait(listener_id)
1233
+
1234
+ def send(self, message: str | bytes | dict) -> None:
1235
+ """发送消息(同步)
1236
+
1237
+ Args:
1238
+ message: 要发送的消息,支持字符串、字节或字典
1239
+ """
1240
+ future = asyncio.run_coroutine_threadsafe(
1241
+ self._client.send(message), self._loop
1242
+ )
1243
+ future.result(timeout=10)
1244
+
1245
+ def get_metrics(self) -> dict[str, Any]:
1246
+ """获取客户端指标(同步)
1247
+
1248
+ Returns:
1249
+ Dict[str, Any]: 客户端运行指标字典
1250
+ """
1251
+ return self._client.get_metrics()
1252
+
1253
+ def __enter__(self):
1254
+ """上下文管理器入口
1255
+
1256
+ Returns:
1257
+ SyncWebSocketClient: 客户端实例
1258
+ """
1259
+ self.start()
1260
+ return self
1261
+
1262
+ def __exit__(self, exc_type, exc_val, exc_tb):
1263
+ """上下文管理器出口
1264
+
1265
+ Args:
1266
+ exc_type: 异常类型
1267
+ exc_val: 异常值
1268
+ exc_tb: 异常追踪
1269
+ """
1270
+ self.stop()