Compare commits

..
19 Commits
Author SHA1 Message Date
sansenhoshiandClaude Code 614e1d7e00 chore(bf-bot): 更新 BF 系列通知图
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 18:47:17 +08:00
sansenhoshiandClaude Code fc2e542418 chore(deps): 加载 nonebot_plugin_doroending
插件已装进 venv,补进 pyproject 的 plugins 列表才会被 nonebot 加载。
已用 bot.py 同款初始化流程(不含 nonebot.run)验证:全部插件加载成功,
doroending 注册 4 个 matcher。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 18:47:17 +08:00
sansenhoshiandClaude Code 023dc1cb5d feat(web): 停止/重启 bot 的等待相位全局化 / 覆盖层收进 BotActionProvider
重启入口有两个 —— 顶栏账号菜单(任何页面可点)和设置页「进程控制」。以前两边
各写一套:设置页只有本页的 fixed 覆盖层,顶栏只弹个 toast。于是从插件页/日志页
点「重启 bot」时界面照常可点,请求全打在已经退出的旧进程上。

- 新增 src/botAction.tsx:Provider 持 phase(null / stop / restart)与 readyCount,
  覆盖层由 AppLayout 在 <Outlet/> 之外统一渲染,任何页面触发都盖得住;
  纯等待逻辑仍在 lib/botAction.ts,两边共用一份
- 设置页删掉本地的 restarting 态与两份覆盖层,只负责发起 + 用 phase 禁用按钮;
  改为消费 readyCount 在回连后重新拉数据(停机期间配置是旧的)
- 未挂载 Provider 时 run() 直接抛错,避免静默无覆盖层

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 18:47:15 +08:00
sansenhoshiandClaude Code 574216645c feat(video-analysis): 本地直发成功且群策略要下载链接时补传 S3
本地直发经 hexi/core/outbound_media 钩子改写后已真正可用,此前它因协议端不在本机
恒失败,public_url 因此总有值(每次实际都落到第 2 级 S3)。本地通道通了以后若不补,
public_url 会恒为 None,entry.py 的 `if pub and policy.sends_link` 会让「发送下载
链接」静默失效。

- _local_sent / _public_link_for 把「取链接」与「发送」解耦:只有策略真要链接
  (policy.sends_link)时才补一次 S3 上传,其余情况一概不碰 S3 —— 媒体不再进
  S3 归档是有意的,temp 保留交给 cleanup
- 补传失败只记日志返回 None:发送本身已经成功,不能反过来判失败
- tests/test_video_sender_link.py:覆盖 sends_link 开/关、多文件逐个补传、
  拿不到 public_url、上传抛异常、policy 为 None 五条路径

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 18:47:12 +08:00
sansenhoshiandClaude Code ed75a66982 feat(core): 出站媒体内联化全局钩子 / 本地文件改写为协议端可达 URL
协议端不在本机,而 NoneBot 的 f2s() 会把 Path 转成 file:///D:/...(file:// 的
语义是「协议端那台机器上的路径」),必然 ENOENT。受影响的不止本仓调用点,还有
装不了改不动的 pip 社区插件(本次报错来自 nonebot_plugin_doroending),只能全局拦截。

- 新增 hexi/core/outbound_media.py:挂 Bot.on_calling_api(公开钩子、适配器不覆盖),
  在真正调用适配器前原地改写发送类 API 的 data:默认改写成
  http://<本机可达IP>:<端口>/media/<token> 由协议端主动回拉(地址与 /hub 首页
  「协议端配对」同源,零新配置),拿不到可达地址时回退 base64://
- 文件上传类(upload_group_file / upload_private_file)单独处理:file 在顶层且
  只做 URL 不做 base64(几十上百 MB 不适合内联),覆盖 video_analysis 群文件
  「本地直传」兜底与 alconna 的 $onebot11:file
- 媒体路由 get_app().mount("/media", sub) 复用现有端口不新开服务;免鉴权是必须的
  (协议端登录不了 hub),安全模型改为「路径由 bot 改写那一刻自己登记、请求方只能
  出示不可猜 token」,配短 TTL;_mounted 为 False 时绝不发 URL
- 钩子内每个段独立 try/except,任何异常只告警不抛,绝不反过来把发送搞挂
- message_utils.common_proc_reply 占位图不再自己转 base64,改传 path= 交给钩子,
  避免两处各有一套策略
- tests/test_outbound_media.py:用 tmp_path 造真实文件而非 mock 文件系统,重点覆盖
  file:// 解析(空格/中文编码、手工未转义的字面 %XX)与 walker 各消息形态

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 18:47:09 +08:00
sansenhoshiandClaude Code 4b5e8b8b2e feat(web): 顶栏账号菜单接入 bot 启停 / 插件页分区 / 首页协议端配对信息
- 重启/关闭 bot 从设置页提到顶栏账号下拉(破坏性操作弹确认框),等待回连逻辑抽成
  lib/botAction.ts 供两处共用:先等旧进程让出端口,再以 dashboard SSE 真收到一帧为准,
  只看 HTTP 200 会在"端口起来但插件未挂接"时误判就绪
- 插件管理页按 带 Web 页面 / 带配置项 / 都没有 分区,卡片加类型与 Web/配置 徽章
- 首页「BOT 协议端信息」分两半:左半登录账号,右半 NoneBot 接入信息
  (IP / 运行端口 / WS 路径 / 访问令牌,均可一键复制,另给整条反向 WS 地址),
  监听地址非 0.0.0.0 时提示仅本机可连;「BOT 后端信息」移到自己一行
- dashboard.py 新增 endpoint 段:读 driver.config 的 host/port、驱动实际注册的
  反向 WS 路由(自动跳过根路径与尾斜杠别名)、适配器令牌与本机网卡 IP

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 14:23:52 +08:00
sansenhoshiandClaude Code 814c1994b6 feat(mc-server-status): 服务器列表 Web 管理页 / 数据层抽离 / mcstatus 13 适配
- services/servers.py:校验 + 读写 + 增删改收敛成一份数据层(顶层不 import nonebot,
  可脱离运行时单测),群命令与 Web API 都只经它改数据,校验不再各写一套
- web_hub.py + /hub/mc_server_status 页面:按群列出服务器,抽屉新增/编辑(改名)/
  测试连接,行内删除(删空一台连带删群);每条路由自带 require_admin
- handlers/server.py 改为薄封装,只在需要命令特有提示时自己判断
- services/mc.py 的图标属性名兼容 mcstatus ≥13 的 icon(旧版叫 favicon,
  读错会把整条查询打成"查询失败")
