diff --git a/.github/workflows/nonebot-preview.yml b/.github/workflows/nonebot-preview.yml new file mode 100644 index 0000000..958b6f6 --- /dev/null +++ b/.github/workflows/nonebot-preview.yml @@ -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 diff --git a/docs/12-nonebot.md b/docs/12-nonebot.md new file mode 100644 index 0000000..b0962ef --- /dev/null +++ b/docs/12-nonebot.md @@ -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 和实际机器人消息行为 +需要在部署环境进一步验证。 diff --git a/docs/23-seasonal-events-and-rust-service.md b/docs/23-seasonal-events-and-rust-service.md new file mode 100644 index 0000000..9beca10 --- /dev/null +++ b/docs/23-seasonal-events-and-rust-service.md @@ -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 群验证。 diff --git a/docs/README.md b/docs/README.md index 72abc3a..df7c96f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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、测试与限制 | | [10-rendering.md](10-rendering.md) | 共享图片工具、农场绘制、缓存、编码与接入 | | [11-zhenxun-preview.md](11-zhenxun-preview.md) | 真寻原指令预览、构建/校验/安装和可选同步 | diff --git a/integrations/nonebot/README.md b/integrations/nonebot/README.md index bc7a843..42931e4 100644 --- a/integrations/nonebot/README.md +++ b/integrations/nonebot/README.md @@ -1,6 +1,10 @@ -# 普通 NoneBot 接入预留位置 +# 普通 NoneBot 接入 发布目标: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`。 diff --git a/integrations/nonebot/__init__.py b/integrations/nonebot/__init__.py new file mode 100644 index 0000000..39607f6 --- /dev/null +++ b/integrations/nonebot/__init__.py @@ -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 diff --git a/integrations/nonebot/commands.py b/integrations/nonebot/commands.py new file mode 100644 index 0000000..a11b53c --- /dev/null +++ b/integrations/nonebot/commands.py @@ -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)) diff --git a/integrations/nonebot/config.py b/integrations/nonebot/config.py new file mode 100644 index 0000000..ca4764d --- /dev/null +++ b/integrations/nonebot/config.py @@ -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", + ) diff --git a/integrations/nonebot/data_paths.py b/integrations/nonebot/data_paths.py new file mode 100644 index 0000000..68bc2a8 --- /dev/null +++ b/integrations/nonebot/data_paths.py @@ -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" diff --git a/integrations/zhenxun/runtime.py b/integrations/zhenxun/runtime.py index 0630f8f..08891cc 100644 --- a/integrations/zhenxun/runtime.py +++ b/integrations/zhenxun/runtime.py @@ -1,4 +1,4 @@ -"""真寻接入编排;核心私有导入,测试数据与旧版隔离。""" +"""真寻与 NoneBot 共用的运行编排;不导入宿主框架。""" import hashlib from dataclasses import replace @@ -87,9 +87,11 @@ def operation_id(bot_id, platform, user_id, message_id, action): class Runtime: 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.font_path = font_path + self.font_setting = font_setting self.font_dir = Path(font_dir or "resources/font") self.deployment = deployment self.image_format = image_format @@ -273,7 +275,7 @@ class Runtime: if not self.rendering: if action == "plot_ids": return plot_ids_text(await self.service.snapshot(who)) - return "请配置 FARM_PREVIEW_FONT 为可用中文字体路径。" + return f"请配置 {self.font_setting} 为可用中文字体路径。" now = self.service.clock() theme = season_for(now) logger.info("farm preview seasonal theme=%s", theme) diff --git a/release/README.md b/release/README.md index 0092cfd..737eda2 100644 --- a/release/README.md +++ b/release/README.md @@ -1,11 +1,15 @@ -# 发布设计预留位置 +# 发布生成配置 -targets.json 只记录用户提供的仓库与目标名称;enabled=false。 -没有可执行同步脚本、Actions、远程写凭据或发布授权。 +主仓库是唯一业务开发源。真寻与 NoneBot 已有离线打包、清单验证和 +固定目标同步脚本;AstrBot 仍为设计预留,未启用。 主仓库构建的每个产物只含同版本核心、当前框架接入、所需资源和依赖。 -首次接管三个现有仓库需先比较已有内容,通过候选 PR 审查, -不得直接清空目标仓库或强推。 +真寻使用插件目录布局,NoneBot 使用标准 Python 包布局。 +同步脚本默认 dry-run;带 `--publish` 才使用 `FARM_RELEASE_TOKEN` +向固定仓库 `main` 普通提交完整生成树,保留历史并检查分支并发变更。 +生成树会替换目标分支的当前文件集合,首次同步前应审查输出内容。 -用户已要求停止拉取并删除本地克隆,当前不得重新检出这些仓库。 -完整规则见 docs/04-release-design.md。 +NoneBot 既有 `publish.yaml` 已从远端读取,生成模板继续使用 `v*` +标签触发及 `PYPI_TOKEN`。主仓库的同步任务沿用真寻 environment +`zhenxun-preview` 和 `FARM_RELEASE_TOKEN`,普通同步不会发布 PyPI。 +本次没有执行远端推送、分支同步或 PyPI 发布。 diff --git a/release/nonebot/MANIFEST.in b/release/nonebot/MANIFEST.in new file mode 100644 index 0000000..1746cb0 --- /dev/null +++ b/release/nonebot/MANIFEST.in @@ -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 diff --git a/release/nonebot/publish.yaml b/release/nonebot/publish.yaml new file mode 100644 index 0000000..14b84b7 --- /dev/null +++ b/release/nonebot/publish.yaml @@ -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 }} diff --git a/release/nonebot/pyproject.toml b/release/nonebot/pyproject.toml new file mode 100644 index 0000000..f9b69da --- /dev/null +++ b/release/nonebot/pyproject.toml @@ -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", +] diff --git a/release/nonebot/verify.yml b/release/nonebot/verify.yml new file mode 100644 index 0000000..a106074 --- /dev/null +++ b/release/nonebot/verify.yml @@ -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 diff --git a/release/targets.json b/release/targets.json index f5ed88e..5bf8374 100644 --- a/release/targets.json +++ b/release/targets.json @@ -1,6 +1,6 @@ { "schema": "farm.release-targets.v1", - "status": "zhenxun-preview", + "status": "zhenxun-and-nonebot-preview", "source_repository": "https://github.com/Shu-Ying/main_plugin_farm.git", "network_actions_enabled": false, "targets": [ @@ -25,7 +25,9 @@ "repository": "https://github.com/Shu-Ying/nonebot_plugin_farm.git", "observed_default_branch": "main", "entry_directory": "integrations/nonebot", - "enabled": false + "enabled": true, + "preview_branch": "main", + "publish_by_default": false } ] } diff --git a/scripts/README.md b/scripts/README.md index c37319a..87f2ad2 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,4 +5,10 @@ check_baseline.py 为已实现的离线校验工具。 检查清单哈希、缺失/新增文件、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` 检查源工作流、生成工作流及发布权限边界。 diff --git a/scripts/build_nonebot.py b/scripts/build_nonebot.py new file mode 100644 index 0000000..4a1cbce --- /dev/null +++ b/scripts/build_nonebot.py @@ -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() diff --git a/scripts/check_nonebot_runtime.py b/scripts/check_nonebot_runtime.py new file mode 100644 index 0000000..17c0303 --- /dev/null +++ b/scripts/check_nonebot_runtime.py @@ -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() diff --git a/scripts/check_workflows.py b/scripts/check_workflows.py index 489259e..29cbac7 100644 --- a/scripts/check_workflows.py +++ b/scripts/check_workflows.py @@ -1,4 +1,4 @@ -"""检查两个生成工作流的YAML及发布权限;依赖仅用于开发检查。""" +"""检查真寻与 NoneBot 工作流的 YAML 和发布边界。""" import argparse from pathlib import Path @@ -46,6 +46,46 @@ def main(): raise ValueError("Generated workflow permissions differ") if generated["on"]["push"]["branches"] != ["main"]: 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") diff --git a/scripts/export_render_assets.py b/scripts/export_render_assets.py index 23f550d..46f396c 100644 --- a/scripts/export_render_assets.py +++ b/scripts/export_render_assets.py @@ -156,6 +156,12 @@ def export(root): bundle.writestr(item, content) apply_positions(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(), ( json.dumps(metadata, ensure_ascii=False, sort_keys=True, indent=2) + "\n" diff --git a/scripts/publish_nonebot.py b/scripts/publish_nonebot.py new file mode 100644 index 0000000..b6b86da --- /dev/null +++ b/scripts/publish_nonebot.py @@ -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() diff --git a/scripts/verify_nonebot.py b/scripts/verify_nonebot.py new file mode 100644 index 0000000..da73e81 --- /dev/null +++ b/scripts/verify_nonebot.py @@ -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() diff --git a/tests/test_nonebot_package.py b/tests/test_nonebot_package.py new file mode 100644 index 0000000..8e20e13 --- /dev/null +++ b/tests/test_nonebot_package.py @@ -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)) diff --git a/tests/test_render_export.py b/tests/test_render_export.py new file mode 100644 index 0000000..72e0a27 --- /dev/null +++ b/tests/test_render_export.py @@ -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) diff --git a/version.txt b/version.txt index 63ebd3f..10bf840 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.5.4 \ No newline at end of file +2.0.1 \ No newline at end of file