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>
This commit is contained in:
2026-09-03 00:44:38 +08:00
co-authored by Claude
parent 25bc30611f
commit 9371a28e35
2389 changed files with 5100 additions and 10127 deletions
+2 -1
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/
+1 -3
View File
@@ -12,7 +12,6 @@
| `nonebot_plugin_deadlock` | Deadlock 战绩、段位、近期比赛查询 | `/nekoscore` `/nekobind` `/nekounbind` `/nekorank` `/nekomatch` | | `nonebot_plugin_deadlock` | Deadlock 战绩、段位、近期比赛查询 | `/nekoscore` `/nekobind` `/nekounbind` `/nekorank` `/nekomatch` |
| `nonebot_plugin_steam_info` | Steam 玩家信息、库存、游戏时长查询 | `/steamhelp` `/steambind` `/steaminfo` `/steamplaytime` 等 | | `nonebot_plugin_steam_info` | Steam 玩家信息、库存、游戏时长查询 | `/steamhelp` `/steambind` `/steaminfo` `/steamplaytime` 等 |
| `nonebot_plugin_helldivers_tools` | 绝地潜兵 2 前线战况简报、星球详报、随机战备推荐 | `/简报` `/详报` `/随机战备` | | `nonebot_plugin_helldivers_tools` | 绝地潜兵 2 前线战况简报、星球详报、随机战备推荐 | `/简报` `/详报` `/随机战备` |
| `nonebot_plugin_brash_general_supercredits_tools` | 绝地潜兵 超级积分查询 | — |
| `nonebot_plugin_mc_server_status` | Minecraft 服务器状态查询 | `/信息` `/信息数据` | | `nonebot_plugin_mc_server_status` | Minecraft 服务器状态查询 | `/信息` `/信息数据` |
| `nonebot_plugin_ncm_saying` | 网易云音乐热评(网抑云) | `/网抑云` | | `nonebot_plugin_ncm_saying` | 网易云音乐热评(网抑云) | `/网抑云` |
| `nonebot-bison` | Bilibili / 微博等订阅推送 | — | | `nonebot-bison` | Bilibili / 微博等订阅推送 | — |
@@ -36,7 +35,6 @@
| 插件 | 功能 | 主要命令 | | 插件 | 功能 | 主要命令 |
| --- | --- | --- | | --- | --- | --- |
| `nonebot_plugin_hexi_core` | 核心库:消息解析、权限分级、限流器 (token bucket)、命令冷却、热插件管理 | — | | `nonebot_plugin_hexi_core` | 核心库:消息解析、权限分级、限流器 (token bucket)、命令冷却、热插件管理 | — |
| `nonebot_plugin_voice_trans` | 语音消息转文字 | `/voice` |
| `nonebot_plugin_video_analysis` | 视频链接解析 | — | | `nonebot_plugin_video_analysis` | 视频链接解析 | — |
| `nonebot_plugin_regif` | GIF 注册与处理 | — | | `nonebot_plugin_regif` | GIF 注册与处理 | — |
| `nonebot_plugin_picfinder_take` | 识图(Playwright 驱动) | — | | `nonebot_plugin_picfinder_take` | 识图(Playwright 驱动) | — |
@@ -126,4 +124,4 @@ poetry run pyright # 类型检查
## 许可 ## 许可
MIT(`pyproject.toml` 声明,仓库未附带 LICENSE 文件) 无
+31 -1
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})
+365
View File
@@ -0,0 +1,365 @@
# HeXi 插件规范化审计报告
> 依据 《插件结构标准》(docs/plugin-structure-standard.md) 与 《插件配置文件标准》(docs/plugin-config-standard.md),
> 并参照脚手架模板 `plugin_template/nonebot_plugin_template/`。
> 说明:初版为**审计报告**;后续按用户确认已开始落地改造(见下方「本轮已落地改动」)。
---
## 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) | 🟡 | 🟡 |
| 其余扁平插件 | ✅(多数) | ❌ | ❌ | ❌ | ❌(写插件目录/硬编码) |
+1
View File
@@ -33,6 +33,7 @@ __plugin_meta__ = PluginMetadata(
name="表情裸词操作", name="表情裸词操作",
description="恢复“表情 操作词”的裸词用法(对称 右、摸 圆、小丑 爷 等)", description="恢复“表情 操作词”的裸词用法(对称 右、摸 圆、小丑 爷 等)",
usage="对称 右 / 鬼畜 下 / 摸 圆 / 小丑 爷 / 小丑面具 前", usage="对称 右 / 鬼畜 下 / 摸 圆 / 小丑 爷 / 小丑面具 前",
type="application",
) )
try: try:
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,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
@@ -41,6 +29,7 @@ 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 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_config import register_plugin_config # noqa: E402
from hexi.web_plugin_registry import register_web_plugin # noqa: E402 from hexi.web_plugin_registry import register_web_plugin # noqa: E402
@@ -48,9 +37,7 @@ 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,9 +52,7 @@ 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:
@@ -104,32 +89,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 +99,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,
)
@@ -0,0 +1,3 @@
"""群聊人设卡 —— Trigger 层。"""
from .collector import collector # noqa: F401
@@ -0,0 +1,15 @@
"""群聊人设卡 —— 触发器(Trigger)层。"""
from __future__ import annotations
from nonebot import on_message
from nonebot.adapters.onebot.v11 import GroupMessageEvent
from ..services.collector import collect
collector = on_message(block=False) # 监听全部消息事件,不拦截其他插件
@collector.handle()
async def _collect(event: GroupMessageEvent):
await collect(event)
@@ -0,0 +1,3 @@
"""群聊人设卡 —— Service 层。"""
from .collector import collect # noqa: F401
@@ -0,0 +1,77 @@
"""群聊人设卡 —— 采集编排(Service 层)。
把 __init__.py 里的采样/复读/刷屏检测与落库编排抽到此处,使入口更薄。
"""
from __future__ import annotations
import json
import time
from collections import deque
from nonebot import get_driver
from .. import processor, repository
# 采样/复读检测的内存状态(不进库)
_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
async def collect(event):
"""鉴权 + 治理 + 采样 + 落库。"""
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,
)
@@ -30,4 +30,7 @@ __plugin_meta__ = PluginMetadata(
type="application", type="application",
) )
from . import config as _config # noqa: E402,F401
from .handlers import chat, management, notices, repeater # noqa: E402,F401 from .handlers import chat, management, notices, repeater # noqa: E402,F401
_config.register_config()
@@ -0,0 +1,32 @@
"""group_tools —— 配置注册(按《插件配置文件标准》)。
当前插件已具备 handlers/services/utils 分层;此处仅把模块级常量(如违禁词)
接入统一 Web 配置,使 Web 管理台(/hub)可读可改。
"""
from __future__ import annotations
import sys as _sys
from hexi.config_standard import register_config_items
from . import constants
# plugin_id = NoneBot 插件模块名
_PLUGIN_ID = __package__
def register_config() -> None:
register_config_items(
_PLUGIN_ID,
[
{
"key": "BANNED_WORDS",
"label": "违禁词",
"type": "text",
"item_type": "str",
"default": list(constants.BANNED_WORDS),
},
],
store=_sys.modules[constants.__name__],
)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,3 @@
"""聊天类触发(handlers/chat)。"""
from . import greet, reaction, voice # noqa: F401
@@ -1,21 +1,20 @@
import random """群管理工具 —— 聊天互动(问候/尬聊)。"""
from __future__ import annotations
from nonebot import get_driver, on_command, on_fullmatch, on_keyword, require from nonebot import get_driver, on_command, on_fullmatch, on_keyword, require
from nonebot.adapters.onebot.v11 import Bot, MessageEvent from nonebot.adapters.onebot.v11 import Bot, MessageEvent
from nonebot.rule import to_me from nonebot.rule import to_me
require("nonebot_plugin_alconna")
from nonebot_plugin_alconna import UniMessage from nonebot_plugin_alconna import UniMessage
from ..constants import BANNED_WORDS, RES_DIR from ...constants import RES_DIR
from ..services.moderation import silence from ...services.moderation import silence
from ..utils.media import image_to_base64, random_audio from ...utils.media import image_to_base64
require("nonebot_plugin_alconna")
SUPERUSERS = get_driver().config.superusers SUPERUSERS = get_driver().config.superusers
# ---------- 基础打招呼 ----------
say_hello = on_command( say_hello = on_command(
"zai?", "zai?",
aliases={"在?", "在?", "在吗", "在么?", "在嘛", "在嘛?"}, aliases={"在?", "在?", "在吗", "在么?", "在嘛", "在嘛?"},
@@ -29,8 +28,6 @@ async def say_hello_handle(ev: MessageEvent):
await UniMessage.text("はい!私はいつも貴方の側にいますよ!").send() await UniMessage.text("はい!私はいつも貴方の側にいますよ!").send()
# ---------- 关键词/整句互动 ----------
say_sorry = on_fullmatch("沙雕机器人") say_sorry = on_fullmatch("沙雕机器人")
@@ -95,70 +92,3 @@ nihaole = on_fullmatch("我好了")
@nihaole.handle() @nihaole.handle()
async def nihaole_handle(bot: Bot, ev: MessageEvent): async def nihaole_handle(bot: Bot, ev: MessageEvent):
await UniMessage.text("不许好,憋回去!").send() await UniMessage.text("不许好,憋回去!").send()
# await silence(bot, ev, 30)
play_zhanshi = on_keyword({"优质的战士"})
@play_zhanshi.handle()
async def play_zhanshi_handle(ev: MessageEvent):
base64_str = random_audio(RES_DIR / "record" / "voice" / "youzhizhanshi")
if base64_str:
await UniMessage.voice(url=base64_str).send()
play_ice = on_fullmatch("溜冰")
@play_ice.handle()
async def play_ice_handle(ev: MessageEvent):
base64_str = random_audio(RES_DIR / "record" / "voice" / "ice")
if base64_str:
await UniMessage.voice(url=base64_str).send()
play_dxl = on_fullmatch("罕见")
@play_dxl.handle()
async def play_dxl_handle(ev: MessageEvent):
base64_str = random_audio(RES_DIR / "record" / "voice" / "dxl")
if base64_str:
await UniMessage.voice(url=base64_str).send()
chat_queshi = on_keyword({"确实", "有一说一", "u1s1", "yysy"})
@chat_queshi.handle()
async def chat_queshi_handle(ev: MessageEvent):
if random.random() < 0.05:
base64_str = image_to_base64(RES_DIR / "img" / "确实.jpg")
if base64_str:
await UniMessage.image(url=base64_str).send()
chat_neigui = on_keyword({"内鬼"})
@chat_neigui.handle()
async def chat_neigui_handle(ev: MessageEvent):
if random.random() < 0.10:
base64_str = image_to_base64(RES_DIR / "img" / "内鬼.png")
if base64_str:
await UniMessage.image(url=base64_str).send()
# ---------- 违禁词黑手语音 ----------
banned_word_voice = on_command(
"ban_word", aliases=set(BANNED_WORDS), rule=to_me(), block=True
)
@banned_word_voice.handle()
async def banned_word_voice_handle(ev: MessageEvent):
base64_str = random_audio(RES_DIR / "record" / "voice" / "heishou")
if base64_str:
await UniMessage.voice(url=base64_str).send()
@@ -0,0 +1,37 @@
"""群管理工具 —— 聊天互动(概率表情)。"""
from __future__ import annotations
import random
from nonebot import require
from nonebot.adapters.onebot.v11 import MessageEvent
from nonebot_plugin_alconna import UniMessage
from nonebot import on_keyword
from ...constants import RES_DIR
from ...utils.media import image_to_base64
require("nonebot_plugin_alconna")
chat_queshi = on_keyword({"确实", "有一说一", "u1s1", "yysy"})
@chat_queshi.handle()
async def chat_queshi_handle(ev: MessageEvent):
if random.random() < 0.05:
base64_str = image_to_base64(RES_DIR / "img" / "确实.jpg")
if base64_str:
await UniMessage.image(url=base64_str).send()
chat_neigui = on_keyword({"内鬼"})
@chat_neigui.handle()
async def chat_neigui_handle(ev: MessageEvent):
if random.random() < 0.10:
base64_str = image_to_base64(RES_DIR / "img" / "内鬼.png")
if base64_str:
await UniMessage.image(url=base64_str).send()
@@ -0,0 +1,55 @@
"""群管理工具 —— 聊天互动(语音)。"""
from __future__ import annotations
from nonebot import on_command, on_fullmatch, on_keyword, require
from nonebot.adapters.onebot.v11 import MessageEvent
from nonebot.rule import to_me
from nonebot_plugin_alconna import UniMessage
from ...constants import BANNED_WORDS, RES_DIR
from ...utils.media import random_audio
require("nonebot_plugin_alconna")
play_zhanshi = on_keyword({"优质的战士"})
@play_zhanshi.handle()
async def play_zhanshi_handle(ev: MessageEvent):
base64_str = random_audio(RES_DIR / "record" / "voice" / "youzhizhanshi")
if base64_str:
await UniMessage.voice(url=base64_str).send()
play_ice = on_fullmatch("溜冰")
@play_ice.handle()
async def play_ice_handle(ev: MessageEvent):
base64_str = random_audio(RES_DIR / "record" / "voice" / "ice")
if base64_str:
await UniMessage.voice(url=base64_str).send()
play_dxl = on_fullmatch("罕见")
@play_dxl.handle()
async def play_dxl_handle(ev: MessageEvent):
base64_str = random_audio(RES_DIR / "record" / "voice" / "dxl")
if base64_str:
await UniMessage.voice(url=base64_str).send()
banned_word_voice = on_command(
"ban_word", aliases=set(BANNED_WORDS), rule=to_me(), block=True
)
@banned_word_voice.handle()
async def banned_word_voice_handle(ev: MessageEvent):
base64_str = random_audio(RES_DIR / "record" / "voice" / "heishou")
if base64_str:
await UniMessage.voice(url=base64_str).send()
@@ -1,399 +0,0 @@
import asyncio
import math
import random
import re
import time
from nonebot import logger, on_fullmatch, on_message, on_regex, on_startswith, require
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent
from nonebot.rule import Rule, is_type
require("nonebot_plugin_alconna")
from nonebot_plugin_alconna import UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
from ..constants import IMG_DIR, TEMP_DIR
from ..services.moderation import group_silence, member_silence, silence
from ..services.profile import card_edit, group_name, title_get
from ..services.video import download_video, get_video_info, local_video
from ..utils.media import image_to_base64
from ..utils.message import get_at_id, has_at_all
from ..utils.permissions import check_perm, honor_info, self_member_info
from ..utils.text import is_number, parse_seconds, strip_prefix
# ============ 管理员与公告 ============
set_admin = on_startswith("设置管理员")
unset_admin = on_startswith("取消管理员")
set_group_notice = on_startswith("设置群公告")
@set_admin.handle()
async def set_admin_handle(bot: Bot, ev: GroupMessageEvent):
if not await check_perm(bot, ev):
return
for seg in ev.message:
if seg.type == "at" and seg.data.get("qq") != "all":
await bot.set_group_admin(
group_id=ev.group_id, user_id=int(seg.data["qq"]), enable=True
)
await UniMessage.text("我好了").send()
@unset_admin.handle()
async def unset_admin_handle(bot: Bot, ev: GroupMessageEvent):
if not await check_perm(bot, ev):
return
for seg in ev.message:
if seg.type == "at" and seg.data.get("qq") != "all":
await bot.set_group_admin(
group_id=ev.group_id, user_id=int(seg.data["qq"]), enable=False
)
await UniMessage.text("我好了").send()
@set_group_notice.handle()
async def set_group_notice_handle(bot: Bot, ev: GroupMessageEvent):
content = ev.message.extract_plain_text().removeprefix("设置群公告").strip()
if not content:
return
if not await check_perm(bot, ev):
return
await bot.call_api("_send_group_notice", group_id=ev.group_id, content=content)
await UniMessage.text("我好了").send()
# ============ 禁言相关 ============
def _is_ban_cmd(event: GroupMessageEvent) -> bool:
"""分钟制禁言命令:"禁言@xxx 5" 表示禁言 5 分钟;排除秒制的 "禁言一下" """
text = event.message.extract_plain_text()
return text.startswith("禁言") and not text.startswith("禁言一下")
ban_user = on_message(rule=Rule(_is_ban_cmd) & is_type(GroupMessageEvent), block=True)
ban_member = on_startswith(("来发口球", "塞口球", "禁言一下"))
one_for_one = on_startswith("极限一换一")
cancel_ban = on_startswith(("解除口球", "取消口球", "摘口球", "脱口球", "取消禁言", "解除禁言", "解禁"))
ban_all = on_fullmatch(("全员口球", "全员禁言"))
cancel_ban_all = on_fullmatch(("取消全员口球", "取消全员禁言", "解除全员口球", "解除全员禁言"))
sleep_8h = on_fullmatch(("睡眠套餐", "休眠套餐", "精致睡眠", "来一份精致睡眠套餐"))
tea = on_regex(r"(来|來)(.*(份|个)(.*)(睡|茶)(.*))套餐")
@ban_user.handle()
async def ban_user_handle(bot: Bot, ev: GroupMessageEvent):
if not await check_perm(bot, ev, silent=True):
return
msg = ev.message.extract_plain_text().removeprefix("禁言").strip()
at_seg = next(
(seg for seg in ev.message if seg.type == "at" and seg.data.get("qq") != "all"),
None,
)
if not at_seg:
return
try:
uid = int(at_seg.data["qq"])
minutes = 1 if not msg else int(msg)
await bot.set_group_ban(
group_id=ev.group_id, user_id=uid, duration=minutes * 60
)
await UniMessage.text("我好了").send()
except Exception as ex:
logger.error(ex)
@ban_member.handle()
async def ban_member_handle(bot: Bot, ev: GroupMessageEvent):
uid = ev.user_id
gid = ev.group_id
duration_text = strip_prefix(
ev.message.extract_plain_text(), ("来发口球", "塞口球", "禁言一下")
)
if not duration_text:
duration_text = "30"
if has_at_all(ev):
await group_silence(bot, ev, gid, True)
return
sid = get_at_id(ev) or uid
await member_silence(bot, ev, uid, sid, gid, duration_text, silent=False)
@one_for_one.handle()
async def one_for_one_handle(bot: Bot, ev: GroupMessageEvent):
uid = ev.user_id
gid = ev.group_id
duration_text = strip_prefix(ev.message.extract_plain_text(), ("极限一换一",))
if has_at_all(ev):
await UniMessage.text("人干事?").send(at_sender=True)
await member_silence(bot, ev, uid, uid, gid, "180", silent=False)
return
seconds = parse_seconds(duration_text)
if seconds is None:
await UniMessage.text("请输入正确时长~").send(at_sender=True)
return
if seconds > 30:
await UniMessage.text("时长不可大于30秒哟~").send(at_sender=True)
return
sid = get_at_id(ev) or uid
await member_silence(bot, ev, uid, sid, gid, str(seconds), silent=True)
await member_silence(bot, ev, uid, uid, gid, str(seconds), silent=True)
await (
UniMessage.at(user_id=str(uid))
.text("成功一换一")
.at(user_id=str(sid))
.text(f"{seconds}秒~")
.send()
)
@cancel_ban.handle()
async def cancel_ban_handle(bot: Bot, ev: GroupMessageEvent):
if not await check_manage(bot, ev):
await UniMessage.text("权限不足").send()
return
uid = ev.user_id
gid = ev.group_id
if has_at_all(ev):
await group_silence(bot, ev, gid, False)
return
sid = get_at_id(ev)
if sid is None:
await UniMessage.text("请@需要摘口球的群员哦w").send()
return
await member_silence(bot, ev, uid, sid, gid, "0", silent=False)
@ban_all.handle()
async def ban_all_handle(bot: Bot, ev: GroupMessageEvent):
if not await check_manage(bot, ev):
await UniMessage.text("权限不足").send()
return
await group_silence(bot, ev, ev.group_id, True)
@cancel_ban_all.handle()
async def cancel_ban_all_handle(bot: Bot, ev: GroupMessageEvent):
if not await check_manage(bot, ev):
await UniMessage.text("权限不足").send()
return
await group_silence(bot, ev, ev.group_id, False)
@sleep_8h.handle()
async def sleep_8h_handle(bot: Bot, ev: MessageEvent):
await silence(bot, ev, 8 * 60 * 60)
@tea.handle()
async def tea_handle(bot: Bot, ev: MessageEvent):
base = 0 if "午" in ev.plain_text else 5 * 60 * 60
length = len(ev.plain_text)
sleep_time = base + round(
math.sqrt(length) * 60 * 30 + 60 * random.randint(-15, 15)
)
await silence(bot, ev, sleep_time)
# ============ 头衔 / 名片 / 群名 ============
special_title = on_startswith("申请头衔")
del_special_title = on_fullmatch(("删除头衔", "清除头衔", "收回头衔", "回收头衔"))
card_set = on_startswith(("修改名片", "修改群名片", "设置名片", "设置群名片"))
set_group_name = on_startswith(("修改群名", "设置群名"))
@special_title.handle()
async def special_title_handle(bot: Bot, ev: GroupMessageEvent):
uid = ev.user_id
gid = ev.group_id
title = strip_prefix(ev.message.extract_plain_text(), ("申请头衔",))
sid = get_at_id(ev) or uid
await title_get(bot, ev, uid, sid, gid, title)
# go-cqhttp似乎暂时不支持收回专属头衔...
@del_special_title.handle()
async def del_special_title_handle(bot: Bot, ev: GroupMessageEvent):
uid = ev.user_id
gid = ev.group_id
sid = get_at_id(ev) or uid
await title_get(bot, ev, uid, sid, gid, None)
@card_set.handle()
async def card_set_handle(bot: Bot, ev: GroupMessageEvent):
uid = ev.user_id
gid = ev.group_id
card_text = strip_prefix(
ev.message.extract_plain_text(), ("修改名片", "修改群名片", "设置名片", "设置群名片")
)
sid = get_at_id(ev) or uid
await card_edit(bot, ev, uid, sid, gid, card_text)
@set_group_name.handle()
async def set_group_name_handle(bot: Bot, ev: GroupMessageEvent):
if not await check_manage(bot, ev):
await UniMessage.text("权限不足").send()
return
gid = ev.group_id
name_text = strip_prefix(ev.message.extract_plain_text(), ("修改群名", "设置群名"))
await group_name(bot, ev, gid, name_text)
# ============ 龙王 ============
whois_dragon = on_fullmatch(("谁是龙王", "迫害龙王", "龙王是谁"))
dragon_list = on_fullmatch(("龙王榜", "龙王排行", "龙王排行榜", "龙王榜单"))
@whois_dragon.handle()
async def whois_dragon_handle(bot: Bot, ev: GroupMessageEvent):
gid = ev.group_id
self_info = await self_member_info(bot, gid)
sid = self_info["user_id"]
ta_info = await honor_info(bot, gid, "talkative")
if not ta_info or not ta_info.get("current_talkative"):
await UniMessage.text("本群没有开启龙王标志哦~").send()
return
dk = ta_info["current_talkative"]["user_id"]
if sid == dk:
pic = image_to_base64(IMG_DIR / "dk_is_me.jpg")
await (
UniMessage.text("你们这群丢人玩意,龙王怎么又是我\n")
.image(url=pic)
.send()
)
else:
action = random.choice(["龙王出来挨透", "龙王出来喷水"])
dk_avatar = ta_info["current_talkative"]["avatar"] + "640" + f"&t={dk}"
await (
UniMessage.at(user_id=str(dk))
.text(f"\n{action}\n")
.image(url=dk_avatar)
.send()
)
@dragon_list.handle()
async def dragon_list_handle(bot: Bot, ev: GroupMessageEvent):
gid = ev.group_id
ta_info = await honor_info(bot, gid, "talkative")
if not ta_info or not ta_info.get("talkative_list"):
await UniMessage.text("本群暂时没有龙王数据哦~").send()
return
dragon_king_list_info = ta_info["talkative_list"]
# 处理description和nickname关系
dkdir = {}
for item in dragon_king_list_info:
user_id = item["user_id"]
nickname = item["nickname"]
description = item["description"]
temp = re.findall(r"\d+", description)
# 累计获得龙王的次数
dragon_king_days = int(temp[0])
# 连续获得龙王
dragon_king_days_continuous = int(temp[1])
dkdir[nickname] = {
"user_id": user_id,
"days_max": dragon_king_days,
"days_contin": dragon_king_days_continuous,
}
# 瞎写的排序
dkdir_sorted = sorted(dkdir.items(), key=lambda d: d[1]["days_max"], reverse=True)
dkk = dkdir_sorted[0][1]["user_id"]
msg = UniMessage.text(f"本群获得龙王次数最多的是:\n{dkdir_sorted[0][0]}\n")
dk_avatar = f"http://q1.qlogo.cn/g?b=qq&nk={dkk}&s=5"
msg += UniMessage.image(url=dk_avatar)
msg += UniMessage.text("\n本群龙王榜\n")
for i, it in enumerate(dkdir_sorted[:5]):
_nickname = it[0]
_days_max = it[1]["days_max"]
_days_contin = it[1]["days_contin"]
msg += UniMessage.text(
f"第{i + 1}名:{_nickname},共{_days_max}次,最长连续{_days_contin}天获得龙王\n"
)
await msg.send()
# ============ 视频下载 ============
down_video = on_startswith("/video")
@down_video.handle()
async def down_video_handle(bot: Bot, ev: GroupMessageEvent):
url = ev.message.extract_plain_text().removeprefix("/video").strip()
if not url:
return
match = re.match(r"https?:/{2}\w.+$", url)
if not match:
await UniMessage.text("参数不合法").send()
return
timespan = int(time.time())
filename = f"{timespan}"
file = TEMP_DIR / f"{filename}.mp4"
fileabs = str(file)
cover = TEMP_DIR / f"{filename}.mp4.jpg"
TEMP_DIR.mkdir(parents=True, exist_ok=True)
try:
info = await asyncio.to_thread(get_video_info, url)
match = re.search(r"MPEG-4 video(.*)\s*Size:(.*)MiB", info)
if not match:
await UniMessage.text("参数不正确").send()
return
info_type = match.group(1).strip()
if info_type != "(video/mp4)":
await UniMessage.text("未找到视频信息").send()
return
info_size = match.group(2).strip()
if not is_number(info_size):
await UniMessage.text("无法获取视频大小").send()
return
size = float(info_size)
if size >= 6.0:
await UniMessage.text("视频体积过大").send()
return
await UniMessage.text("正在发送...").send()
await asyncio.to_thread(download_video, url, TEMP_DIR, filename)
except Exception as e:
logger.error(e)
await UniMessage.text("获取视频失败了").send()
file.unlink(missing_ok=True)
cover.unlink(missing_ok=True)
return
try:
await local_video(f"file:///{fileabs}").send()
except Exception:
await UniMessage.text("发送失败了").send()
file.unlink(missing_ok=True)
cover.unlink(missing_ok=True)
# ============ 病毒消息拦截 ============
fuck_xxs = on_message(rule=is_type(GroupMessageEvent), block=False)
@fuck_xxs.handle()
async def fuck_xxs_handle(bot: Bot, ev: GroupMessageEvent):
for m in ev.message:
if m.type == "json" and '"app":"com.tencent.autoreply"' in m.data.get(
"data", ""
):
await bot.delete_msg(message_id=ev.message_id)
await UniMessage.text(
"这是onse病毒码,会获取到你手机的xxs,然后发起攻击...以下省略6行需要全文背诵的金句"
).send()
return
@@ -0,0 +1,3 @@
"""管理类触发(handlers/management)。"""
from . import admin, antivirus, ban, dragon, title, video # noqa: F401
@@ -0,0 +1,50 @@
"""群管理工具 —— 管理员与公告。"""
from __future__ import annotations
from nonebot import on_startswith, require
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
from nonebot_plugin_alconna import UniMessage
from ...utils.permissions import check_perm
require("nonebot_plugin_alconna")
set_admin = on_startswith("设置管理员")
unset_admin = on_startswith("取消管理员")
set_group_notice = on_startswith("设置群公告")
@set_admin.handle()
async def set_admin_handle(bot: Bot, ev: GroupMessageEvent):
if not await check_perm(bot, ev):
return
for seg in ev.message:
if seg.type == "at" and seg.data.get("qq") != "all":
await bot.set_group_admin(
group_id=ev.group_id, user_id=int(seg.data["qq"]), enable=True
)
await UniMessage.text("我好了").send()
@unset_admin.handle()
async def unset_admin_handle(bot: Bot, ev: GroupMessageEvent):
if not await check_perm(bot, ev):
return
for seg in ev.message:
if seg.type == "at" and seg.data.get("qq") != "all":
await bot.set_group_admin(
group_id=ev.group_id, user_id=int(seg.data["qq"]), enable=False
)
await UniMessage.text("我好了").send()
@set_group_notice.handle()
async def set_group_notice_handle(bot: Bot, ev: GroupMessageEvent):
content = ev.message.extract_plain_text().removeprefix("设置群公告").strip()
if not content:
return
if not await check_perm(bot, ev):
return
await bot.call_api("_send_group_notice", group_id=ev.group_id, content=content)
await UniMessage.text("我好了").send()
@@ -0,0 +1,25 @@
"""群管理工具 —— 病毒消息拦截。"""
from __future__ import annotations
from nonebot import on_message, require
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
from nonebot.rule import is_type
from nonebot_plugin_alconna import UniMessage
require("nonebot_plugin_alconna")
fuck_xxs = on_message(rule=is_type(GroupMessageEvent), block=False)
@fuck_xxs.handle()
async def fuck_xxs_handle(bot: Bot, ev: GroupMessageEvent):
for m in ev.message:
if m.type == "json" and '"app":"com.tencent.autoreply"' in m.data.get(
"data", ""
):
await bot.delete_msg(message_id=ev.message_id)
await UniMessage.text(
"这是onse病毒码,会获取到你手机的xxs,然后发起攻击...以下省略6行需要全文背诵的金句"
).send()
return
@@ -0,0 +1,145 @@
"""群管理工具 —— 禁言相关。"""
from __future__ import annotations
import math
import random
from nonebot import logger, on_fullmatch, on_message, on_regex, on_startswith, require
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent
from nonebot.rule import Rule, is_type
from nonebot_plugin_alconna import UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
from ...services.moderation import group_silence, member_silence, silence
from ...utils.message import get_at_id, has_at_all
from ...utils.permissions import check_perm
from ...utils.text import parse_seconds, strip_prefix
require("nonebot_plugin_alconna")
def _is_ban_cmd(event: GroupMessageEvent) -> bool:
text = event.message.extract_plain_text()
return text.startswith("禁言") and not text.startswith("禁言一下")
ban_user = on_message(rule=Rule(_is_ban_cmd) & is_type(GroupMessageEvent), block=True)
ban_member = on_startswith(("来发口球", "塞口球", "禁言一下"))
one_for_one = on_startswith("极限一换一")
cancel_ban = on_startswith(("解除口球", "取消口球", "摘口球", "脱口球", "取消禁言", "解除禁言", "解禁"))
ban_all = on_fullmatch(("全员口球", "全员禁言"))
cancel_ban_all = on_fullmatch(("取消全员口球", "取消全员禁言", "解除全员口球", "解除全员禁言"))
sleep_8h = on_fullmatch(("睡眠套餐", "休眠套餐", "精致睡眠", "来一份精致睡眠套餐"))
tea = on_regex(r"(来|來)(.*(份|个)(.*)(睡|茶)(.*))套餐")
@ban_user.handle()
async def ban_user_handle(bot: Bot, ev: GroupMessageEvent):
if not await check_perm(bot, ev, silent=True):
return
msg = ev.message.extract_plain_text().removeprefix("禁言").strip()
at_seg = next(
(seg for seg in ev.message if seg.type == "at" and seg.data.get("qq") != "all"),
None,
)
if not at_seg:
return
try:
uid = int(at_seg.data["qq"])
minutes = 1 if not msg else int(msg)
await bot.set_group_ban(group_id=ev.group_id, user_id=uid, duration=minutes * 60)
await UniMessage.text("我好了").send()
except Exception as ex:
logger.error(ex)
@ban_member.handle()
async def ban_member_handle(bot: Bot, ev: GroupMessageEvent):
uid = ev.user_id
gid = ev.group_id
duration_text = strip_prefix(
ev.message.extract_plain_text(), ("来发口球", "塞口球", "禁言一下")
)
if not duration_text:
duration_text = "30"
if has_at_all(ev):
await group_silence(bot, ev, gid, True)
return
sid = get_at_id(ev) or uid
await member_silence(bot, ev, uid, sid, gid, duration_text, silent=False)
@one_for_one.handle()
async def one_for_one_handle(bot: Bot, ev: GroupMessageEvent):
uid = ev.user_id
gid = ev.group_id
duration_text = strip_prefix(ev.message.extract_plain_text(), ("极限一换一",))
if has_at_all(ev):
await UniMessage.text("人干事?").send(at_sender=True)
await member_silence(bot, ev, uid, uid, gid, "180", silent=False)
return
seconds = parse_seconds(duration_text)
if seconds is None:
await UniMessage.text("请输入正确时长~").send(at_sender=True)
return
if seconds > 30:
await UniMessage.text("时长不可大于30秒哟~").send(at_sender=True)
return
sid = get_at_id(ev) or uid
await member_silence(bot, ev, uid, sid, gid, str(seconds), silent=True)
await member_silence(bot, ev, uid, uid, gid, str(seconds), silent=True)
await (
UniMessage.at(user_id=str(uid))
.text("成功一换一")
.at(user_id=str(sid))
.text(f"{seconds}秒~")
.send()
)
@cancel_ban.handle()
async def cancel_ban_handle(bot: Bot, ev: GroupMessageEvent):
if not await check_manage(bot, ev):
await UniMessage.text("权限不足").send()
return
uid = ev.user_id
gid = ev.group_id
if has_at_all(ev):
await group_silence(bot, ev, gid, False)
return
sid = get_at_id(ev)
if sid is None:
await UniMessage.text("请@需要摘口球的群员哦w").send()
return
await member_silence(bot, ev, uid, sid, gid, "0", silent=False)
@ban_all.handle()
async def ban_all_handle(bot: Bot, ev: GroupMessageEvent):
if not await check_manage(bot, ev):
await UniMessage.text("权限不足").send()
return
await group_silence(bot, ev, ev.group_id, True)
@cancel_ban_all.handle()
async def cancel_ban_all_handle(bot: Bot, ev: GroupMessageEvent):
if not await check_manage(bot, ev):
await UniMessage.text("权限不足").send()
return
await group_silence(bot, ev, ev.group_id, False)
@sleep_8h.handle()
async def sleep_8h_handle(bot: Bot, ev: MessageEvent):
await silence(bot, ev, 8 * 60 * 60)
@tea.handle()
async def tea_handle(bot: Bot, ev: MessageEvent):
base = 0 if "午" in ev.plain_text else 5 * 60 * 60
length = len(ev.plain_text)
sleep_time = base + round(math.sqrt(length) * 60 * 30 + 60 * random.randint(-15, 15))
await silence(bot, ev, sleep_time)
@@ -0,0 +1,84 @@
"""群管理工具 —— 龙王。"""
from __future__ import annotations
import random
import re
from nonebot import on_fullmatch, require
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
from nonebot_plugin_alconna import UniMessage
from ...constants import IMG_DIR
from ...utils.media import image_to_base64
from ...utils.permissions import honor_info, self_member_info
require("nonebot_plugin_alconna")
whois_dragon = on_fullmatch(("谁是龙王", "迫害龙王", "龙王是谁"))
dragon_list = on_fullmatch(("龙王榜", "龙王排行", "龙王排行榜", "龙王榜单"))
@whois_dragon.handle()
async def whois_dragon_handle(bot: Bot, ev: GroupMessageEvent):
gid = ev.group_id
self_info = await self_member_info(bot, gid)
sid = self_info["user_id"]
ta_info = await honor_info(bot, gid, "talkative")
if not ta_info or not ta_info.get("current_talkative"):
await UniMessage.text("本群没有开启龙王标志哦~").send()
return
dk = ta_info["current_talkative"]["user_id"]
if sid == dk:
pic = image_to_base64(IMG_DIR / "dk_is_me.jpg")
await (
UniMessage.text("你们这群丢人玩意,龙王怎么又是我\n")
.image(url=pic)
.send()
)
else:
action = random.choice(["龙王出来挨透", "龙王出来喷水"])
dk_avatar = ta_info["current_talkative"]["avatar"] + "640" + f"&t={dk}"
await (
UniMessage.at(user_id=str(dk))
.text(f"\n{action}\n")
.image(url=dk_avatar)
.send()
)
@dragon_list.handle()
async def dragon_list_handle(bot: Bot, ev: GroupMessageEvent):
gid = ev.group_id
ta_info = await honor_info(bot, gid, "talkative")
if not ta_info or not ta_info.get("talkative_list"):
await UniMessage.text("本群暂时没有龙王数据哦~").send()
return
dragon_king_list_info = ta_info["talkative_list"]
dkdir = {}
for item in dragon_king_list_info:
user_id = item["user_id"]
nickname = item["nickname"]
description = item["description"]
temp = re.findall(r"\d+", description)
dragon_king_days = int(temp[0])
dragon_king_days_continuous = int(temp[1])
dkdir[nickname] = {
"user_id": user_id,
"days_max": dragon_king_days,
"days_contin": dragon_king_days_continuous,
}
dkdir_sorted = sorted(dkdir.items(), key=lambda d: d[1]["days_max"], reverse=True)
dkk = dkdir_sorted[0][1]["user_id"]
msg = UniMessage.text(f"本群获得龙王次数最多的是:\n{dkdir_sorted[0][0]}\n")
dk_avatar = f"http://q1.qlogo.cn/g?b=qq&nk={dkk}&s=5"
msg += UniMessage.image(url=dk_avatar)
msg += UniMessage.text("\n本群龙王榜\n")
for i, it in enumerate(dkdir_sorted[:5]):
_nickname = it[0]
_days_max = it[1]["days_max"]
_days_contin = it[1]["days_contin"]
msg += UniMessage.text(
f"第{i + 1}名:{_nickname},共{_days_max}次,最长连续{_days_contin}天获得龙王\n"
)
await msg.send()
@@ -0,0 +1,58 @@
"""群管理工具 —— 头衔 / 名片 / 群名。"""
from __future__ import annotations
from nonebot import on_fullmatch, on_startswith, require
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
from nonebot_plugin_alconna import UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
from ...services.profile import card_edit, group_name, title_get
from ...utils.message import get_at_id
from ...utils.text import strip_prefix
require("nonebot_plugin_alconna")
special_title = on_startswith("申请头衔")
del_special_title = on_fullmatch(("删除头衔", "清除头衔", "收回头衔", "回收头衔"))
card_set = on_startswith(("修改名片", "修改群名片", "设置名片", "设置群名片"))
set_group_name = on_startswith(("修改群名", "设置群名"))
@special_title.handle()
async def special_title_handle(bot: Bot, ev: GroupMessageEvent):
uid = ev.user_id
gid = ev.group_id
title = strip_prefix(ev.message.extract_plain_text(), ("申请头衔",))
sid = get_at_id(ev) or uid
await title_get(bot, ev, uid, sid, gid, title)
@del_special_title.handle()
async def del_special_title_handle(bot: Bot, ev: GroupMessageEvent):
uid = ev.user_id
gid = ev.group_id
sid = get_at_id(ev) or uid
await title_get(bot, ev, uid, sid, gid, None)
@card_set.handle()
async def card_set_handle(bot: Bot, ev: GroupMessageEvent):
uid = ev.user_id
gid = ev.group_id
card_text = strip_prefix(
ev.message.extract_plain_text(), ("修改名片", "修改群名片", "设置名片", "设置群名片")
)
sid = get_at_id(ev) or uid
await card_edit(bot, ev, uid, sid, gid, card_text)
@set_group_name.handle()
async def set_group_name_handle(bot: Bot, ev: GroupMessageEvent):
if not await check_manage(bot, ev):
await UniMessage.text("权限不足").send()
return
gid = ev.group_id
name_text = strip_prefix(ev.message.extract_plain_text(), ("修改群名", "设置群名"))
await group_name(bot, ev, gid, name_text)
@@ -0,0 +1,71 @@
"""群管理工具 —— 视频下载。"""
from __future__ import annotations
import asyncio
import re
import time
from nonebot import logger, on_startswith, require
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
from nonebot_plugin_alconna import UniMessage
from ...constants import TEMP_DIR
from ...services.video import download_video, get_video_info, local_video
from ...utils.text import is_number
require("nonebot_plugin_alconna")
down_video = on_startswith("/video")
@down_video.handle()
async def down_video_handle(bot: Bot, ev: GroupMessageEvent):
url = ev.message.extract_plain_text().removeprefix("/video").strip()
if not url:
return
match = re.match(r"https?:/{2}w.+$", url)
if not match:
await UniMessage.text("参数不合法").send()
return
timespan = int(time.time())
filename = f"{timespan}"
file = TEMP_DIR / f"{filename}.mp4"
fileabs = str(file)
cover = TEMP_DIR / f"{filename}.mp4.jpg"
TEMP_DIR.mkdir(parents=True, exist_ok=True)
try:
info = await asyncio.to_thread(get_video_info, url)
match = re.search(r"MPEG-4 video(.*)s*Size:(.*)MiB", info)
if not match:
await UniMessage.text("参数不正确").send()
return
info_type = match.group(1).strip()
if info_type != "(video/mp4)":
await UniMessage.text("未找到视频信息").send()
return
info_size = match.group(2).strip()
if not is_number(info_size):
await UniMessage.text("无法获取视频大小").send()
return
size = float(info_size)
if size >= 6.0:
await UniMessage.text("视频体积过大").send()
return
await UniMessage.text("正在发送...").send()
await asyncio.to_thread(download_video, url, TEMP_DIR, filename)
except Exception as e:
logger.error(e)
await UniMessage.text("获取视频失败了").send()
file.unlink(missing_ok=True)
cover.unlink(missing_ok=True)
return
try:
await local_video(f"file:///{fileabs}").send()
except Exception:
await UniMessage.text("发送失败了").send()
file.unlink(missing_ok=True)
cover.unlink(missing_ok=True)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 923 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

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