From 55cfd73c242a907c8098a553b219070928144857 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 6 Jan 2026 09:44:48 +0300 Subject: [PATCH] Add BOINC Telegram bot, CI, and deploy compose --- .gitea/workflows/publish-images.yml | 30 ++++ .gitignore | 5 + Dockerfile | 17 +++ README.md | 52 +++++++ bot/__init__.py | 2 + bot/boinc.py | 205 ++++++++++++++++++++++++++++ bot/config.py | 40 ++++++ bot/main.py | 106 ++++++++++++++ bot/ui.py | 81 +++++++++++ deploy/docker-compose.yml | 13 ++ requirements-dev.txt | 2 + requirements.txt | 1 + sample_data/boinccmd_tasks.txt | 40 ++++++ tests/conftest.py | 6 + tests/test_boinc_parser.py | 17 +++ 15 files changed, 617 insertions(+) create mode 100644 .gitea/workflows/publish-images.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bot/__init__.py create mode 100644 bot/boinc.py create mode 100644 bot/config.py create mode 100644 bot/main.py create mode 100644 bot/ui.py create mode 100644 deploy/docker-compose.yml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 sample_data/boinccmd_tasks.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_boinc_parser.py diff --git a/.gitea/workflows/publish-images.yml b/.gitea/workflows/publish-images.yml new file mode 100644 index 0000000..973de83 --- /dev/null +++ b/.gitea/workflows/publish-images.yml @@ -0,0 +1,30 @@ +name: publish-images + +on: + push: + branches: + - main + +jobs: + build: + runs-on: self-hosted + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login registry + env: + REGISTRY_USER: ${{ vars.REGISTRY_USER }} + REGISTRY_PASSWORD: ${{ vars.REGISTRY_PASSWORD }} + run: | + echo "$REGISTRY_PASSWORD" | docker login cr.danosito.com -u "$REGISTRY_USER" --password-stdin + + - name: Build and push images + env: + IMAGE_TAG: ${{ gitea.sha }} + IMAGE_NAME: cr.danosito.com/dan/boinc-report-bot + run: | + set -euo pipefail + docker build -t "$IMAGE_NAME:$IMAGE_TAG" -t "$IMAGE_NAME:latest" . + docker push "$IMAGE_NAME:$IMAGE_TAG" + docker push "$IMAGE_NAME:latest" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f36a1cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv +__pycache__/ +*.pyc +*.pyo +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..223d0f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends boinc-client ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "-m", "bot.main"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e94255 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# BOINC Telegram Bot + +Телеграм-бот для мониторинга задач BOINC/grcpool. Показывает активные задачи, очередь и задания, готовые к отправке, с компактным ASCII UI и кнопками обновления. + +## Быстрый старт локально +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements-dev.txt +export TELEGRAM_TOKEN="ваш_токен" +# Для тестов без реального BOINC: +export BOINC_SAMPLE_FILE="sample_data/boinccmd_tasks.txt" +python -m bot.main +``` + +## Переменные окружения +- `TELEGRAM_TOKEN` — токен бота (обязательно). +- `BOINC_HOST` — адрес BOINC RPC/boinccmd (по умолчанию `localhost`). +- `BOINC_PORT` — порт RPC (по умолчанию `31416`). +- `BOINC_PASSWORD` — пароль GUI RPC, если требуется. +- `BOINC_CMD` — путь к `boinccmd`, если не в `$PATH`. +- `BOINC_SAMPLE_FILE` — путь к текстовому дампу `boinccmd --get_tasks` для офлайн-тестов. +- `REFRESH_SECONDS` — не используется в логике, оставлен для будущего автообновления (по умолчанию `30`). + +## Запуск в Docker на SSH-сервере +```bash +docker build -t boinc-telegram-bot . +docker run -d --name boinc-telegram-bot \ + --network host \ + -e TELEGRAM_TOKEN="ваш_токен" \ + -e BOINC_HOST="127.0.0.1" \ + -e BOINC_PORT="31416" \ + -e BOINC_PASSWORD="gui_rpc_password" \ + boinc-telegram-bot +``` +Контейнер включает `boinccmd`. Если BOINC-клиент работает на том же сервере, используйте `--network host` или проброс порта 31416. При необходимости смонтируйте файл `gui_rpc_auth.cfg` и считайте пароль из него. + +## Команды бота +- `/start` или `/status` — сводный дашборд. +- Кнопки: «Обновить», «Активные», «Очередь», «Готовы к отправке». + +## Что под капотом +- Python + `python-telegram-bot`. +- Получение статуса через `boinccmd --get_tasks` (RPC на BOINC). +- Парсер выдерживает типовой вывод и группирует задачи: активные, ожидающие, готовые к отправке. +- UI в HTML-маркапе: прогресс-бары `[####.....]`, ETA, дедлайны и краткая сводка. + +## Тесты +```bash +pytest +``` +В тестах используется сэмпл `sample_data/boinccmd_tasks.txt`. diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e61dd39 --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1,2 @@ +"""Telegram bot for monitoring BOINC/grcpool tasks.""" + diff --git a/bot/boinc.py b/bot/boinc.py new file mode 100644 index 0000000..2362445 --- /dev/null +++ b/bot/boinc.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import asyncio +import logging +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class Task: + name: str + project: str + status: str # active | waiting | completed | unknown + fraction_done: float + elapsed_seconds: float + remaining_seconds: Optional[float] + deadline: Optional[datetime] + resources: Optional[str] = None + slot: Optional[str] = None + + @property + def progress_percent(self) -> int: + return max(0, min(int(round(self.fraction_done * 100)), 100)) + + +@dataclass +class BoincSnapshot: + tasks: List[Task] + fetched_at: datetime + raw: str = "" + + @property + def active(self) -> List[Task]: + return [t for t in self.tasks if t.status == "active"] + + @property + def queued(self) -> List[Task]: + return [t for t in self.tasks if t.status == "waiting"] + + @property + def completed(self) -> List[Task]: + return [t for t in self.tasks if t.status == "completed"] + + @property + def average_progress(self) -> float: + if not self.tasks: + return 0.0 + return sum(t.progress_percent for t in self.tasks) / len(self.tasks) + + @property + def total_remaining_seconds(self) -> float: + remaining = [t.remaining_seconds for t in self.tasks if t.remaining_seconds] + return sum(remaining) if remaining else 0.0 + + +class BoincClient: + def __init__( + self, + host: str = "localhost", + port: int = 31416, + password: Optional[str] = None, + boinccmd_path: str = "boinccmd", + sample_output: Optional[str] = None, + ) -> None: + self.host = host + self.port = port + self.password = password + self.boinccmd_path = boinccmd_path + self.sample_output = Path(sample_output) if sample_output else None + + async def fetch_snapshot(self) -> BoincSnapshot: + raw = await self._fetch_raw() + tasks = parse_boinccmd_tasks(raw) + return BoincSnapshot(tasks=tasks, fetched_at=datetime.now(timezone.utc), raw=raw) + + async def _fetch_raw(self) -> str: + if self.sample_output: + return self.sample_output.read_text() + + cmd = [self.boinccmd_path, "--host", f"{self.host}:{self.port}", "--get_tasks"] + if self.password: + cmd.extend(["--passwd", self.password]) + + try: + process = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + except FileNotFoundError as exc: + raise RuntimeError(f"boinccmd not found at '{self.boinccmd_path}'") from exc + + stdout, stderr = await process.communicate() + if process.returncode != 0: + err_text = stderr.decode() if stderr else "unknown error" + raise RuntimeError(f"boinccmd failed: {err_text.strip()}") + + return stdout.decode() + + +def parse_boinccmd_tasks(output: str) -> List[Task]: + tasks: List[Task] = [] + current: Dict[str, str] = {} + + for line in output.splitlines(): + stripped = line.strip() + if not stripped: + continue + if re.match(r"^\d+\)", stripped): + if current: + task = _task_from_dict(current) + if task: + tasks.append(task) + current = {} + continue + if stripped.startswith("========"): + continue + if ":" not in stripped: + continue + + key, value = stripped.split(":", 1) + current[key.strip().lower()] = value.strip() + + if current: + task = _task_from_dict(current) + if task: + tasks.append(task) + + return tasks + + +def _task_from_dict(data: Dict[str, str]) -> Optional[Task]: + name = data.get("name", "unknown") + project = data.get("project url", "unknown project") + fraction_done = _parse_float(data.get("fraction done", "0")) + elapsed = _parse_float(data.get("elapsed time", "0")) + remaining = _parse_float(data.get("estimated cpu time remaining", "0")) + deadline = _parse_deadline(data.get("report deadline")) + resources = data.get("resources") + slot = data.get("slot") + status = _deduce_status(data) + + return Task( + name=name, + project=project, + status=status, + fraction_done=fraction_done, + elapsed_seconds=elapsed, + remaining_seconds=remaining, + deadline=deadline, + resources=resources, + slot=slot, + ) + + +def _deduce_status(data: Dict[str, str]) -> str: + ready = data.get("ready to report", "").lower() == "yes" + if ready: + return "completed" + + active_state = data.get("active_task_state") or data.get("active task state") + if active_state: + active_state = active_state.upper() + if active_state in {"EXECUTING", "RUNNING", "READY"}: + return "active" + if active_state in {"SUSPENDED", "UNINITIALIZED", "SUSPENDED_VIA_GUI"}: + return "waiting" + + scheduler_state = data.get("scheduler state") or data.get("state") + if scheduler_state and scheduler_state.strip().isdigit(): + # 1 is typically waiting to run, 2 is executing + try: + sched_val = int(scheduler_state.strip()) + if sched_val >= 2: + return "active" + if sched_val == 1: + return "waiting" + except ValueError: + pass + + return "waiting" + + +def _parse_float(value: Optional[str]) -> float: + if not value: + return 0.0 + try: + return float(value.split()[0]) + except ValueError: + return 0.0 + + +def _parse_deadline(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + # Example: Tue Jan 9 05:58:07 2024 + return datetime.strptime(value, "%a %b %d %H:%M:%S %Y").replace(tzinfo=timezone.utc) + except Exception: + logger.debug("Could not parse deadline: %s", value) + return None + diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..3ca4a76 --- /dev/null +++ b/bot/config.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class Settings: + telegram_token: str + boinc_host: str = "localhost" + boinc_port: int = 31416 + boinc_password: Optional[str] = None + boinccmd_path: str = "boinccmd" + sample_output: Optional[str] = None + refresh_seconds: int = 30 + + @classmethod + def from_env(cls) -> "Settings": + token = os.getenv("TELEGRAM_TOKEN") or os.getenv("BOT_TOKEN") + if not token: + raise ValueError("Set TELEGRAM_TOKEN (or BOT_TOKEN) in the environment") + + host = os.getenv("BOINC_HOST", "localhost") + port = int(os.getenv("BOINC_PORT", "31416")) + password = os.getenv("BOINC_PASSWORD") + boinccmd_path = os.getenv("BOINC_CMD", "boinccmd") + sample_output = os.getenv("BOINC_SAMPLE_FILE") + refresh_seconds = int(os.getenv("REFRESH_SECONDS", "30")) + + return cls( + telegram_token=token, + boinc_host=host, + boinc_port=port, + boinc_password=password, + boinccmd_path=boinccmd_path, + sample_output=sample_output, + refresh_seconds=refresh_seconds, + ) + diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..20196f1 --- /dev/null +++ b/bot/main.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import asyncio +import logging +from functools import partial +from typing import Literal + +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.constants import ParseMode +from telegram.ext import ( + ApplicationBuilder, + CallbackQueryHandler, + CommandHandler, + ContextTypes, +) + +from .boinc import BoincClient +from .config import Settings +from .ui import format_dashboard, format_tasks_section + +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO +) +logger = logging.getLogger(__name__) + +View = Literal["dashboard", "active", "queued", "completed"] + + +def build_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("Обновить", callback_data="dashboard"), + InlineKeyboardButton("Активные", callback_data="active"), + ], + [ + InlineKeyboardButton("Очередь", callback_data="queued"), + InlineKeyboardButton("Готовы к отправке", callback_data="completed"), + ], + ] + ) + + +async def render_view(view: View, client: BoincClient) -> str: + snapshot = await client.fetch_snapshot() + if view == "dashboard": + return format_dashboard(snapshot) + if view == "active": + return format_tasks_section("Активные задачи", snapshot.active) + if view == "queued": + return format_tasks_section("Ожидают запуска", snapshot.queued) + return format_tasks_section("Готовы к отправке", snapshot.completed) + + +async def start_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + client: BoincClient = context.bot_data["boinc_client"] + text = await render_view("dashboard", client) + await update.message.reply_text( + text, reply_markup=build_keyboard(), parse_mode=ParseMode.HTML + ) + + +async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + client: BoincClient = context.bot_data["boinc_client"] + view: View = query.data if query.data in {"dashboard", "active", "queued", "completed"} else "dashboard" + try: + text = await render_view(view, client) + except Exception as exc: # broad catch to show meaningful message in chat + logger.exception("Error rendering view %s", view) + text = f"Не удалось получить данные: {exc}" + await query.edit_message_text( + text=text, reply_markup=build_keyboard(), parse_mode=ParseMode.HTML + ) + + +async def main() -> None: + settings = Settings.from_env() + client = BoincClient( + host=settings.boinc_host, + port=settings.boinc_port, + password=settings.boinc_password, + boinccmd_path=settings.boinccmd_path, + sample_output=settings.sample_output, + ) + + application = ( + ApplicationBuilder() + .token(settings.telegram_token) + .parse_mode(ParseMode.HTML) + .build() + ) + application.bot_data["boinc_client"] = client + + application.add_handler(CommandHandler("start", start_handler)) + application.add_handler(CommandHandler("status", start_handler)) + application.add_handler(CallbackQueryHandler(callback_handler)) + + logger.info("Starting Telegram bot...") + await application.run_polling(close_loop=False) + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/bot/ui.py b/bot/ui.py new file mode 100644 index 0000000..8c83662 --- /dev/null +++ b/bot/ui.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import html +from datetime import datetime, timezone +from typing import Iterable, List + +from .boinc import BoincSnapshot, Task + + +def progress_bar(percent: float, width: int = 16) -> str: + clamped = max(0, min(percent, 100)) + filled = int(round((clamped / 100.0) * width)) + return "[" + "#" * filled + "." * (width - filled) + "]" + + +def humanize_seconds(seconds: float) -> str: + if seconds <= 0: + return "0s" + minutes, sec = divmod(int(seconds), 60) + hours, minutes = divmod(minutes, 60) + days, hours = divmod(hours, 24) + parts = [] + if days: + parts.append(f"{days}d") + if hours: + parts.append(f"{hours}h") + if minutes: + parts.append(f"{minutes}m") + if sec and len(parts) < 2: + parts.append(f"{sec}s") + return " ".join(parts) if parts else f"{sec}s" + + +def format_timestamp(dt: datetime) -> str: + if not dt: + return "-" + if not dt.tzinfo: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + +def format_task(task: Task) -> str: + safe_name = html.escape(task.name) + safe_project = html.escape(task.project) + eta = humanize_seconds(task.remaining_seconds or 0) + deadline = format_timestamp(task.deadline) if task.deadline else "-" + bar = progress_bar(task.progress_percent) + return ( + f"{safe_name} ({safe_project})\n" + f"{bar} {task.progress_percent}% | ETA {eta} | Deadline {deadline}" + ) + + +def format_tasks_section(title: str, tasks: Iterable[Task]) -> str: + items = list(tasks) + if not items: + return f"{html.escape(title)}: ничего нет" + lines: List[str] = [f"{html.escape(title)}:"] + for task in items: + lines.append(format_task(task)) + return "\n".join(lines) + + +def format_dashboard(snapshot: BoincSnapshot) -> str: + lines = [ + "BOINC / grcpool монитор", + f"Обновлено: {format_timestamp(snapshot.fetched_at)}", + "", + f"Активные: {len(snapshot.active)} | В очереди: {len(snapshot.queued)} | Завершены: {len(snapshot.completed)}", + f"Средний прогресс: {snapshot.average_progress:.1f}% | Оставшееся время: {humanize_seconds(snapshot.total_remaining_seconds)}", + "", + format_tasks_section("Активные задачи", snapshot.active[:5]), + ] + + if snapshot.queued: + lines.extend(["", format_tasks_section("Ожидают запуска", snapshot.queued[:5])]) + if snapshot.completed: + lines.extend(["", format_tasks_section("Готовы к отправке", snapshot.completed[:5])]) + + return "\n".join(lines) + diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..09d3d98 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,13 @@ +version: "3.8" + +services: + boinc-report-bot: + image: cr.danosito.com/dan/boinc-report-bot:latest + restart: unless-stopped + network_mode: host + env_file: + - .env + environment: + - BOINC_HOST=127.0.0.1 + - BOINC_PORT=31416 + command: ["python", "-m", "bot.main"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ee8c21e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest==7.4.4 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7d5efd5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +python-telegram-bot==20.7 diff --git a/sample_data/boinccmd_tasks.txt b/sample_data/boinccmd_tasks.txt new file mode 100644 index 0000000..144b690 --- /dev/null +++ b/sample_data/boinccmd_tasks.txt @@ -0,0 +1,40 @@ +======== Tasks ======== +1) ----------- + name: task_one + WU name: w1 + project URL: https://grcpool.com/ + report deadline: Tue Jan 9 05:58:07 2024 + ready to report: no + active_task_state: EXECUTING + scheduler state: 2 + resources: cpu + slot: 0 + elapsed time: 5400.0 + estimated CPU time remaining: 1200.0 + fraction done: 0.80 +2) ----------- + name: task_two + WU name: w2 + project URL: https://grcpool.com/ + report deadline: Tue Jan 10 05:58:07 2024 + ready to report: yes + active_task_state: SUSPENDED + scheduler state: 1 + resources: cpu + slot: 1 + elapsed time: 3600.0 + estimated CPU time remaining: 0 + fraction done: 1.0 +3) ----------- + name: task_three + WU name: w3 + project URL: https://grcpool.com/ + report deadline: Tue Jan 11 05:58:07 2024 + ready to report: no + active_task_state: READY + scheduler state: 1 + resources: cpu + slot: 2 + elapsed time: 0 + estimated CPU time remaining: 5400.0 + fraction done: 0.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2a855d9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/tests/test_boinc_parser.py b/tests/test_boinc_parser.py new file mode 100644 index 0000000..139fb58 --- /dev/null +++ b/tests/test_boinc_parser.py @@ -0,0 +1,17 @@ +from datetime import datetime, timezone +from pathlib import Path + +from bot.boinc import BoincSnapshot, parse_boinccmd_tasks + + +def test_parse_sample_tasks(): + text = Path("sample_data/boinccmd_tasks.txt").read_text() + tasks = parse_boinccmd_tasks(text) + assert len(tasks) == 3 + assert tasks[0].status == "active" + assert tasks[1].status == "completed" + assert tasks[2].status == "active" + snapshot = BoincSnapshot(tasks=tasks, fetched_at=datetime.now(timezone.utc)) + assert len(snapshot.active) == 2 + assert len(snapshot.completed) == 1 + assert snapshot.average_progress > 0