Merge branch 'main' into feature/db-cache

This commit is contained in:
HibiKier 2025-07-07 09:38:05 +08:00 committed by GitHub
commit a12a3249bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
155 changed files with 14082 additions and 3668 deletions

58
.github/workflows/publish-docker.yml vendored Normal file
View File

@ -0,0 +1,58 @@
#
name: Create and publish a Docker image
# Configures this workflow to run on demand via workflow_dispatch.
on:
workflow_dispatch:
# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
jobs:
build-and-push-image:
runs-on: ubuntu-latest
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
permissions:
contents: read
packages: write
attestations: write
id-token: write
#
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see [Usage](https://github.com/docker/build-push-action#usage) in the README of the `docker/build-push-action` repository.
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
- name: Build and push Docker image
id: push
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see [Using artifact attestations to establish provenance for builds](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds).
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

13
.gitignore vendored
View File

@ -139,22 +139,9 @@ dmypy.json
# Cython debug symbols # Cython debug symbols
cython_debug/ cython_debug/
demo.py
test.py
server_ip.py
member_activity_handle.py
Yu-Gi-Oh/
csgo/
fantasy_card/
data/ data/
log/ log/
backup/ backup/
extensive_plugin/
test/
bot.py
.idea/ .idea/
resources/ resources/
/configs/config.py
configs/config.yaml
.vscode/launch.json .vscode/launch.json
plugins_/

View File

@ -11,6 +11,8 @@
"displayname", "displayname",
"flmt", "flmt",
"getbbox", "getbbox",
"gitcode",
"GITEE",
"hibiapi", "hibiapi",
"httpx", "httpx",
"jsdelivr", "jsdelivr",

118
README.md
View File

@ -112,7 +112,7 @@ 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) | 插件 | [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/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) | 第三方 | | [一键安装](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 实现 [预览](#-webui界面展示) | | [WebUi](https://github.com/zhenxun-org/zhenxun_bot) | 管理 | [hibikier](https://github.com/HibiKier) | 基于真寻 WebApi 的 webui 实现 [预览](#-webui界面展示) |
| [安卓 app(WebUi)](https://github.com/YuS1aN/zhenxun_bot_android_ui) | 安装 | [YuS1aN](https://github.com/YuS1aN) | 第三方 | | [安卓 app(WebUi)](https://github.com/YuS1aN/zhenxun_bot_android_ui) | 安装 | [YuS1aN](https://github.com/YuS1aN) | 第三方 |
</div> </div>
@ -121,11 +121,33 @@ AccessToken: PUBLIC_ZHENXUN_TEST
- 实现了许多功能,且提供了大量功能管理命令,进行了多平台适配,兼容 nb2 商店插件 - 实现了许多功能,且提供了大量功能管理命令,进行了多平台适配,兼容 nb2 商店插件
- 拥有完善可用的 webui - 拥有完善可用的 webui
- 通过 Config 配置项将所有插件配置统保存至 config.yaml利于统一用户修改 - 通过 Config 配置项将所有插件配置统保存至 config.yaml利于统一用户修改
- 方便增删插件,原生 nonebot2 matcher不需要额外修改仅仅通过简单的配置属性就可以生成`帮助图片`和`帮助信息` - 方便增删插件,原生 nonebot2 matcher不需要额外修改仅仅通过简单的配置属性就可以生成`帮助图片`和`帮助信息`
- 提供了 cd阻塞每日次数等限制仅仅通过简单的属性就可以生成一个限制例如`PluginCdBlock` 等 - 提供了 cd阻塞每日次数等限制仅仅通过简单的属性就可以生成一个限制例如`PluginCdBlock` 等
- **更多详细请通过 [传送门](https://zhenxun-org.github.io/zhenxun_bot/) 查看文档!** - **更多详细请通过 [传送门](https://zhenxun-org.github.io/zhenxun_bot/) 查看文档!**
## 🐣 小白整合
如果你系统是 **Windows** 且不想下载 Python
可以使用整合包Python3.10+zhenxun+webui
文档地址:[整合包文档](https://hibikier.github.io/zhenxun_bot/beginner/)
<details>
<summary>下载地址</summary>
- **百度云:**
https://pan.baidu.com/s/1ph4yzx1vdNbkxm9VBKDdgQ?pwd=971j
- **天翼云:**
https://cloud.189.cn/web/share?code=jq67r2i2E7Fb
访问码8wxm
- **Google Drive**
https://drive.google.com/file/d/1cc3Dqjk0x5hWGLNeMkrFwWl8BvsK6KfD/view?usp=drive_link
</details>
## 🛠️ 简单部署 ## 🛠️ 简单部署
```bash ```bash
@ -150,7 +172,7 @@ poetry run python bot.py
1.在 .env.dev 文件中填写你的机器人配置项 1.在 .env.dev 文件中填写你的机器人配置项
2.在 configs/config.yaml 文件中修改你需要修改的插件配置项 2.在 data/config.yaml 文件中修改你需要修改的插件配置项
<details> <details>
<summary>数据库地址DB_URL配置说明</summary> <summary>数据库地址DB_URL配置说明</summary>
@ -272,12 +294,12 @@ DB_URL 是基于 Tortoise ORM 的数据库连接字符串,用于指定项目
## ❔ 需要帮助? ## ❔ 需要帮助?
> [!TIP] > [!TIP]
> 发起 [issue](https://github.com/HibiKier/zhenxun_bot/issues/new/choose) 前,我们希望你能够阅读过或者了解 [提问的智慧](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md) > 发起 [issue](https://github.com/zhenxun-org/zhenxun_bot/issues/new/choose) 前,我们希望你能够阅读过或者了解 [提问的智慧](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md)
> >
> - 善用[搜索引擎](https://www.google.com/) > - 善用[搜索引擎](https://www.google.com/)
> - 查阅 issue 中是否有类似问题,如果没有请按照模板发起 issue > - 查阅 issue 中是否有类似问题,如果没有请按照模板发起 issue
欢迎前往 [issue](https://github.com/HibiKier/zhenxun_bot/issues/new/choose) 中提出你遇到的问题,或者加入我们的 [用户群](https://qm.qq.com/q/mRNtLSl6uc) 或 [技术群](https://qm.qq.com/q/YYYt5rkMYc)与我们联系 欢迎前往 [issue](https://github.com/zhenxun-org/zhenxun_bot/issues/new/choose) 中提出你遇到的问题,或者加入我们的 [用户群](https://qm.qq.com/q/mRNtLSl6uc) 或 [技术群](https://qm.qq.com/q/YYYt5rkMYc)与我们联系
## 🛠️ 进度追踪 ## 🛠️ 进度追踪
@ -287,6 +309,8 @@ Project [zhenxun_bot](https://github.com/users/HibiKier/projects/2)
首席设计师:[酥酥/coldly-ss](https://github.com/coldly-ss) 首席设计师:[酥酥/coldly-ss](https://github.com/coldly-ss)
LOGO 设计:[FrostN0v0](https://github.com/FrostN0v0)
## 🙏 感谢 ## 🙏 感谢
[botuniverse / onebot](https://github.com/botuniverse/onebot) :超棒的机器人协议 [botuniverse / onebot](https://github.com/botuniverse/onebot) :超棒的机器人协议
@ -326,34 +350,68 @@ Project [zhenxun_bot](https://github.com/users/HibiKier/projects/2)
<img src="https://contrib.rocks/image?repo=HibiKier/zhenxun_bot&max=1000" alt="contributors"/> <img src="https://contrib.rocks/image?repo=HibiKier/zhenxun_bot&max=1000" alt="contributors"/>
</a> </a>
## 📸 WebUI 界面展示 ## 📸 WebUI 界面展示(仅展示默认主题下的 pc 端)
<div style="display: flex; flex-wrap: wrap; justify-content: space-between;"> <div style="display: flex; flex-wrap: wrap; justify-content: space-between;">
<div style="width: 48%; margin-bottom: 10px;">
<img src="./docs_image/webui00.png" alt="webui00" style="width: 100%; height: auto;">
</div>
<div style="width: 48%; margin-bottom: 10px;">
<img src="./docs_image/webui01.png" alt="webui01" style="width: 100%; height: auto;">
</div>
<div style="width: 48%; margin-bottom: 10px;"> #### 登录界面
<img src="./docs_image/webui02.png" alt="webui02" style="width: 100%; height: auto;">
</div>
<div style="width: 48%; margin-bottom: 10px;">
<img src="./docs_image/webui03.png" alt="webui03" style="width: 100%; height: auto;">
</div>
<div style="width: 48%; margin-bottom: 10px;"> ![x](https://github.com/zhenxun-org/zhenxun_bot/blob/main/docs_image/pc-login.jpg)
<img src="./docs_image/webui04.png" alt="webui04" style="width: 100%; height: auto;">
</div> #### API 设置
<div style="width: 48%; margin-bottom: 10px;">
<img src="./docs_image/webui05.png" alt="webui05" style="width: 100%; height: auto;"> ![x](https://github.com/zhenxun-org/zhenxun_bot/blob/main/docs_image/pc-api.jpg)
</div>
#### 仪表盘
![x](https://github.com/zhenxun-org/zhenxun_bot/blob/main/docs_image/pc-dashboard.jpg)
#### 仪表盘(展开)
![x](https://github.com/zhenxun-org/zhenxun_bot/blob/main/docs_image/pc-dashboard1.jpg)
#### 控制台
![x](https://github.com/zhenxun-org/zhenxun_bot/blob/main/docs_image/pc-command.jpg)
#### 插件列表
![x](https://github.com/zhenxun-org/zhenxun_bot/blob/main/docs_image/pc-plugin.jpg)
#### 插件列表(配置项)
![x](https://github.com/zhenxun-org/zhenxun_bot/blob/main/docs_image/pc-plugin1.jpg)
#### 插件商店
![x](https://github.com/zhenxun-org/zhenxun_bot/blob/main/docs_image/pc-store.jpg)
#### 好友/群组管理
![x](https://github.com/zhenxun-org/zhenxun_bot/blob/main/docs_image/pc-manage.jpg)
#### 请求管理
![x](https://github.com/zhenxun-org/zhenxun_bot/blob/main/docs_image/pc-manage1.jpg)
#### 数据库管理
![x](https://github.com/zhenxun-org/zhenxun_bot/blob/main/docs_image/pc-database.jpg)
### 文件管理
![x](https://github.com/zhenxun-org/zhenxun_bot/blob/main/docs_image/pc-system.jpg)
### 文件管理(文本查看)
![x](https://github.com/zhenxun-org/zhenxun_bot/blob/main/docs_image/pc-system1.jpg)
### 文件管理(图片查看)
![x](https://github.com/zhenxun-org/zhenxun_bot/blob/main/docs_image/pc-system2.jpg)
### 关于
![x](https://github.com/zhenxun-org/zhenxun_bot/blob/main/docs_image/pc-about.jpg)
<div style="width: 48%; margin-bottom: 10px;">
<img src="./docs_image/webui06.png" alt="webui06" style="width: 100%; height: auto;">
</div>
<div style="width: 48%; margin-bottom: 10px;">
<img src="./docs_image/webui07.png" alt="webui07" style="width: 100%; height: auto;">
</div>
</div> </div>

4
bot.py
View File

@ -14,9 +14,9 @@ driver.register_adapter(OneBotV11Adapter)
# driver.register_adapter(DoDoAdapter) # driver.register_adapter(DoDoAdapter)
# driver.register_adapter(DiscordAdapter) # driver.register_adapter(DiscordAdapter)
from zhenxun.services.db_context import disconnect, init from zhenxun.services.db_context import disconnect
driver.on_startup(init) # driver.on_startup(init)
driver.on_shutdown(disconnect) driver.on_shutdown(disconnect)
# nonebot.load_builtin_plugins("echo") # nonebot.load_builtin_plugins("echo")

File diff suppressed because it is too large Load Diff

BIN
docs_image/pc-about.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

BIN
docs_image/pc-api.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

BIN
docs_image/pc-command.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

BIN
docs_image/pc-dashboard.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

BIN
docs_image/pc-database.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

BIN
docs_image/pc-login.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

BIN
docs_image/pc-manage.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

BIN
docs_image/pc-manage1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

BIN
docs_image/pc-plugin.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

BIN
docs_image/pc-plugin1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

BIN
docs_image/pc-store.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

BIN
docs_image/pc-system.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

BIN
docs_image/pc-system1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

BIN
docs_image/pc-system2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

View File

@ -359,7 +359,7 @@ async def test_add_plugin_exist(
init_mocked_api(mocked_api=mocked_api) init_mocked_api(mocked_api=mocked_api)
mocker.patch( mocker.patch(
"zhenxun.builtin_plugins.plugin_store.data_source.ShopManage.get_loaded_plugins", "zhenxun.builtin_plugins.plugin_store.data_source.StoreManager.get_loaded_plugins",
return_value=[("search_image", "0.1")], return_value=[("search_image", "0.1")],
) )
plugin_id = 1 plugin_id = 1

View File

@ -57,7 +57,7 @@ async def test_search_plugin_name(
) )
ctx.receive_event(bot=bot, event=event) ctx.receive_event(bot=bot, event=event)
mock_table_page.assert_awaited_once_with( mock_table_page.assert_awaited_once_with(
"插件列表", "商店插件列表",
"通过添加/移除插件 ID 来管理插件", "通过添加/移除插件 ID 来管理插件",
["-", "ID", "名称", "简介", "作者", "版本", "类型"], ["-", "ID", "名称", "简介", "作者", "版本", "类型"],
[ [
@ -123,7 +123,7 @@ async def test_search_plugin_author(
) )
ctx.receive_event(bot=bot, event=event) ctx.receive_event(bot=bot, event=event)
mock_table_page.assert_awaited_once_with( mock_table_page.assert_awaited_once_with(
"插件列表", "商店插件列表",
"通过添加/移除插件 ID 来管理插件", "通过添加/移除插件 ID 来管理插件",
["-", "ID", "名称", "简介", "作者", "版本", "类型"], ["-", "ID", "名称", "简介", "作者", "版本", "类型"],
[ [

View File

@ -32,7 +32,7 @@ async def test_update_all_plugin_basic_need_update(
new=tmp_path / "zhenxun", new=tmp_path / "zhenxun",
) )
mocker.patch( mocker.patch(
"zhenxun.builtin_plugins.plugin_store.data_source.ShopManage.get_loaded_plugins", "zhenxun.builtin_plugins.plugin_store.data_source.StoreManager.get_loaded_plugins",
return_value=[("search_image", "0.0")], return_value=[("search_image", "0.0")],
) )
@ -87,7 +87,7 @@ async def test_update_all_plugin_basic_is_new(
new=tmp_path / "zhenxun", new=tmp_path / "zhenxun",
) )
mocker.patch( mocker.patch(
"zhenxun.builtin_plugins.plugin_store.data_source.ShopManage.get_loaded_plugins", "zhenxun.builtin_plugins.plugin_store.data_source.StoreManager.get_loaded_plugins",
return_value=[("search_image", "0.1")], return_value=[("search_image", "0.1")],
) )

View File

@ -32,7 +32,7 @@ async def test_update_plugin_basic_need_update(
new=tmp_path / "zhenxun", new=tmp_path / "zhenxun",
) )
mocker.patch( mocker.patch(
"zhenxun.builtin_plugins.plugin_store.data_source.ShopManage.get_loaded_plugins", "zhenxun.builtin_plugins.plugin_store.data_source.StoreManager.get_loaded_plugins",
return_value=[("search_image", "0.0")], return_value=[("search_image", "0.0")],
) )
@ -87,7 +87,7 @@ async def test_update_plugin_basic_is_new(
new=tmp_path / "zhenxun", new=tmp_path / "zhenxun",
) )
mocker.patch( mocker.patch(
"zhenxun.builtin_plugins.plugin_store.data_source.ShopManage.get_loaded_plugins", "zhenxun.builtin_plugins.plugin_store.data_source.StoreManager.get_loaded_plugins",
return_value=[("search_image", "0.1")], return_value=[("search_image", "0.1")],
) )

View File

@ -116,6 +116,7 @@ async def app(app: App, tmp_path: Path, mocker: MockerFixture):
await init() await init()
# await driver._lifespan.startup() # await driver._lifespan.startup()
os.environ["AIOCACHE_DISABLE"] = "1" os.environ["AIOCACHE_DISABLE"] = "1"
os.environ["PYTEST_CURRENT_TEST"] = "1"
yield app yield app

View File

@ -1,5 +1,6 @@
{ [
"鸡汤": { {
"name": "鸡汤",
"module": "jitang", "module": "jitang",
"module_path": "plugins.alapi.jitang", "module_path": "plugins.alapi.jitang",
"description": "喏,亲手为你煮的鸡汤", "description": "喏,亲手为你煮的鸡汤",
@ -9,7 +10,8 @@
"plugin_type": "NORMAL", "plugin_type": "NORMAL",
"is_dir": false "is_dir": false
}, },
"识图": { {
"name": "识图",
"module": "search_image", "module": "search_image",
"module_path": "plugins.search_image", "module_path": "plugins.search_image",
"description": "以图搜图,看破本源", "description": "以图搜图,看破本源",
@ -19,7 +21,8 @@
"plugin_type": "NORMAL", "plugin_type": "NORMAL",
"is_dir": true "is_dir": true
}, },
"网易云热评": { {
"name": "网易云热评",
"module": "comments_163", "module": "comments_163",
"module_path": "plugins.alapi.comments_163", "module_path": "plugins.alapi.comments_163",
"description": "生了个人,我很抱歉", "description": "生了个人,我很抱歉",
@ -29,7 +32,8 @@
"plugin_type": "NORMAL", "plugin_type": "NORMAL",
"is_dir": false "is_dir": false
}, },
"B站订阅": { {
"name": "B站订阅",
"module": "bilibili_sub", "module": "bilibili_sub",
"module_path": "plugins.bilibili_sub", "module_path": "plugins.bilibili_sub",
"description": "非常便利的B站订阅通知", "description": "非常便利的B站订阅通知",
@ -39,4 +43,4 @@
"plugin_type": "NORMAL", "plugin_type": "NORMAL",
"is_dir": true "is_dir": true
} }
} ]

View File

@ -1,5 +1,6 @@
{ [
"github订阅": { {
"name": "github订阅",
"module": "github_sub", "module": "github_sub",
"module_path": "github_sub", "module_path": "github_sub",
"description": "订阅github用户或仓库", "description": "订阅github用户或仓库",
@ -10,7 +11,8 @@
"is_dir": true, "is_dir": true,
"github_url": "https://github.com/xuanerwa/zhenxun_github_sub" "github_url": "https://github.com/xuanerwa/zhenxun_github_sub"
}, },
"Minecraft查服": { {
"name": "Minecraft查服",
"module": "mc_check", "module": "mc_check",
"module_path": "mc_check", "module_path": "mc_check",
"description": "Minecraft服务器状态查询支持IPv6", "description": "Minecraft服务器状态查询支持IPv6",
@ -21,4 +23,4 @@
"is_dir": true, "is_dir": true,
"github_url": "https://github.com/molanp/zhenxun_check_Minecraft" "github_url": "https://github.com/molanp/zhenxun_check_Minecraft"
} }
} ]

View File

@ -16,6 +16,7 @@ from zhenxun.models.sign_user import SignUser
from zhenxun.models.user_console import UserConsole from zhenxun.models.user_console import UserConsole
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.decorator.shop import shop_register from zhenxun.utils.decorator.shop import shop_register
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from zhenxun.utils.manager.resource_manager import ResourceManager from zhenxun.utils.manager.resource_manager import ResourceManager
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
@ -70,7 +71,7 @@ from public.bag_users t1
""" """
@driver.on_startup @PriorityLifecycle.on_startup(priority=5)
async def _(): async def _():
await ResourceManager.init_resources() await ResourceManager.init_resources()
"""签到与用户的数据迁移""" """签到与用户的数据迁移"""

View File

@ -26,6 +26,21 @@ __plugin_meta__ = PluginMetadata(
_matcher = on_alconna(Alconna("关于"), priority=5, block=True, rule=to_me()) _matcher = on_alconna(Alconna("关于"), priority=5, block=True, rule=to_me())
QQ_INFO = """
绪山真寻Bot
版本{version}
简介基于Nonebot2开发支持多平台是一个非常可爱的Bot呀希望与大家要好好相处
""".strip()
INFO = """
绪山真寻Bot
版本{version}
简介基于Nonebot2开发支持多平台是一个非常可爱的Bot呀希望与大家要好好相处
项目地址https://github.com/zhenxun-org/zhenxun_bot
文档地址https://zhenxun-org.github.io/zhenxun_bot/
""".strip()
@_matcher.handle() @_matcher.handle()
async def _(session: Uninfo, arparma: Arparma): async def _(session: Uninfo, arparma: Arparma):
ver_file = Path() / "__version__" ver_file = Path() / "__version__"
@ -35,25 +50,11 @@ async def _(session: Uninfo, arparma: Arparma):
if text := await f.read(): if text := await f.read():
version = text.split(":")[-1].strip() version = text.split(":")[-1].strip()
if PlatformUtils.is_qbot(session): if PlatformUtils.is_qbot(session):
info: list[str | Path] = [ result: list[str | Path] = [QQ_INFO.format(version=version)]
f"""
绪山真寻Bot
版本{version}
简介基于Nonebot2开发支持多平台是一个非常可爱的Bot呀希望与大家要好好相处
""".strip()
]
path = DATA_PATH / "about.png" path = DATA_PATH / "about.png"
if path.exists(): if path.exists():
info.append(path) result.append(path)
await MessageUtils.build_message(result).send() # type: ignore
else: else:
info = [ await MessageUtils.build_message(INFO.format(version=version)).send()
f"""
绪山真寻Bot
版本{version}
简介基于Nonebot2开发支持多平台是一个非常可爱的Bot呀希望与大家要好好相处
项目地址https://github.com/HibiKier/zhenxun_bot
文档地址https://hibikier.github.io/zhenxun_bot/
""".strip()
]
await MessageUtils.build_message(info).send() # type: ignore
logger.info("查看关于", arparma.header_result, session=session) logger.info("查看关于", arparma.header_result, session=session)

View File

@ -14,13 +14,19 @@ from nonebot_plugin_alconna import (
from nonebot_plugin_session import EventSession from nonebot_plugin_session import EventSession
from zhenxun.configs.config import BotConfig, Config from zhenxun.configs.config import BotConfig, Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.configs.utils import (
AICallableParam,
AICallableProperties,
AICallableTag,
PluginExtraData,
RegisterConfig,
)
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils from zhenxun.utils.message import MessageUtils
from zhenxun.utils.rules import admin_check from zhenxun.utils.rules import admin_check
from ._data_source import BanManage from ._data_source import BanManage, call_ban
base_config = Config.get("ban") base_config = Config.get("ban")
@ -78,6 +84,22 @@ __plugin_meta__ = PluginMetadata(
type=int, type=int,
) )
], ],
smart_tools=[
AICallableTag(
name="call_ban",
description="某人多次(至少三次)辱骂你,调用此方法进行封禁",
parameters=AICallableParam(
type="object",
properties={
"user_id": AICallableProperties(
type="string", description="用户的id"
),
},
required=["user_id"],
),
func=call_ban,
)
],
).to_dict(), ).to_dict(),
) )

View File

@ -5,9 +5,20 @@ from nonebot_plugin_session import EventSession
from zhenxun.models.ban_console import BanConsole from zhenxun.models.ban_console import BanConsole
from zhenxun.models.level_user import LevelUser from zhenxun.models.level_user import LevelUser
from zhenxun.services.log import logger
from zhenxun.utils.image_utils import BuildImage, ImageTemplate from zhenxun.utils.image_utils import BuildImage, ImageTemplate
async def call_ban(user_id: str):
"""调用ban
参数:
user_id: 用户id
"""
await BanConsole.ban(user_id, None, 9, 60 * 12)
logger.info("辱骂次数过多,已将用户加入黑名单...", "ban", session=user_id)
class BanManage: class BanManage:
@classmethod @classmethod
async def build_ban_image( async def build_ban_image(

View File

@ -14,6 +14,7 @@ from zhenxun.services.log import logger
from zhenxun.utils._build_image import BuildImage from zhenxun.utils._build_image import BuildImage
from zhenxun.utils._image_template import ImageTemplate from zhenxun.utils._image_template import ImageTemplate
from zhenxun.utils.http_utils import AsyncHttpx from zhenxun.utils.http_utils import AsyncHttpx
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
BASE_PATH = DATA_PATH / "welcome_message" BASE_PATH = DATA_PATH / "welcome_message"
@ -91,7 +92,7 @@ def migrate(path: Path):
json.dump(new_data, f, ensure_ascii=False, indent=4) json.dump(new_data, f, ensure_ascii=False, indent=4)
@driver.on_startup @PriorityLifecycle.on_startup(priority=5)
def _(): def _():
"""数据迁移 """数据迁移

View File

@ -1,4 +1,5 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from io import BytesIO
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import ( from nonebot_plugin_alconna import (
@ -14,35 +15,38 @@ from nonebot_plugin_alconna import (
from nonebot_plugin_session import EventSession from nonebot_plugin_session import EventSession
import pytz import pytz
from zhenxun.configs.utils import Command, PluginExtraData from zhenxun.configs.config import Config
from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig
from zhenxun.models.chat_history import ChatHistory from zhenxun.models.chat_history import ChatHistory
from zhenxun.models.group_member_info import GroupInfoUser from zhenxun.models.group_member_info import GroupInfoUser
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import PluginType
from zhenxun.utils.image_utils import ImageTemplate from zhenxun.utils.image_utils import BuildImage, ImageTemplate
from zhenxun.utils.message import MessageUtils from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
name="消息统计", name="消息统计",
description="消息统计查询", description="消息统计查询",
usage=""" usage="""
格式: 格式:
消息排行 ?[type [,,,]] ?[--des] 消息排行 ?[type [,,,,]] ?[--des]
快捷: 快捷:
[,,,]消息排行 ?[数量] [,,,,]消息排行 ?[数量]
示例: 示例:
消息排行 : 所有记录排行 消息排行 : 所有记录排行
日消息排行 : 今日记录排行 日消息排行 : 今日记录排行
周消息排行 : 今日记录排行 周消息排行 : 本周记录排行
月消息排行 : 今日记录排行 月消息排行 : 本月记录排行
年消息排行 : 今日记录排行 季消息排行 : 本季度记录排行
年消息排行 : 本年记录排行
消息排行 --des : 逆序周记录排行 消息排行 --des : 逆序周记录排行
""".strip(), """.strip(),
extra=PluginExtraData( extra=PluginExtraData(
author="HibiKier", author="HibiKier",
version="0.1", version="0.2",
plugin_type=PluginType.NORMAL, plugin_type=PluginType.NORMAL,
menu_type="数据统计", menu_type="数据统计",
commands=[ commands=[
@ -50,8 +54,19 @@ __plugin_meta__ = PluginMetadata(
Command(command="日消息统计"), Command(command="日消息统计"),
Command(command="周消息排行"), Command(command="周消息排行"),
Command(command="月消息排行"), Command(command="月消息排行"),
Command(command="季消息排行"),
Command(command="年消息排行"), Command(command="年消息排行"),
], ],
configs=[
RegisterConfig(
module="chat_history",
key="SHOW_QUIT_MEMBER",
value=True,
help="是否在消息排行中显示已退群用户",
default_value=True,
type=bool,
)
],
).to_dict(), ).to_dict(),
) )
@ -60,7 +75,7 @@ _matcher = on_alconna(
Alconna( Alconna(
"消息排行", "消息排行",
Option("--des", action=store_true, help_text="逆序"), Option("--des", action=store_true, help_text="逆序"),
Args["type?", ["", "", "", ""]]["count?", int, 10], Args["type?", ["", "", "", "", ""]]["count?", int, 10],
), ),
aliases={"消息统计"}, aliases={"消息统计"},
priority=5, priority=5,
@ -68,7 +83,7 @@ _matcher = on_alconna(
) )
_matcher.shortcut( _matcher.shortcut(
r"(?P<type>['', '', '', ''])?消息(排行|统计)\s?(?P<cnt>\d+)?", r"(?P<type>['', '', '', '', ''])?消息(排行|统计)\s?(?P<cnt>\d+)?",
command="消息排行", command="消息排行",
arguments=["{type}", "{cnt}"], arguments=["{type}", "{cnt}"],
prefix=True, prefix=True,
@ -96,20 +111,57 @@ async def _(
date_scope = (time_now - timedelta(days=7), time_now) date_scope = (time_now - timedelta(days=7), time_now)
elif date in [""]: elif date in [""]:
date_scope = (time_now - timedelta(days=30), time_now) date_scope = (time_now - timedelta(days=30), time_now)
column_name = ["名次", "昵称", "发言次数"] elif date in [""]:
date_scope = (time_now - timedelta(days=90), time_now)
column_name = ["名次", "头像", "昵称", "发言次数"]
show_quit_member = Config.get_config("chat_history", "SHOW_QUIT_MEMBER", True)
fetch_count = count.result
if not show_quit_member:
fetch_count = count.result * 2
if rank_data := await ChatHistory.get_group_msg_rank( if rank_data := await ChatHistory.get_group_msg_rank(
group_id, count.result, "DES" if arparma.find("des") else "DESC", date_scope group_id, fetch_count, "DES" if arparma.find("des") else "DESC", date_scope
): ):
idx = 1 idx = 1
data_list = [] data_list = []
for uid, num in rank_data: for uid, num in rank_data:
if user := await GroupInfoUser.filter( if len(data_list) >= count.result:
break
user_in_group = await GroupInfoUser.filter(
user_id=uid, group_id=group_id user_id=uid, group_id=group_id
).first(): ).first()
user_name = user.user_name
if not user_in_group and not show_quit_member:
continue
if user_in_group:
user_name = user_in_group.user_name
else: else:
user_name = uid user_name = f"{uid}(已退群)"
data_list.append([idx, user_name, num])
avatar_size = 40
try:
avatar_bytes = await PlatformUtils.get_user_avatar(str(uid), "qq")
if avatar_bytes:
avatar_img = BuildImage(
avatar_size, avatar_size, background=BytesIO(avatar_bytes)
)
await avatar_img.circle()
avatar_tuple = (avatar_img, avatar_size, avatar_size)
else:
avatar_img = BuildImage(avatar_size, avatar_size, color="#CCCCCC")
await avatar_img.circle()
avatar_tuple = (avatar_img, avatar_size, avatar_size)
except Exception as e:
logger.warning(f"获取用户头像失败: {e}", "chat_history")
avatar_img = BuildImage(avatar_size, avatar_size, color="#CCCCCC")
await avatar_img.circle()
avatar_tuple = (avatar_img, avatar_size, avatar_size)
data_list.append([idx, avatar_tuple, user_name, num])
idx += 1 idx += 1
if not date_scope: if not date_scope:
if date_scope := await ChatHistory.get_group_first_msg_datetime(group_id): if date_scope := await ChatHistory.get_group_first_msg_datetime(group_id):
@ -132,13 +184,3 @@ async def _(
) )
await MessageUtils.build_message(A).finish(reply_to=True) await MessageUtils.build_message(A).finish(reply_to=True)
await MessageUtils.build_message("群组消息记录为空...").finish() await MessageUtils.build_message("群组消息记录为空...").finish()
# # @test.handle()
# # async def _(event: MessageEvent):
# # print(await ChatHistory.get_user_msg(event.user_id, "private"))
# # print(await ChatHistory.get_user_msg_count(event.user_id, "private"))
# # print(await ChatHistory.get_user_msg(event.user_id, "group"))
# # print(await ChatHistory.get_user_msg_count(event.user_id, "group"))
# # print(await ChatHistory.get_group_msg(event.group_id))
# # print(await ChatHistory.get_group_msg_count(event.group_id))

View File

@ -37,10 +37,16 @@ __plugin_meta__ = PluginMetadata(
configs=[ configs=[
RegisterConfig( RegisterConfig(
key="type", key="type",
value="normal", value="zhenxun",
help="帮助图片样式 ['normal', 'HTML', 'zhenxun']", help="帮助图片样式 [normal, HTML, zhenxun]",
default_value="zhenxun", default_value="zhenxun",
) ),
RegisterConfig(
key="detail_type",
value="zhenxun",
help="帮助详情图片样式 ['normal', 'zhenxun']",
default_value="zhenxun",
),
], ],
).to_dict(), ).to_dict(),
) )

View File

@ -1,13 +1,19 @@
from pathlib import Path from pathlib import Path
import nonebot import nonebot
from nonebot.plugin import PluginMetadata
from nonebot_plugin_htmlrender import template_to_pic
from nonebot_plugin_uninfo import Uninfo from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.path_config import IMAGE_PATH from zhenxun.configs.config import Config
from zhenxun.configs.path_config import IMAGE_PATH, TEMPLATE_PATH
from zhenxun.configs.utils import PluginExtraData
from zhenxun.models.level_user import LevelUser from zhenxun.models.level_user import LevelUser
from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.statistics import Statistics
from zhenxun.utils._image_template import ImageTemplate
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import PluginType
from zhenxun.utils.image_utils import BuildImage, ImageTemplate from zhenxun.utils.image_utils import BuildImage
from ._config import ( from ._config import (
GROUP_HELP_PATH, GROUP_HELP_PATH,
@ -40,7 +46,9 @@ async def create_help_img(
match help_type: match help_type:
case "html": case "html":
result = BuildImage.open(await build_html_image(group_id, is_detail)) result = BuildImage.open(
await build_html_image(session, group_id, is_detail)
)
case "zhenxun": case "zhenxun":
result = BuildImage.open( result = BuildImage.open(
await build_zhenxun_image(session, group_id, is_detail) await build_zhenxun_image(session, group_id, is_detail)
@ -78,9 +86,96 @@ async def get_user_allow_help(user_id: str) -> list[PluginType]:
return type_list return type_list
async def get_plugin_help( async def get_normal_help(
user_id: str, name: str, is_superuser: bool metadata: PluginMetadata, extra: PluginExtraData, is_superuser: bool
) -> str | BuildImage: ) -> str | bytes:
"""构建默认帮助详情
参数:
metadata: PluginMetadata
extra: PluginExtraData
is_superuser: 是否超级用户帮助
返回:
str | bytes: 返回信息
"""
items = None
if is_superuser:
if usage := extra.superuser_help:
items = {
"简介": metadata.description,
"用法": usage,
}
else:
items = {
"简介": metadata.description,
"用法": metadata.usage,
}
if items:
return (await ImageTemplate.hl_page(metadata.name, items)).pic2bytes()
return "该功能没有帮助信息"
def min_leading_spaces(str_list: list[str]) -> int:
min_spaces = 9999
for s in str_list:
leading_spaces = len(s) - len(s.lstrip(" "))
if leading_spaces < min_spaces:
min_spaces = leading_spaces
return min_spaces if min_spaces != 9999 else 0
def split_text(text: str):
split_text = text.split("\n")
min_spaces = min_leading_spaces(split_text)
if min_spaces > 0:
split_text = [s[min_spaces:] for s in split_text]
return [s.replace(" ", "&nbsp;") for s in split_text]
async def get_zhenxun_help(
module: str, metadata: PluginMetadata, extra: PluginExtraData, is_superuser: bool
) -> str | bytes:
"""构建ZhenXun帮助详情
参数:
module: 模块名
metadata: PluginMetadata
extra: PluginExtraData
is_superuser: 是否超级用户帮助
返回:
str | bytes: 返回信息
"""
call_count = await Statistics.filter(plugin_name=module).count()
usage = metadata.usage
if is_superuser:
if not extra.superuser_help:
return "该功能没有超级用户帮助信息"
usage = extra.superuser_help
return await template_to_pic(
template_path=str((TEMPLATE_PATH / "help_detail").absolute()),
template_name="main.html",
templates={
"title": metadata.name,
"author": extra.author,
"version": extra.version,
"call_count": call_count,
"descriptions": split_text(metadata.description),
"usages": split_text(usage),
},
pages={
"viewport": {"width": 824, "height": 590},
"base_url": f"file://{TEMPLATE_PATH}",
},
wait=2,
)
async def get_plugin_help(user_id: str, name: str, is_superuser: bool) -> str | bytes:
"""获取功能的帮助信息 """获取功能的帮助信息
参数: 参数:
@ -98,20 +193,12 @@ async def get_plugin_help(
if plugin: if plugin:
_plugin = nonebot.get_plugin_by_module_name(plugin.module_path) _plugin = nonebot.get_plugin_by_module_name(plugin.module_path)
if _plugin and _plugin.metadata: if _plugin and _plugin.metadata:
items = None extra_data = PluginExtraData(**_plugin.metadata.extra)
if is_superuser: if Config.get_config("help", "detail_type") == "zhenxun":
extra = _plugin.metadata.extra return await get_zhenxun_help(
if usage := extra.get("superuser_help"): plugin.module, _plugin.metadata, extra_data, is_superuser
items = { )
"简介": _plugin.metadata.description,
"用法": usage,
}
else: else:
items = { return await get_normal_help(_plugin.metadata, extra_data, is_superuser)
"简介": _plugin.metadata.description,
"用法": _plugin.metadata.usage,
}
if items:
return await ImageTemplate.hl_page(plugin.name, items)
return "糟糕! 该功能没有帮助喔..." return "糟糕! 该功能没有帮助喔..."
return "没有查找到这个功能噢..." return "没有查找到这个功能噢..."

View File

@ -1,5 +1,8 @@
from collections.abc import Callable from collections.abc import Callable
from nonebot_plugin_uninfo import Uninfo
from zhenxun.models.bot_console import BotConsole
from zhenxun.models.group_console import GroupConsole from zhenxun.models.group_console import GroupConsole
from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.plugin_info import PluginInfo
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import PluginType
@ -27,13 +30,15 @@ async def sort_type() -> dict[str, list[PluginInfo]]:
async def classify_plugin( async def classify_plugin(
group_id: str | None, is_detail: bool, handle: Callable session: Uninfo, group_id: str | None, is_detail: bool, handle: Callable
) -> dict[str, list]: ) -> dict[str, list]:
"""对插件进行分类并判断状态 """对插件进行分类并判断状态
参数: 参数:
session: Uninfo对象
group_id: 群组id group_id: 群组id
is_detail: 是否详细帮助 is_detail: 是否详细帮助
handle: 回调方法
返回: 返回:
dict[str, list[Item]]: 分类插件数据 dict[str, list[Item]]: 分类插件数据
@ -41,9 +46,10 @@ async def classify_plugin(
sort_data = await sort_type() sort_data = await sort_type()
classify: dict[str, list] = {} classify: dict[str, list] = {}
group = await GroupConsole.get_or_none(group_id=group_id) if group_id else None group = await GroupConsole.get_or_none(group_id=group_id) if group_id else None
bot = await BotConsole.get_or_none(bot_id=session.self_id)
for menu, value in sort_data.items(): for menu, value in sort_data.items():
for plugin in value: for plugin in value:
if not classify.get(menu): if not classify.get(menu):
classify[menu] = [] classify[menu] = []
classify[menu].append(handle(plugin, group, is_detail)) classify[menu].append(handle(bot, plugin, group, is_detail))
return classify return classify

View File

@ -2,9 +2,11 @@ import os
import random import random
from nonebot_plugin_htmlrender import template_to_pic from nonebot_plugin_htmlrender import template_to_pic
from nonebot_plugin_uninfo import Uninfo
from pydantic import BaseModel from pydantic import BaseModel
from zhenxun.configs.path_config import TEMPLATE_PATH from zhenxun.configs.path_config import TEMPLATE_PATH
from zhenxun.models.bot_console import BotConsole
from zhenxun.models.group_console import GroupConsole from zhenxun.models.group_console import GroupConsole
from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.plugin_info import PluginInfo
from zhenxun.utils.enum import BlockType from zhenxun.utils.enum import BlockType
@ -48,11 +50,12 @@ ICON2STR = {
def __handle_item( def __handle_item(
plugin: PluginInfo, group: GroupConsole | None, is_detail: bool bot: BotConsole, plugin: PluginInfo, group: GroupConsole | None, is_detail: bool
) -> Item: ) -> Item:
"""构造Item """构造Item
参数: 参数:
bot: BotConsole
plugin: PluginInfo plugin: PluginInfo
group: 群组 group: 群组
is_detail: 是否详细 is_detail: 是否详细
@ -73,10 +76,13 @@ def __handle_item(
]: ]:
sta = 2 sta = 2
if group: if group:
if f"{plugin.module}:super," in group.block_plugin: if f"{plugin.module}," in group.superuser_block_plugin:
sta = 2 sta = 2
if f"{plugin.module}," in group.block_plugin: if f"{plugin.module}," in group.block_plugin:
sta = 1 sta = 1
if bot:
if f"{plugin.module}," in bot.block_plugins:
sta = 2
return Item(plugin_name=plugin.name, sta=sta) return Item(plugin_name=plugin.name, sta=sta)
@ -119,14 +125,17 @@ def build_plugin_data(classify: dict[str, list[Item]]) -> list[dict[str, str]]:
return plugin_list return plugin_list
async def build_html_image(group_id: str | None, is_detail: bool) -> bytes: async def build_html_image(
session: Uninfo, group_id: str | None, is_detail: bool
) -> bytes:
"""构造HTML帮助图片 """构造HTML帮助图片
参数: 参数:
session: Uninfo
group_id: 群号 group_id: 群号
is_detail: 是否详细帮助 is_detail: 是否详细帮助
""" """
classify = await classify_plugin(group_id, is_detail, __handle_item) classify = await classify_plugin(session, group_id, is_detail, __handle_item)
plugin_list = build_plugin_data(classify) plugin_list = build_plugin_data(classify)
return await template_to_pic( return await template_to_pic(
template_path=str((TEMPLATE_PATH / "menu").absolute()), template_path=str((TEMPLATE_PATH / "menu").absolute()),

View File

@ -6,6 +6,7 @@ from pydantic import BaseModel
from zhenxun.configs.config import BotConfig from zhenxun.configs.config import BotConfig
from zhenxun.configs.path_config import TEMPLATE_PATH from zhenxun.configs.path_config import TEMPLATE_PATH
from zhenxun.configs.utils import PluginExtraData from zhenxun.configs.utils import PluginExtraData
from zhenxun.models.bot_console import BotConsole
from zhenxun.models.group_console import GroupConsole from zhenxun.models.group_console import GroupConsole
from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.plugin_info import PluginInfo
from zhenxun.utils.enum import BlockType from zhenxun.utils.enum import BlockType
@ -21,12 +22,19 @@ class Item(BaseModel):
"""插件命令""" """插件命令"""
def __handle_item(plugin: PluginInfo, group: GroupConsole | None, is_detail: bool): def __handle_item(
bot: BotConsole | None,
plugin: PluginInfo,
group: GroupConsole | None,
is_detail: bool,
):
"""构造Item """构造Item
参数: 参数:
bot: BotConsole
plugin: PluginInfo plugin: PluginInfo
group: 群组 group: 群组
is_detail: 是否为详细
返回: 返回:
Item: Item Item: Item
@ -40,6 +48,8 @@ def __handle_item(plugin: PluginInfo, group: GroupConsole | None, is_detail: boo
plugin.name = f"{plugin.name}(不可用)" plugin.name = f"{plugin.name}(不可用)"
elif group and f"{plugin.module}," in group.block_plugin: elif group and f"{plugin.module}," in group.block_plugin:
plugin.name = f"{plugin.name}(不可用)" plugin.name = f"{plugin.name}(不可用)"
elif bot and f"{plugin.module}," in bot.block_plugins:
plugin.name = f"{plugin.name}(不可用)"
commands = [] commands = []
nb_plugin = nonebot.get_plugin_by_module_name(plugin.module_path) nb_plugin = nonebot.get_plugin_by_module_name(plugin.module_path)
if is_detail and nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra: if is_detail and nb_plugin and nb_plugin.metadata and nb_plugin.metadata.extra:
@ -142,7 +152,7 @@ async def build_zhenxun_image(
group_id: 群号 group_id: 群号
is_detail: 是否详细帮助 is_detail: 是否详细帮助
""" """
classify = await classify_plugin(group_id, is_detail, __handle_item) classify = await classify_plugin(session, group_id, is_detail, __handle_item)
plugin_list = build_plugin_data(classify) plugin_list = build_plugin_data(classify)
platform = PlatformUtils.get_platform(session) platform = PlatformUtils.get_platform(session)
bot_id = BotConfig.get_qbot_uid(session.self_id) or session.self_id bot_id = BotConfig.get_qbot_uid(session.self_id) or session.self_id

View File

@ -21,7 +21,7 @@ from zhenxun.utils.message import MessageUtils
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
name="笨蛋检测", name="笨蛋检测",
description="功能名称当命令检测", description="功能名称当命令检测",
usage="""被动""".strip(), usage="""当一些笨蛋直接输入功能名称时,提示笨蛋使用帮助指令查看功能帮助""".strip(),
extra=PluginExtraData( extra=PluginExtraData(
author="HibiKier", author="HibiKier",
version="0.1", version="0.1",

View File

@ -49,4 +49,14 @@ Config.add_plugin_config(
type=bool, type=bool,
) )
Config.add_plugin_config(
"hook",
"RECORD_BOT_SENT_MESSAGES",
True,
help="记录bot消息发送",
default_value=True,
type=bool,
)
nonebot.load_plugins(str(Path(__file__).parent.resolve())) nonebot.load_plugins(str(Path(__file__).parent.resolve()))

View File

@ -1,23 +1,85 @@
from typing import Any from typing import Any
from nonebot.adapters import Bot from nonebot.adapters import Bot, Message
from zhenxun.configs.config import Config
from zhenxun.models.bot_message_store import BotMessageStore
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.enum import BotSentType
from zhenxun.utils.manager.message_manager import MessageManager from zhenxun.utils.manager.message_manager import MessageManager
from zhenxun.utils.platform import PlatformUtils
def replace_message(message: Message) -> str:
"""将消息中的at、image、record、face替换为字符串
参数:
message: Message
返回:
str: 文本消息
"""
result = ""
for msg in message:
if isinstance(msg, str):
result += msg
elif msg.type == "at":
result += f"@{msg.data['qq']}"
elif msg.type == "image":
result += "[image]"
elif msg.type == "record":
result += "[record]"
elif msg.type == "face":
result += f"[face:{msg.data['id']}]"
elif msg.type == "reply":
result += ""
else:
result += str(msg)
return result
@Bot.on_called_api @Bot.on_called_api
async def handle_api_result( async def handle_api_result(
bot: Bot, exception: Exception | None, api: str, data: dict[str, Any], result: Any bot: Bot, exception: Exception | None, api: str, data: dict[str, Any], result: Any
): ):
if not exception and api == "send_msg": if exception or api != "send_msg":
return
user_id = data.get("user_id")
group_id = data.get("group_id")
message_id = result.get("message_id")
message: Message = data.get("message", "")
message_type = data.get("message_type")
try: try:
if (uid := data.get("user_id")) and (msg_id := result.get("message_id")): # 记录消息id
MessageManager.add(str(uid), str(msg_id)) if user_id and message_id:
MessageManager.add(str(user_id), str(message_id))
logger.debug( logger.debug(
f"收集消息iduser_id: {uid}, msg_id: {msg_id}", "msg_hook" f"收集消息iduser_id: {user_id}, msg_id: {message_id}", "msg_hook"
) )
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"收集消息id发生错误...data: {data}, result: {result}", "msg_hook", e=e f"收集消息id发生错误...data: {data}, result: {result}", "msg_hook", e=e
) )
if not Config.get_config("hook", "RECORD_BOT_SENT_MESSAGES"):
return
try:
await BotMessageStore.create(
bot_id=bot.self_id,
user_id=user_id,
group_id=group_id,
sent_type=BotSentType.GROUP
if message_type == "group"
else BotSentType.PRIVATE,
text=replace_message(message),
plain_text=message.extract_plain_text()
if isinstance(message, Message)
else replace_message(message),
platform=PlatformUtils.get_platform(bot),
)
logger.debug(f"消息发送记录message: {message}")
except Exception as e:
logger.warning(
f"消息发送记录发生错误...data: {data}, result: {result}",
"msg_hook",
e=e,
)

View File

@ -11,6 +11,7 @@ from zhenxun.configs.config import Config
from zhenxun.configs.path_config import DATA_PATH from zhenxun.configs.path_config import DATA_PATH
from zhenxun.configs.utils import RegisterConfig from zhenxun.configs.utils import RegisterConfig
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
_yaml = YAML(pure=True) _yaml = YAML(pure=True)
_yaml.allow_unicode = True _yaml.allow_unicode = True
@ -57,7 +58,7 @@ def _generate_simple_config(exists_module: list[str]):
生成简易配置 生成简易配置
异常: 异常:
AttributeError: _description_ AttributeError: AttributeError
""" """
# 读取用户配置 # 读取用户配置
_data = {} _data = {}
@ -73,7 +74,9 @@ def _generate_simple_config(exists_module: list[str]):
if _data.get(module) and k in _data[module].keys(): if _data.get(module) and k in _data[module].keys():
Config.set_config(module, k, _data[module][k]) Config.set_config(module, k, _data[module][k])
if f"{module}:{k}".lower() in exists_module: if f"{module}:{k}".lower() in exists_module:
_tmp_data[module][k] = Config.get_config(module, k) _tmp_data[module][k] = Config.get_config(
module, k, build_model=False
)
except AttributeError as e: except AttributeError as e:
raise AttributeError(f"{e}\n可能为config.yaml配置文件填写不规范") from e raise AttributeError(f"{e}\n可能为config.yaml配置文件填写不规范") from e
if not _tmp_data[module]: if not _tmp_data[module]:
@ -102,7 +105,7 @@ def _generate_simple_config(exists_module: list[str]):
temp_file.unlink() temp_file.unlink()
@driver.on_startup @PriorityLifecycle.on_startup(priority=0)
def _(): def _():
""" """
初始化插件数据配置 初始化插件数据配置
@ -125,3 +128,4 @@ def _():
with plugins2config_file.open("w", encoding="utf8") as wf: with plugins2config_file.open("w", encoding="utf8") as wf:
_yaml.dump(_data, wf) _yaml.dump(_data, wf)
_generate_simple_config(exists_module) _generate_simple_config(exists_module)
Config.reload()

View File

@ -20,6 +20,7 @@ from zhenxun.utils.enum import (
PluginLimitType, PluginLimitType,
PluginType, PluginType,
) )
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from .manager import manager from .manager import manager
@ -95,7 +96,7 @@ async def _handle_setting(
) )
@driver.on_startup @PriorityLifecycle.on_startup(priority=5)
async def _(): async def _():
""" """
初始化插件数据配置 初始化插件数据配置

View File

@ -10,6 +10,7 @@ from zhenxun.models.group_console import GroupConsole
from zhenxun.models.task_info import TaskInfo from zhenxun.models.task_info import TaskInfo
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.common_utils import CommonUtils from zhenxun.utils.common_utils import CommonUtils
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
driver: Driver = nonebot.get_driver() driver: Driver = nonebot.get_driver()
@ -132,7 +133,7 @@ async def create_schedule(task: Task):
logger.error(f"动态创建定时任务 {task.name}({task.module}) 失败", e=e) logger.error(f"动态创建定时任务 {task.name}({task.module}) 失败", e=e)
@driver.on_startup @PriorityLifecycle.on_startup(priority=5)
async def _(): async def _():
""" """
初始化插件数据配置 初始化插件数据配置

View File

@ -0,0 +1,252 @@
from datetime import datetime
from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import Alconna, Args, Arparma, Match, Subcommand, on_alconna
from nonebot_plugin_apscheduler import scheduler
from nonebot_plugin_uninfo import Uninfo
from nonebot_plugin_waiter import prompt_until
from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.services.log import logger
from zhenxun.utils.depends import UserName
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.utils import is_number
from .data_source import BankManager
__plugin_meta__ = PluginMetadata(
name="小真寻银行",
description="""
小真寻银行提供高品质的存款当好感度等级达到指初识时小真寻会偷偷的帮助你哦
存款额度与好感度有关每日存款次数有限制
基础存款提供基础利息
每日存款提供高额利息
""".strip(),
usage="""
指令
存款 [金额]
取款 [金额]
银行信息
我的银行信息
""".strip(),
extra=PluginExtraData(
author="HibiKier",
version="0.1",
menu_type="群内小游戏",
configs=[
RegisterConfig(
key="sign_max_deposit",
value=100,
help="好感度换算存款金额比例当值是100时最大存款金额=好感度*100存款的最低金额是100强制",
default_value=100,
type=int,
),
RegisterConfig(
key="max_daily_deposit_count",
value=3,
help="每日最大存款次数",
default_value=3,
type=int,
),
RegisterConfig(
key="rate_range",
value=[0.0005, 0.001],
help="小时利率范围",
default_value=[0.0005, 0.001],
type=list[float],
),
RegisterConfig(
key="impression_event",
value=25,
help="到达指定好感度时随机提高或降低利率",
default_value=25,
type=int,
),
RegisterConfig(
key="impression_event_range",
value=[0.00001, 0.0003],
help="到达指定好感度时随机提高或降低利率",
default_value=[0.00001, 0.0003],
type=list[float],
),
RegisterConfig(
key="impression_event_prop",
value=0.3,
help="到达指定好感度时随机提高或降低利率触发概率",
default_value=0.3,
type=float,
),
],
).to_dict(),
)
_matcher = on_alconna(
Alconna(
"mahiro-bank",
Subcommand("deposit", Args["amount?", int]),
Subcommand("withdraw", Args["amount?", int]),
Subcommand("user-info"),
Subcommand("bank-info"),
# Subcommand("loan", Args["amount?", int]),
# Subcommand("repayment", Args["amount?", int]),
),
priority=5,
block=True,
)
_matcher.shortcut(
r"存款\s*(?P<amount>\d+)?",
command="mahiro-bank",
arguments=["deposit", "{amount}"],
prefix=True,
)
_matcher.shortcut(
r"取款\s*(?P<withdraw>\d+)?",
command="mahiro-bank",
arguments=["withdraw", "{withdraw}"],
prefix=True,
)
_matcher.shortcut(
r"我的银行信息",
command="mahiro-bank",
arguments=["user-info"],
prefix=True,
)
_matcher.shortcut(
r"银行信息",
command="mahiro-bank",
arguments=["bank-info"],
prefix=True,
)
async def get_amount(handle_type: str) -> int:
amount_num = await prompt_until(
f"请输入{handle_type}金币数量",
lambda msg: is_number(msg.extract_plain_text()),
timeout=60,
retry=3,
retry_prompt="输入错误,请输入数字。剩余次数:{count}",
)
if not amount_num:
await MessageUtils.build_message(
"输入超时了哦,小真寻柜员以取消本次存款操作..."
).finish()
return int(amount_num.extract_plain_text())
@_matcher.assign("deposit")
async def _(session: Uninfo, arparma: Arparma, amount: Match[int]):
amount_num = amount.result if amount.available else await get_amount("存款")
if result := await BankManager.deposit_check(session.user.id, amount_num):
await MessageUtils.build_message(result).finish(reply_to=True)
_, rate, event_rate = await BankManager.deposit(session.user.id, amount_num)
result = (
f"存款成功!\n此次存款金额为: {amount.result}\n"
f"当前小时利率为: {rate * 100:.2f}%"
)
effective_hour = int(24 - datetime.now().hour)
if event_rate:
result += f"(小真寻偷偷将小时利率给你增加了 {event_rate:.2f}% 哦)"
result += (
f"\n预计总收益为: {int(amount.result * rate * effective_hour) or 1} 金币。"
)
logger.info(
f"小真寻银行存款:{amount_num},当前存款数:{amount.result},存款小时利率: {rate}",
arparma.header_result,
session=session,
)
await MessageUtils.build_message(result).finish(at_sender=True)
@_matcher.assign("withdraw")
async def _(session: Uninfo, arparma: Arparma, amount: Match[int]):
amount_num = amount.result if amount.available else await get_amount("取款")
if result := await BankManager.withdraw_check(session.user.id, amount_num):
await MessageUtils.build_message(result).finish(reply_to=True)
try:
user = await BankManager.withdraw(session.user.id, amount_num)
result = (
f"取款成功!\n当前取款金额为: {amount_num}\n当前存款金额为: {user.amount}"
)
logger.info(
f"小真寻银行取款:{amount_num}, 当前存款数:{user.amount},"
f" 存款小时利率:{user.rate}",
arparma.header_result,
session=session,
)
await MessageUtils.build_message(result).finish(reply_to=True)
except ValueError:
await MessageUtils.build_message("你的银行内的存款数量不足哦...").finish(
reply_to=True
)
@_matcher.assign("user-info")
async def _(session: Uninfo, arparma: Arparma, uname: str = UserName()):
result = await BankManager.get_user_info(session, uname)
await MessageUtils.build_message(result).send()
logger.info("查看银行个人信息", arparma.header_result, session=session)
@_matcher.assign("bank-info")
async def _(session: Uninfo, arparma: Arparma):
result = await BankManager.get_bank_info()
await MessageUtils.build_message(result).send()
logger.info("查看银行信息", arparma.header_result, session=session)
# @_matcher.assign("loan")
# async def _(session: Uninfo, arparma: Arparma, amount: Match[int]):
# amount_num = amount.result if amount.available else await get_amount("贷款")
# if amount_num <= 0:
# await MessageUtils.build_message("贷款数量必须大于 0 啊笨蛋!").finish()
# try:
# user, event_rate = await BankManager.loan(session.user.id, amount_num)
# result = (
# f"贷款成功!\n当前贷金额为: {user.loan_amount}"
# f"\n当前利率为: {user.loan_rate * 100}%"
# )
# if event_rate:
# result += f"(小真寻偷偷将利率给你降低了 {event_rate}% 哦)"
# result += f"\n预计每小时利息为:{int(user.loan_amount * user.loan_rate)}金币。"
# logger.info(
# f"小真寻银行贷款: {amount_num}, 当前贷款数: {user.loan_amount}, "
# f"贷款利率: {user.loan_rate}",
# arparma.header_result,
# session=session,
# )
# except ValueError:
# await MessageUtils.build_message(
# "贷款数量超过最大限制,请签到提升好感度获取更多额度吧..."
# ).finish(reply_to=True)
# @_matcher.assign("repayment")
# async def _(session: Uninfo, arparma: Arparma, amount: Match[int]):
# amount_num = amount.result if amount.available else await get_amount("还款")
# if amount_num <= 0:
# await MessageUtils.build_message("还款数量必须大于 0 啊笨蛋!").finish()
# user = await BankManager.repayment(session.user.id, amount_num)
# result = (f"还款成功!\n当前还款金额为: {amount_num}\n"
# f"当前贷款金额为: {user.loan_amount}")
# logger.info(
# f"小真寻银行还款:{amount_num},当前贷款数:{user.amount}, 贷款利率:{user.rate}",
# arparma.header_result,
# session=session,
# )
# await MessageUtils.build_message(result).finish(at_sender=True)
@scheduler.scheduled_job(
"cron",
hour=0,
minute=0,
)
async def _():
await BankManager.settlement()
logger.info("小真寻银行结算", "定时任务")

View File

@ -0,0 +1,450 @@
import asyncio
from datetime import datetime, timedelta
import random
from nonebot_plugin_htmlrender import template_to_pic
from nonebot_plugin_uninfo import Uninfo
from tortoise.expressions import RawSQL
from tortoise.functions import Count, Sum
from zhenxun.configs.config import Config
from zhenxun.configs.path_config import TEMPLATE_PATH
from zhenxun.models.mahiro_bank import MahiroBank
from zhenxun.models.mahiro_bank_log import MahiroBankLog
from zhenxun.models.sign_user import SignUser
from zhenxun.models.user_console import UserConsole
from zhenxun.utils.enum import BankHandleType, GoldHandle
from zhenxun.utils.platform import PlatformUtils
base_config = Config.get("mahiro_bank")
class BankManager:
@classmethod
async def random_event(cls, impression: float):
"""随机事件"""
impression_event = base_config.get("impression_event")
impression_event_prop = base_config.get("impression_event_prop")
impression_event_range = base_config.get("impression_event_range")
if impression >= impression_event and random.random() < impression_event_prop:
"""触发好感度事件"""
return random.uniform(impression_event_range[0], impression_event_range[1])
return None
@classmethod
async def deposit_check(cls, user_id: str, amount: int) -> str | None:
"""检查存款是否合法
参数:
user_id: 用户id
amount: 存款金额
返回:
str | None: 存款信息
"""
if amount <= 0:
return "存款数量必须大于 0 啊笨蛋!"
user, sign_user, bank_user = await asyncio.gather(
*[
UserConsole.get_user(user_id),
SignUser.get_user(user_id),
cls.get_user(user_id),
]
)
sign_max_deposit: int = base_config.get("sign_max_deposit")
max_deposit = max(int(float(sign_user.impression) * sign_max_deposit), 100)
if user.gold < amount:
return f"金币数量不足,当前你的金币为:{user.gold}."
if bank_user.amount + amount > max_deposit:
return (
f"存款超过上限,存款上限为:{max_deposit}"
f"当前你的还可以存款金额:{max_deposit - bank_user.amount}"
)
max_daily_deposit_count: int = base_config.get("max_daily_deposit_count")
today_deposit_count = len(await cls.get_user_deposit(user_id))
if today_deposit_count >= max_daily_deposit_count:
return f"存款次数超过上限,每日存款次数上限为:{max_daily_deposit_count}"
return None
@classmethod
async def withdraw_check(cls, user_id: str, amount: int) -> str | None:
"""检查取款是否合法
参数:
user_id: 用户id
amount: 取款金额
返回:
str | None: 取款信息
"""
if amount <= 0:
return "取款数量必须大于 0 啊笨蛋!"
user = await cls.get_user(user_id)
data_list = await cls.get_user_deposit(user_id)
lock_amount = sum(data.amount for data in data_list)
if user.amount - lock_amount < amount:
return (
"取款金额不足,当前你的存款为:"
f"{user.amount}{lock_amount}已被锁定)!"
)
return None
@classmethod
async def get_user_deposit(
cls, user_id: str, is_completed: bool = False
) -> list[MahiroBankLog]:
"""获取用户今日存款次数
参数:
user_id: 用户id
返回:
list[MahiroBankLog]: 存款列表
"""
return await MahiroBankLog.filter(
user_id=user_id,
handle_type=BankHandleType.DEPOSIT,
is_completed=is_completed,
)
@classmethod
async def get_user(cls, user_id: str) -> MahiroBank:
"""查询余额
参数:
user_id: 用户id
返回:
MahiroBank
"""
user, _ = await MahiroBank.get_or_create(user_id=user_id)
return user
@classmethod
async def get_user_data(
cls,
user_id: str,
data_type: BankHandleType,
is_completed: bool = False,
count: int = 5,
) -> list[MahiroBankLog]:
return (
await MahiroBankLog.filter(
user_id=user_id, handle_type=data_type, is_completed=is_completed
)
.order_by("-id")
.limit(count)
.all()
)
@classmethod
async def complete_projected_revenue(cls, user_id: str) -> int:
"""预计收益
参数:
user_id: 用户id
返回:
int: 预计收益金额
"""
deposit_list = await cls.get_user_deposit(user_id)
if not deposit_list:
return 0
return int(
sum(
deposit.rate * deposit.amount * deposit.effective_hour
for deposit in deposit_list
)
)
@classmethod
async def get_user_info(cls, session: Uninfo, uname: str) -> bytes:
"""获取用户数据
参数:
session: Uninfo
uname: 用户id
返回:
bytes: 图片数据
"""
user_id = session.user.id
user = await cls.get_user(user_id=user_id)
(
rank,
deposit_count,
user_today_deposit,
projected_revenue,
sum_data,
) = await asyncio.gather(
*[
MahiroBank.filter(amount__gt=user.amount).count(),
MahiroBankLog.filter(user_id=user_id).count(),
cls.get_user_deposit(user_id),
cls.complete_projected_revenue(user_id),
MahiroBankLog.filter(
user_id=user_id, handle_type=BankHandleType.INTEREST
)
.annotate(sum=Sum("amount"))
.values("sum"),
]
)
now = datetime.now()
end_time = (
now
+ timedelta(days=1)
- timedelta(hours=now.hour, minutes=now.minute, seconds=now.second)
)
today_deposit_amount = sum(deposit.amount for deposit in user_today_deposit)
deposit_list = [
{
"id": deposit.id,
"date": now.date(),
"start_time": str(deposit.create_time).split(".")[0],
"end_time": end_time.replace(microsecond=0),
"amount": deposit.amount,
"rate": f"{deposit.rate * 100:.2f}",
"projected_revenue": int(
deposit.amount * deposit.rate * deposit.effective_hour
)
or 1,
}
for deposit in user_today_deposit
]
platform = PlatformUtils.get_platform(session)
data = {
"name": uname,
"rank": rank + 1,
"avatar_url": PlatformUtils.get_user_avatar_url(
user_id, platform, session.self_id
),
"amount": user.amount,
"deposit_count": deposit_count,
"today_deposit_count": len(user_today_deposit),
"cumulative_gain": sum_data[0]["sum"] or 0,
"projected_revenue": projected_revenue,
"today_deposit_amount": today_deposit_amount,
"deposit_list": deposit_list,
"create_time": now.replace(microsecond=0),
}
return await template_to_pic(
template_path=str((TEMPLATE_PATH / "mahiro_bank").absolute()),
template_name="user.html",
templates={"data": data},
pages={
"viewport": {"width": 386, "height": 700},
"base_url": f"file://{TEMPLATE_PATH}",
},
wait=2,
)
@classmethod
async def get_bank_info(cls) -> bytes:
now = datetime.now()
now_start = now - timedelta(
hours=now.hour, minutes=now.minute, seconds=now.second
)
(
bank_data,
today_count,
interest_amount,
active_user_count,
date_data,
) = await asyncio.gather(
*[
MahiroBank.annotate(
amount_sum=Sum("amount"), user_count=Count("id")
).values("amount_sum", "user_count"),
MahiroBankLog.filter(
create_time__gt=now_start, handle_type=BankHandleType.DEPOSIT
).count(),
MahiroBankLog.filter(handle_type=BankHandleType.INTEREST)
.annotate(amount_sum=Sum("amount"))
.values("amount_sum"),
MahiroBankLog.filter(
create_time__gte=now_start - timedelta(days=7),
handle_type=BankHandleType.DEPOSIT,
)
.annotate(count=Count("user_id", distinct=True))
.values("count"),
MahiroBankLog.filter(
create_time__gte=now_start - timedelta(days=7),
handle_type=BankHandleType.DEPOSIT,
)
.annotate(date=RawSQL("DATE(create_time)"), total_amount=Sum("amount"))
.group_by("date")
.values("date", "total_amount"),
]
)
date2cnt = {str(date["date"]): date["total_amount"] for date in date_data}
date = now.date()
e_date, e_amount = [], []
for _ in range(7):
if str(date) in date2cnt:
e_amount.append(date2cnt[str(date)])
else:
e_amount.append(0)
e_date.append(str(date)[5:])
date -= timedelta(days=1)
e_date.reverse()
e_amount.reverse()
date = 1
lasted_log = await MahiroBankLog.annotate().order_by("create_time").first()
if lasted_log:
date = now.date() - lasted_log.create_time.date()
date = (date.days or 1) + 1
data = {
"amount_sum": bank_data[0]["amount_sum"],
"user_count": bank_data[0]["user_count"],
"today_count": today_count,
"day_amount": int(bank_data[0]["amount_sum"] / date),
"interest_amount": interest_amount[0]["amount_sum"] or 0,
"active_user_count": active_user_count[0]["count"] or 0,
"e_data": e_date,
"e_amount": e_amount,
"create_time": now.replace(microsecond=0),
}
return await template_to_pic(
template_path=str((TEMPLATE_PATH / "mahiro_bank").absolute()),
template_name="bank.html",
templates={"data": data},
pages={
"viewport": {"width": 450, "height": 750},
"base_url": f"file://{TEMPLATE_PATH}",
},
wait=2,
)
@classmethod
async def deposit(
cls, user_id: str, amount: int
) -> tuple[MahiroBank, float, float | None]:
"""存款
参数:
user_id: 用户id
amount: 存款数量
返回:
tuple[MahiroBank, float, float]: MahiroBank利率增加的利率
"""
rate_range = base_config.get("rate_range")
rate = random.uniform(rate_range[0], rate_range[1])
sign_user = await SignUser.get_user(user_id)
random_add_rate = await cls.random_event(float(sign_user.impression))
if random_add_rate:
rate += random_add_rate
await UserConsole.reduce_gold(user_id, amount, GoldHandle.PLUGIN, "bank")
return await MahiroBank.deposit(user_id, amount, rate), rate, random_add_rate
@classmethod
async def withdraw(cls, user_id: str, amount: int) -> MahiroBank:
"""取款
参数:
user_id: 用户id
amount: 取款数量
返回:
MahiroBank
"""
await UserConsole.add_gold(user_id, amount, "bank")
return await MahiroBank.withdraw(user_id, amount)
@classmethod
async def loan(cls, user_id: str, amount: int) -> tuple[MahiroBank, float | None]:
"""贷款
参数:
user_id: 用户id
amount: 贷款数量
返回:
tuple[MahiroBank, float]: MahiroBank贷款利率
"""
rate_range = base_config.get("rate_range")
rate = random.uniform(rate_range[0], rate_range[1])
sign_user = await SignUser.get_user(user_id)
user, _ = await MahiroBank.get_or_create(user_id=user_id)
if user.loan_amount + amount > sign_user.impression * 150:
raise ValueError("贷款数量超过最大限制,请签到提升好感度获取更多额度吧...")
random_reduce_rate = await cls.random_event(float(sign_user.impression))
if random_reduce_rate:
rate -= random_reduce_rate
await UserConsole.add_gold(user_id, amount, "bank")
return await MahiroBank.loan(user_id, amount, rate), random_reduce_rate
@classmethod
async def repayment(cls, user_id: str, amount: int) -> MahiroBank:
"""还款
参数:
user_id: 用户id
amount: 还款数量
返回:
MahiroBank
"""
await UserConsole.reduce_gold(user_id, amount, GoldHandle.PLUGIN, "bank")
return await MahiroBank.repayment(user_id, amount)
@classmethod
async def settlement(cls):
"""结算每日利率"""
bank_user_list = await MahiroBank.filter(amount__gt=0).all()
log_list = await MahiroBankLog.filter(
is_completed=False, handle_type=BankHandleType.DEPOSIT
).all()
user_list = await UserConsole.filter(
user_id__in=[user.user_id for user in bank_user_list]
).all()
user_data = {user.user_id: user for user in user_list}
bank_data: dict[str, list[MahiroBankLog]] = {}
for log in log_list:
if log.user_id not in bank_data:
bank_data[log.user_id] = []
bank_data[log.user_id].append(log)
log_create_list = []
log_update_list = []
# 计算每日默认金币
for bank_user in bank_user_list:
if user := user_data.get(bank_user.user_id):
amount = bank_user.amount
if logs := bank_data.get(bank_user.user_id):
amount -= sum(log.amount for log in logs)
if not amount:
continue
# 计算每日默认金币
gold = int(amount * bank_user.rate)
user.gold += gold
log_create_list.append(
MahiroBankLog(
user_id=bank_user.user_id,
amount=gold,
rate=bank_user.rate,
handle_type=BankHandleType.INTEREST,
is_completed=True,
)
)
# 计算每日存款金币
for user_id, logs in bank_data.items():
if user := user_data.get(user_id):
for log in logs:
gold = int(log.amount * log.rate * log.effective_hour) or 1
user.gold += gold
log.is_completed = True
log_update_list.append(log)
log_create_list.append(
MahiroBankLog(
user_id=user_id,
amount=gold,
rate=log.rate,
handle_type=BankHandleType.INTEREST,
is_completed=True,
)
)
if log_create_list:
await MahiroBankLog.bulk_create(log_create_list, 10)
if log_update_list:
await MahiroBankLog.bulk_update(log_update_list, ["is_completed"], 10)
await UserConsole.bulk_update(user_list, ["gold"], 10)

View File

@ -1,12 +1,17 @@
import random import random
from typing import Any
from nonebot import on_regex
from nonebot.adapters import Bot from nonebot.adapters import Bot
from nonebot.params import Depends, RegexGroup
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
from nonebot.rule import to_me from nonebot.rule import to_me
from nonebot_plugin_alconna import Alconna, Option, on_alconna, store_true from nonebot_plugin_alconna import (
Alconna,
Args,
Arparma,
CommandMeta,
Option,
on_alconna,
store_true,
)
from nonebot_plugin_uninfo import Uninfo from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.config import BotConfig, Config from zhenxun.configs.config import BotConfig, Config
@ -54,15 +59,22 @@ __plugin_meta__ = PluginMetadata(
).to_dict(), ).to_dict(),
) )
_nickname_matcher = on_regex( _nickname_matcher = on_alconna(
"(?:以后)?(?:叫我|请叫我|称呼我)(.*)", Alconna(
"re:(?:以后)?(?:叫我|请叫我|称呼我)",
Args["name?", str],
meta=CommandMeta(compact=True),
),
rule=to_me(), rule=to_me(),
priority=5, priority=5,
block=True, block=True,
) )
_global_nickname_matcher = on_regex( _global_nickname_matcher = on_alconna(
"设置全局昵称(.*)", rule=to_me(), priority=5, block=True Alconna("设置全局昵称", Args["name?", str], meta=CommandMeta(compact=True)),
rule=to_me(),
priority=5,
block=True,
) )
_matcher = on_alconna( _matcher = on_alconna(
@ -117,18 +129,16 @@ CANCEL = [
] ]
def CheckNickname(): async def CheckNickname(
bot: Bot,
session: Uninfo,
params: Arparma,
):
""" """
检查名称是否合法 检查名称是否合法
""" """
async def dependency(
bot: Bot,
session: Uninfo,
reg_group: tuple[Any, ...] = RegexGroup(),
):
black_word = Config.get_config("nickname", "BLACK_WORD") black_word = Config.get_config("nickname", "BLACK_WORD")
(name,) = reg_group name = params.query("name")
logger.debug(f"昵称检查: {name}", "昵称设置", session=session) logger.debug(f"昵称检查: {name}", "昵称设置", session=session)
if not name: if not name:
await MessageUtils.build_message("叫你空白?叫你虚空?叫你无名??").finish( await MessageUtils.build_message("叫你空白?叫你虚空?叫你无名??").finish(
@ -138,13 +148,13 @@ def CheckNickname():
logger.debug( logger.debug(
f"超级用户设置昵称, 跳过合法检测: {name}", "昵称设置", session=session f"超级用户设置昵称, 跳过合法检测: {name}", "昵称设置", session=session
) )
return else:
if len(name) > 20: if len(name) > 20:
await MessageUtils.build_message("昵称可不能超过20个字!").finish( await MessageUtils.build_message("昵称可不能超过20个字!").finish(
at_sender=True at_sender=True
) )
if name in bot.config.nickname: if name in bot.config.nickname:
await MessageUtils.build_message("笨蛋!休想占用我的名字! #").finish( await MessageUtils.build_message("笨蛋!休想占用我的名字! ").finish(
at_sender=True at_sender=True
) )
if black_word: if black_word:
@ -162,17 +172,17 @@ def CheckNickname():
await MessageUtils.build_message( await MessageUtils.build_message(
f"字符 [{word}] 为禁止字符!" f"字符 [{word}] 为禁止字符!"
).finish(at_sender=True) ).finish(at_sender=True)
return name
return Depends(dependency)
@_nickname_matcher.handle(parameterless=[CheckNickname()]) @_nickname_matcher.handle()
async def _( async def _(
bot: Bot,
session: Uninfo, session: Uninfo,
name_: Arparma,
uname: str = UserName(), uname: str = UserName(),
reg_group: tuple[Any, ...] = RegexGroup(),
): ):
(name,) = reg_group name = await CheckNickname(bot, session, name_)
if len(name) < 5 and random.random() < 0.3: if len(name) < 5 and random.random() < 0.3:
name = "~".join(name) name = "~".join(name)
group_id = None group_id = None
@ -200,13 +210,14 @@ async def _(
) )
@_global_nickname_matcher.handle(parameterless=[CheckNickname()]) @_global_nickname_matcher.handle()
async def _( async def _(
bot: Bot,
session: Uninfo, session: Uninfo,
name_: Arparma,
nickname: str = UserName(), nickname: str = UserName(),
reg_group: tuple[Any, ...] = RegexGroup(),
): ):
(name,) = reg_group name = await CheckNickname(bot, session, name_)
await FriendUser.set_user_nickname( await FriendUser.set_user_nickname(
session.user.id, session.user.id,
name, name,
@ -227,15 +238,14 @@ async def _(session: Uninfo, uname: str = UserName()):
group_id = session.group.parent.id if session.group.parent else session.group.id group_id = session.group.parent.id if session.group.parent else session.group.id
if group_id: if group_id:
nickname = await GroupInfoUser.get_user_nickname(session.user.id, group_id) nickname = await GroupInfoUser.get_user_nickname(session.user.id, group_id)
card = uname
else: else:
nickname = await FriendUser.get_user_nickname(session.user.id) nickname = await FriendUser.get_user_nickname(session.user.id)
card = uname
if nickname: if nickname:
await MessageUtils.build_message(random.choice(REMIND).format(nickname)).finish( await MessageUtils.build_message(random.choice(REMIND).format(nickname)).finish(
reply_to=True reply_to=True
) )
else: else:
card = uname
await MessageUtils.build_message( await MessageUtils.build_message(
random.choice( random.choice(
[ [

View File

@ -1,4 +1,4 @@
from nonebot import on_notice, on_request from nonebot import on_notice
from nonebot.adapters import Bot from nonebot.adapters import Bot
from nonebot.adapters.onebot.v11 import ( from nonebot.adapters.onebot.v11 import (
GroupDecreaseNoticeEvent, GroupDecreaseNoticeEvent,
@ -14,9 +14,10 @@ from nonebot_plugin_uninfo import Uninfo
from zhenxun.builtin_plugins.platform.qq.exception import ForceAddGroupError from zhenxun.builtin_plugins.platform.qq.exception import ForceAddGroupError
from zhenxun.configs.config import BotConfig, Config from zhenxun.configs.config import BotConfig, Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig, Task from zhenxun.configs.utils import PluginExtraData, RegisterConfig, Task
from zhenxun.models.event_log import EventLog
from zhenxun.models.group_console import GroupConsole from zhenxun.models.group_console import GroupConsole
from zhenxun.utils.common_utils import CommonUtils from zhenxun.utils.common_utils import CommonUtils
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import EventLogType, PluginType
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
from zhenxun.utils.rules import notice_rule from zhenxun.utils.rules import notice_rule
@ -106,8 +107,6 @@ group_decrease_handle = on_notice(
rule=notice_rule([GroupMemberDecreaseEvent, GroupDecreaseNoticeEvent]), rule=notice_rule([GroupMemberDecreaseEvent, GroupDecreaseNoticeEvent]),
) )
"""群员减少处理""" """群员减少处理"""
add_group = on_request(priority=1, block=False)
"""加群同意请求"""
@group_increase_handle.handle() @group_increase_handle.handle()
@ -141,8 +140,21 @@ async def _(
group_id = str(event.group_id) group_id = str(event.group_id)
if event.sub_type == "kick_me": if event.sub_type == "kick_me":
"""踢出Bot""" """踢出Bot"""
await GroupManager.kick_bot(bot, user_id, group_id) await GroupManager.kick_bot(bot, group_id, str(event.operator_id))
await EventLog.create(
user_id=user_id, group_id=group_id, event_type=EventLogType.KICK_BOT
)
elif event.sub_type in ["leave", "kick"]: elif event.sub_type in ["leave", "kick"]:
if event.sub_type == "leave":
"""主动退群"""
await EventLog.create(
user_id=user_id, group_id=group_id, event_type=EventLogType.LEAVE_MEMBER
)
else:
"""被踢出群"""
await EventLog.create(
user_id=user_id, group_id=group_id, event_type=EventLogType.KICK_MEMBER
)
result = await GroupManager.run_user( result = await GroupManager.run_user(
bot, user_id, group_id, str(event.operator_id), event.sub_type bot, user_id, group_id, str(event.operator_id), event.sub_type
) )

View File

@ -0,0 +1,100 @@
import asyncio
from datetime import datetime
import random
from nonebot.adapters import Bot
from nonebot.plugin import PluginMetadata
from nonebot.rule import to_me
from nonebot_plugin_alconna import Alconna, Args, Arparma, Field, on_alconna
from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.utils import PluginCdBlock, PluginExtraData
from zhenxun.models.fg_request import FgRequest
from zhenxun.services.log import logger
from zhenxun.utils.depends import UserName
from zhenxun.utils.enum import RequestHandleType, RequestType
from zhenxun.utils.platform import PlatformUtils
__plugin_meta__ = PluginMetadata(
name="群组申请",
description="""
一些小群直接邀请入群导致无法正常生成审核请求需要用该方法手动生成审核请求
当管理员同意同意时会发送消息进行提示之后再进行拉群不会退出
该消息会发送至管理员多次发送不存在的群组id或相同群组id可能导致ban
""".strip(),
usage="""
指令
申请入群 [群号]
示例: 申请入群 123123123
""".strip(),
extra=PluginExtraData(
author="HibiKier",
version="0.1",
menu_type="其他",
limits=[PluginCdBlock(cd=300, result="每5分钟只能申请一次哦~")],
).to_dict(),
)
_matcher = on_alconna(
Alconna(
"申请入群",
Args[
"group_id",
int,
Field(
missing_tips=lambda: "请在命令后跟随群组id",
unmatch_tips=lambda _: "群组id必须为数字",
),
],
),
skip_for_unmatch=False,
priority=5,
block=True,
rule=to_me(),
)
@_matcher.handle()
async def _(
bot: Bot, session: Uninfo, arparma: Arparma, group_id: int, uname: str = UserName()
):
# 旧请求全部设置为过期
await FgRequest.filter(
request_type=RequestType.GROUP,
user_id=session.user.id,
group_id=str(group_id),
handle_type__isnull=True,
).update(handle_type=RequestHandleType.EXPIRE)
f = await FgRequest.create(
request_type=RequestType.GROUP,
platform=PlatformUtils.get_platform(session),
bot_id=bot.self_id,
flag="0",
user_id=session.user.id,
nickname=uname,
group_id=str(group_id),
)
results = await PlatformUtils.send_superuser(
bot,
f"*****一份入群申请*****\n"
f"ID{f.id}\n"
f"申请人:{uname}({session.user.id})\n群聊:"
f"{group_id}\n邀请日期:{datetime.now().replace(microsecond=0)}\n"
"注:该请求为手动申请入群",
)
if message_ids := [
str(r[1].msg_ids[0]["message_id"]) for r in results if r[1] and r[1].msg_ids
]:
f.message_ids = ",".join(message_ids)
await f.save(update_fields=["message_ids"])
await asyncio.sleep(random.randint(1, 5))
await bot.send_private_msg(
user_id=int(session.user.id),
message=f"已发送申请请等待管理员审核ID{f.id}",
)
logger.info(
f"用户 {uname}({session.user.id}) 申请入群 {group_id}ID{f.id}",
arparma.header_result,
session=session,
)

View File

@ -9,7 +9,7 @@ from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils from zhenxun.utils.message import MessageUtils
from zhenxun.utils.utils import is_number from zhenxun.utils.utils import is_number
from .data_source import ShopManage from .data_source import StoreManager
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
name="插件商店", name="插件商店",
@ -82,7 +82,7 @@ _matcher.shortcut(
@_matcher.assign("$main") @_matcher.assign("$main")
async def _(session: EventSession): async def _(session: EventSession):
try: try:
result = await ShopManage.get_plugins_info() result = await StoreManager.get_plugins_info()
logger.info("查看插件列表", "插件商店", session=session) logger.info("查看插件列表", "插件商店", session=session)
await MessageUtils.build_message(result).send() await MessageUtils.build_message(result).send()
except Exception as e: except Exception as e:
@ -97,7 +97,7 @@ async def _(session: EventSession, plugin_id: str):
await MessageUtils.build_message(f"正在添加插件 Id: {plugin_id}").send() await MessageUtils.build_message(f"正在添加插件 Id: {plugin_id}").send()
else: else:
await MessageUtils.build_message(f"正在添加插件 Module: {plugin_id}").send() await MessageUtils.build_message(f"正在添加插件 Module: {plugin_id}").send()
result = await ShopManage.add_plugin(plugin_id) result = await StoreManager.add_plugin(plugin_id)
except Exception as e: except Exception as e:
logger.error(f"添加插件 Id: {plugin_id}失败", "插件商店", session=session, e=e) logger.error(f"添加插件 Id: {plugin_id}失败", "插件商店", session=session, e=e)
await MessageUtils.build_message( await MessageUtils.build_message(
@ -110,7 +110,7 @@ async def _(session: EventSession, plugin_id: str):
@_matcher.assign("remove") @_matcher.assign("remove")
async def _(session: EventSession, plugin_id: str): async def _(session: EventSession, plugin_id: str):
try: try:
result = await ShopManage.remove_plugin(plugin_id) result = await StoreManager.remove_plugin(plugin_id)
except Exception as e: except Exception as e:
logger.error(f"移除插件 Id: {plugin_id}失败", "插件商店", session=session, e=e) logger.error(f"移除插件 Id: {plugin_id}失败", "插件商店", session=session, e=e)
await MessageUtils.build_message( await MessageUtils.build_message(
@ -123,7 +123,7 @@ async def _(session: EventSession, plugin_id: str):
@_matcher.assign("search") @_matcher.assign("search")
async def _(session: EventSession, plugin_name_or_author: str): async def _(session: EventSession, plugin_name_or_author: str):
try: try:
result = await ShopManage.search_plugin(plugin_name_or_author) result = await StoreManager.search_plugin(plugin_name_or_author)
except Exception as e: except Exception as e:
logger.error( logger.error(
f"搜索插件 name: {plugin_name_or_author}失败", f"搜索插件 name: {plugin_name_or_author}失败",
@ -145,7 +145,7 @@ async def _(session: EventSession, plugin_id: str):
await MessageUtils.build_message(f"正在更新插件 Id: {plugin_id}").send() await MessageUtils.build_message(f"正在更新插件 Id: {plugin_id}").send()
else: else:
await MessageUtils.build_message(f"正在更新插件 Module: {plugin_id}").send() await MessageUtils.build_message(f"正在更新插件 Module: {plugin_id}").send()
result = await ShopManage.update_plugin(plugin_id) result = await StoreManager.update_plugin(plugin_id)
except Exception as e: except Exception as e:
logger.error(f"更新插件 Id: {plugin_id}失败", "插件商店", session=session, e=e) logger.error(f"更新插件 Id: {plugin_id}失败", "插件商店", session=session, e=e)
await MessageUtils.build_message( await MessageUtils.build_message(
@ -159,7 +159,7 @@ async def _(session: EventSession, plugin_id: str):
async def _(session: EventSession): async def _(session: EventSession):
try: try:
await MessageUtils.build_message("正在更新全部插件").send() await MessageUtils.build_message("正在更新全部插件").send()
result = await ShopManage.update_all_plugin() result = await StoreManager.update_all_plugin()
except Exception as e: except Exception as e:
logger.error("更新全部插件失败", "插件商店", session=session, e=e) logger.error("更新全部插件失败", "插件商店", session=session, e=e)
await MessageUtils.build_message(f"更新全部插件失败 e: {e}").finish() await MessageUtils.build_message(f"更新全部插件失败 e: {e}").finish()

View File

@ -9,3 +9,5 @@ DEFAULT_GITHUB_URL = "https://github.com/zhenxun-org/zhenxun_bot_plugins/tree/ma
EXTRA_GITHUB_URL = "https://github.com/zhenxun-org/zhenxun_bot_plugins_index/tree/index" EXTRA_GITHUB_URL = "https://github.com/zhenxun-org/zhenxun_bot_plugins_index/tree/index"
"""插件库索引github仓库地址""" """插件库索引github仓库地址"""
LOG_COMMAND = "插件商店"

View File

@ -1,6 +1,5 @@
from pathlib import Path from pathlib import Path
import shutil import shutil
import subprocess
from aiocache import cached from aiocache import cached
import ujson as json import ujson as json
@ -14,9 +13,15 @@ from zhenxun.utils.github_utils import GithubUtils
from zhenxun.utils.github_utils.models import RepoAPI from zhenxun.utils.github_utils.models import RepoAPI
from zhenxun.utils.http_utils import AsyncHttpx from zhenxun.utils.http_utils import AsyncHttpx
from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
from zhenxun.utils.utils import is_number from zhenxun.utils.utils import is_number
from .config import BASE_PATH, DEFAULT_GITHUB_URL, EXTRA_GITHUB_URL from .config import (
BASE_PATH,
DEFAULT_GITHUB_URL,
EXTRA_GITHUB_URL,
LOG_COMMAND,
)
def row_style(column: str, text: str) -> RowStyle: def row_style(column: str, text: str) -> RowStyle:
@ -39,72 +44,69 @@ def install_requirement(plugin_path: Path):
requirement_files = ["requirement.txt", "requirements.txt"] requirement_files = ["requirement.txt", "requirements.txt"]
requirement_paths = [plugin_path / file for file in requirement_files] requirement_paths = [plugin_path / file for file in requirement_files]
existing_requirements = next( if existing_requirements := next(
(path for path in requirement_paths if path.exists()), None (path for path in requirement_paths if path.exists()), None
) ):
VirtualEnvPackageManager.install_requirement(existing_requirements)
if not existing_requirements:
logger.debug(
f"No requirement.txt found for plugin: {plugin_path.name}", "插件管理"
)
return
try:
result = subprocess.run(
["poetry", "run", "pip", "install", "-r", str(existing_requirements)],
check=True,
capture_output=True,
text=True,
)
logger.debug(
"Successfully installed dependencies for"
f" plugin: {plugin_path.name}. Output:\n{result.stdout}",
"插件管理",
)
except subprocess.CalledProcessError:
logger.error(
f"Failed to install dependencies for plugin: {plugin_path.name}. "
" Error:\n{e.stderr}"
)
class ShopManage: class StoreManager:
@classmethod @classmethod
@cached(60) async def get_github_plugins(cls) -> list[StorePluginInfo]:
async def get_data(cls) -> dict[str, StorePluginInfo]: """获取github插件列表信息
"""获取插件信息数据
异常:
ValueError: 访问请求失败
返回: 返回:
dict: 插件信息数据 list[StorePluginInfo]: 插件列表数据
""" """
default_github_repo = GithubUtils.parse_github_url(DEFAULT_GITHUB_URL) repo_info = GithubUtils.parse_github_url(DEFAULT_GITHUB_URL)
extra_github_repo = GithubUtils.parse_github_url(EXTRA_GITHUB_URL)
for repo_info in [default_github_repo, extra_github_repo]:
if await repo_info.update_repo_commit(): if await repo_info.update_repo_commit():
logger.info(f"获取最新提交: {repo_info.branch}", "插件管理") logger.info(f"获取最新提交: {repo_info.branch}", LOG_COMMAND)
else: else:
logger.warning(f"获取最新提交失败: {repo_info}", "插件管理") logger.warning(f"获取最新提交失败: {repo_info}", LOG_COMMAND)
default_github_url = await default_github_repo.get_raw_download_urls( default_github_url = await repo_info.get_raw_download_urls("plugins.json")
"plugins.json" response = await AsyncHttpx.get(default_github_url, check_status_code=200)
if response.status_code == 200:
logger.info("获取github插件列表成功", LOG_COMMAND)
return [StorePluginInfo(**detail) for detail in json.loads(response.text)]
else:
logger.warning(
f"获取github插件列表失败: {response.status_code}", LOG_COMMAND
) )
extra_github_url = await extra_github_repo.get_raw_download_urls("plugins.json") return []
res = await AsyncHttpx.get(default_github_url)
res2 = await AsyncHttpx.get(extra_github_url)
# 检查请求结果 @classmethod
if res.status_code != 200 or res2.status_code != 200: async def get_extra_plugins(cls) -> list[StorePluginInfo]:
raise ValueError(f"下载错误, code: {res.status_code}, {res2.status_code}") """获取额外插件列表信息
# 解析并合并返回的 JSON 数据 返回:
data1 = json.loads(res.text) list[StorePluginInfo]: 插件列表数据
data2 = json.loads(res2.text) """
return { repo_info = GithubUtils.parse_github_url(EXTRA_GITHUB_URL)
name: StorePluginInfo(**detail) if await repo_info.update_repo_commit():
for name, detail in {**data1, **data2}.items() logger.info(f"获取最新提交: {repo_info.branch}", LOG_COMMAND)
} else:
logger.warning(f"获取最新提交失败: {repo_info}", LOG_COMMAND)
extra_github_url = await repo_info.get_raw_download_urls("plugins.json")
response = await AsyncHttpx.get(extra_github_url, check_status_code=200)
if response.status_code == 200:
return [StorePluginInfo(**detail) for detail in json.loads(response.text)]
else:
logger.warning(
f"获取github扩展插件列表失败: {response.status_code}", LOG_COMMAND
)
return []
@classmethod
@cached(60)
async def get_data(cls) -> list[StorePluginInfo]:
"""获取插件信息数据
返回:
list[StorePluginInfo]: 插件信息数据
"""
plugins = await cls.get_github_plugins()
extra_plugins = await cls.get_extra_plugins()
return [*plugins, *extra_plugins]
@classmethod @classmethod
def version_check(cls, plugin_info: StorePluginInfo, suc_plugin: dict[str, str]): def version_check(cls, plugin_info: StorePluginInfo, suc_plugin: dict[str, str]):
@ -112,7 +114,7 @@ class ShopManage:
参数: 参数:
plugin_info: StorePluginInfo plugin_info: StorePluginInfo
suc_plugin: dict[str, str] suc_plugin: 模块名: 版本号
返回: 返回:
str: 版本号 str: 版本号
@ -132,7 +134,7 @@ class ShopManage:
参数: 参数:
plugin_info: StorePluginInfo plugin_info: StorePluginInfo
suc_plugin: dict[str, str] suc_plugin: 模块名: 版本号
返回: 返回:
bool: 是否有更新 bool: 是否有更新
@ -156,21 +158,21 @@ class ShopManage:
返回: 返回:
BuildImage | str: 返回消息 BuildImage | str: 返回消息
""" """
data: dict[str, StorePluginInfo] = await cls.get_data() plugin_list: list[StorePluginInfo] = await cls.get_data()
column_name = ["-", "ID", "名称", "简介", "作者", "版本", "类型"] column_name = ["-", "ID", "名称", "简介", "作者", "版本", "类型"]
plugin_list = await cls.get_loaded_plugins("module", "version") db_plugin_list = await cls.get_loaded_plugins("module", "version")
suc_plugin = {p[0]: (p[1] or "0.1") for p in plugin_list} suc_plugin = {p[0]: (p[1] or "0.1") for p in db_plugin_list}
data_list = [ data_list = [
[ [
"已安装" if plugin_info[1].module in suc_plugin else "", "已安装" if plugin_info.module in suc_plugin else "",
id, id,
plugin_info[0], plugin_info.name,
plugin_info[1].description, plugin_info.description,
plugin_info[1].author, plugin_info.author,
cls.version_check(plugin_info[1], suc_plugin), cls.version_check(plugin_info, suc_plugin),
plugin_info[1].plugin_type_name, plugin_info.plugin_type_name,
] ]
for id, plugin_info in enumerate(data.items()) for id, plugin_info in enumerate(plugin_list)
] ]
return await ImageTemplate.table_page( return await ImageTemplate.table_page(
"插件列表", "插件列表",
@ -190,15 +192,15 @@ class ShopManage:
返回: 返回:
str: 返回消息 str: 返回消息
""" """
data: dict[str, StorePluginInfo] = await cls.get_data() plugin_list: list[StorePluginInfo] = await cls.get_data()
try: try:
plugin_key = await cls._resolve_plugin_key(plugin_id) plugin_key = await cls._resolve_plugin_key(plugin_id)
except ValueError as e: except ValueError as e:
return str(e) return str(e)
plugin_list = await cls.get_loaded_plugins("module") db_plugin_list = await cls.get_loaded_plugins("module")
plugin_info = data[plugin_key] plugin_info = next(p for p in plugin_list if p.module == plugin_key)
if plugin_info.module in [p[0] for p in plugin_list]: if plugin_info.module in [p[0] for p in db_plugin_list]:
return f"插件 {plugin_key} 已安装,无需重复安装" return f"插件 {plugin_info.name} 已安装,无需重复安装"
is_external = True is_external = True
if plugin_info.github_url is None: if plugin_info.github_url is None:
plugin_info.github_url = DEFAULT_GITHUB_URL plugin_info.github_url = DEFAULT_GITHUB_URL
@ -207,34 +209,39 @@ class ShopManage:
if len(version_split) > 1: if len(version_split) > 1:
github_url_split = plugin_info.github_url.split("/tree/") github_url_split = plugin_info.github_url.split("/tree/")
plugin_info.github_url = f"{github_url_split[0]}/tree/{version_split[1]}" plugin_info.github_url = f"{github_url_split[0]}/tree/{version_split[1]}"
logger.info(f"正在安装插件 {plugin_key}...") logger.info(f"正在安装插件 {plugin_info.name}...", LOG_COMMAND)
await cls.install_plugin_with_repo( await cls.install_plugin_with_repo(
plugin_info.github_url, plugin_info.github_url,
plugin_info.module_path, plugin_info.module_path,
plugin_info.is_dir, plugin_info.is_dir,
is_external, is_external,
) )
return f"插件 {plugin_key} 安装成功! 重启后生效" return f"插件 {plugin_info.name} 安装成功! 重启后生效"
@classmethod @classmethod
async def install_plugin_with_repo( async def install_plugin_with_repo(
cls, github_url: str, module_path: str, is_dir: bool, is_external: bool = False cls,
github_url: str,
module_path: str,
is_dir: bool,
is_external: bool = False,
): ):
files: list[str]
repo_api: RepoAPI repo_api: RepoAPI
repo_info = GithubUtils.parse_github_url(github_url) repo_info = GithubUtils.parse_github_url(github_url)
if await repo_info.update_repo_commit(): if await repo_info.update_repo_commit():
logger.info(f"获取最新提交: {repo_info.branch}", "插件管理") logger.info(f"获取最新提交: {repo_info.branch}", LOG_COMMAND)
else: else:
logger.warning(f"获取最新提交失败: {repo_info}", "插件管理") logger.warning(f"获取最新提交失败: {repo_info}", LOG_COMMAND)
logger.debug(f"成功获取仓库信息: {repo_info}", "插件管理") logger.debug(f"成功获取仓库信息: {repo_info}", LOG_COMMAND)
for repo_api in GithubUtils.iter_api_strategies(): for repo_api in GithubUtils.iter_api_strategies():
try: try:
await repo_api.parse_repo_info(repo_info) await repo_api.parse_repo_info(repo_info)
break break
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"获取插件文件失败: {e} | API类型: {repo_api.strategy}", "插件管理" f"获取插件文件失败 | API类型: {repo_api.strategy}",
LOG_COMMAND,
e=e,
) )
continue continue
else: else:
@ -250,7 +257,7 @@ class ShopManage:
base_path = BASE_PATH / "plugins" if is_external else BASE_PATH base_path = BASE_PATH / "plugins" if is_external else BASE_PATH
base_path = base_path if module_path else base_path / repo_info.repo base_path = base_path if module_path else base_path / repo_info.repo
download_paths: list[Path | str] = [base_path / file for file in files] download_paths: list[Path | str] = [base_path / file for file in files]
logger.debug(f"插件下载路径: {download_paths}", "插件管理") logger.debug(f"插件下载路径: {download_paths}", LOG_COMMAND)
result = await AsyncHttpx.gather_download_file(download_urls, download_paths) result = await AsyncHttpx.gather_download_file(download_urls, download_paths)
for _id, success in enumerate(result): for _id, success in enumerate(result):
if not success: if not success:
@ -265,12 +272,12 @@ class ShopManage:
req_files.extend( req_files.extend(
repo_api.get_files(f"{replace_module_path}/requirement.txt", False) repo_api.get_files(f"{replace_module_path}/requirement.txt", False)
) )
logger.debug(f"获取插件依赖文件列表: {req_files}", "插件管理") logger.debug(f"获取插件依赖文件列表: {req_files}", LOG_COMMAND)
req_download_urls = [ req_download_urls = [
await repo_info.get_raw_download_urls(file) for file in req_files 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] req_paths: list[Path | str] = [plugin_path / file for file in req_files]
logger.debug(f"插件依赖文件下载路径: {req_paths}", "插件管理") logger.debug(f"插件依赖文件下载路径: {req_paths}", LOG_COMMAND)
if req_files: if req_files:
result = await AsyncHttpx.gather_download_file( result = await AsyncHttpx.gather_download_file(
req_download_urls, req_paths req_download_urls, req_paths
@ -278,7 +285,7 @@ class ShopManage:
for success in result: for success in result:
if not success: if not success:
raise Exception("插件依赖文件下载失败") raise Exception("插件依赖文件下载失败")
logger.debug(f"插件依赖文件列表: {req_paths}", "插件管理") logger.debug(f"插件依赖文件列表: {req_paths}", LOG_COMMAND)
install_requirement(plugin_path) install_requirement(plugin_path)
except ValueError as e: except ValueError as e:
logger.warning("未获取到依赖文件路径...", e=e) logger.warning("未获取到依赖文件路径...", e=e)
@ -295,12 +302,12 @@ class ShopManage:
返回: 返回:
str: 返回消息 str: 返回消息
""" """
data: dict[str, StorePluginInfo] = await cls.get_data() plugin_list: list[StorePluginInfo] = await cls.get_data()
try: try:
plugin_key = await cls._resolve_plugin_key(plugin_id) plugin_key = await cls._resolve_plugin_key(plugin_id)
except ValueError as e: except ValueError as e:
return str(e) return str(e)
plugin_info = data[plugin_key] plugin_info = next(p for p in plugin_list if p.module == plugin_key)
path = BASE_PATH path = BASE_PATH
if plugin_info.github_url: if plugin_info.github_url:
path = BASE_PATH / "plugins" path = BASE_PATH / "plugins"
@ -309,14 +316,14 @@ class ShopManage:
if not plugin_info.is_dir: if not plugin_info.is_dir:
path = Path(f"{path}.py") path = Path(f"{path}.py")
if not path.exists(): if not path.exists():
return f"插件 {plugin_key} 不存在..." return f"插件 {plugin_info.name} 不存在..."
logger.debug(f"尝试移除插件 {plugin_key} 文件: {path}", "插件管理") logger.debug(f"尝试移除插件 {plugin_info.name} 文件: {path}", LOG_COMMAND)
if plugin_info.is_dir: if plugin_info.is_dir:
shutil.rmtree(path) shutil.rmtree(path)
else: else:
path.unlink() path.unlink()
await PluginInitManager.remove(f"zhenxun.{plugin_info.module_path}") await PluginInitManager.remove(f"zhenxun.{plugin_info.module_path}")
return f"插件 {plugin_key} 移除成功! 重启后生效" return f"插件 {plugin_info.name} 移除成功! 重启后生效"
@classmethod @classmethod
async def search_plugin(cls, plugin_name_or_author: str) -> BuildImage | str: async def search_plugin(cls, plugin_name_or_author: str) -> BuildImage | str:
@ -328,25 +335,25 @@ class ShopManage:
返回: 返回:
BuildImage | str: 返回消息 BuildImage | str: 返回消息
""" """
data: dict[str, StorePluginInfo] = await cls.get_data() plugin_list: list[StorePluginInfo] = await cls.get_data()
plugin_list = await cls.get_loaded_plugins("module", "version") db_plugin_list = await cls.get_loaded_plugins("module", "version")
suc_plugin = {p[0]: (p[1] or "Unknown") for p in plugin_list} suc_plugin = {p[0]: (p[1] or "Unknown") for p in db_plugin_list}
filtered_data = [ filtered_data = [
(id, plugin_info) (id, plugin_info)
for id, plugin_info in enumerate(data.items()) for id, plugin_info in enumerate(plugin_list)
if plugin_name_or_author.lower() in plugin_info[0].lower() if plugin_name_or_author.lower() in plugin_info.name.lower()
or plugin_name_or_author.lower() in plugin_info[1].author.lower() or plugin_name_or_author.lower() in plugin_info.author.lower()
] ]
data_list = [ data_list = [
[ [
"已安装" if plugin_info[1].module in suc_plugin else "", "已安装" if plugin_info.module in suc_plugin else "",
id, id,
plugin_info[0], plugin_info.name,
plugin_info[1].description, plugin_info.description,
plugin_info[1].author, plugin_info.author,
cls.version_check(plugin_info[1], suc_plugin), cls.version_check(plugin_info, suc_plugin),
plugin_info[1].plugin_type_name, plugin_info.plugin_type_name,
] ]
for id, plugin_info in filtered_data for id, plugin_info in filtered_data
] ]
@ -354,7 +361,7 @@ class ShopManage:
return "未找到相关插件..." return "未找到相关插件..."
column_name = ["-", "ID", "名称", "简介", "作者", "版本", "类型"] column_name = ["-", "ID", "名称", "简介", "作者", "版本", "类型"]
return await ImageTemplate.table_page( return await ImageTemplate.table_page(
"插件列表", "商店插件列表",
"通过添加/移除插件 ID 来管理插件", "通过添加/移除插件 ID 来管理插件",
column_name, column_name,
data_list, data_list,
@ -371,20 +378,20 @@ class ShopManage:
返回: 返回:
str: 返回消息 str: 返回消息
""" """
data: dict[str, StorePluginInfo] = await cls.get_data() plugin_list: list[StorePluginInfo] = await cls.get_data()
try: try:
plugin_key = await cls._resolve_plugin_key(plugin_id) plugin_key = await cls._resolve_plugin_key(plugin_id)
except ValueError as e: except ValueError as e:
return str(e) return str(e)
logger.info(f"尝试更新插件 {plugin_key}", "插件管理") plugin_info = next(p for p in plugin_list if p.module == plugin_key)
plugin_info = data[plugin_key] logger.info(f"尝试更新插件 {plugin_info.name}", LOG_COMMAND)
plugin_list = await cls.get_loaded_plugins("module", "version") db_plugin_list = await cls.get_loaded_plugins("module", "version")
suc_plugin = {p[0]: (p[1] or "Unknown") for p in plugin_list} suc_plugin = {p[0]: (p[1] or "Unknown") for p in db_plugin_list}
if plugin_info.module not in [p[0] for p in plugin_list]: if plugin_info.module not in [p[0] for p in db_plugin_list]:
return f"插件 {plugin_key} 未安装,无法更新" return f"插件 {plugin_info.name} 未安装,无法更新"
logger.debug(f"当前插件列表: {suc_plugin}", "插件管理") logger.debug(f"当前插件列表: {suc_plugin}", LOG_COMMAND)
if cls.check_version_is_new(plugin_info, suc_plugin): if cls.check_version_is_new(plugin_info, suc_plugin):
return f"插件 {plugin_key} 已是最新版本" return f"插件 {plugin_info.name} 已是最新版本"
is_external = True is_external = True
if plugin_info.github_url is None: if plugin_info.github_url is None:
plugin_info.github_url = DEFAULT_GITHUB_URL plugin_info.github_url = DEFAULT_GITHUB_URL
@ -395,7 +402,7 @@ class ShopManage:
plugin_info.is_dir, plugin_info.is_dir,
is_external, is_external,
) )
return f"插件 {plugin_key} 更新成功! 重启后生效" return f"插件 {plugin_info.name} 更新成功! 重启后生效"
@classmethod @classmethod
async def update_all_plugin(cls) -> str: async def update_all_plugin(cls) -> str:
@ -407,24 +414,33 @@ class ShopManage:
返回: 返回:
str: 返回消息 str: 返回消息
""" """
data: dict[str, StorePluginInfo] = await cls.get_data() plugin_list: list[StorePluginInfo] = await cls.get_data()
plugin_list = list(data.keys()) plugin_name_list = [p.name for p in plugin_list]
update_failed_list = [] update_failed_list = []
update_success_list = [] update_success_list = []
result = "--已更新{}个插件 {}个失败 {}个成功--" result = "--已更新{}个插件 {}个失败 {}个成功--"
logger.info(f"尝试更新全部插件 {plugin_list}", "插件管理") logger.info(f"尝试更新全部插件 {plugin_name_list}", LOG_COMMAND)
for plugin_key in plugin_list: for plugin_info in plugin_list:
try: try:
plugin_info = data[plugin_key] db_plugin_list = await cls.get_loaded_plugins("module", "version")
plugin_list = await cls.get_loaded_plugins("module", "version") suc_plugin = {p[0]: (p[1] or "Unknown") for p in db_plugin_list}
suc_plugin = {p[0]: (p[1] or "Unknown") for p in plugin_list} if plugin_info.module not in [p[0] for p in db_plugin_list]:
if plugin_info.module not in [p[0] for p in plugin_list]: logger.debug(
logger.debug(f"插件 {plugin_key} 未安装,跳过", "插件管理") f"插件 {plugin_info.name}({plugin_info.module}) 未安装,跳过",
LOG_COMMAND,
)
continue continue
if cls.check_version_is_new(plugin_info, suc_plugin): if cls.check_version_is_new(plugin_info, suc_plugin):
logger.debug(f"插件 {plugin_key} 已是最新版本,跳过", "插件管理") logger.debug(
f"插件 {plugin_info.name}({plugin_info.module}) "
"已是最新版本,跳过",
LOG_COMMAND,
)
continue continue
logger.info(f"正在更新插件 {plugin_key}", "插件管理") logger.info(
f"正在更新插件 {plugin_info.name}({plugin_info.module})",
LOG_COMMAND,
)
is_external = True is_external = True
if plugin_info.github_url is None: if plugin_info.github_url is None:
plugin_info.github_url = DEFAULT_GITHUB_URL plugin_info.github_url = DEFAULT_GITHUB_URL
@ -435,10 +451,14 @@ class ShopManage:
plugin_info.is_dir, plugin_info.is_dir,
is_external, is_external,
) )
update_success_list.append(plugin_key) update_success_list.append(plugin_info.name)
except Exception as e: except Exception as e:
logger.error(f"更新插件 {plugin_key} 失败: {e}", "插件管理") logger.error(
update_failed_list.append(plugin_key) f"更新插件 {plugin_info.name}({plugin_info.module}) 失败",
LOG_COMMAND,
e=e,
)
update_failed_list.append(plugin_info.name)
if not update_success_list and not update_failed_list: if not update_success_list and not update_failed_list:
return "全部插件已是最新版本" return "全部插件已是最新版本"
if update_success_list: if update_success_list:
@ -460,13 +480,28 @@ class ShopManage:
@classmethod @classmethod
async def _resolve_plugin_key(cls, plugin_id: str) -> str: async def _resolve_plugin_key(cls, plugin_id: str) -> str:
data: dict[str, StorePluginInfo] = await cls.get_data() """获取插件module
参数:
plugin_id: moduleid或插件名称
异常:
ValueError: 插件不存在
ValueError: 插件不存在
返回:
str: 插件模块名
"""
plugin_list: list[StorePluginInfo] = await cls.get_data()
if is_number(plugin_id): if is_number(plugin_id):
idx = int(plugin_id) idx = int(plugin_id)
if idx < 0 or idx >= len(data): if idx < 0 or idx >= len(plugin_list):
raise ValueError("插件ID不存在...") raise ValueError("插件ID不存在...")
return list(data.keys())[idx] return plugin_list[idx].module
elif isinstance(plugin_id, str): elif isinstance(plugin_id, str):
if plugin_id not in [v.module for k, v in data.items()]: result = (
raise ValueError("插件Module不存在...") None if plugin_id not in [v.module for v in plugin_list] else plugin_id
return {v.module: k for k, v in data.items()}[plugin_id] ) or next(v for v in plugin_list if v.name == plugin_id).module
if not result:
raise ValueError("插件 Module / 名称 不存在...")
return result

View File

@ -1,3 +1,5 @@
from typing import Any, Literal
from nonebot.compat import model_dump from nonebot.compat import model_dump
from pydantic import BaseModel from pydantic import BaseModel
@ -13,9 +15,30 @@ type2name: dict[str, str] = {
} }
class GiteeContents(BaseModel):
"""Gitee Api内容"""
type: Literal["file", "dir"]
"""类型"""
size: Any
"""文件大小"""
name: str
"""文件名"""
path: str
"""文件路径"""
url: str
"""文件链接"""
html_url: str
"""文件html链接"""
download_url: str
"""文件raw链接"""
class StorePluginInfo(BaseModel): class StorePluginInfo(BaseModel):
"""插件信息""" """插件信息"""
name: str
"""插件名"""
module: str module: str
"""模块名""" """模块名"""
module_path: str module_path: str

View File

@ -17,11 +17,12 @@ from nonebot_plugin_session import EventSession
from zhenxun.configs.config import BotConfig, Config from zhenxun.configs.config import BotConfig, Config
from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.models.event_log import EventLog
from zhenxun.models.fg_request import FgRequest from zhenxun.models.fg_request import FgRequest
from zhenxun.models.friend_user import FriendUser from zhenxun.models.friend_user import FriendUser
from zhenxun.models.group_console import GroupConsole from zhenxun.models.group_console import GroupConsole
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType, RequestHandleType, RequestType from zhenxun.utils.enum import EventLogType, PluginType, RequestHandleType, RequestType
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
base_config = Config.get("invite_manager") base_config = Config.get("invite_manager")
@ -112,21 +113,29 @@ async def _(bot: v12Bot | v11Bot, event: FriendRequestEvent, session: EventSessi
nickname=nickname, nickname=nickname,
comment=comment, comment=comment,
) )
await PlatformUtils.send_superuser( results = await PlatformUtils.send_superuser(
bot, bot,
f"*****一份好友申请*****\n" f"*****一份好友申请*****\n"
f"ID: {f.id}\n" f"ID: {f.id}\n"
f"昵称:{nickname}({event.user_id})\n" f"昵称:{nickname}({event.user_id})\n"
f"自动同意:{'' if base_config.get('AUTO_ADD_FRIEND') else '×'}\n" f"自动同意:{'' if base_config.get('AUTO_ADD_FRIEND') else '×'}\n"
f"日期:{str(datetime.now()).split('.')[0]}\n" f"日期:{datetime.now().replace(microsecond=0)}\n"
f"备注:{event.comment}", f"备注:{event.comment}",
) )
if message_ids := [
str(r[1].msg_ids[0]["message_id"])
for r in results
if r[1] and r[1].msg_ids
]:
f.message_ids = ",".join(message_ids)
await f.save(update_fields=["message_ids"])
else: else:
logger.debug("好友请求五分钟内重复, 已忽略", "好友请求", target=event.user_id) logger.debug("好友请求五分钟内重复, 已忽略", "好友请求", target=event.user_id)
@group_req.handle() @group_req.handle()
async def _(bot: v12Bot | v11Bot, event: GroupRequestEvent, session: EventSession): async def _(bot: v12Bot | v11Bot, event: GroupRequestEvent, session: EventSession):
# sourcery skip: low-code-quality
if event.sub_type != "invite": if event.sub_type != "invite":
return return
if str(event.user_id) in bot.config.superusers or base_config.get("AUTO_ADD_GROUP"): if str(event.user_id) in bot.config.superusers or base_config.get("AUTO_ADD_GROUP"):
@ -186,7 +195,7 @@ async def _(bot: v12Bot | v11Bot, event: GroupRequestEvent, session: EventSessio
group_id=str(event.group_id), group_id=str(event.group_id),
handle_type=RequestHandleType.APPROVE, handle_type=RequestHandleType.APPROVE,
) )
await PlatformUtils.send_superuser( results = await PlatformUtils.send_superuser(
bot, bot,
f"*****一份入群申请*****\n" f"*****一份入群申请*****\n"
f"ID{f.id}\n" f"ID{f.id}\n"
@ -230,13 +239,27 @@ async def _(bot: v12Bot | v11Bot, event: GroupRequestEvent, session: EventSessio
nickname=nickname, nickname=nickname,
group_id=str(event.group_id), group_id=str(event.group_id),
) )
await PlatformUtils.send_superuser( kick_count = await EventLog.filter(
group_id=str(event.group_id), event_type=EventLogType.KICK_BOT
).count()
kick_message = (
f"\n该群累计踢出{BotConfig.self_nickname} <{kick_count}>次"
if kick_count
else ""
)
results = await PlatformUtils.send_superuser(
bot, bot,
f"*****一份入群申请*****\n" f"*****一份入群申请*****\n"
f"ID{f.id}\n" f"ID{f.id}\n"
f"申请人:{nickname}({event.user_id})\n群聊:" f"申请人:{nickname}({event.user_id})\n群聊:"
f"{event.group_id}\n邀请日期:{datetime.now().replace(microsecond=0)}", f"{event.group_id}\n邀请日期:{datetime.now().replace(microsecond=0)}"
f"{kick_message}",
) )
if message_ids := [
str(r[1].msg_ids[0]["message_id"]) for r in results if r[1] and r[1].msg_ids
]:
f.message_ids = ",".join(message_ids)
await f.save(update_fields=["message_ids"])
else: else:
logger.debug( logger.debug(
"群聊请求五分钟内重复, 已忽略", "群聊请求五分钟内重复, 已忽略",

View File

@ -0,0 +1,51 @@
from nonebot.plugin import PluginMetadata
from zhenxun.configs.utils import PluginExtraData
from zhenxun.utils.enum import PluginType
from . import command # noqa: F401
__plugin_meta__ = PluginMetadata(
name="定时任务管理",
description="查看和管理由 SchedulerManager 控制的定时任务。",
usage="""
📋 定时任务管理 - 支持群聊和私聊操作
🔍 查看任务:
定时任务 查看 [-all] [-g <群号>] [-p <插件>] [--page <页码>]
群聊中: 查看本群任务
私聊中: 必须使用 -g <群号> -all 选项 (SUPERUSER)
📊 任务状态:
定时任务 状态 <任务ID> 任务状态 <任务ID>
查看单个任务的详细信息和状态
任务管理 (SUPERUSER):
定时任务 设置 <插件> [时间选项] [-g <群号> | -g all] [--kwargs <参数>]
定时任务 删除 <任务ID> | -p <插件> [-g <群号>] | -all
定时任务 暂停 <任务ID> | -p <插件> [-g <群号>] | -all
定时任务 恢复 <任务ID> | -p <插件> [-g <群号>] | -all
定时任务 执行 <任务ID>
定时任务 更新 <任务ID> [时间选项] [--kwargs <参数>]
📝 时间选项 (三选一):
--cron "<分> <时> <日> <月> <周>" # 例: --cron "0 8 * * *"
--interval <时间间隔> # 例: --interval 30m, 2h, 10s
--date "<YYYY-MM-DD HH:MM:SS>" # 例: --date "2024-01-01 08:00:00"
--daily "<HH:MM>" # 例: --daily "08:30"
📚 其他功能:
定时任务 插件列表 # 查看所有可设置定时任务的插件 (SUPERUSER)
🏷 别名支持:
查看: ls, list | 设置: add, 开启 | 删除: del, rm, remove, 关闭, 取消
暂停: pause | 恢复: resume | 执行: trigger, run | 状态: status, info
更新: update, modify, 修改 | 插件列表: plugins
""".strip(),
extra=PluginExtraData(
author="HibiKier",
version="0.1.2",
plugin_type=PluginType.SUPERUSER,
is_show=False,
).to_dict(),
)

View File

@ -0,0 +1,836 @@
import asyncio
from datetime import datetime
import re
from nonebot.adapters import Event
from nonebot.adapters.onebot.v11 import Bot
from nonebot.params import Depends
from nonebot.permission import SUPERUSER
from nonebot_plugin_alconna import (
Alconna,
AlconnaMatch,
Args,
Arparma,
Match,
Option,
Query,
Subcommand,
on_alconna,
)
from pydantic import BaseModel, ValidationError
from zhenxun.utils._image_template import ImageTemplate
from zhenxun.utils.manager.schedule_manager import scheduler_manager
def _get_type_name(annotation) -> str:
"""获取类型注解的名称"""
if hasattr(annotation, "__name__"):
return annotation.__name__
elif hasattr(annotation, "_name"):
return annotation._name
else:
return str(annotation)
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.rules import admin_check
def _format_trigger(schedule_status: dict) -> str:
"""将触发器配置格式化为人类可读的字符串"""
trigger_type = schedule_status["trigger_type"]
config = schedule_status["trigger_config"]
if trigger_type == "cron":
minute = config.get("minute", "*")
hour = config.get("hour", "*")
day = config.get("day", "*")
month = config.get("month", "*")
day_of_week = config.get("day_of_week", "*")
if day == "*" and month == "*" and day_of_week == "*":
formatted_hour = hour if hour == "*" else f"{int(hour):02d}"
formatted_minute = minute if minute == "*" else f"{int(minute):02d}"
return f"每天 {formatted_hour}:{formatted_minute}"
else:
return f"Cron: {minute} {hour} {day} {month} {day_of_week}"
elif trigger_type == "interval":
seconds = config.get("seconds", 0)
minutes = config.get("minutes", 0)
hours = config.get("hours", 0)
days = config.get("days", 0)
if days:
trigger_str = f"{days}"
elif hours:
trigger_str = f"{hours} 小时"
elif minutes:
trigger_str = f"{minutes} 分钟"
else:
trigger_str = f"{seconds}"
elif trigger_type == "date":
run_date = config.get("run_date", "未知时间")
trigger_str = f"{run_date}"
else:
trigger_str = f"{trigger_type}: {config}"
return trigger_str
def _format_params(schedule_status: dict) -> str:
"""将任务参数格式化为人类可读的字符串"""
if kwargs := schedule_status.get("job_kwargs"):
kwargs_str = " | ".join(f"{k}: {v}" for k, v in kwargs.items())
return kwargs_str
return "-"
def _parse_interval(interval_str: str) -> dict:
"""增强版解析器,支持 d(天)"""
match = re.match(r"(\d+)([smhd])", interval_str.lower())
if not match:
raise ValueError("时间间隔格式错误, 请使用如 '30m', '2h', '1d', '10s' 的格式。")
value, unit = int(match.group(1)), match.group(2)
if unit == "s":
return {"seconds": value}
if unit == "m":
return {"minutes": value}
if unit == "h":
return {"hours": value}
if unit == "d":
return {"days": value}
return {}
def _parse_daily_time(time_str: str) -> dict:
"""解析 HH:MM 或 HH:MM:SS 格式的时间为 cron 配置"""
if match := re.match(r"^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$", time_str):
hour, minute, second = match.groups()
hour, minute = int(hour), int(minute)
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise ValueError("小时或分钟数值超出范围。")
cron_config = {
"minute": str(minute),
"hour": str(hour),
"day": "*",
"month": "*",
"day_of_week": "*",
}
if second is not None:
if not (0 <= int(second) <= 59):
raise ValueError("秒数值超出范围。")
cron_config["second"] = str(second)
return cron_config
else:
raise ValueError("时间格式错误,请使用 'HH:MM''HH:MM:SS' 格式。")
async def GetBotId(
bot: Bot,
bot_id_match: Match[str] = AlconnaMatch("bot_id"),
) -> str:
"""获取要操作的Bot ID"""
if bot_id_match.available:
return bot_id_match.result
return bot.self_id
class ScheduleTarget:
"""定时任务操作目标的基类"""
pass
class TargetByID(ScheduleTarget):
"""按任务ID操作"""
def __init__(self, id: int):
self.id = id
class TargetByPlugin(ScheduleTarget):
"""按插件名操作"""
def __init__(
self, plugin: str, group_id: str | None = None, all_groups: bool = False
):
self.plugin = plugin
self.group_id = group_id
self.all_groups = all_groups
class TargetAll(ScheduleTarget):
"""操作所有任务"""
def __init__(self, for_group: str | None = None):
self.for_group = for_group
TargetScope = TargetByID | TargetByPlugin | TargetAll | None
def create_target_parser(subcommand_name: str):
"""
创建一个依赖注入函数用于解析删除暂停恢复等命令的操作目标
"""
async def dependency(
event: Event,
schedule_id: Match[int] = AlconnaMatch("schedule_id"),
plugin_name: Match[str] = AlconnaMatch("plugin_name"),
group_id: Match[str] = AlconnaMatch("group_id"),
all_enabled: Query[bool] = Query(f"{subcommand_name}.all"),
) -> TargetScope:
if schedule_id.available:
return TargetByID(schedule_id.result)
if plugin_name.available:
p_name = plugin_name.result
if all_enabled.available:
return TargetByPlugin(plugin=p_name, all_groups=True)
elif group_id.available:
gid = group_id.result
if gid.lower() == "all":
return TargetByPlugin(plugin=p_name, all_groups=True)
return TargetByPlugin(plugin=p_name, group_id=gid)
else:
current_group_id = getattr(event, "group_id", None)
if current_group_id:
return TargetByPlugin(plugin=p_name, group_id=str(current_group_id))
else:
await schedule_cmd.finish(
"私聊中操作插件任务必须使用 -g <群号> 或 -all 选项。"
)
if all_enabled.available:
return TargetAll(for_group=group_id.result if group_id.available else None)
return None
return dependency
schedule_cmd = on_alconna(
Alconna(
"定时任务",
Subcommand(
"查看",
Option("-g", Args["target_group_id", str]),
Option("-all", help_text="查看所有群聊 (SUPERUSER)"),
Option("-p", Args["plugin_name", str], help_text="按插件名筛选"),
Option("--page", Args["page", int, 1], help_text="指定页码"),
alias=["ls", "list"],
help_text="查看定时任务",
),
Subcommand(
"设置",
Args["plugin_name", str],
Option("--cron", Args["cron_expr", str], help_text="设置 cron 表达式"),
Option("--interval", Args["interval_expr", str], help_text="设置时间间隔"),
Option("--date", Args["date_expr", str], help_text="设置特定执行日期"),
Option(
"--daily",
Args["daily_expr", str],
help_text="设置每天执行的时间 (如 08:20)",
),
Option("-g", Args["group_id", str], help_text="指定群组ID或'all'"),
Option("-all", help_text="对所有群生效 (等同于 -g all)"),
Option("--kwargs", Args["kwargs_str", str], help_text="设置任务参数"),
Option(
"--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)"
),
alias=["add", "开启"],
help_text="设置/开启一个定时任务",
),
Subcommand(
"删除",
Args["schedule_id?", int],
Option("-p", Args["plugin_name", str], help_text="指定插件名"),
Option("-g", Args["group_id", str], help_text="指定群组ID"),
Option("-all", help_text="对所有群生效"),
Option(
"--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)"
),
alias=["del", "rm", "remove", "关闭", "取消"],
help_text="删除一个或多个定时任务",
),
Subcommand(
"暂停",
Args["schedule_id?", int],
Option("-all", help_text="对当前群所有任务生效"),
Option("-p", Args["plugin_name", str], help_text="指定插件名"),
Option("-g", Args["group_id", str], help_text="指定群组ID (SUPERUSER)"),
Option(
"--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)"
),
alias=["pause"],
help_text="暂停一个或多个定时任务",
),
Subcommand(
"恢复",
Args["schedule_id?", int],
Option("-all", help_text="对当前群所有任务生效"),
Option("-p", Args["plugin_name", str], help_text="指定插件名"),
Option("-g", Args["group_id", str], help_text="指定群组ID (SUPERUSER)"),
Option(
"--bot", Args["bot_id", str], help_text="指定操作的Bot ID (SUPERUSER)"
),
alias=["resume"],
help_text="恢复一个或多个定时任务",
),
Subcommand(
"执行",
Args["schedule_id", int],
alias=["trigger", "run"],
help_text="立即执行一次任务",
),
Subcommand(
"更新",
Args["schedule_id", int],
Option("--cron", Args["cron_expr", str], help_text="设置 cron 表达式"),
Option("--interval", Args["interval_expr", str], help_text="设置时间间隔"),
Option("--date", Args["date_expr", str], help_text="设置特定执行日期"),
Option(
"--daily",
Args["daily_expr", str],
help_text="更新每天执行的时间 (如 08:20)",
),
Option("--kwargs", Args["kwargs_str", str], help_text="更新参数"),
alias=["update", "modify", "修改"],
help_text="更新任务配置",
),
Subcommand(
"状态",
Args["schedule_id", int],
alias=["status", "info"],
help_text="查看单个任务的详细状态",
),
Subcommand(
"插件列表",
alias=["plugins"],
help_text="列出所有可用的插件",
),
),
priority=5,
block=True,
rule=admin_check(1),
)
schedule_cmd.shortcut(
"任务状态",
command="定时任务",
arguments=["状态", "{%0}"],
prefix=True,
)
@schedule_cmd.handle()
async def _handle_time_options_mutex(arp: Arparma):
time_options = ["cron", "interval", "date", "daily"]
provided_options = [opt for opt in time_options if arp.query(opt) is not None]
if len(provided_options) > 1:
await schedule_cmd.finish(
f"时间选项 --{', --'.join(provided_options)} 不能同时使用,请只选择一个。"
)
@schedule_cmd.assign("查看")
async def _(
bot: Bot,
event: Event,
target_group_id: Match[str] = AlconnaMatch("target_group_id"),
all_groups: Query[bool] = Query("查看.all"),
plugin_name: Match[str] = AlconnaMatch("plugin_name"),
page: Match[int] = AlconnaMatch("page"),
):
is_superuser = await SUPERUSER(bot, event)
schedules = []
title = ""
current_group_id = getattr(event, "group_id", None)
if not (all_groups.available or target_group_id.available) and not current_group_id:
await schedule_cmd.finish("私聊中查看任务必须使用 -g <群号> 或 -all 选项。")
if all_groups.available:
if not is_superuser:
await schedule_cmd.finish("需要超级用户权限才能查看所有群组的定时任务。")
schedules = await scheduler_manager.get_all_schedules()
title = "所有群组的定时任务"
elif target_group_id.available:
if not is_superuser:
await schedule_cmd.finish("需要超级用户权限才能查看指定群组的定时任务。")
gid = target_group_id.result
schedules = [
s for s in await scheduler_manager.get_all_schedules() if s.group_id == gid
]
title = f"{gid} 的定时任务"
else:
gid = str(current_group_id)
schedules = [
s for s in await scheduler_manager.get_all_schedules() if s.group_id == gid
]
title = "本群的定时任务"
if plugin_name.available:
schedules = [s for s in schedules if s.plugin_name == plugin_name.result]
title += f" [插件: {plugin_name.result}]"
if not schedules:
await schedule_cmd.finish("没有找到任何相关的定时任务。")
page_size = 15
current_page = page.result
total_items = len(schedules)
total_pages = (total_items + page_size - 1) // page_size
start_index = (current_page - 1) * page_size
end_index = start_index + page_size
paginated_schedules = schedules[start_index:end_index]
if not paginated_schedules:
await schedule_cmd.finish("这一页没有内容了哦~")
status_tasks = [
scheduler_manager.get_schedule_status(s.id) for s in paginated_schedules
]
all_statuses = await asyncio.gather(*status_tasks)
data_list = [
[
s["id"],
s["plugin_name"],
s.get("bot_id") or "N/A",
s["group_id"] or "全局",
s["next_run_time"],
_format_trigger(s),
_format_params(s),
"✔️ 已启用" if s["is_enabled"] else "⏸️ 已暂停",
]
for s in all_statuses
if s
]
if not data_list:
await schedule_cmd.finish("没有找到任何相关的定时任务。")
img = await ImageTemplate.table_page(
head_text=title,
tip_text=f"{current_page}/{total_pages} 页,共 {total_items} 条任务",
column_name=[
"ID",
"插件",
"Bot ID",
"群组/目标",
"下次运行",
"触发规则",
"参数",
"状态",
],
data_list=data_list,
column_space=20,
)
await MessageUtils.build_message(img).send(reply_to=True)
@schedule_cmd.assign("设置")
async def _(
event: Event,
plugin_name: str,
cron_expr: str | None = None,
interval_expr: str | None = None,
date_expr: str | None = None,
daily_expr: str | None = None,
group_id: str | None = None,
kwargs_str: str | None = None,
all_enabled: Query[bool] = Query("设置.all"),
bot_id_to_operate: str = Depends(GetBotId),
):
if plugin_name not in scheduler_manager._registered_tasks:
await schedule_cmd.finish(
f"插件 '{plugin_name}' 没有注册可用的定时任务。\n"
f"可用插件: {list(scheduler_manager._registered_tasks.keys())}"
)
trigger_type = ""
trigger_config = {}
try:
if cron_expr:
trigger_type = "cron"
parts = cron_expr.split()
if len(parts) != 5:
raise ValueError("Cron 表达式必须有5个部分 (分 时 日 月 周)")
cron_keys = ["minute", "hour", "day", "month", "day_of_week"]
trigger_config = dict(zip(cron_keys, parts))
elif interval_expr:
trigger_type = "interval"
trigger_config = _parse_interval(interval_expr)
elif date_expr:
trigger_type = "date"
trigger_config = {"run_date": datetime.fromisoformat(date_expr)}
elif daily_expr:
trigger_type = "cron"
trigger_config = _parse_daily_time(daily_expr)
else:
await schedule_cmd.finish(
"必须提供一种时间选项: --cron, --interval, --date, 或 --daily。"
)
except ValueError as e:
await schedule_cmd.finish(f"时间参数解析错误: {e}")
job_kwargs = {}
if kwargs_str:
task_meta = scheduler_manager._registered_tasks[plugin_name]
params_model = task_meta.get("model")
if not params_model:
await schedule_cmd.finish(f"插件 '{plugin_name}' 不支持设置额外参数。")
if not (isinstance(params_model, type) and issubclass(params_model, BaseModel)):
await schedule_cmd.finish(f"插件 '{plugin_name}' 的参数模型配置错误。")
raw_kwargs = {}
try:
for item in kwargs_str.split(","):
key, value = item.strip().split("=", 1)
raw_kwargs[key.strip()] = value
except Exception as e:
await schedule_cmd.finish(
f"参数格式错误,请使用 'key=value,key2=value2' 格式。错误: {e}"
)
try:
model_validate = getattr(params_model, "model_validate", None)
if not model_validate:
await schedule_cmd.finish(
f"插件 '{plugin_name}' 的参数模型不支持验证。"
)
return
validated_model = model_validate(raw_kwargs)
model_dump = getattr(validated_model, "model_dump", None)
if not model_dump:
await schedule_cmd.finish(
f"插件 '{plugin_name}' 的参数模型不支持导出。"
)
return
job_kwargs = model_dump()
except ValidationError as e:
errors = [f" - {err['loc'][0]}: {err['msg']}" for err in e.errors()]
error_str = "\n".join(errors)
await schedule_cmd.finish(
f"插件 '{plugin_name}' 的任务参数验证失败:\n{error_str}"
)
return
target_group_id: str | None
current_group_id = getattr(event, "group_id", None)
if group_id and group_id.lower() == "all":
target_group_id = "__ALL_GROUPS__"
elif all_enabled.available:
target_group_id = "__ALL_GROUPS__"
elif group_id:
target_group_id = group_id
elif current_group_id:
target_group_id = str(current_group_id)
else:
await schedule_cmd.finish(
"私聊中设置定时任务时,必须使用 -g <群号> 或 --all 选项指定目标。"
)
return
success, msg = await scheduler_manager.add_schedule(
plugin_name,
target_group_id,
trigger_type,
trigger_config,
job_kwargs,
bot_id=bot_id_to_operate,
)
if target_group_id == "__ALL_GROUPS__":
target_desc = f"所有群组 (Bot: {bot_id_to_operate})"
elif target_group_id is None:
target_desc = "全局"
else:
target_desc = f"群组 {target_group_id}"
if success:
await schedule_cmd.finish(f"已成功为 [{target_desc}] {msg}")
else:
await schedule_cmd.finish(f"为 [{target_desc}] 设置任务失败: {msg}")
@schedule_cmd.assign("删除")
async def _(
target: TargetScope = Depends(create_target_parser("删除")),
bot_id_to_operate: str = Depends(GetBotId),
):
if isinstance(target, TargetByID):
_, message = await scheduler_manager.remove_schedule_by_id(target.id)
await schedule_cmd.finish(message)
elif isinstance(target, TargetByPlugin):
p_name = target.plugin
if p_name not in scheduler_manager.get_registered_plugins():
await schedule_cmd.finish(f"未找到插件 '{p_name}'")
if target.all_groups:
removed_count = await scheduler_manager.remove_schedule_for_all(
p_name, bot_id=bot_id_to_operate
)
message = (
f"已取消了 {removed_count} 个群组的插件 '{p_name}' 定时任务。"
if removed_count > 0
else f"没有找到插件 '{p_name}' 的定时任务。"
)
await schedule_cmd.finish(message)
else:
_, message = await scheduler_manager.remove_schedule(
p_name, target.group_id, bot_id=bot_id_to_operate
)
await schedule_cmd.finish(message)
elif isinstance(target, TargetAll):
if target.for_group:
_, message = await scheduler_manager.remove_schedules_by_group(
target.for_group
)
await schedule_cmd.finish(message)
else:
_, message = await scheduler_manager.remove_all_schedules()
await schedule_cmd.finish(message)
else:
await schedule_cmd.finish(
"删除任务失败请提供任务ID或通过 -p <插件> 或 -all 指定要删除的任务。"
)
@schedule_cmd.assign("暂停")
async def _(
target: TargetScope = Depends(create_target_parser("暂停")),
bot_id_to_operate: str = Depends(GetBotId),
):
if isinstance(target, TargetByID):
_, message = await scheduler_manager.pause_schedule(target.id)
await schedule_cmd.finish(message)
elif isinstance(target, TargetByPlugin):
p_name = target.plugin
if p_name not in scheduler_manager.get_registered_plugins():
await schedule_cmd.finish(f"未找到插件 '{p_name}'")
if target.all_groups:
_, message = await scheduler_manager.pause_schedules_by_plugin(p_name)
await schedule_cmd.finish(message)
else:
_, message = await scheduler_manager.pause_schedule_by_plugin_group(
p_name, target.group_id, bot_id=bot_id_to_operate
)
await schedule_cmd.finish(message)
elif isinstance(target, TargetAll):
if target.for_group:
_, message = await scheduler_manager.pause_schedules_by_group(
target.for_group
)
await schedule_cmd.finish(message)
else:
_, message = await scheduler_manager.pause_all_schedules()
await schedule_cmd.finish(message)
else:
await schedule_cmd.finish("请提供任务ID、使用 -p <插件> 或 -all 选项。")
@schedule_cmd.assign("恢复")
async def _(
target: TargetScope = Depends(create_target_parser("恢复")),
bot_id_to_operate: str = Depends(GetBotId),
):
if isinstance(target, TargetByID):
_, message = await scheduler_manager.resume_schedule(target.id)
await schedule_cmd.finish(message)
elif isinstance(target, TargetByPlugin):
p_name = target.plugin
if p_name not in scheduler_manager.get_registered_plugins():
await schedule_cmd.finish(f"未找到插件 '{p_name}'")
if target.all_groups:
_, message = await scheduler_manager.resume_schedules_by_plugin(p_name)
await schedule_cmd.finish(message)
else:
_, message = await scheduler_manager.resume_schedule_by_plugin_group(
p_name, target.group_id, bot_id=bot_id_to_operate
)
await schedule_cmd.finish(message)
elif isinstance(target, TargetAll):
if target.for_group:
_, message = await scheduler_manager.resume_schedules_by_group(
target.for_group
)
await schedule_cmd.finish(message)
else:
_, message = await scheduler_manager.resume_all_schedules()
await schedule_cmd.finish(message)
else:
await schedule_cmd.finish("请提供任务ID、使用 -p <插件> 或 -all 选项。")
@schedule_cmd.assign("执行")
async def _(schedule_id: int):
_, message = await scheduler_manager.trigger_now(schedule_id)
await schedule_cmd.finish(message)
@schedule_cmd.assign("更新")
async def _(
schedule_id: int,
cron_expr: str | None = None,
interval_expr: str | None = None,
date_expr: str | None = None,
daily_expr: str | None = None,
kwargs_str: str | None = None,
):
if not any([cron_expr, interval_expr, date_expr, daily_expr, kwargs_str]):
await schedule_cmd.finish(
"请提供需要更新的时间 (--cron/--interval/--date/--daily) 或参数 (--kwargs)"
)
trigger_config = None
trigger_type = None
try:
if cron_expr:
trigger_type = "cron"
parts = cron_expr.split()
if len(parts) != 5:
raise ValueError("Cron 表达式必须有5个部分")
cron_keys = ["minute", "hour", "day", "month", "day_of_week"]
trigger_config = dict(zip(cron_keys, parts))
elif interval_expr:
trigger_type = "interval"
trigger_config = _parse_interval(interval_expr)
elif date_expr:
trigger_type = "date"
trigger_config = {"run_date": datetime.fromisoformat(date_expr)}
elif daily_expr:
trigger_type = "cron"
trigger_config = _parse_daily_time(daily_expr)
except ValueError as e:
await schedule_cmd.finish(f"时间参数解析错误: {e}")
job_kwargs = None
if kwargs_str:
schedule = await scheduler_manager.get_schedule_by_id(schedule_id)
if not schedule:
await schedule_cmd.finish(f"未找到 ID 为 {schedule_id} 的任务。")
task_meta = scheduler_manager._registered_tasks.get(schedule.plugin_name)
if not task_meta or not (params_model := task_meta.get("model")):
await schedule_cmd.finish(
f"插件 '{schedule.plugin_name}' 未定义参数模型,无法更新参数。"
)
if not (isinstance(params_model, type) and issubclass(params_model, BaseModel)):
await schedule_cmd.finish(
f"插件 '{schedule.plugin_name}' 的参数模型配置错误。"
)
raw_kwargs = {}
try:
for item in kwargs_str.split(","):
key, value = item.strip().split("=", 1)
raw_kwargs[key.strip()] = value
except Exception as e:
await schedule_cmd.finish(
f"参数格式错误,请使用 'key=value,key2=value2' 格式。错误: {e}"
)
try:
model_validate = getattr(params_model, "model_validate", None)
if not model_validate:
await schedule_cmd.finish(
f"插件 '{schedule.plugin_name}' 的参数模型不支持验证。"
)
return
validated_model = model_validate(raw_kwargs)
model_dump = getattr(validated_model, "model_dump", None)
if not model_dump:
await schedule_cmd.finish(
f"插件 '{schedule.plugin_name}' 的参数模型不支持导出。"
)
return
job_kwargs = model_dump(exclude_unset=True)
except ValidationError as e:
errors = [f" - {err['loc'][0]}: {err['msg']}" for err in e.errors()]
error_str = "\n".join(errors)
await schedule_cmd.finish(f"更新的参数验证失败:\n{error_str}")
return
_, message = await scheduler_manager.update_schedule(
schedule_id, trigger_type, trigger_config, job_kwargs
)
await schedule_cmd.finish(message)
@schedule_cmd.assign("插件列表")
async def _():
registered_plugins = scheduler_manager.get_registered_plugins()
if not registered_plugins:
await schedule_cmd.finish("当前没有已注册的定时任务插件。")
message_parts = ["📋 已注册的定时任务插件:"]
for i, plugin_name in enumerate(registered_plugins, 1):
task_meta = scheduler_manager._registered_tasks[plugin_name]
params_model = task_meta.get("model")
if not params_model:
message_parts.append(f"{i}. {plugin_name} - 无参数")
continue
if not (isinstance(params_model, type) and issubclass(params_model, BaseModel)):
message_parts.append(f"{i}. {plugin_name} - ⚠️ 参数模型配置错误")
continue
model_fields = getattr(params_model, "model_fields", None)
if model_fields:
param_info = ", ".join(
f"{field_name}({_get_type_name(field_info.annotation)})"
for field_name, field_info in model_fields.items()
)
message_parts.append(f"{i}. {plugin_name} - 参数: {param_info}")
else:
message_parts.append(f"{i}. {plugin_name} - 无参数")
await schedule_cmd.finish("\n".join(message_parts))
@schedule_cmd.assign("状态")
async def _(schedule_id: int):
status = await scheduler_manager.get_schedule_status(schedule_id)
if not status:
await schedule_cmd.finish(f"未找到ID为 {schedule_id} 的定时任务。")
info_lines = [
f"📋 定时任务详细信息 (ID: {schedule_id})",
"--------------------",
f"▫️ 插件: {status['plugin_name']}",
f"▫️ Bot ID: {status.get('bot_id') or '默认'}",
f"▫️ 目标: {status['group_id'] or '全局'}",
f"▫️ 状态: {'✔️ 已启用' if status['is_enabled'] else '⏸️ 已暂停'}",
f"▫️ 下次运行: {status['next_run_time']}",
f"▫️ 触发规则: {_format_trigger(status)}",
f"▫️ 任务参数: {_format_params(status)}",
]
await schedule_cmd.finish("\n".join(info_lines))

View File

@ -1,67 +1,8 @@
from asyncio.exceptions import TimeoutError
import aiofiles
import nonebot
from nonebot.drivers import Driver
from nonebot_plugin_apscheduler import scheduler
import ujson as json
from zhenxun.configs.path_config import TEXT_PATH
from zhenxun.models.group_console import GroupConsole from zhenxun.models.group_console import GroupConsole
from zhenxun.services.log import logger from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from zhenxun.utils.http_utils import AsyncHttpx
driver: Driver = nonebot.get_driver()
@driver.on_startup @PriorityLifecycle.on_startup(priority=5)
async def update_city():
"""
部分插件需要中国省份城市
这里直接更新避免插件内代码重复
"""
china_city = TEXT_PATH / "china_city.json"
if not china_city.exists():
data = {}
try:
logger.debug("开始更新城市列表...")
res = await AsyncHttpx.get(
"http://www.weather.com.cn/data/city3jdata/china.html", timeout=5
)
res.encoding = "utf8"
provinces_data = json.loads(res.text)
for province in provinces_data.keys():
data[provinces_data[province]] = []
res = await AsyncHttpx.get(
f"http://www.weather.com.cn/data/city3jdata/provshi/{province}.html",
timeout=5,
)
res.encoding = "utf8"
city_data = json.loads(res.text)
for city in city_data.keys():
data[provinces_data[province]].append(city_data[city])
async with aiofiles.open(china_city, "w", encoding="utf8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
logger.info("自动更新城市列表完成.....")
except TimeoutError as e:
logger.warning("自动更新城市列表超时...", e=e)
except ValueError as e:
logger.warning("自动城市列表失败.....", e=e)
except Exception as e:
logger.error("自动城市列表未知错误", e=e)
# 自动更新城市列表
@scheduler.scheduled_job(
"cron",
hour=6,
minute=1,
)
async def _():
await update_city()
@driver.on_startup
async def _(): async def _():
"""开启/禁用插件格式修改""" """开启/禁用插件格式修改"""
_, is_create = await GroupConsole.get_or_create(group_id=133133133) _, is_create = await GroupConsole.get_or_create(group_id=133133133)

View File

@ -5,7 +5,9 @@ from nonebot_plugin_alconna import (
AlconnaQuery, AlconnaQuery,
Args, Args,
Arparma, Arparma,
At,
Match, Match,
MultiVar,
Option, Option,
Query, Query,
Subcommand, Subcommand,
@ -33,6 +35,7 @@ __plugin_meta__ = PluginMetadata(
usage=""" usage="""
商品操作 商品操作
指令 指令
商店
我的金币 我的金币
我的道具 我的道具
使用道具 [名称/Id] 使用道具 [名称/Id]
@ -46,6 +49,7 @@ __plugin_meta__ = PluginMetadata(
plugin_type=PluginType.NORMAL, plugin_type=PluginType.NORMAL,
menu_type="商店", menu_type="商店",
commands=[ commands=[
Command(command="商店"),
Command(command="我的金币"), Command(command="我的金币"),
Command(command="我的道具"), Command(command="我的道具"),
Command(command="购买道具"), Command(command="购买道具"),
@ -74,13 +78,21 @@ _matcher = on_alconna(
Subcommand("my-cost", help_text="我的金币"), Subcommand("my-cost", help_text="我的金币"),
Subcommand("my-props", help_text="我的道具"), Subcommand("my-props", help_text="我的道具"),
Subcommand("buy", Args["name?", str]["num?", int], help_text="购买道具"), Subcommand("buy", Args["name?", str]["num?", int], help_text="购买道具"),
Subcommand("use", Args["name?", str]["num?", int], help_text="使用道具"),
Subcommand("gold-list", Args["num?", int], help_text="金币排行"), Subcommand("gold-list", Args["num?", int], help_text="金币排行"),
), ),
priority=5, priority=5,
block=True, block=True,
) )
_use_matcher = on_alconna(
Alconna(
"使用道具",
Args["name?", str]["num?", int]["at_users?", MultiVar(At)],
),
priority=5,
block=True,
)
_matcher.shortcut( _matcher.shortcut(
"我的金币", "我的金币",
command="商店", command="商店",
@ -102,13 +114,6 @@ _matcher.shortcut(
prefix=True, prefix=True,
) )
_matcher.shortcut(
"使用道具(?P<name>.*?)",
command="商店",
arguments=["use", "{name}"],
prefix=True,
)
_matcher.shortcut( _matcher.shortcut(
"金币排行", "金币排行",
command="商店", command="商店",
@ -172,7 +177,7 @@ async def _(
await MessageUtils.build_message(result).send(reply_to=True) await MessageUtils.build_message(result).send(reply_to=True)
@_matcher.assign("use") @_use_matcher.handle()
async def _( async def _(
bot: Bot, bot: Bot,
event: Event, event: Event,
@ -181,6 +186,7 @@ async def _(
arparma: Arparma, arparma: Arparma,
name: Match[str], name: Match[str],
num: Query[int] = AlconnaQuery("num", 1), num: Query[int] = AlconnaQuery("num", 1),
at_users: Query[list[At]] = AlconnaQuery("at_users", []),
): ):
if not name.available: if not name.available:
await MessageUtils.build_message( await MessageUtils.build_message(
@ -188,7 +194,7 @@ async def _(
).finish(reply_to=True) ).finish(reply_to=True)
try: try:
result = await ShopManage.use( result = await ShopManage.use(
bot, event, session, message, name.result, num.result, "" bot, event, session, message, name.result, num.result, "", at_users.result
) )
logger.info( logger.info(
f"使用道具 {name.result}, 数量: {num.result}", f"使用道具 {name.result}, 数量: {num.result}",

View File

@ -8,7 +8,7 @@ from typing import Any, Literal
from nonebot.adapters import Bot, Event from nonebot.adapters import Bot, Event
from nonebot.compat import model_dump from nonebot.compat import model_dump
from nonebot_plugin_alconna import UniMessage, UniMsg from nonebot_plugin_alconna import At, UniMessage, UniMsg
from nonebot_plugin_uninfo import Uninfo from nonebot_plugin_uninfo import Uninfo
from pydantic import BaseModel, Field, create_model from pydantic import BaseModel, Field, create_model
from tortoise.expressions import Q from tortoise.expressions import Q
@ -48,6 +48,10 @@ class Goods(BaseModel):
"""model""" """model"""
session: Uninfo | None = None session: Uninfo | None = None
"""Uninfo""" """Uninfo"""
at_user: str | None = None
"""At对象"""
at_users: list[str] = []
"""At对象列表"""
class ShopParam(BaseModel): class ShopParam(BaseModel):
@ -73,6 +77,10 @@ class ShopParam(BaseModel):
"""Uninfo""" """Uninfo"""
message: UniMsg message: UniMsg
"""UniMessage""" """UniMessage"""
at_user: str | None = None
"""At对象"""
at_users: list[str] = []
"""At对象列表"""
extra_data: dict[str, Any] = Field(default_factory=dict) extra_data: dict[str, Any] = Field(default_factory=dict)
"""额外数据""" """额外数据"""
@ -156,6 +164,7 @@ class ShopManage:
goods: Goods, goods: Goods,
num: int, num: int,
text: str, text: str,
at_users: list[str] = [],
) -> tuple[ShopParam, dict[str, Any]]: ) -> tuple[ShopParam, dict[str, Any]]:
"""构造参数 """构造参数
@ -165,6 +174,7 @@ class ShopManage:
goods_name: 商品名称 goods_name: 商品名称
num: 数量 num: 数量
text: 其他信息 text: 其他信息
at_users: at用户
""" """
group_id = None group_id = None
if session.group: if session.group:
@ -172,6 +182,7 @@ class ShopManage:
session.group.parent.id if session.group.parent else session.group.id session.group.parent.id if session.group.parent else session.group.id
) )
_kwargs = goods.params _kwargs = goods.params
at_user = at_users[0] if at_users else None
model = goods.model( model = goods.model(
**{ **{
"goods_name": goods.name, "goods_name": goods.name,
@ -183,6 +194,8 @@ class ShopManage:
"text": text, "text": text,
"session": session, "session": session,
"message": message, "message": message,
"at_user": at_user,
"at_users": at_users,
} }
) )
return model, { return model, {
@ -194,6 +207,8 @@ class ShopManage:
"num": num, "num": num,
"text": text, "text": text,
"goods_name": goods.name, "goods_name": goods.name,
"at_user": at_user,
"at_users": at_users,
} }
@classmethod @classmethod
@ -223,6 +238,7 @@ class ShopManage:
**param.extra_data, **param.extra_data,
"session": session, "session": session,
"message": message, "message": message,
"shop_param": ShopParam,
} }
for key in list(param_json.keys()): for key in list(param_json.keys()):
if key not in args: if key not in args:
@ -308,6 +324,7 @@ class ShopManage:
goods_name: str, goods_name: str,
num: int, num: int,
text: str, text: str,
at_users: list[At] = [],
) -> str | UniMessage | None: ) -> str | UniMessage | None:
"""使用道具 """使用道具
@ -319,6 +336,7 @@ class ShopManage:
goods_name: 商品名称 goods_name: 商品名称
num: 使用数量 num: 使用数量
text: 其他信息 text: 其他信息
at_users: at用户
返回: 返回:
str | MessageFactory | None: 使用完成后返回信息 str | MessageFactory | None: 使用完成后返回信息
@ -339,16 +357,18 @@ class ShopManage:
goods = cls.uuid2goods.get(goods_info.uuid) goods = cls.uuid2goods.get(goods_info.uuid)
if not goods or not goods.func: if not goods or not goods.func:
return f"{goods_info.goods_name} 未注册使用函数, 无法使用..." return f"{goods_info.goods_name} 未注册使用函数, 无法使用..."
at_user_ids = [at.target for at in at_users]
param, kwargs = cls.__build_params( param, kwargs = cls.__build_params(
bot, event, session, message, goods, num, text bot, event, session, message, goods, num, text, at_user_ids
) )
if num > param.max_num_limit: if num > param.max_num_limit:
return f"{goods_info.goods_name} 单次使用最大数量为{param.max_num_limit}..." return f"{goods_info.goods_name} 单次使用最大数量为{param.max_num_limit}..."
await cls.run_before_after(goods, param, session, message, "before", **kwargs) await cls.run_before_after(goods, param, session, message, "before", **kwargs)
result = await cls.__run(goods, param, session, message, **kwargs)
await UserConsole.use_props( await UserConsole.use_props(
session.user.id, goods_info.uuid, num, PlatformUtils.get_platform(session) session.user.id, goods_info.uuid, num, PlatformUtils.get_platform(session)
) )
result = await cls.__run(goods, param, session, message, **kwargs)
await cls.run_before_after(goods, param, session, message, "after", **kwargs) await cls.run_before_after(goods, param, session, message, "after", **kwargs)
if not result and param.send_success_msg: if not result and param.send_success_msg:
result = f"使用道具 {goods.name} {num} 次成功!" result = f"使用道具 {goods.name} {num} 次成功!"
@ -479,10 +499,13 @@ class ShopManage:
if not user.props: if not user.props:
return None return None
user.props = {uuid: count for uuid, count in user.props.items() if count > 0}
goods_list = await GoodsInfo.filter(uuid__in=user.props.keys()).all() goods_list = await GoodsInfo.filter(uuid__in=user.props.keys()).all()
goods_by_uuid = {item.uuid: item for item in goods_list} goods_by_uuid = {item.uuid: item for item in goods_list}
user.props = {
uuid: count
for uuid, count in user.props.items()
if count > 0 and goods_by_uuid.get(uuid)
}
table_rows = [] table_rows = []
for i, prop_uuid in enumerate(user.props): for i, prop_uuid in enumerate(user.props):

View File

@ -10,7 +10,6 @@ from nonebot_plugin_alconna import (
store_true, store_true,
) )
from nonebot_plugin_apscheduler import scheduler from nonebot_plugin_apscheduler import scheduler
from nonebot_plugin_uninfo import Uninfo
from zhenxun.configs.utils import ( from zhenxun.configs.utils import (
Command, Command,
@ -23,7 +22,7 @@ from zhenxun.utils.depends import UserName
from zhenxun.utils.message import MessageUtils from zhenxun.utils.message import MessageUtils
from ._data_source import SignManage from ._data_source import SignManage
from .goods_register import driver # noqa: F401 from .goods_register import Uninfo
from .utils import clear_sign_data_pic from .utils import clear_sign_data_pic
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(

View File

@ -1,7 +1,6 @@
from decimal import Decimal from decimal import Decimal
import nonebot import nonebot
from nonebot.drivers import Driver
from nonebot_plugin_uninfo import Uninfo from nonebot_plugin_uninfo import Uninfo
from zhenxun.models.sign_user import SignUser from zhenxun.models.sign_user import SignUser
@ -9,14 +8,7 @@ from zhenxun.models.user_console import UserConsole
from zhenxun.utils.decorator.shop import shop_register from zhenxun.utils.decorator.shop import shop_register
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
driver: Driver = nonebot.get_driver() driver = nonebot.get_driver()
# @driver.on_startup
# async def _():
# """
# 导入内置的三个商品
# """
@shop_register( @shop_register(

View File

@ -16,6 +16,7 @@ from zhenxun.models.sign_log import SignLog
from zhenxun.models.sign_user import SignUser from zhenxun.models.sign_user import SignUser
from zhenxun.utils.http_utils import AsyncHttpx from zhenxun.utils.http_utils import AsyncHttpx
from zhenxun.utils.image_utils import BuildImage from zhenxun.utils.image_utils import BuildImage
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
from .config import ( from .config import (
@ -54,7 +55,7 @@ LG_MESSAGE = [
] ]
@driver.on_startup @PriorityLifecycle.on_startup(priority=5)
async def init_image(): async def init_image():
SIGN_RESOURCE_PATH.mkdir(parents=True, exist_ok=True) SIGN_RESOURCE_PATH.mkdir(parents=True, exist_ok=True)
SIGN_TODAY_CARD_PATH.mkdir(exist_ok=True, parents=True) SIGN_TODAY_CARD_PATH.mkdir(exist_ok=True, parents=True)

View File

@ -53,10 +53,7 @@ async def _(
) )
@scheduler.scheduled_job( @scheduler.scheduled_job("interval", minutes=1, max_instances=5)
"interval",
minutes=1,
)
async def _(): async def _():
try: try:
call_list = TEMP_LIST.copy() call_list = TEMP_LIST.copy()

View File

@ -110,7 +110,7 @@ async def enable_plugin(
) )
await BotConsole.enable_plugin(None, plugin.module) await BotConsole.enable_plugin(None, plugin.module)
await MessageUtils.build_message( await MessageUtils.build_message(
f"禁用全部 bot 的插件: {plugin_name.result}" f"开启全部 bot 的插件: {plugin_name.result}"
).finish() ).finish()
elif bot_id.available: elif bot_id.available:
logger.info( logger.info(

View File

@ -92,7 +92,7 @@ async def enable_task(
) )
await BotConsole.enable_task(None, task.module) await BotConsole.enable_task(None, task.module)
await MessageUtils.build_message( await MessageUtils.build_message(
f"禁用全部 bot 的被动: {task_name.available}" f"开启全部 bot 的被动: {task_name.available}"
).finish() ).finish()
elif bot_id.available: elif bot_id.available:
logger.info( logger.info(

View File

@ -1,32 +1,77 @@
from typing import Annotated from arclet.alconna import AllParam
from nepattern import UnionPattern
from nonebot import on_command from nonebot.adapters import Bot, Event
from nonebot.adapters import Bot
from nonebot.params import Command
from nonebot.permission import SUPERUSER from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
from nonebot.rule import to_me from nonebot.rule import to_me
from nonebot_plugin_alconna import Text as alcText import nonebot_plugin_alconna as alc
from nonebot_plugin_alconna import UniMsg from nonebot_plugin_alconna import (
Alconna,
Args,
on_alconna,
)
from nonebot_plugin_alconna.uniseg.segment import (
At,
AtAll,
Audio,
Button,
Emoji,
File,
Hyper,
Image,
Keyboard,
Reference,
Reply,
Text,
Video,
Voice,
)
from nonebot_plugin_session import EventSession from nonebot_plugin_session import EventSession
from zhenxun.configs.utils import PluginExtraData, RegisterConfig, Task from zhenxun.configs.utils import PluginExtraData, RegisterConfig, Task
from zhenxun.services.log import logger
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils from zhenxun.utils.message import MessageUtils
from ._data_source import BroadcastManage from .broadcast_manager import BroadcastManager
from .message_processor import (
_extract_broadcast_content,
get_broadcast_target_groups,
send_broadcast_and_notify,
)
BROADCAST_SEND_DELAY_RANGE = (1, 3)
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
name="广播", name="广播",
description="昭告天下!", description="昭告天下!",
usage=""" usage="""
广播 [消息] [图片] 广播 [消息内容]
示例广播 你们好 - 直接发送消息到除当前群组外的所有群组
- 支持文本图片@表情视频等多种消息类型
- 示例广播 你们好
- 示例广播 [图片] 新活动开始啦
广播 + 引用消息
- 将引用的消息作为广播内容发送
- 支持引用普通消息或合并转发消息
- 示例(引用一条消息) 广播
广播撤回
- 撤回最近一次由您触发的广播消息
- 仅能撤回短时间内的消息
- 示例广播撤回
特性
- 在群组中使用广播时不会将消息发送到当前群组
- 在私聊中使用广播时会发送到所有群组
别名
- bc (广播的简写)
- recall (广播撤回的别名)
""".strip(), """.strip(),
extra=PluginExtraData( extra=PluginExtraData(
author="HibiKier", author="HibiKier",
version="0.1", version="1.2",
plugin_type=PluginType.SUPERUSER, plugin_type=PluginType.SUPERUSER,
configs=[ configs=[
RegisterConfig( RegisterConfig(
@ -42,26 +87,106 @@ __plugin_meta__ = PluginMetadata(
).to_dict(), ).to_dict(),
) )
_matcher = on_command( AnySeg = (
"广播", priority=1, permission=SUPERUSER, block=True, rule=to_me() UnionPattern(
[
Text,
Image,
At,
AtAll,
Audio,
Video,
File,
Emoji,
Reply,
Reference,
Hyper,
Button,
Keyboard,
Voice,
]
)
@ "AnySeg"
)
_matcher = on_alconna(
Alconna(
"广播",
Args["content?", AllParam],
),
aliases={"bc"},
priority=1,
permission=SUPERUSER,
block=True,
rule=to_me(),
use_origin=False,
)
_recall_matcher = on_alconna(
Alconna("广播撤回"),
aliases={"recall"},
priority=1,
permission=SUPERUSER,
block=True,
rule=to_me(),
) )
@_matcher.handle() @_matcher.handle()
async def _( async def handle_broadcast(
bot: Bot, bot: Bot,
event: Event,
session: EventSession, session: EventSession,
message: UniMsg, arp: alc.Arparma,
command: Annotated[tuple[str, ...], Command()],
): ):
for msg in message: broadcast_content_msg = await _extract_broadcast_content(bot, event, arp, session)
if isinstance(msg, alcText) and msg.text.strip().startswith(command[0]): if not broadcast_content_msg:
msg.text = msg.text.replace(command[0], "", 1).strip() return
break
await MessageUtils.build_message("正在发送..请等一下哦!").send() target_groups, enabled_groups = await get_broadcast_target_groups(bot, session)
count, error_count = await BroadcastManage.send(bot, message, session) if not target_groups or not enabled_groups:
result = f"成功广播 {count} 个群组" return
try:
await send_broadcast_and_notify(
bot, event, broadcast_content_msg, enabled_groups, target_groups, session
)
except Exception as e:
error_msg = "发送广播失败"
BroadcastManager.log_error(error_msg, e, session)
await MessageUtils.build_message(f"{error_msg}").send(reply_to=True)
@_recall_matcher.handle()
async def handle_broadcast_recall(
bot: Bot,
event: Event,
session: EventSession,
):
"""处理广播撤回命令"""
await MessageUtils.build_message("正在尝试撤回最近一次广播...").send()
try:
success_count, error_count = await BroadcastManager.recall_last_broadcast(
bot, session
)
user_id = str(event.get_user_id())
if success_count == 0 and error_count == 0:
await bot.send_private_msg(
user_id=user_id,
message="没有找到最近的广播消息记录,可能已经撤回或超过可撤回时间。",
)
else:
result = f"广播撤回完成!\n成功撤回 {success_count} 条消息"
if error_count: if error_count:
result += f"\n广播失败 {error_count} 个群组" result += f"\n撤回失败 {error_count} 条消息 (可能已过期或无权限)"
await MessageUtils.build_message(f"发送广播完成!\n{result}").send(reply_to=True) await bot.send_private_msg(user_id=user_id, message=result)
logger.info(f"发送广播信息: {message}", "广播", session=session) BroadcastManager.log_info(
f"广播撤回完成: 成功 {success_count}, 失败 {error_count}", session
)
except Exception as e:
error_msg = "撤回广播消息失败"
BroadcastManager.log_error(error_msg, e, session)
user_id = str(event.get_user_id())
await bot.send_private_msg(user_id=user_id, message=f"{error_msg}")

View File

@ -1,72 +0,0 @@
import asyncio
import random
from nonebot.adapters import Bot
import nonebot_plugin_alconna as alc
from nonebot_plugin_alconna import Image, UniMsg
from nonebot_plugin_session import EventSession
from zhenxun.services.log import logger
from zhenxun.utils.common_utils import CommonUtils
from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
class BroadcastManage:
@classmethod
async def send(
cls, bot: Bot, message: UniMsg, session: EventSession
) -> tuple[int, int]:
"""发送广播消息
参数:
bot: Bot
message: 消息内容
session: Session
返回:
tuple[int, int]: 发送成功的群组数量, 发送失败的群组数量
"""
message_list = []
for msg in message:
if isinstance(msg, alc.Image) and msg.url:
message_list.append(Image(url=msg.url))
elif isinstance(msg, alc.Text):
message_list.append(msg.text)
group_list, _ = await PlatformUtils.get_group_list(bot)
if group_list:
error_count = 0
for group in group_list:
try:
if not await CommonUtils.task_is_block(
bot,
"broadcast", # group.channel_id
group.group_id,
):
target = PlatformUtils.get_target(
group_id=group.group_id, channel_id=group.channel_id
)
if target:
await MessageUtils.build_message(message_list).send(
target, bot
)
logger.debug(
"发送成功",
"广播",
session=session,
target=f"{group.group_id}:{group.channel_id}",
)
await asyncio.sleep(random.randint(1, 3))
else:
logger.warning("target为空", "广播", session=session)
except Exception as e:
error_count += 1
logger.error(
"发送失败",
"广播",
session=session,
target=f"{group.group_id}:{group.channel_id}",
e=e,
)
return len(group_list) - error_count, error_count
return 0, 0

View File

@ -0,0 +1,490 @@
import asyncio
import random
import traceback
from typing import ClassVar
from nonebot.adapters import Bot
from nonebot.adapters.onebot.v11 import Bot as V11Bot
from nonebot.exception import ActionFailed
from nonebot_plugin_alconna import UniMessage
from nonebot_plugin_alconna.uniseg import Receipt, Reference
from nonebot_plugin_session import EventSession
from zhenxun.models.group_console import GroupConsole
from zhenxun.services.log import logger
from zhenxun.utils.common_utils import CommonUtils
from zhenxun.utils.platform import PlatformUtils
from .models import BroadcastDetailResult, BroadcastResult
from .utils import custom_nodes_to_v11_nodes, uni_message_to_v11_list_of_dicts
class BroadcastManager:
"""广播管理器"""
_last_broadcast_msg_ids: ClassVar[dict[str, int]] = {}
@staticmethod
def _get_session_info(session: EventSession | None) -> str:
"""获取会话信息字符串"""
if not session:
return ""
try:
platform = getattr(session, "platform", "unknown")
session_id = str(session)
return f"[{platform}:{session_id}]"
except Exception:
return "[session-info-error]"
@staticmethod
def log_error(
message: str, error: Exception, session: EventSession | None = None, **kwargs
):
"""记录错误日志"""
session_info = BroadcastManager._get_session_info(session)
error_type = type(error).__name__
stack_trace = traceback.format_exc()
error_details = f"\n类型: {error_type}\n信息: {error!s}\n堆栈: {stack_trace}"
logger.error(
f"{session_info} {message}{error_details}", "广播", e=error, **kwargs
)
@staticmethod
def log_warning(message: str, session: EventSession | None = None, **kwargs):
"""记录警告级别日志"""
session_info = BroadcastManager._get_session_info(session)
logger.warning(f"{session_info} {message}", "广播", **kwargs)
@staticmethod
def log_info(message: str, session: EventSession | None = None, **kwargs):
"""记录信息级别日志"""
session_info = BroadcastManager._get_session_info(session)
logger.info(f"{session_info} {message}", "广播", **kwargs)
@classmethod
def get_last_broadcast_msg_ids(cls) -> dict[str, int]:
"""获取最近广播消息ID"""
return cls._last_broadcast_msg_ids.copy()
@classmethod
def clear_last_broadcast_msg_ids(cls) -> None:
"""清空消息ID记录"""
cls._last_broadcast_msg_ids.clear()
@classmethod
async def get_all_groups(cls, bot: Bot) -> tuple[list[GroupConsole], str]:
"""获取群组列表"""
return await PlatformUtils.get_group_list(bot)
@classmethod
async def send(
cls, bot: Bot, message: UniMessage, session: EventSession
) -> BroadcastResult:
"""发送广播到所有群组"""
logger.debug(
f"开始广播(send - 广播到所有群组)Bot ID: {bot.self_id}",
"广播",
session=session,
)
logger.debug("清空上一次的广播消息ID记录", "广播", session=session)
cls.clear_last_broadcast_msg_ids()
all_groups, _ = await cls.get_all_groups(bot)
return await cls.send_to_specific_groups(bot, message, all_groups, session)
@classmethod
async def send_to_specific_groups(
cls,
bot: Bot,
message: UniMessage,
target_groups: list[GroupConsole],
session_info: EventSession | str | None = None,
) -> BroadcastResult:
"""发送广播到指定群组"""
log_session = session_info or bot.self_id
logger.debug(
f"开始广播,目标 {len(target_groups)} 个群组Bot ID: {bot.self_id}",
"广播",
session=log_session,
)
if not target_groups:
logger.debug("目标群组列表为空,广播结束", "广播", session=log_session)
return 0, 0
platform = PlatformUtils.get_platform(bot)
is_forward_broadcast = any(
isinstance(seg, Reference) and getattr(seg, "nodes", None)
for seg in message
)
if platform == "qq" and isinstance(bot, V11Bot) and is_forward_broadcast:
if (
len(message) == 1
and isinstance(message[0], Reference)
and getattr(message[0], "nodes", None)
):
nodes_list = getattr(message[0], "nodes", [])
v11_nodes = custom_nodes_to_v11_nodes(nodes_list)
node_count = len(v11_nodes)
logger.debug(
f"从 UniMessage<Reference> 构造转发节点数: {node_count}",
"广播",
session=log_session,
)
else:
logger.warning(
"广播消息包含合并转发段和其他段,将尝试打平成一个节点发送",
"广播",
session=log_session,
)
v11_content_list = uni_message_to_v11_list_of_dicts(message)
v11_nodes = (
[
{
"type": "node",
"data": {
"user_id": bot.self_id,
"nickname": "广播",
"content": v11_content_list,
},
}
]
if v11_content_list
else []
)
if not v11_nodes:
logger.warning(
"构造出的 V11 合并转发节点为空,无法发送",
"广播",
session=log_session,
)
return 0, len(target_groups)
success_count, error_count, skip_count = await cls._broadcast_forward(
bot, log_session, target_groups, v11_nodes
)
else:
if is_forward_broadcast:
logger.warning(
f"合并转发消息在适配器 ({platform}) 不支持,将作为普通消息发送",
"广播",
session=log_session,
)
success_count, error_count, skip_count = await cls._broadcast_normal(
bot, log_session, target_groups, message
)
total = len(target_groups)
stats = f"成功: {success_count}, 失败: {error_count}"
stats += f", 跳过: {skip_count}, 总计: {total}"
logger.debug(
f"广播统计 - {stats}",
"广播",
session=log_session,
)
msg_ids = cls.get_last_broadcast_msg_ids()
if msg_ids:
id_list_str = ", ".join([f"{k}:{v}" for k, v in msg_ids.items()])
logger.debug(
f"广播结束,记录了 {len(msg_ids)} 条消息ID: {id_list_str}",
"广播",
session=log_session,
)
else:
logger.warning(
"广播结束但没有记录任何消息ID",
"广播",
session=log_session,
)
return success_count, error_count
@classmethod
async def _extract_message_id_from_result(
cls,
result: dict | Receipt,
group_key: str,
session_info: EventSession | str,
msg_type: str = "普通",
) -> None:
"""提取消息ID并记录"""
if isinstance(result, dict) and "message_id" in result:
msg_id = result["message_id"]
try:
msg_id_int = int(msg_id)
cls._last_broadcast_msg_ids[group_key] = msg_id_int
logger.debug(
f"记录群 {group_key}{msg_type}消息ID: {msg_id_int}",
"广播",
session=session_info,
)
except (ValueError, TypeError):
logger.warning(
f"{msg_type}结果中的 message_id 不是有效整数: {msg_id}",
"广播",
session=session_info,
)
elif isinstance(result, Receipt) and result.msg_ids:
try:
first_id_info = result.msg_ids[0]
msg_id = None
if isinstance(first_id_info, dict) and "message_id" in first_id_info:
msg_id = first_id_info["message_id"]
logger.debug(
f"从 Receipt.msg_ids[0] 提取到 ID: {msg_id}",
"广播",
session=session_info,
)
elif isinstance(first_id_info, int | str):
msg_id = first_id_info
logger.debug(
f"从 Receipt.msg_ids[0] 提取到原始ID: {msg_id}",
"广播",
session=session_info,
)
if msg_id is not None:
try:
msg_id_int = int(msg_id)
cls._last_broadcast_msg_ids[group_key] = msg_id_int
logger.debug(
f"记录群 {group_key} 的消息ID: {msg_id_int}",
"广播",
session=session_info,
)
except (ValueError, TypeError):
logger.warning(
f"提取的ID ({msg_id}) 不是有效整数",
"广播",
session=session_info,
)
else:
info_str = str(first_id_info)
logger.warning(
f"无法从 Receipt.msg_ids[0] 提取ID: {info_str}",
"广播",
session=session_info,
)
except IndexError:
logger.warning("Receipt.msg_ids 为空", "广播", session=session_info)
except Exception as e_extract:
logger.error(
f"从 Receipt 提取 msg_id 时出错: {e_extract}",
"广播",
session=session_info,
e=e_extract,
)
else:
logger.warning(
f"发送成功但无法从结果获取消息 ID. 结果: {result}",
"广播",
session=session_info,
)
@classmethod
async def _check_group_availability(cls, bot: Bot, group: GroupConsole) -> bool:
"""检查群组是否可用"""
if not group.group_id:
return False
if await CommonUtils.task_is_block(bot, "broadcast", group.group_id):
return False
return True
@classmethod
async def _broadcast_forward(
cls,
bot: V11Bot,
session_info: EventSession | str,
group_list: list[GroupConsole],
v11_nodes: list[dict],
) -> BroadcastDetailResult:
"""发送合并转发"""
success_count = 0
error_count = 0
skip_count = 0
for _, group in enumerate(group_list):
group_key = group.group_id or group.channel_id
if not await cls._check_group_availability(bot, group):
skip_count += 1
continue
try:
result = await bot.send_group_forward_msg(
group_id=int(group.group_id), messages=v11_nodes
)
logger.debug(
f"合并转发消息发送结果: {result}, 类型: {type(result)}",
"广播",
session=session_info,
)
await cls._extract_message_id_from_result(
result, group_key, session_info, "合并转发"
)
success_count += 1
await asyncio.sleep(random.randint(1, 3))
except ActionFailed as af_e:
error_count += 1
logger.error(
f"发送失败(合并转发) to {group_key}: {af_e}",
"广播",
session=session_info,
e=af_e,
)
except Exception as e:
error_count += 1
logger.error(
f"发送失败(合并转发) to {group_key}: {e}",
"广播",
session=session_info,
e=e,
)
return success_count, error_count, skip_count
@classmethod
async def _broadcast_normal(
cls,
bot: Bot,
session_info: EventSession | str,
group_list: list[GroupConsole],
message: UniMessage,
) -> BroadcastDetailResult:
"""发送普通消息"""
success_count = 0
error_count = 0
skip_count = 0
for _, group in enumerate(group_list):
group_key = (
f"{group.group_id}:{group.channel_id}"
if group.channel_id
else str(group.group_id)
)
if not await cls._check_group_availability(bot, group):
skip_count += 1
continue
try:
target = PlatformUtils.get_target(
group_id=group.group_id, channel_id=group.channel_id
)
if target:
receipt: Receipt = await message.send(target, bot=bot)
logger.debug(
f"广播消息发送结果: {receipt}, 类型: {type(receipt)}",
"广播",
session=session_info,
)
await cls._extract_message_id_from_result(
receipt, group_key, session_info
)
success_count += 1
await asyncio.sleep(random.randint(1, 3))
else:
logger.warning(
"target为空", "广播", session=session_info, target=group_key
)
skip_count += 1
except Exception as e:
error_count += 1
logger.error(
f"发送失败(普通) to {group_key}: {e}",
"广播",
session=session_info,
e=e,
)
return success_count, error_count, skip_count
@classmethod
async def recall_last_broadcast(
cls, bot: Bot, session_info: EventSession | str
) -> BroadcastResult:
"""撤回最近广播"""
msg_ids_to_recall = cls.get_last_broadcast_msg_ids()
if not msg_ids_to_recall:
logger.warning(
"没有找到最近的广播消息ID记录", "广播撤回", session=session_info
)
return 0, 0
id_list_str = ", ".join([f"{k}:{v}" for k, v in msg_ids_to_recall.items()])
logger.debug(
f"找到 {len(msg_ids_to_recall)} 条广播消息ID记录: {id_list_str}",
"广播撤回",
session=session_info,
)
success_count = 0
error_count = 0
logger.info(
f"准备撤回 {len(msg_ids_to_recall)} 条广播消息",
"广播撤回",
session=session_info,
)
for group_key, msg_id in msg_ids_to_recall.items():
try:
logger.debug(
f"尝试撤回消息 (ID: {msg_id}) in {group_key}",
"广播撤回",
session=session_info,
)
await bot.call_api("delete_msg", message_id=msg_id)
success_count += 1
except ActionFailed as af_e:
retcode = getattr(af_e, "retcode", None)
wording = getattr(af_e, "wording", "")
if retcode == 100 and "MESSAGE_NOT_FOUND" in wording.upper():
logger.warning(
f"消息 (ID: {msg_id}) 可能已被撤回或不存在于 {group_key}",
"广播撤回",
session=session_info,
)
elif retcode == 300 and "delete message" in wording.lower():
logger.warning(
f"消息 (ID: {msg_id}) 可能已被撤回或不存在于 {group_key}",
"广播撤回",
session=session_info,
)
else:
error_count += 1
logger.error(
f"撤回消息失败 (ID: {msg_id}) in {group_key}: {af_e}",
"广播撤回",
session=session_info,
e=af_e,
)
except Exception as e:
error_count += 1
logger.error(
f"撤回消息时发生未知错误 (ID: {msg_id}) in {group_key}: {e}",
"广播撤回",
session=session_info,
e=e,
)
await asyncio.sleep(0.2)
logger.debug("撤回操作完成清空消息ID记录", "广播撤回", session=session_info)
cls.clear_last_broadcast_msg_ids()
return success_count, error_count

View File

@ -0,0 +1,584 @@
import base64
import json
from typing import Any
from nonebot.adapters import Bot, Event
from nonebot.adapters.onebot.v11 import Message as V11Message
from nonebot.adapters.onebot.v11 import MessageSegment as V11MessageSegment
from nonebot.exception import ActionFailed
import nonebot_plugin_alconna as alc
from nonebot_plugin_alconna import UniMessage
from nonebot_plugin_alconna.uniseg.segment import (
At,
AtAll,
CustomNode,
Image,
Reference,
Reply,
Text,
Video,
)
from nonebot_plugin_alconna.uniseg.tools import reply_fetch
from nonebot_plugin_session import EventSession
from zhenxun.services.log import logger
from zhenxun.utils.common_utils import CommonUtils
from zhenxun.utils.message import MessageUtils
from .broadcast_manager import BroadcastManager
MAX_FORWARD_DEPTH = 3
async def _process_forward_content(
forward_content: Any, forward_id: str | None, bot: Bot, depth: int
) -> list[CustomNode]:
"""处理转发消息内容"""
nodes_for_alc = []
content_parsed = False
if forward_content:
nodes_from_content = None
if isinstance(forward_content, list):
nodes_from_content = forward_content
elif isinstance(forward_content, str):
try:
parsed_content = json.loads(forward_content)
if isinstance(parsed_content, list):
nodes_from_content = parsed_content
except Exception as json_e:
logger.debug(
f"[Depth {depth}] JSON解析失败: {json_e}",
"广播",
)
if nodes_from_content is not None:
logger.debug(
f"[D{depth}] 节点数: {len(nodes_from_content)}",
"广播",
)
content_parsed = True
for node_data in nodes_from_content:
node = await _create_custom_node_from_data(node_data, bot, depth + 1)
if node:
nodes_for_alc.append(node)
if not content_parsed and forward_id:
logger.debug(
f"[D{depth}] 尝试API调用ID: {forward_id}",
"广播",
)
try:
forward_data = await bot.call_api("get_forward_msg", id=forward_id)
nodes_list = None
if isinstance(forward_data, dict) and "messages" in forward_data:
nodes_list = forward_data["messages"]
elif (
isinstance(forward_data, dict)
and "data" in forward_data
and isinstance(forward_data["data"], dict)
and "message" in forward_data["data"]
):
nodes_list = forward_data["data"]["message"]
elif isinstance(forward_data, list):
nodes_list = forward_data
if nodes_list:
node_count = len(nodes_list)
logger.debug(
f"[D{depth + 1}] 节点:{node_count}",
"广播",
)
for node_data in nodes_list:
node = await _create_custom_node_from_data(
node_data, bot, depth + 1
)
if node:
nodes_for_alc.append(node)
else:
logger.warning(
f"[D{depth + 1}] ID:{forward_id}无节点",
"广播",
)
nodes_for_alc.append(
CustomNode(
uid="0",
name="错误",
content="[嵌套转发消息获取失败]",
)
)
except ActionFailed as af_e:
logger.error(
f"[D{depth + 1}] API失败: {af_e}",
"广播",
e=af_e,
)
nodes_for_alc.append(
CustomNode(
uid="0",
name="错误",
content="[嵌套转发消息获取失败]",
)
)
except Exception as e:
logger.error(
f"[D{depth + 1}] 处理出错: {e}",
"广播",
e=e,
)
nodes_for_alc.append(
CustomNode(
uid="0",
name="错误",
content="[处理嵌套转发时出错]",
)
)
elif not content_parsed and not forward_id:
logger.warning(
f"[D{depth}] 转发段无内容也无ID",
"广播",
)
nodes_for_alc.append(
CustomNode(
uid="0",
name="错误",
content="[嵌套转发消息无法解析]",
)
)
elif content_parsed and not nodes_for_alc:
logger.warning(
f"[D{depth}] 解析成功但无有效节点",
"广播",
)
nodes_for_alc.append(
CustomNode(
uid="0",
name="信息",
content="[嵌套转发内容为空]",
)
)
return nodes_for_alc
async def _create_custom_node_from_data(
node_data: dict, bot: Bot, depth: int
) -> CustomNode | None:
"""从节点数据创建CustomNode"""
node_content_raw = node_data.get("message") or node_data.get("content")
if not node_content_raw:
logger.warning(f"[D{depth}] 节点缺少消息内容", "广播")
return None
sender = node_data.get("sender", {})
uid = str(sender.get("user_id", "10000"))
name = sender.get("nickname", f"用户{uid[:4]}")
extracted_uni_msg = await _extract_content_from_message(
node_content_raw, bot, depth
)
if not extracted_uni_msg:
return None
return CustomNode(uid=uid, name=name, content=extracted_uni_msg)
async def _extract_broadcast_content(
bot: Bot,
event: Event,
arp: alc.Arparma,
session: EventSession,
) -> UniMessage | None:
"""从命令参数或引用消息中提取广播内容"""
broadcast_content_msg: UniMessage | None = None
command_content_list = arp.all_matched_args.get("content", [])
processed_command_list = []
has_command_content = False
if command_content_list:
for item in command_content_list:
if isinstance(item, alc.Segment):
processed_command_list.append(item)
if not (isinstance(item, Text) and not item.text.strip()):
has_command_content = True
elif isinstance(item, str):
if item.strip():
processed_command_list.append(Text(item.strip()))
has_command_content = True
else:
logger.warning(
f"Unexpected type in command content: {type(item)}", "广播"
)
if has_command_content:
logger.debug("检测到命令参数内容,优先使用参数内容", "广播", session=session)
broadcast_content_msg = UniMessage(processed_command_list)
if not broadcast_content_msg.filter(
lambda x: not (isinstance(x, Text) and not x.text.strip())
):
logger.warning(
"命令参数内容解析后为空或只包含空白", "广播", session=session
)
broadcast_content_msg = None
if not broadcast_content_msg:
reply_segment_obj: Reply | None = await reply_fetch(event, bot)
if (
reply_segment_obj
and hasattr(reply_segment_obj, "msg")
and reply_segment_obj.msg
):
logger.debug(
"未检测到有效命令参数,检测到引用消息", "广播", session=session
)
raw_quoted_content = reply_segment_obj.msg
is_forward = False
forward_id = None
if isinstance(raw_quoted_content, V11Message):
for seg in raw_quoted_content:
if isinstance(seg, V11MessageSegment):
if seg.type == "forward":
forward_id = seg.data.get("id")
is_forward = bool(forward_id)
break
elif seg.type == "json":
try:
json_data_str = seg.data.get("data", "{}")
if isinstance(json_data_str, str):
import json
json_data = json.loads(json_data_str)
if (
json_data.get("app") == "com.tencent.multimsg"
or json_data.get("view") == "Forward"
) and json_data.get("meta", {}).get(
"detail", {}
).get("resid"):
forward_id = json_data["meta"]["detail"][
"resid"
]
is_forward = True
break
except Exception:
pass
if is_forward and forward_id:
logger.info(
f"尝试获取并构造合并转发内容 (ID: {forward_id})",
"广播",
session=session,
)
nodes_to_forward: list[CustomNode] = []
try:
forward_data = await bot.call_api("get_forward_msg", id=forward_id)
nodes_list = None
if isinstance(forward_data, dict) and "messages" in forward_data:
nodes_list = forward_data["messages"]
elif (
isinstance(forward_data, dict)
and "data" in forward_data
and isinstance(forward_data["data"], dict)
and "message" in forward_data["data"]
):
nodes_list = forward_data["data"]["message"]
elif isinstance(forward_data, list):
nodes_list = forward_data
if nodes_list is not None:
for node_data in nodes_list:
node_sender = node_data.get("sender", {})
node_user_id = str(node_sender.get("user_id", "10000"))
node_nickname = node_sender.get(
"nickname", f"用户{node_user_id[:4]}"
)
node_content_raw = node_data.get(
"message"
) or node_data.get("content")
if node_content_raw:
extracted_node_uni_msg = (
await _extract_content_from_message(
node_content_raw, bot
)
)
if extracted_node_uni_msg:
nodes_to_forward.append(
CustomNode(
uid=node_user_id,
name=node_nickname,
content=extracted_node_uni_msg,
)
)
if nodes_to_forward:
broadcast_content_msg = UniMessage(
Reference(nodes=nodes_to_forward)
)
except ActionFailed:
await MessageUtils.build_message(
"获取合并转发消息失败,可能不支持此 API。"
).send(reply_to=True)
return None
except Exception as api_e:
logger.error(f"处理合并转发时出错: {api_e}", "广播", e=api_e)
await MessageUtils.build_message(
"处理合并转发消息时发生内部错误。"
).send(reply_to=True)
return None
else:
broadcast_content_msg = await _extract_content_from_message(
raw_quoted_content, bot
)
else:
logger.debug("未检测到命令参数和引用消息", "广播", session=session)
await MessageUtils.build_message("请提供广播内容或引用要广播的消息").send(
reply_to=True
)
return None
if not broadcast_content_msg:
logger.error(
"未能从命令参数或引用消息中获取有效的广播内容", "广播", session=session
)
await MessageUtils.build_message("错误:未能获取有效的广播内容。").send(
reply_to=True
)
return None
return broadcast_content_msg
async def _process_v11_segment(
seg_obj: V11MessageSegment | dict, depth: int, index: int, bot: Bot
) -> list[alc.Segment]:
"""处理V11消息段"""
result = []
seg_type = None
data_dict = None
if isinstance(seg_obj, V11MessageSegment):
seg_type = seg_obj.type
data_dict = seg_obj.data
elif isinstance(seg_obj, dict):
seg_type = seg_obj.get("type")
data_dict = seg_obj.get("data")
else:
return result
if not (seg_type and data_dict is not None):
logger.warning(f"[D{depth}] 跳过无效数据: {type(seg_obj)}", "广播")
return result
if seg_type == "text":
text_content = data_dict.get("text", "")
if isinstance(text_content, str) and text_content.strip():
result.append(Text(text_content))
elif seg_type == "image":
img_seg = None
if data_dict.get("url"):
img_seg = Image(url=data_dict["url"])
elif data_dict.get("file"):
file_val = data_dict["file"]
if isinstance(file_val, str) and file_val.startswith("base64://"):
b64_data = file_val[9:]
raw_bytes = base64.b64decode(b64_data)
img_seg = Image(raw=raw_bytes)
else:
img_seg = Image(path=file_val)
if img_seg:
result.append(img_seg)
else:
logger.warning(f"[Depth {depth}] V11 图片 {index} 缺少URL/文件", "广播")
elif seg_type == "at":
target_qq = data_dict.get("qq", "")
if target_qq.lower() == "all":
result.append(AtAll())
elif target_qq:
result.append(At(flag="user", target=target_qq))
elif seg_type == "video":
video_seg = None
if data_dict.get("url"):
video_seg = Video(url=data_dict["url"])
elif data_dict.get("file"):
file_val = data_dict["file"]
if isinstance(file_val, str) and file_val.startswith("base64://"):
b64_data = file_val[9:]
raw_bytes = base64.b64decode(b64_data)
video_seg = Video(raw=raw_bytes)
else:
video_seg = Video(path=file_val)
if video_seg:
result.append(video_seg)
logger.debug(f"[Depth {depth}] 处理视频消息成功", "广播")
else:
logger.warning(f"[Depth {depth}] V11 视频 {index} 缺少URL/文件", "广播")
elif seg_type == "forward":
nested_forward_id = data_dict.get("id") or data_dict.get("resid")
nested_forward_content = data_dict.get("content")
logger.debug(f"[D{depth}] 嵌套转发ID: {nested_forward_id}", "广播")
nested_nodes = await _process_forward_content(
nested_forward_content, nested_forward_id, bot, depth
)
if nested_nodes:
result.append(Reference(nodes=nested_nodes))
else:
logger.warning(f"[D{depth}] 跳过类型: {seg_type}", "广播")
return result
async def _extract_content_from_message(
message_content: Any, bot: Bot, depth: int = 0
) -> UniMessage:
"""提取消息内容到UniMessage"""
temp_msg = UniMessage()
input_type_str = str(type(message_content))
if depth >= MAX_FORWARD_DEPTH:
logger.warning(
f"[Depth {depth}] 达到最大递归深度 {MAX_FORWARD_DEPTH},停止解析嵌套转发。",
"广播",
)
temp_msg.append(Text("[嵌套转发层数过多,内容已省略]"))
return temp_msg
segments_to_process = []
if isinstance(message_content, UniMessage):
segments_to_process = list(message_content)
elif isinstance(message_content, V11Message):
segments_to_process = list(message_content)
elif isinstance(message_content, list):
segments_to_process = message_content
elif (
isinstance(message_content, dict)
and "type" in message_content
and "data" in message_content
):
segments_to_process = [message_content]
elif isinstance(message_content, str):
if message_content.strip():
temp_msg.append(Text(message_content))
return temp_msg
else:
logger.warning(f"[Depth {depth}] 无法处理的输入类型: {input_type_str}", "广播")
return temp_msg
if segments_to_process:
for index, seg_obj in enumerate(segments_to_process):
try:
if isinstance(seg_obj, Text):
text_content = getattr(seg_obj, "text", None)
if isinstance(text_content, str) and text_content.strip():
temp_msg.append(seg_obj)
elif isinstance(seg_obj, Image):
if (
getattr(seg_obj, "url", None)
or getattr(seg_obj, "path", None)
or getattr(seg_obj, "raw", None)
):
temp_msg.append(seg_obj)
elif isinstance(seg_obj, At):
temp_msg.append(seg_obj)
elif isinstance(seg_obj, AtAll):
temp_msg.append(seg_obj)
elif isinstance(seg_obj, Video):
if (
getattr(seg_obj, "url", None)
or getattr(seg_obj, "path", None)
or getattr(seg_obj, "raw", None)
):
temp_msg.append(seg_obj)
logger.debug(f"[D{depth}] 处理Video对象成功", "广播")
else:
processed_segments = await _process_v11_segment(
seg_obj, depth, index, bot
)
temp_msg.extend(processed_segments)
except Exception as e_conv_seg:
logger.warning(
f"[D{depth}] 处理段 {index} 出错: {e_conv_seg}",
"广播",
e=e_conv_seg,
)
if not temp_msg and message_content:
logger.warning(f"未能从类型 {input_type_str} 中提取内容", "广播")
return temp_msg
async def get_broadcast_target_groups(
bot: Bot, session: EventSession
) -> tuple[list, list]:
"""获取广播目标群组和启用了广播功能的群组"""
target_groups = []
all_groups, _ = await BroadcastManager.get_all_groups(bot)
current_group_id = None
if hasattr(session, "id2") and session.id2:
current_group_id = session.id2
if current_group_id:
target_groups = [
group for group in all_groups if group.group_id != current_group_id
]
logger.info(
f"向除当前群组({current_group_id})外的所有群组广播", "广播", session=session
)
else:
target_groups = all_groups
logger.info("向所有群组广播", "广播", session=session)
if not target_groups:
await MessageUtils.build_message("没有找到符合条件的广播目标群组。").send(
reply_to=True
)
return [], []
enabled_groups = []
for group in target_groups:
if not await CommonUtils.task_is_block(bot, "broadcast", group.group_id):
enabled_groups.append(group)
if not enabled_groups:
await MessageUtils.build_message(
"没有启用了广播功能的目标群组可供立即发送。"
).send(reply_to=True)
return target_groups, []
return target_groups, enabled_groups
async def send_broadcast_and_notify(
bot: Bot,
event: Event,
message: UniMessage,
enabled_groups: list,
target_groups: list,
session: EventSession,
) -> None:
"""发送广播并通知结果"""
BroadcastManager.clear_last_broadcast_msg_ids()
count, error_count = await BroadcastManager.send_to_specific_groups(
bot, message, enabled_groups, session
)
result = f"成功广播 {count} 个群组"
if error_count:
result += f"\n发送失败 {error_count} 个群组"
result += f"\n有效: {len(enabled_groups)} / 总计: {len(target_groups)}"
user_id = str(event.get_user_id())
await bot.send_private_msg(user_id=user_id, message=f"发送广播完成!\n{result}")
BroadcastManager.log_info(
f"广播完成,有效/总计: {len(enabled_groups)}/{len(target_groups)}",
session,
)

View File

@ -0,0 +1,64 @@
from datetime import datetime
from typing import Any
from nonebot_plugin_alconna import UniMessage
from zhenxun.models.group_console import GroupConsole
GroupKey = str
MessageID = int
BroadcastResult = tuple[int, int]
BroadcastDetailResult = tuple[int, int, int]
class BroadcastTarget:
"""广播目标"""
def __init__(self, group_id: str, channel_id: str | None = None):
self.group_id = group_id
self.channel_id = channel_id
def to_dict(self) -> dict[str, str | None]:
"""转换为字典格式"""
return {"group_id": self.group_id, "channel_id": self.channel_id}
@classmethod
def from_group_console(cls, group: GroupConsole) -> "BroadcastTarget":
"""从 GroupConsole 对象创建"""
return cls(group_id=group.group_id, channel_id=group.channel_id)
@property
def key(self) -> str:
"""获取群组的唯一标识"""
if self.channel_id:
return f"{self.group_id}:{self.channel_id}"
return str(self.group_id)
class BroadcastTask:
"""广播任务"""
def __init__(
self,
bot_id: str,
message: UniMessage,
targets: list[BroadcastTarget],
scheduled_time: datetime | None = None,
task_id: str | None = None,
):
self.bot_id = bot_id
self.message = message
self.targets = targets
self.scheduled_time = scheduled_time
self.task_id = task_id
def to_dict(self) -> dict[str, Any]:
"""转换为字典格式,用于序列化"""
return {
"bot_id": self.bot_id,
"targets": [t.to_dict() for t in self.targets],
"scheduled_time": self.scheduled_time.isoformat()
if self.scheduled_time
else None,
"task_id": self.task_id,
}

View File

@ -0,0 +1,175 @@
import base64
import nonebot_plugin_alconna as alc
from nonebot_plugin_alconna import UniMessage
from nonebot_plugin_alconna.uniseg import Reference
from nonebot_plugin_alconna.uniseg.segment import CustomNode, Video
from zhenxun.services.log import logger
def uni_segment_to_v11_segment_dict(
seg: alc.Segment, depth: int = 0
) -> dict | list[dict] | None:
"""UniSeg段转V11字典"""
if isinstance(seg, alc.Text):
return {"type": "text", "data": {"text": seg.text}}
elif isinstance(seg, alc.Image):
if getattr(seg, "url", None):
return {
"type": "image",
"data": {"file": seg.url},
}
elif getattr(seg, "raw", None):
raw_data = seg.raw
if isinstance(raw_data, str):
if len(raw_data) >= 9 and raw_data[:9] == "base64://":
return {"type": "image", "data": {"file": raw_data}}
elif isinstance(raw_data, bytes):
b64_str = base64.b64encode(raw_data).decode()
return {"type": "image", "data": {"file": f"base64://{b64_str}"}}
else:
logger.warning(f"无法处理 Image.raw 的类型: {type(raw_data)}", "广播")
elif getattr(seg, "path", None):
logger.warning(
f"在合并转发中使用了本地图片路径,可能无法显示: {seg.path}", "广播"
)
return {"type": "image", "data": {"file": f"file:///{seg.path}"}}
else:
logger.warning(f"alc.Image 缺少有效数据,无法转换为 V11 段: {seg}", "广播")
elif isinstance(seg, alc.At):
return {"type": "at", "data": {"qq": seg.target}}
elif isinstance(seg, alc.AtAll):
return {"type": "at", "data": {"qq": "all"}}
elif isinstance(seg, Video):
if getattr(seg, "url", None):
return {
"type": "video",
"data": {"file": seg.url},
}
elif getattr(seg, "raw", None):
raw_data = seg.raw
if isinstance(raw_data, str):
if len(raw_data) >= 9 and raw_data[:9] == "base64://":
return {"type": "video", "data": {"file": raw_data}}
elif isinstance(raw_data, bytes):
b64_str = base64.b64encode(raw_data).decode()
return {"type": "video", "data": {"file": f"base64://{b64_str}"}}
else:
logger.warning(f"无法处理 Video.raw 的类型: {type(raw_data)}", "广播")
elif getattr(seg, "path", None):
logger.warning(
f"在合并转发中使用了本地视频路径,可能无法显示: {seg.path}", "广播"
)
return {"type": "video", "data": {"file": f"file:///{seg.path}"}}
else:
logger.warning(f"Video 缺少有效数据,无法转换为 V11 段: {seg}", "广播")
elif isinstance(seg, Reference) and getattr(seg, "nodes", None):
if depth >= 3:
logger.warning(
f"嵌套转发深度超过限制 (depth={depth}),不再继续解析", "广播"
)
return {"type": "text", "data": {"text": "[嵌套转发层数过多,内容已省略]"}}
nested_v11_content_list = []
nodes_list = getattr(seg, "nodes", [])
for node in nodes_list:
if isinstance(node, CustomNode):
node_v11_content = []
if isinstance(node.content, UniMessage):
for nested_seg in node.content:
converted_dict = uni_segment_to_v11_segment_dict(
nested_seg, depth + 1
)
if isinstance(converted_dict, list):
node_v11_content.extend(converted_dict)
elif converted_dict:
node_v11_content.append(converted_dict)
elif isinstance(node.content, str):
node_v11_content.append(
{"type": "text", "data": {"text": node.content}}
)
if node_v11_content:
separator = {
"type": "text",
"data": {
"text": f"\n--- 来自 {node.name} ({node.uid}) 的消息 ---\n"
},
}
nested_v11_content_list.insert(0, separator)
nested_v11_content_list.extend(node_v11_content)
nested_v11_content_list.append(
{"type": "text", "data": {"text": "\n---\n"}}
)
return nested_v11_content_list
else:
logger.warning(f"广播时跳过不支持的 UniSeg 段类型: {type(seg)}", "广播")
return None
def uni_message_to_v11_list_of_dicts(uni_msg: UniMessage | str | list) -> list[dict]:
"""UniMessage转V11字典列表"""
try:
if isinstance(uni_msg, str):
return [{"type": "text", "data": {"text": uni_msg}}]
if isinstance(uni_msg, list):
if not uni_msg:
return []
if all(isinstance(item, str) for item in uni_msg):
return [{"type": "text", "data": {"text": item}} for item in uni_msg]
result = []
for item in uni_msg:
if hasattr(item, "__iter__") and not isinstance(item, str | bytes):
result.extend(uni_message_to_v11_list_of_dicts(item))
elif hasattr(item, "text") and not isinstance(item, str | bytes):
text_value = getattr(item, "text", "")
result.append({"type": "text", "data": {"text": str(text_value)}})
elif hasattr(item, "url") and not isinstance(item, str | bytes):
url_value = getattr(item, "url", "")
if isinstance(item, Video):
result.append(
{"type": "video", "data": {"file": str(url_value)}}
)
else:
result.append(
{"type": "image", "data": {"file": str(url_value)}}
)
else:
try:
result.append({"type": "text", "data": {"text": str(item)}})
except Exception as e:
logger.warning(f"无法转换列表元素: {item}, 错误: {e}", "广播")
return result
except Exception as e:
logger.warning(f"消息转换过程中出错: {e}", "广播")
return [{"type": "text", "data": {"text": str(uni_msg)}}]
def custom_nodes_to_v11_nodes(custom_nodes: list[CustomNode]) -> list[dict]:
"""CustomNode列表转V11节点"""
v11_nodes = []
for node in custom_nodes:
v11_content_list = uni_message_to_v11_list_of_dicts(node.content)
if v11_content_list:
v11_nodes.append(
{
"type": "node",
"data": {
"user_id": str(node.uid),
"nickname": node.name,
"content": v11_content_list,
},
}
)
else:
logger.warning(
f"CustomNode (uid={node.uid}) 内容转换后为空,跳过此节点", "广播"
)
return v11_nodes

View File

@ -2,7 +2,7 @@ from io import BytesIO
from arclet.alconna import Args, Option from arclet.alconna import Args, Option
from arclet.alconna.typing import CommandMeta from arclet.alconna.typing import CommandMeta
from nonebot.adapters import Bot from nonebot.adapters import Bot, Event
from nonebot.permission import SUPERUSER from nonebot.permission import SUPERUSER
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
from nonebot.rule import to_me from nonebot.rule import to_me
@ -10,10 +10,13 @@ from nonebot_plugin_alconna import (
Alconna, Alconna,
AlconnaQuery, AlconnaQuery,
Arparma, Arparma,
Match,
Query, Query,
Reply,
on_alconna, on_alconna,
store_true, store_true,
) )
from nonebot_plugin_alconna.uniseg.tools import reply_fetch
from nonebot_plugin_session import EventSession from nonebot_plugin_session import EventSession
from zhenxun.configs.config import BotConfig from zhenxun.configs.config import BotConfig
@ -54,7 +57,7 @@ __plugin_meta__ = PluginMetadata(
_req_matcher = on_alconna( _req_matcher = on_alconna(
Alconna( Alconna(
"请求处理", "请求处理",
Args["handle", ["-fa", "-fr", "-fi", "-ga", "-gr", "-gi"]]["id", int], Args["handle", ["-fa", "-fr", "-fi", "-ga", "-gr", "-gi"]]["id?", int],
meta=CommandMeta( meta=CommandMeta(
description="好友/群组请求处理", description="好友/群组请求处理",
usage=usage, usage=usage,
@ -105,12 +108,12 @@ _clear_matcher = on_alconna(
) )
reg_arg_list = [ reg_arg_list = [
(r"同意好友请求", ["-fa", "{%0}"]), (r"同意好友请求\s*(?P<id>\d*)", ["-fa", "{id}"]),
(r"拒绝好友请求", ["-fr", "{%0}"]), (r"拒绝好友请求\s*(?P<id>\d*)", ["-fr", "{id}"]),
(r"忽略好友请求", ["-fi", "{%0}"]), (r"忽略好友请求\s*(?P<id>\d*)", ["-fi", "{id}"]),
(r"同意群组请求", ["-ga", "{%0}"]), (r"同意群组请求\s*(?P<id>\d*)", ["-ga", "{id}"]),
(r"拒绝群组请求", ["-gr", "{%0}"]), (r"拒绝群组请求\s*(?P<id>\d*)", ["-gr", "{id}"]),
(r"忽略群组请求", ["-gi", "{%0}"]), (r"忽略群组请求\s*(?P<id>\d*)", ["-gi", "{id}"]),
] ]
for r in reg_arg_list: for r in reg_arg_list:
@ -125,32 +128,48 @@ for r in reg_arg_list:
@_req_matcher.handle() @_req_matcher.handle()
async def _( async def _(
bot: Bot, bot: Bot,
event: Event,
session: EventSession, session: EventSession,
handle: str, handle: str,
id: int, id: Match[int],
arparma: Arparma, arparma: Arparma,
): ):
reply: Reply | None = None
type_dict = { type_dict = {
"a": RequestHandleType.APPROVE, "a": RequestHandleType.APPROVE,
"r": RequestHandleType.REFUSED, "r": RequestHandleType.REFUSED,
"i": RequestHandleType.IGNORE, "i": RequestHandleType.IGNORE,
} }
if not id.available:
reply = await reply_fetch(event, bot)
if not reply:
await MessageUtils.build_message("请引用消息处理或添加处理Id.").finish()
handle_id = id.result
if reply:
db_data = await FgRequest.get_or_none(message_ids__contains=reply.id)
if not db_data:
await MessageUtils.build_message(
"未发现此消息的Id请使用Id进行处理..."
).finish(reply_to=True)
handle_id = db_data.id
req = None req = None
handle_type = type_dict[handle[-1]] handle_type = type_dict[handle[-1]]
try: try:
if handle_type == RequestHandleType.APPROVE: if handle_type == RequestHandleType.APPROVE:
req = await FgRequest.approve(bot, id) req = await FgRequest.approve(bot, handle_id)
if handle_type == RequestHandleType.REFUSED: if handle_type == RequestHandleType.REFUSED:
req = await FgRequest.refused(bot, id) req = await FgRequest.refused(bot, handle_id)
if handle_type == RequestHandleType.IGNORE: if handle_type == RequestHandleType.IGNORE:
req = await FgRequest.ignore(id) req = await FgRequest.ignore(handle_id)
except NotFoundError: except NotFoundError:
await MessageUtils.build_message("未发现此id的请求...").finish(reply_to=True) await MessageUtils.build_message("未发现此id的请求...").finish(reply_to=True)
except Exception: except Exception:
await MessageUtils.build_message("其他错误, 可能flag已失效...").finish( await MessageUtils.build_message("其他错误, 可能flag已失效...").finish(
reply_to=True reply_to=True
) )
logger.info("处理请求", arparma.header_result, session=session) logger.info(
f"处理请求 Id: {req.id if req else ''}", arparma.header_result, session=session
)
await MessageUtils.build_message("成功处理请求!").send(reply_to=True) await MessageUtils.build_message("成功处理请求!").send(reply_to=True)
if req and handle_type == RequestHandleType.APPROVE: if req and handle_type == RequestHandleType.APPROVE:
await bot.send_private_msg( await bot.send_private_msg(

View File

@ -10,7 +10,9 @@ from zhenxun.configs.config import Config as gConfig
from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.configs.utils import PluginExtraData, RegisterConfig
from zhenxun.services.log import logger, logger_ from zhenxun.services.log import logger, logger_
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import PluginType
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from .api.configure import router as configure_router
from .api.logs import router as ws_log_routes from .api.logs import router as ws_log_routes
from .api.logs.log_manager import LOG_STORAGE from .api.logs.log_manager import LOG_STORAGE
from .api.menu import router as menu_router from .api.menu import router as menu_router
@ -29,8 +31,7 @@ from .public import init_public
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
name="WebUi", name="WebUi",
description="WebUi API", description="WebUi API",
usage=""" usage='"""\n """.strip(),',
""".strip(),
extra=PluginExtraData( extra=PluginExtraData(
author="HibiKier", author="HibiKier",
version="0.1", version="0.1",
@ -82,7 +83,7 @@ BaseApiRouter.include_router(database_router)
BaseApiRouter.include_router(plugin_router) BaseApiRouter.include_router(plugin_router)
BaseApiRouter.include_router(system_router) BaseApiRouter.include_router(system_router)
BaseApiRouter.include_router(menu_router) BaseApiRouter.include_router(menu_router)
BaseApiRouter.include_router(configure_router)
WsApiRouter = APIRouter(prefix="/zhenxun/socket") WsApiRouter = APIRouter(prefix="/zhenxun/socket")
@ -91,9 +92,11 @@ WsApiRouter.include_router(status_routes)
WsApiRouter.include_router(chat_routes) WsApiRouter.include_router(chat_routes)
@driver.on_startup @PriorityLifecycle.on_startup(priority=0)
async def _(): async def _():
try: try:
# 存储任务引用的列表,防止任务被垃圾回收
_tasks = []
async def log_sink(message: str): async def log_sink(message: str):
loop = None loop = None
@ -104,7 +107,8 @@ async def _():
logger.warning("Web Ui log_sink", e=e) logger.warning("Web Ui log_sink", e=e)
if not loop: if not loop:
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
loop.create_task(LOG_STORAGE.add(message.rstrip("\n"))) # noqa: RUF006 # 存储任务引用到外部列表中
_tasks.append(loop.create_task(LOG_STORAGE.add(message.rstrip("\n"))))
logger_.add( logger_.add(
log_sink, colorize=True, filter=default_filter, format=default_format log_sink, colorize=True, filter=default_filter, format=default_format

View File

@ -0,0 +1,133 @@
import asyncio
import os
from pathlib import Path
import re
import subprocess
import sys
import time
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import nonebot
from zhenxun.configs.config import BotConfig, Config
from ...base_model import Result
from .data_source import test_db_connection
from .model import Setting
router = APIRouter(prefix="/configure")
driver = nonebot.get_driver()
port = driver.config.port
BAT_FILE = Path() / "win启动.bat"
FILE_NAME = ".configure_restart"
@router.post(
"/set_configure",
response_model=Result,
response_class=JSONResponse,
description="设置基础配置",
)
async def _(setting: Setting) -> Result:
global port
password = Config.get_config("web-ui", "password")
if password or BotConfig.db_url:
return Result.fail("配置已存在请先删除DB_URL内容和前端密码再进行设置。")
env_file = Path() / ".env.dev"
if not env_file.exists():
return Result.fail("配置文件.env.dev不存在。")
env_text = env_file.read_text(encoding="utf-8")
if setting.db_url:
if setting.db_url.startswith("sqlite"):
base_dir = Path().resolve()
# 清理和验证数据库路径
db_path_str = setting.db_url.split(":")[-1].strip()
# 移除任何可能的路径遍历尝试
db_path_str = re.sub(r"[\\/]\.\.[\\/]", "", db_path_str)
# 规范化路径
db_path = Path(db_path_str).resolve()
parent_path = db_path.parent
# 验证路径是否在项目根目录内
try:
if not parent_path.absolute().is_relative_to(base_dir):
return Result.fail("数据库路径不在项目根目录内。")
except ValueError:
return Result.fail("无效的数据库路径。")
# 创建目录
try:
parent_path.mkdir(parents=True, exist_ok=True)
except Exception as e:
return Result.fail(f"创建数据库目录失败: {e!s}")
env_text = env_text.replace('DB_URL = ""', f'DB_URL = "{setting.db_url}"')
if setting.superusers:
superusers = ", ".join([f'"{s}"' for s in setting.superusers])
env_text = re.sub(r"SUPERUSERS=\[.*?\]", f"SUPERUSERS=[{superusers}]", env_text)
if setting.host:
env_text = env_text.replace("HOST = 127.0.0.1", f"HOST = {setting.host}")
if setting.port:
env_text = env_text.replace("PORT = 8080", f"PORT = {setting.port}")
port = setting.port
if setting.username:
Config.set_config("web-ui", "username", setting.username)
Config.set_config("web-ui", "password", setting.password, True)
env_file.write_text(env_text, encoding="utf-8")
if BAT_FILE.exists():
for file in os.listdir(Path()):
if file.startswith(FILE_NAME):
Path(file).unlink()
flag_file = Path() / f"{FILE_NAME}_{int(time.time())}"
flag_file.touch()
return Result.ok(BAT_FILE.exists(), info="设置成功,请重启真寻以完成配置!")
@router.get(
"/test_db",
response_model=Result,
response_class=JSONResponse,
description="设置基础配置",
)
async def _(db_url: str) -> Result:
result = await test_db_connection(db_url)
if isinstance(result, str):
return Result.fail(result)
return Result.ok(info="数据库连接成功!")
async def run_restart_command(bat_path: Path, port: int):
"""在后台执行重启命令"""
await asyncio.sleep(1) # 确保 FastAPI 已返回响应
subprocess.Popen([bat_path, str(port)], shell=True) # noqa: ASYNC220
sys.exit(0) # 退出当前进程
@router.post(
"/restart",
response_model=Result,
response_class=JSONResponse,
description="重启",
)
async def _() -> Result:
if not BAT_FILE.exists():
return Result.fail("自动重启仅支持意见整合包,请尝试手动重启")
flag_file = next(
(Path() / file for file in os.listdir(Path()) if file.startswith(FILE_NAME)),
None,
)
if not flag_file or not flag_file.exists():
return Result.fail("重启标志文件不存在...")
set_time = flag_file.name.split("_")[-1]
if time.time() - float(set_time) > 10 * 60:
return Result.fail("重启标志文件已过期,请重新设置配置。")
flag_file.unlink()
try:
return Result.ok(info="执行重启命令成功")
finally:
asyncio.create_task(run_restart_command(BAT_FILE, port)) # noqa: RUF006

View File

@ -0,0 +1,18 @@
from tortoise import Tortoise
async def test_db_connection(db_url: str) -> bool | str:
try:
# 初始化 Tortoise ORM
await Tortoise.init(
db_url=db_url,
modules={"models": ["__main__"]}, # 这里不需要实际模型
)
# 测试连接
await Tortoise.get_connection("default").execute_query("SELECT 1")
return True
except Exception as e:
return str(e)
finally:
# 关闭连接
await Tortoise.close_connections()

View File

@ -0,0 +1,16 @@
from pydantic import BaseModel
class Setting(BaseModel):
superusers: list[str]
"""超级用户列表"""
db_url: str
"""数据库地址"""
host: str
"""主机地址"""
port: int
"""端口"""
username: str
"""前端用户名"""
password: str
"""前端密码"""

View File

@ -5,18 +5,7 @@ from zhenxun.services.log import logger
from .model import MenuData, MenuItem from .model import MenuData, MenuItem
default_menus = [
class MenuManage:
def __init__(self) -> None:
self.file = DATA_PATH / "web_ui" / "menu.json"
self.menu = []
if self.file.exists():
try:
self.menu = json.load(self.file.open(encoding="utf8"))
except Exception as e:
logger.warning("菜单文件损坏,已重新生成...", "WebUi", e=e)
if not self.menu:
self.menu = [
MenuItem( MenuItem(
name="仪表盘", name="仪表盘",
module="dashboard", module="dashboard",
@ -30,27 +19,50 @@ class MenuManage:
router="/command", router="/command",
icon="command", icon="command",
), ),
MenuItem( MenuItem(name="插件列表", module="plugin", router="/plugin", icon="plugin"),
name="插件列表", module="plugin", router="/plugin", icon="plugin" MenuItem(name="插件商店", module="store", router="/store", icon="store"),
), MenuItem(name="好友/群组", module="manage", router="/manage", icon="user"),
MenuItem(
name="插件商店", module="store", router="/store", icon="store"
),
MenuItem(
name="好友/群组", module="manage", router="/manage", icon="user"
),
MenuItem( MenuItem(
name="数据库管理", name="数据库管理",
module="database", module="database",
router="/database", router="/database",
icon="database", icon="database",
), ),
MenuItem(name="系统信息", module="system", router="/system", icon="system"),
MenuItem(name="关于我们", module="about", router="/about", icon="about"),
]
class MenuManager:
def __init__(self) -> None:
self.file = DATA_PATH / "web_ui" / "menu.json"
self.menu = []
if self.file.exists():
try:
temp_menu = []
self.menu = json.load(self.file.open(encoding="utf8"))
self_menu_name = [menu["name"] for menu in self.menu]
for module in [m.module for m in default_menus]:
if module in self_menu_name:
temp_menu.append(
MenuItem( MenuItem(
name="系统信息", module="system", router="/system", icon="system" **next(m for m in self.menu if m["module"] == module)
), )
] )
else:
temp_menu.append(self.__get_menu_model(module))
self.menu = temp_menu
except Exception as e:
logger.warning("菜单文件损坏,已重新生成...", "WebUi", e=e)
if not self.menu:
self.menu = default_menus
self.save() self.save()
def __get_menu_model(self, module: str):
return default_menus[
next(i for i, m in enumerate(default_menus) if m.module == module)
]
def get_menus(self): def get_menus(self):
return MenuData(menus=self.menu) return MenuData(menus=self.menu)
@ -61,4 +73,4 @@ class MenuManage:
json.dump(temp, f, ensure_ascii=False, indent=4) json.dump(temp, f, ensure_ascii=False, indent=4)
menu_manage = MenuManage() menu_manage = MenuManager()

View File

@ -13,6 +13,7 @@ from zhenxun.models.bot_connect_log import BotConnectLog
from zhenxun.models.chat_history import ChatHistory from zhenxun.models.chat_history import ChatHistory
from zhenxun.models.statistics import Statistics from zhenxun.models.statistics import Statistics
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
from ....base_model import BaseResultModel, QueryModel from ....base_model import BaseResultModel, QueryModel
@ -31,7 +32,7 @@ driver: Driver = nonebot.get_driver()
CONNECT_TIME = 0 CONNECT_TIME = 0
@driver.on_startup @PriorityLifecycle.on_startup(priority=5)
async def _(): async def _():
global CONNECT_TIME global CONNECT_TIME
CONNECT_TIME = int(time.time()) CONNECT_TIME = int(time.time())

View File

@ -8,6 +8,7 @@ from zhenxun.configs.config import BotConfig
from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.plugin_info import PluginInfo
from zhenxun.models.task_info import TaskInfo from zhenxun.models.task_info import TaskInfo
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from ....base_model import BaseResultModel, QueryModel, Result from ....base_model import BaseResultModel, QueryModel, Result
from ....utils import authentication from ....utils import authentication
@ -21,7 +22,7 @@ router = APIRouter(prefix="/database")
driver: Driver = nonebot.get_driver() driver: Driver = nonebot.get_driver()
@driver.on_startup @PriorityLifecycle.on_startup(priority=5)
async def _(): async def _():
for plugin in nonebot.get_loaded_plugins(): for plugin in nonebot.get_loaded_plugins():
module = plugin.name module = plugin.name

View File

@ -16,7 +16,7 @@ from zhenxun.utils.platform import PlatformUtils
from ....base_model import Result from ....base_model import Result
from ....config import QueryDateType from ....config import QueryDateType
from ....utils import authentication, get_system_status from ....utils import authentication, clear_help_image, get_system_status
from .data_source import ApiDataSource from .data_source import ApiDataSource
from .model import ( from .model import (
ActiveGroup, ActiveGroup,
@ -234,6 +234,7 @@ async def _(param: BotManageUpdateParam):
bot_data.block_plugins = CommonUtils.convert_module_format(param.block_plugins) bot_data.block_plugins = CommonUtils.convert_module_format(param.block_plugins)
bot_data.block_tasks = CommonUtils.convert_module_format(param.block_tasks) bot_data.block_tasks = CommonUtils.convert_module_format(param.block_tasks)
await bot_data.save(update_fields=["block_plugins", "block_tasks"]) await bot_data.save(update_fields=["block_plugins", "block_tasks"])
clear_help_image()
return Result.ok() return Result.ok()
except Exception as e: except Exception as e:
logger.error(f"{router.prefix}/update_bot_manage 调用错误", "WebUi", e=e) logger.error(f"{router.prefix}/update_bot_manage 调用错误", "WebUi", e=e)

View File

@ -92,7 +92,7 @@ class ApiDataSource:
""" """
version_file = Path() / "__version__" version_file = Path() / "__version__"
if version_file.exists(): if version_file.exists():
if text := version_file.open().read(): if text := version_file.open(encoding="utf-8").read():
return text.replace("__version__: ", "").strip() return text.replace("__version__: ", "").strip()
return "unknown" return "unknown"

View File

@ -1,3 +1,5 @@
from datetime import datetime
from fastapi import APIRouter from fastapi import APIRouter
import nonebot import nonebot
from nonebot import on_message from nonebot import on_message
@ -49,13 +51,14 @@ async def message_handle(
message: UniMsg, message: UniMsg,
group_id: str | None, group_id: str | None,
): ):
time = str(datetime.now().replace(microsecond=0))
messages = [] messages = []
for m in message: for m in message:
if isinstance(m, Text | str): if isinstance(m, Text | str):
messages.append(MessageItem(type="text", msg=str(m))) messages.append(MessageItem(type="text", msg=str(m), time=time))
elif isinstance(m, Image): elif isinstance(m, Image):
if m.url: if m.url:
messages.append(MessageItem(type="img", msg=m.url)) messages.append(MessageItem(type="img", msg=m.url, time=time))
elif isinstance(m, At): elif isinstance(m, At):
if group_id: if group_id:
if m.target == "0": if m.target == "0":
@ -72,9 +75,9 @@ async def message_handle(
uname = group_user.user_name uname = group_user.user_name
if m.target not in ID2NAME[group_id]: if m.target not in ID2NAME[group_id]:
ID2NAME[group_id][m.target] = uname ID2NAME[group_id][m.target] = uname
messages.append(MessageItem(type="at", msg=f"@{uname}")) messages.append(MessageItem(type="at", msg=f"@{uname}", time=time))
elif isinstance(m, Hyper): elif isinstance(m, Hyper):
messages.append(MessageItem(type="text", msg="[分享消息]")) messages.append(MessageItem(type="text", msg="[分享消息]", time=time))
return messages return messages

View File

@ -237,6 +237,8 @@ class MessageItem(BaseModel):
"""消息类型""" """消息类型"""
msg: str msg: str
"""内容""" """内容"""
time: str
"""发送日期"""
class Message(BaseModel): class Message(BaseModel):

View File

@ -6,13 +6,16 @@ from zhenxun.services.log import logger
from zhenxun.utils.enum import BlockType, PluginType from zhenxun.utils.enum import BlockType, PluginType
from ....base_model import Result from ....base_model import Result
from ....utils import authentication from ....utils import authentication, clear_help_image
from .data_source import ApiDataSource from .data_source import ApiDataSource
from .model import ( from .model import (
BatchUpdatePlugins,
BatchUpdateResult,
PluginCount, PluginCount,
PluginDetail, PluginDetail,
PluginInfo, PluginInfo,
PluginSwitch, PluginSwitch,
RenameMenuTypePayload,
UpdatePlugin, UpdatePlugin,
) )
@ -30,9 +33,8 @@ async def _(
plugin_type: list[PluginType] = Query(None), menu_type: str | None = None plugin_type: list[PluginType] = Query(None), menu_type: str | None = None
) -> Result[list[PluginInfo]]: ) -> Result[list[PluginInfo]]:
try: try:
return Result.ok( result = await ApiDataSource.get_plugin_list(plugin_type, menu_type)
await ApiDataSource.get_plugin_list(plugin_type, menu_type), "拿到信息啦!" return Result.ok(result, "拿到信息啦!")
)
except Exception as e: except Exception as e:
logger.error(f"{router.prefix}/get_plugin_list 调用错误", "WebUi", e=e) logger.error(f"{router.prefix}/get_plugin_list 调用错误", "WebUi", e=e)
return Result.fail(f"发生了一点错误捏 {type(e)}: {e}") return Result.fail(f"发生了一点错误捏 {type(e)}: {e}")
@ -78,6 +80,7 @@ async def _() -> Result[PluginCount]:
async def _(param: UpdatePlugin) -> Result: async def _(param: UpdatePlugin) -> Result:
try: try:
await ApiDataSource.update_plugin(param) await ApiDataSource.update_plugin(param)
clear_help_image()
return Result.ok(info="已经帮你写好啦!") return Result.ok(info="已经帮你写好啦!")
except (ValueError, KeyError): except (ValueError, KeyError):
return Result.fail("插件数据不存在...") return Result.fail("插件数据不存在...")
@ -105,6 +108,7 @@ async def _(param: PluginSwitch) -> Result:
db_plugin.block_type = None db_plugin.block_type = None
db_plugin.status = True db_plugin.status = True
await db_plugin.save() await db_plugin.save()
clear_help_image()
return Result.ok(info="成功改变了开关状态!") return Result.ok(info="成功改变了开关状态!")
except Exception as e: except Exception as e:
logger.error(f"{router.prefix}/change_switch 调用错误", "WebUi", e=e) logger.error(f"{router.prefix}/change_switch 调用错误", "WebUi", e=e)
@ -144,11 +148,68 @@ async def _() -> Result[list[str]]:
) )
async def _(module: str) -> Result[PluginDetail]: async def _(module: str) -> Result[PluginDetail]:
try: try:
return Result.ok( detail = await ApiDataSource.get_plugin_detail(module)
await ApiDataSource.get_plugin_detail(module), "已经帮你写好啦!" return Result.ok(detail, "已经帮你写好啦!")
)
except (ValueError, KeyError): except (ValueError, KeyError):
return Result.fail("插件数据不存在...") return Result.fail("插件数据不存在...")
except Exception as e: except Exception as e:
logger.error(f"{router.prefix}/get_plugin 调用错误", "WebUi", e=e) logger.error(f"{router.prefix}/get_plugin 调用错误", "WebUi", e=e)
return Result.fail(f"{type(e)}: {e}") return Result.fail(f"{type(e)}: {e}")
@router.put(
"/plugins/batch_update",
dependencies=[authentication()],
response_model=Result[BatchUpdateResult],
response_class=JSONResponse,
summary="批量更新插件配置",
)
async def batch_update_plugin_config_api(
params: BatchUpdatePlugins,
) -> Result[BatchUpdateResult]:
"""批量更新插件配置,如开关、类型等"""
try:
result_dict = await ApiDataSource.batch_update_plugins(params=params)
result_model = BatchUpdateResult(
success=result_dict["success"],
updated_count=result_dict["updated_count"],
errors=result_dict["errors"],
)
clear_help_image()
return Result.ok(result_model, "插件配置更新完成")
except Exception as e:
logger.error(f"{router.prefix}/plugins/batch_update 调用错误", "WebUi", e=e)
return Result.fail(f"发生了一点错误捏 {type(e)}: {e}")
# 新增:重命名菜单类型路由
@router.put(
"/menu_type/rename",
dependencies=[authentication()],
response_model=Result,
summary="重命名菜单类型",
)
async def rename_menu_type_api(payload: RenameMenuTypePayload) -> Result:
try:
result = await ApiDataSource.rename_menu_type(
old_name=payload.old_name, new_name=payload.new_name
)
if result.get("success"):
clear_help_image()
return Result.ok(
info=result.get(
"info",
f"成功将 {result.get('updated_count', 0)} 个插件的菜单类型从 "
f"'{payload.old_name}' 修改为 '{payload.new_name}'",
)
)
else:
return Result.fail(info=result.get("info", "重命名失败"))
except ValueError as ve:
return Result.fail(info=str(ve))
except RuntimeError as re:
logger.error(f"{router.prefix}/menu_type/rename 调用错误", "WebUi", e=re)
return Result.fail(info=str(re))
except Exception as e:
logger.error(f"{router.prefix}/menu_type/rename 调用错误", "WebUi", e=e)
return Result.fail(info=f"发生未知错误: {type(e).__name__}")

View File

@ -2,13 +2,20 @@ import re
import cattrs import cattrs
from fastapi import Query from fastapi import Query
from tortoise.exceptions import DoesNotExist
from zhenxun.configs.config import Config from zhenxun.configs.config import Config
from zhenxun.configs.utils import ConfigGroup from zhenxun.configs.utils import ConfigGroup
from zhenxun.models.plugin_info import PluginInfo as DbPluginInfo from zhenxun.models.plugin_info import PluginInfo as DbPluginInfo
from zhenxun.utils.enum import BlockType, PluginType from zhenxun.utils.enum import BlockType, PluginType
from .model import PluginConfig, PluginDetail, PluginInfo, UpdatePlugin from .model import (
BatchUpdatePlugins,
PluginConfig,
PluginDetail,
PluginInfo,
UpdatePlugin,
)
class ApiDataSource: class ApiDataSource:
@ -44,6 +51,11 @@ class ApiDataSource:
level=plugin.level, level=plugin.level,
status=plugin.status, status=plugin.status,
author=plugin.author, author=plugin.author,
block_type=plugin.block_type,
is_builtin="builtin_plugins" in plugin.module_path
or plugin.plugin_type == PluginType.HIDDEN,
allow_setting=plugin.plugin_type != PluginType.HIDDEN,
allow_switch=plugin.plugin_type != PluginType.HIDDEN,
) )
plugin_list.append(plugin_info) plugin_list.append(plugin_info)
return plugin_list return plugin_list
@ -69,7 +81,6 @@ class ApiDataSource:
db_plugin.block_type = param.block_type db_plugin.block_type = param.block_type
db_plugin.status = param.block_type != BlockType.ALL db_plugin.status = param.block_type != BlockType.ALL
await db_plugin.save() await db_plugin.save()
# 配置项
if param.configs and (configs := Config.get(param.module)): if param.configs and (configs := Config.get(param.module)):
for key in param.configs: for key in param.configs:
if c := configs.configs.get(key): if c := configs.configs.get(key):
@ -80,6 +91,87 @@ class ApiDataSource:
Config.save(save_simple_data=True) Config.save(save_simple_data=True)
return db_plugin return db_plugin
@classmethod
async def batch_update_plugins(cls, params: BatchUpdatePlugins) -> dict:
"""批量更新插件数据
参数:
params: BatchUpdatePlugins
返回:
dict: 更新结果, 例如 {'success': True, 'updated_count': 5, 'errors': []}
"""
plugins_to_update_other_fields = []
other_update_fields = set()
updated_count = 0
errors = []
for item in params.updates:
try:
db_plugin = await DbPluginInfo.get(module=item.module)
plugin_changed_other = False
plugin_changed_block = False
if db_plugin.block_type != item.block_type:
db_plugin.block_type = item.block_type
db_plugin.status = item.block_type != BlockType.ALL
plugin_changed_block = True
if item.menu_type is not None and db_plugin.menu_type != item.menu_type:
db_plugin.menu_type = item.menu_type
other_update_fields.add("menu_type")
plugin_changed_other = True
if (
item.default_status is not None
and db_plugin.default_status != item.default_status
):
db_plugin.default_status = item.default_status
other_update_fields.add("default_status")
plugin_changed_other = True
if plugin_changed_block:
try:
await db_plugin.save(update_fields=["block_type", "status"])
updated_count += 1
except Exception as e_save:
errors.append(
{
"module": item.module,
"error": f"Save block_type failed: {e_save!s}",
}
)
plugin_changed_other = False
if plugin_changed_other:
plugins_to_update_other_fields.append(db_plugin)
except DoesNotExist:
errors.append({"module": item.module, "error": "Plugin not found"})
except Exception as e:
errors.append({"module": item.module, "error": str(e)})
bulk_updated_count = 0
if plugins_to_update_other_fields and other_update_fields:
try:
await DbPluginInfo.bulk_update(
plugins_to_update_other_fields, list(other_update_fields)
)
bulk_updated_count = len(plugins_to_update_other_fields)
except Exception as e_bulk:
errors.append(
{
"module": "batch_update_other",
"error": f"Bulk update failed: {e_bulk!s}",
}
)
return {
"success": len(errors) == 0,
"updated_count": updated_count + bulk_updated_count,
"errors": errors,
}
@classmethod @classmethod
def __build_plugin_config( def __build_plugin_config(
cls, module: str, cfg: str, config: ConfigGroup cls, module: str, cfg: str, config: ConfigGroup
@ -115,6 +207,41 @@ class ApiDataSource:
type_inner=type_inner, # type: ignore type_inner=type_inner, # type: ignore
) )
@classmethod
async def rename_menu_type(cls, old_name: str, new_name: str) -> dict:
"""重命名菜单类型,并更新所有相关插件
参数:
old_name: 旧菜单类型名称
new_name: 新菜单类型名称
返回:
dict: 更新结果, 例如 {'success': True, 'updated_count': 3}
"""
if not old_name or not new_name:
raise ValueError("旧名称和新名称都不能为空")
if old_name == new_name:
return {
"success": True,
"updated_count": 0,
"info": "新旧名称相同,无需更新",
}
# 检查新名称是否已存在(理论上前端会校验,后端再保险一次)
exists = await DbPluginInfo.filter(menu_type=new_name).exists()
if exists:
raise ValueError(f"新的菜单类型名称 '{new_name}' 已被其他插件使用")
try:
# 使用 filter().update() 进行批量更新
updated_count = await DbPluginInfo.filter(menu_type=old_name).update(
menu_type=new_name
)
return {"success": True, "updated_count": updated_count}
except Exception as e:
# 可以添加更详细的日志记录
raise RuntimeError(f"数据库更新菜单类型失败: {e!s}")
@classmethod @classmethod
async def get_plugin_detail(cls, module: str) -> PluginDetail: async def get_plugin_detail(cls, module: str) -> PluginDetail:
"""获取插件详情 """获取插件详情

View File

@ -1,6 +1,6 @@
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel, Field
from zhenxun.utils.enum import BlockType from zhenxun.utils.enum import BlockType
@ -37,19 +37,19 @@ class UpdatePlugin(BaseModel):
module: str module: str
"""模块""" """模块"""
default_status: bool default_status: bool
"""默认开关""" """是否默认开启"""
limit_superuser: bool limit_superuser: bool
"""限制超级用户""" """是否限制超级用户"""
cost_gold: int
"""金币花费"""
menu_type: str
"""插件菜单类型"""
level: int level: int
"""插件所需群权限""" """等级"""
cost_gold: int
"""花费金币"""
menu_type: str
"""菜单类型"""
block_type: BlockType | None = None block_type: BlockType | None = None
"""禁用类型""" """禁用类型"""
configs: dict[str, Any] | None = None configs: dict[str, Any] | None = None
"""配置项""" """置项"""
class PluginInfo(BaseModel): class PluginInfo(BaseModel):
@ -58,27 +58,33 @@ class PluginInfo(BaseModel):
""" """
module: str module: str
"""插件名称""" """模块"""
plugin_name: str plugin_name: str
"""插件中文名称""" """插件名称"""
default_status: bool default_status: bool
"""默认开关""" """是否默认开启"""
limit_superuser: bool limit_superuser: bool
"""限制超级用户""" """是否限制超级用户"""
level: int
"""等级"""
cost_gold: int cost_gold: int
"""花费金币""" """花费金币"""
menu_type: str menu_type: str
"""插件菜单类型""" """菜单类型"""
version: str version: str
"""插件版本""" """版本"""
level: int
"""群权限"""
status: bool status: bool
"""当前状态""" """状态"""
author: str | None = None author: str | None = None
"""作者""" """作者"""
block_type: BlockType | None = None block_type: BlockType | None = Field(None, description="插件禁用状态 (None: 启用)")
"""禁用类型""" """禁用状态"""
is_builtin: bool = False
"""是否为内置插件"""
allow_switch: bool = True
"""是否允许开关"""
allow_setting: bool = True
"""是否允许设置"""
class PluginConfig(BaseModel): class PluginConfig(BaseModel):
@ -86,20 +92,13 @@ class PluginConfig(BaseModel):
插件配置项 插件配置项
""" """
module: str module: str = Field(..., description="模块名")
"""模块""" key: str = Field(..., description="")
key: str value: Any = Field(None, description="")
"""""" help: str | None = Field(None, description="帮助信息")
value: Any default_value: Any = Field(None, description="默认值")
"""""" type: str | None = Field(None, description="类型")
help: str | None = None type_inner: list[str] | None = Field(None, description="内部类型")
"""帮助"""
default_value: Any
"""默认值"""
type: Any = None
"""值类型"""
type_inner: list[str] | None = None
"""List Tuple等内部类型检验"""
class PluginCount(BaseModel): class PluginCount(BaseModel):
@ -117,6 +116,21 @@ class PluginCount(BaseModel):
"""其他插件""" """其他插件"""
class BatchUpdatePluginItem(BaseModel):
module: str = Field(..., description="插件模块名")
default_status: bool | None = Field(None, description="默认状态(开关)")
menu_type: str | None = Field(None, description="菜单类型")
block_type: BlockType | None = Field(
None, description="插件禁用状态 (None: 启用, ALL: 禁用)"
)
class BatchUpdatePlugins(BaseModel):
updates: list[BatchUpdatePluginItem] = Field(
..., description="要批量更新的插件列表"
)
class PluginDetail(PluginInfo): class PluginDetail(PluginInfo):
""" """
插件详情 插件详情
@ -125,6 +139,26 @@ class PluginDetail(PluginInfo):
config_list: list[PluginConfig] config_list: list[PluginConfig]
class RenameMenuTypePayload(BaseModel):
old_name: str = Field(..., description="旧菜单类型名称")
new_name: str = Field(..., description="新菜单类型名称")
class PluginIr(BaseModel): class PluginIr(BaseModel):
id: int id: int
"""插件id""" """插件id"""
class BatchUpdateResult(BaseModel):
"""
批量更新插件结果
"""
success: bool = Field(..., description="是否全部成功")
"""是否全部成功"""
updated_count: int = Field(..., description="更新成功的数量")
"""更新成功的数量"""
errors: list[dict[str, str]] = Field(
default_factory=list, description="错误信息列表"
)
"""错误信息列表"""

View File

@ -1,6 +1,7 @@
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from nonebot import require from nonebot import require
from nonebot.compat import model_dump
from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.plugin_info import PluginInfo
from zhenxun.services.log import logger from zhenxun.services.log import logger
@ -22,12 +23,12 @@ router = APIRouter(prefix="/store")
async def _() -> Result[dict]: async def _() -> Result[dict]:
try: try:
require("plugin_store") require("plugin_store")
from zhenxun.builtin_plugins.plugin_store import ShopManage from zhenxun.builtin_plugins.plugin_store import StoreManager
data = await ShopManage.get_data() data = await StoreManager.get_data()
plugin_list = [ plugin_list = [
{**data[name].to_dict(), "name": name, "id": idx} {**model_dump(plugin), "name": plugin.name, "id": idx}
for idx, name in enumerate(data) for idx, plugin in enumerate(data)
] ]
modules = await PluginInfo.filter(load_status=True).values_list( modules = await PluginInfo.filter(load_status=True).values_list(
"module", flat=True "module", flat=True
@ -48,9 +49,9 @@ async def _() -> Result[dict]:
async def _(param: PluginIr) -> Result: async def _(param: PluginIr) -> Result:
try: try:
require("plugin_store") require("plugin_store")
from zhenxun.builtin_plugins.plugin_store import ShopManage from zhenxun.builtin_plugins.plugin_store import StoreManager
result = await ShopManage.add_plugin(param.id) # type: ignore result = await StoreManager.add_plugin(param.id) # type: ignore
return Result.ok(info=result) return Result.ok(info=result)
except Exception as e: except Exception as e:
return Result.fail(f"安装插件失败: {type(e)}: {e}") return Result.fail(f"安装插件失败: {type(e)}: {e}")
@ -66,9 +67,9 @@ async def _(param: PluginIr) -> Result:
async def _(param: PluginIr) -> Result: async def _(param: PluginIr) -> Result:
try: try:
require("plugin_store") require("plugin_store")
from zhenxun.builtin_plugins.plugin_store import ShopManage from zhenxun.builtin_plugins.plugin_store import StoreManager
result = await ShopManage.update_plugin(param.id) # type: ignore result = await StoreManager.update_plugin(param.id) # type: ignore
return Result.ok(info=result) return Result.ok(info=result)
except Exception as e: except Exception as e:
return Result.fail(f"更新插件失败: {type(e)}: {e}") return Result.fail(f"更新插件失败: {type(e)}: {e}")
@ -84,9 +85,9 @@ async def _(param: PluginIr) -> Result:
async def _(param: PluginIr) -> Result: async def _(param: PluginIr) -> Result:
try: try:
require("plugin_store") require("plugin_store")
from zhenxun.builtin_plugins.plugin_store import ShopManage from zhenxun.builtin_plugins.plugin_store import StoreManager
result = await ShopManage.remove_plugin(param.id) # type: ignore result = await StoreManager.remove_plugin(param.id) # type: ignore
return Result.ok(info=result) return Result.ok(info=result)
except Exception as e: except Exception as e:
return Result.fail(f"移除插件失败: {type(e)}: {e}") return Result.fail(f"移除插件失败: {type(e)}: {e}")

View File

@ -9,7 +9,7 @@ from fastapi.responses import JSONResponse
from zhenxun.utils._build_image import BuildImage from zhenxun.utils._build_image import BuildImage
from ....base_model import Result, SystemFolderSize from ....base_model import Result, SystemFolderSize
from ....utils import authentication, get_system_disk from ....utils import authentication, get_system_disk, validate_path
from .model import AddFile, DeleteFile, DirFile, RenameFile, SaveFile from .model import AddFile, DeleteFile, DirFile, RenameFile, SaveFile
router = APIRouter(prefix="/system") router = APIRouter(prefix="/system")
@ -25,7 +25,12 @@ IMAGE_TYPE = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"]
description="获取文件列表", description="获取文件列表",
) )
async def _(path: str | None = None) -> Result[list[DirFile]]: async def _(path: str | None = None) -> Result[list[DirFile]]:
base_path = Path(path) if path else Path() try:
base_path, error = validate_path(path)
if error:
return Result.fail(error)
if not base_path:
return Result.fail("无效的路径")
data_list = [] data_list = []
for file in os.listdir(base_path): for file in os.listdir(base_path):
file_path = base_path / file file_path = base_path / file
@ -36,10 +41,14 @@ async def _(path: str | None = None) -> Result[list[DirFile]]:
is_image=is_image, is_image=is_image,
name=file, name=file,
parent=path, parent=path,
size=None if file_path.is_dir() else file_path.stat().st_size,
mtime=file_path.stat().st_mtime,
) )
) )
sorted(data_list, key=lambda f: f.name) sorted(data_list, key=lambda f: f.name)
return Result.ok(data_list) return Result.ok(data_list)
except Exception as e:
return Result.fail(f"获取文件列表失败: {e!s}")
@router.get( @router.get(
@ -61,8 +70,12 @@ async def _(full_path: str | None = None) -> Result[list[SystemFolderSize]]:
description="删除文件", description="删除文件",
) )
async def _(param: DeleteFile) -> Result: async def _(param: DeleteFile) -> Result:
path = Path(param.full_path) path, error = validate_path(param.full_path)
if not path or not path.exists(): if error:
return Result.fail(error)
if not path:
return Result.fail("无效的路径")
if not path.exists():
return Result.warning_("文件不存在...") return Result.warning_("文件不存在...")
try: try:
path.unlink() path.unlink()
@ -79,8 +92,12 @@ async def _(param: DeleteFile) -> Result:
description="删除文件夹", description="删除文件夹",
) )
async def _(param: DeleteFile) -> Result: async def _(param: DeleteFile) -> Result:
path = Path(param.full_path) path, error = validate_path(param.full_path)
if not path or not path.exists() or path.is_file(): if error:
return Result.fail(error)
if not path:
return Result.fail("无效的路径")
if not path.exists() or path.is_file():
return Result.warning_("文件夹不存在...") return Result.warning_("文件夹不存在...")
try: try:
shutil.rmtree(path.absolute()) shutil.rmtree(path.absolute())
@ -97,10 +114,14 @@ async def _(param: DeleteFile) -> Result:
description="重命名文件", description="重命名文件",
) )
async def _(param: RenameFile) -> Result: async def _(param: RenameFile) -> Result:
path = ( parent_path, error = validate_path(param.parent)
(Path(param.parent) / param.old_name) if param.parent else Path(param.old_name) if error:
) return Result.fail(error)
if not path or not path.exists(): if not parent_path:
return Result.fail("无效的路径")
path = (parent_path / param.old_name) if param.parent else Path(param.old_name)
if not path.exists():
return Result.warning_("文件不存在...") return Result.warning_("文件不存在...")
try: try:
path.rename(path.parent / param.name) path.rename(path.parent / param.name)
@ -117,10 +138,14 @@ async def _(param: RenameFile) -> Result:
description="重命名文件夹", description="重命名文件夹",
) )
async def _(param: RenameFile) -> Result: async def _(param: RenameFile) -> Result:
path = ( parent_path, error = validate_path(param.parent)
(Path(param.parent) / param.old_name) if param.parent else Path(param.old_name) if error:
) return Result.fail(error)
if not path or not path.exists() or path.is_file(): if not parent_path:
return Result.fail("无效的路径")
path = (parent_path / param.old_name) if param.parent else Path(param.old_name)
if not path.exists() or path.is_file():
return Result.warning_("文件夹不存在...") return Result.warning_("文件夹不存在...")
try: try:
new_path = path.parent / param.name new_path = path.parent / param.name
@ -138,7 +163,13 @@ async def _(param: RenameFile) -> Result:
description="新建文件", description="新建文件",
) )
async def _(param: AddFile) -> Result: async def _(param: AddFile) -> Result:
path = (Path(param.parent) / param.name) if param.parent else Path(param.name) parent_path, error = validate_path(param.parent)
if error:
return Result.fail(error)
if not parent_path:
return Result.fail("无效的路径")
path = (parent_path / param.name) if param.parent else Path(param.name)
if path.exists(): if path.exists():
return Result.warning_("文件已存在...") return Result.warning_("文件已存在...")
try: try:
@ -156,7 +187,13 @@ async def _(param: AddFile) -> Result:
description="新建文件夹", description="新建文件夹",
) )
async def _(param: AddFile) -> Result: async def _(param: AddFile) -> Result:
path = (Path(param.parent) / param.name) if param.parent else Path(param.name) parent_path, error = validate_path(param.parent)
if error:
return Result.fail(error)
if not parent_path:
return Result.fail("无效的路径")
path = (parent_path / param.name) if param.parent else Path(param.name)
if path.exists(): if path.exists():
return Result.warning_("文件夹已存在...") return Result.warning_("文件夹已存在...")
try: try:
@ -174,7 +211,11 @@ async def _(param: AddFile) -> Result:
description="读取文件", description="读取文件",
) )
async def _(full_path: str) -> Result: async def _(full_path: str) -> Result:
path = Path(full_path) path, error = validate_path(full_path)
if error:
return Result.fail(error)
if not path:
return Result.fail("无效的路径")
if not path.exists(): if not path.exists():
return Result.warning_("文件不存在...") return Result.warning_("文件不存在...")
try: try:
@ -192,9 +233,13 @@ async def _(full_path: str) -> Result:
description="读取文件", description="读取文件",
) )
async def _(param: SaveFile) -> Result[str]: async def _(param: SaveFile) -> Result[str]:
path = Path(param.full_path) path, error = validate_path(param.full_path)
if error:
return Result.fail(error)
if not path:
return Result.fail("无效的路径")
try: try:
async with aiofiles.open(path, "w", encoding="utf-8") as f: async with aiofiles.open(str(path), "w", encoding="utf-8") as f:
await f.write(param.content) await f.write(param.content)
return Result.ok("更新成功!") return Result.ok("更新成功!")
except Exception as e: except Exception as e:
@ -209,10 +254,24 @@ async def _(param: SaveFile) -> Result[str]:
description="读取图片base64", description="读取图片base64",
) )
async def _(full_path: str) -> Result[str]: async def _(full_path: str) -> Result[str]:
path = Path(full_path) path, error = validate_path(full_path)
if error:
return Result.fail(error)
if not path:
return Result.fail("无效的路径")
if not path.exists(): if not path.exists():
return Result.warning_("文件不存在...") return Result.warning_("文件不存在...")
try: try:
return Result.ok(BuildImage.open(path).pic2bs4()) return Result.ok(BuildImage.open(path).pic2bs4())
except Exception as e: except Exception as e:
return Result.warning_(f"获取图片失败: {e!s}") return Result.warning_(f"获取图片失败: {e!s}")
@router.get(
"/ping",
response_model=Result[str],
response_class=JSONResponse,
description="检查服务器状态",
)
async def _() -> Result[str]:
return Result.ok("pong")

View File

@ -14,6 +14,10 @@ class DirFile(BaseModel):
"""文件夹或文件名称""" """文件夹或文件名称"""
parent: str | None = None parent: str | None = None
"""父级""" """父级"""
size: int | None = None
"""文件大小"""
mtime: float | None = None
"""修改时间"""
class DeleteFile(BaseModel): class DeleteFile(BaseModel):

View File

@ -1,6 +1,12 @@
import sys
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import nonebot import nonebot
from strenum import StrEnum
if sys.version_info >= (3, 11):
from enum import StrEnum
else:
from strenum import StrEnum
from zhenxun.configs.path_config import DATA_PATH, TEMP_PATH from zhenxun.configs.path_config import DATA_PATH, TEMP_PATH

View File

@ -18,6 +18,7 @@ async def update_webui_assets():
download_url = await GithubUtils.parse_github_url( download_url = await GithubUtils.parse_github_url(
WEBUI_DIST_GITHUB_URL WEBUI_DIST_GITHUB_URL
).get_archive_download_urls() ).get_archive_download_urls()
logger.info("开始下载 webui_assets 资源...", COMMAND_NAME)
if await AsyncHttpx.download_file( if await AsyncHttpx.download_file(
download_url, webui_assets_path, follow_redirects=True download_url, webui_assets_path, follow_redirects=True
): ):

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