feat: init project

This commit is contained in:
dnslin
2025-12-11 17:54:06 +08:00
commit 2498e69e9e
23 changed files with 1147 additions and 0 deletions

10
.env.example Normal file
View File

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

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
.mcp.json
env/
venv/
.vscode/
.env

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

48
CLAUDE.md Normal file
View File

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

0
README.md Normal file
View File

11
banner.txt Normal file
View File

@@ -0,0 +1,11 @@
______ ___ ____ __
/\ _ \ __ /'___`\ /\ _`\ /\ \__
\ \ \L\ \ _ __ /\_\ __ /\_\ /\ \\ \ \L\ \ ___\ \ ,_\
\ \ __ \/\`'__\/\ \ /'__`\ \/_/// /__\ \ _ <' / __`\ \ \/
\ \ \/\ \ \ \/ \ \ \/\ \L\.\_ // /_\ \\ \ \L\ \/\ \L\ \ \ \_
\ \_\ \_\ \_\ \ \_\ \__/.\_\/\______/ \ \____/\ \____/\ \__\
\/_/\/_/\/_/ \/_/\/__/\/_/\/_____/ \/___/ \/___/ \/__/
Aria2Bot V1.0
Powered by python-telegram-bot

5
main.py Normal file
View File

@@ -0,0 +1,5 @@
"""Aria2 Telegram Bot - Control aria2 via Telegram"""
from src.telegram import run
if __name__ == "__main__":
run()

13
pyproject.toml Normal file
View File

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

1
src/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Aria2bot - Telegram bot for managing aria2 downloads."""

5
src/aria2/__init__.py Normal file
View File

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

246
src/aria2/installer.py Normal file
View File

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

170
src/aria2/service.py Normal file
View File

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

55
src/core/__init__.py Normal file
View File

@@ -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",
]

42
src/core/config.py Normal file
View File

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

12
src/core/constants.py Normal file
View File

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

29
src/core/exceptions.py Normal file
View File

@@ -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 未安装"""

91
src/core/system.py Normal file
View File

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

5
src/telegram/__init__.py Normal file
View File

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

38
src/telegram/app.py Normal file
View File

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

212
src/telegram/handlers.py Normal file
View File

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

4
src/utils/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""Utility module - logging and other helpers."""
from src.utils.logger import setup_logger, get_logger
__all__ = ["setup_logger", "get_logger"]

28
src/utils/logger.py Normal file
View File

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

106
uv.lock generated Normal file
View File

@@ -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" },
]