From 2498e69e9ecc58a5803222826f25feb451907b3d Mon Sep 17 00:00:00 2001 From: dnslin Date: Thu, 11 Dec 2025 17:54:06 +0800 Subject: [PATCH] feat: init project --- .env.example | 10 ++ .gitignore | 15 +++ .python-version | 1 + CLAUDE.md | 48 ++++++++ README.md | 0 banner.txt | 11 ++ main.py | 5 + pyproject.toml | 13 +++ src/__init__.py | 1 + src/aria2/__init__.py | 5 + src/aria2/installer.py | 246 +++++++++++++++++++++++++++++++++++++++ src/aria2/service.py | 170 +++++++++++++++++++++++++++ src/core/__init__.py | 55 +++++++++ src/core/config.py | 42 +++++++ src/core/constants.py | 12 ++ src/core/exceptions.py | 29 +++++ src/core/system.py | 91 +++++++++++++++ src/telegram/__init__.py | 5 + src/telegram/app.py | 38 ++++++ src/telegram/handlers.py | 212 +++++++++++++++++++++++++++++++++ src/utils/__init__.py | 4 + src/utils/logger.py | 28 +++++ uv.lock | 106 +++++++++++++++++ 23 files changed, 1147 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 banner.txt create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 src/__init__.py create mode 100644 src/aria2/__init__.py create mode 100644 src/aria2/installer.py create mode 100644 src/aria2/service.py create mode 100644 src/core/__init__.py create mode 100644 src/core/config.py create mode 100644 src/core/constants.py create mode 100644 src/core/exceptions.py create mode 100644 src/core/system.py create mode 100644 src/telegram/__init__.py create mode 100644 src/telegram/app.py create mode 100644 src/telegram/handlers.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/logger.py create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..05b9edc --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Telegram Bot Token (required) +TELEGRAM_BOT_TOKEN= + +# Custom Telegram Bot API URL (optional, for self-hosted API) +TELEGRAM_API_BASE_URL= +# Aria2 RPC Port (default: 6800) +ARIA2_RPC_PORT=6800 + +# Aria2 RPC Secret (optional, auto-generated if empty) +ARIA2_RPC_SECRET= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c392ba8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv +.mcp.json +env/ +venv/ +.vscode/ +.env \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..20ced2f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +You must communicate in Chinese and logs and comments must also be in Chinese, in the use of third-party libraries if you are not clear on the use of api's please use context7 mcp to get the documentation. + +## Commands + +```bash +# Install dependencies +pip install -e . + +# Run the bot +python main.py +# or after install: +aria2bot +``` + +## Configuration + +Copy `.env.example` to `.env` and set: + +- `TELEGRAM_BOT_TOKEN` (required) - Bot token from @BotFather +- `TELEGRAM_API_BASE_URL` (optional) - Custom API endpoint for self-hosted bot API +- `ARIA2_RPC_PORT` (default: 6800) +- `ARIA2_RPC_SECRET` (optional, auto-generated) + +## Architecture + +Three-layer design: + +- `src/telegram/` - Bot interface (handlers.py defines commands, app.py runs polling) +- `src/aria2/` - aria2 management (installer.py downloads/configures, service.py manages systemd) +- `src/core/` - Shared utilities (constants, config dataclasses, exceptions, system detection) + +Flow: Telegram command → `Aria2BotAPI` handler → `Aria2Installer` or `Aria2ServiceManager` → system + +## Key Paths (defined in src/core/constants.py) + +- Binary: `~/.local/bin/aria2c` +- Config: `~/.config/aria2/aria2.conf` +- Service: `~/.config/systemd/user/aria2.service` +- Downloads: `~/downloads` + +## Bot Commands + +/install, /uninstall, /start, /stop, /restart, /status, /logs, /clear_logs, /help + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/banner.txt b/banner.txt new file mode 100644 index 0000000..ed7f89e --- /dev/null +++ b/banner.txt @@ -0,0 +1,11 @@ + ______ ___ ____ __ +/\ _ \ __ /'___`\ /\ _`\ /\ \__ +\ \ \L\ \ _ __ /\_\ __ /\_\ /\ \\ \ \L\ \ ___\ \ ,_\ + \ \ __ \/\`'__\/\ \ /'__`\ \/_/// /__\ \ _ <' / __`\ \ \/ + \ \ \/\ \ \ \/ \ \ \/\ \L\.\_ // /_\ \\ \ \L\ \/\ \L\ \ \ \_ + \ \_\ \_\ \_\ \ \_\ \__/.\_\/\______/ \ \____/\ \____/\ \__\ + \/_/\/_/\/_/ \/_/\/__/\/_/\/_____/ \/___/ \/___/ \/__/ + + +Aria2Bot V1.0 +Powered by python-telegram-bot \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..a0adaaa --- /dev/null +++ b/main.py @@ -0,0 +1,5 @@ +"""Aria2 Telegram Bot - Control aria2 via Telegram""" +from src.telegram import run + +if __name__ == "__main__": + run() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c10ea3f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "aria2bot" +version = "0.1.0" +description = "This is a bot that uses tg to control the server to operate aria2 to download files." +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "python-dotenv>=1.2.1", + "python-telegram-bot>=21.0", +] + +[project.scripts] +aria2bot = "main:main" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..35353c6 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +"""Aria2bot - Telegram bot for managing aria2 downloads.""" diff --git a/src/aria2/__init__.py b/src/aria2/__init__.py new file mode 100644 index 0000000..5aae1ef --- /dev/null +++ b/src/aria2/__init__.py @@ -0,0 +1,5 @@ +"""Aria2 operations module - installer and service management.""" +from src.aria2.installer import Aria2Installer +from src.aria2.service import Aria2ServiceManager + +__all__ = ["Aria2Installer", "Aria2ServiceManager"] diff --git a/src/aria2/installer.py b/src/aria2/installer.py new file mode 100644 index 0000000..5e114f0 --- /dev/null +++ b/src/aria2/installer.py @@ -0,0 +1,246 @@ +"""Aria2 installer - download, install, and configure aria2.""" +from __future__ import annotations + +import asyncio +import functools +import json +import shutil +import tarfile +import tempfile +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from urllib import error, request + +from src.core import ( + ARIA2_BIN, + ARIA2_CONFIG_DIR, + ARIA2_CONF, + ARIA2_LOG, + ARIA2_SESSION, + Aria2Config, + Aria2Error, + ConfigError, + DownloadError, + detect_arch, + detect_os, + generate_rpc_secret, + is_aria2_installed, +) + + +class Aria2Installer: + GITHUB_API = "https://api.github.com/repos/P3TERX/Aria2-Pro-Core/releases/latest" + GITHUB_MIRROR = "https://gh-api.p3terx.com/repos/P3TERX/Aria2-Pro-Core/releases/latest" + CONFIG_URLS = [ + "https://p3terx.github.io/aria2.conf", + "https://cdn.jsdelivr.net/gh/P3TERX/aria2.conf@master", + ] + CONFIG_FILES = ["aria2.conf", "script.conf", "dht.dat", "dht6.dat"] + + def __init__(self, config: Aria2Config | None = None): + self.config = config or Aria2Config() + self.os_type = detect_os() + self.arch = detect_arch() + self._executor = ThreadPoolExecutor(max_workers=4) + + async def get_latest_version(self) -> str: + """从 GitHub API 获取最新版本号""" + loop = asyncio.get_running_loop() + last_error: Exception | None = None + + for url in (self.GITHUB_API, self.GITHUB_MIRROR): + try: + data = await loop.run_in_executor( + self._executor, functools.partial(self._fetch_url, url) + ) + payload = json.loads(data.decode("utf-8")) + tag_name = payload.get("tag_name") + if not tag_name: + raise DownloadError("tag_name missing in GitHub API response") + return tag_name + except Exception as exc: # noqa: PERF203 + last_error = exc + continue + + raise DownloadError(f"Failed to fetch latest version: {last_error}") from last_error + + async def download_binary(self, version: str | None = None) -> Path: + """下载并解压 aria2 静态二进制到 ~/.local/bin/""" + resolved_version = version or await self.get_latest_version() + version_name = resolved_version.lstrip("v") + archive_name = f"aria2-{version_name}-static-linux-{self.arch}.tar.gz" + download_url = ( + f"https://github.com/P3TERX/Aria2-Pro-Core/releases/download/" + f"{resolved_version}/{archive_name}" + ) + + loop = asyncio.get_running_loop() + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_dir_path = Path(tmpdir) + archive_path = tmp_dir_path / archive_name + extract_dir = tmp_dir_path / "extract" + extract_dir.mkdir(parents=True, exist_ok=True) + + try: + data = await loop.run_in_executor( + self._executor, functools.partial(self._fetch_url, download_url) + ) + await loop.run_in_executor( + self._executor, functools.partial(self._write_file, archive_path, data) + ) + except Exception as exc: # noqa: PERF203 + raise DownloadError(f"Failed to download aria2 binary: {exc}") from exc + + try: + binary_path = await loop.run_in_executor( + self._executor, functools.partial(self._extract_binary, archive_path, extract_dir) + ) + except Exception as exc: # noqa: PERF203 + raise DownloadError(f"Failed to extract aria2 binary: {exc}") from exc + + try: + ARIA2_BIN.parent.mkdir(parents=True, exist_ok=True) + if ARIA2_BIN.exists(): + ARIA2_BIN.unlink() + shutil.move(str(binary_path), ARIA2_BIN) + ARIA2_BIN.chmod(0o755) + except Exception as exc: # noqa: PERF203 + raise DownloadError(f"Failed to install aria2 binary: {exc}") from exc + + return ARIA2_BIN + + async def download_config(self) -> None: + """下载配置模板文件""" + ARIA2_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + loop = asyncio.get_running_loop() + + for filename in self.CONFIG_FILES: + last_error: Exception | None = None + for base in self.CONFIG_URLS: + url = f"{base.rstrip('/')}/{filename}" + try: + data = await loop.run_in_executor( + self._executor, functools.partial(self._fetch_url, url) + ) + target = ARIA2_CONFIG_DIR / filename + await loop.run_in_executor( + self._executor, functools.partial(self._write_file, target, data) + ) + last_error = None + break + except Exception as exc: # noqa: PERF203 + last_error = exc + continue + if last_error is not None: + raise DownloadError(f"Failed to download {filename}: {last_error}") from last_error + + def render_config(self) -> None: + """渲染配置文件,注入用户参数""" + if not ARIA2_CONF.exists(): + raise ConfigError("Config template not found. Run download_config first.") + + try: + content = ARIA2_CONF.read_text(encoding="utf-8") + except OSError as exc: + raise ConfigError(f"Failed to read config: {exc}") from exc + + rpc_secret = self.config.rpc_secret or generate_rpc_secret() + self.config.rpc_secret = rpc_secret + + replacements = { + "dir=": str(self.config.download_dir), + "rpc-listen-port=": str(self.config.rpc_port), + "rpc-secret=": rpc_secret, + "max-concurrent-downloads=": str(self.config.max_concurrent_downloads), + "max-connection-per-server=": str(self.config.max_connection_per_server), + } + + new_lines: list[str] = [] + for line in content.splitlines(): + stripped = line.lstrip() + replaced = False + for key, value in replacements.items(): + if stripped.startswith(key): + prefix = line[: len(line) - len(stripped)] + new_lines.append(f"{prefix}{key}{value}") + replaced = True + break + if not replaced: + new_lines.append(line) + + try: + ARIA2_CONF.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + ARIA2_SESSION.touch(exist_ok=True) + self.config.download_dir.mkdir(parents=True, exist_ok=True) + ARIA2_LOG.touch(exist_ok=True) + except OSError as exc: + raise ConfigError(f"Failed to render config: {exc}") from exc + + async def install(self, version: str | None = None) -> dict: + """完整安装流程""" + resolved_version = version or await self.get_latest_version() + await self.download_binary(resolved_version) + await self.download_config() + self.render_config() + + return { + "version": resolved_version, + "binary": str(ARIA2_BIN), + "config_dir": str(ARIA2_CONFIG_DIR), + "config": str(ARIA2_CONF), + "session": str(ARIA2_SESSION), + "installed": is_aria2_installed(), + } + + def uninstall(self) -> None: + """卸载 aria2""" + errors: list[Exception] = [] + + try: + if ARIA2_BIN.exists(): + ARIA2_BIN.unlink() + except Exception as exc: # noqa: PERF203 + errors.append(exc) + + try: + if ARIA2_CONFIG_DIR.exists(): + shutil.rmtree(ARIA2_CONFIG_DIR) + except Exception as exc: # noqa: PERF203 + errors.append(exc) + + try: + service_path = Path.home() / ".config" / "systemd" / "user" / "aria2.service" + if service_path.exists(): + service_path.unlink() + except Exception as exc: # noqa: PERF203 + errors.append(exc) + + if errors: + messages = "; ".join(str(err) for err in errors) + raise Aria2Error(f"Failed to uninstall aria2: {messages}") + + def _fetch_url(self, url: str) -> bytes: + """阻塞式 URL 获取,放在线程池中运行""" + req = request.Request(url, headers={"User-Agent": "aria2-installer"}) + try: + with request.urlopen(req, timeout=30) as resp: + if getattr(resp, "status", 200) >= 400: + raise DownloadError(f"HTTP {resp.status} for {url}") + return resp.read() + except (error.HTTPError, error.URLError) as exc: + raise DownloadError(f"Network error for {url}: {exc}") from exc + + @staticmethod + def _write_file(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + @staticmethod + def _extract_binary(archive_path: Path, extract_dir: Path) -> Path: + with tarfile.open(archive_path, "r:gz") as tar: + tar.extractall(extract_dir) + for candidate in extract_dir.rglob("aria2c"): + if candidate.is_file(): + return candidate + raise DownloadError("aria2c binary not found in archive") diff --git a/src/aria2/service.py b/src/aria2/service.py new file mode 100644 index 0000000..bfffc93 --- /dev/null +++ b/src/aria2/service.py @@ -0,0 +1,170 @@ +"""Aria2 service manager - systemd service lifecycle management.""" +from __future__ import annotations + +import os +import subprocess + +from src.core import ( + ARIA2_BIN, + ARIA2_CONF, + ARIA2_LOG, + ARIA2_SERVICE, + SYSTEMD_USER_DIR, + ServiceError, + NotInstalledError, + is_aria2_installed, +) + + +SYSTEMD_SERVICE_TEMPLATE = """[Unit] +Description=Aria2 Download Manager +After=network.target + +[Service] +Type=simple +ExecStart={aria2_bin} --conf-path={aria2_conf} +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target +""" + + +class Aria2ServiceManager: + def __init__(self) -> None: + pass + + def _run_systemctl(self, *args: str) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + ["systemctl", "--user", *args], + capture_output=True, + text=True, + check=True, + ) + except FileNotFoundError as exc: + raise ServiceError("systemctl command not found") from exc + except subprocess.CalledProcessError as exc: + output = exc.stderr.strip() or exc.stdout.strip() or str(exc) + raise ServiceError(output) from exc + + def _ensure_service_file(self) -> None: + try: + SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True) + content = SYSTEMD_SERVICE_TEMPLATE.format( + aria2_bin=str(ARIA2_BIN), + aria2_conf=str(ARIA2_CONF), + ) + ARIA2_SERVICE.write_text(content, encoding="utf-8") + self._run_systemctl("daemon-reload") + except OSError as exc: + raise ServiceError(f"Failed to write service file: {exc}") from exc + + def start(self) -> None: + if not is_aria2_installed(): + raise NotInstalledError("aria2 is not installed") + self._ensure_service_file() + self._run_systemctl("start", "aria2") + + def stop(self) -> None: + self._run_systemctl("stop", "aria2") + + def restart(self) -> None: + self._run_systemctl("restart", "aria2") + + def enable(self) -> None: + self._run_systemctl("enable", "aria2") + + def disable(self) -> None: + self._run_systemctl("disable", "aria2") + + def status(self) -> dict: + installed = is_aria2_installed() + pid = self.get_pid() if installed else None + + try: + active_proc = subprocess.run( + ["systemctl", "--user", "is-active", "aria2"], + capture_output=True, + text=True, + check=False, + ) + enabled_proc = subprocess.run( + ["systemctl", "--user", "is-enabled", "aria2"], + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError as exc: + raise ServiceError("systemctl command not found") from exc + + running = active_proc.returncode == 0 + enabled = enabled_proc.returncode == 0 + + return { + "installed": installed, + "running": running, + "pid": pid, + "enabled": enabled, + } + + def get_pid(self) -> int | None: + try: + result = subprocess.run( + ["pgrep", "-u", str(os.getuid()), "-f", "aria2c"], + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + result = None + + if result and result.returncode == 0: + for line in result.stdout.splitlines(): + line = line.strip() + if line.isdigit(): + return int(line) + + try: + ps_result = subprocess.run( + ["ps", "-C", "aria2c", "-o", "pid="], + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + return None + + for line in ps_result.stdout.splitlines(): + line = line.strip() + if line.isdigit(): + return int(line) + return None + + def view_log(self, lines: int = 50) -> str: + if lines <= 0 or not ARIA2_LOG.exists(): + return "" + try: + content = ARIA2_LOG.read_text(encoding="utf-8", errors="ignore") + except OSError as exc: + raise ServiceError(f"Failed to read log: {exc}") from exc + + log_lines = content.splitlines(keepends=True) + return "".join(log_lines[-lines:]) + + def clear_log(self) -> None: + try: + ARIA2_LOG.parent.mkdir(parents=True, exist_ok=True) + ARIA2_LOG.write_text("", encoding="utf-8") + except OSError as exc: + raise ServiceError(f"Failed to clear log: {exc}") from exc + + def remove_service(self) -> None: + self.stop() + try: + ARIA2_SERVICE.unlink(missing_ok=True) + except OSError as exc: + raise ServiceError(f"Failed to remove service file: {exc}") from exc + self._run_systemctl("daemon-reload") diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..79dacab --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1,55 @@ +"""Core module for aria2bot - constants, config, exceptions, and system utilities.""" +from src.core.constants import ( + HOME, + ARIA2_BIN, + ARIA2_CONFIG_DIR, + ARIA2_CONF, + ARIA2_SESSION, + ARIA2_LOG, + DOWNLOAD_DIR, + SYSTEMD_USER_DIR, + ARIA2_SERVICE, +) +from src.core.exceptions import ( + Aria2Error, + UnsupportedOSError, + UnsupportedArchError, + DownloadError, + ConfigError, + ServiceError, + NotInstalledError, +) +from src.core.config import Aria2Config, BotConfig +from src.core.system import ( + detect_os, + detect_arch, + generate_rpc_secret, + is_aria2_installed, + get_aria2_version, +) + +__all__ = [ + "HOME", + "ARIA2_BIN", + "ARIA2_CONFIG_DIR", + "ARIA2_CONF", + "ARIA2_SESSION", + "ARIA2_LOG", + "DOWNLOAD_DIR", + "SYSTEMD_USER_DIR", + "ARIA2_SERVICE", + "Aria2Error", + "UnsupportedOSError", + "UnsupportedArchError", + "DownloadError", + "ConfigError", + "ServiceError", + "NotInstalledError", + "Aria2Config", + "BotConfig", + "detect_os", + "detect_arch", + "generate_rpc_secret", + "is_aria2_installed", + "get_aria2_version", +] diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..d619e22 --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,42 @@ +"""Configuration dataclass for aria2bot.""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path + +from src.core.constants import DOWNLOAD_DIR + + +@dataclass +class Aria2Config: + rpc_port: int = 6800 + rpc_secret: str = "" + download_dir: Path = DOWNLOAD_DIR + max_concurrent_downloads: int = 5 + max_connection_per_server: int = 16 + bt_tracker_update: bool = True + + +@dataclass +class BotConfig: + token: str = "" + api_base_url: str = "" + aria2: Aria2Config = field(default_factory=Aria2Config) + + @classmethod + def from_env(cls) -> "BotConfig": + """从环境变量加载配置""" + from dotenv import load_dotenv + load_dotenv() + + token = os.environ.get("TELEGRAM_BOT_TOKEN", "") + aria2 = Aria2Config( + rpc_port=int(os.environ.get("ARIA2_RPC_PORT", "6800")), + rpc_secret=os.environ.get("ARIA2_RPC_SECRET", ""), + ) + return cls( + token=token, + api_base_url=os.environ.get("TELEGRAM_API_BASE_URL", ""), + aria2=aria2, + ) diff --git a/src/core/constants.py b/src/core/constants.py new file mode 100644 index 0000000..12e425f --- /dev/null +++ b/src/core/constants.py @@ -0,0 +1,12 @@ +"""Path constants for aria2bot.""" +from pathlib import Path + +HOME = Path.home() +ARIA2_BIN = HOME / ".local" / "bin" / "aria2c" +ARIA2_CONFIG_DIR = HOME / ".config" / "aria2" +ARIA2_CONF = ARIA2_CONFIG_DIR / "aria2.conf" +ARIA2_SESSION = ARIA2_CONFIG_DIR / "aria2.session" +ARIA2_LOG = ARIA2_CONFIG_DIR / "aria2.log" +DOWNLOAD_DIR = HOME / "downloads" +SYSTEMD_USER_DIR = HOME / ".config" / "systemd" / "user" +ARIA2_SERVICE = SYSTEMD_USER_DIR / "aria2.service" diff --git a/src/core/exceptions.py b/src/core/exceptions.py new file mode 100644 index 0000000..6c28c31 --- /dev/null +++ b/src/core/exceptions.py @@ -0,0 +1,29 @@ +"""Exception classes for aria2bot.""" + + +class Aria2Error(Exception): + """Base exception""" + + +class UnsupportedOSError(Aria2Error): + """不支持的操作系统""" + + +class UnsupportedArchError(Aria2Error): + """不支持的 CPU 架构""" + + +class DownloadError(Aria2Error): + """下载失败""" + + +class ConfigError(Aria2Error): + """配置错误""" + + +class ServiceError(Aria2Error): + """服务操作失败""" + + +class NotInstalledError(Aria2Error): + """aria2 未安装""" diff --git a/src/core/system.py b/src/core/system.py new file mode 100644 index 0000000..4b35e76 --- /dev/null +++ b/src/core/system.py @@ -0,0 +1,91 @@ +"""System detection utilities for aria2bot.""" +from __future__ import annotations + +import platform +import secrets +import shutil +import string +import subprocess +from pathlib import Path + +from src.core.constants import ARIA2_BIN +from src.core.exceptions import UnsupportedOSError, UnsupportedArchError + + +def detect_os() -> str: + """检测操作系统,返回 'centos', 'debian', 'ubuntu' 或抛出 UnsupportedOSError""" + os_release_path = Path("/etc/os-release") + if os_release_path.exists(): + info: dict[str, str] = {} + for line in os_release_path.read_text(encoding="utf-8", errors="ignore").splitlines(): + if "=" not in line: + continue + key, value = line.split("=", 1) + info[key.strip()] = value.strip().strip('"').lower() + os_id = info.get("ID") + if os_id in {"ubuntu", "debian"}: + return os_id + if os_id in {"centos", "rhel", "rocky", "almalinux"}: + return "centos" + + redhat_release = Path("/etc/redhat-release") + if redhat_release.exists(): + content = redhat_release.read_text(encoding="utf-8", errors="ignore").lower() + if any(name in content for name in ("centos", "red hat", "rocky", "alma")): + return "centos" + + raise UnsupportedOSError("Unsupported operating system") + + +def detect_arch() -> str: + """检测 CPU 架构,返回 'amd64', 'arm64', 'armhf', 'i386' 或抛出 UnsupportedArchError""" + machine = platform.machine().lower() + if machine in {"x86_64", "amd64"}: + return "amd64" + if machine in {"aarch64", "arm64", "armv8"}: + return "arm64" + if machine.startswith("armv7") or machine.startswith("armv6"): + return "armhf" + if machine in {"i386", "i686", "x86"}: + return "i386" + raise UnsupportedArchError(f"Unsupported CPU architecture: {machine}") + + +def generate_rpc_secret() -> str: + """生成 20 位随机 RPC 密钥""" + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(20)) + + +def is_aria2_installed() -> bool: + """检查 aria2c 是否已安装""" + if ARIA2_BIN.exists(): + return True + return shutil.which("aria2c") is not None + + +def get_aria2_version() -> str | None: + """获取已安装的 aria2 版本""" + candidates = [ARIA2_BIN] if ARIA2_BIN.exists() else [] + path_cmd = shutil.which("aria2c") + if path_cmd: + candidates.append(Path(path_cmd)) + + if not candidates: + return None + + for cmd in candidates: + result = subprocess.run( + [str(cmd), "-v"], capture_output=True, text=True, check=False + ) + if result.returncode != 0: + continue + for line in result.stdout.splitlines(): + lowered = line.lower() + if "aria2 version" in lowered: + parts = line.split() + return parts[-1] if parts else line.strip() + if result.stdout.strip(): + return result.stdout.splitlines()[0].strip() + + return None diff --git a/src/telegram/__init__.py b/src/telegram/__init__.py new file mode 100644 index 0000000..5880179 --- /dev/null +++ b/src/telegram/__init__.py @@ -0,0 +1,5 @@ +"""Telegram bot module - command handlers and application.""" +from src.telegram.handlers import Aria2BotAPI, build_handlers +from src.telegram.app import create_app, run + +__all__ = ["Aria2BotAPI", "build_handlers", "create_app", "run"] diff --git a/src/telegram/app.py b/src/telegram/app.py new file mode 100644 index 0000000..1eaf476 --- /dev/null +++ b/src/telegram/app.py @@ -0,0 +1,38 @@ +"""Telegram application builder and runner.""" +from __future__ import annotations + +import sys + +from telegram.ext import Application + +from src.core import BotConfig +from src.telegram.handlers import Aria2BotAPI, build_handlers +from src.utils import setup_logger + + +def create_app(config: BotConfig) -> Application: + """创建 Telegram Application""" + builder = Application.builder().token(config.token) + if config.api_base_url: + builder = builder.base_url(config.api_base_url).base_file_url(config.api_base_url + "/file") + app = builder.build() + + api = Aria2BotAPI(config.aria2) + for handler in build_handlers(api): + app.add_handler(handler) + + return app + + +def run() -> None: + """加载配置并启动 bot""" + logger = setup_logger() + config = BotConfig.from_env() + + if not config.token: + logger.error("Please set TELEGRAM_BOT_TOKEN in .env or environment") + sys.exit(1) + + app = create_app(config) + logger.info("Bot starting...") + app.run_polling() diff --git a/src/telegram/handlers.py b/src/telegram/handlers.py new file mode 100644 index 0000000..d9f60cc --- /dev/null +++ b/src/telegram/handlers.py @@ -0,0 +1,212 @@ +"""Telegram bot command handlers.""" +from __future__ import annotations + +from telegram import Update +from telegram.ext import ContextTypes, CommandHandler + +from src.core import ( + Aria2Config, + Aria2Error, + NotInstalledError, + ServiceError, + DownloadError, + ConfigError, + is_aria2_installed, + get_aria2_version, + ARIA2_CONF, +) +from src.aria2 import Aria2Installer, Aria2ServiceManager + + +class Aria2BotAPI: + def __init__(self, config: Aria2Config | None = None): + self.config = config or Aria2Config() + self.installer = Aria2Installer(self.config) + self.service = Aria2ServiceManager() + + async def _reply(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text: str, **kwargs): + if update.effective_message: + return await update.effective_message.reply_text(text, **kwargs) + if update.effective_chat: + return await context.bot.send_message(chat_id=update.effective_chat.id, text=text, **kwargs) + return None + + def _get_rpc_secret(self) -> str: + if self.config.rpc_secret: + return self.config.rpc_secret + if ARIA2_CONF.exists(): + try: + for line in ARIA2_CONF.read_text(encoding="utf-8", errors="ignore").splitlines(): + stripped = line.strip() + if stripped.startswith("rpc-secret="): + secret = stripped.split("=", 1)[1].strip() + if secret: + self.config.rpc_secret = secret + return secret + except OSError: + return "" + return "" + + def _get_rpc_port(self) -> int | None: + if ARIA2_CONF.exists(): + try: + for line in ARIA2_CONF.read_text(encoding="utf-8", errors="ignore").splitlines(): + stripped = line.strip() + if stripped.startswith("rpc-listen-port="): + port_str = stripped.split("=", 1)[1].strip() + if port_str.isdigit(): + return int(port_str) + except OSError: + return None + return self.config.rpc_port + + async def install(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + await self._reply(update, context, "正在安装 aria2,处理中,请稍候...") + try: + result = await self.installer.install() + version = get_aria2_version() or result.get("version") or "未知" + rpc_secret = self._get_rpc_secret() or "未设置" + rpc_port = self._get_rpc_port() or self.config.rpc_port + await self._reply( + update, + context, + "\n".join( + [ + "安装完成 ✅", + f"版本:{version}", + f"二进制:{result.get('binary')}", + f"配置目录:{result.get('config_dir')}", + f"配置文件:{result.get('config')}", + f"RPC 端口:{rpc_port}", + f"RPC 密钥:{rpc_secret}", + ] + ), + ) + except (DownloadError, ConfigError, Aria2Error) as exc: + await self._reply(update, context, f"安装失败:{exc}") + except Exception as exc: # noqa: BLE001 + await self._reply(update, context, f"安装失败,发生未知错误:{exc}") + + async def uninstall(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + await self._reply(update, context, "正在卸载 aria2,处理中,请稍候...") + try: + try: + self.service.stop() + except ServiceError: + pass + self.installer.uninstall() + await self._reply(update, context, "卸载完成 ✅") + except Aria2Error as exc: + await self._reply(update, context, f"卸载失败:{exc}") + except Exception as exc: # noqa: BLE001 + await self._reply(update, context, f"卸载失败,发生未知错误:{exc}") + + async def start_service(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + try: + if not is_aria2_installed(): + await self._reply(update, context, "aria2 未安装,请先运行 /install") + return + self.service.start() + await self._reply(update, context, "aria2 服务已启动 ✅") + except NotInstalledError: + await self._reply(update, context, "aria2 未安装,请先运行 /install") + except ServiceError as exc: + await self._reply(update, context, f"启动失败:{exc}") + except Exception as exc: # noqa: BLE001 + await self._reply(update, context, f"启动失败,发生未知错误:{exc}") + + async def stop_service(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + try: + self.service.stop() + await self._reply(update, context, "aria2 服务已停止 ✅") + except ServiceError as exc: + await self._reply(update, context, f"停止失败:{exc}") + except Exception as exc: # noqa: BLE001 + await self._reply(update, context, f"停止失败,发生未知错误:{exc}") + + async def restart_service(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + try: + self.service.restart() + await self._reply(update, context, "aria2 服务已重启 ✅") + except ServiceError as exc: + await self._reply(update, context, f"重启失败:{exc}") + except Exception as exc: # noqa: BLE001 + await self._reply(update, context, f"重启失败,发生未知错误:{exc}") + + async def status(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + try: + info = self.service.status() + version = get_aria2_version() or "未知" + rpc_secret = self._get_rpc_secret() or "未设置" + rpc_port = self._get_rpc_port() or self.config.rpc_port or "未知" + except ServiceError as exc: + await self._reply(update, context, f"获取状态失败:{exc}") + return + except Exception as exc: # noqa: BLE001 + await self._reply(update, context, f"获取状态失败,发生未知错误:{exc}") + return + + text = ( + "*Aria2 状态*\n" + f"- 安装状态:{'已安装 ✅' if info.get('installed') or is_aria2_installed() else '未安装 ❌'}\n" + f"- 运行状态:{'运行中 ✅' if info.get('running') else '未运行 ❌'}\n" + f"- PID:`{info.get('pid') or 'N/A'}`\n" + f"- 版本:`{version}`\n" + f"- RPC 端口:`{rpc_port}`\n" + f"- RPC 密钥:`{rpc_secret}`" + ) + await self._reply(update, context, text, parse_mode="Markdown") + + async def view_logs(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + try: + logs = self.service.view_log(lines=30) + except ServiceError as exc: + await self._reply(update, context, f"读取日志失败:{exc}") + return + except Exception as exc: # noqa: BLE001 + await self._reply(update, context, f"读取日志失败,发生未知错误:{exc}") + return + + if not logs.strip(): + await self._reply(update, context, "暂无日志内容。") + return + + await self._reply(update, context, f"最近 30 行日志:\n{logs}") + + async def clear_logs(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + try: + self.service.clear_log() + await self._reply(update, context, "日志已清空 ✅") + except ServiceError as exc: + await self._reply(update, context, f"清空日志失败:{exc}") + except Exception as exc: # noqa: BLE001 + await self._reply(update, context, f"清空日志失败,发生未知错误:{exc}") + + async def help_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + commands = [ + "/install - 安装 aria2", + "/uninstall - 卸载 aria2", + "/start - 启动 aria2 服务", + "/stop - 停止 aria2 服务", + "/restart - 重启 aria2 服务", + "/status - 查看 aria2 状态", + "/logs - 查看最近日志", + "/clear_logs - 清空日志", + "/help - 显示此帮助", + ] + await self._reply(update, context, "可用命令:\n" + "\n".join(commands)) + + +def build_handlers(api: Aria2BotAPI) -> list[CommandHandler]: + """构建 CommandHandler 列表""" + return [ + CommandHandler("install", api.install), + CommandHandler("uninstall", api.uninstall), + CommandHandler("start", api.start_service), + CommandHandler("stop", api.stop_service), + CommandHandler("restart", api.restart_service), + CommandHandler("status", api.status), + CommandHandler("logs", api.view_logs), + CommandHandler("clear_logs", api.clear_logs), + CommandHandler("help", api.help_command), + ] diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..4892a9c --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,4 @@ +"""Utility module - logging and other helpers.""" +from src.utils.logger import setup_logger, get_logger + +__all__ = ["setup_logger", "get_logger"] diff --git a/src/utils/logger.py b/src/utils/logger.py new file mode 100644 index 0000000..e11069a --- /dev/null +++ b/src/utils/logger.py @@ -0,0 +1,28 @@ +"""Logging module for aria2bot""" +import logging +import sys + +_initialized = False + + +def setup_logger(name: str = "aria2bot", level: int = logging.INFO) -> logging.Logger: + """Initialize and configure the root logger.""" + global _initialized + logger = logging.getLogger(name) + if not _initialized: + logger.setLevel(level) + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter( + logging.Formatter( + "%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + logger.addHandler(handler) + _initialized = True + return logger + + +def get_logger(name: str) -> logging.Logger: + """Get a child logger for a specific module.""" + return logging.getLogger(f"aria2bot.{name}") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bc7b7ed --- /dev/null +++ b/uv.lock @@ -0,0 +1,106 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, +] + +[[package]] +name = "aria2bot" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "python-dotenv" }, + { name = "python-telegram-bot" }, +] + +[package.metadata] +requires-dist = [ + { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "python-telegram-bot", specifier = ">=21.0" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-telegram-bot" +version = "22.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/6b/400f88e5c29a270c1c519a3ca8ad0babc650ec63dbfbd1b73babf625ed54/python_telegram_bot-22.5.tar.gz", hash = "sha256:82d4efd891d04132f308f0369f5b5929e0b96957901f58bcef43911c5f6f92f8", size = 1488269, upload-time = "2025-09-27T13:50:27.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/c3/340c7520095a8c79455fcf699cbb207225e5b36490d2b9ee557c16a7b21b/python_telegram_bot-22.5-py3-none-any.whl", hash = "sha256:4b7cd365344a7dce54312cc4520d7fa898b44d1a0e5f8c74b5bd9b540d035d16", size = 730976, upload-time = "2025-09-27T13:50:25.93Z" }, +]