diff --git a/.env.dev b/.env.dev index 44f19fe28..d9a7a016d 100644 --- a/.env.dev +++ b/.env.dev @@ -16,6 +16,8 @@ SELF_NICKNAME="小真寻" # 数据库配置 # 示例: "postgres://user:password@127.0.0.1:5432/database" +# 示例: "mysql://user:password@127.0.0.1:3306/database" +# 示例: "sqlite:data/db/zhenxun.db" 在data目录下建立db文件夹 DB_URL = "" # 系统代理 diff --git a/.github/workflows/bot_check.yml b/.github/workflows/bot_check.yml index b4502d641..ac532629e 100644 --- a/.github/workflows/bot_check.yml +++ b/.github/workflows/bot_check.yml @@ -3,8 +3,16 @@ name: 检查bot是否运行正常 on: push: branches: ["dev", "main"] + paths: + - zhenxun/** + - tests/** + - bot.py pull_request: branches: ["dev", "main"] + paths: + - zhenxun/** + - tests/** + - bot.py jobs: bot-check: @@ -28,7 +36,14 @@ jobs: uses: actions/cache@v3 with: path: ~/.cache/pypoetry - key: poetry-cache-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }} + key: poetry-cache-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }}-${{ hashFiles('pyproject.toml') }} + + - name: Cache playwright cache + id: cache-playwright + uses: actions/cache@v3 + with: + path: ~/.cache/ms-playwright + key: playwright-cache-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }} - name: Cache Data cache uses: actions/cache@v3 @@ -42,7 +57,9 @@ jobs: rm -rf poetry.lock poetry source remove ali poetry install --no-root - poetry run pip install pydantic==1.10 + + - name: Run tests + run: poetry run pytest --cov=zhenxun --cov-report xml - name: Check bot run id: bot_check_run diff --git a/.github/workflows/update_version.yml b/.github/workflows/update_version.yml new file mode 100644 index 000000000..9e788ae7b --- /dev/null +++ b/.github/workflows/update_version.yml @@ -0,0 +1,65 @@ +name: Update Version + +on: + pull_request_target: + paths: + - zhenxun/** + - resources/** + - bot.py + types: + - opened + - synchronize + branches: + - main + - dev + +jobs: + update-version: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Read current version + id: read_version + run: | + version_line=$(grep '__version__' __version__) + version=$(echo $version_line | sed -E 's/__version__:\s*v([0-9]+\.[0-9]+\.[0-9]+)(-.+)?/\1/') + echo "Current version: $version" + echo "current_version=$version" >> $GITHUB_OUTPUT + + - name: Check for version file changes + id: check_diff + run: | + if git diff --name-only HEAD~1 HEAD | grep -q '__version__'; then + echo "Version file has changes" + echo "version_changed=true" >> $GITHUB_OUTPUT + else + echo "Version file has no changes" + echo "version_changed=false" >> $GITHUB_OUTPUT + fi + + - name: Get commit hash + id: get_commit_hash + run: echo "commit_hash=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Update version file + if: steps.check_diff.outputs.version_changed == 'false' + run: | + current_version="${{ steps.read_version.outputs.current_version }}" + commit_hash="${{ steps.get_commit_hash.outputs.commit_hash }}" + new_version="v${current_version}-${commit_hash}" + echo "Updating version to: $new_version" + echo "__version__: $new_version" > __version__ + git config --global user.name "${{ github.event.pull_request.user.login }}" + git config --global user.email "${{ github.event.pull_request.user.login }}@users.noreply.github.com" + git add __version__ + git remote set-url origin https://github.com/${{ github.event.pull_request.head.repo.full_name }}.git + git commit -m "chore(version): Update version to $new_version" + git push origin HEAD:${{ github.event.pull_request.head.ref }} + + - name: Check updated version + if: steps.check_diff.outputs.version_changed == 'false' + run: cat __version__ diff --git a/.gitignore b/.gitignore index 649322231..db7233b2a 100644 --- a/.gitignore +++ b/.gitignore @@ -174,11 +174,9 @@ data/ /resources/image/prts/ /configs/config.py configs/config.yaml -./.env -./.env.dev plugins/csgo_server/ plugins/activity/ !/resources/image/genshin/alc/back.png !/data/genshin_alc/ .vscode/launch.json -/resources/template/my_info \ No newline at end of file +plugins_/ \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..78547a79a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "charliermarsh.ruff", + "esbenp.prettier-vscode", + "ms-python.black-formatter", + "ms-python.isort", + "ms-python.python", + "ms-python.vscode-pylance" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index dc0ad84cc..6ab1cbda8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,7 +23,37 @@ "ujson", "unban", "userinfo", - "zhenxun" + "zhenxun", + "jsdelivr" ], - "python.analysis.autoImportCompletions": true + "python.analysis.autoImportCompletions": true, + "python.testing.pytestArgs": ["tests"], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", // 默认使用 Ruff 格式化 + "editor.wordBasedSuggestions": "allDocuments", + "editor.formatOnType": true, + "editor.formatOnSave": true, // 保存时自动格式化 + "editor.codeActionsOnSave": { + "source.fixAll.ruff": "explicit", + "source.organizeImports": "explicit" + } + }, + "ruff.format.preview": false, + "isort.check": true, + "ruff.importStrategy": "useBundled", + "ruff.organizeImports": false, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[yaml]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } } diff --git a/Dockerfile b/Dockerfile index 18776e5a0..343d4d966 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,13 @@ WORKDIR /app/zhenxun COPY . /app/zhenxun +RUN apt update && \ + apt upgrade -y && \ + apt install -y --no-install-recommends \ + gcc \ + g++ && \ + apt clean + RUN pip install poetry -i https://mirrors.aliyun.com/pypi/simple/ RUN poetry install @@ -14,6 +21,8 @@ VOLUME /app/zhenxun/data /app/zhenxun/data VOLUME /app/zhenxun/resources /app/zhenxun/resources +VOLUME /app/zhenxun/.env.dev /app/zhenxun/.env.dev + RUN poetry run playwright install --with-deps chromium -CMD ["poetry", "run", "python", "bot.py"] \ No newline at end of file +CMD ["poetry", "run", "python", "bot.py"] diff --git a/README.md b/README.md index 884b7d691..fe7c82480 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@
-## 绪山真寻Bot +## 绪山真寻 Bot
@@ -39,11 +39,12 @@ :tada:喜欢真寻,于是真寻就来了!:tada: 本项目符合 [OneBot](https://github.com/howmanybots/onebot) 标准,可基于以下项目与机器人框架/平台进行交互 -| 项目地址 | 平台 | 核心作者 | 备注 | -| :---: | :---: | :---: | :---: | -| [LLOneBot](https://github.com/LLOneBot/LLOneBot) | NTQQ | linyuchen | 可用 | -| [Napcat](https://github.com/NapNeko/NapCatQQ) | NTQQ | NapNeko | 可用 | -| [Lagrange.Core](https://github.com/LagrangeDev/Lagrange.Core) | | LagrangeDev/Linwenxuan04 | 可用 + +| 项目地址 | 平台 | 核心作者 | 备注 | +| :-----------------------------------------------------------: | :--: | :----------------------: | :--: | +| [LLOneBot](https://github.com/LLOneBot/LLOneBot) | NTQQ | linyuchen | 可用 | +| [Napcat](https://github.com/NapNeko/NapCatQQ) | NTQQ | NapNeko | 可用 | +| [Lagrange.Core](https://github.com/LagrangeDev/Lagrange.Core) | | LagrangeDev/Linwenxuan04 | 可用 | @@ -57,8 +58,9 @@
- + +
@@ -66,7 +68,7 @@ ### 1. 体验一下? -这是一个免费的,版本为dev的zhenxun,你可以通过napcat或拉格朗日等直接连接用于体验与测试 +这是一个免费的,版本为 dev 的 zhenxun,你可以通过 napcat 或拉格朗日等直接连接用于体验与测试 (球球了测试君!) ``` @@ -82,16 +84,13 @@ AccessToken: PUBLIC_ZHENXUN_TEST “不要害怕,你的背后还有千千万万的 伙伴 啊!” -| 项目名称 | 主要用途 | 仓库作者 | 备注 | -| :-----------------------------------------------------------------------------------------------: | :------: | :-------------------------------------------: | :-------------------------------------------------------------: | -| [插件库](https://github.com/zhenxun-org/zhenxun_bot_plugins) | 插件 | [zhenxun-org](https://github.com/zhenxun-org) | 原plugins文件夹插件 | -| [插件索引库](https://github.com/zhenxun-org/zhenxun_bot_plugins_index) | 插件 | [zhenxun-org](https://github.com/zhenxun-org) | 扩展插件索引库 | -| [WebUi](https://github.com/HibiKier/zhenxun_bot_webui) | 管理 | [hibikier](https://github.com/HibiKier) | 基于真寻WebApi的webui实现 | -| [一键安装](https://github.com/zhenxun-org/zhenxun_bot-deploy) | 安装 | [AkashiCoin](https://github.com/AkashiCoin) | 新版本未测试 | -| [Docker单机版](https://github.com/Sakuracio/zhenxun_bot_docker) | 安装 | [zhenxun-org](https://github.com/zhenxun-org) | 新版本未测试 | -| [Docker全量版](https://shields.io/badge/GITHUB-SinKy--Yan-4476AF?logo=github&style=for-the-badge) | 安装 | [zhenxun-org](https://github.com/zhenxun-org) | 包含 真寻Bot PostgreSQL数据库 go-cqhttp webui等(新版本未测试) | - -PS: **ARM平台** 请使用全量版 同时 **如果你的机器 RAM < 1G 可能无法正常启动全量版容器** +| 项目名称 | 主要用途 | 仓库作者 | 备注 | +| :--------------------------------------------------------------------: | :------: | :-------------------------------------------------: | :---------------------------: | +| [插件库](https://github.com/zhenxun-org/zhenxun_bot_plugins) | 插件 | [zhenxun-org](https://github.com/zhenxun-org) | 原 plugins 文件夹插件 | +| [插件索引库](https://github.com/zhenxun-org/zhenxun_bot_plugins_index) | 插件 | [zhenxun-org](https://github.com/zhenxun-org) | 扩展插件索引库 | +| [一键安装](https://github.com/soloxiaoye2022/zhenxun_bot-deploy) | 安装 | [soloxiaoye2022](https://github.com/soloxiaoye2022) | 第三方 | +| [WebUi](https://github.com/HibiKier/zhenxun_bot_webui) | 管理 | [hibikier](https://github.com/HibiKier) | 基于真寻 WebApi 的 webui 实现 | +| [安卓 app(WebUi)](https://github.com/YuS1aN/zhenxun_bot_android_ui) | 安装 | [YuS1aN](https://github.com/YuS1aN) | 第三方 |
WebUI 后台示例图 @@ -113,10 +112,10 @@ PS: **ARM平台** 请使用全量版 同时 **如果你的机器 RAM < 1G 可 ## ~~来点优点?~~ 可爱难道还不够吗 - 实现了许多功能,且提供了大量功能管理命令 -- 通过Config配置项将所有插件配置统计保存至config.yaml,利于统一用户修改 -- 方便增删插件,原生nonebot2 matcher,不需要额外修改,仅仅通过简单的配置属性就可以生成`帮助图片`和`帮助信息` -- 提供了cd,阻塞,每日次数等限制,仅仅通过简单的属性就可以生成一个限制,例如:`PluginCdBlock` 等 -- **..... 更多详细请通过`传送门`查看文档!** +- 通过 Config 配置项将所有插件配置统计保存至 config.yaml,利于统一用户修改 +- 方便增删插件,原生 nonebot2 matcher,不需要额外修改,仅仅通过简单的配置属性就可以生成`帮助图片`和`帮助信息` +- 提供了 cd,阻塞,每日次数等限制,仅仅通过简单的属性就可以生成一个限制,例如:`PluginCdBlock` 等 +- **..... 更多详细请通过[[传送门](https://hibikier.github.io/zhenxun_bot/)]查看文档!** ## 简单部署 @@ -135,9 +134,6 @@ poetry install # 安装依赖 poetry shell # 进入虚拟环境 python bot.py -# 在Linux系统,你可能还需要运行此命令安装playwright依赖 -playwright install-deps - # 首次后会在data目录下生成config.yaml文件 # config.yaml用户配置插件 ``` @@ -159,6 +155,8 @@ playwright install-deps ' # 此处填写你的数据库地址 # 示例: "postgres://user:password@127.0.0.1:5432/database" + # 示例: "mysql://user:password@127.0.0.1:5432/database" + # 示例: "sqlite:data/db/zhenxun.db" 在data目录下建立db文件夹 DB_URL = "" # 数据库地址 @@ -167,7 +165,7 @@ playwright install-deps ``` -## 功能列表 (旧版列表) + ## [爱发电](https://afdian.net/@HibiKier) @@ -325,7 +323,7 @@ playwright install-deps ### 感谢名单 -(可以告诉我你的 **github** 地址,我偷偷换掉0v|) +(可以告诉我你的 **github** 地址,我偷偷换掉 0v|) [shenqi](https://afdian.net/u/fa923a8cfe3d11eba61752540025c377) [A_Kyuu](https://afdian.net/u/b83954fc2c1211eba9eb52540025c377) @@ -343,13 +341,13 @@ playwright install-deps [本喵无敌哒](https://afdian.net/u/dffaa9005bc911ebb69b52540025c377) [椎名冬羽](https://afdian.net/u/ca1ebd64395e11ed81b452540025c377) [kaito](https://afdian.net/u/a055e20a498811eab1f052540025c377) -[笑柒XIAO_Q7](https://afdian.net/u/4696db5c529111ec84ea52540025c377) +[笑柒 XIAO_Q7](https://afdian.net/u/4696db5c529111ec84ea52540025c377) [请问一份爱多少钱](https://afdian.net/u/f57ef6602dbd11ed977f52540025c377) [咸鱼鱼鱼鱼](https://afdian.net/u/8e39b9a400e011ed9f4a52540025c377) [Kafka](https://afdian.net/u/41d66798ef6911ecbc5952540025c377) [墨然](https://afdian.net/u/8aa5874a644d11eb8a6752540025c377) [爱发电用户\_T9e4](https://afdian.net/u/2ad1bb82f3a711eca22852540025c377) -[笑柒XIAO_Q7](https://afdian.net/u/4696db5c529111ec84ea52540025c377) +[笑柒 XIAO_Q7](https://afdian.net/u/4696db5c529111ec84ea52540025c377) [noahzark](https://afdian.net/a/noahzark) [腊条](https://afdian.net/u/f739c4d69eca11eba94b52540025c377) [ze roller](https://afdian.net/u/0e599e96257211ed805152540025c377) @@ -374,11 +372,11 @@ playwright install-deps
-## 更新 +
- + + \ No newline at end of file diff --git a/resources/template/check/main.css b/resources/template/check/main.css new file mode 100644 index 000000000..f67907120 --- /dev/null +++ b/resources/template/check/main.css @@ -0,0 +1,182 @@ + + +@font-face { + font-family: fzrzFont; + /* 导入的字体文件 */ + src: url("../../font/fzrzExtraBold.ttf"); +} + + + + +body { + position: absolute; + left: -8px; + top: -8px; +} + +.wrapper{ + height: 750px; + width: 395px; + background-color: antiquewhite; + position: relative; +} + +.top-image { + height: 215px; + width: 395px; +} + +.abs-image { + height: 426px; + width: 93px; + position: absolute; + right: 39px; + top: 240px; +} + +.bot-text { + height: 55px; + width: 335px; + position: relative; + padding: 0 30px; + margin-top: 10px; +} + +.main { + height: 448px; + width: 335px; + padding: 0 30px; + position: relative; +} + +.title { + background-color: #EB869D; + height: 32px; + font-family: 'fzrzFont'; + color: white; + width: max-content; + border-radius: 20px; + padding: 0 10px; + float: left; + margin-top: 10px; +} + +.title-n { + height: 23px; + width: 23px; + border-radius: 50%; + background-color: white; + position: absolute; + left: 36px; + top: 15px; + display: flex; + justify-content: center; + align-items: center; +} + +.title-a { + background-color: #ED859E; + height: 19px; + width: 19px; + border-radius: 50%; +} + +.network { + display: flex; + margin-left: 10px; +} + +.network-item { + font-family: 'fzrzFont'; + color: #EC859F; + display: flex; + justify-items: center; + align-items: center; + margin-left: 20px; +} + +.network-status { + height: 10px; + width: 10px; + border-radius: 50%; + margin-left: 5px; +} + +.network-text { + margin-left: 5px; +} + +.network-item { + margin-top: 10px; +} + +.data-status-item { + margin-top: 10px; +} + +.data-status { + width: 100%; + height: 240px; + /* background-color:blueviolet; */ + margin-top: 22px; +} + +.data-status-item { + display: flex; + font-family: 'fzrzFont'; + font-size: 15px; + line-height: 18.5px; + height: 50px; +} + +.process { + height: 28px; + width: 105px; + border: #ED859E 4px solid; + border-radius: 30px; + overflow: hidden; +} + +.process-bar { + height: 28px; + width: 30px; + background-color: #EB869D; + border-top-right-radius: 30px; + border-bottom-right-radius: 30px; +} + +.data-status-item-text { + margin-left: 10px; + color: #EC839D; +} + +.line { + background-color: #EC859F; + height: 2px; + width: 100%; + border-radius: 20px; +} + +.status-text { + font-family: 'fzrzFont'; + color: #EC859F; + height: 185px; +} + +.status-text-title { + /* height: 25%; */ + display: flex; + /* justify-content: center; */ + align-items: center; + font-size: 14px; +} + +.tip { + font-family: 'fzrzFont'; + color: #ecbac7; + font-size: 10px; + position: absolute; + right: 5px; + bottom: 1px; +} \ No newline at end of file diff --git a/resources/template/check/main.html b/resources/template/check/main.html new file mode 100644 index 000000000..3b99f3691 --- /dev/null +++ b/resources/template/check/main.html @@ -0,0 +1,87 @@ + + + + + + + + test + + + + + +
+ + +
+
+
+
+
+ {{data.nickname}}自检 +
+
+
BaiDu
+
Google
+
+
+
+
+
+
+
+
+
+
CPU
+ +
{{data.cpu_info}}
+
+
+
+
+
+
+
+
RAM
+
{{data.ram_info}}
+
+
+
+
+
+
+
+
SWAP
+
{{data.swap_info}}
+
+
+
+
+
+
+
+
DISK
+
{{data.disk_info}}
+
+
+
+
+
+

CPU         {{data.brand_raw}}

+

SYSTEM    {{data.system}}

+

VERSION   {{data.version}}

+

PLUGINS   {{data.plugin_count}} loaded

+
+
+
Create By Zhenxun
+
+ + + + \ No newline at end of file diff --git a/resources/template/check/main.js b/resources/template/check/main.js new file mode 100644 index 000000000..e69de29bb diff --git a/resources/template/check/res/img/bk.png b/resources/template/check/res/img/bk.png new file mode 100644 index 000000000..cf1a762f2 Binary files /dev/null and b/resources/template/check/res/img/bk.png differ diff --git a/resources/template/check/res/img/top.jpg b/resources/template/check/res/img/top.jpg new file mode 100644 index 000000000..4ea75eb8c Binary files /dev/null and b/resources/template/check/res/img/top.jpg differ diff --git a/resources/template/help/main.css b/resources/template/help/main.css new file mode 100644 index 000000000..a8bb2c15c --- /dev/null +++ b/resources/template/help/main.css @@ -0,0 +1,100 @@ + + +@font-face { + font-family: fzrzFont; + /* 导入的字体文件 */ + src: url("../../font/fzrzExtraBold.ttf"); +} + +body { + position: absolute; + left: -8px; + top: -8px; +} + +.wrapper{ + width: 1400px; + position: relative; + background-image: url('res/img/bk.jpg'); + background-size: cover; + font-family: 'cr105Font'; + padding: 20px; +} + + +.title { + font-size: 60px; + font-family: 'fzrzFont'; + /* margin-left: 40px; */ + /* color: #F67186; */ + background: linear-gradient(to right, #F67186, #F7889C); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + text-align: center; +} + +.main { + background-image: url('res/img/main.png'); + background-size: 100% 100%; + /* background-size: cover; */ + height: 100%; + width: 1370px; + position: relative; + padding: 20px; + /* box-shadow: 5px 5px 10px 0 rgba(0,0,0,0.5); */ +} + +.items-border { + display: flex; + flex-wrap: wrap; + padding-left: 18px; +} + +.items { + border: #F67186 2px solid; + border-radius: 20px; + width: 675px; + padding: 30px; + max-width: 600px; +} + +.item-title { + background-image: url('res/img/title.png'); + background-size: cover; + background-repeat: no-repeat; + height: 55px; + width: 350px; + font-family: 'fzrzFont'; + display: flex; + justify-content: center; + align-items: center; + color: white; + font-size: 35px; + border-radius: 16px; + letter-spacing:4px; +} + +.usage-title { + font-size: 30px; + position: absolute; + top: -30px; +} + +.item-des { + color: #F78094; + border-radius: 20px; + font-family: 'fzrzFont'; + font-size: 30px; + padding: 10px; +} + +.item-usage { + color: #F78094; + border-radius: 20px; + font-family: 'fzrzFont'; + font-size: 20px; + border: #F67186 5px dotted; + padding: 60px 10px 10px 10px; + position: relative; +} \ No newline at end of file diff --git a/resources/template/help/main.html b/resources/template/help/main.html new file mode 100644 index 000000000..d289ddb68 --- /dev/null +++ b/resources/template/help/main.html @@ -0,0 +1,62 @@ + + + + + + + + test + + + + + +
+
+
+ {{data.nickname}}的{{data.help_name}}帮助 +
+
+ {% for plugin in data['plugin_list'] %} +
+
+ {{plugin.name}} +
+
+ 简介: {{plugin.description}} +
+
+

用法:

+ {{plugin.usage}} +
+
+ {% endfor %} +
+ +
+
+ + + + \ No newline at end of file diff --git a/resources/template/help/main.js b/resources/template/help/main.js new file mode 100644 index 000000000..e69de29bb diff --git a/resources/template/help/res/img/bk.jpg b/resources/template/help/res/img/bk.jpg new file mode 100644 index 000000000..2883c68e4 Binary files /dev/null and b/resources/template/help/res/img/bk.jpg differ diff --git a/resources/template/help/res/img/main.png b/resources/template/help/res/img/main.png new file mode 100644 index 000000000..e1f8110eb Binary files /dev/null and b/resources/template/help/res/img/main.png differ diff --git a/resources/template/help/res/img/title.png b/resources/template/help/res/img/title.png new file mode 100644 index 000000000..d5417753a Binary files /dev/null and b/resources/template/help/res/img/title.png differ diff --git a/resources/template/my_info/main.css b/resources/template/my_info/main.css new file mode 100644 index 000000000..1bab08949 --- /dev/null +++ b/resources/template/my_info/main.css @@ -0,0 +1,235 @@ + + +@font-face { + font-family: fzrzFont; + /* 导入的字体文件 */ + src: url("../../font/HYWenHei-85W.ttf"); +} + +@font-face { + font-family: systFont; + /* 导入的字体文件 */ + src: url("../../font/syst.otf"); +} + +@font-face { + font-family: syhtFont; + /* 导入的字体文件 */ + src: url("../../font/syht.otf"); +} + + + + + + +body { + position: absolute; + left: -8px; + top: -8px; +} + +.wrapper{ + width: 1754px; + height: 1240px; + position: relative; + background-size: cover; + font-family: 'fzrzFont'; + padding: 20px; + background-color: #E08A9F; + border-radius: 50px; + padding: 40px; + color: #E08A9F; + font-size: 30px; +} + +.main{ + width: 100%; + height: 100%; + display: flex; +} + +.main-content { + background-color: white; + height: 1123px; + width: 682px; + border-radius: 50px; + padding: 56px 83px; + position: relative; +} + +.weather-img { + position: absolute; + height: 60px; + left: 325px; + top: -10px; +} + +.top-date { + display: flex; + position: relative; + font-family: "systFont"; +} + +.user-info { + display: flex; + margin-top: 65px; +} + +.user-info-ava { + position: relative; + +} + +.ava-img { + height: 250px; + border-radius: 50%; + overflow: hidden; + display: inline-block; + border: 2px solid #E0899E; +} + +.user-info-data { + margin-left: 30px; +} + +.nickname { + position: absolute; + width: 140px; + left: 50%; + bottom: -27px; + transform: translate(-50%, -50%); + background-color: #E0899E; + color: white; + padding: 5px 10px; + border-radius: 15px; + text-align: center; +} + +.user-des { + margin-top: 60px; + font-size: 35px; + position: relative; + height: 710px; +} + +.des-img { + height: 430px; + position: absolute; + bottom: -28px; + left: 85px; +} + +.tag-img-content { + height: 120px; + width: 75px; + background-color: #E8BB88; + position: absolute; + top: 0px; + left: 120px; +} + +.tag-img-test { + border-left: 37px solid transparent; + border-right: 38px solid transparent; + border-bottom: 54px solid white; + width: 0; + height: 68px; +} + +.menu { + position: absolute; + right: 0px; + height: 496px; + top: 175px; +} + +.menu-item { + writing-mode: vertical-rl; /* 从右向左垂直排列 */ + color: #754E43; + height: 190px; + width: 65px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 30px; + border-top-left-radius: 30px; + border-bottom-left-radius: 30px; + gap: 20px; +} + +.main-img { + position: absolute; + height: 200px; + right: 150px; + top: 10px; +} + +.sign-data { + margin-top: 105px; +} + +.base-title { + font-size: 35px; +} + +.sign-level { + border: 2px solid #E08A9F; + padding: 7px 3px; + font-size: 28px; +} + +.line { + border: 1px solid #E08A9F; + margin-top: 30px; +} + +.text-item { + display: flex; +} + + +.test-icon { + height: 50px; + margin-right: 10px; +} + +.test-content { + display: flex; + align-items: center; + margin-top: 30px; +} + +.test-title { + display: flex; + align-items: center; + margin-bottom: 10px; +} + +.text-chart { + margin-top: 20px; + background-color: #E0899E; + color: white; + padding: 30px; + border-radius: 30px; + height: 560px; +} + +.chart { + height: 490px; + width: 622px; + background-color: white; +} + +.select { + background-color: #E0899E; + color: white; +} + +.uname { + display: -webkit-box; + -webkit-line-clamp: 1; /* 显示的最大行数 */ + -webkit-box-orient: vertical; + overflow: hidden; /* 隐藏溢出内容 */ + text-overflow: ellipsis; /* 溢出部分显示为省略号 */ +} \ No newline at end of file diff --git a/resources/template/my_info/main.html b/resources/template/my_info/main.html new file mode 100644 index 000000000..9e9168163 --- /dev/null +++ b/resources/template/my_info/main.html @@ -0,0 +1,177 @@ + + + + + + + + + test + + + + + +
+
+
+
{{data.date}}...... + sun + 24℃ +
+ +
+

个人简介:

+
+ {{data.description}} +
+ +
+
+
+
+ +
+
+
+
+
+ +
+

好感度等级: {{data.sign_level}}级

+
+ 路人 + 陌生 + 初识 + 普通 + 熟悉 + 信赖 + 相知 + 厚谊 + 亲密 +
+
+
+
+ + 权限等级: {{data.level}} +
+
+
+
+ + 金币数量: {{data.gold}} +
+
+ + 道具数量: {{data.prop}} +
+
+
+
+ + 调用次数: {{data.call}} +
+
+ + 发言统计: {{data.say}} +
+
+ +
+
+ + 发言趋势图 +
+
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/resources/template/my_info/main.js b/resources/template/my_info/main.js new file mode 100644 index 000000000..4aabcc9bc --- /dev/null +++ b/resources/template/my_info/main.js @@ -0,0 +1,32 @@ +// var chartDom = document.getElementById("chart") +// var myChart = echarts.init(chartDom) +// var option + +// option = { +// backgroundColor: "#E0899E", +// animation: false, +// xAxis: { +// type: "category", +// data: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], +// axisLabel: { +// color: "#fff", +// fontSize: 20, +// }, +// }, +// yAxis: { +// type: "value", +// axisLabel: { +// color: "#fff", +// fontSize: 20, +// }, +// }, +// series: [ +// { +// data: [820, 932, 901, 934, 1290, 1330, 1320], +// type: "line", +// smooth: true, +// }, +// ], +// } + +// option && myChart.setOption(option) diff --git a/resources/template/my_info/main_copy.html b/resources/template/my_info/main_copy.html new file mode 100644 index 000000000..6444252de --- /dev/null +++ b/resources/template/my_info/main_copy.html @@ -0,0 +1,130 @@ + + + + + + + + + test + + + + + +
+
+
+
2024/08/14...... + sun + 24℃ +
+ +
+

个人简介:

+
+ 这是人类勇者的传奇在这片古老而神秘的土地上, + 每当黑暗的力量威胁到和平与正义之时总会有一位英雄挺身而出。 + 他们不仅拥有超凡的力量更重要的是更重要的是更重要的是 +
+ +
+
+
+
+ +
+
+
+
+
+ +
+

好感度等级: 5级

+
+ 路人 + 陌生 + 初识 + 普通 + 熟悉 + 信赖 + 相知 + 厚谊 + 亲密 +
+
+
+
+ + 权限等级: 9 +
+
+
+
+ + 金币数量: 999 +
+
+ + 道具数量: 933 +
+
+
+
+ + 调用次数: 6443 +
+
+ + 发言统计: 233 +
+
+ +
+
+ + 发言趋势图 +
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/resources/template/my_info/res/img/1.png b/resources/template/my_info/res/img/1.png new file mode 100644 index 000000000..2d48a1516 Binary files /dev/null and b/resources/template/my_info/res/img/1.png differ diff --git a/resources/template/my_info/res/img/2.png b/resources/template/my_info/res/img/2.png new file mode 100644 index 000000000..54c40fbf2 Binary files /dev/null and b/resources/template/my_info/res/img/2.png differ diff --git a/resources/template/my_info/res/img/3.jpg b/resources/template/my_info/res/img/3.jpg new file mode 100644 index 000000000..076a4e1ac Binary files /dev/null and b/resources/template/my_info/res/img/3.jpg differ diff --git a/resources/template/my_info/res/img/call.png b/resources/template/my_info/res/img/call.png new file mode 100644 index 000000000..a2b2b870e Binary files /dev/null and b/resources/template/my_info/res/img/call.png differ diff --git a/resources/template/my_info/res/img/gold.png b/resources/template/my_info/res/img/gold.png new file mode 100644 index 000000000..d6dd1ebac Binary files /dev/null and b/resources/template/my_info/res/img/gold.png differ diff --git a/resources/template/my_info/res/img/level.png b/resources/template/my_info/res/img/level.png new file mode 100644 index 000000000..df2dc3608 Binary files /dev/null and b/resources/template/my_info/res/img/level.png differ diff --git a/resources/template/my_info/res/img/moon.png b/resources/template/my_info/res/img/moon.png new file mode 100644 index 000000000..0a59e19ab Binary files /dev/null and b/resources/template/my_info/res/img/moon.png differ diff --git a/resources/template/my_info/res/img/prop.png b/resources/template/my_info/res/img/prop.png new file mode 100644 index 000000000..e9b092474 Binary files /dev/null and b/resources/template/my_info/res/img/prop.png differ diff --git a/resources/template/my_info/res/img/say.png b/resources/template/my_info/res/img/say.png new file mode 100644 index 000000000..1f1690581 Binary files /dev/null and b/resources/template/my_info/res/img/say.png differ diff --git a/resources/template/my_info/res/img/sun.png b/resources/template/my_info/res/img/sun.png new file mode 100644 index 000000000..3c81e1b3e Binary files /dev/null and b/resources/template/my_info/res/img/sun.png differ diff --git a/resources/template/my_info/res/img/test.jpg b/resources/template/my_info/res/img/test.jpg new file mode 100644 index 000000000..bf95d664f Binary files /dev/null and b/resources/template/my_info/res/img/test.jpg differ diff --git a/resources/template/my_info/res/img/xian.png b/resources/template/my_info/res/img/xian.png new file mode 100644 index 000000000..da7667a28 Binary files /dev/null and b/resources/template/my_info/res/img/xian.png differ diff --git a/resources/template/sign/main.css b/resources/template/sign/main.css index 8e313f640..d96bf2b4c 100644 --- a/resources/template/sign/main.css +++ b/resources/template/sign/main.css @@ -34,11 +34,6 @@ src: url("./res/font/jcyt.ttf"); } -@font-face { - font-family: yshsFont; - /* 导入的字体文件 */ - src: url("/resources/font/YSHaoShenTi-2.ttf"); -} body { position: absolute; @@ -79,10 +74,17 @@ body { margin-top: 43px; margin-left: 30px; font-size: 47px; - height: 57px; - align-items: center; - justify-content: center; - display: flex + /* align-items: center; + justify-content: center; */ + /* display: flex; */ + width: 275px; + height: 67px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + white-space: normal; } .uid { @@ -122,6 +124,7 @@ body { position: absolute; top: 72px; left: 130px; + width: 200px; } .sign-content{ diff --git a/resources/template/sign/main.html b/resources/template/sign/main.html index 10c3d40ed..040cf87ec 100644 --- a/resources/template/sign/main.html +++ b/resources/template/sign/main.html @@ -1,7 +1,7 @@ - + @@ -10,12 +10,13 @@ +
- +

{{data.name}}

@@ -40,20 +41,21 @@

{{data.impression}}

-

{{data.gold}}

-
+

{{data.gold}}

+
+

{{data.gift}}

-
+

当前好感度: {{data.cur_impression}}

{% for i in data.heart2 %} - + {% endfor %} {% for i in data.heart1 %} - + {% endfor %}
@@ -61,11 +63,11 @@

{{data.attitude}}

距离升级还差{{data.interpolation}}好感度

-
-
+
+
-

28℃

+

28℃

{{data.date}} @@ -75,4 +77,5 @@ + \ No newline at end of file diff --git a/resources/template/ss_menu/main.css b/resources/template/ss_menu/main.css index 1cde35f43..dde338194 100644 --- a/resources/template/ss_menu/main.css +++ b/resources/template/ss_menu/main.css @@ -3,19 +3,19 @@ @font-face { font-family: fzrzFont; /* 导入的字体文件 */ - src: url("./res/font/fzrzExtraBold.ttf"); + src: url("../../font/fzrzExtraBold.ttf"); } @font-face { font-family: syhtFont; /* 导入的字体文件 */ - src: url("./res/font/syht.otf"); + src: url("../../font/syht.otf"); } @font-face { font-family: systFont; /* 导入的字体文件 */ - src: url("./res/font/syst.otf"); + src: url("../../font/syst.otf"); } diff --git a/tests/builtin_plugins/auto_update/test_check_update.py b/tests/builtin_plugins/auto_update/test_check_update.py new file mode 100644 index 000000000..4ef910b7b --- /dev/null +++ b/tests/builtin_plugins/auto_update/test_check_update.py @@ -0,0 +1,529 @@ +import io +import os +import tarfile +import zipfile +from typing import cast +from pathlib import Path +from collections.abc import Callable + +from nonebug import App +from respx import MockRouter +from pytest_mock import MockerFixture +from nonebot.adapters.onebot.v11 import Bot +from nonebot.adapters.onebot.v11.message import Message + +from tests.config import BotId, UserId, GroupId, MessageId +from tests.utils import get_response_json as _get_response_json +from tests.utils import _v11_group_message_event, _v11_private_message_send + + +def get_response_json(file: str) -> dict: + return _get_response_json(Path() / "auto_update", file) + + +def init_mocked_api(mocked_api: MockRouter) -> None: + mocked_api.get( + url="https://api.github.com/repos/HibiKier/zhenxun_bot/releases/latest", + name="release_latest", + ).respond(json=get_response_json("release_latest.json")) + + mocked_api.head( + url="https://raw.githubusercontent.com/", + name="head_raw", + ).respond(text="") + mocked_api.head( + url="https://github.com/", + name="head_github", + ).respond(text="") + mocked_api.head( + url="https://codeload.github.com/", + name="head_codeload", + ).respond(text="") + + mocked_api.get( + url="https://raw.githubusercontent.com/HibiKier/zhenxun_bot/dev/__version__", + name="dev_branch_version", + ).respond(text="__version__: v0.2.2-e6f17c4") + mocked_api.get( + url="https://raw.githubusercontent.com/HibiKier/zhenxun_bot/main/__version__", + name="main_branch_version", + ).respond(text="__version__: v0.2.2-e6f17c4") + mocked_api.get( + url="https://api.github.com/repos/HibiKier/zhenxun_bot/tarball/v0.2.2", + name="release_download_url", + ).respond( + status_code=302, + headers={ + "Location": "https://codeload.github.com/HibiKier/zhenxun_bot/legacy.tar.gz/refs/tags/v0.2.2" + }, + ) + + tar_buffer = io.BytesIO() + zip_bytes = io.BytesIO() + + from zhenxun.builtin_plugins.auto_update.config import ( + REPLACE_FOLDERS, + REQ_TXT_FILE_STRING, + PYPROJECT_FILE_STRING, + PYPROJECT_LOCK_FILE_STRING, + ) + + # 指定要添加到压缩文件中的文件路径列表 + file_paths: list[str] = [ + PYPROJECT_FILE_STRING, + PYPROJECT_LOCK_FILE_STRING, + REQ_TXT_FILE_STRING, + ] + + # 打开一个tarfile对象,写入到上面创建的BytesIO对象中 + with tarfile.open(mode="w:gz", fileobj=tar_buffer) as tar: + add_files_and_folders_to_tar(tar, file_paths, folders=REPLACE_FOLDERS) + + with zipfile.ZipFile(zip_bytes, mode="w", compression=zipfile.ZIP_DEFLATED) as zipf: + add_files_and_folders_to_zip(zipf, file_paths, folders=REPLACE_FOLDERS) + + mocked_api.get( + url="https://codeload.github.com/HibiKier/zhenxun_bot/legacy.tar.gz/refs/tags/v0.2.2", + name="release_download_url_redirect", + ).respond( + content=tar_buffer.getvalue(), + ) + mocked_api.get( + url="https://github.com/HibiKier/zhenxun_bot/archive/refs/heads/dev.zip", + name="dev_download_url", + ).respond( + content=zip_bytes.getvalue(), + ) + mocked_api.get( + url="https://github.com/HibiKier/zhenxun_bot/archive/refs/heads/main.zip", + name="main_download_url", + ).respond( + content=zip_bytes.getvalue(), + ) + + +# TODO Rename this here and in `init_mocked_api` +def add_files_and_folders_to_zip( + zipf: zipfile.ZipFile, file_paths: list[str], folders: list[str] = [] +): + """Add files and folders to a zip archive. + + This function creates a directory structure within the specified zip + archive and adds the provided files to it. It also creates additional + subdirectories as specified in the folders list. + + Args: + zipf: The zip archive to which files and folders will be added. + file_paths: A list of file names to be added to the zip archive. + folders: An optional list of subdirectory names to be created + within the base folder. + """ + + # 假设有一个文件夹名为 folder_name + folder_name = "my_folder/" + + # 添加文件夹到 ZIP 中,注意 ZIP 中文件夹路径应以 '/' 结尾 + zipf.writestr(folder_name, "") # 空内容表示这是一个文件夹 + + for file_path in file_paths: + # 将文件添加到 ZIP 中,路径为 folder_name + file_name + zipf.writestr(f"{folder_name}{os.path.basename(file_path)}", b"new") + base_folder = f"{folder_name}zhenxun/" + zipf.writestr(base_folder, "") + + for folder in folders: + zipf.writestr(f"{base_folder}{folder}/", "") + + +# TODO Rename this here and in `init_mocked_api` +def add_files_and_folders_to_tar( + tar: tarfile.TarFile, file_paths: list[str], folders: list[str] = [] +): + """Add files and folders to a tar archive. + + This function creates a directory structure within the specified tar + archive and adds the provided files to it. It also creates additional + subdirectories as specified in the folders list. + + Args: + tar: The tar archive to which files and folders will be added. + file_paths: A list of file names to be added to the tar archive. + folders: An optional list of subdirectory names to be created + within the base folder. + """ + + folder_name = "my_folder" + tarinfo = tarfile.TarInfo(folder_name) + add_directory_to_tar(tarinfo, tar) + # 读取并添加指定的文件 + for file_path in file_paths: + # 创建TarInfo对象 + tar_buffer = io.BytesIO(b"new") + tarinfo = tarfile.TarInfo( + f"{folder_name}/{file_path}" + ) # 使用文件名作为tar中的名字 + tarinfo.mode = 0o644 # 设置文件夹权限 + tarinfo.size = len(tar_buffer.getvalue()) # 设置文件大小 + + # 添加文件 + tar.addfile(tarinfo, fileobj=tar_buffer) + + base_folder = f"{folder_name}/zhenxun" + tarinfo = tarfile.TarInfo(base_folder) + add_directory_to_tar(tarinfo, tar) + for folder in folders: + tarinfo = tarfile.TarInfo(f"{base_folder}{folder}") + add_directory_to_tar(tarinfo, tar) + + +# TODO Rename this here and in `_extracted_from_init_mocked_api_43` +def add_directory_to_tar(tarinfo, tar): + """Add a directory entry to a tar archive. + + This function modifies the provided tarinfo object to set its type + as a directory and assigns the appropriate permissions before adding + it to the specified tar archive. + + Args: + tarinfo: The tarinfo object representing the directory. + tar: The tar archive to which the directory will be added. + """ + + tarinfo.type = tarfile.DIRTYPE + tarinfo.mode = 0o755 + tar.addfile(tarinfo) + + +def init_mocker_path(mocker: MockerFixture, tmp_path: Path): + from zhenxun.builtin_plugins.auto_update.config import ( + REQ_TXT_FILE_STRING, + VERSION_FILE_STRING, + PYPROJECT_FILE_STRING, + PYPROJECT_LOCK_FILE_STRING, + ) + + mocker.patch( + "zhenxun.builtin_plugins.auto_update._data_source.install_requirement", + return_value=None, + ) + mock_tmp_path = mocker.patch( + "zhenxun.builtin_plugins.auto_update._data_source.TMP_PATH", + new=tmp_path / "auto_update", + ) + mock_base_path = mocker.patch( + "zhenxun.builtin_plugins.auto_update._data_source.BASE_PATH", + new=tmp_path / "zhenxun", + ) + mock_backup_path = mocker.patch( + "zhenxun.builtin_plugins.auto_update._data_source.BACKUP_PATH", + new=tmp_path / "backup", + ) + mock_download_gz_file = mocker.patch( + "zhenxun.builtin_plugins.auto_update._data_source.DOWNLOAD_GZ_FILE", + new=mock_tmp_path / "download_latest_file.tar.gz", + ) + mock_download_zip_file = mocker.patch( + "zhenxun.builtin_plugins.auto_update._data_source.DOWNLOAD_ZIP_FILE", + new=mock_tmp_path / "download_latest_file.zip", + ) + mock_pyproject_file = mocker.patch( + "zhenxun.builtin_plugins.auto_update._data_source.PYPROJECT_FILE", + new=tmp_path / PYPROJECT_FILE_STRING, + ) + mock_pyproject_lock_file = mocker.patch( + "zhenxun.builtin_plugins.auto_update._data_source.PYPROJECT_LOCK_FILE", + new=tmp_path / PYPROJECT_LOCK_FILE_STRING, + ) + mock_req_txt_file = mocker.patch( + "zhenxun.builtin_plugins.auto_update._data_source.REQ_TXT_FILE", + new=tmp_path / REQ_TXT_FILE_STRING, + ) + mock_version_file = mocker.patch( + "zhenxun.builtin_plugins.auto_update._data_source.VERSION_FILE", + new=tmp_path / VERSION_FILE_STRING, + ) + open(mock_version_file, "w").write("__version__: v0.2.2") + return ( + mock_tmp_path, + mock_base_path, + mock_backup_path, + mock_download_gz_file, + mock_download_zip_file, + mock_pyproject_file, + mock_pyproject_lock_file, + mock_req_txt_file, + mock_version_file, + ) + + +async def test_check_update_release( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试检查更新(release) + """ + from zhenxun.builtin_plugins.auto_update import _matcher + from zhenxun.builtin_plugins.auto_update.config import ( + REPLACE_FOLDERS, + REQ_TXT_FILE_STRING, + PYPROJECT_FILE_STRING, + PYPROJECT_LOCK_FILE_STRING, + ) + + init_mocked_api(mocked_api=mocked_api) + + ( + mock_tmp_path, + mock_base_path, + mock_backup_path, + mock_download_gz_file, + mock_download_zip_file, + mock_pyproject_file, + mock_pyproject_lock_file, + mock_req_txt_file, + mock_version_file, + ) = init_mocker_path(mocker, tmp_path) + + # 确保目录下有一个子目录,以便 os.listdir() 能返回一个目录名 + mock_tmp_path.mkdir(parents=True, exist_ok=True) + + for folder in REPLACE_FOLDERS: + (mock_base_path / folder).mkdir(parents=True, exist_ok=True) + + mock_pyproject_file.write_bytes(b"") + mock_pyproject_lock_file.write_bytes(b"") + mock_req_txt_file.write_bytes(b"") + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot = cast(Bot, bot) + raw_message = "检查更新 release" + event = _v11_group_message_event( + raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID, + to_me=True, + ) + ctx.receive_event(bot, event) + ctx.should_call_api( + "send_msg", + _v11_private_message_send( + message="检测真寻已更新,版本更新:v0.2.2 -> v0.2.2\n" "开始更新...", + user_id=UserId.SUPERUSER, + ), + ) + ctx.should_call_send( + event=event, + message=Message( + "版本更新完成\n" "版本: v0.2.2 -> v0.2.2\n" "请重新启动真寻以完成更新!" + ), + result=None, + bot=bot, + ) + ctx.should_finished(_matcher) + assert mocked_api["release_latest"].called + assert mocked_api["release_download_url_redirect"].called + + assert (mock_backup_path / PYPROJECT_FILE_STRING).exists() + assert (mock_backup_path / PYPROJECT_LOCK_FILE_STRING).exists() + assert (mock_backup_path / REQ_TXT_FILE_STRING).exists() + + assert not mock_download_gz_file.exists() + assert not mock_download_zip_file.exists() + + assert mock_pyproject_file.read_bytes() == b"new" + assert mock_pyproject_lock_file.read_bytes() == b"new" + assert mock_req_txt_file.read_bytes() == b"new" + + for folder in REPLACE_FOLDERS: + assert not (mock_base_path / folder).exists() + for folder in REPLACE_FOLDERS: + assert (mock_backup_path / folder).exists() + + +async def test_check_update_dev( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试检查更新(开发环境) + """ + from zhenxun.builtin_plugins.auto_update import _matcher + from zhenxun.builtin_plugins.auto_update.config import ( + REPLACE_FOLDERS, + REQ_TXT_FILE_STRING, + PYPROJECT_FILE_STRING, + PYPROJECT_LOCK_FILE_STRING, + ) + + init_mocked_api(mocked_api=mocked_api) + + ( + mock_tmp_path, + mock_base_path, + mock_backup_path, + mock_download_gz_file, + mock_download_zip_file, + mock_pyproject_file, + mock_pyproject_lock_file, + mock_req_txt_file, + mock_version_file, + ) = init_mocker_path(mocker, tmp_path) + + # 确保目录下有一个子目录,以便 os.listdir() 能返回一个目录名 + mock_tmp_path.mkdir(parents=True, exist_ok=True) + for folder in REPLACE_FOLDERS: + (mock_base_path / folder).mkdir(parents=True, exist_ok=True) + + mock_pyproject_file.write_bytes(b"") + mock_pyproject_lock_file.write_bytes(b"") + mock_req_txt_file.write_bytes(b"") + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot = cast(Bot, bot) + raw_message = "检查更新 dev" + event = _v11_group_message_event( + raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID, + to_me=True, + ) + ctx.receive_event(bot, event) + ctx.should_call_api( + "send_msg", + _v11_private_message_send( + message="检测真寻已更新,版本更新:v0.2.2 -> v0.2.2-e6f17c4\n" + "开始更新...", + user_id=UserId.SUPERUSER, + ), + ) + ctx.should_call_send( + event=event, + message=Message( + "版本更新完成\n" + "版本: v0.2.2 -> v0.2.2-e6f17c4\n" + "请重新启动真寻以完成更新!" + ), + result=None, + bot=bot, + ) + ctx.should_finished(_matcher) + assert mocked_api["dev_download_url"].called + assert (mock_backup_path / PYPROJECT_FILE_STRING).exists() + assert (mock_backup_path / PYPROJECT_LOCK_FILE_STRING).exists() + assert (mock_backup_path / REQ_TXT_FILE_STRING).exists() + + assert not mock_download_gz_file.exists() + assert not mock_download_zip_file.exists() + + assert mock_pyproject_file.read_bytes() == b"new" + assert mock_pyproject_lock_file.read_bytes() == b"new" + assert mock_req_txt_file.read_bytes() == b"new" + + for folder in REPLACE_FOLDERS: + assert (mock_base_path / folder).exists() + for folder in REPLACE_FOLDERS: + assert (mock_backup_path / folder).exists() + + +async def test_check_update_main( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试检查更新(正式环境) + """ + from zhenxun.builtin_plugins.auto_update import _matcher + from zhenxun.builtin_plugins.auto_update.config import ( + REPLACE_FOLDERS, + REQ_TXT_FILE_STRING, + PYPROJECT_FILE_STRING, + PYPROJECT_LOCK_FILE_STRING, + ) + + init_mocked_api(mocked_api=mocked_api) + + ( + mock_tmp_path, + mock_base_path, + mock_backup_path, + mock_download_gz_file, + mock_download_zip_file, + mock_pyproject_file, + mock_pyproject_lock_file, + mock_req_txt_file, + mock_version_file, + ) = init_mocker_path(mocker, tmp_path) + + # 确保目录下有一个子目录,以便 os.listdir() 能返回一个目录名 + mock_tmp_path.mkdir(parents=True, exist_ok=True) + for folder in REPLACE_FOLDERS: + (mock_base_path / folder).mkdir(parents=True, exist_ok=True) + + mock_pyproject_file.write_bytes(b"") + mock_pyproject_lock_file.write_bytes(b"") + mock_req_txt_file.write_bytes(b"") + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot = cast(Bot, bot) + raw_message = "检查更新 main" + event = _v11_group_message_event( + raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID, + to_me=True, + ) + ctx.receive_event(bot, event) + ctx.should_call_api( + "send_msg", + _v11_private_message_send( + message="检测真寻已更新,版本更新:v0.2.2 -> v0.2.2-e6f17c4\n" + "开始更新...", + user_id=UserId.SUPERUSER, + ), + ) + ctx.should_call_send( + event=event, + message=Message( + "版本更新完成\n" + "版本: v0.2.2 -> v0.2.2-e6f17c4\n" + "请重新启动真寻以完成更新!" + ), + result=None, + bot=bot, + ) + ctx.should_finished(_matcher) + assert mocked_api["main_download_url"].called + assert (mock_backup_path / PYPROJECT_FILE_STRING).exists() + assert (mock_backup_path / PYPROJECT_LOCK_FILE_STRING).exists() + assert (mock_backup_path / REQ_TXT_FILE_STRING).exists() + + assert not mock_download_gz_file.exists() + assert not mock_download_zip_file.exists() + + assert mock_pyproject_file.read_bytes() == b"new" + assert mock_pyproject_lock_file.read_bytes() == b"new" + assert mock_req_txt_file.read_bytes() == b"new" + + for folder in REPLACE_FOLDERS: + assert (mock_base_path / folder).exists() + for folder in REPLACE_FOLDERS: + assert (mock_backup_path / folder).exists() diff --git a/tests/builtin_plugins/check/test_check.py b/tests/builtin_plugins/check/test_check.py new file mode 100644 index 000000000..5dbd3f6c3 --- /dev/null +++ b/tests/builtin_plugins/check/test_check.py @@ -0,0 +1,231 @@ +import platform +from typing import cast +from pathlib import Path +from collections.abc import Callable + +import nonebot +from nonebug import App +from respx import MockRouter +from pytest_mock import MockerFixture +from nonebot.adapters.onebot.v11 import Bot +from nonebot.adapters.onebot.v11.event import GroupMessageEvent + +from tests.utils import _v11_group_message_event +from tests.config import BotId, UserId, GroupId, MessageId + +platform_uname = platform.uname_result( + system="Linux", + node="zhenxun", + release="5.15.0-1027-azure", + version="#1 SMP Debian 5.15.0-1027-azure", + machine="x86_64", +) # type: ignore +cpuinfo_get_cpu_info = {"brand_raw": "Intel(R) Core(TM) i7-10700K"} + + +def init_mocker(mocker: MockerFixture, tmp_path: Path): + mock_psutil = mocker.patch("zhenxun.builtin_plugins.check.data_source.psutil") + mock_cpuinfo = mocker.patch("zhenxun.builtin_plugins.check.data_source.cpuinfo") + mock_cpuinfo.get_cpu_info.return_value = cpuinfo_get_cpu_info + + mock_platform = mocker.patch("zhenxun.builtin_plugins.check.data_source.platform") + mock_platform.uname.return_value = platform_uname + + mock_template_to_pic = mocker.patch("zhenxun.builtin_plugins.check.template_to_pic") + mock_template_to_pic_return = mocker.AsyncMock() + mock_template_to_pic.return_value = mock_template_to_pic_return + + mock_build_message = mocker.patch( + "zhenxun.builtin_plugins.check.MessageUtils.build_message" + ) + mock_build_message_return = mocker.AsyncMock() + mock_build_message.return_value = mock_build_message_return + + mock_template_path_new = tmp_path / "resources" / "template" + mocker.patch( + "zhenxun.builtin_plugins.check.TEMPLATE_PATH", new=mock_template_path_new + ) + return ( + mock_psutil, + mock_cpuinfo, + mock_platform, + mock_template_to_pic, + mock_template_to_pic_return, + mock_build_message, + mock_build_message_return, + mock_template_path_new, + ) + + +async def test_check( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试自检 + """ + from zhenxun.configs.config import BotConfig + from zhenxun.builtin_plugins.check import _matcher + from zhenxun.builtin_plugins.check.data_source import __get_version + + ( + mock_psutil, + mock_cpuinfo, + mock_platform, + mock_template_to_pic, + mock_template_to_pic_return, + mock_build_message, + mock_build_message_return, + mock_template_path_new, + ) = init_mocker(mocker, tmp_path) + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = "自检" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID_3, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + mock_template_to_pic.assert_awaited_once_with( + template_path=str((mock_template_path_new / "check").absolute()), + template_name="main.html", + templates={ + "data": { + "cpu_info": "1.0% - 1.0Ghz [1 core]", + "cpu_process": 1.0, + "ram_info": "1.0 / 1.0 GB", + "ram_process": 100.0, + "swap_info": "1.0 / 1.0 GB", + "swap_process": 100.0, + "disk_info": "1.0 / 1.0 GB", + "disk_process": 100.0, + "brand_raw": cpuinfo_get_cpu_info["brand_raw"], + "baidu": "red", + "google": "red", + "system": f"{platform_uname.system} " f"{platform_uname.release}", + "version": __get_version(), + "plugin_count": len(nonebot.get_loaded_plugins()), + "nickname": BotConfig.self_nickname, + } + }, + pages={ + "viewport": {"width": 195, "height": 750}, + "base_url": f"file://{mock_template_path_new.absolute()}", + }, + wait=2, + ) + mock_template_to_pic.assert_awaited_once() + mock_build_message.assert_called_once_with(mock_template_to_pic_return) + mock_build_message_return.send.assert_awaited_once() + + +async def test_check_arm( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试自检(arm) + """ + from zhenxun.configs.config import BotConfig + from zhenxun.builtin_plugins.check import _matcher + from zhenxun.builtin_plugins.check.data_source import __get_version + + platform_uname_arm = platform.uname_result( + system="Linux", + node="zhenxun", + release="5.15.0-1017-oracle", + version="#22~20.04.1-Ubuntu SMP Wed Aug 24 11:13:15 UTC 2022", + machine="aarch64", + ) # type: ignore + mock_subprocess_check_output = mocker.patch( + "zhenxun.builtin_plugins.check.data_source.subprocess.check_output" + ) + mock_environ_copy = mocker.patch( + "zhenxun.builtin_plugins.check.data_source.os.environ.copy" + ) + mock_environ_copy_return = mocker.MagicMock() + mock_environ_copy.return_value = mock_environ_copy_return + ( + mock_psutil, + mock_cpuinfo, + mock_platform, + mock_template_to_pic, + mock_template_to_pic_return, + mock_build_message, + mock_build_message_return, + mock_template_path_new, + ) = init_mocker(mocker, tmp_path) + + mock_platform.uname.return_value = platform_uname_arm + mock_cpuinfo.get_cpu_info.return_value = {} + mock_psutil.cpu_freq.return_value = {} + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = "自检" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID_3, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + mock_template_to_pic.assert_awaited_once_with( + template_path=str((mock_template_path_new / "check").absolute()), + template_name="main.html", + templates={ + "data": { + "cpu_info": "1.0% - 0.0Ghz [1 core]", + "cpu_process": 1.0, + "ram_info": "1.0 / 1.0 GB", + "ram_process": 100.0, + "swap_info": "1.0 / 1.0 GB", + "swap_process": 100.0, + "disk_info": "1.0 / 1.0 GB", + "disk_process": 100.0, + "brand_raw": "", + "baidu": "red", + "google": "red", + "system": f"{platform_uname_arm.system} " + f"{platform_uname_arm.release}", + "version": __get_version(), + "plugin_count": len(nonebot.get_loaded_plugins()), + "nickname": BotConfig.self_nickname, + } + }, + pages={ + "viewport": {"width": 195, "height": 750}, + "base_url": f"file://{mock_template_path_new.absolute()}", + }, + wait=2, + ) + mock_subprocess_check_output.assert_has_calls( + [ + mocker.call(["lscpu"], env=mock_environ_copy_return), + mocker.call().decode(), + mocker.call().decode().splitlines(), + mocker.call().decode().splitlines().__iter__(), + mocker.call(["dmidecode", "-s", "processor-frequency"]), + mocker.call().decode(), + mocker.call().decode().split(), + mocker.call().decode().split().__getitem__(0), + mocker.call().decode().split().__getitem__().__float__(), + ] # type: ignore + ) + mock_template_to_pic.assert_awaited_once() + mock_build_message.assert_called_once_with(mock_template_to_pic_return) + mock_build_message_return.send.assert_awaited_once() diff --git a/tests/builtin_plugins/plugin_store/test_add_plugin.py b/tests/builtin_plugins/plugin_store/test_add_plugin.py new file mode 100644 index 000000000..f2d99bd38 --- /dev/null +++ b/tests/builtin_plugins/plugin_store/test_add_plugin.py @@ -0,0 +1,339 @@ +from typing import cast +from pathlib import Path +from collections.abc import Callable + +import pytest +from nonebug import App +from respx import MockRouter +from pytest_mock import MockerFixture +from nonebot.adapters.onebot.v11 import Bot +from nonebot.adapters.onebot.v11.message import Message +from nonebot.adapters.onebot.v11.event import GroupMessageEvent + +from tests.utils import _v11_group_message_event +from tests.config import BotId, UserId, GroupId, MessageId +from tests.builtin_plugins.plugin_store.utils import init_mocked_api + + +@pytest.mark.parametrize("package_api", ["jsd", "gh"]) +async def test_add_plugin_basic( + package_api: str, + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试添加基础插件 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + + init_mocked_api(mocked_api=mocked_api) + mock_base_path = mocker.patch( + "zhenxun.builtin_plugins.plugin_store.data_source.BASE_PATH", + new=tmp_path / "zhenxun", + ) + + if package_api != "jsd": + mocked_api["zhenxun_bot_plugins_metadata"].respond(404) + if package_api != "gh": + mocked_api["zhenxun_bot_plugins_tree"].respond(404) + + plugin_id = 1 + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = f"添加插件 {plugin_id}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + ctx.should_call_send( + event=event, + message=Message(message=f"正在添加插件 Id: {plugin_id}"), + result=None, + bot=bot, + ) + ctx.should_call_send( + event=event, + message=Message(message="插件 识图 安装成功! 重启后生效"), + result=None, + bot=bot, + ) + assert mocked_api["basic_plugins"].called + assert mocked_api["extra_plugins"].called + assert mocked_api["search_image_plugin_file_init"].called + assert (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file() + + +@pytest.mark.parametrize("package_api", ["jsd", "gh"]) +async def test_add_plugin_basic_commit_version( + package_api: str, + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试添加基础插件 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + + init_mocked_api(mocked_api=mocked_api) + mock_base_path = mocker.patch( + "zhenxun.builtin_plugins.plugin_store.data_source.BASE_PATH", + new=tmp_path / "zhenxun", + ) + + if package_api != "jsd": + mocked_api["zhenxun_bot_plugins_metadata_commit"].respond(404) + if package_api != "gh": + mocked_api["zhenxun_bot_plugins_tree_commit"].respond(404) + + plugin_id = 3 + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = f"添加插件 {plugin_id}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + ctx.should_call_send( + event=event, + message=Message(message=f"正在添加插件 Id: {plugin_id}"), + result=None, + bot=bot, + ) + ctx.should_call_send( + event=event, + message=Message(message="插件 B站订阅 安装成功! 重启后生效"), + result=None, + bot=bot, + ) + assert mocked_api["basic_plugins"].called + assert mocked_api["extra_plugins"].called + if package_api == "jsd": + assert mocked_api["zhenxun_bot_plugins_metadata_commit"].called + if package_api == "gh": + assert mocked_api["zhenxun_bot_plugins_tree_commit"].called + assert mocked_api["bilibili_sub_plugin_file_init"].called + assert (mock_base_path / "plugins" / "bilibili_sub" / "__init__.py").is_file() + + +@pytest.mark.parametrize("package_api", ["jsd", "gh"]) +async def test_add_plugin_basic_is_not_dir( + package_api: str, + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试添加基础插件,插件不是目录 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + + init_mocked_api(mocked_api=mocked_api) + mock_base_path = mocker.patch( + "zhenxun.builtin_plugins.plugin_store.data_source.BASE_PATH", + new=tmp_path / "zhenxun", + ) + + if package_api != "jsd": + mocked_api["zhenxun_bot_plugins_metadata"].respond(404) + if package_api != "gh": + mocked_api["zhenxun_bot_plugins_tree"].respond(404) + + plugin_id = 0 + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = f"添加插件 {plugin_id}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + ctx.should_call_send( + event=event, + message=Message(message=f"正在添加插件 Id: {plugin_id}"), + result=None, + bot=bot, + ) + ctx.should_call_send( + event=event, + message=Message(message="插件 鸡汤 安装成功! 重启后生效"), + result=None, + bot=bot, + ) + assert mocked_api["basic_plugins"].called + assert mocked_api["extra_plugins"].called + assert mocked_api["jitang_plugin_file"].called + assert (mock_base_path / "plugins" / "alapi" / "jitang.py").is_file() + + +@pytest.mark.parametrize("package_api", ["jsd", "gh"]) +async def test_add_plugin_extra( + package_api: str, + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试添加额外插件 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + + init_mocked_api(mocked_api=mocked_api) + mock_base_path = mocker.patch( + "zhenxun.builtin_plugins.plugin_store.data_source.BASE_PATH", + new=tmp_path / "zhenxun", + ) + + if package_api != "jsd": + mocked_api["zhenxun_github_sub_metadata"].respond(404) + if package_api != "gh": + mocked_api["zhenxun_github_sub_tree"].respond(404) + + plugin_id = 4 + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message: str = f"添加插件 {plugin_id}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + ctx.should_call_send( + event=event, + message=Message(message=f"正在添加插件 Id: {plugin_id}"), + result=None, + bot=bot, + ) + ctx.should_call_send( + event=event, + message=Message(message="插件 github订阅 安装成功! 重启后生效"), + result=None, + bot=bot, + ) + assert mocked_api["basic_plugins"].called + assert mocked_api["extra_plugins"].called + assert mocked_api["github_sub_plugin_file_init"].called + assert (mock_base_path / "plugins" / "github_sub" / "__init__.py").is_file() + + +async def test_plugin_not_exist_add( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试插件不存在,添加插件 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + + init_mocked_api(mocked_api=mocked_api) + plugin_id = -1 + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = f"添加插件 {plugin_id}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + ctx.should_call_send( + event=event, + message=Message(message=f"正在添加插件 Id: {plugin_id}"), + result=None, + bot=bot, + ) + ctx.should_call_send( + event=event, + message=Message(message="插件ID不存在..."), + result=None, + bot=bot, + ) + + +async def test_add_plugin_exist( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试插件已经存在,添加插件 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + + init_mocked_api(mocked_api=mocked_api) + mocker.patch( + "zhenxun.builtin_plugins.plugin_store.data_source.ShopManage.get_loaded_plugins", + return_value=[("search_image", "0.1")], + ) + plugin_id = 1 + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = f"添加插件 {plugin_id}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + ctx.should_call_send( + event=event, + message=Message(message=f"正在添加插件 Id: {plugin_id}"), + result=None, + bot=bot, + ) + ctx.should_call_send( + event=event, + message=Message(message="插件 识图 已安装,无需重复安装"), + result=None, + bot=bot, + ) diff --git a/tests/builtin_plugins/plugin_store/test_plugin_store.py b/tests/builtin_plugins/plugin_store/test_plugin_store.py new file mode 100644 index 000000000..7a308354f --- /dev/null +++ b/tests/builtin_plugins/plugin_store/test_plugin_store.py @@ -0,0 +1,140 @@ +from typing import cast +from pathlib import Path +from collections.abc import Callable + +from nonebug import App +from respx import MockRouter +from pytest_mock import MockerFixture +from nonebot.adapters.onebot.v11 import Bot, Message +from nonebot.adapters.onebot.v11.event import GroupMessageEvent + +from tests.utils import _v11_group_message_event +from tests.config import BotId, UserId, GroupId, MessageId +from tests.builtin_plugins.plugin_store.utils import init_mocked_api + + +async def test_plugin_store( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试插件商店 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + from zhenxun.builtin_plugins.plugin_store.data_source import row_style + + init_mocked_api(mocked_api=mocked_api) + + mock_table_page = mocker.patch( + "zhenxun.builtin_plugins.plugin_store.data_source.ImageTemplate.table_page" + ) + mock_table_page_return = mocker.AsyncMock() + mock_table_page.return_value = mock_table_page_return + + mock_build_message = mocker.patch( + "zhenxun.builtin_plugins.plugin_store.MessageUtils.build_message" + ) + mock_build_message_return = mocker.AsyncMock() + mock_build_message.return_value = mock_build_message_return + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = "插件商店" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID_3, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + mock_table_page.assert_awaited_once_with( + "插件列表", + "通过添加/移除插件 ID 来管理插件", + ["-", "ID", "名称", "简介", "作者", "版本", "类型"], + [ + ["", 0, "鸡汤", "喏,亲手为你煮的鸡汤", "HibiKier", "0.1", "普通插件"], + ["", 1, "识图", "以图搜图,看破本源", "HibiKier", "0.1", "普通插件"], + ["", 2, "网易云热评", "生了个人,我很抱歉", "HibiKier", "0.1", "普通插件"], + [ + "", + 3, + "B站订阅", + "非常便利的B站订阅通知", + "HibiKier", + "0.3-b101fbc", + "普通插件", + ], + [ + "", + 4, + "github订阅", + "订阅github用户或仓库", + "xuanerwa", + "0.7", + "普通插件", + ], + [ + "", + 5, + "Minecraft查服", + "Minecraft服务器状态查询,支持IPv6", + "molanp", + "1.13", + "普通插件", + ], + ], + text_style=row_style, + ) + mock_build_message.assert_called_once_with(mock_table_page_return) + mock_build_message_return.send.assert_awaited_once() + + assert mocked_api["basic_plugins"].called + assert mocked_api["extra_plugins"].called + + +async def test_plugin_store_fail( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试插件商店 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + + init_mocked_api(mocked_api=mocked_api) + mocked_api.get( + "https://raw.githubusercontent.com/zhenxun-org/zhenxun_bot_plugins/main/plugins.json", + name="basic_plugins", + ).respond(404) + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = "插件商店" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID_3, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + ctx.should_call_send( + event=event, + message=Message("获取插件列表失败..."), + result=None, + exception=None, + bot=bot, + ) + + assert mocked_api["basic_plugins"].called diff --git a/tests/builtin_plugins/plugin_store/test_remove_plugin.py b/tests/builtin_plugins/plugin_store/test_remove_plugin.py new file mode 100644 index 000000000..5ebd45847 --- /dev/null +++ b/tests/builtin_plugins/plugin_store/test_remove_plugin.py @@ -0,0 +1,142 @@ +# ruff: noqa: ASYNC230 + +from typing import cast +from pathlib import Path +from collections.abc import Callable + +from nonebug import App +from respx import MockRouter +from pytest_mock import MockerFixture +from nonebot.adapters.onebot.v11 import Bot +from nonebot.adapters.onebot.v11.message import Message +from nonebot.adapters.onebot.v11.event import GroupMessageEvent + +from tests.utils import _v11_group_message_event +from tests.config import BotId, UserId, GroupId, MessageId +from tests.builtin_plugins.plugin_store.utils import init_mocked_api, get_content_bytes + + +async def test_remove_plugin( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试删除插件 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + + init_mocked_api(mocked_api=mocked_api) + mock_base_path = mocker.patch( + "zhenxun.builtin_plugins.plugin_store.data_source.BASE_PATH", + new=tmp_path / "zhenxun", + ) + + plugin_path = mock_base_path / "plugins" / "search_image" + plugin_path.mkdir(parents=True, exist_ok=True) + + with open(plugin_path / "__init__.py", "wb") as f: + f.write(get_content_bytes("search_image.py")) + + plugin_id = 1 + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = f"移除插件 {plugin_id}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + ctx.should_call_send( + event=event, + message=Message(message="插件 识图 移除成功! 重启后生效"), + result=None, + bot=bot, + ) + assert mocked_api["basic_plugins"].called + assert mocked_api["extra_plugins"].called + assert not (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file() + + +async def test_plugin_not_exist_remove( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试插件不存在,移除插件 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + + init_mocked_api(mocked_api=mocked_api) + plugin_id = -1 + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = f"移除插件 {plugin_id}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID_2, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + ctx.should_call_send( + event=event, + message=Message(message="插件ID不存在..."), + result=None, + bot=bot, + ) + + +async def test_remove_plugin_not_install( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试插件未安装,移除插件 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + + init_mocked_api(mocked_api=mocked_api) + _ = mocker.patch( + "zhenxun.builtin_plugins.plugin_store.data_source.BASE_PATH", + new=tmp_path / "zhenxun", + ) + plugin_id = 1 + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = f"移除插件 {plugin_id}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID_2, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + ctx.should_call_send( + event=event, + message=Message(message="插件 识图 不存在..."), + result=None, + bot=bot, + ) diff --git a/tests/builtin_plugins/plugin_store/test_search_plugin.py b/tests/builtin_plugins/plugin_store/test_search_plugin.py new file mode 100644 index 000000000..c97f71c02 --- /dev/null +++ b/tests/builtin_plugins/plugin_store/test_search_plugin.py @@ -0,0 +1,182 @@ +from typing import cast +from pathlib import Path +from collections.abc import Callable + +from nonebug import App +from respx import MockRouter +from pytest_mock import MockerFixture +from nonebot.adapters.onebot.v11 import Bot +from nonebot.adapters.onebot.v11.message import Message +from nonebot.adapters.onebot.v11.event import GroupMessageEvent + +from tests.utils import _v11_group_message_event +from tests.config import BotId, UserId, GroupId, MessageId +from tests.builtin_plugins.plugin_store.utils import init_mocked_api + + +async def test_search_plugin_name( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试搜索插件 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + from zhenxun.builtin_plugins.plugin_store.data_source import row_style + + init_mocked_api(mocked_api=mocked_api) + + mock_table_page = mocker.patch( + "zhenxun.builtin_plugins.plugin_store.data_source.ImageTemplate.table_page" + ) + mock_table_page_return = mocker.AsyncMock() + mock_table_page.return_value = mock_table_page_return + + mock_build_message = mocker.patch( + "zhenxun.builtin_plugins.plugin_store.MessageUtils.build_message" + ) + mock_build_message_return = mocker.AsyncMock() + mock_build_message.return_value = mock_build_message_return + + plugin_name = "github订阅" + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = f"搜索插件 {plugin_name}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID_3, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + mock_table_page.assert_awaited_once_with( + "插件列表", + "通过添加/移除插件 ID 来管理插件", + ["-", "ID", "名称", "简介", "作者", "版本", "类型"], + [ + [ + "", + 4, + "github订阅", + "订阅github用户或仓库", + "xuanerwa", + "0.7", + "普通插件", + ] + ], + text_style=row_style, + ) + mock_build_message.assert_called_once_with(mock_table_page_return) + mock_build_message_return.send.assert_awaited_once() + + assert mocked_api["basic_plugins"].called + assert mocked_api["extra_plugins"].called + + +async def test_search_plugin_author( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试搜索插件,作者 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + from zhenxun.builtin_plugins.plugin_store.data_source import row_style + + init_mocked_api(mocked_api=mocked_api) + + mock_table_page = mocker.patch( + "zhenxun.builtin_plugins.plugin_store.data_source.ImageTemplate.table_page" + ) + mock_table_page_return = mocker.AsyncMock() + mock_table_page.return_value = mock_table_page_return + + mock_build_message = mocker.patch( + "zhenxun.builtin_plugins.plugin_store.MessageUtils.build_message" + ) + mock_build_message_return = mocker.AsyncMock() + mock_build_message.return_value = mock_build_message_return + + author_name = "xuanerwa" + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = f"搜索插件 {author_name}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID_3, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + mock_table_page.assert_awaited_once_with( + "插件列表", + "通过添加/移除插件 ID 来管理插件", + ["-", "ID", "名称", "简介", "作者", "版本", "类型"], + [ + [ + "", + 4, + "github订阅", + "订阅github用户或仓库", + "xuanerwa", + "0.7", + "普通插件", + ] + ], + text_style=row_style, + ) + mock_build_message.assert_called_once_with(mock_table_page_return) + mock_build_message_return.send.assert_awaited_once() + + assert mocked_api["basic_plugins"].called + assert mocked_api["extra_plugins"].called + + +async def test_plugin_not_exist_search( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试插件不存在,搜索插件 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + + init_mocked_api(mocked_api=mocked_api) + plugin_name = "not_exist_plugin_name" + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = f"搜索插件 {plugin_name}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID_3, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + ctx.should_call_send( + event=event, + message=Message(message="未找到相关插件..."), + result=None, + bot=bot, + ) diff --git a/tests/builtin_plugins/plugin_store/test_update_plugin.py b/tests/builtin_plugins/plugin_store/test_update_plugin.py new file mode 100644 index 000000000..7a73bb162 --- /dev/null +++ b/tests/builtin_plugins/plugin_store/test_update_plugin.py @@ -0,0 +1,206 @@ +from typing import cast +from pathlib import Path +from collections.abc import Callable + +from nonebug import App +from respx import MockRouter +from pytest_mock import MockerFixture +from nonebot.adapters.onebot.v11 import Bot +from nonebot.adapters.onebot.v11.message import Message +from nonebot.adapters.onebot.v11.event import GroupMessageEvent + +from tests.utils import _v11_group_message_event +from tests.config import BotId, UserId, GroupId, MessageId +from tests.builtin_plugins.plugin_store.utils import init_mocked_api + + +async def test_update_plugin_basic_need_update( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试更新基础插件,插件需要更新 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + + init_mocked_api(mocked_api=mocked_api) + mock_base_path = mocker.patch( + "zhenxun.builtin_plugins.plugin_store.data_source.BASE_PATH", + new=tmp_path / "zhenxun", + ) + mocker.patch( + "zhenxun.builtin_plugins.plugin_store.data_source.ShopManage.get_loaded_plugins", + return_value=[("search_image", "0.0")], + ) + + plugin_id = 1 + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = f"更新插件 {plugin_id}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + ctx.should_call_send( + event=event, + message=Message(message=f"正在更新插件 Id: {plugin_id}"), + result=None, + bot=bot, + ) + ctx.should_call_send( + event=event, + message=Message(message="插件 识图 更新成功! 重启后生效"), + result=None, + bot=bot, + ) + assert mocked_api["basic_plugins"].called + assert mocked_api["extra_plugins"].called + assert mocked_api["search_image_plugin_file_init"].called + assert (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file() + + +async def test_update_plugin_basic_is_new( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试更新基础插件,插件是最新版 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + + init_mocked_api(mocked_api=mocked_api) + mocker.patch( + "zhenxun.builtin_plugins.plugin_store.data_source.BASE_PATH", + new=tmp_path / "zhenxun", + ) + mocker.patch( + "zhenxun.builtin_plugins.plugin_store.data_source.ShopManage.get_loaded_plugins", + return_value=[("search_image", "0.1")], + ) + + plugin_id = 1 + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = f"更新插件 {plugin_id}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + ctx.should_call_send( + event=event, + message=Message(message=f"正在更新插件 Id: {plugin_id}"), + result=None, + bot=bot, + ) + ctx.should_call_send( + event=event, + message=Message(message="插件 识图 已是最新版本"), + result=None, + bot=bot, + ) + assert mocked_api["basic_plugins"].called + assert mocked_api["extra_plugins"].called + + +async def test_plugin_not_exist_update( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试插件不存在,更新插件 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + + init_mocked_api(mocked_api=mocked_api) + plugin_id = -1 + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = f"更新插件 {plugin_id}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID_2, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + ctx.should_call_send( + event=event, + message=Message(message=f"正在更新插件 Id: {plugin_id}"), + result=None, + bot=bot, + ) + ctx.should_call_send( + event=event, + message=Message(message="插件ID不存在..."), + result=None, + bot=bot, + ) + + +async def test_update_plugin_not_install( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + create_bot: Callable, + tmp_path: Path, +) -> None: + """ + 测试插件不存在,更新插件 + """ + from zhenxun.builtin_plugins.plugin_store import _matcher + + init_mocked_api(mocked_api=mocked_api) + plugin_id = 1 + + async with app.test_matcher(_matcher) as ctx: + bot = create_bot(ctx) + bot: Bot = cast(Bot, bot) + raw_message = f"更新插件 {plugin_id}" + event: GroupMessageEvent = _v11_group_message_event( + message=raw_message, + self_id=BotId.QQ_BOT, + user_id=UserId.SUPERUSER, + group_id=GroupId.GROUP_ID_LEVEL_5, + message_id=MessageId.MESSAGE_ID_2, + to_me=True, + ) + ctx.receive_event(bot=bot, event=event) + ctx.should_call_send( + event=event, + message=Message(message=f"正在更新插件 Id: {plugin_id}"), + result=None, + bot=bot, + ) + ctx.should_call_send( + event=event, + message=Message(message="插件 识图 未安装,无法更新"), + result=None, + bot=bot, + ) diff --git a/tests/builtin_plugins/plugin_store/utils.py b/tests/builtin_plugins/plugin_store/utils.py new file mode 100644 index 000000000..831290716 --- /dev/null +++ b/tests/builtin_plugins/plugin_store/utils.py @@ -0,0 +1,86 @@ +# ruff: noqa: ASYNC230 + +from pathlib import Path + +from respx import MockRouter + +from tests.utils import get_content_bytes as _get_content_bytes +from tests.utils import get_response_json as _get_response_json + + +def get_response_json(file: str) -> dict: + return _get_response_json(Path() / "plugin_store", file=file) + + +def get_content_bytes(file: str) -> bytes: + return _get_content_bytes(Path() / "plugin_store", file) + + +def init_mocked_api(mocked_api: MockRouter) -> None: + # metadata + mocked_api.get( + "https://data.jsdelivr.com/v1/packages/gh/zhenxun-org/zhenxun_bot_plugins@main", + name="zhenxun_bot_plugins_metadata", + ).respond(json=get_response_json("zhenxun_bot_plugins_metadata.json")) + mocked_api.get( + "https://data.jsdelivr.com/v1/packages/gh/xuanerwa/zhenxun_github_sub@main", + name="zhenxun_github_sub_metadata", + ).respond(json=get_response_json("zhenxun_github_sub_metadata.json")) + mocked_api.get( + "https://data.jsdelivr.com/v1/packages/gh/zhenxun-org/zhenxun_bot_plugins@b101fbc", + name="zhenxun_bot_plugins_metadata_commit", + ).respond(json=get_response_json("zhenxun_bot_plugins_metadata.json")) + + # tree + mocked_api.get( + "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/main?recursive=1", + name="zhenxun_bot_plugins_tree", + ).respond(json=get_response_json("zhenxun_bot_plugins_tree.json")) + mocked_api.get( + "https://api.github.com/repos/xuanerwa/zhenxun_github_sub/git/trees/main?recursive=1", + name="zhenxun_github_sub_tree", + ).respond(json=get_response_json("zhenxun_github_sub_tree.json")) + mocked_api.get( + "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/b101fbc?recursive=1", + name="zhenxun_bot_plugins_tree_commit", + ).respond(json=get_response_json("zhenxun_bot_plugins_tree.json")) + + mocked_api.head( + "https://raw.githubusercontent.com/", + name="head_raw", + ).respond(200, text="") + + mocked_api.get( + "https://raw.githubusercontent.com/zhenxun-org/zhenxun_bot_plugins/main/plugins.json", + name="basic_plugins", + ).respond(json=get_response_json("basic_plugins.json")) + mocked_api.get( + "https://cdn.jsdelivr.net/gh/zhenxun-org/zhenxun_bot_plugins@main/plugins.json", + name="basic_plugins_jsdelivr", + ).respond(200, json=get_response_json("basic_plugins.json")) + + mocked_api.get( + "https://raw.githubusercontent.com/zhenxun-org/zhenxun_bot_plugins_index/index/plugins.json", + name="extra_plugins", + ).respond(200, json=get_response_json("extra_plugins.json")) + mocked_api.get( + "https://cdn.jsdelivr.net/gh/zhenxun-org/zhenxun_bot_plugins_index@index/plugins.json", + name="extra_plugins_jsdelivr", + ).respond(200, json=get_response_json("extra_plugins.json")) + + mocked_api.get( + "https://raw.githubusercontent.com/zhenxun-org/zhenxun_bot_plugins/main/plugins/search_image/__init__.py", + name="search_image_plugin_file_init", + ).respond(content=get_content_bytes("search_image.py")) + mocked_api.get( + "https://raw.githubusercontent.com/zhenxun-org/zhenxun_bot_plugins/main/plugins/alapi/jitang.py", + name="jitang_plugin_file", + ).respond(content=get_content_bytes("jitang.py")) + mocked_api.get( + "https://raw.githubusercontent.com/xuanerwa/zhenxun_github_sub/main/github_sub/__init__.py", + name="github_sub_plugin_file_init", + ).respond(content=get_content_bytes("github_sub.py")) + mocked_api.get( + "https://raw.githubusercontent.com/zhenxun-org/zhenxun_bot_plugins/b101fbc/plugins/bilibili_sub/__init__.py", + name="bilibili_sub_plugin_file_init", + ).respond(content=get_content_bytes("bilibili_sub.py")) diff --git a/tests/config.py b/tests/config.py new file mode 100644 index 000000000..d472c0a8d --- /dev/null +++ b/tests/config.py @@ -0,0 +1,22 @@ +class BotId: + QQ_BOT = 12345 + + +class UserId: + SUPERUSER = 10000 + SUPERUSER_QQ = 11000 + SUPERUSER_DODO = 12000 + USER_LEVEL_0 = 10010 + USER_LEVEL_5 = 10005 + + +class GroupId: + GROUP_ID_LEVEL_0 = 20000 + GROUP_ID_LEVEL_5 = 20005 + + +class MessageId: + MESSAGE_ID = 30001 + MESSAGE_ID_2 = 30002 + MESSAGE_ID_3 = 30003 + MESSAGE_ID_4 = 30004 diff --git a/tests/conftest.py b/tests/conftest.py index cc181291e..038f51d35 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,37 +1,106 @@ +import os +import json from pathlib import Path +from collections.abc import Callable -import nonebot import pytest -from nonebot.plugin import Plugin -from nonebug import NONEBOT_INIT_KWARGS +import nonebot from nonebug.app import App -from pytest_mock import MockerFixture from respx import MockRouter +from pytest_mock import MockerFixture +from nonebug import NONEBOT_INIT_KWARGS +from nonebug.mixin.process import MatcherContext + +from tests.config import BotId, UserId + +nonebot.load_plugin("nonebot_plugin_session") + + +def get_response_json(path: str) -> dict: + return json.loads( + (Path(__file__).parent / "response" / path).read_text(encoding="utf8") + ) def pytest_configure(config: pytest.Config) -> None: config.stash[NONEBOT_INIT_KWARGS] = { "driver": "~fastapi+~httpx+~websockets", - "superusers": ["AkashiCoin"], - "command_start": "", + "superusers": [UserId.SUPERUSER.__str__()], + "command_start": [""], "session_running_expression": "别急呀,小真寻要宕机了!QAQ", "image_to_bytes": False, "nickname": ["真寻", "小真寻", "绪山真寻", "小寻子"], "session_expire_timeout": 30, "self_nickname": "小真寻", "db_url": "sqlite://:memory:", - "platform_superusers": {"qq": ["qq_su"], "dodo": ["dodo_su"]}, + "platform_superusers": { + "qq": [UserId.SUPERUSER_QQ.__str__()], + "dodo": [UserId.SUPERUSER_DODO.__str__()], + }, "host": "127.0.0.1", "port": 8080, + "log_level": "DEBUG", } @pytest.fixture(scope="session", autouse=True) -def load_plugin(nonebug_init: None) -> set[Plugin]: - return nonebot.load_plugins("zhenxun.plugins") +def _init_bot(nonebug_init: None): + from nonebot.adapters.onebot.v11 import Adapter as OneBotV11Adapter + + driver = nonebot.get_driver() + driver.register_adapter(OneBotV11Adapter) + + nonebot.load_plugin("nonebot_plugin_alconna") + nonebot.load_plugin("nonebot_plugin_apscheduler") + nonebot.load_plugin("nonebot_plugin_userinfo") + nonebot.load_plugin("nonebot_plugin_htmlrender") + + nonebot.load_plugins("zhenxun/builtin_plugins") + nonebot.load_plugins("zhenxun/plugins") @pytest.fixture async def app(app: App, tmp_path: Path, mocker: MockerFixture): - mocker.patch("nonebot.drivers.websockets.connect", return_value=MockRouter()) - return app + from zhenxun.services.db_context import init, disconnect + + driver = nonebot.get_driver() + # 清除连接钩子,现在 NoneBug 会自动触发 on_bot_connect + driver._bot_connection_hook.clear() + mock_config_path = mocker.MagicMock() + mock_config_path.LOG_PATH = tmp_path / "log" + # mock_config_path.LOG_PATH.mkdir(parents=True, exist_ok=True) + mock_config_path.DATA_PATH = tmp_path / "data" + # mock_config_path.DATA_PATH.mkdir(parents=True, exist_ok=True) + mock_config_path.TEMP_PATH = tmp_path / "resources" / "temp" + # mock_config_path.TEMP_PATH.mkdir(parents=True, exist_ok=True) + + mocker.patch("zhenxun.configs.path_config", new=mock_config_path) + + await init() + # await driver._lifespan.startup() + os.environ["AIOCACHE_DISABLE"] = "1" + + yield app + + del os.environ["AIOCACHE_DISABLE"] + # await driver._lifespan.shutdown() + await disconnect() + + +@pytest.fixture +def create_bot() -> Callable: + from nonebot.adapters.onebot.v11 import Bot, Adapter + + def _create_bot(context: MatcherContext) -> Bot: + return context.create_bot( + base=Bot, + adapter=nonebot.get_adapter(Adapter), + self_id=BotId.QQ_BOT.__str__(), + ) + + return _create_bot + + +@pytest.fixture +def mocked_api(respx_mock: MockRouter): + return respx_mock diff --git a/tests/content/plugin_store/bilibili_sub.py b/tests/content/plugin_store/bilibili_sub.py new file mode 100644 index 000000000..e4af9fbfb --- /dev/null +++ b/tests/content/plugin_store/bilibili_sub.py @@ -0,0 +1,37 @@ +from nonebot.plugin import PluginMetadata + +from zhenxun.configs.utils import PluginExtraData + +__plugin_meta__ = PluginMetadata( + name="B站订阅", + description="非常便利的B站订阅通知", + usage=""" + usage: + B站直播,番剧,UP动态开播等提醒 + 主播订阅相当于 直播间订阅 + UP订阅 + 指令: + 添加订阅 ['主播'/'UP'/'番剧'] [id/链接/番名] + 删除订阅 ['主播'/'UP'/'id'] [id] + 查看订阅 + 示例: + 添加订阅主播 2345344 <-(直播房间id) + 添加订阅UP 2355543 <-(个人主页id) + 添加订阅番剧 史莱姆 <-(支持模糊搜索) + 添加订阅番剧 125344 <-(番剧id) + 删除订阅id 2324344 <-(任意id,通过查看订阅获取) + """.strip(), + extra=PluginExtraData( + author="HibiKier", + version="0.3-b101fbc", + superuser_help=""" + 登录b站获取cookie防止风控: + bil_check/检测b站 + bil_login/登录b站 + bil_logout/退出b站 uid + 示例: + 登录b站 + 检测b站 + bil_logout 12345<-(退出登录的b站uid,通过检测b站获取) + """, + ).dict(), +) diff --git a/tests/content/plugin_store/github_sub.py b/tests/content/plugin_store/github_sub.py new file mode 100644 index 000000000..17b349d5b --- /dev/null +++ b/tests/content/plugin_store/github_sub.py @@ -0,0 +1,24 @@ +from nonebot.plugin import PluginMetadata + +from zhenxun.configs.utils import PluginExtraData + +__plugin_meta__ = PluginMetadata( + name="github订阅", + description="订阅github用户或仓库", + usage=""" + usage: + github新Comment,PR,Issue等提醒 + 指令: + 添加github ['用户'/'仓库'] [用户名/{owner/repo}] + 删除github [用户名/{owner/repo}] + 查看github + 示例:添加github订阅 用户 HibiKier + 示例:添加gb订阅 仓库 HibiKier/zhenxun_bot + 示例:添加github 用户 HibiKier + 示例:删除gb订阅 HibiKier + """.strip(), + extra=PluginExtraData( + author="xuanerwa", + version="0.7", + ).dict(), +) diff --git a/tests/content/plugin_store/jitang.py b/tests/content/plugin_store/jitang.py new file mode 100644 index 000000000..ae1207e78 --- /dev/null +++ b/tests/content/plugin_store/jitang.py @@ -0,0 +1,17 @@ +from nonebot.plugin import PluginMetadata + +from zhenxun.configs.utils import PluginExtraData + +__plugin_meta__ = PluginMetadata( + name="鸡汤", + description="喏,亲手为你煮的鸡汤", + usage=""" + 不喝点什么感觉有点不舒服 + 指令: + 鸡汤 + """.strip(), + extra=PluginExtraData( + author="HibiKier", + version="0.1", + ).dict(), +) diff --git a/tests/content/plugin_store/search_image.py b/tests/content/plugin_store/search_image.py new file mode 100644 index 000000000..9b1c14b0a --- /dev/null +++ b/tests/content/plugin_store/search_image.py @@ -0,0 +1,18 @@ +from nonebot.plugin import PluginMetadata + +from zhenxun.configs.utils import PluginExtraData + +__plugin_meta__ = PluginMetadata( + name="识图", + description="以图搜图,看破本源", + usage=""" + 识别图片 [二次元图片] + 指令: + 识图 [图片] + """.strip(), + extra=PluginExtraData( + author="HibiKier", + version="0.1", + menu_type="一些工具", + ).dict(), +) diff --git a/tests/response/auto_update/release_latest.json b/tests/response/auto_update/release_latest.json new file mode 100644 index 000000000..099c928a2 --- /dev/null +++ b/tests/response/auto_update/release_latest.json @@ -0,0 +1,39 @@ +{ + "url": "https://api.github.com/repos/HibiKier/zhenxun_bot/releases/172632135", + "assets_url": "https://api.github.com/repos/HibiKier/zhenxun_bot/releases/172632135/assets", + "upload_url": "https://uploads.github.com/repos/HibiKier/zhenxun_bot/releases/172632135/assets{?name,label}", + "html_url": "https://github.com/HibiKier/zhenxun_bot/releases/tag/v0.2.2", + "id": 172632135, + "author": { + "login": "HibiKier", + "id": 45528451, + "node_id": "MDQ6VXNlcjQ1NTI4NDUx", + "avatar_url": "https://avatars.githubusercontent.com/u/45528451?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/HibiKier", + "html_url": "https://github.com/HibiKier", + "followers_url": "https://api.github.com/users/HibiKier/followers", + "following_url": "https://api.github.com/users/HibiKier/following{/other_user}", + "gists_url": "https://api.github.com/users/HibiKier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/HibiKier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/HibiKier/subscriptions", + "organizations_url": "https://api.github.com/users/HibiKier/orgs", + "repos_url": "https://api.github.com/users/HibiKier/repos", + "events_url": "https://api.github.com/users/HibiKier/events{/privacy}", + "received_events_url": "https://api.github.com/users/HibiKier/received_events", + "type": "User", + "site_admin": false + }, + "node_id": "RE_kwDOFe9cjs4KSihH", + "tag_name": "v0.2.2", + "target_commitish": "main", + "name": "v0.2.2", + "draft": false, + "prerelease": false, + "created_at": "2024-08-29T18:48:53Z", + "published_at": "2024-08-29T18:49:34Z", + "assets": [], + "tarball_url": "https://api.github.com/repos/HibiKier/zhenxun_bot/tarball/v0.2.2", + "zipball_url": "https://api.github.com/repos/HibiKier/zhenxun_bot/zipball/v0.2.2", + "body": "* 集成webui\r\n* 移除plugins\r\n* 签到和帮助ui改版\r\n* 修改bug" +} diff --git a/tests/response/plugin_store/basic_plugins.json b/tests/response/plugin_store/basic_plugins.json new file mode 100644 index 000000000..7459e2ecc --- /dev/null +++ b/tests/response/plugin_store/basic_plugins.json @@ -0,0 +1,42 @@ +{ + "鸡汤": { + "module": "jitang", + "module_path": "plugins.alapi.jitang", + "description": "喏,亲手为你煮的鸡汤", + "usage": "不喝点什么感觉有点不舒服\n 指令:\n 鸡汤", + "author": "HibiKier", + "version": "0.1", + "plugin_type": "NORMAL", + "is_dir": false + }, + "识图": { + "module": "search_image", + "module_path": "plugins.search_image", + "description": "以图搜图,看破本源", + "usage": "识别图片 [二次元图片]\n 指令:\n 识图 [图片]", + "author": "HibiKier", + "version": "0.1", + "plugin_type": "NORMAL", + "is_dir": true + }, + "网易云热评": { + "module": "comments_163", + "module_path": "plugins.alapi.comments_163", + "description": "生了个人,我很抱歉", + "usage": "到点了,还是防不了下塔\n 指令:\n 网易云热评/到点了/12点了", + "author": "HibiKier", + "version": "0.1", + "plugin_type": "NORMAL", + "is_dir": false + }, + "B站订阅": { + "module": "bilibili_sub", + "module_path": "plugins.bilibili_sub", + "description": "非常便利的B站订阅通知", + "usage": "B站直播,番剧,UP动态开播等提醒", + "author": "HibiKier", + "version": "0.3-b101fbc", + "plugin_type": "NORMAL", + "is_dir": true + } +} diff --git a/tests/response/plugin_store/extra_plugins.json b/tests/response/plugin_store/extra_plugins.json new file mode 100644 index 000000000..9d92f8597 --- /dev/null +++ b/tests/response/plugin_store/extra_plugins.json @@ -0,0 +1,24 @@ +{ + "github订阅": { + "module": "github_sub", + "module_path": "github_sub", + "description": "订阅github用户或仓库", + "usage": "usage:\n github新Comment,PR,Issue等提醒\n 指令:\n 添加github ['用户'/'仓库'] [用户名/{owner/repo}]\n 删除github [用户名/{owner/repo}]\n 查看github\n 示例:添加github订阅 用户 HibiKier\n 示例:添加gb订阅 仓库 HibiKier/zhenxun_bot\n 示例:添加github 用户 HibiKier\n 示例:删除gb订阅 HibiKier", + "author": "xuanerwa", + "version": "0.7", + "plugin_type": "NORMAL", + "is_dir": true, + "github_url": "https://github.com/xuanerwa/zhenxun_github_sub" + }, + "Minecraft查服": { + "module": "mc_check", + "module_path": "mc_check", + "description": "Minecraft服务器状态查询,支持IPv6", + "usage": "Minecraft服务器状态查询,支持IPv6\n用法:\n\t查服 [ip]:[端口] / 查服 [ip]\n\t设置语言 zh-cn\n\t当前语言\n\t语言列表\neg:\t\nmcheck ip:port / mcheck ip\n\tset_lang en\n\tlang_now\n\tlang_list", + "author": "molanp", + "version": "1.13", + "plugin_type": "NORMAL", + "is_dir": true, + "github_url": "https://github.com/molanp/zhenxun_check_Minecraft" + } +} diff --git a/tests/response/plugin_store/zhenxun_bot_plugins_metadata.json b/tests/response/plugin_store/zhenxun_bot_plugins_metadata.json new file mode 100644 index 000000000..49faa7ab6 --- /dev/null +++ b/tests/response/plugin_store/zhenxun_bot_plugins_metadata.json @@ -0,0 +1,83 @@ +{ + "type": "gh", + "name": "zhenxun-org/zhenxun_bot_plugins", + "version": "main", + "default": null, + "files": [ + { + "type": "directory", + "name": "plugins", + "files": [ + { + "type": "directory", + "name": "search_image", + "files": [ + { + "type": "file", + "name": "__init__.py", + "hash": "a4Yp9HPoBzMwvnQDT495u0yYqTQWofkOyHxEi1FdVb0=", + "size": 3010 + } + ] + }, + { + "type": "directory", + "name": "alapi", + "files": [ + { + "type": "file", + "name": "__init__.py", + "hash": "ndDxtO0pAq3ZTb4RdqW7FTDgOGC/RjS1dnwdaQfT0uQ=", + "size": 284 + }, + { + "type": "file", + "name": "_data_source.py", + "hash": "KOLqtj4TQWWQco5bA4tWFc7A0z1ruMyDk1RiKeqJHRA=", + "size": 919 + }, + { + "type": "file", + "name": "comments_163.py", + "hash": "Q5pZsj1Pj+EJMdKYcPtLqejcXAWUQIoXVQG49PZPaSI=", + "size": 1593 + }, + { + "type": "file", + "name": "cover.py", + "hash": "QSjtcy0oVrjaRiAWZKmUJlp0L4DQqEcdYNmExNo9mgc=", + "size": 1438 + }, + { + "type": "file", + "name": "jitang.py", + "hash": "xh43Osxt0xogTH448gUMC+/DaSGmCFme8DWUqC25IbU=", + "size": 1411 + }, + { + "type": "file", + "name": "poetry.py", + "hash": "Aj2unoNQboj3/0LhIrYU+dCa5jvMdpjMYXYUayhjuz4=", + "size": 1530 + } + ] + }, + { + "type": "directory", + "name": "bilibili_sub", + "files": [ + { + "type": "file", + "name": "__init__.py", + "hash": "407DCgNFcZnuEK+d716j8EWrFQc4Nlxa35V3yemy3WQ=", + "size": 14293 + } + ] + } + ] + } + ], + "links": { + "stats": "https://data.jsdelivr.com/v1/stats/packages/gh/zhenxun-org/zhenxun_bot_plugins@main" + } +} diff --git a/tests/response/plugin_store/zhenxun_bot_plugins_tree.json b/tests/response/plugin_store/zhenxun_bot_plugins_tree.json new file mode 100644 index 000000000..54a266e39 --- /dev/null +++ b/tests/response/plugin_store/zhenxun_bot_plugins_tree.json @@ -0,0 +1,1324 @@ +{ + "sha": "af93a5425c039ee176207d0aceeeb43221a06e46", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/af93a5425c039ee176207d0aceeeb43221a06e46", + "tree": [ + { + "path": ".gitignore", + "mode": "100644", + "type": "blob", + "sha": "98bf2bb61a79b9b0cd4a51aea8bd21243b1b5fe2", + "size": 2967, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/98bf2bb61a79b9b0cd4a51aea8bd21243b1b5fe2" + }, + { + "path": "LICENSE", + "mode": "100644", + "type": "blob", + "sha": "0ad25db4bd1d86c452db3f9602ccdbe172438f52", + "size": 34523, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/0ad25db4bd1d86c452db3f9602ccdbe172438f52" + }, + { + "path": "README.md", + "mode": "100644", + "type": "blob", + "sha": "68498670cab18fb7c7beac28fa3aa917d772479b", + "size": 195, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/68498670cab18fb7c7beac28fa3aa917d772479b" + }, + { + "path": "plugins.json", + "mode": "100644", + "type": "blob", + "sha": "dcd858cc97fc06469b08b67d932fa143fd96275d", + "size": 23533, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/dcd858cc97fc06469b08b67d932fa143fd96275d" + }, + { + "path": "plugins", + "mode": "040000", + "type": "tree", + "sha": "780cade2ac406cd7ea33e1ed3915dfdc03151655", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/780cade2ac406cd7ea33e1ed3915dfdc03151655" + }, + { + "path": "plugins/ai", + "mode": "040000", + "type": "tree", + "sha": "60d1ed567fef497e99db3068f579812850e4f6a2", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/60d1ed567fef497e99db3068f579812850e4f6a2" + }, + { + "path": "plugins/ai/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "fec808b3349843eb3964d243a7574a52e13e5ae7", + "size": 2810, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/fec808b3349843eb3964d243a7574a52e13e5ae7" + }, + { + "path": "plugins/ai/data_source.py", + "mode": "100644", + "type": "blob", + "sha": "8c813c7d6428e3bf09b4f3f85c4ebf241527f780", + "size": 7401, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/8c813c7d6428e3bf09b4f3f85c4ebf241527f780" + }, + { + "path": "plugins/ai/utils.py", + "mode": "100644", + "type": "blob", + "sha": "e22d1fde9481cb8813abf80dc8768ffb196b870d", + "size": 5511, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/e22d1fde9481cb8813abf80dc8768ffb196b870d" + }, + { + "path": "plugins/alapi", + "mode": "040000", + "type": "tree", + "sha": "55ebd3fffa5b6830baf020901d7ccfdd1153064e", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/55ebd3fffa5b6830baf020901d7ccfdd1153064e" + }, + { + "path": "plugins/alapi/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "3efe41132d09b3195c32f0a31487eb8e4037cdcb", + "size": 284, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/3efe41132d09b3195c32f0a31487eb8e4037cdcb" + }, + { + "path": "plugins/alapi/_data_source.py", + "mode": "100644", + "type": "blob", + "sha": "61037ab9c96c7d8ab67f784903071426c18faa12", + "size": 919, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/61037ab9c96c7d8ab67f784903071426c18faa12" + }, + { + "path": "plugins/alapi/comments_163.py", + "mode": "100644", + "type": "blob", + "sha": "d05a5aa97fec302a5c863bbc4c09500279321c93", + "size": 1593, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/d05a5aa97fec302a5c863bbc4c09500279321c93" + }, + { + "path": "plugins/alapi/cover.py", + "mode": "100644", + "type": "blob", + "sha": "e26bf79cfd181ebe29ef07e89776d7a9b64dfa62", + "size": 1438, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/e26bf79cfd181ebe29ef07e89776d7a9b64dfa62" + }, + { + "path": "plugins/alapi/jitang.py", + "mode": "100644", + "type": "blob", + "sha": "63dc93e29ede6397f3cb445c28deac71e9671fac", + "size": 1411, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/63dc93e29ede6397f3cb445c28deac71e9671fac" + }, + { + "path": "plugins/alapi/poetry.py", + "mode": "100644", + "type": "blob", + "sha": "4d35949805e5c4b271fe077ba7df293ac2436b75", + "size": 1530, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/4d35949805e5c4b271fe077ba7df293ac2436b75" + }, + { + "path": "plugins/bilibili_sub", + "mode": "040000", + "type": "tree", + "sha": "236b6a8ce846884fb7dbb10aab58f95782844c27", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/236b6a8ce846884fb7dbb10aab58f95782844c27" + }, + { + "path": "plugins/bilibili_sub/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "2eec6bd0c8208e4026b0fe1400838c161ac826c4", + "size": 14302, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/2eec6bd0c8208e4026b0fe1400838c161ac826c4" + }, + { + "path": "plugins/black_word", + "mode": "040000", + "type": "tree", + "sha": "8e2b6575f72316ae549999d32c82781dff8dbfbb", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/8e2b6575f72316ae549999d32c82781dff8dbfbb" + }, + { + "path": "plugins/black_word/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "3b45b804e6504536bb50589c777bb429be61d487", + "size": 465, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/3b45b804e6504536bb50589c777bb429be61d487" + }, + { + "path": "plugins/black_word/black_watch.py", + "mode": "100644", + "type": "blob", + "sha": "133352a648f859c4ad9e95afe803744dd49cd016", + "size": 2116, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/133352a648f859c4ad9e95afe803744dd49cd016" + }, + { + "path": "plugins/black_word/black_word.py", + "mode": "100644", + "type": "blob", + "sha": "8164851f0cf4f9075fa6cea9525d44847739b7bc", + "size": 7451, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/8164851f0cf4f9075fa6cea9525d44847739b7bc" + }, + { + "path": "plugins/black_word/data_source.py", + "mode": "100644", + "type": "blob", + "sha": "e985facc2bd751061cb7e28de7b0160d7aa7ac26", + "size": 2818, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/e985facc2bd751061cb7e28de7b0160d7aa7ac26" + }, + { + "path": "plugins/black_word/model.py", + "mode": "100644", + "type": "blob", + "sha": "ef81c0ba751390cf9d500dfcb4c1a19a1c4944fe", + "size": 4754, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/ef81c0ba751390cf9d500dfcb4c1a19a1c4944fe" + }, + { + "path": "plugins/black_word/utils.py", + "mode": "100644", + "type": "blob", + "sha": "53526bd0d12466c9694835d28dcc3ec7c95bf713", + "size": 12896, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/53526bd0d12466c9694835d28dcc3ec7c95bf713" + }, + { + "path": "plugins/bt", + "mode": "040000", + "type": "tree", + "sha": "662f698152b52f405f4e4eae2ae3ae829df6d84e", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/662f698152b52f405f4e4eae2ae3ae829df6d84e" + }, + { + "path": "plugins/bt/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "3aff4f8b1e77c80324240d0c1d602fb5cf594c90", + "size": 2390, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/3aff4f8b1e77c80324240d0c1d602fb5cf594c90" + }, + { + "path": "plugins/bt/data_source.py", + "mode": "100644", + "type": "blob", + "sha": "ad02a5d5961ada54e018f56c90f6842a598b912a", + "size": 1786, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/ad02a5d5961ada54e018f56c90f6842a598b912a" + }, + { + "path": "plugins/check", + "mode": "040000", + "type": "tree", + "sha": "a334ab1bd11e8205294b054939bb08a3612ff627", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/a334ab1bd11e8205294b054939bb08a3612ff627" + }, + { + "path": "plugins/check/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "ee63b50dbee98d0bbfcad936fc8f2f688899b464", + "size": 1112, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/ee63b50dbee98d0bbfcad936fc8f2f688899b464" + }, + { + "path": "plugins/check/data_source.py", + "mode": "100644", + "type": "blob", + "sha": "78fbe7bae7c06cd2cd91bbc8cd47cfb83ec40833", + "size": 2612, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/78fbe7bae7c06cd2cd91bbc8cd47cfb83ec40833" + }, + { + "path": "plugins/coser.py", + "mode": "100644", + "type": "blob", + "sha": "90062d517b634456dd62bfdd9bab6fc5f65e14a7", + "size": 2803, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/90062d517b634456dd62bfdd9bab6fc5f65e14a7" + }, + { + "path": "plugins/dialogue", + "mode": "040000", + "type": "tree", + "sha": "612a62e9b02aa50821fb200d8c537720b5f05c2e", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/612a62e9b02aa50821fb200d8c537720b5f05c2e" + }, + { + "path": "plugins/dialogue/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "a99bc055851cb7b3c7f31fdb96d996d00ad0210a", + "size": 6551, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/a99bc055851cb7b3c7f31fdb96d996d00ad0210a" + }, + { + "path": "plugins/dialogue/_data_source.py", + "mode": "100644", + "type": "blob", + "sha": "440c8176ffed65e190afc6f51e363a586743d5af", + "size": 1098, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/440c8176ffed65e190afc6f51e363a586743d5af" + }, + { + "path": "plugins/draw_card", + "mode": "040000", + "type": "tree", + "sha": "e2d536d70d4f6290a0a4574c381715f5d581ee43", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/e2d536d70d4f6290a0a4574c381715f5d581ee43" + }, + { + "path": "plugins/draw_card/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "2aeeb30eb145b75fb6a0db9858e9be660e783849", + "size": 9788, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/2aeeb30eb145b75fb6a0db9858e9be660e783849" + }, + { + "path": "plugins/draw_card/config.py", + "mode": "100644", + "type": "blob", + "sha": "0aff3ef8b44f4489ec6a5e2caac2216e41e675c5", + "size": 5303, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/0aff3ef8b44f4489ec6a5e2caac2216e41e675c5" + }, + { + "path": "plugins/draw_card/count_manager.py", + "mode": "100644", + "type": "blob", + "sha": "7768b057c2ea57f272d792b8de59ca922f919e84", + "size": 4303, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/7768b057c2ea57f272d792b8de59ca922f919e84" + }, + { + "path": "plugins/draw_card/handles", + "mode": "040000", + "type": "tree", + "sha": "4ac777c06c5e9d6f238da1060c5afb52c3d7a330", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/4ac777c06c5e9d6f238da1060c5afb52c3d7a330" + }, + { + "path": "plugins/draw_card/handles/azur_handle.py", + "mode": "100644", + "type": "blob", + "sha": "67242a774fe7fd30f2e3c490b687bf1492c7d689", + "size": 12045, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/67242a774fe7fd30f2e3c490b687bf1492c7d689" + }, + { + "path": "plugins/draw_card/handles/ba_handle.py", + "mode": "100644", + "type": "blob", + "sha": "d347504af4911bb5d641d439e22cae3cde55a333", + "size": 5424, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/d347504af4911bb5d641d439e22cae3cde55a333" + }, + { + "path": "plugins/draw_card/handles/base_handle.py", + "mode": "100644", + "type": "blob", + "sha": "3483d246ed23aced34bab2873d2b2d08d898c4ed", + "size": 10314, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/3483d246ed23aced34bab2873d2b2d08d898c4ed" + }, + { + "path": "plugins/draw_card/handles/fgo_handle.py", + "mode": "100644", + "type": "blob", + "sha": "5acb8c5f7480ffffa013d7131f27d6f146250f23", + "size": 8350, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/5acb8c5f7480ffffa013d7131f27d6f146250f23" + }, + { + "path": "plugins/draw_card/handles/genshin_handle.py", + "mode": "100644", + "type": "blob", + "sha": "61edcf30f6657fc65675fab39dfe33d200ef6d58", + "size": 18911, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/61edcf30f6657fc65675fab39dfe33d200ef6d58" + }, + { + "path": "plugins/draw_card/handles/guardian_handle.py", + "mode": "100644", + "type": "blob", + "sha": "517f126d91c2f23e2f6dbbb8fd8c9e493c9ad76d", + "size": 15892, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/517f126d91c2f23e2f6dbbb8fd8c9e493c9ad76d" + }, + { + "path": "plugins/draw_card/handles/onmyoji_handle.py", + "mode": "100644", + "type": "blob", + "sha": "25d05c3889d364edd4ed469cba04f41dd3804422", + "size": 6342, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/25d05c3889d364edd4ed469cba04f41dd3804422" + }, + { + "path": "plugins/draw_card/handles/pcr_handle.py", + "mode": "100644", + "type": "blob", + "sha": "666a684267ff5c99ae0c4c97a6f51e4e96994824", + "size": 5474, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/666a684267ff5c99ae0c4c97a6f51e4e96994824" + }, + { + "path": "plugins/draw_card/handles/pretty_handle.py", + "mode": "100644", + "type": "blob", + "sha": "535e2b19dc34ec9d700e42463b41226b3d064b7a", + "size": 16883, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/535e2b19dc34ec9d700e42463b41226b3d064b7a" + }, + { + "path": "plugins/draw_card/handles/prts_handle.py", + "mode": "100644", + "type": "blob", + "sha": "18a86fc3aae1b7fade1fba0207a6b3bc8eab8314", + "size": 14831, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/18a86fc3aae1b7fade1fba0207a6b3bc8eab8314" + }, + { + "path": "plugins/draw_card/rule.py", + "mode": "100644", + "type": "blob", + "sha": "49746d95b6a26ac507717cddf1559b552dcbcbe2", + "size": 233, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/49746d95b6a26ac507717cddf1559b552dcbcbe2" + }, + { + "path": "plugins/draw_card/util.py", + "mode": "100644", + "type": "blob", + "sha": "d0cefc91cb0eb3cb85e89f11434d4507ef0c6d89", + "size": 1736, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/d0cefc91cb0eb3cb85e89f11434d4507ef0c6d89" + }, + { + "path": "plugins/epic", + "mode": "040000", + "type": "tree", + "sha": "a19b3af1c3af1f91f66eb9180678ecb39f5a2046", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/a19b3af1c3af1f91f66eb9180678ecb39f5a2046" + }, + { + "path": "plugins/epic/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "23c084aad72b9a550afb8d0a5235b1af43678235", + "size": 1442, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/23c084aad72b9a550afb8d0a5235b1af43678235" + }, + { + "path": "plugins/epic/data_source.py", + "mode": "100644", + "type": "blob", + "sha": "583221fe8fae749209fd621be12cb9e548798ad1", + "size": 9035, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/583221fe8fae749209fd621be12cb9e548798ad1" + }, + { + "path": "plugins/fudu.py", + "mode": "100644", + "type": "blob", + "sha": "4b08ae12ce0c6b0a40f4e98505f114a8ec187a0a", + "size": 4779, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/4b08ae12ce0c6b0a40f4e98505f114a8ec187a0a" + }, + { + "path": "plugins/gold_redbag", + "mode": "040000", + "type": "tree", + "sha": "b70f26667d85b8a3c5d9c604092864a698ab09c0", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/b70f26667d85b8a3c5d9c604092864a698ab09c0" + }, + { + "path": "plugins/gold_redbag/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "7e6a1c28536e05a8b90740df579125c1eeaabcb4", + "size": 12756, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/7e6a1c28536e05a8b90740df579125c1eeaabcb4" + }, + { + "path": "plugins/gold_redbag/config.py", + "mode": "100644", + "type": "blob", + "sha": "da8c0d396f330478b1ac97e80b1ff9037466f119", + "size": 12223, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/da8c0d396f330478b1ac97e80b1ff9037466f119" + }, + { + "path": "plugins/gold_redbag/data_source.py", + "mode": "100644", + "type": "blob", + "sha": "ecc7dd3808bc78ec581cfb3d96bbb662821b9a0f", + "size": 8240, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/ecc7dd3808bc78ec581cfb3d96bbb662821b9a0f" + }, + { + "path": "plugins/gold_redbag/model.py", + "mode": "100644", + "type": "blob", + "sha": "a8e9359a4a3233fb4b1f3731965108a14bcae6cd", + "size": 1995, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/a8e9359a4a3233fb4b1f3731965108a14bcae6cd" + }, + { + "path": "plugins/group_welcome_msg.py", + "mode": "100644", + "type": "blob", + "sha": "7148e8e9bfe6900911af1a3a8ac730365f7042bf", + "size": 1950, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/7148e8e9bfe6900911af1a3a8ac730365f7042bf" + }, + { + "path": "plugins/image_management", + "mode": "040000", + "type": "tree", + "sha": "cc09c37a1e4e5b6b49b931b79ab4577c279e141b", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/cc09c37a1e4e5b6b49b931b79ab4577c279e141b" + }, + { + "path": "plugins/image_management/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "f6fdde85f99dad6ab35a82e198b883d6ec862b37", + "size": 1968, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/f6fdde85f99dad6ab35a82e198b883d6ec862b37" + }, + { + "path": "plugins/image_management/_config.py", + "mode": "100644", + "type": "blob", + "sha": "d5e01f587db050fc9c7d1aac91adcdcea55fd200", + "size": 215, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/d5e01f587db050fc9c7d1aac91adcdcea55fd200" + }, + { + "path": "plugins/image_management/_data_source.py", + "mode": "100644", + "type": "blob", + "sha": "bc26b74f7133dc428cf03bee4a391bc1600bb560", + "size": 6322, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/bc26b74f7133dc428cf03bee4a391bc1600bb560" + }, + { + "path": "plugins/image_management/delete_image.py", + "mode": "100644", + "type": "blob", + "sha": "6cabb8e9df848690a4b81e79ffaf7754d0388b96", + "size": 3506, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/6cabb8e9df848690a4b81e79ffaf7754d0388b96" + }, + { + "path": "plugins/image_management/image_management_log.py", + "mode": "100644", + "type": "blob", + "sha": "756e58f25053ed4adeb2402e53eed69f10f54fce", + "size": 895, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/756e58f25053ed4adeb2402e53eed69f10f54fce" + }, + { + "path": "plugins/image_management/move_image.py", + "mode": "100644", + "type": "blob", + "sha": "6c1ec5e95fad95e4d728185345bde3873b529b42", + "size": 4697, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/6c1ec5e95fad95e4d728185345bde3873b529b42" + }, + { + "path": "plugins/image_management/send_image.py", + "mode": "100644", + "type": "blob", + "sha": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", + "size": 0, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" + }, + { + "path": "plugins/image_management/upload_image.py", + "mode": "100644", + "type": "blob", + "sha": "b69281a4f9eb781dd81324f5e6401c78ea211e81", + "size": 6690, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/b69281a4f9eb781dd81324f5e6401c78ea211e81" + }, + { + "path": "plugins/luxun.py", + "mode": "100644", + "type": "blob", + "sha": "1c1ab09c1353560e6dd4f092e45016076a34f648", + "size": 2254, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/1c1ab09c1353560e6dd4f092e45016076a34f648" + }, + { + "path": "plugins/mute", + "mode": "040000", + "type": "tree", + "sha": "04f58a6a69988418d0dbff1d8acbe2601fe51875", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/04f58a6a69988418d0dbff1d8acbe2601fe51875" + }, + { + "path": "plugins/mute/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "a11d4d39357fcf680d2c17cac8c49a2464a1b08b", + "size": 468, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/a11d4d39357fcf680d2c17cac8c49a2464a1b08b" + }, + { + "path": "plugins/mute/_data_source.py", + "mode": "100644", + "type": "blob", + "sha": "13c1cadf965c8d5c246480ac96c15e9e4187140d", + "size": 3891, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/13c1cadf965c8d5c246480ac96c15e9e4187140d" + }, + { + "path": "plugins/mute/mute_message.py", + "mode": "100644", + "type": "blob", + "sha": "e6946cd8440ea843fadbce348e3bb8064de3c46b", + "size": 2320, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/e6946cd8440ea843fadbce348e3bb8064de3c46b" + }, + { + "path": "plugins/mute/mute_setting.py", + "mode": "100644", + "type": "blob", + "sha": "b7947ed69035869b887ab1e90afb0c1732a00dd9", + "size": 4043, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/b7947ed69035869b887ab1e90afb0c1732a00dd9" + }, + { + "path": "plugins/nbnhhsh.py", + "mode": "100644", + "type": "blob", + "sha": "7ab78aaaddf9b75516520aac0f74f7014df63118", + "size": 1918, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/7ab78aaaddf9b75516520aac0f74f7014df63118" + }, + { + "path": "plugins/one_friend", + "mode": "040000", + "type": "tree", + "sha": "95c53cea06ec2bf1126704b5a9bd573838a27a9f", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/95c53cea06ec2bf1126704b5a9bd573838a27a9f" + }, + { + "path": "plugins/one_friend/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "7fa7c8bfd373f23fd515dee758c72244c6a2a307", + "size": 2952, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/7fa7c8bfd373f23fd515dee758c72244c6a2a307" + }, + { + "path": "plugins/open_cases", + "mode": "040000", + "type": "tree", + "sha": "d85300068342d8725fa60a5bd3fbd1918124be73", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/d85300068342d8725fa60a5bd3fbd1918124be73" + }, + { + "path": "plugins/open_cases/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "2798942e805c62437ed5f424c6fe509fd56a3db1", + "size": 11529, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/2798942e805c62437ed5f424c6fe509fd56a3db1" + }, + { + "path": "plugins/open_cases/build_image.py", + "mode": "100644", + "type": "blob", + "sha": "8b8db8e2e4518c2689a8870ce3454fc015370ed4", + "size": 6798, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/8b8db8e2e4518c2689a8870ce3454fc015370ed4" + }, + { + "path": "plugins/open_cases/command.py", + "mode": "100644", + "type": "blob", + "sha": "ea86c2fc65b4f04ede6200fda8a8f140845190de", + "size": 1913, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/ea86c2fc65b4f04ede6200fda8a8f140845190de" + }, + { + "path": "plugins/open_cases/config.py", + "mode": "100644", + "type": "blob", + "sha": "cefa7384d4dd783b6d6221238b072c4609d56e8b", + "size": 7343, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/cefa7384d4dd783b6d6221238b072c4609d56e8b" + }, + { + "path": "plugins/open_cases/models", + "mode": "040000", + "type": "tree", + "sha": "803a0682b01de9c453383a12ab24237e9657203d", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/803a0682b01de9c453383a12ab24237e9657203d" + }, + { + "path": "plugins/open_cases/models/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", + "size": 0, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" + }, + { + "path": "plugins/open_cases/models/buff_prices.py", + "mode": "100644", + "type": "blob", + "sha": "9f53de0e666453847d658a5a409696592292ef44", + "size": 536, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/9f53de0e666453847d658a5a409696592292ef44" + }, + { + "path": "plugins/open_cases/models/buff_skin.py", + "mode": "100644", + "type": "blob", + "sha": "7f51221a40d9d88344a31b814c68a46bbdc21e1c", + "size": 4211, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/7f51221a40d9d88344a31b814c68a46bbdc21e1c" + }, + { + "path": "plugins/open_cases/models/buff_skin_log.py", + "mode": "100644", + "type": "blob", + "sha": "ac9fec952ca30d4cb2691820936c9c35b662debc", + "size": 1531, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/ac9fec952ca30d4cb2691820936c9c35b662debc" + }, + { + "path": "plugins/open_cases/models/open_cases_log.py", + "mode": "100644", + "type": "blob", + "sha": "0c4f87bb6e061a19b039bc783fa8de0a72cc39f9", + "size": 1414, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/0c4f87bb6e061a19b039bc783fa8de0a72cc39f9" + }, + { + "path": "plugins/open_cases/models/open_cases_user.py", + "mode": "100644", + "type": "blob", + "sha": "3ed439372782ce4106c9b846ac29cda5c79d8e7c", + "size": 2160, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/3ed439372782ce4106c9b846ac29cda5c79d8e7c" + }, + { + "path": "plugins/open_cases/open_cases_c.py", + "mode": "100644", + "type": "blob", + "sha": "f56aa9fef6d1c2ec098017a065c999639add45b2", + "size": 17693, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/f56aa9fef6d1c2ec098017a065c999639add45b2" + }, + { + "path": "plugins/open_cases/utils.py", + "mode": "100644", + "type": "blob", + "sha": "6fda2265c909c5ac5e9bc8f94bcb9651fa92fd11", + "size": 24033, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/6fda2265c909c5ac5e9bc8f94bcb9651fa92fd11" + }, + { + "path": "plugins/parse_bilibili", + "mode": "040000", + "type": "tree", + "sha": "f5cfedda6a64b13e02c16eff90ed61c23be168c7", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/f5cfedda6a64b13e02c16eff90ed61c23be168c7" + }, + { + "path": "plugins/parse_bilibili/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "dc43ec611c931c41176126a0a59e7d87791adab8", + "size": 7975, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/dc43ec611c931c41176126a0a59e7d87791adab8" + }, + { + "path": "plugins/parse_bilibili/get_image.py", + "mode": "100644", + "type": "blob", + "sha": "3b8c70c86aee56f7c33e070d4b7f8c7a62f2beda", + "size": 3958, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/3b8c70c86aee56f7c33e070d4b7f8c7a62f2beda" + }, + { + "path": "plugins/parse_bilibili/information_container.py", + "mode": "100644", + "type": "blob", + "sha": "ddb685f8bcd9b5becd06dca6a4f07b7d63f40167", + "size": 1221, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/ddb685f8bcd9b5becd06dca6a4f07b7d63f40167" + }, + { + "path": "plugins/parse_bilibili/parse_url.py", + "mode": "100644", + "type": "blob", + "sha": "b4e2a1fe4a63a79270be39d29462c11b11295111", + "size": 2249, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/b4e2a1fe4a63a79270be39d29462c11b11295111" + }, + { + "path": "plugins/pid_search.py", + "mode": "100644", + "type": "blob", + "sha": "97fc4d40f18877f7896471001d6f3b4a2cec2300", + "size": 4661, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/97fc4d40f18877f7896471001d6f3b4a2cec2300" + }, + { + "path": "plugins/pix_gallery", + "mode": "040000", + "type": "tree", + "sha": "aaa0152da0e592c4ef2e2971f5745754a71bb5c8", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/aaa0152da0e592c4ef2e2971f5745754a71bb5c8" + }, + { + "path": "plugins/pix_gallery/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "7dd5fa99506d5948d58301d3648ea94f974889e1", + "size": 2129, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/7dd5fa99506d5948d58301d3648ea94f974889e1" + }, + { + "path": "plugins/pix_gallery/_data_source.py", + "mode": "100644", + "type": "blob", + "sha": "7e9db22194708c254bb8378f5d081ded231bd65f", + "size": 14012, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/7e9db22194708c254bb8378f5d081ded231bd65f" + }, + { + "path": "plugins/pix_gallery/_model", + "mode": "040000", + "type": "tree", + "sha": "a4736e1bf36097384def6dae4bf4688567c168d7", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/a4736e1bf36097384def6dae4bf4688567c168d7" + }, + { + "path": "plugins/pix_gallery/_model/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "8b137891791fe96927ad78e64b0aad7bded08bdc", + "size": 1, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/8b137891791fe96927ad78e64b0aad7bded08bdc" + }, + { + "path": "plugins/pix_gallery/_model/omega_pixiv_illusts.py", + "mode": "100644", + "type": "blob", + "sha": "17e2156c4dda0b6d883b690a65449348cc91ad47", + "size": 2655, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/17e2156c4dda0b6d883b690a65449348cc91ad47" + }, + { + "path": "plugins/pix_gallery/_model/pixiv.py", + "mode": "100644", + "type": "blob", + "sha": "3451781df41ed3de925be513c2600c96c3c19e14", + "size": 2608, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/3451781df41ed3de925be513c2600c96c3c19e14" + }, + { + "path": "plugins/pix_gallery/_model/pixiv_keyword_user.py", + "mode": "100644", + "type": "blob", + "sha": "5de544a5d5360dc33ec236b89e2f23f7b28775e4", + "size": 1717, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/5de544a5d5360dc33ec236b89e2f23f7b28775e4" + }, + { + "path": "plugins/pix_gallery/pix.py", + "mode": "100644", + "type": "blob", + "sha": "2f8d25c386012dcb2d56fc109e1df8d6f8b75e3a", + "size": 8915, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/2f8d25c386012dcb2d56fc109e1df8d6f8b75e3a" + }, + { + "path": "plugins/pix_gallery/pix_add_keyword.py", + "mode": "100644", + "type": "blob", + "sha": "452213e3bcc501f31b1454a061a6048f39921f2b", + "size": 4722, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/452213e3bcc501f31b1454a061a6048f39921f2b" + }, + { + "path": "plugins/pix_gallery/pix_pass_del_keyword.py", + "mode": "100644", + "type": "blob", + "sha": "9a8f2ea774446880612edc21d72c1502439f66ee", + "size": 7558, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/9a8f2ea774446880612edc21d72c1502439f66ee" + }, + { + "path": "plugins/pix_gallery/pix_show_info.py", + "mode": "100644", + "type": "blob", + "sha": "cb1cbf2a9efc5ff23d0f798124b7faf0a6125943", + "size": 3172, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/cb1cbf2a9efc5ff23d0f798124b7faf0a6125943" + }, + { + "path": "plugins/pix_gallery/pix_update.py", + "mode": "100644", + "type": "blob", + "sha": "b0f209dc016d610a5fb51c3fbbd6a47c251b9e3c", + "size": 8134, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/b0f209dc016d610a5fb51c3fbbd6a47c251b9e3c" + }, + { + "path": "plugins/pixiv_rank_search", + "mode": "040000", + "type": "tree", + "sha": "17665e6505c0255a9d4d4c1371641127b9a83eb9", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/17665e6505c0255a9d4d4c1371641127b9a83eb9" + }, + { + "path": "plugins/pixiv_rank_search/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "01945cd85acb629a20c08a9ac75030aa0865bba5", + "size": 7377, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/01945cd85acb629a20c08a9ac75030aa0865bba5" + }, + { + "path": "plugins/pixiv_rank_search/data_source.py", + "mode": "100644", + "type": "blob", + "sha": "761a93f20eba886122c134c7e6d2df884a0d9dc7", + "size": 5585, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/761a93f20eba886122c134c7e6d2df884a0d9dc7" + }, + { + "path": "plugins/poke", + "mode": "040000", + "type": "tree", + "sha": "88decde53947fc3cae4d95b7ec9105e4c8bb37f8", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/88decde53947fc3cae4d95b7ec9105e4c8bb37f8" + }, + { + "path": "plugins/poke/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "38dafc82466013c5f3fa00d3609c55f000afd2cf", + "size": 3319, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/38dafc82466013c5f3fa00d3609c55f000afd2cf" + }, + { + "path": "plugins/quotations.py", + "mode": "100644", + "type": "blob", + "sha": "e213ee04d6196c5b34892c7699851e7477cafcb1", + "size": 1112, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/e213ee04d6196c5b34892c7699851e7477cafcb1" + }, + { + "path": "plugins/roll.py", + "mode": "100644", + "type": "blob", + "sha": "1c21421b648123a3bbe29c093c63bddf9523cdb6", + "size": 2217, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/1c21421b648123a3bbe29c093c63bddf9523cdb6" + }, + { + "path": "plugins/russian", + "mode": "040000", + "type": "tree", + "sha": "a284de1ceeaa4c483a5e61e328d2722f15eb37bf", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/a284de1ceeaa4c483a5e61e328d2722f15eb37bf" + }, + { + "path": "plugins/russian/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "d91323c73ca65d6380d4556d542a2399c9da4728", + "size": 7545, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/d91323c73ca65d6380d4556d542a2399c9da4728" + }, + { + "path": "plugins/russian/command.py", + "mode": "100644", + "type": "blob", + "sha": "de9d186cbad0316cac75e39d68526aa1605bf4b9", + "size": 2259, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/de9d186cbad0316cac75e39d68526aa1605bf4b9" + }, + { + "path": "plugins/russian/data_source.py", + "mode": "100644", + "type": "blob", + "sha": "73cdb078bd700ecf5744eb73c1cdee5ce0839192", + "size": 20332, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/73cdb078bd700ecf5744eb73c1cdee5ce0839192" + }, + { + "path": "plugins/russian/model.py", + "mode": "100644", + "type": "blob", + "sha": "0fab9298c9ad713dac6292da0bec68c96ce883cc", + "size": 3633, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/0fab9298c9ad713dac6292da0bec68c96ce883cc" + }, + { + "path": "plugins/search_anime", + "mode": "040000", + "type": "tree", + "sha": "b65e7639bc2dc2e4b5383333af10034161b10388", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/b65e7639bc2dc2e4b5383333af10034161b10388" + }, + { + "path": "plugins/search_anime/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "d12ad03ecf39f9897aace7235ed2cf918f1e4540", + "size": 2291, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/d12ad03ecf39f9897aace7235ed2cf918f1e4540" + }, + { + "path": "plugins/search_anime/data_source.py", + "mode": "100644", + "type": "blob", + "sha": "59d0ac6159e429f1a31450559ab072d8ef8e483f", + "size": 1810, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/59d0ac6159e429f1a31450559ab072d8ef8e483f" + }, + { + "path": "plugins/search_buff_skin_price", + "mode": "040000", + "type": "tree", + "sha": "36b3f5e8a80056bc3e334f72640fec1c0af39418", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/36b3f5e8a80056bc3e334f72640fec1c0af39418" + }, + { + "path": "plugins/search_buff_skin_price/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "ca2a320fa66a8cb7a7ca9b91a1a061d3fa459a09", + "size": 3387, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/ca2a320fa66a8cb7a7ca9b91a1a061d3fa459a09" + }, + { + "path": "plugins/search_buff_skin_price/data_source.py", + "mode": "100644", + "type": "blob", + "sha": "8dbe6a596286e8db8b9e026a1c81cf076c4b5448", + "size": 2188, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/8dbe6a596286e8db8b9e026a1c81cf076c4b5448" + }, + { + "path": "plugins/search_image", + "mode": "040000", + "type": "tree", + "sha": "53a699804e22730f01ae09b8cc8a1ebe1398c28d", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/53a699804e22730f01ae09b8cc8a1ebe1398c28d" + }, + { + "path": "plugins/search_image/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "38e86de0caafe6c3e88d973fb5c4bc9d1430d213", + "size": 3010, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/38e86de0caafe6c3e88d973fb5c4bc9d1430d213" + }, + { + "path": "plugins/send_setu_", + "mode": "040000", + "type": "tree", + "sha": "113028d9c4100b30b2996c39c0324d05b242464f", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/113028d9c4100b30b2996c39c0324d05b242464f" + }, + { + "path": "plugins/send_setu_/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "eb35e275dae8dab8d2bc4145146ee3f1fe5c1b78", + "size": 101, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/eb35e275dae8dab8d2bc4145146ee3f1fe5c1b78" + }, + { + "path": "plugins/send_setu_/_model.py", + "mode": "100644", + "type": "blob", + "sha": "865af7d13c368cfaf8a90e0747652ad912519810", + "size": 2580, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/865af7d13c368cfaf8a90e0747652ad912519810" + }, + { + "path": "plugins/send_setu_/send_setu", + "mode": "040000", + "type": "tree", + "sha": "e68acc01dbf796c0f1eee93e4d9992dfc09cbe36", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/e68acc01dbf796c0f1eee93e4d9992dfc09cbe36" + }, + { + "path": "plugins/send_setu_/send_setu/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "fea1ad41ef615c6d1cabd06a49c412075560cf4d", + "size": 8491, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/fea1ad41ef615c6d1cabd06a49c412075560cf4d" + }, + { + "path": "plugins/send_setu_/send_setu/_data_source.py", + "mode": "100644", + "type": "blob", + "sha": "a860aa0629678e7f774b7ef74951458c328ee81e", + "size": 12839, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/a860aa0629678e7f774b7ef74951458c328ee81e" + }, + { + "path": "plugins/send_setu_/update_setu", + "mode": "040000", + "type": "tree", + "sha": "ddcc0dfe51ba6a7a0c34c6acbbedb175c2cf8c6c", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/ddcc0dfe51ba6a7a0c34c6acbbedb175c2cf8c6c" + }, + { + "path": "plugins/send_setu_/update_setu/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "2b5b6ae93d952646fdbf271b64212018d6da7ce6", + "size": 1854, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/2b5b6ae93d952646fdbf271b64212018d6da7ce6" + }, + { + "path": "plugins/send_setu_/update_setu/data_source.py", + "mode": "100644", + "type": "blob", + "sha": "07d217d6ed53e7e9ad7ba69c560bb1196d74d5f3", + "size": 7551, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/07d217d6ed53e7e9ad7ba69c560bb1196d74d5f3" + }, + { + "path": "plugins/send_voice", + "mode": "040000", + "type": "tree", + "sha": "9ebdbf817f193e02f99bfd40206cad3603749168", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/9ebdbf817f193e02f99bfd40206cad3603749168" + }, + { + "path": "plugins/send_voice/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "eb35e275dae8dab8d2bc4145146ee3f1fe5c1b78", + "size": 101, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/eb35e275dae8dab8d2bc4145146ee3f1fe5c1b78" + }, + { + "path": "plugins/send_voice/dinggong.py", + "mode": "100644", + "type": "blob", + "sha": "a01129ca1d0a6081df09e4411a7babc6f3c44a1a", + "size": 1595, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/a01129ca1d0a6081df09e4411a7babc6f3c44a1a" + }, + { + "path": "plugins/translate", + "mode": "040000", + "type": "tree", + "sha": "44d79a31ab9a00d2ea01801968bff5192bc81976", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/44d79a31ab9a00d2ea01801968bff5192bc81976" + }, + { + "path": "plugins/translate/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "372a57740d2341e9aa460e74d09548ebf317ae09", + "size": 3067, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/372a57740d2341e9aa460e74d09548ebf317ae09" + }, + { + "path": "plugins/translate/data_source.py", + "mode": "100644", + "type": "blob", + "sha": "a7a3018d0d75379b6a5d74d5b8d808f1b2b39350", + "size": 2049, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/a7a3018d0d75379b6a5d74d5b8d808f1b2b39350" + }, + { + "path": "plugins/wbtop", + "mode": "040000", + "type": "tree", + "sha": "d87a6fab1133ca86eeade93cdd149a279385fcab", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/d87a6fab1133ca86eeade93cdd149a279385fcab" + }, + { + "path": "plugins/wbtop/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "fce1b99540d3b81903f356b9157b97fbe166c287", + "size": 1906, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/fce1b99540d3b81903f356b9157b97fbe166c287" + }, + { + "path": "plugins/wbtop/data_source.py", + "mode": "100644", + "type": "blob", + "sha": "e9c206273fd1f46ceb6d2b1e276d80f0e97ad145", + "size": 2203, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/e9c206273fd1f46ceb6d2b1e276d80f0e97ad145" + }, + { + "path": "plugins/what_anime", + "mode": "040000", + "type": "tree", + "sha": "a43f876cccb0e60c5360b5382ce9f6311d924c3c", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/a43f876cccb0e60c5360b5382ce9f6311d924c3c" + }, + { + "path": "plugins/what_anime/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "d3b918760566cebae8685022923ee9cfdafa4d91", + "size": 1825, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/d3b918760566cebae8685022923ee9cfdafa4d91" + }, + { + "path": "plugins/what_anime/data_source.py", + "mode": "100644", + "type": "blob", + "sha": "15801f624f6298e33b6f5423c68d1d7110be4a6f", + "size": 1883, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/15801f624f6298e33b6f5423c68d1d7110be4a6f" + }, + { + "path": "plugins/word_bank", + "mode": "040000", + "type": "tree", + "sha": "9437ca1b8da4360fb24aac46c037f98fdcc768ef", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/9437ca1b8da4360fb24aac46c037f98fdcc768ef" + }, + { + "path": "plugins/word_bank/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "519a269a7aa377a0be8755142e9223aeff96d5fd", + "size": 724, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/519a269a7aa377a0be8755142e9223aeff96d5fd" + }, + { + "path": "plugins/word_bank/_command.py", + "mode": "100644", + "type": "blob", + "sha": "7dae391a25b418530c539f16c61baa18f415d7ae", + "size": 1122, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/7dae391a25b418530c539f16c61baa18f415d7ae" + }, + { + "path": "plugins/word_bank/_config.py", + "mode": "100644", + "type": "blob", + "sha": "72c4c584f01e158e4d9f6b250ccf8078abf8abae", + "size": 763, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/72c4c584f01e158e4d9f6b250ccf8078abf8abae" + }, + { + "path": "plugins/word_bank/_data_source.py", + "mode": "100644", + "type": "blob", + "sha": "03bc28709c2c38408d130c218775f7f82ae02458", + "size": 9712, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/03bc28709c2c38408d130c218775f7f82ae02458" + }, + { + "path": "plugins/word_bank/_model.py", + "mode": "100644", + "type": "blob", + "sha": "eb00610d6863cc02dbf0390753213e2cd4fe3816", + "size": 20494, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/eb00610d6863cc02dbf0390753213e2cd4fe3816" + }, + { + "path": "plugins/word_bank/_rule.py", + "mode": "100644", + "type": "blob", + "sha": "f64daa75fe7b200c65af02b98f6198f5d1f492ba", + "size": 1979, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/f64daa75fe7b200c65af02b98f6198f5d1f492ba" + }, + { + "path": "plugins/word_bank/message_handle.py", + "mode": "100644", + "type": "blob", + "sha": "07a4b518f18a3c58076eb2e307d56ecb6e829722", + "size": 959, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/07a4b518f18a3c58076eb2e307d56ecb6e829722" + }, + { + "path": "plugins/word_bank/read_word_bank.py", + "mode": "100644", + "type": "blob", + "sha": "bc655e5d298c49a57079e49a5441b2ab4a4d1448", + "size": 2863, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/bc655e5d298c49a57079e49a5441b2ab4a4d1448" + }, + { + "path": "plugins/word_bank/word_handle.py", + "mode": "100644", + "type": "blob", + "sha": "4917229f3a71138b43b9d27a49144f0afcb404e3", + "size": 11914, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/4917229f3a71138b43b9d27a49144f0afcb404e3" + }, + { + "path": "plugins/word_clouds", + "mode": "040000", + "type": "tree", + "sha": "26ae75043fdc51b1e570d3db2df617e1f0e8540e", + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/26ae75043fdc51b1e570d3db2df617e1f0e8540e" + }, + { + "path": "plugins/word_clouds/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "c9873adef7c988847bb23e95d0b0361eb336ef1c", + "size": 6787, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/c9873adef7c988847bb23e95d0b0361eb336ef1c" + }, + { + "path": "plugins/word_clouds/command.py", + "mode": "100644", + "type": "blob", + "sha": "3b03044fd1027cc52849e82ac0f5585f6268b184", + "size": 933, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/3b03044fd1027cc52849e82ac0f5585f6268b184" + }, + { + "path": "plugins/word_clouds/data_source.py", + "mode": "100644", + "type": "blob", + "sha": "fd6fda73fcece4f552c12bf639b3f10460befeab", + "size": 4089, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/fd6fda73fcece4f552c12bf639b3f10460befeab" + }, + { + "path": "plugins/word_clouds/requirement.txt", + "mode": "100644", + "type": "blob", + "sha": "8e022688417114d01ea440354dea9c60e1c50799", + "size": 30, + "url": "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/blobs/8e022688417114d01ea440354dea9c60e1c50799" + } + ], + "truncated": false +} diff --git a/tests/response/plugin_store/zhenxun_github_sub_metadata.json b/tests/response/plugin_store/zhenxun_github_sub_metadata.json new file mode 100644 index 000000000..421fd889b --- /dev/null +++ b/tests/response/plugin_store/zhenxun_github_sub_metadata.json @@ -0,0 +1,23 @@ +{ + "type": "gh", + "name": "xuanerwa/zhenxun_github_sub", + "version": "main", + "default": null, + "files": [ + { + "type": "directory", + "name": "github_sub", + "files": [ + { + "type": "file", + "name": "__init__.py", + "hash": "z1C5BBK0+atbDghbyRlF2xIDwk0HQdHM1yXQZkF7/t8=", + "size": 7551 + } + ] + } + ], + "links": { + "stats": "https://data.jsdelivr.com/v1/stats/packages/gh/xuanerwa/zhenxun_github_sub@main" + } +} diff --git a/tests/response/plugin_store/zhenxun_github_sub_tree.json b/tests/response/plugin_store/zhenxun_github_sub_tree.json new file mode 100644 index 000000000..75c38a54f --- /dev/null +++ b/tests/response/plugin_store/zhenxun_github_sub_tree.json @@ -0,0 +1,38 @@ +{ + "sha": "438298b9e88f9dafa7020e99d7c7b4c98f93aea6", + "url": "https://api.github.com/repos/xuanerwa/zhenxun_github_sub/git/trees/438298b9e88f9dafa7020e99d7c7b4c98f93aea6", + "tree": [ + { + "path": "LICENSE", + "mode": "100644", + "type": "blob", + "sha": "f288702d2fa16d3cdf0035b15a9fcbc552cd88e7", + "size": 35149, + "url": "https://api.github.com/repos/xuanerwa/zhenxun_github_sub/git/blobs/f288702d2fa16d3cdf0035b15a9fcbc552cd88e7" + }, + { + "path": "README.md", + "mode": "100644", + "type": "blob", + "sha": "e974cfc9b973d4a041f03e693ea20563a933b7ca", + "size": 955, + "url": "https://api.github.com/repos/xuanerwa/zhenxun_github_sub/git/blobs/e974cfc9b973d4a041f03e693ea20563a933b7ca" + }, + { + "path": "github_sub", + "mode": "040000", + "type": "tree", + "sha": "0f7d76bcf472e2ab0610fa542b067633d6e3ae7e", + "url": "https://api.github.com/repos/xuanerwa/zhenxun_github_sub/git/trees/0f7d76bcf472e2ab0610fa542b067633d6e3ae7e" + }, + { + "path": "github_sub/__init__.py", + "mode": "100644", + "type": "blob", + "sha": "7d17fd49fe82fa3897afcef61b2c694ed93a4ba3", + "size": 7551, + "url": "https://api.github.com/repos/xuanerwa/zhenxun_github_sub/git/blobs/7d17fd49fe82fa3897afcef61b2c694ed93a4ba3" + } + ], + "truncated": false +} diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 000000000..5e554a8e5 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,67 @@ +import json +from pathlib import Path + +from nonebot.adapters.onebot.v11.event import Sender +from nonebot.adapters.onebot.v11 import Message, MessageSegment, GroupMessageEvent + + +def get_response_json(base_path: Path, file: str) -> dict: + try: + return json.loads( + (Path(__file__).parent / "response" / base_path / file).read_text( + encoding="utf8" + ) + ) + except (FileNotFoundError, json.JSONDecodeError) as e: + raise ValueError(f"Error reading or parsing JSON file: {e}") from e + + +def get_content_bytes(base_path: Path, path: str) -> bytes: + try: + return (Path(__file__).parent / "content" / base_path / path).read_bytes() + except FileNotFoundError as e: + raise ValueError(f"Error reading file: {e}") from e + + +def _v11_group_message_event( + message: str, + self_id: int, + user_id: int, + group_id: int, + message_id: int, + to_me: bool = True, +) -> GroupMessageEvent: + return GroupMessageEvent( + time=1122, + self_id=self_id, + post_type="message", + sub_type="", + user_id=user_id, + message_id=message_id, + message=Message(message), + original_message=Message(message), + message_type="group", + raw_message=message, + font=1, + sender=Sender(user_id=user_id), + to_me=to_me, + group_id=group_id, + ) + + +def _v11_private_message_send( + message: str, + user_id: int, +): + return { + "message_type": "private", + "user_id": user_id, + "message": [ + MessageSegment( + type="text", + data={ + "text": message, + }, + ) + ], + } diff --git a/zhenxun/builtin_plugins/about.py b/zhenxun/builtin_plugins/about.py index f2bcbdb6d..520478d71 100644 --- a/zhenxun/builtin_plugins/about.py +++ b/zhenxun/builtin_plugins/about.py @@ -1,13 +1,14 @@ from pathlib import Path -from nonebot.plugin import PluginMetadata +import aiofiles from nonebot.rule import to_me -from nonebot_plugin_alconna import Alconna, Arparma, on_alconna +from nonebot.plugin import PluginMetadata from nonebot_plugin_session import EventSession +from nonebot_plugin_alconna import Alconna, Arparma, on_alconna -from zhenxun.configs.utils import PluginExtraData from zhenxun.services.log import logger from zhenxun.utils.message import MessageUtils +from zhenxun.configs.utils import PluginExtraData __plugin_meta__ = PluginMetadata( name="关于", @@ -28,8 +29,9 @@ async def _(session: EventSession, arparma: Arparma): ver_file = Path() / "__version__" version = None if ver_file.exists(): - with open(ver_file, "r", encoding="utf8") as f: - version = f.read().split(":")[-1].strip() + async with aiofiles.open(ver_file, encoding="utf8") as f: + if text := await f.read(): + version = text.split(":")[-1].strip() info = f""" 『绪山真寻Bot』 版本:{version} diff --git a/zhenxun/builtin_plugins/admin/admin_help.py b/zhenxun/builtin_plugins/admin/admin_help.py deleted file mode 100644 index e98df047f..000000000 --- a/zhenxun/builtin_plugins/admin/admin_help.py +++ /dev/null @@ -1,160 +0,0 @@ -import nonebot -from nonebot.plugin import PluginMetadata -from nonebot_plugin_alconna import Alconna, Arparma, on_alconna -from nonebot_plugin_alconna.matcher import AlconnaMatcher -from nonebot_plugin_session import EventSession - -from zhenxun.configs.path_config import IMAGE_PATH -from zhenxun.configs.utils import PluginExtraData -from zhenxun.models.plugin_info import PluginInfo -from zhenxun.models.task_info import TaskInfo -from zhenxun.services.log import logger -from zhenxun.utils.enum import PluginType -from zhenxun.utils.exception import EmptyError -from zhenxun.utils.image_utils import ( - BuildImage, - build_sort_image, - group_image, - text2image, -) -from zhenxun.utils.message import MessageUtils -from zhenxun.utils.rules import admin_check, ensure_group - -__plugin_meta__ = PluginMetadata( - name="群组管理员帮助", - description="管理员帮助列表", - usage=""" - 管理员帮助 - """.strip(), - extra=PluginExtraData( - author="HibiKier", - version="0.1", - plugin_type=PluginType.ADMIN, - admin_level=1, - ).dict(), -) - -_matcher = on_alconna( - Alconna("管理员帮助"), - rule=admin_check(1) & ensure_group, - priority=5, - block=True, -) - - -ADMIN_HELP_IMAGE = IMAGE_PATH / "ADMIN_HELP.png" -if ADMIN_HELP_IMAGE.exists(): - ADMIN_HELP_IMAGE.unlink() - - -async def build_help() -> BuildImage: - """构造管理员帮助图片 - - 异常: - EmptyError: 管理员帮助为空 - - 返回: - BuildImage: 管理员帮助图片 - """ - plugin_list = await PluginInfo.filter( - plugin_type__in=[PluginType.ADMIN, PluginType.SUPER_AND_ADMIN] - ).all() - data_list = [] - for plugin in plugin_list: - if _plugin := nonebot.get_plugin_by_module_name(plugin.module_path): - if _plugin.metadata: - data_list.append({"plugin": plugin, "metadata": _plugin.metadata}) - font = BuildImage.load_font("HYWenHei-85W.ttf", 20) - image_list = [] - for data in data_list: - plugin = data["plugin"] - metadata = data["metadata"] - try: - usage = None - description = None - if metadata.usage: - usage = await text2image( - metadata.usage, - padding=5, - color=(255, 255, 255), - font_color=(0, 0, 0), - ) - if metadata.description: - description = await text2image( - metadata.description, - padding=5, - color=(255, 255, 255), - font_color=(0, 0, 0), - ) - width = 0 - height = 100 - if usage: - width = usage.width - height += usage.height - if description and description.width > width: - width = description.width - height += description.height - font_width, font_height = BuildImage.get_text_size( - plugin.name + f"[{plugin.level}]", font - ) - if font_width > width: - width = font_width - A = BuildImage(width + 30, height + 120, "#EAEDF2") - await A.text((15, 10), plugin.name + f"[{plugin.level}]") - await A.text((15, 70), "简介:") - if not description: - description = BuildImage(A.width - 30, 30, (255, 255, 255)) - await description.circle_corner(10) - await A.paste(description, (15, 100)) - if not usage: - usage = BuildImage(A.width - 30, 30, (255, 255, 255)) - await usage.circle_corner(10) - await A.text((15, description.height + 115), "用法:") - await A.paste(usage, (15, description.height + 145)) - await A.circle_corner(10) - image_list.append(A) - except Exception as e: - logger.warning( - f"获取群管理员插件 {plugin.module}: {plugin.name} 设置失败...", - "管理员帮助", - e=e, - ) - if task_list := await TaskInfo.all(): - task_str = "\n".join([task.name for task in task_list]) - task_str = "通过 开启/关闭群被动 来控制群被动\n----------\n" + task_str - task_image = await text2image(task_str, padding=5, color=(255, 255, 255)) - await task_image.circle_corner(10) - A = BuildImage(task_image.width + 50, task_image.height + 85, "#EAEDF2") - await A.text((25, 10), "被动技能") - await A.paste(task_image, (25, 50)) - await A.circle_corner(10) - image_list.append(A) - if not image_list: - raise EmptyError() - image_group, _ = group_image(image_list) - A = await build_sort_image(image_group, color=(255, 255, 255), padding_top=160) - text = await BuildImage.build_text_image( - "群管理员帮助", - size=40, - ) - tip = await BuildImage.build_text_image( - "注: ‘*’ 代表可有多个相同参数 ‘?’ 代表可省略该参数", size=25, font_color="red" - ) - await A.paste(text, (50, 30)) - await A.paste(tip, (50, 90)) - await A.save(ADMIN_HELP_IMAGE) - return BuildImage(1, 1) - - -@_matcher.handle() -async def _( - session: EventSession, - arparma: Arparma, -): - if not ADMIN_HELP_IMAGE.exists(): - try: - await build_help() - except EmptyError: - await MessageUtils.build_message("管理员帮助为空").finish(reply_to=True) - await MessageUtils.build_message(ADMIN_HELP_IMAGE).send() - logger.info("查看管理员帮助", arparma.header_result, session=session) diff --git a/zhenxun/builtin_plugins/admin/admin_help/__init__.py b/zhenxun/builtin_plugins/admin/admin_help/__init__.py new file mode 100644 index 000000000..7a8d501a5 --- /dev/null +++ b/zhenxun/builtin_plugins/admin/admin_help/__init__.py @@ -0,0 +1,63 @@ +from nonebot.plugin import PluginMetadata +from nonebot_plugin_session import EventSession +from nonebot_plugin_alconna import Alconna, Arparma, on_alconna + +from zhenxun.services.log import logger +from zhenxun.configs.config import Config +from zhenxun.utils.enum import PluginType +from zhenxun.utils.exception import EmptyError +from zhenxun.utils.message import MessageUtils +from zhenxun.utils.rules import admin_check, ensure_group +from zhenxun.configs.utils import RegisterConfig, PluginExtraData + +from .normal_help import build_help +from .config import ADMIN_HELP_IMAGE +from .html_help import build_html_help + +__plugin_meta__ = PluginMetadata( + name="群组管理员帮助", + description="管理员帮助列表", + usage=""" + 管理员帮助 + """.strip(), + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.ADMIN, + admin_level=1, + configs=[ + RegisterConfig( + key="type", + value="zhenxun", + help="管理员帮助样式,normal, zhenxun", + default_value="zhenxun", + ) + ], + ).dict(), +) + +_matcher = on_alconna( + Alconna("管理员帮助"), + rule=admin_check(1) & ensure_group, + priority=5, + block=True, +) + + +@_matcher.handle() +async def _( + session: EventSession, + arparma: Arparma, +): + if not ADMIN_HELP_IMAGE.exists(): + try: + if Config.get_config("admin_help", "type") == "zhenxun": + await build_html_help() + else: + await build_help() + except EmptyError: + await MessageUtils.build_message("当前管理员帮助为空...").finish( + reply_to=True + ) + await MessageUtils.build_message(ADMIN_HELP_IMAGE).send() + logger.info("查看管理员帮助", arparma.header_result, session=session) diff --git a/zhenxun/builtin_plugins/admin/admin_help/config.py b/zhenxun/builtin_plugins/admin/admin_help/config.py new file mode 100644 index 000000000..dbc62efcd --- /dev/null +++ b/zhenxun/builtin_plugins/admin/admin_help/config.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel +from nonebot.plugin import PluginMetadata + +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.configs.path_config import IMAGE_PATH + +ADMIN_HELP_IMAGE = IMAGE_PATH / "ADMIN_HELP.png" +if ADMIN_HELP_IMAGE.exists(): + ADMIN_HELP_IMAGE.unlink() + + +class PluginData(BaseModel): + """ + 插件信息 + """ + + plugin: PluginInfo + """插件信息""" + metadata: PluginMetadata + """元数据""" + + class Config: + arbitrary_types_allowed = True diff --git a/zhenxun/builtin_plugins/admin/admin_help/html_help.py b/zhenxun/builtin_plugins/admin/admin_help/html_help.py new file mode 100644 index 000000000..653dcff6c --- /dev/null +++ b/zhenxun/builtin_plugins/admin/admin_help/html_help.py @@ -0,0 +1,55 @@ +from nonebot_plugin_htmlrender import template_to_pic + +from zhenxun.configs.config import BotConfig +from zhenxun.models.task_info import TaskInfo +from zhenxun.utils._build_image import BuildImage +from zhenxun.configs.path_config import TEMPLATE_PATH +from zhenxun.builtin_plugins.admin.admin_help.config import ADMIN_HELP_IMAGE + +from .utils import get_plugins + + +async def get_task() -> dict[str, str] | None: + """获取被动技能帮助""" + if task_list := await TaskInfo.all(): + return { + "name": "被动技能", + "description": "控制群组中的被动技能状态", + "usage": "通过 开启/关闭群被动 来控制群被
----------
" + + "
".join([task.name for task in task_list]), + } + return None + + +async def build_html_help(): + """构建帮助图片""" + plugins = await get_plugins() + plugin_list = [ + { + "name": data.plugin.name, + "description": data.metadata.description.replace("\n", "
"), + "usage": data.metadata.usage.replace("\n", "
"), + } + for data in plugins + ] + if task := await get_task(): + plugin_list.append(task) + plugin_list.sort(key=lambda p: len(p["description"]) + len(p["usage"])) + pic = await template_to_pic( + template_path=str((TEMPLATE_PATH / "help").absolute()), + template_name="main.html", + templates={ + "data": { + "plugin_list": plugin_list, + "nickname": BotConfig.self_nickname, + "help_name": "群管理员", + } + }, + pages={ + "viewport": {"width": 1024, "height": 1024}, + "base_url": f"file://{TEMPLATE_PATH}", + }, + wait=2, + ) + result = await BuildImage.open(pic).resize(0.5) + await result.save(ADMIN_HELP_IMAGE) diff --git a/zhenxun/builtin_plugins/admin/admin_help/normal_help.py b/zhenxun/builtin_plugins/admin/admin_help/normal_help.py new file mode 100644 index 000000000..5a9bb923d --- /dev/null +++ b/zhenxun/builtin_plugins/admin/admin_help/normal_help.py @@ -0,0 +1,127 @@ +from PIL.ImageFont import FreeTypeFont +from nonebot.plugin import PluginMetadata + +from zhenxun.services.log import logger +from zhenxun.models.task_info import TaskInfo +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.utils._build_image import BuildImage +from zhenxun.utils.image_utils import text2image, group_image, build_sort_image + +from .utils import get_plugins +from .config import ADMIN_HELP_IMAGE + + +async def build_usage_des_image( + metadata: PluginMetadata, +) -> tuple[BuildImage | None, BuildImage | None]: + """构建用法和描述图片 + + 参数: + metadata: PluginMetadata + + 返回: + tuple[BuildImage | None, BuildImage | None]: 用法和描述图片 + """ + usage = None + description = None + if metadata.usage: + usage = await text2image( + metadata.usage, + padding=5, + color=(255, 255, 255), + font_color=(0, 0, 0), + ) + if metadata.description: + description = await text2image( + metadata.description, + padding=5, + color=(255, 255, 255), + font_color=(0, 0, 0), + ) + return usage, description + + +async def build_image( + plugin: PluginInfo, metadata: PluginMetadata, font: FreeTypeFont +) -> BuildImage: + """构建帮助图片 + + 参数: + plugin: PluginInfo + metadata: PluginMetadata + font: FreeTypeFont + + 返回: + BuildImage: 帮助图片 + + """ + usage, description = await build_usage_des_image(metadata) + width = 0 + height = 100 + if usage: + width = usage.width + height += usage.height + if description and description.width > width: + width = description.width + height += description.height + font_width, _ = BuildImage.get_text_size(f"{plugin.name}[{plugin.level}]", font) + if font_width > width: + width = font_width + A = BuildImage(width + 30, height + 120, "#EAEDF2") + await A.text((15, 10), f"{plugin.name}[{plugin.level}]") + await A.text((15, 70), "简介:") + if not description: + description = BuildImage(A.width - 30, 30, (255, 255, 255)) + await description.circle_corner(10) + await A.paste(description, (15, 100)) + if not usage: + usage = BuildImage(A.width - 30, 30, (255, 255, 255)) + await usage.circle_corner(10) + await A.text((15, description.height + 115), "用法:") + await A.paste(usage, (15, description.height + 145)) + await A.circle_corner(10) + return A + + +async def build_help(): + """构造管理员帮助图片 + + 返回: + BuildImage: 管理员帮助图片 + """ + font = BuildImage.load_font("HYWenHei-85W.ttf", 20) + image_list = [] + for data in await get_plugins(): + plugin = data.plugin + metadata = data.metadata + try: + A = await build_image(plugin, metadata, font) + image_list.append(A) + except Exception as e: + logger.warning( + f"获取群管理员插件 {plugin.module}: {plugin.name} 设置失败...", + "管理员帮助", + e=e, + ) + if task_list := await TaskInfo.all(): + task_str = "\n".join([task.name for task in task_list]) + task_str = "通过 开启/关闭群被动 来控制群被动\n----------\n" + task_str + task_image = await text2image(task_str, padding=5, color=(255, 255, 255)) + await task_image.circle_corner(10) + A = BuildImage(task_image.width + 50, task_image.height + 85, "#EAEDF2") + await A.text((25, 10), "被动技能") + await A.paste(task_image, (25, 50)) + await A.circle_corner(10) + image_list.append(A) + image_group, _ = group_image(image_list) + A = await build_sort_image(image_group, color=(255, 255, 255), padding_top=160) + text = await BuildImage.build_text_image( + "群管理员帮助", + size=40, + ) + tip = await BuildImage.build_text_image( + "注: ‘*’ 代表可有多个相同参数 ‘?’ 代表可省略该参数", size=25, font_color="red" + ) + await A.paste(text, (50, 30)) + await A.paste(tip, (50, 90)) + await A.save(ADMIN_HELP_IMAGE) diff --git a/zhenxun/builtin_plugins/admin/admin_help/utils.py b/zhenxun/builtin_plugins/admin/admin_help/utils.py new file mode 100644 index 000000000..8efbf6733 --- /dev/null +++ b/zhenxun/builtin_plugins/admin/admin_help/utils.py @@ -0,0 +1,22 @@ +import nonebot + +from zhenxun.utils.enum import PluginType +from zhenxun.utils.exception import EmptyError +from zhenxun.models.plugin_info import PluginInfo + +from .config import PluginData + + +async def get_plugins() -> list[PluginData]: + """获取插件数据""" + plugin_list = await PluginInfo.filter( + plugin_type__in=[PluginType.ADMIN, PluginType.SUPER_AND_ADMIN] + ).all() + data_list = [] + for plugin in plugin_list: + if _plugin := nonebot.get_plugin_by_module_name(plugin.module_path): + if _plugin.metadata: + data_list.append(PluginData(plugin=plugin, metadata=_plugin.metadata)) + if not data_list: + raise EmptyError() + return data_list diff --git a/zhenxun/builtin_plugins/admin/ban/__init__.py b/zhenxun/builtin_plugins/admin/ban/__init__.py index fcca32200..0440244bd 100644 --- a/zhenxun/builtin_plugins/admin/ban/__init__.py +++ b/zhenxun/builtin_plugins/admin/ban/__init__.py @@ -2,23 +2,23 @@ from nonebot.adapters import Bot from nonebot.permission import SUPERUSER from nonebot.plugin import PluginMetadata +from nonebot_plugin_session import EventSession from nonebot_plugin_alconna import ( - Alconna, - Arparma, At, Match, Option, + Alconna, + Arparma, on_alconna, store_true, ) -from nonebot_plugin_session import EventSession -from zhenxun.configs.config import Config -from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.services.log import logger from zhenxun.utils.enum import PluginType -from zhenxun.utils.message import MessageUtils from zhenxun.utils.rules import admin_check +from zhenxun.utils.message import MessageUtils +from zhenxun.configs.config import Config, BotConfig +from zhenxun.configs.utils import RegisterConfig, PluginExtraData from ._data_source import BanManage @@ -160,7 +160,9 @@ async def _( duration: Match[int], group_id: Match[str], ): - user_id = None + user_id = "" + if not session.id1: + await MessageUtils.build_message("用户id为空...").finish(reply_to=True) if user.available: if isinstance(user.result, At): user_id = user.result.target @@ -169,14 +171,35 @@ async def _( await MessageUtils.build_message("权限不足捏...").finish(reply_to=True) user_id = user.result _duration = duration.result * 60 if duration.available else -1 + _duration_text = f"{duration.result} 分钟" if duration.available else " 到世界湮灭" if (gid := session.id3 or session.id2) and not group_id.available: - if group_id.available: - gid = group_id.result + if ( + not user_id + or user_id == bot.self_id + and session.id1 not in bot.config.superusers + ): + _duration = 0.5 + await MessageUtils.build_message("倒反天罡,小小管理速速退下!").send() + await BanManage.ban(session.id1, gid, 30, session, True) + _duration_text = "半 分钟" + logger.info( + f"尝试ban {BotConfig.self_nickname} 反被拿下", + arparma.header_result, + session=session, + ) + await MessageUtils.build_message( + [ + "对 ", + At(flag="user", target=session.id1), + " 狠狠惩戒了一番,一脚踢进了小黑屋!" + f" 在里面乖乖呆 {_duration_text}吧!", + ] + ).finish(reply_to=True) await BanManage.ban( user_id, gid, _duration, session, session.id1 in bot.config.superusers ) logger.info( - f"管理员Ban", + "管理员Ban", arparma.header_result, session=session, target=f"{gid}:{user_id}", @@ -184,20 +207,25 @@ async def _( await MessageUtils.build_message( [ "对 ", - At(flag="user", target=user_id) if isinstance(user.result, At) else user_id, # type: ignore - f" 狠狠惩戒了一番,一脚踢进了小黑屋!", + ( + At(flag="user", target=user_id) + if isinstance(user.result, At) + else user_id + ), # type: ignore + " 狠狠惩戒了一番,一脚踢进了小黑屋!", + f" 在里面乖乖呆 {_duration_text} 吧!", ] ).finish(reply_to=True) elif session.id1 in bot.config.superusers: _group_id = group_id.result if group_id.available else None await BanManage.ban(user_id, _group_id, _duration, session, True) logger.info( - f"超级用户Ban", + "超级用户Ban", arparma.header_result, session=session, target=f"{_group_id}:{user_id}", ) - at_msg = user_id if user_id else f"群组:{_group_id}" + at_msg = user_id or f"群组:{_group_id}" await MessageUtils.build_message( f"对 {at_msg} 狠狠惩戒了一番,一脚踢进了小黑屋!" ).finish(reply_to=True) @@ -211,7 +239,7 @@ async def _( user: Match[str | At], group_id: Match[str], ): - user_id = None + user_id = "" if user.available: if isinstance(user.result, At): user_id = user.result.target @@ -228,7 +256,7 @@ async def _( user_id, gid, session, session.id1 in bot.config.superusers ): logger.info( - f"管理员UnBan", + "管理员UnBan", arparma.header_result, session=session, target=f"{gid}:{user_id}", @@ -236,28 +264,32 @@ async def _( await MessageUtils.build_message( [ "将 ", - At(flag="user", target=user_id) if isinstance(user.result, At) else u_d, # type: ignore - f" 从黑屋中拉了出来并急救了一下!", + ( + At(flag="user", target=user_id) + if isinstance(user.result, At) + else u_d + ), # type: ignore + " 从黑屋中拉了出来并急救了一下!", ] ).finish(reply_to=True) else: - await MessageUtils.build_message(f"该用户不在黑名单中捏...").finish( + await MessageUtils.build_message("该用户不在黑名单中捏...").finish( reply_to=True ) elif session.id1 in bot.config.superusers: _group_id = group_id.result if group_id.available else None if await BanManage.unban(user_id, _group_id, session, True): logger.info( - f"超级用户UnBan", + "超级用户UnBan", arparma.header_result, session=session, target=f"{_group_id}:{user_id}", ) - at_msg = user_id if user_id else f"群组:{_group_id}" + at_msg = user_id or f"群组:{_group_id}" await MessageUtils.build_message( f"对 {at_msg} 从黑屋中拉了出来并急救了一下!" ).finish(reply_to=True) else: - await MessageUtils.build_message(f"该用户不在黑名单中捏...").finish( + await MessageUtils.build_message("该用户不在黑名单中捏...").finish( reply_to=True ) diff --git a/zhenxun/builtin_plugins/admin/ban/_data_source.py b/zhenxun/builtin_plugins/admin/ban/_data_source.py index 3d8f704df..5a872bdc4 100644 --- a/zhenxun/builtin_plugins/admin/ban/_data_source.py +++ b/zhenxun/builtin_plugins/admin/ban/_data_source.py @@ -3,13 +3,12 @@ from nonebot_plugin_session import EventSession -from zhenxun.models.ban_console import BanConsole from zhenxun.models.level_user import LevelUser +from zhenxun.models.ban_console import BanConsole from zhenxun.utils.image_utils import BuildImage, ImageTemplate class BanManage: - @classmethod async def build_ban_image( cls, diff --git a/zhenxun/builtin_plugins/admin/group_member_update/_data_source.py b/zhenxun/builtin_plugins/admin/group_member_update/_data_source.py index acb277e5b..6ece97cc3 100644 --- a/zhenxun/builtin_plugins/admin/group_member_update/_data_source.py +++ b/zhenxun/builtin_plugins/admin/group_member_update/_data_source.py @@ -19,6 +19,10 @@ class MemberUpdateManage: @classmethod async def update(cls, bot: Bot, group_id: str): + if not group_id: + return logger.warning( + f"bot: {bot.self_id},group_id为空,无法更新群成员信息..." + ) if isinstance(bot, v11Bot): await cls.v11(bot, group_id) elif isinstance(bot, v12Bot): @@ -145,14 +149,18 @@ async def v11(cls, bot: v11Bot, group_id: str): if delete_list: await GroupInfoUser.filter(id__in=delete_list).delete() logger.debug(f"删除重复数据 Ids: {delete_list}", "更新群组成员信息") - if delete_member_list := list( - set(exist_member_list).difference(set(db_user_uid)) - ): + + if delete_member_list := [ + uid for uid in db_user_uid if uid not in exist_member_list + ]: await GroupInfoUser.filter( user_id__in=delete_member_list, group_id=group_id ).delete() logger.info( - "删除已退群用户", "更新群组成员信息", group_id=group_id, platform="qq" + f"删除已退群用户 {len(delete_member_list)} 条", + "更新群组成员信息", + group_id=group_id, + platform="qq", ) @classmethod diff --git a/zhenxun/builtin_plugins/admin/plugin_switch/__init__.py b/zhenxun/builtin_plugins/admin/plugin_switch/__init__.py index 0dfb68af4..13528f88a 100644 --- a/zhenxun/builtin_plugins/admin/plugin_switch/__init__.py +++ b/zhenxun/builtin_plugins/admin/plugin_switch/__init__.py @@ -1,16 +1,16 @@ from nonebot.adapters import Bot from nonebot.plugin import PluginMetadata -from nonebot_plugin_alconna import AlconnaQuery, Arparma, Match, Query from nonebot_plugin_session import EventSession +from nonebot_plugin_alconna import Match, Query, Arparma, AlconnaQuery -from zhenxun.configs.config import Config -from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.services.log import logger -from zhenxun.utils.enum import BlockType, PluginType +from zhenxun.configs.config import Config from zhenxun.utils.message import MessageUtils +from zhenxun.utils.enum import BlockType, PluginType +from zhenxun.configs.utils import RegisterConfig, PluginExtraData -from ._data_source import PluginManage, build_plugin, build_task -from .command import _group_status_matcher, _status_matcher +from .command import _status_matcher, _group_status_matcher +from ._data_source import PluginManage, build_task, build_plugin, delete_help_image base_config = Config.get("plugin_switch") @@ -40,36 +40,35 @@ version="0.1", plugin_type=PluginType.SUPER_AND_ADMIN, superuser_help=""" - 超级管理员额外命令 - 格式: - 插件列表 - 开启/关闭[功能名称] ?[-t ["private", "p", "group", "g"](关闭类型)] ?[-g 群组Id] + 格式: + 插件列表 + 开启/关闭[功能名称] ?[-t ["private", "p", "group", "g"](关闭类型)] ?[-g 群组Id] - 开启/关闭插件df[功能名称]: 开启/关闭指定插件进群默认状态 - 开启/关闭所有插件df: 开启/关闭所有插件进群默认状态 - 开启/关闭所有插件: - 私聊中: 开启/关闭所有插件全局状态 - 群组中: 开启/关闭当前群组所有插件状态 + 开启/关闭插件df[功能名称]: 开启/关闭指定插件进群默认状态 + 开启/关闭所有插件df: 开启/关闭所有插件进群默认状态 + 开启/关闭所有插件: + 私聊中: 开启/关闭所有插件全局状态 + 群组中: 开启/关闭当前群组所有插件状态 - 开启/关闭群被动[name] ?[-g [group_id]] - 私聊中: 开启/关闭全局指定的被动状态 - 群组中: 开启/关闭当前群组指定的被动状态 - 示例: - 关闭群被动早晚安 - 关闭群被动早晚安 -g 12355555 + 开启/关闭群被动[name] ?[-g [group_id]] + 私聊中: 开启/关闭全局指定的被动状态 + 群组中: 开启/关闭当前群组指定的被动状态 + 示例: + 关闭群被动早晚安 + 关闭群被动早晚安 -g 12355555 - 开启/关闭所有群被动 ?[-g [group_id]] - 私聊中: 开启/关闭全局或指定群组被动状态 - 示例: - 开启所有群被动: 开启全局所有被动 - 开启所有群被动 -g 12345678: 开启群组12345678所有被动 + 开启/关闭所有群被动 ?[-g [group_id]] + 私聊中: 开启/关闭全局或指定群组被动状态 + 示例: + 开启所有群被动: 开启全局所有被动 + 开启所有群被动 -g 12345678: 开启群组12345678所有被动 - 私聊下: - 示例: - 开启签到 : 全局开启签到 - 关闭签到 : 全局关闭签到 - 关闭签到 p : 全局私聊关闭签到 - 关闭签到 -g 12345678 : 关闭群组12345678的签到功能(普通管理员无法开启) + 私聊下: + 示例: + 开启签到 : 全局开启签到 + 关闭签到 : 全局关闭签到 + 关闭签到 p : 全局私聊关闭签到 + 关闭签到 -g 12345678 : 关闭群组12345678的签到功能(普通管理员无法开启) """, admin_level=base_config.get("CHANGE_GROUP_SWITCH_LEVEL", 2), configs=[ @@ -94,7 +93,7 @@ async def _( if session.id1 in bot.config.superusers: image = await build_plugin() logger.info( - f"查看功能列表", + "查看功能列表", arparma.header_result, session=session, ) @@ -117,43 +116,39 @@ async def _( if not all.result and not plugin_name.available: await MessageUtils.build_message("请输入功能名称").finish(reply_to=True) name = plugin_name.result - gid = session.id3 or session.id2 - if gid: + if gid := session.id3 or session.id2: """修改当前群组的数据""" if task.result: if all.result: result = await PluginManage.unblock_group_all_task(gid) - logger.info(f"开启所有群组被动", arparma.header_result, session=session) + logger.info("开启所有群组被动", arparma.header_result, session=session) else: result = await PluginManage.unblock_group_task(name, gid) logger.info( f"开启群组被动 {name}", arparma.header_result, session=session ) + elif session.id1 in bot.config.superusers and default_status.result: + """单个插件的进群默认修改""" + result = await PluginManage.set_default_status(name, True) + logger.info( + f"超级用户开启 {name} 功能进群默认开关", + arparma.header_result, + session=session, + ) + elif all.result: + """所有插件""" + result = await PluginManage.set_all_plugin_status( + True, default_status.result, gid + ) + logger.info( + "开启群组中全部功能", + arparma.header_result, + session=session, + ) else: - if session.id1 in bot.config.superusers and default_status.result: - """单个插件的进群默认修改""" - result = await PluginManage.set_default_status(name, True) - logger.info( - f"超级用户开启 {name} 功能进群默认开关", - arparma.header_result, - session=session, - ) - else: - if all.result: - """所有插件""" - result = await PluginManage.set_all_plugin_status( - True, default_status.result, gid - ) - logger.info( - f"开启群组中全部功能", - arparma.header_result, - session=session, - ) - else: - result = await PluginManage.unblock_group_plugin(name, gid) - logger.info( - f"开启功能 {name}", arparma.header_result, session=session - ) + result = await PluginManage.unblock_group_plugin(name, gid) + logger.info(f"开启功能 {name}", arparma.header_result, session=session) + delete_help_image(gid) await MessageUtils.build_message(result).finish(reply_to=True) elif session.id1 in bot.config.superusers: """私聊""" @@ -170,7 +165,8 @@ async def _( True, default_status.result, group_id ) logger.info( - f"超级用户开启全部功能全局开关 {f'指定群组: {group_id}' if group_id else ''}", + "超级用户开启全部功能全局开关" + f" {f'指定群组: {group_id}' if group_id else ''}", arparma.header_result, session=session, ) @@ -204,16 +200,16 @@ async def _( arparma.header_result, session=session, ) - await MessageUtils.build_message(result).finish(reply_to=True) else: - result = await PluginManage.superuser_block(name, None, group_id) + result = await PluginManage.superuser_unblock(name, None, group_id) logger.info( f"超级用户开启功能 {name}", arparma.header_result, session=session, target=group_id, ) - await MessageUtils.build_message(result).finish(reply_to=True) + delete_help_image() + await MessageUtils.build_message(result).finish(reply_to=True) @_status_matcher.assign("close") @@ -231,43 +227,35 @@ async def _( if not all.result and not plugin_name.available: await MessageUtils.build_message("请输入功能名称").finish(reply_to=True) name = plugin_name.result - gid = session.id3 or session.id2 - if gid: + if gid := session.id3 or session.id2: """修改当前群组的数据""" if task.result: if all.result: result = await PluginManage.block_group_all_task(gid) - logger.info(f"开启所有群组被动", arparma.header_result, session=session) + logger.info("开启所有群组被动", arparma.header_result, session=session) else: result = await PluginManage.block_group_task(name, gid) logger.info( f"关闭群组被动 {name}", arparma.header_result, session=session ) + elif session.id1 in bot.config.superusers and default_status.result: + """单个插件的进群默认修改""" + result = await PluginManage.set_default_status(name, False) + logger.info( + f"超级用户开启 {name} 功能进群默认开关", + arparma.header_result, + session=session, + ) + elif all.result: + """所有插件""" + result = await PluginManage.set_all_plugin_status( + False, default_status.result, gid + ) + logger.info("关闭群组中全部功能", arparma.header_result, session=session) else: - if session.id1 in bot.config.superusers and default_status.result: - """单个插件的进群默认修改""" - result = await PluginManage.set_default_status(name, False) - logger.info( - f"超级用户开启 {name} 功能进群默认开关", - arparma.header_result, - session=session, - ) - else: - if all.result: - """所有插件""" - result = await PluginManage.set_all_plugin_status( - False, default_status.result, gid - ) - logger.info( - f"关闭群组中全部功能", - arparma.header_result, - session=session, - ) - else: - result = await PluginManage.block_group_plugin(name, gid) - logger.info( - f"关闭功能 {name}", arparma.header_result, session=session - ) + result = await PluginManage.block_group_plugin(name, gid) + logger.info(f"关闭功能 {name}", arparma.header_result, session=session) + delete_help_image(gid) await MessageUtils.build_message(result).finish(reply_to=True) elif session.id1 in bot.config.superusers: group_id = group.result if group.available else None @@ -283,7 +271,8 @@ async def _( False, default_status.result, group_id ) logger.info( - f"超级用户关闭全部功能全局开关 {f'指定群组: {group_id}' if group_id else ''}", + "超级用户关闭全部功能全局开关" + f" {f'指定群组: {group_id}' if group_id else ''}", arparma.header_result, session=session, ) @@ -317,13 +306,13 @@ async def _( arparma.header_result, session=session, ) - await MessageUtils.build_message(result).finish(reply_to=True) else: _type = BlockType.ALL - if block_type.available: - if block_type.result in ["p", "private"]: + if block_type.result in ["p", "private"]: + if block_type.available: _type = BlockType.PRIVATE - elif block_type.result in ["g", "group"]: + elif block_type.result in ["g", "group"]: + if block_type.available: _type = BlockType.GROUP result = await PluginManage.superuser_block(name, _type, group_id) logger.info( @@ -332,7 +321,8 @@ async def _( session=session, target=group_id, ) - await MessageUtils.build_message(result).finish(reply_to=True) + delete_help_image() + await MessageUtils.build_message(result).finish(reply_to=True) @_group_status_matcher.handle() @@ -362,11 +352,7 @@ async def _( ): image = await build_task(session.id3 or session.id2) if image: - logger.info( - f"查看群被动列表", - arparma.header_result, - session=session, - ) + logger.info("查看群被动列表", arparma.header_result, session=session) await MessageUtils.build_message(image).finish(reply_to=True) else: await MessageUtils.build_message("获取群被动任务失败...").finish(reply_to=True) diff --git a/zhenxun/builtin_plugins/admin/plugin_switch/_data_source.py b/zhenxun/builtin_plugins/admin/plugin_switch/_data_source.py index b10a929e3..b5c991bbb 100644 --- a/zhenxun/builtin_plugins/admin/plugin_switch/_data_source.py +++ b/zhenxun/builtin_plugins/admin/plugin_switch/_data_source.py @@ -1,9 +1,27 @@ -from zhenxun.models.group_console import GroupConsole -from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.task_info import TaskInfo +from zhenxun.models.plugin_info import PluginInfo from zhenxun.utils.enum import BlockType, PluginType +from zhenxun.models.group_console import GroupConsole from zhenxun.utils.exception import GroupInfoNotFound -from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle +from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH +from zhenxun.utils.image_utils import RowStyle, BuildImage, ImageTemplate + +HELP_FILE = IMAGE_PATH / "SIMPLE_HELP.png" + +GROUP_HELP_PATH = DATA_PATH / "group_help" + + +def delete_help_image(gid: str | None = None): + """删除帮助图片""" + if gid: + file = GROUP_HELP_PATH / f"{gid}.png" + if file.exists(): + file.unlink() + else: + if HELP_FILE.exists(): + HELP_FILE.unlink() + for file in GROUP_HELP_PATH.iterdir(): + file.unlink() def plugin_row_style(column: str, text: str) -> RowStyle: @@ -17,16 +35,16 @@ def plugin_row_style(column: str, text: str) -> RowStyle: RowStyle: RowStyle """ style = RowStyle() - if column == "全局状态": - if text == "开启": - style.font_color = "#67C23A" - else: - style.font_color = "#F56C6C" - if column == "加载状态": - if text == "SUCCESS": - style.font_color = "#67C23A" - else: - style.font_color = "#F56C6C" + if ( + column == "全局状态" + and text == "开启" + or column != "全局状态" + and column == "加载状态" + and text == "SUCCESS" + ): + style.font_color = "#67C23A" + elif column in {"全局状态", "加载状态"}: + style.font_color = "#F56C6C" return style @@ -44,22 +62,21 @@ async def build_plugin() -> BuildImage: "金币花费", ] plugin_list = await PluginInfo.filter(plugin_type__not=PluginType.HIDDEN).all() - column_data = [] - for plugin in plugin_list: - column_data.append( - [ - plugin.id, - plugin.module, - plugin.name, - "开启" if plugin.status else "关闭", - plugin.block_type, - "SUCCESS" if plugin.load_status else "ERROR", - plugin.menu_type, - plugin.author, - plugin.version, - plugin.cost_gold, - ] - ) + column_data = [ + [ + plugin.id, + plugin.module, + plugin.name, + "开启" if plugin.status else "关闭", + plugin.block_type, + "SUCCESS" if plugin.load_status else "ERROR", + plugin.menu_type, + plugin.author, + plugin.version, + plugin.cost_gold, + ] + for plugin in plugin_list + ] return await ImageTemplate.table_page( "Plugin", "插件状态", @@ -80,11 +97,8 @@ def task_row_style(column: str, text: str) -> RowStyle: RowStyle: RowStyle """ style = RowStyle() - if column in ["群组状态", "全局状态"]: - if text == "开启": - style.font_color = "#67C23A" - else: - style.font_color = "#F56C6C" + if column in {"群组状态", "全局状态"}: + style.font_color = "#67C23A" if text == "开启" else "#F56C6C" return style @@ -144,7 +158,6 @@ async def build_task(group_id: str | None) -> BuildImage: class PluginManage: - @classmethod async def set_default_status(cls, plugin_name: str, status: bool) -> str: """设置插件进群默认状态 @@ -159,12 +172,15 @@ async def set_default_status(cls, plugin_name: str, status: bool) -> str: if plugin_name.isdigit(): plugin = await PluginInfo.get_or_none(id=int(plugin_name)) else: - plugin = await PluginInfo.get_or_none(name=plugin_name) + plugin = await PluginInfo.get_or_none( + name=plugin_name, load_status=True, plugin_type__not=PluginType.PARENT + ) if plugin: plugin.default_status = status await plugin.save(update_fields=["default_status"]) - return f'成功将 {plugin.name} 进群默认状态修改为: {"开启" if status else "关闭"}' - return f"没有找到这个功能喔..." + status_text = "开启" if status else "关闭" + return f"成功将 {plugin.name} 进群默认状态修改为: {status_text}" + return "没有找到这个功能喔..." @classmethod async def set_all_plugin_status( @@ -206,7 +222,7 @@ async def set_all_plugin_status( return f'成功将此群组所有功能状态修改为: {"开启" if status else "关闭"}' return "获取群组失败..." await PluginInfo.filter(plugin_type=PluginType.NORMAL).update( - status=status, block_type=BlockType.ALL if not status else None + status=status, block_type=None if status else BlockType.ALL ) return f'成功将所有功能全局状态修改为: {"开启" if status else "关闭"}' @@ -417,19 +433,18 @@ async def _change_group_task( group.block_task = group.block_task.replace(f"{module},", "") await group.save(update_fields=["block_task"]) return f"已成功{status_str}全部被动技能!" - else: - if task := await TaskInfo.get_or_none(name=task_name): - group, _ = await GroupConsole.get_or_create( - group_id=group_id, channel_id__isnull=True - ) - if status: - group.block_task += f"{task.module}," - else: - if f"super:{task.module}," in group.block_task: - return f"{status_str} {task_name} 被动技能失败,当前群组该被动已被管理员禁用" - group.block_task = group.block_task.replace(f"{task.module},", "") - await group.save(update_fields=["block_task"]) - return f"已成功{status_str} {task_name} 被动技能!" + elif task := await TaskInfo.get_or_none(name=task_name): + group, _ = await GroupConsole.get_or_create( + group_id=group_id, channel_id__isnull=True + ) + if status: + group.block_task += f"{task.module}," + elif f"super:{task.module}," in group.block_task: + return f"{status_str} {task_name} 被动技能失败,当前群组该被动已被管理员禁用" # noqa: E501 + else: + group.block_task = group.block_task.replace(f"{task.module},", "") + await group.save(update_fields=["block_task"]) + return f"已成功{status_str} {task_name} 被动技能!" return "没有找到这个被动技能喔..." @classmethod @@ -450,12 +465,14 @@ async def _change_group_plugin( if plugin_name.isdigit(): plugin = await PluginInfo.get_or_none(id=int(plugin_name)) else: - plugin = await PluginInfo.get_or_none(name=plugin_name) - status_str = "开启" if status else "关闭" + plugin = await PluginInfo.get_or_none( + name=plugin_name, load_status=True, plugin_type__not=PluginType.PARENT + ) if plugin: group, _ = await GroupConsole.get_or_create( group_id=group_id, channel_id__isnull=True ) + status_str = "开启" if status else "关闭" if status: if plugin.module in group.block_plugin: group.block_plugin = group.block_plugin.replace( @@ -463,11 +480,10 @@ async def _change_group_plugin( ) await group.save(update_fields=["block_plugin"]) return f"已成功{status_str} {plugin.name} 功能!" - else: - if plugin.module not in group.block_plugin: - group.block_plugin += f"{plugin.module}," - await group.save(update_fields=["block_plugin"]) - return f"已成功{status_str} {plugin.name} 功能!" + elif plugin.module not in group.block_plugin: + group.block_plugin += f"{plugin.module}," + await group.save(update_fields=["block_plugin"]) + return f"已成功{status_str} {plugin.name} 功能!" return f"该功能已经{status_str}了喔,不要重复{status_str}..." return "没有找到这个功能喔..." @@ -485,22 +501,20 @@ async def superuser_task_handle( 返回: str: 返回信息 """ - if task := await TaskInfo.get_or_none(name=task_name): + if not (task := await TaskInfo.get_or_none(name=task_name)): + return "没有找到这个功能喔..." + if group_id: + group, _ = await GroupConsole.get_or_create( + group_id=group_id, channel_id__isnull=True + ) + if status: + group.block_task = group.block_task.replace(f"super:{task.module},", "") + else: + group.block_task += f"super:{task.module}," + await group.save(update_fields=["block_task"]) status_str = "开启" if status else "关闭" - if group_id: - group, _ = await GroupConsole.get_or_create( - group_id=group_id, channel_id__isnull=True - ) - if status: - group.block_task = group.block_task.replace( - f"super:{task.module},", "" - ) - else: - group.block_task += f"super:{task.module}," - await group.save(update_fields=["block_task"]) - return f"已成功将群组 {group_id} 被动技能 {task_name} {status_str}!" - return "没有找到这个群组喔..." - return "没有找到这个功能喔..." + return f"已成功将群组 {group_id} 被动技能 {task_name} {status_str}!" + return "没有找到这个群组喔..." @classmethod async def superuser_block( @@ -519,7 +533,9 @@ async def superuser_block( if plugin_name.isdigit(): plugin = await PluginInfo.get_or_none(id=int(plugin_name)) else: - plugin = await PluginInfo.get_or_none(name=plugin_name) + plugin = await PluginInfo.get_or_none( + name=plugin_name, load_status=True, plugin_type__not=PluginType.PARENT + ) if plugin: if group_id: if group := await GroupConsole.get_or_none( @@ -538,11 +554,58 @@ async def superuser_block( await plugin.save(update_fields=["status", "block_type"]) if not block_type: return f"已成功将 {plugin.name} 全局启用!" - else: - if block_type == BlockType.ALL: - return f"已成功将 {plugin.name} 全局关闭!" - if block_type == BlockType.GROUP: - return f"已成功将 {plugin.name} 全局群组关闭!" - if block_type == BlockType.PRIVATE: - return f"已成功将 {plugin.name} 全局私聊关闭!" + if block_type == BlockType.ALL: + return f"已成功将 {plugin.name} 全局关闭!" + if block_type == BlockType.GROUP: + return f"已成功将 {plugin.name} 全局群组关闭!" + if block_type == BlockType.PRIVATE: + return f"已成功将 {plugin.name} 全局私聊关闭!" + return "没有找到这个功能喔..." + + @classmethod + async def superuser_unblock( + cls, plugin_name: str, block_type: BlockType | None, group_id: str | None + ) -> str: + """超级用户开启插件 + + 参数: + plugin_name: 插件名称 + block_type: 禁用类型 + group_id: 群组id + + 返回: + str: 返回信息 + """ + if plugin_name.isdigit(): + plugin = await PluginInfo.get_or_none(id=int(plugin_name)) + else: + plugin = await PluginInfo.get_or_none( + name=plugin_name, load_status=True, plugin_type__not=PluginType.PARENT + ) + if plugin: + if group_id: + if group := await GroupConsole.get_or_none( + group_id=group_id, channel_id__isnull=True + ): + if f"super:{plugin.module}," in group.block_plugin: + group.block_plugin = group.block_plugin.replace( + f"super:{plugin.module},", "" + ) + await group.save(update_fields=["block_plugin"]) + return ( + f"已成功开启群组 {group.group_name} 的 {plugin_name} 功能!" + ) + return "此群组该功能已被超级用户开启,不要重复开启..." + return "群组信息未更新,请先更新群组信息..." + plugin.block_type = block_type + plugin.status = not bool(block_type) + await plugin.save(update_fields=["status", "block_type"]) + if not block_type: + return f"已成功将 {plugin.name} 全局启用!" + if block_type == BlockType.ALL: + return f"已成功将 {plugin.name} 全局开启!" + if block_type == BlockType.GROUP: + return f"已成功将 {plugin.name} 全局群组开启!" + if block_type == BlockType.PRIVATE: + return f"已成功将 {plugin.name} 全局私聊开启!" return "没有找到这个功能喔..." diff --git a/zhenxun/builtin_plugins/admin/welcome_message.py b/zhenxun/builtin_plugins/admin/welcome_message.py index 6cb6c8dcc..e9bb9c33e 100644 --- a/zhenxun/builtin_plugins/admin/welcome_message.py +++ b/zhenxun/builtin_plugins/admin/welcome_message.py @@ -1,23 +1,21 @@ -import os import shutil -from typing import Annotated, Dict +from pathlib import Path +from typing import Annotated import ujson as json from nonebot import on_command from nonebot.params import Command from nonebot.plugin import PluginMetadata -from nonebot_plugin_alconna import Image -from nonebot_plugin_alconna import Text as alcText -from nonebot_plugin_alconna import UniMsg from nonebot_plugin_session import EventSession +from nonebot_plugin_alconna import Text, Image, UniMsg -from zhenxun.configs.config import Config -from zhenxun.configs.path_config import DATA_PATH -from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.services.log import logger +from zhenxun.configs.config import Config from zhenxun.utils.enum import PluginType from zhenxun.utils.http_utils import AsyncHttpx +from zhenxun.configs.path_config import DATA_PATH from zhenxun.utils.rules import admin_check, ensure_group +from zhenxun.configs.utils import RegisterConfig, PluginExtraData base_config = Config.get("admin_bot_manage") @@ -25,7 +23,8 @@ name="自定义群欢迎消息", description="自定义群欢迎消息", usage=""" - 设置欢迎消息 欢迎新人! + 设置群欢迎消息,当消息中包含 -at 时会at入群用户 + 设置欢迎消息 欢迎新人![图片] 设置欢迎消息 欢迎你 -at """.strip(), extra=PluginExtraData( @@ -61,7 +60,7 @@ old_file = DATA_PATH / "custom_welcome_msg" / "custom_welcome_msg.json" if old_file.exists(): try: - old_data: Dict[str, str] = json.load(old_file.open(encoding="utf8")) + old_data: dict[str, str] = json.load(old_file.open(encoding="utf8")) for group_id, message in old_data.items(): file = BASE_PATH / "qq" / f"{group_id}" / "text.json" file.parent.mkdir(parents=True, exist_ok=True) @@ -74,15 +73,18 @@ logger.debug("群欢迎消息数据迁移", group_id=group_id) shutil.rmtree(old_file.parent.absolute()) except Exception as e: - pass + logger.error("群欢迎消息数据迁移失败...", e=e) -@_matcher.handle() -async def _( - session: EventSession, - message: UniMsg, - command: Annotated[tuple[str, ...], Command()], -): +def get_path(session: EventSession) -> Path: + """根据Session获取存储路径 + + 参数: + session: EventSession: + + 返回: + Path: 存储路径 + """ path = BASE_PATH / f"{session.platform or session.bot_type}" / f"{session.id2}" if session.id3: path = ( @@ -91,32 +93,53 @@ async def _( / f"{session.id3}" / f"{session.id2}" ) - file = path / "text.json" + path.mkdir(parents=True, exist_ok=True) + for f in path.iterdir(): + f.unlink() + return path + + +async def save(path: Path, message: UniMsg) -> str: + """保存群欢迎消息 + + 参数: + path: 存储路径 + message: 消息内容 + + 返回: + str: 消息内容文本格式 + """ idx = 0 text = "" - for f in os.listdir(path): - (path / f).unlink() - message[0].text = message[0].text.replace(command[0], "").strip() + file = path / "text.json" for msg in message: - if isinstance(msg, alcText): + if isinstance(msg, Text): text += msg.text elif isinstance(msg, Image): if msg.url: text += f"[image:{idx}]" - await AsyncHttpx.download_file(msg.url, path / f"{idx}.png") - idx += 1 + if await AsyncHttpx.download_file(msg.url, path / f"{idx}.png"): + idx += 1 else: - logger.debug("图片 URL 为空...", command[0]) - if not file.exists(): - file.parent.mkdir(exist_ok=True, parents=True) - is_at = "-at" in message - text = text.replace("-at", "") + logger.warning("图片 URL 为空...", "设置欢迎消息") json.dump( - {"at": is_at, "message": text}, - file.open("w"), + {"at": "-at" in text, "message": text.replace("-at", "", 1)}, + file.open("w", encoding="utf-8"), ensure_ascii=False, indent=4, ) - uni_msg = alcText("设置欢迎消息成功: \n") + message + return text + + +@_matcher.handle() +async def _( + session: EventSession, + message: UniMsg, + command: Annotated[tuple[str, ...], Command()], +): + path = get_path(session) + message[0].text = message[0].text.replace(command[0], "").strip() + text = await save(path, message) + uni_msg = Text("设置欢迎消息成功: \n") + message await uni_msg.send() logger.info(f"设置群欢迎消息成功: {text}", command[0], session=session) diff --git a/zhenxun/builtin_plugins/auto_update/_data_source.py b/zhenxun/builtin_plugins/auto_update/_data_source.py index 8bdb349f9..be5e3b230 100644 --- a/zhenxun/builtin_plugins/auto_update/_data_source.py +++ b/zhenxun/builtin_plugins/auto_update/_data_source.py @@ -10,10 +10,10 @@ from zhenxun.services.log import logger from zhenxun.utils.http_utils import AsyncHttpx from zhenxun.utils.platform import PlatformUtils +from zhenxun.utils.github_utils import GithubUtils +from zhenxun.utils.github_utils.models import RepoInfo from .config import ( - DEV_URL, - MAIN_URL, TMP_PATH, BASE_PATH, BACKUP_PATH, @@ -25,6 +25,7 @@ BASE_PATH_STRING, DOWNLOAD_GZ_FILE, DOWNLOAD_ZIP_FILE, + DEFAULT_GITHUB_URL, PYPROJECT_LOCK_FILE, REQ_TXT_FILE_STRING, PYPROJECT_FILE_STRING, @@ -117,14 +118,14 @@ def _file_handle(latest_version: str | None): shutil.move(src_folder_path, dest_folder_path) else: logger.debug(f"源文件夹不存在: {src_folder_path}", "检查更新") + if tf: + tf.close() if download_file.exists(): logger.debug(f"删除下载文件: {download_file}", "检查更新") download_file.unlink() if extract_path.exists(): logger.debug(f"删除解压文件夹: {extract_path}", "检查更新") shutil.rmtree(extract_path) - if tf: - tf.close() if TMP_PATH.exists(): shutil.rmtree(TMP_PATH) if latest_version: @@ -169,23 +170,19 @@ async def update(cls, bot: Bot, user_id: str, version_type: str) -> str | None: cur_version = cls.__get_version() url = None new_version = None - if version_type == "dev": - url = DEV_URL - new_version = await cls.__get_version_from_branch("dev") - if new_version: - new_version = new_version.split(":")[-1].strip() - elif version_type == "main": - url = MAIN_URL - new_version = await cls.__get_version_from_branch("main") + repo_info = GithubUtils.parse_github_url(DEFAULT_GITHUB_URL) + if version_type in {"dev", "main"}: + repo_info.branch = version_type + new_version = await cls.__get_version_from_repo(repo_info) if new_version: new_version = new_version.split(":")[-1].strip() + url = await repo_info.get_archive_download_urls() elif version_type == "release": data = await cls.__get_latest_data() if not data: return "获取更新版本失败..." - url = data.get("tarball_url") - new_version = data.get("name") - url = (await AsyncHttpx.get(url)).headers.get("Location") # type: ignore + new_version = data.get("name", "") + url = await repo_info.get_release_source_download_urls_tgz(new_version) if not url: return "获取版本下载链接失败..." if TMP_PATH.exists(): @@ -203,7 +200,7 @@ async def update(cls, bot: Bot, user_id: str, version_type: str) -> str | None: download_file = ( DOWNLOAD_GZ_FILE if version_type == "release" else DOWNLOAD_ZIP_FILE ) - if await AsyncHttpx.download_file(url, download_file): + if await AsyncHttpx.download_file(url, download_file, stream=True): logger.debug("下载真寻最新版文件完成...", "检查更新") await _file_handle(new_version) return ( @@ -247,7 +244,7 @@ async def __get_latest_data(cls) -> dict: return {} @classmethod - async def __get_version_from_branch(cls, branch: str) -> str: + async def __get_version_from_repo(cls, repo_info: RepoInfo) -> str: """从指定分支获取版本号 参数: @@ -256,11 +253,11 @@ async def __get_version_from_branch(cls, branch: str) -> str: 返回: str: 版本号 """ - version_url = f"https://raw.githubusercontent.com/HibiKier/zhenxun_bot/{branch}/__version__" + version_url = await repo_info.get_raw_download_urls(path="__version__") try: res = await AsyncHttpx.get(version_url) if res.status_code == 200: return res.text.strip() except Exception as e: - logger.error(f"获取 {branch} 分支版本失败", e=e) + logger.error(f"获取 {repo_info.branch} 分支版本失败", e=e) return "未知版本" diff --git a/zhenxun/builtin_plugins/auto_update/config.py b/zhenxun/builtin_plugins/auto_update/config.py index 056b973a2..b4c613453 100644 --- a/zhenxun/builtin_plugins/auto_update/config.py +++ b/zhenxun/builtin_plugins/auto_update/config.py @@ -2,8 +2,7 @@ from zhenxun.configs.path_config import TEMP_PATH -DEV_URL = "https://ghproxy.cc/https://github.com/HibiKier/zhenxun_bot/archive/refs/heads/dev.zip" -MAIN_URL = "https://ghproxy.cc/https://github.com/HibiKier/zhenxun_bot/archive/refs/heads/main.zip" +DEFAULT_GITHUB_URL = "https://github.com/HibiKier/zhenxun_bot/tree/main" RELEASE_URL = "https://api.github.com/repos/HibiKier/zhenxun_bot/releases/latest" VERSION_FILE_STRING = "__version__" @@ -23,8 +22,10 @@ BACKUP_PATH = Path() / "backup" -DOWNLOAD_GZ_FILE = TMP_PATH / "download_latest_file.tar.gz" -DOWNLOAD_ZIP_FILE = TMP_PATH / "download_latest_file.zip" +DOWNLOAD_GZ_FILE_STRING = "download_latest_file.tar.gz" +DOWNLOAD_ZIP_FILE_STRING = "download_latest_file.zip" +DOWNLOAD_GZ_FILE = TMP_PATH / DOWNLOAD_GZ_FILE_STRING +DOWNLOAD_ZIP_FILE = TMP_PATH / DOWNLOAD_ZIP_FILE_STRING REPLACE_FOLDERS = [ "builtin_plugins", diff --git a/zhenxun/builtin_plugins/check/__init__.py b/zhenxun/builtin_plugins/check/__init__.py new file mode 100644 index 000000000..f4c4f89a1 --- /dev/null +++ b/zhenxun/builtin_plugins/check/__init__.py @@ -0,0 +1,53 @@ +from nonebot.permission import SUPERUSER +from nonebot.plugin import PluginMetadata +from nonebot.rule import to_me +from nonebot_plugin_alconna import Alconna, Arparma, on_alconna +from nonebot_plugin_htmlrender import template_to_pic +from nonebot_plugin_session import EventSession + +from zhenxun.configs.path_config import TEMPLATE_PATH +from zhenxun.configs.utils import PluginExtraData +from zhenxun.services.log import logger +from zhenxun.utils.enum import PluginType +from zhenxun.utils.message import MessageUtils + +from .data_source import get_status_info + +__plugin_meta__ = PluginMetadata( + name="服务器自我检查", + description="查看服务器当前状态", + usage=""" + 查看服务器当前状态 + 指令: + 自检 + """.strip(), + extra=PluginExtraData( + author="HibiKier", version="0.1", plugin_type=PluginType.SUPERUSER + ).dict(), +) + + +_matcher = on_alconna( + Alconna("自检"), rule=to_me(), permission=SUPERUSER, block=True, priority=1 +) + + +@_matcher.handle() +async def _(session: EventSession, arparma: Arparma): + try: + data = await get_status_info() + image = await template_to_pic( + template_path=str((TEMPLATE_PATH / "check").absolute()), + template_name="main.html", + templates={"data": data}, + pages={ + "viewport": {"width": 195, "height": 750}, + "base_url": f"file://{TEMPLATE_PATH}", + }, + wait=2, + ) + await MessageUtils.build_message(image).send() + logger.info("自检", arparma.header_result, session=session) + except Exception as e: + await MessageUtils.build_message(f"自检失败: {e}").send() + logger.error("自检失败", arparma.header_result, session=session, e=e) diff --git a/zhenxun/builtin_plugins/check/data_source.py b/zhenxun/builtin_plugins/check/data_source.py new file mode 100644 index 000000000..34d7fc0bf --- /dev/null +++ b/zhenxun/builtin_plugins/check/data_source.py @@ -0,0 +1,209 @@ +import os +import platform +import subprocess +from pathlib import Path +from dataclasses import dataclass + +import psutil +import cpuinfo +import nonebot +from pydantic import BaseModel +from nonebot.utils import run_sync + +from zhenxun.services.log import logger +from zhenxun.configs.config import BotConfig +from zhenxun.utils.http_utils import AsyncHttpx + +BAIDU_URL = "https://www.baidu.com/" +GOOGLE_URL = "https://www.google.com/" + +VERSION_FILE = Path() / "__version__" +ARM_KEY = "aarch64" + + +@dataclass +class CPUInfo: + core: int + """CPU 物理核心数""" + usage: float + """CPU 占用百分比,取值范围(0,100]""" + freq: float + """CPU 的时钟速度(单位:GHz)""" + + @classmethod + def get_cpu_info(cls): + cpu_core = psutil.cpu_count(logical=False) + cpu_usage = psutil.cpu_percent(interval=0.1) + if _cpu_freq := psutil.cpu_freq(): + cpu_freq = round(_cpu_freq.current / 1000, 2) + else: + cpu_freq = 0 + return CPUInfo(core=cpu_core, usage=cpu_usage, freq=cpu_freq) + + +@dataclass +class RAMInfo: + """RAM 信息(单位:GB)""" + + total: float + """RAM 总量""" + usage: float + """当前 RAM 占用量/GB""" + + @classmethod + def get_ram_info(cls): + ram_total = round(psutil.virtual_memory().total / (1024**3), 2) + ram_usage = round(psutil.virtual_memory().used / (1024**3), 2) + + return RAMInfo(total=ram_total, usage=ram_usage) + + +@dataclass +class SwapMemory: + """Swap 信息(单位:GB)""" + + total: float + """Swap 总量""" + usage: float + """当前 Swap 占用量/GB""" + + @classmethod + def get_swap_info(cls): + swap_total = round(psutil.swap_memory().total / (1024**3), 2) + swap_usage = round(psutil.swap_memory().used / (1024**3), 2) + + return SwapMemory(total=swap_total, usage=swap_usage) + + +@dataclass +class DiskInfo: + """硬盘信息""" + + total: float + """硬盘总量""" + usage: float + """当前硬盘占用量/GB""" + + @classmethod + def get_disk_info(cls): + disk_total = round(psutil.disk_usage("/").total / (1024**3), 2) + disk_usage = round(psutil.disk_usage("/").used / (1024**3), 2) + + return DiskInfo(total=disk_total, usage=disk_usage) + + +class SystemInfo(BaseModel): + """系统信息""" + + cpu: CPUInfo + """CPU信息""" + ram: RAMInfo + """RAM信息""" + swap: SwapMemory + """SWAP信息""" + disk: DiskInfo + """DISK信息""" + + def get_system_info(self): + return { + "cpu_info": f"{self.cpu.usage}% - {self.cpu.freq}Ghz " + f"[{self.cpu.core} core]", + "cpu_process": self.cpu.usage, + "ram_info": f"{self.ram.usage} / {self.ram.total} GB", + "ram_process": ( + 0 if self.ram.total == 0 else (self.ram.usage / self.ram.total * 100) + ), + "swap_info": f"{self.swap.usage} / {self.swap.total} GB", + "swap_process": ( + 0 if self.swap.total == 0 else (self.swap.usage / self.swap.total * 100) + ), + "disk_info": f"{self.disk.usage} / {self.disk.total} GB", + "disk_process": ( + 0 if self.disk.total == 0 else (self.disk.usage / self.disk.total * 100) + ), + } + + +@run_sync +def __build_status() -> SystemInfo: + """获取 `CPU` `RAM` `SWAP` `DISK` 信息""" + cpu = CPUInfo.get_cpu_info() + ram = RAMInfo.get_ram_info() + swap = SwapMemory.get_swap_info() + disk = DiskInfo.get_disk_info() + + return SystemInfo(cpu=cpu, ram=ram, swap=swap, disk=disk) + + +async def __get_network_info(): + """网络请求""" + baidu, google = True, True + try: + await AsyncHttpx.get(BAIDU_URL, timeout=5) + except Exception as e: + logger.warning("自检:百度无法访问...", e=e) + baidu = False + try: + await AsyncHttpx.get(GOOGLE_URL, timeout=5) + except Exception as e: + logger.warning("自检:谷歌无法访问...", e=e) + google = False + return baidu, google + + +def __get_version() -> str | None: + """获取版本信息""" + if VERSION_FILE.exists(): + with open(VERSION_FILE, encoding="utf-8") as f: + if text := f.read(): + return text.split(":")[-1] + return None + + +def __get_arm_cpu(): + env = os.environ.copy() + env["LC_ALL"] = "en_US.UTF-8" + cpu_info = subprocess.check_output(["lscpu"], env=env).decode() + model_name = "" + cpu_freq = 0 + for line in cpu_info.splitlines(): + if "Model name" in line: + model_name = line.split(":")[1].strip() + if "CPU MHz" in line: + cpu_freq = float(line.split(":")[1].strip()) + return model_name, cpu_freq + + +def __get_arm_oracle_cpu_freq(): + cpu_freq = subprocess.check_output( + ["dmidecode", "-s", "processor-frequency"] + ).decode() + return round(float(cpu_freq.split()[0]) / 1000, 2) + + +async def get_status_info() -> dict: + """获取信息""" + data = await __build_status() + + system = platform.uname() + if system.machine == ARM_KEY and not ( + cpuinfo.get_cpu_info().get("brand_raw") and data.cpu.freq + ): + model_name, cpu_freq = __get_arm_cpu() + if not data.cpu.freq: + data.cpu.freq = cpu_freq or __get_arm_oracle_cpu_freq() + data = data.get_system_info() + data["brand_raw"] = model_name + else: + data = data.get_system_info() + data["brand_raw"] = cpuinfo.get_cpu_info().get("brand_raw", "Unknown") + + baidu, google = await __get_network_info() + data["baidu"] = "#8CC265" if baidu else "red" + data["google"] = "#8CC265" if google else "red" + + data["system"] = f"{system.system} {system.release}" + data["version"] = __get_version() + data["plugin_count"] = len(nonebot.get_loaded_plugins()) + data["nickname"] = BotConfig.self_nickname + return data diff --git a/zhenxun/builtin_plugins/help/__init__.py b/zhenxun/builtin_plugins/help/__init__.py index 7bbcdbf37..8bce868df 100644 --- a/zhenxun/builtin_plugins/help/__init__.py +++ b/zhenxun/builtin_plugins/help/__init__.py @@ -16,10 +16,9 @@ from zhenxun.services.log import logger from zhenxun.utils.enum import PluginType from zhenxun.utils.message import MessageUtils -from zhenxun.configs.path_config import IMAGE_PATH from zhenxun.configs.utils import RegisterConfig, PluginExtraData +from zhenxun.builtin_plugins.help._config import GROUP_HELP_PATH, SIMPLE_HELP_IMAGE -from ._utils import GROUP_HELP_PATH from ._data_source import create_help_img, get_plugin_help __plugin_meta__ = PluginMetadata( @@ -42,10 +41,6 @@ ) -SIMPLE_HELP_IMAGE = IMAGE_PATH / "SIMPLE_HELP.png" -if SIMPLE_HELP_IMAGE.exists(): - SIMPLE_HELP_IMAGE.unlink() - _matcher = on_alconna( Alconna( "功能", @@ -66,11 +61,13 @@ async def _( session: EventSession, is_superuser: Query[bool] = AlconnaQuery("superuser.value", False), ): + if not session.id1: + await MessageUtils.build_message("用户id为空...").finish() _is_superuser = is_superuser.result if is_superuser.available else False if name.available: if _is_superuser and session.id1 not in bot.config.superusers: _is_superuser = False - if result := await get_plugin_help(name.result, _is_superuser): + if result := await get_plugin_help(session.id1, name.result, _is_superuser): await MessageUtils.build_message(result).send(reply_to=True) else: await MessageUtils.build_message("没有此功能的帮助信息...").send( @@ -80,11 +77,9 @@ async def _( elif gid := session.id3 or session.id2: _image_path = GROUP_HELP_PATH / f"{gid}.png" if not _image_path.exists(): - await create_help_img(bot.self_id, gid) + await create_help_img(bot.self_id, gid, session.platform) await MessageUtils.build_message(_image_path).finish() else: if not SIMPLE_HELP_IMAGE.exists(): - if SIMPLE_HELP_IMAGE.exists(): - SIMPLE_HELP_IMAGE.unlink() - await create_help_img(bot.self_id, None) + await create_help_img(bot.self_id, None, session.platform) await MessageUtils.build_message(SIMPLE_HELP_IMAGE).finish() diff --git a/zhenxun/builtin_plugins/help/_config.py b/zhenxun/builtin_plugins/help/_config.py index b38bf0667..fe8fff559 100644 --- a/zhenxun/builtin_plugins/help/_config.py +++ b/zhenxun/builtin_plugins/help/_config.py @@ -1,13 +1,13 @@ -from pydantic import BaseModel +from zhenxun.configs.config import Config +from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH +GROUP_HELP_PATH = DATA_PATH / "group_help" +GROUP_HELP_PATH.mkdir(exist_ok=True, parents=True) +for f in GROUP_HELP_PATH.iterdir(): + f.unlink() -class Item(BaseModel): - plugin_name: str - sta: int +SIMPLE_HELP_IMAGE = IMAGE_PATH / "SIMPLE_HELP.png" +if SIMPLE_HELP_IMAGE.exists(): + SIMPLE_HELP_IMAGE.unlink() - -class PluginList(BaseModel): - plugin_type: str - icon: str - logo: str - items: list[Item] +base_config = Config.get("help") diff --git a/zhenxun/builtin_plugins/help/_data_source.py b/zhenxun/builtin_plugins/help/_data_source.py index 604d72d36..68db44073 100644 --- a/zhenxun/builtin_plugins/help/_data_source.py +++ b/zhenxun/builtin_plugins/help/_data_source.py @@ -1,37 +1,83 @@ import nonebot +from zhenxun.utils.enum import PluginType +from zhenxun.models.level_user import LevelUser from zhenxun.models.plugin_info import PluginInfo from zhenxun.configs.path_config import IMAGE_PATH from zhenxun.utils.image_utils import BuildImage, ImageTemplate -from ._utils import HelpImageBuild +from .html_help import build_html_image +from .normal_help import build_normal_image +from .zhenxun_help import build_zhenxun_image +from ._config import GROUP_HELP_PATH, SIMPLE_HELP_IMAGE, base_config random_bk_path = IMAGE_PATH / "background" / "help" / "simple_help" background = IMAGE_PATH / "background" / "0.png" -async def create_help_img(bot_id: str, group_id: str | None): +driver = nonebot.get_driver() + + +async def create_help_img(bot_id: str, group_id: str | None, platform: str): """生成帮助图片 参数: bot_id: bot id group_id: 群号 + platform: 平台 + """ + help_type: str = base_config.get("type") + if help_type.lower() == "html": + result = BuildImage.open(await build_html_image(group_id)) + elif help_type.lower() == "zhenxun": + result = BuildImage.open(await build_zhenxun_image(bot_id, group_id, platform)) + else: + result = await build_normal_image(group_id) + if group_id: + await result.save(GROUP_HELP_PATH / f"{group_id}.png") + else: + await result.save(SIMPLE_HELP_IMAGE) + + +async def get_user_allow_help(user_id: str) -> list[PluginType]: + """获取用户可访问插件类型列表 + + 参数: + user_id: 用户id + + 返回: + list[PluginType]: 插件类型列表 """ - await HelpImageBuild().build_image(bot_id, group_id) + type_list = [PluginType.NORMAL, PluginType.DEPENDANT] + for level in await LevelUser.filter(user_id=user_id).values_list( + "user_level", flat=True + ): + if level > 0: # type: ignore + type_list.extend((PluginType.ADMIN, PluginType.SUPER_AND_ADMIN)) + break + if user_id in driver.config.superusers: + type_list.append(PluginType.SUPERUSER) + return type_list -async def get_plugin_help(name: str, is_superuser: bool) -> str | BuildImage: +async def get_plugin_help( + user_id: str, name: str, is_superuser: bool +) -> str | BuildImage: """获取功能的帮助信息 参数: + user_id: 用户id name: 插件名称或id is_superuser: 是否为超级用户 """ + type_list = await get_user_allow_help(user_id) if name.isdigit(): - plugin = await PluginInfo.get_or_none(id=int(name), load_status=True) + plugin = await PluginInfo.get_or_none(id=int(name), plugin_type__in=type_list) else: - plugin = await PluginInfo.get_or_none(name__iexact=name, load_status=True) + plugin = await PluginInfo.get_or_none( + name__iexact=name, load_status=True, plugin_type__in=type_list + ) if plugin: _plugin = nonebot.get_plugin_by_module_name(plugin.module_path) if _plugin and _plugin.metadata: diff --git a/zhenxun/builtin_plugins/help/_utils.py b/zhenxun/builtin_plugins/help/_utils.py index fa5b51b9a..378cacc31 100644 --- a/zhenxun/builtin_plugins/help/_utils.py +++ b/zhenxun/builtin_plugins/help/_utils.py @@ -1,332 +1,45 @@ -import os -import random +from collections.abc import Callable -import aiofiles -from nonebot_plugin_htmlrender import template_to_pic - -from zhenxun.configs.config import Config +from zhenxun.utils.enum import PluginType from zhenxun.models.plugin_info import PluginInfo -from zhenxun.utils.enum import BlockType, PluginType from zhenxun.models.group_console import GroupConsole -from zhenxun.builtin_plugins.sign_in.utils import AVA_URL -from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH, TEMPLATE_PATH -from zhenxun.utils.image_utils import BuildImage, group_image, build_sort_image - -from ._config import Item - -GROUP_HELP_PATH = DATA_PATH / "group_help" -GROUP_HELP_PATH.mkdir(exist_ok=True, parents=True) -for f in os.listdir(GROUP_HELP_PATH): - group_help_image = GROUP_HELP_PATH / f - group_help_image.unlink() - -BACKGROUND_PATH = IMAGE_PATH / "background" / "help" / "simple_help" - -LOGO_PATH = TEMPLATE_PATH / "menu" / "res" / "logo" - - -class HelpImageBuild: - def __init__(self): - self._data: list[PluginInfo] = [] - self._sort_data: dict[str, list[PluginInfo]] = {} - self._image_list = [] - self.icon2str = { - "normal": "fa fa-cog", - "原神相关": "fa fa-circle-o", - "常规插件": "fa fa-cubes", - "联系管理员": "fa fa-envelope-o", - "抽卡相关": "fa fa-credit-card-alt", - "来点好康的": "fa fa-picture-o", - "数据统计": "fa fa-bar-chart", - "一些工具": "fa fa-shopping-cart", - "商店": "fa fa-shopping-cart", - "其它": "fa fa-tags", - "群内小游戏": "fa fa-gamepad", - } - - async def sort_type(self): - """ - 对插件按照菜单类型分类 - """ - if not self._data: - self._data = await PluginInfo.filter( - menu_type__not="", - load_status=True, - plugin_type__in=[PluginType.NORMAL, PluginType.DEPENDANT], - ) - if not self._sort_data: - for plugin in self._data: - menu_type = plugin.menu_type or "normal" - if menu_type == "normal": - menu_type = "功能" - if not self._sort_data.get(menu_type): - self._sort_data[menu_type] = [] - self._sort_data[menu_type].append(plugin) - - async def build_image(self, bot_id: str, group_id: str | None): - if group_id: - help_image = GROUP_HELP_PATH / f"{group_id}.png" - else: - help_image = IMAGE_PATH / "SIMPLE_HELP.png" - build_type = Config.get_config("help", "TYPE") - if build_type == "HTML": - byt = await self.build_html_image(group_id) - async with aiofiles.open(help_image, "wb") as f: - await f.write(byt) - elif build_type == "zhenxun": - byt = await self.build_ss_image(bot_id, group_id) - async with aiofiles.open(help_image, "wb") as f: - await f.write(byt) - else: - img = await self.build_pil_image(group_id) - await img.save(help_image) - - async def build_ss_image(self, bot_id: str, group_id: str | None) -> bytes: - """构造ss帮助图片 - - 参数: - group_id: 群号 - """ - await self.sort_type() - classify = {} - for menu in self._sort_data: - self._sort_data[menu].sort(key=lambda k: len(k.name)) - for menu in self._sort_data: - for plugin in self._sort_data[menu]: - if not plugin.status: - if group_id and plugin.block_type in [ - BlockType.ALL, - BlockType.GROUP, - ]: - plugin.name = f"{plugin.name}(不可用)" - if not group_id and plugin.block_type in [ - BlockType.ALL, - BlockType.PRIVATE, - ]: - plugin.name = f"{plugin.name}(不可用)" - if not classify.get(menu): - classify[menu] = [] - classify[menu].append( - Item(plugin_name=f"{plugin.id}-{plugin.name}", sta=0) - ) - max_len = 0 - flag_index = -1 - max_data = None - plugin_list = [] - for index, plu in enumerate(classify.keys()): - data = { - "name": "主要功能" if plu in ["normal", "功能"] else plu, - "items": classify[plu], - } - if len(classify[plu]) > max_len: - max_len = len(classify[plu]) - flag_index = index - max_data = data - plugin_list.append(data) - del plugin_list[flag_index] - plugin_list.insert(0, max_data) - _data = [] - _left = 30 - for plugin in plugin_list: - _plugins = [] - width = 50 - if len(plugin["items"]) // 2 > 6: - width = 100 - _pu1 = [] - _pu2 = [] - for i in range(len(plugin["items"])): - if i % 2: - _pu1.append(plugin["items"][i]) - else: - _pu2.append(plugin["items"][i]) - _plugins = [(30, 50, _pu1), (0, 50, _pu2)] - else: - _plugins = [(_left, 100, plugin["items"])] - _left = 15 if _left == 30 else 30 - _data.append({"name": plugin["name"], "items": _plugins, "width": width}) - return await template_to_pic( - template_path=str((TEMPLATE_PATH / "ss_menu").absolute()), - template_name="main.html", - templates={"data": {"plugin_list": _data, "ava": AVA_URL.format(bot_id)}}, - pages={ - "viewport": {"width": 637, "height": 975}, - "base_url": f"file://{TEMPLATE_PATH}", - }, - wait=2, - ) - - async def build_html_image(self, group_id: str | None) -> bytes: - """构造HTML帮助图片 - - 参数: - group_id: 群号 - """ - await self.sort_type() - classify = {} - for menu in self._sort_data: - for plugin in self._sort_data[menu]: - sta = 0 - if not plugin.status: - if group_id and plugin.block_type in [ - BlockType.ALL, - BlockType.GROUP, - ]: - sta = 2 - if not group_id and plugin.block_type in [ - BlockType.ALL, - BlockType.PRIVATE, - ]: - sta = 2 - if group_id and ( - group := await GroupConsole.get_or_none(group_id=group_id) - ): - if f"{plugin.module}:super," in group.block_plugin: - sta = 2 - if f"{plugin.module}," in group.block_plugin: - sta = 1 - if classify.get(menu): - classify[menu].append(Item(plugin_name=plugin.name, sta=sta)) - else: - classify[menu] = [Item(plugin_name=plugin.name, sta=sta)] - max_len = 0 - flag_index = -1 - max_data = None - plugin_list = [] - for index, plu in enumerate(classify.keys()): - if plu in self.icon2str.keys(): - icon = self.icon2str[plu] - else: - icon = "fa fa-pencil-square-o" - logo = LOGO_PATH / random.choice(os.listdir(LOGO_PATH)) - data = { - "name": plu if plu != "normal" else "功能", - "items": classify[plu], - "icon": icon, - "logo": str(logo.absolute()), - } - if len(classify[plu]) > max_len: - max_len = len(classify[plu]) - flag_index = index - max_data = data - plugin_list.append(data) - del plugin_list[flag_index] - plugin_list.insert(0, max_data) - return await template_to_pic( - template_path=str((TEMPLATE_PATH / "menu").absolute()), - template_name="zhenxun_menu.html", - templates={"plugin_list": plugin_list}, - pages={ - "viewport": {"width": 1903, "height": 975}, - "base_url": f"file://{TEMPLATE_PATH}", - }, - wait=2, - ) - - async def build_pil_image(self, group_id: str | None) -> BuildImage: - """构造PIL帮助图片 - - 参数: - group_id: 群号 - """ - self._image_list = [] - await self.sort_type() - font_size = 24 - build_type = Config.get_config("help", "TYPE") - font = BuildImage.load_font("HYWenHei-85W.ttf", 20) - for idx, menu_type in enumerate(self._sort_data.keys()): - plugin_list = self._sort_data[menu_type] - wh_list = [ - BuildImage.get_text_size(f"{x.id}.{x.name}", font) for x in plugin_list - ] - wh_list.append(BuildImage.get_text_size(menu_type, font)) - # sum_height = sum([x[1] for x in wh_list]) - if build_type == "VV": - sum_height = 50 * len(plugin_list) + 10 - else: - sum_height = (font_size + 6) * len(plugin_list) + 10 - max_width = max(x[0] for x in wh_list) + 30 - bk = BuildImage( - max_width + 40, - sum_height + 50, - font_size=30, - color="#a7d1fc", - font="CJGaoDeGuo.otf", - ) - title_size = bk.getsize(menu_type) - max_width = max_width if max_width > title_size[0] else title_size[0] - B = BuildImage( - max_width + 40, - sum_height, - font_size=font_size, - color="black" if idx % 2 else "white", - ) - curr_h = 10 - group = await GroupConsole.get_or_none(group_id=group_id) - for i, plugin in enumerate(plugin_list): - text_color = (255, 255, 255) if idx % 2 else (0, 0, 0) - if group and f"{plugin.module}," in group.block_plugin: - text_color = (252, 75, 13) - pos = None - # 禁用状态划线 - if plugin.block_type in [BlockType.ALL, BlockType.GROUP] or ( - group and f"super:{plugin.module}," in group.block_plugin - ): - w = curr_h + int(B.getsize(plugin.name)[1] / 2) + 2 - pos = ( - 7, - w, - B.getsize(plugin.name)[0] + 35, - w, - ) - if build_type == "VV": - name_image = await self.build_name_image( # type: ignore - max_width, - plugin.name, - "white" if idx % 2 else "black", - text_color, - pos, - ) - await B.paste(name_image, (0, curr_h), center_type="width") - curr_h += name_image.h + 5 - else: - await B.text((10, curr_h), f"{plugin.id}.{plugin.name}", text_color) - if pos: - await B.line(pos, (236, 66, 7), 3) - curr_h += font_size + 5 - await bk.text((0, 14), menu_type, center_type="width") - await bk.paste(B, (0, 50)) - await bk.transparent(2) - self._image_list.append(bk) - image_group, h = group_image(self._image_list) - async def _a(image: BuildImage): - await image.filter("GaussianBlur", 5) - B = await build_sort_image( - image_group, - h, - background_path=BACKGROUND_PATH, - background_handle=_a, - ) - w = 10 - h = 10 - for msg in [ - "目前支持的功能列表:", - "可以通过 ‘帮助 [功能名称或功能Id]’ 来获取对应功能的使用方法", - ]: - text = await BuildImage.build_text_image(msg, "HYWenHei-85W.ttf", 24) - await B.paste(text, (w, h)) - h += 50 - if msg == "目前支持的功能列表:": - w += 50 - text = await BuildImage.build_text_image( - "注: 红字代表功能被群管理员禁用,红线代表功能正在维护", - "HYWenHei-85W.ttf", - 24, - (231, 74, 57), - ) - await B.paste( - text, - (300, 10), - ) - return B +async def sort_type() -> dict[str, list[PluginInfo]]: + """ + 对插件按照菜单类型分类 + """ + data = await PluginInfo.filter( + menu_type__not="", + load_status=True, + plugin_type__in=[PluginType.NORMAL, PluginType.DEPENDANT], + ) + sort_data = {} + for plugin in data: + menu_type = plugin.menu_type or "normal" + if menu_type == "normal": + menu_type = "功能" + if not sort_data.get(menu_type): + sort_data[menu_type] = [] + sort_data[menu_type].append(plugin) + return sort_data + + +async def classify_plugin(group_id: str | None, handle: Callable) -> dict[str, list]: + """对插件进行分类并判断状态 + + 参数: + group_id: 群组id + + 返回: + dict[str, list[Item]]: 分类插件数据 + """ + sort_data = await sort_type() + classify: dict[str, list] = {} + group = await GroupConsole.get_or_none(group_id=group_id) if group_id else None + for menu, value in sort_data.items(): + for plugin in value: + if not classify.get(menu): + classify[menu] = [] + classify[menu].append(handle(plugin, group)) + return classify diff --git a/zhenxun/builtin_plugins/help/detail_help.py b/zhenxun/builtin_plugins/help/detail_help.py new file mode 100644 index 000000000..e69de29bb diff --git a/zhenxun/builtin_plugins/help/html_help.py b/zhenxun/builtin_plugins/help/html_help.py new file mode 100644 index 000000000..5e88fc284 --- /dev/null +++ b/zhenxun/builtin_plugins/help/html_help.py @@ -0,0 +1,136 @@ +import os +import random + +from pydantic import BaseModel +from nonebot_plugin_htmlrender import template_to_pic + +from zhenxun.utils.enum import BlockType +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.configs.path_config import TEMPLATE_PATH +from zhenxun.models.group_console import GroupConsole + +from ._utils import classify_plugin + +LOGO_PATH = TEMPLATE_PATH / "menu" / "res" / "logo" + + +class Item(BaseModel): + plugin_name: str + """插件名称""" + sta: int + """插件状态""" + + +class PluginList(BaseModel): + plugin_type: str + """菜单名称""" + icon: str + """图标""" + logo: str + """logo""" + items: list[Item] + """插件列表""" + + +ICON2STR = { + "normal": "fa fa-cog", + "原神相关": "fa fa-circle-o", + "常规插件": "fa fa-cubes", + "联系管理员": "fa fa-envelope-o", + "抽卡相关": "fa fa-credit-card-alt", + "来点好康的": "fa fa-picture-o", + "数据统计": "fa fa-bar-chart", + "一些工具": "fa fa-shopping-cart", + "商店": "fa fa-shopping-cart", + "其它": "fa fa-tags", + "群内小游戏": "fa fa-gamepad", +} + + +def __handle_item(plugin: PluginInfo, group: GroupConsole | None) -> Item: + """构造Item + + 参数: + plugin: PluginInfo + group: 群组 + + 返回: + Item: Item + """ + sta = 0 + if not plugin.status: + if group and plugin.block_type in [ + BlockType.ALL, + BlockType.GROUP, + ]: + sta = 2 + if not group and plugin.block_type in [ + BlockType.ALL, + BlockType.PRIVATE, + ]: + sta = 2 + if group: + if f"{plugin.module}:super," in group.block_plugin: + sta = 2 + if f"{plugin.module}," in group.block_plugin: + sta = 1 + return Item(plugin_name=plugin.name, sta=sta) + + +def build_plugin_data(classify: dict[str, list[Item]]) -> list[dict[str, str]]: + """构建前端插件数据 + + 参数: + classify: 插件数据 + + 返回: + list[dict[str, str]]: 前端插件数据 + """ + lengths = [len(classify[c]) for c in classify] + index = lengths.index(max(lengths)) + menu_key = list(classify.keys())[index] + max_data = classify[menu_key] + del classify[menu_key] + plugin_list = [] + for menu_type in classify: + icon = "fa fa-pencil-square-o" + if menu_type in ICON2STR.keys(): + icon = ICON2STR[menu_type] + logo = LOGO_PATH / random.choice(os.listdir(LOGO_PATH)) + data = { + "name": menu_type if menu_type != "normal" else "功能", + "items": classify[menu_type], + "icon": icon, + "logo": str(logo.absolute()), + } + plugin_list.append(data) + plugin_list.insert( + 0, + { + "name": menu_key if menu_key != "normal" else "功能", + "items": max_data, + "icon": "fa fa-pencil-square-o", + "logo": str((LOGO_PATH / random.choice(os.listdir(LOGO_PATH))).absolute()), + }, + ) + return plugin_list + + +async def build_html_image(group_id: str | None) -> bytes: + """构造HTML帮助图片 + + 参数: + group_id: 群号 + """ + classify = await classify_plugin(group_id, __handle_item) + plugin_list = build_plugin_data(classify) + return await template_to_pic( + template_path=str((TEMPLATE_PATH / "menu").absolute()), + template_name="zhenxun_menu.html", + templates={"plugin_list": plugin_list}, + pages={ + "viewport": {"width": 1903, "height": 975}, + "base_url": f"file://{TEMPLATE_PATH}", + }, + wait=2, + ) diff --git a/zhenxun/builtin_plugins/help/normal_help.py b/zhenxun/builtin_plugins/help/normal_help.py new file mode 100644 index 000000000..5a937745a --- /dev/null +++ b/zhenxun/builtin_plugins/help/normal_help.py @@ -0,0 +1,99 @@ +from zhenxun.utils.enum import BlockType +from zhenxun.utils._build_image import BuildImage +from zhenxun.configs.path_config import IMAGE_PATH +from zhenxun.models.group_console import GroupConsole +from zhenxun.utils.image_utils import group_image, build_sort_image + +from ._utils import sort_type + +BACKGROUND_PATH = IMAGE_PATH / "background" / "help" / "simple_help" + + +async def build_normal_image(group_id: str | None) -> BuildImage: + """构造PIL帮助图片 + + 参数: + group_id: 群号 + """ + image_list = [] + font_size = 24 + font = BuildImage.load_font("HYWenHei-85W.ttf", 20) + sort_data = await sort_type() + for idx, menu_type in enumerate(sort_data): + plugin_list = sort_data[menu_type] + """拿到最大宽度和结算高度""" + wh_list = [ + BuildImage.get_text_size(f"{x.id}.{x.name}", font) for x in plugin_list + ] + wh_list.append(BuildImage.get_text_size(menu_type, font)) + sum_height = (font_size + 6) * len(plugin_list) + 10 + max_width = max(x[0] for x in wh_list) + 30 + bk = BuildImage( + max_width + 40, + sum_height + 50, + font_size=30, + color="#a7d1fc", + font="CJGaoDeGuo.otf", + ) + title_size = bk.getsize(menu_type) + max_width = max_width if max_width > title_size[0] else title_size[0] + row = BuildImage( + max_width + 40, + sum_height, + font_size=font_size, + color="black" if idx % 2 else "white", + ) + curr_h = 10 + group = await GroupConsole.get_or_none(group_id=group_id) + for _, plugin in enumerate(plugin_list): + text_color = (255, 255, 255) if idx % 2 else (0, 0, 0) + if group and f"{plugin.module}," in group.block_plugin: + text_color = (252, 75, 13) + pos = None + # 禁用状态划线 + if plugin.block_type in [BlockType.ALL, BlockType.GROUP] or ( + group and f"super:{plugin.module}," in group.block_plugin + ): + w = curr_h + int(row.getsize(plugin.name)[1] / 2) + 2 + line_width = row.getsize(plugin.name)[0] + 35 + pos = (7, w, line_width, w) + await row.text((10, curr_h), f"{plugin.id}.{plugin.name}", text_color) + if pos: + await row.line(pos, (236, 66, 7), 3) + curr_h += font_size + 5 + await bk.text((0, 14), menu_type, center_type="width") + await bk.paste(row, (0, 50)) + await bk.transparent(2) + image_list.append(bk) + image_group, h = group_image(image_list) + + async def _a(image: BuildImage): + await image.filter("GaussianBlur", 5) + + result = await build_sort_image( + image_group, + h, + background_path=BACKGROUND_PATH, + background_handle=_a, + ) + width, height = 10, 10 + for s in [ + "目前支持的功能列表:", + "可以通过 ‘帮助 [功能名称或功能Id]’ 来获取对应功能的使用方法", + ]: + text = await BuildImage.build_text_image(s, "HYWenHei-85W.ttf", 24) + await result.paste(text, (width, height)) + height += 50 + if s == "目前支持的功能列表:": + width += 50 + text = await BuildImage.build_text_image( + "注: 红字代表功能被群管理员禁用,红线代表功能正在维护", + "HYWenHei-85W.ttf", + 24, + (231, 74, 57), + ) + await result.paste( + text, + (300, 10), + ) + return result diff --git a/zhenxun/builtin_plugins/help/zhenxun_help.py b/zhenxun/builtin_plugins/help/zhenxun_help.py new file mode 100644 index 000000000..a08b49e32 --- /dev/null +++ b/zhenxun/builtin_plugins/help/zhenxun_help.py @@ -0,0 +1,142 @@ +from pydantic import BaseModel +from nonebot_plugin_htmlrender import template_to_pic + +from zhenxun.utils.enum import BlockType +from zhenxun.utils.platform import PlatformUtils +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.configs.path_config import TEMPLATE_PATH +from zhenxun.models.group_console import GroupConsole + +from ._utils import classify_plugin + + +class Item(BaseModel): + plugin_name: str + """插件名称""" + + +def __handle_item(plugin: PluginInfo, group: GroupConsole | None): + """构造Item + + 参数: + plugin: PluginInfo + group: 群组 + + 返回: + Item: Item + """ + if not plugin.status: + if plugin.block_type == BlockType.ALL: + plugin.name = f"{plugin.name}(不可用)" + elif group and plugin.block_type == BlockType.GROUP: + plugin.name = f"{plugin.name}(不可用)" + elif not group and plugin.block_type == BlockType.PRIVATE: + plugin.name = f"{plugin.name}(不可用)" + elif group and f"{plugin.module}," in group.block_plugin: + plugin.name = f"{plugin.name}(不可用)" + return Item(plugin_name=f"{plugin.id}-{plugin.name}") + + +def build_plugin_data(classify: dict[str, list[Item]]) -> list[dict[str, str]]: + """构建前端插件数据 + + 参数: + classify: 插件数据 + + 返回: + list[dict[str, str]]: 前端插件数据 + """ + + lengths = [len(classify[c]) for c in classify] + index = lengths.index(max(lengths)) + menu_key = list(classify.keys())[index] + max_data = classify[menu_key] + del classify[menu_key] + plugin_list = [ + { + "name": "主要功能" if menu in ["normal", "功能"] else menu, + "items": value, + } + for menu, value in classify.items() + ] + plugin_list = build_line_data(plugin_list) + plugin_list.insert(0, build_plugin_line(menu_key, max_data, 30, 100)) + return plugin_list + + +def build_plugin_line( + name: str, items: list, left: int, width: int | None = None +) -> dict: + """构造插件行数据 + + 参数: + name: 菜单名称 + items: 插件名称列表 + left: 左边距 + width: 总插件长度. + + 返回: + dict: 插件数据 + """ + _plugins = [] + width = width or 50 + if len(items) // 2 > 6: + width = 100 + plugin_list1 = [] + plugin_list2 = [] + for i in range(len(items)): + if i % 2: + plugin_list1.append(items[i]) + else: + plugin_list2.append(items[i]) + _plugins = [(30, 50, plugin_list1), (0, 50, plugin_list2)] + else: + _plugins = [(left, 100, items)] + return {"name": name, "items": _plugins, "width": width} + + +def build_line_data(plugin_list: list[dict]) -> list[dict]: + """构造插件数据 + + 参数: + plugin_list: 插件列表 + + 返回: + list[dict]: 插件数据 + """ + left = 30 + data = [] + for plugin in plugin_list: + data.append(build_plugin_line(plugin["name"], plugin["items"], left)) + if len(plugin["items"]) // 2 <= 6: + left = 15 if left == 30 else 30 + return data + + +async def build_zhenxun_image( + bot_id: str, group_id: str | None, platform: str +) -> bytes: + """构造真寻帮助图片 + + 参数: + bot_id: bot_id + group_id: 群号 + platform: 平台 + """ + classify = await classify_plugin(group_id, __handle_item) + plugin_list = build_plugin_data(classify) + return await template_to_pic( + template_path=str((TEMPLATE_PATH / "ss_menu").absolute()), + template_name="main.html", + templates={ + "data": { + "plugin_list": plugin_list, + "ava": PlatformUtils.get_user_avatar_url(bot_id, platform), + } + }, + pages={ + "viewport": {"width": 637, "height": 453}, + "base_url": f"file://{TEMPLATE_PATH}", + }, + wait=2, + ) diff --git a/zhenxun/builtin_plugins/help_help.py b/zhenxun/builtin_plugins/help_help.py index d472440b4..7da4e7cae 100644 --- a/zhenxun/builtin_plugins/help_help.py +++ b/zhenxun/builtin_plugins/help_help.py @@ -2,29 +2,30 @@ import random from nonebot import on_message +from nonebot.rule import to_me from nonebot.matcher import Matcher from nonebot.plugin import PluginMetadata -from nonebot.rule import to_me from nonebot_plugin_alconna import UniMsg from nonebot_plugin_session import EventSession -from zhenxun.configs.path_config import IMAGE_PATH -from zhenxun.configs.utils import PluginExtraData -from zhenxun.models.ban_console import BanConsole -from zhenxun.models.group_console import GroupConsole -from zhenxun.models.plugin_info import PluginInfo from zhenxun.services.log import logger from zhenxun.utils.enum import PluginType from zhenxun.utils.message import MessageUtils +from zhenxun.configs.utils import PluginExtraData +from zhenxun.models.ban_console import BanConsole +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.configs.path_config import IMAGE_PATH +from zhenxun.models.group_console import GroupConsole __plugin_meta__ = PluginMetadata( - name="功能名称当命令检测", + name="笨蛋检测", description="功能名称当命令检测", - usage=f"""被动""".strip(), + usage="""被动""".strip(), extra=PluginExtraData( author="HibiKier", version="0.1", plugin_type=PluginType.DEPENDANT, + menu_type="其他", ).dict(), ) @@ -57,10 +58,12 @@ async def _(matcher: Matcher, message: UniMsg, session: EventSession): if image: message_list.append(image) message_list.append( - f"桀桀桀,预判到会有 '笨蛋' 把功能名称当命令用,特地前来嘲笑!但还是好心来帮帮你啦!\n请at我发送 '帮助{plugin.name}' 或者 '帮助{plugin.id}' 来获取该功能帮助!" + "桀桀桀,预判到会有 '笨蛋' 把功能名称当命令用,特地前来嘲笑!" + f"但还是好心来帮帮你啦!\n请at我发送 '帮助{plugin.name}' 或者" + f" '帮助{plugin.id}' 来获取该功能帮助!" ) logger.info( - f"检测到功能名称当命令使用,已发送帮助信息", "功能帮助", session=session + "检测到功能名称当命令使用,已发送帮助信息", "功能帮助", session=session ) await MessageUtils.build_message(message_list).send(reply_to=True) matcher.stop_propagation() diff --git a/zhenxun/builtin_plugins/hooks/_auth_checker.py b/zhenxun/builtin_plugins/hooks/_auth_checker.py index efffa2369..687fc44d4 100644 --- a/zhenxun/builtin_plugins/hooks/_auth_checker.py +++ b/zhenxun/builtin_plugins/hooks/_auth_checker.py @@ -1,35 +1,34 @@ -from nonebot.adapters import Bot, Event -from nonebot.adapters.onebot.v11 import PokeNotifyEvent -from nonebot.exception import IgnoredException +from pydantic import BaseModel from nonebot.matcher import Matcher +from nonebot.adapters import Bot, Event from nonebot_plugin_alconna import At, UniMsg -from nonebot_plugin_session import EventSession -from pydantic import BaseModel +from nonebot.exception import IgnoredException from tortoise.exceptions import IntegrityError +from nonebot_plugin_session import EventSession +from nonebot.adapters.onebot.v11 import PokeNotifyEvent +from zhenxun.services.log import logger from zhenxun.configs.config import Config -from zhenxun.models.group_console import GroupConsole +from zhenxun.utils.message import MessageUtils from zhenxun.models.level_user import LevelUser from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.plugin_limit import PluginLimit from zhenxun.models.user_console import UserConsole -from zhenxun.services.log import logger +from zhenxun.utils.exception import InsufficientGold +from zhenxun.models.group_console import GroupConsole +from zhenxun.utils.utils import FreqLimiter, CountLimiter, UserBlockLimiter from zhenxun.utils.enum import ( BlockType, GoldHandle, + PluginType, LimitWatchType, PluginLimitType, - PluginType, ) -from zhenxun.utils.exception import InsufficientGold -from zhenxun.utils.message import MessageUtils -from zhenxun.utils.utils import CountLimiter, FreqLimiter, UserBlockLimiter base_config = Config.get("hook") class Limit(BaseModel): - limit: PluginLimit limiter: FreqLimiter | UserBlockLimiter | CountLimiter @@ -38,12 +37,11 @@ class Config: class LimitManage: + add_module = [] # noqa: RUF012 - add_module = [] - - cd_limit: dict[str, Limit] = {} - block_limit: dict[str, Limit] = {} - count_limit: dict[str, Limit] = {} + cd_limit: dict[str, Limit] = {} # noqa: RUF012 + block_limit: dict[str, Limit] = {} # noqa: RUF012 + count_limit: dict[str, Limit] = {} # noqa: RUF012 @classmethod def add_limit(cls, limit: PluginLimit): @@ -90,7 +88,7 @@ def unblock( @classmethod async def check( cls, - module: str, + module_path: str, user_id: str, group_id: str | None, channel_id: str | None, @@ -108,17 +106,17 @@ async def check( 异常: IgnoredException: IgnoredException """ - if limit_model := cls.cd_limit.get(module): + if limit_model := cls.cd_limit.get(module_path): await cls.__check(limit_model, user_id, group_id, channel_id, session) - if limit_model := cls.block_limit.get(module): + if limit_model := cls.block_limit.get(module_path): await cls.__check(limit_model, user_id, group_id, channel_id, session) - if limit_model := cls.count_limit.get(module): + if limit_model := cls.count_limit.get(module_path): await cls.__check(limit_model, user_id, group_id, channel_id, session) @classmethod async def __check( cls, - limit_model: Limit, + limit_model: Limit | None, user_id: str, group_id: str | None, channel_id: str | None, @@ -136,33 +134,34 @@ async def __check( 异常: IgnoredException: IgnoredException """ - if limit_model: - limit = limit_model.limit - limiter = limit_model.limiter - is_limit = ( - LimitWatchType.ALL - or (group_id and limit.watch_type == LimitWatchType.GROUP) - or (not group_id and limit.watch_type == LimitWatchType.USER) + if not limit_model: + return + limit = limit_model.limit + limiter = limit_model.limiter + is_limit = ( + LimitWatchType.ALL + or (group_id and limit.watch_type == LimitWatchType.GROUP) + or (not group_id and limit.watch_type == LimitWatchType.USER) + ) + key_type = user_id + if group_id and limit.watch_type == LimitWatchType.GROUP: + key_type = channel_id or group_id + if is_limit and not limiter.check(key_type): + if limit.result: + await MessageUtils.build_message(limit.result).send() + logger.debug( + f"{limit.module}({limit.limit_type}) 正在限制中...", + "HOOK", + session=session, ) - key_type = user_id - if group_id and limit.watch_type == LimitWatchType.GROUP: - key_type = channel_id or group_id - if is_limit and not limiter.check(key_type): - if limit.result: - await MessageUtils.build_message(limit.result).send() - logger.debug( - f"{limit.module}({limit.limit_type}) 正在限制中...", - "HOOK", - session=session, - ) - raise IgnoredException(f"{limit.module} 正在限制中...") - else: - if isinstance(limiter, FreqLimiter): - limiter.start_cd(key_type) - if isinstance(limiter, UserBlockLimiter): - limiter.set_true(key_type) - if isinstance(limiter, CountLimiter): - limiter.increase(key_type) + raise IgnoredException(f"{limit.module} 正在限制中...") + else: + if isinstance(limiter, FreqLimiter): + limiter.start_cd(key_type) + if isinstance(limiter, UserBlockLimiter): + limiter.set_true(key_type) + if isinstance(limiter, CountLimiter): + limiter.increase(key_type) class IsSuperuserException(Exception): @@ -196,11 +195,7 @@ def is_send_limit_message(self, plugin: PluginInfo, sid: str) -> bool: return False if plugin.plugin_type == PluginType.DEPENDANT: return False - if not self._flmt_s.check(sid): - return False - if plugin.module == "ai": - return False - return True + return plugin.module != "ai" if self._flmt_s.check(sid) else False async def auth( self, @@ -255,7 +250,7 @@ async def auth( await self.auth_limit(plugin, session) except IsSuperuserException: logger.debug( - f"超级用户或被ban跳过权限检测...", "HOOK", session=session + "超级用户或被ban跳过权限检测...", "HOOK", session=session ) except IgnoredException: is_ignore = True @@ -296,12 +291,14 @@ async def auth_limit(self, plugin: PluginInfo, session: EventSession): if not group_id: group_id = channel_id channel_id = None - limit_list: list[PluginLimit] = await plugin.plugin_limit.all() # type: ignore + limit_list: list[PluginLimit] = await plugin.plugin_limit.filter( + status=True + ).all() # type: ignore for limit in limit_list: LimitManage.add_limit(limit) if user_id: await LimitManage.check( - plugin.module, user_id, group_id, channel_id, session + plugin.module_path, user_id, group_id, channel_id, session ) async def auth_plugin( @@ -313,14 +310,13 @@ async def auth_plugin( plugin: PluginInfo session: EventSession """ - user_id = session.id1 group_id = session.id3 channel_id = session.id2 - is_poke = isinstance(event, PokeNotifyEvent) if not group_id: group_id = channel_id channel_id = None - if user_id: + if user_id := session.id1: + is_poke = isinstance(event, PokeNotifyEvent) if group_id: sid = group_id or user_id if await GroupConsole.is_super_block_plugin( @@ -393,9 +389,8 @@ async def auth_plugin( raise IgnoredException("该插件在私聊中已被禁用...") if not plugin.status and plugin.block_type == BlockType.ALL: """全局状态""" - if group_id: - if await GroupConsole.is_super_group(group_id): - raise IsSuperuserException() + if group_id and await GroupConsole.is_super_group(group_id): + raise IsSuperuserException() logger.debug( f"{plugin.name}({plugin.module}) 全局未开启此功能...", "HOOK", @@ -414,9 +409,8 @@ async def auth_admin(self, plugin: PluginInfo, session: EventSession): session: EventSession """ user_id = session.id1 - group_id = session.id3 or session.id2 if user_id and plugin.admin_level: - if group_id: + if group_id := session.id3 or session.id2: if not await LevelUser.check_level( user_id, group_id, plugin.admin_level ): @@ -426,7 +420,8 @@ async def auth_admin(self, plugin: PluginInfo, session: EventSession): await MessageUtils.build_message( [ At(flag="user", target=user_id), - f"你的权限不足喔,该功能需要的权限等级: {plugin.admin_level}", + f"你的权限不足喔," + f"该功能需要的权限等级: {plugin.admin_level}", ] ).send(reply_to=True) except Exception as e: @@ -439,22 +434,21 @@ async def auth_admin(self, plugin: PluginInfo, session: EventSession): session=session, ) raise IgnoredException("管理员权限不足...") - else: - if not await LevelUser.check_level(user_id, None, plugin.admin_level): - try: - await MessageUtils.build_message( - f"你的权限不足喔,该功能需要的权限等级: {plugin.admin_level}" - ).send() - except Exception as e: - logger.error( - "auth_admin 发送消息失败", "HOOK", session=session, e=e - ) - logger.debug( - f"{plugin.name}({plugin.module}) 管理员权限不足...", - "HOOK", - session=session, + elif not await LevelUser.check_level(user_id, None, plugin.admin_level): + try: + await MessageUtils.build_message( + f"你的权限不足喔,该功能需要的权限等级: {plugin.admin_level}" + ).send() + except Exception as e: + logger.error( + "auth_admin 发送消息失败", "HOOK", session=session, e=e ) - raise IgnoredException("权限不足") + logger.debug( + f"{plugin.name}({plugin.module}) 管理员权限不足...", + "HOOK", + session=session, + ) + raise IgnoredException("权限不足") async def auth_group( self, plugin: PluginInfo, session: EventSession, message: UniMsg @@ -466,29 +460,35 @@ async def auth_group( session: EventSession message: UniMsg """ - if group_id := session.id3 or session.id2: - text = message.extract_plain_text() - group = await GroupConsole.get_group(group_id) - if not group: - """群不存在""" - raise IgnoredException("群不存在") - if group.level < 0: - """群权限小于0""" - logger.debug( - f"群黑名单, 群权限-1...", - "HOOK", - session=session, - ) - raise IgnoredException("群黑名单") - if not group.status: - """群休眠""" - if text.strip() != "醒来": - logger.debug( - f"群休眠状态...", - "HOOK", - session=session, - ) - raise IgnoredException("群休眠状态") + if not (group_id := session.id3 or session.id2): + return + text = message.extract_plain_text() + group = await GroupConsole.get_group(group_id) + if not group: + """群不存在""" + raise IgnoredException("群不存在") + if group.level < 0: + """群权限小于0""" + logger.debug( + "群黑名单, 群权限-1...", + "HOOK", + session=session, + ) + raise IgnoredException("群黑名单") + if not group.status: + """群休眠""" + if text.strip() != "醒来": + logger.debug("群休眠状态...", "HOOK", session=session) + raise IgnoredException("群休眠状态") + if plugin.level > group.level: + """插件等级大于群等级""" + logger.debug( + f"{plugin.name}({plugin.module}) 群等级限制.." + f"该功能需要的群等级: {plugin.level}..", + "HOOK", + session=session, + ) + raise IgnoredException(f"{plugin.name}({plugin.module}) 群等级限制...") async def auth_cost( self, user: UserConsole, plugin: PluginInfo, session: EventSession @@ -512,7 +512,8 @@ async def auth_cost( except Exception as e: logger.error("auth_cost 发送消息失败", "HOOK", session=session, e=e) logger.debug( - f"{plugin.name}({plugin.module}) 金币限制..该功能需要{plugin.cost_gold}金币..", + f"{plugin.name}({plugin.module}) 金币限制.." + f"该功能需要{plugin.cost_gold}金币..", "HOOK", session=session, ) diff --git a/zhenxun/builtin_plugins/hooks/chkdsk_hook.py b/zhenxun/builtin_plugins/hooks/chkdsk_hook.py index 5fb66a09e..915a19692 100644 --- a/zhenxun/builtin_plugins/hooks/chkdsk_hook.py +++ b/zhenxun/builtin_plugins/hooks/chkdsk_hook.py @@ -2,19 +2,19 @@ from collections import defaultdict from nonebot.adapters import Event -from nonebot.adapters.onebot.v11 import Bot -from nonebot.exception import IgnoredException -from nonebot.matcher import Matcher -from nonebot.message import run_preprocessor from nonebot.typing import T_State +from nonebot.matcher import Matcher from nonebot_plugin_alconna import At +from nonebot.adapters.onebot.v11 import Bot +from nonebot.message import run_preprocessor +from nonebot.exception import IgnoredException from nonebot_plugin_session import EventSession -from zhenxun.configs.config import Config -from zhenxun.models.ban_console import BanConsole from zhenxun.services.log import logger +from zhenxun.configs.config import Config from zhenxun.utils.enum import PluginType from zhenxun.utils.message import MessageUtils +from zhenxun.models.ban_console import BanConsole malicious_check_time = Config.get_config("hook", "MALICIOUS_CHECK_TIME") malicious_ban_count = Config.get_config("hook", "MALICIOUS_BAN_COUNT") @@ -36,12 +36,12 @@ def __init__(self, default_check_time: float = 5, default_count: int = 4): self.default_check_time = default_check_time self.default_count = default_count - def add(self, key: str | int | float): + def add(self, key: str | float): if self.mint[key] == 1: self.mtime[key] = time.time() self.mint[key] += 1 - def check(self, key: str | int | float) -> bool: + def check(self, key: str | float) -> bool: if time.time() - self.mtime[key] > self.default_check_time: self.mtime[key] = time.time() self.mint[key] = 0 @@ -76,6 +76,7 @@ async def _( PluginType.HIDDEN, PluginType.DEPENDANT, PluginType.ADMIN, + PluginType.SUPERUSER, ]: return else: @@ -101,7 +102,7 @@ async def _( await MessageUtils.build_message( [ At(flag="user", target=user_id), - f"检测到恶意触发命令,您将被封禁 30 分钟", + "检测到恶意触发命令,您将被封禁 30 分钟", ] ).send() logger.debug( diff --git a/zhenxun/builtin_plugins/info/__init__.py b/zhenxun/builtin_plugins/info/__init__.py new file mode 100644 index 000000000..2d71bf8c2 --- /dev/null +++ b/zhenxun/builtin_plugins/info/__init__.py @@ -0,0 +1,56 @@ +from nonebot.adapters import Bot +from nonebot.plugin import PluginMetadata +from nonebot_plugin_session import EventSession +from nonebot_plugin_alconna import At, Args, Match, Alconna, Arparma, on_alconna + +from zhenxun.services.log import logger +from zhenxun.utils.enum import PluginType +from zhenxun.utils.depends import UserName +from zhenxun.utils.message import MessageUtils +from zhenxun.utils.platform import PlatformUtils +from zhenxun.configs.utils import PluginExtraData +from zhenxun.models.group_member_info import GroupInfoUser + +from .my_info import get_user_info + +__plugin_meta__ = PluginMetadata( + name="查看信息", + description="查看个人信息", + usage=""" + 查看个人/群组信息 + 指令: + 我的信息 ?[at] + """.strip(), + extra=PluginExtraData(author="HibiKier", version="0.1").dict(), +) + + +_matcher = on_alconna(Alconna("我的信息", Args["at_user?", At]), priority=5, block=True) + + +@_matcher.handle() +async def _( + bot: Bot, + session: EventSession, + arparma: Arparma, + at_user: Match[At], + nickname: str = UserName(), +): + user_id = session.id1 + if at_user.available: + user_id = at_user.result.target + if user := await GroupInfoUser.get_or_none( + user_id=user_id, group_id=session.id2 + ): + nickname = user.user_name + else: + nickname = user_id + if not user_id: + await MessageUtils.build_message("用户id为空...").finish(reply_to=True) + try: + result = await get_user_info(bot, user_id, session.id2, nickname) + await MessageUtils.build_message(result).send(at_sender=True) + logger.info("获取用户信息", arparma.header_result, session=session) + except Exception as e: + logger.error("获取用户信息失败", arparma.header_result, session=session, e=e) + await MessageUtils.build_message("获取用户信息失败...").finish(reply_to=True) diff --git a/zhenxun/builtin_plugins/info/my_info.py b/zhenxun/builtin_plugins/info/my_info.py new file mode 100644 index 000000000..43eda62c9 --- /dev/null +++ b/zhenxun/builtin_plugins/info/my_info.py @@ -0,0 +1,193 @@ +import random +from datetime import datetime, timedelta + +from nonebot.adapters import Bot +from tortoise.functions import Count +from tortoise.expressions import RawSQL +from nonebot_plugin_htmlrender import template_to_pic + +from zhenxun.models.sign_user import SignUser +from zhenxun.models.level_user import LevelUser +from zhenxun.models.statistics import Statistics +from zhenxun.utils.platform import PlatformUtils +from zhenxun.models.chat_history import ChatHistory +from zhenxun.models.user_console import UserConsole +from zhenxun.configs.path_config import TEMPLATE_PATH + +RACE = [ + "龙族", + "魅魔", + "森林精灵", + "血精灵", + "暗夜精灵", + "狗头人", + "狼人", + "猫人", + "猪头人", + "骷髅", + "僵尸", + "虫族", + "人类", + "天使", + "恶魔", + "甲壳虫", + "猎猫", + "人鱼", + "哥布林", + "地精", + "泰坦", + "矮人", + "山巨人", + "石巨人", +] + +SEX = ["男", "女", "雌", "雄"] + +OCC = [ + "猎人", + "战士", + "魔法师", + "狂战士", + "魔战士", + "盗贼", + "术士", + "牧师", + "骑士", + "刺客", + "游侠", + "召唤师", + "圣骑士", + "魔使", + "龙骑士", + "赏金猎手", + "吟游诗人", + "德鲁伊", + "祭司", + "符文师", + "狂暴术士", + "萨满", + "裁决者", + "角斗士", +] + +lik2level = { + 400: 8, + 270: 7, + 200: 6, + 140: 5, + 90: 4, + 50: 3, + 25: 2, + 10: 1, + 0: 0, +} + + +def get_level(impression: float) -> int: + """获取好感度等级""" + return next((level for imp, level in lik2level.items() if impression >= imp), 0) + + +async def get_chat_history( + user_id: str, group_id: str | None +) -> tuple[list[str], list[str]]: + """获取用户聊天记录 + + 参数: + user_id: 用户id + group_id: 群id + + 返回: + tuple[list[str], list[str]]: 日期列表, 次数列表 + + """ + now = datetime.now() + filter_date = now - timedelta(days=7, hours=now.hour, minutes=now.minute) + date_list = ( + await ChatHistory.filter( + user_id=user_id, group_id=group_id, create_time__gte=filter_date + ) + .annotate(date=RawSQL("DATE(create_time)"), count=Count("id")) + .group_by("date") + .values("date", "count") + ) + chart_date = [] + count_list = [] + date2cnt = {str(date["date"]): date["count"] for date in date_list} + date = now.date() + for _ in range(7): + if str(date) in date2cnt: + count_list.append(date2cnt[str(date)]) + else: + count_list.append(0) + chart_date.append(str(date)) + date -= timedelta(days=1) + for c in chart_date: + chart_date[chart_date.index(c)] = c[5:] + chart_date.reverse() + count_list.reverse() + return chart_date, count_list + + +async def get_user_info( + bot: Bot, user_id: str, group_id: str | None, nickname: str +) -> bytes: + """获取用户个人信息 + + 参数: + bot: Bot + user_id: 用户id + group_id: 群id + nickname: 用户昵称 + + 返回: + bytes: 图片数据 + """ + platform = PlatformUtils.get_platform(bot) or "" + ava_url = PlatformUtils.get_user_avatar_url(user_id, platform) + user = await UserConsole.get_user(user_id, platform) + level = await LevelUser.get_user_level(user_id, group_id) + sign_level = 0 + if sign_user := await SignUser.get_or_none(user_id=user_id): + sign_level = get_level(float(sign_user.impression)) + chat_count = await ChatHistory.filter(user_id=user_id, group_id=group_id).count() + stat_count = await Statistics.filter(user_id=user_id, group_id=group_id).count() + select_index = ["" for _ in range(9)] + select_index[sign_level] = "select" + uid = f"{user.uid}".rjust(8, "0") + uid = f"{uid[:4]} {uid[4:]}" + now = datetime.now() + weather = "moon" if now.hour < 6 or now.hour > 19 else "sun" + chart_date, count_list = await get_chat_history(user_id, group_id) + data = { + "date": now.date(), + "weather": weather, + "ava_url": ava_url, + "nickname": nickname, + "title": "勇 者", + "race": random.choice(RACE), + "sex": random.choice(SEX), + "occ": random.choice(OCC), + "uid": uid, + "description": "这是一个传奇的故事," + "人类的赞歌是勇气的赞歌,人类的伟大是勇气的伟译。", + "sign_level": sign_level, + "level": level, + "gold": user.gold, + "prop": len(user.props), + "call": stat_count, + "say": chat_count, + "select_index": select_index, + "chart_date": chart_date, + "count_list": count_list, + } + return await template_to_pic( + template_path=str((TEMPLATE_PATH / "my_info").absolute()), + template_name="main.html", + templates={"data": data}, + pages={ + "viewport": {"width": 1754, "height": 1240}, + "base_url": f"file://{TEMPLATE_PATH}", + }, + wait=2, + ) diff --git a/zhenxun/builtin_plugins/init/__init__.py b/zhenxun/builtin_plugins/init/__init__.py index f296b1a98..e48b89bb2 100644 --- a/zhenxun/builtin_plugins/init/__init__.py +++ b/zhenxun/builtin_plugins/init/__init__.py @@ -39,5 +39,5 @@ async def _(bot: Bot): await GroupConsole.filter(group_id__in=update_id).update(group_flag=1) logger.debug( f"更新Bot: {bot.self_id} 的群认证完成,共创建 {len(create_list)} 条数据," - "共修改 {len(update_id)} 条数据..." + f"共修改 {len(update_id)} 条数据..." ) diff --git a/zhenxun/builtin_plugins/init/init_config.py b/zhenxun/builtin_plugins/init/init_config.py index 26534d4d5..a2a7f3324 100644 --- a/zhenxun/builtin_plugins/init/init_config.py +++ b/zhenxun/builtin_plugins/init/init_config.py @@ -1,16 +1,16 @@ from pathlib import Path import nonebot -from nonebot import get_loaded_plugins -from nonebot.drivers import Driver -from nonebot.plugin import Plugin from ruamel.yaml import YAML +from nonebot.plugin import Plugin +from nonebot.drivers import Driver +from nonebot import get_loaded_plugins from ruamel.yaml.comments import CommentedMap +from zhenxun.services.log import logger from zhenxun.configs.config import Config -from zhenxun.configs.path_config import DATA_PATH from zhenxun.configs.utils import RegisterConfig -from zhenxun.services.log import logger +from zhenxun.configs.path_config import DATA_PATH _yaml = YAML(pure=True) _yaml.allow_unicode = True @@ -72,15 +72,14 @@ def _generate_simple_config(): Config.set_config(module, k, _data[module][k]) _tmp_data[module][k] = Config.get_config(module, k) except AttributeError as e: - raise AttributeError(f"{e}\n" + "可能为config.yaml配置文件填写不规范") + raise AttributeError(f"{e}\n可能为config.yaml配置文件填写不规范") from e Config.save() temp_file = DATA_PATH / "temp_config.yaml" # 重新生成简易配置文件 try: with open(temp_file, "w", encoding="utf8") as wf: - # yaml.dump(_tmp_data, wf, Dumper=yaml.RoundTripDumper, allow_unicode=True) _yaml.dump(_tmp_data, wf) - with open(temp_file, "r", encoding="utf8") as rf: + with open(temp_file, encoding="utf8") as rf: _data = _yaml.load(rf) # 添加注释 for module in _data.keys(): @@ -93,7 +92,7 @@ def _generate_simple_config(): with SIMPLE_CONFIG_FILE.open("w", encoding="utf8") as wf: _yaml.dump(_data, wf) except Exception as e: - logger.error(f"生成简易配置注释错误...", e=e) + logger.error("生成简易配置注释错误...", e=e) if temp_file.exists(): temp_file.unlink() diff --git a/zhenxun/builtin_plugins/init/init_plugin.py b/zhenxun/builtin_plugins/init/init_plugin.py index 8581dafe5..fff731294 100644 --- a/zhenxun/builtin_plugins/init/init_plugin.py +++ b/zhenxun/builtin_plugins/init/init_plugin.py @@ -2,9 +2,9 @@ import aiofiles import ujson as json from ruamel.yaml import YAML -from nonebot.plugin import Plugin from nonebot.drivers import Driver from nonebot import get_loaded_plugins +from nonebot.plugin import Plugin, PluginMetadata from zhenxun.services.log import logger from zhenxun.models.task_info import TaskInfo @@ -21,6 +21,8 @@ PluginLimitType, ) +from .manager import manager + _yaml = YAML(pure=True) _yaml.allow_unicode = True _yaml.indent = 2 @@ -41,64 +43,68 @@ async def _handle_setting( plugin_list: 插件列表 limit_list: 插件限制列表 """ - if metadata := plugin.metadata: - extra = metadata.extra - extra_data = PluginExtraData(**extra) - logger.debug(f"{metadata.name}:{plugin.name} -> {extra}", "初始化插件数据") - setting = extra_data.setting or PluginSetting() - if metadata.type == "library": - extra_data.plugin_type = PluginType.HIDDEN - if ( - extra_data.plugin_type - == PluginType.HIDDEN - # and extra_data.plugin_type != "功能" - ): - extra_data.menu_type = "" - plugin_list.append( - PluginInfo( + metadata = plugin.metadata + if not metadata: + if not plugin.sub_plugins: + return + """父插件""" + metadata = PluginMetadata(name=plugin.name, description="", usage="") + extra = metadata.extra + extra_data = PluginExtraData(**extra) + logger.debug(f"{metadata.name}:{plugin.name} -> {extra}", "初始化插件数据") + setting = extra_data.setting or PluginSetting() + if metadata.type == "library": + extra_data.plugin_type = PluginType.HIDDEN + if extra_data.plugin_type == PluginType.HIDDEN: + extra_data.menu_type = "" + if plugin.sub_plugins: + extra_data.plugin_type = PluginType.PARENT + plugin_list.append( + PluginInfo( + module=plugin.name, + module_path=plugin.module_name, + name=metadata.name, + author=extra_data.author, + version=extra_data.version, + level=setting.level, + default_status=setting.default_status, + limit_superuser=setting.limit_superuser, + menu_type=extra_data.menu_type, + cost_gold=setting.cost_gold, + plugin_type=extra_data.plugin_type, + admin_level=extra_data.admin_level, + parent=(plugin.parent_plugin.module_name if plugin.parent_plugin else None), + ) + ) + if extra_data.limits: + limit_list.extend( + PluginLimit( module=plugin.name, module_path=plugin.module_name, - name=metadata.name, - author=extra_data.author, - version=extra_data.version, - level=setting.level, - default_status=setting.default_status, - limit_superuser=setting.limit_superuser, - menu_type=extra_data.menu_type, - cost_gold=setting.cost_gold, - plugin_type=extra_data.plugin_type, - admin_level=extra_data.admin_level, + limit_type=limit._type, + watch_type=limit.watch_type, + status=limit.status, + check_type=limit.check_type, + result=limit.result, + cd=getattr(limit, "cd", None), + max_count=getattr(limit, "max_count", None), ) + for limit in extra_data.limits ) - if extra_data.limits: - limit_list.extend( - PluginLimit( - module=plugin.name, - module_path=plugin.module_name, - limit_type=limit._type, - watch_type=limit.watch_type, - status=limit.status, - check_type=limit.check_type, - result=limit.result, - cd=getattr(limit, "cd", None), - max_count=getattr(limit, "max_count", None), - ) - for limit in extra_data.limits - ) - if extra_data.tasks: - task_list.extend( - ( - task.create_status, - TaskInfo( - module=task.module, - name=task.name, - status=task.status, - run_time=task.run_time, - default_status=task.default_status, - ), - ) - for task in extra_data.tasks + if extra_data.tasks: + task_list.extend( + ( + task.create_status, + TaskInfo( + module=task.module, + name=task.name, + status=task.status, + run_time=task.run_time, + default_status=task.default_status, + ), ) + for task in extra_data.tasks + ) @driver.on_startup @@ -115,8 +121,7 @@ async def _(): module2id = {m["module_path"]: m["id"] for m in module_list} for plugin in get_loaded_plugins(): load_plugin.append(plugin.module_name) - if plugin.metadata: - await _handle_setting(plugin, plugin_list, limit_list, task_list) + await _handle_setting(plugin, plugin_list, limit_list, task_list) create_list = [] update_list = [] for plugin in plugin_list: @@ -145,23 +150,25 @@ async def _(): # ["name", "author", "version", "admin_level", "plugin_type"], # 10, # ) - if limit_list: - limit_create = [] - plugins = [] - if module_path_list := [limit.module_path for limit in limit_list]: - plugins = await PluginInfo.filter(module_path__in=module_path_list).all() - if plugins: - for limit in limit_list: - if lmt := [p for p in plugins if p.module_path == limit.module_path]: - plugin = lmt[0] - limit_type_list = [ - _limit.limit_type for _limit in await plugin.plugin_limit.all() # type: ignore - ] - if limit.limit_type not in limit_type_list: - limit.plugin = plugin - limit_create.append(limit) - if limit_create: - await PluginLimit.bulk_create(limit_create, 10) + # for limit in limit_list: + # limit_create = [] + # plugins = [] + # if module_path_list := [limit.module_path for limit in limit_list]: + # plugins = await PluginInfo.get_plugins(module_path__in=module_path_list) + # if plugins: + # for limit in limit_list: + # if lmt := [p for p in plugins if p.module_path == limit.module_path]: + # plugin = lmt[0] + # """不在数据库中""" + # limit_type_list = [ + # _limit.limit_type + # for _limit in await plugin.plugin_limit.all() # type: ignore + # ] + # if limit.limit_type not in limit_type_list: + # limit.plugin = plugin + # limit_create.append(limit) + # if limit_create: + # await PluginLimit.bulk_create(limit_create, 10) if task_list: module_dict = { t[1]: t[0] for t in await TaskInfo.all().values_list("id", "module") @@ -192,10 +199,18 @@ async def _(): await data_migration() await PluginInfo.filter(module_path__in=load_plugin).update(load_status=True) await PluginInfo.filter(module_path__not_in=load_plugin).update(load_status=False) + manager.init() + if limit_list: + for limit in limit_list: + if not manager.exist(limit.module_path, limit.limit_type): + """不存在,添加""" + manager.add(limit.module_path, limit) + manager.save_file() + await manager.load_to_db() async def data_migration(): - await limit_migration() + # await limit_migration() await plugin_migration() await group_migration() diff --git a/zhenxun/builtin_plugins/init/manager.py b/zhenxun/builtin_plugins/init/manager.py new file mode 100644 index 000000000..05b4779d2 --- /dev/null +++ b/zhenxun/builtin_plugins/init/manager.py @@ -0,0 +1,417 @@ +from copy import deepcopy + +from ruamel.yaml import YAML + +from zhenxun.services.log import logger +from zhenxun.configs.path_config import DATA_PATH +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.models.plugin_limit import PluginLimit +from zhenxun.utils.enum import BlockType, LimitCheckType, PluginLimitType +from zhenxun.configs.utils import BaseBlock, PluginCdBlock, PluginCountBlock + +_yaml = YAML(pure=True) +_yaml.indent = 2 +_yaml.allow_unicode = True + + +CD_TEST = """需要cd的功能 +自定义的功能需要cd也可以在此配置 +key:模块名称 +cd:cd 时长(秒) +status:此限制的开关状态 +check_type:'PRIVATE'/'GROUP'/'ALL',限制私聊/群聊/全部 +watch_type:监听对象,以user_id或group_id作为键来限制,'USER':用户id,'GROUP':群id + 示例:'USER':用户N秒内触发1次,'GROUP':群N秒内触发1次 +result:回复的话,可以添加[at],[uname],[nickname]来对应艾特,用户群名称,昵称系统昵称 +result 为 "" 或 None 时则不回复 +result示例:"[uname]你冲的太快了,[nickname]先生,请稍后再冲[at]" +result回复:"老色批你冲的太快了,欧尼酱先生,请稍后再冲@老色批" + 用户昵称↑ 昵称系统的昵称↑ 艾特用户↑""" + + +BLOCK_TEST = """用户调用阻塞 +即 当用户调用此功能还未结束时 +用发送消息阻止用户重复调用此命令直到该命令结束 +key:模块名称 +status:此限制的开关状态 +check_type:'PRIVATE'/'GROUP'/'ALL',限制私聊/群聊/全部 +watch_type:监听对象,以user_id或group_id作为键来限制,'USER':用户id,'GROUP':群id + 示例:'USER':阻塞用户,'group':阻塞群聊 +result:回复的话,可以添加[at],[uname],[nickname]来对应艾特,用户群名称,昵称系统昵称 +result 为 "" 或 None 时则不回复 +result示例:"[uname]你冲的太快了,[nickname]先生,请稍后再冲[at]" +result回复:"老色批你冲的太快了,欧尼酱先生,请稍后再冲@老色批" + 用户昵称↑ 昵称系统的昵称↑ 艾特用户↑""" + +COUNT_TEST = """命令每日次数限制 +即 用户/群聊 每日可调用命令的次数 [数据内存存储,重启将会重置] +每日调用直到 00:00 刷新 +key:模块名称 +max_count: 每日调用上限 +status:此限制的开关状态 +watch_type:监听对象,以user_id或group_id作为键来限制,'USER':用户id,'GROUP':群id + 示例:'USER':用户上限,'group':群聊上限 +result:回复的话,可以添加[at],[uname],[nickname]来对应艾特,用户群名称,昵称系统昵称 +result 为 "" 或 None 时则不回复 +result示例:"[uname]你冲的太快了,[nickname]先生,请稍后再冲[at]" +result回复:"老色批你冲的太快了,欧尼酱先生,请稍后再冲@老色批" + 用户昵称↑ 昵称系统的昵称↑ 艾特用户↑""" + + +class Manager: + """ + 插件命令 cd 管理器 + """ + + def __init__(self): + self.cd_file = DATA_PATH / "configs" / "plugins2cd.yaml" + self.block_file = DATA_PATH / "configs" / "plugins2block.yaml" + self.count_file = DATA_PATH / "configs" / "plugins2count.yaml" + self.cd_data = {} + self.block_data = {} + self.count_data = {} + + def add( + self, + module_path: str, + data: BaseBlock | PluginCdBlock | PluginCountBlock | PluginLimit, + ): + """添加限制""" + if isinstance(data, PluginLimit): + check_type = BlockType.ALL + if LimitCheckType.GROUP == data.check_type: + check_type = BlockType.GROUP + elif LimitCheckType.PRIVATE == data.check_type: + check_type = BlockType.PRIVATE + if data.limit_type == PluginLimitType.CD: + data = PluginCdBlock( + status=data.status, + check_type=check_type, + watch_type=data.watch_type, + result=data.result, + cd=data.cd, + ) + elif data.limit_type == PluginLimitType.BLOCK: + data = BaseBlock( + status=data.status, + check_type=check_type, + watch_type=data.watch_type, + result=data.result, + ) + elif data.limit_type == PluginLimitType.COUNT: + data = PluginCountBlock( + status=data.status, + watch_type=data.watch_type, + result=data.result, + max_count=data.max_count, + ) + if isinstance(data, PluginCdBlock): + self.cd_data[module_path] = data + elif isinstance(data, PluginCountBlock): + self.count_data[module_path] = data + elif isinstance(data, BaseBlock): + self.block_data[module_path] = data + + def exist(self, module_path: str, type: PluginLimitType): + """是否存在""" + if type == PluginLimitType.CD: + return module_path in self.cd_data + elif type == PluginLimitType.BLOCK: + return module_path in self.block_data + elif type == PluginLimitType.COUNT: + return module_path in self.count_data + + def init(self): + if not self.cd_file.exists(): + self.save_cd_file() + if not self.block_file.exists(): + self.save_block_file() + if not self.count_file.exists(): + self.save_count_file() + self.__load_file() + + def __load_file(self): + self.__load_block_file() + self.__load_cd_file() + self.__load_count_file() + + def save_file(self): + """保存文件""" + self.save_cd_file() + self.save_block_file() + self.save_count_file() + + def save_cd_file(self): + """保存文件""" + self._extracted_from_save_file_3("PluginCdLimit", CD_TEST, self.cd_data) + + def save_block_file(self): + """保存文件""" + self._extracted_from_save_file_3( + "PluginBlockLimit", BLOCK_TEST, self.block_data + ) + + def save_count_file(self): + """保存文件""" + self._extracted_from_save_file_3( + "PluginCountLimit", COUNT_TEST, self.count_data + ) + + def _extracted_from_save_file_3(self, type_: str, after: str, data: dict): + """保存文件 + + 参数: + type_: 类型参数 + after: 备注 + """ + temp_data = deepcopy(data) + if not temp_data: + temp_data = { + "test": { + "status": False, + "check_type": "ALL", + "limit_type": "USER", + "result": "你冲的太快了,请稍后再冲", + } + } + if type_ == "PluginCdLimit": + temp_data["test"]["cd"] = 5 + elif type_ == "PluginCountLimit": + temp_data["test"]["max_count"] = 5 + del temp_data["test"]["check_type"] + else: + for v in temp_data: + temp_data[v] = temp_data[v].dict() + if check_type := temp_data[v].get("check_type"): + temp_data[v]["check_type"] = str(check_type) + if watch_type := temp_data[v].get("watch_type"): + temp_data[v]["watch_type"] = str(watch_type) + if type_ == "PluginCountLimit": + del temp_data[v]["check_type"] + file = self.block_file + if type_ == "PluginCdLimit": + file = self.cd_file + elif type_ == "PluginCountLimit": + file = self.count_file + with open(file, "w", encoding="utf8") as f: + _yaml.dump({type_: temp_data}, f) + with open(file, encoding="utf8") as rf: + _data = _yaml.load(rf) + _data.yaml_set_comment_before_after_key(after=after, key=type_) + with open(file, "w", encoding="utf8") as wf: + _yaml.dump(_data, wf) + + def __load_cd_file(self): + self.cd_data: dict[str, PluginCdBlock] = {} + if self.cd_file.exists(): + with open(self.cd_file, encoding="utf8") as f: + temp = _yaml.load(f) + if "PluginCdLimit" in temp.keys(): + for k, v in temp["PluginCdLimit"].items(): + self.cd_data[k] = PluginCdBlock.parse_obj(v) + + def __load_block_file(self): + self.block_data: dict[str, BaseBlock] = {} + if self.block_file.exists(): + with open(self.block_file, encoding="utf8") as f: + temp = _yaml.load(f) + if "PluginBlockLimit" in temp.keys(): + for k, v in temp["PluginBlockLimit"].items(): + self.block_data[k] = BaseBlock.parse_obj(v) + + def __load_count_file(self): + self.count_data: dict[str, PluginCountBlock] = {} + if self.count_file.exists(): + with open(self.count_file, encoding="utf8") as f: + temp = _yaml.load(f) + if "PluginCountLimit" in temp.keys(): + for k, v in temp["PluginCountLimit"].items(): + self.count_data[k] = PluginCountBlock.parse_obj(v) + + def __replace_data( + self, + db_data: PluginLimit | None, + limit: PluginCdBlock | BaseBlock | PluginCountBlock, + ) -> PluginLimit: + """替换数据""" + if not db_data: + db_data = PluginLimit() + db_data.status = limit.status + check_type = LimitCheckType.ALL + if BlockType.GROUP == limit.check_type: + check_type = LimitCheckType.GROUP + elif BlockType.PRIVATE == limit.check_type: + check_type = LimitCheckType.PRIVATE + db_data.check_type = check_type + db_data.watch_type = limit.watch_type + db_data.result = limit.result or "" + return db_data + + def __set_data( + self, + k: str, + db_data: PluginLimit | None, + limit: PluginCdBlock | BaseBlock | PluginCountBlock, + limit_type: PluginLimitType, + module2plugin: dict[str, PluginInfo], + ) -> tuple[PluginLimit, bool]: + """设置数据 + + 参数: + k: 模块名 + db_data: 数据库数据 + limit: 文件数据 + limit_type: 限制类型 + module2plugin: 模块:插件信息 + + 返回: + tuple[PluginLimit, bool]: PluginLimit,是否创建 + """ + if not db_data: + return ( + PluginLimit( + module=k.split(".")[-1], + module_path=k, + limit_type=limit_type, + plugin=module2plugin.get(k), + cd=getattr(limit, "cd", None), + max_count=getattr(limit, "max_count", None), + status=limit.status, + check_type=limit.check_type, + watch_type=limit.watch_type, + result=limit.result, + ), + True, + ) + db_data = self.__replace_data(db_data, limit) + if limit_type == PluginLimitType.CD: + db_data.cd = limit.cd # type: ignore + if limit_type == PluginLimitType.COUNT: + db_data.max_count = limit.max_count # type: ignore + return db_data, False + + def __get_file_data(self, limit_type: PluginLimitType) -> dict: + """获取文件数据 + + 参数: + limit_type: 限制类型 + + 返回: + dict: 文件数据 + """ + if limit_type == PluginLimitType.CD: + return self.cd_data + elif limit_type == PluginLimitType.COUNT: + return self.count_data + else: + return self.block_data + + def __set_db_limits( + self, + db_limits: list[PluginLimit], + module2plugin: dict[str, PluginInfo], + limit_type: PluginLimitType, + ) -> tuple[list[PluginLimit], list[PluginLimit], list[int]]: + """更新cd限制数据 + + 参数: + db_limits: 数据库limits + module2plugin: 模块:插件信息 + + 返回: + tuple[list[PluginLimit], list[PluginLimit]]: 创建列表,更新列表 + """ + update_list = [] + create_list = [] + delete_list = [] + db_type_limits = [ + limit for limit in db_limits if limit.limit_type == limit_type + ] + if data := self.__get_file_data(limit_type): + db_type_limit_modules = [ + (limit.module_path, limit.id) for limit in db_type_limits + ] + delete_list.extend( + id + for module_path, id in db_type_limit_modules + if module_path not in data.keys() + ) + for k, v in data.items(): + if not module2plugin.get(k): + if k != "test": + logger.warning( + f"插件模块 {k} 未加载,已过滤当前 {v._type} 限制..." + ) + continue + db_data = [limit for limit in db_type_limits if limit.module_path == k] + db_data, is_create = self.__set_data( + k, db_data[0] if db_data else None, v, limit_type, module2plugin + ) + if is_create: + create_list.append(db_data) + else: + update_list.append(db_data) + else: + delete_list = [limit.id for limit in db_type_limits] + return create_list, update_list, delete_list + + async def __set_all_limit( + self, + ) -> tuple[list[PluginLimit], list[PluginLimit], list[int]]: + """获取所有插件限制数据 + + 返回: + tuple[list[PluginLimit], list[PluginLimit]]: 创建列表,更新列表 + """ + db_limits = await PluginLimit.all() + modules = set( + list(self.cd_data.keys()) + + list(self.block_data.keys()) + + list(self.count_data.keys()) + ) + plugins = await PluginInfo.get_plugins(module_path__in=modules) + module2plugin = {p.module_path: p for p in plugins} + create_list, update_list, delete_list = self.__set_db_limits( + db_limits, module2plugin, PluginLimitType.CD + ) + create_list1, update_list1, delete_list1 = self.__set_db_limits( + db_limits, module2plugin, PluginLimitType.COUNT + ) + create_list2, update_list2, delete_list2 = self.__set_db_limits( + db_limits, module2plugin, PluginLimitType.BLOCK + ) + all_create = create_list + create_list1 + create_list2 + all_update = update_list + update_list1 + update_list2 + all_delete = delete_list + delete_list1 + delete_list2 + return all_create, all_update, all_delete + + async def load_to_db(self): + """读取配置文件""" + + create_list, update_list, delete_list = await self.__set_all_limit() + if create_list: + await PluginLimit.bulk_create(create_list) + if update_list: + for limit in update_list: + await limit.save( + update_fields=[ + "status", + "check_type", + "watch_type", + "result", + "cd", + "max_count", + ] + ) + # TODO: tortoise.exceptions.OperationalError:syntax error at or near "GROUP" + # await PluginLimit.bulk_update( + # update_list, + # ["status", "check_type", "watch_type", "result", "cd", "max_count"], + # ) + if delete_list: + await PluginLimit.filter(id__in=delete_list).delete() + cnt = await PluginLimit.filter(status=True).count() + logger.info(f"已经加载 {cnt} 个插件限制.") + + +manager = Manager() diff --git a/zhenxun/builtin_plugins/platform/qq/exception.py b/zhenxun/builtin_plugins/platform/qq/exception.py new file mode 100644 index 000000000..87e146767 --- /dev/null +++ b/zhenxun/builtin_plugins/platform/qq/exception.py @@ -0,0 +1,11 @@ +class ForceAddGroupError(Exception): + """ + 强制拉群 + """ + + def __init__(self, info: str): + super().__init__(self) + self._info = info + + def get_info(self) -> str: + return self._info diff --git a/zhenxun/builtin_plugins/platform/qq/group_handle.py b/zhenxun/builtin_plugins/platform/qq/group_handle.py index 148500e61..2e10b79a3 100644 --- a/zhenxun/builtin_plugins/platform/qq/group_handle.py +++ b/zhenxun/builtin_plugins/platform/qq/group_handle.py @@ -105,7 +105,7 @@ @group_increase_handle.handle() async def _(bot: Bot, event: GroupIncreaseNoticeEvent | GroupMemberIncreaseEvent): - superuser = BotConfig.get_superuser("qq") + superusers = BotConfig.get_superuser("qq") user_id = str(event.user_id) group_id = str(event.group_id) if user_id == bot.self_id: @@ -123,10 +123,11 @@ async def _(bot: Bot, event: GroupIncreaseNoticeEvent | GroupMemberIncreaseEvent group_id=event.group_id, message=result_msg ) await bot.set_group_leave(group_id=event.group_id) - await bot.send_private_msg( - user_id=int(superuser), - message=f"触发强制入群保护,已成功退出群聊 {group_id}...", - ) + if superusers: + await bot.send_private_msg( + user_id=int(superusers[0]), + message=f"触发强制入群保护,已成功退出群聊 {group_id}...", + ) logger.info( "强制拉群或未有群信息,退出群聊成功", "入群检测", @@ -144,10 +145,12 @@ async def _(bot: Bot, event: GroupIncreaseNoticeEvent | GroupMemberIncreaseEvent group_id=event.group_id, e=e, ) - await bot.send_private_msg( - user_id=int(superuser), - message=f"触发强制入群保护,退出群聊 {event.group_id} 失败...", - ) + if superusers: + await bot.send_private_msg( + user_id=int(superusers[0]), + message="触发强制入群保护," + f"退出群聊 {event.group_id} 失败...", + ) await GroupConsole.filter(group_id=group_id).delete() else: """允许群组并设置群认证,默认群功能开关""" @@ -274,8 +277,8 @@ async def _(bot: Bot, event: GroupDecreaseNoticeEvent | GroupMemberDecreaseEvent operator_name = "None" group = await GroupConsole.filter(group_id=str(group_id)).first() group_name = group.group_name if group else "" - if superuser := BotConfig.get_superuser("qq"): - coffee = int(superuser) + if superusers := BotConfig.get_superuser("qq"): + coffee = int(superusers[0]) await bot.send_private_msg( user_id=coffee, message=f"****呜..一份踢出报告****\n" diff --git a/zhenxun/builtin_plugins/platform/qq/group_handle/__init__.py b/zhenxun/builtin_plugins/platform/qq/group_handle/__init__.py new file mode 100644 index 000000000..8e2ac0400 --- /dev/null +++ b/zhenxun/builtin_plugins/platform/qq/group_handle/__init__.py @@ -0,0 +1,125 @@ +from nonebot.adapters import Bot +from nonebot import on_notice, on_request +from nonebot.plugin import PluginMetadata +from nonebot.adapters.onebot.v11 import ( + GroupDecreaseNoticeEvent, + GroupIncreaseNoticeEvent, +) +from nonebot.adapters.onebot.v12 import ( + GroupMemberDecreaseEvent, + GroupMemberIncreaseEvent, +) + +from zhenxun.utils.enum import PluginType +from zhenxun.utils.platform import PlatformUtils +from zhenxun.utils.common_utils import CommonUtils +from zhenxun.configs.config import Config, BotConfig +from zhenxun.models.group_console import GroupConsole +from zhenxun.configs.utils import Task, RegisterConfig, PluginExtraData + +from .data_source import GroupManager +from ..exception import ForceAddGroupError + +__plugin_meta__ = PluginMetadata( + name="QQ群事件处理", + description="群事件处理", + usage="", + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.HIDDEN, + configs=[ + RegisterConfig( + module="invite_manager", + key="message", + value=f"请不要未经同意就拉{BotConfig.self_nickname}入群!告辞!", + help="强制拉群后进群回复的内容", + ), + RegisterConfig( + module="invite_manager", + key="flag", + value=True, + help="强制拉群后进群退出并回复内容", + default_value=True, + type=bool, + ), + RegisterConfig( + module="invite_manager", + key="welcome_msg_cd", + value=5, + help="群欢迎消息cd", + default_value=5, + type=int, + ), + RegisterConfig( + module="_task", + key="DEFAULT_GROUP_WELCOME", + value=True, + help="被动 进群欢迎 进群默认开关状态", + default_value=True, + type=bool, + ), + RegisterConfig( + module="_task", + key="DEFAULT_REFUND_GROUP_REMIND", + value=True, + help="被动 退群提醒 进群默认开关状态", + default_value=True, + type=bool, + ), + ], + tasks=[ + Task(module="group_welcome", name="进群欢迎"), + Task(module="refund_group_remind", name="退群提醒"), + ], + ).dict(), +) + + +base_config = Config.get("invite_manager") + + +limit_cd = base_config.get("welcome_msg_cd") + + +group_increase_handle = on_notice(priority=1, block=False) +"""群员增加处理""" +group_decrease_handle = on_notice(priority=1, block=False) +"""群员减少处理""" +add_group = on_request(priority=1, block=False) +"""加群同意请求""" + + +@group_increase_handle.handle() +async def _(bot: Bot, event: GroupIncreaseNoticeEvent | GroupMemberIncreaseEvent): + user_id = str(event.user_id) + group_id = str(event.group_id) + if user_id == bot.self_id: + """新成员为bot本身""" + group, _ = await GroupConsole.get_or_create( + group_id=group_id, channel_id__isnull=True + ) + if group.group_flag == 0: + try: + await GroupManager.add_bot(bot, str(event.operator_id), group_id, group) + except ForceAddGroupError as e: + await PlatformUtils.send_superuser(bot, e.get_info()) + else: + await GroupManager.add_user(bot, user_id, group_id) + + +@group_decrease_handle.handle() +async def _(bot: Bot, event: GroupDecreaseNoticeEvent | GroupMemberDecreaseEvent): + user_id = str(event.user_id) + group_id = str(event.group_id) + if event.sub_type == "kick_me": + """踢出Bot""" + await GroupManager.kick_bot(bot, user_id, group_id) + elif event.sub_type in ["leave", "kick"]: + result = await GroupManager.run_user( + bot, user_id, group_id, str(event.operator_id), event.sub_type + ) + if result and not await CommonUtils.task_is_block( + "refund_group_remind", group_id + ): + await group_decrease_handle.send(result) diff --git a/zhenxun/builtin_plugins/platform/qq/group_handle/data_source.py b/zhenxun/builtin_plugins/platform/qq/group_handle/data_source.py new file mode 100644 index 000000000..87b34c825 --- /dev/null +++ b/zhenxun/builtin_plugins/platform/qq/group_handle/data_source.py @@ -0,0 +1,294 @@ +import os +import re +import random +from pathlib import Path +from datetime import datetime + +import ujson as json +from nonebot.adapters import Bot +from nonebot_plugin_alconna import At + +from zhenxun.services.log import logger +from zhenxun.configs.config import Config +from zhenxun.utils.utils import FreqLimiter +from zhenxun.utils.message import MessageUtils +from zhenxun.models.fg_request import FgRequest +from zhenxun.models.level_user import LevelUser +from zhenxun.utils.enum import RequestHandleType +from zhenxun.utils.platform import PlatformUtils +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.utils.common_utils import CommonUtils +from zhenxun.models.group_console import GroupConsole +from zhenxun.models.group_member_info import GroupInfoUser +from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH + +from ..exception import ForceAddGroupError + +base_config = Config.get("invite_manager") + +limit_cd = base_config.get("welcome_msg_cd") + +WELCOME_PATH = DATA_PATH / "welcome_message" / "qq" + +DEFAULT_IMAGE_PATH = IMAGE_PATH / "qxz" + + +class GroupManager: + _flmt = FreqLimiter(limit_cd) + + @classmethod + async def __handle_add_group( + cls, bot: Bot, group_id: str, group: GroupConsole | None + ): + """允许群组并设置群认证,默认群功能开关 + + 参数: + bot: Bot + group_id: 群组id + group: GroupConsole + """ + if group: + await GroupConsole.filter( + group_id=group_id, channel_id__isnull=True + ).update(group_flag=1) + else: + block_plugin = "" + if plugin_list := await PluginInfo.filter(default_status=False).all(): + for plugin in plugin_list: + block_plugin += f"{plugin.module}," + group_info = await bot.get_group_info(group_id=group_id) + await GroupConsole.create( + group_id=group_info["group_id"], + group_name=group_info["group_name"], + max_member_count=group_info["max_member_count"], + member_count=group_info["member_count"], + group_flag=1, + block_plugin=block_plugin, + platform="qq", + ) + + @classmethod + async def __refresh_level(cls, bot: Bot, group_id: str): + """刷新权限 + + 参数: + bot: Bot + group_id: 群组id + """ + admin_default_auth = Config.get_config("admin_bot_manage", "ADMIN_DEFAULT_AUTH") + member_list = await bot.get_group_member_list(group_id=group_id) + member_id_list = [str(user_info["user_id"]) for user_info in member_list] + flag2u = await LevelUser.filter( + user_id__in=member_id_list, group_id=group_id, group_flag=1 + ).values_list("user_id", flat=True) + # 即刻刷新权限 + for user_info in member_list: + user_id = user_info["user_id"] + role = user_info["role"] + if user_id in bot.config.superusers: + await LevelUser.set_level(user_id, user_info["group_id"], 9) + logger.debug( + "添加超级用户权限: 9", + "入群检测", + session=user_id, + group_id=user_info["group_id"], + ) + elif ( + admin_default_auth is not None + and role in ["owner", "admin"] + and user_id not in flag2u + ): + await LevelUser.set_level( + user_id, + user_info["group_id"], + admin_default_auth, + ) + logger.debug( + f"添加默认群管理员权限: {admin_default_auth}", + "入群检测", + session=user_id, + group_id=user_info["group_id"], + ) + + @classmethod + async def add_bot( + cls, bot: Bot, operator_id: str, group_id: str, group: GroupConsole | None + ): + """拉入bot + + 参数: + bot: Bot + operator_id: 操作者id + group_id: 群组id + group: GroupConsole + """ + if base_config.get("flag") and operator_id not in bot.config.superusers: + """退出群组""" + try: + if result_msg := base_config.get("message"): + await bot.send_group_msg(group_id=int(group_id), message=result_msg) + await bot.set_group_leave(group_id=int(group_id)) + logger.info( + "强制拉群或未有群信息,退出群聊成功", "入群检测", group_id=group_id + ) + await FgRequest.filter( + group_id=group_id, handle_type__isnull=True + ).update(handle_type=RequestHandleType.IGNORE) + except Exception as e: + logger.error( + "强制拉群或未有群信息,退出群聊失败", + "入群检测", + group_id=group_id, + e=e, + ) + raise ForceAddGroupError("强制拉群或未有群信息,退出群聊失败...") from e + await GroupConsole.filter(group_id=group_id).delete() + raise ForceAddGroupError(f"触发强制入群保护,已成功退出群聊 {group_id}...") + else: + await cls.__handle_add_group(bot, group_id, group) + """刷新群管理员权限""" + await cls.__refresh_level(bot, group_id) + + @classmethod + def __build_welcome_message(cls, user_id: str, path: Path) -> list[At | Path | str]: + """构造群欢迎消息 + + 参数: + user_id: 用户id + path: 群欢迎消息存储路径 + + 返回: + list[At | Path | str]: 消息列表 + """ + file = path / "text.json" + data = json.load(file.open(encoding="utf-8")) + message = data["message"] + msg_split = re.split(r"\[image:\d+\]", message) + msg_list = [] + if data["at"]: + msg_list.append(At(flag="user", target=user_id)) + for i, text in enumerate(msg_split): + msg_list.append(text) + img_file = path / f"{i}.png" + if img_file.exists(): + msg_list.append(img_file) + return msg_list + + @classmethod + async def __send_welcome_message(cls, user_id: str, group_id: str): + """发送群欢迎消息 + + 参数: + user_id: 用户id + group_id: 群组id + """ + cls._flmt.start_cd(group_id) + path = WELCOME_PATH / f"{group_id}" + file = path / "text.json" + if file.exists(): + msg_list = cls.__build_welcome_message(user_id, path) + logger.info("发送群欢迎消息...", "入群检测", group_id=group_id) + if msg_list: + await MessageUtils.build_message(msg_list).send() # type: ignore + else: + image = DEFAULT_IMAGE_PATH / random.choice( + os.listdir(DEFAULT_IMAGE_PATH) + ) + await MessageUtils.build_message( + [ + "新人快跑啊!!本群现状↓(快使用自定义!)", + image, + ] + ).send() + + @classmethod + async def add_user(cls, bot: Bot, user_id: str, group_id: str): + """拉入用户 + + 参数: + bot: Bot + user_id: 用户id + group_id: 群组id + """ + join_time = datetime.now() + user_info = await bot.get_group_member_info(group_id=group_id, user_id=user_id) + await GroupInfoUser.update_or_create( + user_id=str(user_info["user_id"]), + group_id=str(user_info["group_id"]), + defaults={"user_name": user_info["nickname"], "user_join_time": join_time}, + ) + logger.info(f"用户{user_info['user_id']} 所属{user_info['group_id']} 更新成功") + if not await CommonUtils.task_is_block( + "group_welcome", group_id + ) and cls._flmt.check(group_id): + await cls.__send_welcome_message(user_id, group_id) + + @classmethod + async def kick_bot(cls, bot: Bot, group_id: str, operator_id: str): + """踢出bot + + 参数: + bot: Bot + group_id: 群组id + operator_id: 操作员id + """ + if user := await GroupInfoUser.get_or_none( + user_id=operator_id, group_id=group_id + ): + operator_name = user.user_name + else: + operator_name = "None" + group = await GroupConsole.get_group(group_id) + group_name = group.group_name if group else "" + if group: + await group.delete() + await PlatformUtils.send_superuser( + bot, + f"****呜..一份踢出报告****\n" + f"我被 {operator_name}({operator_id})\n" + f"踢出了 {group_name}({group_id})\n" + f"日期:{str(datetime.now()).split('.')[0]}", + ) + + @classmethod + async def run_user( + cls, + bot: Bot, + user_id: str, + group_id: str, + operator_id: str, + sub_type: str, + ) -> str | None: + """踢出用户或用户离开 + + 参数: + bot: Bot + user_id: 用户id + group_id: 群组id + operator_id: 操作员id + sub_type: 类型 + + 返回: + str | None: 返回消息 + """ + if user := await GroupInfoUser.get_or_none(user_id=user_id, group_id=group_id): + user_name = user.user_name + else: + user_name = f"{user_id}" + if user: + await user.delete() + logger.info( + f"名称: {user_name} 退出群聊", + "group_decrease_handle", + session=user_id, + group_id=group_id, + ) + if sub_type == "kick": + operator = await bot.get_group_member_info( + user_id=int(operator_id), group_id=int(group_id) + ) + operator_name = operator["card"] or operator["nickname"] + return f"{user_name} 被 {operator_name} 送走了." + elif sub_type == "leave": + return f"{user_name}离开了我们..." + return None diff --git a/zhenxun/builtin_plugins/plugin_store/__init__.py b/zhenxun/builtin_plugins/plugin_store/__init__.py index d9c2cc522..4ff8c9403 100644 --- a/zhenxun/builtin_plugins/plugin_store/__init__.py +++ b/zhenxun/builtin_plugins/plugin_store/__init__.py @@ -1,12 +1,12 @@ from nonebot.permission import SUPERUSER from nonebot.plugin import PluginMetadata -from nonebot_plugin_alconna import Alconna, Args, Subcommand, on_alconna from nonebot_plugin_session import EventSession +from nonebot_plugin_alconna import Args, Alconna, Subcommand, on_alconna -from zhenxun.configs.utils import PluginExtraData from zhenxun.services.log import logger from zhenxun.utils.enum import PluginType from zhenxun.utils.message import MessageUtils +from zhenxun.configs.utils import PluginExtraData from .data_source import ShopManage @@ -68,6 +68,7 @@ prefix=True, ) + @_matcher.assign("$main") async def _(session: EventSession): try: @@ -82,9 +83,7 @@ async def _(session: EventSession): @_matcher.assign("add") async def _(session: EventSession, plugin_id: int): try: - await MessageUtils.build_message( - f"正在添加插件 Id: {plugin_id}" - ).send() + await MessageUtils.build_message(f"正在添加插件 Id: {plugin_id}").send() result = await ShopManage.add_plugin(plugin_id) except Exception as e: logger.error(f"添加插件 Id: {plugin_id}失败", "插件商店", session=session, e=e) @@ -107,24 +106,29 @@ async def _(session: EventSession, plugin_id: int): logger.info(f"移除插件 Id: {plugin_id}", "插件商店", session=session) await MessageUtils.build_message(result).send() + @_matcher.assign("search") async def _(session: EventSession, plugin_name_or_author: str): try: result = await ShopManage.search_plugin(plugin_name_or_author) except Exception as e: - logger.error(f"搜索插件 name: {plugin_name_or_author}失败", "插件商店", session=session, e=e) + logger.error( + f"搜索插件 name: {plugin_name_or_author}失败", + "插件商店", + session=session, + e=e, + ) await MessageUtils.build_message( f"搜索插件 name: {plugin_name_or_author} 失败 e: {e}" ).finish() logger.info(f"搜索插件 name: {plugin_name_or_author}", "插件商店", session=session) await MessageUtils.build_message(result).send() + @_matcher.assign("update") async def _(session: EventSession, plugin_id: int): try: - await MessageUtils.build_message( - f"正在更新插件 Id: {plugin_id}" - ).send() + await MessageUtils.build_message(f"正在更新插件 Id: {plugin_id}").send() result = await ShopManage.update_plugin(plugin_id) except Exception as e: logger.error(f"更新插件 Id: {plugin_id}失败", "插件商店", session=session, e=e) diff --git a/zhenxun/builtin_plugins/plugin_store/config.py b/zhenxun/builtin_plugins/plugin_store/config.py index 1b6c83609..dacaffec0 100644 --- a/zhenxun/builtin_plugins/plugin_store/config.py +++ b/zhenxun/builtin_plugins/plugin_store/config.py @@ -4,16 +4,8 @@ BASE_PATH.mkdir(parents=True, exist_ok=True) -CONFIG_URL = "https://cdn.jsdelivr.net/gh/zhenxun-org/zhenxun_bot_plugins/plugins.json" -"""插件信息文件""" +DEFAULT_GITHUB_URL = "https://github.com/zhenxun-org/zhenxun_bot_plugins/tree/main" +"""伴生插件github仓库地址""" -CONFIG_INDEX_URL = "https://raw.githubusercontent.com/zhenxun-org/zhenxun_bot_plugins_index/index/plugins.json" -"""插件索引库信息文件""" - -CONFIG_INDEX_CDN_URL = "https://cdn.jsdelivr.net/gh/zhenxun-org/zhenxun_bot_plugins_index@index/plugins.json" -"""插件索引库信息文件cdn""" - -DOWNLOAD_URL = ( - "https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/contents/{}?ref=main" -) -"""插件下载地址""" +EXTRA_GITHUB_URL = "https://github.com/zhenxun-org/zhenxun_bot_plugins_index/tree/index" +"""插件库索引github仓库地址""" diff --git a/zhenxun/builtin_plugins/plugin_store/data_source.py b/zhenxun/builtin_plugins/plugin_store/data_source.py index 5b20ddfdc..26c3edff0 100644 --- a/zhenxun/builtin_plugins/plugin_store/data_source.py +++ b/zhenxun/builtin_plugins/plugin_store/data_source.py @@ -1,23 +1,21 @@ -import re import shutil import subprocess from pathlib import Path -import aiofiles import ujson as json +from aiocache import cached from zhenxun.services.log import logger from zhenxun.utils.http_utils import AsyncHttpx from zhenxun.models.plugin_info import PluginInfo +from zhenxun.utils.github_utils import GithubUtils +from zhenxun.utils.github_utils.models import RepoAPI +from zhenxun.services.plugin_init import PluginInitManager +from zhenxun.builtin_plugins.plugin_store.models import StorePluginInfo from zhenxun.utils.image_utils import RowStyle, BuildImage, ImageTemplate +from zhenxun.builtin_plugins.auto_update.config import REQ_TXT_FILE_STRING -from .config import ( - BASE_PATH, - CONFIG_URL, - DOWNLOAD_URL, - CONFIG_INDEX_URL, - CONFIG_INDEX_CDN_URL, -) +from .config import BASE_PATH, EXTRA_GITHUB_URL, DEFAULT_GITHUB_URL def row_style(column: str, text: str) -> RowStyle: @@ -36,68 +34,6 @@ def row_style(column: str, text: str) -> RowStyle: return style -async def recurrence_get_url( - url: str, - data_list: list[tuple[str, str]], - ignore_list: list[str] | None = None, - api_url: str | None = None, -): - """递归获取目录下所有文件 - - 参数: - url: 信息url - data_list: 数据列表 - - 异常: - ValueError: 访问错误 - """ - if ignore_list is None: - ignore_list = [] - logger.debug(f"访问插件下载信息 URL: {url}", "插件管理") - res = await AsyncHttpx.get(url) - if res.status_code != 200: - raise ValueError(f"访问错误, code: {res.status_code}") - json_data = res.json() - if isinstance(json_data, list): - data_list.extend((v.get("download_url"), v["path"]) for v in json_data) - else: - data_list.append((json_data.get("download_url"), json_data["path"])) - for download_url, path in data_list: - if not download_url: - _url = api_url + path if api_url else DOWNLOAD_URL.format(path) - if _url not in ignore_list: - ignore_list.append(_url) - await recurrence_get_url(_url, data_list, ignore_list, api_url) - - -async def download_file(url: str, _is: bool = False, api_url: str | None = None): - """下载文件 - - 参数: - url: 插件详情url - _is: 是否为第三方插件 - url_start : 第三方插件url - - 异常: - ValueError: 下载失败 - """ - data_list = [] - await recurrence_get_url(url, data_list, api_url=api_url) - for download_url, path in data_list: - if download_url and "." in path: - logger.debug(f"下载文件: {path}", "插件管理") - base_path = "zhenxun/plugins/" if _is else "zhenxun/" - file = Path(f"{base_path}{path}") - file.parent.mkdir(parents=True, exist_ok=True) - r = await AsyncHttpx.get(download_url) - if r.status_code != 200: - raise ValueError(f"文件下载错误, code: {r.status_code}") - content = r.text.replace("\r\n", "\n") # 统一换行符为 UNIX 风格 - async with aiofiles.open(file, "w", encoding="utf8") as f: - logger.debug(f"写入文件: {file}", "插件管理") - await f.write(content) - - def install_requirement(plugin_path: Path): requirement_files = ["requirement.txt", "requirements.txt"] requirement_paths = [plugin_path / file for file in requirement_files] @@ -132,17 +68,9 @@ def install_requirement(plugin_path: Path): class ShopManage: - type2name = { # noqa: RUF012 - "NORMAL": "普通插件", - "ADMIN": "管理员插件", - "SUPERUSER": "超级用户插件", - "ADMIN_SUPERUSER": "管理员/超级用户插件", - "DEPENDANT": "依赖插件", - "HIDDEN": "其他插件", - } - @classmethod - async def __get_data(cls) -> dict: + @cached(60) + async def get_data(cls) -> dict[str, StorePluginInfo]: """获取插件信息数据 异常: @@ -151,12 +79,14 @@ async def __get_data(cls) -> dict: 返回: dict: 插件信息数据 """ - res = await AsyncHttpx.get(CONFIG_URL) - res2 = await AsyncHttpx.get(CONFIG_INDEX_URL) - - if res2.status_code != 200: - logger.info("访问第三方插件信息文件失败,改为进行cdn访问") - res2 = await AsyncHttpx.get(CONFIG_INDEX_CDN_URL) + default_github_url = await GithubUtils.parse_github_url( + DEFAULT_GITHUB_URL + ).get_raw_download_urls("plugins.json") + extra_github_url = await GithubUtils.parse_github_url( + EXTRA_GITHUB_URL + ).get_raw_download_urls("plugins.json") + res = await AsyncHttpx.get(default_github_url) + res2 = await AsyncHttpx.get(extra_github_url) # 检查请求结果 if res.status_code != 200 or res2.status_code != 200: @@ -165,36 +95,53 @@ async def __get_data(cls) -> dict: # 解析并合并返回的 JSON 数据 data1 = json.loads(res.text) data2 = json.loads(res2.text) - return {**data1, **data2} + return { + name: StorePluginInfo(**detail) + for name, detail in {**data1, **data2}.items() + } @classmethod - def version_check(cls, plugin_info: dict, suc_plugin: dict[str, str]): - module = plugin_info["module"] - if module in suc_plugin and plugin_info["version"] != suc_plugin[module]: - return f"{suc_plugin[module]} (有更新->{plugin_info['version']})" - return plugin_info["version"] + def version_check(cls, plugin_info: StorePluginInfo, suc_plugin: dict[str, str]): + """版本检查 + + 参数: + plugin_info: StorePluginInfo + suc_plugin: dict[str, str] + + 返回: + str: 版本号 + """ + module = plugin_info.module + if suc_plugin.get(module) and not cls.check_version_is_new( + plugin_info, suc_plugin + ): + return f"{suc_plugin[module]} (有更新->{plugin_info.version})" + return plugin_info.version @classmethod - def get_url_path(cls, module_path: str, is_dir: bool) -> str: - url_path = None - path = BASE_PATH - module_path_split = module_path.split(".") - if len(module_path_split) == 2: - """单个文件或文件夹""" - if is_dir: - url_path = "/".join(module_path_split) - else: - url_path = "/".join(module_path_split) + ".py" - else: - """嵌套文件或文件夹""" - for p in module_path_split[:-1]: - path = path / p - path.mkdir(parents=True, exist_ok=True) - if is_dir: - url_path = f"{'/'.join(module_path_split)}" - else: - url_path = f"{'/'.join(module_path_split)}.py" - return url_path + def check_version_is_new( + cls, plugin_info: StorePluginInfo, suc_plugin: dict[str, str] + ): + """检查版本是否有更新 + + 参数: + plugin_info: StorePluginInfo + suc_plugin: dict[str, str] + + 返回: + bool: 是否有更新 + """ + module = plugin_info.module + return suc_plugin.get(module) and plugin_info.version == suc_plugin[module] + + @classmethod + async def get_loaded_plugins(cls, *args) -> list[tuple[str, str]]: + """获取已加载的插件 + + 返回: + list[str]: 已加载的插件 + """ + return await PluginInfo.filter(load_status=True).values_list(*args) @classmethod async def get_plugins_info(cls) -> BuildImage | str: @@ -203,30 +150,25 @@ async def get_plugins_info(cls) -> BuildImage | str: 返回: BuildImage | str: 返回消息 """ - data: dict = await cls.__get_data() + data: dict[str, StorePluginInfo] = await cls.get_data() column_name = ["-", "ID", "名称", "简介", "作者", "版本", "类型"] - for k in data.copy(): - if data[k]["plugin_type"]: - data[k]["plugin_type"] = cls.type2name[data[k]["plugin_type"]] - plugin_list = await PluginInfo.filter(load_status=True).values_list( - "module", "version" - ) - suc_plugin = {p[0]: p[1] for p in plugin_list if p[1]} + plugin_list = await cls.get_loaded_plugins("module", "version") + suc_plugin = {p[0]: (p[1] or "0.1") for p in plugin_list} data_list = [ [ - "已安装" if plugin_info[1]["module"] in suc_plugin else "", + "已安装" if plugin_info[1].module in suc_plugin else "", id, plugin_info[0], - plugin_info[1]["description"], - plugin_info[1]["author"], + plugin_info[1].description, + plugin_info[1].author, cls.version_check(plugin_info[1], suc_plugin), - plugin_info[1]["plugin_type"], + plugin_info[1].plugin_type_name, ] for id, plugin_info in enumerate(data.items()) ] return await ImageTemplate.table_page( "插件列表", - "通过安装/卸载插件 ID 来管理插件", + "通过添加/移除插件 ID 来管理插件", column_name, data_list, text_style=row_style, @@ -242,57 +184,86 @@ async def add_plugin(cls, plugin_id: int) -> str: 返回: str: 返回消息 """ - data: dict = await cls.__get_data() + data: dict[str, StorePluginInfo] = await cls.get_data() if plugin_id < 0 or plugin_id >= len(data): return "插件ID不存在..." plugin_key = list(data.keys())[plugin_id] + plugin_list = await cls.get_loaded_plugins("module") plugin_info = data[plugin_key] - module_path_split = plugin_info["module_path"].split(".") - url_path = cls.get_url_path(plugin_info["module_path"], plugin_info["is_dir"]) - if not url_path and plugin_info["module_path"]: - return "插件下载地址构建失败..." - logger.debug(f"尝试下载插件 URL: {url_path}", "插件管理") - github_url = plugin_info.get("github_url") - if github_url: - if not (r := re.search(r"github\.com/([^/]+/[^/]+)", github_url)): - return "github地址格式错误" - github_path = r[1] - api_url = f"https://api.github.com/repos/{github_path}/contents/" - download_url = f"{api_url}{url_path}?ref=main" - else: - download_url = DOWNLOAD_URL.format(url_path) - api_url = None - - await download_file(download_url, bool(github_url), api_url) - - # 安装依赖 - plugin_path = BASE_PATH / "/".join(module_path_split) - if url_path and github_url and api_url: - plugin_path = BASE_PATH / "plugins" / "/".join(module_path_split) - res = await AsyncHttpx.get(api_url) - if res.status_code != 200: - return f"访问错误, code: {res.status_code}" - json_data = res.json() - if requirement_file := next( - ( - v - for v in json_data - if v["name"] in ["requirements.txt", "requirement.txt"] - ), - None, - ): - r = await AsyncHttpx.get(requirement_file.get("download_url")) - if r.status_code != 200: - raise ValueError(f"文件下载错误, code: {r.status_code}") - requirement_path = plugin_path / requirement_file["name"] - async with aiofiles.open(requirement_path, "w", encoding="utf8") as f: - logger.debug(f"写入文件: {requirement_path}", "插件管理") - await f.write(r.text) - - install_requirement(plugin_path) - + if plugin_info.module in [p[0] for p in plugin_list]: + return f"插件 {plugin_key} 已安装,无需重复安装" + is_external = True + if plugin_info.github_url is None: + plugin_info.github_url = DEFAULT_GITHUB_URL + is_external = False + version_split = plugin_info.version.split("-") + if len(version_split) > 1: + github_url_split = plugin_info.github_url.split("/tree/") + plugin_info.github_url = f"{github_url_split[0]}/tree/{version_split[1]}" + logger.info(f"正在安装插件 {plugin_key}...") + await cls.install_plugin_with_repo( + plugin_info.github_url, + plugin_info.module_path, + plugin_info.is_dir, + is_external, + ) return f"插件 {plugin_key} 安装成功! 重启后生效" + @classmethod + async def install_plugin_with_repo( + cls, github_url: str, module_path: str, is_dir: bool, is_external: bool = False + ): + files: list[str] + repo_api: RepoAPI + repo_info = GithubUtils.parse_github_url(github_url) + logger.debug(f"成功获取仓库信息: {repo_info}", "插件管理") + for repo_api in GithubUtils.iter_api_strategies(): + try: + await repo_api.parse_repo_info(repo_info) + break + except Exception as e: + logger.warning( + f"获取插件文件失败: {e} | API类型: {repo_api.strategy}", "插件管理" + ) + continue + else: + raise ValueError("所有API获取插件文件失败,请检查网络连接") + files = repo_api.get_files( + module_path=module_path.replace(".", "/") + ("" if is_dir else ".py"), + is_dir=is_dir, + ) + download_urls = [await repo_info.get_raw_download_urls(file) for file in files] + base_path = BASE_PATH / "plugins" if is_external else BASE_PATH + download_paths: list[Path | str] = [base_path / file for file in files] + logger.debug(f"插件下载路径: {download_paths}", "插件管理") + result = await AsyncHttpx.gather_download_file(download_urls, download_paths) + for _id, success in enumerate(result): + if not success: + break + else: + # 安装依赖 + plugin_path = base_path / "/".join(module_path.split(".")) + req_files = repo_api.get_files(REQ_TXT_FILE_STRING, False) + req_files.extend(repo_api.get_files("requirement.txt", False)) + logger.debug(f"获取插件依赖文件列表: {req_files}", "插件管理") + req_download_urls = [ + await repo_info.get_raw_download_urls(file) for file in req_files + ] + req_paths: list[Path | str] = [plugin_path / file for file in req_files] + logger.debug(f"插件依赖文件下载路径: {req_paths}", "插件管理") + if req_files: + result = await AsyncHttpx.gather_download_file( + req_download_urls, req_paths + ) + for _id, success in enumerate(result): + if not success: + raise Exception("插件依赖文件下载失败") + else: + logger.debug(f"插件依赖文件列表: {req_paths}", "插件管理") + install_requirement(plugin_path) + return True + raise Exception("插件下载失败") + @classmethod async def remove_plugin(cls, plugin_id: int) -> str: """移除插件 @@ -303,25 +274,26 @@ async def remove_plugin(cls, plugin_id: int) -> str: 返回: str: 返回消息 """ - data: dict = await cls.__get_data() + data: dict[str, StorePluginInfo] = await cls.get_data() if plugin_id < 0 or plugin_id >= len(data): return "插件ID不存在..." plugin_key = list(data.keys())[plugin_id] - plugin_info = data[plugin_key] + plugin_info = data[plugin_key] # type: ignore path = BASE_PATH - if plugin_info.get("github_url"): + if plugin_info.github_url: path = BASE_PATH / "plugins" - for p in plugin_info["module_path"].split("."): + for p in plugin_info.module_path.split("."): path = path / p - if not plugin_info["is_dir"]: + if not plugin_info.is_dir: path = Path(f"{path}.py") if not path.exists(): return f"插件 {plugin_key} 不存在..." logger.debug(f"尝试移除插件 {plugin_key} 文件: {path}", "插件管理") - if plugin_info["is_dir"]: + if plugin_info.is_dir: shutil.rmtree(path) else: path.unlink() + await PluginInitManager.remove(f"zhenxun.{plugin_info.module_path}") return f"插件 {plugin_key} 移除成功! 重启后生效" @classmethod @@ -334,30 +306,25 @@ async def search_plugin(cls, plugin_name_or_author: str) -> BuildImage | str: 返回: BuildImage | str: 返回消息 """ - data: dict = await cls.__get_data() - for k in data.copy(): - if data[k]["plugin_type"]: - data[k]["plugin_type"] = cls.type2name[data[k]["plugin_type"]] - plugin_list = await PluginInfo.filter(load_status=True).values_list( - "module", "version" - ) - suc_plugin = {p[0]: p[1] for p in plugin_list if p[1]} + data: dict[str, StorePluginInfo] = await cls.get_data() + plugin_list = await cls.get_loaded_plugins("module", "version") + suc_plugin = {p[0]: (p[1] or "Unknown") for p in plugin_list} filtered_data = [ (id, plugin_info) for id, plugin_info in enumerate(data.items()) if plugin_name_or_author.lower() in plugin_info[0].lower() - or plugin_name_or_author.lower() in plugin_info[1]["author"].lower() + or plugin_name_or_author.lower() in plugin_info[1].author.lower() ] data_list = [ [ - "已安装" if plugin_info[1]["module"] in suc_plugin else "", + "已安装" if plugin_info[1].module in suc_plugin else "", id, plugin_info[0], - plugin_info[1]["description"], - plugin_info[1]["author"], + plugin_info[1].description, + plugin_info[1].author, cls.version_check(plugin_info[1], suc_plugin), - plugin_info[1]["plugin_type"], + plugin_info[1].plugin_type_name, ] for id, plugin_info in filtered_data ] @@ -382,53 +349,27 @@ async def update_plugin(cls, plugin_id: int) -> str: 返回: str: 返回消息 """ - data: dict = await cls.__get_data() + data: dict[str, StorePluginInfo] = await cls.get_data() if plugin_id < 0 or plugin_id >= len(data): return "插件ID不存在..." plugin_key = list(data.keys())[plugin_id] + logger.info(f"尝试更新插件 {plugin_key}", "插件管理") plugin_info = data[plugin_key] - module_path_split = plugin_info["module_path"].split(".") - url_path = cls.get_url_path(plugin_info["module_path"], plugin_info["is_dir"]) - if not url_path and plugin_info["module_path"]: - return "插件下载地址构建失败..." - logger.debug(f"尝试下载插件 URL: {url_path}", "插件管理") - github_url = plugin_info.get("github_url") - if github_url: - if not (r := re.search(r"github\.com/([^/]+/[^/]+)", github_url)): - return "github地址格式错误..." - github_path = r[1] - api_url = f"https://api.github.com/repos/{github_path}/contents/" - download_url = f"{api_url}{url_path}?ref=main" - else: - download_url = DOWNLOAD_URL.format(url_path) - api_url = None - - await download_file(download_url, bool(github_url), api_url) - - # 安装依赖 - plugin_path = BASE_PATH / "/".join(module_path_split) - if url_path and github_url and api_url: - plugin_path = BASE_PATH / "plugins" / "/".join(module_path_split) - res = await AsyncHttpx.get(api_url) - if res.status_code != 200: - return f"访问错误, code: {res.status_code}" - json_data = res.json() - if requirement_file := next( - ( - v - for v in json_data - if v["name"] in ["requirements.txt", "requirement.txt"] - ), - None, - ): - r = await AsyncHttpx.get(requirement_file.get("download_url")) - if r.status_code != 200: - raise ValueError(f"文件下载错误, code: {r.status_code}") - requirement_path = plugin_path / requirement_file["name"] - async with aiofiles.open(requirement_path, "w", encoding="utf8") as f: - logger.debug(f"写入文件: {requirement_path}", "插件管理") - await f.write(r.text) - - install_requirement(plugin_path) - + plugin_list = await cls.get_loaded_plugins("module", "version") + suc_plugin = {p[0]: (p[1] or "Unknown") for p in plugin_list} + if plugin_info.module not in [p[0] for p in plugin_list]: + return f"插件 {plugin_key} 未安装,无法更新" + logger.debug(f"当前插件列表: {suc_plugin}", "插件管理") + if cls.check_version_is_new(plugin_info, suc_plugin): + return f"插件 {plugin_key} 已是最新版本" + is_external = True + if plugin_info.github_url is None: + plugin_info.github_url = DEFAULT_GITHUB_URL + is_external = False + await cls.install_plugin_with_repo( + plugin_info.github_url, + plugin_info.module_path, + plugin_info.is_dir, + is_external, + ) return f"插件 {plugin_key} 更新成功! 重启后生效" diff --git a/zhenxun/builtin_plugins/plugin_store/models.py b/zhenxun/builtin_plugins/plugin_store/models.py new file mode 100644 index 000000000..b95294c37 --- /dev/null +++ b/zhenxun/builtin_plugins/plugin_store/models.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel + +from zhenxun.utils.enum import PluginType + +type2name: dict[str, str] = { + "NORMAL": "普通插件", + "ADMIN": "管理员插件", + "SUPERUSER": "超级用户插件", + "ADMIN_SUPERUSER": "管理员/超级用户插件", + "DEPENDANT": "依赖插件", + "HIDDEN": "其他插件", +} + + +class StorePluginInfo(BaseModel): + """插件信息""" + + module: str + """模块名""" + module_path: str + """模块路径""" + description: str + """简介""" + usage: str + """用法""" + author: str + """作者""" + version: str + """版本""" + plugin_type: PluginType + """插件类型""" + is_dir: bool + """是否为文件夹插件""" + github_url: str | None + """github链接""" + + @property + def plugin_type_name(self): + return type2name[self.plugin_type.value] diff --git a/zhenxun/builtin_plugins/record_request.py b/zhenxun/builtin_plugins/record_request.py index 5762c1a3f..f7ac4b21a 100644 --- a/zhenxun/builtin_plugins/record_request.py +++ b/zhenxun/builtin_plugins/record_request.py @@ -14,7 +14,6 @@ ) from zhenxun.services.log import logger -from zhenxun.utils.message import MessageUtils from zhenxun.models.fg_request import FgRequest from zhenxun.utils.platform import PlatformUtils from zhenxun.models.friend_user import FriendUser @@ -69,7 +68,6 @@ def clear(cls): @friend_req.handle() async def _(bot: v12Bot | v11Bot, event: FriendRequestEvent, session: EventSession): - superuser = BotConfig.get_superuser("qq") if event.user_id and Timer.check(event.user_id): logger.debug("收录好友请求...", "好友请求", target=event.user_id) user = await bot.get_stranger_info(user_id=event.user_id) @@ -77,14 +75,6 @@ async def _(bot: v12Bot | v11Bot, event: FriendRequestEvent, session: EventSessi # sex = user["sex"] # age = str(user["age"]) comment = event.comment - if superuser: - await MessageUtils.build_message( - f"*****一份好友申请*****\n" - f"昵称:{nickname}({event.user_id})\n" - f"自动同意:{'√' if base_config.get('AUTO_ADD_FRIEND') else '×'}\n" - f"日期:{str(datetime.now()).split('.')[0]}\n" - f"备注:{event.comment}" - ).send(target=PlatformUtils.get_target(bot, superuser)) if base_config.get("AUTO_ADD_FRIEND"): logger.debug( "已开启好友请求自动同意,成功通过该请求", @@ -102,7 +92,7 @@ async def _(bot: v12Bot | v11Bot, event: FriendRequestEvent, session: EventSessi user_id=str(event.user_id), handle_type__isnull=True, ).update(handle_type=RequestHandleType.EXPIRE) - await FgRequest.create( + f = await FgRequest.create( request_type=RequestType.FRIEND, platform=session.platform, bot_id=bot.self_id, @@ -111,97 +101,107 @@ async def _(bot: v12Bot | v11Bot, event: FriendRequestEvent, session: EventSessi nickname=nickname, comment=comment, ) + await PlatformUtils.send_superuser( + bot, + f"*****一份好友申请*****\n" + f"ID: {f.id}" + f"昵称:{nickname}({event.user_id})\n" + f"自动同意:{'√' if base_config.get('AUTO_ADD_FRIEND') else '×'}\n" + f"日期:{str(datetime.now()).split('.')[0]}\n" + f"备注:{event.comment}", + ) else: logger.debug("好友请求五分钟内重复, 已忽略", "好友请求", target=event.user_id) @group_req.handle() async def _(bot: v12Bot | v11Bot, event: GroupRequestEvent, session: EventSession): - superuser = BotConfig.get_superuser("qq") - if event.sub_type == "invite": - if str(event.user_id) in bot.config.superusers: - try: - logger.debug( - "超级用户自动同意加入群聊", - "群聊请求", - session=event.user_id, - target=event.group_id, - ) - group, _ = await GroupConsole.update_or_create( - group_id=str(event.group_id), - defaults={ - "group_name": "", - "max_member_count": 0, - "member_count": 0, - "group_flag": 1, - }, - ) - await bot.set_group_add_request( - flag=event.flag, sub_type="invite", approve=True - ) - if isinstance(bot, v11Bot): - group_info = await bot.get_group_info(group_id=event.group_id) - max_member_count = group_info["max_member_count"] - member_count = group_info["member_count"] - else: - group_info = await bot.get_group_info(group_id=str(event.group_id)) - max_member_count = 0 - member_count = 0 - group.max_member_count = max_member_count - group.member_count = member_count - group.group_name = group_info["group_name"] - await group.save( - update_fields=["group_name", "max_member_count", "member_count"] - ) - except ActionFailed as e: - logger.error( - "超级用户自动同意加入群聊发生错误", - "群聊请求", - session=event.user_id, - target=event.group_id, - e=e, - ) - elif Timer.check(f"{event.user_id}:{event.group_id}"): + if event.sub_type != "invite": + return + if str(event.user_id) in bot.config.superusers: + try: logger.debug( - f"收录 用户[{event.user_id}] 群聊[{event.group_id}] 群聊请求", + "超级用户自动同意加入群聊", "群聊请求", + session=event.user_id, target=event.group_id, ) - nickname = await FriendUser.get_user_name(str(event.user_id)) - await PlatformUtils.send_superuser( - bot, - f"*****一份入群申请*****\n申请人:{nickname}({event.user_id})\n群聊:" - f"{event.group_id}\n邀请日期:{datetime.now().replace(microsecond=0)}", - superuser, + group, _ = await GroupConsole.update_or_create( + group_id=str(event.group_id), + defaults={ + "group_name": "", + "max_member_count": 0, + "member_count": 0, + "group_flag": 1, + }, ) - await bot.send_private_msg( - user_id=event.user_id, - message=f"想要邀请我偷偷入群嘛~已经提醒{BotConfig.self_nickname}的管理员大人了\n" - "请确保已经群主或群管理沟通过!\n" - "等待管理员处理吧!", + await bot.set_group_add_request( + flag=event.flag, sub_type="invite", approve=True ) - # 旧请求全部设置为过期 - await FgRequest.filter( - request_type=RequestType.GROUP, - user_id=str(event.user_id), - group_id=str(event.group_id), - handle_type__isnull=True, - ).update(handle_type=RequestHandleType.EXPIRE) - await FgRequest.create( - request_type=RequestType.GROUP, - platform=session.platform, - bot_id=bot.self_id, - flag=event.flag, - user_id=str(event.user_id), - nickname=nickname, - group_id=str(event.group_id), + if isinstance(bot, v11Bot): + group_info = await bot.get_group_info(group_id=event.group_id) + max_member_count = group_info["max_member_count"] + member_count = group_info["member_count"] + else: + group_info = await bot.get_group_info(group_id=str(event.group_id)) + max_member_count = 0 + member_count = 0 + group.max_member_count = max_member_count + group.member_count = member_count + group.group_name = group_info["group_name"] + await group.save( + update_fields=["group_name", "max_member_count", "member_count"] ) - else: - logger.debug( - "群聊请求五分钟内重复, 已忽略", + except ActionFailed as e: + logger.error( + "超级用户自动同意加入群聊发生错误", "群聊请求", - target=f"{event.user_id}:{event.group_id}", + session=event.user_id, + target=event.group_id, + e=e, ) + elif Timer.check(f"{event.user_id}:{event.group_id}"): + logger.debug( + f"收录 用户[{event.user_id}] 群聊[{event.group_id}] 群聊请求", + "群聊请求", + target=event.group_id, + ) + nickname = await FriendUser.get_user_name(str(event.user_id)) + await bot.send_private_msg( + user_id=event.user_id, + message=f"想要邀请我偷偷入群嘛~已经提醒{BotConfig.self_nickname}的管理员大人了\n" + "请确保已经群主或群管理沟通过!\n" + "等待管理员处理吧!", + ) + # 旧请求全部设置为过期 + await FgRequest.filter( + request_type=RequestType.GROUP, + user_id=str(event.user_id), + group_id=str(event.group_id), + handle_type__isnull=True, + ).update(handle_type=RequestHandleType.EXPIRE) + f = await FgRequest.create( + request_type=RequestType.GROUP, + platform=session.platform, + bot_id=bot.self_id, + flag=event.flag, + user_id=str(event.user_id), + nickname=nickname, + group_id=str(event.group_id), + ) + await PlatformUtils.send_superuser( + bot, + f"*****一份入群申请*****\n" + f"ID:{f.id}\n" + f"申请人:{nickname}({event.user_id})\n群聊:" + f"{event.group_id}\n邀请日期:{datetime.now().replace(microsecond=0)}", + ) + else: + logger.debug( + "群聊请求五分钟内重复, 已忽略", + "群聊请求", + target=f"{event.user_id}:{event.group_id}", + ) @scheduler.scheduled_job( diff --git a/zhenxun/builtin_plugins/scheduler/chat_check.py b/zhenxun/builtin_plugins/scheduler/chat_check.py index 5b4de3c14..2d4c9823b 100644 --- a/zhenxun/builtin_plugins/scheduler/chat_check.py +++ b/zhenxun/builtin_plugins/scheduler/chat_check.py @@ -1,14 +1,24 @@ from datetime import datetime, timedelta -import nonebot import pytz +import nonebot from nonebot_plugin_apscheduler import scheduler -from zhenxun.models.chat_history import ChatHistory -from zhenxun.models.group_console import GroupConsole -from zhenxun.models.task_info import TaskInfo from zhenxun.services.log import logger +from zhenxun.configs.config import Config +from zhenxun.models.task_info import TaskInfo from zhenxun.utils.platform import PlatformUtils +from zhenxun.models.chat_history import ChatHistory +from zhenxun.models.group_console import GroupConsole + +Config.add_plugin_config( + "chat_check", + "STATUS", + True, + help="是否开启群组两日内未发送任何消息,关闭该群全部被动", + default_value=True, + type=bool, +) @scheduler.scheduled_job( @@ -17,15 +27,18 @@ minute=40, ) async def _(): + if not Config.get_config("chat_history", "FLAG"): + logger.debug("未开启历史发言记录,过滤群组发言检测...") + return + if not Config.get_config("chat_check", "STATUS"): + logger.debug("未开启群组聊天时间检查,过滤群组发言检测...") + return """检测群组发言时间并禁用全部被动""" update_list = [] - if modules := await TaskInfo.annotate().values_list( - "module", flat=True - ): + if modules := await TaskInfo.annotate().values_list("module", flat=True): for bot in nonebot.get_bots().values(): group_list, _ = await PlatformUtils.get_group_list(bot) - group_list = [g for g in group_list if g.channel_id == None] - + group_list = [g for g in group_list if g.channel_id is None] for group in group_list: try: last_message = ( @@ -47,7 +60,7 @@ async def _(): "Chat检测", target=_group.group_id, ) - except Exception as e: + except Exception: logger.error( "检测群组发言时间失败...", "Chat检测", target=group.group_id ) diff --git a/zhenxun/builtin_plugins/scheduler/morning.py b/zhenxun/builtin_plugins/scheduler/morning.py index d858d6279..7aef0e1e9 100644 --- a/zhenxun/builtin_plugins/scheduler/morning.py +++ b/zhenxun/builtin_plugins/scheduler/morning.py @@ -5,7 +5,6 @@ from zhenxun.services.log import logger from zhenxun.utils.enum import PluginType from zhenxun.configs.config import BotConfig -from zhenxun.models.task_info import TaskInfo from zhenxun.utils.message import MessageUtils from zhenxun.configs.path_config import IMAGE_PATH from zhenxun.utils.common_utils import CommonUtils @@ -27,16 +26,6 @@ driver = nonebot.get_driver() -@driver.on_startup -async def _(): - if not await TaskInfo.exists(module="morning_goodnight"): - await TaskInfo.create( - module="morning_goodnight", - name="早晚安", - status=True, - ) - - async def check(group_id: str) -> bool: return not await CommonUtils.task_is_block("morning_goodnight", group_id) diff --git a/zhenxun/builtin_plugins/shop/__init__.py b/zhenxun/builtin_plugins/shop/__init__.py index f31af2c6e..328cdadaf 100644 --- a/zhenxun/builtin_plugins/shop/__init__.py +++ b/zhenxun/builtin_plugins/shop/__init__.py @@ -1,24 +1,28 @@ from nonebot.adapters import Bot, Event from nonebot.plugin import PluginMetadata +from nonebot_plugin_session import EventSession +from nonebot_plugin_userinfo import UserInfo, EventUserInfo from nonebot_plugin_alconna import ( - Alconna, Args, + Query, + Option, + UniMsg, + Alconna, Arparma, Subcommand, UniMessage, - UniMsg, + AlconnaQuery, on_alconna, + store_true, ) -from nonebot_plugin_session import EventSession -from nonebot_plugin_userinfo import EventUserInfo, UserInfo -from zhenxun.configs.utils import BaseBlock, PluginExtraData from zhenxun.services.log import logger -from zhenxun.utils.enum import BlockType, PluginType -from zhenxun.utils.exception import GoodsNotFound from zhenxun.utils.message import MessageUtils +from zhenxun.utils.exception import GoodsNotFound +from zhenxun.utils.enum import BlockType, PluginType +from zhenxun.configs.utils import BaseBlock, PluginExtraData -from ._data_source import ShopManage +from ._data_source import ShopManage, gold_rank __plugin_meta__ = PluginMetadata( name="商店", @@ -30,6 +34,8 @@ 我的道具 使用道具 [名称/Id] 购买道具 [名称/Id] + 金币排行 ?[num=10] + 金币总排行 ?[num=10] """.strip(), extra=PluginExtraData( author="HibiKier", @@ -44,10 +50,12 @@ _matcher = on_alconna( Alconna( "商店", + Option("--all", action=store_true), Subcommand("my-cost", help_text="我的金币"), Subcommand("my-props", help_text="我的道具"), Subcommand("buy", Args["name", str]["num", int, 1], help_text="购买道具"), Subcommand("use", Args["name", str]["num?", int, 1], help_text="使用道具"), + Subcommand("gold-list", Args["num?", int], help_text="金币排行"), ), priority=5, block=True, @@ -81,6 +89,20 @@ prefix=True, ) +_matcher.shortcut( + "金币排行", + command="商店", + arguments=["gold-list"], + prefix=True, +) + +_matcher.shortcut( + r"金币总排行", + command="商店", + arguments=["--all", "gold-list"], + prefix=True, +) + @_matcher.assign("$main") async def _(session: EventSession, arparma: Arparma): @@ -96,7 +118,7 @@ async def _(session: EventSession, arparma: Arparma): gold = await ShopManage.my_cost(session.id1, session.platform) await MessageUtils.build_message(f"你的当前余额: {gold}").send(reply_to=True) else: - await MessageUtils.build_message(f"用户id为空...").send(reply_to=True) + await MessageUtils.build_message("用户id为空...").send(reply_to=True) @_matcher.assign("my-props") @@ -111,11 +133,9 @@ async def _( session.platform, ): await MessageUtils.build_message(image.pic2bytes()).finish(reply_to=True) - return await MessageUtils.build_message(f"你的道具为空捏...").send( - reply_to=True - ) + return await MessageUtils.build_message("你的道具为空捏...").send(reply_to=True) else: - await MessageUtils.build_message(f"用户id为空...").send(reply_to=True) + await MessageUtils.build_message("用户id为空...").send(reply_to=True) @_matcher.assign("buy") @@ -129,7 +149,7 @@ async def _(session: EventSession, arparma: Arparma, name: str, num: int): result = await ShopManage.buy_prop(session.id1, name, num, session.platform) await MessageUtils.build_message(result).send(reply_to=True) else: - await MessageUtils.build_message(f"用户id为空...").send(reply_to=True) + await MessageUtils.build_message("用户id为空...").send(reply_to=True) @_matcher.assign("use") @@ -155,3 +175,28 @@ async def _( await MessageUtils.build_message(f"没有找到道具 {name} 或道具数量不足...").send( reply_to=True ) + + +@_matcher.assign("gold-list") +async def _( + session: EventSession, arparma: Arparma, num: Query[int] = AlconnaQuery("num", 10) +): + if num.result > 50: + await MessageUtils.build_message("排行榜人数不能超过50哦...").finish() + if session.id1: + gid = session.id3 or session.id2 + if not arparma.find("all") and not gid: + await MessageUtils.build_message( + "私聊中无法查看 '金币排行',请发送 '金币总排行'" + ).finish() + if arparma.find("all"): + gid = None + result = await gold_rank(session.id1, gid, num.result, session.platform) + logger.info( + "查看金币排行", + arparma.header_result, + session=session, + ) + await MessageUtils.build_message(result).send(reply_to=True) + else: + await MessageUtils.build_message("用户id为空...").send(reply_to=True) diff --git a/zhenxun/builtin_plugins/shop/_data_source.py b/zhenxun/builtin_plugins/shop/_data_source.py index 19f7fc231..31afe157b 100644 --- a/zhenxun/builtin_plugins/shop/_data_source.py +++ b/zhenxun/builtin_plugins/shop/_data_source.py @@ -1,28 +1,40 @@ +import time import asyncio import inspect -import time +from typing import Any, Literal from types import MappingProxyType -from typing import Any, Callable, Literal +from collections.abc import Callable from nonebot.adapters import Bot, Event -from nonebot_plugin_alconna import UniMessage, UniMsg -from nonebot_plugin_session import EventSession from pydantic import BaseModel, create_model +from nonebot_plugin_session import EventSession +from nonebot_plugin_alconna import UniMsg, UniMessage -from zhenxun.configs.path_config import IMAGE_PATH +from zhenxun.services.log import logger from zhenxun.models.goods_info import GoodsInfo +from zhenxun.utils.platform import PlatformUtils +from zhenxun.models.friend_user import FriendUser +from zhenxun.configs.path_config import IMAGE_PATH from zhenxun.models.user_console import UserConsole from zhenxun.models.user_gold_log import UserGoldLog -from zhenxun.models.user_props_log import UserPropsLog -from zhenxun.services.log import logger from zhenxun.utils.enum import GoldHandle, PropHandle +from zhenxun.models.user_props_log import UserPropsLog +from zhenxun.models.group_member_info import GroupInfoUser from zhenxun.utils.image_utils import BuildImage, ImageTemplate, text2image ICON_PATH = IMAGE_PATH / "shop_icon" +RANK_ICON_PATH = IMAGE_PATH / "_icon" + +PLATFORM_PATH = { + "dodo": RANK_ICON_PATH / "dodo.png", + "discord": RANK_ICON_PATH / "discord.png", + "kaiheila": RANK_ICON_PATH / "kook.png", + "qq": RANK_ICON_PATH / "qq.png", +} -class Goods(BaseModel): +class Goods(BaseModel): name: str """商品名称""" before_handle: list[Callable] = [] @@ -44,7 +56,6 @@ class Goods(BaseModel): class ShopParam(BaseModel): - goods_name: str """商品名称""" user_id: int @@ -65,11 +76,55 @@ class ShopParam(BaseModel): """单次使用最大次数""" session: EventSession | None = None """EventSession""" + message: UniMsg + """UniMessage""" -class ShopManage: +async def gold_rank(user_id: str, group_id: str | None, num: int, platform: str): + query = UserConsole + if group_id: + uid_list = await GroupInfoUser.filter(group_id=group_id).values_list( + "user_id", flat=True + ) + query = query.filter(user_id__in=uid_list) + user_list = await query.annotate().order_by("-gold").values_list("user_id", "gold") + user_id_list = [user[0] for user in user_list] + index = user_id_list.index(user_id) + 1 + user_list = user_list[:num] if num < len(user_list) else user_list + friend_user = await FriendUser.filter(user_id__in=user_id_list).values_list( + "user_id", "user_name" + ) + uid2name = {user[0]: user[1] for user in friend_user} + if diff_id := set(user_id_list).difference(set(uid2name.keys())): + group_user = await GroupInfoUser.filter(user_id__in=diff_id).values_list( + "user_id", "user_name" + ) + for g in group_user: + uid2name[g[0]] = g[1] + column_name = ["排名", "-", "名称", "金币", "平台"] + data_list = [] + for i, user in enumerate(user_list): + ava_bytes = await PlatformUtils.get_user_avatar(user[0], platform) + data_list.append( + [ + f"{i+1}", + (ava_bytes, 30, 30) if platform == "qq" else "", + uid2name.get(user[0]), + user[1], + (PLATFORM_PATH.get(platform), 30, 30), + ] + ) + if group_id: + title = "金币群组内排行" + tip = f"你的排名在本群第 {index} 位哦!" + else: + title = "金币全局排行" + tip = f"你的排名在全局第 {index} 位哦!" + return await ImageTemplate.table_page(title, tip, column_name, data_list) - uuid2goods: dict[str, Goods] = {} + +class ShopManage: + uuid2goods: dict[str, Goods] = {} # noqa: RUF012 @classmethod def __build_params( @@ -102,6 +157,7 @@ def __build_params( "num": num, "text": text, "session": session, + "message": message, } ) return model, { @@ -113,6 +169,7 @@ def __build_params( "num": num, "text": text, "goods_name": goods.name, + "message": message, } @classmethod @@ -121,7 +178,6 @@ def __parse_args( args: MappingProxyType, param: ShopParam, session: EventSession, - message: UniMsg, **kwargs, ) -> list[Any]: """解析参数 @@ -144,7 +200,7 @@ def __parse_args( elif par in ["session"]: param_list.append(session) elif par in ["message"]: - param_list.append(message) + param_list.append(kwargs.get("message")) elif par not in ["args", "kwargs"]: param_list.append(param_json.get(par)) if kwargs.get(par) is not None: @@ -170,16 +226,15 @@ async def run_before_after( if fun_list: for func in fun_list: args = inspect.signature(func).parameters - if args and list(args.keys())[0] != "kwargs": + if args and next(iter(args.keys())) != "kwargs": if asyncio.iscoroutinefunction(func): await func(*cls.__parse_args(args, param, **kwargs)) else: func(*cls.__parse_args(args, param, **kwargs)) + elif asyncio.iscoroutinefunction(func): + await func(**kwargs) else: - if asyncio.iscoroutinefunction(func): - await func(**kwargs) - else: - func(**kwargs) + func(**kwargs) @classmethod async def __run( @@ -187,7 +242,6 @@ async def __run( goods: Goods, param: ShopParam, session: EventSession, - message: UniMsg, **kwargs, ) -> str | UniMessage | None: """运行道具函数 @@ -201,22 +255,18 @@ async def __run( """ args = inspect.signature(goods.func).parameters # type: ignore if goods.func: - if args and list(args.keys())[0] != "kwargs": - if asyncio.iscoroutinefunction(goods.func): - return await goods.func( - *cls.__parse_args(args, param, session, message, **kwargs) - ) - else: - return goods.func( - *cls.__parse_args(args, param, session, message, **kwargs) - ) + if args and next(iter(args.keys())) != "kwargs": + return ( + await goods.func(*cls.__parse_args(args, param, session, **kwargs)) + if asyncio.iscoroutinefunction(goods.func) + else goods.func(*cls.__parse_args(args, param, session, **kwargs)) + ) + if asyncio.iscoroutinefunction(goods.func): + return await goods.func( + **kwargs, + ) else: - if asyncio.iscoroutinefunction(goods.func): - return await goods.func( - **kwargs, - ) - else: - return goods.func(**kwargs) + return goods.func(**kwargs) @classmethod async def use( @@ -252,17 +302,17 @@ async def use( if not goods_info: return f"{goods_name} 不存在..." if goods_info.is_passive: - return f"{goods_name} 是被动道具, 无法使用..." + return f"{goods_info.goods_name} 是被动道具, 无法使用..." goods = cls.uuid2goods.get(goods_info.uuid) if not goods or not goods.func: - return f"{goods_name} 未注册使用函数, 无法使用..." + return f"{goods_info.goods_name} 未注册使用函数, 无法使用..." param, kwargs = cls.__build_params( bot, event, session, message, goods, num, text ) if num > param.max_num_limit: return f"{goods_info.goods_name} 单次使用最大数量为{param.max_num_limit}..." await cls.run_before_after(goods, param, "before", **kwargs) - result = await cls.__run(goods, param, session, message, **kwargs) + result = await cls.__run(goods, param, session, **kwargs) await UserConsole.use_props(session.id1, goods_info.uuid, num, session.platform) # type: ignore await cls.run_before_after(goods, param, "after", **kwargs) if not result and param.send_success_msg: @@ -334,11 +384,10 @@ async def buy_prop( ] if name.isdigit(): goods = goods_list[int(name) - 1] + elif filter_goods := [g for g in goods_list if g.goods_name == name]: + goods = filter_goods[0] else: - if filter_goods := [g for g in goods_list if g.goods_name == name]: - goods = filter_goods[0] - else: - return "道具名称不存在..." + return "道具名称不存在..." user = await UserConsole.get_user(user_id, platform) price = goods.goods_price * num * goods.goods_discount if user.gold < price: @@ -423,13 +472,12 @@ async def build_shop_image(cls) -> BuildImage: BuildImage: 商店图片 """ goods_lst = await GoodsInfo.get_all_goods() - _dc = {} - font_h = BuildImage.get_text_size("正")[1] h = 10 - _list: list[GoodsInfo] = [] - for goods in goods_lst: - if goods.goods_limit_time == 0 or time.time() < goods.goods_limit_time: - _list.append(goods) + _list: list[GoodsInfo] = [ + goods + for goods in goods_lst + if goods.goods_limit_time == 0 or time.time() < goods.goods_limit_time + ] # A = BuildImage(1100, h, color="#f9f6f2") total_n = 0 image_list = [] @@ -471,7 +519,7 @@ async def build_shop_image(cls) -> BuildImage: 440 + _tmp.width, 0, ), - f" 金币", + " 金币", center_type="height", ) des_image = None @@ -541,7 +589,7 @@ async def build_shop_image(cls) -> BuildImage: ).split() y_m_d = limit_time[0] _h_m = limit_time[1].split(":") - h_m = _h_m[0] + "时 " + _h_m[1] + "分" + h_m = f"{_h_m[0]}时 {_h_m[1]}分" await bk.text((_w + 55, 38), str(y_m_d)) await bk.text((_w + 65, 57), str(h_m)) _w += 140 @@ -575,8 +623,7 @@ async def build_shop_image(cls) -> BuildImage: f"{goods.daily_limit}", size=30 ) await bk.paste(_tmp, (_w + 72, 45)) - if total_n < n: - total_n = n + total_n = max(total_n, n) if n: await bk.line((650, -1, 650 + n, -1), "#a29ad6", 5) # await bk.aline((650, 80, 650 + n, 80), "#a29ad6", 5) @@ -585,10 +632,8 @@ async def build_shop_image(cls) -> BuildImage: image_list.append(bk) # await A.apaste(bk, (0, current_h), True) # current_h += 90 - h = 0 current_h = 0 - for img in image_list: - h += img.height + 10 + h = sum(img.height + 10 for img in image_list) or 400 A = BuildImage(1100, h, color="#f9f6f2") for img in image_list: await A.paste(img, (0, current_h)) @@ -597,7 +642,7 @@ async def build_shop_image(cls) -> BuildImage: if total_n: w += total_n h = A.height + 230 + 100 - h = 1000 if h < 1000 else h + h = max(h, 1000) shop_logo = BuildImage(100, 100, background=f"{IMAGE_PATH}/other/shop_text.png") shop = BuildImage(w, h, font_size=20, color="#f9f6f2") await shop.paste(A, (20, 230)) diff --git a/zhenxun/builtin_plugins/sign_in/__init__.py b/zhenxun/builtin_plugins/sign_in/__init__.py index d1a62ab3e..d9e0813e8 100644 --- a/zhenxun/builtin_plugins/sign_in/__init__.py +++ b/zhenxun/builtin_plugins/sign_in/__init__.py @@ -3,9 +3,11 @@ from nonebot_plugin_apscheduler import scheduler from nonebot_plugin_alconna import ( Args, + Query, Option, Alconna, Arparma, + AlconnaQuery, on_alconna, store_true, ) @@ -17,6 +19,7 @@ from ._data_source import SignManage from .utils import clear_sign_data_pic +from .goods_register import driver # noqa: F401 __plugin_meta__ = PluginMetadata( name="签到", @@ -27,8 +30,8 @@ 指令: 签到 我的签到 - 好感度排行 - 好感度总排行 + 好感度排行 ?[num=10] + 好感度总排行 ?[num=10] * 签到时有 3% 概率 * 2 * """.strip(), extra=PluginExtraData( @@ -89,7 +92,7 @@ Option("--my", action=store_true, help_text="我的签到"), Option( "-l|--list", - Args["num", int, 10], + Args["num?", int], help_text="好感度排行", ), Option("-g|--global", action=store_true, help_text="全局排行"), @@ -115,7 +118,7 @@ _sign_matcher.shortcut( "好感度总排行", command="签到", - arguments=["--list", "--global"], + arguments=["--global", "--list"], prefix=True, ) @@ -139,7 +142,11 @@ async def _(session: EventSession, arparma: Arparma, nickname: str = UserName()) @_sign_matcher.assign("list") -async def _(session: EventSession, arparma: Arparma, num: int): +async def _( + session: EventSession, arparma: Arparma, num: Query[int] = AlconnaQuery("num", 10) +): + if num.result > 50: + await MessageUtils.build_message("排行榜人数不能超过50哦...").finish() gid = session.id3 or session.id2 if not arparma.find("global") and not gid: await MessageUtils.build_message( @@ -148,7 +155,7 @@ async def _(session: EventSession, arparma: Arparma, num: int): if session.id1: if arparma.find("global"): gid = None - if image := await SignManage.rank(session.id1, num, gid): + if image := await SignManage.rank(session.id1, num.result, gid): logger.info("查看签到排行", arparma.header_result, session=session) await MessageUtils.build_message(image).finish() return MessageUtils.build_message("用户id为空...").send() diff --git a/zhenxun/builtin_plugins/sign_in/_data_source.py b/zhenxun/builtin_plugins/sign_in/_data_source.py index 83080b59e..e27b074d5 100644 --- a/zhenxun/builtin_plugins/sign_in/_data_source.py +++ b/zhenxun/builtin_plugins/sign_in/_data_source.py @@ -1,23 +1,23 @@ import random import secrets -from datetime import datetime from pathlib import Path +from datetime import datetime import pytz from nonebot_plugin_session import EventSession -from zhenxun.configs.path_config import IMAGE_PATH -from zhenxun.models.friend_user import FriendUser -from zhenxun.models.group_member_info import GroupInfoUser +from zhenxun.services.log import logger from zhenxun.models.sign_log import SignLog from zhenxun.models.sign_user import SignUser +from zhenxun.utils.utils import get_user_avatar +from zhenxun.models.friend_user import FriendUser +from zhenxun.configs.path_config import IMAGE_PATH from zhenxun.models.user_console import UserConsole -from zhenxun.services.log import logger +from zhenxun.models.group_member_info import GroupInfoUser from zhenxun.utils.image_utils import BuildImage, ImageTemplate -from zhenxun.utils.utils import get_user_avatar -from ._random_event import random_event from .utils import get_card +from ._random_event import random_event ICON_PATH = IMAGE_PATH / "_icon" @@ -30,11 +30,10 @@ class SignManage: - @classmethod async def rank( cls, user_id: str, num: int, group_id: str | None = None - ) -> BuildImage: + ) -> BuildImage: # sourcery skip: avoid-builtin-shadow """好感度排行 参数: @@ -51,35 +50,36 @@ async def rank( "user_id", flat=True ) query = query.filter(user_id__in=user_list) - all_list = ( + user_list = ( await query.annotate() .order_by("-impression") - .values_list("user_id", flat=True) + .values_list("user_id", "impression", "sign_count", "platform") ) - index = all_list.index(user_id) + 1 # type: ignore - user_list = await query.annotate().order_by("-impression").limit(num).all() - user_id_list = [u.user_id for u in user_list] + user_id_list = [user[0] for user in user_list] + index = user_id_list.index(user_id) + 1 + user_list = user_list[:num] if num < len(user_list) else user_list column_name = ["排名", "-", "名称", "好感度", "签到次数", "平台"] friend_list = await FriendUser.filter(user_id__in=user_id_list).values_list( "user_id", "user_name" ) uid2name = {f[0]: f[1] for f in friend_list} - group_member_list = await GroupInfoUser.filter( - user_id__in=user_id_list - ).values_list("user_id", "user_name") - for gm in group_member_list: - uid2name[gm[0]] = gm[1] + if diff_id := set(user_id_list).difference(set(uid2name.keys())): + group_user = await GroupInfoUser.filter(user_id__in=diff_id).values_list( + "user_id", "user_name" + ) + for g in group_user: + uid2name[g[0]] = g[1] data_list = [] for i, user in enumerate(user_list): - bytes = await get_user_avatar(user.user_id) + bytes = await get_user_avatar(user[0]) data_list.append( [ f"{i+1}", - (bytes, 30, 30) if user.platform == "qq" else "", - uid2name.get(user.user_id), - user.impression, - user.sign_count, - (PLATFORM_PATH.get(user.platform), 30, 30), + (bytes, 30, 30) if user[3] == "qq" else "", + uid2name.get(user[0]), + user[1], + user[2], + (PLATFORM_PATH.get(user[3]), 30, 30), ] ) if group_id: @@ -120,9 +120,8 @@ async def sign( log_time = new_log.create_time.astimezone( pytz.timezone("Asia/Shanghai") ).date() - if not is_card_view: - if not new_log or (log_time and log_time != now.date()): - return await cls._handle_sign_in(user, nickname, session) + if not is_card_view and (not new_log or (log_time and log_time != now.date())): + return await cls._handle_sign_in(user, nickname, session) return await get_card( user, nickname, -1, user_console.gold, "", is_card_view=is_card_view ) @@ -148,9 +147,7 @@ async def _handle_sign_in( rand = random.random() add_probability = float(user.add_probability) specify_probability = user.specify_probability - if rand + add_probability > 0.97: - impression_added *= 2 - elif rand < specify_probability: + if rand + add_probability > 0.97 or rand < specify_probability: impression_added *= 2 await SignUser.sign(user, impression_added, session.bot_id, session.platform) gold = random.randint(1, 100) diff --git a/zhenxun/builtin_plugins/sign_in/config.py b/zhenxun/builtin_plugins/sign_in/config.py index e2bfdbc66..d2016c5bf 100644 --- a/zhenxun/builtin_plugins/sign_in/config.py +++ b/zhenxun/builtin_plugins/sign_in/config.py @@ -36,7 +36,6 @@ weekdays = {1: "Mon", 2: "Tue", 3: "Wed", 4: "Thu", 5: "Fri", 6: "Sat", 7: "Sun"} lik2level = { - 9999: "9", 400: "8", 270: "7", 200: "6", diff --git a/zhenxun/builtin_plugins/sign_in/goods_register.py b/zhenxun/builtin_plugins/sign_in/goods_register.py index b73cffc08..a280d9a7d 100644 --- a/zhenxun/builtin_plugins/sign_in/goods_register.py +++ b/zhenxun/builtin_plugins/sign_in/goods_register.py @@ -6,7 +6,7 @@ from zhenxun.models.sign_user import SignUser from zhenxun.models.user_console import UserConsole -from zhenxun.utils.decorator.shop import NotMeetUseConditionsException, shop_register +from zhenxun.utils.decorator.shop import shop_register driver: Driver = nonebot.get_driver() @@ -32,9 +32,13 @@ "favorability_card_2.png", "favorability_card_3.png", ), - **{"好感度双倍加持卡Ⅰ_prob": 0.1, "好感度双倍加持卡Ⅱ_prob": 0.2, "好感度双倍加持卡Ⅲ_prob": 0.3}, # type: ignore + **{ + "好感度双倍加持卡Ⅰ_prob": 0.1, + "好感度双倍加持卡Ⅱ_prob": 0.2, + "好感度双倍加持卡Ⅲ_prob": 0.3, + }, # type: ignore ) -async def _(session: EventSession, user_id: int, group_id: int, prob: float): +async def _(session: EventSession, user_id: int, prob: float): if session.id1: user_console = await UserConsole.get_user(session.id1, session.platform) user, _ = await SignUser.get_or_create( @@ -53,20 +57,24 @@ async def _(session: EventSession, user_id: int, group_id: int, prob: float): icon="sword.png", ) async def _(user_id: int, group_id: int): - print(user_id, group_id, "使用测试道具") + # print(user_id, group_id, "使用测试道具") + pass @shop_register.before_handle(name="测试道具A", load_status=False) async def _(user_id: int, group_id: int): - print(user_id, group_id, "第一个使用前函数(before handle)") + # print(user_id, group_id, "第一个使用前函数(before handle)") + pass @shop_register.before_handle(name="测试道具A", load_status=False) async def _(user_id: int, group_id: int): - print(user_id, group_id, "第二个使用前函数(before handle)222") - raise NotMeetUseConditionsException("太笨了!") # 抛出异常,阻断使用,并返回信息 + # print(user_id, group_id, "第二个使用前函数(before handle)222") + # raise NotMeetUseConditionsException("太笨了!") # 抛出异常,阻断使用,并返回信息 + pass @shop_register.after_handle(name="测试道具A", load_status=False) async def _(user_id: int, group_id: int): - print(user_id, group_id, "第一个使用后函数(after handle)") + # print(user_id, group_id, "第一个使用后函数(after handle)") + pass diff --git a/zhenxun/builtin_plugins/sign_in/utils.py b/zhenxun/builtin_plugins/sign_in/utils.py index 81f2a6d79..c78e63ed9 100644 --- a/zhenxun/builtin_plugins/sign_in/utils.py +++ b/zhenxun/builtin_plugins/sign_in/utils.py @@ -1,31 +1,36 @@ import os import random -from datetime import datetime from io import BytesIO from pathlib import Path +from datetime import datetime -import nonebot import pytz +import nonebot from nonebot.drivers import Driver from nonebot_plugin_htmlrender import template_to_pic -from zhenxun.configs.config import BotConfig, Config -from zhenxun.configs.path_config import IMAGE_PATH, TEMPLATE_PATH from zhenxun.models.sign_log import SignLog from zhenxun.models.sign_user import SignUser -from zhenxun.utils.image_utils import BuildImage from zhenxun.utils.utils import get_user_avatar +from zhenxun.utils.image_utils import BuildImage +from zhenxun.utils.platform import PlatformUtils +from zhenxun.configs.config import Config, BotConfig +from zhenxun.configs.path_config import IMAGE_PATH, TEMPLATE_PATH from .config import ( - SIGN_BACKGROUND_PATH, SIGN_BORDER_PATH, SIGN_RESOURCE_PATH, + SIGN_BACKGROUND_PATH, SIGN_TODAY_CARD_PATH, - level2attitude, lik2level, lik2relation, + level2attitude, ) +assert ( + len(level2attitude) == len(lik2level) == len(lik2relation) +), "好感度态度、等级、关系长度不匹配!" + AVA_URL = "http://q1.qlogo.cn/g?b=qq&nk={}&s=160" driver: Driver = nonebot.get_driver() @@ -87,26 +92,26 @@ async def get_card( card_file = Path(SIGN_TODAY_CARD_PATH) / file_name if card_file.exists(): return IMAGE_PATH / "sign" / "today_card" / file_name - else: - if add_impression == -1: - card_file = Path(SIGN_TODAY_CARD_PATH) / view_name - if card_file.exists(): - return card_file - is_card_view = True - if base_config.get("IMAGE_STYLE") == "zhenxun": - return await _generate_html_card( - user, nickname, add_impression, gold, gift, is_double, is_card_view - ) - else: - return await _generate_card( - user, nickname, add_impression, gold, gift, is_double, is_card_view - ) + if add_impression == -1: + card_file = Path(SIGN_TODAY_CARD_PATH) / view_name + if card_file.exists(): + return card_file + is_card_view = True + return ( + await _generate_html_card( + user, nickname, add_impression, gold, gift, is_double, is_card_view + ) + if base_config.get("IMAGE_STYLE") == "zhenxun" + else await _generate_card( + user, nickname, add_impression, gold, gift, is_double, is_card_view + ) + ) async def _generate_card( user: SignUser, nickname: str, - impression: float, + add_impression: float, gold: int | None, gift: str, is_double: bool = False, @@ -117,7 +122,7 @@ async def _generate_card( 参数: user: SignUser nickname: 用户昵称 - impression: 新增的好感度 + add_impression: 新增的好感度 gold: 金币 gift: 礼物 is_double: 是否触发双倍. @@ -139,16 +144,12 @@ async def _generate_card( await ava.circle() await ava_bk.paste(ava, (19, 18)) await ava_bk.paste(ava_border, center_type="center") - add_impression = impression impression = float(user.impression) info_img = BuildImage(250, 150, color=(255, 255, 255, 0), font_size=15) level, next_impression, previous_impression = get_level_and_next_impression( impression ) interpolation = next_impression - impression - if level == "9": - level = "8" - interpolation = 0 await info_img.text((0, 0), f"· 好感度等级:{level} [{lik2relation[level]}]") await info_img.text( (0, 20), f"· {BotConfig.self_nickname}对你的态度:{level2attitude[level]}" @@ -157,16 +158,12 @@ async def _generate_card( bar_bk = BuildImage(220, 20, background=SIGN_RESOURCE_PATH / "bar_white.png") bar = BuildImage(220, 20, background=SIGN_RESOURCE_PATH / "bar.png") - ratio = 1 - (next_impression - user.impression) / ( - next_impression - previous_impression - ) + ratio = 1 - (next_impression - impression) / (next_impression - previous_impression) if next_impression == 0: ratio = 0 await bar.resize(width=int(bar.width * ratio) or 1, height=bar.height) await bar_bk.paste(bar) - font_size = 30 - if "好感度双倍加持卡" in gift: - font_size = 20 + font_size = 20 if "好感度双倍加持卡" in gift else 30 gift_border = BuildImage( 270, 100, @@ -191,9 +188,9 @@ async def _generate_card( nickname, size=50, font_color=(255, 255, 255) ) user_console = await user.user_console - if user_console and user_console.uid: + if user_console and user_console.uid is not None: uid = f"{user_console.uid}".rjust(12, "0") - uid = uid[:4] + " " + uid[4:8] + " " + uid[8:] + uid = f"{uid[:4]} {uid[4:8]} {uid[8:]}" else: uid = "XXXX XXXX XXXX" uid_img = await BuildImage.build_text_image( @@ -249,9 +246,12 @@ async def _generate_card( default_setu_prob = ( Config.get_config("send_setu", "INITIAL_SETU_PROBABILITY") * 100 # type: ignore ) + setu_prob = ( + default_setu_prob + float(user.impression) if user.impression < 100 else 100 + ) await today_data.text( (0, 50), - f"色图概率:{(default_setu_prob + float(user.impression) if user.impression < 100 else 100):.2f}%", + f"色图概率:{setu_prob:.2f}%", ) await today_data.text((0, 75), f"开箱次数:{(20 + int(user.impression / 3))}") _type = "view" @@ -311,11 +311,11 @@ async def generate_progress_bar_pic(): step_g = (bg_2[1] - bg_1[1]) / width step_b = (bg_2[2] - bg_1[2]) / width - for y in range(0, width): + for y in range(width): bg_r = round(bg_1[0] + step_r * y) bg_g = round(bg_1[1] + step_g * y) bg_b = round(bg_1[2] + step_b * y) - for x in range(0, height): + for x in range(height): await A.point((y, x), fill=(bg_r, bg_g, bg_b)) await bk.paste(img_y, (0, 0)) await bk.paste(A, (25, 0)) @@ -336,22 +336,33 @@ async def generate_progress_bar_pic(): await bk.save(SIGN_RESOURCE_PATH / "bar_white.png") -def get_level_and_next_impression(impression: float) -> tuple[str, int, int]: +def get_level_and_next_impression(impression: float) -> tuple[str, int | float, int]: """获取当前好感等级与下一等级的差距 参数: impression: 好感度 返回: - tuple[str, int, int]: 好感度等级中文,好感度等级,下一等级好感差距 + tuple[str, int, int]: 好感度等级,下一等级好感度要求,已达到的好感度要求 """ - if impression == 0: - return lik2level[10], 10, 0 + keys = list(lik2level.keys()) + level, next_impression, previous_impression = ( + lik2level[keys[-1]], + keys[-2], + keys[-1], + ) for i in range(len(keys)): - if impression > keys[i]: - return lik2level[keys[i]], keys[i - 1], keys[i] - return lik2level[10], 10, 0 + if impression >= keys[i]: + level, next_impression, previous_impression = ( + lik2level[keys[i]], + keys[i - 1], + keys[i], + ) + if i == 0: + next_impression = impression + break + return level, next_impression, previous_impression def clear_sign_data_pic(): @@ -367,7 +378,7 @@ def clear_sign_data_pic(): async def _generate_html_card( user: SignUser, nickname: str, - impression: float, + add_impression: float, gold: int | None, gift: str, is_double: bool = False, @@ -378,7 +389,7 @@ async def _generate_html_card( 参数: user: SignUser nickname: 用户昵称 - impression: 新增的好感度 + add_impression: 新增的好感度 gold: 金币 gift: 礼物 is_double: 是否触发双倍. @@ -387,41 +398,36 @@ async def _generate_html_card( 返回: Path: 卡片路径 """ + impression = float(user.impression) user_console = await user.user_console - if user_console and user_console.uid: + if user_console and user_console.uid is not None: uid = f"{user_console.uid}".rjust(12, "0") - uid = uid[:4] + " " + uid[4:8] + " " + uid[8:] + uid = f"{uid[:4]} {uid[4:8]} {uid[8:]}" else: uid = "XXXX XXXX XXXX" level, next_impression, previous_impression = get_level_and_next_impression( - float(user.impression) + impression ) interpolation = next_impression - impression - if level == "9": - level = "8" - interpolation = 0 message = f"{BotConfig.self_nickname}希望你开心!" hour = datetime.now().hour if hour > 6 and hour < 10: message = random.choice(MORNING_MESSAGE) elif hour >= 0 and hour < 6: message = random.choice(LG_MESSAGE) - _impression = impression - if is_double: - _impression = f"{impression}(×2)" - process = 1 - (next_impression - user.impression) / ( + _impression = f"{add_impression}(×2)" if is_double else add_impression + process = 1 - (next_impression - impression) / ( next_impression - previous_impression ) - if next_impression == 0: - process = 0 now = datetime.now() + ava_url = PlatformUtils.get_user_avatar_url(user.user_id, "qq") data = { - "ava": AVA_URL.format(user.user_id), + "ava_url": ava_url, "name": nickname, "uid": uid, "sign_count": f"{user.sign_count}", "message": f"{BotConfig.self_nickname}说: {message}", - "cur_impression": f"{user.impression:.2f}", + "cur_impression": f"{impression:.2f}", "impression": f"好感度+{_impression}", "gold": f"金币+{gold}", "gift": gift, @@ -429,7 +435,7 @@ async def _generate_html_card( "attitude": f"对你的态度: {level2attitude[level]}", "interpolation": f"{interpolation:.2f}", "heart2": [1 for _ in range(int(level))], - "heart1": [1 for _ in range(9 - int(level))], + "heart1": [1 for _ in range(len(lik2level) - int(level) - 1)], "process": process * 100, "date": str(now.replace(microsecond=0)), "font_size": 45, diff --git a/zhenxun/builtin_plugins/superuser/request_manage.py b/zhenxun/builtin_plugins/superuser/request_manage.py index 7f7c4497c..d9eb8d149 100644 --- a/zhenxun/builtin_plugins/superuser/request_manage.py +++ b/zhenxun/builtin_plugins/superuser/request_manage.py @@ -224,8 +224,8 @@ async def _( await _id_img.circle_corner(10) await background.paste(_id_img, (10, 0), center_type="height") img_list.append(background) - A = await BuildImage.auto_paste(img_list, 1) - if A: + if img_list: + A = await BuildImage.auto_paste(img_list, 1) result_image = BuildImage( A.width, A.height + 30, color=(255, 255, 255), font_size=20 ) @@ -237,8 +237,8 @@ async def _( await MessageUtils.build_message("没有任何请求喔...").finish(reply_to=True) if len(req_image_list) == 1: await MessageUtils.build_message(req_image_list[0]).finish() - width = sum([img.width for img in req_image_list]) - height = max([img.height for img in req_image_list]) + width = sum(img.width for img in req_image_list) + height = max(img.height for img in req_image_list) background = BuildImage(width, height) await background.paste(req_image_list[0]) await req_image_list[1].line((0, 10, 1, req_image_list[1].height - 10), width=1) diff --git a/zhenxun/builtin_plugins/superuser/set_admin.py b/zhenxun/builtin_plugins/superuser/set_admin.py index 033d7a3de..7feaf43c6 100644 --- a/zhenxun/builtin_plugins/superuser/set_admin.py +++ b/zhenxun/builtin_plugins/superuser/set_admin.py @@ -1,35 +1,36 @@ from nonebot.permission import SUPERUSER from nonebot.plugin import PluginMetadata +from nonebot_plugin_session import EventSession, SessionLevel from nonebot_plugin_alconna import ( - Alconna, - Args, - Arparma, At, + Args, Match, + Option, + Alconna, + Arparma, Subcommand, on_alconna, ) -from nonebot_plugin_session import EventSession, SessionLevel -from zhenxun.configs.utils import PluginExtraData -from zhenxun.models.level_user import LevelUser from zhenxun.services.log import logger from zhenxun.utils.enum import PluginType from zhenxun.utils.message import MessageUtils +from zhenxun.models.level_user import LevelUser +from zhenxun.configs.utils import PluginExtraData __plugin_meta__ = PluginMetadata( name="用户权限管理", description="设置用户权限", usage=""" - 权限设置 add [level: 权限等级] [at: at对象或用户id] [gid: 群组] - 权限设置 delete [at: at对象或用户id] - - 权限设置 add 5 @user - 权限设置 add 5 422 352352 + 权限设置 add [level: 权限等级] [at: at对象或用户id] ?[-g gid: 群组] + 权限设置 delete [at: at对象或用户id] ?[-g gid: 群组] + + 添加权限 5 @user + 权限设置 add 5 422 -g 352352 + + 删除权限 @user + 删除权限 1234123 -g 123123 - 权限设置 delete @user - 权限设置 delete 123456 - """.strip(), extra=PluginExtraData( author="HibiKier", @@ -44,16 +45,31 @@ "权限设置", Subcommand( "add", - Args["level", int]["uid", [str, At]]["gid?", str], + Args["level", int]["uid", [str, At]], help_text="添加权限", ), - Subcommand("delete", Args["uid", [str, At]]["gid?", str], help_text="删除权限"), + Subcommand("delete", Args["uid", [str, At]], help_text="删除权限"), + Option("-g|--group", Args["gid", str], help_text="指定群组"), ), permission=SUPERUSER, priority=5, block=True, ) +_matcher.shortcut( + "添加权限", + command="权限设置", + arguments=["add", "{%0}"], + prefix=True, +) + +_matcher.shortcut( + "删除权限", + command="权限设置", + arguments=["delete", "{%0}"], + prefix=True, +) + @_matcher.assign("add") async def _( @@ -82,9 +98,10 @@ async def _( ] ).finish(reply_to=True) await MessageUtils.build_message( - f"成功为 \n群组:{group_id}\n用户:{uid} \n设置权限!\n权限:{old_level} -> {level}" + f"成功为 \n群组:{group_id}\n用户:{uid} \n" + f"设置权限!\n权限:{old_level} -> {level}" ).finish() - await MessageUtils.build_message(f"设置权限时群组不能为空...").finish() + await MessageUtils.build_message("设置权限时群组不能为空...").finish() @_matcher.assign("delete") @@ -107,7 +124,7 @@ async def _( session=session, ) await MessageUtils.build_message( - ["成功删除 ", At(flag="user", target=uid), f" 的权限等级!"] + ["成功删除 ", At(flag="user", target=uid), " 的权限等级!"] ).finish(reply_to=True) logger.info( f"删除群组用户权限: {user.user_level} -> 0", @@ -115,7 +132,8 @@ async def _( session=session, ) await MessageUtils.build_message( - f"成功删除 \n群组:{group_id}\n用户:{uid} \n的权限等级!\n权限:{user.user_level} -> 0" + f"成功删除 \n群组:{group_id}\n用户:{uid} \n" + f"的权限等级!\n权限:{user.user_level} -> 0" ).finish() - await MessageUtils.build_message(f"对方目前暂无权限喔...").finish() - await MessageUtils.build_message(f"设置权限时群组不能为空...").finish() + await MessageUtils.build_message("对方目前暂无权限喔...").finish() + await MessageUtils.build_message("设置权限时群组不能为空...").finish() diff --git a/zhenxun/builtin_plugins/superuser/super_help.py b/zhenxun/builtin_plugins/superuser/super_help.py deleted file mode 100644 index 5fa1e09e4..000000000 --- a/zhenxun/builtin_plugins/superuser/super_help.py +++ /dev/null @@ -1,159 +0,0 @@ -import nonebot -from nonebot.permission import SUPERUSER -from nonebot.plugin import PluginMetadata -from nonebot_plugin_alconna import Alconna, Arparma, on_alconna -from nonebot_plugin_alconna.matcher import AlconnaMatcher -from nonebot_plugin_session import EventSession - -from zhenxun.configs.path_config import IMAGE_PATH -from zhenxun.configs.utils import PluginExtraData -from zhenxun.models.plugin_info import PluginInfo -from zhenxun.models.task_info import TaskInfo -from zhenxun.services.log import logger -from zhenxun.utils.enum import PluginType -from zhenxun.utils.exception import EmptyError -from zhenxun.utils.image_utils import ( - BuildImage, - build_sort_image, - group_image, - text2image, -) -from zhenxun.utils.message import MessageUtils -from zhenxun.utils.rules import admin_check, ensure_group - -__plugin_meta__ = PluginMetadata( - name="超级用户帮助", - description="超级用户帮助", - usage=""" - 超级用户帮助 - """.strip(), - extra=PluginExtraData( - author="HibiKier", - version="0.1", - plugin_type=PluginType.SUPERUSER, - ).dict(), -) - -_matcher = on_alconna( - Alconna("超级用户帮助"), - permission=SUPERUSER, - priority=5, - block=True, -) - - -SUPERUSER_HELP_IMAGE = IMAGE_PATH / "SUPERUSER_HELP.png" -if SUPERUSER_HELP_IMAGE.exists(): - SUPERUSER_HELP_IMAGE.unlink() - - -async def build_help() -> BuildImage: - """构造超级用户帮助图片 - - 异常: - EmptyError: 超级用户帮助为空 - - 返回: - BuildImage: 超级用户帮助图片 - """ - plugin_list = await PluginInfo.filter(plugin_type=PluginType.SUPERUSER).all() - data_list = [] - for plugin in plugin_list: - if _plugin := nonebot.get_plugin_by_module_name(plugin.module_path): - if _plugin.metadata: - data_list.append({"plugin": plugin, "metadata": _plugin.metadata}) - font = BuildImage.load_font("HYWenHei-85W.ttf", 20) - image_list = [] - for data in data_list: - plugin = data["plugin"] - metadata = data["metadata"] - try: - usage = None - description = None - if metadata.usage: - usage = await text2image( - metadata.usage, - padding=5, - color=(255, 255, 255), - font_color=(0, 0, 0), - ) - if metadata.description: - description = await text2image( - metadata.description, - padding=5, - color=(255, 255, 255), - font_color=(0, 0, 0), - ) - width = 0 - height = 100 - if usage: - width = usage.width - height += usage.height - if description and description.width > width: - width = description.width - height += description.height - font_width, font_height = BuildImage.get_text_size( - plugin.name + f"[{plugin.level}]", font - ) - if font_width > width: - width = font_width - A = BuildImage(width + 30, height + 120, "#EAEDF2") - await A.text((15, 10), plugin.name + f"[{plugin.level}]") - await A.text((15, 70), "简介:") - if not description: - description = BuildImage(A.width - 30, 30, (255, 255, 255)) - await description.circle_corner(10) - await A.paste(description, (15, 100)) - if not usage: - usage = BuildImage(A.width - 30, 30, (255, 255, 255)) - await usage.circle_corner(10) - await A.text((15, description.height + 115), "用法:") - await A.paste(usage, (15, description.height + 145)) - await A.circle_corner(10) - image_list.append(A) - except Exception as e: - logger.warning( - f"获取超级用户管理员插件 {plugin.module}: {plugin.name} 设置失败...", - "超级用户帮助", - e=e, - ) - if task_list := await TaskInfo.all(): - task_str = "\n".join([task.name for task in task_list]) - task_str = "通过 开启/关闭群被动 来控制群被动\n----------\n" + task_str - task_image = await text2image(task_str, padding=5, color=(255, 255, 255)) - await task_image.circle_corner(10) - A = BuildImage(task_image.width + 50, task_image.height + 85, "#EAEDF2") - await A.text((25, 10), "被动技能") - await A.paste(task_image, (25, 50)) - await A.circle_corner(10) - image_list.append(A) - if not image_list: - raise EmptyError() - image_group, _ = group_image(image_list) - A = await build_sort_image(image_group, color=(255, 255, 255), padding_top=160) - text = await BuildImage.build_text_image( - "超级用户帮助", - size=40, - ) - tip = await BuildImage.build_text_image( - "注: ‘*’ 代表可有多个相同参数 ‘?’ 代表可省略该参数", size=25, font_color="red" - ) - await A.paste(text, (50, 30)) - await A.paste(tip, (50, 90)) - await A.save(SUPERUSER_HELP_IMAGE) - return BuildImage(1, 1) - - -@_matcher.handle() -async def _( - session: EventSession, - matcher: AlconnaMatcher, - arparma: Arparma, -): - if not SUPERUSER_HELP_IMAGE.exists(): - try: - await build_help() - except EmptyError: - await MessageUtils.build_message("超级用户帮助为空").finish(reply_to=True) - await MessageUtils.build_message(SUPERUSER_HELP_IMAGE).send() - logger.info("查看超级用户帮助", arparma.header_result, session=session) diff --git a/zhenxun/builtin_plugins/superuser/super_help/__init__.py b/zhenxun/builtin_plugins/superuser/super_help/__init__.py new file mode 100644 index 000000000..d9b980974 --- /dev/null +++ b/zhenxun/builtin_plugins/superuser/super_help/__init__.py @@ -0,0 +1,59 @@ +from nonebot.permission import SUPERUSER +from nonebot.plugin import PluginMetadata +from nonebot_plugin_session import EventSession +from nonebot_plugin_alconna import Alconna, Arparma, on_alconna + +from zhenxun.services.log import logger +from zhenxun.configs.config import Config +from zhenxun.utils.enum import PluginType +from zhenxun.utils.exception import EmptyError +from zhenxun.utils.message import MessageUtils +from zhenxun.configs.utils import RegisterConfig, PluginExtraData + +from .normal_help import build_help +from .config import SUPERUSER_HELP_IMAGE +from .zhenxun_help import build_html_help + +__plugin_meta__ = PluginMetadata( + name="超级用户帮助", + description="超级用户帮助", + usage=""" + 超级用户帮助 + """.strip(), + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.SUPERUSER, + configs=[ + RegisterConfig( + key="type", + value="zhenxun", + help="超级用户帮助样式,normal, zhenxun", + default_value="zhenxun", + ) + ], + ).dict(), +) + +_matcher = on_alconna( + Alconna("超级用户帮助"), + permission=SUPERUSER, + priority=5, + block=True, +) + + +@_matcher.handle() +async def _(session: EventSession, arparma: Arparma): + if not SUPERUSER_HELP_IMAGE.exists(): + try: + if Config.get_config("admin_help", "type") == "zhenxun": + await build_html_help() + else: + await build_help() + except EmptyError: + await MessageUtils.build_message("当前超级用户帮助为空...").finish( + reply_to=True + ) + await MessageUtils.build_message(SUPERUSER_HELP_IMAGE).send() + logger.info("查看超级用户帮助", arparma.header_result, session=session) diff --git a/zhenxun/builtin_plugins/superuser/super_help/config.py b/zhenxun/builtin_plugins/superuser/super_help/config.py new file mode 100644 index 000000000..55e32f514 --- /dev/null +++ b/zhenxun/builtin_plugins/superuser/super_help/config.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel +from nonebot.plugin import PluginMetadata + +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.configs.path_config import IMAGE_PATH + +SUPERUSER_HELP_IMAGE = IMAGE_PATH / "SUPERUSER_HELP.png" +if SUPERUSER_HELP_IMAGE.exists(): + SUPERUSER_HELP_IMAGE.unlink() + + +class PluginData(BaseModel): + """ + 插件信息 + """ + + plugin: PluginInfo + """插件信息""" + metadata: PluginMetadata + """元数据""" + + class Config: + arbitrary_types_allowed = True diff --git a/zhenxun/builtin_plugins/superuser/super_help/normal_help.py b/zhenxun/builtin_plugins/superuser/super_help/normal_help.py new file mode 100644 index 000000000..0fbdb7747 --- /dev/null +++ b/zhenxun/builtin_plugins/superuser/super_help/normal_help.py @@ -0,0 +1,127 @@ +from PIL.ImageFont import FreeTypeFont +from nonebot.plugin import PluginMetadata + +from zhenxun.services.log import logger +from zhenxun.models.task_info import TaskInfo +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.utils._build_image import BuildImage +from zhenxun.utils.image_utils import text2image, group_image, build_sort_image + +from .utils import get_plugins +from .config import SUPERUSER_HELP_IMAGE + + +async def build_usage_des_image( + metadata: PluginMetadata, +) -> tuple[BuildImage | None, BuildImage | None]: + """构建用法和描述图片 + + 参数: + metadata: PluginMetadata + + 返回: + tuple[BuildImage | None, BuildImage | None]: 用法和描述图片 + """ + usage = None + description = None + if metadata.usage: + usage = await text2image( + metadata.usage, + padding=5, + color=(255, 255, 255), + font_color=(0, 0, 0), + ) + if metadata.description: + description = await text2image( + metadata.description, + padding=5, + color=(255, 255, 255), + font_color=(0, 0, 0), + ) + return usage, description + + +async def build_image( + plugin: PluginInfo, metadata: PluginMetadata, font: FreeTypeFont +) -> BuildImage: + """构建帮助图片 + + 参数: + plugin: PluginInfo + metadata: PluginMetadata + font: FreeTypeFont + + 返回: + BuildImage: 帮助图片 + + """ + usage, description = await build_usage_des_image(metadata) + width = 0 + height = 100 + if usage: + width = usage.width + height += usage.height + if description and description.width > width: + width = description.width + height += description.height + font_width, _ = BuildImage.get_text_size(f"{plugin.name}[{plugin.level}]", font) + if font_width > width: + width = font_width + A = BuildImage(width + 30, height + 120, "#EAEDF2") + await A.text((15, 10), f"{plugin.name}[{plugin.level}]") + await A.text((15, 70), "简介:") + if not description: + description = BuildImage(A.width - 30, 30, (255, 255, 255)) + await description.circle_corner(10) + await A.paste(description, (15, 100)) + if not usage: + usage = BuildImage(A.width - 30, 30, (255, 255, 255)) + await usage.circle_corner(10) + await A.text((15, description.height + 115), "用法:") + await A.paste(usage, (15, description.height + 145)) + await A.circle_corner(10) + return A + + +async def build_help(): + """构造超级用户帮助图片 + + 返回: + BuildImage: 超级用户帮助图片 + """ + font = BuildImage.load_font("HYWenHei-85W.ttf", 20) + image_list = [] + for data in await get_plugins(): + plugin = data.plugin + metadata = data.metadata + try: + A = await build_image(plugin, metadata, font) + image_list.append(A) + except Exception as e: + logger.warning( + f"获取群超级用户插件 {plugin.module}: {plugin.name} 设置失败...", + "超级用户帮助", + e=e, + ) + if task_list := await TaskInfo.all(): + task_str = "\n".join([task.name for task in task_list]) + task_str = "通过 开启/关闭群被动 来控制群被动\n----------\n" + task_str + task_image = await text2image(task_str, padding=5, color=(255, 255, 255)) + await task_image.circle_corner(10) + A = BuildImage(task_image.width + 50, task_image.height + 85, "#EAEDF2") + await A.text((25, 10), "被动技能") + await A.paste(task_image, (25, 50)) + await A.circle_corner(10) + image_list.append(A) + image_group, _ = group_image(image_list) + A = await build_sort_image(image_group, color=(255, 255, 255), padding_top=160) + text = await BuildImage.build_text_image( + "群超级用户帮助", + size=40, + ) + tip = await BuildImage.build_text_image( + "注: ‘*’ 代表可有多个相同参数 ‘?’ 代表可省略该参数", size=25, font_color="red" + ) + await A.paste(text, (50, 30)) + await A.paste(tip, (50, 90)) + await A.save(SUPERUSER_HELP_IMAGE) diff --git a/zhenxun/builtin_plugins/superuser/super_help/utils.py b/zhenxun/builtin_plugins/superuser/super_help/utils.py new file mode 100644 index 000000000..43ea0e6fc --- /dev/null +++ b/zhenxun/builtin_plugins/superuser/super_help/utils.py @@ -0,0 +1,22 @@ +import nonebot + +from zhenxun.utils.enum import PluginType +from zhenxun.utils.exception import EmptyError +from zhenxun.models.plugin_info import PluginInfo + +from .config import PluginData + + +async def get_plugins() -> list[PluginData]: + """获取插件数据""" + plugin_list = await PluginInfo.filter( + plugin_type__in=[PluginType.SUPERUSER, PluginType.SUPER_AND_ADMIN] + ).all() + data_list = [] + for plugin in plugin_list: + if _plugin := nonebot.get_plugin_by_module_name(plugin.module_path): + if _plugin.metadata: + data_list.append(PluginData(plugin=plugin, metadata=_plugin.metadata)) + if not data_list: + raise EmptyError() + return data_list diff --git a/zhenxun/builtin_plugins/superuser/super_help/zhenxun_help.py b/zhenxun/builtin_plugins/superuser/super_help/zhenxun_help.py new file mode 100644 index 000000000..cf389e87f --- /dev/null +++ b/zhenxun/builtin_plugins/superuser/super_help/zhenxun_help.py @@ -0,0 +1,59 @@ +from nonebot_plugin_htmlrender import template_to_pic + +from zhenxun.configs.config import BotConfig +from zhenxun.models.task_info import TaskInfo +from zhenxun.utils._build_image import BuildImage +from zhenxun.configs.path_config import TEMPLATE_PATH + +from .utils import get_plugins +from .config import SUPERUSER_HELP_IMAGE + + +async def get_task() -> dict[str, str] | None: + """获取被动技能帮助""" + if task_list := await TaskInfo.all(): + return { + "name": "被动技能", + "description": "控制群组中的被动技能状态", + "usage": "通过 开启/关闭群被动 来控制群被动
----------
" + + "
".join([task.name for task in task_list]), + } + return None + + +async def build_html_help(): + """构建帮助图片""" + plugins = await get_plugins() + plugin_list = [] + for data in plugins: + if data.metadata.extra: + if superuser_help := data.metadata.extra.get("superuser_help"): + data.metadata.usage += f"
以下为超级用户额外命令
{superuser_help}" + plugin_list.append( + { + "name": data.plugin.name, + "description": data.metadata.description.replace("\n", "
"), + "usage": data.metadata.usage.replace("\n", "
"), + } + ) + if task := await get_task(): + plugin_list.append(task) + plugin_list.sort(key=lambda p: len(p["description"]) + len(p["usage"])) + pic = await template_to_pic( + template_path=str((TEMPLATE_PATH / "help").absolute()), + template_name="main.html", + templates={ + "data": { + "plugin_list": plugin_list, + "nickname": BotConfig.self_nickname, + "help_name": "超级用户", + } + }, + pages={ + "viewport": {"width": 1024, "height": 1024}, + "base_url": f"file://{TEMPLATE_PATH}", + }, + wait=2, + ) + result = await BuildImage.open(pic).resize(0.5) + await result.save(SUPERUSER_HELP_IMAGE) diff --git a/zhenxun/builtin_plugins/web_ui/__init__.py b/zhenxun/builtin_plugins/web_ui/__init__.py index a1beb51c2..83ea0c801 100644 --- a/zhenxun/builtin_plugins/web_ui/__init__.py +++ b/zhenxun/builtin_plugins/web_ui/__init__.py @@ -20,8 +20,10 @@ from .api.tabs.system import router as system_router from .api.tabs.main import ws_router as status_routes from .api.tabs.database import router as database_router +from .api.tabs.dashboard import router as dashboard_router from .api.tabs.manage.chat import ws_router as chat_routes from .api.tabs.plugin_manage import router as plugin_router +from .api.tabs.plugin_manage.store import router as store_router __plugin_meta__ = PluginMetadata( name="WebUi", @@ -71,6 +73,8 @@ BaseApiRouter.include_router(auth_router) +BaseApiRouter.include_router(store_router) +BaseApiRouter.include_router(dashboard_router) BaseApiRouter.include_router(main_router) BaseApiRouter.include_router(manage_router) BaseApiRouter.include_router(database_router) diff --git a/zhenxun/builtin_plugins/web_ui/api/__init__.py b/zhenxun/builtin_plugins/web_ui/api/__init__.py index 32d31b27e..de9b37986 100644 --- a/zhenxun/builtin_plugins/web_ui/api/__init__.py +++ b/zhenxun/builtin_plugins/web_ui/api/__init__.py @@ -1 +1 @@ -from .tabs import * +from .tabs import * # noqa: F403 diff --git a/zhenxun/builtin_plugins/web_ui/api/logs/__init__.py b/zhenxun/builtin_plugins/web_ui/api/logs/__init__.py index d66848887..5f44e4439 100644 --- a/zhenxun/builtin_plugins/web_ui/api/logs/__init__.py +++ b/zhenxun/builtin_plugins/web_ui/api/logs/__init__.py @@ -1 +1 @@ -from .logs import * +from .logs import * # noqa: F403 diff --git a/zhenxun/builtin_plugins/web_ui/api/logs/log_manager.py b/zhenxun/builtin_plugins/web_ui/api/logs/log_manager.py index 71992c915..83e0fb011 100644 --- a/zhenxun/builtin_plugins/web_ui/api/logs/log_manager.py +++ b/zhenxun/builtin_plugins/web_ui/api/logs/log_manager.py @@ -1,7 +1,6 @@ import asyncio -from typing import Awaitable, Callable, Generic, TypeVar - -PATTERN = r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))" +from typing import Generic, TypeVar +from collections.abc import Callable, Awaitable _T = TypeVar("_T") LogListener = Callable[[_T], Awaitable[None]] @@ -22,14 +21,13 @@ async def add(self, log: str): self.logs[seq] = log asyncio.get_running_loop().call_later(self.rotation, self.remove, seq) await asyncio.gather( - *map(lambda listener: listener(log), self.listeners), + *(listener(log) for listener in self.listeners), return_exceptions=True, ) return seq def remove(self, seq: int): del self.logs[seq] - return LOG_STORAGE: LogStorage[str] = LogStorage[str]() diff --git a/zhenxun/builtin_plugins/web_ui/api/logs/logs.py b/zhenxun/builtin_plugins/web_ui/api/logs/logs.py index fcd4cce89..1a4205832 100644 --- a/zhenxun/builtin_plugins/web_ui/api/logs/logs.py +++ b/zhenxun/builtin_plugins/web_ui/api/logs/logs.py @@ -1,7 +1,7 @@ -from fastapi import APIRouter, WebSocket from loguru import logger +from fastapi import APIRouter from nonebot.utils import escape_tag -from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState +from starlette.websockets import WebSocket, WebSocketState, WebSocketDisconnect from .log_manager import LOG_STORAGE @@ -27,4 +27,3 @@ async def log_listener(log: str): pass finally: LOG_STORAGE.listeners.remove(log_listener) - return diff --git a/zhenxun/builtin_plugins/web_ui/api/tabs/dashboard/__init__.py b/zhenxun/builtin_plugins/web_ui/api/tabs/dashboard/__init__.py new file mode 100644 index 000000000..601592967 --- /dev/null +++ b/zhenxun/builtin_plugins/web_ui/api/tabs/dashboard/__init__.py @@ -0,0 +1,22 @@ +from nonebot import require +from fastapi import APIRouter + +from ....base_model import Result +from .data_source import BotManage +from ....utils import authentication + +require("plugin_store") + +router = APIRouter(prefix="/dashboard") + + +@router.get( + "/get_bot_list", + dependencies=[authentication()], + deprecated="获取bot列表", # type: ignore +) +async def _() -> Result: + try: + return Result.ok(await BotManage.get_bot_list(), "拿到信息啦!") + except Exception as e: + return Result.fail(f"发生了一点错误捏 {type(e)}: {e}") diff --git a/zhenxun/builtin_plugins/web_ui/api/tabs/dashboard/data_source.py b/zhenxun/builtin_plugins/web_ui/api/tabs/dashboard/data_source.py new file mode 100644 index 000000000..b09d4ba96 --- /dev/null +++ b/zhenxun/builtin_plugins/web_ui/api/tabs/dashboard/data_source.py @@ -0,0 +1,81 @@ +import time +from datetime import datetime, timedelta + +import nonebot +from nonebot.adapters import Bot +from nonebot.drivers import Driver + +from zhenxun.models.statistics import Statistics +from zhenxun.utils.platform import PlatformUtils +from zhenxun.models.chat_history import ChatHistory + +from .model import BotInfo +from ..main.data_source import bot_live + +driver: Driver = nonebot.get_driver() + + +CONNECT_TIME = 0 + + +@driver.on_startup +async def _(): + global CONNECT_TIME + CONNECT_TIME = int(time.time()) + + +class BotManage: + @classmethod + async def __build_bot_info(cls, bot: Bot) -> BotInfo: + """构建Bot信息 + + 参数: + bot: Bot + + 返回: + BotInfo: Bot信息 + """ + now = datetime.now() + platform = PlatformUtils.get_platform(bot) or "" + if platform == "qq": + login_info = await bot.get_login_info() + nickname = login_info["nickname"] + ava_url = PlatformUtils.get_user_avatar_url(bot.self_id, "qq") or "" + else: + nickname = bot.self_id + ava_url = "" + bot_info = BotInfo( + self_id=bot.self_id, nickname=nickname, ava_url=ava_url, platform=platform + ) + group_list, _ = await PlatformUtils.get_group_list(bot) + group_list = [g for g in group_list if g.channel_id is None] + friend_list = await PlatformUtils.get_friend_list(bot) + bot_info.group_count = len(group_list) + bot_info.friend_count = len(friend_list) + bot_info.day_call = await Statistics.filter( + create_time__gte=now - timedelta(hours=now.hour, minutes=now.minute) + ).count() + bot_info.received_messages = await ChatHistory.filter( + bot_id=bot_info.self_id, + create_time__gte=now - timedelta(hours=now.hour, minutes=now.minute), + ).count() + bot_info.connect_time = bot_live.get(bot.self_id) or 0 + if bot_info.connect_time: + connect_date = datetime.fromtimestamp(CONNECT_TIME) + connect_date_str = connect_date.strftime("%Y-%m-%d %H:%M:%S") + bot_info.connect_date = datetime.strptime( + connect_date_str, "%Y-%m-%d %H:%M:%S" + ) + return bot_info + + @classmethod + async def get_bot_list(cls) -> list[BotInfo]: + """获取bot列表 + + 返回: + list[BotInfo]: Bot列表 + """ + bot_list: list[BotInfo] = [] + for _, bot in nonebot.get_bots().items(): + bot_list.append(await cls.__build_bot_info(bot)) + return bot_list diff --git a/zhenxun/builtin_plugins/web_ui/api/tabs/dashboard/model.py b/zhenxun/builtin_plugins/web_ui/api/tabs/dashboard/model.py new file mode 100644 index 000000000..d2891251b --- /dev/null +++ b/zhenxun/builtin_plugins/web_ui/api/tabs/dashboard/model.py @@ -0,0 +1,26 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class BotInfo(BaseModel): + self_id: str + """SELF ID""" + nickname: str + """昵称""" + ava_url: str + """头像url""" + platform: str + """平台""" + friend_count: int = 0 + """好友数量""" + group_count: int = 0 + """群聊数量""" + received_messages: int = 0 + """今日消息接收""" + day_call: int = 0 + """今日调用插件次数""" + connect_time: int = 0 + """连接时间""" + connect_date: datetime | None = None + """连接日期""" diff --git a/zhenxun/builtin_plugins/web_ui/api/tabs/database/__init__.py b/zhenxun/builtin_plugins/web_ui/api/tabs/database/__init__.py index d334c08c6..93c9f31ff 100644 --- a/zhenxun/builtin_plugins/web_ui/api/tabs/database/__init__.py +++ b/zhenxun/builtin_plugins/web_ui/api/tabs/database/__init__.py @@ -1,16 +1,16 @@ import nonebot -from fastapi import APIRouter, Request -from nonebot.drivers import Driver from tortoise import Tortoise +from nonebot.drivers import Driver +from fastapi import Request, APIRouter from tortoise.exceptions import OperationalError -from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.task_info import TaskInfo +from zhenxun.models.plugin_info import PluginInfo -from ....base_model import BaseResultModel, QueryModel, Result -from ....utils import authentication -from .models.model import SqlModel, SqlText from .models.sql_log import SqlLog +from ....utils import authentication +from .models.model import SqlText, SqlModel +from ....base_model import Result, QueryModel, BaseResultModel router = APIRouter(prefix="/database") @@ -24,7 +24,8 @@ SELECT_TABLE_SQL = """ select a.tablename as name,d.description as desc from pg_tables a left join pg_class c on relname=tablename - left join pg_description d on oid=objoid and objsubid=0 where a.schemaname = 'public' + left join pg_description d on oid=objoid + and objsubid=0 where a.schemaname = 'public' """ SELECT_TABLE_COLUMN_SQL = """ @@ -57,10 +58,7 @@ async def _(): module2name = {r[0]: r[1] for r in result} for s in SQL_DICT: module = SQL_DICT[s].module - if module in module2name: - SQL_DICT[s].name = module2name[module] - else: - SQL_DICT[s].name = module + SQL_DICT[s].name = module2name.get(module, module) @router.get( @@ -77,7 +75,7 @@ async def _() -> Result: ) async def _(table_name: str) -> Result: db = Tortoise.get_connection("default") - print(SELECT_TABLE_COLUMN_SQL.format(table_name)) + # print(SELECT_TABLE_COLUMN_SQL.format(table_name)) query = await db.execute_query_dict(SELECT_TABLE_COLUMN_SQL.format(table_name)) return Result.ok(query) @@ -92,7 +90,7 @@ async def _(sql: SqlText, request: Request) -> Result: await SqlLog.add(ip or "0.0.0.0", sql.sql, "") return Result.ok(res, "执行成功啦!") else: - result = await TestSQL.raw(sql.sql) + result = await TaskInfo.raw(sql.sql) await SqlLog.add(ip or "0.0.0.0", sql.sql, str(result)) return Result.ok(info="执行成功啦!") except OperationalError as e: diff --git a/zhenxun/builtin_plugins/web_ui/api/tabs/database/models/sql_log.py b/zhenxun/builtin_plugins/web_ui/api/tabs/database/models/sql_log.py index 691f1b5a8..73670d60f 100644 --- a/zhenxun/builtin_plugins/web_ui/api/tabs/database/models/sql_log.py +++ b/zhenxun/builtin_plugins/web_ui/api/tabs/database/models/sql_log.py @@ -4,7 +4,6 @@ class SqlLog(Model): - id = fields.IntField(pk=True, generated=True, auto_increment=True) """自增id""" ip = fields.CharField(255) @@ -18,7 +17,7 @@ class SqlLog(Model): create_time = fields.DatetimeField(auto_now_add=True) """创建时间""" - class Meta: + class Meta: # type: ignore table = "sql_log" table_description = "sql执行日志" diff --git a/zhenxun/builtin_plugins/web_ui/api/tabs/main/__init__.py b/zhenxun/builtin_plugins/web_ui/api/tabs/main/__init__.py index ed8bb5765..88f5ee9b6 100644 --- a/zhenxun/builtin_plugins/web_ui/api/tabs/main/__init__.py +++ b/zhenxun/builtin_plugins/web_ui/api/tabs/main/__init__.py @@ -1,26 +1,27 @@ -import asyncio import time -from datetime import datetime, timedelta +import asyncio +import contextlib from pathlib import Path +from datetime import datetime, timedelta import nonebot -from fastapi import APIRouter, WebSocket -from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState +from fastapi import APIRouter from tortoise.functions import Count -from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK +from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError +from starlette.websockets import WebSocket, WebSocketState, WebSocketDisconnect -from zhenxun.models.chat_history import ChatHistory +from zhenxun.services.log import logger from zhenxun.models.group_info import GroupInfo -from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.statistics import Statistics -from zhenxun.services.log import logger from zhenxun.utils.platform import PlatformUtils +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.models.chat_history import ChatHistory from ....base_model import Result -from ....config import AVA_URL, GROUP_AVA_URL, QueryDateType -from ....utils import authentication, get_system_status from .data_source import bot_live -from .model import ActiveGroup, BaseInfo, ChatHistoryCount, HotPlugin +from ....utils import authentication, get_system_status +from ....config import AVA_URL, GROUP_AVA_URL, QueryDateType +from .model import BaseInfo, HotPlugin, ActiveGroup, ChatHistoryCount run_time = time.time() @@ -131,7 +132,7 @@ async def _(bot_id: str) -> Result: "/get_ch_count", dependencies=[authentication()], description="获取接收消息数量" ) async def _(bot_id: str, query_type: QueryDateType | None = None) -> Result: - if bots := nonebot.get_bots(): + if nonebot.get_bot(bot_id): if not query_type: return Result.ok(await ChatHistory.filter(bot_id=bot_id).count()) now = datetime.now() @@ -210,7 +211,6 @@ async def _(date_type: QueryDateType | None = None) -> Result: .limit(5) .values_list("group_id", "count") ) - active_group_list = [] id2name = {} if data_list: if info_list := await GroupInfo.filter( @@ -218,15 +218,15 @@ async def _(date_type: QueryDateType | None = None) -> Result: ).all(): for group_info in info_list: id2name[group_info.group_id] = group_info.group_name - for data in data_list: - active_group_list.append( - ActiveGroup( - group_id=data[0], - name=id2name.get(data[0]) or data[0], - chat_num=data[1], - ava_img=GROUP_AVA_URL.format(data[0], data[0]), - ) + active_group_list = [ + ActiveGroup( + group_id=data[0], + name=id2name.get(data[0]) or data[0], + chat_num=data[1], + ava_img=GROUP_AVA_URL.format(data[0], data[0]), ) + for data in data_list + ] active_group_list = sorted( active_group_list, key=lambda x: x.chat_num, reverse=True ) @@ -263,13 +263,7 @@ async def _(date_type: QueryDateType | None = None) -> Result: for data in data_list: module = data[0] name = module2name.get(module) or module - hot_plugin_list.append( - HotPlugin( - module=data[0], - name=name, - count=data[1], - ) - ) + hot_plugin_list.append(HotPlugin(module=module, name=name, count=data[1])) hot_plugin_list = sorted(hot_plugin_list, key=lambda x: x.count, reverse=True) if len(hot_plugin_list) > 5: hot_plugin_list = hot_plugin_list[:5] @@ -280,11 +274,11 @@ async def _(date_type: QueryDateType | None = None) -> Result: async def system_logs_realtime(websocket: WebSocket, sleep: int = 5): await websocket.accept() logger.debug("ws system_status is connect") - try: + with contextlib.suppress( + WebSocketDisconnect, ConnectionClosedError, ConnectionClosedOK + ): while websocket.client_state == WebSocketState.CONNECTED: system_status = await get_system_status() await websocket.send_text(system_status.json()) await asyncio.sleep(sleep) - except (WebSocketDisconnect, ConnectionClosedError, ConnectionClosedOK): - pass return diff --git a/zhenxun/builtin_plugins/web_ui/api/tabs/manage/__init__.py b/zhenxun/builtin_plugins/web_ui/api/tabs/manage/__init__.py index 902b189b4..a5dccd8ff 100644 --- a/zhenxun/builtin_plugins/web_ui/api/tabs/manage/__init__.py +++ b/zhenxun/builtin_plugins/web_ui/api/tabs/manage/__init__.py @@ -1,40 +1,40 @@ import nonebot from fastapi import APIRouter -from nonebot.adapters.onebot.v11 import ActionFailed from tortoise.functions import Count +from nonebot.adapters.onebot.v11 import ActionFailed +from zhenxun.services.log import logger from zhenxun.configs.config import BotConfig -from zhenxun.models.ban_console import BanConsole -from zhenxun.models.chat_history import ChatHistory +from zhenxun.models.task_info import TaskInfo from zhenxun.models.fg_request import FgRequest -from zhenxun.models.group_console import GroupConsole -from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.statistics import Statistics -from zhenxun.models.task_info import TaskInfo -from zhenxun.services.log import logger -from zhenxun.utils.enum import RequestHandleType, RequestType -from zhenxun.utils.exception import NotFoundError from zhenxun.utils.platform import PlatformUtils +from zhenxun.models.ban_console import BanConsole +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.utils.exception import NotFoundError +from zhenxun.models.chat_history import ChatHistory +from zhenxun.models.group_console import GroupConsole +from zhenxun.utils.enum import RequestType, RequestHandleType from ....base_model import Result -from ....config import AVA_URL, GROUP_AVA_URL from ....utils import authentication +from ....config import AVA_URL, GROUP_AVA_URL from .model import ( - ClearRequest, - DeleteFriend, + Task, Friend, - FriendRequestResult, - GroupDetail, - GroupRequestResult, - GroupResult, - HandleRequest, - LeaveGroup, Plugin, ReqResult, + LeaveGroup, + UserDetail, + GroupDetail, + GroupResult, SendMessage, - Task, UpdateGroup, - UserDetail, + ClearRequest, + DeleteFriend, + HandleRequest, + GroupRequestResult, + FriendRequestResult, ) router = APIRouter(prefix="/manage") @@ -47,21 +47,21 @@ async def _(bot_id: str) -> Result: """ 获取群信息 """ - if bots := nonebot.get_bots(): - if bot_id not in bots: - return Result.warning_("指定Bot未连接...") - group_list_result = [] - try: - group_list = await bots[bot_id].get_group_list() - for g in group_list: - gid = g["group_id"] - g["ava_url"] = GROUP_AVA_URL.format(gid, gid) - group_list_result.append(GroupResult(**g)) - except Exception as e: - logger.error("调用API错误", "/get_group_list", e=e) - return Result.fail(f"{type(e)}: {e}") - return Result.ok(group_list_result, "拿到了新鲜出炉的数据!") - return Result.warning_("无Bot连接...") + if not (bots := nonebot.get_bots()): + return Result.warning_("无Bot连接...") + if bot_id not in bots: + return Result.warning_("指定Bot未连接...") + group_list_result = [] + try: + group_list = await bots[bot_id].get_group_list() + for g in group_list: + gid = g["group_id"] + g["ava_url"] = GROUP_AVA_URL.format(gid, gid) + group_list_result.append(GroupResult(**g)) + except Exception as e: + logger.error("调用API错误", "/get_group_list", e=e) + return Result.fail(f"{type(e)}: {e}") + return Result.ok(group_list_result, "拿到了新鲜出炉的数据!") @router.post( @@ -77,12 +77,8 @@ async def _(group: UpdateGroup) -> Result: if group.close_plugins: db_group.block_plugin = ",".join(group.close_plugins) + "," if group.task: - block_task = [] - for t in task_list: - if t not in group.task: - block_task.append(t) - if block_task: - db_group.block_task = ",".join(block_task) + "," + if block_task := [t for t in task_list if t not in group.task]: + db_group.block_task = ",".join(block_task) + "," # type: ignore await db_group.save( update_fields=["level", "status", "block_plugin", "block_task"] ) @@ -199,7 +195,7 @@ async def _(parma: HandleRequest) -> Result: return Result.warning_("指定Bot未连接...") try: await FgRequest.refused(bots[bot_id], parma.id) - except ActionFailed as e: + except ActionFailed: await FgRequest.expire(parma.id) return Result.warning_("请求失败,可能该请求已失效或请求数据错误...") except NotFoundError: @@ -226,22 +222,21 @@ async def _(parma: HandleRequest) -> Result: bot_id = parma.bot_id if bot_id not in nonebot.get_bots(): return Result.warning_("指定Bot未连接...") - if req := await FgRequest.get_or_none(id=parma.id): - if req.request_type == RequestType.GROUP: - if group := await GroupConsole.get_group(group_id=req.group_id): - group.group_flag = 1 - await group.save(update_fields=["group_flag"]) - else: - await GroupConsole.update_or_create( - group_id=req.group_id, - defaults={"group_flag": 1}, - ) - else: + if not (req := await FgRequest.get_or_none(id=parma.id)): return Result.warning_("未找到此Id请求...") + if req.request_type == RequestType.GROUP: + if group := await GroupConsole.get_group(group_id=req.group_id): + group.group_flag = 1 + await group.save(update_fields=["group_flag"]) + else: + await GroupConsole.update_or_create( + group_id=req.group_id, + defaults={"group_flag": 1}, + ) try: await FgRequest.approve(bots[bot_id], parma.id) return Result.ok(info="成功处理了请求!") - except ActionFailed as e: + except ActionFailed: await FgRequest.expire(parma.id) return Result.warning_("请求失败,可能该请求已失效或请求数据错误...") return Result.warning_("无Bot连接...") @@ -335,99 +330,98 @@ async def _(bot_id: str, user_id: str) -> Result: "/get_group_detail", dependencies=[authentication()], description="获取群组详情" ) async def _(bot_id: str, group_id: str) -> Result: - if bots := nonebot.get_bots(): - if bot_id in bots: - group = await GroupConsole.get_or_none(group_id=group_id) - if not group: - return Result.warning_("指定群组未被收录...") - like_plugin_list = ( - await Statistics.filter(group_id=group_id) - .annotate(count=Count("id")) - .group_by("plugin_name") - .order_by("-count") - .limit(5) - .values_list("plugin_name", "count") + if not (bots := nonebot.get_bots()): + return Result.warning_("无Bot连接...") + if bot_id not in bots: + return Result.warning_("未添加指定群组...") + group = await GroupConsole.get_or_none(group_id=group_id) + if not group: + return Result.warning_("指定群组未被收录...") + like_plugin_list = ( + await Statistics.filter(group_id=group_id) + .annotate(count=Count("id")) + .group_by("plugin_name") + .order_by("-count") + .limit(5) + .values_list("plugin_name", "count") + ) + like_plugin = {} + plugins = await PluginInfo.all() + module2name = {p.module: p.name for p in plugins} + for data in like_plugin_list: + name = module2name.get(data[0]) or data[0] + like_plugin[name] = data[1] + close_plugins = [] + if group.block_plugin: + for module in group.block_plugin.split(","): + module_ = module.replace(":super", "") + is_super_block = module.endswith(":super") + plugin = Plugin( + module=module_, + plugin_name=module, + is_super_block=is_super_block, ) - like_plugin = {} - plugins = await PluginInfo.all() - module2name = {p.module: p.name for p in plugins} - for data in like_plugin_list: - name = module2name.get(data[0]) or data[0] - like_plugin[name] = data[1] - close_plugins = [] - if group.block_plugin: - for module in group.block_plugin.split(","): - module_ = module.replace(":super", "") - is_super_block = module.endswith(":super") - plugin = Plugin( - module=module_, - plugin_name=module, - is_super_block=is_super_block, - ) - plugin.plugin_name = module2name.get(module) or module - close_plugins.append(plugin) - all_task = await TaskInfo.annotate().values_list("module", "name") - task_module2name = {x[0]: x[1] for x in all_task} - task_list = [] - if group.block_task: - split_task = group.block_task.split(",") - for task in all_task: - task_list.append( - Task( - name=task[0], - zh_name=task_module2name.get(task[0]) or task[0], - status=task[0] not in split_task, - ) - ) - else: - for task in all_task: - task_list.append( - Task( - name=task[0], - zh_name=task_module2name.get(task[0]) or task[0], - status=True, - ) - ) - group_detail = GroupDetail( - group_id=group_id, - ava_url=GROUP_AVA_URL.format(group_id, group_id), - name=group.group_name, - member_count=group.member_count, - max_member_count=group.max_member_count, - chat_count=await ChatHistory.filter(group_id=group_id).count(), - call_count=await Statistics.filter(group_id=group_id).count(), - like_plugin=like_plugin, - level=group.level, - status=group.status, - close_plugins=close_plugins, - task=task_list, + plugin.plugin_name = module2name.get(module) or module + close_plugins.append(plugin) + all_task = await TaskInfo.annotate().values_list("module", "name") + task_module2name = {x[0]: x[1] for x in all_task} + task_list = [] + if group.block_task: + split_task = group.block_task.split(",") + for task in all_task: + task_list.append( + Task( + name=task[0], + zh_name=task_module2name.get(task[0]) or task[0], + status=task[0] not in split_task, + ) ) - return Result.ok(group_detail) - else: - return Result.warning_("未添加指定群组...") - return Result.warning_("无Bot连接...") + else: + for task in all_task: + task_list.append( + Task( + name=task[0], + zh_name=task_module2name.get(task[0]) or task[0], + status=True, + ) + ) + group_detail = GroupDetail( + group_id=group_id, + ava_url=GROUP_AVA_URL.format(group_id, group_id), + name=group.group_name, + member_count=group.member_count, + max_member_count=group.max_member_count, + chat_count=await ChatHistory.filter(group_id=group_id).count(), + call_count=await Statistics.filter(group_id=group_id).count(), + like_plugin=like_plugin, + level=group.level, + status=group.status, + close_plugins=close_plugins, + task=task_list, + ) + return Result.ok(group_detail) @router.post( "/send_message", dependencies=[authentication()], description="获取群组详情" ) async def _(param: SendMessage) -> Result: - if bots := nonebot.get_bots(): - if param.bot_id in bots: - platform = PlatformUtils.get_platform(bots[param.bot_id]) - if platform != "qq": - return Result.warning_("暂不支持该平台...") - try: - if param.user_id: - await bots[param.bot_id].send_private_msg( - user_id=str(param.user_id), message=param.message - ) - else: - await bots[param.bot_id].send_group_msg( - group_id=str(param.group_id), message=param.message - ) - except Exception as e: - return Result.fail(str(e)) - return Result.ok("发送成功!") - return Result.warning_("指定Bot未连接...") - return Result.warning_("无Bot连接...") + if not (bots := nonebot.get_bots()): + return Result.warning_("无Bot连接...") + if param.bot_id in bots: + platform = PlatformUtils.get_platform(bots[param.bot_id]) + if platform != "qq": + return Result.warning_("暂不支持该平台...") + try: + if param.user_id: + await bots[param.bot_id].send_private_msg( + user_id=str(param.user_id), message=param.message + ) + else: + await bots[param.bot_id].send_group_msg( + group_id=str(param.group_id), message=param.message + ) + except Exception as e: + return Result.fail(str(e)) + return Result.ok("发送成功!") + return Result.warning_("指定Bot未连接...") diff --git a/zhenxun/builtin_plugins/web_ui/api/tabs/manage/chat.py b/zhenxun/builtin_plugins/web_ui/api/tabs/manage/chat.py index e01715691..7927536e2 100644 --- a/zhenxun/builtin_plugins/web_ui/api/tabs/manage/chat.py +++ b/zhenxun/builtin_plugins/web_ui/api/tabs/manage/chat.py @@ -1,20 +1,15 @@ -import re -from typing import Literal - import nonebot from fastapi import APIRouter from nonebot import on_message -from nonebot.adapters.onebot.v11 import MessageEvent -from nonebot_plugin_alconna import At, Emoji, Hyper, Image, Text, UniMessage, UniMsg from nonebot_plugin_session import EventSession -from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState +from nonebot.adapters.onebot.v11 import MessageEvent +from nonebot_plugin_alconna import At, Text, Hyper, Image, UniMsg +from starlette.websockets import WebSocket, WebSocketState, WebSocketDisconnect -from zhenxun.models.friend_user import FriendUser -from zhenxun.models.group_console import GroupConsole -from zhenxun.models.group_member_info import GroupInfoUser from zhenxun.utils.depends import UserName +from zhenxun.models.group_member_info import GroupInfoUser -from ....config import AVA_URL, GROUP_AVA_URL +from ....config import AVA_URL from .model import Message, MessageItem driver = nonebot.get_driver() @@ -27,7 +22,8 @@ ws_router = APIRouter() -matcher = on_message(block=False, priority=1) + +matcher = on_message(block=False, priority=1, rule=lambda: bool(ws_conn)) @driver.on_shutdown @@ -44,7 +40,7 @@ async def _(websocket: WebSocket): ws_conn = websocket try: while websocket.client_state == WebSocketState.CONNECTED: - recv = await websocket.receive() + await websocket.receive() except WebSocketDisconnect: ws_conn = None @@ -55,7 +51,7 @@ async def message_handle( ): messages = [] for m in message: - if isinstance(m, (Text, str)): + if isinstance(m, Text | str): messages.append(MessageItem(type="text", msg=str(m))) elif isinstance(m, Image): if m.url: @@ -70,18 +66,15 @@ async def message_handle( ID2NAME[group_id] = {} if m.target in ID2NAME[group_id]: uname = ID2NAME[group_id][m.target] - else: - if group_user := await GroupInfoUser.get_or_none( - user_id=m.target, group_id=group_id - ): - uname = group_user.user_name - if m.target not in ID2NAME[group_id]: - ID2NAME[group_id][m.target] = uname + elif group_user := await GroupInfoUser.get_or_none( + user_id=m.target, group_id=group_id + ): + uname = group_user.user_name + if m.target not in ID2NAME[group_id]: + ID2NAME[group_id][m.target] = uname messages.append(MessageItem(type="at", msg=f"@{uname}")) - # elif isinstance(m, Emoji): - # messages.append(MessageItem(type="text", msg=f"[emoji]")) elif isinstance(m, Hyper): - messages.append(MessageItem(type="text", msg=f"[分享消息]")) + messages.append(MessageItem(type="text", msg="[分享消息]")) return messages @@ -91,7 +84,6 @@ async def _( ): global ws_conn, ID2NAME, ID_LIST uid = session.id1 - gid = session.id3 or session.id2 if ws_conn and ws_conn.client_state == WebSocketState.CONNECTED and uid: msg_id = event.message_id if msg_id in ID_LIST: @@ -99,6 +91,7 @@ async def _( ID_LIST.append(msg_id) if len(ID_LIST) > 50: ID_LIST = ID_LIST[40:] + gid = session.id3 or session.id2 messages = await message_handle(message, gid) data = Message( object_id=gid or uid, diff --git a/zhenxun/builtin_plugins/web_ui/api/tabs/manage/model.py b/zhenxun/builtin_plugins/web_ui/api/tabs/manage/model.py index 64b64e8e5..dc788e29d 100644 --- a/zhenxun/builtin_plugins/web_ui/api/tabs/manage/model.py +++ b/zhenxun/builtin_plugins/web_ui/api/tabs/manage/model.py @@ -1,5 +1,3 @@ -from typing import Literal - from pydantic import BaseModel from zhenxun.utils.enum import RequestType @@ -232,7 +230,6 @@ class GroupDetail(BaseModel): class MessageItem(BaseModel): - type: str """消息类型""" msg: str diff --git a/zhenxun/builtin_plugins/web_ui/api/tabs/plugin_manage/__init__.py b/zhenxun/builtin_plugins/web_ui/api/tabs/plugin_manage/__init__.py index 2608eb0cc..13bbd6522 100644 --- a/zhenxun/builtin_plugins/web_ui/api/tabs/plugin_manage/__init__.py +++ b/zhenxun/builtin_plugins/web_ui/api/tabs/plugin_manage/__init__.py @@ -1,20 +1,20 @@ import re import cattrs -from fastapi import APIRouter, Query +from fastapi import Query, APIRouter -from zhenxun.configs.config import Config -from zhenxun.models.plugin_info import PluginInfo as DbPluginInfo from zhenxun.services.log import logger +from zhenxun.configs.config import Config from zhenxun.utils.enum import BlockType, PluginType +from zhenxun.models.plugin_info import PluginInfo as DbPluginInfo from ....base_model import Result from ....utils import authentication from .model import ( - PluginConfig, + PluginInfo, PluginCount, + PluginConfig, PluginDetail, - PluginInfo, PluginSwitch, UpdatePlugin, ) @@ -23,7 +23,9 @@ @router.get( - "/get_plugin_list", dependencies=[authentication()], deprecated="获取插件列表" # type: ignore + "/get_plugin_list", + dependencies=[authentication()], + deprecated="获取插件列表", # type: ignore ) async def _( plugin_type: list[PluginType] = Query(None), menu_type: str | None = None @@ -57,7 +59,9 @@ async def _( @router.get( - "/get_plugin_count", dependencies=[authentication()], deprecated="获取插件数量" # type: ignore + "/get_plugin_count", + dependencies=[authentication()], + deprecated="获取插件数量", # type: ignore ) async def _() -> Result: plugin_count = PluginCount() @@ -93,10 +97,7 @@ async def _(plugin: UpdatePlugin) -> Result: db_plugin.level = plugin.level db_plugin.menu_type = plugin.menu_type db_plugin.block_type = plugin.block_type - if plugin.block_type == BlockType.ALL: - db_plugin.status = False - else: - db_plugin.status = True + db_plugin.status = plugin.block_type != BlockType.ALL await db_plugin.save() # 配置项 if plugin.configs and (configs := Config.get(plugin.module)): @@ -149,19 +150,15 @@ async def _(module: str) -> Result: for cfg in config.configs: type_str = "" type_inner = None - x = str(config.configs[cfg].type) - r = re.search(r"", str(config.configs[cfg].type)) - if r: - type_str = r.group(1) - else: - r = re.search(r"typing\.(.*)\[(.*)\]", str(config.configs[cfg].type)) - if r: - type_str = r.group(1) - if type_str: - type_str = type_str.lower() - type_inner = r.group(2) - if type_inner: - type_inner = [x.strip() for x in type_inner.split(",")] + if r := re.search(r"", str(config.configs[cfg].type)): + type_str = r[1] + elif r := re.search(r"typing\.(.*)\[(.*)\]", str(config.configs[cfg].type)): + type_str = r[1] + if type_str: + type_str = type_str.lower() + type_inner = r[2] + if type_inner: + type_inner = [x.strip() for x in type_inner.split(",")] config_list.append( PluginConfig( module=module, diff --git a/zhenxun/builtin_plugins/web_ui/api/tabs/plugin_manage/model.py b/zhenxun/builtin_plugins/web_ui/api/tabs/plugin_manage/model.py index e29520383..662814c99 100644 --- a/zhenxun/builtin_plugins/web_ui/api/tabs/plugin_manage/model.py +++ b/zhenxun/builtin_plugins/web_ui/api/tabs/plugin_manage/model.py @@ -123,3 +123,8 @@ class PluginDetail(PluginInfo): """ config_list: list[PluginConfig] + + +class PluginIr(BaseModel): + id: int + """插件id""" diff --git a/zhenxun/builtin_plugins/web_ui/api/tabs/plugin_manage/store.py b/zhenxun/builtin_plugins/web_ui/api/tabs/plugin_manage/store.py new file mode 100644 index 000000000..f2b548594 --- /dev/null +++ b/zhenxun/builtin_plugins/web_ui/api/tabs/plugin_manage/store.py @@ -0,0 +1,50 @@ +from nonebot import require +from fastapi import APIRouter + +from .model import PluginIr +from ....base_model import Result +from ....utils import authentication + +require("plugin_store") +from zhenxun.builtin_plugins.plugin_store import ShopManage + +router = APIRouter(prefix="/store") + + +@router.get( + "/get_plugin_store", + dependencies=[authentication()], + deprecated="获取插件商店插件信息", # type: ignore +) +async def _() -> Result: + try: + data = await ShopManage.get_data() + return Result.ok(data) + except Exception as e: + return Result.fail(f"获取插件商店插件信息失败: {type(e)}: {e}") + + +@router.post( + "/install_plugin", + dependencies=[authentication()], + deprecated="安装插件", # type: ignore +) +async def _(param: PluginIr) -> Result: + try: + result = await ShopManage.add_plugin(param.id) # type: ignore + return Result.ok(result) + except Exception as e: + return Result.fail(f"安装插件失败: {type(e)}: {e}") + + +@router.post( + "/remove_plugin", + dependencies=[authentication()], + deprecated="移除插件", # type: ignore +) +async def _(param: PluginIr) -> Result: + try: + result = await ShopManage.remove_plugin(param.id) # type: ignore + return Result.ok(result) + except Exception as e: + return Result.fail(f"移除插件失败: {type(e)}: {e}") diff --git a/zhenxun/builtin_plugins/web_ui/api/tabs/system/__init__.py b/zhenxun/builtin_plugins/web_ui/api/tabs/system/__init__.py index 55c56764d..82ff10277 100644 --- a/zhenxun/builtin_plugins/web_ui/api/tabs/system/__init__.py +++ b/zhenxun/builtin_plugins/web_ui/api/tabs/system/__init__.py @@ -1,15 +1,15 @@ import os import shutil from pathlib import Path -from typing import List, Optional +import aiofiles from fastapi import APIRouter from zhenxun.utils._build_image import BuildImage from ....base_model import Result from ....utils import authentication, get_system_disk -from .model import AddFile, DeleteFile, DirFile, RenameFile, SaveFile +from .model import AddFile, DirFile, SaveFile, DeleteFile, RenameFile router = APIRouter(prefix="/system") @@ -19,16 +19,12 @@ @router.get( "/get_dir_list", dependencies=[authentication()], description="获取文件列表" ) -async def _(path: Optional[str] = None) -> Result: +async def _(path: str | None = None) -> Result: base_path = Path(path) if path else Path() data_list = [] for file in os.listdir(base_path): file_path = base_path / file - is_image = False - for t in IMAGE_TYPE: - if file.endswith(f".{t}"): - is_image = True - break + is_image = any(file.endswith(f".{t}") for t in IMAGE_TYPE) data_list.append( DirFile( is_file=not file_path.is_dir(), @@ -43,7 +39,7 @@ async def _(path: Optional[str] = None) -> Result: @router.get( "/get_resources_size", dependencies=[authentication()], description="获取文件列表" ) -async def _(full_path: Optional[str] = None) -> Result: +async def _(full_path: str | None = None) -> Result: return Result.ok(await get_system_disk(full_path)) @@ -56,7 +52,7 @@ async def _(param: DeleteFile) -> Result: path.unlink() return Result.ok("删除成功!") except Exception as e: - return Result.warning_("删除失败: " + str(e)) + return Result.warning_(f"删除失败: {e!s}") @router.post( @@ -70,7 +66,7 @@ async def _(param: DeleteFile) -> Result: shutil.rmtree(path.absolute()) return Result.ok("删除成功!") except Exception as e: - return Result.warning_("删除失败: " + str(e)) + return Result.warning_(f"删除失败: {e!s}") @router.post("/rename_file", dependencies=[authentication()], description="重命名文件") @@ -84,7 +80,7 @@ async def _(param: RenameFile) -> Result: path.rename(path.parent / param.name) return Result.ok("重命名成功!") except Exception as e: - return Result.warning_("重命名失败: " + str(e)) + return Result.warning_(f"重命名失败: {e!s}") @router.post( @@ -101,7 +97,7 @@ async def _(param: RenameFile) -> Result: shutil.move(path.absolute(), new_path.absolute()) return Result.ok("重命名成功!") except Exception as e: - return Result.warning_("重命名失败: " + str(e)) + return Result.warning_(f"重命名失败: {e!s}") @router.post("/add_file", dependencies=[authentication()], description="新建文件") @@ -113,7 +109,7 @@ async def _(param: AddFile) -> Result: path.open("w") return Result.ok("新建文件成功!") except Exception as e: - return Result.warning_("新建文件失败: " + str(e)) + return Result.warning_(f"新建文件失败: {e!s}") @router.post("/add_folder", dependencies=[authentication()], description="新建文件夹") @@ -125,7 +121,7 @@ async def _(param: AddFile) -> Result: path.mkdir() return Result.ok("新建文件夹成功!") except Exception as e: - return Result.warning_("新建文件夹失败: " + str(e)) + return Result.warning_(f"新建文件夹失败: {e!s}") @router.get("/read_file", dependencies=[authentication()], description="读取文件") @@ -137,18 +133,18 @@ async def _(full_path: str) -> Result: text = path.read_text(encoding="utf-8") return Result.ok(text) except Exception as e: - return Result.warning_("读取文件失败: " + str(e)) + return Result.warning_(f"读取文件失败: {e!s}") @router.post("/save_file", dependencies=[authentication()], description="读取文件") async def _(param: SaveFile) -> Result: path = Path(param.full_path) try: - with path.open("w") as f: - f.write(param.content) + async with aiofiles.open(path, "w", encoding="utf-8") as f: + await f.write(param.content) return Result.ok("更新成功!") except Exception as e: - return Result.warning_("保存文件失败: " + str(e)) + return Result.warning_(f"保存文件失败: {e!s}") @router.get("/get_image", dependencies=[authentication()], description="读取图片base64") @@ -159,4 +155,4 @@ async def _(full_path: str) -> Result: try: return Result.ok(BuildImage.open(path).pic2bs4()) except Exception as e: - return Result.warning_("获取图片失败: " + str(e)) + return Result.warning_(f"获取图片失败: {e!s}") diff --git a/zhenxun/builtin_plugins/web_ui/base_model.py b/zhenxun/builtin_plugins/web_ui/base_model.py index 67bb280f7..fe63d70ef 100644 --- a/zhenxun/builtin_plugins/web_ui/base_model.py +++ b/zhenxun/builtin_plugins/web_ui/base_model.py @@ -1,8 +1,8 @@ from datetime import datetime -from typing import Any, Generic, Optional, TypeVar +from typing_extensions import Self +from typing import Any, Generic, TypeVar from pydantic import BaseModel, validator -from typing_extensions import Self T = TypeVar("T") @@ -28,7 +28,7 @@ class Result(BaseModel): """code""" info: str = "操作成功" """info""" - warning: Optional[str] = None + warning: str | None = None """警告信息""" data: Any = None """返回数据""" @@ -102,7 +102,7 @@ class SystemFolderSize(BaseModel): """名称""" size: float """大小""" - full_path: Optional[str] + full_path: str | None """完整路径""" is_dir: bool """是否为文件夹""" diff --git a/zhenxun/builtin_plugins/web_ui/config.py b/zhenxun/builtin_plugins/web_ui/config.py index 0f16949a8..4fa6fa777 100644 --- a/zhenxun/builtin_plugins/web_ui/config.py +++ b/zhenxun/builtin_plugins/web_ui/config.py @@ -1,7 +1,17 @@ import nonebot -from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel from strenum import StrEnum +from fastapi.middleware.cors import CORSMiddleware + +from zhenxun.configs.path_config import DATA_PATH, TEMP_PATH + +WEBUI_STRING = "web_ui" +PUBLIC_STRING = "public" + +WEBUI_DATA_PATH = DATA_PATH / WEBUI_STRING +PUBLIC_PATH = WEBUI_DATA_PATH / PUBLIC_STRING +TMP_PATH = TEMP_PATH / WEBUI_STRING + +WEBUI_DIST_GITHUB_URL = "https://github.com/HibiKier/zhenxun_bot_webui/tree/dist" app = nonebot.get_app() diff --git a/zhenxun/builtin_plugins/web_ui/public/__init__.py b/zhenxun/builtin_plugins/web_ui/public/__init__.py index b194ffe7d..389c60e92 100644 --- a/zhenxun/builtin_plugins/web_ui/public/__init__.py +++ b/zhenxun/builtin_plugins/web_ui/public/__init__.py @@ -1,11 +1,11 @@ -from fastapi import APIRouter, FastAPI +from fastapi import FastAPI, APIRouter from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from zhenxun.services.log import logger -from .config import PUBLIC_PATH -from .data_source import update_webui_assets +from ..config import PUBLIC_PATH +from .data_source import COMMAND_NAME, update_webui_assets router = APIRouter() @@ -20,16 +20,24 @@ async def favicon(): return FileResponse(PUBLIC_PATH / "favicon.ico") +@router.get("/79edfa81f3308a9f.jfif") +async def _(): + return FileResponse(PUBLIC_PATH / "79edfa81f3308a9f.jfif") + + async def init_public(app: FastAPI): try: if not PUBLIC_PATH.exists(): - await update_webui_assets() + folders = await update_webui_assets() + else: + folders = [x.name for x in PUBLIC_PATH.iterdir() if x.is_dir()] app.include_router(router) - for pathname in ["css", "js", "fonts", "img"]: + for pathname in folders: + logger.debug(f"挂载文件夹: {pathname}") app.mount( f"/{pathname}", StaticFiles(directory=PUBLIC_PATH / pathname, check_dir=True), name=f"public_{pathname}", ) except Exception as e: - logger.error(f"初始化 web ui assets 失败", "Web UI assets", e=e) + logger.error("初始化 WebUI资源 失败", COMMAND_NAME, e=e) diff --git a/zhenxun/builtin_plugins/web_ui/public/config.py b/zhenxun/builtin_plugins/web_ui/public/config.py deleted file mode 100644 index 7c27d38d7..000000000 --- a/zhenxun/builtin_plugins/web_ui/public/config.py +++ /dev/null @@ -1,20 +0,0 @@ -from datetime import datetime -from pydantic import BaseModel -from zhenxun.configs.path_config import DATA_PATH, TEMP_PATH - - -class PublicData(BaseModel): - etag: str - update_time: datetime - - -COMMAND_NAME = "webui_update_assets" - -WEBUI_DATA_PATH = DATA_PATH / "web_ui" -PUBLIC_PATH = WEBUI_DATA_PATH / "public" -TMP_PATH = TEMP_PATH / "web_ui" - -GITHUB_API_COMMITS = "https://api.github.com/repos/HibiKier/zhenxun_bot_webui/commits" -WEBUI_ASSETS_DOWNLOAD_URL = ( - "https://github.com/HibiKier/zhenxun_bot_webui/archive/refs/heads/dist.zip" -) diff --git a/zhenxun/builtin_plugins/web_ui/public/data_source.py b/zhenxun/builtin_plugins/web_ui/public/data_source.py index eb04d2979..a1645178a 100644 --- a/zhenxun/builtin_plugins/web_ui/public/data_source.py +++ b/zhenxun/builtin_plugins/web_ui/public/data_source.py @@ -1,4 +1,3 @@ -import os import shutil import zipfile from pathlib import Path @@ -7,14 +6,20 @@ from zhenxun.services.log import logger from zhenxun.utils.http_utils import AsyncHttpx +from zhenxun.utils.github_utils import GithubUtils -from .config import COMMAND_NAME, PUBLIC_PATH, TMP_PATH, WEBUI_ASSETS_DOWNLOAD_URL +from ..config import TMP_PATH, PUBLIC_PATH, WEBUI_DIST_GITHUB_URL + +COMMAND_NAME = "WebUI资源管理" async def update_webui_assets(): webui_assets_path = TMP_PATH / "webui_assets.zip" + download_url = await GithubUtils.parse_github_url( + WEBUI_DIST_GITHUB_URL + ).get_archive_download_urls() if await AsyncHttpx.download_file( - WEBUI_ASSETS_DOWNLOAD_URL, webui_assets_path, follow_redirects=True + download_url, webui_assets_path, follow_redirects=True ): logger.info("下载 webui_assets 成功...", COMMAND_NAME) return await _file_handle(webui_assets_path) @@ -30,10 +35,9 @@ def _file_handle(webui_assets_path: Path): logger.debug("解压 webui_assets 成功...", COMMAND_NAME) else: raise Exception("解压 webui_assets 失败,文件不存在...", COMMAND_NAME) - download_file_path = ( - TMP_PATH / [x for x in os.listdir(TMP_PATH) if (TMP_PATH / x).is_dir()][0] - ) + download_file_path = next(f for f in TMP_PATH.iterdir() if f.is_dir()) shutil.rmtree(PUBLIC_PATH, ignore_errors=True) shutil.copytree(download_file_path / "dist", PUBLIC_PATH, dirs_exist_ok=True) logger.debug("复制 webui_assets 成功...", COMMAND_NAME) shutil.rmtree(TMP_PATH, ignore_errors=True) + return [x.name for x in PUBLIC_PATH.iterdir() if x.is_dir()] diff --git a/zhenxun/configs/config.py b/zhenxun/configs/config.py index 56f0fdb92..b2b99996f 100644 --- a/zhenxun/configs/config.py +++ b/zhenxun/configs/config.py @@ -1,4 +1,3 @@ -import random from pathlib import Path import nonebot @@ -8,7 +7,6 @@ class BotSetting(BaseModel): - self_nickname: str = "" """回复时NICKNAME""" system_proxy: str | None = None @@ -18,19 +16,26 @@ class BotSetting(BaseModel): platform_superusers: dict[str, list[str]] = {} """平台超级用户""" - def get_superuser(self, platform: str) -> str: + def get_superuser(self, platform: str) -> list[str]: """获取超级用户 参数: platform: 对应平台 返回: - str | None: 超级用户id + list[str]: 超级用户id """ if self.platform_superusers: - if platform_superuser := self.platform_superusers.get(platform): - return random.choice(platform_superuser) - return "" + return self.platform_superusers.get(platform, []) + return [] + + def get_sql_type(self) -> str: + """获取数据库类型 + + 返回: + str: 数据库类型, postgres, aiomysql, sqlite + """ + return self.db_url.split(":", 1)[0] if self.db_url else "" Config = ConfigsManager(Path() / "data" / "configs" / "plugins2config.yaml") diff --git a/zhenxun/configs/utils/__init__.py b/zhenxun/configs/utils/__init__.py index 3277222cd..b041abe30 100644 --- a/zhenxun/configs/utils/__init__.py +++ b/zhenxun/configs/utils/__init__.py @@ -17,6 +17,32 @@ _yaml.allow_unicode = True +class Example(BaseModel): + """ + 示例 + """ + + exec: str + """执行命令""" + description: str = "" + """命令描述""" + + +class Command(BaseModel): + """ + 具体参数说明 + """ + + command: str + """命令""" + params: list[str] = [] + """参数""" + description: str = "" + """描述""" + examples: list[Example] = [] + """示例列表""" + + class RegisterConfig(BaseModel): """ 注册配置项 @@ -167,6 +193,8 @@ class PluginExtraData(BaseModel): """插件基本配置""" limits: list[BaseBlock | PluginCdBlock | PluginCountBlock] | None = None """插件限制""" + commands: list[Command] = [] + """命令列表,用于说明帮助""" tasks: list[Task] | None = None """技能被动""" superuser_help: str | None = None @@ -356,7 +384,7 @@ def get_config(self, module: str, key: str, default: Any = None) -> Any: value = default logger.debug( f"获取配置 MODULE: [{module}] | " - " KEY: [{key}] -> [{value}]" + f" KEY: [{key}] -> [{value}]" ) return value @@ -380,13 +408,6 @@ def save(self, path: str | Path | None = None, save_simple_data: bool = False): """ if save_simple_data: with open(self._simple_file, "w", encoding="utf8") as f: - # yaml.dump( - # self._simple_data, - # f, - # indent=2, - # Dumper=yaml.RoundTripDumper, - # allow_unicode=True, - # ) _yaml.dump(self._simple_data, f) path = path or self.file data = {} @@ -398,14 +419,10 @@ def save(self, path: str | Path | None = None, save_simple_data: bool = False): del value["arg_parser"] data[module][config] = value with open(path, "w", encoding="utf8") as f: - # yaml.dump( - # data, f, indent=2, Dumper=yaml.RoundTripDumper, allow_unicode=True - # ) _yaml.dump(data, f) def reload(self): """重新加载配置文件""" - _yaml = YAML() if self._simple_file.exists(): with open(self._simple_file, encoding="utf8") as f: self._simple_data = _yaml.load(f) @@ -441,7 +458,7 @@ def load_data(self): self._data[module] = config_group logger.info( f"加载配置完成,共加载 {len(temp_data)} 个配置组及对应" - " {count} 个配置项" + f" {count} 个配置项" ) def get_data(self) -> dict[str, ConfigGroup]: diff --git a/zhenxun/models/plugin_info.py b/zhenxun/models/plugin_info.py index e5eb1b210..e07cb5a9b 100644 --- a/zhenxun/models/plugin_info.py +++ b/zhenxun/models/plugin_info.py @@ -1,9 +1,10 @@ +from typing_extensions import Self + from tortoise import fields from zhenxun.services.db_context import Model from zhenxun.utils.enum import BlockType, PluginType - -from .plugin_limit import PluginLimit +from zhenxun.models.plugin_limit import PluginLimit # noqa: F401 class PluginInfo(Model): @@ -45,7 +46,39 @@ class PluginInfo(Model): """调用所需权限等级""" is_delete = fields.BooleanField(default=False, description="是否删除") """是否删除""" + parent = fields.CharField(max_length=255, null=True, description="父插件") + """父插件""" - class Meta: + class Meta: # type: ignore table = "plugin_info" table_description = "插件基本信息" + + @classmethod + async def get_plugin(cls, load_status: bool = True, **kwargs) -> Self | None: + """获取插件列表 + + 参数: + load_status: 加载状态. + + 返回: + Self | None: 插件 + """ + return await cls.get_or_none(load_status=load_status, **kwargs) + + @classmethod + async def get_plugins(cls, load_status: bool = True, **kwargs) -> list[Self]: + """获取插件列表 + + 参数: + load_status: 加载状态. + + 返回: + list[Self]: 插件列表 + """ + return await cls.filter(load_status=load_status, **kwargs).all() + + @classmethod + async def _run_script(cls): + return [ + "ALTER TABLE plugin_info ADD COLUMN parent character varying(255);", + ] diff --git a/zhenxun/models/plugin_limit.py b/zhenxun/models/plugin_limit.py index e6b185e73..172c53946 100644 --- a/zhenxun/models/plugin_limit.py +++ b/zhenxun/models/plugin_limit.py @@ -35,6 +35,6 @@ class PluginLimit(Model): max_count = fields.IntField(null=True, description="最大调用次数") """最大调用次数""" - class Meta: + class Meta: # type: ignore table = "plugin_limit" table_description = "插件限制" diff --git a/zhenxun/services/db_context.py b/zhenxun/services/db_context.py index a39e71759..ed6bc5400 100644 --- a/zhenxun/services/db_context.py +++ b/zhenxun/services/db_context.py @@ -1,7 +1,7 @@ -from nonebot.utils import is_coroutine_callable from tortoise import Tortoise from tortoise.connection import connections from tortoise.models import Model as Model_ +from nonebot.utils import is_coroutine_callable from zhenxun.configs.config import BotConfig from zhenxun.configs.path_config import DATA_PATH @@ -28,9 +28,25 @@ def __init_subclass__(cls, **kwargs): SCRIPT_METHOD.append((cls.__module__, func)) +class DbUrlIsNode(Exception): + """ + 数据库链接地址为空 + """ + + pass + + +class DbConnectError(Exception): + """ + 数据库连接错误 + """ + + pass + + async def init(): if not BotConfig.db_url: - raise Exception(f"数据库配置为空,请在.env.dev中配置DB_URL...") + raise DbUrlIsNode("数据库配置为空,请在.env.dev中配置DB_URL...") try: await Tortoise.init( db_url=BotConfig.db_url, @@ -40,15 +56,13 @@ async def init(): if SCRIPT_METHOD: db = Tortoise.get_connection("default") logger.debug( - f"即将运行SCRIPT_METHOD方法, 合计 {len(SCRIPT_METHOD)} 个..." + "即将运行SCRIPT_METHOD方法, 合计 " + f"{len(SCRIPT_METHOD)} 个..." ) sql_list = [] for module, func in SCRIPT_METHOD: try: - if is_coroutine_callable(func): - sql = await func() - else: - sql = func() + sql = await func() if is_coroutine_callable(func) else func() if sql: sql_list += sql except Exception as e: @@ -63,9 +77,9 @@ async def init(): if sql_list: logger.debug("SCRIPT_METHOD方法执行完毕!") await Tortoise.generate_schemas() - logger.info(f"Database loaded successfully!") + logger.info("Database loaded successfully!") except Exception as e: - raise Exception(f"数据库连接错误... e:{e}") + raise DbConnectError(f"数据库连接错误... e:{e}") from e async def disconnect(): diff --git a/zhenxun/services/plugin_init.py b/zhenxun/services/plugin_init.py new file mode 100644 index 000000000..7d6ac487e --- /dev/null +++ b/zhenxun/services/plugin_init.py @@ -0,0 +1,105 @@ +from abc import ABC, abstractmethod +from collections.abc import Callable + +import nonebot +from pydantic import BaseModel +from nonebot.utils import is_coroutine_callable + +from zhenxun.services.log import logger + +driver = nonebot.get_driver() + + +class PluginInit(ABC): + """ + 插件安装与卸载模块 + """ + + def __init_subclass__(cls, **kwargs): + module_path = cls.__module__ + install_func = getattr(cls, "install", None) + remove_func = getattr(cls, "remove", None) + if install_func or remove_func: + PluginInitManager.plugins[module_path] = PluginInitData( + module_path=module_path, + install=install_func, + remove=remove_func, + class_=cls, + ) + + @abstractmethod + async def install(self): + raise NotImplementedError + + @abstractmethod + async def remove(self): + raise NotImplementedError + + +class PluginInitData(BaseModel): + module_path: str + """模块名""" + install: Callable | None + """安装方法""" + remove: Callable | None + """卸载方法""" + class_: type[PluginInit] + """类""" + + +class PluginInitManager: + plugins: dict[str, PluginInitData] = {} # noqa: RUF012 + + @classmethod + async def install_all(cls): + """运行所有插件安装方法""" + if cls.plugins: + for module_path, model in cls.plugins.items(): + if model.install: + class_ = model.class_() + try: + logger.debug(f"开始执行: {module_path}:install 方法") + if is_coroutine_callable(class_.install): + await class_.install() + else: + class_.install() # type: ignore + logger.debug(f"执行: {module_path}:install 完成") + except Exception as e: + logger.error(f"执行: {module_path}:install 失败", e=e) + + @classmethod + async def install(cls, module_path: str): + """运行指定插件安装方法""" + if model := cls.plugins.get(module_path): + if model.install: + class_ = model.class_() + try: + logger.debug(f"开始执行: {module_path}:install 方法") + if is_coroutine_callable(class_.install): + await class_.install() + else: + class_.install() # type: ignore + logger.debug(f"执行: {module_path}:install 完成") + except Exception as e: + logger.error(f"执行: {module_path}:install 失败", e=e) + + @classmethod + async def remove(cls, module_path: str): + """运行指定插件安装方法""" + if model := cls.plugins.get(module_path): + if model.remove: + class_ = model.class_() + try: + logger.debug(f"开始执行: {module_path}:remove 方法") + if is_coroutine_callable(class_.remove): + await class_.remove() + else: + class_.remove() # type: ignore + logger.debug(f"执行: {module_path}:remove 完成") + except Exception as e: + logger.error(f"执行: {module_path}:remove 失败", e=e) + + +@driver.on_startup +async def _(): + await PluginInitManager.install_all() diff --git a/zhenxun/utils/_build_image.py b/zhenxun/utils/_build_image.py index 0f12b1e35..7f353d4b3 100644 --- a/zhenxun/utils/_build_image.py +++ b/zhenxun/utils/_build_image.py @@ -1,15 +1,17 @@ -import base64 import math import uuid +import base64 +import itertools +import contextlib from io import BytesIO from pathlib import Path -from typing import Literal, Tuple, TypeAlias, overload +from typing_extensions import Self +from typing import Literal, TypeAlias, overload from nonebot.utils import run_sync -from PIL import Image, ImageDraw, ImageFilter, ImageFont from PIL.Image import Image as tImage from PIL.ImageFont import FreeTypeFont -from typing_extensions import Self +from PIL import Image, ImageDraw, ImageFont, ImageFilter from zhenxun.configs.path_config import FONT_PATH @@ -18,7 +20,7 @@ ] """图片类型""" -ColorAlias: TypeAlias = str | Tuple[int, int, int] | Tuple[int, int, int, int] | None +ColorAlias: TypeAlias = str | tuple[int, int, int] | tuple[int, int, int, int] | None CenterType = Literal["center", "height", "width"] """ @@ -52,9 +54,7 @@ def __init__( self.height = height self.color = color self.font = ( - self.load_font(font, font_size) - if not isinstance(font, FreeTypeFont) - else font + font if isinstance(font, FreeTypeFont) else self.load_font(font, font_size) ) if background: if isinstance(background, bytes): @@ -66,14 +66,14 @@ def __init__( else: self.width = self.markImg.width self.height = self.markImg.height - else: - if not width or not height: - raise ValueError("长度和宽度不能为空...") + elif width and height: self.markImg = Image.new(mode, (width, height), color) # type: ignore + else: + raise ValueError("长度和宽度不能为空...") self.draw = ImageDraw.Draw(self.markImg) @property - def size(self) -> Tuple[int, int]: + def size(self) -> tuple[int, int]: return self.markImg.size @classmethod @@ -94,9 +94,9 @@ async def build_text_image( text: str, font: str | FreeTypeFont | Path = "HYWenHei-85W.ttf", size: int = 10, - font_color: str | Tuple[int, int, int] = (0, 0, 0), + font_color: str | tuple[int, int, int] = (0, 0, 0), color: ColorAlias = None, - padding: int | Tuple[int, int, int, int] | None = None, + padding: int | tuple[int, int, int, int] | None = None, ) -> Self: """构建文本图片 @@ -116,7 +116,7 @@ async def build_text_image( _font = None if isinstance(font, FreeTypeFont): _font = font - elif isinstance(font, (str, Path)): + elif isinstance(font, str | Path): _font = cls.load_font(font, size) width, height = cls.get_text_size(text, _font) if isinstance(padding, int): @@ -161,7 +161,7 @@ async def auto_paste( row_count = math.ceil(len(img_list) / row) if row_count == 1: background_width = ( - sum([img.width for img in img_list]) + space * (row - 1) + padding * 2 + sum(img.width for img in img_list) + space * (row - 1) + padding * 2 ) background_height = height * row_count + space * (row_count - 1) + padding * 2 background_image = cls( @@ -189,20 +189,20 @@ def load_font( 返回: FreeTypeFont: 字体 """ - path = FONT_PATH / font if type(font) == str else font + path = FONT_PATH / font if type(font) is str else font return ImageFont.truetype(str(path), font_size) @overload @classmethod def get_text_size( cls, text: str, font: FreeTypeFont | None = None - ) -> Tuple[int, int]: ... + ) -> tuple[int, int]: ... @overload @classmethod def get_text_size( cls, text: str, font: str | None = None, font_size: int = 10 - ) -> Tuple[int, int]: ... + ) -> tuple[int, int]: ... @classmethod def get_text_size( @@ -210,7 +210,7 @@ def get_text_size( text: str, font: str | FreeTypeFont | None = "HYWenHei-85W.ttf", font_size: int = 10, - ) -> Tuple[int, int]: + ) -> tuple[int, int]: # sourcery skip: remove-unnecessary-cast """获取该字体下文本需要的长宽 参数: @@ -219,10 +219,10 @@ def get_text_size( font_size: 字体大小 返回: - Tuple[int, int]: 长宽 + tuple[int, int]: 长宽 """ _font = font - if font and type(font) == str: + if font and type(font) is str: _font = cls.load_font(font, font_size) temp_image = Image.new("RGB", (1, 1), (255, 255, 255)) draw = ImageDraw.Draw(temp_image) @@ -232,7 +232,8 @@ def get_text_size( return text_width, text_height + 10 # return _font.getsize(str(text)) # type: ignore - def getsize(self, msg: str) -> Tuple[int, int]: + def getsize(self, msg: str) -> tuple[int, int]: + # sourcery skip: remove-unnecessary-cast """ 获取文字在该图片 font_size 下所需要的空间 @@ -240,7 +241,7 @@ def getsize(self, msg: str) -> Tuple[int, int]: msg: 文本 返回: - Tuple[int, int]: 长宽 + tuple[int, int]: 长宽 """ temp_image = Image.new("RGB", (1, 1), (255, 255, 255)) draw = ImageDraw.Draw(temp_image) @@ -252,11 +253,11 @@ def getsize(self, msg: str) -> Tuple[int, int]: def __center_xy( self, - pos: Tuple[int, int], + pos: tuple[int, int], width: int, height: int, center_type: CenterType | None, - ) -> Tuple[int, int]: + ) -> tuple[int, int]: """ 根据居中类型定位xy @@ -266,7 +267,7 @@ def __center_xy( center_type: 居中类型 返回: - Tuple[int, int]: 定位 + tuple[int, int]: 定位 """ # _width, _height = pos if self.width and self.height: @@ -285,7 +286,7 @@ def __center_xy( def paste( self, image: Self | tImage, - pos: Tuple[int, int] = (0, 0), + pos: tuple[int, int] = (0, 0), center_type: CenterType | None = None, ) -> Self: """贴图 @@ -303,7 +304,6 @@ def paste( """ if center_type and center_type not in ["center", "height", "width"]: raise ValueError("center_type must be 'center', 'width' or 'height'") - width, height = 0, 0 _image = image if isinstance(image, BuildImage): _image = image.markImg @@ -317,7 +317,7 @@ def paste( @run_sync def point( - self, pos: Tuple[int, int], fill: Tuple[int, int, int] | None = None + self, pos: tuple[int, int], fill: tuple[int, int, int] | None = None ) -> Self: """ 绘制多个或单独的像素 @@ -335,9 +335,9 @@ def point( @run_sync def ellipse( self, - pos: Tuple[int, int, int, int], - fill: Tuple[int, int, int] | None = None, - outline: Tuple[int, int, int] | None = None, + pos: tuple[int, int, int, int], + fill: tuple[int, int, int] | None = None, + outline: tuple[int, int, int] | None = None, width: int = 1, ) -> Self: """ @@ -358,13 +358,13 @@ def ellipse( @run_sync def text( self, - pos: Tuple[int, int], + pos: tuple[int, int], text: str, - fill: str | Tuple[int, int, int] = (0, 0, 0), + fill: str | tuple[int, int, int] = (0, 0, 0), center_type: CenterType | None = None, font: FreeTypeFont | str | Path | None = None, font_size: int = 10, - ) -> Self: + ) -> Self: # sourcery skip: remove-unnecessary-cast """ 在图片上添加文字 @@ -382,11 +382,10 @@ def text( 异常: ValueError: 居中类型错误 """ - text = str(text) if center_type and center_type not in ["center", "height", "width"]: raise ValueError("center_type must be 'center', 'width' or 'height'") max_length_text = "" - sentence = text.split("\n") + sentence = str(text).split("\n") for x in sentence: max_length_text = x if len(x) > len(max_length_text) else max_length_text if font: @@ -398,7 +397,7 @@ def text( ttf_w, ttf_h = self.getsize(max_length_text) # type: ignore # ttf_h = ttf_h * len(sentence) pos = self.__center_xy(pos, ttf_w, ttf_h, center_type) - self.draw.text(pos, text, fill=fill, font=font) + self.draw.text(pos, str(text), fill=fill, font=font) return self @run_sync @@ -437,7 +436,7 @@ def resize(self, ratio: float = 0, width: int = 0, height: int = 0) -> Self: if not width and not height and not ratio: raise ValueError("缺少参数...") if self.width and self.height: - if not width and not height and ratio: + if not width and not height: width = int(self.width * ratio) height = int(self.height * ratio) self.markImg = self.markImg.resize((width, height), Image.LANCZOS) # type: ignore @@ -446,7 +445,7 @@ def resize(self, ratio: float = 0, width: int = 0, height: int = 0) -> Self: return self @run_sync - def crop(self, box: Tuple[int, int, int, int]) -> Self: + def crop(self, box: tuple[int, int, int, int]) -> Self: """ 裁剪图片 @@ -475,11 +474,10 @@ def transparent(self, alpha_ratio: float = 1, n: int = 0) -> Self: """ self.markImg = self.markImg.convert("RGBA") x, y = self.markImg.size - for i in range(n, x - n): - for k in range(n, y - n): - color = self.markImg.getpixel((i, k)) - color = color[:-1] + (int(100 * alpha_ratio),) - self.markImg.putpixel((i, k), color) + for i, k in itertools.product(range(n, x - n), range(n, y - n)): + color = self.markImg.getpixel((i, k)) + color = color[:-1] + (int(100 * alpha_ratio),) + self.markImg.putpixel((i, k), color) self.draw = ImageDraw.Draw(self.markImg) return self @@ -492,7 +490,7 @@ def pic2bs4(self) -> str: buf = BytesIO() self.markImg.save(buf, format="PNG") base64_str = base64.b64encode(buf.getvalue()).decode() - return "base64://" + base64_str + return f"base64://{base64_str}" def pic2bytes(self) -> bytes: """获取bytes @@ -520,8 +518,8 @@ def convert(self, type_: ModeType) -> Self: @run_sync def rectangle( self, - xy: Tuple[int, int, int, int], - fill: Tuple[int, int, int] | None = None, + xy: tuple[int, int, int, int], + fill: tuple[int, int, int] | None = None, outline: str | None = None, width: int = 1, ) -> Self: @@ -543,8 +541,8 @@ def rectangle( @run_sync def polygon( self, - xy: list[Tuple[int, int]], - fill: Tuple[int, int, int] = (0, 0, 0), + xy: list[tuple[int, int]], + fill: tuple[int, int, int] = (0, 0, 0), outline: int = 1, ) -> Self: """ @@ -564,8 +562,8 @@ def polygon( @run_sync def line( self, - xy: Tuple[int, int, int, int], - fill: Tuple[int, int, int] | str = "#D8DEE4", + xy: tuple[int, int, int, int], + fill: tuple[int, int, int] | str = "#D8DEE4", width: int = 1, ) -> Self: """ @@ -605,21 +603,19 @@ def circle(self) -> Self: ) draw = ImageDraw.Draw(mask) for offset, fill in (width / -2.0, "black"), (width / 2.0, "white"): - left, top = [(value + offset) * antialias for value in ellipse_box[:2]] - right, bottom = [(value - offset) * antialias for value in ellipse_box[2:]] + left, top = ((value + offset) * antialias for value in ellipse_box[:2]) + right, bottom = ((value - offset) * antialias for value in ellipse_box[2:]) draw.ellipse([left, top, right, bottom], fill=fill) mask = mask.resize(self.markImg.size, Image.LANCZOS) - try: + with contextlib.suppress(ValueError): self.markImg.putalpha(mask) - except ValueError: - pass return self @run_sync def circle_corner( self, radii: int = 30, - point_list: list[Literal["lt", "rt", "lb", "rb"]] = ["lt", "rt", "lb", "rb"], + point_list: list[Literal["lt", "rt", "lb", "rb"]] | None = None, ) -> Self: """ 矩形四角变圆 @@ -631,6 +627,8 @@ def circle_corner( 返回: BuildImage: Self """ + if point_list is None: + point_list = ["lt", "rt", "lb", "rb"] # 画圆(用于分离4个角) img = self.markImg.convert("RGBA") alpha = img.split()[-1] diff --git a/zhenxun/utils/_image_template.py b/zhenxun/utils/_image_template.py index e989accc4..a1d4c01be 100644 --- a/zhenxun/utils/_image_template.py +++ b/zhenxun/utils/_image_template.py @@ -1,11 +1,10 @@ import random from io import BytesIO from pathlib import Path -from typing import Any, Callable, Dict +from collections.abc import Callable -from fastapi import background -from PIL.ImageFont import FreeTypeFont from pydantic import BaseModel +from PIL.ImageFont import FreeTypeFont from ._build_image import BuildImage @@ -25,13 +24,13 @@ class Config: class ImageTemplate: - color_list = ["#C2CEFE", "#FFA94C", "#3FE6A0", "#D1D4F5"] + color_list = ["#C2CEFE", "#FFA94C", "#3FE6A0", "#D1D4F5"] # noqa: RUF012 @classmethod async def hl_page( cls, head_text: str, - items: Dict[str, str], + items: dict[str, str], row_space: int = 10, padding: int = 30, ) -> BuildImage: @@ -162,9 +161,9 @@ async def table( column_data = [] for i in range(len(column_name)): c = [] - for l in data_list: - if len(l) > i: - c.append(l[i]) + for lst in data_list: + if len(lst) > i: + c.append(lst[i]) else: c.append("") column_data.append(c) @@ -188,7 +187,7 @@ async def table( column_name[i], font, 12, "#C8CCCF" ) column_name_image_list.append(column_name_image) - max_h = max([c.height for c in column_name_image_list]) + max_h = max(c.height for c in column_name_image_list) for i, data in enumerate(build_data_list): width = data["width"] + padding * 2 height = (base_h + row_space) * (len(data["data"]) + 1) + padding * 2 @@ -280,10 +279,9 @@ async def __get_text_size( width = 0 height = 0 _, h = BuildImage.get_text_size("A", font) - image_list = [] for s in text.split("\n"): s = s.strip() or "A" w, _ = BuildImage.get_text_size(s, font) - width = width if width > w else w + width = max(width, w) height += h return width, height diff --git a/zhenxun/utils/common_utils.py b/zhenxun/utils/common_utils.py index f3375c8f5..06722aa9c 100644 --- a/zhenxun/utils/common_utils.py +++ b/zhenxun/utils/common_utils.py @@ -1,3 +1,5 @@ +from zhenxun.services.log import logger +from zhenxun.configs.config import BotConfig from zhenxun.models.task_info import TaskInfo from zhenxun.models.ban_console import BanConsole from zhenxun.models.group_console import GroupConsole @@ -34,3 +36,19 @@ async def task_is_block(cls, module: str, group_id: str | None) -> bool: """群组是否被ban""" return True return False + + +class SqlUtils: + + @classmethod + def random(cls, query, limit: int = 1) -> str: + db_class_name = BotConfig.get_sql_type() + if "postgres" in db_class_name or "sqlite" in db_class_name: + query = f"{query.sql()} ORDER BY RANDOM() LIMIT {limit};" + elif "mysql" in db_class_name: + query = f"{query.sql()} ORDER BY RAND() LIMIT {limit};" + else: + logger.warning( + f"Unsupported database type: {db_class_name}", query.__module__ + ) + return query diff --git a/zhenxun/utils/enum.py b/zhenxun/utils/enum.py index 86d41d13b..c0b793427 100644 --- a/zhenxun/utils/enum.py +++ b/zhenxun/utils/enum.py @@ -42,6 +42,8 @@ class PluginType(StrEnum): """依赖插件,一般为没有主动触发命令的插件,受权限控制""" HIDDEN = "HIDDEN" """隐藏插件,一般为没有主动触发命令的插件,不受权限控制,如消息统计""" + PARENT = "PARENT" + """父插件,仅仅标记""" class BlockType(StrEnum): diff --git a/zhenxun/utils/github_utils/__init__.py b/zhenxun/utils/github_utils/__init__.py new file mode 100644 index 000000000..89b0a80a5 --- /dev/null +++ b/zhenxun/utils/github_utils/__init__.py @@ -0,0 +1,27 @@ +from collections.abc import Generator + +from .consts import GITHUB_REPO_URL_PATTERN +from .func import get_fastest_raw_formats, get_fastest_archive_formats +from .models import RepoAPI, RepoInfo, GitHubStrategy, JsdelivrStrategy + +__all__ = [ + "get_fastest_raw_formats", + "get_fastest_archive_formats", + "GithubUtils", +] + + +class GithubUtils: + # 使用 + jsdelivr_api = RepoAPI(JsdelivrStrategy()) # type: ignore + github_api = RepoAPI(GitHubStrategy()) # type: ignore + + @classmethod + def iter_api_strategies(cls) -> Generator[RepoAPI]: + yield from [cls.github_api, cls.jsdelivr_api] + + @classmethod + def parse_github_url(cls, github_url: str) -> "RepoInfo": + if matched := GITHUB_REPO_URL_PATTERN.match(github_url): + return RepoInfo(**{k: v for k, v in matched.groupdict().items() if v}) + raise ValueError("github地址格式错误") diff --git a/zhenxun/utils/github_utils/consts.py b/zhenxun/utils/github_utils/consts.py new file mode 100644 index 000000000..13b013bea --- /dev/null +++ b/zhenxun/utils/github_utils/consts.py @@ -0,0 +1,35 @@ +import re + +GITHUB_REPO_URL_PATTERN = re.compile( + r"^https://github.com/(?P[^/]+)/(?P[^/]+)(/tree/(?P[^/]+))?$" +) +"""github仓库地址正则""" + +JSD_PACKAGE_API_FORMAT = ( + "https://data.jsdelivr.com/v1/packages/gh/{owner}/{repo}@{branch}" +) +"""jsdelivr包地址格式""" + +GIT_API_TREES_FORMAT = ( + "https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=1" +) +"""git api trees地址格式""" + +CACHED_API_TTL = 300 +"""缓存api ttl""" + +RAW_CONTENT_FORMAT = "https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}" +"""raw content格式""" + +ARCHIVE_URL_FORMAT = "https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip" +"""archive url格式""" + +RELEASE_ASSETS_FORMAT = ( + "https://github.com/{owner}/{repo}/releases/download/{version}/{filename}" +) +"""release assets格式""" + +RELEASE_SOURCE_FORMAT = ( + "https://codeload.github.com/{owner}/{repo}/legacy.{compress}/refs/tags/{version}" +) +"""release 源码格式""" diff --git a/zhenxun/utils/github_utils/func.py b/zhenxun/utils/github_utils/func.py new file mode 100644 index 000000000..95d2a3ef7 --- /dev/null +++ b/zhenxun/utils/github_utils/func.py @@ -0,0 +1,63 @@ +from aiocache import cached + +from ..http_utils import AsyncHttpx +from .consts import ( + ARCHIVE_URL_FORMAT, + RAW_CONTENT_FORMAT, + RELEASE_ASSETS_FORMAT, + RELEASE_SOURCE_FORMAT, +) + + +async def __get_fastest_formats(formats: dict[str, str]) -> list[str]: + sorted_urls = await AsyncHttpx.get_fastest_mirror(list(formats.keys())) + if not sorted_urls: + raise Exception("无法获取任意GitHub资源加速地址,请检查网络") + return [formats[url] for url in sorted_urls] + + +@cached() +async def get_fastest_raw_formats() -> list[str]: + """获取最快的raw下载地址格式""" + formats: dict[str, str] = { + "https://raw.githubusercontent.com/": RAW_CONTENT_FORMAT, + "https://ghproxy.cc/": f"https://ghproxy.cc/{RAW_CONTENT_FORMAT}", + "https://mirror.ghproxy.com/": f"https://mirror.ghproxy.com/{RAW_CONTENT_FORMAT}", + "https://gh-proxy.com/": f"https://gh-proxy.com/{RAW_CONTENT_FORMAT}", + "https://cdn.jsdelivr.net/": "https://cdn.jsdelivr.net/gh/{owner}/{repo}@{branch}/{path}", + } + return await __get_fastest_formats(formats) + + +@cached() +async def get_fastest_archive_formats() -> list[str]: + """获取最快的归档下载地址格式""" + formats: dict[str, str] = { + "https://github.com/": ARCHIVE_URL_FORMAT, + "https://ghproxy.cc/": f"https://ghproxy.cc/{ARCHIVE_URL_FORMAT}", + "https://mirror.ghproxy.com/": f"https://mirror.ghproxy.com/{ARCHIVE_URL_FORMAT}", + "https://gh-proxy.com/": f"https://gh-proxy.com/{ARCHIVE_URL_FORMAT}", + } + return await __get_fastest_formats(formats) + + +@cached() +async def get_fastest_release_formats() -> list[str]: + """获取最快的发行版资源下载地址格式""" + formats: dict[str, str] = { + "https://objects.githubusercontent.com/": RELEASE_ASSETS_FORMAT, + "https://ghproxy.cc/": f"https://ghproxy.cc/{RELEASE_ASSETS_FORMAT}", + "https://mirror.ghproxy.com/": f"https://mirror.ghproxy.com/{RELEASE_ASSETS_FORMAT}", + "https://gh-proxy.com/": f"https://gh-proxy.com/{RELEASE_ASSETS_FORMAT}", + } + return await __get_fastest_formats(formats) + + +@cached() +async def get_fastest_release_source_formats() -> list[str]: + """获取最快的发行版源码下载地址格式""" + formats: dict[str, str] = { + "https://codeload.github.com/": RELEASE_SOURCE_FORMAT, + "https://p.102333.xyz/": f"https://p.102333.xyz/{RELEASE_SOURCE_FORMAT}", + } + return await __get_fastest_formats(formats) diff --git a/zhenxun/utils/github_utils/models.py b/zhenxun/utils/github_utils/models.py new file mode 100644 index 000000000..170892815 --- /dev/null +++ b/zhenxun/utils/github_utils/models.py @@ -0,0 +1,232 @@ +from typing import Protocol + +from aiocache import cached +from strenum import StrEnum +from pydantic import BaseModel + +from ..http_utils import AsyncHttpx +from .consts import CACHED_API_TTL, GIT_API_TREES_FORMAT, JSD_PACKAGE_API_FORMAT +from .func import ( + get_fastest_raw_formats, + get_fastest_archive_formats, + get_fastest_release_source_formats, +) + + +class RepoInfo(BaseModel): + """仓库信息""" + + owner: str + repo: str + branch: str = "main" + + async def get_raw_download_url(self, path: str) -> str: + return (await self.get_raw_download_urls(path))[0] + + async def get_archive_download_url(self) -> str: + return (await self.get_archive_download_urls())[0] + + async def get_release_source_download_url_tgz(self, version: str) -> str: + return (await self.get_release_source_download_urls_tgz(version))[0] + + async def get_release_source_download_url_zip(self, version: str) -> str: + return (await self.get_release_source_download_urls_zip(version))[0] + + async def get_raw_download_urls(self, path: str) -> list[str]: + url_formats = await get_fastest_raw_formats() + return [ + url_format.format(**self.dict(), path=path) for url_format in url_formats + ] + + async def get_archive_download_urls(self) -> list[str]: + url_formats = await get_fastest_archive_formats() + return [url_format.format(**self.dict()) for url_format in url_formats] + + async def get_release_source_download_urls_tgz(self, version: str) -> list[str]: + url_formats = await get_fastest_release_source_formats() + return [ + url_format.format(**self.dict(), version=version, compress="tar.gz") + for url_format in url_formats + ] + + async def get_release_source_download_urls_zip(self, version: str) -> list[str]: + url_formats = await get_fastest_release_source_formats() + return [ + url_format.format(**self.dict(), version=version, compress="zip") + for url_format in url_formats + ] + + +class APIStrategy(Protocol): + """API策略""" + + body: BaseModel + + async def parse_repo_info(self, repo_info: RepoInfo) -> BaseModel: ... + + def get_files(self, module_path: str, is_dir: bool) -> list[str]: ... + + +class RepoAPI: + """基础接口""" + + def __init__(self, strategy: APIStrategy): + self.strategy = strategy + + async def parse_repo_info(self, repo_info: RepoInfo): + body = await self.strategy.parse_repo_info(repo_info) + self.strategy.body = body + + def get_files(self, module_path: str, is_dir: bool) -> list[str]: + return self.strategy.get_files(module_path, is_dir) + + +class FileType(StrEnum): + """文件类型""" + + FILE = "file" + DIR = "directory" + PACKAGE = "gh" + + +class FileInfo(BaseModel): + """文件信息""" + + type: FileType + name: str + files: list["FileInfo"] = [] + + +class JsdelivrStrategy: + """Jsdelivr策略""" + + body: FileInfo + + def get_file_paths(self, module_path: str, is_dir: bool = True) -> list[str]: + """获取文件路径""" + paths = module_path.split("/") + filename = "" if is_dir else paths[-1] + paths = paths if is_dir else paths[:-1] + cur_file = self.body + for path in paths: # 导航到正确的目录 + cur_file = next( + ( + f + for f in cur_file.files + if f.type == FileType.DIR and f.name == path + ), + None, + ) + if not cur_file: + raise ValueError(f"模块路径{module_path}不存在") + + def collect_files(file: FileInfo, current_path: str, filename: str): + """收集文件""" + if file.type == FileType.FILE and (not filename or file.name == filename): + return [f"{current_path}/{file.name}"] + elif file.type == FileType.DIR and file.files: + return [ + path + for f in file.files + for path in collect_files( + f, + ( + f"{current_path}/{f.name}" + if f.type == FileType.DIR + else current_path + ), + filename, + ) + ] + return [] + + return collect_files(cur_file, "/".join(paths), filename) + + @classmethod + @cached(ttl=CACHED_API_TTL) + async def parse_repo_info(cls, repo_info: RepoInfo) -> "FileInfo": + """解析仓库信息""" + + """获取插件包信息 + + 参数: + repo_info: 仓库信息 + + 返回: + FileInfo: 插件包信息 + """ + jsd_package_url: str = JSD_PACKAGE_API_FORMAT.format( + owner=repo_info.owner, repo=repo_info.repo, branch=repo_info.branch + ) + res = await AsyncHttpx.get(url=jsd_package_url) + if res.status_code != 200: + raise ValueError(f"下载错误, code: {res.status_code}") + return FileInfo(**res.json()) + + def get_files(self, module_path: str, is_dir: bool = True) -> list[str]: + """获取文件路径""" + return self.get_file_paths(module_path, is_dir) + + +class TreeType(StrEnum): + """树类型""" + + FILE = "blob" + DIR = "tree" + + +class Tree(BaseModel): + """树""" + + path: str + mode: str + type: TreeType + sha: str + size: int | None + url: str + + +class TreeInfo(BaseModel): + """树信息""" + + sha: str + url: str + tree: list[Tree] + + +class GitHubStrategy: + """GitHub策略""" + + body: TreeInfo + + def export_files(self, module_path: str) -> list[str]: + """导出文件路径""" + tree_info = self.body + return [ + file.path + for file in tree_info.tree + if file.type == TreeType.FILE and file.path.startswith(module_path) + ] + + @classmethod + @cached(ttl=CACHED_API_TTL) + async def parse_repo_info(cls, repo_info: RepoInfo) -> "TreeInfo": + """获取仓库树 + + 参数: + repo_info: 仓库信息 + + 返回: + TreesInfo: 仓库树信息 + """ + git_tree_url: str = GIT_API_TREES_FORMAT.format( + owner=repo_info.owner, repo=repo_info.repo, branch=repo_info.branch + ) + res = await AsyncHttpx.get(url=git_tree_url) + if res.status_code != 200: + raise ValueError(f"下载错误, code: {res.status_code}") + return TreeInfo(**res.json()) + + def get_files(self, module_path: str, is_dir: bool = True) -> list[str]: + """获取文件路径""" + return self.export_files(module_path) diff --git a/zhenxun/utils/http_utils.py b/zhenxun/utils/http_utils.py index b4708b391..4637e1085 100644 --- a/zhenxun/utils/http_utils.py +++ b/zhenxun/utils/http_utils.py @@ -1,20 +1,22 @@ +import time import asyncio -from asyncio.exceptions import TimeoutError -from contextlib import asynccontextmanager from pathlib import Path -from typing import Any, AsyncGenerator, Dict, Literal +from typing import Any, Literal, ClassVar +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from asyncio.exceptions import TimeoutError -import aiofiles -import httpx import rich -from httpx import ConnectTimeout, Response +import httpx +import aiofiles +from retrying import retry +from playwright.async_api import Page from nonebot_plugin_alconna import UniMessage from nonebot_plugin_htmlrender import get_browser -from playwright.async_api import Page -from retrying import retry +from httpx import Response, ConnectTimeout, HTTPStatusError -from zhenxun.configs.config import BotConfig from zhenxun.services.log import logger +from zhenxun.configs.config import BotConfig from zhenxun.utils.message import MessageUtils from zhenxun.utils.user_agent import get_user_agent @@ -22,21 +24,23 @@ class AsyncHttpx: - - proxy = {"http://": BotConfig.system_proxy, "https://": BotConfig.system_proxy} + proxy: ClassVar[dict[str, str | None]] = { + "http://": BotConfig.system_proxy, + "https://": BotConfig.system_proxy, + } @classmethod @retry(stop_max_attempt_number=3) async def get( cls, - url: str, + url: str | list[str], *, - params: Dict[str, Any] | None = None, - headers: Dict[str, str] | None = None, - cookies: Dict[str, str] | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, verify: bool = True, use_proxy: bool = True, - proxy: Dict[str, str] | None = None, + proxy: dict[str, str] | None = None, timeout: int = 30, **kwargs, ) -> Response: @@ -52,6 +56,49 @@ async def get( proxy: 指定代理 timeout: 超时时间 """ + urls = [url] if isinstance(url, str) else url + return await cls._get_first_successful( + urls, + params=params, + headers=headers, + cookies=cookies, + verify=verify, + use_proxy=use_proxy, + proxy=proxy, + timeout=timeout, + **kwargs, + ) + + @classmethod + async def _get_first_successful( + cls, + urls: list[str], + **kwargs, + ) -> Response: + last_exception = None + for url in urls: + try: + return await cls._get_single(url, **kwargs) + except Exception as e: + last_exception = e + if url != urls[-1]: + logger.warning(f"获取 {url} 失败, 尝试下一个") + raise last_exception or Exception("All URLs failed") + + @classmethod + async def _get_single( + cls, + url: str, + *, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, + verify: bool = True, + use_proxy: bool = True, + proxy: dict[str, str] | None = None, + timeout: int = 30, + **kwargs, + ) -> Response: if not headers: headers = get_user_agent() _proxy = proxy if proxy else cls.proxy if use_proxy else None @@ -65,21 +112,60 @@ async def get( **kwargs, ) + @classmethod + async def head( + cls, + url: str, + *, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, + verify: bool = True, + use_proxy: bool = True, + proxy: dict[str, str] | None = None, + timeout: int = 30, + **kwargs, + ) -> Response: + """Get + + 参数: + url: url + params: params + headers: 请求头 + cookies: cookies + verify: verify + use_proxy: 使用默认代理 + proxy: 指定代理 + timeout: 超时时间 + """ + if not headers: + headers = get_user_agent() + _proxy = proxy if proxy else cls.proxy if use_proxy else None + async with httpx.AsyncClient(proxies=_proxy, verify=verify) as client: # type: ignore + return await client.head( + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + **kwargs, + ) + @classmethod async def post( cls, url: str, *, - data: Dict[str, Any] | None = None, + data: dict[str, Any] | None = None, content: Any = None, files: Any = None, verify: bool = True, use_proxy: bool = True, - proxy: Dict[str, str] | None = None, - json: Dict[str, Any] | None = None, - params: Dict[str, str] | None = None, - headers: Dict[str, str] | None = None, - cookies: Dict[str, str] | None = None, + proxy: dict[str, str] | None = None, + json: dict[str, Any] | None = None, + params: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, timeout: int = 30, **kwargs, ) -> Response: @@ -119,17 +205,18 @@ async def post( @classmethod async def download_file( cls, - url: str, + url: str | list[str], path: str | Path, *, - params: Dict[str, str] | None = None, + params: dict[str, str] | None = None, verify: bool = True, use_proxy: bool = True, - proxy: Dict[str, str] | None = None, - headers: Dict[str, str] | None = None, - cookies: Dict[str, str] | None = None, + proxy: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, timeout: int = 30, stream: bool = False, + follow_redirects: bool = True, **kwargs, ) -> bool: """下载文件 @@ -151,71 +238,82 @@ async def download_file( path.parent.mkdir(parents=True, exist_ok=True) try: for _ in range(3): - if not stream: + if not isinstance(url, list): + url = [url] + for u in url: try: - content = ( - await cls.get( - url, + if not stream: + response = await cls.get( + u, params=params, headers=headers, cookies=cookies, use_proxy=use_proxy, proxy=proxy, timeout=timeout, + follow_redirects=follow_redirects, **kwargs, ) - ).content - async with aiofiles.open(path, "wb") as wf: - await wf.write(content) - logger.info(f"下载 {url} 成功.. Path:{path.absolute()}") - return True - except (TimeoutError, ConnectTimeout): - pass - else: - if not headers: - headers = get_user_agent() - _proxy = proxy if proxy else cls.proxy if use_proxy else None - try: - async with httpx.AsyncClient( - proxies=_proxy, verify=verify # type: ignore - ) as client: - async with client.stream( - "GET", - url, - params=params, - headers=headers, - cookies=cookies, - timeout=timeout, - **kwargs, - ) as response: - logger.info( - f"开始下载 {path.name}.. Path: {path.absolute()}" - ) - async with aiofiles.open(path, "wb") as wf: - total = int(response.headers["Content-Length"]) - with rich.progress.Progress( # type: ignore - rich.progress.TextColumn(path.name), # type: ignore - "[progress.percentage]{task.percentage:>3.0f}%", # type: ignore - rich.progress.BarColumn(bar_width=None), # type: ignore - rich.progress.DownloadColumn(), # type: ignore - rich.progress.TransferSpeedColumn(), # type: ignore - ) as progress: - download_task = progress.add_task( - "Download", total=total - ) - async for chunk in response.aiter_bytes(): - await wf.write(chunk) - await wf.flush() - progress.update( - download_task, - completed=response.num_bytes_downloaded, - ) + response.raise_for_status() + content = response.content + async with aiofiles.open(path, "wb") as wf: + await wf.write(content) + logger.info(f"下载 {u} 成功.. Path:{path.absolute()}") + return True + else: + if not headers: + headers = get_user_agent() + _proxy = ( + proxy if proxy else cls.proxy if use_proxy else None + ) + async with httpx.AsyncClient( + proxies=_proxy, # type: ignore + verify=verify, + ) as client: + async with client.stream( + "GET", + u, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + follow_redirects=True, + **kwargs, + ) as response: + response.raise_for_status() logger.info( - f"下载 {url} 成功.. Path:{path.absolute()}" + f"开始下载 {path.name}.. " + f"Path: {path.absolute()}" ) - return True - except (TimeoutError, ConnectTimeout): - pass + async with aiofiles.open(path, "wb") as wf: + total = int( + response.headers.get("Content-Length", 0) + ) + with rich.progress.Progress( # type: ignore + rich.progress.TextColumn(path.name), # type: ignore + "[progress.percentage]{task.percentage:>3.0f}%", # type: ignore + rich.progress.BarColumn(bar_width=None), # type: ignore + rich.progress.DownloadColumn(), # type: ignore + rich.progress.TransferSpeedColumn(), # type: ignore + ) as progress: + download_task = progress.add_task( + "Download", + total=total if total else None, + ) + async for chunk in response.aiter_bytes(): + await wf.write(chunk) + await wf.flush() + progress.update( + download_task, + completed=response.num_bytes_downloaded, + ) + logger.info( + f"下载 {u} 成功.. " + f"Path:{path.absolute()}" + ) + return True + except (TimeoutError, ConnectTimeout, HTTPStatusError): + logger.warning(f"下载 {u} 失败.. 尝试下一个地址..") else: logger.error(f"下载 {url} 下载超时.. Path:{path.absolute()}") except Exception as e: @@ -225,15 +323,15 @@ async def download_file( @classmethod async def gather_download_file( cls, - url_list: list[str], + url_list: list[str] | list[list[str]], path_list: list[str | Path], *, limit_async_number: int | None = None, - params: Dict[str, str] | None = None, + params: dict[str, str] | None = None, use_proxy: bool = True, - proxy: Dict[str, str] | None = None, - headers: Dict[str, str] | None = None, - cookies: Dict[str, str] | None = None, + proxy: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, timeout: int = 30, **kwargs, ) -> list[bool]: @@ -295,6 +393,39 @@ async def gather_download_file( tasks.clear() return result_ + @classmethod + async def get_fastest_mirror(cls, url_list: list[str]) -> list[str]: + assert url_list + + async def head_mirror(client: type[AsyncHttpx], url: str) -> dict[str, Any]: + begin_time = time.time() + + response = await client.head(url=url, timeout=6) + + elapsed_time = (time.time() - begin_time) * 1000 + content_length = int(response.headers.get("content-length", 0)) + + return { + "url": url, + "elapsed_time": elapsed_time, + "content_length": content_length, + } + + logger.debug(f"开始获取最快镜像,可能需要一段时间... | URL列表:{url_list}") + results = await asyncio.gather( + *(head_mirror(cls, url) for url in url_list), + return_exceptions=True, + ) + _results: list[dict[str, Any]] = [] + for result in results: + if isinstance(result, BaseException): + logger.warning(f"获取镜像失败,错误:{result}") + else: + logger.debug(f"获取镜像成功,结果:{result}") + _results.append(result) + _results = sorted(iter(_results), key=lambda r: r["elapsed_time"]) + return [result["url"] for result in _results] + class AsyncPlaywright: @classmethod @@ -322,7 +453,7 @@ async def screenshot( element: str | list[str], *, wait_time: int | None = None, - viewport_size: Dict[str, int] | None = None, + viewport_size: dict[str, int] | None = None, wait_until: ( Literal["domcontentloaded", "load", "networkidle"] | None ) = "networkidle", @@ -344,7 +475,7 @@ async def screenshot( type_: 保存类型 """ if viewport_size is None: - viewport_size = dict(width=2560, height=1080) + viewport_size = {"width": 2560, "height": 1080} if isinstance(path, str): path = Path(path) wait_time = wait_time * 1000 if wait_time else None diff --git a/zhenxun/utils/image_utils.py b/zhenxun/utils/image_utils.py index c193a565f..eb245f48e 100644 --- a/zhenxun/utils/image_utils.py +++ b/zhenxun/utils/image_utils.py @@ -1,23 +1,22 @@ import os -import random import re +import random from io import BytesIO from pathlib import Path -from typing import Awaitable, Callable +from collections.abc import Callable, Awaitable import cv2 import imagehash -from imagehash import ImageHash -from nonebot.utils import is_coroutine_callable from PIL import Image +from nonebot.utils import is_coroutine_callable -from zhenxun.configs.path_config import IMAGE_PATH, TEMP_PATH from zhenxun.services.log import logger from zhenxun.utils.http_utils import AsyncHttpx +from zhenxun.configs.path_config import TEMP_PATH, IMAGE_PATH from ._build_image import BuildImage, ColorAlias -from ._build_mat import BuildMat, MatType -from ._image_template import ImageTemplate, RowStyle +from ._build_mat import MatType, BuildMat # noqa: F401 +from ._image_template import RowStyle, ImageTemplate # noqa: F401 # TODO: text2image 长度错误 @@ -192,8 +191,9 @@ async def text2image( s.strip(), font, font_size, font_color ) ) + height = sum(img.height + 8 for img in image_list) + pw width += pw - height += ph + # height += ph A = BuildImage( width + left_padding, height + top_padding + 2, @@ -386,7 +386,7 @@ def get_img_hash(image_file: str | Path) -> str: with open(image_file, "rb") as fp: hash_value = imagehash.average_hash(Image.open(fp)) except Exception as e: - logger.warning(f"获取图片Hash出错", "禁言检测", e=e) + logger.warning("获取图片Hash出错", "禁言检测", e=e) return str(hash_value) @@ -407,7 +407,7 @@ async def get_download_image_hash(url: str, mark: str) -> str: img_hash = get_img_hash(TEMP_PATH / f"compare_download_{mark}_img.jpg") return str(img_hash) except Exception as e: - logger.warning(f"下载读取图片Hash出错", e=e) + logger.warning("下载读取图片Hash出错", e=e) return "" diff --git a/zhenxun/utils/message.py b/zhenxun/utils/message.py index eb6549f22..342967773 100644 --- a/zhenxun/utils/message.py +++ b/zhenxun/utils/message.py @@ -2,22 +2,38 @@ from pathlib import Path import nonebot +from pydantic import BaseModel from nonebot.adapters.onebot.v11 import Message, MessageSegment -from nonebot_plugin_alconna import At, Image, Text, UniMessage +from nonebot_plugin_alconna import At, Text, AtAll, Image, Video, Voice, UniMessage -from zhenxun.configs.config import BotConfig from zhenxun.services.log import logger +from zhenxun.configs.config import BotConfig from zhenxun.utils._build_image import BuildImage driver = nonebot.get_driver() MESSAGE_TYPE = ( - str | int | float | Path | bytes | BytesIO | BuildImage | At | Image | Text + str + | int + | float + | Path + | bytes + | BytesIO + | BuildImage + | At + | AtAll + | Image + | Text + | Voice + | Video ) -class MessageUtils: +class Config(BaseModel): + image_to_bytes: bool = False + +class MessageUtils: @classmethod def __build_message(cls, msg_list: list[MESSAGE_TYPE]) -> list[Text | Image]: """构造消息 @@ -28,20 +44,17 @@ def __build_message(cls, msg_list: list[MESSAGE_TYPE]) -> list[Text | Image]: 返回: list[Text | Text]: 构造完成的消息列表 """ - is_bytes = False - try: - is_bytes = driver.config.image_to_bytes in ["True", "true"] - except AttributeError: - pass + config = nonebot.get_plugin_config(Config) message_list = [] for msg in msg_list: - if isinstance(msg, (Image, Text, At)): + if isinstance(msg, Image | Text | At | AtAll | Video | Voice): message_list.append(msg) - elif isinstance(msg, (str, int, float)): + elif isinstance(msg, str | int | float): message_list.append(Text(str(msg))) elif isinstance(msg, Path): if msg.exists(): - if is_bytes: + if config.image_to_bytes: + logger.debug("图片转为bytes发送", "MessageUtils") image = BuildImage.open(msg) message_list.append(Image(raw=image.pic2bytes())) else: @@ -120,7 +133,7 @@ def template2forward(cls, msg_list: list[UniMessage], uni: str) -> list[dict]: forward_data = [] for r_list in msg_list: s = "" - if isinstance(r_list, (UniMessage, list)): + if isinstance(r_list, UniMessage | list): for r in r_list: if isinstance(r, Text): s += str(r) @@ -134,3 +147,28 @@ def template2forward(cls, msg_list: list[UniMessage], uni: str) -> list[dict]: s = str(r_list) forward_data.append(s) return cls.custom_forward_msg(forward_data, uni) + + @classmethod + def template2alc(cls, msg_list: list[MessageSegment]) -> list: + """模板转alc + + 参数: + msg_list: 消息列表 + + 返回: + list: alc模板 + """ + forward_data = [] + for msg in msg_list: + if isinstance(msg, str): + forward_data.append(Text(msg)) + elif msg.type == "at": + if msg.data["qq"] == "0": + forward_data.append(AtAll()) + else: + forward_data.append(At(flag="user", target=msg.data["qq"])) + elif msg.type == "image": + forward_data.append(Image(url=msg.data["file"] or msg.data["url"])) + elif msg.type == "text" and msg.data["text"]: + forward_data.append(Text(msg.data["text"])) + return forward_data diff --git a/zhenxun/utils/platform.py b/zhenxun/utils/platform.py index 3e3b4afc9..6ecddb54e 100644 --- a/zhenxun/utils/platform.py +++ b/zhenxun/utils/platform.py @@ -23,7 +23,6 @@ class UserData(BaseModel): - name: str """昵称""" card: str | None = None @@ -41,7 +40,6 @@ class UserData(BaseModel): class PlatformUtils: - @classmethod async def ban_user(cls, bot: Bot, user_id: str, group_id: str, duration: int): """禁言 @@ -268,6 +266,18 @@ async def get_user_avatar(cls, user_id: str, platform: str) -> bytes | None: ) return None + @classmethod + def get_user_avatar_url(cls, user_id: str, platform: str) -> str | None: + """快捷获取用户头像url + + 参数: + user_id: 用户id + platform: 平台 + """ + if platform == "qq": + return f"http://q1.qlogo.cn/g?b=qq&nk={user_id}&s=160" + return None + @classmethod async def get_group_avatar(cls, gid: str, platform: str) -> bytes | None: """快捷获取用群头像