nonebot-plugin-pxchat-enhanced 2.0.0__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,879 @@
1
+ from nonebot import on_message, logger, get_driver, require, get_plugin_config, get_bot
2
+ require("nonebot_plugin_localstore")
3
+ from nonebot.plugin import PluginMetadata
4
+ from nonebot.adapters.onebot.v11 import MessageEvent, Bot, Message, MessageSegment
5
+ from .chat import should_reply_in_group, get_chat_reply_with_tools, thinking_group_reply
6
+ from .context import get_context, add_message, clear_context, load_contexts, get_unjudged_messages, mark_messages_judged, has_unjudged_messages
7
+ from .memory import load_memories, record_group_user_message, get_group_memory_hint, record_interaction, prune_old_messages, flush_memories
8
+ from .state import load_state, record_reply as state_record_reply, record_group_message as state_record_group_message, skip_reply as state_skip_reply, get_consecutive_replies
9
+ from .admin import execute_mute_if_needed, check_bot_is_admin
10
+ from .manager import chat_manager
11
+ from .engagement import GroupEngagementManager, probability_states as group_probability_states, decay_timers as group_timers
12
+ from .commands import *
13
+ from .send2root import *
14
+ from .image2txt import *
15
+ from .config import *
16
+ from .log import logger as pxchat_logger, log_shutdown
17
+ import asyncio
18
+ import random
19
+ import json
20
+ import time
21
+ import re
22
+ from .mcp_manager import *
23
+ from typing import Dict, Set
24
+
25
+ __plugin_meta__ = PluginMetadata(
26
+ name="pxchat-enhanced",
27
+ description="基于AI大模型的拟人化聊天插件,支持多模型切换、上下文记忆、群聊智能参与、短期状态追踪、群成员记忆、图片识别、MCP工具调用、自动禁言等功能",
28
+ usage="使用px about命令获取插件信息,支持指令配置",
29
+ type="application",
30
+ homepage="https://github.com/Srythm/nonebot-plugin-pxchat-enhanced",
31
+ config=PluginConfig,
32
+ supported_adapters={"~onebot.v11"},
33
+ )
34
+
35
+ # 初始化管理器和上下文
36
+ load_contexts()
37
+ load_memories()
38
+ prune_old_messages() # 启动时清理超过6小时的旧消息
39
+ load_state()
40
+ # 读取配置文件
41
+ get_plugin_config(PluginConfig)
42
+ # 创建消息处理器,不限制规则,在handle中自行判断
43
+ chat = on_message(priority=50, block=False)
44
+
45
+ # ============================================================
46
+ # 延迟回复机制
47
+ # ============================================================
48
+
49
+ # 每群的延迟回复计时器 {group_id: asyncio.Task}
50
+ group_reply_timers: Dict[str, asyncio.Task] = {}
51
+ # 每群触发计时器的用户 {group_id: user_id}
52
+ group_timer_user: Dict[str, str] = {}
53
+ # 每群是否为@触发的计时器 {group_id: bool}
54
+ group_timer_is_at: Dict[str, bool] = {}
55
+ # 每群当前计时器的初始延迟 {group_id: delay_seconds}
56
+ group_timer_delay: Dict[str, float] = {}
57
+ # 每群最后一条消息时间 {group_id: timestamp}
58
+ group_last_message_time: Dict[str, float] = {}
59
+ # 群聊安静窗口:非@消息在最后一条群消息后至少等待这些秒数再判断
60
+ GROUP_QUIET_WINDOW = 8.0
61
+
62
+ # 模型建议的typing延迟映射
63
+ _TYPING_DELAY_MAP = {
64
+ "fast": (3.0, 6.0),
65
+ "normal": (8.0, 15.0),
66
+ "slow": (15.0, 25.0),
67
+ }
68
+
69
+
70
+ def _typing_delay_range(hint: str) -> tuple[float, float]:
71
+ """根据模型建议返回(最小延迟, 最大延迟)"""
72
+ return _TYPING_DELAY_MAP.get(hint, _TYPING_DELAY_MAP["normal"])
73
+
74
+
75
+ def _extract_user_ids_from_messages(messages: list) -> list[str]:
76
+ user_ids = []
77
+ for msg in messages:
78
+ content = msg.get("content", "")
79
+ for user_id in re.findall(r"用户(\d+)\(", content):
80
+ if user_id not in user_ids:
81
+ user_ids.append(user_id)
82
+ return user_ids
83
+
84
+
85
+ def _get_memory_hint(group_id: str, messages: list) -> str:
86
+ return get_group_memory_hint(group_id, _extract_user_ids_from_messages(messages))
87
+
88
+
89
+ def _extract_at_mentions(event) -> str:
90
+ """从消息中提取@提及的用户(排除机器人自己),返回 '用户123 用户456'"""
91
+ bot_id = None
92
+ try:
93
+ bot_id = str(get_bot().self_id)
94
+ except Exception:
95
+ pass
96
+ at_users = []
97
+ for seg in event.message:
98
+ if seg.type == "at" and seg.data.get("qq") != "all":
99
+ qq = str(seg.data.get("qq", ""))
100
+ if qq and qq != bot_id:
101
+ at_users.append(f"用户{qq}")
102
+ return " ".join(at_users)
103
+
104
+
105
+ def _extract_reply_info(event, key: str) -> str:
106
+ """
107
+ 从消息中提取回复/引用信息,返回 '[回复了 用户123: "xxx"]' 或 ''。
108
+ 从上下文中查找被回复的消息内容。
109
+ """
110
+ for seg in event.message:
111
+ if seg.type == "reply":
112
+ replied_id = str(seg.data.get("id", ""))
113
+ if not replied_id:
114
+ continue
115
+ # 从上下文中查找原消息
116
+ context = get_context(key)
117
+ for msg in context:
118
+ if msg.get("msg_id", "").startswith(replied_id):
119
+ content = msg.get("content", "")
120
+ # 提取消息摘要(前60字)
121
+ summary = content[:60].replace("\n", " ")
122
+ if len(content) > 60:
123
+ summary += "..."
124
+ return f"[回复了 {summary}]"
125
+ # 上下文中没找到,仅标记
126
+ return f"[回复了消息{replied_id}]"
127
+ return ""
128
+
129
+
130
+ def _extract_structured_text(event) -> str:
131
+ """
132
+ 从结构化消息段中提取文本(json/share/markdown/lightapp等)。
133
+ get_plaintext() 对这些类型返回空字符串。
134
+ """
135
+ for seg in event.message:
136
+ t = seg.type
137
+ data = seg.data
138
+ if t == "json" and data.get("data"):
139
+ try:
140
+ import json as _json
141
+ obj = _json.loads(data["data"])
142
+ # 提取常见字段
143
+ for field in ("prompt", "desc", "title", "content", "text", "meta"):
144
+ v = obj.get(field, "") or (obj.get("meta", {}).get(field, "") if isinstance(obj.get("meta"), dict) else "")
145
+ if v:
146
+ return f"[卡片: {str(v)[:100]}]"
147
+ except Exception:
148
+ return "[json卡片]"
149
+ elif t == "share":
150
+ title = data.get("title", "")
151
+ content = data.get("content", "")
152
+ text = f"{title} {content}".strip()
153
+ return f"[分享: {text[:100]}]" if text else "[分享链接]"
154
+ elif t == "markdown" and data.get("content"):
155
+ return f"[markdown: {str(data['content'])[:100]}]"
156
+ elif t == "lightapp":
157
+ return "[小程序卡片]"
158
+ elif t == "music":
159
+ title = data.get("title", "")
160
+ return f"[音乐分享: {title[:80]}]" if title else "[音乐分享]"
161
+ elif t == "contact":
162
+ ctype = data.get("type", "")
163
+ cid = data.get("id", "")
164
+ return f"[推荐{'好友' if ctype == 'qq' else '群'}: {cid}]"
165
+ elif t == "forward":
166
+ return "[转发消息]"
167
+ return ""
168
+
169
+
170
+ async def _handle_mute_recommendation(group_id: str, mute_users: list):
171
+ """处理模型返回的禁言建议"""
172
+ if not mute_users:
173
+ return
174
+ targets = []
175
+ default_duration = 0
176
+ for entry in mute_users:
177
+ if isinstance(entry, dict):
178
+ uid = str(entry.get("user_id", "")).strip()
179
+ hint = entry.get("duration_hint", 0)
180
+ if uid:
181
+ targets.append(uid)
182
+ if hint and (default_duration == 0 or hint < default_duration):
183
+ default_duration = int(hint)
184
+ if targets:
185
+ pxchat_logger.info(f"[管理] 群{group_id} 禁言建议: {targets}")
186
+ success = await execute_mute_if_needed(group_id, targets, default_duration)
187
+ if success:
188
+ pxchat_logger.info(f"[管理] 群{group_id} 已禁言: {success}")
189
+
190
+
191
+ def _should_reply_by_confidence(model_says_reply: bool, confidence: float, group_id: str) -> tuple[bool, float]:
192
+ """
193
+ 综合模型判断、动态门槛、连续回复惩罚决定是否回复。
194
+ 模型说"不回" → 直接拒绝
195
+ 模型说"回" + confidence ≥ 门槛 → 回复
196
+ 连续回复越多 → 门槛额外抬高
197
+ """
198
+ if not model_says_reply:
199
+ return False, 0
200
+ prob = group_manager.get_probability(group_id)
201
+ threshold = round(max(0.50, 1.0 - prob * 0.5), 2)
202
+ # 连续回复惩罚:每多一轮 +0.10
203
+ consecutive = get_consecutive_replies(group_id)
204
+ if consecutive > 0:
205
+ extra = consecutive * 0.10
206
+ threshold = round(min(0.95, threshold + extra), 2)
207
+ should = confidence >= threshold
208
+ if not should:
209
+ extra_info = f" 连续{consecutive}轮" if consecutive > 0 else ""
210
+ pxchat_logger.info(
211
+ f"[延迟] 群{group_id} confidence={confidence:.2f} < 门槛{threshold:.2f}(参与度{prob:.2f}{extra_info})"
212
+ )
213
+ return should, threshold
214
+
215
+
216
+ def _record_reply_interactions(group_id: str, context_messages: list):
217
+ """从上下文中提取参与本轮对话的用户,记录互动"""
218
+ user_ids = _extract_user_ids_from_messages(context_messages[-8:])
219
+ if not user_ids:
220
+ return
221
+ for uid in user_ids[:3]: # 最多记录3人
222
+ record_interaction(group_id, uid, "群聊参与")
223
+
224
+
225
+ async def _delayed_reply_check(group_id: str, key: str, is_at: bool = False, delay: float = 15.0):
226
+ """
227
+ 延迟回复检查
228
+ - 被@:等待3-5秒后直接回复
229
+ - 非@:等待15-20秒后结合上下文判断是否回复
230
+ """
231
+ await asyncio.sleep(delay)
232
+
233
+ if not is_at:
234
+ elapsed = time.time() - group_last_message_time.get(group_id, 0)
235
+ if elapsed < GROUP_QUIET_WINDOW:
236
+ remaining = max(1.0, GROUP_QUIET_WINDOW - elapsed)
237
+ task = asyncio.create_task(_delayed_reply_check(group_id, key, is_at=False, delay=remaining))
238
+ group_reply_timers[group_id] = task
239
+ group_timer_is_at[group_id] = False
240
+ # 安静窗口续等不重置延迟值,group_timer_delay 保持不变
241
+ pxchat_logger.info(f"[延迟] 群{group_id} 仍在说话,继续等待{remaining:.1f}s")
242
+ return
243
+
244
+ # 计时器触发,清理引用(先保存需要的值)
245
+ timer_user_id = group_timer_user.get(group_id)
246
+ if group_id in group_reply_timers:
247
+ del group_reply_timers[group_id]
248
+ if group_id in group_timer_user:
249
+ del group_timer_user[group_id]
250
+ if group_id in group_timer_is_at:
251
+ del group_timer_is_at[group_id]
252
+ if group_id in group_timer_delay:
253
+ del group_timer_delay[group_id]
254
+
255
+ # 检查群是否仍然启用
256
+ if not chat_manager.is_group_enabled(group_id):
257
+ pxchat_logger.info(f"[延迟] 群{group_id} 已禁用")
258
+ return
259
+
260
+ # 被@触发的计时器:直接回复,不需要判断
261
+ if is_at:
262
+ unjudged = get_unjudged_messages(key)
263
+ unjudged_ids = [msg.get("msg_id") for msg in unjudged if msg.get("msg_id")]
264
+
265
+ # 识别图片缓存
266
+ await _recognize_cached_images(key, unjudged)
267
+ memory_hint = _get_memory_hint(group_id, get_context(key))
268
+
269
+ # 生成回复
270
+ try:
271
+ reply = await get_chat_reply_with_tools(
272
+ get_context(key),
273
+ True,
274
+ reply_style="short",
275
+ decision_reason="有人直接@你",
276
+ memory_hint=memory_hint,
277
+ group_id=group_id,
278
+ )
279
+ add_message(key, "assistant", reply)
280
+ ctx_before_add = get_context(key)[:-1]
281
+ await send_delayed_group_reply(group_id, reply, ctx_before_add)
282
+ state_record_reply(group_id)
283
+ except Exception as e:
284
+ error_msg = f"@回复生成异常:\n {str(e)}"
285
+ pxchat_logger.error(error_msg)
286
+ await send_error_to_super_users(error_msg, None)
287
+
288
+ if unjudged_ids:
289
+ mark_messages_judged(key, unjudged_ids)
290
+ return
291
+
292
+ # 非@触发的计时器:需要判断是否回复
293
+ unjudged = get_unjudged_messages(key)
294
+ if not unjudged:
295
+ pxchat_logger.info(f"[延迟] 群{group_id} 无新消息")
296
+ return
297
+
298
+ # 收集未判断消息的ID,用于标记
299
+ unjudged_ids = [msg.get("msg_id") for msg in unjudged if msg.get("msg_id")]
300
+
301
+ # 按需前置识别图片(仅当未判断消息包含图片时触发)
302
+ await _recognize_cached_images(key, unjudged)
303
+
304
+ # 识别后获取最新上下文(确保判断时能看到识别结果)
305
+ full_context = get_context(key)
306
+ memory_hint = _get_memory_hint(group_id, full_context)
307
+
308
+ # 根据是否为思考模型走不同逻辑
309
+ is_thinking = chat_manager.is_thinking_enabled()
310
+
311
+ if is_thinking:
312
+ # ===== 思考模型:一次调用同时判断+回复 =====
313
+ try:
314
+ result = await thinking_group_reply(full_context, unjudged_ids, memory_hint, group_id=group_id)
315
+ except Exception as e:
316
+ error_msg = f"思考模式延迟回复异常:\n {str(e)}"
317
+ pxchat_logger.error(error_msg)
318
+ await send_error_to_super_users(error_msg, None)
319
+ if unjudged_ids:
320
+ mark_messages_judged(key, unjudged_ids)
321
+ return
322
+
323
+ # 处理禁言建议(回复或不回复都可能触发)
324
+ mute_users = result.get("mute_users", [])
325
+ if mute_users:
326
+ await _handle_mute_recommendation(group_id, mute_users)
327
+
328
+ # 标记已判断
329
+ if unjudged_ids:
330
+ mark_messages_judged(key, unjudged_ids)
331
+
332
+ # 动态置信度门槛(结合活跃度概率,模型判断优先)
333
+ model_says_reply = result.get("should_reply", False)
334
+ confidence = result.get("confidence", 0)
335
+ should_reply, threshold = _should_reply_by_confidence(model_says_reply, confidence, group_id)
336
+ if not should_reply:
337
+ if not model_says_reply:
338
+ pxchat_logger.info(f"[延迟] 群{group_id} 思考:不回复 (模型判断)")
339
+ else:
340
+ pxchat_logger.info(f"[延迟] 群{group_id} 思考:不回复 (conf={confidence:.2f}<{threshold:.2f})")
341
+ state_skip_reply(group_id)
342
+ return
343
+
344
+ pxchat_logger.info(f"[延迟] 群{group_id} 思考:回复 (conf={confidence:.2f}≥{threshold:.2f})")
345
+ group_manager.renew(group_id)
346
+
347
+ # 直接使用合并调用返回的回复内容
348
+ reply = result.get("reply")
349
+ if reply:
350
+ add_message(key, "assistant", reply)
351
+ await send_delayed_group_reply(group_id, reply, full_context)
352
+ state_record_reply(group_id)
353
+ else:
354
+ # ===== 非思考模型:先判断再回复(两步调用) =====
355
+ try:
356
+ decision = await should_reply_in_group(full_context, unjudged_ids, memory_hint, group_id=group_id)
357
+ except Exception as e:
358
+ error_msg = f"延迟回复判断异常:\n {str(e)}"
359
+ pxchat_logger.error(error_msg)
360
+ await send_error_to_super_users(error_msg, None)
361
+ if unjudged_ids:
362
+ mark_messages_judged(key, unjudged_ids)
363
+ return
364
+
365
+ # 处理禁言建议(回复或不回复都可能触发)
366
+ mute_users = decision.get("mute_users", [])
367
+ if mute_users:
368
+ await _handle_mute_recommendation(group_id, mute_users)
369
+
370
+ # 无论判断结果如何,标记这些消息为已判断
371
+ if unjudged_ids:
372
+ mark_messages_judged(key, unjudged_ids)
373
+
374
+ # 动态置信度门槛(结合活跃度概率,模型判断优先)
375
+ model_says_reply = decision.get("should_reply", False)
376
+ confidence = decision.get("confidence", 0)
377
+ should_reply, threshold = _should_reply_by_confidence(model_says_reply, confidence, group_id)
378
+ if not should_reply:
379
+ if not model_says_reply:
380
+ pxchat_logger.info(f"[延迟] 群{group_id} 判断:不回复 (模型判断)")
381
+ else:
382
+ pxchat_logger.info(f"[延迟] 群{group_id} 判断:不回复 (conf={confidence:.2f}<{threshold:.2f})")
383
+ state_skip_reply(group_id)
384
+ return
385
+
386
+ pxchat_logger.info(f"[延迟] 群{group_id} 判断:回复 (conf={confidence:.2f}≥{threshold:.2f}) {decision.get('reason', '')}")
387
+ group_manager.renew(group_id)
388
+
389
+ # 生成回复
390
+ try:
391
+ reply = await get_chat_reply_with_tools(
392
+ full_context,
393
+ True,
394
+ reply_style=decision.get("reply_style", "normal"),
395
+ decision_reason=decision.get("reason", ""),
396
+ memory_hint=memory_hint,
397
+ group_id=group_id,
398
+ skip_personality=True,
399
+ )
400
+ add_message(key, "assistant", reply)
401
+ await send_delayed_group_reply(group_id, reply, full_context)
402
+ state_record_reply(group_id)
403
+ # 记录互动(从上下文提取参与用户)
404
+ _record_reply_interactions(group_id, full_context)
405
+ except Exception as e:
406
+ error_msg = f"延迟回复生成异常:\n {str(e)}"
407
+ pxchat_logger.error(error_msg)
408
+ await send_error_to_super_users(error_msg, None)
409
+
410
+
411
+ async def _recognize_cached_images(key: str, unjudged_messages: list):
412
+ """对未判断消息中缓存的图片进行识别,将识别结果更新到上下文中"""
413
+ if not chat_manager.is_image_recognition_enabled():
414
+ return
415
+
416
+ context = get_context(key)
417
+ for msg in unjudged_messages:
418
+ msg_id = msg.get("msg_id")
419
+ if not msg_id:
420
+ continue
421
+ # 检查该消息是否有图片缓存
422
+ cached_images = image_cache.pop(msg_id, None)
423
+ if not cached_images:
424
+ continue
425
+
426
+ # 在上下文中找到该消息并追加识别结果
427
+ for ctx_msg in context:
428
+ if ctx_msg.get("msg_id") == msg_id and "[图片待识别]" in ctx_msg.get("content", ""):
429
+ recognition_list = []
430
+ for i, img_data in enumerate(cached_images):
431
+ try:
432
+ result = await recognize_image_from_cache(img_data)
433
+ recognition_list.append(f"[图片{i + 1}的识别结果]{result}")
434
+ except Exception as e:
435
+ error_msg = f"图片识别失败: {str(e)}"
436
+ pxchat_logger.info(error_msg)
437
+ recognition_list.append(f"[图片{i + 1}识别失败]")
438
+
439
+ # 更新上下文中的消息内容
440
+ ctx_msg["content"] = ctx_msg["content"].replace(
441
+ "[图片待识别]",
442
+ "\n".join(recognition_list)
443
+ )
444
+ pxchat_logger.info(f"消息{msg_id} 图片识别完成")
445
+ break
446
+
447
+ # 保存更新后的上下文
448
+ from .context import save_contexts
449
+ save_contexts()
450
+
451
+
452
+ def start_or_reset_group_timer(group_id: str, user_id: str, key: str, is_at: bool = False):
453
+ """
454
+ 启动或重置群级别的延迟回复计时器
455
+ 重置时保留原始等待时长,不重新随机
456
+ """
457
+ group_last_message_time[group_id] = time.time()
458
+
459
+ if group_id in group_reply_timers and group_id in group_timer_user:
460
+ current_is_at = group_timer_is_at.get(group_id, False)
461
+ if is_at or not current_is_at:
462
+ # @消息使用新随机短延迟;非@重置时复用已存储的延迟
463
+ if is_at:
464
+ delay = random.uniform(3, 5)
465
+ group_timer_delay[group_id] = delay
466
+ else:
467
+ delay = group_timer_delay.get(group_id, random.uniform(15, 20))
468
+ group_reply_timers[group_id].cancel()
469
+ task = asyncio.create_task(_delayed_reply_check(group_id, key, is_at, delay))
470
+ group_reply_timers[group_id] = task
471
+ group_timer_user[group_id] = user_id
472
+ group_timer_is_at[group_id] = is_at
473
+ pxchat_logger.info(f"[延迟] 群{group_id} 新消息重置{'(@)' if is_at else ''}等待{delay:.1f}s")
474
+ else:
475
+ pxchat_logger.info(f"[延迟] 群{group_id} 已有@计时器,保留快速回复")
476
+ else:
477
+ # 无活跃计时器,启动新计时器
478
+ delay = random.uniform(3, 5) if is_at else random.uniform(15, 20)
479
+ group_timer_delay[group_id] = delay
480
+ task = asyncio.create_task(_delayed_reply_check(group_id, key, is_at, delay))
481
+ group_reply_timers[group_id] = task
482
+ group_timer_user[group_id] = user_id
483
+ group_timer_is_at[group_id] = is_at
484
+ pxchat_logger.info(f"[延迟] 群{group_id} 用户{user_id}{'(@)' if is_at else ''}等待{delay:.1f}s")
485
+
486
+
487
+ def cancel_group_timer(group_id: str):
488
+ """取消群的延迟回复计时器"""
489
+ if group_id in group_reply_timers:
490
+ group_reply_timers[group_id].cancel()
491
+ del group_reply_timers[group_id]
492
+ if group_id in group_timer_user:
493
+ del group_timer_user[group_id]
494
+ if group_id in group_timer_is_at:
495
+ del group_timer_is_at[group_id]
496
+ if group_id in group_timer_delay:
497
+ del group_timer_delay[group_id]
498
+ if group_id in group_last_message_time:
499
+ del group_last_message_time[group_id]
500
+
501
+
502
+ def cancel_all_group_timers():
503
+ """取消所有延迟回复计时器"""
504
+ for group_id in list(group_reply_timers.keys()):
505
+ group_reply_timers[group_id].cancel()
506
+ group_reply_timers.clear()
507
+ group_timer_user.clear()
508
+ group_timer_is_at.clear()
509
+ group_last_message_time.clear()
510
+
511
+
512
+ async def send_delayed_group_reply(group_id: str, reply: str, context_messages: list = None):
513
+ """发送延迟回复到群聊(不@任何人),支持引用回复"""
514
+ try:
515
+ bot = get_bot()
516
+ except ValueError:
517
+ pxchat_logger.error("[延迟回复] 无法获取Bot实例")
518
+ return
519
+
520
+ segments = []
521
+ typing_hint = "normal"
522
+ quote_target = None
523
+ try:
524
+ data = json.loads(reply)
525
+ if isinstance(data, dict) and "reply" in data and isinstance(data["reply"], list):
526
+ segments = [seg for seg in data["reply"] if seg and seg.strip()]
527
+ typing_hint = data.get("typing_delay_hint", "normal")
528
+ quote_target = data.get("quote_target")
529
+ except (json.JSONDecodeError, TypeError):
530
+ pxchat_logger.error("[延迟回复] 回复格式解析失败")
531
+ return
532
+
533
+ if not segments:
534
+ return
535
+
536
+ # 根据id精确查找需要引用的消息
537
+ reply_prefix = ""
538
+ if isinstance(quote_target, str) and quote_target.strip() and context_messages:
539
+ target = quote_target.strip()
540
+ for msg in context_messages:
541
+ msg_id = msg.get("msg_id", "")
542
+ if msg_id and msg_id.startswith(target):
543
+ real_id = msg_id.split("_")[0]
544
+ reply_prefix = f"[CQ:reply,id={real_id}]"
545
+ break
546
+
547
+ delay_range = _typing_delay_range(typing_hint)
548
+ for i, segment in enumerate(segments):
549
+ try:
550
+ msg = reply_prefix + segment if i == 0 and reply_prefix else segment
551
+ await bot.call_api("send_group_msg", group_id=int(group_id), message=msg)
552
+ if i < len(segments) - 1:
553
+ await asyncio.sleep(random.uniform(*delay_range))
554
+ except Exception as e:
555
+ pxchat_logger.error(f"[延迟回复] 发送消息失败: {e}")
556
+ break
557
+
558
+
559
+ # ============================================================
560
+ # 图片缓存
561
+ # ============================================================
562
+
563
+ # 图片缓存 {msg_id: [image_bytes_1, image_bytes_2, ...]}
564
+ image_cache: Dict[str, list] = {}
565
+
566
+
567
+ async def cache_images(event: MessageEvent, msg_id: str) -> bool:
568
+ """
569
+ 检测消息中的图片并下载缓存,返回是否包含图片
570
+ """
571
+ if not chat_manager.is_image_recognition_enabled():
572
+ return False
573
+
574
+ image_urls = []
575
+ for seg in event.message:
576
+ if seg.type == "image":
577
+ url = seg.data.get("url")
578
+ if url:
579
+ image_urls.append(url)
580
+
581
+ if not image_urls:
582
+ return False
583
+
584
+ cached_bytes = []
585
+ for url in image_urls:
586
+ try:
587
+ import httpx
588
+ async with httpx.AsyncClient(timeout=60, follow_redirects=True) as session:
589
+ response = await session.get(url)
590
+ response.raise_for_status()
591
+ cached_bytes.append(response.content)
592
+ pxchat_logger.info(f"图片缓存: {url[:50]}... ({len(response.content)/1024:.0f}KB)")
593
+ except Exception as e:
594
+ pxchat_logger.warning(f"图片下载缓存失败: {e}")
595
+
596
+ if cached_bytes:
597
+ image_cache[msg_id] = cached_bytes
598
+ return True
599
+ return False
600
+
601
+
602
+ def cleanup_image_cache(msg_id: str = None):
603
+ """清理图片缓存"""
604
+ if msg_id:
605
+ image_cache.pop(msg_id, None)
606
+ else:
607
+ image_cache.clear()
608
+
609
+
610
+ # ============================================================
611
+ # 消息分段发送
612
+ # ============================================================
613
+
614
+ async def send_split_messages(chat_handler, message: str, event: MessageEvent = None, delay_range: tuple = (8, 15)):
615
+ """
616
+ 分段发送消息,支持@回复。延迟优先使用模型建议的 typing_delay_hint
617
+ """
618
+ if not message:
619
+ return
620
+
621
+ segments = []
622
+ typing_hint = "normal"
623
+
624
+ try:
625
+ data = json.loads(message)
626
+ if isinstance(data, dict) and "reply" in data and isinstance(data["reply"], list):
627
+ segments = [segment for segment in data["reply"] if segment and segment.strip()]
628
+ typing_hint = data.get("typing_delay_hint", "normal")
629
+ except (json.JSONDecodeError, TypeError) as e:
630
+ error_msg = f"处理聊天请求时发生异常:\n {str(e)}"
631
+ await send_error_to_super_users(error_msg, event)
632
+ return
633
+
634
+ if not segments:
635
+ return
636
+
637
+ delay_range = _typing_delay_range(typing_hint)
638
+
639
+ if event and hasattr(event, 'group_id') and event.group_id and event.is_tome():
640
+ first_segment = segments[0]
641
+ at_message = Message(f"[CQ:at,qq={event.user_id}] {first_segment}")
642
+ await chat_handler.send(at_message)
643
+
644
+ for segment in segments[1:]:
645
+ await asyncio.sleep(random.uniform(*delay_range))
646
+ await chat_handler.send(segment)
647
+ else:
648
+ for i, segment in enumerate(segments):
649
+ await chat_handler.send(segment)
650
+ if i < len(segments) - 1:
651
+ await asyncio.sleep(random.uniform(*delay_range))
652
+
653
+
654
+ # ============================================================
655
+ # 消息处理主逻辑
656
+ # ============================================================
657
+
658
+ # 私聊无意义消息快速过滤(纯emoji、单标点、单字无意义、空消息)
659
+ _PRIVATE_MEANINGLESS_PATTERNS = re.compile(
660
+ r"^[\s\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f\u2000-\u206f\ufe00-\ufe0f\uff00-\uffef]*$"
661
+ )
662
+ _PRIVATE_SKIP_SINGLE_CHARS = {"嗯", "哦", "好", "行", "是", "对", "啊", "呀", "哈", "呵", "嘿", "诶", "?", "?", "!", "!", ".", "。"}
663
+
664
+
665
+ def _is_meaningless_private_message(text: str) -> bool:
666
+ """判断私聊消息是否无意义,可跳过回复"""
667
+ if not text:
668
+ return True
669
+ # 纯标点/空白/特殊字符
670
+ if _PRIVATE_MEANINGLESS_PATTERNS.match(text):
671
+ return True
672
+ # 单字无意义
673
+ if len(text) <= 1 and text in _PRIVATE_SKIP_SINGLE_CHARS:
674
+ return True
675
+ return False
676
+
677
+
678
+ @chat.handle()
679
+ async def _(bot: Bot, event: MessageEvent):
680
+ # 忽略机器人自己的消息(避免回复-触发-回复循环)
681
+ if str(event.user_id) == str(event.self_id):
682
+ return
683
+
684
+ # 检查全局开关
685
+ if not chat_manager.is_chat_enabled():
686
+ return
687
+
688
+ if not chat_manager.get_super_users():
689
+ await chat.finish("请在配置文件中添加管理员账号")
690
+
691
+ # 获取群聊ID
692
+ group_id = getattr(event, "group_id", None)
693
+ user_id = str(event.user_id)
694
+
695
+ # 构建上下文key
696
+ if group_id:
697
+ group_id_str = str(group_id)
698
+ if not chat_manager.is_group_enabled(group_id_str):
699
+ return
700
+ key = f"group_{group_id}"
701
+ is_group = True
702
+ else:
703
+ key = user_id
704
+ is_group = False
705
+
706
+ user_msg2 = str(event.get_plaintext())
707
+ if user_msg2.startswith("px "):
708
+ return
709
+
710
+ if user_msg2 in ["清除对话", "重置对话"]:
711
+ clear_context(key)
712
+ await chat.finish("已清除对话历史")
713
+
714
+ # 生成消息唯一ID(使用消息ID+用户ID+时间戳)
715
+ msg_id = f"{getattr(event, 'message_id', '')}_{user_id}_{int(time.time() * 1000)}"
716
+
717
+ # 群聊特殊处理
718
+ if is_group:
719
+ # 检测并缓存图片(仅下载,不识别)
720
+ has_images = await cache_images(event, msg_id)
721
+
722
+ # 构建消息内容
723
+ user_text = event.get_plaintext().strip()
724
+ # 纯文本为空时尝试从结构化消息段提取(json卡片/分享/小程序等)
725
+ if not user_text:
726
+ user_text = _extract_structured_text(event)
727
+ # 群昵称优先于QQ昵称
728
+ if event.sender:
729
+ nickname = event.sender.card or event.sender.nickname or '未知用户'
730
+ else:
731
+ nickname = '未知用户'
732
+ # 提取消息中的@提及(get_plaintext会丢失@信息)
733
+ at_mentions = _extract_at_mentions(event)
734
+ record_group_user_message(group_id_str, user_id, nickname, user_text)
735
+ state_record_group_message(group_id_str, user_text)
736
+ # 突发检测:短时间大量消息时重置参与度
737
+ group_manager.record_message_and_check_burst(group_id_str)
738
+ user_info = f"用户{user_id}({nickname})说:"
739
+ if at_mentions:
740
+ user_info += f"[@{at_mentions}] "
741
+ # 提取回复/引用信息(get_plaintext会丢失CQ:reply)
742
+ reply_info = _extract_reply_info(event, key)
743
+ if reply_info:
744
+ user_info += f"{reply_info} "
745
+ if has_images:
746
+ user_message_with_info = f"{user_info}: {user_text}\n[图片待识别]"
747
+ else:
748
+ user_message_with_info = f"{user_info}: {user_text}"
749
+
750
+ # 添加到上下文(带msg_id)
751
+ add_message(key, "user", user_message_with_info, msg_id)
752
+
753
+ # 判断是否被@
754
+ is_at = event.is_tome()
755
+
756
+ if is_at:
757
+ pxchat_logger.info(f"群聊被@")
758
+ group_manager.renew(group_id_str)
759
+ # 被@也走延迟计时器,但用更短的时间
760
+ start_or_reset_group_timer(group_id_str, user_id, key, is_at=True)
761
+ return
762
+ else:
763
+ # 非@,启动/重置延迟回复计时器
764
+ start_or_reset_group_timer(group_id_str, user_id, key, is_at=False)
765
+ return
766
+ else:
767
+ # 私聊:过滤无意义消息后处理
768
+ user_text = event.get_plaintext().strip()
769
+ # 跳过纯表情/单标点/空消息等无意义内容
770
+ if _is_meaningless_private_message(user_text):
771
+ pxchat_logger.info(f"[私聊] 跳过无意义消息: {user_text[:30]}")
772
+ return
773
+
774
+ # 私聊延迟1-3秒,模拟阅读/思考
775
+ await asyncio.sleep(random.uniform(1.0, 3.0))
776
+
777
+ user_msg = await event_proc(event)
778
+ add_message(key, "user", user_msg)
779
+
780
+ # 调用聊天接口(仅私聊走到这里,群聊都走延迟回复)
781
+ try:
782
+ reply = await get_chat_reply_with_tools(get_context(key), is_group)
783
+ add_message(key, "assistant", reply)
784
+ await send_split_messages(chat, reply, event if is_group else None)
785
+
786
+ except Exception as e:
787
+ error_msg = f"处理聊天请求时发生异常:\n {str(e)}"
788
+ await send_error_to_super_users(error_msg, event)
789
+ await chat.send("抱歉,处理消息时出现了问题,已通知管理员")
790
+
791
+
792
+ # ============================================================
793
+ # 图片识别(私聊/被@时立即使用)
794
+ # ============================================================
795
+
796
+ async def event_proc(event: MessageEvent):
797
+ """私聊消息预处理:立即识别图片"""
798
+ user_text = event.get_plaintext().strip()
799
+ recognition_msg = f"{user_text}\n"
800
+ if chat_manager.is_image_recognition_enabled():
801
+ image_urls = []
802
+ for seg in event.message:
803
+ if seg.type == "image":
804
+ image_urls.append(seg.data.get("url"))
805
+ if image_urls:
806
+ try:
807
+ recognition_list = []
808
+ for i, image_url in enumerate(image_urls):
809
+ result = await recognize_image(image_url)
810
+ recognition_list.append(f"[图片{i + 1}的识别结果]{result}")
811
+ recognition_msg += "\n".join(recognition_list)
812
+ except Exception as e:
813
+ pxchat_logger.info(f"图片识别失败: {e}")
814
+ await send_error_to_super_users(f"图片识别失败: {e}", event)
815
+ recognition_msg += f"\n[图片识别失败](你现在还没有图片识别的能力)"
816
+ return recognition_msg
817
+
818
+
819
+ # ============================================================
820
+ # 参与度管理器(从 engagement.py 导入)
821
+ # ============================================================
822
+ group_manager: GroupEngagementManager = GroupEngagementManager()
823
+
824
+
825
+ # ============================================================
826
+ # 调试命令
827
+ # ============================================================
828
+
829
+ debug_cmd = on_command("px activity", priority=5, block=True)
830
+
831
+ @debug_cmd.handle()
832
+ async def handle_debug_cmd(event: MessageEvent):
833
+ if not await check_super_user(event):
834
+ await mcp_cmd.finish("你没有权限")
835
+
836
+ tasks = asyncio.all_tasks()
837
+ current_task = asyncio.current_task()
838
+
839
+ delayed_timer_count = len(group_reply_timers)
840
+ delayed_timer_details = []
841
+ for gid, task in group_reply_timers.items():
842
+ delayed_timer_details.append(f" 群{gid}: {'运行中' if not task.done() else '已结束'}")
843
+
844
+ status_lines = [
845
+ "🤖 任务调试信息:",
846
+ f"📊 总任务数: {len(tasks)}",
847
+ f"🎯 活跃度管理器任务数: {len(group_timers)}",
848
+ f"📈 活跃度状态数: {len(group_probability_states)}",
849
+ f"⏱️ 延迟回复计时器数: {delayed_timer_count}",
850
+ f"🖼️ 图片缓存数: {len(image_cache)}",
851
+ "",
852
+ "📋 活跃度管理器状态:",
853
+ f" 活跃群组: {list(group_timers.keys())}",
854
+ f" 活跃度状态: {group_probability_states}",
855
+ "",
856
+ "📋 延迟回复计时器状态:",
857
+ ]
858
+ if delayed_timer_details:
859
+ status_lines.extend(delayed_timer_details)
860
+ else:
861
+ status_lines.append(" 无活跃计时器")
862
+
863
+ await debug_cmd.finish("\n".join(status_lines))
864
+
865
+
866
+ # ============================================================
867
+ # 关闭钩子
868
+ # ============================================================
869
+
870
+ driver = get_driver()
871
+
872
+ @driver.on_shutdown
873
+ async def shutdown_hook():
874
+ cancel_all_group_timers()
875
+ cleanup_image_cache()
876
+ flush_memories() # 确保内存中的记忆写入磁盘
877
+ if group_manager:
878
+ await group_manager.shutdown()
879
+ log_shutdown(pxchat_logger)