- tests/test_mc_servers.py:数据层归一/改名/删群/坏文件兜底 + 图标解码两版属性名

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 14:23:46 +08:00
sansenhoshiandClaude Code 4badcfcf32 feat(video-analysis): 群策略 v3 / 群文件投递通道 / Web 管理页
- policy.py:per-group 正交策略(自动解析 / 自动策略 / 禁用策略 / 存储 A·B·C /
  公网 / 链接 / 群文件 + 平台限定),list.json v1/v2 → v3 自动迁移,
  写入统一走 PolicyStore(加锁 + .tmp 原子替换 + 字段归一)
- 群文件并行通道 group_file.py:打包 zip(可选 pyzipper AES-256)后优先走 S3 预签名、
  本地直传兜底;设了密码但 pyzipper 不可用就放弃上传,不退化成明文
- list_proc.py 收敛到「视频策略」统一入口,权限判定改走 policy
- Web 管理页 /hub/video_analysis(群策略 + 链接解析面板)与 services/web_jobs.py
  (只复用纯函数层,Web 上下文不发消息;内存任务表 + 并发闸门 + 超时)
- 媒体命名统一到 utils.py({作者}_{作者id}/{作品名}[_短码]),cleanup 回收空目录
- 测试:policy / 命名 / 群文件 / web_jobs 四组

顺带 pyproject 的 pytest 加 testpaths=tests(避免收进 debug/ 下的调试脚本)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 14:23:32 +08:00
sansenhoshiandClaude Code 51b08ccb68 feat: 群分析本地消息库 / 处理中占位图 / Web 日志窗口重构
- 群分析: 新增 history_store 只读本地消息源(读 learning_chat 落库,含 uninfo
  昵称补齐/去重/截断判定),适配器优先读本地库、失败回退 OneBot 分页;分页
  锚点字段回退链修复 NapCat 传 message_seq 翻页断裂;新增「本地记录」开关
- core/message_utils: 新增 common_proc_reply 占位图通用回复(引用消息 + 处理中
  动图,支持后台任务显式指定 target),群分析/战况/倒放改用
- web_hub + web: 日志页改固定窗口滚动 + 翻页锚定 + 自动换行,SSE 日志轮转发
  reset 帧,入口 HTML no-cache,行数统计增量缓存,CPU 改非阻塞采样,登录信息缓存
- 插件内 CLAUDE.md / DESIGN.md 不入库(.gitignore),galgame_card 两份文档取消
  跟踪(文件保留在磁盘)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:59:01 +08:00
sansenhoshiandClaude Code 662c28cb2f chore(deps): 补齐运行时依赖声明
代码里直接 import、但 requirements.txt / pyproject.toml 都没声明的包补齐:
beautifulsoup4、opencv-contrib-python、curl_cffi、yt-dlp、boto3、cloudscraper、
cjk-textwrap、lxml、jieba、ruamel.yaml、pytz、psutil、py-cpuinfo、numpy、
nonebot-plugin-htmlrender、taskgroup(仅 py<3.11)。
其中多数此前只是靠别的包传递依赖带上来的(如 curl_cffi 来自 yt-dlp 的 extra),
换环境或升级就会缺。

同时清理无任何引用的 chardet、gradio_client、aiohttp-retry、qrcode,
并把 playwright 下限与 pyproject 对齐(1.44 → 1.50)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 18:58:52 +08:00
sansenhoshi 131b92b319 结构调整
视频解析多图/多媒体结构 消息体适配
2026-09-08 14:25:32 +08:00
sansenhoshiandClaude Code 30899688a7 feat(video-analysis): 多文件媒体改走合并转发
抖音 note/小红书等多图、图+视频混合、多视频(动图)作品,下载产物
超过一个文件时直接拼成一条消息发送会失败(视频段不能与其他段混合),
统一改为合并转发: 一个图/视频一个节点,本地文件优先、S3 预签名链接
兜底; 单文件作品保持原有直发链路,多级发送链(本地→S3→回退)不变。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-08 14:21:57 +08:00
sansenhoshi 8c1a106230 新战备 2026-09-03 15:45:14 +08:00
sansenhoshi 2849a6250c 项目改造 2026-09-03 15:44:57 +08:00
sansenhoshiandClaude Code 24d7c64e17 docs: fix relative paths in plugin standard docs after dev/ restructure
- dev/docs 插件标准文档中的相对路径适配到 dev/ 层级
- gitignore dev/docs/HD2/ 图标转换脚本与生成产物(cairosvg 输出等)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 15:40:43 +08:00
sansenhoshiandClaude Code c53b210413 feat: revamp web hub — global connection status, array config editor, log streaming
- Global connection badge in app header (persistent dashboard SSE via ConnectionProvider)
- LogProvider keeps log data and SSE stream alive across page switches (no reload spinner)
- Config drawer: password reveal toggle, array-type row editor for list fields
- Plugin management: optimistic updates, race-free loading, reset all group overrides
- Logs: load-earlier pagination; backend tail-read optimization (before/end offsets)
- web_auth: Web password changes persist (sync_admin seeding only when .env has explicit key)
- Dashboard: collect in thread pool + 1s result cache
- ErrorBoundary + local Suspense: plugin chunk loading stays inside content area
- UI polish: table row hover, mobile header/breadcrumb fixes, wider config drawer
- config_standard: array type inference for list fields; array env skip
- random_jm_code / steam_info: array schema migration; README web frontend note

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 15:39:10 +08:00
sansenhoshiandClaude 9371a28e35 refactor: restructure plugins per MTSS standard, consolidate assets to res/
Per docs/plugin-audit-report.md (plugins normalized to
Trigger(handlers) → Service(services) → Model(repository/models) + utils):

- Split monolithic __init__.py into handlers/services/utils across
  dailywife, deer_pipe, dice, galgame_card, helldivers_tools,
  huoziyinshua, learning_chat, makeaquote, mc_server_status,
  ncm_saying, picfinder_take, picstatus, random_jm_code, regif,
  steam_info, video_analysis, group_tools
- Move static assets under res/: deer_pipe font/img, makeaquote font,
  helldivers img/templates, huoziyinshua HuoZiYinShua
- Add config.py + register_config_items to ncm_saying, random_jm_code,
  group_tools; learning_chat unified config bridge
- Remove deprecated: voice_trans plugin, bf_bot/test.py, dead code in
  dailywife/deer_pipe, empty dirs, debug scripts under helldivers temp
- Disable brash_general_supercredits_tools (stub comment only)
- bot.py: optional stdout/stderr redirect to log file for Web log viewer,
  force ANSI colorize on non-TTY sinks
