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.
@@ -0,0 +1,833 @@
1
+ """消息处理器 - 重构版本
2
+
3
+ 使用新模块:
4
+ - MessageParser 替换内联解析逻辑
5
+ - StateStore 替换内部缓存
6
+ - ConfigManager 获取配置
7
+ - LogCollector 记录消息日志
8
+
9
+ 合并私聊/群聊公共逻辑到 _process_message()
10
+ 添加异常处理确保不阻塞后续消息
11
+ 保持所有消息处理逻辑不变
12
+ """
13
+ import logging
14
+ import re
15
+ import asyncio
16
+ import random
17
+ from typing import Optional, Dict, Any, TYPE_CHECKING
18
+
19
+ from .message_parser import MessageParser, ParsedMessage
20
+ from ..models import QianXunEvent, QianXunCallback, PrivateMsgData, GroupMsgData
21
+ from ..core.log_collector import log_private_message, log_group_message, log_error
22
+
23
+ if TYPE_CHECKING:
24
+ from ..clients.qianxun import QianXunClient
25
+ from ..clients.langbot import LangBotClient
26
+ from ..core.state_store import StateStore
27
+ from ..core.config_manager import ConfigManager
28
+ from ..core.log_collector import LogCollector
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class MessageHandler:
34
+ """消息处理器 - 重构版本"""
35
+
36
+ def __init__(
37
+ self,
38
+ qianxun_client: "QianXunClient",
39
+ langbot_client: "LangBotClient",
40
+ config_manager: "ConfigManager",
41
+ state_store: "StateStore",
42
+ log_collector: "LogCollector"
43
+ ):
44
+ """初始化消息处理器
45
+
46
+ Args:
47
+ qianxun_client: 千寻客户端
48
+ langbot_client: LangBot客户端
49
+ config_manager: 配置管理器
50
+ state_store: 状态存储器
51
+ log_collector: 日志收集器
52
+ """
53
+ self.qianxun = qianxun_client
54
+ self.langbot = langbot_client
55
+ self.config_manager = config_manager
56
+ self.state_store = state_store
57
+ self.log_collector = log_collector
58
+
59
+ # 消息解析器
60
+ self.message_parser = MessageParser(qianxun_client)
61
+
62
+ # 存储机器人wxid列表和当前机器人wxid
63
+ self.robot_wxids = set()
64
+ self.current_robot_wxid: Optional[str] = None
65
+
66
+ # 设置 LangBot 回调
67
+ self.langbot.set_event_callback(self._handle_langbot_action)
68
+
69
+ @property
70
+ def _config(self) -> Dict[str, Any]:
71
+ """获取当前配置"""
72
+ return self.config_manager.config
73
+
74
+ @property
75
+ def _ignore_wxids(self) -> list:
76
+ """获取忽略的wxid列表"""
77
+ return self.config_manager.get_filter_config().get("ignore_wxids", [])
78
+
79
+ def _should_ignore_wxid(self, wxid: str) -> bool:
80
+ """检查是否应该忽略该wxid(包括前缀匹配)"""
81
+ from src.core.config_manager import DEFAULT_IGNORE_PREFIXES
82
+
83
+ # 检查完整wxid匹配
84
+ if wxid in self._ignore_wxids:
85
+ logger.info(f"wxid {wxid} 在忽略列表中")
86
+ return True
87
+
88
+ # 检查前缀匹配(公众号、服务号等)
89
+ for prefix in DEFAULT_IGNORE_PREFIXES:
90
+ if wxid.startswith(prefix):
91
+ logger.info(f"wxid {wxid} 匹配忽略前缀 {prefix}")
92
+ return True
93
+
94
+ return False
95
+
96
+ @property
97
+ def _reply_at_all(self) -> bool:
98
+ """是否回复@所有人"""
99
+ return self.config_manager.get_filter_config().get("reply_at_all", False)
100
+
101
+ @property
102
+ def _keywords_config(self) -> dict:
103
+ """获取关键词触发配置"""
104
+ return self.config_manager.get_filter_config().get("keywords", {})
105
+
106
+ @property
107
+ def _group_forward_mode(self) -> str:
108
+ """获取群消息转发模式: strict(严格) 或 all(全部)"""
109
+ return self.config_manager.get_filter_config().get("group_forward_mode", "strict")
110
+
111
+ @property
112
+ def _rate_limit(self) -> Dict[str, Any]:
113
+ """获取限流配置"""
114
+ return self.config_manager.get_rate_limit_config()
115
+
116
+ @property
117
+ def _image_cache_ttl(self) -> int:
118
+ """获取图片缓存TTL"""
119
+ return self._config.get('image_cache_ttl', 120)
120
+
121
+ def _check_keyword_trigger(self, content: str) -> bool:
122
+ """检查消息是否触发关键词
123
+
124
+ Args:
125
+ content: 消息内容
126
+
127
+ Returns:
128
+ 是否触发关键词
129
+ """
130
+ keywords_config = self._keywords_config
131
+
132
+ if not keywords_config.get('enabled', False):
133
+ return False
134
+
135
+ keywords = keywords_config.get('list', [])
136
+ if not keywords:
137
+ return False
138
+
139
+ match_mode = keywords_config.get('match_mode', 'contains')
140
+ content_lower = content.lower()
141
+
142
+ for keyword in keywords:
143
+ if not keyword:
144
+ continue
145
+ keyword_lower = keyword.lower()
146
+
147
+ if match_mode == 'exact':
148
+ if content_lower == keyword_lower:
149
+ logger.info(f"关键词触发(精确匹配): {keyword}")
150
+ return True
151
+ elif match_mode == 'startswith':
152
+ if content_lower.startswith(keyword_lower):
153
+ logger.info(f"关键词触发(开头匹配): {keyword}")
154
+ return True
155
+ else: # contains
156
+ if keyword_lower in content_lower:
157
+ logger.info(f"关键词触发(包含匹配): {keyword}")
158
+ return True
159
+
160
+ return False
161
+
162
+ async def _random_delay(self):
163
+ """随机延迟,模拟人工操作"""
164
+ min_interval = self._rate_limit.get('min_interval', 1)
165
+ max_interval = self._rate_limit.get('max_interval', 3)
166
+ delay = random.uniform(min_interval, max_interval)
167
+ logger.info(f"限流延迟: {delay:.2f}秒 (配置: {min_interval}-{max_interval}秒)")
168
+ await asyncio.sleep(delay)
169
+
170
+ async def _send_message_with_split(
171
+ self,
172
+ robot_wxid: str,
173
+ to_wxid: str,
174
+ message: str,
175
+ at_prefix: str = ""
176
+ ):
177
+ """发送消息,支持按分隔符分段发送
178
+
179
+ Args:
180
+ robot_wxid: 机器人wxid
181
+ to_wxid: 接收者wxid
182
+ message: 消息内容
183
+ at_prefix: @前缀(如 [@,wxid=xxx,nick=,isAuto=true])
184
+ """
185
+ split_config = self._config.get('message_split', {})
186
+ split_enabled = split_config.get('enabled', False)
187
+ split_separator = split_config.get('separator', '/!')
188
+ split_min_delay = split_config.get('min_delay', 1)
189
+ split_max_delay = split_config.get('max_delay', 3)
190
+
191
+ logger.info(f"分段配置: enabled={split_enabled}, separator='{split_separator}', 消息中包含分隔符={split_separator in message}")
192
+
193
+ if not split_enabled or split_separator not in message:
194
+ # 不启用分段或消息中没有分隔符,直接发送
195
+ full_message = f"{at_prefix} {message}".strip() if at_prefix else message
196
+ await self.qianxun.send_text(robot_wxid, to_wxid, full_message)
197
+ return
198
+
199
+ # 按分隔符分段
200
+ parts = [p.strip() for p in message.split(split_separator) if p.strip()]
201
+ logger.info(f"消息分段: {len(parts)} 段")
202
+
203
+ if not parts:
204
+ return
205
+
206
+ # 第一段带@前缀
207
+ first_message = f"{at_prefix} {parts[0]}".strip() if at_prefix else parts[0]
208
+ await self.qianxun.send_text(robot_wxid, to_wxid, first_message)
209
+
210
+ # 后续分段随机延迟发送
211
+ for i, part in enumerate(parts[1:], 2):
212
+ delay = random.uniform(split_min_delay, split_max_delay)
213
+ logger.info(f"分段发送第{i}段,延迟 {delay:.1f}秒 (配置: {split_min_delay}-{split_max_delay}秒)")
214
+ await asyncio.sleep(delay)
215
+ await self.qianxun.send_text(robot_wxid, to_wxid, part)
216
+
217
+ async def _handle_langbot_action(self, action: str, params: dict) -> dict:
218
+ """处理 LangBot 发来的动作(如发送消息)"""
219
+ logger.info(f"LangBot动作: {action}, 参数: {params}")
220
+
221
+ if action == "send_private_msg":
222
+ user_id = str(params.get("user_id", ""))
223
+ message = self._extract_text_from_message(params.get("message", ""))
224
+
225
+ # 检查是否应该忽略该用户(防止回复公众号等)
226
+ if user_id and self._should_ignore_wxid(user_id):
227
+ logger.debug(f"忽略发送私聊消息给: {user_id}")
228
+ return {"message_id": 0}
229
+
230
+ if user_id and message and self.current_robot_wxid:
231
+ await self._random_delay()
232
+ await self._send_message_with_split(self.current_robot_wxid, user_id, message)
233
+ return {"message_id": 0}
234
+
235
+ elif action == "send_group_msg":
236
+ group_id = str(params.get("group_id", ""))
237
+ message = self._extract_text_from_message(params.get("message", ""))
238
+
239
+ if group_id and message and self.current_robot_wxid:
240
+ # 从StateStore获取原始的微信群ID
241
+ original_group_id = self.state_store.get_original_group_id(group_id)
242
+ # 获取需要@的用户(从reply消息中提取)
243
+ at_user = self._get_reply_user(params.get("message", []))
244
+ at_prefix = f"[@,wxid={at_user},nick=,isAuto=true]" if at_user else ""
245
+ await self._random_delay()
246
+ await self._send_message_with_split(
247
+ self.current_robot_wxid, original_group_id, message, at_prefix
248
+ )
249
+ return {"message_id": 0}
250
+
251
+ elif action == "send_msg":
252
+ message_type = params.get("message_type", "private")
253
+ user_id = str(params.get("user_id", ""))
254
+ group_id = str(params.get("group_id", ""))
255
+ message = self._extract_text_from_message(params.get("message", ""))
256
+
257
+ # 检查是否应该忽略该用户(防止回复公众号等)
258
+ if user_id and self._should_ignore_wxid(user_id):
259
+ logger.info(f"已拦截发送消息给被忽略用户: {user_id}")
260
+ return {"message_id": 0}
261
+
262
+ if message and self.current_robot_wxid:
263
+ await self._random_delay()
264
+ if message_type == "group" and group_id:
265
+ original_group_id = self.state_store.get_original_group_id(group_id)
266
+ at_user = self._get_reply_user(params.get("message", []))
267
+ at_prefix = f"[@,wxid={at_user},nick=,isAuto=true]" if at_user else ""
268
+ await self._send_message_with_split(
269
+ self.current_robot_wxid, original_group_id, message, at_prefix
270
+ )
271
+ elif user_id:
272
+ await self._send_message_with_split(self.current_robot_wxid, user_id, message)
273
+ return {"message_id": 0}
274
+
275
+ elif action == "get_login_info":
276
+ return {
277
+ "user_id": self.current_robot_wxid or "qianxun_bot",
278
+ "nickname": "千寻机器人"
279
+ }
280
+
281
+ return {}
282
+
283
+ def _extract_text_from_message(self, message) -> str:
284
+ """从 OneBot 消息格式中提取文本"""
285
+ if isinstance(message, str):
286
+ # 移除 CQ 码
287
+ text = re.sub(r'\[CQ:[^\]]+\]', '', message)
288
+ return text.strip()
289
+ elif isinstance(message, list):
290
+ # 消息段数组格式
291
+ texts = []
292
+ for seg in message:
293
+ if isinstance(seg, dict) and seg.get("type") == "text":
294
+ texts.append(seg.get("data", {}).get("text", ""))
295
+ return "".join(texts).strip()
296
+ return str(message)
297
+
298
+ def _get_reply_user(self, message) -> Optional[str]:
299
+ """从消息中获取需要@的用户(通过reply消息段的message_id查找)"""
300
+ if not isinstance(message, list):
301
+ return None
302
+
303
+ for seg in message:
304
+ if isinstance(seg, dict) and seg.get("type") == "reply":
305
+ reply_id = seg.get("data", {}).get("id", "")
306
+ if reply_id:
307
+ # 从StateStore查找对应的user_id
308
+ user_id = self.state_store.get_user_by_message(str(reply_id))
309
+ if user_id:
310
+ return user_id
311
+ return None
312
+
313
+ async def handle_callback(self, callback: QianXunCallback) -> dict:
314
+ """处理千寻框架回调"""
315
+ event = callback.event
316
+ robot_wxid = callback.wxid
317
+ data = callback.data
318
+
319
+ logger.debug(f"收到事件: {event}, 机器人: {robot_wxid}")
320
+
321
+ # 记录机器人wxid
322
+ if robot_wxid:
323
+ self.robot_wxids.add(robot_wxid)
324
+ self.current_robot_wxid = robot_wxid
325
+
326
+ try:
327
+ if event == QianXunEvent.INJECT_SUCCESS:
328
+ logger.info(f"微信注入成功: {data}")
329
+ return {"status": "ok"}
330
+
331
+ elif event == QianXunEvent.USER_CHANGE:
332
+ change_type = data.get("type")
333
+ wxid = data.get("wxid")
334
+ if change_type == 1:
335
+ logger.info(f"账号上线: {wxid}")
336
+ self.robot_wxids.add(wxid)
337
+ else:
338
+ logger.info(f"账号下线: {wxid}")
339
+ self.robot_wxids.discard(wxid)
340
+ return {"status": "ok"}
341
+
342
+ elif event == QianXunEvent.PRIVATE_MSG:
343
+ await self._handle_private_msg(robot_wxid, data)
344
+ return {"status": "ok"}
345
+
346
+ elif event == QianXunEvent.GROUP_MSG:
347
+ await self._handle_group_msg(robot_wxid, data)
348
+ return {"status": "ok"}
349
+
350
+ elif event == QianXunEvent.GROUP_MEMBER_CHANGE:
351
+ await self._handle_group_member_change(robot_wxid, data)
352
+ return {"status": "ok"}
353
+
354
+ else:
355
+ logger.debug(f"未处理的事件类型: {event}")
356
+ return {"status": "ok"}
357
+
358
+ except Exception as e:
359
+ logger.error(f"处理回调异常: {e}", exc_info=True)
360
+ # 记录错误日志
361
+ await log_error(self.log_collector, f"处理回调异常: {e}", {"event": event})
362
+ return {"status": "error", "message": str(e)}
363
+
364
+ async def _handle_private_msg(self, robot_wxid: str, data: dict):
365
+ """处理私聊消息"""
366
+ msg_data_raw = data.get('data', data)
367
+
368
+ try:
369
+ msg_data = PrivateMsgData(**msg_data_raw)
370
+ except Exception as e:
371
+ logger.error(f"解析私聊消息失败: {e}")
372
+ return
373
+
374
+ # 消息去重(使用StateStore)
375
+ if msg_data.msgId and self.state_store.is_duplicate_message(msg_data.msgId):
376
+ logger.debug(f"忽略重复消息: {msg_data.msgId}")
377
+ return
378
+
379
+ from_wxid = msg_data.fromWxid
380
+ content = msg_data.msg.strip()
381
+
382
+ if self._should_ignore_wxid(from_wxid):
383
+ logger.info(f"已拦截接收来自被忽略用户的消息: {from_wxid}")
384
+ return
385
+
386
+ try:
387
+ # 使用MessageParser解析消息
388
+ parsed = self.message_parser.parse_message(
389
+ msg_data.msgType, content, robot_wxid
390
+ )
391
+
392
+ # 处理解析结果
393
+ await self._process_private_message(from_wxid, parsed, msg_data)
394
+
395
+ except Exception as e:
396
+ logger.error(f"处理私聊消息异常: {e}", exc_info=True)
397
+ await log_error(self.log_collector, f"处理私聊消息异常: {e}", {
398
+ "from_wxid": from_wxid,
399
+ "content": content[:100]
400
+ })
401
+
402
+ async def _process_private_message(
403
+ self,
404
+ from_wxid: str,
405
+ parsed: ParsedMessage,
406
+ msg_data: PrivateMsgData
407
+ ):
408
+ """处理私聊消息的公共逻辑"""
409
+ if not parsed.should_process:
410
+ return
411
+
412
+ content = parsed.content
413
+
414
+ # 处理图片消息
415
+ if parsed.type == "image" and parsed.image_url:
416
+ logger.info(f"私聊图片消息 [{from_wxid}], URL: {parsed.image_url}")
417
+ await self.langbot.send_private_message_with_image(from_wxid, parsed.image_url)
418
+ await log_private_message(self.log_collector, from_wxid, "[图片]", "processed")
419
+ return
420
+
421
+ # 处理引用图片消息
422
+ if parsed.type == "quote" and parsed.image_url:
423
+ logger.info(f"私聊引用图片消息: {parsed.image_url}, 提问: {content}")
424
+ await self.langbot.send_private_message_with_image_and_text(
425
+ from_wxid, parsed.image_url, content
426
+ )
427
+ await log_private_message(self.log_collector, from_wxid, f"[引用图片] {content}", "processed")
428
+ return
429
+
430
+ if not content:
431
+ return
432
+
433
+ logger.info(f"私聊消息 [{from_wxid}]: {content}")
434
+
435
+ # 发送到 LangBot
436
+ await self.langbot.send_private_message(from_wxid, content)
437
+ await log_private_message(self.log_collector, from_wxid, content, "processed")
438
+
439
+ async def _handle_group_msg(self, robot_wxid: str, data: dict):
440
+ """处理群聊消息"""
441
+ msg_data_raw = data.get('data', data)
442
+ logger.info(f"群聊原始数据: {msg_data_raw}")
443
+
444
+ try:
445
+ msg_data = GroupMsgData(**msg_data_raw)
446
+ except Exception as e:
447
+ logger.error(f"解析群聊消息失败: {e}")
448
+ return
449
+
450
+ # 消息去重(使用StateStore)
451
+ if msg_data.msgId and self.state_store.is_duplicate_message(msg_data.msgId):
452
+ logger.debug(f"忽略重复消息: {msg_data.msgId}")
453
+ return
454
+
455
+ group_wxid = msg_data.fromWxid
456
+ sender_wxid = msg_data.finalFromWxid or msg_data.fromWxid
457
+ content = msg_data.msg.strip()
458
+
459
+ if self._should_ignore_wxid(sender_wxid):
460
+ logger.debug(f"忽略用户: {sender_wxid}")
461
+ return
462
+
463
+ try:
464
+ # 使用MessageParser解析消息
465
+ parsed = self.message_parser.parse_message(
466
+ msg_data.msgType, content, robot_wxid, msg_data.atWxidList
467
+ )
468
+
469
+ # 处理解析结果
470
+ await self._process_group_message(
471
+ robot_wxid, group_wxid, sender_wxid, parsed, msg_data
472
+ )
473
+
474
+ except Exception as e:
475
+ logger.error(f"处理群聊消息异常: {e}", exc_info=True)
476
+ await log_error(self.log_collector, f"处理群聊消息异常: {e}", {
477
+ "group_wxid": group_wxid,
478
+ "sender_wxid": sender_wxid,
479
+ "content": content[:100]
480
+ })
481
+
482
+ async def _process_group_message(
483
+ self,
484
+ robot_wxid: str,
485
+ group_wxid: str,
486
+ sender_wxid: str,
487
+ parsed: ParsedMessage,
488
+ msg_data: GroupMsgData
489
+ ):
490
+ """处理群聊消息的公共逻辑"""
491
+ # 处理入群消息
492
+ if parsed.is_join_group:
493
+ await self._handle_join_group_message(
494
+ robot_wxid, group_wxid, parsed.content, msg_data.msgXml or ""
495
+ )
496
+ return
497
+
498
+ # 处理拍一拍消息
499
+ if parsed.type == "pat" and parsed.should_process:
500
+ # 更新sender_wxid为拍一拍发起者
501
+ if parsed.pat_info and parsed.pat_info.from_user:
502
+ sender_wxid = parsed.pat_info.from_user
503
+
504
+ # 拍一拍消息直接发送到 LangBot
505
+ logger.info(f"群聊拍一拍消息 [{group_wxid}][{sender_wxid}]")
506
+ await self.langbot.send_group_message(
507
+ group_wxid, sender_wxid, parsed.content, at_bot=True
508
+ )
509
+ await log_group_message(
510
+ self.log_collector, group_wxid, sender_wxid, parsed.content, "processed"
511
+ )
512
+ return
513
+
514
+ if not parsed.should_process:
515
+ return
516
+
517
+ content = parsed.content
518
+
519
+ # 处理图片消息
520
+ if parsed.type == "image" and parsed.image_url:
521
+ # 检查是否@了机器人
522
+ is_at_bot = msg_data.atWxidList and robot_wxid in msg_data.atWxidList
523
+
524
+ if is_at_bot:
525
+ # @机器人 + 图片,直接发送给LangBot处理
526
+ logger.info(f"群聊图片消息(@机器人) [{group_wxid}][{sender_wxid}], URL: {parsed.image_url}")
527
+ await self.langbot.send_group_message_with_image(
528
+ group_wxid, sender_wxid, parsed.image_url, at_bot=True
529
+ )
530
+ await log_group_message(
531
+ self.log_collector, group_wxid, sender_wxid, "[图片@机器人]", "processed"
532
+ )
533
+ else:
534
+ # 普通图片消息,缓存图片URL(使用StateStore)
535
+ logger.info(f"群聊图片消息(缓存) [{group_wxid}][{sender_wxid}], URL: {parsed.image_url}")
536
+ self.state_store.cache_image(group_wxid, sender_wxid, parsed.image_url)
537
+ return
538
+
539
+ # 检查是否@机器人
540
+ is_at_bot_in_msg = self.message_parser.check_at_bot(
541
+ content, robot_wxid, msg_data.atWxidList
542
+ )
543
+
544
+ # 检查是否@所有人,根据配置决定是否响应
545
+ if self._reply_at_all and self.message_parser.check_at_all(msg_data.atWxidList):
546
+ is_at_bot_in_msg = True
547
+
548
+ # 引用机器人消息或语音消息也视为@机器人
549
+ if parsed.is_quote_to_bot or parsed.type == "voice":
550
+ is_at_bot_in_msg = True
551
+
552
+ # 检查关键词触发
553
+ is_keyword_trigger = self._check_keyword_trigger(content)
554
+
555
+ # 如果引用了图片消息,直接发送图片+文本
556
+ if parsed.image_url:
557
+ logger.info(f"引用图片消息,发送图片+文本: {parsed.image_url}")
558
+ await self.langbot.send_group_message_with_image_and_text(
559
+ group_wxid, sender_wxid, parsed.image_url, content
560
+ )
561
+ await log_group_message(
562
+ self.log_collector, group_wxid, sender_wxid, f"[引用图片] {content}", "processed"
563
+ )
564
+ return
565
+
566
+ # 如果@机器人,检查是否有图片缓存需要关联(使用StateStore)
567
+ if is_at_bot_in_msg:
568
+ logger.debug(f"检查图片缓存: group={group_wxid}, user={sender_wxid}")
569
+ cached_image_url = self.state_store.get_cached_image(group_wxid, sender_wxid)
570
+ if cached_image_url:
571
+ # 有图片缓存,带上图片一起发送
572
+ logger.info(f"关联缓存图片: {cached_image_url}")
573
+ await self.langbot.send_group_message_with_image_and_text(
574
+ group_wxid, sender_wxid, cached_image_url, content
575
+ )
576
+ await log_group_message(
577
+ self.log_collector, group_wxid, sender_wxid, f"[缓存图片] {content}", "processed"
578
+ )
579
+ return
580
+ else:
581
+ logger.debug(f"没有找到图片缓存")
582
+
583
+ if not content:
584
+ logger.info("消息内容为空,忽略")
585
+ return
586
+
587
+ # 判断是否需要转发到 LangBot
588
+ # all 模式:所有群消息都转发,让 LangBot 决定是否响应
589
+ # strict 模式:只有 @机器人 或 关键词触发 才转发
590
+ forward_mode = self._group_forward_mode
591
+ should_reply = is_at_bot_in_msg or is_keyword_trigger
592
+
593
+ if forward_mode == "all":
594
+ # 全部转发模式
595
+ logger.info(f"群聊消息(全部转发) [{group_wxid}][{sender_wxid}]: {content}")
596
+ await self.langbot.send_group_message(group_wxid, sender_wxid, content, at_bot=is_at_bot_in_msg)
597
+ await log_group_message(self.log_collector, group_wxid, sender_wxid, content, "processed")
598
+ elif should_reply:
599
+ # 严格模式,只转发触发的消息
600
+ trigger_type = "关键词" if is_keyword_trigger and not is_at_bot_in_msg else "@机器人"
601
+ logger.info(f"群聊消息({trigger_type}) [{group_wxid}][{sender_wxid}]: {content}")
602
+ await self.langbot.send_group_message(group_wxid, sender_wxid, content, at_bot=True)
603
+ await log_group_message(self.log_collector, group_wxid, sender_wxid, content, "processed")
604
+ else:
605
+ logger.debug(f"群聊消息未触发机器人,忽略: [{group_wxid}][{sender_wxid}]")
606
+
607
+ async def _handle_join_group_message(
608
+ self,
609
+ robot_wxid: str,
610
+ group_wxid: str,
611
+ content: str,
612
+ msg_xml: str = ""
613
+ ):
614
+ """处理入群系统消息
615
+
616
+ Args:
617
+ robot_wxid: 机器人wxid
618
+ group_wxid: 群wxid
619
+ content: 系统消息内容
620
+ msg_xml: 消息的XML内容
621
+ """
622
+ logger.info(f"检测到入群消息: [{group_wxid}] {content}")
623
+
624
+ # 查找匹配的欢迎配置
625
+ welcome_tasks = self.config_manager.get_welcome_tasks()
626
+
627
+ welcome_config = None
628
+ for task in welcome_tasks:
629
+ if not task.get('enabled', False):
630
+ continue
631
+ target_groups = task.get('target_groups', [])
632
+ # 如果 target_groups 为空,则匹配所有群;否则检查是否在列表中
633
+ if not target_groups or group_wxid in target_groups:
634
+ welcome_config = task
635
+ break
636
+
637
+ if not welcome_config:
638
+ logger.info(f"群 {group_wxid} 没有启用的欢迎配置")
639
+ return
640
+
641
+ logger.info(f"使用欢迎配置: {welcome_config.get('task_id', 'unnamed')}")
642
+
643
+ # 尝试从消息中提取新成员昵称
644
+ new_member_names = self._parse_new_member_names(content)
645
+ logger.info(f"解析到新成员昵称: {new_member_names}")
646
+
647
+ # 通过群成员列表匹配昵称获取wxid
648
+ new_member_wxids = []
649
+ if new_member_names and welcome_config.get('at_new_member', True):
650
+ # 等待一小段时间,让新成员数据同步到微信
651
+ await asyncio.sleep(5)
652
+ # 新成员刚入群,需要刷新缓存获取最新群成员列表
653
+ members = await self.qianxun.get_group_member_list(
654
+ robot_wxid, group_wxid, get_nick=True, refresh=True
655
+ )
656
+ if members:
657
+ new_member_wxids = self._match_member_wxids(members, new_member_names)
658
+ else:
659
+ logger.warning("获取群成员列表为空")
660
+
661
+ # 构建@新成员代码
662
+ at_members_str = ""
663
+ if welcome_config.get('at_new_member', True) and new_member_wxids:
664
+ at_codes = [f"[@,wxid={wxid},nick=,isAuto=true]" for wxid in new_member_wxids]
665
+ at_members_str = " ".join(at_codes)
666
+
667
+ # 发送欢迎消息
668
+ await self._send_welcome_messages(
669
+ robot_wxid, group_wxid, welcome_config, at_members_str, new_member_names
670
+ )
671
+
672
+ def _parse_new_member_names(self, content: str) -> list:
673
+ """从入群消息中解析新成员昵称
674
+
675
+ Args:
676
+ content: 系统消息内容
677
+
678
+ Returns:
679
+ 新成员昵称列表
680
+ """
681
+ new_member_names = []
682
+
683
+ # 格式1: "邀请者"邀请"新成员1、新成员2"加入了群聊
684
+ match = re.search(r'邀请"([^"]+)"加入了群聊', content)
685
+ if match:
686
+ names_str = match.group(1)
687
+ new_member_names = [n.strip() for n in names_str.split('、')]
688
+ else:
689
+ # 格式2: "新成员"通过扫描二维码加入群聊
690
+ match = re.search(r'"([^"]+)"通过扫描', content)
691
+ if match:
692
+ new_member_names = [match.group(1)]
693
+
694
+ return new_member_names
695
+
696
+ async def _send_welcome_messages(
697
+ self,
698
+ robot_wxid: str,
699
+ group_wxid: str,
700
+ welcome_config: dict,
701
+ at_members_str: str,
702
+ new_member_names: Optional[list] = None
703
+ ):
704
+ """发送欢迎消息的通用方法
705
+
706
+ Args:
707
+ robot_wxid: 机器人wxid
708
+ group_wxid: 群wxid
709
+ welcome_config: 欢迎配置
710
+ at_members_str: @成员字符串
711
+ new_member_names: 新成员昵称列表(用于替换{nickname}变量)
712
+ """
713
+ # 获取欢迎词列表
714
+ messages = welcome_config.get('messages', [])
715
+ if not messages:
716
+ # 兼容旧配置:使用单条 message 字段
717
+ single_message = welcome_config.get('message', '欢迎新朋友!')
718
+ messages = [single_message] if single_message else []
719
+
720
+ if not messages:
721
+ logger.warning("欢迎配置没有设置欢迎词")
722
+ return
723
+
724
+ # 分段发送配置
725
+ split_send = welcome_config.get('split_send', False)
726
+ split_min_delay = welcome_config.get('split_min_delay', 1)
727
+ split_max_delay = welcome_config.get('split_max_delay', 3)
728
+
729
+ # 发送欢迎消息
730
+ for i, message in enumerate(messages):
731
+ if not message.strip():
732
+ continue
733
+
734
+ # 替换变量
735
+ if new_member_names:
736
+ message = message.replace('{nickname}', '、'.join(new_member_names))
737
+
738
+ # 替换 {at_members} 变量
739
+ if '{at_members}' in message:
740
+ message = message.replace('{at_members}', at_members_str)
741
+ elif i == 0 and at_members_str:
742
+ # 第一条消息:如果没有 {at_members} 变量,在消息前面加上@
743
+ message = at_members_str + " " + message
744
+
745
+ # 发送消息
746
+ await self.qianxun.send_text(robot_wxid, group_wxid, message.strip())
747
+ logger.info(f"已发送入群欢迎 ({i+1}/{len(messages)}): [{group_wxid}]")
748
+
749
+ # 分段发送延迟(最后一条不需要延迟)
750
+ if split_send and i < len(messages) - 1:
751
+ delay = random.uniform(split_min_delay, split_max_delay)
752
+ logger.debug(f"分段发送延迟 {delay:.1f}秒")
753
+ await asyncio.sleep(delay)
754
+
755
+ def _match_member_wxids(self, members: list, new_member_names: list) -> list:
756
+ """匹配新成员wxid
757
+
758
+ Args:
759
+ members: 群成员列表
760
+ new_member_names: 新成员昵称列表
761
+
762
+ Returns:
763
+ 匹配到的wxid列表
764
+ """
765
+ # 构建昵称到wxid的映射
766
+ nick_to_wxid = {}
767
+ for m in members:
768
+ nick = m.get('groupNick', '')
769
+ wxid = m.get('wxid', '')
770
+ if nick and wxid:
771
+ nick_to_wxid[nick] = wxid
772
+
773
+ logger.info(f"群成员昵称列表: {list(nick_to_wxid.keys())}")
774
+
775
+ new_member_wxids = []
776
+ for name in new_member_names:
777
+ if name in nick_to_wxid:
778
+ new_member_wxids.append(nick_to_wxid[name])
779
+ logger.info(f"匹配到新成员: {name} -> {nick_to_wxid[name]}")
780
+ else:
781
+ # 尝试模糊匹配
782
+ matched = False
783
+ for nick, wxid in nick_to_wxid.items():
784
+ if name in nick or nick in name:
785
+ new_member_wxids.append(wxid)
786
+ logger.info(f"模糊匹配到新成员: {name} -> {nick} -> {wxid}")
787
+ matched = True
788
+ break
789
+ if not matched:
790
+ logger.warning(f"未匹配到新成员wxid: {name}")
791
+
792
+ return new_member_wxids
793
+
794
+ async def _handle_group_member_change(self, robot_wxid: str, data: dict):
795
+ """处理群成员变动事件(10016事件)"""
796
+ member_data = data.get('data', data)
797
+
798
+ group_wxid = member_data.get('fromWxid', '')
799
+ member_wxid = member_data.get('finalFromWxid', '')
800
+ event_type = member_data.get('eventType', -1)
801
+
802
+ # eventType: 0=退群, 1=进群
803
+ if event_type != 1:
804
+ logger.debug(f"群成员退出: [{group_wxid}] {member_wxid}")
805
+ return
806
+
807
+ logger.info(f"新成员入群(10016事件): [{group_wxid}] {member_wxid}")
808
+
809
+ # 查找匹配的欢迎配置
810
+ welcome_tasks = self.config_manager.get_welcome_tasks()
811
+
812
+ welcome_config = None
813
+ for task in welcome_tasks:
814
+ if not task.get('enabled', False):
815
+ continue
816
+ target_groups = task.get('target_groups', [])
817
+ if not target_groups or group_wxid in target_groups:
818
+ welcome_config = task
819
+ break
820
+
821
+ if not welcome_config:
822
+ logger.debug(f"群 {group_wxid} 没有启用的欢迎配置")
823
+ return
824
+
825
+ # 构建@新成员代码
826
+ at_member_str = ""
827
+ if welcome_config.get('at_new_member', True) and member_wxid:
828
+ at_member_str = f"[@,wxid={member_wxid},nick=,isAuto=true]"
829
+
830
+ # 发送欢迎消息
831
+ await self._send_welcome_messages(
832
+ robot_wxid, group_wxid, welcome_config, at_member_str
833
+ )