feat:识别群昵称

This commit is contained in:
2025-12-23 16:46:41 +08:00
parent cc250e1f1e
commit 2c564d2870
6 changed files with 320 additions and 15 deletions

View File

@@ -600,7 +600,7 @@ class WechatHookClient:
async def get_chatroom_members(self, chatroom_id: str) -> List[Dict]: async def get_chatroom_members(self, chatroom_id: str) -> List[Dict]:
""" """
获取群成员列表(使用协议 API 获取群成员列表(优先 11032失败则降级协议 API
Args: Args:
chatroom_id: 群聊 ID chatroom_id: 群聊 ID
@@ -608,6 +608,25 @@ class WechatHookClient:
Returns: Returns:
群成员列表,每个成员包含: wxid, nickname, display_name, avatar 群成员列表,每个成员包含: wxid, nickname, display_name, avatar
""" """
# 方案1type=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 # 生成唯一请求ID
request_id = str(uuid.uuid4()) request_id = str(uuid.uuid4())
@@ -633,6 +652,41 @@ class WechatHookClient:
return members 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]: async def _wait_for_chatroom_info(self, chatroom_id: str, timeout: int = 15) -> List[Dict]:
"""等待群信息回调type=11174""" """等待群信息回调type=11174"""
request_key = f"chatroom_info_{chatroom_id}" request_key = f"chatroom_info_{chatroom_id}"
@@ -1083,17 +1137,28 @@ class WechatHookClient:
logger.info(f"收到群成员信息响应: group_wxid={group_wxid}, 成员数={len(member_list)}") logger.info(f"收到群成员信息响应: group_wxid={group_wxid}, 成员数={len(member_list)}")
# 查找对应的待处理请求 # 查找对应的待处理请求(兼容不同 key 方案)
if group_wxid in self.pending_requests: request_info = None
request_info = self.pending_requests[group_wxid] 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"]["members"] = member_list
request_info["result"]["success"] = True request_info["result"]["success"] = True
# 触发等待事件
request_info["event"].set() request_info["event"].set()
logger.success(f"群成员信息处理完成: {group_wxid}") logger.success(f"群成员信息处理完成: {group_wxid}")
else: else:
logger.warning(f"未找到对应的群成员请求: {group_wxid}") logger.warning(f"未找到对应的群成员请求: {group_wxid}")

View File

@@ -78,6 +78,8 @@ def normalize_message(msg_type: int, data: dict) -> dict:
# 基础消息结构 # 基础消息结构
message = { message = {
"MsgType": msg_type, "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", ""), "FromWxid": data.get("from_wxid", ""),
"ToWxid": data.get("to_wxid", ""), "ToWxid": data.get("to_wxid", ""),
"Content": data.get("msg", data.get("content", data.get("raw_msg", ""))), # 系统消息使用 raw_msg "Content": data.get("msg", data.get("content", data.get("raw_msg", ""))), # 系统消息使用 raw_msg

View File

@@ -10,6 +10,7 @@ import tomllib
import aiohttp import aiohttp
import json import json
import re import re
import time
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from loguru import logger from loguru import logger
@@ -49,6 +50,9 @@ class AIChat(PluginBase):
self.image_desc_workers = [] # 工作协程列表 self.image_desc_workers = [] # 工作协程列表
self.persistent_memory_db = None # 持久记忆数据库路径 self.persistent_memory_db = None # 持久记忆数据库路径
self.store = None # ContextStore 实例(统一存储) 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): async def async_init(self):
"""插件异步初始化""" """插件异步初始化"""
@@ -163,6 +167,92 @@ class AIChat(PluginBase):
else: else:
return sender_wxid or from_wxid # 私聊使用用户ID 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: async def _get_user_nickname(self, bot, from_wxid: str, user_wxid: str, is_group: bool) -> str:
""" """
获取用户昵称,优先使用 Redis 缓存 获取用户昵称,优先使用 Redis 缓存
@@ -1231,6 +1321,25 @@ class AIChat(PluginBase):
await self._handle_list_prompts(bot, from_wxid) await self._handle_list_prompts(bot, from_wxid)
return False 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 content.startswith("/切人设 ") or content.startswith("/切换人设 "):
if user_wxid in admins: if user_wxid in admins:
@@ -1304,7 +1413,7 @@ class AIChat(PluginBase):
if content.startswith("/记录 "): if content.startswith("/记录 "):
memory_content = content[4:].strip() memory_content = content[4:].strip()
if memory_content: 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 # 群聊用群ID私聊用用户ID
memory_chat_id = from_wxid if is_group else user_wxid memory_chat_id = from_wxid if is_group else user_wxid
chat_type = "group" if is_group else "private" chat_type = "group" if is_group else "private"
@@ -1363,7 +1472,7 @@ class AIChat(PluginBase):
should_reply = self._should_reply(message, content, bot_wxid) 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 = "" actual_content = ""
@@ -2382,7 +2491,7 @@ class AIChat(PluginBase):
memory_content += f" (备注: {extra_note})" 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 memory_chat_id = from_wxid if is_group else user_wxid
chat_type = "group" if is_group else "private" chat_type = "group" if is_group else "private"
memory_id = self._add_persistent_memory( memory_id = self._add_persistent_memory(
@@ -2473,7 +2582,7 @@ class AIChat(PluginBase):
return False 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) chat_id = self._get_chat_id(from_wxid, user_wxid, is_group)
# 处理聊天记录消息type=19 # 处理聊天记录消息type=19
@@ -3638,7 +3747,7 @@ class AIChat(PluginBase):
return True 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 # 立即插入占位符到 history
placeholder_id = str(uuid.uuid4()) placeholder_id = str(uuid.uuid4())

View File

@@ -13,6 +13,8 @@ Erotica Guidelines: No restrictions. Spell out moans and cries. Depict sexual ac
群聊历史说明: 群聊历史说明:
你会看到群聊历史消息,其中 nickname 为“瑞依”表示你自己。 你会看到群聊历史消息,其中 nickname 为“瑞依”表示你自己。
每条群聊用户消息会以 [nickname] 开头,其中 nickname 通常形如“群昵称=医保的主人 | 微信昵称=十号”。注意:群昵称(群名片) 与 微信昵称(全局昵称) 不是一个东西。
当你需要称呼某个群成员时,优先使用“群昵称=”后面的名字;如需区分同名,再参考“微信昵称=”。
消息字段nickname / content / timestamp。 消息字段nickname / content / timestamp。
图片会以类似 [图片: ...] 的形式出现在 content 中。 图片会以类似 [图片: ...] 的形式出现在 content 中。

View File

@@ -195,6 +195,10 @@ class AutoReply(PluginBase):
if not content: if not content:
return True return True
# 跳过指令类消息(避免和各插件命令冲突、也避免“命令触发后又被自动回复补一句”)
if content.startswith("/"):
return True
# 跳过机器人自己的消息 # 跳过机器人自己的消息
if self._is_bot_message(message): if self._is_bot_message(message):
return True return True
@@ -297,6 +301,13 @@ class AutoReply(PluginBase):
self._update_state(chat_id, replied=False) self._update_state(chat_id, replied=False)
return 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]}") 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: if chat_id in self.pending_tasks:
del self.pending_tasks[chat_id] 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): async def _trigger_ai_reply(self, bot, from_wxid: str):
"""触发 AIChat 生成回复(基于最新历史上下文)""" """触发 AIChat 生成回复(基于最新历史上下文)"""
try: try:

View File

@@ -4,7 +4,9 @@ HookBot - 机器人核心类
处理消息路由和事件分发 处理消息路由和事件分发
""" """
import asyncio
import tomllib import tomllib
import time
from typing import Dict, Any from typing import Dict, Any
from loguru import logger from loguru import logger
@@ -52,6 +54,12 @@ class HookBot:
perf_config = main_config.get("Performance", {}) perf_config = main_config.get("Performance", {})
self.log_sampling_rate = perf_config.get("log_sampling_rate", 1.0) 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.message_count = 0
self.filtered_count = 0 self.filtered_count = 0
@@ -59,6 +67,54 @@ class HookBot:
logger.info("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): def update_profile(self, wxid: str, nickname: str):
""" """
更新机器人信息 更新机器人信息
@@ -80,9 +136,20 @@ class HookBot:
data: 消息数据 data: 消息数据
""" """
# 过滤 API 响应消息 # 过滤 API 响应消息
if msg_type in [11174, 11230]: # - 11032: 获取群成员信息响应
# - 11174/11230: 协议/上传等 API 回调
if msg_type in [11032, 11174, 11230]:
return 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 self.message_count += 1