nonebot-plugin-milock 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_milock/__init__.py +378 -0
- nonebot_plugin_milock/client.py +212 -0
- nonebot_plugin_milock/config.py +173 -0
- nonebot_plugin_milock/flow.py +184 -0
- nonebot_plugin_milock/messages.py +293 -0
- nonebot_plugin_milock/report.py +111 -0
- nonebot_plugin_milock/schedule.py +140 -0
- nonebot_plugin_milock/service.py +274 -0
- nonebot_plugin_milock/store.py +557 -0
- nonebot_plugin_milock-0.1.0.dist-info/METADATA +309 -0
- nonebot_plugin_milock-0.1.0.dist-info/RECORD +14 -0
- nonebot_plugin_milock-0.1.0.dist-info/WHEEL +5 -0
- nonebot_plugin_milock-0.1.0.dist-info/licenses/LICENSE +674 -0
- nonebot_plugin_milock-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""nonebot-plugin-milock:审核制申请 milock 一次性密码。
|
|
2
|
+
|
|
3
|
+
流程:``/密码`` → (首次)建单送审 → 管理员在管理群或私聊 ``/milock approve <单号>``
|
|
4
|
+
→ 插件向 milock 取一个**尚未发给任何人**的密码 → 私聊发给申请人 →
|
|
5
|
+
把申请、审核、下发(含密码)汇报给管理员私聊与管理群。
|
|
6
|
+
|
|
7
|
+
配置见 :mod:`nonebot_plugin_milock.config`,全部通过 NoneBot ``.env`` 提供。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from nonebot import get_bots, get_driver, get_plugin_config, on_command, on_keyword
|
|
17
|
+
from nonebot.adapters.onebot.v11 import Bot, Event, GroupMessageEvent, Message
|
|
18
|
+
from nonebot.params import CommandArg
|
|
19
|
+
from nonebot.plugin import PluginMetadata
|
|
20
|
+
|
|
21
|
+
from . import messages
|
|
22
|
+
from .client import MilockClient
|
|
23
|
+
from .config import Config
|
|
24
|
+
from .flow import handle_application, handle_decision, notify_expired
|
|
25
|
+
from .report import Reporter
|
|
26
|
+
from .schedule import TimeParseError, parse_when
|
|
27
|
+
from .service import OTPService
|
|
28
|
+
from .store import STATUS_APPROVED, Store
|
|
29
|
+
|
|
30
|
+
__plugin_meta__ = PluginMetadata(
|
|
31
|
+
name="milock 一次性密码",
|
|
32
|
+
description="审核制申请 milock 服务生成的一次性密码:单次发放、不可复用、管理员可审计",
|
|
33
|
+
usage=(
|
|
34
|
+
"用户:/密码\n"
|
|
35
|
+
"管理员:/milock approve <单号> | deny <单号> | pending | revoke <QQ> | users | status"
|
|
36
|
+
),
|
|
37
|
+
type="application",
|
|
38
|
+
config=Config,
|
|
39
|
+
supported_adapters={"~onebot.v11"},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger("nonebot_plugin_milock")
|
|
43
|
+
|
|
44
|
+
config = get_plugin_config(Config)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AppState:
|
|
48
|
+
"""插件的全部运行时对象,懒加载一次。"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, cfg: Config) -> None:
|
|
51
|
+
self.config = cfg
|
|
52
|
+
self.client = MilockClient(
|
|
53
|
+
cfg.milock_api_base,
|
|
54
|
+
api_key=cfg.milock_api_key,
|
|
55
|
+
did=cfg.milock_did,
|
|
56
|
+
pincode=cfg.milock_pincode,
|
|
57
|
+
seed_hex=cfg.milock_seed_hex,
|
|
58
|
+
timeout=cfg.milock_timeout,
|
|
59
|
+
interval_minutes=cfg.milock_interval_minutes,
|
|
60
|
+
digits=cfg.milock_digits,
|
|
61
|
+
)
|
|
62
|
+
self.store = Store(cfg.milock_db_path)
|
|
63
|
+
self.service = OTPService(
|
|
64
|
+
self.client,
|
|
65
|
+
self.store,
|
|
66
|
+
max_codes_per_window=cfg.milock_max_codes_per_window,
|
|
67
|
+
min_remaining_seconds=cfg.milock_min_remaining_seconds,
|
|
68
|
+
reservation_ttl=cfg.milock_reservation_ttl,
|
|
69
|
+
approval_timeout=cfg.milock_approval_timeout,
|
|
70
|
+
)
|
|
71
|
+
self.reporter = Reporter(
|
|
72
|
+
admins=cfg.milock_admins | set(get_driver().config.superusers),
|
|
73
|
+
groups=cfg.milock_admin_groups,
|
|
74
|
+
report_password=cfg.milock_report_password,
|
|
75
|
+
report_password_in_groups=cfg.milock_report_password_in_groups,
|
|
76
|
+
to_admins=cfg.milock_report_to_admins,
|
|
77
|
+
to_groups=cfg.milock_report_to_groups,
|
|
78
|
+
)
|
|
79
|
+
self.sweeper: asyncio.Task[None] | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_state: AppState | None = None
|
|
83
|
+
_state_lock = asyncio.Lock()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def get_state() -> AppState:
|
|
87
|
+
global _state
|
|
88
|
+
if _state is None:
|
|
89
|
+
async with _state_lock:
|
|
90
|
+
if _state is None:
|
|
91
|
+
_state = AppState(config)
|
|
92
|
+
_log_config(_state)
|
|
93
|
+
return _state
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _log_config(state: AppState) -> None:
|
|
97
|
+
cfg = state.config
|
|
98
|
+
credential = "(未配置!)" if not cfg.has_credentials() else cfg.milock_did or "(seed_hex)"
|
|
99
|
+
channels = []
|
|
100
|
+
if state.reporter.to_admins:
|
|
101
|
+
channels.append(f"私聊×{len(state.reporter.admins)}")
|
|
102
|
+
if state.reporter.to_groups:
|
|
103
|
+
channels.append(f"群×{len(state.reporter.groups)}")
|
|
104
|
+
logger.info(
|
|
105
|
+
"milock 插件已加载:api=%s did=%s db=%s 汇报=%s 每窗口上限=%d",
|
|
106
|
+
cfg.milock_api_base,
|
|
107
|
+
credential,
|
|
108
|
+
cfg.milock_db_path,
|
|
109
|
+
"+".join(channels) or "(全部关闭)",
|
|
110
|
+
cfg.milock_max_codes_per_window,
|
|
111
|
+
)
|
|
112
|
+
if not cfg.has_credentials():
|
|
113
|
+
logger.error("milock: 未配置 MILOCK_DID 或 MILOCK_SEED_HEX,取码一定失败。")
|
|
114
|
+
if not state.reporter.configured:
|
|
115
|
+
# 区分"渠道被主动关闭"与"压根没配目标",两种情况的排查方向不一样。
|
|
116
|
+
if not cfg.milock_report_to_admins and not cfg.milock_report_to_groups:
|
|
117
|
+
logger.error("milock: 汇报渠道全部关闭,申请与审核将不再通知任何人。")
|
|
118
|
+
else:
|
|
119
|
+
logger.error(
|
|
120
|
+
"milock: 没有可用的汇报目标(启用渠道内未配置 MILOCK_ADMINS / "
|
|
121
|
+
"MILOCK_ADMIN_GROUPS),首次申请将无人可审。"
|
|
122
|
+
)
|
|
123
|
+
elif not cfg.milock_admin_groups and not _superusers():
|
|
124
|
+
logger.warning("milock: 未配置管理群,群内审核会被拒绝。")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _superusers() -> set[str]:
|
|
128
|
+
return {str(item) for item in get_driver().config.superusers}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_admin(cfg: Config, qq: str, group_id: str | None) -> bool:
|
|
132
|
+
"""私聊:是管理员即可;群聊:是管理员且位于管理群(超管不受限)。
|
|
133
|
+
|
|
134
|
+
注意:群身份只看 ``MILOCK_ADMIN_GROUPS``,与 ``MILOCK_REPORT_TO_GROUPS``
|
|
135
|
+
无关——关掉群汇报不会顺带关掉群内审核资格。
|
|
136
|
+
"""
|
|
137
|
+
if qq not in cfg.milock_admins and qq not in _superusers():
|
|
138
|
+
return False
|
|
139
|
+
if group_id is None:
|
|
140
|
+
return True
|
|
141
|
+
return group_id in cfg.milock_admin_groups or qq in _superusers()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _origin(event: Event) -> str | None:
|
|
145
|
+
return str(event.group_id) if isinstance(event, GroupMessageEvent) else None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _nickname(event: Event) -> str:
|
|
149
|
+
sender = getattr(event, "sender", None)
|
|
150
|
+
return str(getattr(sender, "nickname", "") or "")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --------------------------------------------------------------------------- #
|
|
154
|
+
# 用户:申请密码
|
|
155
|
+
# --------------------------------------------------------------------------- #
|
|
156
|
+
apply_cmd = on_command(
|
|
157
|
+
config.milock_command,
|
|
158
|
+
aliases={"申请密码", "取密码", "otp"},
|
|
159
|
+
priority=10,
|
|
160
|
+
block=True,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def _run_apply(bot: Bot, event: Event, raw_args: str) -> str | None:
|
|
165
|
+
"""处理一次申请:解析可选的时间参数并交给 flow。"""
|
|
166
|
+
state = await get_state()
|
|
167
|
+
at = await _resolve_requested_at(state.config, raw_args)
|
|
168
|
+
if isinstance(at, str): # 解析失败,at 是给用户看的错误文案
|
|
169
|
+
return at
|
|
170
|
+
return await handle_application(
|
|
171
|
+
bot,
|
|
172
|
+
state.service,
|
|
173
|
+
state.reporter,
|
|
174
|
+
qq=event.get_user_id(),
|
|
175
|
+
nickname=_nickname(event),
|
|
176
|
+
group_id=_origin(event),
|
|
177
|
+
at=at,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def _resolve_requested_at(cfg: Config, raw_args: str) -> int | str | None:
|
|
182
|
+
"""把用户输入的时间文本解析成时间戳。
|
|
183
|
+
|
|
184
|
+
返回 ``int``(时间戳)、``None``(没写时间,要现在)或 ``str``(错误文案)。
|
|
185
|
+
"""
|
|
186
|
+
text = raw_args.strip()
|
|
187
|
+
if not text:
|
|
188
|
+
return None
|
|
189
|
+
if not cfg.milock_allow_time_request:
|
|
190
|
+
return messages.time_disabled_text()
|
|
191
|
+
try:
|
|
192
|
+
at = parse_when(text)
|
|
193
|
+
except TimeParseError as exc:
|
|
194
|
+
return messages.time_parse_failed_text(exc.message)
|
|
195
|
+
|
|
196
|
+
now = int(time.time())
|
|
197
|
+
if at <= now:
|
|
198
|
+
return messages.time_in_past_text()
|
|
199
|
+
limit = now + cfg.milock_max_advance_hours * 3600
|
|
200
|
+
if at > limit:
|
|
201
|
+
return messages.time_too_far_text(cfg.milock_max_advance_hours)
|
|
202
|
+
return at
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@apply_cmd.handle()
|
|
206
|
+
async def _handle_apply(bot: Bot, event: Event, args: Message = CommandArg()) -> None:
|
|
207
|
+
reply = await _run_apply(bot, event, args.extract_plain_text())
|
|
208
|
+
if reply:
|
|
209
|
+
await apply_cmd.finish(reply)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if config.milock_allow_keyword_trigger:
|
|
213
|
+
apply_keyword = on_keyword({"申请密码"}, priority=11, block=True)
|
|
214
|
+
|
|
215
|
+
@apply_keyword.handle()
|
|
216
|
+
async def _handle_apply_keyword(bot: Bot, event: Event) -> None:
|
|
217
|
+
# 关键词触发不带参数("申请密码" 后面跟的时间无法可靠切分),一律按"现在"。
|
|
218
|
+
reply = await _run_apply(bot, event, "")
|
|
219
|
+
if reply:
|
|
220
|
+
await apply_keyword.finish(reply)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --------------------------------------------------------------------------- #
|
|
224
|
+
# 管理员:审核 / 撤销 / 查询
|
|
225
|
+
# --------------------------------------------------------------------------- #
|
|
226
|
+
admin_cmd = on_command("milock", priority=5, block=True)
|
|
227
|
+
|
|
228
|
+
_APPROVE_WORDS = {"approve", "ok", "yes", "批准", "通过", "同意"}
|
|
229
|
+
_DENY_WORDS = {"deny", "no", "reject", "拒绝", "驳回"}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@admin_cmd.handle()
|
|
233
|
+
async def _handle_admin(bot: Bot, event: Event, args: Message = CommandArg()) -> None:
|
|
234
|
+
state = await get_state()
|
|
235
|
+
qq = event.get_user_id()
|
|
236
|
+
group_id = _origin(event)
|
|
237
|
+
if not _is_admin(state.config, qq, group_id):
|
|
238
|
+
await admin_cmd.finish(messages.no_permission_text())
|
|
239
|
+
|
|
240
|
+
parts = args.extract_plain_text().strip().split()
|
|
241
|
+
sub = parts[0].lower() if parts else "help"
|
|
242
|
+
argument = parts[1] if len(parts) > 1 else ""
|
|
243
|
+
|
|
244
|
+
if sub in _APPROVE_WORDS | _DENY_WORDS:
|
|
245
|
+
request_id = _as_int(argument)
|
|
246
|
+
if request_id is None:
|
|
247
|
+
await admin_cmd.finish(f"用法:/milock {sub} <单号>")
|
|
248
|
+
reply = await handle_decision(
|
|
249
|
+
bot,
|
|
250
|
+
state.service,
|
|
251
|
+
state.reporter,
|
|
252
|
+
request_id=request_id,
|
|
253
|
+
admin=qq,
|
|
254
|
+
approve=sub in _APPROVE_WORDS,
|
|
255
|
+
)
|
|
256
|
+
await admin_cmd.finish(reply)
|
|
257
|
+
|
|
258
|
+
if sub in {"pending", "待审", "列表"}:
|
|
259
|
+
await admin_cmd.finish(await _render_pending(state))
|
|
260
|
+
|
|
261
|
+
if sub in {"revoke", "撤销"}:
|
|
262
|
+
target = argument.strip()
|
|
263
|
+
if not target.isdigit():
|
|
264
|
+
await admin_cmd.finish("用法:/milock revoke <QQ号>")
|
|
265
|
+
if await state.service.revoke(target, qq):
|
|
266
|
+
await state.reporter.broadcast(bot, messages.report_revoke(target, qq))
|
|
267
|
+
try:
|
|
268
|
+
await state.reporter.send_private(bot, target, messages.revoked_text())
|
|
269
|
+
except Exception as exc:
|
|
270
|
+
logger.warning("milock: 撤销通知 %s 发送失败: %s", target, exc)
|
|
271
|
+
await admin_cmd.finish(f"已撤销 {target} 的授权。")
|
|
272
|
+
await admin_cmd.finish(f"{target} 当前没有有效授权。")
|
|
273
|
+
|
|
274
|
+
if sub in {"users", "授权"}:
|
|
275
|
+
users = await state.store.list_users(STATUS_APPROVED)
|
|
276
|
+
if not users:
|
|
277
|
+
await admin_cmd.finish("当前没有已授权用户。")
|
|
278
|
+
lines = [
|
|
279
|
+
f"{index}. {user.qq}(by {user.approved_by or '未知'})"
|
|
280
|
+
for index, user in enumerate(users, start=1)
|
|
281
|
+
]
|
|
282
|
+
await admin_cmd.finish("已授权用户:\n" + "\n".join(lines))
|
|
283
|
+
|
|
284
|
+
if sub in {"status", "状态"}:
|
|
285
|
+
await admin_cmd.finish(await _render_status(state))
|
|
286
|
+
|
|
287
|
+
# 管理命令固定为 /milock(见上面的 on_command),别把它和用户申请命令名混用。
|
|
288
|
+
await admin_cmd.finish(messages.admin_help())
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def _render_pending(state: AppState) -> str:
|
|
292
|
+
pending = await state.service.pending()
|
|
293
|
+
if not pending:
|
|
294
|
+
return "当前没有待审核申请。"
|
|
295
|
+
lines = [
|
|
296
|
+
f"#{item.id} {item.nickname}({item.qq}) 群={item.group_id or '私聊'} "
|
|
297
|
+
f"{_fmt_time(item.created_at)}"
|
|
298
|
+
for item in pending
|
|
299
|
+
]
|
|
300
|
+
return "待审核申请:\n" + "\n".join(lines)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def _render_status(state: AppState) -> str:
|
|
304
|
+
stats = await state.store.stats()
|
|
305
|
+
reachable = await state.client.healthz()
|
|
306
|
+
cfg = state.config
|
|
307
|
+
lines = [
|
|
308
|
+
"milock 插件状态:",
|
|
309
|
+
f"服务:{cfg.milock_api_base}({'可达' if reachable else '不可达'})",
|
|
310
|
+
f"凭证:{'已配置' if cfg.has_credentials() else '未配置'}",
|
|
311
|
+
f"管理员:{len(state.reporter.admins)} 人,管理群:{len(state.reporter.groups)} 个",
|
|
312
|
+
f"已授权用户:{stats['approved_users']}",
|
|
313
|
+
f"待审核申请:{stats['pending_requests']}",
|
|
314
|
+
f"已下发密码:{stats['delivered_codes']}(占位中 {stats['reserved_codes']})",
|
|
315
|
+
f"每窗口上限:{cfg.milock_max_codes_per_window} 个/人",
|
|
316
|
+
]
|
|
317
|
+
return "\n".join(lines)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _as_int(value: str) -> int | None:
|
|
321
|
+
try:
|
|
322
|
+
return int(str(value).strip())
|
|
323
|
+
except (TypeError, ValueError):
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _fmt_time(ts: int) -> str:
|
|
328
|
+
return time.strftime("%m-%d %H:%M", time.localtime(ts))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# --------------------------------------------------------------------------- #
|
|
332
|
+
# 后台清理:超时申请作废 + 失效占位回收
|
|
333
|
+
# --------------------------------------------------------------------------- #
|
|
334
|
+
async def _sweeper(state: AppState) -> None:
|
|
335
|
+
interval = state.config.milock_sweep_interval
|
|
336
|
+
while True:
|
|
337
|
+
await asyncio.sleep(interval)
|
|
338
|
+
try:
|
|
339
|
+
result = await state.service.sweep()
|
|
340
|
+
except Exception: # 后台任务不能因一次异常退出
|
|
341
|
+
logger.exception("milock: 清理任务失败")
|
|
342
|
+
continue
|
|
343
|
+
bots = get_bots()
|
|
344
|
+
bot = next(iter(bots.values()), None)
|
|
345
|
+
if bot is None:
|
|
346
|
+
if result.expired or result.released:
|
|
347
|
+
logger.info(
|
|
348
|
+
"milock: 清理完成(无在线 Bot,未汇报)过期申请=%d 回收占位=%d",
|
|
349
|
+
len(result.expired),
|
|
350
|
+
result.released,
|
|
351
|
+
)
|
|
352
|
+
continue
|
|
353
|
+
if result.expired:
|
|
354
|
+
await notify_expired(bot, state.reporter, result.expired)
|
|
355
|
+
if result.released:
|
|
356
|
+
logger.info("milock: 回收了 %d 个未投递的密码占位", result.released)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
driver = get_driver()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@driver.on_startup
|
|
363
|
+
async def _startup() -> None:
|
|
364
|
+
state = await get_state()
|
|
365
|
+
if state.sweeper is None or state.sweeper.done():
|
|
366
|
+
state.sweeper = asyncio.create_task(_sweeper(state))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@driver.on_shutdown
|
|
370
|
+
async def _shutdown() -> None:
|
|
371
|
+
global _state
|
|
372
|
+
if _state is None:
|
|
373
|
+
return
|
|
374
|
+
if _state.sweeper is not None:
|
|
375
|
+
_state.sweeper.cancel()
|
|
376
|
+
_state.sweeper = None
|
|
377
|
+
_state.store.close()
|
|
378
|
+
_state = None
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""milock HTTP 客户端。
|
|
2
|
+
|
|
3
|
+
只用标准库(``urllib`` + ``asyncio.to_thread``),因此插件除 NoneBot 外
|
|
4
|
+
不需要任何第三方运行期依赖。对应服务端接口见 ``docs/API.md``:
|
|
5
|
+
|
|
6
|
+
* ``POST /api/v1/otp/now`` —— 当前窗口
|
|
7
|
+
* ``POST /api/v1/otp`` —— 指定时间(用于提前取下一个窗口)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from http import HTTPStatus
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
__all__ = ["MilockClient", "MilockError", "Window"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MilockError(Exception):
|
|
24
|
+
"""milock 调用失败。``kind`` 用于决定给用户的提示文案。"""
|
|
25
|
+
|
|
26
|
+
#: 服务不可达(网络/连接被拒/DNS 失败)
|
|
27
|
+
UNREACHABLE = "unreachable"
|
|
28
|
+
#: 超时
|
|
29
|
+
TIMEOUT = "timeout"
|
|
30
|
+
#: API Key 缺失或错误
|
|
31
|
+
AUTH = "auth"
|
|
32
|
+
#: 触发服务端限流
|
|
33
|
+
RATELIMITED = "ratelimited"
|
|
34
|
+
#: 请求本身有问题(did/pincode 不对等)
|
|
35
|
+
BAD_REQUEST = "badrequest"
|
|
36
|
+
#: 服务端 5xx
|
|
37
|
+
SERVER = "server"
|
|
38
|
+
#: 响应不是预期的 JSON 结构
|
|
39
|
+
MALFORMED = "malformed"
|
|
40
|
+
|
|
41
|
+
def __init__(self, kind: str, detail: str, status: int | None = None) -> None:
|
|
42
|
+
super().__init__(detail)
|
|
43
|
+
self.kind = kind
|
|
44
|
+
self.detail = detail
|
|
45
|
+
self.status = status
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(slots=True, frozen=True)
|
|
49
|
+
class Window:
|
|
50
|
+
"""一个时间窗口内的全部密码。"""
|
|
51
|
+
|
|
52
|
+
passwords: tuple[str, ...]
|
|
53
|
+
interval_minutes: int
|
|
54
|
+
digits: int
|
|
55
|
+
counter: int
|
|
56
|
+
window_start: int
|
|
57
|
+
window_end: int
|
|
58
|
+
seconds_remaining: int
|
|
59
|
+
seed_source: str | None = None
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_payload(cls, data: Any) -> Window:
|
|
63
|
+
if not isinstance(data, dict):
|
|
64
|
+
raise MilockError(MilockError.MALFORMED, "响应不是 JSON 对象")
|
|
65
|
+
try:
|
|
66
|
+
raw = data["passwords"]
|
|
67
|
+
if not isinstance(raw, list) or not raw:
|
|
68
|
+
raise TypeError("passwords")
|
|
69
|
+
passwords = tuple(str(item) for item in raw)
|
|
70
|
+
return cls(
|
|
71
|
+
passwords=passwords,
|
|
72
|
+
interval_minutes=int(data["interval_minutes"]),
|
|
73
|
+
digits=int(data["digits"]),
|
|
74
|
+
counter=int(data["counter"]),
|
|
75
|
+
window_start=int(data["window_start"]),
|
|
76
|
+
window_end=int(data["window_end"]),
|
|
77
|
+
seconds_remaining=int(data["seconds_remaining"]),
|
|
78
|
+
seed_source=str(data["seed_source"]) if data.get("seed_source") else None,
|
|
79
|
+
)
|
|
80
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
81
|
+
raise MilockError(MilockError.MALFORMED, f"响应缺少字段或类型错误: {exc!r}") from None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _extract_error(body: bytes) -> str:
|
|
85
|
+
try:
|
|
86
|
+
data = json.loads(body.decode("utf-8", "replace"))
|
|
87
|
+
except (ValueError, UnicodeDecodeError):
|
|
88
|
+
return body.decode("utf-8", "replace")[:200].strip() or "无响应内容"
|
|
89
|
+
if isinstance(data, dict):
|
|
90
|
+
message = data.get("error") or data.get("message")
|
|
91
|
+
if isinstance(message, str) and message:
|
|
92
|
+
return message
|
|
93
|
+
return json.dumps(data, ensure_ascii=False)[:200]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class MilockClient:
|
|
97
|
+
"""极简 async 客户端。"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
base: str,
|
|
102
|
+
*,
|
|
103
|
+
api_key: str = "",
|
|
104
|
+
did: str = "",
|
|
105
|
+
pincode: str = "",
|
|
106
|
+
seed_hex: str = "",
|
|
107
|
+
timeout: float = 10.0,
|
|
108
|
+
interval_minutes: int | None = None,
|
|
109
|
+
digits: int | None = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
self.base = base.rstrip("/")
|
|
112
|
+
self.api_key = api_key
|
|
113
|
+
self.did = did
|
|
114
|
+
self.pincode = pincode
|
|
115
|
+
self.seed_hex = seed_hex
|
|
116
|
+
self.timeout = timeout
|
|
117
|
+
self.interval_minutes = interval_minutes
|
|
118
|
+
self.digits = digits
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------ #
|
|
121
|
+
# 公开 API
|
|
122
|
+
# ------------------------------------------------------------------ #
|
|
123
|
+
async def window_now(self) -> Window:
|
|
124
|
+
"""当前窗口。"""
|
|
125
|
+
return await self._window("/api/v1/otp/now", None)
|
|
126
|
+
|
|
127
|
+
async def window_at(self, at: int) -> Window:
|
|
128
|
+
"""包含 ``at``(unix 秒)的窗口,用于提前领取下一个窗口。"""
|
|
129
|
+
return await self._window("/api/v1/otp", at)
|
|
130
|
+
|
|
131
|
+
async def healthz(self) -> bool:
|
|
132
|
+
"""服务是否存活(用于启动自检,失败不抛异常)。"""
|
|
133
|
+
try:
|
|
134
|
+
await self._call("GET", "/healthz", None)
|
|
135
|
+
except MilockError:
|
|
136
|
+
return False
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------ #
|
|
140
|
+
# 内部实现
|
|
141
|
+
# ------------------------------------------------------------------ #
|
|
142
|
+
def build_payload(self, at: int | None = None) -> dict[str, Any]:
|
|
143
|
+
payload: dict[str, Any] = {}
|
|
144
|
+
if self.seed_hex:
|
|
145
|
+
payload["seed_hex"] = self.seed_hex
|
|
146
|
+
elif self.did:
|
|
147
|
+
payload["did"] = self.did
|
|
148
|
+
if self.pincode:
|
|
149
|
+
payload["pincode"] = self.pincode
|
|
150
|
+
if self.interval_minutes is not None:
|
|
151
|
+
payload["interval"] = self.interval_minutes
|
|
152
|
+
if self.digits is not None:
|
|
153
|
+
payload["digits"] = self.digits
|
|
154
|
+
if at is not None:
|
|
155
|
+
payload["at"] = int(at)
|
|
156
|
+
return payload
|
|
157
|
+
|
|
158
|
+
async def _window(self, path: str, at: int | None) -> Window:
|
|
159
|
+
data = await self._call("POST", path, self.build_payload(at))
|
|
160
|
+
return Window.from_payload(data)
|
|
161
|
+
|
|
162
|
+
async def _call(self, method: str, path: str, payload: dict[str, Any] | None) -> Any:
|
|
163
|
+
try:
|
|
164
|
+
return await asyncio.to_thread(self._call_sync, method, path, payload)
|
|
165
|
+
except MilockError:
|
|
166
|
+
raise
|
|
167
|
+
except TimeoutError as exc: # pragma: no cover - 平台差异
|
|
168
|
+
raise MilockError(MilockError.TIMEOUT, f"请求超时: {exc}") from None
|
|
169
|
+
except urllib.error.URLError as exc:
|
|
170
|
+
reason = exc.reason
|
|
171
|
+
if isinstance(reason, TimeoutError):
|
|
172
|
+
raise MilockError(MilockError.TIMEOUT, f"请求超时: {reason}") from None
|
|
173
|
+
raise MilockError(MilockError.UNREACHABLE, f"无法连接 {self.base}: {reason}") from None
|
|
174
|
+
except OSError as exc:
|
|
175
|
+
raise MilockError(MilockError.UNREACHABLE, f"网络错误: {exc}") from None
|
|
176
|
+
|
|
177
|
+
def _call_sync(self, method: str, path: str, payload: dict[str, Any] | None) -> Any:
|
|
178
|
+
url = f"{self.base}{path}"
|
|
179
|
+
data = None
|
|
180
|
+
headers = {"Accept": "application/json"}
|
|
181
|
+
if payload is not None:
|
|
182
|
+
data = json.dumps(payload).encode("utf-8")
|
|
183
|
+
headers["Content-Type"] = "application/json"
|
|
184
|
+
if self.api_key:
|
|
185
|
+
headers["X-API-Key"] = self.api_key
|
|
186
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
187
|
+
try:
|
|
188
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
189
|
+
body = response.read()
|
|
190
|
+
status = response.status
|
|
191
|
+
except urllib.error.HTTPError as exc:
|
|
192
|
+
body = exc.read()
|
|
193
|
+
raise MilockError(self._classify(exc.code), _extract_error(body), exc.code) from None
|
|
194
|
+
|
|
195
|
+
if status >= HTTPStatus.BAD_REQUEST: # pragma: no cover - HTTPError 已覆盖
|
|
196
|
+
raise MilockError(self._classify(status), _extract_error(body), status)
|
|
197
|
+
if not body:
|
|
198
|
+
return {}
|
|
199
|
+
try:
|
|
200
|
+
return json.loads(body.decode("utf-8"))
|
|
201
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
202
|
+
raise MilockError(MilockError.MALFORMED, f"响应不是合法 JSON: {exc}") from None
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _classify(status: int) -> str:
|
|
206
|
+
if status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
|
|
207
|
+
return MilockError.AUTH
|
|
208
|
+
if status == HTTPStatus.TOO_MANY_REQUESTS:
|
|
209
|
+
return MilockError.RATELIMITED
|
|
210
|
+
if HTTPStatus.BAD_REQUEST <= status < HTTPStatus.INTERNAL_SERVER_ERROR:
|
|
211
|
+
return MilockError.BAD_REQUEST
|
|
212
|
+
return MilockError.SERVER
|