Commit dbb9205

benny-dou <60535774+benny-dou@users.noreply.github.com>
2026-04-27 10:26:39
feat(update): add `/update` command to update bot in container
1 parent 5a65136
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",