feat:识别群昵称
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user