feat: 优化整体项目

This commit is contained in:
2025-12-05 18:06:13 +08:00
parent b4df26f61d
commit 7d3ef70093
13 changed files with 2661 additions and 305 deletions

View File

@@ -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:
"""下载表情包并转换为base64HTTP 直接下载"""
try:
# 替换 HTML 实体
cdn_url = cdn_url.replace("&", "&")
async def _download_emoji_and_encode(self, cdn_url: str, max_retries: int = 3) -> str:
"""下载表情包并转换为base64HTTP 直接下载,带重试机制),优先从缓存获取"""
# 替换 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())

View File

@@ -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
View 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 # 阻止继续传递

View File

@@ -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()}"
# 缓存到 Redis5分钟过期
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:

View File

@@ -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

View File

@@ -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}")