Compare commits

...

9 Commits

Author SHA1 Message Date
157236d9a6 Merge branch 'master' into enhancement/typst-binary 2026-03-21 20:13:34 +08:00
8725f28caf 更改文件 2026-03-19 00:08:49 +08:00
51c0bf4229 Merge pull request 'fix: support empty string literals in textfx' (#63) from pi-agent/konabot:fix/textfx-empty-string into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #63
2026-03-18 19:41:34 +08:00
5b1c6d446c fix: remove redundant whitespace check; echo no-arg outputs empty line 2026-03-18 19:40:02 +08:00
717b7a95e8 fix: echo should not read stdin (Unix semantics) 2026-03-18 19:30:55 +08:00
9bac2b8cdf fix: support empty string literals in textfx
- Fix tokenizer to emit empty string token when closing quote on empty buffer
- Add force parameter to flush_word() to handle empty quoted strings
- Add test case for echo "" and echo ''
2026-03-18 19:23:42 +08:00
fef9041a97 在本地开发环境保证 typst 二进制存在 2026-03-18 17:53:45 +08:00
6a2fe11753 不再直接在 Dockerfile 里面下载构建产物 2026-03-18 17:29:34 +08:00
97e87c7ec3 添加 Typst 的二进制文件下载 2026-03-18 17:26:36 +08:00
6 changed files with 165 additions and 36 deletions

View File

@ -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

View File

@ -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)

View File

@ -161,9 +161,9 @@ class PipelineRunner:
"'": "'",
}
def flush_word():
def flush_word(force: bool = False):
nonlocal buf
if buf:
if buf or force:
tokens.append(Token(TokenKind.WORD, buf))
buf = ""
@ -178,6 +178,7 @@ class PipelineRunner:
escape = True
elif c == quote:
quote = None
flush_word(force=True) # 引号闭合时强制 flush即使是空字符串
else:
buf += c
i += 1
@ -188,7 +189,7 @@ class PipelineRunner:
i += 1
continue
if c.isspace() or c in "":
if c.isspace():
flush_word()
i += 1
continue

View File

@ -13,10 +13,8 @@ class THEcho(TextHandler):
async def handle(
self, env: TextHandlerEnvironment, istream: str | None, args: list[str]
) -> TextHandleResult:
if len(args) == 0 and istream is None:
return TextHandleResult(1, "请在 echo 后面添加需要输出的文本")
if istream is not None:
return TextHandleResult(0, "\n".join([istream] + args))
# echo 不读 stdin只输出参数Unix 语义)
# 无参数时输出空行(与 Unix echo 行为一致)
return TextHandleResult(0, "\n".join(args))

View File

@ -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,99 @@ 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, ensure_artifact, 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 = BINARY_PATH / "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:
global bin_path
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 +140,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 +149,21 @@ 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,
):
global bin_path
# 对于本地机器,一般不会在应用启动时自动下载,这里再保证存在
await ensure_artifact(arti_typst_linux)
await ensure_artifact(arti_typst_windows)
if bin_path is None or not bin_path.exists():
logger.warning("当前环境不存在 Typst但仍然调用了")
return
typst_code = ""
if isinstance(evt, OB11MessageEvent):
if evt.reply is not None:
@ -92,6 +185,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

View File

@ -205,3 +205,21 @@ async def test_while_body_can_use_if(runner: PipelineRunner):
assert not isinstance(parsed, str)
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
assert results[0].code == 1
@pytest.mark.asyncio
async def test_echo_empty_string(runner: PipelineRunner):
"""测试 echo 空字符串"""
# 双引号空字符串
parsed = runner.parse_pipeline('echo ""')
assert not isinstance(parsed, str)
results = await runner.run_pipeline(parsed, None, TextHandlerEnvironment(False))
assert results[0].code == 0
assert results[0].ostream == ''
# 单引号空字符串
parsed2 = runner.parse_pipeline("echo ''")
assert not isinstance(parsed2, str)
results2 = await runner.run_pipeline(parsed2, None, TextHandlerEnvironment(False))
assert results2[0].code == 0
assert results2[0].ostream == ''