- Move runtime data (jm_code.json) out of plugin dir into hexi/data
- Docs: plugin-audit-report.md; README reflects removed plugins

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-03 00:44:38 +08:00
sansenhoshiandClaude 25bc30611f docs: remove emoji from README headings
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 13:26:59 +08:00
sansenhoshiandClaude 0157399443 docs: expand README with features, quick start, config, structure
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 13:25:16 +08:00
2534 changed files with 16051 additions and 25870 deletions
+9 -2
View File
@@ -57,8 +57,9 @@ node_modules/
/hexi/plugins/nonebot_plugin_deadlock/data/ /hexi/plugins/nonebot_plugin_deadlock/data/
# QQ 头像缓存 # QQ 头像缓存
/hexi/plugins/nonebot_plugin_group_tools/res/img/memavatar/ /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/img/assets/wiki assets/
/hexi/plugins/nonebot_plugin_helldivers_tools/res/img/assets/wiki assets/
# 调用统计/备份 # 调用统计/备份
/hexi/plugins/nonebot_plugin_steam_info/data/ /hexi/plugins/nonebot_plugin_steam_info/data/
@@ -77,4 +78,10 @@ bg.jpg
/.ai/ /.ai/
/.claude/ /.claude/
/hexi/config/ /hexi/config/
/CLAUDE.md
# ---- 开发文档(插件内 CLAUDE.md / DESIGN.md,含根目录,不入库) ----
**/CLAUDE.md
**/DESIGN.md
# helldivers 图标素材&生成脚本(不入库)
/dev/docs/HD2/
+129 -1
View File
@@ -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/` 脚手架模板
## 许可
无
+37 -3
View File
@@ -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 import nonebot
from nonebot.adapters.onebot.v11 import Adapter as ONEBOTV11Adapter 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 sqlalchemy import StaticPool
from starlette.middleware.cors import CORSMiddleware 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 以及 数据库
nonebot.init(datastore_engine_options={"poolclass": StaticPool}) nonebot.init(datastore_engine_options={"poolclass": StaticPool})
@@ -25,10 +55,14 @@ driver = nonebot.get_driver()
# 注册适配器 # 注册适配器
driver.register_adapter(ONEBOTV11Adapter) driver.register_adapter(ONEBOTV11Adapter)
# 加载插件 # 加载插件(hexi/plugins 下的自定义插件 + pyproject 声明的社区插件)
nonebot.load_plugins("hexi")
nonebot.load_from_toml("pyproject.toml") 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__": if __name__ == "__main__":
logger.warning("hexi?启动!") logger.warning("hexi?启动!")
nonebot.run() # 直接运行,不需要传参 nonebot.run() # 直接运行,不需要传参
+367
View File
@@ -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. 目标与一句话总结 ## 0. 目标与一句话总结
- **核心原则**:插件只声明 `schema`,Web 自动生成表单;值统一写入值库;插件通过统一 API 读取生效值。 - **核心原则**:插件只声明 `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。 - **`plugin_id` 约定**:必须等于 NoneBot 插件模块名(如 `hexi.plugins.nonebot_plugin_helldivers_tools`)。Web 的 `/api/plugins/<id>/config` 才能命中,插件目录合表也用该 id。
@@ -20,8 +20,12 @@
```text ```text
hexi/ hexi/
├── web_config.py # 配置标准核心:schema 注册 / 值库 / 保存热刷新 ├── web_hub/ # 统一 Web 管理台 / 鉴权 / 配置标准
├── config_standard.py # pydantic Config 一键接入的辅助封装 │ ├── 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/ ├── config/
│ └── plugin_config.json # 统一值库:{ "<plugin_id>": { key: value, ... } } │ └── plugin_config.json # 统一值库:{ "<plugin_id>": { key: value, ... } }
└── web/ # /hub 前端(通用 schema 表单渲染器) └── web/ # /hub 前端(通用 schema 表单渲染器)
@@ -103,7 +107,7 @@ hexi/
**约定** **约定**
- `key` 一律 snake_case。 - `key` 一律 snake_case。
- `secret` 字段只返回 `****`,绝不返回明文;提交未修改的 `****` 表示保持原值,只有提交 `null` 才清除。 - `secret` 字段只返回 `****`,绝不返回明文;提交未修改的 `****` 表示保持原值,只有提交 `null` 才清除。
- 复杂类型(`list`/`path`/`object`/`object_set`)在 `.env` 里不易安全表达,**只做运行期热更新**,不落 `.env`;重启需插件自行从值库读取。 - 复杂类型(`list`/`path`/`object`/`object_set`)在 `../../.env` 里不易安全表达,**只做运行期热更新**,不落 `../../.env`;重启需插件自行从值库读取。
--- ---
@@ -143,7 +147,7 @@ hexi/
```python ```python
# 在插件 __init__.py 里 # 在插件 __init__.py 里
from hexi.config_standard import register_model_config from hexi.web_hub.config_standard import register_model_config
from .config import config from .config import config
register_model_config( register_model_config(
@@ -168,7 +172,7 @@ register_model_config(
### 方式 B:非 pydantic 插件手动注册 ### 方式 B:非 pydantic 插件手动注册
```python ```python
from hexi.web_config import register_plugin_config from hexi.web_hub.web_config import register_plugin_config
def _get(): # 返回当前生效值 dict def _get(): # 返回当前生效值 dict
return {"field": get_my_cur_value("field")} 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)。 1. 按 schema 字段的 type/`item_type` 做 **类型校验与转换**(string/int/float/bool/enum/list)。
2. **写值库** `plugin_config.json`(原子写)。 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 也在此生效)。 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。 **为什么 apply 与 env 都做**:NoneBot 的 `get_driver().config` 在启动时即固定,重载插件也读不到新 env;所以运行期必须 apply,重启靠 env。
@@ -220,7 +224,7 @@ register_plugin_config(
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|---|---|---| |---|---|---|
| GET | `/api/plugins` | 所有已注册 Web 插件 | | 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 值只返回 `****` | | GET | `/api/plugins/{id}/config` | 读 `{schema, values, revision}`(登录);secret 值只返回 `****` |
| POST | `/api/plugins/{id}/config` | 保存 `{ "revision": n, "values": { key: value } }`;冲突 409,校验失败 422 | | POST | `/api/plugins/{id}/config` | 保存 `{ "revision": n, "values": { key: value } }`;冲突 409,校验失败 422 |
| DELETE | `/api/plugins/{id}/config` | 清空该插件覆盖,恢复默认(可选) | | 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. 权限与安全 ## 9. 权限与安全
- 全部配置接口走 `hexi.web_auth.require_admin`(OAuth2 + SQLite)。 - 全部配置接口走 `hexi.web_hub.web_auth.require_admin`(OAuth2 + SQLite)。
- 配置 POST 必须携带 GET 返回的 `revision`;缺失返回 428,冲突返回 409。 - 配置 POST 必须携带 GET 返回的 `revision`;缺失返回 428,冲突返回 409。
- 只允许 `plugin_id` 存在于注册表,未注册返回 `ok:false`(防任意写入)。 - 只允许 `plugin_id` 存在于注册表,未注册返回 `ok:false`(防任意写入)。
- 部署时必须显式设置 Web 管理员凭据;禁止生产环境使用默认的 `admin/admin`。 - 部署时必须显式设置 Web 管理员凭据;禁止生产环境使用默认的 `admin/admin`。
@@ -277,7 +281,7 @@ register_plugin_config(
```python ```python
# hexi/plugins/nonebot_plugin_helldivers_tools/__init__.py # 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 from .config import config as _hd2_config
register_model_config( register_model_config(
@@ -295,28 +299,28 @@ register_model_config(
## 12. 已接入与待接入 ## 12. 已接入与待接入
- **已接入**:`helldivers_tools`、`mc_server_status`、`video_analysis`、`steam_info`、`picstatus`、`galgame_card`。 - **已接入**:`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. 插件内部配置项(来源无关)接入标准 ## 13. 插件内部配置项(来源无关)接入标准
> **不只是 `.env`**:插件内部自己的配置项(module 常量、YAML/JSON 配置、数据库里的开关等)同样遵循统一 schema,Web 可读可改。核心是让每个配置项自带 **getter/setter**,与来源解耦。 > **不只是 `../../.env`**:插件内部自己的配置项(module 常量、YAML/JSON 配置、数据库里的开关等)同样遵循统一 schema,Web 可读可改。核心是让每个配置项自带 **getter/setter**,与来源解耦。
### 13.1 四种来源与接入方式 ### 13.1 四种来源与接入方式
| 配置来源 | 接入方式 | getter/setter | 持久化 | 运行期生效点 | | 配置来源 | 接入方式 | getter/setter | 持久化 | 运行期生效点 |
|---|---|---|---|---| |---|---|---|---|---|
| pydantic Config(`.env` 驱动) | `register_model_config` | 自动(读写 Config 对象) | 写值库 + 写 `.env` | apply 热更新对象 | | pydantic Config(`../../.env` 驱动) | `register_model_config` | 自动(读写 Config 对象) | 写值库 + 写 `../../.env` | apply 热更新对象 |
| 模块常量 | `register_config_items(..., store=模块)` | 自动(getattr/setattr) | 写值库(可选写 `.env`) | 需插件用 `get_effective_value` 读取 | | 模块常量 | `register_config_items(..., store=模块)` | 自动(getattr/setattr) | 写值库(可选写 `../../.env`) | 需插件用 `get_effective_value` 读取 |
| 配置文件(dict/JSON/YAML) | `register_config_items(..., store=dict)` | 自动(读写 dict) | 插件自身写回文件 / 值库 | 插件从 dict 读取时即生效 | | 配置文件(dict/JSON/YAML) | `register_config_items(..., store=dict)` | 自动(读写 dict) | 插件自身写回文件 / 值库 | 插件从 dict 读取时即生效 |
| 自定义(DB/运行态) | `register_config_items` 传 `getter`/`setter` | 自定义 | 自定义回写 | 自定义 | | 自定义(DB/运行态) | `register_config_items` 传 `getter`/`setter` | 自定义 | 自定义回写 | 自定义 |
### 13.2 通用注册 API(任意来源) ### 13.2 通用注册 API(任意来源)
```python ```python
from hexi.config_standard import register_config_items from hexi.web_hub.config_standard import register_config_items
register_config_items( register_config_items(
__name__, # = NoneBot 插件模块名 __name__, # = NoneBot 插件模块名
@@ -339,7 +343,7 @@ register_config_items(
Web 保存后 apply 会把新值写回来源(模块属性/dict/自定义),但若插件在别处是用 `from .config import X` **值拷贝**进来的量,不受影响。要真正运行期生效,插件在读配置处改用统一 API: Web 保存后 apply 会把新值写回来源(模块属性/dict/自定义),但若插件在别处是用 `from .config import X` **值拷贝**进来的量,不受影响。要真正运行期生效,插件在读配置处改用统一 API:
```python ```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) limit = get_effective_value("hexi.plugins.nonebot_plugin_picfinder_take", "DAILY_LIMIT", 50)
if not check_quota(limit): ... if not check_quota(limit): ...
@@ -395,7 +399,7 @@ if not check_quota(limit): ...
### 14.3 一键注册 API ### 14.3 一键注册 API
```python ```python
from hexi.config_standard import register_object_set from hexi.web_hub.config_standard import register_object_set
register_object_set( register_object_set(
__name__, # NoneBot 插件模块名 __name__, # NoneBot 插件模块名
@@ -13,7 +13,7 @@
## 1. 包命名与位置 ## 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>`。 - 包名 = NoneBot 模块名 = `hexi.plugins.nonebot_plugin_<name>`。
- **`plugin_id` 约定**:用于配置 schema / 插件控制 / Web 挂载的值,一律等于模块名(`__name__`)。 - **`plugin_id` 约定**:用于配置 schema / 插件控制 / Web 挂载的值,一律等于模块名(`__name__`)。
@@ -84,6 +84,7 @@ nonebot_plugin_xxx/
## 4. `__init__.py` 标准职责(固定顺序) ## 4. `__init__.py` 标准职责(固定顺序)
1. `__plugin_meta__ = PluginMetadata(name=..., description=..., usage=..., type="application")`。 1. `__plugin_meta__ = PluginMetadata(name=..., description=..., usage=..., type="application")`。
> **type 取值(NoneBot 发布规范)**:`application`(向机器人用户提供功能,支持热插拔/热重载)或 `library`(为其他插件提供能力,不可热拔插)。新插件默认 `application`。本仓库的 `hexi/core`、`hexi/web_hub` 已从插件目录移出,作为机器人核心模块(**非插件**),直接不参与热拔插。
2. 声明依赖 `require("nonebot_plugin_alconna")` 等。 2. 声明依赖 `require("nonebot_plugin_alconna")` 等。
3. 显式导入子模块(`from . import handlers, services, utils`),注册 matcher/handler。 3. 显式导入子模块(`from . import handlers, services, utils`),注册 matcher/handler。
4. **配置注册**(在 config 就绪后):`register_model_config`(pydantic Config)或 `register_config_items` / `register_object_set`(来源无关)。 4. **配置注册**(在 config 就绪后):`register_model_config`(pydantic Config)或 `register_config_items` / `register_object_set`(来源无关)。
@@ -93,7 +94,7 @@ nonebot_plugin_xxx/
## 5. config.py 标准 ## 5. config.py 标准
### 4.1 环境驱动型(读 `.env` / NoneBot config) ### 4.1 环境驱动型(读 `../../.env` / NoneBot config)
```python ```python
from nonebot import get_plugin_config from nonebot import get_plugin_config
from pydantic import BaseModel from pydantic import BaseModel
@@ -108,7 +109,7 @@ config = get_plugin_config(Config)
### 4.2 来源无关型(模块常量 / 配置文件 / DB) ### 4.2 来源无关型(模块常量 / 配置文件 / DB)
```python ```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", register_object_set(__name__, "groups", [ {item_schema...} ], key_field="group_id",
getter=get_groups, setter=set_groups, nosave=True) 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;把「一个业务动作」收敛到一个方法。 - `services/`:业务编排,可依赖 repository / 外部 API;把「一个业务动作」收敛到一个方法。
- **禁止** `from x import *`、`except: pass`、`print()`;用 `logger`。 - **禁止** `from x import *`、`except: pass`、`print()`;用 `logger`。
- async handler 内避免同步阻塞(`requests` / `time.sleep` / 同步爬虫);需要就 `asyncio.to_thread`。 - 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:统一超时 + 重试 + 失败降级/用户提示。 - 外部 API:统一超时 + 重试 + 失败降级/用户提示。
## 8. Web 接入标准 ## 8. Web 接入标准
- 需要独立 Web 页:`register_web_plugin(id, name, icon, lambda: build_app(), module_name=__name__)`,hub 启动自动挂载 `/api/<id>`。 - 需要独立 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 驱动表单)。 - 需要 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`,前端掩码;写回允许明文。 - 敏感字段 `secret=True`,前端掩码;写回允许明文。
## 9. 插件迁移检查清单 ## 9. 插件迁移检查清单
- [ ] 包名改为 `nonebot_plugin_*`,位置在 `hexi/plugins/`。 - [ ] 包名改为 `nonebot_plugin_*`,位置在 `../../hexi/plugins`。
- [ ] 有 `__plugin_meta__`,`type="application"`。 - [ ] 有 `__plugin_meta__`,`type="application"`(核心/框架插件标 `library`,不参与热拔插)。
- [ ] 配置已接入统一标准(Web 能读能改,运行期/重启生效)。 - [ ] 配置已接入统一标准(Web 能读能改,运行期/重启生效)。
- [ ] 命令在 `handlers/`,业务在 `services/`,工具在 `utils/`。 - [ ] 命令在 `handlers/`,业务在 `services/`,工具在 `utils/`。
- [ ] 数据访问集中(repository / services),原子写 / 事务。 - [ ] 数据访问集中(repository / services),原子写 / 事务。
+75
View File
@@ -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` 回调,将不会执行热重载。
View File
@@ -31,8 +31,8 @@ nonebot_plugin_template/
## Web 子应用 ## Web 子应用
- `web/admin.py::build_admin_app()` 返回一个 FastAPI 实例,经 `register_web_plugin` 由 hub 挂载到 `/api/template`。 - `web/admin.py::build_admin_app()` 返回一个 FastAPI 实例,经 `register_web_plugin` 由 hub 挂载到 `/api/template`。
- 鉴权统一 `hexi.web_auth.require_admin`;示例端点:`GET /ping`、`GET /config`(读插件配置)。 - 鉴权统一 `hexi.web_hub.web_auth.require_admin`;示例端点:`GET /ping`、`GET /config`(读插件配置)。
- 想要更丰富的管理页:前端放 `web/dist/`,在 admin.py 里挂 `StaticFiles` + SPA 兜底即可(参考 `nonebot_plugin_web_hub`)。 - 想要更丰富的管理页:前端放 `web/dist/`,在 admin.py 里挂 `StaticFiles` + SPA 兜底即可(参考 `hexi/web_hub`)。
## 约定 ## 约定
- `plugin_id` = 模块名(自动取 `__package__`;web 子应用用 `__package__.rsplit(".", 1)[0]`)。 - `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 handlers as _handlers # noqa: E402,F401
from . import web as _web # 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() _config.register_config()
@@ -11,7 +11,7 @@ from __future__ import annotations
from nonebot import get_plugin_config from nonebot import get_plugin_config
from pydantic import BaseModel 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 = NoneBot 插件模块名
_PLUGIN_ID = __package__ _PLUGIN_ID = __package__
@@ -8,7 +8,7 @@ from nonebot.rule import to_me
from nonebot.params import CommandArg from nonebot.params import CommandArg
from nonebot_plugin_alconna import UniMessage 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 from ..services.main import do_something
@@ -2,7 +2,7 @@
from __future__ import annotations 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 from ..repository import get_record, save_record
@@ -2,9 +2,9 @@
- 由 `__init__.py` 里 `register_web_plugin("template", ..., module_name=__name__)` 注册, - 由 `__init__.py` 里 `register_web_plugin("template", ..., module_name=__name__)` 注册,
hub 启动时自动挂载到 `/api/template`。 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(...)`。 `Depends` 实例,直接用 `_: dict = API` 注入,不要再包一层 `Depends(...)`。
- 可直接读写 `hexi.web_config` 的插件配置。 - 可直接读写 `hexi.web_hub.web_config` 的插件配置。
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,7 +13,7 @@ from fastapi import FastAPI
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from hexi import web_config 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 # 插件模块名(去掉 .web):= NoneBot 模块名 = plugin_id
_PLUGIN_ID = __package__.rsplit(".", 1)[0] _PLUGIN_ID = __package__.rsplit(".", 1)[0]
+1
View File
@@ -0,0 +1 @@
# 机器人核心目录
@@ -1,18 +1,11 @@
from nonebot import get_driver, logger from nonebot import get_driver, logger
from nonebot.plugin import PluginMetadata
__plugin_meta__ = PluginMetadata( # 显式导入子模块注册 matcher/工具(hexi/core 为机器人核心模块,非 NoneBot 插件)
name="HeXi 核心工具",
description="HeXi 公共工具库:权限分级、消息解析、限频器、命令冷却、插件热插拔等",
usage="工具库 + 帮助/插件管理命令(帮助、插件列表、加载/卸载/重载插件)",
type="application",
)
# 显式导入子模块注册 matcher(load_plugins 只加载到插件包层,不会自动导入包内模块)
from . import ( # noqa: E402,F401 from . import ( # noqa: E402,F401
cooldown, cooldown,
custom_utils, custom_utils,
message_utils, message_utils,
outbound_media,
plugin_control, plugin_control,
plugin_manager, plugin_manager,
rate_limit, rate_limit,
@@ -24,3 +17,9 @@ async def _startup_plugin_gate() -> None:
"""等所有插件 matcher 注册完成后,把统一 filter 规则注入到 application 插件。""" """等所有插件 matcher 注册完成后,把统一 filter 规则注入到 application 插件。"""
n = plugin_control.instrument_plugin_gate() n = plugin_control.instrument_plugin_gate()
logger.info(f"插件控制面: 已注入 {n} 条 gateway 规则") 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 - "global":所有人共享一个 CD
用法一(装饰器,包在 on_command 外面): 用法一(装饰器,包在 on_command 外面):
from hexi.plugins.nonebot_plugin_hexi_core.cooldown import cooldown from hexi.core.cooldown import cooldown
# 单用户 10s CD,群聊/私聊共用 # 单用户 10s CD,群聊/私聊共用
bf_cmd = cooldown(10, hint="查询太频繁了,{secs} 秒后再试")(on_command("bf3")) 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) cd = Cooldown(10)
if cd.in_cd(ev.get_user_id()): if cd.in_cd(ev.get_user_id()):
@@ -36,7 +36,7 @@ from nonebot.adapters import Event
from nonebot.dependencies import Dependent from nonebot.dependencies import Dependent
from nonebot.matcher import Matcher 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 互不冲突 # 无 name 时的实例序号,保证同一进程内各实例的注册表 key 互不冲突
_cd_seq = itertools.count(1) _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 对象 ├── __init__.py # 插件入口,导出 hexi 对象
├── poke.py # 戳一戳 ├── poke.py # 戳一戳
├── forward.py # 合并转发消息构建器 ├── forward.py # 合并转发消息构建器
@@ -1,11 +1,19 @@
from pathlib import Path
from nonebot import get_bot from nonebot import get_bot
from nonebot import require from nonebot import require
from nonebot.adapters import Event
from nonebot.adapters.onebot.v11 import Bot, Message, MessageEvent, MessageSegment from nonebot.adapters.onebot.v11 import Bot, Message, MessageEvent, MessageSegment
from nonebot.log import logger from nonebot.log import logger
from nonebot_plugin_alconna import UniMessage
from nonebot_plugin_alconna.uniseg import Receipt, Target
require("nonebot_plugin_htmlrender") require("nonebot_plugin_htmlrender")
from nonebot_plugin_htmlrender import md_to_pic from nonebot_plugin_htmlrender import md_to_pic
# 「处理中」占位动图(任务驱动通用回复用)
THINKING_GIF = Path(__file__).parents[1] / "resource" / "imgs" / "thinking.gif"
async def send_markdown( async def send_markdown(
bot: Bot, bot: Bot,
@@ -108,3 +116,64 @@ async def send_forward_msg(
except Exception as e: except Exception as e:
logger.warning(f"合并转发发送失败,回退文本: {e}") logger.warning(f"合并转发发送失败,回退文本: {e}")
await bot.send(event, fallback) 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
+487
View File
@@ -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.internal.matcher import matchers as matchers_registry
from nonebot.rule import Rule 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" DATA_DIR = _PLUGIN_ROOT / "data"
STORE_PATH = DATA_DIR / "plugin_control.json" 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]]: def list_plugins() -> list[dict[str, Any]]:
"""枚举所有 application 插件(或注册了配置 schema 的插件)及其控制面状态。""" """枚举所有 application 插件(或注册了配置 schema 的插件)及其控制面状态。"""
from nonebot.plugin import get_loaded_plugins 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]] = [] result: list[dict[str, Any]] = []
seen: set[str] = set() seen: set[str] = set()
@@ -174,6 +174,7 @@ def list_plugins() -> list[dict[str, Any]]:
"name": meta.name, "name": meta.name,
"description": meta.description or "", "description": meta.description or "",
"usage": meta.usage or "", "usage": meta.usage or "",
"type": meta.type,
"enabled": ctl["global"]["enabled"], "enabled": ctl["global"]["enabled"],
"chat": ctl["global"]["chat"], "chat": ctl["global"]["chat"],
"groups": {gid: lvl for gid, lvl in ctl["groups"].items()}, "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.permission import SUPERUSER
from nonebot.plugin import ( from nonebot.plugin import (
Plugin, Plugin,
PluginMetadata,
_plugins, _plugins,
get_loaded_plugins, get_loaded_plugins,
get_plugin_by_module_name, get_plugin_by_module_name,
@@ -26,16 +25,10 @@ from nonebot.rule import Rule, to_me
require("nonebot_plugin_alconna") require("nonebot_plugin_alconna")
from nonebot_plugin_alconna import UniMessage 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 from . import plugin_control
__plugin_meta__ = PluginMetadata( # 插件热拔插管理(hexi.core 核心模块,非 NoneBot 插件)
name="插件管理",
description="热插拔插件:运行时加载/卸载/重载插件(仅超管)",
usage="插件列表 / 加载插件 <模块名> / 卸载插件 <模块名> / 重载插件 <模块名>",
type="application",
)
def _forward_sender_name() -> str: def _forward_sender_name() -> str:
"""合并转发节点的发送者昵称(用机器人配置昵称,插件名写进 content)""" """合并转发节点的发送者昵称(用机器人配置昵称,插件名写进 content)"""
nickname = get_driver().config.nickname nickname = get_driver().config.nickname
@@ -94,8 +87,33 @@ def _unregister_plugin(plugin: Plugin) -> None:
parent.sub_plugins.discard(plugin) 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: def hot_load(name: str) -> bool:
"""热加载插件模块(直接 import,matcher 随之注册)""" """热加载插件模块(直接 import,matcher 随之注册)"""
if _is_library(name):
logger.warning(f"插件 {name} 是非 application(library/未声明)插件,不支持热加载")
return False
if name in _hot_plugins: if name in _hot_plugins:
return False return False
if any(p.module_name == name for p in get_loaded_plugins()): 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 不限于热加载的插件:启动时加载的插件(hexi/plugins 与 pyproject.toml
声明)同样支持卸载,以 NoneBot 插件注册表为准查找。 声明)同样支持卸载,以 NoneBot 插件注册表为准查找。
安全限制:非 application 插件(library/未声明类别,常为核心库/框架)被其他
插件依赖,热卸载会让依赖方引用悬空并导致运行混乱,故拒绝卸载。
""" """
if _is_library(name):
logger.warning(f"插件 {name} 是非 application(library/未声明)插件,热卸载会使依赖它的插件运行混乱,已拒绝")
return False
plugin = get_plugin_by_module_name(name) plugin = get_plugin_by_module_name(name)
if plugin is None and name not in _hot_plugins: if plugin is None and name not in _hot_plugins:
return False return False
@@ -148,6 +173,9 @@ def hot_unload(name: str) -> bool:
def hot_reload(name: str) -> bool: def hot_reload(name: str) -> bool:
"""热重载插件:先卸载再加载""" """热重载插件:先卸载再加载"""
if _is_library(name):
logger.warning(f"插件 {name} 是非 application(library/未声明)插件,热重载会使依赖它的插件运行混乱,已拒绝")
return False
hot_unload(name) hot_unload(name)
return hot_load(name) return hot_load(name)
@@ -3,7 +3,7 @@
纯逻辑实现,不依赖 NoneBot 运行时;同步(requests)与异步(httpx/aiohttp)均可用。 纯逻辑实现,不依赖 NoneBot 运行时;同步(requests)与异步(httpx/aiohttp)均可用。
用法: 用法:
from hexi.plugins.nonebot_plugin_hexi_core import rate_limit from hexi.core import rate_limit
# 异步请求前取令牌(无令牌时等待,最多等 timeout 秒,超时返回 False) # 异步请求前取令牌(无令牌时等待,最多等 timeout 秒,超时返回 False)
if not await rate_limit.acquire("steam", rate=1, capacity=2): if not await rate_limit.acquire("steam", rate=1, capacity=2):
+1
View File
@@ -0,0 +1 @@
# 机器人插件目录
+1
View File
@@ -33,6 +33,7 @@ __plugin_meta__ = PluginMetadata(
name="表情裸词操作", name="表情裸词操作",
description="恢复“表情 操作词”的裸词用法(对称 右、摸 圆、小丑 爷 等)", description="恢复“表情 操作词”的裸词用法(对称 右、摸 圆、小丑 爷 等)",
usage="对称 右 / 鬼畜 下 / 摸 圆 / 小丑 爷 / 小丑面具 前", usage="对称 右 / 鬼畜 下 / 摸 圆 / 小丑 爷 / 小丑面具 前",
type="application",
) )
try: try:
@@ -29,6 +29,7 @@ __plugin_meta__ = PluginMetadata(
解绑: /解绑 (解除你的QQ与当前绑定的EAID) 解绑: /解绑 (解除你的QQ与当前绑定的EAID)
修改绑定: /修改绑定 EAID (修改你的QQ与当前绑定的EAID) 修改绑定: /修改绑定 EAID (修改你的QQ与当前绑定的EAID)
""".strip(), """.strip(),
type="application",
extra={ extra={
}, },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 199 KiB

File diff suppressed because it is too large Load Diff
@@ -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 nonebot import on_command
from .utils import *
import httpx
from nonebot import on_command, logger
from nonebot.adapters.onebot.v11 import MessageEvent, Bot, MessageSegment
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
@@ -21,106 +10,4 @@ __plugin_meta__ = PluginMetadata(
type="application", type="application",
) )
wife = on_command("今日老婆", aliases={"今日老婆"}) from .handlers import wife # noqa: E402,F401
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)
@@ -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 nonebot import require
from .img_generator import * from nonebot.plugin import PluginMetadata
import io
import base64 require("nonebot_plugin_orm")
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
name="打卡记鹿", name="打卡记鹿",
@@ -16,54 +13,4 @@ __plugin_meta__ = PluginMetadata(
type="application", type="application",
) )
record = on_command("打卡记鹿", aliases={"打卡"}, rule=to_me()) from .handlers import query, record # noqa: E402,F401
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)
@@ -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 datetime import datetime
from nonebot import require from nonebot import require
require("nonebot_plugin_orm") require("nonebot_plugin_orm")
from nonebot_plugin_orm import Model, get_session from nonebot_plugin_orm import get_session
from sqlalchemy import BigInteger, Date, Integer, select from sqlalchemy import select
from sqlalchemy.orm import Mapped, mapped_column
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): async def record_month(uid):
now = datetime.now() now = datetime.now()
async with get_session() as session: async with get_session() as session:
@@ -27,7 +21,6 @@ async def record_month(uid):
await session.commit() await session.commit()
# 读取当月信息(返回当月所有打卡的日,同一天多次打卡会有重复项)
async def get_records(uid): async def get_records(uid):
now = datetime.now() now = datetime.now()
month_start = datetime(now.year, now.month, 1).date() 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 """打卡记鹿 —— 渲染工具(utils 层)。"""
from collections import Counter
import os
from datetime import datetime, timedelta
from PIL import Image, ImageDraw, ImageFont, ImageOps
from .data_proc import *
import random
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): async def draw_calendar_grid_image(uid, year, month):
# 获取该月的第一天是星期几,以及这个月有多少天
first_weekday, num_days = calendar.monthrange(year, month) first_weekday, num_days = calendar.monthrange(year, month)
# 调整第一天的索引,使周日对应0,周六对应6
first_weekday = (first_weekday + 1) % 7 first_weekday = (first_weekday + 1) % 7
# 设置单元格尺寸和内边距
cell_size = 50 cell_size = 50
cell_padding = 5 cell_padding = 5
num_rows = (num_days + first_weekday + 6) // 7 + 1
# 计算当前月份的行数
num_rows = (num_days + first_weekday + 6) // 7 + 1 # +1 行用于留空
# 计算图片宽度和高度
image_width = 7 * (cell_size + cell_padding) - cell_padding image_width = 7 * (cell_size + cell_padding) - cell_padding
image_height = num_rows * (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") img = Image.new("RGBA", (image_width, image_height), "white")
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
font_path = filepath + "/font/SourceHanSansCN-Medium.otf" 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_normal = ImageFont.truetype(font=font_path, size=18)
font_large = ImageFont.truetype(font=font_path, size=24) font_large = ImageFont.truetype(font=font_path, size=24)
# 绘制网格线
for i in range(7): for i in range(7):
x = i * (cell_size + cell_padding) x = i * (cell_size + cell_padding)
draw.line([(x, 55), (x, image_height)], fill="black", width=1) draw.line([(x, 55), (x, image_height)], fill="black", width=1)
for j in range(0, image_height + cell_size, cell_size + cell_padding): for j in range(0, image_height + cell_size, cell_size + cell_padding):
draw.line([(0, j), (image_width, j)], fill="black", width=1) draw.line([(0, j), (image_width, j)], fill="black", width=1)
# 小🦌
deer_pipe_path = filepath + "/img/deer_pipe.jpg" deer_pipe_path = filepath + "/img/deer_pipe.jpg"
deer_pipe_img = Image.open(deer_pipe_path) deer_pipe_img = Image.open(deer_pipe_path)
deer_pipe_img = deer_pipe_img.resize((54, 50)) deer_pipe_img = deer_pipe_img.resize((54, 50))
# 画标题
title = f"{month}月打卡记鹿" title = f"{month}月打卡记鹿"
text_length = draw.textlength(text=title, font=font_large) text_length = draw.textlength(text=title, font=font_large)
img_xy = (int(((image_width - text_length + 55) / 2) - 55), 5) img_xy = (int(((image_width - text_length + 55) / 2) - 55), 5)
title_pos = ((image_width - text_length + 55) / 2, 10) title_pos = ((image_width - text_length + 55) / 2, 10)
img.paste(deer_pipe_img, img_xy) img.paste(deer_pipe_img, img_xy)
draw.text(title_pos, title, fill="black", font=font_large) draw.text(title_pos, title, fill="black", font=font_large)
# 获取🦌信息(同一天多次打卡会得到重复项)
day_counts = Counter(await get_records(uid)) day_counts = Counter(await get_records(uid))
# 填充日期
date = datetime(year, month, 1) date = datetime(year, month, 1)
for day in range(1, num_days + 1): for day in range(1, num_days + 1):
weekday = (first_weekday + day - 1) % 7 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 x = weekday * (cell_size + cell_padding) + cell_padding
y = row * (cell_size + cell_padding) + cell_padding y = row * (cell_size + cell_padding) + cell_padding
img.paste(deer_pipe_img, (x - 4, y)) img.paste(deer_pipe_img, (x - 4, y))
draw.text((x + 20, y - 2), f"{str(day)}", fill="black", font=font_normal) draw.text((x + 20, y - 2), f"{str(day)}", fill="black", font=font_normal)
# 🦌是天的标记,打卡的PNG印记才是要多次绘制的对象,横向错开
for i in range(day_counts.get(day, 0)): for i in range(day_counts.get(day, 0)):
im = get_random_right() im = get_random_right()
img = image_paste(im, img, (x - 4 + i * 10, y)) 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="black")
img = ImageOps.expand(img, border=5, fill="white") img = ImageOps.expand(img, border=5, fill="white")
return img return img
# 全局变量,用于存储已经选择过的文件名
selected_images = [] selected_images = []
def get_random_right(): def get_random_right():
global selected_images global selected_images
path = filepath + "/img/right" path = filepath + "/img/right"
# 过滤掉已经选择过的文件名
im_name = [name for name in os.listdir(path) if name not in selected_images] im_name = [name for name in os.listdir(path) if name not in selected_images]
# 如果所有文件都已经选择过,重新初始化已选择列表
if not im_name: if not im_name:
selected_images = [] selected_images = []
im_name = os.listdir(path) im_name = os.listdir(path)
index = random.randint(0, len(im_name) - 1) index = random.randint(0, len(im_name) - 1)
im = im_name[index] im = im_name[index]
selected_images.append(im) # 将选择的文件名添加到已选择列表中 selected_images.append(im)
im_path = os.path.join(path, im) im_path = os.path.join(path, im)
im_file = Image.open(im_path) im_file = Image.open(im_path)
im_file = im_file.resize((50, 50)) im_file = im_file.resize((50, 50))
return im_file return im_file
# 图片粘贴
def image_paste(paste_image, under_image, pos): 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() r, g, b, alpha = paste_image.split()
# 粘贴时将alpha值传递至mask属性
under_image.paste(paste_image, pos, alpha) under_image.paste(paste_image, pos, alpha)
return under_image return under_image
+3 -72
View File
@@ -1,13 +1,9 @@
import random """掷骰子 / 结婚判定"""
import re
from nonebot import on_regex, on_startswith, require from nonebot import require
from nonebot.adapters.onebot.v11 import MessageEvent
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
from nonebot.typing import T_State
require("nonebot_plugin_alconna") require("nonebot_plugin_alconna")
from nonebot_plugin_alconna import UniMessage
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
name="掷骰子", name="掷骰子",
@@ -16,69 +12,4 @@ __plugin_meta__ = PluginMetadata(
type="application", type="application",
) )
dice = on_regex( from .handlers import dice, marriage # noqa: E402,F401
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)
@@ -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 风格人物卡 """群聊人设卡 —— 基于群聊语料蒸馏成员形象,生成 galgame 风格人物卡
消息入口:on_message 监听全部消息事件,类型注解过滤出群聊, 消息入口:on_message 监听全部消息事件,类型注解过滤出群聊,两级闸门(群开关 +
两级闸门(群开关 + 个人 opt-in)通过后,治理层处理落库。 个人 opt-in)通过后,治理层处理落库。采集编排已下沉到 services/collector.py。
当前进度:数据层 + 采集层(消息路径)完成;总结路径(印象/画像)待实现。 当前进度:数据层 + 采集层(消息路径)完成;总结路径(印象/画像)待实现。
""" """
import json from nonebot import get_driver, require
import time
from collections import deque
from typing import Optional
from nonebot import get_driver, on_message, require
require("nonebot_plugin_orm") 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 from nonebot.plugin import PluginMetadata
# ── 禁用 nonebot_plugin_orm 的启动自动同步 ───────────────────────────── # ── 禁用 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 from nonebot import logger as _logger
try: try:
import nonebot_plugin_orm.migrate as _orm_migrate import nonebot_plugin_orm.migrate as _orm_migrate
def _safe_sync(*_args, **_kwargs): def _safe_sync(*_args, **_kwargs):
# 同步函数:orm 用 greenlet_spawn 同步调用 migrate.sync,
# 若写成 async def 会产生 never-awaited coroutine 警告
_logger.debug("galgame_card | 已禁用 orm 自动同步(schema 演进由 ensure_schema 接管)") _logger.debug("galgame_card | 已禁用 orm 自动同步(schema 演进由 ensure_schema 接管)")
_orm_migrate.sync = _safe_sync _orm_migrate.sync = _safe_sync
@@ -40,17 +28,15 @@ except ImportError:
from . import models # noqa: F401 # 确保表结构注册 from . import models # noqa: F401 # 确保表结构注册
from . import repository, processor 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_hub.web_config import register_plugin_config # noqa: E402
from hexi.web_plugin_registry import register_web_plugin # noqa: E402 from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402
from . import web_hub # noqa: E402 from . import web_hub # noqa: E402
def _apply_galgame_config(values: dict) -> None: def _apply_galgame_config(values: dict) -> None:
"""Web 保存后热刷新:直接改 config 模块的 pydantic 对象,无需重启。"""
import sys import sys
conf_mod = sys.modules.get("hexi.plugins.nonebot_plugin_galgame_card.config") conf_mod = sys.modules.get("hexi.plugins.nonebot_plugin_galgame_card.config")
if conf_mod is None: if conf_mod is None:
return return
@@ -65,14 +51,12 @@ def _apply_galgame_config(values: dict) -> None:
def _get_galgame_config_values() -> dict: def _get_galgame_config_values() -> dict:
"""Web 表单回填当前生效值。"""
import sys import sys
conf_mod = sys.modules.get("hexi.plugins.nonebot_plugin_galgame_card.config") conf_mod = sys.modules.get("hexi.plugins.nonebot_plugin_galgame_card.config")
conf = getattr(conf_mod, "config", None) if conf_mod else None conf = getattr(conf_mod, "config", None) if conf_mod else None
if conf is None: if conf is None:
return {} 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} return {k: getattr(conf, k, None) for k in keys}
@@ -80,10 +64,6 @@ register_plugin_config(
__name__, __name__,
{ {
"fields": [ "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}, {"key": "image_vision_enabled", "label": "图片识别启用", "type": "bool", "default": False},
] ]
}, },
@@ -104,32 +84,9 @@ __plugin_meta__ = PluginMetadata(
name="群聊人设卡", name="群聊人设卡",
description="基于群聊语料蒸馏成员形象,生成 galgame 风格人物卡", description="基于群聊语料蒸馏成员形象,生成 galgame 风格人物卡",
usage="开发中……", 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() _driver = get_driver()
@@ -137,51 +94,3 @@ _driver = get_driver()
async def _ensure_schema(): async def _ensure_schema():
"""老库迁移(persona_group.group_name 列),幂等""" """老库迁移(persona_group.group_name 列),幂等"""
await repository.ensure_schema() 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,
)

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