MAATRIX GAMES / Блог / Discord-бот для автоматического перезапуска сервера по расписанию

Discord-бот для автоматического перезапуска сервера по расписанию

MAATRIX GAMES

Плановый рестарт сервера — штука нужная: чистит память от утечек, подхватывает обновлённые конфиги, сбрасывает мелкие баги, которые копятся за сутки аптайма. Проблема в другом — если рестарт прилетает без предупреждения, игроки теряют прогресс, крафт, а иногда и нервы. Обычный cron с командой restart эту задачу не решает: он немой. Ниже — как собрать Discord-бота, который сам следит за расписанием, предупреждает в текстовом и голосовом канале, дёргает RCON и пишет результат в лог, чтобы вы не гадали, прошёл рестарт штатно или сервер лёг.

Почему просто cron — не решение

Классический способ — cron-задача на хосте, которая раз в сутки шлёт sudo systemctl restart <server>.service. Он живёт своей жизнью, ничего не знает о Discord и не умеет предупредить игроков. Раньше эту дыру закрывали костылём: отдельный скрипт, который за 10 минут до рестарта дёргает вебхук в канал, а сам рестарт всё равно делает cron. Работает, но два независимых механизма — это два места, где может разъехаться расписание при переводе времени или ручной правке crontab.

Идея статьи — свести всё в одного бота: он сам держит расписание, сам считает обратный отсчёт, сам вызывает RCON и сам логирует итог. Никакого второго cron на хосте не нужно — точка правды одна.

Если вам не нужен именно "живой" бот с отсчётом в голосовом, а хватает тихого рестарта по расписанию — базовый вариант через cron и systemd разобран в статье про автоматический рестарт по расписанию. Здесь мы идём дальше и добавляем коммуникацию с игроками.

Стек, структура и .env

Бот пишется на discord.py (или py-cord — форк с более активной поддержкой voice на 2026 год) плюс библиотека для RCON-протокола. Понадобится:

  • Python 3.11+ на машине, откуда бот будет ходить в RCON (можно прямо на сервере, можно с отдельной VPS — главное, чтобы RCON-порт был доступен).
  • Токен Discord-приложения с правами bot и applications.commands, включённый intent MESSAGE CONTENT в Developer Portal.
  • RCON-пароль и порт вашего игрового сервера — где их искать и как проверить подключение, подробно разобрано в статье про RCON: подключение и основные команды.
  • Библиотека rcon (пакет mcrcon для Minecraft или универсальный rcon, работающий по протоколу Source RCON для Rust, ARK, CS2 и других игр на движке Source/Unreal).
  • APScheduler для гибкого cron-триггера внутри самого бота — надёжнее, чем ручной asyncio.sleep в цикле.
python3 -m venv venv
source venv/bin/activate
pip install discord.py apscheduler rcon python-dotenv

Файл .env — храним секреты отдельно от кода, чтобы токен не улетел в git:

DISCORD_TOKEN=ваш_токен_бота
RCON_HOST=127.0.0.1
RCON_PORT=25575
RCON_PASSWORD=ваш_rcon_пароль
TEXT_CHANNEL_ID=123456789012345678
VOICE_CHANNEL_ID=123456789012345679
LOG_CHANNEL_ID=123456789012345680
RESTART_CRON=0 5 * * *

RESTART_CRON — время рестарта в формате cron (в данном примере — 5:00 по часовому поясу бота, ночное окно с минимумом онлайна). Файлы бота: bot.py, restart_manager.py, .env и лог restart.log в одной папке.

Нужен игровой сервер?

MAATRIX GAMES — 45+ игр, деплой за минуты, DDoS-защита, автобэкапы. Тарифы от 399 ₽/мес.

Создать сервер

Ядро: cron-триггер внутри бота

Вместо системного crontab используем AsyncIOScheduler из APScheduler — он живёт внутри event loop бота и не требует отдельного процесса на хосте.

# bot.py
import os
import logging
from datetime import datetime
import discord
from discord.ext import commands
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from dotenv import load_dotenv
from restart_manager import do_countdown_and_restart

load_dotenv()

logging.basicConfig(
    filename="restart.log",
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
)

intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix="!", intents=intents)

scheduler = AsyncIOScheduler(timezone="Europe/Moscow")

@bot.event
async def on_ready():
    print(f"Бот запущен как {bot.user}")
    cron_expr = os.getenv("RESTART_CRON", "0 5 * * *")
    minute, hour, day, month, dow = cron_expr.split()
    trigger = CronTrigger(minute=minute, hour=hour, day=day, month=month, day_of_week=dow)
    scheduler.add_job(scheduled_restart, trigger, id="scheduled_restart")
    scheduler.start()
    logging.info("Планировщик запущен, расписание: %s", cron_expr)

