Add BOINC Telegram bot, CI, and deploy compose
All checks were successful
publish-images / build (push) Successful in 1m3s
All checks were successful
publish-images / build (push) Successful in 1m3s
This commit is contained in:
30
.gitea/workflows/publish-images.yml
Normal file
30
.gitea/workflows/publish-images.yml
Normal file
@@ -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"
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.log
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -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"]
|
||||||
52
README.md
Normal file
52
README.md
Normal file
@@ -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`.
|
||||||
2
bot/__init__.py
Normal file
2
bot/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Telegram bot for monitoring BOINC/grcpool tasks."""
|
||||||
|
|
||||||
205
bot/boinc.py
Normal file
205
bot/boinc.py
Normal file
@@ -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
|
||||||
|
|
||||||
40
bot/config.py
Normal file
40
bot/config.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
106
bot/main.py
Normal file
106
bot/main.py
Normal file
@@ -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())
|
||||||
|
|
||||||
81
bot/ui.py
Normal file
81
bot/ui.py
Normal file
@@ -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"<b>{safe_name}</b> ({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"<b>{html.escape(title)}:</b> ничего нет"
|
||||||
|
lines: List[str] = [f"<b>{html.escape(title)}:</b>"]
|
||||||
|
for task in items:
|
||||||
|
lines.append(format_task(task))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_dashboard(snapshot: BoincSnapshot) -> str:
|
||||||
|
lines = [
|
||||||
|
"<b>BOINC / grcpool монитор</b>",
|
||||||
|
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)
|
||||||
|
|
||||||
13
deploy/docker-compose.yml
Normal file
13
deploy/docker-compose.yml
Normal file
@@ -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"]
|
||||||
2
requirements-dev.txt
Normal file
2
requirements-dev.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==7.4.4
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
python-telegram-bot==20.7
|
||||||
40
sample_data/boinccmd_tasks.txt
Normal file
40
sample_data/boinccmd_tasks.txt
Normal file
@@ -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
|
||||||
6
tests/conftest.py
Normal file
6
tests/conftest.py
Normal file
@@ -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))
|
||||||
17
tests/test_boinc_parser.py
Normal file
17
tests/test_boinc_parser.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user