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