RE:NODE

Приложения15 мин чтения

Как запустить Discord-бота на discord.js на хостинге 24/7

Как держать бота на discord.js онлайн постоянно: токен, intents, команда запуска, переживающая перезапуски, деплой из GitHub и сбои, из-за которых боты падают.

0 прочтений

Discord-бот онлайн ровно столько, сколько работает его процесс. Никакой очереди, которая бы его будила, и никакого запроса, который запускал бы его по требованию, нет: если процесс не подключён к gateway, бот отображается офлайн, а каждая slash-команда завершается ошибкой «The application did not respond». Поэтому запуск бота 24/7 сводится к одному не самому эффектному делу: держать небольшой процесс Node живым на машине, которая не ваш ноутбук, перезапускать его, когда он падает, и выкатывать заново, когда вы его меняете.

Здесь описан весь путь для бота на discord.js: приложение и его токен, intents, которые вы объявляете, структура проекта, который чисто стартует на сервере, регистрация slash-команд, деплой из GitHub и те немногие сбои, из-за которых боты отваливаются в три часа ночи. Вопрос о размере тарифа вынесен отдельно, ответ на него в статье сколько RAM и CPU нужно Discord-боту; если коротко, командный бот на нескольких десятках серверов спокойно помещается в самый маленький тариф из всех, что продаются.

Что на самом деле нужно для работы круглые сутки#

Ваш бот открывает один исходящий WebSocket к gateway Discord и держит его открытым. Discord присылает hello-пакет с heartbeat_interval (обычно чуть больше сорока секунд), и библиотека отправляет heartbeat с этим интервалом, пока живо соединение. События приходят по этому сокету; всё, что бот делает в ответ - отвечает, редактирует, регистрирует команды, - идёт через REST API обычными HTTPS-запросами.

Отсюда два следствия, и они определяют всё в хостинге бота.

  • Снаружи до бота ничему не нужно доходить. Ни входящего порта, ни домена, ни сертификата, ни правила firewall. Бот прекрасно работает за NAT. Если позже вы добавите веб-панель, это будет второй, другой сервис со своими требованиями.
  • Процесс должен постоянно быть в памяти. Платформы, которые усыпляют приложение после периода без HTTP-трафика, не подходят, потому что бот вообще не получает HTTP-трафика. Любая схема, где процесс останавливается и запускается на каждый запрос, - неправильная форма для такой нагрузки.
Что нужно ботуЧто ему не нужно
Процесс, который остаётся запущеннымВходящий порт
Исходящие HTTPS и WebSocketДомен или сертификат
Перезапуск после выходаБалансировщик нагрузки
Место для хранения состоянияБольше одного процесса до 2 500 guild

Ещё одно, что стоит знать до деплоя: переподключение не бесплатно. Когда сокет рвётся, библиотека сначала пытается возобновить сессию: отправляет ID сессии и последний увиденный номер последовательности, а Discord повторно присылает пропущенные события. Если сессию возобновить нельзя, боту приходится проходить identify заново, а identify ограничены по частоте - у большинства ботов 1 000 в сутки. Свой лимит можно узнать авторизованным запросом GET /gateway/bot, который возвращает объект session_start_limit с полями total, remaining и reset_after. Бот в цикле падений быстро съедает этот запас, поэтому цикл перезапусков - это настоящая проблема, а не просто шум в логе.

Приложение, токен и приглашение#

Всё начинается в Discord Developer Portal. Создайте приложение, откройте вкладку Bot и сбросьте токен. Он показывается один раз. Если вы его потеряете, сбросьте снова: старый сразу станет недействительным, и бот будет офлайн, пока вы не выкатите новое значение.

Токен бота состоит из трёх частей, похожих на base64, разделённых точками, и первая часть - это закодированный ID вашего приложения, так что утёкший токен не анонимен: любой, кто его увидел, знает, какому боту он принадлежит. Discord участвует в secret scanning на GitHub, поэтому токен, отправленный в публичный репозиторий, обычно отзывается в течение нескольких минут. Это хороший исход. Плохой - токен в приватном репозитории, который потом становится публичным. Относитесь к нему как к паролю, дающему полный контроль над ботом, и храните в переменной окружения; общий случай разобран в статье где хранить секреты на сервере приложений.

