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.
- forcomebot-2.2.4.dist-info/METADATA +342 -0
- forcomebot-2.2.4.dist-info/RECORD +36 -0
- forcomebot-2.2.4.dist-info/WHEEL +4 -0
- forcomebot-2.2.4.dist-info/entry_points.txt +4 -0
- src/__init__.py +68 -0
- src/__main__.py +487 -0
- src/api/__init__.py +21 -0
- src/api/routes.py +775 -0
- src/api/websocket.py +280 -0
- src/auth/__init__.py +33 -0
- src/auth/database.py +87 -0
- src/auth/dingtalk.py +373 -0
- src/auth/jwt_handler.py +129 -0
- src/auth/middleware.py +260 -0
- src/auth/models.py +107 -0
- src/auth/routes.py +385 -0
- src/clients/__init__.py +7 -0
- src/clients/langbot.py +710 -0
- src/clients/qianxun.py +388 -0
- src/core/__init__.py +19 -0
- src/core/config_manager.py +411 -0
- src/core/log_collector.py +167 -0
- src/core/message_queue.py +364 -0
- src/core/state_store.py +242 -0
- src/handlers/__init__.py +8 -0
- src/handlers/message_handler.py +833 -0
- src/handlers/message_parser.py +325 -0
- src/handlers/scheduler.py +822 -0
- src/models.py +77 -0
- src/static/assets/index-B4i68B5_.js +50 -0
- src/static/assets/index-BPXisDkw.css +2 -0
- src/static/index.html +14 -0
- src/static/vite.svg +1 -0
- src/utils/__init__.py +13 -0
- src/utils/text_processor.py +166 -0
- src/utils/xml_parser.py +215 -0
|
@@ -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
|
+
)
|