nonebot-plugin-msg-rank-card 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.
Files changed (25) hide show
  1. nonebot_plugin_msg_rank_card-0.1.0/.github/workflows/pypi-publish.yml +39 -0
  2. nonebot_plugin_msg_rank_card-0.1.0/.gitignore +176 -0
  3. nonebot_plugin_msg_rank_card-0.1.0/LICENSE +21 -0
  4. nonebot_plugin_msg_rank_card-0.1.0/PKG-INFO +132 -0
  5. nonebot_plugin_msg_rank_card-0.1.0/README.md +104 -0
  6. nonebot_plugin_msg_rank_card-0.1.0/nonebot_plugin_msg_rank_card/__init__.py +85 -0
  7. nonebot_plugin_msg_rank_card-0.1.0/nonebot_plugin_msg_rank_card/card_generator.py +279 -0
  8. nonebot_plugin_msg_rank_card-0.1.0/nonebot_plugin_msg_rank_card/config.py +17 -0
  9. nonebot_plugin_msg_rank_card-0.1.0/nonebot_plugin_msg_rank_card/data_source.py +127 -0
  10. nonebot_plugin_msg_rank_card-0.1.0/pyproject.toml +58 -0
  11. nonebot_plugin_msg_rank_card-0.1.0/resources/bg/bg1.jpg +0 -0
  12. nonebot_plugin_msg_rank_card-0.1.0/resources/bg/bg2.jpg +0 -0
  13. nonebot_plugin_msg_rank_card-0.1.0/resources/bg/bg3.jpg +0 -0
  14. nonebot_plugin_msg_rank_card-0.1.0/resources/frame/1.png +0 -0
  15. nonebot_plugin_msg_rank_card-0.1.0/resources/frame/10.png +0 -0
  16. nonebot_plugin_msg_rank_card-0.1.0/resources/frame/2.png +0 -0
  17. nonebot_plugin_msg_rank_card-0.1.0/resources/frame/3.png +0 -0
  18. nonebot_plugin_msg_rank_card-0.1.0/resources/frame/4.png +0 -0
  19. nonebot_plugin_msg_rank_card-0.1.0/resources/frame/5.png +0 -0
  20. nonebot_plugin_msg_rank_card-0.1.0/resources/frame/6.png +0 -0
  21. nonebot_plugin_msg_rank_card-0.1.0/resources/frame/7.png +0 -0
  22. nonebot_plugin_msg_rank_card-0.1.0/resources/frame/8.png +0 -0
  23. nonebot_plugin_msg_rank_card-0.1.0/resources/frame/9.png +0 -0
  24. nonebot_plugin_msg_rank_card-0.1.0/resources/ttf//347/224/234/350/214/266/346/231/232/351/243/216/346/237/224/350/275/257/350/256/260.ttf +0 -0
  25. nonebot_plugin_msg_rank_card-0.1.0/tests/test_load.py +10 -0