На этой странице два переключателя, на которых часто ошибаются:

  • Public bot определяет, может ли кто угодно пригласить вашего бота. Выключите его для приватного бота, и добавить его на серверы сможете только вы.
  • Requires OAuth2 code grant должен быть выключен. Он нужен для сценария, которым почти никто не пользуется, а при включённом обычная ссылка-приглашение падает с непонятной ошибкой.

Ссылку-приглашение собирайте в разделе OAuth2, URL Generator. Отметьте в scopes bot и applications.commands: бот, приглашённый без applications.commands, подключится, появится онлайн, но slash-команд у него не будет - это одна из самых частых причин жалобы «мои команды не отображаются». Затем выберите права, которые боту действительно нужны. Добавить права позже можно только повторным приглашением по новой ссылке или правкой роли бота на сервере, так что лучше продумать это один раз, чем просить каждого владельца сервера делать это дважды.

Intents и на что вы на самом деле подписываетесь#

Intents объявляются при подключении и определяют, какие события Discord вообще вам присылает. Ошибка здесь приводит к тишине, а не к ошибке, так что подходите к этому обдуманно.

javascript
import { Client, GatewayIntentBits, Partials } from "discord.js";const client = new Client({  intents: [    GatewayIntentBits.Guilds,    GatewayIntentBits.GuildMessages,    GatewayIntentBits.MessageContent,  ],  partials: [Partials.Message, Partials.Channel, Partials.Reaction],});

Guilds фактически обязателен: без него у библиотеки нет кэша guild, каналов и ролей, и большая часть API перестаёт работать. Три intents привилегированные и должны быть дополнительно включены на вкладке Bot в портале: GUILD_MEMBERS, GUILD_PRESENCES и MESSAGE_CONTENT. Когда бот попадает на серверы численностью больше 100, его нужно верифицировать, чтобы сохранить их, а это процесс с периодом ожидания, так что решайте заранее, нужны ли они вам. Боту, целиком построенному на slash-командах, MESSAGE_CONTENT вообще не нужен, потому что данные команды приходят вместе с interaction.

Массив partials - вторая половина. Если событие ссылается на то, чего нет в кэше библиотеки, например на реакцию к сообщению, отправленному до запуска бота, discord.js вообще не сгенерирует это событие, пока вы не согласились на partial-структуры. С включёнными partials вы получаете заготовку и вызываете у неё .fetch(), когда нужен полный объект.

Проект, который запускается сам#

На сервере команду запуска никто не вводит. Она должна работать из холодного контейнера, в каталоге, который только что был получен из git, без интерактивной оболочки.

package.json
{  "name": "my-bot",  "type": "module",  "engines": { "node": ">=20" },  "scripts": {    "start": "node src/index.js",    "register": "node scripts/register-commands.js"  },  "dependencies": { "discord.js": "^14.16.3" }}

Команда запуска на хосте должна ставить зависимости из lock-файла и затем запускать бота:

bash
npm ci --omit=dev && node src/index.js

npm ci удаляет node_modules и ставит ровно то, что записано в lock-файле, громко падая, если lock-файл и манифест расходятся. npm install втихую подбирает что-то новое, и именно так бот, работавший на вашей машине, получает на сервере сломанную минорную версию. Закоммитьте package-lock.json. Разница разобрана в статье npm ci или npm install.

Стоит назвать две ловушки с версиями. Во-первых, --omit=dev убирает devDependencies, поэтому бот на TypeScript не сможет скомпилироваться на сервере с этим флагом: либо собирайте в CI и выкладывайте dist, либо уберите --omit=dev и смиритесь с размером установки. Во-вторых, discord.js поднимал минимальную версию Node на протяжении жизни v14, а engines - это заметка для людей, а не то, что контейнер принудительно проверяет. Выводите версию рантайма при старте, а не предполагайте её:

