diff --git a/README.md b/README.md index 8d0adcf..52e357f 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,13 @@ | 插件 | 功能 | 主要命令 | | --- | --- | --- | -| `nonebot_plugin_hexi_core` | 核心库:消息解析、权限分级、限流器 (token bucket)、命令冷却、热插件管理 | — | +| `hexi/core`(核心模块) | 核心库:消息解析、权限分级、限流器 (token bucket)、命令冷却、热插件管理(**非插件**) | — | | `nonebot_plugin_video_analysis` | 视频链接解析 | — | | `nonebot_plugin_regif` | GIF 注册与处理 | — | | `nonebot_plugin_picfinder_take` | 识图(Playwright 驱动) | — | | `nonebot_plugin_picstatus` | 头像状态图生成 | — | | `nonebot_plugin_group_daily_analysis` | 群聊行为分析报告 | `/群分析` | -| `nonebot_plugin_web_hub` | 统一 Web 管理入口 | — | +| `hexi/web_hub`(核心模块) | 统一 Web 管理入口 `/hub`(**非插件**) | — | ## 快速开始 @@ -88,13 +88,15 @@ poetry run python bot.py HeXi/ ├── bot.py pyproject.toml README.md ├── hexi/ -│ ├── plugins/ # 全部插件(nonebot.load_plugins("hexi") 加载) +│ ├── plugins/ # 全部插件(pyproject [tool.nonebot] plugin_dirs 加载) +│ ├── core/ # 机器人核心模块(非插件):工具库/插件控制面/热拔插管理 +│ ├── web_hub/ # 机器人核心模块:统一 Web 管理台 + 鉴权/插件注册/配置标准 │ ├── data/ # 运行数据(不入库) │ ├── config/ # 业务配置(不入库) │ ├── resource/ # 全局静态资源(fonts/ 字体、menu_config 等) │ ├── web/ # 统一 Web 前端(React + Vite,构建产物 dist/ 供 /hub 挂载) │ ├── logs/ # 日志(不入库) -│ └── web_*.py # Web 管理相关(鉴权、插件注册) +│ └── web_hub/ # 见上:统一 Web 管理台/鉴权/插件注册/配置标准 ├── docs/ # 插件开发标准文档 ├── tests/ # pytest 测试 └── plugin_template/ # 新插件脚手架模板 diff --git a/bot.py b/bot.py index 1faf8fc..2bbf6af 100644 --- a/bot.py +++ b/bot.py @@ -55,10 +55,14 @@ driver = nonebot.get_driver() # 注册适配器 driver.register_adapter(ONEBOTV11Adapter) -# 加载插件 -nonebot.load_plugins("hexi") +# 加载插件(hexi/plugins 下的自定义插件 + pyproject 声明的社区插件) nonebot.load_from_toml("pyproject.toml") +# 核心模块(已从 hexi/plugins 移出,不再是插件):在插件管理器建立后再导入, +# 避免在插件管理器创建前 require 社区插件导致重复注册/“not loaded as a plugin”。 +import hexi.core # noqa: F401,E402 +import hexi.web_hub # noqa: F401,E402 + if __name__ == "__main__": logger.warning("hexi?启动!") nonebot.run() # 直接运行,不需要传参 \ No newline at end of file diff --git a/dev/docs/plugin-audit-report.md b/dev/docs/plugin-audit-report.md index d7653e2..8a4f006 100644 --- a/dev/docs/plugin-audit-report.md +++ b/dev/docs/plugin-audit-report.md @@ -3,6 +3,8 @@ > 依据 《插件结构标准》(docs/plugin-structure-standard.md) 与 《插件配置文件标准》(docs/plugin-config-standard.md), > 并参照脚手架模板 `../plugin_template/nonebot_plugin_template`。 > 说明:初版为**审计报告**;后续按用户确认已开始落地改造(见下方「本轮已落地改动」)。 +> +> **后续变动**:其中 `hexi_core` 与 `web_hub` 已从 `hexi/plugins/` 移出,改为机器人核心模块 `hexi/core`、`hexi/web_hub`(非插件),本报告中这两项的“插件”表述仅供参考。 --- diff --git a/dev/docs/plugin-config-standard.md b/dev/docs/plugin-config-standard.md index 3448c5e..cfbc9c1 100644 --- a/dev/docs/plugin-config-standard.md +++ b/dev/docs/plugin-config-standard.md @@ -20,8 +20,12 @@ ```text hexi/ -├── web_config.py # 配置标准核心:schema 注册 / 值库 / 保存热刷新 -├── config_standard.py # pydantic Config 一键接入的辅助封装 +├── web_hub/ # 统一 Web 管理台 / 鉴权 / 配置标准 +│ ├── web_config.py # 配置标准核心:schema 注册 / 值库 / 保存热刷新 +│ ├── config_standard.py # pydantic Config 一键接入的辅助封装 +│ ├── web_auth.py # 统一 Web 鉴权(OAuth2 + SQLite) +│ ├── web_plugin_registry.py # 统一 Web 插件注册中心 +│ └── web_hub_auth.py # 兼容 shim ├── config/ │ └── plugin_config.json # 统一值库:{ "": { key: value, ... } } └── web/ # /hub 前端(通用 schema 表单渲染器) @@ -143,7 +147,7 @@ hexi/ ```python # 在插件 __init__.py 里 -from hexi.config_standard import register_model_config +from hexi.web_hub.config_standard import register_model_config from .config import config register_model_config( @@ -168,7 +172,7 @@ register_model_config( ### 方式 B:非 pydantic 插件手动注册 ```python -from hexi.web_config import register_plugin_config +from hexi.web_hub.web_config import register_plugin_config def _get(): # 返回当前生效值 dict return {"field": get_my_cur_value("field")} @@ -209,7 +213,7 @@ register_plugin_config( 2. **写值库** `plugin_config.json`(原子写)。 3. **写 .env**:非 list 字段写入 `os.environ` + `../../.env`(保证重启仍生效);list/path/object 跳过,避免 `str(list)` 破坏重启解析。 4. **调 `apply(values)`**:把值热应用到插件运行态对象(list/path/object 也在此生效)。 -5. 若插件未提供 apply,则 `hot_reload(plugin_id)` 让插件重载。 +5. 若插件未提供 apply,则 `hot_reload(plugin_id)` 让插件重载;**library/非 application 插件不受热重载**(见《插件类型分类》,热拔插会破坏依赖它的插件)。 **为什么 apply 与 env 都做**:NoneBot 的 `get_driver().config` 在启动时即固定,重载插件也读不到新 env;所以运行期必须 apply,重启靠 env。 @@ -220,7 +224,7 @@ register_plugin_config( | 方法 | 路径 | 说明 | |---|---|---| | GET | `/api/plugins` | 所有已注册 Web 插件 | -| GET | `/api/plugins/catalog` | 所有应用插件 + `has_config/has_web/web_path` | +| GET | `/api/plugins/catalog` | 所有 application/library 插件(含带配置项的 library)+ `type/has_config/has_web/web_path` | | GET | `/api/plugins/{id}/config` | 读 `{schema, values, revision}`(登录);secret 值只返回 `****` | | POST | `/api/plugins/{id}/config` | 保存 `{ "revision": n, "values": { key: value } }`;冲突 409,校验失败 422 | | DELETE | `/api/plugins/{id}/config` | 清空该插件覆盖,恢复默认(可选) | @@ -242,7 +246,7 @@ register_plugin_config( ## 9. 权限与安全 -- 全部配置接口走 `hexi.web_auth.require_admin`(OAuth2 + SQLite)。 +- 全部配置接口走 `hexi.web_hub.web_auth.require_admin`(OAuth2 + SQLite)。 - 配置 POST 必须携带 GET 返回的 `revision`;缺失返回 428,冲突返回 409。 - 只允许 `plugin_id` 存在于注册表,未注册返回 `ok:false`(防任意写入)。 - 部署时必须显式设置 Web 管理员凭据;禁止生产环境使用默认的 `admin/admin`。 @@ -277,7 +281,7 @@ register_plugin_config( ```python # hexi/plugins/nonebot_plugin_helldivers_tools/__init__.py -from hexi.config_standard import register_model_config +from hexi.web_hub.config_standard import register_model_config from .config import config as _hd2_config register_model_config( @@ -295,7 +299,7 @@ register_model_config( ## 12. 已接入与待接入 - **已接入**:`helldivers_tools`、`mc_server_status`、`video_analysis`、`steam_info`、`picstatus`、`galgame_card`。 -- **未接入(建议后续)**:`learning_chat`、`group_daily_analysis`(已有独自 Web 配置,避免冲突)、`picfinder_take`、`bf_bot`、`group_tools`、`hexi_core`(模块级常量/SUPERUSERS,运行期热更复杂)。 +- **未接入(建议后续)**:`learning_chat`、`group_daily_analysis`(已有独自 Web 配置,避免冲突)、`picfinder_take`、`bf_bot`、`group_tools`、`hexi/core`(模块级常量/SUPERUSERS,运行期热更复杂)。 --- @@ -316,7 +320,7 @@ register_model_config( ### 13.2 通用注册 API(任意来源) ```python -from hexi.config_standard import register_config_items +from hexi.web_hub.config_standard import register_config_items register_config_items( __name__, # = NoneBot 插件模块名 @@ -339,7 +343,7 @@ register_config_items( Web 保存后 apply 会把新值写回来源(模块属性/dict/自定义),但若插件在别处是用 `from .config import X` **值拷贝**进来的量,不受影响。要真正运行期生效,插件在读配置处改用统一 API: ```python -from hexi.web_config import get_effective_value +from hexi.web_hub.web_config import get_effective_value limit = get_effective_value("hexi.plugins.nonebot_plugin_picfinder_take", "DAILY_LIMIT", 50) if not check_quota(limit): ... @@ -395,7 +399,7 @@ if not check_quota(limit): ... ### 14.3 一键注册 API ```python -from hexi.config_standard import register_object_set +from hexi.web_hub.config_standard import register_object_set register_object_set( __name__, # NoneBot 插件模块名 diff --git a/dev/docs/plugin-structure-standard.md b/dev/docs/plugin-structure-standard.md index 0c7ce3b..508040c 100644 --- a/dev/docs/plugin-structure-standard.md +++ b/dev/docs/plugin-structure-standard.md @@ -84,6 +84,7 @@ nonebot_plugin_xxx/ ## 4. `__init__.py` 标准职责(固定顺序) 1. `__plugin_meta__ = PluginMetadata(name=..., description=..., usage=..., type="application")`。 + > **type 取值(NoneBot 发布规范)**:`application`(向机器人用户提供功能,支持热插拔/热重载)或 `library`(为其他插件提供能力,不可热拔插)。新插件默认 `application`。本仓库的 `hexi/core`、`hexi/web_hub` 已从插件目录移出,作为机器人核心模块(**非插件**),直接不参与热拔插。 2. 声明依赖 `require("nonebot_plugin_alconna")` 等。 3. 显式导入子模块(`from . import handlers, services, utils`),注册 matcher/handler。 4. **配置注册**(在 config 就绪后):`register_model_config`(pydantic Config)或 `register_config_items` / `register_object_set`(来源无关)。 @@ -108,7 +109,7 @@ config = get_plugin_config(Config) ### 4.2 来源无关型(模块常量 / 配置文件 / DB) ```python -from hexi.config_standard import register_config_items, register_object_set +from hexi.web_hub.config_standard import register_config_items, register_object_set register_object_set(__name__, "groups", [ {item_schema...} ], key_field="group_id", getter=get_groups, setter=set_groups, nosave=True) ``` @@ -133,20 +134,20 @@ register_object_set(__name__, "groups", [ {item_schema...} ], key_field="group_i - `services/`:业务编排,可依赖 repository / 外部 API;把「一个业务动作」收敛到一个方法。 - **禁止** `from x import *`、`except: pass`、`print()`;用 `logger`。 - async handler 内避免同步阻塞(`requests` / `time.sleep` / 同步爬虫);需要就 `asyncio.to_thread`。 -- 命令触发:需要 @ 用 `rule=to_me()`;全局命令注意与其它插件冲突;高开销命令加冷却/限频(`hexi_core` 的 `cooldown/rate_limit`)。 +- 命令触发:需要 @ 用 `rule=to_me()`;全局命令注意与其它插件冲突;高开销命令加冷却/限频(`hexi/core` 的 `cooldown/rate_limit`)。 - 外部 API:统一超时 + 重试 + 失败降级/用户提示。 ## 8. Web 接入标准 - 需要独立 Web 页:`register_web_plugin(id, name, icon, lambda: build_app(), module_name=__name__)`,hub 启动自动挂载 `/api/`。 - 需要 Web 配置:`register_plugin_config` / `register_model_config` / `register_config_items` / `register_object_set`(schema 驱动表单)。 -- 鉴权统一用 `hexi.web_auth.require_admin`(OAuth2 + SQLite),不要自造一套。 +- 鉴权统一用 `hexi.web_hub.web_auth.require_admin`(OAuth2 + SQLite),不要自造一套。 - 敏感字段 `secret=True`,前端掩码;写回允许明文。 ## 9. 插件迁移检查清单 - [ ] 包名改为 `nonebot_plugin_*`,位置在 `../../hexi/plugins`。 -- [ ] 有 `__plugin_meta__`,`type="application"`。 +- [ ] 有 `__plugin_meta__`,`type="application"`(核心/框架插件标 `library`,不参与热拔插)。 - [ ] 配置已接入统一标准(Web 能读能改,运行期/重启生效)。 - [ ] 命令在 `handlers/`,业务在 `services/`,工具在 `utils/`。 - [ ] 数据访问集中(repository / services),原子写 / 事务。 diff --git a/dev/docs/plugin-type-classification.md b/dev/docs/plugin-type-classification.md new file mode 100644 index 0000000..aecdc59 --- /dev/null +++ b/dev/docs/plugin-type-classification.md @@ -0,0 +1,75 @@ +# HeXi 插件类型分类(library / application) + +> 依据 NoneBot 发布规范:`type` 是插件类别,发布必填。当前有效类别: +> - `library`:为其他插件编写提供功能(核心库/框架),**不参与热拔插/热重载**。 +> - `application`:向机器人用户提供功能,**可热拔插/热重载**。 +> +> 本仓库自定义插件统一在 `hexi/plugins/`(经 `nonebot.load_plugins("hexi")` 加载); +> pip 社区插件由其自带元数据决定类别,本仓库不改动,但热拔插管理器会按类别保护。 +> +> **变动**:原 `nonebot_plugin_hexi_core` / `nonebot_plugin_web_hub` 已从 `hexi/plugins/` 移出,现为机器人核心模块 `hexi/core`、`hexi/web_hub`(**非插件**),不再参与插件类型分类与热拔插,故下表不列出。 + +--- + +## 自定义插件(`hexi/plugins/`) + +| 插件(模块名) | type | 说明 | +|---|---|---| +| `hexi.plugins.nonebot_plugin_bf_bot` | `application` | 战地系列战绩查询(BF3/4/1/5/2042/6)。 | +| `hexi.plugins.nonebot_plugin_steam_info` | `application` | Steam 信息播报/查询。 | +| `hexi.plugins.nonebot_plugin_mc_server_status` | `application` | Minecraft 服务器状态查询。 | +| `hexi.plugins.nonebot_plugin_ncm_saying` | `application` | 网易云热评。 | +| `hexi.plugins.nonebot_plugin_helldivers_tools` | `application` | 绝地潜兵 2 前线战况/战备。 | +| `hexi.plugins.nonebot_plugin_learning_chat` | `application` | 马尔可夫链群聊学习/复读。 | +| `hexi.plugins.nonebot_plugin_galgame_card` | `application` | 群聊人设卡生成/展示。 | +| `hexi.plugins.nonebot_plugin_group_tools` | `application` | 群管理工具集。 | +| `hexi.plugins.nonebot_plugin_group_daily_analysis` | `application` | 群聊行为分析报告。 | +| `hexi.plugins.nonebot_plugin_dailywife` | `application` | 每日随机抽取群友。 | +| `hexi.plugins.nonebot_plugin_deer_pipe` | `application` | 每日打卡(鹿)。 | +| `hexi.plugins.nonebot_plugin_dice` | `application` | 掷骰子/结婚判定。 | +| `hexi.plugins.nonebot_plugin_makeaquote` | `application` | 名人/语录图生成。 | +| `hexi.plugins.nonebot_plugin_random_jm_code` | `application` | 随机 JM 码。 | +| `hexi.plugins.nonebot_plugin_video_analysis` | `application` | 视频链接解析。 | +| `hexi.plugins.nonebot_plugin_regif` | `application` | GIF 倒放/处理。 | +| `hexi.plugins.nonebot_plugin_picfinder_take` | `application` | 二次元搜图。 | +| `hexi.plugins.nonebot_plugin_picstatus` | `application` | 设备状态图。 | +| `hexi.plugins.nonebot_plugin_huoziyinshua` | `application` | otto 活字印刷(语音合成)。 | +| `hexi.plugins.memes_ops` | `application` | 给 `nonebot_plugin_memes` 追加裸词选项(用户侧语法)。 | +| `hexi.plugins.nonebot_plugin_deadlock` | - | **停用**:整文件被注释,不注册任何功能(历史遗留)。 | +| `hexi.plugins.nonebot_plugin_brash_general_supercredits_tools` | - | **空壳**:未完成占位,`__init__.py` 仅注释,无功能。 | + +--- + +## pip 社区插件(`pyproject.toml` 声明) + +这些插件由安装包自带的 `__plugin_meta__` 决定类别,本仓库不改动;其中的 library 型插件同样受热拔插保护。 + +| 插件 | 常见类别 | 说明 | +|---|---|---| +| `nonebot_plugin_apscheduler` | library | 定时任务框架。 | +| `nonebot_plugin_alconna` | library | 命令解析/消息框架。 | +| `nonebot_plugin_saa` | library | 跨平台发送辅助。 | +| `nonebot_plugin_session` | library | 会话/状态管理。 | +| `nonebot_plugin_userinfo` | library | 用户信息接口。 | +| `nonebot_plugin_user` | application | 用户数据接口(含绑定/查看命令)。 | +| `nonebot_plugin_datastore` | library | 数据存储底座。 | +| `nonebot_plugin_orm` | library | ORM 底座。 | +| `nonebot_plugin_fix_qq_img_ssl` | library | 图片 SSL 修复补丁。 | +| `nonebot_plugin_wordcloud` | application | 词云生成。 | +| `nonebot_plugin_memes` | application | 表情包。 | +| `nonebot_plugin_multincm` | application | 多源网抑云点歌。 | +| `nonebot_plugin_random_stereotypes` | application | 随机刻板印象。 | +| `nonebot_plugin_rollpig` | application | 滚猪。 | + +> 说明:社区插件类别以其安装包 `PluginMetadata.type` 为准,若与本表不符请以包内元数据为准。 + +--- + +## 热拔插保护 + +`../../hexi/core/plugin_manager.py` 中的 `hot_load / hot_unload / hot_reload` 会先判断目标插件类别: + +- 目标为 `application` → 正常热拔插。 +- 其余(`library` / 未声明类别,含根包无 meta 但被依赖的核心/框架,如 alconna)→ **拒绝**并记录 warning(热拔插会使依赖它的插件运行混乱)。 + +`hexi/web_hub/web_config.py` 保存配置触发的 `hot_reload` 同样受此保护:非 application 插件若未提供 `apply` 回调,将不会执行热重载。 diff --git a/dev/plugin_template/nonebot_plugin_template/README.md b/dev/plugin_template/nonebot_plugin_template/README.md index c2dd7de..3b58f78 100644 --- a/dev/plugin_template/nonebot_plugin_template/README.md +++ b/dev/plugin_template/nonebot_plugin_template/README.md @@ -31,8 +31,8 @@ nonebot_plugin_template/ ## Web 子应用 - `web/admin.py::build_admin_app()` 返回一个 FastAPI 实例,经 `register_web_plugin` 由 hub 挂载到 `/api/template`。 -- 鉴权统一 `hexi.web_auth.require_admin`;示例端点:`GET /ping`、`GET /config`(读插件配置)。 -- 想要更丰富的管理页:前端放 `web/dist/`,在 admin.py 里挂 `StaticFiles` + SPA 兜底即可(参考 `nonebot_plugin_web_hub`)。 +- 鉴权统一 `hexi.web_hub.web_auth.require_admin`;示例端点:`GET /ping`、`GET /config`(读插件配置)。 +- 想要更丰富的管理页:前端放 `web/dist/`,在 admin.py 里挂 `StaticFiles` + SPA 兜底即可(参考 `hexi/web_hub`)。 ## 约定 - `plugin_id` = 模块名(自动取 `__package__`;web 子应用用 `__package__.rsplit(".", 1)[0]`)。 diff --git a/dev/plugin_template/nonebot_plugin_template/__init__.py b/dev/plugin_template/nonebot_plugin_template/__init__.py index fffbf92..2065c36 100644 --- a/dev/plugin_template/nonebot_plugin_template/__init__.py +++ b/dev/plugin_template/nonebot_plugin_template/__init__.py @@ -24,7 +24,7 @@ from . import config as _config # noqa: E402,F401 from . import handlers as _handlers # noqa: E402,F401 from . import web as _web # noqa: E402,F401 -from hexi.web_plugin_registry import register_web_plugin # noqa: E402 +from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402 _config.register_config() diff --git a/dev/plugin_template/nonebot_plugin_template/config.py b/dev/plugin_template/nonebot_plugin_template/config.py index 4aa8492..c52c4ba 100644 --- a/dev/plugin_template/nonebot_plugin_template/config.py +++ b/dev/plugin_template/nonebot_plugin_template/config.py @@ -11,7 +11,7 @@ from __future__ import annotations from nonebot import get_plugin_config from pydantic import BaseModel -from hexi.config_standard import register_config_items, register_model_config, register_object_set +from hexi.web_hub.config_standard import register_config_items, register_model_config, register_object_set # plugin_id = NoneBot 插件模块名 _PLUGIN_ID = __package__ diff --git a/dev/plugin_template/nonebot_plugin_template/handlers/entry.py b/dev/plugin_template/nonebot_plugin_template/handlers/entry.py index 2e4b243..80cb495 100644 --- a/dev/plugin_template/nonebot_plugin_template/handlers/entry.py +++ b/dev/plugin_template/nonebot_plugin_template/handlers/entry.py @@ -8,7 +8,7 @@ from nonebot.rule import to_me from nonebot.params import CommandArg from nonebot_plugin_alconna import UniMessage -from hexi.web_config import get_effective_value +from hexi.web_hub.web_config import get_effective_value from ..services.main import do_something diff --git a/dev/plugin_template/nonebot_plugin_template/services/main.py b/dev/plugin_template/nonebot_plugin_template/services/main.py index 0219dff..610b649 100644 --- a/dev/plugin_template/nonebot_plugin_template/services/main.py +++ b/dev/plugin_template/nonebot_plugin_template/services/main.py @@ -2,7 +2,7 @@ from __future__ import annotations -from hexi.web_config import get_effective_value +from hexi.web_hub.web_config import get_effective_value from ..repository import get_record, save_record diff --git a/dev/plugin_template/nonebot_plugin_template/web/admin.py b/dev/plugin_template/nonebot_plugin_template/web/admin.py index abeb607..413833c 100644 --- a/dev/plugin_template/nonebot_plugin_template/web/admin.py +++ b/dev/plugin_template/nonebot_plugin_template/web/admin.py @@ -2,9 +2,9 @@ - 由 `__init__.py` 里 `register_web_plugin("template", ..., module_name=__name__)` 注册, hub 启动时自动挂载到 `/api/template`。 -- 鉴权统一走 `hexi.web_auth.require_admin`(OAuth2 + SQLite);注意它本身是 FastAPI 的 +- 鉴权统一走 `hexi.web_hub.web_auth.require_admin`(OAuth2 + SQLite);注意它本身是 FastAPI 的 `Depends` 实例,直接用 `_: dict = API` 注入,不要再包一层 `Depends(...)`。 -- 可直接读写 `hexi.web_config` 的插件配置。 +- 可直接读写 `hexi.web_hub.web_config` 的插件配置。 """ from __future__ import annotations @@ -13,7 +13,7 @@ from fastapi import FastAPI from fastapi.responses import HTMLResponse from hexi import web_config -from hexi.web_auth import require_admin as API +from hexi.web_hub.web_auth import require_admin as API # 插件模块名(去掉 .web):= NoneBot 模块名 = plugin_id _PLUGIN_ID = __package__.rsplit(".", 1)[0] diff --git a/hexi/__init__.py b/hexi/__init__.py index e69de29..63fa758 100644 --- a/hexi/__init__.py +++ b/hexi/__init__.py @@ -0,0 +1 @@ +# 机器人核心目录 \ No newline at end of file diff --git a/hexi/plugins/nonebot_plugin_hexi_core/__init__.py b/hexi/core/__init__.py similarity index 50% rename from hexi/plugins/nonebot_plugin_hexi_core/__init__.py rename to hexi/core/__init__.py index 78ac2eb..d4bf708 100644 --- a/hexi/plugins/nonebot_plugin_hexi_core/__init__.py +++ b/hexi/core/__init__.py @@ -1,14 +1,6 @@ from nonebot import get_driver, logger -from nonebot.plugin import PluginMetadata -__plugin_meta__ = PluginMetadata( - name="HeXi 核心工具", - description="HeXi 公共工具库:权限分级、消息解析、限频器、命令冷却、插件热插拔等", - usage="工具库 + 帮助/插件管理命令(帮助、插件列表、加载/卸载/重载插件)", - type="application", -) - -# 显式导入子模块注册 matcher(load_plugins 只加载到插件包层,不会自动导入包内模块) +# 显式导入子模块注册 matcher/工具(hexi/core 为机器人核心模块,非 NoneBot 插件) from . import ( # noqa: E402,F401 cooldown, custom_utils, diff --git a/hexi/plugins/nonebot_plugin_hexi_core/cooldown.py b/hexi/core/cooldown.py similarity index 95% rename from hexi/plugins/nonebot_plugin_hexi_core/cooldown.py rename to hexi/core/cooldown.py index 8d74a98..32e3c0b 100644 --- a/hexi/plugins/nonebot_plugin_hexi_core/cooldown.py +++ b/hexi/core/cooldown.py @@ -7,7 +7,7 @@ - "global":所有人共享一个 CD 用法一(装饰器,包在 on_command 外面): - from hexi.plugins.nonebot_plugin_hexi_core.cooldown import cooldown + from hexi.core.cooldown import cooldown # 单用户 10s CD,群聊/私聊共用 bf_cmd = cooldown(10, hint="查询太频繁了,{secs} 秒后再试")(on_command("bf3")) @@ -19,7 +19,7 @@ ... 用法二(手动查询/触发,自定义提示文本): - from hexi.plugins.nonebot_plugin_hexi_core.cooldown import Cooldown + from hexi.core.cooldown import Cooldown cd = Cooldown(10) if cd.in_cd(ev.get_user_id()): @@ -36,7 +36,7 @@ from nonebot.adapters import Event from nonebot.dependencies import Dependent from nonebot.matcher import Matcher -from hexi.plugins.nonebot_plugin_hexi_core.rate_limit import RateLimiter, get_limiter +from hexi.core.rate_limit import RateLimiter, get_limiter # 无 name 时的实例序号,保证同一进程内各实例的注册表 key 互不冲突 _cd_seq = itertools.count(1) diff --git a/hexi/plugins/nonebot_plugin_hexi_core/custom_utils.py b/hexi/core/custom_utils.py similarity index 100% rename from hexi/plugins/nonebot_plugin_hexi_core/custom_utils.py rename to hexi/core/custom_utils.py diff --git a/hexi/plugins/nonebot_plugin_hexi_core/docs/NoneBot2_能力清单.md b/hexi/core/docs/NoneBot2_能力清单.md similarity index 100% rename from hexi/plugins/nonebot_plugin_hexi_core/docs/NoneBot2_能力清单.md rename to hexi/core/docs/NoneBot2_能力清单.md diff --git a/hexi/plugins/nonebot_plugin_hexi_core/docs/清单.md b/hexi/core/docs/清单.md similarity index 99% rename from hexi/plugins/nonebot_plugin_hexi_core/docs/清单.md rename to hexi/core/docs/清单.md index ffb8405..45602cc 100644 --- a/hexi/plugins/nonebot_plugin_hexi_core/docs/清单.md +++ b/hexi/core/docs/清单.md @@ -134,7 +134,7 @@ if hexi.is_superuser(event): # 超级用户 # 架构设计 ``` -hexi/plugins/nonebot_plugin_hexi_core/ +hexi/core/ ├── __init__.py # 插件入口,导出 hexi 对象 ├── poke.py # 戳一戳 ├── forward.py # 合并转发消息构建器 diff --git a/hexi/plugins/nonebot_plugin_hexi_core/message_handle.py b/hexi/core/message_handle.py similarity index 100% rename from hexi/plugins/nonebot_plugin_hexi_core/message_handle.py rename to hexi/core/message_handle.py diff --git a/hexi/plugins/nonebot_plugin_hexi_core/message_utils.py b/hexi/core/message_utils.py similarity index 100% rename from hexi/plugins/nonebot_plugin_hexi_core/message_utils.py rename to hexi/core/message_utils.py diff --git a/hexi/plugins/nonebot_plugin_hexi_core/plugin_control.py b/hexi/core/plugin_control.py similarity index 98% rename from hexi/plugins/nonebot_plugin_hexi_core/plugin_control.py rename to hexi/core/plugin_control.py index 7d399a4..1b01c87 100644 --- a/hexi/plugins/nonebot_plugin_hexi_core/plugin_control.py +++ b/hexi/core/plugin_control.py @@ -27,7 +27,7 @@ from nonebot import get_loaded_plugins, logger from nonebot.internal.matcher import matchers as matchers_registry from nonebot.rule import Rule -_PLUGIN_ROOT = Path(__file__).resolve().parents[2] # hexi/ +_PLUGIN_ROOT = Path(__file__).resolve().parents[1] # hexi/ DATA_DIR = _PLUGIN_ROOT / "data" STORE_PATH = DATA_DIR / "plugin_control.json" @@ -153,7 +153,7 @@ def remove_plugin(plugin_id: str) -> None: def list_plugins() -> list[dict[str, Any]]: """枚举所有 application 插件(或注册了配置 schema 的插件)及其控制面状态。""" from nonebot.plugin import get_loaded_plugins - from hexi.web_config import has_schema + from hexi.web_hub.web_config import has_schema result: list[dict[str, Any]] = [] seen: set[str] = set() @@ -174,6 +174,7 @@ def list_plugins() -> list[dict[str, Any]]: "name": meta.name, "description": meta.description or "", "usage": meta.usage or "", + "type": meta.type, "enabled": ctl["global"]["enabled"], "chat": ctl["global"]["chat"], "groups": {gid: lvl for gid, lvl in ctl["groups"].items()}, diff --git a/hexi/plugins/nonebot_plugin_hexi_core/plugin_manager.py b/hexi/core/plugin_manager.py similarity index 85% rename from hexi/plugins/nonebot_plugin_hexi_core/plugin_manager.py rename to hexi/core/plugin_manager.py index 52e4ac3..fe08660 100644 --- a/hexi/plugins/nonebot_plugin_hexi_core/plugin_manager.py +++ b/hexi/core/plugin_manager.py @@ -16,7 +16,6 @@ from nonebot.params import CommandArg from nonebot.permission import SUPERUSER from nonebot.plugin import ( Plugin, - PluginMetadata, _plugins, get_loaded_plugins, get_plugin_by_module_name, @@ -26,16 +25,10 @@ from nonebot.rule import Rule, to_me require("nonebot_plugin_alconna") from nonebot_plugin_alconna import UniMessage -from hexi.plugins.nonebot_plugin_hexi_core.message_utils import send_forward_msg +from hexi.core.message_utils import send_forward_msg from . import plugin_control -__plugin_meta__ = PluginMetadata( - name="插件管理", - description="热插拔插件:运行时加载/卸载/重载插件(仅超管)", - usage="插件列表 / 加载插件 <模块名> / 卸载插件 <模块名> / 重载插件 <模块名>", - type="application", -) - +# 插件热拔插管理(hexi.core 核心模块,非 NoneBot 插件) def _forward_sender_name() -> str: """合并转发节点的发送者昵称(用机器人配置昵称,插件名写进 content)""" nickname = get_driver().config.nickname @@ -94,8 +87,33 @@ def _unregister_plugin(plugin: Plugin) -> None: parent.sub_plugins.discard(plugin) +def _is_library(name: str) -> bool: + """判断模块是否归属「非 application」插件(library / 未声明类别)。 + + 只有 application 插件可热拔插;library(含根包未声明类别但被依赖的核心/框架) + 都应保护,热拔插会破坏依赖它的插件运行态。 + + 优先级:NoneBot 插件注册表 > sys.modules 回溯(含父包 __plugin_meta__)。 + """ + plugin = get_plugin_by_module_name(name) + if plugin is not None: + meta = plugin.metadata + return not (meta and meta.type == "application") + parts = name.split(".") + for i in range(len(parts), 0, -1): + module = sys.modules.get(".".join(parts[:i])) + if module is not None: + meta = getattr(module, "__plugin_meta__", None) + if meta is not None: + return meta.type != "application" + return False + + def hot_load(name: str) -> bool: """热加载插件模块(直接 import,matcher 随之注册)""" + if _is_library(name): + logger.warning(f"插件 {name} 是非 application(library/未声明)插件,不支持热加载") + return False if name in _hot_plugins: return False if any(p.module_name == name for p in get_loaded_plugins()): @@ -115,7 +133,14 @@ def hot_unload(name: str) -> bool: 不限于热加载的插件:启动时加载的插件(hexi/plugins 与 pyproject.toml 声明)同样支持卸载,以 NoneBot 插件注册表为准查找。 + + 安全限制:非 application 插件(library/未声明类别,常为核心库/框架)被其他 + 插件依赖,热卸载会让依赖方引用悬空并导致运行混乱,故拒绝卸载。 """ + if _is_library(name): + logger.warning(f"插件 {name} 是非 application(library/未声明)插件,热卸载会使依赖它的插件运行混乱,已拒绝") + return False + plugin = get_plugin_by_module_name(name) if plugin is None and name not in _hot_plugins: return False @@ -148,6 +173,9 @@ def hot_unload(name: str) -> bool: def hot_reload(name: str) -> bool: """热重载插件:先卸载再加载""" + if _is_library(name): + logger.warning(f"插件 {name} 是非 application(library/未声明)插件,热重载会使依赖它的插件运行混乱,已拒绝") + return False hot_unload(name) return hot_load(name) diff --git a/hexi/plugins/nonebot_plugin_hexi_core/rate_limit.py b/hexi/core/rate_limit.py similarity index 98% rename from hexi/plugins/nonebot_plugin_hexi_core/rate_limit.py rename to hexi/core/rate_limit.py index 509da71..4c19bfa 100644 --- a/hexi/plugins/nonebot_plugin_hexi_core/rate_limit.py +++ b/hexi/core/rate_limit.py @@ -3,7 +3,7 @@ 纯逻辑实现,不依赖 NoneBot 运行时;同步(requests)与异步(httpx/aiohttp)均可用。 用法: - from hexi.plugins.nonebot_plugin_hexi_core import rate_limit + from hexi.core import rate_limit # 异步请求前取令牌(无令牌时等待,最多等 timeout 秒,超时返回 False) if not await rate_limit.acquire("steam", rate=1, capacity=2): diff --git a/hexi/plugins/__init__.py b/hexi/plugins/__init__.py index e69de29..988c9a5 100644 --- a/hexi/plugins/__init__.py +++ b/hexi/plugins/__init__.py @@ -0,0 +1 @@ +# 机器人插件目录 \ No newline at end of file diff --git a/hexi/plugins/nonebot_plugin_bf_bot/__init__.py b/hexi/plugins/nonebot_plugin_bf_bot/__init__.py index be67d69..7e0ded5 100644 --- a/hexi/plugins/nonebot_plugin_bf_bot/__init__.py +++ b/hexi/plugins/nonebot_plugin_bf_bot/__init__.py @@ -29,6 +29,7 @@ __plugin_meta__ = PluginMetadata( 解绑: /解绑 (解除你的QQ与当前绑定的EAID) 修改绑定: /修改绑定 EAID (修改你的QQ与当前绑定的EAID) """.strip(), + type="application", extra={ }, diff --git a/hexi/plugins/nonebot_plugin_galgame_card/CLAUDE.md b/hexi/plugins/nonebot_plugin_galgame_card/CLAUDE.md index ab98bc9..f657e6d 100644 --- a/hexi/plugins/nonebot_plugin_galgame_card/CLAUDE.md +++ b/hexi/plugins/nonebot_plugin_galgame_card/CLAUDE.md @@ -19,12 +19,12 @@ nonebot_plugin_galgame_card/ ├── repository.py 数据层:仓储(来源无关,所有读写唯一入口) ├── processor.py 治理层:纯函数(五维提取/噪声/脱敏),可单测 ├── config.py 插件配置(仅图片识别开关等;Web 鉴权统一走 /hub) -├── web_hub.py Web API 子应用(挂载到 /api/galgame_card,auth=hexi.web_auth) +├── web_hub.py Web API 子应用(挂载到 /api/galgame_card,auth=hexi.web_hub.web_auth) ├── DESIGN.md 设计定稿(数据模型/流水线/九段协议/证据纪律) └── CLAUDE.md 本文件 ``` -**Web 管理后台**:已嵌入统一管理台 `/hub/`,前端由 `hexi/web` 渲染,API 挂载到 `/api/galgame_card`,鉴权与 /hub 共用 `hexi.web_auth`(OAuth2 + SQLite)。功能:群开关、参与者增删、语料/印象/画像浏览与删除、清空群数据。改后端需重启 bot。 +**Web 管理后台**:已嵌入统一管理台 `/hub/`,前端由 `hexi/web` 渲染,API 挂载到 `/api/galgame_card`,鉴权与 /hub 共用 `hexi.web_hub.web_auth`(OAuth2 + SQLite)。功能:群开关、参与者增删、语料/印象/画像浏览与删除、清空群数据。改后端需重启 bot。 ## 核心设计(速览,细节见 DESIGN.md) diff --git a/hexi/plugins/nonebot_plugin_galgame_card/__init__.py b/hexi/plugins/nonebot_plugin_galgame_card/__init__.py index 8c2cd4c..e40db96 100644 --- a/hexi/plugins/nonebot_plugin_galgame_card/__init__.py +++ b/hexi/plugins/nonebot_plugin_galgame_card/__init__.py @@ -30,8 +30,8 @@ from . import models # noqa: F401 # 确保表结构注册 from . import repository, processor from . import handlers as _handlers # noqa: E402 -from hexi.web_config import register_plugin_config # noqa: E402 -from hexi.web_plugin_registry import register_web_plugin # noqa: E402 +from hexi.web_hub.web_config import register_plugin_config # noqa: E402 +from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402 from . import web_hub # noqa: E402 diff --git a/hexi/plugins/nonebot_plugin_galgame_card/web_hub.py b/hexi/plugins/nonebot_plugin_galgame_card/web_hub.py index d54e8bb..e43df31 100644 --- a/hexi/plugins/nonebot_plugin_galgame_card/web_hub.py +++ b/hexi/plugins/nonebot_plugin_galgame_card/web_hub.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """群聊人设卡 Web API 子应用(挂载到 /api/galgame_card)。 -统一鉴权走 hexi.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。 +统一鉴权走 hexi.web_hub.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。 前端由统一管理台 hexi/web 渲染。 """ @@ -12,7 +12,7 @@ from typing import Optional from fastapi import FastAPI from fastapi.responses import JSONResponse -from hexi.web_auth import require_admin +from hexi.web_hub.web_auth import require_admin from . import repository @@ -41,7 +41,7 @@ async def _first_bot(): def build_admin_app() -> FastAPI | None: """构建群聊人设卡管理 API 子应用(挂载到 /api/galgame_card)。 - 鉴权由 hub 统一登录态提供(hexi.web_auth)。 + 鉴权由 hub 统一登录态提供(hexi.web_hub.web_auth)。 """ app = FastAPI(title="Galgame Card API") diff --git a/hexi/plugins/nonebot_plugin_group_tools/config.py b/hexi/plugins/nonebot_plugin_group_tools/config.py index 6cb180b..b87f6e4 100644 --- a/hexi/plugins/nonebot_plugin_group_tools/config.py +++ b/hexi/plugins/nonebot_plugin_group_tools/config.py @@ -8,7 +8,7 @@ from __future__ import annotations import sys as _sys -from hexi.config_standard import register_config_items +from hexi.web_hub.config_standard import register_config_items from . import constants diff --git a/hexi/plugins/nonebot_plugin_group_tools/handlers/management/ban.py b/hexi/plugins/nonebot_plugin_group_tools/handlers/management/ban.py index e564461..50b8ecb 100644 --- a/hexi/plugins/nonebot_plugin_group_tools/handlers/management/ban.py +++ b/hexi/plugins/nonebot_plugin_group_tools/handlers/management/ban.py @@ -10,7 +10,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent from nonebot.rule import Rule, is_type from nonebot_plugin_alconna import UniMessage -from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage +from hexi.core.custom_utils import check_manage from ...services.moderation import group_silence, member_silence, silence from ...utils.message import get_at_id, has_at_all diff --git a/hexi/plugins/nonebot_plugin_group_tools/handlers/management/title.py b/hexi/plugins/nonebot_plugin_group_tools/handlers/management/title.py index fc2dfd9..906cb76 100644 --- a/hexi/plugins/nonebot_plugin_group_tools/handlers/management/title.py +++ b/hexi/plugins/nonebot_plugin_group_tools/handlers/management/title.py @@ -6,7 +6,7 @@ from nonebot import on_fullmatch, on_startswith, require from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent from nonebot_plugin_alconna import UniMessage -from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage +from hexi.core.custom_utils import check_manage from ...services.profile import card_edit, group_name, title_get from ...utils.message import get_at_id diff --git a/hexi/plugins/nonebot_plugin_group_tools/services/moderation.py b/hexi/plugins/nonebot_plugin_group_tools/services/moderation.py index 757217a..2200107 100644 --- a/hexi/plugins/nonebot_plugin_group_tools/services/moderation.py +++ b/hexi/plugins/nonebot_plugin_group_tools/services/moderation.py @@ -2,7 +2,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent from nonebot_plugin_alconna import UniMessage -from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage +from hexi.core.custom_utils import check_manage from ..utils.permissions import is_bot_manage, self_member_info from ..utils.text import parse_seconds diff --git a/hexi/plugins/nonebot_plugin_group_tools/services/profile.py b/hexi/plugins/nonebot_plugin_group_tools/services/profile.py index e8f94bc..b48fa7d 100644 --- a/hexi/plugins/nonebot_plugin_group_tools/services/profile.py +++ b/hexi/plugins/nonebot_plugin_group_tools/services/profile.py @@ -2,7 +2,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent from nonebot_plugin_alconna import UniMessage -from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage +from hexi.core.custom_utils import check_manage from ..utils.permissions import is_bot_manage, self_member_info diff --git a/hexi/plugins/nonebot_plugin_group_tools/utils/permissions.py b/hexi/plugins/nonebot_plugin_group_tools/utils/permissions.py index bff70c9..ceaea26 100644 --- a/hexi/plugins/nonebot_plugin_group_tools/utils/permissions.py +++ b/hexi/plugins/nonebot_plugin_group_tools/utils/permissions.py @@ -3,7 +3,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent from nonebot_plugin_alconna import UniMessage -from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import ( +from hexi.core.custom_utils import ( check_bot_admin, check_manage, ) diff --git a/hexi/plugins/nonebot_plugin_helldivers_tools/__init__.py b/hexi/plugins/nonebot_plugin_helldivers_tools/__init__.py index da1d56d..4dbf222 100644 --- a/hexi/plugins/nonebot_plugin_helldivers_tools/__init__.py +++ b/hexi/plugins/nonebot_plugin_helldivers_tools/__init__.py @@ -16,7 +16,7 @@ from .utils.war_renderer import render_war_briefing # noqa: F401 from .utils import gen_ms_img, pic2b64 # noqa: F401 from .services.equipment_store import init_db as _init_equipment_db from .web import stratagem_admin as _stratagem_admin -from hexi.web_plugin_registry import register_web_plugin +from hexi.web_hub.web_plugin_registry import register_web_plugin __plugin_meta__ = PluginMetadata( name="绝地潜兵小助手", @@ -47,7 +47,7 @@ register_web_plugin( logger.info("战备管理 Web 插件已注册(/api/helldivers)") # 统一配置标准:把 API 相关配置暴露给 Web 管理台(/hub 插件配置) -from hexi.config_standard import register_model_config # noqa: E402 +from hexi.web_hub.config_standard import register_model_config # noqa: E402 from .config import config as _hd2_config # noqa: E402 register_model_config( diff --git a/hexi/plugins/nonebot_plugin_helldivers_tools/web/stratagem_admin.py b/hexi/plugins/nonebot_plugin_helldivers_tools/web/stratagem_admin.py index 1cd59d2..5233355 100644 --- a/hexi/plugins/nonebot_plugin_helldivers_tools/web/stratagem_admin.py +++ b/hexi/plugins/nonebot_plugin_helldivers_tools/web/stratagem_admin.py @@ -2,7 +2,7 @@ """战备管理 API:作为统一 Web 的一个模块,挂到 /api/helldivers。 - 不再自己出 HTML,页面由统一前端 (hexi/web) 渲染。 -- 鉴权统一走 hexi.web_auth(OAuth2 + SQLite)。 +- 鉴权统一走 hexi.web_hub.web_auth(OAuth2 + SQLite)。 """ from __future__ import annotations @@ -12,7 +12,7 @@ from pathlib import Path from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.responses import FileResponse, JSONResponse -from hexi.web_auth import require_admin +from hexi.web_hub.web_auth import require_admin from ..services.equipment_store import ( create_stratagem, diff --git a/hexi/plugins/nonebot_plugin_learning_chat/__init__.py b/hexi/plugins/nonebot_plugin_learning_chat/__init__.py index ddff324..befb9b4 100644 --- a/hexi/plugins/nonebot_plugin_learning_chat/__init__.py +++ b/hexi/plugins/nonebot_plugin_learning_chat/__init__.py @@ -7,7 +7,7 @@ require("nonebot_plugin_orm") from .config import NICKNAME, config_manager, register_config as _register_learning_config # noqa: E402 from . import web_hub # noqa: E402 -from hexi.web_plugin_registry import register_web_plugin # noqa: E402 +from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402 # 注册到统一 Web 管理台(/hub):hub 启动时挂载 /api/learning_chat register_web_plugin( diff --git a/hexi/plugins/nonebot_plugin_learning_chat/config.py b/hexi/plugins/nonebot_plugin_learning_chat/config.py index 15c3a3f..c7d77bd 100644 --- a/hexi/plugins/nonebot_plugin_learning_chat/config.py +++ b/hexi/plugins/nonebot_plugin_learning_chat/config.py @@ -113,7 +113,7 @@ def register_config() -> None: getter/setter 直接读写 config_manager.config,并保存到 learning_chat.yml。 权威源是插件自身的 yml,故 nosave 不写入 plugin_config.json。 """ - from hexi.config_standard import register_config_items + from hexi.web_hub.config_standard import register_config_items def _get(key): return getattr(config_manager.config, key) diff --git a/hexi/plugins/nonebot_plugin_learning_chat/services/learn.py b/hexi/plugins/nonebot_plugin_learning_chat/services/learn.py index 7c38d10..842153a 100644 --- a/hexi/plugins/nonebot_plugin_learning_chat/services/learn.py +++ b/hexi/plugins/nonebot_plugin_learning_chat/services/learn.py @@ -6,7 +6,7 @@ import time from dataclasses import dataclass from functools import cached_property, cmp_to_key -# from ..nonebot_plugin_hexi_core.MessageUtils import send_poke +# from ..core.MessageUtils import send_poke try: import jieba_fast.analyse as jieba_analyse diff --git a/hexi/plugins/nonebot_plugin_learning_chat/web_hub.py b/hexi/plugins/nonebot_plugin_learning_chat/web_hub.py index 60da3d9..2147819 100644 --- a/hexi/plugins/nonebot_plugin_learning_chat/web_hub.py +++ b/hexi/plugins/nonebot_plugin_learning_chat/web_hub.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """群聊学习 Web API 子应用(挂载到 /api/learning_chat)。 -统一鉴权走 hexi.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。 +统一鉴权走 hexi.web_hub.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。 前端由统一管理台 hexi/web 渲染。 """ @@ -22,7 +22,7 @@ from nonebot import get_adapter from nonebot.adapters.onebot.v11 import Adapter from nonebot_plugin_orm import get_session -from hexi.web_auth import require_admin +from hexi.web_hub.web_auth import require_admin from .services.learn import LearningChat from .models import ChatMessage, ChatContext, ChatAnswer, ChatBlackList diff --git a/hexi/plugins/nonebot_plugin_mc_server_status/__init__.py b/hexi/plugins/nonebot_plugin_mc_server_status/__init__.py index 10faac8..20e0f01 100644 --- a/hexi/plugins/nonebot_plugin_mc_server_status/__init__.py +++ b/hexi/plugins/nonebot_plugin_mc_server_status/__init__.py @@ -5,7 +5,7 @@ from nonebot.plugin import PluginMetadata from .config import Config, pc, save_file # noqa: E402,F401 # 统一配置标准:把管理 QQ / 机器人 QQ / 数据文件名暴露给 Web 管理台 -from hexi.config_standard import register_model_config # noqa: E402 +from hexi.web_hub.config_standard import register_model_config # noqa: E402 register_model_config( __name__, diff --git a/hexi/plugins/nonebot_plugin_ncm_saying/config.py b/hexi/plugins/nonebot_plugin_ncm_saying/config.py index 747b27c..e2f2c3a 100644 --- a/hexi/plugins/nonebot_plugin_ncm_saying/config.py +++ b/hexi/plugins/nonebot_plugin_ncm_saying/config.py @@ -7,7 +7,7 @@ from __future__ import annotations import sys as _sys -from hexi.config_standard import register_config_items +from hexi.web_hub.config_standard import register_config_items _PLUGIN_ID = __package__ diff --git a/hexi/plugins/nonebot_plugin_picfinder_take/__init__.py b/hexi/plugins/nonebot_plugin_picfinder_take/__init__.py index 1a27f22..295684f 100644 --- a/hexi/plugins/nonebot_plugin_picfinder_take/__init__.py +++ b/hexi/plugins/nonebot_plugin_picfinder_take/__init__.py @@ -18,7 +18,7 @@ __plugin_meta__ = PluginMetadata( ) # 统一配置标准:把「插件内部常量」也暴露给 Web 管理台(来源无关)。 -from hexi.config_standard import register_config_items # noqa: E402 +from hexi.web_hub.config_standard import register_config_items # noqa: E402 from . import config as _cfgmod # noqa: E402 register_config_items( diff --git a/hexi/plugins/nonebot_plugin_picstatus/__init__.py b/hexi/plugins/nonebot_plugin_picstatus/__init__.py index 345114f..84441ba 100644 --- a/hexi/plugins/nonebot_plugin_picstatus/__init__.py +++ b/hexi/plugins/nonebot_plugin_picstatus/__init__.py @@ -20,7 +20,7 @@ from .templates import load_builtin_templates, loaded_templates # 统一配置标准:把常用、可安全热更的配置暴露给 Web 管理台 # (路径/嵌套模型/复杂 list 字段不改,避免格式互转出错) -from hexi.config_standard import register_model_config # noqa: E402 +from hexi.web_hub.config_standard import register_model_config # noqa: E402 register_model_config( __name__, diff --git a/hexi/plugins/nonebot_plugin_random_jm_code/config.py b/hexi/plugins/nonebot_plugin_random_jm_code/config.py index 54088f1..3a7f634 100644 --- a/hexi/plugins/nonebot_plugin_random_jm_code/config.py +++ b/hexi/plugins/nonebot_plugin_random_jm_code/config.py @@ -9,7 +9,7 @@ from __future__ import annotations import sys as _sys -from hexi.config_standard import register_config_items +from hexi.web_hub.config_standard import register_config_items # plugin_id = NoneBot 插件模块名 _PLUGIN_ID = __package__ diff --git a/hexi/plugins/nonebot_plugin_random_jm_code/handlers/jm.py b/hexi/plugins/nonebot_plugin_random_jm_code/handlers/jm.py index 6747a15..683abe5 100644 --- a/hexi/plugins/nonebot_plugin_random_jm_code/handlers/jm.py +++ b/hexi/plugins/nonebot_plugin_random_jm_code/handlers/jm.py @@ -13,7 +13,7 @@ from nonebot.plugin.on import on_notice, on_command from nonebot.rule import to_me from nonebot_plugin_alconna import UniMessage, get_target -from hexi.plugins.nonebot_plugin_hexi_core.message_utils import send_poke +from hexi.core.message_utils import send_poke from .. import config as _cfg from ..services.store import add_new_jm_code, get_random_jm_code diff --git a/hexi/plugins/nonebot_plugin_regif/handlers/reverse.py b/hexi/plugins/nonebot_plugin_regif/handlers/reverse.py index 611df3f..de49de1 100644 --- a/hexi/plugins/nonebot_plugin_regif/handlers/reverse.py +++ b/hexi/plugins/nonebot_plugin_regif/handlers/reverse.py @@ -8,7 +8,7 @@ from nonebot.log import logger from nonebot.plugin.on import on_keyword from nonebot_plugin_alconna import UniMessage -from hexi.plugins.nonebot_plugin_hexi_core.message_utils import get_reply_message +from hexi.core.message_utils import get_reply_message from ..services.gif import reverse_gif_bytes diff --git a/hexi/plugins/nonebot_plugin_steam_info/__init__.py b/hexi/plugins/nonebot_plugin_steam_info/__init__.py index 204a000..8e7697f 100644 --- a/hexi/plugins/nonebot_plugin_steam_info/__init__.py +++ b/hexi/plugins/nonebot_plugin_steam_info/__init__.py @@ -6,12 +6,9 @@ from io import BytesIO from pathlib import Path from nonebot.log import logger from PIL import Image as PILImage -from nonebot.params import Depends -from nonebot.params import CommandArg -from nonebot import on_command, require -from nonebot.permission import SUPERUSER +from nonebot import require from typing import Optional, List, Dict -from nonebot.adapters import Message, Event +from nonebot.adapters import Event from nonebot.plugin import PluginMetadata, inherit_supported_adapters require("nonebot_plugin_alconna") @@ -20,9 +17,8 @@ require("nonebot_plugin_apscheduler") from nonebot_plugin_alconna.uniseg import get_target as uni_get_target import nonebot_plugin_localstore as store from nonebot_plugin_apscheduler import scheduler -from nonebot_plugin_alconna import Text, Image, UniMessage, Target, At -from nonebot.adapters.onebot.v11 import Bot, MessageEvent, MessageSegment -from hexi.plugins.nonebot_plugin_hexi_core.cooldown import cooldown +from nonebot_plugin_alconna import Text, Image, UniMessage, Target +from nonebot.adapters.onebot.v11 import Bot from .config import Config from .models import ProcessedPlayer from .data_source import BindData, SteamInfoData, ParentData, DisableParentData @@ -122,7 +118,7 @@ except Exception as e: logger.error(f"初始化失败: {e}") # 统一配置标准:把常见可调配置暴露给 Web 管理台 -from hexi.config_standard import register_model_config # noqa: E402 +from hexi.web_hub.config_standard import register_model_config # noqa: E402 register_model_config( __name__, diff --git a/hexi/plugins/nonebot_plugin_steam_info/handlers/steam.py b/hexi/plugins/nonebot_plugin_steam_info/handlers/steam.py index 6f45f1b..7b0354a 100644 --- a/hexi/plugins/nonebot_plugin_steam_info/handlers/steam.py +++ b/hexi/plugins/nonebot_plugin_steam_info/handlers/steam.py @@ -4,14 +4,14 @@ from __future__ import annotations import asyncio -from nonebot import logger, on_command, require +from nonebot import logger, on_command from nonebot.adapters import Message, Event from nonebot.adapters.onebot.v11 import Bot, MessageEvent, MessageSegment from nonebot.params import CommandArg, Depends from nonebot.permission import SUPERUSER -from nonebot_plugin_alconna import At, Image, Target, Text, UniMessage +from nonebot_plugin_alconna import At, Image, Target, UniMessage -from hexi.plugins.nonebot_plugin_hexi_core.cooldown import cooldown +from hexi.core.cooldown import cooldown # 业务/状态在插件根 __init__ 中(本插件为社区插件,服务层保留在根模块) from .. import ( @@ -36,11 +36,8 @@ from .. import ( get_user_data, image_to_bytes, key_select, - key_select_2, localize_game_name, - parent_data, simplize_steam_player_data, - steam_info_data, _fill_missing_icon_hashes, _query_game_names, _upsert_game_icon_hashes, diff --git a/hexi/plugins/nonebot_plugin_steam_info/steam.py b/hexi/plugins/nonebot_plugin_steam_info/steam.py index b3484c9..1e05d7c 100644 --- a/hexi/plugins/nonebot_plugin_steam_info/steam.py +++ b/hexi/plugins/nonebot_plugin_steam_info/steam.py @@ -9,7 +9,7 @@ from typing import List, Optional, Dict from datetime import datetime, timezone import asyncio import json -from hexi.plugins.nonebot_plugin_hexi_core.rate_limit import acquire +from hexi.core.rate_limit import acquire from .config import Config diff --git a/hexi/plugins/nonebot_plugin_video_analysis/cleanup.py b/hexi/plugins/nonebot_plugin_video_analysis/cleanup.py index 2d60cdd..7fb1f5f 100644 --- a/hexi/plugins/nonebot_plugin_video_analysis/cleanup.py +++ b/hexi/plugins/nonebot_plugin_video_analysis/cleanup.py @@ -22,7 +22,7 @@ from nonebot import logger, require, get_plugin_config, on_command from nonebot.adapters.onebot.v11 import Bot, MessageEvent from pydantic import BaseModel -from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage +from hexi.core.custom_utils import check_manage require("nonebot_plugin_apscheduler") from nonebot_plugin_apscheduler import scheduler diff --git a/hexi/plugins/nonebot_plugin_video_analysis/config.py b/hexi/plugins/nonebot_plugin_video_analysis/config.py index 1f45017..f29d679 100644 --- a/hexi/plugins/nonebot_plugin_video_analysis/config.py +++ b/hexi/plugins/nonebot_plugin_video_analysis/config.py @@ -1,4 +1,4 @@ -"""统一配置注册:把插件配置按文档标准接入 hexi.web_config(Web 可读可改)。 +"""统一配置注册:把插件配置按文档标准接入 hexi.web_hub.web_config(Web 可读可改)。 - temp 清理配置(env pydantic) → register_model_config - S3 存储配置(原硬编码在 services/storage/s3.py) → register_config_items(store=s3 模块) @@ -7,7 +7,7 @@ from __future__ import annotations -from hexi.config_standard import register_config_items, register_model_config +from hexi.web_hub.config_standard import register_config_items, register_model_config from . import cleanup, list_proc # noqa: F401 from .services.storage import s3 as _s3mod diff --git a/hexi/plugins/nonebot_plugin_video_analysis/handlers/douyin.py b/hexi/plugins/nonebot_plugin_video_analysis/handlers/douyin.py index b9dd766..145e903 100644 --- a/hexi/plugins/nonebot_plugin_video_analysis/handlers/douyin.py +++ b/hexi/plugins/nonebot_plugin_video_analysis/handlers/douyin.py @@ -68,13 +68,18 @@ async def parse_douyin( logger.info(f"短链重定向: {target_url} -> {resolved}") target_url = resolved else: - # 全链接: www.douyin.com/note/xxx 或 www.douyin.com/video/xxx + # 全链接: www.douyin.com/note/xxx、www.douyin.com/video/xxx, + # 以及抖音复制出的 iesdouyin.com/share/video|note/xxx(弱网/无网下 App + # 生成的恢复连接同样会是这个形态)。统一提取出作品 id 后再交给 SSR / playwright。 m = re.search( - r"(https?://(?:www\.)?douyin\.com/(?:note|video)/\d+)", url + r"(https?://(?:www\.)?(?:m\.)?(?:ies)?douyin\.com/(?:share/)?(note|video)/(\d+))", + url, ) if not m: return None, None, False - target_url = m.group(1) + # 统一规范化为 www.douyin.com/{note|video}/{id},避免 iesdouyin 分享页 + # 在无登录态/弱网下只返回壳页,直接落到浏览器可正常打开的作品页。 + target_url = f"https://www.douyin.com/{m.group(2)}/{m.group(3)}" try: is_img_post = False diff --git a/hexi/plugins/nonebot_plugin_video_analysis/handlers/entry.py b/hexi/plugins/nonebot_plugin_video_analysis/handlers/entry.py index 8c0d86a..025439e 100644 --- a/hexi/plugins/nonebot_plugin_video_analysis/handlers/entry.py +++ b/hexi/plugins/nonebot_plugin_video_analysis/handlers/entry.py @@ -179,6 +179,12 @@ async def dispatch_url( await UniMessage.text("检测到链接,正在处理,请稍候...").send() try: title, parsed_path, image_post = await parse_douyin(url) + if not title and not parsed_path: + # 链接识别成功但未能获取到作品(网络/风控/链接失效等), + # 给出明确失败提示,避免只发"请稍候"后无下文。 + await UniMessage.text(f"无法解析到媒体:{url}").send() + logger.warning(f"媒体解析:未能获取到作品:{url}") + return None, None return await process_douyin_res( title, parsed_path, is_private, image_post, plan=plan ) diff --git a/hexi/plugins/nonebot_plugin_video_analysis/list_proc.py b/hexi/plugins/nonebot_plugin_video_analysis/list_proc.py index 3cff685..16aa01d 100644 --- a/hexi/plugins/nonebot_plugin_video_analysis/list_proc.py +++ b/hexi/plugins/nonebot_plugin_video_analysis/list_proc.py @@ -1,20 +1,15 @@ import json -import random import asyncio import os -from typing import List -from nonebot.adapters.onebot.v11 import MessageSegment, PokeNotifyEvent, Message -from nonebot.plugin.on import on_notice, on_command -from nonebot import on_type, on_message, logger +from nonebot.plugin.on import on_command +from nonebot import logger from nonebot.adapters import Event from nonebot.matcher import Matcher -from nonebot.params import CommandArg -from nonebot.rule import to_me from nonebot_plugin_alconna import UniMessage, get_target -from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_admin -# from hexi.plugins.nonebot_plugin_hexi_core.MessageUtils import send_poke +from hexi.core.custom_utils import check_admin +# from hexi.core.message_utils import send_poke BASE_DIR = os.path.dirname(os.path.abspath(__file__)) FILE_PATH = os.path.join(BASE_DIR, "data", "list.json") diff --git a/hexi/plugins/nonebot_plugin_video_analysis/services/storage/s3.py b/hexi/plugins/nonebot_plugin_video_analysis/services/storage/s3.py index b47a815..5d2e4a3 100644 --- a/hexi/plugins/nonebot_plugin_video_analysis/services/storage/s3.py +++ b/hexi/plugins/nonebot_plugin_video_analysis/services/storage/s3.py @@ -7,7 +7,7 @@ import boto3 from botocore.config import Config from nonebot import logger -from hexi.web_config import get_effective_value +from hexi.web_hub.web_config import get_effective_value # 该插件模块名,用于读取统一配置值库(Web 修改后生效) _PLUGIN_ID = "hexi.plugins.nonebot_plugin_video_analysis" diff --git a/hexi/web/favicon.svg b/hexi/web/favicon.svg deleted file mode 100644 index 3e4be0b..0000000 --- a/hexi/web/favicon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/hexi/web/index.html b/hexi/web/index.html index 2aa4ba1..bf50961 100644 --- a/hexi/web/index.html +++ b/hexi/web/index.html @@ -3,7 +3,7 @@ - + HeXi · 统一管理台 diff --git a/hexi/web/public/favicon.png b/hexi/web/public/favicon.png new file mode 100644 index 0000000..875b033 Binary files /dev/null and b/hexi/web/public/favicon.png differ diff --git a/hexi/web/src/layout/AppLayout.tsx b/hexi/web/src/layout/AppLayout.tsx index 5e06ec6..5649030 100644 --- a/hexi/web/src/layout/AppLayout.tsx +++ b/hexi/web/src/layout/AppLayout.tsx @@ -174,7 +174,7 @@ export function AppLayout() { {isPluginSub && ( - + )} {isPluginSub ? (
diff --git a/hexi/web/src/pages/plugins/index.tsx b/hexi/web/src/pages/plugins/index.tsx index dc16821..41121d8 100644 --- a/hexi/web/src/pages/plugins/index.tsx +++ b/hexi/web/src/pages/plugins/index.tsx @@ -33,6 +33,7 @@ interface CatalogPlugin { name: string description: string usage: string + type?: string enabled: boolean chat: string[] groups: Record @@ -304,6 +305,11 @@ export default function PluginsPage() {
{p.name} + {p.type && ( + + {p.type === 'library' ? 'lib' : 'app'} + + )} {p.has_web && Web} {p.has_config && 配置} {on ? '启用' : '停用'} diff --git a/hexi/web/src/pages/settings/index.tsx b/hexi/web/src/pages/settings/index.tsx index a8d5cca..db8ba33 100644 --- a/hexi/web/src/pages/settings/index.tsx +++ b/hexi/web/src/pages/settings/index.tsx @@ -17,6 +17,8 @@ interface BotConfigData { debug: boolean log_level: string log_level_options: string[] + log_max_mb: number + log_max_mb_default: number source: string notice?: string applied?: string[] @@ -46,6 +48,7 @@ export default function SettingsPage() { const [cfgNickname, setCfgNickname] = useState('') const [cfgDebug, setCfgDebug] = useState(false) const [cfgLogLevel, setCfgLogLevel] = useState('INFO') + const [cfgLogMaxMb, setCfgLogMaxMb] = useState('20') const [cfgBusy, setCfgBusy] = useState(false) const [cfgLoaded, setCfgLoaded] = useState(false) @@ -64,6 +67,7 @@ export default function SettingsPage() { setCfgNickname((c.nickname || []).join(', ')) setCfgDebug(!!c.debug) setCfgLogLevel(c.log_level || 'INFO') + setCfgLogMaxMb(String(c.log_max_mb ?? c.log_max_mb_default ?? 20)) setCfgLoaded(true) } catch (e: any) { setCfgLoaded(false) @@ -95,18 +99,25 @@ export default function SettingsPage() { const saveBotConfig = async () => { setCfgBusy(true) try { - const values = { + const values: Record = { superusers: (cfgSuperusers || '').split(/[,\s]+/).map(s => s.trim()).filter(Boolean), nickname: (cfgNickname || '').split(/[,\s]+/).map(s => s.trim()).filter(Boolean), debug: cfgDebug, log_level: cfgLogLevel, } + const logMaxMbRaw = cfgLogMaxMb.trim() + if (logMaxMbRaw !== '') { + const logMaxMb = parseInt(logMaxMbRaw, 10) + if (!(logMaxMb > 0)) { toast.warning('日志清理大小需为正整数(MB)'); setCfgBusy(false); return } + values.log_max_mb = logMaxMb + } const r = await hubBotConfigSave(values) setBotCfg(r) setCfgSuperusers((r.superusers || []).join(', ')) setCfgNickname((r.nickname || []).join(', ')) setCfgDebug(!!r.debug) setCfgLogLevel(r.log_level || 'INFO') + setCfgLogMaxMb(String(r.log_max_mb ?? r.log_max_mb_default ?? 20)) toast.success('BOT 配置已保存') if (r.notice) toast.info(r.notice) } catch (e: any) { @@ -199,6 +210,7 @@ export default function SettingsPage() {

SUPERUSERS / NICKNAME 支持逗号或空格分隔;保存后会写入 .env 并尽量热应用。 部分插件在启动时缓存了这些值,完全生效建议保存后重启 bot。 + 日志清理大小保存后仅在下一个检查周期(约 1 分钟)内生效,无需重启。

@@ -230,6 +242,11 @@ export default function SettingsPage() { 日志等级(INFO 为默认)
+ + + setCfgLogMaxMb(e.target.value)} placeholder="如: 20" /> + _bot_run.log 超过该大小即清空(不归档) +
diff --git a/hexi/plugins/nonebot_plugin_web_hub/__init__.py b/hexi/web_hub/__init__.py similarity index 81% rename from hexi/plugins/nonebot_plugin_web_hub/__init__.py rename to hexi/web_hub/__init__.py index 515e1ec..72569ed 100644 --- a/hexi/plugins/nonebot_plugin_web_hub/__init__.py +++ b/hexi/web_hub/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """统一 Web 管理系统(/hub):自动聚合所有注册过的 Web 插件。 -- 插件在自己 __init__ 里调用 hexi.web_plugin_registry.register_web_plugin(...) 注册 +- 插件在自己 __init__ 里调用 hexi.web_hub.web_plugin_registry.register_web_plugin(...) 注册 - hub 启动时自动挂载每个插件 API 到 /api/,无需手动配置 - /hub/api/plugins 由注册中心聚合返回 """ @@ -13,7 +13,6 @@ import asyncio import json import logging as _logging import os -import socket import subprocess import sys import threading @@ -27,9 +26,8 @@ from starlette.staticfiles import StaticFiles from nonebot import get_driver, logger from nonebot.adapters.onebot.v11 import Adapter -from nonebot.plugin import PluginMetadata -from hexi.web_auth import ( +from .web_auth import ( authenticate, change_password, get_current_user, @@ -39,20 +37,14 @@ from hexi.web_auth import ( revoke_token, sync_admin, ) -from hexi.web_plugin_registry import get_web_plugin_builder, list_web_plugins +from .web_plugin_registry import get_web_plugin_builder, list_web_plugins from .dashboard import collect_dashboard -__plugin_meta__ = PluginMetadata( - name="HeXi Web Hub", - description="统一 Web 管理系统(/hub):自动聚合所有注册过的 Web 插件", - usage="/hub 进入管理台", - type="application", -) - +# hexi/web_hub 为机器人核心模块(统一 Web 管理台),非 NoneBot 插件 basic_path = Path(__file__).resolve().parent # hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web -WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist" +WEB_DIST = Path(__file__).resolve().parents[1] / "web" / "dist" def _hub_version() -> str: """管理台版本号:以 hexi/web/package.json 为唯一来源(前端构建也读它)。""" @@ -63,9 +55,51 @@ def _hub_version() -> str: return "0.1.0" # 仓库根目录(bot.py 所在)与 bot 端口 -_REPO_ROOT = Path(__file__).resolve().parents[3] +_REPO_ROOT = Path(__file__).resolve().parents[2] _BOT_PORT = 39697 +# —— 日志清理策略(可用 .env 覆盖)—— +# 单次运行时 _bot_run.log 超过该大小即直接清空(不归档),避免长期运行无限膨胀。 +# 阈值读取优先级:HEXI_LOG_MAX_BYTES(字节) > HEXI_LOG_MAX_MB(MB) > 默认 20MB。 +# 后台清理检查周期(秒) +_LOG_WATCH_INTERVAL = float(os.environ.get("HEXI_LOG_WATCH_INTERVAL", "60")) +# 清空互斥锁(防止后台线程与其它路径同时清空) +_LOG_LOCK = threading.Lock() + + +def _env_get(name: str, default: str = "") -> str: + """读取配置:os.environ > .env 文件 > default。NoneBot 不会把 .env 注入 os.environ,故手动读取。""" + val = os.getenv(name) + if val: + return val + try: + env_path = _REPO_ROOT / ".env" + if env_path.exists(): + for line in env_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith(name + "=") or line.startswith(name + " ="): + return line.split("=", 1)[1].strip().split(" #", maxsplit=1)[0].strip() + except OSError: + pass + return default + + +def _log_max_bytes() -> int: + """日志清理阈值(字节)。优先级:HEXI_LOG_MAX_BYTES > HEXI_LOG_MAX_MB > 默认 20MB。""" + raw = _env_get("HEXI_LOG_MAX_BYTES") + if raw: + try: + return max(1, int(raw)) + except ValueError: + pass + mb = _env_get("HEXI_LOG_MAX_MB") + if mb: + try: + return max(1, int(float(mb)) * 1024 * 1024) + except ValueError: + pass + return 20 * 1024 * 1024 + def _spawn_bot() -> None: """以分离进程方式拉起 bot.py:等旧实例释放端口后接管,日志追加到 _bot_run.log。""" @@ -103,24 +137,59 @@ def _spawn_bot() -> None: ) -def _archive_log() -> None: - """把当前 _bot_run.log 归档到 hexi/logs/bot_run_<时间戳>.log,并把原文件清空(供新运行)。""" - log = _REPO_ROOT / "_bot_run.log" - if not log.exists(): - return +def _purge_legacy_archives() -> None: + """删除历史遗留的 bot_run_*.log 归档(策略改为不归档,仅保留当前 _bot_run.log)。""" logs_dir = _REPO_ROOT / "hexi" / "logs" try: - logs_dir.mkdir(parents=True, exist_ok=True) - data = log.read_bytes() - if data: - ts = time.strftime("%Y%m%d_%H%M%S") - target = logs_dir / f"bot_run_{ts}.log" - target.write_bytes(data) - logger.info(f"已归档旧日志 -> {target.name}") - # 清空原日志,新 run 从空文件开始 - open(log, "wb").close() + for p in logs_dir.glob("bot_run_*.log"): + try: + p.unlink() + except OSError: + pass except OSError as e: # noqa: BLE001 - logger.warning(f"归档日志失败: {type(e).__name__}: {e}") + logger.warning(f"清理历史归档日志失败: {type(e).__name__}: {e}") + + +def _clear_log() -> None: + """把 _bot_run.log 截断清空(不归档)。写端均为 append 模式,截断后下次写入从头续写。""" + with _LOG_LOCK: + log = _REPO_ROOT / "_bot_run.log" + if not log.exists(): + return + try: + open(log, "wb").close() + logger.info("_bot_run.log 超过阈值,已清空(不归档)。") + except OSError as e: # noqa: BLE001 + logger.warning(f"清空日志失败: {type(e).__name__}: {e}") + + +def _rotate_log_if_needed() -> None: + """当 _bot_run.log 超过阈值时直接清空(不归档),避免长期运行无限膨胀。""" + try: + log = _REPO_ROOT / "_bot_run.log" + if log.exists() and log.stat().st_size >= _log_max_bytes(): + _clear_log() + except OSError as e: # noqa: BLE001 + logger.warning(f"日志轮转检查失败: {type(e).__name__}: {e}") + + +def _start_log_watchdog() -> None: + """后台线程按周期检查日志大小,超阈值直接清空(不归档),避免长期运行无限膨胀。""" + global _log_watchdog_started + if _log_watchdog_started: + return + _log_watchdog_started = True + _purge_legacy_archives() # 顺带清掉历史遗留的归档文件 + + def _loop() -> None: + while True: + time.sleep(_LOG_WATCH_INTERVAL) + _rotate_log_if_needed() + + threading.Thread(target=_loop, name="hexi-log-watchdog", daemon=True).start() + + +_log_watchdog_started = False def _tail_log_lines( @@ -187,7 +256,6 @@ def _schedule_bot_action(action: str) -> None: def _work() -> None: time.sleep(1.0) - _archive_log() if action == "restart": _spawn_bot() time.sleep(1.0) @@ -219,6 +287,7 @@ class BotConfigModel(BaseModel): nickname: list[str] | None = None debug: bool | None = None log_level: str | None = None + log_max_mb: int | None = None async def _first_bot(): @@ -283,10 +352,10 @@ def build_hub_app() -> FastAPI: @app.get("/api/plugins/catalog") async def plugins_catalog(_: dict = Depends(get_current_user)): - """全部 application 插件 + 控制面状态 + 是否带 Web 页面。""" - from hexi.plugins.nonebot_plugin_hexi_core.plugin_control import list_plugins - from hexi.web_config import has_schema - from hexi.web_plugin_registry import get_web_plugin_by_module + """全部 application/library 插件(含带配置项的 library)+ 控制面状态 + 是否带 Web 页面。""" + from hexi.core.plugin_control import list_plugins + from .web_config import has_schema + from .web_plugin_registry import get_web_plugin_by_module items = list_plugins() for it in items: @@ -320,7 +389,7 @@ def build_hub_app() -> FastAPI: payload: PluginControlModel, _: dict = Depends(get_current_user), ): - from hexi.plugins.nonebot_plugin_hexi_core.plugin_control import set_global + from hexi.core.plugin_control import set_global ctl = set_global(plugin_id, enabled=payload.enabled, chat=payload.chat) return {"ok": True, "control": ctl} @@ -332,7 +401,7 @@ def build_hub_app() -> FastAPI: payload: PluginControlModel, _: dict = Depends(get_current_user), ): - from hexi.plugins.nonebot_plugin_hexi_core.plugin_control import set_group + from hexi.core.plugin_control import set_group ctl = set_group( plugin_id, group_id, enabled=payload.enabled, chat=payload.chat @@ -345,7 +414,7 @@ def build_hub_app() -> FastAPI: group_id: str, _: dict = Depends(get_current_user), ): - from hexi.plugins.nonebot_plugin_hexi_core.plugin_control import remove_group + from hexi.core.plugin_control import remove_group ctl = remove_group(plugin_id, group_id) return {"ok": True, "control": ctl} @@ -354,7 +423,7 @@ def build_hub_app() -> FastAPI: async def plugin_config_get( plugin_id: str, _: dict = Depends(get_current_user) ): - from hexi.web_config import get_config + from .web_config import get_config cfg = get_config(plugin_id) if cfg is None: @@ -367,7 +436,7 @@ def build_hub_app() -> FastAPI: payload: dict, _: dict = Depends(get_current_user), ): - from hexi.web_config import save_config + from .web_config import save_config payload = payload or {} if "revision" not in payload: @@ -389,7 +458,7 @@ def build_hub_app() -> FastAPI: @app.get("/api/system/bot_config") async def bot_config_get(_: dict = Depends(get_current_user)): """读取 bot 全局配置(SUPERUSERS / NICKNAME / DEBUG / LOG_LEVEL)。""" - from hexi.plugins.nonebot_plugin_web_hub.bot_settings import current_values + from .bot_settings import current_values return {"ok": True, **current_values()} @@ -399,7 +468,7 @@ def build_hub_app() -> FastAPI: _: dict = Depends(get_current_user), ): """保存 bot 全局配置并尽量热应用到运行态。""" - from hexi.plugins.nonebot_plugin_web_hub.bot_settings import save_bot_settings + from .bot_settings import save_bot_settings try: result = save_bot_settings(payload.dict(exclude_none=True)) @@ -433,7 +502,7 @@ def build_hub_app() -> FastAPI: - before 传字节偏移:返回该偏移之前最近 limit 行,offset 为这些行的起点, 继续把 offset 传给 before 即可向前翻页;offset==0 表示已到文件头。 """ - root = Path(__file__).resolve().parents[3] # 仓库根目录 + root = Path(__file__).resolve().parents[2] # 仓库根目录 candidates = [ root / "_bot_run.log", root / "hexi" / "logs" / "bot.log", @@ -481,7 +550,7 @@ def build_hub_app() -> FastAPI: _: dict = Depends(get_current_user), ): """SSE 实时日志流:从 since(字节偏移)增量推送新日志行。""" - root = Path(__file__).resolve().parents[3] # 仓库根目录 + root = Path(__file__).resolve().parents[2] # 仓库根目录 candidates = [ root / "_bot_run.log", root / "hexi" / "logs" / "bot.log", @@ -602,6 +671,7 @@ def mount_hub() -> None: mount_hub() +_start_log_watchdog() @get_driver().on_startup diff --git a/hexi/plugins/nonebot_plugin_web_hub/bot_settings.py b/hexi/web_hub/bot_settings.py similarity index 82% rename from hexi/plugins/nonebot_plugin_web_hub/bot_settings.py rename to hexi/web_hub/bot_settings.py index 09ba815..9434f25 100644 --- a/hexi/plugins/nonebot_plugin_web_hub/bot_settings.py +++ b/hexi/web_hub/bot_settings.py @@ -17,7 +17,7 @@ from typing import Any from nonebot import get_driver, logger -_REPO_ROOT = Path(__file__).resolve().parents[3] +_REPO_ROOT = Path(__file__).resolve().parents[2] # 仓库根目录的 .env(NoneBot 启动时自动加载) _ENV_PATH = _REPO_ROOT / ".env" @@ -32,6 +32,9 @@ _KEY_ENV: dict[str, str] = { # LOG_LEVEL 可选项(写入时建议大写) LOG_LEVEL_OPTIONS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] +# 日志清理大小(MB)默认值(未在 .env 配置时) +_LOG_MAX_MB_DEFAULT = 20 + def _env_get(name: str, default: str = "") -> str: """os.environ > .env 文件 > default。""" @@ -79,6 +82,27 @@ def _normalize_log_level(value: Any) -> str: return str(value).upper() +def _current_log_max_mb() -> int: + """当前日志清理大小(MB)。优先级:HEXI_LOG_MAX_MB > HEXI_LOG_MAX_BYTES(换算) > 默认。""" + raw = _env_get("HEXI_LOG_MAX_MB").strip() + if raw: + try: + val = float(raw) + if val > 0: + return int(val) + except ValueError: + pass + raw_bytes = _env_get("HEXI_LOG_MAX_BYTES").strip() + if raw_bytes: + try: + b = int(raw_bytes) + if b > 0: + return max(1, b // (1024 * 1024)) + except ValueError: + pass + return _LOG_MAX_MB_DEFAULT + + def current_values() -> dict[str, Any]: """返回当前生效值(读取优先级:driver.config > .env)。""" try: @@ -103,6 +127,8 @@ def current_values() -> dict[str, Any]: "debug": bool(debug), "log_level": log_level, "log_level_options": LOG_LEVEL_OPTIONS, + "log_max_mb": _current_log_max_mb(), + "log_max_mb_default": _LOG_MAX_MB_DEFAULT, "source": "config" if cfg is not None else "env", } @@ -153,17 +179,32 @@ def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]: if log_level not in LOG_LEVEL_OPTIONS: raise ValueError(f"LOG_LEVEL 必须是 {'/'.join(LOG_LEVEL_OPTIONS)} 之一") + # 日志清理大小(MB,写入 .env 的 HEXI_LOG_MAX_MB) + log_max_mb: int | None = None + _log_max_mb_raw = values.get("log_max_mb") + if _log_max_mb_raw is not None and str(_log_max_mb_raw).strip() != "": + try: + log_max_mb = int(float(_log_max_mb_raw)) + except (TypeError, ValueError): + raise ValueError("日志清理大小必须是正整数(MB)") + if log_max_mb <= 0: + raise ValueError("日志清理大小必须是正整数(MB)") + # 1. 写 os.environ(即时可读,进程内 getenv 立刻生效) os.environ["SUPERUSERS"] = _serialize_list(superusers) os.environ["NICKNAME"] = _serialize_list(nickname) os.environ["DEBUG"] = "true" if debug else "false" os.environ["LOG_LEVEL"] = log_level + if log_max_mb is not None: + os.environ["HEXI_LOG_MAX_MB"] = str(log_max_mb) # 2. 落盘 .env(重启后依然生效) _set_env_line("SUPERUSERS", _serialize_list(superusers)) _set_env_line("NICKNAME", _serialize_list(nickname)) _set_env_line("DEBUG", "true" if debug else "false") _set_env_line("LOG_LEVEL", log_level) + if log_max_mb is not None: + _set_env_line("HEXI_LOG_MAX_MB", str(log_max_mb)) # 3. 热应用到 driver.config applied: list[str] = [] @@ -189,6 +230,8 @@ def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]: # 4. LOG_LEVEL 应用到 loguru _apply_log_level(log_level) + if log_max_mb is not None: + applied.append("HEXI_LOG_MAX_MB") return { "ok": True, diff --git a/hexi/config_standard.py b/hexi/web_hub/config_standard.py similarity index 98% rename from hexi/config_standard.py rename to hexi/web_hub/config_standard.py index 7c5fe2a..f0be8e7 100644 --- a/hexi/config_standard.py +++ b/hexi/web_hub/config_standard.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -"""统一配置标准辅助:把插件 pydantic Config 模型一键接入 hexi.web_config。 +"""统一配置标准辅助:把插件 pydantic Config 模型一键接入 hexi.web_hub.web_config。 -这个模块是对 hexi.web_config 的薄封装,目的是把「插件配置标准」统一起来: +这个模块是对 hexi.web_hub.web_config 的薄封装,目的是把「插件配置标准」统一起来: - 插件只需声明一份字段列表(或交给字段推断),就能在 Web 管理台可读可设置。 - 提供 getter(回填当前生效值)与 apply(保存后热更新 pydantic 对象)。 @@ -20,7 +20,7 @@ from typing import Any, Callable, Iterable, Literal, Optional, Union, get_args, from pydantic import BaseModel -from hexi.web_config import register_plugin_config +from .web_config import register_plugin_config # 常见「密码/密钥」类字段名,前端会自动脱敏 _SECRET_HINTS = ("password", "secret", "token", "key", "cookie", "apikey", "api_key") diff --git a/hexi/plugins/nonebot_plugin_web_hub/dashboard.py b/hexi/web_hub/dashboard.py similarity index 100% rename from hexi/plugins/nonebot_plugin_web_hub/dashboard.py rename to hexi/web_hub/dashboard.py diff --git a/hexi/web_auth.py b/hexi/web_hub/web_auth.py similarity index 97% rename from hexi/web_auth.py rename to hexi/web_hub/web_auth.py index 7fbeee0..ff77d5d 100644 --- a/hexi/web_auth.py +++ b/hexi/web_hub/web_auth.py @@ -20,7 +20,7 @@ from typing import Optional from fastapi import Depends, HTTPException from fastapi.security import OAuth2PasswordBearer -basic_path = Path(__file__).resolve().parent +basic_path = Path(__file__).resolve().parents[1] # hexi/ DATA_DIR = basic_path / "data" DB_PATH = DATA_DIR / "web_auth.db" @@ -48,7 +48,7 @@ def _read_env(key: str, default: str) -> str: val = os.getenv(key) if val: return val - env_path = Path(__file__).resolve().parent.parent / ".env" + env_path = basic_path.parent / ".env" if env_path.exists(): for line in env_path.read_text(encoding="utf-8").splitlines(): line = line.strip() @@ -107,7 +107,7 @@ def init_auth_db() -> None: def _env_has_password_key() -> bool: """.env 中是否显式配置了 hexi_web_password(区别于代码默认值 admin)。""" - env_path = Path(__file__).resolve().parent.parent / ".env" + env_path = basic_path.parent / ".env" if not env_path.exists(): return False for line in env_path.read_text(encoding="utf-8").splitlines(): @@ -121,7 +121,7 @@ def _env_has_password_key() -> bool: def _write_env_password(password: str) -> None: """把 Web 端修改后的密码回写 .env,保持 .env 永远是权威源(重启后不还原)。""" - env_path = Path(__file__).resolve().parent.parent / ".env" + env_path = basic_path.parent / ".env" try: lines = ( env_path.read_text(encoding="utf-8").splitlines() diff --git a/hexi/web_config.py b/hexi/web_hub/web_config.py similarity index 97% rename from hexi/web_config.py rename to hexi/web_hub/web_config.py index 4e336b0..6178a60 100644 --- a/hexi/web_config.py +++ b/hexi/web_hub/web_config.py @@ -35,7 +35,7 @@ from typing import Any, Callable, Optional from nonebot import logger -_ROOT = Path(__file__).resolve().parent # hexi/ +_ROOT = Path(__file__).resolve().parents[1] # hexi/ DATA_DIR = _ROOT / "config" STORE_PATH = DATA_DIR / "plugin_config.json" ENV_PATH = _ROOT.parent / ".env" # 仓库根 .env @@ -334,10 +334,16 @@ def _append_env(env_name: str, value: str) -> None: def _hot_reload(plugin_id: str) -> None: try: - from hexi.plugins.nonebot_plugin_hexi_core.plugin_manager import hot_reload + from hexi.core.plugin_manager import hot_reload ok = hot_reload(plugin_id) - logger.info(f"插件配置热刷新: hot_reload({plugin_id}) -> {ok}") + if ok: + logger.info(f"插件配置热刷新: hot_reload({plugin_id}) 成功") + else: + logger.warning( + f"插件配置热刷新跳过 {plugin_id}: 非 application(library/未声明)插件不可热重载," + "请检查是否已提供 apply 回调或直接重启" + ) except Exception as e: # noqa: BLE001 logger.warning(f"插件配置热刷新失败({plugin_id}): {type(e).__name__}: {e}") diff --git a/hexi/web_hub_auth.py b/hexi/web_hub/web_hub_auth.py similarity index 57% rename from hexi/web_hub_auth.py rename to hexi/web_hub/web_hub_auth.py index 3ee8f1e..f228afa 100644 --- a/hexi/web_hub_auth.py +++ b/hexi/web_hub/web_hub_auth.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -"""兼容 shim:统一鉴权已迁移到 hexi.web_auth(OAuth2 + SQLite)。""" -from hexi.web_auth import ( # noqa: F401 +"""兼容 shim:统一鉴权已迁移到 hexi.web_hub.web_auth(OAuth2 + SQLite)。""" +from .web_auth import ( # noqa: F401 authenticate, create_user, get_user_by_token, diff --git a/hexi/web_plugin_registry.py b/hexi/web_hub/web_plugin_registry.py similarity index 100% rename from hexi/web_plugin_registry.py rename to hexi/web_hub/web_plugin_registry.py diff --git a/tests/test_cooldown.py b/tests/test_cooldown.py index 492fb9e..04c1c4c 100644 --- a/tests/test_cooldown.py +++ b/tests/test_cooldown.py @@ -31,11 +31,10 @@ from nonebot.matcher import Matcher _BASE = ( Path(__file__).resolve().parents[1] / "hexi" - / "plugins" - / "nonebot_plugin_hexi_core" + / "core" ) -_RL_NAME = "hexi.plugins.nonebot_plugin_hexi_core.rate_limit" -_CD_NAME = "hexi.plugins.nonebot_plugin_hexi_core.cooldown" +_RL_NAME = "hexi.core.rate_limit" +_CD_NAME = "hexi.core.cooldown" def _load_module(name: str, path: Path): diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py index 22da168..5ba2a10 100644 --- a/tests/test_rate_limit.py +++ b/tests/test_rate_limit.py @@ -15,8 +15,7 @@ import pytest _MODULE_PATH = ( Path(__file__).resolve().parents[1] / "hexi" - / "plugins" - / "nonebot_plugin_hexi_core" + / "core" / "rate_limit.py" )