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