javascript
import { Events } from "discord.js";client.once(Events.ClientReady, () => {  console.log(`node ${process.version}, logged in as ${client.user.tag}`);  console.log(`guilds: ${client.guilds.cache.size}`);});

Эти две строки отвечают на три вопроса поддержки раньше, чем их зададут: какой Node запущен, сработал ли токен и сколько серверов видит бот. Используйте константу Events, а не строковый литерал, потому что сами имена событий менялись от релиза к релизу, а константа следует за установленной версией.

Ещё одна деталь окружения: серверные контейнеры работают в UTC, если не указано иное. Если ваш бот публикует ежедневное сообщение в 09:00, он опубликует его в 09:00 UTC. Задайте переменную окружения TZ или явно считайте разницу в коде. Тихий сдвиг часового пояса - самый досадный баг в этой категории, потому что он ошибается ровно на фиксированное число часов.

Регистрация slash-команд, ничего не сломав#

Команды регистрируются через REST API, а не через gateway, и хранятся на стороне Discord. Значит, регистрация - это шаг деплоя, а не шаг запуска.

scripts/register-commands.js
import { REST, Routes } from "discord.js";const commands = [  { name: "ping", description: "Check that the bot is alive" },];const rest = new REST().setToken(process.env.DISCORD_TOKEN);await rest.put(  Routes.applicationGuildCommands(process.env.APP_ID, process.env.GUILD_ID),  { body: commands },);

applicationGuildCommands регистрирует команды на одном сервере и вступает в силу сразу, что и нужно при разработке. applicationCommands регистрирует глобально; по документации Discord, глобальные команды распространяются до часа, так что команда, которой нет сразу после глобальной регистрации, обычно просто пришла рано. PUT заменяет весь набор, поэтому всё, чего нет в массиве, удаляется: так убирают старую команду, и так же случайно стирают весь список команд, зарегистрировав неполный массив.

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

Если регистрация падает с Invalid Form Body и кодом ошибки 50035, прочитайте путь к полю в сообщении. Имена команд должны быть в нижнем регистре, длиной 1-32 символа, без пробелов; описания - 1-100 символов и обязательны для chat input команд. Ошибка точна, как только вы понимаете, что она указывает на конкретный параметр.

Деплой из GitHub#

Загрузка zip-файла - это способ получить сервер, содержимое которого никто не может опознать. Подключите вместо этого репозиторий.

деплой при pushсобытия и heartbeatHTTPSважные записиGitHubваша веткаСервер приложенияпроцесс NodeDiscord gatewayодин websocketDiscord RESTответы, командыСлот базы данныхсостояние, которое сохранитс
С чем связан хостящийся бот

На RE:NODE интеграция с Git работает только с GitHub, через GitHub App, который выпускает короткоживущие токены, а не просит вас вставить персональный токен доступа, который никогда не истекает, так что приватные репозитории работают без учётных данных, вечно лежащих на сервере. Есть два независимых переключателя: подтягивать ветку при каждом запуске контейнера и деплоить при push, то есть перезапускать сервер, когда GitHub сообщает о push в эту ветку, причём только если он уже был запущен, так что намеренно остановленный сервер остаётся остановленным. Каждый деплой записывается одной строкой, которая открывается, когда приходит push, и закрывается, когда контейнер снова замечен запущенным, - так отличают деплой, который состоялся, от деплоя, который упал. Пошаговый разбор есть в статье деплой приложения Node.js из GitHub.

За деплой приходится платить коротким провалом: процесс останавливается, выполняются pull и установка, бот переподключается, обычно за пять-двадцать секунд. Всё это время любой interaction, отправленный боту, завершается ошибкой. С одним процессом обойти это нельзя, так что выкатывайте, когда пользователи спят, и делайте установку быстрой; в статье деплой без простоя на сервере, где всего по одному честно сказано, какие приёмы применимы к боту на gateway.

