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.
@@ -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()