async def scheduled_restart():
    await do_countdown_and_restart(bot)

bot.run(os.getenv("DISCORD_TOKEN"))

Обратите внимание на часовой пояс в AsyncIOScheduler(timezone=...) — это частая грабля: если оставить дефолт, бот будет считать по UTC, и рестарт "ночью в 5 утра" внезапно окажется днём.

Отсчёт в чате и статус в голосовом канале

Логика отсчёта и сам вызов RCON вынесены в отдельный модуль — так удобнее переиспользовать и для ручной команды !restart_now, и для планового триггера.

# restart_manager.py
import asyncio
import logging
import os
from rcon.source import rcon

TEXT_CHANNEL_ID = int(os.getenv("TEXT_CHANNEL_ID"))
VOICE_CHANNEL_ID = int(os.getenv("VOICE_CHANNEL_ID"))
LOG_CHANNEL_ID = int(os.getenv("LOG_CHANNEL_ID"))
RCON_HOST = os.getenv("RCON_HOST")
RCON_PORT = int(os.getenv("RCON_PORT"))
RCON_PASSWORD = os.getenv("RCON_PASSWORD")

WARNING_STEPS = [600, 300, 60, 10]  # секунды до рестарта, когда шлём напоминание

async def do_countdown_and_restart(bot):
    text_channel = bot.get_channel(TEXT_CHANNEL_ID)
    log_channel = bot.get_channel(LOG_CHANNEL_ID)

    await text_channel.send(
        "⚠️ Плановый рестарт сервера через **10 минут**. Сохраните прогресс, найдите безопасное место."
    )
    await send_rcon_warning("Server restart in 10 minutes. Please find a safe spot.")

    remaining = 600
    for step in WARNING_STEPS[1:]:
        await asyncio.sleep(remaining - step)
        remaining = step
        minutes = step // 60
        text = f"{minutes} мин." if minutes else f"{step} сек."
        await text_channel.send(f"⏳ Рестарт через **{text}**.")
        await send_rcon_warning(f"Server restart in {text}.")

    await asyncio.sleep(remaining)
    await text_channel.send("🔄 Перезапускаю сервер...")

    try:
        response = await rcon("restart", host=RCON_HOST, port=RCON_PORT, passwd=RCON_PASSWORD)
        logging.info("RCON restart отправлен, ответ: %s", response)
        await text_channel.send("✅ Команда рестарта отправлена, сервер поднимется через 1-2 минуты.")
        if log_channel:
            await log_channel.send(f"[{bot.user}] Плановый рестарт выполнен успешно. Ответ RCON: `{response}`")
    except Exception as e:
        logging.error("Ошибка при рестарте: %s", e)
        await text_channel.send("❌ Рестарт не удался, проверяю вручную.")
        if log_channel:
            await log_channel.send(f"⚠️ Ошибка планового рестарта: `{e}`")

async def send_rcon_warning(message: str):
    try:
        await rcon(f"say {message}", host=RCON_HOST, port=RCON_PORT, passwd=RCON_PASSWORD)
    except Exception as e:
        logging.warning("Не удалось отправить внутриигровое предупреждение: %s", e)

Важный нюанс — команда restart не универсальна: у части серверных бинарников (например, чистый Source-движок) такой RCON-команды может не быть вовсе, и рестарт делается через quit + внешний watchdog (systemd с Restart=always), который поднимает процесс заново. Проверьте, что конкретно понимает ваш сервер, командой rcon "help" — не полагайтесь на то, что restart сработает "как везде".

Ловушка в названии темы — "голосовой отсчёт" не означает, что бот обязан зачитывать текст голосом в канале (это отдельная и куда более тяжёлая история с discord.py[voice], FFmpeg и TTS-движком). Практичнее и надёжнее — менять имя голосового канала на текущий статус, это видно всем, кто сейчас в войсе, без возни с аудио-стримом:

async def update_voice_status(bot, text: str):
    voice_channel = bot.get_channel(VOICE_CHANNEL_ID)
    if voice_channel:
        try:
            await voice_channel.edit(name=f"🎮 {text}")
        except discord.HTTPException as e:
            logging.warning("Не удалось переименовать голосовой канал: %s", e)

Учтите лимит Discord API — переименование канала ограничено примерно двумя изменениями в 10 минут, так что вызывайте update_voice_status только на ключевых шагах (за 10 минут, за 1 минуту, после рестарта), а не на каждом тике отсчёта. Полноценный голосовой бот, который заходит в канал и озвучивает статус — отдельная механика подключения к voice и стриминга аудио, за рамками этой статьи.

