feat(cherry-tomato): 新增图片混淆/解混淆插件(小番茄)

Gilbert 曲线像素重排,可与网页版互解;`混淆` / `解混淆` 命令
(回复图片或图文同条消息发送),附 tests/test_cherry_tomato.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
sansenhoshiandClaude Code committed 2026-10-13 17:53:27 +08:00
1 parent 968b3d1cfe
commit fb15b976aa
9 files changed
+862

No files matched your search

@@ -0,0 +1,3 @@
"""小番茄图片混淆 —— Service 层。"""
from .pipeline import download_image, run_pipeline # noqa: F401
@@ -0,0 +1,69 @@
"""小番茄图片混淆 —— 编排(Service 层)。无 nonebot 依赖:下载 / 解码 / 变换 / 编码。"""
from __future__ import annotations
import asyncio
import io
import httpx
from PIL import Image
from ..utils.obfuscation import obfuscate, to_jpeg_bytes
async def download_image(url: str) -> bytes | None:
"""下载图片;失败(超时/404/断链)返回 None,由调用方决定提示与重试。"""
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(url)
resp.raise_for_status()
return resp.content
except Exception: # noqa: BLE001 - 网络失败一律走 None,交给上层提示
return None
async def run_pipeline(
image_bytes: bytes,
*,
decrypt: bool,
max_pixels: int,
quality: int,
) -> tuple[bytes | None, str | None, list[str]]:
"""PIL 解码 → 混淆/解混淆(线程池) → JPEG 编码。
max_pixels 语义随方向不同:
- 加密:超过则等比缩小(与网页版一致,自洽即可);
- 解密:不能缩放(缩了就永远解不出),超过直接拒绝。
Returns: (jpeg_bytes, err, notes) —— err 非空时 bytes 为 None;
notes 是给用户的附加提示(已压缩 / 动图仅首帧)。
"""
try:
img = Image.open(io.BytesIO(image_bytes))
img.load() # 提前解码,截断/坏文件在这里就暴露
except Exception: # noqa: BLE001
return None, "打不开这张图,可能不是有效的图片文件", []
width, height = img.size
if decrypt and width * height > max_pixels:
return (
None,
f"图片太大({width}x{height}),解混淆不能缩放"
f"(上限 {max_pixels / 1_000_000:.0f}MP),先裁剪或压小再试",
[],
)
try:
out, scaled, extra_frames = await asyncio.to_thread(
obfuscate, img, decrypt=decrypt, max_pixels=max_pixels
)
data = await asyncio.to_thread(to_jpeg_bytes, out, quality)
except Exception: # noqa: BLE001
return None, "处理这张图失败了,换一张再试试", []
notes: list[str] = []
if scaled:
notes.append(f"图片超过 {max_pixels / 1_000_000:.0f}MP,已等比缩小后再混淆")
if extra_frames:
notes.append("这是动图,只处理了第一帧")
return data, None, notes