initial commit

This commit is contained in:
2025-12-14 22:45:11 +02:00
commit cf1801bff4
11 changed files with 493 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
__pycache__/
*.pyc
.venv/
venv/
.git/
.gitignore
data/
.env
.env.example

6
.env.example Normal file
View File

@@ -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

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# Python
__pycache__/
*.pyc
.venv/
venv/
# Env
.env
# Project data
data/
# IDE
.idea/
.vscode/

14
Dockerfile Normal file
View File

@@ -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"]

80
README.md Normal file
View File

@@ -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`.

34
activity.py Normal file
View File

@@ -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

77
bot_handlers.py Normal file
View File

@@ -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

51
config.py Normal file
View File

@@ -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,
)

45
main.py Normal file
View File

@@ -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

3
requirements.txt Normal file
View File

@@ -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

159
status_service.py Normal file
View File

@@ -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)