ForcomeBot 2.2.4__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.
src/clients/langbot.py ADDED
@@ -0,0 +1,710 @@
1
+ """LangBot OneBot v11 WebSocket 客户端 - 带指数退避重连
2
+
3
+ 重构版本:
4
+ - 实现指数退避重连(1s→2s→4s→...→60s)
5
+ - 集成 StateStore(替换内部映射)
6
+ - 添加 update_connection() 方法(配置热更新)
7
+ - 保持所有原有方法签名不变
8
+ """
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import time
13
+ from typing import Optional, Callable, TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from ..core.state_store import StateStore
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class LangBotClient:
22
+ """
23
+ OneBot v11 反向 WebSocket 客户端(带指数退避重连)
24
+ 连接到 LangBot 的 aiocqhttp 适配器
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ host: str = "127.0.0.1",
30
+ port: int = 2280,
31
+ access_token: str = "",
32
+ initial_reconnect_delay: float = 1.0,
33
+ max_reconnect_delay: float = 60.0
34
+ ):
35
+ """初始化LangBot客户端
36
+
37
+ Args:
38
+ host: LangBot主机地址
39
+ port: LangBot端口
40
+ access_token: 访问令牌
41
+ initial_reconnect_delay: 初始重连延迟(秒),默认1秒
42
+ max_reconnect_delay: 最大重连延迟(秒),默认60秒
43
+ """
44
+ self.host = host
45
+ self.port = port
46
+ self.access_token = access_token
47
+ self.ws = None
48
+ self._connected = False
49
+ self._reconnecting = False
50
+ self._event_callback: Optional[Callable] = None
51
+ self._receive_task: Optional[asyncio.Task] = None
52
+ self._reconnect_task: Optional[asyncio.Task] = None
53
+ self._self_id = "10000" # 模拟的机器人ID
54
+
55
+ # 指数退避重连配置
56
+ self._initial_reconnect_delay = initial_reconnect_delay
57
+ self._max_reconnect_delay = max_reconnect_delay
58
+ self._current_reconnect_delay = initial_reconnect_delay
59
+
60
+ # StateStore 集成(可选)
61
+ self._state_store: Optional["StateStore"] = None
62
+
63
+ # 内部映射(当没有 StateStore 时使用)
64
+ self._group_id_mapping: dict = {}
65
+ self._message_user_mapping: dict = {}
66
+
67
+ def set_state_store(self, store: "StateStore"):
68
+ """设置状态存储器
69
+
70
+ Args:
71
+ store: StateStore实例
72
+ """
73
+ self._state_store = store
74
+ logger.info("LangBot客户端已集成StateStore")
75
+
76
+ async def update_connection(self, host: str, port: int, access_token: str = ""):
77
+ """更新连接配置并重连(配置热更新)
78
+
79
+ Args:
80
+ host: 新的主机地址
81
+ port: 新的端口
82
+ access_token: 新的访问令牌
83
+ """
84
+ logger.info(f"更新LangBot连接配置: {host}:{port}")
85
+ self.host = host
86
+ self.port = port
87
+ self.access_token = access_token
88
+
89
+ # 关闭现有连接
90
+ await self.close()
91
+
92
+ # 重置重连延迟
93
+ self._current_reconnect_delay = self._initial_reconnect_delay
94
+
95
+ # 重新连接
96
+ await self.connect()
97
+
98
+ @property
99
+ def is_connected(self) -> bool:
100
+ """是否已连接"""
101
+ return self._connected
102
+
103
+ @property
104
+ def is_reconnecting(self) -> bool:
105
+ """是否正在重连"""
106
+ return self._reconnecting
107
+
108
+ async def connect(self) -> bool:
109
+ """连接到 LangBot
110
+
111
+ Returns:
112
+ 是否连接成功
113
+ """
114
+ try:
115
+ import websockets
116
+
117
+ ws_url = f"ws://{self.host}:{self.port}/ws"
118
+ logger.info(f"正在连接 LangBot: {ws_url}")
119
+
120
+ # aiocqhttp 需要特定的请求头
121
+ headers = {
122
+ "X-Self-ID": self._self_id,
123
+ "X-Client-Role": "Universal" # 同时处理事件和API
124
+ }
125
+
126
+ if self.access_token:
127
+ headers["Authorization"] = f"Bearer {self.access_token}"
128
+
129
+ logger.info(f"连接头: {headers}")
130
+
131
+ # 尝试不同版本的 websockets 库
132
+ try:
133
+ # websockets >= 10.0
134
+ self.ws = await websockets.connect(
135
+ ws_url,
136
+ additional_headers=headers
137
+ )
138
+ except TypeError:
139
+ try:
140
+ # websockets 旧版本
141
+ self.ws = await websockets.connect(
142
+ ws_url,
143
+ extra_headers=headers
144
+ )
145
+ except TypeError:
146
+ # 更旧的版本
147
+ self.ws = await websockets.connect(ws_url)
148
+
149
+ self._connected = True
150
+ self._reconnecting = False
151
+ # 重置重连延迟
152
+ self._current_reconnect_delay = self._initial_reconnect_delay
153
+
154
+ logger.info(f"WebSocket 连接成功: {ws_url}")
155
+
156
+ # 发送生命周期连接事件
157
+ await self._send_lifecycle_event()
158
+
159
+ # 启动消息接收任务
160
+ self._receive_task = asyncio.create_task(self._receive_loop())
161
+
162
+ logger.info("已连接到 LangBot (OneBot v11)")
163
+ return True
164
+
165
+ except Exception as e:
166
+ logger.error(f"连接 LangBot 失败: {e}", exc_info=True)
167
+ self._connected = False
168
+ return False
169
+
170
+ async def reconnect(self):
171
+ """指数退避重连
172
+
173
+ 重连延迟按指数增长:1s→2s→4s→8s→...→60s(最大)
174
+ 连接成功后重置延迟为初始值
175
+ """
176
+ if self._reconnecting:
177
+ logger.debug("已在重连中,跳过")
178
+ return
179
+
180
+ self._reconnecting = True
181
+
182
+ while not self._connected:
183
+ try:
184
+ logger.info(f"尝试重连 LangBot...")
185
+ success = await self.connect()
186
+
187
+ if success:
188
+ logger.info("重连成功")
189
+ self._reconnecting = False
190
+ return
191
+
192
+ except Exception as e:
193
+ logger.error(f"重连失败: {e}")
194
+
195
+ # 等待后重试
196
+ logger.info(f"将在 {self._current_reconnect_delay} 秒后重试...")
197
+ await asyncio.sleep(self._current_reconnect_delay)
198
+
199
+ # 指数退避,最大不超过 max_reconnect_delay
200
+ self._current_reconnect_delay = min(
201
+ self._current_reconnect_delay * 2,
202
+ self._max_reconnect_delay
203
+ )
204
+
205
+ self._reconnecting = False
206
+
207
+ def get_reconnect_delay(self) -> float:
208
+ """获取当前重连延迟(用于测试)
209
+
210
+ Returns:
211
+ 当前重连延迟(秒)
212
+ """
213
+ return self._current_reconnect_delay
214
+
215
+ async def _send_lifecycle_event(self):
216
+ """发送生命周期连接事件"""
217
+ event = {
218
+ "time": int(time.time()),
219
+ "self_id": int(self._self_id),
220
+ "post_type": "meta_event",
221
+ "meta_event_type": "lifecycle",
222
+ "sub_type": "connect"
223
+ }
224
+ await self._send(event)
225
+
226
+ async def _send(self, data: dict):
227
+ """发送数据"""
228
+ if self.ws and self._connected:
229
+ try:
230
+ message = json.dumps(data)
231
+ logger.info(f"WebSocket 发送: {message[:500]}") # 限制日志长度
232
+ await self.ws.send(message)
233
+ except Exception as e:
234
+ logger.error(f"发送失败: {e}")
235
+ self._connected = False
236
+ else:
237
+ logger.warning(f"WebSocket 未连接,无法发送: connected={self._connected}, ws={self.ws is not None}")
238
+
239
+ async def _receive_loop(self):
240
+ """接收消息循环"""
241
+ try:
242
+ while self._connected and self.ws:
243
+ try:
244
+ message = await self.ws.recv()
245
+ logger.info(f"WebSocket 收到: {message[:500]}") # 限制日志长度
246
+ data = json.loads(message)
247
+ await self._handle_message(data)
248
+ except Exception as e:
249
+ if self._connected:
250
+ logger.error(f"接收消息异常: {e}")
251
+ self._connected = False
252
+ break
253
+ except asyncio.CancelledError:
254
+ pass
255
+
256
+ # 连接断开,启动重连
257
+ if not self._connected and not self._reconnecting:
258
+ logger.info("连接断开,启动指数退避重连...")
259
+ self._reconnect_task = asyncio.create_task(self.reconnect())
260
+
261
+ async def _handle_message(self, data: dict):
262
+ """处理收到的消息"""
263
+ # 检查是否是 API 调用请求(LangBot 发送消息)
264
+ action = data.get("action")
265
+ if action:
266
+ params = data.get("params", {})
267
+ echo = data.get("echo")
268
+
269
+ logger.info(f"收到动作: {action}, 参数: {params}")
270
+
271
+ # 调用回调处理
272
+ result = {}
273
+ if self._event_callback:
274
+ try:
275
+ result = await self._event_callback(action, params) or {}
276
+ except Exception as e:
277
+ logger.error(f"处理动作异常: {e}")
278
+
279
+ # 发送响应
280
+ response = {
281
+ "status": "ok",
282
+ "retcode": 0,
283
+ "data": result
284
+ }
285
+ if echo:
286
+ response["echo"] = echo
287
+
288
+ await self._send(response)
289
+
290
+ def set_event_callback(self, callback: Callable):
291
+ """设置事件回调函数"""
292
+ self._event_callback = callback
293
+
294
+ def get_original_group_id(self, numeric_group_id: str) -> str:
295
+ """获取原始的微信群ID
296
+
297
+ Args:
298
+ numeric_group_id: 数字格式的群ID
299
+
300
+ Returns:
301
+ 原始微信群ID
302
+ """
303
+ # 优先使用 StateStore
304
+ if self._state_store:
305
+ return self._state_store.get_original_group_id(str(numeric_group_id))
306
+ # 回退到内部映射
307
+ return self._group_id_mapping.get(str(numeric_group_id), numeric_group_id)
308
+
309
+ def get_user_id_by_message(self, message_id: str) -> Optional[str]:
310
+ """根据消息ID获取发送者user_id
311
+
312
+ Args:
313
+ message_id: 消息ID
314
+
315
+ Returns:
316
+ 用户ID,不存在则返回None
317
+ """
318
+ # 优先使用 StateStore
319
+ if self._state_store:
320
+ return self._state_store.get_user_by_message(str(message_id))
321
+ # 回退到内部映射
322
+ return self._message_user_mapping.get(str(message_id))
323
+
324
+ def _save_group_mapping(self, numeric_id: str, original_id: str):
325
+ """保存群ID映射
326
+
327
+ Args:
328
+ numeric_id: 数字ID
329
+ original_id: 原始微信群ID
330
+ """
331
+ if self._state_store:
332
+ self._state_store.set_group_mapping(str(numeric_id), original_id)
333
+ else:
334
+ self._group_id_mapping[str(numeric_id)] = original_id
335
+
336
+ def _save_message_user_mapping(self, message_id: str, user_id: str):
337
+ """保存消息-用户映射
338
+
339
+ Args:
340
+ message_id: 消息ID
341
+ user_id: 用户ID
342
+ """
343
+ if self._state_store:
344
+ self._state_store.set_message_user(str(message_id), user_id)
345
+ else:
346
+ self._message_user_mapping[str(message_id)] = user_id
347
+
348
+ async def send_private_message(self, user_id: str, message: str) -> None:
349
+ """发送私聊消息事件到 LangBot
350
+
351
+ Args:
352
+ user_id: 用户ID
353
+ message: 消息内容
354
+ """
355
+ if not self._connected:
356
+ logger.warning("未连接到 LangBot")
357
+ return
358
+
359
+ message_id = int(time.time() * 1000) % 2147483647
360
+
361
+ event = {
362
+ "time": int(time.time()),
363
+ "self_id": int(self._self_id),
364
+ "post_type": "message",
365
+ "message_type": "private",
366
+ "sub_type": "friend",
367
+ "message_id": message_id,
368
+ "user_id": user_id,
369
+ "message": message,
370
+ "raw_message": message,
371
+ "font": 0,
372
+ "sender": {
373
+ "user_id": user_id,
374
+ "nickname": user_id,
375
+ "sex": "unknown",
376
+ "age": 0
377
+ }
378
+ }
379
+
380
+ await self._send(event)
381
+ logger.info(f"已发送私聊消息事件: [{user_id}] {message}")
382
+
383
+ async def send_group_message(
384
+ self,
385
+ group_id: str,
386
+ user_id: str,
387
+ message: str,
388
+ at_bot: bool = False
389
+ ) -> None:
390
+ """发送群聊消息事件到 LangBot
391
+
392
+ Args:
393
+ group_id: 群ID
394
+ user_id: 发送者ID
395
+ message: 消息内容
396
+ at_bot: 是否标记为@机器人(用于引用消息等场景)
397
+ """
398
+ if not self._connected:
399
+ logger.warning("未连接到 LangBot")
400
+ return
401
+
402
+ message_id = int(time.time() * 1000) % 2147483647
403
+
404
+ # 尝试将 group_id 转换为数字(微信群ID格式: 57388706417@chatroom)
405
+ numeric_group_id = group_id
406
+ if '@' in group_id:
407
+ numeric_group_id = group_id.split('@')[0]
408
+
409
+ try:
410
+ numeric_group_id = int(numeric_group_id)
411
+ except ValueError:
412
+ pass
413
+
414
+ # 根据 at_bot 参数决定是否添加 @机器人 标记
415
+ cq_message = message
416
+ if at_bot:
417
+ # 引用机器人消息或明确需要触发机器人时,添加@标记
418
+ cq_message = f'[CQ:at,qq={self._self_id}] {message}'
419
+
420
+ event = {
421
+ "time": int(time.time()),
422
+ "self_id": int(self._self_id),
423
+ "post_type": "message",
424
+ "message_type": "group",
425
+ "sub_type": "normal",
426
+ "message_id": message_id,
427
+ "group_id": numeric_group_id,
428
+ "user_id": user_id,
429
+ "message": cq_message,
430
+ "raw_message": message,
431
+ "font": 0,
432
+ "sender": {
433
+ "user_id": user_id,
434
+ "nickname": user_id,
435
+ "card": "",
436
+ "sex": "unknown",
437
+ "age": 0,
438
+ "area": "",
439
+ "level": "0",
440
+ "role": "member",
441
+ "title": ""
442
+ }
443
+ }
444
+
445
+ # 保存映射(使用 StateStore 或内部映射)
446
+ self._save_group_mapping(str(numeric_group_id), group_id)
447
+ self._save_message_user_mapping(str(message_id), user_id)
448
+
449
+ logger.info(f"发送群聊事件到 LangBot: group_id={numeric_group_id}, message={cq_message}")
450
+ await self._send(event)
451
+ logger.info(f"已发送群聊消息事件: [{group_id}][{user_id}] {message}")
452
+
453
+ async def health_check(self) -> bool:
454
+ """健康检查
455
+
456
+ Returns:
457
+ 是否健康(已连接)
458
+ """
459
+ return self._connected and self.ws is not None
460
+
461
+ async def close(self):
462
+ """关闭客户端"""
463
+ self._connected = False
464
+
465
+ # 取消重连任务
466
+ if self._reconnect_task:
467
+ self._reconnect_task.cancel()
468
+ try:
469
+ await self._reconnect_task
470
+ except asyncio.CancelledError:
471
+ pass
472
+ self._reconnect_task = None
473
+
474
+ # 取消接收任务
475
+ if self._receive_task:
476
+ self._receive_task.cancel()
477
+ try:
478
+ await self._receive_task
479
+ except asyncio.CancelledError:
480
+ pass
481
+ self._receive_task = None
482
+
483
+ # 关闭WebSocket
484
+ if self.ws:
485
+ await self.ws.close()
486
+ self.ws = None
487
+
488
+ self._reconnecting = False
489
+
490
+ async def send_private_message_with_image(self, user_id: str, image_url: str) -> None:
491
+ """发送带图片的私聊消息事件到 LangBot
492
+
493
+ Args:
494
+ user_id: 用户ID
495
+ image_url: 图片URL
496
+ """
497
+ if not self._connected:
498
+ logger.warning("未连接到 LangBot")
499
+ return
500
+
501
+ message_id = int(time.time() * 1000) % 2147483647
502
+
503
+ event = {
504
+ "time": int(time.time()),
505
+ "self_id": int(self._self_id),
506
+ "post_type": "message",
507
+ "message_type": "private",
508
+ "sub_type": "friend",
509
+ "message_id": message_id,
510
+ "user_id": user_id,
511
+ "message": [
512
+ {
513
+ "type": "image",
514
+ "data": {
515
+ "url": image_url
516
+ }
517
+ }
518
+ ],
519
+ "raw_message": "[图片]",
520
+ "font": 0,
521
+ "sender": {
522
+ "user_id": user_id,
523
+ "nickname": user_id,
524
+ "sex": "unknown",
525
+ "age": 0
526
+ }
527
+ }
528
+
529
+ logger.info(f"发送私聊图片事件到 LangBot: [{user_id}], url={image_url}")
530
+ await self._send(event)
531
+
532
+ async def send_private_message_with_image_and_text(
533
+ self,
534
+ user_id: str,
535
+ image_url: str,
536
+ text: str
537
+ ) -> None:
538
+ """发送带图片和文本的私聊消息事件到 LangBot(用于引用图片场景)
539
+
540
+ Args:
541
+ user_id: 用户ID
542
+ image_url: 图片URL
543
+ text: 文本内容
544
+ """
545
+ if not self._connected:
546
+ logger.warning("未连接到 LangBot")
547
+ return
548
+
549
+ message_id = int(time.time() * 1000) % 2147483647
550
+
551
+ event = {
552
+ "time": int(time.time()),
553
+ "self_id": int(self._self_id),
554
+ "post_type": "message",
555
+ "message_type": "private",
556
+ "sub_type": "friend",
557
+ "message_id": message_id,
558
+ "user_id": user_id,
559
+ "message": [
560
+ {"type": "image", "data": {"url": image_url}},
561
+ {"type": "text", "data": {"text": text}}
562
+ ],
563
+ "raw_message": f"[图片] {text}",
564
+ "font": 0,
565
+ "sender": {
566
+ "user_id": user_id,
567
+ "nickname": user_id,
568
+ "sex": "unknown",
569
+ "age": 0
570
+ }
571
+ }
572
+
573
+ logger.info(f"发送私聊图片+文本事件到 LangBot: [{user_id}], url={image_url}, text={text}")
574
+ await self._send(event)
575
+
576
+ async def send_group_message_with_image(
577
+ self,
578
+ group_id: str,
579
+ user_id: str,
580
+ image_url: str,
581
+ at_bot: bool = False
582
+ ) -> None:
583
+ """发送带图片的群聊消息事件到 LangBot
584
+
585
+ Args:
586
+ group_id: 群ID
587
+ user_id: 发送者ID
588
+ image_url: 图片URL
589
+ at_bot: 是否标记为@机器人
590
+ """
591
+ if not self._connected:
592
+ logger.warning("未连接到 LangBot")
593
+ return
594
+
595
+ message_id = int(time.time() * 1000) % 2147483647
596
+
597
+ # 转换 group_id
598
+ numeric_group_id = group_id
599
+ if '@' in group_id:
600
+ numeric_group_id = group_id.split('@')[0]
601
+ try:
602
+ numeric_group_id = int(numeric_group_id)
603
+ except ValueError:
604
+ pass
605
+
606
+ # 保存映射
607
+ self._save_group_mapping(str(numeric_group_id), group_id)
608
+
609
+ # 构建消息内容
610
+ message_segments = []
611
+ if at_bot:
612
+ message_segments.append({"type": "at", "data": {"qq": str(self._self_id)}})
613
+ message_segments.append({"type": "image", "data": {"url": image_url}})
614
+
615
+ event = {
616
+ "time": int(time.time()),
617
+ "self_id": int(self._self_id),
618
+ "post_type": "message",
619
+ "message_type": "group",
620
+ "sub_type": "normal",
621
+ "message_id": message_id,
622
+ "group_id": numeric_group_id,
623
+ "user_id": user_id,
624
+ "message": message_segments,
625
+ "raw_message": "[图片]",
626
+ "font": 0,
627
+ "sender": {
628
+ "user_id": user_id,
629
+ "nickname": user_id,
630
+ "card": "",
631
+ "sex": "unknown",
632
+ "age": 0,
633
+ "area": "",
634
+ "level": "0",
635
+ "role": "member",
636
+ "title": ""
637
+ }
638
+ }
639
+
640
+ logger.info(f"发送群聊图片事件到 LangBot: [{group_id}][{user_id}], url={image_url}, at_bot={at_bot}")
641
+ await self._send(event)
642
+
643
+ async def send_group_message_with_image_and_text(
644
+ self,
645
+ group_id: str,
646
+ user_id: str,
647
+ image_url: str,
648
+ text: str
649
+ ) -> None:
650
+ """发送带图片和文本的群聊消息事件到 LangBot(用于关联图片缓存场景)
651
+
652
+ Args:
653
+ group_id: 群ID
654
+ user_id: 发送者ID
655
+ image_url: 图片URL
656
+ text: 文本内容
657
+ """
658
+ if not self._connected:
659
+ logger.warning("未连接到 LangBot")
660
+ return
661
+
662
+ message_id = int(time.time() * 1000) % 2147483647
663
+
664
+ # 转换 group_id
665
+ numeric_group_id = group_id
666
+ if '@' in group_id:
667
+ numeric_group_id = group_id.split('@')[0]
668
+ try:
669
+ numeric_group_id = int(numeric_group_id)
670
+ except ValueError:
671
+ pass
672
+
673
+ # 保存映射
674
+ self._save_group_mapping(str(numeric_group_id), group_id)
675
+ self._save_message_user_mapping(str(message_id), user_id)
676
+
677
+ # 构建消息:@机器人 + 图片 + 文本
678
+ message_segments = [
679
+ {"type": "at", "data": {"qq": str(self._self_id)}},
680
+ {"type": "image", "data": {"url": image_url}},
681
+ {"type": "text", "data": {"text": text}}
682
+ ]
683
+
684
+ event = {
685
+ "time": int(time.time()),
686
+ "self_id": int(self._self_id),
687
+ "post_type": "message",
688
+ "message_type": "group",
689
+ "sub_type": "normal",
690
+ "message_id": message_id,
691
+ "group_id": numeric_group_id,
692
+ "user_id": user_id,
693
+ "message": message_segments,
694
+ "raw_message": f"[图片] {text}",
695
+ "font": 0,
696
+ "sender": {
697
+ "user_id": user_id,
698
+ "nickname": user_id,
699
+ "card": "",
700
+ "sex": "unknown",
701
+ "age": 0,
702
+ "area": "",
703
+ "level": "0",
704
+ "role": "member",
705
+ "title": ""
706
+ }
707
+ }
708
+
709
+ logger.info(f"发送群聊图片+文本事件到 LangBot: [{group_id}][{user_id}], url={image_url}, text={text}")
710
+ await self._send(event)