From e23607a7dcaf3424f218d8e9264dfa28d745b162 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Fri, 2 Jan 2026 15:55:20 +0800
Subject: [PATCH 01/46] 123
---
core/__init__.py | 2 +-
core/bot.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/core/__init__.py b/core/__init__.py
index dc3ae5d..41e8a92 100644
--- a/core/__init__.py
+++ b/core/__init__.py
@@ -1,5 +1,5 @@
from .command_manager import matcher
from .config_loader import global_config
-from .ws import WS
+from .WS import WS
__all__ = ["WS", "matcher", "global_config"]
diff --git a/core/bot.py b/core/bot.py
index e329cf7..fb41a51 100644
--- a/core/bot.py
+++ b/core/bot.py
@@ -6,7 +6,7 @@ Bot 抽象模块
from typing import TYPE_CHECKING, Dict, Any
if TYPE_CHECKING:
- from .ws import WS
+ from .WS import WS
from .api import MessageAPI, GroupAPI, FriendAPI, AccountAPI
From 3163bbf8c1b7f9ef5902b8063d6066961c2f1317 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Fri, 2 Jan 2026 16:05:00 +0800
Subject: [PATCH 02/46] =?UTF-8?q?=E5=B0=91=E5=AF=BC=E5=85=A5=E4=BA=86Messa?=
=?UTF-8?q?geSegment=E3=80=82=E3=80=82=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
models/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/models/__init__.py b/models/__init__.py
index 7103dd1..a25a0af 100644
--- a/models/__init__.py
+++ b/models/__init__.py
@@ -1,5 +1,5 @@
from .events.base import OneBotEvent
-from .events.message import MessageEvent, PrivateMessageEvent, GroupMessageEvent
+from .events.message import MessageEvent, PrivateMessageEvent, GroupMessageEvent, MessageSegment
from .events.notice import (
NoticeEvent, FriendAddNoticeEvent, FriendRecallNoticeEvent,
GroupRecallNoticeEvent, GroupIncreaseNoticeEvent,
From 01b83803c14ded6b95c00d0b2bab6ee1c8fb3e5f Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Fri, 2 Jan 2026 17:10:42 +0800
Subject: [PATCH 03/46] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B3=A8=E9=87=8A?=
=?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=8A=A0redis=E6=94=AF=E6=8C=81=EF=BC=8C?=
=?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86=E8=81=8A=E5=A4=A9=E8=AE=B0=E5=BD=95?=
=?UTF-8?q?=E6=9E=84=E5=BB=BA=E6=94=AF=E6=8C=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 109 ++++++++++++++++++-
config.toml | 8 +-
core/api/account.py | 112 ++++++++++++-------
core/api/friend.py | 69 ++++++++----
core/api/group.py | 228 ++++++++++++++++++++++++++-------------
core/api/message.py | 136 +++++++++++++++++------
core/bot.py | 102 +++++++++++++++---
core/command_manager.py | 162 ++++++++++++++++++----------
core/config_loader.py | 9 ++
core/redis_manager.py | 60 +++++++++++
core/ws.py | 58 +++++++---
main.py | 1 +
models/events/base.py | 59 ++++++----
models/events/message.py | 2 +-
models/events/meta.py | 2 +-
models/events/notice.py | 2 +-
models/events/request.py | 2 +-
models/message.py | 74 ++++++++-----
models/objects.py | 2 +-
models/sender.py | 2 +-
plugins/forward_test.py | 42 ++++++++
plugins/jrcd.py | 29 +++--
plugins/thpic.py | 7 ++
requirements.txt | 1 +
24 files changed, 965 insertions(+), 313 deletions(-)
create mode 100644 core/redis_manager.py
create mode 100644 plugins/forward_test.py
diff --git a/README.md b/README.md
index f74b384..f8ee6ff 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,42 @@ NEO 框架的设计遵循以下核心理念:
* **异步核心**:基于 `asyncio` 和 `websockets` 的高性能异步核心。
* **自动重连**:内置 WebSocket 断线重连机制。
+## ⚡️ 性能优化
+
+### Redis 缓存机制
+为了提升响应速度并减少对 OneBot API 的重复调用,框架核心集成了一套基于 Redis 的缓存系统。对于一些不频繁变更的数据(如群信息、好友列表等),首次查询后会将其缓存至 Redis,在缓存有效期内(默认为 1 小时),后续请求将直接从 Redis 读取,极大提升了性能。
+
+#### 工作原理
+- **自动缓存**:框架会自动缓存特定 API 的调用结果。
+- **缓存键**:缓存键根据 API 名称和关键参数(如 `group_id`, `user_id`)生成,确保唯一性。
+- **过期时间**:默认缓存 1 小时,之后会自动失效,下次调用时将重新从 OneBot 实现端获取最新数据。
+
+#### 受影响的 API
+以下核心 API 已默认启用缓存:
+- `get_group_info`
+- `get_group_member_info`
+- `get_friend_list`
+- `get_stranger_info`
+- `get_login_info`
+
+#### 如何绕过缓存
+在某些场景下,你可能需要获取实时数据而非缓存数据。为此,所有受缓存影响的 API 方法都增加了一个 `no_cache: bool = False` 的可选参数。
+
+当你需要强制从服务器获取最新信息时,只需在调用时传入 `no_cache=True` 即可。
+
+**示例:**
+```python
+# 正常调用,会使用缓存
+group_info = await bot.get_group_info(group_id=12345)
+
+# 强制获取最新信息,不使用缓存
+latest_group_info = await bot.get_group_info(group_id=12345, no_cache=True)
+```
+
+### `__slots__` 内存优化
+框架内的所有数据模型(包括事件、消息段、API 返回对象等)均已启用 `__slots__ = True` 优化。这可以显著减少每个对象实例的内存占用,特别是在处理大量事件和数据时,能够有效降低机器人的整体内存消耗。
+
+
## 📝 待办事项 (TODO)
### API 封装
@@ -90,10 +126,11 @@ NEO 框架的设计遵循以下核心理念:
- [ ] **系统控制**
- `set_restart`
- [ ] **扩展功能**
- - `send_forward_msg`: 发送合并转发消息
+ - [x] `send_forward_msg`: 发送合并转发消息
### 其他改进
- [x] **API 强类型封装**: 将 API 返回值从 `dict` 转换为数据模型对象。
+- [x] **Redis 支持**: 集成 Redis 连接池,便于插件复用连接。
- [ ] **日志系统优化**: 引入更完善的日志记录机制,支持文件输出和日志级别控制。
- [ ] **异常处理增强**: 增强插件执行过程中的异常捕获,防止单个插件崩溃影响整个 Bot。
- [ ] **中间件支持**: 添加消息处理中间件,支持在指令执行前/后进行拦截和处理。
@@ -104,7 +141,10 @@ NEO 框架的设计遵循以下核心理念:
```
NEO/
├── plugins/ # 插件目录,新建插件文件即可自动加载(支持热重载)
-│ └── echo.py # 示例插件:实现 /echo 和 /赞我 指令
+│ ├── echo.py # 示例插件:实现 /echo 和 /赞我 指令
+│ ├── forward_test.py # 示例插件:演示合并转发消息的构建和发送
+│ ├── jrcd.py # 娱乐插件:提供 /jrcd 和 /bbcd 指令
+│ └── thpic.py # 图片插件:提供 /thpic 指令,发送随机东方图片
├── core/ # 核心框架代码
│ ├── api/ # API 模块抽象层 (MessageAPI, GroupAPI, FriendAPI, AccountAPI)
│ │ ├── __init__.py
@@ -117,6 +157,7 @@ NEO/
│ ├── command_manager.py # 命令与事件分发器
│ ├── config_loader.py # 配置加载器
│ ├── plugin_manager.py # 插件加载与管理
+│ ├── redis_manager.py # Redis 连接管理器
│ └── ws.py # WebSocket 客户端核心
├── models/ # 数据模型
│ ├── events/ # OneBot 事件定义 (Message, Notice, Request, Meta)
@@ -378,6 +419,70 @@ async def dangerous_command(bot: Bot, event: MessageEvent, args: list[str]):
5. **性能考虑**:避免在插件中执行耗时同步操作
6. **资源清理**:必要时使用 `try...finally` 确保资源释放
+### 🚀 高性能插件开发规范 (避坑指南)
+为了保证整个机器人框架的响应速度和稳定性,所有插件都必须遵循异步、非阻塞的开发原则。任何一个插件中的阻塞操作都可能导致整个机器人卡顿或无响应。
+
+以下是必须遵守的核心规范:
+
+#### 1. 禁止任何形式的同步网络请求
+- **错误示范**: 使用 `requests` 库发起网络请求。
+ ```python
+ import requests
+ # 错误!这会阻塞整个程序
+ response = requests.get("https://api.example.com")
+ ```
+- **正确做法**: 必须使用异步 HTTP 客户端,如 `aiohttp` 或 `httpx`。
+ ```python
+ import httpx
+ # 正确,使用 async with 和 await
+ async with httpx.AsyncClient() as client:
+ response = await client.get("https://api.example.com")
+ ```
+
+#### 2. 禁止使用 `time.sleep()`
+- **错误示范**: 使用 `time.sleep()` 进行等待。
+ ```python
+ import time
+ # 错误!这会阻塞事件循环
+ time.sleep(5)
+ ```
+- **正确做法**: 必须使用 `asyncio.sleep()`。
+ ```python
+ import asyncio
+ # 正确,这会将控制权交还给事件循环
+ await asyncio.sleep(5)
+ ```
+
+#### 3. 谨慎处理文件 I/O
+- 对于读写小型、本地文件,直接使用 `with open(...)` 通常是可接受的。
+- 但对于大型文件或网络文件系统(NFS)上的文件,同步 I/O 可能会导致明显的阻塞。
+- **推荐做法**: 对于可能耗时较长的文件操作,使用 `aiofiles` 库。
+ ```python
+ import aiofiles
+ async with aiofiles.open('large_file.dat', mode='rb') as f:
+ contents = await f.read()
+ ```
+
+#### 4. 将 CPU 密集型任务移出事件循环
+- 如果插件需要执行复杂的计算(例如,图像处理、视频转码、数据分析),这些任务会长时间占用 CPU,同样会阻塞事件循环。
+- **正确做法**: 使用 `loop.run_in_executor()` 将这类任务抛到独立的线程池或进程池中执行。
+ ```python
+ import asyncio
+
+ def cpu_bound_task(data):
+ # 这是一个耗时的同步函数
+ # ... 进行大量计算 ...
+ return "计算结果"
+
+ # 在异步的事件处理器中调用
+ loop = asyncio.get_running_loop()
+ # `None` 表示使用默认的线程池
+ result = await loop.run_in_executor(None, cpu_bound_task, "一些数据")
+ await event.reply(result)
+ ```
+
+遵循以上规范,可以确保您开发的插件不会成为整个机器人应用的性能瓶颈。
+
### 示例:完整插件模板
```python
"""
diff --git a/config.toml b/config.toml
index ff7cf3e..1c9df7e 100644
--- a/config.toml
+++ b/config.toml
@@ -4,4 +4,10 @@ token = "&d_VTfksE%}ul?_Y"
reconnect_interval = 5
[bot]
-command = ["/"]
\ No newline at end of file
+command = ["/"]
+
+[redis]
+host = "114.66.58.203"
+port = 1931
+db = 0
+password = "redis_5dxyJG"
diff --git a/core/api/account.py b/core/api/account.py
index 8845451..0a5ef0d 100644
--- a/core/api/account.py
+++ b/core/api/account.py
@@ -1,78 +1,106 @@
"""
-账号相关 API 模块
+账号与状态相关 API 模块
+
+该模块定义了 `AccountAPI` Mixin 类,提供了所有与机器人自身账号信息、
+状态设置等相关的 OneBot v11 API 封装。
"""
+import json
from typing import Dict, Any
from .base import BaseAPI
from models.objects import LoginInfo, VersionInfo, Status
+from core.redis_manager import redis_client as redis_manager
class AccountAPI(BaseAPI):
"""
- 账号相关 API Mixin
+ `AccountAPI` Mixin 类,提供了所有与机器人账号、状态相关的 API 方法。
"""
- async def get_login_info(self) -> LoginInfo:
+ async def get_login_info(self, no_cache: bool = False) -> LoginInfo:
"""
- 获取登录号信息
+ 获取当前登录的机器人账号信息。
- :return: 登录信息对象
+ Args:
+ no_cache (bool, optional): 是否不使用缓存,直接从服务器获取最新信息。Defaults to False.
+
+ Returns:
+ LoginInfo: 包含登录号 QQ 和昵称的 `LoginInfo` 数据对象。
"""
+ cache_key = f"neobot:cache:get_login_info:{self.self_id}"
+ if not no_cache:
+ cached_data = await redis_manager.get(cache_key)
+ if cached_data:
+ return LoginInfo(**json.loads(cached_data))
+
res = await self.call_api("get_login_info")
+ await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
return LoginInfo(**res)
async def get_version_info(self) -> VersionInfo:
"""
- 获取版本信息
+ 获取 OneBot v11 实现的版本信息。
- :return: 版本信息对象
+ Returns:
+ VersionInfo: 包含 OneBot 实现版本信息的 `VersionInfo` 数据对象。
"""
res = await self.call_api("get_version_info")
return VersionInfo(**res)
async def get_status(self) -> Status:
"""
- 获取状态
+ 获取 OneBot v11 实现的状态信息。
- :return: 状态对象
+ Returns:
+ Status: 包含 OneBot 状态信息的 `Status` 数据对象。
"""
res = await self.call_api("get_status")
return Status(**res)
async def bot_exit(self) -> Dict[str, Any]:
"""
- 退出机器人
+ 让机器人进程退出(需要实现端支持)。
- :return: API 响应结果
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("bot_exit")
async def set_self_longnick(self, long_nick: str) -> Dict[str, Any]:
"""
- 设置个性签名
+ 设置机器人账号的个性签名。
- :param long_nick: 个性签名内容
- :return: API 响应结果
+ Args:
+ long_nick (str): 要设置的个性签名内容。
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_self_longnick", {"longNick": long_nick})
async def set_input_status(self, user_id: int, event_type: int) -> Dict[str, Any]:
"""
- 设置输入状态
+ 设置 "对方正在输入..." 状态提示。
- :param user_id: 用户 ID
- :param event_type: 事件类型
- :return: API 响应结果
+ Args:
+ user_id (int): 目标用户的 QQ 号。
+ event_type (int): 事件类型。
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_input_status", {"user_id": user_id, "event_type": event_type})
async def set_diy_online_status(self, face_id: int, face_type: int, wording: str) -> Dict[str, Any]:
"""
- 设置自定义在线状态
+ 设置自定义的 "在线状态"。
- :param face_id: 状态 ID
- :param face_type: 状态类型
- :param wording: 状态描述
- :return: API 响应结果
+ Args:
+ face_id (int): 状态的表情 ID。
+ face_type (int): 状态的表情类型。
+ wording (str): 状态的描述文本。
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_diy_online_status", {
"face_id": face_id,
@@ -82,43 +110,55 @@ class AccountAPI(BaseAPI):
async def set_online_status(self, status_code: int) -> Dict[str, Any]:
"""
- 设置在线状态
+ 设置在线状态(如在线、离开、摸鱼等)。
- :param status_code: 状态码
- :return: API 响应结果
+ Args:
+ status_code (int): 目标在线状态的状态码。
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_online_status", {"status_code": status_code})
async def set_qq_profile(self, **kwargs) -> Dict[str, Any]:
"""
- 设置 QQ 资料
+ 设置机器人账号的个人资料。
- :param kwargs: 个人资料相关参数
- :return: API 响应结果
+ Args:
+ **kwargs: 个人资料的相关参数,具体字段请参考 OneBot v11 规范。
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_qq_profile", kwargs)
async def set_qq_avatar(self, **kwargs) -> Dict[str, Any]:
"""
- 设置 QQ 头像
+ 设置机器人账号的头像。
- :param kwargs: 头像相关参数
- :return: API 响应结果
+ Args:
+ **kwargs: 头像的相关参数,具体字段请参考 OneBot v11 规范。
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_qq_avatar", kwargs)
async def get_clientkey(self) -> Dict[str, Any]:
"""
- 获取客户端密钥
+ 获取客户端密钥(通常用于 QQ 登录相关操作)。
- :return: API 响应结果
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("get_clientkey")
async def clean_cache(self) -> Dict[str, Any]:
"""
- 清理缓存
+ 清理 OneBot v11 实现端的缓存。
- :return: API 响应结果
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("clean_cache")
+
diff --git a/core/api/friend.py b/core/api/friend.py
index 8bb4453..76e696b 100644
--- a/core/api/friend.py
+++ b/core/api/friend.py
@@ -1,53 +1,86 @@
"""
-好友相关 API 模块
+好友与陌生人相关 API 模块
+
+该模块定义了 `FriendAPI` Mixin 类,提供了所有与好友、陌生人信息
+等相关的 OneBot v11 API 封装。
"""
+import json
from typing import List, Dict, Any
from .base import BaseAPI
from models.objects import FriendInfo, StrangerInfo
+from core.redis_manager import redis_client as redis_manager
class FriendAPI(BaseAPI):
"""
- 好友相关 API Mixin
+ `FriendAPI` Mixin 类,提供了所有与好友、陌生人操作相关的 API 方法。
"""
async def send_like(self, user_id: int, times: int = 1) -> Dict[str, Any]:
"""
- 发送点赞
+ 向指定用户发送 "戳一戳" (点赞)。
- :param user_id: 对方 QQ 号
- :param times: 点赞次数
- :return: API 响应结果
+ Args:
+ user_id (int): 目标用户的 QQ 号。
+ times (int, optional): 点赞次数,建议不超过 10 次。Defaults to 1.
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("send_like", {"user_id": user_id, "times": times})
async def get_stranger_info(self, user_id: int, no_cache: bool = False) -> StrangerInfo:
"""
- 获取陌生人信息
+ 获取陌生人的信息。
- :param user_id: QQ 号
- :param no_cache: 是否不使用缓存
- :return: 陌生人信息对象
+ Args:
+ user_id (int): 目标用户的 QQ 号。
+ no_cache (bool, optional): 是否不使用缓存,直接从服务器获取。Defaults to False.
+
+ Returns:
+ StrangerInfo: 包含陌生人信息的 `StrangerInfo` 数据对象。
"""
+ cache_key = f"neobot:cache:get_stranger_info:{user_id}"
+ if not no_cache:
+ cached_data = await redis_manager.get(cache_key)
+ if cached_data:
+ return StrangerInfo(**json.loads(cached_data))
+
res = await self.call_api("get_stranger_info", {"user_id": user_id, "no_cache": no_cache})
+ await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
return StrangerInfo(**res)
- async def get_friend_list(self) -> List[FriendInfo]:
+ async def get_friend_list(self, no_cache: bool = False) -> List[FriendInfo]:
"""
- 获取好友列表
+ 获取机器人账号的好友列表。
- :return: 好友信息对象列表
+ Args:
+ no_cache (bool, optional): 是否不使用缓存,直接从服务器获取最新信息。Defaults to False.
+
+ Returns:
+ List[FriendInfo]: 包含所有好友信息的 `FriendInfo` 对象列表。
"""
+ cache_key = f"neobot:cache:get_friend_list:{self.self_id}"
+ if not no_cache:
+ cached_data = await redis_manager.get(cache_key)
+ if cached_data:
+ return [FriendInfo(**item) for item in json.loads(cached_data)]
+
res = await self.call_api("get_friend_list")
+ await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
return [FriendInfo(**item) for item in res]
async def set_friend_add_request(self, flag: str, approve: bool = True, remark: str = "") -> Dict[str, Any]:
"""
- 处理加好友请求
+ 处理收到的加好友请求。
- :param flag: 加好友请求的 flag(需从上报的数据中获取)
- :param approve: 是否同意请求
- :param remark: 添加后的好友备注(仅在同意时有效)
- :return: API 响应结果
+ Args:
+ flag (str): 请求的标识,需要从 `request` 事件中获取。
+ approve (bool, optional): 是否同意该好友请求。Defaults to True.
+ remark (str, optional): 在同意请求时,为该好友设置的备注。Defaults to "".
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_friend_add_request", {"flag": flag, "approve": approve, "remark": remark})
+
diff --git a/core/api/group.py b/core/api/group.py
index a3d2dd6..d4eb5ae 100644
--- a/core/api/group.py
+++ b/core/api/group.py
@@ -1,47 +1,64 @@
"""
群组相关 API 模块
+
+该模块定义了 `GroupAPI` Mixin 类,提供了所有与群组管理、成员操作
+等相关的 OneBot v11 API 封装。
"""
from typing import List, Dict, Any, Optional
+import json
+from core.redis_manager import redis_client as redis_manager
from .base import BaseAPI
from models.objects import GroupInfo, GroupMemberInfo, GroupHonorInfo
class GroupAPI(BaseAPI):
"""
- 群组相关 API Mixin
+ `GroupAPI` Mixin 类,提供了所有与群组操作相关的 API 方法。
"""
async def set_group_kick(self, group_id: int, user_id: int, reject_add_request: bool = False) -> Dict[str, Any]:
"""
- 群组踢人
+ 将指定成员踢出群组。
- :param group_id: 群号
- :param user_id: 要踢的 QQ 号
- :param reject_add_request: 拒绝此人的加群请求
- :return: API 响应结果
+ Args:
+ group_id (int): 目标群组的群号。
+ user_id (int): 要踢出的成员的 QQ 号。
+ reject_add_request (bool, optional): 是否拒绝该用户此后的加群请求。Defaults to False.
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_group_kick", {"group_id": group_id, "user_id": user_id, "reject_add_request": reject_add_request})
- async def set_group_ban(self, group_id: int, user_id: int, duration: int = 30 * 60) -> Dict[str, Any]:
+ async def set_group_ban(self, group_id: int, user_id: int, duration: int = 1800) -> Dict[str, Any]:
"""
- 群组单人禁言
+ 禁言群组中的指定成员。
- :param group_id: 群号
- :param user_id: 要禁言的 QQ 号
- :param duration: 禁言时长(秒),0 表示解除禁言
- :return: API 响应结果
+ Args:
+ group_id (int): 目标群组的群号。
+ user_id (int): 要禁言的成员的 QQ 号。
+ duration (int, optional): 禁言时长,单位为秒。设置为 0 表示解除禁言。
+ Defaults to 1800 (30 分钟).
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_group_ban", {"group_id": group_id, "user_id": user_id, "duration": duration})
- async def set_group_anonymous_ban(self, group_id: int, anonymous: Dict[str, Any] = None, duration: int = 30 * 60, flag: str = None) -> Dict[str, Any]:
+ async def set_group_anonymous_ban(self, group_id: int, anonymous: Dict[str, Any] = None, duration: int = 1800, flag: str = None) -> Dict[str, Any]:
"""
- 群组匿名禁言
+ 禁言群组中的匿名用户。
- :param group_id: 群号
- :param anonymous: 可选,要禁言的匿名用户对象(群消息事件的 anonymous 字段)
- :param duration: 禁言时长(秒)
- :param flag: 可选,要禁言的匿名用户的 flag(需从群消息事件的 anonymous 字段中获取)
- :return: API 响应结果
+ Args:
+ group_id (int): 目标群组的群号。
+ anonymous (Dict[str, Any], optional): 要禁言的匿名用户对象,
+ 可从群消息事件的 `anonymous` 字段中获取。Defaults to None.
+ duration (int, optional): 禁言时长,单位为秒。Defaults to 1800.
+ flag (str, optional): 要禁言的匿名用户的 flag 标识,
+ 可从群消息事件的 `anonymous` 字段中获取。Defaults to None.
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
params = {"group_id": group_id, "duration": duration}
if anonymous:
@@ -52,139 +69,196 @@ class GroupAPI(BaseAPI):
async def set_group_whole_ban(self, group_id: int, enable: bool = True) -> Dict[str, Any]:
"""
- 群组全员禁言
+ 开启或关闭群组全员禁言。
- :param group_id: 群号
- :param enable: 是否开启
- :return: API 响应结果
+ Args:
+ group_id (int): 目标群组的群号。
+ enable (bool, optional): True 表示开启全员禁言,False 表示关闭。Defaults to True.
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_group_whole_ban", {"group_id": group_id, "enable": enable})
async def set_group_admin(self, group_id: int, user_id: int, enable: bool = True) -> Dict[str, Any]:
"""
- 群组设置管理员
+ 设置或取消群组成员的管理员权限。
- :param group_id: 群号
- :param user_id: 要设置的 QQ 号
- :param enable: True 为设置,False 为取消
- :return: API 响应结果
+ Args:
+ group_id (int): 目标群组的群号。
+ user_id (int): 目标成员的 QQ 号。
+ enable (bool, optional): True 表示设为管理员,False 表示取消管理员。Defaults to True.
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_group_admin", {"group_id": group_id, "user_id": user_id, "enable": enable})
async def set_group_anonymous(self, group_id: int, enable: bool = True) -> Dict[str, Any]:
"""
- 群组匿名
+ 开启或关闭群组的匿名聊天功能。
- :param group_id: 群号
- :param enable: 是否开启
- :return: API 响应结果
+ Args:
+ group_id (int): 目标群组的群号。
+ enable (bool, optional): True 表示开启匿名,False 表示关闭。Defaults to True.
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_group_anonymous", {"group_id": group_id, "enable": enable})
async def set_group_card(self, group_id: int, user_id: int, card: str = "") -> Dict[str, Any]:
"""
- 设置群名片(群备注)
+ 设置群组成员的群名片。
- :param group_id: 群号
- :param user_id: 要设置的 QQ 号
- :param card: 群名片内容,不填或空字符串表示删除群名片
- :return: API 响应结果
+ Args:
+ group_id (int): 目标群组的群号。
+ user_id (int): 目标成员的 QQ 号。
+ card (str, optional): 要设置的群名片内容。
+ 传入空字符串 `""` 或 `None` 表示删除该成员的群名片。Defaults to "".
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_group_card", {"group_id": group_id, "user_id": user_id, "card": card})
async def set_group_name(self, group_id: int, group_name: str) -> Dict[str, Any]:
"""
- 设置群名
+ 设置群组的名称。
- :param group_id: 群号
- :param group_name: 新群名
- :return: API 响应结果
+ Args:
+ group_id (int): 目标群组的群号。
+ group_name (str): 新的群组名称。
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_group_name", {"group_id": group_id, "group_name": group_name})
async def set_group_leave(self, group_id: int, is_dismiss: bool = False) -> Dict[str, Any]:
"""
- 退出群组
+ 退出或解散一个群组。
- :param group_id: 群号
- :param is_dismiss: 是否解散,如果登录号是群主,则仅在此项为 True 时能够解散
- :return: API 响应结果
+ Args:
+ group_id (int): 目标群组的群号。
+ is_dismiss (bool, optional): 是否解散群组。
+ 仅当机器人是群主时,此项设为 True 才能解散群。Defaults to False.
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_group_leave", {"group_id": group_id, "is_dismiss": is_dismiss})
async def set_group_special_title(self, group_id: int, user_id: int, special_title: str = "", duration: int = -1) -> Dict[str, Any]:
"""
- 设置群组专属头衔
+ 为群组成员设置专属头衔。
- :param group_id: 群号
- :param user_id: 要设置的 QQ 号
- :param special_title: 专属头衔,不填或空字符串表示删除
- :param duration: 有效期(秒),-1 表示永久
- :return: API 响应结果
+ Args:
+ group_id (int): 目标群组的群号。
+ user_id (int): 目标成员的 QQ 号。
+ special_title (str, optional): 专属头衔内容。
+ 传入空字符串 `""` 或 `None` 表示删除头衔。Defaults to "".
+ duration (int, optional): 头衔有效期,单位为秒。-1 表示永久。Defaults to -1.
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_group_special_title", {"group_id": group_id, "user_id": user_id, "special_title": special_title, "duration": duration})
async def get_group_info(self, group_id: int, no_cache: bool = False) -> GroupInfo:
"""
- 获取群信息
+ 获取群组的详细信息。
- :param group_id: 群号
- :param no_cache: 是否不使用缓存
- :return: 群信息对象
+ Args:
+ group_id (int): 目标群组的群号。
+ no_cache (bool, optional): 是否不使用缓存,直接从服务器获取最新信息。Defaults to False.
+
+ Returns:
+ GroupInfo: 包含群组信息的 `GroupInfo` 数据对象。
"""
- res = await self.call_api("get_group_info", {"group_id": group_id, "no_cache": no_cache})
+ cache_key = f"neobot:cache:get_group_info:{group_id}"
+ if not no_cache:
+ cached_data = await redis_manager.get(cache_key)
+ if cached_data:
+ return GroupInfo(**json.loads(cached_data))
+
+ res = await self.call_api("get_group_info", {"group_id": group_id})
+ await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
return GroupInfo(**res)
async def get_group_list(self) -> List[GroupInfo]:
"""
- 获取群列表
+ 获取机器人加入的所有群组的列表。
- :return: 群信息对象列表
+ Returns:
+ List[GroupInfo]: 包含所有群组信息的 `GroupInfo` 对象列表。
"""
res = await self.call_api("get_group_list")
return [GroupInfo(**item) for item in res]
async def get_group_member_info(self, group_id: int, user_id: int, no_cache: bool = False) -> GroupMemberInfo:
"""
- 获取群成员信息
+ 获取指定群组成员的详细信息。
- :param group_id: 群号
- :param user_id: QQ 号
- :param no_cache: 是否不使用缓存
- :return: 群成员信息对象
+ Args:
+ group_id (int): 目标群组的群号。
+ user_id (int): 目标成员的 QQ 号。
+ no_cache (bool, optional): 是否不使用缓存。Defaults to False.
+
+ Returns:
+ GroupMemberInfo: 包含群成员信息的 `GroupMemberInfo` 数据对象。
"""
- res = await self.call_api("get_group_member_info", {"group_id": group_id, "user_id": user_id, "no_cache": no_cache})
+ cache_key = f"neobot:cache:get_group_member_info:{group_id}:{user_id}"
+ if not no_cache:
+ cached_data = await redis_manager.get(cache_key)
+ if cached_data:
+ return GroupMemberInfo(**json.loads(cached_data))
+
+ res = await self.call_api("get_group_member_info", {"group_id": group_id, "user_id": user_id})
+ await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
return GroupMemberInfo(**res)
async def get_group_member_list(self, group_id: int) -> List[GroupMemberInfo]:
"""
- 获取群成员列表
+ 获取一个群组的所有成员列表。
- :param group_id: 群号
- :return: 群成员信息对象列表
+ Args:
+ group_id (int): 目标群组的群号。
+
+ Returns:
+ List[GroupMemberInfo]: 包含所有群成员信息的 `GroupMemberInfo` 对象列表。
"""
res = await self.call_api("get_group_member_list", {"group_id": group_id})
return [GroupMemberInfo(**item) for item in res]
async def get_group_honor_info(self, group_id: int, type: str) -> GroupHonorInfo:
"""
- 获取群荣誉信息
+ 获取群组的荣誉信息(如龙王、群聊之火等)。
- :param group_id: 群号
- :param type: 要获取的群荣誉类型,可传入 talkative, performer, legend, strong_newbie, emotion 等
- :return: 群荣誉信息对象
+ Args:
+ group_id (int): 目标群组的群号。
+ type (str): 要获取的荣誉类型。
+ 可选值: "talkative", "performer", "legend", "strong_newbie", "emotion" 等。
+
+ Returns:
+ GroupHonorInfo: 包含群荣誉信息的 `GroupHonorInfo` 数据对象。
"""
res = await self.call_api("get_group_honor_info", {"group_id": group_id, "type": type})
return GroupHonorInfo(**res)
async def set_group_add_request(self, flag: str, sub_type: str, approve: bool = True, reason: str = "") -> Dict[str, Any]:
"""
- 处理加群请求/邀请
+ 处理加群请求或邀请。
- :param flag: 加群请求的 flag(需从上报的数据中获取)
- :param sub_type: add 或 invite,请求类型(需要与上报消息中的 sub_type 字段相符)
- :param approve: 是否同意请求/邀请
- :param reason: 拒绝理由(仅在拒绝时有效)
- :return: API 响应结果
+ Args:
+ flag (str): 请求的标识,需要从 `request` 事件中获取。
+ sub_type (str): 请求的子类型,`add` 或 `invite`,
+ 需要与 `request` 事件中的 `sub_type` 字段相符。
+ approve (bool, optional): 是否同意请求或邀请。Defaults to True.
+ reason (str, optional): 拒绝加群的理由(仅在 `approve` 为 False 时有效)。Defaults to "".
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_group_add_request", {"flag": flag, "sub_type": sub_type, "approve": approve, "reason": reason})
+
diff --git a/core/api/message.py b/core/api/message.py
index b712215..25458a4 100644
--- a/core/api/message.py
+++ b/core/api/message.py
@@ -1,5 +1,8 @@
"""
消息相关 API 模块
+
+该模块定义了 `MessageAPI` Mixin 类,提供了所有与消息发送、撤回、
+转发等相关的 OneBot v11 API 封装。
"""
from typing import Union, List, Dict, Any, TYPE_CHECKING
from .base import BaseAPI
@@ -10,17 +13,22 @@ if TYPE_CHECKING:
class MessageAPI(BaseAPI):
"""
- 消息相关 API Mixin
+ `MessageAPI` Mixin 类,提供了所有与消息操作相关的 API 方法。
"""
async def send_group_msg(self, group_id: int, message: Union[str, "MessageSegment", List["MessageSegment"]], auto_escape: bool = False) -> Dict[str, Any]:
"""
- 发送群消息
+ 发送群消息。
- :param group_id: 群号
- :param message: 消息内容,可以是字符串、MessageSegment 对象或 MessageSegment 列表
- :param auto_escape: 是否自动转义(仅当 message 为字符串时有效)
- :return: API 响应结果
+ Args:
+ group_id (int): 目标群组的群号。
+ message (Union[str, MessageSegment, List[MessageSegment]]): 要发送的消息内容。
+ 可以是纯文本字符串、单个消息段对象或消息段列表。
+ auto_escape (bool, optional): 仅当 `message` 为字符串时有效,
+ 是否对消息内容进行 CQ 码转义。Defaults to False.
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api(
"send_group_msg", {"group_id": group_id, "message": self._process_message(message), "auto_escape": auto_escape}
@@ -28,12 +36,15 @@ class MessageAPI(BaseAPI):
async def send_private_msg(self, user_id: int, message: Union[str, "MessageSegment", List["MessageSegment"]], auto_escape: bool = False) -> Dict[str, Any]:
"""
- 发送私聊消息
+ 发送私聊消息。
- :param user_id: 用户 QQ 号
- :param message: 消息内容,可以是字符串、MessageSegment 对象或 MessageSegment 列表
- :param auto_escape: 是否自动转义(仅当 message 为字符串时有效)
- :return: API 响应结果
+ Args:
+ user_id (int): 目标用户的 QQ 号。
+ message (Union[str, MessageSegment, List[MessageSegment]]): 要发送的消息内容。
+ auto_escape (bool, optional): 是否对消息内容进行 CQ 码转义。Defaults to False.
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api(
"send_private_msg", {"user_id": user_id, "message": self._process_message(message), "auto_escape": auto_escape}
@@ -41,12 +52,18 @@ class MessageAPI(BaseAPI):
async def send(self, event: "OneBotEvent", message: Union[str, "MessageSegment", List["MessageSegment"]], auto_escape: bool = False) -> Dict[str, Any]:
"""
- 智能发送消息,根据事件类型自动选择发送方式
+ 智能发送消息。
- :param event: 触发事件对象
- :param message: 消息内容
- :param auto_escape: 是否自动转义
- :return: API 响应结果
+ 该方法会根据传入的事件对象 `event` 自动判断是私聊还是群聊,
+ 并调用相应的发送函数。如果事件是消息事件,则优先使用 `reply` 方法。
+
+ Args:
+ event (OneBotEvent): 触发该发送行为的事件对象。
+ message (Union[str, MessageSegment, List[MessageSegment]]): 要发送的消息内容。
+ auto_escape (bool, optional): 是否对消息内容进行 CQ 码转义。Defaults to False.
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
# 如果是消息事件,直接调用 reply
if hasattr(event, "reply"):
@@ -66,53 +83,98 @@ class MessageAPI(BaseAPI):
async def delete_msg(self, message_id: int) -> Dict[str, Any]:
"""
- 撤回消息
+ 撤回一条消息。
- :param message_id: 消息 ID
- :return: API 响应结果
+ Args:
+ message_id (int): 要撤回的消息的 ID。
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("delete_msg", {"message_id": message_id})
async def get_msg(self, message_id: int) -> Dict[str, Any]:
"""
- 获取消息
+ 获取一条消息的详细信息。
- :param message_id: 消息 ID
- :return: API 响应结果
+ Args:
+ message_id (int): 要获取的消息的 ID。
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据,包含消息详情。
"""
return await self.call_api("get_msg", {"message_id": message_id})
async def get_forward_msg(self, id: str) -> Dict[str, Any]:
"""
- 获取合并转发消息
+ 获取合并转发消息的内容。
- :param id: 合并转发 ID
- :return: API 响应结果
+ Args:
+ id (str): 合并转发消息的 ID。
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据,包含转发消息的节点列表。
"""
return await self.call_api("get_forward_msg", {"id": id})
+ async def send_group_forward_msg(self, group_id: int, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
+ """
+ 发送群聊合并转发消息。
+
+ Args:
+ group_id (int): 目标群组的群号。
+ messages (List[Dict[str, Any]]): 消息节点列表。
+ 推荐使用 `bot.build_forward_node` 来构建节点。
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
+ """
+ return await self.call_api("send_group_forward_msg", {"group_id": group_id, "messages": messages})
+
+ async def send_private_forward_msg(self, user_id: int, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
+ """
+ 发送私聊合并转发消息。
+
+ Args:
+ user_id (int): 目标用户的 QQ 号。
+ messages (List[Dict[str, Any]]): 消息节点列表。
+
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
+ """
+ return await self.call_api("send_private_forward_msg", {"user_id": user_id, "messages": messages})
+
async def can_send_image(self) -> Dict[str, Any]:
"""
- 检查是否可以发送图片
+ 检查当前机器人账号是否可以发送图片。
- :return: API 响应结果
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("can_send_image")
async def can_send_record(self) -> Dict[str, Any]:
"""
- 检查是否可以发送语音
+ 检查当前机器人账号是否可以发送语音。
- :return: API 响应结果
+ Returns:
+ Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("can_send_record")
def _process_message(self, message: Union[str, "MessageSegment", List["MessageSegment"]]) -> Union[str, List[Dict[str, Any]]]:
"""
- 处理消息内容,将其转换为 API 可接受的格式
+ 内部方法:将消息内容处理成 OneBot API 可接受的格式。
- :param message: 原始消息内容
- :return: 处理后的消息内容
+ - `str` -> `str`
+ - `MessageSegment` -> `List[Dict]`
+ - `List[MessageSegment]` -> `List[Dict]`
+
+ Args:
+ message: 原始消息内容。
+
+ Returns:
+ 处理后的消息内容。
"""
if isinstance(message, str):
return message
@@ -130,12 +192,16 @@ class MessageAPI(BaseAPI):
def _segment_to_dict(self, segment: "MessageSegment") -> Dict[str, Any]:
"""
- 将 MessageSegment 对象转换为字典
+ 内部方法:将 `MessageSegment` 对象转换为字典。
- :param segment: MessageSegment 对象
- :return: 字典格式的消息段
+ Args:
+ segment (MessageSegment): 消息段对象。
+
+ Returns:
+ Dict[str, Any]: 符合 OneBot 规范的消息段字典。
"""
return {
"type": segment.type,
"data": segment.data
}
+
diff --git a/core/bot.py b/core/bot.py
index fb41a51..2af4efc 100644
--- a/core/bot.py
+++ b/core/bot.py
@@ -1,9 +1,18 @@
"""
-Bot 抽象模块
+Bot 核心抽象模块
-定义了 Bot 类,封装了 OneBot API 的调用逻辑,提供了便捷的消息发送方法。
+该模块定义了 `Bot` 类,它是与 OneBot v11 API 进行交互的主要接口。
+`Bot` 类通过继承 `api` 目录下的各个 Mixin 类,将不同类别的 API 调用
+整合在一起,提供了一个统一、便捷的调用入口。
+
+主要职责包括:
+- 封装 WebSocket 通信,提供 `call_api` 方法。
+- 提供高级消息发送功能,如 `send_forwarded_messages`。
+- 整合所有细分的 API 调用(消息、群组、好友等)。
"""
-from typing import TYPE_CHECKING, Dict, Any
+from typing import TYPE_CHECKING, Dict, Any, List, Union
+from models.events.base import OneBotEvent
+from models.message import MessageSegment
if TYPE_CHECKING:
from .WS import WS
@@ -13,24 +22,93 @@ from .api import MessageAPI, GroupAPI, FriendAPI, AccountAPI
class Bot(MessageAPI, GroupAPI, FriendAPI, AccountAPI):
"""
- Bot 抽象类,封装 API 调用和常用操作
- 继承各个 API Mixin 以提高代码的可维护性
+ 机器人核心类,封装了所有与 OneBot API 的交互。
+
+ 通过 Mixin 模式继承了所有 API 功能,使得结构清晰且易于扩展。
+ 实例由 `WS` 客户端在连接成功后创建,并传递给所有事件处理器和插件。
"""
def __init__(self, ws_client: "WS"):
"""
- 初始化 Bot 实例
+ 初始化 Bot 实例。
- :param ws_client: WebSocket 客户端实例,用于底层通信
+ Args:
+ ws_client (WS): WebSocket 客户端实例,负责底层的 API 请求和响应处理。
"""
self.ws = ws_client
async def call_api(self, action: str, params: Dict[str, Any] = None) -> Any:
"""
- 调用 OneBot API
+ 底层 API 调用方法。
- :param action: API 动作名称
- :param params: API 参数
- :return: API 响应结果
+ 所有具体的 API 实现最终都会调用此方法,通过 WebSocket 发送请求。
+
+ Args:
+ action (str): API 的动作名称,例如 "send_group_msg"。
+ params (Dict[str, Any], optional): API 请求的参数字典。Defaults to None.
+
+ Returns:
+ Any: OneBot API 的响应数据。
"""
- return await self.ws.call_api(action, params)
\ No newline at end of file
+ return await self.ws.call_api(action, params)
+
+ def build_forward_node(self, user_id: int, nickname: str, message: Union[str, "MessageSegment", List["MessageSegment"]]) -> Dict[str, Any]:
+ """
+ 构建一个用于合并转发的消息节点 (Node)。
+
+ 这是一个辅助方法,用于方便地创建符合 OneBot v11 规范的消息节点,
+ 以便在 `send_forwarded_messages` 中使用。
+
+ Args:
+ user_id (int): 发送者的 QQ 号。
+ nickname (str): 发送者在消息中显示的昵称。
+ message (Union[str, MessageSegment, List[MessageSegment]]): 该节点的消息内容,
+ 可以是纯文本、单个消息段或消息段列表。
+
+ Returns:
+ Dict[str, Any]: 构造好的消息节点字典。
+ """
+ return {
+ "type": "node",
+ "data": {
+ "uin": user_id,
+ "name": nickname,
+ "content": self._process_message(message)
+ }
+ }
+
+ async def send_forwarded_messages(self, target: Union[int, "OneBotEvent"], nodes: List[Dict[str, Any]]):
+ """
+ 发送合并转发消息。
+
+ 该方法实现了智能判断,可以根据 `target` 的类型自动发送群聊合并转发
+ 或私聊合并转发消息。
+
+ Args:
+ target (Union[int, OneBotEvent]): 发送目标。
+ - 如果是 `OneBotEvent` 对象,则自动判断是群聊还是私聊。
+ - 如果是 `int`,则默认为群号,发送群聊合并转发。
+ nodes (List[Dict[str, Any]]): 消息节点列表。
+ 推荐使用 `build_forward_node` 方法来构建列表中的每个节点。
+
+ Raises:
+ ValueError: 如果事件对象中既没有 `group_id` 也没有 `user_id`。
+ """
+ if isinstance(target, OneBotEvent):
+ group_id = getattr(target, "group_id", None)
+ user_id = getattr(target, "user_id", None)
+
+ if group_id:
+ # 直接发送群聊合并转发
+ await self.send_group_forward_msg(group_id, nodes)
+ elif user_id:
+ # 发送私聊合并转发
+ await self.send_private_forward_msg(user_id, nodes)
+ else:
+ raise ValueError("Event has neither group_id nor user_id")
+
+ else:
+ # 默认行为是发送到群聊
+ group_id = target
+ await self.send_group_forward_msg(group_id, nodes)
+
diff --git a/core/command_manager.py b/core/command_manager.py
index e2bb131..5f29ff2 100644
--- a/core/command_manager.py
+++ b/core/command_manager.py
@@ -1,7 +1,17 @@
"""
-命令管理器模块
+命令与事件管理器模块
-提供装饰器用于注册消息指令、通知处理器和请求处理器,并负责事件的分发。
+该模块定义了 `CommandManager` 类,它是整个机器人框架事件处理的核心。
+它通过装饰器模式,为插件提供了注册消息指令、通知事件处理器和
+请求事件处理器的能力。
+
+主要职责:
+- 提供 `@matcher.command()` 装饰器来注册命令。
+- 提供 `@matcher.on_notice()` 装饰器来注册通知处理器。
+- 提供 `@matcher.on_request()` 装饰器来注册请求处理器。
+- 负责解析收到的消息,匹配命令前缀并分发给对应的处理器。
+- 统一处理所有类型的事件,并将其分发给所有已注册的处理器。
+- 内置一个 `/help` 命令,用于展示所有已加载插件的帮助信息。
"""
import inspect
from typing import Any, Callable, Dict, List, Tuple
@@ -14,22 +24,25 @@ comm_prefixes = global_config.bot.get("command", ("/",))
class CommandManager:
"""
- 命令管理器,负责注册和分发指令、通知和请求事件
+ 命令管理器,负责注册和分发所有类型的事件。
+
+ 这是一个单例对象(`matcher`),在整个应用中共享。
"""
- def __init__(self, prefixes: Tuple[str, ...] = ("/",)):
+ def __init__(self, prefixes: Tuple[str, ...]):
"""
- 初始化命令管理器
+ 初始化命令管理器。
- :param prefixes: 命令前缀元组
+ Args:
+ prefixes (Tuple[str, ...]): 一个包含所有合法命令前缀的元组。
"""
self.prefixes = prefixes
- self.commands: Dict[str, Callable] = {} # 存储消息指令
- self.notice_handlers: List[Dict] = [] # 存储通知处理器
- self.request_handlers: List[Dict] = [] # 存储请求处理器
- self.plugins: Dict[str, Dict[str, Any]] = {} # 存储插件元数据
+ self.commands: Dict[str, Callable] = {} # 存储消息指令处理器
+ self.notice_handlers: List[Dict] = [] # 存储通知事件处理器
+ self.request_handlers: List[Dict] = [] # 存储请求事件处理器
+ self.plugins: Dict[str, Dict[str, Any]] = {} # 存储已加载插件的元数据
- # --- 内置 help 指令 ---
+ # --- 注册内置 help 指令 ---
self.commands["help"] = self._help_command
self.plugins["core.help"] = {
"name": "帮助",
@@ -39,10 +52,13 @@ class CommandManager:
async def _help_command(self, bot, event):
"""
- 内置的 /help 指令处理器
+ 内置的 `/help` 命令的实现。
- :param bot: Bot 实例
- :param event: 消息事件对象
+ 该命令会遍历所有已加载插件的元数据,并生成一段格式化的帮助文本。
+
+ Args:
+ bot: Bot 实例。
+ event: 消息事件对象。
"""
help_text = "--- 可用指令列表 ---\n"
@@ -57,58 +73,78 @@ class CommandManager:
await bot.send(event, help_text.strip())
- # --- 1. 消息指令装饰器 ---
- def command(self, name: str):
+ def command(self, name: str) -> Callable:
"""
- 装饰器:注册消息指令
+ 装饰器:用于注册一个消息指令处理器。
- :param name: 指令名称(不含前缀)
- :return: 装饰器函数
+ Example:
+ @matcher.command("echo")
+ async def handle_echo(bot, event, args):
+ await bot.send(event, " ".join(args))
+
+ Args:
+ name (str): 指令的名称(不包含命令前缀)。
+
+ Returns:
+ Callable: 原函数,使其可以继续被调用。
"""
- def decorator(func):
+ def decorator(func: Callable) -> Callable:
self.commands[name] = func
return func
return decorator
- # --- 2. 通知事件装饰器 ---
- def on_notice(self, notice_type: str = None):
+ def on_notice(self, notice_type: str = None) -> Callable:
"""
- 装饰器:注册通知处理器
+ 装饰器:用于注册一个通知事件处理器。
- :param notice_type: 通知类型,如果为 None 则处理所有通知
- :return: 装饰器函数
+ 如果 `notice_type` 未指定,则该处理器会接收所有类型的通知事件。
+
+ Args:
+ notice_type (str, optional): 要处理的通知类型 (e.g., "group_increase")。
+ Defaults to None.
+
+ Returns:
+ Callable: 原函数。
"""
- def decorator(func):
+ def decorator(func: Callable) -> Callable:
self.notice_handlers.append({"type": notice_type, "func": func})
return func
return decorator
- # --- 3. 请求事件装饰器 ---
- def on_request(self, request_type: str = None):
+ def on_request(self, request_type: str = None) -> Callable:
"""
- 装饰器:注册请求处理器
+ 装饰器:用于注册一个请求事件处理器。
- :param request_type: 请求类型,如果为 None 则处理所有请求
- :return: 装饰器函数
+ 如果 `request_type` 未指定,则该处理器会接收所有类型的请求事件。
+
+ Args:
+ request_type (str, optional): 要处理的请求类型 (e.g., "friend", "group")。
+ Defaults to None.
+
+ Returns:
+ Callable: 原函数。
"""
- def decorator(func):
+ def decorator(func: Callable) -> Callable:
self.request_handlers.append({"type": request_type, "func": func})
return func
return decorator
- # --- 统一事件分发入口 ---
async def handle_event(self, bot, event):
"""
- 统一事件分发入口
+ 统一的事件分发入口。
- :param bot: Bot 实例
- :param event: 事件对象
+ 由 `WS` 客户端在接收到事件后调用。该方法会根据事件的 `post_type`
+ 将其分发给对应的具体处理方法。
+
+ Args:
+ bot: Bot 实例。
+ event: 已解析的事件对象。
"""
post_type = event.post_type
@@ -119,13 +155,16 @@ class CommandManager:
elif post_type == 'request':
await self.handle_request(bot, event)
- # --- 消息分发逻辑 ---
async def handle_message(self, bot, event):
"""
- 解析并分发消息指令
+ 处理消息事件,解析并分发指令。
- :param bot: Bot 实例
- :param event: 消息事件对象
+ 该方法会检查消息是否以已配置的命令前缀开头,如果是,则解析出
+ 指令名称和参数,并调用对应的处理器。
+
+ Args:
+ bot: Bot 实例。
+ event: 消息事件对象。
"""
if not event.raw_message:
return
@@ -155,39 +194,43 @@ class CommandManager:
func = self.commands[cmd_name]
await self._run_handler(func, bot, event, args)
- # --- 通知分发逻辑 ---
async def handle_notice(self, bot, event):
"""
- 分发通知事件
+ 分发通知事件给所有匹配的处理器。
- :param bot: Bot 实例
- :param event: 通知事件对象
+ Args:
+ bot: Bot 实例。
+ event: 通知事件对象。
"""
for handler in self.notice_handlers:
if handler["type"] is None or handler["type"] == event.notice_type:
await self._run_handler(handler["func"], bot, event)
- # --- 请求分发逻辑 ---
async def handle_request(self, bot, event):
"""
- 分发请求事件
+ 分发请求事件给所有匹配的处理器。
- :param bot: Bot 实例
- :param event: 请求事件对象
+ Args:
+ bot: Bot 实例。
+ event: 请求事件对象。
"""
for handler in self.request_handlers:
if handler["type"] is None or handler["type"] == event.request_type:
await self._run_handler(handler["func"], bot, event)
- # --- 通用执行器:自动注入参数 ---
- async def _run_handler(self, func, bot, event, args=None):
+ async def _run_handler(self, func: Callable, bot, event, args: List[str] = None):
"""
- 根据函数签名自动注入 bot, event 或 args
+ 智能执行事件处理器。
- :param func: 目标处理函数
- :param bot: Bot 实例
- :param event: 事件对象
- :param args: 指令参数(仅消息指令有效)
+ 该方法会检查目标处理器的函数签名,并根据签名动态地传入所需的参数
+ (如 `bot`, `event`, `args`),实现了依赖注入。
+
+ Args:
+ func (Callable): 目标处理器函数。
+ bot: Bot 实例。
+ event: 事件对象。
+ args (List[str], optional): 指令参数列表(仅对消息事件有效)。
+ Defaults to None.
"""
sig = inspect.signature(func)
params = sig.parameters
@@ -204,11 +247,14 @@ class CommandManager:
await func(**kwargs)
-# 确保前缀是元组格式
+# --- 全局单例 ---
+
+# 确保前缀配置是元组格式
if isinstance(comm_prefixes, list):
- comm_prefixes = tuple[Any, ...](comm_prefixes)
+ comm_prefixes = tuple(comm_prefixes)
elif isinstance(comm_prefixes, str):
comm_prefixes = (comm_prefixes,)
-# 实例化全局管理器
+# 实例化全局唯一的命令管理器
matcher = CommandManager(prefixes=comm_prefixes)
+
diff --git a/core/config_loader.py b/core/config_loader.py
index 0882266..776ddcb 100644
--- a/core/config_loader.py
+++ b/core/config_loader.py
@@ -64,6 +64,15 @@ class Config:
"""
return self._data.get("features", {})
+ @property
+ def redis(self) -> dict:
+ """
+ 获取 Redis 配置
+
+ :return: 配置字典
+ """
+ return self._data.get("redis", {})
+
# 实例化全局配置对象
global_config = Config()
diff --git a/core/redis_manager.py b/core/redis_manager.py
new file mode 100644
index 0000000..2b232d2
--- /dev/null
+++ b/core/redis_manager.py
@@ -0,0 +1,60 @@
+import redis
+from .config_loader import global_config as config
+
+class RedisManager:
+ """
+ Redis 连接管理器
+ """
+ _pool = None
+ _client = None
+
+ @classmethod
+ def initialize(cls):
+ """
+ 初始化 Redis 连接并进行健康检查
+ """
+ if cls._pool is None:
+ try:
+ host = config.redis['host']
+ port = config.redis['port']
+ db = config.redis['db']
+ password = config.redis.get('password')
+
+ print(f" 正在尝试连接 Redis: {host}:{port}, DB: {db}")
+
+ cls._pool = redis.ConnectionPool(
+ host=host,
+ port=port,
+ db=db,
+ password=password,
+ decode_responses=True
+ )
+ cls._client = redis.Redis(connection_pool=cls._pool)
+ if cls._client.ping():
+ print(" Redis 连接成功!")
+ else:
+ print(" Redis 连接失败: PING 命令无响应")
+ except redis.exceptions.ConnectionError as e:
+ print(f" Redis 连接失败: {e}")
+ cls._pool = None
+ cls._client = None
+ except Exception as e:
+ print(f" Redis 初始化时发生未知错误: {e}")
+ cls._pool = None
+ cls._client = None
+
+ @classmethod
+ def get_redis(cls):
+ """
+ 获取 Redis 连接
+
+ :return: Redis 连接实例
+ """
+ if cls._client is None:
+ # 理论上 initialize 应该在程序启动时被调用,这里作为备用
+ cls.initialize()
+ return cls._client
+
+# 在模块加载时直接初始化
+RedisManager.initialize()
+redis_client = RedisManager.get_redis()
diff --git a/core/ws.py b/core/ws.py
index 7a6206d..e35317f 100644
--- a/core/ws.py
+++ b/core/ws.py
@@ -1,7 +1,15 @@
"""
-WebSocket 核心模块
+WebSocket 核心通信模块
-负责与 OneBot 实现端建立 WebSocket 连接,处理消息接收、事件分发和 API 调用。
+该模块定义了 `WS` 类,负责与 OneBot v11 实现(如 NapCat)建立和管理
+WebSocket 连接。它是整个机器人框架的底层通信基础。
+
+主要职责包括:
+- 建立 WebSocket 连接并处理认证。
+- 实现断线自动重连机制。
+- 监听并接收来自 OneBot 的事件和 API 响应。
+- 分发事件给 `CommandManager` 进行处理。
+- 提供 `call_api` 方法,用于异步发送 API 请求并等待响应。
"""
import asyncio
import json
@@ -20,12 +28,14 @@ from .config_loader import global_config
class WS:
"""
- WebSocket 客户端类,负责与 OneBot 实现端建立连接并处理通信
+ WebSocket 客户端,负责与 OneBot v11 实现进行底层通信。
"""
def __init__(self):
"""
- 初始化 WebSocket 客户端
+ 初始化 WebSocket 客户端。
+
+ 从全局配置中读取 WebSocket URI、访问令牌(Token)和重连间隔。
"""
# 读取参数
cfg = global_config.napcat_ws
@@ -39,7 +49,10 @@ class WS:
async def connect(self):
"""
- 主连接循环,负责建立连接和自动重连
+ 启动并管理 WebSocket 连接。
+
+ 这是一个无限循环,负责建立连接。如果连接断开,它会根据配置的
+ `reconnect_interval` 时间间隔后自动尝试重新连接。
"""
headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
@@ -67,9 +80,13 @@ class WS:
async def _listen_loop(self, websocket):
"""
- 核心监听循环,处理接收到的 WebSocket 消息
+ 核心监听循环,处理所有接收到的 WebSocket 消息。
- :param websocket: WebSocket 连接对象
+ 此循环会持续从 WebSocket 连接中读取消息,并根据消息内容
+ 判断是 API 响应还是上报的事件,然后分发给相应的处理逻辑。
+
+ Args:
+ websocket: 当前活动的 WebSocket 连接对象。
"""
async for message in websocket:
try:
@@ -96,9 +113,16 @@ class WS:
async def on_event(self, raw_data: dict):
"""
- 事件分发层:根据 post_type 调用 matcher 对应的处理器
+ 事件处理和分发层。
- :param raw_data: 原始事件数据字典
+ 当接收到一个 OneBot 事件时,此方法负责:
+ 1. 使用 `EventFactory` 将原始 JSON 数据解析成对应的事件对象。
+ 2. 为事件对象注入 `Bot` 实例,以便在插件中可以调用 API。
+ 3. 打印格式化的事件日志。
+ 4. 将事件对象传递给 `CommandManager` (`matcher`) 进行后续处理。
+
+ Args:
+ raw_data (dict): 从 WebSocket 接收到的原始事件字典。
"""
try:
# 使用工厂创建事件对象
@@ -124,11 +148,18 @@ class WS:
async def call_api(self, action: str, params: dict = None):
"""
- 调用 OneBot API
+ 向 OneBot v11 实现端发送一个 API 请求。
- :param action: API 动作名称
- :param params: API 参数
- :return: API 响应结果
+ 该方法通过 WebSocket 发送请求,并使用 `echo` 字段来匹配对应的响应。
+ 它创建了一个 `Future` 对象来异步等待响应,并设置了超时机制。
+
+ Args:
+ action (str): API 的动作名称,例如 "send_group_msg"。
+ params (dict, optional): API 请求的参数字典。 Defaults to None.
+
+ Returns:
+ dict: OneBot API 的响应数据。如果超时或连接断开,则返回一个
+ 表示失败的字典。
"""
if not self.ws:
return {"status": "failed", "msg": "websocket not initialized"}
@@ -152,3 +183,4 @@ class WS:
except asyncio.TimeoutError:
self._pending_requests.pop(echo_id, None)
return {"status": "failed", "retcode": -1, "msg": "api timeout"}
+
diff --git a/main.py b/main.py
index 99e9a8d..50b87cc 100644
--- a/main.py
+++ b/main.py
@@ -12,6 +12,7 @@ from watchdog.events import FileSystemEventHandler
from core import WS
from core.plugin_manager import load_all_plugins
+#from core.redis_manager import redis_client
class PluginReloadHandler(FileSystemEventHandler):
diff --git a/models/events/base.py b/models/events/base.py
index 5bbe8d6..2a83962 100644
--- a/models/events/base.py
+++ b/models/events/base.py
@@ -1,7 +1,8 @@
"""
基础事件模型模块
-定义了所有 OneBot 11 事件的基类和事件类型枚举。
+该模块定义了所有 OneBot v11 事件模型的基类 `OneBotEvent` 和
+事件类型常量 `EventType`。所有具体的事件模型都应继承自 `OneBotEvent`。
"""
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional
@@ -13,46 +14,60 @@ if TYPE_CHECKING:
class EventType:
"""
- 事件类型枚举
+ OneBot v11 事件类型常量。
+
+ 用于标识不同种类的事件上报。
"""
- META = 'meta_event' # 元事件
- REQUEST = 'request' # 请求事件
- NOTICE = 'notice' # 通知事件
- MESSAGE = 'message' # 消息事件
- MESSAGE_SENT = 'message_sent' # 消息发送事件
+ META = 'meta_event'
+ """元事件 (meta_event): 如心跳、生命周期等。"""
+ REQUEST = 'request'
+ """请求事件 (request): 如加好友请求、加群请求等。"""
+ NOTICE = 'notice'
+ """通知事件 (notice): 如群成员增加、文件上传等。"""
+ MESSAGE = 'message'
+ """消息事件 (message): 如私聊消息、群消息等。"""
+ MESSAGE_SENT = 'message_sent'
+ """消息发送事件 (message_sent): 机器人自己发送消息的上报。"""
-@dataclass
+@dataclass(slots=True)
class OneBotEvent(ABC):
"""
- OneBot 事件基类
- 所有具体的事件类型都应该继承自此类
+ OneBot v11 事件的抽象基类。
+
+ 所有具体的事件模型都必须继承此类,并实现 `post_type` 属性。
+
+ Attributes:
+ time (int): 事件发生的时间戳 (秒)。
+ self_id (int): 收到事件的机器人 QQ 号。
+ _bot (Optional[Bot]): 内部持有的 Bot 实例引用,用于快捷 API 调用。
"""
time: int
- """事件发生的时间戳"""
-
self_id: int
- """收到事件的机器人 QQ 号"""
-
_bot: Optional["Bot"] = field(default=None, init=False)
- """Bot 实例引用,用于快捷调用 API"""
@property
@abstractmethod
def post_type(self) -> str:
"""
- 上报类型
+ 抽象属性,代表事件的上报类型。
+
+ 子类必须重写此属性,并返回对应的 `EventType` 常量值。
+ 例如: `return EventType.MESSAGE`
"""
pass
@property
def bot(self) -> "Bot":
"""
- 获取 Bot 实例
+ 获取与此事件关联的 `Bot` 实例,以便快捷调用 API。
- :return: Bot 实例
- :raises ValueError: 如果 Bot 实例未设置
+ Returns:
+ Bot: 当前事件所对应的 `Bot` 实例。
+
+ Raises:
+ ValueError: 如果 `Bot` 实例尚未被设置到事件对象中。
"""
if self._bot is None:
raise ValueError("Bot instance not set for this event")
@@ -61,8 +76,10 @@ class OneBotEvent(ABC):
@bot.setter
def bot(self, value: "Bot"):
"""
- 设置 Bot 实例
+ 为事件对象设置关联的 `Bot` 实例。
- :param value: Bot 实例
+ Args:
+ value (Bot): 要设置的 `Bot` 实例。
"""
self._bot = value
+
diff --git a/models/events/message.py b/models/events/message.py
index e2e0bf1..3ede724 100644
--- a/models/events/message.py
+++ b/models/events/message.py
@@ -11,7 +11,7 @@ from models.sender import Sender
from .base import OneBotEvent, EventType
-@dataclass
+@dataclass(slots=True)
class Anonymous:
"""
匿名信息
diff --git a/models/events/meta.py b/models/events/meta.py
index 91b44d8..b2c720f 100644
--- a/models/events/meta.py
+++ b/models/events/meta.py
@@ -8,7 +8,7 @@ from typing import Optional
from .base import OneBotEvent, EventType
-@dataclass
+@dataclass(slots=True)
class HeartbeatStatus:
"""
心跳状态接口
diff --git a/models/events/notice.py b/models/events/notice.py
index 8dcf56c..82cbbfc 100644
--- a/models/events/notice.py
+++ b/models/events/notice.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass, field
from .base import OneBotEvent, EventType
-@dataclass
+@dataclass(slots=True)
class NoticeEvent(OneBotEvent):
"""
通知事件基类
diff --git a/models/events/request.py b/models/events/request.py
index 87930b6..6f7d82d 100644
--- a/models/events/request.py
+++ b/models/events/request.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from .base import OneBotEvent, EventType
-@dataclass
+@dataclass(slots=True)
class RequestEvent(OneBotEvent):
"""
请求事件基类
diff --git a/models/message.py b/models/message.py
index 914a149..ee7f701 100644
--- a/models/message.py
+++ b/models/message.py
@@ -1,48 +1,56 @@
"""
消息段模型模块
-定义了 MessageSegment 类,用于封装 OneBot 11 的消息段。
+该模块定义了 `MessageSegment` 类,用于构建和表示 OneBot v11 协议中的消息段。
+通过此类,可以方便地创建文本、图片、At 等不同类型的消息内容,并支持链式操作。
"""
from dataclasses import dataclass
from typing import Any, Dict
-@dataclass
+@dataclass(slots=True)
class MessageSegment:
"""
- 消息段,对应 OneBot 11 标准中的消息段对象
+ 表示一个 OneBot v11 消息段。
+
+ Attributes:
+ type (str): 消息段的类型,例如 'text', 'image', 'at'。
+ data (Dict[str, Any]): 消息段的具体数据,是一个键值对字典。
"""
type: str
- """消息段类型,如 text, image, at 等"""
-
data: Dict[str, Any]
- """消息段数据"""
@property
def text(self) -> str:
"""
- 获取文本内容(仅当 type 为 text 时有效)
+ 当消息段类型为 'text' 时,快速获取其文本内容。
- :return: 文本内容
+ Returns:
+ str: 消息段的文本内容。如果类型不是 'text',则返回空字符串。
"""
return self.data.get("text", "") if self.type == "text" else ""
@property
def image_url(self) -> str:
"""
- 获取图片 URL(仅当 type 为 image 时有效)
+ 当消息段类型为 'image' 时,快速获取其图片 URL。
- :return: 图片 URL
+ Returns:
+ str: 图片的 URL。如果类型不是 'image' 或数据中不含 'url',则返回空字符串。
"""
return self.data.get("url", "") if self.type == "image" else ""
def is_at(self, user_id: int = None) -> bool:
"""
- 判断是否为 @某人
+ 检查当前消息段是否是一个 'at' (提及) 消息段。
- :param user_id: 指定的 QQ 号,如果为 None 则只判断是否为 at 类型
- :return: 是否匹配
+ Args:
+ user_id (int, optional): 如果提供,则进一步检查被提及的 QQ 号是否匹配。
+ Defaults to None.
+
+ Returns:
+ bool: 如果消息段是 'at' 类型且 user_id 匹配 (如果提供),则返回 True。
"""
if self.type != "at":
return False
@@ -51,6 +59,9 @@ class MessageSegment:
return str(self.data.get("qq")) == str(user_id)
def __repr__(self):
+ """
+ 返回消息段对象的字符串表示形式,便于调试。
+ """
return f"[MS:{self.type}:{self.data}]"
# --- 快捷构造方法 ---
@@ -58,39 +69,52 @@ class MessageSegment:
@staticmethod
def text(text: str) -> "MessageSegment":
"""
- 构造文本消息段
+ 创建一个文本消息段。
- :param text: 文本内容
- :return: MessageSegment 对象
+ Args:
+ text (str): 文本内容。
+
+ Returns:
+ MessageSegment: 一个类型为 'text' 的消息段对象。
"""
return MessageSegment(type="text", data={"text": text})
@staticmethod
def at(user_id: int | str) -> "MessageSegment":
"""
- 构造 @某人 消息段
+ 创建一个 @某人 的消息段。
- :param user_id: 目标 QQ 号,"all" 表示 @全体成员
- :return: MessageSegment 对象
+ Args:
+ user_id (int | str): 要提及的 QQ 号。若为 "all",则表示 @全体成员。
+
+ Returns:
+ MessageSegment: 一个类型为 'at' 的消息段对象。
"""
return MessageSegment(type="at", data={"qq": str(user_id)})
@staticmethod
def image(file: str) -> "MessageSegment":
"""
- 构造图片消息段
+ 创建一个图片消息段。
- :param file: 图片文件名、URL 或 Base64
- :return: MessageSegment 对象
+ Args:
+ file (str): 图片的路径、URL 或 Base64 编码的字符串。
+
+ Returns:
+ MessageSegment: 一个类型为 'image' 的消息段对象。
"""
return MessageSegment(type="image", data={"file": file})
@staticmethod
def face(id: int) -> "MessageSegment":
"""
- 构造表情消息段
+ 创建一个 QQ 表情消息段。
- :param id: 表情 ID
- :return: MessageSegment 对象
+ Args:
+ id (int): QQ 表情的 ID。
+
+ Returns:
+ MessageSegment: 一个类型为 'face' 的消息段对象。
"""
return MessageSegment(type="face", data={"id": str(id)})
+
diff --git a/models/objects.py b/models/objects.py
index 49d1769..2f20c51 100644
--- a/models/objects.py
+++ b/models/objects.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass, field
from typing import List, Optional
-@dataclass
+@dataclass(slots=True)
class GroupInfo:
"""
群信息
diff --git a/models/sender.py b/models/sender.py
index 3d63821..df81dfb 100644
--- a/models/sender.py
+++ b/models/sender.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from typing import Optional
-@dataclass
+@dataclass(slots=True)
class Sender:
"""
发送者信息类,对应 OneBot 11 标准中的 sender 字段
diff --git a/plugins/forward_test.py b/plugins/forward_test.py
new file mode 100644
index 0000000..429037f
--- /dev/null
+++ b/plugins/forward_test.py
@@ -0,0 +1,42 @@
+"""
+合并转发消息测试插件
+"""
+from core.command_manager import matcher
+from core.bot import Bot
+from models import MessageEvent
+from models.message import MessageSegment
+
+__plugin_meta__ = {
+ "name": "furry",
+ "description": "处理 /furry 指令,发送furry图片,同时也是bot.build_forward_node演示",
+ "usage": "/furry - 发送一条furry图",
+}
+
+@matcher.command("furry")
+async def handle_forward_test(bot: Bot, event: MessageEvent, args: list[str]):
+ """
+ 处理 /furry 指令,发送furry图片,同时也是bot.build_forward_node实例
+
+ :param bot: Bot 实例
+ :param event: 消息事件对象
+ :param args: 指令参数
+ """
+ # 1. 构建消息节点列表
+ 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.self_id,
+ nickname="机器人",
+ message=[
+ MessageSegment.text("你要的福瑞图"),
+ MessageSegment.image("https://api.furry.ist/furry-img/")
+ ]
+ )
+ ]
+
+ try:
+ # 2. 发送合并转发消息
+ await bot.send_forwarded_messages(event, nodes)
+ except Exception as e:
+ await event.reply(f"发送失败: {e}")
diff --git a/plugins/jrcd.py b/plugins/jrcd.py
index 2528643..4b7e318 100644
--- a/plugins/jrcd.py
+++ b/plugins/jrcd.py
@@ -1,3 +1,8 @@
+"""
+今日人品插件
+
+提供 /jrcd 和 /bbcd 指令,用于娱乐。
+"""
import random
from datetime import datetime
@@ -19,7 +24,7 @@ JRCDMSG_2 = [
JRCDMSG_3 = [
"今天的长度是%scm,哦豁?听说你很勇哦?(✧◡✧)",
"今天的长度是%scm,嘶哈嘶哈(((o(*°▽°*)o)))...",
- "今天的长度是%scm,我靠,让哥哥爽一爽吧!(((o(*°▽°*)o)))...",
+ "今天的长度是%scm,我靠,让哥哥爽一-爽吧!(((o(*°▽°*)o)))...",
"今天的长度是%scm,单是看到哥哥的长度就....(〃w〃)",
]
@@ -38,6 +43,12 @@ BBCDMSG7 = ["试试刺刀看看谁能赢吧!"]
def get_jrcd(user_id: int) -> int:
+ """
+ 根据用户ID和当前日期生成一个伪随机的“长度”值。
+
+ :param user_id: 用户QQ号。
+ :return: 返回一个1到30之间的整数。
+ """
current_time = (
datetime.now().year * 100 + datetime.now().month * 100 + datetime.now().day
)
@@ -52,11 +63,11 @@ def get_jrcd(user_id: int) -> int:
@matcher.command("jrcd")
async def handle_jrcd(bot: Bot, event: MessageEvent, args: list[str]):
"""
- 处理 jrcd 指令,来看看你的长度吧!
+ 处理 jrcd 指令,回复用户的“今日长度”。
- :param bot: Bot 实例
- :param event: 消息事件对象
- :param args: 指令参数列表
+ :param bot: Bot 实例。
+ :param event: 消息事件对象。
+ :param args: 指令参数列表(未使用)。
"""
user_id = event.user_id
jrcd = get_jrcd(user_id)
@@ -73,11 +84,11 @@ async def handle_jrcd(bot: Bot, event: MessageEvent, args: list[str]):
@matcher.command("bbcd")
async def handle_bbcd(bot: Bot, event: MessageEvent, args: list[str]):
"""
- 处理 bbcd 指令,和别人比比长度吧!
+ 处理 bbcd 指令,比较两位用户的“长度”。
- :param bot: Bot 实例
- :param event: 消息事件对象
- :param args: 指令参数列表
+ :param bot: Bot 实例。
+ :param event: 消息事件对象。
+ :param args: 指令参数列表(未使用)。
"""
message = event.message
print(message)
diff --git a/plugins/thpic.py b/plugins/thpic.py
index caa752d..a586533 100644
--- a/plugins/thpic.py
+++ b/plugins/thpic.py
@@ -12,4 +12,11 @@ from models import MessageEvent, MessageSegment
@matcher.command("thpic")
async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
+ """
+ 处理 thpic 指令,发送一张随机的东方Project图片。
+
+ :param bot: Bot 实例(未使用)。
+ :param event: 消息事件对象。
+ :param args: 指令参数列表(未使用)。
+ """
await event.reply(MessageSegment.image("https://img.paulzzh.com/touhou/random"))
diff --git a/requirements.txt b/requirements.txt
index 5fff61d..a0a58de 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,3 +11,4 @@ urllib3==2.6.2
websockets==15.0.1
yarg==0.1.10
watchdog==6.0.0
+redis==5.0.7
From 093a47ea5046ad80a1743624428e8d62f60c81b0 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Fri, 2 Jan 2026 17:23:13 +0800
Subject: [PATCH 04/46] =?UTF-8?q?=E6=9B=B4=E6=8D=A2print=E5=88=B0logger?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
core/logger.py | 50 ++++++++++++++++++++++++++++++++++++++++++
core/plugin_manager.py | 7 +++---
core/redis_manager.py | 11 +++++-----
core/ws.py | 31 ++++++++++++++------------
main.py | 17 ++++++++------
requirements.txt | 1 +
6 files changed, 88 insertions(+), 29 deletions(-)
create mode 100644 core/logger.py
diff --git a/core/logger.py b/core/logger.py
new file mode 100644
index 0000000..76ec223
--- /dev/null
+++ b/core/logger.py
@@ -0,0 +1,50 @@
+"""
+日志模块
+
+该模块负责初始化和配置 loguru 日志记录器,为整个应用程序提供统一的日志记录接口。
+"""
+import sys
+from pathlib import Path
+from loguru import logger
+
+# 定义日志格式
+LOG_FORMAT = (
+ "{time:YYYY-MM-DD HH:mm:ss.SSS} | "
+ "{level: <8} | "
+ "{name}:{function}:{line} - "
+ "{message}"
+)
+
+# 移除 loguru 默认的处理器
+logger.remove()
+
+# 添加控制台输出处理器
+logger.add(
+ sys.stderr,
+ level="INFO",
+ format=LOG_FORMAT,
+ colorize=True,
+ enqueue=True # 异步写入
+)
+
+# 定义日志文件路径
+log_dir = Path("logs")
+log_dir.mkdir(exist_ok=True)
+log_file_path = log_dir / "{time:YYYY-MM-DD}.log"
+
+# 添加文件输出处理器
+logger.add(
+ log_file_path,
+ level="DEBUG",
+ format=LOG_FORMAT,
+ colorize=False,
+ rotation="00:00", # 每天午夜创建新文件
+ retention="7 days", # 保留最近 7 天的日志
+ encoding="utf-8",
+ enqueue=True, # 异步写入
+ backtrace=True, # 记录完整的异常堆栈
+ diagnose=True # 添加异常诊断信息
+)
+
+# 导出配置好的 logger
+__all__ = ["logger"]
diff --git a/core/plugin_manager.py b/core/plugin_manager.py
index df15828..aa16f52 100644
--- a/core/plugin_manager.py
+++ b/core/plugin_manager.py
@@ -9,6 +9,7 @@ import pkgutil
import sys
from core.command_manager import matcher
+from .logger import logger
def load_all_plugins():
@@ -24,7 +25,7 @@ def load_all_plugins():
plugin_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "plugins")
package_name = "plugins"
- print(f" 正在从 {package_name} 加载插件...")
+ logger.info(f"正在从 {package_name} 加载插件...")
for loader, module_name, is_pkg in pkgutil.iter_modules([plugin_dir]):
full_module_name = f"{package_name}.{module_name}"
@@ -43,6 +44,6 @@ def load_all_plugins():
matcher.plugins[full_module_name] = meta
type_str = "包" if is_pkg else "文件"
- print(f" [{type_str}] 成功{action}: {module_name}")
+ logger.success(f" [{type_str}] 成功{action}: {module_name}")
except Exception as e:
- print(f" {action if 'action' in locals() else '加载'}插件 {module_name} 失败: {e}")
+ logger.error(f" {action if 'action' in locals() else '加载'}插件 {module_name} 失败: {e}")
diff --git a/core/redis_manager.py b/core/redis_manager.py
index 2b232d2..3786d9c 100644
--- a/core/redis_manager.py
+++ b/core/redis_manager.py
@@ -1,5 +1,6 @@
import redis
from .config_loader import global_config as config
+from .logger import logger
class RedisManager:
"""
@@ -20,7 +21,7 @@ class RedisManager:
db = config.redis['db']
password = config.redis.get('password')
- print(f" 正在尝试连接 Redis: {host}:{port}, DB: {db}")
+ logger.info(f"正在尝试连接 Redis: {host}:{port}, DB: {db}")
cls._pool = redis.ConnectionPool(
host=host,
@@ -31,15 +32,15 @@ class RedisManager:
)
cls._client = redis.Redis(connection_pool=cls._pool)
if cls._client.ping():
- print(" Redis 连接成功!")
+ logger.success("Redis 连接成功!")
else:
- print(" Redis 连接失败: PING 命令无响应")
+ logger.error("Redis 连接失败: PING 命令无响应")
except redis.exceptions.ConnectionError as e:
- print(f" Redis 连接失败: {e}")
+ logger.error(f"Redis 连接失败: {e}")
cls._pool = None
cls._client = None
except Exception as e:
- print(f" Redis 初始化时发生未知错误: {e}")
+ logger.exception(f"Redis 初始化时发生未知错误: {e}")
cls._pool = None
cls._client = None
diff --git a/core/ws.py b/core/ws.py
index e35317f..c9d2b77 100644
--- a/core/ws.py
+++ b/core/ws.py
@@ -24,6 +24,7 @@ from models import EventFactory
from .bot import Bot
from .command_manager import matcher
from .config_loader import global_config
+from .logger import logger
class WS:
@@ -58,24 +59,23 @@ class WS:
while True:
try:
- print(f" 正在尝试连接至 NapCat: {self.url}")
+ logger.info(f"正在尝试连接至 NapCat: {self.url}")
async with websockets.connect(
self.url, additional_headers=headers
) as websocket:
self.ws = websocket
- print(" 连接成功!")
+ logger.success("连接成功!")
await self._listen_loop(websocket)
except (
websockets.exceptions.ConnectionClosed,
ConnectionRefusedError,
) as e:
- print(f" 连接断开或服务器拒绝访问: {e}")
+ logger.warning(f"连接断开或服务器拒绝访问: {e}")
except Exception as e:
- print(f" 运行异常: {e}")
- traceback.print_exc()
+ logger.exception(f"运行异常: {e}")
- print(f" {self.reconnect_interval}秒后尝试重连...")
+ logger.info(f"{self.reconnect_interval}秒后尝试重连...")
await asyncio.sleep(self.reconnect_interval)
async def _listen_loop(self, websocket):
@@ -108,8 +108,7 @@ class WS:
asyncio.create_task(self.on_event(data))
except Exception as e:
- print(f" 解析消息异常: {e}")
- traceback.print_exc()
+ logger.exception(f"解析消息异常: {e}")
async def on_event(self, raw_data: dict):
"""
@@ -130,21 +129,22 @@ class WS:
event.bot = self.bot # 注入 Bot 实例
# 打印日志
- t = datetime.fromtimestamp(event.time).strftime("%H:%M:%S")
if event.post_type == "message":
sender_name = event.sender.nickname if event.sender else "Unknown"
- print(f" [{t}] [消息] {event.message_type} | {event.user_id}({sender_name}): {event.raw_message}")
+ logger.info(f"[消息] {event.message_type} | {event.user_id}({sender_name}): {event.raw_message}")
elif event.post_type == "notice":
- print(f" [{t}] [通知] {event.notice_type}")
+ logger.info(f"[通知] {event.notice_type}")
elif event.post_type == "request":
- print(f" [{t}] [请求] {event.request_type}")
+ logger.info(f"[请求] {event.request_type}")
+ elif event.post_type == "meta_event":
+ logger.debug(f"[元事件] {event.meta_event_type}")
+
# 分发事件
await matcher.handle_event(self.bot, event)
except Exception as e:
- print(f" 事件处理异常: {e}")
- traceback.print_exc()
+ logger.exception(f"事件处理异常: {e}")
async def call_api(self, action: str, params: dict = None):
"""
@@ -162,11 +162,13 @@ class WS:
表示失败的字典。
"""
if not self.ws:
+ logger.error("调用 API 失败: WebSocket 未初始化")
return {"status": "failed", "msg": "websocket not initialized"}
from websockets.protocol import State
if getattr(self.ws, "state", None) is not State.OPEN:
+ logger.error("调用 API 失败: WebSocket 连接未打开")
return {"status": "failed", "msg": "websocket is not open"}
echo_id = str(uuid.uuid4())
@@ -182,5 +184,6 @@ class WS:
return await asyncio.wait_for(future, timeout=30.0)
except asyncio.TimeoutError:
self._pending_requests.pop(echo_id, None)
+ logger.warning(f"API 调用超时: action={action}, params={params}")
return {"status": "failed", "retcode": -1, "msg": "api timeout"}
diff --git a/main.py b/main.py
index 50b87cc..1d9d6cc 100644
--- a/main.py
+++ b/main.py
@@ -10,9 +10,11 @@ import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
+# 初始化日志系统,必须在其他 core 模块导入之前执行
+from core.logger import logger
+
from core import WS
from core.plugin_manager import load_all_plugins
-#from core.redis_manager import redis_client
class PluginReloadHandler(FileSystemEventHandler):
@@ -55,17 +57,18 @@ class PluginReloadHandler(FileSystemEventHandler):
self.last_reload_time = current_time
- print(f"\n[HotReload] 检测到文件变更: {event.src_path}")
- print("[HotReload] 正在重载插件...")
+ logger.info(f"检测到文件变更: {event.src_path}")
+ logger.info("正在重载插件...")
try:
# 重新扫描并加载插件
load_all_plugins()
- print("[HotReload] 插件重载完成")
+ logger.success("插件重载完成")
except Exception as e:
- print(f"[HotReload] 重载失败: {e}")
+ logger.exception(f"重载失败: {e}")
+@logger.catch
async def main():
"""
主函数
@@ -87,9 +90,9 @@ async def main():
if os.path.exists(plugin_path):
observer.schedule(event_handler, plugin_path, recursive=True)
observer.start()
- print(f"[HotReload] 已启动插件热重载监控: {plugin_path}")
+ logger.info(f"已启动插件热重载监控: {plugin_path}")
else:
- print(f"[HotReload] 警告: 插件目录不存在 {plugin_path}")
+ logger.warning(f"插件目录不存在 {plugin_path}")
try:
bot = WS()
diff --git a/requirements.txt b/requirements.txt
index a0a58de..f630282 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -12,3 +12,4 @@ websockets==15.0.1
yarg==0.1.10
watchdog==6.0.0
redis==5.0.7
+loguru
From 7259524b1277ad70d23f65700a35dca5d48a8509 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=95=80=E9=93=AC=E9=85=B8=E9=92=BE?=
<148796996+K2cr2O1@users.noreply.github.com>
Date: Fri, 2 Jan 2026 17:48:44 +0800
Subject: [PATCH 05/46] Update __init__.py
---
core/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/core/__init__.py b/core/__init__.py
index 7ff7c22..072049f 100644
--- a/core/__init__.py
+++ b/core/__init__.py
@@ -1,5 +1,5 @@
from .command_manager import matcher
from .config_loader import global_config
-from .WS import WS
+from .ws import WS
__all__ = ["WS", "matcher", "global_config", "PluginDataManager"]
From 617823da11d92e8f855555c4d7f31990b69680f6 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Fri, 2 Jan 2026 18:48:35 +0800
Subject: [PATCH 06/46] hotfix
---
config.toml | 1 +
core/command_manager.py | 7 +++++++
2 files changed, 8 insertions(+)
diff --git a/config.toml b/config.toml
index 1c9df7e..4846aeb 100644
--- a/config.toml
+++ b/config.toml
@@ -5,6 +5,7 @@ reconnect_interval = 5
[bot]
command = ["/"]
+ignore_self_message = true #是否忽略自身消息
[redis]
host = "114.66.58.203"
diff --git a/core/command_manager.py b/core/command_manager.py
index 5f29ff2..f95053a 100644
--- a/core/command_manager.py
+++ b/core/command_manager.py
@@ -146,6 +146,13 @@ class CommandManager:
bot: Bot 实例。
event: 已解析的事件对象。
"""
+ # --- 全局过滤机器人自身消息 ---
+ # 仅对消息事件生效
+ if event.post_type == 'message' and global_config.bot.get('ignore_self_message', False):
+
+ if hasattr(event, 'user_id') and hasattr(event, 'self_id') and event.user_id == event.self_id:
+ return
+
post_type = event.post_type
if post_type == 'message':
From 9a494fc870b700c9b94c18eb8c57fbf5d98c6ef6 Mon Sep 17 00:00:00 2001
From: baby20162016 <2185823427@qq.com>
Date: Fri, 2 Jan 2026 19:11:28 +0800
Subject: [PATCH 07/46] =?UTF-8?q?=E4=BF=AE=E5=A4=8D/jrcd=20@=E5=85=A8?=
=?UTF-8?q?=E4=BD=93=E6=88=90=E5=91=98=20=E5=91=BD=E4=BB=A4=E4=BC=9A?=
=?UTF-8?q?=E5=AF=BC=E8=87=B4=E6=8A=A5=E9=94=99=E7=9A=84BUG?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
plugins/jrcd.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/plugins/jrcd.py b/plugins/jrcd.py
index ee82e7f..d4df757 100644
--- a/plugins/jrcd.py
+++ b/plugins/jrcd.py
@@ -3,6 +3,7 @@
提供 /jrcd 和 /bbcd 指令,用于娱乐。
"""
+
import random
from datetime import datetime
@@ -100,8 +101,13 @@ async def handle_bbcd(bot: Bot, event: MessageEvent, args: list[str]):
print(message)
if len(message) < 2:
return
+
user_id1 = event.user_id
- user_id2 = int(message[1].data.get("qq", 0))
+ try:
+ user_id2 = int(message[1].data.get("qq", 0))
+ except Exception:
+ return
+
if user_id1 == user_id2:
await event.reply("不能和自己比!")
return
From 1bcad91984f658983bb29fff6b941b9abd7edb7b Mon Sep 17 00:00:00 2001
From: baby20162016 <2185823427@qq.com>
Date: Fri, 2 Jan 2026 19:41:11 +0800
Subject: [PATCH 08/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=8F=92=E4=BB=B6code?=
=?UTF-8?q?=5Fpy?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
core/__init__.py | 3 +-
plugins/code_py.py | 89 ++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 91 insertions(+), 1 deletion(-)
create mode 100644 plugins/code_py.py
diff --git a/core/__init__.py b/core/__init__.py
index 072049f..2717750 100644
--- a/core/__init__.py
+++ b/core/__init__.py
@@ -1,5 +1,6 @@
from .command_manager import matcher
from .config_loader import global_config
-from .ws import WS
+from .plugin_manager import PluginDataManager
+from .WS import WS
__all__ = ["WS", "matcher", "global_config", "PluginDataManager"]
diff --git a/plugins/code_py.py b/plugins/code_py.py
new file mode 100644
index 0000000..5af3e4c
--- /dev/null
+++ b/plugins/code_py.py
@@ -0,0 +1,89 @@
+"""
+code_py插件
+
+输入/code py回车再加上python代码,机器人就会执行代码并返回执行结果。
+"""
+
+import asyncio
+import os
+import sys
+import tempfile
+from typing import Tuple
+
+from core.bot import Bot
+from core.command_manager import matcher
+from models import MessageEvent
+
+__plugin_meta__ = {
+ "name": "code_py",
+ "description": "提供执行python代码的功能",
+ "usage": "/code py [python代码] - 执行python代码",
+}
+
+
+@matcher.command("code_py")
+async def execute_python_code(bot: Bot, event: MessageEvent, args: list[str]):
+ if not args:
+ await event.reply("请提供要执行的Python代码。用法:/code_py [python代码]")
+ return
+
+ code = " ".join(args)
+
+ async def run_code_in_subprocess(
+ code_str: str, timeout: float = 5.0
+ ) -> Tuple[str, str]:
+ # 这里用临时文件
+ with tempfile.NamedTemporaryFile(
+ "w", suffix=".py", delete=False, encoding="utf-8"
+ ) as tf:
+ tf.write(code_str)
+ tf_path = tf.name
+
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ sys.executable,
+ tf_path,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ try:
+ out_bytes, err_bytes = await asyncio.wait_for(
+ proc.communicate(), timeout=timeout
+ )
+ except asyncio.TimeoutError:
+ proc.kill()
+ await proc.communicate()
+ return "", f"执行超时(>{timeout}s)"
+
+ return out_bytes.decode(errors="ignore"), err_bytes.decode(errors="ignore")
+ finally:
+ try:
+ os.remove(tf_path)
+ except Exception:
+ pass
+
+ try:
+ stdout, stderr = await run_code_in_subprocess(code, timeout=5.0)
+ except Exception as e:
+ await event.reply(f"执行失败:{e}")
+ return
+
+ # 优先显示 stderr,如果 stderr 为空则显示 stdout
+ resp = stderr.strip() or stdout.strip() or "(无输出)"
+
+ # 限制返回长度,避免过长消息
+ MAX = 1500
+ if len(resp) > MAX:
+ resp = resp[:MAX] + "\n...输出被截断..."
+
+ nodes = [
+ bot.build_forward_node(user_id=event.self_id, nickname="机器人", message=code),
+ bot.build_forward_node(
+ user_id=event.self_id, nickname="机器人", message="执行结果:\n" + resp
+ ),
+ ]
+
+ try:
+ await bot.send_forwarded_messages(event, nodes)
+ except Exception as e:
+ await event.reply(f"发送失败: {e}")
From 3fcac59ef9c2a8d93e2e805a4036de5d3276bca2 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Fri, 2 Jan 2026 20:10:35 +0800
Subject: [PATCH 09/46] =?UTF-8?q?=E4=BC=98=E5=8C=96codepy=E6=8F=92?=
=?UTF-8?q?=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
core/__init__.py | 2 +-
core/command_manager.py | 63 +++++++++-----
main.py | 2 +-
plugins/code_py.py | 177 ++++++++++++++++++++++++++++------------
4 files changed, 170 insertions(+), 74 deletions(-)
diff --git a/core/__init__.py b/core/__init__.py
index 2717750..032d0c6 100644
--- a/core/__init__.py
+++ b/core/__init__.py
@@ -1,6 +1,6 @@
from .command_manager import matcher
from .config_loader import global_config
from .plugin_manager import PluginDataManager
-from .WS import WS
+from .ws import WS
__all__ = ["WS", "matcher", "global_config", "PluginDataManager"]
diff --git a/core/command_manager.py b/core/command_manager.py
index f95053a..ec875b2 100644
--- a/core/command_manager.py
+++ b/core/command_manager.py
@@ -36,13 +36,15 @@ class CommandManager:
Args:
prefixes (Tuple[str, ...]): 一个包含所有合法命令前缀的元组。
"""
+ # --- 初始化所有处理器列表 ---
self.prefixes = prefixes
- self.commands: Dict[str, Callable] = {} # 存储消息指令处理器
- self.notice_handlers: List[Dict] = [] # 存储通知事件处理器
- self.request_handlers: List[Dict] = [] # 存储请求事件处理器
- self.plugins: Dict[str, Dict[str, Any]] = {} # 存储已加载插件的元数据
+ self.commands: Dict[str, Callable] = {}
+ self.message_handlers: List[Callable] = []
+ self.notice_handlers: List[Dict] = []
+ self.request_handlers: List[Dict] = []
+ self.plugins: Dict[str, Dict[str, Any]] = {}
- # --- 注册内置 help 指令 ---
+ # --- 注册内置指令 ---
self.commands["help"] = self._help_command
self.plugins["core.help"] = {
"name": "帮助",
@@ -50,6 +52,26 @@ class CommandManager:
"usage": "/help",
}
+ def on_message(self) -> Callable:
+ """
+ 装饰器:用于注册一个通用的消息处理器。
+
+ 被此装饰器注册的函数,会在每次收到消息时(在指令匹配前)被调用。
+ 如果函数返回 True,则表示该消息已被“消费”,后续的指令匹配将不会进行。
+
+ Example:
+ @matcher.on_message()
+ async def code_input_handler(bot, event):
+ if is_waiting_for_code(event.user_id):
+ await process_code(event.raw_message)
+ return True # 消费事件
+ """
+ def decorator(func: Callable) -> Callable:
+ self.message_handlers.append(func)
+ return func
+ return decorator
+
+
async def _help_command(self, bot, event):
"""
内置的 `/help` 命令的实现。
@@ -164,21 +186,21 @@ class CommandManager:
async def handle_message(self, bot, event):
"""
- 处理消息事件,解析并分发指令。
-
- 该方法会检查消息是否以已配置的命令前缀开头,如果是,则解析出
- 指令名称和参数,并调用对应的处理器。
-
- Args:
- bot: Bot 实例。
- event: 消息事件对象。
+ 处理消息事件,优先执行通用处理器,然后解析并分发指令。
"""
+ # --- 1. 执行通用消息处理器 ---
+ for handler in self.message_handlers:
+ # 如果任何一个处理器返回 True,则中断后续处理
+ consumed = await self._run_handler(handler, bot, event)
+ if consumed:
+ return
+
+ # --- 2. 检查并执行指令 ---
if not event.raw_message:
return
raw_text = event.raw_message.strip()
- # 1. 检查前缀
prefix_found = None
for p in self.prefixes:
if raw_text.startswith(p):
@@ -188,7 +210,6 @@ class CommandManager:
if not prefix_found:
return
- # 2. 拆分指令和参数
full_cmd = raw_text[len(prefix_found) :].split()
if not full_cmd:
return
@@ -196,7 +217,6 @@ class CommandManager:
cmd_name = full_cmd[0]
args = full_cmd[1:]
- # 3. 查找并执行
if cmd_name in self.commands:
func = self.commands[cmd_name]
await self._run_handler(func, bot, event, args)
@@ -227,7 +247,7 @@ class CommandManager:
async def _run_handler(self, func: Callable, bot, event, args: List[str] = None):
"""
- 智能执行事件处理器。
+ 智能执行事件处理器,并返回事件是否被消费。
该方法会检查目标处理器的函数签名,并根据签名动态地传入所需的参数
(如 `bot`, `event`, `args`),实现了依赖注入。
@@ -237,7 +257,9 @@ class CommandManager:
bot: Bot 实例。
event: 事件对象。
args (List[str], optional): 指令参数列表(仅对消息事件有效)。
- Defaults to None.
+
+ Returns:
+ bool: 如果处理器函数返回 True,则返回 True,否则返回 False。
"""
sig = inspect.signature(func)
params = sig.parameters
@@ -250,8 +272,9 @@ class CommandManager:
if "args" in params and args is not None:
kwargs["args"] = args
- # 执行函数
- await func(**kwargs)
+ # 执行函数并获取返回值
+ result = await func(**kwargs)
+ return result is True
# --- 全局单例 ---
diff --git a/main.py b/main.py
index 1d9d6cc..d4c723c 100644
--- a/main.py
+++ b/main.py
@@ -13,7 +13,7 @@ from watchdog.events import FileSystemEventHandler
# 初始化日志系统,必须在其他 core 模块导入之前执行
from core.logger import logger
-from core import WS
+from core.ws import WS
from core.plugin_manager import load_all_plugins
diff --git a/plugins/code_py.py b/plugins/code_py.py
index 5af3e4c..cb53f2b 100644
--- a/plugins/code_py.py
+++ b/plugins/code_py.py
@@ -5,10 +5,11 @@ code_py插件
"""
import asyncio
-import os
+import re
import sys
import tempfile
-from typing import Tuple
+import os
+from typing import Tuple, Set
from core.bot import Bot
from core.command_manager import matcher
@@ -17,73 +18,145 @@ from models import MessageEvent
__plugin_meta__ = {
"name": "code_py",
"description": "提供执行python代码的功能",
- "usage": "/code py [python代码] - 执行python代码",
+ "usage": "/code_py - 进入交互模式,等待输入代码块\n/code_py [单行代码] - 快速执行单行代码",
}
+# --- 安全配置:危险模块黑名单 ---
+DANGEROUS_MODULES = [
+ "os", "sys", "subprocess", "shutil", "socket", "requests", "urllib",
+ "http", "ftplib", "telnetlib", "ctypes", "_thread", "multiprocessing",
+ "asyncio",
+]
-@matcher.command("code_py")
-async def execute_python_code(bot: Bot, event: MessageEvent, args: list[str]):
- if not args:
- await event.reply("请提供要执行的Python代码。用法:/code_py [python代码]")
+# 编译后的正则表达式,用于分割语句
+STATEMENT_SPLIT_PATTERN = re.compile(r'[;\n]')
+
+def is_code_safe(code: str) -> Tuple[bool, str]:
+ """
+ 检查代码中是否包含危险的模块导入。
+ """
+ statements = STATEMENT_SPLIT_PATTERN.split(code)
+ for statement in statements:
+ statement = statement.strip()
+ if not statement: continue
+ parts = statement.split()
+ if not parts: continue
+ if parts[0] == 'from' and len(parts) > 1:
+ module_name = parts[1].strip()
+ if module_name in DANGEROUS_MODULES:
+ return False, f"检测到不允许的模块导入:'{module_name}'"
+ elif parts[0] == 'import' and len(parts) > 1:
+ modules_str = ' '.join(parts[1:])
+ imported_modules = [m.strip() for m in modules_str.split(',')]
+ for module_name in imported_modules:
+ actual_module_name = module_name.split()[0]
+ if actual_module_name in DANGEROUS_MODULES:
+ return False, f"检测到不允许的模块导入:'{actual_module_name}'"
+ return True, ""
+
+async def run_code_in_subprocess(code_str: str, timeout: float = 10.0) -> Tuple[str, str]:
+ """
+ 在子进程中安全地执行Python代码。
+ """
+ with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False, encoding="utf-8") as tf:
+ tf.write(code_str)
+ tf_path = tf.name
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ sys.executable, tf_path,
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
+ )
+ try:
+ out_bytes, err_bytes = await asyncio.wait_for(proc.communicate(), timeout=timeout)
+ except asyncio.TimeoutError:
+ proc.kill()
+ await proc.communicate()
+ return "", f"执行超时(>{timeout}s)"
+ return out_bytes.decode(errors="ignore"), err_bytes.decode(errors="ignore")
+ finally:
+ try:
+ os.remove(tf_path)
+ except Exception:
+ pass
+
+async def process_and_reply(bot: Bot, event: MessageEvent, code: str):
+ """
+ 核心处理逻辑:安全检查、执行代码并回复结果。
+ """
+ safe, message = is_code_safe(code)
+ if not safe:
+ await event.reply(f"代码安全检查未通过:\n{message}")
return
- code = " ".join(args)
-
- async def run_code_in_subprocess(
- code_str: str, timeout: float = 5.0
- ) -> Tuple[str, str]:
- # 这里用临时文件
- with tempfile.NamedTemporaryFile(
- "w", suffix=".py", delete=False, encoding="utf-8"
- ) as tf:
- tf.write(code_str)
- tf_path = tf.name
-
- try:
- proc = await asyncio.create_subprocess_exec(
- sys.executable,
- tf_path,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- try:
- out_bytes, err_bytes = await asyncio.wait_for(
- proc.communicate(), timeout=timeout
- )
- except asyncio.TimeoutError:
- proc.kill()
- await proc.communicate()
- return "", f"执行超时(>{timeout}s)"
-
- return out_bytes.decode(errors="ignore"), err_bytes.decode(errors="ignore")
- finally:
- try:
- os.remove(tf_path)
- except Exception:
- pass
-
try:
- stdout, stderr = await run_code_in_subprocess(code, timeout=5.0)
+ stdout, stderr = await run_code_in_subprocess(code, timeout=10.0)
except Exception as e:
await event.reply(f"执行失败:{e}")
return
- # 优先显示 stderr,如果 stderr 为空则显示 stdout
resp = stderr.strip() or stdout.strip() or "(无输出)"
-
- # 限制返回长度,避免过长消息
MAX = 1500
if len(resp) > MAX:
resp = resp[:MAX] + "\n...输出被截断..."
nodes = [
- bot.build_forward_node(user_id=event.self_id, nickname="机器人", message=code),
- bot.build_forward_node(
- user_id=event.self_id, nickname="机器人", message="执行结果:\n" + resp
- ),
+ bot.build_forward_node(user_id=event.self_id, nickname="输入代码", message=code),
+ bot.build_forward_node(user_id=event.self_id, nickname="执行结果", message=resp),
]
-
try:
await bot.send_forwarded_messages(event, nodes)
except Exception as e:
- await event.reply(f"发送失败: {e}")
+ await event.reply(f"结果发送失败: {e}\n\n{resp}")
+
+# --- 交互式会话状态 ---
+# 使用集合存储正在等待代码输入的用户标识
+waiting_users: Set[str] = set()
+
+def get_session_id(event: MessageEvent) -> str:
+ """根据事件类型生成唯一的会话ID"""
+ if hasattr(event, 'group_id'):
+ # 群聊会话ID
+ return f"group_{event.group_id}-{event.user_id}"
+ else:
+ # 私聊会话ID
+ return f"private_{event.user_id}"
+
+@matcher.command("code_py")
+async def handle_code_command(bot: Bot, event: MessageEvent, args: list[str]):
+ # 模式一:快速执行单行代码
+ if args:
+ code = " ".join(args)
+ await process_and_reply(bot, event, code)
+ return
+
+ # 模式二:进入交互模式
+ session_id = get_session_id(event)
+ if session_id in waiting_users:
+ await event.reply("您已经有一个正在等待输入的code会话了,请直接发送代码。")
+ return
+
+ waiting_users.add(session_id)
+ await event.reply("请在下一条消息中发送要执行的Python代码块。(发送“取消”可退出)")
+
+@matcher.on_message()
+async def handle_code_input(bot: Bot, event: MessageEvent):
+ session_id = get_session_id(event)
+
+ # 检查用户是否处于等待状态
+ if session_id in waiting_users:
+ # 从等待集合中移除,无论输入是什么
+ waiting_users.remove(session_id)
+
+ # 处理取消操作
+ if event.raw_message.strip() == "取消":
+ await event.reply("已取消输入。")
+ return True # 消费事件
+
+ # 执行代码
+ await process_and_reply(bot, event, event.raw_message)
+ return True # 消费事件,防止被其他指令匹配
+
+ # 如果用户不在等待状态,则不处理
+ return False
+
+
From bbdeecb89b9b65de56f9df06accbb87d09a2abe9 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Sun, 4 Jan 2026 19:38:47 +0800
Subject: [PATCH 10/46] =?UTF-8?q?feat:=20=E6=95=B4=E5=90=88=E5=BC=80?=
=?UTF-8?q?=E5=8F=91=E5=8E=86=E5=8F=B2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 2 +-
README.md | 680 ++++++++++++++++++++++++------
core/admin_manager.py | 166 ++++++++
core/api/account.py | 2 +-
core/api/friend.py | 10 +-
core/api/group.py | 10 +-
core/command_manager.py | 293 ++++---------
core/event_handler.py | 197 +++++++++
core/exceptions.py | 9 +
core/executor.py | 27 ++
core/permission_manager.py | 252 +++++++++++
core/plugin_manager.py | 32 +-
core/redis_manager.py | 51 ++-
data/admin.json | 3 +
data/permissions.json | 3 +
html/404.html | 288 +++++++++++++
html/index.html | 387 +++++++++++++++++
main.py | 13 +-
models/events/message.py | 6 +
plugins/admin.py | 147 +++----
plugins/code_py.py | 13 +-
plugins/data/admin.json | 1 -
plugins/echo.py | 18 +-
plugins/jrcd.py | 7 +-
plugins/sync_async_test_plugin.py | 88 ++++
25 files changed, 2199 insertions(+), 506 deletions(-)
create mode 100644 core/admin_manager.py
create mode 100644 core/event_handler.py
create mode 100644 core/exceptions.py
create mode 100644 core/executor.py
create mode 100644 core/permission_manager.py
create mode 100644 data/admin.json
create mode 100644 data/permissions.json
create mode 100644 html/404.html
create mode 100644 html/index.html
delete mode 100644 plugins/data/admin.json
create mode 100644 plugins/sync_async_test_plugin.py
diff --git a/.gitignore b/.gitignore
index 093255f..2729cb2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -138,4 +138,4 @@ dmypy.json
# pytype static type analyzer
.pytype/
-# End of https://www.toptal.com/developers/gitignore/api/python
\ No newline at end of file
+# End of https://www.toptal.com/developers/gitignore/api/python
diff --git a/README.md b/README.md
index f8ee6ff..99b7656 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ NEO 框架的设计遵循以下核心理念:
* **类型安全**:基于 `dataclasses` 的强类型事件模型,开发体验更佳。
* **插件系统**:轻量级的装饰器风格插件系统,支持指令 (`@matcher.command`) 和事件监听 (`@matcher.on_notice`, `@matcher.on_request`)。
* **插件元数据与内置帮助**:插件可通过 `__plugin_meta__` 变量进行自我描述。框架核心内置了 `/help` 指令,可自动收集并展示所有插件的帮助信息,无需手动维护。
-* **🔥 热重载支持**:内置文件监控,修改 `base_plugins` 下的代码自动重载,无需重启,极大提升调试效率。
+* **🔥 热重载支持**:内置文件监控,修改 `plugins` 下的代码自动重载,无需重启,极大提升调试效率。
* **异步核心**:基于 `asyncio` 和 `websockets` 的高性能异步核心。
* **自动重连**:内置 WebSocket 断线重连机制。
@@ -131,43 +131,112 @@ latest_group_info = await bot.get_group_info(group_id=12345, no_cache=True)
### 其他改进
- [x] **API 强类型封装**: 将 API 返回值从 `dict` 转换为数据模型对象。
- [x] **Redis 支持**: 集成 Redis 连接池,便于插件复用连接。
-- [ ] **日志系统优化**: 引入更完善的日志记录机制,支持文件输出和日志级别控制。
-- [ ] **异常处理增强**: 增强插件执行过程中的异常捕获,防止单个插件崩溃影响整个 Bot。
-- [ ] **中间件支持**: 添加消息处理中间件,支持在指令执行前/后进行拦截和处理。
-- [ ] **权限系统**: 实现基础的权限管理(如超级管理员、群管理员等)。
+- [x] **权限系统**: 实现基础的权限管理(超级管理员、群管理员等)。
+- [x] **日志系统优化**: 引入 `loguru` 进行日志记录,支持文件输出和日志级别控制。
+- [x] **异常处理增强**: 增强插件执行过程中的异常捕获,防止单个插件崩溃影响整个 Bot。
+- [x] **中间件支持**: 添加消息处理中间件,支持在指令执行前/后进行拦截和处理。
## 📂 项目结构
```
-NEO/
-├── plugins/ # 插件目录,新建插件文件即可自动加载(支持热重载)
-│ ├── echo.py # 示例插件:实现 /echo 和 /赞我 指令
-│ ├── forward_test.py # 示例插件:演示合并转发消息的构建和发送
-│ ├── jrcd.py # 娱乐插件:提供 /jrcd 和 /bbcd 指令
-│ └── thpic.py # 图片插件:提供 /thpic 指令,发送随机东方图片
-├── core/ # 核心框架代码
-│ ├── api/ # API 模块抽象层 (MessageAPI, GroupAPI, FriendAPI, AccountAPI)
-│ │ ├── __init__.py
-│ │ ├── account.py
-│ │ ├── base.py
-│ │ ├── friend.py
-│ │ ├── group.py
-│ │ └── message.py
-│ ├── bot.py # Bot API 封装,提供 send_group_msg 等方法
-│ ├── command_manager.py # 命令与事件分发器
-│ ├── config_loader.py # 配置加载器
-│ ├── plugin_manager.py # 插件加载与管理
-│ ├── redis_manager.py # Redis 连接管理器
-│ └── ws.py # WebSocket 客户端核心
-├── models/ # 数据模型
-│ ├── events/ # OneBot 事件定义 (Message, Notice, Request, Meta)
-│ ├── message.py # 消息段定义 (MessageSegment)
-│ └── sender.py # 发送者定义 (Sender)
-├── config.toml # 配置文件
-├── main.py # 启动入口(包含热重载监控)
-└── requirements.txt # 项目依赖
+.
+├── plugins/ # 插件目录,新建插件文件即可自动加载(支持热重载)
+│ ├── admin.py # 管理员插件
+│ ├── code_py.py # Python 代码执行插件
+│ ├── echo.py # 示例插件:实现 /echo 和 /赞我 指令
+│ ├── forward_test.py # 示例插件:演示合并转发消息
+│ ├── jrcd.py # 娱乐插件:/jrcd 和 /bbcd
+│ └── thpic.py # 图片插件:/thpic
+├── core/ # 核心框架代码
+│ ├── api/ # API 模块抽象层
+│ ├── bot.py # Bot 实例与 API 封装
+│ ├── admin_manager.py # 管理员管理模块
+│ ├── command_manager.py # 命令与事件分发器
+│ ├── config_loader.py # 配置加载器
+│ ├── event_handler.py # 事件处理器
+│ ├── executor.py # 插件执行器
+│ ├── logger.py # 日志系统
+│ ├── permission_manager.py # 权限管理器
+│ ├── plugin_manager.py # 插件加载与管理
+│ ├── redis_manager.py # Redis 连接管理器
+│ └── ws.py # WebSocket 客户端核心
+├── data/ # 数据存储目录
+│ ├── admin.json # 管理员配置文件
+│ └── permissions.json # 权限数据
+├── html/ # HTML 静态文件
+│ ├── 404.html
+│ └── index.html
+├── models/ # 数据模型
+│ ├── events/ # OneBot 事件定义
+│ ├── message.py # 消息段定义
+│ ├── objects.py # API 返回对象定义
+│ └── sender.py # 发送者定义
+├── .gitignore
+├── config.toml # 配置文件
+├── main.py # 启动入口(包含热重载监控)
+└── requirements.txt # 项目依赖
```
+### 目录结构详细说明
+
+#### `plugins/` - 插件目录
+- **功能**存放:所有机器人插件,支持热重载机制
+- **加载机制**:框架会自动扫描此目录下的所有 `.py` 文件,并作为插件加载
+- **插件约定**:每个插件文件应包含 `__plugin_meta__` 字典用于插件元数据定义
+- **热重载**:开发过程中修改插件文件会自动触发重载,无需重启机器人
+- **内置插件**:
+ - `admin.py` - 管理员管理插件,支持动态添加/移除管理员
+ - `code_py.py` - Python 代码执行插件,支持安全的代码执行环境
+ - `echo.py` - 示例插件,演示基本指令处理
+ - `forward_test.py` - 合并转发消息演示插件
+ - `jrcd.py` - 娱乐插件,提供 `/jrcd` 和 `/bbcd` 指令
+ - `thpic.py` - 图片插件,提供 `/thpic` 指令返回东方Project图片
+
+#### `core/` - 核心框架代码
+- `api/` - API 模块抽象层
+ - `base.py` - API 基类定义
+ - `message.py` - 消息相关 API 封装
+ - `group.py` - 群组管理 API 封装
+ - `friend.py` - 好友相关 API 封装
+ - `account.py` - 账号相关 API 封装
+- `bot.py` - Bot 核心类,通过 Mixin 模式继承所有 API 功能,提供统一的调用接口
+ - `admin_manager.py` - 管理员管理模块,负责管理员的添加、移除和权限验证
+ - `command_manager.py` - 命令与事件分发器,负责注册和处理所有指令和事件
+- `config_loader.py` - 配置加载器,读取和解析 `config.toml` 配置文件
+- `event_handler.py` - 事件处理器,负责将原始事件转换为类型化事件对象
+- `executor.py` - 插件执行器,提供线程池执行环境用于执行同步任务
+- `logger.py` - 日志系统,基于 `loguru` 提供高性能日志记录
+- `permission_manager.py` - 权限管理器,管理用户权限级别(admin、op、user)
+- `plugin_manager.py` - 插件加载与管理,负责插件的扫描、加载和热重载
+- `redis_manager.py` - Redis 连接管理器,提供异步 Redis 客户端连接池
+- `ws.py` - WebSocket 客户端核心,负责与 OneBot 实现端建立和管理连接
+
+#### `data/` - 数据存储目录
+- `admin.json` - 管理员配置文件,存储全局管理员列表
+- `permissions.json` - 权限数据文件,存储用户权限映射关系
+
+#### `html/` - HTML 静态文件
+- `404.html` - 404 错误页面
+- `index.html` - 项目主页 HTML 文件,展示项目信息和特性
+
+#### `models/` - 数据模型定义
+- `events/` - OneBot 事件定义
+ - `base.py` - 事件基类定义
+ - `message.py` - 消息事件定义
+ - `notice.py` - 通知事件定义
+ - `request.py` - 请求事件定义
+ - `meta.py` - 元事件定义
+ - `factory.py` - 事件工厂类,用于根据 JSON 数据创建对应事件对象
+- `message.py` - 消息段定义,支持文本、图片、表情等多种消息类型
+- `objects.py` - API 返回对象定义,提供强类型化的 API 响应数据模型
+- `sender.py` - 发送者定义,包含用户、群成员等信息
+
+#### 根目录文件
+- `.gitignore` - Git 忽略文件配置
+- `config.toml` - 主配置文件,包含 WebSocket 连接、机器人指令前缀、Redis 连接等配置
+- `main.py` - 程序入口文件,负责初始化插件、启动热重载监控和建立 WebSocket 连接
+- `requirements.txt` - Python 依赖包列表
+
## 🚀 快速开始
### 1. 环境准备
@@ -207,13 +276,13 @@ python main.py
项目集成了 `watchdog` 文件监控。在开发过程中,你只需要:
1. 保持 `main.py` 运行。
-2. 修改或新建 `base_plugins` 目录下的 `.py` 插件文件。
+2. 修改或新建 `plugins` 目录下的 `.py` 插件文件。
3. 保存文件。
4. 控制台会自动提示 `[HotReload] 插件重载完成`,新的逻辑立即生效。
### 创建新插件
-在 `base_plugins` 目录下创建一个新的 `.py` 文件(例如 `my_plugin.py`),框架会自动加载它。
+在 `plugins` 目录下创建一个新的 `.py` 文件(例如 `my_plugin.py`),框架会自动加载它。
### 示例代码
@@ -333,7 +402,7 @@ async def get_group_info_legacy(bot: Bot, event: MessageEvent, args: list[str]):
**示例:**
```python
-# base_plugins/echo.py
+# plugins/echo.py
__plugin_meta__ = {
"name": "回声与交互",
@@ -407,10 +476,184 @@ async def dangerous_command(bot: Bot, event: MessageEvent, args: list[str]):
except Exception as e:
await event.reply(f"执行失败:{str(e)}")
# 记录日志
- import logging
- logging.error(f"插件执行错误:{e}", exc_info=True)
+ from core.logger import logger
+ logger.error(f"插件执行错误:{e}", exc_info=True)
```
+### 处理同步阻塞操作
+为了保持机器人的响应性,所有可能导致长时间阻塞的同步操作都应该在单独的线程池中执行。框架提供了 `run_in_thread_pool` 函数来简化这一过程。
+
+**示例:执行同步阻塞任务**
+```python
+from core.command_manager import matcher
+from core.bot import Bot
+from models import MessageEvent
+from core.executor import run_in_thread_pool
+import time
+
+# 模拟一个耗时的同步操作
+def blocking_task(duration: int):
+ time.sleep(duration)
+ return f"阻塞任务完成,耗时 {duration} 秒"
+
+@matcher.command("block_test")
+async def handle_blocking_test(bot: Bot, event: MessageEvent, args: list[str]):
+ if not args or not args[0].isdigit():
+ await event.reply("请提供一个数字作为阻塞时间(秒)。例如:/block_test 5")
+ return
+
+ duration = int(args[0])
+ await event.reply(f"开始执行阻塞任务,耗时 {duration} 秒...")
+
+ # 将同步阻塞任务放入线程池执行
+ result = await run_in_thread_pool(blocking_task, duration)
+ await event.reply(result)
+```
+
+### 权限管理
+框架内置了基于用户角色的权限管理系统,支持 `admin`(超级管理员)、`op`(操作员)、`user`(普通用户)三个权限级别。权限数据存储在 `data/permissions.json` 文件中。
+
+#### 权限级别说明
+- **admin**:最高权限,可以执行所有管理命令,包括添加/移除其他管理员
+- **op**:操作员权限,可以执行大部分管理命令,但不能修改管理员列表
+- **user**:普通用户权限,只能使用基础功能
+
+#### 在插件中使用权限控制
+注册命令时可以通过 `permission` 参数指定所需权限级别:
+
+```python
+from models import MessageEvent
+
+# 只有管理员可以执行此命令
+@matcher.command("admin_only", permission=MessageEvent.ADMIN)
+async def admin_command(bot: Bot, event: MessageEvent, args: list[str]):
+ await event.reply("此命令仅限管理员使用")
+
+# 操作员及以上权限可以执行
+@matcher.command("op_only", permission=MessageEvent.OP)
+async def op_command(bot: Bot, event: MessageEvent, args: list[str]):
+ await event.reply("此命令需要操作员权限")
+
+# 所有用户都可以执行(默认)
+@matcher.command("public")
+async def public_command(bot: Bot, event: MessageEvent, args: list[str]):
+ await event.reply("所有用户都可以使用此命令")
+```
+
+#### 动态权限检查
+如果需要更复杂的权限逻辑,可以使用 `override_permission_check=True` 参数,然后在函数中手动检查权限:
+
+```python
+@matcher.command(
+ "special",
+ permission=MessageEvent.OP,
+ override_permission_check=True
+)
+async def special_command(bot: Bot, event: MessageEvent, permission_granted: bool):
+ if not permission_granted:
+ await event.reply("权限不足!")
+ return
+
+ # 额外的权限逻辑
+ if event.user_id == 123456:
+ await event.reply("特殊用户,允许执行")
+ else:
+ await event.reply("普通用户,拒绝执行")
+```
+
+### 使用 Redis 进行数据缓存
+框架集成了 Redis 客户端,提供了便捷的异步接口用于数据缓存和持久化。Redis 连接管理器会自动管理连接池,你可以在插件中直接使用。
+
+#### 基本用法
+```python
+from core.redis_manager import redis_manager
+
+@matcher.command("cache")
+async def cache_example(bot: Bot, event: MessageEvent, args: list[str]):
+ # 设置缓存
+ await redis_manager.set("user:123:name", "张三")
+
+ # 获取缓存
+ name = await redis_manager.get("user:123:name")
+
+ # 设置带过期时间的缓存(单位:秒)
+ await redis_manager.setex("temp:data", 3600, "临时数据")
+
+ # 删除缓存
+ await redis_manager.delete("user:123:name")
+
+ await event.reply(f"用户名:{name}")
+```
+
+#### 使用哈希表(Hash)
+```python
+# 设置哈希字段
+await redis_manager.hset("user:123", "age", 20)
+await redis_manager.hset("user:123", "city", "北京")
+
+# 获取哈希字段
+age = await redis_manager.hget("user:123", "age")
+user_data = await redis_manager.hgetall("user:123")
+
+# 删除哈希字段
+await redis_manager.hdel("user:123", "city")
+```
+
+#### 使用列表(List)
+```python
+# 向列表添加元素
+await redis_manager.lpush("recent:actions", "login")
+await redis_manager.rpush("recent:actions", "logout")
+
+# 获取列表范围
+actions = await redis_manager.lrange("recent:actions", 0, 9)
+
+# 获取列表长度
+length = await redis_manager.llen("recent:actions")
+```
+
+### 插件数据管理
+对于需要持久化存储配置或数据的插件,框架提供了 `PluginDataManager` 类,可以方便地管理 JSON 格式的数据文件。
+
+#### 基本用法
+```python
+from core.plugin_manager import PluginDataManager
+
+# 初始化数据管理器
+data_manager = PluginDataManager("weather_plugin")
+
+@matcher.command("weather_set")
+async def set_weather_config(bot: Bot, event: MessageEvent, args: list[str]):
+ if len(args) < 2:
+ await event.reply("用法:/weather_set <城市> <温度>")
+ return
+
+ city = args[0]
+ temperature = args[1]
+
+ # 保存配置
+ await data_manager.set(city, temperature)
+ await event.reply(f"已设置 {city} 的温度为 {temperature}℃")
+
+@matcher.command("weather_get")
+async def get_weather_config(bot: Bot, event: MessageEvent, args: list[str]):
+ if not args:
+ await event.reply("用法:/weather_get <城市>")
+ return
+
+ city = args[0]
+
+ # 读取配置
+ temperature = data_manager.get(city)
+ if temperature:
+ await event.reply(f"{city} 的温度是 {temperature}℃")
+ else:
+ await event.reply(f"未找到 {city} 的温度配置")
+```
+
+#### 数据文件位置
+插件数据文件保存在 `plugins/data/` 目录下,每个插件对应一个独立的 JSON 文件。例如 `weather_plugin` 插件的数据文件为 `plugins/data/weather_plugin.json`。
+
### 插件开发最佳实践
1. **单一职责**:每个插件专注于一个功能领域
2. **错误处理**:妥善处理可能发生的异常
@@ -530,97 +773,290 @@ async def welcome_new_member(bot: Bot, event):
## 📚 事件模型说明
-项目采用了基于工厂模式的事件处理系统,所有事件定义在 `models/events/` 下:
+NEO 框架的事件模型是基于 OneBot v11 协议的强类型数据模型,采用 `dataclasses` 和类型注解构建。所有事件都继承自 `OneBotEvent` 基类,并通过事件工厂自动从 JSON 数据创建对应的事件对象。
-* **MessageEvent**: 消息事件,包含 `PrivateMessageEvent` 和 `GroupMessageEvent`。支持 `await event.reply()` 快速回复。
-* **NoticeEvent**: 通知事件,如 `FriendAddNoticeEvent`, `GroupRecallNoticeEvent` 等。
-* **RequestEvent**: 请求事件,如 `FriendRequestEvent`, `GroupRequestEvent`。
-* **MetaEvent**: 元事件,如心跳 `HeartbeatEvent`。
-
-所有事件均继承自 `OneBotEvent`,并包含 `bot` 属性用于调用 API。
-
-## 🏗️ 技术架构
-
-NEO 框架采用分层架构设计,各层职责明确,便于维护和扩展:
-
-### 架构层次
-
-1. **通信层 (WebSocket Client)**
- - 负责与 OneBot 实现端的 Web Socket连接
- - 实现断线自动重连机制
- - 处理原始消息的收发和协议解析
-
-2. **API 抽象层 (API Mixins)**
- - 提供类型安全的 API 封装
- - 按功能领域划分:消息、群组、好友、账号
- - 所有 API 返回强类型数据模型对象
-
-3. **业务逻辑层 (Bot & Command Manager)**
- - Bot 类组合所有 API 功能
- - 指令和事件分发器
- - 插件加载和管理
-
-4. **插件层 (Plugins)**
- - 支持热重载的插件系统
- - 装饰器风格的注册方式
- - 独立的业务逻辑模块
-
-5. **数据模型层 (Models)**
- - 基于 dataclasses 的事件模型
- - API 响应数据模型
- - 类型安全的序列化/反序列化
-
-### 核心组件交互
+### 事件层次结构
```
-┌─────────────────────────────────────┐
-│ 插件层 (Plugins) │
-│ @matcher.command() │
-│ @matcher.on_notice() │
-└──────────────┬──────────────────────┘
- │
-┌──────────────▼──────────────────────┐
-│ 业务逻辑层 (Command Manager) │
-│ • 事件分发与路由 │
-│ • 指令参数解析 │
-└──────────────┬──────────────────────┘
- │
-┌──────────────▼──────────────────────┐
-│ Bot 组合类 │
-│ • 继承所有 API Mixin │
-│ • 提供统一接口 │
-└──────────────┬──────────────────────┘
- │
-┌──────────────▼──────────────────────┐
-│ API 抽象层 (Mixin) │
-│ • MessageAPI │
-│ • GroupAPI │
-│ • FriendAPI │
-│ • AccountAPI │
-└──────────────┬──────────────────────┘
- │
-┌──────────────▼──────────────────────┐
-│ 通信层 (WebSocket) │
-│ • 连接管理 │
-│ • 消息编解码 │
-│ • 断线重连 │
-└─────────────────────────────────────┘
+OneBotEvent (抽象基类)
+├── MetaEvent (元事件)
+│ ├── HeartbeatEvent (心跳事件)
+│ └── LifeCycleEvent (生命周期事件)
+├── MessageEvent (消息事件)
+│ ├── PrivateMessageEvent (私聊消息事件)
+│ └── GroupMessageEvent (群聊消息事件)
+├── NoticeEvent (通知事件)
+│ ├── FriendAddNoticeEvent (好友添加通知)
+│ ├── FriendRecallNoticeEvent (好友消息撤回通知)
+│ ├── GroupRecallNoticeEvent (群消息撤回通知)
+│ ├── GroupIncreaseNoticeEvent (群成员增加通知)
+│ ├── GroupDecreaseNoticeEvent (群成员减少通知)
+│ ├── GroupAdminNoticeEvent (群管理员变动通知)
+│ ├── GroupBanNoticeEvent (群禁言通知)
+│ ├── GroupUploadNoticeEvent (群文件上传通知)
+│ ├── PokeNotifyEvent (戳一戳通知)
+│ ├── LuckyKingNotifyEvent (运气王通知)
+│ ├── HonorNotifyEvent (群荣誉变更通知)
+│ ├── GroupCardNoticeEvent (群成员名片更新通知)
+│ ├── OfflineFileNoticeEvent (离线文件通知)
+│ ├── ClientStatusNoticeEvent (客户端状态变更通知)
+│ └── EssenceNoticeEvent (精华消息变动通知)
+└── RequestEvent (请求事件)
+ ├── FriendRequestEvent (加好友请求)
+ └── GroupRequestEvent (加群请求/邀请)
```
-### 设计模式应用
+### 事件基类:OneBotEvent
-- **工厂模式**:事件对象的创建和管理
-- **装饰器模式**:插件和指令的注册
-- **组合模式**:Bot 类通过继承组合 API 功能
-- **观察者模式**:事件监听和处理
-- **策略模式**:不同的消息处理策略
+所有事件的基类,定义了事件的通用属性和方法:
-### 性能特点
+```python
+@dataclass(slots=True)
+class OneBotEvent(ABC):
+ """
+ OneBot v11 事件的抽象基类。
+
+ Attributes:
+ time (int): 事件发生的时间戳 (秒)
+ self_id (int): 收到事件的机器人 QQ 号
+ _bot (Optional[Bot]): 内部持有的 Bot 实例引用
+ """
+ time: int
+ self_id: int
+ _bot: Optional["Bot"] = field(default=None, init=False)
+
+ @property
+ @abstractmethod
+ def post_type(self) -> str:
+ """事件的上报类型,子类必须重写此属性"""
+ pass
+
+ @property
+ def bot(self) -> "Bot":
+ """获取与此事件关联的 Bot 实例"""
+ if self._bot is None:
+ raise ValueError("Bot instance not set for this event")
+ return self._bot
+
+ @bot.setter
+ def bot(self, value: "Bot"):
+ """为事件对象设置关联的 Bot 实例"""
+ self._bot = value
+```
-- **异步非阻塞**:全面基于 asyncio,支持高并发
-- **内存高效**:事件和模型对象使用 dataclasses,内存占用小
-- **快速响应**:插件热重载和事件分发机制确保快速响应
-- **可扩展性**:模块化设计便于功能扩展和定制
+### 事件类型常量
----
-*Internal Use Only - DOGSOHA ond baby2016 by Fairy-Oracle-Sanctuary*
+框架定义了完整的事件类型常量,用于标识不同种类的事件:
+
+```python
+class EventType:
+ META = 'meta_event' # 元事件:心跳、生命周期等
+ REQUEST = 'request ' # 请求事件:加好友请求、加群请求等
+ NOTICE = 'notice' # 通知事件:群成员增加、文件上传等
+ MESSAGE = 'message' # 消息事件:私聊消息、群消息等
+ MESSAGE_SENT = 'message_sent' # 消息发送事件:机器人自己发送消息的上报
+```
+
+### 消息事件
+
+消息事件是机器人最常处理的事件类型,框架提供了完整的消息段支持和便捷的回复方法:
+
+#### MessageEvent (消息事件基类)
+
+```python
+@dataclass
+class MessageEvent(OneBotEvent):
+ message_type: str # 消息类型: private (私聊), group (群聊)
+ sub_type: str # 消息子类型
+ message_id: int # 消息 ID
+ user_id: int # 发送者 QQ 号
+ message: List[MessageSegment] # 消息内容列表
+ raw_message: str # 原始消息内容
+ font: int # 字体
+ sender: Optional[Sender] # 发送者信息
+
+ @property
+ def post_type(self) -> str:
+ return EventType.MESSAGE
+
+ async def reply(self, message: str, auto_escape: bool = False):
+ """回复消息(抽象方法,由子类实现)"""
+ raise NotImplementedError
+```
+
+#### PrivateMessageEvent (私聊消息事件)
+
+```python
+@dataclass
+class PrivateMessageEvent(MessageEvent):
+ async def reply(self, message: str, auto_escape: bool = False):
+ """回复私聊消息"""
+ await self.bot.send_private_msg(
+ user_id=self.user_id, message=message, auto_escape=auto_escape
+ )
+```
+
+#### GroupMessageEvent (群聊消息事件)
+
+```python
+@dataclass
+class GroupMessageEvent(MessageEvent):
+ group_id: int = 0 # 群号
+ anonymous: Optional[Anonymous] = None # 匿名信息
+
+ async def reply(self, message: str, auto_escape: bool = False):
+ """回复群聊消息"""
+ await self.bot.send_group_msg(
+ group_id=self.group_id, message=message, auto_escape=auto_escape
+ )
+```
+
+### 通知事件
+
+通知事件用于处理各种系统通知,如群成员变动、文件上传等:
+
+#### 常用通知事件示例
+
+```python
+@dataclass
+class GroupIncreaseNoticeEvent(GroupNoticeEvent):
+ """群成员增加通知"""
+ operator_id: int = 0 # 操作者 QQ 号
+ sub_type: str = "" # 子类型: approve (管理员同意入群), invite (管理员邀请入群)
+
+@dataclass
+class GroupRecallNoticeEvent(GroupNoticeEvent):
+ """群消息撤回通知"""
+ operator_id: int = 0 # 操作者 QQ 号
+ message_id: int = 0 # 被撤回的消息 ID
+
+@dataclass
+class PokeNotifyEvent(NotifyNoticeEvent):
+ """戳一戳通知"""
+ target_id: int = 0 # 被戳者 QQ 号
+ group_id: int = 0 # 群号 (如果是群内戳一戳)
+```
+
+### 请求事件
+
+请求事件用于处理用户的主动请求,如加好友、加群等:
+
+```python
+@dataclass
+class FriendRequestEvent(RequestEvent):
+ """加好友请求事件"""
+ user_id: int = 0 # 发送请求的 QQ 号
+ comment: str = "" # 验证信息
+ flag: str = "" # 请求 flag,用于 API 调用
+
+@dataclass
+class GroupRequestEvent(RequestEvent):
+ """加群请求/邀请事件"""
+ sub_type: str = "" # 子类型: add (加群请求), invite (邀请登录号入群)
+ group_id: int = 0 # 群号
+ user_id: int = 0 # 发送请求的 QQ 号
+ comment: str = "" # 验证信息
+ flag: str = "" # 请求 flag,用于 API 调用
+```
+
+### 元事件
+
+元事件用于处理框架自身状态变化,如心跳、生命周期等:
+
+```python
+@dataclass
+class HeartbeatEvent(MetaEvent):
+ """心跳事件,用于确认连接状态"""
+ meta_event_type: str = 'heartbeat'
+ status: HeartbeatStatus = field(default_factory=HeartbeatStatus)
+ interval: int = 0 # 心跳间隔时间(ms)
+
+@dataclass
+class LifeCycleEvent(MetaEvent):
+ """生命周期事件,用于通知框架生命周期变化"""
+ meta_event_type: str = 'lifecycle'
+ sub_type: LifeCycleSubType = LifeCycleSubType.ENABLE # 子类型: enable, disable, connect
+```
+
+### 事件工厂:EventFactory
+
+事件工厂是框架的核心组件之一,负责将原始 JSON 数据转换为强类型的事件对象:
+
+```python
+class EventFactory:
+ @staticmethod
+ def create_event(data: Dict[str, Any]) -> OneBotEvent:
+ """根据数据创建事件对象"""
+ post_type = data.get("post_type")
+
+ if post_type == EventType.MESSAGE or post_type == EventType.MESSAGE_SENT:
+ return EventFactory._create_message_event(data, common_args)
+ elif post_type == EventType.NOTICE:
+ return EventFactory._create_notice_event(data, common_args)
+ elif post_type == EventType.REQUEST:
+ return EventFactory._create_request_event(data, common_args)
+ elif post_type == EventType.META:
+ return EventFactory._create_meta_event(data, common_args)
+ else:
+ raise ValueError(f"Unknown event type: {post_type}")
+```
+
+### 在插件中使用事件
+
+插件可以直接使用这些事件类型来处理各种场景:
+
+```python
+from core.command_manager import matcher
+from core.bot import Bot
+from models import GroupMessageEvent, PrivateMessageEvent
+from models.events.notice import GroupIncreaseNoticeEvent
+from models.events.request import FriendRequestEvent
+
+# 处理群消息事件
+@matcher.command("hello")
+async def handle_hello(bot: Bot, event: GroupMessageEvent, args: list[str]):
+ await event.reply(f"你好 {event.sender.nickname}!")
+
+# 处理私聊消息事件
+@matcher.command("help", permission_level=MessageEvent.USER)
+async def handle_help(bot: Bot, event: PrivateMessageEvent, args: list[str]):
+ await event.reply("这里是帮助信息...")
+
+# 处理群成员增加通知
+@matcher.on_notice("group_increase")
+async def handle_group_increase(bot: Bot, event: GroupIncreaseNoticeEvent):
+ await bot.send_group_msg(
+ event.group_id,
+ f"欢迎新成员 {event.user_id} 加入!操作者:{event.operator_id}"
+ )
+
+# 处理加好友请求
+@matcher.on_request("friend")
+async def handle_friend_request(bot: Bot, event: FriendRequestEvent):
+ # 自动同意所有好友请求
+ await bot.set_friend_add_request(flag=event.flag, approve=True)
+ await bot.send_private_msg(event.user_id, "已通过您的好友请求!")
+```
+
+### 事件处理的优势
+
+1. **类型安全**:所有事件都有明确的类型定义,IDE 可以提供完整的代码提示和补全
+2. **易于测试**:事件对象可以轻松构造,便于编写单元测试
+3. **数据完整**:所有字段都有类型注解,确保数据的一致性和完整性
+4. **性能优化**:使用 `@dataclass(slots=True)` 减少内存占用,提高属性访问速度
+5. **可扩展性**:可以轻松定义自定义事件类型,扩展框架功能
+
+### 常用事件属性速查
+
+| 事件类型 | 关键属性 | 描述 |
+|---------|---------|------|
+| **MessageEvent** | `message_type`, `user_id`, `message`, `sender` | 所有消息事件的基类 |
+| **PrivateMessageEvent** | 继承自 MessageEvent | 私聊消息事件 |
+| **GroupMessageEvent** | `group_id`, `anonymous` | 群聊消息事件,包含群号和匿名信息 |
+| **GroupIncreaseNoticeEvent** | `group_id`, `user_id`, `operator_id`, `sub_type` | 群成员增加通知 |
+| **RecallGroupNoticeEvent** | `group_id`, `user_id`, `operator_id`, `message_id` | 群消息撤回通知 |
+| **FriendRequestEvent** | `user_id`, `comment`, `flag` | 加好友请求事件 |
+| **GroupRequestEvent** | `group_id`, `user_id`, `sub_type`, `comment`, `flag` | 加群请求/邀请事件 |
+| **HeartbeatEvent** | `status`, `interval` | 心跳事件,用于监控连接状态 |
+
+通过这套完整的事件模型,NEO 框架为开发者提供了强大而灵活的事件处理能力,同时保持了代码的类型安全和良好的开发体验。
diff --git a/core/admin_manager.py b/core/admin_manager.py
new file mode 100644
index 0000000..864df52
--- /dev/null
+++ b/core/admin_manager.py
@@ -0,0 +1,166 @@
+"""
+管理员管理器模块
+
+该模块负责管理机器人的管理员列表。
+它实现了文件和 Redis 缓存之间的数据同步,并提供了一套清晰的 API
+供其他模块调用。
+"""
+import json
+import os
+from typing import Set
+
+from .logger import logger
+
+
+class AdminManager:
+ """
+ 管理员管理器类
+
+ 负责加载、缓存和管理管理员列表。
+ 使用单例模式,确保全局只有一个实例。
+ """
+ _instance = None
+ _REDIS_KEY = "neobot:admins" # 用于存储管理员集合的 Redis 键
+
+ def __new__(cls):
+ """
+ 单例模式实现
+ """
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ cls._instance._initialized = False
+ return cls._instance
+
+ def __init__(self):
+ """
+ 初始化 AdminManager
+ """
+ if getattr(self, "_initialized", False):
+ return
+
+ # 管理员数据文件路径
+ self.data_file = os.path.join(
+ os.path.dirname(os.path.abspath(__file__)),
+ "..",
+ "data",
+ "admin.json"
+ )
+
+ self._admins: Set[int] = set()
+ self._initialized = True
+ logger.info("管理员管理器初始化完成")
+
+ async def initialize(self):
+ """
+ 异步初始化,加载数据并同步到 Redis
+ """
+ await self._load_from_file()
+ await self._sync_to_redis()
+ logger.info("管理员数据加载并同步到 Redis 完成")
+
+ async def _load_from_file(self):
+ """
+ 从 admin.json 加载管理员列表
+ """
+ try:
+ if os.path.exists(self.data_file):
+ with open(self.data_file, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ admins = data.get("admins", [])
+ self._admins = set(int(admin_id) for admin_id in admins)
+ logger.debug(f"从 {self.data_file} 加载了 {len(self._admins)} 位管理员")
+ else:
+ # 如果文件不存在,创建一个空的
+ self._admins = set()
+ await self._save_to_file()
+ except (json.JSONDecodeError, ValueError) as e:
+ logger.error(f"加载或解析 admin.json 失败: {e}")
+ self._admins = set()
+
+ async def _save_to_file(self):
+ """
+ 将当前管理员列表保存回 admin.json
+ """
+ try:
+ # 确保目录存在
+ os.makedirs(os.path.dirname(self.data_file), exist_ok=True)
+ # 将 set 转换为 list 以便 JSON 序列化
+ admin_list = [str(admin_id) for admin_id in self._admins]
+ with open(self.data_file, "w", encoding="utf-8") as f:
+ json.dump({"admins": admin_list}, f, indent=2, ensure_ascii=False)
+ logger.debug(f"管理员列表已保存到 {self.data_file}")
+ except Exception as e:
+ logger.error(f"保存 admin.json 失败: {e}")
+
+ async def _sync_to_redis(self):
+ """
+ 将内存中的管理员集合同步到 Redis
+ """
+ from .redis_manager import redis_manager
+ try:
+ # 首先清空旧的集合
+ await redis_manager.redis.delete(self._REDIS_KEY)
+ if self._admins:
+ # 将所有管理员ID添加到集合中
+ await redis_manager.redis.sadd(self._REDIS_KEY, *self._admins)
+ logger.debug(f"已将 {len(self._admins)} 位管理员同步到 Redis")
+ except Exception as e:
+ logger.error(f"同步管理员到 Redis 失败: {e}")
+
+ async def is_admin(self, user_id: int) -> bool:
+ """
+ 检查用户是否为管理员(从 Redis 缓存读取)
+ """
+ from .redis_manager import redis_manager
+ try:
+ return await redis_manager.redis.sismember(self._REDIS_KEY, user_id)
+ except Exception as e:
+ logger.error(f"从 Redis 检查管理员权限失败: {e}")
+ # Redis 失败时,回退到内存检查
+ return user_id in self._admins
+
+ async def add_admin(self, user_id: int) -> bool:
+ """
+ 添加管理员,并同步到文件和 Redis
+ """
+ from .redis_manager import redis_manager
+ if user_id in self._admins:
+ return False # 用户已经是管理员
+
+ self._admins.add(user_id)
+ await self._save_to_file()
+ try:
+ await redis_manager.redis.sadd(self._REDIS_KEY, user_id)
+ logger.info(f"已添加新管理员 {user_id} 并更新缓存")
+ return True
+ except Exception as e:
+ logger.error(f"添加管理员 {user_id} 到 Redis 失败: {e}")
+ return False
+
+ async def remove_admin(self, user_id: int) -> bool:
+ """
+ 移除管理员,并同步到文件和 Redis
+ """
+ from .redis_manager import redis_manager
+ if user_id not in self._admins:
+ return False # 用户不是管理员
+
+ self._admins.remove(user_id)
+ await self._save_to_file()
+ try:
+ await redis_manager.redis.srem(self._REDIS_KEY, user_id)
+ logger.info(f"已移除管理员 {user_id} 并更新缓存")
+ return True
+ except Exception as e:
+ logger.error(f"从 Redis 移除管理员 {user_id} 失败: {e}")
+ return False
+
+ async def get_all_admins(self) -> Set[int]:
+ """
+ 获取所有管理员的集合
+ """
+ return self._admins.copy()
+
+
+# 全局 AdminManager 实例
+admin_manager = AdminManager()
diff --git a/core/api/account.py b/core/api/account.py
index 0a5ef0d..07bab8d 100644
--- a/core/api/account.py
+++ b/core/api/account.py
@@ -8,7 +8,7 @@ import json
from typing import Dict, Any
from .base import BaseAPI
from models.objects import LoginInfo, VersionInfo, Status
-from core.redis_manager import redis_client as redis_manager
+from core.redis_manager import redis_manager
class AccountAPI(BaseAPI):
diff --git a/core/api/friend.py b/core/api/friend.py
index 76e696b..823fa06 100644
--- a/core/api/friend.py
+++ b/core/api/friend.py
@@ -8,7 +8,7 @@ import json
from typing import List, Dict, Any
from .base import BaseAPI
from models.objects import FriendInfo, StrangerInfo
-from core.redis_manager import redis_client as redis_manager
+from core.redis_manager import redis_manager
class FriendAPI(BaseAPI):
@@ -42,12 +42,12 @@ class FriendAPI(BaseAPI):
"""
cache_key = f"neobot:cache:get_stranger_info:{user_id}"
if not no_cache:
- cached_data = await redis_manager.get(cache_key)
+ cached_data = await redis_manager.redis.get(cache_key)
if cached_data:
return StrangerInfo(**json.loads(cached_data))
res = await self.call_api("get_stranger_info", {"user_id": user_id, "no_cache": no_cache})
- await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
+ await redis_manager.redis.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
return StrangerInfo(**res)
async def get_friend_list(self, no_cache: bool = False) -> List[FriendInfo]:
@@ -62,12 +62,12 @@ class FriendAPI(BaseAPI):
"""
cache_key = f"neobot:cache:get_friend_list:{self.self_id}"
if not no_cache:
- cached_data = await redis_manager.get(cache_key)
+ cached_data = await redis_manager.redis.get(cache_key)
if cached_data:
return [FriendInfo(**item) for item in json.loads(cached_data)]
res = await self.call_api("get_friend_list")
- await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
+ await redis_manager.redis.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
return [FriendInfo(**item) for item in res]
async def set_friend_add_request(self, flag: str, approve: bool = True, remark: str = "") -> Dict[str, Any]:
diff --git a/core/api/group.py b/core/api/group.py
index d4eb5ae..224d148 100644
--- a/core/api/group.py
+++ b/core/api/group.py
@@ -6,7 +6,7 @@
"""
from typing import List, Dict, Any, Optional
import json
-from core.redis_manager import redis_client as redis_manager
+from core.redis_manager import redis_manager
from .base import BaseAPI
from models.objects import GroupInfo, GroupMemberInfo, GroupHonorInfo
@@ -178,12 +178,12 @@ class GroupAPI(BaseAPI):
"""
cache_key = f"neobot:cache:get_group_info:{group_id}"
if not no_cache:
- cached_data = await redis_manager.get(cache_key)
+ cached_data = await redis_manager.redis.get(cache_key)
if cached_data:
return GroupInfo(**json.loads(cached_data))
res = await self.call_api("get_group_info", {"group_id": group_id})
- await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
+ await redis_manager.redis.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
return GroupInfo(**res)
async def get_group_list(self) -> List[GroupInfo]:
@@ -210,12 +210,12 @@ class GroupAPI(BaseAPI):
"""
cache_key = f"neobot:cache:get_group_member_info:{group_id}:{user_id}"
if not no_cache:
- cached_data = await redis_manager.get(cache_key)
+ cached_data = await redis_manager.redis.get(cache_key)
if cached_data:
return GroupMemberInfo(**json.loads(cached_data))
res = await self.call_api("get_group_member_info", {"group_id": group_id, "user_id": user_id})
- await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
+ await redis_manager.redis.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
return GroupMemberInfo(**res)
async def get_group_member_list(self, group_id: int) -> List[GroupMemberInfo]:
diff --git a/core/command_manager.py b/core/command_manager.py
index ec875b2..c51794a 100644
--- a/core/command_manager.py
+++ b/core/command_manager.py
@@ -4,19 +4,12 @@
该模块定义了 `CommandManager` 类,它是整个机器人框架事件处理的核心。
它通过装饰器模式,为插件提供了注册消息指令、通知事件处理器和
请求事件处理器的能力。
-
-主要职责:
-- 提供 `@matcher.command()` 装饰器来注册命令。
-- 提供 `@matcher.on_notice()` 装饰器来注册通知处理器。
-- 提供 `@matcher.on_request()` 装饰器来注册请求处理器。
-- 负责解析收到的消息,匹配命令前缀并分发给对应的处理器。
-- 统一处理所有类型的事件,并将其分发给所有已注册的处理器。
-- 内置一个 `/help` 命令,用于展示所有已加载插件的帮助信息。
"""
-import inspect
-from typing import Any, Callable, Dict, List, Tuple
+from typing import Any, Callable, Dict, Optional, Tuple
from .config_loader import global_config
+from .event_handler import MessageHandler, NoticeHandler, RequestHandler
+
# 从配置中获取命令前缀
comm_prefixes = global_config.bot.get("command", ("/",))
@@ -27,6 +20,7 @@ class CommandManager:
命令管理器,负责注册和分发所有类型的事件。
这是一个单例对象(`matcher`),在整个应用中共享。
+ 它将不同类型的事件处理委托给专门的处理器类。
"""
def __init__(self, prefixes: Tuple[str, ...]):
@@ -36,51 +30,91 @@ class CommandManager:
Args:
prefixes (Tuple[str, ...]): 一个包含所有合法命令前缀的元组。
"""
- # --- 初始化所有处理器列表 ---
- self.prefixes = prefixes
- self.commands: Dict[str, Callable] = {}
- self.message_handlers: List[Callable] = []
- self.notice_handlers: List[Dict] = []
- self.request_handlers: List[Dict] = []
self.plugins: Dict[str, Dict[str, Any]] = {}
+
+ # 初始化专门的事件处理器
+ self.message_handler = MessageHandler(prefixes)
+ self.notice_handler = NoticeHandler()
+ self.request_handler = RequestHandler()
- # --- 注册内置指令 ---
- self.commands["help"] = self._help_command
+ # 将处理器映射到事件类型
+ self.handler_map = {
+ "message": self.message_handler,
+ "notice": self.notice_handler,
+ "request": self.request_handler,
+ }
+
+ # 注册内置的 /help 命令
+ self._register_internal_commands()
+
+ def _register_internal_commands(self):
+ """
+ 注册框架内置的命令
+ """
+ # Help 命令
+ self.message_handler.command("help")(self._help_command)
self.plugins["core.help"] = {
"name": "帮助",
"description": "显示所有可用指令的帮助信息",
"usage": "/help",
}
+ # --- 装饰器代理 ---
+
def on_message(self) -> Callable:
"""
- 装饰器:用于注册一个通用的消息处理器。
-
- 被此装饰器注册的函数,会在每次收到消息时(在指令匹配前)被调用。
- 如果函数返回 True,则表示该消息已被“消费”,后续的指令匹配将不会进行。
-
- Example:
- @matcher.on_message()
- async def code_input_handler(bot, event):
- if is_waiting_for_code(event.user_id):
- await process_code(event.raw_message)
- return True # 消费事件
+ 装饰器:注册一个通用的消息处理器。
"""
- def decorator(func: Callable) -> Callable:
- self.message_handlers.append(func)
- return func
- return decorator
+ return self.message_handler.on_message()
+ def command(
+ self,
+ name: str,
+ permission: Optional[Any] = None,
+ override_permission_check: bool = False
+ ) -> Callable:
+ """
+ 装饰器:注册一个消息指令处理器。
+ """
+ return self.message_handler.command(
+ name,
+ permission=permission,
+ override_permission_check=override_permission_check
+ )
+
+ def on_notice(self, notice_type: Optional[str] = None) -> Callable:
+ """
+ 装饰器:注册一个通知事件处理器。
+ """
+ return self.notice_handler.register(notice_type=notice_type)
+
+ def on_request(self, request_type: Optional[str] = None) -> Callable:
+ """
+ 装饰器:注册一个请求事件处理器。
+ """
+ return self.request_handler.register(request_type=request_type)
+
+ # --- 事件处理 ---
+
+ async def handle_event(self, bot, event):
+ """
+ 统一的事件分发入口。
+
+ 根据事件的 `post_type` 将其分发给对应的处理器。
+ """
+ if event.post_type == 'message' and global_config.bot.get('ignore_self_message', False):
+ if hasattr(event, 'user_id') and hasattr(event, 'self_id') and event.user_id == event.self_id:
+ return
+
+ handler = self.handler_map.get(event.post_type)
+ if handler:
+ await handler.handle(bot, event)
+
+ # --- 内置命令实现 ---
async def _help_command(self, bot, event):
"""
内置的 `/help` 命令的实现。
-
- 该命令会遍历所有已加载插件的元数据,并生成一段格式化的帮助文本。
-
- Args:
- bot: Bot 实例。
- event: 消息事件对象。
"""
help_text = "--- 可用指令列表 ---\n"
@@ -95,187 +129,6 @@ class CommandManager:
await bot.send(event, help_text.strip())
- def command(self, name: str) -> Callable:
- """
- 装饰器:用于注册一个消息指令处理器。
-
- Example:
- @matcher.command("echo")
- async def handle_echo(bot, event, args):
- await bot.send(event, " ".join(args))
-
- Args:
- name (str): 指令的名称(不包含命令前缀)。
-
- Returns:
- Callable: 原函数,使其可以继续被调用。
- """
-
- def decorator(func: Callable) -> Callable:
- self.commands[name] = func
- return func
-
- return decorator
-
- def on_notice(self, notice_type: str = None) -> Callable:
- """
- 装饰器:用于注册一个通知事件处理器。
-
- 如果 `notice_type` 未指定,则该处理器会接收所有类型的通知事件。
-
- Args:
- notice_type (str, optional): 要处理的通知类型 (e.g., "group_increase")。
- Defaults to None.
-
- Returns:
- Callable: 原函数。
- """
-
- def decorator(func: Callable) -> Callable:
- self.notice_handlers.append({"type": notice_type, "func": func})
- return func
-
- return decorator
-
- def on_request(self, request_type: str = None) -> Callable:
- """
- 装饰器:用于注册一个请求事件处理器。
-
- 如果 `request_type` 未指定,则该处理器会接收所有类型的请求事件。
-
- Args:
- request_type (str, optional): 要处理的请求类型 (e.g., "friend", "group")。
- Defaults to None.
-
- Returns:
- Callable: 原函数。
- """
-
- def decorator(func: Callable) -> Callable:
- self.request_handlers.append({"type": request_type, "func": func})
- return func
-
- return decorator
-
- async def handle_event(self, bot, event):
- """
- 统一的事件分发入口。
-
- 由 `WS` 客户端在接收到事件后调用。该方法会根据事件的 `post_type`
- 将其分发给对应的具体处理方法。
-
- Args:
- bot: Bot 实例。
- event: 已解析的事件对象。
- """
- # --- 全局过滤机器人自身消息 ---
- # 仅对消息事件生效
- if event.post_type == 'message' and global_config.bot.get('ignore_self_message', False):
-
- if hasattr(event, 'user_id') and hasattr(event, 'self_id') and event.user_id == event.self_id:
- return
-
- post_type = event.post_type
-
- if post_type == 'message':
- await self.handle_message(bot, event)
- elif post_type == 'notice':
- await self.handle_notice(bot, event)
- elif post_type == 'request':
- await self.handle_request(bot, event)
-
- async def handle_message(self, bot, event):
- """
- 处理消息事件,优先执行通用处理器,然后解析并分发指令。
- """
- # --- 1. 执行通用消息处理器 ---
- for handler in self.message_handlers:
- # 如果任何一个处理器返回 True,则中断后续处理
- consumed = await self._run_handler(handler, bot, event)
- if consumed:
- return
-
- # --- 2. 检查并执行指令 ---
- if not event.raw_message:
- return
-
- raw_text = event.raw_message.strip()
-
- prefix_found = None
- for p in self.prefixes:
- if raw_text.startswith(p):
- prefix_found = p
- break
-
- if not prefix_found:
- return
-
- full_cmd = raw_text[len(prefix_found) :].split()
- if not full_cmd:
- return
-
- cmd_name = full_cmd[0]
- args = full_cmd[1:]
-
- if cmd_name in self.commands:
- func = self.commands[cmd_name]
- await self._run_handler(func, bot, event, args)
-
- async def handle_notice(self, bot, event):
- """
- 分发通知事件给所有匹配的处理器。
-
- Args:
- bot: Bot 实例。
- event: 通知事件对象。
- """
- for handler in self.notice_handlers:
- if handler["type"] is None or handler["type"] == event.notice_type:
- await self._run_handler(handler["func"], bot, event)
-
- async def handle_request(self, bot, event):
- """
- 分发请求事件给所有匹配的处理器。
-
- Args:
- bot: Bot 实例。
- event: 请求事件对象。
- """
- for handler in self.request_handlers:
- if handler["type"] is None or handler["type"] == event.request_type:
- await self._run_handler(handler["func"], bot, event)
-
- async def _run_handler(self, func: Callable, bot, event, args: List[str] = None):
- """
- 智能执行事件处理器,并返回事件是否被消费。
-
- 该方法会检查目标处理器的函数签名,并根据签名动态地传入所需的参数
- (如 `bot`, `event`, `args`),实现了依赖注入。
-
- Args:
- func (Callable): 目标处理器函数。
- bot: Bot 实例。
- event: 事件对象。
- args (List[str], optional): 指令参数列表(仅对消息事件有效)。
-
- Returns:
- bool: 如果处理器函数返回 True,则返回 True,否则返回 False。
- """
- sig = inspect.signature(func)
- params = sig.parameters
- kwargs = {}
-
- if "bot" in params:
- kwargs["bot"] = bot
- if "event" in params:
- kwargs["event"] = event
- if "args" in params and args is not None:
- kwargs["args"] = args
-
- # 执行函数并获取返回值
- result = await func(**kwargs)
- return result is True
-
# --- 全局单例 ---
diff --git a/core/event_handler.py b/core/event_handler.py
new file mode 100644
index 0000000..b355718
--- /dev/null
+++ b/core/event_handler.py
@@ -0,0 +1,197 @@
+"""
+事件处理器模块
+
+该模块定义了用于处理不同类型事件的处理器类。
+每个处理器都负责注册和分发特定类型的事件。
+"""
+import inspect
+from abc import ABC, abstractmethod
+from typing import Any, Callable, Dict, List, Optional, Tuple
+
+from .bot import Bot
+from .permission_manager import Permission, permission_manager
+from .exceptions import SyncHandlerError
+from .executor import run_in_thread_pool
+
+
+class BaseHandler(ABC):
+ """
+ 事件处理器抽象基类
+ """
+ def __init__(self):
+ self.handlers: List[Dict[str, Any]] = []
+
+ @abstractmethod
+ async def handle(self, bot: Bot, event: Any):
+ """
+ 处理事件
+ """
+ raise NotImplementedError
+
+ async def _run_handler(
+ self,
+ func: Callable,
+ bot: Bot,
+ event: Any,
+ args: Optional[List[str]] = None,
+ permission_granted: Optional[bool] = None
+ ):
+ """
+ 智能执行事件处理器,并注入所需参数
+ """
+ sig = inspect.signature(func)
+ params = sig.parameters
+ kwargs = {}
+
+ if "bot" in params:
+ kwargs["bot"] = bot
+ if "event" in params:
+ kwargs["event"] = event
+ if "args" in params and args is not None:
+ kwargs["args"] = args
+ if "permission_granted" in params and permission_granted is not None:
+ kwargs["permission_granted"] = permission_granted
+
+ if inspect.iscoroutinefunction(func):
+ result = await func(**kwargs)
+ else:
+ # 如果是同步函数,则放入线程池执行
+ result = await run_in_thread_pool(func, **kwargs)
+ return result is True
+
+
+class MessageHandler(BaseHandler):
+ """
+ 消息事件处理器
+ """
+ def __init__(self, prefixes: Tuple[str, ...]):
+ super().__init__()
+ self.prefixes = prefixes
+ self.commands: Dict[str, Dict] = {}
+ self.message_handlers: List[Callable] = []
+
+ def on_message(self) -> Callable:
+ """
+ 注册通用消息处理器
+ """
+ def decorator(func: Callable) -> Callable:
+ if not inspect.iscoroutinefunction(func):
+ raise SyncHandlerError(f"消息处理器 {func.__name__} 必须是异步函数 (async def).")
+ self.message_handlers.append(func)
+ return func
+ return decorator
+
+ def command(
+ self,
+ name: str,
+ permission: Optional[Permission] = None,
+ override_permission_check: bool = False
+ ) -> Callable:
+ """
+ 注册命令处理器
+ """
+ def decorator(func: Callable) -> Callable:
+ if not inspect.iscoroutinefunction(func):
+ raise SyncHandlerError(f"命令处理器 {func.__name__} 必须是异步函数 (async def).")
+ self.commands[name] = {
+ "func": func,
+ "permission": permission,
+ "override_permission_check": override_permission_check,
+ }
+ return func
+ return decorator
+
+ async def handle(self, bot: Bot, event: Any):
+ """
+ 处理消息事件,包括通用消息和命令
+ """
+ for handler in self.message_handlers:
+ consumed = await self._run_handler(handler, bot, event)
+ if consumed:
+ return
+
+ if not event.raw_message:
+ return
+
+ raw_text = event.raw_message.strip()
+ prefix_found = next((p for p in self.prefixes if raw_text.startswith(p)), None)
+
+ if not prefix_found:
+ return
+
+ full_cmd = raw_text[len(prefix_found):].split()
+ if not full_cmd:
+ return
+
+ cmd_name = full_cmd[0]
+ args = full_cmd[1:]
+
+ if cmd_name in self.commands:
+ command_info = self.commands[cmd_name]
+ func = command_info["func"]
+ permission = command_info.get("permission")
+ override_check = command_info.get("override_permission_check", False)
+
+ permission_granted = True
+ if permission:
+ permission_granted = await permission_manager.check_permission(event.user_id, permission)
+
+ if not permission_granted and not override_check:
+ await bot.send(event, f"权限不足,需要 {permission.name} 权限")
+ return
+
+ await self._run_handler(
+ func,
+ bot,
+ event,
+ args=args,
+ permission_granted=permission_granted
+ )
+
+
+class NoticeHandler(BaseHandler):
+ """
+ 通知事件处理器
+ """
+ def register(self, notice_type: Optional[str] = None) -> Callable:
+ """
+ 注册通知处理器
+ """
+ def decorator(func: Callable) -> Callable:
+ if not inspect.iscoroutinefunction(func):
+ raise SyncHandlerError(f"通知处理器 {func.__name__} 必须是异步函数 (async def).")
+ self.handlers.append({"type": notice_type, "func": func})
+ return func
+ return decorator
+
+ async def handle(self, bot: Bot, event: Any):
+ """
+ 处理通知事件
+ """
+ for handler in self.handlers:
+ if handler["type"] is None or handler["type"] == event.notice_type:
+ await self._run_handler(handler["func"], bot, event)
+
+
+class RequestHandler(BaseHandler):
+ """
+ 请求事件处理器
+ """
+ def register(self, request_type: Optional[str] = None) -> Callable:
+ """
+ 注册请求处理器
+ """
+ def decorator(func: Callable) -> Callable:
+ if not inspect.iscoroutinefunction(func):
+ raise SyncHandlerError(f"请求处理器 {func.__name__} 必须是异步函数 (async def).")
+ self.handlers.append({"type": request_type, "func": func})
+ return func
+ return decorator
+
+ async def handle(self, bot: Bot, event: Any):
+ """
+ 处理请求事件
+ """
+ for handler in self.handlers:
+ if handler["type"] is None or handler["type"] == event.request_type:
+ await self._run_handler(handler["func"], bot, event)
diff --git a/core/exceptions.py b/core/exceptions.py
new file mode 100644
index 0000000..9b8cd18
--- /dev/null
+++ b/core/exceptions.py
@@ -0,0 +1,9 @@
+"""
+自定义异常模块
+"""
+
+class SyncHandlerError(Exception):
+ """
+ 当尝试注册同步函数作为异步事件处理器时抛出此异常。
+ """
+ pass
diff --git a/core/executor.py b/core/executor.py
new file mode 100644
index 0000000..6d691bd
--- /dev/null
+++ b/core/executor.py
@@ -0,0 +1,27 @@
+"""
+线程池执行器
+
+提供一个全局的线程池和异步接口,用于在事件循环中安全地运行同步函数。
+"""
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+from functools import partial
+from typing import Any, Callable
+
+# 创建一个全局的线程池,可以根据需要调整 max_workers
+executor = ThreadPoolExecutor(max_workers=10)
+
+async def run_in_thread_pool(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
+ """
+ 在线程池中异步运行同步函数
+
+ :param func: 要运行的同步函数
+ :param args: 函数的位置参数
+ :param kwargs: 函数的关键字参数
+ :return: 函数的返回值
+ """
+ loop = asyncio.get_running_loop()
+ # 使用 functools.partial 绑定函数和参数,以便传递给 run_in_executor
+ func_to_run = partial(func, *args, **kwargs)
+ # loop.run_in_executor 会返回一个 awaitable 对象
+ return await loop.run_in_executor(executor, func_to_run)
diff --git a/core/permission_manager.py b/core/permission_manager.py
new file mode 100644
index 0000000..c79a18d
--- /dev/null
+++ b/core/permission_manager.py
@@ -0,0 +1,252 @@
+"""
+权限管理器模块
+
+该模块负责管理用户权限,支持 admin、op、user 三个权限级别。
+权限数据存储在 `permissions.json` 文件中,格式为:
+{
+ "users": {
+ "123456": "admin",
+ "789012": "op",
+ "345678": "user"
+ }
+}
+"""
+import json
+import os
+from functools import total_ordering
+from typing import Dict
+
+from .logger import logger
+from .admin_manager import admin_manager # 导入 AdminManager
+
+
+@total_ordering
+class Permission:
+ """
+ 权限封装类
+
+ 封装了权限的名称和等级,并提供了比较方法。
+ 使用 @total_ordering 装饰器可以自动生成所有的比较运算符。
+ """
+ def __init__(self, name: str, level: int):
+ """
+ 初始化权限对象
+
+ Args:
+ name (str): 权限名称 (e.g., "admin", "op")
+ level (int): 权限等级,数字越大权限越高
+ """
+ self.name = name
+ self.level = level
+
+ def __eq__(self, other):
+ """
+ 判断权限是否相等
+ """
+ if not isinstance(other, Permission):
+ return NotImplemented
+ return self.level == other.level
+
+ def __lt__(self, other):
+ """
+ 判断权限是否小于另一个权限
+ """
+ if not isinstance(other, Permission):
+ return NotImplemented
+ return self.level < other.level
+
+ def __str__(self) -> str:
+ """
+ 返回权限的字符串表示(即权限名称)
+ """
+ return self.name
+
+
+# 定义全局权限常量
+ADMIN = Permission("admin", 3)
+OP = Permission("op", 2)
+USER = Permission("user", 1)
+
+# 用于从字符串名称查找权限对象的字典
+_PERMISSIONS: Dict[str, Permission] = {
+ p.name: p for p in [ADMIN, OP, USER]
+}
+
+
+class PermissionManager:
+ """
+ 权限管理器类
+
+ 负责加载、保存和查询用户权限数据。
+ 使用单例模式,确保全局只有一个权限管理器实例。
+ """
+
+ _instance = None
+
+ def __new__(cls):
+ """
+ 单例模式实现
+
+ Returns:
+ PermissionManager: 全局唯一的权限管理器实例
+ """
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ cls._instance._initialized = False
+ return cls._instance
+
+ def __init__(self):
+ """
+ 初始化权限管理器
+
+ 如果已经初始化过,则直接返回。
+ """
+ if getattr(self, "_initialized", False):
+ return
+
+ # 权限数据文件路径
+ self.data_file = os.path.join(
+ os.path.dirname(os.path.abspath(__file__)),
+ "..",
+ "data",
+ "permissions.json"
+ )
+
+ # 确保数据目录存在
+ data_dir = os.path.dirname(self.data_file)
+ os.makedirs(data_dir, exist_ok=True)
+
+ # 权限数据存储结构:{"users": {"user_id": "level_name"}}
+ self._data: Dict[str, Dict[str, str]] = {"users": {}}
+
+ # 加载现有数据
+ self.load()
+
+ self._initialized = True
+ logger.info("权限管理器初始化完成")
+
+ def load(self) -> None:
+ """
+ 从文件加载权限数据
+
+ 如果文件不存在,则创建空文件并初始化默认数据结构。
+ """
+ try:
+ if os.path.exists(self.data_file):
+ with open(self.data_file, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ # 兼容旧格式
+ if "users" in data:
+ self._data["users"] = data["users"]
+ else:
+ self._data["users"] = {}
+ logger.debug(f"权限数据已从 {self.data_file} 加载")
+ else:
+ # 文件不存在,创建空文件
+ self.save()
+ logger.debug(f"创建空的权限数据文件: {self.data_file}")
+ except json.JSONDecodeError as e:
+ logger.error(f"权限数据文件格式错误: {e}")
+ # 文件损坏,重置为空数据
+ self._data["users"] = {}
+ self.save()
+ except Exception as e:
+ logger.error(f"加载权限数据失败: {e}")
+ self._data["users"] = {}
+
+ def save(self) -> None:
+ """
+ 将权限数据保存到文件
+ """
+ try:
+ with open(self.data_file, "w", encoding="utf-8") as f:
+ json.dump(self._data, f, indent=2, ensure_ascii=False)
+ logger.debug(f"权限数据已保存到 {self.data_file}")
+ except Exception as e:
+ logger.error(f"保存权限数据失败: {e}")
+
+ async def get_user_permission(self, user_id: int) -> Permission:
+ """
+ 获取指定用户的权限对象
+
+ Args:
+ user_id (int): 用户 QQ 号
+
+ Returns:
+ Permission: 用户的权限对象,如果用户不存在则返回默认级别 USER
+ """
+ # 首先,通过 AdminManager 检查是否为管理员
+ if await admin_manager.is_admin(user_id):
+ return ADMIN
+
+ # 如果不是管理员,则从 permissions.json 中查找
+ user_id_str = str(user_id)
+ level_name = self._data["users"].get(user_id_str, USER.name)
+ return _PERMISSIONS.get(level_name, USER)
+
+ def set_user_permission(self, user_id: int, permission: Permission) -> None:
+ """
+ 设置指定用户的权限级别
+
+ Args:
+ user_id (int): 用户 QQ 号
+ permission (Permission): 权限对象
+
+ Raises:
+ ValueError: 如果权限对象无效
+ """
+ if not isinstance(permission, Permission) or permission.name not in _PERMISSIONS:
+ raise ValueError(f"无效的权限对象: {permission}")
+
+ user_id_str = str(user_id)
+ self._data["users"][user_id_str] = permission.name
+ self.save()
+ logger.info(f"设置用户 {user_id} 的权限级别为 {permission.name}")
+
+ def remove_user(self, user_id: int) -> None:
+ """
+ 移除指定用户的权限设置,恢复为默认级别
+
+ Args:
+ user_id (int): 用户 QQ 号
+ """
+ user_id_str = str(user_id)
+ if user_id_str in self._data["users"]:
+ del self._data["users"][user_id_str]
+ self.save()
+ logger.info(f"移除用户 {user_id} 的权限设置")
+
+ async def check_permission(self, user_id: int, required_permission: Permission) -> bool:
+ """
+ 检查用户是否具有指定权限级别
+
+ Args:
+ user_id (int): 用户 QQ 号
+ required_permission (Permission): 所需的权限对象
+
+ Returns:
+ bool: 如果用户权限 >= 所需权限,返回 True,否则返回 False
+ """
+ user_permission = await self.get_user_permission(user_id)
+ return user_permission >= required_permission
+
+ def get_all_users(self) -> Dict[str, str]:
+ """
+ 获取所有设置了权限的用户及其级别名称
+
+ Returns:
+ Dict[str, str]: 用户ID到权限级别名称的映射
+ """
+ return self._data["users"].copy()
+
+ def clear_all(self) -> None:
+ """
+ 清空所有权限设置
+ """
+ self._data["users"].clear()
+ self.save()
+ logger.info("已清空所有权限设置")
+
+
+# 全局权限管理器实例
+permission_manager = PermissionManager()
\ No newline at end of file
diff --git a/core/plugin_manager.py b/core/plugin_manager.py
index a80a197..8b0e7f1 100644
--- a/core/plugin_manager.py
+++ b/core/plugin_manager.py
@@ -11,7 +11,9 @@ import pkgutil
import sys
from core.command_manager import matcher
+from core.exceptions import SyncHandlerError
from .logger import logger
+from .executor import run_in_thread_pool
def load_all_plugins():
@@ -49,6 +51,8 @@ def load_all_plugins():
type_str = "包" if is_pkg else "文件"
logger.success(f" [{type_str}] 成功{action}: {module_name}")
+ except SyncHandlerError as e:
+ logger.error(f" 插件 {module_name} 加载失败: {e} (跳过此插件)")
except Exception as e:
print(
f" {action if 'action' in locals() else '加载'}插件 {module_name} 失败: {e}"
@@ -75,50 +79,48 @@ class PluginDataManager:
self.plugin_name + ".json",
)
self.data = {}
- self.load()
- def load(self):
+ async def load(self):
"""读取配置文件"""
if not os.path.exists(self.data_file):
- with open(self.data_file, "w", encoding="utf-8") as f:
- self.set(self.plugin_name, [])
+ await self.set(self.plugin_name, [])
try:
with open(self.data_file, "r", encoding="utf-8") as f:
- self.data = json.load(f)
+ self.data = await run_in_thread_pool(json.load, f)
except json.JSONDecodeError:
self.data = {}
- def save(self):
+ async def save(self):
"""保存配置到文件"""
with open(self.data_file, "w", encoding="utf-8") as f:
- json.dump(self.data, f, indent=2, ensure_ascii=False)
+ await run_in_thread_pool(json.dump, self.data, f, indent=2, ensure_ascii=False)
def get(self, key, default=None):
"""获取配置项"""
return self.data.get(key, default)
- def set(self, key, value):
+ async def set(self, key, value):
"""设置配置项"""
self.data[key] = value
- self.save()
+ await self.save()
- def add(self, key, value):
+ async def add(self, key, value):
"""添加配置项"""
if key not in self.data:
self.data[key] = []
self.data[key].append(value)
- self.save()
+ await self.save()
- def remove(self, key):
+ async def remove(self, key):
"""删除配置项"""
if key in self.data:
del self.data[key]
- self.save()
+ await self.save()
- def clear(self):
+ async def clear(self):
"""清空所有配置"""
self.data.clear()
- self.save()
+ await self.save()
def get_all(self):
return self.data.copy()
diff --git a/core/redis_manager.py b/core/redis_manager.py
index 3786d9c..7440a22 100644
--- a/core/redis_manager.py
+++ b/core/redis_manager.py
@@ -1,20 +1,24 @@
-import redis
+import redis.asyncio as redis
from .config_loader import global_config as config
from .logger import logger
class RedisManager:
"""
- Redis 连接管理器
+ Redis 连接管理器(异步单例)
"""
- _pool = None
- _client = None
+ _instance = None
+ _redis = None
- @classmethod
- def initialize(cls):
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ async def initialize(self):
"""
- 初始化 Redis 连接并进行健康检查
+ 异步初始化 Redis 连接并进行健康检查
"""
- if cls._pool is None:
+ if self._redis is None:
try:
host = config.redis['host']
port = config.redis['port']
@@ -23,39 +27,32 @@ class RedisManager:
logger.info(f"正在尝试连接 Redis: {host}:{port}, DB: {db}")
- cls._pool = redis.ConnectionPool(
+ self._redis = redis.Redis(
host=host,
port=port,
db=db,
password=password,
decode_responses=True
)
- cls._client = redis.Redis(connection_pool=cls._pool)
- if cls._client.ping():
+ if await self._redis.ping():
logger.success("Redis 连接成功!")
else:
logger.error("Redis 连接失败: PING 命令无响应")
except redis.exceptions.ConnectionError as e:
logger.error(f"Redis 连接失败: {e}")
- cls._pool = None
- cls._client = None
+ self._redis = None
except Exception as e:
logger.exception(f"Redis 初始化时发生未知错误: {e}")
- cls._pool = None
- cls._client = None
+ self._redis = None
- @classmethod
- def get_redis(cls):
+ @property
+ def redis(self):
"""
- 获取 Redis 连接
-
- :return: Redis 连接实例
+ 获取 Redis 连接实例
"""
- if cls._client is None:
- # 理论上 initialize 应该在程序启动时被调用,这里作为备用
- cls.initialize()
- return cls._client
+ if self._redis is None:
+ raise ConnectionError("Redis 未初始化或连接失败,请先调用 initialize()")
+ return self._redis
-# 在模块加载时直接初始化
-RedisManager.initialize()
-redis_client = RedisManager.get_redis()
+# 全局 Redis 管理器实例
+redis_manager = RedisManager()
diff --git a/data/admin.json b/data/admin.json
new file mode 100644
index 0000000..577c240
--- /dev/null
+++ b/data/admin.json
@@ -0,0 +1,3 @@
+{
+ "admins": [2221577113]
+}
\ No newline at end of file
diff --git a/data/permissions.json b/data/permissions.json
new file mode 100644
index 0000000..864ddb4
--- /dev/null
+++ b/data/permissions.json
@@ -0,0 +1,3 @@
+{
+ "users": {}
+}
\ No newline at end of file
diff --git a/html/404.html b/html/404.html
new file mode 100644
index 0000000..be035f3
--- /dev/null
+++ b/html/404.html
@@ -0,0 +1,288 @@
+
+
404 - Signal Lost
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 404
+
+
+ Sys.Malfunction
+ 0x00_DEAD
+
+
+
+
+
+
+
+
+
root@neobot:~/system/logs
+
+
+
+
+
+
+
> initiating_handshake...
> resolving_host: calglaubot.internal
> connection_established (port: 443)
> GET /requested_resource HTTP/1.1
> waiting_for_response...
> FATAL: endpoint_not_found
> stack_trace_dump:
> at Router.resolve (core.js:204)
> at Neobot.Handler (main.py:404)
> error: signal_lost_in_void
+
+ ➜
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CPU: 98%
+ |
+ MEM: OVERFLOW
+
+
+ NEOBOT FRAMEWORK
+
+
+
\ No newline at end of file
diff --git a/html/index.html b/html/index.html
new file mode 100644
index 0000000..4eba739
--- /dev/null
+++ b/html/index.html
@@ -0,0 +1,387 @@
+
+
+
+
+
+ NEOBOT | F.O.S FRAMEWORK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🚀 HIGH PERFORMANCE ASYNC FRAMEWORK
+
+
+
+
+
+ 基于 Python 异步生态构建的 OneBot 11 解决方案。内置 Redis 缓存、插件热重载与类型安全检查。这是我的第一个 Python 作品,致力于极致的开发体验。
+
+
+
+
+
Core Team
+
+
+

+
+

+
+
Fairy-Oracle-Sanctuary
+
+
+
+
+
+ $ git clone ...
+
+
+
+
+
+
+
+
+
+
+
+
+
为什么选择 NEO?
+
不仅仅是一个框架,更是一套完整的现代化开发解决方案。
+
+
+
+
+
+
+
+
+
高性能异步 IO
+
+ 基于 Python 原生 asyncio 和 websockets 构建。完全非阻塞设计,单进程即可轻松处理海量并发消息,拒绝卡顿。
+
+
+
+
+
+
+
+
+
智能插件热重载
+
+ 基于 watchdog 实现文件监控。修改代码后自动重载插件逻辑,无需重启机器人进程。让调试和开发效率提升 200%。
+
+
+
+
+
+
+
+
+
Redis 深度集成
+
+ 内置 Redis 连接池。自动缓存群信息、好友列表等高频数据,减少 API 调用延迟,让响应速度快人一步。
+
+
+
+
+
+
+
+
+
类型安全
+
+ 全面采用 Pydantic 和 Dataclasses。为所有事件和数据模型提供完整的类型注解,IDE 智能补全,减少运行时错误。
+
+
+
+
+
+
+
+
+
精细权限管理
+
+ 内置 Admin/Op/User 三级权限体系。支持动态添加管理员,通过装饰器即可轻松控制每个指令的访问权限。
+
+
+
+
+
+
+
+
+
标准 OneBot 11
+
+ 完美兼容 OneBot v11 协议标准。支持 NapCatQQ、LLOneBot 等主流实现端,无缝对接,开箱即用。
+
+
+
+
+
+
+
+
+
+
TERMINAL OUTPUT
+
+
+
+
+
+
+
+
+
+
+
性能建议:使用 PyPy
+
+ 为了获得最佳性能,我们强烈推荐使用 PyPy JIT 编译器 来运行 NEO 框架。在处理高并发消息时,PyPy 相比标准 CPython 能提供显著的性能提升。
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/main.py b/main.py
index d4c723c..5b15394 100644
--- a/main.py
+++ b/main.py
@@ -13,8 +13,11 @@ from watchdog.events import FileSystemEventHandler
# 初始化日志系统,必须在其他 core 模块导入之前执行
from core.logger import logger
+from core.admin_manager import admin_manager
from core.ws import WS
from core.plugin_manager import load_all_plugins
+from core.redis_manager import redis_manager
+from core.executor import run_in_thread_pool
class PluginReloadHandler(FileSystemEventHandler):
@@ -62,7 +65,7 @@ class PluginReloadHandler(FileSystemEventHandler):
try:
# 重新扫描并加载插件
- load_all_plugins()
+ run_in_thread_pool(load_all_plugins)
logger.success("插件重载完成")
except Exception as e:
logger.exception(f"重载失败: {e}")
@@ -78,7 +81,13 @@ async def main():
3. 建立连接并保持运行
"""
# 首次加载插件
- load_all_plugins()
+ await run_in_thread_pool(load_all_plugins)
+
+ # 初始化 Redis 连接
+ await redis_manager.initialize()
+
+ # 初始化管理员管理器
+ await admin_manager.initialize()
# 启动文件监控
# 监控 plugins 目录
diff --git a/models/events/message.py b/models/events/message.py
index 3ede724..febb1d9 100644
--- a/models/events/message.py
+++ b/models/events/message.py
@@ -6,6 +6,7 @@
from dataclasses import dataclass, field
from typing import List, Optional
+from core.permission_manager import ADMIN, OP, USER
from models.message import MessageSegment
from models.sender import Sender
from .base import OneBotEvent, EventType
@@ -32,6 +33,11 @@ class MessageEvent(OneBotEvent):
消息事件基类
"""
+ # 权限级别常量,用于装饰器参数
+ ADMIN = ADMIN
+ OP = OP
+ USER = USER
+
message_type: str
"""消息类型: private (私聊), group (群聊)"""
diff --git a/plugins/admin.py b/plugins/admin.py
index 8e1b0e6..2d4854d 100644
--- a/plugins/admin.py
+++ b/plugins/admin.py
@@ -1,115 +1,74 @@
-from core import PluginDataManager
+"""
+管理员管理插件
+
+提供通过聊天指令动态添加或移除机器人管理员的功能。
+"""
from core.bot import Bot
from core.command_manager import matcher
-from models import GroupMessageEvent
+from core.admin_manager import admin_manager
+from models.events.message import MessageEvent
__plugin_meta__ = {
- "name": "admin",
- "description": "机器人权限管理插件",
- "usage": "/admin",
+ "name": "管理员管理",
+ "description": "管理机器人的全局管理员",
+ "usage": (
+ "/admin list - 列出所有管理员\n"
+ "/admin add - 添加管理员\n"
+ "/admin remove - 移除管理员"
+ ),
}
-data = PluginDataManager("admin")
+@matcher.command("admin", permission=MessageEvent.ADMIN)
+async def handle_admin_command(bot: Bot, event: MessageEvent, args: list[str]):
+ """
+ 处理 /admin 指令
-@matcher.command("admin")
-async def handle_permission(bot: Bot, event: GroupMessageEvent, args: list[str]):
+ :param bot: Bot 实例
+ :param event: 消息事件实例
+ :param args: 指令参数列表
+ """
if not args:
- await event.reply(
- "机器人权限管理插件指令:\n/admin list 列出所有权限\n/admin add member 添加群成员权限\n/admin remove member 删除群成员权限\n/admin add group <群号> 添加群权限\n/admin remove group <群号> 删除群权限\n/admin clear member 清空群成员权限\n/admin clear group 清空群权限\n/admin clear all 清空所有权限"
- )
- return
-
- if str(event.user_id) not in data.get("members", []):
- await event.reply("你没有权限使用此命令。")
- return
- if str(event.group_id) not in data.get("groups", []):
- await event.reply("群聊不在权限中")
+ await event.reply(__plugin_meta__["usage"])
return
action = args[0].lower()
- # ensure storage keys exist
- members = data.get("members", []) or []
- groups = data.get("groups", []) or []
-
if action == "list":
- msg_lines = ["当前权限列表:"]
- msg_lines.append(
- f"群成员权限 ({len(members)}): {', '.join(members) if members else '无'}"
- )
- msg_lines.append(
- f"群权限 ({len(groups)}): {', '.join(groups) if groups else '无'}"
- )
- await event.reply("\n".join(msg_lines))
+ admins = await admin_manager.get_all_admins()
+ if not admins:
+ await event.reply("当前没有设置任何管理员。")
+ return
+
+ admin_list_str = "\n".join(str(admin_id) for admin_id in admins)
+ await event.reply(f"当前管理员列表 ({len(admins)}):\n{admin_list_str}")
return
if action in ("add", "remove"):
- if len(args) < 3:
- await event.reply("参数错误,示例:/admin add member 123456")
+ if len(args) < 2 or not args[1].isdigit():
+ await event.reply("参数错误,请提供一个有效的 QQ 号。\n示例: /admin add 123456")
return
- target = args[1].lower()
- value = args[2]
-
- if target == "member":
- # operate on members list
- if action == "add":
- if str(value) in members:
- await event.reply(f"成员 {value} 已存在,无需重复添加。")
- return
- members.append(str(value))
- data.set("members", members)
- await event.reply(f"已添加群成员权限:{value}")
- return
- else: # remove
- if str(value) not in members:
- await event.reply(f"成员 {value} 不在权限列表中。")
- return
- members = [m for m in members if m != str(value)]
- data.set("members", members)
- await event.reply(f"已移除群成员权限:{value}")
- return
-
- if target == "group":
- if action == "add":
- if str(value) in groups:
- await event.reply(f"群 {value} 已存在,无需重复添加。")
- return
- groups.append(str(value))
- data.set("groups", groups)
- await event.reply(f"已添加群权限:{value}")
- return
- else: # remove
- if str(value) not in groups:
- await event.reply(f"群 {value} 不在权限列表中。")
- return
- groups = [g for g in groups if g != str(value)]
- data.set("groups", groups)
- await event.reply(f"已移除群权限:{value}")
- return
-
- await event.reply("未知目标类型,请使用 member 或 group")
- return
-
- if action == "clear":
- if len(args) < 2:
- await event.reply("参数错误,示例:/admin clear member")
+ try:
+ user_id = int(args[1])
+ except ValueError:
+ await event.reply("无效的 QQ 号,请输入纯数字。")
return
- target = args[1].lower()
- if target == "member":
- data.set("members", [])
- await event.reply("已清空群成员权限。")
- return
- if target == "group":
- data.set("groups", [])
- await event.reply("已清空群权限。")
- return
- if target == "all":
- data.clear()
- await event.reply("已清空所有权限。")
- return
- await event.reply("未知清空目标,请使用 member/group/all")
- return
- await event.reply("未知指令,使用 /admin 查看帮助")
+ 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
+
+ await event.reply(f"未知的指令: {action}\n\n{__plugin_meta__['usage']}")
diff --git a/plugins/code_py.py b/plugins/code_py.py
index cb53f2b..b595354 100644
--- a/plugins/code_py.py
+++ b/plugins/code_py.py
@@ -13,6 +13,7 @@ from typing import Tuple, Set
from core.bot import Bot
from core.command_manager import matcher
+from core.executor import run_in_thread_pool
from models import MessageEvent
__plugin_meta__ = {
@@ -38,9 +39,11 @@ def is_code_safe(code: str) -> Tuple[bool, str]:
statements = STATEMENT_SPLIT_PATTERN.split(code)
for statement in statements:
statement = statement.strip()
- if not statement: continue
+ if not statement:
+ continue
parts = statement.split()
- if not parts: continue
+ if not parts:
+ continue
if parts[0] == 'from' and len(parts) > 1:
module_name = parts[1].strip()
if module_name in DANGEROUS_MODULES:
@@ -83,7 +86,7 @@ async def process_and_reply(bot: Bot, event: MessageEvent, code: str):
"""
核心处理逻辑:安全检查、执行代码并回复结果。
"""
- safe, message = is_code_safe(code)
+ safe, message = await run_in_thread_pool(is_code_safe, code)
if not safe:
await event.reply(f"代码安全检查未通过:\n{message}")
return
@@ -150,11 +153,11 @@ async def handle_code_input(bot: Bot, event: MessageEvent):
# 处理取消操作
if event.raw_message.strip() == "取消":
await event.reply("已取消输入。")
- return True # 消费事件
+ return True # 消费事件
# 执行代码
await process_and_reply(bot, event, event.raw_message)
- return True # 消费事件,防止被其他指令匹配
+ return True # 消费事件,防止被其他指令匹配
# 如果用户不在等待状态,则不处理
return False
diff --git a/plugins/data/admin.json b/plugins/data/admin.json
deleted file mode 100644
index 9e26dfe..0000000
--- a/plugins/data/admin.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
\ No newline at end of file
diff --git a/plugins/echo.py b/plugins/echo.py
index 24a997d..34a7f22 100644
--- a/plugins/echo.py
+++ b/plugins/echo.py
@@ -29,18 +29,26 @@ async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
await event.reply(reply_msg)
-@matcher.command("赞我")
-async def handle_poke(bot: Bot, event: MessageEvent, args: list[str]):
+@matcher.command(
+ "赞我",
+ permission=MessageEvent.ADMIN,
+ override_permission_check=True
+)
+async def handle_poke(bot: Bot, event: MessageEvent, permission_granted: bool):
"""
处理 赞我 指令,发送点赞
:param bot: Bot 实例
:param event: 消息事件对象
- :param args: 指令参数列表(本指令不使用参数)
+ :param permission_granted: 权限检查结果
"""
+ if not permission_granted:
+ await event.reply("只有我的操作员才能让我点赞哦!(。•ˇ‸ˇ•。)")
+ return
+
try:
# 尝试发送赞
await bot.send_like(event.user_id, times=10)
- await event.reply("戳一戳发送成功!")
+ await event.reply("好感度+10!(〃'▽'〃)")
except Exception as e:
- await event.reply(f"戳一戳发送失败:{str(e)}")
\ No newline at end of file
+ await event.reply(f"点赞失败了 >_<: {str(e)}")
\ No newline at end of file
diff --git a/plugins/jrcd.py b/plugins/jrcd.py
index d4df757..f1d4767 100644
--- a/plugins/jrcd.py
+++ b/plugins/jrcd.py
@@ -9,6 +9,7 @@ from datetime import datetime
from core.bot import Bot
from core.command_manager import matcher
+from core.executor import run_in_thread_pool
from models import MessageEvent, MessageSegment
__plugin_meta__ = {
@@ -77,7 +78,7 @@ async def handle_jrcd(bot: Bot, event: MessageEvent, args: list[str]):
:param args: 指令参数列表(未使用)。
"""
user_id = event.user_id
- jrcd = get_jrcd(user_id)
+ jrcd = await run_in_thread_pool(get_jrcd, user_id)
msg = [MessageSegment.at(user_id)]
if jrcd <= 9:
msg.append(MessageSegment.text(random.choice(JRCDMSG_1) % jrcd))
@@ -112,8 +113,8 @@ async def handle_bbcd(bot: Bot, event: MessageEvent, args: list[str]):
await event.reply("不能和自己比!")
return
- jrcd1 = get_jrcd(user_id1)
- jrcd2 = get_jrcd(user_id2)
+ jrcd1 = await run_in_thread_pool(get_jrcd, user_id1)
+ jrcd2 = await run_in_thread_pool(get_jrcd, user_id2)
jrcz = jrcd1 - jrcd2
diff --git a/plugins/sync_async_test_plugin.py b/plugins/sync_async_test_plugin.py
new file mode 100644
index 0000000..b863959
--- /dev/null
+++ b/plugins/sync_async_test_plugin.py
@@ -0,0 +1,88 @@
+"""
+同步/异步函数测试插件
+
+用于演示 SyncHandlerError 异常以及如何将同步函数放入线程池执行。
+"""
+import time
+from typing import Any
+from core.command_manager import matcher
+from core.executor import run_in_thread_pool
+from core.bot import Bot
+from core.logger import logger
+
+# 插件元数据
+__plugin_meta__ = {
+ "name": "SyncAsyncTestPlugin",
+ "description": "用于测试同步/异步函数处理的插件。",
+ "usage": (
+ "/test_sync_error - 尝试注册一个同步函数作为异步处理器,会触发错误。\n"
+ "/test_blocking_task - 演示将同步阻塞任务放入线程池执行。"
+ ),
+}
+
+# --- 示例 1: 触发 SyncHandlerError (此函数不会被成功注册) ---
+
+# 这是一个同步函数,如果直接用 @matcher.message_handler 装饰,
+# 并且 event_handler 检查到它是同步的,就会抛出 SyncHandlerError。
+# 注意:为了演示错误,我们不会真正注册它,因为注册会失败。
+def _sync_function_that_should_fail(bot: Bot, event: Any):
+ """
+ 一个同步函数,如果直接作为异步事件处理器注册,会触发 SyncHandlerError。
+ """
+ logger.info("这个同步函数不应该被直接调用。")
+ return "这是一个同步函数的结果。"
+
+# --- 示例 2: 将同步阻塞任务放入线程池运行 ---
+
+def _blocking_task(duration: int) -> str:
+ """
+ 一个模拟耗时操作的同步函数。
+ Args:
+ duration (int): 模拟阻塞的秒数。
+ Returns:
+ str: 任务完成消息。
+ """
+ logger.info(f"同步阻塞任务开始,持续 {duration} 秒...")
+ time.sleep(duration)
+ logger.info("同步阻塞任务结束。")
+ return f"阻塞任务完成,耗时 {duration} 秒。"
+
+@matcher.message_handler.command("test_blocking_task")
+async def test_blocking_task_handler(bot: Bot, event: Any, args: list):
+ """
+ 处理 /test_blocking_task 命令,将同步阻塞任务放入线程池执行。
+ Args:
+ bot (Bot): 机器人实例。
+ event (Any): 接收到的事件对象。
+ args (list): 命令参数列表。
+ """
+ if not args:
+ await bot.send(event, "请提供阻塞时长,例如:/test_blocking_task 5")
+ return
+
+ try:
+ duration = int(args[0])
+ if duration <= 0:
+ raise ValueError("时长必须是正整数。")
+ except ValueError:
+ await bot.send(event, "无效的时长,请提供一个正整数。")
+ return
+
+ await bot.send(event, f"开始执行同步阻塞任务,预计耗时 {duration} 秒...")
+
+ # 将同步函数放入线程池执行
+ result = await run_in_thread_pool(_blocking_task, duration)
+
+ await bot.send(event, f"同步阻塞任务已完成:{result}")
+
+# --- 示例 3: 尝试注册一个同步函数作为异步处理器 (会失败) ---
+# 这个函数不会被成功注册,因为 event_handler 会检测到它是同步的并抛出 SyncHandlerError。
+# 插件管理器会捕获这个错误并跳过加载此插件。
+# 为了演示,我们故意尝试注册它。
+# @matcher.message_handler.command("test_sync_error")
+# def test_sync_error_handler(bot: Bot, event: Any):
+# """
+# 这个同步函数尝试作为异步处理器注册,会触发 SyncHandlerError。
+# """
+# logger.error("这个同步函数不应该被直接注册为异步处理器。")
+# return "这个消息不应该被看到。"
From a733d3dc4ba3760bbb4f40957830c786a4ba4876 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=95=80=E9=93=AC=E9=85=B8=E9=92=BE?=
<148796996+K2cr2O1@users.noreply.github.com>
Date: Sun, 4 Jan 2026 22:21:35 +0800
Subject: [PATCH 11/46] =?UTF-8?q?feat:=20=E6=95=B4=E5=90=88=E5=BC=80?=
=?UTF-8?q?=E5=8F=91=E5=8E=86=E5=8F=B2=20(#20)=EF=BC=8C=E5=A4=A7=E6=9B=B4?=
=?UTF-8?q?=E6=96=B0=E3=80=82=E3=80=82=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 2 +-
README.md | 680 ++++++++++++++++++++++++------
core/admin_manager.py | 166 ++++++++
core/api/account.py | 2 +-
core/api/friend.py | 10 +-
core/api/group.py | 10 +-
core/command_manager.py | 293 ++++---------
core/event_handler.py | 197 +++++++++
core/exceptions.py | 9 +
core/executor.py | 27 ++
core/permission_manager.py | 252 +++++++++++
core/plugin_manager.py | 32 +-
core/redis_manager.py | 51 ++-
data/admin.json | 3 +
data/permissions.json | 3 +
html/404.html | 288 +++++++++++++
html/index.html | 387 +++++++++++++++++
main.py | 13 +-
models/events/message.py | 6 +
plugins/admin.py | 147 +++----
plugins/code_py.py | 13 +-
plugins/data/admin.json | 1 -
plugins/echo.py | 18 +-
plugins/jrcd.py | 7 +-
plugins/sync_async_test_plugin.py | 88 ++++
25 files changed, 2199 insertions(+), 506 deletions(-)
create mode 100644 core/admin_manager.py
create mode 100644 core/event_handler.py
create mode 100644 core/exceptions.py
create mode 100644 core/executor.py
create mode 100644 core/permission_manager.py
create mode 100644 data/admin.json
create mode 100644 data/permissions.json
create mode 100644 html/404.html
create mode 100644 html/index.html
delete mode 100644 plugins/data/admin.json
create mode 100644 plugins/sync_async_test_plugin.py
diff --git a/.gitignore b/.gitignore
index 093255f..2729cb2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -138,4 +138,4 @@ dmypy.json
# pytype static type analyzer
.pytype/
-# End of https://www.toptal.com/developers/gitignore/api/python
\ No newline at end of file
+# End of https://www.toptal.com/developers/gitignore/api/python
diff --git a/README.md b/README.md
index f8ee6ff..99b7656 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ NEO 框架的设计遵循以下核心理念:
* **类型安全**:基于 `dataclasses` 的强类型事件模型,开发体验更佳。
* **插件系统**:轻量级的装饰器风格插件系统,支持指令 (`@matcher.command`) 和事件监听 (`@matcher.on_notice`, `@matcher.on_request`)。
* **插件元数据与内置帮助**:插件可通过 `__plugin_meta__` 变量进行自我描述。框架核心内置了 `/help` 指令,可自动收集并展示所有插件的帮助信息,无需手动维护。
-* **🔥 热重载支持**:内置文件监控,修改 `base_plugins` 下的代码自动重载,无需重启,极大提升调试效率。
+* **🔥 热重载支持**:内置文件监控,修改 `plugins` 下的代码自动重载,无需重启,极大提升调试效率。
* **异步核心**:基于 `asyncio` 和 `websockets` 的高性能异步核心。
* **自动重连**:内置 WebSocket 断线重连机制。
@@ -131,43 +131,112 @@ latest_group_info = await bot.get_group_info(group_id=12345, no_cache=True)
### 其他改进
- [x] **API 强类型封装**: 将 API 返回值从 `dict` 转换为数据模型对象。
- [x] **Redis 支持**: 集成 Redis 连接池,便于插件复用连接。
-- [ ] **日志系统优化**: 引入更完善的日志记录机制,支持文件输出和日志级别控制。
-- [ ] **异常处理增强**: 增强插件执行过程中的异常捕获,防止单个插件崩溃影响整个 Bot。
-- [ ] **中间件支持**: 添加消息处理中间件,支持在指令执行前/后进行拦截和处理。
-- [ ] **权限系统**: 实现基础的权限管理(如超级管理员、群管理员等)。
+- [x] **权限系统**: 实现基础的权限管理(超级管理员、群管理员等)。
+- [x] **日志系统优化**: 引入 `loguru` 进行日志记录,支持文件输出和日志级别控制。
+- [x] **异常处理增强**: 增强插件执行过程中的异常捕获,防止单个插件崩溃影响整个 Bot。
+- [x] **中间件支持**: 添加消息处理中间件,支持在指令执行前/后进行拦截和处理。
## 📂 项目结构
```
-NEO/
-├── plugins/ # 插件目录,新建插件文件即可自动加载(支持热重载)
-│ ├── echo.py # 示例插件:实现 /echo 和 /赞我 指令
-│ ├── forward_test.py # 示例插件:演示合并转发消息的构建和发送
-│ ├── jrcd.py # 娱乐插件:提供 /jrcd 和 /bbcd 指令
-│ └── thpic.py # 图片插件:提供 /thpic 指令,发送随机东方图片
-├── core/ # 核心框架代码
-│ ├── api/ # API 模块抽象层 (MessageAPI, GroupAPI, FriendAPI, AccountAPI)
-│ │ ├── __init__.py
-│ │ ├── account.py
-│ │ ├── base.py
-│ │ ├── friend.py
-│ │ ├── group.py
-│ │ └── message.py
-│ ├── bot.py # Bot API 封装,提供 send_group_msg 等方法
-│ ├── command_manager.py # 命令与事件分发器
-│ ├── config_loader.py # 配置加载器
-│ ├── plugin_manager.py # 插件加载与管理
-│ ├── redis_manager.py # Redis 连接管理器
-│ └── ws.py # WebSocket 客户端核心
-├── models/ # 数据模型
-│ ├── events/ # OneBot 事件定义 (Message, Notice, Request, Meta)
-│ ├── message.py # 消息段定义 (MessageSegment)
-│ └── sender.py # 发送者定义 (Sender)
-├── config.toml # 配置文件
-├── main.py # 启动入口(包含热重载监控)
-└── requirements.txt # 项目依赖
+.
+├── plugins/ # 插件目录,新建插件文件即可自动加载(支持热重载)
+│ ├── admin.py # 管理员插件
+│ ├── code_py.py # Python 代码执行插件
+│ ├── echo.py # 示例插件:实现 /echo 和 /赞我 指令
+│ ├── forward_test.py # 示例插件:演示合并转发消息
+│ ├── jrcd.py # 娱乐插件:/jrcd 和 /bbcd
+│ └── thpic.py # 图片插件:/thpic
+├── core/ # 核心框架代码
+│ ├── api/ # API 模块抽象层
+│ ├── bot.py # Bot 实例与 API 封装
+│ ├── admin_manager.py # 管理员管理模块
+│ ├── command_manager.py # 命令与事件分发器
+│ ├── config_loader.py # 配置加载器
+│ ├── event_handler.py # 事件处理器
+│ ├── executor.py # 插件执行器
+│ ├── logger.py # 日志系统
+│ ├── permission_manager.py # 权限管理器
+│ ├── plugin_manager.py # 插件加载与管理
+│ ├── redis_manager.py # Redis 连接管理器
+│ └── ws.py # WebSocket 客户端核心
+├── data/ # 数据存储目录
+│ ├── admin.json # 管理员配置文件
+│ └── permissions.json # 权限数据
+├── html/ # HTML 静态文件
+│ ├── 404.html
+│ └── index.html
+├── models/ # 数据模型
+│ ├── events/ # OneBot 事件定义
+│ ├── message.py # 消息段定义
+│ ├── objects.py # API 返回对象定义
+│ └── sender.py # 发送者定义
+├── .gitignore
+├── config.toml # 配置文件
+├── main.py # 启动入口(包含热重载监控)
+└── requirements.txt # 项目依赖
```
+### 目录结构详细说明
+
+#### `plugins/` - 插件目录
+- **功能**存放:所有机器人插件,支持热重载机制
+- **加载机制**:框架会自动扫描此目录下的所有 `.py` 文件,并作为插件加载
+- **插件约定**:每个插件文件应包含 `__plugin_meta__` 字典用于插件元数据定义
+- **热重载**:开发过程中修改插件文件会自动触发重载,无需重启机器人
+- **内置插件**:
+ - `admin.py` - 管理员管理插件,支持动态添加/移除管理员
+ - `code_py.py` - Python 代码执行插件,支持安全的代码执行环境
+ - `echo.py` - 示例插件,演示基本指令处理
+ - `forward_test.py` - 合并转发消息演示插件
+ - `jrcd.py` - 娱乐插件,提供 `/jrcd` 和 `/bbcd` 指令
+ - `thpic.py` - 图片插件,提供 `/thpic` 指令返回东方Project图片
+
+#### `core/` - 核心框架代码
+- `api/` - API 模块抽象层
+ - `base.py` - API 基类定义
+ - `message.py` - 消息相关 API 封装
+ - `group.py` - 群组管理 API 封装
+ - `friend.py` - 好友相关 API 封装
+ - `account.py` - 账号相关 API 封装
+- `bot.py` - Bot 核心类,通过 Mixin 模式继承所有 API 功能,提供统一的调用接口
+ - `admin_manager.py` - 管理员管理模块,负责管理员的添加、移除和权限验证
+ - `command_manager.py` - 命令与事件分发器,负责注册和处理所有指令和事件
+- `config_loader.py` - 配置加载器,读取和解析 `config.toml` 配置文件
+- `event_handler.py` - 事件处理器,负责将原始事件转换为类型化事件对象
+- `executor.py` - 插件执行器,提供线程池执行环境用于执行同步任务
+- `logger.py` - 日志系统,基于 `loguru` 提供高性能日志记录
+- `permission_manager.py` - 权限管理器,管理用户权限级别(admin、op、user)
+- `plugin_manager.py` - 插件加载与管理,负责插件的扫描、加载和热重载
+- `redis_manager.py` - Redis 连接管理器,提供异步 Redis 客户端连接池
+- `ws.py` - WebSocket 客户端核心,负责与 OneBot 实现端建立和管理连接
+
+#### `data/` - 数据存储目录
+- `admin.json` - 管理员配置文件,存储全局管理员列表
+- `permissions.json` - 权限数据文件,存储用户权限映射关系
+
+#### `html/` - HTML 静态文件
+- `404.html` - 404 错误页面
+- `index.html` - 项目主页 HTML 文件,展示项目信息和特性
+
+#### `models/` - 数据模型定义
+- `events/` - OneBot 事件定义
+ - `base.py` - 事件基类定义
+ - `message.py` - 消息事件定义
+ - `notice.py` - 通知事件定义
+ - `request.py` - 请求事件定义
+ - `meta.py` - 元事件定义
+ - `factory.py` - 事件工厂类,用于根据 JSON 数据创建对应事件对象
+- `message.py` - 消息段定义,支持文本、图片、表情等多种消息类型
+- `objects.py` - API 返回对象定义,提供强类型化的 API 响应数据模型
+- `sender.py` - 发送者定义,包含用户、群成员等信息
+
+#### 根目录文件
+- `.gitignore` - Git 忽略文件配置
+- `config.toml` - 主配置文件,包含 WebSocket 连接、机器人指令前缀、Redis 连接等配置
+- `main.py` - 程序入口文件,负责初始化插件、启动热重载监控和建立 WebSocket 连接
+- `requirements.txt` - Python 依赖包列表
+
## 🚀 快速开始
### 1. 环境准备
@@ -207,13 +276,13 @@ python main.py
项目集成了 `watchdog` 文件监控。在开发过程中,你只需要:
1. 保持 `main.py` 运行。
-2. 修改或新建 `base_plugins` 目录下的 `.py` 插件文件。
+2. 修改或新建 `plugins` 目录下的 `.py` 插件文件。
3. 保存文件。
4. 控制台会自动提示 `[HotReload] 插件重载完成`,新的逻辑立即生效。
### 创建新插件
-在 `base_plugins` 目录下创建一个新的 `.py` 文件(例如 `my_plugin.py`),框架会自动加载它。
+在 `plugins` 目录下创建一个新的 `.py` 文件(例如 `my_plugin.py`),框架会自动加载它。
### 示例代码
@@ -333,7 +402,7 @@ async def get_group_info_legacy(bot: Bot, event: MessageEvent, args: list[str]):
**示例:**
```python
-# base_plugins/echo.py
+# plugins/echo.py
__plugin_meta__ = {
"name": "回声与交互",
@@ -407,10 +476,184 @@ async def dangerous_command(bot: Bot, event: MessageEvent, args: list[str]):
except Exception as e:
await event.reply(f"执行失败:{str(e)}")
# 记录日志
- import logging
- logging.error(f"插件执行错误:{e}", exc_info=True)
+ from core.logger import logger
+ logger.error(f"插件执行错误:{e}", exc_info=True)
```
+### 处理同步阻塞操作
+为了保持机器人的响应性,所有可能导致长时间阻塞的同步操作都应该在单独的线程池中执行。框架提供了 `run_in_thread_pool` 函数来简化这一过程。
+
+**示例:执行同步阻塞任务**
+```python
+from core.command_manager import matcher
+from core.bot import Bot
+from models import MessageEvent
+from core.executor import run_in_thread_pool
+import time
+
+# 模拟一个耗时的同步操作
+def blocking_task(duration: int):
+ time.sleep(duration)
+ return f"阻塞任务完成,耗时 {duration} 秒"
+
+@matcher.command("block_test")
+async def handle_blocking_test(bot: Bot, event: MessageEvent, args: list[str]):
+ if not args or not args[0].isdigit():
+ await event.reply("请提供一个数字作为阻塞时间(秒)。例如:/block_test 5")
+ return
+
+ duration = int(args[0])
+ await event.reply(f"开始执行阻塞任务,耗时 {duration} 秒...")
+
+ # 将同步阻塞任务放入线程池执行
+ result = await run_in_thread_pool(blocking_task, duration)
+ await event.reply(result)
+```
+
+### 权限管理
+框架内置了基于用户角色的权限管理系统,支持 `admin`(超级管理员)、`op`(操作员)、`user`(普通用户)三个权限级别。权限数据存储在 `data/permissions.json` 文件中。
+
+#### 权限级别说明
+- **admin**:最高权限,可以执行所有管理命令,包括添加/移除其他管理员
+- **op**:操作员权限,可以执行大部分管理命令,但不能修改管理员列表
+- **user**:普通用户权限,只能使用基础功能
+
+#### 在插件中使用权限控制
+注册命令时可以通过 `permission` 参数指定所需权限级别:
+
+```python
+from models import MessageEvent
+
+# 只有管理员可以执行此命令
+@matcher.command("admin_only", permission=MessageEvent.ADMIN)
+async def admin_command(bot: Bot, event: MessageEvent, args: list[str]):
+ await event.reply("此命令仅限管理员使用")
+
+# 操作员及以上权限可以执行
+@matcher.command("op_only", permission=MessageEvent.OP)
+async def op_command(bot: Bot, event: MessageEvent, args: list[str]):
+ await event.reply("此命令需要操作员权限")
+
+# 所有用户都可以执行(默认)
+@matcher.command("public")
+async def public_command(bot: Bot, event: MessageEvent, args: list[str]):
+ await event.reply("所有用户都可以使用此命令")
+```
+
+#### 动态权限检查
+如果需要更复杂的权限逻辑,可以使用 `override_permission_check=True` 参数,然后在函数中手动检查权限:
+
+```python
+@matcher.command(
+ "special",
+ permission=MessageEvent.OP,
+ override_permission_check=True
+)
+async def special_command(bot: Bot, event: MessageEvent, permission_granted: bool):
+ if not permission_granted:
+ await event.reply("权限不足!")
+ return
+
+ # 额外的权限逻辑
+ if event.user_id == 123456:
+ await event.reply("特殊用户,允许执行")
+ else:
+ await event.reply("普通用户,拒绝执行")
+```
+
+### 使用 Redis 进行数据缓存
+框架集成了 Redis 客户端,提供了便捷的异步接口用于数据缓存和持久化。Redis 连接管理器会自动管理连接池,你可以在插件中直接使用。
+
+#### 基本用法
+```python
+from core.redis_manager import redis_manager
+
+@matcher.command("cache")
+async def cache_example(bot: Bot, event: MessageEvent, args: list[str]):
+ # 设置缓存
+ await redis_manager.set("user:123:name", "张三")
+
+ # 获取缓存
+ name = await redis_manager.get("user:123:name")
+
+ # 设置带过期时间的缓存(单位:秒)
+ await redis_manager.setex("temp:data", 3600, "临时数据")
+
+ # 删除缓存
+ await redis_manager.delete("user:123:name")
+
+ await event.reply(f"用户名:{name}")
+```
+
+#### 使用哈希表(Hash)
+```python
+# 设置哈希字段
+await redis_manager.hset("user:123", "age", 20)
+await redis_manager.hset("user:123", "city", "北京")
+
+# 获取哈希字段
+age = await redis_manager.hget("user:123", "age")
+user_data = await redis_manager.hgetall("user:123")
+
+# 删除哈希字段
+await redis_manager.hdel("user:123", "city")
+```
+
+#### 使用列表(List)
+```python
+# 向列表添加元素
+await redis_manager.lpush("recent:actions", "login")
+await redis_manager.rpush("recent:actions", "logout")
+
+# 获取列表范围
+actions = await redis_manager.lrange("recent:actions", 0, 9)
+
+# 获取列表长度
+length = await redis_manager.llen("recent:actions")
+```
+
+### 插件数据管理
+对于需要持久化存储配置或数据的插件,框架提供了 `PluginDataManager` 类,可以方便地管理 JSON 格式的数据文件。
+
+#### 基本用法
+```python
+from core.plugin_manager import PluginDataManager
+
+# 初始化数据管理器
+data_manager = PluginDataManager("weather_plugin")
+
+@matcher.command("weather_set")
+async def set_weather_config(bot: Bot, event: MessageEvent, args: list[str]):
+ if len(args) < 2:
+ await event.reply("用法:/weather_set <城市> <温度>")
+ return
+
+ city = args[0]
+ temperature = args[1]
+
+ # 保存配置
+ await data_manager.set(city, temperature)
+ await event.reply(f"已设置 {city} 的温度为 {temperature}℃")
+
+@matcher.command("weather_get")
+async def get_weather_config(bot: Bot, event: MessageEvent, args: list[str]):
+ if not args:
+ await event.reply("用法:/weather_get <城市>")
+ return
+
+ city = args[0]
+
+ # 读取配置
+ temperature = data_manager.get(city)
+ if temperature:
+ await event.reply(f"{city} 的温度是 {temperature}℃")
+ else:
+ await event.reply(f"未找到 {city} 的温度配置")
+```
+
+#### 数据文件位置
+插件数据文件保存在 `plugins/data/` 目录下,每个插件对应一个独立的 JSON 文件。例如 `weather_plugin` 插件的数据文件为 `plugins/data/weather_plugin.json`。
+
### 插件开发最佳实践
1. **单一职责**:每个插件专注于一个功能领域
2. **错误处理**:妥善处理可能发生的异常
@@ -530,97 +773,290 @@ async def welcome_new_member(bot: Bot, event):
## 📚 事件模型说明
-项目采用了基于工厂模式的事件处理系统,所有事件定义在 `models/events/` 下:
+NEO 框架的事件模型是基于 OneBot v11 协议的强类型数据模型,采用 `dataclasses` 和类型注解构建。所有事件都继承自 `OneBotEvent` 基类,并通过事件工厂自动从 JSON 数据创建对应的事件对象。
-* **MessageEvent**: 消息事件,包含 `PrivateMessageEvent` 和 `GroupMessageEvent`。支持 `await event.reply()` 快速回复。
-* **NoticeEvent**: 通知事件,如 `FriendAddNoticeEvent`, `GroupRecallNoticeEvent` 等。
-* **RequestEvent**: 请求事件,如 `FriendRequestEvent`, `GroupRequestEvent`。
-* **MetaEvent**: 元事件,如心跳 `HeartbeatEvent`。
-
-所有事件均继承自 `OneBotEvent`,并包含 `bot` 属性用于调用 API。
-
-## 🏗️ 技术架构
-
-NEO 框架采用分层架构设计,各层职责明确,便于维护和扩展:
-
-### 架构层次
-
-1. **通信层 (WebSocket Client)**
- - 负责与 OneBot 实现端的 Web Socket连接
- - 实现断线自动重连机制
- - 处理原始消息的收发和协议解析
-
-2. **API 抽象层 (API Mixins)**
- - 提供类型安全的 API 封装
- - 按功能领域划分:消息、群组、好友、账号
- - 所有 API 返回强类型数据模型对象
-
-3. **业务逻辑层 (Bot & Command Manager)**
- - Bot 类组合所有 API 功能
- - 指令和事件分发器
- - 插件加载和管理
-
-4. **插件层 (Plugins)**
- - 支持热重载的插件系统
- - 装饰器风格的注册方式
- - 独立的业务逻辑模块
-
-5. **数据模型层 (Models)**
- - 基于 dataclasses 的事件模型
- - API 响应数据模型
- - 类型安全的序列化/反序列化
-
-### 核心组件交互
+### 事件层次结构
```
-┌─────────────────────────────────────┐
-│ 插件层 (Plugins) │
-│ @matcher.command() │
-│ @matcher.on_notice() │
-└──────────────┬──────────────────────┘
- │
-┌──────────────▼──────────────────────┐
-│ 业务逻辑层 (Command Manager) │
-│ • 事件分发与路由 │
-│ • 指令参数解析 │
-└──────────────┬──────────────────────┘
- │
-┌──────────────▼──────────────────────┐
-│ Bot 组合类 │
-│ • 继承所有 API Mixin │
-│ • 提供统一接口 │
-└──────────────┬──────────────────────┘
- │
-┌──────────────▼──────────────────────┐
-│ API 抽象层 (Mixin) │
-│ • MessageAPI │
-│ • GroupAPI │
-│ • FriendAPI │
-│ • AccountAPI │
-└──────────────┬──────────────────────┘
- │
-┌──────────────▼──────────────────────┐
-│ 通信层 (WebSocket) │
-│ • 连接管理 │
-│ • 消息编解码 │
-│ • 断线重连 │
-└─────────────────────────────────────┘
+OneBotEvent (抽象基类)
+├── MetaEvent (元事件)
+│ ├── HeartbeatEvent (心跳事件)
+│ └── LifeCycleEvent (生命周期事件)
+├── MessageEvent (消息事件)
+│ ├── PrivateMessageEvent (私聊消息事件)
+│ └── GroupMessageEvent (群聊消息事件)
+├── NoticeEvent (通知事件)
+│ ├── FriendAddNoticeEvent (好友添加通知)
+│ ├── FriendRecallNoticeEvent (好友消息撤回通知)
+│ ├── GroupRecallNoticeEvent (群消息撤回通知)
+│ ├── GroupIncreaseNoticeEvent (群成员增加通知)
+│ ├── GroupDecreaseNoticeEvent (群成员减少通知)
+│ ├── GroupAdminNoticeEvent (群管理员变动通知)
+│ ├── GroupBanNoticeEvent (群禁言通知)
+│ ├── GroupUploadNoticeEvent (群文件上传通知)
+│ ├── PokeNotifyEvent (戳一戳通知)
+│ ├── LuckyKingNotifyEvent (运气王通知)
+│ ├── HonorNotifyEvent (群荣誉变更通知)
+│ ├── GroupCardNoticeEvent (群成员名片更新通知)
+│ ├── OfflineFileNoticeEvent (离线文件通知)
+│ ├── ClientStatusNoticeEvent (客户端状态变更通知)
+│ └── EssenceNoticeEvent (精华消息变动通知)
+└── RequestEvent (请求事件)
+ ├── FriendRequestEvent (加好友请求)
+ └── GroupRequestEvent (加群请求/邀请)
```
-### 设计模式应用
+### 事件基类:OneBotEvent
-- **工厂模式**:事件对象的创建和管理
-- **装饰器模式**:插件和指令的注册
-- **组合模式**:Bot 类通过继承组合 API 功能
-- **观察者模式**:事件监听和处理
-- **策略模式**:不同的消息处理策略
+所有事件的基类,定义了事件的通用属性和方法:
-### 性能特点
+```python
+@dataclass(slots=True)
+class OneBotEvent(ABC):
+ """
+ OneBot v11 事件的抽象基类。
+
+ Attributes:
+ time (int): 事件发生的时间戳 (秒)
+ self_id (int): 收到事件的机器人 QQ 号
+ _bot (Optional[Bot]): 内部持有的 Bot 实例引用
+ """
+ time: int
+ self_id: int
+ _bot: Optional["Bot"] = field(default=None, init=False)
+
+ @property
+ @abstractmethod
+ def post_type(self) -> str:
+ """事件的上报类型,子类必须重写此属性"""
+ pass
+
+ @property
+ def bot(self) -> "Bot":
+ """获取与此事件关联的 Bot 实例"""
+ if self._bot is None:
+ raise ValueError("Bot instance not set for this event")
+ return self._bot
+
+ @bot.setter
+ def bot(self, value: "Bot"):
+ """为事件对象设置关联的 Bot 实例"""
+ self._bot = value
+```
-- **异步非阻塞**:全面基于 asyncio,支持高并发
-- **内存高效**:事件和模型对象使用 dataclasses,内存占用小
-- **快速响应**:插件热重载和事件分发机制确保快速响应
-- **可扩展性**:模块化设计便于功能扩展和定制
+### 事件类型常量
----
-*Internal Use Only - DOGSOHA ond baby2016 by Fairy-Oracle-Sanctuary*
+框架定义了完整的事件类型常量,用于标识不同种类的事件:
+
+```python
+class EventType:
+ META = 'meta_event' # 元事件:心跳、生命周期等
+ REQUEST = 'request ' # 请求事件:加好友请求、加群请求等
+ NOTICE = 'notice' # 通知事件:群成员增加、文件上传等
+ MESSAGE = 'message' # 消息事件:私聊消息、群消息等
+ MESSAGE_SENT = 'message_sent' # 消息发送事件:机器人自己发送消息的上报
+```
+
+### 消息事件
+
+消息事件是机器人最常处理的事件类型,框架提供了完整的消息段支持和便捷的回复方法:
+
+#### MessageEvent (消息事件基类)
+
+```python
+@dataclass
+class MessageEvent(OneBotEvent):
+ message_type: str # 消息类型: private (私聊), group (群聊)
+ sub_type: str # 消息子类型
+ message_id: int # 消息 ID
+ user_id: int # 发送者 QQ 号
+ message: List[MessageSegment] # 消息内容列表
+ raw_message: str # 原始消息内容
+ font: int # 字体
+ sender: Optional[Sender] # 发送者信息
+
+ @property
+ def post_type(self) -> str:
+ return EventType.MESSAGE
+
+ async def reply(self, message: str, auto_escape: bool = False):
+ """回复消息(抽象方法,由子类实现)"""
+ raise NotImplementedError
+```
+
+#### PrivateMessageEvent (私聊消息事件)
+
+```python
+@dataclass
+class PrivateMessageEvent(MessageEvent):
+ async def reply(self, message: str, auto_escape: bool = False):
+ """回复私聊消息"""
+ await self.bot.send_private_msg(
+ user_id=self.user_id, message=message, auto_escape=auto_escape
+ )
+```
+
+#### GroupMessageEvent (群聊消息事件)
+
+```python
+@dataclass
+class GroupMessageEvent(MessageEvent):
+ group_id: int = 0 # 群号
+ anonymous: Optional[Anonymous] = None # 匿名信息
+
+ async def reply(self, message: str, auto_escape: bool = False):
+ """回复群聊消息"""
+ await self.bot.send_group_msg(
+ group_id=self.group_id, message=message, auto_escape=auto_escape
+ )
+```
+
+### 通知事件
+
+通知事件用于处理各种系统通知,如群成员变动、文件上传等:
+
+#### 常用通知事件示例
+
+```python
+@dataclass
+class GroupIncreaseNoticeEvent(GroupNoticeEvent):
+ """群成员增加通知"""
+ operator_id: int = 0 # 操作者 QQ 号
+ sub_type: str = "" # 子类型: approve (管理员同意入群), invite (管理员邀请入群)
+
+@dataclass
+class GroupRecallNoticeEvent(GroupNoticeEvent):
+ """群消息撤回通知"""
+ operator_id: int = 0 # 操作者 QQ 号
+ message_id: int = 0 # 被撤回的消息 ID
+
+@dataclass
+class PokeNotifyEvent(NotifyNoticeEvent):
+ """戳一戳通知"""
+ target_id: int = 0 # 被戳者 QQ 号
+ group_id: int = 0 # 群号 (如果是群内戳一戳)
+```
+
+### 请求事件
+
+请求事件用于处理用户的主动请求,如加好友、加群等:
+
+```python
+@dataclass
+class FriendRequestEvent(RequestEvent):
+ """加好友请求事件"""
+ user_id: int = 0 # 发送请求的 QQ 号
+ comment: str = "" # 验证信息
+ flag: str = "" # 请求 flag,用于 API 调用
+
+@dataclass
+class GroupRequestEvent(RequestEvent):
+ """加群请求/邀请事件"""
+ sub_type: str = "" # 子类型: add (加群请求), invite (邀请登录号入群)
+ group_id: int = 0 # 群号
+ user_id: int = 0 # 发送请求的 QQ 号
+ comment: str = "" # 验证信息
+ flag: str = "" # 请求 flag,用于 API 调用
+```
+
+### 元事件
+
+元事件用于处理框架自身状态变化,如心跳、生命周期等:
+
+```python
+@dataclass
+class HeartbeatEvent(MetaEvent):
+ """心跳事件,用于确认连接状态"""
+ meta_event_type: str = 'heartbeat'
+ status: HeartbeatStatus = field(default_factory=HeartbeatStatus)
+ interval: int = 0 # 心跳间隔时间(ms)
+
+@dataclass
+class LifeCycleEvent(MetaEvent):
+ """生命周期事件,用于通知框架生命周期变化"""
+ meta_event_type: str = 'lifecycle'
+ sub_type: LifeCycleSubType = LifeCycleSubType.ENABLE # 子类型: enable, disable, connect
+```
+
+### 事件工厂:EventFactory
+
+事件工厂是框架的核心组件之一,负责将原始 JSON 数据转换为强类型的事件对象:
+
+```python
+class EventFactory:
+ @staticmethod
+ def create_event(data: Dict[str, Any]) -> OneBotEvent:
+ """根据数据创建事件对象"""
+ post_type = data.get("post_type")
+
+ if post_type == EventType.MESSAGE or post_type == EventType.MESSAGE_SENT:
+ return EventFactory._create_message_event(data, common_args)
+ elif post_type == EventType.NOTICE:
+ return EventFactory._create_notice_event(data, common_args)
+ elif post_type == EventType.REQUEST:
+ return EventFactory._create_request_event(data, common_args)
+ elif post_type == EventType.META:
+ return EventFactory._create_meta_event(data, common_args)
+ else:
+ raise ValueError(f"Unknown event type: {post_type}")
+```
+
+### 在插件中使用事件
+
+插件可以直接使用这些事件类型来处理各种场景:
+
+```python
+from core.command_manager import matcher
+from core.bot import Bot
+from models import GroupMessageEvent, PrivateMessageEvent
+from models.events.notice import GroupIncreaseNoticeEvent
+from models.events.request import FriendRequestEvent
+
+# 处理群消息事件
+@matcher.command("hello")
+async def handle_hello(bot: Bot, event: GroupMessageEvent, args: list[str]):
+ await event.reply(f"你好 {event.sender.nickname}!")
+
+# 处理私聊消息事件
+@matcher.command("help", permission_level=MessageEvent.USER)
+async def handle_help(bot: Bot, event: PrivateMessageEvent, args: list[str]):
+ await event.reply("这里是帮助信息...")
+
+# 处理群成员增加通知
+@matcher.on_notice("group_increase")
+async def handle_group_increase(bot: Bot, event: GroupIncreaseNoticeEvent):
+ await bot.send_group_msg(
+ event.group_id,
+ f"欢迎新成员 {event.user_id} 加入!操作者:{event.operator_id}"
+ )
+
+# 处理加好友请求
+@matcher.on_request("friend")
+async def handle_friend_request(bot: Bot, event: FriendRequestEvent):
+ # 自动同意所有好友请求
+ await bot.set_friend_add_request(flag=event.flag, approve=True)
+ await bot.send_private_msg(event.user_id, "已通过您的好友请求!")
+```
+
+### 事件处理的优势
+
+1. **类型安全**:所有事件都有明确的类型定义,IDE 可以提供完整的代码提示和补全
+2. **易于测试**:事件对象可以轻松构造,便于编写单元测试
+3. **数据完整**:所有字段都有类型注解,确保数据的一致性和完整性
+4. **性能优化**:使用 `@dataclass(slots=True)` 减少内存占用,提高属性访问速度
+5. **可扩展性**:可以轻松定义自定义事件类型,扩展框架功能
+
+### 常用事件属性速查
+
+| 事件类型 | 关键属性 | 描述 |
+|---------|---------|------|
+| **MessageEvent** | `message_type`, `user_id`, `message`, `sender` | 所有消息事件的基类 |
+| **PrivateMessageEvent** | 继承自 MessageEvent | 私聊消息事件 |
+| **GroupMessageEvent** | `group_id`, `anonymous` | 群聊消息事件,包含群号和匿名信息 |
+| **GroupIncreaseNoticeEvent** | `group_id`, `user_id`, `operator_id`, `sub_type` | 群成员增加通知 |
+| **RecallGroupNoticeEvent** | `group_id`, `user_id`, `operator_id`, `message_id` | 群消息撤回通知 |
+| **FriendRequestEvent** | `user_id`, `comment`, `flag` | 加好友请求事件 |
+| **GroupRequestEvent** | `group_id`, `user_id`, `sub_type`, `comment`, `flag` | 加群请求/邀请事件 |
+| **HeartbeatEvent** | `status`, `interval` | 心跳事件,用于监控连接状态 |
+
+通过这套完整的事件模型,NEO 框架为开发者提供了强大而灵活的事件处理能力,同时保持了代码的类型安全和良好的开发体验。
diff --git a/core/admin_manager.py b/core/admin_manager.py
new file mode 100644
index 0000000..864df52
--- /dev/null
+++ b/core/admin_manager.py
@@ -0,0 +1,166 @@
+"""
+管理员管理器模块
+
+该模块负责管理机器人的管理员列表。
+它实现了文件和 Redis 缓存之间的数据同步,并提供了一套清晰的 API
+供其他模块调用。
+"""
+import json
+import os
+from typing import Set
+
+from .logger import logger
+
+
+class AdminManager:
+ """
+ 管理员管理器类
+
+ 负责加载、缓存和管理管理员列表。
+ 使用单例模式,确保全局只有一个实例。
+ """
+ _instance = None
+ _REDIS_KEY = "neobot:admins" # 用于存储管理员集合的 Redis 键
+
+ def __new__(cls):
+ """
+ 单例模式实现
+ """
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ cls._instance._initialized = False
+ return cls._instance
+
+ def __init__(self):
+ """
+ 初始化 AdminManager
+ """
+ if getattr(self, "_initialized", False):
+ return
+
+ # 管理员数据文件路径
+ self.data_file = os.path.join(
+ os.path.dirname(os.path.abspath(__file__)),
+ "..",
+ "data",
+ "admin.json"
+ )
+
+ self._admins: Set[int] = set()
+ self._initialized = True
+ logger.info("管理员管理器初始化完成")
+
+ async def initialize(self):
+ """
+ 异步初始化,加载数据并同步到 Redis
+ """
+ await self._load_from_file()
+ await self._sync_to_redis()
+ logger.info("管理员数据加载并同步到 Redis 完成")
+
+ async def _load_from_file(self):
+ """
+ 从 admin.json 加载管理员列表
+ """
+ try:
+ if os.path.exists(self.data_file):
+ with open(self.data_file, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ admins = data.get("admins", [])
+ self._admins = set(int(admin_id) for admin_id in admins)
+ logger.debug(f"从 {self.data_file} 加载了 {len(self._admins)} 位管理员")
+ else:
+ # 如果文件不存在,创建一个空的
+ self._admins = set()
+ await self._save_to_file()
+ except (json.JSONDecodeError, ValueError) as e:
+ logger.error(f"加载或解析 admin.json 失败: {e}")
+ self._admins = set()
+
+ async def _save_to_file(self):
+ """
+ 将当前管理员列表保存回 admin.json
+ """
+ try:
+ # 确保目录存在
+ os.makedirs(os.path.dirname(self.data_file), exist_ok=True)
+ # 将 set 转换为 list 以便 JSON 序列化
+ admin_list = [str(admin_id) for admin_id in self._admins]
+ with open(self.data_file, "w", encoding="utf-8") as f:
+ json.dump({"admins": admin_list}, f, indent=2, ensure_ascii=False)
+ logger.debug(f"管理员列表已保存到 {self.data_file}")
+ except Exception as e:
+ logger.error(f"保存 admin.json 失败: {e}")
+
+ async def _sync_to_redis(self):
+ """
+ 将内存中的管理员集合同步到 Redis
+ """
+ from .redis_manager import redis_manager
+ try:
+ # 首先清空旧的集合
+ await redis_manager.redis.delete(self._REDIS_KEY)
+ if self._admins:
+ # 将所有管理员ID添加到集合中
+ await redis_manager.redis.sadd(self._REDIS_KEY, *self._admins)
+ logger.debug(f"已将 {len(self._admins)} 位管理员同步到 Redis")
+ except Exception as e:
+ logger.error(f"同步管理员到 Redis 失败: {e}")
+
+ async def is_admin(self, user_id: int) -> bool:
+ """
+ 检查用户是否为管理员(从 Redis 缓存读取)
+ """
+ from .redis_manager import redis_manager
+ try:
+ return await redis_manager.redis.sismember(self._REDIS_KEY, user_id)
+ except Exception as e:
+ logger.error(f"从 Redis 检查管理员权限失败: {e}")
+ # Redis 失败时,回退到内存检查
+ return user_id in self._admins
+
+ async def add_admin(self, user_id: int) -> bool:
+ """
+ 添加管理员,并同步到文件和 Redis
+ """
+ from .redis_manager import redis_manager
+ if user_id in self._admins:
+ return False # 用户已经是管理员
+
+ self._admins.add(user_id)
+ await self._save_to_file()
+ try:
+ await redis_manager.redis.sadd(self._REDIS_KEY, user_id)
+ logger.info(f"已添加新管理员 {user_id} 并更新缓存")
+ return True
+ except Exception as e:
+ logger.error(f"添加管理员 {user_id} 到 Redis 失败: {e}")
+ return False
+
+ async def remove_admin(self, user_id: int) -> bool:
+ """
+ 移除管理员,并同步到文件和 Redis
+ """
+ from .redis_manager import redis_manager
+ if user_id not in self._admins:
+ return False # 用户不是管理员
+
+ self._admins.remove(user_id)
+ await self._save_to_file()
+ try:
+ await redis_manager.redis.srem(self._REDIS_KEY, user_id)
+ logger.info(f"已移除管理员 {user_id} 并更新缓存")
+ return True
+ except Exception as e:
+ logger.error(f"从 Redis 移除管理员 {user_id} 失败: {e}")
+ return False
+
+ async def get_all_admins(self) -> Set[int]:
+ """
+ 获取所有管理员的集合
+ """
+ return self._admins.copy()
+
+
+# 全局 AdminManager 实例
+admin_manager = AdminManager()
diff --git a/core/api/account.py b/core/api/account.py
index 0a5ef0d..07bab8d 100644
--- a/core/api/account.py
+++ b/core/api/account.py
@@ -8,7 +8,7 @@ import json
from typing import Dict, Any
from .base import BaseAPI
from models.objects import LoginInfo, VersionInfo, Status
-from core.redis_manager import redis_client as redis_manager
+from core.redis_manager import redis_manager
class AccountAPI(BaseAPI):
diff --git a/core/api/friend.py b/core/api/friend.py
index 76e696b..823fa06 100644
--- a/core/api/friend.py
+++ b/core/api/friend.py
@@ -8,7 +8,7 @@ import json
from typing import List, Dict, Any
from .base import BaseAPI
from models.objects import FriendInfo, StrangerInfo
-from core.redis_manager import redis_client as redis_manager
+from core.redis_manager import redis_manager
class FriendAPI(BaseAPI):
@@ -42,12 +42,12 @@ class FriendAPI(BaseAPI):
"""
cache_key = f"neobot:cache:get_stranger_info:{user_id}"
if not no_cache:
- cached_data = await redis_manager.get(cache_key)
+ cached_data = await redis_manager.redis.get(cache_key)
if cached_data:
return StrangerInfo(**json.loads(cached_data))
res = await self.call_api("get_stranger_info", {"user_id": user_id, "no_cache": no_cache})
- await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
+ await redis_manager.redis.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
return StrangerInfo(**res)
async def get_friend_list(self, no_cache: bool = False) -> List[FriendInfo]:
@@ -62,12 +62,12 @@ class FriendAPI(BaseAPI):
"""
cache_key = f"neobot:cache:get_friend_list:{self.self_id}"
if not no_cache:
- cached_data = await redis_manager.get(cache_key)
+ cached_data = await redis_manager.redis.get(cache_key)
if cached_data:
return [FriendInfo(**item) for item in json.loads(cached_data)]
res = await self.call_api("get_friend_list")
- await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
+ await redis_manager.redis.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
return [FriendInfo(**item) for item in res]
async def set_friend_add_request(self, flag: str, approve: bool = True, remark: str = "") -> Dict[str, Any]:
diff --git a/core/api/group.py b/core/api/group.py
index d4eb5ae..224d148 100644
--- a/core/api/group.py
+++ b/core/api/group.py
@@ -6,7 +6,7 @@
"""
from typing import List, Dict, Any, Optional
import json
-from core.redis_manager import redis_client as redis_manager
+from core.redis_manager import redis_manager
from .base import BaseAPI
from models.objects import GroupInfo, GroupMemberInfo, GroupHonorInfo
@@ -178,12 +178,12 @@ class GroupAPI(BaseAPI):
"""
cache_key = f"neobot:cache:get_group_info:{group_id}"
if not no_cache:
- cached_data = await redis_manager.get(cache_key)
+ cached_data = await redis_manager.redis.get(cache_key)
if cached_data:
return GroupInfo(**json.loads(cached_data))
res = await self.call_api("get_group_info", {"group_id": group_id})
- await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
+ await redis_manager.redis.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
return GroupInfo(**res)
async def get_group_list(self) -> List[GroupInfo]:
@@ -210,12 +210,12 @@ class GroupAPI(BaseAPI):
"""
cache_key = f"neobot:cache:get_group_member_info:{group_id}:{user_id}"
if not no_cache:
- cached_data = await redis_manager.get(cache_key)
+ cached_data = await redis_manager.redis.get(cache_key)
if cached_data:
return GroupMemberInfo(**json.loads(cached_data))
res = await self.call_api("get_group_member_info", {"group_id": group_id, "user_id": user_id})
- await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
+ await redis_manager.redis.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
return GroupMemberInfo(**res)
async def get_group_member_list(self, group_id: int) -> List[GroupMemberInfo]:
diff --git a/core/command_manager.py b/core/command_manager.py
index ec875b2..c51794a 100644
--- a/core/command_manager.py
+++ b/core/command_manager.py
@@ -4,19 +4,12 @@
该模块定义了 `CommandManager` 类,它是整个机器人框架事件处理的核心。
它通过装饰器模式,为插件提供了注册消息指令、通知事件处理器和
请求事件处理器的能力。
-
-主要职责:
-- 提供 `@matcher.command()` 装饰器来注册命令。
-- 提供 `@matcher.on_notice()` 装饰器来注册通知处理器。
-- 提供 `@matcher.on_request()` 装饰器来注册请求处理器。
-- 负责解析收到的消息,匹配命令前缀并分发给对应的处理器。
-- 统一处理所有类型的事件,并将其分发给所有已注册的处理器。
-- 内置一个 `/help` 命令,用于展示所有已加载插件的帮助信息。
"""
-import inspect
-from typing import Any, Callable, Dict, List, Tuple
+from typing import Any, Callable, Dict, Optional, Tuple
from .config_loader import global_config
+from .event_handler import MessageHandler, NoticeHandler, RequestHandler
+
# 从配置中获取命令前缀
comm_prefixes = global_config.bot.get("command", ("/",))
@@ -27,6 +20,7 @@ class CommandManager:
命令管理器,负责注册和分发所有类型的事件。
这是一个单例对象(`matcher`),在整个应用中共享。
+ 它将不同类型的事件处理委托给专门的处理器类。
"""
def __init__(self, prefixes: Tuple[str, ...]):
@@ -36,51 +30,91 @@ class CommandManager:
Args:
prefixes (Tuple[str, ...]): 一个包含所有合法命令前缀的元组。
"""
- # --- 初始化所有处理器列表 ---
- self.prefixes = prefixes
- self.commands: Dict[str, Callable] = {}
- self.message_handlers: List[Callable] = []
- self.notice_handlers: List[Dict] = []
- self.request_handlers: List[Dict] = []
self.plugins: Dict[str, Dict[str, Any]] = {}
+
+ # 初始化专门的事件处理器
+ self.message_handler = MessageHandler(prefixes)
+ self.notice_handler = NoticeHandler()
+ self.request_handler = RequestHandler()
- # --- 注册内置指令 ---
- self.commands["help"] = self._help_command
+ # 将处理器映射到事件类型
+ self.handler_map = {
+ "message": self.message_handler,
+ "notice": self.notice_handler,
+ "request": self.request_handler,
+ }
+
+ # 注册内置的 /help 命令
+ self._register_internal_commands()
+
+ def _register_internal_commands(self):
+ """
+ 注册框架内置的命令
+ """
+ # Help 命令
+ self.message_handler.command("help")(self._help_command)
self.plugins["core.help"] = {
"name": "帮助",
"description": "显示所有可用指令的帮助信息",
"usage": "/help",
}
+ # --- 装饰器代理 ---
+
def on_message(self) -> Callable:
"""
- 装饰器:用于注册一个通用的消息处理器。
-
- 被此装饰器注册的函数,会在每次收到消息时(在指令匹配前)被调用。
- 如果函数返回 True,则表示该消息已被“消费”,后续的指令匹配将不会进行。
-
- Example:
- @matcher.on_message()
- async def code_input_handler(bot, event):
- if is_waiting_for_code(event.user_id):
- await process_code(event.raw_message)
- return True # 消费事件
+ 装饰器:注册一个通用的消息处理器。
"""
- def decorator(func: Callable) -> Callable:
- self.message_handlers.append(func)
- return func
- return decorator
+ return self.message_handler.on_message()
+ def command(
+ self,
+ name: str,
+ permission: Optional[Any] = None,
+ override_permission_check: bool = False
+ ) -> Callable:
+ """
+ 装饰器:注册一个消息指令处理器。
+ """
+ return self.message_handler.command(
+ name,
+ permission=permission,
+ override_permission_check=override_permission_check
+ )
+
+ def on_notice(self, notice_type: Optional[str] = None) -> Callable:
+ """
+ 装饰器:注册一个通知事件处理器。
+ """
+ return self.notice_handler.register(notice_type=notice_type)
+
+ def on_request(self, request_type: Optional[str] = None) -> Callable:
+ """
+ 装饰器:注册一个请求事件处理器。
+ """
+ return self.request_handler.register(request_type=request_type)
+
+ # --- 事件处理 ---
+
+ async def handle_event(self, bot, event):
+ """
+ 统一的事件分发入口。
+
+ 根据事件的 `post_type` 将其分发给对应的处理器。
+ """
+ if event.post_type == 'message' and global_config.bot.get('ignore_self_message', False):
+ if hasattr(event, 'user_id') and hasattr(event, 'self_id') and event.user_id == event.self_id:
+ return
+
+ handler = self.handler_map.get(event.post_type)
+ if handler:
+ await handler.handle(bot, event)
+
+ # --- 内置命令实现 ---
async def _help_command(self, bot, event):
"""
内置的 `/help` 命令的实现。
-
- 该命令会遍历所有已加载插件的元数据,并生成一段格式化的帮助文本。
-
- Args:
- bot: Bot 实例。
- event: 消息事件对象。
"""
help_text = "--- 可用指令列表 ---\n"
@@ -95,187 +129,6 @@ class CommandManager:
await bot.send(event, help_text.strip())
- def command(self, name: str) -> Callable:
- """
- 装饰器:用于注册一个消息指令处理器。
-
- Example:
- @matcher.command("echo")
- async def handle_echo(bot, event, args):
- await bot.send(event, " ".join(args))
-
- Args:
- name (str): 指令的名称(不包含命令前缀)。
-
- Returns:
- Callable: 原函数,使其可以继续被调用。
- """
-
- def decorator(func: Callable) -> Callable:
- self.commands[name] = func
- return func
-
- return decorator
-
- def on_notice(self, notice_type: str = None) -> Callable:
- """
- 装饰器:用于注册一个通知事件处理器。
-
- 如果 `notice_type` 未指定,则该处理器会接收所有类型的通知事件。
-
- Args:
- notice_type (str, optional): 要处理的通知类型 (e.g., "group_increase")。
- Defaults to None.
-
- Returns:
- Callable: 原函数。
- """
-
- def decorator(func: Callable) -> Callable:
- self.notice_handlers.append({"type": notice_type, "func": func})
- return func
-
- return decorator
-
- def on_request(self, request_type: str = None) -> Callable:
- """
- 装饰器:用于注册一个请求事件处理器。
-
- 如果 `request_type` 未指定,则该处理器会接收所有类型的请求事件。
-
- Args:
- request_type (str, optional): 要处理的请求类型 (e.g., "friend", "group")。
- Defaults to None.
-
- Returns:
- Callable: 原函数。
- """
-
- def decorator(func: Callable) -> Callable:
- self.request_handlers.append({"type": request_type, "func": func})
- return func
-
- return decorator
-
- async def handle_event(self, bot, event):
- """
- 统一的事件分发入口。
-
- 由 `WS` 客户端在接收到事件后调用。该方法会根据事件的 `post_type`
- 将其分发给对应的具体处理方法。
-
- Args:
- bot: Bot 实例。
- event: 已解析的事件对象。
- """
- # --- 全局过滤机器人自身消息 ---
- # 仅对消息事件生效
- if event.post_type == 'message' and global_config.bot.get('ignore_self_message', False):
-
- if hasattr(event, 'user_id') and hasattr(event, 'self_id') and event.user_id == event.self_id:
- return
-
- post_type = event.post_type
-
- if post_type == 'message':
- await self.handle_message(bot, event)
- elif post_type == 'notice':
- await self.handle_notice(bot, event)
- elif post_type == 'request':
- await self.handle_request(bot, event)
-
- async def handle_message(self, bot, event):
- """
- 处理消息事件,优先执行通用处理器,然后解析并分发指令。
- """
- # --- 1. 执行通用消息处理器 ---
- for handler in self.message_handlers:
- # 如果任何一个处理器返回 True,则中断后续处理
- consumed = await self._run_handler(handler, bot, event)
- if consumed:
- return
-
- # --- 2. 检查并执行指令 ---
- if not event.raw_message:
- return
-
- raw_text = event.raw_message.strip()
-
- prefix_found = None
- for p in self.prefixes:
- if raw_text.startswith(p):
- prefix_found = p
- break
-
- if not prefix_found:
- return
-
- full_cmd = raw_text[len(prefix_found) :].split()
- if not full_cmd:
- return
-
- cmd_name = full_cmd[0]
- args = full_cmd[1:]
-
- if cmd_name in self.commands:
- func = self.commands[cmd_name]
- await self._run_handler(func, bot, event, args)
-
- async def handle_notice(self, bot, event):
- """
- 分发通知事件给所有匹配的处理器。
-
- Args:
- bot: Bot 实例。
- event: 通知事件对象。
- """
- for handler in self.notice_handlers:
- if handler["type"] is None or handler["type"] == event.notice_type:
- await self._run_handler(handler["func"], bot, event)
-
- async def handle_request(self, bot, event):
- """
- 分发请求事件给所有匹配的处理器。
-
- Args:
- bot: Bot 实例。
- event: 请求事件对象。
- """
- for handler in self.request_handlers:
- if handler["type"] is None or handler["type"] == event.request_type:
- await self._run_handler(handler["func"], bot, event)
-
- async def _run_handler(self, func: Callable, bot, event, args: List[str] = None):
- """
- 智能执行事件处理器,并返回事件是否被消费。
-
- 该方法会检查目标处理器的函数签名,并根据签名动态地传入所需的参数
- (如 `bot`, `event`, `args`),实现了依赖注入。
-
- Args:
- func (Callable): 目标处理器函数。
- bot: Bot 实例。
- event: 事件对象。
- args (List[str], optional): 指令参数列表(仅对消息事件有效)。
-
- Returns:
- bool: 如果处理器函数返回 True,则返回 True,否则返回 False。
- """
- sig = inspect.signature(func)
- params = sig.parameters
- kwargs = {}
-
- if "bot" in params:
- kwargs["bot"] = bot
- if "event" in params:
- kwargs["event"] = event
- if "args" in params and args is not None:
- kwargs["args"] = args
-
- # 执行函数并获取返回值
- result = await func(**kwargs)
- return result is True
-
# --- 全局单例 ---
diff --git a/core/event_handler.py b/core/event_handler.py
new file mode 100644
index 0000000..b355718
--- /dev/null
+++ b/core/event_handler.py
@@ -0,0 +1,197 @@
+"""
+事件处理器模块
+
+该模块定义了用于处理不同类型事件的处理器类。
+每个处理器都负责注册和分发特定类型的事件。
+"""
+import inspect
+from abc import ABC, abstractmethod
+from typing import Any, Callable, Dict, List, Optional, Tuple
+
+from .bot import Bot
+from .permission_manager import Permission, permission_manager
+from .exceptions import SyncHandlerError
+from .executor import run_in_thread_pool
+
+
+class BaseHandler(ABC):
+ """
+ 事件处理器抽象基类
+ """
+ def __init__(self):
+ self.handlers: List[Dict[str, Any]] = []
+
+ @abstractmethod
+ async def handle(self, bot: Bot, event: Any):
+ """
+ 处理事件
+ """
+ raise NotImplementedError
+
+ async def _run_handler(
+ self,
+ func: Callable,
+ bot: Bot,
+ event: Any,
+ args: Optional[List[str]] = None,
+ permission_granted: Optional[bool] = None
+ ):
+ """
+ 智能执行事件处理器,并注入所需参数
+ """
+ sig = inspect.signature(func)
+ params = sig.parameters
+ kwargs = {}
+
+ if "bot" in params:
+ kwargs["bot"] = bot
+ if "event" in params:
+ kwargs["event"] = event
+ if "args" in params and args is not None:
+ kwargs["args"] = args
+ if "permission_granted" in params and permission_granted is not None:
+ kwargs["permission_granted"] = permission_granted
+
+ if inspect.iscoroutinefunction(func):
+ result = await func(**kwargs)
+ else:
+ # 如果是同步函数,则放入线程池执行
+ result = await run_in_thread_pool(func, **kwargs)
+ return result is True
+
+
+class MessageHandler(BaseHandler):
+ """
+ 消息事件处理器
+ """
+ def __init__(self, prefixes: Tuple[str, ...]):
+ super().__init__()
+ self.prefixes = prefixes
+ self.commands: Dict[str, Dict] = {}
+ self.message_handlers: List[Callable] = []
+
+ def on_message(self) -> Callable:
+ """
+ 注册通用消息处理器
+ """
+ def decorator(func: Callable) -> Callable:
+ if not inspect.iscoroutinefunction(func):
+ raise SyncHandlerError(f"消息处理器 {func.__name__} 必须是异步函数 (async def).")
+ self.message_handlers.append(func)
+ return func
+ return decorator
+
+ def command(
+ self,
+ name: str,
+ permission: Optional[Permission] = None,
+ override_permission_check: bool = False
+ ) -> Callable:
+ """
+ 注册命令处理器
+ """
+ def decorator(func: Callable) -> Callable:
+ if not inspect.iscoroutinefunction(func):
+ raise SyncHandlerError(f"命令处理器 {func.__name__} 必须是异步函数 (async def).")
+ self.commands[name] = {
+ "func": func,
+ "permission": permission,
+ "override_permission_check": override_permission_check,
+ }
+ return func
+ return decorator
+
+ async def handle(self, bot: Bot, event: Any):
+ """
+ 处理消息事件,包括通用消息和命令
+ """
+ for handler in self.message_handlers:
+ consumed = await self._run_handler(handler, bot, event)
+ if consumed:
+ return
+
+ if not event.raw_message:
+ return
+
+ raw_text = event.raw_message.strip()
+ prefix_found = next((p for p in self.prefixes if raw_text.startswith(p)), None)
+
+ if not prefix_found:
+ return
+
+ full_cmd = raw_text[len(prefix_found):].split()
+ if not full_cmd:
+ return
+
+ cmd_name = full_cmd[0]
+ args = full_cmd[1:]
+
+ if cmd_name in self.commands:
+ command_info = self.commands[cmd_name]
+ func = command_info["func"]
+ permission = command_info.get("permission")
+ override_check = command_info.get("override_permission_check", False)
+
+ permission_granted = True
+ if permission:
+ permission_granted = await permission_manager.check_permission(event.user_id, permission)
+
+ if not permission_granted and not override_check:
+ await bot.send(event, f"权限不足,需要 {permission.name} 权限")
+ return
+
+ await self._run_handler(
+ func,
+ bot,
+ event,
+ args=args,
+ permission_granted=permission_granted
+ )
+
+
+class NoticeHandler(BaseHandler):
+ """
+ 通知事件处理器
+ """
+ def register(self, notice_type: Optional[str] = None) -> Callable:
+ """
+ 注册通知处理器
+ """
+ def decorator(func: Callable) -> Callable:
+ if not inspect.iscoroutinefunction(func):
+ raise SyncHandlerError(f"通知处理器 {func.__name__} 必须是异步函数 (async def).")
+ self.handlers.append({"type": notice_type, "func": func})
+ return func
+ return decorator
+
+ async def handle(self, bot: Bot, event: Any):
+ """
+ 处理通知事件
+ """
+ for handler in self.handlers:
+ if handler["type"] is None or handler["type"] == event.notice_type:
+ await self._run_handler(handler["func"], bot, event)
+
+
+class RequestHandler(BaseHandler):
+ """
+ 请求事件处理器
+ """
+ def register(self, request_type: Optional[str] = None) -> Callable:
+ """
+ 注册请求处理器
+ """
+ def decorator(func: Callable) -> Callable:
+ if not inspect.iscoroutinefunction(func):
+ raise SyncHandlerError(f"请求处理器 {func.__name__} 必须是异步函数 (async def).")
+ self.handlers.append({"type": request_type, "func": func})
+ return func
+ return decorator
+
+ async def handle(self, bot: Bot, event: Any):
+ """
+ 处理请求事件
+ """
+ for handler in self.handlers:
+ if handler["type"] is None or handler["type"] == event.request_type:
+ await self._run_handler(handler["func"], bot, event)
diff --git a/core/exceptions.py b/core/exceptions.py
new file mode 100644
index 0000000..9b8cd18
--- /dev/null
+++ b/core/exceptions.py
@@ -0,0 +1,9 @@
+"""
+自定义异常模块
+"""
+
+class SyncHandlerError(Exception):
+ """
+ 当尝试注册同步函数作为异步事件处理器时抛出此异常。
+ """
+ pass
diff --git a/core/executor.py b/core/executor.py
new file mode 100644
index 0000000..6d691bd
--- /dev/null
+++ b/core/executor.py
@@ -0,0 +1,27 @@
+"""
+线程池执行器
+
+提供一个全局的线程池和异步接口,用于在事件循环中安全地运行同步函数。
+"""
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+from functools import partial
+from typing import Any, Callable
+
+# 创建一个全局的线程池,可以根据需要调整 max_workers
+executor = ThreadPoolExecutor(max_workers=10)
+
+async def run_in_thread_pool(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
+ """
+ 在线程池中异步运行同步函数
+
+ :param func: 要运行的同步函数
+ :param args: 函数的位置参数
+ :param kwargs: 函数的关键字参数
+ :return: 函数的返回值
+ """
+ loop = asyncio.get_running_loop()
+ # 使用 functools.partial 绑定函数和参数,以便传递给 run_in_executor
+ func_to_run = partial(func, *args, **kwargs)
+ # loop.run_in_executor 会返回一个 awaitable 对象
+ return await loop.run_in_executor(executor, func_to_run)
diff --git a/core/permission_manager.py b/core/permission_manager.py
new file mode 100644
index 0000000..c79a18d
--- /dev/null
+++ b/core/permission_manager.py
@@ -0,0 +1,252 @@
+"""
+权限管理器模块
+
+该模块负责管理用户权限,支持 admin、op、user 三个权限级别。
+权限数据存储在 `permissions.json` 文件中,格式为:
+{
+ "users": {
+ "123456": "admin",
+ "789012": "op",
+ "345678": "user"
+ }
+}
+"""
+import json
+import os
+from functools import total_ordering
+from typing import Dict
+
+from .logger import logger
+from .admin_manager import admin_manager # 导入 AdminManager
+
+
+@total_ordering
+class Permission:
+ """
+ 权限封装类
+
+ 封装了权限的名称和等级,并提供了比较方法。
+ 使用 @total_ordering 装饰器可以自动生成所有的比较运算符。
+ """
+ def __init__(self, name: str, level: int):
+ """
+ 初始化权限对象
+
+ Args:
+ name (str): 权限名称 (e.g., "admin", "op")
+ level (int): 权限等级,数字越大权限越高
+ """
+ self.name = name
+ self.level = level
+
+ def __eq__(self, other):
+ """
+ 判断权限是否相等
+ """
+ if not isinstance(other, Permission):
+ return NotImplemented
+ return self.level == other.level
+
+ def __lt__(self, other):
+ """
+ 判断权限是否小于另一个权限
+ """
+ if not isinstance(other, Permission):
+ return NotImplemented
+ return self.level < other.level
+
+ def __str__(self) -> str:
+ """
+ 返回权限的字符串表示(即权限名称)
+ """
+ return self.name
+
+
+# 定义全局权限常量
+ADMIN = Permission("admin", 3)
+OP = Permission("op", 2)
+USER = Permission("user", 1)
+
+# 用于从字符串名称查找权限对象的字典
+_PERMISSIONS: Dict[str, Permission] = {
+ p.name: p for p in [ADMIN, OP, USER]
+}
+
+
+class PermissionManager:
+ """
+ 权限管理器类
+
+ 负责加载、保存和查询用户权限数据。
+ 使用单例模式,确保全局只有一个权限管理器实例。
+ """
+
+ _instance = None
+
+ def __new__(cls):
+ """
+ 单例模式实现
+
+ Returns:
+ PermissionManager: 全局唯一的权限管理器实例
+ """
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ cls._instance._initialized = False
+ return cls._instance
+
+ def __init__(self):
+ """
+ 初始化权限管理器
+
+ 如果已经初始化过,则直接返回。
+ """
+ if getattr(self, "_initialized", False):
+ return
+
+ # 权限数据文件路径
+ self.data_file = os.path.join(
+ os.path.dirname(os.path.abspath(__file__)),
+ "..",
+ "data",
+ "permissions.json"
+ )
+
+ # 确保数据目录存在
+ data_dir = os.path.dirname(self.data_file)
+ os.makedirs(data_dir, exist_ok=True)
+
+ # 权限数据存储结构:{"users": {"user_id": "level_name"}}
+ self._data: Dict[str, Dict[str, str]] = {"users": {}}
+
+ # 加载现有数据
+ self.load()
+
+ self._initialized = True
+ logger.info("权限管理器初始化完成")
+
+ def load(self) -> None:
+ """
+ 从文件加载权限数据
+
+ 如果文件不存在,则创建空文件并初始化默认数据结构。
+ """
+ try:
+ if os.path.exists(self.data_file):
+ with open(self.data_file, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ # 兼容旧格式
+ if "users" in data:
+ self._data["users"] = data["users"]
+ else:
+ self._data["users"] = {}
+ logger.debug(f"权限数据已从 {self.data_file} 加载")
+ else:
+ # 文件不存在,创建空文件
+ self.save()
+ logger.debug(f"创建空的权限数据文件: {self.data_file}")
+ except json.JSONDecodeError as e:
+ logger.error(f"权限数据文件格式错误: {e}")
+ # 文件损坏,重置为空数据
+ self._data["users"] = {}
+ self.save()
+ except Exception as e:
+ logger.error(f"加载权限数据失败: {e}")
+ self._data["users"] = {}
+
+ def save(self) -> None:
+ """
+ 将权限数据保存到文件
+ """
+ try:
+ with open(self.data_file, "w", encoding="utf-8") as f:
+ json.dump(self._data, f, indent=2, ensure_ascii=False)
+ logger.debug(f"权限数据已保存到 {self.data_file}")
+ except Exception as e:
+ logger.error(f"保存权限数据失败: {e}")
+
+ async def get_user_permission(self, user_id: int) -> Permission:
+ """
+ 获取指定用户的权限对象
+
+ Args:
+ user_id (int): 用户 QQ 号
+
+ Returns:
+ Permission: 用户的权限对象,如果用户不存在则返回默认级别 USER
+ """
+ # 首先,通过 AdminManager 检查是否为管理员
+ if await admin_manager.is_admin(user_id):
+ return ADMIN
+
+ # 如果不是管理员,则从 permissions.json 中查找
+ user_id_str = str(user_id)
+ level_name = self._data["users"].get(user_id_str, USER.name)
+ return _PERMISSIONS.get(level_name, USER)
+
+ def set_user_permission(self, user_id: int, permission: Permission) -> None:
+ """
+ 设置指定用户的权限级别
+
+ Args:
+ user_id (int): 用户 QQ 号
+ permission (Permission): 权限对象
+
+ Raises:
+ ValueError: 如果权限对象无效
+ """
+ if not isinstance(permission, Permission) or permission.name not in _PERMISSIONS:
+ raise ValueError(f"无效的权限对象: {permission}")
+
+ user_id_str = str(user_id)
+ self._data["users"][user_id_str] = permission.name
+ self.save()
+ logger.info(f"设置用户 {user_id} 的权限级别为 {permission.name}")
+
+ def remove_user(self, user_id: int) -> None:
+ """
+ 移除指定用户的权限设置,恢复为默认级别
+
+ Args:
+ user_id (int): 用户 QQ 号
+ """
+ user_id_str = str(user_id)
+ if user_id_str in self._data["users"]:
+ del self._data["users"][user_id_str]
+ self.save()
+ logger.info(f"移除用户 {user_id} 的权限设置")
+
+ async def check_permission(self, user_id: int, required_permission: Permission) -> bool:
+ """
+ 检查用户是否具有指定权限级别
+
+ Args:
+ user_id (int): 用户 QQ 号
+ required_permission (Permission): 所需的权限对象
+
+ Returns:
+ bool: 如果用户权限 >= 所需权限,返回 True,否则返回 False
+ """
+ user_permission = await self.get_user_permission(user_id)
+ return user_permission >= required_permission
+
+ def get_all_users(self) -> Dict[str, str]:
+ """
+ 获取所有设置了权限的用户及其级别名称
+
+ Returns:
+ Dict[str, str]: 用户ID到权限级别名称的映射
+ """
+ return self._data["users"].copy()
+
+ def clear_all(self) -> None:
+ """
+ 清空所有权限设置
+ """
+ self._data["users"].clear()
+ self.save()
+ logger.info("已清空所有权限设置")
+
+
+# 全局权限管理器实例
+permission_manager = PermissionManager()
\ No newline at end of file
diff --git a/core/plugin_manager.py b/core/plugin_manager.py
index a80a197..8b0e7f1 100644
--- a/core/plugin_manager.py
+++ b/core/plugin_manager.py
@@ -11,7 +11,9 @@ import pkgutil
import sys
from core.command_manager import matcher
+from core.exceptions import SyncHandlerError
from .logger import logger
+from .executor import run_in_thread_pool
def load_all_plugins():
@@ -49,6 +51,8 @@ def load_all_plugins():
type_str = "包" if is_pkg else "文件"
logger.success(f" [{type_str}] 成功{action}: {module_name}")
+ except SyncHandlerError as e:
+ logger.error(f" 插件 {module_name} 加载失败: {e} (跳过此插件)")
except Exception as e:
print(
f" {action if 'action' in locals() else '加载'}插件 {module_name} 失败: {e}"
@@ -75,50 +79,48 @@ class PluginDataManager:
self.plugin_name + ".json",
)
self.data = {}
- self.load()
- def load(self):
+ async def load(self):
"""读取配置文件"""
if not os.path.exists(self.data_file):
- with open(self.data_file, "w", encoding="utf-8") as f:
- self.set(self.plugin_name, [])
+ await self.set(self.plugin_name, [])
try:
with open(self.data_file, "r", encoding="utf-8") as f:
- self.data = json.load(f)
+ self.data = await run_in_thread_pool(json.load, f)
except json.JSONDecodeError:
self.data = {}
- def save(self):
+ async def save(self):
"""保存配置到文件"""
with open(self.data_file, "w", encoding="utf-8") as f:
- json.dump(self.data, f, indent=2, ensure_ascii=False)
+ await run_in_thread_pool(json.dump, self.data, f, indent=2, ensure_ascii=False)
def get(self, key, default=None):
"""获取配置项"""
return self.data.get(key, default)
- def set(self, key, value):
+ async def set(self, key, value):
"""设置配置项"""
self.data[key] = value
- self.save()
+ await self.save()
- def add(self, key, value):
+ async def add(self, key, value):
"""添加配置项"""
if key not in self.data:
self.data[key] = []
self.data[key].append(value)
- self.save()
+ await self.save()
- def remove(self, key):
+ async def remove(self, key):
"""删除配置项"""
if key in self.data:
del self.data[key]
- self.save()
+ await self.save()
- def clear(self):
+ async def clear(self):
"""清空所有配置"""
self.data.clear()
- self.save()
+ await self.save()
def get_all(self):
return self.data.copy()
diff --git a/core/redis_manager.py b/core/redis_manager.py
index 3786d9c..7440a22 100644
--- a/core/redis_manager.py
+++ b/core/redis_manager.py
@@ -1,20 +1,24 @@
-import redis
+import redis.asyncio as redis
from .config_loader import global_config as config
from .logger import logger
class RedisManager:
"""
- Redis 连接管理器
+ Redis 连接管理器(异步单例)
"""
- _pool = None
- _client = None
+ _instance = None
+ _redis = None
- @classmethod
- def initialize(cls):
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ async def initialize(self):
"""
- 初始化 Redis 连接并进行健康检查
+ 异步初始化 Redis 连接并进行健康检查
"""
- if cls._pool is None:
+ if self._redis is None:
try:
host = config.redis['host']
port = config.redis['port']
@@ -23,39 +27,32 @@ class RedisManager:
logger.info(f"正在尝试连接 Redis: {host}:{port}, DB: {db}")
- cls._pool = redis.ConnectionPool(
+ self._redis = redis.Redis(
host=host,
port=port,
db=db,
password=password,
decode_responses=True
)
- cls._client = redis.Redis(connection_pool=cls._pool)
- if cls._client.ping():
+ if await self._redis.ping():
logger.success("Redis 连接成功!")
else:
logger.error("Redis 连接失败: PING 命令无响应")
except redis.exceptions.ConnectionError as e:
logger.error(f"Redis 连接失败: {e}")
- cls._pool = None
- cls._client = None
+ self._redis = None
except Exception as e:
logger.exception(f"Redis 初始化时发生未知错误: {e}")
- cls._pool = None
- cls._client = None
+ self._redis = None
- @classmethod
- def get_redis(cls):
+ @property
+ def redis(self):
"""
- 获取 Redis 连接
-
- :return: Redis 连接实例
+ 获取 Redis 连接实例
"""
- if cls._client is None:
- # 理论上 initialize 应该在程序启动时被调用,这里作为备用
- cls.initialize()
- return cls._client
+ if self._redis is None:
+ raise ConnectionError("Redis 未初始化或连接失败,请先调用 initialize()")
+ return self._redis
-# 在模块加载时直接初始化
-RedisManager.initialize()
-redis_client = RedisManager.get_redis()
+# 全局 Redis 管理器实例
+redis_manager = RedisManager()
diff --git a/data/admin.json b/data/admin.json
new file mode 100644
index 0000000..577c240
--- /dev/null
+++ b/data/admin.json
@@ -0,0 +1,3 @@
+{
+ "admins": [2221577113]
+}
\ No newline at end of file
diff --git a/data/permissions.json b/data/permissions.json
new file mode 100644
index 0000000..864ddb4
--- /dev/null
+++ b/data/permissions.json
@@ -0,0 +1,3 @@
+{
+ "users": {}
+}
\ No newline at end of file
diff --git a/html/404.html b/html/404.html
new file mode 100644
index 0000000..be035f3
--- /dev/null
+++ b/html/404.html
@@ -0,0 +1,288 @@
+
+404 - Signal Lost
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 404
+
+
+ Sys.Malfunction
+ 0x00_DEAD
+
+
+
+
+
+
+
+
+
root@neobot:~/system/logs
+
+
+
+
+
+
+
> initiating_handshake...
> resolving_host: calglaubot.internal
> connection_established (port: 443)
> GET /requested_resource HTTP/1.1
> waiting_for_response...
> FATAL: endpoint_not_found
> stack_trace_dump:
> at Router.resolve (core.js:204)
> at Neobot.Handler (main.py:404)
> error: signal_lost_in_void
+
+ ➜
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CPU: 98%
+ |
+ MEM: OVERFLOW
+
+
+ NEOBOT FRAMEWORK
+
+
+
\ No newline at end of file
diff --git a/html/index.html b/html/index.html
new file mode 100644
index 0000000..4eba739
--- /dev/null
+++ b/html/index.html
@@ -0,0 +1,387 @@
+
+
+
+
+
+ NEOBOT | F.O.S FRAMEWORK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🚀 HIGH PERFORMANCE ASYNC FRAMEWORK
+
+
+
+
+
+ 基于 Python 异步生态构建的 OneBot 11 解决方案。内置 Redis 缓存、插件热重载与类型安全检查。这是我的第一个 Python 作品,致力于极致的开发体验。
+
+
+
+
+
Core Team
+
+
+

+
+

+
+
Fairy-Oracle-Sanctuary
+
+
+
+
+
+ $ git clone ...
+
+
+
+
+
+
+
+
+
+
+
+
+
为什么选择 NEO?
+
不仅仅是一个框架,更是一套完整的现代化开发解决方案。
+
+
+
+
+
+
+
+
+
高性能异步 IO
+
+ 基于 Python 原生 asyncio 和 websockets 构建。完全非阻塞设计,单进程即可轻松处理海量并发消息,拒绝卡顿。
+
+
+
+
+
+
+
+
+
智能插件热重载
+
+ 基于 watchdog 实现文件监控。修改代码后自动重载插件逻辑,无需重启机器人进程。让调试和开发效率提升 200%。
+
+
+
+
+
+
+
+
+
Redis 深度集成
+
+ 内置 Redis 连接池。自动缓存群信息、好友列表等高频数据,减少 API 调用延迟,让响应速度快人一步。
+
+
+
+
+
+
+
+
+
类型安全
+
+ 全面采用 Pydantic 和 Dataclasses。为所有事件和数据模型提供完整的类型注解,IDE 智能补全,减少运行时错误。
+
+
+
+
+
+
+
+
+
精细权限管理
+
+ 内置 Admin/Op/User 三级权限体系。支持动态添加管理员,通过装饰器即可轻松控制每个指令的访问权限。
+
+
+
+
+
+
+
+
+
标准 OneBot 11
+
+ 完美兼容 OneBot v11 协议标准。支持 NapCatQQ、LLOneBot 等主流实现端,无缝对接,开箱即用。
+
+
+
+
+
+
+
+
+
+
TERMINAL OUTPUT
+
+
+
+
+
+
+
+
+
+
+
性能建议:使用 PyPy
+
+ 为了获得最佳性能,我们强烈推荐使用 PyPy JIT 编译器 来运行 NEO 框架。在处理高并发消息时,PyPy 相比标准 CPython 能提供显著的性能提升。
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/main.py b/main.py
index d4c723c..5b15394 100644
--- a/main.py
+++ b/main.py
@@ -13,8 +13,11 @@ from watchdog.events import FileSystemEventHandler
# 初始化日志系统,必须在其他 core 模块导入之前执行
from core.logger import logger
+from core.admin_manager import admin_manager
from core.ws import WS
from core.plugin_manager import load_all_plugins
+from core.redis_manager import redis_manager
+from core.executor import run_in_thread_pool
class PluginReloadHandler(FileSystemEventHandler):
@@ -62,7 +65,7 @@ class PluginReloadHandler(FileSystemEventHandler):
try:
# 重新扫描并加载插件
- load_all_plugins()
+ run_in_thread_pool(load_all_plugins)
logger.success("插件重载完成")
except Exception as e:
logger.exception(f"重载失败: {e}")
@@ -78,7 +81,13 @@ async def main():
3. 建立连接并保持运行
"""
# 首次加载插件
- load_all_plugins()
+ await run_in_thread_pool(load_all_plugins)
+
+ # 初始化 Redis 连接
+ await redis_manager.initialize()
+
+ # 初始化管理员管理器
+ await admin_manager.initialize()
# 启动文件监控
# 监控 plugins 目录
diff --git a/models/events/message.py b/models/events/message.py
index 3ede724..febb1d9 100644
--- a/models/events/message.py
+++ b/models/events/message.py
@@ -6,6 +6,7 @@
from dataclasses import dataclass, field
from typing import List, Optional
+from core.permission_manager import ADMIN, OP, USER
from models.message import MessageSegment
from models.sender import Sender
from .base import OneBotEvent, EventType
@@ -32,6 +33,11 @@ class MessageEvent(OneBotEvent):
消息事件基类
"""
+ # 权限级别常量,用于装饰器参数
+ ADMIN = ADMIN
+ OP = OP
+ USER = USER
+
message_type: str
"""消息类型: private (私聊), group (群聊)"""
diff --git a/plugins/admin.py b/plugins/admin.py
index 8e1b0e6..2d4854d 100644
--- a/plugins/admin.py
+++ b/plugins/admin.py
@@ -1,115 +1,74 @@
-from core import PluginDataManager
+"""
+管理员管理插件
+
+提供通过聊天指令动态添加或移除机器人管理员的功能。
+"""
from core.bot import Bot
from core.command_manager import matcher
-from models import GroupMessageEvent
+from core.admin_manager import admin_manager
+from models.events.message import MessageEvent
__plugin_meta__ = {
- "name": "admin",
- "description": "机器人权限管理插件",
- "usage": "/admin",
+ "name": "管理员管理",
+ "description": "管理机器人的全局管理员",
+ "usage": (
+ "/admin list - 列出所有管理员\n"
+ "/admin add - 添加管理员\n"
+ "/admin remove - 移除管理员"
+ ),
}
-data = PluginDataManager("admin")
+@matcher.command("admin", permission=MessageEvent.ADMIN)
+async def handle_admin_command(bot: Bot, event: MessageEvent, args: list[str]):
+ """
+ 处理 /admin 指令
-@matcher.command("admin")
-async def handle_permission(bot: Bot, event: GroupMessageEvent, args: list[str]):
+ :param bot: Bot 实例
+ :param event: 消息事件实例
+ :param args: 指令参数列表
+ """
if not args:
- await event.reply(
- "机器人权限管理插件指令:\n/admin list 列出所有权限\n/admin add member 添加群成员权限\n/admin remove member 删除群成员权限\n/admin add group <群号> 添加群权限\n/admin remove group <群号> 删除群权限\n/admin clear member 清空群成员权限\n/admin clear group 清空群权限\n/admin clear all 清空所有权限"
- )
- return
-
- if str(event.user_id) not in data.get("members", []):
- await event.reply("你没有权限使用此命令。")
- return
- if str(event.group_id) not in data.get("groups", []):
- await event.reply("群聊不在权限中")
+ await event.reply(__plugin_meta__["usage"])
return
action = args[0].lower()
- # ensure storage keys exist
- members = data.get("members", []) or []
- groups = data.get("groups", []) or []
-
if action == "list":
- msg_lines = ["当前权限列表:"]
- msg_lines.append(
- f"群成员权限 ({len(members)}): {', '.join(members) if members else '无'}"
- )
- msg_lines.append(
- f"群权限 ({len(groups)}): {', '.join(groups) if groups else '无'}"
- )
- await event.reply("\n".join(msg_lines))
+ admins = await admin_manager.get_all_admins()
+ if not admins:
+ await event.reply("当前没有设置任何管理员。")
+ return
+
+ admin_list_str = "\n".join(str(admin_id) for admin_id in admins)
+ await event.reply(f"当前管理员列表 ({len(admins)}):\n{admin_list_str}")
return
if action in ("add", "remove"):
- if len(args) < 3:
- await event.reply("参数错误,示例:/admin add member 123456")
+ if len(args) < 2 or not args[1].isdigit():
+ await event.reply("参数错误,请提供一个有效的 QQ 号。\n示例: /admin add 123456")
return
- target = args[1].lower()
- value = args[2]
-
- if target == "member":
- # operate on members list
- if action == "add":
- if str(value) in members:
- await event.reply(f"成员 {value} 已存在,无需重复添加。")
- return
- members.append(str(value))
- data.set("members", members)
- await event.reply(f"已添加群成员权限:{value}")
- return
- else: # remove
- if str(value) not in members:
- await event.reply(f"成员 {value} 不在权限列表中。")
- return
- members = [m for m in members if m != str(value)]
- data.set("members", members)
- await event.reply(f"已移除群成员权限:{value}")
- return
-
- if target == "group":
- if action == "add":
- if str(value) in groups:
- await event.reply(f"群 {value} 已存在,无需重复添加。")
- return
- groups.append(str(value))
- data.set("groups", groups)
- await event.reply(f"已添加群权限:{value}")
- return
- else: # remove
- if str(value) not in groups:
- await event.reply(f"群 {value} 不在权限列表中。")
- return
- groups = [g for g in groups if g != str(value)]
- data.set("groups", groups)
- await event.reply(f"已移除群权限:{value}")
- return
-
- await event.reply("未知目标类型,请使用 member 或 group")
- return
-
- if action == "clear":
- if len(args) < 2:
- await event.reply("参数错误,示例:/admin clear member")
+ try:
+ user_id = int(args[1])
+ except ValueError:
+ await event.reply("无效的 QQ 号,请输入纯数字。")
return
- target = args[1].lower()
- if target == "member":
- data.set("members", [])
- await event.reply("已清空群成员权限。")
- return
- if target == "group":
- data.set("groups", [])
- await event.reply("已清空群权限。")
- return
- if target == "all":
- data.clear()
- await event.reply("已清空所有权限。")
- return
- await event.reply("未知清空目标,请使用 member/group/all")
- return
- await event.reply("未知指令,使用 /admin 查看帮助")
+ 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
+
+ await event.reply(f"未知的指令: {action}\n\n{__plugin_meta__['usage']}")
diff --git a/plugins/code_py.py b/plugins/code_py.py
index cb53f2b..b595354 100644
--- a/plugins/code_py.py
+++ b/plugins/code_py.py
@@ -13,6 +13,7 @@ from typing import Tuple, Set
from core.bot import Bot
from core.command_manager import matcher
+from core.executor import run_in_thread_pool
from models import MessageEvent
__plugin_meta__ = {
@@ -38,9 +39,11 @@ def is_code_safe(code: str) -> Tuple[bool, str]:
statements = STATEMENT_SPLIT_PATTERN.split(code)
for statement in statements:
statement = statement.strip()
- if not statement: continue
+ if not statement:
+ continue
parts = statement.split()
- if not parts: continue
+ if not parts:
+ continue
if parts[0] == 'from' and len(parts) > 1:
module_name = parts[1].strip()
if module_name in DANGEROUS_MODULES:
@@ -83,7 +86,7 @@ async def process_and_reply(bot: Bot, event: MessageEvent, code: str):
"""
核心处理逻辑:安全检查、执行代码并回复结果。
"""
- safe, message = is_code_safe(code)
+ safe, message = await run_in_thread_pool(is_code_safe, code)
if not safe:
await event.reply(f"代码安全检查未通过:\n{message}")
return
@@ -150,11 +153,11 @@ async def handle_code_input(bot: Bot, event: MessageEvent):
# 处理取消操作
if event.raw_message.strip() == "取消":
await event.reply("已取消输入。")
- return True # 消费事件
+ return True # 消费事件
# 执行代码
await process_and_reply(bot, event, event.raw_message)
- return True # 消费事件,防止被其他指令匹配
+ return True # 消费事件,防止被其他指令匹配
# 如果用户不在等待状态,则不处理
return False
diff --git a/plugins/data/admin.json b/plugins/data/admin.json
deleted file mode 100644
index 9e26dfe..0000000
--- a/plugins/data/admin.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
\ No newline at end of file
diff --git a/plugins/echo.py b/plugins/echo.py
index 24a997d..34a7f22 100644
--- a/plugins/echo.py
+++ b/plugins/echo.py
@@ -29,18 +29,26 @@ async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
await event.reply(reply_msg)
-@matcher.command("赞我")
-async def handle_poke(bot: Bot, event: MessageEvent, args: list[str]):
+@matcher.command(
+ "赞我",
+ permission=MessageEvent.ADMIN,
+ override_permission_check=True
+)
+async def handle_poke(bot: Bot, event: MessageEvent, permission_granted: bool):
"""
处理 赞我 指令,发送点赞
:param bot: Bot 实例
:param event: 消息事件对象
- :param args: 指令参数列表(本指令不使用参数)
+ :param permission_granted: 权限检查结果
"""
+ if not permission_granted:
+ await event.reply("只有我的操作员才能让我点赞哦!(。•ˇ‸ˇ•。)")
+ return
+
try:
# 尝试发送赞
await bot.send_like(event.user_id, times=10)
- await event.reply("戳一戳发送成功!")
+ await event.reply("好感度+10!(〃'▽'〃)")
except Exception as e:
- await event.reply(f"戳一戳发送失败:{str(e)}")
\ No newline at end of file
+ await event.reply(f"点赞失败了 >_<: {str(e)}")
\ No newline at end of file
diff --git a/plugins/jrcd.py b/plugins/jrcd.py
index d4df757..f1d4767 100644
--- a/plugins/jrcd.py
+++ b/plugins/jrcd.py
@@ -9,6 +9,7 @@ from datetime import datetime
from core.bot import Bot
from core.command_manager import matcher
+from core.executor import run_in_thread_pool
from models import MessageEvent, MessageSegment
__plugin_meta__ = {
@@ -77,7 +78,7 @@ async def handle_jrcd(bot: Bot, event: MessageEvent, args: list[str]):
:param args: 指令参数列表(未使用)。
"""
user_id = event.user_id
- jrcd = get_jrcd(user_id)
+ jrcd = await run_in_thread_pool(get_jrcd, user_id)
msg = [MessageSegment.at(user_id)]
if jrcd <= 9:
msg.append(MessageSegment.text(random.choice(JRCDMSG_1) % jrcd))
@@ -112,8 +113,8 @@ async def handle_bbcd(bot: Bot, event: MessageEvent, args: list[str]):
await event.reply("不能和自己比!")
return
- jrcd1 = get_jrcd(user_id1)
- jrcd2 = get_jrcd(user_id2)
+ jrcd1 = await run_in_thread_pool(get_jrcd, user_id1)
+ jrcd2 = await run_in_thread_pool(get_jrcd, user_id2)
jrcz = jrcd1 - jrcd2
diff --git a/plugins/sync_async_test_plugin.py b/plugins/sync_async_test_plugin.py
new file mode 100644
index 0000000..b863959
--- /dev/null
+++ b/plugins/sync_async_test_plugin.py
@@ -0,0 +1,88 @@
+"""
+同步/异步函数测试插件
+
+用于演示 SyncHandlerError 异常以及如何将同步函数放入线程池执行。
+"""
+import time
+from typing import Any
+from core.command_manager import matcher
+from core.executor import run_in_thread_pool
+from core.bot import Bot
+from core.logger import logger
+
+# 插件元数据
+__plugin_meta__ = {
+ "name": "SyncAsyncTestPlugin",
+ "description": "用于测试同步/异步函数处理的插件。",
+ "usage": (
+ "/test_sync_error - 尝试注册一个同步函数作为异步处理器,会触发错误。\n"
+ "/test_blocking_task - 演示将同步阻塞任务放入线程池执行。"
+ ),
+}
+
+# --- 示例 1: 触发 SyncHandlerError (此函数不会被成功注册) ---
+
+# 这是一个同步函数,如果直接用 @matcher.message_handler 装饰,
+# 并且 event_handler 检查到它是同步的,就会抛出 SyncHandlerError。
+# 注意:为了演示错误,我们不会真正注册它,因为注册会失败。
+def _sync_function_that_should_fail(bot: Bot, event: Any):
+ """
+ 一个同步函数,如果直接作为异步事件处理器注册,会触发 SyncHandlerError。
+ """
+ logger.info("这个同步函数不应该被直接调用。")
+ return "这是一个同步函数的结果。"
+
+# --- 示例 2: 将同步阻塞任务放入线程池运行 ---
+
+def _blocking_task(duration: int) -> str:
+ """
+ 一个模拟耗时操作的同步函数。
+ Args:
+ duration (int): 模拟阻塞的秒数。
+ Returns:
+ str: 任务完成消息。
+ """
+ logger.info(f"同步阻塞任务开始,持续 {duration} 秒...")
+ time.sleep(duration)
+ logger.info("同步阻塞任务结束。")
+ return f"阻塞任务完成,耗时 {duration} 秒。"
+
+@matcher.message_handler.command("test_blocking_task")
+async def test_blocking_task_handler(bot: Bot, event: Any, args: list):
+ """
+ 处理 /test_blocking_task 命令,将同步阻塞任务放入线程池执行。
+ Args:
+ bot (Bot): 机器人实例。
+ event (Any): 接收到的事件对象。
+ args (list): 命令参数列表。
+ """
+ if not args:
+ await bot.send(event, "请提供阻塞时长,例如:/test_blocking_task 5")
+ return
+
+ try:
+ duration = int(args[0])
+ if duration <= 0:
+ raise ValueError("时长必须是正整数。")
+ except ValueError:
+ await bot.send(event, "无效的时长,请提供一个正整数。")
+ return
+
+ await bot.send(event, f"开始执行同步阻塞任务,预计耗时 {duration} 秒...")
+
+ # 将同步函数放入线程池执行
+ result = await run_in_thread_pool(_blocking_task, duration)
+
+ await bot.send(event, f"同步阻塞任务已完成:{result}")
+
+# --- 示例 3: 尝试注册一个同步函数作为异步处理器 (会失败) ---
+# 这个函数不会被成功注册,因为 event_handler 会检测到它是同步的并抛出 SyncHandlerError。
+# 插件管理器会捕获这个错误并跳过加载此插件。
+# 为了演示,我们故意尝试注册它。
+# @matcher.message_handler.command("test_sync_error")
+# def test_sync_error_handler(bot: Bot, event: Any):
+# """
+# 这个同步函数尝试作为异步处理器注册,会触发 SyncHandlerError。
+# """
+# logger.error("这个同步函数不应该被直接注册为异步处理器。")
+# return "这个消息不应该被看到。"
From 80ae3f4b8f07ee48ed4efa2f270196fca2f48653 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Sun, 4 Jan 2026 22:37:42 +0800
Subject: [PATCH 12/46] =?UTF-8?q?codepy=E5=AE=89=E5=85=A8=E6=80=A7?=
=?UTF-8?q?=E5=8D=87=E7=BA=A7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
plugins/code_py.py | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/plugins/code_py.py b/plugins/code_py.py
index b595354..2fa2988 100644
--- a/plugins/code_py.py
+++ b/plugins/code_py.py
@@ -22,20 +22,31 @@ __plugin_meta__ = {
"usage": "/code_py - 进入交互模式,等待输入代码块\n/code_py [单行代码] - 快速执行单行代码",
}
-# --- 安全配置:危险模块黑名单 ---
+# --- 安全配置:危险模块和内置函数黑名单 ---
DANGEROUS_MODULES = [
"os", "sys", "subprocess", "shutil", "socket", "requests", "urllib",
"http", "ftplib", "telnetlib", "ctypes", "_thread", "multiprocessing",
"asyncio",
]
+DANGEROUS_BUILTINS = [
+ "__import__", "open", "exec", "eval", "compile", "input", "breakpoint"
+]
# 编译后的正则表达式,用于分割语句
STATEMENT_SPLIT_PATTERN = re.compile(r'[;\n]')
+# 编译后的正则表达式,用于查找危险的内置函数调用
+BUILTIN_CALL_PATTERN = re.compile(r'\b(' + '|'.join(DANGEROUS_BUILTINS) + r')\s*\(')
def is_code_safe(code: str) -> Tuple[bool, str]:
"""
- 检查代码中是否包含危险的模块导入。
+ 检查代码中是否包含危险的模块导入或内置函数调用。
"""
+ # 1. 检查危险的内置函数
+ found_builtins = BUILTIN_CALL_PATTERN.search(code)
+ if found_builtins:
+ return False, f"检测到不允许的内置函数调用:'{found_builtins.group(1)}'"
+
+ # 2. 检查危险的模块导入
statements = STATEMENT_SPLIT_PATTERN.split(code)
for statement in statements:
statement = statement.strip()
From 17bd879e9e3edeb9a812a4e3fe04d347a334d5f3 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Sun, 4 Jan 2026 23:57:20 +0800
Subject: [PATCH 13/46] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=B8=80=E4=BA=9B?=
=?UTF-8?q?=E4=B8=9C=E8=A5=BF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
plugins/echo.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/plugins/echo.py b/plugins/echo.py
index 34a7f22..ff743ea 100644
--- a/plugins/echo.py
+++ b/plugins/echo.py
@@ -13,7 +13,7 @@ __plugin_meta__ = {
"usage": "/echo [内容] - 复读内容\n/赞我 - 让机器人给你点赞",
}
-@matcher.command("echo")
+@matcher.command("echo",permission=MessageEvent.ADMIN)
async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
"""
处理 echo 指令,原样回复用户输入的内容
From e84f59e875e8d172f4968a455314a17b4840de8f Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Sun, 4 Jan 2026 23:57:35 +0800
Subject: [PATCH 14/46] =?UTF-8?q?=E5=86=8D=E6=AC=A1=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
main.py | 13 ++--
plugins/bili_parser.py | 141 +++++++++++++++++++++++++++++++++++++++++
plugins/thpic.py | 5 +-
3 files changed, 153 insertions(+), 6 deletions(-)
create mode 100644 plugins/bili_parser.py
diff --git a/main.py b/main.py
index 5b15394..1445f1f 100644
--- a/main.py
+++ b/main.py
@@ -27,12 +27,13 @@ class PluginReloadHandler(FileSystemEventHandler):
继承自 watchdog.events.FileSystemEventHandler,
监听 base_plugins 目录下的文件变化,并触发插件重载。
"""
- def __init__(self):
+ def __init__(self, loop: asyncio.AbstractEventLoop):
"""
初始化处理器
- 设置冷却时间,防止短时间内多次触发重载。
+ 设置冷却时间,并保存主事件循环的引用。
"""
+ self.loop = loop
self.last_reload_time = 0
self.cooldown = 1.0 # 冷却时间,防止短时间内多次重载
@@ -64,8 +65,8 @@ class PluginReloadHandler(FileSystemEventHandler):
logger.info("正在重载插件...")
try:
- # 重新扫描并加载插件
- run_in_thread_pool(load_all_plugins)
+ # 使用线程安全的方式在主事件循环中运行异步的插件加载函数
+ asyncio.run_coroutine_threadsafe(run_in_thread_pool(load_all_plugins), self.loop)
logger.success("插件重载完成")
except Exception as e:
logger.exception(f"重载失败: {e}")
@@ -93,7 +94,9 @@ async def main():
# 监控 plugins 目录
plugin_path = os.path.join(os.path.dirname(__file__), "plugins")
- event_handler = PluginReloadHandler()
+ # 获取当前事件循环并传递给处理器
+ loop = asyncio.get_running_loop()
+ event_handler = PluginReloadHandler(loop)
observer = Observer()
if os.path.exists(plugin_path):
diff --git a/plugins/bili_parser.py b/plugins/bili_parser.py
new file mode 100644
index 0000000..8c08865
--- /dev/null
+++ b/plugins/bili_parser.py
@@ -0,0 +1,141 @@
+# -*- coding: utf-8 -*-
+import re
+import json
+import html
+import requests
+from bs4 import BeautifulSoup
+from typing import Optional, Tuple, Dict, Any
+
+from core.logger import logger
+from core.command_manager import matcher
+from models import MessageEvent, MessageSegment
+
+__plugin_meta__ = {
+ "name": "bili_parser",
+ "description": "自动解析B站分享卡片,提取视频封面和播放量等信息。",
+ "usage": "(自动触发)当检测到B站小程序分享卡片时,自动发送视频信息。",
+}
+
+HEADERS = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
+}
+
+
+def format_count(num: int) -> str:
+ if not isinstance(num, int):
+ return str(num)
+ if num < 10000:
+ return str(num)
+ return f"{num / 10000:.1f}万"
+
+
+def get_real_url(short_url: str) -> Optional[str]:
+ try:
+ response = requests.head(short_url, headers=HEADERS, allow_redirects=False, timeout=5)
+ if response.status_code == 302:
+ return response.headers.get('Location')
+ except requests.RequestException as e:
+ print(f"获取真实URL失败: {e}")
+ return None
+
+def parse_video_info(video_url: str) -> Optional[Dict[str, Any]]:
+ try:
+ response = requests.get(video_url, headers=HEADERS, timeout=5)
+ response.raise_for_status()
+ soup = BeautifulSoup(response.text, 'html.parser')
+
+ script_tag = soup.find('script', text=re.compile('window.__INITIAL_STATE__'))
+ if not script_tag:
+ return None
+
+ json_str = re.search(r'window\.__INITIAL_STATE__\s*=\s*(\{.*?\});', script_tag.string).group(1)
+ data = json.loads(json_str)
+
+ video_data = data.get('videoData', {})
+ stat = video_data.get('stat', {})
+
+ cover_url = video_data.get('pic', '')
+ if cover_url:
+ cover_url = cover_url.split('@')[0]
+ if cover_url.startswith('//'):
+ cover_url = 'https:' + cover_url
+
+ return {
+ "title": video_data.get('title', '未知标题'),
+ "bvid": video_data.get('bvid', '未知BV号'),
+ "cover_url": cover_url,
+ "play": stat.get('view', 0),
+ "like": stat.get('like', 0),
+ "coin": stat.get('coin', 0),
+ "favorite": stat.get('favorite', 0),
+ "share": stat.get('share', 0),
+ }
+
+ except (requests.RequestException, KeyError, AttributeError, json.JSONDecodeError) as e:
+ print(f"解析视频信息失败: {e}")
+
+ return None
+
+@matcher.on_message()
+async def handle_bili_share(event: MessageEvent):
+ if not event.raw_message.startswith('[CQ:json,data='):
+ return
+
+ logger.info(f"[bili_parser] 检测到JSON CQ码: {event.raw_message}")
+
+ try:
+ json_str_raw = event.raw_message.strip('[CQ:json,data=]').rstrip(']')
+
+ json_str_decoded = html.unescape(json_str_raw)
+
+ data = json.loads(json_str_decoded)
+
+ short_url = data.get("meta", {}).get("detail_1", {}).get("qqdocurl")
+
+ if not short_url or "b23.tv" not in short_url:
+ logger.warning("[bili_parser] JSON中未找到有效的b23.tv链接。")
+ return
+
+ short_url = short_url.split('?')[0]
+ logger.success(f"[bili_parser] 成功提取到B站短链接: {short_url}")
+
+ except (json.JSONDecodeError, KeyError) as e:
+ logger.error(f"[bili_parser] 解析JSON失败: {e}")
+ return
+
+ real_url = get_real_url(short_url)
+ if not real_url:
+ logger.error(f"[bili_parser] 无法从 {short_url} 获取真实URL。")
+ await event.reply("无法解析B站短链接。")
+ return
+
+ video_info = parse_video_info(real_url)
+ if not video_info:
+ logger.error(f"[bili_parser] 无法从 {real_url} 解析视频信息。")
+ await event.reply("无法获取视频信息,可能是B站接口变动或视频不存在。")
+ return
+
+ text_message = (
+ f"BiliBili 视频解析\n"
+ f"--------------------\n"
+ f" 标题: {video_info['title']}\n"
+ f" BV号: {video_info['bvid']}\n"
+ f"--------------------\n"
+ f" 数据:\n"
+ f" 播放: {format_count(video_info['play'])}\n"
+ f" 点赞: {format_count(video_info['like'])}\n"
+ f" 投币: {format_count(video_info['coin'])}\n"
+ f" 收藏: {format_count(video_info['favorite'])}\n"
+ f" 转发: {format_count(video_info['share'])}\n"
+ f" B站链接: {short_url}"
+ )
+
+ image_message_segment = [MessageSegment.text("B站封面:"),MessageSegment.image(video_info['cover_url'])]
+
+ nodes = [
+ event.bot.build_forward_node(user_id=event.self_id, nickname="B站视频解析", message=text_message),
+ event.bot.build_forward_node(user_id=event.self_id, nickname="B站视频解析", message=image_message_segment)
+ ]
+
+ logger.success(f"[bili_parser] 成功解析视频信息并准备以聊天记录形式回复: {video_info['title']}")
+ await event.bot.send_group_forward_msg(group_id=event.group_id, messages=nodes)
diff --git a/plugins/thpic.py b/plugins/thpic.py
index da0784d..1a3dfc8 100644
--- a/plugins/thpic.py
+++ b/plugins/thpic.py
@@ -25,4 +25,7 @@ async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
:param event: 消息事件对象。
:param args: 指令参数列表(未使用)。
"""
- await event.reply(MessageSegment.image("https://img.paulzzh.com/touhou/random"))
+ try:
+ await event.reply(MessageSegment.image("https://img.paulzzh.com/touhou/random"))
+ except Exception as e:
+ await event.reply("报错了。。。" + e)
From d7fbc5bb7064e65fec468272add8d01ef69eb211 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=95=80=E9=93=AC=E9=85=B8=E9=92=BE?=
<148796996+K2cr2O1@users.noreply.github.com>
Date: Sun, 4 Jan 2026 23:58:56 +0800
Subject: [PATCH 15/46] =?UTF-8?q?Dev=E8=87=B3main=20(#21)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: 整合开发历史
* codepy安全性升级
* 优化一些东西
* 再次优化
---
main.py | 13 ++--
plugins/bili_parser.py | 141 +++++++++++++++++++++++++++++++++++++++++
plugins/code_py.py | 15 ++++-
plugins/echo.py | 2 +-
plugins/thpic.py | 5 +-
5 files changed, 167 insertions(+), 9 deletions(-)
create mode 100644 plugins/bili_parser.py
diff --git a/main.py b/main.py
index 5b15394..1445f1f 100644
--- a/main.py
+++ b/main.py
@@ -27,12 +27,13 @@ class PluginReloadHandler(FileSystemEventHandler):
继承自 watchdog.events.FileSystemEventHandler,
监听 base_plugins 目录下的文件变化,并触发插件重载。
"""
- def __init__(self):
+ def __init__(self, loop: asyncio.AbstractEventLoop):
"""
初始化处理器
- 设置冷却时间,防止短时间内多次触发重载。
+ 设置冷却时间,并保存主事件循环的引用。
"""
+ self.loop = loop
self.last_reload_time = 0
self.cooldown = 1.0 # 冷却时间,防止短时间内多次重载
@@ -64,8 +65,8 @@ class PluginReloadHandler(FileSystemEventHandler):
logger.info("正在重载插件...")
try:
- # 重新扫描并加载插件
- run_in_thread_pool(load_all_plugins)
+ # 使用线程安全的方式在主事件循环中运行异步的插件加载函数
+ asyncio.run_coroutine_threadsafe(run_in_thread_pool(load_all_plugins), self.loop)
logger.success("插件重载完成")
except Exception as e:
logger.exception(f"重载失败: {e}")
@@ -93,7 +94,9 @@ async def main():
# 监控 plugins 目录
plugin_path = os.path.join(os.path.dirname(__file__), "plugins")
- event_handler = PluginReloadHandler()
+ # 获取当前事件循环并传递给处理器
+ loop = asyncio.get_running_loop()
+ event_handler = PluginReloadHandler(loop)
observer = Observer()
if os.path.exists(plugin_path):
diff --git a/plugins/bili_parser.py b/plugins/bili_parser.py
new file mode 100644
index 0000000..8c08865
--- /dev/null
+++ b/plugins/bili_parser.py
@@ -0,0 +1,141 @@
+# -*- coding: utf-8 -*-
+import re
+import json
+import html
+import requests
+from bs4 import BeautifulSoup
+from typing import Optional, Tuple, Dict, Any
+
+from core.logger import logger
+from core.command_manager import matcher
+from models import MessageEvent, MessageSegment
+
+__plugin_meta__ = {
+ "name": "bili_parser",
+ "description": "自动解析B站分享卡片,提取视频封面和播放量等信息。",
+ "usage": "(自动触发)当检测到B站小程序分享卡片时,自动发送视频信息。",
+}
+
+HEADERS = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
+}
+
+
+def format_count(num: int) -> str:
+ if not isinstance(num, int):
+ return str(num)
+ if num < 10000:
+ return str(num)
+ return f"{num / 10000:.1f}万"
+
+
+def get_real_url(short_url: str) -> Optional[str]:
+ try:
+ response = requests.head(short_url, headers=HEADERS, allow_redirects=False, timeout=5)
+ if response.status_code == 302:
+ return response.headers.get('Location')
+ except requests.RequestException as e:
+ print(f"获取真实URL失败: {e}")
+ return None
+
+def parse_video_info(video_url: str) -> Optional[Dict[str, Any]]:
+ try:
+ response = requests.get(video_url, headers=HEADERS, timeout=5)
+ response.raise_for_status()
+ soup = BeautifulSoup(response.text, 'html.parser')
+
+ script_tag = soup.find('script', text=re.compile('window.__INITIAL_STATE__'))
+ if not script_tag:
+ return None
+
+ json_str = re.search(r'window\.__INITIAL_STATE__\s*=\s*(\{.*?\});', script_tag.string).group(1)
+ data = json.loads(json_str)
+
+ video_data = data.get('videoData', {})
+ stat = video_data.get('stat', {})
+
+ cover_url = video_data.get('pic', '')
+ if cover_url:
+ cover_url = cover_url.split('@')[0]
+ if cover_url.startswith('//'):
+ cover_url = 'https:' + cover_url
+
+ return {
+ "title": video_data.get('title', '未知标题'),
+ "bvid": video_data.get('bvid', '未知BV号'),
+ "cover_url": cover_url,
+ "play": stat.get('view', 0),
+ "like": stat.get('like', 0),
+ "coin": stat.get('coin', 0),
+ "favorite": stat.get('favorite', 0),
+ "share": stat.get('share', 0),
+ }
+
+ except (requests.RequestException, KeyError, AttributeError, json.JSONDecodeError) as e:
+ print(f"解析视频信息失败: {e}")
+
+ return None
+
+@matcher.on_message()
+async def handle_bili_share(event: MessageEvent):
+ if not event.raw_message.startswith('[CQ:json,data='):
+ return
+
+ logger.info(f"[bili_parser] 检测到JSON CQ码: {event.raw_message}")
+
+ try:
+ json_str_raw = event.raw_message.strip('[CQ:json,data=]').rstrip(']')
+
+ json_str_decoded = html.unescape(json_str_raw)
+
+ data = json.loads(json_str_decoded)
+
+ short_url = data.get("meta", {}).get("detail_1", {}).get("qqdocurl")
+
+ if not short_url or "b23.tv" not in short_url:
+ logger.warning("[bili_parser] JSON中未找到有效的b23.tv链接。")
+ return
+
+ short_url = short_url.split('?')[0]
+ logger.success(f"[bili_parser] 成功提取到B站短链接: {short_url}")
+
+ except (json.JSONDecodeError, KeyError) as e:
+ logger.error(f"[bili_parser] 解析JSON失败: {e}")
+ return
+
+ real_url = get_real_url(short_url)
+ if not real_url:
+ logger.error(f"[bili_parser] 无法从 {short_url} 获取真实URL。")
+ await event.reply("无法解析B站短链接。")
+ return
+
+ video_info = parse_video_info(real_url)
+ if not video_info:
+ logger.error(f"[bili_parser] 无法从 {real_url} 解析视频信息。")
+ await event.reply("无法获取视频信息,可能是B站接口变动或视频不存在。")
+ return
+
+ text_message = (
+ f"BiliBili 视频解析\n"
+ f"--------------------\n"
+ f" 标题: {video_info['title']}\n"
+ f" BV号: {video_info['bvid']}\n"
+ f"--------------------\n"
+ f" 数据:\n"
+ f" 播放: {format_count(video_info['play'])}\n"
+ f" 点赞: {format_count(video_info['like'])}\n"
+ f" 投币: {format_count(video_info['coin'])}\n"
+ f" 收藏: {format_count(video_info['favorite'])}\n"
+ f" 转发: {format_count(video_info['share'])}\n"
+ f" B站链接: {short_url}"
+ )
+
+ image_message_segment = [MessageSegment.text("B站封面:"),MessageSegment.image(video_info['cover_url'])]
+
+ nodes = [
+ event.bot.build_forward_node(user_id=event.self_id, nickname="B站视频解析", message=text_message),
+ event.bot.build_forward_node(user_id=event.self_id, nickname="B站视频解析", message=image_message_segment)
+ ]
+
+ logger.success(f"[bili_parser] 成功解析视频信息并准备以聊天记录形式回复: {video_info['title']}")
+ await event.bot.send_group_forward_msg(group_id=event.group_id, messages=nodes)
diff --git a/plugins/code_py.py b/plugins/code_py.py
index b595354..2fa2988 100644
--- a/plugins/code_py.py
+++ b/plugins/code_py.py
@@ -22,20 +22,31 @@ __plugin_meta__ = {
"usage": "/code_py - 进入交互模式,等待输入代码块\n/code_py [单行代码] - 快速执行单行代码",
}
-# --- 安全配置:危险模块黑名单 ---
+# --- 安全配置:危险模块和内置函数黑名单 ---
DANGEROUS_MODULES = [
"os", "sys", "subprocess", "shutil", "socket", "requests", "urllib",
"http", "ftplib", "telnetlib", "ctypes", "_thread", "multiprocessing",
"asyncio",
]
+DANGEROUS_BUILTINS = [
+ "__import__", "open", "exec", "eval", "compile", "input", "breakpoint"
+]
# 编译后的正则表达式,用于分割语句
STATEMENT_SPLIT_PATTERN = re.compile(r'[;\n]')
+# 编译后的正则表达式,用于查找危险的内置函数调用
+BUILTIN_CALL_PATTERN = re.compile(r'\b(' + '|'.join(DANGEROUS_BUILTINS) + r')\s*\(')
def is_code_safe(code: str) -> Tuple[bool, str]:
"""
- 检查代码中是否包含危险的模块导入。
+ 检查代码中是否包含危险的模块导入或内置函数调用。
"""
+ # 1. 检查危险的内置函数
+ found_builtins = BUILTIN_CALL_PATTERN.search(code)
+ if found_builtins:
+ return False, f"检测到不允许的内置函数调用:'{found_builtins.group(1)}'"
+
+ # 2. 检查危险的模块导入
statements = STATEMENT_SPLIT_PATTERN.split(code)
for statement in statements:
statement = statement.strip()
diff --git a/plugins/echo.py b/plugins/echo.py
index 34a7f22..ff743ea 100644
--- a/plugins/echo.py
+++ b/plugins/echo.py
@@ -13,7 +13,7 @@ __plugin_meta__ = {
"usage": "/echo [内容] - 复读内容\n/赞我 - 让机器人给你点赞",
}
-@matcher.command("echo")
+@matcher.command("echo",permission=MessageEvent.ADMIN)
async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
"""
处理 echo 指令,原样回复用户输入的内容
diff --git a/plugins/thpic.py b/plugins/thpic.py
index da0784d..1a3dfc8 100644
--- a/plugins/thpic.py
+++ b/plugins/thpic.py
@@ -25,4 +25,7 @@ async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
:param event: 消息事件对象。
:param args: 指令参数列表(未使用)。
"""
- await event.reply(MessageSegment.image("https://img.paulzzh.com/touhou/random"))
+ try:
+ await event.reply(MessageSegment.image("https://img.paulzzh.com/touhou/random"))
+ except Exception as e:
+ await event.reply("报错了。。。" + e)
From 15dd4e0592aeffd7dccd301cd3eebb9175de6bd4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=95=80=E9=93=AC=E9=85=B8=E9=92=BE?=
<148796996+K2cr2O1@users.noreply.github.com>
Date: Sun, 4 Jan 2026 23:59:26 +0800
Subject: [PATCH 16/46] Update echo.py
---
plugins/echo.py | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/plugins/echo.py b/plugins/echo.py
index ff743ea..e47ebb0 100644
--- a/plugins/echo.py
+++ b/plugins/echo.py
@@ -13,7 +13,7 @@ __plugin_meta__ = {
"usage": "/echo [内容] - 复读内容\n/赞我 - 让机器人给你点赞",
}
-@matcher.command("echo",permission=MessageEvent.ADMIN)
+@matcher.command("echo")
async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
"""
处理 echo 指令,原样回复用户输入的内容
@@ -31,7 +31,6 @@ async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
@matcher.command(
"赞我",
- permission=MessageEvent.ADMIN,
override_permission_check=True
)
async def handle_poke(bot: Bot, event: MessageEvent, permission_granted: bool):
@@ -51,4 +50,4 @@ async def handle_poke(bot: Bot, event: MessageEvent, permission_granted: bool):
await bot.send_like(event.user_id, times=10)
await event.reply("好感度+10!(〃'▽'〃)")
except Exception as e:
- await event.reply(f"点赞失败了 >_<: {str(e)}")
\ No newline at end of file
+ await event.reply(f"点赞失败了 >_<: {str(e)}")
From eded4480ed28be38486460ade19513ac0a58bfc2 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Mon, 5 Jan 2026 00:02:14 +0800
Subject: [PATCH 17/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=B8=80=E4=B8=8B=20re?=
=?UTF-8?q?quirements.txt?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
requirements.txt | Bin 231 -> 750 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
diff --git a/requirements.txt b/requirements.txt
index f63028280ccb38fbda8a61c0c780e87489a04d78..17a9c33e3864882cb7217cb519675a201e0d8f3d 100644
GIT binary patch
literal 750
zcmYk4+fKq@5QO*I#7B|PgW!$NLQ1L72)49E#D`bE+3g=p^Hb89ox`@jKU-_8vWrdD
z+8g(yjn;ARt+ZFJ(iWEaZ()!2U|aA&mLNL0Kd;dlW|h__bI{zLeLnNw#5~|QpwqyA
zvJdO+cPoQ!INzrR}=ZhbWh@**~ZTFN%OI@P!P6*6{xnM>9u>u+y%ynOE7ZK
zg3Jpxr+n#cjA%6W$&rij7fz&@=x`!q^3U8OdQWyEVN3VD<>RQYu*3=
literal 231
zcmW-aNfN^#3HFoOlcYVpG9}u$0SreMF9l?d64|8&JZ3KctjWIpc&GXAzfEDD-&p{cX?Ays4-1!q$6R1^
LkSoNr_0r)74=qE0
From 4a18909c4fb8070a2d4479ef29e43e0b0202e77b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=95=80=E9=93=AC=E9=85=B8=E9=92=BE?=
<148796996+K2cr2O1@users.noreply.github.com>
Date: Mon, 5 Jan 2026 00:02:40 +0800
Subject: [PATCH 18/46] Dev to main (#22)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: 整合开发历史
* codepy安全性升级
* 优化一些东西
* 再次优化
* 更新一下 requirements.txt
---
plugins/echo.py | 2 +-
requirements.txt | Bin 231 -> 750 bytes
2 files changed, 1 insertion(+), 1 deletion(-)
diff --git a/plugins/echo.py b/plugins/echo.py
index e47ebb0..407510a 100644
--- a/plugins/echo.py
+++ b/plugins/echo.py
@@ -13,7 +13,7 @@ __plugin_meta__ = {
"usage": "/echo [内容] - 复读内容\n/赞我 - 让机器人给你点赞",
}
-@matcher.command("echo")
+@matcher.command("echo",permission=MessageEvent.ADMIN)
async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
"""
处理 echo 指令,原样回复用户输入的内容
diff --git a/requirements.txt b/requirements.txt
index f63028280ccb38fbda8a61c0c780e87489a04d78..17a9c33e3864882cb7217cb519675a201e0d8f3d 100644
GIT binary patch
literal 750
zcmYk4+fKq@5QO*I#7B|PgW!$NLQ1L72)49E#D`bE+3g=p^Hb89ox`@jKU-_8vWrdD
z+8g(yjn;ARt+ZFJ(iWEaZ()!2U|aA&mLNL0Kd;dlW|h__bI{zLeLnNw#5~|QpwqyA
zvJdO+cPoQ!INzrR}=ZhbWh@**~ZTFN%OI@P!P6*6{xnM>9u>u+y%ynOE7ZK
zg3Jpxr+n#cjA%6W$&rij7fz&@=x`!q^3U8OdQWyEVN3VD<>RQYu*3=
literal 231
zcmW-aNfN^#3HFoOlcYVpG9}u$0SreMF9l?d64|8&JZ3KctjWIpc&GXAzfEDD-&p{cX?Ays4-1!q$6R1^
LkSoNr_0r)74=qE0
From 723f7b9724572685cb665a8717b7965da825c3e1 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Mon, 5 Jan 2026 21:36:03 +0800
Subject: [PATCH 19/46] =?UTF-8?q?CQ=E7=A0=81=E6=94=AF=E6=8C=81=E4=BB=A5?=
=?UTF-8?q?=E5=8F=8A=E8=A7=86=E9=A2=91=E8=A7=A3=E6=9E=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
models/message.py | 275 ++++++++++++++++++++++++++++++++++++++++-
plugins/bili_parser.py | 119 +++++++++++++-----
2 files changed, 362 insertions(+), 32 deletions(-)
diff --git a/models/message.py b/models/message.py
index f79d05f..bd93aff 100644
--- a/models/message.py
+++ b/models/message.py
@@ -42,6 +42,40 @@ class MessageSegment:
"""
return self.data.get("url", "") if self.type == "image" else ""
+ @property
+ def share_url(self) -> str:
+ """
+ 当消息段类型为 'share' 时,快速获取其分享 URL。
+
+ Returns:
+ str: 分享的 URL。如果类型不是 'share' 或数据中不含 'url',则返回空字符串。
+ """
+ return self.data.get("url", "") if self.type == "share" else ""
+
+ @property
+ def music_url(self) -> str:
+ """
+ 当消息段类型为 'music' 且为 'custom' 类型时,快速获取其 URL。
+
+ Returns:
+ str: 音乐的 URL。如果类型不匹配,则返回空字符串。
+ """
+ if self.type == "music" and self.data.get("type") == "custom":
+ return self.data.get("url", "")
+ return ""
+
+ @property
+ def file_url(self) -> str:
+ """
+ 当消息段类型为 'record', 'video', 'file' 时,快速获取其文件 URL。
+
+ Returns:
+ str: 文件的 URL 或路径。如果类型不匹配,则返回空字符串。
+ """
+ if self.type in ("record", "video", "file"):
+ return self.data.get("file", "")
+ return ""
+
def is_at(self, user_id: int = None) -> bool:
"""
检查当前消息段是否是一个 'at' (提及) 消息段。
@@ -81,30 +115,46 @@ class MessageSegment:
return MessageSegment(type="text", data={"text": text})
@staticmethod
- def at(user_id: int | str) -> "MessageSegment":
+ def at(user_id: int | str, name: str = None) -> "MessageSegment":
"""
创建一个 @某人 的消息段。
Args:
user_id (int | str): 要提及的 QQ 号。若为 "all",则表示 @全体成员。
+ name (str, optional): 当在群中找不到对应的QQ号时,显示的名称。Defaults to None.
Returns:
MessageSegment: 一个类型为 'at' 的消息段对象。
"""
- return MessageSegment(type="at", data={"qq": str(user_id)})
+ data = {"qq": str(user_id)}
+ if name:
+ data["name"] = name
+ return MessageSegment(type="at", data=data)
@staticmethod
- def image(file: str) -> "MessageSegment":
+ def image(file: str, image_type: str = None, cache: bool = True, proxy: bool = True, timeout: int = None, sub_type: int = None) -> "MessageSegment":
"""
创建一个图片消息段。
Args:
file (str): 图片的路径、URL 或 Base64 编码的字符串。
+ image_type (str, optional): 图片类型,'flash' 表示闪照。Defaults to None.
+ cache (bool, optional): 是否使用缓存。Defaults to True.
+ proxy (bool, optional): 是否通过代理下载。Defaults to True.
+ timeout (int, optional): 下载超时时间(秒)。Defaults to None.
+ sub_type (int, optional): 图片子类型,用于特殊图片。Defaults to None.
Returns:
MessageSegment: 一个类型为 'image' 的消息段对象。
"""
- return MessageSegment(type="image", data={"file": file})
+ data = {"file": file, "cache": "1" if cache else "0", "proxy": "1" if proxy else "0"}
+ if image_type:
+ data["type"] = image_type
+ if timeout:
+ data["timeout"] = str(timeout)
+ if sub_type:
+ data["subType"] = str(sub_type)
+ return MessageSegment(type="image", data=data)
@staticmethod
def face(id: int) -> "MessageSegment":
@@ -119,3 +169,220 @@ class MessageSegment:
"""
return MessageSegment(type="face", data={"id": str(id)})
+ @staticmethod
+ def json(data: str) -> "MessageSegment":
+ """
+ 创建一个 JSON 消息段。
+
+ Args:
+ data (str): JSON 字符串。
+
+ Returns:
+ MessageSegment: 一个类型为 'json' 的消息段对象。
+ """
+ return MessageSegment(type="json", data={"data": data})
+ @staticmethod
+ def xml(data: str) -> "MessageSegment":
+ """
+ 创建一个 XML 消息段。
+
+ Args:
+ data (str): XML 字符串。
+
+ Returns:
+ MessageSegment: 一个类型为 'xml' 的消息段对象。
+ """
+ return MessageSegment(type="xml", data={"data": data})
+ @staticmethod
+ def share(url: str, title: str, content: str = None, image: str = None) -> "MessageSegment":
+ """
+ 创建一个分享消息段。
+
+ Args:
+ url (str): 分享的 URL。
+ title (str): 分享的标题。
+ content (str, optional): 分享的描述内容。Defaults to None.
+ image (str, optional): 分享的图片 URL。Defaults to None.
+
+ Returns:
+ MessageSegment: 一个类型为 'share' 的消息段对象。
+ """
+ data = {"url": url, "title": title}
+ if content:
+ data["content"] = content
+ if image:
+ data["image"] = image
+ return MessageSegment(type="share", data=data)
+ @staticmethod
+ def music(type: str, id: str) -> "MessageSegment":
+ """
+ 创建一个音乐消息段。
+
+ Args:
+ type (str): 音乐平台类型,如 "qq"、"xiami" 等。
+ id (str): 音乐在平台上的唯一标识符。
+
+ Returns:
+ MessageSegment: 一个类型为 'music' 的消息段对象。
+ """
+ return MessageSegment(type="music", data={"type": type, "id": id})
+ @staticmethod
+ def music_custom(url: str, audio: str, title: str, content: str = None, image: str = None) -> "MessageSegment":
+ """
+ 创建一个自定义音乐消息段。
+
+ Args:
+ url (str): 音乐的 URL。
+ audio (str): 音乐的音频 URL。
+ title (str): 音乐的标题。
+ content (str, optional): 音乐的描述内容。Defaults to None.
+ image (str, optional): 音乐的图片 URL。Defaults to None.
+
+ Returns:
+ MessageSegment: 一个类型为 'music_custom' 的消息段对象。
+ """
+ data = {"url": url, "audio": audio, "title": title}
+ if content:
+ data["content"] = content
+ if image:
+ data["image"] = image
+ return MessageSegment(type="music", data={"type": "custom", **data})
+ @staticmethod
+ def record(file: str, magic: bool = False, cache: bool = True, proxy: bool = True, timeout: int = None) -> "MessageSegment":
+ """
+ 创建一个语音消息段。
+
+ Args:
+ file (str): 语音的路径、URL 或 Base64 编码的字符串。
+ magic (bool, optional): 是否为变声。Defaults to False.
+ cache (bool, optional): 是否使用缓存。Defaults to True.
+ proxy (bool, optional): 是否通过代理下载。Defaults to True.
+ timeout (int, optional): 下载超时时间(秒)。Defaults to None.
+
+ Returns:
+ MessageSegment: 一个类型为 'record' 的消息段对象。
+ """
+ data = {"file": file, "magic": "1" if magic else "0", "cache": "1" if cache else "0", "proxy": "1" if proxy else "0"}
+ if timeout:
+ data["timeout"] = str(timeout)
+ return MessageSegment(type="record", data=data)
+ @staticmethod
+ def video(file: str, cover: str = None, c: int = 2) -> "MessageSegment":
+ """
+ 创建一个视频消息段。
+
+ Args:
+ file (str): 视频的路径、URL 或 Base64 编码的字符串。
+ cover (str, optional): 视频封面,支持http, file和base64。Defaults to None.
+ c (int, optional): 下载线程数。Defaults to 2.
+
+ Returns:
+ MessageSegment: 一个类型为 'video' 的消息段对象。
+ """
+ data = {"file": file, "c": str(c)}
+ if cover:
+ data["cover"] = cover
+ return MessageSegment(type="video", data=data)
+ @staticmethod
+ def file(file: str) -> "MessageSegment":
+ """
+ 创建一个文件消息段。
+
+ Args:
+ file (str): 文件的路径、URL 或 Base64 编码的字符串。
+
+ Returns:
+ MessageSegment: 一个类型为 'file' 的消息段对象。
+ """
+ return MessageSegment(type="file", data={"file": file})
+
+ @staticmethod
+ def reply(message_id: str) -> "MessageSegment":
+ """
+ 创建一个回复消息段。
+
+ Args:
+ message_id (str): 被回复的消息 ID。
+
+ Returns:
+ MessageSegment: 一个类型为 'reply' 的消息段对象。
+ """
+ return MessageSegment(type="reply", data={"id": message_id})
+
+ @staticmethod
+ def rps() -> "MessageSegment":
+ """
+ 创建一个猜拳魔法表情消息段。
+
+ Returns:
+ MessageSegment: 一个类型为 'rps' 的消息段对象。
+ """
+ return MessageSegment(type="rps", data={})
+
+ @staticmethod
+ def dice() -> "MessageSegment":
+ """
+ 创建一个掷骰子魔法表情消息段。
+
+ Returns:
+ MessageSegment: 一个类型为 'dice' 的消息段对象。
+ """
+ return MessageSegment(type="dice", data={})
+
+ @staticmethod
+ def shake() -> "MessageSegment":
+ """
+ 创建一个戳一戳消息段。
+
+ Returns:
+ MessageSegment: 一个类型为 'shake' 的消息段对象。
+ """
+ return MessageSegment(type="shake", data={})
+
+ @staticmethod
+ def anonymous(ignore: bool = False) -> "MessageSegment":
+ """
+ 创建一个匿名消息段。
+
+ Args:
+ ignore (bool, optional): 发送失败时是否忽略。Defaults to False.
+
+ Returns:
+ MessageSegment: 一个类型为 'anonymous' 的消息段对象。
+ """
+ return MessageSegment(type="anonymous", data={"ignore": "1" if ignore else "0"})
+
+ @staticmethod
+ def contact(contact_type: str, contact_id: int) -> "MessageSegment":
+ """
+ 创建一个推荐好友/群消息段。
+
+ Args:
+ contact_type (str): 推荐类型,'qq' 或 'group'。
+ contact_id (int): 被推荐的 QQ 号或群号。
+
+ Returns:
+ MessageSegment: 一个类型为 'contact' 的消息段对象。
+ """
+ return MessageSegment(type="contact", data={"type": contact_type, "id": str(contact_id)})
+
+ @staticmethod
+ def location(lat: float, lon: float, title: str = "", content: str = "") -> "MessageSegment":
+ """
+ 创建一个位置消息段。
+
+ Args:
+ lat (float): 纬度。
+ lon (float): 经度。
+ title (str, optional): 标题。Defaults to "".
+ content (str, optional): 内容描述。Defaults to "".
+
+ Returns:
+ MessageSegment: 一个类型为 'location' 的消息段对象。
+ """
+ data = {"lat": str(lat), "lon": str(lon)}
+ if title:
+ data["title"] = title
+ if content:
+ data["content"] = content
+ return MessageSegment(type="location", data=data)
\ No newline at end of file
diff --git a/plugins/bili_parser.py b/plugins/bili_parser.py
index 8c08865..a44170f 100644
--- a/plugins/bili_parser.py
+++ b/plugins/bili_parser.py
@@ -1,10 +1,9 @@
# -*- coding: utf-8 -*-
import re
import json
-import html
import requests
from bs4 import BeautifulSoup
-from typing import Optional, Tuple, Dict, Any
+from typing import Optional, Dict, Any
from core.logger import logger
from core.command_manager import matcher
@@ -29,6 +28,14 @@ def format_count(num: int) -> str:
return f"{num / 10000:.1f}万"
+def format_duration(seconds: int) -> str:
+ """将秒数格式化为 MM:SS 的形式"""
+ if not isinstance(seconds, int) or seconds < 0:
+ return "滚木"
+ minutes, seconds = divmod(seconds, 60)
+ return f"{minutes:02d}:{seconds:02d}"
+
+
def get_real_url(short_url: str) -> Optional[str]:
try:
response = requests.head(short_url, headers=HEADERS, allow_redirects=False, timeout=5)
@@ -52,23 +59,35 @@ def parse_video_info(video_url: str) -> Optional[Dict[str, Any]]:
data = json.loads(json_str)
video_data = data.get('videoData', {})
+ up_data = data.get('upData', {})
stat = video_data.get('stat', {})
+ owner = video_data.get('owner', {})
cover_url = video_data.get('pic', '')
if cover_url:
cover_url = cover_url.split('@')[0]
if cover_url.startswith('//'):
cover_url = 'https:' + cover_url
+
+ owner_avatar = owner.get('face', '')
+ if owner_avatar:
+ if owner_avatar.startswith('//'):
+ owner_avatar = 'https:' + owner_avatar
+ owner_avatar = owner_avatar.split('@')[0]
return {
"title": video_data.get('title', '未知标题'),
"bvid": video_data.get('bvid', '未知BV号'),
+ "duration": video_data.get('duration', 0),
"cover_url": cover_url,
"play": stat.get('view', 0),
"like": stat.get('like', 0),
"coin": stat.get('coin', 0),
"favorite": stat.get('favorite', 0),
"share": stat.get('share', 0),
+ "owner_name": owner.get('name', '未知UP主'),
+ "owner_avatar": owner_avatar,
+ "followers": up_data.get('fans', 0),
}
except (requests.RequestException, KeyError, AttributeError, json.JSONDecodeError) as e:
@@ -76,33 +95,52 @@ def parse_video_info(video_url: str) -> Optional[Dict[str, Any]]:
return None
+def get_direct_video_url(video_url: str) -> Optional[str]:
+ """
+ 调用第三方API解析B站视频直链
+ :param video_url: B站视频的完整URL
+ :return: 视频直链URL,如果失败则返回None
+ """
+ api_url = f"https://api.mir6.com/api/bzjiexi?url={video_url}&type=json"
+ try:
+ response = requests.get(api_url, headers=HEADERS, timeout=10)
+ response.raise_for_status()
+ data = response.json()
+ if data.get("code") == 200 and data.get("data"):
+ return data["data"][0].get("video_url")
+ except (requests.RequestException, json.JSONDecodeError, KeyError, IndexError) as e:
+ logger.error(f"[bili_parser] 调用第三方API解析视频失败: {e}")
+ return None
+
@matcher.on_message()
async def handle_bili_share(event: MessageEvent):
- if not event.raw_message.startswith('[CQ:json,data='):
- return
+ # 遍历消息段,寻找JSON CQ码
+ for segment in event.message:
+ if segment.type == "json":
+ logger.info(f"[bili_parser] 检测到JSON CQ码: {segment.data}")
+ try:
+ # 直接从segment的data中获取json字符串
+ json_data = json.loads(segment.data.get("data", "{}"))
+
+ # 提取B站短链接
+ short_url = json_data.get("meta", {}).get("detail_1", {}).get("qqdocurl")
+
+ if not short_url or "b23.tv" not in short_url:
+ continue # 如果不是B站链接,继续检查下一个segment
+
+ short_url = short_url.split('?')[0]
+ logger.success(f"[bili_parser] 成功提取到B站短链接: {short_url}")
+
+ # 找到了有效的B站链接,处理并跳出循环
+ await process_bili_link(event, short_url)
+ break
- logger.info(f"[bili_parser] 检测到JSON CQ码: {event.raw_message}")
+ except (json.JSONDecodeError, KeyError) as e:
+ logger.error(f"[bili_parser] 解析JSON失败: {e}")
+ continue
- try:
- json_str_raw = event.raw_message.strip('[CQ:json,data=]').rstrip(']')
-
- json_str_decoded = html.unescape(json_str_raw)
-
- data = json.loads(json_str_decoded)
-
- short_url = data.get("meta", {}).get("detail_1", {}).get("qqdocurl")
-
- if not short_url or "b23.tv" not in short_url:
- logger.warning("[bili_parser] JSON中未找到有效的b23.tv链接。")
- return
-
- short_url = short_url.split('?')[0]
- logger.success(f"[bili_parser] 成功提取到B站短链接: {short_url}")
-
- except (json.JSONDecodeError, KeyError) as e:
- logger.error(f"[bili_parser] 解析JSON失败: {e}")
- return
-
+async def process_bili_link(event: MessageEvent, short_url: str):
+ """处理B站链接,获取信息并回复"""
real_url = get_real_url(short_url)
if not real_url:
logger.error(f"[bili_parser] 无法从 {short_url} 获取真实URL。")
@@ -115,11 +153,25 @@ async def handle_bili_share(event: MessageEvent):
await event.reply("无法获取视频信息,可能是B站接口变动或视频不存在。")
return
+ # 检查视频时长
+ if video_info['duration'] > 300: # 5分钟 = 300秒
+ video_message = "视频太长了。。。"
+ else:
+ direct_url = get_direct_video_url(real_url)
+ if direct_url:
+ video_message = MessageSegment.video(direct_url)
+ else:
+ video_message = "视频解析失败,无法获取直链。"
+
text_message = (
f"BiliBili 视频解析\n"
f"--------------------\n"
+ f" UP主: {video_info['owner_name']}\n"
+ f" 粉丝: {format_count(video_info['followers'])}\n"
+ f"--------------------\n"
f" 标题: {video_info['title']}\n"
f" BV号: {video_info['bvid']}\n"
+ f" 时长: {format_duration(video_info['duration'])}\n"
f"--------------------\n"
f" 数据:\n"
f" 播放: {format_count(video_info['play'])}\n"
@@ -130,12 +182,23 @@ async def handle_bili_share(event: MessageEvent):
f" B站链接: {short_url}"
)
- image_message_segment = [MessageSegment.text("B站封面:"),MessageSegment.image(video_info['cover_url'])]
+ image_message_segment = [
+ MessageSegment.text("B站封面:"),
+ MessageSegment.image(video_info['cover_url'])
+ ]
+
+ up_info_segment = [
+ MessageSegment.text("UP主头像:"),
+ MessageSegment.image(video_info['owner_avatar'])
+ ]
nodes = [
event.bot.build_forward_node(user_id=event.self_id, nickname="B站视频解析", message=text_message),
- event.bot.build_forward_node(user_id=event.self_id, nickname="B站视频解析", message=image_message_segment)
+ event.bot.build_forward_node(user_id=event.self_id, nickname="B站视频解析", message=image_message_segment),
+ event.bot.build_forward_node(user_id=event.self_id, nickname="B站视频解析", message=up_info_segment),
+ event.bot.build_forward_node(user_id=event.self_id, nickname="B站视频解析", message=video_message)
]
logger.success(f"[bili_parser] 成功解析视频信息并准备以聊天记录形式回复: {video_info['title']}")
- await event.bot.send_group_forward_msg(group_id=event.group_id, messages=nodes)
+ # 使用更通用的 send_forwarded_messages 方法,自动判断私聊或群聊
+ await event.bot.send_forwarded_messages(target=event, nodes=nodes)
From dfe70d27fd3a20670842244b0451ea73febac7f2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=95=80=E9=93=AC=E9=85=B8=E9=92=BE?=
<148796996+K2cr2O1@users.noreply.github.com>
Date: Mon, 5 Jan 2026 21:41:48 +0800
Subject: [PATCH 20/46] Dev (#23)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: 整合开发历史
* codepy安全性升级
* 优化一些东西
* 再次优化
* 更新一下 requirements.txt
* CQ码支持以及视频解析
---
models/message.py | 275 ++++++++++++++++++++++++++++++++++++++++-
plugins/bili_parser.py | 119 +++++++++++++-----
2 files changed, 362 insertions(+), 32 deletions(-)
diff --git a/models/message.py b/models/message.py
index f79d05f..bd93aff 100644
--- a/models/message.py
+++ b/models/message.py
@@ -42,6 +42,40 @@ class MessageSegment:
"""
return self.data.get("url", "") if self.type == "image" else ""
+ @property
+ def share_url(self) -> str:
+ """
+ 当消息段类型为 'share' 时,快速获取其分享 URL。
+
+ Returns:
+ str: 分享的 URL。如果类型不是 'share' 或数据中不含 'url',则返回空字符串。
+ """
+ return self.data.get("url", "") if self.type == "share" else ""
+
+ @property
+ def music_url(self) -> str:
+ """
+ 当消息段类型为 'music' 且为 'custom' 类型时,快速获取其 URL。
+
+ Returns:
+ str: 音乐的 URL。如果类型不匹配,则返回空字符串。
+ """
+ if self.type == "music" and self.data.get("type") == "custom":
+ return self.data.get("url", "")
+ return ""
+
+ @property
+ def file_url(self) -> str:
+ """
+ 当消息段类型为 'record', 'video', 'file' 时,快速获取其文件 URL。
+
+ Returns:
+ str: 文件的 URL 或路径。如果类型不匹配,则返回空字符串。
+ """
+ if self.type in ("record", "video", "file"):
+ return self.data.get("file", "")
+ return ""
+
def is_at(self, user_id: int = None) -> bool:
"""
检查当前消息段是否是一个 'at' (提及) 消息段。
@@ -81,30 +115,46 @@ class MessageSegment:
return MessageSegment(type="text", data={"text": text})
@staticmethod
- def at(user_id: int | str) -> "MessageSegment":
+ def at(user_id: int | str, name: str = None) -> "MessageSegment":
"""
创建一个 @某人 的消息段。
Args:
user_id (int | str): 要提及的 QQ 号。若为 "all",则表示 @全体成员。
+ name (str, optional): 当在群中找不到对应的QQ号时,显示的名称。Defaults to None.
Returns:
MessageSegment: 一个类型为 'at' 的消息段对象。
"""
- return MessageSegment(type="at", data={"qq": str(user_id)})
+ data = {"qq": str(user_id)}
+ if name:
+ data["name"] = name
+ return MessageSegment(type="at", data=data)
@staticmethod
- def image(file: str) -> "MessageSegment":
+ def image(file: str, image_type: str = None, cache: bool = True, proxy: bool = True, timeout: int = None, sub_type: int = None) -> "MessageSegment":
"""
创建一个图片消息段。
Args:
file (str): 图片的路径、URL 或 Base64 编码的字符串。
+ image_type (str, optional): 图片类型,'flash' 表示闪照。Defaults to None.
+ cache (bool, optional): 是否使用缓存。Defaults to True.
+ proxy (bool, optional): 是否通过代理下载。Defaults to True.
+ timeout (int, optional): 下载超时时间(秒)。Defaults to None.
+ sub_type (int, optional): 图片子类型,用于特殊图片。Defaults to None.
Returns:
MessageSegment: 一个类型为 'image' 的消息段对象。
"""
- return MessageSegment(type="image", data={"file": file})
+ data = {"file": file, "cache": "1" if cache else "0", "proxy": "1" if proxy else "0"}
+ if image_type:
+ data["type"] = image_type
+ if timeout:
+ data["timeout"] = str(timeout)
+ if sub_type:
+ data["subType"] = str(sub_type)
+ return MessageSegment(type="image", data=data)
@staticmethod
def face(id: int) -> "MessageSegment":
@@ -119,3 +169,220 @@ class MessageSegment:
"""
return MessageSegment(type="face", data={"id": str(id)})
+ @staticmethod
+ def json(data: str) -> "MessageSegment":
+ """
+ 创建一个 JSON 消息段。
+
+ Args:
+ data (str): JSON 字符串。
+
+ Returns:
+ MessageSegment: 一个类型为 'json' 的消息段对象。
+ """
+ return MessageSegment(type="json", data={"data": data})
+ @staticmethod
+ def xml(data: str) -> "MessageSegment":
+ """
+ 创建一个 XML 消息段。
+
+ Args:
+ data (str): XML 字符串。
+
+ Returns:
+ MessageSegment: 一个类型为 'xml' 的消息段对象。
+ """
+ return MessageSegment(type="xml", data={"data": data})
+ @staticmethod
+ def share(url: str, title: str, content: str = None, image: str = None) -> "MessageSegment":
+ """
+ 创建一个分享消息段。
+
+ Args:
+ url (str): 分享的 URL。
+ title (str): 分享的标题。
+ content (str, optional): 分享的描述内容。Defaults to None.
+ image (str, optional): 分享的图片 URL。Defaults to None.
+
+ Returns:
+ MessageSegment: 一个类型为 'share' 的消息段对象。
+ """
+ data = {"url": url, "title": title}
+ if content:
+ data["content"] = content
+ if image:
+ data["image"] = image
+ return MessageSegment(type="share", data=data)
+ @staticmethod
+ def music(type: str, id: str) -> "MessageSegment":
+ """
+ 创建一个音乐消息段。
+
+ Args:
+ type (str): 音乐平台类型,如 "qq"、"xiami" 等。
+ id (str): 音乐在平台上的唯一标识符。
+
+ Returns:
+ MessageSegment: 一个类型为 'music' 的消息段对象。
+ """
+ return MessageSegment(type="music", data={"type": type, "id": id})
+ @staticmethod
+ def music_custom(url: str, audio: str, title: str, content: str = None, image: str = None) -> "MessageSegment":
+ """
+ 创建一个自定义音乐消息段。
+
+ Args:
+ url (str): 音乐的 URL。
+ audio (str): 音乐的音频 URL。
+ title (str): 音乐的标题。
+ content (str, optional): 音乐的描述内容。Defaults to None.
+ image (str, optional): 音乐的图片 URL。Defaults to None.
+
+ Returns:
+ MessageSegment: 一个类型为 'music_custom' 的消息段对象。
+ """
+ data = {"url": url, "audio": audio, "title": title}
+ if content:
+ data["content"] = content
+ if image:
+ data["image"] = image
+ return MessageSegment(type="music", data={"type": "custom", **data})
+ @staticmethod
+ def record(file: str, magic: bool = False, cache: bool = True, proxy: bool = True, timeout: int = None) -> "MessageSegment":
+ """
+ 创建一个语音消息段。
+
+ Args:
+ file (str): 语音的路径、URL 或 Base64 编码的字符串。
+ magic (bool, optional): 是否为变声。Defaults to False.
+ cache (bool, optional): 是否使用缓存。Defaults to True.
+ proxy (bool, optional): 是否通过代理下载。Defaults to True.
+ timeout (int, optional): 下载超时时间(秒)。Defaults to None.
+
+ Returns:
+ MessageSegment: 一个类型为 'record' 的消息段对象。
+ """
+ data = {"file": file, "magic": "1" if magic else "0", "cache": "1" if cache else "0", "proxy": "1" if proxy else "0"}
+ if timeout:
+ data["timeout"] = str(timeout)
+ return MessageSegment(type="record", data=data)
+ @staticmethod
+ def video(file: str, cover: str = None, c: int = 2) -> "MessageSegment":
+ """
+ 创建一个视频消息段。
+
+ Args:
+ file (str): 视频的路径、URL 或 Base64 编码的字符串。
+ cover (str, optional): 视频封面,支持http, file和base64。Defaults to None.
+ c (int, optional): 下载线程数。Defaults to 2.
+
+ Returns:
+ MessageSegment: 一个类型为 'video' 的消息段对象。
+ """
+ data = {"file": file, "c": str(c)}
+ if cover:
+ data["cover"] = cover
+ return MessageSegment(type="video", data=data)
+ @staticmethod
+ def file(file: str) -> "MessageSegment":
+ """
+ 创建一个文件消息段。
+
+ Args:
+ file (str): 文件的路径、URL 或 Base64 编码的字符串。
+
+ Returns:
+ MessageSegment: 一个类型为 'file' 的消息段对象。
+ """
+ return MessageSegment(type="file", data={"file": file})
+
+ @staticmethod
+ def reply(message_id: str) -> "MessageSegment":
+ """
+ 创建一个回复消息段。
+
+ Args:
+ message_id (str): 被回复的消息 ID。
+
+ Returns:
+ MessageSegment: 一个类型为 'reply' 的消息段对象。
+ """
+ return MessageSegment(type="reply", data={"id": message_id})
+
+ @staticmethod
+ def rps() -> "MessageSegment":
+ """
+ 创建一个猜拳魔法表情消息段。
+
+ Returns:
+ MessageSegment: 一个类型为 'rps' 的消息段对象。
+ """
+ return MessageSegment(type="rps", data={})
+
+ @staticmethod
+ def dice() -> "MessageSegment":
+ """
+ 创建一个掷骰子魔法表情消息段。
+
+ Returns:
+ MessageSegment: 一个类型为 'dice' 的消息段对象。
+ """
+ return MessageSegment(type="dice", data={})
+
+ @staticmethod
+ def shake() -> "MessageSegment":
+ """
+ 创建一个戳一戳消息段。
+
+ Returns:
+ MessageSegment: 一个类型为 'shake' 的消息段对象。
+ """
+ return MessageSegment(type="shake", data={})
+
+ @staticmethod
+ def anonymous(ignore: bool = False) -> "MessageSegment":
+ """
+ 创建一个匿名消息段。
+
+ Args:
+ ignore (bool, optional): 发送失败时是否忽略。Defaults to False.
+
+ Returns:
+ MessageSegment: 一个类型为 'anonymous' 的消息段对象。
+ """
+ return MessageSegment(type="anonymous", data={"ignore": "1" if ignore else "0"})
+
+ @staticmethod
+ def contact(contact_type: str, contact_id: int) -> "MessageSegment":
+ """
+ 创建一个推荐好友/群消息段。
+
+ Args:
+ contact_type (str): 推荐类型,'qq' 或 'group'。
+ contact_id (int): 被推荐的 QQ 号或群号。
+
+ Returns:
+ MessageSegment: 一个类型为 'contact' 的消息段对象。
+ """
+ return MessageSegment(type="contact", data={"type": contact_type, "id": str(contact_id)})
+
+ @staticmethod
+ def location(lat: float, lon: float, title: str = "", content: str = "") -> "MessageSegment":
+ """
+ 创建一个位置消息段。
+
+ Args:
+ lat (float): 纬度。
+ lon (float): 经度。
+ title (str, optional): 标题。Defaults to "".
+ content (str, optional): 内容描述。Defaults to "".
+
+ Returns:
+ MessageSegment: 一个类型为 'location' 的消息段对象。
+ """
+ data = {"lat": str(lat), "lon": str(lon)}
+ if title:
+ data["title"] = title
+ if content:
+ data["content"] = content
+ return MessageSegment(type="location", data=data)
\ No newline at end of file
diff --git a/plugins/bili_parser.py b/plugins/bili_parser.py
index 8c08865..a44170f 100644
--- a/plugins/bili_parser.py
+++ b/plugins/bili_parser.py
@@ -1,10 +1,9 @@
# -*- coding: utf-8 -*-
import re
import json
-import html
import requests
from bs4 import BeautifulSoup
-from typing import Optional, Tuple, Dict, Any
+from typing import Optional, Dict, Any
from core.logger import logger
from core.command_manager import matcher
@@ -29,6 +28,14 @@ def format_count(num: int) -> str:
return f"{num / 10000:.1f}万"
+def format_duration(seconds: int) -> str:
+ """将秒数格式化为 MM:SS 的形式"""
+ if not isinstance(seconds, int) or seconds < 0:
+ return "滚木"
+ minutes, seconds = divmod(seconds, 60)
+ return f"{minutes:02d}:{seconds:02d}"
+
+
def get_real_url(short_url: str) -> Optional[str]:
try:
response = requests.head(short_url, headers=HEADERS, allow_redirects=False, timeout=5)
@@ -52,23 +59,35 @@ def parse_video_info(video_url: str) -> Optional[Dict[str, Any]]:
data = json.loads(json_str)
video_data = data.get('videoData', {})
+ up_data = data.get('upData', {})
stat = video_data.get('stat', {})
+ owner = video_data.get('owner', {})
cover_url = video_data.get('pic', '')
if cover_url:
cover_url = cover_url.split('@')[0]
if cover_url.startswith('//'):
cover_url = 'https:' + cover_url
+
+ owner_avatar = owner.get('face', '')
+ if owner_avatar:
+ if owner_avatar.startswith('//'):
+ owner_avatar = 'https:' + owner_avatar
+ owner_avatar = owner_avatar.split('@')[0]
return {
"title": video_data.get('title', '未知标题'),
"bvid": video_data.get('bvid', '未知BV号'),
+ "duration": video_data.get('duration', 0),
"cover_url": cover_url,
"play": stat.get('view', 0),
"like": stat.get('like', 0),
"coin": stat.get('coin', 0),
"favorite": stat.get('favorite', 0),
"share": stat.get('share', 0),
+ "owner_name": owner.get('name', '未知UP主'),
+ "owner_avatar": owner_avatar,
+ "followers": up_data.get('fans', 0),
}
except (requests.RequestException, KeyError, AttributeError, json.JSONDecodeError) as e:
@@ -76,33 +95,52 @@ def parse_video_info(video_url: str) -> Optional[Dict[str, Any]]:
return None
+def get_direct_video_url(video_url: str) -> Optional[str]:
+ """
+ 调用第三方API解析B站视频直链
+ :param video_url: B站视频的完整URL
+ :return: 视频直链URL,如果失败则返回None
+ """
+ api_url = f"https://api.mir6.com/api/bzjiexi?url={video_url}&type=json"
+ try:
+ response = requests.get(api_url, headers=HEADERS, timeout=10)
+ response.raise_for_status()
+ data = response.json()
+ if data.get("code") == 200 and data.get("data"):
+ return data["data"][0].get("video_url")
+ except (requests.RequestException, json.JSONDecodeError, KeyError, IndexError) as e:
+ logger.error(f"[bili_parser] 调用第三方API解析视频失败: {e}")
+ return None
+
@matcher.on_message()
async def handle_bili_share(event: MessageEvent):
- if not event.raw_message.startswith('[CQ:json,data='):
- return
+ # 遍历消息段,寻找JSON CQ码
+ for segment in event.message:
+ if segment.type == "json":
+ logger.info(f"[bili_parser] 检测到JSON CQ码: {segment.data}")
+ try:
+ # 直接从segment的data中获取json字符串
+ json_data = json.loads(segment.data.get("data", "{}"))
+
+ # 提取B站短链接
+ short_url = json_data.get("meta", {}).get("detail_1", {}).get("qqdocurl")
+
+ if not short_url or "b23.tv" not in short_url:
+ continue # 如果不是B站链接,继续检查下一个segment
+
+ short_url = short_url.split('?')[0]
+ logger.success(f"[bili_parser] 成功提取到B站短链接: {short_url}")
+
+ # 找到了有效的B站链接,处理并跳出循环
+ await process_bili_link(event, short_url)
+ break
- logger.info(f"[bili_parser] 检测到JSON CQ码: {event.raw_message}")
+ except (json.JSONDecodeError, KeyError) as e:
+ logger.error(f"[bili_parser] 解析JSON失败: {e}")
+ continue
- try:
- json_str_raw = event.raw_message.strip('[CQ:json,data=]').rstrip(']')
-
- json_str_decoded = html.unescape(json_str_raw)
-
- data = json.loads(json_str_decoded)
-
- short_url = data.get("meta", {}).get("detail_1", {}).get("qqdocurl")
-
- if not short_url or "b23.tv" not in short_url:
- logger.warning("[bili_parser] JSON中未找到有效的b23.tv链接。")
- return
-
- short_url = short_url.split('?')[0]
- logger.success(f"[bili_parser] 成功提取到B站短链接: {short_url}")
-
- except (json.JSONDecodeError, KeyError) as e:
- logger.error(f"[bili_parser] 解析JSON失败: {e}")
- return
-
+async def process_bili_link(event: MessageEvent, short_url: str):
+ """处理B站链接,获取信息并回复"""
real_url = get_real_url(short_url)
if not real_url:
logger.error(f"[bili_parser] 无法从 {short_url} 获取真实URL。")
@@ -115,11 +153,25 @@ async def handle_bili_share(event: MessageEvent):
await event.reply("无法获取视频信息,可能是B站接口变动或视频不存在。")
return
+ # 检查视频时长
+ if video_info['duration'] > 300: # 5分钟 = 300秒
+ video_message = "视频太长了。。。"
+ else:
+ direct_url = get_direct_video_url(real_url)
+ if direct_url:
+ video_message = MessageSegment.video(direct_url)
+ else:
+ video_message = "视频解析失败,无法获取直链。"
+
text_message = (
f"BiliBili 视频解析\n"
f"--------------------\n"
+ f" UP主: {video_info['owner_name']}\n"
+ f" 粉丝: {format_count(video_info['followers'])}\n"
+ f"--------------------\n"
f" 标题: {video_info['title']}\n"
f" BV号: {video_info['bvid']}\n"
+ f" 时长: {format_duration(video_info['duration'])}\n"
f"--------------------\n"
f" 数据:\n"
f" 播放: {format_count(video_info['play'])}\n"
@@ -130,12 +182,23 @@ async def handle_bili_share(event: MessageEvent):
f" B站链接: {short_url}"
)
- image_message_segment = [MessageSegment.text("B站封面:"),MessageSegment.image(video_info['cover_url'])]
+ image_message_segment = [
+ MessageSegment.text("B站封面:"),
+ MessageSegment.image(video_info['cover_url'])
+ ]
+
+ up_info_segment = [
+ MessageSegment.text("UP主头像:"),
+ MessageSegment.image(video_info['owner_avatar'])
+ ]
nodes = [
event.bot.build_forward_node(user_id=event.self_id, nickname="B站视频解析", message=text_message),
- event.bot.build_forward_node(user_id=event.self_id, nickname="B站视频解析", message=image_message_segment)
+ event.bot.build_forward_node(user_id=event.self_id, nickname="B站视频解析", message=image_message_segment),
+ event.bot.build_forward_node(user_id=event.self_id, nickname="B站视频解析", message=up_info_segment),
+ event.bot.build_forward_node(user_id=event.self_id, nickname="B站视频解析", message=video_message)
]
logger.success(f"[bili_parser] 成功解析视频信息并准备以聊天记录形式回复: {video_info['title']}")
- await event.bot.send_group_forward_msg(group_id=event.group_id, messages=nodes)
+ # 使用更通用的 send_forwarded_messages 方法,自动判断私聊或群聊
+ await event.bot.send_forwarded_messages(target=event, nodes=nodes)
From bee6479d323d850b4cc4e91bb73c192d2a41af62 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Mon, 5 Jan 2026 21:54:38 +0800
Subject: [PATCH 21/46] hotfix
---
plugins/bili_parser.py | 68 ++++++++++++++++++++++++++++--------------
1 file changed, 45 insertions(+), 23 deletions(-)
diff --git a/plugins/bili_parser.py b/plugins/bili_parser.py
index a44170f..ed93b1f 100644
--- a/plugins/bili_parser.py
+++ b/plugins/bili_parser.py
@@ -112,40 +112,62 @@ def get_direct_video_url(video_url: str) -> Optional[str]:
logger.error(f"[bili_parser] 调用第三方API解析视频失败: {e}")
return None
+BILI_URL_PATTERN = re.compile(r"https?://(?:www\.)?(bilibili\.com/video/[a-zA-Z0-9_]+|b23\.tv/[a-zA-Z0-9]+)")
+
+
@matcher.on_message()
async def handle_bili_share(event: MessageEvent):
- # 遍历消息段,寻找JSON CQ码
+ """
+ 处理消息,检测B站分享链接(JSON卡片或文本链接)并进行解析。
+ :param event: 消息事件对象
+ """
+ url_to_process = None
+
+ # 1. 优先解析JSON卡片中的短链接
for segment in event.message:
if segment.type == "json":
logger.info(f"[bili_parser] 检测到JSON CQ码: {segment.data}")
try:
- # 直接从segment的data中获取json字符串
json_data = json.loads(segment.data.get("data", "{}"))
-
- # 提取B站短链接
short_url = json_data.get("meta", {}).get("detail_1", {}).get("qqdocurl")
- if not short_url or "b23.tv" not in short_url:
- continue # 如果不是B站链接,继续检查下一个segment
-
- short_url = short_url.split('?')[0]
- logger.success(f"[bili_parser] 成功提取到B站短链接: {short_url}")
-
- # 找到了有效的B站链接,处理并跳出循环
- await process_bili_link(event, short_url)
- break
-
+ if short_url and "b23.tv" in short_url:
+ url_to_process = short_url.split('?')[0]
+ logger.success(f"[bili_parser] 成功从JSON卡片中提取到B站短链接: {url_to_process}")
+ break # 找到后立即跳出循环
except (json.JSONDecodeError, KeyError) as e:
logger.error(f"[bili_parser] 解析JSON失败: {e}")
continue
+
+ # 2. 如果未在JSON卡片中找到链接,则在文本消息中查找
+ if not url_to_process:
+ for segment in event.message:
+ if segment.type == "text":
+ text_content = segment.data.get("text", "")
+ match = BILI_URL_PATTERN.search(text_content)
+ if match:
+ url_to_process = match.group(0)
+ logger.success(f"[bili_parser] 成功从文本中提取到B站链接: {url_to_process}")
+ break # 找到后立即跳出循环
-async def process_bili_link(event: MessageEvent, short_url: str):
- """处理B站链接,获取信息并回复"""
- real_url = get_real_url(short_url)
- if not real_url:
- logger.error(f"[bili_parser] 无法从 {short_url} 获取真实URL。")
- await event.reply("无法解析B站短链接。")
- return
+ # 3. 如果找到了任何类型的B站链接,则进行处理
+ if url_to_process:
+ await process_bili_link(event, url_to_process)
+
+async def process_bili_link(event: MessageEvent, url: str):
+ """
+ 处理B站链接(长链接或短链接),获取信息并回复
+ :param event: 消息事件对象
+ :param url: 待处理的B站链接
+ """
+ if "b23.tv" in url:
+ real_url = get_real_url(url)
+ if not real_url:
+ logger.error(f"[bili_parser] 无法从 {url} 获取真实URL。")
+ await event.reply("无法解析B站短链接。")
+ return
+ else:
+ real_url = url.split('?')[0]
video_info = parse_video_info(real_url)
if not video_info:
@@ -155,7 +177,7 @@ async def process_bili_link(event: MessageEvent, short_url: str):
# 检查视频时长
if video_info['duration'] > 300: # 5分钟 = 300秒
- video_message = "视频太长了。。。"
+ video_message = "视频时长超过5分钟,不进行解析。"
else:
direct_url = get_direct_video_url(real_url)
if direct_url:
@@ -179,7 +201,7 @@ async def process_bili_link(event: MessageEvent, short_url: str):
f" 投币: {format_count(video_info['coin'])}\n"
f" 收藏: {format_count(video_info['favorite'])}\n"
f" 转发: {format_count(video_info['share'])}\n"
- f" B站链接: {short_url}"
+ f" B站链接: {url}"
)
image_message_segment = [
From faddb0f52146a495fe73e9030e2dc53fb44afa61 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=95=80=E9=93=AC=E9=85=B8=E9=92=BE?=
<148796996+K2cr2O1@users.noreply.github.com>
Date: Mon, 5 Jan 2026 21:55:54 +0800
Subject: [PATCH 22/46] Dev (#24)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: 整合开发历史
* codepy安全性升级
* 优化一些东西
* 再次优化
* 更新一下 requirements.txt
* CQ码支持以及视频解析
* hotfix
---
plugins/bili_parser.py | 68 ++++++++++++++++++++++++++++--------------
1 file changed, 45 insertions(+), 23 deletions(-)
diff --git a/plugins/bili_parser.py b/plugins/bili_parser.py
index a44170f..ed93b1f 100644
--- a/plugins/bili_parser.py
+++ b/plugins/bili_parser.py
@@ -112,40 +112,62 @@ def get_direct_video_url(video_url: str) -> Optional[str]:
logger.error(f"[bili_parser] 调用第三方API解析视频失败: {e}")
return None
+BILI_URL_PATTERN = re.compile(r"https?://(?:www\.)?(bilibili\.com/video/[a-zA-Z0-9_]+|b23\.tv/[a-zA-Z0-9]+)")
+
+
@matcher.on_message()
async def handle_bili_share(event: MessageEvent):
- # 遍历消息段,寻找JSON CQ码
+ """
+ 处理消息,检测B站分享链接(JSON卡片或文本链接)并进行解析。
+ :param event: 消息事件对象
+ """
+ url_to_process = None
+
+ # 1. 优先解析JSON卡片中的短链接
for segment in event.message:
if segment.type == "json":
logger.info(f"[bili_parser] 检测到JSON CQ码: {segment.data}")
try:
- # 直接从segment的data中获取json字符串
json_data = json.loads(segment.data.get("data", "{}"))
-
- # 提取B站短链接
short_url = json_data.get("meta", {}).get("detail_1", {}).get("qqdocurl")
- if not short_url or "b23.tv" not in short_url:
- continue # 如果不是B站链接,继续检查下一个segment
-
- short_url = short_url.split('?')[0]
- logger.success(f"[bili_parser] 成功提取到B站短链接: {short_url}")
-
- # 找到了有效的B站链接,处理并跳出循环
- await process_bili_link(event, short_url)
- break
-
+ if short_url and "b23.tv" in short_url:
+ url_to_process = short_url.split('?')[0]
+ logger.success(f"[bili_parser] 成功从JSON卡片中提取到B站短链接: {url_to_process}")
+ break # 找到后立即跳出循环
except (json.JSONDecodeError, KeyError) as e:
logger.error(f"[bili_parser] 解析JSON失败: {e}")
continue
+
+ # 2. 如果未在JSON卡片中找到链接,则在文本消息中查找
+ if not url_to_process:
+ for segment in event.message:
+ if segment.type == "text":
+ text_content = segment.data.get("text", "")
+ match = BILI_URL_PATTERN.search(text_content)
+ if match:
+ url_to_process = match.group(0)
+ logger.success(f"[bili_parser] 成功从文本中提取到B站链接: {url_to_process}")
+ break # 找到后立即跳出循环
-async def process_bili_link(event: MessageEvent, short_url: str):
- """处理B站链接,获取信息并回复"""
- real_url = get_real_url(short_url)
- if not real_url:
- logger.error(f"[bili_parser] 无法从 {short_url} 获取真实URL。")
- await event.reply("无法解析B站短链接。")
- return
+ # 3. 如果找到了任何类型的B站链接,则进行处理
+ if url_to_process:
+ await process_bili_link(event, url_to_process)
+
+async def process_bili_link(event: MessageEvent, url: str):
+ """
+ 处理B站链接(长链接或短链接),获取信息并回复
+ :param event: 消息事件对象
+ :param url: 待处理的B站链接
+ """
+ if "b23.tv" in url:
+ real_url = get_real_url(url)
+ if not real_url:
+ logger.error(f"[bili_parser] 无法从 {url} 获取真实URL。")
+ await event.reply("无法解析B站短链接。")
+ return
+ else:
+ real_url = url.split('?')[0]
video_info = parse_video_info(real_url)
if not video_info:
@@ -155,7 +177,7 @@ async def process_bili_link(event: MessageEvent, short_url: str):
# 检查视频时长
if video_info['duration'] > 300: # 5分钟 = 300秒
- video_message = "视频太长了。。。"
+ video_message = "视频时长超过5分钟,不进行解析。"
else:
direct_url = get_direct_video_url(real_url)
if direct_url:
@@ -179,7 +201,7 @@ async def process_bili_link(event: MessageEvent, short_url: str):
f" 投币: {format_count(video_info['coin'])}\n"
f" 收藏: {format_count(video_info['favorite'])}\n"
f" 转发: {format_count(video_info['share'])}\n"
- f" B站链接: {short_url}"
+ f" B站链接: {url}"
)
image_message_segment = [
From 6fde4eac7b935cf5db628aeda7bcb6f311c957d6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=95=80=E9=93=AC=E9=85=B8=E9=92=BE?=
<148796996+K2cr2O1@users.noreply.github.com>
Date: Tue, 6 Jan 2026 02:03:03 +0800
Subject: [PATCH 23/46] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 99b7656..5c299b3 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# NEO Bot Framework
+# Calglau BOT by NEO Bot Framework
## 📖 概述
From 5b3cd5bbd03b5a96424664860a97bdca801ff7b2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=95=80=E9=93=AC=E9=85=B8=E9=92=BE?=
<148796996+K2cr2O1@users.noreply.github.com>
Date: Tue, 6 Jan 2026 19:46:44 +0800
Subject: [PATCH 24/46] Create LICENSE
---
LICENSE | 8 ++++++++
1 file changed, 8 insertions(+)
create mode 100644 LICENSE
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..709d2cd
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,8 @@
+Copyright (c) 2026 Fairy-Oracle-Sanctuary Team
+
+All Rights Reserved.
+
+This software is proprietary and confidential. Unauthorized copying, distribution,
+or use of this software, via any medium, is strictly prohibited.
+
+This software is intended for internal use by authorized personnel only.
From 839add3cb99fd125fd21f29015c86bdb44f3bbf0 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Tue, 6 Jan 2026 19:48:46 +0800
Subject: [PATCH 25/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0DEV=20readme.md?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 1123 +++++++----------------------------------------------
1 file changed, 144 insertions(+), 979 deletions(-)
diff --git a/README.md b/README.md
index 99b7656..71d2c06 100644
--- a/README.md
+++ b/README.md
@@ -1,267 +1,117 @@
-# NEO Bot Framework
+# Calglau BOT
-## 📖 概述
+> **[INTERNAL USE ONLY]**
+>
+> 本仓库为 Calglau BOT 的内部开发版本,请遵守相关保密协议。
-NEO 是一个基于 Python 的现代化 OneBot 11 协议机器人框架,专为需要高性能、可扩展性和开发效率的团队设计。该框架通过 WebSocket 与各种 OneBot 实现端(如 NapCatQQ、LLOneBot 等)通信,提供了一套完整的机器人开发解决方案。
+**Powered by NEO Bot Framework**
-### 设计理念
+## 📖 项目概述
-NEO 框架的设计遵循以下核心理念:
+**Calglau BOT** 是一个基于 NEO Bot Framework 构建的、功能丰富的 QQ 机器人。它被设计为一个模块化、易于扩展的内部工具,通过插件化的方式集成了多种实用与娱乐功能。
-1. **开发者友好**:简洁的 API 设计、完整的类型提示和详细的文档,让开发者能够快速上手和高效开发
-2. **架构清晰**:采用模块化设计,分离关注点,使代码易于维护和扩展
-3. **高性能异步**:基于 `asyncio` 和 `websockets` 构建,支持高并发消息处理
-4. **类型安全**:全面使用 Python 类型系统,提供编译时类型检查,减少运行时错误
-5. **热重载支持**:支持插件热重载,开发过程中修改代码无需重启机器人
+本项目旨在提供一个稳定、高性能且开发体验优秀的机器人平台,服务于我们的社群管理和日常自动化需求。
-### 核心价值
+### ✨ 核心特性
-- **快速原型开发**:通过简洁的装饰器语法快速定义指令和事件处理器
-- **生产环境就绪**:内置断线重连、错误处理和性能监控机制
-- **可扩展架构**:支持自定义插件、中间件和权限系统
-- **现代化开发体验**:支持热重载、类型提示和完整的 API 文档
+* **模块化插件架构**:所有功能均以独立插件形式存在于 `plugins/` 目录,易于开发、维护和热重载。
+* **高性能异步核心**:基于 `asyncio` 和 `websockets`,确保在高并发消息下依然响应迅速。
+* **开发者友好**:内置插件热重载,修改代码无需重启;完整的类型提示和清晰的 API 设计,提升开发效率。
+* **集成 Redis 缓存**:自动缓存常用 API 调用(如群信息),减少重复请求,提升响应速度。
+* **内置帮助系统**:通过 `/help` 指令可自动生成并展示所有已加载插件的功能说明。
-### 适用场景
+### 🛠️ 技术栈
-- QQ 群机器人管理
-- 自动化客服与问答系统
-- 游戏社区管理
-- 团队内部工具集成
-- 教育与培训辅助
+* **核心框架**: Python 3.8+ & NEO Bot Framework
+* **异步库**: `asyncio`
+* **网络通信**: `websockets` (OneBot v11)
+* **缓存**: `Redis`
+* **日志**: `Loguru`
+* **文件监控**: `watchdog` (用于热重载)
-## ✨ 特性
-
-* **OneBot 11 标准支持**:完整支持 OneBot 11 的消息、通知、请求和元事件。
-* **类型安全**:基于 `dataclasses` 的强类型事件模型,开发体验更佳。
-* **插件系统**:轻量级的装饰器风格插件系统,支持指令 (`@matcher.command`) 和事件监听 (`@matcher.on_notice`, `@matcher.on_request`)。
-* **插件元数据与内置帮助**:插件可通过 `__plugin_meta__` 变量进行自我描述。框架核心内置了 `/help` 指令,可自动收集并展示所有插件的帮助信息,无需手动维护。
-* **🔥 热重载支持**:内置文件监控,修改 `plugins` 下的代码自动重载,无需重启,极大提升调试效率。
-* **异步核心**:基于 `asyncio` 和 `websockets` 的高性能异步核心。
-* **自动重连**:内置 WebSocket 断线重连机制。
-
-## ⚡️ 性能优化
-
-### Redis 缓存机制
-为了提升响应速度并减少对 OneBot API 的重复调用,框架核心集成了一套基于 Redis 的缓存系统。对于一些不频繁变更的数据(如群信息、好友列表等),首次查询后会将其缓存至 Redis,在缓存有效期内(默认为 1 小时),后续请求将直接从 Redis 读取,极大提升了性能。
-
-#### 工作原理
-- **自动缓存**:框架会自动缓存特定 API 的调用结果。
-- **缓存键**:缓存键根据 API 名称和关键参数(如 `group_id`, `user_id`)生成,确保唯一性。
-- **过期时间**:默认缓存 1 小时,之后会自动失效,下次调用时将重新从 OneBot 实现端获取最新数据。
-
-#### 受影响的 API
-以下核心 API 已默认启用缓存:
-- `get_group_info`
-- `get_group_member_info`
-- `get_friend_list`
-- `get_stranger_info`
-- `get_login_info`
-
-#### 如何绕过缓存
-在某些场景下,你可能需要获取实时数据而非缓存数据。为此,所有受缓存影响的 API 方法都增加了一个 `no_cache: bool = False` 的可选参数。
-
-当你需要强制从服务器获取最新信息时,只需在调用时传入 `no_cache=True` 即可。
-
-**示例:**
-```python
-# 正常调用,会使用缓存
-group_info = await bot.get_group_info(group_id=12345)
-
-# 强制获取最新信息,不使用缓存
-latest_group_info = await bot.get_group_info(group_id=12345, no_cache=True)
-```
-
-### `__slots__` 内存优化
-框架内的所有数据模型(包括事件、消息段、API 返回对象等)均已启用 `__slots__ = True` 优化。这可以显著减少每个对象实例的内存占用,特别是在处理大量事件和数据时,能够有效降低机器人的整体内存消耗。
-
-
-## 📝 待办事项 (TODO)
-
-### API 封装
-- [x] **消息相关**
- - `delete_msg`: 撤回消息
- - `get_msg`: 获取消息
- - `get_forward_msg`: 获取合并转发消息
- - `send_like`: 发送点赞
-- [x] **群组管理**
- - `set_group_kick`: 群组踢人
- - `set_group_ban`: 群组单人禁言
- - `set_group_anonymous_ban`: 群组匿名禁言
- - `set_group_whole_ban`: 群组全员禁言
- - `set_group_admin`: 群组设置管理员
- - `set_group_anonymous`: 群组匿名
- - `set_group_card`: 设置群名片(群备注)
- - `set_group_name`: 设置群名
- - `set_group_leave`: 退出群组
- - `set_group_special_title`: 设置群组专属头衔
-- [x] **群组信息**
- - `get_group_info`: 获取群信息
- - `get_group_list`: 获取群列表
- - `get_group_member_info`: 获取群成员信息
- - `get_group_member_list`: 获取群成员列表
- - `get_group_honor_info`: 获取群荣誉信息
-- [x] **用户相关**
- - `get_login_info`: 获取登录号信息
- - `get_stranger_info`: 获取陌生人信息
- - `get_friend_list`: 获取好友列表
-- [x] **请求处理**
- - `set_friend_add_request`: 处理加好友请求
- - `set_group_add_request`: 处理加群请求/邀请
-- [x] **系统/其他**
- - `get_version_info`: 获取版本信息
- - `get_status`: 获取状态
- - `can_send_image`: 检查是否可以发送图片
- - `can_send_record`: 检查是否可以发送语音
- - `clean_cache`: 清理缓存
-
-### 待实现 API
-- [ ] **Web 凭证类**
- - `get_cookies`
- - `get_csrf_token`
- - `get_credentials`
-- [ ] **文件/资源信息**
- - `get_image`
- - `get_record`
- - `get_file`
-- [ ] **系统控制**
- - `set_restart`
-- [ ] **扩展功能**
- - [x] `send_forward_msg`: 发送合并转发消息
-
-### 其他改进
-- [x] **API 强类型封装**: 将 API 返回值从 `dict` 转换为数据模型对象。
-- [x] **Redis 支持**: 集成 Redis 连接池,便于插件复用连接。
-- [x] **权限系统**: 实现基础的权限管理(超级管理员、群管理员等)。
-- [x] **日志系统优化**: 引入 `loguru` 进行日志记录,支持文件输出和日志级别控制。
-- [x] **异常处理增强**: 增强插件执行过程中的异常捕获,防止单个插件崩溃影响整个 Bot。
-- [x] **中间件支持**: 添加消息处理中间件,支持在指令执行前/后进行拦截和处理。
+---
## 📂 项目结构
```
.
-├── plugins/ # 插件目录,新建插件文件即可自动加载(支持热重载)
-│ ├── admin.py # 管理员插件
-│ ├── code_py.py # Python 代码执行插件
-│ ├── echo.py # 示例插件:实现 /echo 和 /赞我 指令
-│ ├── forward_test.py # 示例插件:演示合并转发消息
-│ ├── jrcd.py # 娱乐插件:/jrcd 和 /bbcd
-│ └── thpic.py # 图片插件:/thpic
-├── core/ # 核心框架代码
-│ ├── api/ # API 模块抽象层
-│ ├── bot.py # Bot 实例与 API 封装
-│ ├── admin_manager.py # 管理员管理模块
-│ ├── command_manager.py # 命令与事件分发器
-│ ├── config_loader.py # 配置加载器
-│ ├── event_handler.py # 事件处理器
-│ ├── executor.py # 插件执行器
-│ ├── logger.py # 日志系统
-│ ├── permission_manager.py # 权限管理器
-│ ├── plugin_manager.py # 插件加载与管理
-│ ├── redis_manager.py # Redis 连接管理器
-│ └── ws.py # WebSocket 客户端核心
-├── data/ # 数据存储目录
-│ ├── admin.json # 管理员配置文件
-│ └── permissions.json # 权限数据
-├── html/ # HTML 静态文件
+├── plugins/ # 插件目录,所有机器人的功能模块都在这里
+│ ├── admin.py
+│ ├── bili_parser.py
+│ ├── code_py.py
+│ ├── echo.py
+│ ├── forward_test.py
+│ ├── jrcd.py
+│ └── thpic.py
+├── core/ # NEO 框架核心代码,通常无需修改
+│ ├── api/
+│ ├── bot.py
+│ ├── ...
+│ └── ws.py
+├── data/ # 数据存储目录 (管理员列表, 权限配置)
+│ ├── admin.json
+│ └── permissions.json
+├── html/ # 静态网页文件
│ ├── 404.html
│ └── index.html
-├── models/ # 数据模型
-│ ├── events/ # OneBot 事件定义
-│ ├── message.py # 消息段定义
-│ ├── objects.py # API 返回对象定义
-│ └── sender.py # 发送者定义
+├── models/ # 数据模型 (事件, 消息段等)
+│ ├── ...
├── .gitignore
-├── config.toml # 配置文件
-├── main.py # 启动入口(包含热重载监控)
-└── requirements.txt # 项目依赖
+├── config.toml # 主配置文件
+├── main.py # 项目启动入口
+└── requirements.txt # Python 依赖
```
-### 目录结构详细说明
-
-#### `plugins/` - 插件目录
-- **功能**存放:所有机器人插件,支持热重载机制
-- **加载机制**:框架会自动扫描此目录下的所有 `.py` 文件,并作为插件加载
-- **插件约定**:每个插件文件应包含 `__plugin_meta__` 字典用于插件元数据定义
-- **热重载**:开发过程中修改插件文件会自动触发重载,无需重启机器人
-- **内置插件**:
- - `admin.py` - 管理员管理插件,支持动态添加/移除管理员
- - `code_py.py` - Python 代码执行插件,支持安全的代码执行环境
- - `echo.py` - 示例插件,演示基本指令处理
- - `forward_test.py` - 合并转发消息演示插件
- - `jrcd.py` - 娱乐插件,提供 `/jrcd` 和 `/bbcd` 指令
- - `thpic.py` - 图片插件,提供 `/thpic` 指令返回东方Project图片
-
-#### `core/` - 核心框架代码
-- `api/` - API 模块抽象层
- - `base.py` - API 基类定义
- - `message.py` - 消息相关 API 封装
- - `group.py` - 群组管理 API 封装
- - `friend.py` - 好友相关 API 封装
- - `account.py` - 账号相关 API 封装
-- `bot.py` - Bot 核心类,通过 Mixin 模式继承所有 API 功能,提供统一的调用接口
- - `admin_manager.py` - 管理员管理模块,负责管理员的添加、移除和权限验证
- - `command_manager.py` - 命令与事件分发器,负责注册和处理所有指令和事件
-- `config_loader.py` - 配置加载器,读取和解析 `config.toml` 配置文件
-- `event_handler.py` - 事件处理器,负责将原始事件转换为类型化事件对象
-- `executor.py` - 插件执行器,提供线程池执行环境用于执行同步任务
-- `logger.py` - 日志系统,基于 `loguru` 提供高性能日志记录
-- `permission_manager.py` - 权限管理器,管理用户权限级别(admin、op、user)
-- `plugin_manager.py` - 插件加载与管理,负责插件的扫描、加载和热重载
-- `redis_manager.py` - Redis 连接管理器,提供异步 Redis 客户端连接池
-- `ws.py` - WebSocket 客户端核心,负责与 OneBot 实现端建立和管理连接
-
-#### `data/` - 数据存储目录
-- `admin.json` - 管理员配置文件,存储全局管理员列表
-- `permissions.json` - 权限数据文件,存储用户权限映射关系
-
-#### `html/` - HTML 静态文件
-- `404.html` - 404 错误页面
-- `index.html` - 项目主页 HTML 文件,展示项目信息和特性
-
-#### `models/` - 数据模型定义
-- `events/` - OneBot 事件定义
- - `base.py` - 事件基类定义
- - `message.py` - 消息事件定义
- - `notice.py` - 通知事件定义
- - `request.py` - 请求事件定义
- - `meta.py` - 元事件定义
- - `factory.py` - 事件工厂类,用于根据 JSON 数据创建对应事件对象
-- `message.py` - 消息段定义,支持文本、图片、表情等多种消息类型
-- `objects.py` - API 返回对象定义,提供强类型化的 API 响应数据模型
-- `sender.py` - 发送者定义,包含用户、群成员等信息
-
-#### 根目录文件
-- `.gitignore` - Git 忽略文件配置
-- `config.toml` - 主配置文件,包含 WebSocket 连接、机器人指令前缀、Redis 连接等配置
-- `main.py` - 程序入口文件,负责初始化插件、启动热重载监控和建立 WebSocket 连接
-- `requirements.txt` - Python 依赖包列表
+---
## 🚀 快速开始
### 1. 环境准备
-* Python 3.8+
-* OneBot 11 实现端(推荐 [NapCatQQ](https://github.com/NapNeko/NapCatQQ) 或 LLOneBot)
+* **Python 3.12 或更高版本**
+ * **我觉得**: 在开发和调试阶段使用官方的 **CPython** 解释器,以获得最佳的第三方库兼容性和调试体验。
+ * **你也可以觉得**: 在生产环境部署时,可以考虑使用 **PyPy** 以获取潜在的性能提升,但这可能会牺牲一定的兼容性。
+* Redis 服务
+* 一个正在运行的 OneBot v11 实现端 (推荐 **NapCatQQ**)
### 2. 安装依赖
+克隆本项目后,在项目根目录执行:
```bash
pip install -r requirements.txt
```
-### 3. 配置文件
+### 3. 配置
-修改根目录下的 `config.toml`,配置 WebSocket 连接信息:
+**[内部开发]**
+
+为了方便内部开发和调试,项目中的 `config.toml` 文件已预先配置为连接到官方的 DEV 调试服务器。
+
+**因此,在拉取仓库后,您通常无需对 `config.toml` 文件进行任何修改即可直接运行。**
+
+如果您需要连接到本地或其他特定环境,可以参考以下配置结构进行修改。配置示例:
```toml
+# config.toml
+
[napcat_ws]
-uri = "ws://127.0.0.1:30004" # OneBot 实现端的 WebSocket 地址
-token = "your_token" # Access Token (如果有)
-reconnect_interval = 5 # 断线重连间隔(秒)
+# OneBot 实现端的 WebSocket 地址
+uri = "ws://127.0.0.1:3001"
+# Access Token (如果有)
+token = ""
+# 断线重连间隔(秒)
+reconnect_interval = 5
[bot]
-command = ["/"] # 指令前缀,支持多个,如 ["/", "#"]
+# 机器人指令的起始符号,可配置多个
+command_prefixes = ["/", "!", "!"]
+
+[redis]
+# Redis 连接信息
+host = "127.0.0.1"
+port = 6379
+db = 0
+password = ""
```
### 4. 运行
@@ -269,794 +119,109 @@ command = ["/"] # 指令前缀,支持多个,如 ["/", "#"]
```bash
python main.py
```
+机器人启动后,将自动连接到 OneBot 实现端。控制台会输出加载的插件列表和连接状态。
-## 🛠️ 开发指南
+---
-### 🔥 热重载调试
+## 🛠️ 插件开发指南
-项目集成了 `watchdog` 文件监控。在开发过程中,你只需要:
-1. 保持 `main.py` 运行。
-2. 修改或新建 `plugins` 目录下的 `.py` 插件文件。
-3. 保存文件。
-4. 控制台会自动提示 `[HotReload] 插件重载完成`,新的逻辑立即生效。
+Calglau BOT 的所有功能都通过插件实现。开发新功能非常简单,并且得益于热重载,你无需在开发过程中频繁重启机器人。
-### 创建新插件
+### 🔥 热重载工作流
-在 `plugins` 目录下创建一个新的 `.py` 文件(例如 `my_plugin.py`),框架会自动加载它。
+1. 保持 `python main.py` 进程运行。
+2. 在 `plugins/` 目录下创建或修改任意 `.py` 文件。
+3. **保存文件**。
+4. 观察控制台输出 `[HotReload] 插件重载完成` 的提示。你的新代码已即时生效。
-### 示例代码
+### 创建一个新插件
-#### 1. 注册消息指令
+1. 在 `plugins/` 目录下新建一个 Python 文件,例如 `weather.py`。
+2. 在该文件中编写你的逻辑。
-使用 `@matcher.command("指令名")` 注册指令。
+#### 1. 定义插件元数据 (`__plugin_meta__`)
+
+为了让 `/help` 指令能自动发现你的插件,请在文件顶部定义 `__plugin_meta__` 字典:
```python
-from core.command_manager import matcher
-from core.bot import Bot
-from models import MessageEvent
+# plugins/weather.py
-# 注册 /hello 指令
-@matcher.command("hello")
-async def handle_hello(bot: Bot, event: MessageEvent, args: list[str]):
- # args 是去除指令后的参数列表
- await event.reply("你好!这里是 NEO Bot。")
-```
-
-#### 2. 监听通知事件
-
-使用 `@matcher.on_notice("通知类型")` 监听通知。
-
-```python
-from core.command_manager import matcher
-from core.bot import Bot
-from models import GroupIncreaseNoticeEvent
-
-# 监听群成员增加事件
-@matcher.on_notice("group_increase")
-async def welcome_new_member(bot: Bot, event: GroupIncreaseNoticeEvent):
- await bot.send_group_msg(event.group_id, f"欢迎新成员 {event.user_id} 加入!")
-```
-
-#### 3. 监听请求事件
-
-使用 `@matcher.on_request("请求类型")` 监听请求。
-
-```python
-from core.command_manager import matcher
-from core.bot import Bot
-from models import FriendRequestEvent
-
-# 自动同意好友请求
-@matcher.on_request("friend")
-async def auto_approve_friend(bot: Bot, event: FriendRequestEvent):
- await bot.call_api("set_friend_add_request", {
- "flag": event.flag,
- "approve": True
- })
-```
-
-#### 4. API 调用方式对比
-
-框架提供两种 API 调用方式:**类型化 API**(推荐)和 **通用 API**(备用)。
-
-##### 方式一:类型化 API(推荐)
-对于已封装的 API,框架提供了类型化的方法,返回数据模型对象而非原始字典:
-
-```python
-from core.command_manager import matcher
-from core.bot import Bot
-from models import MessageEvent
-from models.objects import Group
-
-@matcher.command("info")
-async def get_group_info_typed(bot: Bot, event: MessageEvent, args: list[str]):
- # 使用类型化 API,返回 Group 对象
- group: Group = await bot.get_group_info(event.group_id)
- await event.reply(f"群名:{group.group_name}\n成员数:{group.member_count}\n创建时间:{group.create_time}")
-```
-
-##### 方式二:通用 API(备用)
-如果框架尚未封装某个 OneBot API,你可以使用 `bot.call_api` 直接调用。这是通用的备用调用方法。
-
-```python
-from core.command_manager import matcher
-from core.bot import Bot
-from models import MessageEvent
-
-@matcher.command("info_legacy")
-async def get_group_info_legacy(bot: Bot, event: MessageEvent, args: list[str]):
- # 直接调用 get_group_info API
- # action: API 名称
- # params: API 参数字典
- resp = await bot.call_api("get_group_info", {
- "group_id": event.group_id,
- "no_cache": False
- })
-
- if resp.get("status") == "ok":
- group_name = resp["data"]["group_name"]
- await event.reply(f"当前群名:{group_name}")
-```
-
-**建议**:优先使用类型化 API,获得更好的类型安全和代码提示。仅在框架未封装特定 API 时使用通用 API。
-
-## 📖 插件开发指南
-
-### 插件基本结构
-一个标准的插件文件应该包含以下部分:
-1. **模块文档字符串**:描述插件功能
-2. **导入必要的模块**:从 `core` 和 `models` 导入所需类
-3. **使用装饰器注册事件处理器**:`@matcher.command()`, `@matcher.on_notice()`, `@matcher.on_request()`
-4. **异步函数实现业务逻辑**:使用 `async def` 定义处理函数
-
-### 插件元数据 (`__plugin_meta__`)
-为了实现插件的自动发现和帮助信息的自动生成,框架引入了插件元数据机制。你需要在你的插件模块中定义一个名为 `__plugin_meta__` 的字典。
-
-`load_all_plugins` 函数在加载插件时会自动读取这个变量,并将其注册到 `CommandManager` 中。`/help` 指令会遍历所有已注册的元数据,生成格式化的帮助信息。
-
-一个标准的 `__plugin_meta__` 包含以下字段:
-
-- `name` (str): 插件的友好名称,例如 "回声"。
-- `description` (str): 对插件功能的简短描述。
-- `usage` (str): 插件的使用方法,可以包含多个指令和它们的说明。
-
-**示例:**
-```python
-# plugins/echo.py
-
-__plugin_meta__ = {
- "name": "回声与交互",
- "description": "提供 echo 和 赞我 功能",
- "usage": "/echo [内容] - 复读内容\n/赞我 - 让机器人给你点赞",
-}
-```
-
-### 使用类型化 API
-框架现已提供完整的类型化 API 封装,建议优先使用这些封装方法而非原始的 `call_api`:
-
-| API 方法 | 返回类型 | 说明 |
-|---------|---------|------|
-| `bot.send_group_msg()` | `Message` | 发送群消息 |
-| `bot.get_group_info()` | `Group` | 获取群信息 |
-| `bot.get_group_member_info()` | `GroupMember` | 获取群成员信息 |
-| `bot.get_friend_list()` | `List[Friend]` | 获取好友列表 |
-| `bot.get_login_info()` | `LoginInfo` | 获取登录信息 |
-| `bot.get_version_info()` | `VersionInfo` | 获取版本信息 |
-
-#### 示例:使用类型化 API 重构群信息查询
-```python
-from core.command_manager import matcher
-from core.bot import Bot
-from models import MessageEvent
-from models.objects import Group
-
-@matcher.command("group_info")
-async def get_group_info_typed(bot: Bot, event: MessageEvent, args: list[str]):
- # 使用类型化 API,返回 Group 对象而非字典
- group: Group = await bot.get_group_info(event.group_id)
- await event.reply(f"群名:{group.group_name}\n成员数:{group.member_count}\n创建时间:{group.create_time}")
-```
-
-### 事件处理模式
-除了基本的消息指令,还可以处理多种事件类型:
-
-#### 1. 通知事件处理
-```python
-from models import GroupCardChangeEvent
-
-@matcher.on_notice("group_card")
-async def handle_group_card_change(bot: Bot, event: GroupCardChangeEvent):
- # event.card_new 是新名片,event.card_old 是旧名片
- await bot.send_group_msg(event.group_id, f"成员 {event.user_id} 的名片从 '{event.card_old}' 改为 '{event.card_new}'")
-```
-
-#### 2. 请求事件处理
-```python
-from models import GroupRequestEvent
-
-@matcher.on_request("group")
-async def handle_group_request(bot: Bot, event: GroupRequestEvent):
- # 根据请求类型处理
- if event.sub_type == "add":
- # 自动同意加群请求
- await bot.set_group_add_request(event.flag, event.sub_type, approve=True)
- await bot.send_group_msg(event.group_id, f"已同意用户 {event.user_id} 的加群请求")
-```
-
-### 错误处理
-建议在插件中添加适当的错误处理,避免单个插件崩溃影响整个机器人:
-
-```python
-@matcher.command("dangerous")
-async def dangerous_command(bot: Bot, event: MessageEvent, args: list[str]):
- try:
- # 可能失败的操作
- result = await bot.call_api("some_api", {"param": "value"})
- await event.reply(f"成功:{result}")
- except Exception as e:
- await event.reply(f"执行失败:{str(e)}")
- # 记录日志
- from core.logger import logger
- logger.error(f"插件执行错误:{e}", exc_info=True)
-```
-
-### 处理同步阻塞操作
-为了保持机器人的响应性,所有可能导致长时间阻塞的同步操作都应该在单独的线程池中执行。框架提供了 `run_in_thread_pool` 函数来简化这一过程。
-
-**示例:执行同步阻塞任务**
-```python
-from core.command_manager import matcher
-from core.bot import Bot
-from models import MessageEvent
-from core.executor import run_in_thread_pool
-import time
-
-# 模拟一个耗时的同步操作
-def blocking_task(duration: int):
- time.sleep(duration)
- return f"阻塞任务完成,耗时 {duration} 秒"
-
-@matcher.command("block_test")
-async def handle_blocking_test(bot: Bot, event: MessageEvent, args: list[str]):
- if not args or not args[0].isdigit():
- await event.reply("请提供一个数字作为阻塞时间(秒)。例如:/block_test 5")
- return
-
- duration = int(args[0])
- await event.reply(f"开始执行阻塞任务,耗时 {duration} 秒...")
-
- # 将同步阻塞任务放入线程池执行
- result = await run_in_thread_pool(blocking_task, duration)
- await event.reply(result)
-```
-
-### 权限管理
-框架内置了基于用户角色的权限管理系统,支持 `admin`(超级管理员)、`op`(操作员)、`user`(普通用户)三个权限级别。权限数据存储在 `data/permissions.json` 文件中。
-
-#### 权限级别说明
-- **admin**:最高权限,可以执行所有管理命令,包括添加/移除其他管理员
-- **op**:操作员权限,可以执行大部分管理命令,但不能修改管理员列表
-- **user**:普通用户权限,只能使用基础功能
-
-#### 在插件中使用权限控制
-注册命令时可以通过 `permission` 参数指定所需权限级别:
-
-```python
-from models import MessageEvent
-
-# 只有管理员可以执行此命令
-@matcher.command("admin_only", permission=MessageEvent.ADMIN)
-async def admin_command(bot: Bot, event: MessageEvent, args: list[str]):
- await event.reply("此命令仅限管理员使用")
-
-# 操作员及以上权限可以执行
-@matcher.command("op_only", permission=MessageEvent.OP)
-async def op_command(bot: Bot, event: MessageEvent, args: list[str]):
- await event.reply("此命令需要操作员权限")
-
-# 所有用户都可以执行(默认)
-@matcher.command("public")
-async def public_command(bot: Bot, event: MessageEvent, args: list[str]):
- await event.reply("所有用户都可以使用此命令")
-```
-
-#### 动态权限检查
-如果需要更复杂的权限逻辑,可以使用 `override_permission_check=True` 参数,然后在函数中手动检查权限:
-
-```python
-@matcher.command(
- "special",
- permission=MessageEvent.OP,
- override_permission_check=True
-)
-async def special_command(bot: Bot, event: MessageEvent, permission_granted: bool):
- if not permission_granted:
- await event.reply("权限不足!")
- return
-
- # 额外的权限逻辑
- if event.user_id == 123456:
- await event.reply("特殊用户,允许执行")
- else:
- await event.reply("普通用户,拒绝执行")
-```
-
-### 使用 Redis 进行数据缓存
-框架集成了 Redis 客户端,提供了便捷的异步接口用于数据缓存和持久化。Redis 连接管理器会自动管理连接池,你可以在插件中直接使用。
-
-#### 基本用法
-```python
-from core.redis_manager import redis_manager
-
-@matcher.command("cache")
-async def cache_example(bot: Bot, event: MessageEvent, args: list[str]):
- # 设置缓存
- await redis_manager.set("user:123:name", "张三")
-
- # 获取缓存
- name = await redis_manager.get("user:123:name")
-
- # 设置带过期时间的缓存(单位:秒)
- await redis_manager.setex("temp:data", 3600, "临时数据")
-
- # 删除缓存
- await redis_manager.delete("user:123:name")
-
- await event.reply(f"用户名:{name}")
-```
-
-#### 使用哈希表(Hash)
-```python
-# 设置哈希字段
-await redis_manager.hset("user:123", "age", 20)
-await redis_manager.hset("user:123", "city", "北京")
-
-# 获取哈希字段
-age = await redis_manager.hget("user:123", "age")
-user_data = await redis_manager.hgetall("user:123")
-
-# 删除哈希字段
-await redis_manager.hdel("user:123", "city")
-```
-
-#### 使用列表(List)
-```python
-# 向列表添加元素
-await redis_manager.lpush("recent:actions", "login")
-await redis_manager.rpush("recent:actions", "logout")
-
-# 获取列表范围
-actions = await redis_manager.lrange("recent:actions", 0, 9)
-
-# 获取列表长度
-length = await redis_manager.llen("recent:actions")
-```
-
-### 插件数据管理
-对于需要持久化存储配置或数据的插件,框架提供了 `PluginDataManager` 类,可以方便地管理 JSON 格式的数据文件。
-
-#### 基本用法
-```python
-from core.plugin_manager import PluginDataManager
-
-# 初始化数据管理器
-data_manager = PluginDataManager("weather_plugin")
-
-@matcher.command("weather_set")
-async def set_weather_config(bot: Bot, event: MessageEvent, args: list[str]):
- if len(args) < 2:
- await event.reply("用法:/weather_set <城市> <温度>")
- return
-
- city = args[0]
- temperature = args[1]
-
- # 保存配置
- await data_manager.set(city, temperature)
- await event.reply(f"已设置 {city} 的温度为 {temperature}℃")
-
-@matcher.command("weather_get")
-async def get_weather_config(bot: Bot, event: MessageEvent, args: list[str]):
- if not args:
- await event.reply("用法:/weather_get <城市>")
- return
-
- city = args[0]
-
- # 读取配置
- temperature = data_manager.get(city)
- if temperature:
- await event.reply(f"{city} 的温度是 {temperature}℃")
- else:
- await event.reply(f"未找到 {city} 的温度配置")
-```
-
-#### 数据文件位置
-插件数据文件保存在 `plugins/data/` 目录下,每个插件对应一个独立的 JSON 文件。例如 `weather_plugin` 插件的数据文件为 `plugins/data/weather_plugin.json`。
-
-### 插件开发最佳实践
-1. **单一职责**:每个插件专注于一个功能领域
-2. **错误处理**:妥善处理可能发生的异常
-3. **类型提示**:为函数参数和返回值添加类型提示
-4. **文档完整**:为每个函数添加文档字符串
-5. **性能考虑**:避免在插件中执行耗时同步操作
-6. **资源清理**:必要时使用 `try...finally` 确保资源释放
-
-### 🚀 高性能插件开发规范 (避坑指南)
-为了保证整个机器人框架的响应速度和稳定性,所有插件都必须遵循异步、非阻塞的开发原则。任何一个插件中的阻塞操作都可能导致整个机器人卡顿或无响应。
-
-以下是必须遵守的核心规范:
-
-#### 1. 禁止任何形式的同步网络请求
-- **错误示范**: 使用 `requests` 库发起网络请求。
- ```python
- import requests
- # 错误!这会阻塞整个程序
- response = requests.get("https://api.example.com")
- ```
-- **正确做法**: 必须使用异步 HTTP 客户端,如 `aiohttp` 或 `httpx`。
- ```python
- import httpx
- # 正确,使用 async with 和 await
- async with httpx.AsyncClient() as client:
- response = await client.get("https://api.example.com")
- ```
-
-#### 2. 禁止使用 `time.sleep()`
-- **错误示范**: 使用 `time.sleep()` 进行等待。
- ```python
- import time
- # 错误!这会阻塞事件循环
- time.sleep(5)
- ```
-- **正确做法**: 必须使用 `asyncio.sleep()`。
- ```python
- import asyncio
- # 正确,这会将控制权交还给事件循环
- await asyncio.sleep(5)
- ```
-
-#### 3. 谨慎处理文件 I/O
-- 对于读写小型、本地文件,直接使用 `with open(...)` 通常是可接受的。
-- 但对于大型文件或网络文件系统(NFS)上的文件,同步 I/O 可能会导致明显的阻塞。
-- **推荐做法**: 对于可能耗时较长的文件操作,使用 `aiofiles` 库。
- ```python
- import aiofiles
- async with aiofiles.open('large_file.dat', mode='rb') as f:
- contents = await f.read()
- ```
-
-#### 4. 将 CPU 密集型任务移出事件循环
-- 如果插件需要执行复杂的计算(例如,图像处理、视频转码、数据分析),这些任务会长时间占用 CPU,同样会阻塞事件循环。
-- **正确做法**: 使用 `loop.run_in_executor()` 将这类任务抛到独立的线程池或进程池中执行。
- ```python
- import asyncio
-
- def cpu_bound_task(data):
- # 这是一个耗时的同步函数
- # ... 进行大量计算 ...
- return "计算结果"
-
- # 在异步的事件处理器中调用
- loop = asyncio.get_running_loop()
- # `None` 表示使用默认的线程池
- result = await loop.run_in_executor(None, cpu_bound_task, "一些数据")
- await event.reply(result)
- ```
-
-遵循以上规范,可以确保您开发的插件不会成为整个机器人应用的性能瓶颈。
-
-### 示例:完整插件模板
-```python
-"""
-天气查询插件
-
-提供 /weather 指令,查询指定城市的天气信息。
-"""
-from core.command_manager import matcher
-from core.bot import Bot
-from models import MessageEvent
-
-# 插件元数据,用于 help 指令
__plugin_meta__ = {
"name": "天气查询",
- "description": "查询指定城市的天气信息",
- "usage": "/weather [城市名称]",
+ "description": "提供城市天气查询功能。",
+ "usage": "/weather [城市名] - 查询指定城市的实时天气。",
}
+```
+
+#### 2. 编写指令处理器
+
+使用 `@matcher.command()` 装饰器来注册一个聊天指令。
+
+```python
+# plugins/weather.py
+from core.command_manager import matcher
+from models import MessageEvent
+
+# ... (元数据定义) ...
@matcher.command("weather")
-async def handle_weather(bot: Bot, event: MessageEvent, args: list[str]):
+async def handle_weather_command(event: MessageEvent, args: list[str]):
"""
- 查询天气信息
-
- :param bot: Bot 实例
- :param event: 消息事件对象
- :param args: 指令参数列表(城市名称)
+ 处理 /weather 指令
+ :param event: 消息事件对象,用于回复等操作
+ :param args: 用户发送的参数列表 (已按空格分割)
"""
if not args:
- await event.reply("请输入城市名称,例如:/weather 北京")
+ await event.reply("请输入要查询的城市名,例如:/weather 北京")
return
- city = " ".join(args)
- try:
- # 这里可以调用天气 API
- weather_info = f"{city} 的天气:晴,25℃"
- await event.reply(weather_info)
- except Exception as e:
- await event.reply(f"查询天气失败:{str(e)}")
-
-# 可以注册多个事件处理器
-@matcher.on_notice("group_increase")
-async def welcome_new_member(bot: Bot, event):
- await bot.send_group_msg(event.group_id, f"欢迎新成员 {event.user_id} 加入!")
-```
-
-## 📚 事件模型说明
-
-NEO 框架的事件模型是基于 OneBot v11 协议的强类型数据模型,采用 `dataclasses` 和类型注解构建。所有事件都继承自 `OneBotEvent` 基类,并通过事件工厂自动从 JSON 数据创建对应的事件对象。
-
-### 事件层次结构
-
-```
-OneBotEvent (抽象基类)
-├── MetaEvent (元事件)
-│ ├── HeartbeatEvent (心跳事件)
-│ └── LifeCycleEvent (生命周期事件)
-├── MessageEvent (消息事件)
-│ ├── PrivateMessageEvent (私聊消息事件)
-│ └── GroupMessageEvent (群聊消息事件)
-├── NoticeEvent (通知事件)
-│ ├── FriendAddNoticeEvent (好友添加通知)
-│ ├── FriendRecallNoticeEvent (好友消息撤回通知)
-│ ├── GroupRecallNoticeEvent (群消息撤回通知)
-│ ├── GroupIncreaseNoticeEvent (群成员增加通知)
-│ ├── GroupDecreaseNoticeEvent (群成员减少通知)
-│ ├── GroupAdminNoticeEvent (群管理员变动通知)
-│ ├── GroupBanNoticeEvent (群禁言通知)
-│ ├── GroupUploadNoticeEvent (群文件上传通知)
-│ ├── PokeNotifyEvent (戳一戳通知)
-│ ├── LuckyKingNotifyEvent (运气王通知)
-│ ├── HonorNotifyEvent (群荣誉变更通知)
-│ ├── GroupCardNoticeEvent (群成员名片更新通知)
-│ ├── OfflineFileNoticeEvent (离线文件通知)
-│ ├── ClientStatusNoticeEvent (客户端状态变更通知)
-│ └── EssenceNoticeEvent (精华消息变动通知)
-└── RequestEvent (请求事件)
- ├── FriendRequestEvent (加好友请求)
- └── GroupRequestEvent (加群请求/邀请)
-```
-
-### 事件基类:OneBotEvent
-
-所有事件的基类,定义了事件的通用属性和方法:
-
-```python
-@dataclass(slots=True)
-class OneBotEvent(ABC):
- """
- OneBot v11 事件的抽象基类。
+ city = args[0]
- Attributes:
- time (int): 事件发生的时间戳 (秒)
- self_id (int): 收到事件的机器人 QQ 号
- _bot (Optional[Bot]): 内部持有的 Bot 实例引用
- """
- time: int
- self_id: int
- _bot: Optional["Bot"] = field(default=None, init=False)
+ # 此处应调用天气 API 获取数据
+ # (示例代码,省略了真实 API 调用)
+ weather_data = f"{city}的天气是:晴,25°C。"
- @property
- @abstractmethod
- def post_type(self) -> str:
- """事件的上报类型,子类必须重写此属性"""
- pass
-
- @property
- def bot(self) -> "Bot":
- """获取与此事件关联的 Bot 实例"""
- if self._bot is None:
- raise ValueError("Bot instance not set for this event")
- return self._bot
-
- @bot.setter
- def bot(self, value: "Bot"):
- """为事件对象设置关联的 Bot 实例"""
- self._bot = value
+ await event.reply(weather_data)
```
-### 事件类型常量
+#### 3. 监听事件
-框架定义了完整的事件类型常量,用于标识不同种类的事件:
-
-```python
-class EventType:
- META = 'meta_event' # 元事件:心跳、生命周期等
- REQUEST = 'request ' # 请求事件:加好友请求、加群请求等
- NOTICE = 'notice' # 通知事件:群成员增加、文件上传等
- MESSAGE = 'message' # 消息事件:私聊消息、群消息等
- MESSAGE_SENT = 'message_sent' # 消息发送事件:机器人自己发送消息的上报
-```
-
-### 消息事件
-
-消息事件是机器人最常处理的事件类型,框架提供了完整的消息段支持和便捷的回复方法:
-
-#### MessageEvent (消息事件基类)
-
-```python
-@dataclass
-class MessageEvent(OneBotEvent):
- message_type: str # 消息类型: private (私聊), group (群聊)
- sub_type: str # 消息子类型
- message_id: int # 消息 ID
- user_id: int # 发送者 QQ 号
- message: List[MessageSegment] # 消息内容列表
- raw_message: str # 原始消息内容
- font: int # 字体
- sender: Optional[Sender] # 发送者信息
-
- @property
- def post_type(self) -> str:
- return EventType.MESSAGE
-
- async def reply(self, message: str, auto_escape: bool = False):
- """回复消息(抽象方法,由子类实现)"""
- raise NotImplementedError
-```
-
-#### PrivateMessageEvent (私聊消息事件)
-
-```python
-@dataclass
-class PrivateMessageEvent(MessageEvent):
- async def reply(self, message: str, auto_escape: bool = False):
- """回复私聊消息"""
- await self.bot.send_private_msg(
- user_id=self.user_id, message=message, auto_escape=auto_escape
- )
-```
-
-#### GroupMessageEvent (群聊消息事件)
-
-```python
-@dataclass
-class GroupMessageEvent(MessageEvent):
- group_id: int = 0 # 群号
- anonymous: Optional[Anonymous] = None # 匿名信息
-
- async def reply(self, message: str, auto_escape: bool = False):
- """回复群聊消息"""
- await self.bot.send_group_msg(
- group_id=self.group_id, message=message, auto_escape=auto_escape
- )
-```
-
-### 通知事件
-
-通知事件用于处理各种系统通知,如群成员变动、文件上传等:
-
-#### 常用通知事件示例
-
-```python
-@dataclass
-class GroupIncreaseNoticeEvent(GroupNoticeEvent):
- """群成员增加通知"""
- operator_id: int = 0 # 操作者 QQ 号
- sub_type: str = "" # 子类型: approve (管理员同意入群), invite (管理员邀请入群)
-
-@dataclass
-class GroupRecallNoticeEvent(GroupNoticeEvent):
- """群消息撤回通知"""
- operator_id: int = 0 # 操作者 QQ 号
- message_id: int = 0 # 被撤回的消息 ID
-
-@dataclass
-class PokeNotifyEvent(NotifyNoticeEvent):
- """戳一戳通知"""
- target_id: int = 0 # 被戳者 QQ 号
- group_id: int = 0 # 群号 (如果是群内戳一戳)
-```
-
-### 请求事件
-
-请求事件用于处理用户的主动请求,如加好友、加群等:
-
-```python
-@dataclass
-class FriendRequestEvent(RequestEvent):
- """加好友请求事件"""
- user_id: int = 0 # 发送请求的 QQ 号
- comment: str = "" # 验证信息
- flag: str = "" # 请求 flag,用于 API 调用
-
-@dataclass
-class GroupRequestEvent(RequestEvent):
- """加群请求/邀请事件"""
- sub_type: str = "" # 子类型: add (加群请求), invite (邀请登录号入群)
- group_id: int = 0 # 群号
- user_id: int = 0 # 发送请求的 QQ 号
- comment: str = "" # 验证信息
- flag: str = "" # 请求 flag,用于 API 调用
-```
-
-### 元事件
-
-元事件用于处理框架自身状态变化,如心跳、生命周期等:
-
-```python
-@dataclass
-class HeartbeatEvent(MetaEvent):
- """心跳事件,用于确认连接状态"""
- meta_event_type: str = 'heartbeat'
- status: HeartbeatStatus = field(default_factory=HeartbeatStatus)
- interval: int = 0 # 心跳间隔时间(ms)
-
-@dataclass
-class LifeCycleEvent(MetaEvent):
- """生命周期事件,用于通知框架生命周期变化"""
- meta_event_type: str = 'lifecycle'
- sub_type: LifeCycleSubType = LifeCycleSubType.ENABLE # 子类型: enable, disable, connect
-```
-
-### 事件工厂:EventFactory
-
-事件工厂是框架的核心组件之一,负责将原始 JSON 数据转换为强类型的事件对象:
-
-```python
-class EventFactory:
- @staticmethod
- def create_event(data: Dict[str, Any]) -> OneBotEvent:
- """根据数据创建事件对象"""
- post_type = data.get("post_type")
-
- if post_type == EventType.MESSAGE or post_type == EventType.MESSAGE_SENT:
- return EventFactory._create_message_event(data, common_args)
- elif post_type == EventType.NOTICE:
- return EventFactory._create_notice_event(data, common_args)
- elif post_type == EventType.REQUEST:
- return EventFactory._create_request_event(data, common_args)
- elif post_type == EventType.META:
- return EventFactory._create_meta_event(data, common_args)
- else:
- raise ValueError(f"Unknown event type: {post_type}")
-```
-
-### 在插件中使用事件
-
-插件可以直接使用这些事件类型来处理各种场景:
+除了指令,你还可以监听各种事件,例如新成员入群。
```python
from core.command_manager import matcher
+from models import GroupIncreaseNoticeEvent
from core.bot import Bot
-from models import GroupMessageEvent, PrivateMessageEvent
-from models.events.notice import GroupIncreaseNoticeEvent
-from models.events.request import FriendRequestEvent
-# 处理群消息事件
-@matcher.command("hello")
-async def handle_hello(bot: Bot, event: GroupMessageEvent, args: list[str]):
- await event.reply(f"你好 {event.sender.nickname}!")
-
-# 处理私聊消息事件
-@matcher.command("help", permission_level=MessageEvent.USER)
-async def handle_help(bot: Bot, event: PrivateMessageEvent, args: list[str]):
- await event.reply("这里是帮助信息...")
-
-# 处理群成员增加通知
@matcher.on_notice("group_increase")
-async def handle_group_increase(bot: Bot, event: GroupIncreaseNoticeEvent):
- await bot.send_group_msg(
- event.group_id,
- f"欢迎新成员 {event.user_id} 加入!操作者:{event.operator_id}"
- )
-
-# 处理加好友请求
-@matcher.on_request("friend")
-async def handle_friend_request(bot: Bot, event: FriendRequestEvent):
- # 自动同意所有好友请求
- await bot.set_friend_add_request(flag=event.flag, approve=True)
- await bot.send_private_msg(event.user_id, "已通过您的好友请求!")
+async def welcome_new_member(bot: Bot, event: GroupIncreaseNoticeEvent):
+ """当有新成员加入群聊时触发"""
+ welcome_message = f"欢迎新成员 @{event.user_id} 加入本群!"
+ await bot.send_group_msg(event.group_id, welcome_message)
```
-### 事件处理的优势
+---
-1. **类型安全**:所有事件都有明确的类型定义,IDE 可以提供完整的代码提示和补全
-2. **易于测试**:事件对象可以轻松构造,便于编写单元测试
-3. **数据完整**:所有字段都有类型注解,确保数据的一致性和完整性
-4. **性能优化**:使用 `@dataclass(slots=True)` 减少内存占用,提高属性访问速度
-5. **可扩展性**:可以轻松定义自定义事件类型,扩展框架功能
+## 📦 当前功能插件
-### 常用事件属性速查
+| 插件文件 (`plugins/`) | 功能描述 |
+|-----------------------|----------|
+| `admin.py` | 机器人管理员权限管理 |
+| `bili_parser.py` | 自动解析 Bilibili 视频链接分享卡片 |
+| `code_py.py` | 执行 Python 代码片段 (高危,仅限管理员) |
+| `echo.py` | 提供 `/echo` 复读和 `/赞我` 功能 |
+| `forward_test.py` | 演示如何发送合并转发消息 |
+| `jrcd.py` | 娱乐功能:今日人品、牛牛词典 |
+| `thpic.py` | 发送一张随机的东方 Project 图片 |
-| 事件类型 | 关键属性 | 描述 |
-|---------|---------|------|
-| **MessageEvent** | `message_type`, `user_id`, `message`, `sender` | 所有消息事件的基类 |
-| **PrivateMessageEvent** | 继承自 MessageEvent | 私聊消息事件 |
-| **GroupMessageEvent** | `group_id`, `anonymous` | 群聊消息事件,包含群号和匿名信息 |
-| **GroupIncreaseNoticeEvent** | `group_id`, `user_id`, `operator_id`, `sub_type` | 群成员增加通知 |
-| **RecallGroupNoticeEvent** | `group_id`, `user_id`, `operator_id`, `message_id` | 群消息撤回通知 |
-| **FriendRequestEvent** | `user_id`, `comment`, `flag` | 加好友请求事件 |
-| **GroupRequestEvent** | `group_id`, `user_id`, `sub_type`, `comment`, `flag` | 加群请求/邀请事件 |
-| **HeartbeatEvent** | `status`, `interval` | 心跳事件,用于监控连接状态 |
+---
-通过这套完整的事件模型,NEO 框架为开发者提供了强大而灵活的事件处理能力,同时保持了代码的类型安全和良好的开发体验。
+## 🗺️ 路线图 (Roadmap)
+
+- [ ] **Web 仪表盘**: 开发一个简单的 Web 页面,用于查看机器人状态和插件列表。
+- [ ] **权限系统重构**: 引入更精细化的权限节点,允许按插件或指令控制用户权限。
+- [ ] **数据库集成**: 引入 `SQLite` 或其他数据库,用于需要持久化存储数据的功能。
+- [ ] **新插件开发**:
+ - [ ] 天气查询插件
+ - [ ] GIL实现
+ - [ ] coming soon...
From 54f74d0e7345ac4969e58a71c04d793e2ffb15ab Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Tue, 6 Jan 2026 22:56:00 +0800
Subject: [PATCH 26/46] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0Docker=E6=B2=99?=
=?UTF-8?q?=E7=AE=B1=E4=BB=A3=E7=A0=81=E6=89=A7=E8=A1=8C=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增Docker沙箱执行环境,提供安全隔离的代码执行能力
- 重构code_py插件,使用Docker容器替代子进程执行
- 添加docker配置项和权限检查功能
- 实现代码执行队列和并发控制
- 新增广播插件,仅限管理员使用
---
.gitignore | 1 +
config.toml | 12 ++
core/command_manager.py | 4 +-
core/config_loader.py | 9 ++
core/event_handler.py | 16 ++-
core/executor.py | 197 ++++++++++++++++++++++---
core/permission_manager.py | 27 +++-
main.py | 15 ++
plugins/broadcast.py | 75 ++++++++++
plugins/code_py.py | 284 +++++++++++++++++--------------------
requirements.txt | Bin 750 -> 360 bytes
sandbox.Dockerfile | 19 +++
12 files changed, 477 insertions(+), 182 deletions(-)
create mode 100644 plugins/broadcast.py
create mode 100644 sandbox.Dockerfile
diff --git a/.gitignore b/.gitignore
index 2729cb2..bb22f85 100644
--- a/.gitignore
+++ b/.gitignore
@@ -139,3 +139,4 @@ dmypy.json
.pytype/
# End of https://www.toptal.com/developers/gitignore/api/python
+/ca
\ No newline at end of file
diff --git a/config.toml b/config.toml
index 4846aeb..5955e20 100644
--- a/config.toml
+++ b/config.toml
@@ -12,3 +12,15 @@ host = "114.66.58.203"
port = 1931
db = 0
password = "redis_5dxyJG"
+
+[docker]
+base_url = "tcp://dockertest.k2cro4.my:2375"
+sandbox_image = "python-sandbox:latest"
+timeout = 10
+concurrency_limit = 5
+tls_verify = true
+ca_cert_path = "c:/Users/镀铬酸钾/Documents/NeoBot/ca/ca.crt"
+client_cert_path = "c:/Users/镀铬酸钾/Documents/NeoBot/ca/client-cert.pem"
+client_key_path = "c:/Users/镀铬酸钾/Documents/NeoBot/ca/client-key.pem"
+
+
diff --git a/core/command_manager.py b/core/command_manager.py
index c51794a..058cc89 100644
--- a/core/command_manager.py
+++ b/core/command_manager.py
@@ -69,7 +69,7 @@ class CommandManager:
def command(
self,
- name: str,
+ *names: str,
permission: Optional[Any] = None,
override_permission_check: bool = False
) -> Callable:
@@ -77,7 +77,7 @@ class CommandManager:
装饰器:注册一个消息指令处理器。
"""
return self.message_handler.command(
- name,
+ *names,
permission=permission,
override_permission_check=override_permission_check
)
diff --git a/core/config_loader.py b/core/config_loader.py
index 776ddcb..b00ede3 100644
--- a/core/config_loader.py
+++ b/core/config_loader.py
@@ -73,6 +73,15 @@ class Config:
"""
return self._data.get("redis", {})
+ @property
+ def docker(self) -> dict:
+ """
+ 获取 Docker 配置
+
+ :return: 配置字典
+ """
+ return self._data.get("docker", {})
+
# 实例化全局配置对象
global_config = Config()
diff --git a/core/event_handler.py b/core/event_handler.py
index b355718..8c384f9 100644
--- a/core/event_handler.py
+++ b/core/event_handler.py
@@ -83,7 +83,7 @@ class MessageHandler(BaseHandler):
def command(
self,
- name: str,
+ *names: str,
permission: Optional[Permission] = None,
override_permission_check: bool = False
) -> Callable:
@@ -93,11 +93,12 @@ class MessageHandler(BaseHandler):
def decorator(func: Callable) -> Callable:
if not inspect.iscoroutinefunction(func):
raise SyncHandlerError(f"命令处理器 {func.__name__} 必须是异步函数 (async def).")
- self.commands[name] = {
- "func": func,
- "permission": permission,
- "override_permission_check": override_permission_check,
- }
+ for name in names:
+ self.commands[name] = {
+ "func": func,
+ "permission": permission,
+ "override_permission_check": override_permission_check,
+ }
return func
return decorator
@@ -137,7 +138,8 @@ class MessageHandler(BaseHandler):
permission_granted = await permission_manager.check_permission(event.user_id, permission)
if not permission_granted and not override_check:
- await bot.send(event, f"权限不足,需要 {permission.name} 权限")
+ permission_name = permission.name if isinstance(permission, Permission) else permission
+ await bot.send(event, f"权限不足,需要 {permission_name} 权限")
return
await self._run_handler(
diff --git a/core/executor.py b/core/executor.py
index 6d691bd..73599d9 100644
--- a/core/executor.py
+++ b/core/executor.py
@@ -1,27 +1,184 @@
-"""
-线程池执行器
-
-提供一个全局的线程池和异步接口,用于在事件循环中安全地运行同步函数。
-"""
+# -*- coding: utf-8 -*-
import asyncio
-from concurrent.futures import ThreadPoolExecutor
-from functools import partial
-from typing import Any, Callable
+import docker
+from docker.tls import TLSConfig
+from typing import Dict, Any, Callable
-# 创建一个全局的线程池,可以根据需要调整 max_workers
-executor = ThreadPoolExecutor(max_workers=10)
+from core.logger import logger
-async def run_in_thread_pool(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
+class CodeExecutor:
"""
- 在线程池中异步运行同步函数
+ 代码执行引擎,负责管理一个异步任务队列和并发的 Docker 容器执行。
+ """
+ def __init__(self, bot_instance, config: Dict[str, Any]):
+ """
+ 初始化代码执行引擎。
+ :param bot_instance: Bot 实例,用于后续的消息回复。
+ :param config: 从 config.toml 加载的配置字典。
+ """
+ self.bot = bot_instance
+ self.task_queue = asyncio.Queue()
+
+ # 从传入的配置中读取 Docker 相关设置
+ docker_config = config.docker
+ self.docker_base_url = docker_config.get("base_url")
+ self.sandbox_image = docker_config.get("sandbox_image", "python-sandbox:latest")
+ self.timeout = docker_config.get("timeout", 10)
+ concurrency = docker_config.get("concurrency_limit", 5)
+
+ self.concurrency_limit = asyncio.Semaphore(concurrency)
+ self.docker_client = None
- :param func: 要运行的同步函数
- :param args: 函数的位置参数
- :param kwargs: 函数的关键字参数
- :return: 函数的返回值
+ logger.info("[CodeExecutor] 初始化 Docker 客户端...")
+ try:
+ if self.docker_base_url:
+ # 如果配置了远程 Docker 地址,则使用 TLS 选项进行连接
+ tls_config = None
+ if docker_config.get("tls_verify", False):
+ tls_config = TLSConfig(
+ ca_cert=docker_config.get("ca_cert_path"),
+ client_cert=(docker_config.get("client_cert_path"), docker_config.get("client_key_path")),
+ verify=True
+ )
+ self.docker_client = docker.DockerClient(base_url=self.docker_base_url, tls=tls_config)
+ else:
+ # 否则,使用默认的本地环境连接
+ self.docker_client = docker.from_env()
+
+ # 检查 Docker 服务是否可用
+ self.docker_client.ping()
+ logger.success("[CodeExecutor] Docker 客户端初始化成功,服务连接正常。")
+ except docker.errors.DockerException as e:
+ self.docker_client = None
+ logger.error(f"无法连接到 Docker 服务,请检查 Docker 是否正在运行: {e}")
+ except Exception as e:
+ self.docker_client = None
+ logger.error(f"初始化 Docker 客户端时发生未知错误: {e}")
+
+ async def add_task(self, code: str, callback: Callable[[str], asyncio.Future]):
+ """
+ 将代码执行任务添加到队列中。
+ :param code: 待执行的 Python 代码字符串。
+ :param callback: 执行完毕后用于回复结果的回调函数。
+ """
+ task = {"code": code, "callback": callback}
+ await self.task_queue.put(task)
+ logger.info(f"[CodeExecutor] 新的代码执行任务已入队 (队列当前长度: {self.task_queue.qsize()})。")
+
+ async def worker(self):
+ """
+ 后台工作者,不断从队列中取出任务并执行。
+ """
+ if not self.docker_client:
+ logger.error("[CodeExecutor] Worker 无法启动,因为 Docker 客户端未初始化。")
+ return
+
+ logger.info("[CodeExecutor] 代码执行 Worker 已启动,等待任务...")
+ while True:
+ task = await self.task_queue.get()
+
+ logger.info("[CodeExecutor] 开始处理代码执行任务。")
+
+ async with self.concurrency_limit:
+ result_message = ""
+ try:
+ loop = asyncio.get_running_loop()
+
+ # 使用 asyncio.wait_for 实现超时控制
+ result_bytes = await asyncio.wait_for(
+ loop.run_in_executor(
+ None, # 使用默认线程池
+ self._run_in_container,
+ task['code']
+ ),
+ timeout=self.timeout
+ )
+
+ output = result_bytes.decode('utf-8').strip()
+ result_message = output if output else "代码执行完毕,无输出。"
+ logger.success("[CodeExecutor] 任务成功执行。")
+
+ except docker.errors.ImageNotFound:
+ logger.error(f"[CodeExecutor] 镜像 '{self.sandbox_image}' 不存在!")
+ result_message = f"执行失败:沙箱基础镜像 '{self.sandbox_image}' 不存在,请联系管理员构建。"
+ except docker.errors.ContainerError as e:
+ error_output = e.stderr.decode('utf-8').strip()
+ result_message = f"代码执行出错:\n{error_output}"
+ logger.warning(f"[CodeExecutor] 代码执行时发生错误: {error_output}")
+ except docker.errors.APIError as e:
+ logger.error(f"[CodeExecutor] Docker API 错误: {e}")
+ result_message = "执行失败:与 Docker 服务通信时发生错误,请检查服务状态。"
+ except asyncio.TimeoutError:
+ result_message = f"执行超时 (超过 {self.timeout} 秒)。"
+ logger.warning("[CodeExecutor] 任务执行超时。")
+ except Exception as e:
+ logger.exception(f"[CodeExecutor] 执行 Docker 任务时发生未知严重错误: {e}")
+ result_message = "执行引擎发生内部错误,请联系管理员。"
+
+ # 调用回调函数回复结果
+ await task['callback'](result_message)
+
+ self.task_queue.task_done()
+
+ def _run_in_container(self, code: str) -> bytes:
+ """
+ 同步函数:在 Docker 容器中运行代码。
+ 此函数通过手动管理容器生命周期来提高稳定性。
+ """
+ container = None
+ try:
+ # 1. 创建容器
+ container = self.docker_client.containers.create(
+ image=self.sandbox_image,
+ command=["python", "-c", code],
+ mem_limit='128m',
+ cpu_shares=512,
+ network_disabled=True,
+ log_config={'type': 'json-file', 'config': {'max-size': '1m'}},
+ )
+ # 2. 启动容器
+ container.start()
+
+ # 3. 等待容器执行完成
+ # 主超时由 asyncio.wait_for 控制,这里的 timeout 是一个额外的保险
+ result = container.wait(timeout=self.timeout + 5)
+
+ # 4. 获取日志
+ stdout = container.logs(stdout=True, stderr=False)
+ stderr = container.logs(stdout=False, stderr=True)
+
+ # 5. 检查退出码,如果不为 0,则手动抛出 ContainerError
+ if result.get('StatusCode', 0) != 0:
+ raise docker.errors.ContainerError(
+ container, result['StatusCode'], f"python -c '{code}'", self.sandbox_image, stderr
+ )
+
+ return stdout
+
+ finally:
+ # 6. 确保容器总是被移除
+ if container:
+ try:
+ container.remove(force=True)
+ except docker.errors.NotFound:
+ # 如果容器因为某些原因已经消失,也沒关系
+ pass
+ except Exception as e:
+ logger.error(f"[CodeExecutor] 强制移除容器 {container.id} 时失败: {e}")
+
+def initialize_executor(bot_instance, config: Dict[str, Any]):
+ """
+ 初始化并返回一个 CodeExecutor 实例。
+ """
+ return CodeExecutor(bot_instance, config)
+
+async def run_in_thread_pool(sync_func, *args, **kwargs):
+ """
+ 在线程池中运行同步阻塞函数,以避免阻塞 asyncio 事件循环。
+ :param sync_func: 同步函数
+ :param args: 位置参数
+ :param kwargs: 关键字参数
+ :return: 同步函数的返回值
"""
loop = asyncio.get_running_loop()
- # 使用 functools.partial 绑定函数和参数,以便传递给 run_in_executor
- func_to_run = partial(func, *args, **kwargs)
- # loop.run_in_executor 会返回一个 awaitable 对象
- return await loop.run_in_executor(executor, func_to_run)
+ return await loop.run_in_executor(None, lambda: sync_func(*args, **kwargs))
diff --git a/core/permission_manager.py b/core/permission_manager.py
index c79a18d..917e753 100644
--- a/core/permission_manager.py
+++ b/core/permission_manager.py
@@ -227,6 +227,14 @@ class PermissionManager:
Returns:
bool: 如果用户权限 >= 所需权限,返回 True,否则返回 False
"""
+ # 如果传入的是字符串,先转换为 Permission 对象
+ if isinstance(required_permission, str):
+ required_permission = _PERMISSIONS.get(required_permission.lower())
+ if not required_permission:
+ # 如果是无效的权限字符串,默认拒绝
+ logger.warning(f"检测到无效的权限检查字符串: {required_permission}")
+ return False
+
user_permission = await self.get_user_permission(user_id)
return user_permission >= required_permission
@@ -249,4 +257,21 @@ class PermissionManager:
# 全局权限管理器实例
-permission_manager = PermissionManager()
\ No newline at end of file
+permission_manager = PermissionManager()
+
+def require_admin(func):
+ """
+ 一个装饰器,用于限制命令只能由管理员执行。
+ """
+ from functools import wraps
+ from models.events.message import MessageEvent
+
+ @wraps(func)
+ async def wrapper(event: MessageEvent, *args, **kwargs):
+ user_id = event.user_id
+ if await permission_manager.check_permission(user_id, ADMIN):
+ return await func(event, *args, **kwargs)
+ else:
+ await event.reply("抱歉,您没有权限执行此命令。")
+ return None
+ return wrapper
diff --git a/main.py b/main.py
index 1445f1f..0589512 100644
--- a/main.py
+++ b/main.py
@@ -108,6 +108,21 @@ async def main():
try:
bot = WS()
+
+ # 初始化代码执行器
+ from core.config_loader import global_config as config
+ from core.executor import initialize_executor
+ code_executor = initialize_executor(bot, config)
+ bot.bot.code_executor = code_executor # 将执行器实例附加到 bot.bot 对象上
+
+ # 启动代码执行器的后台 worker
+ logger.debug("[Main] 检查是否需要启动代码执行 Worker...")
+ if code_executor and code_executor.docker_client:
+ logger.info("[Main] Docker 连接成功,正在启动代码执行 Worker...")
+ asyncio.create_task(code_executor.worker())
+ else:
+ logger.warning("[Main] 未启动代码执行 Worker,因为 Docker 客户端未初始化或连接失败。")
+
await bot.connect()
finally:
if observer.is_alive():
diff --git a/plugins/broadcast.py b/plugins/broadcast.py
new file mode 100644
index 0000000..37cfd32
--- /dev/null
+++ b/plugins/broadcast.py
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+"""
+管理员专用的广播插件
+功能:
+- 仅限管理员在私聊中调用。
+- 通过回复一条消息并发送指令,将该消息转发给机器人所在的所有群聊。
+- 此插件不写入 __plugin_meta__,保持隐藏。
+"""
+from core.command_manager import matcher
+from models import MessageEvent
+from core.permission_manager import ADMIN
+from core.logger import logger
+
+@matcher.command("broadcast", "广播", permission=ADMIN)
+async def broadcast_message(event: MessageEvent):
+ """
+ 广播指令处理器。
+
+ :param event: 消息事件对象。
+ """
+ # 1. 检查是否为私聊消息
+ if event.group_id:
+ # 在群聊中调用时,静默处理,不予响应
+ return
+
+ # 2. 检查是否回复了某条消息
+ reply = event.reply
+ if not reply:
+ await event.reply("请通过“回复”一条您想广播的消息来使用此功能。")
+ return
+
+ # 3. 获取机器人所在的群聊列表
+ bot = event.bot
+ try:
+ group_list = await bot.get_group_list()
+ if not group_list:
+ await event.reply("机器人目前没有加入任何群聊。")
+ return
+ except Exception as e:
+ logger.error(f"[Broadcast] 获取群聊列表失败: {e}")
+ await event.reply(f"获取群聊列表时发生错误,无法广播。错误信息: {e}")
+ return
+
+ # 4. 遍历所有群聊并转发消息
+ success_count = 0
+ failed_count = 0
+ total_groups = len(group_list)
+
+ await event.reply(f"准备向 {total_groups} 个群聊广播消息,请稍候...")
+
+ for group in group_list:
+ group_id = group.get("group_id")
+ if not group_id:
+ continue
+
+ try:
+ # 直接转发被回复的消息
+ await bot.forward_message(
+ group_id=group_id,
+ message_id=reply.message_id
+ )
+ success_count += 1
+ logger.info(f"[Broadcast] 已成功将消息转发至群聊: {group_id}")
+ except Exception as e:
+ failed_count += 1
+ logger.error(f"[Broadcast] 转发消息至群聊 {group_id} 失败: {e}")
+
+ # 5. 向管理员报告结果
+ report_message = (
+ f"广播任务完成。\n"
+ f"总群聊数: {total_groups}\n"
+ f"成功: {success_count}\n"
+ f"失败: {failed_count}"
+ )
+ await event.reply(report_message)
diff --git a/plugins/code_py.py b/plugins/code_py.py
index 2fa2988..055b034 100644
--- a/plugins/code_py.py
+++ b/plugins/code_py.py
@@ -1,176 +1,156 @@
-"""
-code_py插件
-
-输入/code py回车再加上python代码,机器人就会执行代码并返回执行结果。
-"""
-
+# -*- coding: utf-8 -*-
+import html
+import textwrap
import asyncio
-import re
-import sys
-import tempfile
-import os
-from typing import Tuple, Set
+from typing import Dict
-from core.bot import Bot
from core.command_manager import matcher
-from core.executor import run_in_thread_pool
from models import MessageEvent
+from core.permission_manager import ADMIN
+from core.logger import logger
__plugin_meta__ = {
- "name": "code_py",
- "description": "提供执行python代码的功能",
- "usage": "/code_py - 进入交互模式,等待输入代码块\n/code_py [单行代码] - 快速执行单行代码",
+ "name": "Python 代码执行",
+ "description": "在安全的沙箱环境中执行 Python 代码片段,支持单行、多行和转发回复。",
+ "usage": "/py <单行代码>\n/code_py <单行代码>\n/py (进入多行输入模式)",
}
-# --- 安全配置:危险模块和内置函数黑名单 ---
-DANGEROUS_MODULES = [
- "os", "sys", "subprocess", "shutil", "socket", "requests", "urllib",
- "http", "ftplib", "telnetlib", "ctypes", "_thread", "multiprocessing",
- "asyncio",
-]
-DANGEROUS_BUILTINS = [
- "__import__", "open", "exec", "eval", "compile", "input", "breakpoint"
-]
+# --- 会话状态管理 ---
+# 结构: {(user_id, group_id): asyncio.TimerHandle}
+multi_line_sessions: Dict[tuple, asyncio.TimerHandle] = {}
-# 编译后的正则表达式,用于分割语句
-STATEMENT_SPLIT_PATTERN = re.compile(r'[;\n]')
-# 编译后的正则表达式,用于查找危险的内置函数调用
-BUILTIN_CALL_PATTERN = re.compile(r'\b(' + '|'.join(DANGEROUS_BUILTINS) + r')\s*\(')
-
-def is_code_safe(code: str) -> Tuple[bool, str]:
+async def reply_as_forward(event: MessageEvent, input_code: str, output_result: str):
"""
- 检查代码中是否包含危险的模块导入或内置函数调用。
+ 将输入和输出打包成转发消息进行回复。
+ 参考 forward_test.py 的实现,兼容私聊和群聊。
"""
- # 1. 检查危险的内置函数
- found_builtins = BUILTIN_CALL_PATTERN.search(code)
- if found_builtins:
- return False, f"检测到不允许的内置函数调用:'{found_builtins.group(1)}'"
-
- # 2. 检查危险的模块导入
- statements = STATEMENT_SPLIT_PATTERN.split(code)
- for statement in statements:
- statement = statement.strip()
- if not statement:
- continue
- parts = statement.split()
- if not parts:
- continue
- if parts[0] == 'from' and len(parts) > 1:
- module_name = parts[1].strip()
- if module_name in DANGEROUS_MODULES:
- return False, f"检测到不允许的模块导入:'{module_name}'"
- elif parts[0] == 'import' and len(parts) > 1:
- modules_str = ' '.join(parts[1:])
- imported_modules = [m.strip() for m in modules_str.split(',')]
- for module_name in imported_modules:
- actual_module_name = module_name.split()[0]
- if actual_module_name in DANGEROUS_MODULES:
- return False, f"检测到不允许的模块导入:'{actual_module_name}'"
- return True, ""
-
-async def run_code_in_subprocess(code_str: str, timeout: float = 10.0) -> Tuple[str, str]:
- """
- 在子进程中安全地执行Python代码。
- """
- with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False, encoding="utf-8") as tf:
- tf.write(code_str)
- tf_path = tf.name
- try:
- proc = await asyncio.create_subprocess_exec(
- sys.executable, tf_path,
- stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
- )
- try:
- out_bytes, err_bytes = await asyncio.wait_for(proc.communicate(), timeout=timeout)
- except asyncio.TimeoutError:
- proc.kill()
- await proc.communicate()
- return "", f"执行超时(>{timeout}s)"
- return out_bytes.decode(errors="ignore"), err_bytes.decode(errors="ignore")
- finally:
- try:
- os.remove(tf_path)
- except Exception:
- pass
-
-async def process_and_reply(bot: Bot, event: MessageEvent, code: str):
- """
- 核心处理逻辑:安全检查、执行代码并回复结果。
- """
- safe, message = await run_in_thread_pool(is_code_safe, code)
- if not safe:
- await event.reply(f"代码安全检查未通过:\n{message}")
- return
-
- try:
- stdout, stderr = await run_code_in_subprocess(code, timeout=10.0)
- except Exception as e:
- await event.reply(f"执行失败:{e}")
- return
-
- resp = stderr.strip() or stdout.strip() or "(无输出)"
- MAX = 1500
- if len(resp) > MAX:
- resp = resp[:MAX] + "\n...输出被截断..."
-
+ bot = event.bot
+
+ # 1. 构建消息节点列表
nodes = [
- bot.build_forward_node(user_id=event.self_id, nickname="输入代码", message=code),
- bot.build_forward_node(user_id=event.self_id, nickname="执行结果", message=resp),
+ 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:
- await event.reply(f"结果发送失败: {e}\n\n{resp}")
+ logger.error(f"[code_py] 发送转发消息失败: {e}")
+ # 降级为普通消息回复
+ await event.reply(f"--- 你的代码 ---\n{input_code}\n--- 执行结果 ---\n{output_result}")
-# --- 交互式会话状态 ---
-# 使用集合存储正在等待代码输入的用户标识
-waiting_users: Set[str] = set()
-
-def get_session_id(event: MessageEvent) -> str:
- """根据事件类型生成唯一的会话ID"""
- if hasattr(event, 'group_id'):
- # 群聊会话ID
- return f"group_{event.group_id}-{event.user_id}"
- else:
- # 私聊会话ID
- return f"private_{event.user_id}"
-
-@matcher.command("code_py")
-async def handle_code_command(bot: Bot, event: MessageEvent, args: list[str]):
- # 模式一:快速执行单行代码
- if args:
- code = " ".join(args)
- await process_and_reply(bot, event, code)
+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:
+ await event.reply("代码执行服务当前不可用,请检查 Docker 连接配置。")
return
- # 模式二:进入交互模式
- session_id = get_session_id(event)
- if session_id in waiting_users:
- await event.reply("您已经有一个正在等待输入的code会话了,请直接发送代码。")
- 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., [ -> [)。
+ 2. 移除整个代码块的公共前导缩进,以修复因复制粘贴导致的多余缩进。
+
+ :param code: 原始代码字符串。
+ :return: 规范化后的代码字符串。
+ """
+ # 1. 解码 HTML 实体
+ code = html.unescape(code)
+
+ # 2. 移除公共前导缩进
+ try:
+ code = textwrap.dedent(code)
+ except Exception:
+ # 在某些情况下(例如,不一致的缩进),dedent 可能会失败,
+ # 但我们不希望因此中断流程,所以捕获异常并继续。
+ pass
- waiting_users.add(session_id)
- await event.reply("请在下一条消息中发送要执行的Python代码块。(发送“取消”可退出)")
+ return code.strip()
+
+
+@matcher.command("py", "python", "code_py", permission=ADMIN)
+async def code_py_main(event: MessageEvent, args: list[str]):
+ """
+ /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)
+ else:
+ # 多行模式
+ # 使用 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_code_input(bot: Bot, event: MessageEvent):
- session_id = get_session_id(event)
-
- # 检查用户是否处于等待状态
- if session_id in waiting_users:
- # 从等待集合中移除,无论输入是什么
- waiting_users.remove(session_id)
+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 event.raw_message.strip() == "取消":
- await event.reply("已取消输入。")
- return True # 消费事件
-
- # 执行代码
- await process_and_reply(bot, event, event.raw_message)
- return True # 消费事件,防止被其他指令匹配
-
- # 如果用户不在等待状态,则不处理
- return False
-
+ if not normalized_code:
+ await event.reply("捕获到的代码为空或格式错误,已取消输入。")
+ return
+ await execute_code(event, normalized_code)
+ return True # 消费事件,防止其他处理器响应
diff --git a/requirements.txt b/requirements.txt
index 17a9c33e3864882cb7217cb519675a201e0d8f3d..81bbec89684e9c693f0a5a1a37e498102247c737 100644
GIT binary patch
literal 360
zcmXX?Npiy=5WMr3Py)2%!dqNOnpl((U=}OR>(f)ojh>!fn3Y^_{;P+YdLFGEr5dFX
zYsGtzgVbW9f(37_9`q!Yk_xlKl}ha+rgFOAf2de%uoPO+cPoQ!INzrR}=ZhbWh@**~ZTFN%OI@P!P6*6{xnM>9u>u+y%ynOE7ZK
zg3Jpxr+n#cjA%6W$&rij7fz&@=x`!q^3U8OdQWyEVN3VD<>RQYu*3=
diff --git a/sandbox.Dockerfile b/sandbox.Dockerfile
new file mode 100644
index 0000000..41f2bc3
--- /dev/null
+++ b/sandbox.Dockerfile
@@ -0,0 +1,19 @@
+# 使用一个轻量级的 Python 官方镜像作为基础
+FROM python:3.11-slim
+
+# 创建一个低权限的用户来运行代码,增加安全性
+# -S: 创建一个系统用户 (没有 home 目录)
+# -u: 指定用户ID
+# -g: 指定组ID
+RUN groupadd -g 1001 sandbox && useradd -u 1001 -g sandbox -s /bin/sh -r sandbox
+
+# 创建一个工作目录,用于存放和执行用户的代码
+WORKDIR /sandbox
+# 将目录所有权交给沙箱用户
+RUN chown sandbox:sandbox /sandbox
+
+# 切换到沙箱用户
+USER sandbox
+
+# 默认的启动命令是 python,这样容器启动时可以直接执行 .py 文件
+CMD ["python"]
From aaf4a896dd8b3c3f3987f50c6811d3fc8df889ca Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=95=80=E9=93=AC=E9=85=B8=E9=92=BE?=
<148796996+K2cr2O1@users.noreply.github.com>
Date: Tue, 6 Jan 2026 22:59:50 +0800
Subject: [PATCH 27/46] Dev (#26)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: 整合开发历史
* codepy安全性升级
* 优化一些东西
* 再次优化
* 更新一下 requirements.txt
* CQ码支持以及视频解析
* hotfix
* 更新DEV readme.md
* feat: 添加Docker沙箱代码执行功能
- 新增Docker沙箱执行环境,提供安全隔离的代码执行能力
- 重构code_py插件,使用Docker容器替代子进程执行
- 添加docker配置项和权限检查功能
- 实现代码执行队列和并发控制
- 新增广播插件,仅限管理员使用
---
.gitignore | 1 +
README.md | 1121 +++++-------------------------------
config.toml | 12 +
core/command_manager.py | 4 +-
core/config_loader.py | 9 +
core/event_handler.py | 16 +-
core/executor.py | 197 ++++++-
core/permission_manager.py | 27 +-
main.py | 15 +
plugins/broadcast.py | 75 +++
plugins/code_py.py | 284 +++++----
requirements.txt | Bin 750 -> 360 bytes
sandbox.Dockerfile | 19 +
13 files changed, 620 insertions(+), 1160 deletions(-)
create mode 100644 plugins/broadcast.py
create mode 100644 sandbox.Dockerfile
diff --git a/.gitignore b/.gitignore
index 2729cb2..bb22f85 100644
--- a/.gitignore
+++ b/.gitignore
@@ -139,3 +139,4 @@ dmypy.json
.pytype/
# End of https://www.toptal.com/developers/gitignore/api/python
+/ca
\ No newline at end of file
diff --git a/README.md b/README.md
index 5c299b3..ec20324 100644
--- a/README.md
+++ b/README.md
@@ -1,267 +1,117 @@
# Calglau BOT by NEO Bot Framework
-## 📖 概述
+> **[INTERNAL USE ONLY]**
+>
+> 本仓库为 Calglau BOT 的内部开发版本,请遵守相关保密协议。
-NEO 是一个基于 Python 的现代化 OneBot 11 协议机器人框架,专为需要高性能、可扩展性和开发效率的团队设计。该框架通过 WebSocket 与各种 OneBot 实现端(如 NapCatQQ、LLOneBot 等)通信,提供了一套完整的机器人开发解决方案。
+**Powered by NEO Bot Framework**
-### 设计理念
+## 📖 项目概述
-NEO 框架的设计遵循以下核心理念:
+**Calglau BOT** 是一个基于 NEO Bot Framework 构建的、功能丰富的 QQ 机器人。它被设计为一个模块化、易于扩展的内部工具,通过插件化的方式集成了多种实用与娱乐功能。
-1. **开发者友好**:简洁的 API 设计、完整的类型提示和详细的文档,让开发者能够快速上手和高效开发
-2. **架构清晰**:采用模块化设计,分离关注点,使代码易于维护和扩展
-3. **高性能异步**:基于 `asyncio` 和 `websockets` 构建,支持高并发消息处理
-4. **类型安全**:全面使用 Python 类型系统,提供编译时类型检查,减少运行时错误
-5. **热重载支持**:支持插件热重载,开发过程中修改代码无需重启机器人
+本项目旨在提供一个稳定、高性能且开发体验优秀的机器人平台,服务于我们的社群管理和日常自动化需求。
-### 核心价值
+### ✨ 核心特性
-- **快速原型开发**:通过简洁的装饰器语法快速定义指令和事件处理器
-- **生产环境就绪**:内置断线重连、错误处理和性能监控机制
-- **可扩展架构**:支持自定义插件、中间件和权限系统
-- **现代化开发体验**:支持热重载、类型提示和完整的 API 文档
+* **模块化插件架构**:所有功能均以独立插件形式存在于 `plugins/` 目录,易于开发、维护和热重载。
+* **高性能异步核心**:基于 `asyncio` 和 `websockets`,确保在高并发消息下依然响应迅速。
+* **开发者友好**:内置插件热重载,修改代码无需重启;完整的类型提示和清晰的 API 设计,提升开发效率。
+* **集成 Redis 缓存**:自动缓存常用 API 调用(如群信息),减少重复请求,提升响应速度。
+* **内置帮助系统**:通过 `/help` 指令可自动生成并展示所有已加载插件的功能说明。
-### 适用场景
+### 🛠️ 技术栈
-- QQ 群机器人管理
-- 自动化客服与问答系统
-- 游戏社区管理
-- 团队内部工具集成
-- 教育与培训辅助
+* **核心框架**: Python 3.8+ & NEO Bot Framework
+* **异步库**: `asyncio`
+* **网络通信**: `websockets` (OneBot v11)
+* **缓存**: `Redis`
+* **日志**: `Loguru`
+* **文件监控**: `watchdog` (用于热重载)
-## ✨ 特性
-
-* **OneBot 11 标准支持**:完整支持 OneBot 11 的消息、通知、请求和元事件。
-* **类型安全**:基于 `dataclasses` 的强类型事件模型,开发体验更佳。
-* **插件系统**:轻量级的装饰器风格插件系统,支持指令 (`@matcher.command`) 和事件监听 (`@matcher.on_notice`, `@matcher.on_request`)。
-* **插件元数据与内置帮助**:插件可通过 `__plugin_meta__` 变量进行自我描述。框架核心内置了 `/help` 指令,可自动收集并展示所有插件的帮助信息,无需手动维护。
-* **🔥 热重载支持**:内置文件监控,修改 `plugins` 下的代码自动重载,无需重启,极大提升调试效率。
-* **异步核心**:基于 `asyncio` 和 `websockets` 的高性能异步核心。
-* **自动重连**:内置 WebSocket 断线重连机制。
-
-## ⚡️ 性能优化
-
-### Redis 缓存机制
-为了提升响应速度并减少对 OneBot API 的重复调用,框架核心集成了一套基于 Redis 的缓存系统。对于一些不频繁变更的数据(如群信息、好友列表等),首次查询后会将其缓存至 Redis,在缓存有效期内(默认为 1 小时),后续请求将直接从 Redis 读取,极大提升了性能。
-
-#### 工作原理
-- **自动缓存**:框架会自动缓存特定 API 的调用结果。
-- **缓存键**:缓存键根据 API 名称和关键参数(如 `group_id`, `user_id`)生成,确保唯一性。
-- **过期时间**:默认缓存 1 小时,之后会自动失效,下次调用时将重新从 OneBot 实现端获取最新数据。
-
-#### 受影响的 API
-以下核心 API 已默认启用缓存:
-- `get_group_info`
-- `get_group_member_info`
-- `get_friend_list`
-- `get_stranger_info`
-- `get_login_info`
-
-#### 如何绕过缓存
-在某些场景下,你可能需要获取实时数据而非缓存数据。为此,所有受缓存影响的 API 方法都增加了一个 `no_cache: bool = False` 的可选参数。
-
-当你需要强制从服务器获取最新信息时,只需在调用时传入 `no_cache=True` 即可。
-
-**示例:**
-```python
-# 正常调用,会使用缓存
-group_info = await bot.get_group_info(group_id=12345)
-
-# 强制获取最新信息,不使用缓存
-latest_group_info = await bot.get_group_info(group_id=12345, no_cache=True)
-```
-
-### `__slots__` 内存优化
-框架内的所有数据模型(包括事件、消息段、API 返回对象等)均已启用 `__slots__ = True` 优化。这可以显著减少每个对象实例的内存占用,特别是在处理大量事件和数据时,能够有效降低机器人的整体内存消耗。
-
-
-## 📝 待办事项 (TODO)
-
-### API 封装
-- [x] **消息相关**
- - `delete_msg`: 撤回消息
- - `get_msg`: 获取消息
- - `get_forward_msg`: 获取合并转发消息
- - `send_like`: 发送点赞
-- [x] **群组管理**
- - `set_group_kick`: 群组踢人
- - `set_group_ban`: 群组单人禁言
- - `set_group_anonymous_ban`: 群组匿名禁言
- - `set_group_whole_ban`: 群组全员禁言
- - `set_group_admin`: 群组设置管理员
- - `set_group_anonymous`: 群组匿名
- - `set_group_card`: 设置群名片(群备注)
- - `set_group_name`: 设置群名
- - `set_group_leave`: 退出群组
- - `set_group_special_title`: 设置群组专属头衔
-- [x] **群组信息**
- - `get_group_info`: 获取群信息
- - `get_group_list`: 获取群列表
- - `get_group_member_info`: 获取群成员信息
- - `get_group_member_list`: 获取群成员列表
- - `get_group_honor_info`: 获取群荣誉信息
-- [x] **用户相关**
- - `get_login_info`: 获取登录号信息
- - `get_stranger_info`: 获取陌生人信息
- - `get_friend_list`: 获取好友列表
-- [x] **请求处理**
- - `set_friend_add_request`: 处理加好友请求
- - `set_group_add_request`: 处理加群请求/邀请
-- [x] **系统/其他**
- - `get_version_info`: 获取版本信息
- - `get_status`: 获取状态
- - `can_send_image`: 检查是否可以发送图片
- - `can_send_record`: 检查是否可以发送语音
- - `clean_cache`: 清理缓存
-
-### 待实现 API
-- [ ] **Web 凭证类**
- - `get_cookies`
- - `get_csrf_token`
- - `get_credentials`
-- [ ] **文件/资源信息**
- - `get_image`
- - `get_record`
- - `get_file`
-- [ ] **系统控制**
- - `set_restart`
-- [ ] **扩展功能**
- - [x] `send_forward_msg`: 发送合并转发消息
-
-### 其他改进
-- [x] **API 强类型封装**: 将 API 返回值从 `dict` 转换为数据模型对象。
-- [x] **Redis 支持**: 集成 Redis 连接池,便于插件复用连接。
-- [x] **权限系统**: 实现基础的权限管理(超级管理员、群管理员等)。
-- [x] **日志系统优化**: 引入 `loguru` 进行日志记录,支持文件输出和日志级别控制。
-- [x] **异常处理增强**: 增强插件执行过程中的异常捕获,防止单个插件崩溃影响整个 Bot。
-- [x] **中间件支持**: 添加消息处理中间件,支持在指令执行前/后进行拦截和处理。
+---
## 📂 项目结构
```
.
-├── plugins/ # 插件目录,新建插件文件即可自动加载(支持热重载)
-│ ├── admin.py # 管理员插件
-│ ├── code_py.py # Python 代码执行插件
-│ ├── echo.py # 示例插件:实现 /echo 和 /赞我 指令
-│ ├── forward_test.py # 示例插件:演示合并转发消息
-│ ├── jrcd.py # 娱乐插件:/jrcd 和 /bbcd
-│ └── thpic.py # 图片插件:/thpic
-├── core/ # 核心框架代码
-│ ├── api/ # API 模块抽象层
-│ ├── bot.py # Bot 实例与 API 封装
-│ ├── admin_manager.py # 管理员管理模块
-│ ├── command_manager.py # 命令与事件分发器
-│ ├── config_loader.py # 配置加载器
-│ ├── event_handler.py # 事件处理器
-│ ├── executor.py # 插件执行器
-│ ├── logger.py # 日志系统
-│ ├── permission_manager.py # 权限管理器
-│ ├── plugin_manager.py # 插件加载与管理
-│ ├── redis_manager.py # Redis 连接管理器
-│ └── ws.py # WebSocket 客户端核心
-├── data/ # 数据存储目录
-│ ├── admin.json # 管理员配置文件
-│ └── permissions.json # 权限数据
-├── html/ # HTML 静态文件
+├── plugins/ # 插件目录,所有机器人的功能模块都在这里
+│ ├── admin.py
+│ ├── bili_parser.py
+│ ├── code_py.py
+│ ├── echo.py
+│ ├── forward_test.py
+│ ├── jrcd.py
+│ └── thpic.py
+├── core/ # NEO 框架核心代码,通常无需修改
+│ ├── api/
+│ ├── bot.py
+│ ├── ...
+│ └── ws.py
+├── data/ # 数据存储目录 (管理员列表, 权限配置)
+│ ├── admin.json
+│ └── permissions.json
+├── html/ # 静态网页文件
│ ├── 404.html
│ └── index.html
-├── models/ # 数据模型
-│ ├── events/ # OneBot 事件定义
-│ ├── message.py # 消息段定义
-│ ├── objects.py # API 返回对象定义
-│ └── sender.py # 发送者定义
+├── models/ # 数据模型 (事件, 消息段等)
+│ ├── ...
├── .gitignore
-├── config.toml # 配置文件
-├── main.py # 启动入口(包含热重载监控)
-└── requirements.txt # 项目依赖
+├── config.toml # 主配置文件
+├── main.py # 项目启动入口
+└── requirements.txt # Python 依赖
```
-### 目录结构详细说明
-
-#### `plugins/` - 插件目录
-- **功能**存放:所有机器人插件,支持热重载机制
-- **加载机制**:框架会自动扫描此目录下的所有 `.py` 文件,并作为插件加载
-- **插件约定**:每个插件文件应包含 `__plugin_meta__` 字典用于插件元数据定义
-- **热重载**:开发过程中修改插件文件会自动触发重载,无需重启机器人
-- **内置插件**:
- - `admin.py` - 管理员管理插件,支持动态添加/移除管理员
- - `code_py.py` - Python 代码执行插件,支持安全的代码执行环境
- - `echo.py` - 示例插件,演示基本指令处理
- - `forward_test.py` - 合并转发消息演示插件
- - `jrcd.py` - 娱乐插件,提供 `/jrcd` 和 `/bbcd` 指令
- - `thpic.py` - 图片插件,提供 `/thpic` 指令返回东方Project图片
-
-#### `core/` - 核心框架代码
-- `api/` - API 模块抽象层
- - `base.py` - API 基类定义
- - `message.py` - 消息相关 API 封装
- - `group.py` - 群组管理 API 封装
- - `friend.py` - 好友相关 API 封装
- - `account.py` - 账号相关 API 封装
-- `bot.py` - Bot 核心类,通过 Mixin 模式继承所有 API 功能,提供统一的调用接口
- - `admin_manager.py` - 管理员管理模块,负责管理员的添加、移除和权限验证
- - `command_manager.py` - 命令与事件分发器,负责注册和处理所有指令和事件
-- `config_loader.py` - 配置加载器,读取和解析 `config.toml` 配置文件
-- `event_handler.py` - 事件处理器,负责将原始事件转换为类型化事件对象
-- `executor.py` - 插件执行器,提供线程池执行环境用于执行同步任务
-- `logger.py` - 日志系统,基于 `loguru` 提供高性能日志记录
-- `permission_manager.py` - 权限管理器,管理用户权限级别(admin、op、user)
-- `plugin_manager.py` - 插件加载与管理,负责插件的扫描、加载和热重载
-- `redis_manager.py` - Redis 连接管理器,提供异步 Redis 客户端连接池
-- `ws.py` - WebSocket 客户端核心,负责与 OneBot 实现端建立和管理连接
-
-#### `data/` - 数据存储目录
-- `admin.json` - 管理员配置文件,存储全局管理员列表
-- `permissions.json` - 权限数据文件,存储用户权限映射关系
-
-#### `html/` - HTML 静态文件
-- `404.html` - 404 错误页面
-- `index.html` - 项目主页 HTML 文件,展示项目信息和特性
-
-#### `models/` - 数据模型定义
-- `events/` - OneBot 事件定义
- - `base.py` - 事件基类定义
- - `message.py` - 消息事件定义
- - `notice.py` - 通知事件定义
- - `request.py` - 请求事件定义
- - `meta.py` - 元事件定义
- - `factory.py` - 事件工厂类,用于根据 JSON 数据创建对应事件对象
-- `message.py` - 消息段定义,支持文本、图片、表情等多种消息类型
-- `objects.py` - API 返回对象定义,提供强类型化的 API 响应数据模型
-- `sender.py` - 发送者定义,包含用户、群成员等信息
-
-#### 根目录文件
-- `.gitignore` - Git 忽略文件配置
-- `config.toml` - 主配置文件,包含 WebSocket 连接、机器人指令前缀、Redis 连接等配置
-- `main.py` - 程序入口文件,负责初始化插件、启动热重载监控和建立 WebSocket 连接
-- `requirements.txt` - Python 依赖包列表
+---
## 🚀 快速开始
### 1. 环境准备
-* Python 3.8+
-* OneBot 11 实现端(推荐 [NapCatQQ](https://github.com/NapNeko/NapCatQQ) 或 LLOneBot)
+* **Python 3.12 或更高版本**
+ * **我觉得**: 在开发和调试阶段使用官方的 **CPython** 解释器,以获得最佳的第三方库兼容性和调试体验。
+ * **你也可以觉得**: 在生产环境部署时,可以考虑使用 **PyPy** 以获取潜在的性能提升,但这可能会牺牲一定的兼容性。
+* Redis 服务
+* 一个正在运行的 OneBot v11 实现端 (推荐 **NapCatQQ**)
### 2. 安装依赖
+克隆本项目后,在项目根目录执行:
```bash
pip install -r requirements.txt
```
-### 3. 配置文件
+### 3. 配置
-修改根目录下的 `config.toml`,配置 WebSocket 连接信息:
+**[内部开发]**
+
+为了方便内部开发和调试,项目中的 `config.toml` 文件已预先配置为连接到官方的 DEV 调试服务器。
+
+**因此,在拉取仓库后,您通常无需对 `config.toml` 文件进行任何修改即可直接运行。**
+
+如果您需要连接到本地或其他特定环境,可以参考以下配置结构进行修改。配置示例:
```toml
+# config.toml
+
[napcat_ws]
-uri = "ws://127.0.0.1:30004" # OneBot 实现端的 WebSocket 地址
-token = "your_token" # Access Token (如果有)
-reconnect_interval = 5 # 断线重连间隔(秒)
+# OneBot 实现端的 WebSocket 地址
+uri = "ws://127.0.0.1:3001"
+# Access Token (如果有)
+token = ""
+# 断线重连间隔(秒)
+reconnect_interval = 5
[bot]
-command = ["/"] # 指令前缀,支持多个,如 ["/", "#"]
+# 机器人指令的起始符号,可配置多个
+command_prefixes = ["/", "!", "!"]
+
+[redis]
+# Redis 连接信息
+host = "127.0.0.1"
+port = 6379
+db = 0
+password = ""
```
### 4. 运行
@@ -269,794 +119,109 @@ command = ["/"] # 指令前缀,支持多个,如 ["/", "#"]
```bash
python main.py
```
+机器人启动后,将自动连接到 OneBot 实现端。控制台会输出加载的插件列表和连接状态。
-## 🛠️ 开发指南
+---
-### 🔥 热重载调试
+## 🛠️ 插件开发指南
-项目集成了 `watchdog` 文件监控。在开发过程中,你只需要:
-1. 保持 `main.py` 运行。
-2. 修改或新建 `plugins` 目录下的 `.py` 插件文件。
-3. 保存文件。
-4. 控制台会自动提示 `[HotReload] 插件重载完成`,新的逻辑立即生效。
+Calglau BOT 的所有功能都通过插件实现。开发新功能非常简单,并且得益于热重载,你无需在开发过程中频繁重启机器人。
-### 创建新插件
+### 🔥 热重载工作流
-在 `plugins` 目录下创建一个新的 `.py` 文件(例如 `my_plugin.py`),框架会自动加载它。
+1. 保持 `python main.py` 进程运行。
+2. 在 `plugins/` 目录下创建或修改任意 `.py` 文件。
+3. **保存文件**。
+4. 观察控制台输出 `[HotReload] 插件重载完成` 的提示。你的新代码已即时生效。
-### 示例代码
+### 创建一个新插件
-#### 1. 注册消息指令
+1. 在 `plugins/` 目录下新建一个 Python 文件,例如 `weather.py`。
+2. 在该文件中编写你的逻辑。
-使用 `@matcher.command("指令名")` 注册指令。
+#### 1. 定义插件元数据 (`__plugin_meta__`)
+
+为了让 `/help` 指令能自动发现你的插件,请在文件顶部定义 `__plugin_meta__` 字典:
```python
-from core.command_manager import matcher
-from core.bot import Bot
-from models import MessageEvent
+# plugins/weather.py
-# 注册 /hello 指令
-@matcher.command("hello")
-async def handle_hello(bot: Bot, event: MessageEvent, args: list[str]):
- # args 是去除指令后的参数列表
- await event.reply("你好!这里是 NEO Bot。")
-```
-
-#### 2. 监听通知事件
-
-使用 `@matcher.on_notice("通知类型")` 监听通知。
-
-```python
-from core.command_manager import matcher
-from core.bot import Bot
-from models import GroupIncreaseNoticeEvent
-
-# 监听群成员增加事件
-@matcher.on_notice("group_increase")
-async def welcome_new_member(bot: Bot, event: GroupIncreaseNoticeEvent):
- await bot.send_group_msg(event.group_id, f"欢迎新成员 {event.user_id} 加入!")
-```
-
-#### 3. 监听请求事件
-
-使用 `@matcher.on_request("请求类型")` 监听请求。
-
-```python
-from core.command_manager import matcher
-from core.bot import Bot
-from models import FriendRequestEvent
-
-# 自动同意好友请求
-@matcher.on_request("friend")
-async def auto_approve_friend(bot: Bot, event: FriendRequestEvent):
- await bot.call_api("set_friend_add_request", {
- "flag": event.flag,
- "approve": True
- })
-```
-
-#### 4. API 调用方式对比
-
-框架提供两种 API 调用方式:**类型化 API**(推荐)和 **通用 API**(备用)。
-
-##### 方式一:类型化 API(推荐)
-对于已封装的 API,框架提供了类型化的方法,返回数据模型对象而非原始字典:
-
-```python
-from core.command_manager import matcher
-from core.bot import Bot
-from models import MessageEvent
-from models.objects import Group
-
-@matcher.command("info")
-async def get_group_info_typed(bot: Bot, event: MessageEvent, args: list[str]):
- # 使用类型化 API,返回 Group 对象
- group: Group = await bot.get_group_info(event.group_id)
- await event.reply(f"群名:{group.group_name}\n成员数:{group.member_count}\n创建时间:{group.create_time}")
-```
-
-##### 方式二:通用 API(备用)
-如果框架尚未封装某个 OneBot API,你可以使用 `bot.call_api` 直接调用。这是通用的备用调用方法。
-
-```python
-from core.command_manager import matcher
-from core.bot import Bot
-from models import MessageEvent
-
-@matcher.command("info_legacy")
-async def get_group_info_legacy(bot: Bot, event: MessageEvent, args: list[str]):
- # 直接调用 get_group_info API
- # action: API 名称
- # params: API 参数字典
- resp = await bot.call_api("get_group_info", {
- "group_id": event.group_id,
- "no_cache": False
- })
-
- if resp.get("status") == "ok":
- group_name = resp["data"]["group_name"]
- await event.reply(f"当前群名:{group_name}")
-```
-
-**建议**:优先使用类型化 API,获得更好的类型安全和代码提示。仅在框架未封装特定 API 时使用通用 API。
-
-## 📖 插件开发指南
-
-### 插件基本结构
-一个标准的插件文件应该包含以下部分:
-1. **模块文档字符串**:描述插件功能
-2. **导入必要的模块**:从 `core` 和 `models` 导入所需类
-3. **使用装饰器注册事件处理器**:`@matcher.command()`, `@matcher.on_notice()`, `@matcher.on_request()`
-4. **异步函数实现业务逻辑**:使用 `async def` 定义处理函数
-
-### 插件元数据 (`__plugin_meta__`)
-为了实现插件的自动发现和帮助信息的自动生成,框架引入了插件元数据机制。你需要在你的插件模块中定义一个名为 `__plugin_meta__` 的字典。
-
-`load_all_plugins` 函数在加载插件时会自动读取这个变量,并将其注册到 `CommandManager` 中。`/help` 指令会遍历所有已注册的元数据,生成格式化的帮助信息。
-
-一个标准的 `__plugin_meta__` 包含以下字段:
-
-- `name` (str): 插件的友好名称,例如 "回声"。
-- `description` (str): 对插件功能的简短描述。
-- `usage` (str): 插件的使用方法,可以包含多个指令和它们的说明。
-
-**示例:**
-```python
-# plugins/echo.py
-
-__plugin_meta__ = {
- "name": "回声与交互",
- "description": "提供 echo 和 赞我 功能",
- "usage": "/echo [内容] - 复读内容\n/赞我 - 让机器人给你点赞",
-}
-```
-
-### 使用类型化 API
-框架现已提供完整的类型化 API 封装,建议优先使用这些封装方法而非原始的 `call_api`:
-
-| API 方法 | 返回类型 | 说明 |
-|---------|---------|------|
-| `bot.send_group_msg()` | `Message` | 发送群消息 |
-| `bot.get_group_info()` | `Group` | 获取群信息 |
-| `bot.get_group_member_info()` | `GroupMember` | 获取群成员信息 |
-| `bot.get_friend_list()` | `List[Friend]` | 获取好友列表 |
-| `bot.get_login_info()` | `LoginInfo` | 获取登录信息 |
-| `bot.get_version_info()` | `VersionInfo` | 获取版本信息 |
-
-#### 示例:使用类型化 API 重构群信息查询
-```python
-from core.command_manager import matcher
-from core.bot import Bot
-from models import MessageEvent
-from models.objects import Group
-
-@matcher.command("group_info")
-async def get_group_info_typed(bot: Bot, event: MessageEvent, args: list[str]):
- # 使用类型化 API,返回 Group 对象而非字典
- group: Group = await bot.get_group_info(event.group_id)
- await event.reply(f"群名:{group.group_name}\n成员数:{group.member_count}\n创建时间:{group.create_time}")
-```
-
-### 事件处理模式
-除了基本的消息指令,还可以处理多种事件类型:
-
-#### 1. 通知事件处理
-```python
-from models import GroupCardChangeEvent
-
-@matcher.on_notice("group_card")
-async def handle_group_card_change(bot: Bot, event: GroupCardChangeEvent):
- # event.card_new 是新名片,event.card_old 是旧名片
- await bot.send_group_msg(event.group_id, f"成员 {event.user_id} 的名片从 '{event.card_old}' 改为 '{event.card_new}'")
-```
-
-#### 2. 请求事件处理
-```python
-from models import GroupRequestEvent
-
-@matcher.on_request("group")
-async def handle_group_request(bot: Bot, event: GroupRequestEvent):
- # 根据请求类型处理
- if event.sub_type == "add":
- # 自动同意加群请求
- await bot.set_group_add_request(event.flag, event.sub_type, approve=True)
- await bot.send_group_msg(event.group_id, f"已同意用户 {event.user_id} 的加群请求")
-```
-
-### 错误处理
-建议在插件中添加适当的错误处理,避免单个插件崩溃影响整个机器人:
-
-```python
-@matcher.command("dangerous")
-async def dangerous_command(bot: Bot, event: MessageEvent, args: list[str]):
- try:
- # 可能失败的操作
- result = await bot.call_api("some_api", {"param": "value"})
- await event.reply(f"成功:{result}")
- except Exception as e:
- await event.reply(f"执行失败:{str(e)}")
- # 记录日志
- from core.logger import logger
- logger.error(f"插件执行错误:{e}", exc_info=True)
-```
-
-### 处理同步阻塞操作
-为了保持机器人的响应性,所有可能导致长时间阻塞的同步操作都应该在单独的线程池中执行。框架提供了 `run_in_thread_pool` 函数来简化这一过程。
-
-**示例:执行同步阻塞任务**
-```python
-from core.command_manager import matcher
-from core.bot import Bot
-from models import MessageEvent
-from core.executor import run_in_thread_pool
-import time
-
-# 模拟一个耗时的同步操作
-def blocking_task(duration: int):
- time.sleep(duration)
- return f"阻塞任务完成,耗时 {duration} 秒"
-
-@matcher.command("block_test")
-async def handle_blocking_test(bot: Bot, event: MessageEvent, args: list[str]):
- if not args or not args[0].isdigit():
- await event.reply("请提供一个数字作为阻塞时间(秒)。例如:/block_test 5")
- return
-
- duration = int(args[0])
- await event.reply(f"开始执行阻塞任务,耗时 {duration} 秒...")
-
- # 将同步阻塞任务放入线程池执行
- result = await run_in_thread_pool(blocking_task, duration)
- await event.reply(result)
-```
-
-### 权限管理
-框架内置了基于用户角色的权限管理系统,支持 `admin`(超级管理员)、`op`(操作员)、`user`(普通用户)三个权限级别。权限数据存储在 `data/permissions.json` 文件中。
-
-#### 权限级别说明
-- **admin**:最高权限,可以执行所有管理命令,包括添加/移除其他管理员
-- **op**:操作员权限,可以执行大部分管理命令,但不能修改管理员列表
-- **user**:普通用户权限,只能使用基础功能
-
-#### 在插件中使用权限控制
-注册命令时可以通过 `permission` 参数指定所需权限级别:
-
-```python
-from models import MessageEvent
-
-# 只有管理员可以执行此命令
-@matcher.command("admin_only", permission=MessageEvent.ADMIN)
-async def admin_command(bot: Bot, event: MessageEvent, args: list[str]):
- await event.reply("此命令仅限管理员使用")
-
-# 操作员及以上权限可以执行
-@matcher.command("op_only", permission=MessageEvent.OP)
-async def op_command(bot: Bot, event: MessageEvent, args: list[str]):
- await event.reply("此命令需要操作员权限")
-
-# 所有用户都可以执行(默认)
-@matcher.command("public")
-async def public_command(bot: Bot, event: MessageEvent, args: list[str]):
- await event.reply("所有用户都可以使用此命令")
-```
-
-#### 动态权限检查
-如果需要更复杂的权限逻辑,可以使用 `override_permission_check=True` 参数,然后在函数中手动检查权限:
-
-```python
-@matcher.command(
- "special",
- permission=MessageEvent.OP,
- override_permission_check=True
-)
-async def special_command(bot: Bot, event: MessageEvent, permission_granted: bool):
- if not permission_granted:
- await event.reply("权限不足!")
- return
-
- # 额外的权限逻辑
- if event.user_id == 123456:
- await event.reply("特殊用户,允许执行")
- else:
- await event.reply("普通用户,拒绝执行")
-```
-
-### 使用 Redis 进行数据缓存
-框架集成了 Redis 客户端,提供了便捷的异步接口用于数据缓存和持久化。Redis 连接管理器会自动管理连接池,你可以在插件中直接使用。
-
-#### 基本用法
-```python
-from core.redis_manager import redis_manager
-
-@matcher.command("cache")
-async def cache_example(bot: Bot, event: MessageEvent, args: list[str]):
- # 设置缓存
- await redis_manager.set("user:123:name", "张三")
-
- # 获取缓存
- name = await redis_manager.get("user:123:name")
-
- # 设置带过期时间的缓存(单位:秒)
- await redis_manager.setex("temp:data", 3600, "临时数据")
-
- # 删除缓存
- await redis_manager.delete("user:123:name")
-
- await event.reply(f"用户名:{name}")
-```
-
-#### 使用哈希表(Hash)
-```python
-# 设置哈希字段
-await redis_manager.hset("user:123", "age", 20)
-await redis_manager.hset("user:123", "city", "北京")
-
-# 获取哈希字段
-age = await redis_manager.hget("user:123", "age")
-user_data = await redis_manager.hgetall("user:123")
-
-# 删除哈希字段
-await redis_manager.hdel("user:123", "city")
-```
-
-#### 使用列表(List)
-```python
-# 向列表添加元素
-await redis_manager.lpush("recent:actions", "login")
-await redis_manager.rpush("recent:actions", "logout")
-
-# 获取列表范围
-actions = await redis_manager.lrange("recent:actions", 0, 9)
-
-# 获取列表长度
-length = await redis_manager.llen("recent:actions")
-```
-
-### 插件数据管理
-对于需要持久化存储配置或数据的插件,框架提供了 `PluginDataManager` 类,可以方便地管理 JSON 格式的数据文件。
-
-#### 基本用法
-```python
-from core.plugin_manager import PluginDataManager
-
-# 初始化数据管理器
-data_manager = PluginDataManager("weather_plugin")
-
-@matcher.command("weather_set")
-async def set_weather_config(bot: Bot, event: MessageEvent, args: list[str]):
- if len(args) < 2:
- await event.reply("用法:/weather_set <城市> <温度>")
- return
-
- city = args[0]
- temperature = args[1]
-
- # 保存配置
- await data_manager.set(city, temperature)
- await event.reply(f"已设置 {city} 的温度为 {temperature}℃")
-
-@matcher.command("weather_get")
-async def get_weather_config(bot: Bot, event: MessageEvent, args: list[str]):
- if not args:
- await event.reply("用法:/weather_get <城市>")
- return
-
- city = args[0]
-
- # 读取配置
- temperature = data_manager.get(city)
- if temperature:
- await event.reply(f"{city} 的温度是 {temperature}℃")
- else:
- await event.reply(f"未找到 {city} 的温度配置")
-```
-
-#### 数据文件位置
-插件数据文件保存在 `plugins/data/` 目录下,每个插件对应一个独立的 JSON 文件。例如 `weather_plugin` 插件的数据文件为 `plugins/data/weather_plugin.json`。
-
-### 插件开发最佳实践
-1. **单一职责**:每个插件专注于一个功能领域
-2. **错误处理**:妥善处理可能发生的异常
-3. **类型提示**:为函数参数和返回值添加类型提示
-4. **文档完整**:为每个函数添加文档字符串
-5. **性能考虑**:避免在插件中执行耗时同步操作
-6. **资源清理**:必要时使用 `try...finally` 确保资源释放
-
-### 🚀 高性能插件开发规范 (避坑指南)
-为了保证整个机器人框架的响应速度和稳定性,所有插件都必须遵循异步、非阻塞的开发原则。任何一个插件中的阻塞操作都可能导致整个机器人卡顿或无响应。
-
-以下是必须遵守的核心规范:
-
-#### 1. 禁止任何形式的同步网络请求
-- **错误示范**: 使用 `requests` 库发起网络请求。
- ```python
- import requests
- # 错误!这会阻塞整个程序
- response = requests.get("https://api.example.com")
- ```
-- **正确做法**: 必须使用异步 HTTP 客户端,如 `aiohttp` 或 `httpx`。
- ```python
- import httpx
- # 正确,使用 async with 和 await
- async with httpx.AsyncClient() as client:
- response = await client.get("https://api.example.com")
- ```
-
-#### 2. 禁止使用 `time.sleep()`
-- **错误示范**: 使用 `time.sleep()` 进行等待。
- ```python
- import time
- # 错误!这会阻塞事件循环
- time.sleep(5)
- ```
-- **正确做法**: 必须使用 `asyncio.sleep()`。
- ```python
- import asyncio
- # 正确,这会将控制权交还给事件循环
- await asyncio.sleep(5)
- ```
-
-#### 3. 谨慎处理文件 I/O
-- 对于读写小型、本地文件,直接使用 `with open(...)` 通常是可接受的。
-- 但对于大型文件或网络文件系统(NFS)上的文件,同步 I/O 可能会导致明显的阻塞。
-- **推荐做法**: 对于可能耗时较长的文件操作,使用 `aiofiles` 库。
- ```python
- import aiofiles
- async with aiofiles.open('large_file.dat', mode='rb') as f:
- contents = await f.read()
- ```
-
-#### 4. 将 CPU 密集型任务移出事件循环
-- 如果插件需要执行复杂的计算(例如,图像处理、视频转码、数据分析),这些任务会长时间占用 CPU,同样会阻塞事件循环。
-- **正确做法**: 使用 `loop.run_in_executor()` 将这类任务抛到独立的线程池或进程池中执行。
- ```python
- import asyncio
-
- def cpu_bound_task(data):
- # 这是一个耗时的同步函数
- # ... 进行大量计算 ...
- return "计算结果"
-
- # 在异步的事件处理器中调用
- loop = asyncio.get_running_loop()
- # `None` 表示使用默认的线程池
- result = await loop.run_in_executor(None, cpu_bound_task, "一些数据")
- await event.reply(result)
- ```
-
-遵循以上规范,可以确保您开发的插件不会成为整个机器人应用的性能瓶颈。
-
-### 示例:完整插件模板
-```python
-"""
-天气查询插件
-
-提供 /weather 指令,查询指定城市的天气信息。
-"""
-from core.command_manager import matcher
-from core.bot import Bot
-from models import MessageEvent
-
-# 插件元数据,用于 help 指令
__plugin_meta__ = {
"name": "天气查询",
- "description": "查询指定城市的天气信息",
- "usage": "/weather [城市名称]",
+ "description": "提供城市天气查询功能。",
+ "usage": "/weather [城市名] - 查询指定城市的实时天气。",
}
+```
+
+#### 2. 编写指令处理器
+
+使用 `@matcher.command()` 装饰器来注册一个聊天指令。
+
+```python
+# plugins/weather.py
+from core.command_manager import matcher
+from models import MessageEvent
+
+# ... (元数据定义) ...
@matcher.command("weather")
-async def handle_weather(bot: Bot, event: MessageEvent, args: list[str]):
+async def handle_weather_command(event: MessageEvent, args: list[str]):
"""
- 查询天气信息
-
- :param bot: Bot 实例
- :param event: 消息事件对象
- :param args: 指令参数列表(城市名称)
+ 处理 /weather 指令
+ :param event: 消息事件对象,用于回复等操作
+ :param args: 用户发送的参数列表 (已按空格分割)
"""
if not args:
- await event.reply("请输入城市名称,例如:/weather 北京")
+ await event.reply("请输入要查询的城市名,例如:/weather 北京")
return
- city = " ".join(args)
- try:
- # 这里可以调用天气 API
- weather_info = f"{city} 的天气:晴,25℃"
- await event.reply(weather_info)
- except Exception as e:
- await event.reply(f"查询天气失败:{str(e)}")
-
-# 可以注册多个事件处理器
-@matcher.on_notice("group_increase")
-async def welcome_new_member(bot: Bot, event):
- await bot.send_group_msg(event.group_id, f"欢迎新成员 {event.user_id} 加入!")
-```
-
-## 📚 事件模型说明
-
-NEO 框架的事件模型是基于 OneBot v11 协议的强类型数据模型,采用 `dataclasses` 和类型注解构建。所有事件都继承自 `OneBotEvent` 基类,并通过事件工厂自动从 JSON 数据创建对应的事件对象。
-
-### 事件层次结构
-
-```
-OneBotEvent (抽象基类)
-├── MetaEvent (元事件)
-│ ├── HeartbeatEvent (心跳事件)
-│ └── LifeCycleEvent (生命周期事件)
-├── MessageEvent (消息事件)
-│ ├── PrivateMessageEvent (私聊消息事件)
-│ └── GroupMessageEvent (群聊消息事件)
-├── NoticeEvent (通知事件)
-│ ├── FriendAddNoticeEvent (好友添加通知)
-│ ├── FriendRecallNoticeEvent (好友消息撤回通知)
-│ ├── GroupRecallNoticeEvent (群消息撤回通知)
-│ ├── GroupIncreaseNoticeEvent (群成员增加通知)
-│ ├── GroupDecreaseNoticeEvent (群成员减少通知)
-│ ├── GroupAdminNoticeEvent (群管理员变动通知)
-│ ├── GroupBanNoticeEvent (群禁言通知)
-│ ├── GroupUploadNoticeEvent (群文件上传通知)
-│ ├── PokeNotifyEvent (戳一戳通知)
-│ ├── LuckyKingNotifyEvent (运气王通知)
-│ ├── HonorNotifyEvent (群荣誉变更通知)
-│ ├── GroupCardNoticeEvent (群成员名片更新通知)
-│ ├── OfflineFileNoticeEvent (离线文件通知)
-│ ├── ClientStatusNoticeEvent (客户端状态变更通知)
-│ └── EssenceNoticeEvent (精华消息变动通知)
-└── RequestEvent (请求事件)
- ├── FriendRequestEvent (加好友请求)
- └── GroupRequestEvent (加群请求/邀请)
-```
-
-### 事件基类:OneBotEvent
-
-所有事件的基类,定义了事件的通用属性和方法:
-
-```python
-@dataclass(slots=True)
-class OneBotEvent(ABC):
- """
- OneBot v11 事件的抽象基类。
+ city = args[0]
- Attributes:
- time (int): 事件发生的时间戳 (秒)
- self_id (int): 收到事件的机器人 QQ 号
- _bot (Optional[Bot]): 内部持有的 Bot 实例引用
- """
- time: int
- self_id: int
- _bot: Optional["Bot"] = field(default=None, init=False)
+ # 此处应调用天气 API 获取数据
+ # (示例代码,省略了真实 API 调用)
+ weather_data = f"{city}的天气是:晴,25°C。"
- @property
- @abstractmethod
- def post_type(self) -> str:
- """事件的上报类型,子类必须重写此属性"""
- pass
-
- @property
- def bot(self) -> "Bot":
- """获取与此事件关联的 Bot 实例"""
- if self._bot is None:
- raise ValueError("Bot instance not set for this event")
- return self._bot
-
- @bot.setter
- def bot(self, value: "Bot"):
- """为事件对象设置关联的 Bot 实例"""
- self._bot = value
+ await event.reply(weather_data)
```
-### 事件类型常量
+#### 3. 监听事件
-框架定义了完整的事件类型常量,用于标识不同种类的事件:
-
-```python
-class EventType:
- META = 'meta_event' # 元事件:心跳、生命周期等
- REQUEST = 'request ' # 请求事件:加好友请求、加群请求等
- NOTICE = 'notice' # 通知事件:群成员增加、文件上传等
- MESSAGE = 'message' # 消息事件:私聊消息、群消息等
- MESSAGE_SENT = 'message_sent' # 消息发送事件:机器人自己发送消息的上报
-```
-
-### 消息事件
-
-消息事件是机器人最常处理的事件类型,框架提供了完整的消息段支持和便捷的回复方法:
-
-#### MessageEvent (消息事件基类)
-
-```python
-@dataclass
-class MessageEvent(OneBotEvent):
- message_type: str # 消息类型: private (私聊), group (群聊)
- sub_type: str # 消息子类型
- message_id: int # 消息 ID
- user_id: int # 发送者 QQ 号
- message: List[MessageSegment] # 消息内容列表
- raw_message: str # 原始消息内容
- font: int # 字体
- sender: Optional[Sender] # 发送者信息
-
- @property
- def post_type(self) -> str:
- return EventType.MESSAGE
-
- async def reply(self, message: str, auto_escape: bool = False):
- """回复消息(抽象方法,由子类实现)"""
- raise NotImplementedError
-```
-
-#### PrivateMessageEvent (私聊消息事件)
-
-```python
-@dataclass
-class PrivateMessageEvent(MessageEvent):
- async def reply(self, message: str, auto_escape: bool = False):
- """回复私聊消息"""
- await self.bot.send_private_msg(
- user_id=self.user_id, message=message, auto_escape=auto_escape
- )
-```
-
-#### GroupMessageEvent (群聊消息事件)
-
-```python
-@dataclass
-class GroupMessageEvent(MessageEvent):
- group_id: int = 0 # 群号
- anonymous: Optional[Anonymous] = None # 匿名信息
-
- async def reply(self, message: str, auto_escape: bool = False):
- """回复群聊消息"""
- await self.bot.send_group_msg(
- group_id=self.group_id, message=message, auto_escape=auto_escape
- )
-```
-
-### 通知事件
-
-通知事件用于处理各种系统通知,如群成员变动、文件上传等:
-
-#### 常用通知事件示例
-
-```python
-@dataclass
-class GroupIncreaseNoticeEvent(GroupNoticeEvent):
- """群成员增加通知"""
- operator_id: int = 0 # 操作者 QQ 号
- sub_type: str = "" # 子类型: approve (管理员同意入群), invite (管理员邀请入群)
-
-@dataclass
-class GroupRecallNoticeEvent(GroupNoticeEvent):
- """群消息撤回通知"""
- operator_id: int = 0 # 操作者 QQ 号
- message_id: int = 0 # 被撤回的消息 ID
-
-@dataclass
-class PokeNotifyEvent(NotifyNoticeEvent):
- """戳一戳通知"""
- target_id: int = 0 # 被戳者 QQ 号
- group_id: int = 0 # 群号 (如果是群内戳一戳)
-```
-
-### 请求事件
-
-请求事件用于处理用户的主动请求,如加好友、加群等:
-
-```python
-@dataclass
-class FriendRequestEvent(RequestEvent):
- """加好友请求事件"""
- user_id: int = 0 # 发送请求的 QQ 号
- comment: str = "" # 验证信息
- flag: str = "" # 请求 flag,用于 API 调用
-
-@dataclass
-class GroupRequestEvent(RequestEvent):
- """加群请求/邀请事件"""
- sub_type: str = "" # 子类型: add (加群请求), invite (邀请登录号入群)
- group_id: int = 0 # 群号
- user_id: int = 0 # 发送请求的 QQ 号
- comment: str = "" # 验证信息
- flag: str = "" # 请求 flag,用于 API 调用
-```
-
-### 元事件
-
-元事件用于处理框架自身状态变化,如心跳、生命周期等:
-
-```python
-@dataclass
-class HeartbeatEvent(MetaEvent):
- """心跳事件,用于确认连接状态"""
- meta_event_type: str = 'heartbeat'
- status: HeartbeatStatus = field(default_factory=HeartbeatStatus)
- interval: int = 0 # 心跳间隔时间(ms)
-
-@dataclass
-class LifeCycleEvent(MetaEvent):
- """生命周期事件,用于通知框架生命周期变化"""
- meta_event_type: str = 'lifecycle'
- sub_type: LifeCycleSubType = LifeCycleSubType.ENABLE # 子类型: enable, disable, connect
-```
-
-### 事件工厂:EventFactory
-
-事件工厂是框架的核心组件之一,负责将原始 JSON 数据转换为强类型的事件对象:
-
-```python
-class EventFactory:
- @staticmethod
- def create_event(data: Dict[str, Any]) -> OneBotEvent:
- """根据数据创建事件对象"""
- post_type = data.get("post_type")
-
- if post_type == EventType.MESSAGE or post_type == EventType.MESSAGE_SENT:
- return EventFactory._create_message_event(data, common_args)
- elif post_type == EventType.NOTICE:
- return EventFactory._create_notice_event(data, common_args)
- elif post_type == EventType.REQUEST:
- return EventFactory._create_request_event(data, common_args)
- elif post_type == EventType.META:
- return EventFactory._create_meta_event(data, common_args)
- else:
- raise ValueError(f"Unknown event type: {post_type}")
-```
-
-### 在插件中使用事件
-
-插件可以直接使用这些事件类型来处理各种场景:
+除了指令,你还可以监听各种事件,例如新成员入群。
```python
from core.command_manager import matcher
+from models import GroupIncreaseNoticeEvent
from core.bot import Bot
-from models import GroupMessageEvent, PrivateMessageEvent
-from models.events.notice import GroupIncreaseNoticeEvent
-from models.events.request import FriendRequestEvent
-# 处理群消息事件
-@matcher.command("hello")
-async def handle_hello(bot: Bot, event: GroupMessageEvent, args: list[str]):
- await event.reply(f"你好 {event.sender.nickname}!")
-
-# 处理私聊消息事件
-@matcher.command("help", permission_level=MessageEvent.USER)
-async def handle_help(bot: Bot, event: PrivateMessageEvent, args: list[str]):
- await event.reply("这里是帮助信息...")
-
-# 处理群成员增加通知
@matcher.on_notice("group_increase")
-async def handle_group_increase(bot: Bot, event: GroupIncreaseNoticeEvent):
- await bot.send_group_msg(
- event.group_id,
- f"欢迎新成员 {event.user_id} 加入!操作者:{event.operator_id}"
- )
-
-# 处理加好友请求
-@matcher.on_request("friend")
-async def handle_friend_request(bot: Bot, event: FriendRequestEvent):
- # 自动同意所有好友请求
- await bot.set_friend_add_request(flag=event.flag, approve=True)
- await bot.send_private_msg(event.user_id, "已通过您的好友请求!")
+async def welcome_new_member(bot: Bot, event: GroupIncreaseNoticeEvent):
+ """当有新成员加入群聊时触发"""
+ welcome_message = f"欢迎新成员 @{event.user_id} 加入本群!"
+ await bot.send_group_msg(event.group_id, welcome_message)
```
-### 事件处理的优势
+---
-1. **类型安全**:所有事件都有明确的类型定义,IDE 可以提供完整的代码提示和补全
-2. **易于测试**:事件对象可以轻松构造,便于编写单元测试
-3. **数据完整**:所有字段都有类型注解,确保数据的一致性和完整性
-4. **性能优化**:使用 `@dataclass(slots=True)` 减少内存占用,提高属性访问速度
-5. **可扩展性**:可以轻松定义自定义事件类型,扩展框架功能
+## 📦 当前功能插件
-### 常用事件属性速查
+| 插件文件 (`plugins/`) | 功能描述 |
+|-----------------------|----------|
+| `admin.py` | 机器人管理员权限管理 |
+| `bili_parser.py` | 自动解析 Bilibili 视频链接分享卡片 |
+| `code_py.py` | 执行 Python 代码片段 (高危,仅限管理员) |
+| `echo.py` | 提供 `/echo` 复读和 `/赞我` 功能 |
+| `forward_test.py` | 演示如何发送合并转发消息 |
+| `jrcd.py` | 娱乐功能:今日人品、牛牛词典 |
+| `thpic.py` | 发送一张随机的东方 Project 图片 |
-| 事件类型 | 关键属性 | 描述 |
-|---------|---------|------|
-| **MessageEvent** | `message_type`, `user_id`, `message`, `sender` | 所有消息事件的基类 |
-| **PrivateMessageEvent** | 继承自 MessageEvent | 私聊消息事件 |
-| **GroupMessageEvent** | `group_id`, `anonymous` | 群聊消息事件,包含群号和匿名信息 |
-| **GroupIncreaseNoticeEvent** | `group_id`, `user_id`, `operator_id`, `sub_type` | 群成员增加通知 |
-| **RecallGroupNoticeEvent** | `group_id`, `user_id`, `operator_id`, `message_id` | 群消息撤回通知 |
-| **FriendRequestEvent** | `user_id`, `comment`, `flag` | 加好友请求事件 |
-| **GroupRequestEvent** | `group_id`, `user_id`, `sub_type`, `comment`, `flag` | 加群请求/邀请事件 |
-| **HeartbeatEvent** | `status`, `interval` | 心跳事件,用于监控连接状态 |
+---
-通过这套完整的事件模型,NEO 框架为开发者提供了强大而灵活的事件处理能力,同时保持了代码的类型安全和良好的开发体验。
+## 🗺️ 路线图 (Roadmap)
+
+- [ ] **Web 仪表盘**: 开发一个简单的 Web 页面,用于查看机器人状态和插件列表。
+- [ ] **权限系统重构**: 引入更精细化的权限节点,允许按插件或指令控制用户权限。
+- [ ] **数据库集成**: 引入 `SQLite` 或其他数据库,用于需要持久化存储数据的功能。
+- [ ] **新插件开发**:
+ - [ ] 天气查询插件
+ - [ ] GIL实现
+ - [ ] coming soon...
diff --git a/config.toml b/config.toml
index 4846aeb..5955e20 100644
--- a/config.toml
+++ b/config.toml
@@ -12,3 +12,15 @@ host = "114.66.58.203"
port = 1931
db = 0
password = "redis_5dxyJG"
+
+[docker]
+base_url = "tcp://dockertest.k2cro4.my:2375"
+sandbox_image = "python-sandbox:latest"
+timeout = 10
+concurrency_limit = 5
+tls_verify = true
+ca_cert_path = "c:/Users/镀铬酸钾/Documents/NeoBot/ca/ca.crt"
+client_cert_path = "c:/Users/镀铬酸钾/Documents/NeoBot/ca/client-cert.pem"
+client_key_path = "c:/Users/镀铬酸钾/Documents/NeoBot/ca/client-key.pem"
+
+
diff --git a/core/command_manager.py b/core/command_manager.py
index c51794a..058cc89 100644
--- a/core/command_manager.py
+++ b/core/command_manager.py
@@ -69,7 +69,7 @@ class CommandManager:
def command(
self,
- name: str,
+ *names: str,
permission: Optional[Any] = None,
override_permission_check: bool = False
) -> Callable:
@@ -77,7 +77,7 @@ class CommandManager:
装饰器:注册一个消息指令处理器。
"""
return self.message_handler.command(
- name,
+ *names,
permission=permission,
override_permission_check=override_permission_check
)
diff --git a/core/config_loader.py b/core/config_loader.py
index 776ddcb..b00ede3 100644
--- a/core/config_loader.py
+++ b/core/config_loader.py
@@ -73,6 +73,15 @@ class Config:
"""
return self._data.get("redis", {})
+ @property
+ def docker(self) -> dict:
+ """
+ 获取 Docker 配置
+
+ :return: 配置字典
+ """
+ return self._data.get("docker", {})
+
# 实例化全局配置对象
global_config = Config()
diff --git a/core/event_handler.py b/core/event_handler.py
index b355718..8c384f9 100644
--- a/core/event_handler.py
+++ b/core/event_handler.py
@@ -83,7 +83,7 @@ class MessageHandler(BaseHandler):
def command(
self,
- name: str,
+ *names: str,
permission: Optional[Permission] = None,
override_permission_check: bool = False
) -> Callable:
@@ -93,11 +93,12 @@ class MessageHandler(BaseHandler):
def decorator(func: Callable) -> Callable:
if not inspect.iscoroutinefunction(func):
raise SyncHandlerError(f"命令处理器 {func.__name__} 必须是异步函数 (async def).")
- self.commands[name] = {
- "func": func,
- "permission": permission,
- "override_permission_check": override_permission_check,
- }
+ for name in names:
+ self.commands[name] = {
+ "func": func,
+ "permission": permission,
+ "override_permission_check": override_permission_check,
+ }
return func
return decorator
@@ -137,7 +138,8 @@ class MessageHandler(BaseHandler):
permission_granted = await permission_manager.check_permission(event.user_id, permission)
if not permission_granted and not override_check:
- await bot.send(event, f"权限不足,需要 {permission.name} 权限")
+ permission_name = permission.name if isinstance(permission, Permission) else permission
+ await bot.send(event, f"权限不足,需要 {permission_name} 权限")
return
await self._run_handler(
diff --git a/core/executor.py b/core/executor.py
index 6d691bd..73599d9 100644
--- a/core/executor.py
+++ b/core/executor.py
@@ -1,27 +1,184 @@
-"""
-线程池执行器
-
-提供一个全局的线程池和异步接口,用于在事件循环中安全地运行同步函数。
-"""
+# -*- coding: utf-8 -*-
import asyncio
-from concurrent.futures import ThreadPoolExecutor
-from functools import partial
-from typing import Any, Callable
+import docker
+from docker.tls import TLSConfig
+from typing import Dict, Any, Callable
-# 创建一个全局的线程池,可以根据需要调整 max_workers
-executor = ThreadPoolExecutor(max_workers=10)
+from core.logger import logger
-async def run_in_thread_pool(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
+class CodeExecutor:
"""
- 在线程池中异步运行同步函数
+ 代码执行引擎,负责管理一个异步任务队列和并发的 Docker 容器执行。
+ """
+ def __init__(self, bot_instance, config: Dict[str, Any]):
+ """
+ 初始化代码执行引擎。
+ :param bot_instance: Bot 实例,用于后续的消息回复。
+ :param config: 从 config.toml 加载的配置字典。
+ """
+ self.bot = bot_instance
+ self.task_queue = asyncio.Queue()
+
+ # 从传入的配置中读取 Docker 相关设置
+ docker_config = config.docker
+ self.docker_base_url = docker_config.get("base_url")
+ self.sandbox_image = docker_config.get("sandbox_image", "python-sandbox:latest")
+ self.timeout = docker_config.get("timeout", 10)
+ concurrency = docker_config.get("concurrency_limit", 5)
+
+ self.concurrency_limit = asyncio.Semaphore(concurrency)
+ self.docker_client = None
- :param func: 要运行的同步函数
- :param args: 函数的位置参数
- :param kwargs: 函数的关键字参数
- :return: 函数的返回值
+ logger.info("[CodeExecutor] 初始化 Docker 客户端...")
+ try:
+ if self.docker_base_url:
+ # 如果配置了远程 Docker 地址,则使用 TLS 选项进行连接
+ tls_config = None
+ if docker_config.get("tls_verify", False):
+ tls_config = TLSConfig(
+ ca_cert=docker_config.get("ca_cert_path"),
+ client_cert=(docker_config.get("client_cert_path"), docker_config.get("client_key_path")),
+ verify=True
+ )
+ self.docker_client = docker.DockerClient(base_url=self.docker_base_url, tls=tls_config)
+ else:
+ # 否则,使用默认的本地环境连接
+ self.docker_client = docker.from_env()
+
+ # 检查 Docker 服务是否可用
+ self.docker_client.ping()
+ logger.success("[CodeExecutor] Docker 客户端初始化成功,服务连接正常。")
+ except docker.errors.DockerException as e:
+ self.docker_client = None
+ logger.error(f"无法连接到 Docker 服务,请检查 Docker 是否正在运行: {e}")
+ except Exception as e:
+ self.docker_client = None
+ logger.error(f"初始化 Docker 客户端时发生未知错误: {e}")
+
+ async def add_task(self, code: str, callback: Callable[[str], asyncio.Future]):
+ """
+ 将代码执行任务添加到队列中。
+ :param code: 待执行的 Python 代码字符串。
+ :param callback: 执行完毕后用于回复结果的回调函数。
+ """
+ task = {"code": code, "callback": callback}
+ await self.task_queue.put(task)
+ logger.info(f"[CodeExecutor] 新的代码执行任务已入队 (队列当前长度: {self.task_queue.qsize()})。")
+
+ async def worker(self):
+ """
+ 后台工作者,不断从队列中取出任务并执行。
+ """
+ if not self.docker_client:
+ logger.error("[CodeExecutor] Worker 无法启动,因为 Docker 客户端未初始化。")
+ return
+
+ logger.info("[CodeExecutor] 代码执行 Worker 已启动,等待任务...")
+ while True:
+ task = await self.task_queue.get()
+
+ logger.info("[CodeExecutor] 开始处理代码执行任务。")
+
+ async with self.concurrency_limit:
+ result_message = ""
+ try:
+ loop = asyncio.get_running_loop()
+
+ # 使用 asyncio.wait_for 实现超时控制
+ result_bytes = await asyncio.wait_for(
+ loop.run_in_executor(
+ None, # 使用默认线程池
+ self._run_in_container,
+ task['code']
+ ),
+ timeout=self.timeout
+ )
+
+ output = result_bytes.decode('utf-8').strip()
+ result_message = output if output else "代码执行完毕,无输出。"
+ logger.success("[CodeExecutor] 任务成功执行。")
+
+ except docker.errors.ImageNotFound:
+ logger.error(f"[CodeExecutor] 镜像 '{self.sandbox_image}' 不存在!")
+ result_message = f"执行失败:沙箱基础镜像 '{self.sandbox_image}' 不存在,请联系管理员构建。"
+ except docker.errors.ContainerError as e:
+ error_output = e.stderr.decode('utf-8').strip()
+ result_message = f"代码执行出错:\n{error_output}"
+ logger.warning(f"[CodeExecutor] 代码执行时发生错误: {error_output}")
+ except docker.errors.APIError as e:
+ logger.error(f"[CodeExecutor] Docker API 错误: {e}")
+ result_message = "执行失败:与 Docker 服务通信时发生错误,请检查服务状态。"
+ except asyncio.TimeoutError:
+ result_message = f"执行超时 (超过 {self.timeout} 秒)。"
+ logger.warning("[CodeExecutor] 任务执行超时。")
+ except Exception as e:
+ logger.exception(f"[CodeExecutor] 执行 Docker 任务时发生未知严重错误: {e}")
+ result_message = "执行引擎发生内部错误,请联系管理员。"
+
+ # 调用回调函数回复结果
+ await task['callback'](result_message)
+
+ self.task_queue.task_done()
+
+ def _run_in_container(self, code: str) -> bytes:
+ """
+ 同步函数:在 Docker 容器中运行代码。
+ 此函数通过手动管理容器生命周期来提高稳定性。
+ """
+ container = None
+ try:
+ # 1. 创建容器
+ container = self.docker_client.containers.create(
+ image=self.sandbox_image,
+ command=["python", "-c", code],
+ mem_limit='128m',
+ cpu_shares=512,
+ network_disabled=True,
+ log_config={'type': 'json-file', 'config': {'max-size': '1m'}},
+ )
+ # 2. 启动容器
+ container.start()
+
+ # 3. 等待容器执行完成
+ # 主超时由 asyncio.wait_for 控制,这里的 timeout 是一个额外的保险
+ result = container.wait(timeout=self.timeout + 5)
+
+ # 4. 获取日志
+ stdout = container.logs(stdout=True, stderr=False)
+ stderr = container.logs(stdout=False, stderr=True)
+
+ # 5. 检查退出码,如果不为 0,则手动抛出 ContainerError
+ if result.get('StatusCode', 0) != 0:
+ raise docker.errors.ContainerError(
+ container, result['StatusCode'], f"python -c '{code}'", self.sandbox_image, stderr
+ )
+
+ return stdout
+
+ finally:
+ # 6. 确保容器总是被移除
+ if container:
+ try:
+ container.remove(force=True)
+ except docker.errors.NotFound:
+ # 如果容器因为某些原因已经消失,也沒关系
+ pass
+ except Exception as e:
+ logger.error(f"[CodeExecutor] 强制移除容器 {container.id} 时失败: {e}")
+
+def initialize_executor(bot_instance, config: Dict[str, Any]):
+ """
+ 初始化并返回一个 CodeExecutor 实例。
+ """
+ return CodeExecutor(bot_instance, config)
+
+async def run_in_thread_pool(sync_func, *args, **kwargs):
+ """
+ 在线程池中运行同步阻塞函数,以避免阻塞 asyncio 事件循环。
+ :param sync_func: 同步函数
+ :param args: 位置参数
+ :param kwargs: 关键字参数
+ :return: 同步函数的返回值
"""
loop = asyncio.get_running_loop()
- # 使用 functools.partial 绑定函数和参数,以便传递给 run_in_executor
- func_to_run = partial(func, *args, **kwargs)
- # loop.run_in_executor 会返回一个 awaitable 对象
- return await loop.run_in_executor(executor, func_to_run)
+ return await loop.run_in_executor(None, lambda: sync_func(*args, **kwargs))
diff --git a/core/permission_manager.py b/core/permission_manager.py
index c79a18d..917e753 100644
--- a/core/permission_manager.py
+++ b/core/permission_manager.py
@@ -227,6 +227,14 @@ class PermissionManager:
Returns:
bool: 如果用户权限 >= 所需权限,返回 True,否则返回 False
"""
+ # 如果传入的是字符串,先转换为 Permission 对象
+ if isinstance(required_permission, str):
+ required_permission = _PERMISSIONS.get(required_permission.lower())
+ if not required_permission:
+ # 如果是无效的权限字符串,默认拒绝
+ logger.warning(f"检测到无效的权限检查字符串: {required_permission}")
+ return False
+
user_permission = await self.get_user_permission(user_id)
return user_permission >= required_permission
@@ -249,4 +257,21 @@ class PermissionManager:
# 全局权限管理器实例
-permission_manager = PermissionManager()
\ No newline at end of file
+permission_manager = PermissionManager()
+
+def require_admin(func):
+ """
+ 一个装饰器,用于限制命令只能由管理员执行。
+ """
+ from functools import wraps
+ from models.events.message import MessageEvent
+
+ @wraps(func)
+ async def wrapper(event: MessageEvent, *args, **kwargs):
+ user_id = event.user_id
+ if await permission_manager.check_permission(user_id, ADMIN):
+ return await func(event, *args, **kwargs)
+ else:
+ await event.reply("抱歉,您没有权限执行此命令。")
+ return None
+ return wrapper
diff --git a/main.py b/main.py
index 1445f1f..0589512 100644
--- a/main.py
+++ b/main.py
@@ -108,6 +108,21 @@ async def main():
try:
bot = WS()
+
+ # 初始化代码执行器
+ from core.config_loader import global_config as config
+ from core.executor import initialize_executor
+ code_executor = initialize_executor(bot, config)
+ bot.bot.code_executor = code_executor # 将执行器实例附加到 bot.bot 对象上
+
+ # 启动代码执行器的后台 worker
+ logger.debug("[Main] 检查是否需要启动代码执行 Worker...")
+ if code_executor and code_executor.docker_client:
+ logger.info("[Main] Docker 连接成功,正在启动代码执行 Worker...")
+ asyncio.create_task(code_executor.worker())
+ else:
+ logger.warning("[Main] 未启动代码执行 Worker,因为 Docker 客户端未初始化或连接失败。")
+
await bot.connect()
finally:
if observer.is_alive():
diff --git a/plugins/broadcast.py b/plugins/broadcast.py
new file mode 100644
index 0000000..37cfd32
--- /dev/null
+++ b/plugins/broadcast.py
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+"""
+管理员专用的广播插件
+功能:
+- 仅限管理员在私聊中调用。
+- 通过回复一条消息并发送指令,将该消息转发给机器人所在的所有群聊。
+- 此插件不写入 __plugin_meta__,保持隐藏。
+"""
+from core.command_manager import matcher
+from models import MessageEvent
+from core.permission_manager import ADMIN
+from core.logger import logger
+
+@matcher.command("broadcast", "广播", permission=ADMIN)
+async def broadcast_message(event: MessageEvent):
+ """
+ 广播指令处理器。
+
+ :param event: 消息事件对象。
+ """
+ # 1. 检查是否为私聊消息
+ if event.group_id:
+ # 在群聊中调用时,静默处理,不予响应
+ return
+
+ # 2. 检查是否回复了某条消息
+ reply = event.reply
+ if not reply:
+ await event.reply("请通过“回复”一条您想广播的消息来使用此功能。")
+ return
+
+ # 3. 获取机器人所在的群聊列表
+ bot = event.bot
+ try:
+ group_list = await bot.get_group_list()
+ if not group_list:
+ await event.reply("机器人目前没有加入任何群聊。")
+ return
+ except Exception as e:
+ logger.error(f"[Broadcast] 获取群聊列表失败: {e}")
+ await event.reply(f"获取群聊列表时发生错误,无法广播。错误信息: {e}")
+ return
+
+ # 4. 遍历所有群聊并转发消息
+ success_count = 0
+ failed_count = 0
+ total_groups = len(group_list)
+
+ await event.reply(f"准备向 {total_groups} 个群聊广播消息,请稍候...")
+
+ for group in group_list:
+ group_id = group.get("group_id")
+ if not group_id:
+ continue
+
+ try:
+ # 直接转发被回复的消息
+ await bot.forward_message(
+ group_id=group_id,
+ message_id=reply.message_id
+ )
+ success_count += 1
+ logger.info(f"[Broadcast] 已成功将消息转发至群聊: {group_id}")
+ except Exception as e:
+ failed_count += 1
+ logger.error(f"[Broadcast] 转发消息至群聊 {group_id} 失败: {e}")
+
+ # 5. 向管理员报告结果
+ report_message = (
+ f"广播任务完成。\n"
+ f"总群聊数: {total_groups}\n"
+ f"成功: {success_count}\n"
+ f"失败: {failed_count}"
+ )
+ await event.reply(report_message)
diff --git a/plugins/code_py.py b/plugins/code_py.py
index 2fa2988..055b034 100644
--- a/plugins/code_py.py
+++ b/plugins/code_py.py
@@ -1,176 +1,156 @@
-"""
-code_py插件
-
-输入/code py回车再加上python代码,机器人就会执行代码并返回执行结果。
-"""
-
+# -*- coding: utf-8 -*-
+import html
+import textwrap
import asyncio
-import re
-import sys
-import tempfile
-import os
-from typing import Tuple, Set
+from typing import Dict
-from core.bot import Bot
from core.command_manager import matcher
-from core.executor import run_in_thread_pool
from models import MessageEvent
+from core.permission_manager import ADMIN
+from core.logger import logger
__plugin_meta__ = {
- "name": "code_py",
- "description": "提供执行python代码的功能",
- "usage": "/code_py - 进入交互模式,等待输入代码块\n/code_py [单行代码] - 快速执行单行代码",
+ "name": "Python 代码执行",
+ "description": "在安全的沙箱环境中执行 Python 代码片段,支持单行、多行和转发回复。",
+ "usage": "/py <单行代码>\n/code_py <单行代码>\n/py (进入多行输入模式)",
}
-# --- 安全配置:危险模块和内置函数黑名单 ---
-DANGEROUS_MODULES = [
- "os", "sys", "subprocess", "shutil", "socket", "requests", "urllib",
- "http", "ftplib", "telnetlib", "ctypes", "_thread", "multiprocessing",
- "asyncio",
-]
-DANGEROUS_BUILTINS = [
- "__import__", "open", "exec", "eval", "compile", "input", "breakpoint"
-]
+# --- 会话状态管理 ---
+# 结构: {(user_id, group_id): asyncio.TimerHandle}
+multi_line_sessions: Dict[tuple, asyncio.TimerHandle] = {}
-# 编译后的正则表达式,用于分割语句
-STATEMENT_SPLIT_PATTERN = re.compile(r'[;\n]')
-# 编译后的正则表达式,用于查找危险的内置函数调用
-BUILTIN_CALL_PATTERN = re.compile(r'\b(' + '|'.join(DANGEROUS_BUILTINS) + r')\s*\(')
-
-def is_code_safe(code: str) -> Tuple[bool, str]:
+async def reply_as_forward(event: MessageEvent, input_code: str, output_result: str):
"""
- 检查代码中是否包含危险的模块导入或内置函数调用。
+ 将输入和输出打包成转发消息进行回复。
+ 参考 forward_test.py 的实现,兼容私聊和群聊。
"""
- # 1. 检查危险的内置函数
- found_builtins = BUILTIN_CALL_PATTERN.search(code)
- if found_builtins:
- return False, f"检测到不允许的内置函数调用:'{found_builtins.group(1)}'"
-
- # 2. 检查危险的模块导入
- statements = STATEMENT_SPLIT_PATTERN.split(code)
- for statement in statements:
- statement = statement.strip()
- if not statement:
- continue
- parts = statement.split()
- if not parts:
- continue
- if parts[0] == 'from' and len(parts) > 1:
- module_name = parts[1].strip()
- if module_name in DANGEROUS_MODULES:
- return False, f"检测到不允许的模块导入:'{module_name}'"
- elif parts[0] == 'import' and len(parts) > 1:
- modules_str = ' '.join(parts[1:])
- imported_modules = [m.strip() for m in modules_str.split(',')]
- for module_name in imported_modules:
- actual_module_name = module_name.split()[0]
- if actual_module_name in DANGEROUS_MODULES:
- return False, f"检测到不允许的模块导入:'{actual_module_name}'"
- return True, ""
-
-async def run_code_in_subprocess(code_str: str, timeout: float = 10.0) -> Tuple[str, str]:
- """
- 在子进程中安全地执行Python代码。
- """
- with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False, encoding="utf-8") as tf:
- tf.write(code_str)
- tf_path = tf.name
- try:
- proc = await asyncio.create_subprocess_exec(
- sys.executable, tf_path,
- stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
- )
- try:
- out_bytes, err_bytes = await asyncio.wait_for(proc.communicate(), timeout=timeout)
- except asyncio.TimeoutError:
- proc.kill()
- await proc.communicate()
- return "", f"执行超时(>{timeout}s)"
- return out_bytes.decode(errors="ignore"), err_bytes.decode(errors="ignore")
- finally:
- try:
- os.remove(tf_path)
- except Exception:
- pass
-
-async def process_and_reply(bot: Bot, event: MessageEvent, code: str):
- """
- 核心处理逻辑:安全检查、执行代码并回复结果。
- """
- safe, message = await run_in_thread_pool(is_code_safe, code)
- if not safe:
- await event.reply(f"代码安全检查未通过:\n{message}")
- return
-
- try:
- stdout, stderr = await run_code_in_subprocess(code, timeout=10.0)
- except Exception as e:
- await event.reply(f"执行失败:{e}")
- return
-
- resp = stderr.strip() or stdout.strip() or "(无输出)"
- MAX = 1500
- if len(resp) > MAX:
- resp = resp[:MAX] + "\n...输出被截断..."
-
+ bot = event.bot
+
+ # 1. 构建消息节点列表
nodes = [
- bot.build_forward_node(user_id=event.self_id, nickname="输入代码", message=code),
- bot.build_forward_node(user_id=event.self_id, nickname="执行结果", message=resp),
+ 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:
- await event.reply(f"结果发送失败: {e}\n\n{resp}")
+ logger.error(f"[code_py] 发送转发消息失败: {e}")
+ # 降级为普通消息回复
+ await event.reply(f"--- 你的代码 ---\n{input_code}\n--- 执行结果 ---\n{output_result}")
-# --- 交互式会话状态 ---
-# 使用集合存储正在等待代码输入的用户标识
-waiting_users: Set[str] = set()
-
-def get_session_id(event: MessageEvent) -> str:
- """根据事件类型生成唯一的会话ID"""
- if hasattr(event, 'group_id'):
- # 群聊会话ID
- return f"group_{event.group_id}-{event.user_id}"
- else:
- # 私聊会话ID
- return f"private_{event.user_id}"
-
-@matcher.command("code_py")
-async def handle_code_command(bot: Bot, event: MessageEvent, args: list[str]):
- # 模式一:快速执行单行代码
- if args:
- code = " ".join(args)
- await process_and_reply(bot, event, code)
+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:
+ await event.reply("代码执行服务当前不可用,请检查 Docker 连接配置。")
return
- # 模式二:进入交互模式
- session_id = get_session_id(event)
- if session_id in waiting_users:
- await event.reply("您已经有一个正在等待输入的code会话了,请直接发送代码。")
- 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., [ -> [)。
+ 2. 移除整个代码块的公共前导缩进,以修复因复制粘贴导致的多余缩进。
+
+ :param code: 原始代码字符串。
+ :return: 规范化后的代码字符串。
+ """
+ # 1. 解码 HTML 实体
+ code = html.unescape(code)
+
+ # 2. 移除公共前导缩进
+ try:
+ code = textwrap.dedent(code)
+ except Exception:
+ # 在某些情况下(例如,不一致的缩进),dedent 可能会失败,
+ # 但我们不希望因此中断流程,所以捕获异常并继续。
+ pass
- waiting_users.add(session_id)
- await event.reply("请在下一条消息中发送要执行的Python代码块。(发送“取消”可退出)")
+ return code.strip()
+
+
+@matcher.command("py", "python", "code_py", permission=ADMIN)
+async def code_py_main(event: MessageEvent, args: list[str]):
+ """
+ /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)
+ else:
+ # 多行模式
+ # 使用 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_code_input(bot: Bot, event: MessageEvent):
- session_id = get_session_id(event)
-
- # 检查用户是否处于等待状态
- if session_id in waiting_users:
- # 从等待集合中移除,无论输入是什么
- waiting_users.remove(session_id)
+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 event.raw_message.strip() == "取消":
- await event.reply("已取消输入。")
- return True # 消费事件
-
- # 执行代码
- await process_and_reply(bot, event, event.raw_message)
- return True # 消费事件,防止被其他指令匹配
-
- # 如果用户不在等待状态,则不处理
- return False
-
+ if not normalized_code:
+ await event.reply("捕获到的代码为空或格式错误,已取消输入。")
+ return
+ await execute_code(event, normalized_code)
+ return True # 消费事件,防止其他处理器响应
diff --git a/requirements.txt b/requirements.txt
index 17a9c33e3864882cb7217cb519675a201e0d8f3d..81bbec89684e9c693f0a5a1a37e498102247c737 100644
GIT binary patch
literal 360
zcmXX?Npiy=5WMr3Py)2%!dqNOnpl((U=}OR>(f)ojh>!fn3Y^_{;P+YdLFGEr5dFX
zYsGtzgVbW9f(37_9`q!Yk_xlKl}ha+rgFOAf2de%uoPO+cPoQ!INzrR}=ZhbWh@**~ZTFN%OI@P!P6*6{xnM>9u>u+y%ynOE7ZK
zg3Jpxr+n#cjA%6W$&rij7fz&@=x`!q^3U8OdQWyEVN3VD<>RQYu*3=
diff --git a/sandbox.Dockerfile b/sandbox.Dockerfile
new file mode 100644
index 0000000..41f2bc3
--- /dev/null
+++ b/sandbox.Dockerfile
@@ -0,0 +1,19 @@
+# 使用一个轻量级的 Python 官方镜像作为基础
+FROM python:3.11-slim
+
+# 创建一个低权限的用户来运行代码,增加安全性
+# -S: 创建一个系统用户 (没有 home 目录)
+# -u: 指定用户ID
+# -g: 指定组ID
+RUN groupadd -g 1001 sandbox && useradd -u 1001 -g sandbox -s /bin/sh -r sandbox
+
+# 创建一个工作目录,用于存放和执行用户的代码
+WORKDIR /sandbox
+# 将目录所有权交给沙箱用户
+RUN chown sandbox:sandbox /sandbox
+
+# 切换到沙箱用户
+USER sandbox
+
+# 默认的启动命令是 python,这样容器启动时可以直接执行 .py 文件
+CMD ["python"]
From afd2c36f889f1df370f4e2617d30cac08e817e16 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Tue, 6 Jan 2026 23:51:09 +0800
Subject: [PATCH 28/46] hotfix!!!!!!
---
core/api/group.py | 17 +++++++++++++++++
plugins/broadcast.py | 31 ++++++++++++++++++++++---------
2 files changed, 39 insertions(+), 9 deletions(-)
diff --git a/core/api/group.py b/core/api/group.py
index 224d148..acdb5c6 100644
--- a/core/api/group.py
+++ b/core/api/group.py
@@ -9,6 +9,7 @@ import json
from core.redis_manager import redis_manager
from .base import BaseAPI
from models.objects import GroupInfo, GroupMemberInfo, GroupHonorInfo
+from core.logger import logger
class GroupAPI(BaseAPI):
@@ -194,6 +195,22 @@ class GroupAPI(BaseAPI):
List[GroupInfo]: 包含所有群组信息的 `GroupInfo` 对象列表。
"""
res = await self.call_api("get_group_list")
+
+ # 增加日志记录 API 原始返回
+ logger.debug(f"OneBot API 'get_group_list' raw response: {res}")
+
+ # 健壮性处理:如果返回的是字符串,尝试解析为 JSON
+ if isinstance(res, str):
+ try:
+ res = json.loads(res)
+ except json.JSONDecodeError:
+ logger.error(f"Failed to decode JSON from 'get_group_list' response: {res}")
+ return []
+
+ if not isinstance(res, list):
+ logger.error(f"Expected a list from 'get_group_list', but got {type(res)}: {res}")
+ return []
+
return [GroupInfo(**item) for item in res]
async def get_group_member_info(self, group_id: int, user_id: int, no_cache: bool = False) -> GroupMemberInfo:
diff --git a/plugins/broadcast.py b/plugins/broadcast.py
index 37cfd32..05658ca 100644
--- a/plugins/broadcast.py
+++ b/plugins/broadcast.py
@@ -19,7 +19,8 @@ async def broadcast_message(event: MessageEvent):
:param event: 消息事件对象。
"""
# 1. 检查是否为私聊消息
- if event.group_id:
+ # 使用 hasattr 安全地检查 group_id 属性,避免 AttributeError
+ if hasattr(event, 'group_id') and event.group_id:
# 在群聊中调用时,静默处理,不予响应
return
@@ -41,7 +42,19 @@ async def broadcast_message(event: MessageEvent):
await event.reply(f"获取群聊列表时发生错误,无法广播。错误信息: {e}")
return
- # 4. 遍历所有群聊并转发消息
+ # 4. 获取被回复的消息内容
+ try:
+ message_data = await bot.get_msg(reply.message_id)
+ message_content = message_data.get("message", "")
+ if not message_content:
+ await event.reply("无法获取被回复的消息内容,广播失败。")
+ return
+ except Exception as e:
+ logger.error(f"[Broadcast] 获取消息内容失败: {e}")
+ await event.reply(f"获取消息内容时发生错误: {e}")
+ return
+
+ # 5. 遍历所有群聊并发送消息
success_count = 0
failed_count = 0
total_groups = len(group_list)
@@ -49,23 +62,23 @@ async def broadcast_message(event: MessageEvent):
await event.reply(f"准备向 {total_groups} 个群聊广播消息,请稍候...")
for group in group_list:
- group_id = group.get("group_id")
+ group_id = group.group_id
if not group_id:
continue
try:
- # 直接转发被回复的消息
- await bot.forward_message(
+ # 发送消息到群聊
+ await bot.send_group_msg(
group_id=group_id,
- message_id=reply.message_id
+ message=message_content
)
success_count += 1
- logger.info(f"[Broadcast] 已成功将消息转发至群聊: {group_id}")
+ logger.info(f"[Broadcast] 已成功将消息发送至群聊: {group_id}")
except Exception as e:
failed_count += 1
- logger.error(f"[Broadcast] 转发消息至群聊 {group_id} 失败: {e}")
+ logger.error(f"[Broadcast] 发送消息至群聊 {group_id} 失败: {e}")
- # 5. 向管理员报告结果
+ # 6. 向管理员报告结果
report_message = (
f"广播任务完成。\n"
f"总群聊数: {total_groups}\n"
From f33d31f5fc75e5c0fc9077840ef54ac6c585da10 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Tue, 6 Jan 2026 23:52:47 +0800
Subject: [PATCH 29/46] 1111
---
core/api/group.py | 24 +++++++++++++-----------
1 file changed, 13 insertions(+), 11 deletions(-)
diff --git a/core/api/group.py b/core/api/group.py
index acdb5c6..b2ffb71 100644
--- a/core/api/group.py
+++ b/core/api/group.py
@@ -199,19 +199,21 @@ class GroupAPI(BaseAPI):
# 增加日志记录 API 原始返回
logger.debug(f"OneBot API 'get_group_list' raw response: {res}")
- # 健壮性处理:如果返回的是字符串,尝试解析为 JSON
- if isinstance(res, str):
- try:
- res = json.loads(res)
- except json.JSONDecodeError:
- logger.error(f"Failed to decode JSON from 'get_group_list' response: {res}")
+ # 健壮性处理:处理标准的 OneBot v11 响应格式
+ if isinstance(res, dict) and res.get("status") == "ok":
+ group_data = res.get("data", [])
+ if isinstance(group_data, list):
+ return [GroupInfo(**item) for item in group_data]
+ else:
+ logger.error(f"The 'data' field in 'get_group_list' response is not a list: {group_data}")
return []
+
+ # 兼容处理:如果返回的是列表(非标准但可能存在)
+ if isinstance(res, list):
+ return [GroupInfo(**item) for item in res]
- if not isinstance(res, list):
- logger.error(f"Expected a list from 'get_group_list', but got {type(res)}: {res}")
- return []
-
- return [GroupInfo(**item) for item in res]
+ logger.error(f"Unexpected response format from 'get_group_list': {res}")
+ return []
async def get_group_member_info(self, group_id: int, user_id: int, no_cache: bool = False) -> GroupMemberInfo:
"""
From c708761726dac566e2a41d0cd53c7b1871a10bf3 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Wed, 7 Jan 2026 00:24:47 +0800
Subject: [PATCH 30/46] =?UTF-8?q?feat(=E5=B9=BF=E6=92=AD):=20=E9=87=8D?=
=?UTF-8?q?=E6=9E=84=E5=B9=BF=E6=92=AD=E6=8F=92=E4=BB=B6=E4=B8=BA=E4=BC=9A?=
=?UTF-8?q?=E8=AF=9D=E6=A8=A1=E5=BC=8F=E5=B9=B6=E6=94=AF=E6=8C=81=E5=90=88?=
=?UTF-8?q?=E5=B9=B6=E8=BD=AC=E5=8F=91=E6=B6=88=E6=81=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
重构广播功能,从简单的回复转发模式改为更安全的会话模式:
1. 添加会话状态管理,60秒超时自动取消
2. 支持直接发送消息内容而非必须回复
3. 使用合并转发消息格式发送广播内容
4. 改进错误处理和状态报告
5. 添加类型提示和文档注释
同时修改相关API和模型:
1. 在GroupInfo中添加群备注和全员禁言字段
2. 改进get_forward_msg返回类型和兼容性处理
3. 清理不必要的Optional导入
---
core/api/group.py | 2 +-
core/api/message.py | 18 +++++-
models/objects.py | 6 ++
plugins/broadcast.py | 138 ++++++++++++++++++++++++++-----------------
4 files changed, 105 insertions(+), 59 deletions(-)
diff --git a/core/api/group.py b/core/api/group.py
index b2ffb71..2f2b6ac 100644
--- a/core/api/group.py
+++ b/core/api/group.py
@@ -4,7 +4,7 @@
该模块定义了 `GroupAPI` Mixin 类,提供了所有与群组管理、成员操作
等相关的 OneBot v11 API 封装。
"""
-from typing import List, Dict, Any, Optional
+from typing import List, Dict, Any
import json
from core.redis_manager import redis_manager
from .base import BaseAPI
diff --git a/core/api/message.py b/core/api/message.py
index 25458a4..c37b5a1 100644
--- a/core/api/message.py
+++ b/core/api/message.py
@@ -105,7 +105,7 @@ class MessageAPI(BaseAPI):
"""
return await self.call_api("get_msg", {"message_id": message_id})
- async def get_forward_msg(self, id: str) -> Dict[str, Any]:
+ async def get_forward_msg(self, id: str) -> List[Dict[str, Any]]:
"""
获取合并转发消息的内容。
@@ -113,9 +113,21 @@ class MessageAPI(BaseAPI):
id (str): 合并转发消息的 ID。
Returns:
- Dict[str, Any]: OneBot API 的响应数据,包含转发消息的节点列表。
+ List[Dict[str, Any]]: 转发消息的节点列表。
"""
- return await self.call_api("get_forward_msg", {"id": id})
+ forward_data = await self.call_api("get_forward_msg", {"id": id})
+ nodes = forward_data.get("data")
+
+ if not isinstance(nodes, list):
+ # 兼容某些实现可能将节点放在 'messages' 键下
+ data = forward_data.get('data', {})
+ if isinstance(data, dict):
+ nodes = data.get('messages')
+
+ if not isinstance(nodes, list):
+ raise ValueError("在 get_forward_msg 响应中找不到消息节点列表")
+
+ return nodes
async def send_group_forward_msg(self, group_id: int, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
diff --git a/models/objects.py b/models/objects.py
index 2f20c51..6f8a5ac 100644
--- a/models/objects.py
+++ b/models/objects.py
@@ -24,6 +24,12 @@ class GroupInfo:
max_member_count: int = 0
"""最大成员数"""
+ group_remark: str = ""
+ """群备注"""
+
+ group_all_shut: int = 0
+ """是否全员禁言"""
+
@dataclass
class GroupMemberInfo:
diff --git a/plugins/broadcast.py b/plugins/broadcast.py
index 05658ca..b150214 100644
--- a/plugins/broadcast.py
+++ b/plugins/broadcast.py
@@ -6,83 +6,111 @@
- 通过回复一条消息并发送指令,将该消息转发给机器人所在的所有群聊。
- 此插件不写入 __plugin_meta__,保持隐藏。
"""
+import asyncio
from core.command_manager import matcher
-from models import MessageEvent
+from models import MessageEvent, PrivateMessageEvent
from core.permission_manager import ADMIN
from core.logger import logger
+# --- 会话状态管理 ---
+# 结构: {user_id: asyncio.TimerHandle}
+broadcast_sessions: dict[int, asyncio.TimerHandle] = {}
+
+def cleanup_session(user_id: int):
+ """
+ 清理超时的广播会话。
+ """
+ if user_id in broadcast_sessions:
+ del broadcast_sessions[user_id]
+ logger.info(f"[Broadcast] 会话 {user_id} 已超时,自动取消。")
+
@matcher.command("broadcast", "广播", permission=ADMIN)
-async def broadcast_message(event: MessageEvent):
+async def broadcast_start(event: MessageEvent):
"""
- 广播指令处理器。
-
- :param event: 消息事件对象。
+ 广播指令的入口,启动一个等待用户消息的会话。
"""
- # 1. 检查是否为私聊消息
- # 使用 hasattr 安全地检查 group_id 属性,避免 AttributeError
- if hasattr(event, 'group_id') and event.group_id:
- # 在群聊中调用时,静默处理,不予响应
+ # 1. 仅限私聊
+ if not isinstance(event, PrivateMessageEvent):
return
- # 2. 检查是否回复了某条消息
- reply = event.reply
- if not reply:
- await event.reply("请通过“回复”一条您想广播的消息来使用此功能。")
+ user_id = event.user_id
+
+ # 如果上一个会话的超时任务还在,先取消它
+ if user_id in broadcast_sessions:
+ broadcast_sessions[user_id].cancel()
+
+ await event.reply("已进入广播模式,请在 60 秒内发送您想要广播的消息内容。")
+
+ # 设置 60 秒超时
+ loop = asyncio.get_running_loop()
+ timeout_handler = loop.call_later(
+ 60,
+ cleanup_session,
+ user_id
+ )
+ broadcast_sessions[user_id] = timeout_handler
+
+@matcher.on_message()
+async def handle_broadcast_content(event: MessageEvent):
+ """
+ 通用消息处理器,用于捕获广播模式下的消息输入。
+ 将捕获到的消息打包成一个新的合并转发消息并广播。
+ """
+ # 仅处理私聊消息,且用户在广播会话中
+ if not isinstance(event, PrivateMessageEvent) or event.user_id not in broadcast_sessions:
return
- # 3. 获取机器人所在的群聊列表
+ user_id = event.user_id
+
+ # 成功捕获到消息,取消超时任务并清理会话
+ broadcast_sessions[user_id].cancel()
+ del broadcast_sessions[user_id]
+
+ message_to_broadcast = event.message
+ if not message_to_broadcast:
+ await event.reply("捕获到的消息为空,已取消广播。")
+ return True
+
+ # --- 执行广播逻辑 ---
bot = event.bot
try:
group_list = await bot.get_group_list()
if not group_list:
await event.reply("机器人目前没有加入任何群聊。")
- return
+ return True
except Exception as e:
logger.error(f"[Broadcast] 获取群聊列表失败: {e}")
- await event.reply(f"获取群聊列表时发生错误,无法广播。错误信息: {e}")
- return
+ await event.reply(f"获取群聊列表时发生错误: {e}")
+ return True
- # 4. 获取被回复的消息内容
- try:
- message_data = await bot.get_msg(reply.message_id)
- message_content = message_data.get("message", "")
- if not message_content:
- await event.reply("无法获取被回复的消息内容,广播失败。")
- return
- except Exception as e:
- logger.error(f"[Broadcast] 获取消息内容失败: {e}")
- await event.reply(f"获取消息内容时发生错误: {e}")
- return
-
- # 5. 遍历所有群聊并发送消息
- success_count = 0
- failed_count = 0
+ success_count, failed_count = 0, 0
total_groups = len(group_list)
-
- await event.reply(f"准备向 {total_groups} 个群聊广播消息,请稍候...")
+ await event.reply(f"已收到广播内容,准备打包并向 {total_groups} 个群聊广播...")
- for group in group_list:
- group_id = group.group_id
- if not group_id:
- continue
-
- try:
- # 发送消息到群聊
- await bot.send_group_msg(
- group_id=group_id,
- message=message_content
+ # --- 将管理员发送的消息打包成一个单节点的合并转发消息 ---
+ try:
+ nodes_to_send = [
+ bot.build_forward_node(
+ user_id=event.user_id,
+ nickname=event.sender.nickname,
+ message=message_to_broadcast
)
+ ]
+ except Exception as e:
+ logger.error(f"[Broadcast] 构建转发节点失败: {e}")
+ await event.reply(f"构建转发消息节点时发生错误: {e}")
+ return True
+
+ # --- 向所有群聊发送打包好的合并转发消息 ---
+ for group in group_list:
+ try:
+ await bot.send_group_forward_msg(group.group_id, nodes_to_send)
success_count += 1
- logger.info(f"[Broadcast] 已成功将消息发送至群聊: {group_id}")
except Exception as e:
failed_count += 1
- logger.error(f"[Broadcast] 发送消息至群聊 {group_id} 失败: {e}")
-
- # 6. 向管理员报告结果
- report_message = (
- f"广播任务完成。\n"
- f"总群聊数: {total_groups}\n"
- f"成功: {success_count}\n"
- f"失败: {failed_count}"
- )
- await event.reply(report_message)
+ logger.error(f"[Broadcast] 转发至群聊 {group.group_id} 失败: {e}")
+
+ report = f"广播完成。\n总群聊: {total_groups}\n成功: {success_count}\n失败: {failed_count}"
+ await event.reply(report)
+
+ return True # 消费事件,防止其他处理器响应
From 56b1014419a13f656f16c986efa11a8158276e1d Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Wed, 7 Jan 2026 22:51:27 +0800
Subject: [PATCH 31/46] =?UTF-8?q?refactor(core):=20=E9=87=8D=E6=9E=84?=
=?UTF-8?q?=E6=A0=B8=E5=BF=83=E6=A8=A1=E5=9D=97=E7=BB=93=E6=9E=84=E5=B9=B6?=
=?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=BC=80=E5=8F=91=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
将核心模块按功能重新组织为更清晰的结构,包括 managers、handlers 和 utils 目录
添加完整的开发文档,涵盖快速开始、项目结构、核心概念和插件开发指南
更新所有相关模块的导入路径以匹配新的结构
将单例模式实现提取到单独的 singleton.py 文件
---
README.md | 8 ++
config.toml | 1 +
core/__init__.py | 4 +-
core/api/account.py | 2 +-
core/api/friend.py | 2 +-
core/api/group.py | 4 +-
core/data/admin.json | 3 +
{data => core/data}/permissions.json | 0
core/handlers/__init__.py | 0
core/{ => handlers}/event_handler.py | 19 ++--
core/managers/__init__.py | 0
core/{ => managers}/admin_manager.py | 25 ++---
core/{ => managers}/command_manager.py | 4 +-
core/{ => managers}/permission_manager.py | 25 ++---
core/{ => managers}/plugin_manager.py | 8 +-
core/{ => managers}/redis_manager.py | 4 +-
core/utils/__init__.py | 0
core/{ => utils}/exceptions.py | 0
core/{ => utils}/executor.py | 2 +-
core/{ => utils}/logger.py | 0
core/utils/singleton.py | 30 ++++++
core/ws.py | 4 +-
data/admin.json | 3 -
docs/core-concepts/event-flow.md | 64 +++++++++++
docs/core-concepts/singleton-managers.md | 89 +++++++++++++++
docs/deployment.md | 102 ++++++++++++++++++
docs/getting-started.md | 113 ++++++++++++++++++++
docs/index.md | 34 ++++++
docs/plugin-development/command-handling.md | 99 +++++++++++++++++
docs/plugin-development/index.md | 88 +++++++++++++++
docs/project-structure.md | 69 ++++++++++++
main.py | 17 +--
models/events/message.py | 2 +-
plugins/admin.py | 4 +-
plugins/bili_parser.py | 4 +-
plugins/broadcast.py | 6 +-
plugins/code_py.py | 6 +-
plugins/echo.py | 2 +-
plugins/forward_test.py | 2 +-
plugins/jrcd.py | 4 +-
plugins/sync_async_test_plugin.py | 6 +-
plugins/thpic.py | 2 +-
pytest.ini | 2 +
requirements.txt | 3 +
sandbox.Dockerfile | 10 --
45 files changed, 772 insertions(+), 104 deletions(-)
create mode 100644 core/data/admin.json
rename {data => core/data}/permissions.json (100%)
create mode 100644 core/handlers/__init__.py
rename core/{ => handlers}/event_handler.py (85%)
create mode 100644 core/managers/__init__.py
rename core/{ => managers}/admin_manager.py (91%)
rename core/{ => managers}/command_manager.py (97%)
rename core/{ => managers}/permission_manager.py (93%)
rename core/{ => managers}/plugin_manager.py (95%)
rename core/{ => managers}/redis_manager.py (95%)
create mode 100644 core/utils/__init__.py
rename core/{ => utils}/exceptions.py (100%)
rename core/{ => utils}/executor.py (99%)
rename core/{ => utils}/logger.py (100%)
create mode 100644 core/utils/singleton.py
delete mode 100644 data/admin.json
create mode 100644 docs/core-concepts/event-flow.md
create mode 100644 docs/core-concepts/singleton-managers.md
create mode 100644 docs/deployment.md
create mode 100644 docs/getting-started.md
create mode 100644 docs/index.md
create mode 100644 docs/plugin-development/command-handling.md
create mode 100644 docs/plugin-development/index.md
create mode 100644 docs/project-structure.md
create mode 100644 pytest.ini
diff --git a/README.md b/README.md
index ec20324..4f7c632 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,14 @@
---
+## 📚 详细开发文档
+
+**想要深入了解框架的工作原理或开发更复杂的插件?**
+
+👉 **[点击这里,查阅完整的开发文档](./docs/index.md)**
+
+---
+
## 🚀 快速开始
### 1. 环境准备
diff --git a/config.toml b/config.toml
index 5955e20..fd8d433 100644
--- a/config.toml
+++ b/config.toml
@@ -6,6 +6,7 @@ reconnect_interval = 5
[bot]
command = ["/"]
ignore_self_message = true #是否忽略自身消息
+permission_denied_message = "权限不足,需要 {permission_name} 权限"
[redis]
host = "114.66.58.203"
diff --git a/core/__init__.py b/core/__init__.py
index 032d0c6..ad853f6 100644
--- a/core/__init__.py
+++ b/core/__init__.py
@@ -1,6 +1,6 @@
-from .command_manager import matcher
+from .managers.command_manager import matcher
from .config_loader import global_config
-from .plugin_manager import PluginDataManager
+from .managers.plugin_manager import PluginDataManager
from .ws import WS
__all__ = ["WS", "matcher", "global_config", "PluginDataManager"]
diff --git a/core/api/account.py b/core/api/account.py
index 07bab8d..7f75507 100644
--- a/core/api/account.py
+++ b/core/api/account.py
@@ -8,7 +8,7 @@ import json
from typing import Dict, Any
from .base import BaseAPI
from models.objects import LoginInfo, VersionInfo, Status
-from core.redis_manager import redis_manager
+from ..managers.redis_manager import redis_manager
class AccountAPI(BaseAPI):
diff --git a/core/api/friend.py b/core/api/friend.py
index 823fa06..a3a118b 100644
--- a/core/api/friend.py
+++ b/core/api/friend.py
@@ -8,7 +8,7 @@ import json
from typing import List, Dict, Any
from .base import BaseAPI
from models.objects import FriendInfo, StrangerInfo
-from core.redis_manager import redis_manager
+from ..managers.redis_manager import redis_manager
class FriendAPI(BaseAPI):
diff --git a/core/api/group.py b/core/api/group.py
index 2f2b6ac..5714baf 100644
--- a/core/api/group.py
+++ b/core/api/group.py
@@ -6,10 +6,10 @@
"""
from typing import List, Dict, Any
import json
-from core.redis_manager import redis_manager
+from ..managers.redis_manager import redis_manager
from .base import BaseAPI
from models.objects import GroupInfo, GroupMemberInfo, GroupHonorInfo
-from core.logger import logger
+from ..utils.logger import logger
class GroupAPI(BaseAPI):
diff --git a/core/data/admin.json b/core/data/admin.json
new file mode 100644
index 0000000..b3c7949
--- /dev/null
+++ b/core/data/admin.json
@@ -0,0 +1,3 @@
+{
+ "admins": []
+}
\ No newline at end of file
diff --git a/data/permissions.json b/core/data/permissions.json
similarity index 100%
rename from data/permissions.json
rename to core/data/permissions.json
diff --git a/core/handlers/__init__.py b/core/handlers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/core/event_handler.py b/core/handlers/event_handler.py
similarity index 85%
rename from core/event_handler.py
rename to core/handlers/event_handler.py
index 8c384f9..3b1540e 100644
--- a/core/event_handler.py
+++ b/core/handlers/event_handler.py
@@ -8,10 +8,10 @@ import inspect
from abc import ABC, abstractmethod
from typing import Any, Callable, Dict, List, Optional, Tuple
-from .bot import Bot
-from .permission_manager import Permission, permission_manager
-from .exceptions import SyncHandlerError
-from .executor import run_in_thread_pool
+from ..bot import Bot
+from ..config_loader import global_config
+from ..managers.permission_manager import Permission, permission_manager
+from ..utils.executor import run_in_thread_pool
class BaseHandler(ABC):
@@ -75,8 +75,6 @@ class MessageHandler(BaseHandler):
注册通用消息处理器
"""
def decorator(func: Callable) -> Callable:
- if not inspect.iscoroutinefunction(func):
- raise SyncHandlerError(f"消息处理器 {func.__name__} 必须是异步函数 (async def).")
self.message_handlers.append(func)
return func
return decorator
@@ -91,8 +89,6 @@ class MessageHandler(BaseHandler):
注册命令处理器
"""
def decorator(func: Callable) -> Callable:
- if not inspect.iscoroutinefunction(func):
- raise SyncHandlerError(f"命令处理器 {func.__name__} 必须是异步函数 (async def).")
for name in names:
self.commands[name] = {
"func": func,
@@ -139,7 +135,8 @@ class MessageHandler(BaseHandler):
if not permission_granted and not override_check:
permission_name = permission.name if isinstance(permission, Permission) else permission
- await bot.send(event, f"权限不足,需要 {permission_name} 权限")
+ message_template = global_config.bot.get("permission_denied_message", "权限不足,需要 {permission_name} 权限")
+ await bot.send(event, message_template.format(permission_name=permission_name))
return
await self._run_handler(
@@ -160,8 +157,6 @@ class NoticeHandler(BaseHandler):
注册通知处理器
"""
def decorator(func: Callable) -> Callable:
- if not inspect.iscoroutinefunction(func):
- raise SyncHandlerError(f"通知处理器 {func.__name__} 必须是异步函数 (async def).")
self.handlers.append({"type": notice_type, "func": func})
return func
return decorator
@@ -184,8 +179,6 @@ class RequestHandler(BaseHandler):
注册请求处理器
"""
def decorator(func: Callable) -> Callable:
- if not inspect.iscoroutinefunction(func):
- raise SyncHandlerError(f"请求处理器 {func.__name__} 必须是异步函数 (async def).")
self.handlers.append({"type": request_type, "func": func})
return func
return decorator
diff --git a/core/managers/__init__.py b/core/managers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/core/admin_manager.py b/core/managers/admin_manager.py
similarity index 91%
rename from core/admin_manager.py
rename to core/managers/admin_manager.py
index 864df52..7e5f0d1 100644
--- a/core/admin_manager.py
+++ b/core/managers/admin_manager.py
@@ -9,33 +9,25 @@ import json
import os
from typing import Set
-from .logger import logger
+from ..utils.logger import logger
+from ..utils.singleton import Singleton
+from .redis_manager import redis_manager
-class AdminManager:
+class AdminManager(Singleton):
"""
管理员管理器类
负责加载、缓存和管理管理员列表。
使用单例模式,确保全局只有一个实例。
"""
- _instance = None
_REDIS_KEY = "neobot:admins" # 用于存储管理员集合的 Redis 键
-
- def __new__(cls):
- """
- 单例模式实现
- """
- if cls._instance is None:
- cls._instance = super().__new__(cls)
- cls._instance._initialized = False
- return cls._instance
-
def __init__(self):
"""
初始化 AdminManager
"""
- if getattr(self, "_initialized", False):
+ super().__init__()
+ if not self._initialized:
return
# 管理员数据文件路径
@@ -47,7 +39,6 @@ class AdminManager:
)
self._admins: Set[int] = set()
- self._initialized = True
logger.info("管理员管理器初始化完成")
async def initialize(self):
@@ -96,7 +87,7 @@ class AdminManager:
"""
将内存中的管理员集合同步到 Redis
"""
- from .redis_manager import redis_manager
+ from core.managers.redis_manager import redis_manager
try:
# 首先清空旧的集合
await redis_manager.redis.delete(self._REDIS_KEY)
@@ -111,7 +102,7 @@ class AdminManager:
"""
检查用户是否为管理员(从 Redis 缓存读取)
"""
- from .redis_manager import redis_manager
+
try:
return await redis_manager.redis.sismember(self._REDIS_KEY, user_id)
except Exception as e:
diff --git a/core/command_manager.py b/core/managers/command_manager.py
similarity index 97%
rename from core/command_manager.py
rename to core/managers/command_manager.py
index 058cc89..9f6fb19 100644
--- a/core/command_manager.py
+++ b/core/managers/command_manager.py
@@ -7,8 +7,8 @@
"""
from typing import Any, Callable, Dict, Optional, Tuple
-from .config_loader import global_config
-from .event_handler import MessageHandler, NoticeHandler, RequestHandler
+from ..config_loader import global_config
+from ..handlers.event_handler import MessageHandler, NoticeHandler, RequestHandler
# 从配置中获取命令前缀
diff --git a/core/permission_manager.py b/core/managers/permission_manager.py
similarity index 93%
rename from core/permission_manager.py
rename to core/managers/permission_manager.py
index 917e753..c4d2825 100644
--- a/core/permission_manager.py
+++ b/core/managers/permission_manager.py
@@ -16,8 +16,9 @@ import os
from functools import total_ordering
from typing import Dict
-from .logger import logger
-from .admin_manager import admin_manager # 导入 AdminManager
+from ..utils.logger import logger
+from ..utils.singleton import Singleton
+from .admin_manager import admin_manager
@total_ordering
@@ -73,7 +74,7 @@ _PERMISSIONS: Dict[str, Permission] = {
}
-class PermissionManager:
+class PermissionManager(Singleton):
"""
权限管理器类
@@ -81,27 +82,14 @@ class PermissionManager:
使用单例模式,确保全局只有一个权限管理器实例。
"""
- _instance = None
-
- def __new__(cls):
- """
- 单例模式实现
-
- Returns:
- PermissionManager: 全局唯一的权限管理器实例
- """
- if cls._instance is None:
- cls._instance = super().__new__(cls)
- cls._instance._initialized = False
- return cls._instance
-
def __init__(self):
"""
初始化权限管理器
如果已经初始化过,则直接返回。
"""
- if getattr(self, "_initialized", False):
+ super().__init__()
+ if not self._initialized:
return
# 权限数据文件路径
@@ -122,7 +110,6 @@ class PermissionManager:
# 加载现有数据
self.load()
- self._initialized = True
logger.info("权限管理器初始化完成")
def load(self) -> None:
diff --git a/core/plugin_manager.py b/core/managers/plugin_manager.py
similarity index 95%
rename from core/plugin_manager.py
rename to core/managers/plugin_manager.py
index 8b0e7f1..0141b8c 100644
--- a/core/plugin_manager.py
+++ b/core/managers/plugin_manager.py
@@ -10,10 +10,10 @@ import os
import pkgutil
import sys
-from core.command_manager import matcher
-from core.exceptions import SyncHandlerError
-from .logger import logger
-from .executor import run_in_thread_pool
+from .command_manager import matcher
+from ..utils.exceptions import SyncHandlerError
+from ..utils.logger import logger
+from ..utils.executor import run_in_thread_pool
def load_all_plugins():
diff --git a/core/redis_manager.py b/core/managers/redis_manager.py
similarity index 95%
rename from core/redis_manager.py
rename to core/managers/redis_manager.py
index 7440a22..cdc16cc 100644
--- a/core/redis_manager.py
+++ b/core/managers/redis_manager.py
@@ -1,6 +1,6 @@
import redis.asyncio as redis
-from .config_loader import global_config as config
-from .logger import logger
+from ..config_loader import global_config as config
+from ..utils.logger import logger
class RedisManager:
"""
diff --git a/core/utils/__init__.py b/core/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/core/exceptions.py b/core/utils/exceptions.py
similarity index 100%
rename from core/exceptions.py
rename to core/utils/exceptions.py
diff --git a/core/executor.py b/core/utils/executor.py
similarity index 99%
rename from core/executor.py
rename to core/utils/executor.py
index 73599d9..3882a32 100644
--- a/core/executor.py
+++ b/core/utils/executor.py
@@ -4,7 +4,7 @@ import docker
from docker.tls import TLSConfig
from typing import Dict, Any, Callable
-from core.logger import logger
+from core.utils.logger import logger
class CodeExecutor:
"""
diff --git a/core/logger.py b/core/utils/logger.py
similarity index 100%
rename from core/logger.py
rename to core/utils/logger.py
diff --git a/core/utils/singleton.py b/core/utils/singleton.py
new file mode 100644
index 0000000..db45819
--- /dev/null
+++ b/core/utils/singleton.py
@@ -0,0 +1,30 @@
+"""
+通用单例模式基类
+"""
+
+class Singleton:
+ """
+ 一个通用的单例基类
+
+ 任何继承自该类的子类都将自动成为单例。
+ 它通过重写 __new__ 方法来确保每个类只有一个实例。
+ 同时,它处理了重复初始化的问题,确保 __init__ 方法只在第一次实例化时被调用。
+ """
+ _instance = None
+ _initialized = False
+
+ def __new__(cls, *args, **kwargs):
+ """
+ 创建或返回现有的实例
+ """
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ def __init__(self):
+ """
+ 确保初始化逻辑只执行一次
+ """
+ if self._initialized:
+ return
+ self._initialized = True
diff --git a/core/ws.py b/core/ws.py
index c9d2b77..2de5244 100644
--- a/core/ws.py
+++ b/core/ws.py
@@ -22,9 +22,9 @@ import websockets
from models import EventFactory
from .bot import Bot
-from .command_manager import matcher
from .config_loader import global_config
-from .logger import logger
+from .managers.command_manager import matcher
+from .utils.logger import logger
class WS:
diff --git a/data/admin.json b/data/admin.json
deleted file mode 100644
index 577c240..0000000
--- a/data/admin.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "admins": [2221577113]
-}
\ No newline at end of file
diff --git a/docs/core-concepts/event-flow.md b/docs/core-concepts/event-flow.md
new file mode 100644
index 0000000..5d7275e
--- /dev/null
+++ b/docs/core-concepts/event-flow.md
@@ -0,0 +1,64 @@
+# 核心概念:事件流转
+
+在 NEO Bot Framework 中,所有交互都由**事件**驱动。理解一个事件从被接收到最终被处理的完整流程,是掌握框架工作原理的关键。
+
+本节将以一个用户发送 `/echo hello` 的群聊消息为例,详细拆解其在框架内部的流转路径。
+
+## 事件流转图
+
+```mermaid
+graph TD
+ A[OneBot v11 实现端] -- WebSocket Message --> B(core/ws.py);
+ B -- Raw JSON Data --> C(models/events/factory.py);
+ C -- Event Object --> D(core/ws.py on_event);
+ D -- Event Object --> E(core/managers/command_manager.py);
+ E -- Event & Command Match --> F(core/handlers/event_handler.py);
+ F -- Matched Handler --> G(plugins/echo.py);
+ G -- Call API --> H(core/bot.py);
+ H -- Send Request --> B;
+ B -- WebSocket Send --> A;
+```
+
+## 详细步骤
+
+### 1. 接收 WebSocket 消息 (`core/ws.py`)
+
+* 当用户在 QQ 群里发送消息时,OneBot v11 实现端(如 NapCatQQ)会将其打包成一个 JSON 格式的数据,并通过 WebSocket 连接发送给框架。
+* `core/ws.py` 中的 `_listen_loop` 方法持续监听连接,接收到这个原始的 JSON 字符串。
+
+### 2. 事件对象实例化 (`models/events/factory.py`)
+
+* `ws.py` 将接收到的 JSON 数据传递给 `EventFactory.create_event()`。
+* `EventFactory` 会根据 JSON 中的 `post_type` 字段(例如 `"message"`)和 `message_type` 字段(例如 `"group"`),智能地将其解析并实例化为对应的 Python 对象,例如 `GroupMessageEvent`。
+* 这个 `Event` 对象包含了所有事件信息,并且具有清晰的类型提示,方便后续处理。
+
+### 3. 事件初步处理与分发 (`core/ws.py`)
+
+* `ws.py` 的 `on_event` 方法接收到 `Event` 对象后,会做两件重要的事:
+ 1. **注入 `Bot` 实例**:将 `self.bot` 赋值给 `event.bot`。这使得插件开发者可以在事件处理器中直接通过 `event.reply()` 或 `event.bot.send(...)` 来调用 API。
+ 2. **分发事件**:将 `Event` 对象传递给全局的命令管理器 `matcher.handle_event(bot, event)`。
+
+### 4. 指令匹配与处理器查找 (`core/managers/command_manager.py`)
+
+* `CommandManager` (即 `matcher`) 是事件处理的核心中枢。
+* 它的 `handle_event` 方法会首先判断事件类型。对于消息事件,它会将其交给内部的 `MessageHandler`。
+* `MessageHandler` 会检查消息内容是否以已注册的命令前缀(如 `/`)开头。
+* 如果匹配成功(例如 `/echo`),它会从已注册的命令字典中查找对应的处理函数(即在 `echo.py` 中被 `@matcher.command("echo")` 装饰的函数)。
+
+### 5. 执行插件逻辑 (`plugins/echo.py`)
+
+* `MessageHandler` 找到了匹配的处理器后,会调用它,并将 `Event` 对象和解析出的参数(`args`)传递进去。
+* 此时,控制权就完全交给了插件开发者编写的函数,例如 `handle_echo_command(event, args)`。
+* 插件函数可以执行任意逻辑,比如操作数据库、请求外部 API,或者调用 `Bot` 的 API 来回复消息。
+
+### 6. API 调用与响应 (`core/bot.py` -> `core/ws.py`)
+
+* 当插件调用 `event.reply("hello")` 时,实际上是调用了 `core/bot.py` 中封装的 `send` 方法。
+* `Bot` 类会将这个调用转换为一个标准的 OneBot v11 API 请求(例如 `{"action": "send_group_msg", "params": {...}}`)。
+* 这个请求最终通过 `core/ws.py` 的 `call_api` 方法,被序列化为 JSON 字符串,并通过 WebSocket 发送回 OneBot v11 实现端。
+
+### 7. 消息发送
+
+* OneBot v11 实现端接收到 API 请求后,执行相应的操作——将 "hello" 这条消息发送到原来的 QQ 群。
+
+至此,一个完整的事件流转闭环就完成了。理解这个流程后,您就能明白框架是如何将底层的网络通信与高层的插件逻辑解耦,并为开发者提供便捷接口的。
diff --git a/docs/core-concepts/singleton-managers.md b/docs/core-concepts/singleton-managers.md
new file mode 100644
index 0000000..41d64b5
--- /dev/null
+++ b/docs/core-concepts/singleton-managers.md
@@ -0,0 +1,89 @@
+# 核心概念:单例管理器
+
+在 `core/managers/` 目录下,存放着一系列全局唯一的**管理器(Managers)**。它们是 NEO Bot Framework 功能的核心实现,负责处理事件、管理权限、加载插件等关键任务。
+
+理解这些管理器的职责,有助于您更好地利用框架提供的能力,并进行更高级的开发。
+
+## 设计模式:单例 (Singleton)
+
+框架中所有的管理器都采用了**单例设计模式**。这意味着在整个应用程序的生命周期中,每个管理器类只会存在一个实例。
+
+**为什么使用单例?**
+
+* **全局访问点**: 任何模块(尤其是插件)都可以方便地导入并使用同一个管理器实例,无需手动传递。
+* **状态共享**: 管理器内部维护的状态(如已注册的命令、用户权限列表)是全局共享和一致的。
+* **资源统一管理**: 对于像 Redis 连接这样的资源,单例模式确保了全局只有一个连接池,避免了资源的浪费和冲突。
+
+框架在 `core/utils/singleton.py` 中提供了一个 `Singleton` 基类,所有管理器都继承自它,以轻松实现单例模式。
+
+## 核心管理器介绍
+
+### 1. `CommandManager` (全局实例: `matcher`)
+
+* **文件**: `core/managers/command_manager.py`
+* **全局实例**: `from core.managers.command_manager import matcher`
+* **核心职责**:
+ * **事件处理中枢**: 它是事件流转的核心,负责接收所有类型的事件,并将其分发给相应的底层处理器。
+ * **装饰器提供者**: 为插件提供了 `@matcher.command()`, `@matcher.on_notice()` 等一系列装饰器,用于注册事件处理器。
+ * **指令匹配**: 内部维护了一个指令注册表,能够根据消息内容匹配到对应的处理函数。
+
+`matcher` 是插件开发者最常打交道的管理器。
+
+### 2. `PermissionManager` (全局实例: `permission_manager`)
+
+* **文件**: `core/managers/permission_manager.py`
+* **全局实例**: `from core.managers.permission_manager import permission_manager`
+* **核心职责**:
+ * **权限定义与检查**: 定义了 `ADMIN`, `OP`, `USER` 等权限等级,并提供了 `check_permission` 方法来验证用户权限。
+ * **数据持久化**: 负责从 `core/data/permissions.json` 文件中加载和保存用户权限设置。
+ * **与 `AdminManager` 联动**: 在检查权限时,会自动将机器人管理员(来自 `AdminManager`)识别为最高权限 `ADMIN`。
+
+### 3. `AdminManager` (全局实例: `admin_manager`)
+
+* **文件**: `core/managers/admin_manager.py`
+* **全局实例**: `from core.managers.admin_manager import admin_manager`
+* **核心职责**:
+ * **管理员管理**: 提供 `add_admin`, `remove_admin`, `is_admin` 等接口,用于管理机器人的超级管理员列表。
+ * **数据同步**: 实现了内存、`core/data/admin.json` 文件以及 Redis 缓存之间的数据同步,确保管理员列表的一致性和高效查询。
+
+### 4. `PluginManager`
+
+* **文件**: `core/managers/plugin_manager.py`
+* **核心职责**:
+ * **插件加载**: 负责扫描 `plugins/` 目录,导入所有合法的插件模块。
+ * **元数据提取**: 读取插件文件中定义的 `__plugin_meta__` 字典,用于 `/help` 指令等功能。
+ * **热重载支持**: `load_all_plugins` 函数被 `main.py` 中的文件监控服务调用,以实现插件的热重载。
+
+此管理器通常在后台工作,开发者较少直接与其交互。
+
+### 5. `RedisManager` (全局实例: `redis_manager`)
+
+* **文件**: `core/managers/redis_manager.py`
+* **全局实例**: `from core.managers.redis_manager import redis_manager`
+* **核心职责**:
+ * **连接管理**: 负责初始化和管理与 Redis 服务器的异步连接。
+ * **提供实例**: 通过 `redis_manager.redis` 属性,为其他模块提供一个可用的 `redis` 客户端实例。
+
+## 如何在插件中使用管理器
+
+在您的插件中,只需通过 `import` 语句导入相应管理器的全局实例即可使用。
+
+**示例**: 在插件中检查用户是否为管理员。
+
+```python
+# plugins/my_plugin.py
+
+from core.managers.command_manager import matcher
+from core.managers.permission_manager import permission_manager, ADMIN
+from models.events.message import MessageEvent
+
+@matcher.command("secret")
+async def secret_command(event: MessageEvent):
+ # 使用 permission_manager 检查用户权限
+ is_admin = await permission_manager.check_permission(event.user_id, ADMIN)
+
+ if is_admin:
+ await event.reply("这是一个只有管理员能看到的秘密。")
+ else:
+ await event.reply("抱歉,您没有权限执行此命令。")
+```
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..bc5b22d
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,102 @@
+# 部署指南
+
+当您的机器人开发完成并准备投入生产环境时,本指南将为您提供部署的最佳实践和建议。
+
+## 1. 生产环境配置
+
+与开发环境不同,生产环境要求更高的稳定性和安全性。
+
+### 创建生产配置文件
+
+建议您复制一份 `config.toml` 并重命名为 `config.prod.toml`,专门用于生产环境。
+
+**关键修改项**:
+
+* **数据库与服务地址**:
+ * 确保 `napcat_ws` 和 `redis` 部分的地址、端口和密码都指向您的生产服务器,而不是本地开发环境。
+
+
+
+## 2. 使用进程守护工具
+
+直接在终端中运行 `python main.py` 适用于开发,但在生产环境中,如果终端关闭或程序意外崩溃,机器人就会下线。
+
+为了确保机器人能够 7x24 小时稳定运行,您应该使用**进程守护工具**。
+
+### 推荐工具
+
+* **PM2 (Node.js)**: 尽管是 Node.js 工具,但 PM2 提供了强大的 Python 进程管理功能,包括崩溃自启、日志管理和性能监控。
+* **Supervisor (Python)**: 一个纯 Python 实现的进程控制系统,配置简单,稳定可靠。
+* **Systemd (Linux)**: Linux 系统自带的服务管理器,可以创建系统服务来管理机器人进程。
+
+### 使用 PM2 (示例)
+
+1. **安装 PM2**:
+ ```bash
+ npm install -g pm2
+ ```
+
+2. **创建生态系统文件**:
+ 在项目根目录创建一个 `ecosystem.config.js` 文件:
+
+ ```javascript
+ // ecosystem.config.js
+ module.exports = {
+ apps: [
+ {
+ name: 'neo-bot', // 应用名称
+ script: 'main.py', // 启动脚本
+ interpreter: '/path/to/your/venv/bin/python', // 指定虚拟环境的 Python 解释器
+ env: {
+ 'APP_ENV': 'production', // 设置环境变量
+ },
+ },
+ ],
+ };
+ ```
+ **注意**: 请务必将 `interpreter` 路径修改为您服务器上虚拟环境的实际路径。
+
+3. **启动应用**:
+ ```bash
+ pm2 start ecosystem.config.js
+ ```
+
+4. **常用 PM2 命令**:
+ * `pm2 list`: 查看所有应用状态
+ * `pm2 logs neo-bot`: 查看日志
+ * `pm2 restart neo-bot`: 重启应用
+ * `pm2 stop neo-bot`: 停止应用
+ * `pm2 startup`: 设置开机自启
+
+## 3. 禁用热重载
+
+热重载功能在开发时非常有用,但在生产环境中会带来不必要的性能开销和潜在的不稳定性。
+
+在部署前,建议您在 `main.py` 中**注释掉**或移除与 `watchdog` 相关的文件监控代码。
+
+**修改 `main.py`**:
+
+```python
+# main.py
+
+async def main():
+ # ...
+
+ # 生产环境中禁用文件监控
+ # loop = asyncio.get_running_loop()
+ # event_handler = PluginReloadHandler(loop)
+ # observer = Observer()
+ # if os.path.exists(plugin_path):
+ # observer.schedule(event_handler, plugin_path, recursive=True)
+ # observer.start()
+ # logger.info(f"已启动插件热重载监控: {plugin_path}")
+
+ try:
+ # ...
+ finally:
+ # if observer.is_alive():
+ # observer.stop()
+ # observer.join()
+```
+
+遵循以上步骤,您就可以将 NEO Bot 机器人稳定、高效地部署在生产服务器上。
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..3eca810
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,113 @@
+# 快速上手
+
+本指南将引导您完成 NEO Bot Framework 的本地开发环境搭建、配置和首次运行。
+
+## 1. 环境准备
+
+在开始之前,请确保您的开发环境中已安装以下软件:
+
+* **Python**: 版本要求 `3.12` 或更高。
+ * 我们推荐使用官方的 CPython 解释器。
+ * 您可以通过在终端运行 `python --version` 来检查您的 Python 版本。
+
+* **Git**: 用于克隆项目仓库。
+
+* **Redis**: 一个键值对数据库,用于缓存和数据共享。
+ * 对于 Windows 用户,可以考虑使用 `memurai` 或通过 WSL2 安装 Redis。
+ * 对于 macOS 用户,可以使用 `brew install redis`。
+ * 安装后,请确保 Redis 服务正在运行。
+
+* **OneBot v11 实现端**: 机器人框架需要连接到一个实现了 OneBot v11 协议的客户端。
+ * **推荐**: [NapCatQQ](https://github.com/NapNeko/NapCatQQ)
+
+## 2. 克隆与安装
+
+### 克隆项目
+
+打开您的终端,并克隆项目仓库到本地:
+
+```bash
+git clone [项目仓库地址]
+cd [项目目录]
+```
+
+### 创建虚拟环境 (推荐)
+
+为了保持项目依赖的隔离,强烈建议您创建一个 Python 虚拟环境。
+
+```bash
+# 创建虚拟环境
+python -m venv venv
+
+# 激活虚拟环境
+# Windows
+.\venv\Scripts\activate
+# macOS / Linux
+source venv/bin/activate
+```
+
+### 安装依赖
+
+激活虚拟环境后,使用 `pip` 安装所有必需的第三方库:
+
+```bash
+pip install -r requirements.txt
+```
+
+## 3. 配置
+
+项目的核心配置位于根目录下的 `config.toml` 文件中。
+
+对于内部开发,该文件通常已预先配置好,可以直接连接到测试服务器。如果您需要连接到自己的环境,请修改以下关键部分:
+
+```toml
+# config.toml
+
+[napcat_ws]
+# 您的 OneBot v11 实现端的 WebSocket 地址
+# 格式通常为 ws://:<端口号>
+uri = "ws://127.0.0.1:3001"
+
+# Access Token (访问令牌),如果您的 OneBot 端设置了
+token = ""
+
+[redis]
+# Redis 服务的连接信息
+host = "127.0.0.1"
+port = 6379
+db = 0
+password = "" # 如果您的 Redis 设置了密码
+```
+
+## 4. 首次运行
+
+完成以上所有步骤后,您就可以启动机器人了。在项目根目录运行:
+
+```bash
+python main.py
+```
+
+如果一切顺利,您将在控制台看到类似以下的输出:
+
+```
+2026-01-07 22:42:41.718 | INFO | ... - 管理员管理器初始化完成
+2026-01-07 22:42:41.826 | INFO | ... - 正在从 plugins 加载插件...
+2026-01-07 22:42:41.994 | SUCCESS | ... - Redis 连接成功!
+...
+2026-01-07 22:42:42.618 | SUCCESS | ... - 连接成功!
+```
+
+看到 `连接成功!` 的日志,即表示您的机器人已成功连接到 OneBot 客户端并准备好接收消息。
+
+## 5. 常见问题排查 (FAQ)
+
+* **Q: 启动时报错 `redis.exceptions.ConnectionError`**
+ * **A**: 请检查您的 Redis 服务是否已启动,以及 `config.toml` 中的 `host` 和 `port` 是否正确。
+
+* **Q: 无法连接到 WebSocket,提示 `ConnectionRefusedError`**
+ * **A**: 请确认您的 OneBot v11 客户端(如 NapCatQQ)是否正在运行,并检查 `config.toml` 中的 `uri` 地址和端口是否匹配。
+
+* **Q: 修改了插件代码但没有生效**
+ * **A**: 框架默认开启了热重载功能。请检查控制台是否有 `[HotReload]` 相关的日志输出。如果没有,请确认 `watchdog` 库已正确安装。
+
+现在,您的开发环境已经准备就绪。接下来,您可以尝试修改一个现有插件或[创建您的第一个插件](./plugin-development/index.md)!
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..508c500
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,34 @@
+# NEO Bot Framework 开发文档
+
+欢迎来到 NEO Bot Framework 的官方开发文档。
+
+本文档旨在为开发者提供一个清晰、全面的指南,帮助您理解框架的设计理念、核心功能,并快速上手插件开发。
+
+## 📖 文档结构
+
+本站点的文档分为以下几个主要部分:
+
+* **基础入门**
+ * [快速上手](./getting-started.md): 从零开始配置和运行您的第一个机器人实例。
+ * [项目结构解析](./project-structure.md): 详细介绍框架的目录和文件结构。
+
+* **核心概念**
+ * [事件流转](./core-concepts/event-flow.md): 深入理解一个事件从接收到处理的完整生命周期。
+ * [单例管理器](./core-concepts/singleton-managers.md): 了解框架中核心管理器(如 `CommandManager`, `PermissionManager`)的设计与使用。
+
+* **插件开发**
+ * [基础指南](./plugin-development/index.md): 学习如何创建一个插件,包括元数据定义和热重载工作流。
+ * [指令处理](./plugin-development/command-handling.md): 掌握如何使用 `@matcher.command()` 装饰器注册和处理聊天指令。
+
+* **部署**
+ * [部署指南](./deployment.md): 了解如何在生产环境中部署和维护机器人。
+
+## 🤝 如何贡献
+
+我们欢迎任何形式的贡献,无论是代码提交、文档修正还是功能建议。
+
+* **报告问题**: 如果您在使用中遇到任何问题或 Bug,请通过内部渠道提交 Issue。
+* **提交代码**: 请遵循项目的编码规范,并通过 Pull Request 流程提交您的代码。
+* **完善文档**: 如果您发现文档中有任何错误或遗漏,可以直接提出修改建议。
+
+我们希望这份文档能让您的开发之旅更加顺畅。如果您有任何疑问,请随时与我们联系。
diff --git a/docs/plugin-development/command-handling.md b/docs/plugin-development/command-handling.md
new file mode 100644
index 0000000..fdb2b94
--- /dev/null
+++ b/docs/plugin-development/command-handling.md
@@ -0,0 +1,99 @@
+# 插件开发:指令处理
+
+`@matcher.command()` 是插件开发中使用最频繁的装饰器。本节将深入介绍它的高级用法,帮助您构建功能更强大的指令。
+
+## 1. 获取指令参数
+
+在很多场景下,指令都需要接收用户提供的参数,例如 `/weather 北京`。框架会自动解析这些参数,并通过函数签名注入到您的处理器中。
+
+您只需要在处理函数的参数列表中添加一个名为 `args` 的参数,并指定其类型为 `list[str]`。
+
+```python
+# plugins/weather.py
+from core.managers.command_manager import matcher
+from models.events.message import MessageEvent
+
+@matcher.command("weather")
+async def handle_weather_command(event: MessageEvent, args: list[str]):
+ """
+ 处理 /weather 指令
+ :param event: 消息事件对象
+ :param args: 用户发送的参数列表 (已按空格分割)
+ """
+ if not args:
+ await event.reply("请输入城市名,例如:/weather 北京")
+ return
+
+ # args[0] 就是 "北京"
+ city = args[0]
+
+ # ...后续逻辑...
+ await event.reply(f"正在查询 {city} 的天气...")
+```
+
+* 如果用户发送 `/weather 北京`,`args` 将是 `['北京']`。
+* 如果用户发送 `/weather 上海 浦东`,`args` 将是 `['上海', '浦东']`。
+* 如果用户只发送 `/weather`,`args` 将是一个空列表 `[]`。
+
+## 2. 设置指令别名
+
+同一个功能,用户可能习惯使用不同的指令名称来触发,例如 `天气` 和 `weather`。`@matcher.command()` 允许您为一个处理器设置多个别名。
+
+只需在装饰器中传入多个名称即可:
+
+```python
+@matcher.command("weather", "天气")
+async def handle_weather_command(event: MessageEvent, args: list[str]):
+ # ...
+```
+
+现在,用户发送 `/weather 北京` 或 `/天气 北京` 都可以触发这个函数。
+
+## 3. 权限控制
+
+某些敏感指令只希望特定权限的用户才能执行,例如 `/reload` (重载插件) 或 `/ban` (禁言用户)。
+
+`@matcher.command()` 装饰器提供了一个 `permission` 参数,可以轻松实现权限控制。
+
+首先,从 `permission_manager` 导入预设的权限等级:
+
+```python
+from core.managers.permission_manager import ADMIN, OP, USER
+```
+
+然后,在装饰器中指定所需的权限:
+
+```python
+# plugins/admin_tools.py
+from core.managers.command_manager import matcher
+from core.managers.permission_manager import ADMIN
+from models.events.message import MessageEvent
+
+__plugin_meta__ = {
+ "name": "管理工具",
+ "description": "提供机器人管理功能",
+ "usage": "/reload - 重载所有插件 (仅管理员)",
+}
+
+@matcher.command("reload", permission=ADMIN)
+async def handle_reload_command(event: MessageEvent):
+ """
+ 重载所有插件,仅限管理员使用。
+ """
+ # 这里的逻辑只有在权限检查通过后才会执行
+ await event.reply("正在重载所有插件...")
+ # ... 执行重载逻辑 ...
+```
+
+* **工作原理**: 在调用您的处理函数之前,`CommandManager` 会自动调用 `PermissionManager` 来检查用户的权限。
+* **失败响应**: 如果用户权限不足,框架会自动回复一条权限不足的消息(该消息内容可在 `config.toml` 中配置),并且**不会**执行您的处理函数。
+
+可用的权限等级:
+
+* `ADMIN`: 机器人超级管理员。
+* `OP`: 管理员(Operator),权限低于 `ADMIN`。
+* `USER`: 普通用户,默认权限。
+
+权限关系是 `ADMIN > OP > USER`。设置 `permission=OP` 意味着 `OP` 和 `ADMIN` 都可以使用该指令。
+
+通过组合使用参数处理、别名和权限控制,您可以构建出既灵活又安全的指令来满足各种复杂的需求。
diff --git a/docs/plugin-development/index.md b/docs/plugin-development/index.md
new file mode 100644
index 0000000..4677ad5
--- /dev/null
+++ b/docs/plugin-development/index.md
@@ -0,0 +1,88 @@
+# 插件开发:基础指南
+
+在 NEO Bot Framework 中,几乎所有的功能都是通过**插件**来实现的。框架提供了一个强大而简单的插件系统,让您可以专注于功能逻辑的实现。
+
+## 插件是什么?
+
+一个插件本质上就是一个位于 `plugins/` 目录下的独立 Python 文件 (`.py`)。
+
+框架会在启动时自动扫描并加载这个目录下的所有文件作为插件。
+
+## 🔥 热重载工作流
+
+在开始编写插件之前,了解框架的**热重载**机制至关重要,它能极大地提升您的开发效率。
+
+1. **启动机器人**: 首先,在您的终端中运行 `python main.py` 并保持其运行状态。
+2. **创建或修改插件**: 在 `plugins/` 目录下创建新的 `.py` 文件,或者修改一个已有的插件文件。
+3. **保存文件**: 当您保存文件时,框架会自动检测到文件变更。
+4. **自动重载**: 控制台会显示 `插件重载完成` 的日志,这意味着您的新代码已经生效,无需重启整个程序。
+
+## 创建您的第一个插件
+
+让我们来创建一个经典的 "Hello World" 插件。
+
+### 1. 创建文件
+
+在 `plugins/` 目录下创建一个新文件,命名为 `hello.py`。
+
+### 2. 定义插件元数据 (`__plugin_meta__`)
+
+为了让框架能够识别您的插件信息(例如在 `/help` 命令中显示),您需要在文件顶部定义一个名为 `__plugin_meta__` 的特殊字典。
+
+```python
+# plugins/hello.py
+
+__plugin_meta__ = {
+ "name": "你好世界",
+ "description": "一个简单的插件,用于回复 'Hello, World!'。",
+ "usage": "/hello - 发送问候。",
+}
+```
+
+* `name`: 插件的名称。
+* `description`: 插件功能的简短描述。
+* `usage`: 插件的使用方法说明。
+
+### 3. 编写处理器
+
+现在,让我们来编写一个响应 `/hello` 指令的函数。我们需要从框架中导入 `matcher` 和事件类型。
+
+```python
+# plugins/hello.py
+
+from core.managers.command_manager import matcher
+from models.events.message import MessageEvent
+
+__plugin_meta__ = {
+ "name": "你好世界",
+ "description": "一个简单的插件,用于回复 'Hello, World!'。",
+ "usage": "/hello - 发送问候。",
+}
+
+# 使用 @matcher.command 装饰器来注册一个指令
+@matcher.command("hello")
+async def handle_hello_command(event: MessageEvent):
+ """
+ 当用户发送 /hello 时,此函数将被调用。
+ """
+ # 使用 event.reply() 方法可以快速回复消息到来源地
+ await event.reply("Hello, World!")
+```
+
+### 4. 测试插件
+
+1. 确保 `python main.py` 正在运行。
+2. 保存 `plugins/hello.py` 文件。您应该会在控制台看到插件重载的日志。
+3. 在任何一个机器人所在的群聊或私聊中,发送 `/hello`。
+4. 机器人应该会回复 `Hello, World!`。
+
+恭喜!您已经成功创建并运行了您的第一个插件。
+
+## 插件的最佳实践
+
+* **保持独立**: 尽量让每个插件文件只负责一项相关的功能。
+* **清晰命名**: 为您的插件文件和处理函数选择清晰、描述性的名称。
+* **善用模型**: 充分利用 `models` 中定义的事件和消息段类型,以获得完整的类型提示和代码补全支持。
+* **异步优先**: 框架是基于 `asyncio` 构建的。对于任何 I/O 密集型操作(如网络请求、文件读写),请务必使用 `async/await` 语法,以避免阻塞事件循环。
+
+现在您已经掌握了插件的基础,可以继续学习更高级的主题,例如[如何处理带参数的指令](./command-handling.md)。
diff --git a/docs/project-structure.md b/docs/project-structure.md
new file mode 100644
index 0000000..4690b15
--- /dev/null
+++ b/docs/project-structure.md
@@ -0,0 +1,69 @@
+# 项目结构解析
+
+理解 NEO Bot Framework 的项目结构是高效开发的第一步。本节将详细介绍每个主要目录和文件的用途。
+
+```
+.
+├── core/ # 框架核心代码
+│ ├── api/ # OneBot v11 API 的 Mixin 封装
+│ ├── data/ # 核心模块的数据存储 (admin, permissions)
+│ ├── handlers/ # 底层事件处理器 (message, notice, request)
+│ ├── managers/ # 核心单例管理器 (command, permission, etc.)
+│ ├── utils/ # 通用工具 (logger, singleton, etc.)
+│ ├── bot.py # Bot 核心类,提供 API 调用接口
+│ ├── config_loader.py # TOML 配置文件加载器
+│ └── ws.py # WebSocket 底层通信模块
+├── docs/ # 开发文档
+├── html/ # 静态网页文件 (用于 Web 仪表盘等)
+├── models/ # 数据模型 (事件, 消息段)
+│ ├── events/ # OneBot v11 事件的 Python 对象封装
+│ ├── message.py # 消息段 (MessageSegment) 的定义
+│ └── ...
+├── plugins/ # 功能插件目录
+├── venv/ # Python 虚拟环境 (推荐)
+├── .gitignore # Git 忽略文件配置
+├── config.toml # 主配置文件
+├── main.py # 项目启动入口
+└── requirements.txt # Python 依赖列表
+```
+
+## 顶层目录
+
+### `core/`
+
+这是框架的心脏,包含了所有核心逻辑。**通常情况下,您不需要修改此目录下的代码**,只需了解其工作原理即可。
+
+* `api/`: 将 OneBot v11 的 API 按功能(如 `message`, `group`)拆分为多个 `Mixin` 类,最终由 `bot.py` 继承,提供了清晰的 API 结构。
+* `data/`: 存放核心模块所需的数据文件,例如 `admin.json` 和 `permissions.json`。
+* `handlers/`: 定义了最底层的事件处理器,如 `MessageHandler`,负责从 `ws.py` 接收原始事件并进行初步处理和分发。
+* `managers/`: 包含一系列全局单例管理器,是框架功能的核心实现。例如,`CommandManager` 负责指令注册与匹配,`PermissionManager` 负责权限控制。
+* `utils/`: 提供被广泛使用的工具类,如 `logger` (日志)、`singleton` (单例模式基类)。
+* `bot.py`: 定义了 `Bot` 类,这是插件开发者最常与之交互的对象,用于调用所有 OneBot API。
+* `config_loader.py`: 负责解析 `config.toml` 文件,并提供一个全局的 `global_config` 对象。
+* `ws.py`: 实现了与 OneBot v11 实现端的 WebSocket 连接、心跳、重连和消息收发。
+
+### `docs/`
+
+存放项目的所有开发文档。
+
+### `html/`
+
+用于存放未来 Web 仪表盘或其他 Web 功能所需的静态资源(HTML, CSS, JavaScript)。
+
+### `models/`
+
+定义了将 OneBot v11 的 JSON 数据转换为易于使用的 Python 对象。
+
+* `events/`: 将所有上报的事件(如 `MessageEvent`, `GroupIncreaseNoticeEvent`)封装为带有类型提示的类。
+* `message.py`: 提供了 `MessageSegment` 类,用于构建复杂的消息内容(如 @某人、发送图片)。
+
+### `plugins/`
+
+这是**插件开发者最关心的目录**。所有机器人的功能都以独立的 `.py` 文件形式存放在这里。框架会自动加载此目录下的所有插件,并支持热重载。
+
+## 顶层文件
+
+* `.gitignore`: 配置 Git 应忽略的文件和目录,如 `__pycache__`、`venv` 等。
+* `config.toml`: 项目的主配置文件,用于设置机器人、数据库、API 等所有可变参数。
+* `main.py`: 项目的启动入口脚本。它负责初始化日志、加载插件、启动 WebSocket 连接和文件监控(用于热重载)。
+* `requirements.txt`: 列出了项目运行所需的所有 Python 第三方库及其版本。
diff --git a/main.py b/main.py
index 0589512..9a27b74 100644
--- a/main.py
+++ b/main.py
@@ -5,19 +5,24 @@ NEO Bot 主程序入口
"""
import asyncio
import os
+import sys
import time
+# 将项目根目录添加到 sys.path
+ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, ROOT_DIR)
+
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
# 初始化日志系统,必须在其他 core 模块导入之前执行
-from core.logger import logger
+from core.utils.logger import logger
-from core.admin_manager import admin_manager
+from core.managers.admin_manager import admin_manager
from core.ws import WS
-from core.plugin_manager import load_all_plugins
-from core.redis_manager import redis_manager
-from core.executor import run_in_thread_pool
+from core.managers.plugin_manager import load_all_plugins
+from core.managers.redis_manager import redis_manager
+from core.utils.executor import run_in_thread_pool
class PluginReloadHandler(FileSystemEventHandler):
@@ -111,7 +116,7 @@ async def main():
# 初始化代码执行器
from core.config_loader import global_config as config
- from core.executor import initialize_executor
+ from core.utils.executor import initialize_executor
code_executor = initialize_executor(bot, config)
bot.bot.code_executor = code_executor # 将执行器实例附加到 bot.bot 对象上
diff --git a/models/events/message.py b/models/events/message.py
index febb1d9..530ca62 100644
--- a/models/events/message.py
+++ b/models/events/message.py
@@ -6,7 +6,7 @@
from dataclasses import dataclass, field
from typing import List, Optional
-from core.permission_manager import ADMIN, OP, USER
+from core.managers.permission_manager import ADMIN, OP, USER
from models.message import MessageSegment
from models.sender import Sender
from .base import OneBotEvent, EventType
diff --git a/plugins/admin.py b/plugins/admin.py
index 2d4854d..f922db8 100644
--- a/plugins/admin.py
+++ b/plugins/admin.py
@@ -4,8 +4,8 @@
提供通过聊天指令动态添加或移除机器人管理员的功能。
"""
from core.bot import Bot
-from core.command_manager import matcher
-from core.admin_manager import admin_manager
+from core.managers.command_manager import matcher
+from core.managers.admin_manager import admin_manager
from models.events.message import MessageEvent
__plugin_meta__ = {
diff --git a/plugins/bili_parser.py b/plugins/bili_parser.py
index ed93b1f..2711d52 100644
--- a/plugins/bili_parser.py
+++ b/plugins/bili_parser.py
@@ -5,8 +5,8 @@ import requests
from bs4 import BeautifulSoup
from typing import Optional, Dict, Any
-from core.logger import logger
-from core.command_manager import matcher
+from core.utils.logger import logger
+from core.managers.command_manager import matcher
from models import MessageEvent, MessageSegment
__plugin_meta__ = {
diff --git a/plugins/broadcast.py b/plugins/broadcast.py
index b150214..530dc09 100644
--- a/plugins/broadcast.py
+++ b/plugins/broadcast.py
@@ -7,10 +7,10 @@
- 此插件不写入 __plugin_meta__,保持隐藏。
"""
import asyncio
-from core.command_manager import matcher
+from core.managers.command_manager import matcher
from models import MessageEvent, PrivateMessageEvent
-from core.permission_manager import ADMIN
-from core.logger import logger
+from core.managers.permission_manager import ADMIN
+from core.utils.logger import logger
# --- 会话状态管理 ---
# 结构: {user_id: asyncio.TimerHandle}
diff --git a/plugins/code_py.py b/plugins/code_py.py
index 055b034..fe28984 100644
--- a/plugins/code_py.py
+++ b/plugins/code_py.py
@@ -4,10 +4,10 @@ import textwrap
import asyncio
from typing import Dict
-from core.command_manager import matcher
+from core.managers.command_manager import matcher
from models import MessageEvent
-from core.permission_manager import ADMIN
-from core.logger import logger
+from core.managers.permission_manager import ADMIN
+from core.utils.logger import logger
__plugin_meta__ = {
"name": "Python 代码执行",
diff --git a/plugins/echo.py b/plugins/echo.py
index 407510a..de712bb 100644
--- a/plugins/echo.py
+++ b/plugins/echo.py
@@ -3,7 +3,7 @@ Echo 与交互插件
提供 /echo 和 /赞我 指令。
"""
-from core.command_manager import matcher
+from core.managers.command_manager import matcher
from core.bot import Bot
from models import MessageEvent
diff --git a/plugins/forward_test.py b/plugins/forward_test.py
index 429037f..e52025b 100644
--- a/plugins/forward_test.py
+++ b/plugins/forward_test.py
@@ -1,7 +1,7 @@
"""
合并转发消息测试插件
"""
-from core.command_manager import matcher
+from core.managers.command_manager import matcher
from core.bot import Bot
from models import MessageEvent
from models.message import MessageSegment
diff --git a/plugins/jrcd.py b/plugins/jrcd.py
index f1d4767..fba2399 100644
--- a/plugins/jrcd.py
+++ b/plugins/jrcd.py
@@ -8,8 +8,8 @@ import random
from datetime import datetime
from core.bot import Bot
-from core.command_manager import matcher
-from core.executor import run_in_thread_pool
+from core.managers.command_manager import matcher
+from core.utils.executor import run_in_thread_pool
from models import MessageEvent, MessageSegment
__plugin_meta__ = {
diff --git a/plugins/sync_async_test_plugin.py b/plugins/sync_async_test_plugin.py
index b863959..ffaa1a8 100644
--- a/plugins/sync_async_test_plugin.py
+++ b/plugins/sync_async_test_plugin.py
@@ -5,10 +5,10 @@
"""
import time
from typing import Any
-from core.command_manager import matcher
-from core.executor import run_in_thread_pool
+from core.managers.command_manager import matcher
+from core.utils.executor import run_in_thread_pool
from core.bot import Bot
-from core.logger import logger
+from core.utils.logger import logger
# 插件元数据
__plugin_meta__ = {
diff --git a/plugins/thpic.py b/plugins/thpic.py
index 1a3dfc8..d6a19db 100644
--- a/plugins/thpic.py
+++ b/plugins/thpic.py
@@ -6,7 +6,7 @@ thpic 插件
"""
from core.bot import Bot
-from core.command_manager import matcher
+from core.managers.command_manager import matcher
from models import MessageEvent, MessageSegment
__plugin_meta__ = {
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..a635c5c
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+pythonpath = .
diff --git a/requirements.txt b/requirements.txt
index 81bbec8..0033075 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -20,3 +20,6 @@ websockets==15.0.1
win32_setctime==1.2.0
yarg==0.1.10
docker
+pytest
+pytest-asyncio
+pytest-mock
diff --git a/sandbox.Dockerfile b/sandbox.Dockerfile
index 41f2bc3..2c8d156 100644
--- a/sandbox.Dockerfile
+++ b/sandbox.Dockerfile
@@ -1,19 +1,9 @@
# 使用一个轻量级的 Python 官方镜像作为基础
FROM python:3.11-slim
-# 创建一个低权限的用户来运行代码,增加安全性
-# -S: 创建一个系统用户 (没有 home 目录)
-# -u: 指定用户ID
-# -g: 指定组ID
-RUN groupadd -g 1001 sandbox && useradd -u 1001 -g sandbox -s /bin/sh -r sandbox
-
# 创建一个工作目录,用于存放和执行用户的代码
WORKDIR /sandbox
-# 将目录所有权交给沙箱用户
-RUN chown sandbox:sandbox /sandbox
-# 切换到沙箱用户
-USER sandbox
# 默认的启动命令是 python,这样容器启动时可以直接执行 .py 文件
CMD ["python"]
From c3b3541694f346ca83e9697db3557ef73a824290 Mon Sep 17 00:00:00 2001
From: K2cr2O1 <2221577113@qq.com>
Date: Wed, 7 Jan 2026 23:02:15 +0800
Subject: [PATCH 32/46] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80=E5=8F=98?=
=?UTF-8?q?=E9=87=8F=E5=91=BD=E5=90=8D=E5=B9=B6=E4=BC=98=E5=8C=96=E4=BB=A3?=
=?UTF-8?q?=E7=A0=81=E7=BB=93=E6=9E=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 将函数名从 handle_admin_command 改为 admin_command_handler 以保持命名一致性
- 将变量名 comm_prefixes 改为 command_prefixes 以提高可读性
- 重命名 full_cmd 为 command_parts 和 cmd_name 为 command_name 以明确用途
- 简化 WebSocket 相关代码,移除未使用的导入
- 优化 main.py 中的初始化逻辑和变量命名
---
core/handlers/event_handler.py | 12 +++++------
core/managers/command_manager.py | 12 +++++------
core/ws.py | 14 ++++++-------
main.py | 36 ++++++++++++++++----------------
plugins/admin.py | 6 +++---
5 files changed, 39 insertions(+), 41 deletions(-)
diff --git a/core/handlers/event_handler.py b/core/handlers/event_handler.py
index 3b1540e..c447e76 100644
--- a/core/handlers/event_handler.py
+++ b/core/handlers/event_handler.py
@@ -116,15 +116,15 @@ class MessageHandler(BaseHandler):
if not prefix_found:
return
- full_cmd = raw_text[len(prefix_found):].split()
- if not full_cmd:
+ command_parts = raw_text[len(prefix_found):].split()
+ if not command_parts:
return
- cmd_name = full_cmd[0]
- args = full_cmd[1:]
+ command_name = command_parts[0]
+ args = command_parts[1:]
- if cmd_name in self.commands:
- command_info = self.commands[cmd_name]
+ if command_name in self.commands:
+ command_info = self.commands[command_name]
func = command_info["func"]
permission = command_info.get("permission")
override_check = command_info.get("override_permission_check", False)
diff --git a/core/managers/command_manager.py b/core/managers/command_manager.py
index 9f6fb19..e1bc3d3 100644
--- a/core/managers/command_manager.py
+++ b/core/managers/command_manager.py
@@ -12,7 +12,7 @@ from ..handlers.event_handler import MessageHandler, NoticeHandler, RequestHandl
# 从配置中获取命令前缀
-comm_prefixes = global_config.bot.get("command", ("/",))
+command_prefixes = global_config.bot.get("command", ("/",))
class CommandManager:
@@ -133,11 +133,11 @@ class CommandManager:
# --- 全局单例 ---
# 确保前缀配置是元组格式
-if isinstance(comm_prefixes, list):
- comm_prefixes = tuple(comm_prefixes)
-elif isinstance(comm_prefixes, str):
- comm_prefixes = (comm_prefixes,)
+if isinstance(command_prefixes, list):
+ command_prefixes = tuple(command_prefixes)
+elif isinstance(command_prefixes, str):
+ command_prefixes = (command_prefixes,)
# 实例化全局唯一的命令管理器
-matcher = CommandManager(prefixes=comm_prefixes)
+matcher = CommandManager(prefixes=command_prefixes)
diff --git a/core/ws.py b/core/ws.py
index 2de5244..3cf2cfc 100644
--- a/core/ws.py
+++ b/core/ws.py
@@ -13,9 +13,7 @@ WebSocket 连接。它是整个机器人框架的底层通信基础。
"""
import asyncio
import json
-import traceback
import uuid
-from datetime import datetime
import websockets
@@ -78,7 +76,7 @@ class WS:
logger.info(f"{self.reconnect_interval}秒后尝试重连...")
await asyncio.sleep(self.reconnect_interval)
- async def _listen_loop(self, websocket):
+ async def _listen_loop(self, websocket_connection):
"""
核心监听循环,处理所有接收到的 WebSocket 消息。
@@ -86,9 +84,9 @@ class WS:
判断是 API 响应还是上报的事件,然后分发给相应的处理逻辑。
Args:
- websocket: 当前活动的 WebSocket 连接对象。
+ websocket_connection: 当前活动的 WebSocket 连接对象。
"""
- async for message in websocket:
+ async for message in websocket_connection:
try:
data = json.loads(message)
@@ -110,7 +108,7 @@ class WS:
except Exception as e:
logger.exception(f"解析消息异常: {e}")
- async def on_event(self, raw_data: dict):
+ async def on_event(self, event_data: dict):
"""
事件处理和分发层。
@@ -121,11 +119,11 @@ class WS:
4. 将事件对象传递给 `CommandManager` (`matcher`) 进行后续处理。
Args:
- raw_data (dict): 从 WebSocket 接收到的原始事件字典。
+ event_data (dict): 从 WebSocket 接收到的原始事件字典。
"""
try:
# 使用工厂创建事件对象
- event = EventFactory.create_event(raw_data)
+ event = EventFactory.create_event(event_data)
event.bot = self.bot # 注入 Bot 实例
# 打印日志
diff --git a/main.py b/main.py
index 9a27b74..1c1549a 100644
--- a/main.py
+++ b/main.py
@@ -7,11 +7,6 @@ import asyncio
import os
import sys
import time
-
-# 将项目根目录添加到 sys.path
-ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
-sys.path.insert(0, ROOT_DIR)
-
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
@@ -22,7 +17,14 @@ from core.managers.admin_manager import admin_manager
from core.ws import WS
from core.managers.plugin_manager import load_all_plugins
from core.managers.redis_manager import redis_manager
-from core.utils.executor import run_in_thread_pool
+from core.utils.executor import run_in_thread_pool, initialize_executor
+from core.config_loader import global_config as config
+
+# 将项目根目录添加到 sys.path
+ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, ROOT_DIR)
+
+
class PluginReloadHandler(FileSystemEventHandler):
@@ -42,21 +44,21 @@ class PluginReloadHandler(FileSystemEventHandler):
self.last_reload_time = 0
self.cooldown = 1.0 # 冷却时间,防止短时间内多次重载
- def on_any_event(self, event):
+ def on_any_event(self, file_system_event):
"""
处理所有文件事件
- :param event: watchdog 事件对象
+ :param file_system_event: watchdog 事件对象
"""
- if event.is_directory:
+ if file_system_event.is_directory:
return
# 只监控 py 文件
- if not event.src_path.endswith(".py"):
+ if not file_system_event.src_path.endswith(".py"):
return
# 过滤掉一些临时文件
- if "__pycache__" in event.src_path:
+ if "__pycache__" in file_system_event.src_path:
return
# 简单的防抖动
@@ -66,7 +68,7 @@ class PluginReloadHandler(FileSystemEventHandler):
self.last_reload_time = current_time
- logger.info(f"检测到文件变更: {event.src_path}")
+ logger.info(f"检测到文件变更: {file_system_event.src_path}")
logger.info("正在重载插件...")
try:
@@ -112,13 +114,11 @@ async def main():
logger.warning(f"插件目录不存在 {plugin_path}")
try:
- bot = WS()
+ websocket_client = WS()
# 初始化代码执行器
- from core.config_loader import global_config as config
- from core.utils.executor import initialize_executor
- code_executor = initialize_executor(bot, config)
- bot.bot.code_executor = code_executor # 将执行器实例附加到 bot.bot 对象上
+ code_executor = initialize_executor(websocket_client, config)
+ websocket_client.bot.code_executor = code_executor # 将执行器实例附加到 bot.bot 对象上
# 启动代码执行器的后台 worker
logger.debug("[Main] 检查是否需要启动代码执行 Worker...")
@@ -128,7 +128,7 @@ async def main():
else:
logger.warning("[Main] 未启动代码执行 Worker,因为 Docker 客户端未初始化或连接失败。")
- await bot.connect()
+ await websocket_client.connect()
finally:
if observer.is_alive():
observer.stop()
diff --git a/plugins/admin.py b/plugins/admin.py
index f922db8..9293d91 100644
--- a/plugins/admin.py
+++ b/plugins/admin.py
@@ -20,7 +20,7 @@ __plugin_meta__ = {
@matcher.command("admin", permission=MessageEvent.ADMIN)
-async def handle_admin_command(bot: Bot, event: MessageEvent, args: list[str]):
+async def admin_command_handler(bot: Bot, event: MessageEvent, args: list[str]):
"""
处理 /admin 指令
@@ -40,8 +40,8 @@ async def handle_admin_command(bot: Bot, event: MessageEvent, args: list[str]):
await event.reply("当前没有设置任何管理员。")
return
- admin_list_str = "\n".join(str(admin_id) for admin_id in admins)
- await event.reply(f"当前管理员列表 ({len(admins)}):\n{admin_list_str}")
+ admin_list_text = "\n".join(str(admin_id) for admin_id in admins)
+ await event.reply(f"当前管理员列表 ({len(admins)}):\n{admin_list_text}")
return
if action in ("add", "remove"):
From fa81229f6f35d0cb08790f346e184475e9eea28f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=95=80=E9=93=AC=E9=85=B8=E9=92=BE?=
<148796996+K2cr2O1@users.noreply.github.com>
Date: Fri, 9 Jan 2026 00:20:56 +0800
Subject: [PATCH 33/46] Dev (#28)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 滚木
* 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: 更新依赖项
---
core/__init__.py | 6 -
core/api/__init__.py | 2 +
core/api/account.py | 53 ++++++++
core/api/base.py | 44 +++++--
core/api/group.py | 11 +-
core/api/media.py | 39 ++++++
core/api/message.py | 23 +---
core/bot.py | 33 ++---
core/config_loader.py | 68 +++++-----
core/config_models.py | 60 +++++++++
core/data/admin.json | 2 +-
core/handlers/event_handler.py | 79 ++++++++---
core/managers/__init__.py | 40 ++++++
core/managers/admin_manager.py | 8 +-
core/managers/command_manager.py | 52 ++++++--
core/managers/permission_manager.py | 95 ++++----------
core/managers/plugin_manager.py | 179 +++++++++++--------------
core/managers/redis_manager.py | 21 ++-
core/permission.py | 42 ++++++
core/utils/executor.py | 45 ++++---
core/ws.py | 52 ++++++--
main.py | 37 +++---
models/__init__.py | 108 +++-------------
models/events/factory.py | 19 ++-
models/events/message.py | 16 +--
models/events/meta.py | 2 +-
models/message.py | 75 +++++++++--
plugins/__init__.py | 0
plugins/admin.py | 132 +++++++++++--------
plugins/bili_parser.py | 24 +++-
plugins/broadcast.py | 8 +-
plugins/code_py.py | 145 +--------------------
plugins/echo.py | 2 +-
plugins/forward_test.py | 7 +-
plugins/jrcd.py | 53 ++++----
plugins/thpic.py | 6 +-
requirements.txt | 9 +-
tests/test_basic.py | 37 ++++++
tests/test_command_manager.py | 114 ++++++++++++++++
tests/test_event_factory.py | 141 ++++++++++++++++++++
tests/test_event_handler.py | 194 ++++++++++++++++++++++++++++
tests/test_models.py | 75 +++++++++++
42 files changed, 1461 insertions(+), 697 deletions(-)
create mode 100644 core/api/media.py
create mode 100644 core/config_models.py
create mode 100644 core/permission.py
create mode 100644 plugins/__init__.py
create mode 100644 tests/test_basic.py
create mode 100644 tests/test_command_manager.py
create mode 100644 tests/test_event_factory.py
create mode 100644 tests/test_event_handler.py
create mode 100644 tests/test_models.py
diff --git a/core/__init__.py b/core/__init__.py
index ad853f6..e69de29 100644
--- a/core/__init__.py
+++ b/core/__init__.py
@@ -1,6 +0,0 @@
-from .managers.command_manager import matcher
-from .config_loader import global_config
-from .managers.plugin_manager import PluginDataManager
-from .ws import WS
-
-__all__ = ["WS", "matcher", "global_config", "PluginDataManager"]
diff --git a/core/api/__init__.py b/core/api/__init__.py
index 65bfcc5..f4dbd6b 100644
--- a/core/api/__init__.py
+++ b/core/api/__init__.py
@@ -3,6 +3,7 @@ from .message import MessageAPI
from .group import GroupAPI
from .friend import FriendAPI
from .account import AccountAPI
+from .media import MediaAPI
__all__ = [
"BaseAPI",
@@ -10,4 +11,5 @@ __all__ = [
"GroupAPI",
"FriendAPI",
"AccountAPI",
+ "MediaAPI",
]
diff --git a/core/api/account.py b/core/api/account.py
index 7f75507..3d8b80e 100644
--- a/core/api/account.py
+++ b/core/api/account.py
@@ -162,3 +162,56 @@ class AccountAPI(BaseAPI):
"""
return await self.call_api("clean_cache")
+ async def get_stranger_info(self, user_id: int, no_cache: bool = False) -> Any:
+ """
+ 获取陌生人信息。
+
+ Args:
+ user_id (int): 目标用户的 QQ 号。
+ no_cache (bool, optional): 是否不使用缓存。Defaults to False.
+
+ Returns:
+ Any: 包含陌生人信息的字典或对象。
+ """
+ return await self.call_api("get_stranger_info", {"user_id": user_id, "no_cache": no_cache})
+
+ async def get_friend_list(self, no_cache: bool = False) -> list:
+ """
+ 获取好友列表。
+
+ Args:
+ no_cache (bool, optional): 是否不使用缓存。Defaults to False.
+
+ Returns:
+ list: 好友列表。
+ """
+ cache_key = f"neobot:cache:get_friend_list:{self.self_id}"
+ if not no_cache:
+ cached_data = await redis_manager.get(cache_key)
+ if cached_data:
+ return json.loads(cached_data)
+
+ res = await self.call_api("get_friend_list")
+ await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
+ return res
+
+ async def get_group_list(self, no_cache: bool = False) -> list:
+ """
+ 获取群列表。
+
+ Args:
+ no_cache (bool, optional): 是否不使用缓存。Defaults to False.
+
+ Returns:
+ list: 群列表。
+ """
+ cache_key = f"neobot:cache:get_group_list:{self.self_id}"
+ if not no_cache:
+ cached_data = await redis_manager.get(cache_key)
+ if cached_data:
+ return json.loads(cached_data)
+
+ res = await self.call_api("get_group_list")
+ await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
+ return res
+
diff --git a/core/api/base.py b/core/api/base.py
index c65138b..0a0ff06 100644
--- a/core/api/base.py
+++ b/core/api/base.py
@@ -1,24 +1,50 @@
"""
API 基础模块
-定义了 API 调用的基础接口。
+定义了 API 调用的基础接口和统一处理逻辑。
"""
-from abc import ABC, abstractmethod
-from typing import Any, Dict, Optional
+from typing import Any, Dict, Optional, TYPE_CHECKING
+
+from ..utils.logger import logger
+
+if TYPE_CHECKING:
+ from ..ws import WS
-class BaseAPI(ABC):
+class BaseAPI:
"""
- API 基础抽象类
+ API 基础类,提供了统一的 `call_api` 方法,包含日志记录和异常处理。
"""
+ _ws: "WS"
+ self_id: int
+
+ def __init__(self, ws_client: "WS", self_id: int):
+ self._ws = ws_client
+ self.self_id = self_id
- @abstractmethod
async def call_api(self, action: str, params: Optional[Dict[str, Any]] = None) -> Any:
"""
- 调用 API
+ 调用 OneBot v11 API,并提供统一的日志和异常处理。
:param action: API 动作名称
:param params: API 参数
- :return: API 响应结果
+ :return: API 响应结果的数据部分
+ :raises Exception: 当 API 调用失败或发生网络错误时
"""
- raise NotImplementedError
+ if params is None:
+ params = {}
+
+ try:
+ logger.debug(f"调用API -> action: {action}, params: {params}")
+ response = await self._ws.call_api(action, params)
+ logger.debug(f"API响应 <- {response}")
+
+ if response.get("status") == "failed":
+ logger.warning(f"API调用失败: {response}")
+
+ return response.get("data")
+
+ except Exception as e:
+ logger.error(f"API调用异常: action={action}, params={params}, error={e}")
+ raise
+
diff --git a/core/api/group.py b/core/api/group.py
index 5714baf..46a63a9 100644
--- a/core/api/group.py
+++ b/core/api/group.py
@@ -4,7 +4,7 @@
该模块定义了 `GroupAPI` Mixin 类,提供了所有与群组管理、成员操作
等相关的 OneBot v11 API 封装。
"""
-from typing import List, Dict, Any
+from typing import List, Dict, Any, Optional
import json
from ..managers.redis_manager import redis_manager
from .base import BaseAPI
@@ -46,7 +46,7 @@ class GroupAPI(BaseAPI):
"""
return await self.call_api("set_group_ban", {"group_id": group_id, "user_id": user_id, "duration": duration})
- async def set_group_anonymous_ban(self, group_id: int, anonymous: Dict[str, Any] = None, duration: int = 1800, flag: str = None) -> Dict[str, Any]:
+ async def set_group_anonymous_ban(self, group_id: int, anonymous: Optional[Dict[str, Any]] = None, duration: int = 1800, flag: Optional[str] = None) -> Dict[str, Any]:
"""
禁言群组中的匿名用户。
@@ -61,7 +61,7 @@ class GroupAPI(BaseAPI):
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
- params = {"group_id": group_id, "duration": duration}
+ params: Dict[str, Any] = {"group_id": group_id, "duration": duration}
if anonymous:
params["anonymous"] = anonymous
if flag:
@@ -187,17 +187,18 @@ class GroupAPI(BaseAPI):
await redis_manager.redis.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
return GroupInfo(**res)
- async def get_group_list(self) -> List[GroupInfo]:
+ async def get_group_list(self) -> Any:
"""
获取机器人加入的所有群组的列表。
Returns:
- List[GroupInfo]: 包含所有群组信息的 `GroupInfo` 对象列表。
+ Any: 包含所有群组信息的列表(可能是字典列表或对象列表)。
"""
res = await self.call_api("get_group_list")
# 增加日志记录 API 原始返回
logger.debug(f"OneBot API 'get_group_list' raw response: {res}")
+ return res
# 健壮性处理:处理标准的 OneBot v11 响应格式
if isinstance(res, dict) and res.get("status") == "ok":
diff --git a/core/api/media.py b/core/api/media.py
new file mode 100644
index 0000000..48e2e25
--- /dev/null
+++ b/core/api/media.py
@@ -0,0 +1,39 @@
+"""
+媒体API模块
+
+封装了与图片、语音等媒体文件相关的API。
+"""
+from typing import Dict, Any
+
+from .base import BaseAPI
+
+
+class MediaAPI(BaseAPI):
+ """
+ 媒体相关API
+ """
+
+ async def can_send_image(self) -> Dict[str, Any]:
+ """
+ 检查是否可以发送图片
+
+ :return: OneBot v11标准响应
+ """
+ return await self.call_api(action="can_send_image")
+
+ async def can_send_record(self) -> Dict[str, Any]:
+ """
+ 检查是否可以发送语音
+
+ :return: OneBot v11标准响应
+ """
+ return await self.call_api(action="can_send_record")
+
+ async def get_image(self, file: str) -> Dict[str, Any]:
+ """
+ 获取图片信息
+
+ :param file: 图片文件名或路径
+ :return: OneBot v11标准响应
+ """
+ return await self.call_api(action="get_image", params={"file": file})
diff --git a/core/api/message.py b/core/api/message.py
index c37b5a1..230cfde 100644
--- a/core/api/message.py
+++ b/core/api/message.py
@@ -8,7 +8,8 @@ from typing import Union, List, Dict, Any, TYPE_CHECKING
from .base import BaseAPI
if TYPE_CHECKING:
- from models import MessageSegment, OneBotEvent
+ from models.message import MessageSegment
+ from models.events.base import OneBotEvent
class MessageAPI(BaseAPI):
@@ -156,24 +157,6 @@ class MessageAPI(BaseAPI):
"""
return await self.call_api("send_private_forward_msg", {"user_id": user_id, "messages": messages})
- async def can_send_image(self) -> Dict[str, Any]:
- """
- 检查当前机器人账号是否可以发送图片。
-
- Returns:
- Dict[str, Any]: OneBot API 的响应数据。
- """
- return await self.call_api("can_send_image")
-
- async def can_send_record(self) -> Dict[str, Any]:
- """
- 检查当前机器人账号是否可以发送语音。
-
- Returns:
- Dict[str, Any]: OneBot API 的响应数据。
- """
- return await self.call_api("can_send_record")
-
def _process_message(self, message: Union[str, "MessageSegment", List["MessageSegment"]]) -> Union[str, List[Dict[str, Any]]]:
"""
内部方法:将消息内容处理成 OneBot API 可接受的格式。
@@ -192,7 +175,7 @@ class MessageAPI(BaseAPI):
return message
# 避免循环导入,在运行时导入
- from models import MessageSegment
+ from models.message import MessageSegment
if isinstance(message, MessageSegment):
return [self._segment_to_dict(message)]
diff --git a/core/bot.py b/core/bot.py
index 2af4efc..e1b8d32 100644
--- a/core/bot.py
+++ b/core/bot.py
@@ -13,14 +13,15 @@ Bot 核心抽象模块
from typing import TYPE_CHECKING, Dict, Any, List, Union
from models.events.base import OneBotEvent
from models.message import MessageSegment
+from models.objects import GroupInfo, StrangerInfo
if TYPE_CHECKING:
- from .WS import WS
+ from .ws import WS
-from .api import MessageAPI, GroupAPI, FriendAPI, AccountAPI
+from .api import MessageAPI, GroupAPI, FriendAPI, AccountAPI, MediaAPI
-class Bot(MessageAPI, GroupAPI, FriendAPI, AccountAPI):
+class Bot(MessageAPI, GroupAPI, FriendAPI, AccountAPI, MediaAPI):
"""
机器人核心类,封装了所有与 OneBot API 的交互。
@@ -35,22 +36,22 @@ class Bot(MessageAPI, GroupAPI, FriendAPI, AccountAPI):
Args:
ws_client (WS): WebSocket 客户端实例,负责底层的 API 请求和响应处理。
"""
- self.ws = ws_client
+ super().__init__(ws_client, ws_client.self_id or 0)
+ self.code_executor = None
- async def call_api(self, action: str, params: Dict[str, Any] = None) -> Any:
- """
- 底层 API 调用方法。
+ async def get_group_list(self, no_cache: bool = False) -> List[GroupInfo]:
+ # GroupAPI.get_group_list 不支持 no_cache 参数,这里忽略它
+ result = await super().get_group_list()
+ # 确保结果是 GroupInfo 对象列表
+ return [GroupInfo(**group) if isinstance(group, dict) else group for group in result]
- 所有具体的 API 实现最终都会调用此方法,通过 WebSocket 发送请求。
+ async def get_stranger_info(self, user_id: int, no_cache: bool = False) -> StrangerInfo:
+ result = await super().get_stranger_info(user_id=user_id, no_cache=no_cache)
+ # 确保结果是 StrangerInfo 对象
+ if isinstance(result, dict):
+ return StrangerInfo(**result)
+ return result
- Args:
- action (str): API 的动作名称,例如 "send_group_msg"。
- params (Dict[str, Any], optional): API 请求的参数字典。Defaults to None.
-
- Returns:
- Any: OneBot API 的响应数据。
- """
- return await self.ws.call_api(action, params)
def build_forward_node(self, user_id: int, nickname: str, message: Union[str, "MessageSegment", List["MessageSegment"]]) -> Dict[str, Any]:
"""
diff --git a/core/config_loader.py b/core/config_loader.py
index b00ede3..92fa18c 100644
--- a/core/config_loader.py
+++ b/core/config_loader.py
@@ -4,9 +4,11 @@
负责读取和解析 config.toml 配置文件,提供全局配置对象。
"""
from pathlib import Path
-from typing import Any, Dict
import tomllib
+from pydantic import ValidationError
+from .config_models import ConfigModel, NapCatWSModel, BotModel, RedisModel, DockerModel
+from .utils.logger import logger
class Config:
@@ -21,73 +23,67 @@ class Config:
:param file_path: 配置文件路径,默认为 "config.toml"
"""
self.path = Path(file_path)
- self._data: Dict[str, Any] = {}
+ self._model: ConfigModel
self.load()
def load(self):
"""
- 加载配置文件
+ 加载并验证配置文件
:raises FileNotFoundError: 如果配置文件不存在
+ :raises ValidationError: 如果配置格式不正确
"""
if not self.path.exists():
+ logger.error(f"配置文件 {self.path} 未找到!")
raise FileNotFoundError(f"配置文件 {self.path} 未找到!")
- with open(self.path, "rb") as f:
- self._data = tomllib.load(f)
+ try:
+ logger.info(f"正在从 {self.path} 加载配置...")
+ with open(self.path, "rb") as f:
+ raw_config = tomllib.load(f)
+
+ self._model = ConfigModel(**raw_config)
+ logger.success("配置加载并验证成功!")
+
+ except ValidationError as e:
+ logger.error("配置验证失败,请检查 `config.toml` 文件中的以下错误:")
+ for error in e.errors():
+ field = " -> ".join(map(str, error["loc"]))
+ logger.error(f" - 字段 '{field}': {error['msg']}")
+ raise
+ except Exception as e:
+ logger.exception(f"加载配置文件时发生未知错误: {e}")
+ raise
# 通过属性访问配置
@property
- def napcat_ws(self) -> dict:
+ def napcat_ws(self) -> NapCatWSModel:
"""
获取 NapCat WebSocket 配置
-
- :return: 配置字典
"""
- return self._data.get("napcat_ws", {})
+ return self._model.napcat_ws
@property
- def bot(self) -> dict:
+ def bot(self) -> BotModel:
"""
获取 Bot 基础配置
-
- :return: 配置字典
"""
- return self._data.get("bot", {})
+ return self._model.bot
@property
- def features(self) -> dict:
- """
- 获取功能特性配置
-
- :return: 配置字典
- """
- return self._data.get("features", {})
-
- @property
- def redis(self) -> dict:
+ def redis(self) -> RedisModel:
"""
获取 Redis 配置
-
- :return: 配置字典
"""
- return self._data.get("redis", {})
+ return self._model.redis
@property
- def docker(self) -> dict:
+ def docker(self) -> DockerModel:
"""
获取 Docker 配置
-
- :return: 配置字典
"""
- return self._data.get("docker", {})
+ return self._model.docker
# 实例化全局配置对象
global_config = Config()
-
-if __name__ == "__main__":
- print(global_config.napcat_ws)
- print(global_config.bot.get("command"))
- print(type(global_config.bot.get("command")) is list)
- print(global_config.features)
diff --git a/core/config_models.py b/core/config_models.py
new file mode 100644
index 0000000..5527aae
--- /dev/null
+++ b/core/config_models.py
@@ -0,0 +1,60 @@
+"""
+Pydantic 配置模型模块
+
+该模块使用 Pydantic 定义了与 `config.toml` 文件结构完全对应的配置模型。
+这使得配置的加载、校验和访问都变得类型安全和健壮。
+"""
+from typing import List, Optional
+from pydantic import BaseModel, Field
+
+
+class NapCatWSModel(BaseModel):
+ """
+ 对应 `config.toml` 中的 `[napcat_ws]` 配置块。
+ """
+ uri: str
+ token: str
+ reconnect_interval: int = 5
+
+
+class BotModel(BaseModel):
+ """
+ 对应 `config.toml` 中的 `[bot]` 配置块。
+ """
+ command: List[str] = Field(default_factory=lambda: ["/"])
+ ignore_self_message: bool = True
+ permission_denied_message: str = "权限不足,需要 {permission_name} 权限"
+
+
+class RedisModel(BaseModel):
+ """
+ 对应 `config.toml` 中的 `[redis]` 配置块。
+ """
+ host: str
+ port: int
+ db: int
+ password: str
+
+
+class DockerModel(BaseModel):
+ """
+ 对应 `config.toml` 中的 `[docker]` 配置块。
+ """
+ base_url: Optional[str] = None
+ sandbox_image: str = "python-sandbox:latest"
+ timeout: int = 10
+ concurrency_limit: int = 5
+ tls_verify: bool = False
+ ca_cert_path: Optional[str] = None
+ client_cert_path: Optional[str] = None
+ client_key_path: Optional[str] = None
+
+
+class ConfigModel(BaseModel):
+ """
+ 顶层配置模型,整合了所有子配置块。
+ """
+ napcat_ws: NapCatWSModel
+ bot: BotModel
+ redis: RedisModel
+ docker: DockerModel
diff --git a/core/data/admin.json b/core/data/admin.json
index b3c7949..577c240 100644
--- a/core/data/admin.json
+++ b/core/data/admin.json
@@ -1,3 +1,3 @@
{
- "admins": []
+ "admins": [2221577113]
}
\ No newline at end of file
diff --git a/core/handlers/event_handler.py b/core/handlers/event_handler.py
index f076564..b188eca 100644
--- a/core/handlers/event_handler.py
+++ b/core/handlers/event_handler.py
@@ -6,11 +6,12 @@
"""
import inspect
from abc import ABC, abstractmethod
-from typing import Any, Callable, Dict, List, Optional, Tuple
+from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
-from ..bot import Bot
+if TYPE_CHECKING:
+ from ..bot import Bot
from ..config_loader import global_config
-from ..managers.permission_manager import Permission, permission_manager
+from ..permission import Permission
from ..utils.executor import run_in_thread_pool
@@ -22,7 +23,7 @@ class BaseHandler(ABC):
self.handlers: List[Dict[str, Any]] = []
@abstractmethod
- async def handle(self, bot: Bot, event: Any):
+ async def handle(self, bot: "Bot", event: Any):
"""
处理事件
"""
@@ -31,7 +32,7 @@ class BaseHandler(ABC):
async def _run_handler(
self,
func: Callable,
- bot: Bot,
+ bot: "Bot",
event: Any,
args: Optional[List[str]] = None,
permission_granted: Optional[bool] = None
@@ -41,7 +42,7 @@ class BaseHandler(ABC):
"""
sig = inspect.signature(func)
params = sig.parameters
- kwargs = {}
+ kwargs: Dict[str, Any] = {}
if "bot" in params:
kwargs["bot"] = bot
@@ -68,21 +69,41 @@ class MessageHandler(BaseHandler):
super().__init__()
self.prefixes = prefixes
self.commands: Dict[str, Dict] = {}
- self.message_handlers: List[Callable] = []
+ self.message_handlers: List[Dict[str, Any]] = []
+
+ def clear(self):
+ """
+ 清空所有已注册的消息和命令处理器
+ """
+ self.commands.clear()
+ self.message_handlers.clear()
+
+ def unregister_by_plugin_name(self, plugin_name: str):
+ """
+ 根据插件名卸载相关的消息和命令处理器
+ """
+ # 卸载命令
+ commands_to_remove = [name for name, info in self.commands.items() if info["plugin_name"] == plugin_name]
+ for name in commands_to_remove:
+ del self.commands[name]
+
+ # 卸载通用消息处理器
+ self.message_handlers = [h for h in self.message_handlers if h["plugin_name"] != plugin_name]
def on_message(self) -> Callable:
"""
注册通用消息处理器
"""
def decorator(func: Callable) -> Callable:
- self.message_handlers.append(func)
+ module = inspect.getmodule(func)
+ plugin_name = module.__name__ if module else "Unknown"
+ self.message_handlers.append({"func": func, "plugin_name": plugin_name})
return func
return decorator
def command(
self,
*names: str,
- *names: str,
permission: Optional[Permission] = None,
override_permission_check: bool = False
) -> Callable:
@@ -90,21 +111,25 @@ class MessageHandler(BaseHandler):
注册命令处理器
"""
def decorator(func: Callable) -> Callable:
+ module = inspect.getmodule(func)
+ plugin_name = module.__name__ if module else "Unknown"
for name in names:
self.commands[name] = {
"func": func,
"permission": permission,
"override_permission_check": override_permission_check,
+ "plugin_name": plugin_name,
}
return func
return decorator
- async def handle(self, bot: Bot, event: Any):
+ async def handle(self, bot: "Bot", event: Any):
"""
- 处理消息事件,包括通用消息和命令
+ 处理消息事件,分发给命令处理器或通用消息处理器
"""
- for handler in self.message_handlers:
- consumed = await self._run_handler(handler, bot, event)
+ from ..managers import permission_manager
+ for handler_info in self.message_handlers:
+ consumed = await self._run_handler(handler_info["func"], bot, event)
if consumed:
return
@@ -136,7 +161,7 @@ class MessageHandler(BaseHandler):
if not permission_granted and not override_check:
permission_name = permission.name if isinstance(permission, Permission) else permission
- message_template = global_config.bot.get("permission_denied_message", "权限不足,需要 {permission_name} 权限")
+ message_template = global_config.bot.permission_denied_message
await bot.send(event, message_template.format(permission_name=permission_name))
return
@@ -153,12 +178,23 @@ class NoticeHandler(BaseHandler):
"""
通知事件处理器
"""
+ def clear(self):
+ self.handlers.clear()
+
+ def unregister_by_plugin_name(self, plugin_name: str):
+ """
+ 根据插件名卸载相关的通知处理器
+ """
+ self.handlers = [h for h in self.handlers if h["plugin_name"] != plugin_name]
+
def register(self, notice_type: Optional[str] = None) -> Callable:
"""
注册通知处理器
"""
def decorator(func: Callable) -> Callable:
- self.handlers.append({"type": notice_type, "func": func})
+ module = inspect.getmodule(func)
+ plugin_name = module.__name__ if module else "Unknown"
+ self.handlers.append({"type": notice_type, "func": func, "plugin_name": plugin_name})
return func
return decorator
@@ -175,12 +211,23 @@ class RequestHandler(BaseHandler):
"""
请求事件处理器
"""
+ def clear(self):
+ self.handlers.clear()
+
+ def unregister_by_plugin_name(self, plugin_name: str):
+ """
+ 根据插件名卸载相关的请求处理器
+ """
+ self.handlers = [h for h in self.handlers if h["plugin_name"] != plugin_name]
+
def register(self, request_type: Optional[str] = None) -> Callable:
"""
注册请求处理器
"""
def decorator(func: Callable) -> Callable:
- self.handlers.append({"type": request_type, "func": func})
+ module = inspect.getmodule(func)
+ plugin_name = module.__name__ if module else "Unknown"
+ self.handlers.append({"type": request_type, "func": func, "plugin_name": plugin_name})
return func
return decorator
diff --git a/core/managers/__init__.py b/core/managers/__init__.py
index e69de29..10780cb 100644
--- a/core/managers/__init__.py
+++ b/core/managers/__init__.py
@@ -0,0 +1,40 @@
+"""
+管理器包
+
+这个包集中了机器人核心的单例管理器。
+通过从这里导入,可以确保在整个应用中访问到的都是同一个实例。
+"""
+from ..config_loader import global_config
+from .admin_manager import AdminManager
+from .command_manager import CommandManager
+from .permission_manager import PermissionManager
+from .plugin_manager import PluginManager
+from .redis_manager import RedisManager
+
+# --- 实例化所有单例管理器 ---
+
+# 管理员管理器
+admin_manager = AdminManager()
+
+# 权限管理器
+permission_manager = PermissionManager()
+
+# 命令与事件管理器 (别名 matcher)
+command_manager = CommandManager(prefixes=tuple(global_config.bot.command))
+matcher = command_manager
+
+# 插件管理器
+plugin_manager = PluginManager(command_manager)
+plugin_manager.load_all_plugins()
+
+# Redis 管理器
+redis_manager = RedisManager()
+
+__all__ = [
+ "admin_manager",
+ "permission_manager",
+ "command_manager",
+ "matcher",
+ "plugin_manager",
+ "redis_manager",
+]
diff --git a/core/managers/admin_manager.py b/core/managers/admin_manager.py
index 7e5f0d1..83b222f 100644
--- a/core/managers/admin_manager.py
+++ b/core/managers/admin_manager.py
@@ -26,8 +26,7 @@ class AdminManager(Singleton):
"""
初始化 AdminManager
"""
- super().__init__()
- if not self._initialized:
+ if hasattr(self, '_initialized') and self._initialized:
return
# 管理员数据文件路径
@@ -39,7 +38,12 @@ class AdminManager(Singleton):
)
self._admins: Set[int] = set()
+
+ # 确保数据目录存在
+ os.makedirs(os.path.dirname(self.data_file), exist_ok=True)
+
logger.info("管理员管理器初始化完成")
+ super().__init__()
async def initialize(self):
"""
diff --git a/core/managers/command_manager.py b/core/managers/command_manager.py
index e1bc3d3..522d86e 100644
--- a/core/managers/command_manager.py
+++ b/core/managers/command_manager.py
@@ -12,7 +12,16 @@ from ..handlers.event_handler import MessageHandler, NoticeHandler, RequestHandl
# 从配置中获取命令前缀
-command_prefixes = global_config.bot.get("command", ("/",))
+_config_prefixes = global_config.bot.command
+
+# 确保前缀配置是元组格式
+_final_prefixes: Tuple[str, ...]
+if isinstance(_config_prefixes, list):
+ _final_prefixes = tuple(_config_prefixes)
+elif isinstance(_config_prefixes, str):
+ _final_prefixes = (_config_prefixes,)
+else:
+ _final_prefixes = tuple(_config_prefixes)
class CommandManager:
@@ -59,6 +68,35 @@ class CommandManager:
"usage": "/help",
}
+ def clear_all_handlers(self):
+ """
+ 清空所有已注册的事件处理器。
+ 注意:这也会移除内置的 /help 命令,因此需要重新注册。
+ """
+ self.message_handler.clear()
+ self.notice_handler.clear()
+ self.request_handler.clear()
+ self.plugins.clear()
+
+ # 清空后,需要重新注册内置命令
+ self._register_internal_commands()
+
+ def unload_plugin(self, plugin_name: str):
+ """
+ 卸载指定插件的所有处理器和命令。
+
+ Args:
+ plugin_name (str): 插件的模块名 (例如 'plugins.bili_parser')
+ """
+ self.message_handler.unregister_by_plugin_name(plugin_name)
+ self.notice_handler.unregister_by_plugin_name(plugin_name)
+ self.request_handler.unregister_by_plugin_name(plugin_name)
+
+ # 移除插件元信息
+ plugins_to_remove = [name for name in self.plugins if name.startswith(plugin_name)]
+ for name in plugins_to_remove:
+ del self.plugins[name]
+
# --- 装饰器代理 ---
def on_message(self) -> Callable:
@@ -102,7 +140,7 @@ class CommandManager:
根据事件的 `post_type` 将其分发给对应的处理器。
"""
- if event.post_type == 'message' and global_config.bot.get('ignore_self_message', False):
+ if event.post_type == 'message' and global_config.bot.ignore_self_message:
if hasattr(event, 'user_id') and hasattr(event, 'self_id') and event.user_id == event.self_id:
return
@@ -130,14 +168,6 @@ class CommandManager:
await bot.send(event, help_text.strip())
-# --- 全局单例 ---
-
-# 确保前缀配置是元组格式
-if isinstance(command_prefixes, list):
- command_prefixes = tuple(command_prefixes)
-elif isinstance(command_prefixes, str):
- command_prefixes = (command_prefixes,)
-
# 实例化全局唯一的命令管理器
-matcher = CommandManager(prefixes=command_prefixes)
+matcher = CommandManager(prefixes=_final_prefixes)
diff --git a/core/managers/permission_manager.py b/core/managers/permission_manager.py
index c4d2825..de808c1 100644
--- a/core/managers/permission_manager.py
+++ b/core/managers/permission_manager.py
@@ -13,64 +13,17 @@
"""
import json
import os
-from functools import total_ordering
-from typing import Dict
+from typing import Dict, Optional
from ..utils.logger import logger
from ..utils.singleton import Singleton
from .admin_manager import admin_manager
+from ..permission import Permission
-@total_ordering
-class Permission:
- """
- 权限封装类
-
- 封装了权限的名称和等级,并提供了比较方法。
- 使用 @total_ordering 装饰器可以自动生成所有的比较运算符。
- """
- def __init__(self, name: str, level: int):
- """
- 初始化权限对象
-
- Args:
- name (str): 权限名称 (e.g., "admin", "op")
- level (int): 权限等级,数字越大权限越高
- """
- self.name = name
- self.level = level
-
- def __eq__(self, other):
- """
- 判断权限是否相等
- """
- if not isinstance(other, Permission):
- return NotImplemented
- return self.level == other.level
-
- def __lt__(self, other):
- """
- 判断权限是否小于另一个权限
- """
- if not isinstance(other, Permission):
- return NotImplemented
- return self.level < other.level
-
- def __str__(self) -> str:
- """
- 返回权限的字符串表示(即权限名称)
- """
- return self.name
-
-
-# 定义全局权限常量
-ADMIN = Permission("admin", 3)
-OP = Permission("op", 2)
-USER = Permission("user", 1)
-
# 用于从字符串名称查找权限对象的字典
_PERMISSIONS: Dict[str, Permission] = {
- p.name: p for p in [ADMIN, OP, USER]
+ p.value: p for p in Permission
}
@@ -88,8 +41,7 @@ class PermissionManager(Singleton):
如果已经初始化过,则直接返回。
"""
- super().__init__()
- if not self._initialized:
+ if hasattr(self, '_initialized') and self._initialized:
return
# 权限数据文件路径
@@ -111,6 +63,7 @@ class PermissionManager(Singleton):
self.load()
logger.info("权限管理器初始化完成")
+ super().__init__()
def load(self) -> None:
"""
@@ -164,12 +117,12 @@ class PermissionManager(Singleton):
"""
# 首先,通过 AdminManager 检查是否为管理员
if await admin_manager.is_admin(user_id):
- return ADMIN
+ return Permission.ADMIN
# 如果不是管理员,则从 permissions.json 中查找
user_id_str = str(user_id)
- level_name = self._data["users"].get(user_id_str, USER.name)
- return _PERMISSIONS.get(level_name, USER)
+ level_name = self._data["users"].get(user_id_str, Permission.USER.value)
+ return _PERMISSIONS.get(level_name, Permission.USER)
def set_user_permission(self, user_id: int, permission: Permission) -> None:
"""
@@ -182,13 +135,13 @@ class PermissionManager(Singleton):
Raises:
ValueError: 如果权限对象无效
"""
- if not isinstance(permission, Permission) or permission.name not in _PERMISSIONS:
+ if not isinstance(permission, Permission):
raise ValueError(f"无效的权限对象: {permission}")
user_id_str = str(user_id)
- self._data["users"][user_id_str] = permission.name
+ self._data["users"][user_id_str] = permission.value
self.save()
- logger.info(f"设置用户 {user_id} 的权限级别为 {permission.name}")
+ logger.info(f"设置用户 {user_id} 的权限级别为 {permission.value}")
def remove_user(self, user_id: int) -> None:
"""
@@ -214,17 +167,17 @@ class PermissionManager(Singleton):
Returns:
bool: 如果用户权限 >= 所需权限,返回 True,否则返回 False
"""
- # 如果传入的是字符串,先转换为 Permission 对象
- if isinstance(required_permission, str):
- required_permission = _PERMISSIONS.get(required_permission.lower())
- if not required_permission:
- # 如果是无效的权限字符串,默认拒绝
- logger.warning(f"检测到无效的权限检查字符串: {required_permission}")
- return False
-
user_permission = await self.get_user_permission(user_id)
return user_permission >= required_permission
+ def get_all_user_permissions(self) -> Dict[str, str]:
+ """
+ 获取所有已配置的用户权限
+
+ :return: 一个包含所有用户权限的字典
+ """
+ return self._data["users"].copy()
+
def get_all_users(self) -> Dict[str, str]:
"""
获取所有设置了权限的用户及其级别名称
@@ -243,22 +196,22 @@ class PermissionManager(Singleton):
logger.info("已清空所有权限设置")
-# 全局权限管理器实例
-permission_manager = PermissionManager()
-
def require_admin(func):
"""
一个装饰器,用于限制命令只能由管理员执行。
"""
from functools import wraps
from models.events.message import MessageEvent
+ from core.managers import permission_manager
@wraps(func)
async def wrapper(event: MessageEvent, *args, **kwargs):
user_id = event.user_id
- if await permission_manager.check_permission(user_id, ADMIN):
+ if await permission_manager.check_permission(user_id, Permission.ADMIN):
return await func(event, *args, **kwargs)
else:
- await event.reply("抱歉,您没有权限执行此命令。")
+ # 假设 event 对象有 reply 方法
+ if hasattr(event, "reply"):
+ await event.reply("抱歉,您没有权限执行此命令。")
return None
return wrapper
diff --git a/core/managers/plugin_manager.py b/core/managers/plugin_manager.py
index 0141b8c..a287527 100644
--- a/core/managers/plugin_manager.py
+++ b/core/managers/plugin_manager.py
@@ -1,126 +1,97 @@
"""
插件管理器模块
-负责扫描、加载和管理 `base_plugins` 目录下的所有插件。
+负责扫描、加载和管理 `plugins` 目录下的所有插件。
"""
-
import importlib
-import json
import os
import pkgutil
import sys
+from typing import Set
-from .command_manager import matcher
from ..utils.exceptions import SyncHandlerError
from ..utils.logger import logger
-from ..utils.executor import run_in_thread_pool
-def load_all_plugins():
+class PluginManager:
"""
- 扫描并加载 `plugins` 目录下的所有插件。
-
- 该函数会遍历 `plugins` 目录下的所有模块:
- 1. 如果模块已加载,则执行 reload 操作(用于热重载)。
- 2. 如果模块未加载,则执行 import 操作。
-
- 加载过程中会提取插件元数据 `__plugin_meta__` 并注册到 CommandManager。
+ 插件管理器类
"""
- plugin_dir = os.path.join(
- os.path.dirname(os.path.abspath(__file__)), "..", "plugins"
- )
- package_name = "plugins"
+ def __init__(self, command_manager):
+ """
+ 初始化插件管理器
- logger.info(f"正在从 {package_name} 加载插件...")
+ :param command_manager: CommandManager的实例
+ """
+ self.command_manager = command_manager
+ self.loaded_plugins: Set[str] = set()
- for loader, module_name, is_pkg in pkgutil.iter_modules([plugin_dir]):
- full_module_name = f"{package_name}.{module_name}"
+ def load_all_plugins(self):
+ """
+ 扫描并加载 `plugins` 目录下的所有插件。
+ """
+ # 使用 pathlib 获取更可靠的路径
+ # 当前文件: core/managers/plugin_manager.py
+ # 目标: plugins/
+ current_dir = os.path.dirname(os.path.abspath(__file__))
+ # 回退两级到项目根目录 (core/managers -> core -> root)
+ root_dir = os.path.dirname(os.path.dirname(current_dir))
+ plugin_dir = os.path.join(root_dir, "plugins")
+
+ package_name = "plugins"
+
+ if not os.path.exists(plugin_dir):
+ logger.error(f"插件目录不存在: {plugin_dir}")
+ return
+
+ logger.info(f"正在从 {package_name} 加载插件 (路径: {plugin_dir})...")
+
+ for _, module_name, is_pkg in pkgutil.iter_modules([plugin_dir]):
+ full_module_name = f"{package_name}.{module_name}"
+
+ try:
+ if full_module_name in self.loaded_plugins:
+ self.command_manager.unload_plugin(full_module_name)
+ module = importlib.reload(sys.modules[full_module_name])
+ action = "重载"
+ else:
+ module = importlib.import_module(full_module_name)
+ action = "加载"
+
+ if hasattr(module, "__plugin_meta__"):
+ meta = getattr(module, "__plugin_meta__")
+ self.command_manager.plugins[full_module_name] = meta
+
+ self.loaded_plugins.add(full_module_name)
+
+ type_str = "包" if is_pkg else "文件"
+ logger.success(f" [{type_str}] 成功{action}: {module_name}")
+ except SyncHandlerError as e:
+ logger.error(f" 插件 {module_name} 加载失败: {e} (跳过此插件)")
+ except Exception as e:
+ logger.exception(
+ f" {action if 'action' in locals() else '加载'}插件 {module_name} 失败: {e}"
+ )
+
+ def reload_plugin(self, full_module_name: str):
+ """
+ 精确重载单个插件。
+ """
+ if full_module_name not in self.loaded_plugins:
+ logger.warning(f"尝试重载一个未被加载的插件: {full_module_name},将按首次加载处理。")
+
+ if full_module_name not in sys.modules:
+ logger.error(f"重载失败: 模块 {full_module_name} 未在 sys.modules 中找到。")
+ return
try:
- if full_module_name in sys.modules:
- module = importlib.reload(sys.modules[full_module_name])
- action = "重载"
- else:
- module = importlib.import_module(full_module_name)
- action = "加载"
-
- # 提取插件元数据
+ self.command_manager.unload_plugin(full_module_name)
+ module = importlib.reload(sys.modules[full_module_name])
+
if hasattr(module, "__plugin_meta__"):
meta = getattr(module, "__plugin_meta__")
- matcher.plugins[full_module_name] = meta
-
- type_str = "包" if is_pkg else "文件"
- logger.success(f" [{type_str}] 成功{action}: {module_name}")
- except SyncHandlerError as e:
- logger.error(f" 插件 {module_name} 加载失败: {e} (跳过此插件)")
+ self.command_manager.plugins[full_module_name] = meta
+
+ logger.success(f"插件 {full_module_name} 已成功重载。")
except Exception as e:
- print(
- f" {action if 'action' in locals() else '加载'}插件 {module_name} 失败: {e}"
- )
-
-
-class PluginDataManager:
- """
- 用于管理插件产生的数据文件的类
- """
-
- def __init__(self, plugin_name: str):
- """
- 初始化插件数据管理器
-
- :param plugin_name: 插件名称
- """
- self.plugin_name = plugin_name
- self.data_file = os.path.join(
- os.path.dirname(os.path.abspath(__file__)),
- "..",
- "plugins",
- "data",
- self.plugin_name + ".json",
- )
- self.data = {}
-
- async def load(self):
- """读取配置文件"""
- if not os.path.exists(self.data_file):
- await self.set(self.plugin_name, [])
- try:
- with open(self.data_file, "r", encoding="utf-8") as f:
- self.data = await run_in_thread_pool(json.load, f)
- except json.JSONDecodeError:
- self.data = {}
-
- async def save(self):
- """保存配置到文件"""
- with open(self.data_file, "w", encoding="utf-8") as f:
- await run_in_thread_pool(json.dump, self.data, f, indent=2, ensure_ascii=False)
-
- def get(self, key, default=None):
- """获取配置项"""
- return self.data.get(key, default)
-
- async def set(self, key, value):
- """设置配置项"""
- self.data[key] = value
- await self.save()
-
- async def add(self, key, value):
- """添加配置项"""
- if key not in self.data:
- self.data[key] = []
- self.data[key].append(value)
- await self.save()
-
- async def remove(self, key):
- """删除配置项"""
- if key in self.data:
- del self.data[key]
- await self.save()
-
- async def clear(self):
- """清空所有配置"""
- self.data.clear()
- await self.save()
-
- def get_all(self):
- return self.data.copy()
+ logger.exception(f"重载插件 {full_module_name} 时发生错误: {e}")
diff --git a/core/managers/redis_manager.py b/core/managers/redis_manager.py
index cdc16cc..a6bcff3 100644
--- a/core/managers/redis_manager.py
+++ b/core/managers/redis_manager.py
@@ -20,10 +20,11 @@ class RedisManager:
"""
if self._redis is None:
try:
- host = config.redis['host']
- port = config.redis['port']
- db = config.redis['db']
- password = config.redis.get('password')
+ redis_config = config.redis
+ host = redis_config.host
+ port = redis_config.port
+ db = redis_config.db
+ password = redis_config.password
logger.info(f"正在尝试连接 Redis: {host}:{port}, DB: {db}")
@@ -54,5 +55,17 @@ class RedisManager:
raise ConnectionError("Redis 未初始化或连接失败,请先调用 initialize()")
return self._redis
+ async def get(self, name):
+ """
+ 获取指定键的值
+ """
+ return await self.redis.get(name)
+
+ async def set(self, name, value, ex=None):
+ """
+ 设置指定键的值
+ """
+ return await self.redis.set(name, value, ex=ex)
+
# 全局 Redis 管理器实例
redis_manager = RedisManager()
diff --git a/core/permission.py b/core/permission.py
new file mode 100644
index 0000000..c66bd3b
--- /dev/null
+++ b/core/permission.py
@@ -0,0 +1,42 @@
+from enum import Enum
+from functools import total_ordering
+
+
+@total_ordering
+class Permission(Enum):
+ """
+ 定义用户权限等级的枚举类。
+
+ 使用 @total_ordering 装饰器,只需定义 __lt__ 和 __eq__,
+ 即可自动实现所有比较运算符。
+ """
+ USER = "user"
+ OP = "op"
+ ADMIN = "admin"
+
+ @property
+ def _level_map(self):
+ """
+ 内部属性,用于映射枚举成员到整数等级。
+ """
+ return {
+ Permission.USER: 1,
+ Permission.OP: 2,
+ Permission.ADMIN: 3
+ }
+
+ def __lt__(self, other):
+ """
+ 比较当前权限是否小于另一个权限。
+ """
+ if not isinstance(other, Permission):
+ return NotImplemented
+ return self._level_map[self] < self._level_map[other]
+
+ def __ge__(self, other):
+ """
+ 比较当前权限是否大于等于另一个权限。
+ """
+ if not isinstance(other, Permission):
+ return NotImplemented
+ return self._level_map[self] >= self._level_map[other]
diff --git a/core/utils/executor.py b/core/utils/executor.py
index 3882a32..79f2103 100644
--- a/core/utils/executor.py
+++ b/core/utils/executor.py
@@ -2,7 +2,8 @@
import asyncio
import docker
from docker.tls import TLSConfig
-from typing import Dict, Any, Callable
+from docker.types import LogConfig
+from typing import Any, Callable
from core.utils.logger import logger
@@ -10,21 +11,20 @@ class CodeExecutor:
"""
代码执行引擎,负责管理一个异步任务队列和并发的 Docker 容器执行。
"""
- def __init__(self, bot_instance, config: Dict[str, Any]):
+ def __init__(self, config: Any):
"""
初始化代码执行引擎。
- :param bot_instance: Bot 实例,用于后续的消息回复。
- :param config: 从 config.toml 加载的配置字典。
+ :param config: 从 config_loader.py 加载的全局配置对象。
"""
- self.bot = bot_instance
- self.task_queue = asyncio.Queue()
+ self.bot: Any = None # Bot 实例将在 WS 连接成功后动态注入
+ self.task_queue: asyncio.Queue = asyncio.Queue()
# 从传入的配置中读取 Docker 相关设置
docker_config = config.docker
- self.docker_base_url = docker_config.get("base_url")
- self.sandbox_image = docker_config.get("sandbox_image", "python-sandbox:latest")
- self.timeout = docker_config.get("timeout", 10)
- concurrency = docker_config.get("concurrency_limit", 5)
+ self.docker_base_url = docker_config.base_url
+ self.sandbox_image = docker_config.sandbox_image
+ self.timeout = docker_config.timeout
+ concurrency = docker_config.concurrency_limit
self.concurrency_limit = asyncio.Semaphore(concurrency)
self.docker_client = None
@@ -34,10 +34,10 @@ class CodeExecutor:
if self.docker_base_url:
# 如果配置了远程 Docker 地址,则使用 TLS 选项进行连接
tls_config = None
- if docker_config.get("tls_verify", False):
+ if docker_config.tls_verify:
tls_config = TLSConfig(
- ca_cert=docker_config.get("ca_cert_path"),
- client_cert=(docker_config.get("client_cert_path"), docker_config.get("client_key_path")),
+ ca_cert=docker_config.ca_cert_path,
+ client_cert=(docker_config.client_cert_path, docker_config.client_key_path),
verify=True
)
self.docker_client = docker.DockerClient(base_url=self.docker_base_url, tls=tls_config)
@@ -60,7 +60,15 @@ class CodeExecutor:
将代码执行任务添加到队列中。
:param code: 待执行的 Python 代码字符串。
:param callback: 执行完毕后用于回复结果的回调函数。
+ :raises RuntimeError: 如果 Docker 客户端未初始化。
"""
+ if not self.docker_client:
+ logger.warning("[CodeExecutor] 尝试添加任务,但 Docker 客户端未初始化。任务被拒绝。")
+ # 这里可以选择抛出异常,或者直接调用回调返回错误信息
+ # 为了用户体验,我们构造一个错误结果并直接调用回调(如果可能)
+ # 但由于 callback 返回 Future,这里简单起见,我们记录日志并抛出异常
+ raise RuntimeError("Docker环境未就绪,无法执行代码。")
+
task = {"code": code, "callback": callback}
await self.task_queue.put(task)
logger.info(f"[CodeExecutor] 新的代码执行任务已入队 (队列当前长度: {self.task_queue.qsize()})。")
@@ -125,6 +133,9 @@ class CodeExecutor:
同步函数:在 Docker 容器中运行代码。
此函数通过手动管理容器生命周期来提高稳定性。
"""
+ if self.docker_client is None:
+ raise docker.errors.DockerException("Docker client is not initialized.")
+
container = None
try:
# 1. 创建容器
@@ -134,7 +145,7 @@ class CodeExecutor:
mem_limit='128m',
cpu_shares=512,
network_disabled=True,
- log_config={'type': 'json-file', 'config': {'max-size': '1m'}},
+ log_config=LogConfig(type='json-file', config={'max-size': '1m'}),
)
# 2. 启动容器
container.start()
@@ -150,7 +161,7 @@ class CodeExecutor:
# 5. 检查退出码,如果不为 0,则手动抛出 ContainerError
if result.get('StatusCode', 0) != 0:
raise docker.errors.ContainerError(
- container, result['StatusCode'], f"python -c '{code}'", self.sandbox_image, stderr
+ container, result['StatusCode'], f"python -c '{code}'", self.sandbox_image, stderr.decode('utf-8')
)
return stdout
@@ -166,11 +177,11 @@ class CodeExecutor:
except Exception as e:
logger.error(f"[CodeExecutor] 强制移除容器 {container.id} 时失败: {e}")
-def initialize_executor(bot_instance, config: Dict[str, Any]):
+def initialize_executor(config: Any):
"""
初始化并返回一个 CodeExecutor 实例。
"""
- return CodeExecutor(bot_instance, config)
+ return CodeExecutor(config)
async def run_in_thread_pool(sync_func, *args, **kwargs):
"""
diff --git a/core/ws.py b/core/ws.py
index 3cf2cfc..8216cce 100644
--- a/core/ws.py
+++ b/core/ws.py
@@ -13,11 +13,12 @@ WebSocket 连接。它是整个机器人框架的底层通信基础。
"""
import asyncio
import json
+from typing import Any, Dict, Optional
import uuid
import websockets
-from models import EventFactory
+from models.events.factory import EventFactory
from .bot import Bot
from .config_loader import global_config
@@ -30,7 +31,7 @@ class WS:
WebSocket 客户端,负责与 OneBot v11 实现进行底层通信。
"""
- def __init__(self):
+ def __init__(self, code_executor=None):
"""
初始化 WebSocket 客户端。
@@ -38,13 +39,15 @@ class WS:
"""
# 读取参数
cfg = global_config.napcat_ws
- self.url = cfg.get("uri")
- self.token = cfg.get("token")
- self.reconnect_interval = cfg.get("reconnect_interval", 5)
+ self.url = cfg.uri
+ self.token = cfg.token
+ self.reconnect_interval = cfg.reconnect_interval
self.ws = None
self._pending_requests = {}
- self.bot = Bot(self)
+ self.bot: Bot | None = None
+ self.self_id: int | None = None
+ self.code_executor = code_executor
async def connect(self):
"""
@@ -124,18 +127,43 @@ class WS:
try:
# 使用工厂创建事件对象
event = EventFactory.create_event(event_data)
+
+ # 尝试初始化 Bot 实例 (如果尚未初始化且事件包含 self_id)
+ # 只要事件中包含 self_id,我们就可以初始化 Bot,不必非要等待 meta_event
+ if self.bot is None and hasattr(event, 'self_id'):
+ self.self_id = event.self_id
+ self.bot = Bot(self)
+ logger.success(f"Bot 实例初始化完成: self_id={self.self_id}")
+
+ # 将代码执行器注入到 Bot 和执行器自身
+ if self.code_executor:
+ self.bot.code_executor = self.code_executor
+ self.code_executor.bot = self.bot
+ logger.info("代码执行器已成功注入 Bot 实例。")
+
+ # 如果 bot 尚未初始化,则不处理后续事件
+ if self.bot is None:
+ logger.warning("Bot 尚未初始化,跳过事件处理。")
+ return
+
event.bot = self.bot # 注入 Bot 实例
# 打印日志
if event.post_type == "message":
- sender_name = event.sender.nickname if event.sender else "Unknown"
- logger.info(f"[消息] {event.message_type} | {event.user_id}({sender_name}): {event.raw_message}")
+ sender_name = event.sender.nickname if hasattr(event, "sender") and event.sender else "Unknown"
+ message_type = getattr(event, "message_type", "Unknown")
+ user_id = getattr(event, "user_id", "Unknown")
+ raw_message = getattr(event, "raw_message", "")
+ logger.info(f"[消息] {message_type} | {user_id}({sender_name}): {raw_message}")
elif event.post_type == "notice":
- logger.info(f"[通知] {event.notice_type}")
+ notice_type = getattr(event, "notice_type", "Unknown")
+ logger.info(f"[通知] {notice_type}")
elif event.post_type == "request":
- logger.info(f"[请求] {event.request_type}")
+ request_type = getattr(event, "request_type", "Unknown")
+ logger.info(f"[请求] {request_type}")
elif event.post_type == "meta_event":
- logger.debug(f"[元事件] {event.meta_event_type}")
+ meta_event_type = getattr(event, "meta_event_type", "Unknown")
+ logger.debug(f"[元事件] {meta_event_type}")
# 分发事件
@@ -144,7 +172,7 @@ class WS:
except Exception as e:
logger.exception(f"事件处理异常: {e}")
- async def call_api(self, action: str, params: dict = None):
+ async def call_api(self, action: str, params: Optional[Dict[Any, Any]] = None) -> Dict[Any, Any]:
"""
向 OneBot v11 实现端发送一个 API 请求。
diff --git a/main.py b/main.py
index 1c1549a..936d63f 100644
--- a/main.py
+++ b/main.py
@@ -15,7 +15,7 @@ from core.utils.logger import logger
from core.managers.admin_manager import admin_manager
from core.ws import WS
-from core.managers.plugin_manager import load_all_plugins
+from core.managers import plugin_manager
from core.managers.redis_manager import redis_manager
from core.utils.executor import run_in_thread_pool, initialize_executor
from core.config_loader import global_config as config
@@ -25,6 +25,8 @@ ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT_DIR)
+# 获取插件目录的绝对路径
+PLUGIN_DIR = os.path.join(ROOT_DIR, "plugins")
class PluginReloadHandler(FileSystemEventHandler):
@@ -32,7 +34,7 @@ class PluginReloadHandler(FileSystemEventHandler):
文件变更处理器,用于热重载插件
继承自 watchdog.events.FileSystemEventHandler,
- 监听 base_plugins 目录下的文件变化,并触发插件重载。
+ 监听 plugins 目录下的文件变化,并触发插件重载。
"""
def __init__(self, loop: asyncio.AbstractEventLoop):
"""
@@ -53,12 +55,14 @@ class PluginReloadHandler(FileSystemEventHandler):
if file_system_event.is_directory:
return
+ src_path = file_system_event.src_path
+
# 只监控 py 文件
- if not file_system_event.src_path.endswith(".py"):
+ if not src_path.endswith(".py"):
return
# 过滤掉一些临时文件
- if "__pycache__" in file_system_event.src_path:
+ if "__pycache__" in src_path or not src_path.startswith(PLUGIN_DIR):
return
# 简单的防抖动
@@ -68,13 +72,18 @@ class PluginReloadHandler(FileSystemEventHandler):
self.last_reload_time = current_time
- logger.info(f"检测到文件变更: {file_system_event.src_path}")
- logger.info("正在重载插件...")
+ # 从文件路径解析出模块名
+ # 例如: C:\path\to\project\plugins\bili_parser.py -> plugins.bili_parser
+ relative_path = os.path.relpath(src_path, ROOT_DIR)
+ module_name = os.path.splitext(relative_path.replace(os.sep, '.'))[0]
+
+ logger.info(f"检测到文件变更: {src_path}")
+ logger.info(f"准备重载插件: {module_name}...")
try:
- # 使用线程安全的方式在主事件循环中运行异步的插件加载函数
- asyncio.run_coroutine_threadsafe(run_in_thread_pool(load_all_plugins), self.loop)
- logger.success("插件重载完成")
+ # 使用线程安全的方式在主事件循环中运行异步的插件重载函数
+ asyncio.run_coroutine_threadsafe(run_in_thread_pool(plugin_manager.reload_plugin, module_name), self.loop)
+ logger.success(f"插件 {module_name} 重载任务已提交")
except Exception as e:
logger.exception(f"重载失败: {e}")
@@ -88,8 +97,7 @@ async def main():
2. 初始化 WebSocket 客户端
3. 建立连接并保持运行
"""
- # 首次加载插件
- await run_in_thread_pool(load_all_plugins)
+ # 插件加载已移至 core.managers.__init__.py 中自动执行
# 初始化 Redis 连接
await redis_manager.initialize()
@@ -114,11 +122,10 @@ async def main():
logger.warning(f"插件目录不存在 {plugin_path}")
try:
- websocket_client = WS()
-
# 初始化代码执行器
- code_executor = initialize_executor(websocket_client, config)
- websocket_client.bot.code_executor = code_executor # 将执行器实例附加到 bot.bot 对象上
+ code_executor = initialize_executor(config)
+
+ websocket_client = WS(code_executor=code_executor)
# 启动代码执行器的后台 worker
logger.debug("[Main] 检查是否需要启动代码执行 Worker...")
diff --git a/models/__init__.py b/models/__init__.py
index 3541531..3418164 100644
--- a/models/__init__.py
+++ b/models/__init__.py
@@ -1,97 +1,23 @@
-from .events.base import OneBotEvent
-from .events.factory import EventFactory
-from .events.message import (
- GroupMessageEvent,
- MessageEvent,
- MessageSegment,
- PrivateMessageEvent,
-)
-from .events.meta import HeartbeatEvent, HeartbeatStatus, LifeCycleEvent, MetaEvent
-from .events.notice import (
- ClientStatus,
- ClientStatusNoticeEvent,
- EssenceNoticeEvent,
- FriendAddNoticeEvent,
- FriendRecallNoticeEvent,
- GroupAdminNoticeEvent,
- GroupBanNoticeEvent,
- GroupCardNoticeEvent,
- GroupDecreaseNoticeEvent,
- GroupIncreaseNoticeEvent,
- GroupRecallNoticeEvent,
- GroupUploadFile,
- GroupUploadNoticeEvent,
- HonorNotifyEvent,
- LuckyKingNotifyEvent,
- NoticeEvent,
- NotifyNoticeEvent,
- OfflineFile,
- OfflineFileNoticeEvent,
- PokeNotifyEvent,
-)
-from .events.request import FriendRequestEvent, GroupRequestEvent, RequestEvent
-from .objects import (
- CurrentTalkative,
- EssenceMessage,
- FriendInfo,
- GroupHonorInfo,
- GroupInfo,
- GroupMemberInfo,
- HonorInfo,
- LoginInfo,
- Status,
- StrangerInfo,
- VersionInfo,
-)
+"""
+Models 包
-# Alias for backward compatibility
-Event = OneBotEvent
+导出常用的模型类,方便插件导入。
+"""
+
+from .events.base import OneBotEvent
+from .events.message import MessageEvent, GroupMessageEvent, PrivateMessageEvent
+from .events.notice import NoticeEvent
+from .events.request import RequestEvent
+from .message import MessageSegment
+from .sender import Sender
__all__ = [
+ "OneBotEvent",
+ "MessageEvent",
+ "GroupMessageEvent",
+ "PrivateMessageEvent",
+ "NoticeEvent",
+ "RequestEvent",
"MessageSegment",
"Sender",
- "OneBotEvent",
- "Event",
- "MessageEvent",
- "PrivateMessageEvent",
- "GroupMessageEvent",
- "NoticeEvent",
- "FriendAddNoticeEvent",
- "FriendRecallNoticeEvent",
- "GroupRecallNoticeEvent",
- "GroupIncreaseNoticeEvent",
- "GroupDecreaseNoticeEvent",
- "GroupAdminNoticeEvent",
- "GroupBanNoticeEvent",
- "GroupUploadNoticeEvent",
- "GroupUploadFile",
- "NotifyNoticeEvent",
- "PokeNotifyEvent",
- "LuckyKingNotifyEvent",
- "HonorNotifyEvent",
- "GroupCardNoticeEvent",
- "OfflineFileNoticeEvent",
- "OfflineFile",
- "ClientStatusNoticeEvent",
- "ClientStatus",
- "EssenceNoticeEvent",
- "RequestEvent",
- "FriendRequestEvent",
- "GroupRequestEvent",
- "MetaEvent",
- "HeartbeatEvent",
- "LifeCycleEvent",
- "HeartbeatStatus",
- "EventFactory",
- "GroupInfo",
- "GroupMemberInfo",
- "FriendInfo",
- "StrangerInfo",
- "LoginInfo",
- "VersionInfo",
- "Status",
- "EssenceMessage",
- "GroupHonorInfo",
- "CurrentTalkative",
- "HonorInfo",
]
diff --git a/models/events/factory.py b/models/events/factory.py
index a1b73f6..7eb4e9f 100644
--- a/models/events/factory.py
+++ b/models/events/factory.py
@@ -70,7 +70,11 @@ class EventFactory:
# 解析消息段
message_list = []
raw_message_list = data.get("message", [])
- if isinstance(raw_message_list, list):
+
+ if isinstance(raw_message_list, str):
+ # 如果消息是字符串,将其视为纯文本消息段
+ message_list.append(MessageSegment.text(raw_message_list))
+ elif isinstance(raw_message_list, list):
for item in raw_message_list:
if isinstance(item, dict):
message_list.append(MessageSegment(type=item.get("type", ""), data=item.get("data", {})))
@@ -252,9 +256,18 @@ class EventFactory:
card_new=data.get("card_new", ""),
card_old=data.get("card_old", "")
)
+ elif notice_type == "group_card":
+ return GroupCardNoticeEvent(
+ **common_args,
+ notice_type=notice_type,
+ group_id=data.get("group_id", 0),
+ user_id=data.get("user_id", 0),
+ card_new=data.get("card_new", ""),
+ card_old=data.get("card_old", "")
+ )
elif notice_type == "offline_file":
file_data = data.get("file", {})
- file = OfflineFile(
+ offline_file = OfflineFile(
name=file_data.get("name", ""),
size=file_data.get("size", 0),
url=file_data.get("url", "")
@@ -263,7 +276,7 @@ class EventFactory:
**common_args,
notice_type=notice_type,
user_id=data.get("user_id", 0),
- file=file
+ file=offline_file
)
elif notice_type == "client_status":
client_data = data.get("client", {})
diff --git a/models/events/message.py b/models/events/message.py
index 530ca62..29b3535 100644
--- a/models/events/message.py
+++ b/models/events/message.py
@@ -4,9 +4,9 @@
定义了消息相关的事件类,包括 MessageEvent, PrivateMessageEvent, GroupMessageEvent。
"""
from dataclasses import dataclass, field
-from typing import List, Optional
+from typing import List, Optional, Union
-from core.managers.permission_manager import ADMIN, OP, USER
+from core.permission import Permission
from models.message import MessageSegment
from models.sender import Sender
from .base import OneBotEvent, EventType
@@ -34,9 +34,9 @@ class MessageEvent(OneBotEvent):
"""
# 权限级别常量,用于装饰器参数
- ADMIN = ADMIN
- OP = OP
- USER = USER
+ ADMIN = Permission.ADMIN
+ OP = Permission.OP
+ USER = Permission.USER
message_type: str
"""消息类型: private (私聊), group (群聊)"""
@@ -70,7 +70,7 @@ class MessageEvent(OneBotEvent):
def post_type(self) -> str:
return EventType.MESSAGE
- async def reply(self, message: str, auto_escape: bool = False):
+ async def reply(self, message: Union[str, "MessageSegment", List["MessageSegment"]], auto_escape: bool = False):
"""
回复消息(抽象方法,由子类实现)
@@ -86,7 +86,7 @@ class PrivateMessageEvent(MessageEvent):
私聊消息事件
"""
- async def reply(self, message: str, auto_escape: bool = False):
+ async def reply(self, message: Union[str, "MessageSegment", List["MessageSegment"]], auto_escape: bool = False):
"""
回复私聊消息
@@ -110,7 +110,7 @@ class GroupMessageEvent(MessageEvent):
anonymous: Optional[Anonymous] = None
"""匿名信息"""
- async def reply(self, message: str, auto_escape: bool = False):
+ async def reply(self, message: Union[str, "MessageSegment", List["MessageSegment"]], auto_escape: bool = False):
"""
回复群聊消息
diff --git a/models/events/meta.py b/models/events/meta.py
index b2c720f..57859fc 100644
--- a/models/events/meta.py
+++ b/models/events/meta.py
@@ -63,5 +63,5 @@ class LifeCycleEvent(MetaEvent):
meta_event_type: str = 'lifecycle'
"""元事件类型:生命周期事件"""
- sub_type: LifeCycleSubType = LifeCycleSubType.ENABLE
+ sub_type: str = LifeCycleSubType.ENABLE
"""子类型:启用、禁用、连接"""
diff --git a/models/message.py b/models/message.py
index bd93aff..2a8cafc 100644
--- a/models/message.py
+++ b/models/message.py
@@ -6,7 +6,7 @@
"""
from dataclasses import dataclass
-from typing import Any, Dict
+from typing import Any, Dict, Optional, List
@dataclass(slots=True)
@@ -23,7 +23,7 @@ class MessageSegment:
data: Dict[str, Any]
@property
- def text(self) -> str:
+ def plain_text(self) -> str:
"""
当消息段类型为 'text' 时,快速获取其文本内容。
@@ -32,6 +32,19 @@ class MessageSegment:
"""
return self.data.get("text", "") if self.type == "text" else ""
+ @staticmethod
+ def text(text: str) -> "MessageSegment":
+ """
+ 创建一个文本消息段。
+
+ Args:
+ text (str): 文本内容。
+
+ Returns:
+ MessageSegment: 一个类型为 'text' 的消息段对象。
+ """
+ return MessageSegment(type="text", data={"text": text})
+
@property
def image_url(self) -> str:
"""
@@ -76,7 +89,7 @@ class MessageSegment:
return self.data.get("file", "")
return ""
- def is_at(self, user_id: int = None) -> bool:
+ def is_at(self, user_id: Optional[int] = None) -> bool:
"""
检查当前消息段是否是一个 'at' (提及) 消息段。
@@ -93,16 +106,52 @@ class MessageSegment:
return True
return str(self.data.get("qq")) == str(user_id)
+ def __str__(self):
+ """
+ 返回消息段的 CQ 码字符串表示。
+ """
+ if self.type == "text":
+ return self.data.get("text", "")
+
+ params = ",".join([f"{k}={v}" for k, v in self.data.items()])
+ if params:
+ return f"[CQ:{self.type},{params}]"
+ return f"[CQ:{self.type}]"
+
def __repr__(self):
"""
返回消息段对象的字符串表示形式,便于调试。
"""
return f"[MS:{self.type}:{self.data}]"
+ def __add__(self, other: Any) -> "List[MessageSegment]":
+ """
+ 支持消息段相加,返回消息段列表。
+ """
+ if isinstance(other, MessageSegment):
+ return [self, other]
+ elif isinstance(other, str):
+ return [self, MessageSegment.text(other)]
+ elif isinstance(other, list):
+ return [self] + other
+ return NotImplemented
+
+ def __radd__(self, other: Any) -> "List[MessageSegment]":
+ """
+ 支持反向相加。
+ """
+ if isinstance(other, MessageSegment):
+ return [other, self]
+ elif isinstance(other, str):
+ return [MessageSegment.text(other), self]
+ elif isinstance(other, list):
+ return other + [self]
+ return NotImplemented
+
# --- 快捷构造方法 ---
@staticmethod
- def text(text: str) -> "MessageSegment": # noqa: F811
+ def from_text(text: str) -> "MessageSegment":
"""
创建一个文本消息段。
@@ -115,7 +164,7 @@ class MessageSegment:
return MessageSegment(type="text", data={"text": text})
@staticmethod
- def at(user_id: int | str, name: str = None) -> "MessageSegment":
+ def at(user_id: int | str, name: Optional[str] = None) -> "MessageSegment":
"""
创建一个 @某人 的消息段。
@@ -132,7 +181,7 @@ class MessageSegment:
return MessageSegment(type="at", data=data)
@staticmethod
- def image(file: str, image_type: str = None, cache: bool = True, proxy: bool = True, timeout: int = None, sub_type: int = None) -> "MessageSegment":
+ def image(file: str, image_type: Optional[str] = None, cache: bool = True, proxy: bool = True, timeout: Optional[int] = None, sub_type: Optional[int] = None) -> "MessageSegment":
"""
创建一个图片消息段。
@@ -194,7 +243,7 @@ class MessageSegment:
"""
return MessageSegment(type="xml", data={"data": data})
@staticmethod
- def share(url: str, title: str, content: str = None, image: str = None) -> "MessageSegment":
+ def share(url: str, title: str, content: Optional[str] = None, image: Optional[str] = None) -> "MessageSegment":
"""
创建一个分享消息段。
@@ -227,7 +276,7 @@ class MessageSegment:
"""
return MessageSegment(type="music", data={"type": type, "id": id})
@staticmethod
- def music_custom(url: str, audio: str, title: str, content: str = None, image: str = None) -> "MessageSegment":
+ def music_custom(url: str, audio: str, title: str, content: Optional[str] = None, image: Optional[str] = None) -> "MessageSegment":
"""
创建一个自定义音乐消息段。
@@ -248,7 +297,7 @@ class MessageSegment:
data["image"] = image
return MessageSegment(type="music", data={"type": "custom", **data})
@staticmethod
- def record(file: str, magic: bool = False, cache: bool = True, proxy: bool = True, timeout: int = None) -> "MessageSegment":
+ def record(file: str, magic: bool = False, cache: bool = True, proxy: bool = True, timeout: Optional[int] = None) -> "MessageSegment":
"""
创建一个语音消息段。
@@ -267,7 +316,7 @@ class MessageSegment:
data["timeout"] = str(timeout)
return MessageSegment(type="record", data=data)
@staticmethod
- def video(file: str, cover: str = None, c: int = 2) -> "MessageSegment":
+ def video(file: str, cover: Optional[str] = None, c: int = 2) -> "MessageSegment":
"""
创建一个视频消息段。
@@ -297,17 +346,17 @@ class MessageSegment:
return MessageSegment(type="file", data={"file": file})
@staticmethod
- def reply(message_id: str) -> "MessageSegment":
+ def reply(message_id: str | int) -> "MessageSegment":
"""
创建一个回复消息段。
Args:
- message_id (str): 被回复的消息 ID。
+ message_id (str | int): 被回复的消息 ID。
Returns:
MessageSegment: 一个类型为 'reply' 的消息段对象。
"""
- return MessageSegment(type="reply", data={"id": message_id})
+ return MessageSegment(type="reply", data={"id": str(message_id)})
@staticmethod
def rps() -> "MessageSegment":
diff --git a/plugins/__init__.py b/plugins/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/plugins/admin.py b/plugins/admin.py
index 9293d91..f9e9aa4 100644
--- a/plugins/admin.py
+++ b/plugins/admin.py
@@ -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 - 添加管理员\n"
- "/admin remove - 移除管理员"
+ "/admin list - 列出所有管理员和操作员\n"
+ "/admin add_admin - 添加管理员\n"
+ "/admin remove_admin - 移除管理员\n"
+ "/admin add_op - 添加操作员\n"
+ "/admin remove_op - 移除操作员"
),
}
-@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} ")
+ 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())
diff --git a/plugins/bili_parser.py b/plugins/bili_parser.py
index 2711d52..a4a8ac5 100644
--- a/plugins/bili_parser.py
+++ b/plugins/bili_parser.py
@@ -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:
diff --git a/plugins/broadcast.py b/plugins/broadcast.py
index 530dc09..7d3cbe0 100644
--- a/plugins/broadcast.py
+++ b/plugins/broadcast.py
@@ -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
)
]
diff --git a/plugins/code_py.py b/plugins/code_py.py
index 4ef5dbd..6119f5e 100644
--- a/plugins/code_py.py
+++ b/plugins/code_py.py
@@ -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., [ -> [)。
- 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
diff --git a/plugins/echo.py b/plugins/echo.py
index de712bb..6acbc11 100644
--- a/plugins/echo.py
+++ b/plugins/echo.py
@@ -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",
diff --git a/plugins/forward_test.py b/plugins/forward_test.py
index e52025b..0579a68 100644
--- a/plugins/forward_test.py
+++ b/plugins/forward_test.py
@@ -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/")
]
)
diff --git a/plugins/jrcd.py b/plugins/jrcd.py
index fba2399..78dd4ba 100644
--- a/plugins/jrcd.py
+++ b/plugins/jrcd.py
@@ -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)
diff --git a/plugins/thpic.py b/plugins/thpic.py
index d6a19db..2112cf6 100644
--- a/plugins/thpic.py
+++ b/plugins/thpic.py
@@ -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}")
diff --git a/requirements.txt b/requirements.txt
index 0033075..fe0f977 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,7 +11,6 @@ pipreqs==0.4.13
redis==5.0.7
requests==2.32.5
soupsieve==2.8.1
-toml==0.10.2
typing==3.7.4.3
typing_extensions==4.15.0
urllib3==2.6.2
@@ -19,7 +18,15 @@ watchdog==6.0.0
websockets==15.0.1
win32_setctime==1.2.0
yarg==0.1.10
+cachetools
+pydantic
docker
pytest
pytest-asyncio
pytest-mock
+pytest-cov
+httpx==0.27.0
+
+# Dev Dependencies
+mypy
+pydantic[mypy]
diff --git a/tests/test_basic.py b/tests/test_basic.py
new file mode 100644
index 0000000..7dafa19
--- /dev/null
+++ b/tests/test_basic.py
@@ -0,0 +1,37 @@
+import pytest
+import sys
+import os
+
+# 确保项目根目录在 sys.path 中
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
+
+def test_import_core():
+ """测试核心模块是否可以被导入"""
+ try:
+ import core
+ import core.bot
+ import core.ws
+ except ImportError as e:
+ pytest.fail(f"无法导入核心模块: {e}")
+
+def test_plugin_manager_path():
+ """测试插件管理器路径逻辑是否正确"""
+ from core.managers.plugin_manager import PluginManager
+ # Mock command manager
+ pm = PluginManager(None)
+
+ # 我们无法直接测试 load_all_plugins 的内部路径变量,
+ # 但我们可以检查它是否能找到 plugins 目录而不报错
+ # 这里我们简单地断言 PluginManager 类存在且可以实例化
+ assert pm is not None
+
+def test_config_loader_exists():
+ """测试配置加载器是否存在"""
+ # 注意:导入 config_loader 会尝试读取 config.toml
+ # 如果 config.toml 不存在,这可能会失败。
+ # 这是一个已知的设计问题,但在测试环境中我们假设 config.toml 存在或被 mock
+ if os.path.exists("config.toml"):
+ from core.config_loader import global_config
+ assert global_config is not None
+ else:
+ pytest.skip("config.toml 不存在,跳过配置加载测试")
diff --git a/tests/test_command_manager.py b/tests/test_command_manager.py
new file mode 100644
index 0000000..3743d99
--- /dev/null
+++ b/tests/test_command_manager.py
@@ -0,0 +1,114 @@
+import pytest
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+from core.managers.command_manager import CommandManager
+from models.events.message import GroupMessageEvent
+from models.message import MessageSegment
+
+@pytest.fixture
+def mock_bot():
+ bot = AsyncMock()
+ bot.self_id = 123456
+ return bot
+
+@pytest.fixture
+def command_manager():
+ # 创建一个新的 CommandManager 实例用于测试,避免单例状态污染
+ return CommandManager(prefixes=("/",))
+
+@pytest.mark.asyncio
+async def test_command_registration_and_execution(command_manager, mock_bot):
+ """测试命令注册和执行"""
+
+ # 定义一个命令处理函数
+ handler_mock = AsyncMock()
+
+ # 注册命令
+ @command_manager.command("test")
+ async def test_command(bot, event):
+ await handler_mock(bot, event)
+
+ # 构造触发命令的事件
+ event = MagicMock(spec=GroupMessageEvent)
+ event.post_type = "message"
+ event.message_type = "group"
+ event.raw_message = "/test"
+ event.message = [MessageSegment.text("/test")]
+ event.user_id = 111
+ event.group_id = 222
+
+ # 处理事件
+ await command_manager.handle_event(mock_bot, event)
+
+ # 验证处理函数被调用
+ handler_mock.assert_called_once_with(mock_bot, event)
+
+@pytest.mark.asyncio
+async def test_command_prefix_match(command_manager, mock_bot):
+ """测试命令前缀匹配"""
+ handler_mock = AsyncMock()
+
+ @command_manager.command("hello")
+ async def hello_command(bot, event):
+ await handler_mock(bot, event)
+
+ # 1. 正确的前缀
+ event1 = MagicMock(spec=GroupMessageEvent)
+ event1.post_type = "message"
+ event1.raw_message = "/hello"
+ event1.message = [MessageSegment.text("/hello")]
+ await command_manager.handle_event(mock_bot, event1)
+ handler_mock.assert_called_once()
+ handler_mock.reset_mock()
+
+ # 2. 错误的前缀 (应该忽略)
+ event2 = MagicMock(spec=GroupMessageEvent)
+ event2.post_type = "message"
+ event2.raw_message = ".hello" # 假设前缀是 /
+ event2.message = [MessageSegment.text(".hello")]
+ await command_manager.handle_event(mock_bot, event2)
+ handler_mock.assert_not_called()
+
+@pytest.mark.asyncio
+async def test_ignore_self_message(command_manager, mock_bot):
+ """测试忽略自身消息"""
+ # 模拟配置
+ with patch("core.managers.command_manager.global_config") as mock_config:
+ mock_config.bot.ignore_self_message = True
+
+ event = MagicMock(spec=GroupMessageEvent)
+ event.post_type = "message"
+ event.user_id = 123456 # 与 bot.self_id 相同
+ event.self_id = 123456
+
+ # Mock handle 方法来检测是否被调用
+ command_manager.message_handler.handle = AsyncMock()
+
+ await command_manager.handle_event(mock_bot, event)
+
+ # 应该直接返回,不调用 handler
+ command_manager.message_handler.handle.assert_not_called()
+
+@pytest.mark.asyncio
+async def test_help_command(command_manager, mock_bot):
+ """测试内置 help 命令"""
+ # 注册一个测试插件信息
+ command_manager.plugins["test_plugin"] = {
+ "name": "测试插件",
+ "description": "这是一个测试",
+ "usage": "/test"
+ }
+
+ event = MagicMock(spec=GroupMessageEvent)
+ event.post_type = "message"
+ event.raw_message = "/help"
+ event.message = [MessageSegment.text("/help")]
+
+ await command_manager.handle_event(mock_bot, event)
+
+ # 验证 bot.send 被调用,且内容包含插件信息
+ mock_bot.send.assert_called_once()
+ args, _ = mock_bot.send.call_args
+ sent_msg = args[1]
+ assert "测试插件" in sent_msg
+ assert "这是一个测试" in sent_msg
diff --git a/tests/test_event_factory.py b/tests/test_event_factory.py
new file mode 100644
index 0000000..1e038fd
--- /dev/null
+++ b/tests/test_event_factory.py
@@ -0,0 +1,141 @@
+import pytest
+from models.events.factory import EventFactory, EventType
+from models.events.message import GroupMessageEvent, PrivateMessageEvent
+from models.events.notice import GroupIncreaseNoticeEvent
+from models.events.request import FriendRequestEvent
+from models.events.meta import HeartbeatEvent
+from models.message import MessageSegment
+
+class TestEventFactory:
+ def test_create_group_message_event_list(self):
+ """测试创建群消息事件 (message 为列表格式)"""
+ data = {
+ "post_type": "message",
+ "message_type": "group",
+ "time": 1600000000,
+ "self_id": 123456,
+ "sub_type": "normal",
+ "message_id": 1001,
+ "user_id": 111111,
+ "group_id": 222222,
+ "message": [
+ {"type": "text", "data": {"text": "Hello"}}
+ ],
+ "raw_message": "Hello",
+ "font": 0,
+ "sender": {
+ "user_id": 111111,
+ "nickname": "User",
+ "role": "member"
+ }
+ }
+ event = EventFactory.create_event(data)
+ assert isinstance(event, GroupMessageEvent)
+ assert event.group_id == 222222
+ assert len(event.message) == 1
+ assert event.message[0].type == "text"
+ assert event.message[0].data["text"] == "Hello"
+
+ def test_create_group_message_event_str(self):
+ """测试创建群消息事件 (message 为字符串格式)"""
+ data = {
+ "post_type": "message",
+ "message_type": "group",
+ "time": 1600000000,
+ "self_id": 123456,
+ "sub_type": "normal",
+ "message_id": 1002,
+ "user_id": 111111,
+ "group_id": 222222,
+ "message": "Hello World",
+ "raw_message": "Hello World",
+ "font": 0,
+ "sender": {
+ "user_id": 111111,
+ "nickname": "User"
+ }
+ }
+ event = EventFactory.create_event(data)
+ assert isinstance(event, GroupMessageEvent)
+ assert len(event.message) == 1
+ assert event.message[0].type == "text"
+ assert event.message[0].data["text"] == "Hello World"
+
+ def test_create_private_message_event(self):
+ """测试创建私聊消息事件"""
+ data = {
+ "post_type": "message",
+ "message_type": "private",
+ "time": 1600000000,
+ "self_id": 123456,
+ "sub_type": "friend",
+ "message_id": 2001,
+ "user_id": 333333,
+ "message": "Private Msg",
+ "raw_message": "Private Msg",
+ "font": 0,
+ "sender": {
+ "user_id": 333333,
+ "nickname": "Friend"
+ }
+ }
+ event = EventFactory.create_event(data)
+ assert isinstance(event, PrivateMessageEvent)
+ assert event.user_id == 333333
+
+ def test_create_notice_event(self):
+ """测试创建通知事件 (群成员增加)"""
+ data = {
+ "post_type": "notice",
+ "notice_type": "group_increase",
+ "sub_type": "approve",
+ "group_id": 222222,
+ "operator_id": 444444,
+ "user_id": 555555,
+ "time": 1600000000,
+ "self_id": 123456
+ }
+ event = EventFactory.create_event(data)
+ assert isinstance(event, GroupIncreaseNoticeEvent)
+ assert event.group_id == 222222
+ assert event.user_id == 555555
+
+ def test_create_request_event(self):
+ """测试创建请求事件 (加好友)"""
+ data = {
+ "post_type": "request",
+ "request_type": "friend",
+ "user_id": 666666,
+ "comment": "Add me",
+ "flag": "flag_123",
+ "time": 1600000000,
+ "self_id": 123456
+ }
+ event = EventFactory.create_event(data)
+ assert isinstance(event, FriendRequestEvent)
+ assert event.user_id == 666666
+ assert event.comment == "Add me"
+
+ def test_create_meta_event(self):
+ """测试创建元事件 (心跳)"""
+ data = {
+ "post_type": "meta_event",
+ "meta_event_type": "heartbeat",
+ "time": 1600000000,
+ "self_id": 123456,
+ "status": {"online": True, "good": True},
+ "interval": 5000
+ }
+ event = EventFactory.create_event(data)
+ assert isinstance(event, HeartbeatEvent)
+ assert event.interval == 5000
+
+ def test_unknown_event_type(self):
+ """测试未知事件类型"""
+ data = {
+ "post_type": "unknown_type",
+ "time": 1600000000,
+ "self_id": 123456
+ }
+ with pytest.raises(ValueError, match="Unknown event type"):
+ EventFactory.create_event(data)
diff --git a/tests/test_event_handler.py b/tests/test_event_handler.py
new file mode 100644
index 0000000..80af28f
--- /dev/null
+++ b/tests/test_event_handler.py
@@ -0,0 +1,194 @@
+import pytest
+from unittest.mock import AsyncMock, MagicMock, patch
+from core.handlers.event_handler import MessageHandler, NoticeHandler, RequestHandler
+from models.events.message import GroupMessageEvent
+from models.events.notice import GroupIncreaseNoticeEvent
+from models.events.request import FriendRequestEvent
+
+@pytest.fixture
+def mock_bot():
+ bot = AsyncMock()
+ return bot
+
+@pytest.mark.asyncio
+async def test_message_handler_run_handler_injection(mock_bot):
+ """测试参数注入"""
+ handler = MessageHandler(prefixes=("/",))
+
+ # 1. 测试注入 bot 和 event
+ async def func1(bot, event):
+ assert bot == mock_bot
+ assert event.user_id == 123
+ return True
+
+ event = MagicMock(spec=GroupMessageEvent)
+ event.user_id = 123
+
+ result = await handler._run_handler(func1, mock_bot, event)
+ assert result is True
+
+ # 2. 测试注入 args
+ async def func2(args):
+ assert args == ["arg1", "arg2"]
+ return True
+
+ result = await handler._run_handler(func2, mock_bot, event, args=["arg1", "arg2"])
+ assert result is True
+
+@pytest.mark.asyncio
+async def test_message_handler_command_parsing(mock_bot):
+ """测试命令解析"""
+ handler = MessageHandler(prefixes=("/",))
+
+ mock_func = AsyncMock()
+ handler.commands["test"] = {
+ "func": mock_func,
+ "permission": None,
+ "override_permission_check": False,
+ "plugin_name": "test_plugin"
+ }
+
+ event = MagicMock(spec=GroupMessageEvent)
+ event.raw_message = "/test arg1 arg2"
+ event.user_id = 123
+
+ # Mock permission manager
+ with patch("core.managers.permission_manager.PermissionManager.check_permission", new_callable=AsyncMock) as mock_perm:
+ mock_perm.return_value = True
+
+ await handler.handle(mock_bot, event)
+
+ mock_func.assert_called_once()
+ # 验证 args 参数是否正确传递
+ call_args = mock_func.call_args
+ if "args" in call_args.kwargs:
+ assert call_args.kwargs["args"] == ["arg1", "arg2"]
+
+@pytest.mark.asyncio
+async def test_notice_handler(mock_bot):
+ """测试通知事件分发"""
+ handler = NoticeHandler()
+
+ mock_func = AsyncMock()
+ handler.handlers.append({
+ "type": "group_increase",
+ "func": mock_func,
+ "plugin_name": "test_plugin"
+ })
+
+ event = MagicMock(spec=GroupIncreaseNoticeEvent)
+ event.notice_type = "group_increase"
+
+ await handler.handle(mock_bot, event)
+
+ mock_func.assert_called_once()
+
+@pytest.mark.asyncio
+async def test_sync_handler_execution(mock_bot):
+ """测试同步处理函数的执行"""
+ handler = MessageHandler(prefixes=("/",))
+
+ def sync_func(event):
+ return True
+
+ event = MagicMock(spec=GroupMessageEvent)
+
+ # 同步函数应该在线程池中运行
+ result = await handler._run_handler(sync_func, mock_bot, event)
+ assert result is True
+
+@pytest.mark.asyncio
+async def test_message_handler_management(mock_bot):
+ """测试消息处理器的管理(注册、卸载、清空)"""
+ handler = MessageHandler(prefixes=("/",))
+
+ # 测试 on_message 装饰器
+ @handler.on_message()
+ async def msg_handler(event):
+ pass
+
+ assert len(handler.message_handlers) == 1
+
+ # 测试 command 装饰器
+ @handler.command("cmd1", "cmd2")
+ async def cmd_handler(event):
+ pass
+
+ assert len(handler.commands) == 2
+ assert "cmd1" in handler.commands
+ assert "cmd2" in handler.commands
+
+ # 测试 unregister_by_plugin_name
+ # 直接从已注册的处理器中获取 plugin_name
+ if handler.message_handlers:
+ plugin_name = handler.message_handlers[0]["plugin_name"]
+ handler.unregister_by_plugin_name(plugin_name)
+
+ assert len(handler.message_handlers) == 0
+ assert len(handler.commands) == 0
+
+ # 测试 clear
+ handler.commands["cmd"] = {}
+ handler.message_handlers.append({})
+ handler.clear()
+ assert len(handler.commands) == 0
+ assert len(handler.message_handlers) == 0
+
+@pytest.mark.asyncio
+async def test_request_handler(mock_bot):
+ """测试请求事件处理器"""
+ handler = RequestHandler()
+
+ mock_func = AsyncMock()
+
+ # 测试 register 装饰器
+ @handler.register("friend")
+ async def req_handler(event):
+ await mock_func(event)
+
+ assert len(handler.handlers) == 1
+
+ event = MagicMock(spec=FriendRequestEvent)
+ event.request_type = "friend"
+
+ await handler.handle(mock_bot, event)
+ mock_func.assert_called_once()
+
+ # 测试 unregister 和 clear
+ import inspect
+ module = inspect.getmodule(req_handler)
+ plugin_name = module.__name__
+
+ handler.unregister_by_plugin_name(plugin_name)
+ assert len(handler.handlers) == 0
+
+ handler.handlers.append({})
+ handler.clear()
+ assert len(handler.handlers) == 0
+
+@pytest.mark.asyncio
+async def test_permission_denied(mock_bot):
+ """测试权限不足的情况"""
+ handler = MessageHandler(prefixes=("/",))
+
+ mock_func = AsyncMock()
+ handler.commands["admin_cmd"] = {
+ "func": mock_func,
+ "permission": "ADMIN", # 假设 Permission.ADMIN
+ "override_permission_check": False,
+ "plugin_name": "test_plugin"
+ }
+
+ event = MagicMock(spec=GroupMessageEvent)
+ event.raw_message = "/admin_cmd"
+ event.user_id = 123
+
+ # Mock permission manager returning False
+ with patch("core.managers.permission_manager.PermissionManager.check_permission", new_callable=AsyncMock) as mock_perm:
+ mock_perm.return_value = False
+
+ await handler.handle(mock_bot, event)
+
+ mock_func.assert_not_called()
+ # 应该发送拒绝消息
+ mock_bot.send.assert_called_once()
diff --git a/tests/test_models.py b/tests/test_models.py
new file mode 100644
index 0000000..497581d
--- /dev/null
+++ b/tests/test_models.py
@@ -0,0 +1,75 @@
+import pytest
+from models.message import MessageSegment
+from models.objects import GroupInfo, StrangerInfo
+
+class TestMessageSegment:
+ def test_text_segment(self):
+ seg = MessageSegment.text("Hello")
+ assert seg.type == "text"
+ assert seg.data["text"] == "Hello"
+ assert str(seg) == "Hello"
+
+ def test_at_segment(self):
+ seg = MessageSegment.at(123456)
+ assert seg.type == "at"
+ assert seg.data["qq"] == "123456"
+ assert str(seg) == "[CQ:at,qq=123456]"
+
+ def test_image_segment(self):
+ seg = MessageSegment.image("http://example.com/img.jpg", cache=False, proxy=False)
+ assert seg.type == "image"
+ assert seg.data["file"] == "http://example.com/img.jpg"
+ assert str(seg) == "[CQ:image,file=http://example.com/img.jpg,cache=0,proxy=0]"
+
+ def test_face_segment(self):
+ seg = MessageSegment.face(123)
+ assert seg.type == "face"
+ assert seg.data["id"] == "123"
+ assert str(seg) == "[CQ:face,id=123]"
+
+ def test_reply_segment(self):
+ seg = MessageSegment.reply(1001)
+ assert seg.type == "reply"
+ assert seg.data["id"] == "1001"
+ assert str(seg) == "[CQ:reply,id=1001]"
+
+ def test_add_segments(self):
+ seg1 = MessageSegment.text("Hello ")
+ seg2 = MessageSegment.at(123)
+ combined = seg1 + seg2
+ assert isinstance(combined, list)
+ assert len(combined) == 2
+ assert combined[0] == seg1
+ assert combined[1] == seg2
+
+ def test_add_segment_and_string(self):
+ seg = MessageSegment.at(123)
+ combined = seg + " Hello"
+ assert isinstance(combined, list)
+ assert len(combined) == 2
+ assert combined[0] == seg
+ assert combined[1].type == "text"
+ assert combined[1].data["text"] == " Hello"
+
+class TestObjects:
+ def test_group_info(self):
+ data = {
+ "group_id": 123456,
+ "group_name": "Test Group",
+ "member_count": 10,
+ "max_member_count": 100
+ }
+ group = GroupInfo(**data)
+ assert group.group_id == 123456
+ assert group.group_name == "Test Group"
+
+ def test_stranger_info(self):
+ data = {
+ "user_id": 111111,
+ "nickname": "Stranger",
+ "sex": "male",
+ "age": 18
+ }
+ user = StrangerInfo(**data)
+ assert user.user_id == 111111
+ assert user.nickname == "Stranger"
From 61c8d6b3281a4ae18ae541d013b95274904fe99e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=95=80=E9=93=AC=E9=85=B8=E9=92=BE?=
<148796996+K2cr2O1@users.noreply.github.com>
Date: Fri, 9 Jan 2026 04:38:51 +0800
Subject: [PATCH 34/46] Dev (#29)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 滚木
* 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: 更新依赖项
* refactor(handler): 移除TYPE_CHECKING并直接导入Bot类
简化类型注解,直接导入Bot类而非使用TYPE_CHECKING条件导入,提高代码可读性和维护性
* fix(command_manager): 修复插件卸载时元信息移除不精确的问题
修复 CommandManager 中 unload_plugin 方法移除插件元信息时使用 startswith 导致可能误删其他插件的问题,改为精确匹配
同时调整相关测试用例验证精确匹配行为
---
core/managers/__init__.py | 3 +-
core/managers/command_manager.py | 2 +-
core/managers/permission_manager.py | 13 +++++--
plugins/admin.py | 6 +--
tests/test_plugin_reload_meta.py | 60 +++++++++++++++++++++++++++++
5 files changed, 75 insertions(+), 9 deletions(-)
create mode 100644 tests/test_plugin_reload_meta.py
diff --git a/core/managers/__init__.py b/core/managers/__init__.py
index 10780cb..5be6af9 100644
--- a/core/managers/__init__.py
+++ b/core/managers/__init__.py
@@ -6,7 +6,7 @@
"""
from ..config_loader import global_config
from .admin_manager import AdminManager
-from .command_manager import CommandManager
+from .command_manager import matcher as command_manager
from .permission_manager import PermissionManager
from .plugin_manager import PluginManager
from .redis_manager import RedisManager
@@ -20,7 +20,6 @@ admin_manager = AdminManager()
permission_manager = PermissionManager()
# 命令与事件管理器 (别名 matcher)
-command_manager = CommandManager(prefixes=tuple(global_config.bot.command))
matcher = command_manager
# 插件管理器
diff --git a/core/managers/command_manager.py b/core/managers/command_manager.py
index 522d86e..da555e7 100644
--- a/core/managers/command_manager.py
+++ b/core/managers/command_manager.py
@@ -93,7 +93,7 @@ class CommandManager:
self.request_handler.unregister_by_plugin_name(plugin_name)
# 移除插件元信息
- plugins_to_remove = [name for name in self.plugins if name.startswith(plugin_name)]
+ plugins_to_remove = [name for name in self.plugins if name == plugin_name]
for name in plugins_to_remove:
del self.plugins[name]
diff --git a/core/managers/permission_manager.py b/core/managers/permission_manager.py
index de808c1..b7904c3 100644
--- a/core/managers/permission_manager.py
+++ b/core/managers/permission_manager.py
@@ -170,13 +170,20 @@ class PermissionManager(Singleton):
user_permission = await self.get_user_permission(user_id)
return user_permission >= required_permission
- def get_all_user_permissions(self) -> Dict[str, str]:
+ async def get_all_user_permissions(self) -> Dict[str, str]:
"""
- 获取所有已配置的用户权限
+ 获取所有已配置的用户权限(包括 AdminManager 中的管理员)
:return: 一个包含所有用户权限的字典
"""
- return self._data["users"].copy()
+ permissions = self._data["users"].copy()
+
+ # 合并 AdminManager 中的管理员
+ admins = await admin_manager.get_all_admins()
+ for admin_id in admins:
+ permissions[str(admin_id)] = Permission.ADMIN.value
+
+ return permissions
def get_all_users(self) -> Dict[str, str]:
"""
diff --git a/plugins/admin.py b/plugins/admin.py
index f9e9aa4..6dd0c18 100644
--- a/plugins/admin.py
+++ b/plugins/admin.py
@@ -18,11 +18,11 @@ __plugin_meta__ = {
@command_manager.command("admin", permission=Permission.ADMIN)
-async def admin_management(event: MessageEvent, args: str):
+async def admin_management(event: MessageEvent, args: list[str]):
"""
处理所有权限管理相关的命令。
"""
- parts = args.split()
+ parts = args
if not parts:
await event.reply(f"用法不正确。\n\n{__plugin_meta__['usage']}")
return
@@ -73,7 +73,7 @@ async def list_permissions(event: MessageEvent):
"""
列出所有具有特殊权限(管理员和操作员)的用户。
"""
- permissions = permission_manager.get_all_user_permissions()
+ permissions = await permission_manager.get_all_user_permissions()
if not permissions:
await event.reply("当前没有配置任何特殊权限的用户。")
return
diff --git a/tests/test_plugin_reload_meta.py b/tests/test_plugin_reload_meta.py
new file mode 100644
index 0000000..92a9e93
--- /dev/null
+++ b/tests/test_plugin_reload_meta.py
@@ -0,0 +1,60 @@
+
+import pytest
+from unittest.mock import MagicMock
+from core.managers.command_manager import CommandManager
+
+class TestPluginReloadMeta:
+ def test_plugin_meta_persistence(self):
+ """
+ 测试插件加载、卸载和重载过程中元信息的持久性
+ """
+ # 初始化 CommandManager
+ command_manager = CommandManager(prefixes=("/",))
+
+ # 模拟插件名称和元信息
+ plugin_name = "plugins.test_plugin"
+ plugin_meta = {
+ "name": "测试插件",
+ "description": "这是一个测试插件",
+ "usage": "/test"
+ }
+
+ # 1. 模拟加载插件
+ command_manager.plugins[plugin_name] = plugin_meta
+
+ # 验证元信息已注册
+ assert plugin_name in command_manager.plugins
+ assert command_manager.plugins[plugin_name] == plugin_meta
+
+ # 2. 模拟卸载插件
+ command_manager.unload_plugin(plugin_name)
+
+ # 验证元信息已移除
+ assert plugin_name not in command_manager.plugins
+
+ # 3. 模拟重载插件(重新注册元信息)
+ # 在实际运行中,PluginManager 会在 reload 后重新赋值
+ command_manager.plugins[plugin_name] = plugin_meta
+
+ # 验证元信息已恢复
+ assert plugin_name in command_manager.plugins
+ assert command_manager.plugins[plugin_name] == plugin_meta
+
+ def test_unload_plugin_exact_match(self):
+ """
+ 测试 unload_plugin 是否只移除精确匹配的插件元信息
+ """
+ command_manager = CommandManager(prefixes=("/",))
+
+ plugin1 = "plugins.test"
+ plugin2 = "plugins.test_extra"
+
+ command_manager.plugins[plugin1] = {"name": "Test 1"}
+ command_manager.plugins[plugin2] = {"name": "Test 2"}
+
+ # 卸载 plugin1
+ command_manager.unload_plugin(plugin1)
+
+ # 验证 plugin1 被移除,但 plugin2 仍然存在
+ assert plugin1 not in command_manager.plugins
+ assert plugin2 in command_manager.plugins
From 5f16c288bf9257ad57eaf14d8dabf85e2167a183 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=95=80=E9=93=AC=E9=85=B8=E9=92=BE?=
<148796996+K2cr2O1@users.noreply.github.com>
Date: Fri, 9 Jan 2026 23:10:58 +0800
Subject: [PATCH 35/46] Dev (#30)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 滚木
* 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: 更新依赖项
* refactor(handler): 移除TYPE_CHECKING并直接导入Bot类
简化类型注解,直接导入Bot类而非使用TYPE_CHECKING条件导入,提高代码可读性和维护性
* fix(command_manager): 修复插件卸载时元信息移除不精确的问题
修复 CommandManager 中 unload_plugin 方法移除插件元信息时使用 startswith 导致可能误删其他插件的问题,改为精确匹配
同时调整相关测试用例验证精确匹配行为
* refactor: 清理未使用的导入和更新文档结构
docs: 添加config_models.py到项目结构文档
docs: 调整数据目录位置到core/data下
docs: 更新权限管理器文档描述
* 文档更新
* 更新thpic插件 支持一次返回多张图
---------
Co-authored-by: baby20162016 <2185823427@qq.com>
---
README.md | 6 +--
core/managers/__init__.py | 1 -
core/managers/permission_manager.py | 2 +-
docs/core-concepts/event-flow.md | 54 ++++++++++++++++++++----
docs/core-concepts/singleton-managers.md | 2 +-
docs/project-structure.md | 1 +
plugins/admin.py | 1 -
plugins/thpic.py | 41 +++++++++++++++---
tests/test_plugin_reload_meta.py | 2 -
9 files changed, 87 insertions(+), 23 deletions(-)
diff --git a/README.md b/README.md
index 55b9d6b..5e14e47 100644
--- a/README.md
+++ b/README.md
@@ -74,12 +74,12 @@
│ └── thpic.py
├── core/ # NEO 框架核心代码,通常无需修改
│ ├── api/
+│ ├── data/ # 数据存储目录 (管理员列表, 权限配置)
+│ │ ├── admin.json
+│ │ └── permissions.json
│ ├── bot.py
│ ├── ...
│ └── ws.py
-├── data/ # 数据存储目录 (管理员列表, 权限配置)
-│ ├── admin.json
-│ └── permissions.json
├── html/ # 静态网页文件
├── plugins/ # 插件目录,所有机器人的功能模块都在这里
│ ├── admin.py
diff --git a/core/managers/__init__.py b/core/managers/__init__.py
index 5be6af9..843b996 100644
--- a/core/managers/__init__.py
+++ b/core/managers/__init__.py
@@ -4,7 +4,6 @@
这个包集中了机器人核心的单例管理器。
通过从这里导入,可以确保在整个应用中访问到的都是同一个实例。
"""
-from ..config_loader import global_config
from .admin_manager import AdminManager
from .command_manager import matcher as command_manager
from .permission_manager import PermissionManager
diff --git a/core/managers/permission_manager.py b/core/managers/permission_manager.py
index b7904c3..0e83055 100644
--- a/core/managers/permission_manager.py
+++ b/core/managers/permission_manager.py
@@ -13,7 +13,7 @@
"""
import json
import os
-from typing import Dict, Optional
+from typing import Dict
from ..utils.logger import logger
from ..utils.singleton import Singleton
diff --git a/docs/core-concepts/event-flow.md b/docs/core-concepts/event-flow.md
index 5d7275e..e38f497 100644
--- a/docs/core-concepts/event-flow.md
+++ b/docs/core-concepts/event-flow.md
@@ -8,15 +8,51 @@
```mermaid
graph TD
- A[OneBot v11 实现端] -- WebSocket Message --> B(core/ws.py);
- B -- Raw JSON Data --> C(models/events/factory.py);
- C -- Event Object --> D(core/ws.py on_event);
- D -- Event Object --> E(core/managers/command_manager.py);
- E -- Event & Command Match --> F(core/handlers/event_handler.py);
- F -- Matched Handler --> G(plugins/echo.py);
- G -- Call API --> H(core/bot.py);
- H -- Send Request --> B;
- B -- WebSocket Send --> A;
+ %% 定义样式
+ classDef external fill:#e1f5fe,stroke:#01579b,stroke-width:2px;
+ classDef network fill:#fff9c4,stroke:#fbc02d,stroke-width:2px;
+ classDef core fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
+ classDef plugin fill:#fce4ec,stroke:#c2185b,stroke-width:2px;
+
+ subgraph External [外部环境]
+ OneBot[OneBot v11 实现端
(如 NapCatQQ)]:::external
+ end
+
+ subgraph NeoBot [NEO Bot Framework]
+ direction TB
+
+ subgraph Network [网络接入层]
+ WS[WebSocket 连接
core/ws.py]:::network
+ end
+
+ subgraph Processing [核心处理层]
+ Factory[事件工厂
models/events/factory.py]:::core
+ Dispatcher[命令管理器
core/managers/command_manager.py]:::core
+ Handler[事件处理器
core/handlers/event_handler.py]:::core
+ BotAPI[Bot API 封装
core/bot.py]:::core
+ end
+
+ subgraph Plugins [业务插件层]
+ UserPlugin[用户插件
plugins/*.py]:::plugin
+ end
+ end
+
+ %% 事件上报流程 (实线)
+ OneBot -- 1. WebSocket 消息 --> WS
+ WS -- 2. 原始 JSON --> Factory
+ Factory -- 3. Event 对象 --> WS
+ WS -- 4. 分发事件 --> Dispatcher
+ Dispatcher -- 5. 匹配指令/事件 --> Handler
+ Handler -- 6. 调用处理函数 --> UserPlugin
+
+ %% API 调用流程 (虚线)
+ UserPlugin -. 7. 调用 bot.send() .-> BotAPI
+ BotAPI -. 8. 封装 API 请求 .-> WS
+ WS -. 9. 发送 JSON .-> OneBot
+
+ %% 链接样式
+ linkStyle 0,1,2,3,4,5 stroke:#333,stroke-width:2px;
+ linkStyle 6,7,8 stroke:#666,stroke-width:2px,stroke-dasharray: 5 5;
```
## 详细步骤
diff --git a/docs/core-concepts/singleton-managers.md b/docs/core-concepts/singleton-managers.md
index 41d64b5..5d9541f 100644
--- a/docs/core-concepts/singleton-managers.md
+++ b/docs/core-concepts/singleton-managers.md
@@ -36,7 +36,7 @@
* **核心职责**:
* **权限定义与检查**: 定义了 `ADMIN`, `OP`, `USER` 等权限等级,并提供了 `check_permission` 方法来验证用户权限。
* **数据持久化**: 负责从 `core/data/permissions.json` 文件中加载和保存用户权限设置。
- * **与 `AdminManager` 联动**: 在检查权限时,会自动将机器人管理员(来自 `AdminManager`)识别为最高权限 `ADMIN`。
+ * **与 `AdminManager` 联动**: 在检查权限和获取所有用户权限时,会自动合并机器人管理员(来自 `AdminManager`)的数据,将其识别为最高权限 `ADMIN`。
### 3. `AdminManager` (全局实例: `admin_manager`)
diff --git a/docs/project-structure.md b/docs/project-structure.md
index 4690b15..037360d 100644
--- a/docs/project-structure.md
+++ b/docs/project-structure.md
@@ -40,6 +40,7 @@
* `utils/`: 提供被广泛使用的工具类,如 `logger` (日志)、`singleton` (单例模式基类)。
* `bot.py`: 定义了 `Bot` 类,这是插件开发者最常与之交互的对象,用于调用所有 OneBot API。
* `config_loader.py`: 负责解析 `config.toml` 文件,并提供一个全局的 `global_config` 对象。
+* `config_models.py`: 使用 Pydantic 定义了配置文件的结构和类型验证。
* `ws.py`: 实现了与 OneBot v11 实现端的 WebSocket 连接、心跳、重连和消息收发。
### `docs/`
diff --git a/plugins/admin.py b/plugins/admin.py
index 6dd0c18..e518bb3 100644
--- a/plugins/admin.py
+++ b/plugins/admin.py
@@ -1,4 +1,3 @@
-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
diff --git a/plugins/thpic.py b/plugins/thpic.py
index 2112cf6..0512118 100644
--- a/plugins/thpic.py
+++ b/plugins/thpic.py
@@ -12,7 +12,7 @@ from models.events.message import MessageEvent, MessageSegment
__plugin_meta__ = {
"name": "thpic",
"description": "来看看东方Project的图片吧!",
- "usage": "/thpic",
+ "usage": "/thpic [nums](1~10)",
}
@@ -25,7 +25,38 @@ async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
:param event: 消息事件对象。
:param args: 指令参数列表(未使用)。
"""
- try:
- await event.reply(str(MessageSegment.image("https://img.paulzzh.com/touhou/random")))
- except Exception as e:
- await event.reply(f"报错了。。。{e}")
+ parts = args
+ print(parts)
+ if not parts:
+ try:
+ await event.reply(
+ str(MessageSegment.image("https://img.paulzzh.com/touhou/random"))
+ )
+ except Exception as e:
+ await event.reply(f"报错了。。。{e}")
+ else:
+ if parts[0].isdigit():
+ nums = int(parts[0])
+ if nums <= 0:
+ await event.reply("请输入一个大于0的整数。")
+ return
+ elif nums > 10:
+ await event.reply("请输入一个不大于10的整数。")
+ return
+ try:
+ nodes = []
+ for _ in range(nums):
+ nodes.append(
+ bot.build_forward_node(
+ user_id=event.self_id,
+ nickname="机器人",
+ message=MessageSegment.image(
+ "https://img.paulzzh.com/touhou/random"
+ ),
+ )
+ )
+ await bot.send_forwarded_messages(event, nodes)
+ except Exception as e:
+ await event.reply(f"报错了。。。{e}")
+ else:
+ await event.reply(f"用法不正确。\n\n{__plugin_meta__['usage']}")
diff --git a/tests/test_plugin_reload_meta.py b/tests/test_plugin_reload_meta.py
index 92a9e93..8eb0f81 100644
--- a/tests/test_plugin_reload_meta.py
+++ b/tests/test_plugin_reload_meta.py
@@ -1,6 +1,4 @@
-import pytest
-from unittest.mock import MagicMock
from core.managers.command_manager import CommandManager
class TestPluginReloadMeta:
From 651d982e19a4ac1dbdae654b4bf19dfced303874 Mon Sep 17 00:00:00 2001
From: baby2016 <111368796+baby-2016@users.noreply.github.com>
Date: Sat, 10 Jan 2026 20:39:52 +0800
Subject: [PATCH 36/46] =?UTF-8?q?=E6=9B=B4=E6=96=B0/help=20(#31)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 滚木
* 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: 更新依赖项
* refactor(handler): 移除TYPE_CHECKING并直接导入Bot类
简化类型注解,直接导入Bot类而非使用TYPE_CHECKING条件导入,提高代码可读性和维护性
* fix(command_manager): 修复插件卸载时元信息移除不精确的问题
修复 CommandManager 中 unload_plugin 方法移除插件元信息时使用 startswith 导致可能误删其他插件的问题,改为精确匹配
同时调整相关测试用例验证精确匹配行为
* refactor: 清理未使用的导入和更新文档结构
docs: 添加config_models.py到项目结构文档
docs: 调整数据目录位置到core/data下
docs: 更新权限管理器文档描述
* 文档更新
* 更新thpic插件 支持一次返回多张图
* feat: 添加测试覆盖率并修复相关问题
refactor(redis_manager): 移除冗余的ConnectionError处理
refactor(event_handler): 优化Bot类型注解
refactor(factory): 移除未使用的GroupCardNoticeEvent
test: 添加全面的单元测试覆盖
- 添加test_import.py测试模块导入
- 添加test_debug.py测试插件加载调试
- 添加test_plugin_error.py测试错误处理
- 添加test_config_loader.py测试配置加载
- 添加test_redis_manager.py测试Redis管理
- 添加test_bot.py测试Bot功能
- 扩展test_models.py测试消息模型
- 添加test_plugin_manager_coverage.py测试插件管理
- 添加test_executor.py测试代码执行器
- 添加test_ws.py测试WebSocket
- 添加test_api.py测试API接口
- 添加test_core_managers.py测试核心管理模块
fix(plugin_manager): 修复插件加载日志变量问题
覆盖率已到达86%(忽略插件)
* 更新/help指令,现在会发送图片
---------
Co-authored-by: K2cr2O1 <2221577113@qq.com>
Co-authored-by: 镀铬酸钾 <148796996+K2cr2O1@users.noreply.github.com>
---
core/handlers/event_handler.py | 4 +-
core/managers/command_manager.py | 31 +-
core/managers/help_pic.py | 1 +
core/managers/plugin_manager.py | 6 +-
core/managers/redis_manager.py | 3 -
models/events/factory.py | 9 -
plugins/resource/help.png | Bin 0 -> 51784 bytes
test_debug.py | 33 ++
test_import.py | 24 ++
test_plugin_error.py | 55 +++
tests/test_api.py | 250 +++++++++++++
tests/test_bot.py | 128 +++++++
tests/test_config_loader.py | 126 +++++++
tests/test_core_managers.py | 290 ++++++++++++++++
tests/test_event_factory.py | 483 ++++++++++++++++++++------
tests/test_executor.py | 187 ++++++++++
tests/test_models.py | 109 ++++++
tests/test_plugin_manager_coverage.py | 145 ++++++++
tests/test_redis_manager.py | 138 ++++++++
tests/test_ws.py | 179 ++++++++++
20 files changed, 2077 insertions(+), 124 deletions(-)
create mode 100644 core/managers/help_pic.py
create mode 100644 plugins/resource/help.png
create mode 100644 test_debug.py
create mode 100644 test_import.py
create mode 100644 test_plugin_error.py
create mode 100644 tests/test_api.py
create mode 100644 tests/test_bot.py
create mode 100644 tests/test_config_loader.py
create mode 100644 tests/test_core_managers.py
create mode 100644 tests/test_executor.py
create mode 100644 tests/test_plugin_manager_coverage.py
create mode 100644 tests/test_redis_manager.py
create mode 100644 tests/test_ws.py
diff --git a/core/handlers/event_handler.py b/core/handlers/event_handler.py
index b188eca..44491e2 100644
--- a/core/handlers/event_handler.py
+++ b/core/handlers/event_handler.py
@@ -198,7 +198,7 @@ class NoticeHandler(BaseHandler):
return func
return decorator
- async def handle(self, bot: Bot, event: Any):
+ async def handle(self, bot: "Bot", event: Any):
"""
处理通知事件
"""
@@ -231,7 +231,7 @@ class RequestHandler(BaseHandler):
return func
return decorator
- async def handle(self, bot: Bot, event: Any):
+ async def handle(self, bot: "Bot", event: Any):
"""
处理请求事件
"""
diff --git a/core/managers/command_manager.py b/core/managers/command_manager.py
index da555e7..cb90b79 100644
--- a/core/managers/command_manager.py
+++ b/core/managers/command_manager.py
@@ -5,11 +5,14 @@
它通过装饰器模式,为插件提供了注册消息指令、通知事件处理器和
请求事件处理器的能力。
"""
+
from typing import Any, Callable, Dict, Optional, Tuple
+from models.events.message import MessageSegment
+
from ..config_loader import global_config
from ..handlers.event_handler import MessageHandler, NoticeHandler, RequestHandler
-
+from .help_pic import help_pic
# 从配置中获取命令前缀
_config_prefixes = global_config.bot.command
@@ -40,7 +43,7 @@ class CommandManager:
prefixes (Tuple[str, ...]): 一个包含所有合法命令前缀的元组。
"""
self.plugins: Dict[str, Dict[str, Any]] = {}
-
+
# 初始化专门的事件处理器
self.message_handler = MessageHandler(prefixes)
self.notice_handler = NoticeHandler()
@@ -77,7 +80,7 @@ class CommandManager:
self.notice_handler.clear()
self.request_handler.clear()
self.plugins.clear()
-
+
# 清空后,需要重新注册内置命令
self._register_internal_commands()
@@ -109,7 +112,7 @@ class CommandManager:
self,
*names: str,
permission: Optional[Any] = None,
- override_permission_check: bool = False
+ override_permission_check: bool = False,
) -> Callable:
"""
装饰器:注册一个消息指令处理器。
@@ -117,7 +120,7 @@ class CommandManager:
return self.message_handler.command(
*names,
permission=permission,
- override_permission_check=override_permission_check
+ override_permission_check=override_permission_check,
)
def on_notice(self, notice_type: Optional[str] = None) -> Callable:
@@ -140,8 +143,12 @@ class CommandManager:
根据事件的 `post_type` 将其分发给对应的处理器。
"""
- if event.post_type == 'message' and global_config.bot.ignore_self_message:
- if hasattr(event, 'user_id') and hasattr(event, 'self_id') and event.user_id == event.self_id:
+ if event.post_type == "message" and global_config.bot.ignore_self_message:
+ if (
+ hasattr(event, "user_id")
+ and hasattr(event, "self_id")
+ and event.user_id == event.self_id
+ ):
return
handler = self.handler_map.get(event.post_type)
@@ -155,19 +162,19 @@ class CommandManager:
内置的 `/help` 命令的实现。
"""
help_text = "--- 可用指令列表 ---\n"
-
+
for plugin_name, meta in self.plugins.items():
name = meta.get("name", "未命名插件")
description = meta.get("description", "暂无描述")
usage = meta.get("usage", "暂无用法说明")
-
+
help_text += f"\n{name}:\n"
help_text += f" 功能: {description}\n"
help_text += f" 用法: {usage}\n"
-
- await bot.send(event, help_text.strip())
+
+ await bot.send(event, MessageSegment.image(help_pic))
+ # await bot.send(event, help_text.strip())
# 实例化全局唯一的命令管理器
matcher = CommandManager(prefixes=_final_prefixes)
-
diff --git a/core/managers/help_pic.py b/core/managers/help_pic.py
new file mode 100644
index 0000000..b3d9920
--- /dev/null
+++ b/core/managers/help_pic.py
@@ -0,0 +1 @@
+help_pic = """data:image/png;base64,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"""
diff --git a/core/managers/plugin_manager.py b/core/managers/plugin_manager.py
index a287527..e1f66ed 100644
--- a/core/managers/plugin_manager.py
+++ b/core/managers/plugin_manager.py
@@ -12,6 +12,9 @@ from typing import Set
from ..utils.exceptions import SyncHandlerError
from ..utils.logger import logger
+# 确保logger在模块级别可见
+__all__ = ['PluginManager', 'logger']
+
class PluginManager:
"""
@@ -49,6 +52,7 @@ class PluginManager:
for _, module_name, is_pkg in pkgutil.iter_modules([plugin_dir]):
full_module_name = f"{package_name}.{module_name}"
+ action = "加载" # 初始化默认值
try:
if full_module_name in self.loaded_plugins:
self.command_manager.unload_plugin(full_module_name)
@@ -70,7 +74,7 @@ class PluginManager:
logger.error(f" 插件 {module_name} 加载失败: {e} (跳过此插件)")
except Exception as e:
logger.exception(
- f" {action if 'action' in locals() else '加载'}插件 {module_name} 失败: {e}"
+ f" 加载插件 {module_name} 失败: {e}"
)
def reload_plugin(self, full_module_name: str):
diff --git a/core/managers/redis_manager.py b/core/managers/redis_manager.py
index a6bcff3..7685bc2 100644
--- a/core/managers/redis_manager.py
+++ b/core/managers/redis_manager.py
@@ -39,9 +39,6 @@ class RedisManager:
logger.success("Redis 连接成功!")
else:
logger.error("Redis 连接失败: PING 命令无响应")
- except redis.exceptions.ConnectionError as e:
- logger.error(f"Redis 连接失败: {e}")
- self._redis = None
except Exception as e:
logger.exception(f"Redis 初始化时发生未知错误: {e}")
self._redis = None
diff --git a/models/events/factory.py b/models/events/factory.py
index 7eb4e9f..271695d 100644
--- a/models/events/factory.py
+++ b/models/events/factory.py
@@ -256,15 +256,6 @@ class EventFactory:
card_new=data.get("card_new", ""),
card_old=data.get("card_old", "")
)
- elif notice_type == "group_card":
- return GroupCardNoticeEvent(
- **common_args,
- notice_type=notice_type,
- group_id=data.get("group_id", 0),
- user_id=data.get("user_id", 0),
- card_new=data.get("card_new", ""),
- card_old=data.get("card_old", "")
- )
elif notice_type == "offline_file":
file_data = data.get("file", {})
offline_file = OfflineFile(
diff --git a/plugins/resource/help.png b/plugins/resource/help.png
new file mode 100644
index 0000000000000000000000000000000000000000..f96d5ded712490eac353f749fcad8d787e3b2cfc
GIT binary patch
literal 51784
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%0DKS>=-OjOnbTQagh`ln?T}Vym5eJf?;BR?sy$
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