nonebot-plugin-lazy 0.1.0__py3-none-any.whl
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.
- nonebot_plugin_lazy/__init__.py +53 -0
- nonebot_plugin_lazy/auth.py +64 -0
- nonebot_plugin_lazy/config.py +33 -0
- nonebot_plugin_lazy/handler.py +234 -0
- nonebot_plugin_lazy/monitor.py +155 -0
- nonebot_plugin_lazy/notifier.py +131 -0
- nonebot_plugin_lazy/state.py +64 -0
- nonebot_plugin_lazy-0.1.0.dist-info/METADATA +193 -0
- nonebot_plugin_lazy-0.1.0.dist-info/RECORD +11 -0
- nonebot_plugin_lazy-0.1.0.dist-info/WHEEL +4 -0
- nonebot_plugin_lazy-0.1.0.dist-info/licenses/LICENSE +663 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""nonebot_plugin_lazy — Nonebot2 插件入口。
|
|
2
|
+
|
|
3
|
+
启动时自动加载 APScheduler,可选注册 .env 账号,然后启动定时轮询。
|
|
4
|
+
|
|
5
|
+
消息处理器在 handler.py 中通过 import 副效应注册到 Nonebot2。"""
|
|
6
|
+
|
|
7
|
+
from nonebot.plugin import require
|
|
8
|
+
from nonebot import get_driver
|
|
9
|
+
from nonebot.log import logger
|
|
10
|
+
|
|
11
|
+
require("nonebot_plugin_apscheduler")
|
|
12
|
+
from nonebot_plugin_apscheduler import scheduler
|
|
13
|
+
|
|
14
|
+
from .config import config
|
|
15
|
+
from .state import user_mgr
|
|
16
|
+
from .auth import TokenManager
|
|
17
|
+
from .monitor import Poller
|
|
18
|
+
|
|
19
|
+
from . import handler # noqa: F401 — 注册消息处理器
|
|
20
|
+
|
|
21
|
+
driver = get_driver()
|
|
22
|
+
|
|
23
|
+
poller = Poller()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@driver.on_startup
|
|
27
|
+
async def startup():
|
|
28
|
+
if config.studentid and config.password:
|
|
29
|
+
tm = TokenManager(
|
|
30
|
+
config.server_url, config.studentid, config.password,
|
|
31
|
+
)
|
|
32
|
+
try:
|
|
33
|
+
token = await tm.login()
|
|
34
|
+
qq_id = config.owner_qq if config.owner_qq > 0 else -1
|
|
35
|
+
user_mgr.add_user(
|
|
36
|
+
qq_id,
|
|
37
|
+
config.studentid,
|
|
38
|
+
config.password,
|
|
39
|
+
token,
|
|
40
|
+
config.server_url,
|
|
41
|
+
)
|
|
42
|
+
logger.success(
|
|
43
|
+
f".env 账号 {config.studentid} 自动注册成功"
|
|
44
|
+
)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logger.error(f".env 账号注册失败: {e}")
|
|
47
|
+
|
|
48
|
+
scheduler.add_job(
|
|
49
|
+
poller.run,
|
|
50
|
+
"interval",
|
|
51
|
+
seconds=config.poll_interval,
|
|
52
|
+
id="lazy_poll",
|
|
53
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Token 管理。
|
|
2
|
+
|
|
3
|
+
TokenManager 为纯工具类,每次使用时独立实例化以避免多会话冲突。
|
|
4
|
+
不再保留模块级单例。
|
|
5
|
+
|
|
6
|
+
认证流程:
|
|
7
|
+
1. POST /api/auth/login → 返回 UUID token
|
|
8
|
+
2. 404 → 用户未注册,自动调 POST /api/auth/register
|
|
9
|
+
3. 401 → 密码错误
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from nonebot.log import logger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TokenManager:
|
|
17
|
+
"""管理单个用户的 LAZY SERVER token。"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, server_url: str, studentid: str, password: str):
|
|
20
|
+
self.server_url = server_url
|
|
21
|
+
self.studentid = studentid
|
|
22
|
+
self.password = password
|
|
23
|
+
self.token: str | None = None
|
|
24
|
+
|
|
25
|
+
async def login(self) -> str:
|
|
26
|
+
"""登录获取 token,如未注册则自动注册。返回 token 字符串。"""
|
|
27
|
+
async with httpx.AsyncClient() as client:
|
|
28
|
+
resp = await client.post(
|
|
29
|
+
f"{self.server_url}/api/auth/login",
|
|
30
|
+
json={"studentid": self.studentid, "password": self.password},
|
|
31
|
+
timeout=10,
|
|
32
|
+
)
|
|
33
|
+
if resp.status_code == 404:
|
|
34
|
+
logger.info("用户未注册,尝试自动注册")
|
|
35
|
+
resp = await client.post(
|
|
36
|
+
f"{self.server_url}/api/auth/register",
|
|
37
|
+
json={"studentid": self.studentid, "password": self.password},
|
|
38
|
+
timeout=10,
|
|
39
|
+
)
|
|
40
|
+
if resp.status_code == 401:
|
|
41
|
+
raise RuntimeError("学号或密码错误")
|
|
42
|
+
if resp.status_code != 200:
|
|
43
|
+
raise RuntimeError(
|
|
44
|
+
f"认证失败 (HTTP {resp.status_code}): {resp.text}"
|
|
45
|
+
)
|
|
46
|
+
data = resp.json()
|
|
47
|
+
self.token = data["token"]
|
|
48
|
+
return self.token
|
|
49
|
+
|
|
50
|
+
async def ensure_token(self) -> str:
|
|
51
|
+
"""确保 token 可用,如为 None 则自动登录。"""
|
|
52
|
+
if self.token is None:
|
|
53
|
+
await self.login()
|
|
54
|
+
return self.token
|
|
55
|
+
|
|
56
|
+
async def handle_401(self):
|
|
57
|
+
"""处理 token 过期:置空后重新登录。"""
|
|
58
|
+
logger.warning("Token 失效,尝试重新登录")
|
|
59
|
+
self.token = None
|
|
60
|
+
try:
|
|
61
|
+
await self.login()
|
|
62
|
+
except Exception as e:
|
|
63
|
+
logger.error(f"重新登录失败: {e}")
|
|
64
|
+
raise
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""LAZY 监控插件配置模型。
|
|
2
|
+
|
|
3
|
+
通过 Nonebot2 的 get_plugin_config 自动绑定 .env 中
|
|
4
|
+
LAZY_MONITOR__* 前缀的环境变量到 Pydantic 模型。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from nonebot import get_plugin_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LazyMonitorConfig(BaseModel):
|
|
12
|
+
"""插件核心配置,所有字段均可通过 .env 覆盖。"""
|
|
13
|
+
|
|
14
|
+
server_url: str = "http://127.0.0.1:8765"
|
|
15
|
+
studentid: str = ""
|
|
16
|
+
password: str = ""
|
|
17
|
+
owner_qq: int = 0
|
|
18
|
+
poll_interval: int = 30
|
|
19
|
+
notify_groups: list[int] = []
|
|
20
|
+
notify_users: list[int] = []
|
|
21
|
+
enable_rollcall: bool = True
|
|
22
|
+
enable_todo: bool = True
|
|
23
|
+
max_retries: int = 3
|
|
24
|
+
retry_delay: int = 5
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Config(BaseModel):
|
|
28
|
+
"""Nonebot2 需要顶层模型来解析嵌套的 LAZY_MONITOR__* 配置。"""
|
|
29
|
+
|
|
30
|
+
lazy_monitor: LazyMonitorConfig
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
config = get_plugin_config(Config).lazy_monitor
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""用户交互命令处理器。
|
|
2
|
+
|
|
3
|
+
包含两大功能:
|
|
4
|
+
1. `/register` — 私聊四步骤注册 / 群聊拒绝引导
|
|
5
|
+
2. `/task` — 任务管理(list / enable / disable / interval / reset)
|
|
6
|
+
|
|
7
|
+
所有命令使用 Nonebot2 标准 Matcher.got() 实现多步骤对话。"""
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from nonebot import on_command
|
|
11
|
+
from nonebot.rule import is_type, to_me
|
|
12
|
+
from nonebot.adapters.onebot.v11 import (
|
|
13
|
+
Bot,
|
|
14
|
+
MessageEvent,
|
|
15
|
+
PrivateMessageEvent,
|
|
16
|
+
GroupMessageEvent,
|
|
17
|
+
MessageSegment,
|
|
18
|
+
Message,
|
|
19
|
+
)
|
|
20
|
+
from nonebot.params import ArgPlainText, CommandArg
|
|
21
|
+
from nonebot.log import logger
|
|
22
|
+
|
|
23
|
+
from .config import config
|
|
24
|
+
from .state import user_mgr
|
|
25
|
+
from .auth import TokenManager
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── 注册 ──────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
register = on_command(
|
|
31
|
+
"register",
|
|
32
|
+
rule=to_me() & is_type(PrivateMessageEvent),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@register.handle()
|
|
36
|
+
async def _(event: PrivateMessageEvent):
|
|
37
|
+
if user_mgr.get_user(event.user_id):
|
|
38
|
+
await register.finish(
|
|
39
|
+
"您已经注册过了。如需重新注册,请联系管理员。"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@register.got(
|
|
43
|
+
"confirm",
|
|
44
|
+
prompt=(
|
|
45
|
+
"⚠️ 安全提示:\n"
|
|
46
|
+
"您的学号和密码将存储在服务器上,"
|
|
47
|
+
"Bot管理员可以查看这些信息。\n\n"
|
|
48
|
+
"是否继续注册?(回复 是/否)"
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
async def _(event: PrivateMessageEvent, confirm: str = ArgPlainText()):
|
|
52
|
+
if confirm.strip() not in ("是", "y", "yes", "Y", "Yes"):
|
|
53
|
+
await register.finish(
|
|
54
|
+
"注册已取消。如需重新注册,请再次发送 /register。"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@register.got("studentid", prompt="请输入您的学号:")
|
|
58
|
+
async def _(studentid: str = ArgPlainText()):
|
|
59
|
+
if not studentid.strip().isdigit():
|
|
60
|
+
await register.reject("学号格式不正确,请输入纯数字学号:")
|
|
61
|
+
|
|
62
|
+
@register.got(
|
|
63
|
+
"password",
|
|
64
|
+
prompt="请输入密码(密码仅用于验证,验证后加密存储):",
|
|
65
|
+
)
|
|
66
|
+
async def _(
|
|
67
|
+
event: PrivateMessageEvent,
|
|
68
|
+
studentid: str = ArgPlainText("studentid"),
|
|
69
|
+
password: str = ArgPlainText(),
|
|
70
|
+
):
|
|
71
|
+
"""最终步骤:验证凭据 → 存入 UserManager → 完成注册。"""
|
|
72
|
+
sid = studentid.strip()
|
|
73
|
+
pwd = password.strip()
|
|
74
|
+
tm = TokenManager(config.server_url, sid, pwd)
|
|
75
|
+
try:
|
|
76
|
+
token = await tm.login()
|
|
77
|
+
except RuntimeError as e:
|
|
78
|
+
await register.reject(
|
|
79
|
+
f"验证失败:{e}\n请检查后重新输入密码"
|
|
80
|
+
"(或发送 取消 中止):"
|
|
81
|
+
)
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.error(f"注册验证异常: {e}")
|
|
84
|
+
await register.reject("验证失败:服务器异常,请稍后重试。")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
user_mgr.add_user(
|
|
88
|
+
event.user_id, sid, pwd, token, config.server_url,
|
|
89
|
+
)
|
|
90
|
+
await register.finish(
|
|
91
|
+
f"✅ 注册成功!\n"
|
|
92
|
+
f"学号:{sid}\n"
|
|
93
|
+
f"您将会收到点名和待办的通知。"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
register_group = on_command(
|
|
98
|
+
"register",
|
|
99
|
+
rule=is_type(GroupMessageEvent),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@register_group.handle()
|
|
103
|
+
async def _(bot: Bot, event: GroupMessageEvent):
|
|
104
|
+
"""群聊中发送 /register 时 @ 用户并引导至私聊。"""
|
|
105
|
+
await bot.send(
|
|
106
|
+
event=event,
|
|
107
|
+
message=(
|
|
108
|
+
MessageSegment.at(event.user_id)
|
|
109
|
+
+ " 请在私聊中使用 /register 进行注册"
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── 任务管理 ───────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
task = on_command("task", rule=to_me())
|
|
117
|
+
|
|
118
|
+
@task.handle()
|
|
119
|
+
async def _(event: MessageEvent, args: Message = CommandArg()):
|
|
120
|
+
"""解析 /task 子命令并分发到对应处理函数。"""
|
|
121
|
+
user = user_mgr.get_user(event.user_id)
|
|
122
|
+
if not user:
|
|
123
|
+
await task.finish(
|
|
124
|
+
"您还没有注册。请在私聊中使用 /register 注册。"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
text = args.extract_plain_text().strip().split()
|
|
128
|
+
cmd = text[0] if text else "list"
|
|
129
|
+
|
|
130
|
+
if cmd == "list":
|
|
131
|
+
await _task_list(user)
|
|
132
|
+
elif cmd in ("enable", "disable") and len(text) >= 2:
|
|
133
|
+
enabled = cmd == "enable"
|
|
134
|
+
await _task_update(user, text[1], {"enabled": enabled})
|
|
135
|
+
elif cmd == "interval" and len(text) >= 3:
|
|
136
|
+
try:
|
|
137
|
+
interval = int(text[2])
|
|
138
|
+
except ValueError:
|
|
139
|
+
await task.finish("间隔必须为数字(秒)。")
|
|
140
|
+
return
|
|
141
|
+
await _task_update(user, text[1], {"interval": interval})
|
|
142
|
+
elif cmd == "reset" and len(text) >= 2:
|
|
143
|
+
await _task_reset(user, text[1])
|
|
144
|
+
else:
|
|
145
|
+
await task.finish(
|
|
146
|
+
"可用命令:\n"
|
|
147
|
+
"/task list 列出所有任务\n"
|
|
148
|
+
"/task enable <task_id> 启用任务\n"
|
|
149
|
+
"/task disable <task_id> 停用任务\n"
|
|
150
|
+
"/task interval <task_id> <秒> 修改轮询间隔\n"
|
|
151
|
+
"/task reset <task_id> 恢复默认"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def _task_list(user):
|
|
156
|
+
"""发送 GET /api/tasks 并格式化输出。"""
|
|
157
|
+
async with httpx.AsyncClient() as client:
|
|
158
|
+
try:
|
|
159
|
+
resp = await client.get(
|
|
160
|
+
f"{config.server_url}/api/tasks",
|
|
161
|
+
params={"token": user.token},
|
|
162
|
+
timeout=10,
|
|
163
|
+
)
|
|
164
|
+
resp.raise_for_status()
|
|
165
|
+
data = resp.json()
|
|
166
|
+
except Exception as e:
|
|
167
|
+
logger.error(f"获取任务列表失败: {e}")
|
|
168
|
+
await task.finish(f"获取任务列表失败:{e}")
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
tasks = data.get("tasks", [])
|
|
172
|
+
if not tasks:
|
|
173
|
+
await task.finish("暂无可用任务。")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
lines = ["📋 任务列表"]
|
|
177
|
+
for t in tasks:
|
|
178
|
+
status = "✅" if t.get("enabled") else "⏹"
|
|
179
|
+
override = " (已覆写)" if t.get("has_override") else ""
|
|
180
|
+
lines.append(
|
|
181
|
+
f"{status} {t['task_id']} — {t.get('description', '')}"
|
|
182
|
+
)
|
|
183
|
+
lines.append(
|
|
184
|
+
f" 间隔: {t.get('interval')}s"
|
|
185
|
+
f" | 状态: {t.get('cache_status')}{override}"
|
|
186
|
+
)
|
|
187
|
+
await task.finish("\n".join(lines))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _task_update(user, task_id: str, body: dict):
|
|
191
|
+
"""发送 PUT /api/tasks/{id} 修改任务配置。"""
|
|
192
|
+
async with httpx.AsyncClient() as client:
|
|
193
|
+
try:
|
|
194
|
+
resp = await client.put(
|
|
195
|
+
f"{config.server_url}/api/tasks/{task_id}",
|
|
196
|
+
params={"token": user.token},
|
|
197
|
+
json=body,
|
|
198
|
+
timeout=10,
|
|
199
|
+
)
|
|
200
|
+
if resp.status_code == 401:
|
|
201
|
+
await task.finish(
|
|
202
|
+
"Token 已失效,请在私聊中重新注册。"
|
|
203
|
+
)
|
|
204
|
+
return
|
|
205
|
+
if resp.status_code == 404:
|
|
206
|
+
await task.finish(f"任务 {task_id} 不存在。")
|
|
207
|
+
return
|
|
208
|
+
resp.raise_for_status()
|
|
209
|
+
await task.finish(f"✅ 任务 {task_id} 已更新。")
|
|
210
|
+
except Exception as e:
|
|
211
|
+
logger.error(f"更新任务失败: {e}")
|
|
212
|
+
await task.finish(f"更新任务失败:{e}")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def _task_reset(user, task_id: str):
|
|
216
|
+
"""发送 DELETE /api/tasks/{id} 重置为系统默认。"""
|
|
217
|
+
async with httpx.AsyncClient() as client:
|
|
218
|
+
try:
|
|
219
|
+
resp = await client.delete(
|
|
220
|
+
f"{config.server_url}/api/tasks/{task_id}",
|
|
221
|
+
params={"token": user.token},
|
|
222
|
+
timeout=10,
|
|
223
|
+
)
|
|
224
|
+
if resp.status_code == 401:
|
|
225
|
+
await task.finish(
|
|
226
|
+
"Token 已失效,请在私聊中重新注册。"
|
|
227
|
+
)
|
|
228
|
+
return
|
|
229
|
+
resp.raise_for_status()
|
|
230
|
+
msg = resp.json().get("message", "已重置")
|
|
231
|
+
await task.finish(f"✅ 任务 {task_id} {msg}。")
|
|
232
|
+
except Exception as e:
|
|
233
|
+
logger.error(f"重置任务失败: {e}")
|
|
234
|
+
await task.finish(f"重置任务失败:{e}")
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""轮询引擎 — 多用户差异检测。
|
|
2
|
+
|
|
3
|
+
Poller 遍历 UserManager 中所有注册用户,每人独立:
|
|
4
|
+
1. 确保 Token 有效
|
|
5
|
+
2. 拉取点名/待办数据
|
|
6
|
+
3. 与 seen_ids 做 diff,发现新项后触发通知
|
|
7
|
+
4. 更新 seen_ids
|
|
8
|
+
|
|
9
|
+
首次启动时所有 seen_ids 为空,自动跳过第一轮通知。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from nonebot.log import logger
|
|
14
|
+
|
|
15
|
+
from .config import config
|
|
16
|
+
from .state import user_mgr, UserSession
|
|
17
|
+
from .auth import TokenManager
|
|
18
|
+
from .notifier import notifier
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _extract_items(data, key: str) -> list[dict]:
|
|
22
|
+
"""从 LAZY SERVER 响应中提取数据项列表。
|
|
23
|
+
|
|
24
|
+
兼容多种返回格式:list 直接返回,dict 尝试预设 key 列表,
|
|
25
|
+
保证与 LAZY server 侧 _extract_items 逻辑一致。
|
|
26
|
+
"""
|
|
27
|
+
if isinstance(data, list):
|
|
28
|
+
return data
|
|
29
|
+
if isinstance(data, dict):
|
|
30
|
+
for k in (
|
|
31
|
+
key, "rollcalls", "todos", "items",
|
|
32
|
+
"data", "list", "uploads", "activities",
|
|
33
|
+
):
|
|
34
|
+
if k in data and isinstance(data[k], list):
|
|
35
|
+
return data[k]
|
|
36
|
+
for v in data.values():
|
|
37
|
+
if isinstance(v, list):
|
|
38
|
+
return v
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Poller:
|
|
43
|
+
"""定时轮询调度器,处理所有注册用户的数据拉取和通知触发。"""
|
|
44
|
+
|
|
45
|
+
async def run(self):
|
|
46
|
+
"""一次完整的轮询:遍历所有用户。"""
|
|
47
|
+
for user in list(user_mgr.all_users()):
|
|
48
|
+
try:
|
|
49
|
+
await self._poll_user(user)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.error(
|
|
52
|
+
f"轮询用户 {user.studentid} 时发生未预期错误: {e}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async def _poll_user(self, user: UserSession):
|
|
56
|
+
"""处理单个用户的轮询(Token 管理 → 数据拉取 → diff 通知)。"""
|
|
57
|
+
tm = TokenManager(user.server_url, user.studentid, user.password)
|
|
58
|
+
tm.token = user.token
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
await tm.ensure_token()
|
|
62
|
+
except Exception as e:
|
|
63
|
+
user.consecutive_failures += 1
|
|
64
|
+
logger.error(
|
|
65
|
+
f"用户 {user.studentid} Token 失败"
|
|
66
|
+
f" ({user.consecutive_failures}/5): {e}"
|
|
67
|
+
)
|
|
68
|
+
if user.consecutive_failures >= 5:
|
|
69
|
+
await notifier.alert_superusers(
|
|
70
|
+
f"用户 {user.studentid} 连续 5 次轮询失败"
|
|
71
|
+
)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
user.consecutive_failures = 0
|
|
75
|
+
user.token = tm.token
|
|
76
|
+
|
|
77
|
+
async with httpx.AsyncClient() as client:
|
|
78
|
+
if config.enable_rollcall:
|
|
79
|
+
await self._poll_rollcall(client, user, tm)
|
|
80
|
+
if config.enable_todo:
|
|
81
|
+
await self._poll_todo(client, user, tm)
|
|
82
|
+
|
|
83
|
+
async def _fetch_data(
|
|
84
|
+
self, client: httpx.AsyncClient,
|
|
85
|
+
tm: TokenManager, task_id: str,
|
|
86
|
+
) -> dict | None:
|
|
87
|
+
"""拉取单任务数据,自动处理 401 重新认证。"""
|
|
88
|
+
url = f"{config.server_url}/api/data/{task_id}"
|
|
89
|
+
try:
|
|
90
|
+
resp = await client.get(
|
|
91
|
+
url, params={"token": tm.token}, timeout=10,
|
|
92
|
+
)
|
|
93
|
+
if resp.status_code == 401:
|
|
94
|
+
logger.warning("Token 失效 (401),尝试重新登录")
|
|
95
|
+
await tm.handle_401()
|
|
96
|
+
resp = await client.get(
|
|
97
|
+
url, params={"token": tm.token}, timeout=10,
|
|
98
|
+
)
|
|
99
|
+
resp.raise_for_status()
|
|
100
|
+
return resp.json()
|
|
101
|
+
except Exception as e:
|
|
102
|
+
logger.error(f"拉取 {task_id} 数据失败: {e}")
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
async def _poll_rollcall(
|
|
106
|
+
self, client: httpx.AsyncClient,
|
|
107
|
+
user: UserSession, tm: TokenManager,
|
|
108
|
+
):
|
|
109
|
+
body = await self._fetch_data(client, tm, "rollcall_watch")
|
|
110
|
+
if body is None:
|
|
111
|
+
return
|
|
112
|
+
if body.get("status") != "ok" or body.get("data") is None:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
items = _extract_items(body["data"], "rollcalls")
|
|
116
|
+
if not items:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
new_items = [
|
|
120
|
+
item for item in items
|
|
121
|
+
if item.get("rollcall_id")
|
|
122
|
+
not in user.state.seen_rollcall_ids
|
|
123
|
+
]
|
|
124
|
+
if new_items:
|
|
125
|
+
await notifier.notify_rollcalls(new_items, user.qq_id)
|
|
126
|
+
|
|
127
|
+
user.state.seen_rollcall_ids.update(
|
|
128
|
+
item["rollcall_id"]
|
|
129
|
+
for item in items if "rollcall_id" in item
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
async def _poll_todo(
|
|
133
|
+
self, client: httpx.AsyncClient,
|
|
134
|
+
user: UserSession, tm: TokenManager,
|
|
135
|
+
):
|
|
136
|
+
body = await self._fetch_data(client, tm, "todo_watch")
|
|
137
|
+
if body is None:
|
|
138
|
+
return
|
|
139
|
+
if body.get("status") != "ok" or body.get("data") is None:
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
items = _extract_items(body["data"], "todos")
|
|
143
|
+
if not items:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
new_items = [
|
|
147
|
+
item for item in items
|
|
148
|
+
if item.get("id") not in user.state.seen_todo_ids
|
|
149
|
+
]
|
|
150
|
+
if new_items:
|
|
151
|
+
await notifier.notify_todos(new_items, user.qq_id)
|
|
152
|
+
|
|
153
|
+
user.state.seen_todo_ids.update(
|
|
154
|
+
item["id"] for item in items if "id" in item
|
|
155
|
+
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""通知格式化与消息发送。
|
|
2
|
+
|
|
3
|
+
支持两种通道:
|
|
4
|
+
- 群通知:@ 特定用户(数据触发者或 owner_qq)
|
|
5
|
+
- 私聊通知:直接发送给数据触发者
|
|
6
|
+
|
|
7
|
+
发送异常不会阻塞后续通知。"""
|
|
8
|
+
|
|
9
|
+
from nonebot import get_bot
|
|
10
|
+
from nonebot.adapters.onebot.v11 import MessageSegment, Message
|
|
11
|
+
from nonebot.log import logger
|
|
12
|
+
|
|
13
|
+
from .config import config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Notifier:
|
|
17
|
+
"""格式化点名/待办消息并通过 OneBot API 发送。"""
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def _format_rollcall(item: dict) -> str:
|
|
21
|
+
rollcall_type = "雷达点名" if item.get("is_radar") else "数字点名"
|
|
22
|
+
return (
|
|
23
|
+
f"🔔 新点名通知\n"
|
|
24
|
+
f"————————\n"
|
|
25
|
+
f"课程:{item.get('course_title', '未知')}\n"
|
|
26
|
+
f"发起人:{item.get('created_by_name', '未知')}\n"
|
|
27
|
+
f"类型:{rollcall_type}"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def _format_todo(item: dict) -> str:
|
|
32
|
+
return (
|
|
33
|
+
f"📋 新待办通知\n"
|
|
34
|
+
f"————————\n"
|
|
35
|
+
f"标题:{item.get('title', '未知')}\n"
|
|
36
|
+
f"课程:{item.get('course_name', '未知')}\n"
|
|
37
|
+
f"截止时间:{item.get('end_time', '未知')}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _resolve_at_qq(self, qq_id: int) -> int:
|
|
41
|
+
"""确定群通知中 @ 的 QQ 号。DM 注册用户 @ 自己,.env 账号 @ owner_qq。"""
|
|
42
|
+
if qq_id > 0:
|
|
43
|
+
return qq_id
|
|
44
|
+
if config.owner_qq > 0:
|
|
45
|
+
return config.owner_qq
|
|
46
|
+
return -1
|
|
47
|
+
|
|
48
|
+
async def notify_rollcalls(self, items: list[dict], qq_id: int):
|
|
49
|
+
"""批量发送点名通知。"""
|
|
50
|
+
try:
|
|
51
|
+
bot = get_bot()
|
|
52
|
+
except ValueError:
|
|
53
|
+
logger.error("没有可用的 Bot 实例")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
at_qq = self._resolve_at_qq(qq_id)
|
|
57
|
+
|
|
58
|
+
for item in items:
|
|
59
|
+
msg = self._format_rollcall(item)
|
|
60
|
+
for group in config.notify_groups:
|
|
61
|
+
try:
|
|
62
|
+
if at_qq > 0:
|
|
63
|
+
full_msg = Message(
|
|
64
|
+
MessageSegment.at(at_qq) + f"\n{msg}"
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
full_msg = Message(msg)
|
|
68
|
+
await bot.send_group_msg(
|
|
69
|
+
group_id=group, message=full_msg,
|
|
70
|
+
)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.error(f"发送点名群通知失败 ({group}): {e}")
|
|
73
|
+
|
|
74
|
+
if qq_id > 0:
|
|
75
|
+
try:
|
|
76
|
+
await bot.send_private_msg(user_id=qq_id, message=msg)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.error(
|
|
79
|
+
f"发送点名私聊通知失败 ({qq_id}): {e}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def notify_todos(self, items: list[dict], qq_id: int):
|
|
83
|
+
"""批量发送待办通知。"""
|
|
84
|
+
try:
|
|
85
|
+
bot = get_bot()
|
|
86
|
+
except ValueError:
|
|
87
|
+
logger.error("没有可用的 Bot 实例")
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
at_qq = self._resolve_at_qq(qq_id)
|
|
91
|
+
|
|
92
|
+
for item in items:
|
|
93
|
+
msg = self._format_todo(item)
|
|
94
|
+
for group in config.notify_groups:
|
|
95
|
+
try:
|
|
96
|
+
if at_qq > 0:
|
|
97
|
+
full_msg = Message(
|
|
98
|
+
MessageSegment.at(at_qq) + f"\n{msg}"
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
full_msg = Message(msg)
|
|
102
|
+
await bot.send_group_msg(
|
|
103
|
+
group_id=group, message=full_msg,
|
|
104
|
+
)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.error(f"发送待办群通知失败 ({group}): {e}")
|
|
107
|
+
|
|
108
|
+
if qq_id > 0:
|
|
109
|
+
try:
|
|
110
|
+
await bot.send_private_msg(user_id=qq_id, message=msg)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
logger.error(
|
|
113
|
+
f"发送待办私聊通知失败 ({qq_id}): {e}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def alert_superusers(self, msg: str):
|
|
117
|
+
"""给所有 SUPERUSERS 发送告警(用于连续失败场景)。"""
|
|
118
|
+
try:
|
|
119
|
+
bot = get_bot()
|
|
120
|
+
for sid in bot.config.superusers:
|
|
121
|
+
try:
|
|
122
|
+
await bot.send_private_msg(
|
|
123
|
+
user_id=int(sid), message=msg,
|
|
124
|
+
)
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
except Exception:
|
|
128
|
+
logger.error(f"无法发送告警消息: {msg}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
notifier = Notifier()
|