Telegram-бот как системный администратор сервера

При запуске сервера часто необходимо предоставить доступ к части функционала другим пользователям, при этом сами пользователи могут не иметь достаточных компетенций для полноценного использования софта и/или мы хотим ограничить доступный набор команд.

Одним из вариантов решения является Telegram-бот, который является прослойкой между пользователем и софтом. С таким решением я встречался на реальном опыте уже как минимум два раза, и на основе одного из них я постараюсь объяснить, как это можно сделать.

Дисклеймер

По специальности я программист C++ для Unreal Engine, а девопс и администрирование сервера — это часть пет-проекта и интереса к области, поэтому если вы знаете другие методы или способы по улучшению приведённого ниже, буду рад пообщаться в комментариях и/или лично.

Системный администратор мечты. Работает за киловатты в час

Системный администратор мечты. Работает за киловатты в час

Введение

Как я уже сказал у меня в опыте есть два примера.

Первый пример — это коммерческий проект, в котором я не успел поучаствовать, но знаю архитектурную часть. Одной из основных функций являлась возможность добавления контента в приложение художниками уже после релиза без вмешательства разработчиков. Для этого художники отправляли контент в специальном формате телеграм боту, и он им собирал новую версию приложения.

Второй пример уже из серии «сделай сам для себя». Недавно я запустил свой домашний сервер на Ubuntu, о котором вкратце рассказал в публикации. На этом сервере я запустил сервер Minecraft, на котором играл с друзьями. Чтобы серверный компьютер не был постоянно нагружен игровым сервером, мне нужно было дать возможность друзьям безболезненно включать и выключать сервер без моего участия, если они захотели часок другой поиграть без меня. Соответственно я решил воспользоваться возможностью и изучил как это можно сделать с помощью телеграм бота.

Вот на основе создания второго бота и будем смотреть, как написать и запустить подобное решение.

Telegram-бот

На просторах интернета полно стартовых гайдов по созданию телеграм ботов на Python, поэтому про основу бота буду писать кратко. Сам я нашёл много информации начиная с этой статьи и онлайн книги по библиотеке aiogram.

Настройка окружения

Первое, что мы делаем это создаёт директорию где будут лежать наши бот (ы), в котором создадим виртуальное окружение и установим aiogram и python-dotenv для файлов конфигурации.

mkdir telegram-bots
cd telegram-bots/
# Создание виртуального окружения
python3 -m venv bots-venv 
# Входим в виртуальное окружение
. bots-venv/bin/activate
# Установка библиотек
pip install aiogram python-dotenv pydantic pydantic_settings
# Выходим из виртуального окружения
deactivate

Для бота я создал отдельную поддиректорию

mkdir minecraft-bot
cd minecraft-bot

Основа бота

Для начала напишем основу бота, которая будет отвечать на команду /start и выводить клавиатуру для управления.

Создадим три файла minecraft_bot.py c основным кодом бота, config_reader.py для чтения .env файла и сам .env для хранения токена бота, который получается от BotFather.

minecraft_bot.py:

minecraft_bot.py Основа

# импорты
import asyncio
import logging
from aiogram import Bot, Dispatcher, types, F
from aiogram.filters.command import Command
from config_reader import config

# Включаем логирование, чтобы не пропустить важные сообщения
logging.basicConfig(level=logging.INFO)

# Бот с токеном из конфига
bot = Bot(token=config.bot_token.get_secret_value())
# Диспетчер, получающий апдейты телеграмма
dp = Dispatcher()

# Хэндлер на команду /start
@dp.message(Command("start"))
async def cmd_start(message: types.Message):
    # Клавиатура с тремя кнопками
    kb = [
        [types.KeyboardButton(text="Check server status")],
        [types.KeyboardButton(text="Start server")],
        [types.KeyboardButton(text="Stop server")],
    ]
    keyboard = types.ReplyKeyboardMarkup(keyboard=kb)
    await message.answer("Hi! \nThis bot can tell you server status and help you to start and stop it", reply_markup=keyboard)

# Хэндлеры кнопок
@dp.message(F.text.lower() == "check server status")
async def check_server_status(message: types.Message):
    await message.answer("Can't Check Server Status")

@dp.message(F.text.lower() == "start server")
async def check_server_status(message: types.Message):
    await message.answer("Starting server")

@dp.message(F.text.lower() == "stop server")
async def check_server_status(message: types.Message):
    await message.answer("Stopping server")


# Запуск процесса поллинга новых апдейтов
async def main():
    await dp.start_polling(bot)

if __name__ == "__main__":
    asyncio.run(main())

config_reader.py:

from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import SecretStr


class Settings(BaseSettings):
    # Желательно вместо str использовать SecretStr 
    # для конфиденциальных данных, например, токена бота
    bot_token: SecretStr

    # Начиная со второй версии pydantic, настройки класса настроек задаются
    # через model_config
    # В данном случае будет использоваться файла .env, который будет прочитан
    # с кодировкой UTF-8
    model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8')


