commit cf1801bff4f79bd88af73ca656ab7679ec8f67f4 Author: MrAkells Date: Sun Dec 14 22:45:11 2025 +0200 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..86bde86 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.venv/ +venv/ +.git/ +.gitignore +data/ +.env +.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0476801 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +TELEGRAM_BOT_TOKEN=your_bot_token +TELEGRAM_CHAT_ID=123456789 +MINECRAFT_HOST=example.org +MINECRAFT_PORT=25565 +POLL_INTERVAL_SECONDS=30 +STATUS_FILE_PATH=data/status.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a13399d --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Python +__pycache__/ +*.pyc +.venv/ +venv/ + +# Env +.env + +# Project data +data/ + +# IDE +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1193cb1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies (none needed now, keep layer for future) +RUN apt-get update && apt-get install -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6682c61 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# Telegram Minecraft Monitor Bot + +Бот на `aiogram` который регулярно проверяет статус Minecraft сервера и: +- Отправляет уведомления в Telegram чат (онлайн/офлайн, изменения игроков). +- Записывает актуальное состояние в `status.json`, чтобы данные можно было забирать на сайт. + +## Быстрый старт +1) Установите зависимости: +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` +2) Заполните `.env` (смотрите пример в `.env.example`): +``` +TELEGRAM_BOT_TOKEN=ваш_токен +TELEGRAM_CHAT_ID=чат_id_куда_слать_уведомления +MINECRAFT_HOST=example.org +MINECRAFT_PORT=25565 +POLL_INTERVAL_SECONDS=30 +STATUS_FILE_PATH=data/status.json +``` +3) Запустите бота: +```bash +python main.py +``` + +## Запуск в Docker +1) Собрать образ: +```bash +docker build -t monitor-bot . +``` +2) Запустить, передав переменные окружения: +```bash +docker run -d \ + --name monitor-bot \ + -e TELEGRAM_BOT_TOKEN=... \ + -e TELEGRAM_CHAT_ID=... \ + -e MINECRAFT_HOST=example.org \ + -e MINECRAFT_PORT=25565 \ + -e POLL_INTERVAL_SECONDS=30 \ + -e STATUS_FILE_PATH=/data/status.json \ + -v "$(pwd)/data:/data" \ + monitor-bot +``` +`STATUS_FILE_PATH` лучше указывать в смонтированную директорию (`/data`), чтобы JSON был доступен снаружи. + +## Что делает бот +- Каждые `POLL_INTERVAL_SECONDS` секунд опрашивает сервер Minecraft. +- Пишет результат в JSON файл `STATUS_FILE_PATH`, например `data/status.json`: +```json +{ + "source": "monitor-bot", + "status": { + "online": true, + "motd": "Привет!", + "version": "1.20.4", + "latency_ms": 62.5, + "players_online": 3, + "players_max": 20, + "player_names": ["Steve", "Alex"], + "last_updated": "2024-01-01T12:00:00.000000+00:00" + } +} +``` +- Следит за изменениями и шлет уведомления в чат: сервер упал/поднялся, кто зашел/вышел, текущее число игроков. +- Команда `/status` в чате выводит актуальную информацию (берется из файла, при его отсутствии запрашивается напрямую). + +## Использование данных на сайте +- Забирайте файл `STATUS_FILE_PATH` любым удобным способом (например, отдавайте его веб-сервером или читайте напрямую из файловой системы) — структура стабильная. +- Поле `last_updated` в формате ISO 8601 поможет показывать время обновления. + +## Настройки +- Все параметры задаются через переменные окружения (см. `.env.example`). +- `POLL_INTERVAL_SECONDS` — частота опроса. +- `STATUS_FILE_PATH` — куда писать JSON. Скрипт сам создаст директорию, если ее нет. + +## Проверка +- Запустите бота и в Telegram отправьте `/start`, затем `/status` чтобы увидеть текущие данные. +- В каталоге `STATUS_FILE_PATH` должен появиться актуальный `status.json`. diff --git a/activity.py b/activity.py new file mode 100644 index 0000000..15b35c2 --- /dev/null +++ b/activity.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Optional + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass +class ChatActivityTracker: + last_bot_message_id: Optional[int] = None + last_bot_message_time: Optional[datetime] = None + last_user_message_time: Optional[datetime] = None + _now_fn: callable = field(default=_now, repr=False) + + def record_user_activity(self) -> None: + self.last_user_message_time = self._now_fn() + + def record_bot_message(self, message_id: int) -> None: + self.last_bot_message_id = message_id + self.last_bot_message_time = self._now_fn() + + def can_edit_last_bot_message(self) -> bool: + if self.last_bot_message_id is None: + return False + if self.last_user_message_time is None: + return True + if self.last_bot_message_time is None: + return False + # Edit only if после последнего бот-сообщения не было новых сообщений пользователей. + return self.last_bot_message_time >= self.last_user_message_time diff --git a/bot_handlers.py b/bot_handlers.py new file mode 100644 index 0000000..1b20c54 --- /dev/null +++ b/bot_handlers.py @@ -0,0 +1,77 @@ +from aiogram import Router +from aiogram.filters import Command +from aiogram.types import Message + +from activity import ChatActivityTracker +from status_service import MinecraftMonitor, ServerSnapshot + + +router = Router() + + +def format_snapshot(snapshot: ServerSnapshot) -> str: + if not snapshot.online: + return "⚠️ Сервер офлайн." + + motd_line = f"Опис: {snapshot.motd}" if snapshot.motd else None + players_line = ( + f"Гравців: {snapshot.players_online}/{snapshot.players_max}" + if snapshot.players_max + else f"Гравців онлайн: {snapshot.players_online}" + ) + latency_line = ( + f"Затримка: {round(snapshot.latency_ms, 1)} мс" + if snapshot.latency_ms is not None + else None + ) + names_line = ( + "Зараз на сервері: " + ", ".join(snapshot.player_names) + if snapshot.player_names + else "На сервері немає гравців." + ) + + lines = [ + "✅ Сервер онлайн.", + motd_line, + f"Версія: {snapshot.version or 'невідомо'}", + players_line, + names_line, + latency_line, + ] + return "\n".join([line for line in lines if line]) + + +def setup_handlers(monitor: MinecraftMonitor, activity: ChatActivityTracker) -> Router: + @router.message(Command("start")) + async def handle_start(message: Message) -> None: + if not message.from_user or message.from_user.is_bot: + return + activity.record_user_activity() + await message.answer( + "Привіт! Я стежу за станом Minecraft сервера.\n" + "Використовуй /status, щоб отримати поточну інформацію." + ) + + @router.message(Command("status")) + async def handle_status(message: Message) -> None: + if message.from_user and not message.from_user.is_bot: + activity.record_user_activity() + + snapshot = monitor.read_cached_snapshot() + if snapshot is None: + snapshot = await monitor.fetch_status() + + if snapshot is None: + await message.answer("Дані недоступні.") + return + + response = await message.answer(format_snapshot(snapshot)) + activity.record_bot_message(response.message_id) + + @router.message() + async def track_any_message(message: Message) -> None: + # Фиксируем любой трафик в чате, чтобы при следующем уведомлении не затирать историю. + if message.from_user and not message.from_user.is_bot: + activity.record_user_activity() + + return router diff --git a/config.py b/config.py new file mode 100644 index 0000000..ad121fe --- /dev/null +++ b/config.py @@ -0,0 +1,51 @@ +import os +from dataclasses import dataclass + +from dotenv import load_dotenv + + +@dataclass +class Settings: + telegram_bot_token: str + telegram_chat_id: int + minecraft_host: str + minecraft_port: int + poll_interval_seconds: int + status_file_path: str + + +def load_settings() -> Settings: + load_dotenv() + + token = os.getenv("TELEGRAM_BOT_TOKEN") + chat_id = os.getenv("TELEGRAM_CHAT_ID") + host = os.getenv("MINECRAFT_HOST") + port = os.getenv("MINECRAFT_PORT", "25565") + interval = os.getenv("POLL_INTERVAL_SECONDS", "30") + status_file_path = os.getenv("STATUS_FILE_PATH", "data/status.json") + + if not token: + raise RuntimeError("TELEGRAM_BOT_TOKEN is required") + if not chat_id: + raise RuntimeError("TELEGRAM_CHAT_ID is required") + if not host: + raise RuntimeError("MINECRAFT_HOST is required") + + try: + parsed_port = int(port) + except ValueError as exc: + raise RuntimeError("MINECRAFT_PORT must be an integer") from exc + + try: + parsed_interval = int(interval) + except ValueError as exc: + raise RuntimeError("POLL_INTERVAL_SECONDS must be an integer") from exc + + return Settings( + telegram_bot_token=token, + telegram_chat_id=int(chat_id), + minecraft_host=host, + minecraft_port=parsed_port, + poll_interval_seconds=parsed_interval, + status_file_path=status_file_path, + ) diff --git a/main.py b/main.py new file mode 100644 index 0000000..2f42fea --- /dev/null +++ b/main.py @@ -0,0 +1,45 @@ +import asyncio +import logging +from pathlib import Path + +from aiogram import Bot, Dispatcher +from aiogram.client.default import DefaultBotProperties + +from activity import ChatActivityTracker +from bot_handlers import setup_handlers +from config import load_settings +from status_service import MinecraftMonitor + + +async def main() -> None: + logging.basicConfig(level=logging.INFO) + settings = load_settings() + + bot = Bot( + token=settings.telegram_bot_token, + default=DefaultBotProperties(parse_mode="HTML"), + ) + dp = Dispatcher() + + activity = ChatActivityTracker() + + monitor = MinecraftMonitor( + host=settings.minecraft_host, + port=settings.minecraft_port, + status_file=Path(settings.status_file_path), + poll_interval_seconds=settings.poll_interval_seconds, + activity=activity, + ) + dp.include_router(setup_handlers(monitor, activity)) + + # Background task: periodically check server status and push updates. + asyncio.create_task(monitor.run(bot, settings.telegram_chat_id)) + + await dp.start_polling(bot) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + pass diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4f998f0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +aiogram>=3.4.1,<4.0.0 +mcstatus>=11.1.1,<12.0.0 +python-dotenv>=1.0.1,<2.0.0 diff --git a/status_service.py b/status_service.py new file mode 100644 index 0000000..6aded34 --- /dev/null +++ b/status_service.py @@ -0,0 +1,159 @@ +import asyncio +import json +from dataclasses import dataclass, field, asdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +from aiogram import Bot +from mcstatus import JavaServer +from activity import ChatActivityTracker + + +@dataclass +class ServerSnapshot: + online: bool + motd: Optional[str] + version: Optional[str] + latency_ms: Optional[float] + players_online: int + players_max: int + player_names: list[str] = field(default_factory=list) + last_updated: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + + def to_dict(self) -> dict: + return asdict(self) + + +class MinecraftMonitor: + def __init__( + self, + host: str, + port: int, + status_file: Path, + poll_interval_seconds: int, + activity: ChatActivityTracker, + ) -> None: + self.server = JavaServer(host, port=port) + self.status_file = status_file + self.poll_interval_seconds = poll_interval_seconds + self._previous_snapshot: Optional[ServerSnapshot] = None + self.activity = activity + + async def fetch_status(self) -> ServerSnapshot: + try: + response = await self.server.async_status() + motd = getattr(response.description, "to_plain", lambda: None)() + player_names = [player.name for player in response.players.sample or []] + return ServerSnapshot( + online=True, + motd=motd, + version=response.version.name, + latency_ms=response.latency, + players_online=response.players.online, + players_max=response.players.max, + player_names=player_names, + ) + except Exception: + # Treat any exception as offline state but keep last known data safe. + return ServerSnapshot( + online=False, + motd=None, + version=None, + latency_ms=None, + players_online=0, + players_max=0, + player_names=[], + ) + + def _write_snapshot(self, snapshot: ServerSnapshot) -> None: + self.status_file.parent.mkdir(parents=True, exist_ok=True) + with self.status_file.open("w", encoding="utf-8") as fp: + json.dump( + { + "source": "monitor-bot", + "status": snapshot.to_dict(), + }, + fp, + ensure_ascii=False, + indent=2, + ) + + def read_cached_snapshot(self) -> Optional[ServerSnapshot]: + if not self.status_file.exists(): + return None + + try: + with self.status_file.open("r", encoding="utf-8") as fp: + payload = json.load(fp) + except (json.JSONDecodeError, OSError): + return None + + status = payload.get("status") if isinstance(payload, dict) else None + if not isinstance(status, dict): + return None + + try: + return ServerSnapshot( + online=bool(status.get("online")), + motd=status.get("motd"), + version=status.get("version"), + latency_ms=status.get("latency_ms"), + players_online=int(status.get("players_online", 0)), + players_max=int(status.get("players_max", 0)), + player_names=list(status.get("player_names", [])), + last_updated=status.get("last_updated") + or datetime.now(timezone.utc).isoformat(), + ) + except Exception: + return None + + def _build_notification(self, snapshot: ServerSnapshot) -> Optional[str]: + previous = self._previous_snapshot + self._previous_snapshot = snapshot + + if previous is None: + prefix = "Старт моніторингу." + return f"✅ {prefix} Сервер онлайн." if snapshot.online else f"⚠️ {prefix} Сервер офлайн." + + if previous.online != snapshot.online: + return "✅ Сервер знову онлайн." if snapshot.online else "⚠️ Сервер офлайн." + + return None + + async def _send_or_edit(self, bot: Bot, chat_id: int, text: str) -> None: + """Edit last bot message if нет новых сообщений в чате, иначе отправить новое.""" + if self.activity.can_edit_last_bot_message(): + message_id = self.activity.last_bot_message_id + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=text, + ) + if message_id is not None: + self.activity.record_bot_message(message_id) + return + except Exception: + # Если не получилось отредактировать (удалено/старое), отправим новое. + pass + + message = await bot.send_message(chat_id=chat_id, text=text) + self.activity.record_bot_message(message.message_id) + + async def run(self, bot: Bot, chat_id: int) -> None: + while True: + snapshot = await self.fetch_status() + self._write_snapshot(snapshot) + + message = self._build_notification(snapshot) + if message: + try: + await self._send_or_edit(bot, chat_id, message) + except Exception: + # Ignore send failures and try again in the next iteration. + pass + + await asyncio.sleep(self.poll_interval_seconds)