@@ -0,0 +1,39 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - '*'
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ pypi-publish:
11
+ name: Upload release to PyPI
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ id-token: write
15
+ contents: read
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: Set up Python
19
+ uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.x"
22
+ - name: Install pypa/build
23
+ run: >-
24
+ python -m
25
+ pip install
26
+ build
27
+ --user
28
+ - name: Build a binary wheel and a source tarball
29
+ run: >-
30
+ python -m
31
+ build
32
+ --sdist
33
+ --wheel
34
+ --outdir dist/
35
+ .
36
+ - name: Publish distribution to PyPI
37
+ uses: pypa/gh-action-pypi-publish@release/v1
38
+ with:
39
+ password: ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1,176 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+ # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
104
+ poetry.toml
105
+
106
+ # pdm
107
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
108
+ #pdm.lock
109
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
110
+ # in version control.
111
+ # https://pdm.fming.dev/#use-with-ide
112
+ .pdm-python
113
+
114
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
115
+ __pypackages__/
116
+
117
+ # Celery stuff
118
+ celerybeat-schedule
119
+ celerybeat.pid
120
+
121
+ # SageMath parsed files
122
+ *.sage.py
123
+
124
+ # Environments
125
+ .env
126
+ .venv
127
+ env/
128
+ venv/
129
+ ENV/
130
+ env.bak/
131
+ venv.bak/
132
+
133
+ # Spyder project settings
134
+ .spyderproject
135
+ .spyproject
136
+
137
+ # Rope project settings
138
+ .ropeproject
139
+
140
+ # mkdocs documentation
141
+ /site
142
+
143
+ # mypy
144
+ .mypy_cache/
145
+ .dmypy.json
146
+ dmypy.json
147
+
148
+ # Pyre type checker
149
+ .pyre/
150
+
151
+ # pytype static type analyzer
152
+ .pytype/
153
+
154
+ # Cython debug symbols
155
+ cython_debug/
156
+
157
+ # ruff
158
+ .ruff_cache/
159
+
160
+ # LSP config files
161
+ pyrightconfig.json
162
+
163
+ # PyCharm
164
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
167
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
168
+ #.idea/
169
+
170
+ # VisualStudioCode
171
+ .vscode/*
172
+ !.vscode/settings.json
173
+ !.vscode/tasks.json
174
+ !.vscode/launch.json
175
+ !.vscode/extensions.json
176
+ !.vscode/*.code-snippets
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 BigCherryBalls
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,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: nonebot-plugin-msg-rank-card
3
+ Version: 0.1.0
4
+ Summary: 每日水群排行榜图片生成插件,记录群聊消息并生成精美排行榜
5
+ Project-URL: Homepage, https://github.com/TonyLiangP2010405/nonebot-plugin-msg-rank-card
6
+ Project-URL: Repository, https://github.com/TonyLiangP2010405/nonebot-plugin-msg-rank-card
7
+ Author: TonyLiangP2010405
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: card,message,nonebot,nonebot2,plugin,rank
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.9
19
+ Requires-Dist: httpx>=0.24.0
20
+ Requires-Dist: nonebot-adapter-onebot>=2.4.0
21
+ Requires-Dist: nonebot2>=2.2.0
22
+ Requires-Dist: pillow>=10.0.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: nonebug>=0.3; extra == 'dev'
25
+ Requires-Dist: pytest>=7.0; extra == 'dev'
26
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ <div align="center">
30
+ <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>
31
+ <br>
32
+ <p><img src="https://github.com/A-kirami/nonebot-plugin-template/blob/resources/NoneBotPlugin.svg" width="240" alt="NoneBotPluginText"></p>
33
+ </div>
34
+
35
+ <div align="center">
36
+
37
+ # nonebot-plugin-msg-rank-card
38
+
39
+ _✨ 每日水群排行榜图片生成插件 ✨_
40
+
41
+ <a href="./LICENSE">
42
+ <img src="https://img.shields.io/github/license/TonyLiangP2010405/nonebot-plugin-msg-rank-card.svg" alt="license">
43
+ </a>
44
+ <a href="https://pypi.python.org/pypi/nonebot-plugin-msg-rank-card">
45
+ <img src="https://img.shields.io/pypi/v/nonebot-plugin-msg-rank-card.svg" alt="pypi">
46
+ </a>
47
+ <img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="python">
48
+
49
+ </div>
50
+
51
+ ## 📖 介绍
52
+
53
+ 本插件基于 [MsgRankCard](https://github.com/TonyLiangP2010405/MsgRankCard) 项目改写,是一个 NoneBot2 插件,用于记录群聊消息并生成精美的每日水群排行榜图片。
54
+
55
+ 功能特点:
56
+ - 📝 自动记录群聊消息数量和在线时长
57
+ - 🎨 生成精美的排行榜图片,支持自定义背景、边框和字体
58
+ - 🖼️ 自动获取用户 QQ 头像
59
+ - 🏆 支持查看今日水群排行榜
60
+ - 🧹 支持自动清理过期数据
61
+
62
+ ## 💿 安装
63
+
64
+ ### 使用 nb-cli 安装
65
+
66
+ 在 nonebot2 项目的根目录下打开命令行,输入以下指令即可安装
67
+
68
+ ```bash
69
+ nb plugin install nonebot-plugin-msg-rank-card
70
+ ```
71
+
72
+ ### 使用包管理器安装
73
+
74
+ 在 nonebot2 项目的插件目录下,打开命令行,根据你使用的包管理器,输入相应的安装命令
75
+
76
+ <details>
77
+ <summary>pip</summary>
78
+
79
+ ```bash
80
+ pip install nonebot-plugin-msg-rank-card
81
+ ```
82
+ </details>
83
+
84
+ <details>
85
+ <summary>poetry</summary>
86
+
87
+ ```bash
88
+ poetry add nonebot-plugin-msg-rank-card
89
+ ```
90
+ </details>
91
+
92
+ 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件,在 `[tool.nonebot]` 部分追加写入
93
+
94
+ ```toml
95
+ plugins = ["nonebot_plugin_msg_rank_card"]
96
+ ```
97
+
98
+ ## ⚙️ 配置
99
+
100
+ 在 nonebot2 项目的 `.env` 文件中添加下表中的配置
101
+
102
+ | 配置项 | 必填 | 默认值 | 说明 |
103
+ |:-----:|:----:|:----:|:----:|
104
+ | msg_rank_data_path | 否 | data/msg_rank | 数据存储路径 |
105
+ | msg_rank_resource_path | 否 | 插件目录/resources | 资源文件路径(背景图、边框、字体) |
106
+ | msg_rank_max_count | 否 | 10 | 排行榜最大显示人数 |
107
+ | msg_rank_auto_clean | 否 | true | 是否自动清理过期数据 |
108
+ | msg_rank_title | 否 | 今日水群排行榜 | 排行榜标题 |
109
+
110
+ ## 🎉 使用
111
+
112
+ ### 指令表
113
+
114
+ | 指令 | 权限 | 需要@ | 范围 | 说明 |
115
+ |:-----:|:----:|:----:|:----:|:----:|
116
+ | 水群榜 / msgrank | 群员 | 否 | 群聊 | 查看今日水群排行榜 |
117
+ | 清理水群数据 / clean_msgrank | 群主/管理员 | 否 | 群聊 | 清理过期数据 |
118
+
119
+ ### 效果图
120
+
121
+ 插件会自动记录群聊消息,发送 `/水群榜` 即可生成精美排行榜图片。
122
+
123
+ ### 自定义资源
124
+
125
+ 你可以替换插件目录下 `resources` 文件夹中的资源文件:
126
+ - `bg/` - 背景图片(随机选择)
127
+ - `frame/` - 排名边框装饰
128
+ - `ttf/` - 自定义字体(第一个 ttf 文件会被使用)
129
+
130
+ ## 📄 许可证
131
+
132
+ 本项目基于 [MsgRankCard](https://github.com/TonyLiangP2010405/MsgRankCard) 改写,采用 MIT 许可证。
@@ -0,0 +1,104 @@
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-msg-rank-card
10
+
11
+ _✨ 每日水群排行榜图片生成插件 ✨_
12
+
13
+ <a href="./LICENSE">
14
+ <img src="https://img.shields.io/github/license/TonyLiangP2010405/nonebot-plugin-msg-rank-card.svg" alt="license">
15
+ </a>
16
+ <a href="https://pypi.python.org/pypi/nonebot-plugin-msg-rank-card">
17
+ <img src="https://img.shields.io/pypi/v/nonebot-plugin-msg-rank-card.svg" alt="pypi">
18
+ </a>
19
+ <img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="python">
20
+
21
+ </div>
22
+
23
+ ## 📖 介绍
24
+
25
+ 本插件基于 [MsgRankCard](https://github.com/TonyLiangP2010405/MsgRankCard) 项目改写,是一个 NoneBot2 插件,用于记录群聊消息并生成精美的每日水群排行榜图片。
26
+
27
+ 功能特点:
28
+ - 📝 自动记录群聊消息数量和在线时长
29
+ - 🎨 生成精美的排行榜图片,支持自定义背景、边框和字体
30
+ - 🖼️ 自动获取用户 QQ 头像
31
+ - 🏆 支持查看今日水群排行榜
32
+ - 🧹 支持自动清理过期数据
33
+
34
+ ## 💿 安装
35
+
36
+ ### 使用 nb-cli 安装
37
+
38
+ 在 nonebot2 项目的根目录下打开命令行,输入以下指令即可安装
39
+
40
+ ```bash
41
+ nb plugin install nonebot-plugin-msg-rank-card
42
+ ```
43
+
44
+ ### 使用包管理器安装
45
+
46
+ 在 nonebot2 项目的插件目录下,打开命令行,根据你使用的包管理器,输入相应的安装命令
47
+
48
+ <details>
49
+ <summary>pip</summary>
50
+
51
+ ```bash
52
+ pip install nonebot-plugin-msg-rank-card
53
+ ```
54
+ </details>
55
+
56
+ <details>
57
+ <summary>poetry</summary>
58
+
59
+ ```bash
60
+ poetry add nonebot-plugin-msg-rank-card
61
+ ```
62
+ </details>
63
+
64
+ 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件,在 `[tool.nonebot]` 部分追加写入
65
+
66
+ ```toml
67
+ plugins = ["nonebot_plugin_msg_rank_card"]
68
+ ```
69
+
70
+ ## ⚙️ 配置
71
+
72
+ 在 nonebot2 项目的 `.env` 文件中添加下表中的配置
73
+
74
+ | 配置项 | 必填 | 默认值 | 说明 |
75
+ |:-----:|:----:|:----:|:----:|
76
+ | msg_rank_data_path | 否 | data/msg_rank | 数据存储路径 |
77
+ | msg_rank_resource_path | 否 | 插件目录/resources | 资源文件路径(背景图、边框、字体) |
78
+ | msg_rank_max_count | 否 | 10 | 排行榜最大显示人数 |
79
+ | msg_rank_auto_clean | 否 | true | 是否自动清理过期数据 |
80
+ | msg_rank_title | 否 | 今日水群排行榜 | 排行榜标题 |
81
+
82
+ ## 🎉 使用
83
+
84
+ ### 指令表
85
+
86
+ | 指令 | 权限 | 需要@ | 范围 | 说明 |
87
+ |:-----:|:----:|:----:|:----:|:----:|
88
+ | 水群榜 / msgrank | 群员 | 否 | 群聊 | 查看今日水群排行榜 |
89
+ | 清理水群数据 / clean_msgrank | 群主/管理员 | 否 | 群聊 | 清理过期数据 |
90
+
91
+ ### 效果图
92
+
93
+ 插件会自动记录群聊消息,发送 `/水群榜` 即可生成精美排行榜图片。
94
+
95
+ ### 自定义资源
96
+
97
+ 你可以替换插件目录下 `resources` 文件夹中的资源文件:
98
+ - `bg/` - 背景图片(随机选择)
99
+ - `frame/` - 排名边框装饰
100
+ - `ttf/` - 自定义字体(第一个 ttf 文件会被使用)
101
+
102
+ ## 📄 许可证
103
+
104
+ 本项目基于 [MsgRankCard](https://github.com/TonyLiangP2010405/MsgRankCard) 改写,采用 MIT 许可证。
@@ -0,0 +1,85 @@
1
+ from nonebot import on_command, on_message
2
+ from nonebot.adapters import Bot, Message
3
+ from nonebot.adapters.onebot.v11 import GroupMessageEvent, MessageSegment
4
+ from nonebot.params import CommandArg
5
+ from nonebot.plugin import PluginMetadata
6
+
7
+ from .card_generator import MemberInfo, RankCardGenerator, download_avatar
8
+ from .config import Config
9
+ from .data_source import clean_old_data, get_rank_data, record_message
10
+
11
+ __plugin_meta__ = PluginMetadata(
12
+ name="每日水群排行榜",
13
+ description="记录群聊消息,生成精美的每日水群排行榜图片",
14
+ usage="发送 /水群榜 或 /msgrank 查看今日排行榜",
15
+ type="application",
16
+ homepage="https://github.com/TonyLiangP2010405/nonebot-plugin-msg-rank-card",
17
+ config=Config,
18
+ supported_adapters={"~onebot.v11"},
19
+ )
20
+
21
+ # 消息监听:记录群聊消息
22
+ msg_handler = on_message(priority=99, block=False)
23
+
24
+ # 排行榜命令
25
+ rank_cmd = on_command("水群榜", aliases={"msgrank", "msg_rank", "今日水群榜"}, priority=10, block=True)
26
+
27
+ # 清理命令(仅管理员可用)
28
+ clean_cmd = on_command("清理水群数据", aliases={"clean_msgrank"}, priority=10, block=True)
29
+
30
+
31
+ @msg_handler.handle()
32
+ async def handle_message(bot: Bot, event: GroupMessageEvent):
33
+ """记录群聊消息"""
34
+ group_id = str(event.group_id)
35
+ user_id = str(event.user_id)
36
+ user_name = event.sender.nickname or "未知用户"
37
+
38
+ # 记录消息
39
+ record_message(group_id, user_id, user_name, msg_length=1)
40
+
41
+
42
+ @rank_cmd.handle()
43
+ async def handle_rank(bot: Bot, event: GroupMessageEvent, args: Message = CommandArg()):
44
+ """处理排行榜命令"""
45
+ group_id = str(event.group_id)
46
+
47
+ # 获取排行榜数据
48
+ rank_data = get_rank_data(group_id)
49
+
50
+ if not rank_data:
51
+ await rank_cmd.finish("今天还没有人水群呢~")
52
+ return
53
+
54
+ # 构建成员信息列表
55
+ members = []
56
+ for info in rank_data:
57
+ # 下载头像
58
+ avatar = await download_avatar(info["user_id"])
59
+ member = MemberInfo(
60
+ name=info["name"],
61
+ msg_count=info["msg_count"],
62
+ time_seconds=info["time_seconds"],
63
+ head_pic=avatar,
64
+ )
65
+ members.append(member)
66
+
67
+ # 生成图片
68
+ generator = RankCardGenerator()
69
+ image_bytes = generator.generate_card_bytes(members)
70
+
71
+ # 发送图片
72
+ await rank_cmd.finish(MessageSegment.image(image_bytes))
73
+
74
+
75
+ @clean_cmd.handle()
76
+ async def handle_clean(bot: Bot, event: GroupMessageEvent):
77
+ """清理过期数据"""
78
+ # 简单权限检查:只有群主和管理员可以清理
79
+ sender_role = event.sender.role if hasattr(event.sender, "role") else "member"
80
+ if sender_role not in ["owner", "admin"]:
81
+ await clean_cmd.finish("只有群主或管理员才能清理数据哦~")
82
+ return
83
+
84
+ clean_old_data()
85
+ await clean_cmd.finish("已清理过期数据~")
@@ -0,0 +1,279 @@
1
+ import contextlib
2
+ import random
3
+ from io import BytesIO
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import httpx
8
+ from nonebot import get_driver
9
+ from nonebot.log import logger
10
+ from PIL import Image, ImageDraw, ImageFont
11
+
12
+ from .config import Config
13
+
14
+ # 资源目录(默认值,会在首次使用时根据配置更新)
15
+ _resource_dir: Path = Path(__file__).parent.parent / "resources"
16
+
17
+
18
+ def _get_resource_dir() -> Path:
19
+ """获取资源目录(延迟加载)"""
20
+ global _resource_dir
21
+ try:
22
+ config = Config.parse_obj(get_driver().config.dict())
23
+ if config.msg_rank_resource_path:
24
+ _resource_dir = Path(config.msg_rank_resource_path)
25
+ except Exception:
26
+ pass
27
+ return _resource_dir
28
+
29
+
30
+ def _get_bg_dir() -> Path:
31
+ return _get_resource_dir() / "bg"
32
+
33
+
34
+ def _get_frame_dir() -> Path:
35
+ return _get_resource_dir() / "frame"
36
+
37
+
38
+ def _get_ttf_dir() -> Path:
39
+ return _get_resource_dir() / "ttf"
40
+
41
+
42
+ # 画布尺寸
43
+ BG_WIDTH = 672
44
+ BG_HEIGHT = 1080
45
+ CARD_WIDTH = 640
46
+ CARD_HEIGHT = 80
47
+ HEAD_WIDTH = 60
48
+ HEAD_HEIGHT = 60
49
+ HEAD_X = 20
50
+ HEAD_Y = 10
51
+
52
+
53
+ class MemberInfo:
54
+ """成员信息"""
55
+
56
+ def __init__(self, name: str, msg_count: int, time_seconds: int, head_pic: Optional[Image.Image] = None):
57
+ self.name = name
58
+ self.msg_count = msg_count
59
+ self.time_seconds = time_seconds
60
+ self.head_pic = head_pic
61
+
62
+
63
+ class RankCardGenerator:
64
+ """排行榜图片生成器"""
65
+
66
+ _instance = None
67
+
68
+ def __new__(cls):
69
+ if cls._instance is None:
70
+ cls._instance = super().__new__(cls)
71
+ cls._instance._initialized = False
72
+ return cls._instance
73
+
74
+ def __init__(self):
75
+ if self._initialized:
76
+ return
77
+ self._initialized = True
78
+
79
+ self.bg_files = self._get_files(_get_bg_dir(), "bg")
80
+ self.frame_files = self._get_files(_get_frame_dir(), "frame")
81
+ self.font_file = self._get_first_ttf()
82
+
83
+ def _get_files(self, dir_path: Path, name_filter: str) -> list[Path]:
84
+ """获取目录下的文件"""
85
+ if not dir_path.exists() or not dir_path.is_dir():
86
+ return []
87
+ return [f for f in dir_path.iterdir() if f.is_file() and name_filter in f.name]
88
+
89
+ def _get_first_ttf(self) -> Optional[Path]:
90
+ """获取第一个 ttf 字体文件"""
91
+ ttf_dir = _get_ttf_dir()
92
+ if not ttf_dir.exists() or not ttf_dir.is_dir():
93
+ return None
94
+ ttfs = [f for f in ttf_dir.iterdir() if f.is_file() and f.suffix.lower() == ".ttf"]
95
+ return ttfs[0] if ttfs else None
96
+
97
+ def _load_font(self, size: int) -> ImageFont.FreeTypeFont:
98
+ """加载字体"""
99
+ if self.font_file:
100
+ try:
101
+ return ImageFont.truetype(str(self.font_file), size)
102
+ except Exception:
103
+ pass
104
+ # 回退到默认字体
105
+ try:
106
+ return ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", size)
107
+ except Exception:
108
+ try:
109
+ return ImageFont.truetype("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", size)
110
+ except Exception:
111
+ return ImageFont.load_default()
112
+
113
+ def _format_time(self, seconds: int) -> str:
114
+ """格式化时间"""
115
+ seconds = max(0, seconds)
116
+ hours = seconds // 3600
117
+ minutes = (seconds % 3600) // 60
118
+ remaining = seconds % 60
119
+
120
+ parts = []
121
+ if hours > 0:
122
+ parts.append(f"{hours}时")
123
+ if minutes > 0:
124
+ parts.append(f"{minutes}分")
125
+ if remaining > 0 or not parts:
126
+ parts.append(f"{remaining}秒")
127
+
128
+ return "".join(parts)
129
+
130
+ def _format_bg_image(self, image: Image.Image) -> Image.Image:
131
+ """格式化背景图:缩放并裁剪到指定尺寸,添加半透明遮罩"""
132
+ # 计算缩放比例(覆盖模式)
133
+ scale = max(BG_WIDTH / image.width, BG_HEIGHT / image.height)
134
+ new_width = int(image.width * scale)
135
+ new_height = int(image.height * scale)
136
+
137
+ # 缩放
138
+ resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
139
+
140
+ # 居中裁剪
141
+ left = (new_width - BG_WIDTH) // 2
142
+ top = (new_height - BG_HEIGHT) // 2
143
+ cropped = resized.crop((left, top, left + BG_WIDTH, top + BG_HEIGHT))
144
+
145
+ # 添加白色半透明遮罩
146
+ overlay = Image.new("RGBA", (BG_WIDTH, BG_HEIGHT), (255, 255, 255, 128))
147
+ result = Image.alpha_composite(cropped.convert("RGBA"), overlay)
148
+
149
+ return result
150
+
151
+ def _create_head_mask(self) -> Image.Image:
152
+ """创建圆形头像遮罩"""
153
+ mask = Image.new("L", (HEAD_WIDTH, HEAD_HEIGHT), 0)
154
+ draw = ImageDraw.Draw(mask)
155
+ draw.ellipse((0, 0, HEAD_WIDTH, HEAD_HEIGHT), fill=255)
156
+ return mask
157
+
158
+ def _create_gradient_mask(self, width: int, height: int) -> Image.Image:
159
+ """创建渐变遮罩(从左到右渐变透明)"""
160
+ mask = Image.new("L", (width, height), 0)
161
+ for x in range(width):
162
+ alpha = int(255 * (1 - x / width))
163
+ for y in range(height):
164
+ mask.putpixel((x, y), alpha)
165
+ return mask
166
+
167
+ def _draw_member_card(self, member: MemberInfo, frame_path: Optional[Path], font: ImageFont.FreeTypeFont) -> Image.Image:
168
+ """绘制单个成员卡片"""
169
+ # 创建卡片背景
170
+ card = Image.new("RGBA", (CARD_WIDTH, CARD_HEIGHT), (255, 255, 255, 255))
171
+ draw = ImageDraw.Draw(card)
172
+
173
+ # 加载边框
174
+ frame = None
175
+ if frame_path and frame_path.exists():
176
+ with contextlib.suppress(Exception):
177
+ frame = Image.open(frame_path).convert("RGBA")
178
+
179
+ # 处理头像
180
+ if member.head_pic:
181
+ head = member.head_pic.copy()
182
+ # 缩放头像
183
+ head = head.resize((HEAD_WIDTH, HEAD_HEIGHT), Image.Resampling.LANCZOS)
184
+ # 创建圆形遮罩
185
+ mask = self._create_head_mask()
186
+ # 应用圆形遮罩
187
+ head.putalpha(mask)
188
+ # 粘贴头像
189
+ card.paste(head, (HEAD_X, HEAD_Y), head)
190
+
191
+ # 绘制边框
192
+ if frame:
193
+ with contextlib.suppress(Exception):
194
+ frame_resized = frame.resize((CARD_WIDTH, CARD_HEIGHT), Image.Resampling.LANCZOS)
195
+ card = Image.alpha_composite(card, frame_resized)
196
+
197
+ # 绘制文字
198
+ name = member.name
199
+ if len(name) > 12:
200
+ name = name[:12] + "..."
201
+
202
+ # 使用字体
203
+ name_font = self._load_font(24)
204
+ count_font = self._load_font(16)
205
+ time_font = self._load_font(20)
206
+
207
+ # 绘制名字
208
+ draw = ImageDraw.Draw(card)
209
+ draw.text((100, 15), name, fill=(0, 0, 0, 255), font=name_font)
210
+
211
+ # 绘制消息数
212
+ draw.text((100, 50), f"消息数: {member.msg_count}条", fill=(80, 80, 80, 255), font=count_font)
213
+
214
+ # 绘制时长(右对齐)
215
+ time_str = self._format_time(member.time_seconds)
216
+ draw.text((440, 40), time_str, fill=(100, 100, 100, 255), font=time_font)
217
+
218
+ return card
219
+
220
+ def generate_card(self, members: list[MemberInfo], title: Optional[str] = None) -> Image.Image:
221
+ """生成排行榜图片"""
222
+ try:
223
+ config = Config.parse_obj(get_driver().config.dict())
224
+ title = title or config.msg_rank_title
225
+ except Exception:
226
+ title = title or "今日水群排行榜"
227
+
228
+ # 创建背景
229
+ if self.bg_files:
230
+ try:
231
+ bg_path = random.choice(self.bg_files)
232
+ bg_image = Image.open(bg_path).convert("RGBA")
233
+ background = self._format_bg_image(bg_image)
234
+ except Exception:
235
+ background = Image.new("RGBA", (BG_WIDTH, BG_HEIGHT), (255, 255, 255, 255))
236
+ else:
237
+ background = Image.new("RGBA", (BG_WIDTH, BG_HEIGHT), (255, 255, 255, 255))
238
+
239
+ draw = ImageDraw.Draw(background)
240
+
241
+ # 绘制标题
242
+ title_font = self._load_font(60)
243
+ # 计算标题居中位置
244
+ bbox = draw.textbbox((0, 0), title, font=title_font)
245
+ title_width = bbox[2] - bbox[0]
246
+ title_x = (BG_WIDTH - title_width) // 2
247
+ draw.text((title_x, 30), title, fill=(255, 0, 255, 255), font=title_font)
248
+
249
+ # 绘制成员卡片
250
+ size = min(len(self.frame_files), len(members))
251
+ for idx in range(size):
252
+ frame_path = self.frame_files[idx] if idx < len(self.frame_files) else None
253
+ card = self._draw_member_card(members[idx], frame_path, title_font)
254
+ # 粘贴到背景上
255
+ card_x = (BG_WIDTH - CARD_WIDTH) // 2
256
+ card_y = 120 + 95 * idx
257
+ background.paste(card, (card_x, card_y), card)
258
+
259
+ return background
260
+
261
+ def generate_card_bytes(self, members: list[MemberInfo], title: Optional[str] = None) -> bytes:
262
+ """生成排行榜图片并返回 bytes"""
263
+ image = self.generate_card(members, title)
264
+ buffer = BytesIO()
265
+ image.convert("RGB").save(buffer, format="PNG")
266
+ return buffer.getvalue()
267
+
268
+
269
+ async def download_avatar(user_id: str) -> Optional[Image.Image]:
270
+ """下载用户头像"""
271
+ try:
272
+ url = f"https://q1.qlogo.cn/g?b=qq&nk={user_id}&s=640"
273
+ async with httpx.AsyncClient(timeout=10) as client:
274
+ response = await client.get(url)
275
+ if response.status_code == 200:
276
+ return Image.open(BytesIO(response.content)).convert("RGBA")
277
+ except Exception as e:
278
+ logger.warning(f"下载头像失败 {user_id}: {e}")
279
+ return None
@@ -0,0 +1,17 @@
1
+
2
+ from pydantic import BaseModel
3
+
4
+
5
+ class Config(BaseModel):
6
+ """插件配置"""
7
+
8
+ # 数据存储路径
9
+ msg_rank_data_path: str = "data/msg_rank"
10
+ # 资源文件路径
11
+ msg_rank_resource_path: str = ""
12
+ # 每日排行榜最大显示人数
13
+ msg_rank_max_count: int = 10
14
+ # 是否自动清理过期数据(保留最近7天)
15
+ msg_rank_auto_clean: bool = True
16
+ # 排行榜标题
17
+ msg_rank_title: str = "今日水群排行榜"
@@ -0,0 +1,127 @@
1
+ import json
2
+ import time
3
+ from datetime import datetime, timedelta
4
+ from pathlib import Path
5
+
6
+ from nonebot import get_driver
7
+ from nonebot.log import logger
8
+
9
+ from .config import Config
10
+
11
+ # 数据目录(默认值,会在首次使用时根据配置更新)
12
+ _data_dir: Path = Path("data/msg_rank")
13
+
14
+
15
+ def _get_data_dir() -> Path:
16
+ """获取数据目录(延迟加载)"""
17
+ global _data_dir
18
+ if not _data_dir.exists():
19
+ try:
20
+ config = Config.parse_obj(get_driver().config.dict())
21
+ _data_dir = Path(config.msg_rank_data_path)
22
+ except Exception:
23
+ _data_dir = Path("data/msg_rank")
24
+ _data_dir.mkdir(parents=True, exist_ok=True)
25
+ return _data_dir
26
+
27
+
28
+ def get_today_str() -> str:
29
+ """获取今天的日期字符串"""
30
+ return datetime.now().strftime("%Y%m%d")
31
+
32
+
33
+ def get_group_data_file(group_id: str) -> Path:
34
+ """获取群数据文件路径"""
35
+ return _get_data_dir() / f"{group_id}_{get_today_str()}.json"
36
+
37
+
38
+ def load_group_data(group_id: str) -> dict:
39
+ """加载群数据"""
40
+ file_path = get_group_data_file(group_id)
41
+ if file_path.exists():
42
+ try:
43
+ with open(file_path, encoding="utf-8") as f:
44
+ return json.load(f)
45
+ except Exception as e:
46
+ logger.warning(f"加载群数据失败: {e}")
47
+ return {}
48
+
49
+
50
+ def save_group_data(group_id: str, data: dict):
51
+ """保存群数据"""
52
+ file_path = get_group_data_file(group_id)
53
+ try:
54
+ with open(file_path, "w", encoding="utf-8") as f:
55
+ json.dump(data, f, ensure_ascii=False, indent=2)
56
+ except Exception as e:
57
+ logger.warning(f"保存群数据失败: {e}")
58
+
59
+
60
+ def record_message(group_id: str, user_id: str, user_name: str, msg_length: int = 1):
61
+ """记录一条消息"""
62
+ data = load_group_data(group_id)
63
+
64
+ if user_id not in data:
65
+ data[user_id] = {
66
+ "name": user_name,
67
+ "msg_count": 0,
68
+ "time_seconds": 0,
69
+ "last_msg_time": 0,
70
+ }
71
+
72
+ # 更新消息数
73
+ data[user_id]["msg_count"] += msg_length
74
+
75
+ # 计算在线时长(简单估算:如果距离上次消息在5分钟内,累加时间差)
76
+ now = int(time.time())
77
+ last_time = data[user_id].get("last_msg_time", 0)
78
+ if last_time > 0 and now - last_time < 300: # 5分钟内
79
+ data[user_id]["time_seconds"] += now - last_time
80
+
81
+ data[user_id]["last_msg_time"] = now
82
+ data[user_id]["name"] = user_name # 更新昵称
83
+
84
+ save_group_data(group_id, data)
85
+
86
+
87
+ def get_rank_data(group_id: str, max_count: int = 10) -> list[dict]:
88
+ """获取排行榜数据,按消息数排序"""
89
+ data = load_group_data(group_id)
90
+
91
+ rank_list = []
92
+ for user_id, info in data.items():
93
+ rank_list.append({
94
+ "user_id": user_id,
95
+ "name": info.get("name", "未知用户"),
96
+ "msg_count": info.get("msg_count", 0),
97
+ "time_seconds": info.get("time_seconds", 0),
98
+ })
99
+
100
+ # 按消息数降序排序
101
+ rank_list.sort(key=lambda x: x["msg_count"], reverse=True)
102
+
103
+ return rank_list[:max_count]
104
+
105
+
106
+ def clean_old_data(days: int = 7):
107
+ """清理过期数据"""
108
+ try:
109
+ config = Config.parse_obj(get_driver().config.dict())
110
+ if not config.msg_rank_auto_clean:
111
+ return
112
+ except Exception:
113
+ pass
114
+
115
+ data_dir = _get_data_dir()
116
+ cutoff = datetime.now() - timedelta(days=days)
117
+ cutoff_str = cutoff.strftime("%Y%m%d")
118
+
119
+ for file_path in data_dir.glob("*_*.json"):
120
+ try:
121
+ # 文件名格式: {group_id}_{date}.json
122
+ date_str = file_path.stem.split("_")[-1]
123
+ if date_str < cutoff_str:
124
+ file_path.unlink()
125
+ logger.info(f"清理过期数据: {file_path.name}")
126
+ except Exception:
127
+ pass
@@ -0,0 +1,58 @@
1
+ [project]
2
+ name = "nonebot-plugin-msg-rank-card"
3
+ version = "0.1.0"
4
+ description = "每日水群排行榜图片生成插件,记录群聊消息并生成精美排行榜"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.9"
8
+ authors = [
9
+ { name = "TonyLiangP2010405", email = "" },
10
+ ]
11
+ keywords = ["nonebot", "nonebot2", "plugin", "rank", "card", "message"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.9",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ ]
21
+ dependencies = [
22
+ "nonebot2>=2.2.0",
23
+ "nonebot-adapter-onebot>=2.4.0",
24
+ "pillow>=10.0.0",
25
+ "httpx>=0.24.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=7.0",
31
+ "nonebug>=0.3",
32
+ "ruff>=0.1.0",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/TonyLiangP2010405/nonebot-plugin-msg-rank-card"
37
+ Repository = "https://github.com/TonyLiangP2010405/nonebot-plugin-msg-rank-card"
38
+
39
+ [tool.nonebot]
40
+ plugins = ["nonebot_plugin_msg_rank_card"]
41
+
42
+ [build-system]
43
+ requires = ["hatchling"]
44
+ build-backend = "hatchling.build"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["nonebot_plugin_msg_rank_card"]
48
+
49
+ [tool.ruff]
50
+ line-length = 120
51
+ target-version = "py39"
52
+
53
+ [tool.ruff.lint]
54
+ select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"]
55
+ ignore = ["E501", "B008"]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
@@ -0,0 +1,10 @@
1
+ import nonebot
2
+ from nonebot.adapters.onebot.v11 import Adapter
3
+
4
+
5
+ def test_plugin_load():
6
+ nonebot.init()
7
+ driver = nonebot.get_driver()
8
+ driver.register_adapter(Adapter)
9
+ plugin = nonebot.load_plugin("nonebot_plugin_msg_rank_card")
10
+ assert plugin is not None