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
+3 -2
View File
@@ -42,11 +42,12 @@ class ByteLRU:
class Assets:
def __init__(self, cache_bytes=32 * 1024 * 1024):
def __init__(self, cache_bytes=32 * 1024 * 1024, bundle_path=None):
data = resources.files(__package__).joinpath("data")
self.metadata = json.loads(data.joinpath("metadata.json").read_bytes())
self.cache = ByteLRU(cache_bytes)
self._bundle = data.joinpath("assets.zip")
self._bundle = (bundle_path if bundle_path is not None
else data.joinpath('assets.zip'))
def image(self, name, size=None):
if name not in self.metadata["images"]:
+120
View File
@@ -0,0 +1,120 @@
"""固定摘要的外部素材缓存;下载后校验,原子替换,不解压到磁盘。"""
import hashlib
from importlib import resources
import json
import logging
from pathlib import Path
import tempfile
import threading
import time
from urllib.parse import urlsplit
from urllib.request import Request, urlopen
from zipfile import ZipFile
logger = logging.getLogger(__name__)
_LOCK = threading.Lock()
SOURCES = ('Github', 'Gitea', 'farm_service')
MAX_BYTES = 64 * 1024 * 1024
def expected_manifest():
return json.loads(resources.files(__package__).joinpath(
'data', 'asset-manifest.json',
).read_bytes())
def validate_options(source, service_url):
if source not in SOURCES:
raise ValueError('Unknown resource source')
if service_url:
checked_url(service_url)
if source == 'farm_service' and not service_url:
raise ValueError('farm_service address required')
def checked_url(url):
parsed = urlsplit(url)
if (parsed.scheme != 'https' or not parsed.hostname or parsed.username
or parsed.password or parsed.query or parsed.fragment):
raise ValueError('Resource URL requires public HTTPS without credentials')
return url
def _copy_remote(url, destination, limit):
request = Request(checked_url(url), headers={'User-Agent': 'Farm-assets/1'})
started = time.monotonic()
with urlopen(request, timeout=15) as response:
checked_url(response.geturl())
length = response.headers.get('Content-Length')
if length is not None and int(length) > limit:
raise ValueError('Resource download exceeds limit')
total = 0
while True:
chunk = response.read(min(65536, limit-total+1))
if not chunk:
break
total += len(chunk)
if total > limit or time.monotonic()-started > 60:
raise ValueError('Resource download limit or timeout')
destination.write(chunk)
def verify_bundle(path, manifest):
path = Path(path)
if (not path.is_file() or path.stat().st_size != manifest['size_bytes']
or not 0 < manifest['size_bytes'] <= MAX_BYTES):
raise ValueError('Resource size mismatch')
digest = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(65536), b''):
digest.update(chunk)
if digest.hexdigest() != manifest['sha256']:
raise ValueError('Resource SHA256 mismatch')
metadata = json.loads(resources.files(__package__).joinpath(
'data', 'metadata.json',
).read_bytes())
with ZipFile(path) as archive:
entries = archive.infolist()
names = [item.filename for item in entries]
if (len(names) != len(set(names))
or set(names) != set(metadata['images'])
or sum(item.file_size for item in entries) > 256*1024*1024
or any(item.is_dir() or item.flag_bits & 1
for item in entries)):
raise ValueError('Resource contents mismatch')
return path
def ensure_assets(data_dir, source='Github', service_url=''):
"""每次启动校验缓存;仅缺失或不匹配时访问所选源。"""
validate_options(source, service_url)
expected = expected_manifest()
target = (Path(data_dir) / 'assets' / expected['sha256'] / 'assets.zip')
with _LOCK:
try:
return verify_bundle(target, expected)
except (OSError, ValueError):
pass
target.parent.mkdir(parents=True, exist_ok=True)
# 清单绑定在插件内;远端不能通过更换清单放行不兼容的素材。
if source == 'farm_service':
url = (service_url.rstrip('/') + '/v1/assets/'
+ expected['sha256'] + '/assets.zip')
else:
url = expected['sources'][source.lower()]
temporary = None
try:
with tempfile.NamedTemporaryFile(
dir=target.parent, suffix='.download', delete=False,
) as stream:
temporary = Path(stream.name)
_copy_remote(url, stream, expected['size_bytes'])
verify_bundle(temporary, expected)
temporary.replace(target)
logger.info('farm assets ready source=%s sha256=%s',
source, expected['sha256'])
return target
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
+11
View File
@@ -0,0 +1,11 @@
{
"schema": 1,
"version": "farm-assets-v1",
"sha256": "78d6e8e448fd5296b21d471b98ea7d58c81ef78a82b0e192a1ed041bb5fddad9",
"size_bytes": 21264561,
"sources": {
"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",
"farm_service": "https://farm.example/v1/assets/78d6e8e448fd5296b21d471b98ea7d58c81ef78a82b0e192a1ed041bb5fddad9/assets.zip"
}
}
+6 -6
View File
@@ -6484,9 +6484,9 @@
"contact_alpha_threshold": 32,
"contact_band_fraction": 0.2,
"grid_fit": {
"maximum": 9.082568081176591,
"maximum": 9.08256808,
"points": 30,
"rms": 4.178633329197882
"rms": 4.17863333
},
"growth_scale": 0.75,
"mode": "visible_contact_center",
@@ -6494,12 +6494,12 @@
"reference_soil_width": 153,
"scene_transform": [
[
1.467423053281315,
0.07509807118862355
1.46742305,
0.07509807
],
[
0.02736505575131576,
-1.4715307877291894
0.02736506,
-1.47153079
]
],
"schema": 4,
+114
View File
@@ -0,0 +1,114 @@
"""单张动态帮助长图:实测换行、固定字号及内容驱动高度。"""
from PIL import Image
from farm_core.commands import HELP_NOTES, help_groups
from .canvas import Canvas, font
from .models import RenderOptions
def wrap_text(value, font_path, size, width):
"""按实际字宽换行,不截断命令、别名或说明。"""
lines = []
for paragraph in value.split('\n'):
line = ''
for char in paragraph:
if line and Canvas.measure_text(
line + char, font_path, size,
) > width:
lines.append(line)
line = ''
line += char
lines.append(line)
return tuple(lines)
def help_layout(specs, font_path):
groups = []
for title, rows in help_groups(specs):
title_lines = wrap_text(title, font_path, 30, 452)
header = 26 + len(title_lines) * 42
prepared = []
height = header + 18
for usage, description in rows:
commands = wrap_text(usage, font_path, 28, 484)
notes = wrap_text(description, font_path, 23, 484)
row_height = len(commands)*40 + len(notes)*34 + 30
prepared.append((commands, notes, row_height))
height += row_height
groups.append((title_lines, header, tuple(prepared), height))
# 先分配较高分类,再恢复各列原有分类顺序,避免长列旁大块留白。
buckets = [[], []]
heights = [0, 0]
for index in sorted(range(len(groups)), key=lambda i: -groups[i][3]):
column = min(range(2), key=lambda i: heights[i])
buckets[column].append(index)
heights[column] += groups[index][3]+24
columns = tuple(tuple(groups[i] for i in sorted(bucket))
for bucket in buckets)
body_height = max(sum(g[3]+24 for g in col) for col in columns)
tips = tuple(line for note in HELP_NOTES for line in wrap_text(
note, font_path, 23, 1072,
))
return columns, body_height, tips
def render_help(renderer, specs, platform, image_format='AUTO', quality=75):
options = RenderOptions(width=1200, format=image_format, quality=quality)
columns, body_height, tips = help_layout(specs, renderer.font_path)
footer_y = 236 + body_height
canvas = Canvas.new((1200, footer_y + 90 + len(tips)*35 + 40), '#f5f3e6')
# 使用包内现有农场背景,仅作小面积页眉,不在正文后叠加纹理。
landscape = renderer.assets.image(
'background/decorateIcon/decorate_default_1.jpg',
)
crop = landscape.crop((0, 0, landscape.width, landscape.height//3))
crop = crop.resize((1200, 200), Image.Resampling.LANCZOS)
canvas.paste(crop, (0, 0))
canvas.rectangle((340, 32, 860, 160), '#915d32', radius=18)
canvas.rectangle((348, 38, 852, 154), '#f2d69b', radius=14)
def text(value, x, y, size=23, color='#504a3d'):
box = font(renderer.font_path, size).getbbox(value)
canvas.text((x, y-box[1]), value, renderer.font_path, size, color)
text('农场帮助', 478, 53, 52, '#382b19')
label = {'zhenxun': '真寻', 'nonebot': 'NoneBot', 'astrbot': 'AstrBot'}
subtitle = f'{label[platform]} · 常用指令与玩法'
subtitle_width = canvas.measure_text(subtitle, renderer.font_path, 23)
text(subtitle, round((1200-subtitle_width)/2), 118)
for column, x in zip(columns, (36, 612)):
y = 224
for title_lines, header, rows, height in column:
canvas.rectangle((x, y, x+552, y+height), '#d1d7bc', radius=16)
canvas.rectangle((x+1, y+1, x+551, y+height-1),
'#fffdf5', radius=15)
canvas.rectangle((x, y, x+552, y+header), '#4c7542', radius=15)
canvas.rectangle((x, y+header-15, x+552, y+header), '#4c7542')
# 小芽标记;不依赖 emoji 字体或生成图片内的文字。
canvas.rectangle((x+22, y+21, x+29, y+44), '#d7e8a0', radius=3)
canvas.rectangle((x+10, y+13, x+26, y+28), '#bad571', radius=7)
canvas.rectangle((x+28, y+9, x+46, y+24), '#e2ecac', radius=8)
for index, line in enumerate(title_lines):
text(line, x+62, y+16+index*42, 30, '#ffffff')
yy = y+header+18
for index, (commands, notes, row_height) in enumerate(rows):
for line in commands:
text(line, x+28, yy, 28, '#342c20')
text(line, x+29, yy, 28, '#342c20')
yy += 40
for line in notes:
text(line, x+28, yy+2)
yy += 34
yy += 30
if index+1 < len(rows):
canvas.rectangle((x+28, yy-15, x+524, yy-14), '#e4e3d3')
y += height+24
canvas.rectangle((36, footer_y, 1164, canvas.image.height-36),
'#e4ecd5', radius=16)
text('参数与玩法提示', 64, footer_y+20, 29, '#3f6437')
for index, line in enumerate(tips):
text(line, 64, footer_y+70+index*35)
return canvas.encode(options.codec(), options.quality, options.method)
+2 -1
View File
@@ -26,12 +26,13 @@ class FarmRenderer:
def __init__(
self, font_path, asset_cache_bytes=32 * 1024 * 1024,
result_cache_bytes=8 * 1024 * 1024,
bundle_path=None,
):
self.font_path = Path(font_path).resolve()
if not self.font_path.is_file():
raise ValueError("Configure an existing Chinese font path")
font(self.font_path, 16)
self.assets = Assets(asset_cache_bytes)
self.assets = Assets(asset_cache_bytes, bundle_path=bundle_path)
self.results = ByteLRU(result_cache_bytes)
def scene(self, identity, snapshot, now):
+28
View File
@@ -9,6 +9,7 @@ import hashlib
from .shop import render_shop
from .details import render_details
from .warehouse import render_warehouse
from .help import render_help
logger = logging.getLogger(__name__)
@@ -147,6 +148,33 @@ class RenderService:
self.renderer.results.put(key, content, len(content))
return content
async def help_menu(self, specs, platform, image_format='AUTO', quality=75):
if self._closed:
raise RuntimeError('Render service closed')
key = ('help', specs, platform, image_format, quality)
cached = self.renderer.results.get(key)
if cached is not None:
return cached
task = self._pending.get(key)
if task is None:
if len(self._pending) >= self._max_pending:
raise RuntimeError('Render queue full')
task = asyncio.create_task(self._run_function(
render_help, self.renderer, specs, platform,
image_format, quality,
))
self._pending[key] = task
def finished(done):
self._pending.pop(key, None)
if not done.cancelled():
done.exception()
task.add_done_callback(finished)
content = await asyncio.shield(task)
self.renderer.results.put(key, content, len(content))
return content
async def _run_function(self, function, *args):
started = perf_counter()
async with self._semaphore: