nonebot-plugin-milock
把 milock 的一次性密码接到 QQ 群里:用户申请 → 管理员审核 → 私聊发码 → 全程汇报。
适用于"家里装了一把小米全自动门锁,想给访客/家人临时开门密码"的场景。 密码由 milock 服务离线生成,本插件只负责"审批 + 发放 + 留痕"。
1. 流程
用户:/密码 用户:/密码 21:00
│ │
│ └─ 取 21:00 所在窗口的前一个窗口(20:30-21:00);
│ 该码有效期 = 本窗口+下一窗口 = 20:30-21:30
│
├─ 未授权 ──▶ 建单送审 ──▶ 管理员私聊 + 管理群 收到「待审核」汇报(含期望时间)
│ │
│ ├─ /milock approve <单号> ─┐
│ └─ /milock deny <单号> ─┴─▶ 私聊通知申请人
│
└─ 已授权 ──▶ (跳过审核)
│
▼
向 milock 取一个尚未发给任何人的密码
(不指定时间 → 当前窗口;指定时间 → at 所在窗口的**前一个**窗口,
因为一个码的有效期是两个窗口 = 1 小时)
│
├─ 私聊投递成功 ──▶ 该密码被永久标记为已发放
└─ 私聊投递失败 ──▶ 立刻回收,密码可以让给别人
三条硬要求分别由下面几个机制保证:
| 要求 | 实现 |
|---|---|
| 首次申请必须管理员审核,之后免审 | users 表按 QQ 号记录授权,批准后全局永久有效(可 /milock revoke 撤销);申请单超时自动作废 |
| 密码只发给申请人 | 只走私聊投递;群里只回「已私聊发送」,群消息里不含密码 |
| 每次申请/下发都汇报 | 申请、重复申请、审核、下发、失败、超时作废、撤销,全部推送到管理员私聊 + 管理群(两个渠道可用 MILOCK_REPORT_TO_ADMINS / MILOCK_REPORT_TO_GROUPS 单独开关) |
2. ⚠️ 关于"一次性"的边界
插件为每个 (时间窗口, 密码序号) 建一条台账(grants 表):
- 发放前先用
BEGIN IMMEDIATE事务占位,投递成功才落成delivered; - 同一个密码永远不会发给第二个人(即使多人同时申请、即使监听了同一条消息);
- 投递失败会释放占位,密码可以给下一个人,不会白白浪费;
- 同一用户每个窗口默认只能领 1 个(
MILOCK_MAX_CODES_PER_WINDOW)。
一个密码的有效期是"本窗口 + 下一个窗口",也就是 1 小时(interval=30 时)。
依据是 Mi Home 自己的算法——GeneralOneTimePasswordGenerateActivity 等多处都写成:
valid_end = (窗口序号 + 2) * interval * 60 // +2 = 本窗口 + 下一窗口
所以窗口 20:30-21:00 的密码实际有效到 21:30。app 也用这个过期时刻做缓存键,
一小时内重复生成会复用同一个码。
但是:锁具固件认这些密码,插件无法在锁上作废它。所以"发出后即失效"只在 本插件这一侧成立——转发出去的密码在有效期结束前依然能开门;真正的物理失效 要等有效期过完(默认 1 小时)。
MILOCK_INTERVAL_MINUTES可调(1–60)。窗口越小,单个密码的有效期越短 (有效期恒为2 × interval分钟)。默认 30 分钟窗口 → 1 小时有效期。
3. 安装
插件只依赖 nonebot2 与 nonebot-adapter-onebot,对 milock 的调用走标准库 urllib。
# 方式一:从 PyPI 安装(推荐)
pip install nonebot-plugin-milock
# 方式二:从本仓库源码装
pip install /path/to/nonebot-plugin-milock
# 开发时用可编辑安装:
pip install -e "/path/to/nonebot-plugin-milock[dev]"
# 方式三:直接把包目录拷进 NoneBot 项目的 plugins/ 下
cp -r nonebot_plugin_milock <你的项目>/plugins/
在 bot.py 里加载:
nonebot.load_plugin("nonebot_plugin_milock")
# 或放进 plugins/ 目录后:nonebot.load_plugins("plugins")
4. 配置(写进 NoneBot 的 .env)
# ---- milock 服务 ----
MILOCK_API_BASE=http://192.168.139.50:8787
MILOCK_API_KEY=你的APIKey # 服务端没开鉴权就留空
MILOCK_DID=1234567890 # 设备的 did,服务端自动取密钥
MILOCK_PINCODE=1234 # 安全密码(encrypt_type=1 时需要)
# ---- 谁能审、汇报给谁 ----
MILOCK_ADMINS=["10001","10002"] # 额外管理员(NoneBot 的 SUPERUSERS 自动算管理员)
MILOCK_ADMIN_GROUPS=["987654321"] # 管理群:汇报目标 + 群内审核的唯一合法场所
MILOCK_REPORT_TO_ADMINS=true # 汇报是否推送到管理员私聊
MILOCK_REPORT_TO_GROUPS=true # 汇报是否推送到管理群
# ---- 谁可以要(与"谁能审"独立)----
MILOCK_REQUEST_GROUPS=[] # 只允许这些群索要;留空 = 不限制群
MILOCK_ALLOW_PRIVATE_REQUEST=true # 是否允许私聊索要
# ---- 出码与限流 ----
MILOCK_MAX_CODES_PER_WINDOW=1 # 每人每窗口可领几个
MILOCK_MIN_REMAINING_SECONDS=60 # 窗口剩余不足这么多秒就改用下一个窗口
MILOCK_APPROVAL_TIMEOUT=300 # 申请单多少秒没人处理就作废
MILOCK_REPORT_PASSWORD=true # 汇报给管理员私聊时是否带密码明文(审计用)
MILOCK_REPORT_PASSWORD_IN_GROUPS=false # 管理群汇报是否也带明文(默认不带)
# ---- 其它 ----
MILOCK_DB_PATH=data/milock.sqlite3 # 授权与发放台账,建议指向挂载卷
MILOCK_COMMAND=密码 # 用户申请命令名,默认 /密码
MILOCK_ALLOW_KEYWORD_TRIGGER=false # 允许直接说"申请密码"触发
MILOCK_ALLOW_TIME_REQUEST=true # 允许 /密码 21:00 取指定时段的码
MILOCK_MAX_ADVANCE_HOURS=24 # 最多提前多久取码
完整字段与含义见 nonebot_plugin_milock/config.py。
集合类型与 NoneBot 的 SUPERUSERS 一样写 JSON 数组,也接受逗号分隔字符串。
汇报渠道怎么选
MILOCK_REPORT_TO_ADMINS / MILOCK_REPORT_TO_GROUPS 分别控制两类汇报目标,互不影响:
| 配置 | 效果 |
|---|---|
| 两个都开(默认) | 管理员私聊 + 管理群都收到汇报 |
| 只开私聊 | 群里完全安静,适合"不想让整个群看到审批流水" |
| 只开群 | 管理员私聊不被打扰,适合"管理员都在群里" |
| 两个都关 | 静默运行,只在日志里留痕(不建议,出错时不利于排查) |
⚠️ 这两个开关只管汇报推送,不影响群内审核资格:能不能在群里执行
/milock approve 仍然只取决于群号是否在 MILOCK_ADMIN_GROUPS 里。
所以 MILOCK_REPORT_TO_ADMINS=false 时必须配 MILOCK_ADMIN_GROUPS——
否则申请没人看得见,插件会直接在启动时报错拒绝这种配置。
谁能要 vs 谁能审
这是两组独立的配置,刻意没有合并——审核场所和分发场所常常不是同一批群:
| 配置 | 管什么 |
|---|---|
MILOCK_REQUEST_GROUPS + MILOCK_ALLOW_PRIVATE_REQUEST |
谁能发起申请(要码) |
MILOCK_ADMIN_GROUPS + MILOCK_ADMINS |
谁能批准(审批) |
常用组合:
| 配置 | 效果 |
|---|---|
| 默认(群白名单为空、私聊开) | 任何群、任何私聊都能要码 |
MILOCK_REQUEST_GROUPS=["555"] |
只有 555 群能要;私聊仍可以 |
再设 MILOCK_ALLOW_PRIVATE_REQUEST=false |
只有 555 群能要,私聊也不行 |
只设 MILOCK_ALLOW_PRIVATE_REQUEST=false |
启动即报错(没有地方能要码了) |
要点:
- 被拒时只回一句「这里不能索要密码」,不会透露白名单内容,避免被拿去试探;
- 管理员与
SUPERUSERS不受白名单限制:被拉进新群时仍能帮人取码, 也避免"私聊关掉 + 群号漏配"时把自己彻底锁死; - 申请单上的「来源群」仍照实记录,审批时能看到是谁在哪个群要的。
5. 命令
| 身份 | 命令 | 说明 |
|---|---|---|
| 用户 | /密码(别名 /申请密码 /取密码 /otp) |
申请密码,首次会送审 |
| 用户 | /密码 <时间> |
取指定时段的密码,如 /密码 21:00 |
| 管理员 | /milock approve <单号>(批准/通过 同义) |
批准并立即私聊发码 |
| 管理员 | /milock deny <单号>(拒绝 同义) |
拒绝并通知申请人 |
| 管理员 | /milock pending |
查看待审核申请 |
| 管理员 | /milock revoke <QQ> |
撤销某人的授权 |
| 管理员 | /milock users |
查看已授权用户 |
| 管理员 | /milock status |
插件状态、milock 可达性、发放统计 |
指定时间取码
/密码 21:00 会立刻发给你一个"21:00 前后各半小时内都能开锁"的密码
(不是到点才发),所以你可以提前把码准备好。支持的写法:
| 写法 | 含义 |
|---|---|
21:00 |
下一个即将到来的 21:00(已过则算明天) |
09-15 21:00 |
今年 9 月 15 日 21:00(已过则算明年) |
2026-09-15 21:00 |
指定年月日(也可写 T 分隔) |
+30m / +2h / +1h30m |
相对现在的偏移 |
怎么做到"前后各半小时":因为一个密码的有效期是两个窗口
(本窗口 + 下一窗口,见第 2 节),所以取 at 所在窗口的前一个窗口即可:
| 输入 | 发出的码所属窗口 | 有效期 |
|---|---|---|
/密码 21:00 |
20:30-21:00 |
20:30 - 21:30(1 小时) |
/密码 21:15 |
21:00-21:30 |
21:00 - 22:00 |
/密码 08:00 |
07:30-08:00 |
07:30 - 08:30 |
21:00 恰好落在窗口边界上,所以有效区间正好以它为中心、前后各 30 分钟。
只发 1 个码——不需要为了覆盖而多发,暴露面最小。
私聊收到的文案会写明完整有效期:
【一次性密码】12345678
有效时段 20:30 - 21:30(约 15 分钟后开始生效)
⚠️ 时间不在半点/整点时(如
21:15),有效区间是21:00-22:00—— 仍然覆盖21:15,但不再是严格的"前后各 30 分钟"。想要严格对称就写整/半点。
其他约束:
- 最多提前
MILOCK_MAX_ADVANCE_HOURS(默认 24)小时;更远的会被拒绝; - 已经结束的时段会被跳过;一个可用时段都不剩则直接拒绝, 不会拿一个"还能用的码"敷衍你;
- 取多个窗口时任一步失败会回滚已占位的码,不会白占着不放;
- 指定时段的码同样受"一人一窗口一个"的限制(
MILOCK_MAX_CODES_PER_WINDOW), 且同一个码永不发给第二个人; - 未授权用户可以用
/密码 21:00申请,审批单上会写明"期望可用时间", 管理员批准后仍按该时段发码(不会因为拖了几分钟就变成批准那一刻的窗口); - 想关掉这个功能:
MILOCK_ALLOW_TIME_REQUEST=false。
权限规则:私聊里必须是管理员;群里必须是管理员且位于 MILOCK_ADMIN_GROUPS(超管不受群限制)。
6. 数据与审计
单文件 SQLite(默认 data/milock.sqlite3,WAL 模式):
users—— 谁被授权、谁批的、什么时候批的;requests—— 每一张申请单(含自动通过的),带窗口和密码序号;grants—— 一次性密码台账,(window_start, code_index)唯一,记录发给谁、状态。
因为台账落盘,重启机器人不会导致同一个密码被重复发放;
多个进程共享同一个 DB 文件时,BEGIN IMMEDIATE 事务同样能防止并发重复发放。
7. 开发与测试
本仓库是独立仓库(不再挂在 milock 主仓库的 contrib/ 下),测试需要 NoneBot 运行时:
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/python -m pytest -q # 全部用例
.venv/bin/ruff check . && .venv/bin/ruff format --check . # 使用本仓库的 ruff 配置
ruff 不在 dev 额外依赖里,需要的话单独 pip install ruff。
覆盖的关键场景:并发不重发、投递失败回收、配额、窗口滚动、审核与拒绝、
汇报同时到达管理员私聊与管理群、管理员私聊挂掉时的容错、超时作废。
其中 tests/test_plugin_load.py 会在子进程里真正跑一次 nonebot.load_plugin,
验证 matcher 注册、启动钩子与 SQLite 落盘。
8. 发布到 PyPI
用 Trusted Publishing(OIDC) 发布,仓库里不需要保存任何 PyPI Token。
工作流见 .github/workflows/publish.yml。
一次性配置(在 PyPI 网站上操作,我无法代做)
在本仓库的 PyPI 账号里添加 pending publisher(首次发布会自动建项目):
| 项目 | 填什么 |
|---|---|
| PyPI Project Name | nonebot-plugin-milock |
| Owner | parhelion-rev |
| Repository name | nonebot-plugin-milock |
| Workflow name | publish.yml |
| Environment name | pypi |
- 正式:https://pypi.org/manage/account/publishing/
- 演练:https://test.pypi.org/manage/account/publishing/ (Environment name 填
testpypi)
⚠️ 本仓库是私有仓库。Trusted Publishing 本身支持私有仓库,但
actions/checkout 在私有仓库下需要 contents: read 权限,否则构建 job 会以
Repository not found 失败——工作流里已经显式声明,别删。
发布流程
# 1) 先发 TestPyPI 演练(版本自动带后缀,可反复跑)
# Actions → Publish → Run workflow → target=testpypi → version_suffix=.dev1
# 验证安装:
pip install --index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ nonebot-plugin-milock
# 2) 确认无误后发正式:改好 pyproject.toml 的 version,打标签即可
git tag v0.1.0 && git push origin v0.1.0
打 v* 标签会自动校验「标签版本 == pyproject.toml 版本」,不一致直接失败——
这样能避免把错版本发出去。
两个设计约束(别改回去)
- 构建与发布拆成两个 job,发布 job 里绝不
checkout。官方明确反对在持有id-token的 job 中执行第三方构建代码(供应链风险);产物流经 artifact 传递。 - 版本号一旦上传就不可重用(PyPI 与 TestPyPI 都是),删除也不行,只能 yank。
所以演练要靠
version_suffix换版本号,而不是重发同一个版本。
Metadata
Release files for nonebot-plugin-milock 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| nonebot_plugin_milock-0.2.0.tar.gz | 77.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| nonebot_plugin_milock-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 131.3 kB
Release files / nonebot_plugin_milock-0.2.0.tar.gz
| Download URL | nonebot_plugin_milock-0.2.0.tar.gz |
|---|---|
| Size | 77.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d07a53b523c46f9ba1b8ff5cd71c951163b485b6a5fd105d6a16a1ba0ef5e0b3
|
|
BLAKE2b-256 checksum How to use checksums |
2c7f2a989d2416b469465ed78871476d4ba37d069dbb6487bed33ca86f558e52
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.
Transparency logRelease files / nonebot_plugin_milock-0.2.0-py3-none-any.whl
| Download URL | nonebot_plugin_milock-0.2.0-py3-none-any.whl |
|---|---|
| Size | 54.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c48eff9f39bbad851ba08fb9deaf4373228cea020813bb8af02ebeade07e4846
|
|
BLAKE2b-256 checksum How to use checksums |
92368d7a6348d4786cfa8a42ced982d527a0669ef4564bc1e5f3410e12b7fb07
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.
Transparency log