Если бот важен, заведите ему двойника для staging: второе приложение в Developer Portal со своим токеном, приглашённое только на ваш тестовый сервер. Схема описана в статье staging и production на одном аккаунте.

Секреты, состояние и файлы, которые пишет бот#

Переменные окружения задаются на вкладке Startup, а не в репозитории. DISCORD_TOKEN, ID приложения, любые ключи API, URL базы данных. Добавьте .env в .gitignore в первый же день и читайте значения так, чтобы при их отсутствии всё жёстко падало:

javascript
const token = process.env.DISCORD_TOKEN;if (!token) throw new Error("DISCORD_TOKEN is not set");

Упасть при старте с внятным сообщением намного лучше, чем подключиться с undefined и прочитать в консоли An invalid token was provided.

Состояние - вторая половина. Всё, что бот пишет внутри каталога своего репозитория, мешает следующему pull, поэтому храните данные времени выполнения там, куда деплой не залезает: в каталоге вне отслеживаемого дерева или в базе данных. Файл SQLite - правильный ответ для удивительно большого числа ботов: один файл, никакого сервера, и объём записи бота он выдерживает, не замечая. Переходите на сетевую базу данных, когда пишут больше одного процесса или когда данные перестают комфортно помещаться в памяти. В тарифы приложений входят два слота баз данных, создаваемых в панели с выданными host, user и password, а на странице хостинг баз данных есть отдельные линейки на случай, когда хранилище перерастает бота; в статье пулы соединений и лимиты разобрана ошибка, которую большинство ботов совершает первой: открытие отдельного соединения на каждую команду.

Что бы вы ни использовали, делайте резервные копии по расписанию, а не когда вспомните. Слоты backup входят в каждый тариф приложений, а вкладка Schedules запускает их по cron-выражению. Backup, который никто не восстанавливал, - это гипотеза; обстоятельно это доказано в статье backup, которые действительно восстанавливаются.

Держаться онлайн: падения, циклы и лимит памяти#

Работающего бота роняют три вещи, и все три можно предотвратить.

Необработанный rejection завершает процесс. В современном Node необработанный rejection промиса по умолчанию завершает процесс. Одного упавшего fetch в обработчике команды без catch достаточно. Установите обработчики и сделайте так, чтобы строка в логе появилась до любого выхода:

javascript
process.on("unhandledRejection", (error) => {  console.error("unhandled rejection:", error);});process.on("SIGTERM", async () => {  await client.destroy();  process.exit(0);});

Закрытие клиента по SIGTERM важнее, чем кажется. Бот, убитый без закрытия сокета, остаётся видимым как онлайн до минуты, так что десятисекундный перезапуск выглядит для ваших пользователей как минута, в которую бот их игнорирует. Общий паттерн описан в статье корректное завершение и health checks.

Лимит памяти - это не мягкое событие. При достижении лимита контейнера ядро останавливает процесс, и он перезапускается с чистого листа, а не уходит в swap. Для бота это правильное поведение по умолчанию, но оно означает, что неограниченный кэш или растущий Map проявятся как загадочный перезапуск каждые несколько часов, а не как медленный спад. После любого изменения следите за графиком памяти в консоли в течение суток; в статье почему ваше приложение Node падает на 2 ГБ при тарифе 4 ГБ объясняется второй потолок, куча V8, в который процесс Node обычно упирается первым.

Цикл перезапусков замечают. На RE:NODE наблюдатель каждые две минуты проверяет сервер, который ушёл офлайн или у которого аптайм пошёл назад, не учитывая перезапуски, о которых вы просили. Три неожиданных перезапуска за час дают предупреждение на странице сервера и открывают тикет; шесть ведут к приостановке. Это не произвол: бот, перезапускающийся каждые двадцать секунд, бьёт по gateway и сжигает описанный выше запас identify. Диагностика такого цикла разобрана в статье почему ваш сервер постоянно перезапускается.

