nonebot-plugin-avatar-manager 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 nonebot-plugin-avatar-manager contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.1
2
+ Name: nonebot-plugin-avatar-manager
3
+ Version: 0.1.0
4
+ Summary: NoneBot2 头像管理插件,支持机器人与群资料的立即修改和定时修改。
5
+ Author-Email: Akiyy-Lab <2806578374@qq.com>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: nonebot2>=2.3.1
9
+ Requires-Dist: nonebot-adapter-onebot>=2.4.6
10
+ Requires-Dist: nonebot-plugin-apscheduler>=0.5.0
11
+ Requires-Dist: httpx>=0.27.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ <div align="center">
15
+ <a href="https://v2.nonebot.dev/store"><img src="https://github.com/A-kirami/nonebot-plugin-template/blob/resources/nbp_logo.png" width="180" height="180" alt="NoneBotPluginLogo"></a>
16
+ <br>
17
+ <p><img src="https://github.com/A-kirami/nonebot-plugin-template/blob/resources/NoneBotPlugin.svg" width="240" alt="NoneBotPluginText"></p>
18
+ </div>
19
+
20
+ <div align="center">
21
+
22
+ # nonebot-plugin-avatar-manager
23
+
24
+ _✨ NoneBot2 头像管理插件,支持机器人和群资料的立即修改与定时修改 ✨_
25
+
26
+ <a href="./LICENSE">
27
+ <img src="https://img.shields.io/github/license/Akiyy-dev/nonebot-plugin-avatar-manager.svg" alt="license">
28
+ </a>
29
+ <a href="https://pypi.python.org/pypi/nonebot-plugin-avatar-manager">
30
+ <img src="https://img.shields.io/pypi/v/nonebot-plugin-avatar-manager.svg" alt="pypi">
31
+ </a>
32
+ <img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
33
+
34
+ </div>
35
+
36
+ ## 📖 介绍
37
+
38
+ nonebot-plugin-avatar-manager 是一个基于 NoneBot2 和 OneBot V11 的资料管理插件,提供机器人头像/昵称、群头像/群名称的立即修改和定时修改能力,并支持任务持久化恢复。
39
+
40
+ 插件目前支持的核心场景:
41
+
42
+ - 超级管理员在私聊中查看机器人信息和可管理群列表
43
+ - 群主或管理员在群聊中直接修改当前群头像或群名称
44
+ - 超级管理员修改机器人自身头像或昵称
45
+ - 为群资料或机器人资料创建定时修改任务
46
+ - 启动时自动恢复已保存任务
47
+
48
+ ## 💿 安装
49
+
50
+ <details open>
51
+ <summary>使用 nb-cli 安装</summary>
52
+
53
+ 在 nonebot2 项目的根目录下打开命令行,输入以下指令即可安装:
54
+
55
+ ```bash
56
+ nb plugin install nonebot-plugin-avatar-manager
57
+ ```
58
+
59
+ </details>
60
+
61
+ <details>
62
+ <summary>使用包管理器安装</summary>
63
+
64
+ 在 nonebot2 项目的插件目录下打开命令行,根据你使用的包管理器,输入相应的安装命令。
65
+
66
+ <details>
67
+ <summary>pip</summary>
68
+
69
+ ```bash
70
+ pip install nonebot-plugin-avatar-manager
71
+ ```
72
+
73
+ </details>
74
+
75
+ 安装完成后,打开 nonebot2 项目根目录下的 pyproject.toml 文件,在 `[tool.nonebot]` 部分追加写入:
76
+
77
+ ```toml
78
+ plugins = ["nonebot_plugin_avatar_manager"]
79
+ ```
80
+
81
+ </details>
82
+
83
+ ## ⚙️ 配置
84
+
85
+ 在 nonebot2 项目的 `.env` 文件中添加下表中的配置项:
86
+
87
+ | 配置项 | 必填 | 默认值 | 说明 |
88
+ |:-----:|:----:|:----:|:----|
89
+ | SUPERUSERS | 是 | 无 | NoneBot 超级管理员账号列表,私聊管理机器人资料时必需 |
90
+ | ENABLE_SELF_AVATAR | 否 | true | 是否允许修改机器人自身头像与昵称 |
91
+ | ENABLE_GROUP_AVATAR | 否 | true | 是否允许修改群头像与群名称 |
92
+
93
+ 示例:
94
+
95
+ ```env
96
+ SUPERUSERS=["123456789"]
97
+ ENABLE_SELF_AVATAR=true
98
+ ENABLE_GROUP_AVATAR=true
99
+ ```
100
+
101
+ ## 🎉 使用
102
+
103
+ ### 指令表
104
+
105
+ | 指令 | 权限 | 需要@ | 范围 | 说明 |
106
+ |:----|:----|:----:|:----:|:----|
107
+ | 头像帮助 | 超级管理员 / 群管理员 / 群主 | 否 | 私聊 / 群聊 | 查看插件帮助 |
108
+ | 头像信息 | 超级管理员 | 否 | 私聊 | 查看机器人账号、昵称、头像地址与所在群列表 |
109
+ | 群管 | 超级管理员 | 否 | 私聊 | 查看机器人在哪些群具备管理权限 |
110
+ | 修改 | 群管理员 / 群主 | 否 | 群聊 | 立即修改当前群头像或群名称 |
111
+ | 定时修改 | 群管理员 / 群主 | 否 | 群聊 | 为当前群创建定时修改任务 |
112
+ | bot修改 | 超级管理员 | 否 | 私聊 / 群聊 | 立即修改机器人头像或昵称 |
113
+ | bot定时修改 | 超级管理员 | 否 | 私聊 / 群聊 | 为机器人自身创建定时修改任务 |
114
+ | 定时列表 | 超级管理员 / 群管理员 / 群主 | 否 | 私聊 / 群聊 | 查看任务列表;群聊中只显示当前群任务 |
115
+ | 删除定时 | 超级管理员 / 群管理员 / 群主 | 否 | 私聊 / 群聊 | 删除指定任务;群聊中仅可删除当前群任务 |
116
+
117
+ ### 使用示例
118
+
119
+ ```text
120
+ 修改 https://example.com/avatar.jpg
121
+ 修改 新群名
122
+ 修改 https://example.com/avatar.jpg 新群名
123
+ 定时修改 0 8 * * * https://example.com/avatar.jpg
124
+ 定时修改 0 8 * * * 新群名
125
+ bot修改 https://example.com/avatar.jpg 新昵称
126
+ bot定时修改 0 9 * * 1 https://example.com/avatar.jpg
127
+ 删除定时 avatar_group_20260409100000
128
+ ```
129
+
130
+ ### Cron 示例
131
+
132
+ ```text
133
+ 0 8 * * * 每天 8 点执行
134
+ 0 9 * * 1 每周一 9 点执行
135
+ */30 * * * * 每 30 分钟执行一次
136
+ ```
137
+
138
+ ### 任务存储
139
+
140
+ - `data/avatar_manager/tasks.json`:保存定时任务
141
+ - `data/avatar_manager/temp`:保存下载的临时图片
@@ -0,0 +1,128 @@
1
+ <div align="center">
2
+ <a href="https://v2.nonebot.dev/store"><img src="https://github.com/A-kirami/nonebot-plugin-template/blob/resources/nbp_logo.png" width="180" height="180" alt="NoneBotPluginLogo"></a>
3
+ <br>
4
+ <p><img src="https://github.com/A-kirami/nonebot-plugin-template/blob/resources/NoneBotPlugin.svg" width="240" alt="NoneBotPluginText"></p>
5
+ </div>
6
+
7
+ <div align="center">
8
+
9
+ # nonebot-plugin-avatar-manager
10
+
11
+ _✨ NoneBot2 头像管理插件,支持机器人和群资料的立即修改与定时修改 ✨_
12
+
13
+ <a href="./LICENSE">
14
+ <img src="https://img.shields.io/github/license/Akiyy-dev/nonebot-plugin-avatar-manager.svg" alt="license">
15
+ </a>
16
+ <a href="https://pypi.python.org/pypi/nonebot-plugin-avatar-manager">
17
+ <img src="https://img.shields.io/pypi/v/nonebot-plugin-avatar-manager.svg" alt="pypi">
18
+ </a>
19
+ <img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
20
+
21
+ </div>
22
+
23
+ ## 📖 介绍
24
+
25
+ nonebot-plugin-avatar-manager 是一个基于 NoneBot2 和 OneBot V11 的资料管理插件,提供机器人头像/昵称、群头像/群名称的立即修改和定时修改能力,并支持任务持久化恢复。
26
+
27
+ 插件目前支持的核心场景:
28
+
29
+ - 超级管理员在私聊中查看机器人信息和可管理群列表
30
+ - 群主或管理员在群聊中直接修改当前群头像或群名称
31
+ - 超级管理员修改机器人自身头像或昵称
32
+ - 为群资料或机器人资料创建定时修改任务
33
+ - 启动时自动恢复已保存任务
34
+
35
+ ## 💿 安装
36
+
37
+ <details open>
38
+ <summary>使用 nb-cli 安装</summary>
39
+
40
+ 在 nonebot2 项目的根目录下打开命令行,输入以下指令即可安装:
41
+
42
+ ```bash
43
+ nb plugin install nonebot-plugin-avatar-manager
44
+ ```
45
+
46
+ </details>
47
+
48
+ <details>
49
+ <summary>使用包管理器安装</summary>
50
+
51
+ 在 nonebot2 项目的插件目录下打开命令行,根据你使用的包管理器,输入相应的安装命令。
52
+
53
+ <details>
54
+ <summary>pip</summary>
55
+
56
+ ```bash
57
+ pip install nonebot-plugin-avatar-manager
58
+ ```
59
+
60
+ </details>
61
+
62
+ 安装完成后,打开 nonebot2 项目根目录下的 pyproject.toml 文件,在 `[tool.nonebot]` 部分追加写入:
63
+
64
+ ```toml
65
+ plugins = ["nonebot_plugin_avatar_manager"]
66
+ ```
67
+
68
+ </details>
69
+
70
+ ## ⚙️ 配置
71
+
72
+ 在 nonebot2 项目的 `.env` 文件中添加下表中的配置项:
73
+
74
+ | 配置项 | 必填 | 默认值 | 说明 |
75
+ |:-----:|:----:|:----:|:----|
76
+ | SUPERUSERS | 是 | 无 | NoneBot 超级管理员账号列表,私聊管理机器人资料时必需 |
77
+ | ENABLE_SELF_AVATAR | 否 | true | 是否允许修改机器人自身头像与昵称 |
78
+ | ENABLE_GROUP_AVATAR | 否 | true | 是否允许修改群头像与群名称 |
79
+
80
+ 示例:
81
+
82
+ ```env
83
+ SUPERUSERS=["123456789"]
84
+ ENABLE_SELF_AVATAR=true
85
+ ENABLE_GROUP_AVATAR=true
86
+ ```
87
+
88
+ ## 🎉 使用
89
+
90
+ ### 指令表
91
+
92
+ | 指令 | 权限 | 需要@ | 范围 | 说明 |
93
+ |:----|:----|:----:|:----:|:----|
94
+ | 头像帮助 | 超级管理员 / 群管理员 / 群主 | 否 | 私聊 / 群聊 | 查看插件帮助 |
95
+ | 头像信息 | 超级管理员 | 否 | 私聊 | 查看机器人账号、昵称、头像地址与所在群列表 |
96
+ | 群管 | 超级管理员 | 否 | 私聊 | 查看机器人在哪些群具备管理权限 |
97
+ | 修改 | 群管理员 / 群主 | 否 | 群聊 | 立即修改当前群头像或群名称 |
98
+ | 定时修改 | 群管理员 / 群主 | 否 | 群聊 | 为当前群创建定时修改任务 |
99
+ | bot修改 | 超级管理员 | 否 | 私聊 / 群聊 | 立即修改机器人头像或昵称 |
100
+ | bot定时修改 | 超级管理员 | 否 | 私聊 / 群聊 | 为机器人自身创建定时修改任务 |
101
+ | 定时列表 | 超级管理员 / 群管理员 / 群主 | 否 | 私聊 / 群聊 | 查看任务列表;群聊中只显示当前群任务 |
102
+ | 删除定时 | 超级管理员 / 群管理员 / 群主 | 否 | 私聊 / 群聊 | 删除指定任务;群聊中仅可删除当前群任务 |
103
+
104
+ ### 使用示例
105
+
106
+ ```text
107
+ 修改 https://example.com/avatar.jpg
108
+ 修改 新群名
109
+ 修改 https://example.com/avatar.jpg 新群名
110
+ 定时修改 0 8 * * * https://example.com/avatar.jpg
111
+ 定时修改 0 8 * * * 新群名
112
+ bot修改 https://example.com/avatar.jpg 新昵称
113
+ bot定时修改 0 9 * * 1 https://example.com/avatar.jpg
114
+ 删除定时 avatar_group_20260409100000
115
+ ```
116
+
117
+ ### Cron 示例
118
+
119
+ ```text
120
+ 0 8 * * * 每天 8 点执行
121
+ 0 9 * * 1 每周一 9 点执行
122
+ */30 * * * * 每 30 分钟执行一次
123
+ ```
124
+
125
+ ### 任务存储
126
+
127
+ - `data/avatar_manager/tasks.json`:保存定时任务
128
+ - `data/avatar_manager/temp`:保存下载的临时图片
@@ -0,0 +1,104 @@
1
+ [project]
2
+ name = "nonebot-plugin-avatar-manager"
3
+ version = "0.1.0"
4
+ description = "NoneBot2 头像管理插件,支持机器人与群资料的立即修改和定时修改。"
5
+ authors = [
6
+ { name = "Akiyy-Lab", email = "2806578374@qq.com" },
7
+ ]
8
+ dependencies = [
9
+ "nonebot2>=2.3.1",
10
+ "nonebot-adapter-onebot>=2.4.6",
11
+ "nonebot-plugin-apscheduler>=0.5.0",
12
+ "httpx>=0.27.0",
13
+ ]
14
+ requires-python = ">=3.10"
15
+ readme = "README.md"
16
+
17
+ [project.license]
18
+ text = "MIT"
19
+
20
+ [build-system]
21
+ requires = [
22
+ "pdm-backend",
23
+ ]
24
+ build-backend = "pdm.backend"
25
+
26
+ [tool.pdm]
27
+ distribution = true
28
+
29
+ [tool.pdm.build]
30
+ includes = [
31
+ "src",
32
+ ]
33
+
34
+ [tool.pdm.dev-dependencies]
35
+ dev = [
36
+ "black>=24.4.2",
37
+ "isort>=5.13.2",
38
+ "ruff>=0.4.6",
39
+ ]
40
+
41
+ [tool.pdm.scripts]
42
+ test = "python -c 'print(\">>> Just \\\"pytest -W ignore -s\\\" when you complete your testsuite\")'"
43
+
44
+ [tool.pdm.scripts.format]
45
+ composite = [
46
+ "isort . ",
47
+ "black . ",
48
+ "ruff check .",
49
+ ]
50
+
51
+ [tool.black]
52
+ line-length = 90
53
+ target-version = [
54
+ "py310",
55
+ "py311",
56
+ "py312",
57
+ ]
58
+ include = "\\\\.pyi?$"
59
+ extend-exclude = ""
60
+
61
+ [tool.isort]
62
+ profile = "black"
63
+ line_length = 90
64
+ length_sort = true
65
+ skip_gitignore = true
66
+ force_sort_within_sections = true
67
+ extra_standard_library = [
68
+ "typing_extensions",
69
+ ]
70
+
71
+ [tool.ruff]
72
+ line-length = 90
73
+ target-version = "py310"
74
+
75
+ [tool.ruff.lint]
76
+ select = [
77
+ "E",
78
+ "W",
79
+ "F",
80
+ "UP",
81
+ "C",
82
+ "T",
83
+ "PYI",
84
+ "PT",
85
+ "Q",
86
+ ]
87
+ ignore = [
88
+ "C901",
89
+ "T201",
90
+ "E731",
91
+ "E402",
92
+ ]
93
+
94
+ [tool.pyright]
95
+ pythonVersion = "3.10"
96
+ pythonPlatform = "All"
97
+ typeCheckingMode = "basic"
98
+
99
+ [tool.nonebot]
100
+ adapters = []
101
+ plugin_dirs = [
102
+ "src",
103
+ ]
104
+ builtin_plugins = []
@@ -0,0 +1,36 @@
1
+ # nonebot2
2
+ # nonebot-adapter-onebot
3
+ # nonebot-plugin-apscheduler
4
+
5
+ from nonebot import get_driver, logger, require
6
+ from nonebot.plugin import PluginMetadata
7
+
8
+ require("nonebot_plugin_apscheduler")
9
+
10
+ from . import handlers # noqa: E402,F401
11
+ from . import scheduler # noqa: E402
12
+
13
+ __plugin_meta__ = PluginMetadata(
14
+ name="头像管理器",
15
+ description="支持定时修改机器人自身头像/昵称以及群头像/群名称(基于 OneBot V11)",
16
+ usage="发送 头像帮助 查看详细指令",
17
+ type="application",
18
+ supported_adapters={"~onebot.v11"},
19
+ )
20
+
21
+ driver = get_driver()
22
+
23
+
24
+ @driver.on_startup
25
+ async def _on_startup() -> None:
26
+ await scheduler.init_scheduler()
27
+
28
+
29
+ @driver.on_shutdown
30
+ async def _on_shutdown() -> None:
31
+ await scheduler.cleanup_temp_files()
32
+
33
+
34
+ __all__ = ["__plugin_meta__", "handlers", "scheduler"]
35
+
36
+ logger.success("头像管理器插件加载完成")
@@ -0,0 +1,7 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class Config(BaseModel):
5
+ superusers: list[str] = Field(default_factory=list)
6
+ enable_self_avatar: bool = True
7
+ enable_group_avatar: bool = True
@@ -0,0 +1,438 @@
1
+ import shlex
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ from nonebot import get_driver, logger, on_command
6
+ from nonebot.adapters.onebot.v11 import (
7
+ Bot,
8
+ GroupMessageEvent,
9
+ Message,
10
+ PrivateMessageEvent,
11
+ )
12
+ from nonebot.adapters.onebot.v11.permission import GROUP_ADMIN, GROUP_OWNER
13
+ from nonebot.params import CommandArg
14
+ from nonebot.permission import SUPERUSER
15
+ from nonebot.rule import Rule
16
+
17
+ from .config import Config
18
+ from .models import ScheduleTask
19
+ from .scheduler import add_job, remove_job, run_task_now, tasks
20
+ from .utils import download_image
21
+
22
+ driver = get_driver()
23
+ plugin_config = Config.model_validate(driver.config.dict())
24
+ manage_permission = SUPERUSER | GROUP_ADMIN | GROUP_OWNER
25
+
26
+
27
+ async def _private_only(event: PrivateMessageEvent) -> bool:
28
+ return True
29
+
30
+
31
+ async def _group_only(event: GroupMessageEvent) -> bool:
32
+ return True
33
+
34
+
35
+ def _looks_like_url(value: str) -> bool:
36
+ return value.startswith(("http://", "https://"))
37
+
38
+
39
+ avatar_help = on_command(
40
+ "头像帮助",
41
+ aliases={"avatar_help"},
42
+ permission=manage_permission,
43
+ priority=5,
44
+ block=True,
45
+ )
46
+
47
+ avatar_info = on_command(
48
+ "头像信息",
49
+ aliases={"avatar_info"},
50
+ permission=SUPERUSER,
51
+ rule=Rule(_private_only),
52
+ priority=5,
53
+ block=True,
54
+ )
55
+
56
+ group_manage = on_command(
57
+ "群管",
58
+ permission=SUPERUSER,
59
+ rule=Rule(_private_only),
60
+ priority=5,
61
+ block=True,
62
+ )
63
+
64
+ group_modify = on_command(
65
+ "修改",
66
+ permission=GROUP_ADMIN | GROUP_OWNER,
67
+ rule=Rule(_group_only),
68
+ priority=5,
69
+ block=True,
70
+ )
71
+
72
+ group_schedule = on_command(
73
+ "定时修改",
74
+ permission=GROUP_ADMIN | GROUP_OWNER,
75
+ rule=Rule(_group_only),
76
+ priority=5,
77
+ block=True,
78
+ )
79
+
80
+ bot_modify = on_command(
81
+ "bot修改",
82
+ permission=SUPERUSER,
83
+ priority=5,
84
+ block=True,
85
+ )
86
+
87
+ bot_schedule = on_command(
88
+ "bot定时修改",
89
+ permission=SUPERUSER,
90
+ priority=5,
91
+ block=True,
92
+ )
93
+
94
+ schedule_list = on_command(
95
+ "定时列表",
96
+ aliases={"schedule_list"},
97
+ permission=manage_permission,
98
+ priority=5,
99
+ block=True,
100
+ )
101
+
102
+ del_schedule = on_command(
103
+ "删除定时",
104
+ aliases={"del_schedule"},
105
+ permission=manage_permission,
106
+ priority=5,
107
+ block=True,
108
+ )
109
+
110
+
111
+ def _extract_image_input(arg: Message) -> str | None:
112
+ for segment in arg:
113
+ if segment.type != "image":
114
+ continue
115
+
116
+ image_url = segment.data.get("url")
117
+ if image_url:
118
+ return str(image_url)
119
+
120
+ image_file = segment.data.get("file")
121
+ if image_file and Path(str(image_file)).exists():
122
+ return str(image_file)
123
+
124
+ return None
125
+
126
+
127
+ def _build_job_id(target_type: str) -> str:
128
+ return f"avatar_{target_type}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
129
+
130
+
131
+ async def _resolve_image_value(image_input: str | None) -> str | None:
132
+ if image_input is None:
133
+ return None
134
+
135
+ if image_input.startswith(("http://", "https://")):
136
+ downloaded_path = await download_image(image_input)
137
+ if downloaded_path is None:
138
+ raise ValueError("图片下载失败")
139
+ return str(downloaded_path)
140
+
141
+ return image_input
142
+
143
+
144
+ async def _parse_modify_payload(arg: Message) -> tuple[str | None, str | None]:
145
+ plain_text = arg.extract_plain_text().strip()
146
+ parts = shlex.split(plain_text) if plain_text else []
147
+
148
+ image_input = _extract_image_input(arg)
149
+ if image_input is None and parts and _looks_like_url(parts[0]):
150
+ image_input = parts.pop(0)
151
+
152
+ image_path_value = await _resolve_image_value(image_input)
153
+ new_name = " ".join(parts).strip() or None
154
+ if image_path_value is None and new_name is None:
155
+ raise ValueError("至少提供头像图片或新名称之一")
156
+
157
+ return image_path_value, new_name
158
+
159
+
160
+ async def _parse_timed_modify_payload(arg: Message) -> tuple[str, str | None, str | None]:
161
+ plain_text = arg.extract_plain_text().strip()
162
+ if not plain_text:
163
+ raise ValueError("参数不能为空")
164
+
165
+ parts = shlex.split(plain_text)
166
+ if len(parts) < 5:
167
+ raise ValueError("cron 格式错误,需要 5 段表达式")
168
+
169
+ cron = " ".join(parts[:5])
170
+ payload_parts = parts[5:]
171
+
172
+ image_input = _extract_image_input(arg)
173
+ if image_input is None and payload_parts and _looks_like_url(payload_parts[0]):
174
+ image_input = payload_parts.pop(0)
175
+
176
+ image_path_value = await _resolve_image_value(image_input)
177
+ new_name = " ".join(payload_parts).strip() or None
178
+ if image_path_value is None and new_name is None:
179
+ raise ValueError("至少提供头像图片或新名称之一")
180
+
181
+ return cron, image_path_value, new_name
182
+
183
+
184
+ @avatar_help.handle()
185
+ async def avatar_help_handler(
186
+ event: PrivateMessageEvent | GroupMessageEvent, bot: Bot, arg=CommandArg()
187
+ ) -> None:
188
+ help_text = """
189
+ 头像管理器
190
+
191
+ 可用命令:
192
+ - 头像帮助 / avatar_help
193
+ - 头像信息 / avatar_info
194
+ - 群管
195
+ - 修改
196
+ - 定时修改
197
+ - bot修改
198
+ - bot定时修改
199
+ - 定时列表 / schedule_list
200
+ - 删除定时 / del_schedule
201
+
202
+ 示例:
203
+ - 群聊中发送:修改 https://example.com/avatar.jpg
204
+ - 群聊中发送:修改 example
205
+ - 群聊中发送:修改 https://example.com/avatar.jpg example
206
+ - 群聊中发送:定时修改 0 8 * * * https://example.com/avatar.jpg
207
+ - 私聊或群聊中超级管理员发送:bot修改 https://example.com/avatar.jpg
208
+ - 私聊或群聊中超级管理员发送:bot定时修改 0 8 * * * https://example.com/avatar.jpg
209
+
210
+ 权限说明:
211
+ - 私聊中:仅超级管理员可操作全部目标
212
+ - 群聊中:群管理员和群主可配置当前群
213
+
214
+ 注意:
215
+ - 具体 API 可用性取决于你使用的 OneBot V11 实现。
216
+ """.strip()
217
+ await avatar_help.finish(help_text)
218
+
219
+
220
+ @avatar_info.handle()
221
+ async def avatar_info_handler(
222
+ event: PrivateMessageEvent, bot: Bot, arg=CommandArg()
223
+ ) -> None:
224
+ try:
225
+ login_info = await bot.get_login_info()
226
+ group_list = await bot.get_group_list()
227
+ except Exception as exception:
228
+ await avatar_info.finish(f"获取头像信息失败: {exception}")
229
+
230
+ lines = [
231
+ "头像管理器信息",
232
+ f"机器人 QQ: {bot.self_id}",
233
+ f"机器人昵称: {login_info.get('nickname', '未知')}",
234
+ f"机器人头像: http://q.qlogo.cn/headimg_dl?dst_uin={bot.self_id}&spec=640",
235
+ "所在群列表:",
236
+ ]
237
+
238
+ for group in group_list:
239
+ group_id = int(group["group_id"])
240
+ group_name = str(group.get("group_name", "未知群名"))
241
+ lines.append(
242
+ f"- {group_name} ({group_id}) | 群头像: http://p.qlogo.cn/gh/{group_id}/{group_id}/640"
243
+ )
244
+
245
+ await avatar_info.finish("\n".join(lines))
246
+
247
+
248
+ @group_manage.handle()
249
+ async def group_manage_handler(
250
+ event: PrivateMessageEvent, bot: Bot, arg=CommandArg()
251
+ ) -> None:
252
+ try:
253
+ group_list = await bot.get_group_list()
254
+ except Exception as exception:
255
+ await group_manage.finish(f"获取群列表失败: {exception}")
256
+
257
+ manageable_groups: list[str] = []
258
+ for group in group_list:
259
+ group_id = int(group["group_id"])
260
+ try:
261
+ member_info = await bot.get_group_member_info(
262
+ group_id=group_id,
263
+ user_id=int(bot.self_id),
264
+ )
265
+ except Exception as exception:
266
+ logger.warning(f"查询群 {group_id} 权限失败: {exception}")
267
+ continue
268
+
269
+ role = str(member_info.get("role", "member"))
270
+ if role in {"owner", "admin"}:
271
+ group_name = str(group.get("group_name", "未知群名"))
272
+ manageable_groups.append(f"- {group_id} | {group_name} | {role}")
273
+
274
+ if not manageable_groups:
275
+ await group_manage.finish("无管理权限")
276
+
277
+ await group_manage.finish("可管理群列表:\n" + "\n".join(manageable_groups))
278
+
279
+
280
+ @group_modify.handle()
281
+ async def group_modify_handler(
282
+ event: GroupMessageEvent, bot: Bot, arg=CommandArg()
283
+ ) -> None:
284
+ try:
285
+ if not plugin_config.enable_group_avatar:
286
+ await group_modify.finish("当前未启用群头像/群名称修改功能")
287
+
288
+ image_path, new_name = await _parse_modify_payload(arg)
289
+ task = ScheduleTask(
290
+ job_id=_build_job_id("group"),
291
+ target_type="group",
292
+ target_id=int(event.group_id),
293
+ cron="0 0 1 1 *",
294
+ new_name=new_name,
295
+ image_path=image_path,
296
+ )
297
+ success, message = await run_task_now(task)
298
+ if not success:
299
+ await group_modify.finish(f"立即修改失败: {message}")
300
+ except ValueError as exception:
301
+ await group_modify.finish(str(exception))
302
+ except Exception as exception:
303
+ await group_modify.finish(f"立即修改失败: {exception}")
304
+
305
+ await group_modify.finish("已立即修改当前群配置")
306
+
307
+
308
+ @group_schedule.handle()
309
+ async def group_schedule_handler(
310
+ event: GroupMessageEvent, bot: Bot, arg=CommandArg()
311
+ ) -> None:
312
+ try:
313
+ if not plugin_config.enable_group_avatar:
314
+ await group_schedule.finish("当前未启用群头像/群名称修改功能")
315
+
316
+ cron, image_path, new_name = await _parse_timed_modify_payload(arg)
317
+ task = ScheduleTask(
318
+ job_id=_build_job_id("group"),
319
+ target_type="group",
320
+ target_id=int(event.group_id),
321
+ cron=cron,
322
+ new_name=new_name,
323
+ image_path=image_path,
324
+ )
325
+ add_job(task)
326
+ except ValueError as exception:
327
+ await group_schedule.finish(str(exception))
328
+ except Exception as exception:
329
+ await group_schedule.finish(f"添加定时任务失败: {exception}")
330
+
331
+ await group_schedule.finish(f"已添加定时任务 ID: {task.job_id}")
332
+
333
+
334
+ @bot_modify.handle()
335
+ async def bot_modify_handler(
336
+ event: PrivateMessageEvent | GroupMessageEvent, bot: Bot, arg=CommandArg()
337
+ ) -> None:
338
+ try:
339
+ if not plugin_config.enable_self_avatar:
340
+ await bot_modify.finish("当前未启用机器人自身头像/昵称修改功能")
341
+
342
+ image_path, new_name = await _parse_modify_payload(arg)
343
+ task = ScheduleTask(
344
+ job_id=_build_job_id("self"),
345
+ target_type="self",
346
+ cron="0 0 1 1 *",
347
+ new_name=new_name,
348
+ image_path=image_path,
349
+ )
350
+ success, message = await run_task_now(task)
351
+ if not success:
352
+ await bot_modify.finish(f"立即修改失败: {message}")
353
+ except ValueError as exception:
354
+ await bot_modify.finish(str(exception))
355
+ except Exception as exception:
356
+ await bot_modify.finish(f"立即修改失败: {exception}")
357
+
358
+ await bot_modify.finish("已立即修改机器人配置")
359
+
360
+
361
+ @bot_schedule.handle()
362
+ async def bot_schedule_handler(
363
+ event: PrivateMessageEvent | GroupMessageEvent, bot: Bot, arg=CommandArg()
364
+ ) -> None:
365
+ try:
366
+ if not plugin_config.enable_self_avatar:
367
+ await bot_schedule.finish("当前未启用机器人自身头像/昵称修改功能")
368
+
369
+ cron, image_path, new_name = await _parse_timed_modify_payload(arg)
370
+ task = ScheduleTask(
371
+ job_id=_build_job_id("self"),
372
+ target_type="self",
373
+ cron=cron,
374
+ new_name=new_name,
375
+ image_path=image_path,
376
+ )
377
+ add_job(task)
378
+ except ValueError as exception:
379
+ await bot_schedule.finish(str(exception))
380
+ except Exception as exception:
381
+ await bot_schedule.finish(f"添加定时任务失败: {exception}")
382
+
383
+ await bot_schedule.finish(f"已添加定时任务 ID: {task.job_id}")
384
+
385
+
386
+ @schedule_list.handle()
387
+ async def schedule_list_handler(
388
+ event: PrivateMessageEvent | GroupMessageEvent, bot: Bot, arg=CommandArg()
389
+ ) -> None:
390
+ filtered_tasks = list(tasks.values())
391
+ if isinstance(event, GroupMessageEvent):
392
+ filtered_tasks = [
393
+ task
394
+ for task in filtered_tasks
395
+ if task.target_type == "group" and task.target_id == int(event.group_id)
396
+ ]
397
+
398
+ if not filtered_tasks:
399
+ await schedule_list.finish("当前没有定时任务")
400
+
401
+ lines = [
402
+ " | ".join(
403
+ [
404
+ f"- {task.job_id}",
405
+ f"target={task.target_type}",
406
+ f"target_id={task.target_id or '-'}",
407
+ f"cron={task.cron}",
408
+ f"name={task.new_name or '-'}",
409
+ f"image={task.image_path or '-'}",
410
+ ]
411
+ )
412
+ for task in filtered_tasks
413
+ ]
414
+ await schedule_list.finish("已保存定时任务:\n" + "\n".join(lines))
415
+
416
+
417
+ @del_schedule.handle()
418
+ async def del_schedule_handler(
419
+ event: PrivateMessageEvent | GroupMessageEvent, bot: Bot, arg=CommandArg()
420
+ ) -> None:
421
+ job_id = arg.extract_plain_text().strip()
422
+ if not job_id:
423
+ await del_schedule.finish("请提供要删除的任务 ID")
424
+
425
+ task = tasks.get(job_id)
426
+ if isinstance(event, GroupMessageEvent):
427
+ invalid_group_task = (
428
+ task is None
429
+ or task.target_type != "group"
430
+ or task.target_id != int(event.group_id)
431
+ )
432
+ if invalid_group_task:
433
+ await del_schedule.finish("未找到本群对应的任务 ID")
434
+
435
+ if not remove_job(job_id):
436
+ await del_schedule.finish(f"未找到任务 ID: {job_id}")
437
+
438
+ await del_schedule.finish(f"已删除定时任务 ID: {job_id}")
@@ -0,0 +1,13 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class ScheduleTask(BaseModel):
7
+ job_id: str
8
+ target_type: str
9
+ target_id: int | None = None
10
+ cron: str
11
+ new_name: str | None = None
12
+ image_path: str | None = None
13
+ create_time: datetime = Field(default_factory=datetime.now)
@@ -0,0 +1,213 @@
1
+ import asyncio
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import nonebot
6
+ from nonebot import logger
7
+ from nonebot.adapters.onebot.v11 import Bot
8
+ from nonebot.exception import ActionFailed
9
+ from nonebot_plugin_apscheduler import scheduler
10
+
11
+ from .models import ScheduleTask
12
+ from .utils import TEMP_DIR, image_to_base64
13
+
14
+ tasks: dict[str, ScheduleTask] = {}
15
+ data_dir = Path("data/avatar_manager")
16
+ tasks_file = data_dir / "tasks.json"
17
+
18
+
19
+ def load_tasks() -> dict[str, ScheduleTask]:
20
+ if not tasks_file.exists():
21
+ return {}
22
+
23
+ try:
24
+ raw_data = json.loads(tasks_file.read_text(encoding="utf-8"))
25
+ except (json.JSONDecodeError, OSError) as exception:
26
+ logger.error(f"读取任务文件失败: {exception}")
27
+ return {}
28
+
29
+ loaded_tasks: dict[str, ScheduleTask] = {}
30
+ for job_id, task_data in raw_data.items():
31
+ try:
32
+ loaded_tasks[job_id] = ScheduleTask.model_validate(task_data)
33
+ except Exception as exception:
34
+ logger.error(f"加载任务 {job_id} 失败: {exception}")
35
+ return loaded_tasks
36
+
37
+
38
+ def save_tasks() -> None:
39
+ try:
40
+ data_dir.mkdir(parents=True, exist_ok=True)
41
+ payload = {
42
+ job_id: task.model_dump(mode="json")
43
+ for job_id, task in tasks.items()
44
+ }
45
+ tasks_file.write_text(
46
+ json.dumps(payload, ensure_ascii=False, indent=2),
47
+ encoding="utf-8",
48
+ )
49
+ except OSError as exception:
50
+ logger.error(f"保存任务文件失败: {exception}")
51
+
52
+
53
+ def _cron_to_kwargs(cron: str) -> dict[str, str]:
54
+ parts = cron.split()
55
+ if len(parts) != 5:
56
+ raise ValueError("cron 格式错误,需要 5 段表达式")
57
+
58
+ return {
59
+ "minute": parts[0],
60
+ "hour": parts[1],
61
+ "day": parts[2],
62
+ "month": parts[3],
63
+ "day_of_week": parts[4],
64
+ }
65
+
66
+
67
+ async def _resolve_bot() -> Bot | None:
68
+ bot = next(
69
+ (
70
+ candidate
71
+ for candidate in nonebot.get_bots().values()
72
+ if isinstance(candidate, Bot)
73
+ ),
74
+ None,
75
+ )
76
+ if bot is None:
77
+ logger.warning("当前没有可用的 OneBot V11 Bot,任务已跳过")
78
+ return bot
79
+
80
+
81
+ async def change_avatar_job(task: ScheduleTask, bot: Bot) -> tuple[bool, str]:
82
+ try:
83
+ upload_payload: str | None = None
84
+ if task.image_path:
85
+ image_path = Path(task.image_path)
86
+ if image_path.exists():
87
+ base64_str = await image_to_base64(image_path)
88
+ upload_payload = f"base64://{base64_str}"
89
+ else:
90
+ message = f"任务 {task.job_id} 的图片不存在: {task.image_path}"
91
+ logger.warning(message)
92
+ return False, message
93
+
94
+ if task.target_type == "self":
95
+ if upload_payload is not None:
96
+ await bot.call_api("set_qq_avatar", file=upload_payload)
97
+ if task.new_name:
98
+ await bot.call_api("set_qq_profile", nickname=task.new_name)
99
+ elif task.target_type == "group" and task.target_id is not None:
100
+ if upload_payload is not None:
101
+ await bot.call_api(
102
+ "set_group_portrait",
103
+ group_id=task.target_id,
104
+ file=upload_payload,
105
+ )
106
+ if task.new_name:
107
+ await bot.call_api(
108
+ "set_group_name",
109
+ group_id=task.target_id,
110
+ group_name=task.new_name,
111
+ )
112
+ else:
113
+ message = f"任务 {task.job_id} 的目标配置无效,已跳过执行"
114
+ logger.warning(message)
115
+ return False, message
116
+
117
+ success_message = f"定时任务执行成功: {task.job_id}"
118
+ logger.success(success_message)
119
+ return True, success_message
120
+ except ActionFailed as exception:
121
+ message = f"任务 {task.job_id} 调用 API 失败: {exception}"
122
+ logger.error(message)
123
+ return False, message
124
+ except Exception as exception:
125
+ message = f"任务 {task.job_id} 执行异常: {exception}"
126
+ logger.exception(message)
127
+ return False, message
128
+
129
+
130
+ async def _run_task(job_id: str) -> None:
131
+ task = tasks.get(job_id)
132
+ if task is None:
133
+ logger.warning(f"未找到任务 ID: {job_id}")
134
+ return
135
+
136
+ bot = await _resolve_bot()
137
+ if bot is None:
138
+ return
139
+
140
+ await change_avatar_job(task, bot)
141
+
142
+
143
+ async def run_task_now(task: ScheduleTask) -> tuple[bool, str]:
144
+ bot = await _resolve_bot()
145
+ if bot is None:
146
+ return False, "当前没有可用的 OneBot V11 Bot"
147
+
148
+ return await change_avatar_job(task, bot)
149
+
150
+
151
+ def add_job(task: ScheduleTask) -> None:
152
+ cron_kwargs = _cron_to_kwargs(task.cron)
153
+ tasks[task.job_id] = task
154
+ scheduler.add_job(
155
+ _run_task,
156
+ "cron",
157
+ id=task.job_id,
158
+ args=[task.job_id],
159
+ replace_existing=True,
160
+ **cron_kwargs,
161
+ )
162
+ save_tasks()
163
+
164
+
165
+ def remove_job(job_id: str) -> bool:
166
+ task = tasks.pop(job_id, None)
167
+ if task is None:
168
+ return False
169
+
170
+ try:
171
+ scheduler.remove_job(job_id)
172
+ except Exception as exception:
173
+ logger.warning(f"移除调度任务失败: {job_id} | error={exception}")
174
+
175
+ save_tasks()
176
+ return True
177
+
178
+
179
+ async def init_scheduler() -> None:
180
+ tasks.clear()
181
+ tasks.update(load_tasks())
182
+
183
+ restored_count = 0
184
+ for task in tasks.values():
185
+ try:
186
+ cron_kwargs = _cron_to_kwargs(task.cron)
187
+ scheduler.add_job(
188
+ _run_task,
189
+ "cron",
190
+ id=task.job_id,
191
+ args=[task.job_id],
192
+ replace_existing=True,
193
+ **cron_kwargs,
194
+ )
195
+ restored_count += 1
196
+ except Exception as exception:
197
+ logger.error(f"恢复任务失败: {task.job_id} | error={exception}")
198
+
199
+ logger.info(f"头像管理器定时任务已恢复,共 {restored_count} 个任务")
200
+
201
+
202
+ async def cleanup_temp_files() -> None:
203
+ if not TEMP_DIR.exists():
204
+ return
205
+
206
+ for path in TEMP_DIR.iterdir():
207
+ if not path.is_file():
208
+ continue
209
+
210
+ try:
211
+ await asyncio.to_thread(path.unlink)
212
+ except OSError as exception:
213
+ logger.warning(f"清理临时文件失败: {path} | error={exception}")
@@ -0,0 +1,46 @@
1
+ import asyncio
2
+ import base64
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ import httpx
7
+ from nonebot import logger
8
+
9
+ TEMP_DIR = Path("data/avatar_manager/temp")
10
+
11
+
12
+ async def download_image(url: str) -> Path | None:
13
+ """下载图片并保存到 data/temp 目录,失败时返回 None。"""
14
+ try:
15
+ TEMP_DIR.mkdir(parents=True, exist_ok=True)
16
+ suffix = Path(url.split("?", maxsplit=1)[0]).suffix or ".jpg"
17
+
18
+ async with httpx.AsyncClient(
19
+ timeout=httpx.Timeout(20.0, connect=10.0),
20
+ follow_redirects=True,
21
+ ) as client:
22
+ response = await client.get(url)
23
+ response.raise_for_status()
24
+
25
+ def _write_file() -> Path:
26
+ with tempfile.NamedTemporaryFile(
27
+ dir=TEMP_DIR,
28
+ prefix="avatar_",
29
+ suffix=suffix,
30
+ delete=False,
31
+ ) as temp_file:
32
+ temp_file.write(response.content)
33
+ return Path(temp_file.name)
34
+
35
+ return await asyncio.to_thread(_write_file)
36
+ except httpx.HTTPError as exception:
37
+ logger.error(f"下载图片失败: {exception}")
38
+ except OSError as exception:
39
+ logger.error(f"写入图片文件失败: {exception}")
40
+
41
+ return None
42
+
43
+
44
+ async def image_to_base64(image_path: Path) -> str:
45
+ image_bytes = await asyncio.to_thread(image_path.read_bytes)
46
+ return base64.b64encode(image_bytes).decode("utf-8")