Add BOINC Telegram bot, CI, and deploy compose
All checks were successful
publish-images / build (push) Successful in 1m3s

This commit is contained in:
dan
2026-01-06 09:44:48 +03:00
commit 55cfd73c24
15 changed files with 617 additions and 0 deletions

2
bot/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Telegram bot for monitoring BOINC/grcpool tasks."""

205
bot/boinc.py Normal file
View 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
View 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
View 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
View 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)