add # 新增Nonebot发布工作流

This commit is contained in:
2026-10-21 18:35:16 +08:00
parent c7d97e294a
commit 531409b8b6
26 changed files with 1585 additions and 19 deletions
+95
View File
@@ -0,0 +1,95 @@
name: NoneBot preview
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
publish:
description: Sync main branch in NoneBot repository
default: false
type: boolean
permissions:
contents: read
concurrency:
group: farm-preview-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python: ['3.10', '3.12']
env:
PYTHONPATH: src
PYTHONIOENCODING: utf-8
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python }}
- run: >-
python -m pip install "Pillow>=10.4,<13"
setuptools wheel "PyYAML>=6,<7" build
- run: python scripts/ci_font.py
- run: python scripts/check_workflows.py
- run: python scripts/verify_foundation.py
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: '3.10'
- run: python scripts/build_nonebot.py --output dist/nonebot-preview
- run: >-
python scripts/verify_nonebot.py
--directory dist/nonebot-preview/repository
- run: python -m pip install build
- name: Build wheel and sdist
working-directory: dist/nonebot-preview/repository
run: python -m build --sdist --wheel --outdir ../packages
- name: Verify installed NoneBot entry
run: |
python -m pip install dist/nonebot-preview/packages/*.whl
python -m pip install nonebot-adapter-onebot
python scripts/check_nonebot_runtime.py
- uses: actions/upload-artifact@v7
with:
name: nonebot-preview
path: dist/nonebot-preview/
include-hidden-files: true
if-no-files-found: error
retention-days: 14
sync-preview:
if: >-
(github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs.publish)) &&
github.ref == 'refs/heads/main' &&
github.repository == 'Shu-Ying/main_plugin_farm'
needs: [test, build]
runs-on: ubuntu-latest
# 沿用真寻环境,使 environment 中的 FARM_RELEASE_TOKEN 同样可用。
environment: zhenxun-preview
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- uses: actions/download-artifact@v8
with:
name: nonebot-preview
path: incoming
- name: Sync validated NoneBot repository
env:
FARM_RELEASE_TOKEN: ${{ secrets.FARM_RELEASE_TOKEN }}
run: >-
python scripts/publish_nonebot.py
incoming/nonebot_plugin_farm-*.zip --publish
+55
View File
@@ -0,0 +1,55 @@
# NoneBot 农场
从 `main_plugin_farm` 自动生成的普通 NoneBot 插件,与真寻版使用相同的
业务核心、命令和渲染资源,不依赖真寻或 zhenxun-utils。
![农场预览](images/farm-preview.png)
## 安装与配置
需要 Python 3.10 及以上。生成仓库中执行 `pip install .`,或安装经过
标签发布的 `nonebot_plugin_farm` 包,然后在 NoneBot 启动脚本中加入:
```python
nonebot.load_plugin("nonebot_plugin_farm")
```
可在 `.env` 中设置:
```dotenv
FARM_FONT=C:/Windows/Fonts/msyh.ttc
FARM_IMAGE_FORMAT=AUTO
FARM_DEPLOYMENT=nonebot
```
`FARM_DATA_DIR` 可显式指定数据目录,默认沿用旧版插件 localstore 下的
`nonebot_plugin_farm/farm_db` 子目录。旧 `farm.db` 保留,首次使用按当前
机器人和平台迁入 `farm.sqlite3`;遇到账号或平台冲突会拒绝覆盖。
日志写入该目录的 `farm-preview.log`。字体不可用时提供文字响应。
部署标识一旦使用,不应随意变更,否则会切换到不同的存档身份。
## 当前命令
开通农场、我的农场、农场详述、地块编号、我的农场币、我的点券、
我的种子、我的作物、种子商店、购买种子、播种、收获、铲除、
作物加锁、作物解锁、出售作物、农场除虫、除虫、偷菜、农场帮助。
发送「农场帮助」查看参数。图片与提及由 Alconna 统一消息适配。
范围与当前真寻生成版一致;旧版开垦、签到、改名、土地升级等命令
尚未在共享核心中实现,此版本不提供这些命令。
## 生成与发布
主仓库 `version.txt` 是版本的唯一来源,生成 ZIP、包版本和清单。
`nonebot-preview.yml` 在 PR 中测试和构建,在主仓库 `main` 推送后
使用 `FARM_RELEASE_TOKEN` 同步到 `Shu-Ying/nonebot_plugin_farm/main`。
同步任务沿用真寻的 `zhenxun-preview` environment,兼容其中已有密钥。
目标仓库保留 `v*` 标签发布 PyPI 的方式和 `PYPI_TOKEN` 密钥。
普通同步只校验、构建,不发布 PyPI。手动推送标签前应确认版本未在
PyPI 使用,且标签去掉 `v` 后与 `version.txt` 完全一致。
接入时远端旧版为 1.5.7,主仓库为 1.5.4;用户计划后续统一改为
2.0.0,本次未提前修改。统一后发布标签应为 `v2.0.0`。
生成仓库的 `verify_package.py --directory .` 校验文件完整性;
`python -m build` 生成 wheel/sdist。远端 CI 和实际机器人消息行为
需要在部署环境进一步验证。
+529
View File
@@ -0,0 +1,529 @@
# 节日活动交互、Rust 服务端与上线事务规划
记录日期:2026-10-21。
状态:设计草案,供产品选择与后续实施使用;本轮仅编写文档。
本文没有表示 Rust 服务、同步协议、活动表或活动命令已经实现。
所有默认值均为建议,尚未进行负载、断网及真实 QQ 群验证。
## 1. 现状依据与设计边界
- 用户交接:农场已有播种、收获、仓库与偷菜等基础功能。
151 项回归及后续 2 项相关测试的通过情况来自用户交接,本轮未重跑。
- 现有文档 `stealing.md` 描述 SQLite 事务、重放收据及版本 4;
`18-web-panel-and-data-directory.md` 描述本地 `farm.sqlite3`。
本轮阅读文档,没有据此宣称已核验当前源码及生产数据库。
- `AGENTS.md` 要求数据库保存权威状态,应用服务拥有事务边界,
框架对象止于接入层;不修改 legacy 参考基线。
- `03-business-rules.md` 的身份隔离、幂等及外部资产设计属于已有建议,
接入活动前仍需与实际身份模型、仓储接口及消息 ID 核对。
- 本轮开始时 `git status --short` 无输出;与此前未提交的交接状态不同,
不推断此前改动如何处理,也不恢复、覆盖或提交其他内容。
- Rust 用于新增服务端,不将现有 Python 农场整体重写为 Rust。
服务端建议独立仓库;本轮不创建服务工程或依赖占位文件。
## 2. 先决定服务端负责什么
| 方案 | Rust 服务负责 | 农场负责 | 主要代价 |
|---|---|---|---|
| A:活动发布中心,建议首期 | 配置、排期、发布、停用、同步状态 | 玩家进度、活动币、兑换、实际发奖 | 无全局共享余额;信任各部署 |
| B:集中活动账本 | A 的职责,加进度、活动币、限购与兑换单 | 上报事件、接收并入库奖励 | 网络依赖、事件防重、跨库交付与对账 |
| C:全部农场中心化 | 农场与活动全部权威业务 | QQ 指令与展示 | 数据迁移和可用性改造范围大 |
建议先做 A:上架活动不再要求发布插件,而玩家资产仍在原数据库事务内
变化。各机器人部署的活动资产独立,不宣称跨机器人共用,也不支持
全服限量兑换。若明确需要这两项能力,再采用 B。
信任边界:A 中部署管理员能够修改自己的 SQLite;服务端签名只能保护
发布配置,不能证明玩家行为真实。面向不受信任的第三方部署发放全服
稀缺奖励时,应重新评估 B 及事件可信来源,不能只接受客户端报来的数量。
## 3. 文字交互方案留档
### 3.1 五种可选方式
| 方案 | 示例 | 优点 | 限制 |
|---|---|---|---|
| 固定活动入口 | `农场活动`、`活动任务` | 学一次长期使用 | 多活动需明确目标 |
| 节日短名 | `万圣商店`、`端午任务` | 有主题感,便于并存 | 每期需展示入口 |
| 商品短编号 | `活动兑换 W1` | 不用输入复杂商品名 | 编号必须稳定 |
| 引用消息菜单 | 回复菜单发送 `2` | 新手不用背指令 | 需验证 QQ 引用消息能力 |
| 融入日常操作 | 收获额外掉落糖果 | 参与成本低 | 提示需节制,掉落需事务化 |
建议组合:固定入口 + 商品编号 + 日常玩法;节日短名作为快捷入口。
引用菜单为后续增强,不作为首期依赖,不监听群聊中的裸数字。
### 3.2 推荐命令
| 命令 | 行为 |
|---|---|
| `农场活动` | 列出可见活动、主活动进度和时间 |
| `活动任务` | 查看主活动任务 |
| `活动领奖` | 一次领取主活动全部已完成且未领取的任务奖励 |
| `活动商店` | 查看主活动商店、余额、限购 |
| `活动兑换 W1` | 兑换指定商品 1 份 |
| `活动兑换 W2 3` | 兑换指定商品 3 份 |
| `万圣商店` | 明确查看万圣活动 |
| `万圣兑换 W1` | 明确操作万圣活动 |
规则:
- 主活动由发布配置指定,同一部署同一时刻至多一个;不由玩家上次
查看菜单决定。无主活动且存在多个候选时,仅提示可复制的明确指令。
- 商品编号在展示范围内唯一并绑定某期活动;不能只用每页第几项。
例如本期为 `W26-1`,下一年不能把同一编号复用给另一件商品。
示例 `W1` 是简写演示,正式编号策略需在首期冻结。
- 通用兑换可按唯一商品编号直接定位活动,不必重复活动名。
- 批量兑换必须全部满足余额和限购才成功;不静默缩减数量。
- 失败说明原因及下一条可执行指令;查询不发奖,消息发送失败不重复交易。
- 进入补兑期时,菜单显示“掉落已结束,兑换截止:北京时间某日某时”。
- 活动菜单默认文字;普通日常操作只在确有掉落、完成任务等变化时
附带一行提示,不每次发送完整活动广告。
### 3.3 用户可见示例
```text
【万圣南瓜夜】
掉落截止:11 月 1 日 00:00(北京时间)
兑换截止:11 月 4 日 00:00(北京时间)
糖果:36;任务奖励:1 项可领取
收获作物有机会获得糖果。
查看任务:活动任务
领取奖励:活动领奖
查看商店:活动商店
W26-1 南瓜灯装饰 20 糖果 本期剩余 1
兑换:活动兑换 W26-1
```
上述日期和奖励仅为示例。没有实际可用装饰系统前,不上架装饰奖励;
首期可用已有种子、作物或内部货币,避免创建无效道具。
## 4. 农场如何知道活动上架了
### 4.1 推荐:主动拉取,通知只作加速
```mermaid
sequenceDiagram
participant O as 运营者
participant R as Rust 发布中心
participant D as 服务端数据库
participant F as 农场插件
participant L as 本地 SQLite
O->>R: 校验并发布活动
R->>D: 同一事务保存不可变版本和发布序号
D-->>R: 提交成功
F->>R: 启动或定期查询目录
R-->>F: 当前完整目录、版本和有效期
F->>F: 校验兼容性、范围及奖励能力
F->>L: 事务安装快照并切换生效指针
F->>R: 上报已应用序号或失败原因
Note over F,L: 实际开关由时间与状态判定,不依赖定时器准时触发
```
建议首期:启动同步一次,随后每 60 秒加随机抖动拉取;玩家访问活动
且快照过旧时触发一次共享刷新,避免每条消息各发一个请求。
网络请求不占用 SQLite 写事务,设置短超时并指数退避。
发布成功只表示服务端已提交;应分别展示“已发布、待客户端应用、
已应用、被拒绝、离线”。没有客户端确认时,不能显示全服上线成功。
### 4.2 目录响应与一致性
首期直接返回作用域内完整目录,规模小时比增量协议更容易正确处理删除。
目录至少覆盖预告、进行中、补兑和停用记录,不只返回正在进行的活动。
| 字段 | 实际用途 |
|---|---|
| `catalog_revision` | 单调递增的发布序号,拒绝乱序响应覆盖新状态 |
| `server_time` | 检查本机时钟偏差,辅助解释时间错误 |
| `valid_until` | 允许进行活动资产操作的快照授权截止时间 |
| `events` | 该部署当前可见的完整活动及不可变配置版本 |
| `revoked_instances` | 显式停用的活动实例,防止旧缓存继续启用 |
传输使用 HTTPS,部署身份由服务端凭据绑定,不能相信请求里的任意
`deployment_id`。支持 ETag/条件请求以降低开销,但 `304` 不自动延长
资产操作授权:有效期要通过可验证的新元数据或一次完整响应续期。
目录内容哈希用于检测损坏;若允许离线分发配置包,再加入签名与密钥轮换。
客户端先验证整个目录,再事务保存快照、撤销信息及生效序号;失败则
保留上个完整版本并上报错误,不先更新序号再下载失败。
目录为空表示确实没有活动,网络失败绝不能解释成空目录并清库。
离线展示可使用旧快照并标明更新时间;活动掉落、领奖和兑换必须同时
满足业务时间及授权有效期。建议首期授权最多 5 分钟,过期则暂停活动
资产操作,基础播种收获继续,且回复明确说明活动暂不可用。
这意味着断网跨过授权期限时不补发活动掉落;该取舍需在上线说明告知。
定时轮询加本地授权具有停用延迟:健康网络通常在一个轮询周期内,
断网时最多延续剩余授权时间,无法承诺瞬时全局停服。
若业务要求每笔兑换立即服从紧急停用,必须每笔在线授权或采用方案 B。
### 4.3 是否需要 WebSocket、SSE、Webhook
- 首期不需要长连接,轮询能穿过机器人所在机器的 NAT。
- 后期可加 SSE,通知仅携带“目录可能变化”,客户端仍重新拉取。
断线后恢复轮询,不把消息送达当作业务提交或客户端已应用。
- Webhook 需要插件暴露可访问入口,不作为默认接入方式。
- PostgreSQL `LISTEN/NOTIFY` 可辅助唤醒服务内部工作者;它不是面向
离线机器人的持久同步协议,发布记录仍必须入表。
PostgreSQL 官方说明通知面向已监听连接,事务中的通知在提交后交付;
因此本设计将数据库记录作为依据,通知只作提示。
来源:[PostgreSQL NOTIFY](https://www.postgresql.org/docs/current/sql-notify.html)。
## 5. 活动生命周期与时间
区分运营状态和按时间计算的玩家阶段,不存储互相矛盾的重复状态。
运营状态:草稿、已发布、暂停、撤销、归档。
玩家阶段由已发布状态和时间计算:
| 阶段 | 时间范围 | 允许行为 |
|---|---|---|
| 预告 | `visible_at <= now < starts_at` | 查看规则,不计进度、不领奖 |
| 进行中 | `starts_at <= now < ends_at` | 计进度、掉落、领奖和兑换 |
| 补兑期 | `ends_at <= now < redeem_until` | 只领取此前完成任务和兑换 |
| 已结束 | `now >= redeem_until` | 只查历史,不新建奖励交易 |
时间均存 UTC,文案按 Asia/Shanghai 展示;每日任务与限购按北京时间
日期分桶。采用左闭右开区间,恰好等于截止时间即不再接受新操作。
暂停:先统一暂停活动资产写入,保留查询;必要时恢复。
撤销:永久停止本期新操作,进入问题处理,不删除账本。
已经提交的奖励不因撤销自动收回;补偿与追缴必须形成独立审计操作。
计划排期预先下发到插件,本地在时间边界自然切换;定时工作者迟到不会
让活动多开放一天。重启后按当前时间重算,不依赖内存倒计时。
检测到明显时钟异常时停止活动资产操作并告警,不用旧授权无限续命。
## 6. Rust 服务端最小组成
建议一个服务进程加一个 PostgreSQL,先不引入 Redis、消息中间件或微服务。
| 模块 | 职责 |
|---|---|
| 管理 API | 草稿、校验、发布、暂停、撤销、查询发布情况 |
| 配置校验 | 时间、整数范围、商品编号、命令别名、规则与奖励能力 |
| 发布应用服务 | 比较预期版本、事务提交、生成发布审计 |
| 同步 API | 按部署返回目录、授予短期有效期、接收应用结果 |
| 后台工作者 | 扫描待处理维护任务、归档、清理和失败重试 |
| 存储与日志 | SQL 事务、迁移、备份、结构化文件日志和指标 |
技术候选为 Tokio + Axum + SQLx + PostgreSQL;实现时再锁定版本和最低
Rust 版本。Axum 用于 HTTP 路由与请求处理,SQLx 用于数据库事务;
库的回滚能力不能替代应用自行设计幂等和事务边界。
参考:[Axum](https://docs.rs/axum/latest/axum/)、
[SQLx Transaction](https://docs.rs/sqlx/latest/sqlx/struct.Transaction.html)。
首期允许使用管理 CLI 调用同一管理服务,不必先建设运营网页。
活动配置只允许受支持的声明式规则,不下发 Python/Rust 脚本、SQL、
模板执行表达式或可执行插件。不支持的新玩法需先升级农场能力。
## 7. 数据设计:每个概念必须参与业务
### 7.1 服务端发布数据(方案 A)
| 概念 | 必要内容及用途 |
|---|---|
| 活动实例 | `instance_id` 标识某年某期;名称、别名用于展示与解析 |
| 配置版本 | `revision`、规则、商品、时间、能力要求、哈希;发布后不可变 |
| 发布记录 | 当前版本、运营状态、目标部署、主活动标记、发布序号 |
| 部署应用结果 | 部署标识、已应用序号、能力集合、最近在线时间、失败码 |
| 操作审计 | 管理员、操作、前后版本、原因、时间;支持故障追溯 |
同一节日每年创建新实例,不修改去年实例来重复开服。
发布时检测同一作用域的主活动时间重叠、别名冲突和商品短编号冲突。
目标名单变更也增加发布序号;被移出范围的客户端必须收到撤销或完整
目录移除语义,不能继续使用无限期缓存。
### 7.2 农场本地数据(方案 A)
| 概念 | 必要内容及用途 |
|---|---|
| 活动快照 | 实例、版本、授权截止、配置正文;重启可恢复且可追溯 |
| 活动账户 | 玩家作用域、实例、余额;防止跨期花币 |
| 任务进度 | 玩家、实例、任务、日期桶、进度;永久任务不用日期桶 |
| 奖励领取记录 | 玩家、实例、任务、日期桶;唯一约束阻止重复领奖 |
| 兑换累计 | 玩家、实例、商品、限购周期桶、已兑换数量;事务限购 |
| 活动账本与收据 | 操作标识、请求摘要、版本、增减额、奖励结果、时间 |
| 日常行为去重 | 原业务操作与活动、规则的关联;避免重放重复掉落 |
玩家键复用实际农场身份范围,不只保存一个 QQ 号;实施时核对机器人、
平台与共享范围。余额和库存只存整数,明确上限并校验乘法溢出。
若已有账本和操作收据能表达这些用途,就扩展复用,不平行创建重复表。
不为首期添加无业务用途的排行榜、跨服积分、全服库存、邮件状态等字段。
快照版本用于解释交易,不能靠“当前最新配置”重算历史收据。
## 8. 发布与版本切换事务
### 8.1 上架步骤
1. 运营创建草稿,设定活动期、兑换期、范围、任务和奖励。
2. 校验时间关系、已有物品 ID、计量单位、上限、编号和能力要求。
3. 在测试部署预演规则,确认菜单中每条指令可执行。
4. 管理请求携带幂等键和预期版本;同键不同参数拒绝。
5. 在一个服务端事务内生成不可变配置版本、更新发布指针和目录序号、
写入操作审计;失败整体回滚。
6. 提交后才响应发布成功。若以后加入可靠推送任务,其待发送记录也在
同一事务写入;不要数据库成功后仅在内存里安排推送。
7. 插件获取目录并校验,在本地事务中安装版本;上报已应用序号。
8. 观察目标部署应用率、错误和时间,再扩大范围。
管理并发修改采用版本条件更新;两名管理员基于同一旧版本发布,至多
一个成功,另一个重新读取后操作。不能用最后写入覆盖来消除冲突。
### 8.2 热更新限制
- 文案可以新版本发布;已接受交易始终按其绑定版本结算。
- 活动币归属、任务语义和商品身份不在进行中重新定义。
- 首期建议活动开始后冻结价格、奖励、限购及掉落规则,避免旧快照
在授权期内继续成交造成不公平;调整通过暂停、等待旧授权耗尽、
发新版本、确认部署应用后恢复完成。
- 严重错误通过新发布序号回退到已知配置,不降低序号,不删除已发生交易。
- 本地交易对快照生效指针和授权有效期在写事务中检查;与同步切换
串行化,不能在事务外读到旧配置后无限期继续写资产。
## 9. 方案 A:本地活动交易
### 9.1 收获掉落和任务进度
一次收获事务中:
1. 查原操作收据;已处理则返回原结果,不再次抽奖。
2. 读取活动快照,检查时间、范围、能力和授权;判定时刻在业务锁取得后
采样,不能沿用排队前的旧时间。
3. 完成原有收获计算与状态更新。
4. 对符合条件的本次实际收获计算活动掉落,更新余额及任务进度。
5. 写入行为去重、活动流水及原操作完整收据,一起提交。
活动停用或授权失效属于正常“不参与活动”,不阻塞基础收获;活动
数据库写失败属于事务失败,不能吞掉异常后提交一半结果。
随机结果记录在已提交收据中。若事务回滚,重新计算不产生已到账的重复奖。
不默认让偷来的作物也触发收获掉落,以免改变现有偷菜事务和经济平衡;
如要支持,单独定义触发类型、计量方式并验证。
### 9.2 任务领奖
在一个事务内检查阶段、已完成进度和领取唯一约束,写领取记录、增加
奖励、写流水和收据,再提交。全部领取以事务内实际可领取集合为准,
并发请求最多领取一次。只查看任务不标记领取。
### 9.3 商店兑换
```text
BEGIN
查幂等键与请求摘要,已成功则返回原收据
校验快照、授权、时间、商品与正整数数量
条件扣活动币:余额 >= 总价
条件更新限购累计:已购 + 本次 <= 上限
向现有种子/作物/内部货币仓储增加奖励
写活动流水和兑换收据
COMMIT
提交后回复 QQ 消息
```
余额不足、限购失败或入库失败时整体回滚,不能只扣币。
任何底层仓储方法不能自行提交。网络与绘图不放进事务。
请求幂等键包含部署、机器人、平台、消息标识与动作命名空间;同键不同
参数拒绝。没有可靠消息 ID 时,需要持久化操作令牌,不能用每次新 UUID。
超时未知结果先查收据,不推断失败后再次扣款;已成功收据的查询不受
活动截止限制。永久奖励留在正常仓库,不能随活动账户清理一并删除。
首期不直接兑换真寻外部金币,以免重新引入跨库原子性问题。
## 10. 方案 B:若以后集中管理活动币
只有需要跨部署共用余额或全服库存时再实施;不能和 A 同时保留两个
可写活动余额。迁移必须暂停入口、结清待办、核对余额并切换唯一权威源。
### 10.1 日常事件可靠上报
- 农场在原收获事务中写业务结果和待上报事件(outbox),提交后发送。
- 服务端按部署和事件 ID 去重;验证已登记部署、规则、玩家范围和时间。
- 服务端同一事务写接收记录、更新进度/活动币、写账本。
- 客户端仅在收到可查询的成功结果后确认事件已交付;超时重试同 ID。
- 明确迟到事件政策:建议最多接受截止前已发生且在宽限期内收到的事件;
事件时间只能来自受信部署,越过宽限期进入核查,不悄悄改变历史结算。
- 未到账进度显示“待同步”,不能拿尚未服务端确认的余额兑换。
### 10.2 中心扣币、农场入库不是一个事务
1. 服务端在一个事务内校验余额与限购,扣币、创建唯一奖励单和交付任务。
2. 农场轮询领取属于本部署的待交付奖励单,或用兑换响应取得该单。
3. 农场在一个本地事务内按 `grant_id` 去重、奖励入库、写入库收据。
4. 农场提交后确认,服务端把奖励单标记已交付。
5. 确认丢失则重发同一奖励单,农场返回已有收据,不二次入库。
玩家看到“兑换已受理,奖励待到账”,到账后才报告已入库。
这里保证的是重试与去重后业务效果一次,不承诺网络只传一次。
待交付任务以数据库为准,多工作者用数据库锁或租约认领并恢复超时任务。
**超时不能自动退款。** 本地可能已经入库,只是确认丢失。
退款需先通过本地持久化终态证明奖励未发放,并将该单永久标记为取消、
拒绝后续交付;无法建立此屏障则保持待核对,不同时退款和重发。
活动结束后仍交付截止前已受理奖励,不能清除尚未确认的交付单。
## 11. 如何清理过期活动
“不再展示”“停止写入”“兑换结束”“数据删除”是四个不同动作。
**建议自动关闭、延后归档、受控删除,而非到点整表删除。**
### 11.1 分阶段策略
| 时刻/条件 | 动作 | 保留内容 |
|---|---|---|
| 到掉落截止 | 停止新掉落和任务计数,进入补兑 | 余额、领奖资格、商店、流水 |
| 到兑换截止 | 停止新领奖和兑换,移出常用菜单 | 历史收据、旧配置、待核对操作 |
| 在途事务排空后 | 生成结算摘要;将剩余活动币记为过期 | 过期流水、总额、审计依据 |
| 建议结束后 30 天 | 归档活动,历史只读 | 配置与关键资产账本 |
| 满足保留策略后 | 分批删除可重建缓存和不再需要的明细 | 防重依据、待办、永久奖励 |
首期建议剩余活动币过期失效,不自动换基础币;这属于待确认的产品规则,
必须在活动开始前公示。若以后支持自动折算,按玩家生成唯一结算操作,
折算发奖和余额消耗同事务完成,不能靠重新扫描重复发放。
### 11.2 并发与崩溃恢复
- 截止判定在交易里执行,不靠清理任务更新一个布尔值后才停止。
- 对同一活动账户的兑换与过期结算串行化;取得锁后再次检查阶段。
截止前已受理的事务可以完成,清理等待它们完成,不抢先清余额。
- 维护任务保存活动、阶段、游标、重试次数、最近错误;这些字段用于
断点续跑和告警。多实例服务认领任务时使用数据库锁/租约。
- 每批小事务提交,不用一个长事务扫描所有玩家。
- 每个账户的过期结算有唯一操作标识;崩溃重跑返回既有结果。
- B 方案须等迟到事件窗口关闭、待交付奖励和退款核对完成后再结算;
不能因已到日历上的归档日期就删除尚未解决的数据。
### 11.3 可以删什么,不能删什么
可以优先清理:旧菜单缓存、可重建展示快照、超过排障窗口的同步诊断。
未发布草稿可以按管理保留策略删除。
不能直接删:永久种子/作物/货币、未交付奖励、待对账交易、有效幂等依据、
被流水引用的规则版本。旧物品若已入仓,正常目录仍要能解释其 ID;
活动下架不能删除仍在使用的物品定义。
关键账本建议先按至少 180 天规划,再根据体量、隐私和运维需求确认;
该天数不是法律要求或既有承诺。幂等记录保留时间必须覆盖消息重放和
任务重试窗口。无法给重放窗口设上限时,保留紧凑的防重摘要,或永久
封存实例并拒绝它的新事件,不能只靠“清过了”防止重复发奖。
首期不开放活动硬删除接口。将来删除需提供预览数量、备份、引用检查、
操作审计及恢复演练。清理工作者无管理权限去删除整个农场数据库。
## 12. API 草案(首期 A)
以下是接口职责,不是已实现路由;具体 JSON 与错误码在开发设计中冻结。
| 接口 | 作用及事务要求 |
|---|---|
| `POST /admin/events` | 创建草稿 |
| `POST /admin/events/{id}/validate` | 校验草稿及目标能力,不上架 |
| `POST /admin/events/{id}/publish` | 幂等发布;预期版本冲突明确返回 |
| `POST /admin/events/{id}/pause` | 暂停并增加目录序号 |
| `POST /admin/events/{id}/revoke` | 撤销并保留审计与历史 |
| `GET /admin/events/{id}/rollout` | 查询目标部署的应用情况 |
| `GET /v1/event-catalog` | 按已认证部署取得完整目录 |
| `POST /v1/catalog-ack` | 上报应用序号、能力和失败原因 |
管理身份与插件身份分离,插件不能发布活动;限流、请求体大小和操作
数量有上限。客户端上报的版本确认只证明安装,不证明玩家奖励已正确到账。
A 不开放远程“给玩家改余额”接口,避免产生第二个资产写入口。
## 13. 上线与运营事务清单
### 13.1 首次部署 Rust 服务
1. 确定域名、HTTPS、访问凭据、目标部署范围和备份位置。
2. 建立数据库迁移及恢复流程,演练空库启动和失败回滚。
3. 先部署兼容旧插件的服务,新服务不要求旧插件立即支持活动。
4. 再发布带活动能力的插件;默认活动入口无可用配置时正常提示。
5. 校验服务身份、目录拉取、授权到期和撤销机制。
6. 在测试群演练完整活动生命周期,不能只检查管理 API 返回成功。
### 13.2 每次活动上架
- 先创建实例和草稿,核对时间、奖励可用性、经济产出和指令冲突。
- 预告阶段验证目标部署已应用;能力不足的部署显示未覆盖,不强行启用。
- 建议先一个测试部署,再小范围,再全量;发布范围由服务端确定。
- 观察活动指令失败率、同步滞后、兑换回滚、掉落总量和余额异常。
- 正式开始前备份;上线后抽查实际玩家操作、账本与库存结果一致。
- 本设计首期只在用户查询和正常操作中提示,不默认向所有 QQ 群群发公告。
### 13.3 日常巡检与告警
结构化文件日志记录:操作关联 ID、实例与版本、部署匿名标识、耗时、
阶段、错误码;不记录凭据、完整 QQ 事件或真实玩家信息。
业务账本可保存关联所需玩家键,但按最小权限访问,不能代替普通日志脱敏。
关键告警:目录同步超过授权窗口、客户端版本拒绝、兑换事务失败、
结算任务积压、重复事件激增、时钟异常;B 额外监控未交付奖励和对账单。
服务就绪检查涵盖数据库可用及迁移兼容,不只检查进程是否存在。
### 13.4 故障与回滚
| 故障 | 处理 |
|---|---|
| Rust 服务不可用 | 基础农场继续;授权过期后暂停活动资产操作 |
| 目录配置无效 | 拒绝整份更新,保留旧快照直至授权到期,上报原因 |
| 插件不识别玩法或奖励 | 标记该部署未应用,不加载一半规则 |
| 错价或异常产出 | 暂停,等待旧授权耗尽,核账,再发布修正版 |
| QQ 回复失败 | 查原收据再回复,不能重复扣币或发奖 |
| 清理任务中断 | 从游标与唯一结算记录恢复,不整表重置 |
| 新插件代码故障 | 停活动再回退兼容代码;不直接还原旧资产库 |
数据库备份恢复会让已发生交易的防重记录倒退,必须停写并对账,不能
把恢复数据库当作无损的代码回滚。迁移先做向后兼容的增量变更,确认
旧版本不能安全使用新结构时禁止直接降级运行。
## 14. 实施顺序与验收
| 阶段 | 实施范围 | 完成证据 |
|---|---|---|
| 1 | 冻结首期玩法、身份范围、配置协议、到期政策 | 规格与示例配置评审 |
| 2 | Rust 管理、校验、发布及完整目录 | 发布并发、回滚、认证测试 |
| 3 | 农场快照同步与生命周期,尚不发资产 | 重启、乱序、断网、撤销验证 |
| 4 | 本地掉落、领奖、兑换与账本 | 并发、重放、事务失败验证 |
| 5 | 过期结算、归档、运维指标 | 截止竞争、断点续跑、恢复演练 |
| 6 | 测试群及灰度活动 | 实际菜单、掉落、兑换、到账核对 |
必须覆盖的异常场景:
- 开始和结束的精确边界、北京时间零点、跨日限购、重启跨越截止时间。
- 同一消息并发重放、同一任务并发领奖、最后一份限购并发兑换。
- 扣币后入库失败、提交后回复失败、数据库提交结果未知。
- 发布后客户端掉线、旧授权未到期时紧急停用、乱序和空目录响应。
- 有效期到期时 `304`、下载失败、奖励 ID 不存在、旧插件能力不足。
- 清理过程中仍有交易、清理中断重启、历史收据重放、永久道具保留。
- 若选 B:事件迟到与重复、确认丢失、重复交付、退款与发货竞争。
这些为后续验收要求,本轮未新增测试文件或执行业务回归。
## 15. 建议默认值与待确认选择
| 决策 | 建议首期默认 | 何时需要调整 |
|---|---|---|
| 资产归属 | A:本地活动账户 | 跨部署共用余额、全服限量时选 B |
| 接入方式 | 60 秒轮询 + 抖动 | 对上线响应有更高要求时加 SSE |
| 离线授权 | 最长 5 分钟 | 需即时停用时逐笔在线授权 |
| 交互 | 固定入口 + 稳定编号 + 日常掉落 | 引用菜单能力验证后增强 |
| 首期玩法 | 收获掉落、累计任务、兑换已有物品 | 新玩法先发布客户端能力 |
| 价格与奖励 | 活动开始后冻结 | 先暂停并排空旧授权后改版 |
| 补兑期 | 掉落结束后再留 3 天 | 各期上架前确定并展示 |
| 剩余活动币 | 到期失效,不自动折算 | 如要折算需独立结算规格 |
| 到期处理 | 自动停止、延后归档,不硬删 | 运营保留策略确定后再实现删除 |
| 通知范围 | 用户主动查询与实际操作提示 | 群发需另行确定渠道与授权 |
以上建议未因生成本文而自动成为已批准的实现需求。
实施前最关键的选择是:是否确实需要“跨机器人共享活动资产”。
它决定采用本地事务即可完成的 A,还是需要跨库交付与对账的 B。
## 16. 本轮交付范围
- 新增本文及文档索引入口;未修改业务代码、legacy、版本文件或数据库。
- 阅读现有项目约定与相关设计文档,未进行结构性源码调用链调查。
- 外部资料仅用于核对 Rust 库职责和 PostgreSQL 通知语义;架构取舍为本文建议。
- 未实现或部署 Rust 服务、未重启机器人、未执行真实 QQ 群验证。
+1
View File
@@ -5,6 +5,7 @@
| 文档 | 用途 | | 文档 | 用途 |
|---|---| |---|---|
| [23-seasonal-events-and-rust-service.md](23-seasonal-events-and-rust-service.md) | 节日活动交互备选、Rust 发布中心、同步、资产事务、过期清理与上线规划(草案) |
| [09-foundation.md](09-foundation.md) | 已实现的第一批核心、API、测试与限制 | | [09-foundation.md](09-foundation.md) | 已实现的第一批核心、API、测试与限制 |
| [10-rendering.md](10-rendering.md) | 共享图片工具、农场绘制、缓存、编码与接入 | | [10-rendering.md](10-rendering.md) | 共享图片工具、农场绘制、缓存、编码与接入 |
| [11-zhenxun-preview.md](11-zhenxun-preview.md) | 真寻原指令预览、构建/校验/安装和可选同步 | | [11-zhenxun-preview.md](11-zhenxun-preview.md) | 真寻原指令预览、构建/校验/安装和可选同步 |
+8 -4
View File
@@ -1,6 +1,10 @@
# 普通 NoneBot 接入预留位置 # 普通 NoneBot 接入
发布目标:https://github.com/Shu-Ying/nonebot_plugin_farm.git 发布目标:https://github.com/Shu-Ying/nonebot_plugin_farm.git
负责 NoneBot 命令、会话、消息、配置与启停,不导入真寻。 本目录负责命令、统一会话、消息、配置与启停,不导入真寻。
发布仓库保留 Python 包构建布局;PyPI 发布流程待实施。 运行时在打包时复用 `integrations/zhenxun/runtime.py`,核心和渲染模块
本目录尚不可安装。远程仓库本轮不再拉取或推送。 按白名单私有化。此源码目录不能直接加载,需先构建生成仓库。
`python scripts/build_nonebot.py --output dist/nonebot-preview` 生成标准
包布局与 ZIP;进入输出的 `repository` 可运行 `python -m build`。
配置、命令范围、数据迁移和发布说明见 `docs/12-nonebot.md`。
+24
View File
@@ -0,0 +1,24 @@
"""普通 NoneBot 农场插件入口。"""
from nonebot import require
from nonebot.plugin import PluginMetadata, inherit_supported_adapters
require("nonebot_plugin_alconna")
require("nonebot_plugin_uninfo")
require("nonebot_plugin_localstore")
from ._version import __version__
from .config import Config
from .runtime import HELP
__plugin_meta__ = PluginMetadata(
name="农场", description="快乐的农场时光", usage=HELP,
type="application", config=Config,
homepage="https://github.com/Shu-Ying/nonebot_plugin_farm",
supported_adapters=inherit_supported_adapters(
"nonebot_plugin_alconna", "nonebot_plugin_uninfo",
),
extra={"author": "Shu-Ying", "version": __version__},
)
from . import commands
+66
View File
@@ -0,0 +1,66 @@
"""NoneBot 命令、统一会话和消息;业务由生成的共享运行时处理。"""
import logging
from nonebot import get_driver, get_plugin_config, on_command
from nonebot.adapters import Bot, Event, Message
from nonebot.params import CommandArg
from nonebot_plugin_alconna import At, UniMessage
from nonebot_plugin_alconna.uniseg import get_message_id
import nonebot_plugin_localstore as store
from nonebot_plugin_uninfo import Uninfo
from .config import Config
from .data_paths import data_directory
from .runtime import COMMANDS, Runtime
logger = logging.getLogger(__name__)
config = get_plugin_config(Config)
driver = get_driver()
runtime = Runtime(
data_directory(store.get_plugin_data_dir(), config.farm_data_dir),
config.farm_font, config.farm_deployment, config.farm_image_format,
font_setting="FARM_FONT",
)
driver.on_startup(runtime.start)
driver.on_shutdown(runtime.close)
def handler_for(action):
async def handle(
bot: Bot, event: Event, session: Uninfo,
args: Message = CommandArg(),
):
try:
message_id = get_message_id(event, bot)
except Exception:
message_id = None
try:
target_user_id = None
if action == "steal":
mentions = UniMessage.of(args, bot=bot)[At]
if len(mentions) != 1 or mentions[0].flag != "user":
await UniMessage("请只@一个具体偷菜目标。").send(
event, bot, reply_to=bool(message_id),
)
return
target_user_id = mentions[0].target
response = await runtime.execute(
action, args.extract_plain_text(),
bot_id=str(bot.self_id), platform=bot.adapter.get_name(),
user_id=str(session.user.id), message_id=message_id,
display_name=session.user.name or "农场玩家",
target_user_id=target_user_id,
)
except Exception as error:
logger.error("farm request failed type=%s", type(error).__name__)
response = "农场服务异常,请查看服务器日志。"
message = (UniMessage.image(raw=response)
if isinstance(response, bytes) else UniMessage(response))
await message.send(event, bot, reply_to=bool(message_id))
return handle
for command, action in COMMANDS.items():
matcher = on_command(command, priority=5, block=True)
matcher.handle()(handler_for(action))
+23
View File
@@ -0,0 +1,23 @@
"""普通 NoneBot 配置;不依赖真寻配置系统。"""
from pathlib import Path
from typing import Literal, Optional
from pydantic import BaseModel, Field
class Config(BaseModel):
"""农场启动配置。"""
farm_data_dir: Optional[Path] = Field(
None, description="数据目录;默认兼容旧版 localstore 农场目录",
)
farm_font: Optional[Path] = Field(
None, description="中文字体路径;不可用时退回文字响应",
)
farm_deployment: str = Field(
"nonebot", min_length=1, description="农场存档部署标识",
)
farm_image_format: Literal["AUTO", "JPEG", "PNG"] = Field(
"AUTO", description="图片格式,AUTO 优先使用压缩 JPEG",
)
+9
View File
@@ -0,0 +1,9 @@
"""兼容旧版 NoneBot localstore 存档位置。"""
from pathlib import Path
def data_directory(base, configured=None):
if configured is not None:
return Path(configured)
return Path(base) / "nonebot_plugin_farm" / "farm_db"
+5 -3
View File
@@ -1,4 +1,4 @@
"""真寻接入编排;核心私有导入,测试数据与旧版隔离。""" """真寻与 NoneBot 共用的运行编排;不导入宿主框架。"""
import hashlib import hashlib
from dataclasses import replace from dataclasses import replace
@@ -87,9 +87,11 @@ def operation_id(bot_id, platform, user_id, message_id, action):
class Runtime: class Runtime:
def __init__(self, data_dir, font_path=None, deployment="zhenxun-preview", def __init__(self, data_dir, font_path=None, deployment="zhenxun-preview",
image_format="AUTO", *, font_dir=None): image_format="AUTO", *, font_dir=None,
font_setting="FARM_PREVIEW_FONT"):
self.data_dir = Path(data_dir).resolve() self.data_dir = Path(data_dir).resolve()
self.font_path = font_path self.font_path = font_path
self.font_setting = font_setting
self.font_dir = Path(font_dir or "resources/font") self.font_dir = Path(font_dir or "resources/font")
self.deployment = deployment self.deployment = deployment
self.image_format = image_format self.image_format = image_format
@@ -273,7 +275,7 @@ class Runtime:
if not self.rendering: if not self.rendering:
if action == "plot_ids": if action == "plot_ids":
return plot_ids_text(await self.service.snapshot(who)) return plot_ids_text(await self.service.snapshot(who))
return "请配置 FARM_PREVIEW_FONT 为可用中文字体路径。" return f"请配置 {self.font_setting} 为可用中文字体路径。"
now = self.service.clock() now = self.service.clock()
theme = season_for(now) theme = season_for(now)
logger.info("farm preview seasonal theme=%s", theme) logger.info("farm preview seasonal theme=%s", theme)
+11 -7
View File
@@ -1,11 +1,15 @@
# 发布设计预留位置 # 发布生成配置
targets.json 只记录用户提供的仓库与目标名称;enabled=false。 主仓库是唯一业务开发源。真寻与 NoneBot 已有离线打包、清单验证和
没有可执行同步脚本、Actions、远程写凭据或发布授权。 固定目标同步脚本;AstrBot 仍为设计预留,未启用。
主仓库构建的每个产物只含同版本核心、当前框架接入、所需资源和依赖。 主仓库构建的每个产物只含同版本核心、当前框架接入、所需资源和依赖。
首次接管三个现有仓库需先比较已有内容,通过候选 PR 审查, 真寻使用插件目录布局,NoneBot 使用标准 Python 包布局。
不得直接清空目标仓库或强推。 同步脚本默认 dry-run;带 `--publish` 才使用 `FARM_RELEASE_TOKEN`
向固定仓库 `main` 普通提交完整生成树,保留历史并检查分支并发变更。
生成树会替换目标分支的当前文件集合,首次同步前应审查输出内容。
用户已要求停止拉取并删除本地克隆,当前不得重新检出这些仓库。 NoneBot 既有 `publish.yaml` 已从远端读取,生成模板继续使用 `v*`
完整规则见 docs/04-release-design.md。 标签触发及 `PYPI_TOKEN`。主仓库的同步任务沿用真寻 environment
`zhenxun-preview` 和 `FARM_RELEASE_TOKEN`,普通同步不会发布 PyPI。
本次没有执行远端推送、分支同步或 PyPI 发布。
+3
View File
@@ -0,0 +1,3 @@
include version.txt LICENSE README.md FARM-MANIFEST.json verify_package.py
include images/farm-preview.png
recursive-include nonebot_plugin_farm *.py *.json *.zip *.md
+53
View File
@@ -0,0 +1,53 @@
name: 🚀 发布到 PyPI
on:
push:
tags: ['v*']
permissions:
contents: read
concurrency:
group: nonebot-pypi-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: '3.10'
- name: Verify generated files
run: python verify_package.py --directory .
- name: Check tag matches package version
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
python - <<'PY'
import os
from pathlib import Path
version = Path('version.txt').read_text().strip()
if os.environ['RELEASE_TAG'] != 'v' + version:
raise SystemExit('Tag differs from version.txt')
PY
- run: python -m pip install build twine
- run: python -m build --sdist --wheel --outdir ../packages
- run: python -m twine check ../packages/*
- uses: actions/upload-artifact@v7
with:
name: pypi-distributions
path: ../packages/*
if-no-files-found: error
publish:
needs: build
if: github.repository == 'Shu-Ying/nonebot_plugin_farm'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
name: pypi-distributions
path: dist
- name: 📤 发布到 PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_TOKEN }}
+35
View File
@@ -0,0 +1,35 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "nonebot_plugin_farm"
dynamic = ["version"]
description = "快乐的农场时光"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "GPL-3.0-or-later"}
authors = [{name = "Art_Sakura"}, {name = "Shu-Ying"}]
dependencies = [
"nonebot2>=2.4,<3",
"nonebot-plugin-alconna>=0.60,<1",
"nonebot-plugin-uninfo>=0.6,<1",
"nonebot-plugin-localstore>=0.7,<1",
"Pillow>=10.4,<13",
]
[project.urls]
Repository = "https://github.com/Shu-Ying/nonebot_plugin_farm"
[tool.setuptools.dynamic]
version = {file = "version.txt"}
[tool.setuptools.packages.find]
where = ["."]
include = ["nonebot_plugin_farm*"]
[tool.setuptools.package-data]
"nonebot_plugin_farm._vendor.farm_core" = ["data/*.json"]
"nonebot_plugin_farm._vendor.farm_render" = [
"data/*.json", "data/*.zip", "data/*.md",
]
+26
View File
@@ -0,0 +1,26 @@
name: Verify generated NoneBot farm
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: '3.10'
- run: python verify_package.py --directory .
- run: python -m pip install build
- run: python -m build --sdist --wheel --outdir ../packages
- uses: actions/upload-artifact@v7
with:
name: nonebot-distributions
path: ../packages/*
if-no-files-found: error
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"schema": "farm.release-targets.v1", "schema": "farm.release-targets.v1",
"status": "zhenxun-preview", "status": "zhenxun-and-nonebot-preview",
"source_repository": "https://github.com/Shu-Ying/main_plugin_farm.git", "source_repository": "https://github.com/Shu-Ying/main_plugin_farm.git",
"network_actions_enabled": false, "network_actions_enabled": false,
"targets": [ "targets": [
@@ -25,7 +25,9 @@
"repository": "https://github.com/Shu-Ying/nonebot_plugin_farm.git", "repository": "https://github.com/Shu-Ying/nonebot_plugin_farm.git",
"observed_default_branch": "main", "observed_default_branch": "main",
"entry_directory": "integrations/nonebot", "entry_directory": "integrations/nonebot",
"enabled": false "enabled": true,
"preview_branch": "main",
"publish_by_default": false
} }
] ]
} }
+7 -1
View File
@@ -5,4 +5,10 @@ check_baseline.py 为已实现的离线校验工具。
检查清单哈希、缺失/新增文件、Python 3.10 语法、JSON 与静态作物库, 检查清单哈希、缺失/新增文件、Python 3.10 语法、JSON 与静态作物库,
不导入旧插件、不联网、不读真实玩家库。 不导入旧插件、不联网、不读真实玩家库。
迁移、分包和远程同步工具尚未实施,不提供会覆盖仓库的占位脚本。 `build_zhenxun.py` / `build_nonebot.py` 从白名单生成对应框架仓库和 ZIP。
`verify_zhenxun.py` / `verify_nonebot.py` 用标准库校验生成清单与文件。
`publish_zhenxun.py` / `publish_nonebot.py` 默认离线报告,显式
`--publish` 才读取 `FARM_RELEASE_TOKEN` 并同步固定目标仓库。
`check_nonebot_runtime.py` 在临时数据目录检查已安装的 NoneBot 插件;
也可用 `--repository` 指定生成仓库,需要 NoneBot 依赖和 OneBot 适配器。
`check_workflows.py` 检查源工作流、生成工作流及发布权限边界。
+103
View File
@@ -0,0 +1,103 @@
"""从主仓库白名单生成标准 NoneBot 包仓库及可复现 ZIP。"""
import argparse
import ast
import hashlib
import json
from pathlib import Path
import re
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
from build_zhenxun import read_version, source_identity
from verify_nonebot import MANIFEST, ROOT_NAME, verify_files, verify_package
def build(root, output):
root, output = Path(root).resolve(), Path(output).resolve()
version = read_version(root)
if output.exists():
raise FileExistsError("Use a new output directory")
if output.is_relative_to(root) and not output.is_relative_to(root / "dist"):
raise ValueError("In-project output must be inside dist")
files = {}
prefix = ROOT_NAME + "/"
integration = root / "integrations/nonebot"
for name in ("__init__.py", "config.py", "commands.py", "data_paths.py"):
files[prefix + name] = (integration / name).read_bytes()
# 同一运行时源,避免两个适配器的业务命令与渲染行为发生分叉。
files[prefix + "runtime.py"] = (
root / "integrations/zhenxun/runtime.py"
).read_bytes()
files[prefix + "_vendor/__init__.py"] = b'"""Private farm modules."""\n'
for package in ("farm_core", "farm_render"):
source = root / "src" / package
selected = list(source.glob("*.py")) + [
path for path in (source / "data").iterdir()
if path.suffix in (".json", ".zip", ".md")
]
for path in selected:
if path.is_symlink():
raise ValueError("Source links are not allowed")
content = path.read_bytes()
if package == "farm_render" and path.suffix == ".py":
content = re.sub(
rb"(?m)^from farm_core(?=[. ])", b"from ..farm_core",
content,
)
relative = path.relative_to(source).as_posix()
files[f"{prefix}_vendor/{package}/{relative}"] = content
for name, source in {
"LICENSE": "LICENSE", "README.md": "docs/12-nonebot.md",
"version.txt": "version.txt",
"images/farm-preview.png": "images/farm-preview.png",
"pyproject.toml": "release/nonebot/pyproject.toml",
"MANIFEST.in": "release/nonebot/MANIFEST.in",
"verify_package.py": "scripts/verify_nonebot.py",
".github/workflows/verify.yml": "release/nonebot/verify.yml",
".github/workflows/publish.yaml": "release/nonebot/publish.yaml",
}.items():
files[name] = (root / source).read_bytes()
files[prefix + "_version.py"] = f'__version__ = "{version}"\n'.encode()
for name, content in files.items():
if name.endswith(".py"):
ast.parse(content, filename=name, feature_version=(3, 10))
manifest = {
"schema": 1, "target": "nonebot", "plugin": ROOT_NAME,
"version": version, "source": source_identity(root),
"files": {name: hashlib.sha256(content).hexdigest()
for name, content in sorted(files.items())},
}
files[MANIFEST] = (json.dumps(
manifest, ensure_ascii=False, sort_keys=True, indent=2,
) + "\n").encode()
verify_files(files)
output.mkdir(parents=True)
for name, content in sorted(files.items()):
path = output / "repository" / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
archive_path = output / f"{ROOT_NAME}-{version}.zip"
with ZipFile(archive_path, "w", compression=ZIP_DEFLATED) as archive:
for name, content in sorted(files.items()):
item = ZipInfo(f"{ROOT_NAME}/{name}", (2020, 1, 1, 0, 0, 0))
item.create_system = 3
item.compress_type = ZIP_DEFLATED
item.external_attr = 0o100644 << 16
archive.writestr(item, content)
verify_package(archive_path)
digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()
archive_path.with_suffix(".zip.sha256").write_text(
f"{digest} {archive_path.name}\n", encoding="ascii",
)
return archive_path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
print(build(Path(__file__).resolve().parents[1], args.output))
if __name__ == "__main__":
main()
+104
View File
@@ -0,0 +1,104 @@
"""在临时目录加载真实 NoneBot 插件,离线验证生命周期与命令适配。"""
import argparse
import asyncio
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
from unittest.mock import patch
async def exercise(module, bot, event, Message, UniMessage):
from nonebot_plugin_alconna import Image
runtime = module.runtime
await runtime.start()
try:
session = SimpleNamespace(user=SimpleNamespace(id="10001", name="测试"))
with patch.object(UniMessage, "send", autospec=True) as send:
await module.handler_for("register")(
bot, event, session, Message(),
)
if "开通" not in str(send.call_args.args[0]):
raise AssertionError("Registration handler failed")
await module.handler_for("farm")(
bot, event, session, Message(),
)
# 无字体环境允许文字退化;有字体时必须生成统一图片段。
response = send.call_args.args[0]
if runtime.rendering and not response[Image]:
raise AssertionError("Image response was not a UniMessage")
await module.handler_for("steal")(
bot, event, session, Message("[CQ:at,qq=10002]"),
)
if "目标尚未" not in str(send.call_args.args[0]):
raise AssertionError("Mention target was not mapped")
await module.handler_for("steal")(
bot, event, session, Message("[CQ:at,qq=all]"),
)
if "具体" not in str(send.call_args.args[0]):
raise AssertionError("Everyone mention was not rejected")
context = dict(
bot_id=str(bot.self_id), platform=bot.adapter.get_name(),
user_id="10001", message_id="buy",
)
first = await runtime.execute("buy", "胡萝卜 1", **context)
second = await runtime.execute("buy", "胡萝卜 1", **context)
if first != second:
raise AssertionError("Repeated event was not idempotent")
context["message_id"] = None
refused = await runtime.execute("sow", "胡萝卜 1", **context)
if "未执行" not in refused:
raise AssertionError("Write without message ID was accepted")
finally:
await runtime.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", type=Path)
args = parser.parse_args()
if args.repository:
sys.path.insert(0, str(args.repository.resolve()))
import nonebot
from nonebot.adapters.onebot.v11 import Adapter, Bot, Message
from nonebot.adapters.onebot.v11.event import PrivateMessageEvent
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
nonebot.init(
driver="~none",
localstore_data_dir=root / "data",
localstore_config_dir=root / "config",
localstore_cache_dir=root / "cache",
)
driver = nonebot.get_driver()
driver.register_adapter(Adapter)
plugin = nonebot.load_plugin("nonebot_plugin_farm")
if plugin is None:
raise AssertionError("NoneBot plugin failed to load")
from nonebot_plugin_farm import commands, __version__
from nonebot_plugin_alconna import UniMessage
expected = (root / "data/nonebot_plugin_farm"
/ "nonebot_plugin_farm/farm_db")
if commands.runtime.data_dir != expected.resolve():
raise AssertionError("Legacy localstore directory changed")
adapter = driver._adapters[Adapter.get_name()]
bot = Bot(adapter, "12345")
event = PrivateMessageEvent(
time=0, self_id=12345, post_type="message",
sub_type="friend", user_id=10001, message_type="private",
message_id=1, message=Message(), original_message=Message(),
raw_message="", font=0, sender={"user_id": 10001},
)
asyncio.run(exercise(commands, bot, event, Message, UniMessage))
if any(name == "zhenxun" or name.startswith("zhenxun.")
for name in sys.modules):
raise AssertionError("Unexpected Zhenxun import")
print(f"NoneBot {__version__}: load, lifecycle, messages checked")
if __name__ == "__main__":
main()
+41 -1
View File
@@ -1,4 +1,4 @@
"""检查两个生成工作流的YAML及发布权限;依赖仅用于开发检查。""" """检查真寻与 NoneBot 工作流的 YAML 和发布边界。"""
import argparse import argparse
from pathlib import Path from pathlib import Path
@@ -46,6 +46,46 @@ def main():
raise ValueError("Generated workflow permissions differ") raise ValueError("Generated workflow permissions differ")
if generated["on"]["push"]["branches"] != ["main"]: if generated["on"]["push"]["branches"] != ["main"]:
raise ValueError("Generated branch differs") raise ValueError("Generated branch differs")
nonebot = yaml.load((
root / ".github/workflows/nonebot-preview.yml"
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
if nonebot["permissions"] != {"contents": "read"}:
raise ValueError("NoneBot source permissions differ")
trigger = nonebot["on"]
if (set(trigger) != {"push", "pull_request", "workflow_dispatch"}
or trigger["push"]["branches"] != ["main"]
or set(trigger["workflow_dispatch"]["inputs"]) != {"publish"}
or trigger["workflow_dispatch"]["inputs"]["publish"]["default"]
!= "false"):
raise ValueError("NoneBot source triggers differ")
sync = nonebot["jobs"]["sync-preview"]
if (sync["environment"] != "zhenxun-preview"
or " ".join(sync["if"].split()) != expected_condition
or nonebot["jobs"]["build"]["needs"] != "test"
or sync["needs"] != ["test", "build"]):
raise ValueError("NoneBot sync boundary differs")
token = sync["steps"][-1]["env"]["FARM_RELEASE_TOKEN"]
if token != "${{ secrets.FARM_RELEASE_TOKEN }}":
raise ValueError("NoneBot must reuse FARM_RELEASE_TOKEN")
generated = yaml.load((
root / "release/nonebot/verify.yml"
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
if (generated["permissions"] != {"contents": "read"}
or generated["on"]["push"]["branches"] != ["main"]):
raise ValueError("NoneBot generated verification boundary differs")
publishing = yaml.load((
root / "release/nonebot/publish.yaml"
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
if (publishing["permissions"] != {"contents": "read"}
or publishing["on"] != {"push": {"tags": ["v*"]}}
or publishing["jobs"]["publish"]["needs"] != "build"
or publishing["jobs"]["publish"]["if"] != (
"github.repository == 'Shu-Ying/nonebot_plugin_farm'"
)):
raise ValueError("NoneBot PyPI boundary differs")
steps = publishing["jobs"]["publish"]["steps"]
if steps[-1]["with"]["password"] != "${{ secrets.PYPI_TOKEN }}":
raise ValueError("Keep the existing PyPI secret")
print("Workflow YAML and publication guards checked") print("Workflow YAML and publication guards checked")
+6
View File
@@ -156,6 +156,12 @@ def export(root):
bundle.writestr(item, content) bundle.writestr(item, content)
apply_positions(root, metadata) apply_positions(root, metadata)
apply_decorations(root, metadata) apply_decorations(root, metadata)
# 与已发布的地块编号一致:前两行交错排列,初始六块组成 2×3。
# 标定仍使用上面的旧资源顺序;这里只调整最终运行时的编号位置。
positions = metadata["positions"]
metadata["positions"] = [positions[index] for index in (
0, 6, 1, 7, 2, 8, 3, 9, 4, 10, 5, 11,
)] + positions[12:]
return output.getvalue(), ( return output.getvalue(), (
json.dumps(metadata, ensure_ascii=False, sort_keys=True, indent=2) json.dumps(metadata, ensure_ascii=False, sort_keys=True, indent=2)
+ "\n" + "\n"
+105
View File
@@ -0,0 +1,105 @@
"""固定同步 NoneBot main;默认离线 dry-run,普通提交保留历史。"""
import argparse
import base64
import json
import os
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from verify_nonebot import read_package, verify_files
REPOSITORY = "Shu-Ying/nonebot_plugin_farm"
BRANCH = "main"
def github(method, path, payload, token, allow_missing=False):
url = f"https://api.github.com/repos/{REPOSITORY}"
if path:
url += f"/{path}"
request = Request(
url,
data=json.dumps(payload).encode() if payload is not None else None,
headers={"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "farm-nonebot-publisher"}, method=method,
)
try:
with urlopen(request, timeout=30) as response:
return json.load(response)
except HTTPError as error:
if error.code == 404 and method == "GET" and allow_missing:
return None
raise RuntimeError(
f"GitHub request failed: {method} {url}: HTTP {error.code}",
) from None
def publish(package, confirmed=False, request=None):
files = read_package(package)
manifest = verify_files(files)
result = {"repository": REPOSITORY, "branch": BRANCH,
"version": manifest["version"], "published": False}
if not confirmed:
return result
if manifest["source"]["dirty"]:
raise ValueError("Publish requires a clean source checkout")
token = os.environ.get("FARM_RELEASE_TOKEN")
if not token:
raise ValueError("Configure FARM_RELEASE_TOKEN in a protected job")
call = request or (lambda method, path, data: github(
method, path, data, token,
allow_missing=path.startswith(("git/ref/heads/", "contents/")),
))
if not call("GET", "", None):
raise ValueError(f"Target repository is not accessible: {REPOSITORY}")
ref = call("GET", f"git/ref/heads/{BRANCH}", None)
parent = ref["object"]["sha"] if ref else None
if parent:
owned = call("GET", f"contents/FARM-MANIFEST.json?ref={BRANCH}", None)
if owned is not None:
if owned.get("encoding") != "base64":
raise ValueError("Existing Farm manifest encoding is invalid")
previous = json.loads(base64.b64decode(owned["content"]))
if (previous.get("target") != "nonebot"
or previous.get("plugin") != manifest["plugin"]):
raise ValueError("Existing branch ownership differs")
tree = []
for name, content in sorted(files.items()):
blob = call("POST", "git/blobs", {
"content": base64.b64encode(content).decode(),
"encoding": "base64",
})
tree.append({"path": name, "mode": "100644", "type": "blob",
"sha": blob["sha"]})
generated = call("POST", "git/trees", {"tree": tree})
commit = call("POST", "git/commits", {
"message": f"Generate NoneBot farm {manifest['version']} "
f"from {manifest['source']['commit']}",
"tree": generated["sha"], "parents": [parent] if parent else [],
})
if parent:
current = call("GET", f"git/ref/heads/{BRANCH}", None)
if not current or current["object"]["sha"] != parent:
raise RuntimeError("Target branch changed; retry explicitly")
call("PATCH", f"git/refs/heads/{BRANCH}", {
"sha": commit["sha"], "force": False,
})
else:
call("POST", "git/refs", {
"ref": f"refs/heads/{BRANCH}", "sha": commit["sha"],
})
return {**result, "published": True, "commit": commit["sha"]}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("package")
parser.add_argument("--publish", action="store_true")
args = parser.parse_args()
print(json.dumps(publish(args.package, args.publish), ensure_ascii=False))
if __name__ == "__main__":
main()
+105
View File
@@ -0,0 +1,105 @@
"""仅用标准库校验 NoneBot 生成仓库或 ZIP;不启动插件。"""
import argparse
import hashlib
import json
from pathlib import Path, PurePosixPath
import stat
from zipfile import ZipFile
ROOT_NAME = "nonebot_plugin_farm"
MANIFEST = "FARM-MANIFEST.json"
MAX_BYTES = 100 * 1024 * 1024
def safe_path(name):
path = PurePosixPath(name)
if (not name or "\\" in name or ":" in name or path.is_absolute()
or any(part in (".", "..", "") for part in name.split("/"))
or path.as_posix() != name):
raise ValueError("Unsafe package path")
return path
def verify_files(files):
if MANIFEST not in files or len(files[MANIFEST]) > 1024 * 1024:
raise ValueError("Missing or oversized manifest")
manifest = json.loads(files[MANIFEST])
if (manifest.get("schema") != 1 or manifest.get("target") != "nonebot"
or manifest.get("plugin") != ROOT_NAME):
raise ValueError("Invalid package identity")
expected = manifest["files"]
if (not isinstance(expected, dict)
or set(files) != set(expected) | {MANIFEST}):
raise ValueError("Package files differ from manifest")
for name, digest in expected.items():
safe_path(name)
if hashlib.sha256(files[name]).hexdigest() != digest:
raise ValueError("Package checksum mismatch")
required = {
"pyproject.toml", "MANIFEST.in", "version.txt", "LICENSE",
"nonebot_plugin_farm/__init__.py", "nonebot_plugin_farm/config.py",
"nonebot_plugin_farm/commands.py", "nonebot_plugin_farm/runtime.py",
"nonebot_plugin_farm/_vendor/farm_core/__init__.py",
"nonebot_plugin_farm/_vendor/farm_render/data/assets.zip",
".github/workflows/publish.yaml", ".github/workflows/verify.yml",
}
if not required <= set(files):
raise ValueError("Incomplete plugin repository")
if files["version.txt"].decode().strip() != manifest["version"]:
raise ValueError("Version file differs from manifest")
return manifest
def read_package(path):
with ZipFile(path) as archive:
items = archive.infolist()
names = [item.filename for item in items]
if (len(names) != len(set(names)) or len(names) > 200
or sum(item.file_size for item in items) > MAX_BYTES):
raise ValueError("Duplicate paths or package too large")
files = {}
for item in items:
name = safe_path(item.filename)
if (len(name.parts) < 2 or name.parts[0] != ROOT_NAME
or item.is_dir()
or stat.S_ISLNK(item.external_attr >> 16)):
raise ValueError("Unexpected package root or link")
files[name.relative_to(ROOT_NAME).as_posix()] = archive.read(item)
verify_files(files)
return files
def verify_package(path):
return verify_files(read_package(path))
def verify_directory(directory):
directory = Path(directory).resolve()
files = {}
for path in directory.rglob("*"):
relative = path.relative_to(directory)
if any(part in (".git", "__pycache__") for part in relative.parts):
continue
if path.is_symlink():
raise ValueError("Unexpected symbolic link")
if path.is_file():
files[relative.as_posix()] = path.read_bytes()
return verify_files(files)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("package", nargs="?", type=Path)
parser.add_argument("--directory", type=Path)
args = parser.parse_args()
if bool(args.package) == bool(args.directory):
parser.error("Choose a ZIP or --directory")
manifest = (verify_directory(args.directory) if args.directory
else verify_package(args.package))
print(f"Verified nonebot {manifest['version']}: "
f"{len(manifest['files'])} files")
if __name__ == "__main__":
main()
+150
View File
@@ -0,0 +1,150 @@
import base64
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
from zipfile import ZipFile
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from build_nonebot import build
from publish_nonebot import publish
from verify_nonebot import MANIFEST, ROOT_NAME, read_package, verify_files
class NoneBotPackageTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.bundle = build(ROOT, self.root / "built")
self.files = read_package(self.bundle)
def test_reproducible_standard_package_and_shared_runtime(self):
other = build(ROOT, self.root / "second")
self.assertEqual(self.bundle.read_bytes(), other.read_bytes())
self.assertEqual(self.files["nonebot_plugin_farm/runtime.py"], (
ROOT / "integrations/zhenxun/runtime.py"
).read_bytes())
for suffix in ("farm_core/data/plants.json",
"farm_render/data/assets.zip"):
self.assertTrue(any(name.endswith(suffix) for name in self.files))
self.assertEqual(verify_files(self.files)["target"], "nonebot")
self.assertIn("pyproject.toml", self.files)
self.assertIn(".github/workflows/publish.yaml", self.files)
self.assertFalse(any("legacy/" in name for name in self.files))
with self.assertRaises(FileExistsError):
build(ROOT, self.root / "built")
def test_host_entry_has_no_zhenxun_import(self):
import ast
for name, content in self.files.items():
if not name.endswith(".py"):
continue
tree = ast.parse(content, feature_version=(3, 10))
for node in ast.walk(tree):
imports = ([node.module] if isinstance(node, ast.ImportFrom)
else [alias.name for alias in node.names]
if isinstance(node, ast.Import) else [])
self.assertFalse(any(
value and value.startswith(("zhenxun", "zhenxun_utils"))
for value in imports
), name)
def test_tampering_and_paths_rejected(self):
files = dict(self.files)
files["pyproject.toml"] += b"# changed\n"
with self.assertRaisesRegex(ValueError, "checksum"):
verify_files(files)
for path in ("../escape", "extra", "C:/escape", "..\\escape"):
bundle = self.root / "bad.zip"
bundle.write_bytes(self.bundle.read_bytes())
with ZipFile(bundle, "a") as archive:
archive.writestr(path, b"invalid")
with self.assertRaises(ValueError):
read_package(bundle)
def clean_package(self):
files = dict(self.files)
manifest = json.loads(files[MANIFEST])
manifest["source"]["dirty"] = False
files[MANIFEST] = json.dumps(manifest).encode()
bundle = self.root / "clean.zip"
with ZipFile(bundle, "w") as archive:
for name, content in files.items():
archive.writestr(f"{ROOT_NAME}/{name}", content)
return bundle, manifest
def test_default_publish_is_offline_and_dirty_source_is_rejected(self):
def forbidden(*args):
self.fail("dry-run must not make requests")
report = publish(self.bundle, request=forbidden)
self.assertFalse(report["published"])
self.assertEqual(report["repository"], "Shu-Ying/nonebot_plugin_farm")
self.assertEqual(report["branch"], "main")
bundle, manifest = self.clean_package()
manifest["source"]["dirty"] = True
self.files[MANIFEST] = json.dumps(manifest).encode()
with ZipFile(bundle, "w") as archive:
for name, content in self.files.items():
archive.writestr(f"{ROOT_NAME}/{name}", content)
with self.assertRaisesRegex(ValueError, "clean source"):
publish(bundle, True, forbidden)
def test_fixed_sync_preserves_history_and_includes_pypi_workflow(self):
bundle, manifest = self.clean_package()
calls = []
def request(method, path, data):
calls.append((method, path, data))
if path == "":
return {"default_branch": "main"}
if path.startswith("git/ref/"):
return {"object": {"sha": "existing"}}
if path.startswith("contents/"):
return None
return {"sha": "generated"}
with patch.dict("os.environ", {"FARM_RELEASE_TOKEN": "test-token"}):
self.assertTrue(publish(bundle, True, request)["published"])
tree = next(data for method, path, data in calls
if path == "git/trees")
self.assertEqual({entry["path"] for entry in tree["tree"]},
set(self.files))
commit = next(data for method, path, data in calls
if path == "git/commits")
self.assertEqual(commit["parents"], ["existing"])
self.assertIn(("PATCH", "git/refs/heads/main", {
"sha": "generated", "force": False,
}), calls)
def test_wrong_owner_and_concurrent_change_are_rejected(self):
bundle, manifest = self.clean_package()
for wrong_owner in (True, False):
calls = []
def request(method, path, data):
calls.append((method, path, data))
if path == "":
return {"default_branch": "main"}
if path.startswith("contents/"):
previous = {**manifest, "target": (
"zhenxun" if wrong_owner else "nonebot"
)}
return {"encoding": "base64", "content": base64.b64encode(
json.dumps(previous).encode(),
).decode()}
if method == "GET":
reads = sum(p.startswith("git/ref/") for _, p, _ in calls)
return {"object": {"sha": (
"existing" if reads == 1 else "changed"
)}}
return {"sha": "generated"}
with patch.dict("os.environ", {"FARM_RELEASE_TOKEN": "test"}):
with self.assertRaises((ValueError, RuntimeError)):
publish(bundle, True, request)
self.assertFalse(any(method == "PATCH" for method, _, _ in calls))
+16
View File
@@ -0,0 +1,16 @@
import json
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from export_render_assets import export
class RenderExportTests(unittest.TestCase):
def test_export_preserves_published_land_numbering_and_calibration(self):
_, metadata = export(ROOT)
stored = (ROOT / "src/farm_render/data/metadata.json").read_bytes()
self.assertEqual(json.loads(metadata), json.loads(stored))
self.assertEqual(metadata, stored)
+1 -1
View File
@@ -1 +1 @@
1.5.4 2.0.1