# При импорте файла сразу создастся 
# и провалидируется объект конфига, 
# который можно далее импортировать из разных мест
config = Settings()

.env:

BOT_TOKEN = 12345678:AaBbCcDdEeFfGgHh

Первое общение с ботом

Первое общение с ботом

Для запуска выполняем команду python3 minecraft_bot.py в виртуальном окружении. В результате у нас есть ещё не самый умный, но уже рабочий бот, с удобными кнопками и ответами на их нажатие.

Bash скрипты

Итак, напомню, что мы создаём бота для управления сервером, то есть нам нужно уметь запускать процессы и отслеживать работают они или нет. Для этого нужно научиться запускать bash команды из программы Python (воспользуемся билиотеками os и subprocess), а также написать bash скрипты для остановки и запуска сервера Minecraft. Все bash скрипты создаём в той же директори, что и код для бота, и не забываем прописывать им разрешение на запуск sudo chmod +x *.sh.

Начнём с bash скриптов start-server.sh:

#!/bin/bash
# Переходим в директорию сервера
cd /home/user/NewServer
# Запускаем сервер в бекграунде, перенаправляем его вывод в nohup.output и отвязываем связь с скриптом
nohup java @user_jvm_args.txt @libraries/net/minecraftforge/forge/1.18.2-40.2.9/unix_args.txt nogui > nohup.output 2>&1 & 
# Выводим pid последнего запущенного процесса (сервера) в консоль и в файл server.pid
echo $!
echo $! > /home/user/telegram-bots/minecraft-bot/server.pid  

И stop-server.sh:

#!/bin/bash
# Проверяем что существует процесс с pid переданном в первом аргументе и убиваем его
if ps -p $1 > /dev/null
then
    echo "Trying to stop pid $1"
    kill $1
else
    echo "No pid"
fi

Для проверки работы процесса используем библиотеку os:

import os

def check_pid(pid):        
    try:
        os.kill(pid, 0)
    except OSError:
        return False
    else:
        return True

Для запуска bash скриптов — subprocess:

# Запуск скрипта старта сервера и запись вывода в переменную result
result = subprocess.run(["sh", "./start-server.sh"], capture_output=True, text=True).stdout
# Запуск скрипта остановки сервера (процесса с pid)
subprocess.call(["sh", "./stop-server.sh", pid])

Хранение данных

Последний момент, которого нам не хватает для запуска бота — это хранение pid’а запущенного процесса. Pid — это сокращение от process id, то есть уникальный номер процесса который сейчас исполняется в системе. Для хранения pid можно было бы использовать глобальную переменную, но я противник не константных глобальных переменных. Поэтому запишем всё в файл json, с помощью одноименной библиотеки (при большем количестве данных можно подключать базу данных):

Использование json для хранения pid

import json
import os.path

# Имя json-файла и переменной в нём
SERVER_DATA_PATH = 'minecraft_server.json'
PID = "pid"

# Получение всех данных из json-файла
def get_server_data():
	
	if os.path.isfile(SERVER_DATA_PATH):
		with open(SERVER_DATA_PATH, 'r') as openfile:
			return json.load(openfile)
	else:
		return {}

# Запись всех данных в json-файла
def set_server_data(server_data):
	with open(SERVER_DATA_PATH, 'w') as f:
		json.dump(server_data, f)

# Запись всех данных в json-файла с изменённым значением
def set_server_data_value(key, value):
	server_data = get_server_data()
	server_data[key] = value
	set_server_data(server_data)

def check_pid(pid):        
    try:
        os.kill(pid, 0)
    except OSError:
        return False
    else:
        return True

# Проверка запущен ли сервер
def check_server_process():
	server_data = get_server_data()
	if not PID in server_data:
		return False
	
	if server_data[PID] == -1:
		return False
	
	return check_pid(server_data[PID])

Вот полный код полученного бота:

minecraft_bot.py

# импорты
import asyncio
import logging
from aiogram import Bot, Dispatcher, types, F
from aiogram.filters.command import Command
from config_reader import config
import subprocess
import json
import os.path
import os

# Имя json-файла и переменной в нём
SERVER_DATA_PATH = 'minecraft_server.json'
PID = "pid"

# Получение всех данных из json-файла
def get_server_data():
	
	if os.path.isfile(SERVER_DATA_PATH):
		with open(SERVER_DATA_PATH, 'r') as openfile:
			return json.load(openfile)
	else:
		return {}

# Запись всех данных в json-файла
def set_server_data(server_data):
	with open(SERVER_DATA_PATH, 'w') as f:
		json.dump(server_data, f)

# Запись всех данных в json-файла с изменённым значением
def set_server_data_value(key, value):
	server_data = get_server_data()
	server_data[key] = value
	set_server_data(server_data)

def check_pid(pid):        
    try:
        os.kill(pid, 0)
    except OSError:
        return False
    else:
        return True

