From 2c564d28703962dc5956b1b24f09ab5b181cc177 Mon Sep 17 00:00:00 2001 From: shihao <3127647737@qq.com> Date: Tue, 23 Dec 2025 16:46:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E8=AF=86=E5=88=AB=E7=BE=A4=E6=98=B5?= =?UTF-8?q?=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WechatHook/client.py | 83 +++++++++++++++++++--- WechatHook/message_types.py | 2 + plugins/AIChat/main.py | 119 ++++++++++++++++++++++++++++++-- plugins/AIChat/prompts/瑞依.txt | 2 + plugins/AutoReply/main.py | 60 ++++++++++++++++ utils/hookbot.py | 69 +++++++++++++++++- 6 files changed, 320 insertions(+), 15 deletions(-) diff --git a/WechatHook/client.py b/WechatHook/client.py index a6a95ba..842860b 100644 --- a/WechatHook/client.py +++ b/WechatHook/client.py @@ -600,7 +600,7 @@ class WechatHookClient: async def get_chatroom_members(self, chatroom_id: str) -> List[Dict]: """ - 获取群成员列表(使用协议 API) + 获取群成员列表(优先 11032,失败则降级协议 API) Args: chatroom_id: 群聊 ID @@ -608,6 +608,25 @@ class WechatHookClient: Returns: 群成员列表,每个成员包含: wxid, nickname, display_name, avatar """ + # 方案1:type=11032(包含 display_name=群内昵称/群名片) + try: + raw_members = await self._get_chatroom_members_via_11032(chatroom_id, timeout=6) + if raw_members: + members = [] + for m in raw_members: + members.append( + { + "wxid": m.get("wxid", ""), + "nickname": m.get("nickname", ""), + "display_name": m.get("display_name", ""), + "avatar": m.get("avatar", ""), + } + ) + logger.success(f"获取群成员成功(11032): {chatroom_id}, 成员数: {len(members)}") + return members + except Exception as e: + logger.debug(f"11032 获取群成员失败,降级协议 API: {chatroom_id}, {e}") + # 生成唯一请求ID request_id = str(uuid.uuid4()) @@ -633,6 +652,41 @@ class WechatHookClient: return members + async def _get_chatroom_members_via_11032(self, chatroom_id: str, timeout: int = 10) -> List[Dict]: + """ + 获取群成员信息(type=11032),返回原始 member_list + + 请求: + type=11032 + data={"room_wxid": chatroom_id} + """ + request_id = str(uuid.uuid4()) + event = asyncio.Event() + result_data = {"members": [], "success": False} + + request_key = f"chatroom_members_{chatroom_id}" + self.pending_requests[request_key] = { + "request_id": request_id, + "event": event, + "result": result_data, + "type": "chatroom_members", + "chatroom_id": chatroom_id, + } + + try: + await self._send_data_async(11032, {"room_wxid": chatroom_id}) + logger.info(f"请求群成员信息(11032): {chatroom_id}, request_id: {request_id}") + await asyncio.wait_for(event.wait(), timeout=timeout) + if result_data.get("success"): + return result_data.get("members") or [] + return [] + except asyncio.TimeoutError: + logger.debug(f"获取群成员信息(11032)超时: {chatroom_id}") + return [] + finally: + # 清理请求 + self.pending_requests.pop(request_key, None) + async def _wait_for_chatroom_info(self, chatroom_id: str, timeout: int = 15) -> List[Dict]: """等待群信息回调(type=11174)""" request_key = f"chatroom_info_{chatroom_id}" @@ -1083,17 +1137,28 @@ class WechatHookClient: logger.info(f"收到群成员信息响应: group_wxid={group_wxid}, 成员数={len(member_list)}") - # 查找对应的待处理请求 - if group_wxid in self.pending_requests: - request_info = self.pending_requests[group_wxid] - - # 存储结果数据 + # 查找对应的待处理请求(兼容不同 key 方案) + request_info = None + key_candidates = [] + if group_wxid: + key_candidates.extend([group_wxid, f"chatroom_members_{group_wxid}"]) + + for k in key_candidates: + if k in self.pending_requests: + request_info = self.pending_requests[k] + break + + # 最后兜底:按类型/目标群匹配 + if request_info is None and group_wxid: + for _, info in list(self.pending_requests.items()): + if info.get("type") == "chatroom_members" and info.get("chatroom_id") == group_wxid: + request_info = info + break + + if request_info: request_info["result"]["members"] = member_list request_info["result"]["success"] = True - - # 触发等待事件 request_info["event"].set() - logger.success(f"群成员信息处理完成: {group_wxid}") else: logger.warning(f"未找到对应的群成员请求: {group_wxid}") diff --git a/WechatHook/message_types.py b/WechatHook/message_types.py index da19176..12088a8 100644 --- a/WechatHook/message_types.py +++ b/WechatHook/message_types.py @@ -78,6 +78,8 @@ def normalize_message(msg_type: int, data: dict) -> dict: # 基础消息结构 message = { "MsgType": msg_type, + # 消息唯一ID(用于去重/撤回等)。个微 API 通常为 msgid 字段。 + "MsgId": data.get("msgid") or data.get("msg_id") or data.get("id") or "", "FromWxid": data.get("from_wxid", ""), "ToWxid": data.get("to_wxid", ""), "Content": data.get("msg", data.get("content", data.get("raw_msg", ""))), # 系统消息使用 raw_msg diff --git a/plugins/AIChat/main.py b/plugins/AIChat/main.py index 5db7e9b..e3cd4d0 100644 --- a/plugins/AIChat/main.py +++ b/plugins/AIChat/main.py @@ -10,6 +10,7 @@ import tomllib import aiohttp import json import re +import time from pathlib import Path from datetime import datetime from loguru import logger @@ -49,6 +50,9 @@ class AIChat(PluginBase): self.image_desc_workers = [] # 工作协程列表 self.persistent_memory_db = None # 持久记忆数据库路径 self.store = None # ContextStore 实例(统一存储) + self._chatroom_member_cache = {} # {chatroom_id: (ts, {wxid: display_name})} + self._chatroom_member_cache_locks = {} # {chatroom_id: asyncio.Lock} + self._chatroom_member_cache_ttl_seconds = 3600 # 群名片缓存1小时,减少协议 API 调用 async def async_init(self): """插件异步初始化""" @@ -163,6 +167,92 @@ class AIChat(PluginBase): else: return sender_wxid or from_wxid # 私聊使用用户ID + def _sanitize_speaker_name(self, name: str) -> str: + """清洗昵称,避免破坏历史格式(如 [name] 前缀)。""" + if name is None: + return "" + s = str(name).strip() + if not s: + return "" + s = s.replace("\r", " ").replace("\n", " ") + s = re.sub(r"\s{2,}", " ", s) + # 避免与历史前缀 [xxx] 冲突 + s = s.replace("[", "(").replace("]", ")") + return s.strip() + + def _combine_display_and_nickname(self, display_name: str, wechat_nickname: str) -> str: + display_name = self._sanitize_speaker_name(display_name) + wechat_nickname = self._sanitize_speaker_name(wechat_nickname) + # 重要:群昵称(群名片) 与 微信昵称(全局) 是两个不同概念,尽量同时给 AI。 + if display_name and wechat_nickname: + return f"群昵称={display_name} | 微信昵称={wechat_nickname}" + if display_name: + return f"群昵称={display_name}" + if wechat_nickname: + return f"微信昵称={wechat_nickname}" + return "" + + def _get_chatroom_member_lock(self, chatroom_id: str) -> asyncio.Lock: + lock = self._chatroom_member_cache_locks.get(chatroom_id) + if lock is None: + lock = asyncio.Lock() + self._chatroom_member_cache_locks[chatroom_id] = lock + return lock + + async def _get_group_display_name(self, bot, chatroom_id: str, user_wxid: str, *, force_refresh: bool = False) -> str: + """获取群名片(群内昵称)。失败时返回空串。""" + if not chatroom_id or not user_wxid: + return "" + if not hasattr(bot, "get_chatroom_members"): + return "" + + now = time.time() + if not force_refresh: + cached = self._chatroom_member_cache.get(chatroom_id) + if cached: + ts, member_map = cached + if now - float(ts or 0) < float(self._chatroom_member_cache_ttl_seconds or 0): + return self._sanitize_speaker_name(member_map.get(user_wxid, "")) + + lock = self._get_chatroom_member_lock(chatroom_id) + async with lock: + now = time.time() + if not force_refresh: + cached = self._chatroom_member_cache.get(chatroom_id) + if cached: + ts, member_map = cached + if now - float(ts or 0) < float(self._chatroom_member_cache_ttl_seconds or 0): + return self._sanitize_speaker_name(member_map.get(user_wxid, "")) + + try: + # 群成员列表可能较大,避免长期阻塞消息处理 + members = await asyncio.wait_for(bot.get_chatroom_members(chatroom_id), timeout=8) + except Exception as e: + logger.debug(f"获取群成员列表失败: {chatroom_id}, {e}") + return "" + + member_map = {} + try: + for m in members or []: + wxid = (m.get("wxid") or "").strip() + if not wxid: + continue + display_name = m.get("display_name") or m.get("displayName") or "" + member_map[wxid] = str(display_name or "").strip() + except Exception as e: + logger.debug(f"解析群成员列表失败: {chatroom_id}, {e}") + + self._chatroom_member_cache[chatroom_id] = (time.time(), member_map) + return self._sanitize_speaker_name(member_map.get(user_wxid, "")) + + async def _get_user_display_label(self, bot, from_wxid: str, user_wxid: str, is_group: bool) -> str: + """用于历史记录:群聊优先使用群名片,其次微信昵称。""" + if not is_group: + return "" + wechat_nickname = await self._get_user_nickname(bot, from_wxid, user_wxid, is_group) + group_display = await self._get_group_display_name(bot, from_wxid, user_wxid) + return self._combine_display_and_nickname(group_display, wechat_nickname) or wechat_nickname or user_wxid + async def _get_user_nickname(self, bot, from_wxid: str, user_wxid: str, is_group: bool) -> str: """ 获取用户昵称,优先使用 Redis 缓存 @@ -1231,6 +1321,25 @@ class AIChat(PluginBase): await self._handle_list_prompts(bot, from_wxid) return False + # 昵称测试:返回“微信昵称(全局)”和“群昵称/群名片(群内)” + if content == "/昵称测试": + if not is_group: + await bot.send_text(from_wxid, "该指令仅支持群聊:/昵称测试") + return False + + wechat_nickname = await self._get_user_nickname(bot, from_wxid, user_wxid, is_group) + group_nickname = await self._get_group_display_name(bot, from_wxid, user_wxid, force_refresh=True) + + wechat_nickname = self._sanitize_speaker_name(wechat_nickname) or "(未获取到)" + group_nickname = self._sanitize_speaker_name(group_nickname) or "(未设置/未获取到)" + + await bot.send_text( + from_wxid, + f"微信昵称: {wechat_nickname}\n" + f"群昵称: {group_nickname}", + ) + return False + # 检查是否是切换人设指令(精确匹配前缀) if content.startswith("/切人设 ") or content.startswith("/切换人设 "): if user_wxid in admins: @@ -1304,7 +1413,7 @@ class AIChat(PluginBase): if content.startswith("/记录 "): memory_content = content[4:].strip() if memory_content: - nickname = await self._get_user_nickname(bot, from_wxid, user_wxid, is_group) + nickname = await self._get_user_display_label(bot, from_wxid, user_wxid, is_group) # 群聊用群ID,私聊用用户ID memory_chat_id = from_wxid if is_group else user_wxid chat_type = "group" if is_group else "private" @@ -1363,7 +1472,7 @@ class AIChat(PluginBase): should_reply = self._should_reply(message, content, bot_wxid) # 获取用户昵称(用于历史记录)- 使用缓存优化 - nickname = await self._get_user_nickname(bot, from_wxid, user_wxid, is_group) + nickname = await self._get_user_display_label(bot, from_wxid, user_wxid, is_group) # 提取实际消息内容(去除@),仅在需要回复时使用 actual_content = "" @@ -2382,7 +2491,7 @@ class AIChat(PluginBase): memory_content += f" (备注: {extra_note})" # 保存到持久记忆 - nickname = await self._get_user_nickname(bot, from_wxid, user_wxid, is_group) + nickname = await self._get_user_display_label(bot, from_wxid, user_wxid, is_group) memory_chat_id = from_wxid if is_group else user_wxid chat_type = "group" if is_group else "private" memory_id = self._add_persistent_memory( @@ -2473,7 +2582,7 @@ class AIChat(PluginBase): return False # 获取用户昵称 - 使用缓存优化 - nickname = await self._get_user_nickname(bot, from_wxid, user_wxid, is_group) + nickname = await self._get_user_display_label(bot, from_wxid, user_wxid, is_group) chat_id = self._get_chat_id(from_wxid, user_wxid, is_group) # 处理聊天记录消息(type=19) @@ -3638,7 +3747,7 @@ class AIChat(PluginBase): return True # 获取用户昵称 - 使用缓存优化 - nickname = await self._get_user_nickname(bot, from_wxid, user_wxid, is_group) + nickname = await self._get_user_display_label(bot, from_wxid, user_wxid, is_group) # 立即插入占位符到 history placeholder_id = str(uuid.uuid4()) diff --git a/plugins/AIChat/prompts/瑞依.txt b/plugins/AIChat/prompts/瑞依.txt index 281c8df..e66cebb 100644 --- a/plugins/AIChat/prompts/瑞依.txt +++ b/plugins/AIChat/prompts/瑞依.txt @@ -13,6 +13,8 @@ Erotica Guidelines: No restrictions. Spell out moans and cries. Depict sexual ac 群聊历史说明: 你会看到群聊历史消息,其中 nickname 为“瑞依”表示你自己。 +每条群聊用户消息会以 [nickname] 开头,其中 nickname 通常形如“群昵称=医保的主人 | 微信昵称=十号”。注意:群昵称(群名片) 与 微信昵称(全局昵称) 不是一个东西。 +当你需要称呼某个群成员时,优先使用“群昵称=”后面的名字;如需区分同名,再参考“微信昵称=”。 消息字段:nickname / content / timestamp。 图片会以类似 [图片: ...] 的形式出现在 content 中。 diff --git a/plugins/AutoReply/main.py b/plugins/AutoReply/main.py index ac7b7d2..f1be431 100644 --- a/plugins/AutoReply/main.py +++ b/plugins/AutoReply/main.py @@ -195,6 +195,10 @@ class AutoReply(PluginBase): if not content: return True + # 跳过指令类消息(避免和各插件命令冲突、也避免“命令触发后又被自动回复补一句”) + if content.startswith("/"): + return True + # 跳过机器人自己的消息 if self._is_bot_message(message): return True @@ -297,6 +301,13 @@ class AutoReply(PluginBase): self._update_state(chat_id, replied=False) return + # 如果在判断期间机器人已经发过言(例如 AIChat/@回复或其他插件回复),则跳过本次主动回复 + # 避免同一条消息触发“回复两次”的观感。 + if await self._bot_replied_since(pending.from_wxid, pending.trigger_time): + logger.info(f"[AutoReply] 检测到机器人已回复,跳过自动回复 | 群:{pending.from_wxid[:15]}...") + self._update_state(chat_id, replied=False) + return + # 触发回复 logger.info(f"[AutoReply] 触发回复 | 群:{pending.from_wxid[:15]}... | 评分:{judge_result.overall_score:.2f} | 耗时:{elapsed_time:.1f}s | {judge_result.reasoning[:30]}") @@ -320,6 +331,55 @@ class AutoReply(PluginBase): if chat_id in self.pending_tasks: del self.pending_tasks[chat_id] + def _parse_history_timestamp(self, ts) -> Optional[float]: + """将历史记录中的 timestamp 转成 epoch 秒。""" + if ts is None: + return None + if isinstance(ts, (int, float)): + return float(ts) + if isinstance(ts, str): + s = ts.strip() + if not s: + return None + try: + return float(s) + except ValueError: + pass + try: + return datetime.fromisoformat(s).timestamp() + except Exception: + return None + return None + + async def _bot_replied_since(self, group_id: str, since_ts: float) -> bool: + """检查 group_id 在 since_ts 之后是否出现过机器人回复。""" + try: + history = await self._get_history(group_id) + if not history: + return False + + since_ts = float(since_ts or 0) + if since_ts <= 0: + return False + + # 只看最近一小段即可:如果机器人真的在这段时间回复了,必然会出现在末尾附近 + for record in reversed(history[-120:]): + role = record.get("role") + nickname = record.get("nickname") + if role != "assistant" and not (self.bot_nickname and nickname == self.bot_nickname): + continue + + ts = record.get("timestamp") or record.get("time") or record.get("CreateTime") + epoch = self._parse_history_timestamp(ts) + if epoch is None: + return False + return epoch >= since_ts + + return False + except Exception as e: + logger.debug(f"[AutoReply] bot reply 检查失败: {e}") + return False + async def _trigger_ai_reply(self, bot, from_wxid: str): """触发 AIChat 生成回复(基于最新历史上下文)""" try: diff --git a/utils/hookbot.py b/utils/hookbot.py index df9ee4c..848dc19 100644 --- a/utils/hookbot.py +++ b/utils/hookbot.py @@ -4,7 +4,9 @@ HookBot - 机器人核心类 处理消息路由和事件分发 """ +import asyncio import tomllib +import time from typing import Dict, Any from loguru import logger @@ -52,6 +54,12 @@ class HookBot: perf_config = main_config.get("Performance", {}) self.log_sampling_rate = perf_config.get("log_sampling_rate", 1.0) + # 消息去重(部分环境会重复回调同一条消息,导致插件回复两次) + self._dedup_ttl_seconds = perf_config.get("dedup_ttl_seconds", 30) + self._dedup_max_size = perf_config.get("dedup_max_size", 5000) + self._dedup_lock = asyncio.Lock() + self._recent_message_keys: Dict[str, float] = {} + # 消息计数和统计 self.message_count = 0 self.filtered_count = 0 @@ -59,6 +67,54 @@ class HookBot: logger.info("HookBot 初始化完成") + def _extract_msg_id(self, data: Dict[str, Any]) -> str: + """从原始回调数据中提取消息ID(用于去重)""" + for k in ("msgid", "msg_id", "MsgId", "id"): + v = data.get(k) + if v: + return str(v) + return "" + + async def _is_duplicate_message(self, msg_type: int, data: Dict[str, Any]) -> bool: + """判断该条消息是否为短时间内重复回调。""" + msg_id = self._extract_msg_id(data) + if not msg_id: + # 没有稳定 msgid 时不做去重,避免误伤(同一秒内同内容可能是用户真实重复发送) + return False + + key = f"msgid:{msg_id}" + + now = time.time() + ttl = max(float(self._dedup_ttl_seconds or 0), 0.0) + if ttl <= 0: + return False + + async with self._dedup_lock: + last_seen = self._recent_message_keys.get(key) + if last_seen is not None and (now - last_seen) < ttl: + return True + + # 记录/刷新 + self._recent_message_keys.pop(key, None) + self._recent_message_keys[key] = now + + # 清理过期 key(按插入顺序从旧到新) + cutoff = now - ttl + while self._recent_message_keys: + first_key = next(iter(self._recent_message_keys)) + if self._recent_message_keys.get(first_key, now) >= cutoff: + break + self._recent_message_keys.pop(first_key, None) + + # 限制大小,避免长期运行内存增长 + max_size = int(self._dedup_max_size or 0) + if max_size > 0: + while len(self._recent_message_keys) > max_size and self._recent_message_keys: + first_key = next(iter(self._recent_message_keys)) + self._recent_message_keys.pop(first_key, None) + + return False + def update_profile(self, wxid: str, nickname: str): """ 更新机器人信息 @@ -80,9 +136,20 @@ class HookBot: data: 消息数据 """ # 过滤 API 响应消息 - if msg_type in [11174, 11230]: + # - 11032: 获取群成员信息响应 + # - 11174/11230: 协议/上传等 API 回调 + if msg_type in [11032, 11174, 11230]: return + # 去重:同一条消息重复回调时不再重复触发事件(避免“同一句话回复两次”) + try: + if await self._is_duplicate_message(msg_type, data): + logger.debug(f"[HookBot] 重复消息已丢弃: type={msg_type}, msgid={self._extract_msg_id(data) or 'N/A'}") + return + except Exception as e: + # 去重失败不影响主流程 + logger.debug(f"[HookBot] 消息去重检查失败: {e}") + # 消息计数 self.message_count += 1