Compare commits

...

33 Commits

Author SHA1 Message Date
pre-commit-ci[bot]
c0a92bff46 🚨 auto fix by pre-commit hooks
Some checks failed
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
2025-08-04 16:56:48 +00:00
pre-commit-ci[bot]
2445cd8515
⬆️ auto update by pre-commit hooks
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.2 → v0.12.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.2...v0.12.7)
2025-08-04 16:56:40 +00:00
Rumio
7c153721f0
♻️ refactor!: 重构LLM服务架构并统一Pydantic兼容性处理 (#2002)
Some checks failed
检查bot是否运行正常 / bot check (push) Waiting to run
Sequential Lint and Type Check / ruff-call (push) Waiting to run
Sequential Lint and Type Check / pyright-call (push) Blocked by required conditions
Release Drafter / Update Release Draft (push) Waiting to run
Force Sync to Aliyun / sync (push) Waiting to run
Update Version / update-version (push) Waiting to run
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
* ♻️ refactor(pydantic): 提取 Pydantic 兼容函数到独立模块

* ♻️ refactor!(llm): 重构LLM服务,引入现代化工具和执行器架构

🏗️ **架构变更**
- 引入ToolProvider/ToolExecutable协议,取代ToolRegistry
- 新增LLMToolExecutor,分离工具调用逻辑
- 新增BaseMemory抽象,解耦会话状态管理

🔄 **API重构**
- 移除:analyze, analyze_multimodal, pipeline_chat
- 新增:generate_structured, run_with_tools
- 重构:chat, search, code变为无状态调用

🛠️ **工具系统**
- 新增@function_tool装饰器
- 统一工具定义到ToolExecutable协议
- 移除MCP工具系统和mcp_tools.json

---------

Co-authored-by: webjoin111 <455457521@qq.com>
2025-08-04 23:36:12 +08:00
molanp
59d72c3b3d
feat(admin): 增加封禁用户理由并优化相关逻辑 (#1992)
Some checks failed
检查bot是否运行正常 / bot check (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Update Version / update-version (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
* feat(admin): 增加封禁用户理由并优化相关逻辑

- 在 ban 命令中添加了 -r 或 --reason 选项,用于指定封禁理由
- 优化了 ban 命令的参数解析和处理逻辑
- 更新了数据库模型,增加了 ban_reason 字段用于存储封禁理由
- 修复了部分逻辑错误,如永久封禁的处理方式

* 🚨 auto fix by pre-commit hooks

* refactor(ban): 优化 ban 命令和相关功能

- 修复 ban 命令中的 reason 参数可选标记
- 完善恶意触发检测和用户昵称违规的禁言信息
- 统一禁言操作的参数顺序,提高代码可读性

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-07-29 17:22:27 +08:00
AkashiCoin
c571bfb133
🎉 chore(version): Update version to v0.2.4-da6d5b4 (#1822)
Some checks failed
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
2025-07-25 10:50:23 +08:00
HibiKier
da6d5b4be4
🐛 修复bot个人介绍重载后不重新读取个人介绍文件 (#1990)
Some checks failed
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
Sequential Lint and Type Check / ruff-call (push) Waiting to run
Sequential Lint and Type Check / pyright-call (push) Blocked by required conditions
Release Drafter / Update Release Draft (push) Waiting to run
Force Sync to Aliyun / sync (push) Waiting to run
检查bot是否运行正常 / bot check (push) Has been cancelled
Update Version / update-version (push) Has been cancelled
2025-07-24 15:59:28 +08:00
HibiKier
62fac483f2
feat(workflow): 新增阿里云强制同步工作流配置 (#1991) 2025-07-24 15:59:17 +08:00
molanp
61251ce137
fix(zhenxun): 修复群员昵称中包含特殊字符导致的更新异常 (#1988)
- 在更新群员信息时,使用正则表达式过滤掉昵称中的控制字符
- 优化了 MemberUpdateManage 类中的代码,提高了数据的兼容性和安全性
2025-07-17 19:49:17 +08:00
xuanerwa
30fe5a5393
feat(aliyun): 添加阿里云相关配置和文件操作功能 (#1985)
*  feat(aliyun): 添加阿里云相关配置和文件操作功能

* 🐛 fix bug

* 🎨 更新requirements

* ⬆️ Update poetry.lock

*  feat(aliyun): 添加阿里云获取commit方法

* 更新env pyproject

---------

Co-authored-by: HibiKier <775757368@qq.com>
Co-authored-by: HibiKier <45528451+HibiKier@users.noreply.github.com>
2025-07-17 19:48:33 +08:00
molanp
3cf7c1d237
fix(plugin_store): 修复递代错误 (#1986)
- 在查找插件时使用 next() 函数的默认值 None,避免抛出 StopIteration 异常
- 增加对未找到插件的错误处理,返回相应的错误信息
- 优化了插件查找逻辑,提高了代码的健壮性和可读性

Co-authored-by: HibiKier <45528451+HibiKier@users.noreply.github.com>
2025-07-17 19:01:02 +08:00
HibiKier
91f35ad63a
feat(exceptions): 将DbUrlIsNode异常类继承自HookPriorityException (#1987) 2025-07-17 18:58:22 +08:00
Rumio
a0b57b6bea
feat(help): 引入LLM智能帮助并优化其功能 (#1982)
- 【新功能】引入LLM智能帮助功能,当传统帮助未找到结果时,可自动调用LLM提供智能回复
- 【配置项】新增多项LLM帮助相关配置:
    - `ENABLE_LLM_HELPER`: 控制LLM智能帮助的启用与禁用
    - `DEFAULT_LLM_MODEL`: 配置智能帮助使用的LLM模型
    - `LLM_HELPER_STYLE`: 设置LLM回复的口吻或风格
    - `LLM_HELPER_REPLY_AS_IMAGE_THRESHOLD`: 定义LLM回复字数超过此阈值时转为图片发送
- 【逻辑优化】帮助指令处理流程调整:
    - 优先尝试传统插件帮助查询
    - 若传统查询无结果且LLM智能帮助已启用,则调用LLM进行自然语言回答

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: HibiKier <45528451+HibiKier@users.noreply.github.com>
2025-07-16 13:48:13 +08:00
HibiKier
205f4ff1fa
添加bot画像
*  新增自我介绍功能及自动发送图片支持

- 在 bot_profile.py 中实现自我介绍指令及重载功能
- 在 group_handle 中添加自动发送自我介绍图片的逻辑
- 在 fg_request 中实现添加好友时自动发送自我介绍图片
- 新增 bot_profile_manager.py 管理 BOT 自我介绍及图片生成
- 更新 models.py 以支持插件自我介绍和注意事项字段

* 🎨 调整管理帮助宽度

*  更新数据访问层,优化获取数据的方法并引入缓存机制

*  更新用户数据访问逻辑,优化获取用户信息的方法,使用新的函数替代原有实现

*  在 BotProfileManager 中添加自我介绍文件不存在的日志记录,优化文件读取逻辑

*  更新 BOT 自我介绍帮助信息,增加文件不存在时自动创建功能
2025-07-16 02:51:06 +08:00
Rumio
b993450a23
feat(limit, message): 引入声明式限流系统并增强消息格式化功能 (#1978)
- 新增 Cooldown、RateLimit、ConcurrencyLimit 三种限流依赖
- MessageUtils 支持动态格式化字符串 (format_args 参数)
- 插件CD限制消息显示精确剩余时间

- 重构限流逻辑至 utils/limiters.py,新增时间工具模块
- 整合时间工具函数并优化时区处理
- 新增 limiter_hook 自动释放资源,CooldownError 优化异常处理

- 冷却提示从固定文本改为动态显示剩余时间
- 示例:总结功能冷却中,请等待 1分30秒 后再试~

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: HibiKier <45528451+HibiKier@users.noreply.github.com>
2025-07-15 17:13:33 +08:00
HibiKier
d218c569d4
格式化db_context (#1980)
*  格式化db_context

* 🔥 移除旧db-context

*  添加旧版本兼容
2025-07-15 17:08:42 +08:00
molanp
faa91b8bd4
🚑 修复数据迁移SQL (#1969)
* perf(zhenxun): 优化签到和道具 SQL 查询语句

- 改为通用SQL

* style(zhenxun): 优化签到 SQL 查询格式

- 调整 SQL 查询的缩进和格式,提高可读性
- 没有修改实际的查询逻辑,仅优化代码结构

---------

Co-authored-by: HibiKier <45528451+HibiKier@users.noreply.github.com>
2025-07-14 23:20:13 +08:00
HibiKier
582ad8c996
🐛 修复sqlite连接问题 (#1979)
* 🚑 修复sqlite连接问题

* 🔧 移除db_url参数以简化数据库配置获取逻辑
2025-07-14 22:59:56 +08:00
Rumio
46a0768a45
feat(llm): 新增LLM模型管理插件并增强API密钥管理 (#1972)
🔧 新增功能:
- LLM模型管理插件 (builtin_plugins/llm_manager/)
  • llm list - 查看可用模型列表 (图片格式)
  • llm info - 查看模型详细信息 (Markdown图片)
  • llm default - 管理全局默认模型
  • llm test - 测试模型连通性
  • llm keys - 查看API Key状态 (表格图片,含健康度/成功率/延迟)
  • llm reset-key - 重置API Key失败状态

🏗️ 架构重构:
- 会话管理: AI/AIConfig 类迁移至独立的 session.py
- 类型定义: TaskType 枚举移至 types/enums.py
- API增强:
  • chat() 函数返回完整 LLMResponse,支持工具调用
  • 新增 generate() 函数用于一次性响应生成
  • 统一API调用核心方法 _perform_api_call,返回使用的API密钥

🚀 密钥管理增强:
- 详细状态跟踪: 健康度、成功率、平均延迟、错误信息、建议操作
- 状态持久化: 启动时加载,关闭时自动保存密钥状态
- 智能冷却策略: 根据错误类型设置不同冷却时间
- 延迟监控: with_smart_retry 记录API调用延迟并更新统计

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: HibiKier <45528451+HibiKier@users.noreply.github.com>
2025-07-14 22:39:17 +08:00
HibiKier
8649aaaa54
引入缓存机制 (#1889)
* 添加全局cache

*  构建缓存,hook使用缓存

*  新增数据库Model方法监控

*  数据库添加semaphore锁

* 🩹 优化webapi返回数据

*  添加增量缓存与缓存过期

* 🎨 优化检测代码结构

*  优化hook权限检测性能

* 🐛 添加新异常判断跳过权限检测

*  添加插件limit缓存

* 🎨 代码格式优化

* 🐛  修复代码导入

* 🐛 修复刷新时检查

* 👽 Rename exception for missing database URL in initialization

*  Update default database URL to SQLite in configuration

* 🔧 Update tortoise-orm and aiocache dependencies restrictions; add optional redis and asyncpg support

* 🐛 修复ban检测

* 🐛 修复所有插件关闭时缓存更新

* 🐛 尝试迁移至aiocache

* 🐛 完善aiocache缓存

*  代码性能优化

* 🐛 移除获取封禁缓存时的日志记录

* 🐛 修复缓存类型声明,优化封禁用户处理逻辑

* 🐛 优化LevelUser权限更新逻辑及数据库迁移

*  cache支持redis连接

* 🚨 auto fix by pre-commit hooks

*  :增强获取群组的安全性和准确性。同时,优化了缓存管理中的相关逻辑,确保缓存操作的一致性。

*  feat(auth_limit): 将插件初始化逻辑的启动装饰器更改为优先级管理器

* 🔧 修复日志记录级别

* 🔧 更新数据库连接字符串

* 🔧 更新数据库连接字符串为内存数据库,并优化权限检查逻辑

*  feat(cache): 增加缓存功能配置项,并新增数据访问层以支持缓存逻辑

* ♻️ 重构cache

*  feat(cache): 增强缓存管理,新增缓存字典和缓存列表功能,支持过期时间管理

* 🔧 修复Notebook类中的viewport高度设置,将其从1000调整为10

*  更新插件管理逻辑,替换缓存服务为CacheRoot并优化缓存失效处理

*  更新RegisterConfig类中的type字段

*  修复清理重复记录逻辑,确保检查记录的id属性有效性

*  超级无敌大优化,解决延迟与卡死问题

*  更新封禁功能,增加封禁时长参数和描述,优化插件信息返回结构

*  更新zhenxun_help.py中的viewport高度,将其从453调整为10,以优化页面显示效果

*  优化插件分类逻辑,增加插件ID排序,并更新插件信息返回结构

---------

Co-authored-by: BalconyJH <balconyjh@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-07-14 22:35:29 +08:00
HibiKier
6283c3d13d
更新README.md (#1976) 2025-07-13 20:07:40 +08:00
HibiKier
8f1e35954b
恢复RegisterConfig的type默认值为None (#1975) 2025-07-12 23:48:07 +08:00
molanp
9686a31419
🚑refactor(config): 修复模型type校验 (#1974)
* 🚑refactor(config): 修复模型type校验

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-07-12 00:05:24 +08:00
molanp
632ec3e46e
fix(zhenxun): 修复初始化配置文件时的类型判断逻辑 (#1971)
- 修改了配置类型判断逻辑,当 reg_config.type 为 None 时,使用 reg_config.value 的类型
- 这样可以更准确地处理配置项的类型,避免潜在的类型错误
2025-07-11 17:15:17 +08:00
molanp
fb8811207e
🚑 修复配置类型处理逻辑 (#1970)
- 在 init_config.py 中,增加了对注册配置类型为空时的处理,使用配置的值类型作为默认类型
- 在 models.py 中,将 RegisterConfig 类的 type 字段默认值从 str 改为 None,以支持更灵活的配置类型
2025-07-11 15:45:12 +08:00
Rumio
99eacdfc12
feat(http_utils): 优化AsyncHttpx类,解决并发下载问题 (#1968)
- 分离客户端配置和请求参数,避免不必要的临时客户端创建
- 添加可选下载进度条,解决并发下载时Progress实例冲突
- 优化 AsyncHttpx 方法文档字符串

Co-authored-by: webjoin111 <455457521@qq.com>
2025-07-11 10:13:02 +08:00
HibiKier
acfed0837a
检查更新支持webui更新 (#1925)
*  检查更新支持webui跟新

* 🎨 移除无用导入
2025-07-11 10:11:14 +08:00
HibiKier
4bcc5aeea5
新增依赖管理功能,支持安装和卸载虚拟环境依赖,同时优化相关API和数据模型 (#1936) 2025-07-11 10:10:53 +08:00
HibiKier
bd62698ea5
优化配置管理和数据处理逻辑 (#1949) 2025-07-11 10:10:33 +08:00
HibiKier
2921aed248
🐛 修复sqlite下的日统计查询和0权限功能调用 (#1943) 2025-07-11 10:07:23 +08:00
HibiKier
579558e59b
🐛 修复被动的默认开关指令 (#1948)
* 🐛 修复被动的默认开关指令

*  优化插件开关命令,增强用户体验

*  移除旧_task配置
2025-07-11 10:07:09 +08:00
Rumio
fcb385cf01
♻️ refactor(scheduler): 重构定时任务服务架构并增强用户体验 (#1967)
**架构重构**
- 拆分为 Service、Repository、Adapter 三层架构,提升模块化
- 统一 APScheduler Job ID 生成方式,优化 ScheduleTargeter 逻辑

**新增功能**
- 支持定时任务时区配置
- 新增"运行中"任务状态显示
- 为"所有群组"任务增加随机延迟,分散并发压力

**用户体验优化**
- 重构操作反馈消息,提供详细的成功提示卡片
- 优化任务查看命令的筛选逻辑
- 统一删除、暂停、恢复、执行、更新操作的响应格式

Co-authored-by: webjoin111 <455457521@qq.com>
2025-07-10 22:20:08 +08:00
Rumio
c3193dd784
🎨 (config): 优化配置值解析与错误处理 (#1962)
Co-authored-by: webjoin111 <455457521@qq.com>
2025-07-08 23:20:13 +08:00
Rumio
48cbb2bf1d
feat(llm): 全面重构LLM服务模块,增强多模态与工具支持 (#1953)
*  feat(llm): 全面重构LLM服务模块,增强多模态与工具支持

🚀 核心功能增强
- 多模型链式调用:新增 `pipeline_chat` 支持复杂任务流处理
- 扩展提供商支持:新增 ARK(火山方舟)、SiliconFlow(硅基流动) 适配器
- 多模态处理增强:支持URL媒体文件下载转换,提升输入灵活性
- 历史对话支持:AI.analyze 方法支持历史消息上下文和可选 UniMessage 参数
- 文本嵌入功能:新增 `embed`、`analyze_multimodal`、`search_multimodal` 等API
- 模型能力系统:新增 `ModelCapabilities` 统一管理模型特性(多模态、工具调用等)

🔧 架构重构与优化
- MCP工具系统重构:配置独立化至 `data/llm/mcp_tools.json`,预置常用工具
- API调用逻辑统一:提取通用 `_perform_api_call` 方法,消除代码重复
- 跨平台兼容:Windows平台MCP工具npx命令自动包装处理
- HTTP客户端增强:兼容不同版本httpx代理配置(0.28+版本适配)

🛠️ API与配置完善
- 统一返回类型:`AI.analyze` 统一返回 `LLMResponse` 类型
- 消息转换工具:新增 `message_to_unimessage` 转换函数
- Gemini适配器增强:URL图片下载编码、动态安全阈值配置
- 缓存管理:新增模型实例缓存和管理功能
- 配置预设:扩展 CommonOverrides 预设配置选项
- 历史管理优化:支持多模态内容占位符替换,提升效率

📚 文档与开发体验
- README全面重写:新增完整使用指南、API参考和架构概览
- 文档内容扩充:补充嵌入模型、缓存管理、工具注册等功能说明
- 日志记录增强:支持详细调试信息输出
- API简化:移除冗余函数,优化接口设计

* 🎨  feat(llm): 统一LLM服务函数文档格式

*  feat(llm): 添加新模型并简化提供者配置加载

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-07-08 11:15:15 +08:00
163 changed files with 17606 additions and 9078 deletions

View File

@ -27,6 +27,18 @@ QBOT_ID_DATA = '{
# 示例: "sqlite:data/db/zhenxun.db" 在data目录下建立db文件夹 # 示例: "sqlite:data/db/zhenxun.db" 在data目录下建立db文件夹
DB_URL = "" DB_URL = ""
# NONE: 不使用缓存, MEMORY: 使用内存缓存, REDIS: 使用Redis缓存
CACHE_MODE = NONE
# REDIS配置使用REDIS替换Cache内存缓存
# REDIS地址
# REDIS_HOST = "127.0.0.1"
# REDIS端口
# REDIS_PORT = 6379
# REDIS密码
# REDIS_PASSWORD = ""
# REDIS过期时间
# REDIS_EXPIRE = 600
# 系统代理 # 系统代理
# SYSTEM_PROXY = "http://127.0.0.1:7890" # SYSTEM_PROXY = "http://127.0.0.1:7890"
@ -40,7 +52,7 @@ PLATFORM_SUPERUSERS = '
DRIVER=~fastapi+~httpx+~websockets DRIVER=~fastapi+~httpx+~websockets
# LOG_LEVEL=DEBUG # LOG_LEVEL = DEBUG
# 服务器和端口 # 服务器和端口
HOST = 127.0.0.1 HOST = 127.0.0.1
PORT = 8080 PORT = 8080

26
.github/workflows/sync-to-aliyun.yml vendored Normal file
View File

@ -0,0 +1,26 @@
name: Force Sync to Aliyun
on:
push:
branches: ["main"]
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git
run: |
git config --global http.postBuffer 524288000
git config --global core.compression 0
- name: Add aliyun remote
run: |
git remote add aliyun https://${{secrets.ALIYUN_ACCOUNT}}:${{secrets.ALIYUN_PASSWORD}}@codeup.aliyun.com/67a361cf556e6cdab537117a/zhenxun-org/zhenxun_bot.git
git fetch aliyun main --force # 强制更新本地引用
- name: Force push
run: git push --progress --force aliyun HEAD:main

View File

@ -7,7 +7,7 @@ ci:
autoupdate_commit_msg: ":arrow_up: auto update by pre-commit hooks" autoupdate_commit_msg: ":arrow_up: auto update by pre-commit hooks"
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.2 rev: v0.12.7
hooks: hooks:
- id: ruff - id: ruff
args: [--fix] args: [--fix]

View File

@ -29,6 +29,7 @@
"unban", "unban",
"Uninfo", "Uninfo",
"userinfo", "userinfo",
"webui",
"zhenxun" "zhenxun"
], ],
"python.analysis.autoImportCompletions": true, "python.analysis.autoImportCompletions": true,

View File

@ -287,6 +287,18 @@ DB_URL 是基于 Tortoise ORM 的数据库连接字符串,用于指定项目
[Zer](https://afdian.com/u/6bccdb2a60b411ec9ad452540025c377) [爱发电用户\_HTjk](https://afdian.com/u/6c7d0208064511ec8d7b52540025c377) [shenghuo2](https://afdian.com/u/bca13286102111eda2a052540025c377) [术樱](https://afdian.com/u/414da63a09a311ec8eb752540025c377) [飞火](https://afdian.com/u/404135f48ed711ec962152540025c377) [shenqi](https://afdian.net/u/fa923a8cfe3d11eba61752540025c377) [A_Kyuu](https://afdian.net/u/b83954fc2c1211eba9eb52540025c377) [疯狂混沌](https://afdian.net/u/789a2f9200cd11edb38352540025c377) [投冥](https://afdian.net/a/144514mm) [茶喵](https://afdian.net/u/fd22382eac4d11ecbfc652540025c377) [AemokpaTNR](https://afdian.net/u/1169bb8c8a9611edb0c152540025c377) [爱发电用户\_wrxn](https://afdian.net/u/4aa03d20db4311ecb1e752540025c377) [qqw](https://afdian.net/u/b71db4e2cc3e11ebb76652540025c377) [溫一壺月光下酒](https://afdian.net/u/ad667a5c650c11ed89bf52540025c377) [伝木](https://afdian.net/u/246b80683f9511edba7552540025c377) [阿奎](https://afdian.net/u/da41f72845d511ed930d52540025c377) [醉梦尘逸](https://afdian.net/u/bc11d2683cd011ed99b552540025c377) [Abc](https://afdian.net/u/870dc10a3cd311ed828852540025c377) [本喵无敌哒](https://afdian.net/u/dffaa9005bc911ebb69b52540025c377) [椎名冬羽](https://afdian.net/u/ca1ebd64395e11ed81b452540025c377) [kaito](https://afdian.net/u/a055e20a498811eab1f052540025c377) [笑柒 XIAO_Q7](https://afdian.net/u/4696db5c529111ec84ea52540025c377) [请问一份爱多少钱](https://afdian.net/u/f57ef6602dbd11ed977f52540025c377) [咸鱼鱼鱼鱼](https://afdian.net/u/8e39b9a400e011ed9f4a52540025c377) [Kafka](https://afdian.net/u/41d66798ef6911ecbc5952540025c377) [墨然](https://afdian.net/u/8aa5874a644d11eb8a6752540025c377) [爱发电用户\_T9e4](https://afdian.net/u/2ad1bb82f3a711eca22852540025c377) [笑柒 XIAO_Q7](https://afdian.net/u/4696db5c529111ec84ea52540025c377) [noahzark](https://afdian.net/a/noahzark) [腊条](https://afdian.net/u/f739c4d69eca11eba94b52540025c377) [zeroller](https://afdian.net/u/0e599e96257211ed805152540025c377) [爱发电用户\_4jrf](https://afdian.net/u/6b2cdcc817c611ed949152540025c377) [爱发电用户\_TBsd](https://afdian.net/u/db638b60217911ed9efd52540025c377) [烟寒若雨](https://afdian.net/u/067bd2161eec11eda62b52540025c377) [ln](https://afdian.net/u/b51914ba1c6611ed8a4e52540025c377) [爱发电用户\_b9S4](https://afdian.net/u/3d8f30581a2911edba6d52540025c377) [爱发电用户\_c58s](https://afdian.net/u/a6ad8dda195e11ed9a4152540025c377) [爱发电用户\_eNr9](https://afdian.net/u/05fdb41c0c9a11ed814952540025c377) [MangataAkihi](https://github.com/Sakuracio) [](https://afdian.net/u/69b76e9ec77b11ec874f52540025c377) [爱发电用户\_Bc6j](https://afdian.net/u/8546be24f44111eca64052540025c377) [大魔王](https://github.com/xipesoy) [CopilotLaLaLa](https://github.com/CopilotLaLaLa) [嘿小欧](https://afdian.net/u/daa4bec4f24911ec82e552540025c377) [回忆的秋千](https://afdian.net/u/e315d9c6f14f11ecbeef52540025c377) [十年くん](https://github.com/shinianj) [](https://afdian.net/u/9b266244f23911eca19052540025c377) [yajiwa](https://github.com/yajiwa) [爆金币](https://afdian.net/u/0d78879ef23711ecb22452540025c377)... [Zer](https://afdian.com/u/6bccdb2a60b411ec9ad452540025c377) [爱发电用户\_HTjk](https://afdian.com/u/6c7d0208064511ec8d7b52540025c377) [shenghuo2](https://afdian.com/u/bca13286102111eda2a052540025c377) [术樱](https://afdian.com/u/414da63a09a311ec8eb752540025c377) [飞火](https://afdian.com/u/404135f48ed711ec962152540025c377) [shenqi](https://afdian.net/u/fa923a8cfe3d11eba61752540025c377) [A_Kyuu](https://afdian.net/u/b83954fc2c1211eba9eb52540025c377) [疯狂混沌](https://afdian.net/u/789a2f9200cd11edb38352540025c377) [投冥](https://afdian.net/a/144514mm) [茶喵](https://afdian.net/u/fd22382eac4d11ecbfc652540025c377) [AemokpaTNR](https://afdian.net/u/1169bb8c8a9611edb0c152540025c377) [爱发电用户\_wrxn](https://afdian.net/u/4aa03d20db4311ecb1e752540025c377) [qqw](https://afdian.net/u/b71db4e2cc3e11ebb76652540025c377) [溫一壺月光下酒](https://afdian.net/u/ad667a5c650c11ed89bf52540025c377) [伝木](https://afdian.net/u/246b80683f9511edba7552540025c377) [阿奎](https://afdian.net/u/da41f72845d511ed930d52540025c377) [醉梦尘逸](https://afdian.net/u/bc11d2683cd011ed99b552540025c377) [Abc](https://afdian.net/u/870dc10a3cd311ed828852540025c377) [本喵无敌哒](https://afdian.net/u/dffaa9005bc911ebb69b52540025c377) [椎名冬羽](https://afdian.net/u/ca1ebd64395e11ed81b452540025c377) [kaito](https://afdian.net/u/a055e20a498811eab1f052540025c377) [笑柒 XIAO_Q7](https://afdian.net/u/4696db5c529111ec84ea52540025c377) [请问一份爱多少钱](https://afdian.net/u/f57ef6602dbd11ed977f52540025c377) [咸鱼鱼鱼鱼](https://afdian.net/u/8e39b9a400e011ed9f4a52540025c377) [Kafka](https://afdian.net/u/41d66798ef6911ecbc5952540025c377) [墨然](https://afdian.net/u/8aa5874a644d11eb8a6752540025c377) [爱发电用户\_T9e4](https://afdian.net/u/2ad1bb82f3a711eca22852540025c377) [笑柒 XIAO_Q7](https://afdian.net/u/4696db5c529111ec84ea52540025c377) [noahzark](https://afdian.net/a/noahzark) [腊条](https://afdian.net/u/f739c4d69eca11eba94b52540025c377) [zeroller](https://afdian.net/u/0e599e96257211ed805152540025c377) [爱发电用户\_4jrf](https://afdian.net/u/6b2cdcc817c611ed949152540025c377) [爱发电用户\_TBsd](https://afdian.net/u/db638b60217911ed9efd52540025c377) [烟寒若雨](https://afdian.net/u/067bd2161eec11eda62b52540025c377) [ln](https://afdian.net/u/b51914ba1c6611ed8a4e52540025c377) [爱发电用户\_b9S4](https://afdian.net/u/3d8f30581a2911edba6d52540025c377) [爱发电用户\_c58s](https://afdian.net/u/a6ad8dda195e11ed9a4152540025c377) [爱发电用户\_eNr9](https://afdian.net/u/05fdb41c0c9a11ed814952540025c377) [MangataAkihi](https://github.com/Sakuracio) [](https://afdian.net/u/69b76e9ec77b11ec874f52540025c377) [爱发电用户\_Bc6j](https://afdian.net/u/8546be24f44111eca64052540025c377) [大魔王](https://github.com/xipesoy) [CopilotLaLaLa](https://github.com/CopilotLaLaLa) [嘿小欧](https://afdian.net/u/daa4bec4f24911ec82e552540025c377) [回忆的秋千](https://afdian.net/u/e315d9c6f14f11ecbeef52540025c377) [十年くん](https://github.com/shinianj) [](https://afdian.net/u/9b266244f23911eca19052540025c377) [yajiwa](https://github.com/yajiwa) [爆金币](https://afdian.net/u/0d78879ef23711ecb22452540025c377)...
### 特别赞助
<div align=center>
<img width="60%" src="https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png" />
[亚洲最佳CDN、边缘和安全解决方案 - Tencent EdgeOne](https://edgeone.ai/zh?from=github)
**本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助**
</div>
## 📜 贡献指南 ## 📜 贡献指南
欢迎查看我们的 [贡献指南](CONTRIBUTING.md) 和 [行为守则](CODE_OF_CONDUCT.md) 以了解如何参与贡献。 欢迎查看我们的 [贡献指南](CONTRIBUTING.md) 和 [行为守则](CODE_OF_CONDUCT.md) 以了解如何参与贡献。

View File

@ -1 +1 @@
__version__: v0.2.4-2c97eea __version__: v0.2.4-da6d5b4

File diff suppressed because it is too large Load Diff

View File

@ -45,6 +45,7 @@ nonebot-plugin-alconna = "^0.54.0"
tenacity = "^9.0.0" tenacity = "^9.0.0"
nonebot-plugin-uninfo = ">0.4.1" nonebot-plugin-uninfo = ">0.4.1"
pydantic = "1.10.18" pydantic = "1.10.18"
alibabacloud-devops20210625 = "^5.0.2"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
nonebug = "^0.4" nonebug = "^0.4"

File diff suppressed because it is too large Load Diff

View File

@ -45,6 +45,7 @@ nonebot-plugin-alconna = "^0.54.0"
tenacity = "^9.0.0" tenacity = "^9.0.0"
nonebot-plugin-uninfo = ">0.4.1" nonebot-plugin-uninfo = ">0.4.1"
pydantic = "2.10.6" pydantic = "2.10.6"
alibabacloud-devops20210625 = "^5.0.2"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
nonebug = "^0.4" nonebug = "^0.4"

3147
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@ python = "^3.10"
playwright = "^1.41.1" playwright = "^1.41.1"
nonebot-adapter-onebot = "^2.3.1" nonebot-adapter-onebot = "^2.3.1"
nonebot-plugin-apscheduler = "^0.5" nonebot-plugin-apscheduler = "^0.5"
tortoise-orm = { extras = ["asyncpg"], version = "^0.20.0" } tortoise-orm = "^0.20.0"
cattrs = "^23.2.3" cattrs = "^23.2.3"
ruamel-yaml = "^0.18.5" ruamel-yaml = "^0.18.5"
strenum = "^0.4.15" strenum = "^0.4.15"
@ -39,7 +39,7 @@ dateparser = "^1.2.0"
bilireq = "0.2.3post0" bilireq = "0.2.3post0"
python-jose = { extras = ["cryptography"], version = "^3.3.0" } python-jose = { extras = ["cryptography"], version = "^3.3.0" }
python-multipart = "^0.0.9" python-multipart = "^0.0.9"
aiocache = "^0.12.2" aiocache = {extras = ["redis"], version = "^0.12.3"}
py-cpuinfo = "^9.0.0" py-cpuinfo = "^9.0.0"
nonebot-plugin-alconna = "^0.54.0" nonebot-plugin-alconna = "^0.54.0"
tenacity = "^9.0.0" tenacity = "^9.0.0"
@ -47,6 +47,10 @@ nonebot-plugin-uninfo = ">0.4.1"
nonebot-plugin-waiter = "^0.8.1" nonebot-plugin-waiter = "^0.8.1"
multidict = ">=6.0.0,!=6.3.2" multidict = ">=6.0.0,!=6.3.2"
redis = { version = ">=5", optional = true }
asyncpg = { version = ">=0.20.0", optional = true }
alibabacloud-devops20210625 = "^5.0.2"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
nonebug = "^0.4" nonebug = "^0.4"
pytest-cov = "^5.0.0" pytest-cov = "^5.0.0"
@ -57,6 +61,9 @@ respx = "^0.21.1"
ruff = "^0.8.0" ruff = "^0.8.0"
pre-commit = "^4.0.0" pre-commit = "^4.0.0"
[tool.poetry.extras]
redis = ["redis"]
postgresql = ["asyncpg"]
[tool.nonebot] [tool.nonebot]
plugins = [ plugins = [

View File

@ -2,6 +2,7 @@ aiocache==0.12.3 ; python_version >= "3.10" and python_version < "4.0"
aiofiles==23.2.1 ; python_version >= "3.10" and python_version < "4.0" aiofiles==23.2.1 ; python_version >= "3.10" and python_version < "4.0"
aiosqlite==0.17.0 ; python_version >= "3.10" and python_version < "4.0" aiosqlite==0.17.0 ; python_version >= "3.10" and python_version < "4.0"
annotated-types==0.7.0 ; python_version >= "3.10" and python_version < "4.0" annotated-types==0.7.0 ; python_version >= "3.10" and python_version < "4.0"
alibabacloud-devops20210625==5.0.2 ; python_version >= "3.10" and python_version < "4.0"
anyio==4.8.0 ; python_version >= "3.10" and python_version < "4.0" anyio==4.8.0 ; python_version >= "3.10" and python_version < "4.0"
apscheduler==3.11.0 ; python_version >= "3.10" and python_version < "4.0" apscheduler==3.11.0 ; python_version >= "3.10" and python_version < "4.0"
arclet-alconna-tools==0.7.10 ; python_version >= "3.10" and python_version < "4.0" arclet-alconna-tools==0.7.10 ; python_version >= "3.10" and python_version < "4.0"

View File

@ -13,7 +13,11 @@ from pytest_mock import MockerFixture
from respx import MockRouter from respx import MockRouter
from tests.config import BotId, GroupId, MessageId, UserId from tests.config import BotId, GroupId, MessageId, UserId
from tests.utils import _v11_group_message_event, _v11_private_message_send from tests.utils import (
_v11_group_message_event,
_v11_private_message_send,
get_reply_cq,
)
from tests.utils import get_response_json as _get_response_json from tests.utils import get_response_json as _get_response_json
@ -311,6 +315,12 @@ async def test_check_update_release(
to_me=True, to_me=True,
) )
ctx.receive_event(bot, event) ctx.receive_event(bot, event)
ctx.should_call_send(
event=event,
message=Message(f"{get_reply_cq(MessageId.MESSAGE_ID)}正在进行检查更新..."),
result=None,
bot=bot,
)
ctx.should_call_api( ctx.should_call_api(
"send_msg", "send_msg",
_v11_private_message_send( _v11_private_message_send(
@ -401,6 +411,12 @@ async def test_check_update_main(
to_me=True, to_me=True,
) )
ctx.receive_event(bot, event) ctx.receive_event(bot, event)
ctx.should_call_send(
event=event,
message=Message(f"{get_reply_cq(MessageId.MESSAGE_ID)}正在进行检查更新..."),
result=None,
bot=bot,
)
ctx.should_call_api( ctx.should_call_api(
"send_msg", "send_msg",
_v11_private_message_send( _v11_private_message_send(

View File

@ -136,20 +136,20 @@ async def test_check(
+ f"- {mock_psutil.cpu_freq.return_value.current}Ghz " + f"- {mock_psutil.cpu_freq.return_value.current}Ghz "
+ f"[{mock_psutil.cpu_count.return_value} core]", + f"[{mock_psutil.cpu_count.return_value} core]",
"cpu_process": mock_psutil.cpu_percent.return_value, "cpu_process": mock_psutil.cpu_percent.return_value,
"ram_info": f"{round(mock_psutil.virtual_memory.return_value.used / (1024 ** 3), 1)}" # noqa: E501 "ram_info": f"{round(mock_psutil.virtual_memory.return_value.used / (1024**3), 1)}" # noqa: E501
+ f" / {round(mock_psutil.virtual_memory.return_value.total / (1024 ** 3), 1)}" + f" / {round(mock_psutil.virtual_memory.return_value.total / (1024**3), 1)}"
+ " GB", + " GB",
"ram_process": mock_psutil.virtual_memory.return_value.percent, "ram_process": mock_psutil.virtual_memory.return_value.percent,
"swap_info": f"{round(mock_psutil.swap_memory.return_value.used / (1024 ** 3), 1)}" # noqa: E501 "swap_info": f"{round(mock_psutil.swap_memory.return_value.used / (1024**3), 1)}" # noqa: E501
+ f" / {round(mock_psutil.swap_memory.return_value.total / (1024 ** 3), 1)} GB", + f" / {round(mock_psutil.swap_memory.return_value.total / (1024**3), 1)} GB",
"swap_process": mock_psutil.swap_memory.return_value.percent, "swap_process": mock_psutil.swap_memory.return_value.percent,
"disk_info": f"{round(mock_psutil.disk_usage.return_value.used / (1024 ** 3), 1)}" # noqa: E501 "disk_info": f"{round(mock_psutil.disk_usage.return_value.used / (1024**3), 1)}" # noqa: E501
+ f" / {round(mock_psutil.disk_usage.return_value.total / (1024 ** 3), 1)} GB", + f" / {round(mock_psutil.disk_usage.return_value.total / (1024**3), 1)} GB",
"disk_process": mock_psutil.disk_usage.return_value.percent, "disk_process": mock_psutil.disk_usage.return_value.percent,
"brand_raw": cpuinfo_get_cpu_info["brand_raw"], "brand_raw": cpuinfo_get_cpu_info["brand_raw"],
"baidu": "red", "baidu": "red",
"google": "red", "google": "red",
"system": f"{platform_uname.system} " f"{platform_uname.release}", "system": f"{platform_uname.system} {platform_uname.release}",
"version": __get_version(), "version": __get_version(),
"plugin_count": len(nonebot.get_loaded_plugins()), "plugin_count": len(nonebot.get_loaded_plugins()),
"nickname": BotConfig.self_nickname, "nickname": BotConfig.self_nickname,
@ -244,8 +244,7 @@ async def test_check_arm(
"brand_raw": "", "brand_raw": "",
"baidu": "red", "baidu": "red",
"google": "red", "google": "red",
"system": f"{platform_uname_arm.system} " "system": f"{platform_uname_arm.system} {platform_uname_arm.release}",
f"{platform_uname_arm.release}",
"version": __get_version(), "version": __get_version(),
"plugin_count": len(nonebot.get_loaded_plugins()), "plugin_count": len(nonebot.get_loaded_plugins()),
"nickname": BotConfig.self_nickname, "nickname": BotConfig.self_nickname,

View File

@ -1,5 +1,3 @@
# ruff: noqa: ASYNC230
from pathlib import Path from pathlib import Path
from respx import MockRouter from respx import MockRouter

View File

@ -5,6 +5,10 @@ from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message, MessageSegme
from nonebot.adapters.onebot.v11.event import Sender from nonebot.adapters.onebot.v11.event import Sender
def get_reply_cq(uid: int | str) -> str:
return f"[CQ:reply,id={uid}]"
def get_response_json(base_path: Path, file: str) -> dict: def get_response_json(base_path: Path, file: str) -> dict:
try: try:
return json.loads( return json.loads(

View File

@ -5,7 +5,7 @@ import nonebot
from nonebot.adapters import Bot from nonebot.adapters import Bot
from nonebot.drivers import Driver from nonebot.drivers import Driver
from tortoise import Tortoise from tortoise import Tortoise
from tortoise.exceptions import OperationalError from tortoise.exceptions import IntegrityError, OperationalError
import ujson as json import ujson as json
from zhenxun.models.bot_connect_log import BotConnectLog from zhenxun.models.bot_connect_log import BotConnectLog
@ -30,9 +30,12 @@ async def _(bot: Bot):
bot_id=bot.self_id, platform=bot.adapter, connect_time=datetime.now(), type=1 bot_id=bot.self_id, platform=bot.adapter, connect_time=datetime.now(), type=1
) )
if not await BotConsole.exists(bot_id=bot.self_id): if not await BotConsole.exists(bot_id=bot.self_id):
await BotConsole.create( try:
bot_id=bot.self_id, platform=PlatformUtils.get_platform(bot) await BotConsole.create(
) bot_id=bot.self_id, platform=PlatformUtils.get_platform(bot)
)
except IntegrityError as e:
logger.warning(f"记录bot: {bot.self_id} 数据已存在...", e=e)
@driver.on_bot_disconnect @driver.on_bot_disconnect
@ -50,22 +53,31 @@ async def _(bot: Bot):
SIGN_SQL = """ SIGN_SQL = """
select distinct on("user_id") t1.user_id, t1.checkin_count, t1.add_probability, SELECT user_id, checkin_count, add_probability, specify_probability, impression
t1.specify_probability, t1.impression FROM (
from public.sign_group_users t1 SELECT
join ( t1.user_id,
select user_id, max(t2.impression) as max_impression t1.checkin_count,
from public.sign_group_users t2 t1.add_probability,
group by user_id t1.specify_probability,
) t on t.user_id = t1.user_id and t.max_impression = t1.impression t1.impression,
ROW_NUMBER() OVER(PARTITION BY t1.user_id ORDER BY t1.impression DESC) AS rn
FROM sign_group_users t1
INNER JOIN (
SELECT user_id, MAX(impression) AS max_impression
FROM sign_group_users
GROUP BY user_id
) t2 ON t2.user_id = t1.user_id AND t2.max_impression = t1.impression
) t
WHERE rn = 1
""" """
BAG_SQL = """ BAG_SQL = """
select t1.user_id, t1.gold, t1.property select t1.user_id, t1.gold, t1.property
from public.bag_users t1 from bag_users t1
join ( join (
select user_id, max(t2.gold) as max_gold select user_id, max(t2.gold) as max_gold
from public.bag_users t2 from bag_users t2
group by user_id group by user_id
) t on t.user_id = t1.user_id and t.max_gold = t1.gold ) t on t.user_id = t1.user_id and t.max_gold = t1.gold
""" """

View File

@ -25,6 +25,11 @@ __plugin_meta__ = PluginMetadata(
version="0.1", version="0.1",
plugin_type=PluginType.ADMIN, plugin_type=PluginType.ADMIN,
admin_level=1, admin_level=1,
introduction="""这是 群主/群管理 的帮助列表,里面记录了群组内开关功能的
方法帮助以及群管特权方法建议首次时在群组中发送 '管理员帮助' 查看""",
precautions=[
"只有群主/群管理 才能使用哦群主拥有6级权限管理员拥有5级权限"
],
configs=[ configs=[
RegisterConfig( RegisterConfig(
key="type", key="type",

View File

@ -16,7 +16,8 @@ async def get_task() -> dict[str, str] | None:
"name": "被动技能", "name": "被动技能",
"description": "控制群组中的被动技能状态", "description": "控制群组中的被动技能状态",
"usage": "通过 开启/关闭群被动 来控制群被动 <br>" "usage": "通过 开启/关闭群被动 来控制群被动 <br>"
+ " 示例:开启/关闭群被动早晚安 <br> ---------- <br> " + " 示例:开启/关闭群被动早晚安 <br> 示例:开启/关闭全部群被动"
+ " <br> ---------- <br> "
+ "<br>".join([task.name for task in task_list]), + "<br>".join([task.name for task in task_list]),
} }
return None return None
@ -47,7 +48,7 @@ async def build_html_help():
} }
}, },
pages={ pages={
"viewport": {"width": 1024, "height": 1024}, "viewport": {"width": 824, "height": 10},
"base_url": f"file://{TEMPLATE_PATH}", "base_url": f"file://{TEMPLATE_PATH}",
}, },
wait=2, wait=2,

View File

@ -36,11 +36,12 @@ __plugin_meta__ = PluginMetadata(
usage=""" usage="""
普通管理员 普通管理员
格式: 格式:
ban [At用户] ?[-t [时长(分钟)]] ban [At用户] ?[-t [时长(分钟)]] ?[-r [理由]]
示例: 示例:
ban @用户 : 永久拉黑用户 ban @用户 : 永久拉黑用户
ban @用户 -t 100 : 拉黑用户100分钟 ban @用户 -t 100 : 拉黑用户100分钟
ban @用户 -t 10 -r : 拉黑用户10分钟并携带理由
unban @用户 : 从小黑屋中拉出来 unban @用户 : 从小黑屋中拉出来
""".strip(), """.strip(),
extra=PluginExtraData( extra=PluginExtraData(
@ -50,7 +51,7 @@ __plugin_meta__ = PluginMetadata(
superuser_help=""" superuser_help="""
超级管理员额外命令 超级管理员额外命令
格式: 格式:
ban [At用户/用户Id] ?[-t [时长]] ban [At用户/用户Id] ?[-t [时长]] ?[-r [理由]]
unban --id [idx] : 通过id来进行unban操作 unban --id [idx] : 通过id来进行unban操作
ban列表: 获取所有Ban数据 ban列表: 获取所有Ban数据
@ -66,10 +67,13 @@ __plugin_meta__ = PluginMetadata(
私聊下: 私聊下:
示例: 示例:
ban 123456789 : 永久拉黑用户123456789 ban 123456789 : 永久拉黑用户123456789
ban 123456789 -r : 永久拉黑用户123456789并携带理由
ban 123456789 -t 100 : 拉黑用户123456789 100分钟 ban 123456789 -t 100 : 拉黑用户123456789 100分钟
ban -g 999999 : 拉黑群组为999999的群组 ban -g 999999 : 拉黑群组为999999的群组
ban -g 999999 -t 100 : 拉黑群组为999999的群组 100分钟 ban -g 999999 -t 100 : 拉黑群组为999999的群组 100分钟
ban -g 999999 -r : 永久拉黑群组为999999的群组并携带理由
unban 123456789 : 从小黑屋中拉出来 unban 123456789 : 从小黑屋中拉出来
unban -g 999999 : 将群组9999999从小黑屋中拉出来 unban -g 999999 : 将群组9999999从小黑屋中拉出来
@ -87,13 +91,20 @@ __plugin_meta__ = PluginMetadata(
smart_tools=[ smart_tools=[
AICallableTag( AICallableTag(
name="call_ban", name="call_ban",
description="某人多次(至少三次)辱骂你,调用此方法进行封禁", description="如果你讨厌(好感度过低并让你感到困扰,或者多次辱骂你,调用此方法进行封禁,调用该方法后要告知用户被封禁和原因",
parameters=AICallableParam( parameters=AICallableParam(
type="object", type="object",
properties={ properties={
"user_id": AICallableProperties( "user_id": AICallableProperties(
type="string", description="用户的id" type="string", description="用户的id"
), ),
"reason": AICallableProperties(
type="string", description="封禁理由"
),
"duration": AICallableProperties(
type="integer",
description="封禁时长选择的值只能是1-360单位为分钟如果频繁触发按情况增加",
),
}, },
required=["user_id"], required=["user_id"],
), ),
@ -108,6 +119,7 @@ _ban_matcher = on_alconna(
Alconna( Alconna(
"ban", "ban",
Args["user?", [str, At]], Args["user?", [str, At]],
Option("-r|--reason", Args["reason", str]),
Option("-g|--group", Args["group_id", str]), Option("-g|--group", Args["group_id", str]),
Option("-t|--time", Args["duration", int]), Option("-t|--time", Args["duration", int]),
), ),
@ -181,6 +193,7 @@ async def _(
session: EventSession, session: EventSession,
arparma: Arparma, arparma: Arparma,
user: Match[str | At], user: Match[str | At],
reason: Match[str],
duration: Match[int], duration: Match[int],
group_id: Match[str], group_id: Match[str],
): ):
@ -196,13 +209,14 @@ async def _(
user_id = user.result user_id = user.result
_duration = duration.result * 60 if duration.available else -1 _duration = duration.result * 60 if duration.available else -1
_duration_text = f"{duration.result} 分钟" if duration.available else " 到世界湮灭" _duration_text = f"{duration.result} 分钟" if duration.available else " 到世界湮灭"
ban_reason = reason.result if reason.available else None
if (gid := session.id3 or session.id2) and not group_id.available: if (gid := session.id3 or session.id2) and not group_id.available:
if not user_id or ( if not user_id or (
user_id == bot.self_id and session.id1 not in bot.config.superusers user_id == bot.self_id and session.id1 not in bot.config.superusers
): ):
_duration = 0.5 _duration = 0.5
await MessageUtils.build_message("倒反天罡,小小管理速速退下!").send() await MessageUtils.build_message("倒反天罡,小小管理速速退下!").send()
await BanManage.ban(session.id1, gid, 30, session, True) await BanManage.ban(session.id1, gid, ban_reason, 30, session, True)
_duration_text = "半 分钟" _duration_text = "半 分钟"
logger.info( logger.info(
f"尝试ban {BotConfig.self_nickname} 反被拿下", f"尝试ban {BotConfig.self_nickname} 反被拿下",
@ -218,7 +232,12 @@ async def _(
] ]
).finish(reply_to=True) ).finish(reply_to=True)
await BanManage.ban( await BanManage.ban(
user_id, gid, _duration, session, session.id1 in bot.config.superusers user_id,
gid,
ban_reason,
_duration,
session,
session.id1 in bot.config.superusers,
) )
logger.info( logger.info(
"管理员Ban", "管理员Ban",
@ -240,7 +259,7 @@ async def _(
).finish(reply_to=True) ).finish(reply_to=True)
elif session.id1 in bot.config.superusers: elif session.id1 in bot.config.superusers:
_group_id = group_id.result if group_id.available else None _group_id = group_id.result if group_id.available else None
await BanManage.ban(user_id, _group_id, _duration, session, True) await BanManage.ban(user_id, _group_id, ban_reason, _duration, session, True)
logger.info( logger.info(
"超级用户Ban", "超级用户Ban",
arparma.header_result, arparma.header_result,
@ -292,7 +311,7 @@ async def _(
At(flag="user", target=user_id) At(flag="user", target=user_id)
if isinstance(user.result, At) if isinstance(user.result, At)
else result else result
), # type: ignore ),
" 从黑屋中拉了出来并急救了一下!", " 从黑屋中拉了出来并急救了一下!",
] ]
).finish(reply_to=True) ).finish(reply_to=True)

View File

@ -9,14 +9,14 @@ from zhenxun.services.log import logger
from zhenxun.utils.image_utils import BuildImage, ImageTemplate from zhenxun.utils.image_utils import BuildImage, ImageTemplate
async def call_ban(user_id: str): async def call_ban(user_id: str, reason: str | None = None, duration: int = 1):
"""调用ban """调用ban
参数: 参数:
user_id: 用户id user_id: 用户id
""" """
await BanConsole.ban(user_id, None, 9, 60 * 12) await BanConsole.ban(user_id, None, 9, reason, duration * 60)
logger.info("辱骂次数过多,已将用户加入黑名单...", "ban", session=user_id) logger.info("被讨厌了,已将用户加入黑名单...", "ban", session=user_id)
class BanManage: class BanManage:
@ -55,20 +55,23 @@ class BanManage:
"用户ID", "用户ID",
"群组ID", "群组ID",
"BAN LEVEL", "BAN LEVEL",
"封禁原因",
"剩余时长(分钟)", "剩余时长(分钟)",
"操作员ID", "操作员ID",
] ]
row_data = [] row_data = []
for data in data_list: for data in data_list:
duration = int((data.ban_time + data.duration - time.time()) / 60) if data.duration == -1:
if data.duration < 0:
duration = "" duration = ""
else:
duration = int((data.ban_time + data.duration - time.time()) / 60)
row_data.append( row_data.append(
[ [
data.id, data.id,
data.user_id, data.user_id,
data.group_id, data.group_id,
data.ban_level, data.ban_level,
data.ban_reason,
duration, duration,
data.operator, data.operator,
] ]
@ -114,16 +117,21 @@ class BanManage:
if not is_superuser and user_id and session.id1: if not is_superuser and user_id and session.id1:
user_level = await LevelUser.get_user_level(session.id1, group_id) user_level = await LevelUser.get_user_level(session.id1, group_id)
if idx: if idx:
ban_data = await BanConsole.get_or_none(id=idx) ban_data = await BanConsole.get_ban(id=idx)
if not ban_data: if not ban_data:
return False, "该用户/群组不在黑名单中捏..." return False, "该用户/群组不在黑名单中捏..."
if ban_data.ban_level > user_level: if ban_data.ban_level > user_level:
return False, "unBan权限等级不足捏..." return False, "unBan权限等级不足捏..."
await ban_data.delete() await ban_data.delete()
return True, str(ban_data.user_id or ban_data.group_id) return (
True,
f"用户 {ban_data.user_id}"
if ban_data.user_id
else f"群组 {ban_data.group_id}",
)
elif await BanConsole.check_ban_level(user_id, group_id, user_level): elif await BanConsole.check_ban_level(user_id, group_id, user_level):
await BanConsole.unban(user_id, group_id) await BanConsole.unban(user_id, group_id)
return True, str(group_id) return True, f"群组 {group_id}"
return False, "该用户/群组不在黑名单中不足捏..." return False, "该用户/群组不在黑名单中不足捏..."
@classmethod @classmethod
@ -131,6 +139,7 @@ class BanManage:
cls, cls,
user_id: str | None, user_id: str | None,
group_id: str | None, group_id: str | None,
reason: str | None,
duration: int, duration: int,
session: EventSession, session: EventSession,
is_superuser: bool, is_superuser: bool,
@ -140,6 +149,7 @@ class BanManage:
参数: 参数:
user_id: 用户id user_id: 用户id
group_id: 群组id group_id: 群组id
reason: 理由
duration: 时长 duration: 时长
session: Session session: Session
is_superuser: 是否为超级用户操作 is_superuser: 是否为超级用户操作
@ -147,4 +157,4 @@ class BanManage:
level = 9999 level = 9999
if not is_superuser and user_id and session.id1: if not is_superuser and user_id and session.id1:
level = await LevelUser.get_user_level(session.id1, group_id) level = await LevelUser.get_user_level(session.id1, group_id)
await BanConsole.ban(user_id, group_id, level, duration, session.id1) await BanConsole.ban(user_id, group_id, level, reason, duration, session.id1)

View File

@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
import re
import nonebot import nonebot
from nonebot.adapters import Bot from nonebot.adapters import Bot
@ -32,7 +33,9 @@ class MemberUpdateManage:
""" """
driver = nonebot.get_driver() driver = nonebot.get_driver()
default_auth = Config.get_config("admin_bot_manage", "ADMIN_DEFAULT_AUTH") default_auth = Config.get_config("admin_bot_manage", "ADMIN_DEFAULT_AUTH")
nickname = member.nick or member.user.name or "" nickname = re.sub(
r"[\x00-\x09\x0b-\x1f\x7f-\x9f]", "", member.nick or member.user.name or ""
)
role = member.role role = member.role
db_user_uid = [u.user_id for u in db_user] db_user_uid = [u.user_id for u in db_user]
uid2name = {u.user_id: u.user_name for u in db_user} uid2name = {u.user_id: u.user_name for u in db_user}

View File

@ -1,7 +1,7 @@
from nonebot.adapters import Bot from nonebot.adapters import Bot
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import AlconnaQuery, Arparma, Match, Query from nonebot_plugin_alconna import AlconnaQuery, Arparma, Match, Query
from nonebot_plugin_session import EventSession from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.config import Config from zhenxun.configs.config import Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.configs.utils import PluginExtraData, RegisterConfig
@ -9,7 +9,7 @@ from zhenxun.services.log import logger
from zhenxun.utils.enum import BlockType, PluginType from zhenxun.utils.enum import BlockType, PluginType
from zhenxun.utils.message import MessageUtils from zhenxun.utils.message import MessageUtils
from ._data_source import PluginManage, build_plugin, build_task, delete_help_image from ._data_source import PluginManager, build_plugin, build_task, delete_help_image
from .command import _group_status_matcher, _status_matcher from .command import _group_status_matcher, _status_matcher
base_config = Config.get("plugin_switch") base_config = Config.get("plugin_switch")
@ -57,6 +57,11 @@ __plugin_meta__ = PluginMetadata(
关闭群被动早晚安 关闭群被动早晚安
关闭群被动早晚安 -g 12355555 关闭群被动早晚安 -g 12355555
开启/关闭默认群被动 [被动名称]
私聊下: 开启/关闭群被动默认状态
示例:
关闭默认群被动 早晚安
开启/关闭所有群被动 ?[-g [group_id]] 开启/关闭所有群被动 ?[-g [group_id]]
私聊中: 开启/关闭全局或指定群组被动状态 私聊中: 开启/关闭全局或指定群组被动状态
示例: 示例:
@ -87,10 +92,10 @@ __plugin_meta__ = PluginMetadata(
@_status_matcher.assign("$main") @_status_matcher.assign("$main")
async def _( async def _(
bot: Bot, bot: Bot,
session: EventSession, session: Uninfo,
arparma: Arparma, arparma: Arparma,
): ):
if session.id1 in bot.config.superusers: if session.user.id in bot.config.superusers:
image = await build_plugin() image = await build_plugin()
logger.info( logger.info(
"查看功能列表", "查看功能列表",
@ -105,7 +110,7 @@ async def _(
@_status_matcher.assign("open") @_status_matcher.assign("open")
async def _( async def _(
bot: Bot, bot: Bot,
session: EventSession, session: Uninfo,
arparma: Arparma, arparma: Arparma,
plugin_name: Match[str], plugin_name: Match[str],
group: Match[str], group: Match[str],
@ -114,22 +119,23 @@ async def _(
all: Query[bool] = AlconnaQuery("all.value", False), all: Query[bool] = AlconnaQuery("all.value", False),
): ):
if not all.result and not plugin_name.available: if not all.result and not plugin_name.available:
await MessageUtils.build_message("请输入功能名称").finish(reply_to=True) await MessageUtils.build_message("请输入功能/被动名称").finish(reply_to=True)
name = plugin_name.result name = plugin_name.result
if gid := session.id3 or session.id2: if session.group:
group_id = session.group.id
"""修改当前群组的数据""" """修改当前群组的数据"""
if task.result: if task.result:
if all.result: if all.result:
result = await PluginManage.unblock_group_all_task(gid) result = await PluginManager.unblock_group_all_task(group_id)
logger.info("开启所有群组被动", arparma.header_result, session=session) logger.info("开启所有群组被动", arparma.header_result, session=session)
else: else:
result = await PluginManage.unblock_group_task(name, gid) result = await PluginManager.unblock_group_task(name, group_id)
logger.info( logger.info(
f"开启群组被动 {name}", arparma.header_result, session=session f"开启群组被动 {name}", arparma.header_result, session=session
) )
elif session.id1 in bot.config.superusers and default_status.result: elif session.user.id in bot.config.superusers and default_status.result:
"""单个插件的进群默认修改""" """单个插件的进群默认修改"""
result = await PluginManage.set_default_status(name, True) result = await PluginManager.set_default_status(name, True)
logger.info( logger.info(
f"超级用户开启 {name} 功能进群默认开关", f"超级用户开启 {name} 功能进群默认开关",
arparma.header_result, arparma.header_result,
@ -137,8 +143,8 @@ async def _(
) )
elif all.result: elif all.result:
"""所有插件""" """所有插件"""
result = await PluginManage.set_all_plugin_status( result = await PluginManager.set_all_plugin_status(
True, default_status.result, gid True, default_status.result, group_id
) )
logger.info( logger.info(
"开启群组中全部功能", "开启群组中全部功能",
@ -146,22 +152,24 @@ async def _(
session=session, session=session,
) )
else: else:
result = await PluginManage.unblock_group_plugin(name, gid) result = await PluginManager.unblock_group_plugin(name, group_id)
logger.info(f"开启功能 {name}", arparma.header_result, session=session) logger.info(f"开启功能 {name}", arparma.header_result, session=session)
delete_help_image(gid) delete_help_image(group_id)
await MessageUtils.build_message(result).finish(reply_to=True) await MessageUtils.build_message(result).finish(reply_to=True)
elif session.id1 in bot.config.superusers: elif session.user.id in bot.config.superusers:
"""私聊""" """私聊"""
group_id = group.result if group.available else None group_id = group.result if group.available else None
if all.result: if all.result:
if task.result: if task.result:
"""关闭全局或指定群全部被动""" """关闭全局或指定群全部被动"""
if group_id: if group_id:
result = await PluginManage.unblock_group_all_task(group_id) result = await PluginManager.unblock_group_all_task(group_id)
else: else:
result = await PluginManage.unblock_global_all_task() result = await PluginManager.unblock_global_all_task(
default_status.result
)
else: else:
result = await PluginManage.set_all_plugin_status( result = await PluginManager.set_all_plugin_status(
True, default_status.result, group_id True, default_status.result, group_id
) )
logger.info( logger.info(
@ -171,8 +179,8 @@ async def _(
session=session, session=session,
) )
await MessageUtils.build_message(result).finish(reply_to=True) await MessageUtils.build_message(result).finish(reply_to=True)
if default_status.result: if default_status.result and not task.result:
result = await PluginManage.set_default_status(name, True) result = await PluginManager.set_default_status(name, True)
logger.info( logger.info(
f"超级用户开启 {name} 功能进群默认开关", f"超级用户开启 {name} 功能进群默认开关",
arparma.header_result, arparma.header_result,
@ -186,7 +194,7 @@ async def _(
name = split_list[0] name = split_list[0]
group_id = split_list[1] group_id = split_list[1]
if group_id: if group_id:
result = await PluginManage.superuser_task_handle(name, group_id, True) result = await PluginManager.superuser_task_handle(name, group_id, True)
logger.info( logger.info(
f"超级用户开启被动技能 {name}", f"超级用户开启被动技能 {name}",
arparma.header_result, arparma.header_result,
@ -194,14 +202,16 @@ async def _(
target=group_id, target=group_id,
) )
else: else:
result = await PluginManage.unblock_global_task(name) result = await PluginManager.unblock_global_task(
name, default_status.result
)
logger.info( logger.info(
f"超级用户开启全局被动技能 {name}", f"超级用户开启全局被动技能 {name}",
arparma.header_result, arparma.header_result,
session=session, session=session,
) )
else: else:
result = await PluginManage.superuser_unblock(name, None, group_id) result = await PluginManager.superuser_unblock(name, None, group_id)
logger.info( logger.info(
f"超级用户开启功能 {name}", f"超级用户开启功能 {name}",
arparma.header_result, arparma.header_result,
@ -215,7 +225,7 @@ async def _(
@_status_matcher.assign("close") @_status_matcher.assign("close")
async def _( async def _(
bot: Bot, bot: Bot,
session: EventSession, session: Uninfo,
arparma: Arparma, arparma: Arparma,
plugin_name: Match[str], plugin_name: Match[str],
block_type: Match[str], block_type: Match[str],
@ -225,22 +235,23 @@ async def _(
all: Query[bool] = AlconnaQuery("all.value", False), all: Query[bool] = AlconnaQuery("all.value", False),
): ):
if not all.result and not plugin_name.available: if not all.result and not plugin_name.available:
await MessageUtils.build_message("请输入功能名称").finish(reply_to=True) await MessageUtils.build_message("请输入功能/被动名称").finish(reply_to=True)
name = plugin_name.result name = plugin_name.result
if gid := session.id3 or session.id2: if session.group:
group_id = session.group.id
"""修改当前群组的数据""" """修改当前群组的数据"""
if task.result: if task.result:
if all.result: if all.result:
result = await PluginManage.block_group_all_task(gid) result = await PluginManager.block_group_all_task(group_id)
logger.info("开启所有群组被动", arparma.header_result, session=session) logger.info("开启所有群组被动", arparma.header_result, session=session)
else: else:
result = await PluginManage.block_group_task(name, gid) result = await PluginManager.block_group_task(name, group_id)
logger.info( logger.info(
f"关闭群组被动 {name}", arparma.header_result, session=session f"关闭群组被动 {name}", arparma.header_result, session=session
) )
elif session.id1 in bot.config.superusers and default_status.result: elif session.user.id in bot.config.superusers and default_status.result:
"""单个插件的进群默认修改""" """单个插件的进群默认修改"""
result = await PluginManage.set_default_status(name, False) result = await PluginManager.set_default_status(name, False)
logger.info( logger.info(
f"超级用户开启 {name} 功能进群默认开关", f"超级用户开启 {name} 功能进群默认开关",
arparma.header_result, arparma.header_result,
@ -248,26 +259,28 @@ async def _(
) )
elif all.result: elif all.result:
"""所有插件""" """所有插件"""
result = await PluginManage.set_all_plugin_status( result = await PluginManager.set_all_plugin_status(
False, default_status.result, gid False, default_status.result, group_id
) )
logger.info("关闭群组中全部功能", arparma.header_result, session=session) logger.info("关闭群组中全部功能", arparma.header_result, session=session)
else: else:
result = await PluginManage.block_group_plugin(name, gid) result = await PluginManager.block_group_plugin(name, group_id)
logger.info(f"关闭功能 {name}", arparma.header_result, session=session) logger.info(f"关闭功能 {name}", arparma.header_result, session=session)
delete_help_image(gid) delete_help_image(group_id)
await MessageUtils.build_message(result).finish(reply_to=True) await MessageUtils.build_message(result).finish(reply_to=True)
elif session.id1 in bot.config.superusers: elif session.user.id in bot.config.superusers:
group_id = group.result if group.available else None group_id = group.result if group.available else None
if all.result: if all.result:
if task.result: if task.result:
"""关闭全局或指定群全部被动""" """关闭全局或指定群全部被动"""
if group_id: if group_id:
result = await PluginManage.block_group_all_task(group_id) result = await PluginManager.block_group_all_task(group_id)
else: else:
result = await PluginManage.block_global_all_task() result = await PluginManager.block_global_all_task(
default_status.result
)
else: else:
result = await PluginManage.set_all_plugin_status( result = await PluginManager.set_all_plugin_status(
False, default_status.result, group_id False, default_status.result, group_id
) )
logger.info( logger.info(
@ -277,8 +290,8 @@ async def _(
session=session, session=session,
) )
await MessageUtils.build_message(result).finish(reply_to=True) await MessageUtils.build_message(result).finish(reply_to=True)
if default_status.result: if default_status.result and not task.result:
result = await PluginManage.set_default_status(name, False) result = await PluginManager.set_default_status(name, False)
logger.info( logger.info(
f"超级用户关闭 {name} 功能进群默认开关", f"超级用户关闭 {name} 功能进群默认开关",
arparma.header_result, arparma.header_result,
@ -292,7 +305,9 @@ async def _(
name = split_list[0] name = split_list[0]
group_id = split_list[1] group_id = split_list[1]
if group_id: if group_id:
result = await PluginManage.superuser_task_handle(name, group_id, False) result = await PluginManager.superuser_task_handle(
name, group_id, False
)
logger.info( logger.info(
f"超级用户关闭被动技能 {name}", f"超级用户关闭被动技能 {name}",
arparma.header_result, arparma.header_result,
@ -300,7 +315,9 @@ async def _(
target=group_id, target=group_id,
) )
else: else:
result = await PluginManage.block_global_task(name) result = await PluginManager.block_global_task(
name, default_status.result
)
logger.info( logger.info(
f"超级用户关闭全局被动技能 {name}", f"超级用户关闭全局被动技能 {name}",
arparma.header_result, arparma.header_result,
@ -314,7 +331,7 @@ async def _(
elif block_type.result in ["g", "group"]: elif block_type.result in ["g", "group"]:
if block_type.available: if block_type.available:
_type = BlockType.GROUP _type = BlockType.GROUP
result = await PluginManage.superuser_block(name, _type, group_id) result = await PluginManager.superuser_block(name, _type, group_id)
logger.info( logger.info(
f"超级用户关闭功能 {name}, 禁用类型: {_type}", f"超级用户关闭功能 {name}, 禁用类型: {_type}",
arparma.header_result, arparma.header_result,
@ -327,19 +344,20 @@ async def _(
@_group_status_matcher.handle() @_group_status_matcher.handle()
async def _( async def _(
session: EventSession, session: Uninfo,
arparma: Arparma, arparma: Arparma,
status: str, status: str,
): ):
if gid := session.id3 or session.id2: if session.group:
group_id = session.group.id
if status == "sleep": if status == "sleep":
await PluginManage.sleep(gid) await PluginManager.sleep(group_id)
logger.info("进行休眠", arparma.header_result, session=session) logger.info("进行休眠", arparma.header_result, session=session)
await MessageUtils.build_message("那我先睡觉了...").finish() await MessageUtils.build_message("那我先睡觉了...").finish()
else: else:
if await PluginManage.is_wake(gid): if await PluginManager.is_wake(group_id):
await MessageUtils.build_message("我还醒着呢!").finish() await MessageUtils.build_message("我还醒着呢!").finish()
await PluginManage.wake(gid) await PluginManager.wake(group_id)
logger.info("醒来", arparma.header_result, session=session) logger.info("醒来", arparma.header_result, session=session)
await MessageUtils.build_message("呜..醒来了...").finish() await MessageUtils.build_message("呜..醒来了...").finish()
return MessageUtils.build_message("群组id为空...").send() return MessageUtils.build_message("群组id为空...").send()
@ -347,10 +365,10 @@ async def _(
@_status_matcher.assign("task") @_status_matcher.assign("task")
async def _( async def _(
session: EventSession, session: Uninfo,
arparma: Arparma, arparma: Arparma,
): ):
image = await build_task(session.id3 or session.id2) image = await build_task(session.group.id if session.group else None)
if image: if image:
logger.info("查看群被动列表", arparma.header_result, session=session) logger.info("查看群被动列表", arparma.header_result, session=session)
await MessageUtils.build_message(image).finish(reply_to=True) await MessageUtils.build_message(image).finish(reply_to=True)

View File

@ -1,10 +1,13 @@
import os import os
from typing import cast
from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH
from zhenxun.models.group_console import GroupConsole from zhenxun.models.group_console import GroupConsole
from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.task_info import TaskInfo from zhenxun.models.task_info import TaskInfo
from zhenxun.utils.enum import BlockType, PluginType from zhenxun.services.cache import CacheRoot
from zhenxun.utils.common_utils import CommonUtils
from zhenxun.utils.enum import BlockType, CacheType, PluginType
from zhenxun.utils.exception import GroupInfoNotFound from zhenxun.utils.exception import GroupInfoNotFound
from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle
@ -116,9 +119,7 @@ async def build_task(group_id: str | None) -> BuildImage:
column_name = ["ID", "模块", "名称", "群组状态", "全局状态", "运行时间"] column_name = ["ID", "模块", "名称", "群组状态", "全局状态", "运行时间"]
group = None group = None
if group_id: if group_id:
group = await GroupConsole.get_or_none( group = await GroupConsole.get_group(group_id=group_id)
group_id=group_id, channel_id__isnull=True
)
if not group: if not group:
raise GroupInfoNotFound() raise GroupInfoNotFound()
else: else:
@ -155,7 +156,7 @@ async def build_task(group_id: str | None) -> BuildImage:
) )
class PluginManage: class PluginManager:
@classmethod @classmethod
async def set_default_status(cls, plugin_name: str, status: bool) -> str: async def set_default_status(cls, plugin_name: str, status: bool) -> str:
"""设置插件进群默认状态 """设置插件进群默认状态
@ -200,26 +201,26 @@ class PluginManage:
) )
return f"成功将所有功能进群默认状态修改为: {'开启' if status else '关闭'}" return f"成功将所有功能进群默认状态修改为: {'开启' if status else '关闭'}"
if group_id: if group_id:
if group := await GroupConsole.get_or_none( if group := await GroupConsole.get_group(group_id=group_id):
group_id=group_id, channel_id__isnull=True module_list = cast(
): list[str],
module_list = await PluginInfo.filter( await PluginInfo.filter(plugin_type=PluginType.NORMAL).values_list(
plugin_type=PluginType.NORMAL "module", flat=True
).values_list("module", flat=True) ),
)
if status: if status:
for module in module_list: # 开启所有功能 - 清空禁用列表
group.block_plugin = group.block_plugin.replace( group.block_plugin = ""
f"<{module},", ""
)
else: else:
module_list = [f"<{module}" for module in module_list] # 关闭所有功能 - 将模块列表转换为禁用格式
group.block_plugin = ",".join(module_list) + "," # type: ignore group.block_plugin = CommonUtils.convert_module_format(module_list)
await group.save(update_fields=["block_plugin"]) await group.save(update_fields=["block_plugin"])
return f"成功将此群组所有功能状态修改为: {'开启' if status else '关闭'}" return f"成功将此群组所有功能状态修改为: {'开启' if status else '关闭'}"
return "获取群组失败..." return "获取群组失败..."
await PluginInfo.filter(plugin_type=PluginType.NORMAL).update( await PluginInfo.filter(plugin_type=PluginType.NORMAL).update(
status=status, block_type=None if status else BlockType.ALL status=status, block_type=None if status else BlockType.ALL
) )
await CacheRoot.invalidate_cache(CacheType.PLUGINS)
return f"成功将所有功能全局状态修改为: {'开启' if status else '关闭'}" return f"成功将所有功能全局状态修改为: {'开启' if status else '关闭'}"
@classmethod @classmethod
@ -232,9 +233,7 @@ class PluginManage:
返回: 返回:
bool: 是否醒来 bool: 是否醒来
""" """
if c := await GroupConsole.get_or_none( if c := await GroupConsole.get_group(group_id=group_id):
group_id=group_id, channel_id__isnull=True
):
return c.status return c.status
return False return False
@ -245,9 +244,11 @@ class PluginManage:
参数: 参数:
group_id: 群组id group_id: 群组id
""" """
await GroupConsole.filter(group_id=group_id, channel_id__isnull=True).update( group, _ = await GroupConsole.get_or_create(
status=False group_id=group_id, channel_id__isnull=True
) )
group.status = False
await group.save(update_fields=["status"])
@classmethod @classmethod
async def wake(cls, group_id: str): async def wake(cls, group_id: str):
@ -256,9 +257,11 @@ class PluginManage:
参数: 参数:
group_id: 群组id group_id: 群组id
""" """
await GroupConsole.filter(group_id=group_id, channel_id__isnull=True).update( group, _ = await GroupConsole.get_or_create(
status=True group_id=group_id, channel_id__isnull=True
) )
group.status = True
await group.save(update_fields=["status"])
@classmethod @classmethod
async def block(cls, module: str): async def block(cls, module: str):
@ -267,7 +270,9 @@ class PluginManage:
参数: 参数:
module: 模块名 module: 模块名
""" """
await PluginInfo.filter(module=module).update(status=False) if plugin := await PluginInfo.get_plugin(module=module):
plugin.status = False
await plugin.save(update_fields=["status"])
@classmethod @classmethod
async def unblock(cls, module: str): async def unblock(cls, module: str):
@ -276,7 +281,9 @@ class PluginManage:
参数: 参数:
module: 模块名 module: 模块名
""" """
await PluginInfo.filter(module=module).update(status=True) if plugin := await PluginInfo.get_plugin(module=module):
plugin.status = True
await plugin.save(update_fields=["status"])
@classmethod @classmethod
async def block_group_plugin(cls, plugin_name: str, group_id: str) -> str: async def block_group_plugin(cls, plugin_name: str, group_id: str) -> str:
@ -342,17 +349,21 @@ class PluginManage:
return await cls._change_group_task("", group_id, True, True) return await cls._change_group_task("", group_id, True, True)
@classmethod @classmethod
async def block_global_all_task(cls) -> str: async def block_global_all_task(cls, is_default: bool) -> str:
"""禁用全局被动技能 """禁用全局被动技能
返回: 返回:
str: 返回信息 str: 返回信息
""" """
await TaskInfo.all().update(status=False) if is_default:
return "已全局禁用所有被动状态" await TaskInfo.all().update(default_status=False)
return "已禁用所有被动进群默认状态"
else:
await TaskInfo.all().update(status=False)
return "已全局禁用所有被动状态"
@classmethod @classmethod
async def block_global_task(cls, name: str) -> str: async def block_global_task(cls, name: str, is_default: bool = False) -> str:
"""禁用全局被动技能 """禁用全局被动技能
参数: 参数:
@ -361,31 +372,47 @@ class PluginManage:
返回: 返回:
str: 返回信息 str: 返回信息
""" """
await TaskInfo.filter(name=name).update(status=False) if is_default:
return f"已全局禁用被动状态 {name}" await TaskInfo.filter(name=name).update(default_status=False)
return f"已禁用被动进群默认状态 {name}"
else:
await TaskInfo.filter(name=name).update(status=False)
return f"已全局禁用被动状态 {name}"
@classmethod @classmethod
async def unblock_global_all_task(cls) -> str: async def unblock_global_all_task(cls, is_default: bool) -> str:
"""开启全局被动技能 """开启全局被动技能
参数:
is_default: 是否为默认状态
返回: 返回:
str: 返回信息 str: 返回信息
""" """
await TaskInfo.all().update(status=True) if is_default:
return "已全局开启所有被动状态" await TaskInfo.all().update(default_status=True)
return "已开启所有被动进群默认状态"
else:
await TaskInfo.all().update(status=True)
return "已全局开启所有被动状态"
@classmethod @classmethod
async def unblock_global_task(cls, name: str) -> str: async def unblock_global_task(cls, name: str, is_default: bool = False) -> str:
"""开启全局被动技能 """开启全局被动技能
参数: 参数:
name: 被动技能名称 name: 被动技能名称
is_default: 是否为默认状态
返回: 返回:
str: 返回信息 str: 返回信息
""" """
await TaskInfo.filter(name=name).update(status=True) if is_default:
return f"已全局开启被动状态 {name}" await TaskInfo.filter(name=name).update(default_status=True)
return f"已开启被动进群默认状态 {name}"
else:
await TaskInfo.filter(name=name).update(status=True)
return f"已全局开启被动状态 {name}"
@classmethod @classmethod
async def unblock_group_plugin(cls, plugin_name: str, group_id: str) -> str: async def unblock_group_plugin(cls, plugin_name: str, group_id: str) -> str:
@ -417,17 +444,18 @@ class PluginManage:
""" """
status_str = "关闭" if status else "开启" status_str = "关闭" if status else "开启"
if is_all: if is_all:
modules = await TaskInfo.annotate().values_list("module", flat=True) module_list = cast(
if modules: list[str], await TaskInfo.annotate().values_list("module", flat=True)
)
if module_list:
group, _ = await GroupConsole.get_or_create( group, _ = await GroupConsole.get_or_create(
group_id=group_id, channel_id__isnull=True group_id=group_id, channel_id__isnull=True
) )
modules = [f"<{module}" for module in modules]
if status: if status:
group.block_task = ",".join(modules) + "," # type: ignore group.block_task = CommonUtils.convert_module_format(module_list)
else: else:
for module in modules: # 开启所有模块 - 清空禁用列表
group.block_task = group.block_task.replace(f"{module},", "") group.block_task = ""
await group.save(update_fields=["block_task"]) await group.save(update_fields=["block_task"])
return f"已成功{status_str}全部被动技能!" return f"已成功{status_str}全部被动技能!"
elif task := await TaskInfo.get_or_none(name=task_name): elif task := await TaskInfo.get_or_none(name=task_name):

View File

@ -58,6 +58,19 @@ _status_matcher.shortcut(
prefix=True, prefix=True,
) )
_status_matcher.shortcut(
r"开启(所有|全部)默认群被动",
command="switch",
arguments=["open", "--task", "--all", "-df"],
prefix=True,
)
_status_matcher.shortcut(
r"关闭(所有|全部)默认群被动",
command="switch",
arguments=["close", "--task", "--all", "-df"],
prefix=True,
)
_status_matcher.shortcut( _status_matcher.shortcut(
r"开启群被动\s*(?P<name>.+)", r"开启群被动\s*(?P<name>.+)",
@ -73,6 +86,20 @@ _status_matcher.shortcut(
prefix=True, prefix=True,
) )
_status_matcher.shortcut(
r"开启默认群被动\s*(?P<name>.+)",
command="switch",
arguments=["open", "{name}", "--task", "-df"],
prefix=True,
)
_status_matcher.shortcut(
r"关闭默认群被动\s*(?P<name>.+)",
command="switch",
arguments=["close", "{name}", "--task", "-df"],
prefix=True,
)
_status_matcher.shortcut( _status_matcher.shortcut(
r"开启(所有|全部)群被动", r"开启(所有|全部)群被动",

View File

@ -11,7 +11,7 @@ from nonebot_plugin_alconna import (
on_alconna, on_alconna,
store_true, store_true,
) )
from nonebot_plugin_session import EventSession from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.utils import PluginExtraData from zhenxun.configs.utils import PluginExtraData
from zhenxun.services.log import logger from zhenxun.services.log import logger
@ -22,7 +22,7 @@ from zhenxun.utils.manager.resource_manager import (
) )
from zhenxun.utils.message import MessageUtils from zhenxun.utils.message import MessageUtils
from ._data_source import UpdateManage from ._data_source import UpdateManager
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
name="自动更新", name="自动更新",
@ -32,16 +32,18 @@ __plugin_meta__ = PluginMetadata(
检查更新真寻最新版本包括了自动更新 检查更新真寻最新版本包括了自动更新
资源文件大小一般在130mb左右除非必须更新一般仅更新代码文件 资源文件大小一般在130mb左右除非必须更新一般仅更新代码文件
指令 指令
检查更新 [main|release|resource] ?[-r] 检查更新 [main|release|resource|webui] ?[-r]
main: main分支 main: main分支
release: 最新release release: 最新release
resource: 资源文件 resource: 资源文件
webui: webui文件
-r: 下载资源文件一般在更新main或release时使用 -r: 下载资源文件一般在更新main或release时使用
示例: 示例:
检查更新 main 检查更新 main
检查更新 main -r 检查更新 main -r
检查更新 release -r 检查更新 release -r
检查更新 resource 检查更新 resource
检查更新 webui
""".strip(), """.strip(),
extra=PluginExtraData( extra=PluginExtraData(
author="HibiKier", author="HibiKier",
@ -53,7 +55,7 @@ __plugin_meta__ = PluginMetadata(
_matcher = on_alconna( _matcher = on_alconna(
Alconna( Alconna(
"检查更新", "检查更新",
Args["ver_type?", ["main", "release", "resource"]], Args["ver_type?", ["main", "release", "resource", "webui"]],
Option("-r|--resource", action=store_true, help_text="下载资源文件"), Option("-r|--resource", action=store_true, help_text="下载资源文件"),
), ),
priority=1, priority=1,
@ -66,23 +68,24 @@ _matcher = on_alconna(
@_matcher.handle() @_matcher.handle()
async def _( async def _(
bot: Bot, bot: Bot,
session: EventSession, session: Uninfo,
ver_type: Match[str], ver_type: Match[str],
resource: Query[bool] = Query("resource", False), resource: Query[bool] = Query("resource", False),
): ):
if not session.id1:
await MessageUtils.build_message("用户id为空...").finish()
result = "" result = ""
await MessageUtils.build_message("正在进行检查更新...").send(reply_to=True)
if ver_type.result in {"main", "release"}: if ver_type.result in {"main", "release"}:
if not ver_type.available: if not ver_type.available:
result = await UpdateManage.check_version() result = await UpdateManager.check_version()
logger.info("查看当前版本...", "检查更新", session=session) logger.info("查看当前版本...", "检查更新", session=session)
await MessageUtils.build_message(result).finish() await MessageUtils.build_message(result).finish()
try: try:
result = await UpdateManage.update(bot, session.id1, ver_type.result) result = await UpdateManager.update(bot, session.user.id, ver_type.result)
except Exception as e: except Exception as e:
logger.error("版本更新失败...", "检查更新", session=session, e=e) logger.error("版本更新失败...", "检查更新", session=session, e=e)
await MessageUtils.build_message(f"更新版本失败...e: {e}").finish() await MessageUtils.build_message(f"更新版本失败...e: {e}").finish()
elif ver_type.result == "webui":
result = await UpdateManager.update_webui()
if resource.result or ver_type.result == "resource": if resource.result or ver_type.result == "resource":
try: try:
await ResourceManager.init_resources(True) await ResourceManager.init_resources(True)

View File

@ -7,6 +7,7 @@ import zipfile
from nonebot.adapters import Bot from nonebot.adapters import Bot
from nonebot.utils import run_sync from nonebot.utils import run_sync
from zhenxun.configs.path_config import DATA_PATH
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.github_utils import GithubUtils from zhenxun.utils.github_utils import GithubUtils
from zhenxun.utils.github_utils.models import RepoInfo from zhenxun.utils.github_utils.models import RepoInfo
@ -17,6 +18,7 @@ from .config import (
BACKUP_PATH, BACKUP_PATH,
BASE_PATH, BASE_PATH,
BASE_PATH_STRING, BASE_PATH_STRING,
COMMAND,
DEFAULT_GITHUB_URL, DEFAULT_GITHUB_URL,
DOWNLOAD_GZ_FILE, DOWNLOAD_GZ_FILE,
DOWNLOAD_ZIP_FILE, DOWNLOAD_ZIP_FILE,
@ -38,7 +40,7 @@ def install_requirement():
if not requirement_path.exists(): if not requirement_path.exists():
logger.debug( logger.debug(
f"没有找到zhenxun的requirement.txt,目标路径为{requirement_path}", "插件管理" f"没有找到zhenxun的requirement.txt,目标路径为{requirement_path}", COMMAND
) )
return return
try: try:
@ -48,9 +50,9 @@ def install_requirement():
capture_output=True, capture_output=True,
text=True, text=True,
) )
logger.debug(f"成功安装真寻依赖,日志:\n{result.stdout}", "插件管理") logger.debug(f"成功安装真寻依赖,日志:\n{result.stdout}", COMMAND)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"安装真寻依赖失败,错误:\n{e.stderr}", "插件管理", e=e) logger.error(f"安装真寻依赖失败,错误:\n{e.stderr}", COMMAND, e=e)
@run_sync @run_sync
@ -61,7 +63,7 @@ def _file_handle(latest_version: str | None):
latest_version: 版本号 latest_version: 版本号
""" """
BACKUP_PATH.mkdir(exist_ok=True, parents=True) BACKUP_PATH.mkdir(exist_ok=True, parents=True)
logger.debug("开始解压文件压缩包...", "检查更新") logger.debug("开始解压文件压缩包...", COMMAND)
download_file = DOWNLOAD_GZ_FILE download_file = DOWNLOAD_GZ_FILE
if DOWNLOAD_GZ_FILE.exists(): if DOWNLOAD_GZ_FILE.exists():
tf = tarfile.open(DOWNLOAD_GZ_FILE) tf = tarfile.open(DOWNLOAD_GZ_FILE)
@ -69,7 +71,7 @@ def _file_handle(latest_version: str | None):
download_file = DOWNLOAD_ZIP_FILE download_file = DOWNLOAD_ZIP_FILE
tf = zipfile.ZipFile(DOWNLOAD_ZIP_FILE) tf = zipfile.ZipFile(DOWNLOAD_ZIP_FILE)
tf.extractall(TMP_PATH) tf.extractall(TMP_PATH)
logger.debug("解压文件压缩包完成...", "检查更新") logger.debug("解压文件压缩包完成...", COMMAND)
download_file_path = TMP_PATH / next( download_file_path = TMP_PATH / next(
x for x in os.listdir(TMP_PATH) if (TMP_PATH / x).is_dir() x for x in os.listdir(TMP_PATH) if (TMP_PATH / x).is_dir()
) )
@ -79,52 +81,52 @@ def _file_handle(latest_version: str | None):
extract_path = download_file_path / BASE_PATH_STRING extract_path = download_file_path / BASE_PATH_STRING
target_path = BASE_PATH target_path = BASE_PATH
if PYPROJECT_FILE.exists(): if PYPROJECT_FILE.exists():
logger.debug(f"移除备份文件: {PYPROJECT_FILE}", "检查更新") logger.debug(f"移除备份文件: {PYPROJECT_FILE}", COMMAND)
shutil.move(PYPROJECT_FILE, BACKUP_PATH / PYPROJECT_FILE_STRING) shutil.move(PYPROJECT_FILE, BACKUP_PATH / PYPROJECT_FILE_STRING)
if PYPROJECT_LOCK_FILE.exists(): if PYPROJECT_LOCK_FILE.exists():
logger.debug(f"移除备份文件: {PYPROJECT_LOCK_FILE}", "检查更新") logger.debug(f"移除备份文件: {PYPROJECT_LOCK_FILE}", COMMAND)
shutil.move(PYPROJECT_LOCK_FILE, BACKUP_PATH / PYPROJECT_LOCK_FILE_STRING) shutil.move(PYPROJECT_LOCK_FILE, BACKUP_PATH / PYPROJECT_LOCK_FILE_STRING)
if REQ_TXT_FILE.exists(): if REQ_TXT_FILE.exists():
logger.debug(f"移除备份文件: {REQ_TXT_FILE}", "检查更新") logger.debug(f"移除备份文件: {REQ_TXT_FILE}", COMMAND)
shutil.move(REQ_TXT_FILE, BACKUP_PATH / REQ_TXT_FILE_STRING) shutil.move(REQ_TXT_FILE, BACKUP_PATH / REQ_TXT_FILE_STRING)
if _pyproject.exists(): if _pyproject.exists():
logger.debug("移动文件: pyproject.toml", "检查更新") logger.debug("移动文件: pyproject.toml", COMMAND)
shutil.move(_pyproject, PYPROJECT_FILE) shutil.move(_pyproject, PYPROJECT_FILE)
if _lock_file.exists(): if _lock_file.exists():
logger.debug("移动文件: poetry.lock", "检查更新") logger.debug("移动文件: poetry.lock", COMMAND)
shutil.move(_lock_file, PYPROJECT_LOCK_FILE) shutil.move(_lock_file, PYPROJECT_LOCK_FILE)
if _req_file.exists(): if _req_file.exists():
logger.debug("移动文件: requirements.txt", "检查更新") logger.debug("移动文件: requirements.txt", COMMAND)
shutil.move(_req_file, REQ_TXT_FILE) shutil.move(_req_file, REQ_TXT_FILE)
for folder in REPLACE_FOLDERS: for folder in REPLACE_FOLDERS:
"""移动指定文件夹""" """移动指定文件夹"""
_dir = BASE_PATH / folder _dir = BASE_PATH / folder
_backup_dir = BACKUP_PATH / folder _backup_dir = BACKUP_PATH / folder
if _backup_dir.exists(): if _backup_dir.exists():
logger.debug(f"删除备份文件夹 {_backup_dir}", "检查更新") logger.debug(f"删除备份文件夹 {_backup_dir}", COMMAND)
shutil.rmtree(_backup_dir) shutil.rmtree(_backup_dir)
if _dir.exists(): if _dir.exists():
logger.debug(f"移动旧文件夹 {_dir}", "检查更新") logger.debug(f"移动旧文件夹 {_dir}", COMMAND)
shutil.move(_dir, _backup_dir) shutil.move(_dir, _backup_dir)
else: else:
logger.warning(f"文件夹 {_dir} 不存在,跳过删除", "检查更新") logger.warning(f"文件夹 {_dir} 不存在,跳过删除", COMMAND)
for folder in REPLACE_FOLDERS: for folder in REPLACE_FOLDERS:
src_folder_path = extract_path / folder src_folder_path = extract_path / folder
dest_folder_path = target_path / folder dest_folder_path = target_path / folder
if src_folder_path.exists(): if src_folder_path.exists():
logger.debug( logger.debug(
f"移动文件夹: {src_folder_path} -> {dest_folder_path}", "检查更新" f"移动文件夹: {src_folder_path} -> {dest_folder_path}", COMMAND
) )
shutil.move(src_folder_path, dest_folder_path) shutil.move(src_folder_path, dest_folder_path)
else: else:
logger.debug(f"源文件夹不存在: {src_folder_path}", "检查更新") logger.debug(f"源文件夹不存在: {src_folder_path}", COMMAND)
if tf: if tf:
tf.close() tf.close()
if download_file.exists(): if download_file.exists():
logger.debug(f"删除下载文件: {download_file}", "检查更新") logger.debug(f"删除下载文件: {download_file}", COMMAND)
download_file.unlink() download_file.unlink()
if extract_path.exists(): if extract_path.exists():
logger.debug(f"删除解压文件夹: {extract_path}", "检查更新") logger.debug(f"删除解压文件夹: {extract_path}", COMMAND)
shutil.rmtree(extract_path) shutil.rmtree(extract_path)
if TMP_PATH.exists(): if TMP_PATH.exists():
shutil.rmtree(TMP_PATH) shutil.rmtree(TMP_PATH)
@ -134,7 +136,35 @@ def _file_handle(latest_version: str | None):
install_requirement() install_requirement()
class UpdateManage: class UpdateManager:
@classmethod
async def update_webui(cls) -> str:
from zhenxun.builtin_plugins.web_ui.public.data_source import (
update_webui_assets,
)
WEBUI_PATH = DATA_PATH / "web_ui" / "public"
BACKUP_PATH = DATA_PATH / "web_ui" / "backup_public"
if WEBUI_PATH.exists():
if BACKUP_PATH.exists():
logger.debug(f"删除旧的备份webui文件夹 {BACKUP_PATH}", COMMAND)
shutil.rmtree(BACKUP_PATH)
WEBUI_PATH.rename(BACKUP_PATH)
try:
await update_webui_assets()
logger.info("更新webui成功...", COMMAND)
if BACKUP_PATH.exists():
logger.debug(f"删除旧的webui文件夹 {BACKUP_PATH}", COMMAND)
shutil.rmtree(BACKUP_PATH)
return "Webui更新成功"
except Exception as e:
logger.error("更新webui失败...", COMMAND, e=e)
if BACKUP_PATH.exists():
logger.debug(f"恢复旧的webui文件夹 {BACKUP_PATH}", COMMAND)
BACKUP_PATH.rename(WEBUI_PATH)
raise e
return ""
@classmethod @classmethod
async def check_version(cls) -> str: async def check_version(cls) -> str:
"""检查更新版本 """检查更新版本
@ -166,7 +196,7 @@ class UpdateManage:
返回: 返回:
str | None: 返回消息 str | None: 返回消息
""" """
logger.info("开始下载真寻最新版文件....", "检查更新") logger.info("开始下载真寻最新版文件....", COMMAND)
cur_version = cls.__get_version() cur_version = cls.__get_version()
url = None url = None
new_version = None new_version = None
@ -186,11 +216,11 @@ class UpdateManage:
if not url: if not url:
return "获取版本下载链接失败..." return "获取版本下载链接失败..."
if TMP_PATH.exists(): if TMP_PATH.exists():
logger.debug(f"删除临时文件夹 {TMP_PATH}", "检查更新") logger.debug(f"删除临时文件夹 {TMP_PATH}", COMMAND)
shutil.rmtree(TMP_PATH) shutil.rmtree(TMP_PATH)
logger.debug( logger.debug(
f"开始更新版本:{cur_version} -> {new_version} | 下载链接:{url}", f"开始更新版本:{cur_version} -> {new_version} | 下载链接:{url}",
"检查更新", COMMAND,
) )
await PlatformUtils.send_superuser( await PlatformUtils.send_superuser(
bot, bot,
@ -201,7 +231,7 @@ class UpdateManage:
DOWNLOAD_GZ_FILE if version_type == "release" else DOWNLOAD_ZIP_FILE DOWNLOAD_GZ_FILE if version_type == "release" else DOWNLOAD_ZIP_FILE
) )
if await AsyncHttpx.download_file(url, download_file, stream=True): if await AsyncHttpx.download_file(url, download_file, stream=True):
logger.debug("下载真寻最新版文件完成...", "检查更新") logger.debug("下载真寻最新版文件完成...", COMMAND)
await _file_handle(new_version) await _file_handle(new_version)
result = "版本更新完成" result = "版本更新完成"
return ( return (
@ -210,7 +240,7 @@ class UpdateManage:
"请重新启动真寻以完成更新!" "请重新启动真寻以完成更新!"
) )
else: else:
logger.debug("下载真寻最新版文件失败...", "检查更新") logger.debug("下载真寻最新版文件失败...", COMMAND)
return "" return ""
@classmethod @classmethod

View File

@ -34,3 +34,5 @@ REPLACE_FOLDERS = [
"models", "models",
"configs", "configs",
] ]
COMMAND = "检查更新"

View File

@ -0,0 +1,58 @@
from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata
from nonebot.rule import to_me
from nonebot_plugin_alconna import Alconna, Arparma, on_alconna
from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.config import BotConfig
from zhenxun.configs.utils import PluginExtraData
from zhenxun.services.log import logger
from zhenxun.utils.manager.bot_profile_manager import BotProfileManager
from zhenxun.utils.message import MessageUtils
__plugin_meta__ = PluginMetadata(
name="自我介绍",
description=f"这是{BotConfig.self_nickname}的深情告白",
usage="""
指令
自我介绍
""".strip(),
extra=PluginExtraData(
author="HibiKier",
version="0.1",
menu_type="其他",
superuser_help="""
在data/bot_profile/bot_id/profile.txt 中编辑BOT自我介绍
在data/bot_profile/bot_id/bot_id.png 中编辑BOT头像
指令
重载自我介绍
""".strip(),
).to_dict(),
)
_matcher = on_alconna(Alconna("自我介绍"), priority=5, block=True, rule=to_me())
_reload_matcher = on_alconna(
Alconna("重载自我介绍"), priority=1, block=True, permission=SUPERUSER
)
@_matcher.handle()
async def _(session: Uninfo, arparma: Arparma):
file_path = await BotProfileManager.build_bot_profile_image(session.self_id)
if not file_path:
await MessageUtils.build_message(
f"{BotConfig.self_nickname}当前没有自我简介哦"
).finish(reply_to=True)
await MessageUtils.build_message(file_path).send()
logger.info("BOT自我介绍", arparma.header_result, session=session)
@_reload_matcher.handle()
async def _(session: Uninfo, arparma: Arparma):
BotProfileManager.clear_profile_image(session.self_id)
await MessageUtils.build_message(f"重载{BotConfig.self_nickname}自我介绍成功").send(
reply_to=True
)
logger.info("重载BOT自我介绍", arparma.header_result, session=session)

View File

@ -1,13 +1,15 @@
from nonebot import on_message from nonebot import on_message
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import UniMsg from nonebot_plugin_alconna import UniMsg
from nonebot_plugin_session import EventSession from nonebot_plugin_apscheduler import scheduler
from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.config import Config from zhenxun.configs.config import Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.models.chat_history import ChatHistory from zhenxun.models.chat_history import ChatHistory
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import PluginType
from zhenxun.utils.utils import get_entity_ids
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
name="消息存储", name="消息存储",
@ -37,18 +39,34 @@ def rule(message: UniMsg) -> bool:
chat_history = on_message(rule=rule, priority=1, block=False) chat_history = on_message(rule=rule, priority=1, block=False)
TEMP_LIST = []
@chat_history.handle() @chat_history.handle()
async def handle_message(message: UniMsg, session: EventSession): async def _(message: UniMsg, session: Uninfo):
"""处理消息存储""" entity = get_entity_ids(session)
try: TEMP_LIST.append(
await ChatHistory.create( ChatHistory(
user_id=session.id1, user_id=entity.user_id,
group_id=session.id2, group_id=entity.group_id,
text=str(message), text=str(message),
plain_text=message.extract_plain_text(), plain_text=message.extract_plain_text(),
bot_id=session.bot_id, bot_id=session.self_id,
platform=session.platform, platform=session.platform,
) )
)
@scheduler.scheduled_job(
"interval",
minutes=1,
)
async def _():
try:
message_list = TEMP_LIST.copy()
TEMP_LIST.clear()
if message_list:
await ChatHistory.bulk_create(message_list)
logger.debug(f"批量添加聊天记录 {len(message_list)}", "定时任务")
except Exception as e: except Exception as e:
logger.warning("存储聊天记录失败", "chat_history", e=e) logger.warning("存储聊天记录失败", "chat_history", e=e)

View File

@ -18,12 +18,13 @@ from zhenxun.builtin_plugins.help._config import (
SIMPLE_DETAIL_HELP_IMAGE, SIMPLE_DETAIL_HELP_IMAGE,
SIMPLE_HELP_IMAGE, SIMPLE_HELP_IMAGE,
) )
from zhenxun.configs.config import Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils from zhenxun.utils.message import MessageUtils
from ._data_source import create_help_img, get_plugin_help from ._data_source import create_help_img, get_llm_help, get_plugin_help
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
name="帮助", name="帮助",
@ -47,6 +48,34 @@ __plugin_meta__ = PluginMetadata(
help="帮助详情图片样式 ['normal', 'zhenxun']", help="帮助详情图片样式 ['normal', 'zhenxun']",
default_value="zhenxun", default_value="zhenxun",
), ),
RegisterConfig(
key="ENABLE_LLM_HELPER",
value=False,
help="是否开启LLM智能帮助功能",
default_value=False,
type=bool,
),
RegisterConfig(
key="DEFAULT_LLM_MODEL",
value="Gemini/gemini-2.5-flash-lite-preview-06-17",
help="智能帮助功能使用的默认LLM模型",
default_value="Gemini/gemini-2.5-flash-lite-preview-06-17",
type=str,
),
RegisterConfig(
key="LLM_HELPER_STYLE",
value="绪山真寻",
help="设置智能帮助功能的回复口吻或风格",
default_value="绪山真寻",
type=str,
),
RegisterConfig(
key="LLM_HELPER_REPLY_AS_IMAGE_THRESHOLD",
value=100,
help="AI帮助回复超过多少字时转为图片发送",
default_value=100,
type=int,
),
], ],
).to_dict(), ).to_dict(),
) )
@ -83,20 +112,36 @@ async def _(
is_detail: Query[bool] = AlconnaQuery("detail.value", False), is_detail: Query[bool] = AlconnaQuery("detail.value", False),
): ):
_is_superuser = is_superuser.result if is_superuser.available else False _is_superuser = is_superuser.result if is_superuser.available else False
if name.available: if name.available:
if _is_superuser and session.user.id not in bot.config.superusers: traditional_help_result = await get_plugin_help(
_is_superuser = False session.user.id, name.result, _is_superuser
if result := await get_plugin_help(session.user.id, name.result, _is_superuser): )
await MessageUtils.build_message(result).send(reply_to=True)
else: is_plugin_found = not (
await MessageUtils.build_message("没有此功能的帮助信息...").send( isinstance(traditional_help_result, str)
and "没有查找到这个功能噢..." in traditional_help_result
)
if is_plugin_found:
await MessageUtils.build_message(traditional_help_result).send(
reply_to=True reply_to=True
) )
logger.info(f"查看帮助详情: {name.result}", "帮助", session=session) logger.info(f"查看帮助详情: {name.result}", "帮助", session=session)
elif Config.get_config("help", "ENABLE_LLM_HELPER"):
logger.info(f"智能帮助处理问题: {name.result}", "帮助", session=session)
llm_answer = await get_llm_help(name.result, session.user.id)
await MessageUtils.build_message(llm_answer).send(reply_to=True)
else:
await MessageUtils.build_message(traditional_help_result).send(
reply_to=True
)
logger.info(
f"查看帮助详情失败,未找到: {name.result}", "帮助", session=session
)
elif session.group and (gid := session.group.id): elif session.group and (gid := session.group.id):
_image_path = GROUP_HELP_PATH / f"{gid}_{is_detail.result}.png" _image_path = GROUP_HELP_PATH / f"{gid}_{is_detail.result}.png"
if not _image_path.exists(): if not _image_path.exists():
result = await create_help_img(session, gid, is_detail.result) await create_help_img(session, gid, is_detail.result)
await MessageUtils.build_message(_image_path).finish() await MessageUtils.build_message(_image_path).finish()
else: else:
if is_detail.result: if is_detail.result:
@ -104,5 +149,5 @@ async def _(
else: else:
_image_path = SIMPLE_HELP_IMAGE _image_path = SIMPLE_HELP_IMAGE
if not _image_path.exists(): if not _image_path.exists():
result = await create_help_img(session, None, is_detail.result) await create_help_img(session, None, is_detail.result)
await MessageUtils.build_message(_image_path).finish() await MessageUtils.build_message(_image_path).finish()

View File

@ -11,9 +11,15 @@ from zhenxun.configs.utils import PluginExtraData
from zhenxun.models.level_user import LevelUser from zhenxun.models.level_user import LevelUser
from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.statistics import Statistics from zhenxun.models.statistics import Statistics
from zhenxun.utils._image_template import ImageTemplate from zhenxun.services import (
LLMException,
LLMMessage,
generate,
)
from zhenxun.services.log import logger
from zhenxun.utils._image_template import Markdown
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import PluginType
from zhenxun.utils.image_utils import BuildImage from zhenxun.utils.image_utils import BuildImage, ImageTemplate
from ._config import ( from ._config import (
GROUP_HELP_PATH, GROUP_HELP_PATH,
@ -202,3 +208,89 @@ async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str |
return await get_normal_help(_plugin.metadata, extra_data, is_superuser) return await get_normal_help(_plugin.metadata, extra_data, is_superuser)
return "糟糕! 该功能没有帮助喔..." return "糟糕! 该功能没有帮助喔..."
return "没有查找到这个功能噢..." return "没有查找到这个功能噢..."
async def get_llm_help(question: str, user_id: str) -> str | bytes:
"""
使用LLM来回答用户的自然语言求助
参数:
question: 用户的问题
user_id: 提问用户的ID
返回:
str | bytes: LLM生成的回答或错误提示
"""
try:
allowed_types = await get_user_allow_help(user_id)
plugins = await PluginInfo.filter(
is_show=True, plugin_type__in=allowed_types
).all()
knowledge_base_parts = []
for p in plugins:
meta = nonebot.get_plugin_by_module_name(p.module_path)
if not meta or not meta.metadata:
continue
usage = meta.metadata.usage.strip() or ""
desc = meta.metadata.description.strip() or ""
part = f"功能名称: {p.name}\n功能描述: {desc}\n用法示例:\n{usage}"
knowledge_base_parts.append(part)
if not knowledge_base_parts:
return "抱歉,根据您的权限,当前没有可供查询的功能信息。"
knowledge_base = "\n\n---\n\n".join(knowledge_base_parts)
user_role = "普通用户"
if PluginType.SUPERUSER in allowed_types:
user_role = "超级管理员"
elif PluginType.ADMIN in allowed_types:
user_role = "管理员"
base_system_prompt = (
f"你是一个精通机器人功能的AI助手。当前向你提问的用户是一位「{user_role}」。\n"
"你的任务是根据下面提供的功能列表和详细说明,来回答用户关于如何使用机器人的问题。\n"
"请仔细阅读每个功能的描述和用法,然后用简洁、清晰的语言告诉用户应该使用哪个或哪些命令来解决他们的问题。\n"
"如果找不到完全匹配的功能,可以推荐最相关的一个或几个。直接给出操作指令和简要解释即可。"
)
if (
Config.get_config("help", "LLM_HELPER_STYLE")
and Config.get_config("help", "LLM_HELPER_STYLE").strip()
):
style = Config.get_config("help", "LLM_HELPER_STYLE")
style_instruction = f"请务必使用「{style}」的风格和口吻来回答。"
system_prompt = f"{base_system_prompt}\n{style_instruction}"
else:
system_prompt = base_system_prompt
full_instruction = (
f"{system_prompt}\n\n=== 功能列表和说明 ===\n{knowledge_base}"
)
messages = [
LLMMessage.system(full_instruction),
LLMMessage.user(question),
]
response = await generate(
messages=messages,
model=Config.get_config("help", "DEFAULT_LLM_MODEL"),
)
reply_text = response.text if response else "抱歉,我暂时无法回答这个问题。"
threshold = Config.get_config("help", "LLM_HELPER_REPLY_AS_IMAGE_THRESHOLD", 50)
if len(reply_text) > threshold:
markdown = Markdown()
markdown.text(reply_text)
return await markdown.build()
return reply_text
except LLMException as e:
logger.error(f"LLM智能帮助出错: {e}", "帮助", e=e)
return "抱歉,智能帮助功能当前不可用,请稍后再试或联系管理员。"
except Exception as e:
logger.error(f"构建LLM帮助时发生未知错误: {e}", "帮助", e=e)
return "抱歉,智能帮助功能遇到了一点小问题,正在紧急处理中!"

View File

@ -45,11 +45,13 @@ async def classify_plugin(
""" """
sort_data = await sort_type() sort_data = await sort_type()
classify: dict[str, list] = {} classify: dict[str, list] = {}
group = await GroupConsole.get_or_none(group_id=group_id) if group_id else None group = await GroupConsole.get_group(group_id=group_id) if group_id else None
bot = await BotConsole.get_or_none(bot_id=session.self_id) bot = await BotConsole.get_or_none(bot_id=session.self_id)
for menu, value in sort_data.items(): for menu, value in sort_data.items():
for plugin in value: for plugin in value:
if not classify.get(menu): if not classify.get(menu):
classify[menu] = [] classify[menu] = []
classify[menu].append(handle(bot, plugin, group, is_detail)) classify[menu].append(handle(bot, plugin, group, is_detail))
for value in classify.values():
value.sort(key=lambda x: x.id)
return classify return classify

View File

@ -21,6 +21,8 @@ class Item(BaseModel):
"""插件名称""" """插件名称"""
sta: int sta: int
"""插件状态""" """插件状态"""
id: int
"""插件id"""
class PluginList(BaseModel): class PluginList(BaseModel):
@ -80,10 +82,9 @@ def __handle_item(
sta = 2 sta = 2
if f"{plugin.module}," in group.block_plugin: if f"{plugin.module}," in group.block_plugin:
sta = 1 sta = 1
if bot: if bot and f"{plugin.module}," in bot.block_plugins:
if f"{plugin.module}," in bot.block_plugins: sta = 2
sta = 2 return Item(plugin_name=plugin.name, sta=sta, id=plugin.id)
return Item(plugin_name=plugin.name, sta=sta)
def build_plugin_data(classify: dict[str, list[Item]]) -> list[dict[str, str]]: def build_plugin_data(classify: dict[str, list[Item]]) -> list[dict[str, str]]:
@ -142,7 +143,7 @@ async def build_html_image(
template_name="zhenxun_menu.html", template_name="zhenxun_menu.html",
templates={"plugin_list": plugin_list}, templates={"plugin_list": plugin_list},
pages={ pages={
"viewport": {"width": 1903, "height": 975}, "viewport": {"width": 1903, "height": 10},
"base_url": f"file://{TEMPLATE_PATH}", "base_url": f"file://{TEMPLATE_PATH}",
}, },
wait=2, wait=2,

View File

@ -45,7 +45,7 @@ async def build_normal_image(group_id: str | None, is_detail: bool) -> BuildImag
color="black" if idx % 2 else "white", color="black" if idx % 2 else "white",
) )
curr_h = 10 curr_h = 10
group = await GroupConsole.get_or_none(group_id=group_id) group = await GroupConsole.get_group(group_id=group_id) if group_id else None
for _, plugin in enumerate(plugin_list): for _, plugin in enumerate(plugin_list):
text_color = (255, 255, 255) if idx % 2 else (0, 0, 0) text_color = (255, 255, 255) if idx % 2 else (0, 0, 0)
if group and f"{plugin.module}," in group.block_plugin: if group and f"{plugin.module}," in group.block_plugin:
@ -80,7 +80,7 @@ async def build_normal_image(group_id: str | None, is_detail: bool) -> BuildImag
width, height = 10, 10 width, height = 10, 10
for s in [ for s in [
"目前支持的功能列表:", "目前支持的功能列表:",
"可以通过 ‘帮助 [功能名称或功能Id] 来获取对应功能的使用方法", "可以通过 '帮助 [功能名称或功能Id]' 来获取对应功能的使用方法",
]: ]:
text = await BuildImage.build_text_image(s, "HYWenHei-85W.ttf", 24) text = await BuildImage.build_text_image(s, "HYWenHei-85W.ttf", 24)
await result.paste(text, (width, height)) await result.paste(text, (width, height))

View File

@ -20,6 +20,12 @@ class Item(BaseModel):
"""插件名称""" """插件名称"""
commands: list[str] commands: list[str]
"""插件命令""" """插件命令"""
id: str
"""插件id"""
status: bool
"""插件状态"""
has_superuser_help: bool
"""插件是否拥有超级用户帮助"""
def __handle_item( def __handle_item(
@ -39,23 +45,36 @@ def __handle_item(
返回: 返回:
Item: Item Item: Item
""" """
status = True
has_superuser_help = False
nb_plugin = nonebot.get_plugin_by_module_name(plugin.module_path)
if nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra:
extra_data = PluginExtraData(**nb_plugin.metadata.extra)
if extra_data.superuser_help:
has_superuser_help = True
if not plugin.status: if not plugin.status:
if plugin.block_type == BlockType.ALL: if plugin.block_type == BlockType.ALL:
plugin.name = f"{plugin.name}(不可用)" status = False
elif group and plugin.block_type == BlockType.GROUP: elif group and plugin.block_type == BlockType.GROUP:
plugin.name = f"{plugin.name}(不可用)" status = False
elif not group and plugin.block_type == BlockType.PRIVATE: elif not group and plugin.block_type == BlockType.PRIVATE:
plugin.name = f"{plugin.name}(不可用)" status = False
elif group and f"{plugin.module}," in group.block_plugin: elif group and f"{plugin.module}," in group.block_plugin:
plugin.name = f"{plugin.name}(不可用)" status = False
elif bot and f"{plugin.module}," in bot.block_plugins: elif bot and f"{plugin.module}," in bot.block_plugins:
plugin.name = f"{plugin.name}(不可用)" status = False
commands = [] commands = []
nb_plugin = nonebot.get_plugin_by_module_name(plugin.module_path) nb_plugin = nonebot.get_plugin_by_module_name(plugin.module_path)
if is_detail and nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra: if is_detail and nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra:
extra_data = PluginExtraData(**nb_plugin.metadata.extra) extra_data = PluginExtraData(**nb_plugin.metadata.extra)
commands = [cmd.command for cmd in extra_data.commands] commands = [cmd.command for cmd in extra_data.commands]
return Item(plugin_name=f"{plugin.id}-{plugin.name}", commands=commands) return Item(
plugin_name=plugin.name,
commands=commands,
id=str(plugin.id),
status=status,
has_superuser_help=has_superuser_help,
)
def build_plugin_data(classify: dict[str, list[Item]]) -> list[dict[str, str]]: def build_plugin_data(classify: dict[str, list[Item]]) -> list[dict[str, str]]:
@ -78,68 +97,10 @@ def build_plugin_data(classify: dict[str, list[Item]]) -> list[dict[str, str]]:
} }
for menu, value in classify.items() for menu, value in classify.items()
] ]
plugin_list = build_line_data(plugin_list) plugin_list.insert(0, {"name": menu_key, "items": max_data})
plugin_list.insert(
0,
build_plugin_line(
menu_key if menu_key not in ["normal", "功能"] else "主要功能",
max_data,
30,
100,
True,
),
)
return plugin_list
def build_plugin_line(
name: str, items: list, left: int, width: int | None = None, is_max: bool = False
) -> dict:
"""构造插件行数据
参数:
name: 菜单名称
items: 插件名称列表
left: 左边距
width: 总插件长度.
is_max: 是否为最大长度的插件菜单
返回:
dict: 插件数据
"""
_plugins = []
width = width or 50
if len(items) // 2 > 6 or is_max:
width = 100
plugin_list1 = []
plugin_list2 = []
for i in range(len(items)):
if i % 2:
plugin_list1.append(items[i])
else:
plugin_list2.append(items[i])
_plugins = [(30, 50, plugin_list1), (0, 50, plugin_list2)]
else:
_plugins = [(left, 100, items)]
return {"name": name, "items": _plugins, "width": width}
def build_line_data(plugin_list: list[dict]) -> list[dict]:
"""构造插件数据
参数:
plugin_list: 插件列表
返回:
list[dict]: 插件数据
"""
left = 30
data = []
for plugin in plugin_list: for plugin in plugin_list:
data.append(build_plugin_line(plugin["name"], plugin["items"], left)) plugin["items"].sort(key=lambda x: x.id)
if len(plugin["items"]) // 2 <= 6: return plugin_list
left = 15 if left == 30 else 30
return data
async def build_zhenxun_image( async def build_zhenxun_image(
@ -160,6 +121,7 @@ async def build_zhenxun_image(
width = int(637 * 1.5) if is_detail else 637 width = int(637 * 1.5) if is_detail else 637
title_font = int(53 * 1.5) if is_detail else 53 title_font = int(53 * 1.5) if is_detail else 53
tip_font = int(19 * 1.5) if is_detail else 19 tip_font = int(19 * 1.5) if is_detail else 19
plugin_count = sum(len(plugin["items"]) for plugin in plugin_list)
return await template_to_pic( return await template_to_pic(
template_path=str((TEMPLATE_PATH / "ss_menu").absolute()), template_path=str((TEMPLATE_PATH / "ss_menu").absolute()),
template_name="main.html", template_name="main.html",
@ -170,10 +132,11 @@ async def build_zhenxun_image(
"width": width, "width": width,
"font_size": (title_font, tip_font), "font_size": (title_font, tip_font),
"is_detail": is_detail, "is_detail": is_detail,
"plugin_count": plugin_count,
} }
}, },
pages={ pages={
"viewport": {"width": width, "height": 453}, "viewport": {"width": width, "height": 10},
"base_url": f"file://{TEMPLATE_PATH}", "base_url": f"file://{TEMPLATE_PATH}",
}, },
wait=2, wait=2,

View File

@ -1,597 +0,0 @@
from typing import ClassVar
from nonebot.adapters import Bot, Event
from nonebot.adapters.onebot.v11 import PokeNotifyEvent
from nonebot.exception import IgnoredException
from nonebot.matcher import Matcher
from nonebot_plugin_alconna import At, UniMsg
from nonebot_plugin_session import EventSession
from pydantic import BaseModel
from tortoise.exceptions import IntegrityError
from zhenxun.configs.config import Config
from zhenxun.models.bot_console import BotConsole
from zhenxun.models.group_console import GroupConsole
from zhenxun.models.level_user import LevelUser
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.plugin_limit import PluginLimit
from zhenxun.models.sign_user import SignUser
from zhenxun.models.user_console import UserConsole
from zhenxun.services.log import logger
from zhenxun.utils.enum import (
BlockType,
GoldHandle,
LimitWatchType,
PluginLimitType,
PluginType,
)
from zhenxun.utils.exception import InsufficientGold
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.utils import CountLimiter, FreqLimiter, UserBlockLimiter
base_config = Config.get("hook")
class Limit(BaseModel):
limit: PluginLimit
limiter: FreqLimiter | UserBlockLimiter | CountLimiter
class Config:
arbitrary_types_allowed = True
class LimitManage:
add_module: ClassVar[list] = []
cd_limit: ClassVar[dict[str, Limit]] = {}
block_limit: ClassVar[dict[str, Limit]] = {}
count_limit: ClassVar[dict[str, Limit]] = {}
@classmethod
def add_limit(cls, limit: PluginLimit):
"""添加限制
参数:
limit: PluginLimit
"""
if limit.module not in cls.add_module:
cls.add_module.append(limit.module)
if limit.limit_type == PluginLimitType.BLOCK:
cls.block_limit[limit.module] = Limit(
limit=limit, limiter=UserBlockLimiter()
)
elif limit.limit_type == PluginLimitType.CD:
cls.cd_limit[limit.module] = Limit(
limit=limit, limiter=FreqLimiter(limit.cd)
)
elif limit.limit_type == PluginLimitType.COUNT:
cls.count_limit[limit.module] = Limit(
limit=limit, limiter=CountLimiter(limit.max_count)
)
@classmethod
def unblock(
cls, module: str, user_id: str, group_id: str | None, channel_id: str | None
):
"""解除插件block
参数:
module: 模块名
user_id: 用户id
group_id: 群组id
channel_id: 频道id
"""
if limit_model := cls.block_limit.get(module):
limit = limit_model.limit
limiter: UserBlockLimiter = limit_model.limiter # type: ignore
key_type = user_id
if group_id and limit.watch_type == LimitWatchType.GROUP:
key_type = channel_id or group_id
logger.debug(
f"解除对象: {key_type} 的block限制",
"AuthChecker",
session=user_id,
group_id=group_id,
)
limiter.set_false(key_type)
@classmethod
async def check(
cls,
module: str,
user_id: str,
group_id: str | None,
channel_id: str | None,
session: EventSession,
):
"""检测限制
参数:
module: 模块名
user_id: 用户id
group_id: 群组id
channel_id: 频道id
session: Session
异常:
IgnoredException: IgnoredException
"""
if limit_model := cls.cd_limit.get(module):
await cls.__check(limit_model, user_id, group_id, channel_id, session)
if limit_model := cls.block_limit.get(module):
await cls.__check(limit_model, user_id, group_id, channel_id, session)
if limit_model := cls.count_limit.get(module):
await cls.__check(limit_model, user_id, group_id, channel_id, session)
@classmethod
async def __check(
cls,
limit_model: Limit | None,
user_id: str,
group_id: str | None,
channel_id: str | None,
session: EventSession,
):
"""检测限制
参数:
limit_model: Limit
user_id: 用户id
group_id: 群组id
channel_id: 频道id
session: Session
异常:
IgnoredException: IgnoredException
"""
if not limit_model:
return
limit = limit_model.limit
limiter = limit_model.limiter
is_limit = (
LimitWatchType.ALL
or (group_id and limit.watch_type == LimitWatchType.GROUP)
or (not group_id and limit.watch_type == LimitWatchType.USER)
)
key_type = user_id
if group_id and limit.watch_type == LimitWatchType.GROUP:
key_type = channel_id or group_id
if is_limit and not limiter.check(key_type):
if limit.result:
await MessageUtils.build_message(limit.result).send()
logger.debug(
f"{limit.module}({limit.limit_type}) 正在限制中...",
"AuthChecker",
session=session,
)
raise IgnoredException(f"{limit.module} 正在限制中...")
else:
logger.debug(
f"开始进行限制 {limit.module}({limit.limit_type})...",
"AuthChecker",
session=user_id,
group_id=group_id,
)
if isinstance(limiter, FreqLimiter):
limiter.start_cd(key_type)
if isinstance(limiter, UserBlockLimiter):
limiter.set_true(key_type)
if isinstance(limiter, CountLimiter):
limiter.increase(key_type)
class IsSuperuserException(Exception):
pass
class AuthChecker:
"""
权限检查
"""
def __init__(self):
check_notice_info_cd = Config.get_config("hook", "CHECK_NOTICE_INFO_CD")
if check_notice_info_cd is None or check_notice_info_cd < 0:
raise ValueError("模块: [hook], 配置项: [CHECK_NOTICE_INFO_CD] 为空或小于0")
self._flmt = FreqLimiter(check_notice_info_cd)
self._flmt_g = FreqLimiter(check_notice_info_cd)
self._flmt_s = FreqLimiter(check_notice_info_cd)
self._flmt_c = FreqLimiter(check_notice_info_cd)
def is_send_limit_message(self, plugin: PluginInfo, sid: str) -> bool:
"""是否发送提示消息
参数:
plugin: PluginInfo
返回:
bool: 是否发送提示消息
"""
if not base_config.get("IS_SEND_TIP_MESSAGE"):
return False
if plugin.plugin_type == PluginType.DEPENDANT:
return False
if plugin.ignore_prompt:
return False
return self._flmt_s.check(sid)
async def auth(
self,
matcher: Matcher,
event: Event,
bot: Bot,
session: EventSession,
message: UniMsg,
):
"""权限检查
参数:
matcher: matcher
bot: bot
session: EventSession
message: UniMsg
"""
is_ignore = False
cost_gold = 0
user_id = session.id1
group_id = session.id3
channel_id = session.id2
if not group_id:
group_id = channel_id
channel_id = None
if matcher.type == "notice" and not isinstance(event, PokeNotifyEvent):
"""过滤除poke外的notice"""
return
if user_id and matcher.plugin and (module_path := matcher.plugin.module_name):
try:
user = await UserConsole.get_user(user_id, session.platform)
except IntegrityError as e:
logger.debug(
"重复创建用户,已跳过该次权限...",
"AuthChecker",
session=session,
e=e,
)
return
if plugin := await PluginInfo.get_or_none(module_path=module_path):
if plugin.plugin_type == PluginType.HIDDEN:
logger.debug(
f"插件: {plugin.name}:{plugin.module} "
"为HIDDEN已跳过权限检查..."
)
return
try:
cost_gold = await self.auth_cost(user, plugin, session)
if session.id1 in bot.config.superusers:
if plugin.plugin_type == PluginType.SUPERUSER:
raise IsSuperuserException()
if not plugin.limit_superuser:
cost_gold = 0
raise IsSuperuserException()
await self.auth_bot(plugin, bot.self_id)
await self.auth_group(plugin, session, message)
await self.auth_admin(plugin, session)
await self.auth_plugin(plugin, session, event)
await self.auth_limit(plugin, session)
except IsSuperuserException:
logger.debug(
"超级用户或被ban跳过权限检测...", "AuthChecker", session=session
)
except IgnoredException:
is_ignore = True
LimitManage.unblock(
matcher.plugin.name, user_id, group_id, channel_id
)
except AssertionError as e:
is_ignore = True
logger.debug("消息无法发送", session=session, e=e)
if cost_gold and user_id:
"""花费金币"""
try:
await UserConsole.reduce_gold(
user_id,
cost_gold,
GoldHandle.PLUGIN,
matcher.plugin.name if matcher.plugin else "",
session.platform,
)
except InsufficientGold:
if u := await UserConsole.get_user(user_id):
u.gold = 0
await u.save(update_fields=["gold"])
logger.debug(
f"调用功能花费金币: {cost_gold}", "AuthChecker", session=session
)
if is_ignore:
raise IgnoredException("权限检测 ignore")
async def auth_bot(self, plugin: PluginInfo, bot_id: str):
"""机器人权限
参数:
plugin: PluginInfo
bot_id: bot_id
"""
if not await BotConsole.get_bot_status(bot_id):
logger.debug("Bot休眠中阻断权限检测...", "AuthChecker")
raise IgnoredException("BotConsole休眠权限检测 ignore")
if await BotConsole.is_block_plugin(bot_id, plugin.module):
logger.debug(
f"Bot插件 {plugin.name}({plugin.module}) 权限检查结果为关闭...",
"AuthChecker",
)
raise IgnoredException("BotConsole插件权限检测 ignore")
async def auth_limit(self, plugin: PluginInfo, session: EventSession):
"""插件限制
参数:
plugin: PluginInfo
session: EventSession
"""
user_id = session.id1
group_id = session.id3
channel_id = session.id2
if not group_id:
group_id = channel_id
channel_id = None
if plugin.module not in LimitManage.add_module:
limit_list: list[PluginLimit] = await plugin.plugin_limit.filter(
status=True
).all() # type: ignore
for limit in limit_list:
LimitManage.add_limit(limit)
if user_id:
await LimitManage.check(
plugin.module, user_id, group_id, channel_id, session
)
async def auth_plugin(
self, plugin: PluginInfo, session: EventSession, event: Event
):
"""插件状态
参数:
plugin: PluginInfo
session: EventSession
"""
group_id = session.id3
channel_id = session.id2
if not group_id:
group_id = channel_id
channel_id = None
if user_id := session.id1:
if plugin.impression > 0:
sign_user = await SignUser.get_user(user_id)
if float(sign_user.impression) < plugin.impression:
if self.is_send_limit_message(plugin, user_id):
self._flmt_s.start_cd(user_id)
await MessageUtils.build_message(
f"好感度不足哦,当前功能需要好感度: {plugin.impression}"
"请继续签到提升好感度吧!"
).send(reply_to=True)
logger.debug(
f"{plugin.name}({plugin.module}) 用户好感度不足...",
"AuthChecker",
session=session,
)
raise IgnoredException("好感度不足...")
if group_id:
sid = group_id or user_id
if await GroupConsole.is_superuser_block_plugin(
group_id, plugin.module
):
"""超级用户群组插件状态"""
if self.is_send_limit_message(plugin, sid):
self._flmt_s.start_cd(group_id or user_id)
await MessageUtils.build_message(
"超级管理员禁用了该群此功能..."
).send(reply_to=True)
logger.debug(
f"{plugin.name}({plugin.module}) 超级管理员禁用了该群此功能...",
"AuthChecker",
session=session,
)
raise IgnoredException("超级管理员禁用了该群此功能...")
if await GroupConsole.is_normal_block_plugin(group_id, plugin.module):
"""群组插件状态"""
if self.is_send_limit_message(plugin, sid):
self._flmt_s.start_cd(group_id or user_id)
await MessageUtils.build_message("该群未开启此功能...").send(
reply_to=True
)
logger.debug(
f"{plugin.name}({plugin.module}) 未开启此功能...",
"AuthChecker",
session=session,
)
raise IgnoredException("该群未开启此功能...")
if plugin.block_type == BlockType.GROUP:
"""全局群组禁用"""
try:
if self.is_send_limit_message(plugin, sid):
self._flmt_c.start_cd(group_id)
await MessageUtils.build_message(
"该功能在群组中已被禁用..."
).send(reply_to=True)
except Exception as e:
logger.error(
"auth_plugin 发送消息失败",
"AuthChecker",
session=session,
e=e,
)
logger.debug(
f"{plugin.name}({plugin.module}) 该插件在群组中已被禁用...",
"AuthChecker",
session=session,
)
raise IgnoredException("该插件在群组中已被禁用...")
else:
sid = user_id
if plugin.block_type == BlockType.PRIVATE:
"""全局私聊禁用"""
try:
if self.is_send_limit_message(plugin, sid):
self._flmt_c.start_cd(user_id)
await MessageUtils.build_message(
"该功能在私聊中已被禁用..."
).send()
except Exception as e:
logger.error(
"auth_admin 发送消息失败",
"AuthChecker",
session=session,
e=e,
)
logger.debug(
f"{plugin.name}({plugin.module}) 该插件在私聊中已被禁用...",
"AuthChecker",
session=session,
)
raise IgnoredException("该插件在私聊中已被禁用...")
if not plugin.status and plugin.block_type == BlockType.ALL:
"""全局状态"""
if group_id and await GroupConsole.is_super_group(group_id):
raise IsSuperuserException()
logger.debug(
f"{plugin.name}({plugin.module}) 全局未开启此功能...",
"AuthChecker",
session=session,
)
if self.is_send_limit_message(plugin, sid):
self._flmt_s.start_cd(group_id or user_id)
await MessageUtils.build_message("全局未开启此功能...").send()
raise IgnoredException("全局未开启此功能...")
async def auth_admin(self, plugin: PluginInfo, session: EventSession):
"""管理员命令 个人权限
参数:
plugin: PluginInfo
session: EventSession
"""
user_id = session.id1
if user_id and plugin.admin_level:
if group_id := session.id3 or session.id2:
if not await LevelUser.check_level(
user_id, group_id, plugin.admin_level
):
try:
if self._flmt.check(user_id):
self._flmt.start_cd(user_id)
await MessageUtils.build_message(
[
At(flag="user", target=user_id),
f"你的权限不足喔,"
f"该功能需要的权限等级: {plugin.admin_level}",
]
).send(reply_to=True)
except Exception as e:
logger.error(
"auth_admin 发送消息失败",
"AuthChecker",
session=session,
e=e,
)
logger.debug(
f"{plugin.name}({plugin.module}) 管理员权限不足...",
"AuthChecker",
session=session,
)
raise IgnoredException("管理员权限不足...")
elif not await LevelUser.check_level(user_id, None, plugin.admin_level):
try:
await MessageUtils.build_message(
f"你的权限不足喔,该功能需要的权限等级: {plugin.admin_level}"
).send()
except Exception as e:
logger.error(
"auth_admin 发送消息失败", "AuthChecker", session=session, e=e
)
logger.debug(
f"{plugin.name}({plugin.module}) 管理员权限不足...",
"AuthChecker",
session=session,
)
raise IgnoredException("权限不足")
async def auth_group(
self, plugin: PluginInfo, session: EventSession, message: UniMsg
):
"""群黑名单检测 群总开关检测
参数:
plugin: PluginInfo
session: EventSession
message: UniMsg
"""
if not (group_id := session.id3 or session.id2):
return
text = message.extract_plain_text()
group = await GroupConsole.get_group(group_id)
if not group:
"""群不存在"""
logger.debug(
"群组信息不存在...",
"AuthChecker",
session=session,
)
raise IgnoredException("群不存在")
if group.level < 0:
"""群权限小于0"""
logger.debug(
"群黑名单, 群权限-1...",
"AuthChecker",
session=session,
)
raise IgnoredException("群黑名单")
if not group.status:
"""群休眠"""
if text.strip() != "醒来":
logger.debug("群休眠状态...", "AuthChecker", session=session)
raise IgnoredException("群休眠状态")
if plugin.level > group.level:
"""插件等级大于群等级"""
logger.debug(
f"{plugin.name}({plugin.module}) 群等级限制.."
f"该功能需要的群等级: {plugin.level}..",
"AuthChecker",
session=session,
)
raise IgnoredException(f"{plugin.name}({plugin.module}) 群等级限制...")
async def auth_cost(
self, user: UserConsole, plugin: PluginInfo, session: EventSession
) -> int:
"""检测是否满足金币条件
参数:
user: UserConsole
plugin: PluginInfo
session: EventSession
返回:
int: 需要消耗的金币
"""
if user.gold < plugin.cost_gold:
"""插件消耗金币不足"""
try:
await MessageUtils.build_message(
f"金币不足..该功能需要{plugin.cost_gold}金币.."
).send()
except Exception as e:
logger.error(
"auth_cost 发送消息失败", "AuthChecker", session=session, e=e
)
logger.debug(
f"{plugin.name}({plugin.module}) 金币限制.."
f"该功能需要{plugin.cost_gold}金币..",
"AuthChecker",
session=session,
)
raise IgnoredException(f"{plugin.name}({plugin.module}) 金币限制...")
return plugin.cost_gold
checker = AuthChecker()

View File

@ -0,0 +1,99 @@
import asyncio
import time
from nonebot_plugin_alconna import At
from nonebot_plugin_uninfo import Uninfo
from zhenxun.models.level_user import LevelUser
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.services.data_access import DataAccess
from zhenxun.services.db_context import DB_TIMEOUT_SECONDS
from zhenxun.services.log import logger
from zhenxun.utils.utils import get_entity_ids
from .config import LOGGER_COMMAND, WARNING_THRESHOLD
from .exception import SkipPluginException
from .utils import send_message
async def auth_admin(plugin: PluginInfo, session: Uninfo):
"""管理员命令 个人权限
参数:
plugin: PluginInfo
session: Uninfo
"""
start_time = time.time()
if not plugin.admin_level:
return
try:
entity = get_entity_ids(session)
level_dao = DataAccess(LevelUser)
# 并行查询用户权限数据
global_user: LevelUser | None = None
group_users: LevelUser | None = None
# 查询全局权限
global_user_task = level_dao.safe_get_or_none(
user_id=session.user.id, group_id__isnull=True
)
# 如果在群组中,查询群组权限
group_users_task = None
if entity.group_id:
group_users_task = level_dao.safe_get_or_none(
user_id=session.user.id, group_id=entity.group_id
)
# 等待查询完成,添加超时控制
try:
results = await asyncio.wait_for(
asyncio.gather(global_user_task, group_users_task or asyncio.sleep(0)),
timeout=DB_TIMEOUT_SECONDS,
)
global_user = results[0]
group_users = results[1] if group_users_task else None
except asyncio.TimeoutError:
logger.error(f"查询用户权限超时: user_id={session.user.id}", LOGGER_COMMAND)
# 超时时不阻塞,继续执行
return
user_level = global_user.user_level if global_user else 0
if entity.group_id and group_users:
user_level = max(user_level, group_users.user_level)
if user_level < plugin.admin_level:
await send_message(
session,
[
At(flag="user", target=session.user.id),
f"你的权限不足喔,该功能需要的权限等级: {plugin.admin_level}",
],
entity.user_id,
)
raise SkipPluginException(
f"{plugin.name}({plugin.module}) 管理员权限不足..."
)
elif global_user:
if global_user.user_level < plugin.admin_level:
await send_message(
session,
f"你的权限不足喔,该功能需要的权限等级: {plugin.admin_level}",
)
raise SkipPluginException(
f"{plugin.name}({plugin.module}) 管理员权限不足..."
)
finally:
# 记录执行时间
elapsed = time.time() - start_time
if elapsed > WARNING_THRESHOLD: # 记录耗时超过500ms的检查
logger.warning(
f"auth_admin 耗时: {elapsed:.3f}s, plugin={plugin.module}",
LOGGER_COMMAND,
session=session,
)

View File

@ -0,0 +1,306 @@
import asyncio
import time
from nonebot.adapters import Bot
from nonebot.matcher import Matcher
from nonebot_plugin_alconna import At
from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.config import Config
from zhenxun.models.ban_console import BanConsole
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.services.data_access import DataAccess
from zhenxun.services.db_context import DB_TIMEOUT_SECONDS
from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType
from zhenxun.utils.utils import EntityIDs, get_entity_ids
from .config import LOGGER_COMMAND, WARNING_THRESHOLD
from .exception import SkipPluginException
from .utils import freq, send_message
Config.add_plugin_config(
"hook",
"BAN_RESULT",
"才不会给你发消息.",
help="对被ban用户发送的消息",
)
async def calculate_ban_time(ban_record: BanConsole | None) -> int:
"""根据ban记录计算剩余ban时间
参数:
ban_record: BanConsole记录
返回:
int: ban剩余时长-1时为永久ban0表示未被ban
"""
if not ban_record:
return 0
if ban_record.duration == -1:
return -1
_time = time.time() - (ban_record.ban_time + ban_record.duration)
if _time < 0:
return int(abs(_time))
await ban_record.delete()
return 0
async def is_ban(user_id: str | None, group_id: str | None) -> int:
"""检查用户或群组是否被ban
参数:
user_id: 用户ID
group_id: 群组ID
返回:
int: ban的剩余时间0表示未被ban
"""
if not user_id and not group_id:
return 0
start_time = time.time()
ban_dao = DataAccess(BanConsole)
# 分别获取用户在群组中的ban记录和全局ban记录
group_user = None
user = None
try:
# 并行查询用户和群组的 ban 记录
tasks = []
if user_id and group_id:
tasks.append(ban_dao.safe_get_or_none(user_id=user_id, group_id=group_id))
if user_id:
tasks.append(
ban_dao.safe_get_or_none(user_id=user_id, group_id__isnull=True)
)
# 等待所有查询完成,添加超时控制
if tasks:
try:
ban_records = await asyncio.wait_for(
asyncio.gather(*tasks), timeout=DB_TIMEOUT_SECONDS
)
if len(tasks) == 2:
group_user, user = ban_records
elif user_id and group_id:
group_user = ban_records[0]
else:
user = ban_records[0]
except asyncio.TimeoutError:
logger.error(
f"查询ban记录超时: user_id={user_id}, group_id={group_id}",
LOGGER_COMMAND,
)
# 超时时返回0避免阻塞
return 0
# 检查记录并计算ban时间
results = []
if group_user:
results.append(group_user)
if user:
results.append(user)
# 如果没有找到记录返回0
if not results:
return 0
logger.debug(f"查询到的ban记录: {results}", LOGGER_COMMAND)
# 检查所有记录找出最严格的ban时间最长的
max_ban_time: int = 0
for result in results:
if result.duration > 0 or result.duration == -1:
# 直接计算ban时间避免再次查询数据库
ban_time = await calculate_ban_time(result)
if ban_time == -1 or ban_time > max_ban_time:
max_ban_time = ban_time
return max_ban_time
finally:
# 记录执行时间
elapsed = time.time() - start_time
if elapsed > WARNING_THRESHOLD: # 记录耗时超过500ms的检查
logger.warning(
f"is_ban 耗时: {elapsed:.3f}s",
LOGGER_COMMAND,
session=user_id,
group_id=group_id,
)
def check_plugin_type(matcher: Matcher) -> bool:
"""判断插件类型是否是隐藏插件
参数:
matcher: Matcher
返回:
bool: 是否为隐藏插件
"""
if plugin := matcher.plugin:
if metadata := plugin.metadata:
extra = metadata.extra
if extra.get("plugin_type") in [PluginType.HIDDEN]:
return False
return True
def format_time(time_val: float) -> str:
"""格式化时间
参数:
time_val: ban时长
返回:
str: 格式化时间文本
"""
if time_val == -1:
return ""
time_val = abs(int(time_val))
if time_val < 60:
time_str = f"{time_val!s}"
else:
minute = int(time_val / 60)
if minute > 60:
hours = minute // 60
minute %= 60
time_str = f"{hours} 小时 {minute}分钟"
else:
time_str = f"{minute} 分钟"
return time_str
async def group_handle(group_id: str) -> None:
"""群组ban检查
参数:
group_id: 群组id
异常:
SkipPluginException: 群组处于黑名单
"""
start_time = time.time()
try:
if await is_ban(None, group_id):
raise SkipPluginException("群组处于黑名单中...")
finally:
# 记录执行时间
elapsed = time.time() - start_time
if elapsed > WARNING_THRESHOLD: # 记录耗时超过500ms的检查
logger.warning(
f"group_handle 耗时: {elapsed:.3f}s",
LOGGER_COMMAND,
group_id=group_id,
)
async def user_handle(module: str, entity: EntityIDs, session: Uninfo) -> None:
"""用户ban检查
参数:
module: 插件模块名
entity: 实体ID信息
session: Uninfo
异常:
SkipPluginException: 用户处于黑名单
"""
start_time = time.time()
try:
ban_result = Config.get_config("hook", "BAN_RESULT")
time_val = await is_ban(entity.user_id, entity.group_id)
if not time_val:
return
time_str = format_time(time_val)
plugin_dao = DataAccess(PluginInfo)
try:
db_plugin = await asyncio.wait_for(
plugin_dao.safe_get_or_none(module=module), timeout=DB_TIMEOUT_SECONDS
)
except asyncio.TimeoutError:
logger.error(f"查询插件信息超时: {module}", LOGGER_COMMAND)
# 超时时不阻塞,继续执行
raise SkipPluginException("用户处于黑名单中...")
if (
db_plugin
and not db_plugin.ignore_prompt
and time_val != -1
and ban_result
and freq.is_send_limit_message(db_plugin, entity.user_id, False)
):
try:
await asyncio.wait_for(
send_message(
session,
[
At(flag="user", target=entity.user_id),
f"{ban_result}\n在..在 {time_str} 后才会理你喔",
],
entity.user_id,
),
timeout=DB_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.error(f"发送消息超时: {entity.user_id}", LOGGER_COMMAND)
raise SkipPluginException("用户处于黑名单中...")
finally:
# 记录执行时间
elapsed = time.time() - start_time
if elapsed > WARNING_THRESHOLD: # 记录耗时超过500ms的检查
logger.warning(
f"user_handle 耗时: {elapsed:.3f}s",
LOGGER_COMMAND,
session=session,
)
async def auth_ban(matcher: Matcher, bot: Bot, session: Uninfo) -> None:
"""权限检查 - ban 检查
参数:
matcher: Matcher
bot: Bot
session: Uninfo
"""
start_time = time.time()
try:
if not check_plugin_type(matcher):
return
if not matcher.plugin_name:
return
entity = get_entity_ids(session)
if entity.user_id in bot.config.superusers:
return
if entity.group_id:
try:
await asyncio.wait_for(
group_handle(entity.group_id), timeout=DB_TIMEOUT_SECONDS
)
except asyncio.TimeoutError:
logger.error(f"群组ban检查超时: {entity.group_id}", LOGGER_COMMAND)
# 超时时不阻塞,继续执行
if entity.user_id:
try:
await asyncio.wait_for(
user_handle(matcher.plugin_name, entity, session),
timeout=DB_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.error(f"用户ban检查超时: {entity.user_id}", LOGGER_COMMAND)
# 超时时不阻塞,继续执行
finally:
# 记录总执行时间
elapsed = time.time() - start_time
if elapsed > WARNING_THRESHOLD: # 记录耗时超过500ms的检查
logger.warning(
f"auth_ban 总耗时: {elapsed:.3f}s, plugin={matcher.plugin_name}",
LOGGER_COMMAND,
session=session,
)

View File

@ -0,0 +1,55 @@
import asyncio
import time
from zhenxun.models.bot_console import BotConsole
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.services.data_access import DataAccess
from zhenxun.services.db_context import DB_TIMEOUT_SECONDS
from zhenxun.services.log import logger
from zhenxun.utils.common_utils import CommonUtils
from .config import LOGGER_COMMAND, WARNING_THRESHOLD
from .exception import SkipPluginException
async def auth_bot(plugin: PluginInfo, bot_id: str):
"""bot层面的权限检查
参数:
plugin: PluginInfo
bot_id: bot id
异常:
SkipPluginException: 忽略插件
SkipPluginException: 忽略插件
"""
start_time = time.time()
try:
# 从数据库或缓存中获取 bot 信息
bot_dao = DataAccess(BotConsole)
try:
bot: BotConsole | None = await asyncio.wait_for(
bot_dao.safe_get_or_none(bot_id=bot_id), timeout=DB_TIMEOUT_SECONDS
)
except asyncio.TimeoutError:
logger.error(f"查询Bot信息超时: bot_id={bot_id}", LOGGER_COMMAND)
# 超时时不阻塞,继续执行
return
if not bot or not bot.status:
raise SkipPluginException("Bot不存在或休眠中阻断权限检测...")
if CommonUtils.format(plugin.module) in bot.block_plugins:
raise SkipPluginException(
f"Bot插件 {plugin.name}({plugin.module}) 权限检查结果为关闭..."
)
finally:
# 记录执行时间
elapsed = time.time() - start_time
if elapsed > WARNING_THRESHOLD: # 记录耗时超过500ms的检查
logger.warning(
f"auth_bot 耗时: {elapsed:.3f}s, "
f"bot_id={bot_id}, plugin={plugin.module}",
LOGGER_COMMAND,
)

View File

@ -0,0 +1,41 @@
import time
from nonebot_plugin_uninfo import Uninfo
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.user_console import UserConsole
from zhenxun.services.log import logger
from .config import LOGGER_COMMAND, WARNING_THRESHOLD
from .exception import SkipPluginException
from .utils import send_message
async def auth_cost(user: UserConsole, plugin: PluginInfo, session: Uninfo) -> int:
"""检测是否满足金币条件
参数:
user: UserConsole
plugin: PluginInfo
session: Uninfo
返回:
int: 需要消耗的金币
"""
start_time = time.time()
try:
if user.gold < plugin.cost_gold:
"""插件消耗金币不足"""
await send_message(session, f"金币不足..该功能需要{plugin.cost_gold}金币..")
raise SkipPluginException(f"{plugin.name}({plugin.module}) 金币限制...")
return plugin.cost_gold
finally:
# 记录执行时间
elapsed = time.time() - start_time
if elapsed > WARNING_THRESHOLD: # 记录耗时超过500ms的检查
logger.warning(
f"auth_cost 耗时: {elapsed:.3f}s, plugin={plugin.module}",
LOGGER_COMMAND,
session=session,
)

View File

@ -0,0 +1,68 @@
import asyncio
import time
from nonebot_plugin_alconna import UniMsg
from zhenxun.models.group_console import GroupConsole
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.services.data_access import DataAccess
from zhenxun.services.db_context import DB_TIMEOUT_SECONDS
from zhenxun.services.log import logger
from zhenxun.utils.utils import EntityIDs
from .config import LOGGER_COMMAND, WARNING_THRESHOLD, SwitchEnum
from .exception import SkipPluginException
async def auth_group(plugin: PluginInfo, entity: EntityIDs, message: UniMsg):
"""群黑名单检测 群总开关检测
参数:
plugin: PluginInfo
entity: EntityIDs
message: UniMsg
"""
start_time = time.time()
if not entity.group_id:
return
try:
text = message.extract_plain_text()
# 从数据库或缓存中获取群组信息
group_dao = DataAccess(GroupConsole)
try:
group: GroupConsole | None = await asyncio.wait_for(
group_dao.safe_get_or_none(
group_id=entity.group_id, channel_id__isnull=True
),
timeout=DB_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.error("查询群组信息超时", LOGGER_COMMAND, session=entity.user_id)
# 超时时不阻塞,继续执行
return
if not group:
raise SkipPluginException("群组信息不存在...")
if group.level < 0:
raise SkipPluginException("群组黑名单, 目标群组群权限权限-1...")
if text.strip() != SwitchEnum.ENABLE and not group.status:
raise SkipPluginException("群组休眠状态...")
if plugin.level > group.level:
raise SkipPluginException(
f"{plugin.name}({plugin.module}) 群等级限制,"
f"该功能需要的群等级: {plugin.level}..."
)
finally:
# 记录执行时间
elapsed = time.time() - start_time
if elapsed > WARNING_THRESHOLD: # 记录耗时超过500ms的检查
logger.warning(
f"auth_group 耗时: {elapsed:.3f}s, plugin={plugin.module}",
LOGGER_COMMAND,
session=entity.user_id,
group_id=entity.group_id,
)

View File

@ -0,0 +1,322 @@
import asyncio
import time
from typing import ClassVar
import nonebot
from nonebot_plugin_uninfo import Uninfo
from pydantic import BaseModel
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.plugin_limit import PluginLimit
from zhenxun.services.db_context import DB_TIMEOUT_SECONDS
from zhenxun.services.log import logger
from zhenxun.utils.enum import LimitWatchType, PluginLimitType
from zhenxun.utils.limiters import CountLimiter, FreqLimiter, UserBlockLimiter
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.time_utils import TimeUtils
from zhenxun.utils.utils import get_entity_ids
from .config import LOGGER_COMMAND, WARNING_THRESHOLD
from .exception import SkipPluginException
driver = nonebot.get_driver()
@PriorityLifecycle.on_startup(priority=5)
async def _():
"""初始化限制"""
await LimitManager.init_limit()
class Limit(BaseModel):
limit: PluginLimit
limiter: FreqLimiter | UserBlockLimiter | CountLimiter
class Config:
arbitrary_types_allowed = True
class LimitManager:
add_module: ClassVar[list] = []
last_update_time: ClassVar[float] = 0
update_interval: ClassVar[float] = 6000 # 1小时更新一次
is_updating: ClassVar[bool] = False # 防止并发更新
cd_limit: ClassVar[dict[str, Limit]] = {}
block_limit: ClassVar[dict[str, Limit]] = {}
count_limit: ClassVar[dict[str, Limit]] = {}
# 模块限制缓存,避免频繁查询数据库
module_limit_cache: ClassVar[dict[str, tuple[float, list[PluginLimit]]]] = {}
module_cache_ttl: ClassVar[float] = 60 # 模块缓存有效期(秒)
@classmethod
async def init_limit(cls):
"""初始化限制"""
cls.last_update_time = time.time()
try:
await asyncio.wait_for(cls.update_limits(), timeout=DB_TIMEOUT_SECONDS * 2)
except asyncio.TimeoutError:
logger.error("初始化限制超时", LOGGER_COMMAND)
@classmethod
async def update_limits(cls):
"""更新限制信息"""
# 防止并发更新
if cls.is_updating:
return
cls.is_updating = True
try:
start_time = time.time()
try:
limit_list = await asyncio.wait_for(
PluginLimit.filter(status=True).all(), timeout=DB_TIMEOUT_SECONDS
)
except asyncio.TimeoutError:
logger.error("查询限制信息超时", LOGGER_COMMAND)
cls.is_updating = False
return
# 清空旧数据
cls.add_module = []
cls.cd_limit = {}
cls.block_limit = {}
cls.count_limit = {}
# 添加新数据
for limit in limit_list:
cls.add_limit(limit)
cls.last_update_time = time.time()
elapsed = time.time() - start_time
if elapsed > WARNING_THRESHOLD: # 记录耗时超过500ms的更新
logger.warning(f"更新限制信息耗时: {elapsed:.3f}s", LOGGER_COMMAND)
finally:
cls.is_updating = False
@classmethod
def add_limit(cls, limit: PluginLimit):
"""添加限制
参数:
limit: PluginLimit
"""
if limit.module not in cls.add_module:
cls.add_module.append(limit.module)
if limit.limit_type == PluginLimitType.BLOCK:
cls.block_limit[limit.module] = Limit(
limit=limit, limiter=UserBlockLimiter()
)
elif limit.limit_type == PluginLimitType.CD:
cls.cd_limit[limit.module] = Limit(
limit=limit, limiter=FreqLimiter(limit.cd)
)
elif limit.limit_type == PluginLimitType.COUNT:
cls.count_limit[limit.module] = Limit(
limit=limit, limiter=CountLimiter(limit.max_count)
)
@classmethod
def unblock(
cls, module: str, user_id: str, group_id: str | None, channel_id: str | None
):
"""解除插件block
参数:
module: 模块名
user_id: 用户id
group_id: 群组id
channel_id: 频道id
"""
if limit_model := cls.block_limit.get(module):
limit = limit_model.limit
limiter: UserBlockLimiter = limit_model.limiter # type: ignore
key_type = user_id
if group_id and limit.watch_type == LimitWatchType.GROUP:
key_type = channel_id or group_id
logger.debug(
f"解除对象: {key_type} 的block限制",
LOGGER_COMMAND,
session=user_id,
group_id=group_id,
)
limiter.set_false(key_type)
@classmethod
async def get_module_limits(cls, module: str) -> list[PluginLimit]:
"""获取模块的限制信息,使用缓存减少数据库查询
参数:
module: 模块名
返回:
list[PluginLimit]: 限制列表
"""
current_time = time.time()
# 检查缓存
if module in cls.module_limit_cache:
cache_time, limits = cls.module_limit_cache[module]
if current_time - cache_time < cls.module_cache_ttl:
return limits
# 缓存不存在或已过期,从数据库查询
try:
start_time = time.time()
limits = await asyncio.wait_for(
PluginLimit.filter(module=module, status=True).all(),
timeout=DB_TIMEOUT_SECONDS,
)
elapsed = time.time() - start_time
if elapsed > WARNING_THRESHOLD: # 记录耗时超过500ms的查询
logger.warning(
f"查询模块限制信息耗时: {elapsed:.3f}s, 模块: {module}",
LOGGER_COMMAND,
)
# 更新缓存
cls.module_limit_cache[module] = (current_time, limits)
return limits
except asyncio.TimeoutError:
logger.error(f"查询模块限制信息超时: {module}", LOGGER_COMMAND)
# 超时时返回空列表,避免阻塞
return []
@classmethod
async def check(
cls,
module: str,
user_id: str,
group_id: str | None,
channel_id: str | None,
):
"""检测限制
参数:
module: 模块名
user_id: 用户id
group_id: 群组id
channel_id: 频道id
异常:
IgnoredException: IgnoredException
"""
start_time = time.time()
# 定期更新全局限制信息
if (
time.time() - cls.last_update_time > cls.update_interval
and not cls.is_updating
):
# 使用异步任务更新,避免阻塞当前请求
asyncio.create_task(cls.update_limits()) # noqa: RUF006
# 如果模块不在已加载列表中,只加载该模块的限制
if module not in cls.add_module:
limits = await cls.get_module_limits(module)
for limit in limits:
cls.add_limit(limit)
# 检查各种限制
try:
if limit_model := cls.cd_limit.get(module):
await cls.__check(limit_model, user_id, group_id, channel_id)
if limit_model := cls.block_limit.get(module):
await cls.__check(limit_model, user_id, group_id, channel_id)
if limit_model := cls.count_limit.get(module):
await cls.__check(limit_model, user_id, group_id, channel_id)
finally:
# 记录总执行时间
elapsed = time.time() - start_time
if elapsed > WARNING_THRESHOLD: # 记录耗时超过500ms的检查
logger.warning(
f"限制检查耗时: {elapsed:.3f}s, 模块: {module}",
LOGGER_COMMAND,
session=user_id,
group_id=group_id,
)
@classmethod
async def __check(
cls,
limit_model: Limit | None,
user_id: str,
group_id: str | None,
channel_id: str | None,
):
"""检测限制
参数:
limit_model: Limit
user_id: 用户id
group_id: 群组id
channel_id: 频道id
异常:
IgnoredException: IgnoredException
"""
if not limit_model:
return
limit = limit_model.limit
limiter = limit_model.limiter
is_limit = (
LimitWatchType.ALL
or (group_id and limit.watch_type == LimitWatchType.GROUP)
or (not group_id and limit.watch_type == LimitWatchType.USER)
)
key_type = user_id
if group_id and limit.watch_type == LimitWatchType.GROUP:
key_type = channel_id or group_id
if is_limit and not limiter.check(key_type):
if limit.result:
format_kwargs = {}
if isinstance(limiter, FreqLimiter):
left_time = limiter.left_time(key_type)
cd_str = TimeUtils.format_duration(left_time)
format_kwargs = {"cd": cd_str}
try:
await asyncio.wait_for(
MessageUtils.build_message(
limit.result, format_args=format_kwargs
).send(),
timeout=DB_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.error(f"发送限制消息超时: {limit.module}", LOGGER_COMMAND)
raise SkipPluginException(
f"{limit.module}({limit.limit_type}) 正在限制中..."
)
else:
logger.debug(
f"开始进行限制 {limit.module}({limit.limit_type})...",
LOGGER_COMMAND,
session=user_id,
group_id=group_id,
)
if isinstance(limiter, FreqLimiter):
limiter.start_cd(key_type)
if isinstance(limiter, UserBlockLimiter):
limiter.set_true(key_type)
if isinstance(limiter, CountLimiter):
limiter.increase(key_type)
async def auth_limit(plugin: PluginInfo, session: Uninfo):
"""插件限制
参数:
plugin: PluginInfo
session: Uninfo
"""
entity = get_entity_ids(session)
try:
await asyncio.wait_for(
LimitManager.check(
plugin.module, entity.user_id, entity.group_id, entity.channel_id
),
timeout=DB_TIMEOUT_SECONDS * 2, # 给予更长的超时时间
)
except asyncio.TimeoutError:
logger.error(f"检查插件限制超时: {plugin.module}", LOGGER_COMMAND)
# 超时时不抛出异常,允许继续执行

View File

@ -0,0 +1,242 @@
import asyncio
import time
from nonebot.adapters import Event
from nonebot_plugin_uninfo import Uninfo
from zhenxun.models.group_console import GroupConsole
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.services.data_access import DataAccess
from zhenxun.services.db_context import DB_TIMEOUT_SECONDS
from zhenxun.services.log import logger
from zhenxun.utils.common_utils import CommonUtils
from zhenxun.utils.enum import BlockType
from zhenxun.utils.utils import get_entity_ids
from .config import LOGGER_COMMAND, WARNING_THRESHOLD
from .exception import IsSuperuserException, SkipPluginException
from .utils import freq, is_poke, send_message
class GroupCheck:
def __init__(
self, plugin: PluginInfo, group_id: str, session: Uninfo, is_poke: bool
) -> None:
self.group_id = group_id
self.session = session
self.is_poke = is_poke
self.plugin = plugin
self.group_dao = DataAccess(GroupConsole)
self.group_data = None
async def check(self):
start_time = time.time()
try:
# 只查询一次数据库,使用 DataAccess 的缓存机制
try:
self.group_data = await asyncio.wait_for(
self.group_dao.safe_get_or_none(
group_id=self.group_id, channel_id__isnull=True
),
timeout=DB_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.error(f"查询群组数据超时: {self.group_id}", LOGGER_COMMAND)
return # 超时时不阻塞,继续执行
# 检查超级用户禁用
if (
self.group_data
and CommonUtils.format(self.plugin.module)
in self.group_data.superuser_block_plugin
):
if freq.is_send_limit_message(self.plugin, self.group_id, self.is_poke):
try:
await asyncio.wait_for(
send_message(
self.session,
"超级管理员禁用了该群此功能...",
self.group_id,
),
timeout=DB_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.error(f"发送消息超时: {self.group_id}", LOGGER_COMMAND)
raise SkipPluginException(
f"{self.plugin.name}({self.plugin.module})"
f" 超级管理员禁用了该群此功能..."
)
# 检查普通禁用
if (
self.group_data
and CommonUtils.format(self.plugin.module)
in self.group_data.block_plugin
):
if freq.is_send_limit_message(self.plugin, self.group_id, self.is_poke):
try:
await asyncio.wait_for(
send_message(
self.session, "该群未开启此功能...", self.group_id
),
timeout=DB_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.error(f"发送消息超时: {self.group_id}", LOGGER_COMMAND)
raise SkipPluginException(
f"{self.plugin.name}({self.plugin.module}) 未开启此功能..."
)
# 检查全局禁用
if self.plugin.block_type == BlockType.GROUP:
if freq.is_send_limit_message(self.plugin, self.group_id, self.is_poke):
try:
await asyncio.wait_for(
send_message(
self.session, "该功能在群组中已被禁用...", self.group_id
),
timeout=DB_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.error(f"发送消息超时: {self.group_id}", LOGGER_COMMAND)
raise SkipPluginException(
f"{self.plugin.name}({self.plugin.module})该插件在群组中已被禁用..."
)
finally:
# 记录执行时间
elapsed = time.time() - start_time
if elapsed > WARNING_THRESHOLD: # 记录耗时超过500ms的检查
logger.warning(
f"GroupCheck.check 耗时: {elapsed:.3f}s, 群组: {self.group_id}",
LOGGER_COMMAND,
)
class PluginCheck:
def __init__(self, group_id: str | None, session: Uninfo, is_poke: bool):
self.session = session
self.is_poke = is_poke
self.group_id = group_id
self.group_dao = DataAccess(GroupConsole)
self.group_data = None
async def check_user(self, plugin: PluginInfo):
"""全局私聊禁用检测
参数:
plugin: PluginInfo
异常:
IgnoredException: 忽略插件
"""
if plugin.block_type == BlockType.PRIVATE:
if freq.is_send_limit_message(plugin, self.session.user.id, self.is_poke):
try:
await asyncio.wait_for(
send_message(self.session, "该功能在私聊中已被禁用..."),
timeout=DB_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.error("发送消息超时", LOGGER_COMMAND)
raise SkipPluginException(
f"{plugin.name}({plugin.module}) 该插件在私聊中已被禁用..."
)
async def check_global(self, plugin: PluginInfo):
"""全局状态
参数:
plugin: PluginInfo
异常:
IgnoredException: 忽略插件
"""
start_time = time.time()
try:
if plugin.status or plugin.block_type != BlockType.ALL:
return
"""全局状态"""
if self.group_id:
# 使用 DataAccess 的缓存机制
try:
self.group_data = await asyncio.wait_for(
self.group_dao.safe_get_or_none(
group_id=self.group_id, channel_id__isnull=True
),
timeout=DB_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.error(f"查询群组数据超时: {self.group_id}", LOGGER_COMMAND)
return # 超时时不阻塞,继续执行
if self.group_data and self.group_data.is_super:
raise IsSuperuserException()
sid = self.group_id or self.session.user.id
if freq.is_send_limit_message(plugin, sid, self.is_poke):
try:
await asyncio.wait_for(
send_message(self.session, "全局未开启此功能...", sid),
timeout=DB_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.error(f"发送消息超时: {sid}", LOGGER_COMMAND)
raise SkipPluginException(
f"{plugin.name}({plugin.module}) 全局未开启此功能..."
)
finally:
# 记录执行时间
elapsed = time.time() - start_time
if elapsed > WARNING_THRESHOLD: # 记录耗时超过500ms的检查
logger.warning(
f"PluginCheck.check_global 耗时: {elapsed:.3f}s", LOGGER_COMMAND
)
async def auth_plugin(plugin: PluginInfo, session: Uninfo, event: Event):
"""插件状态
参数:
plugin: PluginInfo
session: Uninfo
event: Event
"""
start_time = time.time()
try:
entity = get_entity_ids(session)
is_poke_event = is_poke(event)
user_check = PluginCheck(entity.group_id, session, is_poke_event)
if entity.group_id:
group_check = GroupCheck(plugin, entity.group_id, session, is_poke_event)
try:
await asyncio.wait_for(
group_check.check(), timeout=DB_TIMEOUT_SECONDS * 2
)
except asyncio.TimeoutError:
logger.error(f"群组检查超时: {entity.group_id}", LOGGER_COMMAND)
# 超时时不阻塞,继续执行
else:
try:
await asyncio.wait_for(
user_check.check_user(plugin), timeout=DB_TIMEOUT_SECONDS
)
except asyncio.TimeoutError:
logger.error("用户检查超时", LOGGER_COMMAND)
# 超时时不阻塞,继续执行
try:
await asyncio.wait_for(
user_check.check_global(plugin), timeout=DB_TIMEOUT_SECONDS
)
except asyncio.TimeoutError:
logger.error("全局检查超时", LOGGER_COMMAND)
# 超时时不阻塞,继续执行
finally:
# 记录总执行时间
elapsed = time.time() - start_time
if elapsed > WARNING_THRESHOLD: # 记录耗时超过500ms的检查
logger.warning(
f"auth_plugin 总耗时: {elapsed:.3f}s, 模块: {plugin.module}",
LOGGER_COMMAND,
)

View File

@ -0,0 +1,35 @@
import nonebot
from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.config import Config
from .exception import SkipPluginException
Config.add_plugin_config(
"hook",
"FILTER_BOT",
True,
help="过滤当前连接bot防止bot互相调用",
default_value=True,
type=bool,
)
def bot_filter(session: Uninfo):
"""过滤bot调用bot
参数:
session: Uninfo
异常:
SkipPluginException: bot互相调用
"""
if not Config.get_config("hook", "FILTER_BOT"):
return
bot_ids = list(nonebot.get_bots().keys())
if session.user.id == session.self_id:
return
if session.user.id in bot_ids:
raise SkipPluginException(
f"bot:{session.self_id} 尝试调用 bot:{session.user.id}"
)

View File

@ -0,0 +1,16 @@
import sys
if sys.version_info >= (3, 11):
from enum import StrEnum
else:
from strenum import StrEnum
LOGGER_COMMAND = "AuthChecker"
class SwitchEnum(StrEnum):
ENABLE = "醒来"
DISABLE = "休息吧"
WARNING_THRESHOLD = 0.5 # 警告阈值(秒)

View File

@ -0,0 +1,26 @@
class IsSuperuserException(Exception):
pass
class SkipPluginException(Exception):
def __init__(self, info: str, *args: object) -> None:
super().__init__(*args)
self.info = info
def __str__(self) -> str:
return self.info
def __repr__(self) -> str:
return self.info
class PermissionExemption(Exception):
def __init__(self, info: str, *args: object) -> None:
super().__init__(*args)
self.info = info
def __str__(self) -> str:
return self.info
def __repr__(self) -> str:
return self.info

View File

@ -0,0 +1,91 @@
import contextlib
from nonebot.adapters import Event
from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.config import Config
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.utils import FreqLimiter
from .config import LOGGER_COMMAND
base_config = Config.get("hook")
def is_poke(event: Event) -> bool:
"""判断是否为poke类型
参数:
event: Event
返回:
bool: 是否为poke类型
"""
with contextlib.suppress(ImportError):
from nonebot.adapters.onebot.v11 import PokeNotifyEvent
return isinstance(event, PokeNotifyEvent)
return False
async def send_message(
session: Uninfo, message: list | str, check_tag: str | None = None
):
"""发送消息
参数:
session: Uninfo
message: 消息
check_tag: cd flag
"""
try:
if not check_tag:
await MessageUtils.build_message(message).send(reply_to=True)
elif freq._flmt.check(check_tag):
freq._flmt.start_cd(check_tag)
await MessageUtils.build_message(message).send(reply_to=True)
except Exception as e:
logger.error(
"发送消息失败",
LOGGER_COMMAND,
session=session,
e=e,
)
class FreqUtils:
def __init__(self):
check_notice_info_cd = Config.get_config("hook", "CHECK_NOTICE_INFO_CD")
if check_notice_info_cd is None or check_notice_info_cd < 0:
raise ValueError("模块: [hook], 配置项: [CHECK_NOTICE_INFO_CD] 为空或小于0")
self._flmt = FreqLimiter(check_notice_info_cd)
self._flmt_g = FreqLimiter(check_notice_info_cd)
self._flmt_s = FreqLimiter(check_notice_info_cd)
self._flmt_c = FreqLimiter(check_notice_info_cd)
def is_send_limit_message(
self, plugin: PluginInfo, sid: str, is_poke: bool
) -> bool:
"""是否发送提示消息
参数:
plugin: PluginInfo
sid: 检测键
is_poke: 是否是戳一戳
返回:
bool: 是否发送提示消息
"""
if is_poke:
return False
if not base_config.get("IS_SEND_TIP_MESSAGE"):
return False
if plugin.plugin_type == PluginType.DEPENDANT:
return False
return plugin.module != "ai" if self._flmt_s.check(sid) else False
freq = FreqUtils()

View File

@ -0,0 +1,379 @@
import asyncio
import time
from nonebot.adapters import Bot, Event
from nonebot.exception import IgnoredException
from nonebot.matcher import Matcher
from nonebot_plugin_alconna import UniMsg
from nonebot_plugin_uninfo import Uninfo
from tortoise.exceptions import IntegrityError
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.user_console import UserConsole
from zhenxun.services.data_access import DataAccess
from zhenxun.services.log import logger
from zhenxun.utils.enum import GoldHandle, PluginType
from zhenxun.utils.exception import InsufficientGold
from zhenxun.utils.platform import PlatformUtils
from zhenxun.utils.utils import get_entity_ids
from .auth.auth_admin import auth_admin
from .auth.auth_ban import auth_ban
from .auth.auth_bot import auth_bot
from .auth.auth_cost import auth_cost
from .auth.auth_group import auth_group
from .auth.auth_limit import LimitManager, auth_limit
from .auth.auth_plugin import auth_plugin
from .auth.bot_filter import bot_filter
from .auth.config import LOGGER_COMMAND, WARNING_THRESHOLD
from .auth.exception import (
IsSuperuserException,
PermissionExemption,
SkipPluginException,
)
# 超时设置(秒)
TIMEOUT_SECONDS = 5.0
# 熔断计数器
CIRCUIT_BREAKERS = {
"auth_ban": {"failures": 0, "threshold": 3, "active": False, "reset_time": 0},
"auth_bot": {"failures": 0, "threshold": 3, "active": False, "reset_time": 0},
"auth_group": {"failures": 0, "threshold": 3, "active": False, "reset_time": 0},
"auth_admin": {"failures": 0, "threshold": 3, "active": False, "reset_time": 0},
"auth_plugin": {"failures": 0, "threshold": 3, "active": False, "reset_time": 0},
"auth_limit": {"failures": 0, "threshold": 3, "active": False, "reset_time": 0},
}
# 熔断重置时间(秒)
CIRCUIT_RESET_TIME = 300 # 5分钟
# 超时装饰器
async def with_timeout(coro, timeout=TIMEOUT_SECONDS, name=None):
"""带超时控制的协程执行
参数:
coro: 要执行的协程
timeout: 超时时间
name: 操作名称用于日志记录
返回:
协程的返回值或者在超时时抛出 TimeoutError
"""
try:
return await asyncio.wait_for(coro, timeout=timeout)
except asyncio.TimeoutError:
if name:
logger.error(f"{name} 操作超时 (>{timeout}s)", LOGGER_COMMAND)
# 更新熔断计数器
if name in CIRCUIT_BREAKERS:
CIRCUIT_BREAKERS[name]["failures"] += 1
if (
CIRCUIT_BREAKERS[name]["failures"]
>= CIRCUIT_BREAKERS[name]["threshold"]
and not CIRCUIT_BREAKERS[name]["active"]
):
CIRCUIT_BREAKERS[name]["active"] = True
CIRCUIT_BREAKERS[name]["reset_time"] = (
time.time() + CIRCUIT_RESET_TIME
)
logger.warning(
f"{name} 熔断器已激活,将在 {CIRCUIT_RESET_TIME} 秒后重置",
LOGGER_COMMAND,
)
raise
# 检查熔断状态
def check_circuit_breaker(name):
"""检查熔断器状态
参数:
name: 操作名称
返回:
bool: 是否已熔断
"""
if name not in CIRCUIT_BREAKERS:
return False
# 检查是否需要重置熔断器
if (
CIRCUIT_BREAKERS[name]["active"]
and time.time() > CIRCUIT_BREAKERS[name]["reset_time"]
):
CIRCUIT_BREAKERS[name]["active"] = False
CIRCUIT_BREAKERS[name]["failures"] = 0
logger.info(f"{name} 熔断器已重置", LOGGER_COMMAND)
return CIRCUIT_BREAKERS[name]["active"]
async def get_plugin_and_user(
module: str, user_id: str
) -> tuple[PluginInfo, UserConsole]:
"""获取用户数据和插件信息
参数:
module: 模块名
user_id: 用户id
异常:
PermissionExemption: 插件数据不存在
PermissionExemption: 插件类型为HIDDEN
PermissionExemption: 重复创建用户
PermissionExemption: 用户数据不存在
返回:
tuple[PluginInfo, UserConsole]: 插件信息用户信息
"""
user_dao = DataAccess(UserConsole)
plugin_dao = DataAccess(PluginInfo)
# 并行查询插件和用户数据
plugin_task = plugin_dao.safe_get_or_none(module=module)
user_task = user_dao.get_by_func_or_none(
UserConsole.get_user, False, user_id=user_id
)
try:
plugin, user = await with_timeout(
asyncio.gather(plugin_task, user_task), name="get_plugin_and_user"
)
except asyncio.TimeoutError:
# 如果并行查询超时,尝试串行查询
logger.warning("并行查询超时,尝试串行查询", LOGGER_COMMAND)
plugin = await with_timeout(
plugin_dao.safe_get_or_none(module=module), name="get_plugin"
)
user = await with_timeout(
user_dao.safe_get_or_none(user_id=user_id), name="get_user"
)
if not plugin:
raise PermissionExemption(f"插件:{module} 数据不存在,已跳过权限检查...")
if plugin.plugin_type == PluginType.HIDDEN:
raise PermissionExemption(
f"插件: {plugin.name}:{plugin.module} 为HIDDEN已跳过权限检查..."
)
user = None
try:
user = await user_dao.get_by_func_or_none(
UserConsole.get_user, False, user_id=user_id
)
except IntegrityError as e:
raise PermissionExemption("重复创建用户,已跳过该次权限检查...") from e
if not user:
raise PermissionExemption("用户数据不存在,已跳过权限检查...")
return plugin, user
async def get_plugin_cost(
bot: Bot, user: UserConsole, plugin: PluginInfo, session: Uninfo
) -> int:
"""获取插件费用
参数:
bot: Bot
user: 用户数据
plugin: 插件数据
session: Uninfo
异常:
IsSuperuserException: 超级用户
IsSuperuserException: 超级用户
返回:
int: 调用插件金币费用
"""
cost_gold = await with_timeout(auth_cost(user, plugin, session), name="auth_cost")
if session.user.id in bot.config.superusers:
if plugin.plugin_type == PluginType.SUPERUSER:
raise IsSuperuserException()
if not plugin.limit_superuser:
raise IsSuperuserException()
return cost_gold
async def reduce_gold(user_id: str, module: str, cost_gold: int, session: Uninfo):
"""扣除用户金币
参数:
user_id: 用户id
module: 插件模块名称
cost_gold: 消耗金币
session: Uninfo
"""
user_dao = DataAccess(UserConsole)
try:
await with_timeout(
UserConsole.reduce_gold(
user_id,
cost_gold,
GoldHandle.PLUGIN,
module,
PlatformUtils.get_platform(session),
),
name="reduce_gold",
)
except InsufficientGold:
if u := await UserConsole.get_user(user_id):
u.gold = 0
await u.save(update_fields=["gold"])
except asyncio.TimeoutError:
logger.error(
f"扣除金币超时,用户: {user_id}, 金币: {cost_gold}",
LOGGER_COMMAND,
session=session,
)
# 清除缓存,使下次查询时从数据库获取最新数据
await user_dao.clear_cache(user_id=user_id)
logger.debug(f"调用功能花费金币: {cost_gold}", LOGGER_COMMAND, session=session)
# 辅助函数,用于记录每个 hook 的执行时间
async def time_hook(coro, name, time_dict):
start = time.time()
try:
# 检查熔断状态
if check_circuit_breaker(name):
logger.info(f"{name} 熔断器激活中,跳过执行", LOGGER_COMMAND)
time_dict[name] = "熔断跳过"
return
# 添加超时控制
return await with_timeout(coro, name=name)
except asyncio.TimeoutError:
time_dict[name] = f"超时 (>{TIMEOUT_SECONDS}s)"
finally:
if name not in time_dict:
time_dict[name] = f"{time.time() - start:.3f}s"
async def auth(
matcher: Matcher,
event: Event,
bot: Bot,
session: Uninfo,
message: UniMsg,
):
"""权限检查
参数:
matcher: matcher
event: Event
bot: bot
session: Uninfo
message: UniMsg
"""
start_time = time.time()
cost_gold = 0
ignore_flag = False
entity = get_entity_ids(session)
module = matcher.plugin_name or ""
# 用于记录各个 hook 的执行时间
hook_times = {}
hooks_time = 0 # 初始化 hooks_time 变量
try:
if not module:
raise PermissionExemption("Matcher插件名称不存在...")
# 获取插件和用户数据
plugin_user_start = time.time()
try:
plugin, user = await with_timeout(
get_plugin_and_user(module, entity.user_id), name="get_plugin_and_user"
)
hook_times["get_plugin_user"] = f"{time.time() - plugin_user_start:.3f}s"
except asyncio.TimeoutError:
logger.error(
f"获取插件和用户数据超时,模块: {module}",
LOGGER_COMMAND,
session=session,
)
raise PermissionExemption("获取插件和用户数据超时,请稍后再试...")
# 获取插件费用
cost_start = time.time()
try:
cost_gold = await with_timeout(
get_plugin_cost(bot, user, plugin, session), name="get_plugin_cost"
)
hook_times["cost_gold"] = f"{time.time() - cost_start:.3f}s"
except asyncio.TimeoutError:
logger.error(
f"获取插件费用超时,模块: {module}", LOGGER_COMMAND, session=session
)
# 继续执行,不阻止权限检查
# 执行 bot_filter
bot_filter(session)
# 并行执行所有 hook 检查,并记录执行时间
hooks_start = time.time()
# 创建所有 hook 任务
hook_tasks = [
time_hook(auth_ban(matcher, bot, session), "auth_ban", hook_times),
time_hook(auth_bot(plugin, bot.self_id), "auth_bot", hook_times),
time_hook(auth_group(plugin, entity, message), "auth_group", hook_times),
time_hook(auth_admin(plugin, session), "auth_admin", hook_times),
time_hook(auth_plugin(plugin, session, event), "auth_plugin", hook_times),
time_hook(auth_limit(plugin, session), "auth_limit", hook_times),
]
# 使用 gather 并行执行所有 hook但添加总体超时控制
try:
await with_timeout(
asyncio.gather(*hook_tasks),
timeout=TIMEOUT_SECONDS * 2, # 给总体执行更多时间
name="auth_hooks_gather",
)
except asyncio.TimeoutError:
logger.error(
f"权限检查 hooks 总体执行超时,模块: {module}",
LOGGER_COMMAND,
session=session,
)
# 不抛出异常,允许继续执行
hooks_time = time.time() - hooks_start
except SkipPluginException as e:
LimitManager.unblock(module, entity.user_id, entity.group_id, entity.channel_id)
logger.info(str(e), LOGGER_COMMAND, session=session)
ignore_flag = True
except IsSuperuserException:
logger.debug("超级用户跳过权限检测...", LOGGER_COMMAND, session=session)
except PermissionExemption as e:
logger.info(str(e), LOGGER_COMMAND, session=session)
# 扣除金币
if not ignore_flag and cost_gold > 0:
gold_start = time.time()
try:
await with_timeout(
reduce_gold(entity.user_id, module, cost_gold, session),
name="reduce_gold",
)
hook_times["reduce_gold"] = f"{time.time() - gold_start:.3f}s"
except asyncio.TimeoutError:
logger.error(
f"扣除金币超时,模块: {module}", LOGGER_COMMAND, session=session
)
# 记录总执行时间
total_time = time.time() - start_time
if total_time > WARNING_THRESHOLD: # 如果总时间超过500ms记录详细信息
logger.warning(
f"权限检查耗时过长: {total_time:.3f}s, 模块: {module}, "
f"hooks时间: {hooks_time:.3f}s, "
f"详情: {hook_times}",
LOGGER_COMMAND,
session=session,
)
if ignore_flag:
raise IgnoredException("权限检测 ignore")

View File

@ -1,41 +1,43 @@
from nonebot.adapters.onebot.v11 import Bot, Event import time
from nonebot.adapters import Bot, Event
from nonebot.matcher import Matcher from nonebot.matcher import Matcher
from nonebot.message import run_postprocessor, run_preprocessor from nonebot.message import run_postprocessor, run_preprocessor
from nonebot_plugin_alconna import UniMsg from nonebot_plugin_alconna import UniMsg
from nonebot_plugin_session import EventSession from nonebot_plugin_uninfo import Uninfo
from ._auth_checker import LimitManage, checker from zhenxun.services.log import logger
from .auth.config import LOGGER_COMMAND
from .auth_checker import LimitManager, auth
# # 权限检测 # # 权限检测
@run_preprocessor @run_preprocessor
async def _( async def _(matcher: Matcher, event: Event, bot: Bot, session: Uninfo, message: UniMsg):
matcher: Matcher, event: Event, bot: Bot, session: EventSession, message: UniMsg start_time = time.time()
): await auth(
await checker.auth(
matcher, matcher,
event, event,
bot, bot,
session, session,
message, message,
) )
logger.debug(f"权限检测耗时:{time.time() - start_time}", LOGGER_COMMAND)
# 解除命令block阻塞 # 解除命令block阻塞
@run_postprocessor @run_postprocessor
async def _( async def _(matcher: Matcher, session: Uninfo):
matcher: Matcher, user_id = session.user.id
exception: Exception | None, group_id = None
bot: Bot, channel_id = None
event: Event, if session.group:
session: EventSession, if session.group.parent:
): group_id = session.group.parent.id
user_id = session.id1 channel_id = session.group.id
group_id = session.id3 else:
channel_id = session.id2 group_id = session.group.id
if not group_id:
group_id = channel_id
channel_id = None
if user_id and matcher.plugin: if user_id and matcher.plugin:
module = matcher.plugin.name module = matcher.plugin.name
LimitManage.unblock(module, user_id, group_id, channel_id) LimitManager.unblock(module, user_id, group_id, channel_id)

View File

@ -1,84 +0,0 @@
from nonebot.adapters import Bot, Event
from nonebot.exception import IgnoredException
from nonebot.matcher import Matcher
from nonebot.message import run_preprocessor
from nonebot.typing import T_State
from nonebot_plugin_alconna import At
from nonebot_plugin_session import EventSession
from zhenxun.configs.config import Config
from zhenxun.models.ban_console import BanConsole
from zhenxun.models.group_console import GroupConsole
from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.utils import FreqLimiter
Config.add_plugin_config(
"hook",
"BAN_RESULT",
"才不会给你发消息.",
help="对被ban用户发送的消息",
)
_flmt = FreqLimiter(300)
# 检查是否被ban
@run_preprocessor
async def _(
matcher: Matcher, bot: Bot, event: Event, state: T_State, session: EventSession
):
extra = {}
if plugin := matcher.plugin:
if metadata := plugin.metadata:
extra = metadata.extra
if extra.get("plugin_type") in [PluginType.HIDDEN]:
return
user_id = session.id1
group_id = session.id3 or session.id2
if group_id:
if user_id in bot.config.superusers:
return
if await BanConsole.is_ban(None, group_id):
logger.debug("群组处于黑名单中...", "ban_hook")
raise IgnoredException("群组处于黑名单中...")
if g := await GroupConsole.get_group(group_id):
if g.level < 0:
logger.debug("群黑名单, 群权限-1...", "ban_hook")
raise IgnoredException("群黑名单, 群权限-1..")
if user_id:
ban_result = Config.get_config("hook", "BAN_RESULT")
if user_id in bot.config.superusers:
return
if await BanConsole.is_ban(user_id, group_id):
time = await BanConsole.check_ban_time(user_id, group_id)
if time == -1:
time_str = ""
else:
time = abs(int(time))
if time < 60:
time_str = f"{time!s}"
else:
minute = int(time / 60)
if minute > 60:
hours = minute // 60
minute %= 60
time_str = f"{hours} 小时 {minute}分钟"
else:
time_str = f"{minute} 分钟"
if (
not extra.get("ignore_prompt")
and time != -1
and ban_result
and _flmt.check(user_id)
):
_flmt.start_cd(user_id)
await MessageUtils.build_message(
[
At(flag="user", target=user_id),
f"{ban_result}\n在..在 {time_str} 后才会理你喔",
]
).send()
logger.debug("用户处于黑名单中...", "ban_hook")
raise IgnoredException("用户处于黑名单中...")

View File

@ -9,6 +9,8 @@ from zhenxun.utils.enum import BotSentType
from zhenxun.utils.manager.message_manager import MessageManager from zhenxun.utils.manager.message_manager import MessageManager
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
LOG_COMMAND = "MessageHook"
def replace_message(message: Message) -> str: def replace_message(message: Message) -> str:
"""将消息中的at、image、record、face替换为字符串 """将消息中的at、image、record、face替换为字符串
@ -54,11 +56,11 @@ async def handle_api_result(
if user_id and message_id: if user_id and message_id:
MessageManager.add(str(user_id), str(message_id)) MessageManager.add(str(user_id), str(message_id))
logger.debug( logger.debug(
f"收集消息iduser_id: {user_id}, msg_id: {message_id}", "msg_hook" f"收集消息iduser_id: {user_id}, msg_id: {message_id}", LOG_COMMAND
) )
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"收集消息id发生错误...data: {data}, result: {result}", "msg_hook", e=e f"收集消息id发生错误...data: {data}, result: {result}", LOG_COMMAND, e=e
) )
if not Config.get_config("hook", "RECORD_BOT_SENT_MESSAGES"): if not Config.get_config("hook", "RECORD_BOT_SENT_MESSAGES"):
return return
@ -80,6 +82,6 @@ async def handle_api_result(
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"消息发送记录发生错误...data: {data}, result: {result}", f"消息发送记录发生错误...data: {data}, result: {result}",
"msg_hook", LOG_COMMAND,
e=e, e=e,
) )

View File

@ -92,7 +92,12 @@ async def _(
if module: if module:
if _blmt.check(f"{user_id}__{module}"): if _blmt.check(f"{user_id}__{module}"):
await BanConsole.ban( await BanConsole.ban(
user_id, group_id, 9, malicious_ban_time * 60, bot.self_id user_id,
group_id,
9,
"恶意触发命令检测",
malicious_ban_time * 60,
bot.self_id,
) )
logger.info( logger.info(
f"触发了恶意触发检测: {matcher.plugin_name}", f"触发了恶意触发检测: {matcher.plugin_name}",

View File

@ -0,0 +1,15 @@
from nonebot.matcher import Matcher
from nonebot.message import run_postprocessor
from zhenxun.utils.limiters import ConcurrencyLimiter
@run_postprocessor
async def _concurrency_release_hook(matcher: Matcher):
"""
后处理器在事件处理结束后释放并发限制的信号量
"""
if concurrency_info := matcher.state.get("_concurrency_limiter_info"):
limiter: ConcurrencyLimiter = concurrency_info["limiter"]
key = concurrency_info["key"]
limiter.release(key)

View File

@ -4,15 +4,27 @@ import nonebot
from nonebot.adapters import Bot from nonebot.adapters import Bot
from zhenxun.models.group_console import GroupConsole from zhenxun.models.group_console import GroupConsole
from zhenxun.services.cache import CacheException
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
nonebot.load_plugins(str(Path(__file__).parent.resolve())) nonebot.load_plugins(str(Path(__file__).parent.resolve()))
try:
from .__init_cache import register_cache_types
except CacheException as e:
raise SystemError(f"ERROR{e}")
driver = nonebot.get_driver() driver = nonebot.get_driver()
@PriorityLifecycle.on_startup(priority=5)
async def _():
register_cache_types()
logger.info("缓存类型注册完成")
@driver.on_bot_connect @driver.on_bot_connect
async def _(bot: Bot): async def _(bot: Bot):
"""将bot已存在的群组添加群认证 """将bot已存在的群组添加群认证

View File

@ -0,0 +1,35 @@
"""
缓存初始化模块
负责注册各种缓存类型实现按需缓存机制
"""
from zhenxun.models.ban_console import BanConsole
from zhenxun.models.bot_console import BotConsole
from zhenxun.models.group_console import GroupConsole
from zhenxun.models.level_user import LevelUser
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.user_console import UserConsole
from zhenxun.services.cache import CacheRegistry, cache_config
from zhenxun.services.cache.config import CacheMode
from zhenxun.services.log import logger
from zhenxun.utils.enum import CacheType
# 注册缓存类型
def register_cache_types():
"""注册所有缓存类型"""
CacheRegistry.register(CacheType.PLUGINS, PluginInfo)
CacheRegistry.register(CacheType.GROUPS, GroupConsole)
CacheRegistry.register(CacheType.BOT, BotConsole)
CacheRegistry.register(CacheType.USERS, UserConsole)
CacheRegistry.register(
CacheType.LEVEL, LevelUser, key_format="{user_id}_{group_id}"
)
CacheRegistry.register(CacheType.BAN, BanConsole, key_format="{user_id}_{group_id}")
if cache_config.cache_mode == CacheMode.NONE:
logger.info("缓存功能已禁用,将直接从数据库获取数据")
else:
logger.info(f"已注册所有缓存类型,缓存模式: {cache_config.cache_mode}")
logger.info("使用增量缓存模式,数据将按需加载到缓存中")

View File

@ -46,7 +46,7 @@ def _handle_config(plugin: Plugin, exists_module: list[str]):
reg_config.value, reg_config.value,
help=reg_config.help, help=reg_config.help,
default_value=reg_config.default_value, default_value=reg_config.default_value,
type=reg_config.type, type=reg_config.type, # type: ignore
arg_parser=reg_config.arg_parser, arg_parser=reg_config.arg_parser,
_override=False, _override=False,
) )

View File

@ -1,3 +1,5 @@
import asyncio
import aiofiles import aiofiles
import nonebot import nonebot
from nonebot import get_loaded_plugins from nonebot import get_loaded_plugins
@ -112,24 +114,29 @@ async def _():
await _handle_setting(plugin, plugin_list, limit_list) await _handle_setting(plugin, plugin_list, limit_list)
create_list = [] create_list = []
update_list = [] update_list = []
update_task_list = []
for plugin in plugin_list: for plugin in plugin_list:
if plugin.module_path not in module2id: if plugin.module_path not in module2id:
create_list.append(plugin) create_list.append(plugin)
else: else:
plugin.id = module2id[plugin.module_path] plugin.id = module2id[plugin.module_path]
await plugin.save( update_task_list.append(
update_fields=[ plugin.save(
"name", update_fields=[
"author", "name",
"version", "author",
"admin_level", "version",
"plugin_type", "admin_level",
"is_show", "plugin_type",
] "is_show",
]
)
) )
update_list.append(plugin) update_list.append(plugin)
if create_list: if create_list:
await PluginInfo.bulk_create(create_list, 10) await PluginInfo.bulk_create(create_list, 10)
if update_task_list:
await asyncio.gather(*update_task_list)
# if update_list: # if update_list:
# # TODO: 批量更新无法更新plugin_type: tortoise.exceptions.OperationalError: # # TODO: 批量更新无法更新plugin_type: tortoise.exceptions.OperationalError:
# column "superuser" does not exist # column "superuser" does not exist

View File

@ -205,7 +205,7 @@ class Manager:
self.cd_data: dict[str, PluginCdBlock] = {} self.cd_data: dict[str, PluginCdBlock] = {}
if self.cd_file.exists(): if self.cd_file.exists():
with open(self.cd_file, encoding="utf8") as f: with open(self.cd_file, encoding="utf8") as f:
temp = _yaml.load(f) temp = _yaml.load(f) or {}
if "PluginCdLimit" in temp.keys(): if "PluginCdLimit" in temp.keys():
for k, v in temp["PluginCdLimit"].items(): for k, v in temp["PluginCdLimit"].items():
if "." in k: if "." in k:
@ -216,7 +216,7 @@ class Manager:
self.block_data: dict[str, BaseBlock] = {} self.block_data: dict[str, BaseBlock] = {}
if self.block_file.exists(): if self.block_file.exists():
with open(self.block_file, encoding="utf8") as f: with open(self.block_file, encoding="utf8") as f:
temp = _yaml.load(f) temp = _yaml.load(f) or {}
if "PluginBlockLimit" in temp.keys(): if "PluginBlockLimit" in temp.keys():
for k, v in temp["PluginBlockLimit"].items(): for k, v in temp["PluginBlockLimit"].items():
if "." in k: if "." in k:
@ -227,7 +227,7 @@ class Manager:
self.count_data: dict[str, PluginCountBlock] = {} self.count_data: dict[str, PluginCountBlock] = {}
if self.count_file.exists(): if self.count_file.exists():
with open(self.count_file, encoding="utf8") as f: with open(self.count_file, encoding="utf8") as f:
temp = _yaml.load(f) temp = _yaml.load(f) or {}
if "PluginCountLimit" in temp.keys(): if "PluginCountLimit" in temp.keys():
for k, v in temp["PluginCountLimit"].items(): for k, v in temp["PluginCountLimit"].items():
if "." in k: if "." in k:

View File

@ -0,0 +1,171 @@
from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import (
Alconna,
Args,
Arparma,
Match,
Option,
Query,
Subcommand,
on_alconna,
store_true,
)
from zhenxun.configs.utils import PluginExtraData
from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
from .data_source import DataSource
from .presenters import Presenters
__plugin_meta__ = PluginMetadata(
name="LLM模型管理",
description="查看和管理大语言模型服务。",
usage="""
LLM模型管理 (SUPERUSER)
llm list [--all]
- 查看可用模型列表
- --all: 显示包括不可用在内的所有模型
llm info <Provider/ModelName>
- 查看指定模型的详细信息和能力
llm default [Provider/ModelName]
- 查看或设置全局默认模型
- 不带参数: 查看当前默认模型
- 带参数: 设置新的默认模型
- 例子: llm default Gemini/gemini-2.0-flash
llm test <Provider/ModelName>
- 测试指定模型的连通性和API Key有效性
llm keys <ProviderName>
- 查看指定提供商的所有API Key状态
llm reset-key <ProviderName> [--key <api_key>]
- 重置提供商的所有或指定API Key的失败状态
""",
extra=PluginExtraData(
author="HibiKier",
version="1.0.0",
plugin_type=PluginType.SUPERUSER,
).to_dict(),
)
llm_cmd = on_alconna(
Alconna(
"llm",
Subcommand("list", alias=["ls"], help_text="查看模型列表"),
Subcommand("info", Args["model_name", str], help_text="查看模型详情"),
Subcommand("default", Args["model_name?", str], help_text="查看或设置默认模型"),
Subcommand(
"test", Args["model_name", str], alias=["ping"], help_text="测试模型连通性"
),
Subcommand("keys", Args["provider_name", str], help_text="查看API密钥状态"),
Subcommand(
"reset-key",
Args["provider_name", str],
Option("--key", Args["api_key", str], help_text="指定要重置的API Key"),
help_text="重置API Key状态",
),
Option("--all", action=store_true, help_text="显示所有条目"),
),
permission=SUPERUSER,
priority=5,
block=True,
)
@llm_cmd.assign("list")
async def handle_list(arp: Arparma, show_all: Query[bool] = Query("all")):
"""处理 'llm list' 命令"""
logger.info("获取LLM模型列表", command="LLM Manage", session=arp.header_result)
models = await DataSource.get_model_list(show_all=show_all.result)
image = await Presenters.format_model_list_as_image(models, show_all.result)
await llm_cmd.finish(MessageUtils.build_message(image))
@llm_cmd.assign("info")
async def handle_info(arp: Arparma, model_name: Match[str]):
"""处理 'llm info' 命令"""
logger.info(
f"获取模型详情: {model_name.result}",
command="LLM Manage",
session=arp.header_result,
)
details = await DataSource.get_model_details(model_name.result)
if not details:
await llm_cmd.finish(f"未找到模型: {model_name.result}")
image_bytes = await Presenters.format_model_details_as_markdown_image(details)
await llm_cmd.finish(MessageUtils.build_message(image_bytes))
@llm_cmd.assign("default")
async def handle_default(arp: Arparma, model_name: Match[str]):
"""处理 'llm default' 命令"""
if model_name.available:
logger.info(
f"设置默认模型为: {model_name.result}",
command="LLM Manage",
session=arp.header_result,
)
success, message = await DataSource.set_default_model(model_name.result)
await llm_cmd.finish(message)
else:
logger.info("查看默认模型", command="LLM Manage", session=arp.header_result)
current_default = await DataSource.get_default_model()
await llm_cmd.finish(f"当前全局默认模型为: {current_default or '未设置'}")
@llm_cmd.assign("test")
async def handle_test(arp: Arparma, model_name: Match[str]):
"""处理 'llm test' 命令"""
logger.info(
f"测试模型连通性: {model_name.result}",
command="LLM Manage",
session=arp.header_result,
)
await llm_cmd.send(f"正在测试模型 '{model_name.result}',请稍候...")
success, message = await DataSource.test_model_connectivity(model_name.result)
await llm_cmd.finish(message)
@llm_cmd.assign("keys")
async def handle_keys(arp: Arparma, provider_name: Match[str]):
"""处理 'llm keys' 命令"""
logger.info(
f"查看提供商API Key状态: {provider_name.result}",
command="LLM Manage",
session=arp.header_result,
)
sorted_stats = await DataSource.get_key_status(provider_name.result)
if not sorted_stats:
await llm_cmd.finish(
f"未找到提供商 '{provider_name.result}' 或其没有配置API Keys。"
)
image = await Presenters.format_key_status_as_image(
provider_name.result, sorted_stats
)
await llm_cmd.finish(MessageUtils.build_message(image))
@llm_cmd.assign("reset-key")
async def handle_reset_key(
arp: Arparma, provider_name: Match[str], api_key: Match[str]
):
"""处理 'llm reset-key' 命令"""
key_to_reset = api_key.result if api_key.available else None
log_msg = f"重置 {provider_name.result}" + (
"指定API Key" if key_to_reset else "所有API Keys"
)
logger.info(log_msg, command="LLM Manage", session=arp.header_result)
success, message = await DataSource.reset_key(provider_name.result, key_to_reset)
await llm_cmd.finish(message)

View File

@ -0,0 +1,121 @@
import time
from typing import Any
from zhenxun.services.llm import (
LLMException,
get_global_default_model_name,
get_model_instance,
list_available_models,
set_global_default_model_name,
)
from zhenxun.services.llm.core import KeyStatus
from zhenxun.services.llm.manager import (
reset_key_status,
)
from zhenxun.services.llm.types import LLMMessage
class DataSource:
"""LLM管理插件的数据源和业务逻辑"""
@staticmethod
async def get_model_list(show_all: bool = False) -> list[dict[str, Any]]:
"""获取模型列表"""
models = list_available_models()
if show_all:
return models
return [m for m in models if m.get("is_available", True)]
@staticmethod
async def get_model_details(model_name_str: str) -> dict[str, Any] | None:
"""获取指定模型的详细信息"""
try:
model = await get_model_instance(model_name_str)
return {
"provider_config": model.provider_config,
"model_detail": model.model_detail,
"capabilities": model.capabilities,
}
except LLMException:
return None
@staticmethod
async def get_default_model() -> str | None:
"""获取全局默认模型"""
return get_global_default_model_name()
@staticmethod
async def set_default_model(model_name_str: str) -> tuple[bool, str]:
"""设置全局默认模型"""
success = set_global_default_model_name(model_name_str)
if success:
return True, f"✅ 成功将默认模型设置为: {model_name_str}"
else:
return False, f"❌ 设置失败,模型 '{model_name_str}' 不存在或无效。"
@staticmethod
async def test_model_connectivity(model_name_str: str) -> tuple[bool, str]:
"""测试模型连通性"""
start_time = time.monotonic()
try:
async with await get_model_instance(model_name_str) as model:
await model.generate_response([LLMMessage.user("你好")])
end_time = time.monotonic()
latency = (end_time - start_time) * 1000
return (
True,
f"✅ 模型 '{model_name_str}' 连接成功!\n响应延迟: {latency:.2f} ms",
)
except LLMException as e:
return (
False,
f"❌ 模型 '{model_name_str}' 连接测试失败:\n"
f"{e.user_friendly_message}\n错误码: {e.code.name}",
)
except Exception as e:
return False, f"❌ 测试时发生未知错误: {e!s}"
@staticmethod
async def get_key_status(provider_name: str) -> list[dict[str, Any]] | None:
"""获取并排序指定提供商的API Key状态"""
from zhenxun.services.llm.manager import get_key_usage_stats
all_stats = await get_key_usage_stats()
provider_stats = all_stats.get(provider_name)
if not provider_stats or not provider_stats.get("key_stats"):
return None
key_stats_dict = provider_stats["key_stats"]
stats_list = [
{"key_id": key_id, **stats} for key_id, stats in key_stats_dict.items()
]
def sort_key(item: dict[str, Any]):
status_priority = item.get("status_enum", KeyStatus.UNUSED).value
return (
status_priority,
100 - item.get("success_rate", 100.0),
-item.get("total_calls", 0),
)
sorted_stats_list = sorted(stats_list, key=sort_key)
return sorted_stats_list
@staticmethod
async def reset_key(provider_name: str, api_key: str | None) -> tuple[bool, str]:
"""重置API Key状态"""
success = await reset_key_status(provider_name, api_key)
if success:
if api_key:
if len(api_key) > 8:
target = f"API Key '{api_key[:4]}...{api_key[-4:]}'"
else:
target = f"API Key '{api_key}'"
else:
target = "所有API Keys"
return True, f"✅ 成功重置提供商 '{provider_name}'{target} 的状态。"
else:
return False, "❌ 重置失败请检查提供商名称或API Key是否正确。"

View File

@ -0,0 +1,204 @@
from typing import Any
from zhenxun.services.llm.core import KeyStatus
from zhenxun.services.llm.types import ModelModality
from zhenxun.utils._build_image import BuildImage
from zhenxun.utils._image_template import ImageTemplate, Markdown, RowStyle
def _format_seconds(seconds: int) -> str:
"""将秒数格式化为 'Xm Ys''Xh Ym' 的形式"""
if seconds <= 0:
return "0s"
if seconds < 60:
return f"{seconds}s"
minutes, seconds = divmod(seconds, 60)
if minutes < 60:
return f"{minutes}m {seconds}s"
hours, minutes = divmod(minutes, 60)
return f"{hours}h {minutes}m"
class Presenters:
"""格式化LLM管理插件的输出 (图片格式)"""
@staticmethod
async def format_model_list_as_image(
models: list[dict[str, Any]], show_all: bool
) -> BuildImage:
"""将模型列表格式化为表格图片"""
title = "📋 LLM模型列表" + (" (所有已配置模型)" if show_all else " (仅可用)")
if not models:
return await BuildImage.build_text_image(
f"{title}\n\n当前没有配置任何LLM模型。"
)
column_name = ["提供商", "模型名称", "API类型", "状态"]
data_list = []
for model in models:
status_text = "✅ 可用" if model.get("is_available", True) else "❌ 不可用"
embed_tag = " (Embed)" if model.get("is_embedding_model", False) else ""
data_list.append(
[
model.get("provider_name", "N/A"),
f"{model.get('model_name', 'N/A')}{embed_tag}",
model.get("api_type", "N/A"),
status_text,
]
)
return await ImageTemplate.table_page(
head_text=title,
tip_text="使用 `llm info <Provider/ModelName>` 查看详情",
column_name=column_name,
data_list=data_list,
)
@staticmethod
async def format_model_details_as_markdown_image(details: dict[str, Any]) -> bytes:
"""将模型详情格式化为Markdown图片"""
provider = details["provider_config"]
model = details["model_detail"]
caps = details["capabilities"]
cap_list = []
if ModelModality.IMAGE in caps.input_modalities:
cap_list.append("视觉")
if ModelModality.VIDEO in caps.input_modalities:
cap_list.append("视频")
if ModelModality.AUDIO in caps.input_modalities:
cap_list.append("音频")
if caps.supports_tool_calling:
cap_list.append("工具调用")
if caps.is_embedding_model:
cap_list.append("文本嵌入")
md = Markdown()
md.head(f"🔎 模型详情: {provider.name}/{model.model_name}", level=1)
md.text("---")
md.head("提供商信息", level=2)
md.list(
[
f"**名称**: {provider.name}",
f"**API 类型**: {provider.api_type}",
f"**API Base**: {provider.api_base or '默认'}",
]
)
md.head("模型详情", level=2)
temp_value = model.temperature or provider.temperature or "未设置"
token_value = model.max_tokens or provider.max_tokens or "未设置"
md.list(
[
f"**名称**: {model.model_name}",
f"**默认温度**: {temp_value}",
f"**最大Token**: {token_value}",
f"**核心能力**: {', '.join(cap_list) or '纯文本'}",
]
)
return await md.build()
@staticmethod
async def format_key_status_as_image(
provider_name: str, sorted_stats: list[dict[str, Any]]
) -> BuildImage:
"""将已排序的、详细的API Key状态格式化为表格图片"""
title = f"🔑 '{provider_name}' API Key 状态"
if not sorted_stats:
return await BuildImage.build_text_image(
f"{title}\n\n该提供商没有配置API Keys。"
)
def _status_row_style(column: str, text: str) -> RowStyle:
style = RowStyle()
if column == "状态":
if "✅ 健康" in text:
style.font_color = "#67C23A"
elif "⚠️ 告警" in text:
style.font_color = "#E6A23C"
elif "❌ 错误" in text or "🚫" in text:
style.font_color = "#F56C6C"
elif "❄️ 冷却中" in text:
style.font_color = "#409EFF"
elif column == "成功率":
try:
if text != "N/A":
rate = float(text.replace("%", ""))
if rate < 80:
style.font_color = "#F56C6C"
elif rate < 95:
style.font_color = "#E6A23C"
except (ValueError, TypeError):
pass
return style
column_name = [
"Key (部分)",
"状态",
"总调用",
"成功率",
"平均延迟(s)",
"上次错误",
"建议操作",
]
data_list = []
for key_info in sorted_stats:
status_enum: KeyStatus = key_info["status_enum"]
if status_enum == KeyStatus.COOLDOWN:
cooldown_seconds = int(key_info["cooldown_seconds_left"])
formatted_time = _format_seconds(cooldown_seconds)
status_text = f"❄️ 冷却中({formatted_time})"
else:
status_text = {
KeyStatus.DISABLED: "🚫 永久禁用",
KeyStatus.ERROR: "❌ 错误",
KeyStatus.WARNING: "⚠️ 告警",
KeyStatus.HEALTHY: "✅ 健康",
KeyStatus.UNUSED: "⚪️ 未使用",
}.get(status_enum, "❔ 未知")
total_calls = key_info["total_calls"]
total_calls_text = (
f"{key_info['success_count']}/{total_calls}"
if total_calls > 0
else "0/0"
)
success_rate = key_info["success_rate"]
success_rate_text = f"{success_rate:.1f}%" if total_calls > 0 else "N/A"
avg_latency = key_info["avg_latency"]
avg_latency_text = f"{avg_latency / 1000:.2f}" if avg_latency > 0 else "N/A"
last_error = key_info.get("last_error") or "-"
if len(last_error) > 25:
last_error = last_error[:22] + "..."
data_list.append(
[
key_info["key_id"],
status_text,
total_calls_text,
success_rate_text,
avg_latency_text,
last_error,
key_info["suggested_action"],
]
)
return await ImageTemplate.table_page(
head_text=title,
tip_text="使用 `llm reset-key <Provider>` 重置Key状态",
column_name=column_name,
data_list=data_list,
text_style=_status_row_style,
column_space=15,
)

View File

@ -275,7 +275,9 @@ async def _(bot: Bot, session: Uninfo):
await GroupInfoUser.set_user_nickname(session.user.id, group_id, "") await GroupInfoUser.set_user_nickname(session.user.id, group_id, "")
else: else:
await FriendUser.set_user_nickname(session.user.id, "") await FriendUser.set_user_nickname(session.user.id, "")
await BanConsole.ban(session.user.id, group_id, 9, 60, bot.self_id) await BanConsole.ban(
session.user.id, group_id, 9, "用户昵称违规", 60, bot.self_id
)
return return
else: else:
await MessageUtils.build_message("你在做梦吗?你没有昵称啊").finish( await MessageUtils.build_message("你在做梦吗?你没有昵称啊").finish(

View File

@ -54,22 +54,6 @@ __plugin_meta__ = PluginMetadata(
default_value=5, default_value=5,
type=int, type=int,
), ),
RegisterConfig(
module="_task",
key="DEFAULT_GROUP_WELCOME",
value=True,
help="被动 进群欢迎 进群默认开关状态",
default_value=True,
type=bool,
),
RegisterConfig(
module="_task",
key="DEFAULT_REFUND_GROUP_REMIND",
value=True,
help="被动 退群提醒 进群默认开关状态",
default_value=True,
type=bool,
),
], ],
tasks=[ tasks=[
Task( Task(

View File

@ -10,7 +10,7 @@ from nonebot_plugin_uninfo import Uninfo
import ujson as json import ujson as json
from zhenxun.builtin_plugins.platform.qq.exception import ForceAddGroupError from zhenxun.builtin_plugins.platform.qq.exception import ForceAddGroupError
from zhenxun.configs.config import Config from zhenxun.configs.config import BotConfig, Config
from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH
from zhenxun.models.fg_request import FgRequest from zhenxun.models.fg_request import FgRequest
from zhenxun.models.group_console import GroupConsole from zhenxun.models.group_console import GroupConsole
@ -20,6 +20,7 @@ from zhenxun.models.plugin_info import PluginInfo
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.common_utils import CommonUtils from zhenxun.utils.common_utils import CommonUtils
from zhenxun.utils.enum import RequestHandleType from zhenxun.utils.enum import RequestHandleType
from zhenxun.utils.manager.bot_profile_manager import BotProfileManager
from zhenxun.utils.message import MessageUtils from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
from zhenxun.utils.utils import FreqLimiter from zhenxun.utils.utils import FreqLimiter
@ -55,15 +56,17 @@ class GroupManager:
if plugin_list := await PluginInfo.filter(default_status=False).all(): if plugin_list := await PluginInfo.filter(default_status=False).all():
for plugin in plugin_list: for plugin in plugin_list:
block_plugin += f"<{plugin.module}," block_plugin += f"<{plugin.module},"
group_info = await bot.get_group_info(group_id=group_id, no_cache=True) group_info = await bot.get_group_info(group_id=group_id)
await GroupConsole.create( await GroupConsole.update_or_create(
group_id=group_info["group_id"], group_id=group_info["group_id"],
group_name=group_info["group_name"], defaults={
max_member_count=group_info["max_member_count"], "group_name": group_info["group_name"],
member_count=group_info["member_count"], "max_member_count": group_info["max_member_count"],
group_flag=1, "member_count": group_info["member_count"],
block_plugin=block_plugin, "group_flag": 1,
platform="qq", "block_plugin": block_plugin,
"platform": "qq",
},
) )
@classmethod @classmethod
@ -145,12 +148,23 @@ class GroupManager:
e=e, e=e,
) )
raise ForceAddGroupError("强制拉群或未有群信息,退出群聊失败...") from e raise ForceAddGroupError("强制拉群或未有群信息,退出群聊失败...") from e
await GroupConsole.filter(group_id=group_id).delete() # await GroupConsole.filter(group_id=group_id).delete()
raise ForceAddGroupError(f"触发强制入群保护,已成功退出群聊 {group_id}...") raise ForceAddGroupError(f"触发强制入群保护,已成功退出群聊 {group_id}...")
else: else:
await cls.__handle_add_group(bot, group_id, group) await cls.__handle_add_group(bot, group_id, group)
"""刷新群管理员权限""" """刷新群管理员权限"""
await cls.__refresh_level(bot, group_id) await cls.__refresh_level(bot, group_id)
if BotProfileManager.is_auto_send_profile():
file_path = await BotProfileManager.build_bot_profile_image(bot.self_id)
if file_path:
await MessageUtils.build_message(
[
f"嗨,大家好,我是{BotConfig.self_nickname} "
"希望我们可以友好相处(眨眼眨眼)!",
file_path,
]
).send()
logger.info("加入群组自动发送BOT自我介绍图片", session=group_id)
@classmethod @classmethod
def get_path(cls, session: Uninfo) -> Path | None: def get_path(cls, session: Uninfo) -> Path | None:

View File

@ -1,4 +1,4 @@
from nonebot.message import run_preprocessor from nonebot import on_message
from nonebot_plugin_uninfo import Uninfo from nonebot_plugin_uninfo import Uninfo
from zhenxun.models.friend_user import FriendUser from zhenxun.models.friend_user import FriendUser
@ -8,24 +8,27 @@ from zhenxun.services.log import logger
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
@run_preprocessor def rule(session: Uninfo) -> bool:
async def do_something(session: Uninfo): return PlatformUtils.is_qbot(session)
_matcher = on_message(priority=999, block=False, rule=rule)
@_matcher.handle()
async def _(session: Uninfo):
platform = PlatformUtils.get_platform(session) platform = PlatformUtils.get_platform(session)
if session.group: if session.group:
if not await GroupConsole.exists(group_id=session.group.id): if not await GroupConsole.exists(group_id=session.group.id):
await GroupConsole.create(group_id=session.group.id) await GroupConsole.create(group_id=session.group.id)
logger.info("添加当前群组ID信息" "", session=session) logger.info("添加当前群组ID信息", session=session)
await GroupInfoUser.update_or_create(
if not await GroupInfoUser.exists( user_id=session.user.id,
user_id=session.user.id, group_id=session.group.id group_id=session.group.id,
): platform=PlatformUtils.get_platform(session),
await GroupInfoUser.create( )
user_id=session.user.id, group_id=session.group.id, platform=platform
)
logger.info("添加当前用户群组ID信息", "", session=session)
elif not await FriendUser.exists(user_id=session.user.id, platform=platform): elif not await FriendUser.exists(user_id=session.user.id, platform=platform):
try: await FriendUser.create(
await FriendUser.create(user_id=session.user.id, platform=platform) user_id=session.user.id, platform=PlatformUtils.get_platform(session)
logger.info("添加当前好友用户信息", "", session=session) )
except Exception as e: logger.info("添加当前好友用户信息", "", session=session)
logger.error("添加当前好友用户信息失败", session=session, e=e)

View File

@ -198,7 +198,9 @@ class StoreManager:
except ValueError as e: except ValueError as e:
return str(e) return str(e)
db_plugin_list = await cls.get_loaded_plugins("module") db_plugin_list = await cls.get_loaded_plugins("module")
plugin_info = next(p for p in plugin_list if p.module == plugin_key) plugin_info = next((p for p in plugin_list if p.module == plugin_key), None)
if plugin_info is None:
return f"未找到插件 {plugin_key}"
if plugin_info.module in [p[0] for p in db_plugin_list]: if plugin_info.module in [p[0] for p in db_plugin_list]:
return f"插件 {plugin_info.name} 已安装,无需重复安装" return f"插件 {plugin_info.name} 已安装,无需重复安装"
is_external = True is_external = True
@ -307,7 +309,9 @@ class StoreManager:
plugin_key = await cls._resolve_plugin_key(plugin_id) plugin_key = await cls._resolve_plugin_key(plugin_id)
except ValueError as e: except ValueError as e:
return str(e) return str(e)
plugin_info = next(p for p in plugin_list if p.module == plugin_key) plugin_info = next((p for p in plugin_list if p.module == plugin_key), None)
if plugin_info is None:
return f"未找到插件 {plugin_key}"
path = BASE_PATH path = BASE_PATH
if plugin_info.github_url: if plugin_info.github_url:
path = BASE_PATH / "plugins" path = BASE_PATH / "plugins"
@ -383,7 +387,9 @@ class StoreManager:
plugin_key = await cls._resolve_plugin_key(plugin_id) plugin_key = await cls._resolve_plugin_key(plugin_id)
except ValueError as e: except ValueError as e:
return str(e) return str(e)
plugin_info = next(p for p in plugin_list if p.module == plugin_key) plugin_info = next((p for p in plugin_list if p.module == plugin_key), None)
if plugin_info is None:
return f"未找到插件 {plugin_key}"
logger.info(f"尝试更新插件 {plugin_info.name}", LOG_COMMAND) logger.info(f"尝试更新插件 {plugin_info.name}", LOG_COMMAND)
db_plugin_list = await cls.get_loaded_plugins("module", "version") db_plugin_list = await cls.get_loaded_plugins("module", "version")
suc_plugin = {p[0]: (p[1] or "Unknown") for p in db_plugin_list} suc_plugin = {p[0]: (p[1] or "Unknown") for p in db_plugin_list}

View File

@ -1,9 +1,11 @@
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
from zhenxun.configs.utils import PluginExtraData from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import PluginType
from . import command # noqa: F401 from . import commands, handlers
__all__ = ["commands", "handlers"]
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
name="定时任务管理", name="定时任务管理",
@ -27,6 +29,8 @@ __plugin_meta__ = PluginMetadata(
定时任务 恢复 <任务ID> | -p <插件> [-g <群号>] | -all 定时任务 恢复 <任务ID> | -p <插件> [-g <群号>] | -all
定时任务 执行 <任务ID> 定时任务 执行 <任务ID>
定时任务 更新 <任务ID> [时间选项] [--kwargs <参数>] 定时任务 更新 <任务ID> [时间选项] [--kwargs <参数>]
# [修改] 增加说明
说明: -p 选项可单独使用用于操作指定插件的所有任务
📝 时间选项 (三选一): 📝 时间选项 (三选一):
--cron "<分> <时> <日> <月> <周>" # 例: --cron "0 8 * * *" --cron "<分> <时> <日> <月> <周>" # 例: --cron "0 8 * * *"
@ -47,5 +51,35 @@ __plugin_meta__ = PluginMetadata(
version="0.1.2", version="0.1.2",
plugin_type=PluginType.SUPERUSER, plugin_type=PluginType.SUPERUSER,
is_show=False, is_show=False,
configs=[
RegisterConfig(
module="SchedulerManager",
key="ALL_GROUPS_CONCURRENCY_LIMIT",
value=5,
help="“所有群组”类型定时任务的并发执行数量限制",
type=int,
),
RegisterConfig(
module="SchedulerManager",
key="JOB_MAX_RETRIES",
value=2,
help="定时任务执行失败时的最大重试次数",
type=int,
),
RegisterConfig(
module="SchedulerManager",
key="JOB_RETRY_DELAY",
value=10,
help="定时任务执行重试的间隔时间(秒)",
type=int,
),
RegisterConfig(
module="SchedulerManager",
key="SCHEDULER_TIMEZONE",
value="Asia/Shanghai",
help="定时任务使用的时区,默认为 Asia/Shanghai",
type=str,
),
],
).to_dict(), ).to_dict(),
) )

View File

@ -1,836 +0,0 @@
import asyncio
from datetime import datetime
import re
from nonebot.adapters import Event
from nonebot.adapters.onebot.v11 import Bot
from nonebot.params import Depends
from nonebot.permission import SUPERUSER
from nonebot_plugin_alconna import (
Alconna,
AlconnaMatch,
Args,
Arparma,
Match,
Option,
Query,
Subcommand,
on_alconna,
)
from pydantic import BaseModel, ValidationError
from zhenxun.utils._image_template import ImageTemplate
from zhenxun.utils.manager.schedule_manager import scheduler_manager
def _get_type_name(annotation) -> str:
"""获取类型注解的名称"""
if hasattr(annotation, "__name__"):
return annotation.__name__
elif hasattr(annotation, "_name"):
return annotation._name
else:
return str(annotation)
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.rules import admin_check
def _format_trigger(schedule_status: dict) -> str:
"""将触发器配置格式化为人类可读的字符串"""
trigger_type = schedule_status["trigger_type"]
config = schedule_status["trigger_config"]
if trigger_type == "cron":
minute = config.get("minute", "*")
hour = config.get("hour", "*")
day = config.get("day", "*")
month = config.get("month", "*")
day_of_week = config.get("day_of_week", "*")
if day == "*" and month == "*" and day_of_week == "*":
formatted_hour = hour if hour == "*" else f"{int(hour):02d}"
formatted_minute = minute if minute == "*" else f"{int(minute):02d}"
return f"每天 {formatted_hour}:{formatted_minute}"
else:
return f"Cron: {minute} {hour} {day} {month} {day_of_week}"
elif trigger_type == "interval":
seconds = config.get("seconds", 0)
minutes = config.get("minutes", 0)
hours = config.get("hours", 0)
days = config.get("days", 0)
if days:
trigger_str = f"{days}"
elif hours:
trigger_str = f"{hours} 小时"
elif minutes:
trigger_str = f"{minutes} 分钟"
else:
trigger_str = f"{seconds}"
elif trigger_type == "date":
run_date = config.get("run_date", "未知时间")
trigger_str = f"{run_date}"
else:
trigger_str = f"{trigger_type}: {config}"
return trigger_str
def _format_params(schedule_status: dict) -> str:
"""将任务参数格式化为人类可读的字符串"""
if kwargs := schedule_status.get("job_kwargs"):
kwargs_str = " | ".join(f"{k}: {v}" for k, v in kwargs.items())
return kwargs_str
return "-"
def _parse_interval(interval_str: str) -> dict:
"""增强版解析器,支持 d(天)"""
match = re.match(r"(\d+)([smhd])", interval_str.lower())
if not match:
raise ValueError("时间间隔格式错误, 请使用如 '30m', '2h', '1d', '10s' 的格式。")
value, unit = int(match.group(1)), match.group(2)
if unit == "s":
return {"seconds": value}
if unit == "m":
return {"minutes": value}
if unit == "h":
return {"hours": value}
if unit == "d":
return {"days": value}
return {}
def _parse_daily_time(time_str: str) -> dict:
"""解析 HH:MM 或 HH:MM:SS 格式的时间为 cron 配置"""
if match := re.match(r"^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$", time_str):
hour, minute, second = match.groups()
hour, minute = int(hour), int(minute)
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise ValueError("小时或分钟数值超出范围。")
cron_config = {
"minute": str(minute),
"hour": str(hour),
"day": "*",
"month": "*",
"day_of_week": "*",
}
if second is not None:
if not (0 <= int(second) <= 59):
raise ValueError("秒数值超出范围。")
cron_config["second"] = str(second)
return cron_config
else:
raise ValueError("时间格式错误,请使用 'HH:MM''HH:MM:SS' 格式。")
async def GetBotId(
bot: Bot,
bot_id_match: Match[str] = AlconnaMatch("bot_id"),
) -> str:
"""获取要操作的Bot ID"""
if bot_id_match.available:
return bot_id_match.result
return bot.self_id
class ScheduleTarget:
"""定时任务操作目标的基类"""
pass
class TargetByID(ScheduleTarget):
"""按任务ID操作"""
def __init__(self, id: int):
self.id = id
class TargetByPlugin(ScheduleTarget):
"""按插件名操作"""
def __init__(
self, plugin: str, group_id: str | None = None, all_groups: bool = False
):
self.plugin = plugin
self.group_id = group_id
self.all_groups = all_groups
class TargetAll(ScheduleTarget):
"""操作所有任务"""
def __init__(self, for_group: str | None = None):
self.for_group = for_group
TargetScope = TargetByID | TargetByPlugin | TargetAll | None
def create_target_parser(subcommand_name: str):
"""
创建一个依赖注入函数用于解析删除暂停恢复等命令的操作目标
"""
async def dependency(
event: Event,
schedule_id: Match[int] = AlconnaMatch("schedule_id"),
plugin_name: Match[str] = AlconnaMatch("plugin_name"),
group_id: Match[str] = AlconnaMatch("group_id"),
all_enabled: Query[bool] = Query(f"{subcommand_name}.all"),
) -> TargetScope:
if schedule_id.available:
return TargetByID(schedule_id.result)
if plugin_name.available:
p_name = plugin_name.result
if all_enabled.available:
return TargetByPlugin(plugin=p_name, all_groups=True)
elif group_id.available:
gid = group_id.result
if gid.lower() == "all":
return TargetByPlugin(plugin=p_name, all_groups=True)
return TargetByPlugin(plugin=p_name, group_id=gid)
else:
current_group_id = getattr(event, "group_id", None)
if current_group_id:
return TargetByPlugin(plugin=p_name, group_id=str(current_group_id))
else:
await schedule_cmd.finish(
"私聊中操作插件任务必须使用 -g <群号> 或 -all 选项。"
)
if all_enabled.available:
return TargetAll(for_group=group_id.result if group_id.available else None)
return None
return dependency
schedule_cmd = on_alconna(
Alconna(
"定时任务",
Subcommand(
"查看",
Option("-g", Args["target_group_id", str]),
Option("-all", help_text="查看所有群聊 (SUPERUSER)"),
Option("-p", Args["plugin_name", str], help_text="按插件名筛选"),
Option("--page", Args["page", int, 1], help_text="指定页码"),
alias=["ls", "list"],
help_text="查看定时任务",
),
Subcommand(
"设置",
Args["plugin_name", str],
Option("--cron", Args["cron_expr", str], help_text="设置 cron 表达式"),
Option("--interval", Args["interval_expr", str], help_text="设置时间间隔"),
Option("--date", Args["date_expr", str], help_text="设置特定执行日期"),
Option(
"--daily",
Args["daily_expr", str],
help_text="设置每天执行的时间 (如 08:20)",
),
Option("-g", Args["group_id", str], help_text="指定群组ID或'all'"),
Option("-all", help_text="对所有群生效 (等同于 -g all)"),
Option("--kwargs", Args["kwargs_str", str], help_text="设置任务参数"),
Option(
"--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)"
),
alias=["add", "开启"],
help_text="设置/开启一个定时任务",
),
Subcommand(
"删除",
Args["schedule_id?", int],
Option("-p", Args["plugin_name", str], help_text="指定插件名"),
Option("-g", Args["group_id", str], help_text="指定群组ID"),
Option("-all", help_text="对所有群生效"),
Option(
"--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)"
),
alias=["del", "rm", "remove", "关闭", "取消"],
help_text="删除一个或多个定时任务",
),
Subcommand(
"暂停",
Args["schedule_id?", int],
Option("-all", help_text="对当前群所有任务生效"),
Option("-p", Args["plugin_name", str], help_text="指定插件名"),
Option("-g", Args["group_id", str], help_text="指定群组ID (SUPERUSER)"),
Option(
"--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)"
),
alias=["pause"],
help_text="暂停一个或多个定时任务",
),
Subcommand(
"恢复",
Args["schedule_id?", int],
Option("-all", help_text="对当前群所有任务生效"),
Option("-p", Args["plugin_name", str], help_text="指定插件名"),
Option("-g", Args["group_id", str], help_text="指定群组ID (SUPERUSER)"),
Option(
"--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)"
),
alias=["resume"],
help_text="恢复一个或多个定时任务",
),
Subcommand(
"执行",
Args["schedule_id", int],
alias=["trigger", "run"],
help_text="立即执行一次任务",
),
Subcommand(
"更新",
Args["schedule_id", int],
Option("--cron", Args["cron_expr", str], help_text="设置 cron 表达式"),
Option("--interval", Args["interval_expr", str], help_text="设置时间间隔"),
Option("--date", Args["date_expr", str], help_text="设置特定执行日期"),
Option(
"--daily",
Args["daily_expr", str],
help_text="更新每天执行的时间 (如 08:20)",
),
Option("--kwargs", Args["kwargs_str", str], help_text="更新参数"),
alias=["update", "modify", "修改"],
help_text="更新任务配置",
),
Subcommand(
"状态",
Args["schedule_id", int],
alias=["status", "info"],
help_text="查看单个任务的详细状态",
),
Subcommand(
"插件列表",
alias=["plugins"],
help_text="列出所有可用的插件",
),
),
priority=5,
block=True,
rule=admin_check(1),
)
schedule_cmd.shortcut(
"任务状态",
command="定时任务",
arguments=["状态", "{%0}"],
prefix=True,
)
@schedule_cmd.handle()
async def _handle_time_options_mutex(arp: Arparma):
time_options = ["cron", "interval", "date", "daily"]
provided_options = [opt for opt in time_options if arp.query(opt) is not None]
if len(provided_options) > 1:
await schedule_cmd.finish(
f"时间选项 --{', --'.join(provided_options)} 不能同时使用,请只选择一个。"
)
@schedule_cmd.assign("查看")
async def _(
bot: Bot,
event: Event,
target_group_id: Match[str] = AlconnaMatch("target_group_id"),
all_groups: Query[bool] = Query("查看.all"),
plugin_name: Match[str] = AlconnaMatch("plugin_name"),
page: Match[int] = AlconnaMatch("page"),
):
is_superuser = await SUPERUSER(bot, event)
schedules = []
title = ""
current_group_id = getattr(event, "group_id", None)
if not (all_groups.available or target_group_id.available) and not current_group_id:
await schedule_cmd.finish("私聊中查看任务必须使用 -g <群号> 或 -all 选项。")
if all_groups.available:
if not is_superuser:
await schedule_cmd.finish("需要超级用户权限才能查看所有群组的定时任务。")
schedules = await scheduler_manager.get_all_schedules()
title = "所有群组的定时任务"
elif target_group_id.available:
if not is_superuser:
await schedule_cmd.finish("需要超级用户权限才能查看指定群组的定时任务。")
gid = target_group_id.result
schedules = [
s for s in await scheduler_manager.get_all_schedules() if s.group_id == gid
]
title = f"{gid} 的定时任务"
else:
gid = str(current_group_id)
schedules = [
s for s in await scheduler_manager.get_all_schedules() if s.group_id == gid
]
title = "本群的定时任务"
if plugin_name.available:
schedules = [s for s in schedules if s.plugin_name == plugin_name.result]
title += f" [插件: {plugin_name.result}]"
if not schedules:
await schedule_cmd.finish("没有找到任何相关的定时任务。")
page_size = 15
current_page = page.result
total_items = len(schedules)
total_pages = (total_items + page_size - 1) // page_size
start_index = (current_page - 1) * page_size
end_index = start_index + page_size
paginated_schedules = schedules[start_index:end_index]
if not paginated_schedules:
await schedule_cmd.finish("这一页没有内容了哦~")
status_tasks = [
scheduler_manager.get_schedule_status(s.id) for s in paginated_schedules
]
all_statuses = await asyncio.gather(*status_tasks)
data_list = [
[
s["id"],
s["plugin_name"],
s.get("bot_id") or "N/A",
s["group_id"] or "全局",
s["next_run_time"],
_format_trigger(s),
_format_params(s),
"✔️ 已启用" if s["is_enabled"] else "⏸️ 已暂停",
]
for s in all_statuses
if s
]
if not data_list:
await schedule_cmd.finish("没有找到任何相关的定时任务。")
img = await ImageTemplate.table_page(
head_text=title,
tip_text=f"{current_page}/{total_pages} 页,共 {total_items} 条任务",
column_name=[
"ID",
"插件",
"Bot ID",
"群组/目标",
"下次运行",
"触发规则",
"参数",
"状态",
],
data_list=data_list,
column_space=20,
)
await MessageUtils.build_message(img).send(reply_to=True)
@schedule_cmd.assign("设置")
async def _(
event: Event,
plugin_name: str,
cron_expr: str | None = None,
interval_expr: str | None = None,
date_expr: str | None = None,
daily_expr: str | None = None,
group_id: str | None = None,
kwargs_str: str | None = None,
all_enabled: Query[bool] = Query("设置.all"),
bot_id_to_operate: str = Depends(GetBotId),
):
if plugin_name not in scheduler_manager._registered_tasks:
await schedule_cmd.finish(
f"插件 '{plugin_name}' 没有注册可用的定时任务。\n"
f"可用插件: {list(scheduler_manager._registered_tasks.keys())}"
)
trigger_type = ""
trigger_config = {}
try:
if cron_expr:
trigger_type = "cron"
parts = cron_expr.split()
if len(parts) != 5:
raise ValueError("Cron 表达式必须有5个部分 (分 时 日 月 周)")
cron_keys = ["minute", "hour", "day", "month", "day_of_week"]
trigger_config = dict(zip(cron_keys, parts))
elif interval_expr:
trigger_type = "interval"
trigger_config = _parse_interval(interval_expr)
elif date_expr:
trigger_type = "date"
trigger_config = {"run_date": datetime.fromisoformat(date_expr)}
elif daily_expr:
trigger_type = "cron"
trigger_config = _parse_daily_time(daily_expr)
else:
await schedule_cmd.finish(
"必须提供一种时间选项: --cron, --interval, --date, 或 --daily。"
)
except ValueError as e:
await schedule_cmd.finish(f"时间参数解析错误: {e}")
job_kwargs = {}
if kwargs_str:
task_meta = scheduler_manager._registered_tasks[plugin_name]
params_model = task_meta.get("model")
if not params_model:
await schedule_cmd.finish(f"插件 '{plugin_name}' 不支持设置额外参数。")
if not (isinstance(params_model, type) and issubclass(params_model, BaseModel)):
await schedule_cmd.finish(f"插件 '{plugin_name}' 的参数模型配置错误。")
raw_kwargs = {}
try:
for item in kwargs_str.split(","):
key, value = item.strip().split("=", 1)
raw_kwargs[key.strip()] = value
except Exception as e:
await schedule_cmd.finish(
f"参数格式错误,请使用 'key=value,key2=value2' 格式。错误: {e}"
)
try:
model_validate = getattr(params_model, "model_validate", None)
if not model_validate:
await schedule_cmd.finish(
f"插件 '{plugin_name}' 的参数模型不支持验证。"
)
return
validated_model = model_validate(raw_kwargs)
model_dump = getattr(validated_model, "model_dump", None)
if not model_dump:
await schedule_cmd.finish(
f"插件 '{plugin_name}' 的参数模型不支持导出。"
)
return
job_kwargs = model_dump()
except ValidationError as e:
errors = [f" - {err['loc'][0]}: {err['msg']}" for err in e.errors()]
error_str = "\n".join(errors)
await schedule_cmd.finish(
f"插件 '{plugin_name}' 的任务参数验证失败:\n{error_str}"
)
return
target_group_id: str | None
current_group_id = getattr(event, "group_id", None)
if group_id and group_id.lower() == "all":
target_group_id = "__ALL_GROUPS__"
elif all_enabled.available:
target_group_id = "__ALL_GROUPS__"
elif group_id:
target_group_id = group_id
elif current_group_id:
target_group_id = str(current_group_id)
else:
await schedule_cmd.finish(
"私聊中设置定时任务时,必须使用 -g <群号> 或 --all 选项指定目标。"
)
return
success, msg = await scheduler_manager.add_schedule(
plugin_name,
target_group_id,
trigger_type,
trigger_config,
job_kwargs,
bot_id=bot_id_to_operate,
)
if target_group_id == "__ALL_GROUPS__":
target_desc = f"所有群组 (Bot: {bot_id_to_operate})"
elif target_group_id is None:
target_desc = "全局"
else:
target_desc = f"群组 {target_group_id}"
if success:
await schedule_cmd.finish(f"已成功为 [{target_desc}] {msg}")
else:
await schedule_cmd.finish(f"为 [{target_desc}] 设置任务失败: {msg}")
@schedule_cmd.assign("删除")
async def _(
target: TargetScope = Depends(create_target_parser("删除")),
bot_id_to_operate: str = Depends(GetBotId),
):
if isinstance(target, TargetByID):
_, message = await scheduler_manager.remove_schedule_by_id(target.id)
await schedule_cmd.finish(message)
elif isinstance(target, TargetByPlugin):
p_name = target.plugin
if p_name not in scheduler_manager.get_registered_plugins():
await schedule_cmd.finish(f"未找到插件 '{p_name}'")
if target.all_groups:
removed_count = await scheduler_manager.remove_schedule_for_all(
p_name, bot_id=bot_id_to_operate
)
message = (
f"已取消了 {removed_count} 个群组的插件 '{p_name}' 定时任务。"
if removed_count > 0
else f"没有找到插件 '{p_name}' 的定时任务。"
)
await schedule_cmd.finish(message)
else:
_, message = await scheduler_manager.remove_schedule(
p_name, target.group_id, bot_id=bot_id_to_operate
)
await schedule_cmd.finish(message)
elif isinstance(target, TargetAll):
if target.for_group:
_, message = await scheduler_manager.remove_schedules_by_group(
target.for_group
)
await schedule_cmd.finish(message)
else:
_, message = await scheduler_manager.remove_all_schedules()
await schedule_cmd.finish(message)
else:
await schedule_cmd.finish(
"删除任务失败请提供任务ID或通过 -p <插件> 或 -all 指定要删除的任务。"
)
@schedule_cmd.assign("暂停")
async def _(
target: TargetScope = Depends(create_target_parser("暂停")),
bot_id_to_operate: str = Depends(GetBotId),
):
if isinstance(target, TargetByID):
_, message = await scheduler_manager.pause_schedule(target.id)
await schedule_cmd.finish(message)
elif isinstance(target, TargetByPlugin):
p_name = target.plugin
if p_name not in scheduler_manager.get_registered_plugins():
await schedule_cmd.finish(f"未找到插件 '{p_name}'")
if target.all_groups:
_, message = await scheduler_manager.pause_schedules_by_plugin(p_name)
await schedule_cmd.finish(message)
else:
_, message = await scheduler_manager.pause_schedule_by_plugin_group(
p_name, target.group_id, bot_id=bot_id_to_operate
)
await schedule_cmd.finish(message)
elif isinstance(target, TargetAll):
if target.for_group:
_, message = await scheduler_manager.pause_schedules_by_group(
target.for_group
)
await schedule_cmd.finish(message)
else:
_, message = await scheduler_manager.pause_all_schedules()
await schedule_cmd.finish(message)
else:
await schedule_cmd.finish("请提供任务ID、使用 -p <插件> 或 -all 选项。")
@schedule_cmd.assign("恢复")
async def _(
target: TargetScope = Depends(create_target_parser("恢复")),
bot_id_to_operate: str = Depends(GetBotId),
):
if isinstance(target, TargetByID):
_, message = await scheduler_manager.resume_schedule(target.id)
await schedule_cmd.finish(message)
elif isinstance(target, TargetByPlugin):
p_name = target.plugin
if p_name not in scheduler_manager.get_registered_plugins():
await schedule_cmd.finish(f"未找到插件 '{p_name}'")
if target.all_groups:
_, message = await scheduler_manager.resume_schedules_by_plugin(p_name)
await schedule_cmd.finish(message)
else:
_, message = await scheduler_manager.resume_schedule_by_plugin_group(
p_name, target.group_id, bot_id=bot_id_to_operate
)
await schedule_cmd.finish(message)
elif isinstance(target, TargetAll):
if target.for_group:
_, message = await scheduler_manager.resume_schedules_by_group(
target.for_group
)
await schedule_cmd.finish(message)
else:
_, message = await scheduler_manager.resume_all_schedules()
await schedule_cmd.finish(message)
else:
await schedule_cmd.finish("请提供任务ID、使用 -p <插件> 或 -all 选项。")
@schedule_cmd.assign("执行")
async def _(schedule_id: int):
_, message = await scheduler_manager.trigger_now(schedule_id)
await schedule_cmd.finish(message)
@schedule_cmd.assign("更新")
async def _(
schedule_id: int,
cron_expr: str | None = None,
interval_expr: str | None = None,
date_expr: str | None = None,
daily_expr: str | None = None,
kwargs_str: str | None = None,
):
if not any([cron_expr, interval_expr, date_expr, daily_expr, kwargs_str]):
await schedule_cmd.finish(
"请提供需要更新的时间 (--cron/--interval/--date/--daily) 或参数 (--kwargs)"
)
trigger_config = None
trigger_type = None
try:
if cron_expr:
trigger_type = "cron"
parts = cron_expr.split()
if len(parts) != 5:
raise ValueError("Cron 表达式必须有5个部分")
cron_keys = ["minute", "hour", "day", "month", "day_of_week"]
trigger_config = dict(zip(cron_keys, parts))
elif interval_expr:
trigger_type = "interval"
trigger_config = _parse_interval(interval_expr)
elif date_expr:
trigger_type = "date"
trigger_config = {"run_date": datetime.fromisoformat(date_expr)}
elif daily_expr:
trigger_type = "cron"
trigger_config = _parse_daily_time(daily_expr)
except ValueError as e:
await schedule_cmd.finish(f"时间参数解析错误: {e}")
job_kwargs = None
if kwargs_str:
schedule = await scheduler_manager.get_schedule_by_id(schedule_id)
if not schedule:
await schedule_cmd.finish(f"未找到 ID 为 {schedule_id} 的任务。")
task_meta = scheduler_manager._registered_tasks.get(schedule.plugin_name)
if not task_meta or not (params_model := task_meta.get("model")):
await schedule_cmd.finish(
f"插件 '{schedule.plugin_name}' 未定义参数模型,无法更新参数。"
)
if not (isinstance(params_model, type) and issubclass(params_model, BaseModel)):
await schedule_cmd.finish(
f"插件 '{schedule.plugin_name}' 的参数模型配置错误。"
)
raw_kwargs = {}
try:
for item in kwargs_str.split(","):
key, value = item.strip().split("=", 1)
raw_kwargs[key.strip()] = value
except Exception as e:
await schedule_cmd.finish(
f"参数格式错误,请使用 'key=value,key2=value2' 格式。错误: {e}"
)
try:
model_validate = getattr(params_model, "model_validate", None)
if not model_validate:
await schedule_cmd.finish(
f"插件 '{schedule.plugin_name}' 的参数模型不支持验证。"
)
return
validated_model = model_validate(raw_kwargs)
model_dump = getattr(validated_model, "model_dump", None)
if not model_dump:
await schedule_cmd.finish(
f"插件 '{schedule.plugin_name}' 的参数模型不支持导出。"
)
return
job_kwargs = model_dump(exclude_unset=True)
except ValidationError as e:
errors = [f" - {err['loc'][0]}: {err['msg']}" for err in e.errors()]
error_str = "\n".join(errors)
await schedule_cmd.finish(f"更新的参数验证失败:\n{error_str}")
return
_, message = await scheduler_manager.update_schedule(
schedule_id, trigger_type, trigger_config, job_kwargs
)
await schedule_cmd.finish(message)
@schedule_cmd.assign("插件列表")
async def _():
registered_plugins = scheduler_manager.get_registered_plugins()
if not registered_plugins:
await schedule_cmd.finish("当前没有已注册的定时任务插件。")
message_parts = ["📋 已注册的定时任务插件:"]
for i, plugin_name in enumerate(registered_plugins, 1):
task_meta = scheduler_manager._registered_tasks[plugin_name]
params_model = task_meta.get("model")
if not params_model:
message_parts.append(f"{i}. {plugin_name} - 无参数")
continue
if not (isinstance(params_model, type) and issubclass(params_model, BaseModel)):
message_parts.append(f"{i}. {plugin_name} - ⚠️ 参数模型配置错误")
continue
model_fields = getattr(params_model, "model_fields", None)
if model_fields:
param_info = ", ".join(
f"{field_name}({_get_type_name(field_info.annotation)})"
for field_name, field_info in model_fields.items()
)
message_parts.append(f"{i}. {plugin_name} - 参数: {param_info}")
else:
message_parts.append(f"{i}. {plugin_name} - 无参数")
await schedule_cmd.finish("\n".join(message_parts))
@schedule_cmd.assign("状态")
async def _(schedule_id: int):
status = await scheduler_manager.get_schedule_status(schedule_id)
if not status:
await schedule_cmd.finish(f"未找到ID为 {schedule_id} 的定时任务。")
info_lines = [
f"📋 定时任务详细信息 (ID: {schedule_id})",
"--------------------",
f"▫️ 插件: {status['plugin_name']}",
f"▫️ Bot ID: {status.get('bot_id') or '默认'}",
f"▫️ 目标: {status['group_id'] or '全局'}",
f"▫️ 状态: {'✔️ 已启用' if status['is_enabled'] else '⏸️ 已暂停'}",
f"▫️ 下次运行: {status['next_run_time']}",
f"▫️ 触发规则: {_format_trigger(status)}",
f"▫️ 任务参数: {_format_params(status)}",
]
await schedule_cmd.finish("\n".join(info_lines))

View File

@ -0,0 +1,298 @@
import re
from nonebot.adapters import Event
from nonebot.adapters.onebot.v11 import Bot
from nonebot.params import Depends
from nonebot.permission import SUPERUSER
from nonebot_plugin_alconna import (
Alconna,
AlconnaMatch,
Args,
Match,
Option,
Query,
Subcommand,
on_alconna,
)
from zhenxun.configs.config import Config
from zhenxun.services.scheduler import scheduler_manager
from zhenxun.services.scheduler.targeter import ScheduleTargeter
from zhenxun.utils.rules import admin_check
schedule_cmd = on_alconna(
Alconna(
"定时任务",
Subcommand(
"查看",
Option("-g", Args["target_group_id", str]),
Option("-all", help_text="查看所有群聊 (SUPERUSER)"),
Option("-p", Args["plugin_name", str], help_text="按插件名筛选"),
Option("--page", Args["page", int, 1], help_text="指定页码"),
alias=["ls", "list"],
help_text="查看定时任务",
),
Subcommand(
"设置",
Args["plugin_name", str],
Option("--cron", Args["cron_expr", str], help_text="设置 cron 表达式"),
Option("--interval", Args["interval_expr", str], help_text="设置时间间隔"),
Option("--date", Args["date_expr", str], help_text="设置特定执行日期"),
Option(
"--daily",
Args["daily_expr", str],
help_text="设置每天执行的时间 (如 08:20)",
),
Option("-g", Args["group_id", str], help_text="指定群组ID或'all'"),
Option("-all", help_text="对所有群生效 (等同于 -g all)"),
Option("--kwargs", Args["kwargs_str", str], help_text="设置任务参数"),
Option(
"--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)"
),
alias=["add", "开启"],
help_text="设置/开启一个定时任务",
),
Subcommand(
"删除",
Args["schedule_id?", int],
Option("-p", Args["plugin_name", str], help_text="指定插件名"),
Option("-g", Args["group_id", str], help_text="指定群组ID"),
Option("-all", help_text="对所有群生效"),
Option(
"--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)"
),
alias=["del", "rm", "remove", "关闭", "取消"],
help_text="删除一个或多个定时任务",
),
Subcommand(
"暂停",
Args["schedule_id?", int],
Option("-all", help_text="对当前群所有任务生效"),
Option("-p", Args["plugin_name", str], help_text="指定插件名"),
Option("-g", Args["group_id", str], help_text="指定群组ID (SUPERUSER)"),
Option(
"--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)"
),
alias=["pause"],
help_text="暂停一个或多个定时任务",
),
Subcommand(
"恢复",
Args["schedule_id?", int],
Option("-all", help_text="对当前群所有任务生效"),
Option("-p", Args["plugin_name", str], help_text="指定插件名"),
Option("-g", Args["group_id", str], help_text="指定群组ID (SUPERUSER)"),
Option(
"--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)"
),
alias=["resume"],
help_text="恢复一个或多个定时任务",
),
Subcommand(
"执行",
Args["schedule_id", int],
alias=["trigger", "run"],
help_text="立即执行一次任务",
),
Subcommand(
"更新",
Args["schedule_id", int],
Option("--cron", Args["cron_expr", str], help_text="设置 cron 表达式"),
Option("--interval", Args["interval_expr", str], help_text="设置时间间隔"),
Option("--date", Args["date_expr", str], help_text="设置特定执行日期"),
Option(
"--daily",
Args["daily_expr", str],
help_text="更新每天执行的时间 (如 08:20)",
),
Option("--kwargs", Args["kwargs_str", str], help_text="更新参数"),
alias=["update", "modify", "修改"],
help_text="更新任务配置",
),
Subcommand(
"状态",
Args["schedule_id", int],
alias=["status", "info"],
help_text="查看单个任务的详细状态",
),
Subcommand(
"插件列表",
alias=["plugins"],
help_text="列出所有可用的插件",
),
),
priority=5,
block=True,
rule=admin_check(1),
)
schedule_cmd.shortcut(
"任务状态",
command="定时任务",
arguments=["状态", "{%0}"],
prefix=True,
)
class ScheduleTarget:
pass
class TargetByID(ScheduleTarget):
def __init__(self, id: int):
self.id = id
class TargetByPlugin(ScheduleTarget):
def __init__(
self, plugin: str, group_id: str | None = None, all_groups: bool = False
):
self.plugin = plugin
self.group_id = group_id
self.all_groups = all_groups
class TargetAll(ScheduleTarget):
def __init__(self, for_group: str | None = None):
self.for_group = for_group
TargetScope = TargetByID | TargetByPlugin | TargetAll | None
def create_target_parser(subcommand_name: str):
async def dependency(
event: Event,
schedule_id: Match[int] = AlconnaMatch("schedule_id"),
plugin_name: Match[str] = AlconnaMatch("plugin_name"),
group_id: Match[str] = AlconnaMatch("group_id"),
all_enabled: Query[bool] = Query(f"{subcommand_name}.all"),
) -> TargetScope:
if schedule_id.available:
return TargetByID(schedule_id.result)
if plugin_name.available:
p_name = plugin_name.result
if all_enabled.available:
return TargetByPlugin(plugin=p_name, all_groups=True)
elif group_id.available:
gid = group_id.result
if gid.lower() == "all":
return TargetByPlugin(plugin=p_name, all_groups=True)
return TargetByPlugin(plugin=p_name, group_id=gid)
else:
current_group_id = getattr(event, "group_id", None)
return TargetByPlugin(
plugin=p_name,
group_id=str(current_group_id) if current_group_id else None,
)
if all_enabled.available:
current_group_id = getattr(event, "group_id", None)
if not current_group_id:
await schedule_cmd.finish(
"私聊中单独使用 -all 选项时,必须使用 -g <群号> 指定目标。"
)
return TargetAll(for_group=str(current_group_id))
return None
return dependency
def parse_interval(interval_str: str) -> dict:
match = re.match(r"(\d+)([smhd])", interval_str.lower())
if not match:
raise ValueError("时间间隔格式错误, 请使用如 '30m', '2h', '1d', '10s' 的格式。")
value, unit = int(match.group(1)), match.group(2)
if unit == "s":
return {"seconds": value}
if unit == "m":
return {"minutes": value}
if unit == "h":
return {"hours": value}
if unit == "d":
return {"days": value}
return {}
def parse_daily_time(time_str: str) -> dict:
if match := re.match(r"^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$", time_str):
hour, minute, second = match.groups()
hour, minute = int(hour), int(minute)
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise ValueError("小时或分钟数值超出范围。")
cron_config = {
"minute": str(minute),
"hour": str(hour),
"day": "*",
"month": "*",
"day_of_week": "*",
"timezone": Config.get_config("SchedulerManager", "SCHEDULER_TIMEZONE"),
}
if second is not None:
if not (0 <= int(second) <= 59):
raise ValueError("秒数值超出范围。")
cron_config["second"] = str(second)
return cron_config
else:
raise ValueError("时间格式错误,请使用 'HH:MM''HH:MM:SS' 格式。")
async def GetBotId(bot: Bot, bot_id_match: Match[str] = AlconnaMatch("bot_id")) -> str:
if bot_id_match.available:
return bot_id_match.result
return bot.self_id
def GetTargeter(subcommand: str):
"""
依赖注入函数用于解析命令参数并返回一个配置好的 ScheduleTargeter 实例
"""
async def dependency(
event: Event,
bot: Bot,
schedule_id: Match[int] = AlconnaMatch("schedule_id"),
plugin_name: Match[str] = AlconnaMatch("plugin_name"),
group_id: Match[str] = AlconnaMatch("group_id"),
all_enabled: Query[bool] = Query(f"{subcommand}.all"),
bot_id_to_operate: str = Depends(GetBotId),
) -> ScheduleTargeter:
if schedule_id.available:
return scheduler_manager.target(id=schedule_id.result)
if plugin_name.available:
if all_enabled.available:
return scheduler_manager.target(plugin_name=plugin_name.result)
current_group_id = getattr(event, "group_id", None)
gid = group_id.result if group_id.available else current_group_id
return scheduler_manager.target(
plugin_name=plugin_name.result,
group_id=str(gid) if gid else None,
bot_id=bot_id_to_operate,
)
if all_enabled.available:
current_group_id = getattr(event, "group_id", None)
gid = group_id.result if group_id.available else current_group_id
is_su = await SUPERUSER(bot, event)
if not gid and not is_su:
await schedule_cmd.finish(
f"在私聊中对所有任务进行'{subcommand}'操作需要超级用户权限。"
)
if (gid and str(gid).lower() == "all") or (not gid and is_su):
return scheduler_manager.target()
return scheduler_manager.target(
group_id=str(gid) if gid else None, bot_id=bot_id_to_operate
)
await schedule_cmd.finish(
f"'{subcommand}'操作失败请提供任务ID"
f"或通过 -p <插件名> 或 -all 指定要操作的任务。"
)
return Depends(dependency)

View File

@ -0,0 +1,380 @@
from datetime import datetime
from nonebot.adapters import Event
from nonebot.adapters.onebot.v11 import Bot
from nonebot.params import Depends
from nonebot.permission import SUPERUSER
from nonebot_plugin_alconna import AlconnaMatch, Arparma, Match, Query
from pydantic import BaseModel, ValidationError
from zhenxun.models.schedule_info import ScheduleInfo
from zhenxun.services.scheduler import scheduler_manager
from zhenxun.services.scheduler.targeter import ScheduleTargeter
from zhenxun.utils.message import MessageUtils
from . import presenters
from .commands import (
GetBotId,
GetTargeter,
parse_daily_time,
parse_interval,
schedule_cmd,
)
@schedule_cmd.handle()
async def _handle_time_options_mutex(arp: Arparma):
time_options = ["cron", "interval", "date", "daily"]
provided_options = [opt for opt in time_options if arp.query(opt) is not None]
if len(provided_options) > 1:
await schedule_cmd.finish(
f"时间选项 --{', --'.join(provided_options)} 不能同时使用,请只选择一个。"
)
@schedule_cmd.assign("查看")
async def handle_view(
bot: Bot,
event: Event,
target_group_id: Match[str] = AlconnaMatch("target_group_id"),
all_groups: Query[bool] = Query("查看.all"),
plugin_name: Match[str] = AlconnaMatch("plugin_name"),
page: Match[int] = AlconnaMatch("page"),
):
is_superuser = await SUPERUSER(bot, event)
title = ""
gid_filter = None
current_group_id = getattr(event, "group_id", None)
if not (all_groups.available or target_group_id.available) and not current_group_id:
await schedule_cmd.finish("私聊中查看任务必须使用 -g <群号> 或 -all 选项。")
if all_groups.available:
if not is_superuser:
await schedule_cmd.finish("需要超级用户权限才能查看所有群组的定时任务。")
title = "所有群组的定时任务"
elif target_group_id.available:
if not is_superuser:
await schedule_cmd.finish("需要超级用户权限才能查看指定群组的定时任务。")
gid_filter = target_group_id.result
title = f"{gid_filter} 的定时任务"
else:
gid_filter = str(current_group_id)
title = "本群的定时任务"
p_name_filter = plugin_name.result if plugin_name.available else None
schedules = await scheduler_manager.get_schedules(
plugin_name=p_name_filter, group_id=gid_filter
)
if p_name_filter:
title += f" [插件: {p_name_filter}]"
if not schedules:
await schedule_cmd.finish("没有找到任何相关的定时任务。")
img = await presenters.format_schedule_list_as_image(
schedules=schedules, title=title, current_page=page.result
)
await MessageUtils.build_message(img).send(reply_to=True)
@schedule_cmd.assign("设置")
async def handle_set(
event: Event,
plugin_name: Match[str] = AlconnaMatch("plugin_name"),
cron_expr: Match[str] = AlconnaMatch("cron_expr"),
interval_expr: Match[str] = AlconnaMatch("interval_expr"),
date_expr: Match[str] = AlconnaMatch("date_expr"),
daily_expr: Match[str] = AlconnaMatch("daily_expr"),
group_id: Match[str] = AlconnaMatch("group_id"),
kwargs_str: Match[str] = AlconnaMatch("kwargs_str"),
all_enabled: Query[bool] = Query("设置.all"),
bot_id_to_operate: str = Depends(GetBotId),
):
if not plugin_name.available:
await schedule_cmd.finish("设置任务时必须提供插件名称。")
has_time_option = any(
[
cron_expr.available,
interval_expr.available,
date_expr.available,
daily_expr.available,
]
)
if not has_time_option:
await schedule_cmd.finish(
"必须提供一种时间选项: --cron, --interval, --date, 或 --daily。"
)
p_name = plugin_name.result
if p_name not in scheduler_manager.get_registered_plugins():
await schedule_cmd.finish(
f"插件 '{p_name}' 没有注册可用的定时任务。\n"
f"可用插件: {list(scheduler_manager.get_registered_plugins())}"
)
trigger_type, trigger_config = "", {}
try:
if cron_expr.available:
trigger_type, trigger_config = (
"cron",
dict(
zip(
["minute", "hour", "day", "month", "day_of_week"],
cron_expr.result.split(),
)
),
)
elif interval_expr.available:
trigger_type, trigger_config = (
"interval",
parse_interval(interval_expr.result),
)
elif date_expr.available:
trigger_type, trigger_config = (
"date",
{"run_date": datetime.fromisoformat(date_expr.result)},
)
elif daily_expr.available:
trigger_type, trigger_config = "cron", parse_daily_time(daily_expr.result)
else:
await schedule_cmd.finish(
"必须提供一种时间选项: --cron, --interval, --date, 或 --daily。"
)
except ValueError as e:
await schedule_cmd.finish(f"时间参数解析错误: {e}")
job_kwargs = {}
if kwargs_str.available:
task_meta = scheduler_manager._registered_tasks[p_name]
params_model = task_meta.get("model")
if not (
params_model
and isinstance(params_model, type)
and issubclass(params_model, BaseModel)
):
await schedule_cmd.finish(f"插件 '{p_name}' 不支持或配置了无效的参数模型。")
try:
raw_kwargs = dict(
item.strip().split("=", 1) for item in kwargs_str.result.split(",")
)
model_validate = getattr(params_model, "model_validate", None)
if not model_validate:
await schedule_cmd.finish(f"插件 '{p_name}' 的参数模型不支持验证")
validated_model = model_validate(raw_kwargs)
model_dump = getattr(validated_model, "model_dump", None)
if not model_dump:
await schedule_cmd.finish(f"插件 '{p_name}' 的参数模型不支持导出")
job_kwargs = model_dump()
except ValidationError as e:
errors = [f" - {err['loc'][0]}: {err['msg']}" for err in e.errors()]
await schedule_cmd.finish(
f"插件 '{p_name}' 的任务参数验证失败:\n" + "\n".join(errors)
)
except Exception as e:
await schedule_cmd.finish(
f"参数格式错误,请使用 'key=value,key2=value2' 格式。错误: {e}"
)
gid_str = group_id.result if group_id.available else None
target_group_id = (
scheduler_manager.ALL_GROUPS
if (gid_str and gid_str.lower() == "all") or all_enabled.available
else gid_str or getattr(event, "group_id", None)
)
if not target_group_id:
await schedule_cmd.finish(
"私聊中设置定时任务时,必须使用 -g <群号> 或 --all 选项指定目标。"
)
schedule = await scheduler_manager.add_schedule(
p_name,
str(target_group_id),
trigger_type,
trigger_config,
job_kwargs,
bot_id=bot_id_to_operate,
)
target_desc = (
f"所有群组 (Bot: {bot_id_to_operate})"
if target_group_id == scheduler_manager.ALL_GROUPS
else f"群组 {target_group_id}"
)
if schedule:
await schedule_cmd.finish(
f"为 [{target_desc}] 已成功设置插件 '{p_name}' 的定时任务 "
f"(ID: {schedule.id})。"
)
else:
await schedule_cmd.finish(f"为 [{target_desc}] 设置任务失败。")
@schedule_cmd.assign("删除")
async def handle_delete(targeter: ScheduleTargeter = GetTargeter("删除")):
schedules_to_remove: list[ScheduleInfo] = await targeter._get_schedules()
if not schedules_to_remove:
await schedule_cmd.finish("没有找到可删除的任务。")
count, _ = await targeter.remove()
if count > 0 and schedules_to_remove:
if len(schedules_to_remove) == 1:
message = presenters.format_remove_success(schedules_to_remove[0])
else:
target_desc = targeter._generate_target_description()
message = f"✅ 成功移除了{target_desc} {count} 个任务。"
else:
message = "没有任务被移除。"
await schedule_cmd.finish(message)
@schedule_cmd.assign("暂停")
async def handle_pause(targeter: ScheduleTargeter = GetTargeter("暂停")):
schedules_to_pause: list[ScheduleInfo] = await targeter._get_schedules()
if not schedules_to_pause:
await schedule_cmd.finish("没有找到可暂停的任务。")
count, _ = await targeter.pause()
if count > 0 and schedules_to_pause:
if len(schedules_to_pause) == 1:
message = presenters.format_pause_success(schedules_to_pause[0])
else:
target_desc = targeter._generate_target_description()
message = f"✅ 成功暂停了{target_desc} {count} 个任务。"
else:
message = "没有任务被暂停。"
await schedule_cmd.finish(message)
@schedule_cmd.assign("恢复")
async def handle_resume(targeter: ScheduleTargeter = GetTargeter("恢复")):
schedules_to_resume: list[ScheduleInfo] = await targeter._get_schedules()
if not schedules_to_resume:
await schedule_cmd.finish("没有找到可恢复的任务。")
count, _ = await targeter.resume()
if count > 0 and schedules_to_resume:
if len(schedules_to_resume) == 1:
message = presenters.format_resume_success(schedules_to_resume[0])
else:
target_desc = targeter._generate_target_description()
message = f"✅ 成功恢复了{target_desc} {count} 个任务。"
else:
message = "没有任务被恢复。"
await schedule_cmd.finish(message)
@schedule_cmd.assign("执行")
async def handle_trigger(schedule_id: Match[int] = AlconnaMatch("schedule_id")):
from zhenxun.services.scheduler.repository import ScheduleRepository
schedule_info = await ScheduleRepository.get_by_id(schedule_id.result)
if not schedule_info:
await schedule_cmd.finish(f"未找到 ID 为 {schedule_id.result} 的任务。")
success, message = await scheduler_manager.trigger_now(schedule_id.result)
if success:
final_message = presenters.format_trigger_success(schedule_info)
else:
final_message = f"❌ 手动触发失败: {message}"
await schedule_cmd.finish(final_message)
@schedule_cmd.assign("更新")
async def handle_update(
schedule_id: Match[int] = AlconnaMatch("schedule_id"),
cron_expr: Match[str] = AlconnaMatch("cron_expr"),
interval_expr: Match[str] = AlconnaMatch("interval_expr"),
date_expr: Match[str] = AlconnaMatch("date_expr"),
daily_expr: Match[str] = AlconnaMatch("daily_expr"),
kwargs_str: Match[str] = AlconnaMatch("kwargs_str"),
):
if not any(
[
cron_expr.available,
interval_expr.available,
date_expr.available,
daily_expr.available,
kwargs_str.available,
]
):
await schedule_cmd.finish(
"请提供需要更新的时间 (--cron/--interval/--date/--daily) 或参数 (--kwargs)"
)
trigger_type, trigger_config, job_kwargs = None, None, None
try:
if cron_expr.available:
trigger_type, trigger_config = (
"cron",
dict(
zip(
["minute", "hour", "day", "month", "day_of_week"],
cron_expr.result.split(),
)
),
)
elif interval_expr.available:
trigger_type, trigger_config = (
"interval",
parse_interval(interval_expr.result),
)
elif date_expr.available:
trigger_type, trigger_config = (
"date",
{"run_date": datetime.fromisoformat(date_expr.result)},
)
elif daily_expr.available:
trigger_type, trigger_config = "cron", parse_daily_time(daily_expr.result)
except ValueError as e:
await schedule_cmd.finish(f"时间参数解析错误: {e}")
if kwargs_str.available:
job_kwargs = dict(
item.strip().split("=", 1) for item in kwargs_str.result.split(",")
)
success, message = await scheduler_manager.update_schedule(
schedule_id.result, trigger_type, trigger_config, job_kwargs
)
if success:
from zhenxun.services.scheduler.repository import ScheduleRepository
updated_schedule = await ScheduleRepository.get_by_id(schedule_id.result)
if updated_schedule:
final_message = presenters.format_update_success(updated_schedule)
else:
final_message = "✅ 更新成功,但无法获取更新后的任务详情。"
else:
final_message = f"❌ 更新失败: {message}"
await schedule_cmd.finish(final_message)
@schedule_cmd.assign("插件列表")
async def handle_plugins_list():
message = await presenters.format_plugins_list()
await schedule_cmd.finish(message)
@schedule_cmd.assign("状态")
async def handle_status(schedule_id: Match[int] = AlconnaMatch("schedule_id")):
status = await scheduler_manager.get_schedule_status(schedule_id.result)
if not status:
await schedule_cmd.finish(f"未找到ID为 {schedule_id.result} 的定时任务。")
message = presenters.format_single_status_message(status)
await schedule_cmd.finish(message)

View File

@ -0,0 +1,274 @@
import asyncio
from zhenxun.models.schedule_info import ScheduleInfo
from zhenxun.services.scheduler import scheduler_manager
from zhenxun.utils._image_template import ImageTemplate, RowStyle
def _get_type_name(annotation) -> str:
"""获取类型注解的名称"""
if hasattr(annotation, "__name__"):
return annotation.__name__
elif hasattr(annotation, "_name"):
return annotation._name
else:
return str(annotation)
def _format_trigger(schedule: dict) -> str:
"""格式化触发器信息为可读字符串"""
trigger_type = schedule.get("trigger_type")
config = schedule.get("trigger_config")
if not isinstance(config, dict):
return f"配置错误: {config}"
if trigger_type == "cron":
hour = config.get("hour", "??")
minute = config.get("minute", "??")
try:
hour_int = int(hour)
minute_int = int(minute)
return f"每天 {hour_int:02d}:{minute_int:02d}"
except (ValueError, TypeError):
return f"每天 {hour}:{minute}"
elif trigger_type == "interval":
units = {
"weeks": "",
"days": "",
"hours": "小时",
"minutes": "分钟",
"seconds": "",
}
for unit, unit_name in units.items():
if value := config.get(unit):
return f"{value} {unit_name}"
return "未知间隔"
elif trigger_type == "date":
run_date = config.get("run_date", "N/A")
return f"特定时间 {run_date}"
else:
return f"未知触发器类型: {trigger_type}"
def _format_trigger_for_card(schedule_info: ScheduleInfo | dict) -> str:
"""为信息卡片格式化触发器规则"""
trigger_type = (
schedule_info.get("trigger_type")
if isinstance(schedule_info, dict)
else schedule_info.trigger_type
)
config = (
schedule_info.get("trigger_config")
if isinstance(schedule_info, dict)
else schedule_info.trigger_config
)
if not isinstance(config, dict):
return f"配置错误: {config}"
if trigger_type == "cron":
hour = config.get("hour", "??")
minute = config.get("minute", "??")
try:
hour_int = int(hour)
minute_int = int(minute)
return f"每天 {hour_int:02d}:{minute_int:02d}"
except (ValueError, TypeError):
return f"每天 {hour}:{minute}"
elif trigger_type == "interval":
units = {
"weeks": "",
"days": "",
"hours": "小时",
"minutes": "分钟",
"seconds": "",
}
for unit, unit_name in units.items():
if value := config.get(unit):
return f"{value} {unit_name}"
return "未知间隔"
elif trigger_type == "date":
run_date = config.get("run_date", "N/A")
return f"特定时间 {run_date}"
else:
return f"未知规则: {trigger_type}"
def _format_operation_result_card(
title: str, schedule_info: ScheduleInfo, extra_info: list[str] | None = None
) -> str:
"""
生成一个标准的操作结果信息卡片
参数:
title: 卡片的标题 (例如 "✅ 成功暂停定时任务!")
schedule_info: 相关的 ScheduleInfo 对象
extra_info: (可选) 额外的补充信息行
"""
target_desc = (
f"群组 {schedule_info.group_id}"
if schedule_info.group_id
and schedule_info.group_id != scheduler_manager.ALL_GROUPS
else "所有群组"
if schedule_info.group_id == scheduler_manager.ALL_GROUPS
else "全局"
)
info_lines = [
title,
f"✓ 任务 ID: {schedule_info.id}",
f"🖋 插件: {schedule_info.plugin_name}",
f"🎯 目标: {target_desc}",
f"⏰ 时间: {_format_trigger_for_card(schedule_info)}",
]
if extra_info:
info_lines.extend(extra_info)
return "\n".join(info_lines)
def format_pause_success(schedule_info: ScheduleInfo) -> str:
"""格式化暂停成功的消息"""
return _format_operation_result_card("✅ 成功暂停定时任务!", schedule_info)
def format_resume_success(schedule_info: ScheduleInfo) -> str:
"""格式化恢复成功的消息"""
return _format_operation_result_card("▶️ 成功恢复定时任务!", schedule_info)
def format_remove_success(schedule_info: ScheduleInfo) -> str:
"""格式化删除成功的消息"""
return _format_operation_result_card("❌ 成功删除定时任务!", schedule_info)
def format_trigger_success(schedule_info: ScheduleInfo) -> str:
"""格式化手动触发成功的消息"""
return _format_operation_result_card("🚀 成功手动触发定时任务!", schedule_info)
def format_update_success(schedule_info: ScheduleInfo) -> str:
"""格式化更新成功的消息"""
return _format_operation_result_card("🔄️ 成功更新定时任务配置!", schedule_info)
def _status_row_style(column: str, text: str) -> RowStyle:
"""为状态列设置颜色"""
style = RowStyle()
if column == "状态":
if text == "启用":
style.font_color = "#67C23A"
elif text == "暂停":
style.font_color = "#F56C6C"
elif text == "运行中":
style.font_color = "#409EFF"
return style
def _format_params(schedule_status: dict) -> str:
"""将任务参数格式化为人类可读的字符串"""
if kwargs := schedule_status.get("job_kwargs"):
return " | ".join(f"{k}: {v}" for k, v in kwargs.items())
return "-"
async def format_schedule_list_as_image(
schedules: list[ScheduleInfo], title: str, current_page: int
):
"""将任务列表格式化为图片"""
page_size = 15
total_items = len(schedules)
total_pages = (total_items + page_size - 1) // page_size
start_index = (current_page - 1) * page_size
end_index = start_index + page_size
paginated_schedules = schedules[start_index:end_index]
if not paginated_schedules:
return "这一页没有内容了哦~"
status_tasks = [
scheduler_manager.get_schedule_status(s.id) for s in paginated_schedules
]
all_statuses = await asyncio.gather(*status_tasks)
def get_status_text(status_value):
if isinstance(status_value, bool):
return "启用" if status_value else "暂停"
return str(status_value)
data_list = [
[
s["id"],
s["plugin_name"],
s.get("bot_id") or "N/A",
s["group_id"] or "全局",
s["next_run_time"],
_format_trigger(s),
_format_params(s),
get_status_text(s["is_enabled"]),
]
for s in all_statuses
if s
]
if not data_list:
return "没有找到任何相关的定时任务。"
return await ImageTemplate.table_page(
head_text=title,
tip_text=f"{current_page}/{total_pages} 页,共 {total_items} 条任务",
column_name=["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"],
data_list=data_list,
column_space=20,
text_style=_status_row_style,
)
def format_single_status_message(status: dict) -> str:
"""格式化单个任务状态为文本消息"""
info_lines = [
f"📋 定时任务详细信息 (ID: {status['id']})",
"--------------------",
f"▫️ 插件: {status['plugin_name']}",
f"▫️ Bot ID: {status.get('bot_id') or '默认'}",
f"▫️ 目标: {status['group_id'] or '全局'}",
f"▫️ 状态: {'✔️ 已启用' if status['is_enabled'] else '⏸️ 已暂停'}",
f"▫️ 下次运行: {status['next_run_time']}",
f"▫️ 触发规则: {_format_trigger(status)}",
f"▫️ 任务参数: {_format_params(status)}",
]
return "\n".join(info_lines)
async def format_plugins_list() -> str:
"""格式化可用插件列表为文本消息"""
from pydantic import BaseModel
registered_plugins = scheduler_manager.get_registered_plugins()
if not registered_plugins:
return "当前没有已注册的定时任务插件。"
message_parts = ["📋 已注册的定时任务插件:"]
for i, plugin_name in enumerate(registered_plugins, 1):
task_meta = scheduler_manager._registered_tasks[plugin_name]
params_model = task_meta.get("model")
param_info_str = "无参数"
if (
params_model
and isinstance(params_model, type)
and issubclass(params_model, BaseModel)
):
model_fields = getattr(params_model, "model_fields", None)
if model_fields:
param_info_str = "参数: " + ", ".join(
f"{field_name}({_get_type_name(field_info.annotation)})"
for field_name, field_info in model_fields.items()
)
elif params_model:
param_info_str = "⚠️ 参数模型配置错误"
message_parts.append(f"{i}. {plugin_name} - {param_info_str}")
return "\n".join(message_parts)

View File

@ -1,30 +0,0 @@
from zhenxun.models.group_console import GroupConsole
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
@PriorityLifecycle.on_startup(priority=5)
async def _():
"""开启/禁用插件格式修改"""
_, is_create = await GroupConsole.get_or_create(group_id=133133133)
"""标记"""
if is_create:
data_list = []
for group in await GroupConsole.all():
if group.block_plugin:
if modules := group.block_plugin.split(","):
block_plugin = "".join(
(f"{module}," if module.startswith("<") else f"<{module},")
for module in modules
if module.strip()
)
group.block_plugin = block_plugin.replace("<,", "")
if group.block_task:
if modules := group.block_task.split(","):
block_task = "".join(
(f"{module}," if module.startswith("<") else f"<{module},")
for module in modules
if module.strip()
)
group.block_task = block_task.replace("<,", "")
data_list.append(group)
await GroupConsole.bulk_update(data_list, ["block_plugin", "block_task"], 10)

View File

@ -344,6 +344,16 @@ class ShopManage:
if goods_name.isdigit(): if goods_name.isdigit():
try: try:
user = await UserConsole.get_user(user_id=session.user.id) user = await UserConsole.get_user(user_id=session.user.id)
goods_list = await GoodsInfo.filter(uuid__in=user.props.keys()).all()
goods_by_uuid = {item.uuid: item for item in goods_list}
props_str = str(user.props)
user.props = {
uuid: count
for uuid, count in user.props.items()
if count > 0 and goods_by_uuid.get(uuid)
}
if props_str != str(user.props):
await user.save(update_fields=["props"])
uuid = list(user.props.keys())[int(goods_name)] uuid = list(user.props.keys())[int(goods_name)]
goods_info = await GoodsInfo.get_or_none(uuid=uuid) goods_info = await GoodsInfo.get_or_none(uuid=uuid)
except IndexError: except IndexError:
@ -501,11 +511,14 @@ class ShopManage:
goods_list = await GoodsInfo.filter(uuid__in=user.props.keys()).all() goods_list = await GoodsInfo.filter(uuid__in=user.props.keys()).all()
goods_by_uuid = {item.uuid: item for item in goods_list} goods_by_uuid = {item.uuid: item for item in goods_list}
props_str = str(user.props)
user.props = { user.props = {
uuid: count uuid: count
for uuid, count in user.props.items() for uuid, count in user.props.items()
if count > 0 and goods_by_uuid.get(uuid) if count > 0 and goods_by_uuid.get(uuid)
} }
if props_str != str(user.props):
await user.save(update_fields=["props"])
table_rows = [] table_rows = []
for i, prop_uuid in enumerate(user.props): for i, prop_uuid in enumerate(user.props):

View File

@ -29,9 +29,9 @@ from .config import (
lik2relation, lik2relation,
) )
assert ( assert len(level2attitude) == len(lik2level) == len(lik2relation), (
len(level2attitude) == len(lik2level) == len(lik2relation) "好感度态度、等级、关系长度不匹配!"
), "好感度态度、等级、关系长度不匹配!" )
AVA_URL = "http://q1.qlogo.cn/g?b=qq&nk={}&s=160" AVA_URL = "http://q1.qlogo.cn/g?b=qq&nk={}&s=160"

View File

@ -1,5 +1,3 @@
from datetime import datetime, timedelta
from tortoise.functions import Count from tortoise.functions import Count
from zhenxun.models.group_console import GroupConsole from zhenxun.models.group_console import GroupConsole
@ -10,6 +8,7 @@ from zhenxun.utils.echart_utils import ChartUtils
from zhenxun.utils.echart_utils.models import Barh from zhenxun.utils.echart_utils.models import Barh
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import PluginType
from zhenxun.utils.image_utils import BuildImage from zhenxun.utils.image_utils import BuildImage
from zhenxun.utils.time_utils import TimeUtils
class StatisticsManage: class StatisticsManage:
@ -45,9 +44,7 @@ class StatisticsManage:
title = f"{user.user_name if user else user_id} {day_type}功能调用统计" title = f"{user.user_name if user else user_id} {day_type}功能调用统计"
elif group_id: elif group_id:
"""查群组""" """查群组"""
group = await GroupConsole.get_or_none( group = await GroupConsole.get_group(group_id=group_id)
group_id=group_id, channel_id__isnull=True
)
title = f"{group.group_name if group else group_id} {day_type}功能调用统计" title = f"{group.group_name if group else group_id} {day_type}功能调用统计"
else: else:
title = "功能调用统计" title = "功能调用统计"
@ -68,8 +65,7 @@ class StatisticsManage:
if plugin_name: if plugin_name:
query = query.filter(plugin_name=plugin_name) query = query.filter(plugin_name=plugin_name)
if day: if day:
time = datetime.now() - timedelta(days=day) query = query.filter(create_time__gte=TimeUtils.get_day_start())
query = query.filter(create_time__gte=time)
data_list = ( data_list = (
await query.annotate(count=Count("id")) await query.annotate(count=Count("id"))
.group_by("plugin_name") .group_by("plugin_name")
@ -89,8 +85,7 @@ class StatisticsManage:
if group_id: if group_id:
query = query.filter(group_id=group_id) query = query.filter(group_id=group_id)
if day: if day:
time = datetime.now() - timedelta(days=day) query = query.filter(create_time__gte=TimeUtils.get_day_start())
query = query.filter(create_time__gte=time)
data_list = ( data_list = (
await query.annotate(count=Count("id")) await query.annotate(count=Count("id"))
.group_by("plugin_name") .group_by("plugin_name")
@ -106,8 +101,7 @@ class StatisticsManage:
async def get_group_statistics(cls, group_id: str, day: int | None, title: str): async def get_group_statistics(cls, group_id: str, day: int | None, title: str):
query = Statistics.filter(group_id=group_id) query = Statistics.filter(group_id=group_id)
if day: if day:
time = datetime.now() - timedelta(days=day) query = query.filter(create_time__gte=TimeUtils.get_day_start())
query = query.filter(create_time__gte=time)
data_list = ( data_list = (
await query.annotate(count=Count("id")) await query.annotate(count=Count("id"))
.group_by("plugin_name") .group_by("plugin_name")

View File

@ -28,7 +28,7 @@ from nonebot_plugin_alconna.uniseg.segment import (
) )
from nonebot_plugin_session import EventSession from nonebot_plugin_session import EventSession
from zhenxun.configs.utils import PluginExtraData, RegisterConfig, Task from zhenxun.configs.utils import PluginExtraData, Task
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils from zhenxun.utils.message import MessageUtils
@ -73,16 +73,6 @@ __plugin_meta__ = PluginMetadata(
author="HibiKier", author="HibiKier",
version="1.2", version="1.2",
plugin_type=PluginType.SUPERUSER, plugin_type=PluginType.SUPERUSER,
configs=[
RegisterConfig(
module="_task",
key="DEFAULT_BROADCAST",
value=True,
help="被动 广播 进群默认开关状态",
default_value=True,
type=bool,
)
],
tasks=[Task(module="broadcast", name="广播")], tasks=[Task(module="broadcast", name="广播")],
).to_dict(), ).to_dict(),
) )

View File

@ -163,7 +163,7 @@ async def _(session: EventSession, arparma: Arparma, state: T_State, level: int)
@_matcher.assign("super-handle", parameterless=[CheckGroupId()]) @_matcher.assign("super-handle", parameterless=[CheckGroupId()])
async def _(session: EventSession, arparma: Arparma, state: T_State): async def _(session: EventSession, arparma: Arparma, state: T_State):
gid = state["group_id"] gid = state["group_id"]
group = await GroupConsole.get_or_none(group_id=gid) group = await GroupConsole.get_group(group_id=gid)
if not group: if not group:
await MessageUtils.build_message("群组信息不存在, 请更新群组信息...").finish() await MessageUtils.build_message("群组信息不存在, 请更新群组信息...").finish()
s = "删除" if arparma.find("delete") else "添加" s = "删除" if arparma.find("delete") else "添加"
@ -177,7 +177,9 @@ async def _(session: EventSession, arparma: Arparma, state: T_State):
async def _(session: EventSession, arparma: Arparma, state: T_State): async def _(session: EventSession, arparma: Arparma, state: T_State):
gid = state["group_id"] gid = state["group_id"]
await GroupConsole.update_or_create( await GroupConsole.update_or_create(
group_id=gid, defaults={"group_flag": 0 if arparma.find("delete") else 1} group_id=gid,
channel_id__isnull=True,
defaults={"group_flag": 0 if arparma.find("delete") else 1},
) )
s = "删除" if arparma.find("delete") else "添加" s = "删除" if arparma.find("delete") else "添加"
await MessageUtils.build_message(f"{s}群认证成功!").send(reply_to=True) await MessageUtils.build_message(f"{s}群认证成功!").send(reply_to=True)

View File

@ -163,15 +163,20 @@ async def _(
req = await FgRequest.ignore(handle_id) req = await FgRequest.ignore(handle_id)
except NotFoundError: except NotFoundError:
await MessageUtils.build_message("未发现此id的请求...").finish(reply_to=True) await MessageUtils.build_message("未发现此id的请求...").finish(reply_to=True)
except Exception: except Exception as e:
await MessageUtils.build_message("其他错误, 可能flag已失效...").finish( logger.error(f"处理请求失败 ID: {handle_id}", session=session, e=e)
await MessageUtils.build_message(f"其他错误, 可能flag已失效...: {e}").finish(
reply_to=True reply_to=True
) )
logger.info( logger.info(
f"处理请求 Id: {req.id if req else ''}", arparma.header_result, session=session f"处理请求 Id: {req.id if req else ''}", arparma.header_result, session=session
) )
await MessageUtils.build_message("成功处理请求!").send(reply_to=True) await MessageUtils.build_message("成功处理请求!").send(reply_to=True)
if req and handle_type == RequestHandleType.APPROVE: if (
req
and req.request_type == RequestType.GROUP
and handle_type == RequestHandleType.APPROVE
):
await bot.send_private_msg( await bot.send_private_msg(
user_id=req.user_id, user_id=req.user_id,
message=f"管理员已同意此次群组邀请,请不要让{BotConfig.self_nickname}受委屈哦(狠狠监控)" message=f"管理员已同意此次群组邀请,请不要让{BotConfig.self_nickname}受委屈哦(狠狠监控)"

View File

@ -51,7 +51,7 @@ async def build_html_help():
} }
}, },
pages={ pages={
"viewport": {"width": 1024, "height": 1024}, "viewport": {"width": 824, "height": 10},
"base_url": f"file://{TEMPLATE_PATH}", "base_url": f"file://{TEMPLATE_PATH}",
}, },
wait=2, wait=2,

View File

@ -119,7 +119,7 @@ class ApiDataSource:
(await PlatformUtils.get_friend_list(select_bot.bot))[0] (await PlatformUtils.get_friend_list(select_bot.bot))[0]
) )
except Exception as e: except Exception as e:
logger.warning("获取bot好友/群组信息失败...", "WebUi", e=e) logger.warning("获取bot好友/群组数量失败...", "WebUi", e=e)
select_bot.group_count = 0 select_bot.group_count = 0
select_bot.friend_count = 0 select_bot.friend_count = 0
select_bot.status = await BotConsole.get_bot_status(select_bot.self_id) select_bot.status = await BotConsole.get_bot_status(select_bot.self_id)

View File

@ -250,7 +250,7 @@ class ApiDataSource:
返回: 返回:
GroupDetail | None: 群组详情数据 GroupDetail | None: 群组详情数据
""" """
group = await GroupConsole.get_or_none(group_id=group_id) group = await GroupConsole.get_group(group_id=group_id)
if not group: if not group:
return None return None
like_plugin = await cls.__get_group_detail_like_plugin(group_id) like_plugin = await cls.__get_group_detail_like_plugin(group_id)

View File

@ -4,6 +4,7 @@ from fastapi.responses import JSONResponse
from zhenxun.models.plugin_info import PluginInfo as DbPluginInfo from zhenxun.models.plugin_info import PluginInfo as DbPluginInfo
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.enum import BlockType, PluginType from zhenxun.utils.enum import BlockType, PluginType
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
from ....base_model import Result from ....base_model import Result
from ....utils import authentication, clear_help_image from ....utils import authentication, clear_help_image
@ -11,6 +12,7 @@ from .data_source import ApiDataSource
from .model import ( from .model import (
BatchUpdatePlugins, BatchUpdatePlugins,
BatchUpdateResult, BatchUpdateResult,
InstallDependenciesPayload,
PluginCount, PluginCount,
PluginDetail, PluginDetail,
PluginInfo, PluginInfo,
@ -162,9 +164,9 @@ async def _(module: str) -> Result[PluginDetail]:
dependencies=[authentication()], dependencies=[authentication()],
response_model=Result[BatchUpdateResult], response_model=Result[BatchUpdateResult],
response_class=JSONResponse, response_class=JSONResponse,
summary="批量更新插件配置", description="批量更新插件配置",
) )
async def batch_update_plugin_config_api( async def _(
params: BatchUpdatePlugins, params: BatchUpdatePlugins,
) -> Result[BatchUpdateResult]: ) -> Result[BatchUpdateResult]:
"""批量更新插件配置,如开关、类型等""" """批量更新插件配置,如开关、类型等"""
@ -187,9 +189,9 @@ async def batch_update_plugin_config_api(
"/menu_type/rename", "/menu_type/rename",
dependencies=[authentication()], dependencies=[authentication()],
response_model=Result, response_model=Result,
summary="重命名菜单类型", description="重命名菜单类型",
) )
async def rename_menu_type_api(payload: RenameMenuTypePayload) -> Result: async def _(payload: RenameMenuTypePayload) -> Result[str]:
try: try:
result = await ApiDataSource.rename_menu_type( result = await ApiDataSource.rename_menu_type(
old_name=payload.old_name, new_name=payload.new_name old_name=payload.old_name, new_name=payload.new_name
@ -213,3 +215,24 @@ async def rename_menu_type_api(payload: RenameMenuTypePayload) -> Result:
except Exception as e: except Exception as e:
logger.error(f"{router.prefix}/menu_type/rename 调用错误", "WebUi", e=e) logger.error(f"{router.prefix}/menu_type/rename 调用错误", "WebUi", e=e)
return Result.fail(info=f"发生未知错误: {type(e).__name__}") return Result.fail(info=f"发生未知错误: {type(e).__name__}")
@router.post(
"/install_dependencies",
dependencies=[authentication()],
response_model=Result,
response_class=JSONResponse,
description="安装/卸载依赖",
)
async def _(payload: InstallDependenciesPayload) -> Result:
try:
if not payload.dependencies:
return Result.fail("依赖列表不能为空")
if payload.handle_type == "install":
result = VirtualEnvPackageManager.install(payload.dependencies)
else:
result = VirtualEnvPackageManager.uninstall(payload.dependencies)
return Result.ok(result)
except Exception as e:
logger.error(f"{router.prefix}/install_dependencies 调用错误", "WebUi", e=e)
return Result.fail(f"发生了一点错误捏 {type(e)}: {e}")

View File

@ -167,7 +167,7 @@ class ApiDataSource:
) )
return { return {
"success": len(errors) == 0, "success": not errors,
"updated_count": updated_count + bulk_updated_count, "updated_count": updated_count + bulk_updated_count,
"errors": errors, "errors": errors,
} }
@ -184,19 +184,24 @@ class ApiDataSource:
config: ConfigGroup config: ConfigGroup
返回: 返回:
lPluginConfig: 配置数据 PluginConfig: 配置数据
""" """
type_str = "" type_str = ""
type_inner = None type_inner = None
if r := re.search(r"<class '(.*)'>", str(config.configs[cfg].type)): ct = str(config.configs[cfg].type)
if r := re.search(r"<class '(.*)'>", ct):
type_str = r[1] type_str = r[1]
elif r := re.search(r"typing\.(.*)\[(.*)\]", str(config.configs[cfg].type)): elif (r := re.search(r"typing\.(.*)\[(.*)\]", ct)) or (
r := re.search(r"(.*)\[(.*)\]", ct)
):
type_str = r[1] type_str = r[1]
if type_str: if type_str:
type_str = type_str.lower() type_str = type_str.lower()
type_inner = r[2] type_inner = r[2]
if type_inner: if type_inner:
type_inner = [x.strip() for x in type_inner.split(",")] type_inner = [x.strip() for x in type_inner.split(",")]
else:
type_str = ct
return PluginConfig( return PluginConfig(
module=module, module=module,
key=cfg, key=cfg,

View File

@ -1,4 +1,4 @@
from typing import Any from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -162,3 +162,15 @@ class BatchUpdateResult(BaseModel):
default_factory=list, description="错误信息列表" default_factory=list, description="错误信息列表"
) )
"""错误信息列表""" """错误信息列表"""
class InstallDependenciesPayload(BaseModel):
"""
安装依赖
"""
handle_type: Literal["install", "uninstall"] = Field(..., description="处理类型")
"""处理类型"""
dependencies: list[str] = Field(..., description="依赖列表")
"""依赖列表"""

View File

@ -45,6 +45,7 @@ async def _(path: str | None = None) -> Result[list[DirFile]]:
mtime=file_path.stat().st_mtime, mtime=file_path.stat().st_mtime,
) )
) )
data_list.sort(key=lambda f: f.name)
return Result.ok(data_list) return Result.ok(data_list)
except Exception as e: except Exception as e:
return Result.fail(f"获取文件列表失败: {e!s}") return Result.fail(f"获取文件列表失败: {e!s}")

View File

@ -13,8 +13,8 @@ class BotSetting(BaseModel):
"""回复时NICKNAME""" """回复时NICKNAME"""
system_proxy: str | None = None system_proxy: str | None = None
"""系统代理""" """系统代理"""
db_url: str = "" db_url: str = "sqlite:data/zhenxun.db"
"""数据库链接""" """数据库链接, 默认值为sqlite:data/zhenxun.db"""
platform_superusers: dict[str, list[str]] = Field(default_factory=dict) platform_superusers: dict[str, list[str]] = Field(default_factory=dict)
"""平台超级用户""" """平台超级用户"""
qbot_id_data: dict[str, str] = Field(default_factory=dict) qbot_id_data: dict[str, str] = Field(default_factory=dict)

View File

@ -1,16 +1,21 @@
from collections.abc import Callable from collections.abc import Callable
import copy import copy
from pathlib import Path from pathlib import Path
from typing import Any, TypeVar, get_args, get_origin from typing import Any, TypeVar
import cattrs import cattrs
from nonebot.compat import model_dump from pydantic import BaseModel, Field
from pydantic import VERSION, BaseModel, Field
from ruamel.yaml import YAML from ruamel.yaml import YAML
from ruamel.yaml.scanner import ScannerError from ruamel.yaml.scanner import ScannerError
from zhenxun.configs.path_config import DATA_PATH from zhenxun.configs.path_config import DATA_PATH
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import (
_dump_pydantic_obj,
_is_pydantic_type,
model_dump,
parse_as,
)
from .models import ( from .models import (
AICallableParam, AICallableParam,
@ -39,46 +44,6 @@ class NoSuchConfig(Exception):
pass pass
def _dump_pydantic_obj(obj: Any) -> Any:
"""
递归地将一个对象内部的 Pydantic BaseModel 实例转换为字典
支持单个实例实例列表实例字典等情况
"""
if isinstance(obj, BaseModel):
return model_dump(obj)
if isinstance(obj, list):
return [_dump_pydantic_obj(item) for item in obj]
if isinstance(obj, dict):
return {key: _dump_pydantic_obj(value) for key, value in obj.items()}
return obj
def _is_pydantic_type(t: Any) -> bool:
"""
递归检查一个类型注解是否与 Pydantic BaseModel 相关
"""
if t is None:
return False
origin = get_origin(t)
if origin:
return any(_is_pydantic_type(arg) for arg in get_args(t))
return isinstance(t, type) and issubclass(t, BaseModel)
def parse_as(type_: type[T], obj: Any) -> T:
"""
一个兼容 Pydantic V1 parse_obj_as 和V2的TypeAdapter.validate_python 的辅助函数
"""
if VERSION.startswith("1"):
from pydantic import parse_obj_as
return parse_obj_as(type_, obj)
else:
from pydantic import TypeAdapter # type: ignore
return TypeAdapter(type_).validate_python(obj)
class ConfigGroup(BaseModel): class ConfigGroup(BaseModel):
""" """
配置组 配置组
@ -106,21 +71,34 @@ class ConfigGroup(BaseModel):
if value_to_process is None: if value_to_process is None:
return default return default
if cfg.type: if cfg.arg_parser:
if _is_pydantic_type(cfg.type):
if build_model:
try:
return parse_as(cfg.type, value_to_process)
except Exception as e:
logger.warning(
f"Pydantic 模型解析失败 (key: {c.upper()}). ", e=e
)
try: try:
return cattrs.structure(value_to_process, cfg.type) return cfg.arg_parser(value_to_process)
except Exception as e: except Exception as e:
logger.warning(f"Cattrs 结构化失败 (key: {key}),返回原始值。", e=e) logger.debug(
f"配置项类型转换 MODULE: [<u><y>{self.module}</y></u>] | "
f"KEY: [<u><y>{key}</y></u>] 的自定义解析器失败,将使用原始值",
e=e,
)
return value_to_process
return value_to_process if not build_model or not cfg.type:
return value_to_process
try:
if _is_pydantic_type(cfg.type):
parsed_value = parse_as(cfg.type, value_to_process)
return parsed_value
else:
structured_value = cattrs.structure(value_to_process, cfg.type)
return structured_value
except Exception as e:
logger.error(
f"❌ 配置项 '{self.module}.{key}' 自动类型转换失败 "
f"(目标类型: {cfg.type}),将返回原始值。请检查配置文件格式。错误: {e}",
e=e,
)
return value_to_process
def to_dict(self, **kwargs): def to_dict(self, **kwargs):
return model_dump(self, **kwargs) return model_dump(self, **kwargs)
@ -167,6 +145,48 @@ class ConfigsManager:
if data := self._data.get(module): if data := self._data.get(module):
data.name = name data.name = name
def _merge_dicts(self, new_data: dict, original_data: dict) -> dict:
"""合并两个字典只进行key值的新增和删除操作不修改原有key的值
递归处理嵌套字典确保所有层级的key保持一致
参数:
new_data: 新数据字典
original_data: 原数据字典
返回:
合并后的字典
"""
result = dict(original_data)
for key, value in new_data.items():
if key not in original_data:
result[key] = value
elif isinstance(value, dict) and isinstance(original_data[key], dict):
result[key] = self._merge_dicts(value, original_data[key])
return result
def _normalize_config_data(self, value: Any, original_value: Any = None) -> Any:
"""标准化配置数据处理BaseModel和字典的情况
参数:
value: 要标准化的值
original_value: 原始值用于合并字典
返回:
标准化后的值
"""
processed_value = _dump_pydantic_obj(value)
if isinstance(processed_value, dict) and original_value is not None:
processed_original = _dump_pydantic_obj(original_value)
if isinstance(processed_original, dict):
return self._merge_dicts(processed_value, processed_original)
return processed_value
def add_plugin_config( def add_plugin_config(
self, self,
module: str, module: str,
@ -195,16 +215,16 @@ class ConfigsManager:
ValueError: module和key不能为为空 ValueError: module和key不能为为空
ValueError: 填写错误 ValueError: 填写错误
""" """
key = key.upper()
if not module or not key: if not module or not key:
raise ValueError("add_plugin_config: module和key不能为为空") raise ValueError("add_plugin_config: module和key不能为为空")
if isinstance(value, BaseModel):
value = model_dump(value)
if isinstance(default_value, BaseModel):
default_value = model_dump(default_value)
processed_value = _dump_pydantic_obj(value) existing_value = None
processed_default_value = _dump_pydantic_obj(default_value) if module in self._data and (config := self._data[module].configs.get(key)):
existing_value = config.value
processed_value = self._normalize_config_data(value, existing_value)
processed_default_value = self._normalize_config_data(default_value)
self.add_module.append(f"{module}:{key}".lower()) self.add_module.append(f"{module}:{key}".lower())
if module in self._data and (config := self._data[module].configs.get(key)): if module in self._data and (config := self._data[module].configs.get(key)):
@ -282,7 +302,6 @@ class ConfigsManager:
if value_to_process is None: if value_to_process is None:
return default return default
# 1. 最高优先级:自定义的参数解析器
if config.arg_parser: if config.arg_parser:
try: try:
return config.arg_parser(value_to_process) return config.arg_parser(value_to_process)
@ -338,14 +357,13 @@ class ConfigsManager:
with open(self._simple_file, "w", encoding="utf8") as f: with open(self._simple_file, "w", encoding="utf8") as f:
_yaml.dump(self._simple_data, f) _yaml.dump(self._simple_data, f)
path = path or self.file path = path or self.file
save_data = {} save_data = {
for module, config_group in self._data.items(): module: {
save_data[module] = {} config_key: model_dump(config_model, exclude={"type", "arg_parser"})
for config_key, config_model in config_group.configs.items(): for config_key, config_model in config_group.configs.items()
save_data[module][config_key] = model_dump( }
config_model, exclude={"type", "arg_parser"} for module, config_group in self._data.items()
) }
with open(path, "w", encoding="utf8") as f: with open(path, "w", encoding="utf8") as f:
_yaml.dump(save_data, f) _yaml.dump(save_data, f)

View File

@ -2,10 +2,10 @@ from collections.abc import Callable
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from nonebot.compat import model_dump
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from zhenxun.utils.enum import BlockType, LimitWatchType, PluginLimitType, PluginType from zhenxun.utils.enum import BlockType, LimitWatchType, PluginLimitType, PluginType
from zhenxun.utils.pydantic_compat import model_dump
__all__ = [ __all__ = [
"AICallableParam", "AICallableParam",
@ -65,7 +65,7 @@ class RegisterConfig(BaseModel):
"""配置注解""" """配置注解"""
default_value: Any | None = None default_value: Any | None = None
"""默认值""" """默认值"""
type: Any = None type: object = None
"""参数类型""" """参数类型"""
arg_parser: Callable | None = None arg_parser: Callable | None = None
"""参数解析""" """参数解析"""
@ -155,8 +155,6 @@ class AICallableProperties(BaseModel):
"""参数类型""" """参数类型"""
description: str description: str
"""参数描述""" """参数描述"""
enums: list[str] | None = None
"""参数枚举"""
class AICallableParam(BaseModel): class AICallableParam(BaseModel):
@ -265,6 +263,10 @@ class PluginExtraData(BaseModel):
"""是否显示在菜单中""" """是否显示在菜单中"""
smart_tools: list[AICallableTag] | None = None smart_tools: list[AICallableTag] | None = None
"""智能模式函数工具集""" """智能模式函数工具集"""
introduction: str | None = None
"""BOT自我介绍时插件的自我介绍"""
precautions: list[str] | None = None
"""BOT自我介绍时插件的注意事项"""
def to_dict(self, **kwargs): def to_dict(self, **kwargs):
return model_dump(self, **kwargs) return model_dump(self, **kwargs)

View File

@ -1,10 +1,12 @@
import time import time
from typing import ClassVar
from typing_extensions import Self from typing_extensions import Self
from tortoise import fields from tortoise import fields
from zhenxun.services.db_context import Model from zhenxun.services.db_context import Model
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.enum import CacheType, DbLockType
from zhenxun.utils.exception import UserAndGroupIsNone from zhenxun.utils.exception import UserAndGroupIsNone
@ -19,6 +21,8 @@ class BanConsole(Model):
"""使用ban命令的用户等级""" """使用ban命令的用户等级"""
ban_time = fields.BigIntField() ban_time = fields.BigIntField()
"""ban开始的时间""" """ban开始的时间"""
ban_reason = fields.TextField(null=True, default=None)
"""ban的理由"""
duration = fields.BigIntField() duration = fields.BigIntField()
"""ban时长""" """ban时长"""
operator = fields.CharField(255) operator = fields.CharField(255)
@ -27,6 +31,15 @@ class BanConsole(Model):
class Meta: # pyright: ignore [reportIncompatibleVariableOverride] class Meta: # pyright: ignore [reportIncompatibleVariableOverride]
table = "ban_console" table = "ban_console"
table_description = "封禁人员/群组数据表" table_description = "封禁人员/群组数据表"
unique_together = ("user_id", "group_id")
indexes = [("user_id",), ("group_id",)] # noqa: RUF012
cache_type = CacheType.BAN
"""缓存类型"""
cache_key_field = ("user_id", "group_id")
"""缓存键字段"""
enable_lock: ClassVar[list[DbLockType]] = [DbLockType.CREATE, DbLockType.UPSERT]
"""开启锁"""
@classmethod @classmethod
async def _get_data(cls, user_id: str | None, group_id: str | None) -> Self | None: async def _get_data(cls, user_id: str | None, group_id: str | None) -> Self | None:
@ -46,12 +59,12 @@ class BanConsole(Model):
raise UserAndGroupIsNone() raise UserAndGroupIsNone()
if user_id: if user_id:
return ( return (
await cls.get_or_none(user_id=user_id, group_id=group_id) await cls.safe_get_or_none(user_id=user_id, group_id=group_id)
if group_id if group_id
else await cls.get_or_none(user_id=user_id, group_id__isnull=True) else await cls.safe_get_or_none(user_id=user_id, group_id__isnull=True)
) )
else: else:
return await cls.get_or_none(user_id="", group_id=group_id) return await cls.safe_get_or_none(user_id="", group_id=group_id)
@classmethod @classmethod
async def check_ban_level( async def check_ban_level(
@ -96,7 +109,9 @@ class BanConsole(Model):
if user.duration == -1: if user.duration == -1:
return -1 return -1
_time = time.time() - (user.ban_time + user.duration) _time = time.time() - (user.ban_time + user.duration)
return 0 if _time > 0 else int(time.time() - user.ban_time - user.duration) if _time < 0:
return int(time.time() - user.ban_time - user.duration)
await user.delete()
return 0 return 0
@classmethod @classmethod
@ -122,6 +137,7 @@ class BanConsole(Model):
user_id: str | None, user_id: str | None,
group_id: str | None, group_id: str | None,
ban_level: int, ban_level: int,
reason: str | None,
duration: int, duration: int,
operator: str | None = None, operator: str | None = None,
): ):
@ -146,6 +162,7 @@ class BanConsole(Model):
group_id=group_id, group_id=group_id,
ban_level=ban_level, ban_level=ban_level,
ban_time=int(time.time()), ban_time=int(time.time()),
ban_reason=reason,
duration=duration, duration=duration,
operator=operator or 0, operator=operator or 0,
) )
@ -167,3 +184,33 @@ class BanConsole(Model):
await user.delete() await user.delete()
return True return True
return False return False
@classmethod
async def get_ban(
cls,
*,
id: int | None = None,
user_id: str | None = None,
group_id: str | None = None,
) -> Self | None:
"""安全地获取ban记录
参数:
id: 记录id
user_id: 用户id
group_id: 群组id
返回:
Self | None: ban记录
"""
if id is not None:
return await cls.safe_get_or_none(id=id)
return await cls._get_data(user_id, group_id)
@classmethod
async def _run_script(cls):
return [
"CREATE INDEX idx_ban_console_user_id ON ban_console(user_id);",
"CREATE INDEX idx_ban_console_group_id ON ban_console(group_id);",
"ALTER TABLE ban_console ADD COLUMN ban_reason TEXT DEFAULT NULL;",
]

View File

@ -3,6 +3,7 @@ from typing import Literal, overload
from tortoise import fields from tortoise import fields
from zhenxun.services.db_context import Model from zhenxun.services.db_context import Model
from zhenxun.utils.enum import CacheType
class BotConsole(Model): class BotConsole(Model):
@ -29,6 +30,11 @@ class BotConsole(Model):
table = "bot_console" table = "bot_console"
table_description = "Bot数据表" table_description = "Bot数据表"
cache_type = CacheType.BOT
"""缓存类型"""
cache_key_field = "bot_id"
"""缓存键字段"""
@staticmethod @staticmethod
def format(name: str) -> str: def format(name: str) -> str:
return f"<{name}," return f"<{name},"

View File

@ -49,7 +49,8 @@ class ChatHistory(Model):
o = "-" if order == "DESC" else "" o = "-" if order == "DESC" else ""
query = cls.filter(group_id=gid) if gid else cls query = cls.filter(group_id=gid) if gid else cls
if date_scope: if date_scope:
query = query.filter(create_time__range=date_scope) filter_scope = (date_scope[0].isoformat(" "), date_scope[1].isoformat(" "))
query = query.filter(create_time__range=filter_scope)
return list( return list(
await query.annotate(count=Count("user_id")) await query.annotate(count=Count("user_id"))
.order_by(f"{o}count") .order_by(f"{o}count")

View File

@ -1,3 +1,4 @@
import asyncio
from typing_extensions import Self from typing_extensions import Self
from nonebot.adapters import Bot from nonebot.adapters import Bot
@ -6,9 +7,13 @@ from tortoise import fields
from zhenxun.configs.config import BotConfig from zhenxun.configs.config import BotConfig
from zhenxun.models.group_console import GroupConsole from zhenxun.models.group_console import GroupConsole
from zhenxun.services.db_context import Model from zhenxun.services.db_context import Model
from zhenxun.services.log import logger
from zhenxun.utils.common_utils import SqlUtils from zhenxun.utils.common_utils import SqlUtils
from zhenxun.utils.enum import RequestHandleType, RequestType from zhenxun.utils.enum import RequestHandleType, RequestType
from zhenxun.utils.exception import NotFoundError from zhenxun.utils.exception import NotFoundError
from zhenxun.utils.manager.bot_profile_manager import BotProfileManager
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
class FgRequest(Model): class FgRequest(Model):
@ -123,6 +128,27 @@ class FgRequest(Model):
await bot.set_friend_add_request( await bot.set_friend_add_request(
flag=req.flag, approve=handle_type == RequestHandleType.APPROVE flag=req.flag, approve=handle_type == RequestHandleType.APPROVE
) )
if BotProfileManager.is_auto_send_profile():
file_path = await BotProfileManager.build_bot_profile_image(
bot.self_id
)
if file_path:
await asyncio.sleep(2)
await PlatformUtils.send_message(
bot,
req.user_id,
None,
MessageUtils.build_message(
[
f"你好,我是{BotConfig.self_nickname} "
"初次见面,希望我们可以好好相处!",
file_path,
]
),
)
logger.info(
"添加好友自动发送BOT自我介绍图片", session=req.user_id
)
else: else:
await GroupConsole.update_or_create( await GroupConsole.update_or_create(
group_id=req.group_id, defaults={"group_flag": 1} group_id=req.group_id, defaults={"group_flag": 1}

View File

@ -1,4 +1,4 @@
from typing import Any, cast, overload from typing import Any, ClassVar, cast, overload
from typing_extensions import Self from typing_extensions import Self
from tortoise import fields from tortoise import fields
@ -6,8 +6,9 @@ from tortoise.backends.base.client import BaseDBAsyncClient
from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.task_info import TaskInfo from zhenxun.models.task_info import TaskInfo
from zhenxun.services.cache import CacheRoot
from zhenxun.services.db_context import Model from zhenxun.services.db_context import Model
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import CacheType, DbLockType, PluginType
def add_disable_marker(name: str) -> str: def add_disable_marker(name: str) -> str:
@ -86,6 +87,16 @@ class GroupConsole(Model):
table = "group_console" table = "group_console"
table_description = "群组信息表" table_description = "群组信息表"
unique_together = ("group_id", "channel_id") unique_together = ("group_id", "channel_id")
indexes = [ # noqa: RUF012
("group_id",)
]
cache_type = CacheType.GROUPS
"""缓存类型"""
cache_key_field = ("group_id", "channel_id")
"""缓存键字段"""
enable_lock: ClassVar[list[DbLockType]] = [DbLockType.CREATE, DbLockType.UPSERT]
"""开启锁"""
@classmethod @classmethod
async def _get_task_modules(cls, *, default_status: bool) -> list[str]: async def _get_task_modules(cls, *, default_status: bool) -> list[str]:
@ -116,6 +127,18 @@ class GroupConsole(Model):
).values_list("module", flat=True), ).values_list("module", flat=True),
) )
@classmethod
async def _update_cache(cls, instance):
"""更新缓存
参数:
instance: 需要更新缓存的实例
"""
if cache_type := cls.get_cache_type():
key = cls.get_cache_key(instance)
if key is not None:
await CacheRoot.invalidate_cache(cache_type, key)
@classmethod @classmethod
async def create( async def create(
cls, using_db: BaseDBAsyncClient | None = None, **kwargs: Any cls, using_db: BaseDBAsyncClient | None = None, **kwargs: Any
@ -129,6 +152,9 @@ class GroupConsole(Model):
if task_modules or plugin_modules: if task_modules or plugin_modules:
await cls._update_modules(group, task_modules, plugin_modules, using_db) await cls._update_modules(group, task_modules, plugin_modules, using_db)
# 更新缓存
await cls._update_cache(group)
return group return group
@classmethod @classmethod
@ -180,6 +206,10 @@ class GroupConsole(Model):
if task_modules or plugin_modules: if task_modules or plugin_modules:
await cls._update_modules(group, task_modules, plugin_modules, using_db) await cls._update_modules(group, task_modules, plugin_modules, using_db)
# 更新缓存
if is_create:
await cls._update_cache(group)
return group, is_create return group, is_create
@classmethod @classmethod
@ -202,24 +232,39 @@ class GroupConsole(Model):
if task_modules or plugin_modules: if task_modules or plugin_modules:
await cls._update_modules(group, task_modules, plugin_modules, using_db) await cls._update_modules(group, task_modules, plugin_modules, using_db)
# 更新缓存
await cls._update_cache(group)
return group, is_create return group, is_create
@classmethod @classmethod
async def get_group( async def get_group(
cls, group_id: str, channel_id: str | None = None cls,
group_id: str,
channel_id: str | None = None,
clean_duplicates: bool = True,
) -> Self | None: ) -> Self | None:
"""获取群组 """获取群组
参数: 参数:
group_id: 群组id group_id: 群组id
channel_id: 频道id. channel_id: 频道id
clean_duplicates: 是否删除重复的记录仅保留最新的
返回: 返回:
Self: GroupConsole Self: GroupConsole
""" """
if channel_id: if channel_id:
return await cls.get_or_none(group_id=group_id, channel_id=channel_id) return await cls.safe_get_or_none(
return await cls.get_or_none(group_id=group_id, channel_id__isnull=True) group_id=group_id,
channel_id=channel_id,
clean_duplicates=clean_duplicates,
)
return await cls.safe_get_or_none(
group_id=group_id,
channel_id__isnull=True,
clean_duplicates=clean_duplicates,
)
@classmethod @classmethod
async def is_super_group(cls, group_id: str) -> bool: async def is_super_group(cls, group_id: str) -> bool:
@ -303,6 +348,9 @@ class GroupConsole(Model):
if update_fields: if update_fields:
await group.save(update_fields=update_fields) await group.save(update_fields=update_fields)
# 更新缓存
await cls._update_cache(group)
@classmethod @classmethod
async def set_unblock_plugin( async def set_unblock_plugin(
cls, cls,
@ -339,6 +387,9 @@ class GroupConsole(Model):
if update_fields: if update_fields:
await group.save(update_fields=update_fields) await group.save(update_fields=update_fields)
# 更新缓存
await cls._update_cache(group)
@classmethod @classmethod
async def is_normal_block_plugin( async def is_normal_block_plugin(
cls, group_id: str, module: str, channel_id: str | None = None cls, group_id: str, module: str, channel_id: str | None = None
@ -442,6 +493,9 @@ class GroupConsole(Model):
if update_fields: if update_fields:
await group.save(update_fields=update_fields) await group.save(update_fields=update_fields)
# 更新缓存
await cls._update_cache(group)
@classmethod @classmethod
async def set_unblock_task( async def set_unblock_task(
cls, cls,
@ -476,6 +530,9 @@ class GroupConsole(Model):
if update_fields: if update_fields:
await group.save(update_fields=update_fields) await group.save(update_fields=update_fields)
# 更新缓存
await cls._update_cache(group)
@classmethod @classmethod
def _run_script(cls): def _run_script(cls):
return [ return [
@ -483,4 +540,6 @@ class GroupConsole(Model):
" character varying(255) NOT NULL DEFAULT '';", " character varying(255) NOT NULL DEFAULT '';",
"ALTER TABLE group_console ADD superuser_block_task" "ALTER TABLE group_console ADD superuser_block_task"
" character varying(255) NOT NULL DEFAULT '';", " character varying(255) NOT NULL DEFAULT '';",
"CREATE INDEX idx_group_console_group_id ON group_console(group_id);",
"CREATE INDEX idx_group_console_group_null_channel ON group_console(group_id) WHERE channel_id IS NULL;", # 单独创建channel为空的索引 # noqa: E501
] ]

View File

@ -1,6 +1,7 @@
from tortoise import fields from tortoise import fields
from zhenxun.services.db_context import Model from zhenxun.services.db_context import Model
from zhenxun.utils.enum import CacheType
class LevelUser(Model): class LevelUser(Model):
@ -20,6 +21,11 @@ class LevelUser(Model):
table_description = "用户权限数据库" table_description = "用户权限数据库"
unique_together = ("user_id", "group_id") unique_together = ("user_id", "group_id")
cache_type = CacheType.LEVEL
"""缓存类型"""
cache_key_field = ("user_id", "group_id")
"""缓存键字段"""
@classmethod @classmethod
async def get_user_level(cls, user_id: str, group_id: str | None) -> int: async def get_user_level(cls, user_id: str, group_id: str | None) -> int:
"""获取用户在群内的等级 """获取用户在群内的等级
@ -53,6 +59,9 @@ class LevelUser(Model):
level: 权限等级 level: 权限等级
group_flag: 是否被自动更新刷新权限 0:, 1:. group_flag: 是否被自动更新刷新权限 0:, 1:.
""" """
if await cls.exists(user_id=user_id, group_id=group_id, user_level=level):
# 权限相同时跳过
return
await cls.update_or_create( await cls.update_or_create(
user_id=user_id, user_id=user_id,
group_id=group_id, group_id=group_id,
@ -90,13 +99,14 @@ class LevelUser(Model):
返回: 返回:
bool: 是否大于level bool: 是否大于level
""" """
if level == 0:
return True
if group_id: if group_id:
if user := await cls.get_or_none(user_id=user_id, group_id=group_id): if user := await cls.get_or_none(user_id=user_id, group_id=group_id):
return user.user_level >= level return user.user_level >= level
else: elif user_list := await cls.filter(user_id=user_id).all():
if user_list := await cls.filter(user_id=user_id).all(): user = max(user_list, key=lambda x: x.user_level)
user = max(user_list, key=lambda x: x.user_level) return user.user_level >= level
return user.user_level >= level
return False return False
@classmethod @classmethod
@ -119,8 +129,7 @@ class LevelUser(Model):
return [ return [
# 将user_id改为user_id # 将user_id改为user_id
"ALTER TABLE level_users RENAME COLUMN user_qq TO user_id;", "ALTER TABLE level_users RENAME COLUMN user_qq TO user_id;",
"ALTER TABLE level_users " "ALTER TABLE level_users ALTER COLUMN user_id TYPE character varying(255);",
"ALTER COLUMN user_id TYPE character varying(255);",
# 将user_id字段类型改为character varying(255) # 将user_id字段类型改为character varying(255)
"ALTER TABLE level_users " "ALTER TABLE level_users "
"ALTER COLUMN group_id TYPE character varying(255);", "ALTER COLUMN group_id TYPE character varying(255);",

View File

@ -4,7 +4,7 @@ from tortoise import fields
from zhenxun.models.plugin_limit import PluginLimit # noqa: F401 from zhenxun.models.plugin_limit import PluginLimit # noqa: F401
from zhenxun.services.db_context import Model from zhenxun.services.db_context import Model
from zhenxun.utils.enum import BlockType, PluginType from zhenxun.utils.enum import BlockType, CacheType, PluginType
class PluginInfo(Model): class PluginInfo(Model):
@ -59,6 +59,11 @@ class PluginInfo(Model):
table = "plugin_info" table = "plugin_info"
table_description = "插件基本信息" table_description = "插件基本信息"
cache_type = CacheType.PLUGINS
"""缓存类型"""
cache_key_field = "module"
"""缓存键字段"""
@classmethod @classmethod
async def get_plugin( async def get_plugin(
cls, load_status: bool = True, filter_parent: bool = True, **kwargs cls, load_status: bool = True, filter_parent: bool = True, **kwargs

View File

@ -2,7 +2,7 @@ from tortoise import fields
from zhenxun.models.goods_info import GoodsInfo from zhenxun.models.goods_info import GoodsInfo
from zhenxun.services.db_context import Model from zhenxun.services.db_context import Model
from zhenxun.utils.enum import GoldHandle from zhenxun.utils.enum import CacheType, GoldHandle
from zhenxun.utils.exception import GoodsNotFound, InsufficientGold from zhenxun.utils.exception import GoodsNotFound, InsufficientGold
from .user_gold_log import UserGoldLog from .user_gold_log import UserGoldLog
@ -29,6 +29,12 @@ class UserConsole(Model):
class Meta: # pyright: ignore [reportIncompatibleVariableOverride] class Meta: # pyright: ignore [reportIncompatibleVariableOverride]
table = "user_console" table = "user_console"
table_description = "用户数据表" table_description = "用户数据表"
indexes = [("user_id",), ("uid",)] # noqa: RUF012
cache_type = CacheType.USERS
"""缓存类型"""
cache_key_field = "user_id"
"""缓存键字段"""
@classmethod @classmethod
async def get_user(cls, user_id: str, platform: str | None = None) -> "UserConsole": async def get_user(cls, user_id: str, platform: str | None = None) -> "UserConsole":
@ -193,3 +199,10 @@ class UserConsole(Model):
if goods := await GoodsInfo.get_or_none(goods_name=name): if goods := await GoodsInfo.get_or_none(goods_name=name):
return await cls.use_props(user_id, goods.uuid, num, platform) return await cls.use_props(user_id, goods.uuid, num, platform)
raise GoodsNotFound("未找到商品...") raise GoodsNotFound("未找到商品...")
@classmethod
async def _run_script(cls):
return [
"CREATE INDEX idx_user_console_user_id ON user_console(user_id);",
"CREATE INDEX idx_user_console_uid ON user_console(uid);",
]

View File

@ -1,3 +1,14 @@
"""
Zhenxun Bot - 核心服务模块
主要服务包括
- 数据库上下文 (db_context): 提供数据库模型基类和连接管理
- 日志服务 (log): 提供增强的带上下文的日志记录器
- LLM服务 (llm): 提供与大语言模型交互的统一API
- 插件生命周期管理 (plugin_init): 支持插件安装和卸载时的钩子函数
- 定时任务调度器 (scheduler): 提供持久化的可管理的定时任务服务
"""
from nonebot import require from nonebot import require
require("nonebot_plugin_apscheduler") require("nonebot_plugin_apscheduler")
@ -6,3 +17,60 @@ require("nonebot_plugin_session")
require("nonebot_plugin_htmlrender") require("nonebot_plugin_htmlrender")
require("nonebot_plugin_uninfo") require("nonebot_plugin_uninfo")
require("nonebot_plugin_waiter") require("nonebot_plugin_waiter")
from .db_context import Model, disconnect, with_db_timeout
from .llm import (
AI,
AIConfig,
CommonOverrides,
LLMContentPart,
LLMException,
LLMGenerationConfig,
LLMMessage,
chat,
clear_model_cache,
code,
create_multimodal_message,
embed,
generate,
generate_structured,
get_cache_stats,
get_model_instance,
list_available_models,
list_embedding_models,
search,
set_global_default_model_name,
)
from .log import logger
from .plugin_init import PluginInit, PluginInitManager
from .scheduler import scheduler_manager
__all__ = [
"AI",
"AIConfig",
"CommonOverrides",
"LLMContentPart",
"LLMException",
"LLMGenerationConfig",
"LLMMessage",
"Model",
"PluginInit",
"PluginInitManager",
"chat",
"clear_model_cache",
"code",
"create_multimodal_message",
"disconnect",
"embed",
"generate",
"generate_structured",
"get_cache_stats",
"get_model_instance",
"list_available_models",
"list_embedding_models",
"logger",
"scheduler_manager",
"search",
"set_global_default_model_name",
"with_db_timeout",
]

1056
zhenxun/services/cache/__init__.py vendored Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More