Metadata-Version: 2.4
Name: nonebot-plugin-milock
Version: 0.1.0
Summary: NoneBot2 插件：审核制申请 milock 一次性密码（单次发放、管理员可审计）
Author: milock contributors
License-Expression: GPL-3.0-or-later
Project-URL: Homepage, https://github.com/parhelion-rev/nonebot-plugin-milock
Project-URL: Repository, https://github.com/parhelion-rev/nonebot-plugin-milock
Project-URL: Issues, https://github.com/parhelion-rev/nonebot-plugin-milock/issues
Project-URL: Related: milock, https://github.com/parhelion-rev/mijia-lock-otp
Keywords: nonebot,nonebot2,onebot,milock,otp,one-time-password,smart-lock
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Plugins
Classifier: Intended Audience :: End Users/Desktop
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Chat
Classifier: Topic :: Home Automation
Classifier: Topic :: Utilities
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: nonebot2>=2.3.0
Requires-Dist: nonebot-adapter-onebot>=2.4.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: anyio>=4.0; extra == "dev"
Dynamic: license-file

# nonebot-plugin-milock

把 [milock](https://github.com/parhelion-rev/mijia-lock-otp) 的一次性密码接到 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` 等多处都写成：

```java
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`。

```bash
# 方式一：从 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` 里加载：

```python
nonebot.load_plugin("nonebot_plugin_milock")
# 或放进 plugins/ 目录后：nonebot.load_plugins("plugins")
```

## 4. 配置（写进 NoneBot 的 `.env`）

```dotenv
# ---- 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_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_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`——
否则申请没人看得见，插件会直接在启动时报错拒绝这种配置。

## 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 运行时：

```bash
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`](.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` 失败——工作流里已经显式声明，别删。

### 发布流程

```bash
# 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` 换版本号，而不是重发同一个版本。
