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