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