mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-29 21:59:58 +08:00
fixed # 适配AstrBot
This commit is contained in:
@@ -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'):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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])
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user