mirror of
https://github.com/dnslin/aria2bot.git
synced 2026-01-10 11:52:20 +08:00
feat: init project
This commit is contained in:
10
.env.example
Normal file
10
.env.example
Normal 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
15
.gitignore
vendored
Normal 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
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13
|
||||
48
CLAUDE.md
Normal file
48
CLAUDE.md
Normal 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
|
||||
|
||||
11
banner.txt
Normal file
11
banner.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
______ ___ ____ __
|
||||
/\ _ \ __ /'___`\ /\ _`\ /\ \__
|
||||
\ \ \L\ \ _ __ /\_\ __ /\_\ /\ \\ \ \L\ \ ___\ \ ,_\
|
||||
\ \ __ \/\`'__\/\ \ /'__`\ \/_/// /__\ \ _ <' / __`\ \ \/
|
||||
\ \ \/\ \ \ \/ \ \ \/\ \L\.\_ // /_\ \\ \ \L\ \/\ \L\ \ \ \_
|
||||
\ \_\ \_\ \_\ \ \_\ \__/.\_\/\______/ \ \____/\ \____/\ \__\
|
||||
\/_/\/_/\/_/ \/_/\/__/\/_/\/_____/ \/___/ \/___/ \/__/
|
||||
|
||||
|
||||
Aria2Bot V1.0
|
||||
Powered by python-telegram-bot
|
||||
5
main.py
Normal file
5
main.py
Normal 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
13
pyproject.toml
Normal 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
1
src/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Aria2bot - Telegram bot for managing aria2 downloads."""
|
||||
5
src/aria2/__init__.py
Normal file
5
src/aria2/__init__.py
Normal 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
246
src/aria2/installer.py
Normal 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
170
src/aria2/service.py
Normal 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
55
src/core/__init__.py
Normal 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
42
src/core/config.py
Normal 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
12
src/core/constants.py
Normal 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
29
src/core/exceptions.py
Normal 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
91
src/core/system.py
Normal 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
5
src/telegram/__init__.py
Normal 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
38
src/telegram/app.py
Normal 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
212
src/telegram/handlers.py
Normal 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
4
src/utils/__init__.py
Normal 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
28
src/utils/logger.py
Normal 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
106
uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Reference in New Issue
Block a user