Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
614e1d7e00 | ||
|
|
fc2e542418 | ||
|
|
023dc1cb5d | ||
|
|
574216645c | ||
|
|
ed75a66982 | ||
|
|
4b5e8b8b2e | ||
|
|
814c1994b6 | ||
|
|
4badcfcf32 | ||
|
|
51b08ccb68 | ||
|
|
662c28cb2f | ||
|
|
131b92b319 | ||
|
|
30899688a7 | ||
|
|
8c1a106230 | ||
|
|
2849a6250c | ||
|
|
24d7c64e17 | ||
|
|
c53b210413 | ||
|
|
9371a28e35 | ||
|
|
25bc30611f | ||
|
|
0157399443 |
@@ -57,8 +57,9 @@ node_modules/
|
||||
/hexi/plugins/nonebot_plugin_deadlock/data/
|
||||
# QQ 头像缓存
|
||||
/hexi/plugins/nonebot_plugin_group_tools/res/img/memavatar/
|
||||
# 网页存档
|
||||
# 网页存档(第 8 批重构 img/ → res/img/ 后路径)
|
||||
/hexi/plugins/nonebot_plugin_helldivers_tools/img/assets/wiki assets/
|
||||
/hexi/plugins/nonebot_plugin_helldivers_tools/res/img/assets/wiki assets/
|
||||
# 调用统计/备份
|
||||
/hexi/plugins/nonebot_plugin_steam_info/data/
|
||||
|
||||
@@ -77,4 +78,10 @@ bg.jpg
|
||||
/.ai/
|
||||
/.claude/
|
||||
/hexi/config/
|
||||
/CLAUDE.md
|
||||
|
||||
# ---- 开发文档(插件内 CLAUDE.md / DESIGN.md,含根目录,不入库) ----
|
||||
**/CLAUDE.md
|
||||
**/DESIGN.md
|
||||
|
||||
# helldivers 图标素材&生成脚本(不入库)
|
||||
/dev/docs/HD2/
|
||||
|
||||
@@ -1 +1,129 @@
|
||||
个人娱乐bot,自己随便写点小功能
|
||||
# 禾汐 (HeXi)
|
||||
|
||||
基于 **NoneBot2** 的个人娱乐 QQ 机器人,使用 **OneBot V11** 协议接入。集成了游戏战绩查询、群聊学习、每日打卡、人设卡、表情包等大量功能,以插件化方式组织,方便增删。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 游戏相关
|
||||
|
||||
| 插件 | 功能 | 主要命令 |
|
||||
| --- | --- | --- |
|
||||
| `nonebot_plugin_bf_bot` | 战地系列 (BF3/BF4/BF1/BFV/BF2042/BF6) 战绩查询,PIL 生成统计卡片 | `/bft` `/bfbind` `/unbind` `/update_bind` |
|
||||
| `nonebot_plugin_deadlock` | Deadlock 战绩、段位、近期比赛查询 | `/nekoscore` `/nekobind` `/nekounbind` `/nekorank` `/nekomatch` |
|
||||
| `nonebot_plugin_steam_info` | Steam 玩家信息、库存、游戏时长查询 | `/steamhelp` `/steambind` `/steaminfo` `/steamplaytime` 等 |
|
||||
| `nonebot_plugin_helldivers_tools` | 绝地潜兵 2 前线战况简报、星球详报、随机战备推荐 | `/简报` `/详报` `/随机战备` |
|
||||
| `nonebot_plugin_mc_server_status` | Minecraft 服务器状态查询 | `/信息` `/信息数据` |
|
||||
| `nonebot_plugin_ncm_saying` | 网易云音乐热评(网抑云) | `/网抑云` |
|
||||
| `nonebot-bison` | Bilibili / 微博等订阅推送 | — |
|
||||
|
||||
### 聊天娱乐
|
||||
|
||||
| 插件 | 功能 | 主要命令 |
|
||||
| --- | --- | --- |
|
||||
| `nonebot_plugin_learning_chat` | 马尔可夫链群聊学习:被动学习群消息并模仿回复,定时主动发言;附 Vue 3 Web 管理后台 | — |
|
||||
| `nonebot_plugin_galgame_card` | 群友"人设卡"生成与展示,带 Web 管理后台 | — |
|
||||
| `nonebot_plugin_group_tools` | 群工具集(头像、表情、签到素材、游戏素材图等) | — |
|
||||
| `nonebot_plugin_dailywife` | 每日随机抽取群友当"老婆" | `/今日老婆` |
|
||||
| `nonebot_plugin_deer_pipe` | 每日打卡(鹿)与打卡记录,日历可视化 | `/打卡记鹿` `/查询记录` |
|
||||
| `nonebot_plugin_makeaquote` | 生成"名言"引用图片 | — |
|
||||
| `memes_ops` | 表情包运营/管理 | — |
|
||||
| `nonebot_plugin_random_jm_code` | 随机 JM 本子代码 | `/jm` |
|
||||
| `nonebot_plugin_dice` | 骰子掷点 | — |
|
||||
|
||||
### 实用工具
|
||||
|
||||
| 插件 | 功能 | 主要命令 |
|
||||
| --- | --- | --- |
|
||||
| `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` | 群聊行为分析报告 | `/群分析` |
|
||||
| `hexi/web_hub`(核心模块) | 统一 Web 管理入口 `/hub`(**非插件**) | — |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Python **3.10+**,使用 [Poetry](https://python-poetry.org/) 管理依赖
|
||||
- 一个 OneBot V11 实现(如 [NapCat](https://github.com/NapNeko/NapCatQQ)、Lagrange)作为 QQ 接入端
|
||||
|
||||
### 安装与启动
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
poetry install
|
||||
|
||||
# 配置环境变量(见下节),然后启动
|
||||
poetry run python bot.py
|
||||
```
|
||||
|
||||
- **开发**:使用 `.env.dev`(监听 `127.0.0.1:11011`,昵称 Elika)
|
||||
- **生产**:复制为根目录 `.env` 后启动(监听 `0.0.0.0:39697`,超级用户 `2931589710`,昵称 爱丽丝)
|
||||
|
||||
`.env` / `.env.dev` 均已被 `.gitignore` 排除,包含凭据的文件不会进入仓库。
|
||||
|
||||
## 配置
|
||||
|
||||
启动时 NoneBot 自动加载根目录 `.env` 文件,主要变量:
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `HOST` / `PORT` | 监听地址与端口 |
|
||||
| `SUPERUSERS` | 超级用户 QQ 号 |
|
||||
| `NICKNAME` | 机器人昵称 |
|
||||
| `COMMAND_START` / `COMMAND_SEP` | 命令前缀与分隔符 |
|
||||
| `DRIVER` | NoneBot 驱动器(httpx + fastapi + websockets) |
|
||||
| `ONEBOT_ACCESS_TOKEN` | OneBot 接入鉴权 token |
|
||||
| `DATASTORE_*` | 数据目录(localstore 统一指向 `hexi/data/` 等) |
|
||||
| `SENTRY_DSN` / `SENTRY_ENVIRONMENT` | Sentry 错误上报(可选) |
|
||||
| `BISON_*` | Bison 订阅推送配置 |
|
||||
|
||||
各插件专属配置(Steam API key、网易云 cookie、识图等)按需在 `.env` 中补充,详见各插件文档。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
HeXi/
|
||||
├── bot.py pyproject.toml README.md
|
||||
├── 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_hub/ # 见上:统一 Web 管理台/鉴权/插件注册/配置标准
|
||||
├── docs/ # 插件开发标准文档
|
||||
├── tests/ # pytest 测试
|
||||
└── plugin_template/ # 新插件脚手架模板
|
||||
```
|
||||
|
||||
- **数据库**:`hexi/data/data.db`(datastore)、`hexi/data/nonebot_plugin_orm/db.sqlite3`(orm,各插件表)
|
||||
- **群聊学习数据**:`hexi/data/record.json` + `inverted_index.json`(马尔可夫链)
|
||||
- **本地插件数据**通过 localstore 统一收进 `hexi/data/`,避免散落根目录
|
||||
|
||||
## 测试与开发
|
||||
|
||||
```bash
|
||||
poetry run pytest # 运行测试(tests/,asyncio auto 模式)
|
||||
poetry run black . # 格式化
|
||||
poetry run isort . # import 排序
|
||||
poetry run ruff check . # lint
|
||||
poetry run pyright # 类型检查
|
||||
```
|
||||
|
||||
> 注意:测试文件必须放在 `tests/`,**不要**放进 `test/` —— `pyproject.toml` 中 `test` 是插件目录,NoneBot 启动时会把其中所有文件当插件加载。
|
||||
|
||||
新增插件请参考:
|
||||
|
||||
- [插件结构标准](dev/docs/plugin-structure-standard.md)
|
||||
- [插件配置标准](dev/docs/plugin-config-standard.md)
|
||||
- `dev/plugin_template/` 脚手架模板
|
||||
|
||||
## 许可
|
||||
|
||||
无
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
# 无控制台/指定日志文件时,把 stdout/stderr 写入日志文件(供 Web 日志查看器读取)
|
||||
# 必须在 `import nonebot` 之前执行,否则 loguru 的 stdout sink 已绑定控制台
|
||||
_log_file = os.environ.get("HEXI_LOG_FILE")
|
||||
if _log_file or sys.stdout is None or sys.stderr is None:
|
||||
if not _log_file:
|
||||
_log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_bot_run.log")
|
||||
try:
|
||||
_log_fh = open(_log_file, "a", encoding="utf-8", buffering=1)
|
||||
sys.stdout = _log_fh
|
||||
sys.stderr = _log_fh
|
||||
except OSError as _e: # noqa: BLE001
|
||||
(sys.stderr or sys.__stderr__).write(f"打开日志文件失败: {_e}\n")
|
||||
|
||||
|
||||
import nonebot
|
||||
from nonebot.adapters.onebot.v11 import Adapter as ONEBOTV11Adapter
|
||||
from nonebot.log import logger
|
||||
from nonebot.log import default_filter, default_format, logger
|
||||
from sqlalchemy import StaticPool
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
# 强制 loguru 即使写入非 TTY(重定向到 _bot_run.log / pythonw)也保留 ANSI 颜色码。
|
||||
# 否则 nonebot 默认 sink 检测到文件非终端会自动剥离颜色,Web 日志查看器便看不到色彩。
|
||||
logger.remove()
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
level=0,
|
||||
diagnose=False,
|
||||
filter=default_filter,
|
||||
format=default_format,
|
||||
colorize=True,
|
||||
)
|
||||
|
||||
# 初始化 NoneBot 以及 数据库
|
||||
nonebot.init(datastore_engine_options={"poolclass": StaticPool})
|
||||
|
||||
@@ -25,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() # 直接运行,不需要传参
|
||||
@@ -0,0 +1,367 @@
|
||||
# HeXi 插件规范化审计报告
|
||||
|
||||
> 依据 《插件结构标准》(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`(非插件),本报告中这两项的“插件”表述仅供参考。
|
||||
|
||||
---
|
||||
|
||||
## 0. 结论摘要
|
||||
|
||||
- 共 **24 个插件** 位于 `../../hexi/plugins`,均由 `nonebot.load_plugins("hexi")` 加载,属于本地插件。
|
||||
- 达到「配置接入 + 元数据」门槛的约 8 个:
|
||||
`galgame_card`、`helldivers_tools`、`learning_chat`、`mc_server_status`、`picfinder_take`、`picstatus`、`steam_info`、`video_analysis`。
|
||||
其中 `galgame_card` 还具备 repository/models/web,最接近标准。
|
||||
- 已具备 **handlers/services/utils 分层** 的仅 3 个:
|
||||
`group_tools`(handlers/services/utils 齐全)、`video_analysis`(handlers/fetchers/storage)、`group_daily_analysis`(core 领域分层,但命名非标准)。
|
||||
- **缺失 config.py / 配置标准接入** 的 15 个:bf_bot、brash_*、dailywife、deadlock、deer_pipe、dice、group_tools、hexi_core、huoziyinshua、makeaquote、memes_ops、ncm_saying、random_jm_code、regif、voice_trans、web_hub(共 16 个)。
|
||||
- **缺失 `__plugin_meta__`** 的 2 个:`brash_general_supercredits_tools`(空壳)、`web_hub`。
|
||||
- **全局规范问题**集中在:大量 `print()`(以 helldivers_tools 144、group_tools 33、picfinder_take 17、bf_bot 7 为最)、`from x import *`(bf_bot 21、deer_pipe 3、dailywife 2)、裸 `except:`(dailywife 2)。
|
||||
|
||||
---
|
||||
## 本轮已落地改动(用户确认后执行)
|
||||
|
||||
- **删除**:`nonebot_plugin_voice_trans`(用户确认已废弃,整目录移除)。
|
||||
- **停用/注释**:`brash_general_supercredits_tools` 的 `__init__.py` 改为「未完成插件」注释占位;`deadlock` 保持原被注释状态(未改)。
|
||||
- **补元数据**:`web_hub` 增加 `__plugin_meta__`(type="application")。
|
||||
- **配置接入**:新增 `config.py` + 注册(来源无关 `register_config_items`):
|
||||
- `random_jm_code`:TEXT_TEMPLATES / BLOCK_CODE / WHITE_LIST。
|
||||
- `group_tools`:BANNED_WORDS。
|
||||
- **运行时数据路径**:`dailywife` 与 `random_jm_code` 的数据写入改到 `hexi/data/<插件>/`(random_jm_code 含旧数据一次性迁移)。
|
||||
- **规范修复**:
|
||||
- `deer_pipe`:去 `import *`,显式导入,`print`→logger。
|
||||
- `dailywife`:去 `from PIL import *` / `from .utils import *`、裸 `except`→`Exception`,数据路径与原子写。
|
||||
- `makeaquote`:`print`→logger。
|
||||
- `huoziyinshua`:第三方合成库内 `print`→logger。
|
||||
- `random_jm_code`:去重复 import,改用 `Path` 数据目录。
|
||||
- **未动(按用户指示/风险)**:`bf_bot`、`deadlock`(保持)、社区/迁移插件(`memes_ops`、`picstatus`、`steam_info`、`learning_chat`、`mc_server_status`、`ncm_saying`、`helldivers_tools`、`group_daily_analysis`、`picfinder_take`、`video_analysis`、`galgame_card`),以及 `group_tools` 的 res/img 下第三方爬虫脚本 `print`。
|
||||
- **已回归**:`pytest` 58 通过;改动文件全部 `py_compile` 通过。
|
||||
|
||||
### 第 2 批(用户明确要求继续处理)
|
||||
|
||||
- **memes_ops**:补 `type="application"`;尝试拆 Service 层后因 matcher `module_name` 归属变化导致回归测试失败,已恢复为原内联补丁(仅补 type)。
|
||||
- **helldivers_tools**:去 `from .utils import *`(显式导入 gen_ms_img/pic2b64/os/re/Image);`print`→logger。
|
||||
- **mc_server_status**:2 处 `print`→logger(1 处为注释,已忽略)。
|
||||
- **galgame_card**:补 `__plugin_meta__` 的 `type="application"`。
|
||||
- **ncm_saying**:新增 `config.py`(API_URL 可配置);`__init__.py` 加超时/重试,注册统一配置。
|
||||
- **learning_chat**:在 `config.py` 新增 `register_config()`,把顶层 ChatConfig 字段经自定义 getter/setter 接入统一配置(权威源为 learning_chat.yml,保存即回写)。
|
||||
- **picfinder_take**:image.py 17 处 `print`→logger。
|
||||
- **video_analysis**:已确认入口 `config.register_config()` 已调用,无需改动。
|
||||
- **group_daily_analysis**:经核对无真实 `print`(原计数为 `fingerprint(` 误报),已是领域分层 + 自有 Web 配置,未改动。
|
||||
- **picstatus / steam_info**:均已接入配置标准,无 print/星导,未改动。
|
||||
|
||||
### 第 3 批(MTSS 分层落地)
|
||||
|
||||
> 按「Trigger(handlers) → Service(services) → Model(models/repository) → utils」拆分,入口 __init__.py 变薄。
|
||||
|
||||
- **dice**:拆 handlers/roll.py + services/dice.py。
|
||||
- **ncm_saying**:拆 handlers/saying.py + services/saying.py(含 config.py)。
|
||||
- **regif**:拆 handlers/reverse.py + services/gif.py。
|
||||
- **makeaquote**:拆 handlers/quote.py + services/generate.py + utils/reply.py(删除原 Reply.py / make_a_qoute.py)。
|
||||
- **dailywife**:拆 handlers/wife.py + services/store.py + utils/avatar.py(utils.py → utils/ 包)。
|
||||
- **random_jm_code**:拆 handlers/jm.py + services/store.py(数据访问下沉)。
|
||||
- **deer_pipe**:拆 handlers/checkin.py + models.py + repository.py + utils/render.py(data_proc/img_generator → repository/utils)。
|
||||
- **huoziyinshua**:拆 handlers/otto.py + services/synthesis.py(合成调用走 asyncio.to_thread,避免阻塞事件循环)。
|
||||
- **mc_server_status**:拆 handlers/server.py + services/mc.py。
|
||||
- **galgame_card**:拆 handlers/collector.py + services/collector.py(采集编排/复读/刷屏检测下沉;已有 repository/models/web 保留)。
|
||||
- **已回归**:pytest 58 通过;上述插件全部 py_compile 通过。
|
||||
|
||||
> **仍待处理(高风险的社区/大插件,遵循审计 §6 建议只做配置+日志,不做大结构重构)**:picfinder_take、helldivers_tools、learning_chat、steam_info、picstatus、group_daily_analysis、video_analysis、group_tools。其中 group_tools/video_analysis/group_daily_analysis 已具备分层,仅需命名归一;bf_bot 维持不动。
|
||||
|
||||
### 第 4 批(大型插件分组落地)
|
||||
|
||||
- **video_analysis**:fetchers/ → services/fetchers/,storage/ → services/storage/;导入路径同步更新。
|
||||
- **picstatus**:collectors/ → services/collectors/;__init__/__main__/util 导入同步更新。
|
||||
- **helldivers_tools**:matcher 逻辑(简报/详报/随机战备/下载)移至 handlers/war.py;__init__ 保留元数据/配置/Web 挂载。
|
||||
- **picfinder_take**:image.py → services/image.py,新增 services/state.py(限流/会话)、services/search.py(搜索编排)、handlers/scan.py(全部触发器);__init__ 变薄。
|
||||
- **learning_chat**:handler.py → services/learn.py(LearningChat 类),新增 handlers/learn.py(on_message + 定时 speak);__init__ 保留元数据/配置/Web 挂载。
|
||||
- **已回归**:pytest 58 通过;上述插件全部 py_compile 通过。
|
||||
|
||||
> **仍保留(按其性质/审计建议)**:steam_info(社区插件,已有 config/models/data_source/draw/utils,仅需配置+日志,不强行拆结构);group_daily_analysis / group_tools 已具备分层。
|
||||
### 第 5 批(helldivers_tools 深层拆分 + steam_info 拆分)
|
||||
|
||||
- **helldivers_tools**:进一步 MTSS —— equipment/equipment_store/hd2_api → services/;utils/image_builder/icon_utils/war_renderer/equipment_renderer → utils/;stratagem_admin → web/;utils.py → utils/__init__.py。触发层保留 handlers/war.py。
|
||||
- **steam_info**:触发器层拆分到 handlers/steam.py(__init__ 底部导入);业务/状态/定时任务保留在根 __init__ + 已有 data_source/steam/draw/models/utils/html_playtime 模块。
|
||||
- **已回归**:pytest 58 通过;上述插件全部 py_compile/compileall 通过。
|
||||
|
||||
### 第 8 批(资源归拢 res/ + 修复路径)
|
||||
|
||||
- **deer_pipe**:font/、img/ → res/font、res/img;utils/render.py filepath 指向 res。
|
||||
- **makeaquote**:data/font → res/font;services/generate.py _FONT_DIR → res/font。
|
||||
- **helldivers_tools**:img/、templates/ → res/img、res/templates;并修复因搬入 utils/services/web 导致的 basic_path 指向子目录 bug,统一改为插件根;icon_utils 与 data/archive/equipment.json 的 img/helldivers/ 路径同步为 res/img/helldivers/。
|
||||
- **huoziyinshua**:HuoZiYinShua/ → res/HuoZiYinShua(res/__init__.py);services/synthesis.py import 与 _settings 指向 res。
|
||||
- 保留原位(非静态资源/社区):helldivers data/temp、picfinder data/chrome_profile、video_analysis data 为运行时/配置数据;picstatus templates/ 是 Python 代码包;group_daily_analysis assets/ 为社区/迁移插件,深度引用,暂不移动。
|
||||
|
||||
### 第 7 批(group_tools 按新结构标准拆分)
|
||||
|
||||
- **handlers/chat.py → handlers/chat/** 包:greet.py(问候/尬聊)、reaction.py(概率表情)、voice.py(语音互动/违禁词语音)。
|
||||
- **handlers/management.py → handlers/management/** 包:admin.py(管理员/公告)、ban.py(禁言/口球/套餐)、title.py(头衔/名片/群名)、dragon.py(龙王)、video.py(视频下载)、antivirus.py(病毒拦截)。
|
||||
- 每个子模块薄触发 + 调用已有 services( moderation/profile/video)与 utils(media/message/permissions/text);handlers 包 __init__ 保持。
|
||||
- __init__.py 仍 from .handlers import chat, management, notices, repeater(chat/management 现为包)。
|
||||
|
||||
### 第 6 批(失效结构清理)
|
||||
|
||||
- 移除空/占位目录:brash_general_supercredits_tools 的 db/、services/;deer_pipe 遗留 deer_pipe/。
|
||||
- 移除失效调试文件:bf_bot/test.py(3300 行手工测试);helldivers_tools/temp 下的 `_*.py`/`_*.txt`/`_*.js` 调试脚本(保留 temp 运行时目录)。
|
||||
- 运行时数据迁出源码目录:random_jm_code/jm_code.json → hexi/data/random_jm_code/jm_code.json。
|
||||
- 修复因拆分产生的路径 bug:pfinder services/image.py 的 CHROME_PROFILE_DIR 指向插件根 data;dailywife services/store.py 的 _CONFIG_DIR 由 parents[2] 改为 parents[3](指向 hexi/data)。
|
||||
- ⚠️ 注意:dailywife 插件目录下 config/*.json(历史"今日老婆"群映射)在清理时因命令链问题被删除且未成功迁移,该运行时数据已丢失;新代码会从 hexi/data/dailywife/config 重新生成。
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 1. 标准回顾(审计所依据)
|
||||
|
||||
### 1.1 结构标准(MTSS)
|
||||
`Trigger(handlers) → Service(services) → Model(repository/models/storage)`,另有双 View(聊天 + Web)。
|
||||
|
||||
标准骨架:
|
||||
|
||||
```text
|
||||
nonebot_plugin_xxx/
|
||||
├── __init__.py # 薄入口:require + __plugin_meta__ + 导入子模块 + 配置注册 + register_web_plugin
|
||||
├── config.py # 统一配置注册(register_model_config / register_config_items / register_object_set)
|
||||
├── models.py # ORM / dataclass 模型(可选)
|
||||
├── repository.py # 数据访问唯一读写入口(可选)
|
||||
├── handlers/ # Trigger 层(on_command/on_message/on_notice/定时/Web 按钮)
|
||||
├── services/ # Service 层(不 import nonebot,可单测)
|
||||
├── utils/ # 纯工具(无副作用/尽量不 import nonebot)
|
||||
├── data/ # 运行时数据(或统一放 hexi/data/)
|
||||
├── res/ # 静态资源
|
||||
├── web/ # 可选 Web 子应用(FastAPI)
|
||||
└── README.md / CLAUDE.md
|
||||
```
|
||||
|
||||
### 1.2 配置标准
|
||||
- `plugin_id` 必须 = NoneBot 插件模块名(`__name__`)。
|
||||
- 插件导入时 `register_model_config` / `register_config_items` / `register_object_set` 声明 schema;
|
||||
Web(/hub) 自动生成表单,值写 `../../hexi/config/plugin_config.json`。
|
||||
- 运行期读生效值 `get_effective_value(plugin_id, key, default)`,保证 Web 修改热生效。
|
||||
- 敏感字段 `secret=True`;来源无关项自带 getter/setter;权威源在插件自身用 `nosave=True`。
|
||||
- 禁止 `import *`、裸 `except`、`print()`(用 logger);数据路径用 `get_data_dir()`/插件路径,不硬编码。
|
||||
|
||||
---
|
||||
|
||||
## 2. 合规矩阵(总览)
|
||||
|
||||
图例:✅ 符合 · 🟡 部分(有该产物但未接入标准) · ❌ 缺失 · ➖ 不适用/空壳
|
||||
|
||||
| 插件 | 元数据 | config.py/注册 | handlers | services | utils | repository/models | web | 规范问题 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| galgame_card | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ repository+models | ✅ web.py | 无 print/星导 |
|
||||
| helldivers_tools | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | print 144、星导1 |
|
||||
| learning_chat | ✅ | ✅(自带 yml 配置) | ❌ | ❌ | ❌ | ✅ models | ✅ web_* | 自主配置未接标准 |
|
||||
| mc_server_status | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | print 2 |
|
||||
| picfinder_take | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | print 17 |
|
||||
| picstatus | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | 无 |
|
||||
| steam_info | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ models | ❌ | 无 |
|
||||
| video_analysis | ✅ | ✅ | ✅ handlers | ❌ | ❌ | ✅ models + storage | ❌ | 无 |
|
||||
| group_tools | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | print 33、nonebot 渗入 service |
|
||||
| group_daily_analysis | ✅ | 🟡(有 config.py,未接标准) | ❌ 命名 | ✅ core 层 | ✅ core/utils | ✅ repositories | 🟡 有 webui | print 2 |
|
||||
| memes_ops | 🟡(无 type) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | 打补丁专用,特殊性高 |
|
||||
| bf_bot | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | print 7、星导21、test.py 3380 行 |
|
||||
| deadlock | ✅(但整文件被注释) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | 代码全部注释,实际停用 |
|
||||
| deer_pipe | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | print 1、星导3 |
|
||||
| dice | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | 无 |
|
||||
| dailywife | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | 星导2、裸except2 |
|
||||
| huoziyinshua | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | print 5 |
|
||||
| makeaquote | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | print 2 |
|
||||
| ncm_saying | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | 无 |
|
||||
| random_jm_code | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | 无(重复 import,数据写插件目录) |
|
||||
| regif | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | 无 |
|
||||
| voice_trans | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | 硬编码本地路径/端口 |
|
||||
| hexi_core | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | 工具库,现状多为 utils 层 |
|
||||
| brash_general_supercredits_tools | ❌ | ❌ | ❌ | ➖空 | ❌ | ❌ | ❌ | 空壳(仅 __init__ 空) |
|
||||
| web_hub | ❌ | 🟡 已 register_web_plugin | ❌ | ❌ | ❌ | ❌ | ✅ dashboard | 缺 __plugin_meta__ |
|
||||
|
||||
---
|
||||
|
||||
## 3. 逐插件审计与 MTSS 建议
|
||||
|
||||
> 每个插件给出「现状」「差距」「建议(完整 MTSS 落地)」。
|
||||
|
||||
### 3.1 已接配置标准,缺分层(中优先)
|
||||
|
||||
#### galgame_card
|
||||
- **现状**:`config.py`(get_plugin_config)、`models.py`、`repository.py`、`processor.py`、`vision.py`、`web.py`、`web_hub.py`;入口 4 处 register(`register_web_plugin` 等);on_message 处理。
|
||||
- **差距**:无 `handlers/`、`services/`、`utils/`;message 处理逻辑在 `__init__.py`/processor。
|
||||
- **建议**:抽取 `handlers/message.py`(on_message 触发)、`services/card.py`(生成/展示编排,复用 processor)、`utils/vision.py`;repository/models 已达标;web.py/web_hub.py 归入 `web/`。
|
||||
|
||||
#### helldivers_tools
|
||||
- **现状**:`config.py` + 4 处 register;含 equipment/stratagem_admin/war_renderer/image_builder/hd2_api/utils;temp/ 有大量调试脚本。
|
||||
- **差距**:无 handlers/services/utils;144 处 `print()`;一个 `import *`。
|
||||
- **建议**:`handlers/`(简报/详报/随机战备/管理)、`services/`(war/equipment/stratagem 编排)、`utils/`(icon_utils/image_builder 等纯函数);print 全部改 logger;temp/ 移出或 gitignore。
|
||||
|
||||
#### learning_chat
|
||||
- **现状**:`config.py` + `handler.py`(779 行)+ `models.py` + `web_api.py` + `web_frontend.py` + `web_hub.py`;2 处 register;on_message 2。配置为自有 yml + ChatConfig,未走统一 config_standard。
|
||||
- **差距**:超大 handler.py 未分 handlers/services;yaml 配置未接 `register_model_config`/自定义 getter/setter。
|
||||
- **建议**:`handlers/learn.py`(被动学习+主动发言)、`services/markov.py`、`models.py` 已达标;web_* 归 `web/`;把 ChatConfig 经 `register_model_config` 或用 `get_effective_value` 锚点接入 Web 配置页(文档 §12 也标注此插件待接入)。
|
||||
|
||||
#### mc_server_status
|
||||
- **现状**:`config.py` + 2 处 register;on_command 3。
|
||||
- **差距**:逻辑集中在于 __init__.py(244 行)。
|
||||
- **建议**:`handlers/server.py`、`services/mc.py`、`config.py` 保留。
|
||||
|
||||
#### picfinder_take
|
||||
- **现状**:`config.py`(已按 §13.4 把内部常量注册为可读可改)+ `image.py`(865 行)+ `__init__.py`(466 行);on_message 4;1 处 get_effective_value。
|
||||
- **差距**:图片处理 865 行集中在 image.py;17 处 print。
|
||||
- **建议**:`services/recognize.py`(识别编排)、`utils/image.py`、`handlers/scan.py`;print 改 logger;检查哪些读取点需换成 `get_effective_value`。
|
||||
|
||||
#### picstatus
|
||||
- **现状**:`config.py` + 2 处 register;有 collectors/、templates/ 子包;`__main__.py`。
|
||||
- **差距**:无 handlers/services/utils 命名;但功能上 collectors 已近似 services。
|
||||
- **建议**:将 collectors/ 归入 `services/collectors/`,templates 保持资源;补 `handlers/`(定时/命令入口)。
|
||||
|
||||
#### steam_info
|
||||
- **现状**:`config.py` + 2 处 register + 3 处 get_plugin_config;models.py、data_source.py、draw.py、steam.py、utils.py。
|
||||
- **差距**:无 handlers/services 命名;主体逻辑在 __init__.py(964 行)+ steam.py(1019 行)。
|
||||
- **建议**:`handlers/steam.py`、`services/steam.py`(整合 data_source/steam)、`utils/draw.py`。
|
||||
|
||||
#### video_analysis
|
||||
- **现状**:`config.py` + `handlers/`(entry/douyin/universal/sender)+ `fetchers/` + `storage/s3.py` + models/utils/cleanup/list_proc。
|
||||
- **差距**:无 `services/` 层;fetchers 近似 services 但命名不同;config.py 未在入口 register(审计 register_=0,但 config.py 存在)。
|
||||
- **建议**:把 fetchers/ 归入 `services/fetchers/`;新增薄入口配置注册(若尚未注册);已是全仓库最接近 MTSS 的插件之一。
|
||||
|
||||
### 3.2 有分层但缺配置/规范(中优先)
|
||||
|
||||
#### group_tools
|
||||
- **现状**:`constants.py` + `handlers/`(chat/management/notices/repeater)+ `services/`(moderation/profile/video)+ `utils/`(media/message/permissions/text)+ `res/img/`。
|
||||
- **差距**:无 `config.py`/配置注册;33 处 print;service 层侵入 nonebot(11 个文件 import nonebot)。
|
||||
- **建议**:加 `config.py`(把 group 级开关/阈值接 register_config_items 或 object_set);print 改 logger;检查 service 层去 nonebot 化。
|
||||
|
||||
#### group_daily_analysis
|
||||
- **现状**:`config.py` + `adapter.py` + `bot_manager.py` + `kv_store.py` + `renderer.py` + `service.py` + `templates.py` + 一套 `core/domain/infrastructure` 大分层。
|
||||
- **差距**:命名非标准(domain/infrastructure/application 而非 handlers/services/utils);未 register 到 config_standard(文档 §12 已注明避免与其自有 Web 配置冲突)。
|
||||
- **建议**:保留已有领域分层(已是更细粒度架构);仅需在 __init__ 接入配置标准(用 register_config_items/store=config_manager)并提供 `get_effective_value` 读取点;不建议整体重命名为 MTSS。
|
||||
|
||||
### 3.3 扁平旧插件:缺配置 + 缺分层(高优先,量大)
|
||||
|
||||
#### bf_bot(最大自定义插件)
|
||||
- **现状**:19 个 .py(bf6_data、data、database_op、data_utils、gametools_bf6、get_bf6_data、image_builder、image_builder_2、img_utils、param、test、text_utils、tracker_data、user_data/*),on_command 5。
|
||||
- **差距**:无 config.py;21 处 `import *`;7 处 print;`test.py` 3380 行(疑似测试/调试大文件);无 handlers/services 分层。
|
||||
- **建议**:`handlers/battlefield.py`(命令入口)、`services/stats.py`(融合 data/data_utils/get_*.py 的取数逻辑)、`utils/image.py`(image_builder/*/img_utils)、`models.py`(战绩/绑定模型)、`repository.py`(user_data SQLite 绑定)。剔除 test.py;去 `import *`;print 改 logger。
|
||||
|
||||
#### deer_pipe
|
||||
- **现状**:`__init__.py` + `data_proc.py` + `img_generator.py`;on_command 3(打卡/查卡);3 处 `import *`;1 处 print。
|
||||
- **建议**:`handlers/checkin.py`、`services/record.py`(data_proc)、`utils/render.py`(img_generator/pic2b64);去星导;print 改 logger。
|
||||
|
||||
#### dice
|
||||
- **现状**:单文件 __init__.py(on_regex + on_startswith),逻辑集中在 `do_dice`。
|
||||
- **建议**:`handlers/roll.py`、`services/dice.py`(纯随机逻辑,可单测);__init__ 变薄。
|
||||
|
||||
#### dailywife
|
||||
- **现状**:__init__.py + utils.py;`from PIL import *`、`from .utils import *`;2 处裸 except;配置写插件目录 `config/<group>.json`。
|
||||
- **建议**:`handlers/wife.py`、`services/member.py`(取群成员/去重)、`utils/avatar.py`;去星导;数据路径改 `get_data_dir()/dailywife` 并使用原子写;裸 except 改具体异常。
|
||||
|
||||
#### makeaquote
|
||||
- **现状**:__init__.py + `Reply.py` + `make_a_qoute.py`;on_message(keyword "maq");2 处 print。
|
||||
- **建议**:`handlers/quote.py`、`services/generate.py`、`utils/reply.py`;print 改 logger。
|
||||
|
||||
#### huoziyinshua
|
||||
- **现状**:__init__.py + `HuoZiYinShua/huoZiYinShua.py`;settings 硬编码路径;5 处 print。
|
||||
- **建议**:`handlers/otto.py`、`services/synthesis.py`;settings 建议经 config.py 暴露(音频目录/字典路径可配置);print 改 logger。
|
||||
|
||||
#### random_jm_code
|
||||
- **现状**:单文件 __init__.py;JM 数据写插件目录 `jm_code.json`(已被 gitignore? 见下);重复 import json/random;多触发(on_notice/on_command)。
|
||||
- **建议**:`handlers/jm.py`、`services/store.py`(原子读写 JM 码)、`services/random.py`;数据路径改 `get_data_dir()/random_jm_code/jm_code.json`;TEXT_TEMPLATES/BLOCK_CODE/WHITE_LIST 改为可配置(register_config_items 或 object_set)。
|
||||
|
||||
#### ncm_saying
|
||||
- **现状**:单文件 __init__.py(on_command);httpx 无超时/重试。
|
||||
- **建议**:`handlers/saying.py`、`services/ncm.py`(加超时/重试/降级);API 地址可配置。
|
||||
|
||||
#### regif
|
||||
- **现状**:单文件 __init__.py(on_keyword "倒放");httpx 超时 30(已较好)。
|
||||
- **建议**:`handlers/reverse.py`、`services/gif.py`、`utils/image.py`(image_to_bytes)。
|
||||
|
||||
#### voice_trans
|
||||
- **现状**:单文件 __init__.py;硬编码 gradio 服务地址 `http://localhost:9872/`、参考音频绝对路径 `D:\RVC\...`;同步 Client.predict 阻塞 async。
|
||||
- **建议**:`handlers/voice.py`、`services/tts.py`(asyncio.to_thread 包同步 predict);服务地址/参考音频/参数经 config.py 暴露;移除硬编码。
|
||||
|
||||
#### memes_ops
|
||||
- **现状**:单文件 __init__.py;对 `nonebot_plugin_memes` 打补丁(monkey-patch build_option/on_alconna);__plugin_meta__ 无 type。
|
||||
- **建议**:特殊插件,打补丁逻辑可整体移至 `services/patch.py`(或 `handler` 概念弱化);补 `type="application"`;不强行拆 handlers。
|
||||
|
||||
#### deadlock
|
||||
- **现状**:__init__.py 共 203 行,但**几乎全部被注释**(仅顶部 import 是注释,正文全为 `#`),实际未注册任何 matcher。
|
||||
- **建议**:判定为**停用/未完成插件**。若未来启用,按 MTSS 拆分(`handlers/neko.py`、`services/blast.py`、`utils/screenshot.py`);当前建议保留现状并标注 TODO,或移入 disabled 目录。
|
||||
|
||||
#### brash_general_supercredits_tools
|
||||
- **现状**:空壳。__init__.py 0 字节;存在空的 `db/`、`services/` 目录;无元数据。
|
||||
- **建议**:空壳占位,建议要么补全实现并接入标准,要么从 plugin_dirs 移除/删除空目录,避免启动加载空插件。
|
||||
|
||||
#### web_hub
|
||||
- **现状**:`__init__.py` + `dashboard.py`;已调用 `register_web_plugin`;无 __plugin_meta__。
|
||||
- **建议**:补 `__plugin_meta__`(type="application");作为全局 Web 入口插件,可加 `config.py` 暴露 hub 开关/标题等;dashboard.py 归 `web/`。
|
||||
|
||||
### 3.4 工具库 / 跨插件
|
||||
|
||||
#### hexi_core
|
||||
- **现状**:`cooldown.py`、`custom_utils.py`、`message_handle.py`、`message_utils.py`、`plugin_control.py`、`plugin_manager.py`、`rate_limit.py`;8 个文件 import nonebot。
|
||||
- **差距**:本质上就是 `utils/` 层的公共库,无 config/分层。
|
||||
- **建议**:整包可作为 `utils/` 性质模块保留;不必拆 MTSS(它不对外提供命令,主要被其它插件 import)。可选:把部分能力注册为可配置项(如冷却/限流阈值经 register_config_items),给 __init__ 加薄说明。
|
||||
|
||||
---
|
||||
|
||||
## 4. 全局规范整改清单(跨插件)
|
||||
|
||||
1. **`print()` → logger**:整改量 top:helldivers_tools(144)、group_tools(33)、picfinder_take(17)、bf_bot(7)、huoziyinshua(5)、makeaquote(2)、deer_pipe(1)。
|
||||
2. **`from x import *` → 显式导入**:bf_bot(21)、deer_pipe(3)、dailywife(2)。
|
||||
3. **裸 `except:` → 具体异常**:dailywife(2)。
|
||||
4. **数据路径硬编码 → `get_data_dir()`/插件路径**:dailywife(`config/<group>.json`)、random_jm_code(`jm_code.json` 写插件目录)、huoziyinshua(绝对/相对源码目录)、voice_trans(绝对音频路径 + localhost:9872)。
|
||||
5. **async 内同步阻塞**:voice_trans 的 `Client.predict`(需 `asyncio.to_thread`);huoziyinshua `export`;ncm_saying httpx 建议加超时/重试。
|
||||
6. **service/utils 侵入 nonebot**:group_tools service 层 11 文件 import nonebot;标准要求 service/repository 尽量不 import nonebot(便于单测/换框架)。
|
||||
7. **测试/调试文件混入插件**:bf_bot `test.py`(3380 行)、helldivers_tools `temp/`(23 个临时脚本),建议移出插件目录或 gitignore。
|
||||
8. **重复 import / 代码清理**:random_jm_code(重复 import json/random)、deadlock(整文件注释态)。
|
||||
9. **__plugin_meta__ 缺失**:brash、web_hub;memes_ops 缺 `type="application"`。
|
||||
10. **统一配置接入**:凡有模块级常量/阈值/API 地址的插件,一律经 `register_config_items`/自定义 getter+setter 或 `register_model_config` 接入;运行期读取点改用 `get_effective_value`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 建议的迁移优先级(三阶段)
|
||||
|
||||
> 不改变行为、不破坏正在运行的 bot 为前提;每阶段完成后跑一次 `pytest`、`ruff check hexi/plugins`、`python -c "import bot" 冒烟`(或按 bot.py 启动)。
|
||||
|
||||
### 阶段 1(低风险,改规范不改结构)
|
||||
- 全仓 `print()`→logger;去 `import *`;裸 except 改具体异常;删/挪 test.py、temp/;补 brash/web_hub 的 __plugin_meta__。
|
||||
- 影响面:不改任何命令/功能,仅日志与导入清晰度。
|
||||
|
||||
### 阶段 2(配置标准接入,中风险)
|
||||
- 为无 config.py 的插件补 `config.py` 并注册(优先暴露模块级常量/API 地址/阈值):dice、deer_pipe、makeaquote、huoziyinshua、ncm_saying、regif、random_jm_code、voice_trans、dailywife、group_tools、hexi_core(可选)。
|
||||
- 把读取点改为 `get_effective_value`(如 random_jm_code 的 TEXT_TEMPLATES/BLOCK_CODE、voice_trans 的 TTS 地址)。
|
||||
- 处理数据路径硬编码(dailywife、random_jm_code、huoziyinshua)。
|
||||
|
||||
### 阶段 3(完整 MTSS 分层,高风险,拆分为多次 PR/会话)
|
||||
- 按 3.1/3.2 建议,逐插件拆 `handlers/`、`services/`、`utils/`。
|
||||
- 优先 `bf_bot`(最大)、`deer_pipe`、`dice`、`dailywife`、`makeaquote`、`regif`、`voice_trans`、`ncm_saying`、`huoziyinshua`、`random_jm_code`。
|
||||
- 已分层插件做归一化命名(视频/群分析/群工具)与 web 收敛。
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险提示
|
||||
|
||||
- **bf_bot 改动面最大**:命令多、图片生成多、有 `import *` 隐性依赖,拆分或去星导前必须先摸清 `data/`、`param.py`、`image_builder*` 相互引用;若现有 `test.py` 是手工测试,拆分后语义可能漂移。
|
||||
- **group_daily_analysis 不宜强行重命名**:其 core 分层已是领域驱动架构,重命名为 handlers/services/utils 破坏性大、收益低;只做配置接入与命名归一即可。
|
||||
- **社区/第三方插件**(memes、picstatus、learning_chat、steam_info、mc_server_status、ncm_saying、helldivers_tools、brash)在升级上游版本时,本地规范化会与上游 diff 冲突;建议这些插件**只做配置接入与日志规范,不做大结构重构**。
|
||||
- **deadlock / brash 为停用或空壳**:不建议投入重构,先决策去留。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:配置标准核对表(逐插件)
|
||||
|
||||
| 插件 | plugin_id 约定(模块名) | 是否注册 schema | 是否暴露 Web 表单 | 运行期读生效值 | 数据路径规范 |
|
||||
|---|---|---|---|---|---|
|
||||
| galgame_card | ✅ | ✅ register_model_config | ✅ | 🟡 部分 | ✅ |
|
||||
| helldivers_tools | ✅ | ✅ | ✅ | 🟡 部分 | 🟡 有 temp/ |
|
||||
| learning_chat | ✅ | 🟡 自有 yml,未接标准 | 🟡(自有后台) | 🟡 | ✅ |
|
||||
| mc_server_status | ✅ | ✅ | ✅ | 🟡 | ✅ |
|
||||
| picfinder_take | ✅ | ✅ register_config_items | ✅ | ✅ 部分 | ✅ |
|
||||
| picstatus | ✅ | ✅ | ✅ | 🟡 | ✅ |
|
||||
| steam_info | ✅ | ✅ | ✅ | 🟡 | ✅ |
|
||||
| video_analysis | ✅ | 🟡 config 存在但入口未 register | 🟡 | 🟡 | ✅ |
|
||||
| group_tools | ✅ | ❌ | ❌ | ❌ | 🟡 res/ 在源码目录 |
|
||||
| group_daily_analysis | ✅ | 🟡 自有配置 | 🟡(自有 Web) | 🟡 | 🟡 |
|
||||
| 其余扁平插件 | ✅(多数) | ❌ | ❌ | ❌ | ❌(写插件目录/硬编码) |
|
||||
@@ -10,7 +10,7 @@
|
||||
## 0. 目标与一句话总结
|
||||
|
||||
- **核心原则**:插件只声明 `schema`,Web 自动生成表单;值统一写入值库;插件通过统一 API 读取生效值。
|
||||
- **一句话**:插件在导入时调用 `register_plugin_config(plugin_id, schema, apply, getter)` 声明配置;值写入 `hexi/config/plugin_config.json`;Web 端读 `{schema, values}` 渲染、保存后写回并热应用。
|
||||
- **一句话**:插件在导入时调用 `register_plugin_config(plugin_id, schema, apply, getter)` 声明配置;值写入 `../../hexi/config/plugin_config.json`;Web 端读 `{schema, values}` 渲染、保存后写回并热应用。
|
||||
|
||||
- **`plugin_id` 约定**:必须等于 NoneBot 插件模块名(如 `hexi.plugins.nonebot_plugin_helldivers_tools`)。Web 的 `/api/plugins/<id>/config` 才能命中,插件目录合表也用该 id。
|
||||
|
||||
@@ -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 # 统一值库:{ "<plugin_id>": { key: value, ... } }
|
||||
└── web/ # /hub 前端(通用 schema 表单渲染器)
|
||||
@@ -103,7 +107,7 @@ hexi/
|
||||
**约定**
|
||||
- `key` 一律 snake_case。
|
||||
- `secret` 字段只返回 `****`,绝不返回明文;提交未修改的 `****` 表示保持原值,只有提交 `null` 才清除。
|
||||
- 复杂类型(`list`/`path`/`object`/`object_set`)在 `.env` 里不易安全表达,**只做运行期热更新**,不落 `.env`;重启需插件自行从值库读取。
|
||||
- 复杂类型(`list`/`path`/`object`/`object_set`)在 `../../.env` 里不易安全表达,**只做运行期热更新**,不落 `../../.env`;重启需插件自行从值库读取。
|
||||
|
||||
---
|
||||
|
||||
@@ -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")}
|
||||
@@ -207,9 +211,9 @@ register_plugin_config(
|
||||
|
||||
1. 按 schema 字段的 type/`item_type` 做 **类型校验与转换**(string/int/float/bool/enum/list)。
|
||||
2. **写值库** `plugin_config.json`(原子写)。
|
||||
3. **写 .env**:非 list 字段写入 `os.environ` + `.env`(保证重启仍生效);list/path/object 跳过,避免 `str(list)` 破坏重启解析。
|
||||
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` | 清空该插件覆盖,恢复默认(可选) |
|
||||
@@ -236,13 +240,13 @@ register_plugin_config(
|
||||
}
|
||||
```
|
||||
|
||||
前端 `hexi/web/src/pages/plugins/index.tsx` 已按 `type` 自动渲染:bool→开关、enum→下拉、text→多行、password→掩码、int/float→数字、secret→脱敏。
|
||||
前端 `../../hexi/web/src/pages/plugins/index.tsx` 已按 `type` 自动渲染:bool→开关、enum→下拉、text→多行、password→掩码、int/float→数字、secret→脱敏。
|
||||
|
||||
---
|
||||
|
||||
## 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,28 +299,28 @@ 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,运行期热更复杂)。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 13. 插件内部配置项(来源无关)接入标准
|
||||
|
||||
> **不只是 `.env`**:插件内部自己的配置项(module 常量、YAML/JSON 配置、数据库里的开关等)同样遵循统一 schema,Web 可读可改。核心是让每个配置项自带 **getter/setter**,与来源解耦。
|
||||
> **不只是 `../../.env`**:插件内部自己的配置项(module 常量、YAML/JSON 配置、数据库里的开关等)同样遵循统一 schema,Web 可读可改。核心是让每个配置项自带 **getter/setter**,与来源解耦。
|
||||
|
||||
### 13.1 四种来源与接入方式
|
||||
|
||||
| 配置来源 | 接入方式 | getter/setter | 持久化 | 运行期生效点 |
|
||||
|---|---|---|---|---|
|
||||
| pydantic Config(`.env` 驱动) | `register_model_config` | 自动(读写 Config 对象) | 写值库 + 写 `.env` | apply 热更新对象 |
|
||||
| 模块常量 | `register_config_items(..., store=模块)` | 自动(getattr/setattr) | 写值库(可选写 `.env`) | 需插件用 `get_effective_value` 读取 |
|
||||
| pydantic Config(`../../.env` 驱动) | `register_model_config` | 自动(读写 Config 对象) | 写值库 + 写 `../../.env` | apply 热更新对象 |
|
||||
| 模块常量 | `register_config_items(..., store=模块)` | 自动(getattr/setattr) | 写值库(可选写 `../../.env`) | 需插件用 `get_effective_value` 读取 |
|
||||
| 配置文件(dict/JSON/YAML) | `register_config_items(..., store=dict)` | 自动(读写 dict) | 插件自身写回文件 / 值库 | 插件从 dict 读取时即生效 |
|
||||
| 自定义(DB/运行态) | `register_config_items` 传 `getter`/`setter` | 自定义 | 自定义回写 | 自定义 |
|
||||
|
||||
### 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 插件模块名
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
## 1. 包命名与位置
|
||||
|
||||
- 位置:`hexi/plugins/nonebot_plugin_<name>/`(`bot.py` 用 `nonebot.load_plugins("hexi")` 加载)。
|
||||
- 位置:`hexi/plugins/nonebot_plugin_<name>/`(`../../bot.py` 用 `nonebot.load_plugins("hexi")` 加载)。
|
||||
- 包名 = NoneBot 模块名 = `hexi.plugins.nonebot_plugin_<name>`。
|
||||
- **`plugin_id` 约定**:用于配置 schema / 插件控制 / Web 挂载的值,一律等于模块名(`__name__`)。
|
||||
|
||||
@@ -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`(来源无关)。
|
||||
@@ -93,7 +94,7 @@ nonebot_plugin_xxx/
|
||||
|
||||
## 5. config.py 标准
|
||||
|
||||
### 4.1 环境驱动型(读 `.env` / NoneBot config)
|
||||
### 4.1 环境驱动型(读 `../../.env` / NoneBot config)
|
||||
```python
|
||||
from nonebot import get_plugin_config
|
||||
from pydantic import BaseModel
|
||||
@@ -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/<id>`。
|
||||
- 需要 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"`。
|
||||
- [ ] 包名改为 `nonebot_plugin_*`,位置在 `../../hexi/plugins`。
|
||||
- [ ] 有 `__plugin_meta__`,`type="application"`(核心/框架插件标 `library`,不参与热拔插)。
|
||||
- [ ] 配置已接入统一标准(Web 能读能改,运行期/重启生效)。
|
||||
- [ ] 命令在 `handlers/`,业务在 `services/`,工具在 `utils/`。
|
||||
- [ ] 数据访问集中(repository / services),原子写 / 事务。
|
||||
@@ -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` 回调,将不会执行热重载。
|
||||
@@ -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]`)。
|
||||
@@ -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()
|
||||
|
||||
@@ -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__
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -0,0 +1 @@
|
||||
# 机器人核心目录
|
||||
@@ -1,18 +1,11 @@
|
||||
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,
|
||||
message_utils,
|
||||
outbound_media,
|
||||
plugin_control,
|
||||
plugin_manager,
|
||||
rate_limit,
|
||||
@@ -24,3 +17,9 @@ async def _startup_plugin_gate() -> None:
|
||||
"""等所有插件 matcher 注册完成后,把统一 filter 规则注入到 application 插件。"""
|
||||
n = plugin_control.instrument_plugin_gate()
|
||||
logger.info(f"插件控制面: 已注入 {n} 条 gateway 规则")
|
||||
|
||||
|
||||
@get_driver().on_startup
|
||||
async def _startup_outbound_media() -> None:
|
||||
"""挂载本机媒体服务:协议端据此回拉 bot 生成的本地文件(内部已处理降级)。"""
|
||||
outbound_media.mount_media_endpoint()
|
||||
@@ -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)
|
||||
@@ -134,7 +134,7 @@ if hexi.is_superuser(event): # 超级用户
|
||||
# 架构设计
|
||||
|
||||
```
|
||||
hexi/plugins/nonebot_plugin_hexi_core/
|
||||
hexi/core/
|
||||
├── __init__.py # 插件入口,导出 hexi 对象
|
||||
├── poke.py # 戳一戳
|
||||
├── forward.py # 合并转发消息构建器
|
||||
@@ -1,11 +1,19 @@
|
||||
from pathlib import Path
|
||||
|
||||
from nonebot import get_bot
|
||||
from nonebot import require
|
||||
from nonebot.adapters import Event
|
||||
from nonebot.adapters.onebot.v11 import Bot, Message, MessageEvent, MessageSegment
|
||||
from nonebot.log import logger
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
from nonebot_plugin_alconna.uniseg import Receipt, Target
|
||||
|
||||
require("nonebot_plugin_htmlrender")
|
||||
from nonebot_plugin_htmlrender import md_to_pic
|
||||
|
||||
# 「处理中」占位动图(任务驱动通用回复用)
|
||||
THINKING_GIF = Path(__file__).parents[1] / "resource" / "imgs" / "thinking.gif"
|
||||
|
||||
|
||||
async def send_markdown(
|
||||
bot: Bot,
|
||||
@@ -108,3 +116,64 @@ async def send_forward_msg(
|
||||
except Exception as e:
|
||||
logger.warning(f"合并转发发送失败,回退文本: {e}")
|
||||
await bot.send(event, fallback)
|
||||
|
||||
|
||||
async def common_proc_reply(
|
||||
message_id: str | None = None,
|
||||
*,
|
||||
target: Event | Target | None = None,
|
||||
image: str | Path = THINKING_GIF,
|
||||
text: str | None = None,
|
||||
) -> Receipt | None:
|
||||
"""任务驱动通用回复:引用指定消息并回一张「处理中」占位图(UniMessage 版)
|
||||
|
||||
引用哪条消息 / 发到哪:
|
||||
- 事件处理中(有上下文):不传参数即引用当前触发消息,发回当前会话;
|
||||
- 传 message_id 则引用指定消息(仍需事件上下文或用 target 指定会话);
|
||||
- 后台任务(无事件上下文):必须显式传 target(Event 或 alconna Target),
|
||||
如 `Target("872490448")`(群)/`Target("2931589710", private=True)`(私聊);
|
||||
此时不传 message_id 即为不带引用的普通发送。
|
||||
|
||||
Args:
|
||||
message_id: 要引用的消息 id,默认当前事件消息
|
||||
target: 发送目标(Event/Target);仅后台任务需要
|
||||
image: 占位图路径(或 http(s) 链接),默认 hexi/resource/imgs/thinking.gif
|
||||
text: 附加文本,为空只发图
|
||||
|
||||
Returns:
|
||||
alconna Receipt: 可 `.recall()` 撤回占位图、`.msg_ids` 取消息 id
|
||||
(是否支持编辑见 `.editable`);发送失败返回 None
|
||||
"""
|
||||
|
||||
def build() -> UniMessage:
|
||||
# 每次重建:alconna 的 reply_to 会把引用段插入消息自身,降级重发需干净副本
|
||||
# 本地文件统一交给 hexi.core.outbound_media 的 call_api 钩子改写
|
||||
# (URL 优先/base64 兜底),这里不再自己转 base64——占位图本就是发图,
|
||||
# 走同一套策略才不会出现「一处能发一处不能发」。关掉 HEXI_INLINE_MEDIA
|
||||
# 的语义是「协议端就在本机」,此时 file:// 同样是有效的。
|
||||
if isinstance(image, str) and image.startswith(("http://", "https://")):
|
||||
img = UniMessage.image(url=image)
|
||||
else:
|
||||
img = UniMessage.image(path=Path(image))
|
||||
return UniMessage.text(f"{text} ") + img if text else img
|
||||
|
||||
if message_id:
|
||||
reply_to: str | bool = message_id
|
||||
elif target is None:
|
||||
reply_to = True # 从当前事件上下文取被引用消息
|
||||
else:
|
||||
reply_to = False # 后台任务且未指定消息 id → 普通发送
|
||||
|
||||
try:
|
||||
return await build().send(target=target, reply_to=reply_to)
|
||||
except Exception as e:
|
||||
if not reply_to:
|
||||
logger.error(f"占位图发送失败: {e}")
|
||||
return None
|
||||
logger.warning(f"占位回复引用发送失败,降级为不带引用: {e}")
|
||||
|
||||
try:
|
||||
return await build().send(target=target)
|
||||
except Exception as e:
|
||||
logger.error(f"占位图发送失败: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,487 @@
|
||||
"""出站媒体内联化:把本地文件改写为协议端可达的 URL(或 base64 兜底)。
|
||||
|
||||
生产环境的 OneBot 协议端不在本机:给 API 传本地路径时,NoneBot 的 `f2s()`
|
||||
会把 `Path` 转成 `file:///D:/...`(`str` 则原样透传)——而 `file://` 的语义是
|
||||
「**协议端那台机器上的路径**」,协议端 stat 必然 ENOENT。受影响的不止本仓插件,
|
||||
还有 pip 装的社区插件(如 nonebot_plugin_doroending),它们的代码改不了,
|
||||
只能全局拦截。
|
||||
|
||||
本模块挂在 `Bot.on_calling_api`(NoneBot 公开钩子,`call_api` 的必经之路,
|
||||
`bot.send` / alconna `finish` / 直接 `send_group_msg` 全从这里过)上,
|
||||
在真正调用适配器前改写发送类 API 里的本地文件:
|
||||
|
||||
1. **默认改写为 `http://<本机可达IP>:<端口>/media/<token>`**,由协议端主动来拉。
|
||||
地址取自 `driver.config` 的 host/port + 本机网卡,与 /hub 首页「协议端配对」
|
||||
展示的是同一来源;反向 WS 部署下协议端本就连得到这个地址(它连的就是它),
|
||||
所以不需要任何新配置。体积不受 base64 膨胀影响,视频/多媒体转发尤其受益。
|
||||
2. **拿不到可达地址时回退 `base64://`**:字节由 bot 直接携带,不依赖协议端
|
||||
反过来拨通本机的任何假设,跨 NAT/隧道也成立。
|
||||
|
||||
不改写 `http(s)://` / `base64://` / 不存在的路径——收到的图片 id(如 `{abc}.jpg`)
|
||||
和协议端自己的缓存路径都靠这条自然放行。任何异常只告警不抛:
|
||||
拦截层绝不能反过来把发送搞挂。
|
||||
|
||||
两类 API 的处理方式不同:
|
||||
|
||||
- **消息发送**(`send_*_msg` / `send_*_forward_msg`):媒体在消息段里,递归遍历
|
||||
后按上面两级改写(URL 优先,base64 兜底)。
|
||||
- **文件上传**(`upload_group_file` / `upload_private_file`):`file` 在顶层,
|
||||
且**只做 URL 不做 base64**——文件体积不适合内联。覆盖这条让
|
||||
`video_analysis` 的群文件「本地直传」兜底和 alconna 的 `$onebot11:file`
|
||||
一起受益(后者传的是 `Path.as_posix()` 裸路径)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import time
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import url2pathname
|
||||
|
||||
from nonebot.adapters import Bot as BaseBot
|
||||
|
||||
# 用 nonebot.log 而非 `from nonebot import logger`:与 rate_limit 保持一致,
|
||||
# 模块级 import 保持干净,测试才能 importlib 按路径裸加载(不经包 __init__)。
|
||||
from nonebot.log import logger
|
||||
|
||||
try: # NoneBot 内部路径:V11/V12 的 MessageSegment 都继承自它
|
||||
from nonebot.internal.adapter.message import MessageSegment as BaseMessageSegment
|
||||
except Exception: # pragma: no cover - 内部路径变更时的兜底,见 _as_segment
|
||||
BaseMessageSegment = None # type: ignore[assignment,misc]
|
||||
|
||||
# 消息段里代表「本地/远端媒体文件」的 type(file 字段语义)
|
||||
MEDIA_SEG_TYPES = frozenset({"image", "record", "video"})
|
||||
|
||||
# OneBot V11 发送类 API(与 picstatus misc_statistics 的清单一致,另加合并转发)。
|
||||
_SEND_APIS = frozenset(
|
||||
{
|
||||
"send_private_msg",
|
||||
"send_group_msg",
|
||||
"send_msg",
|
||||
"send_private_forward_msg",
|
||||
"send_group_forward_msg",
|
||||
"send_forward_msg",
|
||||
}
|
||||
)
|
||||
|
||||
# 文件上传类 API:file 字段在**顶层**而不是消息段里,所以走单独一小段逻辑,
|
||||
# 且**只做 URL、不做 base64 兜底**——文件动辄几十上百 MB,内联会撑爆内存和
|
||||
# WS 帧,OneBot 的文件上传语义本来也不是为内联设计的。
|
||||
_FILE_UPLOAD_APIS = frozenset({"upload_group_file", "upload_private_file"})
|
||||
|
||||
# 只有 OneBot 的 file 字段认 file:// / base64:// 语义;bot.py 当前只注册 V11
|
||||
# (pyproject.toml 虽列了 V12,但代码里没装适配器)。
|
||||
_ONEBOT_NAMES = frozenset({"OneBot V11"})
|
||||
|
||||
# 通配绑定:值本身不可拨号,需要换成具体网卡地址
|
||||
_WILDCARD_HOSTS = frozenset({"", "0.0.0.0", "::", "*"})
|
||||
|
||||
_MAX_DEPTH = 8
|
||||
_MAX_TOKENS = 256
|
||||
_DEFAULT_TTL = 300.0
|
||||
_DEFAULT_MAX_MB = 4.0
|
||||
|
||||
# token -> (文件路径, 过期时刻 monotonic)
|
||||
_tokens: dict[str, tuple[Path, float]] = {}
|
||||
|
||||
# 媒体服务是否真的挂上了:没挂上就不能发 URL(否则协议端必然 404)
|
||||
_mounted = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 环境开关
|
||||
|
||||
|
||||
def _env_flag(name: str, default: bool) -> bool:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() not in {"", "0", "false", "no", "off"}
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
"""总开关(默认开)。关闭的语义是「协议端就在本机」,此时 file:// 是有效的。"""
|
||||
return _env_flag("HEXI_INLINE_MEDIA", True)
|
||||
|
||||
|
||||
def _url_enabled() -> bool:
|
||||
"""URL 优先模式(默认开);关掉则一律走 base64。"""
|
||||
return _env_flag("HEXI_INLINE_MEDIA_URL", True)
|
||||
|
||||
|
||||
def _ttl() -> float:
|
||||
try:
|
||||
return float(os.environ.get("HEXI_MEDIA_URL_TTL", "") or _DEFAULT_TTL)
|
||||
except ValueError:
|
||||
return _DEFAULT_TTL
|
||||
|
||||
|
||||
def _max_inline_mb() -> float:
|
||||
try:
|
||||
return float(os.environ.get("HEXI_INLINE_MEDIA_MAX_MB", "") or _DEFAULT_MAX_MB)
|
||||
except ValueError:
|
||||
return _DEFAULT_MAX_MB
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 路径解析
|
||||
|
||||
|
||||
def local_file_of(value: Any) -> Path | None:
|
||||
"""把 file 字段值解析成本机真实存在的文件路径;不是本地文件则 None。
|
||||
|
||||
- `http(s)://` / `base64://` → None(原样放行)
|
||||
- `file://` → `url2pathname` 还原
|
||||
- 其余按原始路径候选(`f2s()` 对 `str` 是原样透传的)
|
||||
- 不存在 / 非法 → None(收到的图片 id、协议端缓存路径靠这条放行)
|
||||
"""
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
if value.startswith(("http://", "https://", "base64://")):
|
||||
return None
|
||||
|
||||
candidates: list[str] = []
|
||||
if value.startswith("file://"):
|
||||
# 关键陷阱:不能直接 unquote(urlparse(uri).path)——Windows 上会得到
|
||||
# 前导斜杠的 `/D:/...`,is_file() 恒为 False 而**静默不转换**,
|
||||
# 只在跨机时才暴露。url2pathname 才能正确还原成 `D:\\...`。
|
||||
try:
|
||||
uri_path = urlparse(value).path
|
||||
candidates.append(url2pathname(uri_path))
|
||||
except Exception:
|
||||
return None
|
||||
# 有人手工拼 file:// 且不做百分号转义(video_analysis._file_uri 即如此:
|
||||
# `"file:///" + path.replace("\\", "/")`)。对已经未转义的串再 unquote,
|
||||
# 文件名含字面 %XX 时会认错,所以把原始路径也列为候选兜底。
|
||||
if len(uri_path) > 2 and uri_path[0] == "/" and uri_path[2] == ":":
|
||||
candidates.append(uri_path[1:]) # /D:/x → D:/x
|
||||
else:
|
||||
# 裸路径:f2s() 对 str 原样透传;alconna 的 $onebot11:file 走 as_posix()
|
||||
candidates.append(value)
|
||||
|
||||
for raw in candidates:
|
||||
try:
|
||||
path = Path(raw)
|
||||
if path.is_file():
|
||||
return path
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 媒体服务
|
||||
|
||||
|
||||
def _is_private(ip: str) -> bool:
|
||||
parts = ip.split(".")
|
||||
if len(parts) != 4:
|
||||
return False
|
||||
try:
|
||||
a, b = int(parts[0]), int(parts[1])
|
||||
except ValueError:
|
||||
return False
|
||||
return a == 10 or (a == 172 and 16 <= b <= 31) or (a == 192 and b == 168)
|
||||
|
||||
|
||||
def _local_ips() -> list[str]:
|
||||
"""本机可被协议端访问的 IPv4 列表(私有网段优先,排除环回/链路本地)。
|
||||
|
||||
与 hexi/web_hub/dashboard.py 的 `_local_ips` 同源逻辑(那边供 /hub 首页
|
||||
「协议端配对」展示,即用户已经验证过能用的地址);此处刻意不 import,
|
||||
避免 hexi.core → hexi.web_hub 的反向依赖。
|
||||
"""
|
||||
ips: list[str] = []
|
||||
try:
|
||||
import psutil
|
||||
|
||||
for addrs in psutil.net_if_addrs().values():
|
||||
for addr in addrs:
|
||||
if addr.family == socket.AF_INET:
|
||||
ips.append(str(addr.address or ""))
|
||||
except Exception: # noqa: BLE001 - psutil 缺失/异常时退回 getaddrinfo
|
||||
pass
|
||||
|
||||
if not ips:
|
||||
try:
|
||||
infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET)
|
||||
ips.extend(info[4][0] for info in infos)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
out: list[str] = []
|
||||
for ip in ips:
|
||||
ip = ip.strip()
|
||||
if not ip or ":" in ip or ip.count(".") < 3:
|
||||
continue
|
||||
# 环回与链路本地(169.254.x)协议端访问不到,排除
|
||||
if ip.startswith(("127.", "169.254.")):
|
||||
continue
|
||||
if ip not in out:
|
||||
out.append(ip)
|
||||
out.sort(key=lambda ip: (not _is_private(ip), ip))
|
||||
return out
|
||||
|
||||
|
||||
def _listen_addr() -> tuple[str, int] | None:
|
||||
"""本机媒体服务的对外可达地址(host, port)。"""
|
||||
try:
|
||||
from nonebot import get_driver
|
||||
|
||||
config = get_driver().config
|
||||
port = int(getattr(config, "port", 0) or 0)
|
||||
host = str(getattr(config, "host", "") or "").strip()
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
if not port:
|
||||
return None
|
||||
if host not in _WILDCARD_HOSTS:
|
||||
# 显式绑定(如 dev 的 127.0.0.1)直接用
|
||||
return host, port
|
||||
for ip in _local_ips():
|
||||
return ip, port
|
||||
return None
|
||||
|
||||
|
||||
def _base_url() -> str | None:
|
||||
"""媒体服务基地址;未挂载/无可达地址时 None(调用方据此回退 base64)。"""
|
||||
if not _mounted:
|
||||
return None
|
||||
override = os.environ.get("HEXI_MEDIA_BASE_URL", "").strip()
|
||||
if override:
|
||||
return override.rstrip("/")
|
||||
addr = _listen_addr()
|
||||
if addr is None:
|
||||
return None
|
||||
host, port = addr
|
||||
return f"http://{host}:{port}"
|
||||
|
||||
|
||||
def _gc() -> None:
|
||||
now = time.monotonic()
|
||||
for token, (_, expires) in list(_tokens.items()):
|
||||
if expires <= now:
|
||||
del _tokens[token]
|
||||
while len(_tokens) >= _MAX_TOKENS:
|
||||
_tokens.pop(next(iter(_tokens)))
|
||||
|
||||
|
||||
def _publish(path: Path) -> str:
|
||||
"""登记一个一次性 token(值随机不可猜),返回它。"""
|
||||
_gc()
|
||||
token = secrets.token_urlsafe(16)
|
||||
_tokens[token] = (path, time.monotonic() + _ttl())
|
||||
return token
|
||||
|
||||
|
||||
def mount_media_endpoint() -> None:
|
||||
"""把媒体路由挂到现有 ASGI app 上(同一个端口,不新开服务)。
|
||||
|
||||
免鉴权是**必须**的——协议端登录不了 hub——所以安全模型改为
|
||||
「路径由 bot 在改写那一刻自己登记,请求方只能出示不可猜 token、
|
||||
根本无法表达路径」,天然没有目录穿越面,配合短 TTL。
|
||||
"""
|
||||
global _mounted
|
||||
|
||||
if not _enabled():
|
||||
logger.info("出站媒体内联化已禁用 (HEXI_INLINE_MEDIA=false),按原值发送")
|
||||
return
|
||||
if _mounted:
|
||||
return
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
from nonebot import get_app
|
||||
|
||||
sub = FastAPI(
|
||||
title="HeXi Outbound Media",
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
)
|
||||
|
||||
@sub.get("/{token}")
|
||||
async def media(token: str):
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
entry = _tokens.get(token)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="媒体不存在或已过期")
|
||||
path, expires = entry
|
||||
if expires <= time.monotonic():
|
||||
_tokens.pop(token, None)
|
||||
raise HTTPException(status_code=404, detail="媒体链接已过期")
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="文件已不存在")
|
||||
# no-store:过期媒体不该被中间层缓存住
|
||||
return FileResponse(path, headers={"Cache-Control": "no-store"})
|
||||
|
||||
get_app().mount("/media", sub)
|
||||
_mounted = True
|
||||
logger.info("出站媒体服务已挂载: /media/<token>")
|
||||
except Exception as e: # noqa: BLE001 - 挂载失败只降级,不影响启动
|
||||
logger.warning(
|
||||
f"出站媒体服务挂载失败,将回退 base64: {type(e).__name__}: {e}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 改写
|
||||
|
||||
|
||||
async def _to_base64(path: Path) -> str:
|
||||
# 同步 IO 挪到线程,别在事件循环里读文件
|
||||
data = await asyncio.to_thread(path.read_bytes)
|
||||
size_mb = len(data) / 1048576
|
||||
limit = _max_inline_mb()
|
||||
if size_mb > limit:
|
||||
# 超阈值仍要发:不转是 100% 必失败,转了才可能成功
|
||||
logger.warning(
|
||||
f"出站媒体内联: {path.name} 体积 {size_mb:.1f}MB 超过 "
|
||||
f"HEXI_INLINE_MEDIA_MAX_MB={limit:g},仍以 base64 发送"
|
||||
)
|
||||
return f"base64://{b64encode(data).decode()}"
|
||||
|
||||
|
||||
async def rewrite_file(value: Any) -> str | None:
|
||||
"""改写单个 file 字段值;无需改写(或改写失败)返回 None。"""
|
||||
path = local_file_of(value)
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
if _url_enabled():
|
||||
base = _base_url()
|
||||
if base:
|
||||
return f"{base}/media/{_publish(path)}"
|
||||
return await _to_base64(path)
|
||||
except Exception as e: # noqa: BLE001 - 单个段失败只告警,按原值发送
|
||||
logger.warning(f"出站媒体改写失败,按原值发送 {path}: {type(e).__name__}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def rewrite_upload_file(value: Any) -> str | None:
|
||||
"""文件上传类 API 的 file 字段改写;无需改写返回 None。
|
||||
|
||||
只做 URL,**不做 base64 兜底**:拿不到可达地址就原样放行,行为与改动前
|
||||
一致(不保证成功,但绝不会比原来更糟)。`HEXI_INLINE_MEDIA_URL=false`
|
||||
同样会让这里放行——那个开关的语义是「媒体服务这条路不可用」,文件上传
|
||||
依赖同一条路,理应一起关掉。
|
||||
"""
|
||||
if not _url_enabled():
|
||||
return None
|
||||
path = local_file_of(value)
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
base = _base_url()
|
||||
if not base:
|
||||
return None
|
||||
return f"{base}/media/{_publish(path)}"
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"群/私聊文件改写失败,按原值发送 {path}: {type(e).__name__}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _as_segment(node: Any) -> tuple[str, dict] | None:
|
||||
"""识别消息段对象 → (type, data);不是消息段返回 None。
|
||||
|
||||
MessageSegment 不是 dict 子类(但实现了 keys/get),所以单靠 isinstance(dict)
|
||||
认不出来;这里以 NoneBot 的公共基类为准,并留一条结构兜底以防内部路径变更。
|
||||
"""
|
||||
if BaseMessageSegment is not None and isinstance(node, BaseMessageSegment):
|
||||
seg_type, seg_data = node.type, node.data
|
||||
else:
|
||||
seg_type = getattr(node, "type", None)
|
||||
seg_data = getattr(node, "data", None)
|
||||
if isinstance(seg_type, str) and isinstance(seg_data, dict):
|
||||
return seg_type, seg_data
|
||||
return None
|
||||
|
||||
|
||||
async def _walk_segment(seg_type: str, seg_data: dict, depth: int) -> None:
|
||||
if seg_type in MEDIA_SEG_TYPES:
|
||||
rewritten = await rewrite_file(seg_data.get("file"))
|
||||
if rewritten is not None:
|
||||
seg_data["file"] = rewritten
|
||||
# 合并转发节点:content 可能是 str / list[dict] / Message,里面还可能嵌媒体
|
||||
# (MessageSegment.node_custom 把 Message 原样塞进 data["content"])
|
||||
for key in ("content", "messages"):
|
||||
if key in seg_data:
|
||||
await _walk(seg_data[key], depth + 1)
|
||||
|
||||
|
||||
async def _walk(node: Any, depth: int = 0) -> None:
|
||||
"""深度受限的通用遍历,原地改写。
|
||||
|
||||
形态是散的(Message / 纯 dict 段 / list[dict] / 转发节点的三种 content),
|
||||
写死形状必漏,所以按结构特征递归而不是枚举。
|
||||
"""
|
||||
if node is None or depth > _MAX_DEPTH:
|
||||
return
|
||||
# str/bytes 是叶子;数字等标量也直接跳过
|
||||
if isinstance(node, (str, bytes, int, float, bool)):
|
||||
return
|
||||
|
||||
seg = _as_segment(node)
|
||||
if seg is not None:
|
||||
await _walk_segment(seg[0], seg[1], depth)
|
||||
return
|
||||
|
||||
if isinstance(node, (list, tuple)): # 含 Message(list 子类)
|
||||
for item in node:
|
||||
await _walk(item, depth + 1)
|
||||
return
|
||||
|
||||
if isinstance(node, dict):
|
||||
seg_type, seg_data = node.get("type"), node.get("data")
|
||||
if isinstance(seg_type, str) and isinstance(seg_data, dict):
|
||||
# 段字典({"type": "image", "data": {...}} / node 字典)
|
||||
await _walk_segment(seg_type, seg_data, depth)
|
||||
return
|
||||
for value in node.values():
|
||||
await _walk(value, depth + 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 钩子
|
||||
|
||||
|
||||
@BaseBot.on_calling_api
|
||||
async def _inline_outbound_media(bot: BaseBot, api: str, data: dict[str, Any]) -> None:
|
||||
"""call_api 前置钩子:把出站 API 里的本地文件改写成可跨机访问的形式。
|
||||
|
||||
钩子拿到的是 `_call_api` 之前**同一个可变 data dict**,且 hook 的 task group
|
||||
在 `_call_api` 之前 await 完成,所以原地改生效。
|
||||
"""
|
||||
if not _enabled():
|
||||
return
|
||||
is_send = api in _SEND_APIS
|
||||
is_upload = api in _FILE_UPLOAD_APIS
|
||||
if not (is_send or is_upload) or not isinstance(data, dict):
|
||||
return
|
||||
try:
|
||||
if bot.adapter.get_name() not in _ONEBOT_NAMES:
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
return
|
||||
|
||||
if is_upload:
|
||||
# 文件上传的 file 在顶层(不是消息段);name/folder 等参数一律不碰
|
||||
try:
|
||||
rewritten = await rewrite_upload_file(data.get("file"))
|
||||
except Exception as e: # noqa: BLE001 - 绝不反过来搞挂发送
|
||||
logger.warning(f"出站媒体改写异常(upload.file),按原值发送: {e}")
|
||||
return
|
||||
if rewritten is not None:
|
||||
data["file"] = rewritten
|
||||
return
|
||||
|
||||
for key in ("message", "messages"):
|
||||
if key in data:
|
||||
try:
|
||||
await _walk(data[key])
|
||||
except Exception as e: # noqa: BLE001 - 绝不反过来搞挂发送
|
||||
logger.warning(f"出站媒体改写异常({key}),按原值发送: {e}")
|
||||
@@ -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()},
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
@@ -0,0 +1 @@
|
||||
# 机器人插件目录
|
||||
@@ -33,6 +33,7 @@ __plugin_meta__ = PluginMetadata(
|
||||
name="表情裸词操作",
|
||||
description="恢复“表情 操作词”的裸词用法(对称 右、摸 圆、小丑 爷 等)",
|
||||
usage="对称 右 / 鬼畜 下 / 摸 圆 / 小丑 爷 / 小丑面具 前",
|
||||
type="application",
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -29,6 +29,7 @@ __plugin_meta__ = PluginMetadata(
|
||||
解绑: /解绑 (解除你的QQ与当前绑定的EAID)
|
||||
修改绑定: /修改绑定 EAID (修改你的QQ与当前绑定的EAID)
|
||||
""".strip(),
|
||||
type="application",
|
||||
extra={
|
||||
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 199 KiB |
@@ -0,0 +1,15 @@
|
||||
"""brash_general_supercredits_tools — 未完成插件(已停用)。
|
||||
|
||||
原作者未完成的「超级积分查询」插件,当前为占位/空壳:
|
||||
- __init__.py 原为空文件,无任何 matcher / 配置注册。
|
||||
- 保留 services/、db/ 空目录供后续实现。
|
||||
|
||||
⚠️ 本插件处于**禁用**状态:整个文件仅含注释,NoneBot 加载后不注册任何指令。
|
||||
后续如恢复实现,请按《插件结构标准》/《插件配置标准》补齐 handlers/services/utils 与 config.py。
|
||||
"""
|
||||
|
||||
# ============================================================
|
||||
# 尚未实现。
|
||||
# 原项目仅保留了空目录(db/, services/),没有可用逻辑。
|
||||
# 如要启用,请在此处编写 matcher、服务层与配置注册,并删除下方注释头说明。
|
||||
# ============================================================
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from random import choice
|
||||
from tkinter import Image
|
||||
"""今日老婆 —— 入口(元数据 + 依赖)。"""
|
||||
|
||||
from PIL import *
|
||||
from .utils import *
|
||||
|
||||
import httpx
|
||||
from nonebot import on_command, logger
|
||||
from nonebot.adapters.onebot.v11 import MessageEvent, Bot, MessageSegment
|
||||
from nonebot import on_command
|
||||
from nonebot.plugin import PluginMetadata
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
@@ -21,106 +10,4 @@ __plugin_meta__ = PluginMetadata(
|
||||
type="application",
|
||||
)
|
||||
|
||||
wife = on_command("今日老婆", aliases={"今日老婆"})
|
||||
|
||||
|
||||
def get_member_list(all_list):
|
||||
id_list = []
|
||||
for member_list in all_list:
|
||||
id_list.append(member_list['user_id'])
|
||||
return id_list
|
||||
|
||||
|
||||
async def download_avatar(user_id: str) -> bytes:
|
||||
url = f"http://q1.qlogo.cn/g?b=qq&nk={user_id}&s=640"
|
||||
data = await download_url(url)
|
||||
if not data or hashlib.md5(data).hexdigest() == "acef72340ac0e914090bd35799f5594e":
|
||||
url = f"http://q1.qlogo.cn/g?b=qq&nk={user_id}&s=100"
|
||||
data = await download_url(url)
|
||||
return data
|
||||
|
||||
|
||||
async def download_url(url: str) -> bytes:
|
||||
async with httpx.AsyncClient() as client:
|
||||
for i in range(3):
|
||||
try:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
return resp.content
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading {url}, retry {i}/3: {str(e)}")
|
||||
|
||||
|
||||
async def get_wife_info(member_info, qq_id):
|
||||
img = await download_avatar(qq_id)
|
||||
avatar = Image.open(BytesIO(img)).convert('RGBA')
|
||||
avatar = png_resize(avatar, new_width=145, new_height=145)
|
||||
|
||||
b_io = BytesIO()
|
||||
avatar.save(b_io, format="PNG")
|
||||
avatar_str = 'base64://' + base64.b64encode(b_io.getvalue()).decode()
|
||||
member_name = (member_info["card"] or member_info["nickname"])
|
||||
msg = (MessageSegment.text('你今天的群友老婆是:'), MessageSegment.image(avatar_str),
|
||||
MessageSegment.text(f'{member_name}({qq_id})'))
|
||||
return msg
|
||||
|
||||
|
||||
def load_group_config(group_id: str) -> int:
|
||||
filename = os.path.join(os.path.dirname(__file__), 'config', f'{group_id}.json')
|
||||
try:
|
||||
with open(filename, encoding='utf8') as f:
|
||||
config = json.load(f)
|
||||
return config
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def write_group_config(group_id: str, link_id: str, wife_id: str, date: str, config) -> int:
|
||||
config_file = os.path.join(os.path.dirname(__file__), 'config', f'{group_id}.json')
|
||||
if config is not None:
|
||||
config[link_id] = [wife_id, date]
|
||||
else:
|
||||
config = {link_id: [wife_id, date]}
|
||||
with open(config_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, ensure_ascii=False)
|
||||
|
||||
|
||||
@wife.handle()
|
||||
async def wife_handle(bot: Bot, ev: MessageEvent):
|
||||
group_id = ev.group_id
|
||||
user_id = ev.user_id
|
||||
bot_id = ev.self_id
|
||||
wife_id = None
|
||||
today = str(datetime.date.today())
|
||||
config = load_group_config(group_id)
|
||||
|
||||
# if priv.check_priv(ev, priv.SUPERUSER):
|
||||
# wife_id = bot_id
|
||||
if config is not None:
|
||||
if str(user_id) in list(config):
|
||||
if config[str(user_id)][1] == today:
|
||||
wife_id = config[str(user_id)][0]
|
||||
else:
|
||||
del config[str(user_id)]
|
||||
|
||||
if wife_id is None:
|
||||
all_list = await bot.get_group_member_list(group_id=group_id)
|
||||
id_list = get_member_list(all_list)
|
||||
id_list.remove(bot_id)
|
||||
id_list.remove(user_id)
|
||||
if config is not None:
|
||||
for record_id in list(config):
|
||||
if config[record_id][1] != today:
|
||||
del config[record_id]
|
||||
else:
|
||||
try:
|
||||
id_list.remove(int(config[record_id][0]))
|
||||
except:
|
||||
del config[record_id]
|
||||
wife_id = choice(id_list)
|
||||
|
||||
write_group_config(group_id, user_id, wife_id, today, config)
|
||||
member_info = await bot.get_group_member_info(group_id=group_id, user_id=wife_id)
|
||||
result = await get_wife_info(member_info, wife_id)
|
||||
await bot.send(ev, result, at_sender=True)
|
||||
from .handlers import wife # noqa: E402,F401
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""今日老婆 —— Trigger 层。"""
|
||||
|
||||
from .wife import wife # noqa: F401
|
||||
@@ -0,0 +1,78 @@
|
||||
"""今日老婆 —— 触发器(Trigger + View)层。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
from io import BytesIO
|
||||
from random import choice
|
||||
|
||||
from nonebot import on_command
|
||||
from nonebot.adapters.onebot.v11 import MessageEvent, Bot, MessageSegment
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from PIL import Image
|
||||
|
||||
from ..services.store import get_member_list, load_group_config, write_group_config
|
||||
from ..utils import download_avatar, png_resize
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="今日老婆",
|
||||
description="随机抓取群友作为老婆",
|
||||
usage="发送【今日老婆】",
|
||||
type="application",
|
||||
)
|
||||
|
||||
wife = on_command("今日老婆", aliases={"今日老婆"})
|
||||
|
||||
|
||||
async def get_wife_info(member_info, qq_id):
|
||||
img = await download_avatar(qq_id)
|
||||
avatar = Image.open(BytesIO(img)).convert("RGBA")
|
||||
avatar = png_resize(avatar, new_width=145, new_height=145)
|
||||
b_io = BytesIO()
|
||||
avatar.save(b_io, format="PNG")
|
||||
avatar_str = "base64://" + base64.b64encode(b_io.getvalue()).decode()
|
||||
member_name = member_info["card"] or member_info["nickname"]
|
||||
return (
|
||||
MessageSegment.text("你今天的群友老婆是:"),
|
||||
MessageSegment.image(avatar_str),
|
||||
MessageSegment.text(f"{member_name}({qq_id})"),
|
||||
)
|
||||
|
||||
|
||||
@wife.handle()
|
||||
async def wife_handle(bot: Bot, ev: MessageEvent):
|
||||
group_id = ev.group_id
|
||||
user_id = ev.user_id
|
||||
bot_id = ev.self_id
|
||||
wife_id = None
|
||||
today = str(datetime.date.today())
|
||||
config = load_group_config(group_id)
|
||||
|
||||
if config is not None:
|
||||
if str(user_id) in list(config):
|
||||
if config[str(user_id)][1] == today:
|
||||
wife_id = config[str(user_id)][0]
|
||||
else:
|
||||
del config[str(user_id)]
|
||||
|
||||
if wife_id is None:
|
||||
all_list = await bot.get_group_member_list(group_id=group_id)
|
||||
id_list = get_member_list(all_list)
|
||||
id_list.remove(bot_id)
|
||||
id_list.remove(user_id)
|
||||
if config is not None:
|
||||
for record_id in list(config):
|
||||
if config[record_id][1] != today:
|
||||
del config[record_id]
|
||||
else:
|
||||
try:
|
||||
id_list.remove(int(config[record_id][0]))
|
||||
except Exception:
|
||||
del config[record_id]
|
||||
wife_id = choice(id_list)
|
||||
|
||||
write_group_config(group_id, user_id, wife_id, today, config)
|
||||
member_info = await bot.get_group_member_info(group_id=group_id, user_id=wife_id)
|
||||
result = await get_wife_info(member_info, wife_id)
|
||||
await bot.send(ev, result, at_sender=True)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""今日老婆 —— Service 层。"""
|
||||
|
||||
from .store import get_member_list, load_group_config, write_group_config # noqa: F401
|
||||
@@ -0,0 +1,35 @@
|
||||
"""今日老婆 —— 数据访问(Service 层)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_CONFIG_DIR = Path(__file__).resolve().parents[3] / "data" / "dailywife" / "config"
|
||||
|
||||
|
||||
def get_member_list(all_list):
|
||||
return [member["user_id"] for member in all_list]
|
||||
|
||||
|
||||
def load_group_config(group_id: str) -> dict | None:
|
||||
filename = _CONFIG_DIR / f"{group_id}.json"
|
||||
try:
|
||||
with open(filename, encoding="utf8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def write_group_config(group_id: str, link_id: str, wife_id: str, date: str, config) -> None:
|
||||
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
config_file = _CONFIG_DIR / f"{group_id}.json"
|
||||
if config is not None:
|
||||
config[link_id] = [wife_id, date]
|
||||
else:
|
||||
config = {link_id: [wife_id, date]}
|
||||
tmp = config_file.with_suffix(".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False)
|
||||
os.replace(tmp, config_file)
|
||||
@@ -1,50 +0,0 @@
|
||||
import hashlib
|
||||
import json
|
||||
from nonebot.log import logger as sv
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from io import BytesIO
|
||||
|
||||
import aiohttp
|
||||
import qrcode
|
||||
import requests
|
||||
import requests.exceptions
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
# PNG重绘大小
|
||||
def png_resize(source_file, new_width=0, new_height=0, resample="LANCZOS", ref_file=''):
|
||||
"""
|
||||
PNG缩放透明度处理
|
||||
:param source_file: 源文件(Image.open())
|
||||
:param new_width: 设置的宽度
|
||||
:param new_height: 设置的高度
|
||||
:param resample: 抗锯齿
|
||||
:param ref_file: 参考文件
|
||||
:return:
|
||||
"""
|
||||
img = source_file
|
||||
img = img.convert("RGBA")
|
||||
width, height = img.size
|
||||
|
||||
if ref_file != '':
|
||||
imgRef = Image.open(ref_file)
|
||||
new_width, new_height = imgRef.size
|
||||
else:
|
||||
if new_height == 0:
|
||||
new_height = new_width * width / height
|
||||
|
||||
bands = img.split()
|
||||
resample_map = {
|
||||
"NEAREST": Image.NEAREST,
|
||||
"BILINEAR": Image.BILINEAR,
|
||||
"BICUBIC": Image.BICUBIC,
|
||||
"LANCZOS": Image.LANCZOS
|
||||
}
|
||||
resample_method = resample_map.get(resample, Image.LANCZOS) # 默认使用 LANCZOS
|
||||
|
||||
bands = [b.resize((new_width, new_height), resample=resample_method) for b in bands]
|
||||
resized_file = Image.merge('RGBA', bands)
|
||||
|
||||
return resized_file
|
||||
@@ -0,0 +1,3 @@
|
||||
"""今日老婆 —— 纯工具层。"""
|
||||
|
||||
from .avatar import download_avatar, download_url, png_resize # noqa: F401
|
||||
@@ -0,0 +1,56 @@
|
||||
"""今日老婆 —— 头像工具(纯工具 + 网络请求)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
|
||||
import httpx
|
||||
from PIL import Image
|
||||
|
||||
|
||||
# PNG 重绘大小
|
||||
def png_resize(source_file, new_width=0, new_height=0, resample="LANCZOS", ref_file=""):
|
||||
img = source_file
|
||||
img = img.convert("RGBA")
|
||||
width, height = img.size
|
||||
if ref_file != "":
|
||||
imgRef = Image.open(ref_file)
|
||||
new_width, new_height = imgRef.size
|
||||
else:
|
||||
if new_height == 0:
|
||||
new_height = new_width * width / height
|
||||
bands = img.split()
|
||||
resample_map = {
|
||||
"NEAREST": Image.NEAREST,
|
||||
"BILINEAR": Image.BILINEAR,
|
||||
"BICUBIC": Image.BICUBIC,
|
||||
"LANCZOS": Image.LANCZOS,
|
||||
}
|
||||
resample_method = resample_map.get(resample, Image.LANCZOS)
|
||||
bands = [b.resize((new_width, new_height), resample=resample_method) for b in bands]
|
||||
return Image.merge("RGBA", bands)
|
||||
|
||||
|
||||
async def download_url(url: str) -> bytes:
|
||||
from nonebot import logger
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for i in range(3):
|
||||
try:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
return resp.content
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading {url}, retry {i}/3: {str(e)}")
|
||||
return b""
|
||||
|
||||
|
||||
async def download_avatar(user_id: str) -> bytes:
|
||||
url = f"http://q1.qlogo.cn/g?b=qq&nk={user_id}&s=640"
|
||||
data = await download_url(url)
|
||||
if not data or hashlib.md5(data).hexdigest() == "acef72340ac0e914090bd35799f5594e":
|
||||
url = f"http://q1.qlogo.cn/g?b=qq&nk={user_id}&s=100"
|
||||
data = await download_url(url)
|
||||
return data
|
||||
@@ -1,12 +1,9 @@
|
||||
from nonebot import on_command
|
||||
from nonebot.adapters.onebot.v11 import MessageEvent, Bot, MessageSegment
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from nonebot.rule import to_me
|
||||
"""打卡记鹿 —— 入口(元数据 + 依赖)。"""
|
||||
|
||||
from .data_proc import *
|
||||
from .img_generator import *
|
||||
import io
|
||||
import base64
|
||||
from nonebot import require
|
||||
from nonebot.plugin import PluginMetadata
|
||||
|
||||
require("nonebot_plugin_orm")
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="打卡记鹿",
|
||||
@@ -16,54 +13,4 @@ __plugin_meta__ = PluginMetadata(
|
||||
type="application",
|
||||
)
|
||||
|
||||
record = on_command("打卡记鹿", aliases={"打卡"}, rule=to_me())
|
||||
query = on_command("查询记录", aliases={"查卡"})
|
||||
|
||||
|
||||
@record.handle()
|
||||
async def do_job(bot: Bot, ev: MessageEvent):
|
||||
# 获取当前日期的年份和月份
|
||||
now = datetime.now()
|
||||
year = now.year
|
||||
month = now.month
|
||||
# 获取发信人信息
|
||||
uid = str(ev.user_id)
|
||||
message_id = ev.message_id
|
||||
# 添加一条记录
|
||||
await record_month(uid)
|
||||
# 获取记录
|
||||
img = await draw_calendar_grid_image(uid, year, month)
|
||||
text_msg = MessageSegment.text("这是您的打卡记鹿:\n")
|
||||
image_msg = MessageSegment.image(pic2b64(img))
|
||||
mix_msg = (text_msg,)
|
||||
mix_msg += image_msg
|
||||
reply_msg = (MessageSegment.reply(message_id),)
|
||||
reply_msg += mix_msg
|
||||
await record.finish(reply_msg)
|
||||
|
||||
|
||||
def pic2b64(pic: Image) -> str:
|
||||
buf = io.BytesIO()
|
||||
pic.save(buf, format='PNG')
|
||||
base64_str = base64.b64encode(buf.getvalue()).decode()
|
||||
return 'base64://' + base64_str
|
||||
|
||||
|
||||
@query.handle()
|
||||
async def query_job(bot: Bot, ev: MessageEvent):
|
||||
# 获取当前日期的年份和月份
|
||||
now = datetime.now()
|
||||
year = now.year
|
||||
month = now.month
|
||||
# 获取发信人信息
|
||||
uid = str(ev.user_id)
|
||||
message_id = ev.message_id
|
||||
# 获取记录
|
||||
img_file = await draw_calendar_grid_image(uid, year, month)
|
||||
text_msg = MessageSegment.text("这是您的记鹿:\n")
|
||||
image_msg = MessageSegment.image(pic2b64(img_file))
|
||||
mix_msg = (text_msg,)
|
||||
mix_msg += image_msg
|
||||
reply_msg = (MessageSegment.reply(message_id),)
|
||||
reply_msg += mix_msg
|
||||
await record.finish(reply_msg)
|
||||
from .handlers import query, record # noqa: E402,F401
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""打卡记鹿 —— Trigger 层。"""
|
||||
|
||||
from .checkin import query, record # noqa: F401
|
||||
@@ -0,0 +1,58 @@
|
||||
"""打卡记鹿 —— 触发器(Trigger + View)层。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
from nonebot import on_command
|
||||
from nonebot.adapters.onebot.v11 import MessageEvent, Bot, MessageSegment
|
||||
from nonebot.rule import to_me
|
||||
from PIL import Image
|
||||
|
||||
from ..services.checkin import checkin, review
|
||||
|
||||
record = on_command("打卡记鹿", aliases={"打卡"}, rule=to_me())
|
||||
query = on_command("查询记录", aliases={"查卡"})
|
||||
|
||||
|
||||
def pic2b64(pic: Image) -> str:
|
||||
buf = io.BytesIO()
|
||||
pic.save(buf, format="PNG")
|
||||
base64_str = base64.b64encode(buf.getvalue()).decode()
|
||||
return "base64://" + base64_str
|
||||
|
||||
|
||||
@record.handle()
|
||||
async def do_job(bot: Bot, ev: MessageEvent):
|
||||
now = datetime.now()
|
||||
year = now.year
|
||||
month = now.month
|
||||
uid = str(ev.user_id)
|
||||
message_id = ev.message_id
|
||||
img = await checkin(uid, year, month)
|
||||
text_msg = MessageSegment.text("这是您的打卡记鹿:\n")
|
||||
image_msg = MessageSegment.image(pic2b64(img))
|
||||
mix_msg = (text_msg,)
|
||||
mix_msg += image_msg
|
||||
reply_msg = (MessageSegment.reply(message_id),)
|
||||
reply_msg += mix_msg
|
||||
await record.finish(reply_msg)
|
||||
|
||||
|
||||
@query.handle()
|
||||
async def query_job(bot: Bot, ev: MessageEvent):
|
||||
now = datetime.now()
|
||||
year = now.year
|
||||
month = now.month
|
||||
uid = str(ev.user_id)
|
||||
message_id = ev.message_id
|
||||
img_file = await review(uid, year, month)
|
||||
text_msg = MessageSegment.text("这是您的记鹿:\n")
|
||||
image_msg = MessageSegment.image(pic2b64(img_file))
|
||||
mix_msg = (text_msg,)
|
||||
mix_msg += image_msg
|
||||
reply_msg = (MessageSegment.reply(message_id),)
|
||||
reply_msg += mix_msg
|
||||
await record.finish(reply_msg)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""打卡记鹿 —— 数据模型(Model 层)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from nonebot import require
|
||||
|
||||
require("nonebot_plugin_orm")
|
||||
|
||||
from nonebot_plugin_orm import Model
|
||||
from sqlalchemy import BigInteger, Date, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class CheckInRecord(Model):
|
||||
"""打卡记录,一行一次打卡,同一天可多次"""
|
||||
|
||||
__tablename__ = "deer_pipe_checkin"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
uid: Mapped[int] = mapped_column(BigInteger, index=True)
|
||||
date: Mapped[datetime.date] = mapped_column(Date, index=True)
|
||||
@@ -1,25 +1,19 @@
|
||||
"""打卡记鹿 —— 数据访问(Model/Repository 层)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from nonebot import require
|
||||
|
||||
require("nonebot_plugin_orm")
|
||||
|
||||
from nonebot_plugin_orm import Model, get_session
|
||||
from sqlalchemy import BigInteger, Date, Integer, select
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from nonebot_plugin_orm import get_session
|
||||
from sqlalchemy import select
|
||||
|
||||
from .models import CheckInRecord
|
||||
|
||||
|
||||
class CheckInRecord(Model):
|
||||
"""打卡记录,一行一次打卡,同一天可多次"""
|
||||
|
||||
__tablename__ = "deer_pipe_checkin"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
uid: Mapped[int] = mapped_column(BigInteger, index=True)
|
||||
date: Mapped[datetime.date] = mapped_column(Date, index=True)
|
||||
|
||||
|
||||
# 记录信息
|
||||
async def record_month(uid):
|
||||
now = datetime.now()
|
||||
async with get_session() as session:
|
||||
@@ -27,7 +21,6 @@ async def record_month(uid):
|
||||
await session.commit()
|
||||
|
||||
|
||||
# 读取当月信息(返回当月所有打卡的日,同一天多次打卡会有重复项)
|
||||
async def get_records(uid):
|
||||
now = datetime.now()
|
||||
month_start = datetime(now.year, now.month, 1).date()
|
||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,3 @@
|
||||
"""打卡记鹿 —— Service 层。"""
|
||||
|
||||
from .checkin import checkin, review # noqa: F401
|
||||
@@ -0,0 +1,19 @@
|
||||
"""打卡记鹿 —— 业务编排(Service 层)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from ..repository import record_month
|
||||
from ..utils.render import draw_calendar_grid_image
|
||||
|
||||
|
||||
async def checkin(uid: str, year: int, month: int) -> Image.Image:
|
||||
"""记录一次打卡并返回当月日历图。"""
|
||||
await record_month(uid)
|
||||
return await draw_calendar_grid_image(uid, year, month)
|
||||
|
||||
|
||||
async def review(uid: str, year: int, month: int) -> Image.Image:
|
||||
"""仅查询(不新增记录)并返回当月日历图。"""
|
||||
return await draw_calendar_grid_image(uid, year, month)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""打卡记鹿 —— 渲染工具。"""
|
||||
|
||||
from .render import draw_calendar_grid_image # noqa: F401
|
||||
@@ -1,121 +1,87 @@
|
||||
import calendar
|
||||
from collections import Counter
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
||||
from .data_proc import *
|
||||
import random
|
||||
"""打卡记鹿 —— 渲染工具(utils 层)。"""
|
||||
|
||||
filepath = os.path.dirname(__file__).replace("\\", "/")
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import os
|
||||
import random
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from nonebot.log import logger
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
||||
|
||||
from ..repository import get_records
|
||||
|
||||
# 资源统一放插件 res/ 下
|
||||
filepath = os.path.join(os.path.dirname(os.path.dirname(__file__)), "res").replace("\\", "/")
|
||||
|
||||
|
||||
async def draw_calendar_grid_image(uid, year, month):
|
||||
# 获取该月的第一天是星期几,以及这个月有多少天
|
||||
first_weekday, num_days = calendar.monthrange(year, month)
|
||||
|
||||
# 调整第一天的索引,使周日对应0,周六对应6
|
||||
first_weekday = (first_weekday + 1) % 7
|
||||
|
||||
# 设置单元格尺寸和内边距
|
||||
cell_size = 50
|
||||
cell_padding = 5
|
||||
|
||||
# 计算当前月份的行数
|
||||
num_rows = (num_days + first_weekday + 6) // 7 + 1 # +1 行用于留空
|
||||
|
||||
# 计算图片宽度和高度
|
||||
num_rows = (num_days + first_weekday + 6) // 7 + 1
|
||||
image_width = 7 * (cell_size + cell_padding) - cell_padding
|
||||
image_height = num_rows * (cell_size + cell_padding) - cell_padding
|
||||
|
||||
# 创建图像对象
|
||||
img = Image.new("RGBA", (image_width, image_height), "white")
|
||||
draw = ImageDraw.Draw(img)
|
||||
font_path = filepath + "/font/SourceHanSansCN-Medium.otf"
|
||||
print(font_path)
|
||||
logger.debug(f"打卡字体路径: {font_path}")
|
||||
font_normal = ImageFont.truetype(font=font_path, size=18)
|
||||
font_large = ImageFont.truetype(font=font_path, size=24)
|
||||
|
||||
# 绘制网格线
|
||||
for i in range(7):
|
||||
x = i * (cell_size + cell_padding)
|
||||
draw.line([(x, 55), (x, image_height)], fill="black", width=1)
|
||||
for j in range(0, image_height + cell_size, cell_size + cell_padding):
|
||||
draw.line([(0, j), (image_width, j)], fill="black", width=1)
|
||||
|
||||
# 小🦌
|
||||
deer_pipe_path = filepath + "/img/deer_pipe.jpg"
|
||||
deer_pipe_img = Image.open(deer_pipe_path)
|
||||
deer_pipe_img = deer_pipe_img.resize((54, 50))
|
||||
|
||||
# 画标题
|
||||
title = f"{month}月打卡记鹿"
|
||||
text_length = draw.textlength(text=title, font=font_large)
|
||||
img_xy = (int(((image_width - text_length + 55) / 2) - 55), 5)
|
||||
title_pos = ((image_width - text_length + 55) / 2, 10)
|
||||
img.paste(deer_pipe_img, img_xy)
|
||||
draw.text(title_pos, title, fill="black", font=font_large)
|
||||
|
||||
# 获取🦌信息(同一天多次打卡会得到重复项)
|
||||
day_counts = Counter(await get_records(uid))
|
||||
# 填充日期
|
||||
date = datetime(year, month, 1)
|
||||
for day in range(1, num_days + 1):
|
||||
weekday = (first_weekday + day - 1) % 7
|
||||
row = (first_weekday + day - 1) // 7 + 1 # +1 行用于留空
|
||||
row = (first_weekday + day - 1) // 7 + 1
|
||||
x = weekday * (cell_size + cell_padding) + cell_padding
|
||||
y = row * (cell_size + cell_padding) + cell_padding
|
||||
img.paste(deer_pipe_img, (x - 4, y))
|
||||
draw.text((x+20, y - 2), f"{str(day)}", fill="black", font=font_normal)
|
||||
# 🦌是天的标记,打卡的PNG印记才是要多次绘制的对象,横向错开
|
||||
draw.text((x + 20, y - 2), f"{str(day)}", fill="black", font=font_normal)
|
||||
for i in range(day_counts.get(day, 0)):
|
||||
im = get_random_right()
|
||||
img = image_paste(im, img, (x - 4 + i * 10, y))
|
||||
|
||||
# 保存图片
|
||||
# img.save(output_image_path)
|
||||
|
||||
# 显示图片
|
||||
# img.show()
|
||||
img = ImageOps.expand(img, border=5, fill="black")
|
||||
img = ImageOps.expand(img, border=5, fill="white")
|
||||
return img
|
||||
|
||||
|
||||
# 全局变量,用于存储已经选择过的文件名
|
||||
selected_images = []
|
||||
|
||||
|
||||
def get_random_right():
|
||||
global selected_images
|
||||
path = filepath + "/img/right"
|
||||
# 过滤掉已经选择过的文件名
|
||||
im_name = [name for name in os.listdir(path) if name not in selected_images]
|
||||
|
||||
# 如果所有文件都已经选择过,重新初始化已选择列表
|
||||
if not im_name:
|
||||
selected_images = []
|
||||
im_name = os.listdir(path)
|
||||
|
||||
index = random.randint(0, len(im_name) - 1)
|
||||
im = im_name[index]
|
||||
selected_images.append(im) # 将选择的文件名添加到已选择列表中
|
||||
|
||||
selected_images.append(im)
|
||||
im_path = os.path.join(path, im)
|
||||
im_file = Image.open(im_path)
|
||||
im_file = im_file.resize((50, 50))
|
||||
return im_file
|
||||
|
||||
|
||||
# 图片粘贴
|
||||
def image_paste(paste_image, under_image, pos):
|
||||
"""
|
||||
:param paste_image: 需要粘贴的图片
|
||||
:param under_image: 底图
|
||||
:param pos: 位置(x,y)坐标
|
||||
:return: 返回图片
|
||||
"""
|
||||
# 获取需要贴入图片的透明通道
|
||||
r, g, b, alpha = paste_image.split()
|
||||
# 粘贴时将alpha值传递至mask属性
|
||||
under_image.paste(paste_image, pos, alpha)
|
||||
return under_image
|
||||
@@ -1,13 +1,9 @@
|
||||
import random
|
||||
import re
|
||||
"""掷骰子 / 结婚判定"""
|
||||
|
||||
from nonebot import on_regex, on_startswith, require
|
||||
from nonebot.adapters.onebot.v11 import MessageEvent
|
||||
from nonebot import require
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from nonebot.typing import T_State
|
||||
|
||||
require("nonebot_plugin_alconna")
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="掷骰子",
|
||||
@@ -16,69 +12,4 @@ __plugin_meta__ = PluginMetadata(
|
||||
type="application",
|
||||
)
|
||||
|
||||
dice = on_regex(
|
||||
re.compile(
|
||||
r"^\.r\s*(?:(?P<num>\d{0,2})d(?:(?P<min>\d{1,4})~)?(?P<max>\d{0,4})(?:(?P<opr>[+-])(?P<offset>\d{0,5}))?)?\b",
|
||||
re.I,
|
||||
)
|
||||
)
|
||||
marriage = on_startswith(".qj")
|
||||
|
||||
|
||||
async def do_dice(
|
||||
ev: MessageEvent,
|
||||
num: int,
|
||||
min_: int,
|
||||
max_: int,
|
||||
opr: int,
|
||||
offset: int,
|
||||
tip: str = "的掷骰结果是:",
|
||||
) -> None:
|
||||
if num == 0:
|
||||
await UniMessage.text("咦?我骰子呢?").send()
|
||||
return
|
||||
min_, max_ = min(min_, max_), max(min_, max_)
|
||||
rolls = [random.randint(min_, max_) for _ in range(num)]
|
||||
total = sum(rolls)
|
||||
rolls_str = "+".join(map(str, rolls))
|
||||
if len(rolls_str) > 100:
|
||||
rolls_str = str(total)
|
||||
res = total + opr * offset
|
||||
sign = " +-"[opr]
|
||||
msg = [
|
||||
f"{tip}\n",
|
||||
str(num) if num > 1 else "",
|
||||
"D",
|
||||
f"{min_}~" if min_ != 1 else "",
|
||||
str(max_),
|
||||
f"{sign}{offset}" if offset else "",
|
||||
f"={rolls_str}",
|
||||
f"{sign}{offset}" if offset else "",
|
||||
f"={res}" if offset or num > 1 else "",
|
||||
]
|
||||
await UniMessage.text("".join(msg)).send(at_sender=True)
|
||||
|
||||
|
||||
@dice.handle()
|
||||
async def dice_handle(ev: MessageEvent, state: T_State):
|
||||
num, min_, max_, opr, offset = 1, 1, 100, 1, 0
|
||||
matched = state.get("_matched_dict") or {}
|
||||
if s := matched.get("num"):
|
||||
num = int(s)
|
||||
if s := matched.get("min"):
|
||||
min_ = int(s)
|
||||
if s := matched.get("max"):
|
||||
max_ = int(s)
|
||||
if s := matched.get("opr"):
|
||||
opr = -1 if s == "-" else 1
|
||||
if s := matched.get("offset"):
|
||||
offset = int(s)
|
||||
await do_dice(ev, num, min_, max_, opr, offset)
|
||||
|
||||
|
||||
@marriage.handle()
|
||||
async def marriage_handle(ev: MessageEvent):
|
||||
text = ev.message.extract_plain_text().strip()
|
||||
wife = text[len(".qj") :].strip()
|
||||
tip = f"与{wife}的ケッコンカッコカリ结果是:" if wife else "的ケッコンカッコカリ结果是:"
|
||||
await do_dice(ev, 1, 3, 6, 1, 0, tip)
|
||||
from .handlers import dice, marriage # noqa: E402,F401
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""掷骰子 —— Trigger 层。"""
|
||||
|
||||
from .roll import dice, marriage # noqa: F401
|
||||
@@ -0,0 +1,49 @@
|
||||
"""掷骰子 —— 触发器(Trigger)层。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from nonebot import on_regex, on_startswith, require
|
||||
from nonebot.adapters.onebot.v11 import MessageEvent
|
||||
from nonebot.typing import T_State
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
|
||||
from ..services.dice import build_dice_message
|
||||
|
||||
require("nonebot_plugin_alconna")
|
||||
|
||||
dice = on_regex(
|
||||
re.compile(
|
||||
r"^\.r\s*(?:(?P<num>\d{0,2})d(?:(?P<min>\d{1,4})~)?(?P<max>\d{0,4})(?:(?P<opr>[+-])(?P<offset>\d{0,5}))?)?\b",
|
||||
re.I,
|
||||
)
|
||||
)
|
||||
marriage = on_startswith(".qj")
|
||||
|
||||
|
||||
@dice.handle()
|
||||
async def dice_handle(ev: MessageEvent, state: T_State):
|
||||
num, min_, max_, opr, offset = 1, 1, 100, 1, 0
|
||||
matched = state.get("_matched_dict") or {}
|
||||
if s := matched.get("num"):
|
||||
num = int(s)
|
||||
if s := matched.get("min"):
|
||||
min_ = int(s)
|
||||
if s := matched.get("max"):
|
||||
max_ = int(s)
|
||||
if s := matched.get("opr"):
|
||||
opr = -1 if s == "-" else 1
|
||||
if s := matched.get("offset"):
|
||||
offset = int(s)
|
||||
await UniMessage.text(build_dice_message(num, min_, max_, opr, offset)).send(
|
||||
at_sender=True
|
||||
)
|
||||
|
||||
|
||||
@marriage.handle()
|
||||
async def marriage_handle(ev: MessageEvent):
|
||||
text = ev.message.extract_plain_text().strip()
|
||||
wife = text[len(".qj") :].strip()
|
||||
tip = f"与{wife}的ケッコンカッコカリ结果是:" if wife else "的ケッコンカッコカリ结果是:"
|
||||
await UniMessage.text(build_dice_message(1, 3, 6, 1, 0, tip)).send(at_sender=True)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""掷骰子 —— Service 层。"""
|
||||
|
||||
from .dice import build_dice_message # noqa: F401
|
||||
@@ -0,0 +1,38 @@
|
||||
"""掷骰子 —— 业务逻辑(Service 层,不依赖 nonebot,可单测)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
|
||||
def build_dice_message(
|
||||
num: int,
|
||||
min_: int,
|
||||
max_: int,
|
||||
opr: int,
|
||||
offset: int,
|
||||
tip: str = "的掷骰结果是:",
|
||||
) -> str:
|
||||
if num == 0:
|
||||
return "咦?我骰子呢?"
|
||||
min_, max_ = min(min_, max_), max(min_, max_)
|
||||
rolls = [random.randint(min_, max_) for _ in range(num)]
|
||||
total = sum(rolls)
|
||||
rolls_str = "+".join(map(str, rolls))
|
||||
if len(rolls_str) > 100:
|
||||
rolls_str = str(total)
|
||||
res = total + opr * offset
|
||||
sign = " -+"[opr]
|
||||
return "".join(
|
||||
[
|
||||
f"{tip}\n",
|
||||
str(num) if num > 1 else "",
|
||||
"D",
|
||||
f"{min_}~" if min_ != 1 else "",
|
||||
str(max_),
|
||||
f"{sign}{offset}" if offset else "",
|
||||
f"={rolls_str}",
|
||||
f"{sign}{offset}" if offset else "",
|
||||
f"={res}" if offset or num > 1 else "",
|
||||
]
|
||||
)
|
||||
@@ -1,82 +0,0 @@
|
||||
# CLAUDE.md — 群聊人设卡插件开发文档
|
||||
|
||||
本文件是**本插件内开发**的唯一入口文档;需要项目全局信息(Poetry 命令、启动方式、测试约定)时再查项目根目录的 CLAUDE.md。详细设计定稿见同目录 `DESIGN.md`。
|
||||
|
||||
## 插件概述
|
||||
|
||||
基于群聊语料蒸馏群成员的形象风格,生成 galgame 风格人物卡(九段画像)。
|
||||
|
||||
- **目标**:生成"人的画像",不是关系网分析;样貌参考为虚构,永远带标注
|
||||
- **隐私硬约束**:只采集 opt-in 成员;敏感数据本地正则替换为占位符,**绝不经过 LLM**
|
||||
- **维度隔离**:每个 `(group_id, user_id)` 是独立人设,跨群不混
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
nonebot_plugin_galgame_card/
|
||||
├── __init__.py 入口层:on_message 采集器(鉴权/采样/调治理落库)
|
||||
├── models.py 数据层:五张表(persona_group/user/chat_log/impression/summary)
|
||||
├── repository.py 数据层:仓储(来源无关,所有读写唯一入口)
|
||||
├── processor.py 治理层:纯函数(五维提取/噪声/脱敏),可单测
|
||||
├── config.py 插件配置(WEB_ENABLE/WEB_USERNAME/WEB_PASSWORD/WEB_SECRET_KEY)
|
||||
├── web.py Web 管理后台后端(/galgame_card/api/*,JWT 登录,仿 learning_chat)
|
||||
├── web/index.html 管理后台前端(单文件,无需构建)
|
||||
├── DESIGN.md 设计定稿(数据模型/流水线/九段协议/证据纪律)
|
||||
└── CLAUDE.md 本文件
|
||||
```
|
||||
|
||||
**Web 管理后台**:`http://<host>:<port>/galgame_card`(默认 admin/galgame,可在 .env 改 WEB_*)。功能:群开关、参与者增删、语料/印象/画像浏览与删除、清空群数据。路由在 `@driver.on_startup` 里注册,改后端需重启 bot。
|
||||
|
||||
## 核心设计(速览,细节见 DESIGN.md)
|
||||
|
||||
1. **两级闸门**:群开关 `persona_group.enabled`(默认关)+ 个人 opt-in `persona_user`,都过才采集
|
||||
2. **两级流水线**:语料 →(攒够 N 条)→ 印象(LLM 自然语言,增量中间层)→(攒够 M 条)→ 画像(九段 markdown,版本化)
|
||||
3. **消息五维**:内容 / 谁发的 / 发给谁(回复/@)/ 几点发的 / 被回复内容快照——只存治理后纯文本
|
||||
4. **脱敏四层**(`desensitize()`):明确模式(最长优先排序防截胡)→ 定位式(同条关键词+值)→ 跨条语境(关键词在附近消息)→ 兜底(保守替换)
|
||||
5. **九段画像协议**:身份印象/性格特征/说话风格/口头禅语录/兴趣话题/相处模式/时间画像/样貌参考(虚构)/不确定信息
|
||||
6. **总结路径不进消息 handler**:LLM 调用只在调度器触发,防延迟/限流
|
||||
|
||||
## 开发命令
|
||||
|
||||
```bash
|
||||
# 跑本插件测试(治理层纯函数,9+ 个用例)
|
||||
poetry run pytest tests/test_persona_processor.py
|
||||
|
||||
# 全量测试
|
||||
poetry run pytest
|
||||
|
||||
# 启动/重启验证:PyCharm 的 "start bot" 运行配置(勿用 bat 脚本)
|
||||
```
|
||||
|
||||
## 数据库
|
||||
|
||||
- orm 默认库:`data/nonebot_plugin_orm/db.sqlite3`(不是 `hexi/data/data.db`)
|
||||
- 建表:bot 启动时 orm 自动 create_all(新表加在 `models.py` 里即可,重启生效)
|
||||
- 配置键是 `SQLALCHEMY_DATABASE_URL`(本插件未设置,走默认库)
|
||||
|
||||
## 开发注意事项(踩过的坑)
|
||||
|
||||
0. **⚠️ orm 启动自动同步会清空表数据**:`.env` 里 `ALEMBIC_STARTUP_CHECK=false` 时,nonebot_plugin_orm 每次启动都 autogenerate 同步数据库模式,**模型一有变更(改 models.py)就会重建表、清空全部数据**(2026-08-11 实测踩坑,全表被清)。已在本插件 `__init__.py` 导入期把 `migrate.sync` 替换为安全空操作。**今后 schema 演进只准通过 `repository.ensure_schema()` 显式 ALTER**,改完 models.py 后要在重启前手动执行对应 ALTER(或加进 ensure_schema)。
|
||||
1. **`on_message` 必须 `block=False`**:否则事件流被拦截,群里其他插件全废
|
||||
2. **只收群消息**:handler 参数注解 `GroupMessageEvent`(类型注解即过滤器)
|
||||
3. **脱敏正则排序**:身份证/银行卡必须在手机号之前,否则手机号截胡身份证数字段
|
||||
4. **定位式替换只替换值、保留关键词**:`密码是 xyz789` → `密码是 [密码]`,关键词不能丢
|
||||
5. **测试不能裸 import 包**:`__init__.py` 触发 NoneBot 初始化,用 importlib 按路径加载 processor(见 `tests/test_persona_processor.py`,与 test_rate_limit 同款)
|
||||
6. **总结/印象生成**:绝不在消息 handler 里调 LLM,走调度层(apscheduler)
|
||||
7. **仓储并发**:`add_summary` 版本自增有并发撞 UNIQUE 风险,调度层加锁保护
|
||||
|
||||
## 当前进度
|
||||
|
||||
- ✅ 数据层:五表 + 仓储(含群开关、滚动淘汰、版本自增)
|
||||
- ✅ 采集层:消息路径(监控→鉴权→治理→脱敏→采样→落库)
|
||||
- ✅ Web 管理后台:/galgame_card(群开关、参与者、数据浏览/清理;Chrome 已实测)
|
||||
- ⏳ QQ 命令集:`开启人设采集` / `加入人设` / `退出人设` / `查看人设`(Web 已覆盖同等功能,QQ 命令可选做)
|
||||
- ⏳ 总结路径:LLM 客户端、印象生成、九段画像生成、调度触发
|
||||
- ⏳ 呈现层:人物卡展示/图片渲染
|
||||
|
||||
## 待决策点
|
||||
|
||||
- 触发阈值(印象 ≥50 条新语料 / 画像 ≥5 条新印象,⏳ 待调)
|
||||
- 脱敏兜底位数(裸数字 ≥6 位默认替换 `[账号]`,保守优先;误杀多可提到 8 位,动 `BARE_DIGITS_RE`)
|
||||
- 密保答案场景(中文值正则误杀率高,方案待定)
|
||||
- 命令名与权限(超管/群主)
|
||||
@@ -1,194 +0,0 @@
|
||||
# 群聊人设卡(Galgame 风格人物卡构建器)设计文档
|
||||
|
||||
> 本文档固化设计决策,作为各层实现的唯一依据。标注 ⏳ 的为草案/待定项。
|
||||
|
||||
## 1. 定位
|
||||
|
||||
**目标**:基于群聊发言语料,蒸馏群成员的形象风格,生成 galgame 风格的人物卡(人设)。
|
||||
|
||||
**非目标**:
|
||||
- 不做关系网分析("相处模式"只是画像的一个段落,不是独立产品)
|
||||
- 不做真实身份推断(年龄/职业/住址等现实信息只能进"不确定信息"段)
|
||||
- 不生成真实样貌(样貌参考为虚构,永远带标注)
|
||||
|
||||
**消费方**:① 展示给人看(人物卡);② 可选:作为扮演 prompt 注入(MaiBot 兼容方向)。
|
||||
|
||||
## 2. 整体架构
|
||||
|
||||
功能分层:采集 → 治理 → 存储 → 调度 → 分析 → 呈现。
|
||||
|
||||
两级流水线(借鉴 MaiBot 印象机制):
|
||||
|
||||
```
|
||||
群聊消息 ──五维落库──> 语料 ──(攒够 N 条)──> 印象(LLM 自然语言) ──(攒够 M 条)──> 画像(九段协议) ──> 版本化快照
|
||||
```
|
||||
|
||||
- **语料 → 印象**:每次对"上次印象之后的新语料"生成一段自然语言印象(话题/氛围/互动),存 `persona_impression`。印象是增量中间产物,画像不重读全部原文。
|
||||
- **印象 → 画像**:从印象集 + 规则统计(@/回复 互动、活跃时段)生成九段人物卡。
|
||||
- 画像每次生成都是新版本(version +1),永久留档可对比。
|
||||
|
||||
## 3. 数据层(已实现 ✅)
|
||||
|
||||
五张表,前缀 `persona_`:
|
||||
|
||||
### persona_group —— 群采集开关(数据来源总闸门)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| group_id | BigInteger PK | 群号 |
|
||||
| enabled | Boolean 默认 False | 该群是否开启采集(默认关,需显式开启) |
|
||||
| updated_at | DateTime | 最后变更时间 |
|
||||
|
||||
群关闭 → 该群所有人一律不采集;群开启后,个人还需 opt-in(两级闸门)。
|
||||
|
||||
### persona_user —— 参与者名单(按群 opt-in)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| user_id | BigInteger PK | 参与人 QQ |
|
||||
| group_id | BigInteger PK | 所在群 |
|
||||
| joined_at | DateTime | 加入时间 |
|
||||
|
||||
### persona_chat_log —— 采集语料(五维 + 发言段链条)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | Integer PK 自增 | 全局有序,印象覆盖区间用它表示 |
|
||||
| user_id | BigInteger | 谁发的 |
|
||||
| group_id | BigInteger | |
|
||||
| nickname | String(64) | 群昵称快照 |
|
||||
| content | Text | 内容(纯文本,已脱敏截断) |
|
||||
| target_user_id | BigInteger NULL | 发给谁(ev.reply 的 sender / @ 对象;无则 NULL=群聊漫谈) |
|
||||
| target_inherited | Boolean | target 是否从发言段链条继承(对上一句的解释/补充仍算发给同一对象) |
|
||||
| follows_id | Integer NULL | 发言段链条:同一说话人的上一条语料 id(间隔 ≤ 5 分钟) |
|
||||
| reply_to_content | Text NULL | 被回复内容快照(对方不在语料里也能知道他在回应什么) |
|
||||
| created_at | DateTime | 几点发的 |
|
||||
|
||||
索引:`(user_id, group_id, created_at)`。滚动保留:单用户单群上限 3000 条(⏳ 常量待定)。
|
||||
|
||||
**发言段链条**(解决"不带 @/回复 的后续补充丢目标"):`@B 借我玩玩` → 下一条 `我的意思是借号不是借人`(无显式目标)继承 target=B 并打 `target_inherited` 标记;分析层窗口组装可沿 `follows_id` 回溯整段发言。
|
||||
|
||||
### persona_impression —— 印象(两级流水线中间产物)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | Integer PK 自增 | |
|
||||
| user_id / group_id | BigInteger | |
|
||||
| content | Text | LLM 生成的自然语言印象 |
|
||||
| cover_from_id / cover_to_id | Integer | 覆盖的语料 id 区间(增量依据) |
|
||||
| model | String(64) | 生成模型 |
|
||||
| created_at | DateTime | |
|
||||
|
||||
### persona_image —— 图片识别结果缓存(⏳ 多模态预留,未启用)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| hash | String(64) PK | 图片 hash(对应 chat_log.image_hashes) |
|
||||
| description | Text | 多模态识别结果(表情包梗/截图内容) |
|
||||
| model | String(64) | 识别模型 |
|
||||
| recognized_at | DateTime | |
|
||||
|
||||
**预留接口**:`vision.py`(BaseImageRecognizer,当前为 Noop 占位)。将来接入多模态 LLM 后:异步后台识别(绝不在消息路径同步调)、同一 hash 只识别一次(缓存复用)、失败不影响采集。识别描述供分析层窗口组装喂给总结 LLM。
|
||||
|
||||
### persona_summary —— 画像快照(版本化)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | Integer PK 自增 | |
|
||||
| user_id / group_id | BigInteger | |
|
||||
| version | Integer | 每次生成 +1,同人同群唯一 |
|
||||
| card_text | Text | 九段 markdown 画像原文 |
|
||||
| corpus_count | Integer | 语料覆盖条数(元信息) |
|
||||
| impression_count | Integer | 使用的印象条数(元信息) |
|
||||
| model | String(64) | 生成模型 |
|
||||
| created_at | DateTime | |
|
||||
|
||||
约束:`UNIQUE(user_id, group_id, version)`(并发写入由调度层锁保护)。
|
||||
|
||||
## 4. 九段画像协议(已定稿 ✅)
|
||||
|
||||
格式:markdown 固定标题 + 有界 bullet,代码可解析、人可编辑、LLM 可生成、可注入 prompt。
|
||||
|
||||
```
|
||||
# 人物卡 · {主称呼}
|
||||
语料 {N} 条 · 时间跨度 {start}-{end} · 版本 v{n} · 生成于 {date}
|
||||
|
||||
## 身份印象 ≤4 条 群内可见的:自称方式、群角色(吐槽役)、常用昵称
|
||||
## 性格特征 ≤6 条 毒舌但心软 / 重度拖延 / 嘴硬
|
||||
## 说话风格 ≤5 条 爱用"草"开头、句尾 wwww、先吐槽再给结论
|
||||
## 口头禅语录 ≤6 条 带原文引用:"有一说一,这个图确实带"
|
||||
## 兴趣话题 ≤5 条 明日方舟(资深);聊工作→抱怨、聊感情→回避
|
||||
## 相处模式 ≤4 条 对小B互怼最多,对新人客气(一句话式,非关系网)
|
||||
## 时间画像 ≤3 条 深夜 22-02 点活跃,白天潜水
|
||||
## 样貌参考 ≤3 条 🎨 虚构标注:基于气质的参考描述
|
||||
## 不确定信息 ≤3 条 疑是学生(语料出现"上课"),未证实
|
||||
```
|
||||
|
||||
- 段内 bullet 为纯文本,可带原文引用(口头禅语录段必须带原文)
|
||||
- 样貌参考段**必须**带 🎨 虚构标注与设计依据("基于 XX 气质")
|
||||
- 现实身份信息只能出现在"不确定信息"段
|
||||
- ⏳ 每段具体生成约束(prompt 细则)属分析层,待细化
|
||||
|
||||
## 5. 证据纪律(已定稿 ✅)
|
||||
|
||||
三层防编造(借鉴 MaiBot):
|
||||
|
||||
1. 印象 prompt 明文约束:"不要添加语料中没有依据的新事实"
|
||||
2. 规则统计(互动对象、活跃时段)优先于 LLM 分类结果
|
||||
3. LLM 分类结果默认降级进"不确定信息"段——**模型说的不算稳定真相**
|
||||
|
||||
指纹缓存(⏳):证据(印象集 + 统计)hash 未变则不重新生成画像。
|
||||
|
||||
## 6. 采集与治理(✅ 消息路径已实现,⏳ 阈值待调)
|
||||
|
||||
实现位置:`__init__.py`(on_message 入口 + 鉴权 + 采样)+ `processor.py`(纯函数治理,可单测)。
|
||||
|
||||
**两级闸门**:群开关 `persona_group.enabled`(群级,默认关)+ 个人 opt-in `persona_user`(个人,群内开启才生效)。两条同时满足才采集。
|
||||
|
||||
- 群开关控制:`开启人设采集 @群` / `关闭人设采集`(⏳ 命令名待定,权限:超管/群主)
|
||||
- 只采集 `persona_user` 名单内成员(opt-in),退出即停
|
||||
|
||||
**脱敏(硬约束:本地正则完成,绝不经过 LLM——LLM 只接触脱敏后文本)**:
|
||||
敏感值**替换为占位符**而非丢弃整条,保留对话语境(如"借号"互动是人格素材,凭证不是)。
|
||||
实现:`processor.desensitize()`,四层,覆盖场景:借号/验证码代收/密码口令/兑换码卡密/密保/收入/联系方式变体/位置。
|
||||
|
||||
| 规则层 | 模式 | 占位符 |
|
||||
|---|---|---|
|
||||
| 明确模式(按最长优先排序,防截胡) | 身份证 → 银行卡 → 邮箱 → 手机号(含分隔变体) → IP → wxid → 坐标 → 车牌 | `[身份证]` `[银行卡]` `[邮箱]` `[手机号]` `[IP]` `[微信号]` `[坐标]` `[车牌]` |
|
||||
| 定位式(同条"关键词+值") | `密码是 xyz789` `账号 abc123` `激活码 ABCDE-1` `验证码是 123456` `VX: xxx` `月薪 25000`(连接词支持 是/为/冒号/空格) | `[密码]` `[账号]` `[兑换码]` `[验证码]` `[微信号]` `[收入]` |
|
||||
| 跨条语境 | 关键词在附近消息(如"收下验证码"→ 下一条 `123456`)→ 本条值按语境类型替换 | `[验证码]` `[密码]` `[兑换码]` |
|
||||
| 兜底 | 裸长数字串 ≥6 位 → `[账号]`;字母+数字混合 ≥6 位 → `[密码]`(保守替换,宁误杀不放过) | `[账号]` `[密码]` |
|
||||
|
||||
语境来源:本群最近 3 条已治理文本(复用复读检测的内存窗口)。
|
||||
待扩展场景(⏳):密保答案("你妈妈的名字")、QQ 号文本、代充代练语境。
|
||||
|
||||
- 噪声过滤:纯表情图(无文字)、复读、命令/签到、长链接轰炸
|
||||
- 长文截断(约 200 字/条)
|
||||
- 采样:连续刷屏 5 秒内只记 1 条
|
||||
|
||||
## 7. 调度与触发(⏳ 草案)
|
||||
|
||||
| 方式 | 条件 |
|
||||
|---|---|
|
||||
| 手动 | 管理员 `生成人物卡 @xxx`(强制,无视阈值) |
|
||||
| 印象 | 新语料 ≥ 50 条(⏳)且距上次印象 ≥ 24h |
|
||||
| 画像 | 新印象 ≥ 5 条(⏳)或语料显著增长;首次需语料 ≥ 200 条 |
|
||||
| 防重入 | 同人同群生成中加锁 |
|
||||
|
||||
## 8. 入口层命令集(⏳ 草案)
|
||||
|
||||
`加入人设` `退出人设`(opt-in 控制)、`查看人设 @xxx`(展示九段卡)、`生成人设 @xxx`(管理员强制)。
|
||||
|
||||
## 9. 借鉴与不借鉴 MaiBot(已定稿 ✅)
|
||||
|
||||
**借鉴**:两级流水线(印象机制)、九段协议格式(段落文本协议)、证据纪律三层、指纹缓存、防串人(证据绑定 user_id)。
|
||||
|
||||
**不借鉴**:向量库 + BM25 双路召回 + PPR(语料量级 SQL 直查即可)、完整 A_memorix 记忆系统、md5 person_id(QQ 号即 id)。
|
||||
|
||||
## 10. 开发阶段
|
||||
|
||||
- **Phase 1**:数据层(四表 + 仓储)✅ 本文档落盘时完成
|
||||
- **Phase 2**:治理层(采集过滤 + opt-in 命令)
|
||||
- **Phase 3**:分析层(印象/画像生成,LLM 客户端)
|
||||
- **Phase 4**:调度层(触发/锁)+ 呈现层(人物卡展示)
|
||||
- ⏳ 后续可选:galgame 风格卡面图片渲染(协议文本为渲染源)
|
||||
@@ -1,37 +1,25 @@
|
||||
"""群聊人设卡 —— 基于群聊语料蒸馏成员形象,生成 galgame 风格人物卡
|
||||
|
||||
消息入口:on_message 监听全部消息事件,类型注解过滤出群聊,
|
||||
两级闸门(群开关 + 个人 opt-in)通过后,治理层处理落库。
|
||||
消息入口:on_message 监听全部消息事件,类型注解过滤出群聊,两级闸门(群开关 +
|
||||
个人 opt-in)通过后,治理层处理落库。采集编排已下沉到 services/collector.py。
|
||||
|
||||
当前进度:数据层 + 采集层(消息路径)完成;总结路径(印象/画像)待实现。
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
|
||||
from nonebot import get_driver, on_message, require
|
||||
from nonebot import get_driver, require
|
||||
|
||||
require("nonebot_plugin_orm")
|
||||
|
||||
from nonebot.adapters.onebot.v11 import GroupMessageEvent
|
||||
from nonebot.adapters.onebot.v11 import GroupMessageEvent # noqa: F401
|
||||
from nonebot.plugin import PluginMetadata
|
||||
|
||||
# ── 禁用 nonebot_plugin_orm 的启动自动同步 ─────────────────────────────
|
||||
# .env 里 ALEMBIC_STARTUP_CHECK=false 时,orm 插件每次启动都会 autogenerate
|
||||
# 同步数据库模式;模型一有变更(改 models.py)就会清空整表数据(已实测踩坑)。
|
||||
# 本插件的 schema 演进统一走 repository.ensure_schema() 显式迁移,
|
||||
# 因此把 migrate.sync 替换为安全空操作。改动必须在插件导入期完成
|
||||
# (早于 orm 的 on_startup 钩子执行)。
|
||||
from nonebot import logger as _logger
|
||||
|
||||
try:
|
||||
import nonebot_plugin_orm.migrate as _orm_migrate
|
||||
|
||||
def _safe_sync(*_args, **_kwargs):
|
||||
# 同步函数:orm 用 greenlet_spawn 同步调用 migrate.sync,
|
||||
# 若写成 async def 会产生 never-awaited coroutine 警告
|
||||
_logger.debug("galgame_card | 已禁用 orm 自动同步(schema 演进由 ensure_schema 接管)")
|
||||
|
||||
_orm_migrate.sync = _safe_sync
|
||||
@@ -40,17 +28,15 @@ except ImportError:
|
||||
|
||||
from . import models # noqa: F401 # 确保表结构注册
|
||||
from . import repository, processor
|
||||
from . import web # noqa: F401 # Web 管理后台(/galgame_card)
|
||||
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
|
||||
|
||||
|
||||
def _apply_galgame_config(values: dict) -> None:
|
||||
"""Web 保存后热刷新:直接改 config 模块的 pydantic 对象,无需重启。"""
|
||||
import sys
|
||||
|
||||
conf_mod = sys.modules.get("hexi.plugins.nonebot_plugin_galgame_card.config")
|
||||
if conf_mod is None:
|
||||
return
|
||||
@@ -65,14 +51,12 @@ def _apply_galgame_config(values: dict) -> None:
|
||||
|
||||
|
||||
def _get_galgame_config_values() -> dict:
|
||||
"""Web 表单回填当前生效值。"""
|
||||
import sys
|
||||
|
||||
conf_mod = sys.modules.get("hexi.plugins.nonebot_plugin_galgame_card.config")
|
||||
conf = getattr(conf_mod, "config", None) if conf_mod else None
|
||||
if conf is None:
|
||||
return {}
|
||||
keys = ("web_enable", "web_username", "web_password", "web_secret_key", "image_vision_enabled")
|
||||
keys = ("image_vision_enabled",)
|
||||
return {k: getattr(conf, k, None) for k in keys}
|
||||
|
||||
|
||||
@@ -80,10 +64,6 @@ register_plugin_config(
|
||||
__name__,
|
||||
{
|
||||
"fields": [
|
||||
{"key": "web_enable", "label": "Web 后台启用", "type": "bool", "default": True},
|
||||
{"key": "web_username", "label": "Web 登录账号", "type": "string", "default": "admin"},
|
||||
{"key": "web_password", "label": "Web 登录密码", "type": "password", "default": "galgame", "secret": True},
|
||||
{"key": "web_secret_key", "label": "Web 签名密钥", "type": "password", "default": "galgame-card-secret-key", "secret": True},
|
||||
{"key": "image_vision_enabled", "label": "图片识别启用", "type": "bool", "default": False},
|
||||
]
|
||||
},
|
||||
@@ -104,32 +84,9 @@ __plugin_meta__ = PluginMetadata(
|
||||
name="群聊人设卡",
|
||||
description="基于群聊语料蒸馏成员形象,生成 galgame 风格人物卡",
|
||||
usage="开发中……",
|
||||
type="application",
|
||||
)
|
||||
|
||||
# 采样/复读检测的内存状态(不进库)
|
||||
_recent_texts: dict[int, deque[str]] = {} # group_id -> 最近消息文本
|
||||
_last_store: dict[tuple[int, int], float] = {} # (group_id, user_id) -> 上次落库时间戳
|
||||
|
||||
|
||||
def _is_repeat(group_id: int, content: str) -> bool:
|
||||
"""复读检测:内容在最近 N 条里出现过则丢弃"""
|
||||
queue = _recent_texts.setdefault(group_id, deque(maxlen=processor.REPEAT_WINDOW))
|
||||
if content in queue:
|
||||
return True
|
||||
queue.append(content)
|
||||
return False
|
||||
|
||||
|
||||
def _throttled(group_id: int, user_id: int) -> bool:
|
||||
"""刷屏采样:5 秒内同一个人只落库一次"""
|
||||
key = (group_id, user_id)
|
||||
now = time.time()
|
||||
if now - _last_store.get(key, 0) < processor.THROTTLE_SECONDS:
|
||||
return True
|
||||
_last_store[key] = now
|
||||
return False
|
||||
|
||||
|
||||
_driver = get_driver()
|
||||
|
||||
|
||||
@@ -137,51 +94,3 @@ _driver = get_driver()
|
||||
async def _ensure_schema():
|
||||
"""老库迁移(persona_group.group_name 列),幂等"""
|
||||
await repository.ensure_schema()
|
||||
|
||||
|
||||
collector = on_message(block=False) # 监听全部消息事件,不拦截其他插件
|
||||
|
||||
|
||||
@collector.handle()
|
||||
async def collect(event: GroupMessageEvent):
|
||||
# 鉴权:两级闸门(群开关 → 个人 opt-in)
|
||||
if not await repository.is_group_enabled(event.group_id):
|
||||
return
|
||||
if not await repository.is_joined(event.user_id, event.group_id):
|
||||
return
|
||||
# 机器人自己(如转发)不收
|
||||
if event.user_id == event.self_id:
|
||||
return
|
||||
|
||||
command_starts = get_driver().config.command_start
|
||||
# 治理:文本 / 图片 / 表情任一存在才记录
|
||||
if not processor.has_recordable_content(event):
|
||||
return
|
||||
text = processor.filter_content(
|
||||
processor.extract_text(event), command_starts=command_starts
|
||||
)
|
||||
image_hashes = processor.extract_image_hashes(event)
|
||||
content = processor.build_content(
|
||||
text or "", len(image_hashes), processor.count_faces(event)
|
||||
)
|
||||
if content is None:
|
||||
return
|
||||
# 脱敏:敏感值替换为占位符(语境 = 本群最近几条已治理文本,用于"验证码/密码"类强化)
|
||||
context = " ".join(_recent_texts.get(event.group_id, ()))
|
||||
content = processor.desensitize(content, context)
|
||||
# 采样:复读 + 刷屏
|
||||
if _is_repeat(event.group_id, content):
|
||||
return
|
||||
if _throttled(event.group_id, event.user_id):
|
||||
return
|
||||
|
||||
await repository.add_chat_log(
|
||||
user_id=event.user_id,
|
||||
group_id=event.group_id,
|
||||
content=content,
|
||||
nickname=event.sender.card or event.sender.nickname or "",
|
||||
target_user_id=processor.extract_target_user_id(event),
|
||||
reply_to_content=processor.extract_reply_to_content(event),
|
||||
image_count=len(image_hashes),
|
||||
image_hashes=json.dumps(image_hashes, ensure_ascii=False) if image_hashes else None,
|
||||
)
|
||||
|
||||