This commit is contained in:
baby20162016
2026-01-20 09:52:32 +08:00
115 changed files with 13739 additions and 1524 deletions

9
.gitignore vendored
View File

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

@@ -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.14Redis 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
View 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()

View File

@@ -1,7 +0,0 @@
[napcat_ws]
uri = "ws://114.66.58.203:3001"
token = "&d_VTfksE%}ul?_Y"
reconnect_interval = 5
[bot]
command = ["/"]

View File

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

View File

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

View File

@@ -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",
] ]

View File

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

View File

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

View File

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

View File

@@ -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")
return [GroupInfo(**item) for item in res]
# 增加日志记录 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]
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
View 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})

View 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
} }

View File

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

View File

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

View File

@@ -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
with open(self.path, "rb") as f: try:
self._data = tomllib.load(f) self.logger.info(f"正在从 {self.path} 加载配置...")
with open(self.path, "rb") as 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
View 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
View File

@@ -0,0 +1,3 @@
{
"admins": [2221577113]
}

View File

@@ -0,0 +1,3 @@
{
"users": {}
}

View File

View 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
View 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",
]

View 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()

View 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()
# 启动 Chromiumheadless=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()

View 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)

File diff suppressed because one or more lines are too long

View 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()

View 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

View 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)

View 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
View 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]

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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): 管理机器人自己的状态

View 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 请求都用连接池

View 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 的错误处理机制提供了:
- 完整的自定义异常类体系
- 统一的错误码系统
- 一致的错误响应格式
- 增强的日志记录功能
- 全局异常捕获和友好提示
这些功能确保了系统在各种异常情况下都能提供清晰、一致的错误信息,便于开发和维护。

View 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" 发到了群里。
* 恩。。。
至此,一个完整的事件流转闭环就完成了。理解这个流程后,您就能明白框架是如何为开发者提供便捷接口的。

View 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` 的支持得到改善后,会重新尝试将事件模型加入编译列表,以实现极致的性能。
通过这种方式,我们在保证核心模块性能的同时,也维持了项目的稳定性和可维护性。

View 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
View 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不多赘述了。。。
改完后重启 NapCatQQBot 应该就能收到消息了。

95
docs/getting-started.md Normal file
View 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 ToolsLinux 上需要 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
View 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.

View 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("出错了,请稍后再试。")
```

