feat: 优化整体项目
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
AI 聊天插件
|
||||
|
||||
支持自定义模型、API 和人设
|
||||
支持 Redis 存储对话历史和限流
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -12,6 +13,7 @@ from datetime import datetime
|
||||
from loguru import logger
|
||||
from utils.plugin_base import PluginBase
|
||||
from utils.decorators import on_text_message, on_quote_message, on_image_message, on_emoji_message
|
||||
from utils.redis_cache import get_cache
|
||||
import xml.etree.ElementTree as ET
|
||||
import base64
|
||||
import uuid
|
||||
@@ -95,6 +97,92 @@ class AIChat(PluginBase):
|
||||
else:
|
||||
return sender_wxid or from_wxid # 私聊使用用户ID
|
||||
|
||||
async def _get_user_nickname(self, bot, from_wxid: str, user_wxid: str, is_group: bool) -> str:
|
||||
"""
|
||||
获取用户昵称,优先使用 Redis 缓存
|
||||
|
||||
Args:
|
||||
bot: WechatHookClient 实例
|
||||
from_wxid: 消息来源(群聊ID或私聊用户ID)
|
||||
user_wxid: 用户wxid
|
||||
is_group: 是否群聊
|
||||
|
||||
Returns:
|
||||
用户昵称
|
||||
"""
|
||||
if not is_group:
|
||||
return ""
|
||||
|
||||
nickname = ""
|
||||
|
||||
# 1. 优先从 Redis 缓存获取
|
||||
redis_cache = get_cache()
|
||||
if redis_cache and redis_cache.enabled:
|
||||
cached_info = redis_cache.get_user_basic_info(from_wxid, user_wxid)
|
||||
if cached_info and cached_info.get("nickname"):
|
||||
logger.debug(f"[缓存命中] 用户昵称: {user_wxid} -> {cached_info['nickname']}")
|
||||
return cached_info["nickname"]
|
||||
|
||||
# 2. 缓存未命中,调用 API 获取
|
||||
try:
|
||||
user_info = await bot.get_user_info_in_chatroom(from_wxid, user_wxid)
|
||||
if user_info and user_info.get("nickName", {}).get("string"):
|
||||
nickname = user_info["nickName"]["string"]
|
||||
# 存入缓存
|
||||
if redis_cache and redis_cache.enabled:
|
||||
redis_cache.set_user_info(from_wxid, user_wxid, user_info)
|
||||
logger.debug(f"[已缓存] 用户昵称: {user_wxid} -> {nickname}")
|
||||
return nickname
|
||||
except Exception as e:
|
||||
logger.warning(f"API获取用户昵称失败: {e}")
|
||||
|
||||
# 3. 从 MessageLogger 数据库查询
|
||||
if not nickname:
|
||||
try:
|
||||
from plugins.MessageLogger.main import MessageLogger
|
||||
msg_logger = MessageLogger.get_instance()
|
||||
if msg_logger:
|
||||
with msg_logger.get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT nickname FROM messages WHERE sender_wxid = %s AND nickname != '' ORDER BY create_time DESC LIMIT 1",
|
||||
(user_wxid,)
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
nickname = result[0]
|
||||
except Exception as e:
|
||||
logger.debug(f"从数据库获取昵称失败: {e}")
|
||||
|
||||
# 4. 最后降级使用 wxid
|
||||
if not nickname:
|
||||
nickname = user_wxid or "未知用户"
|
||||
|
||||
return nickname
|
||||
|
||||
def _check_rate_limit(self, user_wxid: str) -> tuple:
|
||||
"""
|
||||
检查用户是否超过限流
|
||||
|
||||
Args:
|
||||
user_wxid: 用户wxid
|
||||
|
||||
Returns:
|
||||
(是否允许, 剩余次数, 重置时间秒数)
|
||||
"""
|
||||
rate_limit_config = self.config.get("rate_limit", {})
|
||||
if not rate_limit_config.get("enabled", True):
|
||||
return (True, 999, 0)
|
||||
|
||||
redis_cache = get_cache()
|
||||
if not redis_cache or not redis_cache.enabled:
|
||||
return (True, 999, 0) # Redis 不可用时不限流
|
||||
|
||||
limit = rate_limit_config.get("ai_chat_limit", 20)
|
||||
window = rate_limit_config.get("ai_chat_window", 60)
|
||||
|
||||
return redis_cache.check_rate_limit(user_wxid, limit, window, "ai_chat")
|
||||
|
||||
def _add_to_memory(self, chat_id: str, role: str, content, image_base64: str = None):
|
||||
"""
|
||||
添加消息到记忆
|
||||
@@ -108,9 +196,6 @@ class AIChat(PluginBase):
|
||||
if not self.config.get("memory", {}).get("enabled", False):
|
||||
return
|
||||
|
||||
if chat_id not in self.memory:
|
||||
self.memory[chat_id] = []
|
||||
|
||||
# 如果有图片,构建多模态内容
|
||||
if image_base64:
|
||||
message_content = [
|
||||
@@ -120,6 +205,22 @@ class AIChat(PluginBase):
|
||||
else:
|
||||
message_content = content
|
||||
|
||||
# 优先使用 Redis 存储
|
||||
redis_config = self.config.get("redis", {})
|
||||
if redis_config.get("use_redis_history", True):
|
||||
redis_cache = get_cache()
|
||||
if redis_cache and redis_cache.enabled:
|
||||
ttl = redis_config.get("chat_history_ttl", 86400)
|
||||
redis_cache.add_chat_message(chat_id, role, message_content, ttl=ttl)
|
||||
# 裁剪历史
|
||||
max_messages = self.config["memory"]["max_messages"]
|
||||
redis_cache.trim_chat_history(chat_id, max_messages)
|
||||
return
|
||||
|
||||
# 降级到内存存储
|
||||
if chat_id not in self.memory:
|
||||
self.memory[chat_id] = []
|
||||
|
||||
self.memory[chat_id].append({"role": role, "content": message_content})
|
||||
|
||||
# 限制记忆长度
|
||||
@@ -131,16 +232,47 @@ class AIChat(PluginBase):
|
||||
"""获取记忆中的消息"""
|
||||
if not self.config.get("memory", {}).get("enabled", False):
|
||||
return []
|
||||
|
||||
# 优先从 Redis 获取
|
||||
redis_config = self.config.get("redis", {})
|
||||
if redis_config.get("use_redis_history", True):
|
||||
redis_cache = get_cache()
|
||||
if redis_cache and redis_cache.enabled:
|
||||
max_messages = self.config["memory"]["max_messages"]
|
||||
return redis_cache.get_chat_history(chat_id, max_messages)
|
||||
|
||||
# 降级到内存
|
||||
return self.memory.get(chat_id, [])
|
||||
|
||||
def _clear_memory(self, chat_id: str):
|
||||
"""清空指定会话的记忆"""
|
||||
# 清空 Redis
|
||||
redis_config = self.config.get("redis", {})
|
||||
if redis_config.get("use_redis_history", True):
|
||||
redis_cache = get_cache()
|
||||
if redis_cache and redis_cache.enabled:
|
||||
redis_cache.clear_chat_history(chat_id)
|
||||
|
||||
# 同时清空内存
|
||||
if chat_id in self.memory:
|
||||
del self.memory[chat_id]
|
||||
|
||||
async def _download_and_encode_image(self, bot, cdnurl: str, aeskey: str) -> str:
|
||||
"""下载图片并转换为base64"""
|
||||
"""下载图片并转换为base64,优先从缓存获取"""
|
||||
try:
|
||||
# 1. 优先从 Redis 缓存获取
|
||||
from utils.redis_cache import RedisCache
|
||||
redis_cache = get_cache()
|
||||
if redis_cache and redis_cache.enabled:
|
||||
media_key = RedisCache.generate_media_key(cdnurl, aeskey)
|
||||
if media_key:
|
||||
cached_data = redis_cache.get_cached_media(media_key, "image")
|
||||
if cached_data:
|
||||
logger.debug(f"[缓存命中] 图片从 Redis 获取: {media_key[:20]}...")
|
||||
return cached_data
|
||||
|
||||
# 2. 缓存未命中,下载图片
|
||||
logger.debug(f"[缓存未命中] 开始下载图片...")
|
||||
temp_dir = Path(__file__).parent / "temp"
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
@@ -168,74 +300,114 @@ class AIChat(PluginBase):
|
||||
with open(save_path, "rb") as f:
|
||||
image_data = base64.b64encode(f.read()).decode()
|
||||
|
||||
base64_result = f"data:image/jpeg;base64,{image_data}"
|
||||
|
||||
# 3. 缓存到 Redis(供后续使用)
|
||||
if redis_cache and redis_cache.enabled and media_key:
|
||||
redis_cache.cache_media(media_key, base64_result, "image", ttl=300)
|
||||
logger.debug(f"[已缓存] 图片缓存到 Redis: {media_key[:20]}...")
|
||||
|
||||
try:
|
||||
Path(save_path).unlink()
|
||||
except:
|
||||
pass
|
||||
|
||||
return f"data:image/jpeg;base64,{image_data}"
|
||||
return base64_result
|
||||
except Exception as e:
|
||||
logger.error(f"下载图片失败: {e}")
|
||||
return ""
|
||||
|
||||
async def _download_emoji_and_encode(self, cdn_url: str) -> str:
|
||||
"""下载表情包并转换为base64(HTTP 直接下载)"""
|
||||
try:
|
||||
# 替换 HTML 实体
|
||||
cdn_url = cdn_url.replace("&", "&")
|
||||
async def _download_emoji_and_encode(self, cdn_url: str, max_retries: int = 3) -> str:
|
||||
"""下载表情包并转换为base64(HTTP 直接下载,带重试机制),优先从缓存获取"""
|
||||
# 替换 HTML 实体
|
||||
cdn_url = cdn_url.replace("&", "&")
|
||||
|
||||
temp_dir = Path(__file__).parent / "temp"
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
# 1. 优先从 Redis 缓存获取
|
||||
from utils.redis_cache import RedisCache
|
||||
redis_cache = get_cache()
|
||||
media_key = RedisCache.generate_media_key(cdnurl=cdn_url)
|
||||
if redis_cache and redis_cache.enabled and media_key:
|
||||
cached_data = redis_cache.get_cached_media(media_key, "emoji")
|
||||
if cached_data:
|
||||
logger.debug(f"[缓存命中] 表情包从 Redis 获取: {media_key[:20]}...")
|
||||
return cached_data
|
||||
|
||||
filename = f"temp_{uuid.uuid4().hex[:8]}.gif"
|
||||
save_path = temp_dir / filename
|
||||
# 2. 缓存未命中,下载表情包
|
||||
logger.debug(f"[缓存未命中] 开始下载表情包...")
|
||||
temp_dir = Path(__file__).parent / "temp"
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 使用 aiohttp 下载
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
filename = f"temp_{uuid.uuid4().hex[:8]}.gif"
|
||||
save_path = temp_dir / filename
|
||||
|
||||
# 配置代理
|
||||
connector = None
|
||||
proxy_config = self.config.get("proxy", {})
|
||||
if proxy_config.get("enabled", False):
|
||||
proxy_type = proxy_config.get("type", "socks5").upper()
|
||||
proxy_host = proxy_config.get("host", "127.0.0.1")
|
||||
proxy_port = proxy_config.get("port", 7890)
|
||||
proxy_username = proxy_config.get("username")
|
||||
proxy_password = proxy_config.get("password")
|
||||
last_error = None
|
||||
|
||||
if proxy_username and proxy_password:
|
||||
proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
|
||||
else:
|
||||
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# 使用 aiohttp 下载,每次重试增加超时时间
|
||||
timeout = aiohttp.ClientTimeout(total=30 + attempt * 15)
|
||||
|
||||
if PROXY_SUPPORT:
|
||||
try:
|
||||
connector = ProxyConnector.from_url(proxy_url)
|
||||
except:
|
||||
connector = None
|
||||
# 配置代理
|
||||
connector = None
|
||||
proxy_config = self.config.get("proxy", {})
|
||||
if proxy_config.get("enabled", False):
|
||||
proxy_type = proxy_config.get("type", "socks5").upper()
|
||||
proxy_host = proxy_config.get("host", "127.0.0.1")
|
||||
proxy_port = proxy_config.get("port", 7890)
|
||||
proxy_username = proxy_config.get("username")
|
||||
proxy_password = proxy_config.get("password")
|
||||
|
||||
async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
|
||||
async with session.get(cdn_url) as response:
|
||||
if response.status == 200:
|
||||
content = await response.read()
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(content)
|
||||
if proxy_username and proxy_password:
|
||||
proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
|
||||
else:
|
||||
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
|
||||
|
||||
# 编码为 base64
|
||||
image_data = base64.b64encode(content).decode()
|
||||
|
||||
# 删除临时文件
|
||||
if PROXY_SUPPORT:
|
||||
try:
|
||||
save_path.unlink()
|
||||
connector = ProxyConnector.from_url(proxy_url)
|
||||
except:
|
||||
pass
|
||||
connector = None
|
||||
|
||||
return f"data:image/gif;base64,{image_data}"
|
||||
async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
|
||||
async with session.get(cdn_url) as response:
|
||||
if response.status == 200:
|
||||
content = await response.read()
|
||||
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.error(f"下载表情包失败: {e}")
|
||||
return ""
|
||||
if len(content) == 0:
|
||||
logger.warning(f"表情包下载内容为空,重试 {attempt + 1}/{max_retries}")
|
||||
continue
|
||||
|
||||
# 编码为 base64
|
||||
image_data = base64.b64encode(content).decode()
|
||||
|
||||
logger.debug(f"表情包下载成功,大小: {len(content)} 字节")
|
||||
base64_result = f"data:image/gif;base64,{image_data}"
|
||||
|
||||
# 3. 缓存到 Redis(供后续使用)
|
||||
if redis_cache and redis_cache.enabled and media_key:
|
||||
redis_cache.cache_media(media_key, base64_result, "emoji", ttl=300)
|
||||
logger.debug(f"[已缓存] 表情包缓存到 Redis: {media_key[:20]}...")
|
||||
|
||||
return base64_result
|
||||
else:
|
||||
logger.warning(f"表情包下载失败,状态码: {response.status},重试 {attempt + 1}/{max_retries}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
last_error = "请求超时"
|
||||
logger.warning(f"表情包下载超时,重试 {attempt + 1}/{max_retries}")
|
||||
except aiohttp.ClientError as e:
|
||||
last_error = str(e)
|
||||
logger.warning(f"表情包下载网络错误: {e},重试 {attempt + 1}/{max_retries}")
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
logger.warning(f"表情包下载异常: {e},重试 {attempt + 1}/{max_retries}")
|
||||
|
||||
# 重试前等待(指数退避)
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep(1 * (attempt + 1))
|
||||
|
||||
logger.error(f"表情包下载失败,已重试 {max_retries} 次: {last_error}")
|
||||
return ""
|
||||
|
||||
async def _generate_image_description(self, image_base64: str, prompt: str, config: dict) -> str:
|
||||
"""
|
||||
@@ -479,37 +651,8 @@ class AIChat(PluginBase):
|
||||
# 检查是否应该回复
|
||||
should_reply = self._should_reply(message, content, bot_wxid)
|
||||
|
||||
# 获取用户昵称(用于历史记录)
|
||||
nickname = ""
|
||||
if is_group:
|
||||
try:
|
||||
user_info = await bot.get_user_info_in_chatroom(from_wxid, user_wxid)
|
||||
if user_info and user_info.get("nickName", {}).get("string"):
|
||||
nickname = user_info["nickName"]["string"]
|
||||
except:
|
||||
pass
|
||||
|
||||
# 如果获取昵称失败,从 MessageLogger 数据库查询
|
||||
if not nickname:
|
||||
from plugins.MessageLogger.main import MessageLogger
|
||||
msg_logger = MessageLogger.get_instance()
|
||||
if msg_logger:
|
||||
try:
|
||||
with msg_logger.get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT nickname FROM messages WHERE sender_wxid = %s AND nickname != '' ORDER BY create_time DESC LIMIT 1",
|
||||
(user_wxid,)
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
nickname = result[0]
|
||||
except:
|
||||
pass
|
||||
|
||||
# 最后降级使用 wxid
|
||||
if not nickname:
|
||||
nickname = user_wxid or sender_wxid or "未知用户"
|
||||
# 获取用户昵称(用于历史记录)- 使用缓存优化
|
||||
nickname = await self._get_user_nickname(bot, from_wxid, user_wxid, is_group)
|
||||
|
||||
# 保存到群组历史记录(所有消息都保存,不管是否回复)
|
||||
if is_group:
|
||||
@@ -519,6 +662,16 @@ class AIChat(PluginBase):
|
||||
if not should_reply:
|
||||
return
|
||||
|
||||
# 限流检查(仅在需要回复时检查)
|
||||
allowed, remaining, reset_time = self._check_rate_limit(user_wxid)
|
||||
if not allowed:
|
||||
rate_limit_config = self.config.get("rate_limit", {})
|
||||
msg = rate_limit_config.get("rate_limit_message", "⚠️ 消息太频繁了,请 {seconds} 秒后再试~")
|
||||
msg = msg.format(seconds=reset_time)
|
||||
await bot.send_text(from_wxid, msg)
|
||||
logger.warning(f"用户 {user_wxid} 触发限流,{reset_time}秒后重置")
|
||||
return False
|
||||
|
||||
# 提取实际消息内容(去除@)
|
||||
actual_content = self._extract_content(message, content)
|
||||
if not actual_content:
|
||||
@@ -1004,8 +1157,23 @@ class AIChat(PluginBase):
|
||||
json.dump(history, f, ensure_ascii=False, indent=2)
|
||||
temp_file.replace(history_file)
|
||||
|
||||
def _use_redis_for_group_history(self) -> bool:
|
||||
"""检查是否使用 Redis 存储群聊历史"""
|
||||
redis_config = self.config.get("redis", {})
|
||||
if not redis_config.get("use_redis_history", True):
|
||||
return False
|
||||
redis_cache = get_cache()
|
||||
return redis_cache and redis_cache.enabled
|
||||
|
||||
async def _load_history(self, chat_id: str) -> list:
|
||||
"""异步读取群聊历史, 用锁避免与写入冲突"""
|
||||
"""异步读取群聊历史, 优先使用 Redis"""
|
||||
# 优先使用 Redis
|
||||
if self._use_redis_for_group_history():
|
||||
redis_cache = get_cache()
|
||||
max_history = self.config.get("history", {}).get("max_history", 100)
|
||||
return redis_cache.get_group_history(chat_id, max_history)
|
||||
|
||||
# 降级到文件存储
|
||||
history_file = self._get_history_file(chat_id)
|
||||
if not history_file:
|
||||
return []
|
||||
@@ -1015,6 +1183,10 @@ class AIChat(PluginBase):
|
||||
|
||||
async def _save_history(self, chat_id: str, history: list):
|
||||
"""异步写入群聊历史, 包含长度截断"""
|
||||
# Redis 模式下不需要单独保存,add_group_message 已经处理
|
||||
if self._use_redis_for_group_history():
|
||||
return
|
||||
|
||||
history_file = self._get_history_file(chat_id)
|
||||
if not history_file:
|
||||
return
|
||||
@@ -1040,6 +1212,27 @@ class AIChat(PluginBase):
|
||||
if not self.config.get("history", {}).get("enabled", True):
|
||||
return
|
||||
|
||||
# 构建消息内容
|
||||
if image_base64:
|
||||
message_content = [
|
||||
{"type": "text", "text": content},
|
||||
{"type": "image_url", "image_url": {"url": image_base64}}
|
||||
]
|
||||
else:
|
||||
message_content = content
|
||||
|
||||
# 优先使用 Redis
|
||||
if self._use_redis_for_group_history():
|
||||
redis_cache = get_cache()
|
||||
redis_config = self.config.get("redis", {})
|
||||
ttl = redis_config.get("group_history_ttl", 172800)
|
||||
redis_cache.add_group_message(chat_id, nickname, message_content, ttl=ttl)
|
||||
# 裁剪历史
|
||||
max_history = self.config.get("history", {}).get("max_history", 100)
|
||||
redis_cache.trim_group_history(chat_id, max_history)
|
||||
return
|
||||
|
||||
# 降级到文件存储
|
||||
history_file = self._get_history_file(chat_id)
|
||||
if not history_file:
|
||||
return
|
||||
@@ -1050,17 +1243,10 @@ class AIChat(PluginBase):
|
||||
|
||||
message_record = {
|
||||
"nickname": nickname,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"content": message_content
|
||||
}
|
||||
|
||||
if image_base64:
|
||||
message_record["content"] = [
|
||||
{"type": "text", "text": content},
|
||||
{"type": "image_url", "image_url": {"url": image_base64}}
|
||||
]
|
||||
else:
|
||||
message_record["content"] = content
|
||||
|
||||
history.append(message_record)
|
||||
max_history = self.config.get("history", {}).get("max_history", 100)
|
||||
if len(history) > max_history:
|
||||
@@ -1073,6 +1259,18 @@ class AIChat(PluginBase):
|
||||
if not self.config.get("history", {}).get("enabled", True):
|
||||
return
|
||||
|
||||
# 优先使用 Redis
|
||||
if self._use_redis_for_group_history():
|
||||
redis_cache = get_cache()
|
||||
redis_config = self.config.get("redis", {})
|
||||
ttl = redis_config.get("group_history_ttl", 172800)
|
||||
redis_cache.add_group_message(chat_id, nickname, content, record_id=record_id, ttl=ttl)
|
||||
# 裁剪历史
|
||||
max_history = self.config.get("history", {}).get("max_history", 100)
|
||||
redis_cache.trim_group_history(chat_id, max_history)
|
||||
return
|
||||
|
||||
# 降级到文件存储
|
||||
history_file = self._get_history_file(chat_id)
|
||||
if not history_file:
|
||||
return
|
||||
@@ -1097,6 +1295,13 @@ class AIChat(PluginBase):
|
||||
if not self.config.get("history", {}).get("enabled", True):
|
||||
return
|
||||
|
||||
# 优先使用 Redis
|
||||
if self._use_redis_for_group_history():
|
||||
redis_cache = get_cache()
|
||||
redis_cache.update_group_message_by_id(chat_id, record_id, new_content)
|
||||
return
|
||||
|
||||
# 降级到文件存储
|
||||
history_file = self._get_history_file(chat_id)
|
||||
if not history_file:
|
||||
return
|
||||
@@ -1204,18 +1409,20 @@ class AIChat(PluginBase):
|
||||
return True
|
||||
|
||||
logger.info(f"AI处理引用图片消息: {title_text[:50]}...")
|
||||
|
||||
# 获取用户昵称
|
||||
nickname = ""
|
||||
if is_group:
|
||||
try:
|
||||
user_info = await bot.get_user_info_in_chatroom(from_wxid, user_wxid)
|
||||
if user_info and user_info.get("nickName", {}).get("string"):
|
||||
nickname = user_info["nickName"]["string"]
|
||||
logger.info(f"获取到用户昵称: {nickname}")
|
||||
except Exception as e:
|
||||
logger.error(f"获取用户昵称失败: {e}")
|
||||
|
||||
|
||||
# 限流检查
|
||||
allowed, remaining, reset_time = self._check_rate_limit(user_wxid)
|
||||
if not allowed:
|
||||
rate_limit_config = self.config.get("rate_limit", {})
|
||||
msg = rate_limit_config.get("rate_limit_message", "⚠️ 消息太频繁了,请 {seconds} 秒后再试~")
|
||||
msg = msg.format(seconds=reset_time)
|
||||
await bot.send_text(from_wxid, msg)
|
||||
logger.warning(f"用户 {user_wxid} 触发限流,{reset_time}秒后重置")
|
||||
return False
|
||||
|
||||
# 获取用户昵称 - 使用缓存优化
|
||||
nickname = await self._get_user_nickname(bot, from_wxid, user_wxid, is_group)
|
||||
|
||||
# 下载并编码图片
|
||||
logger.info(f"开始下载图片: {cdnbigimgurl[:50]}...")
|
||||
image_base64 = await self._download_and_encode_image(bot, cdnbigimgurl, aeskey)
|
||||
@@ -1627,34 +1834,8 @@ class AIChat(PluginBase):
|
||||
if not is_emoji and not aeskey:
|
||||
return True
|
||||
|
||||
# 获取用户昵称
|
||||
nickname = ""
|
||||
try:
|
||||
user_info = await bot.get_user_info_in_chatroom(from_wxid, user_wxid)
|
||||
if user_info and user_info.get("nickName", {}).get("string"):
|
||||
nickname = user_info["nickName"]["string"]
|
||||
except:
|
||||
pass
|
||||
|
||||
if not nickname:
|
||||
from plugins.MessageLogger.main import MessageLogger
|
||||
msg_logger = MessageLogger.get_instance()
|
||||
if msg_logger:
|
||||
try:
|
||||
with msg_logger.get_db_connection() as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT nickname FROM messages WHERE sender_wxid = %s AND nickname != '' ORDER BY create_time DESC LIMIT 1",
|
||||
(user_wxid,)
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
nickname = result[0]
|
||||
except:
|
||||
pass
|
||||
|
||||
if not nickname:
|
||||
nickname = user_wxid or sender_wxid or "未知用户"
|
||||
# 获取用户昵称 - 使用缓存优化
|
||||
nickname = await self._get_user_nickname(bot, from_wxid, user_wxid, is_group)
|
||||
|
||||
# 立即插入占位符到 history
|
||||
placeholder_id = str(uuid.uuid4())
|
||||
|
||||
@@ -2,8 +2,17 @@
|
||||
插件管理插件
|
||||
|
||||
提供插件的热重载、启用、禁用等管理功能
|
||||
支持的指令:
|
||||
/插件列表 - 查看所有插件状态
|
||||
/重载插件 <名称> - 重载指定插件
|
||||
/重载所有插件 - 重载所有插件
|
||||
/启用插件 <名称> - 启用指定插件
|
||||
/禁用插件 <名称> - 禁用指定插件
|
||||
/刷新插件 - 扫描并发现新插件
|
||||
/插件帮助 - 显示帮助信息
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
@@ -18,7 +27,10 @@ class ManagePlugin(PluginBase):
|
||||
# 插件元数据
|
||||
description = "插件管理,支持热重载、启用、禁用"
|
||||
author = "ShiHao"
|
||||
version = "1.0.0"
|
||||
version = "2.0.0"
|
||||
|
||||
# 最高加载优先级,确保最先加载
|
||||
load_priority = 100
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -34,37 +46,72 @@ class ManagePlugin(PluginBase):
|
||||
self.admins = main_config.get("Bot", {}).get("admins", [])
|
||||
logger.info(f"插件管理插件已加载,管理员: {self.admins}")
|
||||
|
||||
@on_text_message()
|
||||
def _check_admin(self, message: dict) -> bool:
|
||||
"""检查是否是管理员"""
|
||||
sender_wxid = message.get("SenderWxid", "")
|
||||
from_wxid = message.get("FromWxid", "")
|
||||
is_group = message.get("IsGroup", False)
|
||||
|
||||
# 私聊时 sender_wxid 可能为空,使用 from_wxid
|
||||
user_wxid = sender_wxid if is_group else from_wxid
|
||||
|
||||
return user_wxid in self.admins
|
||||
|
||||
@on_text_message(priority=99)
|
||||
async def handle_command(self, bot, message: dict):
|
||||
"""处理管理命令"""
|
||||
content = message.get("Content", "").strip()
|
||||
from_wxid = message.get("FromWxid", "")
|
||||
sender_wxid = message.get("SenderWxid", "")
|
||||
|
||||
logger.debug(f"ManagePlugin: content={content}, from={from_wxid}, sender={sender_wxid}, admins={self.admins}")
|
||||
|
||||
# 检查权限
|
||||
if not self.admins or sender_wxid not in self.admins:
|
||||
return
|
||||
if not self._check_admin(message):
|
||||
return True # 继续传递给其他插件
|
||||
|
||||
# 插件帮助
|
||||
if content == "/插件帮助" or content == "/plugin help":
|
||||
await self._show_help(bot, from_wxid)
|
||||
return False
|
||||
|
||||
# 插件列表
|
||||
if content == "/插件列表" or content == "/plugins":
|
||||
elif content == "/插件列表" or content == "/plugins":
|
||||
await self._list_plugins(bot, from_wxid)
|
||||
return False
|
||||
|
||||
# 重载所有插件
|
||||
elif content == "/重载所有插件" or content == "/reload all":
|
||||
await self._reload_all_plugins(bot, from_wxid)
|
||||
return False
|
||||
|
||||
# 重载插件
|
||||
elif content.startswith("/重载插件 ") or content.startswith("/reload "):
|
||||
plugin_name = content.split(maxsplit=1)[1].strip()
|
||||
await self._reload_plugin(bot, from_wxid, plugin_name)
|
||||
return False
|
||||
|
||||
# 启用插件
|
||||
elif content.startswith("/启用插件 ") or content.startswith("/enable "):
|
||||
plugin_name = content.split(maxsplit=1)[1].strip()
|
||||
await self._enable_plugin(bot, from_wxid, plugin_name)
|
||||
return False
|
||||
|
||||
# 禁用插件
|
||||
elif content.startswith("/禁用插件 ") or content.startswith("/disable "):
|
||||
plugin_name = content.split(maxsplit=1)[1].strip()
|
||||
await self._disable_plugin(bot, from_wxid, plugin_name)
|
||||
return False
|
||||
|
||||
# 刷新插件(发现新插件)
|
||||
elif content == "/刷新插件" or content == "/refresh":
|
||||
await self._refresh_plugins(bot, from_wxid)
|
||||
return False
|
||||
|
||||
# 加载新插件(从目录加载全新插件)
|
||||
elif content.startswith("/加载插件 ") or content.startswith("/load "):
|
||||
plugin_name = content.split(maxsplit=1)[1].strip()
|
||||
await self._load_new_plugin(bot, from_wxid, plugin_name)
|
||||
return False
|
||||
|
||||
return True # 不是管理命令,继续传递
|
||||
|
||||
async def _list_plugins(self, bot, to_wxid: str):
|
||||
"""列出所有插件"""
|
||||
@@ -159,3 +206,200 @@ class ManagePlugin(PluginBase):
|
||||
logger.info(f"插件 {plugin_name} 已被禁用")
|
||||
else:
|
||||
await bot.send_text(to_wxid, f"❌ 插件 {plugin_name} 禁用失败")
|
||||
|
||||
async def _show_help(self, bot, to_wxid: str):
|
||||
"""显示帮助信息"""
|
||||
help_text = """📦 插件管理帮助
|
||||
|
||||
/插件列表 - 查看所有插件状态
|
||||
/插件帮助 - 显示此帮助信息
|
||||
|
||||
/加载插件 <名称> - 加载新插件(无需重启)
|
||||
/重载插件 <名称> - 热重载指定插件
|
||||
/重载所有插件 - 热重载所有插件
|
||||
|
||||
/启用插件 <名称> - 启用已禁用的插件
|
||||
/禁用插件 <名称> - 禁用指定插件
|
||||
|
||||
/刷新插件 - 扫描发现新插件
|
||||
|
||||
示例:
|
||||
/加载插件 NewPlugin
|
||||
/重载插件 AIChat
|
||||
/禁用插件 Weather"""
|
||||
await bot.send_text(to_wxid, help_text)
|
||||
|
||||
async def _reload_all_plugins(self, bot, to_wxid: str):
|
||||
"""重载所有插件"""
|
||||
pm = PluginManager()
|
||||
|
||||
await bot.send_text(to_wxid, "⏳ 正在重载所有插件...")
|
||||
|
||||
try:
|
||||
# 清理插件相关的模块缓存
|
||||
modules_to_remove = [
|
||||
name for name in sys.modules.keys()
|
||||
if name.startswith('plugins.') and 'ManagePlugin' not in name
|
||||
]
|
||||
for module_name in modules_to_remove:
|
||||
del sys.modules[module_name]
|
||||
|
||||
# 重载所有插件
|
||||
reloaded = await pm.reload_plugins()
|
||||
|
||||
if reloaded:
|
||||
await bot.send_text(
|
||||
to_wxid,
|
||||
f"✅ 重载完成\n已加载 {len(reloaded)} 个插件:\n" +
|
||||
"\n".join(f" • {name}" for name in reloaded)
|
||||
)
|
||||
logger.success(f"已重载 {len(reloaded)} 个插件")
|
||||
else:
|
||||
await bot.send_text(to_wxid, "⚠️ 没有插件被重载")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"重载所有插件失败: {e}")
|
||||
await bot.send_text(to_wxid, f"❌ 重载失败: {e}")
|
||||
|
||||
async def _refresh_plugins(self, bot, to_wxid: str):
|
||||
"""刷新插件列表,发现新插件"""
|
||||
pm = PluginManager()
|
||||
|
||||
try:
|
||||
# 记录刷新前的插件数量
|
||||
old_count = len(pm.plugin_info)
|
||||
|
||||
# 刷新插件列表
|
||||
await pm.refresh_plugins()
|
||||
|
||||
# 计算新发现的插件
|
||||
new_count = len(pm.plugin_info)
|
||||
new_plugins = new_count - old_count
|
||||
|
||||
if new_plugins > 0:
|
||||
# 获取新发现的插件名称
|
||||
new_plugin_names = [
|
||||
info["name"] for info in pm.plugin_info.values()
|
||||
if not info.get("enabled", False)
|
||||
][-new_plugins:]
|
||||
|
||||
await bot.send_text(
|
||||
to_wxid,
|
||||
f"✅ 发现 {new_plugins} 个新插件:\n" +
|
||||
"\n".join(f" • {name}" for name in new_plugin_names) +
|
||||
"\n\n使用 /启用插件 <名称> 来启用"
|
||||
)
|
||||
logger.info(f"发现 {new_plugins} 个新插件: {new_plugin_names}")
|
||||
else:
|
||||
await bot.send_text(to_wxid, "ℹ️ 没有发现新插件")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"刷新插件失败: {e}")
|
||||
await bot.send_text(to_wxid, f"❌ 刷新失败: {e}")
|
||||
|
||||
async def _load_new_plugin(self, bot, to_wxid: str, plugin_name: str):
|
||||
"""加载全新的插件(支持插件类名或目录名)"""
|
||||
import os
|
||||
import importlib
|
||||
import inspect
|
||||
|
||||
pm = PluginManager()
|
||||
|
||||
# 检查是否已加载
|
||||
if plugin_name in pm.plugins:
|
||||
await bot.send_text(to_wxid, f"ℹ️ 插件 {plugin_name} 已经加载,如需重载请使用 /重载插件")
|
||||
return
|
||||
|
||||
try:
|
||||
# 尝试查找插件
|
||||
found = False
|
||||
plugin_class = None
|
||||
actual_plugin_name = None
|
||||
|
||||
for dirname in os.listdir("plugins"):
|
||||
dirpath = f"plugins/{dirname}"
|
||||
if not os.path.isdir(dirpath) or not os.path.exists(f"{dirpath}/main.py"):
|
||||
continue
|
||||
|
||||
# 支持通过目录名或类名查找
|
||||
if dirname == plugin_name or dirname.lower() == plugin_name.lower():
|
||||
# 通过目录名匹配
|
||||
module_name = f"plugins.{dirname}.main"
|
||||
|
||||
# 清理旧的模块缓存
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
# 导入模块
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
# 查找插件类
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if (inspect.isclass(obj) and
|
||||
issubclass(obj, PluginBase) and
|
||||
obj != PluginBase):
|
||||
plugin_class = obj
|
||||
actual_plugin_name = obj.__name__
|
||||
found = True
|
||||
break
|
||||
|
||||
if found:
|
||||
break
|
||||
|
||||
# 尝试通过类名匹配
|
||||
try:
|
||||
module_name = f"plugins.{dirname}.main"
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if (inspect.isclass(obj) and
|
||||
issubclass(obj, PluginBase) and
|
||||
obj != PluginBase and
|
||||
(obj.__name__ == plugin_name or obj.__name__.lower() == plugin_name.lower())):
|
||||
plugin_class = obj
|
||||
actual_plugin_name = obj.__name__
|
||||
found = True
|
||||
break
|
||||
|
||||
if found:
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if not found or not plugin_class:
|
||||
await bot.send_text(
|
||||
to_wxid,
|
||||
f"❌ 未找到插件 {plugin_name}\n"
|
||||
f"请确认:\n"
|
||||
f"1. plugins/{plugin_name}/main.py 存在\n"
|
||||
f"2. main.py 中有继承 PluginBase 的类"
|
||||
)
|
||||
return
|
||||
|
||||
# 检查是否已加载(用实际类名再检查一次)
|
||||
if actual_plugin_name in pm.plugins:
|
||||
await bot.send_text(to_wxid, f"ℹ️ 插件 {actual_plugin_name} 已经加载")
|
||||
return
|
||||
|
||||
# 加载插件
|
||||
success = await pm._load_plugin_class(plugin_class)
|
||||
|
||||
if success:
|
||||
await bot.send_text(
|
||||
to_wxid,
|
||||
f"✅ 插件加载成功\n"
|
||||
f"名称: {actual_plugin_name}\n"
|
||||
f"版本: {plugin_class.version}\n"
|
||||
f"作者: {plugin_class.author}"
|
||||
)
|
||||
logger.success(f"新插件 {actual_plugin_name} 已加载")
|
||||
else:
|
||||
await bot.send_text(to_wxid, f"❌ 插件 {actual_plugin_name} 加载失败")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"加载新插件失败: {e}\n{traceback.format_exc()}")
|
||||
await bot.send_text(to_wxid, f"❌ 加载失败: {e}")
|
||||
|
||||
100
plugins/Menu/main.py
Normal file
100
plugins/Menu/main.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
菜单插件
|
||||
|
||||
用户发送 /菜单、/帮助 等指令时,按顺序发送菜单图片
|
||||
图片命名格式:menu1.png、menu2.png、menu3.png ...
|
||||
"""
|
||||
|
||||
import re
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
from utils.plugin_base import PluginBase
|
||||
from utils.decorators import on_text_message
|
||||
|
||||
|
||||
class Menu(PluginBase):
|
||||
"""菜单插件"""
|
||||
|
||||
# 插件元数据
|
||||
description = "菜单插件,发送帮助图片"
|
||||
author = "ShiHao"
|
||||
version = "1.0.0"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.menu_dir = None
|
||||
self.trigger_commands = ["/菜单", "/帮助", "/help", "/menu"]
|
||||
self.send_interval = 0.5 # 发送间隔(秒),避免发送过快
|
||||
|
||||
async def async_init(self):
|
||||
"""插件异步初始化"""
|
||||
# 设置菜单图片目录
|
||||
self.menu_dir = Path(__file__).parent / "images"
|
||||
self.menu_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 扫描现有图片
|
||||
images = self._get_menu_images()
|
||||
logger.info(f"菜单插件已加载,图片目录: {self.menu_dir},找到 {len(images)} 张菜单图片")
|
||||
|
||||
def _get_menu_images(self) -> list:
|
||||
"""
|
||||
获取所有符合命名规范的菜单图片,按序号排序
|
||||
|
||||
命名格式:menu1.png、menu2.jpg、menu3.jpeg 等
|
||||
"""
|
||||
if not self.menu_dir or not self.menu_dir.exists():
|
||||
return []
|
||||
|
||||
# 匹配 menu + 数字 + 图片扩展名
|
||||
pattern = re.compile(r'^menu(\d+)\.(png|jpg|jpeg|gif|bmp)$', re.IGNORECASE)
|
||||
|
||||
images = []
|
||||
for file in self.menu_dir.iterdir():
|
||||
if file.is_file():
|
||||
match = pattern.match(file.name)
|
||||
if match:
|
||||
seq_num = int(match.group(1))
|
||||
images.append((seq_num, file))
|
||||
|
||||
# 按序号排序
|
||||
images.sort(key=lambda x: x[0])
|
||||
|
||||
# 只返回文件路径
|
||||
return [img[1] for img in images]
|
||||
|
||||
@on_text_message(priority=60)
|
||||
async def handle_menu_command(self, bot, message: dict):
|
||||
"""处理菜单指令"""
|
||||
content = message.get("Content", "").strip()
|
||||
from_wxid = message.get("FromWxid", "")
|
||||
|
||||
# 检查是否是菜单指令
|
||||
if content not in self.trigger_commands:
|
||||
return True # 继续传递给其他插件
|
||||
|
||||
logger.info(f"收到菜单指令: {content}, from: {from_wxid}")
|
||||
|
||||
# 获取菜单图片
|
||||
images = self._get_menu_images()
|
||||
|
||||
if not images:
|
||||
await bot.send_text(from_wxid, "暂无菜单图片,请联系管理员添加")
|
||||
logger.warning(f"菜单图片目录为空: {self.menu_dir}")
|
||||
return False
|
||||
|
||||
# 按顺序发送图片
|
||||
for i, image_path in enumerate(images):
|
||||
try:
|
||||
await bot.send_image(from_wxid, str(image_path))
|
||||
logger.debug(f"已发送菜单图片 {i+1}/{len(images)}: {image_path.name}")
|
||||
|
||||
# 发送间隔,避免发送过快
|
||||
if i < len(images) - 1:
|
||||
await asyncio.sleep(self.send_interval)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送菜单图片失败: {image_path}, 错误: {e}")
|
||||
|
||||
logger.success(f"菜单图片发送完成,共 {len(images)} 张")
|
||||
return False # 阻止继续传递
|
||||
@@ -18,6 +18,7 @@ from utils.decorators import (
|
||||
on_file_message,
|
||||
on_emoji_message
|
||||
)
|
||||
from utils.redis_cache import RedisCache, get_cache
|
||||
import pymysql
|
||||
from WechatHook import WechatHookClient
|
||||
from minio import Minio
|
||||
@@ -39,6 +40,7 @@ class MessageLogger(PluginBase):
|
||||
super().__init__()
|
||||
self.config = None
|
||||
self.db_config = None
|
||||
self.redis_cache = None # Redis 缓存实例
|
||||
|
||||
# 创建独立的日志记录器
|
||||
self._setup_logger()
|
||||
@@ -83,9 +85,22 @@ class MessageLogger(PluginBase):
|
||||
|
||||
self.db_config = self.config["database"]
|
||||
|
||||
# 初始化 Redis 缓存
|
||||
redis_config = self.config.get("redis", {})
|
||||
if redis_config.get("enabled", False):
|
||||
self.log.info("正在初始化 Redis 缓存...")
|
||||
self.redis_cache = RedisCache(redis_config)
|
||||
if self.redis_cache.enabled:
|
||||
self.log.success(f"Redis 缓存初始化成功,TTL={redis_config.get('ttl', 3600)}秒")
|
||||
else:
|
||||
self.log.warning("Redis 缓存初始化失败,将不使用缓存")
|
||||
self.redis_cache = None
|
||||
else:
|
||||
self.log.info("Redis 缓存未启用")
|
||||
|
||||
# 初始化 MinIO 客户端
|
||||
self.minio_client = Minio(
|
||||
"101.201.65.129:19000",
|
||||
"115.190.113.141:19000",
|
||||
access_key="admin",
|
||||
secret_key="80012029Lz",
|
||||
secure=False
|
||||
@@ -216,7 +231,7 @@ class MessageLogger(PluginBase):
|
||||
return ("", "", "", "", "0")
|
||||
|
||||
async def download_image_and_upload(self, bot, cdnurl: str, aeskey: str) -> str:
|
||||
"""下载图片并上传到 MinIO"""
|
||||
"""下载图片并上传到 MinIO,同时缓存 base64 供其他插件使用"""
|
||||
try:
|
||||
temp_file = Path(__file__).parent / f"temp_{uuid.uuid4().hex}.jpg"
|
||||
success = await bot.cdn_download(cdnurl, aeskey, str(temp_file), file_type=2)
|
||||
@@ -225,12 +240,26 @@ class MessageLogger(PluginBase):
|
||||
|
||||
# 等待文件下载完成
|
||||
import asyncio
|
||||
import base64
|
||||
for _ in range(50):
|
||||
if temp_file.exists() and temp_file.stat().st_size > 0:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if temp_file.exists() and temp_file.stat().st_size > 0:
|
||||
# 读取文件并缓存 base64(供 AIChat 等插件使用)
|
||||
with open(temp_file, "rb") as f:
|
||||
image_data = f.read()
|
||||
base64_data = f"data:image/jpeg;base64,{base64.b64encode(image_data).decode()}"
|
||||
|
||||
# 缓存到 Redis(5分钟过期)
|
||||
redis_cache = get_cache()
|
||||
if redis_cache and redis_cache.enabled:
|
||||
media_key = RedisCache.generate_media_key(cdnurl, aeskey)
|
||||
if media_key:
|
||||
redis_cache.cache_media(media_key, base64_data, "image", ttl=300)
|
||||
self.log.debug(f"图片已缓存到 Redis: {media_key[:20]}...")
|
||||
|
||||
media_url = await self.upload_file_to_minio(str(temp_file), "images")
|
||||
temp_file.unlink()
|
||||
return media_url
|
||||
@@ -326,13 +355,25 @@ class MessageLogger(PluginBase):
|
||||
return ""
|
||||
|
||||
async def download_and_upload(self, url: str, file_type: str, ext: str) -> str:
|
||||
"""下载文件并上传到 MinIO"""
|
||||
"""下载文件并上传到 MinIO,同时缓存 base64 供其他插件使用"""
|
||||
try:
|
||||
import base64
|
||||
# 下载文件
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.read()
|
||||
|
||||
# 缓存表情包 base64(供 AIChat 等插件使用)
|
||||
if file_type == "emojis" and data:
|
||||
redis_cache = get_cache()
|
||||
if redis_cache and redis_cache.enabled:
|
||||
base64_data = f"data:image/gif;base64,{base64.b64encode(data).decode()}"
|
||||
media_key = RedisCache.generate_media_key(cdnurl=url)
|
||||
if media_key:
|
||||
redis_cache.cache_media(media_key, base64_data, "emoji", ttl=300)
|
||||
self.log.debug(f"表情包已缓存到 Redis: {media_key[:20]}...")
|
||||
|
||||
# 保存到临时文件
|
||||
temp_file = Path(__file__).parent / f"temp_{uuid.uuid4().hex}{ext}"
|
||||
temp_file.write_bytes(data)
|
||||
@@ -374,7 +415,7 @@ class MessageLogger(PluginBase):
|
||||
)
|
||||
|
||||
# 返回访问 URL
|
||||
url = f"http://101.201.65.129:19000/{self.minio_bucket}/{object_name}"
|
||||
url = f"http://115.190.113.141:19000/{self.minio_bucket}/{object_name}"
|
||||
self.log.debug(f"文件上传成功: {url}")
|
||||
return url
|
||||
|
||||
@@ -405,29 +446,45 @@ class MessageLogger(PluginBase):
|
||||
avatar_url = ""
|
||||
|
||||
if is_group and self.config["behavior"]["fetch_avatar"]:
|
||||
try:
|
||||
self.log.info(f"尝试获取用户信息: from_wxid={from_wxid}, sender_wxid={sender_wxid}")
|
||||
user_info = await bot.get_user_info_in_chatroom(from_wxid, sender_wxid)
|
||||
self.log.info(f"获取到用户信息: {user_info}")
|
||||
cache_hit = False
|
||||
|
||||
if user_info:
|
||||
# 处理不同的数据结构
|
||||
if isinstance(user_info.get("nickName"), dict):
|
||||
nickname = user_info.get("nickName", {}).get("string", "")
|
||||
# 1. 先尝试从 Redis 缓存获取
|
||||
if self.redis_cache and self.redis_cache.enabled:
|
||||
cached_info = self.redis_cache.get_user_basic_info(from_wxid, sender_wxid)
|
||||
if cached_info:
|
||||
nickname = cached_info.get("nickname", "")
|
||||
avatar_url = cached_info.get("avatar_url", "")
|
||||
if nickname and avatar_url:
|
||||
cache_hit = True
|
||||
self.log.debug(f"[缓存命中] {sender_wxid}: {nickname}")
|
||||
|
||||
# 2. 缓存未命中,调用 API 获取
|
||||
if not cache_hit:
|
||||
try:
|
||||
self.log.info(f"[缓存未命中] 调用API获取用户信息: {sender_wxid}")
|
||||
user_info = await bot.get_user_info_in_chatroom(from_wxid, sender_wxid)
|
||||
|
||||
if user_info:
|
||||
# 处理不同的数据结构
|
||||
if isinstance(user_info.get("nickName"), dict):
|
||||
nickname = user_info.get("nickName", {}).get("string", "")
|
||||
else:
|
||||
nickname = user_info.get("nickName", "")
|
||||
|
||||
avatar_url = user_info.get("bigHeadImgUrl", "")
|
||||
self.log.info(f"API获取成功: nickname={nickname}, avatar_url={avatar_url[:50] if avatar_url else ''}...")
|
||||
|
||||
# 3. 将用户信息存入 Redis 缓存
|
||||
if self.redis_cache and self.redis_cache.enabled and nickname:
|
||||
self.redis_cache.set_user_info(from_wxid, sender_wxid, user_info)
|
||||
self.log.debug(f"[已缓存] {sender_wxid}: {nickname}")
|
||||
else:
|
||||
nickname = user_info.get("nickName", "")
|
||||
self.log.warning(f"用户信息为空: {sender_wxid}")
|
||||
|
||||
avatar_url = user_info.get("bigHeadImgUrl", "")
|
||||
self.log.info(f"解析用户信息: nickname={nickname}, avatar_url={avatar_url[:50]}...")
|
||||
else:
|
||||
self.log.warning(f"用户信息为空: {sender_wxid}")
|
||||
except Exception as e:
|
||||
self.log.error(f"获取用户信息失败: {e}")
|
||||
|
||||
except Exception as e:
|
||||
self.log.error(f"获取用户信息失败: {e}")
|
||||
import traceback
|
||||
self.log.error(f"详细错误: {traceback.format_exc()}")
|
||||
|
||||
# 如果获取失败,从历史记录中查找
|
||||
# 4. 如果仍然没有获取到,从历史记录中查找
|
||||
if not nickname or not avatar_url:
|
||||
self.log.info(f"尝试从历史记录获取用户信息: {sender_wxid}")
|
||||
try:
|
||||
|
||||
@@ -46,7 +46,7 @@ class PerformanceMonitor(PluginBase):
|
||||
if sender_wxid not in admins:
|
||||
return
|
||||
|
||||
if content in ["/性能", "/stats", "/状态"]:
|
||||
if content in ["/性能", "/stats", "/状态", "/性能报告"]:
|
||||
stats_msg = await self._get_performance_stats(bot)
|
||||
await bot.send_text(from_wxid, stats_msg)
|
||||
return False # 阻止其他插件处理
|
||||
@@ -66,11 +66,21 @@ class PerformanceMonitor(PluginBase):
|
||||
async def _get_performance_stats(self, bot) -> str:
|
||||
"""获取性能统计信息"""
|
||||
try:
|
||||
# 尝试使用新的性能监控器
|
||||
try:
|
||||
from utils.bot_utils import get_performance_monitor
|
||||
monitor = get_performance_monitor()
|
||||
if monitor and monitor.message_received > 0:
|
||||
return self._format_new_stats(monitor)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 降级到旧的统计方式
|
||||
stats = await self._get_performance_data()
|
||||
|
||||
|
||||
# 格式化统计信息
|
||||
uptime_hours = (time.time() - self.start_time) / 3600
|
||||
|
||||
|
||||
msg = f"""📊 系统性能统计
|
||||
|
||||
🕐 运行时间: {uptime_hours:.1f} 小时
|
||||
@@ -94,11 +104,57 @@ class PerformanceMonitor(PluginBase):
|
||||
• 过滤模式: {stats['ignore_mode']}"""
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取性能统计失败: {e}")
|
||||
return f"❌ 获取性能统计失败: {str(e)}"
|
||||
|
||||
def _format_new_stats(self, monitor) -> str:
|
||||
"""格式化新性能监控器的统计信息"""
|
||||
stats = monitor.get_stats()
|
||||
|
||||
# 基础信息
|
||||
msg = f"""📊 系统性能报告
|
||||
|
||||
🕐 运行时间: {stats['uptime_formatted']}
|
||||
|
||||
📨 消息统计:
|
||||
• 收到: {stats['messages']['received']}
|
||||
• 处理: {stats['messages']['processed']}
|
||||
• 失败: {stats['messages']['failed']}
|
||||
• 丢弃: {stats['messages']['dropped']}
|
||||
• 成功率: {stats['messages']['success_rate']}
|
||||
• 处理速率: {stats['messages']['processing_rate']}
|
||||
|
||||
⚡ 处理性能:
|
||||
• 平均耗时: {stats['processing_time']['average_ms']}ms
|
||||
• 最大耗时: {stats['processing_time']['max_ms']}ms
|
||||
• 最小耗时: {stats['processing_time']['min_ms']}ms
|
||||
|
||||
📦 队列状态:
|
||||
• 当前大小: {stats['queue']['current_size']}
|
||||
• 历史最大: {stats['queue']['max_size']}"""
|
||||
|
||||
# 熔断器状态
|
||||
cb = stats.get('circuit_breaker', {})
|
||||
if cb:
|
||||
state_icon = {'closed': '🟢', 'open': '🔴', 'half_open': '🟡'}.get(cb.get('state', ''), '⚪')
|
||||
msg += f"""
|
||||
|
||||
🔌 熔断器:
|
||||
• 状态: {state_icon} {cb.get('state', 'N/A')}
|
||||
• 失败计数: {cb.get('failure_count', 0)}
|
||||
• 恢复时间: {cb.get('current_recovery_time', 0):.0f}s"""
|
||||
|
||||
# 插件耗时排行
|
||||
plugins = stats.get('plugins', [])
|
||||
if plugins:
|
||||
msg += "\n\n🔧 插件耗时排行:"
|
||||
for i, p in enumerate(plugins[:5], 1):
|
||||
msg += f"\n {i}. {p['name']}: {p['avg_time_ms']}ms ({p['calls']}次)"
|
||||
|
||||
return msg
|
||||
|
||||
async def _get_performance_data(self) -> dict:
|
||||
"""获取性能数据"""
|
||||
# 系统资源(简化版本,不依赖psutil)
|
||||
|
||||
@@ -19,6 +19,7 @@ import pymysql
|
||||
from loguru import logger
|
||||
from utils.plugin_base import PluginBase
|
||||
from utils.decorators import on_text_message
|
||||
from utils.redis_cache import get_cache
|
||||
from WechatHook import WechatHookClient
|
||||
|
||||
try:
|
||||
@@ -49,14 +50,14 @@ class SignInPlugin(PluginBase):
|
||||
self.config = tomllib.load(f)
|
||||
|
||||
self.db_config = self.config["database"]
|
||||
|
||||
|
||||
# 创建临时文件夹
|
||||
self.temp_dir = Path(__file__).parent / "temp"
|
||||
self.temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
# 图片文件夹
|
||||
self.images_dir = Path(__file__).parent / "images"
|
||||
|
||||
|
||||
logger.success("签到插件初始化完成")
|
||||
|
||||
def get_db_connection(self):
|
||||
@@ -149,29 +150,42 @@ class SignInPlugin(PluginBase):
|
||||
logger.error(f"更新用户昵称失败: {e}")
|
||||
return False
|
||||
|
||||
async def get_user_nickname_from_group(self, client: WechatHookClient,
|
||||
async def get_user_nickname_from_group(self, client: WechatHookClient,
|
||||
group_wxid: str, user_wxid: str) -> str:
|
||||
"""从群聊中获取用户昵称"""
|
||||
"""从群聊中获取用户昵称(优先使用缓存)"""
|
||||
try:
|
||||
logger.debug(f"尝试获取用户 {user_wxid} 在群 {group_wxid} 中的昵称")
|
||||
|
||||
# 使用11174 API获取单个用户的详细信息
|
||||
# 动态获取缓存实例(由 MessageLogger 初始化)
|
||||
redis_cache = get_cache()
|
||||
|
||||
# 1. 先尝试从 Redis 缓存获取
|
||||
if redis_cache and redis_cache.enabled:
|
||||
cached_info = redis_cache.get_user_basic_info(group_wxid, user_wxid)
|
||||
if cached_info and cached_info.get("nickname"):
|
||||
logger.debug(f"[缓存命中] {user_wxid}: {cached_info['nickname']}")
|
||||
return cached_info["nickname"]
|
||||
|
||||
# 2. 缓存未命中,调用 API 获取
|
||||
logger.debug(f"[缓存未命中] 调用API获取用户昵称: {user_wxid}")
|
||||
user_info = await client.get_user_info_in_chatroom(group_wxid, user_wxid)
|
||||
|
||||
|
||||
if user_info:
|
||||
# 从返回的详细信息中提取昵称
|
||||
nickname = user_info.get("nickName", {}).get("string", "")
|
||||
|
||||
|
||||
if nickname:
|
||||
logger.success(f"获取到用户昵称: {user_wxid} -> {nickname}")
|
||||
logger.success(f"API获取用户昵称成功: {user_wxid} -> {nickname}")
|
||||
# 3. 将用户信息存入缓存
|
||||
if redis_cache and redis_cache.enabled:
|
||||
redis_cache.set_user_info(group_wxid, user_wxid, user_info)
|
||||
logger.debug(f"[已缓存] {user_wxid}: {nickname}")
|
||||
return nickname
|
||||
else:
|
||||
logger.warning(f"用户 {user_wxid} 的昵称字段为空")
|
||||
else:
|
||||
logger.warning(f"未找到用户 {user_wxid} 在群 {group_wxid} 中的信息")
|
||||
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取群成员昵称失败: {e}")
|
||||
return ""
|
||||
@@ -770,13 +784,24 @@ class SignInPlugin(PluginBase):
|
||||
current_points = updated_user["points"] if updated_user else total_earned
|
||||
updated_user["points"] = current_points
|
||||
|
||||
# 尝试获取用户头像
|
||||
# 尝试获取用户头像(优先使用缓存)
|
||||
avatar_url = None
|
||||
if is_group:
|
||||
try:
|
||||
user_detail = await client.get_user_info_in_chatroom(from_wxid, user_wxid)
|
||||
if user_detail:
|
||||
avatar_url = user_detail.get("bigHeadImgUrl", "")
|
||||
redis_cache = get_cache()
|
||||
# 先从缓存获取
|
||||
if redis_cache and redis_cache.enabled:
|
||||
cached_info = redis_cache.get_user_basic_info(from_wxid, user_wxid)
|
||||
if cached_info:
|
||||
avatar_url = cached_info.get("avatar_url", "")
|
||||
# 缓存未命中则调用 API
|
||||
if not avatar_url:
|
||||
user_detail = await client.get_user_info_in_chatroom(from_wxid, user_wxid)
|
||||
if user_detail:
|
||||
avatar_url = user_detail.get("bigHeadImgUrl", "")
|
||||
# 存入缓存
|
||||
if redis_cache and redis_cache.enabled:
|
||||
redis_cache.set_user_info(from_wxid, user_wxid, user_detail)
|
||||
except Exception as e:
|
||||
logger.warning(f"获取用户头像失败: {e}")
|
||||
|
||||
@@ -864,13 +889,24 @@ class SignInPlugin(PluginBase):
|
||||
self.update_user_nickname(user_wxid, nickname)
|
||||
user_info["nickname"] = nickname
|
||||
|
||||
# 尝试获取用户头像
|
||||
# 尝试获取用户头像(优先使用缓存)
|
||||
avatar_url = None
|
||||
if is_group:
|
||||
try:
|
||||
user_detail = await client.get_user_info_in_chatroom(from_wxid, user_wxid)
|
||||
if user_detail:
|
||||
avatar_url = user_detail.get("bigHeadImgUrl", "")
|
||||
redis_cache = get_cache()
|
||||
# 先从缓存获取
|
||||
if redis_cache and redis_cache.enabled:
|
||||
cached_info = redis_cache.get_user_basic_info(from_wxid, user_wxid)
|
||||
if cached_info:
|
||||
avatar_url = cached_info.get("avatar_url", "")
|
||||
# 缓存未命中则调用 API
|
||||
if not avatar_url:
|
||||
user_detail = await client.get_user_info_in_chatroom(from_wxid, user_wxid)
|
||||
if user_detail:
|
||||
avatar_url = user_detail.get("bigHeadImgUrl", "")
|
||||
# 存入缓存
|
||||
if redis_cache and redis_cache.enabled:
|
||||
redis_cache.set_user_info(from_wxid, user_wxid, user_detail)
|
||||
except Exception as e:
|
||||
logger.warning(f"获取用户头像失败: {e}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user