MAATRIX GAMES / Блог / Discord-бот для статистики онлайна игроков

Discord-бот для статистики онлайна игроков

MAATRIX GAMES

«Сколько у нас обычно людей по вечерам?» — вопрос, на который у большинства админов нет ответа, кроме «ну, вроде, много». Панель хостинга покажет текущий онлайн, но не покажет, во сколько сервер реально забивается под завязку и когда там пусто хоть шаром покати. А это ровно та информация, которая нужна, чтобы ставить рестарт и вайп не наугад, а в мёртвый час, и анонсировать ивент так, чтобы на него пришли, а не пропустили мимо. Соберём Discord-бота, который сам опрашивает сервер, копит историю и по команде рисует график — без сторонних сервисов мониторинга и без ручного вбивания цифр в таблицу.

Что должен делать бот и почему не хватает готовых сервисов

Задача разбивается на четыре простые части: опрашивать сервер через query-протокол с заданным интервалом, писать результат (время, онлайн, макс. слотов) в локальное хранилище, по накопленным данным строить график активности за период и отдавать текущий и исторический онлайн по команде игрока прямо в Discord.

Готовые виджеты статуса сервера (те, что вставляют на форумы и в шапку Discord-канала) решают только первую и четвёртую задачу — они показывают, сколько человек сейчас, но не хранят историю дольше пары часов и не умеют считать «пиковые часы за последний месяц». А именно эта цифра нужна, чтобы решить: рестарт в 6 утра по МСК — это действительно тихое окно или там как раз играет половина расписания из другого часового пояса.

Технически всё держится на трёх опорах: библиотека для query-запросов к серверу (подробно про сам протокол — в статье про Server Query API и про A2S-протокол Source-игр), планировщик, который дёргает опрос по расписанию, и база данных для хранения точек. Discord-бот в этой схеме — просто фасад: он читает то же хранилище и рисует ответ.

Стек и структура проекта

Для многоигровой аренды (у вас может стоять Minecraft, Rust, CS2, ARK и ещё десяток игр на разных серверах MAATRIX) удобнее не писать парсер под каждый протокол вручную, а взять библиотеку gamedig — она умеет опрашивать под сотню игровых протоколов через единый интерфейс и покрывает практически весь каталог: minecraft, rust, csgo/cs2, arkse, valheim и так далее. Если сервер один и это Source-игра — можно обойтись собственным A2S-запросом, как описано в статье про протокол выше, но для универсального бота gamedig экономит недели.

mkdir online-stats-bot && cd online-stats-bot
npm init -y
npm install discord.js gamedig better-sqlite3 node-cron chartjs-node-canvas dotenv

Структура проекта:

online-stats-bot/
├── .env                  # DISCORD_TOKEN, GAME_HOST, GAME_PORT, GAME_TYPE
├── db.js                 # инициализация SQLite и запросы
├── poller.js             # cron-задача опроса сервера
├── chart.js              # рендер графика в PNG
├── deploy-commands.js    # регистрация слэш-команд
├── index.js              # точка входа бота
└── stats.sqlite          # файл базы (создаётся сам)

Если бота ещё нет и токен в Discord Developer Portal не выпущен — не буду повторять эту часть здесь, она подробно разобрана в статье про Discord-бота для управления сервером: там про токен, права Gateway Intents, discord.js/discord.py и автозапуск через pm2 или systemd. Дальше исхожу из того, что базовый бот уже стартует и цепляется к серверу Discord.

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

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

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

Опрос сервера: query-протокол и разумный интервал

Прежде чем опрашивать сервер, на нём нужно включить query-протокол — по умолчанию он часто выключен. Для Minecraft (Java) это server.properties:

enable-query=true
query.port=25565

Для Source-игр (CS2, Rust — частично, у Rust свой Steam Query поверх RCON-порта) протокол обычно включён из коробки на игровом порту или порту +1, но конкретный порт зависит от конфигурации хостинга — уточните его в панели управления сервером, а не угадывайте.

Сам опрос через gamedig выглядит так:

// poller.js
const { GameDig } = require('gamedig');
const db = require('./db');

async function pollOnce() {
  try {
    const state = await GameDig.query({
      type: process.env.GAME_TYPE,   // 'minecraft', 'rust', 'cs2'...
      host: process.env.GAME_HOST,
      port: Number(process.env.GAME_PORT),
    });
    db.insertSample({ ts: Date.now(), online: state.players.length, maxPlayers: state.maxplayers });
  } catch (err) {
    console.warn('[poller] query failed:', err.message); // рестарт сервера — нормальный пропуск точки
  }
}