Ручной запуск и защита от случайного дубля

Полезно оставить возможность запустить тот же сценарий вручную — например, перед экстренным патчем, — и заблокировать повторный запуск, если рестарт уже идёт:

restart_lock = asyncio.Lock()

@bot.command(name="restart_now")
@commands.has_permissions(administrator=True)
async def restart_now(ctx):
    if restart_lock.locked():
        await ctx.send("Рестарт уже выполняется, подождите.")
        return
    async with restart_lock:
        await ctx.send("Запускаю внеплановый рестарт.")
        await do_countdown_and_restart(bot)

@restart_now.error
async def restart_now_error(ctx, error):
    if isinstance(error, commands.MissingPermissions):
        await ctx.send("Команда доступна только администраторам.")

commands.has_permissions(administrator=True) — минимальная защита, но для боевого сервера лучше явно проверять роль по ID через кастомный decorator, чтобы не зависеть от того, кто как назвал роли на сервере Discord.

Логирование и что делать, если рестарт "молчит"

Файл restart.log пишет каждый шаг — от старта планировщика до ответа RCON. Для долгоживущего бота лог стоит ротировать, иначе за пару месяцев файл разрастётся:

from logging.handlers import RotatingFileHandler

handler = RotatingFileHandler("restart.log", maxBytes=5_000_000, backupCount=3)
logging.basicConfig(handlers=[handler], level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")

Отдельно стоит добавить проверку "сервер действительно поднялся" — RCON рапортует об успехе, но процесс иногда падает при старте из-за битого конфига или занятого порта:

async def verify_server_up(bot):
    await asyncio.sleep(90)
    log_channel = bot.get_channel(LOG_CHANNEL_ID)
    try:
        response = await rcon("list", host=RCON_HOST, port=RCON_PORT, passwd=RCON_PASSWORD)
        await log_channel.send(f"✅ Сервер отвечает после рестарта: `{response}`")
    except Exception:
        await log_channel.send("🚨 Сервер не отвечает через 90 секунд после рестарта — нужна ручная проверка!")

Команда list — универсальный "пинг" (список игроков онлайн), который поддерживают почти все RCON-реализации, но название может отличаться в вашей сборке — сверьтесь с документацией движка.

Запуск как systemd-сервис

Чтобы бот не падал вместе с закрытой SSH-сессией и поднимался сам после перезагрузки хоста, оформите его unit-файлом:

# /etc/systemd/system/discord-restart-bot.service
[Unit]
Description=Discord bot for scheduled server restarts
After=network.target

[Service]
Type=simple
User=gameadmin
WorkingDirectory=/home/gameadmin/discord-restart-bot
ExecStart=/home/gameadmin/discord-restart-bot/venv/bin/python bot.py
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now discord-restart-bot.service
sudo systemctl status discord-restart-bot.service

Restart=on-failure — важная деталь: если сам бот упадёт (например, Discord API временно недоступен), systemd поднимет его снова, и запланированный APScheduler-джоб восстановится при следующем on_ready.

Нужен игровой сервер?

MAATRIX GAMES — 45+ игр, деплой за минуты, DDoS-защита, автобэкапы. Тарифы от 399 ₽/мес.

Создать сервер

Частые вопросы

Можно ли обойтись без Python и просто вебхуком из crontab?

Да, для минимального варианта — cron дёргает curl с вебхуком в канал за 10 минут, а следом отдельной командой RCON. Но тогда отсчёт будет грубым (одно сообщение, а не серия), и вы теряете единую точку логирования — придётся смотреть два места вместо одного.

Бот обязательно должен сидеть в голосовом канале?

Нет. Переименование канала-статуса — самый лёгкий и надёжный способ показать состояние "всем, кто в войсе", без подключения к аудио-потоку и без риска упереться в лимиты FFmpeg/opus.

Что если RCON-пароль или порт закрыты фаерволом?

Первым делом проверьте доступность порта с той машины, где крутится бот: nc -zv RCON_HOST RCON_PORT. Если бот и сервер на разных VPS — откройте порт только для IP бота, не для всего интернета, RCON без ограничения по IP — частая причина взлома сервера.

Расписание съехало после перехода на зимнее/летнее время?

Убедитесь, что timezone в AsyncIOScheduler указан явно (например, "Europe/Moscow"), а не оставлен по умолчанию в UTC — так CronTrigger сам учтёт переход, не потребуется править RESTART_CRON вручную дважды в год.

Нужно ли предупреждать игроков внутриигровым say, если уже есть Discord-сообщение?

Да, обязательно — часть игроков сидит в игре без открытого Discord, и say-команда через RCON — единственный способ достучаться до них напрямую в момент отсчёта.