# Проверка запущен ли сервер
def check_server_process():
	server_data = get_server_data()
	if not PID in server_data:
		return False
	
	if server_data[PID] == -1:
		return False
	
	return check_pid(server_data[PID])

# Включаем логирование, чтобы не пропустить важные сообщения
logging.basicConfig(level=logging.INFO)

# Бот с токеном из конфига
bot = Bot(token=config.bot_token.get_secret_value())
# Диспетчер, получающий апдейты телеграмма
dp = Dispatcher()

# Хэндлер на команду /start
@dp.message(Command("start"))
async def cmd_start(message: types.Message):
    # Клавиатура с тремя кнопками
    kb = [
        [types.KeyboardButton(text="Check server status")],
        [types.KeyboardButton(text="Start server")],
        [types.KeyboardButton(text="Stop server")],
    ]
    keyboard = types.ReplyKeyboardMarkup(keyboard=kb)
    await message.answer("Hi! \nThis bot can tell you server status and help you to start and stop it", reply_markup=keyboard)

# Хэндлеры кнопок
@dp.message(F.text.lower() == "check server status")
async def check_server_status(message: types.Message):
    if check_server_process():
        await message.answer("Server is running")
        return
    await message.answer("Server is stopped")

@dp.message(F.text.lower() == "start server")
async def check_server_status(message: types.Message):
    if check_server_process():
        await message.answer("Server is already running")
        return
    result = subprocess.run(["sh", "./start-server.sh"], capture_output=True, text=True).stdout
    set_server_data_value(PID, int(result))
    await message.answer("Starting server")

@dp.message(F.text.lower() == "stop server")
async def check_server_status(message: types.Message):
    server_data = get_server_data()
    if check_server_process():
        subprocess.call(["sh", "./stop-server.sh", str(server_data[PID])])
        set_server_data_value(PID, -1)
        await message.answer("Stoping server")
        return
    await message.answer("Server is already stopped")


# Запуск процесса поллинга новых апдейтов
async def main():
    await dp.start_polling(bot)

if __name__ == "__main__":
    asyncio.run(main())

Автозапуск бота

Последний штрих этого проекта — это автозапуск нашего бота при включении Ubuntu сервера и чистка процессов при неожиданных вылетах бота. Для этого снова обратимся к bash скриптам и напишем сервис, который будет их запускать.

start.sh для запуска телеграм бота, все команды уже были рассмотрены ранее:

#!/bin/bash
cd /home/user/telegram-bots
. bots-venv/bin/activate
cd minecraft-bot
python3 minecraft_bot.py

stop.sh для остановки сервера в случае падения бота (вот тут нам и пригодился pid записанный в файл server.pid)

#!/bin/bash
cd /home/user/telegram-bots/minecraft-bot
# Читаем pid Minecraft сервера
server_pid=$(head -n 1 server.pid)
# Останавливаем Minecraft сервер и удаляем файлы с pid данными
sh ./stop-server.sh "$server_pid"
rm minecraft_server.json
rm server.pid

Теперь осталось только написать сервис, который будет запускаться с помощью systemd, создадим файл /etc/systemd/system/minecraft-bot.service:

[Unit]
Description=Minecraft Server Telegram Bot

[Service]
ExecStart=/home/user/telegram-bots/minecraft-bot/start.sh
ExecStop=/home/user/telegram-bots/minecraft-bot/stop.sh
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target

Здесь мы указали какие скрипты запускать при запуске (ExecStart) и остановке (ExecStop) сервиса. Также прописали, что сервис необходимо перезапустить при его падении (Restart=on-failure) и пробовать это сделать каждые 5 секунд (RestartSec=5s).

После чего выполняем следующие команды для запуска сервиса:

sudo systemctl daemon-reload
sudo systemctl enable minecraft-bot.service
sudo systemctl start minecraft-bot.service

Вот на этом моменте создание бота можно считать оконченным.

Заключение

В этой статье я хотел отразить весь процесс создания Telegram-бота для администрирования сервера Ubuntu, а именно на примере запуска, остановки и проверки работы сервера Minecraft.

Плюсы данного решения заключаются в том, что:

  • Дружелюбный интерфейс. Пользователю не надо быть гением linux и программирования, чтобы взаимодействовать с софтом на сервере.

  • Лёгкий доступ. Не надо иметь доступ по ip, так как все запросы идут через сервер Telegram. Кроме этого бот позволяет работать с любого устройство с установленным Telegram

  • Гибкий контроль доступа. Разработчик полностью контролирует функционал доступный пользователю, а также можно ввести базу данных с авторизацией для разных уровней доступа.

Из минусов могу выделить:

  • Сложность решения. Много трудозатрат может уйти на само создание бота, особенно с более сложно логикой, что может быть менее выгодно, чем обучить пользователя работать с функционалом без красивой прослойки.

P.S.

Материал был собран на основе самостоятельного обучения, поэтому всегда буду рад предложениям по улучшению в комментариях или личном общении.

© Habrahabr.ru