module.exports = { pollOnce };

Частота опроса — компромисс между точностью графика и нагрузкой на сервер и хранилище. Раз в минуту даёт красивый плавный график, но за месяц это ~43 тысячи строк — для SQLite не проблема, но избыточно для задачи «понять пиковые часы». На практике интервала в 5 минут вполне хватает: пики активности размазаны на часы, а не на минуты, и такая частота не создаёт лишней нагрузки на query-порт при десятках одновременных проверок (uptime-мониторинг, виджеты, ваш бот).

// index.js (фрагмент)
const cron = require('node-cron');
const { pollOnce } = require('./poller');

cron.schedule('*/5 * * * *', pollOnce); // каждые 5 минут

Если сервер держит несколько игр или несколько инстансов, просто заводите отдельную задачу cron.schedule и отдельную таблицу (или колонку server_id) на каждый — не пытайтесь совмещать разнородные query в один запрос.

Хранилище: SQLite вместо JSON, если история важна всерьёз

Для прототипа JSON-файл с массивом точек работает, но у него два слабых места: файл целиком перезаписывается на каждой вставке (риск повредить данные при падении процесса посреди записи) и любая выборка «онлайн за последнюю неделю» требует прогнать весь файл в памяти. Если бот живёт больше пары недель и опрашивает раз в 5 минут, лучше сразу взять SQLite через better-sqlite3 — она синхронная, без внешнего процесса СУБД и с индексами из коробки.

// db.js
const Database = require('better-sqlite3');
const db = new Database('stats.sqlite');

db.exec(`
  CREATE TABLE IF NOT EXISTS samples (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    ts INTEGER NOT NULL, online INTEGER NOT NULL, max_players INTEGER NOT NULL
  );
  CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts);
`);

const insertSample = ({ ts, online, maxPlayers }) =>
  db.prepare('INSERT INTO samples (ts, online, max_players) VALUES (?, ?, ?)').run(ts, online, maxPlayers);

const getRange = (sinceTs) =>
  db.prepare('SELECT ts, online FROM samples WHERE ts >= ? ORDER BY ts ASC').all(sinceTs);

const getLatest = () =>
  db.prepare('SELECT ts, online, max_players FROM samples ORDER BY ts DESC LIMIT 1').get();

module.exports = { insertSample, getRange, getLatest };

Если хочется совсем без зависимостей на нативные модули (актуально при деплое на некоторые бесплатные PaaS, где сборка better-sqlite3 из исходников иногда капризничает) — храните JSON, но раз в сутки ротируйте файл (по одному файлу на день) и не держите в памяти больше, чем нужно для текущего графика. Для долгоживущего сервера с реальной историей SQLite всё же надёжнее — держите это в уме как явный компромисс, а не универсальный совет «JSON всегда хуже».

Не забудьте про бэкап файла stats.sqlite — он не критичен так, как сейв мира, но терять полугодовую историю онлайна обидно. Если у вас уже настроена автоматизация бэкапов игрового сервера, добавьте в неё и файл базы бота — принципы те же, что описаны в статье про автобэкапы игрового сервера.

Графики пиковой активности: строим и отдаём в Discord

Для рендера графика без браузера подходит chartjs-node-canvas — она рисует Chart.js на сервере в PNG-буфер, который можно сразу отправить как вложение сообщения.

// chart.js
const { ChartJSNodeCanvas } = require('chartjs-node-canvas');
const canvas = new ChartJSNodeCanvas({ width: 900, height: 400, backgroundColour: '#2b2d31' });

async function renderOnlineChart(samples, label) {
  const config = {
    type: 'line',
    data: {
      labels: samples.map(s => new Date(s.ts).toLocaleString('ru-RU', { hour: '2-digit', day: '2-digit' })),
      datasets: [{ label, data: samples.map(s => s.online), borderColor: '#5865F2',
        backgroundColor: 'rgba(88,101,242,0.2)', fill: true, tension: 0.25, pointRadius: 0 }],
    },
    options: {
      scales: { x: { ticks: { color: '#ccc', maxTicksLimit: 12 } }, y: { ticks: { color: '#ccc' }, beginAtZero: true } },
      plugins: { legend: { labels: { color: '#ccc' } } },
    },
  };
  return canvas.renderToBuffer(config);
}

module.exports = { renderOnlineChart };

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

function peakHours(samples) {
  const byHour = Array(24).fill(0).map(() => []);
  for (const s of samples) byHour[new Date(s.ts).getHours()].push(s.online);
  return byHour.map((arr, hour) => ({ hour, avg: arr.length ? arr.reduce((a, b) => a + b) / arr.length : 0 }));
}

