"""白名单生成AstrBot可安装包,复用已校验的共享模块打包路径。""" import argparse import ast import hashlib import json import runpy from pathlib import Path import tempfile from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo from build_zhenxun import build as build_shared, read_version, source_identity from verify_astrbot import MANIFEST, ROOT_NAME, verify_files, verify_package from verify_zhenxun import read_package as read_shared from help_image import build_help_image def host_logging(content): """仅替换日志入口,保留共享业务源码和注释;未知用法交给包校验拒绝。""" lines = content.decode('utf-8').splitlines(keepends=True) edits = [] for node in ast.parse(content).body: if (isinstance(node, ast.Import) and any(alias.name == 'logging' for alias in node.names)): if len(node.names) != 1 or node.names[0].asname: raise ValueError('Unsupported logging import') edits.append((node.lineno, node.end_lineno, '')) if (isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name) and isinstance(node.value, ast.Call) and isinstance(node.value.func, ast.Attribute) and isinstance(node.value.func.value, ast.Name) and node.value.func.value.id == 'logging' and node.value.func.attr == 'getLogger'): alias = node.targets[0].id statement = 'from astrbot.api import logger' if alias != 'logger': statement += ' as ' + alias edits.append((node.lineno, node.end_lineno, statement + '\n')) for start, end, replacement in sorted(edits, reverse=True): lines[start-1:end] = [replacement] return ''.join(lines).encode('utf-8') def render_readme(root): template = (root / 'readme/astrbot.md').read_text(encoding='utf-8') marker = '' if marker not in template: if './images/help-astrbot.png' not in template: raise ValueError('AstrBot README requires its help menu image') return template.encode('utf-8') 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, *, help_image=None): 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') with tempfile.TemporaryDirectory() as temporary: shared = read_shared(build_shared(root, Path(temporary) / 'shared')) files = {name: content for name, content in shared.items() if name.startswith('_vendor/') or name in ( '_version.py', 'version.txt', 'LICENSE', ) if not name.endswith('/__main__.py')} source = root / 'integrations/astrbot' for name in ('main.py', 'avatars.py', '_conf_schema.json', 'requirements.txt', 'CHANGELOG.md'): files[name] = (source / name).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['images/help-astrbot.png'] = build_help_image( root, 'astrbot', help_image, ) files['README.md'] = render_readme(root) files['runtime.py'] = ( root / 'integrations/shared/runtime.py' ).read_bytes() files['runtime_logging.py'] = ( source / 'runtime_logging.py' ).read_bytes() files['verify_package.py'] = ( root / 'scripts/verify_astrbot.py' ).read_bytes() # JSON是YAML的子集;标准库即可生成AstrBot可读的metadata.yaml。 files['metadata.yaml'] = (json.dumps({ 'name': ROOT_NAME, 'display_name': '农场', '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'): content = host_logging(content) files[name] = content ast.parse(content, filename=name, feature_version=(3, 10)) manifest = { 'schema': 1, 'target': 'astrbot', '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()): target = output / 'repository' / name target.parent.mkdir(parents=True, exist_ok=True) target.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) if archive_path.stat().st_size > 16_000_000: raise ValueError('AstrBot plugin ZIP exceeds 16 MB') 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) parser.add_argument('--help-image', type=Path) args = parser.parse_args() print(build(Path(__file__).resolve().parents[1], args.output, help_image=args.help_image)) if __name__ == '__main__': main()