* 滚木

* feat: 重构核心架构,增强类型安全与插件管理

本次提交对核心模块进行了深度重构,引入 Pydantic 增强配置管理的类型安全性,并全面优化了插件管理系统。

主要变更详情:

1. 核心架构与配置
   - 重构配置加载模块:引入 Pydantic 模型 (`core/config_models.py`),提供严格的配置项类型检查、验证及默认值管理。
   - 统一模块结构:规范化模块导入路径,移除冗余的 `__init__.py` 文件,提升项目结构的清晰度。
   - 性能优化:集成 Redis 缓存支持 (`RedisManager`),有效降低高频 API 调用开销,提升响应速度。

2. 插件系统升级
   - 实现热重载机制:新增插件文件变更监听功能,支持开发过程中自动重载插件,提升开发效率。
   - 优化生命周期管理:改进插件加载与卸载逻辑,支持精确卸载指定插件及其关联的命令、事件处理器和定时任务。

3. 功能特性增强
   - 新增媒体 API:引入 `MediaAPI` 模块,封装图片、语音等富媒体资源的获取与处理接口。
   - 完善权限体系:重构权限管理系统,实现管理员与操作员的分级控制,支持更细粒度的命令权限校验。

4. 代码质量与稳定性
   - 全面类型修复:解决 `mypy` 静态类型检查发现的大量类型错误(包括 `CommandManager`、`EventFactory` 及 `Bot` API 签名不匹配问题)。
   - 增强错误处理:优化消息处理管道的异常捕获机制,完善关键路径的日志记录,提升系统运行稳定性。

* feat: 添加测试用例并优化代码结构

refactor(permission_manager): 调整初始化顺序和逻辑
fix(admin_manager): 修复初始化逻辑和目录创建问题
feat(ws): 优化Bot实例初始化条件
feat(message): 增强MessageSegment功能并添加测试
feat(events): 支持字符串格式的消息解析
test: 添加核心功能测试用例
refactor(plugin_manager): 改进插件路径处理
style: 清理无用导入和代码
chore: 更新依赖项
This commit is contained in:
镀铬酸钾
2026-01-09 00:20:56 +08:00
committed by GitHub
parent 6d7dfc179d
commit fa81229f6f
42 changed files with 1461 additions and 697 deletions

0
plugins/__init__.py Normal file
View File

View File

