Commit dbb9205
Changed files (4)
docker/base.Dockerfile
@@ -2,11 +2,12 @@ FROM ghcr.io/benny-dou/ffmpeg:latest@sha256:5fce806515a3868baabacac5a55ff3d39a40
FROM shinsenter/s6-overlay:latest@sha256:966c806bcf0cdfcf70c0bb17a77dbfc04662fa217a8aa4a7e984df807b45edc7 AS s6
FROM python:3.13.12-slim@sha256:739e7213785e88c0f702dcdc12c0973afcbd606dbf021a589cab77d6b00b579d AS python
FROM denoland/deno:bin@sha256:6dd27a6c41ae66edf209e2f9981278b4f3510b9f6c0cdc1fd4511e08a7ec567d AS deno
+FROM ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a AS uv
FROM python AS venv
RUN --mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
- --mount=from=ghcr.io/astral-sh/uv:latest,source=/uv,target=/bin/uv \
+ --mount=from=uv,source=/uv,target=/bin/uv \
apt-get update && \
apt-get install -y --no-install-recommends git build-essential && \
uv venv --relocatable --no-python-downloads --no-cache && \
@@ -14,6 +15,7 @@ RUN --mount=type=bind,source=uv.lock,target=uv.lock \
COPY --from=ffmpeg /ffmpeg /.venv/bin/ffmpeg
COPY --from=ffmpeg /ffprobe /.venv/bin/ffprobe
COPY --from=deno /deno /.venv/bin/deno
+COPY --from=uv /uv /.venv/bin/uv
FROM python
COPY --link --from=s6 / /
@@ -22,12 +24,14 @@ COPY --link --from=s6 / /
COPY --from=venv /.venv /venv
RUN apt-get update && \
- apt-get install -y --no-install-recommends libmagic1 cron aria2 && \
+ apt-get install -y --no-install-recommends libmagic1 cron aria2 git gh && \
groupadd --gid 1000 abc && \
useradd -u 1000 -g 1000 --create-home -d /app -s /bin/false abc && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
-ENV PATH=/command:/venv/bin:$PATH
+ENV UV_PROJECT_ENVIRONMENT=/venv \
+ PATH=/command:/venv/bin:$PATH
+
# important: sets s6-overlay entrypoint
ENTRYPOINT ["/init"]
src/messages/main.py
@@ -30,7 +30,7 @@ from others.ffmpeg import ffmpeg_cut, ffmpeg_h264, ffprobe
from others.search_google import search_google
from others.search_ytb import search_youtube
from others.tmdb import search_tmdb
-from others.version import get_bot_version
+from others.version import get_bot_version, update_bot
from others.watermark import add_watermark
from preview.arxiv import preview_arxiv
from preview.bilibili import preview_bilibili
@@ -147,6 +147,7 @@ async def process_message(
await add_watermark(client, message, **kwargs)
if version:
await get_bot_version(client, message, **kwargs)
+ await update_bot(message)
await show_msg_info(client, message, **kwargs)
await preview_social_media(client, message, **kwargs)
src/others/version.py
@@ -4,14 +4,21 @@ import contextlib
import importlib.metadata
import os
import platform
+import shutil
+import subprocess
+from pathlib import Path
from ffmpeg.asyncio import FFmpeg
+from glom import glom
+from loguru import logger
from pyrogram.client import Client
from pyrogram.types import Message
-from config import PREFIX
+from config import DOWNLOAD_DIR, PREFIX, TID, TOKEN
+from messages.progress import modify_progress
from messages.sender import send2tg
from messages.utils import blockquote, startswith_prefix
+from utils import rand_string, strings_list
async def get_bot_version(client: Client, message: Message, **kwargs):
@@ -43,3 +50,133 @@ async def get_ffmpeg_version() -> str:
lines = res.decode("utf-8").splitlines()
return lines[0].split(" ")[2]
return ""
+
+
+async def update_bot(message: Message):
+ """Update this bot."""
+ if message.text != "/update":
+ return
+ if glom(message, "chat.type.name", default="") not in ["PRIVATE", "BOT"]:
+ return
+ handle = glom(message, "from_user.username", default="")
+ uid = glom(message, "from_user.id", default="")
+ admins = [s.lower().removeprefix("@") for s in strings_list(TID.ADMIN)]
+ if str(handle) not in admins and str(uid) not in admins:
+ return
+ if not (Path("/.dockerenv").exists() or Path("/run/.containerenv").exists()):
+ logger.info("Not in container, skip update.")
+ return
+ status = await message.reply_text(text="⌛️开始更新")
+
+ async def run(cmd: list[str]) -> bool:
+ """Run a command."""
+ env = os.environ.copy()
+ env["GH_TOKEN"] = TOKEN.GITHUB
+ env["GH_CONFIG_DIR"] = DOWNLOAD_DIR # must be writable
+ try:
+ result = subprocess.run( # noqa: ASYNC221, S603
+ cmd,
+ env=env,
+ check=True, # 若命令执行失败(返回码非0),抛出 CalledProcessError
+ capture_output=True,
+ text=True, # 输出以字符串形式返回
+ )
+ logger.info(f"{result.stdout}\n{result.stderr}".strip())
+ except subprocess.CalledProcessError as e:
+ logger.error(f"❌升级失败,错误码:{e.returncode}, 错误信息:{e.stderr}")
+ await modify_progress(status, text=f"❌升级失败,错误码:{e.returncode}, 错误信息:{e.stderr}", force_update=True)
+ return False
+ except Exception as e:
+ logger.error(f"❌升级失败\n{e}")
+ await modify_progress(status, text=f"❌升级失败\n{e}", force_update=True)
+ return False
+ return True
+
+ Path(DOWNLOAD_DIR).mkdir(parents=True, exist_ok=True)
+ root = Path(DOWNLOAD_DIR).joinpath(rand_string(10)).as_posix()
+
+ git_clone_cmd = ["gh", "repo", "clone", "https://github.com/benny-dou/bennybot", root]
+ if not await run(git_clone_cmd):
+ return
+
+ uv_sync_cmd = [
+ "uv",
+ "sync",
+ "--frozen",
+ "--no-dev",
+ "--no-python-downloads",
+ "--no-install-project",
+ "--no-cache",
+ "--no-editable",
+ "--project",
+ root,
+ "--directory",
+ root,
+ "--python",
+ "/venv/bin/python",
+ ]
+ if not await run(uv_sync_cmd):
+ return
+ sync_dir(Path(root).joinpath("src"), "/app")
+ await modify_progress(status, text="✅升级成功\n重启Bot,请稍后...", force_update=True)
+ os._exit(0) # restarting is managed by s6-overlay
+
+
+def sync_dir(src: str | Path, dst: str | Path):
+ """类似 rsync --delete 的同步操作.
+
+ 1. 将 src 中的所有文件移动到 dst(保留目录结构)
+ 2. 删除 dst 中存在但 src 中不存在的文件和目录
+
+ Args:
+ src: 源目录(字符串或 Path 对象)
+ dst: 目标目录(字符串或 Path 对象)
+ """
+ src_root = Path(src)
+ dst_root = Path(dst)
+ files = set() # 存储应保留的所有文件路径
+ # --------------------------
+ # 第一步:移动 src 的文件到 dst(保留层级结构)
+ # --------------------------
+ for src_file in src_root.rglob("*"):
+ if src_file.is_file():
+ # 计算相对路径,例如 src/a/b.txt -> a/b.txt
+ rel_path = src_file.relative_to(src_root)
+ # 拼接目标路径:dst / a/b.txt
+ dst_file = dst_root / rel_path
+
+ # 确保目标文件的父目录存在(parents=True 表示递归创建)
+ dst_file.parent.mkdir(parents=True, exist_ok=True)
+
+ # 移动文件(同文件系统下为原子重命名,自动覆盖)
+ files.add(dst_file.as_posix())
+ shutil.move(src_file, dst_file)
+
+ # --------------------------
+ # 第二步:删除 dst 中多余的文件和目录
+ # --------------------------
+ for dst_file in dst_root.rglob("*"):
+ fname = dst_file.as_posix()
+ if dst_file.is_file() and fname not in files and not fname.startswith(DOWNLOAD_DIR):
+ dst_file.unlink()
+ del_empty_dir(dst_root)
+ shutil.rmtree(src_root)
+
+
+def del_empty_dir(root: Path):
+ """Delete empty directory."""
+ # 递归遍历:先处理所有子目录(深度优先)
+ # 只有先把子目录删干净,才能判断父目录是否为空
+ for child in root.iterdir():
+ if child.is_dir():
+ del_empty_dir(child)
+
+ # 检查并删除:此时子目录已处理完,检查当前目录是否为空
+ # 使用 next(..., None) 检查是否有内容,比 list(root.iterdir()) 更高效
+ if next(root.iterdir(), None) is None:
+ try:
+ root.rmdir() # rmdir 只能删除空目录,非常安全
+ except PermissionError:
+ logger.error(f"[跳过] 权限不足: {root}")
+ except Exception as e:
+ logger.error(f"[错误] 无法删除 {root}: {e}")
pyproject.toml
@@ -71,6 +71,7 @@ exclude = ["src/quotly/fonts.py"]
ignore = [
"ANN",
"PTH",
+ "RUF",
"D417",
"BLE001",
"T20",