From 97e87c7ec3cee3fca38497422e0cbc0a669b5e7e Mon Sep 17 00:00:00 2001 From: passthem Date: Wed, 18 Mar 2026 17:26:36 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20Typst=20=E7=9A=84?= =?UTF-8?q?=E4=BA=8C=E8=BF=9B=E5=88=B6=E6=96=87=E4=BB=B6=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- konabot/common/artifact.py | 51 ++++++++++++---- konabot/plugins/typst/__init__.py | 97 +++++++++++++++++++++++++++++-- 2 files changed, 133 insertions(+), 15 deletions(-) diff --git a/konabot/common/artifact.py b/konabot/common/artifact.py index 2c4453b..3f70f61 100644 --- a/konabot/common/artifact.py +++ b/konabot/common/artifact.py @@ -1,9 +1,10 @@ import asyncio +from typing import Any, Awaitable, Callable import aiohttp import hashlib import platform -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path import nonebot @@ -14,6 +15,8 @@ from pydantic import BaseModel @dataclass class ArtifactDepends: + _Callback = Callable[[bool], Awaitable[Any]] + url: str sha256: str target: Path @@ -27,6 +30,9 @@ class ArtifactDepends: use_proxy: bool = True "网络问题,赫赫;使用的是 Discord 模块配置的 proxy" + callbacks: list[_Callback] = field(default_factory=list) + "在任务完成以后,应该做的事情" + def is_corresponding_platform(self) -> bool: if self.required_os is not None: if self.required_os.lower() != platform.system().lower(): @@ -36,26 +42,43 @@ class ArtifactDepends: return False return True + def on_finished(self, task: _Callback) -> _Callback: + self.callbacks.append(task) + return task + + async def _finished(self, downloaded: bool) -> list[Any | BaseException]: + tasks = set() + for f in self.callbacks: + tasks.add(f(downloaded)) + return await asyncio.gather(*tasks, return_exceptions=True) + class Config(BaseModel): prefetch_artifact: bool = False "是否提前下载好二进制依赖" -artifact_list = [] +artifact_list: list[ArtifactDepends] = [] driver = nonebot.get_driver() config = nonebot.get_plugin_config(Config) + @driver.on_startup async def _(): if config.prefetch_artifact: logger.info("启动检测中:正在检测需求的二进制是否下载") semaphore = asyncio.Semaphore(10) + async def _task(artifact: ArtifactDepends): async with semaphore: - await ensure_artifact(artifact) + downloaded = await ensure_artifact(artifact) + result = await artifact._finished(downloaded) + for r in result: + if isinstance(r, BaseException): + logger.warning("完成了二进制文件的下载,但是有未捕捉的错误") + logger.exception(r) tasks: set[asyncio.Task] = set() for a in artifact_list: @@ -78,35 +101,43 @@ async def download_artifact(artifact: ArtifactDepends): async with aiohttp.ClientSession(proxy=proxy) as client: result = await client.get(artifact.url) if result.status != 200: - logger.warning(f"已经下载了二进制,但是注意服务器没有返回 200! URL={artifact.url} TARGET={artifact.target} CODE={result.status}") + logger.warning( + f"已经下载了二进制,但是注意服务器没有返回 200! URL={artifact.url} TARGET={artifact.target} CODE={result.status}" + ) data = await result.read() artifact.target.write_bytes(data) - if not platform.system().lower() == 'windows': + if not platform.system().lower() == "windows": artifact.target.chmod(0o755) logger.info(f"下载好了 TARGET={artifact.target} URL={artifact.url}") m = hashlib.sha256(artifact.target.read_bytes()) if m.hexdigest().lower() != artifact.sha256.lower(): - logger.warning(f"下载到的二进制的 sha256 与需求不同 TARGET={artifact.target} REQUESTED={artifact.sha256} ACTUAL={m.hexdigest()}") + logger.warning( + f"下载到的二进制的 sha256 与需求不同 TARGET={artifact.target} REQUESTED={artifact.sha256} ACTUAL={m.hexdigest()}" + ) -async def ensure_artifact(artifact: ArtifactDepends): +async def ensure_artifact(artifact: ArtifactDepends) -> bool: if not artifact.is_corresponding_platform(): - return + return False if not artifact.target.exists(): logger.info(f"二进制依赖 {artifact.target} 不存在") if not artifact.target.parent.exists(): artifact.target.parent.mkdir(parents=True, exist_ok=True) await download_artifact(artifact) + return True else: m = hashlib.sha256(artifact.target.read_bytes()) if m.hexdigest().lower() != artifact.sha256.lower(): - logger.info(f"二进制依赖 {artifact.target} 的哈希无法对应需求的哈希,准备重新下载") + logger.info( + f"二进制依赖 {artifact.target} 的哈希无法对应需求的哈希,准备重新下载" + ) artifact.target.unlink() await download_artifact(artifact) + return True + return False def register_artifacts(*artifacts: ArtifactDepends): artifact_list.extend(artifacts) - diff --git a/konabot/plugins/typst/__init__.py b/konabot/plugins/typst/__init__.py index 86d156a..5f47aeb 100644 --- a/konabot/plugins/typst/__init__.py +++ b/konabot/plugins/typst/__init__.py @@ -1,9 +1,11 @@ import asyncio +import os import subprocess from pathlib import Path from tempfile import TemporaryDirectory from typing import cast +import zipfile from loguru import logger from nonebot import on_command @@ -13,22 +15,97 @@ from nonebot.adapters.onebot.v11.event import MessageEvent as OB11MessageEvent from nonebot.adapters.onebot.v11.bot import Bot as OB11Bot from nonebot.adapters.onebot.v11.message import Message as OB11Message +from konabot.common.artifact import ArtifactDepends, register_artifacts from konabot.common.longtask import DepLongTaskTarget -from konabot.common.path import TMP_PATH +from konabot.common.path import BINARY_PATH, TMP_PATH + + +arti_typst_linux = ArtifactDepends( + url="https://github.com/typst/typst/releases/download/v0.14.2/typst-x86_64-unknown-linux-musl.tar.xz", + sha256="a6044cbad2a954deb921167e257e120ac0a16b20339ec01121194ff9d394996d", + target=BINARY_PATH / "typst.tar.xz", + required_os="Linux", + required_arch="x86_64", +) +arti_typst_windows = ArtifactDepends( + url="https://github.com/typst/typst/releases/download/v0.14.2/typst-x86_64-pc-windows-msvc.zip", + sha256="51353994ac83218c3497052e89b2c432c53b9d4439cdc1b361e2ea4798ebfc13", + target=BINARY_PATH / "typst.zip", + required_os="Windows", + required_arch="AMD64", +) + + +bin_path: Path | None = None + + +@arti_typst_linux.on_finished +async def _(downloaded: bool): + global bin_path + + tar_path = arti_typst_linux.target + bin_path = tar_path.with_name("typst") + + if downloaded or not bin_path.exists(): + bin_path.unlink(missing_ok=True) + process = await asyncio.create_subprocess_exec( + "tar", + "-xvf", + tar_path, + "--strip-components=1", + "-C", + BINARY_PATH, + "typst-x86_64-unknown-linux-musl/typst", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + if process.returncode != 0 or not bin_path.exists(): + logger.warning( + "似乎没有成功解压 Typst 二进制文件,检查一下吧! " + f"stdout={stdout} stderr={stderr}" + ) + else: + os.chmod(bin_path, 0o755) + + +@arti_typst_windows.on_finished +async def _(downloaded: bool): + global bin_path + zip_path = arti_typst_windows.target + bin_path = BINARY_PATH / "typst.exe" + + if downloaded or not bin_path.exists(): + bin_path.unlink(missing_ok=True) + with zipfile.ZipFile(zip_path, "r") as zf: + target_name = "typst-x86_64-pc-windows-msvc/typst.exe" + if target_name not in zf.namelist(): + logger.warning("在 Zip 压缩包里面没有找到目标文件") + return + zf.extract(target_name, BINARY_PATH) + (BINARY_PATH / target_name).rename(bin_path) + (BINARY_PATH / "typst-x86_64-pc-windows-msvc").rmdir() + + +register_artifacts(arti_typst_linux) +register_artifacts(arti_typst_windows) TEMPLATE_PATH = Path(__file__).parent / "template.typ" TEMPLATE = TEMPLATE_PATH.read_text() -def render_sync(code: str) -> bytes: +def render_sync(code: str) -> bytes | None: + if bin_path is None: + return + with TemporaryDirectory(dir=TMP_PATH) as tmpdirname: temp_dir = Path(tmpdirname).resolve() temp_typ = temp_dir / "page.typ" temp_typ.write_text(TEMPLATE + "\n\n" + code) cmd = [ - "typst", + bin_path, "compile", temp_typ.name, "--format", @@ -61,7 +138,7 @@ def render_sync(code: str) -> bytes: return result_png.read_bytes() -async def render(code: str) -> bytes: +async def render(code: str) -> bytes | None: task = asyncio.to_thread(lambda: render_sync(code)) return await task @@ -70,7 +147,15 @@ cmd = on_command("typst") @cmd.handle() -async def _(evt: Event, bot: Bot, msg: UniMsg, target: DepLongTaskTarget): +async def _( + evt: Event, + bot: Bot, + msg: UniMsg, + target: DepLongTaskTarget, +): + if bin_path is None: + return + typst_code = "" if isinstance(evt, OB11MessageEvent): if evt.reply is not None: @@ -92,6 +177,8 @@ async def _(evt: Event, bot: Bot, msg: UniMsg, target: DepLongTaskTarget): try: res = await render(typst_code) + if res is None: + raise FileNotFoundError("没有渲染出来内容") except FileNotFoundError as e: await target.send_message("渲染出错:内部错误") raise e from e From 6a2fe11753d2af58e4a1bccbe894ba49fc8e005a Mon Sep 17 00:00:00 2001 From: passthem Date: Wed, 18 Mar 2026 17:29:34 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E5=9C=A8=20Dockerfile=20=E9=87=8C=E9=9D=A2=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index ea01981..9fe869f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,3 @@ -FROM alpine:latest AS artifacts - -RUN apk add --no-cache curl xz -WORKDIR /tmp - -RUN mkdir -p /artifacts -RUN curl -L -o typst.tar.xz "https://github.com/typst/typst/releases/download/v0.14.2/typst-x86_64-unknown-linux-musl.tar.xz" \ - && tar -xJf typst.tar.xz \ - && mv typst-x86_64-unknown-linux-musl/typst /artifacts - -RUN chmod -R +x /artifacts/ - - FROM python:3.13-slim AS base ENV VIRTUAL_ENV=/app/.venv \ @@ -51,7 +38,6 @@ RUN uv sync --no-install-project FROM base AS runtime COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} -COPY --from=artifacts /artifacts/ /usr/local/bin/ WORKDIR /app From fef9041a974e7fbfa7e633b3531f17d9d462add6 Mon Sep 17 00:00:00 2001 From: passthem Date: Wed, 18 Mar 2026 17:53:45 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E5=9C=A8=E6=9C=AC=E5=9C=B0=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E7=8E=AF=E5=A2=83=E4=BF=9D=E8=AF=81=20typst=20?= =?UTF-8?q?=E4=BA=8C=E8=BF=9B=E5=88=B6=E5=AD=98=E5=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- konabot/plugins/typst/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/konabot/plugins/typst/__init__.py b/konabot/plugins/typst/__init__.py index 5f47aeb..a45d811 100644 --- a/konabot/plugins/typst/__init__.py +++ b/konabot/plugins/typst/__init__.py @@ -15,7 +15,7 @@ from nonebot.adapters.onebot.v11.event import MessageEvent as OB11MessageEvent from nonebot.adapters.onebot.v11.bot import Bot as OB11Bot from nonebot.adapters.onebot.v11.message import Message as OB11Message -from konabot.common.artifact import ArtifactDepends, register_artifacts +from konabot.common.artifact import ArtifactDepends, ensure_artifact, register_artifacts from konabot.common.longtask import DepLongTaskTarget from konabot.common.path import BINARY_PATH, TMP_PATH @@ -153,6 +153,9 @@ async def _( msg: UniMsg, target: DepLongTaskTarget, ): + # 对于本地机器,一般不会在应用启动时自动下载,这里再保证存在 + await ensure_artifact(arti_typst_linux) + await ensure_artifact(arti_typst_windows) if bin_path is None: return From 8725f28caf26e44e729023fd9403fe40731b51f3 Mon Sep 17 00:00:00 2001 From: passthem Date: Thu, 19 Mar 2026 00:08:49 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E6=9B=B4=E6=94=B9=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- konabot/plugins/typst/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/konabot/plugins/typst/__init__.py b/konabot/plugins/typst/__init__.py index a45d811..4de27e7 100644 --- a/konabot/plugins/typst/__init__.py +++ b/konabot/plugins/typst/__init__.py @@ -44,7 +44,7 @@ async def _(downloaded: bool): global bin_path tar_path = arti_typst_linux.target - bin_path = tar_path.with_name("typst") + bin_path = BINARY_PATH / "typst" if downloaded or not bin_path.exists(): bin_path.unlink(missing_ok=True) @@ -96,6 +96,8 @@ TEMPLATE = TEMPLATE_PATH.read_text() def render_sync(code: str) -> bytes | None: + global bin_path + if bin_path is None: return @@ -153,10 +155,13 @@ async def _( msg: UniMsg, target: DepLongTaskTarget, ): + global bin_path + # 对于本地机器,一般不会在应用启动时自动下载,这里再保证存在 await ensure_artifact(arti_typst_linux) await ensure_artifact(arti_typst_windows) - if bin_path is None: + if bin_path is None or not bin_path.exists(): + logger.warning("当前环境不存在 Typst,但仍然调用了") return typst_code = ""