mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-28 13:10:02 +08:00
add # 新增Nonebot发布工作流
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# NoneBot 农场
|
||||||
|
|
||||||
|
从 `main_plugin_farm` 自动生成的普通 NoneBot 插件,与真寻版使用相同的
|
||||||
|
业务核心、命令和渲染资源,不依赖真寻或 zhenxun-utils。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 安装与配置
|
||||||
|
|
||||||
|
需要 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 和实际机器人消息行为
|
||||||
|
需要在部署环境进一步验证。
|
||||||
@@ -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 群验证。
|
||||||
@@ -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) | 真寻原指令预览、构建/校验/安装和可选同步 |
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -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 发布。
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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` 检查源工作流、生成工作流及发布权限边界。
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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))
|
||||||
@@ -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
@@ -1 +1 @@
|
|||||||
1.5.4
|
2.0.1
|
||||||
Reference in New Issue
Block a user