Compare commits

..
Author SHA1 Message Date
HibiKier 4c082e9f07 ✨ feat(page-template): 添加页面模板服务,支持前端布局组件和数据提交处理
- 新增页面模板服务模块,提供页面模板配置、字段定义和数据验证功能。
- 实现了前端布局组件模型,包括行、列、文本、按钮、卡片等,支持灵活的页面布局。
- 引入 FastAPI 路由,提供统一的API接口以获取模板配置和处理数据提交。
- 注册用户表单模板示例,包含提交、重置和取消按钮的功能。
- 增强了数据验证和处理逻辑,确保提交数据的有效性和安全性。
2025-12-23 14:31:17 +08:00
c9f0a8b9d9 ♻️ refactor(llm): 重构 LLM 服务架构,引入中间件与组件化适配器 (#2073)
检查bot是否运行正常 / bot check (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
* ♻️ refactor(llm): 重构 LLM 服务架构,引入中间件与组件化适配器

- 【重构】LLM 服务核心架构:
    - 引入中间件管道,统一处理请求生命周期(重试、密钥选择、日志、网络请求)。
    - 适配器重构为组件化设计,分离配置映射、消息转换、响应解析和工具序列化逻辑。
    - 移除 `with_smart_retry` 装饰器,其功能由中间件接管。
    - 移除 `LLMToolExecutor`,工具执行逻辑集成到 `ToolInvoker`。
- 【功能】增强配置系统:
    - `LLMGenerationConfig` 采用组件化结构(Core, Reasoning, Visual, Output, Safety, ToolConfig)。
    - 新增 `GenConfigBuilder` 提供语义化配置构建方式。
    - 新增 `LLMEmbeddingConfig` 用于嵌入专用配置。
    - `CommonOverrides` 迁移并更新至新配置结构。
- 【功能】强化工具系统:
    - 引入 `ToolInvoker` 实现更灵活的工具执行,支持回调与结构化错误。
    - `function_tool` 装饰器支持动态 Pydantic 模型创建和依赖注入 (`ToolParam`, `RunContext`)。
    - 平台原生工具支持 (`GeminiCodeExecution`, `GeminiGoogleSearch`, `GeminiUrlContext`)。
- 【功能】高级生成与嵌入:
    - `generate_structured` 方法支持 In-Context Validation and Repair (IVR) 循环和 AutoCoT (思维链) 包装。
    - 新增 `embed_query` 和 `embed_documents` 便捷嵌入 API。
    - `OpenAIImageAdapter` 支持 OpenAI 兼容的图像生成。
    - `SmartAdapter` 实现模型名称智能路由。
- 【重构】消息与类型系统:
    - `LLMContentPart` 扩展支持更多模态和代码执行相关内容。
    - `LLMMessage` 和 `LLMResponse` 结构更新,支持 `content_parts` 和思维链签名。
    - 统一 `LLMErrorCode` 和用户友好错误消息,提供更详细的网络/代理错误提示。
    - `pyproject.toml` 移除 `bilireq`,新增 `json_repair`。
- 【优化】日志与调试:
    - 引入 `DebugLogOptions`,提供细粒度日志脱敏控制。
    - 增强日志净化器,处理更多敏感数据和长字符串。
- 【清理】删除废弃模块:
    - `zhenxun/services/llm/memory.py`
    - `zhenxun/services/llm/executor.py`
    - `zhenxun/services/llm/config/presets.py`
    - `zhenxun/services/llm/types/content.py`
    - `zhenxun/services/llm/types/enums.py`
    - `zhenxun/services/llm/tools/__init__.py`
    - `zhenxun/services/llm/tools/manager.py`

* 📦️ build(deps): 移除 bilireq 并添加 json_repair 依赖

* 🐛 (llm): 移除图片生成模型能力预检查

* ♻️ refactor(llm.session): 重构记忆系统以分离存储和策略

* 🐛 fix(reload_setting): 重载配置时清除LLM缓存

* ✨ feat(llm): 支持结构化生成函数接收 UniMessage

* ✨ feat(search): 为搜索功能默认启用 Gemini Google Search 工具

* 🚨 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-12-14 20:27:02 +08:00
Rumioandwebjoin111 e5b2a872d3 ✨ feat(group-settings): 实现群插件配置管理系统 (#2072)
检查bot是否运行正常 / bot check (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(group-settings): 实现群插件配置管理系统

- 引入 GroupSettingsService 服务,提供统一的群插件配置管理接口
- 新增 GroupPluginSetting 模型,用于持久化存储插件在不同群组的配置
- 插件扩展数据 PluginExtraData 增加 group_config_model 字段,用于注册分群配置模型
- 新增 GetGroupConfig 依赖注入,允许插件轻松获取和解析当前群组的配置

【核心服务 GroupSettingsService】
- 支持按群组、插件名和键设置、获取和删除配置项
- 实现配置聚合缓存机制,提升配置读取效率,减少数据库查询
- 支持配置继承与覆盖逻辑(群配置覆盖全局默认值)
- 提供批量设置功能 set_bulk,方便为多个群组同时更新配置

【管理与缓存】
- 新增超级用户命令 pconf (plugin_config_manager),用于命令行管理插件的分群和全局配置
- 新增 CacheType.GROUP_PLUGIN_SETTINGS 缓存类型并注册
- 增加 Pydantic model_construct 兼容函数

* 🐛 fix(codeql): 移除对 JavaScript 和 TypeScript 的分析支持

---------

Co-authored-by: webjoin111 <455457521@qq.com>
2025-12-01 14:52:36 +08:00
68460d18cc ✨ Feat: 增强 LLM、渲染与广播功能并优化性能 (#2071)
检查bot是否运行正常 / bot check (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
* ⚡️ perf(image_utils): 优化图片哈希获取避免阻塞异步

* ✨ feat(llm): 增强 LLM 管理功能,支持纯文本列表输出,优化模型能力识别并新增提供商

- 【LLM 管理器】为 `llm list` 命令添加 `--text` 选项,支持以纯文本格式输出模型列表。
- 【LLM 配置】新增 `OpenRouter` LLM 提供商的默认配置。
- 【模型能力】增强 `get_model_capabilities` 函数的查找逻辑,支持模型名称分段匹配和更灵活的通配符匹配。
- 【模型能力】为 `Gemini` 模型能力注册表使用更通用的通配符模式。
- 【模型能力】新增 `GPT` 系列模型的详细能力定义,包括多模态输入输出和工具调用支持。

* ✨ feat(renderer): 添加 Jinja2 `inline_asset` 全局函数

- 新增 `RendererService._inline_asset_global` 方法,并注册为 Jinja2 全局函数 `inline_asset`。
- 允许模板通过 `{{ inline_asset('@namespace/path/to/asset.svg') }}` 直接内联已注册命名空间下的资源文件内容。
- 主要用于解决内联 SVG 时可能遇到的跨域安全问题。
- 【重构】优化 `ResourceResolver.resolve_asset_uri` 中对命名空间资源 (以 `@` 开头) 的解析逻辑,确保能够正确获取文件绝对路径并返回 URI。
- 改进 `RenderableComponent.get_extra_css`,使其在组件定义 `component_css` 时自动返回该 CSS 内容。
- 清理 `Renderable` 协议和 `RenderableComponent` 基类中已存在方法的 `[新增]` 标记。

* ✨ feat(tag): 添加标签克隆功能

- 新增 `tag clone <源标签名> <新标签名>` 命令,用于复制现有标签。
- 【优化】在 `tag create`, `tag edit --add`, `tag edit --set` 命令中,自动去重传入的群组ID,避免重复关联。

* ✨ feat(broadcast): 实现标签定向广播、强制发送及并发控制

- 【新功能】
  - 新增标签定向广播功能,支持通过 `-t <标签名>` 或 `广播到 <标签名>` 命令向指定标签的群组发送消息
  - 引入广播强制发送模式,允许绕过群组的任务阻断设置
  - 实现广播并发控制,通过配置限制同时发送任务数量,避免API速率限制
  - 优化视频消息处理,支持从URL下载视频内容并作为原始数据发送,提高跨平台兼容性
- 【配置】
  - 添加 `DEFAULT_BROADCAST` 配置项,用于设置群组进群时广播功能的默认开关状态
  - 添加 `BROADCAST_CONCURRENCY_LIMIT` 配置项,用于控制广播时的最大并发任务数

* ✨ feat(renderer): 支持组件变体样式收集

* ✨ feat(tag): 实现群组标签自动清理及手动清理功能

* 🐛 fix(gemini): 增加响应验证以处理内容过滤(promptFeedback)

* 🐛 fix(codeql): 移除对 JavaScript 和 TypeScript 的分析支持

* 🚨 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-11-26 14:13:19 +08:00
ThelevenFDandHibiKier c839b44256 转换specify_probability为float 增加鉴权配置 (#2067)
检查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
* 转换specify_probability为float

* 解决#2045 添加密钥配置

* Revise access token configuration in .env.example

Updated comments and modified access token configuration.

---------

Co-authored-by: HibiKier <45528451+HibiKier@users.noreply.github.com>
2025-11-03 16:36:43 +08:00
70bde00757 ✨ feat(core): 增强定时任务与群组标签管理,重构调度核心 (#2068)
* ✨ feat(core): 更新群组信息、Markdown 样式与 Pydantic 兼容层

- 【group】添加更新所有群组信息指令,并同步群组控制台数据
- 【markdown】支持合并 Markdown 的 CSS 来源
- 【pydantic-compat】提供 model_validate 兼容函数

* ✨ feat(core): 增强定时任务与群组标签管理,重构调度核心

✨ 新功能

* **标签 (tags)**: 引入群组标签服务。
    * 支持静态标签和动态标签 (基于 Alconna 规则自动匹配群信息)。
    * 支持黑名单模式及 `@all` 特殊标签。
    * 提供 `tag_manage` 超级用户插件 (list, create, edit, delete 等)。
    * 群成员变动时自动失效动态标签缓存。
* **调度 (scheduler)**: 增强定时任务。
    * 重构 `ScheduledJob` 模型,支持 `TAG`, `ALL_GROUPS` 等多种目标类型。
    * 新增任务别名 (`name`)、创建者、权限、来源等字段。
    * 支持一次性任务 (`schedule_once`) 和 Alconna 命令行参数 (`--params-cli`)。
    * 新增执行选项 (`jitter`, `spread`) 和并发策略 (`ALLOW`, `SKIP`, `QUEUE`)。
    * 支持批量获取任务状态。

♻️ 重构优化

* **调度器核心**:
    * 拆分 `service.py` 为 `manager.py` (API) 和 `types.py` (模型)。
    * 合并 `adapter.py` / `job.py` 至 `engine.py` (统一调度引擎)。
    * 引入 `targeting.py` 模块管理任务目标解析。
* **调度器插件 (scheduler_admin)**:
    * 迁移命令参数校验逻辑至 `ArparmaBehavior`。
    * 引入 `dependencies.py` 和 `data_source.py` 解耦业务逻辑与依赖注入。
    * 适配新的任务目标类型展示。

* 🐛 fix(tag): 修复黑名单标签解析逻辑并优化标签详情展示

* ✨ feat(scheduler): 为多目标定时任务添加固定间隔串行执行选项

* ✨ feat(schedulerAdmin): 允许定时任务删除、暂停、恢复命令支持多ID操作

* 🚨 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-11-03 10:53:40 +08:00
molanp eb6d90ae88 docs(data-source): 更新插件安装函数的参数文档说明 (#2069)
检查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
修改 StoreManager 类中安装插件函数的文档字符串,更新参数列表说
明。将原有的 github_url、module_path、is_dir 参数说明替换为
plugin_info 和 source 参数说明,保持文档与实际函数签名一致。
2025-10-22 20:57:07 +08:00
molanp 4b8013d2d6 Feat: Add spaces (#2064)
检查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
2025-10-17 09:22:18 +08:00
HibiKier d528711641 🐛 fix(http_utils): 增强错误处理,记录请求失败的详细信息 (#2065)
检查bot是否运行正常 / bot check (push) Waiting to run
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
Update Version / update-version (push) Waiting to run
* 🐛 fix(http_utils): 增强错误处理,记录请求失败的详细信息

* 🐛 fix(http_utils): 改进HTTP错误处理,记录请求失败的状态码和响应内容
2025-10-16 17:31:08 +08:00
molanp 1cc18bb195 fix(shop): 修改道具不存在时的提示信息 (#2061)
检查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
- 将道具不存在时的提示信息从具体的道具名称改为通用提示,避免暴露内部实现细节,
提升用户体验和安全性。
- resolve Bug: 使用道具功能优化
Fixes #2060
2025-10-09 09:01:20 +08:00
Rumioandwebjoin111 74a9f3a843 ✨ feat(core): 支持LLM多图片响应,增强UI主题皮肤系统及优化JSON/Markdown处理 (#2062)
- 【LLM服务】
  - `LLMResponse` 模型现在支持 `images: list[bytes]`,允许模型返回多张图片。
  - LLM适配器 (`base.py`, `gemini.py`) 和 API 层 (`api.py`, `service.py`) 已更新以处理多图片响应。
  - 响应验证逻辑已调整,以检查 `images` 列表而非单个 `image_bytes`。
- 【UI渲染服务】
  - 引入组件“皮肤”(variant)概念,允许为同一组件提供不同视觉风格。
  - 改进了 `manifest.json` 的加载、合并和缓存机制,支持基础清单与皮肤清单的递归合并。
  - `ThemeManager` 现在会缓存已加载的清单,并在主题重载时清除缓存。
  - 增强了资源解析器 (`ResourceResolver`),支持 `@` 命名空间路径和更健壮的相对路径处理。
  - 独立模板现在会继承主 Jinja 环境的过滤器。
- 【工具函数】
  - 引入 `dump_json_safely` 工具函数,用于更安全地序列化包含 Pydantic 模型、枚举等复杂类型的对象为 JSON。
  - LLM 服务中的请求体和缓存键生成已改用 `dump_json_safely`。
  - 优化了 `format_usage_for_markdown` 函数,改进了 Markdown 文本的格式化,确保块级元素前有正确换行,并正确处理段落内硬换行。

Co-authored-by: webjoin111 <455457521@qq.com>
2025-10-09 08:50:40 +08:00
HibiKierandpre-commit-ci[bot] e7f3c210df 修复并发时数据库超时 (#2063)
* 🔧 修复和优化:调整超时设置,重构检查逻辑,简化代码结构

- 在 `chkdsk_hook.py` 中重构 `check` 方法,提取公共逻辑
- 更新 `CacheManager` 中的超时设置,使用新的 `CACHE_TIMEOUT`
- 在 `utils.py` 中添加缓存逻辑,记录数据库操作的执行情况

* ✨ feat(auth): 添加并发控制,优化权限检查逻辑

* Update utils.py

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-10-09 08:46:08 +08:00
molanp f94121080f fix(check): 修复自检插件在ARM设备下的CPU频率获取逻辑 (#2057)
检查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
- 将插件版本从0.1更新至0.2
- 新增安全获取ARM设备CPU频率的函数get_arm_cpu_freq_safe
- 优化CPU信息采集逻辑,提高在ARM架构下的兼容性
2025-10-01 18:42:47 +08:00
HibiKier 761c8daac4 ✨ feat(configs): 优化 ConfigsManager 中的键值获取逻辑,确保未定义键时自动创建 ConfigGroup 实例 (#2058) 2025-10-01 18:42:19 +08:00
Rumioandwebjoin111 c667fc215e ✨ feat(llm): 增强LLM服务,支持图片生成、响应验证与OpenRouter集成 (#2054)
* ✨ feat(llm): 增强LLM服务,支持图片生成、响应验证与OpenRouter集成

- 【新功能】统一图片生成与编辑API `create_image`,支持文生图、图生图及多图输入
- 【新功能】引入LLM响应验证机制,通过 `validation_policy` 和 `response_validator` 确保响应内容符合预期,例如强制返回图片
- 【新功能】适配OpenRouter API,扩展LLM服务提供商支持,并添加OpenRouter特定请求头
- 【重构】将日志净化逻辑重构至 `log_sanitizer` 模块,提供统一的净化入口,并应用于NoneBot消息、LLM请求/响应日志
- 【修复】优化Gemini适配器,正确解析图片生成响应中的Base64图片数据,并更新模型能力注册表

* ✨ feat(image): 优化图片生成响应并返回完整LLMResponse

* ✨ feat(llm): 为 OpenAI 兼容请求体添加日志净化

* 🐛 fix(ui): 截断UI调试HTML日志中的长base64图片数据

---------

Co-authored-by: webjoin111 <455457521@qq.com>
2025-10-01 18:41:46 +08:00
Rumioandwebjoin111 07be73c1b7 ✨ feat(avatar): 引入头像缓存服务并优化头像获取 (#2055)
检查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
Co-authored-by: webjoin111 <455457521@qq.com>
2025-09-28 08:53:10 +08:00
molanp 7e6896fa01 🚑fix(data_source): 修复插件商店更新路径错误 (#2056)
* 🚑fix(data_source): 修复插件商店更新路径错误

* fix(plugin_store): 修复插件模块路径处理逻辑

简化了插件模块路径的赋值逻辑,直接使用插件对象的模块路径,避免不必要的路径分割操作。
同时修复了目标目录判断条件,确保只有在模块路径为根目录时才使用插件名称作为目录。
2025-09-28 08:50:54 +08:00
Rumioandwebjoin111 3cc882b116 ✨ feat(auto_update): 增强自动更新与版本检查 (#2042)
检查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
- 优化 `检查更新` 默认行为,未指定类型时直接显示版本信息
- 扩展版本详情显示:当前版本、最新开发版/正式版(含日期)、资源版本及更新提示
- 新增更新后资源兼容性检查,自动读取 `resources.spec` 并提示更新
- 使用 `asyncio.gather` 并发获取版本信息,引入 `packaging` 库提高比较准确性
- 优化错误处理与日志记录

Co-authored-by: webjoin111 <455457521@qq.com>
2025-09-12 17:38:41 +08:00
molanpandHibiKier ee699fb345 fix(plugin_store): 修复插件商店的安装与卸载逻辑 (#2050)
* fix(plugin_store): 修复插件商店的安装与卸载逻辑

- 优化了插件安装、更新和移除的逻辑
- 调整了插件路径的处理方式,支持更灵活的安装位置
- 重构了 `install_plugin_with_repo` 方法,使用 `StorePluginInfo` 对象作为参数
- 修复了一些潜在的路径问题和模块命名问题

* refactor(zhenxun): 优化插件信息获取逻辑

- 将 PluginInfo.get_or_none 替换为 get_plugin 方法,简化插件信息获取逻辑
- 优化了插件移除操作中的插件信息获取流程

* refactor(zhenxun): 优化 sparse_checkout_clone 函数的实现

- 将 git 操作移至临时目录中执行,避免影响目标目录中的现有内容
- 简化了稀疏检出的配置和执行过程
- 改进了错误处理和回退逻辑
- 优化了文件移动和目录清理的操作

* 🐛 添加移除插件时二次查询

* ✨ plugin_info.get_plugin参数包含plugin_type时无效过滤

---------

Co-authored-by: HibiKier <45528451+HibiKier@users.noreply.github.com>
2025-09-12 17:38:24 +08:00
molanp 631e66d54f fix(htmlrender): 更新htmlrender 导入 路径 (#2051)
- 将 get_browser 的导入路径从 nonebot_plugin_htmlrender 更新为 nonebot_plugin_htmlrender.browser
2025-09-12 16:41:43 +08:00
c7ef6fdb17 ✨ feat(ui): 增强表格构建器并完善组件模型文档 (#2048)
检查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(table): 添加 ComponentCell 以支持表格单元格中嵌入可渲染组件

* ✨ feat(ui): 增强表格构建器并完善组件模型文档

- 增强 `TableBuilder`,新增 `_normalize_cell` 辅助方法,支持自动将原生数据类型(如 `str`, `int`, `Path`)转换为 `TableCell` 模型,简化了表格行的创建。
- 完善 `zhenxun/ui/models` 目录下所有组件模型字段的 `description` 属性和文档字符串,显著提升了代码可读性和开发者体验。
- 优化 `shop/_data_source.py` 中 `gold_rank` 函数的平台路径判断格式,并统一 `my_props` 函数中图标路径的处理逻辑。

* 🚨 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-09-11 10:31:49 +08:00
molanp fb0a9813e1 fix(ui): 修复表格组件中对本地图片的显示问题 (#2047)
检查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
- 在 ImageCell 中添加对 Path 类型的支持,并在验证器中处理路径解析
- 优化 ShopManage 和 SignManage 类中的代码,使用新的 ImageCell 构造方式
- 更新 TableData 类中的注释,提高代码可读性
2025-09-09 15:01:45 +08:00
molanp 6940c2f37b 🚑 修复 我的道具 渲染异常 (#2046)
检查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
2025-09-08 08:43:56 +08:00
molanp 74ce848127 修复对 Uninfo 错误的版本限制 (#2041)
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-09-01 17:20:24 +08:00
molanp 9e5c4aa3e7 build(deps): 更新项目依赖项 (#2038)
检查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
- 移除了大量不再需要的依赖项
- 更新了部分依赖项的版本,指定了版本范围
- 添加了少数新的依赖项
2025-08-30 18:13:56 +08:00
7f460296dd ✨ feat(ui): 添加富文本单元格并迁移UI表格渲染 (#2039)
* ✨ feat(ui): 添加富文本单元格并迁移UI表格渲染

- 【新功能】
  - 添加 `RichTextCell` 模型,支持在表格单元格中显示多个带样式的文本片段。
  - `TableCell` 类型别名更新以包含 `RichTextCell`。
- 【迁移】
  - 将`ShopManage`、`SignManage` 和 `SchedulerManager` 中所有基于 `ImageTemplate.table_page` 的表格图片生成逻辑迁移至新的 `TableBuilder` 和 `ui.render` 系统。
  - 移除旧的 `ImageTemplate` 导入和 `RowStyle` 函数。
  - 将 `ThemeManager` 中的资源解析逻辑提取到独立的 `ResourceResolver` 类中,增强模块化和可维护性。
  - 优化 `ThemeManager.load_theme` 中 `ChoiceLoader` 的处理逻辑。
  - 优化签到卡片数据结构,移除 `last_sign_date_str` 字段,并调整 `reward_info` 在卡片视图下的结构。
  - 移除 `_generate_html_card` 中 `favorability_info` 的 `attitude` 和 `relation` 字段。

* 🎨 (log): 优化消息日志格式,摘要base64内容

* 🚨 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-08-30 18:13:37 +08:00
HibiKier b505307f2f 🐛 优化CacheDict类中的键存在性检查和获取逻辑,简化代码结构,提高可读性。 (#2037)
检查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
2025-08-29 16:07:04 +08:00
molanp 4ab9382205 build(deps): 更新依赖版本 (#2034) 2025-08-29 15:08:17 +08:00
HibiKier 1e2aa99207 Bugfix/fix aliyun (#2036)
* 🐛 修复数据库超时问题

* 🔧 移除帮助图片清理功能.

* ✨ 更新插件商店功能,允许在添加插件时指定源类型为 None。优化插件 ID 查找逻辑,增强代码可读性。新增 zhenxun/ui 模块导入。

* 🔧 优化数据访问和数据库上下文逻辑,移除不必要的全局变量和日志信息,调整日志级别为调试,提升代码可读性和性能。

* ✨ 增强插件商店功能,支持在下载文件时指定稀疏检出路径和目标目录。优化二进制文件处理逻辑,提升文件下载的准确性和效率。

* ✨ 增强阿里云和GitHub的文件管理功能,新增Git不可用异常处理,优化稀疏检出逻辑,提升代码可读性和稳定性。

* ✨ 增强插件下载功能,新增对下载结果的异常处理,确保在Git不可用时抛出相应异常信息。优化错误提示,提升用户体验。

* ✨ 增强插件商店功能,优化添加插件时的提示信息,明确区分插件模块和名称。新增 Windows 下删除只读文件的处理逻辑,提升插件管理的稳定性和用户体验。

* ✨ 优化文件内容获取逻辑,新增对非二进制文件的UTF-8解码处理,提升文件读取的稳定性和准确性。
2025-08-29 14:57:08 +08:00
7472cabd48 ✨ feat!(ui): 重构图表组件架构,实现数据与样式分离 (#2035)
* ✨ feat!(ui): 重构图表组件架构,实现数据与样式分离

🏗️ **架构重构**
- 移除charts.py中所有硬编码样式参数(grid、tooltip、legend等)
- 将样式配置迁移至主题层style.json文件
- 统一图表模板消费样式文件的能力

📊 **图表组件优化**
- bar_chart: 移除grid和坐标轴show参数
- pie_chart: 移除tooltip、legend样式和series视觉参数
- line_chart: 移除tooltip、grid和坐标轴配置
- radar_chart: 移除tooltip硬编码

🎨 **主题系统增强**
- 新增pie_chart、line_chart、radar_chart的style.json配置
- 更新bar_chart/style.json,添加grid、xAxis、yAxis样式
- 所有图表模板支持deepMerge样式合并逻辑

🔧 **Breaking Changes**
- 图表工厂函数不再接受样式参数
- 主题开发者现可通过style.json完全定制图表外观
- 提升组件可维护性和主题灵活性

* 📦️ build(pyinstaller): 引入 resources.spec 并更新 .gitignore 规则

* 🚨 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-08-28 09:20:15 +08:00
HibiKier d9e65057cf ⚡ 减少数据库查询次数,提高数据库查询方面性能 (#2030)
检查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
* 🐛 修复数据库超时问题

* 🔧 移除帮助图片清理功能.

* ✨ 更新插件商店功能,允许在添加插件时指定源类型为 None。优化插件 ID 查找逻辑,增强代码可读性。新增 zhenxun/ui 模块导入。

* 🔧 优化数据访问和数据库上下文逻辑,移除不必要的全局变量和日志信息,调整日志级别为调试,提升代码可读性和性能。
2025-08-26 16:53:14 +08:00
molanp b12168b6b9 refactor(zhenxun): 移除失效的 GitHub 代理 (#2029)
- 从 get_fastest_raw_formats、get_fastest_archive_formats 和 get_fastest_release_formats 函数中移除了 ghproxy.cc 代理地址
2025-08-26 16:48:02 +08:00
HibiKier a63f26c3b6 ✨ 增强插件商店功能,支持添加插件时指定源类型。 (#2028)
检查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
2025-08-21 11:08:34 +08:00
HibiKier 095a123c3c 🔧 移除帮助图片删除功能,简化插件切换逻辑。更新相关导入,优化代码结构。 (#2027)
检查bot是否运行正常 / bot check (push) Waiting to run
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
Update Version / update-version (push) Waiting to run
2025-08-20 21:18:57 +08:00
HibiKier f9a38a26b2 🐛 修复群组申请通知 (#2026)
检查bot是否运行正常 / bot check (push) Waiting to run
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
Update Version / update-version (push) Waiting to run
* ✨ 修复一些bug

- 移除不必要的定时器类,简化代码结构
- 优化好友请求处理逻辑,确保在自动同意和手动处理之间的清晰区分
- 更新缓存机制,避免重复处理相同的好友请求
- 新增判断文件是否为二进制文件的功能,提升文件处理的准确性
- 优化缓存字典的过期检查逻辑,提高性能和可读性

* ✨ 更新 get_async_client 函数,支持字符串类型的代理参数

- 修改 proxies 参数类型,允许传入字符串形式的代理地址
- 增强代理处理逻辑,将字符串代理转换为字典格式,提升灵活性和可用性
2025-08-19 16:20:52 +08:00
6124e217d0 ♻️ refactor(UI): 重构UI渲染服务为组件化分层架构 (#2025)
检查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(UI): 重构UI渲染服务为组件化分层架构

♻️ **架构重构**
- UI渲染服务重构为组件化分层架构
- 解耦主题管理、HTML生成、截图功能

✨ **新增功能**
- `zhenxun.ui` 统一入口,提供 `render`、`markdown`、`vstack` 等API
- `RenderableComponent` 基类和渲染协议抽象
- 新增主题管理器和截图引擎模块

⚙️ **配置优化**
- UI配置迁移至 `superuser/ui_manager.py`
- 新增"重载UI主题"管理指令

🔧 **性能改进**
- 优化渲染缓存,支持组件级透明缓存
- 所有UI组件适配新渲染流程

* 🚨 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-08-18 23:08:22 +08:00
11524bcb04 ♻️ refactor: 统一图片渲染架构并引入通用UI组件系统 (#2019)
检查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
* ♻️ refactor: 统一图片渲染架构并引入通用UI组件系统

🎨 **渲染服务重构**
- 统一图片渲染入口,引入主题系统支持
- 优化Jinja2环境管理,支持主题覆盖和插件命名空间
- 新增UI缓存机制和主题重载功能

✨ **通用UI组件系统**
- 新增 zhenxun.ui 模块,提供数据模型和构建器
- 引入BaseBuilder基类,支持链式调用
- 新增多种UI构建器:InfoCard, Markdown, Table, Chart, Layout等
- 新增通用组件:Divider, Badge, ProgressBar, UserInfoBlock

🔄 **插件迁移**
- 迁移9个内置插件至新渲染系统
- 移除各插件中分散的图片生成工具
- 优化数据处理和渲染逻辑

💥 **Breaking Changes**
- 移除旧的图片渲染接口和模板路径
- TEMPLATE_PATH 更名为 THEMES_PATH
- 插件需适配新的RendererService和zhenxun.ui模块

* ✅ test(check): 更新自检插件测试中的渲染服务模拟

* ♻️ refactor(renderer): 将缓存文件名哈希算法切换到 SHA256

* ♻️ refactor(shop): 移除商店HTML图片生成模块

* 🚨 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-08-15 16:34:37 +08:00
d5e5fac02d 🐛 修复webui移除插件bug (#2018)
检查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
* 🐛 修复webui移除插件bug

* test: 使用 xfail 替代 skip 标记测试用例 (#2020)

* test: 暂时跳过插件商店相关测试 (#2015)

- 在五个测试文件中,为所有测试函数添加了 @pytest.mark.skip("修不好") 装饰器
- 导入了 pytest 模块以支持跳过测试
- 保留了现有的测试逻辑,仅添加了跳过标记
- 等以后能修好了再说,不能因为它影响测试流程

* test: 使用 xfail 替代 skip 标记测试用例

- 将多个测试用例中的 @pytest.mark.skip 标记替换为 @pytest.mark.xfail
- 这一变更可以更准确地反映测试用例的预期行为
- 主要涉及 auto_update、plugin_store 相关的测试文件

* test: 标记 test_check 和 test_check_arm 测试用例为预期失败

- 在 test_check.py 文件中,为 test_check 和 test_check_arm 两个异步测试用例添加了 pytest.mark.xfail 装饰器
- 这表示这两个测试用例预期会失败,可能是由于已知的错误或不稳定因素
- 使用 xfail 标记可以帮助区分正常的测试失败和预期的失败,避免误报

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

---------

Co-authored-by: molanp <104612722+molanp@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-08-14 09:06:16 +08:00
molanp 55da0046a2 test: 暂时跳过插件商店相关测试 (#2015)
检查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
Sequential Lint and Type Check / pyright-call (push) Has been cancelled
- 在五个测试文件中,为所有测试函数添加了 @pytest.mark.skip("修不好") 装饰器
- 导入了 pytest 模块以支持跳过测试
- 保留了现有的测试逻辑,仅添加了跳过标记
- 等以后能修好了再说,不能因为它影响测试流程
2025-08-12 21:33:37 +08:00
molanp 977f0b13b3 fix(zhenxun): 修复 HTTP 客户端初始化逻辑错误 (#2014)
检查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
- 将 httpx.AsyncClient 实例化逻辑封装到 get_async_client 函数中
- 统一全局客户端和测试环境客户端的创建方式
- 提高代码复用性和可维护性
2025-08-11 10:18:15 +08:00
molanpandpre-commit-ci[bot] 2fed781350 fix(scheduler_admin): 修复定时任务列表页码显示逻辑 (#2016)
* fix(scheduler_admin): 修复定时任务列表页码显示逻辑

- 在格式化定时任务列表图像时,添加对当前页码的可用性判断
- 如果页码不可用,则将当前页码默认设置为 1,避免显示错误的页码信息

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-08-11 10:15:31 +08:00
molanp 6d1789bbee fix(zhenxun): 修正插件更新逻辑 (#2017)
- 将循环遍历的变量从 plugin_list 改为 all_plugin_list,以确保尝试更新所有插件
- 优化了插件更新日志的输出格式
2025-08-11 10:14:41 +08:00
242 changed files with 20356 additions and 19392 deletions
+5 -1
View File
@@ -10,6 +10,9 @@ SESSION_EXPIRE_TIMEOUT=00:00:30
ALCONNA_USE_COMMAND_START=True
# ws连接密钥,若bot能被公网访问则建议打开该注释并设置该配置项
# ONEBOT_ACCESS_TOKEN=""
# 全局图片统一使用bytes发送,当真寻与协议端不在同一服务器上时为True
IMAGE_TO_BYTES = True
@@ -29,6 +32,7 @@ DB_URL = ""
# NONE: 不使用缓存, MEMORY: 使用内存缓存, REDIS: 使用Redis缓存
CACHE_MODE = NONE
# REDIS配置,使用REDIS替换Cache内存缓存
# REDIS地址
# REDIS_HOST = "127.0.0.1"
@@ -86,4 +90,4 @@ PORT = 8080
# '
# application_commands的{"*": ["*"]}代表将全部应用命令注册为全局应用命令
# {"admin": ["123", "456"]}则代表将admin命令注册为id是123、456服务器的局部命令,其余命令不注册
# {"admin": ["123", "456"]}则代表将admin命令注册为id是123、456服务器的局部命令,其余命令不注册
-3
View File
@@ -45,12 +45,9 @@ jobs:
include:
- language: python
build-mode: none
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
+1
View File
@@ -32,6 +32,7 @@ MANIFEST
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
!resources.spec
# Installer logs
pip-log.txt
+1 -1
View File
@@ -1 +1 @@
__version__: v0.2.4-3deffcb
__version__: v0.2.4-da6d5b4
-5483
View File
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -14,21 +14,21 @@ priority = "primary"
[tool.poetry.dependencies]
python = "^3.10"
playwright = "^1.41.1"
nonebot-adapter-onebot = "^2.3.1"
nonebot-adapter-onebot = ">=2.3.1"
nonebot-plugin-apscheduler = "^0.5"
tortoise-orm = { extras = ["asyncpg"], version = "^0.20.0" }
tortoise-orm = "^0.20.0"
cattrs = "^23.2.3"
ruamel-yaml = "^0.18.5"
strenum = "^0.4.15"
nonebot-plugin-session = "^0.2.3"
ujson = "^5.9.0"
nb-cli = "^1.3.0"
nonebot2 = { extras = ["fastapi"], version = "^2.3.3" }
nonebot-plugin-session = "^0.3.2"
ujson = ">=5.9.0"
nb-cli = ">=1.3.0"
nonebot2 = { extras = ["fastapi"], version = ">=2.3.3" }
pillow = "^10.0.0"
retrying = "^1.3.4"
aiofiles = "^23.2.1"
nonebot-plugin-htmlrender = ">=0.6.0,<1.0.0"
pypinyin = "^0.51.0"
pypinyin = ">=0.51.0"
beautifulsoup4 = "^4.12.3"
lxml = "^5.1.0"
psutil = "^5.9.8"
@@ -36,16 +36,20 @@ feedparser = "^6.0.11"
imagehash = "^4.3.1"
cn2an = "^0.5.22"
dateparser = "^1.2.0"
bilireq = "0.2.3post0"
python-jose = { extras = ["cryptography"], version = "^3.3.0" }
python-multipart = "^0.0.9"
aiocache = "^0.12.2"
aiocache = {extras = ["redis"], version = "^0.12.3"}
py-cpuinfo = "^9.0.0"
nonebot-plugin-alconna = "^0.54.0"
nonebot-plugin-alconna = ">=0.56.0"
tenacity = "^9.0.0"
nonebot-plugin-uninfo = ">0.4.1"
pydantic = "1.10.18"
nonebot-plugin-uninfo = ">=0.7.3"
nonebot-plugin-waiter = "^0.8.1"
multidict = ">=6.0.0,!=6.3.2"
pydantic = ">=1.0.0, <2.0.0"
redis = { version = ">=5", optional = true }
asyncpg = { version = ">=0.20.0", optional = true }
alibabacloud-devops20210625 = "^5.0.2"
json_repair = "^0.54.0"
[tool.poetry.group.dev.dependencies]
nonebug = "^0.4"
@@ -57,7 +61,6 @@ respx = "^0.21.1"
ruff = "^0.8.0"
pre-commit = "^4.0.0"
[tool.nonebot]
plugins = [
"nonebot_plugin_apscheduler",
-5580
View File
File diff suppressed because it is too large Load Diff
+16 -12
View File
@@ -14,21 +14,21 @@ priority = "primary"
[tool.poetry.dependencies]
python = "^3.10"
playwright = "^1.41.1"
nonebot-adapter-onebot = "^2.3.1"
nonebot-adapter-onebot = ">=2.3.1"
nonebot-plugin-apscheduler = "^0.5"
tortoise-orm = { extras = ["asyncpg"], version = "^0.20.0" }
tortoise-orm = "^0.20.0"
cattrs = "^23.2.3"
ruamel-yaml = "^0.18.5"
strenum = "^0.4.15"
nonebot-plugin-session = "^0.2.3"
ujson = "^5.9.0"
nb-cli = "^1.3.0"
nonebot2 = { extras = ["fastapi"], version = "^2.3.3" }
nonebot-plugin-session = "^0.3.2"
ujson = ">=5.9.0"
nb-cli = ">=1.3.0"
nonebot2 = { extras = ["fastapi"], version = ">=2.3.3" }
pillow = "^10.0.0"
retrying = "^1.3.4"
aiofiles = "^23.2.1"
nonebot-plugin-htmlrender = ">=0.6.0,<1.0.0"
pypinyin = "^0.51.0"
pypinyin = ">=0.51.0"
beautifulsoup4 = "^4.12.3"
lxml = "^5.1.0"
psutil = "^5.9.8"
@@ -36,16 +36,20 @@ feedparser = "^6.0.11"
imagehash = "^4.3.1"
cn2an = "^0.5.22"
dateparser = "^1.2.0"
bilireq = "0.2.3post0"
python-jose = { extras = ["cryptography"], version = "^3.3.0" }
python-multipart = "^0.0.9"
aiocache = "^0.12.2"
aiocache = {extras = ["redis"], version = "^0.12.3"}
py-cpuinfo = "^9.0.0"
nonebot-plugin-alconna = "^0.54.0"
nonebot-plugin-alconna = ">=0.56.0"
tenacity = "^9.0.0"
nonebot-plugin-uninfo = ">0.4.1"
pydantic = "2.10.6"
nonebot-plugin-uninfo = ">=0.7.3"
nonebot-plugin-waiter = "^0.8.1"
multidict = ">=6.0.0,!=6.3.2"
pydantic = ">=2.0.0, <3.0.0"
redis = { version = ">=5", optional = true }
asyncpg = { version = ">=0.20.0", optional = true }
alibabacloud-devops20210625 = "^5.0.2"
json_repair = "^0.54.0"
[tool.poetry.group.dev.dependencies]
nonebug = "^0.4"
Generated
+1156 -921
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -14,21 +14,21 @@ priority = "primary"
[tool.poetry.dependencies]
python = "^3.10"
playwright = "^1.41.1"
nonebot-adapter-onebot = "^2.3.1"
nonebot-adapter-onebot = ">=2.3.1"
nonebot-plugin-apscheduler = "^0.5"
tortoise-orm = "^0.20.0"
cattrs = "^23.2.3"
ruamel-yaml = "^0.18.5"
strenum = "^0.4.15"
nonebot-plugin-session = "^0.2.3"
ujson = "^5.9.0"
nb-cli = "^1.3.0"
nonebot2 = { extras = ["fastapi"], version = "^2.3.3" }
nonebot-plugin-session = "^0.3.2"
ujson = ">=5.9.0"
nb-cli = ">=1.3.0"
nonebot2 = { extras = ["fastapi"], version = ">=2.3.3" }
pillow = "^10.0.0"
retrying = "^1.3.4"
aiofiles = "^23.2.1"
nonebot-plugin-htmlrender = ">=0.6.0,<1.0.0"
pypinyin = "^0.51.0"
pypinyin = ">=0.51.0"
beautifulsoup4 = "^4.12.3"
lxml = "^5.1.0"
psutil = "^5.9.8"
@@ -36,16 +36,16 @@ feedparser = "^6.0.11"
imagehash = "^4.3.1"
cn2an = "^0.5.22"
dateparser = "^1.2.0"
bilireq = "0.2.3post0"
python-jose = { extras = ["cryptography"], version = "^3.3.0" }
python-multipart = "^0.0.9"
aiocache = {extras = ["redis"], version = "^0.12.3"}
py-cpuinfo = "^9.0.0"
nonebot-plugin-alconna = "^0.54.0"
nonebot-plugin-alconna = ">=0.56.0"
tenacity = "^9.0.0"
nonebot-plugin-uninfo = ">0.4.1"
nonebot-plugin-uninfo = ">=0.7.3"
nonebot-plugin-waiter = "^0.8.1"
multidict = ">=6.0.0,!=6.3.2"
json_repair = "^0.54.0"
redis = { version = ">=5", optional = true }
asyncpg = { version = ">=0.20.0", optional = true }
@@ -145,4 +145,4 @@ asyncio_default_fixture_loop_scope = "session"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
build-backend = "poetry.core.masonry.api"
+36 -131
View File
@@ -1,131 +1,36 @@
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"
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"
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"
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==1.8.35 ; python_version >= "3.10" and python_version < "4.0"
arrow==1.3.0 ; python_version >= "3.10" and python_version < "4.0"
async-timeout==5.0.1 ; python_version == "3.10"
asyncpg==0.30.0 ; python_version >= "3.10" and python_version < "4.0"
attrs==25.1.0 ; python_version >= "3.10" and python_version < "4.0"
beautifulsoup4==4.13.3 ; python_version >= "3.10" and python_version < "4.0"
bilireq==0.2.3.post0 ; python_version >= "3.10" and python_version < "4.0"
binaryornot==0.4.4 ; python_version >= "3.10" and python_version < "4.0"
cashews==7.4.0 ; python_version >= "3.10" and python_version < "4.0"
cattrs==23.2.3 ; python_version >= "3.10" and python_version < "4.0"
certifi==2025.1.31 ; python_version >= "3.10" and python_version < "4.0"
cffi==1.17.1 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy"
chardet==5.2.0 ; python_version >= "3.10" and python_version < "4.0"
charset-normalizer==3.4.1 ; python_version >= "3.10" and python_version < "4.0"
click==8.1.8 ; python_version >= "3.10" and python_version < "4.0"
cn2an==0.5.23 ; python_version >= "3.10" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and (platform_system == "Windows" or sys_platform == "win32")
cookiecutter==2.6.0 ; python_version >= "3.10" and python_version < "4.0"
cryptography==44.0.1 ; python_version >= "3.10" and python_version < "4.0"
dateparser==1.2.1 ; python_version >= "3.10" and python_version < "4.0"
distlib==0.3.9 ; python_version >= "3.10" and python_version < "4.0"
ecdsa==0.19.0 ; python_version >= "3.10" and python_version < "4.0"
exceptiongroup==1.2.2 ; python_version >= "3.10" and python_version < "4.0"
fastapi==0.115.8 ; python_version >= "3.10" and python_version < "4.0"
feedparser==6.0.11 ; python_version >= "3.10" and python_version < "4.0"
filelock==3.17.0 ; python_version >= "3.10" and python_version < "4.0"
greenlet==3.1.1 ; python_version >= "3.10" and python_version < "4.0"
grpcio==1.70.0 ; python_version >= "3.10" and python_version < "4.0"
h11==0.14.0 ; python_version >= "3.10" and python_version < "4.0"
httpcore==0.16.3 ; python_version >= "3.10" and python_version < "4.0"
httptools==0.6.4 ; python_version >= "3.10" and python_version < "4.0"
httpx==0.23.3 ; python_version >= "3.10" and python_version < "4.0"
idna==3.10 ; python_version >= "3.10" and python_version < "4.0"
imagehash==4.3.2 ; python_version >= "3.10" and python_version < "4.0"
importlib-metadata==8.6.1 ; python_version >= "3.10" and python_version < "4.0"
iso8601==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
jinja2==3.1.5 ; python_version >= "3.10" and python_version < "4.0"
loguru==0.7.3 ; python_version >= "3.10" and python_version < "4.0"
lxml==5.3.1 ; python_version >= "3.10" and python_version < "4.0"
markdown-it-py==3.0.0 ; python_version >= "3.10" and python_version < "4.0"
markdown==3.7 ; python_version >= "3.10" and python_version < "4.0"
markupsafe==3.0.2 ; python_version >= "3.10" and python_version < "4.0"
mdurl==0.1.2 ; python_version >= "3.10" and python_version < "4.0"
msgpack==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
multidict==6.1.0 ; python_version >= "3.10" and python_version < "4.0"
nb-cli==1.4.2 ; python_version >= "3.10" and python_version < "4.0"
nepattern==0.7.7 ; python_version >= "3.10" and python_version < "4.0"
nonebot-adapter-onebot==2.4.6 ; python_version >= "3.10" and python_version < "4.0"
nonebot-plugin-alconna==0.54.2 ; python_version >= "3.10" and python_version < "4.0"
nonebot-plugin-apscheduler==0.5.0 ; python_version >= "3.10" and python_version < "4.0"
nonebot-plugin-htmlrender==0.6.0 ; python_version >= "3.10" and python_version < "4.0"
nonebot-plugin-session==0.2.3 ; python_version >= "3.10" and python_version < "4.0"
nonebot-plugin-uninfo==0.6.8 ; python_version >= "3.10" and python_version < "4.0"
nonebot-plugin-waiter==0.8.1 ; python_version >= "3.10" and python_version < "4.0"
nonebot2==2.4.1 ; python_version >= "3.10" and python_version < "4.0"
nonebot2[fastapi]==2.4.1 ; python_version >= "3.10" and python_version < "4.0"
noneprompt==0.1.9 ; python_version >= "3.10" and python_version < "4.0"
numpy==2.2.2 ; python_version >= "3.10" and python_version < "4.0"
pillow==10.4.0 ; python_version >= "3.10" and python_version < "4.0"
platformdirs==4.3.6 ; python_version >= "3.10" and python_version < "4.0"
playwright==1.50.0 ; python_version >= "3.10" and python_version < "4.0"
proces==0.1.7 ; python_version >= "3.10" and python_version < "4.0"
prompt-toolkit==3.0.50 ; python_version >= "3.10" and python_version < "4.0"
propcache==0.2.1 ; python_version >= "3.10" and python_version < "4.0"
protobuf==4.25.6 ; python_version >= "3.10" and python_version < "4.0"
psutil==5.9.8 ; python_version >= "3.10" and python_version < "4.0"
py-cpuinfo==9.0.0 ; python_version >= "3.10" and python_version < "4.0"
pyasn1==0.6.1 ; python_version >= "3.10" and python_version < "4.0"
pycparser==2.22 ; python_version >= "3.10" and python_version < "4.0" and platform_python_implementation != "PyPy"
pydantic-core==2.27.2 ; python_version >= "3.10" and python_version < "4.0"
pydantic==2.10.6 ; python_version >= "3.10" and python_version < "4.0"
pyee==12.1.1 ; python_version >= "3.10" and python_version < "4.0"
pyfiglet==1.0.2 ; python_version >= "3.10" and python_version < "4.0"
pygments==2.19.1 ; python_version >= "3.10" and python_version < "4.0"
pygtrie==2.5.0 ; python_version >= "3.10" and python_version < "4.0"
pymdown-extensions==10.14.3 ; python_version >= "3.10" and python_version < "4.0"
pypika-tortoise==0.1.6 ; python_version >= "3.10" and python_version < "4.0"
pypinyin==0.51.0 ; python_version >= "3.10" and python_version < "4"
python-dateutil==2.9.0.post0 ; python_version >= "3.10" and python_version < "4.0"
python-dotenv==1.0.1 ; python_version >= "3.10" and python_version < "4.0"
python-jose[cryptography]==3.3.0 ; python_version >= "3.10" and python_version < "4.0"
python-markdown-math==0.8 ; python_version >= "3.10" and python_version < "4.0"
python-multipart==0.0.9 ; python_version >= "3.10" and python_version < "4.0"
python-slugify==8.0.4 ; python_version >= "3.10" and python_version < "4.0"
pytz==2025.1 ; python_version >= "3.10" and python_version < "4.0"
pywavelets==1.8.0 ; python_version >= "3.10" and python_version < "4.0"
pyyaml==6.0.2 ; python_version >= "3.10" and python_version < "4.0"
regex==2024.11.6 ; python_version >= "3.10" and python_version < "4.0"
requests==2.32.3 ; python_version >= "3.10" and python_version < "4.0"
retrying==1.3.4 ; python_version >= "3.10" and python_version < "4.0"
rfc3986[idna2008]==1.5.0 ; python_version >= "3.10" and python_version < "4.0"
rich==13.9.4 ; python_version >= "3.10" and python_version < "4.0"
rsa==4.9 ; python_version >= "3.10" and python_version < "4"
ruamel-yaml-clib==0.2.12 ; platform_python_implementation == "CPython" and python_version < "3.13" and python_version >= "3.10"
ruamel-yaml==0.18.10 ; python_version >= "3.10" and python_version < "4.0"
scipy==1.15.1 ; python_version >= "3.10" and python_version < "4.0"
sgmllib3k==1.0.0 ; python_version >= "3.10" and python_version < "4.0"
six==1.17.0 ; python_version >= "3.10" and python_version < "4.0"
sniffio==1.3.1 ; python_version >= "3.10" and python_version < "4.0"
soupsieve==2.6 ; python_version >= "3.10" and python_version < "4.0"
starlette==0.45.3 ; python_version >= "3.10" and python_version < "4.0"
strenum==0.4.15 ; python_version >= "3.10" and python_version < "4.0"
tarina==0.6.8 ; python_version >= "3.10" and python_version < "4.0"
tenacity==9.0.0 ; python_version >= "3.10" and python_version < "4.0"
text-unidecode==1.3 ; python_version >= "3.10" and python_version < "4.0"
tomli==2.2.1 ; python_version == "3.10"
tomlkit==0.13.2 ; python_version >= "3.10" and python_version < "4.0"
tortoise-orm[asyncpg]==0.20.0 ; python_version >= "3.10" and python_version < "4.0"
types-python-dateutil==2.9.0.20241206 ; python_version >= "3.10" and python_version < "4.0"
typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "4.0"
tzdata==2025.1 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
tzlocal==5.2 ; python_version >= "3.10" and python_version < "4.0"
ujson==5.10.0 ; python_version >= "3.10" and python_version < "4.0"
urllib3==2.3.0 ; python_version >= "3.10" and python_version < "4.0"
uvicorn[standard]==0.34.0 ; python_version >= "3.10" and python_version < "4.0"
uvloop==0.21.0 ; sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy" and python_version >= "3.10" and python_version < "4.0"
virtualenv==20.29.2 ; python_version >= "3.10" and python_version < "4.0"
watchfiles==0.24.0 ; python_version >= "3.10" and python_version < "4.0"
wcwidth==0.2.13 ; python_version >= "3.10" and python_version < "4.0"
websockets==14.2 ; python_version >= "3.10" and python_version < "4.0"
win32-setctime==1.2.0 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"
yarl==1.18.3 ; python_version >= "3.10" and python_version < "4.0"
zipp==3.21.0 ; python_version >= "3.10" and python_version < "4.0"
playwright>=1.41.1,<2.0.0
nonebot-adapter-onebot>=2.3.1
nonebot-plugin-apscheduler>=0.5,<0.6
tortoise-orm>=0.20.0,<0.21.0
cattrs>=23.2.3,<24.0.0
ruamel.yaml>=0.18.5,<0.19.0
strenum>=0.4.15,<0.5.0
nonebot-plugin-session>=0.3.2,<0.4.0
ujson>=5.9.0
nb-cli>=1.3.0
nonebot2[fastapi]>=2.3.3
Pillow>=10.0.0,<11.0.0
retrying>=1.3.4,<2.0.0
aiofiles>=23.2.1,<24.0.0
nonebot-plugin-htmlrender>=0.6.0,<1.0.0
pypinyin>=0.51.0
beautifulsoup4>=4.12.3,<5.0.0
lxml>=5.1.0,<6.0.0
psutil>=5.9.8,<6.0.0
feedparser>=6.0.11,<7.0.0
ImageHash>=4.3.1,<5.0.0
cn2an>=0.5.22,<0.6.0
dateparser>=1.2.0,<2.0.0
python-jose[cryptography]>=3.3.0,<4.0.0
python-multipart>=0.0.9,<0.1.0
aiocache[redis]>=0.12.3,<0.13.0
py-cpuinfo>=9.0.0,<10.0.0
nonebot-plugin-alconna>=0.56.0
tenacity>=9.0.0,<10.0.0
nonebot-plugin-uninfo>=0.7.3
nonebot-plugin-waiter>=0.8.1,<0.9.0
multidict>=6.0.0,<7.0.0,!=6.3.2
alibabacloud-devops20210625>=5.0.2,<6.0.0
json_repair>=0.54.0,<0.55.0
redis>=5
asyncpg>=0.20.0
+1
View File
@@ -0,0 +1 @@
require_resources_version: ">=1.0.0"
@@ -225,7 +225,7 @@ def init_mocker_path(mocker: MockerFixture, tmp_path: Path):
)
@pytest.mark.skip("不会修")
@pytest.mark.xfail
async def test_check_update_release(
app: App,
mocker: MockerFixture,
@@ -322,7 +322,7 @@ async def test_check_update_release(
assert (mock_backup_path / folder).exists()
@pytest.mark.skip("不会修")
@pytest.mark.xfail
async def test_check_update_main(
app: App,
mocker: MockerFixture,
+18 -20
View File
@@ -7,6 +7,7 @@ from typing import cast
from nonebot.adapters.onebot.v11 import Bot
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
from nonebug import App
import pytest
from pytest_mock import MockerFixture
from tests.config import BotId, GroupId, MessageId, UserId
@@ -64,9 +65,11 @@ def init_mocker(mocker: MockerFixture, tmp_path: Path):
mock_platform = mocker.patch("zhenxun.builtin_plugins.check.data_source.platform")
mock_platform.uname.return_value = platform_uname
mock_template_to_pic = mocker.patch("zhenxun.builtin_plugins.check.template_to_pic")
mock_template_to_pic_return = mocker.AsyncMock()
mock_template_to_pic.return_value = mock_template_to_pic_return
mock_render_service = mocker.patch(
"zhenxun.builtin_plugins.check.renderer_service.render"
)
mock_render_service_return = mocker.AsyncMock()
mock_render_service.return_value = mock_render_service_return
mock_build_message = mocker.patch(
"zhenxun.builtin_plugins.check.MessageUtils.build_message"
@@ -74,22 +77,18 @@ def init_mocker(mocker: MockerFixture, tmp_path: Path):
mock_build_message_return = mocker.AsyncMock()
mock_build_message.return_value = mock_build_message_return
mock_template_path_new = tmp_path / "resources" / "template"
mocker.patch(
"zhenxun.builtin_plugins.check.TEMPLATE_PATH", new=mock_template_path_new
)
return (
mock_psutil,
mock_cpuinfo,
mock_platform,
mock_template_to_pic,
mock_template_to_pic_return,
mock_render_service,
mock_render_service_return,
mock_build_message,
mock_build_message_return,
mock_template_path_new,
)
@pytest.mark.xfail
async def test_check(
app: App,
mocker: MockerFixture,
@@ -105,11 +104,10 @@ async def test_check(
mock_psutil,
mock_cpuinfo,
mock_platform,
mock_template_to_pic,
mock_template_to_pic_return,
mock_render_service,
mock_render_service_return,
mock_build_message,
mock_build_message_return,
mock_template_path_new,
) = init_mocker(mocker, tmp_path)
async with app.test_matcher(_self_check_matcher) as ctx:
bot = create_bot(ctx)
@@ -126,11 +124,12 @@ async def test_check(
ctx.receive_event(bot=bot, event=event)
ctx.should_ignore_rule(_self_check_matcher)
mock_template_to_pic.assert_awaited_once()
mock_build_message.assert_called_once_with(mock_template_to_pic_return)
mock_render_service.assert_awaited_once()
mock_build_message.assert_called_once_with(mock_render_service_return)
mock_build_message_return.send.assert_awaited_once()
@pytest.mark.xfail
async def test_check_arm(
app: App,
mocker: MockerFixture,
@@ -161,11 +160,10 @@ async def test_check_arm(
mock_psutil,
mock_cpuinfo,
mock_platform,
mock_template_to_pic,
mock_template_to_pic_return,
mock_render_service,
mock_render_service_return,
mock_build_message,
mock_build_message_return,
mock_template_path_new,
) = init_mocker(mocker, tmp_path)
mock_platform.uname.return_value = platform_uname_arm
@@ -199,6 +197,6 @@ async def test_check_arm(
mocker.call().decode().split().__getitem__().__float__(),
] # type: ignore
)
mock_template_to_pic.assert_awaited_once()
mock_build_message.assert_called_once_with(mock_template_to_pic_return)
mock_render_service.assert_awaited_once()
mock_build_message.assert_called_once_with(mock_render_service_return)
mock_build_message_return.send.assert_awaited_once()
@@ -6,6 +6,7 @@ from nonebot.adapters.onebot.v11 import Bot
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
from nonebot.adapters.onebot.v11.message import Message
from nonebug import App
import pytest
from pytest_mock import MockerFixture
from tests.config import BotId, GroupId, MessageId, UserId
@@ -14,6 +15,7 @@ from tests.utils import _v11_group_message_event
test_path = Path(__file__).parent.parent.parent
@pytest.mark.xfail
async def test_add_plugin_basic(
app: App,
mocker: MockerFixture,
@@ -60,6 +62,7 @@ async def test_add_plugin_basic(
assert (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file()
@pytest.mark.xfail
async def test_add_plugin_basic_commit_version(
app: App,
mocker: MockerFixture,
@@ -106,6 +109,7 @@ async def test_add_plugin_basic_commit_version(
assert (mock_base_path / "plugins" / "bilibili_sub" / "__init__.py").is_file()
@pytest.mark.xfail
async def test_add_plugin_basic_is_not_dir(
app: App,
mocker: MockerFixture,
@@ -152,6 +156,7 @@ async def test_add_plugin_basic_is_not_dir(
assert (mock_base_path / "plugins" / "jitang.py").is_file()
@pytest.mark.xfail
async def test_add_plugin_extra(
app: App,
mocker: MockerFixture,
@@ -198,6 +203,7 @@ async def test_add_plugin_extra(
assert (mock_base_path / "plugins" / "github_sub" / "__init__.py").is_file()
@pytest.mark.xfail
async def test_plugin_not_exist_add(
app: App,
create_bot: Callable,
@@ -236,6 +242,7 @@ async def test_plugin_not_exist_add(
)
@pytest.mark.xfail
async def test_add_plugin_exist(
app: App,
mocker: MockerFixture,
@@ -8,12 +8,14 @@ from nonebot.adapters.onebot.v11 import Bot
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
from nonebot.adapters.onebot.v11.message import Message
from nonebug import App
import pytest
from pytest_mock import MockerFixture
from tests.config import BotId, GroupId, MessageId, UserId
from tests.utils import _v11_group_message_event
@pytest.mark.xfail
async def test_remove_plugin(
app: App,
mocker: MockerFixture,
@@ -60,6 +62,7 @@ async def test_remove_plugin(
assert not (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file()
@pytest.mark.xfail
async def test_plugin_not_exist_remove(
app: App,
create_bot: Callable,
@@ -92,6 +95,7 @@ async def test_plugin_not_exist_remove(
)
@pytest.mark.xfail
async def test_remove_plugin_not_install(
app: App,
mocker: MockerFixture,
@@ -5,12 +5,14 @@ from nonebot.adapters.onebot.v11 import Bot
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
from nonebot.adapters.onebot.v11.message import Message
from nonebug import App
import pytest
from pytest_mock import MockerFixture
from tests.config import BotId, GroupId, MessageId, UserId
from tests.utils import _v11_group_message_event
@pytest.mark.xfail
async def test_search_plugin_name(
app: App,
mocker: MockerFixture,
@@ -52,6 +54,7 @@ async def test_search_plugin_name(
mock_build_message_return.send.assert_awaited_once()
@pytest.mark.xfail
async def test_search_plugin_author(
app: App,
mocker: MockerFixture,
@@ -93,6 +96,7 @@ async def test_search_plugin_author(
mock_build_message_return.send.assert_awaited_once()
@pytest.mark.xfail
async def test_plugin_not_exist_search(
app: App,
create_bot: Callable,
@@ -6,12 +6,14 @@ from nonebot.adapters.onebot.v11 import Bot
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
from nonebot.adapters.onebot.v11.message import Message
from nonebug import App
import pytest
from pytest_mock import MockerFixture
from tests.config import BotId, GroupId, MessageId, UserId
from tests.utils import _v11_group_message_event
@pytest.mark.xfail
async def test_update_all_plugin_basic_need_update(
app: App,
mocker: MockerFixture,
@@ -62,6 +64,7 @@ async def test_update_all_plugin_basic_need_update(
assert (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file()
@pytest.mark.xfail
async def test_update_all_plugin_basic_is_new(
app: App,
mocker: MockerFixture,
@@ -6,13 +6,14 @@ from nonebot.adapters.onebot.v11 import Bot
from nonebot.adapters.onebot.v11.event import GroupMessageEvent
from nonebot.adapters.onebot.v11.message import Message
from nonebug import App
import pytest
from pytest_mock import MockerFixture
from respx import MockRouter
from tests.config import BotId, GroupId, MessageId, UserId
from tests.utils import _v11_group_message_event
@pytest.mark.xfail
async def test_update_plugin_basic_need_update(
app: App,
mocker: MockerFixture,
@@ -63,6 +64,7 @@ async def test_update_plugin_basic_need_update(
assert (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file()
@pytest.mark.xfail
async def test_update_plugin_basic_is_new(
app: App,
mocker: MockerFixture,
@@ -112,6 +114,7 @@ async def test_update_plugin_basic_is_new(
)
@pytest.mark.xfail
async def test_plugin_not_exist_update(
app: App,
create_bot: Callable,
@@ -150,9 +153,9 @@ async def test_plugin_not_exist_update(
)
@pytest.mark.xfail
async def test_update_plugin_not_install(
app: App,
mocked_api: MockRouter,
create_bot: Callable,
) -> None:
"""
@@ -2,18 +2,14 @@ from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import Alconna, Arparma, on_alconna
from nonebot_plugin_session import EventSession
from zhenxun.configs.config import Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.configs.utils import PluginExtraData
from zhenxun.services.help_service import create_plugin_help_image
from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType
from zhenxun.utils.exception import EmptyError
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.rules import admin_check, ensure_group
from .config import ADMIN_HELP_IMAGE
from .html_help import build_html_help
from .normal_help import build_help
__plugin_meta__ = PluginMetadata(
name="群组管理员帮助",
description="管理员帮助列表",
@@ -30,17 +26,19 @@ __plugin_meta__ = PluginMetadata(
precautions=[
"只有群主/群管理 才能使用哦,群主拥有6级权限,管理员拥有5级权限!"
],
configs=[
RegisterConfig(
key="type",
value="zhenxun",
help="管理员帮助样式,normal, zhenxun",
default_value="zhenxun",
)
],
configs=[],
).to_dict(),
)
async def build_html_help() -> bytes:
"""构建管理员帮助图片"""
return await create_plugin_help_image(
plugin_types=[PluginType.ADMIN, PluginType.SUPER_AND_ADMIN],
page_title="群管理员帮助手册",
)
_matcher = on_alconna(
Alconna("管理员帮助"),
rule=admin_check(1) & ensure_group,
@@ -54,15 +52,9 @@ async def _(
session: EventSession,
arparma: Arparma,
):
if not ADMIN_HELP_IMAGE.exists():
try:
if Config.get_config("admin_help", "type") == "zhenxun":
await build_html_help()
else:
await build_help()
except EmptyError:
await MessageUtils.build_message("当前管理员帮助为空...").finish(
reply_to=True
)
await MessageUtils.build_message(ADMIN_HELP_IMAGE).send()
try:
image_bytes = await build_html_help()
await MessageUtils.build_message(image_bytes).send()
except EmptyError:
await MessageUtils.build_message("当前管理员帮助为空...").finish(reply_to=True)
logger.info("查看管理员帮助", arparma.header_result, session=session)
@@ -1,23 +0,0 @@
from nonebot.plugin import PluginMetadata
from pydantic import BaseModel
from zhenxun.configs.path_config import IMAGE_PATH
from zhenxun.models.plugin_info import PluginInfo
ADMIN_HELP_IMAGE = IMAGE_PATH / "ADMIN_HELP.png"
if ADMIN_HELP_IMAGE.exists():
ADMIN_HELP_IMAGE.unlink()
class PluginData(BaseModel):
"""
插件信息
"""
plugin: PluginInfo
"""插件信息"""
metadata: PluginMetadata
"""元数据"""
class Config:
arbitrary_types_allowed = True
@@ -1,57 +0,0 @@
from nonebot_plugin_htmlrender import template_to_pic
from zhenxun.builtin_plugins.admin.admin_help.config import ADMIN_HELP_IMAGE
from zhenxun.configs.config import BotConfig
from zhenxun.configs.path_config import TEMPLATE_PATH
from zhenxun.models.task_info import TaskInfo
from zhenxun.utils._build_image import BuildImage
from .utils import get_plugins
async def get_task() -> dict[str, str] | None:
"""获取被动技能帮助"""
if task_list := await TaskInfo.all():
return {
"name": "被动技能",
"description": "控制群组中的被动技能状态",
"usage": "通过 开启/关闭群被动 来控制群被动 <br>"
+ " 示例:开启/关闭群被动早晚安 <br> 示例:开启/关闭全部群被动"
+ " <br> ---------- <br> "
+ "<br>".join([task.name for task in task_list]),
}
return None
async def build_html_help():
"""构建帮助图片"""
plugins = await get_plugins()
plugin_list = [
{
"name": data.plugin.name,
"description": data.metadata.description.replace("\n", "<br>"),
"usage": data.metadata.usage.replace("\n", "<br>"),
}
for data in plugins
]
if task := await get_task():
plugin_list.append(task)
plugin_list.sort(key=lambda p: len(p["description"]) + len(p["usage"]))
pic = await template_to_pic(
template_path=str((TEMPLATE_PATH / "help").absolute()),
template_name="main.html",
templates={
"data": {
"plugin_list": plugin_list,
"nickname": BotConfig.self_nickname,
"help_name": "群管理员",
}
},
pages={
"viewport": {"width": 824, "height": 10},
"base_url": f"file://{TEMPLATE_PATH}",
},
wait=2,
)
result = await BuildImage.open(pic).resize(0.5)
await result.save(ADMIN_HELP_IMAGE)
@@ -1,127 +0,0 @@
from nonebot.plugin import PluginMetadata
from PIL.ImageFont import FreeTypeFont
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.task_info import TaskInfo
from zhenxun.services.log import logger
from zhenxun.utils._build_image import BuildImage
from zhenxun.utils.image_utils import build_sort_image, group_image, text2image
from .config import ADMIN_HELP_IMAGE
from .utils import get_plugins
async def build_usage_des_image(
metadata: PluginMetadata,
) -> tuple[BuildImage | None, BuildImage | None]:
"""构建用法和描述图片
参数:
metadata: PluginMetadata
返回:
tuple[BuildImage | None, BuildImage | None]: 用法和描述图片
"""
usage = None
description = None
if metadata.usage:
usage = await text2image(
metadata.usage,
padding=5,
color=(255, 255, 255),
font_color=(0, 0, 0),
)
if metadata.description:
description = await text2image(
metadata.description,
padding=5,
color=(255, 255, 255),
font_color=(0, 0, 0),
)
return usage, description
async def build_image(
plugin: PluginInfo, metadata: PluginMetadata, font: FreeTypeFont
) -> BuildImage:
"""构建帮助图片
参数:
plugin: PluginInfo
metadata: PluginMetadata
font: FreeTypeFont
返回:
BuildImage: 帮助图片
"""
usage, description = await build_usage_des_image(metadata)
width = 0
height = 100
if usage:
width = usage.width
height += usage.height
if description and description.width > width:
width = description.width
height += description.height
font_width, _ = BuildImage.get_text_size(f"{plugin.name}[{plugin.level}]", font)
if font_width > width:
width = font_width
A = BuildImage(width + 30, height + 120, "#EAEDF2")
await A.text((15, 10), f"{plugin.name}[{plugin.level}]")
await A.text((15, 70), "简介:")
if not description:
description = BuildImage(A.width - 30, 30, (255, 255, 255))
await description.circle_corner(10)
await A.paste(description, (15, 100))
if not usage:
usage = BuildImage(A.width - 30, 30, (255, 255, 255))
await usage.circle_corner(10)
await A.text((15, description.height + 115), "用法:")
await A.paste(usage, (15, description.height + 145))
await A.circle_corner(10)
return A
async def build_help():
"""构造管理员帮助图片
返回:
BuildImage: 管理员帮助图片
"""
font = BuildImage.load_font("HYWenHei-85W.ttf", 20)
image_list = []
for data in await get_plugins():
plugin = data.plugin
metadata = data.metadata
try:
A = await build_image(plugin, metadata, font)
image_list.append(A)
except Exception as e:
logger.warning(
f"获取群管理员插件 {plugin.module}: {plugin.name} 设置失败...",
"管理员帮助",
e=e,
)
if task_list := await TaskInfo.all():
task_str = "\n".join([task.name for task in task_list])
task_str = "通过 开启/关闭群被动 来控制群被动\n----------\n" + task_str
task_image = await text2image(task_str, padding=5, color=(255, 255, 255))
await task_image.circle_corner(10)
A = BuildImage(task_image.width + 50, task_image.height + 85, "#EAEDF2")
await A.text((25, 10), "被动技能")
await A.paste(task_image, (25, 50))
await A.circle_corner(10)
image_list.append(A)
image_group, _ = group_image(image_list)
A = await build_sort_image(image_group, color=(255, 255, 255), padding_top=160)
text = await BuildImage.build_text_image(
"群管理员帮助",
size=40,
)
tip = await BuildImage.build_text_image(
"注: ‘*’ 代表可有多个相同参数 ‘?’ 代表可省略该参数", size=25, font_color="red"
)
await A.paste(text, (50, 30))
await A.paste(tip, (50, 90))
await A.save(ADMIN_HELP_IMAGE)
@@ -1,22 +0,0 @@
import nonebot
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.utils.enum import PluginType
from zhenxun.utils.exception import EmptyError
from .config import PluginData
async def get_plugins() -> list[PluginData]:
"""获取插件数据"""
plugin_list = await PluginInfo.filter(
plugin_type__in=[PluginType.ADMIN, PluginType.SUPER_AND_ADMIN]
).all()
data_list = []
for plugin in plugin_list:
if _plugin := nonebot.get_plugin_by_module_name(plugin.module_path):
if _plugin.metadata:
data_list.append(PluginData(plugin=plugin, metadata=_plugin.metadata))
if not data_list:
raise EmptyError()
return data_list
@@ -1,7 +1,11 @@
import asyncio
import random
import nonebot
from nonebot import on_notice
from nonebot.adapters import Bot
from nonebot.adapters.onebot.v11 import GroupIncreaseNoticeEvent
from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import Alconna, Arparma, on_alconna
from nonebot_plugin_apscheduler import scheduler
@@ -10,6 +14,7 @@ from nonebot_plugin_session import EventSession
from zhenxun.configs.config import BotConfig
from zhenxun.configs.utils import PluginExtraData
from zhenxun.services.log import logger
from zhenxun.services.tags import tag_manager
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
@@ -45,12 +50,79 @@ _matcher = on_alconna(
_notice = on_notice(priority=1, block=False, rule=notice_rule(GroupIncreaseNoticeEvent))
_update_all_matcher = on_alconna(
Alconna("更新所有群组信息"),
permission=SUPERUSER,
priority=1,
block=True,
)
async def _update_all_groups_task(bot: Bot, session: EventSession):
"""
在后台执行所有群组的更新任务,并向超级用户发送最终报告。
"""
success_count = 0
fail_count = 0
total_count = 0
bot_id = bot.self_id
logger.info(f"Bot {bot_id}: 开始执行所有群组信息更新任务...", "更新所有群组")
try:
group_list, _ = await PlatformUtils.get_group_list(bot)
total_count = len(group_list)
for i, group in enumerate(group_list):
try:
logger.debug(
f"Bot {bot_id}: 正在更新第 {i + 1}/{total_count} 个群组: "
f"{group.group_id}",
"更新所有群组",
)
await MemberUpdateManage.update_group_member(bot, group.group_id)
success_count += 1
except Exception as e:
fail_count += 1
logger.error(
f"Bot {bot_id}: 更新群组 {group.group_id} 信息失败",
"更新所有群组",
e=e,
)
await asyncio.sleep(random.uniform(1.5, 3.0))
except Exception as e:
logger.error(f"Bot {bot_id}: 获取群组列表失败,任务中断", "更新所有群组", e=e)
await PlatformUtils.send_superuser(
bot,
f"Bot {bot_id} 更新所有群组信息任务失败:无法获取群组列表。",
session.id1,
)
return
await tag_manager._invalidate_cache()
summary_message = (
f"🤖 Bot {bot_id} 所有群组信息更新任务完成!\n"
f"总计群组: {total_count}\n"
f"✅ 成功: {success_count}\n"
f"❌ 失败: {fail_count}"
)
logger.info(summary_message.replace("\n", " | "), "更新所有群组")
await PlatformUtils.send_superuser(bot, summary_message, session.id1)
@_update_all_matcher.handle()
async def _(bot: Bot, session: EventSession):
await MessageUtils.build_message(
"已开始在后台更新所有群组信息,过程可能需要几分钟到几十分钟,完成后将私聊通知您。"
).send(reply_to=True)
asyncio.create_task(_update_all_groups_task(bot, session)) # noqa: RUF006
@_matcher.handle()
async def _(bot: Bot, session: EventSession, arparma: Arparma):
if gid := session.id3 or session.id2:
logger.info("更新群组成员信息", arparma.header_result, session=session)
result = await MemberUpdateManage.update_group_member(bot, gid)
await MessageUtils.build_message(result).finish(reply_to=True)
await tag_manager._invalidate_cache()
await MessageUtils.build_message("群组id为空...").send()
@@ -64,6 +136,7 @@ async def _(bot: Bot, event: GroupIncreaseNoticeEvent):
session=event.user_id,
group_id=event.group_id,
)
await tag_manager._invalidate_cache()
@scheduler.scheduled_job(
@@ -91,3 +164,5 @@ async def _():
except Exception as e:
logger.error(f"Bot: {bot.self_id} 自动更新群组信息", e=e)
logger.debug(f"自动 Bot: {bot.self_id} 更新群组成员信息成功...")
await tag_manager._invalidate_cache()
@@ -6,6 +6,7 @@ from nonebot.adapters import Bot
from nonebot_plugin_uninfo import Member, SceneType, get_interface
from zhenxun.configs.config import Config
from zhenxun.models.group_console import GroupConsole
from zhenxun.models.group_member_info import GroupInfoUser
from zhenxun.models.level_user import LevelUser
from zhenxun.services.log import logger
@@ -94,6 +95,25 @@ class MemberUpdateManage:
)
return "更新群组失败,群组不存在..."
members = await interface.get_members(SceneType.GROUP, group_list[0].id)
try:
group_console, _ = await GroupConsole.get_or_create(
group_id=group_id, defaults={"platform": platform}
)
group_console.member_count = len(members)
group_console.group_name = group_list[0].name or ""
await group_console.save(update_fields=["member_count", "group_name"])
logger.debug(
f"已更新群组 {group_id} 的成员总数为 {len(members)}",
"更新群组成员信息",
)
except Exception as e:
logger.error(
f"更新群组 {group_id} 的 GroupConsole 信息失败",
"更新群组成员信息",
e=e,
)
db_user = await GroupInfoUser.filter(group_id=group_id).all()
db_user_uid = [u.user_id for u in db_user]
data_list = ([], [], [])
@@ -9,7 +9,7 @@ from zhenxun.services.log import logger
from zhenxun.utils.enum import BlockType, PluginType
from zhenxun.utils.message import MessageUtils
from ._data_source import PluginManager, build_plugin, build_task, delete_help_image
from ._data_source import PluginManager, build_plugin, build_task
from .command import _group_status_matcher, _status_matcher
base_config = Config.get("plugin_switch")
@@ -154,7 +154,6 @@ async def _(
else:
result = await PluginManager.unblock_group_plugin(name, group_id)
logger.info(f"开启功能 {name}", arparma.header_result, session=session)
delete_help_image(group_id)
await MessageUtils.build_message(result).finish(reply_to=True)
elif session.user.id in bot.config.superusers:
"""私聊"""
@@ -218,7 +217,6 @@ async def _(
session=session,
target=group_id,
)
delete_help_image()
await MessageUtils.build_message(result).finish(reply_to=True)
@@ -266,7 +264,6 @@ async def _(
else:
result = await PluginManager.block_group_plugin(name, group_id)
logger.info(f"关闭功能 {name}", arparma.header_result, session=session)
delete_help_image(group_id)
await MessageUtils.build_message(result).finish(reply_to=True)
elif session.user.id in bot.config.superusers:
group_id = group.result if group.available else None
@@ -338,7 +335,6 @@ async def _(
session=session,
target=group_id,
)
delete_help_image()
await MessageUtils.build_message(result).finish(reply_to=True)
@@ -1,7 +1,5 @@
import os
from typing import cast
from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH
from zhenxun.models.group_console import GroupConsole
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.task_info import TaskInfo
@@ -11,23 +9,6 @@ from zhenxun.utils.enum import BlockType, CacheType, PluginType
from zhenxun.utils.exception import GroupInfoNotFound
from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle
HELP_FILE = IMAGE_PATH / "SIMPLE_HELP.png"
GROUP_HELP_PATH = DATA_PATH / "group_help"
def delete_help_image(gid: str | None = None):
"""删除帮助图片"""
if gid:
for file in os.listdir(GROUP_HELP_PATH):
if file.startswith(f"{gid}"):
os.remove(GROUP_HELP_PATH / file)
else:
if HELP_FILE.exists():
HELP_FILE.unlink()
for file in GROUP_HELP_PATH.iterdir():
file.unlink()
def plugin_row_style(column: str, text: str) -> RowStyle:
"""被动技能文本风格
@@ -84,13 +84,16 @@ async def _(
):
result = ""
await MessageUtils.build_message("正在进行检查更新...").send(reply_to=True)
if not ver_type.available:
result += await UpdateManager.check_version()
logger.info("查看当前版本...", "检查更新", session=session)
await MessageUtils.build_message(result).finish()
return
ver_type_str = ver_type.result
source_str = source.result
if ver_type_str in {"main", "release"}:
if not ver_type.available:
result += await UpdateManager.check_version()
logger.info("查看当前版本...", "检查更新", session=session)
await MessageUtils.build_message(result).finish()
try:
result += await UpdateManager.update_zhenxun(
bot,
@@ -1,37 +1,135 @@
import asyncio
from typing import Literal
from nonebot.adapters import Bot
from packaging.specifiers import SpecifierSet
from packaging.version import InvalidVersion, Version
from zhenxun.services.log import logger
from zhenxun.utils.http_utils import AsyncHttpx
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
from zhenxun.utils.manager.zhenxun_repo_manager import (
ZhenxunRepoConfig,
ZhenxunRepoManager,
)
from zhenxun.utils.platform import PlatformUtils
from zhenxun.utils.repo_utils import RepoFileManager
LOG_COMMAND = "AutoUpdate"
class UpdateManager:
@staticmethod
async def _get_latest_commit_date(owner: str, repo: str, path: str) -> str:
"""获取文件最新 commit 日期"""
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits"
params = {"path": path, "page": 1, "per_page": 1}
try:
data = await AsyncHttpx.get_json(api_url, params=params)
if data and isinstance(data, list) and data[0]:
date_str = data[0]["commit"]["committer"]["date"]
return date_str.split("T")[0]
except Exception as e:
logger.warning(f"获取 {owner}/{repo}/{path} 的 commit 日期失败", e=e)
return "获取失败"
@classmethod
async def check_version(cls) -> str:
"""检查更新版本
"""检查真寻和资源的版本"""
bot_cur_version = cls.__get_version()
返回:
str: 更新信息
"""
cur_version = cls.__get_version()
release_data = await ZhenxunRepoManager.zhenxun_get_latest_releases_data()
if not release_data:
return "检查更新获取版本失败..."
return (
"检测到当前版本更新\n"
f"当前版本:{cur_version}\n"
f"最新版本:{release_data.get('name')}\n"
f"创建日期:{release_data.get('created_at')}\n"
f"更新内容:\n{release_data.get('body')}"
release_task = ZhenxunRepoManager.zhenxun_get_latest_releases_data()
dev_version_task = RepoFileManager.get_file_content(
ZhenxunRepoConfig.ZHENXUN_BOT_GITHUB_URL, "__version__"
)
bot_commit_date_task = cls._get_latest_commit_date(
"HibiKier", "zhenxun_bot", "__version__"
)
res_commit_date_task = cls._get_latest_commit_date(
"zhenxun-org", "zhenxun-bot-resources", "__version__"
)
(
release_data,
dev_version_text,
bot_commit_date,
res_commit_date,
) = await asyncio.gather(
release_task,
dev_version_task,
bot_commit_date_task,
res_commit_date_task,
return_exceptions=True,
)
if isinstance(release_data, dict):
bot_release_version = release_data.get("name", "获取失败")
bot_release_date = release_data.get("created_at", "").split("T")[0]
else:
bot_release_version = "获取失败"
bot_release_date = "获取失败"
logger.warning(f"获取 Bot release 信息失败: {release_data}")
if isinstance(dev_version_text, str):
bot_dev_version = dev_version_text.split(":")[-1].strip()
else:
bot_dev_version = "获取失败"
bot_commit_date = "获取失败"
logger.warning(f"获取 Bot dev 版本信息失败: {dev_version_text}")
bot_update_hint = ""
try:
cur_base_v = bot_cur_version.split("-")[0].lstrip("v")
dev_base_v = bot_dev_version.split("-")[0].lstrip("v")
if Version(cur_base_v) < Version(dev_base_v):
bot_update_hint = "\n-> 发现新开发版本, 可用 `检查更新 main` 更新"
elif (
Version(cur_base_v) == Version(dev_base_v)
and bot_cur_version != bot_dev_version
):
bot_update_hint = "\n-> 发现新开发版本, 可用 `检查更新 main` 更新"
except (InvalidVersion, TypeError, IndexError):
if bot_cur_version != bot_dev_version and bot_dev_version != "获取失败":
bot_update_hint = "\n-> 发现新开发版本, 可用 `检查更新 main` 更新"
bot_update_info = (
f"当前版本: {bot_cur_version}\n"
f"最新开发版: {bot_dev_version} (更新于: {bot_commit_date})\n"
f"最新正式版: {bot_release_version} (发布于: {bot_release_date})"
f"{bot_update_hint}"
)
res_version_file = ZhenxunRepoConfig.RESOURCE_PATH / "__version__"
res_cur_version = "未找到"
if res_version_file.exists():
if text := res_version_file.open(encoding="utf8").readline():
res_cur_version = text.split(":")[-1].strip()
res_latest_version = "获取失败"
try:
res_latest_version_text = await RepoFileManager.get_file_content(
ZhenxunRepoConfig.RESOURCE_GITHUB_URL, "__version__"
)
res_latest_version = res_latest_version_text.split(":")[-1].strip()
except Exception as e:
res_commit_date = "获取失败"
logger.warning(f"获取资源版本信息失败: {e}")
res_update_hint = ""
try:
if Version(res_cur_version) < Version(res_latest_version):
res_update_hint = "\n-> 发现新资源版本, 可用 `检查更新 resource` 更新"
except (InvalidVersion, TypeError):
pass
res_update_info = (
f"当前版本: {res_cur_version}\n"
f"最新版本: {res_latest_version} (更新于: {res_commit_date})"
f"{res_update_hint}"
)
return f"『绪山真寻 Bot』\n{bot_update_info}\n\n『真寻资源』\n{res_update_info}"
@classmethod
async def update_webui(
@@ -125,6 +223,7 @@ class UpdateManager:
f"检测真寻已更新,当前版本:{cur_version}\n开始更新...",
user_id,
)
result_message = ""
if zip:
new_version = await ZhenxunRepoManager.zhenxun_zip_update(version_type)
await PlatformUtils.send_superuser(
@@ -133,7 +232,7 @@ class UpdateManager:
await VirtualEnvPackageManager.install_requirement(
ZhenxunRepoConfig.REQUIREMENTS_FILE
)
return (
result_message = (
f"版本更新完成!\n版本: {cur_version} -> {new_version}\n"
"请重新启动真寻以完成更新!"
)
@@ -155,13 +254,54 @@ class UpdateManager:
await VirtualEnvPackageManager.install_requirement(
ZhenxunRepoConfig.REQUIREMENTS_FILE
)
return (
result_message = (
f"版本更新完成!\n"
f"版本: {cur_version} -> {result.new_version}\n"
f"变更文件个数: {len(result.changed_files)}"
f"{'' if source == 'git' else '(阿里云更新不支持查看变更文件)'}\n"
"请重新启动真寻以完成更新!"
)
resource_warning = ""
if version_type == "main":
try:
spec_content = await RepoFileManager.get_file_content(
ZhenxunRepoConfig.ZHENXUN_BOT_GITHUB_URL, "resources.spec"
)
required_spec_str = None
for line in spec_content.splitlines():
if line.startswith("require_resources_version:"):
required_spec_str = line.split(":", 1)[1].strip().strip("\"'")
break
if required_spec_str:
res_version_file = ZhenxunRepoConfig.RESOURCE_PATH / "__version__"
local_res_version_str = "0.0.0"
if res_version_file.exists():
if text := res_version_file.open(encoding="utf8").readline():
local_res_version_str = text.split(":")[-1].strip()
spec = SpecifierSet(required_spec_str)
local_ver = Version(local_res_version_str)
if not spec.contains(local_ver):
warning_header = (
f"⚠️ **资源版本不兼容!**\n"
f"当前代码需要资源版本: `{required_spec_str}`\n"
f"您当前的资源版本是: `{local_res_version_str}`\n"
"**将自动为您更新资源文件...**"
)
await PlatformUtils.send_superuser(bot, warning_header, user_id)
resource_update_source = None if zip else source
resource_update_result = await cls.update_resources(
source=resource_update_source, force=force
)
resource_warning = (
f"\n\n{warning_header}\n{resource_update_result}"
)
except Exception as e:
logger.warning(f"检查资源版本兼容性时出错: {e}", LOG_COMMAND, e=e)
resource_warning = (
"\n\n⚠️ 检查资源版本兼容性时出错,建议手动运行 `检查更新 resource`"
)
return result_message + resource_warning
@classmethod
def __get_version(cls) -> str:
@@ -1,5 +1,4 @@
from datetime import datetime, timedelta
from io import BytesIO
from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import (
@@ -15,15 +14,17 @@ from nonebot_plugin_alconna import (
from nonebot_plugin_session import EventSession
import pytz
from zhenxun import ui
from zhenxun.configs.config import Config
from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig
from zhenxun.models.chat_history import ChatHistory
from zhenxun.models.group_member_info import GroupInfoUser
from zhenxun.services import avatar_service
from zhenxun.services.log import logger
from zhenxun.ui.builders import TableBuilder
from zhenxun.ui.models import ImageCell, TextCell
from zhenxun.utils.enum import PluginType
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
__plugin_meta__ = PluginMetadata(
name="消息统计",
@@ -123,64 +124,63 @@ async def _(
if rank_data := await ChatHistory.get_group_msg_rank(
group_id, fetch_count, "DES" if arparma.find("des") else "DESC", date_scope
):
idx = 1
data_list = []
rows_data = []
platform = "qq"
for uid, num in rank_data:
if len(data_list) >= count.result:
user_ids_in_rank = [str(uid) for uid, _ in rank_data]
users_in_group_query = GroupInfoUser.filter(
user_id__in=user_ids_in_rank, group_id=group_id
)
users_in_group = {u.user_id: u for u in await users_in_group_query}
for idx, (uid, num) in enumerate(rank_data):
if len(rows_data) >= count.result:
break
user_in_group = await GroupInfoUser.filter(
user_id=uid, group_id=group_id
).first()
uid_str = str(uid)
user_in_group = users_in_group.get(uid_str)
if not user_in_group and not show_quit_member:
continue
if user_in_group:
user_name = user_in_group.user_name
else:
user_name = f"{uid}(已退群)"
user_name = (
user_in_group.user_name if user_in_group else f"{uid_str}(已退群)"
)
avatar_size = 40
try:
avatar_bytes = await PlatformUtils.get_user_avatar(str(uid), "qq")
if avatar_bytes:
avatar_img = BuildImage(
avatar_size, avatar_size, background=BytesIO(avatar_bytes)
)
await avatar_img.circle()
avatar_tuple = (avatar_img, avatar_size, avatar_size)
else:
avatar_img = BuildImage(avatar_size, avatar_size, color="#CCCCCC")
await avatar_img.circle()
avatar_tuple = (avatar_img, avatar_size, avatar_size)
except Exception as e:
logger.warning(f"获取用户头像失败: {e}", "chat_history")
avatar_img = BuildImage(avatar_size, avatar_size, color="#CCCCCC")
await avatar_img.circle()
avatar_tuple = (avatar_img, avatar_size, avatar_size)
avatar_path = await avatar_service.get_avatar_path(platform, uid_str)
data_list.append([idx, avatar_tuple, user_name, num])
idx += 1
rows_data.append(
[
TextCell(content=str(len(rows_data) + 1)),
ImageCell(
src=avatar_path.as_uri() if avatar_path else "", shape="circle"
),
TextCell(content=user_name),
TextCell(content=str(num), bold=True),
]
)
if not date_scope:
if date_scope := await ChatHistory.get_group_first_msg_datetime(group_id):
date_scope = date_scope.astimezone(
first_msg_time = await ChatHistory.get_group_first_msg_datetime(group_id)
if first_msg_time:
date_scope_start = first_msg_time.astimezone(
pytz.timezone("Asia/Shanghai")
).replace(microsecond=0)
date_str = f"{str(date_scope_start).split('+')[0]} - 至今"
else:
date_scope = time_now.replace(microsecond=0)
date_str = f"{str(date_scope).split('+')[0]} - 至今"
date_str = f"{time_now.replace(microsecond=0)} - 至今"
else:
date_str = (
f"{date_scope[0].replace(microsecond=0)} - "
f"{date_scope[1].replace(microsecond=0)}"
)
A = await ImageTemplate.table_page(
f"消息排行({count.result})", date_str, column_name, data_list
)
builder = TableBuilder(f"消息排行({count.result})", date_str)
builder.set_headers(column_name).add_rows(rows_data)
image_bytes = await ui.render(builder.build())
logger.info(
f"查看消息排行 数量={count.result}", arparma.header_result, session=session
)
await MessageUtils.build_message(A).finish(reply_to=True)
await MessageUtils.build_message(image_bytes).finish(reply_to=True)
await MessageUtils.build_message("群组消息记录为空...").finish()
+9 -14
View File
@@ -4,10 +4,9 @@ from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata
from nonebot.rule import Rule, to_me
from nonebot_plugin_alconna import Alconna, on_alconna
from nonebot_plugin_htmlrender import template_to_pic
from zhenxun import ui
from zhenxun.configs.config import Config
from zhenxun.configs.path_config import TEMPLATE_PATH
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType
@@ -27,7 +26,7 @@ __plugin_meta__ = PluginMetadata(
""".strip(),
extra=PluginExtraData(
author="HibiKier",
version="0.1",
version="0.2",
plugin_type=PluginType.SUPERUSER,
configs=[
RegisterConfig(
@@ -67,18 +66,14 @@ _self_check_poke_matcher = on_notice(
async def handle_self_check():
try:
data = await get_status_info()
image = await template_to_pic(
template_path=str((TEMPLATE_PATH / "check").absolute()),
template_name="main.html",
templates={"data": data},
pages={
"viewport": {"width": 195, "height": 750},
"base_url": f"file://{TEMPLATE_PATH}",
},
wait=2,
data_dict = await get_status_info()
image_bytes = await ui.render_template(
"pages/builtin/check",
data=data_dict,
)
await MessageUtils.build_message(image).send()
await MessageUtils.build_message(image_bytes).send()
logger.info("自检成功", "自检")
except Exception as e:
await MessageUtils.build_message(f"自检失败: {e}").send()
+45 -35
View File
@@ -1,3 +1,4 @@
import contextlib
from dataclasses import dataclass
import os
from pathlib import Path
@@ -18,7 +19,47 @@ BAIDU_URL = "https://www.baidu.com/"
GOOGLE_URL = "https://www.google.com/"
VERSION_FILE = Path() / "__version__"
ARM_KEY = "aarch64"
def get_arm_cpu_freq_safe():
"""获取ARM设备CPU频率"""
# 方法1: 优先从系统频率文件读取
freq_files = [
"/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq",
"/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq",
"/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_cur_freq",
"/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq",
]
for freq_file in freq_files:
try:
with open(freq_file) as f:
frequency = int(f.read().strip())
return round(frequency / 1000000, 2) # 转换为GHz
except (OSError, ValueError):
continue
# 方法2: 解析/proc/cpuinfo
with contextlib.suppress(OSError, FileNotFoundError, ValueError, PermissionError):
with open("/proc/cpuinfo") as f:
for line in f:
if "CPU MHz" in line:
freq = float(line.split(":")[1].strip())
return round(freq / 1000, 2) # 转换为GHz
# 方法3: 使用lscpu命令
with contextlib.suppress(OSError, subprocess.SubprocessError, ValueError):
env = os.environ.copy()
env["LC_ALL"] = "C"
result = subprocess.run(
["lscpu"], capture_output=True, text=True, env=env, timeout=10
)
if result.returncode == 0:
for line in result.stdout.split("\n"):
if "CPU max MHz" in line or "CPU MHz" in line:
freq = float(line.split(":")[1].strip())
return round(freq / 1000, 2) # 转换为GHz
return 0 # 如果所有方法都失败,返回0
@dataclass
@@ -37,7 +78,7 @@ class CPUInfo:
if _cpu_freq := psutil.cpu_freq():
cpu_freq = round(_cpu_freq.current / 1000, 2)
else:
cpu_freq = 0
cpu_freq = get_arm_cpu_freq_safe()
return CPUInfo(core=cpu_core, usage=cpu_usage, freq=cpu_freq)
@@ -160,44 +201,13 @@ def __get_version() -> str | None:
return None
def __get_arm_cpu():
env = os.environ.copy()
env["LC_ALL"] = "en_US.UTF-8"
cpu_info = subprocess.check_output(["lscpu"], env=env).decode()
model_name = ""
cpu_freq = 0
for line in cpu_info.splitlines():
if "Model name" in line:
model_name = line.split(":")[1].strip()
if "CPU MHz" in line:
cpu_freq = float(line.split(":")[1].strip())
return model_name, cpu_freq
def __get_arm_oracle_cpu_freq():
cpu_freq = subprocess.check_output(
["dmidecode", "-s", "processor-frequency"]
).decode()
return round(float(cpu_freq.split()[0]) / 1000, 2)
async def get_status_info() -> dict:
"""获取信息"""
data = await __build_status()
system = platform.uname()
if system.machine == ARM_KEY and not (
cpuinfo.get_cpu_info().get("brand_raw") and data.cpu.freq
):
model_name, cpu_freq = __get_arm_cpu()
if not data.cpu.freq:
data.cpu.freq = cpu_freq or __get_arm_oracle_cpu_freq()
data = data.get_system_info()
data["brand_raw"] = model_name
else:
data = data.get_system_info()
data["brand_raw"] = cpuinfo.get_cpu_info().get("brand_raw", "Unknown")
data = data.get_system_info()
data["brand_raw"] = cpuinfo.get_cpu_info().get("brand_raw", "Unknown")
baidu, google = await __get_network_info()
data["baidu"] = "#8CC265" if baidu else "red"
data["google"] = "#8CC265" if google else "red"
+15 -29
View File
@@ -13,11 +13,6 @@ from nonebot_plugin_alconna import (
)
from nonebot_plugin_uninfo import Uninfo
from zhenxun.builtin_plugins.help._config import (
GROUP_HELP_PATH,
SIMPLE_DETAIL_HELP_IMAGE,
SIMPLE_HELP_IMAGE,
)
from zhenxun.configs.config import Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.services.log import logger
@@ -36,18 +31,6 @@ __plugin_meta__ = PluginMetadata(
plugin_type=PluginType.DEPENDANT,
is_show=False,
configs=[
RegisterConfig(
key="type",
value="zhenxun",
help="帮助图片样式 [normal, HTML, zhenxun]",
default_value="zhenxun",
),
RegisterConfig(
key="detail_type",
value="zhenxun",
help="帮助详情图片样式 ['normal', 'zhenxun']",
default_value="zhenxun",
),
RegisterConfig(
key="ENABLE_LLM_HELPER",
value=False,
@@ -76,6 +59,13 @@ __plugin_meta__ = PluginMetadata(
default_value=100,
type=int,
),
RegisterConfig(
key="HELP_STYLE",
value="default",
help="帮助页面的显示样式 (可选值: 'default', 'simple')",
default_value="default",
type=str,
),
],
).to_dict(),
)
@@ -119,8 +109,11 @@ async def _(
)
if name.available:
help_style = Config.get_config("help", "HELP_STYLE")
variant = help_style if help_style != "default" else None
traditional_help_result = await get_plugin_help(
session.user.id, name.result, _is_superuser
session.user.id, name.result, _is_superuser, variant=variant
)
is_plugin_found = not (
@@ -144,15 +137,8 @@ async def _(
f"查看帮助详情失败,未找到: {name.result}", "帮助", session=session
)
elif session.group and (gid := session.group.id):
_image_path = GROUP_HELP_PATH / f"{gid}_{is_detail.result}.png"
if not _image_path.exists():
await create_help_img(session, gid, is_detail.result)
await MessageUtils.build_message(_image_path).finish()
image_bytes = await create_help_img(session, gid, is_detail.result)
await MessageUtils.build_message(image_bytes).finish()
else:
if is_detail.result:
_image_path = SIMPLE_DETAIL_HELP_IMAGE
else:
_image_path = SIMPLE_HELP_IMAGE
if not _image_path.exists():
await create_help_img(session, None, is_detail.result)
await MessageUtils.build_message(_image_path).finish()
image_bytes = await create_help_img(session, None, is_detail.result)
await MessageUtils.build_message(image_bytes).finish()
+141 -124
View File
@@ -1,74 +1,126 @@
from pathlib import Path
import nonebot
from nonebot.plugin import PluginMetadata
from nonebot_plugin_htmlrender import template_to_pic
from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.config import Config
from zhenxun.configs.path_config import IMAGE_PATH, TEMPLATE_PATH
from zhenxun import ui
from zhenxun.configs.config import BotConfig, Config
from zhenxun.configs.path_config import IMAGE_PATH
from zhenxun.configs.utils import PluginExtraData
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.statistics import Statistics
from zhenxun.services import (
LLMException,
LLMMessage,
avatar_service,
generate,
)
from zhenxun.services.log import logger
from zhenxun.utils._image_template import Markdown
from zhenxun.utils.enum import PluginType
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
from ._config import (
GROUP_HELP_PATH,
SIMPLE_DETAIL_HELP_IMAGE,
SIMPLE_HELP_IMAGE,
base_config,
from zhenxun.ui.builders import (
NotebookBuilder,
PluginMenuBuilder,
)
from .html_help import build_html_image
from .normal_help import build_normal_image
from .zhenxun_help import build_zhenxun_image
from zhenxun.ui.models import PluginMenuCategory
from zhenxun.utils.common_utils import format_usage_for_markdown
from zhenxun.utils.enum import BlockType, PluginType
from zhenxun.utils.platform import PlatformUtils
from ._utils import classify_plugin
random_bk_path = IMAGE_PATH / "background" / "help" / "simple_help"
background = IMAGE_PATH / "background" / "0.png"
driver = nonebot.get_driver()
def _create_plugin_menu_item(
bot: BotConsole | None,
plugin: PluginInfo,
group: GroupConsole | None,
is_detail: bool,
) -> dict:
"""为插件菜单构造一个插件菜单项数据字典"""
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 plugin.block_type == BlockType.ALL:
status = False
elif group and plugin.block_type == BlockType.GROUP:
status = False
elif not group and plugin.block_type == BlockType.PRIVATE:
status = False
elif group and f"{plugin.module}," in group.block_plugin:
status = False
elif bot and f"{plugin.module}," in bot.block_plugins:
status = False
commands = []
if is_detail and nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra:
extra_data = PluginExtraData(**nb_plugin.metadata.extra)
commands = [cmd.command for cmd in extra_data.commands]
return {
"id": str(plugin.id),
"name": plugin.name,
"status": status,
"has_superuser_help": has_superuser_help,
"commands": commands,
}
async def create_help_img(
session: Uninfo, group_id: str | None, is_detail: bool
) -> Path:
"""生成帮助图片
) -> bytes:
"""使用渲染服务生成帮助图片"""
classified_data = await classify_plugin(
session, group_id, is_detail, _create_plugin_menu_item
)
参数:
session: Uninfo
group_id: 群号
"""
help_type = base_config.get("type", "").strip().lower()
sorted_categories = dict(
sorted(classified_data.items(), key=lambda x: len(x[1]), reverse=True)
)
categories_for_model = []
plugin_count = 0
active_count = 0
match help_type:
case "html":
result = BuildImage.open(
await build_html_image(session, group_id, is_detail)
)
case "zhenxun":
result = BuildImage.open(
await build_zhenxun_image(session, group_id, is_detail)
)
case _:
result = await build_normal_image(group_id, is_detail)
if group_id:
save_path = GROUP_HELP_PATH / f"{group_id}_{is_detail}.png"
elif is_detail:
save_path = SIMPLE_DETAIL_HELP_IMAGE
else:
save_path = SIMPLE_HELP_IMAGE
await result.save(save_path)
return save_path
if sorted_categories:
menu_key = next(iter(sorted_categories.keys()))
max_data = sorted_categories.pop(menu_key)
main_category_name = "主要功能" if menu_key in ["normal", "功能"] else menu_key
categories_for_model.append({"name": main_category_name, "items": max_data})
plugin_count += len(max_data)
active_count += sum(1 for item in max_data if item["status"])
for menu, value in sorted_categories.items():
category_name = "主要功能" if menu in ["normal", "功能"] else menu
categories_for_model.append({"name": category_name, "items": value})
plugin_count += len(value)
active_count += sum(1 for item in value if item["status"])
platform = PlatformUtils.get_platform(session)
bot_id = BotConfig.get_qbot_uid(session.self_id) or session.self_id
bot_avatar_path = await avatar_service.get_avatar_path(platform, bot_id)
bot_avatar_url = bot_avatar_path.as_uri() if bot_avatar_path else ""
builder = PluginMenuBuilder(
bot_name=BotConfig.self_nickname,
bot_avatar_url=bot_avatar_url,
is_detail=is_detail,
)
for category in categories_for_model:
builder.add_category(
PluginMenuCategory(name=category["name"], items=category["items"])
)
return await ui.render(builder.build())
async def get_user_allow_help(user_id: str) -> list[PluginType]:
@@ -92,36 +144,6 @@ async def get_user_allow_help(user_id: str) -> list[PluginType]:
return type_list
async def get_normal_help(
metadata: PluginMetadata, extra: PluginExtraData, is_superuser: bool
) -> str | bytes:
"""构建默认帮助详情
参数:
metadata: PluginMetadata
extra: PluginExtraData
is_superuser: 是否超级用户帮助
返回:
str | bytes: 返回信息
"""
items = None
if is_superuser:
if usage := extra.superuser_help:
items = {
"简介": metadata.description,
"用法": usage,
}
else:
items = {
"简介": metadata.description,
"用法": metadata.usage,
}
if items:
return (await ImageTemplate.hl_page(metadata.name, items)).pic2bytes()
return "该功能没有帮助信息"
def min_leading_spaces(str_list: list[str]) -> int:
min_spaces = 9999
@@ -142,52 +164,16 @@ def split_text(text: str):
return [s.replace(" ", "&nbsp;") for s in split_text]
async def get_zhenxun_help(
module: str, metadata: PluginMetadata, extra: PluginExtraData, is_superuser: bool
async def get_plugin_help(
user_id: str, name: str, is_superuser: bool, variant: str | None = None
) -> str | bytes:
"""构建ZhenXun帮助详情
参数:
module: 模块名
metadata: PluginMetadata
extra: PluginExtraData
is_superuser: 是否超级用户帮助
返回:
str | bytes: 返回信息
"""
call_count = await Statistics.filter(plugin_name=module).count()
usage = metadata.usage
if is_superuser:
if not extra.superuser_help:
return "该功能没有超级用户帮助信息"
usage = extra.superuser_help
return await template_to_pic(
template_path=str((TEMPLATE_PATH / "help_detail").absolute()),
template_name="main.html",
templates={
"title": metadata.name,
"author": extra.author,
"version": extra.version,
"call_count": call_count,
"descriptions": split_text(metadata.description),
"usages": split_text(usage),
},
pages={
"viewport": {"width": 824, "height": 590},
"base_url": f"file://{TEMPLATE_PATH}",
},
wait=2,
)
async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str | bytes:
"""获取功能的帮助信息
参数:
user_id: 用户id
name: 插件名称或id
is_superuser: 是否为超级用户
variant: 使用的皮肤/变体名称
"""
type_list = await get_user_allow_help(user_id)
if name.isdigit():
@@ -196,16 +182,45 @@ async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str |
plugin = await PluginInfo.get_or_none(
name__iexact=name, load_status=True, plugin_type__in=type_list
)
if plugin:
_plugin = nonebot.get_plugin_by_module_name(plugin.module_path)
if _plugin and _plugin.metadata:
extra_data = PluginExtraData(**_plugin.metadata.extra)
if Config.get_config("help", "detail_type") == "zhenxun":
return await get_zhenxun_help(
plugin.module, _plugin.metadata, extra_data, is_superuser
)
else:
return await get_normal_help(_plugin.metadata, extra_data, is_superuser)
call_count = await Statistics.filter(plugin_name=plugin.module).count()
usage = _plugin.metadata.usage
if is_superuser:
if not extra_data.superuser_help:
return "该功能没有超级用户帮助信息"
usage = extra_data.superuser_help
metadata_items = [
{"label": "作者", "value": extra_data.author or "未知"},
{"label": "版本", "value": extra_data.version or "未知"},
{"label": "调用次数", "value": call_count},
]
processed_description = format_usage_for_markdown(
_plugin.metadata.description.strip()
)
processed_usage = format_usage_for_markdown(usage.strip())
sections = [
{"title": "简介", "content": [processed_description]},
{"title": "使用方法", "content": [processed_usage]},
]
page_data = {
"title": _plugin.metadata.name,
"metadata": metadata_items,
"sections": sections,
}
component = ui.template("pages/builtin/help", data=page_data)
if variant:
component.variant = variant
return await ui.render(component, use_cache=True, device_scale_factor=2)
return "糟糕! 该功能没有帮助喔..."
return "没有查找到这个功能噢..."
@@ -282,10 +297,12 @@ async def get_llm_help(question: str, user_id: str) -> str | bytes:
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()
builder = NotebookBuilder()
builder.text(reply_text)
return await ui.render(builder.build())
return reply_text
except LLMException as e:
+1 -1
View File
@@ -53,5 +53,5 @@ async def classify_plugin(
classify[menu] = []
classify[menu].append(handle(bot, plugin, group, is_detail))
for value in classify.values():
value.sort(key=lambda x: x.id)
value.sort(key=lambda x: int(x["id"]))
return classify
-150
View File
@@ -1,150 +0,0 @@
import os
import random
from nonebot_plugin_htmlrender import template_to_pic
from nonebot_plugin_uninfo import Uninfo
from pydantic import BaseModel
from zhenxun.configs.path_config import TEMPLATE_PATH
from zhenxun.models.bot_console import BotConsole
from zhenxun.models.group_console import GroupConsole
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.utils.enum import BlockType
from ._utils import classify_plugin
LOGO_PATH = TEMPLATE_PATH / "menu" / "res" / "logo"
class Item(BaseModel):
plugin_name: str
"""插件名称"""
sta: int
"""插件状态"""
id: int
"""插件id"""
class PluginList(BaseModel):
plugin_type: str
"""菜单名称"""
icon: str
"""图标"""
logo: str
"""logo"""
items: list[Item]
"""插件列表"""
ICON2STR = {
"normal": "fa fa-cog",
"原神相关": "fa fa-circle-o",
"常规插件": "fa fa-cubes",
"联系管理员": "fa fa-envelope-o",
"抽卡相关": "fa fa-credit-card-alt",
"来点好康的": "fa fa-picture-o",
"数据统计": "fa fa-bar-chart",
"一些工具": "fa fa-shopping-cart",
"商店": "fa fa-shopping-cart",
"其它": "fa fa-tags",
"群内小游戏": "fa fa-gamepad",
}
def __handle_item(
bot: BotConsole, plugin: PluginInfo, group: GroupConsole | None, is_detail: bool
) -> Item:
"""构造Item
参数:
bot: BotConsole
plugin: PluginInfo
group: 群组
is_detail: 是否详细
返回:
Item: Item
"""
sta = 0
if not plugin.status:
if group and plugin.block_type in [
BlockType.ALL,
BlockType.GROUP,
]:
sta = 2
if not group and plugin.block_type in [
BlockType.ALL,
BlockType.PRIVATE,
]:
sta = 2
if group:
if f"{plugin.module}," in group.superuser_block_plugin:
sta = 2
if f"{plugin.module}," in group.block_plugin:
sta = 1
if bot and f"{plugin.module}," in bot.block_plugins:
sta = 2
return Item(plugin_name=plugin.name, sta=sta, id=plugin.id)
def build_plugin_data(classify: dict[str, list[Item]]) -> list[dict[str, str]]:
"""构建前端插件数据
参数:
classify: 插件数据
返回:
list[dict[str, str]]: 前端插件数据
"""
lengths = [len(classify[c]) for c in classify]
index = lengths.index(max(lengths))
menu_key = list(classify.keys())[index]
max_data = classify[menu_key]
del classify[menu_key]
plugin_list = []
for menu_type in classify:
icon = "fa fa-pencil-square-o"
if menu_type in ICON2STR.keys():
icon = ICON2STR[menu_type]
logo = LOGO_PATH / random.choice(os.listdir(LOGO_PATH))
data = {
"name": menu_type if menu_type != "normal" else "功能",
"items": classify[menu_type],
"icon": icon,
"logo": str(logo.absolute()),
}
plugin_list.append(data)
plugin_list.insert(
0,
{
"name": menu_key if menu_key != "normal" else "功能",
"items": max_data,
"icon": "fa fa-pencil-square-o",
"logo": str((LOGO_PATH / random.choice(os.listdir(LOGO_PATH))).absolute()),
},
)
return plugin_list
async def build_html_image(
session: Uninfo, group_id: str | None, is_detail: bool
) -> bytes:
"""构造HTML帮助图片
参数:
session: Uninfo
group_id: 群号
is_detail: 是否详细帮助
"""
classify = await classify_plugin(session, group_id, is_detail, __handle_item)
plugin_list = build_plugin_data(classify)
return await template_to_pic(
template_path=str((TEMPLATE_PATH / "menu").absolute()),
template_name="zhenxun_menu.html",
templates={"plugin_list": plugin_list},
pages={
"viewport": {"width": 1903, "height": 10},
"base_url": f"file://{TEMPLATE_PATH}",
},
wait=2,
)
-100
View File
@@ -1,100 +0,0 @@
from zhenxun.configs.path_config import IMAGE_PATH
from zhenxun.models.group_console import GroupConsole
from zhenxun.utils._build_image import BuildImage
from zhenxun.utils.enum import BlockType
from zhenxun.utils.image_utils import build_sort_image, group_image
from ._utils import sort_type
BACKGROUND_PATH = IMAGE_PATH / "background" / "help" / "simple_help"
async def build_normal_image(group_id: str | None, is_detail: bool) -> BuildImage:
"""构造PIL帮助图片
参数:
group_id: 群号
is_detail: 详细帮助
"""
image_list = []
font_size = 24
font = BuildImage.load_font("HYWenHei-85W.ttf", 20)
sort_data = await sort_type()
for idx, menu_type in enumerate(sort_data):
plugin_list = sort_data[menu_type]
"""拿到最大宽度和结算高度"""
wh_list = [
BuildImage.get_text_size(f"{x.id}.{x.name}", font) for x in plugin_list
]
wh_list.append(BuildImage.get_text_size(menu_type, font))
sum_height = (font_size + 6) * len(plugin_list) + 10
max_width = max(x[0] for x in wh_list) + 30
bk = BuildImage(
max_width + 40,
sum_height + 50,
font_size=30,
color="#a7d1fc",
font="CJGaoDeGuo.otf",
)
title_size = bk.getsize(menu_type)
max_width = max_width if max_width > title_size[0] else title_size[0]
row = BuildImage(
max_width + 40,
sum_height,
font_size=font_size,
color="black" if idx % 2 else "white",
)
curr_h = 10
group = await GroupConsole.get_group(group_id=group_id) if group_id else None
for _, plugin in enumerate(plugin_list):
text_color = (255, 255, 255) if idx % 2 else (0, 0, 0)
if group and f"{plugin.module}," in group.block_plugin:
text_color = (252, 75, 13)
pos = None
# 禁用状态划线
if plugin.block_type in [BlockType.ALL, BlockType.GROUP] or (
group and f"super:{plugin.module}," in group.block_plugin
):
w = curr_h + int(row.getsize(plugin.name)[1] / 2) + 2
line_width = row.getsize(plugin.name)[0] + 35
pos = (7, w, line_width, w)
await row.text((10, curr_h), f"{plugin.id}.{plugin.name}", text_color)
if pos:
await row.line(pos, (236, 66, 7), 3)
curr_h += font_size + 5
await bk.text((0, 14), menu_type, center_type="width")
await bk.paste(row, (0, 50))
await bk.transparent(2)
image_list.append(bk)
image_group, h = group_image(image_list)
async def _a(image: BuildImage):
await image.filter("GaussianBlur", 5)
result = await build_sort_image(
image_group,
h,
background_path=BACKGROUND_PATH,
background_handle=_a,
)
width, height = 10, 10
for s in [
"目前支持的功能列表:",
"可以通过 '帮助 [功能名称或功能Id]' 来获取对应功能的使用方法",
]:
text = await BuildImage.build_text_image(s, "HYWenHei-85W.ttf", 24)
await result.paste(text, (width, height))
height += 50
if s == "目前支持的功能列表:":
width += 50
text = await BuildImage.build_text_image(
"注: 红字代表功能被群管理员禁用,红线代表功能正在维护",
"HYWenHei-85W.ttf",
24,
(231, 74, 57),
)
await result.paste(
text,
(300, 10),
)
return result
@@ -1,143 +0,0 @@
import nonebot
from nonebot_plugin_htmlrender import template_to_pic
from nonebot_plugin_uninfo import Uninfo
from pydantic import BaseModel
from zhenxun.configs.config import BotConfig
from zhenxun.configs.path_config import TEMPLATE_PATH
from zhenxun.configs.utils import PluginExtraData
from zhenxun.models.bot_console import BotConsole
from zhenxun.models.group_console import GroupConsole
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.utils.enum import BlockType
from zhenxun.utils.platform import PlatformUtils
from ._utils import classify_plugin
class Item(BaseModel):
plugin_name: str
"""插件名称"""
commands: list[str]
"""插件命令"""
id: str
"""插件id"""
status: bool
"""插件状态"""
has_superuser_help: bool
"""插件是否拥有超级用户帮助"""
def __handle_item(
bot: BotConsole | None,
plugin: PluginInfo,
group: GroupConsole | None,
is_detail: bool,
):
"""构造Item
参数:
bot: BotConsole
plugin: PluginInfo
group: 群组
is_detail: 是否为详细
返回:
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 plugin.block_type == BlockType.ALL:
status = False
elif group and plugin.block_type == BlockType.GROUP:
status = False
elif not group and plugin.block_type == BlockType.PRIVATE:
status = False
elif group and f"{plugin.module}," in group.block_plugin:
status = False
elif bot and f"{plugin.module}," in bot.block_plugins:
status = False
commands = []
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:
extra_data = PluginExtraData(**nb_plugin.metadata.extra)
commands = [cmd.command for cmd in extra_data.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]]:
"""构建前端插件数据
参数:
classify: 插件数据
返回:
list[dict[str, str]]: 前端插件数据
"""
classify = dict(sorted(classify.items(), key=lambda x: len(x[1]), reverse=True))
menu_key = next(iter(classify.keys()))
max_data = classify[menu_key]
del classify[menu_key]
plugin_list = [
{
"name": "主要功能" if menu in ["normal", "功能"] else menu,
"items": value,
}
for menu, value in classify.items()
]
plugin_list.insert(0, {"name": menu_key, "items": max_data})
for plugin in plugin_list:
plugin["items"].sort(key=lambda x: x.id)
return plugin_list
async def build_zhenxun_image(
session: Uninfo, group_id: str | None, is_detail: bool
) -> bytes:
"""构造真寻帮助图片
参数:
bot_id: bot_id
group_id: 群号
is_detail: 是否详细帮助
"""
classify = await classify_plugin(session, group_id, is_detail, __handle_item)
plugin_list = build_plugin_data(classify)
platform = PlatformUtils.get_platform(session)
bot_id = BotConfig.get_qbot_uid(session.self_id) or session.self_id
bot_ava = PlatformUtils.get_user_avatar_url(bot_id, platform)
width = int(637 * 1.5) if is_detail else 637
title_font = int(53 * 1.5) if is_detail else 53
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(
template_path=str((TEMPLATE_PATH / "ss_menu").absolute()),
template_name="main.html",
templates={
"data": {
"plugin_list": plugin_list,
"ava": bot_ava,
"width": width,
"font_size": (title_font, tip_font),
"is_detail": is_detail,
"plugin_count": plugin_count,
}
},
pages={
"viewport": {"width": width, "height": 10},
"base_url": f"file://{TEMPLATE_PATH}",
},
wait=2,
)
+2 -2
View File
@@ -74,8 +74,8 @@ async def _(matcher: Matcher, message: UniMsg, session: EventSession):
message_list.append(image)
message_list.append(
"桀桀桀,预判到会有 '笨蛋' 把功能名称当命令用,特地前来嘲笑!"
f"但还是好心来帮帮你啦!\n请at我发送 '帮助{plugin.name}' 或者"
f" '帮助{plugin.id}' 来获取该功能帮助!"
f"但还是好心来帮帮你啦!\n请at我发送 '帮助 {plugin.name}' 或者"
f" '帮助 {plugin.id}' 来获取该功能帮助!"
)
logger.info("检测到功能名称当命令使用,已发送帮助信息", "功能帮助", session=session)
await MessageUtils.build_message(message_list).send(reply_to=True)
@@ -58,5 +58,14 @@ Config.add_plugin_config(
type=bool,
)
Config.add_plugin_config(
"hook",
"AUTH_HOOKS_CONCURRENCY_LIMIT",
5,
help="同步进入权限钩子最大并发数",
default_value=5,
type=int,
)
nonebot.load_plugins(str(Path(__file__).parent.resolve()))
+7 -16
View File
@@ -96,7 +96,6 @@ async def is_ban(user_id: str | None, group_id: str | None) -> int:
f"查询ban记录超时: user_id={user_id}, group_id={group_id}",
LOGGER_COMMAND,
)
# 超时时返回0,避免阻塞
return 0
# 检查记录并计算ban时间
@@ -199,7 +198,7 @@ async def group_handle(group_id: str) -> None:
)
async def user_handle(module: str, entity: EntityIDs, session: Uninfo) -> None:
async def user_handle(plugin: PluginInfo, entity: EntityIDs, session: Uninfo) -> None:
"""用户ban检查
参数:
@@ -217,22 +216,12 @@ async def user_handle(module: str, entity: EntityIDs, session: Uninfo) -> None:
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
plugin
and time_val != -1
and ban_result
and freq.is_send_limit_message(db_plugin, entity.user_id, False)
and freq.is_send_limit_message(plugin, entity.user_id, False)
):
try:
await asyncio.wait_for(
@@ -260,7 +249,9 @@ async def user_handle(module: str, entity: EntityIDs, session: Uninfo) -> None:
)
async def auth_ban(matcher: Matcher, bot: Bot, session: Uninfo) -> None:
async def auth_ban(
matcher: Matcher, bot: Bot, session: Uninfo, plugin: PluginInfo
) -> None:
"""权限检查 - ban 检查
参数:
@@ -289,7 +280,7 @@ async def auth_ban(matcher: Matcher, bot: Bot, session: Uninfo) -> None:
if entity.user_id:
try:
await asyncio.wait_for(
user_handle(matcher.plugin_name, entity, session),
user_handle(plugin, entity, session),
timeout=DB_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
@@ -1,50 +1,36 @@
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):
async def auth_group(
plugin: PluginInfo,
group: GroupConsole | None,
message: UniMsg,
group_id: str | None,
):
"""群黑名单检测 群总开关检测
参数:
plugin: PluginInfo
entity: EntityIDs
group: GroupConsole
message: UniMsg
"""
start_time = time.time()
if not entity.group_id:
if not group_id:
return
start_time = time.time()
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:
@@ -63,6 +49,5 @@ async def auth_group(plugin: PluginInfo, entity: EntityIDs, message: UniMsg):
logger.warning(
f"auth_group 耗时: {elapsed:.3f}s, plugin={plugin.module}",
LOGGER_COMMAND,
session=entity.user_id,
group_id=entity.group_id,
group_id=group_id,
)
@@ -6,12 +6,10 @@ 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
@@ -20,30 +18,17 @@ from .utils import freq, is_poke, send_message
class GroupCheck:
def __init__(
self, plugin: PluginInfo, group_id: str, session: Uninfo, is_poke: bool
self, plugin: PluginInfo, group: GroupConsole, 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
self.group_data = group
self.group_id = group.group_id
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
@@ -113,12 +98,13 @@ class GroupCheck:
class PluginCheck:
def __init__(self, group_id: str | None, session: Uninfo, is_poke: bool):
def __init__(self, group: GroupConsole | 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
self.group_data = group
self.group_id = None
if group:
self.group_id = group.group_id
async def check_user(self, plugin: PluginInfo):
"""全局私聊禁用检测
@@ -156,21 +142,8 @@ class PluginCheck:
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()
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):
@@ -193,7 +166,9 @@ class PluginCheck:
)
async def auth_plugin(plugin: PluginInfo, session: Uninfo, event: Event):
async def auth_plugin(
plugin: PluginInfo, group: GroupConsole | None, session: Uninfo, event: Event
):
"""插件状态
参数:
@@ -203,35 +178,23 @@ async def auth_plugin(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)
user_check = PluginCheck(group, 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)
# 超时时不阻塞,继续执行
tasks = []
if group:
tasks.append(GroupCheck(plugin, group, session, is_poke_event).check())
else:
try:
await asyncio.wait_for(
user_check.check_user(plugin), timeout=DB_TIMEOUT_SECONDS
)
except asyncio.TimeoutError:
logger.error("用户检查超时", LOGGER_COMMAND)
# 超时时不阻塞,继续执行
tasks.append(user_check.check_user(plugin))
tasks.append(user_check.check_global(plugin))
try:
await asyncio.wait_for(
user_check.check_global(plugin), timeout=DB_TIMEOUT_SECONDS
asyncio.gather(*tasks), timeout=DB_TIMEOUT_SECONDS * 2
)
except asyncio.TimeoutError:
logger.error("全局检查超时", LOGGER_COMMAND)
# 超时时不阻塞,继续执行
logger.error("插件用户/群组/全局检查超时...", LOGGER_COMMAND)
finally:
# 记录总执行时间
elapsed = time.time() - start_time
+1 -1
View File
@@ -85,7 +85,7 @@ class FreqUtils:
return False
if plugin.plugin_type == PluginType.DEPENDANT:
return False
return plugin.module != "ai" if self._flmt_s.check(sid) else False
return False if plugin.ignore_prompt else self._flmt_s.check(sid)
freq = FreqUtils()
+73 -4
View File
@@ -8,6 +8,7 @@ from nonebot_plugin_alconna import UniMsg
from nonebot_plugin_uninfo import Uninfo
from tortoise.exceptions import IntegrityError
from zhenxun.models.group_console import GroupConsole
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.user_console import UserConsole
from zhenxun.services.data_access import DataAccess
@@ -31,6 +32,7 @@ from .auth.exception import (
PermissionExemption,
SkipPluginException,
)
from .auth.utils import base_config
# 超时设置(秒)
TIMEOUT_SECONDS = 5.0
@@ -46,6 +48,16 @@ CIRCUIT_BREAKERS = {
# 熔断重置时间(秒)
CIRCUIT_RESET_TIME = 300 # 5分钟
# 并发控制:限制同时进入 hooks 并行检查的协程数
# 默认为 6,可通过环境变量 AUTH_HOOKS_CONCURRENCY_LIMIT 调整
HOOKS_CONCURRENCY_LIMIT = base_config.get("AUTH_HOOKS_CONCURRENCY_LIMIT")
# 全局信号量与计数器
HOOKS_SEMAPHORE = asyncio.Semaphore(HOOKS_CONCURRENCY_LIMIT)
HOOKS_ACTIVE_COUNT = 0
HOOKS_ACTIVE_LOCK = asyncio.Lock()
# 超时装饰器
async def with_timeout(coro, timeout=TIMEOUT_SECONDS, name=None):
@@ -259,6 +271,30 @@ async def time_hook(coro, name, time_dict):
time_dict[name] = f"{time.time() - start:.3f}s"
async def _enter_hooks_section():
"""尝试获取全局信号量并更新计数器,超时则抛出 PermissionExemption。"""
global HOOKS_ACTIVE_COUNT
# 队列模式:如果达到上限,协程将排队等待直到获取到信号量
await HOOKS_SEMAPHORE.acquire()
async with HOOKS_ACTIVE_LOCK:
HOOKS_ACTIVE_COUNT += 1
logger.debug(f"当前并发权限检查数量: {HOOKS_ACTIVE_COUNT}", LOGGER_COMMAND)
async def _leave_hooks_section():
"""释放信号量并更新计数器。"""
global HOOKS_ACTIVE_COUNT
from contextlib import suppress
with suppress(Exception):
HOOKS_SEMAPHORE.release()
async with HOOKS_ACTIVE_LOCK:
HOOKS_ACTIVE_COUNT -= 1
# 保证计数不为负
HOOKS_ACTIVE_COUNT = max(HOOKS_ACTIVE_COUNT, 0)
logger.debug(f"当前并发权限检查数量: {HOOKS_ACTIVE_COUNT}", LOGGER_COMMAND)
async def auth(
matcher: Matcher,
event: Event,
@@ -285,6 +321,9 @@ async def auth(
hook_times = {}
hooks_time = 0 # 初始化 hooks_time 变量
# 记录是否已进入 hooks 区域(用于 finally 中释放)
entered_hooks = False
try:
if not module:
raise PermissionExemption("Matcher插件名称不存在...")
@@ -304,6 +343,10 @@ async def auth(
)
raise PermissionExemption("获取插件和用户数据超时,请稍后再试...")
# 进入 hooks 并行检查区域(会在高并发时排队)
await _enter_hooks_section()
entered_hooks = True
# 获取插件费用
cost_start = time.time()
try:
@@ -320,16 +363,32 @@ async def auth(
# 执行 bot_filter
bot_filter(session)
group = None
if entity.group_id:
group_dao = DataAccess(GroupConsole)
group = await with_timeout(
group_dao.safe_get_or_none(
group_id=entity.group_id, channel_id__isnull=True
),
name="get_group",
)
# 并行执行所有 hook 检查,并记录执行时间
hooks_start = time.time()
# 创建所有 hook 任务
hook_tasks = [
time_hook(auth_ban(matcher, bot, session), "auth_ban", hook_times),
time_hook(auth_ban(matcher, bot, session, plugin), "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_group(plugin, group, message, entity.group_id),
"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_plugin(plugin, group, session, event), "auth_plugin", hook_times
),
time_hook(auth_limit(plugin, session), "auth_limit", hook_times),
]
@@ -358,7 +417,17 @@ async def auth(
logger.debug("超级用户跳过权限检测...", LOGGER_COMMAND, session=session)
except PermissionExemption as e:
logger.info(str(e), LOGGER_COMMAND, session=session)
finally:
# 如果进入过 hooks 区域,确保释放信号量(即使上层处理抛出了异常)
if entered_hooks:
try:
await _leave_hooks_section()
except Exception:
logger.error(
"释放 hooks 信号量时出错",
LOGGER_COMMAND,
session=session,
)
# 扣除金币
if not ignore_flag and cost_gold > 0:
gold_start = time.time()
+3 -2
View File
@@ -6,6 +6,7 @@ from zhenxun.configs.config import Config
from zhenxun.models.bot_message_store import BotMessageStore
from zhenxun.services.log import logger
from zhenxun.utils.enum import BotSentType
from zhenxun.utils.log_sanitizer import sanitize_for_logging
from zhenxun.utils.manager.message_manager import MessageManager
from zhenxun.utils.platform import PlatformUtils
@@ -52,7 +53,6 @@ async def handle_api_result(
message: Message = data.get("message", "")
message_type = data.get("message_type")
try:
# 记录消息id
if user_id and message_id:
MessageManager.add(str(user_id), str(message_id))
logger.debug(
@@ -78,7 +78,8 @@ async def handle_api_result(
else replace_message(message),
platform=PlatformUtils.get_platform(bot),
)
logger.debug(f"消息发送记录,message: {message}")
sanitized_message = sanitize_for_logging(message, context="nonebot_message")
logger.debug(f"消息发送记录,message: {sanitized_message}")
except Exception as e:
logger.warning(
f"消息发送记录发生错误...data: {data}, result: {result}",
+45 -45
View File
@@ -43,18 +43,20 @@ class BanCheckLimiter:
def check(self, key: str | float) -> bool:
if time.time() - self.mtime[key] > self.default_check_time:
self.mtime[key] = time.time()
self.mint[key] = 0
return False
return self._extracted_from_check_3(key, False)
if (
self.mint[key] >= self.default_count
and time.time() - self.mtime[key] < self.default_check_time
):
self.mtime[key] = time.time()
self.mint[key] = 0
return True
return self._extracted_from_check_3(key, True)
return False
# TODO Rename this here and in `check`
def _extracted_from_check_3(self, key, arg1):
self.mtime[key] = time.time()
self.mint[key] = 0
return arg1
_blmt = BanCheckLimiter(
malicious_check_time,
@@ -70,16 +72,15 @@ async def _(
module = None
if plugin := matcher.plugin:
module = plugin.module_name
if metadata := plugin.metadata:
extra = metadata.extra
if extra.get("plugin_type") in [
PluginType.HIDDEN,
PluginType.DEPENDANT,
PluginType.ADMIN,
PluginType.SUPERUSER,
]:
return
else:
if not (metadata := plugin.metadata):
return
extra = metadata.extra
if extra.get("plugin_type") in [
PluginType.HIDDEN,
PluginType.DEPENDANT,
PluginType.ADMIN,
PluginType.SUPERUSER,
]:
return
if matcher.type == "notice":
return
@@ -88,32 +89,31 @@ async def _(
malicious_ban_time = Config.get_config("hook", "MALICIOUS_BAN_TIME")
if not malicious_ban_time:
raise ValueError("模块: [hook], 配置项: [MALICIOUS_BAN_TIME] 为空或小于0")
if user_id:
if module:
if _blmt.check(f"{user_id}__{module}"):
await BanConsole.ban(
user_id,
group_id,
9,
"恶意触发命令检测",
malicious_ban_time * 60,
bot.self_id,
)
logger.info(
f"触发了恶意触发检测: {matcher.plugin_name}",
"HOOK",
session=session,
)
await MessageUtils.build_message(
[
At(flag="user", target=user_id),
"检测到恶意触发命令,您将被封禁 30 分钟",
]
).send()
logger.debug(
f"触发了恶意触发检测: {matcher.plugin_name}",
"HOOK",
session=session,
)
raise IgnoredException("检测到恶意触发命令")
_blmt.add(f"{user_id}__{module}")
if user_id and module:
if _blmt.check(f"{user_id}__{module}"):
await BanConsole.ban(
user_id,
group_id,
9,
"恶意触发命令检测",
malicious_ban_time * 60,
bot.self_id,
)
logger.info(
f"触发了恶意触发检测: {matcher.plugin_name}",
"HOOK",
session=session,
)
await MessageUtils.build_message(
[
At(flag="user", target=user_id),
"检测到恶意触发命令,您将被封禁 30 分钟",
]
).send()
logger.debug(
f"触发了恶意触发检测: {matcher.plugin_name}",
"HOOK",
session=session,
)
raise IgnoredException("检测到恶意触发命令")
_blmt.add(f"{user_id}__{module}")
+63 -56
View File
@@ -1,17 +1,17 @@
from datetime import datetime, timedelta
import random
from nonebot_plugin_htmlrender import template_to_pic
from nonebot_plugin_uninfo import Uninfo
from tortoise.expressions import RawSQL
from tortoise.functions import Count
from zhenxun.configs.path_config import TEMPLATE_PATH
from zhenxun import ui
from zhenxun.models.chat_history import ChatHistory
from zhenxun.models.level_user import LevelUser
from zhenxun.models.sign_user import SignUser
from zhenxun.models.statistics import Statistics
from zhenxun.models.user_console import UserConsole
from zhenxun.services import avatar_service
from zhenxun.utils.platform import PlatformUtils
RACE = [
@@ -90,7 +90,7 @@ def get_level(impression: float) -> int:
async def get_chat_history(
user_id: str, group_id: str | None
) -> tuple[list[str], list[str]]:
) -> tuple[list[str], list[int]]:
"""获取用户聊天记录
参数:
@@ -98,11 +98,11 @@ async def get_chat_history(
group_id: 群id
返回:
tuple[list[str], list[str]]: 日期列表, 次数列表
tuple[list[str], list[int]]: 日期列表, 次数列表
"""
now = datetime.now()
filter_date = now - timedelta(days=7, hours=now.hour, minutes=now.minute)
filter_date = now - timedelta(days=7)
date_list = (
await ChatHistory.filter(
user_id=user_id, group_id=group_id, create_time__gte=filter_date
@@ -111,19 +111,15 @@ async def get_chat_history(
.group_by("date")
.values("date", "count")
)
chart_date = []
count_list = []
date2cnt = {str(date["date"]): date["count"] for date in date_list}
date = now.date()
chart_date: list[str] = []
count_list: list[int] = []
date2cnt = {str(item["date"]): item["count"] for item in date_list}
current_date = now.date()
for _ in range(7):
if str(date) in date2cnt:
count_list.append(date2cnt[str(date)])
else:
count_list.append(0)
chart_date.append(str(date))
date -= timedelta(days=1)
for c in chart_date:
chart_date[chart_date.index(c)] = c[5:]
date_str = str(current_date)
count_list.append(date2cnt.get(date_str, 0))
chart_date.append(date_str[5:])
current_date -= timedelta(days=1)
chart_date.reverse()
count_list.reverse()
return chart_date, count_list
@@ -136,7 +132,6 @@ async def get_user_info(
参数:
session: Uninfo
bot: Bot
user_id: 用户id
group_id: 群id
nickname: 用户昵称
@@ -145,50 +140,62 @@ async def get_user_info(
bytes: 图片数据
"""
platform = PlatformUtils.get_platform(session) or "qq"
ava_url = PlatformUtils.get_user_avatar_url(user_id, platform, session.self_id)
avatar_path = await avatar_service.get_avatar_path(platform, user_id)
avatar_url = avatar_path.as_uri() if avatar_path else ""
user = await UserConsole.get_user(user_id, platform)
level = await LevelUser.get_user_level(user_id, group_id)
permission_level = await LevelUser.get_user_level(user_id, group_id)
sign_level = 0
if sign_user := await SignUser.get_or_none(user_id=user_id):
sign_level = get_level(float(sign_user.impression))
chat_count = await ChatHistory.filter(user_id=user_id, group_id=group_id).count()
stat_count = await Statistics.filter(user_id=user_id, group_id=group_id).count()
select_index = ["" for _ in range(9)]
select_index[sign_level] = "select"
selected_indices = [""] * 9
selected_indices[sign_level] = "select"
uid = f"{user.uid}".rjust(8, "0")
uid = f"{uid[:4]} {uid[4:]}"
uid_formatted = f"{uid[:4]} {uid[4:]}"
now = datetime.now()
weather = "moon" if now.hour < 6 or now.hour > 19 else "sun"
chart_date, count_list = await get_chat_history(user_id, group_id)
data = {
"date": now.date(),
"weather": weather,
"ava_url": ava_url,
"nickname": nickname,
"title": "勇 者",
"race": random.choice(RACE),
"sex": random.choice(SEX),
"occ": random.choice(OCC),
"uid": uid,
"description": "这是一个传奇的故事,"
"人类的赞歌是勇气的赞歌,人类的伟大是勇气的伟译。",
"sign_level": sign_level,
"level": level,
"gold": user.gold,
"prop": len(user.props),
"call": stat_count,
"say": chat_count,
"select_index": select_index,
"chart_date": chart_date,
"count_list": count_list,
}
return await template_to_pic(
template_path=str((TEMPLATE_PATH / "my_info").absolute()),
template_name="main.html",
templates={"data": data},
pages={
"viewport": {"width": 1754, "height": 1240},
"base_url": f"file://{TEMPLATE_PATH}",
weather_icon_name = "moon" if now.hour < 6 or now.hour > 19 else "sun"
chart_labels, chart_data = await get_chat_history(user_id, group_id)
profile_data = {
"page": {
"date": str(now.date()),
"weather_icon_name": weather_icon_name,
},
wait=2,
)
"info": {
"avatar_url": avatar_url,
"nickname": nickname,
"title": "勇 者",
"race": random.choice(RACE),
"sex": random.choice(SEX),
"occupation": random.choice(OCC),
"uid": uid_formatted,
"description": (
"这是一个传奇的故事,人类的赞歌是勇气的赞歌,人类的伟大是勇气的伟大"
),
},
"stats": {
"gold": user.gold,
"prop_count": len(user.props),
"call_count": stat_count,
"chat_count": chat_count,
},
"favorability": {
"level": sign_level,
"selected_indices": selected_indices,
},
"permission_level": permission_level,
"chart": {
"labels": chart_labels,
"data": chart_data,
},
}
return await ui.render_template("pages/builtin/my_info", data=profile_data)
@@ -7,6 +7,7 @@
from zhenxun.models.ban_console import BanConsole
from zhenxun.models.bot_console import BotConsole
from zhenxun.models.group_console import GroupConsole
from zhenxun.models.group_plugin_setting import GroupPluginSetting
from zhenxun.models.level_user import LevelUser
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.user_console import UserConsole
@@ -23,6 +24,11 @@ def register_cache_types():
CacheRegistry.register(CacheType.GROUPS, GroupConsole)
CacheRegistry.register(CacheType.BOT, BotConsole)
CacheRegistry.register(CacheType.USERS, UserConsole)
CacheRegistry.register(
CacheType.GROUP_PLUGIN_SETTINGS,
GroupPluginSetting,
key_format="{group_id}_{plugin_name}_{key}",
)
CacheRegistry.register(
CacheType.LEVEL, LevelUser, key_format="{user_id}_{group_id}"
)
@@ -1,3 +1,5 @@
from collections import defaultdict
from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import (
@@ -58,7 +60,12 @@ __plugin_meta__ = PluginMetadata(
llm_cmd = on_alconna(
Alconna(
"llm",
Subcommand("list", alias=["ls"], help_text="查看模型列表"),
Subcommand(
"list",
Option("--text", action=store_true, help_text="以纯文本格式输出模型列表"),
alias=["ls"],
help_text="查看模型列表",
),
Subcommand("info", Args["model_name", str], help_text="查看模型详情"),
Subcommand("default", Args["model_name?", str], help_text="查看或设置默认模型"),
Subcommand(
@@ -80,13 +87,36 @@ llm_cmd = on_alconna(
@llm_cmd.assign("list")
async def handle_list(arp: Arparma, show_all: Query[bool] = Query("all")):
async def handle_list(
arp: Arparma,
show_all: Query[bool] = Query("all"),
text_mode: Query[bool] = Query("list.text.value", False),
):
"""处理 '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))
if text_mode.result:
if not models:
await llm_cmd.finish("当前没有配置任何LLM模型。")
grouped_models = defaultdict(list)
for model in models:
grouped_models[model["provider_name"]].append(model)
response_parts = ["可用的LLM模型列表:"]
for provider, model_list in grouped_models.items():
response_parts.append(f"\n{provider}:")
for model in model_list:
response_parts.append(
f" {model['provider_name']}/{model['model_name']}"
)
response_text = "\n".join(response_parts)
await llm_cmd.finish(response_text)
else:
image = await Presenters.format_model_list_as_image(models, show_all.result)
await llm_cmd.finish(MessageUtils.build_message(image))
@llm_cmd.assign("info")
@@ -114,7 +144,7 @@ async def handle_default(arp: Arparma, model_name: Match[str]):
command="LLM Manage",
session=arp.header_result,
)
success, message = await DataSource.set_default_model(model_name.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)
@@ -132,7 +162,7 @@ async def handle_test(arp: Arparma, model_name: Match[str]):
)
await llm_cmd.send(f"正在测试模型 '{model_name.result}',请稍候...")
success, message = await DataSource.test_model_connectivity(model_name.result)
_success, message = await DataSource.test_model_connectivity(model_name.result)
await llm_cmd.finish(message)
@@ -167,5 +197,5 @@ async def handle_reset_key(
)
logger.info(log_msg, command="LLM Manage", session=arp.header_result)
success, message = await DataSource.reset_key(provider_name.result, key_to_reset)
_success, message = await DataSource.reset_key(provider_name.result, key_to_reset)
await llm_cmd.finish(message)
+81 -100
View File
@@ -1,9 +1,10 @@
from typing import Any
from zhenxun.services import renderer_service
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
from zhenxun.ui.builders import MarkdownBuilder, TableBuilder
from zhenxun.ui.models import StatusBadgeCell, TextCell
def _format_seconds(seconds: int) -> str:
@@ -27,35 +28,40 @@ class Presenters:
@staticmethod
async def format_model_list_as_image(
models: list[dict[str, Any]], show_all: bool
) -> BuildImage:
) -> bytes:
"""将模型列表格式化为表格图片"""
title = "📋 LLM模型列表" + (" (所有已配置模型)" if show_all else " (仅可用)")
title = "LLM模型列表" + (" (所有已配置模型)" if show_all else " (仅可用)")
if not models:
return await BuildImage.build_text_image(
f"{title}\n\n当前没有配置任何LLM模型。"
)
builder = TableBuilder(
title=title, tip="当前没有配置任何LLM模型。"
).set_headers(["提供商", "模型名称", "API类型", "状态"])
return await renderer_service.render(builder.build())
column_name = ["提供商", "模型名称", "API类型", "状态"]
data_list = []
rows_data = []
for model in models:
status_text = "✅ 可用" if model.get("is_available", True) else "❌ 不可用"
is_available = model.get("is_available", True)
embed_tag = " (Embed)" if model.get("is_embedding_model", False) else ""
data_list.append(
rows_data.append(
[
model.get("provider_name", "N/A"),
f"{model.get('model_name', 'N/A')}{embed_tag}",
model.get("api_type", "N/A"),
status_text,
TextCell(content=model.get("provider_name", "N/A")),
TextCell(content=f"{model.get('model_name', 'N/A')}{embed_tag}"),
TextCell(content=model.get("api_type", "N/A")),
StatusBadgeCell(
text="可用" if is_available else "不可用",
status_type="ok" if is_available else "error",
),
]
)
return await ImageTemplate.table_page(
head_text=title,
tip_text="使用 `llm info <Provider/ModelName>` 查看详情",
column_name=column_name,
data_list=data_list,
builder = TableBuilder(
title=title, tip="使用 `llm info <Provider/ModelName>` 查看详情"
)
builder.set_headers(column_name)
builder.set_column_alignments(["left", "left", "left", "center"])
builder.add_rows(rows_data)
return await renderer_service.render(builder.build(), use_cache=True)
@staticmethod
async def format_model_details_as_markdown_image(details: dict[str, Any]) -> bytes:
@@ -76,77 +82,33 @@ class Presenters:
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)
builder = MarkdownBuilder()
builder.head(f"🔎 模型详情: {provider.name}/{model.model_name}", 1)
builder.text("---")
builder.head("提供商信息", 2)
builder.text(f"- **名称**: {provider.name}")
builder.text(f"- **API 类型**: {provider.api_type}")
builder.text(f"- **API Base**: {provider.api_base or '默认'}")
builder.head("模型详情", 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 '纯文本'}",
]
)
builder.text(f"- **名称**: {model.model_name}")
builder.text(f"- **默认温度**: {temp_value}")
builder.text(f"- **最大Token**: {token_value}")
builder.text(f"- **核心能力**: {', '.join(cap_list) or '纯文本'}")
return await md.build()
return await renderer_service.render(builder.with_style("light").build())
@staticmethod
async def format_key_status_as_image(
provider_name: str, sorted_stats: list[dict[str, Any]]
) -> BuildImage:
) -> bytes:
"""将已排序的、详细的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:
@@ -155,15 +117,19 @@ class Presenters:
if status_enum == KeyStatus.COOLDOWN:
cooldown_seconds = int(key_info["cooldown_seconds_left"])
formatted_time = _format_seconds(cooldown_seconds)
status_text = f"❄️ 冷却中({formatted_time})"
status_cell = StatusBadgeCell(
text=f"冷却中({formatted_time})", status_type="info"
)
else:
status_text = {
KeyStatus.DISABLED: "🚫 永久禁用",
KeyStatus.ERROR: "❌ 错误",
KeyStatus.WARNING: "⚠️ 告警",
KeyStatus.HEALTHY: "✅ 健康",
KeyStatus.UNUSED: "⚪️ 未使用",
}.get(status_enum, "❔ 未知")
status_map = {
KeyStatus.DISABLED: ("永久禁用", "error"),
KeyStatus.ERROR: ("错误", "error"),
KeyStatus.WARNING: ("告警", "warning"),
KeyStatus.HEALTHY: ("健康", "ok"),
KeyStatus.UNUSED: ("未使用", "info"),
}
text, status_type = status_map.get(status_enum, ("未知", "info"))
status_cell = StatusBadgeCell(text=text, status_type=status_type) # type: ignore
total_calls = key_info["total_calls"]
total_calls_text = (
@@ -174,6 +140,13 @@ class Presenters:
success_rate = key_info["success_rate"]
success_rate_text = f"{success_rate:.1f}%" if total_calls > 0 else "N/A"
rate_color = None
if total_calls > 0:
if success_rate < 80:
rate_color = "#F56C6C"
elif success_rate < 95:
rate_color = "#E6A23C"
success_rate_cell = TextCell(content=success_rate_text, color=rate_color)
avg_latency = key_info["avg_latency"]
avg_latency_text = f"{avg_latency / 1000:.2f}" if avg_latency > 0 else "N/A"
@@ -184,21 +157,29 @@ class Presenters:
data_list.append(
[
key_info["key_id"],
status_text,
total_calls_text,
success_rate_text,
avg_latency_text,
last_error,
key_info["suggested_action"],
TextCell(content=key_info["key_id"]),
status_cell,
TextCell(content=total_calls_text),
success_rate_cell,
TextCell(content=avg_latency_text),
TextCell(content=last_error),
TextCell(content=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,
builder = TableBuilder(
title=title, tip="使用 `llm reset-key <Provider>` 重置Key状态"
)
builder.set_headers(
[
"Key (部分)",
"状态",
"总调用",
"成功率",
"平均延迟(s)",
"上次错误",
"建议操作",
]
)
builder.add_rows(data_list)
return await renderer_service.render(builder.build(), use_cache=False)
@@ -6,6 +6,7 @@ from nonebot_plugin_apscheduler import scheduler
from nonebot_plugin_uninfo import Uninfo
from nonebot_plugin_waiter import prompt_until
from zhenxun import ui
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.services.log import logger
from zhenxun.utils.depends import UserName
@@ -188,15 +189,33 @@ async def _(session: Uninfo, arparma: Arparma, amount: Match[int]):
@_matcher.assign("user-info")
async def _(session: Uninfo, arparma: Arparma, uname: str = UserName()):
result = await BankManager.get_user_info(session, uname)
await MessageUtils.build_message(result).send()
user_payload = await BankManager.get_user_info_data(session, uname)
render_data = {"page_type": "user", "payload": user_payload}
image_bytes = await ui.render_template(
"pages/builtin/mahiro_bank",
data=render_data,
viewport={"width": 386, "height": 10},
)
await MessageUtils.build_message(image_bytes).send()
logger.info("查看银行个人信息", arparma.header_result, session=session)
@_matcher.assign("bank-info")
async def _(session: Uninfo, arparma: Arparma):
result = await BankManager.get_bank_info()
await MessageUtils.build_message(result).send()
overview_payload = await BankManager.get_bank_info_data()
render_data = {"page_type": "overview", "payload": overview_payload}
image_bytes = await ui.render_template(
"pages/builtin/mahiro_bank",
data=render_data,
viewport={"width": 450, "height": 10},
)
await MessageUtils.build_message(image_bytes).send()
logger.info("查看银行信息", arparma.header_result, session=session)
@@ -2,17 +2,16 @@ import asyncio
from datetime import datetime, timedelta
import random
from nonebot_plugin_htmlrender import template_to_pic
from nonebot_plugin_uninfo import Uninfo
from tortoise.expressions import RawSQL
from tortoise.functions import Count, Sum
from zhenxun.configs.config import Config
from zhenxun.configs.path_config import TEMPLATE_PATH
from zhenxun.models.mahiro_bank import MahiroBank
from zhenxun.models.mahiro_bank_log import MahiroBankLog
from zhenxun.models.sign_user import SignUser
from zhenxun.models.user_console import UserConsole
from zhenxun.services import avatar_service
from zhenxun.utils.enum import BankHandleType, GoldHandle
from zhenxun.utils.platform import PlatformUtils
@@ -158,15 +157,15 @@ class BankManager:
)
@classmethod
async def get_user_info(cls, session: Uninfo, uname: str) -> bytes:
"""获取用户数据
async def get_user_info_data(cls, session: Uninfo, uname: str) -> dict:
"""获取用户数据(返回字典)
参数:
session: Uninfo
uname: 用户id
返回:
bytes: 图片数据
dict: 用户银行数据字典
"""
user_id = session.user.id
user = await cls.get_user(user_id=user_id)
@@ -199,9 +198,9 @@ class BankManager:
deposit_list = [
{
"id": deposit.id,
"date": now.date(),
"date": str(now.date()),
"start_time": str(deposit.create_time).split(".")[0],
"end_time": end_time.replace(microsecond=0),
"end_time": str(end_time.replace(microsecond=0)),
"amount": deposit.amount,
"rate": f"{deposit.rate * 100:.2f}",
"projected_revenue": int(
@@ -212,12 +211,12 @@ class BankManager:
for deposit in user_today_deposit
]
platform = PlatformUtils.get_platform(session)
data = {
avatar_path = await avatar_service.get_avatar_path(platform, user_id)
avatar_url = avatar_path.as_uri() if avatar_path else ""
return {
"name": uname,
"rank": rank + 1,
"avatar_url": PlatformUtils.get_user_avatar_url(
user_id, platform, session.self_id
),
"avatar_url": avatar_url or "",
"amount": user.amount,
"deposit_count": deposit_count,
"today_deposit_count": len(user_today_deposit),
@@ -225,21 +224,16 @@ class BankManager:
"projected_revenue": projected_revenue,
"today_deposit_amount": today_deposit_amount,
"deposit_list": deposit_list,
"create_time": now.replace(microsecond=0),
"create_time": str(now.replace(microsecond=0)),
}
return await template_to_pic(
template_path=str((TEMPLATE_PATH / "mahiro_bank").absolute()),
template_name="user.html",
templates={"data": data},
pages={
"viewport": {"width": 386, "height": 700},
"base_url": f"file://{TEMPLATE_PATH}",
},
wait=2,
)
@classmethod
async def get_bank_info(cls) -> bytes:
async def get_bank_info_data(cls) -> dict:
"""获取银行总览数据(返回字典)
返回:
dict: 银行总览数据字典
"""
now = datetime.now()
now_start = now - timedelta(
hours=now.hour, minutes=now.minute, seconds=now.second
@@ -293,27 +287,17 @@ class BankManager:
if lasted_log:
date = now.date() - lasted_log.create_time.date()
date = (date.days or 1) + 1
data = {
"amount_sum": bank_data[0]["amount_sum"],
"user_count": bank_data[0]["user_count"],
return {
"amount_sum": bank_data[0]["amount_sum"] or 0,
"user_count": bank_data[0]["user_count"] or 0,
"today_count": today_count,
"day_amount": int(bank_data[0]["amount_sum"] / date),
"day_amount": int((bank_data[0]["amount_sum"] or 0) / date),
"interest_amount": interest_amount[0]["amount_sum"] or 0,
"active_user_count": active_user_count[0]["count"] or 0,
"e_data": e_date,
"e_amount": e_amount,
"create_time": now.replace(microsecond=0),
"create_time": str(now.replace(microsecond=0)),
}
return await template_to_pic(
template_path=str((TEMPLATE_PATH / "mahiro_bank").absolute()),
template_name="bank.html",
templates={"data": data},
pages={
"viewport": {"width": 450, "height": 750},
"base_url": f"file://{TEMPLATE_PATH}",
},
wait=2,
)
@classmethod
async def deposit(
@@ -406,7 +390,6 @@ class BankManager:
bank_data[log.user_id].append(log)
log_create_list = []
log_update_list = []
# 计算每日默认金币
for bank_user in bank_user_list:
if user := user_data.get(bank_user.user_id):
amount = bank_user.amount
@@ -414,7 +397,6 @@ class BankManager:
amount -= sum(log.amount for log in logs)
if not amount:
continue
# 计算每日默认金币
gold = int(amount * bank_user.rate)
user.gold += gold
log_create_list.append(
@@ -426,7 +408,6 @@ class BankManager:
is_completed=True,
)
)
# 计算每日存款金币
for user_id, logs in bank_data.items():
if user := user_data.get(user_id):
for log in logs:
@@ -17,6 +17,8 @@ from zhenxun.configs.utils import PluginExtraData, RegisterConfig, Task
from zhenxun.models.event_log import EventLog
from zhenxun.models.group_console import GroupConsole
from zhenxun.services.cache import CacheRoot
from zhenxun.services.log import logger
from zhenxun.services.tags import tag_manager
from zhenxun.utils.common_utils import CommonUtils
from zhenxun.utils.enum import EventLogType, PluginType
from zhenxun.utils.platform import PlatformUtils
@@ -135,6 +137,11 @@ async def _(
await EventLog.create(
user_id=user_id, group_id=group_id, event_type=EventLogType.KICK_BOT
)
await tag_manager.remove_group_from_all_tags(group_id)
logger.info(
f"机器人被移出群聊,已自动从所有静态标签中移除群组 {group_id}",
"群组标签管理",
)
elif event.sub_type in ["leave", "kick"]:
if event.sub_type == "leave":
"""主动退群"""
@@ -1,6 +1,6 @@
from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import Alconna, Args, Subcommand, on_alconna
from nonebot_plugin_alconna import Alconna, Args, Match, Option, Subcommand, on_alconna
from nonebot_plugin_session import EventSession
from zhenxun.configs.utils import PluginExtraData
@@ -16,11 +16,16 @@ __plugin_meta__ = PluginMetadata(
description="插件商店",
usage="""
插件商店 : 查看当前的插件商店
添加插件 id or module : 添加插件
移除插件 id or module : 移除插件
搜索插件 name or author : 搜索插件
更新插件 id or module : 更新插件
添加插件 id或module或插件名称 ?[-s [git, ali]]: 添加插件
使用-s时指定源,git为github,ali为阿里云
移除插件 id或module: 移除插件
搜索插件 name或author: 搜索插件
更新插件 id或module: 更新插件
更新全部插件 : 更新全部插件
示例:
添加插件 pix
添加插件 真寻日报 -s git
""".strip(),
extra=PluginExtraData(
author="HibiKier",
@@ -32,7 +37,11 @@ __plugin_meta__ = PluginMetadata(
_matcher = on_alconna(
Alconna(
"插件商店",
Subcommand("add", Args["plugin_id", str]),
Subcommand(
"add",
Args["plugin_id", str],
Option("-s", Args["source", str]),
),
Subcommand("remove", Args["plugin_id", str]),
Subcommand("search", Args["plugin_name_or_author", str]),
Subcommand("update", Args["plugin_id", str]),
@@ -91,13 +100,20 @@ async def _(session: EventSession):
@_matcher.assign("add")
async def _(session: EventSession, plugin_id: str):
async def _(session: EventSession, plugin_id: str, source: Match[str]):
if is_number(plugin_id):
await MessageUtils.build_message(f"正在添加插件 Id: {plugin_id}").send()
else:
await MessageUtils.build_message(
f"正在添加插件 Module/名称: {plugin_id}"
).send()
source_str = source.result if source.available else None
if source_str and source_str not in ["ali", "git"]:
await MessageUtils.build_message(
f"源类型错误: {source_str} 请使用 ali 或 git"
).finish()
try:
if is_number(plugin_id):
await MessageUtils.build_message(f"正在添加插件 Id: {plugin_id}").send()
else:
await MessageUtils.build_message(f"正在添加插件 Module: {plugin_id}").send()
result = await StoreManager.add_plugin(plugin_id)
result = await StoreManager.add_plugin(plugin_id, source_str)
except Exception as e:
logger.error(f"添加插件 Id: {plugin_id}失败", "插件商店", session=session, e=e)
await MessageUtils.build_message(
@@ -1,3 +1,4 @@
import os
from pathlib import Path
import random
import shutil
@@ -10,11 +11,12 @@ from zhenxun.configs.path_config import TEMP_PATH
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.services.log import logger
from zhenxun.services.plugin_init import PluginInitManager
from zhenxun.utils.enum import PluginType
from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
from zhenxun.utils.repo_utils import RepoFileManager
from zhenxun.utils.repo_utils.models import RepoFileInfo, RepoType
from zhenxun.utils.utils import is_number
from zhenxun.utils.utils import is_number, win_on_rm_error
from .config import (
BASE_PATH,
@@ -163,13 +165,17 @@ class StoreManager:
@classmethod
async def get_plugin_by_value(
cls, index_or_module: str, is_update: bool = False
cls,
index_or_module: str,
is_update: bool = False,
is_remove: bool = False,
) -> tuple[StorePluginInfo, bool]:
"""获取插件信息
参数:
index_or_module: 插件索引或模块名
is_update: 是否是更新插件
is_remove: 是否是移除插件
异常:
PluginStoreException: 插件不存在
@@ -179,6 +185,8 @@ class StoreManager:
StorePluginInfo: 插件信息
bool: 是否是外部插件
"""
plugin_list: list[StorePluginInfo]
extra_plugin_list: list[StorePluginInfo]
plugin_list, extra_plugin_list = await cls.get_data()
plugin_info = None
is_external = False
@@ -196,14 +204,32 @@ class StoreManager:
break
if not plugin_info:
raise PluginStoreException(f"插件不存在: {plugin_key}")
if not is_update and plugin_info.module in [p[0] for p in db_plugin_list]:
modules = [p[0] for p in db_plugin_list]
if is_remove:
if plugin_info.module not in modules:
raise PluginStoreException(f"插件 {plugin_info.name} 未安装,无法移除")
if plugin_obj := await PluginInfo.get_plugin(
module=plugin_info.module, plugin_type=PluginType.PARENT
):
plugin_info.module_path = plugin_obj.module_path
elif plugin_obj := await PluginInfo.get_plugin(module=plugin_info.module):
plugin_info.module_path = plugin_obj.module_path
return plugin_info, is_external
if is_update:
if plugin_info.module not in modules:
raise PluginStoreException(f"插件 {plugin_info.name} 未安装,无法更新")
return plugin_info, is_external
if plugin_info.module in modules:
raise PluginStoreException(f"插件 {plugin_info.name} 已安装,无需重复安装")
if plugin_info.module not in [p[0] for p in db_plugin_list] and is_update:
raise PluginStoreException(f"插件 {plugin_info.name} 未安装,无法更新")
return plugin_info, is_external
@classmethod
async def add_plugin(cls, index_or_module: str) -> str:
async def add_plugin(cls, index_or_module: str, source: str | None = None) -> str:
"""添加插件
参数:
@@ -221,43 +247,60 @@ class StoreManager:
plugin_info.github_url = f"{github_url_split[0]}/tree/{version_split[1]}"
logger.info(f"正在安装插件 {plugin_info.name}...", LOG_COMMAND)
await cls.install_plugin_with_repo(
plugin_info.github_url,
plugin_info.module_path,
plugin_info.is_dir,
plugin_info,
is_external,
source,
)
return f"插件 {plugin_info.name} 安装成功! 重启后生效"
@classmethod
async def install_plugin_with_repo(
cls,
github_url: str,
module_path: str,
is_dir: bool,
plugin_info: StorePluginInfo,
is_external: bool = False,
source: str | None = None,
):
"""安装插件
参数:
github_url: 仓库地址
module_path: 模块路径
is_dir: 是否是文件夹
plugin_info: 插件信息
is_external: 是否是外部仓库
source: 源
"""
repo_type = RepoType.GITHUB if is_external else None
replace_module_path = module_path.replace(".", "/")
if source == "ali":
repo_type = RepoType.ALIYUN
elif source == "git":
repo_type = RepoType.GITHUB
module_path = plugin_info.module_path
is_dir = plugin_info.is_dir
github_url = plugin_info.github_url
assert github_url
replace_module_path = module_path.replace(".", "/").lstrip("/")
plugin_name = module_path.split(".")[-1] or plugin_info.module
if is_dir:
files = await RepoFileManager.list_directory_files(
github_url, replace_module_path, repo_type=repo_type
)
else:
files = [RepoFileInfo(path=f"{replace_module_path}.py", is_dir=False)]
local_path = BASE_PATH / "plugins" if is_external else BASE_PATH
if not is_external:
target_dir = BASE_PATH
elif is_dir and module_path == ".":
target_dir = BASE_PATH / "plugins" / plugin_name
else:
target_dir = BASE_PATH / "plugins"
files = [file for file in files if not file.is_dir]
download_files = [(file.path, local_path / file.path) for file in files]
await RepoFileManager.download_files(
github_url, download_files, repo_type=repo_type
download_files = [(file.path, target_dir / file.path) for file in files]
result = await RepoFileManager.download_files(
github_url,
download_files,
repo_type=repo_type,
sparse_path=replace_module_path,
target_dir=target_dir,
)
if not result.success:
raise PluginStoreException(result.error_message)
requirement_paths = [
file
@@ -268,7 +311,7 @@ class StoreManager:
is_install_req = False
for requirement_path in requirement_paths:
requirement_file = local_path / requirement_path.path
requirement_file = target_dir / requirement_path.path
if requirement_file.exists():
is_install_req = True
await VirtualEnvPackageManager.install_requirement(requirement_file)
@@ -310,22 +353,21 @@ class StoreManager:
返回:
str: 返回消息
"""
plugin_info, _ = await cls.get_plugin_by_value(index_or_module)
path = BASE_PATH
if plugin_info.github_url:
path = BASE_PATH / "plugins"
for p in plugin_info.module_path.split("."):
path = path / p
plugin_info, _ = await cls.get_plugin_by_value(index_or_module, is_remove=True)
module_path = plugin_info.module_path
module = module_path.split(".")[-1]
path = BASE_PATH.parent / Path(module_path.replace(".", os.sep))
if not plugin_info.is_dir:
path = Path(f"{path}.py")
path = path.parent / f"{module}.py"
if not path.exists():
return f"插件 {plugin_info.name} 不存在..."
logger.debug(f"尝试移除插件 {plugin_info.name} 文件: {path}", LOG_COMMAND)
if plugin_info.is_dir:
shutil.rmtree(path)
# 处理 Windows 下 .git 等目录内只读文件导致的 WinError 5
shutil.rmtree(path, onerror=win_on_rm_error)
else:
path.unlink()
await PluginInitManager.remove(f"zhenxun.{plugin_info.module_path}")
await PluginInitManager.remove(module_path)
return f"插件 {plugin_info.name} 移除成功! 重启后生效"
@classmethod
@@ -392,9 +434,7 @@ class StoreManager:
if plugin_info.github_url is None:
plugin_info.github_url = DEFAULT_GITHUB_URL
await cls.install_plugin_with_repo(
plugin_info.github_url,
plugin_info.module_path,
plugin_info.is_dir,
plugin_info,
is_external,
)
return f"插件 {plugin_info.name} 更新成功! 重启后生效"
@@ -416,7 +456,7 @@ class StoreManager:
update_success_list = []
result = "--已更新{}个插件 {}个失败 {}个成功--"
logger.info(f"尝试更新全部插件 {plugin_name_list}", LOG_COMMAND)
for plugin_info in plugin_list:
for plugin_info in all_plugin_list:
try:
db_plugin_list = await cls.get_loaded_plugins("module", "version")
suc_plugin = {p[0]: (p[1] or "Unknown") for p in db_plugin_list}
@@ -442,9 +482,7 @@ class StoreManager:
plugin_info.github_url = DEFAULT_GITHUB_URL
is_external = False
await cls.install_plugin_with_repo(
plugin_info.github_url,
plugin_info.module_path,
plugin_info.is_dir,
plugin_info,
is_external,
)
update_success_list.append(plugin_info.name)
@@ -496,11 +534,11 @@ class StoreManager:
raise PluginStoreException("插件ID不存在...")
return all_plugin_list[idx].module
elif isinstance(plugin_id, str):
result = (
None
if plugin_id not in [v.module for v in all_plugin_list]
else plugin_id
) or next(v for v in all_plugin_list if v.name == plugin_id).module
if not result:
raise PluginStoreException("插件 Module / 名称 不存在...")
return result
if plugin_id in [v.module for v in all_plugin_list]:
return plugin_id
for plugin_info in all_plugin_list:
if plugin_info.name.lower() == plugin_id.lower():
return plugin_info.module
raise PluginStoreException("插件 Module / 名称 不存在...")
+60 -85
View File
@@ -1,7 +1,6 @@
import asyncio
from datetime import datetime
import random
import time
from nonebot import on_message, on_request
from nonebot.adapters.onebot.v11 import (
@@ -12,7 +11,6 @@ from nonebot.adapters.onebot.v11 import (
from nonebot.adapters.onebot.v11 import Bot as v11Bot
from nonebot.adapters.onebot.v12 import Bot as v12Bot
from nonebot.plugin import PluginMetadata
from nonebot_plugin_apscheduler import scheduler
from nonebot_plugin_session import EventSession
from zhenxun.configs.config import BotConfig, Config
@@ -66,19 +64,6 @@ __plugin_meta__ = PluginMetadata(
)
class Timer:
data: dict[str, float] = {} # noqa: RUF012
@classmethod
def check(cls, uid: int | str):
return True if uid not in cls.data else time.time() - cls.data[uid] > 5 * 60
@classmethod
def clear(cls):
now = time.time()
cls.data = {k: v for k, v in cls.data.items() if v - now < 5 * 60}
# TODO: 其他平台请求
friend_req = on_request(priority=5, block=True)
@@ -86,68 +71,70 @@ group_req = on_request(priority=5, block=True)
_t = on_message(priority=999, block=False, rule=lambda: False)
cache = CacheRoot.cache_dict(
"REQUEST_CACHE", (base_config.get("TIP_MESSAGE_LIMIT") or 360) * 60, str
)
cache = CacheRoot.cache_dict("REQUEST_CACHE", 60, str)
@friend_req.handle()
async def _(bot: v12Bot | v11Bot, event: FriendRequestEvent, session: EventSession):
if event.user_id and Timer.check(event.user_id):
logger.debug("收录好友请求...", "好友请求", target=event.user_id)
user = await bot.get_stranger_info(user_id=event.user_id)
nickname = user["nickname"]
# sex = user["sex"]
# age = str(user["age"])
comment = event.comment
if base_config.get("AUTO_ADD_FRIEND"):
logger.debug(
"已开启好友请求自动同意,成功通过该请求",
"好友请求",
target=event.user_id,
)
await asyncio.sleep(random.randint(1, 10))
await bot.set_friend_add_request(flag=event.flag, approve=True)
await FriendUser.create(
user_id=str(user["user_id"]), user_name=user["nickname"]
)
else:
# 旧请求全部设置为过期
await FgRequest.filter(
request_type=RequestType.FRIEND,
user_id=str(event.user_id),
handle_type__isnull=True,
).update(handle_type=RequestHandleType.EXPIRE)
f = await FgRequest.create(
request_type=RequestType.FRIEND,
platform=session.platform,
bot_id=bot.self_id,
flag=event.flag,
user_id=event.user_id,
nickname=nickname,
comment=comment,
)
cache_key = str(event.user_id)
if not cache.get(cache_key):
cache.set(cache_key, "1")
results = await PlatformUtils.send_superuser(
bot,
f"*****一份好友申请*****\n"
f"ID: {f.id}\n"
f"昵称:{nickname}({event.user_id})\n"
f"自动同意:{'√' if base_config.get('AUTO_ADD_FRIEND') else '×'}\n"
f"日期:{datetime.now().replace(microsecond=0)}\n"
f"备注:{event.comment}",
)
if message_ids := [
str(r[1].msg_ids[0]["message_id"])
for r in results
if r[1] and r[1].msg_ids
]:
f.message_ids = ",".join(message_ids)
await f.save(update_fields=["message_ids"])
logger.debug("收录好友请求...", "好友请求", target=event.user_id)
user = await bot.get_stranger_info(user_id=event.user_id)
nickname = user["nickname"]
# sex = user["sex"]
# age = str(user["age"])
comment = event.comment
if base_config.get("AUTO_ADD_FRIEND"):
logger.debug(
"已开启好友请求自动同意,成功通过该请求",
"好友请求",
target=event.user_id,
)
await asyncio.sleep(random.randint(1, 10))
await bot.set_friend_add_request(flag=event.flag, approve=True)
await FriendUser.create(
user_id=str(user["user_id"]), user_name=user["nickname"]
)
else:
logger.debug("好友请求五分钟内重复, 已忽略", "好友请求", target=event.user_id)
# 旧请求全部设置为过期
await FgRequest.filter(
request_type=RequestType.FRIEND,
user_id=str(event.user_id),
handle_type__isnull=True,
).update(handle_type=RequestHandleType.EXPIRE)
f = await FgRequest.create(
request_type=RequestType.FRIEND,
platform=session.platform,
bot_id=bot.self_id,
flag=event.flag,
user_id=event.user_id,
nickname=nickname,
comment=comment,
)
cache_key = str(event.user_id)
if not cache.get(cache_key):
cache.set(cache_key, "1")
results = await PlatformUtils.send_superuser(
bot,
f"*****一份好友申请*****\n"
f"ID: {f.id}\n"
f"昵称:{nickname}({event.user_id})\n"
f"自动同意:{'√' if base_config.get('AUTO_ADD_FRIEND') else '×'}\n"
f"日期:{datetime.now().replace(microsecond=0)}\n"
f"备注:{event.comment}",
)
if message_ids := [
str(r[1].msg_ids[0]["message_id"])
for r in results
if r[1] and r[1].msg_ids
]:
f.message_ids = ",".join(message_ids)
await f.save(update_fields=["message_ids"])
else:
tip_limit = base_config.get("TIP_MESSAGE_LIMIT") or 360
logger.debug(
f"好友请求{tip_limit}分钟内重复, 已忽略",
"好友请求",
target=cache_key,
)
@group_req.handle()
@@ -227,7 +214,7 @@ async def _(bot: v12Bot | v11Bot, event: GroupRequestEvent, session: EventSessio
"\n在群组中 群组管理员与群主 允许使用管理员帮助"
"(包括ban与功能开关等)\n请在群组中发送 '管理员帮助'",
)
elif cache.get(f"{event.group_id}"):
elif not cache.get(f"{event.group_id}"):
cache.set(f"{event.group_id}", "1")
logger.debug(
f"收录 用户[{event.user_id}] 群聊[{event.group_id}] 群聊请求",
@@ -284,15 +271,3 @@ async def _(bot: v12Bot | v11Bot, event: GroupRequestEvent, session: EventSessio
"群聊请求",
target=f"{event.user_id}:{event.group_id}",
)
@scheduler.scheduled_job(
"interval",
minutes=5,
)
async def _():
Timer.clear()
async def _():
Timer.clear()
@@ -2,6 +2,7 @@ import nonebot
from nonebot_plugin_apscheduler import scheduler
from zhenxun.services.log import logger
from zhenxun.services.tags import tag_manager
from zhenxun.utils.platform import PlatformUtils
@@ -37,3 +38,20 @@ async def _():
f"Bot: {bot.self_id} 自动更新好友信息错误", "自动更新好友", e=e
)
logger.info("自动更新好友信息成功...")
# 自动清理静态标签中的无效群组
@scheduler.scheduled_job(
"cron",
hour=23,
minute=30,
)
async def _prune_stale_tags():
deleted_count = await tag_manager.prune_stale_group_links()
if deleted_count > 0:
logger.info(
f"定时任务:成功清理了 {deleted_count} 个无效的群组标签" f"关联。",
"群组标签管理",
)
else:
logger.debug("定时任务:未发现无效的群组标签关联。", "群组标签管理")
@@ -10,47 +10,54 @@ __all__ = ["commands", "handlers"]
__plugin_meta__ = PluginMetadata(
name="定时任务管理",
description="查看和管理由 SchedulerManager 控制的定时任务。",
usage="""
📋 定时任务管理 - 支持群聊和私聊操作
usage="""### 📋 定时任务管理
---
#### 🔍 **查看任务**
- **命令**: `定时任务 查看 [选项]` (别名: `ls`, `list`)
- **选项**:
- `--all`: 查看所有群组的任务 **(SUPERUSER)**。
- `-g <群号>`: 查看指定群组的任务 **(SUPERUSER)**。
- `-p <插件名>`: 按插件名筛选。
- `--page <页码>`: 指定页码。
- **说明**:
- 在群聊中不带选项使用,默认查看本群任务。
- 在私聊中必须使用 `-g <群号>` 或 `--all`。
🔍 查看任务:
定时任务 查看 [-all] [-g <群号>] [-p <插件>] [--page <页码>]
• 群聊中: 查看本群任务
• 私聊中: 必须使用 -g <群号> 或 -all 选项 (SUPERUSER)
#### 📊 **任务状态**
- **命令**: `定时任务 状态 <任务ID>` (别名: `status`, `info`, `任务状态`)
- **说明**: 查看单个任务的详细信息和状态。
📊 任务状态:
定时任务 状态 <任务ID> 或 任务状态 <任务ID>
• 查看单个任务的详细信息和状态
#### ⚙️ **任务管理 (SUPERUSER)**
- **设置**: `定时任务 设置 <插件>` (别名: `add`, `开启`)
- **选项**:
- `<时间选项>`: 详见下文。
- `-g <群号|all>`: 指定目标群组。
- `--kwargs "<参数>"`: 设置任务参数 (例: `"key=value"`)。
- **删除**: `定时任务 删除 <ID>` (别名: `del`, `rm`, `remove`, `关闭`, `取消`)
- **暂停**: `定时任务 暂停 <ID>` (别名: `pause`)
- **恢复**: `定时任务 恢复 <ID>` (别名: `resume`)
- **执行**: `定时任务 执行 <ID>` (别名: `trigger`, `run`)
- **更新**: `定时任务 更新 <ID>` (别名: `update`, `modify`, `修改`)
- **选项**:
- `<时间选项>`: 详见下文。
- `--kwargs "<参数>"`: 更新任务参数。
- **批量操作**: `删除/暂停/恢复` 命令支持通过 `-p <插件名>` 或 `--all`
(当前群) 进行批量操作。
⚙️ 任务管理 (SUPERUSER):
定时任务 设置 <插件> [时间选项] [-g <群号> | -g all] [--kwargs <参数>]
定时任务 删除 <任务ID> | -p <插件> [-g <群号>] | -all
定时任务 暂停 <任务ID> | -p <插件> [-g <群号>] | -all
定时任务 恢复 <任务ID> | -p <插件> [-g <群号>] | -all
定时任务 执行 <任务ID>
定时任务 更新 <任务ID> [时间选项] [--kwargs <参数>]
# [修改] 增加说明
• 说明: -p 选项可单独使用,用于操作指定插件的所有任务
#### 📝 **时间选项 (设置/更新时三选一)**
- `--cron "<分> <时> <日> <月> <周>"` (例: `--cron "0 8 * * *"`)
- `--interval <时间间隔>` (例: `--interval 30m`, `2h`, `10s`)
- `--date "<YYYY-MM-DD HH:MM:SS>"` (例: `--date "2024-01-01 08:00:00"`)
- `--daily "<HH:MM>"` (例: `--daily "08:30"`)
📝 时间选项 (三选一):
--cron "<分> <时> <日> <月> <周>" # 例: --cron "0 8 * * *"
--interval <时间间隔> # 例: --interval 30m, 2h, 10s
--date "<YYYY-MM-DD HH:MM:SS>" # 例: --date "2024-01-01 08:00:00"
--daily "<HH:MM>" # 例: --daily "08:30"
📚 其他功能:
定时任务 插件列表 # 查看所有可设置定时任务的插件 (SUPERUSER)
🏷️ 别名支持:
查看: ls, list | 设置: add, 开启 | 删除: del, rm, remove, 关闭, 取消
暂停: pause | 恢复: resume | 执行: trigger, run | 状态: status, info
更新: update, modify, 修改 | 插件列表: plugins
#### 📚 **其他功能**
- **命令**: `定时任务 插件列表` (别名: `plugins`)
- **说明**: 查看所有可设置定时任务的插件 **(SUPERUSER)**。
""".strip(),
extra=PluginExtraData(
author="HibiKier",
version="0.1.2",
plugin_type=PluginType.SUPERUSER,
is_show=False,
configs=[
RegisterConfig(
module="SchedulerManager",
@@ -80,6 +87,38 @@ __plugin_meta__ = PluginMetadata(
help="定时任务使用的时区,默认为 Asia/Shanghai",
type=str,
),
RegisterConfig(
module="SchedulerManager",
key="SCHEDULE_ADMIN_LEVEL",
value=5,
help="设置'定时任务'系列命令的基础使用权限等级",
default_value=5,
type=int,
),
RegisterConfig(
module="SchedulerManager",
key="DEFAULT_JITTER_SECONDS",
value=60,
help="为多目标定时任务(如 --all, -t)设置的默认触发抖动秒数,避免所有任务同时启动。", # noqa: E501
default_value=60,
type=int,
),
RegisterConfig(
module="SchedulerManager",
key="DEFAULT_SPREAD_SECONDS",
value=300,
help="为多目标定时任务设置的默认执行分散秒数,将任务执行分散在一个时间窗口内。",
default_value=300,
type=int,
),
RegisterConfig(
module="SchedulerManager",
key="DEFAULT_INTERVAL_SECONDS",
value=0,
help="为多目标定时任务设置的默认串行执行间隔秒数(大于0时生效),用于控制任务间的固定时间间隔。",
default_value=0,
type=int,
),
],
).to_dict(),
)
@@ -1,33 +1,101 @@
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 arclet.alconna import ArparmaBehavior
from nonebot_plugin_alconna import (
Alconna,
AlconnaMatch,
Args,
Match,
Arparma,
Field,
MultiVar,
Option,
Query,
Subcommand,
on_alconna,
store_true,
)
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
def create_time_options() -> list[Option]:
"""创建一组用于定义任务执行时间的通用选项"""
return [
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)",
),
]
def create_targeting_options() -> list[Option]:
"""创建一组用于定位定时任务的通用选项"""
return [
Option("-p", Args["plugin_name", str], help_text="按插件名筛选"),
Option("-u", Args["user_id", str], help_text="指定用户ID"),
Option(
"-g",
Args["group_ids", MultiVar(str)],
help_text="指定一个或多个群组ID (SUPERUSER)",
),
Option("-t", Args["tag_name", str], help_text="指定标签"),
Option("--all", action=store_true, help_text="对所有群生效"),
Option("--global", action=store_true, help_text="操作全局任务"),
Option("--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)"),
]
class SchedulerAdminBehavior(ArparmaBehavior):
"""对定时任务命令的参数进行复杂的复合验证。"""
def _validate_time_options(self, interface: Arparma, subcommand: str):
"""验证时间选项 (--cron, --interval, --date, --daily) 的互斥性。"""
time_options = ["cron", "interval", "date", "daily"]
provided_options = [
f"--{opt}" for opt in time_options if interface.query(f"{subcommand}.{opt}")
]
if len(provided_options) > 1:
interface.behave_fail(
f"时间选项 {', '.join(provided_options)} 不能同时使用,请只选择一个。"
)
def _validate_target_options(self, interface: Arparma, subcommand: str):
"""验证目标选项 (-u, -g, -t, --all, --global) 的互斥性。"""
target_flags = {
"-u": "u",
"-g": "g",
"-t": "t",
"--all": "all",
"--global": "global",
}
provided_flags = [
flag
for flag, name in target_flags.items()
if interface.query(f"{subcommand}.{name}")
]
if len(provided_flags) > 1:
interface.behave_fail(
f"目标选项 {', '.join(provided_flags)} 是互斥的,请只选择一个。"
)
def operate(self, interface: Arparma):
subcommand = next(iter(interface.subcommands.keys()), None)
if not subcommand:
return
if subcommand in {"设置", "更新"}:
self._validate_time_options(interface, subcommand)
if subcommand in {"查看", "设置", "删除", "暂停", "恢复"}:
self._validate_target_options(interface, subcommand)
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="按插件名筛选"),
*create_targeting_options(),
Option("--page", Args["page", int, 1], help_text="指定页码"),
alias=["ls", "list"],
help_text="查看定时任务",
@@ -35,17 +103,41 @@ schedule_cmd = on_alconna(
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="设置特定执行日期"),
*create_time_options(),
Option(
"--daily",
Args["daily_expr", str],
help_text="设置每天执行的时间 (如 08:20)",
"-g", Args["group_ids", MultiVar(str)], help_text="指定一个或多个群组ID"
),
Option("-g", Args["group_id", str], help_text="指定群组ID或'all'"),
Option("-all", help_text="对所有群生效 (等同于 -g all)"),
Option("-u", Args["user_id", str], help_text="指定用户ID"),
Option("-t", Args["tag_name", str], help_text="指定一个群组标签"),
Option("--all", action=store_true, help_text="对所有群生效"),
Option("--global", action=store_true, help_text="设置为全局任务"),
Option("--name", Args["job_name", str], help_text="为任务设置一个别名"),
Option("--kwargs", Args["kwargs_str", str], help_text="设置任务参数"),
Option(
"--params-cli",
Args["cli_string", str],
help_text="传递给插件任务的原始命令行参数字符串",
),
Option(
"--jitter",
Args["jitter_seconds", int],
help_text="设置触发时间抖动(秒)",
),
Option(
"--spread",
Args["spread_seconds", int],
help_text="设置多目标执行的分散延迟(秒)",
),
Option(
"--fixed-interval",
Args["interval_seconds", int],
help_text="设置任务间的固定执行间隔(秒),将强制串行",
),
Option(
"--permission",
Args["perm_level", int],
help_text="设置任务的管理权限等级",
),
Option(
"--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)"
),
@@ -54,64 +146,75 @@ schedule_cmd = on_alconna(
),
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)"
),
Args[
"schedule_ids?",
MultiVar(int),
Field(unmatch_tips=lambda text: f"任务ID '{text}' 必须是数字!"),
],
*create_targeting_options(),
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)"
),
Args[
"schedule_ids?",
MultiVar(int),
Field(unmatch_tips=lambda text: f"任务ID '{text}' 必须是数字!"),
],
*create_targeting_options(),
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)"
),
Args[
"schedule_ids?",
MultiVar(int),
Field(unmatch_tips=lambda text: f"任务ID '{text}' 必须是数字!"),
],
*create_targeting_options(),
alias=["resume"],
help_text="恢复一个或多个定时任务",
),
Subcommand(
"执行",
Args["schedule_id", int],
Args[
"schedule_id",
int,
Field(
missing_tips=lambda: "请提供要立即执行的任务ID!",
unmatch_tips=lambda text: f"任务ID '{text}' 必须是数字!",
),
],
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)",
),
Args[
"schedule_id",
int,
Field(
missing_tips=lambda: "请提供要更新的任务ID!",
unmatch_tips=lambda text: f"任务ID '{text}' 必须是数字!",
),
],
*create_time_options(),
Option("--kwargs", Args["kwargs_str", str], help_text="更新参数"),
alias=["update", "modify", "修改"],
help_text="更新任务配置",
),
Subcommand(
"状态",
Args["schedule_id", int],
Args[
"schedule_id",
int,
Field(
missing_tips=lambda: "请提供要查看状态的任务ID!",
unmatch_tips=lambda text: f"任务ID '{text}' 必须是数字!",
),
],
alias=["status", "info"],
help_text="查看单个任务的详细状态",
),
@@ -120,179 +223,19 @@ schedule_cmd = on_alconna(
alias=["plugins"],
help_text="列出所有可用的插件",
),
behaviors=[SchedulerAdminBehavior()],
),
priority=5,
block=True,
rule=admin_check(1),
skip_for_unmatch=False,
aliases={"schedule", "cron", "job"},
rule=admin_check("SchedulerManager", "SCHEDULE_ADMIN_LEVEL"),
)
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)
@@ -0,0 +1,314 @@
from typing import Any
from pydantic import BaseModel, ValidationError
from zhenxun.models.level_user import LevelUser
from zhenxun.models.scheduled_job import ScheduledJob
from zhenxun.services import scheduler_manager
from zhenxun.services.log import logger
from zhenxun.services.scheduler.repository import ScheduleRepository
from zhenxun.utils.pydantic_compat import model_dump, model_validate
from . import presenters
class SchedulerAdminService:
"""封装定时任务管理的所有业务逻辑"""
async def get_schedules_view(
self,
user_id: str,
group_id: str | None,
is_superuser: bool,
filters: dict[str, Any],
page: int,
) -> bytes | str:
"""获取任务列表视图"""
page_size = 30
schedules, total_items = await scheduler_manager.get_schedules(
page=page, page_size=page_size, **filters
)
if not schedules:
return "没有找到任何相关的定时任务。"
permitted_schedules = schedules
skipped_count = 0
if not is_superuser:
permitted_schedules, skipped_count = await self._filter_schedules_for_user(
schedules, user_id, group_id
)
if not permitted_schedules:
return (
f"您没有权限查看任何匹配的任务。(因权限不足跳过 {skipped_count} 个)"
)
title = self._generate_view_title(filters)
return await presenters.format_schedule_list_as_image(
schedules=permitted_schedules,
title=title,
current_page=page,
total_items=total_items,
)
async def set_schedule(
self,
targets: list[str],
creator_permission_level: int,
plugin_name: str,
trigger_info: tuple[str, dict],
job_kwargs: dict,
permission: int,
bot_id: str,
job_name: str | None,
jitter: int | None,
spread: int | None,
interval: int | None,
created_by: str,
) -> str:
"""创建或更新一个定时任务"""
trigger_type, trigger_config = trigger_info
success_targets = []
failed_targets = []
permission_denied_targets = []
execution_options = {}
if jitter is not None:
execution_options["jitter"] = jitter
if spread is not None:
execution_options["spread"] = spread
if interval is not None:
execution_options["interval"] = interval
for target_desc in targets:
target_type, target_id = self._resolve_target_descriptor(target_desc)
existing_schedule = await ScheduleRepository.filter(
plugin_name=plugin_name,
target_type=target_type,
target_identifier=target_id,
bot_id=bot_id,
).first()
if (
existing_schedule
and creator_permission_level < existing_schedule.required_permission
):
permission_denied_targets.append(
(
target_desc,
f"需要 {existing_schedule.required_permission} 级权限",
)
)
continue
if target_type in ["TAG", "ALL_GROUPS"]:
logger.debug(
f"检测到多目标任务 (类型: {target_type}),"
f"将所需权限强制提升至超级用户级别。"
)
permission = 9
try:
schedule = await scheduler_manager.add_schedule(
plugin_name=plugin_name,
target_type=target_type,
target_identifier=target_id,
trigger_type=trigger_type,
trigger_config=trigger_config,
job_kwargs=job_kwargs,
bot_id=bot_id,
required_permission=permission,
name=job_name,
created_by=created_by,
execution_options=execution_options if execution_options else None,
)
if schedule:
success_targets.append((target_desc, schedule.id))
else:
failed_targets.append((target_desc, "服务返回失败"))
except Exception as e:
failed_targets.append((target_desc, str(e)))
return self._format_set_result_message(
targets, success_targets, failed_targets, permission_denied_targets
)
async def perform_bulk_operation(
self,
operation_name: str,
user_id: str,
group_id: str | None,
is_superuser: bool,
targeter,
all_flag: bool,
global_flag: bool,
) -> str:
"""执行批量操作(删除、暂停、恢复)"""
if not is_superuser:
permission_denied = False
if all_flag or global_flag:
permission_denied = True
elif targeter._filters.get("target_type") in ["TAG", "ALL_GROUPS"]:
permission_denied = True
if permission_denied:
return "权限不足,只有超级用户才能对所有群组或通过标签进行批量操作。"
schedules_to_operate = await targeter._get_schedules()
if not schedules_to_operate:
return "没有找到符合条件的可操作任务。"
permitted_schedules, skipped_count = (
(schedules_to_operate, 0)
if is_superuser
else await self._filter_schedules_for_user(
schedules_to_operate, user_id, group_id
)
)
if not permitted_schedules:
return (
f"您没有权限{operation_name}任何匹配的任务。"
f"(因权限不足跳过 {skipped_count} 个)"
)
permitted_ids = [s.id for s in permitted_schedules]
final_targeter = scheduler_manager.target(id__in=permitted_ids)
operation_map = {
"删除": final_targeter.remove,
"暂停": final_targeter.pause,
"恢复": final_targeter.resume,
}
operation_func = operation_map.get(operation_name)
if not operation_func:
return f"未知的批量操作: {operation_name}"
count, _ = await operation_func()
msg = f"批量{operation_name}操作完成:\n - 成功: {count} 个"
if skipped_count > 0:
msg += f"\n - 因权限不足跳过: {skipped_count} 个"
return msg
async def trigger_schedule_now(self, schedule: ScheduledJob) -> str:
"""立即触发一个任务"""
success, message = await scheduler_manager.trigger_now(schedule.id)
return (
presenters.format_trigger_success(schedule)
if success
else f"❌ 触发失败: {message}"
)
async def update_schedule(
self, schedule: ScheduledJob, trigger_info: tuple | None, kwargs_str: str | None
) -> str:
"""更新一个任务的配置"""
trigger_type = trigger_info[0] if trigger_info else None
trigger_config = trigger_info[1] if trigger_info else None
job_kwargs = await self._parse_and_validate_kwargs_for_update(
schedule.plugin_name, kwargs_str
)
success, message = await scheduler_manager.update_schedule(
schedule.id, trigger_type, trigger_config, job_kwargs
)
if success:
updated_schedule = await scheduler_manager.get_schedule_by_id(schedule.id)
return (
presenters.format_update_success(updated_schedule)
if updated_schedule
else "✅ 更新成功,但无法获取更新后的任务详情。"
)
return f"❌ 更新失败: {message}"
async def get_schedule_status(self, schedule_id: int) -> str:
"""获取单个任务的状态"""
status = await scheduler_manager.get_schedule_status(schedule_id)
if not status:
return f"未找到ID为 {schedule_id} 的任务。"
return presenters.format_single_status_message(status)
async def get_plugins_list(self) -> str:
"""获取可定时执行的插件列表"""
return await presenters.format_plugins_list()
async def _filter_schedules_for_user(
self, schedules: list[ScheduledJob], user_id: str, group_id: str | None
) -> tuple[list[ScheduledJob], int]:
user_level = await LevelUser.get_user_level(user_id, group_id)
permitted = [s for s in schedules if user_level >= s.required_permission]
skipped_count = len(schedules) - len(permitted)
return permitted, skipped_count
def _generate_view_title(self, filters: dict) -> str:
title = "定时任务"
if filters.get("target_type") == "ALL_GROUPS":
title = "全局定时任务"
elif "target_identifier" in filters:
title = f"群 {filters['target_identifier']} 的定时任务"
if "plugin_name" in filters:
title += f" [插件: {filters['plugin_name']}]"
return title
def _resolve_target_descriptor(self, target_desc: str) -> tuple[str, str]:
if target_desc == scheduler_manager.ALL_GROUPS:
return "ALL_GROUPS", scheduler_manager.ALL_GROUPS
if target_desc.startswith("tag:"):
return "TAG", target_desc[4:]
if target_desc.isdigit():
return "GROUP", target_desc
return "USER", target_desc
def _format_set_result_message(
self, targets: list, success: list, failed: list, permission_denied: list
) -> str:
msg = f"为 {len(targets)} 个目标设置/更新任务完成:\n"
if success:
msg += f"- 成功: {len(success)} 个"
ids_str = ", ".join(str(s[1]) for s in success)
msg += f"\n - ID列表: {ids_str}"
else:
msg += "- 成功: 0 个"
if permission_denied:
msg += f"\n- 因权限不足跳过: {len(permission_denied)} 个"
for target, reason in permission_denied:
msg += f"\n - 目标 {target}: {reason}"
if failed:
msg += f"\n- 失败: {len(failed)} 个"
for target, reason in failed:
msg += f"\n - 目标 {target}: {reason}"
return msg.strip()
async def _parse_and_validate_kwargs_for_update(
self, plugin_name: str, kwargs_str: str | None
) -> dict:
if not kwargs_str:
return {}
task_meta = scheduler_manager._registered_tasks.get(plugin_name)
if not task_meta:
raise ValueError(f"插件 '{plugin_name}' 未注册。")
params_model = task_meta.get("model")
if not (
params_model
and isinstance(params_model, type)
and issubclass(params_model, BaseModel)
):
raise ValueError(f"插件 '{plugin_name}' 不支持或配置了无效的参数模型。")
try:
raw_kwargs = dict(
item.strip().split("=", 1) for item in kwargs_str.split(";")
)
validated_model = model_validate(params_model, raw_kwargs)
return model_dump(validated_model)
except ValidationError as e:
errors = [f" - {err['loc'][0]}: {err['msg']}" for err in e.errors()]
raise ValueError("参数验证失败:\n" + "\n".join(errors))
except Exception as e:
raise ValueError(f"参数格式错误: {e}")
scheduler_admin_service = SchedulerAdminService()
@@ -0,0 +1,370 @@
from datetime import datetime
import re
from typing import Any
from arclet.alconna import Alconna
from nonebot.adapters import Bot, Event
from nonebot.params import Depends
from nonebot.permission import SUPERUSER
from nonebot_plugin_alconna import (
AlconnaMatch,
AlconnaMatcher,
AlconnaMatches,
AlconnaQuery,
Arparma,
Match,
Query,
)
from nonebot_plugin_session import EventSession
from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.config import Config
from zhenxun.models.level_user import LevelUser
from zhenxun.models.scheduled_job import ScheduledJob
from zhenxun.services import scheduler_manager
from zhenxun.utils.time_utils import TimeUtils
async def GetCreatorPermissionLevel(
bot: Bot,
event: Event,
session: Uninfo,
) -> int:
"""
依赖注入函数:获取执行命令的用户的权限等级。
"""
is_superuser = await SUPERUSER(bot, event)
if is_superuser:
return 999
current_group_id = session.group.id if session.group else None
return await LevelUser.get_user_level(session.user.id, current_group_id)
async def RequireTaskPermission(
matcher: AlconnaMatcher,
bot: Bot,
event: Event,
session: EventSession,
schedule_id_match: Match[int] = AlconnaMatch("schedule_id"),
) -> ScheduledJob:
"""
依赖注入函数:获取并验证用户对特定任务的操作权限。
"""
if not schedule_id_match.available:
await matcher.finish("此操作需要一个有效的任务ID。")
schedule_id = schedule_id_match.result
schedule = await scheduler_manager.get_schedule_by_id(schedule_id)
if not schedule:
await matcher.finish(f"未找到ID为 {schedule_id} 的任务。")
is_superuser = await SUPERUSER(bot, event)
if is_superuser:
return schedule
user_id = session.id1
if not user_id:
await matcher.finish("无法获取用户信息,权限检查失败。")
group_id = session.id3 or session.id2
user_level = await LevelUser.get_user_level(user_id, group_id)
if user_level < schedule.required_permission:
await matcher.finish(
f"权限不足!操作此任务需要 {schedule.required_permission} 级权限,"
f"您当前为 {user_level} 级。"
)
return schedule
def parse_daily_time(time_str: str) -> dict:
"""解析每日时间字符串为 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": "*",
"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' 格式。")
def _parse_trigger_from_arparma(arp: Arparma) -> tuple[str, dict] | None:
"""从 Arparma 中解析时间触发器配置"""
subcommand_name = next(iter(arp.subcommands.keys()), None)
if not subcommand_name:
return None
try:
if cron_expr := arp.query[str](f"{subcommand_name}.cron.cron_expr", None):
return "cron", dict(
zip(
["minute", "hour", "day", "month", "day_of_week"], cron_expr.split()
)
)
if interval_expr := arp.query[str](
f"{subcommand_name}.interval.interval_expr", None
):
return "interval", TimeUtils.parse_interval_to_dict(interval_expr)
if date_expr := arp.query[str](f"{subcommand_name}.date.date_expr", None):
return "date", {"run_date": datetime.fromisoformat(date_expr)}
if daily_expr := arp.query[str](f"{subcommand_name}.daily.daily_expr", None):
return "cron", parse_daily_time(daily_expr)
except ValueError as e:
raise ValueError(f"时间参数解析错误: {e}") from e
return None
async def GetTriggerInfo(
matcher: AlconnaMatcher,
arp: Arparma = AlconnaMatches(),
) -> tuple[str, dict]:
"""依赖注入函数:解析并验证时间触发器"""
try:
trigger_info = _parse_trigger_from_arparma(arp)
if trigger_info:
return trigger_info
except ValueError as e:
await matcher.finish(f"时间参数解析错误: {e}")
await matcher.finish(
"必须提供一种时间选项: --cron, --interval, --date, 或 --daily。"
)
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
async def GetTargeter(
matcher: AlconnaMatcher,
event: Event,
bot: Bot,
arp: Arparma = AlconnaMatches(),
schedule_ids: Match[list[int]] = AlconnaMatch("schedule_ids"),
plugin_name: Match[str] = AlconnaMatch("plugin_name"),
group_ids: Match[list[str]] = AlconnaMatch("group_ids"),
user_id: Match[str] = AlconnaMatch("user_id"),
tag_name: Match[str] = AlconnaMatch("tag_name"),
bot_id_to_operate: str = Depends(GetBotId),
) -> Any:
"""
依赖注入函数,用于解析命令参数并返回一个配置好的 ScheduleTargeter 实例。
"""
subcommand = next(iter(arp.subcommands.keys()), None)
if not subcommand:
await matcher.finish("内部错误:无法解析子命令。")
if schedule_ids.available:
return scheduler_manager.target(id__in=schedule_ids.result)
all_enabled = arp.query(f"{subcommand}.all.value", False)
global_flag = arp.query(f"{subcommand}.global.value", False)
if not any(
[
plugin_name.available,
all_enabled,
global_flag,
user_id.available,
group_ids.available,
tag_name.available,
getattr(event, "group_id", None),
]
):
await matcher.finish(
f"'{subcommand}'操作失败:请提供任务ID,"
f"或通过 -p <插件名> / --global / --all 指定要操作的任务。"
)
filters: dict[str, Any] = {"bot_id": bot_id_to_operate}
if plugin_name.available:
filters["plugin_name"] = plugin_name.result
if global_flag:
filters["target_type"] = "ALL_GROUPS"
filters["target_identifier"] = scheduler_manager.ALL_GROUPS
elif user_id.available:
filters["target_type"] = "USER"
filters["target_identifier"] = user_id.result
elif all_enabled:
pass
elif tag_name.available:
filters["target_type"] = "TAG"
filters["target_identifier"] = tag_name.result
elif group_ids.available:
gids = [str(gid) for gid in group_ids.result]
filters["target_type"] = "GROUP"
filters["target_identifier__in"] = gids
else:
current_group_id = getattr(event, "group_id", None)
if current_group_id:
filters["target_type"] = "GROUP"
filters["target_identifier"] = str(current_group_id)
return scheduler_manager.target(**filters)
async def GetValidatedJobKwargs(
matcher: AlconnaMatcher,
plugin_name: Match[str] = AlconnaMatch("plugin_name"),
cli_string: Match[str] = AlconnaMatch("cli_string"),
kwargs_str: Match[str] = AlconnaMatch("kwargs_str"),
) -> dict:
"""依赖注入函数:解析、合并和验证任务的关键字参数"""
p_name = plugin_name.result
task_meta = scheduler_manager._registered_tasks.get(p_name)
if not task_meta:
await matcher.finish(f"插件 '{p_name}' 未注册可定时执行的任务。")
cli_kwargs = {}
if cli_string.available and cli_string.result.strip():
if not (cli_parser := task_meta.get("cli_parser")):
await matcher.finish(
f"插件 '{p_name}' 不支持通过 --params-cli 设置参数,"
f"因为它没有注册解析器。"
)
try:
temp_parser = Alconna("_", cli_parser.args, *cli_parser.options) # type: ignore
parsed_cli = temp_parser.parse(f"_ {cli_string.result.strip()}")
if not parsed_cli.matched:
raise ValueError(f"参数无法匹配: {parsed_cli.error_info or '未知错误'}")
cli_kwargs = parsed_cli.all_matched_args
except Exception as e:
await matcher.finish(
f"使用 --params-cli 解析参数失败: {e}\n\n请确保参数格式与插件命令一致。"
)
explicit_kwargs = {}
if kwargs_str.available and kwargs_str.result.strip():
try:
explicit_kwargs = dict(
item.strip().split("=", 1)
for item in kwargs_str.result.split(";")
if item.strip()
)
except ValueError:
await matcher.finish(
"参数格式错误,--kwargs 请使用 'key=value;key2=value2' 格式。"
)
final_job_kwargs = {**cli_kwargs, **explicit_kwargs}
is_valid, result = scheduler_manager._validate_and_prepare_kwargs(
p_name, final_job_kwargs
)
if not is_valid:
await matcher.finish(f"任务参数校验失败:\n{result}")
return result if isinstance(result, dict) else {}
async def GetFinalPermission(
matcher: AlconnaMatcher,
bot: Bot,
event: Event,
session: Uninfo,
plugin_name: Match[str] = AlconnaMatch("plugin_name"),
perm_level: Match[int] = AlconnaMatch("perm_level"),
) -> int:
"""依赖注入函数:计算任务的最终权限等级"""
is_superuser = await SUPERUSER(bot, event)
current_group_id = session.group.id if session.group else None
if is_superuser:
effective_user_level = 9
else:
effective_user_level = await LevelUser.get_user_level(
session.user.id, current_group_id
)
if perm_level.available:
requested_perm_level = perm_level.result
if not is_superuser and requested_perm_level > effective_user_level:
await matcher.send(
f"⚠️ 警告:您指定的权限等级 ({requested_perm_level}) "
f"高于自身权限 ({effective_user_level})。\n"
f"任务的管理权限已被自动设置为 {effective_user_level} 级。"
)
return effective_user_level
return requested_perm_level
else:
base_permission = effective_user_level
task_meta = scheduler_manager._registered_tasks.get(plugin_name.result)
if task_meta and "default_permission" in task_meta:
default_perm = task_meta.get("default_permission")
if isinstance(default_perm, int):
base_permission = default_perm
return min(base_permission, effective_user_level)
async def ResolveTargets(
matcher: AlconnaMatcher,
bot: Bot,
event: Event,
session: Uninfo,
group_ids: Match[list[str]] = AlconnaMatch("group_ids"),
tag_name: Match[str] = AlconnaMatch("tag_name"),
user_id: Match[str] = AlconnaMatch("user_id"),
all_flag: Query[bool] = AlconnaQuery("设置.all.value", False),
global_flag: Query[bool] = AlconnaQuery("设置.global.value", False),
) -> list[str]:
"""依赖注入函数,用于解析和计算最终的目标描述符列表,并进行权限检查"""
is_superuser = await SUPERUSER(bot, event)
current_group_id = session.group.id if session.group else None
if not is_superuser:
permission_denied = False
if (
global_flag.result
or all_flag.result
or tag_name.available
or user_id.available
):
permission_denied = True
elif group_ids.available and any(
str(gid) != str(current_group_id) for gid in group_ids.result
):
permission_denied = True
if permission_denied:
await matcher.finish(
"权限不足,只有超级用户才能为其他群组、所有群组或通过标签设置任务。"
)
if user_id.available:
return [user_id.result]
if all_flag.result or global_flag.result:
return [scheduler_manager.ALL_GROUPS]
if tag_name.available:
return [f"tag:{tag_name.result}"]
if group_ids.available:
return group_ids.result
if current_group_id:
return [str(current_group_id)]
await matcher.finish(
"私聊中设置任务必须使用 -u, -g, --all, --global 或 -t 选项指定目标。"
)
@@ -1,380 +1,238 @@
from datetime import datetime
from typing import cast
from nonebot.adapters import Event
from nonebot.adapters.onebot.v11 import Bot
from nonebot.adapters import Bot, Event
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.scheduled_job import ScheduledJob
from zhenxun.services.scheduler import scheduler_manager
from zhenxun.services.scheduler.targeter import ScheduleTargeter
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.pydantic_compat import model_dump
from . import presenters
from .commands import (
GetBotId,
GetTargeter,
parse_daily_time,
parse_interval,
schedule_cmd,
from nonebot_plugin_alconna import (
AlconnaMatch,
AlconnaMatches,
AlconnaQuery,
Arparma,
Match,
Query,
)
from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.config import Config
from zhenxun.models.scheduled_job import ScheduledJob
from zhenxun.services import scheduler_manager
from zhenxun.utils.message import MessageUtils
@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)} 不能同时使用,请只选择一个。"
)
from .commands import schedule_cmd
from .data_source import scheduler_admin_service
from .dependencies import (
GetBotId,
GetCreatorPermissionLevel,
GetFinalPermission,
GetTargeter,
GetTriggerInfo,
GetValidatedJobKwargs,
RequireTaskPermission,
ResolveTargets,
_parse_trigger_from_arparma,
)
@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"),
session: Uninfo,
page: Match[int] = AlconnaMatch("page"),
targeter=Depends(GetTargeter),
):
"""处理 '查看' 子命令"""
is_superuser = await SUPERUSER(bot, event)
title = ""
gid_filter = None
current_page = page.result if page.available else 1
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
result = await scheduler_admin_service.get_schedules_view(
user_id=session.user.id,
group_id=session.group.id if session.group else None,
is_superuser=is_superuser,
filters=targeter._filters,
page=current_page,
)
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)
await MessageUtils.build_message(result).send(reply_to=True)
@schedule_cmd.assign("设置")
async def handle_set(
event: Event,
session: Uninfo,
target_groups: list[str] = Depends(ResolveTargets),
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"),
tag_name: Match[str] = AlconnaMatch("tag_name"),
jitter: Match[int] = AlconnaMatch("jitter_seconds"),
spread: Match[int] = AlconnaMatch("spread_seconds"),
interval: Match[int] = AlconnaMatch("interval_seconds"),
job_name: Match[str] = AlconnaMatch("job_name"),
bot_id_to_operate: str = Depends(GetBotId),
trigger_info: tuple[str, dict] = Depends(GetTriggerInfo),
job_kwargs: dict = Depends(GetValidatedJobKwargs),
creator_permission_level: int = Depends(GetCreatorPermissionLevel),
final_permission: int = Depends(GetFinalPermission),
):
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())}"
jitter_val: int | None = jitter.result if jitter.available else None
spread_val: int | None = spread.result if spread.available else None
interval_val: int | None = interval.result if interval.available else None
is_multi_target = (
len(target_groups) > 1
or (
len(target_groups) == 1 and target_groups[0] == scheduler_manager.ALL_GROUPS
)
or tag_name.available
)
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:
if is_multi_target:
task_meta = scheduler_manager._registered_tasks.get(p_name)
if not task_meta:
await schedule_cmd.finish(f"插件 '{p_name}' 未注册。")
if jitter_val is None:
if task_meta and task_meta.get("default_jitter") is not None:
jitter_val = cast(int | None, task_meta["default_jitter"])
else:
jitter_val = Config.get_config(
"SchedulerManager", "DEFAULT_JITTER_SECONDS"
)
if spread_val is None:
if task_meta and task_meta.get("default_spread") is not None:
spread_val = cast(int | None, task_meta["default_spread"])
else:
spread_val = Config.get_config(
"SchedulerManager", "DEFAULT_SPREAD_SECONDS"
)
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(",")
)
if interval_val is None:
if task_meta and task_meta.get("default_interval") is not None:
interval_val = cast(int | None, task_meta["default_interval"])
else:
interval_val = Config.get_config(
"SchedulerManager", "DEFAULT_INTERVAL_SECONDS"
)
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)
job_kwargs = model_dump(validated_model)
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,
result_message = await scheduler_admin_service.set_schedule(
targets=target_groups,
creator_permission_level=creator_permission_level,
plugin_name=p_name,
trigger_info=trigger_info,
job_kwargs=job_kwargs,
permission=final_permission,
bot_id=bot_id_to_operate,
job_name=job_name.result if job_name.available else None,
jitter=jitter_val,
spread=spread_val,
interval=interval_val,
created_by=session.user.id,
)
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}] 设置任务失败。")
await MessageUtils.build_message(result_message).send()
@schedule_cmd.assign("删除")
async def handle_delete(targeter: ScheduleTargeter = GetTargeter("删除")):
schedules_to_remove: list[ScheduledJob] = 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)
async def handle_delete(
bot: Bot,
event: Event,
session: Uninfo,
targeter=Depends(GetTargeter),
all_flag: Query[bool] = AlconnaQuery("删除.all.value", False),
global_flag: Query[bool] = AlconnaQuery("删除.global.value", False),
):
"""处理 '删除' 子命令"""
is_superuser = await SUPERUSER(bot, event)
result_message = await scheduler_admin_service.perform_bulk_operation(
operation_name="删除",
user_id=session.user.id,
group_id=session.group.id if session.group else None,
is_superuser=is_superuser,
targeter=targeter,
all_flag=all_flag.result,
global_flag=global_flag.result,
)
await schedule_cmd.finish(result_message)
@schedule_cmd.assign("暂停")
async def handle_pause(targeter: ScheduleTargeter = GetTargeter("暂停")):
schedules_to_pause: list[ScheduledJob] = 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)
async def handle_pause(
bot: Bot,
event: Event,
session: Uninfo,
targeter=Depends(GetTargeter),
all_flag: Query[bool] = AlconnaQuery("暂停.all.value", False),
global_flag: Query[bool] = AlconnaQuery("暂停.global.value", False),
):
"""处理 '暂停' 子命令"""
is_superuser = await SUPERUSER(bot, event)
result_message = await scheduler_admin_service.perform_bulk_operation(
operation_name="暂停",
user_id=session.user.id,
group_id=session.group.id if session.group else None,
is_superuser=is_superuser,
targeter=targeter,
all_flag=all_flag.result,
global_flag=global_flag.result,
)
await schedule_cmd.finish(result_message)
@schedule_cmd.assign("恢复")
async def handle_resume(targeter: ScheduleTargeter = GetTargeter("恢复")):
schedules_to_resume: list[ScheduledJob] = 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)
async def handle_resume(
bot: Bot,
event: Event,
session: Uninfo,
targeter=Depends(GetTargeter),
all_flag: Query[bool] = AlconnaQuery("恢复.all.value", False),
global_flag: Query[bool] = AlconnaQuery("恢复.global.value", False),
):
"""处理 '恢复' 子命令"""
is_superuser = await SUPERUSER(bot, event)
result_message = await scheduler_admin_service.perform_bulk_operation(
operation_name="恢复",
user_id=session.user.id,
group_id=session.group.id if session.group else None,
is_superuser=is_superuser,
targeter=targeter,
all_flag=all_flag.result,
global_flag=global_flag.result,
)
await schedule_cmd.finish(result_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)
async def handle_trigger(schedule: ScheduledJob = Depends(RequireTaskPermission)):
"""处理 '执行' 子命令"""
result_message = await scheduler_admin_service.trigger_schedule_now(schedule)
await schedule_cmd.finish(result_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"),
schedule: ScheduledJob = Depends(RequireTaskPermission),
arp: Arparma = AlconnaMatches(),
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,
]
):
"""处理 '更新' 子命令"""
trigger_info = _parse_trigger_from_arparma(arp)
if not trigger_info and not 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
result_message = await scheduler_admin_service.update_schedule(
schedule, trigger_info, kwargs_str.result if kwargs_str.available else None
)
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)
await schedule_cmd.finish(result_message)
@schedule_cmd.assign("插件列表")
async def handle_plugins_list():
message = await presenters.format_plugins_list()
"""处理 '插件列表' 子命令"""
message = await scheduler_admin_service.get_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)
async def handle_status(
schedule: ScheduledJob = Depends(RequireTaskPermission),
):
"""处理 '状态' 子命令"""
message = await scheduler_admin_service.get_schedule_status(schedule.id)
await schedule_cmd.finish(message)
@@ -1,22 +1,13 @@
import asyncio
from typing import Any
from zhenxun import ui
from zhenxun.models.scheduled_job import ScheduledJob
from zhenxun.services.scheduler import scheduler_manager
from zhenxun.utils._image_template import ImageTemplate, RowStyle
from zhenxun.services import scheduler_manager
from zhenxun.ui.builders import TableBuilder
from zhenxun.ui.models import StatusBadgeCell, TextCell
from zhenxun.utils.pydantic_compat import model_json_schema
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 _get_schedule_attr(schedule: ScheduledJob | dict, attr_name: str) -> Any:
"""兼容地从字典或对象获取属性"""
if isinstance(schedule, dict):
@@ -71,13 +62,8 @@ def _format_operation_result_card(
schedule_info: 相关的 ScheduledJob 对象
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 "全局"
target_desc = format_target_info(
schedule_info.target_type, schedule_info.target_identifier
)
info_lines = [
@@ -118,19 +104,6 @@ def format_update_success(schedule_info: ScheduledJob) -> 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"):
@@ -139,68 +112,95 @@ def _format_params(schedule_status: dict) -> str:
async def format_schedule_list_as_image(
schedules: list[ScheduledJob], title: str, current_page: int
schedules: list[ScheduledJob], title: str, current_page: int, total_items: int
):
"""将任务列表格式化为图片"""
page_size = 15
total_items = len(schedules)
page_size = 30
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:
if not schedules:
return "这一页没有内容了哦~"
status_tasks = [
scheduler_manager.get_schedule_status(s.id) for s in paginated_schedules
]
all_statuses = await asyncio.gather(*status_tasks)
schedule_ids = [s.id for s in schedules]
all_statuses_list = await scheduler_manager.get_schedules_status_bulk(schedule_ids)
all_statuses_map = {status["id"]: status for status in all_statuses_list}
def get_status_text(status_value):
if isinstance(status_value, bool):
return "启用" if status_value else "暂停"
return str(status_value)
data_list = []
for schedule_db in schedules:
s = all_statuses_map.get(schedule_db.id)
if not s:
continue
data_list = [
[
s["id"],
s["plugin_name"],
s.get("bot_id") or "N/A",
s["group_id"] or "全局",
s["next_run_time"],
_format_trigger_info(s),
_format_params(s),
get_status_text(s["is_enabled"]),
]
for s in all_statuses
if s
]
status_value = s["is_enabled"]
if status_value == "运行中":
status_cell = StatusBadgeCell(text="运行中", status_type="info")
else:
is_enabled = status_value == "启用"
status_cell = StatusBadgeCell(
text="启用" if is_enabled else "暂停",
status_type="ok" if is_enabled else "error",
)
data_list.append(
[
TextCell(content=str(s["id"])),
TextCell(content=s["plugin_name"]),
TextCell(content=s.get("bot_id") or "N/A"),
TextCell(
content=format_target_info(s["target_type"], s["target_identifier"])
),
TextCell(content=s["next_run_time"]),
TextCell(content=_format_trigger_info(s)),
TextCell(content=_format_params(s)),
status_cell,
]
)
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,
builder = TableBuilder(
title, f"第 {current_page}/{total_pages} 页,共 {total_items} 条任务"
)
builder.set_headers(
["ID", "插件", "Bot", "目标", "下次运行", "规则", "参数", "状态"]
).add_rows(data_list)
return await ui.render(
builder.build(),
viewport={"width": 1400, "height": 10},
device_scale_factor=2,
)
def format_target_info(target_type: str, target_identifier: str) -> str:
"""格式化目标信息以供显示"""
if target_type == "GLOBAL":
return "全局"
elif target_type == "ALL_GROUPS":
return "所有群组"
elif target_type == "TAG":
return f"标签: {target_identifier}"
elif target_type == "GROUP":
return f"群: {target_identifier}"
elif target_type == "USER":
return f"用户: {target_identifier}"
else:
return f"{target_type}: {target_identifier}"
def format_single_status_message(status: dict) -> str:
"""格式化单个任务状态为文本消息"""
target_info = format_target_info(status["target_type"], status["target_identifier"])
trigger_info = status.get("trigger_info_str", _format_trigger_info(status))
info_lines = [
f"📋 定时任务详细信息 (ID: {status['id']})",
"--------------------",
f"▫️ 插件: {status['plugin_name']}",
f"▫️ Bot ID: {status.get('bot_id') or '默认'}",
f"▫️ 目标: {status['group_id'] or '全局'}",
f"▫️ 目标: {target_info}",
f"▫️ 状态: {'✔️ 已启用' if status['is_enabled'] else '⏸️ 已暂停'}",
f"▫️ 下次运行: {status['next_run_time']}",
f"▫️ 触发规则: {_format_trigger_info(status)}",
f"▫️ 触发规则: {trigger_info}",
f"▫️ 任务参数: {_format_params(status)}",
]
return "\n".join(info_lines)
+1 -1
View File
@@ -153,7 +153,7 @@ async def _(session: Uninfo, arparma: Arparma, nickname: str = UserName()):
nickname,
PlatformUtils.get_platform(session),
):
await MessageUtils.build_message(image.pic2bytes()).finish(reply_to=True)
await MessageUtils.build_message(image).finish(reply_to=True) # type: ignore
return await MessageUtils.build_message("你的道具为空捏...").send(reply_to=True)
+94 -30
View File
@@ -1,4 +1,5 @@
import asyncio
from collections import defaultdict
from collections.abc import Callable
from datetime import datetime, timedelta
import inspect
@@ -7,26 +8,27 @@ from types import MappingProxyType
from typing import Any, Literal
from nonebot.adapters import Bot, Event
from nonebot.compat import model_dump
from nonebot_plugin_alconna import At, UniMessage, UniMsg
from nonebot_plugin_uninfo import Uninfo
from pydantic import BaseModel, Field, create_model
from tortoise.expressions import Q
from zhenxun import ui
from zhenxun.configs.config import BotConfig
from zhenxun.models.friend_user import FriendUser
from zhenxun.models.goods_info import GoodsInfo
from zhenxun.models.group_member_info import GroupInfoUser
from zhenxun.models.user_console import UserConsole
from zhenxun.models.user_gold_log import UserGoldLog
from zhenxun.models.user_props_log import UserPropsLog
from zhenxun.services import avatar_service
from zhenxun.services.log import logger
from zhenxun.ui.models import ImageCell, TextCell
from zhenxun.utils.enum import GoldHandle, PropHandle
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
from zhenxun.utils.platform import PlatformUtils
from zhenxun.utils.pydantic_compat import model_dump
from .config import ICON_PATH, PLATFORM_PATH, base_config
from .html_image import html_image
from .normal_image import normal_image
from .config import ICON_PATH, PLATFORM_PATH
class Goods(BaseModel):
@@ -91,9 +93,7 @@ class ShopParam(BaseModel):
return model_dump(self, **kwargs)
async def gold_rank(
session: Uninfo, group_id: str | None, num: int
) -> BuildImage | str:
async def gold_rank(session: Uninfo, group_id: str | None, num: int) -> bytes | str:
query = UserConsole
if group_id:
uid_list = await GroupInfoUser.filter(group_id=group_id).values_list(
@@ -124,16 +124,20 @@ async def gold_rank(
data_list = []
platform = PlatformUtils.get_platform(session)
for i, user in enumerate(user_list):
ava_bytes = await PlatformUtils.get_user_avatar(
user[0], platform, session.self_id
)
avatar_path = await avatar_service.get_avatar_path(platform, user[0])
data_list.append(
[
f"{i + 1}",
(ava_bytes, 30, 30) if platform == "qq" else "",
uid2name.get(user[0]),
user[1],
(PLATFORM_PATH.get(platform), 30, 30),
TextCell(content=f"{i + 1}"),
ImageCell(
src=avatar_path.as_uri() if avatar_path else "", shape="circle"
)
if avatar_path
else TextCell(content=""),
TextCell(content=uid2name.get(user[0]) or user[0]),
TextCell(content=str(user[1]), bold=True),
ImageCell(src=platform_path.resolve().as_uri())
if (platform_path := PLATFORM_PATH.get(platform))
else TextCell(content=""),
]
)
if group_id:
@@ -142,7 +146,11 @@ async def gold_rank(
else:
title = "金币全局排行"
tip = f"你的排名在全局第 {index} 位哦!"
return await ImageTemplate.table_page(title, tip, column_name, data_list)
from zhenxun.ui.builders import TableBuilder
builder = TableBuilder(title, tip)
builder.set_headers(column_name).add_rows(data_list)
return await ui.render(builder.build())
class ShopManage:
@@ -150,9 +158,7 @@ class ShopManage:
@classmethod
async def get_shop_image(cls) -> bytes:
if base_config.get("style") == "zhenxun":
return await html_image()
return await normal_image()
return await prepare_shop_data()
@classmethod
def __build_params(
@@ -361,7 +367,7 @@ class ShopManage:
else:
goods_info = await GoodsInfo.get_or_none(goods_name=goods_name)
if not goods_info:
return f"{goods_name} 不存在..."
return "对应的道具不存在..."
if goods_info.is_passive:
return f"{goods_info.goods_name} 是被动道具, 无法使用..."
goods = cls.uuid2goods.get(goods_info.uuid)
@@ -494,7 +500,7 @@ class ShopManage:
@classmethod
async def my_props(
cls, user_id: str, name: str, platform: str | None = None
) -> BuildImage | None:
) -> bytes | None:
"""获取道具背包
参数:
@@ -526,10 +532,10 @@ class ShopManage:
if not prop:
continue
icon = ""
icon = None
if prop.icon:
icon_path = ICON_PATH / prop.icon
icon = (icon_path, 33, 33) if icon_path.exists() else ""
icon = icon_path if icon_path.exists() else None
table_rows.append(
[
@@ -545,12 +551,11 @@ class ShopManage:
return None
column_name = ["-", "使用ID", "名称", "数量", "简介"]
return await ImageTemplate.table_page(
f"{name}的道具仓库",
"通过 使用道具[ID/名称] 令道具生效",
column_name,
table_rows,
)
from zhenxun.ui.builders import TableBuilder
builder = TableBuilder(f"{name}的道具仓库", "通过 使用道具[ID/名称] 令道具生效")
builder.set_headers(column_name).add_rows(table_rows)
return await ui.render(builder.build())
@classmethod
async def my_cost(cls, user_id: str, platform: str | None = None) -> int:
@@ -565,3 +570,62 @@ class ShopManage:
"""
user = await UserConsole.get_user(user_id, platform)
return user.gold
def get_limit_time(end_time: int) -> str | None:
now = int(time.time())
if now > end_time or end_time == 0:
return None
time_difference = datetime.fromtimestamp(end_time) - datetime.fromtimestamp(now)
total_seconds = time_difference.total_seconds()
hours = int(total_seconds // 3600)
minutes = int((total_seconds % 3600) // 60)
return f"{hours}:{minutes:02d}"
def get_discount(price: int, discount: float) -> int | None:
return None if discount == 1.0 else int(price * discount)
async def prepare_shop_data() -> bytes:
"""准备商店数据并调用渲染服务"""
goods_list = (
await GoodsInfo.filter(
Q(goods_limit_time__gte=time.time()) | Q(goods_limit_time=0)
)
.annotate()
.order_by("id")
.all()
)
partition_dict: dict[str, list[dict]] = defaultdict(list)
for idx, goods in enumerate(goods_list):
partition_name = goods.partition or "默认分区"
icon_asset_path = None
if goods.icon and (ICON_PATH / goods.icon).exists():
icon_asset_path = f"image/shop_icon/{goods.icon}"
goods_item = {
"id": idx + 1,
"name": goods.goods_name,
"description": goods.goods_description,
"price": goods.goods_price,
"discount_price": get_discount(goods.goods_price, goods.goods_discount),
"limit_time": get_limit_time(goods.goods_limit_time),
"daily_limit": goods.daily_limit or "∞",
"icon_url": icon_asset_path,
}
partition_dict[partition_name].append(goods_item)
categories = [
{"partition_title": partition, "goods_list": items}
for partition, items in partition_dict.items()
]
shop_data = {
"bot_nickname": BotConfig.self_nickname,
"categories": categories,
}
return await ui.render_template("pages/builtin/shop", data=shop_data)
+2 -2
View File
@@ -1,5 +1,5 @@
from zhenxun.configs.config import Config
from zhenxun.configs.path_config import IMAGE_PATH, TEMPLATE_PATH
from zhenxun.configs.path_config import IMAGE_PATH, THEMES_PATH
base_config = Config.get("shop")
@@ -17,4 +17,4 @@ PLATFORM_PATH = {
LEFT_RIGHT_IMAGE = ["1.png", "2.png", "qq.png"]
LEFT_RIGHT_PATH = TEMPLATE_PATH / "shop" / "res" / "img"
LEFT_RIGHT_PATH = THEMES_PATH / "default" / "assets" / "shop" / "img"
@@ -1,89 +0,0 @@
from datetime import datetime
import time
from nonebot_plugin_htmlrender import template_to_pic
from pydantic import BaseModel
from tortoise.expressions import Q
from zhenxun.configs.config import BotConfig
from zhenxun.configs.path_config import TEMPLATE_PATH
from zhenxun.models.goods_info import GoodsInfo
from zhenxun.utils._build_image import BuildImage
from .config import ICON_PATH
class GoodsItem(BaseModel):
goods_list: list[dict]
"""商品列表"""
partition: str
"""分区名称"""
def get_limit_time(end_time: int):
now = int(time.time())
if now > end_time:
return None
current_datetime = datetime.fromtimestamp(now)
end_datetime = datetime.fromtimestamp(end_time)
time_difference = end_datetime - current_datetime
total_seconds = time_difference.total_seconds()
hours = int(total_seconds // 3600)
minutes = int((total_seconds % 3600) // 60)
return f"{hours}:{minutes}"
def get_discount(price: int, discount: float):
return None if discount == 1.0 else int(price * discount)
async def html_image() -> bytes:
"""构建图片"""
goods_list = (
await GoodsInfo.filter(
Q(goods_limit_time__gte=time.time()) | Q(goods_limit_time=0)
)
.annotate()
.order_by("id")
.all()
)
partition_dict: dict[str, list[dict]] = {}
for idx, goods in enumerate(goods_list):
if not goods.partition:
goods.partition = "默认分区"
if goods.partition not in partition_dict:
partition_dict[goods.partition] = []
icon = None
if goods.icon:
path = ICON_PATH / goods.icon
if path.exists():
icon = (
"data:image/png;base64,"
f"{BuildImage.open(ICON_PATH / goods.icon).pic2bs4()[9:]}"
)
partition_dict[goods.partition].append(
{
"id": idx + 1,
"price": goods.goods_price,
"discount_price": get_discount(goods.goods_price, goods.goods_discount),
"limit_time": get_limit_time(goods.goods_limit_time),
"daily_limit": goods.daily_limit or "∞",
"name": goods.goods_name,
"icon": icon,
"description": goods.goods_description,
}
)
data_list = [
GoodsItem(goods_list=value, partition=partition)
for partition, value in partition_dict.items()
]
return await template_to_pic(
template_path=str((TEMPLATE_PATH / "shop").absolute()),
template_name="main.html",
templates={"name": BotConfig.self_nickname, "data_list": data_list},
pages={
"viewport": {"width": 850, "height": 1024},
"base_url": f"file://{TEMPLATE_PATH}",
},
wait=2,
)
@@ -1,207 +0,0 @@
import time
from tortoise.expressions import Q
from zhenxun.configs.path_config import IMAGE_PATH
from zhenxun.models.goods_info import GoodsInfo
from zhenxun.utils._build_image import BuildImage
from zhenxun.utils.image_utils import text2image
from .config import ICON_PATH
async def normal_image() -> bytes:
"""制作商店图片
返回:
BuildImage: 商店图片
"""
h = 10
goods_list = (
await GoodsInfo.filter(
Q(goods_limit_time__gte=time.time()) | Q(goods_limit_time=0)
)
.annotate()
.order_by("id")
.all()
)
# A = BuildImage(1100, h, color="#f9f6f2")
total_n = 0
image_list = []
for idx, goods in enumerate(goods_list):
name_image = BuildImage(
580, 40, font_size=25, color="#e67b6b", font="CJGaoDeGuo.otf"
)
await name_image.text(
(15, 0), f"{idx + 1}.{goods.goods_name}", center_type="height"
)
await name_image.line((380, -5, 280, 45), "#a29ad6", 5)
await name_image.text((390, 0), "售价:", center_type="height")
if goods.goods_discount != 1:
discount_price = int(goods.goods_discount * goods.goods_price)
old_price_image = await BuildImage.build_text_image(
str(goods.goods_price), font_color=(194, 194, 194), size=15
)
await old_price_image.line(
(
0,
int(old_price_image.height / 2),
old_price_image.width + 1,
int(old_price_image.height / 2),
),
(0, 0, 0),
)
await name_image.paste(old_price_image, (440, 0))
await name_image.text((440, 15), str(discount_price), (255, 255, 255))
else:
await name_image.text(
(440, 0),
str(goods.goods_price),
(255, 255, 255),
center_type="height",
)
_tmp = await BuildImage.build_text_image(str(goods.goods_price), size=25)
await name_image.text(
(
440 + _tmp.width,
0,
),
" 金币",
center_type="height",
)
des_image = None
font_img = BuildImage(600, 80, font_size=20, color="#a29ad6")
p = font_img.getsize("简介:")[0] + 20
if goods.goods_description:
des_list = goods.goods_description.split("\n")
desc = ""
for des in des_list:
if font_img.getsize(des)[0] > font_img.width - p - 20:
msg = ""
tmp = ""
for i in range(len(des)):
if font_img.getsize(tmp)[0] < font_img.width - p - 20:
tmp += des[i]
else:
msg += tmp + "\n"
tmp = des[i]
desc += msg
if tmp:
desc += tmp
else:
desc += des + "\n"
if desc[-1] == "\n":
desc = desc[:-1]
des_image = await text2image(desc, color="#a29ad6")
goods_image = BuildImage(
600,
(50 + des_image.height) if des_image else 50,
font_size=20,
color="#a29ad6",
font="CJGaoDeGuo.otf",
)
if des_image:
await goods_image.text((15, 50), "简介:")
await goods_image.paste(des_image, (p, 50))
await name_image.circle_corner(5)
await goods_image.paste(name_image, (0, 5), center_type="width")
await goods_image.circle_corner(20)
bk = BuildImage(
1180,
(50 + des_image.height) if des_image else 50,
font_size=15,
color="#f9f6f2",
font="CJGaoDeGuo.otf",
)
if goods.icon and (ICON_PATH / goods.icon).exists():
icon = BuildImage(70, 70, background=ICON_PATH / goods.icon)
await bk.paste(icon)
await bk.paste(goods_image, (70, 0))
n = 0
_w = 650
# 添加限时图标和时间
if goods.goods_limit_time > 0:
n += 140
_limit_time_logo = BuildImage(
40, 40, background=f"{IMAGE_PATH}/other/time.png"
)
await bk.paste(_limit_time_logo, (_w + 50, 0))
_time_img = await BuildImage.build_text_image("限时!", size=23)
await bk.paste(
_time_img,
(_w + 90, 10),
)
limit_time = time.strftime(
"%Y-%m-%d %H:%M", time.localtime(goods.goods_limit_time)
).split()
y_m_d = limit_time[0]
_h_m = limit_time[1].split(":")
h_m = f"{_h_m[0]}时 {_h_m[1]}分"
await bk.text((_w + 55, 38), str(y_m_d))
await bk.text((_w + 65, 57), str(h_m))
_w += 140
if goods.goods_discount != 1:
n += 140
_discount_logo = BuildImage(
30, 30, background=f"{IMAGE_PATH}/other/discount.png"
)
await bk.paste(_discount_logo, (_w + 50, 10))
_tmp = await BuildImage.build_text_image("折扣!", size=23)
await bk.paste(_tmp, (_w + 90, 15))
_tmp = await BuildImage.build_text_image(
f"{10 * goods.goods_discount:.1f} 折",
size=30,
font_color=(85, 156, 75),
)
await bk.paste(_tmp, (_w + 50, 44))
_w += 140
if goods.daily_limit != 0:
n += 140
_daily_limit_logo = BuildImage(
35, 35, background=f"{IMAGE_PATH}/other/daily_limit.png"
)
await bk.paste(_daily_limit_logo, (_w + 50, 10))
_tmp = await BuildImage.build_text_image(
"限购!",
size=23,
)
await bk.paste(_tmp, (_w + 90, 20))
_tmp = await BuildImage.build_text_image(f"{goods.daily_limit}", size=30)
await bk.paste(_tmp, (_w + 72, 45))
total_n = max(total_n, n)
if n:
await bk.line((650, -1, 650 + n, -1), "#a29ad6", 5)
# await bk.aline((650, 80, 650 + n, 80), "#a29ad6", 5)
# 添加限时图标和时间
image_list.append(bk)
# await A.apaste(bk, (0, current_h), True)
# current_h += 90
current_h = 0
h = sum(img.height + 10 for img in image_list) or 400
A = BuildImage(1100, h, color="#f9f6f2")
for img in image_list:
await A.paste(img, (0, current_h))
current_h += img.height + 10
w = 950
if total_n:
w += total_n
h = A.height + 230 + 100
h = max(h, 1000)
shop_logo = BuildImage(100, 100, background=f"{IMAGE_PATH}/other/shop_text.png")
shop = BuildImage(w, h, font_size=20, color="#f9f6f2")
await shop.paste(A, (20, 230))
await shop.paste(shop_logo, (450, 30))
tip = "注【通过 购买道具 序号 或者 商品名称 购买】"
await shop.text(
(
int((1000 - shop.getsize(tip)[0]) / 2),
170,
),
"注【通过 序号 或者 商品名称 购买】",
)
await shop.text(
(20, h - 100),
"神秘药水\t\t售价:9999999金币\n\t\t鬼知道会有什么效果~",
)
return shop.pic2bytes()
@@ -84,12 +84,6 @@ __plugin_meta__ = PluginMetadata(
default_value=0.05,
type=float,
),
RegisterConfig(
key="IMAGE_STYLE",
value="zhenxun",
help="签到图片样式, [normal, zhenxun]",
default_value="zhenxun",
),
],
limits=[PluginCdBlock()],
).to_dict(),
+25 -13
View File
@@ -6,14 +6,16 @@ import secrets
from nonebot_plugin_uninfo import Uninfo
import pytz
from zhenxun import ui
from zhenxun.configs.path_config import IMAGE_PATH
from zhenxun.models.friend_user import FriendUser
from zhenxun.models.group_member_info import GroupInfoUser
from zhenxun.models.sign_log import SignLog
from zhenxun.models.sign_user import SignUser
from zhenxun.models.user_console import UserConsole
from zhenxun.services.avatar_service import avatar_service
from zhenxun.services.log import logger
from zhenxun.utils.image_utils import BuildImage, ImageTemplate
from zhenxun.ui.models import ImageCell, TextCell
from zhenxun.utils.platform import PlatformUtils
from ._random_event import random_event
@@ -33,7 +35,7 @@ class SignManage:
@classmethod
async def rank(
cls, session: Uninfo, num: int, group_id: str | None = None
) -> BuildImage | str: # sourcery skip: avoid-builtin-shadow
) -> bytes | str:
"""好感度排行
参数:
@@ -42,7 +44,7 @@ class SignManage:
group_id: 群组id
返回:
BuildImage: 构造图片
bytes: 构造图片
"""
query = SignUser
if group_id:
@@ -78,17 +80,23 @@ class SignManage:
data_list = []
platform = PlatformUtils.get_platform(session)
for i, user in enumerate(user_list):
bytes = await PlatformUtils.get_user_avatar(
user[0], platform, session.self_id
avatar_path = await avatar_service.get_avatar_path(
platform=user[3] or "qq", identifier=user[0]
)
data_list.append(
[
f"{i + 1}",
(bytes, 30, 30) if user[3] == "qq" else "",
uid2name.get(user[0]),
user[1],
user[2],
(PLATFORM_PATH.get(user[3]), 30, 30),
TextCell(content=f"{i + 1}"),
ImageCell(
src=avatar_path.as_uri() if avatar_path else "", shape="circle"
)
if avatar_path
else TextCell(content=""),
TextCell(content=uid2name.get(user[0]) or user[0]),
TextCell(content=str(user[1]), bold=True),
TextCell(content=str(user[2])),
ImageCell(src=platform_path.resolve().as_uri())
if (platform_path := PLATFORM_PATH.get(platform))
else TextCell(content=""),
]
)
if group_id:
@@ -97,7 +105,11 @@ class SignManage:
else:
title = "好感度全局排行"
tip = f"你的排名在全局第 {index} 位哦!"
return await ImageTemplate.table_page(title, tip, column_name, data_list)
from zhenxun.ui.builders import TableBuilder
builder = TableBuilder(title, tip)
builder.set_headers(column_name).add_rows(data_list)
return await ui.render(builder.build())
@classmethod
async def sign(
@@ -163,7 +175,7 @@ class SignManage:
impression_added = (secrets.randbelow(99) + 1) / 100
rand = random.random()
add_probability = float(user.add_probability)
specify_probability = user.specify_probability
specify_probability = float(user.specify_probability)
if rand + add_probability > 0.97 or rand < specify_probability:
impression_added *= 2
await SignUser.sign(user, impression_added, session.self_id, platform)
@@ -1,12 +1,6 @@
from zhenxun.configs.path_config import IMAGE_PATH
SIGN_RESOURCE_PATH = IMAGE_PATH / "sign" / "sign_res"
SIGN_TODAY_CARD_PATH = IMAGE_PATH / "sign" / "today_card"
SIGN_BORDER_PATH = SIGN_RESOURCE_PATH / "border"
SIGN_BACKGROUND_PATH = SIGN_RESOURCE_PATH / "background"
SIGN_BORDER_PATH.mkdir(exist_ok=True, parents=True)
SIGN_BACKGROUND_PATH.mkdir(exist_ok=True, parents=True)
lik2relation = {
+132 -323
View File
@@ -1,28 +1,21 @@
from datetime import datetime
from io import BytesIO
import os
from pathlib import Path
import random
import aiofiles
import nonebot
from nonebot.drivers import Driver
from nonebot_plugin_htmlrender import template_to_pic
from nonebot_plugin_uninfo import Uninfo
import pytz
from zhenxun import ui
from zhenxun.configs.config import BotConfig, Config
from zhenxun.configs.path_config import IMAGE_PATH, TEMPLATE_PATH
from zhenxun.models.sign_log import SignLog
from zhenxun.models.sign_user import SignUser
from zhenxun.utils.http_utils import AsyncHttpx
from zhenxun.utils.image_utils import BuildImage
from zhenxun.services import avatar_service
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from zhenxun.utils.platform import PlatformUtils
from .config import (
SIGN_BACKGROUND_PATH,
SIGN_BORDER_PATH,
SIGN_RESOURCE_PATH,
SIGN_TODAY_CARD_PATH,
level2attitude,
lik2level,
@@ -57,9 +50,7 @@ LG_MESSAGE = [
@PriorityLifecycle.on_startup(priority=5)
async def init_image():
SIGN_RESOURCE_PATH.mkdir(parents=True, exist_ok=True)
SIGN_TODAY_CARD_PATH.mkdir(exist_ok=True, parents=True)
# await generate_progress_bar_pic()
clear_sign_data_pic()
@@ -88,290 +79,54 @@ async def get_card(
返回:
Path: 卡片路径
"""
await generate_progress_bar_pic()
user_id = user.user_id
date = datetime.now().date()
_type = "view" if is_card_view else "sign"
file_name = f"{user_id}_{_type}_{date}.png"
view_name = f"{user_id}_view_{date}.png"
card_file = Path(SIGN_TODAY_CARD_PATH) / file_name
card_file = SIGN_TODAY_CARD_PATH / file_name
if card_file.exists():
return IMAGE_PATH / "sign" / "today_card" / file_name
return card_file
if add_impression == -1:
card_file = Path(SIGN_TODAY_CARD_PATH) / view_name
if card_file.exists():
return card_file
view_name = f"{user_id}_view_{date}.png"
view_card_file = SIGN_TODAY_CARD_PATH / view_name
if view_card_file.exists():
return view_card_file
is_card_view = True
return (
await _generate_html_card(
user, session, nickname, add_impression, gold, gift, is_double, is_card_view
)
if base_config.get("IMAGE_STYLE") == "zhenxun"
else await _generate_card(
user, session, nickname, add_impression, gold, gift, is_double, is_card_view
)
return await _generate_html_card(
user, session, nickname, add_impression, gold, gift, is_double, is_card_view
)
async def _generate_card(
user: SignUser,
session: Uninfo,
nickname: str,
add_impression: float,
gold: int | None,
gift: str,
is_double: bool = False,
is_card_view: bool = False,
) -> Path:
"""生成签到卡片
参数:
user: SignUser
session: Uninfo
nickname: 用户昵称
add_impression: 新增的好感度
gold: 金币
gift: 礼物
is_double: 是否触发双倍.
is_card_view: 是否展示好感度卡片.
返回:
Path: 卡片路径
"""
ava_bk = BuildImage(140, 140, (255, 255, 255, 0))
ava_border = BuildImage(
140,
140,
background=SIGN_BORDER_PATH / "ava_border_01.png",
)
if session.user.avatar and (
byt := await AsyncHttpx.get_content(session.user.avatar)
):
ava = BuildImage(107, 107, background=BytesIO(byt))
else:
ava = BuildImage(107, 107, (0, 0, 0))
await ava.circle()
await ava_bk.paste(ava, (19, 18))
await ava_bk.paste(ava_border, center_type="center")
impression = float(user.impression)
info_img = BuildImage(250, 150, color=(255, 255, 255, 0), font_size=15)
level, next_impression, previous_impression = get_level_and_next_impression(
impression
)
interpolation = next_impression - impression
await info_img.text((0, 0), f"· 好感度等级:{level} [{lik2relation[level]}]")
await info_img.text(
(0, 20), f"· {BotConfig.self_nickname}对你的态度:{level2attitude[level]}"
)
await info_img.text((0, 40), f"· 距离升级还差 {interpolation:.2f} 好感度")
bar_bk = BuildImage(220, 20, background=SIGN_RESOURCE_PATH / "bar_white.png")
bar = BuildImage(220, 20, background=SIGN_RESOURCE_PATH / "bar.png")
ratio = 1 - (next_impression - impression) / (next_impression - previous_impression)
if next_impression == 0:
ratio = 0
await bar.resize(width=int(bar.width * ratio) or 1, height=bar.height)
await bar_bk.paste(bar)
font_size = 20 if "好感度双倍加持卡" in gift else 30
gift_border = BuildImage(
270,
100,
background=SIGN_BORDER_PATH / "gift_border_02.png",
font_size=font_size,
)
await gift_border.text((0, 0), gift, center_type="center")
bk = BuildImage(
876,
424,
background=SIGN_BACKGROUND_PATH
/ random.choice(os.listdir(SIGN_BACKGROUND_PATH)),
font_size=25,
)
A = BuildImage(876, 274, background=SIGN_RESOURCE_PATH / "white.png")
line = BuildImage(2, 180, color="black")
await A.transparent(2)
await A.paste(ava_bk, (25, 80))
await A.paste(line, (200, 70))
nickname_img = await BuildImage.build_text_image(
nickname, size=50, font_color=(255, 255, 255)
)
user_console = await user.user_console
if user_console and user_console.uid is not None:
uid = f"{user_console.uid}".rjust(12, "0")
uid = f"{uid[:4]} {uid[4:8]} {uid[8:]}"
else:
uid = "XXXX XXXX XXXX"
uid_img = await BuildImage.build_text_image(
f"UID: {uid}", size=30, font_color=(255, 255, 255)
)
image1 = await bk.build_text_image("Accumulative check-in for", bk.font, size=30)
image2 = await bk.build_text_image("days", bk.font, size=30)
sign_day_img = await BuildImage.build_text_image(
f"{user.sign_count}", size=40, font_color=(211, 64, 33)
)
tip_width = image1.width + image2.width + sign_day_img.width + 60
tip_height = max([image1.height, image2.height, sign_day_img.height])
tip_image = BuildImage(tip_width, tip_height, (255, 255, 255, 0))
await tip_image.paste(image1, (0, 7))
await tip_image.paste(sign_day_img, (image1.width + 7, 0))
await tip_image.paste(image2, (image1.width + sign_day_img.width + 15, 7))
lik_text1_img = await BuildImage.build_text_image("当前", size=20)
lik_text2_img = await BuildImage.build_text_image(
f"好感度:{user.impression:.2f}", size=30
)
watermark = await BuildImage.build_text_image(
f"{BotConfig.self_nickname}@{datetime.now().year}",
size=15,
font_color=(155, 155, 155),
)
today_data = BuildImage(300, 300, color=(255, 255, 255, 0), font_size=20)
if is_card_view:
today_sign_text_img = await BuildImage.build_text_image("", size=30)
value_list = (
await SignUser.annotate()
.order_by("-impression")
.values_list("user_id", flat=True)
)
index = value_list.index(user.user_id) + 1 # type: ignore
rank_img = await BuildImage.build_text_image(
f"* 好感度排名第 {index} 位", size=30
)
await A.paste(rank_img, ((A.width - rank_img.width - 32), 20))
last_log = (
await SignLog.filter(user_id=user.user_id).order_by("create_time").first()
)
last_date = "从未"
if last_log:
last_date = last_log.create_time.astimezone(
pytz.timezone("Asia/Shanghai")
).date()
await today_data.text(
(0, 0),
f"上次签到日期:{last_date}",
)
await today_data.text((0, 25), f"总金币:{gold}")
default_setu_prob = (
Config.get_config("send_setu", "INITIAL_SETU_PROBABILITY") * 100 # type: ignore
)
setu_prob = (
default_setu_prob + float(user.impression) if user.impression < 100 else 100
)
await today_data.text(
(0, 50),
f"色图概率:{setu_prob:.2f}%",
)
await today_data.text((0, 75), f"开箱次数:{(20 + int(user.impression / 3))}")
_type = "view"
else:
await A.paste(gift_border, (570, 140))
today_sign_text_img = await BuildImage.build_text_image("今日签到", size=30)
if is_double:
await today_data.text((0, 0), f"好感度 + {add_impression / 2:.2f} × 2")
else:
await today_data.text((0, 0), f"好感度 + {add_impression:.2f}")
await today_data.text((0, 25), f"金币 + {gold}")
_type = "sign"
current_date = datetime.now()
current_datetime_str = current_date.strftime("%Y-%m-%d %a %H:%M:%S")
date = current_date.date()
date_img = await BuildImage.build_text_image(
f"时间:{current_datetime_str}", size=20
)
await bk.paste(nickname_img, (30, 15))
await bk.paste(uid_img, (30, 85))
await bk.paste(A, (0, 150))
await bk.paste(tip_image, (10, 167))
await bk.paste(date_img, (220, 370))
await bk.paste(lik_text1_img, (220, 240))
await bk.paste(lik_text2_img, (262, 234))
await bk.paste(bar_bk, (225, 275))
await bk.paste(info_img, (220, 305))
await bk.paste(today_sign_text_img, (550, 180))
await bk.paste(today_data, (580, 220))
await bk.paste(watermark, (15, 400))
await bk.save(SIGN_TODAY_CARD_PATH / f"{user.user_id}_{_type}_{date}.png")
return IMAGE_PATH / "sign" / "today_card" / f"{user.user_id}_{_type}_{date}.png"
async def generate_progress_bar_pic():
"""
初始化进度条图片
"""
bar_white_file = SIGN_RESOURCE_PATH / "bar_white.png"
if bar_white_file.exists():
return
bg_2 = (254, 1, 254)
bg_1 = (0, 245, 246)
bk = BuildImage(1000, 50)
img_x = BuildImage(50, 50, color=bg_2)
await img_x.circle()
await img_x.crop((25, 0, 50, 50))
img_y = BuildImage(50, 50, color=bg_1)
await img_y.circle()
await img_y.crop((0, 0, 25, 50))
A = BuildImage(950, 50)
width, height = A.size
step_r = (bg_2[0] - bg_1[0]) / width
step_g = (bg_2[1] - bg_1[1]) / width
step_b = (bg_2[2] - bg_1[2]) / width
for y in range(width):
bg_r = round(bg_1[0] + step_r * y)
bg_g = round(bg_1[1] + step_g * y)
bg_b = round(bg_1[2] + step_b * y)
for x in range(height):
await A.point((y, x), fill=(bg_r, bg_g, bg_b))
await bk.paste(img_y, (0, 0))
await bk.paste(A, (25, 0))
await bk.paste(img_x, (975, 0))
await bk.save(SIGN_RESOURCE_PATH / "bar.png")
A = BuildImage(950, 50)
bk = BuildImage(1000, 50)
img_x = BuildImage(50, 50)
await img_x.circle()
await img_x.crop((25, 0, 50, 50))
img_y = BuildImage(50, 50)
await img_y.circle()
await img_y.crop((0, 0, 25, 50))
await bk.paste(img_y, (0, 0))
await bk.paste(A, (25, 0))
await bk.paste(img_x, (975, 0))
await bk.save(bar_white_file)
def get_level_and_next_impression(impression: float) -> tuple[str, int | float, int]:
def get_level_and_next_impression(impression: float) -> tuple[int, int | float, int]:
"""获取当前好感等级与下一等级的差距
参数:
impression: 好感度
返回:
tuple[str, int, int]: 好感度等级,下一等级好感度要求,已达到的好感度要求
tuple[int, int, int]: 好感度等级,下一等级好感度要求,已达到的好感度要求
"""
keys = list(lik2level.keys())
level, next_impression, previous_impression = (
lik2level[keys[-1]],
level_int, next_impression, previous_impression = (
int(lik2level[keys[-1]]),
keys[-2],
keys[-1],
)
for i in range(len(keys)):
if impression >= keys[i]:
level, next_impression, previous_impression = (
lik2level[keys[i]],
level_int, next_impression, previous_impression = (
int(lik2level[keys[i]]),
keys[i - 1],
keys[i],
)
if i == 0:
next_impression = impression
break
return level, next_impression, previous_impression
return level_int, next_impression, previous_impression
def clear_sign_data_pic():
@@ -394,7 +149,7 @@ async def _generate_html_card(
is_double: bool = False,
is_card_view: bool = False,
) -> Path:
"""生成签到卡片
"""使用渲染服务生成签到卡片
参数:
user: SignUser
@@ -404,79 +159,133 @@ async def _generate_html_card(
gold: 金币
gift: 礼物
is_double: 是否触发双倍.
is_card_view: 是否展示好感度卡片.
is_card_view: 是否为卡片视图.
返回:
Path: 卡片路径
"""
now = datetime.now()
date = now.date()
_type = "view" if is_card_view else "sign"
file_name = f"{user.user_id}_{_type}_{date}.png"
card_file = SIGN_TODAY_CARD_PATH / file_name
if card_file.exists():
return card_file
impression = float(user.impression)
user_console = await user.user_console
if user_console and user_console.uid is not None:
uid = f"{user_console.uid}".rjust(12, "0")
uid = f"{uid[:4]} {uid[4:8]} {uid[8:]}"
uid_formatted = f"{uid[:4]} {uid[4:8]} {uid[8:]}"
else:
uid = "XXXX XXXX XXXX"
uid_formatted = "XXXX XXXX XXXX"
level, next_impression, previous_impression = get_level_and_next_impression(
impression
)
interpolation = next_impression - impression
message = f"{BotConfig.self_nickname}希望你开心!"
hour = datetime.now().hour
if hour > 6 and hour < 10:
message = random.choice(MORNING_MESSAGE)
elif hour >= 0 and hour < 6:
message = random.choice(LG_MESSAGE)
_impression = f"{add_impression}(×2)" if is_double else add_impression
process = 1 - (next_impression - impression) / (
next_impression - previous_impression
attitude = f"对你的态度: {level2attitude.get(str(level), '未知')}"
interpolation_val = max(0, next_impression - impression)
interpolation = f"{interpolation_val:.2f}"
denominator = next_impression - previous_impression
progress = (
100.0
if denominator == 0
else min(100.0, ((impression - previous_impression) / denominator) * 100)
)
now = datetime.now()
data = {
"ava_url": PlatformUtils.get_user_avatar_url(
user.user_id, PlatformUtils.get_platform(session), session.self_id
),
"name": nickname,
"uid": uid,
"sign_count": f"{user.sign_count}",
"message": f"{BotConfig.self_nickname}说: {message}",
"cur_impression": f"{impression:.2f}",
"impression": f"好感度+{_impression}",
"gold": f"金币+{gold}",
"gift": gift,
"level": f"{level} [{lik2relation[level]}]",
"attitude": f"对你的态度: {level2attitude[level]}",
"interpolation": f"{interpolation:.2f}",
"heart2": [1 for _ in range(int(level))],
"heart1": [1 for _ in range(len(lik2level) - int(level) - 1)],
"process": process * 100,
"date": str(now.replace(microsecond=0)),
"font_size": 45,
}
hour = now.hour
if 6 < hour < 10:
message = random.choice(MORNING_MESSAGE)
elif 0 <= hour < 6:
message = random.choice(LG_MESSAGE)
else:
message = f"{BotConfig.self_nickname}希望你开心!"
bot_message = f"{BotConfig.self_nickname}说: {message}"
temperature = random.randint(1, 40)
weather_icon_name = f"{random.randint(0, 11)}.png"
tag_icon_name = f"{random.randint(0, 5)}.png"
font_size = 45
if len(nickname) > 6:
data["font_size"] = 27
_type = "sign"
font_size = 27
avatar_path = await avatar_service.get_avatar_path(
PlatformUtils.get_platform(session), user.user_id
)
user_info = {
"nickname": nickname,
"uid_str": uid_formatted,
"avatar_url": avatar_path.as_uri() if avatar_path else "",
"sign_count": user.sign_count,
"font_size": font_size,
}
favorability_info = {
"current": impression,
"level": level,
"level_text": f"{level} [{lik2relation.get(str(level), '未知')}]",
"heart2": [1 for _ in range(level)],
"heart1": [1 for _ in range(len(lik2level) - level - 1)],
"next_level_at": next_impression,
"previous_level_at": previous_impression,
}
reward_info = None
rank = None
total_gold = None
if is_card_view:
_type = "view"
value_list = (
await SignUser.annotate()
.order_by("-impression")
.values_list("user_id", flat=True)
)
index = value_list.index(user.user_id) + 1 # type: ignore
data["impression"] = f"好感度排名第 {index} 位"
data["gold"] = f"总金币:{gold}"
data["gift"] = ""
pic = await template_to_pic(
template_path=str((TEMPLATE_PATH / "sign").absolute()),
template_name="main.html",
templates={"data": data},
pages={
"viewport": {"width": 465, "height": 926},
"base_url": f"file://{TEMPLATE_PATH}",
},
wait=2,
)
image = BuildImage.open(pic)
date = now.date()
await image.save(SIGN_TODAY_CARD_PATH / f"{user.user_id}_{_type}_{date}.png")
return IMAGE_PATH / "sign" / "today_card" / f"{user.user_id}_{_type}_{date}.png"
rank = value_list.index(user.user_id) + 1 if user.user_id in value_list else 0
total_gold = user_console.gold if user_console else 0
reward_info = {
"impression_added": 0,
"gold_added": 0,
"gift_received": "",
"is_double": False,
}
else:
reward_info = {
"impression_added": add_impression,
"gold_added": gold or 0,
"gift_received": gift,
"is_double": is_double,
}
page_info = {
"date_str": str(now.replace(microsecond=0)),
"weather_icon_name": weather_icon_name,
"temperature": temperature,
"tag_icon_name": tag_icon_name,
}
card_data = {
"is_card_view": is_card_view,
"user": user_info,
"favorability": favorability_info,
"reward": reward_info,
"page": page_info,
"bot_message": bot_message,
"attitude": attitude,
"interpolation": interpolation,
"progress": progress,
"rank": rank,
"total_gold": total_gold,
}
image_bytes = await ui.render_template("pages/builtin/sign", data=card_data)
async with aiofiles.open(card_file, "wb") as f:
await f.write(image_bytes)
return card_file
@@ -7,7 +7,6 @@ from zhenxun.models.statistics import Statistics
from zhenxun.utils.echart_utils import ChartUtils
from zhenxun.utils.echart_utils.models import Barh
from zhenxun.utils.enum import PluginType
from zhenxun.utils.image_utils import BuildImage
from zhenxun.utils.time_utils import TimeUtils
@@ -60,7 +59,7 @@ class StatisticsManage:
@classmethod
async def get_global_statistics(
cls, plugin_name: str | None, day: int | None, title: str
) -> BuildImage | str:
) -> bytes | str:
query = Statistics
if plugin_name:
query = query.filter(plugin_name=plugin_name)
@@ -114,7 +113,7 @@ class StatisticsManage:
)
@classmethod
async def __build_image(cls, data_list: list[tuple[str, int]], title: str):
async def __build_image(cls, data_list: list[tuple[str, int]], title: str) -> bytes:
module2count = {x[0]: x[1] for x in data_list}
plugin_info = await PluginInfo.filter(
module__in=module2count.keys(),
@@ -28,7 +28,8 @@ from nonebot_plugin_alconna.uniseg.segment import (
)
from nonebot_plugin_session import EventSession
from zhenxun.configs.utils import PluginExtraData, Task
from zhenxun.configs.utils import PluginExtraData, RegisterConfig, Task
from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
@@ -45,34 +46,52 @@ __plugin_meta__ = PluginMetadata(
name="广播",
description="昭告天下!",
usage="""
广播 [消息内容]
- 直接发送消息到除当前群组外的所有群组
- 支持文本、图片、@、表情、视频等多种消息类型
- 示例:广播 你们好!
- 示例:广播 [图片] 新活动开始啦!
向所有群组或指定标签的群组发送广播消息。
广播 + 引用消息
- 将引用的消息作为广播内容发送
- 支持引用普通消息或合并转发消息
- 示例:(引用一条消息) 广播
**基础用法**
- `广播 [消息内容]`:向所有群组发送广播。
- `广播` (并引用一条消息):将引用的消息作为内容进行广播。
广播撤回
- 撤回最近一次由您触发的广播消息
- 仅能撤回短时间内的消息
- 示例:广播撤回
**高级定向广播**
- `广播 -t <标签名> [消息内容]`:向指定标签下的所有群组广播。
- `广播到 <标签名> [消息内容]`:与 `-t` 等效的快捷方式。
特性:
- 在群组中使用广播时,不会将消息发送到当前群组
- 在私聊中使用广播时,会发送到所有群组
**标签可以是静态的,也可以是动态的,例如:**
- `广播到 核心群 通知:...`
- `广播到 成员数>500的群 通知:...`
别名:
- bc (广播的简写)
- recall (广播撤回的别名)
**其他命令**
- `广播撤回` (别名: `recall`):撤回最近一次发送的广播。
特性:
- 在群组中使用广播时,不会将消息发送到当前群组
- 在私聊中使用广播时,会发送到所有群组
别名:
- bc (广播的简写)
- recall (广播撤回的别名)
""".strip(),
extra=PluginExtraData(
author="HibiKier",
version="1.2",
version="1.3",
plugin_type=PluginType.SUPERUSER,
configs=[
RegisterConfig(
module="_task",
key="DEFAULT_BROADCAST",
value=True,
help="被动 广播 进群默认开关状态",
default_value=True,
type=bool,
),
RegisterConfig(
module="_task",
key="BROADCAST_CONCURRENCY_LIMIT",
value=10,
help="广播时的最大并发任务数,以避免API速率限制",
default_value=10,
),
],
tasks=[Task(module="broadcast", name="广播")],
).to_dict(),
)
@@ -103,6 +122,9 @@ _matcher = on_alconna(
Alconna(
"广播",
Args["content?", AllParam],
alc.Option(
"-t|--tag", Args["tag_name_bc", str], help_text="向指定标签的群组广播"
),
),
aliases={"bc"},
priority=1,
@@ -112,6 +134,8 @@ _matcher = on_alconna(
use_origin=False,
)
_matcher.shortcut("广播到 {tag}", command="广播 -t {tag} {%*}")
_recall_matcher = on_alconna(
Alconna("广播撤回"),
aliases={"recall"},
@@ -128,23 +152,59 @@ async def handle_broadcast(
event: Event,
session: EventSession,
arp: alc.Arparma,
tag_name_match: alc.Match[str] = alc.AlconnaMatch("tag_name_bc"),
):
broadcast_content_msg = await _extract_broadcast_content(bot, event, arp, session)
if not broadcast_content_msg:
return
target_groups, enabled_groups = await get_broadcast_target_groups(bot, session)
if not target_groups or not enabled_groups:
tag_name_to_broadcast = None
force_send = False
if tag_name_match.available:
tag_name_to_broadcast = tag_name_match.result
force_send = True
mode_desc = "强制发送到标签" if force_send else "普通发送"
logger.debug(
f"广播模式: {mode_desc}, 标签名: {tag_name_to_broadcast}",
"广播",
)
target_groups_console, groups_to_actually_send = await get_broadcast_target_groups(
bot, session, tag_name_to_broadcast, force_send
)
if not target_groups_console:
if tag_name_to_broadcast:
await MessageUtils.build_message(
f"标签 '{tag_name_to_broadcast}' 中没有群组或标签不存在。"
).send(reply_to=True)
return
if not groups_to_actually_send:
if not force_send and target_groups_console:
await MessageUtils.build_message(
"没有启用了广播功能的目标群组可供立即发送。"
).send(reply_to=True)
return
try:
await send_broadcast_and_notify(
bot, event, broadcast_content_msg, enabled_groups, target_groups, session
bot,
event,
broadcast_content_msg,
groups_to_actually_send,
target_groups_console,
session,
force_send,
)
except Exception as e:
error_msg = "发送广播失败"
BroadcastManager.log_error(error_msg, e, session)
await MessageUtils.build_message(f"{error_msg}。").send(reply_to=True)
await bot.send_private_msg(
user_id=str(event.get_user_id()), message=f"{error_msg}。"
)
@_recall_matcher.handle()
@@ -178,5 +238,6 @@ async def handle_broadcast_recall(
except Exception as e:
error_msg = "撤回广播消息失败"
BroadcastManager.log_error(error_msg, e, session)
user_id = str(event.get_user_id())
await bot.send_private_msg(user_id=user_id, message=f"{error_msg}。")
await bot.send_private_msg(
user_id=str(event.get_user_id()), message=f"{error_msg}。"
)
@@ -5,11 +5,12 @@ from typing import ClassVar
from nonebot.adapters import Bot
from nonebot.adapters.onebot.v11 import Bot as V11Bot
from nonebot.exception import ActionFailed
from nonebot.exception import ActionFailed, AdapterException
from nonebot_plugin_alconna import UniMessage
from nonebot_plugin_alconna.uniseg import Receipt, Reference
from nonebot_plugin_session import EventSession
from zhenxun.configs.config import Config
from zhenxun.models.group_console import GroupConsole
from zhenxun.services.log import logger
from zhenxun.utils.common_utils import CommonUtils
@@ -18,6 +19,8 @@ from zhenxun.utils.platform import PlatformUtils
from .models import BroadcastDetailResult, BroadcastResult
from .utils import custom_nodes_to_v11_nodes, uni_message_to_v11_list_of_dicts
BROADCAST_SEND_DELAY_RANGE = (1, 3)
class BroadcastManager:
"""广播管理器"""
@@ -92,8 +95,16 @@ class BroadcastManager:
logger.debug("清空上一次的广播消息ID记录", "广播", session=session)
cls.clear_last_broadcast_msg_ids()
concurrency_limit = Config.get_config(
"_task",
"BROADCAST_CONCURRENCY_LIMIT",
10,
)
all_groups, _ = await cls.get_all_groups(bot)
return await cls.send_to_specific_groups(bot, message, all_groups, session)
return await cls.send_to_specific_groups(
bot, message, all_groups, session, concurrency_limit=concurrency_limit
)
@classmethod
async def send_to_specific_groups(
@@ -102,14 +113,17 @@ class BroadcastManager:
message: UniMessage,
target_groups: list[GroupConsole],
session_info: EventSession | str | None = None,
force_send: bool = False,
concurrency_limit: int = 10,
) -> BroadcastResult:
"""发送广播到指定群组"""
log_session = session_info or bot.self_id
logger.debug(
f"开始广播,目标 {len(target_groups)} 个群组,Bot ID: {bot.self_id}",
"广播",
session=log_session,
target_count = len(target_groups)
log_message = (
f"开始广播,目标 {target_count} 个群组 (并发数: {concurrency_limit}),"
f"Bot ID: {bot.self_id}, ForceSend: {force_send}"
)
logger.info(log_message, "广播", session=log_session)
if not target_groups:
logger.debug("目标群组列表为空,广播结束", "广播", session=log_session)
@@ -165,7 +179,12 @@ class BroadcastManager:
)
return 0, len(target_groups)
success_count, error_count, skip_count = await cls._broadcast_forward(
bot, log_session, target_groups, v11_nodes
bot,
log_session,
target_groups,
v11_nodes,
force_send,
concurrency_limit,
)
else:
if is_forward_broadcast:
@@ -175,7 +194,12 @@ class BroadcastManager:
session=log_session,
)
success_count, error_count, skip_count = await cls._broadcast_normal(
bot, log_session, target_groups, message
bot,
log_session,
target_groups,
message,
force_send,
concurrency_limit,
)
total = len(target_groups)
@@ -287,11 +311,16 @@ class BroadcastManager:
)
@classmethod
async def _check_group_availability(cls, bot: Bot, group: GroupConsole) -> bool:
async def _check_group_availability(
cls, bot: Bot, group: GroupConsole, force_send: bool = False
) -> bool:
"""检查群组是否可用"""
if not group.group_id:
return False
if force_send:
return True
if await CommonUtils.task_is_block(bot, "broadcast", group.group_id):
return False
@@ -304,54 +333,69 @@ class BroadcastManager:
session_info: EventSession | str,
group_list: list[GroupConsole],
v11_nodes: list[dict],
force_send: bool = False,
concurrency_limit: int = 10,
) -> BroadcastDetailResult:
"""发送合并转发"""
success_count = 0
error_count = 0
skip_count = 0
semaphore = asyncio.Semaphore(concurrency_limit)
msg_id_lock = asyncio.Lock()
for _, group in enumerate(group_list):
async def send_to_group(group: GroupConsole) -> GroupConsole:
group_key = group.group_id or group.channel_id
async with semaphore:
try:
result = await bot.send_group_forward_msg(
group_id=int(group.group_id), messages=v11_nodes
)
async with msg_id_lock:
await cls._extract_message_id_from_result(
result, group_key, session_info, "合并转发"
)
await asyncio.sleep(random.uniform(*BROADCAST_SEND_DELAY_RANGE))
return group
except (ActionFailed, AdapterException) as ae:
logger.error(
f"发送失败(合并转发) to {group_key}: {ae}",
"广播",
session=session_info,
e=ae,
)
raise
except Exception as e:
logger.error(
f"发送失败(合并转发) to {group_key}: {e}",
"广播",
session=session_info,
e=e,
)
raise
if not await cls._check_group_availability(bot, group):
skip_count += 1
continue
tasks: list[asyncio.Task] = []
skipped_groups: list[GroupConsole] = []
for group in group_list:
if await cls._check_group_availability(bot, group, force_send):
tasks.append(asyncio.create_task(send_to_group(group)))
else:
skipped_groups.append(group)
try:
result = await bot.send_group_forward_msg(
group_id=int(group.group_id), messages=v11_nodes
)
if skipped_groups:
logger.info(
f"跳过 {len(skipped_groups)} 个不符合条件的群组",
"广播",
session=session_info,
)
logger.debug(
f"合并转发消息发送结果: {result}, 类型: {type(result)}",
"广播",
session=session_info,
)
if not tasks:
return 0, 0, len(skipped_groups)
await cls._extract_message_id_from_result(
result, group_key, session_info, "合并转发"
)
results = await asyncio.gather(*tasks, return_exceptions=True)
success_count += 1
await asyncio.sleep(random.randint(1, 3))
except ActionFailed as af_e:
error_count += 1
logger.error(
f"发送失败(合并转发) to {group_key}: {af_e}",
"广播",
session=session_info,
e=af_e,
)
except Exception as e:
error_count += 1
logger.error(
f"发送失败(合并转发) to {group_key}: {e}",
"广播",
session=session_info,
e=e,
)
success_count = sum(
1 for result in results if not isinstance(result, Exception)
)
error_count = len(results) - success_count
return success_count, error_count, skip_count
return success_count, error_count, len(skipped_groups)
@classmethod
async def _broadcast_normal(
@@ -360,58 +404,83 @@ class BroadcastManager:
session_info: EventSession | str,
group_list: list[GroupConsole],
message: UniMessage,
force_send: bool = False,
concurrency_limit: int = 10,
) -> BroadcastDetailResult:
"""发送普通消息"""
success_count = 0
error_count = 0
skip_count = 0
semaphore = asyncio.Semaphore(concurrency_limit)
msg_id_lock = asyncio.Lock()
for _, group in enumerate(group_list):
async def send_to_group(group: GroupConsole) -> GroupConsole:
group_key = (
f"{group.group_id}:{group.channel_id}"
if group.channel_id
else str(group.group_id)
)
if not await cls._check_group_availability(bot, group):
skip_count += 1
continue
try:
target = PlatformUtils.get_target(
group_id=group.group_id, channel_id=group.channel_id
)
if target:
receipt: Receipt = await message.send(target, bot=bot)
logger.debug(
f"广播消息发送结果: {receipt}, 类型: {type(receipt)}",
"广播",
session=session_info,
)
await cls._extract_message_id_from_result(
receipt, group_key, session_info
)
success_count += 1
await asyncio.sleep(random.randint(1, 3))
else:
logger.warning(
"target为空", "广播", session=session_info, target=group_key
)
skip_count += 1
except Exception as e:
error_count += 1
logger.error(
f"发送失败(普通) to {group_key}: {e}",
target = PlatformUtils.get_target(
group_id=group.group_id, channel_id=group.channel_id
)
if not target:
logger.warning(
"target为空",
"广播",
session=session_info,
e=e,
target=group_key,
)
raise ValueError(f"无法为群组 {group_key} 创建发送目标")
return success_count, error_count, skip_count
async with semaphore:
try:
receipt: Receipt = await message.send(target, bot=bot)
async with msg_id_lock:
await cls._extract_message_id_from_result(
receipt, group_key, session_info
)
await asyncio.sleep(random.uniform(*BROADCAST_SEND_DELAY_RANGE))
return group
except (ActionFailed, AdapterException) as ae:
logger.error(
f"发送失败(普通) to {group_key}: {ae}",
"广播",
session=session_info,
e=ae,
)
raise
except Exception as e:
logger.error(
f"发送失败(普通) to {group_key}: {e}",
"广播",
session=session_info,
e=e,
)
raise
tasks: list[asyncio.Task] = []
skipped_groups: list[GroupConsole] = []
for group in group_list:
if await cls._check_group_availability(bot, group, force_send):
tasks.append(asyncio.create_task(send_to_group(group)))
else:
skipped_groups.append(group)
if skipped_groups:
logger.info(
f"跳过 {len(skipped_groups)} 个不符合条件的群组",
"广播",
session=session_info,
)
if not tasks:
return 0, 0, len(skipped_groups)
results = await asyncio.gather(*tasks, return_exceptions=True)
success_count = sum(
1 for result in results if not isinstance(result, Exception)
)
error_count = len(results) - success_count
return success_count, error_count, len(skipped_groups)
@classmethod
async def recall_last_broadcast(
@@ -21,8 +21,11 @@ from nonebot_plugin_alconna.uniseg.segment import (
from nonebot_plugin_alconna.uniseg.tools import reply_fetch
from nonebot_plugin_session import EventSession
from zhenxun.models.group_console import GroupConsole
from zhenxun.services.log import logger
from zhenxun.services.tags import tag_manager as TagManager
from zhenxun.utils.common_utils import CommonUtils
from zhenxun.utils.http_utils import AsyncHttpx
from zhenxun.utils.message import MessageUtils
from .broadcast_manager import BroadcastManager
@@ -399,22 +402,29 @@ async def _process_v11_segment(
elif target_qq:
result.append(At(flag="user", target=target_qq))
elif seg_type == "video":
video_seg = None
if data_dict.get("url"):
video_seg = Video(url=data_dict["url"])
elif data_dict.get("file"):
file_val = data_dict["file"]
if url := data_dict.get("url"):
try:
logger.debug(f"[D{depth}] 正在下载视频用于广播: {url}", "广播")
video_bytes = await AsyncHttpx.get_content(url)
video_seg = Video(raw=video_bytes)
logger.debug(
f"[D{depth}] 视频下载成功, 大小: {len(video_bytes)} bytes",
"广播",
)
result.append(video_seg)
except Exception as e:
logger.error(f"[D{depth}] 广播时下载视频失败: {url}", "广播", e=e)
result.append(Text(f"[视频下载失败: {url}]"))
elif file_val := data_dict.get("file"):
if isinstance(file_val, str) and file_val.startswith("base64://"):
b64_data = file_val[9:]
raw_bytes = base64.b64decode(b64_data)
video_seg = Video(raw=raw_bytes)
result.append(video_seg)
else:
video_seg = Video(path=file_val)
if video_seg:
result.append(video_seg)
logger.debug(f"[Depth {depth}] 处理视频消息成功", "广播")
else:
logger.warning(f"[Depth {depth}] V11 视频 {index} 缺少URL/文件", "广播")
result.append(video_seg)
return result
elif seg_type == "forward":
nested_forward_id = data_dict.get("id") or data_dict.get("resid")
nested_forward_content = data_dict.get("content")
@@ -515,70 +525,129 @@ async def _extract_content_from_message(
async def get_broadcast_target_groups(
bot: Bot, session: EventSession
bot: Bot,
session: EventSession,
tag_name: str | None = None,
force_send: bool = False,
) -> tuple[list, list]:
"""获取广播目标群组和启用了广播功能的群组"""
target_groups = []
all_groups, _ = await BroadcastManager.get_all_groups(bot)
target_groups_console: list[GroupConsole] = []
current_group_id = None
if hasattr(session, "id2") and session.id2:
current_group_id = session.id2
current_group_raw = getattr(session, "id2", None) or getattr(
session, "group_id", None
)
current_group_id = str(current_group_raw) if current_group_raw else None
if current_group_id:
target_groups = [
group for group in all_groups if group.group_id != current_group_id
]
logger.info(
f"向除当前群组({current_group_id})外的所有群组广播", "广播", session=session
)
logger.debug(f"当前群组ID: {current_group_id}", "广播")
if tag_name:
tagged_group_ids = await TagManager.resolve_tag_to_group_ids(tag_name, bot=bot)
if not tagged_group_ids:
return [], []
valid_groups = await GroupConsole.filter(group_id__in=tagged_group_ids)
if current_group_id:
target_groups_console = [
group
for group in valid_groups
if str(group.group_id) != current_group_id
]
excluded_msg = (
f",已排除当前群组({current_group_id})"
if any(
str(group.group_id) == current_group_id for group in valid_groups
)
else ""
)
broadcast_msg = (
f"向标签 '{tag_name}' 中的 {len(target_groups_console)} 个群组广播 "
f"(ForceSend: {force_send}){excluded_msg}"
)
logger.info(broadcast_msg, "广播", session=session)
else:
target_groups_console = valid_groups
broadcast_msg = (
f"向标签 '{tag_name}' 中的 {len(target_groups_console)} 个群组广播 "
f"(ForceSend: {force_send})"
)
logger.info(broadcast_msg, "广播", session=session)
else:
target_groups = all_groups
logger.info("向所有群组广播", "广播", session=session)
all_groups, _ = await BroadcastManager.get_all_groups(bot)
if not target_groups:
await MessageUtils.build_message("没有找到符合条件的广播目标群组。").send(
reply_to=True
)
if current_group_id:
target_groups_console = [
group for group in all_groups if str(group.group_id) != current_group_id
]
logger.info(
(
f"向除当前群组({current_group_id})外的所有群组广播 "
f"(ForceSend: {force_send})"
),
"广播",
session=session,
)
else:
target_groups_console = all_groups
logger.info(
f"向所有群组广播 (ForceSend: {force_send})", "广播", session=session
)
if not target_groups_console:
if not tag_name:
await MessageUtils.build_message("没有找到符合条件的广播目标群组。").send(
reply_to=True
)
return [], []
enabled_groups = []
for group in target_groups:
if not await CommonUtils.task_is_block(bot, "broadcast", group.group_id):
enabled_groups.append(group)
groups_to_actually_send = []
if force_send:
groups_to_actually_send = target_groups_console
logger.debug(
f"强制发送模式,将向 {len(groups_to_actually_send)} 个目标群组尝试发送。",
"广播",
)
else:
for group in target_groups_console:
if not await CommonUtils.task_is_block(bot, "broadcast", group.group_id):
groups_to_actually_send.append(group)
logger.debug(
f"普通发送模式,筛选后将向 {len(groups_to_actually_send)} "
f"个目标群组尝试发送",
"广播",
)
if not enabled_groups:
await MessageUtils.build_message(
"没有启用了广播功能的目标群组可供立即发送。"
).send(reply_to=True)
return target_groups, []
return target_groups, enabled_groups
return target_groups_console, groups_to_actually_send
async def send_broadcast_and_notify(
bot: Bot,
event: Event,
message: UniMessage,
enabled_groups: list,
target_groups: list,
groups_to_send: list,
all_target_groups_for_stats: list,
session: EventSession,
force_send: bool = False,
) -> None:
"""发送广播并通知结果"""
BroadcastManager.clear_last_broadcast_msg_ids()
count, error_count = await BroadcastManager.send_to_specific_groups(
bot, message, enabled_groups, session
bot, message, groups_to_send, session, force_send
)
result = f"成功广播 {count} 个群组"
if error_count:
result += f"\n发送失败 {error_count} 个群组"
result += f"\n有效: {len(enabled_groups)} / 总计: {len(target_groups)}"
effective_sent_count = len(groups_to_send)
total_considered_count = len(all_target_groups_for_stats)
result += f"\n有效: {effective_sent_count} / 总计目标: {total_considered_count}"
user_id = str(event.get_user_id())
await bot.send_private_msg(user_id=user_id, message=f"发送广播完成!\n{result}")
BroadcastManager.log_info(
f"广播完成,有效/总计: {len(enabled_groups)}/{len(target_groups)}",
f"广播完成,有效/总计目标: {effective_sent_count}/{total_considered_count}",
session,
)
@@ -59,7 +59,7 @@ def uni_segment_to_v11_segment_dict(
logger.warning(f"无法处理 Video.raw 的类型: {type(raw_data)}", "广播")
elif getattr(seg, "path", None):
logger.warning(
f"在合并转发中使用了本地视频路径,可能无法显示: {seg.path}", "广播"
f"在合并转发中使用了本地视频路径,可能无法发送: {seg.path}", "广播"
)
return {"type": "video", "data": {"file": f"file:///{seg.path}"}}
else:
@@ -0,0 +1,581 @@
from typing import Any
from arclet.alconna.typing import KeyWordVar
import nonebot
from nonebot.adapters import Bot, Event
from nonebot.compat import model_fields
from nonebot.exception import SkippedException
from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import (
Alconna,
Args,
Arparma,
Match,
MultiVar,
Option,
Subcommand,
on_alconna,
store_true,
)
from nonebot_plugin_session import EventSession
from pydantic import BaseModel, ValidationError
from zhenxun.configs.config import Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.services import group_settings_service, renderer_service
from zhenxun.services.log import logger
from zhenxun.services.tags import tag_manager
from zhenxun.ui import builders as ui
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
from zhenxun.utils.pydantic_compat import parse_as
from zhenxun.utils.rules import admin_check
__plugin_meta__ = PluginMetadata(
name="插件配置管理",
description="一个统一的命令,用于管理所有插件的分群配置",
usage="""
### ⚙️ 插件配置管理 (pconf)
---
一个统一的命令,用于管理所有插件的分群或全局配置。
#### **📖 命令格式**
`pconf <子命令> [参数] [选项]`
#### **🎯 目标选项 (互斥)**
- `-g, --group <群号...>`: 指定一个或多个群组ID **(SUPERUSER)**
- `-t, --tag <标签名>`: 指定一个群组标签 **(SUPERUSER)**
- `--all`: 对当前Bot所在的所有群组执行操作 **(SUPERUSER)**
- `--global`: 操作全局配置 (config.yaml) **(SUPERUSER)**
- **(无)**: 在群聊中操作时,默认目标为当前群。
#### **📋 子命令列表**
* **`list` (或 `ls`)**: 查看列表
* `pconf list`: 查看所有支持分群配置的插件。
* `pconf list -p <插件名>`: 查看指定插件的所有分群可配置项。
* `pconf list -p <插件名> --all`: 查看所有群组对该插件的配置。
* `pconf list -p <插件名> --global`: 查看指定插件的全局可配置项。
* **`get <配置项>`**: 获取配置值
* `pconf get <配置项> -p <插件名>`: 获取当前群的配置值。
* `pconf get <配置项> -p <插件名> -g <群号>`: 获取指定群的配置值。
* **`set <key=value...>`**: 设置一个或多个配置值
* `pconf set key1=value1 key2=value2 -p <插件名>`
* **`reset [配置项]`**: 重置配置为默认值
* `pconf reset -p <插件名>`: 重置当前群该插件的所有配置。
* `pconf reset <配置项> -p <插件名>`: 重置当前群该插件的指定配置项。
""",
extra=PluginExtraData(
author="HibiKier",
version="1.0",
plugin_type=PluginType.SUPERUSER,
configs=[
RegisterConfig(
module="plugin_config_manager",
key="PCONF_ADMIN_LEVEL",
value=5,
help="管理分群配置的基础权限等级",
default_value=5,
type=int,
),
RegisterConfig(
module="plugin_config_manager",
key="SHOW_DEFAULT_CONFIG_IN_ALL",
value=False,
help="在使用 --all 查询时,是否显示配置为默认值的群组",
default_value=False,
type=bool,
),
],
).to_dict(),
)
pconf_cmd = on_alconna(
Alconna(
"pconf",
Subcommand(
"list",
alias=["ls"],
help_text="查看插件或配置项列表",
),
Subcommand(
"get",
Args["key", str],
help_text="获取配置值",
),
Subcommand(
"set",
Args["settings", MultiVar(KeyWordVar(Any))],
help_text="设置配置值",
),
Subcommand(
"reset",
Args["key?", str],
help_text="重置配置",
),
Option("-p|--plugin", Args["plugin_name", str], help_text="指定插件名"),
Option("-g|--group", Args["group_ids", MultiVar(str)], help_text="指定群组ID"),
Option("-t|--tag", Args["tag_name", str], help_text="指定群组标签"),
Option("--all", action=store_true, help_text="操作所有群组"),
Option("--global", action=store_true, help_text="操作全局配置"),
),
rule=admin_check("plugin_config_manager", "PCONF_ADMIN_LEVEL"),
priority=5,
block=True,
)
async def get_plugin_config_model(plugin_name: str) -> type[BaseModel] | None:
"""通过插件名查找其注册的分群配置模型"""
for p in nonebot.get_loaded_plugins():
if p.name == plugin_name and p.metadata and p.metadata.extra:
extra = PluginExtraData(**p.metadata.extra)
if extra.group_config_model:
return extra.group_config_model
return None
def truncate_text(text: str, max_len: int) -> str:
"""截断文本,过长时添加省略号"""
if len(text) > max_len:
return text[: max_len - 3] + "..."
return text
async def GetTargets(
bot: Bot, event: Event, session: EventSession, arp: Arparma
) -> list[str]:
"""
依赖注入,根据 -g, -t, --all 或当前会话解析目标群组ID列表,并进行权限检查。
"""
is_superuser = await SUPERUSER(bot, event)
if group_ids_match := arp.query[list[str]]("group.group_ids"):
if not is_superuser:
logger.warning(f"非超级用户 {session.id1} 尝试使用 -g 参数。")
raise SkippedException("权限不足")
return group_ids_match
if tag_name_match := arp.query[str]("tag.tag_name"):
if not is_superuser:
logger.warning(f"非超级用户 {session.id1} 尝试使用 -t 参数。")
raise SkippedException("权限不足")
resolved_groups = await tag_manager.resolve_tag_to_group_ids(
tag_name_match, bot=bot
)
if not resolved_groups:
await pconf_cmd.finish(f"标签 '{tag_name_match}' 没有匹配到任何群组。")
return resolved_groups
if arp.find("all"):
if not is_superuser:
logger.warning(f"非超级用户 {session.id1} 尝试使用 --all 参数。")
raise SkippedException("权限不足")
from zhenxun.utils.platform import PlatformUtils
all_groups, _ = await PlatformUtils.get_group_list(bot)
return [g.group_id for g in all_groups]
if gid := session.id3 or session.id2:
return [gid]
if not is_superuser:
logger.warning(f"管理员 {session.id1} 尝试在私聊中操作分群配置。")
raise SkippedException("权限不足")
await pconf_cmd.finish(
"超级用户在私聊中操作时,必须使用 -g <群号>、-t <标签名> 或 --all 指定目标群组"
)
@pconf_cmd.assign("list")
async def handle_list(arp: Arparma, bot: Bot, event: Event):
"""处理 list 子命令"""
plugin_name_str = None
is_superuser = await SUPERUSER(bot, event)
if arp.find("plugin"):
plugin_name_str = arp.query[str]("plugin.plugin_name")
if plugin_name_str:
is_global = arp.find("global")
is_all_groups = arp.find("all")
if is_all_groups and not is_global:
if not is_superuser:
await MessageUtils.build_message(
"只有超级用户才能查看所有群的配置。"
).finish()
model = await get_plugin_config_model(plugin_name_str)
model_fields_list = model_fields(model) if model else []
if not model_fields_list:
await MessageUtils.build_message(
f"插件 '{plugin_name_str}' 不支持分群配置。"
).finish()
all_groups, _ = await PlatformUtils.get_group_list(bot)
if not all_groups:
await MessageUtils.build_message("机器人未加入任何群组。").finish()
model_fields_dict = {field.name: field for field in model_fields_list}
config_keys = list(model_fields_dict.keys())
headers = ["群号", "群名称", *config_keys]
rows = []
for group in all_groups:
settings_dict = await group_settings_service.get_all_for_plugin(
group.group_id, plugin_name_str
)
row_data = [group.group_id, truncate_text(group.group_name, 10)]
for key in config_keys:
value = settings_dict.get(key)
default_value = model_fields_dict[key].field_info.default
if value == default_value:
value_str = "默认"
else:
value_str = str(value) if value is not None else "N/A"
row_data.append(truncate_text(value_str, 20))
show_default = Config.get_config(
"plugin_config_manager", "SHOW_DEFAULT_CONFIG_IN_ALL", False
)
if not show_default:
is_all_default = all(val == "默认" for val in row_data[2:])
if is_all_default:
continue
rows.append(row_data)
builder = ui.TableBuilder(
title=f"插件 '{plugin_name_str}' 全群配置",
tip=f"共查询 {len(rows)} 个群组",
)
builder.set_headers(headers).add_rows(rows)
viewport_width = 300 + len(config_keys) * 280
img = await renderer_service.render(
builder.build(), viewport={"width": viewport_width, "height": 10}
)
await MessageUtils.build_message(img).finish()
if is_global:
if not is_superuser:
await MessageUtils.build_message(
"只有超级用户才能查看全局配置。"
).finish()
config_group = Config.get(plugin_name_str)
if not config_group or not config_group.configs:
await MessageUtils.build_message(
f"插件 '{plugin_name_str}' 没有可配置的全局项。"
).finish()
builder = ui.TableBuilder(
title=f"插件 '{plugin_name_str}' 全局可配置项",
tip=(
f"位于 config.yaml, 使用 pconf set <key>=<value> "
f"-p {plugin_name_str} --global 进行设置"
),
)
builder.set_headers(["配置项", "当前值", "类型", "描述"])
for key, config_model in config_group.configs.items():
type_name = getattr(
config_model.type, "__name__", str(config_model.type)
)
builder.add_row(
[
key,
truncate_text(str(config_model.value), 20),
type_name,
truncate_text(config_model.help or "无", 20),
]
)
img = await renderer_service.render(builder.build())
await MessageUtils.build_message(img).finish()
else:
model = await get_plugin_config_model(plugin_name_str)
model_fields_list = model_fields(model) if model else []
if not model_fields_list:
await MessageUtils.build_message(
f"插件 '{plugin_name_str}' 不支持分群配置。"
).finish()
builder = ui.TableBuilder(
title=f"插件 '{plugin_name_str}' 可配置项",
tip=f"使用 pconf set <key>=<value> -p {plugin_name_str} 进行设置",
)
builder.set_headers(["配置项", "类型", "描述", "默认值"])
for field in model_fields_list:
type_name = getattr(field.annotation, "__name__", str(field.annotation))
description = field.field_info.description or "无"
default_value = (
str(field.get_default())
if field.field_info.default is not None
else "无"
)
builder.add_row([field.name, type_name, description, default_value])
img = await renderer_service.render(builder.build())
await MessageUtils.build_message(img).finish()
else:
configurable_plugins = []
for p in nonebot.get_loaded_plugins():
if p.metadata and p.metadata.extra:
extra = PluginExtraData(**p.metadata.extra)
if extra.group_config_model:
configurable_plugins.append(p.name)
if not configurable_plugins:
await MessageUtils.build_message("当前没有插件支持分群配置。").finish()
await MessageUtils.build_message(
"支持分群配置的插件列表:\n"
+ "\n".join(f"- {name}" for name in configurable_plugins)
).finish()
@pconf_cmd.assign("get")
async def handle_get(
arp: Arparma,
key: Match[str],
bot: Bot,
event: Event,
session: EventSession,
):
if not arp.find("plugin"):
await pconf_cmd.finish("必须使用 -p <插件名> 指定要操作的插件。")
plugin_name_str = arp.query[str]("plugin.plugin_name")
if not plugin_name_str:
await pconf_cmd.finish("插件名不能为空。")
is_superuser = await SUPERUSER(bot, event)
if arp.find("global"):
if not is_superuser:
await MessageUtils.build_message("只有超级用户才能获取全局配置。").finish()
value = Config.get_config(plugin_name_str, key.result)
await MessageUtils.build_message(
f"全局配置项 '{key.result}' 的值为: {value}"
).finish()
else:
target_group_ids = await GetTargets(bot, event, session, arp)
target_group_id = target_group_ids[0]
value = await group_settings_service.get(
target_group_id, plugin_name_str, key.result
)
await MessageUtils.build_message(
f"群组 {target_group_id} 的配置项 '{key.result}' 的值为: {value}"
).finish()
@pconf_cmd.assign("set")
async def handle_set(
arp: Arparma,
settings: Match[dict],
bot: Bot,
event: Event,
session: EventSession,
):
if not arp.find("plugin"):
await pconf_cmd.finish("必须使用 -p <插件名> 指定要操作的插件。")
plugin_name_str = arp.query[str]("plugin.plugin_name")
if not plugin_name_str:
await pconf_cmd.finish("插件名不能为空。")
is_superuser = await SUPERUSER(bot, event)
is_global = arp.find("global")
if is_global:
if not is_superuser:
await MessageUtils.build_message("只有超级用户才能设置全局配置。").finish()
config_group = Config.get(plugin_name_str)
if not config_group or not config_group.configs:
await MessageUtils.build_message(
f"插件 '{plugin_name_str}' 没有可配置的全局项。"
).finish()
changes_made = False
success_messages = []
for key, value_str in settings.result.items():
config_model = config_group.configs.get(key.upper())
if not config_model:
await MessageUtils.build_message(
f"❌ 全局配置项 '{key}' 不存在。"
).send()
continue
target_type = config_model.type
if target_type is None:
if config_model.default_value is not None:
target_type = type(config_model.default_value)
elif config_model.value is not None:
target_type = type(config_model.value)
converted_value: Any = value_str
if target_type and value_str is not None:
try:
converted_value = parse_as(target_type, value_str)
except (ValidationError, TypeError, ValueError) as e:
type_name = getattr(target_type, "__name__", str(target_type))
await MessageUtils.build_message(
f"❌ 配置项 '{key}' 的值 '{value_str}' "
f"无法转换为期望的类型 '{type_name}': {e}"
).send()
continue
Config.set_config(plugin_name_str, key.upper(), converted_value)
success_messages.append(f" - 配置项 '{key}' 已设置为: `{converted_value}`")
changes_made = True
if changes_made:
Config.save(save_simple_data=True)
response_msg = (
f"✅ 插件 '{plugin_name_str}' 的全局配置已更新:\n"
+ "\n".join(success_messages)
)
await MessageUtils.build_message(response_msg).finish()
else:
model = await get_plugin_config_model(plugin_name_str)
if not model:
await MessageUtils.build_message(
f"插件 '{plugin_name_str}' 不支持分群配置。"
).finish()
target_group_ids = await GetTargets(bot, event, session, arp)
model_fields_map = {field.name: field for field in model_fields(model)}
success_groups = []
failed_groups = []
update_details = []
for group_id in target_group_ids:
for key, value_str in settings.result.items():
field = model_fields_map.get(key)
if not field:
await MessageUtils.build_message(
f"配置项 '{key}' 在插件 '{plugin_name_str}' 中不存在。"
).finish()
try:
validated_value = (
parse_as(field.annotation, value_str)
if field.annotation is not None
else value_str
)
await group_settings_service.set_key_value(
group_id, plugin_name_str, key, validated_value
)
if group_id not in success_groups:
success_groups.append(group_id)
if (key, validated_value) not in update_details:
update_details.append((key, validated_value))
except (ValidationError, TypeError, ValueError) as e:
failed_groups.append(
(group_id, f"配置项 '{key}' 值 '{value_str}' 类型错误: {e}")
)
except Exception as e:
failed_groups.append((group_id, f"内部错误: {e}"))
if len(target_group_ids) == 1:
group_id = target_group_ids[0]
if group_id in success_groups and group_id not in [
g[0] for g in failed_groups
]:
settings_summary = [
f" - '{k}' 已设置为: `{v}`" for k, v in update_details
]
msg = (
f"✅ 群组 {group_id} 插件 '{plugin_name_str}' 配置更新成功:\n"
+ "\n".join(settings_summary)
)
else:
errors = [f[1] for f in failed_groups if f[0] == group_id]
msg = (
f"❌ 群组 {group_id} 插件 '{plugin_name_str}' 配置更新失败:\n"
+ "\n".join(errors)
)
else:
settings_count = len(settings.result)
msg = (
f"✅ 批量为 {len(success_groups)} 个群组设置了 "
f"{settings_count} 个配置项。"
)
if failed_groups:
failed_count = len({g[0] for g in failed_groups})
msg += f"\n❌ 其中 {failed_count} 个群组部分或全部设置失败。"
await MessageUtils.build_message(msg).finish()
@pconf_cmd.assign("reset")
async def handle_reset(
arp: Arparma,
key: Match[str],
bot: Bot,
event: Event,
session: EventSession,
):
if not arp.find("plugin"):
await pconf_cmd.finish("必须使用 -p <插件名> 指定要操作的插件。")
plugin_name_str = arp.query[str]("plugin.plugin_name")
if not plugin_name_str:
await pconf_cmd.finish("插件名不能为空。")
is_superuser = await SUPERUSER(bot, event)
if arp.find("global"):
if not is_superuser:
await MessageUtils.build_message("只有超级用户才能重置全局配置。").finish()
await MessageUtils.build_message("全局配置重置功能暂未实现。").finish()
else:
target_group_ids = await GetTargets(bot, event, session, arp)
key_str = key.result if key.available else None
success_groups = []
failed_groups = []
for group_id in target_group_ids:
try:
if key_str:
await group_settings_service.reset_key(
group_id, plugin_name_str, key_str
)
else:
await group_settings_service.reset_all_for_plugin(
group_id, plugin_name_str
)
success_groups.append(group_id)
except Exception as e:
failed_groups.append((group_id, str(e)))
action = f"配置项 '{key_str}'" if key_str else "所有配置"
if len(target_group_ids) == 1:
if success_groups:
msg = (
f"✅ 群组 {target_group_ids[0]} 中插件 '{plugin_name_str}' "
f"的 {action} 已成功重置。"
)
else:
msg = (
f"❌ 群组 {target_group_ids[0]} 中插件 '{plugin_name_str}' "
f"的 {action} 重置失败: {failed_groups[0][1]}"
)
else:
msg = (
f"✅ 批量操作完成: 成功为 {len(success_groups)} 个群组重置了 {action}。"
)
if failed_groups:
failed_count = len({g[0] for g in failed_groups})
msg += f"\n❌ 其中 {failed_count} 个群组操作失败。"
await MessageUtils.build_message(msg).finish()
@@ -7,6 +7,8 @@ from nonebot_plugin_session import EventSession
from zhenxun.configs.config import Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.services.llm.config.providers import get_llm_config
from zhenxun.services.llm.manager import clear_model_cache
from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
@@ -54,6 +56,8 @@ _matcher = on_alconna(
@_matcher.handle()
async def _(session: EventSession, arparma: Arparma):
Config.reload()
get_llm_config.cache_clear()
clear_model_cache()
logger.debug("自动重载配置文件", arparma.header_result, session=session)
await MessageUtils.build_message("重载完成!").send(reply_to=True)
@@ -65,4 +69,6 @@ async def _(session: EventSession, arparma: Arparma):
async def _():
if Config.get_config("reload_setting", "AUTO_RELOAD"):
Config.reload()
get_llm_config.cache_clear()
clear_model_cache()
logger.debug("已自动重载配置文件...")
@@ -3,17 +3,13 @@ from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import Alconna, Arparma, on_alconna
from nonebot_plugin_session import EventSession
from zhenxun.configs.config import Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.configs.utils import PluginExtraData
from zhenxun.services.help_service import create_plugin_help_image
from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType
from zhenxun.utils.exception import EmptyError
from zhenxun.utils.message import MessageUtils
from .config import SUPERUSER_HELP_IMAGE
from .normal_help import build_help
from .zhenxun_help import build_html_help
__plugin_meta__ = PluginMetadata(
name="超级用户帮助",
description="超级用户帮助",
@@ -24,17 +20,18 @@ __plugin_meta__ = PluginMetadata(
author="HibiKier",
version="0.1",
plugin_type=PluginType.SUPERUSER,
configs=[
RegisterConfig(
key="type",
value="zhenxun",
help="超级用户帮助样式,normal, zhenxun",
default_value="zhenxun",
)
],
).to_dict(),
)
async def build_html_help() -> bytes:
"""构建超级用户帮助图片"""
return await create_plugin_help_image(
plugin_types=[PluginType.SUPERUSER, PluginType.SUPER_AND_ADMIN],
page_title="超级用户帮助手册",
)
_matcher = on_alconna(
Alconna("超级用户帮助"),
permission=SUPERUSER,
@@ -45,15 +42,11 @@ _matcher = on_alconna(
@_matcher.handle()
async def _(session: EventSession, arparma: Arparma):
if not SUPERUSER_HELP_IMAGE.exists():
try:
if Config.get_config("admin_help", "type") == "zhenxun":
await build_html_help()
else:
await build_help()
except EmptyError:
await MessageUtils.build_message("当前超级用户帮助为空...").finish(
reply_to=True
)
await MessageUtils.build_message(SUPERUSER_HELP_IMAGE).send()
try:
image_bytes = await build_html_help()
await MessageUtils.build_message(image_bytes).send()
except EmptyError:
await MessageUtils.build_message("当前超级用户帮助为空...").finish(
reply_to=True
)
logger.info("查看超级用户帮助", arparma.header_result, session=session)
@@ -1,23 +0,0 @@
from nonebot.plugin import PluginMetadata
from pydantic import BaseModel
from zhenxun.configs.path_config import IMAGE_PATH
from zhenxun.models.plugin_info import PluginInfo
SUPERUSER_HELP_IMAGE = IMAGE_PATH / "SUPERUSER_HELP.png"
if SUPERUSER_HELP_IMAGE.exists():
SUPERUSER_HELP_IMAGE.unlink()
class PluginData(BaseModel):
"""
插件信息
"""
plugin: PluginInfo
"""插件信息"""
metadata: PluginMetadata
"""元数据"""
class Config:
arbitrary_types_allowed = True
@@ -1,127 +0,0 @@
from nonebot.plugin import PluginMetadata
from PIL.ImageFont import FreeTypeFont
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.task_info import TaskInfo
from zhenxun.services.log import logger
from zhenxun.utils._build_image import BuildImage
from zhenxun.utils.image_utils import build_sort_image, group_image, text2image
from .config import SUPERUSER_HELP_IMAGE
from .utils import get_plugins
async def build_usage_des_image(
metadata: PluginMetadata,
) -> tuple[BuildImage | None, BuildImage | None]:
"""构建用法和描述图片
参数:
metadata: PluginMetadata
返回:
tuple[BuildImage | None, BuildImage | None]: 用法和描述图片
"""
usage = None
description = None
if metadata.usage:
usage = await text2image(
metadata.usage,
padding=5,
color=(255, 255, 255),
font_color=(0, 0, 0),
)
if metadata.description:
description = await text2image(
metadata.description,
padding=5,
color=(255, 255, 255),
font_color=(0, 0, 0),
)
return usage, description
async def build_image(
plugin: PluginInfo, metadata: PluginMetadata, font: FreeTypeFont
) -> BuildImage:
"""构建帮助图片
参数:
plugin: PluginInfo
metadata: PluginMetadata
font: FreeTypeFont
返回:
BuildImage: 帮助图片
"""
usage, description = await build_usage_des_image(metadata)
width = 0
height = 100
if usage:
width = usage.width
height += usage.height
if description and description.width > width:
width = description.width
height += description.height
font_width, _ = BuildImage.get_text_size(f"{plugin.name}[{plugin.level}]", font)
if font_width > width:
width = font_width
A = BuildImage(width + 30, height + 120, "#EAEDF2")
await A.text((15, 10), f"{plugin.name}[{plugin.level}]")
await A.text((15, 70), "简介:")
if not description:
description = BuildImage(A.width - 30, 30, (255, 255, 255))
await description.circle_corner(10)
await A.paste(description, (15, 100))
if not usage:
usage = BuildImage(A.width - 30, 30, (255, 255, 255))
await usage.circle_corner(10)
await A.text((15, description.height + 115), "用法:")
await A.paste(usage, (15, description.height + 145))
await A.circle_corner(10)
return A
async def build_help():
"""构造超级用户帮助图片
返回:
BuildImage: 超级用户帮助图片
"""
font = BuildImage.load_font("HYWenHei-85W.ttf", 20)
image_list = []
for data in await get_plugins():
plugin = data.plugin
metadata = data.metadata
try:
A = await build_image(plugin, metadata, font)
image_list.append(A)
except Exception as e:
logger.warning(
f"获取群超级用户插件 {plugin.module}: {plugin.name} 设置失败...",
"超级用户帮助",
e=e,
)
if task_list := await TaskInfo.all():
task_str = "\n".join([task.name for task in task_list])
task_str = "通过 开启/关闭群被动 来控制群被动\n----------\n" + task_str
task_image = await text2image(task_str, padding=5, color=(255, 255, 255))
await task_image.circle_corner(10)
A = BuildImage(task_image.width + 50, task_image.height + 85, "#EAEDF2")
await A.text((25, 10), "被动技能")
await A.paste(task_image, (25, 50))
await A.circle_corner(10)
image_list.append(A)
image_group, _ = group_image(image_list)
A = await build_sort_image(image_group, color=(255, 255, 255), padding_top=160)
text = await BuildImage.build_text_image(
"群超级用户帮助",
size=40,
)
tip = await BuildImage.build_text_image(
"注: ‘*’ 代表可有多个相同参数 ‘?’ 代表可省略该参数", size=25, font_color="red"
)
await A.paste(text, (50, 30))
await A.paste(tip, (50, 90))
await A.save(SUPERUSER_HELP_IMAGE)
@@ -1,22 +0,0 @@
import nonebot
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.utils.enum import PluginType
from zhenxun.utils.exception import EmptyError
from .config import PluginData
async def get_plugins() -> list[PluginData]:
"""获取插件数据"""
plugin_list = await PluginInfo.filter(
plugin_type__in=[PluginType.SUPERUSER, PluginType.SUPER_AND_ADMIN]
).all()
data_list = []
for plugin in plugin_list:
if _plugin := nonebot.get_plugin_by_module_name(plugin.module_path):
if _plugin.metadata:
data_list.append(PluginData(plugin=plugin, metadata=_plugin.metadata))
if not data_list:
raise EmptyError()
return data_list
@@ -1,60 +0,0 @@
from nonebot_plugin_htmlrender import template_to_pic
from zhenxun.configs.config import BotConfig
from zhenxun.configs.path_config import TEMPLATE_PATH
from zhenxun.models.task_info import TaskInfo
from zhenxun.utils._build_image import BuildImage
from .config import SUPERUSER_HELP_IMAGE
from .utils import get_plugins
async def get_task() -> dict[str, str] | None:
"""获取被动技能帮助"""
if task_list := await TaskInfo.all():
return {
"name": "被动技能",
"description": "控制群组中的被动技能状态",
"usage": "通过 开启/关闭群被动 来控制群被动 <br>"
+ " 示例:开启/关闭群被动早晚安 <br> ---------- <br> "
+ "<br>".join([task.name for task in task_list]),
}
return None
async def build_html_help():
"""构建帮助图片"""
plugins = await get_plugins()
plugin_list = []
for data in plugins:
if data.metadata.extra:
if superuser_help := data.metadata.extra.get("superuser_help"):
data.metadata.usage += f"<br>以下为超级用户额外命令<br>{superuser_help}"
plugin_list.append(
{
"name": data.plugin.name,
"description": data.metadata.description.replace("\n", "<br>"),
"usage": data.metadata.usage.replace("\n", "<br>"),
}
)
if task := await get_task():
plugin_list.append(task)
plugin_list.sort(key=lambda p: len(p["description"]) + len(p["usage"]))
pic = await template_to_pic(
template_path=str((TEMPLATE_PATH / "help").absolute()),
template_name="main.html",
templates={
"data": {
"plugin_list": plugin_list,
"nickname": BotConfig.self_nickname,
"help_name": "超级用户",
}
},
pages={
"viewport": {"width": 824, "height": 10},
"base_url": f"file://{TEMPLATE_PATH}",
},
wait=2,
)
result = await BuildImage.open(pic).resize(0.5)
await result.save(SUPERUSER_HELP_IMAGE)
@@ -0,0 +1,483 @@
from nonebot.adapters import Bot
from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import (
Alconna,
AlconnaMatch,
AlconnaQuery,
Args,
Match,
MultiVar,
Option,
Query,
Subcommand,
on_alconna,
store_true,
)
from nonebot_plugin_waiter import prompt_until
from tortoise.exceptions import IntegrityError
from zhenxun.configs.utils import PluginExtraData
from zhenxun.services.tags import tag_manager
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
__plugin_meta__ = PluginMetadata(
name="群组标签管理",
description="用于管理和操作群组标签",
usage="""### 🏷️ 群组标签管理
用于创建和管理群组标签,以实现对群组的批量操作和筛选。
---
#### **✨ 核心命令**
- **`tag list`** (别名: `ls`)
- 查看所有标签及其基本信息。
- **`tag info <标签名>`**
- 查看指定标签的详细信息,包括关联群组或动态规则的匹配结果。
- **`tag create <标签名> [选项...]`**
- 创建一个新标签。
- **选项**:
- `--type <static|dynamic>`: 标签类型,默认为 `static`。
- `static`: 静态标签,需手动关联群组。
- `dynamic`: 动态标签,根据规则自动匹配。
- `-g <群号...>`: **(静态)** 初始关联的群组ID。
- `--rule "<规则>"`: **(动态)** 定义动态规则,**规则必须用引号包裹**。
- `--desc "<描述>"`: 为标签添加描述。
- `--blacklist`: **(静态)** 将标签设为黑名单(排除)模式。
- **`tag edit <标签名> [操作...]`**
- 编辑一个已存在的标签。
- **通用操作**:
- `--rename <新名>`: 重命名标签。
- `--desc "<描述>"`: 更新描述。
- `--mode <white|black>`: 切换为白名单/黑名单模式。
- **静态标签操作**:
- `--add <群号...>`: 添加群组。
- `--remove <群号...>`: 移除群组。
- `--set <群号...>`: **[覆盖]** 重新设置所有关联群组。
- **动态标签操作**:
- `--rule "<新规则>"`: 更新动态规则。
- **`tag delete <名1> [名2] ...`**
- 删除一个或多个标签。
- **`tag clear`**
- **[⚠️ 危险]** 删除所有标签,操作前会请求确认。
---
#### **🔧 动态规则速查**
规则支持 `and` 和 `or` 组合(`and` 优先)。
**包含空格或特殊字符的规则值建议用英文引号包裹**。
- `member_count > 100`
按 **群成员数** 筛选 (`>`, `>=`, `<`, `<=`, `=`)。
- `level >= 5`
按 **群权限等级** 筛选。
- `status = true`
按 **群是否休眠** 筛选 (`true` / `false`)。
- `is_super = false`
按 **群是否为白名单** 筛选 (`true` / `false`)。
- `group_name contains "模式"`
按 **群名模糊/正则匹配**。
例: `contains "测试.*群$"` 匹配以“测试”开头、“群”结尾的群名。
- `group_name in "群1,群2"`
按 **群名多值精确匹配** (英文逗号分隔)。
---
#### **💡 使用示例**
##### 静态标签示例
```bash
# 创建一个名为“核心群”的静态标签,并关联两个群组
tag create 核心群 -g 12345 67890 --desc "核心业务群"
# 向“核心群”中添加一个新群组
tag edit 核心群 --add 98765
# 创建一个用于排除的黑名单标签
tag create 排除群 --blacklist -g 11111
```
##### 动态标签示例
```bash
# 创建一个动态标签,匹配所有成员数大于200的群
tag create 大群 --type dynamic --rule "member_count > 200"
# 创建一个匹配高权限且未休眠的群的标签
tag create 活跃管理群 --type dynamic --rule "level > 5 and status = true"
# 创建一个匹配群名包含“核心”或“测试”的标签
tag create 业务群 --type dynamic --rule "group_name contains 核心 or group_name contains 测试"
```
""".strip(), # noqa: E501
extra=PluginExtraData(
author="HibiKier",
version="1.0.0",
plugin_type=PluginType.SUPERUSER,
).to_dict(),
)
tag_cmd = on_alconna(
Alconna(
"tag",
Subcommand("list", alias=["ls"], help_text="查看所有标签"),
Subcommand("info", Args["name", str], help_text="查看标签详情"),
Subcommand(
"create",
Args["name", str],
Option(
"--rule",
Args["rule", str],
help_text="动态标签规则 (例如: min_members=100)",
),
Option(
"--type",
Args["tag_type", ["static", "dynamic"]],
help_text="标签类型 (默认: static)",
),
Option(
"--blacklist", action=store_true, help_text="设为黑名单模式(仅静态标签)"
),
Option("--desc", Args["description", str], help_text="标签描述"),
Option(
"-g", Args["group_ids", MultiVar(str)], help_text="创建时要关联的群组ID"
),
),
Subcommand(
"edit",
Args["name", str],
Option(
"--rule",
Args["rule", str],
help_text="更新动态标签规则",
),
Option("--add", Args["add_groups", MultiVar(str)]),
Option("--remove", Args["remove_groups", MultiVar(str)]),
Option("--set", Args["set_groups", MultiVar(str)]),
Option("--rename", Args["new_name", str]),
Option("--desc", Args["description", str]),
Option("--mode", Args["mode", ["black", "white"]]),
help_text="编辑标签",
),
Subcommand(
"delete",
Args["names", MultiVar(str)],
alias=["del", "rm"],
help_text="删除标签",
),
Subcommand("clear", help_text="清空所有标签"),
Subcommand("prune", alias=["check", "清理"], help_text="清理无效的群组关联"),
Subcommand(
"clone",
Args["source_name", str]["new_name", str],
Option("--add", Args["add_groups", MultiVar(str)]),
Option("--remove", Args["remove_groups", MultiVar(str)]),
Option("--as-dynamic", action=store_true),
Option("--desc", Args["description", str]),
Option("--mode", Args["mode", ["black", "white"]]),
help_text="克隆标签",
),
),
permission=SUPERUSER,
priority=5,
block=True,
)
tag_cmd.shortcut(
"清理标签",
command="tag",
arguments=["prune"],
prefix=True,
)
@tag_cmd.assign("list")
async def handle_list():
tags = await tag_manager.list_tags_with_counts()
if not tags:
await MessageUtils.build_message("当前没有已创建的标签。").finish()
msg = "已创建的群组标签:\n"
for tag in tags:
mode = "黑名单(排除)" if tag["is_blacklist"] else "白名单(包含)"
tag_type = "动态" if tag["tag_type"] == "DYNAMIC" else "静态"
count_desc = (
f"含 {tag['group_count']} 个群组" if tag_type == "静态" else "动态计算"
)
msg += f"- {tag['name']} (类型: {tag_type}, 模式: {mode}): {count_desc}\n"
await MessageUtils.build_message(msg).finish()
@tag_cmd.assign("info")
async def handle_info(name: Match[str], bot: Bot):
details = await tag_manager.get_tag_details(name.result, bot=bot)
if not details:
await MessageUtils.build_message(f"标签 '{name.result}' 不存在。").finish()
mode = "黑名单(排除)" if details["is_blacklist"] else "白名单(包含)"
tag_type_str = "动态" if details["tag_type"] == "DYNAMIC" else "静态"
msg = f"标签详情: {details['name']}\n"
msg += f"类型: {tag_type_str}\n"
msg += f"模式: {mode}\n"
msg += f"描述: {details['description'] or '无'}\n"
if details["tag_type"] == "STATIC" and details["is_blacklist"]:
msg += f"排除群组 ({len(details['groups'])}个):\n"
if details["groups"]:
msg += "\n".join(f"- {gid}" for gid in details["groups"])
else:
msg += "无"
msg += "\n\n"
if details["tag_type"] == "DYNAMIC" and details.get("dynamic_rule"):
msg += f"动态规则: {details['dynamic_rule']}\n"
title = (
"当前生效群组"
if details["tag_type"] == "DYNAMIC" or details["is_blacklist"]
else "关联群组"
)
if details["resolved_groups"] is not None:
msg += f"{title} ({len(details['resolved_groups'])}个):\n"
if details["resolved_groups"]:
msg += "\n".join(
f"- {g_name} ({g_id})" for g_id, g_name in details["resolved_groups"]
)
else:
msg += "无"
else:
msg += f"关联群组 ({len(details['groups'])}个):\n"
if details["groups"]:
msg += "\n".join(f"- {gid}" for gid in details["groups"])
else:
msg += "无"
await MessageUtils.build_message(msg).finish()
@tag_cmd.assign("create")
async def handle_create(
name: Match[str],
description: Match[str],
group_ids: Match[list[str]],
rule: Match[str] = AlconnaMatch("rule"),
tag_type: Match[str] = AlconnaMatch("tag_type"),
blacklist: Query[bool] = AlconnaQuery("create.blacklist.value", False),
):
ttype = (
tag_type.result.upper()
if tag_type.available
else ("DYNAMIC" if rule.available else "STATIC")
)
if ttype == "DYNAMIC" and not rule.available:
await MessageUtils.build_message(
"创建失败: 动态标签必须提供至少一个规则。"
).finish()
try:
gids_to_create = None
unique_gids_count = 0
if group_ids.available:
unique_gids = list(dict.fromkeys(group_ids.result))
gids_to_create = unique_gids
unique_gids_count = len(unique_gids)
tag = await tag_manager.create_tag(
name=name.result,
is_blacklist=blacklist.result,
description=description.result if description.available else None,
group_ids=gids_to_create,
tag_type=ttype,
dynamic_rule=rule.result if rule.available else None,
)
msg = f"标签 '{tag.name}' 创建成功!"
if group_ids.available:
msg += f"\n已同时关联 {unique_gids_count} 个群组。"
await MessageUtils.build_message(msg).finish()
except IntegrityError:
await MessageUtils.build_message(
f"创建失败: 标签 '{name.result}' 已存在。"
).finish()
except ValueError as e:
await MessageUtils.build_message(f"创建失败: {e}").finish()
@tag_cmd.assign("edit")
async def handle_edit(
name: Match[str],
add_groups: Match[list[str]],
remove_groups: Match[list[str]],
set_groups: Match[list[str]],
new_name: Match[str],
description: Match[str],
mode: Match[str],
rule: Match[str] = AlconnaMatch("rule"),
):
tag_name = name.result
tag_details = await tag_manager.get_tag_details(tag_name)
if not tag_details:
await MessageUtils.build_message(f"标签 '{tag_name}' 不存在。").finish()
group_actions = [
add_groups.available,
remove_groups.available,
set_groups.available,
]
if sum(group_actions) > 1:
await MessageUtils.build_message(
"`--add`, `--remove`, `--set` 选项不能同时使用。"
).finish()
is_dynamic = tag_details.get("tag_type") == "DYNAMIC"
if is_dynamic and any(group_actions):
await MessageUtils.build_message(
"编辑失败: 不能对动态标签执行 --add, --remove, 或 --set 操作。"
).finish()
if not is_dynamic and rule.available:
await MessageUtils.build_message(
"编辑失败: 不能为静态标签设置动态规则。"
).finish()
results = []
try:
rule_str = rule.result if rule.available else None
if add_groups.available:
count = await tag_manager.add_groups_to_tag(tag_name, add_groups.result)
results.append(f"添加了 {count} 个群组。")
if remove_groups.available:
count = await tag_manager.remove_groups_from_tag(
tag_name, remove_groups.result
)
results.append(f"移除了 {count} 个群组。")
if set_groups.available:
count = await tag_manager.set_groups_for_tag(tag_name, set_groups.result)
results.append(f"关联群组已覆盖为 {count} 个。")
if description.available or mode.available or rule_str is not None:
is_blacklist = None
if mode.available:
is_blacklist = mode.result == "black"
await tag_manager.update_tag_attributes(
tag_name,
description.result if description.available else None,
is_blacklist,
rule_str,
)
if rule_str is not None:
results.append(f"动态规则已更新为 '{rule_str}'。")
if description.available:
results.append("描述已更新。")
if mode.available:
results.append(
f"模式已更新为 {'黑名单' if is_blacklist else '白名单'}。"
)
if new_name.available:
await tag_manager.rename_tag(tag_name, new_name.result)
results.append(f"已重命名为 '{new_name.result}'。")
tag_name = new_name.result
except (ValueError, IntegrityError) as e:
await MessageUtils.build_message(f"操作失败: {e}").finish()
if not results:
await MessageUtils.build_message(
"未执行任何操作,请提供至少一个编辑选项。"
).finish()
final_msg = f"对标签 '{tag_name}' 的操作已完成:\n" + "\n".join(
f"- {r}" for r in results
)
await MessageUtils.build_message(final_msg).finish()
@tag_cmd.assign("delete")
async def handle_delete(names: Match[list[str]]):
success, failed = [], []
for name in names.result:
if await tag_manager.delete_tag(name):
success.append(name)
else:
failed.append(name)
msg = ""
if success:
msg += f"成功删除标签: {', '.join(success)}\n"
if failed:
msg += f"标签不存在,删除失败: {', '.join(failed)}"
await MessageUtils.build_message(msg.strip()).finish()
@tag_cmd.assign("clear")
async def handle_clear():
confirm = await prompt_until(
"【警告】此操作将删除所有群组标签,是否继续?\n请输入 `是` 或 `确定` 确认操作",
lambda msg: msg.extract_plain_text().lower()
in ["是", "确定", "yes", "confirm"],
timeout=30,
retry=1,
)
if confirm:
count = await tag_manager.clear_all_tags()
await MessageUtils.build_message(f"操作完成,已清空 {count} 个标签。").finish()
else:
await MessageUtils.build_message("操作已取消。").finish()
@tag_cmd.assign("clone")
async def handle_clone(
bot: Bot,
source_name: Match[str],
new_name: Match[str],
add_groups: Query[list[str] | None] = AlconnaQuery("clone.add.add_groups", None),
remove_groups: Query[list[str] | None] = AlconnaQuery(
"clone.remove.remove_groups", None
),
as_dynamic: Query[bool] = AlconnaQuery("clone.as-dynamic.value", False),
description: Query[str | None] = AlconnaQuery("clone.desc.description", None),
mode: Query[str | None] = AlconnaQuery("clone.mode.mode", None),
):
try:
new_tag = await tag_manager.clone_tag(
source_name=source_name.result,
new_name=new_name.result,
bot=bot,
add_groups=add_groups.result,
remove_groups=remove_groups.result,
as_dynamic=as_dynamic.result,
description=description.result,
mode=mode.result,
)
tag_type_str = "动态" if new_tag.tag_type == "DYNAMIC" else "静态"
group_count = 0
if new_tag.tag_type == "STATIC":
group_count = await new_tag.groups.all().count()
msg = f"✅ 成功克隆标签!\n- 新标签: {new_tag.name}\n- 类型: {tag_type_str}"
if new_tag.tag_type == "STATIC":
msg += f" (含 {group_count} 个群组)"
await MessageUtils.build_message(msg).finish()
except (ValueError, IntegrityError) as e:
await MessageUtils.build_message(f"克隆失败: {e}").finish()
@tag_cmd.assign("prune")
async def handle_prune():
deleted_count = await tag_manager.prune_stale_group_links()
msg = f"清理完成!共移除了 {deleted_count} 个无效的群组关联。"
await MessageUtils.build_message(msg).finish()
@@ -0,0 +1,142 @@
from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata
from nonebot.rule import to_me
from nonebot_plugin_alconna import (
Alconna,
AlconnaMatch,
Args,
Arparma,
Match,
Subcommand,
on_alconna,
)
from zhenxun.configs.config import Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.services import renderer_service
from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils
__plugin_meta__ = PluginMetadata(
name="UI管理",
description="管理UI、主题和渲染服务的相关配置",
usage="""
指令:
ui reload / 重载主题: 重新加载当前主题的配置和资源。
ui theme / 主题列表: 显示所有可用的主题,并高亮显示当前主题。
ui theme [主题名称] / 切换主题 [主题名称]: 将UI主题切换为指定主题。
""".strip(),
extra=PluginExtraData(
author="HibiKier",
version="0.1",
plugin_type=PluginType.SUPERUSER,
configs=[
RegisterConfig(
module="UI",
key="THEME",
value="default",
help="设置渲染服务使用的全局主题名称(对应 resources/themes/下的目录名)",
default_value="default",
type=str,
),
RegisterConfig(
module="UI",
key="CACHE",
value=True,
help="是否为渲染服务生成的图片启用文件缓存",
default_value=True,
type=bool,
),
RegisterConfig(
module="UI",
key="DEBUG_MODE",
value=False,
help="是否在日志中输出渲染组件的完整HTML源码,用于调试",
default_value=False,
type=bool,
),
],
).to_dict(),
)
ui_matcher = on_alconna(
Alconna(
"ui",
Subcommand("reload", help_text="重载当前主题"),
Subcommand("theme", Args["theme_name?", str], help_text="查看或切换主题"),
),
aliases={"主题管理"},
rule=to_me(),
permission=SUPERUSER,
priority=1,
block=True,
)
ui_matcher.shortcut("重载主题", command="ui reload")
ui_matcher.shortcut("主题列表", command="ui theme")
ui_matcher.shortcut("切换主题", command="ui theme", arguments=["{%0}"])
@ui_matcher.assign("reload")
async def handle_reload(arparma: Arparma):
theme_name = await renderer_service.reload_theme()
logger.info(
f"UI主题已重载为: {theme_name}", "UI管理器", session=arparma.header_result
)
await MessageUtils.build_message(f"UI主题已成功重载为 '{theme_name}'!").send(
reply_to=True
)
@ui_matcher.assign("theme")
async def handle_theme(
arparma: Arparma, theme_name_match: Match[str] = AlconnaMatch("theme_name")
):
if theme_name_match.available:
new_theme_name = theme_name_match.result
try:
await renderer_service.switch_theme(new_theme_name)
logger.info(
f"UI主题已切换为: {new_theme_name}",
"UI管理器",
session=arparma.header_result,
)
await MessageUtils.build_message(
f"🎨 主题已成功切换为 '{new_theme_name}'!"
).send(reply_to=True)
except FileNotFoundError as e:
logger.warning(
f"尝试切换到不存在的主题: {new_theme_name}",
"UI管理器",
session=arparma.header_result,
)
await MessageUtils.build_message(str(e)).send(reply_to=True)
except Exception as e:
logger.error(
f"切换主题时发生错误: {e}",
"UI管理器",
session=arparma.header_result,
e=e,
)
await MessageUtils.build_message(f"切换主题失败: {e}").send(reply_to=True)
else:
try:
available_themes = renderer_service.list_available_themes()
current_theme = Config.get_config("UI", "THEME", "default")
theme_list_str = "\n".join(
f" - {theme}{' <- 当前' if theme == current_theme else ''}"
for theme in sorted(available_themes)
)
response = f"🎨 可用主题列表:\n{theme_list_str}"
await MessageUtils.build_message(response).send(reply_to=True)
except Exception as e:
logger.error(
f"获取主题列表时发生错误: {e}",
"UI管理器",
session=arparma.header_result,
e=e,
)
await MessageUtils.build_message("获取主题列表失败。").send(reply_to=True)
+128
View File
@@ -0,0 +1,128 @@
from typing import Any
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 pydantic import BaseModel, Field
from zhenxun.configs.utils import PluginExtraData
from zhenxun.services.log import logger
from zhenxun.services.page_template import PageTemplateConfig, template_manager
from zhenxun.services.page_template.components import (
Button,
ButtonProps,
Col,
ColProps,
Form,
FormItem,
FormItemProps,
FormProps,
Row,
RowProps,
)
__plugin_meta__ = PluginMetadata(
name="web测试",
description="想要更加了解真寻吗",
usage="""
指令:
关于
""".strip(),
extra=PluginExtraData(author="HibiKier", version="0.1", menu_type="其他").to_dict(),
)
_matcher = on_alconna(Alconna("test"), priority=5, block=True, rule=to_me())
@_matcher.handle()
async def _(session: Uninfo, arparma: Arparma):
logger.info("1")
def temp(a: dict[str, Any]):
pass
class UserFormData(BaseModel):
username: str = Field(..., min_length=3, max_length=20)
email: str
age: int | None = None
def register_user_form_template():
# 使用 list[Any] 避免 list 协变导致的类型告警
layout: list[Any] = [
Row(
props=RowProps(gutter=16),
children=[
Col(
props=ColProps(span=12),
children=[
Form(
props=FormProps(label_width="100px", inline=True),
children=[
FormItem(
props=FormItemProps(
label="用户名", prop="username"
),
children=None,
bind_field="username",
),
FormItem(
props=FormItemProps(label="邮箱", prop="email"),
children=None,
bind_field="email",
),
FormItem(
props=FormItemProps(label="年龄", prop="age"),
children=None,
bind_field="age",
),
FormItem(
props=FormItemProps(label=""),
children=[
Button(
props=ButtonProps(
text="提交",
type="primary",
action="submit",
confirm=True,
confirm_text="确认提交吗?",
),
),
Button(
props=ButtonProps(
text="重置",
type="default",
action="reset", # 前端重置表单
),
),
Button(
props=ButtonProps(
text="取消",
type="danger",
action="cancel", # 前端自行关闭/返回
),
),
],
bind_field=None,
),
],
)
],
)
],
)
]
config = PageTemplateConfig(
template_id="user_form",
title="用户表单示例",
description="包含提交/重置/取消按钮的示例表单",
layout=layout,
callback_handler=temp,
)
template_manager.register(config, data_model=UserFormData)
@@ -16,7 +16,7 @@ from zhenxun.utils.platform import PlatformUtils
from ....base_model import Result
from ....config import QueryDateType
from ....utils import authentication, clear_help_image, get_system_status
from ....utils import authentication, get_system_status
from .data_source import ApiDataSource
from .model import (
ActiveGroup,
@@ -234,7 +234,6 @@ async def _(param: BotManageUpdateParam):
bot_data.block_plugins = CommonUtils.convert_module_format(param.block_plugins)
bot_data.block_tasks = CommonUtils.convert_module_format(param.block_tasks)
await bot_data.save(update_fields=["block_plugins", "block_tasks"])
clear_help_image()
return Result.ok()
except Exception as e:
logger.error(f"{router.prefix}/update_bot_manage 调用错误", "WebUi", e=e)
@@ -7,7 +7,7 @@ from zhenxun.utils.enum import BlockType, PluginType
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
from ....base_model import Result
from ....utils import authentication, clear_help_image
from ....utils import authentication
from .data_source import ApiDataSource
from .model import (
BatchUpdatePlugins,
@@ -82,7 +82,6 @@ async def _() -> Result[PluginCount]:
async def _(param: UpdatePlugin) -> Result:
try:
await ApiDataSource.update_plugin(param)
clear_help_image()
return Result.ok(info="已经帮你写好啦!")
except (ValueError, KeyError):
return Result.fail("插件数据不存在...")
@@ -110,7 +109,6 @@ async def _(param: PluginSwitch) -> Result:
db_plugin.block_type = None
db_plugin.status = True
await db_plugin.save()
clear_help_image()
return Result.ok(info="成功改变了开关状态!")
except Exception as e:
logger.error(f"{router.prefix}/change_switch 调用错误", "WebUi", e=e)
@@ -177,7 +175,6 @@ async def _(
updated_count=result_dict["updated_count"],
errors=result_dict["errors"],
)
clear_help_image()
return Result.ok(result_model, "插件配置更新完成")
except Exception as e:
logger.error(f"{router.prefix}/plugins/batch_update 调用错误", "WebUi", e=e)
@@ -197,7 +194,6 @@ async def _(payload: RenameMenuTypePayload) -> Result[str]:
old_name=payload.old_name, new_name=payload.new_name
)
if result.get("success"):
clear_help_image()
return Result.ok(
info=result.get(
"info",
+1 -17
View File
@@ -12,7 +12,7 @@ import psutil
import ujson as json
from zhenxun.configs.config import Config
from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH
from zhenxun.configs.path_config import DATA_PATH
from .base_model import SystemFolderSize, SystemStatus, User
@@ -68,22 +68,6 @@ def validate_path(path_str: str | None) -> tuple[Path | None, str | None]:
return None, f"路径验证失败: {e!s}"
GROUP_HELP_PATH = DATA_PATH / "group_help"
SIMPLE_HELP_IMAGE = IMAGE_PATH / "SIMPLE_HELP.png"
SIMPLE_DETAIL_HELP_IMAGE = IMAGE_PATH / "SIMPLE_DETAIL_HELP.png"
def clear_help_image():
"""清理帮助图片"""
if SIMPLE_HELP_IMAGE.exists():
SIMPLE_HELP_IMAGE.unlink()
if SIMPLE_DETAIL_HELP_IMAGE.exists():
SIMPLE_DETAIL_HELP_IMAGE.unlink()
for file in GROUP_HELP_PATH.iterdir():
if file.is_file():
file.unlink()
def get_user(uname: str) -> User | None:
"""获取账号密码
+4 -1
View File
@@ -15,7 +15,9 @@ DATA_PATH = Path() / "data"
# 临时数据路径
TEMP_PATH = Path() / "resources" / "temp"
# 网页模板路径
TEMPLATE_PATH = Path() / "resources" / "template"
THEMES_PATH = Path() / "resources" / "themes"
# [新增] UI渲染服务的统一缓存路径
UI_CACHE_PATH = TEMP_PATH / "ui_cache"
IMAGE_PATH.mkdir(parents=True, exist_ok=True)
@@ -25,3 +27,4 @@ LOG_PATH.mkdir(parents=True, exist_ok=True)
FONT_PATH.mkdir(parents=True, exist_ok=True)
DATA_PATH.mkdir(parents=True, exist_ok=True)
TEMP_PATH.mkdir(parents=True, exist_ok=True)
UI_CACHE_PATH.mkdir(parents=True, exist_ok=True)
+3 -1
View File
@@ -344,7 +344,9 @@ class ConfigsManager:
返回:
ConfigGroup: ConfigGroup
"""
return self._data.get(key) or ConfigGroup(module="")
if key not in self._data:
self._data[key] = ConfigGroup(module=key)
return self._data[key]
def save(self, path: str | Path | None = None, save_simple_data: bool = False):
"""保存数据
+6
View File
@@ -270,3 +270,9 @@ class PluginExtraData(BaseModel):
def to_dict(self, **kwargs):
return model_dump(self, **kwargs)
group_config_model: type[BaseModel] | None = None
"""插件的分群配置模型"""
class Config:
arbitrary_types_allowed = True
+5 -3
View File
@@ -4,6 +4,7 @@ from typing_extensions import Self
from tortoise import fields
from zhenxun.services.data_access import DataAccess
from zhenxun.services.db_context import Model
from zhenxun.services.log import logger
from zhenxun.utils.enum import CacheType, DbLockType
@@ -57,14 +58,15 @@ class BanConsole(Model):
"""
if not user_id and not group_id:
raise UserAndGroupIsNone()
dao = DataAccess(cls)
if user_id:
return (
await cls.safe_get_or_none(user_id=user_id, group_id=group_id)
await dao.safe_get_or_none(user_id=user_id, group_id=group_id)
if group_id
else await cls.safe_get_or_none(user_id=user_id, group_id__isnull=True)
else await dao.safe_get_or_none(user_id=user_id, group_id__isnull=True)
)
else:
return await cls.safe_get_or_none(user_id="", group_id=group_id)
return await dao.safe_get_or_none(user_id="", group_id=group_id)
@classmethod
async def check_ban_level(
+4 -2
View File
@@ -7,6 +7,7 @@ from tortoise.backends.base.client import BaseDBAsyncClient
from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.task_info import TaskInfo
from zhenxun.services.cache import CacheRoot
from zhenxun.services.data_access import DataAccess
from zhenxun.services.db_context import Model
from zhenxun.utils.enum import CacheType, DbLockType, PluginType
@@ -254,13 +255,14 @@ class GroupConsole(Model):
返回:
Self: GroupConsole
"""
dao = DataAccess(cls)
if channel_id:
return await cls.safe_get_or_none(
return await dao.safe_get_or_none(
group_id=group_id,
channel_id=channel_id,
clean_duplicates=clean_duplicates,
)
return await cls.safe_get_or_none(
return await dao.safe_get_or_none(
group_id=group_id,
channel_id__isnull=True,
clean_duplicates=clean_duplicates,
+29
View File
@@ -0,0 +1,29 @@
from tortoise import fields
from zhenxun.services.db_context import Model
from zhenxun.utils.enum import CacheType
class GroupPluginSetting(Model):
id = fields.IntField(pk=True, generated=True, auto_increment=True)
"""自增ID"""
group_id = fields.CharField(max_length=255, indexed=True, description="群组ID")
"""群组ID"""
plugin_name = fields.CharField(
max_length=255, indexed=True, description="插件模块名"
)
"""插件模块名"""
settings = fields.JSONField(description="插件的完整配置 (JSON)")
"""插件的完整配置 (JSON)"""
updated_at = fields.DatetimeField(auto_now=True, description="最后更新时间")
"""最后更新时间"""
cache_type = CacheType.GROUP_PLUGIN_SETTINGS
"""缓存类型"""
cache_key_field = ("group_id", "plugin_name")
"""缓存键字段"""
class Meta: # pyright: ignore [reportIncompatibleVariableOverride]
table = "group_plugin_settings"
table_description = "插件分群通用配置表"
unique_together = ("group_id", "plugin_name")
+54
View File
@@ -0,0 +1,54 @@
from tortoise import fields
from zhenxun.services.db_context import Model
class GroupTag(Model):
"""群组标签模型"""
id = fields.IntField(pk=True, generated=True, auto_increment=True)
"""自增ID"""
name = fields.CharField(max_length=255, unique=True, description="标签名称")
"""标签名称"""
description = fields.TextField(null=True, description="标签描述")
"""标签描述"""
owner_id = fields.CharField(
max_length=255, null=True, description="创建者ID, null为系统级"
)
"""创建此标签的用户ID"""
bot_id = fields.CharField(
max_length=255, null=True, description="所属Bot ID, null为全局通用"
)
"""此标签所属的Bot ID"""
tag_type = fields.CharField(
max_length=20, default="STATIC", description="标签类型 (STATIC, DYNAMIC)"
)
"""标签类型"""
dynamic_rule = fields.TextField(null=True, description="动态标签的计算规则")
"""动态标签的计算规则"""
is_blacklist = fields.BooleanField(default=False, description="是否为黑名单模式")
"""是否为黑名单模式 (True: 排除模式, False: 包含模式)"""
groups: fields.ReverseRelation["GroupTagLink"]
class Meta: # type: ignore
table = "group_tags"
table_description = "群组标签表"
class GroupTagLink(Model):
"""群组与标签的多对多关联模型"""
id = fields.IntField(pk=True, generated=True, auto_increment=True)
"""自增ID"""
tag = fields.ForeignKeyField(
"models.GroupTag", related_name="groups", on_delete=fields.CASCADE
)
"""关联的标签"""
group_id = fields.CharField(max_length=255, description="群组ID")
"""群组ID"""
class Meta: # type: ignore
table = "group_tag_links"
table_description = "群组标签关联表"
unique_together = ("tag", "group_id")

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