@@ -1,74 +1,94 @@
"""
管理员管理插件
提供通过聊天指令动态添加或移除机器人管理员的功能。
"""
from core.bot import Bot
from core.managers.command_manager import matcher
from core.managers.admin_manager import admin_manager
from core.handlers.event_handler import MessageHandler
from core.managers import command_manager, permission_manager
from core.permission import Permission
from models.events.message import MessageEvent
# 更新插件元信息以包含OP管理
__plugin_meta__ = {
"name": "管理员管理",
"description": "管理机器人的全局管理员",
"name": "权限管理",
"description": "管理机器人的管理员和操作",
"usage": (
"/admin list - 列出所有管理员\n"
"/admin add <QQ号> - 添加管理员\n"
"/admin remove <QQ号> - 移除管理员"
"/admin list - 列出所有管理员和操作员\n"
"/admin add_admin <QQ号> - 添加管理员\n"
"/admin remove_admin <QQ号> - 移除管理员\n"
"/admin add_op <QQ号> - 添加操作员\n"
"/admin remove_op <QQ号> - 移除操作员"
),
}
@matcher.command("admin", permission=MessageEvent.ADMIN)
async def admin_command_handler(bot: Bot, event: MessageEvent, args: list[str]):
@command_manager.command("admin", permission=Permission.ADMIN)
async def admin_management(event: MessageEvent, args: str):
"""
处理 /admin 指令
:param bot: Bot 实例
:param event: 消息事件实例
:param args: 指令参数列表
处理所有权限管理相关的命令。
"""
if not args:
await event.reply(__plugin_meta__["usage"])
parts = args.split()
if not parts:
await event.reply(f"用法不正确。\n\n{__plugin_meta__['usage']}")
return
action = args[0].lower()
subcommand = parts[0].lower()
if action == "list":
admins = await admin_manager.get_all_admins()
if not admins:
await event.reply("当前没有设置任何管理员。")
return
admin_list_text = "\n".join(str(admin_id) for admin_id in admins)
await event.reply(f"当前管理员列表 ({len(admins)}):\n{admin_list_text}")
if subcommand == "list":
await list_permissions(event)
return
if action in ("add", "remove"):
if len(args) < 2 or not args[1].isdigit():
await event.reply("参数错误,请提供一个有效的 QQ 号。\n示例: /admin add 123456")
return
# 处理需要QQ号的命令
if len(parts) < 2 or not parts[1].isdigit():
await event.reply(f"请提供有效的用户QQ号。\n用法: /admin {subcommand} <QQ号>")
return
try:
user_id = int(args[1])
except ValueError:
await event.reply("无效的 QQ 号,请输入纯数字")
return
try:
target_user_id = int(parts[1])
except ValueError:
await event.reply("无效的QQ")
return
if action == "add":
success = await admin_manager.add_admin(user_id)
if success:
await event.reply(f"成功添加管理员: {user_id}")
else:
await event.reply(f"管理员 {user_id} 已存在,无需重复添加")
return
elif action == "remove":
success = await admin_manager.remove_admin(user_id)
if success:
await event.reply(f"成功移除管理员: {user_id}")
else:
await event.reply(f"管理员 {user_id} 不存在。")
return
# 安全检查
if target_user_id == event.user_id:
await event.reply("你不能操作自己的权限。")
return
if target_user_id == event.self_id:
await event.reply("你不能操作机器人自身的权限")
return
await event.reply(f"未知的指令: {action}\n\n{__plugin_meta__['usage']}")
# 根据子命令分发
if subcommand == "add_admin":
permission_manager.set_user_permission(target_user_id, Permission.ADMIN)
await event.reply(f"已成功添加管理员:{target_user_id}")
elif subcommand == "remove_admin":
permission_manager.set_user_permission(target_user_id, Permission.USER)
await event.reply(f"已成功移除管理员:{target_user_id}")
elif subcommand == "add_op":
permission_manager.set_user_permission(target_user_id, Permission.OP)
await event.reply(f"已成功添加操作员:{target_user_id}")
elif subcommand == "remove_op":
permission_manager.set_user_permission(target_user_id, Permission.USER)
await event.reply(f"已成功移除操作员:{target_user_id}")
else:
await event.reply(f"未知的子命令 '{subcommand}'\n\n{__plugin_meta__['usage']}")
async def list_permissions(event: MessageEvent):
"""
列出所有具有特殊权限(管理员和操作员)的用户。
"""
permissions = permission_manager.get_all_user_permissions()
if not permissions:
await event.reply("当前没有配置任何特殊权限的用户。")
return
admins = {uid for uid, p in permissions.items() if p == 'admin'}
ops = {uid for uid, p in permissions.items() if p == 'op'}
reply_msg = "当前权限列表:\n"
if admins:
reply_msg += "--- 管理员 ---\n"
for user_id in admins:
reply_msg += f"- {user_id}\n"
if ops:
reply_msg += "--- 操作员 ---\n"
for user_id in ops:
reply_msg += f"- {user_id}\n"
await event.reply(reply_msg.strip())

View File

