initial commit
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
6
.env.example
Normal file
6
.env.example
Normal 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
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Project data
|
||||||
|
data/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal 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
80
README.md
Normal 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
34
activity.py
Normal 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
77
bot_handlers.py
Normal 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
51
config.py
Normal 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
45
main.py
Normal 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
3
requirements.txt
Normal 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
159
status_service.py
Normal 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)
|
||||||
Reference in New Issue
Block a user