fixed # 适配AstrBot

This commit is contained in:
2026-10-23 19:12:16 +08:00
parent d0c1741940
commit 685cff9293
49 changed files with 1557 additions and 165 deletions
+32 -4
View File
@@ -4,6 +4,7 @@ import argparse
import ast
import hashlib
import json
import runpy
from pathlib import Path
import tempfile
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
@@ -13,6 +14,24 @@ from verify_astrbot import MANIFEST, ROOT_NAME, verify_files, verify_package
from verify_zhenxun import read_package as read_shared
def render_readme(root):
template = (root / 'readme/astrbot.md').read_text(encoding='utf-8')
marker = '<!-- FARM_COMMANDS -->'
if template.count(marker) != 1:
raise ValueError('AstrBot README requires one command marker')
definitions = runpy.run_path(str(root / 'src/farm_core/commands.py'))
lines = ['| 分类 | 指令 | 说明 |', '| --- | --- | --- |']
specs = definitions['command_specs']('astrbot')
for group, rows in definitions['help_groups'](specs):
for usage, description in rows:
values = (group, f'`/{usage}`', description)
lines.append('| ' + ' | '.join(
value.replace('|', r'\|').replace('\n', ' ')
for value in values
) + ' |')
return template.replace(marker, '\n'.join(lines)).encode('utf-8')
def build(root, output):
root, output = Path(root).resolve(), Path(output).resolve()
version = read_version(root)
@@ -28,11 +47,15 @@ def build(root, output):
)}
source = root / 'integrations/astrbot'
for name in ('main.py', 'avatars.py', '_conf_schema.json',
'requirements.txt'):
'requirements.txt', 'CHANGELOG.md'):
files[name] = (source / name).read_bytes()
files['README.md'] = (source / 'README.md').read_bytes()
# AstrBot识别固定文件名logo.png;README单独使用高清原图。
files['logo.png'] = (root / 'logo-astrbot.png').read_bytes()
files['logo-readme.png'] = (root / 'logo.png').read_bytes()
files['images/farm-preview.png'] = shared['images/farm-preview.png']
files['README.md'] = render_readme(root)
files['runtime.py'] = (
root / 'integrations/zhenxun/runtime.py'
root / 'integrations/shared/runtime.py'
).read_bytes()
files['verify_package.py'] = (
root / 'scripts/verify_astrbot.py'
@@ -40,9 +63,14 @@ def build(root, output):
# JSON是YAML的子集;标准库即可生成AstrBot可读的metadata.yaml。
files['metadata.yaml'] = (json.dumps({
'name': ROOT_NAME, 'display_name': '农场',
'desc': '种植、收获、仓库、交易与农场图片,复用Farm共享核心。',
'short_desc': '开通农场,体验种植、收获、偷菜与土地开垦。',
'desc': '农场种植小游戏,支持种子商店、分类仓库、作物交易、'
'除虫、偷菜、土地开垦和动态图片帮助;使用本地存档。',
'version': version, 'author': 'Shu-Ying',
'repo': 'https://github.com/Shu-Ying/astrbot_plugin_farm',
'social_link': 'https://github.com/Shu-Ying',
'tags': ['农场', '种植', '小游戏'],
'support_platforms': ['aiocqhttp'],
}, ensure_ascii=False, indent=2) + '\n').encode()
for name, content in files.items():
if name.endswith('.py'):
+4 -3
View File
@@ -26,14 +26,14 @@ def build(root, output):
files[prefix + name] = (integration / name).read_bytes()
# 同一运行时源,避免两个适配器的业务命令与渲染行为发生分叉。
files[prefix + "runtime.py"] = (
root / "integrations/zhenxun/runtime.py"
root / "integrations/shared/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")
if path.suffix in (".json", ".md")
]
for path in selected:
if path.is_symlink():
@@ -47,8 +47,9 @@ def build(root, output):
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",
"LICENSE": "LICENSE", "README.md": "readme/nonebot.md",
"version.txt": "version.txt",
"logo.png": "logo.png",
"images/farm-preview.png": "images/farm-preview.png",
"pyproject.toml": "release/nonebot/pyproject.toml",
"MANIFEST.in": "release/nonebot/MANIFEST.in",
+7 -3
View File
@@ -46,17 +46,20 @@ def build(root, output):
files = {}
integration = root / "integrations/zhenxun"
for name in (
"__init__.py", "runtime.py", "commands.py", "data_paths.py",
"__init__.py", "commands.py", "data_paths.py",
"requirements.txt", "config_migration.py",
):
files[name] = (integration / name).read_bytes()
files["runtime.py"] = (
root / "integrations/shared/runtime.py"
).read_bytes()
files["_vendor/__init__.py"] = b'"""Private shared farm modules."""\n'
for package in ("farm_core", "farm_render"):
source = root / "src" / package
selected = list(source.glob("*.py")) + [
p for p in (source / "data").iterdir() if p.suffix in (
".json", ".zip", ".md",
)
) and p.name != 'assets.zip'
]
for path in selected:
if path.is_symlink():
@@ -71,8 +74,9 @@ def build(root, output):
files[f"_vendor/{package}/{relative}"] = content
for name, source in {
"LICENSE": root / "LICENSE",
"README.md": root / "docs/11-zhenxun-preview.md",
"README.md": root / "readme/zhenxun.md",
"version.txt": root / "version.txt",
"logo.png": root / "logo.png",
"images/farm-preview.png": root / "images/farm-preview.png",
"verify_package.py": root / "scripts/verify_zhenxun.py",
".github/workflows/verify.yml": root / "release/zhenxun/verify.yml",
+34
View File
@@ -0,0 +1,34 @@
"""离线核对发布清单、开发素材及布局;不访问远端。"""
import hashlib
import json
from pathlib import Path
from zipfile import ZipFile
def check(root):
data = Path(root) / 'src/farm_render/data'
manifest = json.loads((data / 'asset-manifest.json').read_bytes())
metadata = json.loads((data / 'metadata.json').read_bytes())
bundle = data / 'assets.zip'
if (manifest['schema'] != 1
or bundle.stat().st_size != manifest['size_bytes']
or hashlib.sha256(bundle.read_bytes()).hexdigest()
!= manifest['sha256']):
raise ValueError('Asset manifest and development ZIP differ')
with ZipFile(bundle) as archive:
if set(archive.namelist()) != set(metadata['images']):
raise ValueError('Asset manifest and layout differ')
for source, prefix in (
('github', 'https://raw.githubusercontent.com/Shu-Ying/'
'farm_service/main/assets/assets.zip'),
('gitea', 'https://gitea.diuse.work/Diuse/farm_service/'
'raw/branch/main/assets/assets.zip'),
):
if manifest['sources'][source] != prefix:
raise ValueError('Asset source must use service repository')
print('Verified external asset manifest and development ZIP')
if __name__ == '__main__':
check(Path(__file__).resolve().parents[1])
+20 -2
View File
@@ -1,4 +1,4 @@
"""检查真寻与 NoneBot 工作流的 YAML 和发布边界。"""
"""检查真寻、NoneBot 与 AstrBot 工作流的 YAML 和发布边界。"""
import argparse
from pathlib import Path
@@ -49,7 +49,10 @@ def main():
nonebot = yaml.load((
root / ".github/workflows/nonebot-preview.yml"
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
for workflow in (source, nonebot):
astrbot = yaml.load((
root / ".github/workflows/astrbot-preview.yml"
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
for workflow in (source, nonebot, astrbot):
install_commands = [
step.get("run", "").split()
for step in workflow["jobs"]["test"]["steps"]
@@ -98,6 +101,21 @@ def main():
steps = publishing["jobs"]["publish"]["steps"]
if steps[-1]["with"]["password"] != "${{ secrets.PYPI_TOKEN }}":
raise ValueError("Keep the existing PyPI secret")
trigger = astrbot["on"]
sync = astrbot["jobs"]["sync-preview"]
if (astrbot["permissions"] != {"contents": "read"}
or set(trigger) != set(nonebot["on"])
or trigger["push"]["branches"] != ["main"]
or set(trigger["workflow_dispatch"]["inputs"]) != {"publish"}
or trigger["workflow_dispatch"]["inputs"]["publish"]["default"]
!= "false"
or sync["environment"] != "zhenxun-preview"
or " ".join(sync["if"].split()) != expected_condition
or astrbot["jobs"]["build"]["needs"] != "test"
or sync["needs"] != ["test", "build"]
or sync["steps"][-1]["env"]["FARM_RELEASE_TOKEN"]
!= "${{ secrets.FARM_RELEASE_TOKEN }}"):
raise ValueError("AstrBot workflow publication boundary differs")
print("Workflow YAML and publication guards checked")
+5 -3
View File
@@ -44,9 +44,11 @@ def fit_grid(source, target):
]) for point, dest in zip(design, target)]
if max(errors) > 10:
raise ValueError('Local grid no longer matches reference geometry')
return [row[:2] for row in affine], {
'rms': math.sqrt(sum(e*e for e in errors)/len(errors)),
'maximum': max(errors), 'points': len(errors),
# Python 版本的浮点求和及平台数学库可能产生末位差异。
# 固定导出精度,避免无意义的差异使 CI 字节校验失败。
return [[round(value, 8) for value in row[:2]] for row in affine], {
'rms': round(math.sqrt(sum(e*e for e in errors)/len(errors)), 8),
'maximum': round(max(errors), 8), 'points': len(errors),
}
+107
View File
@@ -0,0 +1,107 @@
"""固定同步 AstrBot main;默认离线 dry-run,普通提交保留历史。"""
import argparse
import base64
import json
import os
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from verify_astrbot import read_package, verify_files
from release_metadata import commit_message
REPOSITORY = "Shu-Ying/astrbot_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-astrbot-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)
message = commit_message(manifest)
result = {"repository": REPOSITORY, "branch": BRANCH,
"version": manifest["version"], "message": message,
"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") != "astrbot"
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": message,
"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()
+13 -4
View File
@@ -11,6 +11,7 @@ from zipfile import ZipFile
ROOT_NAME = 'astrbot_plugin_farm'
MANIFEST = 'FARM-MANIFEST.json'
MAX_BYTES = 100 * 1024 * 1024
MAX_ARCHIVE_BYTES = 16 * 1024 * 1024
def safe_path(name):
@@ -37,10 +38,14 @@ def verify_files(files):
if hashlib.sha256(files[name]).hexdigest() != digest:
raise ValueError('Package checksum mismatch')
required = {'main.py', 'avatars.py', 'metadata.yaml', '_conf_schema.json',
'runtime.py',
'runtime.py', 'README.md', 'CHANGELOG.md', 'logo.png',
'logo-readme.png',
'images/farm-preview.png',
'requirements.txt', 'LICENSE', 'version.txt', '_version.py',
'_vendor/farm_core/__init__.py',
'_vendor/farm_render/data/assets.zip'}
'_vendor/farm_render/data/asset-manifest.json'}
if any(name.endswith('/assets.zip') for name in files):
raise ValueError('Resource ZIP must not be in plugin repository')
if not required <= set(files):
raise ValueError('Incomplete plugin')
metadata = json.loads(files['metadata.yaml'])
@@ -70,15 +75,19 @@ def read_package(path):
return files
def verify_package(path):
def verify_package(path, marketplace=False):
if marketplace and Path(path).stat().st_size > MAX_ARCHIVE_BYTES:
raise ValueError('Marketplace ZIP exceeds 16 MiB')
return verify_files(read_package(path))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('package', type=Path)
parser.add_argument('--marketplace', action='store_true',
help='Also enforce the marketplace 16 MiB limit')
args = parser.parse_args()
manifest = verify_package(args.package)
manifest = verify_package(args.package, marketplace=args.marketplace)
print(f"Verified astrbot {manifest['version']}: "
f"{len(manifest['files'])} files")
+1
View File
@@ -16,6 +16,7 @@ def main():
[sys.executable, "scripts/check_baseline.py"],
[sys.executable, "scripts/export_catalogue.py", "--check"],
[sys.executable, "scripts/export_render_assets.py", "--check"],
[sys.executable, 'scripts/check_asset_manifest.py'],
[
sys.executable, "-m", "unittest", "discover",
"-s", "tests", "-p", "test_*.py", "-v",
+3 -1
View File
@@ -41,11 +41,13 @@ def verify_files(files):
"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",
"nonebot_plugin_farm/_vendor/farm_render/data/asset-manifest.json",
".github/workflows/publish.yaml", ".github/workflows/verify.yml",
}
if not required <= set(files):
raise ValueError("Incomplete plugin repository")
if any(name.endswith('/assets.zip') for name in files):
raise ValueError('Resource ZIP must not be in plugin repository')
if files["version.txt"].decode().strip() != manifest["version"]:
raise ValueError("Version file differs from manifest")
return manifest
+3 -1
View File
@@ -38,7 +38,9 @@ def verify_files(files):
raise ValueError("Package checksum mismatch")
required = {"__init__.py", "runtime.py", "commands.py", "requirements.txt",
"LICENSE", "_vendor/farm_core/__init__.py",
"_vendor/farm_render/data/assets.zip"}
"_vendor/farm_render/data/asset-manifest.json"}
if any(name.endswith('/assets.zip') for name in files):
raise ValueError('Resource ZIP must not be in plugin repository')
if not required <= set(files):
raise ValueError("Incomplete plugin")
return manifest