Add HeXi bot codebase: custom plugins, web frontends, tests
- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# 插件模板(MTSS 结构)
|
||||
|
||||
按《插件结构标准 + 插件配置文件标准》编写的可复制插件骨架,含配置标准 + Web 子应用样例。
|
||||
|
||||
## 结构(MTSS)
|
||||
|
||||
```text
|
||||
nonebot_plugin_template/
|
||||
├── __init__.py # 入口(薄): require + meta + 导入 + config.register_config() + register_web_plugin
|
||||
├── config.py # 配置注册(register_model_config / register_config_items / register_object_set)
|
||||
├── handlers/ # Trigger 层
|
||||
│ ├── __init__.py
|
||||
│ └── entry.py # on_command 示例
|
||||
├── services/ # Service 层(不 import nonebot)
|
||||
│ └── main.py
|
||||
├── models.py # 模型/异常
|
||||
├── repository.py # Model 数据访问(唯一读写入口)
|
||||
├── web/ # 可选: 插件自带 Web 子应用(FastAPI)
|
||||
│ ├── __init__.py
|
||||
│ └── admin.py # build_admin_app() → 挂载到 /api/template
|
||||
├── data/ # 运行时数据
|
||||
└── utils/ # 纯工具
|
||||
```
|
||||
|
||||
## 使用
|
||||
1. 把 `nonebot_plugin_template` 目录复制到 `hexi/plugins/nonebot_plugin_<name>`。
|
||||
2. 全局搜索替换 `nonebot_plugin_template` → `nonebot_plugin_<name>`,包括 `_PLUGIN_ID` 相关说明。
|
||||
3. 在 `__init__.py` 改 `__plugin_meta__` 的 name/description/usage,并把 `register_web_plugin("template", ...)` 的 id/name/icon 改掉;Web id 只能使用安全的短标识,不要直接使用带点的模块名。
|
||||
4. 在 `config.py` 改成你要的配置项;在 `handlers/entry.py` 改成你的命令/触发。
|
||||
5. 重启 bot;/hub 插件页能看到配置,点「打开 Web 页面」进入 `/hub/template`(子应用挂在 `/api/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`)。
|
||||
|
||||
## 约定
|
||||
- `plugin_id` = 模块名(自动取 `__package__`;web 子应用用 `__package__.rsplit(".", 1)[0]`)。
|
||||
- Trigger/View 可 import nonebot;Service/Model/repository 尽量不 import nonebot。
|
||||
- 配置默认值写进 schema,Web 自动回填;敏感字段 `secret=True`。GET 只显示 `****`;保存配置时带上 GET 返回的 `revision`,修改冲突会返回 409。
|
||||
- 同一 `plugin_id` 多次注册会自动合并(getter/apply 组合),但字段 key 应由单一注册者负责,避免定义冲突。
|
||||
- Service 中的文件/数据库访问应使用异步 repository 或 `asyncio.to_thread`,不要阻塞事件循环。
|
||||
@@ -0,0 +1,38 @@
|
||||
"""<插件名> — MTSS 结构 + 统一配置标准参考模板。
|
||||
|
||||
放在 hexi/plugins/ 后改名:目录 nonebot_plugin_<name>,并替换所有
|
||||
`nonebot_plugin_template` / `template` 占位(建议全局搜索替换)。
|
||||
|
||||
结构:Trigger(handlers) → Service(services) → Model(repository/models)。
|
||||
配置在 config.py 统一注册,Web 可读可改。
|
||||
"""
|
||||
|
||||
from nonebot import require
|
||||
from nonebot.plugin import PluginMetadata
|
||||
|
||||
require("nonebot_plugin_alconna")
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="插件模板",
|
||||
description="MTSS 结构 + 统一配置标准参考模板",
|
||||
usage="模板指令 [参数]",
|
||||
type="application",
|
||||
)
|
||||
|
||||
# 入口只做四件事:require / meta / 导入子模块 / 配置注册
|
||||
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
|
||||
|
||||
_config.register_config()
|
||||
|
||||
# 注册到统一 Web 管理台(/hub):hub 启动时自动挂载 /api/template
|
||||
register_web_plugin(
|
||||
"template",
|
||||
"插件模板",
|
||||
"user",
|
||||
lambda: _web.build_admin_app(),
|
||||
module_name=__name__,
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""配置注册:按《插件配置文件标准》统一接入 Web 管理台。
|
||||
|
||||
此处演示三类:
|
||||
1) register_model_config → 读 .env / NoneBot config 的 pydantic Config
|
||||
2) register_config_items → 来源无关(模块常量)
|
||||
3) register_object_set → 一组结构化条目(数组/对象集合)
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
# plugin_id = NoneBot 插件模块名
|
||||
_PLUGIN_ID = __package__
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
enable: bool = True
|
||||
reply: str = "你好,我是模板插件!"
|
||||
|
||||
|
||||
config = get_plugin_config(Config)
|
||||
|
||||
# —— 来源无关示例:模块级常量(Web 可读可改) ——
|
||||
_COLOR = "red"
|
||||
|
||||
|
||||
def _get_color() -> str:
|
||||
return _COLOR
|
||||
|
||||
|
||||
def _set_color(v: str) -> None:
|
||||
global _COLOR
|
||||
_COLOR = v
|
||||
|
||||
|
||||
# —— 对象集示例:一组条目(如服务器/关键词/白名单) ——
|
||||
_ITEMS: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _obj_getter() -> list[dict]:
|
||||
return [{"name": k, "value": v.get("value", "")} for k, v in _ITEMS.items()]
|
||||
|
||||
|
||||
def _obj_setter(rows: list[dict]) -> None:
|
||||
_ITEMS.clear()
|
||||
for row in rows:
|
||||
_ITEMS[str(row["name"])] = {"value": row.get("value", "")}
|
||||
|
||||
|
||||
def register_config() -> None:
|
||||
# 1) env/pydantic 配置
|
||||
register_model_config(
|
||||
_PLUGIN_ID,
|
||||
config,
|
||||
fields=["enable", "reply"],
|
||||
labels={"enable": "启用", "reply": "回复内容"},
|
||||
descriptions={"enable": "关闭后指令直接提示已停用", "reply": "模板指令的默认回复"},
|
||||
)
|
||||
|
||||
# 2) 来源无关(模块常量)
|
||||
register_config_items(
|
||||
_PLUGIN_ID,
|
||||
[
|
||||
{
|
||||
"key": "color",
|
||||
"label": "主题色",
|
||||
"type": "string",
|
||||
"getter": _get_color,
|
||||
"setter": _set_color,
|
||||
"secret": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
# 3) 对象集(数组/结构化条目)
|
||||
register_object_set(
|
||||
_PLUGIN_ID,
|
||||
"dict_items",
|
||||
[
|
||||
{"key": "name", "label": "名称", "type": "string"},
|
||||
{"key": "value", "label": "值", "type": "string"},
|
||||
],
|
||||
key_field="name",
|
||||
getter=_obj_getter,
|
||||
setter=_obj_setter,
|
||||
label="条目表",
|
||||
# nosave=True 表示权威源在插件自身(文件/DB);此处写进 plugin_config.json,故为 False
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""handlers 包:触发器(Trigger)层。"""
|
||||
|
||||
from .entry import cmd # noqa: F401
|
||||
@@ -0,0 +1,29 @@
|
||||
"""触发器(Trigger)层:接触发 → 鉴权 → 调 service → 出 View。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nonebot import on_command
|
||||
from nonebot.adapters import Message
|
||||
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 ..services.main import do_something
|
||||
|
||||
_PLUGIN_ID = __package__.rsplit(".", 1)[0]
|
||||
|
||||
cmd = on_command("模板指令", aliases={"模板"}, rule=to_me(), priority=10, block=True)
|
||||
|
||||
|
||||
@cmd.handle()
|
||||
async def handle(arg: Message = CommandArg()):
|
||||
if not get_effective_value(_PLUGIN_ID, "enable", True):
|
||||
await cmd.finish("模板插件已停用")
|
||||
return
|
||||
text = arg.extract_plain_text().strip()
|
||||
if not text:
|
||||
await cmd.finish("请提供要记录的内容")
|
||||
result = await do_something(text)
|
||||
await UniMessage.text(result).send()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""模型/异常定义(可选)。"""
|
||||
|
||||
class TemplateError(Exception):
|
||||
"""模板插件业务异常。"""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Model 层的数据访问:唯一读写入口(此处用 JSON 文件示例)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Runtime data belongs under hexi/data, not beside importable plugin code.
|
||||
_DATA_DIR = Path(__file__).resolve().parents[2] / "data" / "nonebot_plugin_template"
|
||||
_STORE = _DATA_DIR / "store.json"
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
if _STORE.exists():
|
||||
try:
|
||||
return json.loads(_STORE.read_text("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = _STORE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), "utf-8")
|
||||
os.replace(tmp, _STORE) # 原子写
|
||||
|
||||
|
||||
def get_record(key: str):
|
||||
return _load().get(key)
|
||||
|
||||
|
||||
def save_record(key: str, value) -> None:
|
||||
data = _load()
|
||||
data[key] = value
|
||||
_save(data)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""services 包:业务编排层(不应 import nonebot)。"""
|
||||
|
||||
from .main import do_something # noqa: F401
|
||||
@@ -0,0 +1,20 @@
|
||||
"""业务逻辑层:一个业务动作一个方法;不依赖 nonebot(可单测)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hexi.web_config import get_effective_value
|
||||
|
||||
from ..repository import get_record, save_record
|
||||
|
||||
_PLUGIN_ID = __package__.rsplit(".", 1)[0]
|
||||
|
||||
|
||||
async def do_something(text: str) -> str:
|
||||
"""示例业务:记录输入并返回带配置的回复。"""
|
||||
save_record("last_input", text)
|
||||
|
||||
prefix = get_effective_value(_PLUGIN_ID, "reply", "你好,我是模板插件!")
|
||||
record = get_record("last_input")
|
||||
if record:
|
||||
return f"{prefix} 已记录:{record}"
|
||||
return prefix
|
||||
@@ -0,0 +1,4 @@
|
||||
"""纯工具:无副作用、尽量不 import nonebot,可单测。"""
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
return text.lower().replace(" ", "-")
|
||||
@@ -0,0 +1,3 @@
|
||||
"""web 子应用:插件自带管理后台(FastAPI),由 hub 自动挂载到 /api/<id>。"""
|
||||
|
||||
from .admin import build_admin_app # noqa: F401
|
||||
@@ -0,0 +1,40 @@
|
||||
"""插件 Web 子应用样例。
|
||||
|
||||
- 由 `__init__.py` 里 `register_web_plugin("template", ..., module_name=__name__)` 注册,
|
||||
hub 启动时自动挂载到 `/api/template`。
|
||||
- 鉴权统一走 `hexi.web_auth.require_admin`(OAuth2 + SQLite);注意它本身是 FastAPI 的
|
||||
`Depends` 实例,直接用 `_: dict = API` 注入,不要再包一层 `Depends(...)`。
|
||||
- 可直接读写 `hexi.web_config` 的插件配置。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from hexi import web_config
|
||||
from hexi.web_auth import require_admin as API
|
||||
|
||||
# 插件模块名(去掉 .web):= NoneBot 模块名 = plugin_id
|
||||
_PLUGIN_ID = __package__.rsplit(".", 1)[0]
|
||||
|
||||
|
||||
def build_admin_app() -> FastAPI:
|
||||
app = FastAPI(title="插件模板管理")
|
||||
|
||||
@app.get("/")
|
||||
async def index(_: dict = API) -> HTMLResponse:
|
||||
return HTMLResponse("<h1>插件模板 · Web 子应用</h1><p>鉴权已生效。</p>")
|
||||
|
||||
@app.get("/ping")
|
||||
async def ping(_: dict = API):
|
||||
return {"ok": True, "plugin": _PLUGIN_ID}
|
||||
|
||||
@app.get("/config")
|
||||
async def get_config(_: dict = API):
|
||||
cfg = web_config.get_config(_PLUGIN_ID)
|
||||
if cfg is None:
|
||||
return {"ok": False, "msg": "未注册配置 schema"}
|
||||
return {"ok": True, "plugin": _PLUGIN_ID, **cfg}
|
||||
|
||||
return app
|
||||
Reference in New Issue
Block a user