View 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`。框架会自动完成权限检查,如果失败,甚至不会执行我们的函数,并会发送一条权限不足的消息。这就是依赖注入的强大之处。

View 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
View 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 服务。

219
main.py
View File

@@ -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__":
asyncio.run(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())
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)

View File

@@ -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",
] ]

View File

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

View File

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

View File

@@ -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):
""" """
回复群聊消息 回复群聊消息

View File

@@ -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
"""子类型:启用、禁用、连接""" """子类型:启用、禁用、连接"""

View File

@@ -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):
""" """
精华消息变动通知 精华消息变动通知

View File

@@ -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):
""" """
加群请求/邀请事件 加群请求/邀请事件

View File

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

View File

@@ -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:
""" """
群荣誉信息 群荣誉信息

View File

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

View 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
View File

View File

@@ -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:
return await event.reply("无效的QQ号。")
target = args[1].lower()
value = args[2]
if target == "member":
# operate on members list
if action == "add":
if str(value) in members:
await event.reply(f"成员 {value} 已存在,无需重复添加。")
return
members.append(str(value))
data.set("members", members)
await event.reply(f"已添加群成员权限:{value}")
return
else: # remove
if str(value) not in members:
await event.reply(f"成员 {value} 不在权限列表中。")
return
members = [m for m in members if m != str(value)]
data.set("members", members)
await event.reply(f"已移除群成员权限:{value}")
return
if target == "group":
if action == "add":
if str(value) in groups:
await event.reply(f"{value} 已存在,无需重复添加。")
return
groups.append(str(value))
data.set("groups", groups)
await event.reply(f"已添加群权限:{value}")
return
else: # remove
if str(value) not in groups:
await event.reply(f"{value} 不在权限列表中。")
return
groups = [g for g in groups if g != str(value)]
data.set("groups", groups)
await event.reply(f"已移除群权限:{value}")
return
await event.reply("未知目标类型,请使用 member 或 group")
return return
if action == "clear": # 安全检查
if len(args) < 2: if target_user_id == event.user_id:
await event.reply("参数错误,示例:/admin clear member") await event.reply("你不能操作自己的权限。")
return return
target = args[1].lower() if target_user_id == event.self_id:
if target == "member": await event.reply("你不能操作机器人自身的权限。")
data.set("members", [])
await event.reply("已清空群成员权限。")
return
if target == "group":
data.set("groups", [])
await event.reply("已清空群权限。")
return
if target == "all":
data.clear()
await event.reply("已清空所有权限。")
return
await event.reply("未知清空目标,请使用 member/group/all")
return return
await event.reply("未知指令,使用 /admin 查看帮助") # 根据子命令分发
if subcommand == "add_admin":
permission_manager.set_user_permission(target_user_id, Permission.ADMIN)
await event.reply(f"已成功添加管理员:{target_user_id}")
elif subcommand == "remove_admin":
permission_manager.set_user_permission(target_user_id, Permission.USER)
await event.reply(f"已成功移除管理员:{target_user_id}")
elif subcommand == "add_op":
permission_manager.set_user_permission(target_user_id, Permission.OP)
await event.reply(f"已成功添加操作员:{target_user_id}")
elif subcommand == "remove_op":
permission_manager.set_user_permission(target_user_id, Permission.USER)
await event.reply(f"已成功移除操作员:{target_user_id}")
else:
await event.reply(f"未知的子命令 '{subcommand}'\n\n{__plugin_meta__['usage']}")
async def list_permissions(event: MessageEvent):
"""
列出所有具有特殊权限(管理员和操作员)的用户。
"""
permissions = await permission_manager.get_all_user_permissions()
if not permissions:
await event.reply("当前没有配置任何特殊权限的用户。")
return
admins = {uid for uid, p in permissions.items() if p == 'admin'}
ops = {uid for uid, p in permissions.items() if p == 'op'}
reply_msg = "当前权限列表:\n"
if admins:
reply_msg += "--- 管理员 ---\n"
for user_id in admins:
reply_msg += f"- {user_id}\n"
if ops:
reply_msg += "--- 操作员 ---\n"
for user_id in ops:
reply_msg += f"- {user_id}\n"
await event.reply(reply_msg.strip())

53
plugins/auto_approve.py Normal file
View 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
View 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
View 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
View 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., &#91; -> [)。
2. 移除整个代码块的公共前导缩进,以修复因复制粘贴导致的多余缩进。
:param code: 原始代码字符串。
:return: 规范化后的代码字符串。
"""
# 1. 解码 HTML 实体
code = html.unescape(code)
# 2. 移除公共前导缩进
try:
code = textwrap.dedent(code)
except Exception:
# 在某些情况下例如不一致的缩进dedent 可能会失败,
# 但我们不希望因此中断流程,所以捕获异常并继续。
pass
return code.strip()
@matcher.command("py", "python", "code_py", permission=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 # 消费事件,防止其他处理器响应

View File

@@ -1 +0,0 @@
{}

391
plugins/douyin_parser.py Normal file
View 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

View File

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

View File

@@ -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
user_id2 = int(message[1].data.get("qq", 0)) try:
user_id2 = int(message[1].data.get("qq", 0))
except Exception:
return
if user_id1 == user_id2: 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View 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 "这个消息不应该被看到。"

View File

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

@@ -0,0 +1,2 @@
[pytest]
pythonpath = .

4
requirements-dev.txt Normal file
View File

@@ -0,0 +1,4 @@
# 开发依赖
pyinstrument>=4.5.0 # 性能分析工具,支持异步代码
memory-profiler>=0.61.0 # 内存分析工具
psutil>=5.9.8 # 系统资源监控

View File

@@ -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
View 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
View 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()

View 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()

View 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()

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

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