Час с минимальным средним онлайном за 2-4 недели — кандидат на автоматический рестарт: логику самого расписания и cron-задачи разбирали отдельно в статье про автоматический рестарт сервера по расписанию, здесь же вы получаете конкретное число вместо интуиции «наверное, ночью потише».

Команды для игроков: /online и /stats

Слэш-команды регистрируются один раз через отдельный скрипт (их не нужно перерегистрировать при каждом запуске бота):

// deploy-commands.js
const { REST, Routes, SlashCommandBuilder } = require('discord.js');
require('dotenv').config();

const commands = [
  new SlashCommandBuilder().setName('online').setDescription('Текущий онлайн сервера'),
  new SlashCommandBuilder().setName('stats').setDescription('График онлайна за период')
    .addStringOption(opt => opt.setName('period').setDescription('день / неделя / месяц')
      .addChoices({ name: 'День', value: '1' }, { name: 'Неделя', value: '7' }, { name: 'Месяц', value: '30' })),
].map(c => c.toJSON());

const rest = new REST().setToken(process.env.DISCORD_TOKEN);
rest.put(Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID), { body: commands })
  .then(() => console.log('Команды зарегистрированы'));

Обработчик самих команд в index.js:

const { Client, GatewayIntentBits, AttachmentBuilder } = require('discord.js');
const db = require('./db');
const { renderOnlineChart } = require('./chart');

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

client.on('interactionCreate', async (interaction) => {
  if (!interaction.isChatInputCommand()) return;

  if (interaction.commandName === 'online') {
    const latest = db.getLatest();
    if (!latest) return interaction.reply('Данных пока нет — бот только запустился.');
    const minutesAgo = Math.round((Date.now() - latest.ts) / 60000);
    await interaction.reply(`🟢 На сервере сейчас **${latest.online}/${latest.max_players}** игроков (данные ${minutesAgo} мин. назад).`);
  }

  if (interaction.commandName === 'stats') {
    await interaction.deferReply();
    const days = Number(interaction.options.getString('period') ?? '7');
    const samples = db.getRange(Date.now() - days * 86400000);
    if (samples.length < 2) return interaction.editReply('Пока недостаточно данных для графика — попробуйте позже.');
    const buffer = await renderOnlineChart(samples, `Онлайн за ${days} дн.`);
    await interaction.editReply({ files: [new AttachmentBuilder(buffer, { name: 'online-stats.png' })] });
  }
});

client.login(process.env.DISCORD_TOKEN);

Игроку не нужно ничего знать про query-протоколы и cron — он просто пишет /online в канале и получает актуальную цифру, или /stats period:Неделя и получает график. Для сообщества это заодно небольшой элемент вовлечённости: люди любят видеть, что сервер живёт и «сейчас как раз собирается пати».

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

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

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

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

Что если сервер перезагружается и опрос падает на несколько минут?

Это нормально — pollOnce в примере просто логирует ошибку и не пишет точку в базу. График корректно покажет разрыв, а не нолики, если не подставлять online: 0 на ошибку опроса — не путайте «сервер недоступен» и «сервер доступен, но пуст».

Можно ли использовать RCON вместо query-протокола, чтобы получить список ников?

Да, для игр с RCON (Minecraft через list, Rust, некоторые Source-игры) RCON даёт больше деталей, включая точные никнеймы, но требует пароля и, как правило, чуть дороже по нагрузке и задержке, чем UDP query. Для чистой статистики численности query-протокола достаточно; RCON имеет смысл подключать, если нужна ещё и функция «кто сейчас в топе онлайна».

Как быть с несколькими серверами (например, разные локации UK/US/RU)?

Заведите отдельную строку конфигурации и, по желанию, колонку server_id в таблице samples, плюс отдельные слэш-команды или параметр в /online server:eu — логика опроса и хранения не меняется, просто дублируется на каждый инстанс.

Нужен ли отдельный сервер под самого бота или хватит того же хостинга?

Discord-бот с такой нагрузкой (один опрос раз в 5 минут плюс редкие слэш-команды) требует минимум ресурсов — конфигурация от 512 МБ ОЗУ обычно достаточна, важнее не ронять процесс, чем давать ему много памяти.

Как долго хранить историю, чтобы база не разрослась зря?

Год ежедневных 5-минутных точек — это около 105 тысяч строк, для SQLite несерьёзная цифра. Если хочется подчистить старьё, добавьте раз в месяц агрегацию старых точек в почасовые средние и удаляйте сырые записи старше, скажем, 90 дней — так график за год останется, а таблица не будет расти бесконечно.