nonebot-plugin-dancecube 0.1.2__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) 2026 1v7w
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,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: nonebot-plugin-dancecube
3
+ Version: 0.1.2
4
+ Summary: 一个简单的舞立方插件
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Author: 1v7w
8
+ Author-email: gascd11@163.com
9
+ Requires-Python: >=3.10
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Dist: Pillow (>=10.0.0)
18
+ Requires-Dist: httpx (>=0.24.0)
19
+ Requires-Dist: nonebot-adapter-onebot (>=2.2.0)
20
+ Requires-Dist: nonebot2 (>=2.2.0)
21
+ Requires-Dist: nonebot_plugin_apscheduler (>=0.3.0)
22
+ Requires-Dist: nonebot_plugin_htmlrender (>=0.3.0)
23
+ Requires-Dist: nonebot_plugin_localstore (>=0.7.0)
24
+ Requires-Dist: pydantic (>=2.0.0)
25
+ Project-URL: Bug-Tracker, https://github.com/1v7w/nonebot-plugin-dancecube
26
+ Project-URL: Homepage, https://github.com/1v7w/nonebot-plugin-dancecube
27
+ Project-URL: Repository, https://github.com/1v7w/nonebot-plugin-dancecube
28
+ Description-Content-Type: text/markdown
29
+
30
+ <div align="center">
31
+ <a href="https://v2.nonebot.dev/store"><img src="https://github.com/A-kirami/nonebot-plugin-dancecube/blob/resources/nbp_logo.png" width="180" height="180" alt="NoneBotPluginLogo"></a>
32
+ <br>
33
+ <p><img src="https://github.com/A-kirami/nonebot-plugin-dancecube/blob/resources/NoneBotPlugin.svg" width="240" alt="NoneBotPluginText"></p>
34
+ </div>
35
+
36
+ <div align="center">
37
+
38
+ # nonebot-plugin-dancecube
39
+
40
+ _✨ 舞立方插件:提供舞立方战力分析等基础功能 ✨_
41
+
42
+
43
+ <a href="./LICENSE">
44
+ <img src="https://img.shields.io/github/license/owner/nonebot-plugin-dancecube.svg" alt="license">
45
+ </a>
46
+ <a href="https://pypi.python.org/pypi/nonebot-plugin-dancecube">
47
+ <img src="https://img.shields.io/pypi/v/nonebot-plugin-dancecube.svg" alt="pypi">
48
+ </a>
49
+ <img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
50
+
51
+ </div>
52
+
53
+ ## 📖 介绍
54
+
55
+ 目前支持二维码登录、战力分析、战力分析(包含自制谱)、战绩最好的30首ap歌曲、获取指定歌曲id的个人成绩、自动更新官方曲目封面。
56
+
57
+ ## 💿 安装
58
+
59
+ <details open>
60
+ <summary>使用 nb-cli 安装</summary>
61
+ 在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装
62
+
63
+ nb plugin install nonebot-plugin-dancecube
64
+
65
+ </details>
66
+
67
+ <details>
68
+ <summary>使用包管理器安装</summary>
69
+ 在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令
70
+
71
+ <details>
72
+ <summary>pip</summary>
73
+
74
+ pip install nonebot-plugin-dancecube
75
+ </details>
76
+ <details>
77
+ <summary>pdm</summary>
78
+
79
+ pdm add nonebot-plugin-dancecube
80
+ </details>
81
+ <details>
82
+ <summary>poetry</summary>
83
+
84
+ poetry add nonebot-plugin-dancecube
85
+ </details>
86
+ <details>
87
+ <summary>conda</summary>
88
+
89
+ conda install nonebot-plugin-dancecube
90
+ </details>
91
+
92
+ 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分追加写入
93
+
94
+ plugins = ["nonebot_plugin_dancecube"]
95
+
96
+ </details>
97
+
98
+ ## ⚙️ 配置
99
+
100
+ 在 nonebot2 项目的`.env`文件中添加下表中的必填配置
101
+
102
+ | 配置项 | 必填 | 默认值 | 说明 |
103
+ |:-----:|:----:|:----:|:----:|
104
+ | COVER_UPDATE_CRON | 否 | 0 3 * * * | cron格式;默认每天凌晨3点更新官方曲目封面 |
105
+ |SUPERUSERS|否|-|超级用户/管理员|
106
+ |NICKNAME|否|nisky|机器人名字,生成图片最低下会展示|
107
+
108
+ ## 🎉 使用
109
+ ### 指令表
110
+ | 指令 | 权限 | 需要@ | 范围 | 说明 |
111
+ |:-----:|:----:|:----:|:----:|:----:|
112
+ | /dc | 群员 | 否 | 私聊/群聊 | 获取指令帮助 |
113
+ | /dc login | 群员 | 否 | 私聊 | 二维码登录 |
114
+ | /dc myrt | 群员 | 否 | 群聊 | 获取战力分析 |
115
+ | /dc myrtall | 群员 | 否 | 群聊 | 获取战力分析(含自制谱) |
116
+ | /dc ap30 | 群员 | 否 | 群聊 | 获取ap战绩最好的30首 |
117
+ | /dc updatecover | 超级用户 | 否 | 私聊/群聊 | 更新官方曲目封面 |
118
+
119
+ ### 效果图
120
+
121
+
122
+ **/dc myrt**
123
+ ![myrt](https://private-user-images.githubusercontent.com/25610914/575376467-021edb83-5a6c-4629-99a5-e5e419761a55.jpg?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NzU2NjM1NjIsIm5iZiI6MTc3NTY2MzI2MiwicGF0aCI6Ii8yNTYxMDkxNC81NzUzNzY0NjctMDIxZWRiODMtNWE2Yy00NjI5LTk5YTUtZTVlNDE5NzYxYTU1LmpwZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjA0MDglMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYwNDA4VDE1NDc0MlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTY2NDc1NTg2Y2MxMzUxZTdkNDk2M2MzNmY3OGU4NjhlYmU4MGFlNWE4NjNlODhhZjRjYzViYWZiZGQ1ODFlYzEmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.JpVV5dISyef-l5PMuJS5k0L_CvDdOuOLtP2vGLY2yvY)
124
+
125
+ **/dc ap30**
126
+ ![ap30](https://private-user-images.githubusercontent.com/25610914/575376735-63abf0b3-4e46-4e3f-8e99-c8cb442daf70.jpg?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NzU2NjM1NjIsIm5iZiI6MTc3NTY2MzI2MiwicGF0aCI6Ii8yNTYxMDkxNC81NzUzNzY3MzUtNjNhYmYwYjMtNGU0Ni00ZTNmLThlOTktYzhjYjQ0MmRhZjcwLmpwZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjA0MDglMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYwNDA4VDE1NDc0MlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTUzZDczYjkxOTA1YjMzZjVjNjI4ODExMDE5NjhiZDA2ZWIyMTdjZDE1MTM4ZTVmODcxNzY0MTZkZmRlMzc2YWYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.m10nGaxpK1JYJ8Ax5zOFn6kbUalDJdb4HUD2tt5h-xc)
127
+
128
+ **/dc song 6354**
129
+ ![song 6354](https://private-user-images.githubusercontent.com/25610914/575376771-ce0049f3-80eb-4c02-9079-f3a794f1762a.jpg?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NzU2NjM1NjIsIm5iZiI6MTc3NTY2MzI2MiwicGF0aCI6Ii8yNTYxMDkxNC81NzUzNzY3NzEtY2UwMDQ5ZjMtODBlYi00YzAyLTkwNzktZjNhNzk0ZjE3NjJhLmpwZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjA0MDglMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYwNDA4VDE1NDc0MlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTU3ZGE3NGIxMDEzNTA2OGM1NWNiNDFkOGVmN2YxY2Y5ZTJkZjNiYzg5ZTM0NTYwNGE5MTg0ZTVjNWU4OGUyOGYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.WEUPNeNyOALOGqWIw5ZZvCXZmnF5Jla0_BqUw_RuPoY)
130
+
@@ -0,0 +1,100 @@
1
+ <div align="center">
2
+ <a href="https://v2.nonebot.dev/store"><img src="https://github.com/A-kirami/nonebot-plugin-dancecube/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-dancecube/blob/resources/NoneBotPlugin.svg" width="240" alt="NoneBotPluginText"></p>
5
+ </div>
6
+
7
+ <div align="center">
8
+
9
+ # nonebot-plugin-dancecube
10
+
11
+ _✨ 舞立方插件:提供舞立方战力分析等基础功能 ✨_
12
+
13
+
14
+ <a href="./LICENSE">
15
+ <img src="https://img.shields.io/github/license/owner/nonebot-plugin-dancecube.svg" alt="license">
16
+ </a>
17
+ <a href="https://pypi.python.org/pypi/nonebot-plugin-dancecube">
18
+ <img src="https://img.shields.io/pypi/v/nonebot-plugin-dancecube.svg" alt="pypi">
19
+ </a>
20
+ <img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
21
+
22
+ </div>
23
+
24
+ ## 📖 介绍
25
+
26
+ 目前支持二维码登录、战力分析、战力分析(包含自制谱)、战绩最好的30首ap歌曲、获取指定歌曲id的个人成绩、自动更新官方曲目封面。
27
+
28
+ ## 💿 安装
29
+
30
+ <details open>
31
+ <summary>使用 nb-cli 安装</summary>
32
+ 在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装
33
+
34
+ nb plugin install nonebot-plugin-dancecube
35
+
36
+ </details>
37
+
38
+ <details>
39
+ <summary>使用包管理器安装</summary>
40
+ 在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令
41
+
42
+ <details>
43
+ <summary>pip</summary>
44
+
45
+ pip install nonebot-plugin-dancecube
46
+ </details>
47
+ <details>
48
+ <summary>pdm</summary>
49
+
50
+ pdm add nonebot-plugin-dancecube
51
+ </details>
52
+ <details>
53
+ <summary>poetry</summary>
54
+
55
+ poetry add nonebot-plugin-dancecube
56
+ </details>
57
+ <details>
58
+ <summary>conda</summary>
59
+
60
+ conda install nonebot-plugin-dancecube
61
+ </details>
62
+
63
+ 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分追加写入
64
+
65
+ plugins = ["nonebot_plugin_dancecube"]
66
+
67
+ </details>
68
+
69
+ ## ⚙️ 配置
70
+
71
+ 在 nonebot2 项目的`.env`文件中添加下表中的必填配置
72
+
73
+ | 配置项 | 必填 | 默认值 | 说明 |
74
+ |:-----:|:----:|:----:|:----:|
75
+ | COVER_UPDATE_CRON | 否 | 0 3 * * * | cron格式;默认每天凌晨3点更新官方曲目封面 |
76
+ |SUPERUSERS|否|-|超级用户/管理员|
77
+ |NICKNAME|否|nisky|机器人名字,生成图片最低下会展示|
78
+
79
+ ## 🎉 使用
80
+ ### 指令表
81
+ | 指令 | 权限 | 需要@ | 范围 | 说明 |
82
+ |:-----:|:----:|:----:|:----:|:----:|
83
+ | /dc | 群员 | 否 | 私聊/群聊 | 获取指令帮助 |
84
+ | /dc login | 群员 | 否 | 私聊 | 二维码登录 |
85
+ | /dc myrt | 群员 | 否 | 群聊 | 获取战力分析 |
86
+ | /dc myrtall | 群员 | 否 | 群聊 | 获取战力分析(含自制谱) |
87
+ | /dc ap30 | 群员 | 否 | 群聊 | 获取ap战绩最好的30首 |
88
+ | /dc updatecover | 超级用户 | 否 | 私聊/群聊 | 更新官方曲目封面 |
89
+
90
+ ### 效果图
91
+
92
+
93
+ **/dc myrt**
94
+ ![myrt](https://private-user-images.githubusercontent.com/25610914/575376467-021edb83-5a6c-4629-99a5-e5e419761a55.jpg?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NzU2NjM1NjIsIm5iZiI6MTc3NTY2MzI2MiwicGF0aCI6Ii8yNTYxMDkxNC81NzUzNzY0NjctMDIxZWRiODMtNWE2Yy00NjI5LTk5YTUtZTVlNDE5NzYxYTU1LmpwZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjA0MDglMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYwNDA4VDE1NDc0MlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTY2NDc1NTg2Y2MxMzUxZTdkNDk2M2MzNmY3OGU4NjhlYmU4MGFlNWE4NjNlODhhZjRjYzViYWZiZGQ1ODFlYzEmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.JpVV5dISyef-l5PMuJS5k0L_CvDdOuOLtP2vGLY2yvY)
95
+
96
+ **/dc ap30**
97
+ ![ap30](https://private-user-images.githubusercontent.com/25610914/575376735-63abf0b3-4e46-4e3f-8e99-c8cb442daf70.jpg?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NzU2NjM1NjIsIm5iZiI6MTc3NTY2MzI2MiwicGF0aCI6Ii8yNTYxMDkxNC81NzUzNzY3MzUtNjNhYmYwYjMtNGU0Ni00ZTNmLThlOTktYzhjYjQ0MmRhZjcwLmpwZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjA0MDglMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYwNDA4VDE1NDc0MlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTUzZDczYjkxOTA1YjMzZjVjNjI4ODExMDE5NjhiZDA2ZWIyMTdjZDE1MTM4ZTVmODcxNzY0MTZkZmRlMzc2YWYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.m10nGaxpK1JYJ8Ax5zOFn6kbUalDJdb4HUD2tt5h-xc)
98
+
99
+ **/dc song 6354**
100
+ ![song 6354](https://private-user-images.githubusercontent.com/25610914/575376771-ce0049f3-80eb-4c02-9079-f3a794f1762a.jpg?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NzU2NjM1NjIsIm5iZiI6MTc3NTY2MzI2MiwicGF0aCI6Ii8yNTYxMDkxNC81NzUzNzY3NzEtY2UwMDQ5ZjMtODBlYi00YzAyLTkwNzktZjNhNzk0ZjE3NjJhLmpwZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjA0MDglMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYwNDA4VDE1NDc0MlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTU3ZGE3NGIxMDEzNTA2OGM1NWNiNDFkOGVmN2YxY2Y5ZTJkZjNiYzg5ZTM0NTYwNGE5MTg0ZTVjNWU4OGUyOGYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.WEUPNeNyOALOGqWIw5ZZvCXZmnF5Jla0_BqUw_RuPoY)
@@ -0,0 +1,117 @@
1
+ from nonebot import on_command, require, get_driver
2
+ from nonebot.adapters.onebot.v11 import Bot, Message, MessageEvent, MessageSegment
3
+
4
+ require("nonebot_plugin_apscheduler")
5
+ require("nonebot_plugin_htmlrender")
6
+
7
+ from .tokens import Token, TokenManager, TokenBuilder
8
+ from .utils import calc_time_difference
9
+ from .recording import MusicInfoManager
10
+ from .userinfo import UserInfo
11
+ from .pic import create_rating_analysis_img, create_ap30_img, create_single_song_record_img, update_official_covers
12
+ from .config import Config, user_tokens_file
13
+
14
+
15
+ from nonebot.plugin import PluginMetadata
16
+
17
+ __plugin_meta__ = PluginMetadata(
18
+ name="舞立方插件",
19
+ description="提供舞立方战力分析等基础功能",
20
+ usage="发送【/dc】获取帮助",
21
+
22
+ type="application",
23
+ homepage="https://github.com/1v7w/nonebot-plugin-dancecube",
24
+
25
+ config=Config,
26
+
27
+ supported_adapters={"~onebot.v11"},
28
+ )
29
+
30
+ SUPERUSERS = set(get_driver().config.superusers)
31
+
32
+ dc = on_command('dc', aliases={'dancecube', '舞立方'}, priority=50, block=True)
33
+
34
+ HELP_TEXT = (
35
+ "/dc myrt 获取战力分析\n"
36
+ "/dc myrtall 获取战力分析(包含自制谱)\n"
37
+ "/dc ap30 获取战绩最好的30首ap歌曲\n"
38
+ "/dc song [id] 获取歌曲id=[id]的个人成绩\n"
39
+ "/dc login 获取登录二维码(仅私聊可用)\n"
40
+ "/dc updatecover 更新官方曲目封面(仅超级用户)\n"
41
+ "/dc help 显示本帮助"
42
+ )
43
+
44
+
45
+ async def _check_token(qq_userid: int) -> Token | str:
46
+ """检查用户 token 状态,返回 token 或错误消息"""
47
+ token = await TokenManager(user_tokens_file).get_token_by_qq(qq_userid)
48
+ if token is None:
49
+ return '还没有登录。\n请私聊我发送"/dc login"来获取二维码登录吧。'
50
+ if calc_time_difference(token.expires) < 600:
51
+ return '登录过期。\n请私聊我发送"/dc login"来获取二维码登录吧。'
52
+ return token
53
+
54
+
55
+ @dc.handle()
56
+ async def handle_dc(bot: Bot, event: MessageEvent):
57
+ qq_userid = event.user_id
58
+ args = str(event.get_plaintext()).split(' ')
59
+ cmd = args[1].lower() if len(args) > 1 else None
60
+
61
+ # 帮助
62
+ if cmd in (None, 'help', 'h'):
63
+ await dc.finish(HELP_TEXT)
64
+
65
+ # 登录
66
+ if cmd == 'login':
67
+ if event.message_type != 'private':
68
+ await dc.finish('请私聊发送"/dc login"来使用登录舞立方账号。')
69
+ token_builder = TokenBuilder()
70
+ qr_code_url = await token_builder.get_qrcode()
71
+ await token_builder.get_token(qq_userid)
72
+ await dc.finish(user_id=qq_userid, message=MessageSegment.image(qr_code_url) + "请在2分钟内微信扫描二维码")
73
+
74
+ # 更新官方曲目封面(仅超级用户)
75
+ if cmd == 'updatecover':
76
+ if str(qq_userid) not in SUPERUSERS:
77
+ await dc.finish('仅超级用户可使用此命令。')
78
+ result = await update_official_covers()
79
+ await dc.finish(result)
80
+
81
+ # 以下命令仅限群聊
82
+ if event.message_type != 'group':
83
+ await dc.finish('本命令仅限群聊中使用。')
84
+
85
+ # 检查 token
86
+ token_or_msg = await _check_token(qq_userid)
87
+ if isinstance(token_or_msg, str):
88
+ await dc.finish(token_or_msg)
89
+
90
+ token = token_or_msg
91
+ music_info_manager = MusicInfoManager(token.user_id, token.access_token)
92
+ userinfo = await UserInfo.fetch_user_data(token.access_token, token.user_id)
93
+
94
+ if cmd == 'ap30':
95
+ img_bytes = await create_ap30_img(userinfo, music_info_manager)
96
+ await dc.finish(MessageSegment.image(img_bytes))
97
+
98
+ elif cmd == 'myrt':
99
+ img_bytes = await create_rating_analysis_img(userinfo, music_info_manager)
100
+ await dc.finish(MessageSegment.image(img_bytes))
101
+
102
+ elif cmd == 'myrtall':
103
+ img_bytes = await create_rating_analysis_img(userinfo, music_info_manager, is_official=False)
104
+ await dc.finish(MessageSegment.image(img_bytes))
105
+
106
+ elif cmd == 'song':
107
+ if len(args) < 3:
108
+ await dc.finish('请指定歌曲id,例如: /dc song 10009')
109
+ song_id = args[2]
110
+ success, result = await create_single_song_record_img(userinfo, music_info_manager, song_id)
111
+ if success:
112
+ await dc.finish(MessageSegment.image(result))
113
+ else:
114
+ await dc.finish(str(result))
115
+
116
+ else:
117
+ await dc.finish(HELP_TEXT)
@@ -0,0 +1,26 @@
1
+ import shutil
2
+ from pathlib import Path
3
+
4
+ from nonebot import get_driver, get_plugin_config, require
5
+ require("nonebot_plugin_localstore")
6
+ from nonebot_plugin_localstore import get_plugin_data_dir
7
+ from pydantic import BaseModel
8
+
9
+ driver = get_driver()
10
+
11
+ class Config(BaseModel):
12
+ botName: str = list(driver.config.nickname)[0] if driver.config.nickname else 'nisky'
13
+ cover_update_cron: str = "0 3 * * *" # 定时更新官方封面的 cron 表达式,默认每天凌晨3点
14
+
15
+
16
+ dc_config = get_plugin_config(Config)
17
+
18
+ data_dir: Path = get_plugin_data_dir()
19
+ data_dir.mkdir(parents=True, exist_ok=True)
20
+
21
+ user_tokens_file: Path = data_dir / 'user_tokens.json'
22
+ cover_dir: Path = data_dir / 'cover'
23
+ templates_dir: Path = data_dir / 'templates'
24
+ thumb_dir = cover_dir / "thumb" # 封面缩略图目录(用于网页渲染)
25
+
26
+ thumb_dir.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,66 @@
1
+ import asyncio
2
+ import functools
3
+
4
+ import httpx
5
+ from PIL import Image
6
+ from io import BytesIO
7
+
8
+ from nonebot.log import logger
9
+
10
+
11
+
12
+ def retry_on_failure(max_retries: int = 3, delay: float = 1, backoff: float = 2):
13
+ """装饰器:请求失败时自动重试,指数退避"""
14
+ def decorator(func):
15
+ @functools.wraps(func)
16
+ async def wrapper(*args, **kwargs):
17
+ last_exception: Exception | None = None
18
+ for attempt in range(max_retries):
19
+ try:
20
+ return await func(*args, **kwargs)
21
+ except (httpx.HTTPError, httpx.HTTPStatusError) as e:
22
+ last_exception = e
23
+ if attempt < max_retries - 1:
24
+ wait_time = delay * (backoff ** attempt)
25
+ logger.debug(f"第{attempt + 1}次尝试失败,{wait_time}秒后重试: {e}")
26
+ await asyncio.sleep(wait_time)
27
+ raise last_exception # type: ignore[misc]
28
+ return wrapper
29
+ return decorator
30
+
31
+
32
+ @retry_on_failure(max_retries=3, delay=1, backoff=2)
33
+ async def http_get(url: str, params: dict | None = None, headers: dict | None = None) -> dict | None:
34
+ """GET 请求,返回 JSON 或 None"""
35
+ async with httpx.AsyncClient(proxy=None) as client:
36
+ rep = await client.get(url, headers=headers, params=params)
37
+ if rep.status_code == 200:
38
+ return rep.json()
39
+ logger.debug(f"GET {url} 返回状态码 {rep.status_code}")
40
+ return None
41
+
42
+
43
+ async def http_get_with_token(url: str, params: dict | None = None, token: str = "") -> dict | None:
44
+ """带 Authorization 的 GET 请求"""
45
+ headers = {"Authorization": f"Bearer {token}"}
46
+ return await http_get(url, params=params, headers=headers)
47
+
48
+
49
+ @retry_on_failure(max_retries=3, delay=1, backoff=2)
50
+ async def http_post(url: str, data: dict | None = None) -> dict | None:
51
+ """POST 请求,返回 JSON 或 None"""
52
+ async with httpx.AsyncClient(proxy=None) as client:
53
+ rep = await client.post(url, data=data)
54
+ if rep.status_code == 200:
55
+ return rep.json()
56
+ logger.debug(f"POST {url} 返回状态码 {rep.status_code}")
57
+ return None
58
+
59
+
60
+ @retry_on_failure(max_retries=3, delay=1, backoff=2)
61
+ async def http_get_image(url: str) -> Image.Image:
62
+ """下载图片并返回 PIL Image 对象"""
63
+ async with httpx.AsyncClient(proxy=None) as client:
64
+ response = await client.get(url)
65
+ response.raise_for_status()
66
+ return Image.open(BytesIO(response.content))
@@ -0,0 +1,343 @@
1
+ import asyncio
2
+ import os
3
+ from datetime import datetime
4
+
5
+ from nonebot.log import logger
6
+ from nonebot_plugin_apscheduler import scheduler
7
+ from nonebot_plugin_htmlrender import template_to_pic
8
+
9
+ from .download import http_get, http_get_image
10
+ from .recording import MusicInfoManager, RankMusicInfo, LastPlayMusicInfo
11
+ from .userinfo import UserInfo
12
+ from .utils import LEVEL_TYPE_TO_STR, LEVEL_TYPE_LIST
13
+ from .config import cover_dir, templates_dir, dc_config, thumb_dir, driver
14
+
15
+ # 图片生成配置
16
+ IMAGE_DEVICE_SCALE_FACTOR = 1 # 降低设备缩放因子以减小图片体积
17
+ IMAGE_JPEG_QUALITY = 80 # JPEG 质量(0-100)
18
+ COVER_MAX_SIZE = 300 # 封面图最大边长(像素),用于压缩已下载封面
19
+
20
+ async def get_music_cover_path(music_id: int) -> str:
21
+ """获取音乐封面缩略图路径(用于网页渲染),如原图不存在则下载,然后生成缩略图。
22
+ 下载失败时直接返回默认封面,不复制到音乐封面路径,以便后续定时任务仍可下载真正的封面。"""
23
+ cover_path = cover_dir / f"{music_id}.jpg"
24
+ default_cover_path = cover_dir / "-1.jpg"
25
+ default_thumb_path = thumb_dir / "-1.jpg"
26
+ thumb_path = thumb_dir / f"{music_id}.jpg"
27
+
28
+ # 原图不存在则尝试下载
29
+ if not os.path.exists(cover_path):
30
+ logger.debug(f"下载封面: music {music_id}")
31
+ await _download_and_save_cover(music_id)
32
+
33
+ # 下载失败则返回默认封面缩略图(不复制到音乐封面路径)
34
+ if not os.path.exists(cover_path):
35
+ if not os.path.exists(default_thumb_path):
36
+ _generate_thumbnail(default_cover_path, default_thumb_path)
37
+ return str(default_thumb_path)
38
+
39
+ # 缩略图不存在则从原图生成
40
+ if not os.path.exists(thumb_path):
41
+ _generate_thumbnail(cover_path, thumb_path)
42
+
43
+ return str(thumb_path)
44
+
45
+
46
+ async def _download_and_save_cover(music_id: int) -> None:
47
+ """下载自制谱封面并保存原图"""
48
+ get_goods_info_api = "https://dancedemo.shenghuayule.com/Dance/api/MusicData/GetGoodsInfo"
49
+ rep = await http_get(get_goods_info_api, params={"musicId": music_id})
50
+ if rep is None:
51
+ return
52
+
53
+ for list_file in rep.get("ListFile", []):
54
+ if list_file.get("FileType") == 3:
55
+ image_url = list_file.get("Url")
56
+ img = await http_get_image(image_url)
57
+ _save_cover(img, cover_dir / f"{music_id}.jpg")
58
+ return
59
+
60
+
61
+ def _save_cover(img, save_path) -> None:
62
+ """保存封面原图(不压缩)"""
63
+ from PIL import Image
64
+ if img.mode in ("RGBA", "P"):
65
+ img = img.convert("RGB")
66
+ img.save(save_path, "JPEG", quality=95, optimize=True)
67
+
68
+
69
+ def _generate_thumbnail(src_path, thumb_path, max_size=COVER_MAX_SIZE) -> None:
70
+ """从原图生成压缩缩略图,用于网页渲染"""
71
+ from PIL import Image
72
+ thumb_dir.mkdir(parents=True, exist_ok=True)
73
+
74
+ img = Image.open(src_path)
75
+ w, h = img.size
76
+ if max(w, h) > max_size:
77
+ ratio = max_size / max(w, h)
78
+ img = img.resize((int(w * ratio), int(h * ratio)), Image.Resampling.LANCZOS)
79
+
80
+ if img.mode in ("RGBA", "P"):
81
+ img = img.convert("RGB")
82
+ img.save(thumb_path, "JPEG", quality=85, optimize=True)
83
+
84
+
85
+ async def _generate_score_entry(music_info: RankMusicInfo | LastPlayMusicInfo) -> dict:
86
+ """生成单曲数据字典"""
87
+ level_type_str = LEVEL_TYPE_TO_STR[music_info.level_type]
88
+ cover_path = await get_music_cover_path(music_info.id)
89
+ return {
90
+ "songName": music_info.name,
91
+ "coverUrl": cover_path,
92
+ "id": music_info.id,
93
+ "difficulty": level_type_str[-2:], # 基础、进阶、专家、大师、传奇
94
+ "level": music_info.level,
95
+ "levelType": level_type_str[:-3], # 经典/show
96
+ "accuracy": music_info.accuracy, # 0.00~100.00
97
+ "rating": int(music_info.rating),
98
+ "playTime": music_info.record_time,
99
+ }
100
+
101
+
102
+ def _base_template_data(user_info: UserInfo) -> dict:
103
+ """生成模板通用数据"""
104
+ return {
105
+ "generatedTime": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
106
+ "playerName": user_info.username,
107
+ "avatarUrl": user_info.head_img_url,
108
+ "powerValue": user_info.rating,
109
+ "points": user_info.score,
110
+ "botName": dc_config.botName,
111
+ }
112
+
113
+
114
+ async def _render_template(template_name: str, template_data: dict,
115
+ viewport_width: int = 1200, viewport_height: int = 1500) -> bytes:
116
+ """通用模板渲染方法,包含图片优化"""
117
+ img_bytes = await template_to_pic(
118
+ template_path=str(templates_dir),
119
+ template_name=template_name,
120
+ templates=template_data,
121
+ pages={
122
+ "viewport": {"width": viewport_width, "height": viewport_height},
123
+ },
124
+ wait=2000,
125
+ type="jpeg",
126
+ quality=IMAGE_JPEG_QUALITY,
127
+ device_scale_factor=IMAGE_DEVICE_SCALE_FACTOR,
128
+ )
129
+ return img_bytes
130
+
131
+
132
+ def _take_n(items: list, n: int) -> list:
133
+ """取列表前 n 项"""
134
+ return items[:n]
135
+
136
+
137
+ async def create_rating_analysis_img(user_info: UserInfo, music_info_manager: MusicInfoManager,
138
+ is_official: bool = True) -> bytes:
139
+ """生成战力分析图片"""
140
+ if is_official:
141
+ await music_info_manager.get_all_rank_official_list()
142
+ all_rank_list = music_info_manager.all_rank_official_list
143
+ else:
144
+ await music_info_manager.get_all_rank_list()
145
+ all_rank_list = music_info_manager.all_rank_list
146
+
147
+ await music_info_manager.get_recent_record_list()
148
+
149
+ template_data = _base_template_data(user_info)
150
+ template_data["pageTitle"] = "舞立方战力分析" if is_official else "舞立方战力分析(含自制谱)"
151
+ template_data["best30"] = [await _generate_score_entry(m) for m in _take_n(all_rank_list, 30)]
152
+ template_data["recent30"] = [await _generate_score_entry(m) for m in _take_n(music_info_manager.recent_record_list, 30)]
153
+ template_data["best30Count"] = len(template_data["best30"])
154
+ template_data["recent30Count"] = len(template_data["recent30"])
155
+
156
+ return await _render_template("myrt.html", template_data)
157
+
158
+
159
+ async def create_ap30_img(user_info: UserInfo, music_info_manager: MusicInfoManager) -> bytes:
160
+ """生成 AP30 图片"""
161
+ await music_info_manager.get_all_rank_list()
162
+ all_ap_list = [x for x in music_info_manager.all_rank_list if x.accuracy == 100]
163
+
164
+ template_data = _base_template_data(user_info)
165
+ template_data["apCount"] = len(all_ap_list)
166
+ template_data["apListCount"] = min(len(all_ap_list), 30)
167
+ template_data["apList"] = [await _generate_score_entry(m) for m in _take_n(all_ap_list, 30)]
168
+
169
+ return await _render_template("ap30.html", template_data)
170
+
171
+
172
+ async def create_single_song_record_img(user_info: UserInfo, music_info_manager: MusicInfoManager,
173
+ song_id: str) -> tuple[bool, bytes | str]:
174
+ """生成单曲成绩图片,返回 (成功标志, 图片bytes或错误消息)"""
175
+ await music_info_manager.get_all_rank_list()
176
+ rank_list = sorted(
177
+ [x for x in music_info_manager.all_rank_list if str(x.id) == str(song_id)],
178
+ key=lambda x: x.level_type,
179
+ )
180
+ if not rank_list:
181
+ return False, f"未找到id:{song_id}游玩记录,请检查歌曲id是否正确。"
182
+
183
+ cover_path = await get_music_cover_path(rank_list[0].id)
184
+
185
+ template_data = _base_template_data(user_info)
186
+ template_data["songName"] = rank_list[0].name
187
+ template_data["songId"] = rank_list[0].id
188
+ template_data["coverUrl"] = cover_path
189
+ template_data["records"] = []
190
+
191
+ # 构建各难度记录
192
+ rank_dict = {r.level_type: r for r in rank_list}
193
+ for lt in LEVEL_TYPE_LIST:
194
+ level_type_str = LEVEL_TYPE_TO_STR[lt]
195
+ difficulty = level_type_str[-2:]
196
+ level_type_prefix = level_type_str[:-3]
197
+ if lt in rank_dict:
198
+ r = rank_dict[lt]
199
+ template_data["records"].append({
200
+ "hasRecord": True,
201
+ "difficulty": difficulty,
202
+ "levelType": level_type_prefix,
203
+ "level": r.level,
204
+ "accuracy": r.accuracy,
205
+ "combo": r.combo,
206
+ "miss": r.miss,
207
+ "rating": int(r.rating),
208
+ "playTime": r.record_time,
209
+ })
210
+ else:
211
+ template_data["records"].append({
212
+ "hasRecord": False,
213
+ "difficulty": difficulty,
214
+ "levelType": level_type_prefix,
215
+ })
216
+
217
+ img_bytes = await _render_template("song.html", template_data,
218
+ viewport_width=800, viewport_height=900)
219
+ return True, img_bytes
220
+
221
+
222
+ async def update_official_covers() -> str:
223
+ """更新官方曲目封面"""
224
+ logger.info("开始更新官方曲目封面")
225
+
226
+ music_ranking_api = "https://dancedemo.shenghuayule.com/Dance/api/User/GetMusicRankingNew"
227
+ page_size = 50
228
+ downloaded = 0
229
+ skipped = 0
230
+ failed = 0
231
+ api_max_retries = 5
232
+ consecutive_failures = 0
233
+ max_consecutive_failures = 3 # 连续失败页数上限,超过则跳到下一类别
234
+
235
+ try:
236
+ for music_index in range(2, 7): # 2-6: 国语、粤语、韩语、欧美、其它
237
+ page = 1
238
+ consecutive_failures = 0
239
+ while True:
240
+ # 请求失败时自动重试,直到成功获取数据
241
+ rep = None
242
+ for retry in range(1, api_max_retries + 1):
243
+ rep = await http_get(
244
+ music_ranking_api,
245
+ params={"musicIndex": music_index, "page": page, "pagesize": page_size},
246
+ )
247
+ if rep is not None:
248
+ break
249
+ logger.warning(
250
+ f"获取官方曲目列表失败: musicIndex={music_index}, page={page}, "
251
+ f"第 {retry}/{api_max_retries} 次重试"
252
+ )
253
+ if retry < api_max_retries:
254
+ await asyncio.sleep(retry)
255
+
256
+ if rep is None:
257
+ consecutive_failures += 1
258
+ logger.error(
259
+ f"获取官方曲目列表最终失败: musicIndex={music_index}, page={page},"
260
+ f"连续失败 {consecutive_failures}/{max_consecutive_failures} 次"
261
+ )
262
+ if consecutive_failures >= max_consecutive_failures:
263
+ logger.error(f"连续 {max_consecutive_failures} 页获取失败,跳到下一类别")
264
+ break
265
+ page += 1
266
+ continue
267
+
268
+ consecutive_failures = 0 # 成功后重置连续失败计数
269
+
270
+ music_list = rep.get("List", [])
271
+ if not music_list:
272
+ break # 无更多数据,下一类别
273
+
274
+ for music in music_list:
275
+ music_id = music.get("MusicID")
276
+ cover_url = music.get("Cover")
277
+ cover_path = cover_dir / f"{music_id}.jpg"
278
+
279
+ if os.path.exists(cover_path):
280
+ skipped += 1
281
+ continue
282
+
283
+ if not cover_url:
284
+ failed += 1
285
+ continue
286
+
287
+ # 去掉 Cover URL 末尾的尺寸后缀(如 "/200")以获取原图
288
+ if cover_url.endswith("/200"):
289
+ cover_url = cover_url[:-4]
290
+
291
+ try:
292
+ img = await http_get_image(cover_url)
293
+ _save_cover(img, cover_path)
294
+ downloaded += 1
295
+ logger.debug(f"下载官方封面: {music_id}")
296
+ except Exception as e:
297
+ failed += 1
298
+ logger.debug(f"下载官方封面失败: {music_id}, {e}")
299
+
300
+ page += 1
301
+
302
+ except Exception as e:
303
+ logger.error(f"更新官方封面时发生错误: {e}")
304
+ return f"官方曲目封面更新出错:{e}\n已下载 {downloaded} 张,跳过 {skipped} 张,失败 {failed} 张"
305
+
306
+ logger.info(f"官方曲目封面更新完成: 下载 {downloaded}, 跳过 {skipped}, 失败 {failed}")
307
+ return f"官方曲目封面更新完成!\n新下载: {downloaded} 张\n已存在: {skipped} 张\n失败: {failed} 张"
308
+
309
+
310
+ @driver.on_startup
311
+ async def _register_cover_update_job():
312
+ """根据配置的 cron 表达式注册定时更新官方封面任务"""
313
+ from apscheduler.triggers.cron import CronTrigger
314
+ trigger = CronTrigger.from_crontab(dc_config.cover_update_cron)
315
+ scheduler.add_job(
316
+ update_official_covers,
317
+ trigger,
318
+ id="update_official_covers",
319
+ replace_existing=True,
320
+ )
321
+ logger.info(f"已注册定时更新官方封面任务,cron: {dc_config.cover_update_cron}")
322
+
323
+ @driver.on_startup
324
+ async def _ensure_default_cover() -> None:
325
+ """确保默认封面 cover/-1.jpg 存在,如不存在则生成占位图"""
326
+ default_cover_path = cover_dir / "-1.jpg"
327
+ if default_cover_path.exists():
328
+ return
329
+
330
+ from PIL import Image, ImageDraw, ImageFont
331
+
332
+ cover_dir.mkdir(parents=True, exist_ok=True)
333
+ img = Image.new("RGB", (175, 202), color=(180, 180, 180))
334
+ draw = ImageDraw.Draw(img)
335
+ text = "MISSING"
336
+ font = ImageFont.load_default(size=41)
337
+ bbox = draw.textbbox((0, 0), text, font=font)
338
+ text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
339
+ x = (175 - text_w) // 2
340
+ y = (202 - text_h) // 2
341
+ draw.text((x, y), text, fill=(80, 80, 80), font=font)
342
+ img.save(default_cover_path, "JPEG", quality=85)
343
+ logger.info("已生成默认占位封面 cover/-1.jpg")
@@ -0,0 +1,133 @@
1
+ from .download import http_get_with_token
2
+ from .utils import compute_rating
3
+
4
+
5
+ class RecordedMusicInfo:
6
+ """已记录音乐信息基类"""
7
+
8
+ def __init__(self, music_id: int, name: str, cls: int, difficulty: int,
9
+ level: int, level_type: int, accuracy: float,
10
+ score: int, combo: int, miss: int, record_time: str):
11
+ self.id: int = music_id
12
+ self.name: str = name
13
+ self.cls: int = cls
14
+ self.difficulty: int = difficulty # show 为 -1
15
+ self.level: int = level # 1-19
16
+ self.level_type: int = level_type # 经典1x show 10x
17
+ self.accuracy: float = accuracy
18
+ self.score: int = score
19
+ self.combo: int = combo
20
+ self.miss: int = miss
21
+ self.rating: float = compute_rating(level, accuracy)
22
+ self.record_time: str = record_time
23
+
24
+
25
+ class RankMusicInfo(RecordedMusicInfo):
26
+ """排行榜音乐信息"""
27
+
28
+ def __init__(self, music_id: int, name: str, cls: int, owner_type: int, details: dict):
29
+ super().__init__(
30
+ music_id=music_id,
31
+ name=name,
32
+ cls=cls,
33
+ difficulty=int(details.get("MusicLevOld")),
34
+ level=int(details.get("MusicRank")),
35
+ level_type=int(details.get("MusicLev")),
36
+ accuracy=float(details.get("PlayerPercent")) / 100,
37
+ score=int(details.get("PlayerScore")),
38
+ combo=int(details.get("ComboCount")),
39
+ miss=int(details.get("PlayerMiss")),
40
+ record_time=details.get("RecordTime"),
41
+ )
42
+ self.is_official: bool = owner_type == 1
43
+ self.ranking: int = int(details.get("MusicRanking"))
44
+
45
+ def __str__(self):
46
+ return (f'RankMusicInfo {{"difficulty": {self.difficulty}, "level": {self.level}, '
47
+ f'"level_type": {self.level_type}, "accuracy": {self.accuracy:.2f}, '
48
+ f'"rating": {self.rating:.2f}}}')
49
+
50
+
51
+ class LastPlayMusicInfo(RecordedMusicInfo):
52
+ """最近游玩音乐信息"""
53
+
54
+ def __init__(self, music_id: int, name: str, details: dict):
55
+ super().__init__(
56
+ music_id=music_id,
57
+ name=name,
58
+ cls=0,
59
+ difficulty=int(details.get("MusicLevOld")),
60
+ level=int(details.get("MusicLevel")),
61
+ level_type=int(details.get("MusicLev")),
62
+ accuracy=float(details.get("PlayerPercent")) / 100,
63
+ score=int(details.get("PlayerScore")),
64
+ combo=int(details.get("ComboCount")),
65
+ miss=int(details.get("PlayerMiss")),
66
+ record_time=details.get("RecordTime"),
67
+ )
68
+ self.perfect: int = int(details.get("PlayerPerfect"))
69
+ self.great: int = int(details.get("PlayerGreat"))
70
+ self.good: int = int(details.get("PlayerGood"))
71
+
72
+
73
+ class MusicInfoManager:
74
+ """音乐成绩管理器"""
75
+
76
+ RANK_LIST_API = "https://dancedemo.shenghuayule.com/Dance/api/User/GetMyRankNew"
77
+ RECENT_PLAY_API = "https://dancedemo.shenghuayule.com/Dance/api/User/GetLastPlay"
78
+
79
+ def __init__(self, user_id: str, access_token: str):
80
+ self.user_id: str = user_id
81
+ self.access_token: str = access_token
82
+ self.all_rank_list: list[RankMusicInfo] = []
83
+ self.all_rank_official_list: list[RankMusicInfo] = []
84
+ self.recent_record_list: list[LastPlayMusicInfo] = []
85
+
86
+ async def _fetch_rank_list(self, official_only: bool = False) -> list[RankMusicInfo]:
87
+ """获取排行榜列表,可按官方谱过滤"""
88
+ result: list[RankMusicInfo] = []
89
+ for cls_index in range(2, 7): # 2-6: 国语、粤语、韩语、欧美、其它
90
+ rep = await http_get_with_token(self.RANK_LIST_API, {"musicIndex": cls_index}, self.access_token)
91
+ if rep is None:
92
+ continue
93
+ for music_info in rep:
94
+ music_id = music_info.get("MusicID")
95
+ name = music_info.get("Name")
96
+ owner_type = int(music_info.get("OwnerType"))
97
+
98
+ if official_only and owner_type != 1:
99
+ continue
100
+
101
+ for record_info in music_info.get("ItemRankList"):
102
+ # 官方谱过滤:仅保留等级 1-19
103
+ if official_only:
104
+ rank_val = record_info.get("MusicRank")
105
+ if rank_val is not None and (rank_val > 19 or rank_val < 1):
106
+ continue
107
+ result.append(RankMusicInfo(music_id, name, cls_index, owner_type, record_info))
108
+
109
+ result.sort(key=lambda x: x.rating, reverse=True)
110
+ return result
111
+
112
+ async def get_all_rank_list(self) -> list[RankMusicInfo]:
113
+ """获取所有排行榜(含自制谱)"""
114
+ self.all_rank_list = await self._fetch_rank_list(official_only=False)
115
+ return self.all_rank_list
116
+
117
+ async def get_all_rank_official_list(self) -> list[RankMusicInfo]:
118
+ """获取官方谱排行榜"""
119
+ self.all_rank_official_list = await self._fetch_rank_list(official_only=True)
120
+ return self.all_rank_official_list
121
+
122
+ async def get_recent_record_list(self) -> list[LastPlayMusicInfo]:
123
+ """获取最近游玩记录"""
124
+ rep = await http_get_with_token(self.RECENT_PLAY_API, {}, self.access_token)
125
+ if rep is None:
126
+ self.recent_record_list = []
127
+ return self.recent_record_list
128
+
129
+ self.recent_record_list = [
130
+ LastPlayMusicInfo(item.get("MusicID"), item.get("MusicName"), item)
131
+ for item in rep
132
+ ]
133
+ return self.recent_record_list
@@ -0,0 +1,150 @@
1
+ import json
2
+ from datetime import datetime, timedelta
3
+ from pathlib import Path
4
+
5
+ from nonebot import get_bot
6
+ from nonebot.log import logger
7
+ from nonebot_plugin_apscheduler import scheduler
8
+
9
+ from .download import http_get, http_post
10
+ from .config import user_tokens_file
11
+
12
+ import asyncio
13
+
14
+ _tokens_lock = asyncio.Lock()
15
+
16
+ class Token:
17
+ def __init__(self, access_token: str = "", refresh_token: str = "", expires: str = "",
18
+ refresh_token_expires: str = "", user_id: str = "", qq: str = ""):
19
+ self.access_token = access_token
20
+ self.refresh_token = refresh_token
21
+ self.expires = expires
22
+ self.refresh_token_expires = refresh_token_expires
23
+ self.user_id = user_id
24
+ self.qq = qq
25
+
26
+ def to_dict(self) -> dict:
27
+ return {
28
+ "access_token": self.access_token,
29
+ "refresh_token": self.refresh_token,
30
+ "expires": self.expires,
31
+ "refresh_token_expires": self.refresh_token_expires,
32
+ "user_id": self.user_id,
33
+ "qq": self.qq,
34
+ }
35
+
36
+ @classmethod
37
+ def from_dict(cls, data: dict) -> "Token":
38
+ return cls(
39
+ access_token=data.get("access_token", ""),
40
+ refresh_token=data.get("refresh_token", ""),
41
+ expires=data.get("expires", ""),
42
+ refresh_token_expires=data.get("refresh_token_expires", data.get("refreshExpires", "")),
43
+ user_id=data.get("user_id", data.get("userId", "")),
44
+ qq=data.get("qq", "0"),
45
+ )
46
+
47
+
48
+ class TokenBuilder:
49
+ """处理二维码登录流程"""
50
+
51
+ QRCODE_URL_API = "https://dancedemo.shenghuayule.com/Dance/api/Common/GetQrCode"
52
+ GET_TOKEN_API = "https://dancedemo.shenghuayule.com/Dance/token"
53
+
54
+ def __init__(self):
55
+ self.id: str = ""
56
+ self.qrcode_url: str = ""
57
+
58
+ async def get_qrcode(self) -> str:
59
+ """获取登录二维码 URL"""
60
+ rep = await http_get(self.QRCODE_URL_API, {"id": ""})
61
+ if rep is None:
62
+ return ""
63
+ self.qrcode_url = rep.get("QrcodeUrl", "")
64
+ self.id = rep.get("ID", "")
65
+ return self.qrcode_url
66
+
67
+ async def get_token(self, qq: int) -> None:
68
+ """启动定时轮询获取 token 的任务"""
69
+ job_id = f"get_token_job_{qq}"
70
+
71
+ async def _poll_token(job_id: str, client_id: str, qq: int):
72
+ data = {
73
+ "client_type": "qrcode",
74
+ "grant_type": "client_credentials",
75
+ "client_id": client_id,
76
+ }
77
+ rep = await http_post(self.GET_TOKEN_API, data)
78
+ if rep is None:
79
+ return
80
+ token = Token.from_dict(rep)
81
+ token.qq = str(qq)
82
+ await TokenManager(user_tokens_file).update_token(token)
83
+ scheduler.remove_job(job_id)
84
+ await get_bot().send_private_msg(
85
+ user_id=qq,
86
+ message=f"登录成功。登录的舞立方ID:{token.user_id}\n如果不是你的舞立方ID号,请重新登录!",
87
+ )
88
+
89
+ scheduler.add_job(
90
+ _poll_token,
91
+ "interval",
92
+ seconds=5,
93
+ args=[job_id, self.id, qq],
94
+ id=job_id,
95
+ )
96
+
97
+ async def _cancel_on_timeout(job_id: str, qq: int):
98
+ if scheduler.get_job(job_id):
99
+ logger.info(f"{qq}扫描二维码超时。")
100
+ scheduler.remove_job(job_id)
101
+ await get_bot().send_private_msg(user_id=qq, message="登录失败,请重新发送命令进行登录。")
102
+
103
+ scheduler.add_job(
104
+ _cancel_on_timeout,
105
+ "date",
106
+ run_date=datetime.now() + timedelta(minutes=2),
107
+ args=[job_id, qq],
108
+ id=f"cancel_{job_id}",
109
+ )
110
+
111
+
112
+ class TokenManager:
113
+ """管理 token 的持久化存储"""
114
+
115
+ def __init__(self, file_path: Path | str):
116
+ self.file_path = file_path
117
+
118
+ def _load_tokens_unsafe(self) -> list[Token]:
119
+ """不加锁的读取"""
120
+ try:
121
+ with open(self.file_path, "r", encoding="utf-8") as f:
122
+ return [Token.from_dict(item) for item in json.load(f)]
123
+ except FileNotFoundError:
124
+ return []
125
+
126
+ def _save_tokens_unsafe(self, tokens: list[Token]) -> None:
127
+ """不加锁的写入"""
128
+ data = [token.to_dict() for token in tokens]
129
+ with open(self.file_path, "w", encoding="utf-8") as f:
130
+ json.dump(data, f, indent=4)
131
+
132
+ async def get_token_by_qq(self, qq: int) -> Token | None:
133
+ """加锁读取指定 QQ 的 token"""
134
+ async with _tokens_lock:
135
+ for token in self._load_tokens_unsafe():
136
+ if token.qq == str(qq):
137
+ return token
138
+ return None
139
+
140
+ async def update_token(self, new_token: Token) -> None:
141
+ """加锁更新 token"""
142
+ async with _tokens_lock:
143
+ tokens = self._load_tokens_unsafe()
144
+ for i, token in enumerate(tokens):
145
+ if token.qq == new_token.qq:
146
+ tokens[i] = new_token
147
+ self._save_tokens_unsafe(tokens)
148
+ return
149
+ tokens.append(new_token)
150
+ self._save_tokens_unsafe(tokens)
@@ -0,0 +1,34 @@
1
+ from .download import http_get_with_token
2
+
3
+
4
+ class UserInfo:
5
+ def __init__(self):
6
+ self.user_id: str = ""
7
+ self.head_img_url: str = ""
8
+ self.username: str = ""
9
+ self.rating: int = 0 # 战力值
10
+ self.score: int = 0 # 积分
11
+ self.title_url: str = "" # 头衔
12
+ self.head_img_box_url: str = "" # 头像边框
13
+
14
+ @staticmethod
15
+ async def fetch_user_data(token: str, user_id: str) -> "UserInfo":
16
+ url: str = "https://dancedemo.shenghuayule.com/Dance/api/User/GetInfo"
17
+ query: dict[str, str | bool] = {
18
+ "userId": str(user_id),
19
+ "getNationRank": True,
20
+ }
21
+ user_data = await http_get_with_token(url, query, token)
22
+ user = UserInfo()
23
+ if user_data:
24
+ user.user_id = user_data.get("UserID", user_id)
25
+ user.head_img_url = user_data.get("HeadimgURL", "")
26
+ user.username = user_data.get("UserName", "")
27
+ user.rating = user_data.get("LvRatio", 0)
28
+ user.score = user_data.get("MusicScore", 0)
29
+ user.title_url = str(user_data.get("TitleUrl", "")).removesuffix('/256')
30
+ user.head_img_box_url = str(user_data.get("HeadimgBoxPath", "")).removesuffix('/256')
31
+ return user
32
+
33
+ def __str__(self):
34
+ return f"user_id: {self.user_id}, head_img_url: {self.head_img_url}, username: {self.username}, rating: {self.rating}"
@@ -0,0 +1,71 @@
1
+ from datetime import datetime
2
+
3
+
4
+ def calc_time_difference(given_time_str: str) -> int:
5
+ """获取给定时间与当前时间的差值(秒)"""
6
+ given_time = datetime.strptime(given_time_str, "%Y-%m-%d %H:%M:%S")
7
+ return int((given_time - datetime.now()).total_seconds())
8
+
9
+
10
+ # 难度类型映射
11
+ LEVEL_TYPE_LIST = [11, 12, 13, 14, 15, 101, 102, 103, 104, 105]
12
+
13
+ LEVEL_TYPE_TO_STR = {
14
+ 11: "经典-基础",
15
+ 12: "经典-进阶",
16
+ 13: "经典-专家",
17
+ 14: "经典-大师",
18
+ 15: "经典-传奇",
19
+ 101: "show+基础",
20
+ 102: "show+进阶",
21
+ 103: "show+专家",
22
+ 104: "show+大师",
23
+ 105: "show+传奇",
24
+ }
25
+
26
+
27
+ def compute_rating(level: int, acc: float) -> int:
28
+ """
29
+ 返回 level 级谱面 acc 准度贡献的战力。
30
+ level: 谱面等级(1-19)
31
+ acc: 记录百分比(0-100,两位小数)
32
+ """
33
+ acc_int = max(0, min(int(acc * 100), 10000))
34
+ if level < 1 or level > 19:
35
+ return 0
36
+
37
+ base_ratings: list[float] = [
38
+ (level + 2) * 100.0, # 100
39
+ (level + 1) * 100.0, # [98, 100)
40
+ level * 100.0, # [95, 98)
41
+ (level - 1) * 100.0, # [90, 95)
42
+ (level - 2 + (19 - level) / 19.0) * 100.0, # [85, 90)
43
+ (level - 3 + 2 * (19 - level) / 19.0) * 100.0, # [80, 85)
44
+ (level - 4 + 3 * (19 - level) / 19.0) * 100.0, # [75, 80)
45
+ (level - 5 + 4 * (19 - level) / 19.0) * 100.0, # [70, 75)
46
+ ]
47
+
48
+ if acc_int == 10000:
49
+ base, offset = base_ratings[0], 0.0
50
+ elif acc_int >= 9800:
51
+ base, offset = base_ratings[1], (acc_int - 9800) / 200.0 * 100
52
+ elif acc_int >= 9500:
53
+ base, offset = base_ratings[2], (acc_int - 9500) / 300.0 * 100
54
+ elif acc_int >= 9000:
55
+ base, offset = base_ratings[3], (acc_int - 9000) / 500.0 * 100
56
+ elif acc_int >= 8500:
57
+ base = base_ratings[4]
58
+ offset = (acc_int - 8500) / 500.0 * (base_ratings[3] - base)
59
+ elif acc_int >= 8000:
60
+ base = base_ratings[5]
61
+ offset = (acc_int - 8000) / 500.0 * (base_ratings[4] - base)
62
+ elif acc_int >= 7500:
63
+ base = base_ratings[6]
64
+ offset = (acc_int - 7500) / 500.0 * (base_ratings[5] - base)
65
+ elif acc_int >= 7000:
66
+ base = base_ratings[7]
67
+ offset = (acc_int - 7000) / 500.0 * (base_ratings[6] - base)
68
+ else:
69
+ base, offset = 0.0, 0.0
70
+
71
+ return int(base + offset)
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "nonebot-plugin-dancecube"
3
+ version = "0.1.2"
4
+ description = "一个简单的舞立方插件"
5
+ authors = [
6
+ { name = "1v7w", email = "gascd11@163.com" },
7
+ ]
8
+ dependencies = [
9
+ "nonebot2>=2.2.0",
10
+ "nonebot-adapter-onebot>=2.2.0",
11
+ "httpx>=0.24.0",
12
+ "Pillow>=10.0.0",
13
+ "pydantic>=2.0.0",
14
+ "nonebot_plugin_apscheduler>=0.3.0",
15
+ "nonebot_plugin_htmlrender>=0.3.0",
16
+ "nonebot_plugin_localstore>=0.7.0",
17
+ ]
18
+ requires-python = ">=3.10"
19
+ readme = "README.md"
20
+ license = { text = "MIT" }
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/1v7w/nonebot-plugin-dancecube"
24
+ Repository = "https://github.com/1v7w/nonebot-plugin-dancecube"
25
+ Bug-Tracker = "https://github.com/1v7w/nonebot-plugin-dancecube"
26
+
27
+ [tool.nonebot]
28
+ plugins = ["nonebot_plugin_dancecube"]
29
+ plugin_dirs = ["nonebot_plugin_dancecube"]
30
+
31
+ [build-system]
32
+ requires = ["poetry-core"]
33
+ build-backend = "poetry.core.masonry.api"