@@ -3,12 +3,16 @@ import re
import json
import requests
from bs4 import BeautifulSoup
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, Union
from cachetools import TTLCache
from core.utils.logger import logger
from core.managers.command_manager import matcher
from models import MessageEvent, MessageSegment
# 创建一个TTL缓存最大容量100缓存时间10秒
processed_messages: TTLCache[int, bool] = TTLCache(maxsize=100, ttl=10)
__plugin_meta__ = {
"name": "bili_parser",
"description": "自动解析B站分享卡片提取视频封面和播放量等信息。",
@@ -52,10 +56,14 @@ def parse_video_info(video_url: str) -> Optional[Dict[str, Any]]:
soup = BeautifulSoup(response.text, 'html.parser')
script_tag = soup.find('script', text=re.compile('window.__INITIAL_STATE__'))
if not script_tag:
if not script_tag or not script_tag.string:
return None
json_str = re.search(r'window\.__INITIAL_STATE__\s*=\s*(\{.*?\});', script_tag.string).group(1)
match = re.search(r'window\.__INITIAL_STATE__\s*=\s*(\{.*?\});', script_tag.string)
if not match:
return None
json_str = match.group(1)
data = json.loads(json_str)
video_data = data.get('videoData', {})
@@ -121,6 +129,15 @@ async def handle_bili_share(event: MessageEvent):
处理消息检测B站分享链接JSON卡片或文本链接并进行解析。
:param event: 消息事件对象
"""
# 消息去重
if event.message_id in processed_messages:
return
processed_messages[event.message_id] = True
# 忽略机器人自己发送的消息,防止无限循环
if event.user_id == event.self_id:
return
url_to_process = None
# 1. 优先解析JSON卡片中的短链接
@@ -176,6 +193,7 @@ async def process_bili_link(event: MessageEvent, url: str):
return
# 检查视频时长
video_message: Union[str, MessageSegment]
if video_info['duration'] > 300: # 5分钟 = 300秒
video_message = "视频时长超过5分钟不进行解析。"
else:

View File

@@ -8,8 +8,8 @@
"""
import asyncio
from core.managers.command_manager import matcher
from models import MessageEvent, PrivateMessageEvent
from core.managers.permission_manager import ADMIN
from models.events.message import MessageEvent, PrivateMessageEvent
from core.permission import Permission
from core.utils.logger import logger
# --- 会话状态管理 ---
@@ -24,7 +24,7 @@ def cleanup_session(user_id: int):
del broadcast_sessions[user_id]
logger.info(f"[Broadcast] 会话 {user_id} 已超时,自动取消。")
@matcher.command("broadcast", "广播", permission=ADMIN)
@matcher.command("broadcast", "广播", permission=Permission.ADMIN)
async def broadcast_start(event: MessageEvent):
"""
广播指令的入口,启动一个等待用户消息的会话。
@@ -92,7 +92,7 @@ async def handle_broadcast_content(event: MessageEvent):
nodes_to_send = [
bot.build_forward_node(
user_id=event.user_id,
nickname=event.sender.nickname,
nickname=event.sender.nickname if event.sender else "未知用户",
message=message_to_broadcast
)
]

View File

@@ -1,35 +1,24 @@
# -*- coding: utf-8 -*-
import html
import textwrap
# -*- coding: utf-8 -*-
import html
import textwrap
import asyncio
from typing import Dict
from core.managers.command_manager import matcher
from models import MessageEvent
from core.managers.permission_manager import ADMIN
from models.events.message import MessageEvent
from core.permission import Permission
from core.utils.logger import logger
__plugin_meta__ = {
"name": "Python 代码执行",
"description": "在安全的沙箱环境中执行 Python 代码片段,支持单行、多行和转发回复。",
"usage": "/py <单行代码>\n/code_py <单行代码>\n/py (进入多行输入模式)",
"name": "Python 代码执行",
"description": "在安全的沙箱环境中执行 Python 代码片段,支持单行、多行和转发回复。",
"usage": "/py <单行代码>\n/code_py <单行代码>\n/py (进入多行输入模式)",
}
# --- 会话状态管理 ---
# 结构: {(user_id, group_id): asyncio.TimerHandle}
multi_line_sessions: Dict[tuple, asyncio.TimerHandle] = {}
async def reply_as_forward(event: MessageEvent, input_code: str, output_result: str):
# --- 会话状态管理 ---
# 结构: {(user_id, group_id): asyncio.TimerHandle}
multi_line_sessions: Dict[tuple, asyncio.TimerHandle] = {}
async def reply_as_forward(event: MessageEvent, input_code: str, output_result: str):
"""
将输入和输出打包成转发消息进行回复。
@@ -41,35 +30,7 @@ async def reply_as_forward(event: MessageEvent, input_code: str, output_result:
nodes = [
bot.build_forward_node(
user_id=event.user_id,
nickname=event.sender.nickname or str(event.user_id),
message=f"--- Your Code ---\n{input_code}"
),
bot.build_forward_node(
user_id=event.self_id,
nickname="Code Executor",
message=f"--- Execution Result ---\n{output_result}"
)
]
try:
# 2. 发送合并转发消息
await bot.send_forwarded_messages(event, nodes)
except Exception as e:
logger.error(f"[code_py] 发送转发消息失败: {e}")
# 降级为普通消息回复
await event.reply(f"--- 你的代码 ---\n{input_code}\n--- 执行结果 ---\n{output_result}")
async def execute_code(event: MessageEvent, code: str):
将输入和输出打包成转发消息进行回复
参考 forward_test.py 的实现兼容私聊和群聊
"""
bot = event.bot
# 1. 构建消息节点列表
nodes = [
bot.build_forward_node(
user_id=event.user_id,
nickname=event.sender.nickname or str(event.user_id),
nickname=event.sender.nickname if event.sender else str(event.user_id),
message=f"--- Your Code ---\n{input_code}"
),
bot.build_forward_node(
@@ -90,7 +51,6 @@ async def execute_code(event: MessageEvent, code: str):
async def execute_code(event: MessageEvent, code: str):
"""
核心代码执行逻辑。
核心代码执行逻辑
"""
code_executor = getattr(event.bot, 'code_executor', None)
if not code_executor or not code_executor.docker_client:
@@ -137,74 +97,15 @@ def normalize_code(code: str) -> str:
return code.strip()
@matcher.command("py", "python", "code_py", permission=ADMIN)
async def code_py_main(event: MessageEvent, args: list[str]):
code_executor = getattr(event.bot, 'code_executor', None)
if not code_executor or not code_executor.docker_client:
await event.reply("代码执行服务当前不可用,请检查 Docker 连接配置。")
return
# 修改 add_task让它能直接接收回复函数
await code_executor.add_task(
code,
lambda result: reply_as_forward(event, code, result)
)
await event.reply("代码已提交至沙箱执行队列,请稍候...")
def cleanup_session(session_key: tuple):
"""
清理超时的会话
"""
if session_key in multi_line_sessions:
del multi_line_sessions[session_key]
logger.info(f"[code_py] 会话 {session_key} 已超时,自动取消。")
def normalize_code(code: str) -> str:
"""
规范化用户输入的 Python 代码字符串
主要处理两个问题
1. 对消息中可能存在的 HTML 实体进行解码 (e.g., &#91; -> [)。
2. 移除整个代码块的公共前导缩进以修复因复制粘贴导致的多余缩进
:param code: 原始代码字符串
:return: 规范化后的代码字符串
"""
# 1. 解码 HTML 实体
code = html.unescape(code)
# 2. 移除公共前导缩进
try:
code = textwrap.dedent(code)
except Exception:
# 在某些情况下例如不一致的缩进dedent 可能会失败,
# 但我们不希望因此中断流程,所以捕获异常并继续。
pass
return code.strip()
@matcher.command("py", "python", "code_py", permission=ADMIN)
@matcher.command("py", "python", "code_py", permission=Permission.ADMIN)
async def code_py_main(event: MessageEvent, args: list[str]):
"""
/py 命令的主入口。
- 如果有参数,直接执行。
- 如果没有参数,开启多行输入模式。
/py 命令的主入口
- 如果有参数直接执行
- 如果没有参数开启多行输入模式
"""
code_to_run = " ".join(args)
if code_to_run:
# 单行模式,对代码进行规范化处理
normalized_code = normalize_code(code_to_run)
if not normalized_code:
await event.reply("代码为空或格式错误,请输入有效的代码。")
return
await execute_code(event, normalized_code)
code_to_run = " ".join(args)
if code_to_run:
# 单行模式,对代码进行规范化处理
normalized_code = normalize_code(code_to_run)
@@ -231,24 +132,6 @@ async def code_py_main(event: MessageEvent, args: list[str]):
session_key
)
multi_line_sessions[session_key] = timeout_handler
# 多行模式
# 使用 getattr 兼容私聊和群聊
session_key = (event.user_id, getattr(event, 'group_id', 'private'))
# 如果上一个会话的超时任务还在,先取消它
if session_key in multi_line_sessions:
multi_line_sessions[session_key].cancel()
await event.reply("已进入多行代码输入模式,请直接发送你的代码。\n(60秒内无操作将自动取消)")
# 设置 60 秒超时
loop = asyncio.get_running_loop()
timeout_handler = loop.call_later(
60,
cleanup_session,
session_key
)
multi_line_sessions[session_key] = timeout_handler
@matcher.on_message()
async def handle_multi_line_code(event: MessageEvent):
@@ -265,26 +148,6 @@ async def handle_multi_line_code(event: MessageEvent):
# 对多行代码进行规范化处理
normalized_code = normalize_code(event.raw_message)
if not normalized_code:
await event.reply("捕获到的代码为空或格式错误,已取消输入。")
return
await execute_code(event, normalized_code)
return True # 消费事件,防止其他处理器响应
async def handle_multi_line_code(event: MessageEvent):
"""
通用消息处理器用于捕获多行模式下的代码输入
"""
# 使用 getattr 兼容私聊和群聊
session_key = (event.user_id, getattr(event, 'group_id', 'private'))
if session_key in multi_line_sessions:
# 取消超时任务
multi_line_sessions[session_key].cancel()
del multi_line_sessions[session_key]
# 对多行代码进行规范化处理
normalized_code = normalize_code(event.raw_message)
if not normalized_code:
await event.reply("捕获到的代码为空或格式错误,已取消输入。")
return

View File

@@ -5,7 +5,7 @@ Echo 与交互插件
"""
from core.managers.command_manager import matcher
from core.bot import Bot
from models import MessageEvent
from models.events.message import MessageEvent
__plugin_meta__ = {
"name": "echo",

View File

@@ -3,7 +3,7 @@
"""
from core.managers.command_manager import matcher
from core.bot import Bot
from models import MessageEvent
from models.events.message import MessageEvent
from models.message import MessageSegment
__plugin_meta__ = {
@@ -22,14 +22,15 @@ async def handle_forward_test(bot: Bot, event: MessageEvent, args: list[str]):
:param args: 指令参数
"""
# 1. 构建消息节点列表
nickname = event.sender.nickname if event.sender else "未知用户"
nodes = [
bot.build_forward_node(user_id=event.self_id, nickname="机器人", message="你要的furry来了"),
bot.build_forward_node(user_id=event.user_id, nickname=event.sender.nickname, message="让我看看"),
bot.build_forward_node(user_id=event.user_id, nickname=nickname, message="让我看看"),
bot.build_forward_node(
user_id=event.self_id,
nickname="机器人",
message=[
MessageSegment.text("你要的福瑞图"),
MessageSegment.from_text("你要的福瑞图"),
MessageSegment.image("https://api.furry.ist/furry-img/")
]
)

View File

@@ -10,7 +10,7 @@ from datetime import datetime
from core.bot import Bot
from core.managers.command_manager import matcher
from core.utils.executor import run_in_thread_pool
from models import MessageEvent, MessageSegment
from models.events.message import MessageEvent, MessageSegment
__plugin_meta__ = {
"name": "jrcd",
@@ -79,14 +79,17 @@ async def handle_jrcd(bot: Bot, event: MessageEvent, args: list[str]):
"""
user_id = event.user_id
jrcd = await run_in_thread_pool(get_jrcd, user_id)
msg = [MessageSegment.at(user_id)]
msg_text = ""
if jrcd <= 9:
msg.append(MessageSegment.text(random.choice(JRCDMSG_1) % jrcd))
msg_text = random.choice(JRCDMSG_1) % jrcd
elif jrcd <= 19:
msg.append(MessageSegment.text(random.choice(JRCDMSG_2) % jrcd))
msg_text = random.choice(JRCDMSG_2) % jrcd
else:
msg.append(MessageSegment.text(random.choice(JRCDMSG_3) % jrcd))
await event.reply(msg)
msg_text = random.choice(JRCDMSG_3) % jrcd
reply_segments = [MessageSegment.at(user_id), MessageSegment.from_text(msg_text)]
await event.reply(reply_segments)
@matcher.command("bbcd")
@@ -118,29 +121,31 @@ async def handle_bbcd(bot: Bot, event: MessageEvent, args: list[str]):
jrcz = jrcd1 - jrcd2
msg = [
MessageSegment.at(user_id1),
MessageSegment.text("你的长度比"),
MessageSegment.at(user_id2),
]
text_part = ""
if jrcz == 0:
msg.append(MessageSegment.text("一样长。"))
msg.append(MessageSegment.text(random.choice(BBCDMSG7)))
text_part = f" 一样长。{random.choice(BBCDMSG7)}"
elif jrcz > 0:
msg.append(MessageSegment.text("" + str(jrcz) + "cm。"))
text_part = f"{jrcz}cm。"
if jrcz <= 9:
msg.append(MessageSegment.text(random.choice(BBCDMSG1)))
text_part += random.choice(BBCDMSG1)
elif jrcz <= 19:
msg.append(MessageSegment.text(random.choice(BBCDMSG2)))
text_part += random.choice(BBCDMSG2)
else:
msg.append(MessageSegment.text(random.choice(BBCDMSG3)))
elif jrcz < 0:
msg.append(MessageSegment.text("" + str(abs(jrcz)) + "cm。"))
text_part += random.choice(BBCDMSG3)
else: # jrcz < 0
text_part = f"{abs(jrcz)}cm。"
if jrcz >= -9:
msg.append(MessageSegment.text(random.choice(BBCDMSG4)))
text_part += random.choice(BBCDMSG4)
elif jrcz >= -19:
msg.append(MessageSegment.text(random.choice(BBCDMSG5)))
text_part += random.choice(BBCDMSG5)
else:
msg.append(MessageSegment.text(random.choice(BBCDMSG6)))
await event.reply(msg)
text_part += random.choice(BBCDMSG6)
segments = [
MessageSegment.at(user_id1),
MessageSegment.from_text(" 你的长度比 "),
MessageSegment.at(user_id2),
MessageSegment.from_text(text_part),
]
await event.reply(segments)

View File

@@ -7,7 +7,7 @@ thpic 插件
from core.bot import Bot
from core.managers.command_manager import matcher
from models import MessageEvent, MessageSegment
from models.events.message import MessageEvent, MessageSegment
__plugin_meta__ = {
"name": "thpic",
@@ -26,6 +26,6 @@ async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
:param args: 指令参数列表(未使用)。
"""
try:
await event.reply(MessageSegment.image("https://img.paulzzh.com/touhou/random"))
await event.reply(str(MessageSegment.image("https://img.paulzzh.com/touhou/random")))
except Exception as e:
await event.reply("报错了。。。" + e)
await event.reply(f"报错了。。。{e}")