nonebot-plugin-sublike 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,151 @@
1
+ Metadata-Version: 2.3
2
+ Name: nonebot-plugin-sublike
3
+ Version: 0.1.2
4
+ Summary: 一个基于 `OneBot v11` 的 NoneBot2 QQ 点赞插件。
5
+ Author: ByteColtX
6
+ Author-email: ByteColtX <ByteColtX@gmail.com>
7
+ Requires-Dist: nonebot-plugin-apscheduler>=0.5.0,<1.0.0
8
+ Requires-Dist: nonebot-plugin-localstore>=0.7.4,<1.0.0
9
+ Requires-Dist: nonebot2>=2.4.0,<3.0.0
10
+ Requires-Dist: nonebot-adapter-onebot>=2.4.0,<3.0.0
11
+ Requires-Python: >=3.10
12
+ Project-URL: Homepage, https://github.com/ByteColtX/nonebot-plugin-sublike
13
+ Project-URL: Issues, https://github.com/ByteColtX/nonebot-plugin-sublike/issues
14
+ Project-URL: Repository, https://github.com/ByteColtX/nonebot-plugin-sublike.git
15
+ Description-Content-Type: text/markdown
16
+
17
+ <div align="center">
18
+ <a href="https://v2.nonebot.dev/store">
19
+ <img src="https://raw.githubusercontent.com/fllesser/nonebot-plugin-template/refs/heads/resource/.docs/NoneBotPlugin.svg" width="310" alt="logo"></a>
20
+
21
+ ## ✨ nonebot-plugin-sublike ✨
22
+ [![LICENSE](https://img.shields.io/github/license/ByteColtX/nonebot-plugin-sublike.svg)](./LICENSE)
23
+ [![pypi](https://img.shields.io/pypi/v/nonebot-plugin-sublike.svg)](https://pypi.python.org/pypi/nonebot-plugin-sublike)
24
+ [![python](https://img.shields.io/badge/python-3.10|3.11|3.12|3.13-blue.svg)](https://www.python.org)
25
+ [![uv](https://img.shields.io/badge/package%20manager-uv-black?style=flat-square&logo=uv)](https://github.com/astral-sh/uv)
26
+ <br/>
27
+ [![ruff](https://img.shields.io/badge/code%20style-ruff-black?style=flat-square&logo=ruff)](https://github.com/astral-sh/ruff)
28
+ [![pre-commit](https://results.pre-commit.ci/badge/github/ByteColtX/nonebot-plugin-sublike/master.svg)](https://results.pre-commit.ci/latest/github/ByteColtX/nonebot-plugin-sublike/master)
29
+
30
+ </div>
31
+
32
+ ## 📖 介绍
33
+
34
+ 一个基于 `OneBot v11` 的 NoneBot2 QQ 点赞插件。
35
+
36
+ 当前已支持:
37
+
38
+ - 即时点赞自己
39
+ - 即时点赞群内其他人
40
+ - 订阅定时点赞
41
+ - 订阅续期、取消订阅、订阅状态查询
42
+ - 按运行时段轮询订阅用户,并在执行前加入随机延迟
43
+
44
+ 此外还提供以下个性化配置能力:
45
+
46
+ - 自定义“赞我”“赞他”“订阅赞”等触发词
47
+ - 控制即时点赞、订阅点赞是否要求好友关系
48
+ - 控制是否允许即时点赞他人
49
+ - 配置订阅有效期、扫描间隔、运行时段和最大随机延迟
50
+ - 配置禁用插件命令的群号列表
51
+
52
+
53
+ ## 💿 安装
54
+
55
+ <details open>
56
+ <summary>使用 nb-cli 安装</summary>
57
+ 在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装
58
+
59
+ nb plugin install nonebot-plugin-sublike --upgrade
60
+ 使用 **pypi** 源安装
61
+
62
+ nb plugin install nonebot-plugin-sublike --upgrade -i "https://pypi.org/simple"
63
+ 使用**清华源**安装
64
+
65
+ nb plugin install nonebot-plugin-sublike --upgrade -i "https://pypi.tuna.tsinghua.edu.cn/simple"
66
+
67
+
68
+ </details>
69
+
70
+ <details>
71
+ <summary>使用包管理器安装</summary>
72
+ 在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令
73
+
74
+ <details open>
75
+ <summary>uv</summary>
76
+
77
+ uv add nonebot-plugin-sublike
78
+ 安装仓库 master 分支
79
+
80
+ uv add git+https://github.com/ByteColtX/nonebot-plugin-sublike@master
81
+ </details>
82
+
83
+ <details>
84
+ <summary>pdm</summary>
85
+
86
+ pdm add nonebot-plugin-sublike
87
+ 安装仓库 master 分支
88
+
89
+ pdm add git+https://github.com/ByteColtX/nonebot-plugin-sublike@master
90
+ </details>
91
+ <details>
92
+ <summary>poetry</summary>
93
+
94
+ poetry add nonebot-plugin-sublike
95
+ 安装仓库 master 分支
96
+
97
+ poetry add git+https://github.com/ByteColtX/nonebot-plugin-sublike@master
98
+ </details>
99
+
100
+ 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分追加写入
101
+
102
+ plugins = ["nonebot_plugin_sublike"]
103
+
104
+ </details>
105
+
106
+ <details>
107
+ <summary>使用 nbr 安装(使用 uv 管理依赖可用)</summary>
108
+
109
+ [nbr](https://github.com/fllesser/nbr) 是一个基于 uv 的 nb-cli,可以方便地管理 nonebot2
110
+
111
+ nbr plugin install nonebot-plugin-sublike
112
+ 使用 **pypi** 源安装
113
+
114
+ nbr plugin install nonebot-plugin-sublike -i "https://pypi.org/simple"
115
+ 使用**清华源**安装
116
+
117
+ nbr plugin install nonebot-plugin-sublike -i "https://pypi.tuna.tsinghua.edu.cn/simple"
118
+
119
+ </details>
120
+
121
+
122
+ ## ⚙️ 配置
123
+
124
+ 在 nonebot2 项目的 `.env` 文件中添加下表中的配置
125
+
126
+ | 配置项 | 必填 | 默认值 | 说明 |
127
+ | :---: | :---: | :---: | :--- |
128
+ | `sublike_cmd_me` | 否 | `["赞我", "草我"]` | 即时点赞自己的触发词 |
129
+ | `sublike_cmd_other` | 否 | `["赞ta","赞他"]` | 即时点赞他人的触发词 |
130
+ | `sublike_cmd_sub` | 否 | `["订阅赞", "天天赞我"]` | 订阅赞触发词 |
131
+ | `sublike_cmd_unsub` | 否 | `["取消订阅赞"]` | 取消订阅触发词 |
132
+ | `sublike_cmd_status` | 否 | `["查询订阅赞"]` | 订阅状态查询触发词 |
133
+ | `sublike_need_friend_me` | 否 | `false` | 即时点赞是否要求好友关系 |
134
+ | `sublike_need_friend_sub` | 否 | `true` | 订阅点赞是否要求好友关系 |
135
+ | `sublike_allow_other` | 否 | `true` | 是否允许即时点赞他人 |
136
+ | `sublike_sub_expire_days` | 否 | `7` | 订阅有效期天数,需在过期前再次触发订阅命令续期 |
137
+ | `sublike_sched_interval` | 否 | `60` | 定时扫描间隔,单位分钟 |
138
+ | `sublike_sched_start` | 否 | `8` | 定时任务开始小时 |
139
+ | `sublike_sched_end` | 否 | `0` | 定时任务结束小时,`0` 表示次日 `00:00` |
140
+ | `sublike_delay_max` | 否 | `2` | 单个订阅用户执行前的最大随机延迟,单位分钟 |
141
+ | `sublike_banned_groups` | 否 | `[]` | 禁用插件命令的群号列表 |
142
+
143
+ ## 🎉 使用
144
+ ### 指令表
145
+ | 指令 | 权限 | 需要@ | 范围 | 说明 |
146
+ | :---: | :---: | :---: | :---: | :--- |
147
+ | `赞我` | 群员 | 否 | 群聊 / 私聊 | 给发送者点赞直到当日上限 |
148
+ | `赞他 @用户` | 群员 | 否 | 群聊 | 给目标用户点赞直到当日上限 |
149
+ | `订阅赞` / `天天赞我` | 群员 | 否 | 群聊 / 私聊 | 创建或续期订阅赞 |
150
+ | `取消订阅赞` | 群员 | 否 | 群聊 / 私聊 | 取消自己的订阅赞 |
151
+ | `查询订阅赞` | 群员 | 否 | 群聊 / 私聊 | 普通用户查看自己的订阅状态,`SUPERUSERS` 可查看全部有效订阅 |
@@ -0,0 +1,135 @@
1
+ <div align="center">
2
+ <a href="https://v2.nonebot.dev/store">
3
+ <img src="https://raw.githubusercontent.com/fllesser/nonebot-plugin-template/refs/heads/resource/.docs/NoneBotPlugin.svg" width="310" alt="logo"></a>
4
+
5
+ ## ✨ nonebot-plugin-sublike ✨
6
+ [![LICENSE](https://img.shields.io/github/license/ByteColtX/nonebot-plugin-sublike.svg)](./LICENSE)
7
+ [![pypi](https://img.shields.io/pypi/v/nonebot-plugin-sublike.svg)](https://pypi.python.org/pypi/nonebot-plugin-sublike)
8
+ [![python](https://img.shields.io/badge/python-3.10|3.11|3.12|3.13-blue.svg)](https://www.python.org)
9
+ [![uv](https://img.shields.io/badge/package%20manager-uv-black?style=flat-square&logo=uv)](https://github.com/astral-sh/uv)
10
+ <br/>
11
+ [![ruff](https://img.shields.io/badge/code%20style-ruff-black?style=flat-square&logo=ruff)](https://github.com/astral-sh/ruff)
12
+ [![pre-commit](https://results.pre-commit.ci/badge/github/ByteColtX/nonebot-plugin-sublike/master.svg)](https://results.pre-commit.ci/latest/github/ByteColtX/nonebot-plugin-sublike/master)
13
+
14
+ </div>
15
+
16
+ ## 📖 介绍
17
+
18
+ 一个基于 `OneBot v11` 的 NoneBot2 QQ 点赞插件。
19
+
20
+ 当前已支持:
21
+
22
+ - 即时点赞自己
23
+ - 即时点赞群内其他人
24
+ - 订阅定时点赞
25
+ - 订阅续期、取消订阅、订阅状态查询
26
+ - 按运行时段轮询订阅用户,并在执行前加入随机延迟
27
+
28
+ 此外还提供以下个性化配置能力:
29
+
30
+ - 自定义“赞我”“赞他”“订阅赞”等触发词
31
+ - 控制即时点赞、订阅点赞是否要求好友关系
32
+ - 控制是否允许即时点赞他人
33
+ - 配置订阅有效期、扫描间隔、运行时段和最大随机延迟
34
+ - 配置禁用插件命令的群号列表
35
+
36
+
37
+ ## 💿 安装
38
+
39
+ <details open>
40
+ <summary>使用 nb-cli 安装</summary>
41
+ 在 nonebot2 项目的根目录下打开命令行, 输入以下指令即可安装
42
+
43
+ nb plugin install nonebot-plugin-sublike --upgrade
44
+ 使用 **pypi** 源安装
45
+
46
+ nb plugin install nonebot-plugin-sublike --upgrade -i "https://pypi.org/simple"
47
+ 使用**清华源**安装
48
+
49
+ nb plugin install nonebot-plugin-sublike --upgrade -i "https://pypi.tuna.tsinghua.edu.cn/simple"
50
+
51
+
52
+ </details>
53
+
54
+ <details>
55
+ <summary>使用包管理器安装</summary>
56
+ 在 nonebot2 项目的插件目录下, 打开命令行, 根据你使用的包管理器, 输入相应的安装命令
57
+
58
+ <details open>
59
+ <summary>uv</summary>
60
+
61
+ uv add nonebot-plugin-sublike
62
+ 安装仓库 master 分支
63
+
64
+ uv add git+https://github.com/ByteColtX/nonebot-plugin-sublike@master
65
+ </details>
66
+
67
+ <details>
68
+ <summary>pdm</summary>
69
+
70
+ pdm add nonebot-plugin-sublike
71
+ 安装仓库 master 分支
72
+
73
+ pdm add git+https://github.com/ByteColtX/nonebot-plugin-sublike@master
74
+ </details>
75
+ <details>
76
+ <summary>poetry</summary>
77
+
78
+ poetry add nonebot-plugin-sublike
79
+ 安装仓库 master 分支
80
+
81
+ poetry add git+https://github.com/ByteColtX/nonebot-plugin-sublike@master
82
+ </details>
83
+
84
+ 打开 nonebot2 项目根目录下的 `pyproject.toml` 文件, 在 `[tool.nonebot]` 部分追加写入
85
+
86
+ plugins = ["nonebot_plugin_sublike"]
87
+
88
+ </details>
89
+
90
+ <details>
91
+ <summary>使用 nbr 安装(使用 uv 管理依赖可用)</summary>
92
+
93
+ [nbr](https://github.com/fllesser/nbr) 是一个基于 uv 的 nb-cli,可以方便地管理 nonebot2
94
+
95
+ nbr plugin install nonebot-plugin-sublike
96
+ 使用 **pypi** 源安装
97
+
98
+ nbr plugin install nonebot-plugin-sublike -i "https://pypi.org/simple"
99
+ 使用**清华源**安装
100
+
101
+ nbr plugin install nonebot-plugin-sublike -i "https://pypi.tuna.tsinghua.edu.cn/simple"
102
+
103
+ </details>
104
+
105
+
106
+ ## ⚙️ 配置
107
+
108
+ 在 nonebot2 项目的 `.env` 文件中添加下表中的配置
109
+
110
+ | 配置项 | 必填 | 默认值 | 说明 |
111
+ | :---: | :---: | :---: | :--- |
112
+ | `sublike_cmd_me` | 否 | `["赞我", "草我"]` | 即时点赞自己的触发词 |
113
+ | `sublike_cmd_other` | 否 | `["赞ta","赞他"]` | 即时点赞他人的触发词 |
114
+ | `sublike_cmd_sub` | 否 | `["订阅赞", "天天赞我"]` | 订阅赞触发词 |
115
+ | `sublike_cmd_unsub` | 否 | `["取消订阅赞"]` | 取消订阅触发词 |
116
+ | `sublike_cmd_status` | 否 | `["查询订阅赞"]` | 订阅状态查询触发词 |
117
+ | `sublike_need_friend_me` | 否 | `false` | 即时点赞是否要求好友关系 |
118
+ | `sublike_need_friend_sub` | 否 | `true` | 订阅点赞是否要求好友关系 |
119
+ | `sublike_allow_other` | 否 | `true` | 是否允许即时点赞他人 |
120
+ | `sublike_sub_expire_days` | 否 | `7` | 订阅有效期天数,需在过期前再次触发订阅命令续期 |
121
+ | `sublike_sched_interval` | 否 | `60` | 定时扫描间隔,单位分钟 |
122
+ | `sublike_sched_start` | 否 | `8` | 定时任务开始小时 |
123
+ | `sublike_sched_end` | 否 | `0` | 定时任务结束小时,`0` 表示次日 `00:00` |
124
+ | `sublike_delay_max` | 否 | `2` | 单个订阅用户执行前的最大随机延迟,单位分钟 |
125
+ | `sublike_banned_groups` | 否 | `[]` | 禁用插件命令的群号列表 |
126
+
127
+ ## 🎉 使用
128
+ ### 指令表
129
+ | 指令 | 权限 | 需要@ | 范围 | 说明 |
130
+ | :---: | :---: | :---: | :---: | :--- |
131
+ | `赞我` | 群员 | 否 | 群聊 / 私聊 | 给发送者点赞直到当日上限 |
132
+ | `赞他 @用户` | 群员 | 否 | 群聊 | 给目标用户点赞直到当日上限 |
133
+ | `订阅赞` / `天天赞我` | 群员 | 否 | 群聊 / 私聊 | 创建或续期订阅赞 |
134
+ | `取消订阅赞` | 群员 | 否 | 群聊 / 私聊 | 取消自己的订阅赞 |
135
+ | `查询订阅赞` | 群员 | 否 | 群聊 / 私聊 | 普通用户查看自己的订阅状态,`SUPERUSERS` 可查看全部有效订阅 |
@@ -0,0 +1,69 @@
1
+ [project]
2
+ name = "nonebot-plugin-sublike"
3
+ version = "0.1.2"
4
+ description = "一个基于 `OneBot v11` 的 NoneBot2 QQ 点赞插件。"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ authors = [{ name = "ByteColtX", email = "ByteColtX@gmail.com" }]
8
+ dependencies = [
9
+ "nonebot-plugin-apscheduler>=0.5.0,<1.0.0", # 定时任务
10
+ "nonebot-plugin-localstore>=0.7.4,<1.0.0", # 存储文件
11
+ "nonebot2>=2.4.0,<3.0.0",
12
+ "nonebot-adapter-onebot>=2.4.0,<3.0.0",
13
+ ]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/ByteColtX/nonebot-plugin-sublike"
17
+ Issues = "https://github.com/ByteColtX/nonebot-plugin-sublike/issues"
18
+ Repository = "https://github.com/ByteColtX/nonebot-plugin-sublike.git"
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "bump-my-version>=1.2.7",
23
+ "ruff>=0.15.2,<1.0.0",
24
+ ]
25
+
26
+ [build-system]
27
+ requires = ["uv_build>=0.10.0,<0.11.0"]
28
+ build-backend = "uv_build"
29
+
30
+
31
+ [tool.bumpversion]
32
+ current_version = "0.1.0"
33
+ commit = true
34
+ message = "release: bump vesion from {current_version} to {new_version}"
35
+ tag = true
36
+
37
+ [[tool.bumpversion.files]]
38
+ filename = "uv.lock"
39
+ search = "name = \"nonebot-plugin-sublike\"\nversion = \"{current_version}\""
40
+ replace = "name = \"nonebot-plugin-sublike\"\nversion = \"{new_version}\""
41
+
42
+
43
+ [tool.nonebot]
44
+ plugins = ["nonebot_plugin_sublike"]
45
+
46
+ [tool.ruff]
47
+ line-length = 88
48
+
49
+ [tool.ruff.format]
50
+ line-ending = "lf"
51
+
52
+ [tool.ruff.lint]
53
+ select = [
54
+ "F", # Pyflakes
55
+ "W", # pycodestyle warnings
56
+ "E", # pycodestyle errors
57
+ "I", # isort
58
+ "UP", # pyupgrade
59
+ "RUF", # Ruff-specific rules
60
+ ]
61
+ ignore = [
62
+ "E402", # module-import-not-at-top-of-file
63
+ "RUF001", # ambiguous-unicode-character-string
64
+ "RUF002", # ambiguous-unicode-character-docstring
65
+ "RUF003", # ambiguous-unicode-character-comment
66
+ ]
67
+
68
+ [tool.ruff.lint.pyupgrade]
69
+ keep-runtime-typing = true
@@ -0,0 +1,22 @@
1
+ from nonebot import require
2
+ from nonebot.plugin import PluginMetadata
3
+
4
+ require("nonebot_plugin_apscheduler")
5
+ require("nonebot_plugin_localstore")
6
+
7
+ from .config import Config
8
+ from .matcher import like_me
9
+ from .scheduler import subscription_scan_job
10
+
11
+ __plugin_meta__ = PluginMetadata(
12
+ name="QQ点赞",
13
+ description="QQ点赞、订阅赞",
14
+ usage="赞我|赞他|订阅赞|取消订阅赞|订阅列表查询",
15
+ type="application",
16
+ homepage="https://github.com/ByteColtX/nonebot-plugin-sublike",
17
+ config=Config,
18
+ supported_adapters={"~onebot.v11"},
19
+ extra={"author": "ByteColtX <umk@live.com>"},
20
+ )
21
+
22
+ __all__ = ["__plugin_meta__", "like_me", "subscription_scan_job"]
@@ -0,0 +1,44 @@
1
+ """插件配置。"""
2
+
3
+ from nonebot import get_plugin_config
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class Config(BaseModel):
8
+ """插件配置模型。"""
9
+
10
+ sublike_cmd_me: tuple[str, ...] = ("赞我", "草我", "超我")
11
+ sublike_cmd_sub: tuple[str, ...] = ("订阅赞", "天天赞我")
12
+ sublike_cmd_unsub: tuple[str, ...] = ("取消订阅赞", "订阅赞取消")
13
+ sublike_cmd_status: tuple[str, ...] = (
14
+ "订阅赞查看",
15
+ "查看订阅赞",
16
+ "订阅赞状态",
17
+ "订阅赞查询",
18
+ "查询订阅赞",
19
+ )
20
+ sublike_cmd_other: tuple[str, ...] = (
21
+ "赞ta",
22
+ "赞TA",
23
+ "赞他",
24
+ "赞她",
25
+ "草ta",
26
+ "草TA",
27
+ "草他",
28
+ "草她",
29
+ )
30
+
31
+ sublike_need_friend_me: bool = False
32
+ sublike_need_friend_sub: bool = True
33
+ sublike_allow_other: bool = True
34
+
35
+ sublike_sub_expire_days: int = Field(default=7, ge=1)
36
+ sublike_sched_interval: int = Field(default=60, ge=1)
37
+ sublike_sched_start: int = Field(default=8, ge=0, le=23)
38
+ sublike_sched_end: int = Field(default=0, ge=0, le=23)
39
+ sublike_delay_max: int = Field(default=2, ge=0)
40
+
41
+ sublike_banned_groups: tuple[int, ...] = ()
42
+
43
+
44
+ plugin_config = get_plugin_config(Config)
@@ -0,0 +1,278 @@
1
+ """消息触发器。"""
2
+
3
+ import re
4
+
5
+ from nonebot import on_message
6
+ from nonebot.adapters.onebot.v11 import (
7
+ Bot,
8
+ GroupMessageEvent,
9
+ Message,
10
+ MessageEvent,
11
+ MessageSegment,
12
+ )
13
+ from nonebot.rule import Rule
14
+
15
+ from .config import plugin_config
16
+ from .models import (
17
+ LikeResult,
18
+ LikeSource,
19
+ LikeStatus,
20
+ SubscriptionResult,
21
+ SubscriptionStatus,
22
+ )
23
+ from .service import (
24
+ handle_instant_like,
25
+ handle_subscribe,
26
+ handle_subscription_status,
27
+ handle_unsubscribe,
28
+ is_superuser,
29
+ )
30
+
31
+ QQ_RE = re.compile(r"\b[1-9]\d{5,11}\b")
32
+
33
+
34
+ def _is_banned_group(event: MessageEvent) -> bool:
35
+ """判断当前消息是否来自被禁用的群。"""
36
+
37
+ return (
38
+ isinstance(event, GroupMessageEvent)
39
+ and event.group_id in plugin_config.sublike_banned_groups
40
+ )
41
+
42
+
43
+ def is_like_me(event: MessageEvent) -> bool:
44
+ """判断消息是否为“赞我”命令。"""
45
+
46
+ if _is_banned_group(event):
47
+ return False
48
+
49
+ plain_text = event.get_plaintext().strip()
50
+ return plain_text in plugin_config.sublike_cmd_me
51
+
52
+
53
+ def is_like_other(event: MessageEvent) -> bool:
54
+ """判断消息是否为“赞他人”命令。"""
55
+
56
+ if not plugin_config.sublike_allow_other:
57
+ return False
58
+ if not isinstance(event, GroupMessageEvent):
59
+ return False
60
+ if _is_banned_group(event):
61
+ return False
62
+
63
+ plain_text = event.get_plaintext().strip()
64
+ return any(
65
+ plain_text.startswith(keyword) for keyword in plugin_config.sublike_cmd_other
66
+ )
67
+
68
+
69
+ def is_subscribe(event: MessageEvent) -> bool:
70
+ """判断消息是否为订阅命令。"""
71
+
72
+ if _is_banned_group(event):
73
+ return False
74
+
75
+ plain_text = event.get_plaintext().strip()
76
+ return plain_text in plugin_config.sublike_cmd_sub
77
+
78
+
79
+ def is_unsubscribe(event: MessageEvent) -> bool:
80
+ """判断消息是否为取消订阅命令。"""
81
+
82
+ if _is_banned_group(event):
83
+ return False
84
+
85
+ plain_text = event.get_plaintext().strip()
86
+ return plain_text in plugin_config.sublike_cmd_unsub
87
+
88
+
89
+ def is_subscription_status(event: MessageEvent) -> bool:
90
+ """判断消息是否为订阅状态查询命令。"""
91
+
92
+ if _is_banned_group(event):
93
+ return False
94
+
95
+ plain_text = event.get_plaintext().strip()
96
+ return plain_text in plugin_config.sublike_cmd_status
97
+
98
+
99
+ def extract_target_user_id(event: GroupMessageEvent) -> int | None:
100
+ """从群消息中提取被点赞的目标 QQ 号。"""
101
+
102
+ for segment in event.get_message():
103
+ if segment.type != "at":
104
+ continue
105
+ qq = segment.data.get("qq")
106
+ if isinstance(qq, str) and qq.isdigit():
107
+ return int(qq)
108
+
109
+ plain_text = event.get_plaintext().strip()
110
+ match = QQ_RE.search(plain_text)
111
+ if match is None:
112
+ return None
113
+
114
+ return int(match.group(0))
115
+
116
+
117
+ def build_like_me_message(result: LikeResult) -> str:
118
+ """生成“赞我”回复文案。"""
119
+
120
+ if result.status == LikeStatus.NOT_FRIEND:
121
+ return "⚠️ 需要先加好友才能点赞"
122
+ if result.status == LikeStatus.SUCCESS:
123
+ return f"👍 已经给你点了 {result.total} 个赞"
124
+ if result.status == LikeStatus.LIMIT_REACHED:
125
+ return "🌟 今天赞不了你更多了喵~"
126
+ return "💥 点赞失败了喵~"
127
+
128
+
129
+ def build_like_other_message(
130
+ target_user_id: int,
131
+ result: LikeResult,
132
+ ) -> Message | str:
133
+ """生成“赞他”回复文案。"""
134
+
135
+ if result.status == LikeStatus.NOT_FRIEND:
136
+ return Message(
137
+ [
138
+ MessageSegment.text("⚠️ 请先让 "),
139
+ MessageSegment.at(target_user_id),
140
+ MessageSegment.text(" 添加机器人为好友后再点赞"),
141
+ ]
142
+ )
143
+
144
+ if result.status == LikeStatus.SUCCESS:
145
+ return Message(
146
+ [
147
+ MessageSegment.text("👍 已经给 "),
148
+ MessageSegment.at(target_user_id),
149
+ MessageSegment.text(f" 点了 {result.total} 个赞"),
150
+ ]
151
+ )
152
+
153
+ if result.status == LikeStatus.LIMIT_REACHED:
154
+ return Message(
155
+ [
156
+ MessageSegment.text("🌟 今天赞不了 "),
157
+ MessageSegment.at(target_user_id),
158
+ MessageSegment.text(" 更多了喵~"),
159
+ ]
160
+ )
161
+
162
+ return "💥 点赞失败了喵~"
163
+
164
+
165
+ def build_subscribe_message(result: SubscriptionResult) -> str:
166
+ """生成订阅命令回复文案。"""
167
+
168
+ if result.status == SubscriptionStatus.RENEWED:
169
+ if result.require_friend and result.is_friend is False:
170
+ return "🔁 订阅赞已续期,但当前你还不是机器人好友,定时点赞可能不会生效"
171
+ return "🔁 订阅赞已续期"
172
+
173
+ if result.status == SubscriptionStatus.SUBSCRIBED:
174
+ if result.require_friend and result.is_friend is False:
175
+ return "👍 订阅赞成功,但当前你还不是机器人好友,定时点赞可能不会生效"
176
+ return "👍 订阅赞成功"
177
+
178
+ return "💥 订阅处理失败"
179
+
180
+
181
+ def build_unsubscribe_message(result: SubscriptionResult) -> str:
182
+ """生成取消订阅回复文案。"""
183
+
184
+ if result.status == SubscriptionStatus.UNSUBSCRIBED:
185
+ return "👎 已取消订阅赞"
186
+ return "💢 你当前没有订阅赞"
187
+
188
+
189
+ def build_status_message(result: SubscriptionResult) -> str:
190
+ """生成订阅状态回复文案。"""
191
+
192
+ if result.status == SubscriptionStatus.EMPTY:
193
+ if result.is_superuser_view:
194
+ return "📭 当前没有有效订阅"
195
+ return "📭 你当前没有有效订阅"
196
+
197
+ if result.status == SubscriptionStatus.STATUS_LIST:
198
+ lines = ["📋 当前有效订阅:"]
199
+ for record in result.records:
200
+ lines.append(f"{record.user_id} 到期于 {record.expires_at:%Y-%m-%d}")
201
+ return "\n".join(lines)
202
+
203
+ if result.status == SubscriptionStatus.STATUS_SINGLE and result.record is not None:
204
+ lines = [
205
+ "📌 你的订阅状态:",
206
+ f"QQ:{result.record.user_id}",
207
+ f"到期时间:{result.record.expires_at:%Y-%m-%d}",
208
+ ]
209
+ if result.record.last_like_at is not None:
210
+ lines.append(f"最近点赞:{result.record.last_like_at:%Y-%m-%d}")
211
+ else:
212
+ lines.append("最近点赞:暂无")
213
+ return "\n".join(lines)
214
+
215
+ return "💥 查询订阅状态失败"
216
+
217
+
218
+ like_me = on_message(rule=Rule(is_like_me), priority=5, block=True)
219
+ like_other = on_message(rule=Rule(is_like_other), priority=5, block=True)
220
+ like_subscribe = on_message(rule=Rule(is_subscribe), priority=5, block=True)
221
+ like_unsubscribe = on_message(rule=Rule(is_unsubscribe), priority=5, block=True)
222
+ like_status = on_message(
223
+ rule=Rule(is_subscription_status),
224
+ priority=5,
225
+ block=True,
226
+ )
227
+
228
+
229
+ @like_me.handle()
230
+ async def handle_like_me(bot: Bot, event: MessageEvent):
231
+ """处理“赞我”命令。"""
232
+
233
+ result = await handle_instant_like(bot, event.user_id)
234
+ await like_me.finish(build_like_me_message(result))
235
+
236
+
237
+ @like_other.handle()
238
+ async def handle_like_other(bot: Bot, event: GroupMessageEvent):
239
+ """处理“赞他人”命令。"""
240
+
241
+ target_user_id = extract_target_user_id(event)
242
+ if target_user_id is None:
243
+ await like_other.finish("🤡 请提供有效的 QQ 号或 @目标用户")
244
+
245
+ result = await handle_instant_like(
246
+ bot,
247
+ target_user_id,
248
+ source=LikeSource.INSTANT,
249
+ )
250
+ reply = build_like_other_message(target_user_id, result)
251
+ await like_other.finish(reply)
252
+
253
+
254
+ @like_subscribe.handle()
255
+ async def handle_like_subscribe(bot: Bot, event: MessageEvent):
256
+ """处理订阅命令。"""
257
+
258
+ result = await handle_subscribe(bot, event.user_id)
259
+ await like_subscribe.finish(build_subscribe_message(result))
260
+
261
+
262
+ @like_unsubscribe.handle()
263
+ async def handle_like_unsubscribe(event: MessageEvent):
264
+ """处理取消订阅命令。"""
265
+
266
+ result = handle_unsubscribe(event.user_id)
267
+ await like_unsubscribe.finish(build_unsubscribe_message(result))
268
+
269
+
270
+ @like_status.handle()
271
+ async def handle_like_status(event: MessageEvent):
272
+ """处理订阅状态查询命令。"""
273
+
274
+ result = handle_subscription_status(
275
+ event.user_id,
276
+ is_superuser(event.user_id),
277
+ )
278
+ await like_status.finish(build_status_message(result))
@@ -0,0 +1,80 @@
1
+ """数据模型。"""
2
+
3
+ from datetime import date, datetime
4
+ from enum import Enum
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class LikeSource(str, Enum):
10
+ """点赞来源。"""
11
+
12
+ INSTANT = "instant"
13
+ SUBSCRIPTION = "subscription"
14
+
15
+
16
+ class LikeStatus(str, Enum):
17
+ """点赞结果状态。"""
18
+
19
+ SUCCESS = "success"
20
+ LIMIT_REACHED = "limit_reached"
21
+ NOT_FRIEND = "not_friend"
22
+ FAILED = "failed"
23
+ SKIPPED = "skipped"
24
+
25
+
26
+ class SubscriptionStatus(str, Enum):
27
+ """订阅操作状态。"""
28
+
29
+ SUBSCRIBED = "subscribed"
30
+ RENEWED = "renewed"
31
+ UNSUBSCRIBED = "unsubscribed"
32
+ NOT_SUBSCRIBED = "not_subscribed"
33
+ STATUS_SINGLE = "status_single"
34
+ STATUS_LIST = "status_list"
35
+ EMPTY = "empty"
36
+
37
+
38
+ class SubscriptionRecord(BaseModel):
39
+ """订阅记录。"""
40
+
41
+ user_id: int
42
+ created_at: datetime
43
+ last_trigger_at: datetime
44
+ expires_at: datetime
45
+ last_like_at: datetime | None = None
46
+ last_like_date: date | None = None
47
+
48
+
49
+ class UserLikeStats(BaseModel):
50
+ """用户累计点赞统计。"""
51
+
52
+ user_id: int
53
+ total_like_days: int = Field(default=0, ge=0)
54
+ total_like_count: int = Field(default=0, ge=0)
55
+ last_like_date: date | None = None
56
+
57
+
58
+ class LikeResult(BaseModel):
59
+ """单次点赞流程结果。"""
60
+
61
+ user_id: int
62
+ status: LikeStatus = LikeStatus.FAILED
63
+ total: int = Field(default=0, ge=0)
64
+ source: LikeSource = LikeSource.INSTANT
65
+ is_friend: bool | None = None
66
+ hit_limit: bool = False
67
+ success: bool = False
68
+ detail: str = ""
69
+
70
+
71
+ class SubscriptionResult(BaseModel):
72
+ """订阅操作结果。"""
73
+
74
+ user_id: int
75
+ status: SubscriptionStatus
76
+ is_superuser_view: bool = False
77
+ require_friend: bool = False
78
+ is_friend: bool | None = None
79
+ record: SubscriptionRecord | None = None
80
+ records: list[SubscriptionRecord] = Field(default_factory=list)
@@ -0,0 +1,46 @@
1
+ """定时任务入口。"""
2
+
3
+ from datetime import datetime
4
+
5
+ from nonebot import get_bots, logger, require
6
+ from nonebot.adapters.onebot.v11 import Bot
7
+
8
+ require("nonebot_plugin_apscheduler")
9
+ from nonebot_plugin_apscheduler import scheduler
10
+
11
+ from .config import plugin_config
12
+ from .service import run_subscription_scan
13
+ from .utils import in_active_window
14
+
15
+
16
+ def _get_onebot_bot() -> Bot | None:
17
+ """获取可用的 OneBot v11 Bot。"""
18
+
19
+ for bot in get_bots().values():
20
+ if isinstance(bot, Bot):
21
+ return bot
22
+ return None
23
+
24
+
25
+ @scheduler.scheduled_job(
26
+ "interval",
27
+ minutes=plugin_config.sublike_sched_interval,
28
+ id="nonebot_plugin_sublike_subscription_scan",
29
+ )
30
+ async def subscription_scan_job() -> None:
31
+ """定时扫描订阅用户。"""
32
+
33
+ now = datetime.now()
34
+ if not in_active_window(
35
+ now,
36
+ plugin_config.sublike_sched_start,
37
+ plugin_config.sublike_sched_end,
38
+ ):
39
+ return
40
+
41
+ bot = _get_onebot_bot()
42
+ if bot is None:
43
+ logger.warning("nonebot_plugin_sublike 未找到可用的 OneBot v11 Bot")
44
+ return
45
+
46
+ await run_subscription_scan(bot)
@@ -0,0 +1,355 @@
1
+ """点赞业务逻辑。"""
2
+
3
+ import asyncio
4
+ from datetime import datetime, timedelta
5
+
6
+ from nonebot import get_driver, logger
7
+ from nonebot.adapters.onebot.v11 import Bot
8
+
9
+ from .config import plugin_config
10
+ from .models import (
11
+ LikeResult,
12
+ LikeSource,
13
+ LikeStatus,
14
+ SubscriptionRecord,
15
+ SubscriptionResult,
16
+ SubscriptionStatus,
17
+ UserLikeStats,
18
+ )
19
+ from .store import (
20
+ get_subscription,
21
+ get_user_stats,
22
+ load_subscriptions,
23
+ purge_expired_subscriptions,
24
+ remove_subscription,
25
+ upsert_subscription,
26
+ upsert_user_stats,
27
+ )
28
+ from .utils import get_random_delay_seconds, in_active_window, is_friend
29
+
30
+
31
+ async def check_friend(
32
+ bot: Bot,
33
+ user_id: int,
34
+ *,
35
+ require_friend: bool,
36
+ ) -> bool:
37
+ """按配置判断是否需要好友关系。"""
38
+
39
+ if not require_friend:
40
+ return True
41
+
42
+ return await is_friend(bot, user_id)
43
+
44
+
45
+ def update_user_like_stats(user_id: int, total: int, liked_at: datetime) -> None:
46
+ """更新用户累计点赞统计。"""
47
+
48
+ stats = get_user_stats(user_id)
49
+ if stats is None:
50
+ stats = UserLikeStats(user_id=user_id)
51
+
52
+ liked_date = liked_at.date()
53
+ if stats.last_like_date != liked_date:
54
+ stats.total_like_days += 1
55
+
56
+ stats.total_like_count += total
57
+ stats.last_like_date = liked_date
58
+ upsert_user_stats(stats)
59
+
60
+
61
+ def _is_limit_response(response: object) -> bool:
62
+ """判断接口返回是否表示已到点赞上限。"""
63
+
64
+ if not isinstance(response, dict):
65
+ return False
66
+
67
+ return response.get("ok") is False or response.get("times") == 0
68
+
69
+
70
+ def _is_limit_exception(exception: Exception) -> bool:
71
+ """判断异常是否表示已到点赞上限。"""
72
+
73
+ text = repr(exception)
74
+ limit_markers = (
75
+ "已达上限",
76
+ "不能再赞",
77
+ "点赞失败 今日同一好友点赞数已达上限",
78
+ "limit",
79
+ )
80
+ return any(marker in text for marker in limit_markers)
81
+
82
+
83
+ async def execute_like(bot: Bot, user_id: int, *, source: LikeSource) -> LikeResult:
84
+ """执行点赞请求,直到接口拒绝继续点赞为止。"""
85
+
86
+ result = LikeResult(
87
+ user_id=user_id,
88
+ source=source,
89
+ status=LikeStatus.FAILED,
90
+ )
91
+ while True:
92
+ try:
93
+ response = await bot.send_like(user_id=user_id, times=10)
94
+ except Exception as exception:
95
+ if result.total > 0:
96
+ result.success = True
97
+ result.status = LikeStatus.SUCCESS
98
+ result.hit_limit = True
99
+ result.detail = repr(exception)
100
+ logger.info(
101
+ f"用户 {user_id} 点赞已到上限,本次累计 {result.total} 赞:"
102
+ f"{exception!r}"
103
+ )
104
+ elif _is_limit_exception(exception):
105
+ result.status = LikeStatus.LIMIT_REACHED
106
+ result.hit_limit = True
107
+ result.detail = repr(exception)
108
+ logger.info(f"用户 {user_id} 今日点赞已达上限:{exception!r}")
109
+ else:
110
+ result.status = LikeStatus.FAILED
111
+ result.detail = repr(exception)
112
+ logger.warning(f"用户 {user_id} 点赞失败:{exception!r}")
113
+ break
114
+
115
+ result.total += 10
116
+
117
+ if _is_limit_response(response):
118
+ result.hit_limit = True
119
+ break
120
+
121
+ if result.total > 0:
122
+ result.success = True
123
+ result.status = LikeStatus.SUCCESS
124
+
125
+ return result
126
+
127
+
128
+ async def handle_instant_like(
129
+ bot: Bot,
130
+ user_id: int,
131
+ *,
132
+ source: LikeSource = LikeSource.INSTANT,
133
+ ) -> LikeResult:
134
+ """处理即时点赞流程。"""
135
+
136
+ result = LikeResult(user_id=user_id, source=source)
137
+ result.is_friend = await check_friend(
138
+ bot,
139
+ user_id,
140
+ require_friend=plugin_config.sublike_need_friend_me,
141
+ )
142
+ if not result.is_friend:
143
+ result.status = LikeStatus.NOT_FRIEND
144
+ return result
145
+
146
+ like_result = await execute_like(bot, user_id, source=source)
147
+ like_result.is_friend = result.is_friend
148
+
149
+ if like_result.success:
150
+ update_user_like_stats(user_id, like_result.total, datetime.now())
151
+
152
+ return like_result
153
+
154
+
155
+ def _get_superusers() -> set[str]:
156
+ """获取超级用户列表。"""
157
+
158
+ return set(get_driver().config.superusers)
159
+
160
+
161
+ async def handle_subscribe(bot: Bot, user_id: int) -> SubscriptionResult:
162
+ """创建或续期订阅。"""
163
+
164
+ now = datetime.now()
165
+ purge_expired_subscriptions(now)
166
+
167
+ current = get_subscription(user_id)
168
+ expires_at = now + timedelta(days=plugin_config.sublike_sub_expire_days)
169
+ is_renew = current is not None and current.expires_at > now
170
+
171
+ if current is None or current.expires_at <= now:
172
+ record = SubscriptionRecord(
173
+ user_id=user_id,
174
+ created_at=now,
175
+ last_trigger_at=now,
176
+ expires_at=expires_at,
177
+ )
178
+ else:
179
+ record = current.model_copy(
180
+ update={
181
+ "last_trigger_at": now,
182
+ "expires_at": expires_at,
183
+ }
184
+ )
185
+
186
+ upsert_subscription(record)
187
+
188
+ require_friend = plugin_config.sublike_need_friend_sub
189
+ friend_state: bool | None = None
190
+ if require_friend:
191
+ friend_state = await is_friend(bot, user_id)
192
+
193
+ like_result = await handle_subscription_like(
194
+ bot,
195
+ record,
196
+ skip_delay=True,
197
+ friend_state=friend_state,
198
+ )
199
+ if like_result.success:
200
+ record = get_subscription(user_id) or record
201
+ elif like_result.status == LikeStatus.FAILED:
202
+ logger.warning(f"用户 {user_id} 订阅后立即点赞失败:{like_result.detail}")
203
+
204
+ return SubscriptionResult(
205
+ user_id=user_id,
206
+ status=(
207
+ SubscriptionStatus.RENEWED if is_renew else SubscriptionStatus.SUBSCRIBED
208
+ ),
209
+ require_friend=require_friend,
210
+ is_friend=friend_state,
211
+ record=record,
212
+ )
213
+
214
+
215
+ def handle_unsubscribe(user_id: int) -> SubscriptionResult:
216
+ """取消订阅。"""
217
+
218
+ removed = remove_subscription(user_id)
219
+ if removed:
220
+ return SubscriptionResult(
221
+ user_id=user_id,
222
+ status=SubscriptionStatus.UNSUBSCRIBED,
223
+ )
224
+ return SubscriptionResult(
225
+ user_id=user_id,
226
+ status=SubscriptionStatus.NOT_SUBSCRIBED,
227
+ )
228
+
229
+
230
+ def handle_subscription_status(
231
+ user_id: int,
232
+ is_superuser: bool,
233
+ ) -> SubscriptionResult:
234
+ """查看订阅状态。"""
235
+
236
+ now = datetime.now()
237
+ purge_expired_subscriptions(now)
238
+
239
+ if is_superuser:
240
+ records = load_subscriptions()
241
+ if not records:
242
+ return SubscriptionResult(
243
+ user_id=user_id,
244
+ status=SubscriptionStatus.EMPTY,
245
+ is_superuser_view=True,
246
+ )
247
+ return SubscriptionResult(
248
+ user_id=user_id,
249
+ status=SubscriptionStatus.STATUS_LIST,
250
+ is_superuser_view=True,
251
+ records=records,
252
+ )
253
+
254
+ record = get_subscription(user_id)
255
+ if record is None or record.expires_at <= now:
256
+ return SubscriptionResult(
257
+ user_id=user_id,
258
+ status=SubscriptionStatus.EMPTY,
259
+ )
260
+
261
+ return SubscriptionResult(
262
+ user_id=user_id,
263
+ status=SubscriptionStatus.STATUS_SINGLE,
264
+ record=record,
265
+ )
266
+
267
+
268
+ def is_superuser(user_id: int) -> bool:
269
+ """判断当前用户是否为超级用户。"""
270
+
271
+ return str(user_id) in _get_superusers()
272
+
273
+
274
+ async def handle_subscription_like(bot: Bot, record: SubscriptionRecord) -> LikeResult:
275
+ """执行单个订阅用户的定时点赞。"""
276
+ return await _handle_subscription_like(bot, record)
277
+
278
+
279
+ async def _handle_subscription_like(
280
+ bot: Bot,
281
+ record: SubscriptionRecord,
282
+ *,
283
+ skip_delay: bool = False,
284
+ friend_state: bool | None = None,
285
+ ) -> LikeResult:
286
+ """执行单个订阅用户的点赞。"""
287
+
288
+ result = LikeResult(
289
+ user_id=record.user_id,
290
+ source=LikeSource.SUBSCRIPTION,
291
+ )
292
+
293
+ if plugin_config.sublike_need_friend_sub:
294
+ if friend_state is None:
295
+ result.is_friend = await check_friend(
296
+ bot,
297
+ record.user_id,
298
+ require_friend=plugin_config.sublike_need_friend_sub,
299
+ )
300
+ else:
301
+ result.is_friend = friend_state
302
+ if not result.is_friend:
303
+ result.status = LikeStatus.NOT_FRIEND
304
+ result.detail = "当前不是机器人好友,跳过订阅点赞"
305
+ return result
306
+
307
+ delay_seconds = (
308
+ 0 if skip_delay else get_random_delay_seconds(plugin_config.sublike_delay_max)
309
+ )
310
+ if delay_seconds > 0:
311
+ await asyncio.sleep(delay_seconds)
312
+
313
+ result = await execute_like(
314
+ bot,
315
+ record.user_id,
316
+ source=LikeSource.SUBSCRIPTION,
317
+ )
318
+ if plugin_config.sublike_need_friend_sub:
319
+ result.is_friend = True
320
+
321
+ if result.success:
322
+ now = datetime.now()
323
+ update_user_like_stats(record.user_id, result.total, now)
324
+ updated_record = record.model_copy(
325
+ update={
326
+ "last_like_at": now,
327
+ "last_like_date": now.date(),
328
+ }
329
+ )
330
+ upsert_subscription(updated_record)
331
+
332
+ return result
333
+
334
+
335
+ async def run_subscription_scan(bot: Bot) -> None:
336
+ """执行一次订阅扫描。"""
337
+
338
+ now = datetime.now()
339
+ if not in_active_window(
340
+ now,
341
+ plugin_config.sublike_sched_start,
342
+ plugin_config.sublike_sched_end,
343
+ ):
344
+ return
345
+
346
+ purge_expired_subscriptions(now)
347
+ records = load_subscriptions()
348
+ for record in records:
349
+ if record.last_like_date == now.date():
350
+ continue
351
+ result = await handle_subscription_like(bot, record)
352
+ if result.status == LikeStatus.NOT_FRIEND:
353
+ logger.info(f"用户 {record.user_id} 不是好友,跳过订阅点赞")
354
+ elif result.status == LikeStatus.FAILED:
355
+ logger.warning(f"用户 {record.user_id} 订阅点赞失败:{result.detail}")
@@ -0,0 +1,164 @@
1
+ """数据存储。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import TypeVar
9
+
10
+ from nonebot import require
11
+ from pydantic import BaseModel
12
+
13
+ require("nonebot_plugin_localstore")
14
+ import nonebot_plugin_localstore as store
15
+
16
+ from .models import SubscriptionRecord, UserLikeStats
17
+
18
+ SUBSCRIPTIONS_FILE = store.get_plugin_data_file("subscriptions.json")
19
+ USER_STATS_FILE = store.get_plugin_data_file("user_stats.json")
20
+ ModelT = TypeVar("ModelT", bound=BaseModel)
21
+
22
+
23
+ def _load_records(
24
+ file_path: Path,
25
+ model_type: type[SubscriptionRecord],
26
+ ) -> list[SubscriptionRecord]:
27
+ """读取订阅记录列表。"""
28
+
29
+ return _load_model_list(file_path, model_type)
30
+
31
+
32
+ def _load_stats(
33
+ file_path: Path,
34
+ model_type: type[UserLikeStats],
35
+ ) -> list[UserLikeStats]:
36
+ """读取用户统计列表。"""
37
+
38
+ return _load_model_list(file_path, model_type)
39
+
40
+
41
+ def _load_model_list(
42
+ file_path: Path,
43
+ model_type: type[ModelT],
44
+ ) -> list[ModelT]:
45
+ """从 JSON 文件中读取模型列表。"""
46
+
47
+ if not file_path.exists():
48
+ return []
49
+
50
+ try:
51
+ raw_data = json.loads(file_path.read_text(encoding="utf-8"))
52
+ except json.JSONDecodeError:
53
+ return []
54
+
55
+ if not isinstance(raw_data, list):
56
+ return []
57
+
58
+ records: list[ModelT] = []
59
+ for item in raw_data:
60
+ try:
61
+ records.append(model_type.model_validate(item))
62
+ except Exception:
63
+ continue
64
+
65
+ return records
66
+
67
+
68
+ def _save_model_list(
69
+ file_path: Path,
70
+ records: list[SubscriptionRecord] | list[UserLikeStats],
71
+ ) -> None:
72
+ """将模型列表写入 JSON 文件。"""
73
+
74
+ file_path.parent.mkdir(parents=True, exist_ok=True)
75
+ payload = [record.model_dump(mode="json") for record in records]
76
+ file_path.write_text(
77
+ json.dumps(payload, ensure_ascii=False, indent=2),
78
+ encoding="utf-8",
79
+ )
80
+
81
+
82
+ def load_subscriptions() -> list[SubscriptionRecord]:
83
+ """读取全部订阅记录。"""
84
+
85
+ records = _load_records(SUBSCRIPTIONS_FILE, SubscriptionRecord)
86
+ return sorted(records, key=lambda record: record.user_id)
87
+
88
+
89
+ def save_subscriptions(records: list[SubscriptionRecord]) -> None:
90
+ """保存全部订阅记录。"""
91
+
92
+ ordered_records = sorted(records, key=lambda record: record.user_id)
93
+ _save_model_list(SUBSCRIPTIONS_FILE, ordered_records)
94
+
95
+
96
+ def get_subscription(user_id: int) -> SubscriptionRecord | None:
97
+ """按 QQ 号获取订阅记录。"""
98
+
99
+ for record in load_subscriptions():
100
+ if record.user_id == user_id:
101
+ return record
102
+ return None
103
+
104
+
105
+ def upsert_subscription(record: SubscriptionRecord) -> None:
106
+ """新增或更新订阅记录。"""
107
+
108
+ records = [item for item in load_subscriptions() if item.user_id != record.user_id]
109
+ records.append(record)
110
+ save_subscriptions(records)
111
+
112
+
113
+ def remove_subscription(user_id: int) -> bool:
114
+ """删除订阅记录。"""
115
+
116
+ records = load_subscriptions()
117
+ new_records = [record for record in records if record.user_id != user_id]
118
+ if len(new_records) == len(records):
119
+ return False
120
+
121
+ save_subscriptions(new_records)
122
+ return True
123
+
124
+
125
+ def purge_expired_subscriptions(now: datetime) -> int:
126
+ """清理已过期的订阅记录。"""
127
+
128
+ records = load_subscriptions()
129
+ valid_records = [record for record in records if record.expires_at > now]
130
+ removed_count = len(records) - len(valid_records)
131
+ if removed_count > 0:
132
+ save_subscriptions(valid_records)
133
+ return removed_count
134
+
135
+
136
+ def load_user_stats() -> list[UserLikeStats]:
137
+ """读取全部用户统计。"""
138
+
139
+ records = _load_stats(USER_STATS_FILE, UserLikeStats)
140
+ return sorted(records, key=lambda record: record.user_id)
141
+
142
+
143
+ def save_user_stats(records: list[UserLikeStats]) -> None:
144
+ """保存全部用户统计。"""
145
+
146
+ ordered_records = sorted(records, key=lambda record: record.user_id)
147
+ _save_model_list(USER_STATS_FILE, ordered_records)
148
+
149
+
150
+ def get_user_stats(user_id: int) -> UserLikeStats | None:
151
+ """按 QQ 号获取用户统计。"""
152
+
153
+ for record in load_user_stats():
154
+ if record.user_id == user_id:
155
+ return record
156
+ return None
157
+
158
+
159
+ def upsert_user_stats(record: UserLikeStats) -> None:
160
+ """新增或更新用户统计。"""
161
+
162
+ records = [item for item in load_user_stats() if item.user_id != record.user_id]
163
+ records.append(record)
164
+ save_user_stats(records)
@@ -0,0 +1,32 @@
1
+ """工具函数。"""
2
+
3
+ from datetime import datetime
4
+ from random import randint
5
+
6
+ from nonebot.adapters.onebot.v11 import Bot
7
+
8
+
9
+ async def is_friend(bot: Bot, user_id: int) -> bool:
10
+ """判断目标用户是否在机器人好友列表中。"""
11
+
12
+ friend_list = await bot.get_friend_list()
13
+ return any(friend.get("user_id") == user_id for friend in friend_list)
14
+
15
+
16
+ def in_active_window(now: datetime, start_hour: int, end_hour: int) -> bool:
17
+ """判断当前时间是否处于定时任务运行时段。"""
18
+
19
+ current_hour = now.hour
20
+ if start_hour == end_hour:
21
+ return True
22
+ if start_hour < end_hour:
23
+ return start_hour <= current_hour < end_hour
24
+ return current_hour >= start_hour or current_hour < end_hour
25
+
26
+
27
+ def get_random_delay_seconds(max_minutes: int) -> int:
28
+ """获取随机延迟秒数。"""
29
+
30
+ if max_minutes <= 0:
31
+ return 0
32
+ return randint(0, max_minutes * 60)