Что до мониторинга, помните, что у бота нет endpoint, который можно опрашивать. Либо читайте консоль, либо поднимите крошечный HTTP-сервер на порту из выделения по тарифу и направьте на него монитор доступности через слот proxy. Как держать этот список коротким, рассказано в статье мониторинг, который что-то вам сообщает.

Диагностика#

Бот онлайн, но команд нет. Либо в приглашении не было scope applications.commands, либо команды зарегистрированы глобально и ещё не распространились. Зарегистрируйте их на тестовый guild, чтобы проверить за секунды.

«The application did not respond». Ваш обработчик не ответил за три секунды. Либо он упал, либо занят настоящей работой. Сначала вызовите interaction.deferReply(), что продлевает окно до пятнадцати минут, а затем editReply(), когда появится ответ.

Код закрытия 4004 в цикле. Неверный токен. Обычно это сброшенный токен, который так и не выкатили заново, либо опечатка в имени переменной, либо client secret, вставленный вместо токена бота.

Код закрытия 4014 в цикле. Привилегированный intent, не включённый в Developer Portal.

`Missing Permissions` (50013) или `Missing Access` (50001). Чаще всего дело в иерархии ролей. Бот не может модерировать участника, чья высшая роль выше роли самого бота, независимо от прав, и не может писать в канал, где переопределение канала это запрещает.

HTTP 429, а потом ничего. Вас ограничили по частоте; в ответе есть retry_after, и библиотека его выжидает. Устойчивый поток отклонённых запросов хуже, чем медленные, потому что edge Discord блокирует адреса, которые за короткое время шлют много некорректных запросов. Чините цикл, а не повторяйте настойчивее; общий вид проблемы описан в статье лимиты запросов и злоупотребления.

FAQ#

Можно ли хостить Discord-бота бесплатно?

Можно запустить его на запасном компьютере дома, и он будет офлайн всякий раз, когда эта машина спит, обновляется или у неё нестабильное соединение. Бесплатные тарифы приложений обычно останавливают процесс, не получающий HTTP-трафика, а именно так и выглядит бот на gateway. Бесплатного тарифа здесь нет; самый маленький тариф приложений - 1 ГБ и 0.5 vCPU от $4 в месяц, что больше, чем нужно командному боту.

Нужен ли Discord-боту открытый порт или домен?

Нет. Соединение с Discord исходящее, поэтому боту не нужны ни входящий порт, ни сертификат. Домен понадобится, только если вы добавите панель или callback для OAuth, и в этом случае слот proxy в тарифе приложений берёт на себя имя хоста и сертификат.

Почему бот показывается офлайн, когда процесс ещё работает?

Потому что Discord видит соединение с gateway, а не процесс. Заблокированный event loop - долгий синхронный цикл, огромный разбор JSON, синхронное чтение файла - останавливает отправку heartbeat, Discord закрывает сокет, и бот выглядит офлайн, а контейнер показывает вполне здоровые CPU и память. Уберите медленную работу с основного пути.

Нужно ли заново регистрировать slash-команды при каждом деплое?

Нет, и не стоит. Регистрации хранятся на стороне Discord, пока вы их не измените. Запускайте скрипт регистрации, когда меняются определения команд, и не включайте его в команду запуска.

Как перенести бота с моего ПК на сервер?

Отправьте код в GitHub без токена, создайте сервер приложения, подключите репозиторий, задайте переменные окружения на вкладке Startup, задайте команду запуска и запустите. Затем остановите копию на своём ПК: два процесса на одном токене борются за одну и ту же сессию и дают очень запутанное поведение.

Что происходит, когда бот достигает лимита памяти?

Контейнер останавливается и запускается заново с чистого листа, а не уходит в swap. Всё, что хранилось только в памяти, теряется, так что сохраняйте важное. Если это повторяется, ограничьте кэши библиотеки и собственные коллекции, прежде чем покупать больше памяти; чем эти два случая отличаются, объясняет статья когда пора повысить тариф.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000