Merge branch 'main' of https://github.com/Fairy-Oracle-Sanctuary/NeoBot
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -139,3 +139,12 @@ dmypy.json
|
|||||||
.pytype/
|
.pytype/
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/python
|
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||||
|
/ca
|
||||||
|
# Build artifacts
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Scratch files
|
||||||
|
scratch_files/
|
||||||
|
|
||||||
|
/config.toml
|
||||||
|
/core/data/TEMP/*
|
||||||
8
LICENSE
Normal file
8
LICENSE
Normal file
@@ -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.
|
||||||
576
README.md
576
README.md
@@ -1,521 +1,77 @@
|
|||||||
# NEO Bot Framework
|
# 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. **热重载支持**:支持插件热重载,开发过程中修改代码无需重启机器人
|
|
||||||
|
|
||||||
### 核心价值
|
### 核心特性
|
||||||
|
|
||||||
- **快速原型开发**:通过简洁的装饰器语法快速定义指令和事件处理器
|
* **模块化插件架构**:所有功能都在 `plugins/` 目录
|
||||||
- **生产环境就绪**:内置断线重连、错误处理和性能监控机制
|
* **极致性能优化**:
|
||||||
- **可扩展架构**:支持自定义插件、中间件和权限系统
|
* **Python 3.14 JIT**:pypy不支持那个浏览器扩展我只能用JIT了。。。
|
||||||
- **现代化开发体验**:支持热重载、类型提示和完整的 API 文档
|
* **Mypyc 编译**:一些核心模块已经编译成机器码了
|
||||||
|
* **Playwright 页面池**:浏览器页面预热池
|
||||||
|
* **全局连接复用**:HTTP 和 Redis 连接池化管理
|
||||||
|
* **开发者友好**:完整的类型提示,清晰的 API 设计。
|
||||||
|
* **集成 Redis 缓存**:能缓存的都缓存了。群信息、用户信息、帮助图片
|
||||||
|
* **正向 WebSocket 连接**:我只会支持正向WS连接。。。不要提意见,我不会听的。。。
|
||||||
|
|
||||||
### 适用场景
|
### 技术栈
|
||||||
|
|
||||||
- QQ 群机器人管理
|
* **核心框架**: Python 3.14 JIT & NEO Bot Framework
|
||||||
- 自动化客服与问答系统
|
* **编译器**: Mypyc
|
||||||
- 游戏社区管理
|
* **异步核心**: `asyncio` + `uvloop` (Linux) / 原生 Loop (Windows)
|
||||||
- 团队内部工具集成
|
* **网络通信**: `websockets` (OneBot v11), `aiohttp` (Shared Session)
|
||||||
- 教育与培训辅助
|
* **浏览器引擎**: `Playwright` (Chromium) + Page Pool
|
||||||
|
* **数据序列化**: `orjson`
|
||||||
## ✨ 特性
|
* **缓存**: `Redis`
|
||||||
|
* **日志**: `Loguru`
|
||||||
* **OneBot 11 标准支持**:完整支持 OneBot 11 的消息、通知、请求和元事件。
|
|
||||||
* **类型安全**:基于 `dataclasses` 的强类型事件模型,开发体验更佳。
|
|
||||||
* **插件系统**:轻量级的装饰器风格插件系统,支持指令 (`@matcher.command`) 和事件监听 (`@matcher.on_notice`, `@matcher.on_request`)。
|
|
||||||
* **插件元数据与内置帮助**:插件可通过 `__plugin_meta__` 变量进行自我描述。框架核心内置了 `/help` 指令,可自动收集并展示所有插件的帮助信息,无需手动维护。
|
|
||||||
* **🔥 热重载支持**:内置文件监控,修改 `base_plugins` 下的代码自动重载,无需重启,极大提升调试效率。
|
|
||||||
* **异步核心**:基于 `asyncio` 和 `websockets` 的高性能异步核心。
|
|
||||||
* **自动重连**:内置 WebSocket 断线重连机制。
|
|
||||||
|
|
||||||
## 📝 待办事项 (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`
|
|
||||||
- [ ] **扩展功能**
|
|
||||||
- `send_forward_msg`: 发送合并转发消息
|
|
||||||
|
|
||||||
### 其他改进
|
|
||||||
- [x] **API 强类型封装**: 将 API 返回值从 `dict` 转换为数据模型对象。
|
|
||||||
- [ ] **日志系统优化**: 引入更完善的日志记录机制,支持文件输出和日志级别控制。
|
|
||||||
- [ ] **异常处理增强**: 增强插件执行过程中的异常捕获,防止单个插件崩溃影响整个 Bot。
|
|
||||||
- [ ] **中间件支持**: 添加消息处理中间件,支持在指令执行前/后进行拦截和处理。
|
|
||||||
- [ ] **权限系统**: 实现基础的权限管理(如超级管理员、群管理员等)。
|
|
||||||
|
|
||||||
## 📂 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
NEO/
|
|
||||||
├── plugins/ # 插件目录,新建插件文件即可自动加载(支持热重载)
|
|
||||||
│ └── echo.py # 示例插件:实现 /echo 和 /赞我 指令
|
|
||||||
├── 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 # 插件加载与管理
|
|
||||||
│ └── ws.py # WebSocket 客户端核心
|
|
||||||
├── models/ # 数据模型
|
|
||||||
│ ├── events/ # OneBot 事件定义 (Message, Notice, Request, Meta)
|
|
||||||
│ ├── message.py # 消息段定义 (MessageSegment)
|
|
||||||
│ └── sender.py # 发送者定义 (Sender)
|
|
||||||
├── config.toml # 配置文件
|
|
||||||
├── main.py # 启动入口(包含热重载监控)
|
|
||||||
└── requirements.txt # 项目依赖
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 快速开始
|
|
||||||
|
|
||||||
### 1. 环境准备
|
|
||||||
|
|
||||||
* Python 3.8+
|
|
||||||
* OneBot 11 实现端(推荐 [NapCatQQ](https://github.com/NapNeko/NapCatQQ) 或 LLOneBot)
|
|
||||||
|
|
||||||
### 2. 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 配置文件
|
|
||||||
|
|
||||||
修改根目录下的 `config.toml`,配置 WebSocket 连接信息:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[napcat_ws]
|
|
||||||
uri = "ws://127.0.0.1:30004" # OneBot 实现端的 WebSocket 地址
|
|
||||||
token = "your_token" # Access Token (如果有)
|
|
||||||
reconnect_interval = 5 # 断线重连间隔(秒)
|
|
||||||
|
|
||||||
[bot]
|
|
||||||
command = ["/"] # 指令前缀,支持多个,如 ["/", "#"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 运行
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛠️ 开发指南
|
|
||||||
|
|
||||||
### 🔥 热重载调试
|
|
||||||
|
|
||||||
项目集成了 `watchdog` 文件监控。在开发过程中,你只需要:
|
|
||||||
1. 保持 `main.py` 运行。
|
|
||||||
2. 修改或新建 `base_plugins` 目录下的 `.py` 插件文件。
|
|
||||||
3. 保存文件。
|
|
||||||
4. 控制台会自动提示 `[HotReload] 插件重载完成`,新的逻辑立即生效。
|
|
||||||
|
|
||||||
### 创建新插件
|
|
||||||
|
|
||||||
在 `base_plugins` 目录下创建一个新的 `.py` 文件(例如 `my_plugin.py`),框架会自动加载它。
|
|
||||||
|
|
||||||
### 示例代码
|
|
||||||
|
|
||||||
#### 1. 注册消息指令
|
|
||||||
|
|
||||||
使用 `@matcher.command("指令名")` 注册指令。
|
|
||||||
|
|
||||||
```python
|
|
||||||
from core.command_manager import matcher
|
|
||||||
from core.bot import Bot
|
|
||||||
from models import MessageEvent
|
|
||||||
|
|
||||||
# 注册 /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
|
|
||||||
# base_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)}")
|
|
||||||
# 记录日志
|
|
||||||
import logging
|
|
||||||
logging.error(f"插件执行错误:{e}", exc_info=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 插件开发最佳实践
|
|
||||||
1. **单一职责**:每个插件专注于一个功能领域
|
|
||||||
2. **错误处理**:妥善处理可能发生的异常
|
|
||||||
3. **类型提示**:为函数参数和返回值添加类型提示
|
|
||||||
4. **文档完整**:为每个函数添加文档字符串
|
|
||||||
5. **性能考虑**:避免在插件中执行耗时同步操作
|
|
||||||
6. **资源清理**:必要时使用 `try...finally` 确保资源释放
|
|
||||||
|
|
||||||
### 示例:完整插件模板
|
|
||||||
```python
|
|
||||||
"""
|
|
||||||
天气查询插件
|
|
||||||
|
|
||||||
提供 /weather 指令,查询指定城市的天气信息。
|
|
||||||
"""
|
|
||||||
from core.command_manager import matcher
|
|
||||||
from core.bot import Bot
|
|
||||||
from models import MessageEvent
|
|
||||||
|
|
||||||
# 插件元数据,用于 help 指令
|
|
||||||
__plugin_meta__ = {
|
|
||||||
"name": "天气查询",
|
|
||||||
"description": "查询指定城市的天气信息",
|
|
||||||
"usage": "/weather [城市名称]",
|
|
||||||
}
|
|
||||||
|
|
||||||
@matcher.command("weather")
|
|
||||||
async def handle_weather(bot: Bot, event: MessageEvent, args: list[str]):
|
|
||||||
"""
|
|
||||||
查询天气信息
|
|
||||||
|
|
||||||
:param bot: Bot 实例
|
|
||||||
:param event: 消息事件对象
|
|
||||||
:param args: 指令参数列表(城市名称)
|
|
||||||
"""
|
|
||||||
if not args:
|
|
||||||
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} 加入!")
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📚 事件模型说明
|
|
||||||
|
|
||||||
项目采用了基于工厂模式的事件处理系统,所有事件定义在 `models/events/` 下:
|
|
||||||
|
|
||||||
* **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) │
|
|
||||||
│ • 连接管理 │
|
|
||||||
│ • 消息编解码 │
|
|
||||||
│ • 断线重连 │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 设计模式应用
|
|
||||||
|
|
||||||
- **工厂模式**:事件对象的创建和管理
|
|
||||||
- **装饰器模式**:插件和指令的注册
|
|
||||||
- **组合模式**:Bot 类通过继承组合 API 功能
|
|
||||||
- **观察者模式**:事件监听和处理
|
|
||||||
- **策略模式**:不同的消息处理策略
|
|
||||||
|
|
||||||
### 性能特点
|
|
||||||
|
|
||||||
- **异步非阻塞**:全面基于 asyncio,支持高并发
|
|
||||||
- **内存高效**:事件和模型对象使用 dataclasses,内存占用小
|
|
||||||
- **快速响应**:插件热重载和事件分发机制确保快速响应
|
|
||||||
- **可扩展性**:模块化设计便于功能扩展和定制
|
|
||||||
|
|
||||||
---
|
---
|
||||||
*Internal Use Only - DOGSOHA ond baby2016 by Fairy-Oracle-Sanctuary*
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── plugins/ # 插件目录,业务逻辑都在这
|
||||||
|
│ ├── admin.py # 管理员指令
|
||||||
|
│ ├── bili_parser.py # B站解析 (高性能版)
|
||||||
|
│ ├── code_py.py # 代码沙箱
|
||||||
|
│ ├── echo.py # 复读机
|
||||||
|
│ ├── forward_test.py # 合并转发测试
|
||||||
|
│ ├── jrcd.py # 今日运势
|
||||||
|
│ └── thpic.py # 东方图片
|
||||||
|
├── core/ # 框架核心,非请勿动
|
||||||
|
│ ├── api/ # OneBot API 封装
|
||||||
|
│ ├── managers/ # 各种管理器 (指令, 浏览器, 图片, 插件)
|
||||||
|
│ ├── utils/ # 工具函数
|
||||||
|
│ ├── ws.py # WebSocket 通信层 (已编译)
|
||||||
|
│ └── bot.py # Bot 实例
|
||||||
|
├── data/ # 数据存储
|
||||||
|
│ ├── admin.json # 管理员名单
|
||||||
|
│ └── permissions.json # 权限配置
|
||||||
|
├── templates/ # Jinja2 模板
|
||||||
|
├── setup_mypyc.py # 编译脚本
|
||||||
|
└── main.py # 启动入口
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
1
|
||||||
|
|
||||||
|
|
||||||
|
1. **装环境**: Python 3.14,Redis, OneBot 客户端 (推荐 NapCat)。
|
||||||
|
2. **装依赖**: `pip install -r requirements.txt`
|
||||||
|
3. **装浏览器**: `playwright install chromium`
|
||||||
|
4. **编译核心 (可选)**: `python setup_mypyc.py build_ext --inplace`
|
||||||
|
5. **启动**: `python -X jit main.py`
|
||||||
|
|
||||||
|
详细文档去 `docs/` 目录看
|
||||||
|
|||||||
70
check_syntax.py
Normal file
70
check_syntax.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
检查项目中所有Python文件的语法
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def check_python_syntax(file_path):
|
||||||
|
"""
|
||||||
|
检查单个Python文件的语法
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Python文件路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 如果语法正确返回True,否则返回False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
code = f.read()
|
||||||
|
|
||||||
|
# 使用compile函数检查语法
|
||||||
|
compile(code, file_path, 'exec')
|
||||||
|
return True
|
||||||
|
except SyntaxError as e:
|
||||||
|
print(f"语法错误: {file_path}:{e.lineno}:{e.offset}: {e.msg}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"无法检查文件 {file_path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
检查项目中所有Python文件的语法
|
||||||
|
"""
|
||||||
|
# 要检查的目录
|
||||||
|
directories = ['core', 'models', 'plugins', 'scripts', 'tests']
|
||||||
|
|
||||||
|
# 要检查的单独文件
|
||||||
|
files = ['main.py', 'profile_main.py', 'test_performance_simple.py', 'setup_mypyc.py']
|
||||||
|
|
||||||
|
error_count = 0
|
||||||
|
file_count = 0
|
||||||
|
|
||||||
|
# 检查目录中的所有Python文件
|
||||||
|
for directory in directories:
|
||||||
|
for root, _, filenames in os.walk(directory):
|
||||||
|
for filename in filenames:
|
||||||
|
if filename.endswith('.py'):
|
||||||
|
file_path = os.path.join(root, filename)
|
||||||
|
file_count += 1
|
||||||
|
if not check_python_syntax(file_path):
|
||||||
|
error_count += 1
|
||||||
|
|
||||||
|
# 检查单独的Python文件
|
||||||
|
for file in files:
|
||||||
|
if os.path.exists(file):
|
||||||
|
file_count += 1
|
||||||
|
if not check_python_syntax(file):
|
||||||
|
error_count += 1
|
||||||
|
|
||||||
|
print(f"\n检查完成: {file_count} 个文件,{error_count} 个语法错误")
|
||||||
|
|
||||||
|
if error_count > 0:
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
[napcat_ws]
|
|
||||||
uri = "ws://114.66.58.203:3001"
|
|
||||||
token = "&d_VTfksE%}ul?_Y"
|
|
||||||
reconnect_interval = 5
|
|
||||||
|
|
||||||
[bot]
|
|
||||||
command = ["/"]
|
|
||||||
231
core/WS.py
231
core/WS.py
@@ -1,77 +1,125 @@
|
|||||||
"""
|
"""
|
||||||
WebSocket 核心模块
|
WebSocket 核心通信模块
|
||||||
|
|
||||||
负责与 OneBot 实现端建立 WebSocket 连接,处理消息接收、事件分发和 API 调用。
|
该模块定义了 `WS` 类,负责与 OneBot v11 实现(如 NapCat)建立和管理
|
||||||
|
WebSocket 连接。它是整个机器人框架的底层通信基础。
|
||||||
|
|
||||||
|
主要职责包括:
|
||||||
|
- 建立 WebSocket 连接并处理认证。
|
||||||
|
- 实现断线自动重连机制。
|
||||||
|
- 监听并接收来自 OneBot 的事件和 API 响应。
|
||||||
|
- 分发事件给 `CommandManager` 进行处理。
|
||||||
|
- 提供 `call_api` 方法,用于异步发送 API 请求并等待响应。
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import traceback
|
from typing import Any, Dict, Optional, cast
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
|
from websockets.legacy.client import WebSocketClientProtocol
|
||||||
|
|
||||||
from models import EventFactory
|
from models.events.factory import EventFactory
|
||||||
|
|
||||||
from .bot import Bot
|
from .bot import Bot
|
||||||
from .command_manager import matcher
|
|
||||||
from .config_loader import global_config
|
from .config_loader import global_config
|
||||||
|
from .managers.command_manager import matcher
|
||||||
|
from .utils.executor import CodeExecutor
|
||||||
|
from .utils.logger import logger, ModuleLogger
|
||||||
|
from .utils.exceptions import (
|
||||||
|
WebSocketError, WebSocketConnectionError, WebSocketAuthenticationError
|
||||||
|
)
|
||||||
|
from .utils.error_codes import ErrorCode, create_error_response
|
||||||
|
|
||||||
|
|
||||||
class WS:
|
class WS:
|
||||||
"""
|
"""
|
||||||
WebSocket 客户端类,负责与 OneBot 实现端建立连接并处理通信
|
WebSocket 客户端,负责与 OneBot v11 实现进行底层通信。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, code_executor: Optional[CodeExecutor] = None) -> None:
|
||||||
"""
|
"""
|
||||||
初始化 WebSocket 客户端
|
初始化 WebSocket 客户端。
|
||||||
|
|
||||||
|
从全局配置中读取 WebSocket URI、访问令牌(Token)和重连间隔。
|
||||||
"""
|
"""
|
||||||
# 读取参数
|
# 读取参数
|
||||||
cfg = global_config.napcat_ws
|
cfg = global_config.napcat_ws
|
||||||
self.url = cfg.get("uri")
|
self.url = cfg.uri
|
||||||
self.token = cfg.get("token")
|
self.token = cfg.token
|
||||||
self.reconnect_interval = cfg.get("reconnect_interval", 5)
|
self.reconnect_interval = cfg.reconnect_interval
|
||||||
|
|
||||||
self.ws = None
|
# 初始化状态
|
||||||
self._pending_requests = {}
|
self.ws: Optional[WebSocketClientProtocol] = None
|
||||||
self.bot = Bot(self)
|
self._pending_requests: Dict[str, asyncio.Future] = {} # echo: future
|
||||||
|
self.bot: Bot | None = None
|
||||||
|
self.self_id: int | None = None
|
||||||
|
self.code_executor = code_executor
|
||||||
|
|
||||||
async def connect(self):
|
# 创建模块专用日志记录器
|
||||||
|
self.logger = ModuleLogger("WebSocket")
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
"""
|
"""
|
||||||
主连接循环,负责建立连接和自动重连
|
启动并管理 WebSocket 连接。
|
||||||
|
|
||||||
|
这是一个无限循环,负责建立连接。如果连接断开,它会根据配置的
|
||||||
|
`reconnect_interval` 时间间隔后自动尝试重新连接。
|
||||||
"""
|
"""
|
||||||
headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
print(f" 正在尝试连接至 NapCat: {self.url}")
|
self.logger.info(f"正在尝试连接至 NapCat: {self.url}")
|
||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
self.url, additional_headers=headers
|
self.url, additional_headers=headers
|
||||||
) as websocket:
|
) as websocket_raw:
|
||||||
|
websocket = cast(WebSocketClientProtocol, websocket_raw)
|
||||||
self.ws = websocket
|
self.ws = websocket
|
||||||
print(" 连接成功!")
|
self.logger.success("连接成功!")
|
||||||
await self._listen_loop(websocket)
|
await self._listen_loop(websocket)
|
||||||
|
|
||||||
|
except websockets.exceptions.AuthenticationError as e:
|
||||||
|
error = WebSocketAuthenticationError(
|
||||||
|
message=f"WebSocket认证失败: {str(e)}",
|
||||||
|
code=ErrorCode.WS_AUTH_FAILED,
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
self.logger.error(f"连接失败: {error.message}")
|
||||||
|
self.logger.log_custom_exception(error)
|
||||||
except (
|
except (
|
||||||
websockets.exceptions.ConnectionClosed,
|
websockets.exceptions.ConnectionClosed,
|
||||||
ConnectionRefusedError,
|
ConnectionRefusedError,
|
||||||
) as e:
|
) as e:
|
||||||
print(f" 连接断开或服务器拒绝访问: {e}")
|
error = WebSocketConnectionError(
|
||||||
|
message=f"连接断开或服务器拒绝访问: {str(e)}",
|
||||||
|
code=ErrorCode.WS_CONNECTION_FAILED,
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
self.logger.warning(f"连接失败: {error.message}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" 运行异常: {e}")
|
error = WebSocketError(
|
||||||
traceback.print_exc()
|
message=f"WebSocket运行异常: {str(e)}",
|
||||||
|
code=ErrorCode.WS_MESSAGE_ERROR,
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
self.logger.exception(f"运行异常: {error.message}")
|
||||||
|
self.logger.log_custom_exception(error)
|
||||||
|
|
||||||
print(f" {self.reconnect_interval}秒后尝试重连...")
|
self.logger.info(f"{self.reconnect_interval}秒后尝试重连...")
|
||||||
await asyncio.sleep(self.reconnect_interval)
|
await asyncio.sleep(self.reconnect_interval)
|
||||||
|
|
||||||
async def _listen_loop(self, websocket):
|
async def _listen_loop(self, websocket_connection: WebSocketClientProtocol) -> None:
|
||||||
"""
|
"""
|
||||||
核心监听循环,处理接收到的 WebSocket 消息
|
核心监听循环,处理所有接收到的 WebSocket 消息。
|
||||||
|
|
||||||
:param websocket: WebSocket 连接对象
|
此循环会持续从 WebSocket 连接中读取消息,并根据消息内容
|
||||||
|
判断是 API 响应还是上报的事件,然后分发给相应的处理逻辑。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
websocket_connection: 当前活动的 WebSocket 连接对象。
|
||||||
"""
|
"""
|
||||||
async for message in websocket:
|
async for message in websocket_connection:
|
||||||
try:
|
try:
|
||||||
data = json.loads(message)
|
data = json.loads(message)
|
||||||
|
|
||||||
@@ -90,53 +138,121 @@ class WS:
|
|||||||
# 使用 create_task 异步执行,避免阻塞 WebSocket 接收循环
|
# 使用 create_task 异步执行,避免阻塞 WebSocket 接收循环
|
||||||
asyncio.create_task(self.on_event(data))
|
asyncio.create_task(self.on_event(data))
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
error = WebSocketError(
|
||||||
|
message=f"JSON解析失败: {str(e)}",
|
||||||
|
code=ErrorCode.WS_MESSAGE_ERROR,
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
self.logger.error(f"解析消息异常: {error.message}")
|
||||||
|
self.logger.debug(f"原始消息: {message}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" 解析消息异常: {e}")
|
error = WebSocketError(
|
||||||
traceback.print_exc()
|
message=f"处理消息异常: {str(e)}",
|
||||||
|
code=ErrorCode.WS_MESSAGE_ERROR,
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
self.logger.exception(f"解析消息异常: {error.message}")
|
||||||
|
self.logger.log_custom_exception(error)
|
||||||
|
|
||||||
async def on_event(self, raw_data: dict):
|
async def on_event(self, event_data: Dict[str, Any]) -> None:
|
||||||
"""
|
"""
|
||||||
事件分发层:根据 post_type 调用 matcher 对应的处理器
|
事件处理和分发层。
|
||||||
|
|
||||||
:param raw_data: 原始事件数据字典
|
当接收到一个 OneBot 事件时,此方法负责:
|
||||||
|
1. 使用 `EventFactory` 将原始 JSON 数据解析成对应的事件对象。
|
||||||
|
2. 为事件对象注入 `Bot` 实例,以便在插件中可以调用 API。
|
||||||
|
3. 打印格式化的事件日志。
|
||||||
|
4. 将事件对象传递给 `CommandManager` (`matcher`) 进行后续处理。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_data (dict): 从 WebSocket 接收到的原始事件字典。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 使用工厂创建事件对象
|
# 使用工厂创建事件对象
|
||||||
event = EventFactory.create_event(raw_data)
|
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)
|
||||||
|
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
|
||||||
|
self.logger.info("代码执行器已成功注入 Bot 实例。")
|
||||||
|
|
||||||
|
# 如果 bot 尚未初始化,则不处理后续事件
|
||||||
|
if self.bot is None:
|
||||||
|
self.logger.warning("Bot 尚未初始化,跳过事件处理。")
|
||||||
|
return
|
||||||
|
|
||||||
event.bot = self.bot # 注入 Bot 实例
|
event.bot = self.bot # 注入 Bot 实例
|
||||||
|
|
||||||
# 打印日志
|
# 打印日志
|
||||||
t = datetime.fromtimestamp(event.time).strftime("%H:%M:%S")
|
|
||||||
if event.post_type == "message":
|
if event.post_type == "message":
|
||||||
sender_name = event.sender.nickname if event.sender else "Unknown"
|
sender_name = event.sender.nickname if hasattr(event, "sender") and event.sender else "Unknown"
|
||||||
print(f" [{t}] [消息] {event.message_type} | {event.user_id}({sender_name}): {event.raw_message}")
|
message_type = getattr(event, "message_type", "Unknown")
|
||||||
|
user_id = getattr(event, "user_id", "Unknown")
|
||||||
|
raw_message = getattr(event, "raw_message", "")
|
||||||
|
self.logger.info(f"[消息] {message_type} | {user_id}({sender_name}): {raw_message}")
|
||||||
elif event.post_type == "notice":
|
elif event.post_type == "notice":
|
||||||
print(f" [{t}] [通知] {event.notice_type}")
|
notice_type = getattr(event, "notice_type", "Unknown")
|
||||||
|
self.logger.info(f"[通知] {notice_type}")
|
||||||
elif event.post_type == "request":
|
elif event.post_type == "request":
|
||||||
print(f" [{t}] [请求] {event.request_type}")
|
request_type = getattr(event, "request_type", "Unknown")
|
||||||
|
self.logger.info(f"[请求] {request_type}")
|
||||||
|
elif event.post_type == "meta_event":
|
||||||
|
meta_event_type = getattr(event, "meta_event_type", "Unknown")
|
||||||
|
self.logger.debug(f"[元事件] {meta_event_type}")
|
||||||
|
|
||||||
# 分发事件
|
# 分发事件
|
||||||
await matcher.handle_event(self.bot, event)
|
await matcher.handle_event(self.bot, event)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" 事件处理异常: {e}")
|
self.logger.exception(f"事件处理异常: {str(e)}")
|
||||||
traceback.print_exc()
|
error = WebSocketError(
|
||||||
|
message=f"事件处理异常: {str(e)}",
|
||||||
|
code=ErrorCode.WS_MESSAGE_ERROR,
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
self.logger.log_custom_exception(error)
|
||||||
|
|
||||||
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 API
|
向 OneBot v11 实现端发送一个 API 请求。
|
||||||
|
|
||||||
:param action: API 动作名称
|
该方法通过 WebSocket 发送请求,并使用 `echo` 字段来匹配对应的响应。
|
||||||
:param params: API 参数
|
它创建了一个 `Future` 对象来异步等待响应,并设置了超时机制。
|
||||||
:return: API 响应结果
|
|
||||||
|
Args:
|
||||||
|
action (str): API 的动作名称,例如 "send_group_msg"。
|
||||||
|
params (dict, optional): API 请求的参数字典。 Defaults to None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: OneBot API 的响应数据。如果超时或连接断开,则返回一个
|
||||||
|
表示失败的字典。
|
||||||
"""
|
"""
|
||||||
if not self.ws:
|
if not self.ws:
|
||||||
return {"status": "failed", "msg": "websocket not initialized"}
|
self.logger.error("调用 API 失败: WebSocket 未初始化")
|
||||||
|
return create_error_response(
|
||||||
|
code=ErrorCode.WS_DISCONNECTED,
|
||||||
|
message="WebSocket未初始化",
|
||||||
|
data={"action": action, "params": params}
|
||||||
|
)
|
||||||
|
|
||||||
from websockets.protocol import State
|
from websockets.protocol import State
|
||||||
|
|
||||||
if getattr(self.ws, "state", None) is not State.OPEN:
|
if getattr(self.ws, "state", None) is not State.OPEN:
|
||||||
return {"status": "failed", "msg": "websocket is not open"}
|
self.logger.error("调用 API 失败: WebSocket 连接未打开")
|
||||||
|
return create_error_response(
|
||||||
|
code=ErrorCode.WS_DISCONNECTED,
|
||||||
|
message="WebSocket连接未打开",
|
||||||
|
data={"action": action, "params": params}
|
||||||
|
)
|
||||||
|
|
||||||
echo_id = str(uuid.uuid4())
|
echo_id = str(uuid.uuid4())
|
||||||
payload = {"action": action, "params": params or {}, "echo": echo_id}
|
payload = {"action": action, "params": params or {}, "echo": echo_id}
|
||||||
@@ -145,10 +261,23 @@ class WS:
|
|||||||
future = loop.create_future()
|
future = loop.create_future()
|
||||||
self._pending_requests[echo_id] = future
|
self._pending_requests[echo_id] = future
|
||||||
|
|
||||||
await self.ws.send(json.dumps(payload))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
await self.ws.send(json.dumps(payload))
|
||||||
return await asyncio.wait_for(future, timeout=30.0)
|
return await asyncio.wait_for(future, timeout=30.0)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
self._pending_requests.pop(echo_id, None)
|
self._pending_requests.pop(echo_id, None)
|
||||||
return {"status": "failed", "retcode": -1, "msg": "api timeout"}
|
self.logger.warning(f"API 调用超时: action={action}, params={params}")
|
||||||
|
return create_error_response(
|
||||||
|
code=ErrorCode.TIMEOUT_ERROR,
|
||||||
|
message="API调用超时",
|
||||||
|
data={"action": action, "params": params}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self._pending_requests.pop(echo_id, None)
|
||||||
|
self.logger.exception(f"API 调用异常: action={action}, error={str(e)}")
|
||||||
|
return create_error_response(
|
||||||
|
code=ErrorCode.WS_MESSAGE_ERROR,
|
||||||
|
message=f"API调用异常: {str(e)}",
|
||||||
|
data={"action": action, "params": params}
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
from .command_manager import matcher
|
|
||||||
from .config_loader import global_config
|
|
||||||
from .plugin_manager import PluginDataManager
|
|
||||||
from .ws import WS
|
|
||||||
|
|
||||||
__all__ = ["WS", "matcher", "global_config", "PluginDataManager"]
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from .message import MessageAPI
|
|||||||
from .group import GroupAPI
|
from .group import GroupAPI
|
||||||
from .friend import FriendAPI
|
from .friend import FriendAPI
|
||||||
from .account import AccountAPI
|
from .account import AccountAPI
|
||||||
|
from .media import MediaAPI
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseAPI",
|
"BaseAPI",
|
||||||
@@ -10,4 +11,5 @@ __all__ = [
|
|||||||
"GroupAPI",
|
"GroupAPI",
|
||||||
"FriendAPI",
|
"FriendAPI",
|
||||||
"AccountAPI",
|
"AccountAPI",
|
||||||
|
"MediaAPI",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,78 +1,106 @@
|
|||||||
"""
|
"""
|
||||||
账号相关 API 模块
|
账号与状态相关 API 模块
|
||||||
|
|
||||||
|
该模块定义了 `AccountAPI` Mixin 类,提供了所有与机器人自身账号信息、
|
||||||
|
状态设置等相关的 OneBot v11 API 封装。
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from .base import BaseAPI
|
from .base import BaseAPI
|
||||||
from models.objects import LoginInfo, VersionInfo, Status
|
from models.objects import LoginInfo, VersionInfo, Status
|
||||||
|
from ..managers.redis_manager import redis_manager
|
||||||
|
|
||||||
|
|
||||||
class AccountAPI(BaseAPI):
|
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")
|
res = await self.call_api("get_login_info")
|
||||||
|
await redis_manager.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
|
||||||
return LoginInfo(**res)
|
return LoginInfo(**res)
|
||||||
|
|
||||||
async def get_version_info(self) -> VersionInfo:
|
async def get_version_info(self) -> VersionInfo:
|
||||||
"""
|
"""
|
||||||
获取版本信息
|
获取 OneBot v11 实现的版本信息。
|
||||||
|
|
||||||
:return: 版本信息对象
|
Returns:
|
||||||
|
VersionInfo: 包含 OneBot 实现版本信息的 `VersionInfo` 数据对象。
|
||||||
"""
|
"""
|
||||||
res = await self.call_api("get_version_info")
|
res = await self.call_api("get_version_info")
|
||||||
return VersionInfo(**res)
|
return VersionInfo(**res)
|
||||||
|
|
||||||
async def get_status(self) -> Status:
|
async def get_status(self) -> Status:
|
||||||
"""
|
"""
|
||||||
获取状态
|
获取 OneBot v11 实现的状态信息。
|
||||||
|
|
||||||
:return: 状态对象
|
Returns:
|
||||||
|
Status: 包含 OneBot 状态信息的 `Status` 数据对象。
|
||||||
"""
|
"""
|
||||||
res = await self.call_api("get_status")
|
res = await self.call_api("get_status")
|
||||||
return Status(**res)
|
return Status(**res)
|
||||||
|
|
||||||
async def bot_exit(self) -> Dict[str, Any]:
|
async def bot_exit(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
退出机器人
|
让机器人进程退出(需要实现端支持)。
|
||||||
|
|
||||||
:return: API 响应结果
|
Returns:
|
||||||
|
Dict[str, Any]: OneBot API 的响应数据。
|
||||||
"""
|
"""
|
||||||
return await self.call_api("bot_exit")
|
return await self.call_api("bot_exit")
|
||||||
|
|
||||||
async def set_self_longnick(self, long_nick: str) -> Dict[str, Any]:
|
async def set_self_longnick(self, long_nick: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
设置个性签名
|
设置机器人账号的个性签名。
|
||||||
|
|
||||||
:param long_nick: 个性签名内容
|
Args:
|
||||||
:return: API 响应结果
|
long_nick (str): 要设置的个性签名内容。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: OneBot API 的响应数据。
|
||||||
"""
|
"""
|
||||||
return await self.call_api("set_self_longnick", {"longNick": long_nick})
|
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]:
|
async def set_input_status(self, user_id: int, event_type: int) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
设置输入状态
|
设置 "对方正在输入..." 状态提示。
|
||||||
|
|
||||||
:param user_id: 用户 ID
|
Args:
|
||||||
:param event_type: 事件类型
|
user_id (int): 目标用户的 QQ 号。
|
||||||
:return: API 响应结果
|
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})
|
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]:
|
async def set_diy_online_status(self, face_id: int, face_type: int, wording: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
设置自定义在线状态
|
设置自定义的 "在线状态"。
|
||||||
|
|
||||||
:param face_id: 状态 ID
|
Args:
|
||||||
:param face_type: 状态类型
|
face_id (int): 状态的表情 ID。
|
||||||
:param wording: 状态描述
|
face_type (int): 状态的表情类型。
|
||||||
:return: API 响应结果
|
wording (str): 状态的描述文本。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: OneBot API 的响应数据。
|
||||||
"""
|
"""
|
||||||
return await self.call_api("set_diy_online_status", {
|
return await self.call_api("set_diy_online_status", {
|
||||||
"face_id": face_id,
|
"face_id": face_id,
|
||||||
@@ -82,43 +110,108 @@ class AccountAPI(BaseAPI):
|
|||||||
|
|
||||||
async def set_online_status(self, status_code: int) -> Dict[str, Any]:
|
async def set_online_status(self, status_code: int) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
设置在线状态
|
设置在线状态(如在线、离开、摸鱼等)。
|
||||||
|
|
||||||
:param status_code: 状态码
|
Args:
|
||||||
:return: API 响应结果
|
status_code (int): 目标在线状态的状态码。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: OneBot API 的响应数据。
|
||||||
"""
|
"""
|
||||||
return await self.call_api("set_online_status", {"status_code": status_code})
|
return await self.call_api("set_online_status", {"status_code": status_code})
|
||||||
|
|
||||||
async def set_qq_profile(self, **kwargs) -> Dict[str, Any]:
|
async def set_qq_profile(self, **kwargs) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
设置 QQ 资料
|
设置机器人账号的个人资料。
|
||||||
|
|
||||||
:param kwargs: 个人资料相关参数
|
Args:
|
||||||
:return: API 响应结果
|
**kwargs: 个人资料的相关参数,具体字段请参考 OneBot v11 规范。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: OneBot API 的响应数据。
|
||||||
"""
|
"""
|
||||||
return await self.call_api("set_qq_profile", kwargs)
|
return await self.call_api("set_qq_profile", kwargs)
|
||||||
|
|
||||||
async def set_qq_avatar(self, **kwargs) -> Dict[str, Any]:
|
async def set_qq_avatar(self, **kwargs) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
设置 QQ 头像
|
设置机器人账号的头像。
|
||||||
|
|
||||||
:param kwargs: 头像相关参数
|
Args:
|
||||||
:return: API 响应结果
|
**kwargs: 头像的相关参数,具体字段请参考 OneBot v11 规范。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: OneBot API 的响应数据。
|
||||||
"""
|
"""
|
||||||
return await self.call_api("set_qq_avatar", kwargs)
|
return await self.call_api("set_qq_avatar", kwargs)
|
||||||
|
|
||||||
async def get_clientkey(self) -> Dict[str, Any]:
|
async def get_clientkey(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
获取客户端密钥
|
获取客户端密钥(通常用于 QQ 登录相关操作)。
|
||||||
|
|
||||||
:return: API 响应结果
|
Returns:
|
||||||
|
Dict[str, Any]: OneBot API 的响应数据。
|
||||||
"""
|
"""
|
||||||
return await self.call_api("get_clientkey")
|
return await self.call_api("get_clientkey")
|
||||||
|
|
||||||
async def clean_cache(self) -> Dict[str, Any]:
|
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")
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,50 @@
|
|||||||
"""
|
"""
|
||||||
API 基础模块
|
API 基础模块
|
||||||
|
|
||||||
定义了 API 调用的基础接口。
|
定义了 API 调用的基础接口和统一处理逻辑。
|
||||||
"""
|
"""
|
||||||
from abc import ABC, abstractmethod
|
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
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:
|
async def call_api(self, action: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
||||||
"""
|
"""
|
||||||
调用 API
|
调用 OneBot v11 API,并提供统一的日志和异常处理。
|
||||||
|
|
||||||
:param action: API 动作名称
|
:param action: API 动作名称
|
||||||
:param params: 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +1,86 @@
|
|||||||
"""
|
"""
|
||||||
好友相关 API 模块
|
好友与陌生人相关 API 模块
|
||||||
|
|
||||||
|
该模块定义了 `FriendAPI` Mixin 类,提供了所有与好友、陌生人信息
|
||||||
|
等相关的 OneBot v11 API 封装。
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
from .base import BaseAPI
|
from .base import BaseAPI
|
||||||
from models.objects import FriendInfo, StrangerInfo
|
from models.objects import FriendInfo, StrangerInfo
|
||||||
|
from ..managers.redis_manager import redis_manager
|
||||||
|
|
||||||
|
|
||||||
class FriendAPI(BaseAPI):
|
class FriendAPI(BaseAPI):
|
||||||
"""
|
"""
|
||||||
好友相关 API Mixin
|
`FriendAPI` Mixin 类,提供了所有与好友、陌生人操作相关的 API 方法。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def send_like(self, user_id: int, times: int = 1) -> Dict[str, Any]:
|
async def send_like(self, user_id: int, times: int = 1) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
发送点赞
|
向指定用户发送 "戳一戳" (点赞)。
|
||||||
|
|
||||||
:param user_id: 对方 QQ 号
|
Args:
|
||||||
:param times: 点赞次数
|
user_id (int): 目标用户的 QQ 号。
|
||||||
:return: API 响应结果
|
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})
|
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:
|
async def get_stranger_info(self, user_id: int, no_cache: bool = False) -> StrangerInfo:
|
||||||
"""
|
"""
|
||||||
获取陌生人信息
|
获取陌生人的信息。
|
||||||
|
|
||||||
:param user_id: QQ 号
|
Args:
|
||||||
:param no_cache: 是否不使用缓存
|
user_id (int): 目标用户的 QQ 号。
|
||||||
:return: 陌生人信息对象
|
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.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})
|
res = await self.call_api("get_stranger_info", {"user_id": user_id, "no_cache": no_cache})
|
||||||
|
await redis_manager.redis.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
|
||||||
return StrangerInfo(**res)
|
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.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")
|
res = await self.call_api("get_friend_list")
|
||||||
|
await redis_manager.redis.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
|
||||||
return [FriendInfo(**item) for item in res]
|
return [FriendInfo(**item) for item in res]
|
||||||
|
|
||||||
async def set_friend_add_request(self, flag: str, approve: bool = True, remark: str = "") -> Dict[str, Any]:
|
async def set_friend_add_request(self, flag: str, approve: bool = True, remark: str = "") -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
处理加好友请求
|
处理收到的加好友请求。
|
||||||
|
|
||||||
:param flag: 加好友请求的 flag(需从上报的数据中获取)
|
Args:
|
||||||
:param approve: 是否同意请求
|
flag (str): 请求的标识,需要从 `request` 事件中获取。
|
||||||
:param remark: 添加后的好友备注(仅在同意时有效)
|
approve (bool, optional): 是否同意该好友请求。Defaults to True.
|
||||||
:return: API 响应结果
|
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})
|
return await self.call_api("set_friend_add_request", {"flag": flag, "approve": approve, "remark": remark})
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +1,67 @@
|
|||||||
"""
|
"""
|
||||||
群组相关 API 模块
|
群组相关 API 模块
|
||||||
|
|
||||||
|
该模块定义了 `GroupAPI` Mixin 类,提供了所有与群组管理、成员操作
|
||||||
|
等相关的 OneBot v11 API 封装。
|
||||||
"""
|
"""
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
import json
|
||||||
|
from ..managers.redis_manager import redis_manager
|
||||||
from .base import BaseAPI
|
from .base import BaseAPI
|
||||||
from models.objects import GroupInfo, GroupMemberInfo, GroupHonorInfo
|
from models.objects import GroupInfo, GroupMemberInfo, GroupHonorInfo
|
||||||
|
from ..utils.logger import logger
|
||||||
|
|
||||||
|
|
||||||
class GroupAPI(BaseAPI):
|
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]:
|
async def set_group_kick(self, group_id: int, user_id: int, reject_add_request: bool = False) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
群组踢人
|
将指定成员踢出群组。
|
||||||
|
|
||||||
:param group_id: 群号
|
Args:
|
||||||
:param user_id: 要踢的 QQ 号
|
group_id (int): 目标群组的群号。
|
||||||
:param reject_add_request: 拒绝此人的加群请求
|
user_id (int): 要踢出的成员的 QQ 号。
|
||||||
:return: API 响应结果
|
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})
|
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: 群号
|
Args:
|
||||||
:param user_id: 要禁言的 QQ 号
|
group_id (int): 目标群组的群号。
|
||||||
:param duration: 禁言时长(秒),0 表示解除禁言
|
user_id (int): 要禁言的成员的 QQ 号。
|
||||||
:return: API 响应结果
|
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})
|
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: Optional[Dict[str, Any]] = None, duration: int = 1800, flag: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
群组匿名禁言
|
禁言群组中的匿名用户。
|
||||||
|
|
||||||
:param group_id: 群号
|
Args:
|
||||||
:param anonymous: 可选,要禁言的匿名用户对象(群消息事件的 anonymous 字段)
|
group_id (int): 目标群组的群号。
|
||||||
:param duration: 禁言时长(秒)
|
anonymous (Dict[str, Any], optional): 要禁言的匿名用户对象,
|
||||||
:param flag: 可选,要禁言的匿名用户的 flag(需从群消息事件的 anonymous 字段中获取)
|
可从群消息事件的 `anonymous` 字段中获取。Defaults to None.
|
||||||
:return: API 响应结果
|
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}
|
params: Dict[str, Any] = {"group_id": group_id, "duration": duration}
|
||||||
if anonymous:
|
if anonymous:
|
||||||
params["anonymous"] = anonymous
|
params["anonymous"] = anonymous
|
||||||
if flag:
|
if flag:
|
||||||
@@ -52,139 +70,215 @@ class GroupAPI(BaseAPI):
|
|||||||
|
|
||||||
async def set_group_whole_ban(self, group_id: int, enable: bool = True) -> Dict[str, Any]:
|
async def set_group_whole_ban(self, group_id: int, enable: bool = True) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
群组全员禁言
|
开启或关闭群组全员禁言。
|
||||||
|
|
||||||
:param group_id: 群号
|
Args:
|
||||||
:param enable: 是否开启
|
group_id (int): 目标群组的群号。
|
||||||
:return: API 响应结果
|
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})
|
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]:
|
async def set_group_admin(self, group_id: int, user_id: int, enable: bool = True) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
群组设置管理员
|
设置或取消群组成员的管理员权限。
|
||||||
|
|
||||||
:param group_id: 群号
|
Args:
|
||||||
:param user_id: 要设置的 QQ 号
|
group_id (int): 目标群组的群号。
|
||||||
:param enable: True 为设置,False 为取消
|
user_id (int): 目标成员的 QQ 号。
|
||||||
:return: API 响应结果
|
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})
|
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]:
|
async def set_group_anonymous(self, group_id: int, enable: bool = True) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
群组匿名
|
开启或关闭群组的匿名聊天功能。
|
||||||
|
|
||||||
:param group_id: 群号
|
Args:
|
||||||
:param enable: 是否开启
|
group_id (int): 目标群组的群号。
|
||||||
:return: API 响应结果
|
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})
|
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]:
|
async def set_group_card(self, group_id: int, user_id: int, card: str = "") -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
设置群名片(群备注)
|
设置群组成员的群名片。
|
||||||
|
|
||||||
:param group_id: 群号
|
Args:
|
||||||
:param user_id: 要设置的 QQ 号
|
group_id (int): 目标群组的群号。
|
||||||
:param card: 群名片内容,不填或空字符串表示删除群名片
|
user_id (int): 目标成员的 QQ 号。
|
||||||
:return: API 响应结果
|
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})
|
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]:
|
async def set_group_name(self, group_id: int, group_name: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
设置群名
|
设置群组的名称。
|
||||||
|
|
||||||
:param group_id: 群号
|
Args:
|
||||||
:param group_name: 新群名
|
group_id (int): 目标群组的群号。
|
||||||
:return: API 响应结果
|
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})
|
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]:
|
async def set_group_leave(self, group_id: int, is_dismiss: bool = False) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
退出群组
|
退出或解散一个群组。
|
||||||
|
|
||||||
:param group_id: 群号
|
Args:
|
||||||
:param is_dismiss: 是否解散,如果登录号是群主,则仅在此项为 True 时能够解散
|
group_id (int): 目标群组的群号。
|
||||||
:return: API 响应结果
|
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})
|
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]:
|
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: 群号
|
Args:
|
||||||
:param user_id: 要设置的 QQ 号
|
group_id (int): 目标群组的群号。
|
||||||
:param special_title: 专属头衔,不填或空字符串表示删除
|
user_id (int): 目标成员的 QQ 号。
|
||||||
:param duration: 有效期(秒),-1 表示永久
|
special_title (str, optional): 专属头衔内容。
|
||||||
:return: API 响应结果
|
传入空字符串 `""` 或 `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})
|
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:
|
async def get_group_info(self, group_id: int, no_cache: bool = False) -> GroupInfo:
|
||||||
"""
|
"""
|
||||||
获取群信息
|
获取群组的详细信息。
|
||||||
|
|
||||||
:param group_id: 群号
|
Args:
|
||||||
:param no_cache: 是否不使用缓存
|
group_id (int): 目标群组的群号。
|
||||||
:return: 群信息对象
|
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.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.redis.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
|
||||||
return GroupInfo(**res)
|
return GroupInfo(**res)
|
||||||
|
|
||||||
async def get_group_list(self) -> List[GroupInfo]:
|
async def get_group_list(self) -> Any:
|
||||||
"""
|
"""
|
||||||
获取群列表
|
获取机器人加入的所有群组的列表。
|
||||||
|
|
||||||
:return: 群信息对象列表
|
Returns:
|
||||||
|
Any: 包含所有群组信息的列表(可能是字典列表或对象列表)。
|
||||||
"""
|
"""
|
||||||
res = await self.call_api("get_group_list")
|
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":
|
||||||
|
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]
|
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:
|
async def get_group_member_info(self, group_id: int, user_id: int, no_cache: bool = False) -> GroupMemberInfo:
|
||||||
"""
|
"""
|
||||||
获取群成员信息
|
获取指定群组成员的详细信息。
|
||||||
|
|
||||||
:param group_id: 群号
|
Args:
|
||||||
:param user_id: QQ 号
|
group_id (int): 目标群组的群号。
|
||||||
:param no_cache: 是否不使用缓存
|
user_id (int): 目标成员的 QQ 号。
|
||||||
:return: 群成员信息对象
|
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.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.redis.set(cache_key, json.dumps(res), ex=3600) # 缓存 1 小时
|
||||||
return GroupMemberInfo(**res)
|
return GroupMemberInfo(**res)
|
||||||
|
|
||||||
async def get_group_member_list(self, group_id: int) -> List[GroupMemberInfo]:
|
async def get_group_member_list(self, group_id: int) -> List[GroupMemberInfo]:
|
||||||
"""
|
"""
|
||||||
获取群成员列表
|
获取一个群组的所有成员列表。
|
||||||
|
|
||||||
:param group_id: 群号
|
Args:
|
||||||
:return: 群成员信息对象列表
|
group_id (int): 目标群组的群号。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[GroupMemberInfo]: 包含所有群成员信息的 `GroupMemberInfo` 对象列表。
|
||||||
"""
|
"""
|
||||||
res = await self.call_api("get_group_member_list", {"group_id": group_id})
|
res = await self.call_api("get_group_member_list", {"group_id": group_id})
|
||||||
return [GroupMemberInfo(**item) for item in res]
|
return [GroupMemberInfo(**item) for item in res]
|
||||||
|
|
||||||
async def get_group_honor_info(self, group_id: int, type: str) -> GroupHonorInfo:
|
async def get_group_honor_info(self, group_id: int, type: str) -> GroupHonorInfo:
|
||||||
"""
|
"""
|
||||||
获取群荣誉信息
|
获取群组的荣誉信息(如龙王、群聊之火等)。
|
||||||
|
|
||||||
:param group_id: 群号
|
Args:
|
||||||
:param type: 要获取的群荣誉类型,可传入 talkative, performer, legend, strong_newbie, emotion 等
|
group_id (int): 目标群组的群号。
|
||||||
:return: 群荣誉信息对象
|
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})
|
res = await self.call_api("get_group_honor_info", {"group_id": group_id, "type": type})
|
||||||
return GroupHonorInfo(**res)
|
return GroupHonorInfo(**res)
|
||||||
|
|
||||||
async def set_group_add_request(self, flag: str, sub_type: str, approve: bool = True, reason: str = "") -> Dict[str, Any]:
|
async def set_group_add_request(self, flag: str, sub_type: str, approve: bool = True, reason: str = "") -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
处理加群请求/邀请
|
处理加群请求或邀请。
|
||||||
|
|
||||||
:param flag: 加群请求的 flag(需从上报的数据中获取)
|
Args:
|
||||||
:param sub_type: add 或 invite,请求类型(需要与上报消息中的 sub_type 字段相符)
|
flag (str): 请求的标识,需要从 `request` 事件中获取。
|
||||||
:param approve: 是否同意请求/邀请
|
sub_type (str): 请求的子类型,`add` 或 `invite`,
|
||||||
:param reason: 拒绝理由(仅在拒绝时有效)
|
需要与 `request` 事件中的 `sub_type` 字段相符。
|
||||||
:return: API 响应结果
|
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})
|
return await self.call_api("set_group_add_request", {"flag": flag, "sub_type": sub_type, "approve": approve, "reason": reason})
|
||||||
|
|
||||||
|
|||||||
39
core/api/media.py
Normal file
39
core/api/media.py
Normal file
@@ -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})
|
||||||
@@ -1,26 +1,35 @@
|
|||||||
"""
|
"""
|
||||||
消息相关 API 模块
|
消息相关 API 模块
|
||||||
|
|
||||||
|
该模块定义了 `MessageAPI` Mixin 类,提供了所有与消息发送、撤回、
|
||||||
|
转发等相关的 OneBot v11 API 封装。
|
||||||
"""
|
"""
|
||||||
from typing import Union, List, Dict, Any, TYPE_CHECKING
|
from typing import Union, List, Dict, Any, TYPE_CHECKING
|
||||||
from .base import BaseAPI
|
from .base import BaseAPI
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from models import MessageSegment, OneBotEvent
|
from models.message import MessageSegment
|
||||||
|
from models.events.base import OneBotEvent
|
||||||
|
|
||||||
|
|
||||||
class MessageAPI(BaseAPI):
|
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]:
|
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: 群号
|
Args:
|
||||||
:param message: 消息内容,可以是字符串、MessageSegment 对象或 MessageSegment 列表
|
group_id (int): 目标群组的群号。
|
||||||
:param auto_escape: 是否自动转义(仅当 message 为字符串时有效)
|
message (Union[str, MessageSegment, List[MessageSegment]]): 要发送的消息内容。
|
||||||
:return: API 响应结果
|
可以是纯文本字符串、单个消息段对象或消息段列表。
|
||||||
|
auto_escape (bool, optional): 仅当 `message` 为字符串时有效,
|
||||||
|
是否对消息内容进行 CQ 码转义。Defaults to False.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: OneBot API 的响应数据。
|
||||||
"""
|
"""
|
||||||
return await self.call_api(
|
return await self.call_api(
|
||||||
"send_group_msg", {"group_id": group_id, "message": self._process_message(message), "auto_escape": auto_escape}
|
"send_group_msg", {"group_id": group_id, "message": self._process_message(message), "auto_escape": auto_escape}
|
||||||
@@ -28,12 +37,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]:
|
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 号
|
Args:
|
||||||
:param message: 消息内容,可以是字符串、MessageSegment 对象或 MessageSegment 列表
|
user_id (int): 目标用户的 QQ 号。
|
||||||
:param auto_escape: 是否自动转义(仅当 message 为字符串时有效)
|
message (Union[str, MessageSegment, List[MessageSegment]]): 要发送的消息内容。
|
||||||
:return: API 响应结果
|
auto_escape (bool, optional): 是否对消息内容进行 CQ 码转义。Defaults to False.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: OneBot API 的响应数据。
|
||||||
"""
|
"""
|
||||||
return await self.call_api(
|
return await self.call_api(
|
||||||
"send_private_msg", {"user_id": user_id, "message": self._process_message(message), "auto_escape": auto_escape}
|
"send_private_msg", {"user_id": user_id, "message": self._process_message(message), "auto_escape": auto_escape}
|
||||||
@@ -41,12 +53,18 @@ class MessageAPI(BaseAPI):
|
|||||||
|
|
||||||
async def send(self, event: "OneBotEvent", message: Union[str, "MessageSegment", List["MessageSegment"]], auto_escape: bool = False) -> Dict[str, Any]:
|
async def send(self, event: "OneBotEvent", message: Union[str, "MessageSegment", List["MessageSegment"]], auto_escape: bool = False) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
智能发送消息,根据事件类型自动选择发送方式
|
智能发送消息。
|
||||||
|
|
||||||
:param event: 触发事件对象
|
该方法会根据传入的事件对象 `event` 自动判断是私聊还是群聊,
|
||||||
:param message: 消息内容
|
并调用相应的发送函数。如果事件是消息事件,则优先使用 `reply` 方法。
|
||||||
:param auto_escape: 是否自动转义
|
|
||||||
:return: API 响应结果
|
Args:
|
||||||
|
event (OneBotEvent): 触发该发送行为的事件对象。
|
||||||
|
message (Union[str, MessageSegment, List[MessageSegment]]): 要发送的消息内容。
|
||||||
|
auto_escape (bool, optional): 是否对消息内容进行 CQ 码转义。Defaults to False.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: OneBot API 的响应数据。
|
||||||
"""
|
"""
|
||||||
# 如果是消息事件,直接调用 reply
|
# 如果是消息事件,直接调用 reply
|
||||||
if hasattr(event, "reply"):
|
if hasattr(event, "reply"):
|
||||||
@@ -66,59 +84,98 @@ class MessageAPI(BaseAPI):
|
|||||||
|
|
||||||
async def delete_msg(self, message_id: int) -> Dict[str, Any]:
|
async def delete_msg(self, message_id: int) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
撤回消息
|
撤回一条消息。
|
||||||
|
|
||||||
:param message_id: 消息 ID
|
Args:
|
||||||
:return: API 响应结果
|
message_id (int): 要撤回的消息的 ID。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: OneBot API 的响应数据。
|
||||||
"""
|
"""
|
||||||
return await self.call_api("delete_msg", {"message_id": message_id})
|
return await self.call_api("delete_msg", {"message_id": message_id})
|
||||||
|
|
||||||
async def get_msg(self, message_id: int) -> Dict[str, Any]:
|
async def get_msg(self, message_id: int) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
获取消息
|
获取一条消息的详细信息。
|
||||||
|
|
||||||
:param message_id: 消息 ID
|
Args:
|
||||||
:return: API 响应结果
|
message_id (int): 要获取的消息的 ID。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: OneBot API 的响应数据,包含消息详情。
|
||||||
"""
|
"""
|
||||||
return await self.call_api("get_msg", {"message_id": message_id})
|
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]]:
|
||||||
"""
|
"""
|
||||||
获取合并转发消息
|
获取合并转发消息的内容。
|
||||||
|
|
||||||
:param id: 合并转发 ID
|
Args:
|
||||||
:return: API 响应结果
|
id (str): 合并转发消息的 ID。
|
||||||
"""
|
|
||||||
return await self.call_api("get_forward_msg", {"id": id})
|
|
||||||
|
|
||||||
async def can_send_image(self) -> Dict[str, Any]:
|
Returns:
|
||||||
|
List[Dict[str, Any]]: 转发消息的节点列表。
|
||||||
"""
|
"""
|
||||||
检查是否可以发送图片
|
forward_data = await self.call_api("get_forward_msg", {"id": id})
|
||||||
|
nodes = forward_data.get("data")
|
||||||
|
|
||||||
:return: API 响应结果
|
if not isinstance(nodes, list):
|
||||||
"""
|
# 兼容某些实现可能将节点放在 'messages' 键下
|
||||||
return await self.call_api("can_send_image")
|
data = forward_data.get('data', {})
|
||||||
|
if isinstance(data, dict):
|
||||||
|
nodes = data.get('messages')
|
||||||
|
|
||||||
async def can_send_record(self) -> Dict[str, Any]:
|
if not isinstance(nodes, list):
|
||||||
"""
|
raise ValueError("在 get_forward_msg 响应中找不到消息节点列表")
|
||||||
检查是否可以发送语音
|
|
||||||
|
|
||||||
:return: API 响应结果
|
return nodes
|
||||||
|
|
||||||
|
async def send_group_forward_msg(self, group_id: int, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
return await self.call_api("can_send_record")
|
发送群聊合并转发消息。
|
||||||
|
|
||||||
|
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})
|
||||||
|
|
||||||
def _process_message(self, message: Union[str, "MessageSegment", List["MessageSegment"]]) -> Union[str, List[Dict[str, Any]]]:
|
def _process_message(self, message: Union[str, "MessageSegment", List["MessageSegment"]]) -> Union[str, List[Dict[str, Any]]]:
|
||||||
"""
|
"""
|
||||||
处理消息内容,将其转换为 API 可接受的格式
|
内部方法:将消息内容处理成 OneBot API 可接受的格式。
|
||||||
|
|
||||||
:param message: 原始消息内容
|
- `str` -> `str`
|
||||||
:return: 处理后的消息内容
|
- `MessageSegment` -> `List[Dict]`
|
||||||
|
- `List[MessageSegment]` -> `List[Dict]`
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: 原始消息内容。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
处理后的消息内容。
|
||||||
"""
|
"""
|
||||||
if isinstance(message, str):
|
if isinstance(message, str):
|
||||||
return message
|
return message
|
||||||
|
|
||||||
# 避免循环导入,在运行时导入
|
# 避免循环导入,在运行时导入
|
||||||
from models import MessageSegment
|
from models.message import MessageSegment
|
||||||
|
|
||||||
if isinstance(message, MessageSegment):
|
if isinstance(message, MessageSegment):
|
||||||
return [self._segment_to_dict(message)]
|
return [self._segment_to_dict(message)]
|
||||||
@@ -130,12 +187,16 @@ class MessageAPI(BaseAPI):
|
|||||||
|
|
||||||
def _segment_to_dict(self, segment: "MessageSegment") -> Dict[str, Any]:
|
def _segment_to_dict(self, segment: "MessageSegment") -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
将 MessageSegment 对象转换为字典
|
内部方法:将 `MessageSegment` 对象转换为字典。
|
||||||
|
|
||||||
:param segment: MessageSegment 对象
|
Args:
|
||||||
:return: 字典格式的消息段
|
segment (MessageSegment): 消息段对象。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: 符合 OneBot 规范的消息段字典。
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"type": segment.type,
|
"type": segment.type,
|
||||||
"data": segment.data
|
"data": segment.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
114
core/bot.py
114
core/bot.py
@@ -1,36 +1,116 @@
|
|||||||
"""
|
"""
|
||||||
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, Optional
|
||||||
|
from models.events.base import OneBotEvent
|
||||||
|
from models.message import MessageSegment
|
||||||
|
from models.objects import GroupInfo, StrangerInfo
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .ws import WS
|
from .ws import WS
|
||||||
|
from .utils.executor import CodeExecutor
|
||||||
|
|
||||||
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):
|
||||||
"""
|
"""
|
||||||
Bot 抽象类,封装 API 调用和常用操作
|
机器人核心类,封装了所有与 OneBot API 的交互。
|
||||||
继承各个 API Mixin 以提高代码的可维护性
|
|
||||||
|
通过 Mixin 模式继承了所有 API 功能,使得结构清晰且易于扩展。
|
||||||
|
实例由 `WS` 客户端在连接成功后创建,并传递给所有事件处理器和插件。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, ws_client: "WS"):
|
def __init__(self, ws_client: "WS"):
|
||||||
"""
|
"""
|
||||||
初始化 Bot 实例
|
初始化 Bot 实例。
|
||||||
|
|
||||||
:param ws_client: WebSocket 客户端实例,用于底层通信
|
Args:
|
||||||
|
ws_client (WS): WebSocket 客户端实例,负责底层的 API 请求和响应处理。
|
||||||
"""
|
"""
|
||||||
self.ws = ws_client
|
super().__init__(ws_client, ws_client.self_id or 0)
|
||||||
|
self.code_executor: Optional["CodeExecutor"] = None
|
||||||
|
|
||||||
async def call_api(self, action: str, params: Dict[str, Any] = None) -> Any:
|
async def get_group_list(self, no_cache: bool = False) -> List[GroupInfo]:
|
||||||
"""
|
# GroupAPI.get_group_list 不支持 no_cache 参数,这里忽略它
|
||||||
调用 OneBot API
|
result = await super().get_group_list()
|
||||||
|
# 确保结果是 GroupInfo 对象列表
|
||||||
|
return [GroupInfo(**group) if isinstance(group, dict) else group for group in result]
|
||||||
|
|
||||||
:param action: API 动作名称
|
async def get_stranger_info(self, user_id: int, no_cache: bool = False) -> StrangerInfo:
|
||||||
:param params: API 参数
|
result = await super().get_stranger_info(user_id=user_id, no_cache=no_cache)
|
||||||
:return: API 响应结果
|
# 确保结果是 StrangerInfo 对象
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return StrangerInfo(**result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_forward_node(self, user_id: int, nickname: str, message: Union[str, "MessageSegment", List["MessageSegment"]]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
return await self.ws.call_api(action, params)
|
构建一个用于合并转发的消息节点 (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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
"""
|
|
||||||
命令管理器模块
|
|
||||||
|
|
||||||
提供装饰器用于注册消息指令、通知处理器和请求处理器,并负责事件的分发。
|
|
||||||
"""
|
|
||||||
import inspect
|
|
||||||
from typing import Any, Callable, Dict, List, Tuple
|
|
||||||
|
|
||||||
from .config_loader import global_config
|
|
||||||
|
|
||||||
# 从配置中获取命令前缀
|
|
||||||
comm_prefixes = global_config.bot.get("command", ("/",))
|
|
||||||
|
|
||||||
|
|
||||||
class CommandManager:
|
|
||||||
"""
|
|
||||||
命令管理器,负责注册和分发指令、通知和请求事件
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, prefixes: Tuple[str, ...] = ("/",)):
|
|
||||||
"""
|
|
||||||
初始化命令管理器
|
|
||||||
|
|
||||||
:param prefixes: 命令前缀元组
|
|
||||||
"""
|
|
||||||
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]] = {} # 存储插件元数据
|
|
||||||
|
|
||||||
# --- 内置 help 指令 ---
|
|
||||||
self.commands["help"] = self._help_command
|
|
||||||
self.plugins["core.help"] = {
|
|
||||||
"name": "帮助",
|
|
||||||
"description": "显示所有可用指令的帮助信息",
|
|
||||||
"usage": "/help",
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _help_command(self, bot, event):
|
|
||||||
"""
|
|
||||||
内置的 /help 指令处理器
|
|
||||||
|
|
||||||
:param bot: Bot 实例
|
|
||||||
:param event: 消息事件对象
|
|
||||||
"""
|
|
||||||
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())
|
|
||||||
|
|
||||||
# --- 1. 消息指令装饰器 ---
|
|
||||||
def command(self, name: str):
|
|
||||||
"""
|
|
||||||
装饰器:注册消息指令
|
|
||||||
|
|
||||||
:param name: 指令名称(不含前缀)
|
|
||||||
:return: 装饰器函数
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorator(func):
|
|
||||||
self.commands[name] = func
|
|
||||||
return func
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
# --- 2. 通知事件装饰器 ---
|
|
||||||
def on_notice(self, notice_type: str = None):
|
|
||||||
"""
|
|
||||||
装饰器:注册通知处理器
|
|
||||||
|
|
||||||
:param notice_type: 通知类型,如果为 None 则处理所有通知
|
|
||||||
:return: 装饰器函数
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorator(func):
|
|
||||||
self.notice_handlers.append({"type": notice_type, "func": func})
|
|
||||||
return func
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
# --- 3. 请求事件装饰器 ---
|
|
||||||
def on_request(self, request_type: str = None):
|
|
||||||
"""
|
|
||||||
装饰器:注册请求处理器
|
|
||||||
|
|
||||||
:param request_type: 请求类型,如果为 None 则处理所有请求
|
|
||||||
:return: 装饰器函数
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorator(func):
|
|
||||||
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: 事件对象
|
|
||||||
"""
|
|
||||||
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):
|
|
||||||
"""
|
|
||||||
解析并分发消息指令
|
|
||||||
|
|
||||||
:param bot: Bot 实例
|
|
||||||
:param event: 消息事件对象
|
|
||||||
"""
|
|
||||||
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):
|
|
||||||
prefix_found = p
|
|
||||||
break
|
|
||||||
|
|
||||||
if not prefix_found:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 2. 拆分指令和参数
|
|
||||||
full_cmd = raw_text[len(prefix_found) :].split()
|
|
||||||
if not full_cmd:
|
|
||||||
return
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
# --- 通知分发逻辑 ---
|
|
||||||
async def handle_notice(self, bot, event):
|
|
||||||
"""
|
|
||||||
分发通知事件
|
|
||||||
|
|
||||||
:param bot: Bot 实例
|
|
||||||
:param 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: 请求事件对象
|
|
||||||
"""
|
|
||||||
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):
|
|
||||||
"""
|
|
||||||
根据函数签名自动注入 bot, event 或 args
|
|
||||||
|
|
||||||
:param func: 目标处理函数
|
|
||||||
:param bot: Bot 实例
|
|
||||||
:param event: 事件对象
|
|
||||||
:param args: 指令参数(仅消息指令有效)
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
# 执行函数
|
|
||||||
await func(**kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
# 确保前缀是元组格式
|
|
||||||
if isinstance(comm_prefixes, list):
|
|
||||||
comm_prefixes = tuple[Any, ...](comm_prefixes)
|
|
||||||
elif isinstance(comm_prefixes, str):
|
|
||||||
comm_prefixes = (comm_prefixes,)
|
|
||||||
|
|
||||||
# 实例化全局管理器
|
|
||||||
matcher = CommandManager(prefixes=comm_prefixes)
|
|
||||||
@@ -4,9 +4,13 @@
|
|||||||
负责读取和解析 config.toml 配置文件,提供全局配置对象。
|
负责读取和解析 config.toml 配置文件,提供全局配置对象。
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
import tomllib
|
import tomllib
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from .config_models import ConfigModel, NapCatWSModel, BotModel, RedisModel, DockerModel
|
||||||
|
from .utils.logger import logger, ModuleLogger
|
||||||
|
from .utils.exceptions import ConfigError, ConfigNotFoundError, ConfigValidationError
|
||||||
|
from .utils.error_codes import ErrorCode, create_error_response
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -21,55 +25,97 @@ class Config:
|
|||||||
:param file_path: 配置文件路径,默认为 "config.toml"
|
:param file_path: 配置文件路径,默认为 "config.toml"
|
||||||
"""
|
"""
|
||||||
self.path = Path(file_path)
|
self.path = Path(file_path)
|
||||||
self._data: Dict[str, Any] = {}
|
self._model: ConfigModel
|
||||||
|
# 创建模块专用日志记录器
|
||||||
|
self.logger = ModuleLogger("ConfigLoader")
|
||||||
self.load()
|
self.load()
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
"""
|
"""
|
||||||
加载配置文件
|
加载并验证配置文件
|
||||||
|
|
||||||
:raises FileNotFoundError: 如果配置文件不存在
|
:raises ConfigNotFoundError: 如果配置文件不存在
|
||||||
|
:raises ConfigValidationError: 如果配置格式不正确
|
||||||
|
:raises ConfigError: 如果加载配置时发生其他错误
|
||||||
"""
|
"""
|
||||||
if not self.path.exists():
|
if not self.path.exists():
|
||||||
raise FileNotFoundError(f"配置文件 {self.path} 未找到!")
|
error = ConfigNotFoundError(message=f"配置文件 {self.path} 未找到!")
|
||||||
|
self.logger.error(f"配置加载失败: {error.message}")
|
||||||
|
self.logger.log_custom_exception(error)
|
||||||
|
raise error
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.info(f"正在从 {self.path} 加载配置...")
|
||||||
with open(self.path, "rb") as f:
|
with open(self.path, "rb") as f:
|
||||||
self._data = tomllib.load(f)
|
raw_config = tomllib.load(f)
|
||||||
|
|
||||||
|
self._model = ConfigModel(**raw_config)
|
||||||
|
self.logger.success("配置加载并验证成功!")
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
error_details = []
|
||||||
|
for error in e.errors():
|
||||||
|
field = " -> ".join(map(str, error["loc"]))
|
||||||
|
error_msg = f"字段 '{field}': {error['msg']}"
|
||||||
|
error_details.append(error_msg)
|
||||||
|
|
||||||
|
validation_error = ConfigValidationError(
|
||||||
|
message="配置验证失败",
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.error("配置验证失败,请检查 `config.toml` 文件中的以下错误:")
|
||||||
|
for detail in error_details:
|
||||||
|
self.logger.error(f" - {detail}")
|
||||||
|
|
||||||
|
self.logger.log_custom_exception(validation_error)
|
||||||
|
raise validation_error
|
||||||
|
except tomllib.TOMLDecodeError as e:
|
||||||
|
error = ConfigError(
|
||||||
|
message=f"TOML解析错误: {str(e)}",
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
self.logger.error(f"加载配置文件时发生TOML解析错误: {error.message}")
|
||||||
|
self.logger.log_custom_exception(error)
|
||||||
|
raise error
|
||||||
|
except Exception as e:
|
||||||
|
error = ConfigError(
|
||||||
|
message=f"加载配置文件时发生未知错误: {str(e)}",
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
self.logger.exception(f"加载配置文件时发生未知错误: {error.message}")
|
||||||
|
self.logger.log_custom_exception(error)
|
||||||
|
raise error
|
||||||
|
|
||||||
# 通过属性访问配置
|
# 通过属性访问配置
|
||||||
@property
|
@property
|
||||||
def napcat_ws(self) -> dict:
|
def napcat_ws(self) -> NapCatWSModel:
|
||||||
"""
|
"""
|
||||||
获取 NapCat WebSocket 配置
|
获取 NapCat WebSocket 配置
|
||||||
|
|
||||||
:return: 配置字典
|
|
||||||
"""
|
"""
|
||||||
return self._data.get("napcat_ws", {})
|
return self._model.napcat_ws
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bot(self) -> dict:
|
def bot(self) -> BotModel:
|
||||||
"""
|
"""
|
||||||
获取 Bot 基础配置
|
获取 Bot 基础配置
|
||||||
|
|
||||||
:return: 配置字典
|
|
||||||
"""
|
"""
|
||||||
return self._data.get("bot", {})
|
return self._model.bot
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def features(self) -> dict:
|
def redis(self) -> RedisModel:
|
||||||
"""
|
"""
|
||||||
获取功能特性配置
|
获取 Redis 配置
|
||||||
|
"""
|
||||||
|
return self._model.redis
|
||||||
|
|
||||||
:return: 配置字典
|
@property
|
||||||
|
def docker(self) -> DockerModel:
|
||||||
"""
|
"""
|
||||||
return self._data.get("features", {})
|
获取 Docker 配置
|
||||||
|
"""
|
||||||
|
return self._model.docker
|
||||||
|
|
||||||
|
|
||||||
# 实例化全局配置对象
|
# 实例化全局配置对象
|
||||||
global_config = Config()
|
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)
|
|
||||||
|
|||||||
60
core/config_models.py
Normal file
60
core/config_models.py
Normal file
@@ -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
|
||||||
3
core/data/admin.json
Normal file
3
core/data/admin.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"admins": [2221577113]
|
||||||
|
}
|
||||||
3
core/data/permissions.json
Normal file
3
core/data/permissions.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"users": {}
|
||||||
|
}
|
||||||
0
core/handlers/__init__.py
Normal file
0
core/handlers/__init__.py
Normal file
240
core/handlers/event_handler.py
Normal file
240
core/handlers/event_handler.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
事件处理器模块
|
||||||
|
|
||||||
|
该模块定义了用于处理不同类型事件的处理器类。
|
||||||
|
每个处理器都负责注册和分发特定类型的事件。
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..bot import Bot
|
||||||
|
from ..config_loader import global_config
|
||||||
|
from ..permission import Permission
|
||||||
|
from ..utils.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: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
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[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:
|
||||||
|
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,
|
||||||
|
permission: Optional[Permission] = None,
|
||||||
|
override_permission_check: bool = False
|
||||||
|
) -> Callable:
|
||||||
|
"""
|
||||||
|
注册命令处理器
|
||||||
|
"""
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
处理消息事件,分发给命令处理器或通用消息处理器
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
command_parts = raw_text[len(prefix_found):].split()
|
||||||
|
if not command_parts:
|
||||||
|
return
|
||||||
|
|
||||||
|
command_name = command_parts[0]
|
||||||
|
args = command_parts[1:]
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
permission_granted = True
|
||||||
|
if permission:
|
||||||
|
permission_granted = await permission_manager.check_permission(event.user_id, permission)
|
||||||
|
|
||||||
|
if not permission_granted and not override_check:
|
||||||
|
permission_name = permission.name if isinstance(permission, Permission) else permission
|
||||||
|
message_template = global_config.bot.permission_denied_message
|
||||||
|
await bot.send(event, message_template.format(permission_name=permission_name))
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._run_handler(
|
||||||
|
func,
|
||||||
|
bot,
|
||||||
|
event,
|
||||||
|
args=args,
|
||||||
|
permission_granted=permission_granted
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
|
||||||
|
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 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:
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
48
core/managers/__init__.py
Normal file
48
core/managers/__init__.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
管理器包
|
||||||
|
|
||||||
|
这个包集中了机器人核心的单例管理器。
|
||||||
|
通过从这里导入,可以确保在整个应用中访问到的都是同一个实例。
|
||||||
|
"""
|
||||||
|
from .admin_manager import AdminManager
|
||||||
|
from .command_manager import matcher as command_manager
|
||||||
|
from .permission_manager import PermissionManager
|
||||||
|
from .plugin_manager import PluginManager
|
||||||
|
from .redis_manager import RedisManager
|
||||||
|
from .browser_manager import BrowserManager
|
||||||
|
from .image_manager import ImageManager
|
||||||
|
|
||||||
|
# --- 实例化所有单例管理器 ---
|
||||||
|
|
||||||
|
# 管理员管理器
|
||||||
|
admin_manager = AdminManager()
|
||||||
|
|
||||||
|
# 权限管理器
|
||||||
|
permission_manager = PermissionManager()
|
||||||
|
|
||||||
|
# 命令与事件管理器 (别名 matcher)
|
||||||
|
matcher = command_manager
|
||||||
|
|
||||||
|
# 插件管理器
|
||||||
|
plugin_manager = PluginManager(command_manager)
|
||||||
|
plugin_manager.load_all_plugins()
|
||||||
|
|
||||||
|
# Redis 管理器
|
||||||
|
redis_manager = RedisManager()
|
||||||
|
|
||||||
|
# 浏览器管理器
|
||||||
|
browser_manager = BrowserManager()
|
||||||
|
|
||||||
|
# 图片管理器
|
||||||
|
image_manager = ImageManager()
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"admin_manager",
|
||||||
|
"permission_manager",
|
||||||
|
"command_manager",
|
||||||
|
"matcher",
|
||||||
|
"plugin_manager",
|
||||||
|
"redis_manager",
|
||||||
|
"browser_manager",
|
||||||
|
"image_manager",
|
||||||
|
]
|
||||||
150
core/managers/admin_manager.py
Normal file
150
core/managers/admin_manager.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
管理员管理器模块
|
||||||
|
|
||||||
|
该模块负责管理机器人的管理员列表。
|
||||||
|
它现在以 Redis 作为主要数据源,文件仅用作备份。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Set
|
||||||
|
|
||||||
|
from ..utils.logger import logger
|
||||||
|
from ..utils.singleton import Singleton
|
||||||
|
from .redis_manager import redis_manager
|
||||||
|
|
||||||
|
|
||||||
|
class AdminManager(Singleton):
|
||||||
|
"""
|
||||||
|
管理员管理器类
|
||||||
|
|
||||||
|
以 Redis Set 作为管理员列表的唯一真实来源,提供高速的读写能力。
|
||||||
|
文件 (admin.json) 仅用于首次启动时的数据迁移和作为灾备。
|
||||||
|
"""
|
||||||
|
_REDIS_KEY = "neobot:admins" # 用于存储管理员集合的 Redis 键
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""
|
||||||
|
初始化 AdminManager
|
||||||
|
"""
|
||||||
|
if hasattr(self, '_initialized') and self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 管理员数据文件路径,主要用于备份和首次迁移
|
||||||
|
self.data_file = os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"..",
|
||||||
|
"data",
|
||||||
|
"admin.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(self.data_file), exist_ok=True)
|
||||||
|
logger.info("管理员管理器初始化完成")
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
"""
|
||||||
|
异步初始化,检查 Redis 数据,如果为空则尝试从文件迁移
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 检查 Redis 中是否已存在数据
|
||||||
|
if await redis_manager.redis.exists(self._REDIS_KEY):
|
||||||
|
admin_count = await redis_manager.redis.scard(self._REDIS_KEY)
|
||||||
|
logger.info(f"Redis 中已存在管理员数据,共 {admin_count} 位。")
|
||||||
|
else:
|
||||||
|
# Redis 为空,尝试从文件迁移
|
||||||
|
logger.info("Redis 中未找到管理员数据,尝试从 admin.json 文件迁移...")
|
||||||
|
await self._migrate_from_file_to_redis()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"初始化管理员数据时发生错误: {e}")
|
||||||
|
|
||||||
|
async def _migrate_from_file_to_redis(self):
|
||||||
|
"""
|
||||||
|
从 admin.json 加载管理员列表并存入 Redis
|
||||||
|
这通常只在首次启动或 Redis 数据丢失时执行一次
|
||||||
|
"""
|
||||||
|
admins_to_migrate = set()
|
||||||
|
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", [])
|
||||||
|
admins_to_migrate = set(int(admin_id) for admin_id in admins)
|
||||||
|
|
||||||
|
if admins_to_migrate:
|
||||||
|
await redis_manager.redis.sadd(self._REDIS_KEY, *admins_to_migrate)
|
||||||
|
logger.success(f"成功从文件迁移 {len(admins_to_migrate)} 位管理员到 Redis。")
|
||||||
|
else:
|
||||||
|
logger.info("admin.json 文件为空或不存在,无需迁移。")
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.error(f"解析 admin.json 失败,无法迁移: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"迁移管理员数据到 Redis 失败: {e}")
|
||||||
|
|
||||||
|
async def _save_to_file_backup(self):
|
||||||
|
"""
|
||||||
|
将 Redis 中的管理员列表备份到 admin.json
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
admins = await self.get_all_admins()
|
||||||
|
admin_list = [str(admin_id) for admin_id in 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 is_admin(self, user_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
检查用户是否为管理员(直接从 Redis 读取)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await redis_manager.redis.sismember(self._REDIS_KEY, user_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"从 Redis 检查管理员权限失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def add_admin(self, user_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
添加管理员到 Redis,并更新文件备份
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# sadd 返回成功添加的成员数量,1 表示成功,0 表示已存在
|
||||||
|
if await redis_manager.redis.sadd(self._REDIS_KEY, user_id) == 1:
|
||||||
|
logger.info(f"已添加新管理员 {user_id} 到 Redis")
|
||||||
|
await self._save_to_file_backup() # 更新备份
|
||||||
|
return True
|
||||||
|
return False # 用户已经是管理员
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"添加管理员 {user_id} 到 Redis 失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def remove_admin(self, user_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
从 Redis 移除管理员,并更新文件备份
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# srem 返回成功移除的成员数量,1 表示成功,0 表示不存在
|
||||||
|
if await redis_manager.redis.srem(self._REDIS_KEY, user_id) == 1:
|
||||||
|
logger.info(f"已从 Redis 移除管理员 {user_id}")
|
||||||
|
await self._save_to_file_backup() # 更新备份
|
||||||
|
return True
|
||||||
|
return False # 用户不是管理员
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"从 Redis 移除管理员 {user_id} 失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_all_admins(self) -> Set[int]:
|
||||||
|
"""
|
||||||
|
从 Redis 获取所有管理员的集合
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
admins = await redis_manager.redis.smembers(self._REDIS_KEY)
|
||||||
|
return {int(admin_id) for admin_id in admins}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"从 Redis 获取所有管理员失败: {e}")
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
# 全局 AdminManager 实例
|
||||||
|
admin_manager = AdminManager()
|
||||||
151
core/managers/browser_manager.py
Normal file
151
core/managers/browser_manager.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""
|
||||||
|
浏览器管理器模块
|
||||||
|
|
||||||
|
负责管理全局唯一的 Playwright 浏览器实例,避免频繁启动/关闭浏览器的开销。
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional
|
||||||
|
from playwright.async_api import async_playwright, Browser, Playwright, Page
|
||||||
|
from ..utils.logger import logger
|
||||||
|
|
||||||
|
class BrowserManager:
|
||||||
|
"""
|
||||||
|
浏览器管理器(异步单例)
|
||||||
|
"""
|
||||||
|
_instance = None
|
||||||
|
_playwright: Optional[Playwright] = None
|
||||||
|
_browser: Optional[Browser] = None
|
||||||
|
_page_pool: Optional[asyncio.Queue] = None
|
||||||
|
_pool_size: int = 3
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
"""
|
||||||
|
初始化 Playwright 和 Browser
|
||||||
|
"""
|
||||||
|
if self._browser is None:
|
||||||
|
try:
|
||||||
|
logger.info("正在启动无头浏览器...")
|
||||||
|
self._playwright = await async_playwright().start()
|
||||||
|
# 启动 Chromium,headless=True 表示无头模式
|
||||||
|
self._browser = await self._playwright.chromium.launch(headless=True)
|
||||||
|
logger.success("无头浏览器启动成功!")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"无头浏览器启动失败: {e}")
|
||||||
|
self._browser = None
|
||||||
|
|
||||||
|
async def init_pool(self, size: int = 3):
|
||||||
|
"""
|
||||||
|
初始化页面池
|
||||||
|
"""
|
||||||
|
if not self._browser:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
if not self._browser:
|
||||||
|
logger.error("浏览器初始化失败,无法创建页面池")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._pool_size = size
|
||||||
|
self._page_pool = asyncio.Queue(maxsize=size)
|
||||||
|
|
||||||
|
logger.info(f"正在初始化页面池 (大小: {size})...")
|
||||||
|
for i in range(size):
|
||||||
|
try:
|
||||||
|
page = await self._browser.new_page()
|
||||||
|
await self._page_pool.put(page)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建页面池页面 {i+1} 失败: {e}")
|
||||||
|
|
||||||
|
logger.success(f"页面池初始化完成,当前可用页面: {self._page_pool.qsize()}")
|
||||||
|
|
||||||
|
async def get_page(self) -> Optional[Page]:
|
||||||
|
"""
|
||||||
|
从池中获取一个页面。如果池未初始化或为空,则尝试创建一个新页面(不入池)。
|
||||||
|
"""
|
||||||
|
if self._page_pool and not self._page_pool.empty():
|
||||||
|
try:
|
||||||
|
page = self._page_pool.get_nowait()
|
||||||
|
# 简单的健康检查
|
||||||
|
if page.is_closed():
|
||||||
|
logger.warning("检测到池中页面已关闭,重新创建一个...")
|
||||||
|
if self._browser:
|
||||||
|
page = await self._browser.new_page()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return page
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 如果池空了或者没初始化,回退到临时创建
|
||||||
|
logger.debug("页面池为空或未初始化,创建临时页面")
|
||||||
|
return await self.get_new_page()
|
||||||
|
|
||||||
|
async def release_page(self, page: Page):
|
||||||
|
"""
|
||||||
|
归还页面到池中。如果池已满或未初始化,则关闭页面。
|
||||||
|
"""
|
||||||
|
if not page or page.is_closed():
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._page_pool:
|
||||||
|
try:
|
||||||
|
# 重置页面状态 (例如清空内容),防止数据污染
|
||||||
|
# 注意: goto('about:blank') 比 close() 快得多
|
||||||
|
await page.goto("about:blank")
|
||||||
|
|
||||||
|
self._page_pool.put_nowait(page)
|
||||||
|
return
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 池满或未启用池,直接关闭
|
||||||
|
await page.close()
|
||||||
|
|
||||||
|
async def get_new_page(self) -> Optional[Page]:
|
||||||
|
"""
|
||||||
|
获取一个新的页面 (Page)
|
||||||
|
|
||||||
|
使用完毕后,调用者应该负责关闭该页面 (await page.close())
|
||||||
|
"""
|
||||||
|
if self._browser is None:
|
||||||
|
logger.warning("浏览器尚未初始化,尝试重新初始化...")
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
if self._browser:
|
||||||
|
try:
|
||||||
|
return await self._browser.new_page()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建新页面失败: {e}")
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def shutdown(self):
|
||||||
|
"""
|
||||||
|
关闭浏览器和 Playwright
|
||||||
|
"""
|
||||||
|
# 清空页面池
|
||||||
|
if self._page_pool:
|
||||||
|
while not self._page_pool.empty():
|
||||||
|
try:
|
||||||
|
page = self._page_pool.get_nowait()
|
||||||
|
await page.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._page_pool = None
|
||||||
|
|
||||||
|
if self._browser:
|
||||||
|
await self._browser.close()
|
||||||
|
self._browser = None
|
||||||
|
logger.info("浏览器已关闭")
|
||||||
|
|
||||||
|
if self._playwright:
|
||||||
|
await self._playwright.stop()
|
||||||
|
self._playwright = None
|
||||||
|
logger.info("Playwright 已停止")
|
||||||
|
|
||||||
|
# 全局浏览器管理器实例
|
||||||
|
browser_manager = BrowserManager()
|
||||||
235
core/managers/command_manager.py
Normal file
235
core/managers/command_manager.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"""
|
||||||
|
命令与事件管理器模块
|
||||||
|
|
||||||
|
该模块定义了 `CommandManager` 类,它是整个机器人框架事件处理的核心。
|
||||||
|
它通过装饰器模式,为插件提供了注册消息指令、通知事件处理器和
|
||||||
|
请求事件处理器的能力。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Callable, Dict, Optional, Tuple
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from models.events.message import MessageSegment
|
||||||
|
|
||||||
|
from models.events.message import MessageSegment
|
||||||
|
|
||||||
|
from ..config_loader import global_config
|
||||||
|
from ..handlers.event_handler import MessageHandler, NoticeHandler, RequestHandler
|
||||||
|
from .redis_manager import redis_manager
|
||||||
|
from .image_manager import image_manager
|
||||||
|
from ..utils.logger import logger
|
||||||
|
|
||||||
|
# 从配置中获取命令前缀
|
||||||
|
_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:
|
||||||
|
"""
|
||||||
|
命令管理器,负责注册和分发所有类型的事件。
|
||||||
|
|
||||||
|
这是一个单例对象(`matcher`),在整个应用中共享。
|
||||||
|
它将不同类型的事件处理委托给专门的处理器类。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, prefixes: Tuple[str, ...]):
|
||||||
|
"""
|
||||||
|
初始化命令管理器。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prefixes (Tuple[str, ...]): 一个包含所有合法命令前缀的元组。
|
||||||
|
"""
|
||||||
|
self.plugins: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
# 初始化专门的事件处理器
|
||||||
|
self.message_handler = MessageHandler(prefixes)
|
||||||
|
self.notice_handler = NoticeHandler()
|
||||||
|
self.request_handler = RequestHandler()
|
||||||
|
|
||||||
|
# 将处理器映射到事件类型
|
||||||
|
self.handler_map = {
|
||||||
|
"message": self.message_handler,
|
||||||
|
"notice": self.notice_handler,
|
||||||
|
"request": self.request_handler,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 注册内置的 /help 命令
|
||||||
|
self._register_internal_commands()
|
||||||
|
|
||||||
|
async def sync_help_pic(self):
|
||||||
|
"""
|
||||||
|
启动时或插件重载时同步 help 图片到 Redis
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("正在生成帮助图片...")
|
||||||
|
|
||||||
|
# 1. 收集插件数据
|
||||||
|
plugins_data = []
|
||||||
|
for plugin_name, meta in self.plugins.items():
|
||||||
|
plugins_data.append({
|
||||||
|
"name": meta.get("name", plugin_name),
|
||||||
|
"description": meta.get("description", "暂无描述"),
|
||||||
|
"usage": meta.get("usage", "暂无用法")
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. 渲染图片
|
||||||
|
# 使用 png 格式以获得更好的文字清晰度
|
||||||
|
base64_str = await image_manager.render_template_to_base64(
|
||||||
|
template_name="help.html",
|
||||||
|
data={"plugins": plugins_data},
|
||||||
|
output_name="help_menu.png",
|
||||||
|
image_type="png"
|
||||||
|
)
|
||||||
|
|
||||||
|
if base64_str:
|
||||||
|
await redis_manager.set("neobot:core:help_pic", base64_str)
|
||||||
|
logger.success("帮助图片已更新并缓存到 Redis")
|
||||||
|
else:
|
||||||
|
logger.error("帮助图片生成失败")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"同步帮助图片失败: {e}")
|
||||||
|
|
||||||
|
def _register_internal_commands(self):
|
||||||
|
"""
|
||||||
|
注册框架内置的命令
|
||||||
|
"""
|
||||||
|
# Help 命令
|
||||||
|
self.message_handler.command("help")(self._help_command)
|
||||||
|
self.plugins["core.help"] = {
|
||||||
|
"name": "帮助",
|
||||||
|
"description": "显示所有可用指令的帮助信息",
|
||||||
|
"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 == plugin_name]
|
||||||
|
for name in plugins_to_remove:
|
||||||
|
del self.plugins[name]
|
||||||
|
|
||||||
|
# --- 装饰器代理 ---
|
||||||
|
|
||||||
|
def on_message(self) -> Callable:
|
||||||
|
"""
|
||||||
|
装饰器:注册一个通用的消息处理器。
|
||||||
|
"""
|
||||||
|
return self.message_handler.on_message()
|
||||||
|
|
||||||
|
def command(
|
||||||
|
self,
|
||||||
|
*names: str,
|
||||||
|
permission: Optional[Any] = None,
|
||||||
|
override_permission_check: bool = False,
|
||||||
|
) -> Callable:
|
||||||
|
"""
|
||||||
|
装饰器:注册一个消息指令处理器。
|
||||||
|
"""
|
||||||
|
return self.message_handler.command(
|
||||||
|
*names,
|
||||||
|
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.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)
|
||||||
|
if handler:
|
||||||
|
await handler.handle(bot, event)
|
||||||
|
|
||||||
|
# --- 内置命令实现 ---
|
||||||
|
|
||||||
|
async def _help_command(self, bot, event):
|
||||||
|
"""
|
||||||
|
内置的 `/help` 命令的实现。
|
||||||
|
直接从 Redis 获取缓存的图片。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. 尝试从 Redis 获取
|
||||||
|
help_pic = await redis_manager.get("neobot:core:help_pic")
|
||||||
|
|
||||||
|
if not help_pic:
|
||||||
|
await bot.send(event, "帮助图片缓存缺失,正在重新生成...")
|
||||||
|
await self.sync_help_pic()
|
||||||
|
help_pic = await redis_manager.get("neobot:core:help_pic")
|
||||||
|
|
||||||
|
if help_pic:
|
||||||
|
await bot.send(event, MessageSegment.image(help_pic))
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取或生成帮助图片失败: {e}")
|
||||||
|
|
||||||
|
# 2. 最后的兜底:发送纯文本
|
||||||
|
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())
|
||||||
|
|
||||||
|
|
||||||
|
# 实例化全局唯一的命令管理器
|
||||||
|
matcher = CommandManager(prefixes=_final_prefixes)
|
||||||
1
core/managers/help_pic.py
Normal file
1
core/managers/help_pic.py
Normal file
File diff suppressed because one or more lines are too long
123
core/managers/image_manager.py
Normal file
123
core/managers/image_manager.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
图片生成管理器模块
|
||||||
|
|
||||||
|
负责管理图片生成相关的逻辑,支持多种渲染引擎(目前支持 Playwright)。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from jinja2 import Template
|
||||||
|
|
||||||
|
from .browser_manager import browser_manager
|
||||||
|
from ..utils.logger import logger
|
||||||
|
|
||||||
|
class ImageManager:
|
||||||
|
"""
|
||||||
|
图片生成管理器(单例)
|
||||||
|
"""
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# 模板目录
|
||||||
|
self.template_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "templates")
|
||||||
|
# 临时文件目录
|
||||||
|
# core/managers/image_manager.py -> core/managers -> core -> core/data/temp
|
||||||
|
self.temp_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "temp")
|
||||||
|
os.makedirs(self.temp_dir, exist_ok=True)
|
||||||
|
# 模板缓存
|
||||||
|
self._template_cache: Dict[str, Template] = {}
|
||||||
|
|
||||||
|
async def render_template(self, template_name: str, data: Dict[str, Any], output_name: str = "output.png", quality: int = 80, image_type: str = "png") -> Optional[str]:
|
||||||
|
"""
|
||||||
|
使用 Playwright 渲染 Jinja2 模板并保存为图片文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_name (str): 模板文件名 (例如 "help.html")
|
||||||
|
data (Dict[str, Any]): 传递给模板的数据字典
|
||||||
|
output_name (str, optional): 输出文件名. Defaults to "output.png".
|
||||||
|
quality (int, optional): JPEG 质量 (0-100). 仅在 image_type 为 jpeg 时有效. Defaults to 80.
|
||||||
|
image_type (str, optional): 图片类型 ('png' or 'jpeg'). Defaults to "png".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[str]: 生成图片的绝对路径,如果失败则返回 None
|
||||||
|
"""
|
||||||
|
template_path = os.path.join(self.template_dir, template_name)
|
||||||
|
if not os.path.exists(template_path):
|
||||||
|
logger.error(f"模板文件未找到: {template_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 渲染 HTML (使用缓存)
|
||||||
|
if template_name in self._template_cache:
|
||||||
|
template = self._template_cache[template_name]
|
||||||
|
else:
|
||||||
|
with open(template_path, "r", encoding="utf-8") as f:
|
||||||
|
template_str = f.read()
|
||||||
|
template = Template(template_str)
|
||||||
|
self._template_cache[template_name] = template
|
||||||
|
|
||||||
|
html_content = template.render(**data)
|
||||||
|
|
||||||
|
# 2. 使用浏览器截图
|
||||||
|
# 改为从池中获取页面
|
||||||
|
page = await browser_manager.get_page()
|
||||||
|
if not page:
|
||||||
|
logger.error("无法获取浏览器页面")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 设置视口
|
||||||
|
await page.set_viewport_size({"width": 650, "height": 100})
|
||||||
|
|
||||||
|
# 加载内容
|
||||||
|
await page.set_content(html_content)
|
||||||
|
await page.wait_for_selector("body")
|
||||||
|
|
||||||
|
# 截图
|
||||||
|
screenshot_args = {'full_page': True, 'type': image_type}
|
||||||
|
if image_type == 'jpeg':
|
||||||
|
screenshot_args['quality'] = quality
|
||||||
|
|
||||||
|
screenshot_bytes = await page.screenshot(**screenshot_args) # type: ignore
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# 归还页面到池中,而不是直接关闭
|
||||||
|
await browser_manager.release_page(page)
|
||||||
|
|
||||||
|
# 3. 保存文件
|
||||||
|
output_path = os.path.join(self.temp_dir, output_name)
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(screenshot_bytes)
|
||||||
|
|
||||||
|
logger.info(f"图片已生成: {output_path} ({len(screenshot_bytes)/1024:.2f} KB)")
|
||||||
|
return os.path.abspath(output_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"渲染模板 {template_name} 失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def render_template_to_base64(self, template_name: str, data: Dict[str, Any], output_name: str = "output.png", quality: int = 80, image_type: str = "png") -> Optional[str]:
|
||||||
|
"""
|
||||||
|
渲染模板并返回 Base64 编码的图片字符串
|
||||||
|
"""
|
||||||
|
file_path = await self.render_template(template_name, data, output_name, quality, image_type)
|
||||||
|
if not file_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
mime_type = "image/jpeg" if image_type == "jpeg" else "image/png"
|
||||||
|
return f"data:{mime_type};base64," + base64.b64encode(content).decode("utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"读取图片文件失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 全局图片管理器实例
|
||||||
|
image_manager = ImageManager()
|
||||||
209
core/managers/permission_manager.py
Normal file
209
core/managers/permission_manager.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
权限管理器模块
|
||||||
|
|
||||||
|
该模块负责管理用户权限,支持 admin、op、user 三个权限级别。
|
||||||
|
以 Redis Hash 作为主要数据源,文件仅用作备份和首次数据迁移。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from ..utils.logger import logger
|
||||||
|
from ..utils.singleton import Singleton
|
||||||
|
from .admin_manager import admin_manager
|
||||||
|
from .redis_manager import redis_manager
|
||||||
|
from ..permission import Permission
|
||||||
|
|
||||||
|
|
||||||
|
# 用于从字符串名称查找权限对象的字典
|
||||||
|
_PERMISSIONS: Dict[str, Permission] = {
|
||||||
|
p.value: p for p in Permission
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionManager(Singleton):
|
||||||
|
"""
|
||||||
|
权限管理器类
|
||||||
|
|
||||||
|
以 Redis Hash 作为权限数据的唯一真实来源,提供高速的读写能力。
|
||||||
|
文件 (permissions.json) 仅用于首次启动时的数据迁移和作为灾备。
|
||||||
|
"""
|
||||||
|
_REDIS_KEY = "neobot:permissions" # 用于存储用户权限的 Redis Hash 键
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""
|
||||||
|
初始化权限管理器
|
||||||
|
"""
|
||||||
|
if hasattr(self, '_initialized') and self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 权限数据文件路径,主要用于备份和首次迁移
|
||||||
|
self.data_file = os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"..",
|
||||||
|
"data",
|
||||||
|
"permissions.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(self.data_file), exist_ok=True)
|
||||||
|
logger.info("权限管理器初始化完成")
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
"""
|
||||||
|
异步初始化,检查 Redis 数据,如果为空则尝试从文件迁移
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not await redis_manager.redis.exists(self._REDIS_KEY):
|
||||||
|
logger.info("Redis 中未找到权限数据,尝试从 permissions.json 文件迁移...")
|
||||||
|
await self._migrate_from_file_to_redis()
|
||||||
|
else:
|
||||||
|
perm_count = await redis_manager.redis.hlen(self._REDIS_KEY)
|
||||||
|
logger.info(f"Redis 中已存在权限数据,共 {perm_count} 条。")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"初始化权限数据时发生错误: {e}")
|
||||||
|
|
||||||
|
async def _migrate_from_file_to_redis(self):
|
||||||
|
"""
|
||||||
|
从 permissions.json 加载权限数据并存入 Redis Hash
|
||||||
|
"""
|
||||||
|
perms_to_migrate = {}
|
||||||
|
try:
|
||||||
|
if os.path.exists(self.data_file):
|
||||||
|
with open(self.data_file, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
perms_to_migrate = data.get("users", {})
|
||||||
|
|
||||||
|
if perms_to_migrate:
|
||||||
|
# 使用 pipeline 批量写入,提高效率
|
||||||
|
async with redis_manager.redis.pipeline(transaction=True) as pipe:
|
||||||
|
for user_id, level_name in perms_to_migrate.items():
|
||||||
|
pipe.hset(self._REDIS_KEY, user_id, level_name)
|
||||||
|
await pipe.execute()
|
||||||
|
logger.success(f"成功从文件迁移 {len(perms_to_migrate)} 条权限数据到 Redis。")
|
||||||
|
else:
|
||||||
|
logger.info("permissions.json 文件为空或不存在,无需迁移。")
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.error(f"解析 permissions.json 失败,无法迁移: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"迁移权限数据到 Redis 失败: {e}")
|
||||||
|
|
||||||
|
async def _save_to_file_backup(self):
|
||||||
|
"""
|
||||||
|
将 Redis 中的权限数据完整备份到 permissions.json
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
all_perms = await redis_manager.redis.hgetall(self._REDIS_KEY)
|
||||||
|
# Redis 返回的是 bytes,需要解码
|
||||||
|
users_data = {k.decode('utf-8'): v.decode('utf-8') for k, v in all_perms.items()}
|
||||||
|
with open(self.data_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"users": users_data}, f, indent=2, ensure_ascii=False)
|
||||||
|
logger.debug(f"权限数据已备份到 {self.data_file}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"备份权限数据到 permissions.json 失败: {e}")
|
||||||
|
|
||||||
|
async def get_user_permission(self, user_id: int) -> Permission:
|
||||||
|
"""
|
||||||
|
获取指定用户的权限对象
|
||||||
|
|
||||||
|
优先检查是否为机器人管理员,然后从 Redis 查询。
|
||||||
|
"""
|
||||||
|
if await admin_manager.is_admin(user_id):
|
||||||
|
return Permission.ADMIN
|
||||||
|
|
||||||
|
try:
|
||||||
|
level_name_bytes = await redis_manager.redis.hget(self._REDIS_KEY, str(user_id))
|
||||||
|
if level_name_bytes:
|
||||||
|
level_name = level_name_bytes.decode('utf-8')
|
||||||
|
return _PERMISSIONS.get(level_name, Permission.USER)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"从 Redis 获取用户 {user_id} 权限失败: {e}")
|
||||||
|
|
||||||
|
return Permission.USER
|
||||||
|
|
||||||
|
async def set_user_permission(self, user_id: int, permission: Permission) -> None:
|
||||||
|
"""
|
||||||
|
在 Redis 中设置指定用户的权限级别,并更新文件备份
|
||||||
|
"""
|
||||||
|
if not isinstance(permission, Permission):
|
||||||
|
raise ValueError(f"无效的权限对象: {permission}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await redis_manager.redis.hset(self._REDIS_KEY, str(user_id), permission.value)
|
||||||
|
await self._save_to_file_backup()
|
||||||
|
logger.info(f"已在 Redis 中设置用户 {user_id} 的权限为 {permission.value}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"在 Redis 中设置用户 {user_id} 权限失败: {e}")
|
||||||
|
|
||||||
|
async def remove_user(self, user_id: int) -> None:
|
||||||
|
"""
|
||||||
|
从 Redis 中移除指定用户的权限设置,并更新文件备份
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if await redis_manager.redis.hdel(self._REDIS_KEY, str(user_id)):
|
||||||
|
await self._save_to_file_backup()
|
||||||
|
logger.info(f"已从 Redis 中移除用户 {user_id} 的权限设置")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"从 Redis 移除用户 {user_id} 权限失败: {e}")
|
||||||
|
|
||||||
|
async def check_permission(self, user_id: int, required_permission: Permission) -> bool:
|
||||||
|
"""
|
||||||
|
检查用户是否具有指定权限级别
|
||||||
|
"""
|
||||||
|
user_permission = await self.get_user_permission(user_id)
|
||||||
|
return user_permission >= required_permission
|
||||||
|
|
||||||
|
async def get_all_user_permissions(self) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
获取所有已配置的用户权限(合并 Redis 和 AdminManager)
|
||||||
|
"""
|
||||||
|
permissions = {}
|
||||||
|
try:
|
||||||
|
# 从 Redis 获取基础权限
|
||||||
|
all_perms = await redis_manager.redis.hgetall(self._REDIS_KEY)
|
||||||
|
permissions = {k.decode('utf-8'): v.decode('utf-8') for k, v in all_perms.items()}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"从 Redis 获取所有权限失败: {e}")
|
||||||
|
|
||||||
|
# 合并 AdminManager 中的管理员,ADMIN 权限覆盖一切
|
||||||
|
try:
|
||||||
|
admins = await admin_manager.get_all_admins()
|
||||||
|
for admin_id in admins:
|
||||||
|
permissions[str(admin_id)] = Permission.ADMIN.value
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取管理员列表以合并权限时失败: {e}")
|
||||||
|
|
||||||
|
return permissions
|
||||||
|
|
||||||
|
async def clear_all(self) -> None:
|
||||||
|
"""
|
||||||
|
清空 Redis 中的所有权限设置,并更新备份文件
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await redis_manager.redis.delete(self._REDIS_KEY)
|
||||||
|
await self._save_to_file_backup()
|
||||||
|
logger.info("已清空 Redis 中的所有权限设置")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"清空 Redis 权限数据失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
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, Permission.ADMIN):
|
||||||
|
return await func(event, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
# 假设 event 对象有 reply 方法
|
||||||
|
if hasattr(event, "reply"):
|
||||||
|
await event.reply("抱歉,您没有权限执行此命令。")
|
||||||
|
return None
|
||||||
|
return wrapper
|
||||||
134
core/managers/plugin_manager.py
Normal file
134
core/managers/plugin_manager.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
插件管理器模块
|
||||||
|
|
||||||
|
负责扫描、加载和管理 `plugins` 目录下的所有插件。
|
||||||
|
"""
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import pkgutil
|
||||||
|
import sys
|
||||||
|
from typing import Set
|
||||||
|
from .command_manager import CommandManager
|
||||||
|
|
||||||
|
from ..utils.exceptions import SyncHandlerError, PluginError, PluginLoadError, PluginReloadError, PluginNotFoundError
|
||||||
|
from ..utils.logger import logger, ModuleLogger
|
||||||
|
from ..utils.error_codes import ErrorCode, create_error_response
|
||||||
|
|
||||||
|
# 确保logger在模块级别可见
|
||||||
|
__all__ = ['PluginManager', 'logger']
|
||||||
|
|
||||||
|
|
||||||
|
class PluginManager:
|
||||||
|
"""
|
||||||
|
插件管理器类
|
||||||
|
"""
|
||||||
|
def __init__(self, command_manager: "CommandManager") -> None:
|
||||||
|
"""
|
||||||
|
初始化插件管理器
|
||||||
|
|
||||||
|
:param command_manager: CommandManager的实例
|
||||||
|
"""
|
||||||
|
self.command_manager = command_manager
|
||||||
|
self.loaded_plugins: Set[str] = set()
|
||||||
|
# 创建模块专用日志记录器
|
||||||
|
self.logger = ModuleLogger("PluginManager")
|
||||||
|
|
||||||
|
def load_all_plugins(self) -> None:
|
||||||
|
"""
|
||||||
|
扫描并加载 `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):
|
||||||
|
self.logger.error(f"插件目录不存在: {plugin_dir}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.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}"
|
||||||
|
|
||||||
|
action = "加载" # 初始化默认值
|
||||||
|
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 "文件"
|
||||||
|
self.logger.success(f" [{type_str}] 成功{action}: {module_name}")
|
||||||
|
except SyncHandlerError as e:
|
||||||
|
error = PluginLoadError(
|
||||||
|
plugin_name=module_name,
|
||||||
|
message=f"同步处理器错误: {str(e)}",
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
self.logger.error(f" 插件 {module_name} 加载失败: {error.message} (跳过此插件)")
|
||||||
|
self.logger.log_custom_exception(error)
|
||||||
|
except Exception as e:
|
||||||
|
error = PluginLoadError(
|
||||||
|
plugin_name=module_name,
|
||||||
|
message=f"未知错误: {str(e)}",
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
self.logger.exception(f" 加载插件 {module_name} 失败: {error.message}")
|
||||||
|
self.logger.log_custom_exception(error)
|
||||||
|
|
||||||
|
def reload_plugin(self, full_module_name: str) -> None:
|
||||||
|
"""
|
||||||
|
精确重载单个插件。
|
||||||
|
"""
|
||||||
|
if full_module_name not in self.loaded_plugins:
|
||||||
|
self.logger.warning(f"尝试重载一个未被加载的插件: {full_module_name},将按首次加载处理。")
|
||||||
|
|
||||||
|
if full_module_name not in sys.modules:
|
||||||
|
error = PluginNotFoundError(
|
||||||
|
plugin_name=full_module_name,
|
||||||
|
message="模块未在sys.modules中找到"
|
||||||
|
)
|
||||||
|
self.logger.error(f"重载失败: {error.message}")
|
||||||
|
self.logger.log_custom_exception(error)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
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__")
|
||||||
|
self.command_manager.plugins[full_module_name] = meta
|
||||||
|
|
||||||
|
self.logger.success(f"插件 {full_module_name} 已成功重载。")
|
||||||
|
except SyncHandlerError as e:
|
||||||
|
error = PluginReloadError(
|
||||||
|
plugin_name=full_module_name,
|
||||||
|
message=f"同步处理器错误: {str(e)}",
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
self.logger.error(f"重载插件 {full_module_name} 失败: {error.message}")
|
||||||
|
self.logger.log_custom_exception(error)
|
||||||
|
except Exception as e:
|
||||||
|
error = PluginReloadError(
|
||||||
|
plugin_name=full_module_name,
|
||||||
|
message=f"未知错误: {str(e)}",
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
self.logger.exception(f"重载插件 {full_module_name} 时发生错误: {error.message}")
|
||||||
|
self.logger.log_custom_exception(error)
|
||||||
68
core/managers/redis_manager.py
Normal file
68
core/managers/redis_manager.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import redis.asyncio as redis
|
||||||
|
from ..config_loader import global_config as config
|
||||||
|
from ..utils.logger import logger
|
||||||
|
|
||||||
|
class RedisManager:
|
||||||
|
"""
|
||||||
|
Redis 连接管理器(异步单例)
|
||||||
|
"""
|
||||||
|
_instance = None
|
||||||
|
_redis = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
"""
|
||||||
|
异步初始化 Redis 连接并进行健康检查
|
||||||
|
"""
|
||||||
|
if self._redis is None:
|
||||||
|
try:
|
||||||
|
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}")
|
||||||
|
|
||||||
|
self._redis = redis.Redis(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
db=db,
|
||||||
|
password=password,
|
||||||
|
decode_responses=True
|
||||||
|
)
|
||||||
|
if await self._redis.ping():
|
||||||
|
logger.success("Redis 连接成功!")
|
||||||
|
else:
|
||||||
|
logger.error("Redis 连接失败: PING 命令无响应")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Redis 初始化时发生未知错误: {e}")
|
||||||
|
self._redis = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def redis(self):
|
||||||
|
"""
|
||||||
|
获取 Redis 连接实例
|
||||||
|
"""
|
||||||
|
if self._redis is None:
|
||||||
|
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()
|
||||||
42
core/permission.py
Normal file
42
core/permission.py
Normal file
@@ -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]
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
"""
|
|
||||||
插件管理器模块
|
|
||||||
|
|
||||||
负责扫描、加载和管理 `base_plugins` 目录下的所有插件。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import pkgutil
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from core.command_manager import matcher
|
|
||||||
|
|
||||||
|
|
||||||
def load_all_plugins():
|
|
||||||
"""
|
|
||||||
扫描并加载 `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"
|
|
||||||
|
|
||||||
print(f" 正在从 {package_name} 加载插件...")
|
|
||||||
|
|
||||||
for loader, module_name, is_pkg in pkgutil.iter_modules([plugin_dir]):
|
|
||||||
full_module_name = f"{package_name}.{module_name}"
|
|
||||||
|
|
||||||
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 = "加载"
|
|
||||||
|
|
||||||
# 提取插件元数据
|
|
||||||
if hasattr(module, "__plugin_meta__"):
|
|
||||||
meta = getattr(module, "__plugin_meta__")
|
|
||||||
matcher.plugins[full_module_name] = meta
|
|
||||||
|
|
||||||
type_str = "包" if is_pkg else "文件"
|
|
||||||
print(f" [{type_str}] 成功{action}: {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 = {}
|
|
||||||
self.load()
|
|
||||||
|
|
||||||
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, [])
|
|
||||||
try:
|
|
||||||
with open(self.data_file, "r", encoding="utf-8") as f:
|
|
||||||
self.data = json.load(f)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
self.data = {}
|
|
||||||
|
|
||||||
def save(self):
|
|
||||||
"""保存配置到文件"""
|
|
||||||
with open(self.data_file, "w", encoding="utf-8") as f:
|
|
||||||
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):
|
|
||||||
"""设置配置项"""
|
|
||||||
self.data[key] = value
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def add(self, key, value):
|
|
||||||
"""添加配置项"""
|
|
||||||
if key not in self.data:
|
|
||||||
self.data[key] = []
|
|
||||||
self.data[key].append(value)
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def remove(self, key):
|
|
||||||
"""删除配置项"""
|
|
||||||
if key in self.data:
|
|
||||||
del self.data[key]
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
"""清空所有配置"""
|
|
||||||
self.data.clear()
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def get_all(self):
|
|
||||||
return self.data.copy()
|
|
||||||
45
core/utils/__init__.py
Normal file
45
core/utils/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
工具函数包
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 导出核心工具
|
||||||
|
from .logger import logger, ModuleLogger, log_exception
|
||||||
|
from .exceptions import *
|
||||||
|
from .json_utils import *
|
||||||
|
from .singleton import singleton
|
||||||
|
from .executor import run_in_thread_pool, initialize_executor
|
||||||
|
from .performance import (
|
||||||
|
timeit,
|
||||||
|
profile,
|
||||||
|
aprofile,
|
||||||
|
memory_profile,
|
||||||
|
memory_profile_decorator,
|
||||||
|
performance_monitor,
|
||||||
|
PerformanceStats,
|
||||||
|
performance_stats,
|
||||||
|
global_stats
|
||||||
|
)
|
||||||
|
from .error_codes import ErrorCode, get_error_message, create_error_response, exception_to_error_response
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'logger',
|
||||||
|
'ModuleLogger',
|
||||||
|
'log_exception',
|
||||||
|
'timeit',
|
||||||
|
'profile',
|
||||||
|
'aprofile',
|
||||||
|
'memory_profile',
|
||||||
|
'memory_profile_decorator',
|
||||||
|
'performance_monitor',
|
||||||
|
'PerformanceStats',
|
||||||
|
'performance_stats',
|
||||||
|
'global_stats',
|
||||||
|
'run_in_thread_pool',
|
||||||
|
'initialize_executor',
|
||||||
|
'singleton',
|
||||||
|
'ErrorCode',
|
||||||
|
'get_error_message',
|
||||||
|
'create_error_response',
|
||||||
|
'exception_to_error_response'
|
||||||
|
]
|
||||||
234
core/utils/error_codes.py
Normal file
234
core/utils/error_codes.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"""
|
||||||
|
错误码和统一响应格式模块
|
||||||
|
|
||||||
|
该模块定义了项目中使用的错误码和统一的错误响应格式,确保所有模块返回一致的错误信息。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 错误码定义
|
||||||
|
class ErrorCode:
|
||||||
|
"""
|
||||||
|
错误码枚举类,包含所有系统错误码的定义。
|
||||||
|
|
||||||
|
错误码规则:
|
||||||
|
- 1xxx: 系统级错误
|
||||||
|
- 2xxx: WebSocket相关错误
|
||||||
|
- 3xxx: 插件相关错误
|
||||||
|
- 4xxx: 配置相关错误
|
||||||
|
- 5xxx: 权限相关错误
|
||||||
|
- 6xxx: 命令相关错误
|
||||||
|
- 7xxx: Redis相关错误
|
||||||
|
- 8xxx: 浏览器管理器相关错误
|
||||||
|
- 9xxx: 代码执行相关错误
|
||||||
|
"""
|
||||||
|
# 系统级错误
|
||||||
|
SUCCESS = 0 # 成功
|
||||||
|
UNKNOWN_ERROR = 1000 # 未知错误
|
||||||
|
INVALID_PARAMETER = 1001 # 参数无效
|
||||||
|
DATABASE_ERROR = 1002 # 数据库错误
|
||||||
|
NETWORK_ERROR = 1003 # 网络错误
|
||||||
|
TIMEOUT_ERROR = 1004 # 超时错误
|
||||||
|
RESOURCE_EXHAUSTED = 1005 # 资源耗尽
|
||||||
|
|
||||||
|
# WebSocket相关错误
|
||||||
|
WS_CONNECTION_FAILED = 2000 # WebSocket连接失败
|
||||||
|
WS_AUTH_FAILED = 2001 # WebSocket认证失败
|
||||||
|
WS_DISCONNECTED = 2002 # WebSocket已断开
|
||||||
|
WS_MESSAGE_ERROR = 2003 # WebSocket消息错误
|
||||||
|
|
||||||
|
# 插件相关错误
|
||||||
|
PLUGIN_LOAD_FAILED = 3000 # 插件加载失败
|
||||||
|
PLUGIN_RELOAD_FAILED = 3001 # 插件重载失败
|
||||||
|
PLUGIN_NOT_FOUND = 3002 # 插件未找到
|
||||||
|
PLUGIN_INVALID = 3003 # 插件无效
|
||||||
|
PLUGIN_DEPENDENCY_ERROR = 3004 # 插件依赖错误
|
||||||
|
|
||||||
|
# 配置相关错误
|
||||||
|
CONFIG_NOT_FOUND = 4000 # 配置文件未找到
|
||||||
|
CONFIG_PARSE_ERROR = 4001 # 配置解析错误
|
||||||
|
CONFIG_VALIDATION_ERROR = 4002 # 配置验证错误
|
||||||
|
CONFIG_KEY_NOT_FOUND = 4003 # 配置项未找到
|
||||||
|
|
||||||
|
# 权限相关错误
|
||||||
|
PERMISSION_DENIED = 5000 # 权限不足
|
||||||
|
NOT_ADMIN = 5001 # 不是管理员
|
||||||
|
USER_BANNED = 5002 # 用户已被禁止
|
||||||
|
|
||||||
|
# 命令相关错误
|
||||||
|
COMMAND_NOT_FOUND = 6000 # 命令未找到
|
||||||
|
COMMAND_PARAM_ERROR = 6001 # 命令参数错误
|
||||||
|
COMMAND_EXECUTE_ERROR = 6002 # 命令执行错误
|
||||||
|
COMMAND_TIMEOUT = 6003 # 命令执行超时
|
||||||
|
|
||||||
|
# Redis相关错误
|
||||||
|
REDIS_CONNECTION_FAILED = 7000 # Redis连接失败
|
||||||
|
REDIS_OPERATION_ERROR = 7001 # Redis操作错误
|
||||||
|
|
||||||
|
# 浏览器管理器相关错误
|
||||||
|
BROWSER_INIT_FAILED = 8000 # 浏览器初始化失败
|
||||||
|
BROWSER_POOL_ERROR = 8001 # 浏览器池错误
|
||||||
|
BROWSER_OPERATION_ERROR = 8002 # 浏览器操作错误
|
||||||
|
|
||||||
|
# 代码执行相关错误
|
||||||
|
CODE_EXECUTE_ERROR = 9000 # 代码执行错误
|
||||||
|
CODE_SECURITY_ERROR = 9001 # 代码安全错误
|
||||||
|
|
||||||
|
|
||||||
|
# 错误码到错误消息的映射
|
||||||
|
ERROR_MESSAGES = {
|
||||||
|
# 系统级错误
|
||||||
|
ErrorCode.SUCCESS: "操作成功",
|
||||||
|
ErrorCode.UNKNOWN_ERROR: "未知错误",
|
||||||
|
ErrorCode.INVALID_PARAMETER: "参数无效",
|
||||||
|
ErrorCode.DATABASE_ERROR: "数据库错误",
|
||||||
|
ErrorCode.NETWORK_ERROR: "网络错误",
|
||||||
|
ErrorCode.TIMEOUT_ERROR: "操作超时",
|
||||||
|
ErrorCode.RESOURCE_EXHAUSTED: "资源耗尽",
|
||||||
|
|
||||||
|
# WebSocket相关错误
|
||||||
|
ErrorCode.WS_CONNECTION_FAILED: "WebSocket连接失败",
|
||||||
|
ErrorCode.WS_AUTH_FAILED: "WebSocket认证失败",
|
||||||
|
ErrorCode.WS_DISCONNECTED: "WebSocket已断开连接",
|
||||||
|
ErrorCode.WS_MESSAGE_ERROR: "WebSocket消息格式错误",
|
||||||
|
|
||||||
|
# 插件相关错误
|
||||||
|
ErrorCode.PLUGIN_LOAD_FAILED: "插件加载失败",
|
||||||
|
ErrorCode.PLUGIN_RELOAD_FAILED: "插件重载失败",
|
||||||
|
ErrorCode.PLUGIN_NOT_FOUND: "插件未找到",
|
||||||
|
ErrorCode.PLUGIN_INVALID: "插件无效",
|
||||||
|
ErrorCode.PLUGIN_DEPENDENCY_ERROR: "插件依赖错误",
|
||||||
|
|
||||||
|
# 配置相关错误
|
||||||
|
ErrorCode.CONFIG_NOT_FOUND: "配置文件未找到",
|
||||||
|
ErrorCode.CONFIG_PARSE_ERROR: "配置文件解析错误",
|
||||||
|
ErrorCode.CONFIG_VALIDATION_ERROR: "配置验证失败",
|
||||||
|
ErrorCode.CONFIG_KEY_NOT_FOUND: "配置项未找到",
|
||||||
|
|
||||||
|
# 权限相关错误
|
||||||
|
ErrorCode.PERMISSION_DENIED: "权限不足",
|
||||||
|
ErrorCode.NOT_ADMIN: "需要管理员权限",
|
||||||
|
ErrorCode.USER_BANNED: "用户已被禁止操作",
|
||||||
|
|
||||||
|
# 命令相关错误
|
||||||
|
ErrorCode.COMMAND_NOT_FOUND: "命令未找到",
|
||||||
|
ErrorCode.COMMAND_PARAM_ERROR: "命令参数错误",
|
||||||
|
ErrorCode.COMMAND_EXECUTE_ERROR: "命令执行错误",
|
||||||
|
ErrorCode.COMMAND_TIMEOUT: "命令执行超时",
|
||||||
|
|
||||||
|
# Redis相关错误
|
||||||
|
ErrorCode.REDIS_CONNECTION_FAILED: "Redis连接失败",
|
||||||
|
ErrorCode.REDIS_OPERATION_ERROR: "Redis操作错误",
|
||||||
|
|
||||||
|
# 浏览器管理器相关错误
|
||||||
|
ErrorCode.BROWSER_INIT_FAILED: "浏览器初始化失败",
|
||||||
|
ErrorCode.BROWSER_POOL_ERROR: "浏览器池错误",
|
||||||
|
ErrorCode.BROWSER_OPERATION_ERROR: "浏览器操作错误",
|
||||||
|
|
||||||
|
# 代码执行相关错误
|
||||||
|
ErrorCode.CODE_EXECUTE_ERROR: "代码执行错误",
|
||||||
|
ErrorCode.CODE_SECURITY_ERROR: "代码存在安全风险",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_error_message(code: int) -> str:
|
||||||
|
"""
|
||||||
|
根据错误码获取错误消息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: 错误码
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 错误消息
|
||||||
|
"""
|
||||||
|
return ERROR_MESSAGES.get(code, ERROR_MESSAGES[ErrorCode.UNKNOWN_ERROR])
|
||||||
|
|
||||||
|
|
||||||
|
def create_error_response(code: int, message: str = None, data: dict = None, request_id: str = None) -> dict:
|
||||||
|
"""
|
||||||
|
创建统一格式的错误响应
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: 错误码
|
||||||
|
message: 错误消息(可选,如果未提供则使用默认消息)
|
||||||
|
data: 附加数据(可选)
|
||||||
|
request_id: 请求ID(可选,用于追踪请求)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 统一格式的错误响应
|
||||||
|
"""
|
||||||
|
error_message = message if message is not None else get_error_message(code)
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"code": code,
|
||||||
|
"message": error_message,
|
||||||
|
"success": code == ErrorCode.SUCCESS,
|
||||||
|
}
|
||||||
|
|
||||||
|
if data is not None:
|
||||||
|
response["data"] = data
|
||||||
|
|
||||||
|
if request_id is not None:
|
||||||
|
response["request_id"] = request_id
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def exception_to_error_response(exception: Exception, code: int = None, request_id: str = None) -> dict:
|
||||||
|
"""
|
||||||
|
将异常对象转换为统一格式的错误响应
|
||||||
|
|
||||||
|
Args:
|
||||||
|
exception: 异常对象
|
||||||
|
code: 错误码(可选,如果未提供则根据异常类型自动推断)
|
||||||
|
request_id: 请求ID(可选,用于追踪请求)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 统一格式的错误响应
|
||||||
|
"""
|
||||||
|
# 从自定义异常类中提取错误码
|
||||||
|
if hasattr(exception, "code") and exception.code is not None:
|
||||||
|
code = exception.code
|
||||||
|
|
||||||
|
# 如果仍未找到错误码,则根据异常类型推断
|
||||||
|
if code is None:
|
||||||
|
from .exceptions import (
|
||||||
|
WebSocketError, PluginError, ConfigError, PermissionError,
|
||||||
|
CommandError, RedisError, BrowserManagerError, CodeExecutionError
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(exception, WebSocketError):
|
||||||
|
code = ErrorCode.WS_CONNECTION_FAILED
|
||||||
|
elif isinstance(exception, PluginError):
|
||||||
|
code = ErrorCode.PLUGIN_LOAD_FAILED
|
||||||
|
elif isinstance(exception, ConfigError):
|
||||||
|
code = ErrorCode.CONFIG_PARSE_ERROR
|
||||||
|
elif isinstance(exception, PermissionError):
|
||||||
|
code = ErrorCode.PERMISSION_DENIED
|
||||||
|
elif isinstance(exception, CommandError):
|
||||||
|
code = ErrorCode.COMMAND_EXECUTE_ERROR
|
||||||
|
elif isinstance(exception, RedisError):
|
||||||
|
code = ErrorCode.REDIS_OPERATION_ERROR
|
||||||
|
elif isinstance(exception, BrowserManagerError):
|
||||||
|
code = ErrorCode.BROWSER_OPERATION_ERROR
|
||||||
|
elif isinstance(exception, CodeExecutionError):
|
||||||
|
code = ErrorCode.CODE_EXECUTE_ERROR
|
||||||
|
else:
|
||||||
|
code = ErrorCode.UNKNOWN_ERROR
|
||||||
|
|
||||||
|
# 获取错误消息
|
||||||
|
message = str(exception)
|
||||||
|
|
||||||
|
# 如果异常有原始错误,也包含在响应中
|
||||||
|
data = None
|
||||||
|
if hasattr(exception, "original_error") and exception.original_error is not None:
|
||||||
|
data = {"original_error": str(exception.original_error)}
|
||||||
|
|
||||||
|
return create_error_response(code, message, data, request_id)
|
||||||
|
|
||||||
|
|
||||||
|
# 将错误码导出以便其他模块使用
|
||||||
|
__all__ = [
|
||||||
|
"ErrorCode",
|
||||||
|
"get_error_message",
|
||||||
|
"create_error_response",
|
||||||
|
"exception_to_error_response"
|
||||||
|
]
|
||||||
221
core/utils/exceptions.py
Normal file
221
core/utils/exceptions.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""
|
||||||
|
自定义异常模块
|
||||||
|
|
||||||
|
该模块定义了项目中使用的各种自定义异常类,用于提供更精确、更友好的错误提示。
|
||||||
|
"""
|
||||||
|
|
||||||
|
class SyncHandlerError(Exception):
|
||||||
|
"""
|
||||||
|
当尝试注册同步函数作为异步事件处理器时抛出此异常。
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketError(Exception):
|
||||||
|
"""
|
||||||
|
WebSocket相关错误的基类。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: 错误消息
|
||||||
|
code: 错误代码(可选)
|
||||||
|
original_error: 原始异常对象(可选)
|
||||||
|
"""
|
||||||
|
def __init__(self, message, code=None, original_error=None):
|
||||||
|
self.message = message
|
||||||
|
self.code = code
|
||||||
|
self.original_error = original_error
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketConnectionError(WebSocketError):
|
||||||
|
"""
|
||||||
|
WebSocket连接失败时抛出此异常。
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketAuthenticationError(WebSocketError):
|
||||||
|
"""
|
||||||
|
WebSocket认证失败时抛出此异常。
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PluginError(Exception):
|
||||||
|
"""
|
||||||
|
插件相关错误的基类。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_name: 插件名称
|
||||||
|
message: 错误消息
|
||||||
|
original_error: 原始异常对象(可选)
|
||||||
|
"""
|
||||||
|
def __init__(self, plugin_name, message, original_error=None):
|
||||||
|
self.plugin_name = plugin_name
|
||||||
|
self.message = message
|
||||||
|
self.original_error = original_error
|
||||||
|
super().__init__(f"插件 {plugin_name}: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
class PluginLoadError(PluginError):
|
||||||
|
"""
|
||||||
|
插件加载失败时抛出此异常。
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PluginReloadError(PluginError):
|
||||||
|
"""
|
||||||
|
插件重载失败时抛出此异常。
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PluginNotFoundError(PluginError):
|
||||||
|
"""
|
||||||
|
找不到指定插件时抛出此异常。
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
"""
|
||||||
|
配置相关错误的基类。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
section: 配置部分名称
|
||||||
|
key: 配置项名称
|
||||||
|
message: 错误消息
|
||||||
|
"""
|
||||||
|
def __init__(self, section=None, key=None, message=None):
|
||||||
|
self.section = section
|
||||||
|
self.key = key
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
if section and key and message:
|
||||||
|
super().__init__(f"配置错误 [{section}.{key}]: {message}")
|
||||||
|
elif section and message:
|
||||||
|
super().__init__(f"配置错误 [{section}]: {message}")
|
||||||
|
else:
|
||||||
|
super().__init__(message or "配置错误")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigNotFoundError(ConfigError):
|
||||||
|
"""
|
||||||
|
配置文件不存在时抛出此异常。
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigValidationError(ConfigError):
|
||||||
|
"""
|
||||||
|
配置验证失败时抛出此异常。
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionError(Exception):
|
||||||
|
"""
|
||||||
|
权限相关错误的基类。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 用户ID
|
||||||
|
operation: 操作名称
|
||||||
|
message: 错误消息
|
||||||
|
"""
|
||||||
|
def __init__(self, user_id=None, operation=None, message=None):
|
||||||
|
self.user_id = user_id
|
||||||
|
self.operation = operation
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
if user_id and operation and message:
|
||||||
|
super().__init__(f"权限错误 [用户 {user_id}]: 无权限执行操作 {operation} - {message}")
|
||||||
|
elif user_id and operation:
|
||||||
|
super().__init__(f"权限错误 [用户 {user_id}]: 无权限执行操作 {operation}")
|
||||||
|
else:
|
||||||
|
super().__init__(message or "权限错误")
|
||||||
|
|
||||||
|
|
||||||
|
class CommandError(Exception):
|
||||||
|
"""
|
||||||
|
命令处理相关错误的基类。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: 命令名称
|
||||||
|
message: 错误消息
|
||||||
|
original_error: 原始异常对象(可选)
|
||||||
|
"""
|
||||||
|
def __init__(self, command=None, message=None, original_error=None):
|
||||||
|
self.command = command
|
||||||
|
self.message = message
|
||||||
|
self.original_error = original_error
|
||||||
|
|
||||||
|
if command and message:
|
||||||
|
super().__init__(f"命令错误 [{command}]: {message}")
|
||||||
|
else:
|
||||||
|
super().__init__(message or "命令错误")
|
||||||
|
|
||||||
|
|
||||||
|
class CommandNotFoundError(CommandError):
|
||||||
|
"""
|
||||||
|
找不到指定命令时抛出此异常。
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CommandParameterError(CommandError):
|
||||||
|
"""
|
||||||
|
命令参数错误时抛出此异常。
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RedisError(Exception):
|
||||||
|
"""
|
||||||
|
Redis相关错误的基类。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: 错误消息
|
||||||
|
original_error: 原始异常对象(可选)
|
||||||
|
"""
|
||||||
|
def __init__(self, message, original_error=None):
|
||||||
|
self.message = message
|
||||||
|
self.original_error = original_error
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserManagerError(Exception):
|
||||||
|
"""
|
||||||
|
浏览器管理器相关错误的基类。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: 错误消息
|
||||||
|
original_error: 原始异常对象(可选)
|
||||||
|
"""
|
||||||
|
def __init__(self, message, original_error=None):
|
||||||
|
self.message = message
|
||||||
|
self.original_error = original_error
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserPoolError(BrowserManagerError):
|
||||||
|
"""
|
||||||
|
浏览器池相关错误时抛出此异常。
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CodeExecutionError(Exception):
|
||||||
|
"""
|
||||||
|
代码执行相关错误的基类。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: 错误消息
|
||||||
|
code: 执行的代码(可选)
|
||||||
|
original_error: 原始异常对象(可选)
|
||||||
|
"""
|
||||||
|
def __init__(self, message, code=None, original_error=None):
|
||||||
|
self.message = message
|
||||||
|
self.code = code
|
||||||
|
self.original_error = original_error
|
||||||
|
super().__init__(message)
|
||||||
195
core/utils/executor.py
Normal file
195
core/utils/executor.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import asyncio
|
||||||
|
import docker
|
||||||
|
from docker.tls import TLSConfig
|
||||||
|
from docker.types import LogConfig
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from core.utils.logger import logger
|
||||||
|
|
||||||
|
class CodeExecutor:
|
||||||
|
"""
|
||||||
|
代码执行引擎,负责管理一个异步任务队列和并发的 Docker 容器执行。
|
||||||
|
"""
|
||||||
|
def __init__(self, config: Any):
|
||||||
|
"""
|
||||||
|
初始化代码执行引擎。
|
||||||
|
:param config: 从 config_loader.py 加载的全局配置对象。
|
||||||
|
"""
|
||||||
|
self.bot: Any = None # Bot 实例将在 WS 连接成功后动态注入
|
||||||
|
self.task_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
|
||||||
|
# 从传入的配置中读取 Docker 相关设置
|
||||||
|
docker_config = config.docker
|
||||||
|
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
|
||||||
|
|
||||||
|
logger.info("[CodeExecutor] 初始化 Docker 客户端...")
|
||||||
|
try:
|
||||||
|
if self.docker_base_url:
|
||||||
|
# 如果配置了远程 Docker 地址,则使用 TLS 选项进行连接
|
||||||
|
tls_config = None
|
||||||
|
if docker_config.tls_verify:
|
||||||
|
tls_config = TLSConfig(
|
||||||
|
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)
|
||||||
|
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: 执行完毕后用于回复结果的回调函数。
|
||||||
|
: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()})。")
|
||||||
|
|
||||||
|
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 容器中运行代码。
|
||||||
|
此函数通过手动管理容器生命周期来提高稳定性。
|
||||||
|
"""
|
||||||
|
if self.docker_client is None:
|
||||||
|
raise docker.errors.DockerException("Docker client is not initialized.")
|
||||||
|
|
||||||
|
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=LogConfig(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.decode('utf-8')
|
||||||
|
)
|
||||||
|
|
||||||
|
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(config: Any):
|
||||||
|
"""
|
||||||
|
初始化并返回一个 CodeExecutor 实例。
|
||||||
|
"""
|
||||||
|
return CodeExecutor(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()
|
||||||
|
return await loop.run_in_executor(None, lambda: sync_func(*args, **kwargs))
|
||||||
34
core/utils/json_utils.py
Normal file
34
core/utils/json_utils.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
JSON 工具模块
|
||||||
|
|
||||||
|
统一使用高性能的 orjson 库进行 JSON 序列化和反序列化。
|
||||||
|
如果 orjson 不可用,则回退到标准库 json。
|
||||||
|
"""
|
||||||
|
from typing import Any, Union
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 在模块加载时检查 orjson 是否可用
|
||||||
|
try:
|
||||||
|
import orjson
|
||||||
|
_orjson_available = True
|
||||||
|
except ImportError:
|
||||||
|
_orjson_available = False
|
||||||
|
|
||||||
|
def dumps(obj: Any) -> str:
|
||||||
|
"""
|
||||||
|
将对象序列化为 JSON 字符串。
|
||||||
|
"""
|
||||||
|
if _orjson_available:
|
||||||
|
# orjson.dumps 返回 bytes,需要 decode
|
||||||
|
return orjson.dumps(obj).decode("utf-8")
|
||||||
|
else:
|
||||||
|
return json.dumps(obj, ensure_ascii=False)
|
||||||
|
|
||||||
|
def loads(json_str: Union[str, bytes]) -> Any:
|
||||||
|
"""
|
||||||
|
将 JSON 字符串反序列化为对象。
|
||||||
|
"""
|
||||||
|
if _orjson_available:
|
||||||
|
return orjson.loads(json_str)
|
||||||
|
else:
|
||||||
|
return json.loads(json_str)
|
||||||
137
core/utils/logger.py
Normal file
137
core/utils/logger.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""
|
||||||
|
日志模块
|
||||||
|
|
||||||
|
该模块负责初始化和配置 loguru 日志记录器,为整个应用程序提供统一的日志记录接口。
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
# 定义日志格式,添加进程ID和线程ID作为上下文信息
|
||||||
|
LOG_FORMAT = (
|
||||||
|
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
||||||
|
"<level>{level: <8}</level> | "
|
||||||
|
"<magenta>PID {process} TID {thread}</magenta> | "
|
||||||
|
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
|
||||||
|
"<level>{message}</level>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 开发环境日志格式(更详细)
|
||||||
|
DEBUG_LOG_FORMAT = (
|
||||||
|
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
||||||
|
"<level>{level: <8}</level> | "
|
||||||
|
"<magenta>PID {process} TID {thread}</magenta> | "
|
||||||
|
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
|
||||||
|
"<yellow>Module: {module}</yellow> | "
|
||||||
|
"<level>{message}</level>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 移除 loguru 默认的处理器
|
||||||
|
logger.remove()
|
||||||
|
|
||||||
|
# 获取当前环境
|
||||||
|
ENVIRONMENT = os.getenv("NEOBOT_ENV", "development")
|
||||||
|
|
||||||
|
# 添加控制台输出处理器
|
||||||
|
logger.add(
|
||||||
|
sys.stderr,
|
||||||
|
level="INFO" if ENVIRONMENT == "production" else "DEBUG",
|
||||||
|
format=LOG_FORMAT if ENVIRONMENT == "production" else DEBUG_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=DEBUG_LOG_FORMAT,
|
||||||
|
colorize=False,
|
||||||
|
rotation="00:00", # 每天午夜创建新文件
|
||||||
|
retention="7 days", # 保留最近 7 天的日志
|
||||||
|
encoding="utf-8",
|
||||||
|
enqueue=True, # 异步写入
|
||||||
|
backtrace=True, # 记录完整的异常堆栈
|
||||||
|
diagnose=True # 添加异常诊断信息
|
||||||
|
)
|
||||||
|
|
||||||
|
# 为自定义异常添加专门的日志记录方法
|
||||||
|
def log_exception(exc, module_name="unknown", level="error"):
|
||||||
|
"""
|
||||||
|
记录自定义异常的详细信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
exc: 异常对象
|
||||||
|
module_name: 模块名称(可选)
|
||||||
|
level: 日志级别(可选,默认为 "error")
|
||||||
|
"""
|
||||||
|
log_func = getattr(logger, level)
|
||||||
|
log_func(f"模块 {module_name} 发生异常: {exc}")
|
||||||
|
|
||||||
|
# 如果异常对象有原始异常,也记录原始异常信息
|
||||||
|
if hasattr(exc, "original_error") and exc.original_error:
|
||||||
|
log_func(f"原始异常: {exc.original_error}")
|
||||||
|
|
||||||
|
# 如果是配置错误,记录配置相关信息
|
||||||
|
if hasattr(exc, "section") and hasattr(exc, "key"):
|
||||||
|
log_func(f"配置信息: 部分={exc.section}, 键={exc.key}")
|
||||||
|
|
||||||
|
# 如果是插件错误,记录插件名称
|
||||||
|
if hasattr(exc, "plugin_name"):
|
||||||
|
log_func(f"插件名称: {exc.plugin_name}")
|
||||||
|
|
||||||
|
# 如果是命令错误,记录命令名称
|
||||||
|
if hasattr(exc, "command"):
|
||||||
|
log_func(f"命令名称: {exc.command}")
|
||||||
|
|
||||||
|
# 如果是权限错误,记录用户ID和操作
|
||||||
|
if hasattr(exc, "user_id") and hasattr(exc, "operation"):
|
||||||
|
log_func(f"权限信息: 用户ID={exc.user_id}, 操作={exc.operation}")
|
||||||
|
|
||||||
|
# 为不同模块提供日志工具
|
||||||
|
class ModuleLogger:
|
||||||
|
"""
|
||||||
|
模块专用日志记录器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
module_name: 模块名称
|
||||||
|
"""
|
||||||
|
def __init__(self, module_name):
|
||||||
|
self.module_name = module_name
|
||||||
|
|
||||||
|
def debug(self, message):
|
||||||
|
logger.debug(f"[{self.module_name}] {message}")
|
||||||
|
|
||||||
|
def info(self, message):
|
||||||
|
logger.info(f"[{self.module_name}] {message}")
|
||||||
|
|
||||||
|
def success(self, message):
|
||||||
|
logger.success(f"[{self.module_name}] {message}")
|
||||||
|
|
||||||
|
def warning(self, message):
|
||||||
|
logger.warning(f"[{self.module_name}] {message}")
|
||||||
|
|
||||||
|
def error(self, message):
|
||||||
|
logger.error(f"[{self.module_name}] {message}")
|
||||||
|
|
||||||
|
def exception(self, message, exc_info=True):
|
||||||
|
logger.exception(f"[{self.module_name}] {message}", exc_info=exc_info)
|
||||||
|
|
||||||
|
def log_custom_exception(self, exc, level="error"):
|
||||||
|
"""
|
||||||
|
记录自定义异常
|
||||||
|
|
||||||
|
Args:
|
||||||
|
exc: 异常对象
|
||||||
|
level: 日志级别
|
||||||
|
"""
|
||||||
|
log_exception(exc, self.module_name, level)
|
||||||
|
|
||||||
|
# 导出配置好的 logger 和工具函数
|
||||||
|
__all__ = ["logger", "log_exception", "ModuleLogger"]
|
||||||
364
core/utils/performance.py
Normal file
364
core/utils/performance.py
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
性能分析工具模块
|
||||||
|
|
||||||
|
提供同步和异步函数的性能分析装饰器、上下文管理器和统计工具。
|
||||||
|
|
||||||
|
主要功能:
|
||||||
|
1. 函数执行时间分析(支持同步和异步)
|
||||||
|
2. 内存使用分析
|
||||||
|
3. 性能统计和报告生成
|
||||||
|
4. 低开销的生产环境监控
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Callable, Optional
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
# 尝试导入性能分析库
|
||||||
|
try:
|
||||||
|
from pyinstrument import Profiler
|
||||||
|
from pyinstrument.renderers import HTMLRenderer
|
||||||
|
PYINSTRUMENT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
PYINSTRUMENT_AVAILABLE = False
|
||||||
|
|
||||||
|
# 尝试导入内存分析库
|
||||||
|
try:
|
||||||
|
from memory_profiler import memory_usage
|
||||||
|
MEMORY_PROFILER_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
MEMORY_PROFILER_AVAILABLE = False
|
||||||
|
|
||||||
|
from .logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceStats:
|
||||||
|
"""
|
||||||
|
性能统计工具类
|
||||||
|
用于收集和报告函数执行的性能指标
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
self.stats: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def record(self, func_name: str, duration: float, memory_used: Optional[float] = None):
|
||||||
|
"""
|
||||||
|
记录函数执行的性能数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func_name: 函数名称
|
||||||
|
duration: 执行时间(秒)
|
||||||
|
memory_used: 使用的内存(MB),可选
|
||||||
|
"""
|
||||||
|
if func_name not in self.stats:
|
||||||
|
self.stats[func_name] = {
|
||||||
|
"count": 0,
|
||||||
|
"total_time": 0.0,
|
||||||
|
"avg_time": 0.0,
|
||||||
|
"min_time": float('inf'),
|
||||||
|
"max_time": 0.0,
|
||||||
|
"total_memory": 0.0,
|
||||||
|
"avg_memory": 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
stat = self.stats[func_name]
|
||||||
|
stat["count"] += 1
|
||||||
|
stat["total_time"] += duration
|
||||||
|
stat["avg_time"] = stat["total_time"] / stat["count"]
|
||||||
|
stat["min_time"] = min(stat["min_time"], duration)
|
||||||
|
stat["max_time"] = max(stat["max_time"], duration)
|
||||||
|
|
||||||
|
if memory_used is not None:
|
||||||
|
stat["total_memory"] += memory_used
|
||||||
|
stat["avg_memory"] = stat["total_memory"] / stat["count"]
|
||||||
|
|
||||||
|
def report(self) -> str:
|
||||||
|
"""
|
||||||
|
生成性能统计报告
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
格式化的性能统计报告字符串
|
||||||
|
"""
|
||||||
|
if not self.stats:
|
||||||
|
return "暂无性能统计数据"
|
||||||
|
|
||||||
|
report = ["\n=== 性能统计报告 ===\n"]
|
||||||
|
report.append(f"{'函数名':<40} {'调用次数':<10} {'平均时间(ms)':<15} {'最长时间(ms)':<15} {'内存(MB)':<10}")
|
||||||
|
report.append("-" * 100)
|
||||||
|
|
||||||
|
for func_name, stat in sorted(self.stats.items(), key=lambda x: x[1]["total_time"], reverse=True):
|
||||||
|
memory_str = f"{stat['avg_memory']:.2f}" if stat['avg_memory'] > 0 else "-"
|
||||||
|
report.append(
|
||||||
|
f"{func_name:<40} {stat['count']:<10} {stat['avg_time']*1000:<15.2f} "
|
||||||
|
f"{stat['max_time']*1000:<15.2f} {memory_str:<10}"
|
||||||
|
)
|
||||||
|
|
||||||
|
report.append("=" * 100)
|
||||||
|
return "\n".join(report)
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""
|
||||||
|
重置性能统计数据
|
||||||
|
"""
|
||||||
|
self.stats.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# 创建全局性能统计实例
|
||||||
|
performance_stats = PerformanceStats()
|
||||||
|
|
||||||
|
|
||||||
|
def timeit(func: Callable = None, *, log_level: int = logging.INFO, collect_stats: bool = True):
|
||||||
|
"""
|
||||||
|
函数执行时间分析装饰器(支持同步和异步)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: 要装饰的函数
|
||||||
|
log_level: 日志级别
|
||||||
|
collect_stats: 是否收集到全局统计中
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
装饰后的函数
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
func_name = func.__qualname__
|
||||||
|
is_coroutine = inspect.iscoroutinefunction(func)
|
||||||
|
|
||||||
|
if is_coroutine:
|
||||||
|
@functools.wraps(func)
|
||||||
|
async def async_wrapper(*args, **kwargs):
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
try:
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
end_time = time.perf_counter()
|
||||||
|
duration = end_time - start_time
|
||||||
|
|
||||||
|
if collect_stats:
|
||||||
|
performance_stats.record(func_name, duration)
|
||||||
|
|
||||||
|
logger.log(log_level, f"[性能] {func_name} 执行时间: {duration*1000:.2f} ms")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
return async_wrapper
|
||||||
|
else:
|
||||||
|
@functools.wraps(func)
|
||||||
|
def sync_wrapper(*args, **kwargs):
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
try:
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
end_time = time.perf_counter()
|
||||||
|
duration = end_time - start_time
|
||||||
|
|
||||||
|
if collect_stats:
|
||||||
|
performance_stats.record(func_name, duration)
|
||||||
|
|
||||||
|
logger.log(log_level, f"[性能] {func_name} 执行时间: {duration*1000:.2f} ms")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
return sync_wrapper
|
||||||
|
|
||||||
|
if func is None:
|
||||||
|
return decorator
|
||||||
|
return decorator(func)
|
||||||
|
|
||||||
|
|
||||||
|
class profile:
|
||||||
|
"""
|
||||||
|
性能分析上下文管理器
|
||||||
|
使用 pyinstrument 进行详细的性能分析
|
||||||
|
"""
|
||||||
|
def __init__(self, enabled: bool = True, output_file: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
enabled: 是否启用分析
|
||||||
|
output_file: 分析结果输出文件路径(HTML格式)
|
||||||
|
"""
|
||||||
|
self.enabled = enabled
|
||||||
|
self.output_file = output_file
|
||||||
|
self.profiler = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
if self.enabled and PYINSTRUMENT_AVAILABLE:
|
||||||
|
self.profiler = Profiler()
|
||||||
|
self.profiler.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
if self.enabled and PYINSTRUMENT_AVAILABLE and self.profiler:
|
||||||
|
self.profiler.stop()
|
||||||
|
|
||||||
|
# 输出到日志
|
||||||
|
logger.info(f"[性能分析] {self.profiler.print()}")
|
||||||
|
|
||||||
|
# 如果指定了输出文件,保存为HTML
|
||||||
|
if self.output_file:
|
||||||
|
try:
|
||||||
|
html = self.profiler.render(HTMLRenderer())
|
||||||
|
with open(self.output_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html)
|
||||||
|
logger.info(f"[性能分析] 报告已保存到: {self.output_file}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[性能分析] 保存报告失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def aprofile(func: Callable, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
异步函数性能分析
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: 要分析的异步函数
|
||||||
|
*args: 函数参数
|
||||||
|
**kwargs: 函数关键字参数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
函数执行结果
|
||||||
|
"""
|
||||||
|
if not PYINSTRUMENT_AVAILABLE:
|
||||||
|
logger.warning("[性能分析] pyinstrument 未安装,无法进行详细分析")
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
profiler = Profiler()
|
||||||
|
profiler.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
profiler.stop()
|
||||||
|
logger.info(f"[性能分析] {profiler.print()}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class memory_profile:
|
||||||
|
"""
|
||||||
|
内存分析上下文管理器
|
||||||
|
"""
|
||||||
|
def __init__(self, interval: float = 0.1, enabled: bool = True):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
interval: 内存采样间隔(秒)
|
||||||
|
enabled: 是否启用内存分析
|
||||||
|
"""
|
||||||
|
self.interval = interval
|
||||||
|
self.enabled = enabled
|
||||||
|
self.memory_start = 0.0
|
||||||
|
self.memory_end = 0.0
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
if self.enabled and MEMORY_PROFILER_AVAILABLE:
|
||||||
|
self.memory_start = memory_usage()[0]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
if self.enabled and MEMORY_PROFILER_AVAILABLE:
|
||||||
|
self.memory_end = memory_usage()[0]
|
||||||
|
memory_used = self.memory_end - self.memory_start
|
||||||
|
logger.info(f"[内存分析] 使用内存: {memory_used:.2f} MB")
|
||||||
|
|
||||||
|
|
||||||
|
def memory_profile_decorator(func: Callable = None, *, interval: float = 0.1):
|
||||||
|
"""
|
||||||
|
内存分析装饰器(支持同步函数)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: 要装饰的函数
|
||||||
|
interval: 内存采样间隔
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
装饰后的函数
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not MEMORY_PROFILER_AVAILABLE:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
mem_usage = memory_usage(
|
||||||
|
(func, args, kwargs),
|
||||||
|
interval=interval,
|
||||||
|
timeout=None,
|
||||||
|
include_children=False
|
||||||
|
)
|
||||||
|
|
||||||
|
max_memory = max(mem_usage)
|
||||||
|
logger.info(f"[内存分析] {func.__qualname__} 最大内存使用: {max_memory:.2f} MB")
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
if func is None:
|
||||||
|
return decorator
|
||||||
|
return decorator(func)
|
||||||
|
|
||||||
|
|
||||||
|
def performance_monitor(func: Callable = None, *, threshold: float = 1.0):
|
||||||
|
"""
|
||||||
|
性能监控装饰器
|
||||||
|
仅当函数执行时间超过阈值时记录日志
|
||||||
|
适合生产环境使用
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: 要装饰的函数
|
||||||
|
threshold: 时间阈值(秒)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
装饰后的函数
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
func_name = func.__qualname__
|
||||||
|
is_coroutine = inspect.iscoroutinefunction(func)
|
||||||
|
|
||||||
|
if is_coroutine:
|
||||||
|
@functools.wraps(func)
|
||||||
|
async def async_wrapper(*args, **kwargs):
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
end_time = time.perf_counter()
|
||||||
|
duration = end_time - start_time
|
||||||
|
|
||||||
|
if duration > threshold:
|
||||||
|
logger.warning(f"[性能监控] {func_name} 执行时间过长: {duration*1000:.2f} ms (阈值: {threshold*1000:.2f} ms)")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
return async_wrapper
|
||||||
|
else:
|
||||||
|
@functools.wraps(func)
|
||||||
|
def sync_wrapper(*args, **kwargs):
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
end_time = time.perf_counter()
|
||||||
|
duration = end_time - start_time
|
||||||
|
|
||||||
|
if duration > threshold:
|
||||||
|
logger.warning(f"[性能监控] {func_name} 执行时间过长: {duration*1000:.2f} ms (阈值: {threshold*1000:.2f} ms)")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
return sync_wrapper
|
||||||
|
|
||||||
|
if func is None:
|
||||||
|
return decorator
|
||||||
|
return decorator(func)
|
||||||
|
|
||||||
|
|
||||||
|
# 全局实例
|
||||||
|
global_stats = PerformanceStats()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'timeit',
|
||||||
|
'profile',
|
||||||
|
'aprofile',
|
||||||
|
'memory_profile',
|
||||||
|
'memory_profile_decorator',
|
||||||
|
'performance_monitor',
|
||||||
|
'PerformanceStats',
|
||||||
|
'performance_stats',
|
||||||
|
'global_stats'
|
||||||
|
]
|
||||||
78
core/utils/singleton.py
Normal file
78
core/utils/singleton.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
通用单例模式基类
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, Optional, Type, TypeVar
|
||||||
|
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
# 存储每个类的实例
|
||||||
|
_instance_store: Dict[Type, Any] = {}
|
||||||
|
|
||||||
|
class Singleton:
|
||||||
|
"""
|
||||||
|
一个通用的单例基类
|
||||||
|
|
||||||
|
任何继承自该类的子类都将自动成为单例。
|
||||||
|
它通过重写 __new__ 方法来确保每个类只有一个实例。
|
||||||
|
同时,它处理了重复初始化的问题,确保 __init__ 方法只在第一次实例化时被调用。
|
||||||
|
"""
|
||||||
|
_initialized: bool = False
|
||||||
|
|
||||||
|
def __new__(cls: Type[T], *args: Any, **kwargs: Any) -> T:
|
||||||
|
"""
|
||||||
|
创建或返回现有的实例
|
||||||
|
|
||||||
|
Args:
|
||||||
|
*args: 传递给构造函数的位置参数
|
||||||
|
**kwargs: 传递给构造函数的关键字参数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
T: 单例实例
|
||||||
|
"""
|
||||||
|
# 使用全局字典存储实例,避免类型检查问题
|
||||||
|
if cls not in _instance_store:
|
||||||
|
_instance_store[cls] = super().__new__(cls)
|
||||||
|
return _instance_store[cls]
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""
|
||||||
|
确保初始化逻辑只执行一次
|
||||||
|
"""
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
|
||||||
|
def singleton(cls: Type[T]) -> Type[T]:
|
||||||
|
"""
|
||||||
|
单例装饰器
|
||||||
|
|
||||||
|
将普通类转换为单例类,确保整个应用程序中只有一个实例。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cls: 要转换为单例的类
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Type[T]: 单例类
|
||||||
|
"""
|
||||||
|
# 为每个装饰的类创建一个实例存储
|
||||||
|
class_instance: Optional[T] = None
|
||||||
|
|
||||||
|
# 创建一个新的类,继承自原始类
|
||||||
|
class SingletonClass(cls):
|
||||||
|
"""单例包装类"""
|
||||||
|
|
||||||
|
def __new__(cls: Type[T], *args: Any, **kwargs: Any) -> T:
|
||||||
|
"""创建或返回现有的实例"""
|
||||||
|
nonlocal class_instance
|
||||||
|
if class_instance is None:
|
||||||
|
# 使用super()调用原始类的__new__方法
|
||||||
|
class_instance = cls(*args, **kwargs)
|
||||||
|
return class_instance
|
||||||
|
|
||||||
|
# 复制类的元数据
|
||||||
|
SingletonClass.__name__ = cls.__name__
|
||||||
|
SingletonClass.__doc__ = cls.__doc__
|
||||||
|
SingletonClass.__module__ = cls.__module__
|
||||||
|
|
||||||
|
return SingletonClass
|
||||||
398
docs/api/account.md
Normal file
398
docs/api/account.md
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
# 账号 API
|
||||||
|
|
||||||
|
这一页讲的是怎么管理机器人自己的账号:查看登录信息、设置在线状态、修改资料、退出登录等等。这些都是跟机器人自身相关的操作。
|
||||||
|
|
||||||
|
## 账号信息
|
||||||
|
|
||||||
|
### `get_login_info` - 获取登录信息
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_login_info(self, no_cache: bool = False) -> LoginInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
获取当前登录的机器人账号信息。默认会缓存 1 小时。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `no_cache`: 是否跳过缓存,直接从服务器获取
|
||||||
|
|
||||||
|
**返回值:**
|
||||||
|
- `LoginInfo`: 登录信息对象
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
info = await bot.get_login_info()
|
||||||
|
print(f"机器人QQ号: {info.user_id}")
|
||||||
|
print(f"机器人昵称: {info.nickname}")
|
||||||
|
```
|
||||||
|
|
||||||
|
`LoginInfo` 对象包含:
|
||||||
|
- `user_id`: 机器人 QQ 号
|
||||||
|
- `nickname`: 机器人昵称
|
||||||
|
|
||||||
|
### `get_version_info` - 获取版本信息
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_version_info(self) -> VersionInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
获取 OneBot v11 实现的版本信息(比如 NapCatQQ 的版本)。
|
||||||
|
|
||||||
|
**返回值:**
|
||||||
|
- `VersionInfo`: 版本信息对象
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
version = await bot.get_version_info()
|
||||||
|
print(f"客户端: {version.app_name}")
|
||||||
|
print(f"版本: {version.app_version}")
|
||||||
|
print(f"OneBot 协议版本: {version.protocol_version}")
|
||||||
|
```
|
||||||
|
|
||||||
|
`VersionInfo` 对象包含:
|
||||||
|
- `app_name`: 客户端名称(如 "NapCatQQ")
|
||||||
|
- `app_version`: 客户端版本
|
||||||
|
- `protocol_version`: 支持的 OneBot 协议版本
|
||||||
|
|
||||||
|
### `get_status` - 获取运行状态
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_status(self) -> Status
|
||||||
|
```
|
||||||
|
|
||||||
|
获取 OneBot 实现的运行状态信息。
|
||||||
|
|
||||||
|
**返回值:**
|
||||||
|
- `Status`: 状态信息对象
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
status = await bot.get_status()
|
||||||
|
print(f"在线: {status.online}")
|
||||||
|
print(f"状态: {status.status}")
|
||||||
|
print(f"正常: {status.good}")
|
||||||
|
```
|
||||||
|
|
||||||
|
`Status` 对象包含:
|
||||||
|
- `online`: 是否在线
|
||||||
|
- `status`: 状态描述
|
||||||
|
- `good`: 运行是否正常
|
||||||
|
|
||||||
|
## 状态设置
|
||||||
|
|
||||||
|
### `set_self_longnick` - 设置个性签名
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_self_longnick(self, long_nick: str) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
设置机器人账号的个性签名(QQ 资料里的那个长签名)。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `long_nick`: 要设置的个性签名内容
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
@matcher.command("setsign")
|
||||||
|
async def handle_setsign(event: MessageEvent, args: str):
|
||||||
|
if not args:
|
||||||
|
await event.reply("需要签名内容")
|
||||||
|
return
|
||||||
|
|
||||||
|
await event.bot.set_self_longnick(args)
|
||||||
|
await event.reply("个性签名已更新")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `set_online_status` - 设置在线状态
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_online_status(self, status_code: int) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
设置机器人的在线状态(在线、离开、忙碌等)。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `status_code`: 状态码
|
||||||
|
- `1`: 在线
|
||||||
|
- `2`: 离开
|
||||||
|
- `3`: 忙碌
|
||||||
|
- `4`: 请勿打扰
|
||||||
|
- `5`: 隐身
|
||||||
|
- 其他值取决于客户端支持
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
# 设置为隐身
|
||||||
|
await bot.set_online_status(5)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `set_diy_online_status` - 设置自定义在线状态
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_diy_online_status(
|
||||||
|
self,
|
||||||
|
face_id: int,
|
||||||
|
face_type: int,
|
||||||
|
wording: str
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
设置自定义的在线状态(需要客户端支持)。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `face_id`: 状态表情 ID
|
||||||
|
- `face_type`: 状态表情类型
|
||||||
|
- `wording`: 状态描述文本
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
# 设置为"摸鱼中"
|
||||||
|
await bot.set_diy_online_status(
|
||||||
|
face_id=100,
|
||||||
|
face_type=1,
|
||||||
|
wording="摸鱼中"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `set_input_status` - 设置"正在输入"状态
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_input_status(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
event_type: int
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
向指定用户显示"对方正在输入..."的状态提示。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `user_id`: 目标用户的 QQ 号
|
||||||
|
- `event_type`: 事件类型(具体含义取决于客户端)
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
# 向某个用户显示"正在输入"
|
||||||
|
await bot.set_input_status(123456, 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 资料修改
|
||||||
|
|
||||||
|
### `set_qq_profile` - 设置个人资料
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_qq_profile(self, **kwargs) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
设置机器人账号的个人资料。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `**kwargs`: 个人资料的相关参数,具体字段请参考 OneBot v11 规范
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
# 修改昵称
|
||||||
|
await bot.set_qq_profile(nickname="新的昵称")
|
||||||
|
|
||||||
|
# 修改多个字段
|
||||||
|
await bot.set_qq_profile(
|
||||||
|
nickname="新昵称",
|
||||||
|
sex="female",
|
||||||
|
age=18,
|
||||||
|
level=50
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `set_qq_avatar` - 设置头像
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_qq_avatar(self, **kwargs) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
设置机器人账号的头像。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `**kwargs`: 头像的相关参数,具体字段请参考 OneBot v11 规范
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
# 设置头像(具体参数格式取决于客户端)
|
||||||
|
await bot.set_qq_avatar(file="path/to/avatar.jpg")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 系统操作
|
||||||
|
|
||||||
|
### `bot_exit` - 退出登录
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def bot_exit(self) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
让机器人进程退出(需要客户端支持)。谨慎使用!
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
@matcher.command("shutdown", permission="admin")
|
||||||
|
async def handle_shutdown(event: MessageEvent):
|
||||||
|
await event.reply("机器人正在退出...")
|
||||||
|
await event.bot.bot_exit()
|
||||||
|
```
|
||||||
|
|
||||||
|
### `clean_cache` - 清理缓存
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def clean_cache(self) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
清理 OneBot 客户端的缓存。
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
@matcher.command("clearcache", permission="admin")
|
||||||
|
async def handle_clearcache(event: MessageEvent):
|
||||||
|
await event.bot.clean_cache()
|
||||||
|
await event.reply("缓存已清理")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `get_clientkey` - 获取客户端密钥
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_clientkey(self) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
获取客户端密钥(通常用于 QQ 登录相关操作)。
|
||||||
|
|
||||||
|
**返回值:**
|
||||||
|
- 包含客户端密钥的字典
|
||||||
|
|
||||||
|
## 实用示例
|
||||||
|
|
||||||
|
### 机器人状态查询插件
|
||||||
|
|
||||||
|
```python
|
||||||
|
@matcher.command("status")
|
||||||
|
async def handle_status(event: MessageEvent):
|
||||||
|
# 获取各种信息
|
||||||
|
login_info = await event.bot.get_login_info()
|
||||||
|
version_info = await event.bot.get_version_info()
|
||||||
|
status_info = await event.bot.get_status()
|
||||||
|
|
||||||
|
# 构建状态消息
|
||||||
|
msg = "🤖 机器人状态\n"
|
||||||
|
msg += f"QQ号: {login_info.user_id}\n"
|
||||||
|
msg += f"昵称: {login_info.nickname}\n"
|
||||||
|
msg += f"客户端: {version_info.app_name} v{version_info.app_version}\n"
|
||||||
|
msg += f"协议: OneBot v{version_info.protocol_version}\n"
|
||||||
|
msg += f"状态: {'在线' if status_info.online else '离线'}\n"
|
||||||
|
msg += f"运行: {'正常' if status_info.good else '异常'}"
|
||||||
|
|
||||||
|
await event.reply(msg)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自动切换状态
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
async def auto_status_scheduler(bot):
|
||||||
|
"""
|
||||||
|
定时自动切换状态
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
now = datetime.now().hour
|
||||||
|
|
||||||
|
if 9 <= now < 18:
|
||||||
|
# 工作时间:在线
|
||||||
|
await bot.set_online_status(1)
|
||||||
|
status_text = "工作中"
|
||||||
|
elif 18 <= now < 22:
|
||||||
|
# 晚上:离开
|
||||||
|
await bot.set_online_status(2)
|
||||||
|
status_text = "休息中"
|
||||||
|
else:
|
||||||
|
# 深夜:隐身
|
||||||
|
await bot.set_online_status(5)
|
||||||
|
status_text = "睡眠模式"
|
||||||
|
|
||||||
|
# 设置个性签名
|
||||||
|
await bot.set_self_longnick(f"当前状态: {status_text} | 最后更新: {datetime.now():%H:%M}")
|
||||||
|
|
||||||
|
# 每小时更新一次
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
|
# 在初始化插件时启动
|
||||||
|
# (注意:这只是一个示例,实际使用需要考虑插件生命周期)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 资料备份与恢复
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
|
||||||
|
@matcher.command("backupprofile", permission="admin")
|
||||||
|
async def handle_backup_profile(event: MessageEvent):
|
||||||
|
"""
|
||||||
|
备份当前资料到文件
|
||||||
|
"""
|
||||||
|
# 获取当前登录信息
|
||||||
|
login_info = await event.bot.get_login_info()
|
||||||
|
|
||||||
|
# 构建备份数据
|
||||||
|
backup_data = {
|
||||||
|
"user_id": login_info.user_id,
|
||||||
|
"nickname": login_info.nickname,
|
||||||
|
"backup_time": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# 保存到文件
|
||||||
|
filename = f"profile_backup_{login_info.user_id}.json"
|
||||||
|
with open(filename, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(backup_data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
await event.reply(f"资料已备份到 {filename}")
|
||||||
|
|
||||||
|
@matcher.command("restoreprofile", permission="admin")
|
||||||
|
async def handle_restore_profile(event: MessageEvent, args: str):
|
||||||
|
"""
|
||||||
|
从备份恢复资料
|
||||||
|
"""
|
||||||
|
if not args:
|
||||||
|
await event.reply("需要备份文件名")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(args, "r", encoding="utf-8") as f:
|
||||||
|
backup_data = json.load(f)
|
||||||
|
|
||||||
|
# 恢复资料(这里只是示例,实际可能需要更多字段)
|
||||||
|
await event.bot.set_qq_profile(
|
||||||
|
nickname=backup_data.get("nickname", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
await event.reply("资料已恢复")
|
||||||
|
except Exception as e:
|
||||||
|
await event.reply(f"恢复失败: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **权限**: 修改资料、退出登录等操作通常需要机器人有相应权限。
|
||||||
|
2. **频率限制**: 不要频繁修改资料或状态,可能被限制。
|
||||||
|
3. **客户端支持**: 不是所有 OneBot 客户端都支持全部 API,使用前最好测试一下。
|
||||||
|
4. **谨慎操作**: `bot_exit` 会让机器人下线,谨慎使用。
|
||||||
|
|
||||||
|
## 重复的方法
|
||||||
|
|
||||||
|
`AccountAPI` 中还包含了一些与好友、群组相关的方法,这些方法在其他模块中也有定义:
|
||||||
|
|
||||||
|
- `get_stranger_info()`: 同 [好友 API](./friend.md#get_stranger_info---获取陌生人信息)
|
||||||
|
- `get_friend_list()`: 同 [好友 API](./friend.md#get_friend_list---获取好友列表)
|
||||||
|
- `get_group_list()`: 同 [群组 API](./group.md#get_group_list---获取群列表)
|
||||||
|
|
||||||
|
这些方法在 `AccountAPI` 中的实现可能略有不同(比如缓存逻辑),但功能相同。建议使用对应模块中的版本,因为那些是专门为该功能设计的。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [好友 API](./friend.md): 管理好友相关功能
|
||||||
|
- [群组 API](./group.md): 管理群聊相关功能
|
||||||
|
- [消息 API](./message.md): 怎么发消息、撤回消息
|
||||||
130
docs/api/base.md
Normal file
130
docs/api/base.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# API 基础
|
||||||
|
|
||||||
|
这一页讲的是 NEO Bot 里 API 调用的底层原理。如果你只是写插件发消息,可以直接跳过这页,去看 [消息 API](./message.md)。
|
||||||
|
|
||||||
|
但如果你想了解背后发生了什么,或者想自己封装一些高级功能,那这里的信息会帮到你。
|
||||||
|
|
||||||
|
## API 调用流程
|
||||||
|
|
||||||
|
简单来说,当你调用 `bot.send_group_msg()` 时:
|
||||||
|
|
||||||
|
1. **你的插件** → `bot.send_group_msg(123456, "hello")`
|
||||||
|
2. **Bot 类** → 把它打包成 OneBot 标准的 JSON
|
||||||
|
3. **WebSocket** → 通过 `ws.py` 发给 NapCatQQ(或其他 OneBot 实现)
|
||||||
|
4. **OneBot 实现** → 收到请求,真的把消息发到 QQ 群里
|
||||||
|
5. **响应返回** → 原路返回,告诉 Bot “消息发送成功”
|
||||||
|
|
||||||
|
整个过程是异步的,所以你要用 `await`。
|
||||||
|
|
||||||
|
## call_api 方法
|
||||||
|
|
||||||
|
所有 API 最终都会调用 `BaseAPI.call_api()` 方法。这是最底层的接口:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def call_api(self, action: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
||||||
|
```
|
||||||
|
|
||||||
|
- `action`: API 动作名,比如 `"send_group_msg"`、`"get_login_info"`
|
||||||
|
- `params`: 参数字典,比如 `{"group_id": 123456, "message": "hello"}`
|
||||||
|
|
||||||
|
### 返回值
|
||||||
|
|
||||||
|
`call_api` 返回的是 OneBot 响应中的 `data` 字段。如果 API 调用失败(返回 `{"status": "failed", ...}`),它会记录一条警告日志,但**不会抛出异常**(除非网络错误)。
|
||||||
|
|
||||||
|
这样设计是为了让插件能更灵活地处理失败情况。比如:
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
result = await bot.call_api("send_group_msg", {"group_id": 123456, "message": "test"})
|
||||||
|
if result is None:
|
||||||
|
print("API 调用失败,但没抛异常")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"网络或底层错误: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 响应格式
|
||||||
|
|
||||||
|
OneBot v11 的标准响应格式是:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"retcode": 0,
|
||||||
|
"data": { ... },
|
||||||
|
"message": "",
|
||||||
|
"echo": "请求时的 echo 值(如果有)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `status`: `"ok"` 或 `"failed"`
|
||||||
|
- `retcode`: 状态码,0 表示成功
|
||||||
|
- `data`: 真正的返回数据
|
||||||
|
- `message`: 错误信息(失败时)
|
||||||
|
- `echo`: 用来匹配请求和响应的标识(WebSocket 用)
|
||||||
|
|
||||||
|
NEO Bot 的 `call_api` 方法会自动提取 `data` 字段返回给你。如果 `status` 是 `"failed"`,它会在日志里记录警告,但依然返回 `data`(通常是 `None` 或空字典)。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
API 调用可能因为各种原因失败:
|
||||||
|
|
||||||
|
1. **网络问题**: WebSocket 断开、超时
|
||||||
|
2. **权限不足**: 机器人不是管理员却想踢人
|
||||||
|
3. **参数错误**: 群号不存在、消息太长
|
||||||
|
4. **客户端不支持**: 某些 OneBot 实现可能没实现某些 API
|
||||||
|
|
||||||
|
建议在插件里做好错误处理:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@matcher.command("kick")
|
||||||
|
async def handle_kick(event: MessageEvent, args: str):
|
||||||
|
target_id = int(args) if args.isdigit() else 0
|
||||||
|
if not target_id:
|
||||||
|
await event.reply("参数错误,需要 QQ 号")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await event.bot.set_group_kick(event.group_id, target_id)
|
||||||
|
if result.get("status") == "failed":
|
||||||
|
await event.reply(f"踢人失败: {result.get('message', '未知错误')}")
|
||||||
|
else:
|
||||||
|
await event.reply("踢人成功")
|
||||||
|
except Exception as e:
|
||||||
|
await event.reply(f"网络错误: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 直接调用 vs 高级封装
|
||||||
|
|
||||||
|
NEO Bot 提供了两种调用 API 的方式:
|
||||||
|
|
||||||
|
### 1. 直接调用 `call_api`
|
||||||
|
|
||||||
|
```python
|
||||||
|
await bot.call_api("send_group_msg", {"group_id": 123456, "message": "hello"})
|
||||||
|
```
|
||||||
|
|
||||||
|
**什么时候用?**
|
||||||
|
- 你想调用的 API 没有被封装成独立方法(很少见)
|
||||||
|
- 你在调试,想看看原始请求和响应
|
||||||
|
- 你在写框架代码,需要动态生成 action 名
|
||||||
|
|
||||||
|
### 2. 使用封装好的方法
|
||||||
|
|
||||||
|
```python
|
||||||
|
await bot.send_group_msg(123456, "hello")
|
||||||
|
```
|
||||||
|
|
||||||
|
**这是推荐的方式**,因为:
|
||||||
|
- 有类型提示,编辑器能帮你补全
|
||||||
|
- 参数有文档,不用去查 OneBot 标准
|
||||||
|
- 有些方法有额外逻辑(比如缓存、参数转换)
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
现在你了解了 API 调用的基础。接下来可以去看看具体的 API 类别:
|
||||||
|
|
||||||
|
- [消息 API](./message.md): 最常用,先看这个
|
||||||
|
- [群组 API](./group.md): 管理群聊
|
||||||
|
- [好友 API](./friend.md): 好友相关操作
|
||||||
|
- [账号 API](./account.md): 机器人自身状态
|
||||||
|
- [媒体 API](./media.md): 图片、语音
|
||||||
273
docs/api/friend.md
Normal file
273
docs/api/friend.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# 好友 API
|
||||||
|
|
||||||
|
这一页讲的是怎么管理好友:获取好友列表、给好友点赞、处理加好友请求,还有获取陌生人信息。
|
||||||
|
|
||||||
|
## 好友列表
|
||||||
|
|
||||||
|
### `get_friend_list` - 获取好友列表
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_friend_list(self, no_cache: bool = False) -> List[FriendInfo]
|
||||||
|
```
|
||||||
|
|
||||||
|
获取机器人账号的所有好友列表。默认会缓存 1 小时。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `no_cache`: 是否跳过缓存,直接从服务器获取最新列表
|
||||||
|
|
||||||
|
**返回值:**
|
||||||
|
- `List[FriendInfo]`: 好友信息对象列表
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
friends = await bot.get_friend_list()
|
||||||
|
print(f"我有 {len(friends)} 个好友")
|
||||||
|
for friend in friends:
|
||||||
|
print(f"{friend.user_id}: {friend.nickname} (备注: {friend.remark})")
|
||||||
|
```
|
||||||
|
|
||||||
|
`FriendInfo` 对象包含以下字段:
|
||||||
|
- `user_id`: QQ 号
|
||||||
|
- `nickname`: 昵称
|
||||||
|
- `remark`: 备注(你给好友设置的备注名)
|
||||||
|
- 其他可能的信息字段
|
||||||
|
|
||||||
|
## 陌生人信息
|
||||||
|
|
||||||
|
### `get_stranger_info` - 获取陌生人信息
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_stranger_info(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
no_cache: bool = False
|
||||||
|
) -> StrangerInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
获取非好友的 QQ 用户信息。默认会缓存 1 小时。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `user_id`: 目标用户的 QQ 号
|
||||||
|
- `no_cache`: 是否跳过缓存
|
||||||
|
|
||||||
|
**返回值:**
|
||||||
|
- `StrangerInfo`: 陌生人信息对象
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
@matcher.command("who")
|
||||||
|
async def handle_who(event: MessageEvent, args: str):
|
||||||
|
if not args.isdigit():
|
||||||
|
await event.reply("参数错误,需要 QQ 号")
|
||||||
|
return
|
||||||
|
|
||||||
|
target_id = int(args)
|
||||||
|
info = await event.bot.get_stranger_info(target_id)
|
||||||
|
|
||||||
|
msg = f"用户 {target_id} 的信息:\n"
|
||||||
|
msg += f"昵称: {info.nickname}\n"
|
||||||
|
msg += f"性别: {info.sex}\n"
|
||||||
|
msg += f"年龄: {info.age}\n"
|
||||||
|
msg += f"等级: {info.level}"
|
||||||
|
|
||||||
|
await event.reply(msg)
|
||||||
|
```
|
||||||
|
|
||||||
|
`StrangerInfo` 对象包含以下字段:
|
||||||
|
- `user_id`: QQ 号
|
||||||
|
- `nickname`: 昵称
|
||||||
|
- `sex`: 性别(`male`/`female`/`unknown`)
|
||||||
|
- `age`: 年龄
|
||||||
|
- `level`: QQ 等级
|
||||||
|
- 其他可能的信息字段
|
||||||
|
|
||||||
|
## 互动功能
|
||||||
|
|
||||||
|
### `send_like` - 发送点赞(戳一戳)
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def send_like(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
times: int = 1
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
给指定用户发送"戳一戳"(点赞)。每天有次数限制,建议不要超过 10 次。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `user_id`: 目标用户的 QQ 号
|
||||||
|
- `times`: 点赞次数,建议 1-10 次
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
@matcher.command("like")
|
||||||
|
async def handle_like(event: MessageEvent, args: str):
|
||||||
|
# 给发送者点赞
|
||||||
|
await event.bot.send_like(event.user_id, times=1)
|
||||||
|
await event.reply("给你点了个赞!")
|
||||||
|
|
||||||
|
# 如果提供了参数,给指定用户点赞
|
||||||
|
if args.isdigit():
|
||||||
|
target_id = int(args)
|
||||||
|
await event.bot.send_like(target_id, times=1)
|
||||||
|
await event.reply(f"给 {target_id} 点了个赞!")
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意:**
|
||||||
|
- 不是所有 OneBot 实现都支持这个 API
|
||||||
|
- 有每日次数限制,不要滥用
|
||||||
|
- 对方可能关闭了"戳一戳"功能,这时会失败
|
||||||
|
|
||||||
|
## 加好友请求处理
|
||||||
|
|
||||||
|
### `set_friend_add_request` - 处理加好友请求
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_friend_add_request(
|
||||||
|
self,
|
||||||
|
flag: str,
|
||||||
|
approve: bool = True,
|
||||||
|
remark: str = ""
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
处理收到的加好友请求。需要在 `request` 事件中调用。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `flag`: 请求标识,从 `request` 事件的 `flag` 字段获取
|
||||||
|
- `approve`: 是否同意,`True` 同意,`False` 拒绝
|
||||||
|
- `remark`: 同意请求时,为该好友设置的备注(可选)
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
from models.events.request import RequestEvent
|
||||||
|
from core.managers.command_manager import matcher
|
||||||
|
|
||||||
|
# 处理所有加好友请求
|
||||||
|
@matcher.on_event(RequestEvent)
|
||||||
|
async def handle_friend_request(event: RequestEvent):
|
||||||
|
if event.request_type == "friend":
|
||||||
|
# 自动同意并设置备注
|
||||||
|
await event.bot.set_friend_add_request(
|
||||||
|
flag=event.flag,
|
||||||
|
approve=True,
|
||||||
|
remark=f"自动添加-{event.user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 给新好友发个欢迎消息
|
||||||
|
await event.bot.send_private_msg(
|
||||||
|
event.user_id,
|
||||||
|
"你好!我是机器人,已自动通过你的好友请求。"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实用示例
|
||||||
|
|
||||||
|
### 好友信息查询插件
|
||||||
|
|
||||||
|
```python
|
||||||
|
@matcher.command("friendinfo")
|
||||||
|
async def handle_friendinfo(event: MessageEvent):
|
||||||
|
# 获取好友列表
|
||||||
|
friends = await event.bot.get_friend_list()
|
||||||
|
|
||||||
|
# 按备注名排序
|
||||||
|
sorted_friends = sorted(friends, key=lambda f: f.remark or f.nickname)
|
||||||
|
|
||||||
|
# 生成好友列表消息
|
||||||
|
if len(sorted_friends) > 50:
|
||||||
|
msg = f"好友太多啦,只显示前50个(共{len(sorted_friends)}个)\n"
|
||||||
|
sorted_friends = sorted_friends[:50]
|
||||||
|
else:
|
||||||
|
msg = f"我的好友列表(共{len(sorted_friends)}个):\n"
|
||||||
|
|
||||||
|
for i, friend in enumerate(sorted_friends, 1):
|
||||||
|
remark_display = friend.remark if friend.remark else "(无备注)"
|
||||||
|
msg += f"{i}. {friend.nickname} ({friend.user_id}) - 备注: {remark_display}\n"
|
||||||
|
|
||||||
|
await event.reply(msg)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自动通过特定用户的好友请求
|
||||||
|
|
||||||
|
```python
|
||||||
|
@matcher.on_event(RequestEvent)
|
||||||
|
async def handle_specific_friend_request(event: RequestEvent):
|
||||||
|
if event.request_type != "friend":
|
||||||
|
return
|
||||||
|
|
||||||
|
# 允许列表
|
||||||
|
allowed_users = [123456, 789012, 345678]
|
||||||
|
|
||||||
|
if event.user_id in allowed_users:
|
||||||
|
# 自动同意
|
||||||
|
await event.bot.set_friend_add_request(
|
||||||
|
flag=event.flag,
|
||||||
|
approve=True,
|
||||||
|
remark="重要联系人"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送欢迎消息
|
||||||
|
await event.bot.send_private_msg(
|
||||||
|
event.user_id,
|
||||||
|
"你好!已通过你的好友请求。\n"
|
||||||
|
"发送 /help 查看可用指令。"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 拒绝其他人
|
||||||
|
await event.bot.set_friend_add_request(
|
||||||
|
flag=event.flag,
|
||||||
|
approve=False,
|
||||||
|
reason="仅限授权用户添加"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 批量给好友发送消息(谨慎使用!)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@matcher.command("broadcast", permission="admin")
|
||||||
|
async def handle_broadcast(event: MessageEvent, args: str):
|
||||||
|
if not args:
|
||||||
|
await event.reply("需要广播内容")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取好友列表
|
||||||
|
friends = await event.bot.get_friend_list()
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
|
||||||
|
await event.reply(f"开始向 {len(friends)} 个好友发送广播...")
|
||||||
|
|
||||||
|
for friend in friends:
|
||||||
|
try:
|
||||||
|
await event.bot.send_private_msg(friend.user_id, args)
|
||||||
|
success_count += 1
|
||||||
|
# 避免发送太快被限制
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"发送给 {friend.user_id} 失败: {e}")
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
await event.reply(
|
||||||
|
f"广播完成!\n"
|
||||||
|
f"成功: {success_count} 个\n"
|
||||||
|
f"失败: {fail_count} 个"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:批量发送消息容易被腾讯限制,谨慎使用!
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **频率限制**: 获取好友列表、查询陌生人信息等操作有频率限制。
|
||||||
|
2. **缓存**: 好友列表和陌生人信息默认缓存 1 小时,如果需要实时数据,设 `no_cache=True`。
|
||||||
|
3. **权限**: 有些 API 需要特定的权限或客户端支持。
|
||||||
|
4. **隐私**: 处理好友请求时,注意保护用户隐私。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [账号 API](./account.md): 管理机器人自己的信息
|
||||||
|
- [群组 API](./group.md): 管理群聊相关功能
|
||||||
|
- [消息 API](./message.md): 怎么发消息、撤回消息
|
||||||
506
docs/api/group.md
Normal file
506
docs/api/group.md
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
# 群组 API
|
||||||
|
|
||||||
|
管群是个技术活。这一页讲的是怎么管理群聊:踢人、禁言、改名片、设管理员……所有跟群相关的操作都在这里。
|
||||||
|
|
||||||
|
## 权限说明
|
||||||
|
|
||||||
|
**重要提醒**:很多群管理 API 需要机器人有相应的权限:
|
||||||
|
- **管理员权限**:禁言、踢人、改群名片等
|
||||||
|
- **群主权限**:解散群、设置管理员等
|
||||||
|
|
||||||
|
如果机器人权限不足,API 调用会失败。建议先检查机器人的权限,或者做好错误处理。
|
||||||
|
|
||||||
|
## 成员管理
|
||||||
|
|
||||||
|
### `set_group_kick` - 踢出群聊
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_group_kick(
|
||||||
|
self,
|
||||||
|
group_id: int,
|
||||||
|
user_id: int,
|
||||||
|
reject_add_request: bool = False
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
把指定成员踢出群聊。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `group_id`: 群号
|
||||||
|
- `user_id`: 要踢出的成员的 QQ 号
|
||||||
|
- `reject_add_request`: 是否同时拒绝该用户此后的加群请求(默认 `False`)
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
@matcher.command("kick")
|
||||||
|
async def handle_kick(event: MessageEvent, args: str):
|
||||||
|
if not args.isdigit():
|
||||||
|
await event.reply("参数错误,需要 QQ 号")
|
||||||
|
return
|
||||||
|
|
||||||
|
target_id = int(args)
|
||||||
|
await event.bot.set_group_kick(event.group_id, target_id)
|
||||||
|
await event.reply(f"已踢出 {target_id}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `set_group_ban` - 禁言/解除禁言
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_group_ban(
|
||||||
|
self,
|
||||||
|
group_id: int,
|
||||||
|
user_id: int,
|
||||||
|
duration: int = 1800
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
禁言群成员。设置 `duration=0` 可以解除禁言。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `group_id`: 群号
|
||||||
|
- `user_id`: 要禁言的成员的 QQ 号
|
||||||
|
- `duration`: 禁言时长,单位秒。默认 1800 秒(30 分钟),0 表示解除禁言
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
# 禁言 10 分钟
|
||||||
|
await bot.set_group_ban(123456, 789012, duration=600)
|
||||||
|
|
||||||
|
# 解除禁言
|
||||||
|
await bot.set_group_ban(123456, 789012, duration=0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `set_group_anonymous_ban` - 禁言匿名用户
|
||||||
|
|
||||||
|
```python
|
||||||
|
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]
|
||||||
|
```
|
||||||
|
|
||||||
|
禁言发送匿名消息的用户。需要从消息事件的 `anonymous` 字段获取匿名用户信息。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `group_id`: 群号
|
||||||
|
- `anonymous`: 匿名用户对象(从事件中获取)
|
||||||
|
- `duration`: 禁言时长,单位秒
|
||||||
|
- `flag`: 匿名用户的 flag 标识(从事件中获取)
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
@matcher.command("ban_anonymous")
|
||||||
|
async def handle_ban_anonymous(event: GroupMessageEvent):
|
||||||
|
if not event.anonymous:
|
||||||
|
await event.reply("这不是匿名消息")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 方法 1: 使用 anonymous 对象
|
||||||
|
await event.bot.set_group_anonymous_ban(
|
||||||
|
event.group_id,
|
||||||
|
anonymous=event.anonymous,
|
||||||
|
duration=3600 # 禁言 1 小时
|
||||||
|
)
|
||||||
|
|
||||||
|
# 方法 2: 使用 flag(如果事件中有的话)
|
||||||
|
# await event.bot.set_group_anonymous_ban(
|
||||||
|
# event.group_id,
|
||||||
|
# flag=event.anonymous.get("flag"),
|
||||||
|
# duration=3600
|
||||||
|
# )
|
||||||
|
```
|
||||||
|
|
||||||
|
### `set_group_whole_ban` - 全员禁言
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_group_whole_ban(
|
||||||
|
self,
|
||||||
|
group_id: int,
|
||||||
|
enable: bool = True
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
开启或关闭全员禁言。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `group_id`: 群号
|
||||||
|
- `enable`: `True` 开启全员禁言,`False` 关闭
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
# 开启全员禁言
|
||||||
|
await bot.set_group_whole_ban(123456, enable=True)
|
||||||
|
|
||||||
|
# 关闭全员禁言
|
||||||
|
await bot.set_group_whole_ban(123456, enable=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 权限设置
|
||||||
|
|
||||||
|
### `set_group_admin` - 设置/取消管理员
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_group_admin(
|
||||||
|
self,
|
||||||
|
group_id: int,
|
||||||
|
user_id: int,
|
||||||
|
enable: bool = True
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
设置或取消群管理员。**需要机器人是群主**。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `group_id`: 群号
|
||||||
|
- `user_id`: 目标成员的 QQ 号
|
||||||
|
- `enable`: `True` 设为管理员,`False` 取消管理员
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
# 设某人为管理员
|
||||||
|
await bot.set_group_admin(123456, 789012, enable=True)
|
||||||
|
|
||||||
|
# 取消某人的管理员
|
||||||
|
await bot.set_group_admin(123456, 789012, enable=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `set_group_anonymous` - 匿名聊天设置
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_group_anonymous(
|
||||||
|
self,
|
||||||
|
group_id: int,
|
||||||
|
enable: bool = True
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
开启或关闭群匿名聊天功能。**需要机器人是管理员**。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `group_id`: 群号
|
||||||
|
- `enable`: `True` 开启匿名,`False` 关闭
|
||||||
|
|
||||||
|
## 成员信息
|
||||||
|
|
||||||
|
### `set_group_card` - 设置群名片
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_group_card(
|
||||||
|
self,
|
||||||
|
group_id: int,
|
||||||
|
user_id: int,
|
||||||
|
card: str = ""
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
设置群成员的群名片(群内显示的名称)。传空字符串可以删除群名片,恢复为昵称。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `group_id`: 群号
|
||||||
|
- `user_id`: 目标成员的 QQ 号
|
||||||
|
- `card`: 要设置的群名片内容,空字符串表示删除
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
# 设置群名片
|
||||||
|
await bot.set_group_card(123456, 789012, "技术大佬")
|
||||||
|
|
||||||
|
# 删除群名片(恢复为昵称)
|
||||||
|
await bot.set_group_card(123456, 789012, "")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `set_group_special_title` - 设置专属头衔
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_group_special_title(
|
||||||
|
self,
|
||||||
|
group_id: int,
|
||||||
|
user_id: int,
|
||||||
|
special_title: str = "",
|
||||||
|
duration: int = -1
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
为群成员设置专属头衔(群主/管理员才有权限设置)。**需要机器人是群主**。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `group_id`: 群号
|
||||||
|
- `user_id`: 目标成员的 QQ 号
|
||||||
|
- `special_title`: 专属头衔内容,空字符串表示删除
|
||||||
|
- `duration`: 头衔有效期,单位秒。-1 表示永久
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
# 设置永久头衔
|
||||||
|
await bot.set_group_special_title(123456, 789012, "御用摄影师", duration=-1)
|
||||||
|
|
||||||
|
# 设置 7 天有效的头衔
|
||||||
|
await bot.set_group_special_title(123456, 789012, "本周活跃之星", duration=7*24*3600)
|
||||||
|
|
||||||
|
# 删除头衔
|
||||||
|
await bot.set_group_special_title(123456, 789012, "")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 群信息管理
|
||||||
|
|
||||||
|
### `set_group_name` - 修改群名
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_group_name(
|
||||||
|
self,
|
||||||
|
group_id: int,
|
||||||
|
group_name: str
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
修改群名称。**需要机器人是群主或管理员**。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `group_id`: 群号
|
||||||
|
- `group_name`: 新的群名称
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
await bot.set_group_name(123456, "技术交流群")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `set_group_leave` - 退出/解散群聊
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_group_leave(
|
||||||
|
self,
|
||||||
|
group_id: int,
|
||||||
|
is_dismiss: bool = False
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
退出群聊,如果是群主还可以解散群。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `group_id`: 群号
|
||||||
|
- `is_dismiss`: 是否解散群(仅群主有效)
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
# 普通退群
|
||||||
|
await bot.set_group_leave(123456)
|
||||||
|
|
||||||
|
# 解散群(需要是群主)
|
||||||
|
await bot.set_group_leave(123456, is_dismiss=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 获取信息
|
||||||
|
|
||||||
|
### `get_group_info` - 获取群信息
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_group_info(
|
||||||
|
self,
|
||||||
|
group_id: int,
|
||||||
|
no_cache: bool = False
|
||||||
|
) -> GroupInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
获取群的详细信息,包括群名、成员数、创建时间等。默认会缓存 1 小时。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `group_id`: 群号
|
||||||
|
- `no_cache`: 是否跳过缓存,直接从服务器获取最新信息
|
||||||
|
|
||||||
|
**返回值:**
|
||||||
|
- `GroupInfo` 对象,包含群信息
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
info = await bot.get_group_info(123456)
|
||||||
|
print(f"群名: {info.group_name}")
|
||||||
|
print(f"成员数: {info.member_count}")
|
||||||
|
print(f"创建时间: {info.create_time}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `get_group_list` - 获取群列表
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_group_list(self) -> List[GroupInfo]
|
||||||
|
```
|
||||||
|
|
||||||
|
获取机器人加入的所有群列表。
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
groups = await bot.get_group_list()
|
||||||
|
for group in groups:
|
||||||
|
print(f"{group.group_id}: {group.group_name}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `get_group_member_info` - 获取群成员信息
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_group_member_info(
|
||||||
|
self,
|
||||||
|
group_id: int,
|
||||||
|
user_id: int,
|
||||||
|
no_cache: bool = False
|
||||||
|
) -> GroupMemberInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
获取指定群成员的详细信息,包括昵称、群名片、加群时间、最后发言时间等。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `group_id`: 群号
|
||||||
|
- `user_id`: 成员 QQ 号
|
||||||
|
- `no_cache`: 是否跳过缓存
|
||||||
|
|
||||||
|
**返回值:**
|
||||||
|
- `GroupMemberInfo` 对象
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
member = await bot.get_group_member_info(123456, 789012)
|
||||||
|
print(f"昵称: {member.nickname}")
|
||||||
|
print(f"群名片: {member.card}")
|
||||||
|
print(f"权限: {member.role}") # owner, admin, member
|
||||||
|
```
|
||||||
|
|
||||||
|
### `get_group_member_list` - 获取群成员列表
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_group_member_list(self, group_id: int) -> List[GroupMemberInfo]
|
||||||
|
```
|
||||||
|
|
||||||
|
获取群的所有成员列表。
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
members = await bot.get_group_member_list(123456)
|
||||||
|
print(f"群里有 {len(members)} 个成员")
|
||||||
|
for member in members:
|
||||||
|
print(f"{member.user_id}: {member.nickname}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `get_group_honor_info` - 获取群荣誉信息
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_group_honor_info(
|
||||||
|
self,
|
||||||
|
group_id: int,
|
||||||
|
type: str
|
||||||
|
) -> GroupHonorInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
获取群的荣誉信息,比如龙王、群聊之火、快乐源泉等。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `group_id`: 群号
|
||||||
|
- `type`: 荣誉类型,可选值:
|
||||||
|
- `"talkative`:" 龙王(发言最多)
|
||||||
|
- `"performer"`: 群聊之火(发言最活跃)
|
||||||
|
- `"legend"`: 群传奇(连续多天发言最多)
|
||||||
|
- `"strong_newbie"`: 冒尖小萌新(新人中发言最多)
|
||||||
|
- `"emotion"`: 快乐源泉(发送表情包最多)
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
honor = await bot.get_group_honor_info(123456, "talkative")
|
||||||
|
print(f"本周龙王: {honor.current_talkative.user_id}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 加群请求处理
|
||||||
|
|
||||||
|
### `set_group_add_request` - 处理加群请求/邀请
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def set_group_add_request(
|
||||||
|
self,
|
||||||
|
flag: str,
|
||||||
|
sub_type: str,
|
||||||
|
approve: bool = True,
|
||||||
|
reason: str = ""
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
处理加群请求或邀请。需要在 `request` 事件中调用。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `flag`: 请求标识,从 `request` 事件的 `flag` 字段获取
|
||||||
|
- `sub_type`: 请求类型,`"add"`(加群请求)或 `"invite"`(群邀请)
|
||||||
|
- `approve`: 是否同意,`True` 同意,`False` 拒绝
|
||||||
|
- `reason`: 拒绝理由(仅在 `approve=False` 时有效)
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
from models.events.request import RequestEvent
|
||||||
|
|
||||||
|
# 在请求事件处理函数中
|
||||||
|
async def handle_group_request(event: RequestEvent):
|
||||||
|
if event.request_type == "group":
|
||||||
|
# 自动同意所有加群请求
|
||||||
|
await event.bot.set_group_add_request(
|
||||||
|
flag=event.flag,
|
||||||
|
sub_type=event.sub_type,
|
||||||
|
approve=True
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实用示例
|
||||||
|
|
||||||
|
### 自动同意加群请求
|
||||||
|
|
||||||
|
```python
|
||||||
|
from models.events.request import RequestEvent
|
||||||
|
from core.managers.command_manager import matcher
|
||||||
|
|
||||||
|
@matcher.on_event(RequestEvent)
|
||||||
|
async def handle_all_requests(event: RequestEvent):
|
||||||
|
if event.request_type == "group":
|
||||||
|
# 检查是否来自特定用户
|
||||||
|
if event.user_id in [123456, 789012]:
|
||||||
|
await event.bot.set_group_add_request(
|
||||||
|
flag=event.flag,
|
||||||
|
sub_type=event.sub_type,
|
||||||
|
approve=True
|
||||||
|
)
|
||||||
|
await event.bot.send_private_msg(
|
||||||
|
event.user_id,
|
||||||
|
f"已同意你的加群请求,欢迎加入!"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 群活跃度统计
|
||||||
|
|
||||||
|
```python
|
||||||
|
@matcher.command("active")
|
||||||
|
async def handle_active(event: MessageEvent):
|
||||||
|
# 获取群成员列表
|
||||||
|
members = await event.bot.get_group_member_list(event.group_id)
|
||||||
|
|
||||||
|
# 找出最后发言时间最近的一批成员
|
||||||
|
active_members = sorted(
|
||||||
|
members,
|
||||||
|
key=lambda m: m.last_sent_time or 0,
|
||||||
|
reverse=True
|
||||||
|
)[:10]
|
||||||
|
|
||||||
|
# 生成统计消息
|
||||||
|
msg = "本群最近活跃成员TOP10:\n"
|
||||||
|
for i, member in enumerate(active_members, 1):
|
||||||
|
msg += f"{i}. {member.nickname} (最后发言: {member.last_sent_time})\n"
|
||||||
|
|
||||||
|
await event.reply(msg)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **权限检查**: 调用管理 API 前,最好先检查机器人的权限。
|
||||||
|
2. **频率限制**: 不要频繁调用 API,尤其是获取群成员列表这种大数据量的操作。
|
||||||
|
3. **缓存**: 获取信息的 API 默认有缓存,如果需要实时数据,记得设 `no_cache=True`。
|
||||||
|
4. **错误处理**: 管理操作可能失败(权限不足、参数错误等),要做好错误处理。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [好友 API](./friend.md): 处理好友相关操作
|
||||||
|
- [账号 API](./account.md): 管理机器人自身状态
|
||||||
|
- [消息 API](./message.md): 怎么发消息、撤回消息
|
||||||
61
docs/api/index.md
Normal file
61
docs/api/index.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# API 参考
|
||||||
|
|
||||||
|
嘿,这里是 NEO Bot 的 API 参考文档。
|
||||||
|
|
||||||
|
如果你在写插件,那这里就是你的工具库。所有能和 OneBot 交互的方法都在这了。
|
||||||
|
|
||||||
|
## 快速导航
|
||||||
|
|
||||||
|
### 1. 基础概念
|
||||||
|
- [API 调用方式](./base.md): 怎么调用 API、参数格式、返回格式
|
||||||
|
- [消息段 (MessageSegment)](./message.md#消息段): 除了文字,还能发图片、表情、@人……
|
||||||
|
|
||||||
|
### 2. 分类 API
|
||||||
|
- [消息 API](./message.md): 发消息、撤回、转发
|
||||||
|
- [群组 API](./group.md): 管群、禁言、踢人、改名片
|
||||||
|
- [好友 API](./friend.md): 好友列表、点赞、加好友请求
|
||||||
|
- [账号 API](./account.md): 机器人自己的信息、状态设置
|
||||||
|
- [媒体 API](./media.md): 图片、语音相关
|
||||||
|
|
||||||
|
### 3. 高级功能
|
||||||
|
- [合并转发](./message.md#合并转发): 怎么发那种一条消息展开好多条的“聊天记录”
|
||||||
|
- [智能回复](./message.md#智能回复): `event.reply()` 和 `bot.send()` 怎么选
|
||||||
|
|
||||||
|
## 怎么用这些 API
|
||||||
|
|
||||||
|
在插件里,你拿到的 `event` 对象自带一个 `bot` 属性,那就是你的机器人实例:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.managers.command_manager import matcher
|
||||||
|
from models.events.message import MessageEvent
|
||||||
|
|
||||||
|
@matcher.command("test")
|
||||||
|
async def handle_test(event: MessageEvent):
|
||||||
|
# 方法 1: 快捷回复(推荐)
|
||||||
|
await event.reply("你好!")
|
||||||
|
|
||||||
|
# 方法 2: 直接调用 bot 上的 API
|
||||||
|
bot = event.bot
|
||||||
|
await bot.send_group_msg(123456, "这是一条群消息")
|
||||||
|
|
||||||
|
# 方法 3: 如果你只有 bot 实例,没有 event
|
||||||
|
# (这种情况比较少见,一般只在初始化时用到)
|
||||||
|
await bot.get_login_info()
|
||||||
|
```
|
||||||
|
|
||||||
|
大部分时候,用 `event.reply()` 就够了。它帮你判断是群聊还是私聊,自动调用正确的 API。
|
||||||
|
|
||||||
|
## 兼容性说明
|
||||||
|
|
||||||
|
NEO Bot 基于 **OneBot v11** 标准实现,兼容:
|
||||||
|
- [NapCatQQ](https://github.com/NapNeko/NapCatQQ) (推荐)
|
||||||
|
- go-cqhttp
|
||||||
|
- 以及其他实现了 OneBot v11 标准的客户端
|
||||||
|
|
||||||
|
但要注意:不同客户端的实现细节可能有差异。比如某些 API 可能不支持,或者参数格式稍有不同。
|
||||||
|
|
||||||
|
如果你发现某个 API 调用失败,先看看日志里的错误信息,或者去对应客户端的文档里查查。
|
||||||
|
|
||||||
|
## 接下来?
|
||||||
|
|
||||||
|
挑一个你感兴趣的类别开始看吧。建议从 [消息 API](./message.md) 开始,因为发消息是最常用的功能。
|
||||||
259
docs/api/media.md
Normal file
259
docs/api/media.md
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
# 媒体 API
|
||||||
|
|
||||||
|
这一页讲的是怎么处理图片、语音等媒体文件。虽然方法不多,但都很实用。
|
||||||
|
|
||||||
|
## 能力检查
|
||||||
|
|
||||||
|
### `can_send_image` - 检查是否可以发送图片
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def can_send_image(self) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
检查当前上下文是否允许发送图片。
|
||||||
|
|
||||||
|
**返回值:**
|
||||||
|
- 包含检查结果的字典,通常有 `yes` 或 `no` 字段
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
@matcher.command("sendpic")
|
||||||
|
async def handle_sendpic(event: MessageEvent, args: str):
|
||||||
|
# 先检查能不能发图片
|
||||||
|
result = await event.bot.can_send_image()
|
||||||
|
|
||||||
|
if result.get("yes"):
|
||||||
|
# 可以发图片
|
||||||
|
await event.reply(MessageSegment.image("https://example.com/image.jpg"))
|
||||||
|
else:
|
||||||
|
# 不能发图片
|
||||||
|
await event.reply("当前环境不支持发送图片")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `can_send_record` - 检查是否可以发送语音
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def can_send_record(self) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
检查当前上下文是否允许发送语音消息。
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
result = await bot.can_send_record()
|
||||||
|
if result.get("yes"):
|
||||||
|
print("可以发语音")
|
||||||
|
else:
|
||||||
|
print("不能发语音")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 图片信息
|
||||||
|
|
||||||
|
### `get_image` - 获取图片信息
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_image(self, file: str) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
获取图片的详细信息,比如大小、尺寸、MD5 等。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `file`: 图片文件名、路径或 URL
|
||||||
|
|
||||||
|
**返回值:**
|
||||||
|
- 包含图片信息的字典
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
@matcher.command("imageinfo")
|
||||||
|
async def handle_imageinfo(event: MessageEvent):
|
||||||
|
# 检查消息中是否有图片
|
||||||
|
for segment in event.message:
|
||||||
|
if segment.type == "image":
|
||||||
|
file = segment.data.get("file", "")
|
||||||
|
if file:
|
||||||
|
# 获取图片信息
|
||||||
|
info = await event.bot.get_image(file)
|
||||||
|
await event.reply(
|
||||||
|
f"图片信息:\n"
|
||||||
|
f"大小: {info.get('size', '未知')} 字节\n"
|
||||||
|
f"尺寸: {info.get('width', '?')}x{info.get('height', '?')}\n"
|
||||||
|
f"MD5: {info.get('md5', '未知')}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await event.reply("消息中没有图片")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实际应用示例
|
||||||
|
|
||||||
|
### 图片转发器
|
||||||
|
|
||||||
|
```python
|
||||||
|
@matcher.command("forwardimage")
|
||||||
|
async def handle_forwardimage(event: MessageEvent, args: str):
|
||||||
|
"""
|
||||||
|
将收到的图片转发到指定群
|
||||||
|
用法: /forwardimage 群号
|
||||||
|
"""
|
||||||
|
if not args.isdigit():
|
||||||
|
await event.reply("参数错误,需要群号")
|
||||||
|
return
|
||||||
|
|
||||||
|
target_group = int(args)
|
||||||
|
|
||||||
|
# 查找消息中的图片
|
||||||
|
images = []
|
||||||
|
for segment in event.message:
|
||||||
|
if segment.type == "image":
|
||||||
|
images.append(segment)
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
await event.reply("消息中没有图片")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 检查是否能发图片到目标群
|
||||||
|
can_send = await event.bot.can_send_image()
|
||||||
|
if not can_send.get("yes"):
|
||||||
|
await event.reply("当前环境不支持发送图片")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 转发所有图片
|
||||||
|
for image in images:
|
||||||
|
await event.bot.send_group_msg(target_group, image)
|
||||||
|
await asyncio.sleep(0.5) # 避免发送太快
|
||||||
|
|
||||||
|
await event.reply(f"已转发 {lenimages()} 张图片到群 {target_group}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 图片信息查询插件
|
||||||
|
|
||||||
|
```python
|
||||||
|
@matcher.on_event(GroupMessageEvent)
|
||||||
|
async def handle_image_autoinfo(event: GroupMessageEvent):
|
||||||
|
"""
|
||||||
|
自动回复图片信息(当有人发图片时)
|
||||||
|
"""
|
||||||
|
# 只处理包含图片的消息
|
||||||
|
images = [seg for seg in event.message if seg.type == "image"]
|
||||||
|
if not images:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 只处理第一张图片(避免消息太长)
|
||||||
|
image_seg = images[0]
|
||||||
|
file = image_seg.data.get("file", "")
|
||||||
|
|
||||||
|
if not file:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取图片信息
|
||||||
|
info = await event.bot.get_image(file)
|
||||||
|
|
||||||
|
# 构建回复消息
|
||||||
|
msg = "📷 图片信息:n\"
|
||||||
|
if "size" in info:
|
||||||
|
size_kb = info["size"] / 1024
|
||||||
|
msg += f"大小: {size_kb:.1f} KB\n"
|
||||||
|
if "width" in info and "height" in info:
|
||||||
|
msg += f"尺寸: {info['width']}×{info['height']}\n"
|
||||||
|
if "md5" in info:
|
||||||
|
msg += f"MD5: {info['md5'][:8]}...\n"
|
||||||
|
|
||||||
|
await event.reply(msg)
|
||||||
|
except Exception as e:
|
||||||
|
# 获取图片信息失败,静默处理
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### 图片发送安全检查
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def safe_send_image(bot, target_id, image_url, is_group=True):
|
||||||
|
"""
|
||||||
|
安全发送图片:先检查是否能发,再发送
|
||||||
|
"""
|
||||||
|
# 检查发送能力
|
||||||
|
can_send = await bot.can_send_image()
|
||||||
|
if not can_send.get("yes"):
|
||||||
|
return False, "当前环境不支持发送图片"
|
||||||
|
|
||||||
|
# 检查图片是否存在(简单检查)
|
||||||
|
if not image_url:
|
||||||
|
return False, "图片URL为空"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 发送图片
|
||||||
|
if is_group:
|
||||||
|
await bot.send_group_msg(target_id, MessageSegment.image(image_url))
|
||||||
|
else:
|
||||||
|
await bot.send_private_msg(target_id, MessageSegment.image(image_url))
|
||||||
|
return True, "图片发送成功"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"发送失败: {e}"
|
||||||
|
|
||||||
|
@matcher.command("safepic")
|
||||||
|
async def handle_safepic(event: MessageEvent, args: str):
|
||||||
|
"""
|
||||||
|
安全发送图片示例
|
||||||
|
"""
|
||||||
|
if not args:
|
||||||
|
await event.reply("需要图片URL")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 是判断群聊还是私聊
|
||||||
|
is_group = hasattr(event, "group_id") and event.group_id
|
||||||
|
|
||||||
|
if is_group:
|
||||||
|
target_id = event.group_id
|
||||||
|
else:
|
||||||
|
target_id = event.user_id
|
||||||
|
|
||||||
|
# 安全发送
|
||||||
|
success, message = await safe_send_image(
|
||||||
|
event.bot, target_id, args, is_group
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
await event.reply(message)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **客户端支持**: 不是所有 OneBot 客户端都完全支持媒体 API。
|
||||||
|
2. **网络限制**: 发送图片和语音可能受网络环境限制。
|
||||||
|
3. **文件大小**: 图片和语音文件有大小限制,太大的文件可能发送失败。
|
||||||
|
4. **缓存**: 图片默认会缓存,重复发送同一图片会更快。
|
||||||
|
5. **安全性**: 不要发送可疑或非法内容。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 为什么 `can_send_image` 总是返回可以?
|
||||||
|
A: 这取决于 OneBot 客户端的实现。有些客户端可能不检查实际能力,总是返回可以。
|
||||||
|
|
||||||
|
### Q: 怎么发送本地图片?
|
||||||
|
A: 使用 `file://` 协议或直接使用本地路径:
|
||||||
|
```python
|
||||||
|
# 本地文件路径
|
||||||
|
image = MessageSegment.image("file:///path/to/image.jpg")
|
||||||
|
# 或者(取决于客户端)
|
||||||
|
image = MessageSegment.image("/path/to/image.jpg")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 怎么发送语音消息?
|
||||||
|
A: NEO Bot 目前没有封装发送语音的 API,但你可以通过 `call_api` 直接调用:
|
||||||
|
```python
|
||||||
|
await bot.call_api("send_group_msg", {
|
||||||
|
"group_id": 123456,
|
||||||
|
"message": [{
|
||||||
|
"type": "record",
|
||||||
|
"data": {"file": "http://example.com/voice.amr"}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [消息 API](./message.md): 怎么发消息、撤回消息,包含消息段的使用
|
||||||
|
- [群组 API](./group.md): 管理群聊相关功能
|
||||||
|
- [好友 API](./friend.md): 管理好友相关功能
|
||||||
309
docs/api/message.md
Normal file
309
docs/api/message.md
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
# 消息 API
|
||||||
|
|
||||||
|
发消息是机器人最基础的功能。这一页讲的是怎么发消息、撤回消息、转发消息,以及怎么用消息段(图片、@人、表情等等)。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 发一条简单的消息
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.managers.command_manager import matcher
|
||||||
|
from models.events.message import MessageEvent
|
||||||
|
|
||||||
|
@matcher.command("hello")
|
||||||
|
async def handle_hello(event: MessageEvent):
|
||||||
|
# 方法 1: 直接回复(最常用)
|
||||||
|
await event.reply("你好呀!")
|
||||||
|
|
||||||
|
# 方法 2: 通过 bot 实例发消息
|
||||||
|
await event.bot.send_group_msg(event.group_id, "这是一条群消息")
|
||||||
|
# 如果是私聊,可以用 send_private_msg
|
||||||
|
# await event.bot.send_private_msg(event.user_id, "这是一条私聊消息")
|
||||||
|
```
|
||||||
|
|
||||||
|
`event.reply()` 是最简单的方式,它会自动判断是群聊还是私聊,然后调用正确的 API。
|
||||||
|
|
||||||
|
## 消息段 (MessageSegment)
|
||||||
|
|
||||||
|
除了纯文字,QQ 消息还能包含图片、@某人、表情、分享链接等等。在 OneBot 里,这些叫“消息段”。
|
||||||
|
|
||||||
|
NEO Bot 用 `MessageSegment` 类来表示消息段。
|
||||||
|
|
||||||
|
### 创建消息段
|
||||||
|
|
||||||
|
```python
|
||||||
|
from models.message import MessageSegment
|
||||||
|
|
||||||
|
# 文本
|
||||||
|
text_seg = MessageSegment.text("这是一段文字")
|
||||||
|
|
||||||
|
# @某人
|
||||||
|
at_seg = MessageSegment.at(123456) # @QQ号 123456
|
||||||
|
at_all = MessageSegment.at("all") # @全体成员
|
||||||
|
|
||||||
|
# 图片
|
||||||
|
image_seg = MessageSegment.image("https://example.com/image.jpg")
|
||||||
|
# 本地图片
|
||||||
|
local_image = MessageSegment.image("file:///path/to/image.png")
|
||||||
|
|
||||||
|
# 表情 (QQ 表情,不是 emoji)
|
||||||
|
face_seg = MessageSegment(type="face", data={"id": "123"})
|
||||||
|
|
||||||
|
# 分享链接
|
||||||
|
share_seg = MessageSegment(type="share", data={
|
||||||
|
"url": "https://example.com",
|
||||||
|
"title": "示例网站",
|
||||||
|
"content": "这是一个示例网站",
|
||||||
|
"image": "https://example.com/thumb.jpg"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 组合消息段
|
||||||
|
|
||||||
|
你可以把多个消息段组合成一条消息:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 方法 1: 用列表
|
||||||
|
message = [
|
||||||
|
MessageSegment.text("你好,"),
|
||||||
|
MessageSegment.at(123456),
|
||||||
|
MessageSegment.text("!"),
|
||||||
|
MessageSegment.image("https://example.com/welcome.jpg")
|
||||||
|
]
|
||||||
|
|
||||||
|
# 方法 2: 用加法运算符(更直观)
|
||||||
|
message = (
|
||||||
|
MessageSegment.text("你好,") +
|
||||||
|
MessageSegment.at(123456) +
|
||||||
|
MessageSegment.text("!")
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送组合消息
|
||||||
|
await event.reply(message)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 从 CQ 码转换
|
||||||
|
|
||||||
|
如果你熟悉 CQ 码,也可以用 `MessageSegment` 来解析:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# CQ 码字符串转消息段列表(需要手动解析,这里只是示例)
|
||||||
|
# 实际使用中,框架会自动处理 CQ 码
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 方法详解
|
||||||
|
|
||||||
|
### `send_group_msg` - 发送群消息
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def send_group_msg(
|
||||||
|
self,
|
||||||
|
group_id: int,
|
||||||
|
message: Union[str, MessageSegment, List[MessageSegment]],
|
||||||
|
auto_escape: bool = False
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `group_id`: 群号
|
||||||
|
- `message`: 消息内容,可以是字符串、单个消息段,或消息段列表
|
||||||
|
- `auto_escape`: 是否对消息中的 CQ 码特殊字符进行转义(仅当 `message` 是字符串时有效)
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
# 发文字
|
||||||
|
await bot.send_group_msg(123456, "大家好!")
|
||||||
|
|
||||||
|
# 发图片
|
||||||
|
await bot.send_group_msg(123456, MessageSegment.image("https://example.com/cat.jpg"))
|
||||||
|
|
||||||
|
# 发组合消息
|
||||||
|
msg = MessageSegment.text("看这只猫:") + MessageSegment.image("https://example.com/cat.jpg")
|
||||||
|
await bot.send_group_msg(123456, msg)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `send_private_msg` - 发送私聊消息
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def send_private_msg(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
message: Union[str, MessageSegment, List[MessageSegment]],
|
||||||
|
auto_escape: bool = False
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `user_id`: 对方的 QQ 号
|
||||||
|
- `message`: 消息内容
|
||||||
|
- `auto_escape`: 是否转义 CQ 码
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
await bot.send_private_msg(123456, "你好,这是一条私聊消息")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `send` - 智能发送
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def send(
|
||||||
|
self,
|
||||||
|
event: OneBotEvent,
|
||||||
|
message: Union[str, MessageSegment, List[MessageSegment]],
|
||||||
|
auto_escape: bool = False
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
这个方法会根据事件的类型自动选择发群消息还是私聊消息。如果事件是消息事件,它其实会调用 `event.reply()`。
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
# 在事件处理函数中
|
||||||
|
await bot.send(event, "自动判断是群聊还是私聊")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `delete_msg` - 撤回消息
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def delete_msg(self, message_id: int) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `message_id`: 要撤回的消息 ID(从消息事件中获取)
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```python
|
||||||
|
@matcher.command("recall")
|
||||||
|
async def handle_recall(event: MessageEvent):
|
||||||
|
# 撤回上一条消息(假设我们知道 message_id)
|
||||||
|
message_id = event.message_id
|
||||||
|
await event.bot.delete_msg(message_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `get_msg` - 获取消息详情
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_msg(self, message_id: int) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
获取一条消息的详细信息,包括发送者、发送时间、内容等。
|
||||||
|
|
||||||
|
### `get_forward_msg` - 获取合并转发消息
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_forward_msg(self, id: str) -> List[Dict[str, Any]]
|
||||||
|
```
|
||||||
|
|
||||||
|
获取一条合并转发消息(聊天记录)的详细内容。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `id`: 合并转发消息的 ID(从消息中获取)
|
||||||
|
|
||||||
|
**返回值:**
|
||||||
|
- 消息节点列表,每个节点包含发送者、时间、内容等信息
|
||||||
|
|
||||||
|
## 合并转发
|
||||||
|
|
||||||
|
合并转发就是那种“点击展开查看聊天记录”的消息。在 QQ 里很常见。
|
||||||
|
|
||||||
|
### 构建转发节点
|
||||||
|
|
||||||
|
先用 `bot.build_forward_node()` 创建节点:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 创建一个转发节点
|
||||||
|
node = bot.build_forward_node(
|
||||||
|
user_id=123456, # 发送者的 QQ 号
|
||||||
|
nickname ="张三", # 显示的名字
|
||||||
|
message="这是一条测试消息" # 消息内容
|
||||||
|
)
|
||||||
|
|
||||||
|
# 消息内容也可以用消息段
|
||||||
|
node2 = bot.build_forward_node(
|
||||||
|
user_id=789012,
|
||||||
|
nickname="李四",
|
||||||
|
message=MessageSegment.text("看这个图片:") + MessageSegment.image("https://example.com/img.jpg")
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 发送合并转发
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 方法 1: 直接发到群聊
|
||||||
|
nodes = [node1, node2, node3]
|
||||||
|
await bot.send_group_forward_msg(group_id=123456, messages=nodes)
|
||||||
|
|
||||||
|
# 方法 2: 发到私聊
|
||||||
|
await bot.send_private_forward_msg(user_id=123456, messages=nodes)
|
||||||
|
|
||||||
|
# 方法 3: 智能发送(根据事件判断)
|
||||||
|
await bot.send_forwarded_messages(target=event, nodes=nodes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 完整示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
@matcher.command("forward")
|
||||||
|
async def handleforward_(event: MessageEvent):
|
||||||
|
# 创建几个测试节点
|
||||||
|
nodes = [
|
||||||
|
event.bot.build_forward_node(
|
||||||
|
user_id=10001,
|
||||||
|
nickname="系统",
|
||||||
|
message="欢迎使用 NEO Bot"
|
||||||
|
),
|
||||||
|
event.bot.build_forward_node(
|
||||||
|
user_id=event.user_id,
|
||||||
|
nickname=event.sender.nickname,
|
||||||
|
message="这个合并转发功能真好用!"
|
||||||
|
),
|
||||||
|
event.bot.build_forward_node(
|
||||||
|
user_id=10002,
|
||||||
|
nickname="机器人",
|
||||||
|
message=MessageSegment.text("谢谢夸奖!") + MessageSegment.face(id="123")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# 发送
|
||||||
|
await event.bot.send_forwarded_messages(event, nodes)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 消息事件中的快捷方法
|
||||||
|
|
||||||
|
在消息事件 (`MessageEvent`) 中,有一些快捷方法:
|
||||||
|
|
||||||
|
### `event.reply()`
|
||||||
|
|
||||||
|
```python
|
||||||
|
await event.reply("你好!")
|
||||||
|
await event.reply(message_segment_list)
|
||||||
|
```
|
||||||
|
|
||||||
|
自动回复到消息来源(群聊或私聊)。
|
||||||
|
|
||||||
|
### `event.message`
|
||||||
|
|
||||||
|
获取事件中的消息内容(已经是 `MessageSegment` 列表格式)。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 检查消息是否包含图片
|
||||||
|
for segment in event.message:
|
||||||
|
if segment.type == "image":
|
||||||
|
await event.reply("你发了一张图片!")
|
||||||
|
break
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **消息长度限制**: QQ 对单条消息有长度限制,太长的消息会被截断。
|
||||||
|
2. **频率限制**: 不要疯狂发消息,可能会被腾讯限制。
|
||||||
|
3. **图片缓存**: 默认情况下,图片会缓存到本地,下次发送同样的图片会更快。
|
||||||
|
4. **网络错误**: 发消息可能因为网络问题失败,建议做好错误处理。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
现在你已经知道怎么发消息了。接下来可以看看:
|
||||||
|
|
||||||
|
- [群组 API](./group.md): 管理群聊,比如禁言、踢人
|
||||||
|
- [好友 API](./friend.md): 处理好友相关操作
|
||||||
|
- [账号 API](./account.md): 管理机器人自己的状态
|
||||||
56
docs/core-concepts/architecture.md
Normal file
56
docs/core-concepts/architecture.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 骨架
|
||||||
|
|
||||||
|
Neobot是面向内部开发者的,我会开源,但是写的很烂。。。
|
||||||
|
|
||||||
|
## 1. 动力核心
|
||||||
|
|
||||||
|
### Python 3.14 + JIT
|
||||||
|
镀铬酸钾创项目的时候用的 Python 3.14 3.14兼容JIT,那就这样吧
|
||||||
|
* **何原理**: 运行时把热点代码编译成机器码(Just-In-Time)
|
||||||
|
* **何用途**: 密集CPU运算能提升一些,尤其是插件里的循环和函数调用
|
||||||
|
* **怎么开**: 启动时加 `-X jit` 参数
|
||||||
|
|
||||||
|
### Mypyc 编译 (AOT)
|
||||||
|
光 JIT 还不够。。核心模块(`core/ws.py`, `core/managers/*.py`)我编译成了C扩展
|
||||||
|
* **何原理**: 因为这个项目有很多类型提示,然后我就编译成C库了。。。
|
||||||
|
* **何用途**: WS和manager下边的模块都是机器码运行,或许会快一些。。。
|
||||||
|
|
||||||
|
### 异步 IO 模型
|
||||||
|
* **Linux**: uvloop
|
||||||
|
* **Windows**:IOCP
|
||||||
|
* *注*: `winloop` 死了,会和面具打架。。。
|
||||||
|
|
||||||
|
## 2. 连接模式
|
||||||
|
|
||||||
|
### 正向 WebSocket 模式
|
||||||
|
这是一种简单直接的模式
|
||||||
|
|
||||||
|
* **主动出击 (Client)**: Bot 是个客户端
|
||||||
|
* **好处**: 你电脑能上网就行(实际上是因为没公网ip哈。。。)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph Local [你的电脑/服务器]
|
||||||
|
Bot[NEO Bot]
|
||||||
|
Browser[Playwright 页面池]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Remote [外部]
|
||||||
|
NapCat[NapCatQQ]
|
||||||
|
end
|
||||||
|
|
||||||
|
Bot -- "WebSocket (主动连接)" --> NapCat
|
||||||
|
Bot -- "内部调用" --> Browser
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 资源管理
|
||||||
|
|
||||||
|
### 单例管理器
|
||||||
|
所有东西(指令、权限、浏览器、图片)都是全局独一份的。
|
||||||
|
* **随叫随到**: 在哪都能直接 `import`
|
||||||
|
* **绝对权威**: 全局就一份数据
|
||||||
|
|
||||||
|
### 资源池化
|
||||||
|
别几把开多个实例。。。
|
||||||
|
* **Browser Pool**: 浏览器页面提前开好,用完洗干净放回去
|
||||||
|
* **Connection Pool**: Redis 和 HTTP 请求都用连接池
|
||||||
194
docs/core-concepts/error-handling.md
Normal file
194
docs/core-concepts/error-handling.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# 错误处理机制
|
||||||
|
|
||||||
|
NEO Bot 采用了统一的错误处理机制,确保在各种异常情况下提供清晰、一致的错误信息。本文档将介绍系统的错误处理架构、错误码定义和使用方法。
|
||||||
|
|
||||||
|
## 1. 错误处理架构
|
||||||
|
|
||||||
|
### 1.1 自定义异常体系
|
||||||
|
|
||||||
|
系统定义了一套完整的自定义异常类体系,覆盖了各种常见的错误场景:
|
||||||
|
|
||||||
|
- **WebSocket 相关错误**:`WebSocketError`、`WebSocketConnectionError`、`WebSocketAuthenticationError`
|
||||||
|
- **插件相关错误**:`PluginError`、`PluginLoadError`、`PluginReloadError`、`PluginNotFoundError`
|
||||||
|
- **配置相关错误**:`ConfigError`、`ConfigNotFoundError`、`ConfigValidationError`
|
||||||
|
- **权限相关错误**:`PermissionError`
|
||||||
|
- **命令相关错误**:`CommandError`、`CommandNotFoundError`、`CommandParameterError`
|
||||||
|
- **Redis 相关错误**:`RedisError`
|
||||||
|
- **浏览器管理器相关错误**:`BrowserManagerError`、`BrowserPoolError`
|
||||||
|
- **代码执行相关错误**:`CodeExecutionError`
|
||||||
|
|
||||||
|
所有自定义异常类都位于 `core.utils.exceptions` 模块中。
|
||||||
|
|
||||||
|
### 1.2 统一的错误码系统
|
||||||
|
|
||||||
|
系统使用统一的错误码来标识不同类型的错误,错误码规则如下:
|
||||||
|
|
||||||
|
- `1xxx`:系统级错误
|
||||||
|
- `2xxx`:WebSocket 相关错误
|
||||||
|
- `3xxx`:插件相关错误
|
||||||
|
- `4xxx`:配置相关错误
|
||||||
|
- `5xxx`:权限相关错误
|
||||||
|
- `6xxx`:命令相关错误
|
||||||
|
- `7xxx`:Redis 相关错误
|
||||||
|
- `8xxx`:浏览器管理器相关错误
|
||||||
|
- `9xxx`:代码执行相关错误
|
||||||
|
|
||||||
|
完整的错误码定义可以在 `core.utils.error_codes` 模块的 `ErrorCode` 类中找到。
|
||||||
|
|
||||||
|
### 1.3 统一的错误响应格式
|
||||||
|
|
||||||
|
系统提供了统一的错误响应格式,确保所有模块返回一致的错误信息:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 错误代码,
|
||||||
|
"message": 错误消息,
|
||||||
|
"success": false,
|
||||||
|
"data": 附加数据(可选),
|
||||||
|
"request_id": 请求ID(可选)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 日志记录增强
|
||||||
|
|
||||||
|
### 2.1 模块专用日志记录器
|
||||||
|
|
||||||
|
系统提供了 `ModuleLogger` 类,用于创建模块专用的日志记录器,自动添加模块标识:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.utils.logger import ModuleLogger
|
||||||
|
|
||||||
|
# 创建模块专用日志记录器
|
||||||
|
logger = ModuleLogger("MyModule")
|
||||||
|
|
||||||
|
# 使用日志记录器
|
||||||
|
logger.info("模块初始化完成")
|
||||||
|
logger.error("发生错误")
|
||||||
|
logger.exception("发生异常")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 异常详情记录
|
||||||
|
|
||||||
|
系统提供了 `log_exception` 函数,用于记录自定义异常的详细信息:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.utils.logger import log_exception
|
||||||
|
from core.utils.exceptions import PluginError
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 代码逻辑
|
||||||
|
raise PluginError("插件加载失败", plugin_name="test_plugin")
|
||||||
|
except Exception as e:
|
||||||
|
log_exception(e, module_name="PluginManager")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 核心模块的错误处理
|
||||||
|
|
||||||
|
### 3.1 WebSocket 模块
|
||||||
|
|
||||||
|
WebSocket 模块使用自定义异常类处理各种连接错误:
|
||||||
|
|
||||||
|
- `WebSocketConnectionError`:连接失败
|
||||||
|
- `WebSocketAuthenticationError`:认证失败
|
||||||
|
- `WebSocketError`:其他 WebSocket 相关错误
|
||||||
|
|
||||||
|
### 3.2 插件管理器模块
|
||||||
|
|
||||||
|
插件管理器模块使用自定义异常类处理各种插件操作错误:
|
||||||
|
|
||||||
|
- `PluginLoadError`:插件加载失败
|
||||||
|
- `PluginReloadError`:插件重载失败
|
||||||
|
- `PluginNotFoundError`:插件未找到
|
||||||
|
|
||||||
|
### 3.3 配置加载器模块
|
||||||
|
|
||||||
|
配置加载器模块使用自定义异常类处理各种配置加载错误:
|
||||||
|
|
||||||
|
- `ConfigNotFoundError`:配置文件未找到
|
||||||
|
- `ConfigValidationError`:配置验证失败
|
||||||
|
- `ConfigError`:其他配置相关错误
|
||||||
|
|
||||||
|
## 4. 全局异常捕获
|
||||||
|
|
||||||
|
系统在主程序入口添加了全局异常捕获机制,确保所有未处理的异常都能被捕获并提供友好的错误信息:
|
||||||
|
|
||||||
|
- 捕获并记录所有未处理的异常
|
||||||
|
- 生成统一格式的错误响应
|
||||||
|
- 根据错误类型给出不同的排查建议
|
||||||
|
- 提供详细的错误信息和日志记录位置
|
||||||
|
|
||||||
|
## 5. 如何在插件中使用错误处理
|
||||||
|
|
||||||
|
### 5.1 抛出自定义异常
|
||||||
|
|
||||||
|
在插件中,您可以使用系统提供的自定义异常类来抛出更精确的错误:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.utils.exceptions import CommandParameterError
|
||||||
|
from core.utils.logger import ModuleLogger
|
||||||
|
|
||||||
|
logger = ModuleLogger("MyPlugin")
|
||||||
|
|
||||||
|
@matcher.command("test")
|
||||||
|
async def test_command(bot, event, args):
|
||||||
|
if len(args) < 1:
|
||||||
|
raise CommandParameterError("test", "缺少必要参数")
|
||||||
|
|
||||||
|
# 命令逻辑
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 捕获并处理异常
|
||||||
|
|
||||||
|
在插件中,您可以捕获并处理异常,提供更友好的错误信息:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.utils.exceptions import PluginError
|
||||||
|
from core.utils.logger import ModuleLogger
|
||||||
|
|
||||||
|
logger = ModuleLogger("MyPlugin")
|
||||||
|
|
||||||
|
@matcher.command("test")
|
||||||
|
async def test_command(bot, event, args):
|
||||||
|
try:
|
||||||
|
# 可能抛出异常的代码
|
||||||
|
result = await some_operation()
|
||||||
|
await bot.send(event, f"操作结果: {result}")
|
||||||
|
except PluginError as e:
|
||||||
|
logger.error(f"插件操作失败: {e}")
|
||||||
|
await bot.send(event, f"操作失败: {e.message}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"发生未知错误: {e}")
|
||||||
|
await bot.send(event, "操作失败,请检查日志获取详细信息")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 错误排查建议
|
||||||
|
|
||||||
|
### 6.1 WebSocket 错误
|
||||||
|
|
||||||
|
- 检查 WebSocket 服务是否正在运行
|
||||||
|
- 检查配置文件中的 WebSocket 地址和令牌是否正确
|
||||||
|
- 检查网络连接是否正常
|
||||||
|
|
||||||
|
### 6.2 插件错误
|
||||||
|
|
||||||
|
- 检查插件目录是否存在
|
||||||
|
- 检查插件文件是否有语法错误
|
||||||
|
- 检查插件是否符合插件开发规范
|
||||||
|
|
||||||
|
### 6.3 配置错误
|
||||||
|
|
||||||
|
- 检查配置文件 config.toml 是否存在
|
||||||
|
- 检查配置文件格式是否正确
|
||||||
|
- 检查所有必填配置项是否都已设置
|
||||||
|
|
||||||
|
## 7. 总结
|
||||||
|
|
||||||
|
NEO Bot 的错误处理机制提供了:
|
||||||
|
|
||||||
|
- 完整的自定义异常类体系
|
||||||
|
- 统一的错误码系统
|
||||||
|
- 一致的错误响应格式
|
||||||
|
- 增强的日志记录功能
|
||||||
|
- 全局异常捕获和友好提示
|
||||||
|
|
||||||
|
这些功能确保了系统在各种异常情况下都能提供清晰、一致的错误信息,便于开发和维护。
|
||||||
100
docs/core-concepts/event-flow.md
Normal file
100
docs/core-concepts/event-flow.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# 核心概念:事件流转
|
||||||
|
|
||||||
|
NEO Bot 的核心就是**事件驱动**。搞懂一个事件从哪来、到哪去,你就懂了一大半。
|
||||||
|
|
||||||
|
下面就拿 `/echo hello` 举例
|
||||||
|
|
||||||
|
## 事件流转图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
%% 定义样式
|
||||||
|
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 实现端<br/>(如 NapCatQQ)"]:::external
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph NeoBot [NEO Bot Framework]
|
||||||
|
direction TB
|
||||||
|
|
||||||
|
subgraph Network [网络接入层]
|
||||||
|
WS["WebSocket 连接<br/>core/ws.py"]:::network
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Processing [核心处理层]
|
||||||
|
Factory["事件工厂<br/>models/events/factory.py"]:::core
|
||||||
|
Dispatcher["命令管理器<br/>core/managers/command_manager.py"]:::core
|
||||||
|
Handler["事件处理器<br/>core/handlers/event_handler.py"]:::core
|
||||||
|
BotAPI["Bot API 封装<br/>core/bot.py"]:::core
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Plugins [业务插件层]
|
||||||
|
UserPlugin["用户插件<br/>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;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 详细步骤
|
||||||
|
|
||||||
|
### 1. 接收 WebSocket 消息 (`core/ws.py`)
|
||||||
|
|
||||||
|
* 你在群里发了条消息,OneBot (比如 NapCatQQ) 就会把它打包成一个 JSON,通过 WebSocket 扔给 Bot。
|
||||||
|
* `core/ws.py` 里的 `_listen_loop` 一直在那蹲着,收到这个 JSON 字符串。
|
||||||
|
|
||||||
|
### 2. 变成对象 (`models/events/factory.py`)
|
||||||
|
|
||||||
|
* `ws.py` 拿到 JSON 后,扔给 `EventFactory.create_event()`。
|
||||||
|
* 工厂类看一眼 `post_type` 是 `"message"`,`message_type` 是 `"group"`,会包装成 `GroupMessageEvent` 对象。
|
||||||
|
* 这时候是python对象了,有属性有方法,感觉很方便。。。
|
||||||
|
|
||||||
|
### 3. 塞点东西,准备分发 (`core/ws.py`)
|
||||||
|
|
||||||
|
* `ws.py` 拿到这个对象后,干两件事:
|
||||||
|
1. **塞 Bot 实例**:把 `self.bot` 塞进 `event.bot` 里。这样你在插件里拿到事件,就能直接 `event.reply()` 回复,不用到处找 Bot 实例。
|
||||||
|
2. **扔出去**:把事件扔给 `matcher.handle_event(bot, event)`,也就是命令管理器。
|
||||||
|
|
||||||
|
### 4. 找找谁来处理 (`core/managers/command_manager.py`)
|
||||||
|
|
||||||
|
* `CommandManager` (就是代码里的 `matcher`)
|
||||||
|
* 它看了一眼,然后转手交给 `MessageHandler`。
|
||||||
|
* `MessageHandler` 看消息内容是以 `/` 开头的吗?”
|
||||||
|
* 如果是 `/echo`,已经注册的指令列表,找到了 `plugins/echo.py` 里那个被 `@matcher.command("echo")` 标记的函数。
|
||||||
|
|
||||||
|
### 5. 干活 (`plugins/echo.py`)
|
||||||
|
|
||||||
|
* 直接调用它,把 `Event` 对象和参数 `args` 传进去。
|
||||||
|
* 这时候就是你写的代码在跑了。你想干啥都行。。。
|
||||||
|
|
||||||
|
### 6. 回复消息 (`core/bot.py` -> `core/ws.py`)
|
||||||
|
|
||||||
|
* 你在插件里写了 `await event.reply("hello")`。
|
||||||
|
* 这行代码背后,是 `core/bot.py` 把你的话封装成了一个标准的 OneBot API 请求(`send_group_msg`)。
|
||||||
|
* 然后 `core/ws.py` 把这个请求变成 JSON,通过 WebSocket 扔回给 OneBot。
|
||||||
|
|
||||||
|
### 7. 发送成功
|
||||||
|
|
||||||
|
* OneBot 收到请求,把 "hello" 发到了群里。
|
||||||
|
* 恩。。。
|
||||||
|
|
||||||
|
至此,一个完整的事件流转闭环就完成了。理解这个流程后,您就能明白框架是如何为开发者提供便捷接口的。
|
||||||
122
docs/core-concepts/performance.md
Normal file
122
docs/core-concepts/performance.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# 性能优化详解
|
||||||
|
|
||||||
|
NEO Bot 实际上是python,有人说用Java可能更好。。。嗯但是镀铬酸钾不会Java,镀铬酸钾只会python,所以只能用python了
|
||||||
|
|
||||||
|
## 1. Playwright 页面池 (Page Pool)
|
||||||
|
|
||||||
|
### 痛点
|
||||||
|
之前 Bot 发图流程:
|
||||||
|
1. 用户发指令。
|
||||||
|
2. Bot 启动浏览器。
|
||||||
|
3. 创建新页面。。
|
||||||
|
4. 渲染,截图。
|
||||||
|
5. 关闭浏览器。
|
||||||
|
|
||||||
|
这种模式下,发一张图至少要等 1 秒以上。。。
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
`BrowserManager` 维护了一个**页面池**。
|
||||||
|
* **启动时**: 自动预热 3 个页面(可配置),挂在后台待命。
|
||||||
|
* **运行时**: 需要截图时,直接从池里 `get_page()`
|
||||||
|
* **结束后**: 截图完成,页面执行 `about:blank` 洗白,然后 `release_page()` 放回池里。
|
||||||
|
|
||||||
|
### 收益
|
||||||
|
我不知道快了多少,也没人测试,嗯
|
||||||
|
|
||||||
|
## 2. Jinja2 模板缓存
|
||||||
|
|
||||||
|
### 痛点
|
||||||
|
每次渲染 HTML,都要从硬盘读文件,然后解析模板语法。硬盘 IO 是慢的,解析也是慢的。
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
`ImageManager` 引入了内存缓存 `_template_cache`。
|
||||||
|
* 第一次读取模板后,编译好的 `Template` 对象直接存入字典。
|
||||||
|
* 后续请求直接从内存拿对象渲染。
|
||||||
|
|
||||||
|
### 收益
|
||||||
|
省了硬盘IO
|
||||||
|
|
||||||
|
## 3. 全局 HTTP 连接复用
|
||||||
|
|
||||||
|
### 痛点
|
||||||
|
插件(如 B站解析)每次请求 API 都创建一个新的 `aiohttp.ClientSession`。
|
||||||
|
这意味着每次都要进行:DNS 解析 -> TCP 握手 -> SSL 握手。这在 HTTPS 下非常慢。
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
我们在插件层面实现了 `get_session()`。
|
||||||
|
* 全局共享一个 `ClientSession`。
|
||||||
|
* 复用底层的 TCP 连接 (Keep-Alive)。
|
||||||
|
|
||||||
|
### 收益
|
||||||
|
实际上我也不知道,bot没高并发的实验。。。
|
||||||
|
|
||||||
|
## 4. orjson 极速序列化
|
||||||
|
|
||||||
|
### 痛点
|
||||||
|
Python 自带的 `json` 库性能好像不太好,特别是在处理 OneBot 这种大量 JSON 通信的场景下。
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
全面替换为 `orjson`。
|
||||||
|
* Rust 编写
|
||||||
|
* 支持直接返回 `bytes`,减少内存复制。
|
||||||
|
|
||||||
|
## 5. Python 3.14 JIT (Just-In-Time Compilation)
|
||||||
|
|
||||||
|
### 痛点
|
||||||
|
Python 解释器一边解析一边执行,遇到循环和函数调用就得反复解释。像消息处理这种高频循环,解释开销就特别明显。
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
Python 3.14 自带了一个实验性的 JIT 编译器。启动时加上 `-X jit` 参数,它就会在运行时把热点代码编译成机器码。
|
||||||
|
|
||||||
|
**JIT 怎么工作的?**
|
||||||
|
1. **监控**: 解释器运行时会统计哪些函数、哪些循环被调最得频繁。
|
||||||
|
2. **编译**: 把这些“热点”代码编译成机器码。
|
||||||
|
3. **替换**: 下次再执行到这段代码,直接跑机器码,跳过解释步骤。
|
||||||
|
|
||||||
|
**哪些代码受益最大?**
|
||||||
|
- `plugins/` 里的业务逻辑(比如 B站解析、代码沙箱)。
|
||||||
|
- 循环密集的操作(比如遍历消息段、处理大量群消息)。
|
||||||
|
- 频繁调用的工具函数。
|
||||||
|
|
||||||
|
### 如何启用?
|
||||||
|
启动机器人时加上 `-X jit` 参数:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -X jit main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 收益
|
||||||
|
* **热点代码加速**: 经常跑的代码能快 2-10 倍(看具体场景)。
|
||||||
|
* **零配置**: 不用改代码,加个启动参数就行。
|
||||||
|
* **与 Mypyc 互补**: JIT 负责动态、灵活的插件代码;Mypyc 负责静态、类型明确的核心模块。两者结合,全面覆盖。
|
||||||
|
|
||||||
|
## 6. Mypyc 编译 (AOT Compilation)
|
||||||
|
|
||||||
|
### 痛点
|
||||||
|
Python 作为一种解释型语言,在处理 CPU 密集型任务时性能较差。对于机器人框架的核心部分,如 WebSocket 消息解析、事件分发和插件管理,这些代码被高频调用,其性能直接影响机器人的响应速度和吞吐量。
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
我们引入了 `Mypyc`,一个将类型注解的 Python 代码编译为高性能 C 扩展的工具。通过项目根目录下的 `setup_mypyc.py` 脚本,我们可以选择性地将核心模块编译为二进制文件(在 Windows 上是 `.pyd`,在 Linux 上是 `.so`)。
|
||||||
|
|
||||||
|
**哪些模块被编译了?**
|
||||||
|
- `core/ws.py`: WebSocket 消息处理循环,这是整个机器人框架的 I/O 中枢。
|
||||||
|
- `core/managers/*.py`: 所有的核心管理器,如指令管理器、插件管理器等,负责事件分发和业务逻辑。
|
||||||
|
- `core/utils/*.py`: 高频使用的工具函数。
|
||||||
|
- `models/*.py`: 数据模型类,如消息段、发送者等。
|
||||||
|
|
||||||
|
这些高频调用的代码路径被编译为接近原生机器码的速度,极大地提升了性能。
|
||||||
|
|
||||||
|
### 如何编译?
|
||||||
|
在项目根目录下运行以下指令:
|
||||||
|
```bash
|
||||||
|
python setup_mypyc.py
|
||||||
|
```
|
||||||
|
脚本会自动查找并编译预设的模块列表。
|
||||||
|
|
||||||
|
### 特别注意:关于事件模型的编译
|
||||||
|
`Mypyc` 对 Python 某些动态特性和高级用法支持尚不完善。在实践中,我们发现 `dataclass` 与 `Mypyc` 存在一些兼容性问题,尤其是在使用继承和某些高级特性(如 `slots=True`)时,可能会导致编译失败或运行时错误(例如 `AttributeError: attribute '__dict__' of 'type' objects is not writable`)。
|
||||||
|
|
||||||
|
- **当前状态**:为了确保稳定性,`setup_mypyc.py` 脚本**默认不编译** `models/events/` 目录下的事件模型文件。这些文件虽然也被频繁使用,但它们的结构相对复杂,与 `Mypyc` 的兼容性问题仍在探索中。
|
||||||
|
- **未来展望**:我们会持续关注 `Mypyc` 的更新,当其对 `dataclass` 的支持得到改善后,会重新尝试将事件模型加入编译列表,以实现极致的性能。
|
||||||
|
|
||||||
|
通过这种方式,我们在保证核心模块性能的同时,也维持了项目的稳定性和可维护性。
|
||||||
94
docs/core-concepts/singleton-managers.md
Normal file
94
docs/core-concepts/singleton-managers.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# 核心概念:单例管理器
|
||||||
|
|
||||||
|
`core/managers/` 这地方,放的都是些**管事的**。它们是 NEO Bot 的核心。梨花飘落在你窗前。。。
|
||||||
|
|
||||||
|
## 为啥是单例?
|
||||||
|
|
||||||
|
就是**全局独一份**。
|
||||||
|
|
||||||
|
* **到处都能用**: 在插件里 `import` 就行,不用传来传去。
|
||||||
|
* **数据不打架**: 权限、命令这些东西,全局就一份,改了都认。
|
||||||
|
* **省资源**: Redis 连接池、浏览器这种东西,开一个就够了,多了浪费。
|
||||||
|
|
||||||
|
我专门在 `core/utils/singleton.py` 搞了个基类,继承一下就行,你会的,加油。。。
|
||||||
|
|
||||||
|
## 认识一下
|
||||||
|
|
||||||
|
### 1. `CommandManager` (`matcher`)
|
||||||
|
|
||||||
|
* **怎么找**: `from core.managers.command_manager import matcher`
|
||||||
|
* **管啥**:
|
||||||
|
* **总调度**: 所有消息都得从它这过一遍
|
||||||
|
* **发牌的**: 你用的 `@matcher.command()` 这种装饰器,就是它发的。
|
||||||
|
* **对号入座**: 消息来了,它负责对一下,看是哪个插件的。
|
||||||
|
|
||||||
|
写插件天天都得跟它打交道。
|
||||||
|
|
||||||
|
### 2. `PermissionManager` (`permission_manager`)
|
||||||
|
|
||||||
|
* **怎么找**: `from core.managers.permission_manager import permission_manager`
|
||||||
|
* **管啥**:
|
||||||
|
* **划分三六九等**: `ADMIN`, `OP`, `USER` 这些等级都是它定的。
|
||||||
|
* **管理权限**: 谁有啥权限,都记在 `core/data/permissions.json` 里。
|
||||||
|
* **会自动变通**: 查权限的时候,它会把 `AdminManager` 里的超管也当成 `ADMIN`。
|
||||||
|
|
||||||
|
### 3. `AdminManager` (`admin_manager`)
|
||||||
|
|
||||||
|
* **怎么找**: `from core.managers.admin_manager import admin_manager`
|
||||||
|
* **管啥**:
|
||||||
|
* **钦差大臣**: 专门管机器人的超级管理员,增删改查都在这。
|
||||||
|
* **三级缓存**: 内存 -> Redis -> 文件
|
||||||
|
|
||||||
|
### 4. `PluginManager`
|
||||||
|
|
||||||
|
* **管啥**:
|
||||||
|
* **拉人头**: 启动时把 `plugins/` 目录下的插件都拉进来。
|
||||||
|
* **热更新**: 你改了插件代码,它负责重载,不用重启机器人。
|
||||||
|
|
||||||
|
这一般在幕后,你基本不用找它。
|
||||||
|
|
||||||
|
### 5. `RedisManager` (`redis_manager`)
|
||||||
|
|
||||||
|
* **怎么找**: `from core.managers.redis_manager import redis_manager`
|
||||||
|
* **管啥**:
|
||||||
|
* **接线员**: 管着和 Redis 的连接。
|
||||||
|
* **提供工具**: 你要用 Redis,就找 `redis_manager.redis`。
|
||||||
|
|
||||||
|
### 6. `BrowserManager` (`browser_manager`)
|
||||||
|
|
||||||
|
* **怎么找**: `from core.managers.browser_manager import browser_manager`
|
||||||
|
* **管啥**:
|
||||||
|
* **浏览器**: 负责启动和关闭 Playwright。
|
||||||
|
* **页面池**: 提前准备好几个空白页面(默认3个),你要用直接拿
|
||||||
|
* **循环利用**: 用完记得还回来 (`release_page`)
|
||||||
|
|
||||||
|
### 7. `ImageManager` (`image_manager`)
|
||||||
|
|
||||||
|
* **怎么找**: `from core.managers.image_manager import image_manager`
|
||||||
|
* **管啥**:
|
||||||
|
* **美工**: 把数据塞进网页模板
|
||||||
|
* **记性好**: 模板用一次就记住,下次直接用缓存。
|
||||||
|
* **自动借还**: 它会自动找 `BrowserManager` 借页面,你只管 `render_template` 就行。
|
||||||
|
|
||||||
|
## 咋用?
|
||||||
|
|
||||||
|
`import`
|
||||||
|
|
||||||
|
**例子**: 查查这人是不是op
|
||||||
|
|
||||||
|
```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):
|
||||||
|
# 只有管理员能看
|
||||||
|
is_admin = await permission_manager.check_permission(event.user_id, ADMIN)
|
||||||
|
if is_admin:
|
||||||
|
await event.reply("这是秘密!")
|
||||||
|
else:
|
||||||
|
await event.reply("你没权限看这个。")
|
||||||
|
```
|
||||||
107
docs/deployment.md
Normal file
107
docs/deployment.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# 部署指南
|
||||||
|
|
||||||
|
把 Bot 扔到服务器上长期运行,比在自己电脑上跑要多几个步骤。
|
||||||
|
|
||||||
|
## 1. 环境准备
|
||||||
|
|
||||||
|
### a. 安装 Python 3.14
|
||||||
|
|
||||||
|
用3.14。。。
|
||||||
|
|
||||||
|
### b. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 切换到项目目录
|
||||||
|
cd /path/to/your/bot
|
||||||
|
|
||||||
|
# 创建虚拟环境 (强烈建议)
|
||||||
|
python3.14 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### c. 编译核心模块 (可选,但为获得最佳性能强烈建议)
|
||||||
|
|
||||||
|
为了最大化性能,你可以将项目中的核心 Python 模块编译成 C 语言扩展。这将大幅提升机器人的响应速度和处理效率。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 确保你在虚拟环境中
|
||||||
|
python setup_mypyc.py
|
||||||
|
```
|
||||||
|
|
||||||
|
该脚本会自动编译 `core` 和 `models` 目录下的指定模块。编译后的文件(`.pyd` 或 `.so`)会直接生成在源码旁边。
|
||||||
|
|
||||||
|
> **注意**: 编译产物是平台相关的(例如,在 Windows 上编译的 `.pyd` 文件不能在 Linux 上使用)。因此,**请务必在你最终部署的服务器环境(例如 Linux)上执行此编译步骤**。更多关于 Mypyc 编译的细节,请参考 [性能优化详解](core-concepts/performance.md)。
|
||||||
|
|
||||||
|
## 2. 使用进程管理器
|
||||||
|
|
||||||
|
你想直接 `python main.py` 然后关掉 SSH?那机器人也跟着停了。必须用进程管理器来守护它。
|
||||||
|
|
||||||
|
这里推荐用 `pm2`,虽然是 Node.js 的工具,但管 Python 程序一样好用。
|
||||||
|
|
||||||
|
### a. 安装 pm2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 你需要先装 Node.js 和 npm
|
||||||
|
npm install pm2 -g
|
||||||
|
```
|
||||||
|
|
||||||
|
### b. 启动 Bot
|
||||||
|
|
||||||
|
在项目根目录,创建一个 `ecosystem.config.js` 文件:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
apps : [{
|
||||||
|
name : "neobot",
|
||||||
|
script : "main.py",
|
||||||
|
interpreter: "/path/to/your/bot/venv/bin/python", // 指定虚拟环境里的 python
|
||||||
|
max_memory_restart: "500M", // 内存超过 500M 自动重启
|
||||||
|
env: {
|
||||||
|
"PYTHONUNBUFFERED": "1" // 禁用 python 输出缓冲,日志能实时看
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
然后启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### c. 常用 pm2 命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 list # 查看所有进程状态
|
||||||
|
pm2 logs neobot # 查看 neobot 的实时日志
|
||||||
|
pm2 restart neobot# 重启 neobot
|
||||||
|
pm2 stop neobot # 停止 neobot
|
||||||
|
pm2 delete neobot # 删除 neobot
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 配置 NapCatQQ
|
||||||
|
|
||||||
|
最后一步,修改 NapCatQQ 的配置文件,让它把消息推送到你的服务器上。
|
||||||
|
|
||||||
|
找到 NapCatQQ 的 `config/onebot11.json` 文件,修改 `ws_reverse_servers` 部分:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"ws_reverse_servers": [
|
||||||
|
{
|
||||||
|
"url": "ws://你的服务器IP:8080/onebot/v11/ws",
|
||||||
|
"access_token": "你的访问令牌"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
* `url`: 改成你服务器的 IP 和 `main.py` 里配置的端口。
|
||||||
|
* `access_token`: 如果你在 `main.py` 里设置了 `ACCESS_TOKEN`,这里要保持一致。
|
||||||
|
|
||||||
|
|
||||||
|
或者你也可以用napcat的webui,不多赘述了。。。
|
||||||
|
|
||||||
|
|
||||||
|
改完后重启 NapCatQQ,Bot 应该就能收到消息了。
|
||||||
95
docs/getting-started.md
Normal file
95
docs/getting-started.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# 快速上手
|
||||||
|
|
||||||
|
runit
|
||||||
|
|
||||||
|
## 1. 你需要准备
|
||||||
|
|
||||||
|
* **Python 3.14**: 必须是这个版本别问我为什么。。。
|
||||||
|
* **Git**: 拉取代码
|
||||||
|
* **Redis**: 装一个
|
||||||
|
* **脑子和手**: 这个最重要,或者你去问问镀铬酸钾,会给你一对一教学的。。。
|
||||||
|
* **OneBot v11 客户端**: 机器人本体,推荐用 [NapCatQQ](https://github.com/NapNeko/NapCatQQ)
|
||||||
|
|
||||||
|
## 2. 搭起来
|
||||||
|
|
||||||
|
### a. 克隆代码
|
||||||
|
|
||||||
|
找个你喜欢的地方,把代码从 GitHub 上clone下来
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone [项目仓库地址]
|
||||||
|
cd [项目目录]
|
||||||
|
```
|
||||||
|
|
||||||
|
### b. 创建虚拟环境
|
||||||
|
|
||||||
|
别把你的系统环境搞得乱七八糟
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
python -m venv venv
|
||||||
|
.\venv\Scripts\activate
|
||||||
|
|
||||||
|
# Linux / macOS
|
||||||
|
python3.14 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
看到命令行前面多了个 `(venv)`,就说明你进来了。
|
||||||
|
|
||||||
|
### c. 安装依赖
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### d. 安装 Playwright 依赖
|
||||||
|
|
||||||
|
我们用 Playwright 来截图画画,它需要一个浏览器核心。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
### e. 编译核心 (可选,但强烈建议)
|
||||||
|
|
||||||
|
想让你的代码更快?把它的核心代码编译成 C。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python setup_mypyc.py build_ext --inplace
|
||||||
|
```
|
||||||
|
*注:Windows 上可能需要装个 Visual Studio Build Tools,Linux 上需要 GCC。编译失败也别慌,跳过就行,JIT 也能保证不错的速度*
|
||||||
|
|
||||||
|
## 3. 第一次
|
||||||
|
|
||||||
|
### a. 修改配置
|
||||||
|
|
||||||
|
去根目录找 `config.toml`。
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[napcat_ws]
|
||||||
|
# 你的 OneBot 地址
|
||||||
|
# 我们用的是正向连接,也就是 Bot 主动去连 OneBot
|
||||||
|
uri = "ws://127.0.0.1:3001"
|
||||||
|
token = ""
|
||||||
|
|
||||||
|
[redis]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 6379
|
||||||
|
db = 0
|
||||||
|
```
|
||||||
|
把 `uri` 改成你自己的 OneBot 地址。
|
||||||
|
|
||||||
|
### b. 启动!
|
||||||
|
|
||||||
|
一切就绪
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 推荐开启 JIT 模式启动
|
||||||
|
python -X jit main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你看到日志刷出来,最后显示 “连接成功!”,恭喜,你成功了!
|
||||||
|
|
||||||
|
现在,试着给你的机器人发个 `/help`看看会返回什么东西
|
||||||
38
docs/index.md
Normal file
38
docs/index.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# NEO Bot 开发文档
|
||||||
|
|
||||||
|
嘿,朋友,欢迎来到 NEO Bot
|
||||||
|
|
||||||
|
这里没那么多规矩。这份文档是我写给你——未来的插件开发者、或者单纯好奇想拆开看看的家伙——的一份地图
|
||||||
|
|
||||||
|
|
||||||
|
## 📖 地图导览
|
||||||
|
|
||||||
|
### 1. 准备阶段
|
||||||
|
* [快速上手](./getting-started.md): 搭环境、装东西、启动。跟着走一遍,能省不少事。
|
||||||
|
* [项目怎么样](./project-structure.md): 看看各个文件夹都是干嘛的,免得迷路。
|
||||||
|
* [生产环境](./deployment.md): 怎么把你调教好的 Bot 扔服务器上,让它自己 7x24 小时跑。
|
||||||
|
|
||||||
|
### 2. 核心探秘
|
||||||
|
* [骨架](./core-concepts/architecture.md): 看看镀铬酸钾和python打架,嗯。。。
|
||||||
|
* [性能优化](./core-concepts/performance.md): 页面池、JIT、Mypyc...
|
||||||
|
* [消息流](./core-concepts/event-flow.md): 看看一条消息从被接收到被回复是如何运行的
|
||||||
|
* [核心](./core-concepts/singleton-managers.md): `matcher`, `browser_manager`... 认识这些核心模块。
|
||||||
|
* [错误处理](./core-concepts/error-handling.md): 了解系统的错误处理机制和错误码定义。
|
||||||
|
|
||||||
|
### 3. API 参考
|
||||||
|
* [API 总览](./api/index.md): 所有 API 的快速导航和调用方式
|
||||||
|
* [消息 API](./api/message.md): 发消息、撤回、转发、合并转发
|
||||||
|
* [群组 API](./api/group.md): 管群、禁言、踢人、改名片
|
||||||
|
* [好友 API](./api/friend.md): 好友列表、点赞、加好友请求
|
||||||
|
* [账号 API](./api/account.md): 机器人自己的信息、状态设置
|
||||||
|
* [媒体 API](./api/media.md): 图片、语音相关
|
||||||
|
|
||||||
|
### 4. 插件开发
|
||||||
|
* [插件开发第一步](./plugin-development/index.md): 带你写第一个插件
|
||||||
|
* [指南](./plugin-development/command-handling.md): 怎么教你的 Bot 听懂指令和参数。
|
||||||
|
* [绝对不要做的事情](./plugin-development/best-practices.md): **(必读!)**
|
||||||
|
|
||||||
|
## 贡献
|
||||||
|
|
||||||
|
发现 Bug 了?觉得文档写得烂?
|
||||||
|
直接提 Issue 或者 PR。代码质量是第一位的,Talk is cheap, show me the code.
|
||||||
67
docs/plugin-development/best-practices.md
Normal file
67
docs/plugin-development/best-practices.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# 插件开发最佳实践
|
||||||
|
|
||||||
|
写插件很简单,但写出**高性能、不炸裂**的插件需要遵守规矩。
|
||||||
|
|
||||||
|
## 1. 绝对不要阻塞事件循环。。。
|
||||||
|
|
||||||
|
这是底线。NEO Bot 是单线程异步架构,如果你在主线程里 `time.sleep(5)`,整个机器人就会卡死 5 秒
|
||||||
|
|
||||||
|
* **错误**: `time.sleep(1)`, `requests.get(...)`, 大量 CPU 计算。
|
||||||
|
* **正确**: `await asyncio.sleep(1)`, `await session.get(...)`。
|
||||||
|
|
||||||
|
如果你必须运行同步代码(比如图像处理、复杂计算):
|
||||||
|
```python
|
||||||
|
from core.utils.executor import run_in_thread_pool
|
||||||
|
|
||||||
|
# 扔到线程池里去跑,别占着主线程
|
||||||
|
result = await run_in_thread_pool(heavy_function, arg1, arg2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 复用资源
|
||||||
|
|
||||||
|
别每次都创建新的连接。
|
||||||
|
|
||||||
|
* **HTTP 请求**: 使用插件内提供的 `get_session()` 或全局 `aiohttp` session。
|
||||||
|
* **浏览器**: 必须使用 `browser_manager.get_page()`,严禁自己 `playwright.chromium.launch()`。
|
||||||
|
|
||||||
|
## 3. 善用缓存
|
||||||
|
|
||||||
|
如果你的插件需要查外部 API(比如查天气、查 B 站),记得加缓存。
|
||||||
|
Redis 就在那里,不用白不用。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.managers.redis_manager import redis_manager
|
||||||
|
|
||||||
|
# 存
|
||||||
|
await redis_manager.set("weather:beijing", "sunny", ex=3600)
|
||||||
|
# 取
|
||||||
|
weather = await redis_manager.get("weather:beijing")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 类型提示 (Type Hinting)
|
||||||
|
|
||||||
|
我开启了 Mypyc 编译,这意味着你的代码最好有规范的类型提示。
|
||||||
|
这不仅是为了编译,也是为了让你自己少写 Bug
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 好的写法
|
||||||
|
async def handle(event: MessageEvent, args: list[str]) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 不好写法
|
||||||
|
async def handle(event, args):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 异常处理
|
||||||
|
|
||||||
|
别让你的插件因为一个报错就崩溃机器人
|
||||||
|
虽然框架层有捕获机制,但你自己处理好异常是最好的。。。
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
await do_something()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"插件炸了: {e}")
|
||||||
|
await event.reply("出错了,请稍后再试。")
|
||||||
|
```
|
||||||
137
docs/plugin-development/command-handling.md
Normal file
137
docs/plugin-development/command-handling.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# 指令处理与参数解析
|
||||||
|
|
||||||
|
光会 `event.reply()` 只能写小插件。。。认识一下其他的方法吧
|
||||||
|
|
||||||
|
## 1. 获取原始参数
|
||||||
|
|
||||||
|
最简单粗暴的方式,就是直接在处理器函数里声明 `args: str`。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.managers.command_manager import matcher
|
||||||
|
from models.events.message import MessageEvent
|
||||||
|
|
||||||
|
@matcher.command("echo")
|
||||||
|
async def handle_echo(event: MessageEvent, args: str):
|
||||||
|
# 如果用户发送 /echo hello world
|
||||||
|
# args 的值就是 "hello world"
|
||||||
|
if not args:
|
||||||
|
await event.reply("你啥也没说啊")
|
||||||
|
else:
|
||||||
|
await event.reply(f"你说了:{args}")
|
||||||
|
```
|
||||||
|
|
||||||
|
`args` 就是去掉命令本身后,后面跟着的**一整坨字符串**。
|
||||||
|
|
||||||
|
## 2. 自动解析参数 (推荐)
|
||||||
|
|
||||||
|
一整坨字符串用起来太费劲了,还得自己 `split()`。框架提供了更高级的玩法:**参数自动解析**。
|
||||||
|
|
||||||
|
你只需要在函数签名里,用类型提示声明你想要的参数,框架会动帮你解析和注入。
|
||||||
|
|
||||||
|
### a. 基础用法
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.managers.command_manager import matcher
|
||||||
|
from models.events.message import MessageEvent
|
||||||
|
|
||||||
|
@matcher.command("add")
|
||||||
|
async def handle_add(event: MessageEvent, a: int, b: int):
|
||||||
|
# 如果用户发送 /add 10 20
|
||||||
|
# 框架会自动把 "10" 转成整数 10,注入给 a
|
||||||
|
# 把 "20" 转成整数 20,注入给 b
|
||||||
|
result = a + b
|
||||||
|
await event.reply(f"计算结果是:{result}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**它是怎么工作的?**
|
||||||
|
|
||||||
|
框架会按顺序把 `args` 字符串用空格分割,然后尝试把分割后的每一块,转换成你声明的参数类型。
|
||||||
|
|
||||||
|
* `/add 10 20` -> `args` 是 `"10 20"` -> 分割成 `["10", "20"]`
|
||||||
|
* 第一块 `"10"` -> 尝试转成 `int` -> 成功,`a = 10`
|
||||||
|
* 第二块 `"20"` -> 尝试转成 `int` -> 成功,`b = 20`
|
||||||
|
|
||||||
|
### b. 处理可选参数和默认值
|
||||||
|
|
||||||
|
你可以像普通 Python 函数一样,给参数提供默认值。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
@matcher.command("greet")
|
||||||
|
async def handle_greet(event: MessageEvent, name: str, title: Optional[str] = "先生"):
|
||||||
|
# 例 1: /greet 张三
|
||||||
|
# name = "张三", title = "先生" (默认值)
|
||||||
|
|
||||||
|
# 例 2: /greet 李四 女士
|
||||||
|
# name = "李四", title = "女士"
|
||||||
|
|
||||||
|
await event.reply(f"你好,{name} {title}!")
|
||||||
|
```
|
||||||
|
|
||||||
|
### c. 贪婪的最后一个参数
|
||||||
|
|
||||||
|
有时候,最后一个参数可能包含空格,比如 `/say hello world`。默认情况下,`hello` 会被解析给第一个参数,`world` 会被解析给第二个。
|
||||||
|
|
||||||
|
如果你想让最后一个参数“吃掉”所有剩下的内容,可以用 `...` 作为默认值(这是一个特殊的标记)。
|
||||||
|
|
||||||
|
```python
|
||||||
|
@matcher.command("say")
|
||||||
|
async def handle_say(event: MessageEvent, target_user: str, content: str = ...):
|
||||||
|
# 例: /say 张三 早上好,吃了没?
|
||||||
|
# target_user = "张三"
|
||||||
|
# content = "早上好,吃了没?"
|
||||||
|
|
||||||
|
await event.reply(f"正在对 {target_user} 说:{content}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 智能的参数注入
|
||||||
|
|
||||||
|
除了 `args` 列表,命令处理器还可以自动接收一些非常有用的上下文对象。框架底层使用了 Python 的 `inspect` 模块来分析你函数的参数签名,并自动“注入”你需要的对象。
|
||||||
|
|
||||||
|
这是一种轻量级的**依赖注入**,让你的代码更简洁、更易于测试。
|
||||||
|
|
||||||
|
### 可用的参数
|
||||||
|
|
||||||
|
你可以在命令处理函数的参数中声明以下任意名称,框架会自动为你传入:
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 描述 |
|
||||||
|
| ------------------- | -------------------------------- | ---------------------------------------- |
|
||||||
|
| `bot` | `Bot` | 当前的 Bot 实例,用于调用 API 发送消息等。 |
|
||||||
|
| `event` | `MessageEvent` (或其子类) | 触发该命令的完整消息事件对象。 |
|
||||||
|
| `args` | `List[str]` | 和之前一样,包含命令参数的字符串列表。 |
|
||||||
|
| `permission_granted`| `bool` | 指示当前用户是否通过了权限检查。 |
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
假设我们想写一个“回声”命令,但只在用户拥有管理员权限时才重复他们的消息。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# plugins/echo_plus.py
|
||||||
|
from core.bot import Bot
|
||||||
|
from core.permission import ADMIN
|
||||||
|
from models.events.message import MessageEvent
|
||||||
|
from core.managers.command_manager import matcher
|
||||||
|
|
||||||
|
@matcher.command("echo_plus", permission=ADMIN)
|
||||||
|
async def echo_plus(bot: Bot, event: MessageEvent, args: list[str], permission_granted: bool):
|
||||||
|
"""
|
||||||
|
一个更强大的回声命令
|
||||||
|
"""
|
||||||
|
# 只有当 permission_granted 为 True 时,代码才会执行到这里
|
||||||
|
# 因为框架会自动处理权限拒绝的情况
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
await bot.send(event, "你想要我复述什么呢?")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 我们可以从 event 对象中获取更详细的信息
|
||||||
|
user_id = event.user_id
|
||||||
|
message_to_echo = " ".join(args)
|
||||||
|
|
||||||
|
response = f"管理员 {user_id} 说:{message_to_echo}"
|
||||||
|
await bot.send(event, response)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
在这个例子中,我们没有手动检查权限。我们只是在 `@matcher.command` 中声明了 `permission=ADMIN`,然后在函数参数中请求了 `permission_granted: bool`。框架会自动完成权限检查,如果失败,甚至不会执行我们的函数,并会发送一条权限不足的消息。这就是依赖注入的强大之处。
|
||||||
74
docs/plugin-development/index.md
Normal file
74
docs/plugin-development/index.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# 插件开发入门
|
||||||
|
|
||||||
|
写插件是给 NEO Bot 添加功能的唯一方式,一个 Python 文件就是一个插件。(或者一个文件夹里边有__init__.py)
|
||||||
|
|
||||||
|
## 1. 创建你的第一个插件
|
||||||
|
|
||||||
|
在 `plugins/` 目录下,新建一个 `hello.py` 文件。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# plugins/hello.py
|
||||||
|
|
||||||
|
from core.managers.command_manager import matcher
|
||||||
|
from models.events.message import MessageEvent
|
||||||
|
|
||||||
|
# __plugin_meta__ 是插件元信息,会在 /help 指令里显示
|
||||||
|
__plugin_meta__ = {
|
||||||
|
"name": "你好世界",
|
||||||
|
"description": "一个简单的示例插件",
|
||||||
|
"usage": "/hello - 发送你好"
|
||||||
|
}
|
||||||
|
|
||||||
|
# @matcher.command() 装饰器注册一个命令
|
||||||
|
# "hello" 是命令名,aliases 是别名
|
||||||
|
@matcher.command("hello", aliases=["hi", "你好"])
|
||||||
|
async def handle_hello(event: MessageEvent):
|
||||||
|
"""
|
||||||
|
处理 /hello 命令
|
||||||
|
"""
|
||||||
|
# event.reply() 是一个快捷方法,可以直接回复消息
|
||||||
|
await event.reply(f"你好,{event.sender.nickname}!")
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 加载插件
|
||||||
|
|
||||||
|
不用你动手,NEO Bot 启动时会自动加载 `plugins/` 目录下的所有 `.py` 文件。
|
||||||
|
|
||||||
|
## 3. 测试插件
|
||||||
|
|
||||||
|
现在,去群里或者私聊给 Bot 发送:
|
||||||
|
|
||||||
|
* `/hello`
|
||||||
|
* `/hi`
|
||||||
|
* `/你好`
|
||||||
|
|
||||||
|
Bot 应该会回复你:“你好,[你的昵称]!”
|
||||||
|
|
||||||
|
## 插件剖析
|
||||||
|
|
||||||
|
### `__plugin_meta__`
|
||||||
|
|
||||||
|
这个字典不是必须的,但强烈建议写上。它定义了插件的元信息,主要给 `/help` 命令用。
|
||||||
|
|
||||||
|
* `name`: 插件叫啥。
|
||||||
|
* `description`: 这插件是干嘛的。
|
||||||
|
* `usage`: 怎么用,写上具体的指令和说明。
|
||||||
|
|
||||||
|
### `@matcher.command()`
|
||||||
|
|
||||||
|
这是最核心的装饰器,用来注册一个命令处理器。
|
||||||
|
|
||||||
|
* **第一个参数**: `name` (str),命令的主名。
|
||||||
|
* `aliases`: `List[str]`,命令的别名列表。
|
||||||
|
* `permission`: `int`,执行该命令所需的权限等级,默认为 `USER` (所有人可用)。可以是 `ADMIN`, `OP`。
|
||||||
|
|
||||||
|
### 处理器函数
|
||||||
|
|
||||||
|
被 `@matcher.command()` 装饰的函数就是处理器。它必须是一个 `async` 异步函数。
|
||||||
|
|
||||||
|
* **参数**: 框架会自动往里注入参数,你只需要用类型提示声明你需要什么。
|
||||||
|
* `event: MessageEvent`: 这是最常用的,包含了消息的所有信息,比如发送者、群号、消息内容等。
|
||||||
|
* `args: str`: 如果命令有参数(比如 `/echo hello world`),`args` 就是 `hello world` 这部分字符串。
|
||||||
|
|
||||||
|
就这么简单,一个最基础的插件就写完了。
|
||||||
48
docs/project-structure.md
Normal file
48
docs/project-structure.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# 项目结构
|
||||||
|
|
||||||
|
了解项目里每个文件夹是干嘛的,能让你更快找到代码。
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── core/ # 核心代码,别乱动
|
||||||
|
│ ├── handlers/ # 底层事件处理器
|
||||||
|
│ ├── managers/ # 全局单例管理器
|
||||||
|
│ ├── utils/ # 工具函数
|
||||||
|
│ └── ws.py # WebSocket 连接实现
|
||||||
|
├── data/ # 存放持久化数据
|
||||||
|
│ ├── admin.json # 管理员列表
|
||||||
|
│ └── permissions.json # 用户权限列表
|
||||||
|
├── docs/ # 开发文档
|
||||||
|
├── logs/ # 日志文件
|
||||||
|
├── models/ # 数据模型
|
||||||
|
│ └── events/ # OneBot 事件模型
|
||||||
|
├── plugins/ # 你的插件都放这
|
||||||
|
├── templates/ # 图片渲染用的网页模板
|
||||||
|
├── venv/ # Python 虚拟环境
|
||||||
|
├── .gitignore # Git 忽略配置
|
||||||
|
├── main.py # 主入口文件
|
||||||
|
├── requirements.txt # Python 依赖列表
|
||||||
|
└── setup_mypyc.py # [可选] Mypyc 编译脚本,用于将核心模块编译为 C 扩展以提升性能
|
||||||
|
```
|
||||||
|
|
||||||
|
## 重点目录说明
|
||||||
|
|
||||||
|
### `core/`
|
||||||
|
|
||||||
|
这是框架的心脏。除非你知道自己在干嘛,否则别碰这里面的东西。大部分功能都由 `managers` 里的管理器提供,你只需要 `import` 它们就行。
|
||||||
|
|
||||||
|
### `data/`
|
||||||
|
|
||||||
|
存放一些 JSON 格式的数据。管理员和用户权限默认存在这里。如果你用 Redis,这些文件会作为备份。
|
||||||
|
|
||||||
|
### `plugins/`
|
||||||
|
|
||||||
|
**这是你最常待的地方**。你写的所有插件(`.py` 文件)都扔在这个目录里。Bot 启动时会自动加载这里的所有插件。
|
||||||
|
|
||||||
|
### `templates/`
|
||||||
|
|
||||||
|
如果你要用 `ImageManager` 画图,就需要把 HTML 模板文件放在这里。
|
||||||
|
|
||||||
|
### `main.py`
|
||||||
|
|
||||||
|
程序的入口。负责加载配置、初始化管理器、启动 WebSocket 连接和 FastAPI 服务。
|
||||||
217
main.py
217
main.py
@@ -5,13 +5,95 @@ NEO Bot 主程序入口
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from watchdog.observers import Observer
|
from watchdog.observers import Observer
|
||||||
from watchdog.events import FileSystemEventHandler
|
from watchdog.events import FileSystemEventHandler
|
||||||
|
|
||||||
from core import WS
|
# 初始化日志系统,必须在其他 core 模块导入之前执行
|
||||||
from core.plugin_manager import load_all_plugins
|
from core.utils.logger import logger
|
||||||
|
|
||||||
|
# 核心模块导入
|
||||||
|
from core.managers.admin_manager import admin_manager
|
||||||
|
from core.ws import WS
|
||||||
|
from core.managers import plugin_manager, matcher
|
||||||
|
from core.managers.redis_manager import redis_manager
|
||||||
|
from core.managers.browser_manager import browser_manager
|
||||||
|
from core.utils.executor import run_in_thread_pool, initialize_executor
|
||||||
|
from core.config_loader import global_config as config
|
||||||
|
|
||||||
|
# 检查 JIT 编译状态
|
||||||
|
def check_jit_status():
|
||||||
|
"""
|
||||||
|
检查 Python JIT 编译状态
|
||||||
|
|
||||||
|
该函数用于检测当前 Python 解释器是否启用了 JIT 编译功能,
|
||||||
|
并打印相关信息,帮助用户了解运行环境的性能优化状态。
|
||||||
|
"""
|
||||||
|
print("\n=== Python JIT 编译状态检查 ===")
|
||||||
|
|
||||||
|
# 检查解释器信息
|
||||||
|
print(f"Python 版本: {sys.version}")
|
||||||
|
print(f"解释器路径: {sys.executable}")
|
||||||
|
|
||||||
|
# 检查优化级别
|
||||||
|
print(f"优化级别 (-O): {sys.flags.optimize}")
|
||||||
|
|
||||||
|
# 检查 JIT 相关模块和功能
|
||||||
|
if sys.version_info >= (3, 10):
|
||||||
|
try:
|
||||||
|
# 对于 CPython 3.10+,检查是否启用了 JIT
|
||||||
|
import _opcode
|
||||||
|
if hasattr(_opcode, 'jit'):
|
||||||
|
print("JIT 状态: 已启用 (_opcode.jit)")
|
||||||
|
else:
|
||||||
|
print("JIT 状态: 未启用 (_opcode.jit 不可用)")
|
||||||
|
except ImportError:
|
||||||
|
print("JIT 状态: 未启用 (_opcode 模块不可用)")
|
||||||
|
else:
|
||||||
|
print("JIT 状态: 不可用 (需要 Python 3.10+)")
|
||||||
|
|
||||||
|
# 检查是否使用了 PyPy
|
||||||
|
if hasattr(sys, 'pypy_version_info'):
|
||||||
|
print(f"PyPy 版本: {sys.pypy_version_info}")
|
||||||
|
print("JIT 状态: 已启用 (PyPy 内置 JIT)")
|
||||||
|
|
||||||
|
print("==============================\n")
|
||||||
|
|
||||||
|
# 执行 JIT 状态检查
|
||||||
|
check_jit_status()
|
||||||
|
|
||||||
|
# 尝试使用高性能事件循环
|
||||||
|
try:
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
# winloop 与 Playwright 存在兼容性问题 (不支持 startupinfo),暂时禁用
|
||||||
|
# import winloop
|
||||||
|
# asyncio.set_event_loop_policy(winloop.EventLoopPolicy())
|
||||||
|
# print("已启用 winloop 高性能事件循环")
|
||||||
|
print("Windows 平台检测到 Playwright,已自动禁用 winloop 以确保兼容性")
|
||||||
|
else:
|
||||||
|
import uvloop
|
||||||
|
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||||
|
print("已启用 uvloop 高性能事件循环")
|
||||||
|
except ImportError:
|
||||||
|
print("未检测到高性能事件循环库 (uvloop/winloop),将使用默认事件循环")
|
||||||
|
|
||||||
|
# 将项目根目录添加到 sys.path
|
||||||
|
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, ROOT_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
# 获取插件目录的绝对路径
|
||||||
|
PLUGIN_DIR = os.path.join(ROOT_DIR, "plugins")
|
||||||
|
|
||||||
|
|
||||||
|
async def reload_plugin_and_sync_help(module_name: str):
|
||||||
|
"""
|
||||||
|
重载插件并同步帮助图片
|
||||||
|
"""
|
||||||
|
await run_in_thread_pool(plugin_manager.reload_plugin, module_name)
|
||||||
|
# 插件重载后,重新生成帮助图片
|
||||||
|
await matcher.sync_help_pic()
|
||||||
|
|
||||||
|
|
||||||
class PluginReloadHandler(FileSystemEventHandler):
|
class PluginReloadHandler(FileSystemEventHandler):
|
||||||
@@ -19,32 +101,35 @@ class PluginReloadHandler(FileSystemEventHandler):
|
|||||||
文件变更处理器,用于热重载插件
|
文件变更处理器,用于热重载插件
|
||||||
|
|
||||||
继承自 watchdog.events.FileSystemEventHandler,
|
继承自 watchdog.events.FileSystemEventHandler,
|
||||||
监听 base_plugins 目录下的文件变化,并触发插件重载。
|
监听 plugins 目录下的文件变化,并触发插件重载。
|
||||||
"""
|
"""
|
||||||
def __init__(self):
|
def __init__(self, loop: asyncio.AbstractEventLoop):
|
||||||
"""
|
"""
|
||||||
初始化处理器
|
初始化处理器
|
||||||
|
|
||||||
设置冷却时间,防止短时间内多次触发重载。
|
设置冷却时间,并保存主事件循环的引用。
|
||||||
"""
|
"""
|
||||||
|
self.loop = loop
|
||||||
self.last_reload_time = 0
|
self.last_reload_time = 0
|
||||||
self.cooldown = 1.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
|
return
|
||||||
|
|
||||||
|
src_path = file_system_event.src_path
|
||||||
|
|
||||||
# 只监控 py 文件
|
# 只监控 py 文件
|
||||||
if not event.src_path.endswith(".py"):
|
if not src_path.endswith(".py"):
|
||||||
return
|
return
|
||||||
|
|
||||||
# 过滤掉一些临时文件
|
# 过滤掉一些临时文件和__init__.py文件
|
||||||
if "__pycache__" in event.src_path:
|
if "__pycache__" in src_path or not src_path.startswith(PLUGIN_DIR) or os.path.basename(src_path) == "__init__.py":
|
||||||
return
|
return
|
||||||
|
|
||||||
# 简单的防抖动
|
# 简单的防抖动
|
||||||
@@ -54,17 +139,23 @@ class PluginReloadHandler(FileSystemEventHandler):
|
|||||||
|
|
||||||
self.last_reload_time = current_time
|
self.last_reload_time = current_time
|
||||||
|
|
||||||
print(f"\n[HotReload] 检测到文件变更: {event.src_path}")
|
# 从文件路径解析出模块名
|
||||||
print("[HotReload] 正在重载插件...")
|
# 例如: 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:
|
try:
|
||||||
# 重新扫描并加载插件
|
# 使用线程安全的方式在主事件循环中运行异步的插件重载函数
|
||||||
load_all_plugins()
|
asyncio.run_coroutine_threadsafe(run_in_thread_pool(plugin_manager.reload_plugin, module_name), self.loop)
|
||||||
print("[HotReload] 插件重载完成")
|
logger.success(f"插件 {module_name} 重载任务已提交")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[HotReload] 重载失败: {e}")
|
logger.exception(f"重载失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@logger.catch
|
||||||
async def main():
|
async def main():
|
||||||
"""
|
"""
|
||||||
主函数
|
主函数
|
||||||
@@ -73,26 +164,51 @@ async def main():
|
|||||||
2. 初始化 WebSocket 客户端
|
2. 初始化 WebSocket 客户端
|
||||||
3. 建立连接并保持运行
|
3. 建立连接并保持运行
|
||||||
"""
|
"""
|
||||||
# 首次加载插件
|
# 插件加载已移至 core.managers.__init__.py 中自动执行
|
||||||
load_all_plugins()
|
|
||||||
|
# 初始化 Redis 连接
|
||||||
|
await redis_manager.initialize()
|
||||||
|
|
||||||
|
# 同步帮助图片
|
||||||
|
await matcher.sync_help_pic()
|
||||||
|
|
||||||
|
# 初始化管理员管理器
|
||||||
|
await admin_manager.initialize()
|
||||||
|
|
||||||
|
# 初始化浏览器管理器 (使用页面池)
|
||||||
|
await browser_manager.init_pool(size=3)
|
||||||
|
|
||||||
# 启动文件监控
|
# 启动文件监控
|
||||||
# 监控 plugins 目录
|
# 监控 plugins 目录
|
||||||
plugin_path = os.path.join(os.path.dirname(__file__), "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()
|
observer = Observer()
|
||||||
|
|
||||||
if os.path.exists(plugin_path):
|
if os.path.exists(plugin_path):
|
||||||
observer.schedule(event_handler, plugin_path, recursive=True)
|
observer.schedule(event_handler, plugin_path, recursive=True)
|
||||||
observer.start()
|
observer.start()
|
||||||
print(f"[HotReload] 已启动插件热重载监控: {plugin_path}")
|
logger.info(f"已启动插件热重载监控: {plugin_path}")
|
||||||
else:
|
else:
|
||||||
print(f"[HotReload] 警告: 插件目录不存在 {plugin_path}")
|
logger.warning(f"插件目录不存在 {plugin_path}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bot = WS()
|
# 初始化代码执行器
|
||||||
await bot.connect()
|
code_executor = initialize_executor(config)
|
||||||
|
|
||||||
|
websocket_client = WS(code_executor=code_executor)
|
||||||
|
|
||||||
|
# 启动代码执行器的后台 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 websocket_client.connect()
|
||||||
finally:
|
finally:
|
||||||
if observer.is_alive():
|
if observer.is_alive():
|
||||||
observer.stop()
|
observer.stop()
|
||||||
@@ -100,4 +216,55 @@ async def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
"""
|
||||||
|
程序主入口,添加全局异常捕获和友好提示
|
||||||
|
"""
|
||||||
|
from core.utils.error_codes import exception_to_error_response
|
||||||
|
from core.utils.logger import ModuleLogger
|
||||||
|
|
||||||
|
# 创建主程序日志记录器
|
||||||
|
main_logger = ModuleLogger("Main")
|
||||||
|
|
||||||
|
try:
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
main_logger.info("程序已被用户中断")
|
||||||
|
exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
main_logger.exception("程序发生未处理的全局异常")
|
||||||
|
|
||||||
|
# 生成统一的错误响应
|
||||||
|
error_response = exception_to_error_response(e)
|
||||||
|
|
||||||
|
# 打印友好的错误提示
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("程序发生错误,请检查以下信息:")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"错误代码: {error_response['code']}")
|
||||||
|
print(f"错误信息: {error_response['message']}")
|
||||||
|
print("=" * 60)
|
||||||
|
print("详细错误信息已记录到日志文件中")
|
||||||
|
print("请检查 logs 目录下的日志文件以获取更多调试信息")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 根据错误类型给出不同的建议
|
||||||
|
if hasattr(e, "original_error") and e.original_error:
|
||||||
|
print(f"\n原始错误: {e.original_error}")
|
||||||
|
|
||||||
|
if "WebSocket" in str(type(e).__name__):
|
||||||
|
print("\n建议检查:")
|
||||||
|
print("1. WebSocket 服务是否正在运行")
|
||||||
|
print("2. 配置文件中的 WebSocket 地址和令牌是否正确")
|
||||||
|
print("3. 网络连接是否正常")
|
||||||
|
elif "Config" in str(type(e).__name__):
|
||||||
|
print("\n建议检查:")
|
||||||
|
print("1. 配置文件 config.toml 是否存在")
|
||||||
|
print("2. 配置文件格式是否正确")
|
||||||
|
print("3. 所有必填配置项是否都已设置")
|
||||||
|
elif "Plugin" in str(type(e).__name__):
|
||||||
|
print("\n建议检查:")
|
||||||
|
print("1. 插件目录是否存在")
|
||||||
|
print("2. 插件文件是否有语法错误")
|
||||||
|
print("3. 插件是否符合插件开发规范")
|
||||||
|
|
||||||
|
exit(1)
|
||||||
|
|||||||
@@ -1,97 +1,23 @@
|
|||||||
from .events.base import OneBotEvent
|
"""
|
||||||
from .events.factory import EventFactory
|
Models 包
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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__ = [
|
__all__ = [
|
||||||
|
"OneBotEvent",
|
||||||
|
"MessageEvent",
|
||||||
|
"GroupMessageEvent",
|
||||||
|
"PrivateMessageEvent",
|
||||||
|
"NoticeEvent",
|
||||||
|
"RequestEvent",
|
||||||
"MessageSegment",
|
"MessageSegment",
|
||||||
"Sender",
|
"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",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
基础事件模型模块
|
基础事件模型模块
|
||||||
|
|
||||||
定义了所有 OneBot 11 事件的基类和事件类型枚举。
|
该模块定义了所有 OneBot v11 事件模型的基类 `OneBotEvent` 和
|
||||||
|
事件类型常量 `EventType`。所有具体的事件模型都应继承自 `OneBotEvent`。
|
||||||
"""
|
"""
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional, Final
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -13,46 +14,60 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
class EventType:
|
class EventType:
|
||||||
"""
|
"""
|
||||||
事件类型枚举
|
OneBot v11 事件类型常量。
|
||||||
|
|
||||||
|
用于标识不同种类的事件上报。
|
||||||
"""
|
"""
|
||||||
META = 'meta_event' # 元事件
|
META: Final[str] = 'meta_event'
|
||||||
REQUEST = 'request' # 请求事件
|
"""元事件 (meta_event): 如心跳、生命周期等。"""
|
||||||
NOTICE = 'notice' # 通知事件
|
REQUEST: Final[str] = 'request'
|
||||||
MESSAGE = 'message' # 消息事件
|
"""请求事件 (request): 如加好友请求、加群请求等。"""
|
||||||
MESSAGE_SENT = 'message_sent' # 消息发送事件
|
NOTICE: Final[str] = 'notice'
|
||||||
|
"""通知事件 (notice): 如群成员增加、文件上传等。"""
|
||||||
|
MESSAGE: Final[str] = 'message'
|
||||||
|
"""消息事件 (message): 如私聊消息、群消息等。"""
|
||||||
|
MESSAGE_SENT: Final[str] = 'message_sent'
|
||||||
|
"""消息发送事件 (message_sent): 机器人自己发送消息的上报。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class OneBotEvent(ABC):
|
class OneBotEvent(ABC):
|
||||||
"""
|
"""
|
||||||
OneBot 事件基类
|
OneBot v11 事件的抽象基类。
|
||||||
所有具体的事件类型都应该继承自此类
|
|
||||||
|
所有具体的事件模型都必须继承此类,并实现 `post_type` 属性。
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
time (int): 事件发生的时间戳 (秒)。
|
||||||
|
self_id (int): 收到事件的机器人 QQ 号。
|
||||||
|
_bot (Optional[Bot]): 内部持有的 Bot 实例引用,用于快捷 API 调用。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
time: int
|
time: int
|
||||||
"""事件发生的时间戳"""
|
|
||||||
|
|
||||||
self_id: int
|
self_id: int
|
||||||
"""收到事件的机器人 QQ 号"""
|
|
||||||
|
|
||||||
_bot: Optional["Bot"] = field(default=None, init=False)
|
_bot: Optional["Bot"] = field(default=None, init=False)
|
||||||
"""Bot 实例引用,用于快捷调用 API"""
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def post_type(self) -> str:
|
def post_type(self) -> str:
|
||||||
"""
|
"""
|
||||||
上报类型
|
抽象属性,代表事件的上报类型。
|
||||||
|
|
||||||
|
子类必须重写此属性,并返回对应的 `EventType` 常量值。
|
||||||
|
例如: `return EventType.MESSAGE`
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bot(self) -> "Bot":
|
def bot(self) -> "Bot":
|
||||||
"""
|
"""
|
||||||
获取 Bot 实例
|
获取与此事件关联的 `Bot` 实例,以便快捷调用 API。
|
||||||
|
|
||||||
:return: Bot 实例
|
Returns:
|
||||||
:raises ValueError: 如果 Bot 实例未设置
|
Bot: 当前事件所对应的 `Bot` 实例。
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 如果 `Bot` 实例尚未被设置到事件对象中。
|
||||||
"""
|
"""
|
||||||
if self._bot is None:
|
if self._bot is None:
|
||||||
raise ValueError("Bot instance not set for this event")
|
raise ValueError("Bot instance not set for this event")
|
||||||
@@ -61,8 +76,10 @@ class OneBotEvent(ABC):
|
|||||||
@bot.setter
|
@bot.setter
|
||||||
def bot(self, value: "Bot"):
|
def bot(self, value: "Bot"):
|
||||||
"""
|
"""
|
||||||
设置 Bot 实例
|
为事件对象设置关联的 `Bot` 实例。
|
||||||
|
|
||||||
:param value: Bot 实例
|
Args:
|
||||||
|
value (Bot): 要设置的 `Bot` 实例。
|
||||||
"""
|
"""
|
||||||
self._bot = value
|
self._bot = value
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,11 @@ class EventFactory:
|
|||||||
# 解析消息段
|
# 解析消息段
|
||||||
message_list = []
|
message_list = []
|
||||||
raw_message_list = data.get("message", [])
|
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:
|
for item in raw_message_list:
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
message_list.append(MessageSegment(type=item.get("type", ""), data=item.get("data", {})))
|
message_list.append(MessageSegment(type=item.get("type", ""), data=item.get("data", {})))
|
||||||
@@ -254,7 +258,7 @@ class EventFactory:
|
|||||||
)
|
)
|
||||||
elif notice_type == "offline_file":
|
elif notice_type == "offline_file":
|
||||||
file_data = data.get("file", {})
|
file_data = data.get("file", {})
|
||||||
file = OfflineFile(
|
offline_file = OfflineFile(
|
||||||
name=file_data.get("name", ""),
|
name=file_data.get("name", ""),
|
||||||
size=file_data.get("size", 0),
|
size=file_data.get("size", 0),
|
||||||
url=file_data.get("url", "")
|
url=file_data.get("url", "")
|
||||||
@@ -263,7 +267,7 @@ class EventFactory:
|
|||||||
**common_args,
|
**common_args,
|
||||||
notice_type=notice_type,
|
notice_type=notice_type,
|
||||||
user_id=data.get("user_id", 0),
|
user_id=data.get("user_id", 0),
|
||||||
file=file
|
file=offline_file
|
||||||
)
|
)
|
||||||
elif notice_type == "client_status":
|
elif notice_type == "client_status":
|
||||||
client_data = data.get("client", {})
|
client_data = data.get("client", {})
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
定义了消息相关的事件类,包括 MessageEvent, PrivateMessageEvent, GroupMessageEvent。
|
定义了消息相关的事件类,包括 MessageEvent, PrivateMessageEvent, GroupMessageEvent。
|
||||||
"""
|
"""
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import List, Optional
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
from core.permission import Permission
|
||||||
from models.message import MessageSegment
|
from models.message import MessageSegment
|
||||||
from models.sender import Sender
|
from models.sender import Sender
|
||||||
from .base import OneBotEvent, EventType
|
from .base import OneBotEvent, EventType
|
||||||
@@ -26,7 +27,14 @@ class Anonymous:
|
|||||||
"""匿名用户 flag"""
|
"""匿名用户 flag"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
# 权限级别常量,用于装饰器参数
|
||||||
|
# 定义在类外部,避免 dataclass 参数顺序问题
|
||||||
|
MESSAGE_EVENT_ADMIN = Permission.ADMIN
|
||||||
|
MESSAGE_EVENT_OP = Permission.OP
|
||||||
|
MESSAGE_EVENT_USER = Permission.USER
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
class MessageEvent(OneBotEvent):
|
class MessageEvent(OneBotEvent):
|
||||||
"""
|
"""
|
||||||
消息事件基类
|
消息事件基类
|
||||||
@@ -64,7 +72,22 @@ class MessageEvent(OneBotEvent):
|
|||||||
def post_type(self) -> str:
|
def post_type(self) -> str:
|
||||||
return EventType.MESSAGE
|
return EventType.MESSAGE
|
||||||
|
|
||||||
async def reply(self, message: str, auto_escape: bool = False):
|
@property
|
||||||
|
def ADMIN(self) -> Permission:
|
||||||
|
"""权限级别常量,用于装饰器参数"""
|
||||||
|
return MESSAGE_EVENT_ADMIN
|
||||||
|
|
||||||
|
@property
|
||||||
|
def OP(self) -> Permission:
|
||||||
|
"""权限级别常量,用于装饰器参数"""
|
||||||
|
return MESSAGE_EVENT_OP
|
||||||
|
|
||||||
|
@property
|
||||||
|
def USER(self) -> Permission:
|
||||||
|
"""权限级别常量,用于装饰器参数"""
|
||||||
|
return MESSAGE_EVENT_USER
|
||||||
|
|
||||||
|
async def reply(self, message: Union[str, "MessageSegment", List["MessageSegment"]], auto_escape: bool = False):
|
||||||
"""
|
"""
|
||||||
回复消息(抽象方法,由子类实现)
|
回复消息(抽象方法,由子类实现)
|
||||||
|
|
||||||
@@ -74,13 +97,13 @@ class MessageEvent(OneBotEvent):
|
|||||||
raise NotImplementedError("reply method must be implemented by subclasses")
|
raise NotImplementedError("reply method must be implemented by subclasses")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class PrivateMessageEvent(MessageEvent):
|
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):
|
||||||
"""
|
"""
|
||||||
回复私聊消息
|
回复私聊消息
|
||||||
|
|
||||||
@@ -92,7 +115,7 @@ class PrivateMessageEvent(MessageEvent):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class GroupMessageEvent(MessageEvent):
|
class GroupMessageEvent(MessageEvent):
|
||||||
"""
|
"""
|
||||||
群聊消息事件
|
群聊消息事件
|
||||||
@@ -104,7 +127,7 @@ class GroupMessageEvent(MessageEvent):
|
|||||||
anonymous: Optional[Anonymous] = None
|
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):
|
||||||
"""
|
"""
|
||||||
回复群聊消息
|
回复群聊消息
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
定义了元事件相关的事件类,包括心跳事件和生命周期事件。
|
定义了元事件相关的事件类,包括心跳事件和生命周期事件。
|
||||||
"""
|
"""
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional, Final
|
||||||
from .base import OneBotEvent, EventType
|
from .base import OneBotEvent, EventType
|
||||||
|
|
||||||
|
|
||||||
@@ -21,12 +21,12 @@ class LifeCycleSubType:
|
|||||||
"""
|
"""
|
||||||
生命周期子类型枚举
|
生命周期子类型枚举
|
||||||
"""
|
"""
|
||||||
ENABLE = 'enable' # 启用
|
ENABLE: Final[str] = 'enable' # 启用
|
||||||
DISABLE = 'disable' # 禁用
|
DISABLE: Final[str] = 'disable' # 禁用
|
||||||
CONNECT = 'connect' # 连接
|
CONNECT: Final[str] = 'connect' # 连接
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class MetaEvent(OneBotEvent):
|
class MetaEvent(OneBotEvent):
|
||||||
"""
|
"""
|
||||||
元事件基类
|
元事件基类
|
||||||
@@ -40,7 +40,7 @@ class MetaEvent(OneBotEvent):
|
|||||||
return EventType.META
|
return EventType.META
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class HeartbeatEvent(MetaEvent):
|
class HeartbeatEvent(MetaEvent):
|
||||||
"""
|
"""
|
||||||
心跳事件,用于确认连接状态
|
心跳事件,用于确认连接状态
|
||||||
@@ -55,7 +55,7 @@ class HeartbeatEvent(MetaEvent):
|
|||||||
"""心跳间隔时间(ms)"""
|
"""心跳间隔时间(ms)"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class LifeCycleEvent(MetaEvent):
|
class LifeCycleEvent(MetaEvent):
|
||||||
"""
|
"""
|
||||||
生命周期事件,用于通知框架生命周期变化
|
生命周期事件,用于通知框架生命周期变化
|
||||||
@@ -63,5 +63,5 @@ class LifeCycleEvent(MetaEvent):
|
|||||||
meta_event_type: str = 'lifecycle'
|
meta_event_type: str = 'lifecycle'
|
||||||
"""元事件类型:生命周期事件"""
|
"""元事件类型:生命周期事件"""
|
||||||
|
|
||||||
sub_type: LifeCycleSubType = LifeCycleSubType.ENABLE
|
sub_type: str = LifeCycleSubType.ENABLE
|
||||||
"""子类型:启用、禁用、连接"""
|
"""子类型:启用、禁用、连接"""
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class NoticeEvent(OneBotEvent):
|
|||||||
return EventType.NOTICE
|
return EventType.NOTICE
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class FriendAddNoticeEvent(NoticeEvent):
|
class FriendAddNoticeEvent(NoticeEvent):
|
||||||
"""
|
"""
|
||||||
好友添加通知
|
好友添加通知
|
||||||
@@ -30,7 +30,7 @@ class FriendAddNoticeEvent(NoticeEvent):
|
|||||||
"""新好友 QQ 号"""
|
"""新好友 QQ 号"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class FriendRecallNoticeEvent(NoticeEvent):
|
class FriendRecallNoticeEvent(NoticeEvent):
|
||||||
"""
|
"""
|
||||||
好友消息撤回通知
|
好友消息撤回通知
|
||||||
@@ -42,7 +42,7 @@ class FriendRecallNoticeEvent(NoticeEvent):
|
|||||||
"""被撤回的消息 ID"""
|
"""被撤回的消息 ID"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class GroupNoticeEvent(NoticeEvent):
|
class GroupNoticeEvent(NoticeEvent):
|
||||||
"""
|
"""
|
||||||
群组通知事件基类
|
群组通知事件基类
|
||||||
@@ -54,7 +54,7 @@ class GroupNoticeEvent(NoticeEvent):
|
|||||||
"""用户 QQ 号"""
|
"""用户 QQ 号"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class GroupRecallNoticeEvent(GroupNoticeEvent):
|
class GroupRecallNoticeEvent(GroupNoticeEvent):
|
||||||
"""
|
"""
|
||||||
群消息撤回通知
|
群消息撤回通知
|
||||||
@@ -66,7 +66,7 @@ class GroupRecallNoticeEvent(GroupNoticeEvent):
|
|||||||
"""被撤回的消息 ID"""
|
"""被撤回的消息 ID"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class GroupIncreaseNoticeEvent(GroupNoticeEvent):
|
class GroupIncreaseNoticeEvent(GroupNoticeEvent):
|
||||||
"""
|
"""
|
||||||
群成员增加通知
|
群成员增加通知
|
||||||
@@ -82,7 +82,7 @@ class GroupIncreaseNoticeEvent(GroupNoticeEvent):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class GroupDecreaseNoticeEvent(GroupNoticeEvent):
|
class GroupDecreaseNoticeEvent(GroupNoticeEvent):
|
||||||
"""
|
"""
|
||||||
群成员减少通知
|
群成员减少通知
|
||||||
@@ -100,7 +100,7 @@ class GroupDecreaseNoticeEvent(GroupNoticeEvent):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class GroupAdminNoticeEvent(GroupNoticeEvent):
|
class GroupAdminNoticeEvent(GroupNoticeEvent):
|
||||||
"""
|
"""
|
||||||
群管理员变动通知
|
群管理员变动通知
|
||||||
@@ -113,7 +113,7 @@ class GroupAdminNoticeEvent(GroupNoticeEvent):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class GroupBanNoticeEvent(GroupNoticeEvent):
|
class GroupBanNoticeEvent(GroupNoticeEvent):
|
||||||
"""
|
"""
|
||||||
群禁言通知
|
群禁言通知
|
||||||
@@ -132,7 +132,7 @@ class GroupBanNoticeEvent(GroupNoticeEvent):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class GroupUploadFile:
|
class GroupUploadFile:
|
||||||
"""
|
"""
|
||||||
群文件信息
|
群文件信息
|
||||||
@@ -150,7 +150,7 @@ class GroupUploadFile:
|
|||||||
"""文件总线 ID"""
|
"""文件总线 ID"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class GroupUploadNoticeEvent(GroupNoticeEvent):
|
class GroupUploadNoticeEvent(GroupNoticeEvent):
|
||||||
"""
|
"""
|
||||||
群文件上传通知
|
群文件上传通知
|
||||||
@@ -159,7 +159,7 @@ class GroupUploadNoticeEvent(GroupNoticeEvent):
|
|||||||
"""文件信息"""
|
"""文件信息"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class NotifyNoticeEvent(NoticeEvent):
|
class NotifyNoticeEvent(NoticeEvent):
|
||||||
"""
|
"""
|
||||||
系统通知事件基类 (notify)
|
系统通知事件基类 (notify)
|
||||||
@@ -175,7 +175,7 @@ class NotifyNoticeEvent(NoticeEvent):
|
|||||||
"""发送者 QQ 号"""
|
"""发送者 QQ 号"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class PokeNotifyEvent(NotifyNoticeEvent):
|
class PokeNotifyEvent(NotifyNoticeEvent):
|
||||||
"""
|
"""
|
||||||
戳一戳通知
|
戳一戳通知
|
||||||
@@ -187,7 +187,7 @@ class PokeNotifyEvent(NotifyNoticeEvent):
|
|||||||
"""群号 (如果是群内戳一戳)"""
|
"""群号 (如果是群内戳一戳)"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class LuckyKingNotifyEvent(NotifyNoticeEvent):
|
class LuckyKingNotifyEvent(NotifyNoticeEvent):
|
||||||
"""
|
"""
|
||||||
群红包运气王通知
|
群红包运气王通知
|
||||||
@@ -199,7 +199,7 @@ class LuckyKingNotifyEvent(NotifyNoticeEvent):
|
|||||||
"""运气王 QQ 号"""
|
"""运气王 QQ 号"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class HonorNotifyEvent(NotifyNoticeEvent):
|
class HonorNotifyEvent(NotifyNoticeEvent):
|
||||||
"""
|
"""
|
||||||
群荣誉变更通知
|
群荣誉变更通知
|
||||||
@@ -216,7 +216,7 @@ class HonorNotifyEvent(NotifyNoticeEvent):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class GroupCardNoticeEvent(GroupNoticeEvent):
|
class GroupCardNoticeEvent(GroupNoticeEvent):
|
||||||
"""
|
"""
|
||||||
群成员名片更新通知
|
群成员名片更新通知
|
||||||
@@ -228,7 +228,7 @@ class GroupCardNoticeEvent(GroupNoticeEvent):
|
|||||||
"""旧名片"""
|
"""旧名片"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class OfflineFile:
|
class OfflineFile:
|
||||||
"""
|
"""
|
||||||
离线文件信息
|
离线文件信息
|
||||||
@@ -243,7 +243,7 @@ class OfflineFile:
|
|||||||
"""下载链接"""
|
"""下载链接"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class OfflineFileNoticeEvent(NoticeEvent):
|
class OfflineFileNoticeEvent(NoticeEvent):
|
||||||
"""
|
"""
|
||||||
接收离线文件通知
|
接收离线文件通知
|
||||||
@@ -255,7 +255,7 @@ class OfflineFileNoticeEvent(NoticeEvent):
|
|||||||
"""文件数据"""
|
"""文件数据"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class ClientStatus:
|
class ClientStatus:
|
||||||
"""
|
"""
|
||||||
客户端状态
|
客户端状态
|
||||||
@@ -267,7 +267,7 @@ class ClientStatus:
|
|||||||
"""状态描述"""
|
"""状态描述"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class ClientStatusNoticeEvent(NoticeEvent):
|
class ClientStatusNoticeEvent(NoticeEvent):
|
||||||
"""
|
"""
|
||||||
其他客户端在线状态变更通知
|
其他客户端在线状态变更通知
|
||||||
@@ -276,7 +276,7 @@ class ClientStatusNoticeEvent(NoticeEvent):
|
|||||||
"""客户端信息"""
|
"""客户端信息"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class EssenceNoticeEvent(GroupNoticeEvent):
|
class EssenceNoticeEvent(GroupNoticeEvent):
|
||||||
"""
|
"""
|
||||||
精华消息变动通知
|
精华消息变动通知
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class RequestEvent(OneBotEvent):
|
|||||||
return EventType.REQUEST
|
return EventType.REQUEST
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class FriendRequestEvent(RequestEvent):
|
class FriendRequestEvent(RequestEvent):
|
||||||
"""
|
"""
|
||||||
加好友请求事件
|
加好友请求事件
|
||||||
@@ -36,7 +36,7 @@ class FriendRequestEvent(RequestEvent):
|
|||||||
"""请求 flag,在调用处理请求的 API 时需要传入此 flag"""
|
"""请求 flag,在调用处理请求的 API 时需要传入此 flag"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class GroupRequestEvent(RequestEvent):
|
class GroupRequestEvent(RequestEvent):
|
||||||
"""
|
"""
|
||||||
加群请求/邀请事件
|
加群请求/邀请事件
|
||||||
|
|||||||
@@ -1,49 +1,104 @@
|
|||||||
"""
|
"""
|
||||||
消息段模型模块
|
消息段模型模块
|
||||||
|
|
||||||
定义了 MessageSegment 类,用于封装 OneBot 11 的消息段。
|
该模块定义了 `MessageSegment` 类,用于构建和表示 OneBot v11 协议中的消息段。
|
||||||
|
通过此类,可以方便地创建文本、图片、At 等不同类型的消息内容,并支持链式操作。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, Optional, List
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class MessageSegment:
|
class MessageSegment:
|
||||||
"""
|
"""
|
||||||
消息段,对应 OneBot 11 标准中的消息段对象
|
表示一个 OneBot v11 消息段。
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
type (str): 消息段的类型,例如 'text', 'image', 'at'。
|
||||||
|
data (Dict[str, Any]): 消息段的具体数据,是一个键值对字典。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
type: str
|
type: str
|
||||||
"""消息段类型,如 text, image, at 等"""
|
|
||||||
|
|
||||||
data: Dict[str, Any]
|
data: Dict[str, Any]
|
||||||
"""消息段数据"""
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def text(self) -> str:
|
def plain_text(self) -> str:
|
||||||
"""
|
"""
|
||||||
获取文本内容(仅当 type 为 text 时有效)
|
当消息段类型为 'text' 时,快速获取其文本内容。
|
||||||
|
|
||||||
:return: 文本内容
|
Returns:
|
||||||
|
str: 消息段的文本内容。如果类型不是 'text',则返回空字符串。
|
||||||
"""
|
"""
|
||||||
return self.data.get("text", "") if self.type == "text" else ""
|
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
|
@property
|
||||||
def image_url(self) -> str:
|
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 ""
|
return self.data.get("url", "") if self.type == "image" else ""
|
||||||
|
|
||||||
def is_at(self, user_id: int = None) -> bool:
|
@property
|
||||||
|
def share_url(self) -> str:
|
||||||
"""
|
"""
|
||||||
判断是否为 @某人
|
当消息段类型为 'share' 时,快速获取其分享 URL。
|
||||||
|
|
||||||
:param user_id: 指定的 QQ 号,如果为 None 则只判断是否为 at 类型
|
Returns:
|
||||||
:return: 是否匹配
|
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: Optional[int] = None) -> bool:
|
||||||
|
"""
|
||||||
|
检查当前消息段是否是一个 'at' (提及) 消息段。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id (int, optional): 如果提供,则进一步检查被提及的 QQ 号是否匹配。
|
||||||
|
Defaults to None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 如果消息段是 'at' 类型且 user_id 匹配 (如果提供),则返回 True。
|
||||||
"""
|
"""
|
||||||
if self.type != "at":
|
if self.type != "at":
|
||||||
return False
|
return False
|
||||||
@@ -51,47 +106,332 @@ class MessageSegment:
|
|||||||
return True
|
return True
|
||||||
return str(self.data.get("qq")) == str(user_id)
|
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):
|
def __repr__(self):
|
||||||
|
"""
|
||||||
|
返回消息段对象的字符串表示形式,便于调试。
|
||||||
|
"""
|
||||||
return f"[MS:{self.type}:{self.data}]"
|
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
|
@staticmethod
|
||||||
def text(text: str) -> "MessageSegment": # noqa: F811
|
def from_text(text: str) -> "MessageSegment":
|
||||||
"""
|
"""
|
||||||
构造文本消息段
|
创建一个文本消息段。
|
||||||
|
|
||||||
:param text: 文本内容
|
Args:
|
||||||
:return: MessageSegment 对象
|
text (str): 文本内容。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MessageSegment: 一个类型为 'text' 的消息段对象。
|
||||||
"""
|
"""
|
||||||
return MessageSegment(type="text", data={"text": text})
|
return MessageSegment(type="text", data={"text": text})
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def at(user_id: int | str) -> "MessageSegment":
|
def at(user_id: int | str, name: Optional[str] = None) -> "MessageSegment":
|
||||||
"""
|
"""
|
||||||
构造 @某人 消息段
|
创建一个 @某人 的消息段。
|
||||||
|
|
||||||
:param user_id: 目标 QQ 号,"all" 表示 @全体成员
|
Args:
|
||||||
:return: MessageSegment 对象
|
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
|
@staticmethod
|
||||||
def image(file: str) -> "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":
|
||||||
"""
|
"""
|
||||||
构造图片消息段
|
创建一个图片消息段。
|
||||||
|
|
||||||
:param file: 图片文件名、URL 或 Base64
|
Args:
|
||||||
:return: MessageSegment 对象
|
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
|
@staticmethod
|
||||||
def face(id: int) -> "MessageSegment":
|
def face(id: int) -> "MessageSegment":
|
||||||
"""
|
"""
|
||||||
构造表情消息段
|
创建一个 QQ 表情消息段。
|
||||||
|
|
||||||
:param id: 表情 ID
|
Args:
|
||||||
:return: MessageSegment 对象
|
id (int): QQ 表情的 ID。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MessageSegment: 一个类型为 'face' 的消息段对象。
|
||||||
"""
|
"""
|
||||||
return MessageSegment(type="face", data={"id": str(id)})
|
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: Optional[str] = None, image: Optional[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: Optional[str] = None, image: Optional[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: Optional[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: Optional[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 | int) -> "MessageSegment":
|
||||||
|
"""
|
||||||
|
创建一个回复消息段。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id (str | int): 被回复的消息 ID。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MessageSegment: 一个类型为 'reply' 的消息段对象。
|
||||||
|
"""
|
||||||
|
return MessageSegment(type="reply", data={"id": str(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)
|
||||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass, field
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class GroupInfo:
|
class GroupInfo:
|
||||||
"""
|
"""
|
||||||
群信息
|
群信息
|
||||||
@@ -24,8 +24,14 @@ class GroupInfo:
|
|||||||
max_member_count: int = 0
|
max_member_count: int = 0
|
||||||
"""最大成员数"""
|
"""最大成员数"""
|
||||||
|
|
||||||
|
group_remark: str = ""
|
||||||
|
"""群备注"""
|
||||||
|
|
||||||
@dataclass
|
group_all_shut: int = 0
|
||||||
|
"""是否全员禁言"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
class GroupMemberInfo:
|
class GroupMemberInfo:
|
||||||
"""
|
"""
|
||||||
群成员信息
|
群成员信息
|
||||||
@@ -76,7 +82,7 @@ class GroupMemberInfo:
|
|||||||
"""是否允许修改群名片"""
|
"""是否允许修改群名片"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class FriendInfo:
|
class FriendInfo:
|
||||||
"""
|
"""
|
||||||
好友信息
|
好友信息
|
||||||
@@ -91,7 +97,7 @@ class FriendInfo:
|
|||||||
"""备注"""
|
"""备注"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class StrangerInfo:
|
class StrangerInfo:
|
||||||
"""
|
"""
|
||||||
陌生人信息
|
陌生人信息
|
||||||
@@ -109,7 +115,7 @@ class StrangerInfo:
|
|||||||
"""年龄"""
|
"""年龄"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class LoginInfo:
|
class LoginInfo:
|
||||||
"""
|
"""
|
||||||
登录号信息
|
登录号信息
|
||||||
@@ -121,7 +127,7 @@ class LoginInfo:
|
|||||||
"""昵称"""
|
"""昵称"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class VersionInfo:
|
class VersionInfo:
|
||||||
"""
|
"""
|
||||||
版本信息
|
版本信息
|
||||||
@@ -136,7 +142,7 @@ class VersionInfo:
|
|||||||
"""OneBot 标准版本"""
|
"""OneBot 标准版本"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class Status:
|
class Status:
|
||||||
"""
|
"""
|
||||||
运行状态
|
运行状态
|
||||||
@@ -148,7 +154,7 @@ class Status:
|
|||||||
"""运行状态是否良好"""
|
"""运行状态是否良好"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class EssenceMessage:
|
class EssenceMessage:
|
||||||
"""
|
"""
|
||||||
精华消息
|
精华消息
|
||||||
@@ -175,7 +181,7 @@ class EssenceMessage:
|
|||||||
"""消息 ID"""
|
"""消息 ID"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class CurrentTalkative:
|
class CurrentTalkative:
|
||||||
"""
|
"""
|
||||||
龙王信息
|
龙王信息
|
||||||
@@ -193,7 +199,7 @@ class CurrentTalkative:
|
|||||||
"""持续天数"""
|
"""持续天数"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class HonorInfo:
|
class HonorInfo:
|
||||||
"""
|
"""
|
||||||
荣誉信息
|
荣誉信息
|
||||||
@@ -211,7 +217,7 @@ class HonorInfo:
|
|||||||
"""荣誉描述"""
|
"""荣誉描述"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class GroupHonorInfo:
|
class GroupHonorInfo:
|
||||||
"""
|
"""
|
||||||
群荣誉信息
|
群荣誉信息
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(slots=True)
|
||||||
class Sender:
|
class Sender:
|
||||||
"""
|
"""
|
||||||
发送者信息类,对应 OneBot 11 标准中的 sender 字段
|
发送者信息类,对应 OneBot 11 标准中的 sender 字段
|
||||||
|
|||||||
76
performance_config_example.py
Normal file
76
performance_config_example.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
性能分析配置示例
|
||||||
|
|
||||||
|
展示如何在项目中配置和使用性能分析功能。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 配置性能分析的使用方式
|
||||||
|
PERFORMANCE_CONFIG = {
|
||||||
|
# 全局性能分析开关
|
||||||
|
'enabled': True,
|
||||||
|
|
||||||
|
# 详细性能分析开关(使用 pyinstrument)
|
||||||
|
'detailed': False,
|
||||||
|
|
||||||
|
# 内存分析开关
|
||||||
|
'memory': False,
|
||||||
|
|
||||||
|
# 性能监控阈值(秒)
|
||||||
|
'threshold': 0.5,
|
||||||
|
|
||||||
|
# 性能报告输出文件
|
||||||
|
'output_file': 'performance_report.html',
|
||||||
|
|
||||||
|
# 要监控的核心组件列表
|
||||||
|
'monitored_components': [
|
||||||
|
'core.ws.WS',
|
||||||
|
'core.managers.plugin_manager',
|
||||||
|
'core.managers.browser_manager',
|
||||||
|
'core.utils.executor.CodeExecutor',
|
||||||
|
'core.handlers.event_handler',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_performance_config():
|
||||||
|
"""
|
||||||
|
获取性能分析配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 性能分析配置
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 从环境变量加载配置
|
||||||
|
config = PERFORMANCE_CONFIG.copy()
|
||||||
|
|
||||||
|
if os.environ.get('PERFORMANCE_PROFILE'):
|
||||||
|
config['detailed'] = os.environ['PERFORMANCE_PROFILE'] == '1'
|
||||||
|
|
||||||
|
if os.environ.get('PERFORMANCE_MEMORY'):
|
||||||
|
config['memory'] = os.environ['PERFORMANCE_MEMORY'] == '1'
|
||||||
|
|
||||||
|
if os.environ.get('PERFORMANCE_THRESHOLD'):
|
||||||
|
try:
|
||||||
|
config['threshold'] = float(os.environ['PERFORMANCE_THRESHOLD'])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if os.environ.get('PERFORMANCE_OUTPUT'):
|
||||||
|
config['output_file'] = os.environ['PERFORMANCE_OUTPUT']
|
||||||
|
|
||||||
|
if os.environ.get('PERFORMANCE_STATS'):
|
||||||
|
config['enabled'] = os.environ['PERFORMANCE_STATS'] == '1'
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 打印当前配置
|
||||||
|
print("当前性能分析配置:")
|
||||||
|
print("=" * 50)
|
||||||
|
config = get_performance_config()
|
||||||
|
for key, value in config.items():
|
||||||
|
print(f"{key}: {value}")
|
||||||
0
plugins/__init__.py
Normal file
0
plugins/__init__.py
Normal file
168
plugins/admin.py
168
plugins/admin.py
@@ -1,115 +1,93 @@
|
|||||||
from core import PluginDataManager
|
from core.managers import command_manager, permission_manager
|
||||||
from core.bot import Bot
|
from core.permission import Permission
|
||||||
from core.command_manager import matcher
|
from models.events.message import MessageEvent
|
||||||
from models import GroupMessageEvent
|
|
||||||
|
|
||||||
|
# 更新插件元信息以包含OP管理
|
||||||
__plugin_meta__ = {
|
__plugin_meta__ = {
|
||||||
"name": "admin",
|
"name": "权限管理",
|
||||||
"description": "机器人权限管理插件",
|
"description": "管理机器人的管理员和操作员",
|
||||||
"usage": "/admin",
|
"usage": (
|
||||||
|
"/admin list - 列出所有管理员和操作员\n"
|
||||||
|
"/admin add_admin <QQ号> - 添加管理员\n"
|
||||||
|
"/admin remove_admin <QQ号> - 移除管理员\n"
|
||||||
|
"/admin add_op <QQ号> - 添加操作员\n"
|
||||||
|
"/admin remove_op <QQ号> - 移除操作员"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
data = PluginDataManager("admin")
|
|
||||||
|
|
||||||
|
@command_manager.command("admin", permission=Permission.ADMIN)
|
||||||
@matcher.command("admin")
|
async def admin_management(event: MessageEvent, args: list[str]):
|
||||||
async def handle_permission(bot: Bot, event: GroupMessageEvent, args: list[str]):
|
"""
|
||||||
if not args:
|
处理所有权限管理相关的命令。
|
||||||
await event.reply(
|
"""
|
||||||
"机器人权限管理插件指令:\n/admin list 列出所有权限\n/admin add member <QQ号> 添加群成员权限\n/admin remove member <QQ号> 删除群成员权限\n/admin add group <群号> 添加群权限\n/admin remove group <群号> 删除群权限\n/admin clear member 清空群成员权限\n/admin clear group 清空群权限\n/admin clear all 清空所有权限"
|
parts = args
|
||||||
)
|
if not parts:
|
||||||
|
await event.reply(f"用法不正确。\n\n{__plugin_meta__['usage']}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if str(event.user_id) not in data.get("members", []):
|
subcommand = parts[0].lower()
|
||||||
await event.reply("你没有权限使用此命令。")
|
|
||||||
return
|
if subcommand == "list":
|
||||||
if str(event.group_id) not in data.get("groups", []):
|
await list_permissions(event)
|
||||||
await event.reply("群聊不在权限中")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
action = args[0].lower()
|
# 处理需要QQ号的命令
|
||||||
|
if len(parts) < 2 or not parts[1].isdigit():
|
||||||
# ensure storage keys exist
|
await event.reply(f"请提供有效的用户QQ号。\n用法: /admin {subcommand} <QQ号>")
|
||||||
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))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if action in ("add", "remove"):
|
try:
|
||||||
if len(args) < 3:
|
target_user_id = int(parts[1])
|
||||||
await event.reply("参数错误,示例:/admin add member 123456")
|
except ValueError:
|
||||||
|
await event.reply("无效的QQ号。")
|
||||||
return
|
return
|
||||||
|
|
||||||
target = args[1].lower()
|
# 安全检查
|
||||||
value = args[2]
|
if target_user_id == event.user_id:
|
||||||
|
await event.reply("你不能操作自己的权限。")
|
||||||
if target == "member":
|
|
||||||
# operate on members list
|
|
||||||
if action == "add":
|
|
||||||
if str(value) in members:
|
|
||||||
await event.reply(f"成员 {value} 已存在,无需重复添加。")
|
|
||||||
return
|
return
|
||||||
members.append(str(value))
|
if target_user_id == event.self_id:
|
||||||
data.set("members", members)
|
await event.reply("你不能操作机器人自身的权限。")
|
||||||
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
|
return
|
||||||
|
|
||||||
if target == "group":
|
# 根据子命令分发
|
||||||
if action == "add":
|
if subcommand == "add_admin":
|
||||||
if str(value) in groups:
|
permission_manager.set_user_permission(target_user_id, Permission.ADMIN)
|
||||||
await event.reply(f"群 {value} 已存在,无需重复添加。")
|
await event.reply(f"已成功添加管理员:{target_user_id}")
|
||||||
return
|
elif subcommand == "remove_admin":
|
||||||
groups.append(str(value))
|
permission_manager.set_user_permission(target_user_id, Permission.USER)
|
||||||
data.set("groups", groups)
|
await event.reply(f"已成功移除管理员:{target_user_id}")
|
||||||
await event.reply(f"已添加群权限:{value}")
|
elif subcommand == "add_op":
|
||||||
return
|
permission_manager.set_user_permission(target_user_id, Permission.OP)
|
||||||
else: # remove
|
await event.reply(f"已成功添加操作员:{target_user_id}")
|
||||||
if str(value) not in groups:
|
elif subcommand == "remove_op":
|
||||||
await event.reply(f"群 {value} 不在权限列表中。")
|
permission_manager.set_user_permission(target_user_id, Permission.USER)
|
||||||
return
|
await event.reply(f"已成功移除操作员:{target_user_id}")
|
||||||
groups = [g for g in groups if g != str(value)]
|
else:
|
||||||
data.set("groups", groups)
|
await event.reply(f"未知的子命令 '{subcommand}'。\n\n{__plugin_meta__['usage']}")
|
||||||
await event.reply(f"已移除群权限:{value}")
|
|
||||||
|
|
||||||
|
async def list_permissions(event: MessageEvent):
|
||||||
|
"""
|
||||||
|
列出所有具有特殊权限(管理员和操作员)的用户。
|
||||||
|
"""
|
||||||
|
permissions = await permission_manager.get_all_user_permissions()
|
||||||
|
if not permissions:
|
||||||
|
await event.reply("当前没有配置任何特殊权限的用户。")
|
||||||
return
|
return
|
||||||
|
|
||||||
await event.reply("未知目标类型,请使用 member 或 group")
|
admins = {uid for uid, p in permissions.items() if p == 'admin'}
|
||||||
return
|
ops = {uid for uid, p in permissions.items() if p == 'op'}
|
||||||
|
|
||||||
if action == "clear":
|
reply_msg = "当前权限列表:\n"
|
||||||
if len(args) < 2:
|
if admins:
|
||||||
await event.reply("参数错误,示例:/admin clear member")
|
reply_msg += "--- 管理员 ---\n"
|
||||||
return
|
for user_id in admins:
|
||||||
target = args[1].lower()
|
reply_msg += f"- {user_id}\n"
|
||||||
if target == "member":
|
if ops:
|
||||||
data.set("members", [])
|
reply_msg += "--- 操作员 ---\n"
|
||||||
await event.reply("已清空群成员权限。")
|
for user_id in ops:
|
||||||
return
|
reply_msg += f"- {user_id}\n"
|
||||||
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 查看帮助")
|
await event.reply(reply_msg.strip())
|
||||||
|
|||||||
53
plugins/auto_approve.py
Normal file
53
plugins/auto_approve.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""
|
||||||
|
自动同意请求插件
|
||||||
|
|
||||||
|
提供自动同意好友请求和群聊邀请的功能。
|
||||||
|
"""
|
||||||
|
from core.managers.command_manager import matcher
|
||||||
|
from core.bot import Bot
|
||||||
|
from models.events.request import FriendRequestEvent, GroupRequestEvent
|
||||||
|
|
||||||
|
__plugin_meta__ = {
|
||||||
|
"name": "自动同意请求",
|
||||||
|
"description": "自动同意好友请求和群聊邀请",
|
||||||
|
"usage": "无需手动操作,自动处理请求事件",
|
||||||
|
}
|
||||||
|
|
||||||
|
@matcher.on_request(request_type="friend")
|
||||||
|
async def handle_friend_request(bot: Bot, event: FriendRequestEvent):
|
||||||
|
"""
|
||||||
|
处理好友请求事件,自动同意好友申请
|
||||||
|
|
||||||
|
:param bot: Bot实例
|
||||||
|
:param event: 好友请求事件对象
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 自动同意好友请求
|
||||||
|
await bot.call_api(
|
||||||
|
"set_friend_add_request",
|
||||||
|
flag=event.flag,
|
||||||
|
approve=True
|
||||||
|
)
|
||||||
|
print(f"[自动同意] 已同意用户 {event.user_id} 的好友请求")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[自动同意] 同意好友请求失败: {e}")
|
||||||
|
|
||||||
|
@matcher.on_request(request_type="group")
|
||||||
|
async def handle_group_request(bot: Bot, event: GroupRequestEvent):
|
||||||
|
"""
|
||||||
|
处理群聊邀请事件,自动同意群聊邀请
|
||||||
|
|
||||||
|
:param bot: Bot实例
|
||||||
|
:param event: 群聊邀请事件对象
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 自动同意群聊邀请
|
||||||
|
await bot.call_api(
|
||||||
|
"set_group_add_request",
|
||||||
|
flag=event.flag,
|
||||||
|
sub_type=event.sub_type,
|
||||||
|
approve=True
|
||||||
|
)
|
||||||
|
print(f"[自动同意] 已同意加入群聊 {event.group_id} (邀请人: {event.user_id})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[自动同意] 同意群聊邀请失败: {e}")
|
||||||
340
plugins/bili_parser.py
Normal file
340
plugins/bili_parser.py
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import aiohttp
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
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站分享卡片,提取视频封面和播放量等信息。",
|
||||||
|
"usage": "(自动触发)当检测到B站小程序分享卡片时,自动发送视频信息。",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 常量定义
|
||||||
|
BILI_NICKNAME = "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'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 全局共享的 ClientSession
|
||||||
|
_session: Optional[aiohttp.ClientSession] = None
|
||||||
|
|
||||||
|
def get_session() -> aiohttp.ClientSession:
|
||||||
|
global _session
|
||||||
|
if _session is None or _session.closed:
|
||||||
|
_session = aiohttp.ClientSession(headers=HEADERS)
|
||||||
|
return _session
|
||||||
|
|
||||||
|
|
||||||
|
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 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}"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_real_url(short_url: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
session = get_session()
|
||||||
|
async with session.head(short_url, headers=HEADERS, allow_redirects=False, timeout=5) as response:
|
||||||
|
if response.status == 302:
|
||||||
|
return response.headers.get('Location')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取真实URL失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def parse_video_info(video_url: str) -> Optional[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
# 清理URL,去掉不必要的查询参数,只保留基本的视频URL
|
||||||
|
clean_url = video_url.split('?')[0]
|
||||||
|
if '#/' in clean_url:
|
||||||
|
clean_url = clean_url.split('#/')[0]
|
||||||
|
|
||||||
|
session = get_session()
|
||||||
|
async with session.get(clean_url, headers=HEADERS, timeout=5) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
text = await response.text()
|
||||||
|
soup = BeautifulSoup(text, 'html.parser')
|
||||||
|
|
||||||
|
# 尝试多种方式获取视频数据
|
||||||
|
# 方式1: 尝试获取 __INITIAL_STATE__
|
||||||
|
script_tag = soup.find('script', text=re.compile('window.__INITIAL_STATE__'))
|
||||||
|
if not script_tag or not script_tag.string:
|
||||||
|
# 方式2: 尝试获取 __PLAYINFO__
|
||||||
|
script_tag = soup.find('script', text=re.compile('window.__PLAYINFO__'))
|
||||||
|
|
||||||
|
if not script_tag or not script_tag.string:
|
||||||
|
# 方式3: 尝试获取页面标题和其他信息
|
||||||
|
title_tag = soup.find('title')
|
||||||
|
if title_tag:
|
||||||
|
title = title_tag.get_text().strip()
|
||||||
|
# 提取BV号
|
||||||
|
bv_match = re.search(r'(BV\w{10})', clean_url)
|
||||||
|
bvid = bv_match.group(1) if bv_match else '未知BV号'
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": title.replace('_哔哩哔哩_bilibili', '').strip(),
|
||||||
|
"bvid": bvid,
|
||||||
|
"duration": 0,
|
||||||
|
"cover_url": '',
|
||||||
|
"play": 0,
|
||||||
|
"like": 0,
|
||||||
|
"coin": 0,
|
||||||
|
"favorite": 0,
|
||||||
|
"share": 0,
|
||||||
|
"owner_name": '未知UP主',
|
||||||
|
"owner_avatar": '',
|
||||||
|
"followers": 0,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 原始解析逻辑
|
||||||
|
match = re.search(r'window\.__INITIAL_STATE__\s*=\s*(\{[^}]*\});', script_tag.string)
|
||||||
|
if not match:
|
||||||
|
# 尝试另一种正则表达式
|
||||||
|
match = re.search(r'window\.__INITIAL_STATE__\s*=\s*(\{.*?\});', script_tag.string, re.DOTALL)
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
json_str = match.group(1)
|
||||||
|
# 清理JSON字符串中的潜在问题字符
|
||||||
|
json_str = json_str.strip().rstrip(';')
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(json_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# 如果直接解析失败,尝试清理JSON字符串
|
||||||
|
# 移除可能的注释或无效字符
|
||||||
|
cleaned_json = re.sub(r',\s*[}]', '}', json_str) # 移除末尾多余的逗号
|
||||||
|
cleaned_json = re.sub(r'/\*.*?\*/', '', cleaned_json) # 移除注释
|
||||||
|
cleaned_json = re.sub(r'//.*', '', cleaned_json) # 移除行注释
|
||||||
|
data = json.loads(cleaned_json)
|
||||||
|
|
||||||
|
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 (aiohttp.ClientError, KeyError, AttributeError, json.JSONDecodeError) as e:
|
||||||
|
logger.error(f"解析视频信息失败: {e}")
|
||||||
|
logger.debug(f"失败的URL: {video_url}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解析视频信息时发生未知错误: {e}")
|
||||||
|
logger.debug(f"失败的URL: {video_url}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async 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:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(api_url, headers=HEADERS, timeout=10) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
# 使用 content_type=None 来忽略 Content-Type 检查
|
||||||
|
# 因为 API 返回 text/json 而不是标准的 application/json
|
||||||
|
data = await response.json(content_type=None)
|
||||||
|
if data.get("code") == 200 and data.get("data"):
|
||||||
|
return data["data"][0].get("video_url")
|
||||||
|
except (aiohttp.ClientError, json.JSONDecodeError, KeyError, IndexError) as e:
|
||||||
|
logger.error(f"[bili_parser] 调用第三方API解析视频失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
BILI_URL_PATTERN = re.compile(r"https?://(?:www\.)?(bilibili\.com/video/\w+|b23\.tv/[a-zA-Z0-9]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_url_from_json_segments(segments):
|
||||||
|
"""
|
||||||
|
从消息的JSON段中提取B站链接
|
||||||
|
:param segments: 消息段列表
|
||||||
|
:return: 提取到的URL或None
|
||||||
|
"""
|
||||||
|
for segment in segments:
|
||||||
|
if segment.type == "json":
|
||||||
|
logger.info(f"[bili_parser] 检测到JSON CQ码: {segment.data}")
|
||||||
|
try:
|
||||||
|
json_data = json.loads(segment.data.get("data", "{}"))
|
||||||
|
short_url = json_data.get("meta", {}).get("detail_1", {}).get("qqdocurl")
|
||||||
|
|
||||||
|
if short_url and "b23.tv" in short_url:
|
||||||
|
extracted_url = short_url.split('?')[0]
|
||||||
|
logger.success(f"[bili_parser] 成功从JSON卡片中提取到B站短链接: {extracted_url}")
|
||||||
|
return extracted_url
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
logger.error(f"[bili_parser] 解析JSON失败: {e}")
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_url_from_text_segments(segments):
|
||||||
|
"""
|
||||||
|
从消息的文本段中提取B站链接
|
||||||
|
:param segments: 消息段列表
|
||||||
|
:return: 提取到的URL或None
|
||||||
|
"""
|
||||||
|
for segment in segments:
|
||||||
|
if segment.type == "text":
|
||||||
|
text_content = segment.data.get("text", "")
|
||||||
|
match = BILI_URL_PATTERN.search(text_content)
|
||||||
|
if match:
|
||||||
|
extracted_url = match.group(0)
|
||||||
|
logger.success(f"[bili_parser] 成功从文本中提取到B站链接: {extracted_url}")
|
||||||
|
return extracted_url
|
||||||
|
return None
|
||||||
|
|
||||||
|
@matcher.on_message()
|
||||||
|
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
|
||||||
|
|
||||||
|
# 1. 优先解析JSON卡片中的短链接
|
||||||
|
url_to_process = extract_url_from_json_segments(event.message)
|
||||||
|
|
||||||
|
# 2. 如果未在JSON卡片中找到链接,则在文本消息中查找
|
||||||
|
if not url_to_process:
|
||||||
|
url_to_process = extract_url_from_text_segments(event.message)
|
||||||
|
|
||||||
|
# 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站链接
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if "b23.tv" in url:
|
||||||
|
real_url = await get_real_url(url)
|
||||||
|
if not real_url:
|
||||||
|
logger.error(f"[bili_parser] 无法从 {url} 获取真实URL。")
|
||||||
|
await event.reply("无法解析B站短链接。")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# 清理URL,移除复杂查询参数,只保留基本的视频URL
|
||||||
|
real_url = url.split('?')[0]
|
||||||
|
if '#/' in real_url:
|
||||||
|
real_url = real_url.split('#/')[0]
|
||||||
|
|
||||||
|
video_info = await parse_video_info(real_url)
|
||||||
|
if not video_info:
|
||||||
|
logger.error(f"[bili_parser] 无法从 {real_url} 解析视频信息。")
|
||||||
|
await event.reply("无法获取视频信息,可能是B站接口变动或视频不存在。")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[bili_parser] 处理B站链接时发生错误: {e}")
|
||||||
|
await event.reply("处理B站链接时发生错误,请稍后再试。")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 检查视频时长
|
||||||
|
video_message: Union[str, MessageSegment]
|
||||||
|
if video_info['duration'] > 1200: # 5分钟 = 300秒
|
||||||
|
video_message = "视频时长超过5分钟,不进行解析。"
|
||||||
|
else:
|
||||||
|
direct_url = await 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"
|
||||||
|
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站链接: {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=BILI_NICKNAME, message=text_message),
|
||||||
|
event.bot.build_forward_node(user_id=event.self_id, nickname=BILI_NICKNAME, message=image_message_segment),
|
||||||
|
event.bot.build_forward_node(user_id=event.self_id, nickname=BILI_NICKNAME, message=up_info_segment),
|
||||||
|
event.bot.build_forward_node(user_id=event.self_id, nickname=BILI_NICKNAME, message=video_message)
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.success(f"[bili_parser] 成功解析视频信息并准备以聊天记录形式回复: {video_info['title']}")
|
||||||
|
# 使用更通用的 send_forwarded_messages 方法,自动判断私聊或群聊
|
||||||
|
await event.bot.send_forwarded_messages(target=event, nodes=nodes)
|
||||||
116
plugins/broadcast.py
Normal file
116
plugins/broadcast.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
管理员专用的广播插件
|
||||||
|
功能:
|
||||||
|
- 仅限管理员在私聊中调用。
|
||||||
|
- 通过回复一条消息并发送指令,将该消息转发给机器人所在的所有群聊。
|
||||||
|
- 此插件不写入 __plugin_meta__,保持隐藏。
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from core.managers.command_manager import matcher
|
||||||
|
from models.events.message import MessageEvent, PrivateMessageEvent
|
||||||
|
from core.permission import Permission
|
||||||
|
from core.utils.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=Permission.ADMIN)
|
||||||
|
async def broadcast_start(event: MessageEvent):
|
||||||
|
"""
|
||||||
|
广播指令的入口,启动一个等待用户消息的会话。
|
||||||
|
"""
|
||||||
|
# 1. 仅限私聊
|
||||||
|
if not isinstance(event, PrivateMessageEvent):
|
||||||
|
return
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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 True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Broadcast] 获取群聊列表失败: {e}")
|
||||||
|
await event.reply(f"获取群聊列表时发生错误: {e}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
success_count, failed_count = 0, 0
|
||||||
|
total_groups = len(group_list)
|
||||||
|
await event.reply(f"已收到广播内容,准备打包并向 {total_groups} 个群聊广播...")
|
||||||
|
|
||||||
|
# --- 将管理员发送的消息打包成一个单节点的合并转发消息 ---
|
||||||
|
try:
|
||||||
|
nodes_to_send = [
|
||||||
|
bot.build_forward_node(
|
||||||
|
user_id=event.user_id,
|
||||||
|
nickname=event.sender.nickname if event.sender else "未知用户",
|
||||||
|
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
|
||||||
|
except Exception as e:
|
||||||
|
failed_count += 1
|
||||||
|
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 # 消费事件,防止其他处理器响应
|
||||||
156
plugins/code_py.py
Normal file
156
plugins/code_py.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import html
|
||||||
|
import textwrap
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from core.managers.command_manager import matcher
|
||||||
|
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 (进入多行输入模式)",
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 会话状态管理 ---
|
||||||
|
# 结构: {(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):
|
||||||
|
"""
|
||||||
|
将输入和输出打包成转发消息进行回复。
|
||||||
|
参考 forward_test.py 的实现,兼容私聊和群聊。
|
||||||
|
"""
|
||||||
|
bot = event.bot
|
||||||
|
|
||||||
|
# 1. 构建消息节点列表
|
||||||
|
nodes = [
|
||||||
|
bot.build_forward_node(
|
||||||
|
user_id=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(
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
核心代码执行逻辑。
|
||||||
|
"""
|
||||||
|
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=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_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
|
||||||
|
|
||||||
|
await execute_code(event, normalized_code)
|
||||||
|
return True # 消费事件,防止其他处理器响应
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
391
plugins/douyin_parser.py
Normal file
391
plugins/douyin_parser.py
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import aiohttp
|
||||||
|
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": "douyin_parser",
|
||||||
|
"description": "自动解析抖音分享链接,提取视频信息和直链。",
|
||||||
|
"usage": "(自动触发)当检测到抖音分享链接时,自动发送视频信息。",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 常量定义
|
||||||
|
DOUYIN_NICKNAME = "抖音视频解析"
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2',
|
||||||
|
'Accept-Encoding': 'gzip, deflate, br', # 重新启用br编码支持
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'Upgrade-Insecure-Requests': '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 全局共享的 ClientSession
|
||||||
|
_session: Optional[aiohttp.ClientSession] = None
|
||||||
|
|
||||||
|
async def get_session() -> aiohttp.ClientSession:
|
||||||
|
global _session
|
||||||
|
if _session is None or _session.closed:
|
||||||
|
_session = aiohttp.ClientSession(headers=HEADERS)
|
||||||
|
return _session
|
||||||
|
|
||||||
|
|
||||||
|
def format_count(num: Union[int, str]) -> str:
|
||||||
|
try:
|
||||||
|
n = int(num)
|
||||||
|
if n < 10000:
|
||||||
|
return str(n)
|
||||||
|
return f"{n / 10000:.1f}万"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return str(num)
|
||||||
|
|
||||||
|
|
||||||
|
DOUYIN_URL_PATTERN = re.compile(r"https?://v\.douyin\.com/[a-zA-Z0-9_]+/?", re.IGNORECASE) # 包含下划线
|
||||||
|
DOUYIN_SHORT_PATTERN = re.compile(r"(?:https?://)?v\.douyin\.com/[a-zA-Z0-9_]+/?", re.IGNORECASE) # 包含下划线
|
||||||
|
|
||||||
|
|
||||||
|
def extract_url_from_json_segments(segments):
|
||||||
|
"""
|
||||||
|
从消息的JSON段中提取抖音链接
|
||||||
|
:param segments: 消息段列表
|
||||||
|
:return: 提取到的URL或None
|
||||||
|
"""
|
||||||
|
for segment in segments:
|
||||||
|
if segment.type == "json":
|
||||||
|
logger.info(f"[douyin_parser] 检测到JSON CQ码: {segment.data}")
|
||||||
|
try:
|
||||||
|
json_data = json.loads(segment.data.get("data", "{}"))
|
||||||
|
# 检查是否是抖音分享卡片
|
||||||
|
meta = json_data.get("meta", {})
|
||||||
|
if "detail_1" in meta:
|
||||||
|
detail = meta["detail_1"]
|
||||||
|
if "qqdocurl" in detail:
|
||||||
|
url = detail["qqdocurl"]
|
||||||
|
if "douyin.com" in url or "iesdouyin.com" in url:
|
||||||
|
logger.success(f"[douyin_parser] 成功从JSON卡片中提取到抖音链接: {url}")
|
||||||
|
return url
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
logger.error(f"[douyin_parser] 解析JSON失败: {e}")
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_url_from_text_segments(segments):
|
||||||
|
"""
|
||||||
|
从消息的文本段中提取抖音链接
|
||||||
|
:param segments: 消息段列表
|
||||||
|
:return: 提取到的URL或None
|
||||||
|
"""
|
||||||
|
for segment in segments:
|
||||||
|
if segment.type == "text":
|
||||||
|
text_content = segment.data.get("text", "")
|
||||||
|
# 查找抖音链接
|
||||||
|
match = DOUYIN_URL_PATTERN.search(text_content)
|
||||||
|
if match:
|
||||||
|
extracted_url = match.group(0)
|
||||||
|
logger.success(f"[douyin_parser] 成功从文本中提取到抖音链接: {extracted_url}")
|
||||||
|
return extracted_url
|
||||||
|
# 也检查是否有v.douyin.com格式的链接
|
||||||
|
short_match = DOUYIN_SHORT_PATTERN.search(text_content)
|
||||||
|
if short_match:
|
||||||
|
extracted_url = short_match.group(0)
|
||||||
|
logger.success(f"[douyin_parser] 成功从文本中提取到抖音短链接: {extracted_url}")
|
||||||
|
return extracted_url
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@matcher.on_message()
|
||||||
|
async def handle_douyin_share(event: MessageEvent):
|
||||||
|
"""
|
||||||
|
处理消息,检测抖音分享链接(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
|
||||||
|
|
||||||
|
# 1. 优先解析JSON卡片中的链接
|
||||||
|
url_to_process = extract_url_from_json_segments(event.message)
|
||||||
|
|
||||||
|
# 2. 如果未在JSON卡片中找到链接,则在文本消息中查找
|
||||||
|
if not url_to_process:
|
||||||
|
url_to_process = extract_url_from_text_segments(event.message)
|
||||||
|
|
||||||
|
# 3. 如果找到了抖音链接,则进行处理
|
||||||
|
if url_to_process:
|
||||||
|
await process_douyin_link(event, url_to_process)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_real_url(short_url: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取抖音短链接的真实URL
|
||||||
|
:param short_url: 抖音短链接
|
||||||
|
:return: 真实URL或None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 首先尝试获取重定向后的URL
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
# 添加更多头部信息模拟移动端访问
|
||||||
|
mobile_headers = HEADERS.copy() # 使用更新后的完整请求头
|
||||||
|
mobile_headers.update({
|
||||||
|
'Sec-Fetch-Dest': 'document',
|
||||||
|
'Sec-Fetch-Mode': 'navigate',
|
||||||
|
'Sec-Fetch-Site': 'none',
|
||||||
|
'Cache-Control': 'max-age=0',
|
||||||
|
# 模拟移动设备的额外头部
|
||||||
|
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Referer': 'https://www.douyin.com/'
|
||||||
|
})
|
||||||
|
|
||||||
|
async with session.get(short_url, headers=mobile_headers, allow_redirects=True, timeout=10) as response:
|
||||||
|
redirected_url = str(response.url)
|
||||||
|
|
||||||
|
# 检查重定向后的URL是否包含视频ID
|
||||||
|
# 抖音视频页通常包含 aweme_id 或 sec_uid 参数
|
||||||
|
if 'video/' in redirected_url or '/note/' in redirected_url:
|
||||||
|
logger.info(f"[douyin_parser] 重定向后的视频URL: {redirected_url}")
|
||||||
|
return redirected_url
|
||||||
|
elif 'share_item' in redirected_url:
|
||||||
|
# 如果URL中有share_item参数,尝试从中提取视频信息
|
||||||
|
logger.info(f"[douyin_parser] 重定向后的分享URL: {redirected_url}")
|
||||||
|
return redirected_url
|
||||||
|
else:
|
||||||
|
# 如果重定向到了主页或其他非视频页面,尝试从响应中提取信息
|
||||||
|
logger.warning(f"[douyin_parser] 重定向到了非预期页面: {redirected_url}")
|
||||||
|
return redirected_url
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[douyin_parser] 获取真实URL失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_douyin_video(video_url: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
解析抖音视频信息
|
||||||
|
:param video_url: 抖音视频链接
|
||||||
|
:return: 视频信息字典或None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 使用新的第三方API解析抖音视频
|
||||||
|
api_url = f"http://api.xhus.cn/api/douyin?url={video_url}"
|
||||||
|
|
||||||
|
session = await get_session()
|
||||||
|
async with session.get(api_url, headers=HEADERS, timeout=10) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
logger.error(f"[douyin_parser] API请求失败,状态码: {response.status}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
response_data = await response.json()
|
||||||
|
|
||||||
|
if not isinstance(response_data, dict):
|
||||||
|
logger.error(f"[douyin_parser] API返回格式错误: {response_data}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if response_data.get("code") != 200:
|
||||||
|
logger.error(f"[douyin_parser] API返回错误: {response_data}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = response_data.get("data", {})
|
||||||
|
if not data:
|
||||||
|
logger.error("[douyin_parser] API返回数据为空")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 新API的响应格式转换
|
||||||
|
return {
|
||||||
|
"type": "video" if not data.get("images") or not isinstance(data.get("images"), list) else "image",
|
||||||
|
"video_url": data.get("url", ""), # 核心字段:视频播放地址
|
||||||
|
"video_url_HQ": data.get("url", ""), # 新API没有HQ字段,使用同一个地址
|
||||||
|
"nickname": data.get("author", "未知作者"),
|
||||||
|
"desc": data.get("title", "无描述"),
|
||||||
|
"aweme_id": data.get("uid", ""),
|
||||||
|
"like": data.get("like", 0),
|
||||||
|
"cover": data.get("cover", ""),
|
||||||
|
"time": data.get("time", 0),
|
||||||
|
"author_avatar": data.get("avatar", ""),
|
||||||
|
"music": data.get("music", {}),
|
||||||
|
}
|
||||||
|
except (aiohttp.ClientError, KeyError, AttributeError, json.JSONDecodeError) as e:
|
||||||
|
logger.error(f"[douyin_parser] 解析抖音视频信息失败: {e}")
|
||||||
|
logger.debug(f"失败的URL: {video_url}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[douyin_parser] 解析抖音视频时发生未知错误: {e}")
|
||||||
|
logger.debug(f"失败的URL: {video_url}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def process_douyin_link(event: MessageEvent, url: str):
|
||||||
|
"""
|
||||||
|
处理抖音链接,获取信息并回复
|
||||||
|
:param event: 消息事件对象
|
||||||
|
:param url: 待处理的抖音链接
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 直接将原始链接传递给API,不需要获取真实URL
|
||||||
|
video_info = await parse_douyin_video(url)
|
||||||
|
if not video_info:
|
||||||
|
logger.error(f"[douyin_parser] 无法从 {url} 解析视频信息。")
|
||||||
|
await event.reply("无法获取视频信息,可能是抖音接口变动或视频不存在。")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 构建回复消息,包含原分享中的文本内容(如果有)
|
||||||
|
original_text = ""
|
||||||
|
for segment in event.message:
|
||||||
|
if segment.type == "text":
|
||||||
|
text_content = segment.data.get("text", "")
|
||||||
|
# 提取除了链接以外的文本内容
|
||||||
|
# 移除链接和复制提示
|
||||||
|
cleaned_text = re.sub(DOUYIN_URL_PATTERN, '', text_content)
|
||||||
|
cleaned_text = re.sub(DOUYIN_SHORT_PATTERN, '', cleaned_text)
|
||||||
|
cleaned_text = re.sub(r'复制此链接,打开Dou音搜索,直接观看视频!', '', cleaned_text)
|
||||||
|
cleaned_text = cleaned_text.strip()
|
||||||
|
if cleaned_text:
|
||||||
|
original_text = cleaned_text
|
||||||
|
break
|
||||||
|
|
||||||
|
# 构建回复消息
|
||||||
|
text_parts = ["抖音视频解析"]
|
||||||
|
text_parts.append("--------------------")
|
||||||
|
|
||||||
|
if original_text:
|
||||||
|
text_parts.append(f" 分享内容: {original_text}")
|
||||||
|
text_parts.append("--------------------")
|
||||||
|
|
||||||
|
text_parts.append(f" 作者: {video_info['nickname']}")
|
||||||
|
text_parts.append(f" 抖音号: {video_info['aweme_id']}")
|
||||||
|
text_parts.append(f" 标题: {video_info['desc']}")
|
||||||
|
text_parts.append(f" 点赞: {format_count(video_info['like'])}")
|
||||||
|
text_parts.append(f" 类型: {video_info['type']}")
|
||||||
|
|
||||||
|
# 如果是音乐,添加音乐信息
|
||||||
|
if video_info.get('music'):
|
||||||
|
music_info = video_info['music']
|
||||||
|
text_parts.append("--------------------")
|
||||||
|
text_parts.append(" 背景音乐:")
|
||||||
|
text_parts.append(f" 标题: {music_info.get('title', '')}")
|
||||||
|
text_parts.append(f" 作者: {music_info.get('author', '')}")
|
||||||
|
|
||||||
|
text_parts.append("--------------------")
|
||||||
|
text_parts.append(f" 原始链接: {url}")
|
||||||
|
|
||||||
|
text_message = "\n".join(text_parts)
|
||||||
|
|
||||||
|
# 准备转发消息节点
|
||||||
|
nodes = []
|
||||||
|
|
||||||
|
# 添加文本信息节点
|
||||||
|
text_node = event.bot.build_forward_node(
|
||||||
|
user_id=event.self_id,
|
||||||
|
nickname=DOUYIN_NICKNAME,
|
||||||
|
message=text_message
|
||||||
|
)
|
||||||
|
nodes.append(text_node)
|
||||||
|
|
||||||
|
# 添加封面图片节点(如果有)
|
||||||
|
if video_info.get('cover'):
|
||||||
|
try:
|
||||||
|
cover_node = event.bot.build_forward_node(
|
||||||
|
user_id=event.self_id,
|
||||||
|
nickname=DOUYIN_NICKNAME,
|
||||||
|
message=[
|
||||||
|
MessageSegment.text("抖音视频封面:\n"),
|
||||||
|
MessageSegment.image(video_info['cover'])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
nodes.append(cover_node)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[douyin_parser] 无法添加封面图片: {e}")
|
||||||
|
|
||||||
|
# 添加作者头像节点(如果有)
|
||||||
|
if video_info.get('author_avatar'):
|
||||||
|
try:
|
||||||
|
avatar_node = event.bot.build_forward_node(
|
||||||
|
user_id=event.self_id,
|
||||||
|
nickname=DOUYIN_NICKNAME,
|
||||||
|
message=[
|
||||||
|
MessageSegment.text("作者头像:\n"),
|
||||||
|
MessageSegment.image(video_info['author_avatar'])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
nodes.append(avatar_node)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[douyin_parser] 无法添加作者头像: {e}")
|
||||||
|
|
||||||
|
# 尝试添加视频直链(单独节点)
|
||||||
|
video_success = False
|
||||||
|
try:
|
||||||
|
if video_info.get('video_url'):
|
||||||
|
video_url = video_info.get('video_url', '')
|
||||||
|
# 检查视频类型
|
||||||
|
if video_info.get('type') == 'video':
|
||||||
|
video_message = MessageSegment.video(video_url)
|
||||||
|
video_type_text = "视频直链:"
|
||||||
|
else: # image类型
|
||||||
|
video_message = MessageSegment.image(video_url) # 单个图片
|
||||||
|
video_type_text = "图集首图:"
|
||||||
|
|
||||||
|
# 构建视频/图片节点
|
||||||
|
video_node = event.bot.build_forward_node(
|
||||||
|
user_id=event.self_id,
|
||||||
|
nickname=DOUYIN_NICKNAME,
|
||||||
|
message=[
|
||||||
|
MessageSegment.text(video_type_text + "\n"),
|
||||||
|
video_message
|
||||||
|
]
|
||||||
|
)
|
||||||
|
nodes.append(video_node)
|
||||||
|
video_success = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[douyin_parser] 无法添加视频/图片: {e}")
|
||||||
|
|
||||||
|
# 如果无法添加视频,添加提示信息
|
||||||
|
if not video_success:
|
||||||
|
no_video_node = event.bot.build_forward_node(
|
||||||
|
user_id=event.self_id,
|
||||||
|
nickname=DOUYIN_NICKNAME,
|
||||||
|
message="视频解析成功,但无法获取直链或播放视频。"
|
||||||
|
)
|
||||||
|
nodes.append(no_video_node)
|
||||||
|
|
||||||
|
logger.success(f"[douyin_parser] 成功解析视频信息并准备以聊天记录形式回复: {video_info['desc'][:20]}...")
|
||||||
|
|
||||||
|
# 发送合并转发消息
|
||||||
|
try:
|
||||||
|
# 使用更通用的 send_forwarded_messages 方法,自动判断私聊或群聊
|
||||||
|
await event.bot.send_forwarded_messages(target=event, nodes=nodes)
|
||||||
|
except Exception as e:
|
||||||
|
# 如果发送合并转发失败,尝试单独发送文本信息
|
||||||
|
logger.error(f"[douyin_parser] 发送合并转发失败: {e}")
|
||||||
|
|
||||||
|
# 构建替代的简单文本回复,避免电脑端显示问题
|
||||||
|
simple_reply = f"抖音视频解析成功\n{text_message}\n\n如果无法查看视频内容,请复制原始链接到浏览器打开:{url}"
|
||||||
|
await event.reply(simple_reply)
|
||||||
|
|
||||||
|
# 如果有封面,尝试单独发送
|
||||||
|
if video_info.get('cover'):
|
||||||
|
try:
|
||||||
|
await event.reply(MessageSegment.image(video_info['cover']))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[douyin_parser] 处理抖音链接时发生错误: {e}")
|
||||||
|
await event.reply("处理抖音链接时发生错误,请稍后再试。")
|
||||||
|
return
|
||||||
@@ -3,9 +3,9 @@ Echo 与交互插件
|
|||||||
|
|
||||||
提供 /echo 和 /赞我 指令。
|
提供 /echo 和 /赞我 指令。
|
||||||
"""
|
"""
|
||||||
from core.command_manager import matcher
|
from core.managers.command_manager import matcher
|
||||||
from core.bot import Bot
|
from core.bot import Bot
|
||||||
from models import MessageEvent
|
from models.events.message import MessageEvent
|
||||||
|
|
||||||
__plugin_meta__ = {
|
__plugin_meta__ = {
|
||||||
"name": "echo",
|
"name": "echo",
|
||||||
@@ -13,7 +13,7 @@ __plugin_meta__ = {
|
|||||||
"usage": "/echo [内容] - 复读内容\n/赞我 - 让机器人给你点赞",
|
"usage": "/echo [内容] - 复读内容\n/赞我 - 让机器人给你点赞",
|
||||||
}
|
}
|
||||||
|
|
||||||
@matcher.command("echo")
|
@matcher.command("echo",permission=MessageEvent.ADMIN)
|
||||||
async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
|
async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
|
||||||
"""
|
"""
|
||||||
处理 echo 指令,原样回复用户输入的内容
|
处理 echo 指令,原样回复用户输入的内容
|
||||||
@@ -29,18 +29,25 @@ async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
|
|||||||
|
|
||||||
await event.reply(reply_msg)
|
await event.reply(reply_msg)
|
||||||
|
|
||||||
@matcher.command("赞我")
|
@matcher.command(
|
||||||
async def handle_poke(bot: Bot, event: MessageEvent, args: list[str]):
|
"赞我",
|
||||||
|
override_permission_check=True
|
||||||
|
)
|
||||||
|
async def handle_poke(bot: Bot, event: MessageEvent, permission_granted: bool):
|
||||||
"""
|
"""
|
||||||
处理 赞我 指令,发送点赞
|
处理 赞我 指令,发送点赞
|
||||||
|
|
||||||
:param bot: Bot 实例
|
:param bot: Bot 实例
|
||||||
:param event: 消息事件对象
|
:param event: 消息事件对象
|
||||||
:param args: 指令参数列表(本指令不使用参数)
|
:param permission_granted: 权限检查结果
|
||||||
"""
|
"""
|
||||||
|
if not permission_granted:
|
||||||
|
await event.reply("只有我的操作员才能让我点赞哦!(。•ˇ‸ˇ•。)")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 尝试发送赞
|
# 尝试发送赞
|
||||||
await bot.send_like(event.user_id, times=10)
|
await bot.send_like(event.user_id, times=10)
|
||||||
await event.reply("戳一戳发送成功!")
|
await event.reply("好感度+10!(〃'▽'〃)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await event.reply(f"戳一戳发送失败:{str(e)}")
|
await event.reply(f"点赞失败了 >_<: {str(e)}")
|
||||||
|
|||||||
43
plugins/forward_test.py
Normal file
43
plugins/forward_test.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
合并转发消息测试插件
|
||||||
|
"""
|
||||||
|
from core.managers.command_manager import matcher
|
||||||
|
from core.bot import Bot
|
||||||
|
from models.events.message 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. 构建消息节点列表
|
||||||
|
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=nickname, message="让我看看"),
|
||||||
|
bot.build_forward_node(
|
||||||
|
user_id=event.self_id,
|
||||||
|
nickname="机器人",
|
||||||
|
message=[
|
||||||
|
MessageSegment.from_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}")
|
||||||
105
plugins/jrcd.py
105
plugins/jrcd.py
@@ -1,9 +1,16 @@
|
|||||||
|
"""
|
||||||
|
今日人品插件
|
||||||
|
|
||||||
|
提供 /jrcd 和 /bbcd 指令,用于娱乐。
|
||||||
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from core.bot import Bot
|
from core.bot import Bot
|
||||||
from core.command_manager import matcher
|
from core.managers.command_manager import matcher
|
||||||
from models import MessageEvent, MessageSegment
|
from core.utils.executor import run_in_thread_pool
|
||||||
|
from models.events.message import MessageEvent, MessageSegment
|
||||||
|
|
||||||
__plugin_meta__ = {
|
__plugin_meta__ = {
|
||||||
"name": "jrcd",
|
"name": "jrcd",
|
||||||
@@ -25,7 +32,7 @@ JRCDMSG_2 = [
|
|||||||
JRCDMSG_3 = [
|
JRCDMSG_3 = [
|
||||||
"今天的长度是%scm,哦豁?听说你很勇哦?(✧◡✧)",
|
"今天的长度是%scm,哦豁?听说你很勇哦?(✧◡✧)",
|
||||||
"今天的长度是%scm,嘶哈嘶哈(((o(*°▽°*)o)))...",
|
"今天的长度是%scm,嘶哈嘶哈(((o(*°▽°*)o)))...",
|
||||||
"今天的长度是%scm,我靠,让哥哥爽一爽吧!(((o(*°▽°*)o)))...",
|
"今天的长度是%scm,我靠,让哥哥爽一-爽吧!(((o(*°▽°*)o)))...",
|
||||||
"今天的长度是%scm,单是看到哥哥的长度就....(〃w〃)",
|
"今天的长度是%scm,单是看到哥哥的长度就....(〃w〃)",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -44,6 +51,12 @@ BBCDMSG7 = ["试试刺刀看看谁能赢吧!"]
|
|||||||
|
|
||||||
|
|
||||||
def get_jrcd(user_id: int) -> int:
|
def get_jrcd(user_id: int) -> int:
|
||||||
|
"""
|
||||||
|
根据用户ID和当前日期生成一个伪随机的“长度”值。
|
||||||
|
|
||||||
|
:param user_id: 用户QQ号。
|
||||||
|
:return: 返回一个1到30之间的整数。
|
||||||
|
"""
|
||||||
current_time = (
|
current_time = (
|
||||||
datetime.now().year * 100 + datetime.now().month * 100 + datetime.now().day
|
datetime.now().year * 100 + datetime.now().month * 100 + datetime.now().day
|
||||||
)
|
)
|
||||||
@@ -58,71 +71,81 @@ def get_jrcd(user_id: int) -> int:
|
|||||||
@matcher.command("jrcd")
|
@matcher.command("jrcd")
|
||||||
async def handle_jrcd(bot: Bot, event: MessageEvent, args: list[str]):
|
async def handle_jrcd(bot: Bot, event: MessageEvent, args: list[str]):
|
||||||
"""
|
"""
|
||||||
处理 jrcd 指令,来看看你的长度吧!
|
处理 jrcd 指令,回复用户的“今日长度”。
|
||||||
|
|
||||||
:param bot: Bot 实例
|
:param bot: Bot 实例。
|
||||||
:param event: 消息事件对象
|
:param event: 消息事件对象。
|
||||||
:param args: 指令参数列表
|
:param args: 指令参数列表(未使用)。
|
||||||
"""
|
"""
|
||||||
user_id = event.user_id
|
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)]
|
|
||||||
|
msg_text = ""
|
||||||
if jrcd <= 9:
|
if jrcd <= 9:
|
||||||
msg.append(MessageSegment.text(random.choice(JRCDMSG_1) % jrcd))
|
msg_text = random.choice(JRCDMSG_1) % jrcd
|
||||||
elif jrcd <= 19:
|
elif jrcd <= 19:
|
||||||
msg.append(MessageSegment.text(random.choice(JRCDMSG_2) % jrcd))
|
msg_text = random.choice(JRCDMSG_2) % jrcd
|
||||||
else:
|
else:
|
||||||
msg.append(MessageSegment.text(random.choice(JRCDMSG_3) % jrcd))
|
msg_text = random.choice(JRCDMSG_3) % jrcd
|
||||||
await event.reply(msg)
|
|
||||||
|
reply_segments = [MessageSegment.at(user_id), MessageSegment.from_text(msg_text)]
|
||||||
|
await event.reply(reply_segments)
|
||||||
|
|
||||||
|
|
||||||
@matcher.command("bbcd")
|
@matcher.command("bbcd")
|
||||||
async def handle_bbcd(bot: Bot, event: MessageEvent, args: list[str]):
|
async def handle_bbcd(bot: Bot, event: MessageEvent, args: list[str]):
|
||||||
"""
|
"""
|
||||||
处理 bbcd 指令,和别人比比长度吧!
|
处理 bbcd 指令,比较两位用户的“长度”。
|
||||||
|
|
||||||
:param bot: Bot 实例
|
:param bot: Bot 实例。
|
||||||
:param event: 消息事件对象
|
:param event: 消息事件对象。
|
||||||
:param args: 指令参数列表
|
:param args: 指令参数列表(未使用)。
|
||||||
"""
|
"""
|
||||||
message = event.message
|
message = event.message
|
||||||
print(message)
|
print(message)
|
||||||
if len(message) < 2:
|
if len(message) < 2:
|
||||||
return
|
return
|
||||||
|
|
||||||
user_id1 = event.user_id
|
user_id1 = event.user_id
|
||||||
|
try:
|
||||||
user_id2 = int(message[1].data.get("qq", 0))
|
user_id2 = int(message[1].data.get("qq", 0))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
if user_id1 == user_id2:
|
if user_id1 == user_id2:
|
||||||
await event.reply("不能和自己比!")
|
await event.reply("不能和自己比!")
|
||||||
return
|
return
|
||||||
|
|
||||||
jrcd1 = get_jrcd(user_id1)
|
jrcd1 = await run_in_thread_pool(get_jrcd, user_id1)
|
||||||
jrcd2 = get_jrcd(user_id2)
|
jrcd2 = await run_in_thread_pool(get_jrcd, user_id2)
|
||||||
|
|
||||||
jrcz = jrcd1 - jrcd2
|
jrcz = jrcd1 - jrcd2
|
||||||
|
|
||||||
msg = [
|
text_part = ""
|
||||||
|
if jrcz == 0:
|
||||||
|
text_part = f" 一样长。{random.choice(BBCDMSG7)}"
|
||||||
|
elif jrcz > 0:
|
||||||
|
text_part = f" 长{jrcz}cm。"
|
||||||
|
if jrcz <= 9:
|
||||||
|
text_part += random.choice(BBCDMSG1)
|
||||||
|
elif jrcz <= 19:
|
||||||
|
text_part += random.choice(BBCDMSG2)
|
||||||
|
else:
|
||||||
|
text_part += random.choice(BBCDMSG3)
|
||||||
|
else: # jrcz < 0
|
||||||
|
text_part = f" 短{abs(jrcz)}cm。"
|
||||||
|
if jrcz >= -9:
|
||||||
|
text_part += random.choice(BBCDMSG4)
|
||||||
|
elif jrcz >= -19:
|
||||||
|
text_part += random.choice(BBCDMSG5)
|
||||||
|
else:
|
||||||
|
text_part += random.choice(BBCDMSG6)
|
||||||
|
|
||||||
|
segments = [
|
||||||
MessageSegment.at(user_id1),
|
MessageSegment.at(user_id1),
|
||||||
MessageSegment.text("你的长度比"),
|
MessageSegment.from_text(" 你的长度比 "),
|
||||||
MessageSegment.at(user_id2),
|
MessageSegment.at(user_id2),
|
||||||
|
MessageSegment.from_text(text_part),
|
||||||
]
|
]
|
||||||
|
|
||||||
if jrcz == 0:
|
await event.reply(segments)
|
||||||
msg.append(MessageSegment.text("一样长。"))
|
|
||||||
msg.append(MessageSegment.text(random.choice(BBCDMSG7)))
|
|
||||||
elif jrcz > 0:
|
|
||||||
msg.append(MessageSegment.text("长" + str(jrcz) + "cm。"))
|
|
||||||
if jrcz <= 9:
|
|
||||||
msg.append(MessageSegment.text(random.choice(BBCDMSG1)))
|
|
||||||
elif jrcz <= 19:
|
|
||||||
msg.append(MessageSegment.text(random.choice(BBCDMSG2)))
|
|
||||||
else:
|
|
||||||
msg.append(MessageSegment.text(random.choice(BBCDMSG3)))
|
|
||||||
elif jrcz < 0:
|
|
||||||
msg.append(MessageSegment.text("短" + str(abs(jrcz)) + "cm。"))
|
|
||||||
if jrcz >= -9:
|
|
||||||
msg.append(MessageSegment.text(random.choice(BBCDMSG4)))
|
|
||||||
elif jrcz >= -19:
|
|
||||||
msg.append(MessageSegment.text(random.choice(BBCDMSG5)))
|
|
||||||
else:
|
|
||||||
msg.append(MessageSegment.text(random.choice(BBCDMSG6)))
|
|
||||||
await event.reply(msg)
|
|
||||||
|
|||||||
BIN
plugins/resource/help.png
Normal file
BIN
plugins/resource/help.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
88
plugins/sync_async_test_plugin.py
Normal file
88
plugins/sync_async_test_plugin.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
同步/异步函数测试插件
|
||||||
|
|
||||||
|
用于演示 SyncHandlerError 异常以及如何将同步函数放入线程池执行。
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from core.managers.command_manager import matcher
|
||||||
|
from core.utils.executor import run_in_thread_pool
|
||||||
|
from core.bot import Bot
|
||||||
|
from core.utils.logger import logger
|
||||||
|
|
||||||
|
# 插件元数据
|
||||||
|
__plugin_meta__ = {
|
||||||
|
"name": "SyncAsyncTestPlugin",
|
||||||
|
"description": "用于测试同步/异步函数处理的插件。",
|
||||||
|
"usage": (
|
||||||
|
"/test_sync_error - 尝试注册一个同步函数作为异步处理器,会触发错误。\n"
|
||||||
|
"/test_blocking_task <duration> - 演示将同步阻塞任务放入线程池执行。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 示例 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 "这个消息不应该被看到。"
|
||||||
@@ -6,16 +6,57 @@ thpic 插件
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from core.bot import Bot
|
from core.bot import Bot
|
||||||
from core.command_manager import matcher
|
from core.managers.command_manager import matcher
|
||||||
from models import MessageEvent, MessageSegment
|
from models.events.message import MessageEvent, MessageSegment
|
||||||
|
|
||||||
__plugin_meta__ = {
|
__plugin_meta__ = {
|
||||||
"name": "thpic",
|
"name": "thpic",
|
||||||
"description": "来看看东方Project的图片吧!",
|
"description": "来看看东方Project的图片吧!",
|
||||||
"usage": "/thpic",
|
"usage": "/thpic [nums](1~10)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@matcher.command("thpic")
|
@matcher.command("thpic")
|
||||||
async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
|
async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
|
||||||
await event.reply(MessageSegment.image("https://img.paulzzh.com/touhou/random"))
|
"""
|
||||||
|
处理 thpic 指令,发送一张随机的东方Project图片。
|
||||||
|
|
||||||
|
:param bot: Bot 实例(未使用)。
|
||||||
|
:param event: 消息事件对象。
|
||||||
|
:param args: 指令参数列表(未使用)。
|
||||||
|
"""
|
||||||
|
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']}")
|
||||||
|
|||||||
94
profile_main.py
Normal file
94
profile_main.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
性能分析入口文件
|
||||||
|
|
||||||
|
用于启动带有性能分析功能的应用程序。
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
python profile_main.py [options]
|
||||||
|
|
||||||
|
选项:
|
||||||
|
-h, --help 显示帮助信息
|
||||||
|
--profile, -p 启用详细性能分析(使用 pyinstrument)
|
||||||
|
--memory, -m 启用内存使用分析
|
||||||
|
--output, -o FILE 性能分析报告输出文件(HTML格式)
|
||||||
|
--threshold, -t SEC 设置性能监控阈值(秒)
|
||||||
|
--stats, -s 在程序结束时输出性能统计报告
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
|
||||||
|
# 将项目根目录添加到 sys.path
|
||||||
|
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, ROOT_DIR)
|
||||||
|
|
||||||
|
# 解析命令行参数
|
||||||
|
parser = argparse.ArgumentParser(description='性能分析入口文件')
|
||||||
|
parser.add_argument('--profile', '-p', action='store_true', help='启用详细性能分析(使用 pyinstrument)')
|
||||||
|
parser.add_argument('--memory', '-m', action='store_true', help='启用内存使用分析')
|
||||||
|
parser.add_argument('--output', '-o', type=str, default='performance_report.html', help='性能分析报告输出文件(HTML格式)')
|
||||||
|
parser.add_argument('--threshold', '-t', type=float, default=0.5, help='设置性能监控阈值(秒)')
|
||||||
|
parser.add_argument('--stats', '-s', action='store_true', help='在程序结束时输出性能统计报告')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# 设置全局性能分析配置
|
||||||
|
os.environ['PERFORMANCE_PROFILE'] = '1' if args.profile else '0'
|
||||||
|
os.environ['PERFORMANCE_MEMORY'] = '1' if args.memory else '0'
|
||||||
|
os.environ['PERFORMANCE_OUTPUT'] = args.output
|
||||||
|
os.environ['PERFORMANCE_THRESHOLD'] = str(args.threshold)
|
||||||
|
os.environ['PERFORMANCE_STATS'] = '1' if args.stats else '0'
|
||||||
|
|
||||||
|
# 导入并运行主程序
|
||||||
|
from core.utils.performance import profile, aprofile
|
||||||
|
from main import main
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def main_with_profile():
|
||||||
|
"""
|
||||||
|
带有性能分析的主函数入口
|
||||||
|
"""
|
||||||
|
if args.profile:
|
||||||
|
# 使用 pyinstrument 进行详细性能分析
|
||||||
|
from pyinstrument import Profiler
|
||||||
|
from pyinstrument.renderers import HTMLRenderer
|
||||||
|
|
||||||
|
profiler = Profiler()
|
||||||
|
profiler.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await main()
|
||||||
|
finally:
|
||||||
|
profiler.stop()
|
||||||
|
|
||||||
|
# 输出分析结果到控制台
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("性能分析结果")
|
||||||
|
print("=" * 80)
|
||||||
|
print(profiler.print())
|
||||||
|
|
||||||
|
# 保存HTML报告
|
||||||
|
try:
|
||||||
|
html = profiler.render(HTMLRenderer())
|
||||||
|
with open(args.output, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html)
|
||||||
|
print(f"\n性能分析报告已保存到: {args.output}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n保存性能分析报告失败: {e}")
|
||||||
|
else:
|
||||||
|
# 不使用详细分析,直接运行
|
||||||
|
await main()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main_with_profile())
|
||||||
|
finally:
|
||||||
|
# 输出性能统计报告
|
||||||
|
if args.stats:
|
||||||
|
from core.utils.performance import performance_stats
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("性能统计报告")
|
||||||
|
print("=" * 80)
|
||||||
|
print(performance_stats.report())
|
||||||
2
pytest.ini
Normal file
2
pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
4
requirements-dev.txt
Normal file
4
requirements-dev.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# 开发依赖
|
||||||
|
pyinstrument>=4.5.0 # 性能分析工具,支持异步代码
|
||||||
|
memory-profiler>=0.61.0 # 内存分析工具
|
||||||
|
psutil>=5.9.8 # 系统资源监控
|
||||||
@@ -1,13 +1,72 @@
|
|||||||
certifi==2025.11.12
|
aiohappyeyeballs==2.6.1
|
||||||
|
aiohttp==3.13.3
|
||||||
|
aiosignal==1.4.0
|
||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.12.1
|
||||||
|
astroid==4.0.3
|
||||||
|
attrs==25.4.0
|
||||||
|
beautifulsoup4==4.14.3
|
||||||
|
bs4==0.0.2
|
||||||
|
cachetools==6.2.4
|
||||||
|
certifi==2026.1.4
|
||||||
|
cffi==2.0.0
|
||||||
charset-normalizer==3.4.4
|
charset-normalizer==3.4.4
|
||||||
|
colorama==0.4.6
|
||||||
|
coverage==7.13.1
|
||||||
|
cryptography==46.0.3
|
||||||
|
dill==0.4.0
|
||||||
|
docker==7.1.0
|
||||||
docopt==0.6.2
|
docopt==0.6.2
|
||||||
|
frozenlist==1.8.0
|
||||||
|
greenlet==3.3.0
|
||||||
|
h11==0.16.0
|
||||||
|
httpcore==1.0.9
|
||||||
|
httpx==0.27.0
|
||||||
idna==3.11
|
idna==3.11
|
||||||
pathlib==1.0.1
|
iniconfig==2.3.0
|
||||||
|
isort==7.0.0
|
||||||
|
Jinja2==3.1.6
|
||||||
|
librt==0.7.7
|
||||||
|
loguru==0.7.3
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
mccabe==0.7.0
|
||||||
|
multidict==6.7.0
|
||||||
|
mypy==1.19.1
|
||||||
|
mypy_extensions==1.1.0
|
||||||
|
orjson==3.11.5
|
||||||
|
packaging==25.0
|
||||||
|
pathspec==1.0.3
|
||||||
|
pillow==12.1.0
|
||||||
pipreqs==0.4.13
|
pipreqs==0.4.13
|
||||||
|
platformdirs==4.5.1
|
||||||
|
playwright==1.57.0
|
||||||
|
pluggy==1.6.0
|
||||||
|
propcache==0.4.1
|
||||||
|
pycparser==2.23
|
||||||
|
pydantic==2.12.5
|
||||||
|
pydantic_core==2.41.5
|
||||||
|
pyee==13.0.0
|
||||||
|
Pygments==2.19.2
|
||||||
|
pylint==4.0.4
|
||||||
|
pytest==9.0.2
|
||||||
|
pytest-asyncio==1.3.0
|
||||||
|
pytest-cov==7.0.0
|
||||||
|
pytest-mock==3.15.1
|
||||||
|
redis==7.1.0
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
|
setuptools==80.9.0
|
||||||
|
sniffio==1.3.1
|
||||||
|
soupsieve==2.8.1
|
||||||
toml==0.10.2
|
toml==0.10.2
|
||||||
typing==3.7.4.3
|
tomlkit==0.13.3
|
||||||
urllib3==2.6.2
|
types-cachetools==6.2.0.20251022
|
||||||
websockets==15.0.1
|
types-docker==7.1.0.20251202
|
||||||
yarg==0.1.10
|
types-paramiko==4.0.0.20250822
|
||||||
|
types-requests==2.32.4.20260107
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
urllib3==2.6.3
|
||||||
watchdog==6.0.0
|
watchdog==6.0.0
|
||||||
|
websockets==16.0
|
||||||
|
yarg==0.1.10
|
||||||
|
yarl==1.22.0
|
||||||
|
|||||||
9
sandbox.Dockerfile
Normal file
9
sandbox.Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# 使用一个轻量级的 Python 官方镜像作为基础
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# 创建一个工作目录,用于存放和执行用户的代码
|
||||||
|
WORKDIR /sandbox
|
||||||
|
|
||||||
|
|
||||||
|
# 默认的启动命令是 python,这样容器启动时可以直接执行 .py 文件
|
||||||
|
CMD ["python"]
|
||||||
104
scripts/check_python_env.py
Normal file
104
scripts/check_python_env.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Python 环境检查脚本
|
||||||
|
|
||||||
|
检查当前 Python 环境是否符合 NEO Bot 要求,包括版本、GIL 状态、JIT 支持等。
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import platform
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("NEO Bot Python 环境检查")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 1. Python 版本信息
|
||||||
|
version_info = sys.version_info
|
||||||
|
print("\n[1] Python 版本:")
|
||||||
|
print(f" 版本号: {sys.version}")
|
||||||
|
print(f" 主版本: {version_info.major}.{version_info.minor}.{version_info.micro}")
|
||||||
|
print(f" 发布日期: {version_info.releaselevel} {version_info.serial}")
|
||||||
|
|
||||||
|
# 检查是否为 Python 3.14
|
||||||
|
if version_info.major == 3 and version_info.minor == 14:
|
||||||
|
print(" ✓ 符合要求: Python 3.14")
|
||||||
|
else:
|
||||||
|
print(f" ⚠ 警告: 推荐使用 Python 3.14,当前为 {version_info.major}.{version_info.minor}")
|
||||||
|
|
||||||
|
# 2. 平台信息
|
||||||
|
print("\n[2] 平台信息:")
|
||||||
|
print(f" 操作系统: {platform.system()} {platform.release()}")
|
||||||
|
print(f" 处理器: {platform.processor()}")
|
||||||
|
print(f" 架构: {platform.machine()}")
|
||||||
|
|
||||||
|
# 3. GIL 状态
|
||||||
|
print("\n[3] GIL (全局解释器锁) 状态:")
|
||||||
|
try:
|
||||||
|
# Python 3.13+ free-threading build 才有这个属性
|
||||||
|
is_gil_enabled = sys._is_gil_enabled()
|
||||||
|
if is_gil_enabled:
|
||||||
|
print(" GIL 已启用 (传统模式)")
|
||||||
|
else:
|
||||||
|
print(" GIL 已禁用 (自由线程模式)")
|
||||||
|
except AttributeError:
|
||||||
|
print(" GIL 状态: 未知 (sys._is_gil_enabled 未找到,可能是传统 GIL 构建)")
|
||||||
|
|
||||||
|
# 4. JIT 状态
|
||||||
|
print("\n[4] JIT (即时编译) 状态:")
|
||||||
|
|
||||||
|
# 检查是否启用了 JIT
|
||||||
|
jit_enabled = False
|
||||||
|
jit_details = "未知"
|
||||||
|
|
||||||
|
# 方法1: 检查启动标志
|
||||||
|
if hasattr(sys, 'flags'):
|
||||||
|
# Python 3.14 的 JIT 通过 -X jit 启用
|
||||||
|
# 但 sys.flags 中没有直接的 JIT 标志
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 方法2: 检查是否有 JIT 相关属性
|
||||||
|
try:
|
||||||
|
# 尝试导入 _jit 模块(如果存在)
|
||||||
|
import _jit
|
||||||
|
jit_enabled = True
|
||||||
|
jit_details = "检测到 _jit 模块"
|
||||||
|
del _jit # 避免未使用的导入警告
|
||||||
|
except ImportError:
|
||||||
|
# 检查 sys 模块中是否有 JIT 相关属性
|
||||||
|
if hasattr(sys, '_jit_enabled'):
|
||||||
|
jit_enabled = sys._jit_enabled
|
||||||
|
jit_details = f"sys._jit_enabled = {jit_enabled}"
|
||||||
|
else:
|
||||||
|
jit_details = "未检测到 JIT 模块或属性"
|
||||||
|
|
||||||
|
if jit_enabled:
|
||||||
|
print(" ✓ JIT 已启用")
|
||||||
|
print(f" 详情: {jit_details}")
|
||||||
|
else:
|
||||||
|
print(" ⚠ JIT 未启用或不可用")
|
||||||
|
print(f" 详情: {jit_details}")
|
||||||
|
print(" 建议: 启动时使用 -X jit 参数启用 JIT,例如: python -X jit main.py")
|
||||||
|
|
||||||
|
# 5. 其他信息
|
||||||
|
print("\n[5] 其他信息:")
|
||||||
|
print(f" 实现: {platform.python_implementation()}")
|
||||||
|
print(f" 构建: {platform.python_build()}")
|
||||||
|
print(f" 编译器: {platform.python_compiler()}")
|
||||||
|
|
||||||
|
# 6. 路径信息
|
||||||
|
print("\n[6] 路径信息:")
|
||||||
|
print(f" 执行文件: {sys.executable}")
|
||||||
|
print(f" 前缀: {sys.prefix}")
|
||||||
|
print(" 路径:")
|
||||||
|
for i, path in enumerate(sys.path[:5], 1): # 只显示前5个
|
||||||
|
print(f" {i}. {path}")
|
||||||
|
if len(sys.path) > 5:
|
||||||
|
print(f" ... 还有 {len(sys.path) - 5} 个路径")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("检查完成")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
355
scripts/compile_machine_code.py
Normal file
355
scripts/compile_machine_code.py
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
优化版跨平台 Python 模块编译脚本
|
||||||
|
|
||||||
|
将核心 Python 模块编译为机器码(.pyd 或 .so)以提升性能。
|
||||||
|
此版本基于对项目结构的深入分析,包含了更多高频使用的模块。
|
||||||
|
|
||||||
|
支持的平台:
|
||||||
|
- Windows: 生成 .pyd 文件
|
||||||
|
- Linux: 生成 .so 文件
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
python compile_machine_code.py [options]
|
||||||
|
|
||||||
|
选项:
|
||||||
|
--compile, -c 编译指定的模块(默认)
|
||||||
|
--list, -l 列出已编译的模块
|
||||||
|
--clean, -k 清理编译生成的文件
|
||||||
|
--help, -h 显示帮助信息
|
||||||
|
|
||||||
|
注意:
|
||||||
|
1. 需要安装 C 编译器 (Windows 上需要 Visual Studio Build Tools, Linux 上需要 GCC)
|
||||||
|
2. 需要安装 mypyc: pip install mypyc
|
||||||
|
3. 编译后的文件是平台相关的,不能跨平台复制
|
||||||
|
4. 建议在部署的目标环境上运行此脚本
|
||||||
|
5. Mypyc 不支持动态特性,如 eval/exec/getattr/setattr 等
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import glob
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
# 检测当前平台和 Python 版本
|
||||||
|
PLATFORM = sys.platform
|
||||||
|
PYTHON_VERSION = f"{sys.version_info.major}{sys.version_info.minor}" # 例如 "314"
|
||||||
|
|
||||||
|
if PLATFORM.startswith('win'):
|
||||||
|
EXTENSION = '.pyd'
|
||||||
|
BUILD_PREFIX = f'cp{PYTHON_VERSION}-win_amd64'
|
||||||
|
BUILD_PATH = os.path.join('build', f'lib.win-amd64-cpython-{PYTHON_VERSION}')
|
||||||
|
elif PLATFORM.startswith('linux'):
|
||||||
|
EXTENSION = '.so'
|
||||||
|
BUILD_PREFIX = f'cp{PYTHON_VERSION}-x86_64-linux-gnu'
|
||||||
|
BUILD_PATH = os.path.join('build', f'lib.linux-x86_64-cpython-{PYTHON_VERSION}')
|
||||||
|
else:
|
||||||
|
print(f"不支持的平台: {PLATFORM}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 根据项目分析,优化要编译的模块列表
|
||||||
|
# 这些是项目中使用频率最高的模块,编译后能显著提升性能
|
||||||
|
MODULES = [
|
||||||
|
# 工具模块 - 高频使用
|
||||||
|
'core/utils/json_utils.py', # JSON 处理 - 高频使用
|
||||||
|
'core/utils/executor.py', # 代码执行引擎 - 高频使用
|
||||||
|
'core/utils/exceptions.py', # 自定义异常 - 基础组件
|
||||||
|
'core/utils/performance.py', # 性能监控工具 - 重要组件
|
||||||
|
'core/utils/logger.py', # 日志模块 - 高频使用
|
||||||
|
'core/utils/singleton.py', # 单例模式 - 基础组件
|
||||||
|
|
||||||
|
# 核心管理模块 - 高频使用
|
||||||
|
# 'core/managers/command_manager.py', # 指令匹配和分发 - 包含动态特性,不适合编译
|
||||||
|
# 'core/managers/admin_manager.py', # 管理员管理 - 包含动态特性,不适合编译
|
||||||
|
# 'core/managers/permission_manager.py', # 权限管理 - 包含动态特性,不适合编译
|
||||||
|
# 'core/managers/plugin_manager.py', # 插件管理器 - 包含动态特性,不适合编译
|
||||||
|
# 'core/managers/redis_manager.py', # Redis 管理器 - 包含动态特性,不适合编译
|
||||||
|
# 'core/managers/image_manager.py', # 图片管理器 - 包含动态特性,不适合编译
|
||||||
|
|
||||||
|
# 核心基础模块 - 高频使用
|
||||||
|
'core/ws.py', # WebSocket 核心 - 核心通信,被10个文件引用
|
||||||
|
# 'core/bot.py', # Bot 核心抽象 - 使用多重继承,不适合编译
|
||||||
|
'core/config_loader.py', # 配置加载 - 启动必需,被7个文件引用
|
||||||
|
# 'core/config_models.py', # 配置模型 - 包含复杂类型定义,不适合编译
|
||||||
|
# 'core/permission.py', # 权限枚举 - 包含动态属性,不适合编译
|
||||||
|
|
||||||
|
# 数据模型 - 高频使用
|
||||||
|
'models/message.py', # 消息段模型 - 高频消息处理
|
||||||
|
'models/sender.py', # 发送者模型 - 高频消息处理
|
||||||
|
'models/objects.py', # API 响应数据模型 - 高频数据处理
|
||||||
|
|
||||||
|
# 事件处理相关 - 高频使用
|
||||||
|
'core/handlers/event_handler.py', # 事件处理器 - 核心事件处理
|
||||||
|
|
||||||
|
# 事件模型 - 高频使用,但包含dataclass,可能有编译问题,暂时排除
|
||||||
|
# 'models/events/message.py', # 消息事件 - 最高频事件类型
|
||||||
|
# 'models/events/notice.py', # 通知事件 - 高频事件类型
|
||||||
|
# 'models/events/request.py', # 请求事件 - 高频事件类型
|
||||||
|
# 'models/events/meta.py', # 元事件 - 高频事件类型
|
||||||
|
|
||||||
|
# 注意:以下文件不适合编译
|
||||||
|
# - 主程序文件(main.py)
|
||||||
|
# - 测试文件(tests/目录)
|
||||||
|
# - 插件文件(plugins/目录)
|
||||||
|
# - 编译(脚本compile_machine_code.py等)
|
||||||
|
# - 包含复杂动态特性的文件
|
||||||
|
# - API 基础类(由于多重继承问题)
|
||||||
|
]
|
||||||
|
|
||||||
|
def list_compiled_modules():
|
||||||
|
"""列出已编译的模块"""
|
||||||
|
print(f"\n已编译的 {PLATFORM} 模块:")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# 查找所有编译后的文件
|
||||||
|
compiled_files = []
|
||||||
|
for ext in [EXTENSION, f'__mypyc{EXTENSION}']:
|
||||||
|
compiled_files.extend(glob.glob(f'**/*{ext}', recursive=True))
|
||||||
|
|
||||||
|
# 过滤掉虚拟环境中的文件
|
||||||
|
compiled_files = [f for f in compiled_files if 'venv' not in f and '.venv' not in f]
|
||||||
|
|
||||||
|
if compiled_files:
|
||||||
|
for f in sorted(compiled_files):
|
||||||
|
size = os.path.getsize(f) // 1024 # KB
|
||||||
|
print(f"{f} ({size} KB)")
|
||||||
|
else:
|
||||||
|
print(f"未找到已编译的 {EXTENSION} 文件")
|
||||||
|
|
||||||
|
print(f"\n总计: {len(compiled_files)} 个文件")
|
||||||
|
|
||||||
|
def clean_compiled_files():
|
||||||
|
"""清理编译生成的文件"""
|
||||||
|
print(f"\n清理编译生成的 {EXTENSION} 文件...")
|
||||||
|
|
||||||
|
# 查找所有编译后的文件
|
||||||
|
compiled_files = []
|
||||||
|
for ext in [EXTENSION, f'__mypyc{EXTENSION}']:
|
||||||
|
compiled_files.extend(glob.glob(f'**/*{ext}', recursive=True))
|
||||||
|
|
||||||
|
# 过滤掉虚拟环境中的文件
|
||||||
|
compiled_files = [f for f in compiled_files if 'venv' not in f and '.venv' not in f]
|
||||||
|
|
||||||
|
if compiled_files:
|
||||||
|
for f in sorted(compiled_files):
|
||||||
|
try:
|
||||||
|
os.remove(f)
|
||||||
|
print(f"已删除: {f}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"删除失败 {f}: {e}")
|
||||||
|
|
||||||
|
# 清理 build 目录
|
||||||
|
if os.path.exists('build'):
|
||||||
|
try:
|
||||||
|
shutil.rmtree('build')
|
||||||
|
print("已删除 build 目录")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"删除 build 目录失败: {e}")
|
||||||
|
else:
|
||||||
|
print(f"没有可清理的 {EXTENSION} 文件")
|
||||||
|
|
||||||
|
def get_platform_specific_module_name(module_path):
|
||||||
|
"""获取平台特定的模块文件名"""
|
||||||
|
# 只获取模块名,不包含路径
|
||||||
|
module_name = os.path.basename(module_path).replace('.py', '')
|
||||||
|
return f"{module_name}.{BUILD_PREFIX}{EXTENSION}"
|
||||||
|
|
||||||
|
def compile_module(module_path):
|
||||||
|
"""编译单个模块"""
|
||||||
|
print(f"\n编译: {module_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 直接调用 mypyc 命令行工具
|
||||||
|
# 使用二进制模式捕获输出以避免编码问题
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, '-m', 'mypyc', module_path],
|
||||||
|
capture_output=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# 解码输出时处理可能的编码错误
|
||||||
|
try:
|
||||||
|
stdout_text = result.stdout.decode('utf-8', errors='replace')
|
||||||
|
stderr_text = result.stderr.decode('utf-8', errors='replace')
|
||||||
|
except AttributeError:
|
||||||
|
# 如果已经是字符串(Python 3.7+),则直接使用
|
||||||
|
stdout_text = result.stdout
|
||||||
|
stderr_text = result.stderr
|
||||||
|
|
||||||
|
# 获取平台特定的模块名
|
||||||
|
# 获取模块名和目录
|
||||||
|
module_dir = os.path.dirname(module_path)
|
||||||
|
module_basename = os.path.basename(module_path).replace('.py', '')
|
||||||
|
|
||||||
|
# 生成平台特定的模块文件名(仅文件名,不含路径)
|
||||||
|
platform_module_name = f"{module_basename}.{BUILD_PREFIX}{EXTENSION}"
|
||||||
|
mypyc_platform_module_name = f"{module_basename}__mypyc.{BUILD_PREFIX}{EXTENSION}"
|
||||||
|
|
||||||
|
# 完整路径构造
|
||||||
|
platform_module = os.path.join(module_dir, platform_module_name)
|
||||||
|
mypyc_platform_module = os.path.join(module_dir, mypyc_platform_module_name)
|
||||||
|
|
||||||
|
# 检查编译产物是否在当前目录
|
||||||
|
if os.path.exists(platform_module):
|
||||||
|
print(f" ✓ 编译成功: {platform_module}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# 检查 build 目录中是否有编译产物
|
||||||
|
build_module_path = os.path.join(BUILD_PATH, module_dir, platform_module_name)
|
||||||
|
build_mypyc_path = os.path.join(BUILD_PATH, module_dir, mypyc_platform_module_name)
|
||||||
|
|
||||||
|
if os.path.exists(build_module_path):
|
||||||
|
# 如果在 build 目录中,复制到正确位置
|
||||||
|
os.makedirs(module_dir, exist_ok=True)
|
||||||
|
shutil.copy2(build_module_path, platform_module)
|
||||||
|
if os.path.exists(build_mypyc_path):
|
||||||
|
shutil.copy2(build_mypyc_path, mypyc_platform_module)
|
||||||
|
print(f" ✓ 编译成功(已从 build 目录复制): {platform_module}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(" ✗ 编译失败:找不到编译产物")
|
||||||
|
if result.stdout:
|
||||||
|
print(f" 编译输出:{stdout_text[:500]}...")
|
||||||
|
if result.stderr:
|
||||||
|
print(f" 错误信息:{stderr_text[:500]}...")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f" ✗ 编译失败,退出码: {e.returncode}")
|
||||||
|
if hasattr(e, 'stdout') and e.stdout:
|
||||||
|
try:
|
||||||
|
stdout_text = e.stdout.decode('utf-8', errors='replace') if isinstance(e.stdout, bytes) else e.stdout
|
||||||
|
print(f" 编译输出:{stdout_text[:500]}...")
|
||||||
|
except Exception:
|
||||||
|
print(f" 编译输出:{str(e.stdout)[:500]}...")
|
||||||
|
if hasattr(e, 'stderr') and e.stderr:
|
||||||
|
try:
|
||||||
|
stderr_text = e.stderr.decode('utf-8', errors='replace') if isinstance(e.stderr, bytes) else e.stderr
|
||||||
|
print(f" 错误信息:{stderr_text[:500]}...")
|
||||||
|
except Exception:
|
||||||
|
print(f" 错误信息:{str(e.stderr)[:500]}...")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ 编译失败,意外错误: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def should_skip_module(module_path):
|
||||||
|
"""检查模块是否应该被跳过编译"""
|
||||||
|
try:
|
||||||
|
with open(module_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# 检查是否包含抽象基类相关代码
|
||||||
|
if 'from abc import ABC' in content or 'from abc import abstractmethod' in content:
|
||||||
|
return True, "包含抽象基类,不适合编译"
|
||||||
|
|
||||||
|
# 检查是否包含危险的动态特性
|
||||||
|
# 注意:我们允许基本的动态特性,如getattr,但对于eval、exec等危险操作仍然阻止
|
||||||
|
if ('eval(' in content or 'exec(' in content or
|
||||||
|
'compile(' in content):
|
||||||
|
return True, "包含危险动态特性,不适合编译"
|
||||||
|
|
||||||
|
# 检查是否包含复杂的动态属性访问
|
||||||
|
if ('__dict__' in content or '__class__' in content or
|
||||||
|
'__module__' in content or '__bases__' in content):
|
||||||
|
return True, "包含复杂动态特性,不适合编译"
|
||||||
|
|
||||||
|
# 检查是否包含复杂的动态属性访问
|
||||||
|
if '.__dict__' in content or '.__class__' in content:
|
||||||
|
return True, "包含复杂动态特性,不适合编译"
|
||||||
|
|
||||||
|
return False, ""
|
||||||
|
except Exception as e:
|
||||||
|
return True, f"读取文件时出错: {e}"
|
||||||
|
|
||||||
|
def compile_all_modules():
|
||||||
|
"""编译所有指定的模块"""
|
||||||
|
print(f"\n开始编译 {len(MODULES)} 个模块 (平台: {PLATFORM})")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 验证模块文件是否存在并检查是否适合编译
|
||||||
|
valid_modules = []
|
||||||
|
skipped_modules = []
|
||||||
|
|
||||||
|
for module_path in MODULES:
|
||||||
|
if os.path.exists(module_path):
|
||||||
|
should_skip, reason = should_skip_module(module_path)
|
||||||
|
if should_skip:
|
||||||
|
print(f"跳过: {module_path} ({reason})")
|
||||||
|
skipped_modules.append((module_path, reason))
|
||||||
|
else:
|
||||||
|
valid_modules.append(module_path)
|
||||||
|
else:
|
||||||
|
print(f"警告: 模块 {module_path} 不存在,将被跳过")
|
||||||
|
|
||||||
|
print(f"\n有效模块: {len(valid_modules)}, 跳过模块: {len(skipped_modules)}")
|
||||||
|
|
||||||
|
if not valid_modules:
|
||||||
|
print("错误: 没有有效的模块可编译")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 编译模块
|
||||||
|
success_count = 0
|
||||||
|
failed_modules = []
|
||||||
|
|
||||||
|
for module_path in valid_modules:
|
||||||
|
if compile_module(module_path):
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
failed_modules.append(module_path)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"编译完成: {success_count}/{len(valid_modules)} 个模块成功")
|
||||||
|
|
||||||
|
if failed_modules:
|
||||||
|
print(f"失败模块: {failed_modules}")
|
||||||
|
|
||||||
|
if success_count == len(valid_modules):
|
||||||
|
print("✓ 所有模块编译成功")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("✗ 部分模块编译失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
# 检查 Python 版本
|
||||||
|
if not (sys.version_info.major == 3 and sys.version_info.minor >= 8):
|
||||||
|
print("警告: 推荐使用 Python 3.8+ 以获得最佳性能")
|
||||||
|
print(f"当前版本: {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
|
||||||
|
print("继续编译可能导致兼容性问题")
|
||||||
|
print()
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description='优化版跨平台 Python 模块编译脚本')
|
||||||
|
|
||||||
|
group = parser.add_mutually_exclusive_group()
|
||||||
|
group.add_argument('--compile', '-c', action='store_true', default=True,
|
||||||
|
help='编译指定的模块 (默认)')
|
||||||
|
group.add_argument('--list', '-l', action='store_true',
|
||||||
|
help='列出已编译的模块')
|
||||||
|
group.add_argument('--clean', '-k', action='store_true',
|
||||||
|
help='清理编译生成的文件')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# 检查是否安装了 mypyc
|
||||||
|
try:
|
||||||
|
import mypyc
|
||||||
|
except ImportError:
|
||||||
|
print("错误: 未安装 mypyc,请先安装: pip install mypyc")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
list_compiled_modules()
|
||||||
|
elif args.clean:
|
||||||
|
clean_compiled_files()
|
||||||
|
else:
|
||||||
|
compile_all_modules()
|
||||||
|
print("\n使用 --list 选项查看已编译的模块")
|
||||||
|
print("使用 --clean 选项清理编译文件")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
75
scripts/compile_modules.py
Normal file
75
scripts/compile_modules.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
编译模块脚本
|
||||||
|
|
||||||
|
这个脚本会单独编译每个Python模块,确保每个模块都在正确位置生成独立的.pyd文件。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import glob
|
||||||
|
from mypyc.build import mypycify
|
||||||
|
try:
|
||||||
|
from setuptools import setup
|
||||||
|
except ImportError:
|
||||||
|
from distutils.core import setup
|
||||||
|
|
||||||
|
def compile_module(module_path):
|
||||||
|
"""
|
||||||
|
编译单个模块
|
||||||
|
|
||||||
|
Args:
|
||||||
|
module_path: 要编译的Python模块路径
|
||||||
|
"""
|
||||||
|
print(f"\nCompiling {module_path}...")
|
||||||
|
try:
|
||||||
|
ext_modules = mypycify([module_path])
|
||||||
|
setup(name=f'compiled_{os.path.basename(module_path).replace(".py", "")}',
|
||||||
|
ext_modules=ext_modules)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error compiling {module_path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
主函数
|
||||||
|
"""
|
||||||
|
# 检查 Python 版本
|
||||||
|
if not (sys.version_info.major == 3 and sys.version_info.minor == 14):
|
||||||
|
print("警告: 推荐使用 Python 3.14 以获得最佳性能")
|
||||||
|
print(f"当前版本: {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
|
||||||
|
print("继续编译可能导致兼容性问题")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 要编译的模块列表
|
||||||
|
modules = [
|
||||||
|
'core/utils/json_utils.py', # JSON 处理
|
||||||
|
'core/utils/executor.py', # 代码执行引擎
|
||||||
|
'core/managers/command_manager.py', # 指令匹配和分发
|
||||||
|
'core/managers/admin_manager.py', # 管理员管理
|
||||||
|
'core/managers/permission_manager.py', # 权限管理
|
||||||
|
'core/ws.py', # WebSocket 核心
|
||||||
|
'core/managers/plugin_manager.py', # 插件管理器
|
||||||
|
'core/bot.py', # Bot 核心抽象
|
||||||
|
'core/config_loader.py', # 配置加载
|
||||||
|
]
|
||||||
|
|
||||||
|
# 自动添加 events 模型
|
||||||
|
event_models = glob.glob('models/events/*.py')
|
||||||
|
event_models = [m for m in event_models if not m.endswith('__init__.py')]
|
||||||
|
modules.extend(event_models)
|
||||||
|
|
||||||
|
print(f"Found {len(modules)} modules to compile.")
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
for module in modules:
|
||||||
|
if compile_module(module):
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
print("\n--- Compilation Summary ---")
|
||||||
|
print(f"Total modules: {len(modules)}")
|
||||||
|
print(f"Successfully compiled: {success_count}")
|
||||||
|
print(f"Failed: {len(modules) - success_count}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
137
scripts/export_requirements.py
Normal file
137
scripts/export_requirements.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
导出项目依赖到 requirements.txt 文件
|
||||||
|
|
||||||
|
支持两种模式:
|
||||||
|
1. 默认模式:导出当前虚拟环境中的所有包(pip freeze)
|
||||||
|
2. 本地模式:只导出当前项目的依赖(pip freeze --local)
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
python export_requirements.py [options]
|
||||||
|
|
||||||
|
选项:
|
||||||
|
--local, -l 只导出当前项目的依赖(推荐)
|
||||||
|
--output, -o 指定输出文件路径(默认为 requirements.txt)
|
||||||
|
--help, -h 显示帮助信息
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
def run_pip_freeze(local_mode=False):
|
||||||
|
"""
|
||||||
|
运行 pip freeze 命令
|
||||||
|
|
||||||
|
Args:
|
||||||
|
local_mode: 是否只导出当前项目依赖
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, output): 成功标志和输出内容
|
||||||
|
"""
|
||||||
|
cmd = ['pip', 'freeze']
|
||||||
|
if local_mode:
|
||||||
|
cmd.append('--local')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
encoding='utf-8'
|
||||||
|
)
|
||||||
|
return True, result.stdout
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
error_msg = f"pip freeze 命令失败,退出码: {e.returncode}\n"
|
||||||
|
if e.stderr:
|
||||||
|
error_msg += f"错误信息: {e.stderr}"
|
||||||
|
return False, error_msg
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False, "错误: 未找到 pip 命令,请确保 Python 环境已正确安装"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"未知错误: {e}"
|
||||||
|
|
||||||
|
def write_requirements_file(output_path, content):
|
||||||
|
"""
|
||||||
|
将依赖内容写入文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_path: 输出文件路径
|
||||||
|
content: 依赖内容
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
success: 是否成功
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
# 统计行数(忽略空行)
|
||||||
|
lines = [line.strip() for line in content.split('\n') if line.strip()]
|
||||||
|
return True, len(lines)
|
||||||
|
except IOError as e:
|
||||||
|
return False, f"写入文件失败: {e}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"未知错误: {e}"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
parser = argparse.ArgumentParser(description='导出项目依赖到 requirements.txt 文件')
|
||||||
|
|
||||||
|
parser.add_argument('--local', '-l', action='store_true',
|
||||||
|
help='只导出当前项目的依赖(推荐)')
|
||||||
|
parser.add_argument('--output', '-o', default='requirements.txt',
|
||||||
|
help='指定输出文件路径(默认为 requirements.txt)')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("NEO Bot 依赖导出工具")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 显示模式信息
|
||||||
|
if args.local:
|
||||||
|
print("模式: 本地模式(只导出当前项目依赖)")
|
||||||
|
else:
|
||||||
|
print("模式: 全局模式(导出所有已安装包)")
|
||||||
|
print("提示: 建议使用 --local 选项只导出当前项目依赖")
|
||||||
|
|
||||||
|
print(f"输出文件: {args.output}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 运行 pip freeze
|
||||||
|
print("正在收集依赖信息...")
|
||||||
|
success, output = run_pip_freeze(args.local)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print(f"错误: {output}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 写入文件
|
||||||
|
print("正在写入文件...")
|
||||||
|
success, result = write_requirements_file(args.output, output)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print(f"错误: {result}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
line_count = result
|
||||||
|
print("✓ 依赖导出完成")
|
||||||
|
print(f" 文件: {args.output}")
|
||||||
|
print(f" 依赖数量: {line_count} 个包")
|
||||||
|
|
||||||
|
# 显示前几个依赖(如果有)
|
||||||
|
lines = [line.strip() for line in output.split('\n') if line.strip()]
|
||||||
|
if lines:
|
||||||
|
print("\n前5个依赖:")
|
||||||
|
for i, line in enumerate(lines[:5], 1):
|
||||||
|
print(f" {i}. {line}")
|
||||||
|
if len(lines) > 5:
|
||||||
|
print(f" ... 还有 {len(lines) - 5} 个依赖")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("提示: 可以使用 pip install -r requirements.txt 安装这些依赖")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
118
setup_mypyc.py
Normal file
118
setup_mypyc.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
Mypyc 编译脚本
|
||||||
|
|
||||||
|
用于将核心 Python 模块编译为 C 扩展,以提升性能。
|
||||||
|
使用方法:
|
||||||
|
python setup_mypyc.py build_ext --inplace
|
||||||
|
|
||||||
|
注意:
|
||||||
|
1. 需要安装 C 编译器 (Windows 上需要 Visual Studio Build Tools, Linux 上需要 GCC)。
|
||||||
|
2. 编译后的文件 (.pyd 或 .so) 是平台相关的,不能跨平台复制。
|
||||||
|
3. 建议在部署的目标环境 (Linux) 上运行此脚本。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# 基础模块列表
|
||||||
|
# 注意:Mypyc 对动态特性支持有限,只选择计算密集或类型明确的模块
|
||||||
|
modules = [
|
||||||
|
# 工具模块
|
||||||
|
'core/utils/json_utils.py', # JSON 处理
|
||||||
|
'core/utils/executor.py', # 代码执行引擎
|
||||||
|
'core/utils/singleton.py', # 单例模式基类
|
||||||
|
'core/utils/exceptions.py', # 自定义异常
|
||||||
|
'core/utils/logger.py', # 日志模块
|
||||||
|
|
||||||
|
# 核心管理模块
|
||||||
|
'core/managers/command_manager.py', # 指令匹配和分发
|
||||||
|
'core/managers/admin_manager.py', # 管理员管理
|
||||||
|
'core/managers/permission_manager.py', # 权限管理
|
||||||
|
'core/managers/plugin_manager.py', # 插件管理器
|
||||||
|
|
||||||
|
# 核心基础模块
|
||||||
|
'core/ws.py', # WebSocket 核心
|
||||||
|
'core/bot.py', # Bot 核心抽象
|
||||||
|
'core/config_loader.py', # 配置加载
|
||||||
|
'core/config_models.py', # 配置模型
|
||||||
|
'core/permission.py', # 权限枚举
|
||||||
|
|
||||||
|
# API 基础模块
|
||||||
|
'core/api/base.py', # API 基础类
|
||||||
|
|
||||||
|
# 数据模型(适合编译的高频使用数据类)
|
||||||
|
'models/message.py', # 消息段模型
|
||||||
|
'models/sender.py', # 发送者模型
|
||||||
|
'models/objects.py', # API 响应数据模型
|
||||||
|
]
|
||||||
|
|
||||||
|
# 注意:事件模型文件暂时不编译,因为它们与 mypyc 存在兼容性问题
|
||||||
|
# mypyc 对某些数据类特性和继承结构的支持有限,会导致运行时错误
|
||||||
|
# event_models = glob.glob('models/events/*.py')
|
||||||
|
# event_models = [m for m in event_models if not m.endswith('__init__.py')]
|
||||||
|
# modules.extend(event_models)
|
||||||
|
|
||||||
|
# 确保文件存在
|
||||||
|
valid_modules = []
|
||||||
|
for m in modules:
|
||||||
|
if os.path.exists(m):
|
||||||
|
valid_modules.append(m)
|
||||||
|
else:
|
||||||
|
print(f"Warning: Module {m} not found, skipping.")
|
||||||
|
|
||||||
|
if not valid_modules:
|
||||||
|
print("No valid modules found to compile.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Compiling the following modules with mypyc: {valid_modules}")
|
||||||
|
|
||||||
|
# 使用 mypyc 命令行工具单独编译每个模块,确保位置正确
|
||||||
|
success_count = 0
|
||||||
|
for module_path in valid_modules:
|
||||||
|
print(f"\nCompiling {module_path}...")
|
||||||
|
try:
|
||||||
|
# 直接调用 mypyc 命令行工具
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, '-m', 'mypyc', module_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证编译产物是否在正确位置
|
||||||
|
module_name = module_path.replace('.py', '')
|
||||||
|
pyd_path = module_name + '.cp314-win_amd64.pyd'
|
||||||
|
mypyc_path = module_name + '__mypyc.cp314-win_amd64.pyd'
|
||||||
|
|
||||||
|
if os.path.exists(pyd_path):
|
||||||
|
print(f" ✓ Compiled successfully: {pyd_path}")
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
# 检查 build 目录中是否有编译产物
|
||||||
|
build_pyd_path = os.path.join('build', 'lib.win-amd64-cpython-314', pyd_path)
|
||||||
|
if os.path.exists(build_pyd_path):
|
||||||
|
# 如果在 build 目录中,复制到正确位置
|
||||||
|
os.makedirs(os.path.dirname(pyd_path), exist_ok=True)
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(build_pyd_path, pyd_path)
|
||||||
|
shutil.copy2(os.path.join('build', 'lib.win-amd64-cpython-314', mypyc_path), mypyc_path)
|
||||||
|
print(f" ✓ Compiled successfully (copied from build directory): {pyd_path}")
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
print(" ✗ Compiled but cannot find pyd file")
|
||||||
|
print(f" Build output:\n{result.stdout[:500]}...")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f" ✗ Compilation failed with exit code {e.returncode}")
|
||||||
|
print(f" Error:\n{e.stderr[:500]}...")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Unexpected error: {e}")
|
||||||
|
|
||||||
|
print("\n--- Compilation Summary ---")
|
||||||
|
print(f"Total modules: {len(valid_modules)}")
|
||||||
|
print(f"Successfully compiled: {success_count}")
|
||||||
|
print(f"Failed: {len(valid_modules) - success_count}")
|
||||||
|
|
||||||
|
if success_count == 0:
|
||||||
|
print("No modules were compiled successfully. Exiting with error.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
237
templates/help.html
Normal file
237
templates/help.html
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CalglauBot Menu</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
/* 调整为更深邃、高对比度的配色 */
|
||||||
|
--bg-color: #0f172a; /* 深蓝黑背景 */
|
||||||
|
--window-bg: rgba(30, 41, 59, 0.85); /* 窗口背景,增加不透明度以提高文字可读性 */
|
||||||
|
--border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
|
--accent: #6366f1; /* 核心强调色 - 靛蓝 */
|
||||||
|
--accent-glow: rgba(99, 102, 241, 0.4);
|
||||||
|
|
||||||
|
--text-title: #f8fafc;
|
||||||
|
--text-desc: #94a3b8;
|
||||||
|
--text-cmd: #a5f3fc; /* 指令高亮色 - 浅青 */
|
||||||
|
|
||||||
|
--card-bg: rgba(0, 0, 0, 0.2);
|
||||||
|
--cmd-bg: #0b1120; /* 代码块深色背景 */
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Noto Sans SC', system-ui, sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-title);
|
||||||
|
/* 居中布局 */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 窗口容器 */
|
||||||
|
.window {
|
||||||
|
width: 800px; /* 稍微收窄一点,更像手机/卡片比例 */
|
||||||
|
background: var(--window-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部标题栏 */
|
||||||
|
.header {
|
||||||
|
padding: 24px 32px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dots { display: flex; gap: 8px; }
|
||||||
|
.dot { width: 12px; height: 12px; border-radius: 50%; }
|
||||||
|
.red { background: #ef4444; }
|
||||||
|
.yellow { background: #f59e0b; }
|
||||||
|
.green { background: #10b981; }
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-desc);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容区域 */
|
||||||
|
.content {
|
||||||
|
padding: 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px; /* 卡片之间的间距 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.page-title h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
background: linear-gradient(to right, #fff, #94a3b8);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
.page-title p {
|
||||||
|
color: var(--text-desc);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 插件卡片 - 改为单列宽卡片 */
|
||||||
|
.plugin-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column; /* 垂直排列 */
|
||||||
|
gap: 16px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧装饰线 */
|
||||||
|
.plugin-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 插件头部信息 */
|
||||||
|
.card-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 装饰性Tag */
|
||||||
|
.plugin-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
color: #818cf8;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-desc);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 指令代码块 - 核心修改区域 */
|
||||||
|
.cmd-block {
|
||||||
|
background: var(--cmd-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-cmd);
|
||||||
|
|
||||||
|
/* 处理长文本的关键 CSS */
|
||||||
|
white-space: pre-wrap; /* 保留换行符,且允许自动换行 */
|
||||||
|
word-wrap: break-word; /* 允许长单词换行 */
|
||||||
|
overflow-wrap: break-word; /* 标准写法 */
|
||||||
|
word-break: break-word; /* 兼容性写法 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模拟终端提示符 */
|
||||||
|
.cmd-block::before {
|
||||||
|
content: '$ ';
|
||||||
|
color: #64748b;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页脚 */
|
||||||
|
.footer {
|
||||||
|
padding: 20px 32px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="window">
|
||||||
|
<!-- 窗口栏 -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="dots">
|
||||||
|
<div class="dot red"></div>
|
||||||
|
<div class="dot yellow"></div>
|
||||||
|
<div class="dot green"></div>
|
||||||
|
</div>
|
||||||
|
<div class="title">NeoBot System</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div class="page-title">
|
||||||
|
<h1>功能中心</h1>
|
||||||
|
<p>Dashboard & Command List · {{ plugins|length }} Modules Loaded</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 插件列表 - 单列流式布局 -->
|
||||||
|
{% for plugin in plugins %}
|
||||||
|
<div class="plugin-card">
|
||||||
|
<div class="card-top">
|
||||||
|
<div class="plugin-name">
|
||||||
|
{{ plugin.name }}
|
||||||
|
<span class="plugin-tag">Plugin</span>
|
||||||
|
</div>
|
||||||
|
<div class="plugin-desc">
|
||||||
|
{{ plugin.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 代码块:宽度占满容器,高度自适应 -->
|
||||||
|
<div class="cmd-block">{{ plugin.usage }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
Generated by NeoBot Render Engine
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
79
test_performance_simple.py
Normal file
79
test_performance_simple.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
简单的性能分析功能测试脚本
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from core.utils.performance import (
|
||||||
|
timeit,
|
||||||
|
profile,
|
||||||
|
performance_stats
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print("性能分析功能测试")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
# 重置全局性能统计
|
||||||
|
performance_stats.reset()
|
||||||
|
|
||||||
|
# 测试1: 同步函数的时间测量
|
||||||
|
@timeit
|
||||||
|
def sync_test():
|
||||||
|
"""同步测试函数"""
|
||||||
|
time.sleep(0.1)
|
||||||
|
return "sync done"
|
||||||
|
|
||||||
|
# 测试2: 异步函数的时间测量
|
||||||
|
@timeit
|
||||||
|
async def async_test():
|
||||||
|
"""异步测试函数"""
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
return "async done"
|
||||||
|
|
||||||
|
# 异步主函数
|
||||||
|
async def main():
|
||||||
|
# 同步函数测试
|
||||||
|
print("执行同步函数...")
|
||||||
|
sync_result = sync_test()
|
||||||
|
print(f"同步函数结果: {sync_result}")
|
||||||
|
|
||||||
|
# 异步函数测试
|
||||||
|
print("\n执行异步函数...")
|
||||||
|
async_result = await async_test()
|
||||||
|
print(f"异步函数结果: {async_result}")
|
||||||
|
|
||||||
|
# 测试3: 详细性能分析
|
||||||
|
print("\n2. 测试性能分析上下文管理器:")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
with profile(enabled=False): # 禁用实际分析以避免输出太多
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
print("性能分析上下文管理器测试完成")
|
||||||
|
|
||||||
|
# 测试4: 性能统计报告
|
||||||
|
print("\n3. 测试性能统计报告:")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
# 执行多次函数调用
|
||||||
|
for _ in range(3):
|
||||||
|
sync_test()
|
||||||
|
await async_test()
|
||||||
|
|
||||||
|
# 生成并打印性能报告
|
||||||
|
print("\n性能统计报告:")
|
||||||
|
print(performance_stats.report())
|
||||||
|
|
||||||
|
|
||||||
|
# 执行测试
|
||||||
|
print("\n1. 测试时间测量装饰器:")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
# 使用 asyncio.run() 执行异步主函数
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("所有测试完成!")
|
||||||
|
print("=" * 80)
|
||||||
250
tests/test_api.py
Normal file
250
tests/test_api.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Import all API classes
|
||||||
|
from core.api.base import BaseAPI
|
||||||
|
from core.api.account import AccountAPI
|
||||||
|
from core.api.friend import FriendAPI
|
||||||
|
from core.api.group import GroupAPI
|
||||||
|
from core.api.media import MediaAPI
|
||||||
|
from core.api.message import MessageAPI
|
||||||
|
from models.objects import (
|
||||||
|
LoginInfo, VersionInfo, Status, StrangerInfo, FriendInfo,
|
||||||
|
GroupInfo, GroupMemberInfo, GroupHonorInfo
|
||||||
|
)
|
||||||
|
from models.message import MessageSegment
|
||||||
|
|
||||||
|
|
||||||
|
# Fixture for a mock websocket client
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_ws():
|
||||||
|
"""模拟一个 WebSocket 客户端。"""
|
||||||
|
return AsyncMock()
|
||||||
|
|
||||||
|
# Fixture for a comprehensive API client instance
|
||||||
|
@pytest.fixture
|
||||||
|
def api_client(mock_ws):
|
||||||
|
"""
|
||||||
|
创建一个包含所有 API Mixin 的测试客户端实例。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mock_ws: 模拟的 WebSocket 客户端。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
一个功能完备的 API 客户端实例。
|
||||||
|
"""
|
||||||
|
# Combine all mixins into one class for testing
|
||||||
|
class FullAPI(AccountAPI, FriendAPI, GroupAPI, MediaAPI, MessageAPI):
|
||||||
|
def __init__(self, ws_client, self_id):
|
||||||
|
super().__init__(ws_client, self_id)
|
||||||
|
|
||||||
|
return FullAPI(mock_ws, 12345)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Test BaseAPI ---
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_base_api_call_success(mock_ws):
|
||||||
|
"""测试 BaseAPI 成功调用。"""
|
||||||
|
base_api = BaseAPI(mock_ws, 12345)
|
||||||
|
mock_ws.call_api.return_value = {"status": "ok", "data": {"key": "value"}}
|
||||||
|
|
||||||
|
result = await base_api.call_api("test_action", {"param": 1})
|
||||||
|
|
||||||
|
mock_ws.call_api.assert_called_once_with("test_action", {"param": 1})
|
||||||
|
assert result == {"key": "value"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_base_api_call_failed_status(mock_ws):
|
||||||
|
"""测试 BaseAPI 调用返回失败状态。"""
|
||||||
|
base_api = BaseAPI(mock_ws, 12345)
|
||||||
|
mock_ws.call_api.return_value = {"status": "failed", "data": None}
|
||||||
|
|
||||||
|
result = await base_api.call_api("test_action")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_base_api_call_exception(mock_ws):
|
||||||
|
"""测试 BaseAPI 调用时发生异常。"""
|
||||||
|
base_api = BaseAPI(mock_ws, 12345)
|
||||||
|
mock_ws.call_api.side_effect = Exception("Network error")
|
||||||
|
|
||||||
|
with pytest.raises(Exception, match="Network error"):
|
||||||
|
await base_api.call_api("test_action")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Test AccountAPI ---
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_login_info_no_cache(api_client):
|
||||||
|
"""测试 get_login_info 在无缓存时能正确调用 API 并设置缓存。"""
|
||||||
|
api_client.call_api = AsyncMock(return_value={"user_id": 123, "nickname": "test"})
|
||||||
|
with patch("core.managers.redis_manager.redis_manager.get", new_callable=AsyncMock) as mock_redis_get, \
|
||||||
|
patch("core.managers.redis_manager.redis_manager.set", new_callable=AsyncMock) as mock_redis_set:
|
||||||
|
mock_redis_get.return_value = None
|
||||||
|
|
||||||
|
info = await api_client.get_login_info()
|
||||||
|
|
||||||
|
api_client.call_api.assert_called_once_with("get_login_info")
|
||||||
|
mock_redis_set.assert_called_once()
|
||||||
|
assert isinstance(info, LoginInfo)
|
||||||
|
assert info.user_id == 123
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_login_info_with_cache(api_client):
|
||||||
|
"""测试 get_login_info 在有缓存时直接返回缓存数据。"""
|
||||||
|
cached_data = json.dumps({"user_id": 123, "nickname": "test"})
|
||||||
|
api_client.call_api = AsyncMock()
|
||||||
|
with patch("core.managers.redis_manager.redis_manager.get", new_callable=AsyncMock) as mock_redis_get:
|
||||||
|
mock_redis_get.return_value = cached_data
|
||||||
|
|
||||||
|
info = await api_client.get_login_info()
|
||||||
|
|
||||||
|
api_client.call_api.assert_not_called()
|
||||||
|
assert isinstance(info, LoginInfo)
|
||||||
|
assert info.user_id == 123
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_version_info(api_client):
|
||||||
|
"""测试 get_version_info 能正确解析 API 返回。"""
|
||||||
|
api_client.call_api = AsyncMock(return_value={"app_name": "test_app", "app_version": "1.0", "protocol_version": "v11"})
|
||||||
|
info = await api_client.get_version_info()
|
||||||
|
assert isinstance(info, VersionInfo)
|
||||||
|
assert info.app_name == "test_app"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_status(api_client):
|
||||||
|
"""测试 get_status 能正确解析 API 返回。"""
|
||||||
|
api_client.call_api = AsyncMock(return_value={"online": True, "good": True})
|
||||||
|
status = await api_client.get_status()
|
||||||
|
assert isinstance(status, Status)
|
||||||
|
assert status.online is True
|
||||||
|
|
||||||
|
# --- Test FriendAPI ---
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_like(api_client):
|
||||||
|
"""测试 send_like 方法能正确调用 API。"""
|
||||||
|
api_client.call_api = AsyncMock()
|
||||||
|
await api_client.send_like(54321, 5)
|
||||||
|
api_client.call_api.assert_called_once_with("send_like", {"user_id": 54321, "times": 5})
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_friend_add_request(api_client):
|
||||||
|
"""测试 set_friend_add_request 方法能正确调用 API。"""
|
||||||
|
api_client.call_api = AsyncMock()
|
||||||
|
await api_client.set_friend_add_request("flag_test", approve=False)
|
||||||
|
api_client.call_api.assert_called_once_with("set_friend_add_request", {"flag": "flag_test", "approve": False, "remark": ""})
|
||||||
|
|
||||||
|
# --- Test GroupAPI ---
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_group_kick(api_client):
|
||||||
|
"""测试 set_group_kick 方法能正确调用 API。"""
|
||||||
|
api_client.call_api = AsyncMock()
|
||||||
|
await api_client.set_group_kick(111, 222, True)
|
||||||
|
api_client.call_api.assert_called_once_with("set_group_kick", {"group_id": 111, "user_id": 222, "reject_add_request": True})
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_group_anonymous_ban(api_client):
|
||||||
|
"""测试 set_group_anonymous_ban 方法能正确调用 API。"""
|
||||||
|
api_client.call_api = AsyncMock()
|
||||||
|
await api_client.set_group_anonymous_ban(111, flag="anon_flag")
|
||||||
|
api_client.call_api.assert_called_once_with("set_group_anonymous_ban", {"group_id": 111, "duration": 1800, "flag": "anon_flag"})
|
||||||
|
|
||||||
|
# --- Test MediaAPI ---
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_can_send_image(api_client):
|
||||||
|
"""测试 can_send_image 方法能正确调用 API。"""
|
||||||
|
api_client.call_api = AsyncMock()
|
||||||
|
await api_client.can_send_image()
|
||||||
|
api_client.call_api.assert_called_once_with(action="can_send_image")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_image(api_client):
|
||||||
|
"""测试 get_image 方法能正确调用 API。"""
|
||||||
|
api_client.call_api = AsyncMock()
|
||||||
|
await api_client.get_image("file.jpg")
|
||||||
|
api_client.call_api.assert_called_once_with(action="get_image", params={"file": "file.jpg"})
|
||||||
|
|
||||||
|
# --- Test MessageAPI ---
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_group_msg_str(api_client):
|
||||||
|
"""测试 send_group_msg 发送字符串消息。"""
|
||||||
|
api_client.call_api = AsyncMock()
|
||||||
|
await api_client.send_group_msg(111, "hello")
|
||||||
|
api_client.call_api.assert_called_once_with("send_group_msg", {"group_id": 111, "message": "hello", "auto_escape": False})
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_group_msg_segment(api_client):
|
||||||
|
"""测试 send_group_msg 发送单个消息段。"""
|
||||||
|
api_client.call_api = AsyncMock()
|
||||||
|
segment = MessageSegment.text("hello")
|
||||||
|
await api_client.send_group_msg(111, segment)
|
||||||
|
api_client.call_api.assert_called_once_with("send_group_msg", {"group_id": 111, "message": [{"type": "text", "data": {"text": "hello"}}], "auto_escape": False})
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_group_msg_list_segments(api_client):
|
||||||
|
"""测试 send_group_msg 发送消息段列表。"""
|
||||||
|
api_client.call_api = AsyncMock()
|
||||||
|
segments = [MessageSegment.text("hello"), MessageSegment.image("file.jpg")]
|
||||||
|
await api_client.send_group_msg(111, segments)
|
||||||
|
api_client.call_api.assert_called_once_with("send_group_msg", {"group_id": 111, "message": [
|
||||||
|
{"type": "text", "data": {"text": "hello"}},
|
||||||
|
{"type": "image", "data": {"file": "file.jpg", "cache": "1", "proxy": "1"}}
|
||||||
|
], "auto_escape": False})
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_reply(api_client):
|
||||||
|
"""测试 send 方法在事件有 reply 方法时优先调用 reply。"""
|
||||||
|
mock_event = MagicMock()
|
||||||
|
mock_event.reply = AsyncMock()
|
||||||
|
# 确保没有 user_id 和 group_id,以验证 reply 路径被优先选择
|
||||||
|
delattr(mock_event, "user_id")
|
||||||
|
delattr(mock_event, "group_id")
|
||||||
|
|
||||||
|
await api_client.send(mock_event, "hello reply")
|
||||||
|
mock_event.reply.assert_called_once_with("hello reply", False)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_auto_private(api_client):
|
||||||
|
"""测试 send 方法能根据事件自动判断并发送私聊消息。"""
|
||||||
|
mock_event = MagicMock()
|
||||||
|
mock_event.user_id = 123
|
||||||
|
delattr(mock_event, "group_id") # 确保没有 group_id
|
||||||
|
delattr(mock_event, "reply") # 确保没有 reply 方法
|
||||||
|
|
||||||
|
api_client.send_private_msg = AsyncMock()
|
||||||
|
await api_client.send(mock_event, "hello private")
|
||||||
|
api_client.send_private_msg.assert_called_once_with(123, "hello private", False)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_auto_group(api_client):
|
||||||
|
"""测试 send 方法能根据事件自动判断并发送群聊消息。"""
|
||||||
|
mock_event = MagicMock()
|
||||||
|
mock_event.user_id = 123
|
||||||
|
mock_event.group_id = 456
|
||||||
|
delattr(mock_event, "reply")
|
||||||
|
|
||||||
|
api_client.send_group_msg = AsyncMock()
|
||||||
|
await api_client.send(mock_event, "hello group")
|
||||||
|
api_client.send_group_msg.assert_called_once_with(456, "hello group", False)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_forward_msg_valid(api_client):
|
||||||
|
"""测试 get_forward_msg 能正确解析有效的合并转发消息。"""
|
||||||
|
api_client.call_api = AsyncMock(return_value={"data": [{"content": "node1"}]})
|
||||||
|
nodes = await api_client.get_forward_msg("forward_id")
|
||||||
|
assert nodes == [{"content": "node1"}]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_forward_msg_nested(api_client):
|
||||||
|
"""测试 get_forward_msg 能正确解析嵌套在 'messages' 键下的消息。"""
|
||||||
|
api_client.call_api = AsyncMock(return_value={"data": {"messages": [{"content": "node2"}]}})
|
||||||
|
nodes = await api_client.get_forward_msg("forward_id_nested")
|
||||||
|
assert nodes == [{"content": "node2"}]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_forward_msg_invalid(api_client):
|
||||||
|
"""测试 get_forward_msg 在无效数据结构下抛出异常。"""
|
||||||
|
api_client.call_api = AsyncMock(return_value={"data": "not a list or dict"})
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await api_client.get_forward_msg("forward_id_invalid")
|
||||||
37
tests/test_basic.py
Normal file
37
tests/test_basic.py
Normal file
@@ -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 不存在,跳过配置加载测试")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user