В этой теме мы напишем небольшого Discord-бота на Node.js с использованием slash-комманд, кнопок и выпадающих меню, который будет показывать курс валют.
В этой статье для создания бота мы будем использовать библиотеку discord.js
Подготовка рабочей среды
Шаг 1.1: Установка Node.js
Для начала работы нам нужно установить ноду. Переходим на сайт Node.js и скачиваем LTS-версию (можно и последнюю).
Установка не сложная, просто нажимаете далее. Можно выбрать свою папку установки.
Шаг 1.2: Установка редактора
Существует множество различных редакторов, можно использовать даже блокнот, НО я рекомендую вам эти программы:
- Visual Studio Code – быстрый редактор, поддерживает плагины, множество языков, имеет встроенный терминал. В этой теме я буду использовать именно этот редактор.
- Atom – простой, быстрый, не нагружающий систему редактор, поддерживает плагины.
Шаг 1.3: Создание проекта
Сейчас нам нужно создать папку с любым названием, желательно на латинице и без пробелов. Затем необходимо открыть созданную нами папку через выбранный редактор.
Далее необходимо открыть терминал. В Visual Studio Code это делается так:
Теперь проверим установлена ли нода, команда
node -v
должна выдать что-то примерно
v16.13.0
Если у вас какая-либо ошибка, попробуйте перезагрузить компьютер, либо переустановить ноду.
Сейчас нам нужно инициировать наш проект. В терминале нам необходимо инициировать NPM-модуль (в нашем случае, наш проект):
# С вопросами для настройки
npm init
# Без вопросов
npm init --yes
Получение токена
Шаг 2.1: Регистрация приложения
Для начала нам нужно зарегистрировать приложение на странице Приложений:
Вводим желаемое имя, и жмём Create.
Шаг 2.2: Добавление бота
Сейчас нужно добавить бота к нашему приложению:
Шаг 2.3: Получение токена
Добавив бота нам необходимо получить его токен:
Этот токен пригодится нам в дальнейшем
Установка библиотек
Шаг 3: Установка всех библиотек
Получив токен мы приступаем к установке библиотек. В терминале необходимо выполнить команду:
npm i discord.js dotenv axios
Всё! Мы установили все библиотеки. Переходим к следующему шагу.
Структура проекта
Шаг 4.1: Создание папок
Чтобы всё было понятно, вкратце объясню какие папки и файл вам нужно создать и что в них находится:
- index.js – наш главный файл, в котором мы объявим все важные нам переменные и вызовем необходимые функции
- .env – файл с нашим токеном
- src – папка со всеми ресурсами проекта. В корне этой папки мы будем хранить различные утилиты
- src/events – папка слушателей ивентов (прослушиватель slash-комманд)
- src/interactions – папка с обработчиками (обработчик slash-комманды)
Шаг 4.2: Настройка файла .env
Нам необходимо настроить этот файл, чтобы наш бот мог получить из него данные.
BOT_TOKEN – ваш токен который вы получили в шаге 2.3
API_URL – url адрес api которое мы будем использовать для получения курса валют.
BOT_TOKEN=Токен полученный в шаге 2.3
API_URL=https://api.privatbank.ua/p24api/pubinfo?json&exchange&coursid=5
Если вы будете заливать код на github (хоть в приватный проект) убедитесь, что файл .env находится в .gitignore,
чтобы ваш токен никто не узнал.
Кстати если вы нечаянно выложите свой токен в публичный проект, Discord сразу же оповестит вас об этом и сбросит ваш токен на новый.
Шаг 4.3: Добавление утилит
Итак добавим нашу первую утилиту которая будет генерировать рандомный индивидульный ID
В папке src создадим файл с названием snowflake.js
В нём находится код для генерации ID. Мы будем использовать snowflake, генерируются они по этой схеме:
Использовать мы их будем для обозначения кнопок и выпадающих меню в нашем кеше (объясню позже):
// Переменная которая постоянно прибавляется и
// обнуляется достигая значение 4095, чтобы не сгенерировались одинаковые ID
let i = 0;
// Экспортируем нашу функцию для доступа извне
module.exports = () => {
// Инкрементируем нашу переменную i чтобы избежать одинаковые ID
if(i == 0xFFF) i = 0;
else i++;
// Первая часть Snowflake
let first = (BigInt(Date.now()) - 1420070400000n).toString(2);
// Вторая часть
let second = "0000100000";
// Третья часть, как раз наша переменная i
let third = i.toString(2).padStart(12, "0");
// Возвращаем в виде строки
return BigInt("0b"+first+second+third).toString();
}
Готово.
Теперь нужно создать файл currency.js в папке src, в нём будет функция которая обращается к API
для получения курса валют:
//Импортируем библиотеку axios
const axios = require('axios');
// Экспортируем нашу функцию для доступа извне
// Async позволяет сделать нашу функцию асинхронной
module.exports = async () => {
// Делаем запрос к API указанному нами в файле .env
const res = await axios.get(process.env.API_URL);
// Проверяем успешно ли прошёл запрос, иначе выбрасываем ошибку
if(!res || res.status !== 200 || !res.data) throw new Error('[CURRENCY] Ошибка при получении курса валют');
// Возвращаем результат
return res.data;
}
Асинхронная функция – функция, выполнение которой можно подождать, и получить ответ.
Главный файл
Шаг 5.1: index.js
Приступим к настройке созданного нами файла index.js в корне проекта. Для этого вставим туда этот код:
// Импортируем и настраиваем библиотеку dotenv, чтобы импортировать наш .env файл
require('dotenv').config();
// Импортируем библиотеку discord.js для бота
const Discord = require('discord.js');
// Создаём объект бота
const bot = new Discord.Client({ intents: 31997 });
// Создаём пользовательский объект чтобы нам было удобно хранить глобальную информацию
bot.info = {
interactions: {}, // Объект для наших будущих обработчиков
cache: {} // Объект для кеша
};
// Импортируем нашу утилиту currency.js,
// чтобы при включении бота автоматически узнать все доступные курсы валют
// Делаем это в виде функции чтобы упростить обновление
function updateCurrency(){
require("./src/currency")().then(result => { // Метод .then позволяет подождать и получить результат из async-функции
bot.info.currencies = result; // Устанавливаем результат в наш пользовательский объект
});
}
// Вызываем обновление
updateCurrency();
// Создаём интервал на обновление курса каждые 30 минут
setInterval(updateCurrency, 1000 * 60 * 30);
bot.login(process.env.BOT_TOKEN) // Авторизовываем бота с нашим токеном из .env
.catch(e => console.log("[BOT] Произошла ошибка:\n" + e)); // Ловим ошибки во время авторизации (у async-функции login)
Вам, наверное, интересно, что же за intents мы указали в Discord.Client ? Это фишка Discord, которая позволяет избавить нашего бота от лишней информации, которую мы не будем использовать. В число 31997 я указал все публичные интенты.
Существуют также приватные интенты, которые можно включить в настройках бота, они нужны для дополнительной информации, которая по умолчанию не доступна.
Загрузчик (Loader)
Шаг 6.1: Создание загрузчика
Т.к. мы хотим чтобы наш бот автоматически определял всех наших слушателей и обработчиков, нам нужно создать загрузчик.
Для этого в папке src создадим файл loader.js со следующим содержимым:
// Импортируем нативную библиотеку node.js для работы с файловой системой
const fs = require("fs");
// Экспортируем функцию loadEvents для доступа извне
module.exports.loadEvents = (bot) => { // Наша функция получает параметр bot
//Получаем все файлы в папке src/events
// Здесь необходимо указывать путь относительно корневой папки проекта
let dir = fs.readdirSync("./src/events");
//Если папка пустая логируем это
if(!dir[0]) return console.log("[LOADER] Ивенты не найдены!");
//Счётчик успешно загруженных ивентов
let suc = 0;
//Загрузка ивентов
dir.forEach(eventName => {
// Ловим ошибки у синхронной функции
try{
// Импортируем наш слушатель
let event = require("./events/" + eventName);
// Вызываем функцию init (узнаете позже)
event.init(bot);
// Делаем +1 к счётчику
suc++;
}catch(e){
//Логирование ошибки
console.log("[LOADER] Ошибка при загрузке ивента " + eventName + ":\n" + e);
}
});
//Информация
console.log("[LOADER] Загружено " + suc + " ивентов из " + dir.length + "!");
}
// Экспортируем функцию loadEvents для доступа извне
module.exports.loadInteractions = (bot) => {// Наша функция получает параметр bot
//Получаем все файлы в папке src/events
// Здесь необходимо указывать путь относительно корневой папки проекта
let dir = fs.readdirSync("./src/interactions");
//Если папка пустая логируем это
if(!dir[0]) return console.log("[LOADER] Интеракции не найдены!");
//Счётчик успешно загруженных интеракций
let suc = 0;
//Загрузка интеракций
dir.forEach(interactionName => {
// Ловим ошибки у синхронной функции
try{
// Импортируем наш обработчик
let interaction = require("./interactions/" + interactionName);
// Добавляем наш обработчик в пользовательский объект для удобства
// Что такое customId вы узнаете позже
bot.info.interactions[interaction.customId] = interaction;
// Делаем +1 к счётчику
suc++;
}catch(e){
//Логирование ошибки
console.log("[LOADER] Ошибка при загрузке интеракции " + interactionName + ":\n" + e);
}
});
//Информация
console.log("[LOADER] Загружено " + suc + " интеракций из " + dir.length + "!");
}
Готово. Перейдём к следующему шагу.
Шаг 6.2: Вызов загрузчика из index.js
В нашем главном файле index.js примерно в середине (над bot.login…) нужно добавить следующие строки:
// Импортируем наш загрузик
const Loader = require("./src/loader");
// Загружаем слушатели
Loader.loadEvents(bot);
// Загружаем обработчики
Loader.loadInteractions(bot);
Шаг 7.1: Ивент ready
Создаём файл ready.js в папке src/events со следующим содержимым:
// Экспортируем функцию для доступа извне
// Наша функция принимает параметр bot
// Далее у bot вызывает метод on, он слушает ивенты и выполняет функцию которую мы указываем через запятую
module.exports.init = (bot) => bot.on('ready', () => {
console.log("[BOT] Готов к работе!"); // Лог в консоль
});
Сейчас мы создали наш первый слушатель, который слушает ивент ready который сообщает о том, что наш бот готов к работе.
Перейдём к следующему ивенту
Шаг 7.2: Ивент interactionCreate
Создаём файл interactionCreatejs в папке src/events со следующим содержимым:
// Экспортируем функцию для доступа извне
// Далее у bot она вызвает метод on, слушает ивент interactionCreate, и передаём асинхронную функцию
// в прослушиватель, которая принимает параметр interaction от ивента
module.exports.init = (bot) => bot.on('interactionCreate', async interaction => {
//Если наша интеракция slash-команда
if(interaction.isCommand()){
// Ищём наш обработчик в пользовательском объекте, в который мы записали наши обработчики через загрузчик
let handler = bot.info.interactions[interaction.commandName];
// Если обработчик мы не нашли, пропускаем
if(!handler) return;
// А если нашли, вызываем метод .run и передаём туда параметры bot и interaction
handler.run(bot, interaction);
// Если наша интеракция это кнопка или выпадающее меню
}else if(interaction.isButton() || interaction.isSelectMenu()){
// Вы дальше узнаете как мы записали айди интеракции в кеш
// Теперь мы ищем в кеше наш обработчик кнопок/выпадающих меню по айди,
// который ранее мы записали и передали в Discord
let cache = bot.info.cache[interaction.customId];
// Если не нашли, пропускаем
if(!cache) return;
// Проверяем в кеше, какие пользователи могут использовать кнопку/выпадающее меню
// Если пользователь вызвавший интеракцию не может использовать, пропускаем
if(cache.users && !cache.users.includes(interaction.user.id)) return;
// Ищем обработчик в пользовательском объекте, в который мы записали наши обработчики через загрузчик
let handler = bot.info.interactions[cache.customId];
// Если нет обработчика, пропускаем
if(!handler) return;
handler.run(bot, interaction, cache);
}
});
Шаг 7.3: Утилита очистки кеша
Чтобы легко очистить кеш имея объект сообщения нам нужна простая утилита.
В папке src создаём файл clearCache.js со следующим содержимым:
// Экспортируем для доступа извне
// Наша функция принимает bot и interaction
module.exports = (bot, interaction) => {
// Получаем message из interaction, а из message - components
// Методом forEach перебираем все components
interaction.message.components.forEach(e => { // Теперь каждый из компонентов объявлен как e
// Компоненты сообщений могут быть только ActionRow, которые в свою очередь обязаны иметь свои компоненты
e.components.forEach(e2 => { // Перебираем их, и объявляем как e2
// Ищем в кеше значения с таким же id и удаляем
if(bot.info.cache[e2.customId]) delete bot.info.cache[e2.customId];
});
});
}
Шаг 8.1: Теперь нам необходимо зарегистрировать нашу команду
Для начала заходим на страницу Приложений, выбираем там нашего бота и копируем Application ID
Теперь нам нужно вставить ID в ссылку для API запроса:
https://discord.com/api/v9/applications/<Скопированный ID>/commands
# Например
https://discord.com/api/v9/applications/894996856684286032/commands
Шаг 8.2: Body
Теперь сформулируем тело нашего запроса:
{
"name": "convert",
"type": 1,
"description": "Конвертер валют"
}
Итак давайте я объясню подробнее
- name – название нашей команды
- type – тип интеракции, 1 – slash-команда
- description – описание команды
Шаг 8.3: Выполнение http-запроса
Открываем любую программу для создания http-запросов, например Postman. Я буду использовать Advanced REST client.
Здесь нам нужно всё настроить:
- Выбрать тип запроса – POST
- Вставить ссылку из шага 7.1
- Добавить хедер
Content-Type
со значениемapplication/json
- Добавить хедер
Authorization
со значениемBot <токен бота из шага 2.3>
- Затем открываем вкладку Body
- Во вкладке Body нам нужно вставить наше тело из шага 7.2
- И нажать кнопку Send
Готово! В ответе мы должны были получить объект с копией нашего тела и дополнительной информацией
Если у вас какая-либо ошибка, попробуйте повторить всё начиная с шага 7.1
Создание обработчиков
Шаг 9.1: Создание обработчика команды
Для начала нам нужно создать обработчик самой команды. Создадим файл convert.js в папке src/interactions
Добавим туда следующее содержимое:
// Импортируем библиотеку discord.js и наши утилиты snowflake, currency
const Discord = require("discord.js");
const snowflake = require("../snowflake");
const currency = require("../currency");
// Экспортируем объект нашего обработчика
module.exports = {
// В этом поле хранится имя нашей команды
customId: "convert",
// В этом поле вспомогательная функция interactionType
// Она нужна, чтобы слушатель из src/events/interactionCreate.js
// Автоматически понимал подходит ли эта команда к типу интеракции
interactionType: (i) => i.isCommand(), //isCommand возвращает true если интеракция это slash-комманда
// Функция run принимает аргументы bot и interaction. Является главной функцией обработчика
run(bot, interaction){
// Создадим эмбед для ответа на команду
let embed = new Discord.MessageEmbed()
.setColor("RANDOM") // Установим цвет
.setTitle("Конвертация") // Заголовок
// Футер с ником и аватаркой пользователя вызвавшего команду
.setFooter(interaction.user.tag, interaction.user.displayAvatarURL());
// Создадим ID для кнопки
let buttonId = snowflake();
// Создадим ID для выпадающего меню
let selectMenuId = snowflake();
// Получим список курсов из пользовательского объекта
// И используем метод map чтобы обработать значение каждого курса как нам надо
let currencies = bot.info.currencies.map(e => ({
label: e.base_ccy + " -> " + e.ccy, // Заголовок варианта из выпадающего меню
// Значение варианта из выпадающего меню обязательно должно быть строкой
// Поэтому наш курс мы превратим в строку формата JSON
value: JSON.stringify(e)
}));
// Создадим компоненты (кнопки и меню) которые будут внизу сообщения
let components = [
// Каждый элемент или несколько элементов (до 5) обязаны быть внутри ActionRow
// Создаём ActionRow и добавляем в него компоненты
new Discord.MessageActionRow().addComponents(
// Создаём выпадающее меню, с плейсхолдером "Курс", и customId который мы объявили чуть выше
// Затем добавляем варианты курса (addOptions) которые мы сделали чуть выше с помошью .map
new Discord.MessageSelectMenu({ placeholder: "Курс", customId: selectMenuId }).addOptions(currencies)
),
// Создаём ActionRow для кнопки которая будет под меню и добавляем компоненты
new Discord.MessageActionRow().addComponents(
// Создаём кнопку красного цвета (style: "DANGER") с заголовком "Отменить"
// И customId который мы объявили чуть выше
new Discord.MessageButton({ label: "Отменить", customId: buttonId, style: "DANGER" })
)
];
// Добавляем в кеш нашу кнопку и меню, чтобы обработчик interactionCreate
// Понял с каким именем он должен искать обработчик
// И какие пользователи могут нажимать на кнопку или выбирать вариант в меню
bot.info.cache[buttonId] = {
users: [interaction.user.id],
customId: "delete"
}
bot.info.cache[selectMenuId] = {
users: [interaction.user.id],
customId: "convert-select"
}
// Отвечаем на команду нашим эмбедом и компонентами
interaction.reply({ embeds: [embed], components });
}
}
Сохраняем. И идём дальше
Шаг 9.2: Создание обработчика кнопки Отменить
В папке src/interactions создаём файл delete.js со следующим содержимым:
// Импортируем библиотеку discord.js и нашу утилиту clearCache
const Discord = require("discord.js");
const clearCache = require("../clearCache");
// Экспортируем обработчик
module.exports = {
// Вы уже знаете, что в этом поле хранится название обработчика
customId: "delete",
// А здесь вспомогательная функция, но здесь isButton говорит о том, что это обработчик кнопки
interactionType: (i) => i.isButton(),
// Функция принимает bot, interaction и вдобавок cache (который получил наш слушатель interactionCreate)
run(bot, interaction, cache){
// Передаём в функцию-утилиту clearCache объект бота (в котором наш пользовательский объект с кешем)
// И объект интеракции, чтобы утилита нашла все компоненты и удалила их из кеша
clearCache(bot, interaction);
// Создаём новый эмбед
let embed = new Discord.MessageEmbed()
.setColor("#ff0000") // Ставим красный цвет
.setTitle("Отменено!") // Ставим заголовок
// Редактируем сообщение на котором была нажата кнопка Отменить
interaction.update({
embeds: [embed], // Устанавливаем новый эмбед
components: [] // Убираем всё компоненты (кнопки и меню)
})
}
}
Шаг 9.3: Обработчик выпадающего меню
И наконец, это наш последний файл и последний код!
В папке src/interactions создаём файл convertSelect.js со следующим содержимым:
// Вы уже это видели
const Discord = require("discord.js");
const clearCache = require("../clearCache");
module.exports = {
// Название обработчика
customId: "convert-select",
// Вспомогательная функция-определитель, isSelectMenu - является ли интеракция (i) выпадающим меню
interactionType: (i) => i.isSelectMenu(),
// Снова главная функция принимающая объект бота, интеракции и кеш.
run(bot, interaction, cache){
// Очищаем кеш
clearCache(bot, interaction);
// Получаем выбранное значение (.values[0]) и парсим JSON-строку в JS-объект
let exchangeRate = JSON.parse(interaction.values[0]);
// Создаём эмбед
let embed = new Discord.MessageEmbed()
.setColor("RANDOM") // Ставим рандомный цвет
.setTitle("Конвертация") // Ставим заголовок
// Устанавливаем описание эмбеда.
// Первая строка - Курс (заголовок нашего варианта из меню)
// Вторая строка - цена продажи валюты
// Третья строка - цена покупки валюты
.setDescription("`" + exchangeRate.base_ccy + " -> " + exchangeRate.ccy + "`\n" +
"Покупка: `" + exchangeRate.buy + "`\n" +
"Продажа: `" + exchangeRate.sale + "`")
// Футер с ником и аватаркой пользователя выбравшего вариант из меню
.setFooter(interaction.user.tag, interaction.user.displayAvatarURL());
// Редактируем сообщение на котором выбран вариант
interaction.update({
embeds: [embed], // Ставим наш новый эмбед
components: [] // Убираем компоненты
})
}
}
Это был обработчик для выпадающего меню, а мы перейдём к заключительным действиям!
Приглашение и запуск бота
Шаг 10.1: Приглашение бота на сервер
Заходим на сайт Калькулятора прав и делаем следующие действия:
- Выбираем право Administrator
- Вставляем наш Application ID из шага 7.2 в поле Client ID
- В поле Scope нам нужно вставить
bot applications.commands
- Теперь копируем ссылку из поля Link и переходим по ней
Здесь мы выбираем нужный нам сервер, жмём Продолжить и проходим капчу. Готово!
Шаг 10.2: Запуск бота
В терминале нашего проекта нам нужно прописать команду
node index.js
.
В терминале должно было вывестись так:
[LOADER] Загружено 2 ивентов из 2!
[LOADER] Загружено 3 интеракций из 3!
[BOT] Готов к работе!
Как нам сообщил [BOT], он готов к работе!
Если произошли какие-то ошибки, попробуйте разобрать где они произошли и исправить руководствуясь кодом из этой статьи.
Проверим работу нашего бота:
Ура! Бот работает, значит мы всё сделали правильно!
1 Comment
C:\Users\Паша\Desktop\Тест\node_modules\axios\dist\node\axios.cjs:836
AxiosError.call(axiosError, error.message, code, config, request, response);
^
AxiosError: connect ECONNREFUSED ::1:80
at AxiosError.from (C:\Users\Паша\Desktop\Тест\node_modules\axios\dist\node\axios.cjs:836:14)
at RedirectableRequest.handleRequestError (C:\Users\Паша\Desktop\Тест\node_modules\axios\dist\node\axios.cjs:3010:25)
at RedirectableRequest.emit (node:events:513:28)
at eventHandlers. (C:\Users\Паша\Desktop\Тест\node_modules\follow-redirects\index.js:14:24)
at ClientRequest.emit (node:events:513:28)
at Socket.socketErrorListener (node:_http_client:502:9)
at Socket.emit (node:events:513:28)
at emitErrorNT (node:internal/streams/destroy:151:8)
at emitErrorCloseNT (node:internal/streams/destroy:116:3)
at process.processTicksAndRejections (node:internal/process/task_queues:82:21) {
Всё перепробовал всё также…