Доброго времени суток уважаемые форумчане Листал я значит список статей и наталкиваюсь на статью от dophaminov - ТЫК и понял, что оказывается по дискорд ботам очень мало полезной информации. В этой статье хотелось бы более подробно затронуть тему создания дискорд ботов. Мы рассмотрим создание дискорд ботов с помощью фреймворка Sapphire.js (надстройка над discord.js) Рекомендую к прочтению тем людям, которые хотят создавать больших и масштабируемых ботов, а не шалости с использованием детских фреймворков. В данной статье я затрону следующие темы: 1. Про Sapphire.js 2. Структура бота 3. Создание команд 4. Создание слушателей (listeners) 5. Создание кнопок, выборочных меню, модалей и управление ими 6. Интеграция с MongoDB Немного о Sapphire.js Sapphire - это объектно-ориентированный фреймворк нового поколения для ботов Discord.js. Sapphire - это фреймворк, созданный сообществом, цель которого - предоставить вам все возможности, необходимые для создания вашего Discord-бота. История многих других фреймворков для ботов Discord (как для Node.js, так и для других языков), вдохновлявших Sapphire, он стал самым современным опыт написания кода. Возможности фреймворка Расширенная поддержка плагинов Поддерживает CommonJS и ESM Полностью модульный и расширяемый Разработан с учетом первоклассной поддержки TypeScript Включает в себя дополнительные утилиты, которые можно использовать в любом проекте Подготовка рабочей среды Настройка рабочей среды для разработки ботов Скачиваем Node.js - Node.js (LTS) Ставим зависимости npm i -g yarn yarn add typescript yarn add ts-node yarn add @sapphire/framework discord.js Code npm i -g yarn yarn add typescript yarn add ts-node yarn add @sapphire/framework discord.js Инициализируем проект yarn init Code yarn init Структура бота Мы будем использовать данную структуру, поскольку она является оптимальной и расширяемой Создаем tsconfig.json { "compilerOptions": { "module": "CommonJS", "target": "ESNext", "outDir": "dist", "lib": [ "ESNext", "ESNext.Array", "ESNext.AsyncIterable", "ESNext.Intl", "ESNext.Symbol", "DOM" ], "sourceMap": true, "inlineSources": true, "incremental": true, "esModuleInterop": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "skipLibCheck": true }, "include": ["src/**/*"], "exclude": ["node_modules"] } Code { "compilerOptions": { "module": "CommonJS", "target": "ESNext", "outDir": "dist", "lib": [ "ESNext", "ESNext.Array", "ESNext.AsyncIterable", "ESNext.Intl", "ESNext.Symbol", "DOM" ], "sourceMap": true, "inlineSources": true, "incremental": true, "esModuleInterop": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "skipLibCheck": true }, "include": ["src/**/*"], "exclude": ["node_modules"] } Также необходимо немного изменить наш package.json { "name": "mydiscordbot", "scripts": { "start": "ts-node src/index.ts" }, "version": "1.0.0", "license": "MIT", "main": "src/index.ts", "dependencies": { "@sapphire/framework": "^5.2.1", "discord.js": "^14.16.2", "dotenv": "^16.4.5", "mongoose": "^8.6.3", "ts-node": "^10.9.2", "typescript": "^5.6.2" } } Code { "name": "mydiscordbot", "scripts": { "start": "ts-node src/index.ts" }, "version": "1.0.0", "license": "MIT", "main": "src/index.ts", "dependencies": { "@sapphire/framework": "^5.2.1", "discord.js": "^14.16.2", "dotenv": "^16.4.5", "mongoose": "^8.6.3", "ts-node": "^10.9.2", "typescript": "^5.6.2" } } Мы добавили скрипт, который позволит автоматически перезагружать наш typescript без ручной компиляции Наша базовая структура готова, далее уже можно создавать файлы и писать код, но сначала необходимо создать самого бота, это мы рассмотрим в следующем этапе Получаем токен бота Cоздание вашего Discord бота 1. Зайдите на портал разработчиков дискорда 2. Войдите в ваш Discord аккаунт 3. Выберите классное имя для вашего бота 4. Примите Discord ToS 5. Нажмите кнопку "Создать" Тыкаете на кнопку "Bot" Включаете все разрешения для бота спиздил картинки Приглашаем бота Убедитесь, что выбрали эти разрешения: - bot - application.commands Приглашаем бота на сервер Возвращайтесь во вкладку "Bot" и нажимаете кнопку "Reset Token". Мы будем использовать этот токен для того, чтобы управлять нашим ботом. Распространять токен ни в коем случае нельзя! Подготовка бота к запуску Помните мы ранее создавали .env файлик в корне проекта? Так вот сейчас он нам пригодится, его нужно заполнить следующим образом: Не забудьте добавить .env файлик в .gitignore -> .env, а также .gitignore -> node_modules Создаем файл по пути src/client/client.ts и заполняем его следующим образом: import { SapphireClient, ApplicationCommandRegistries, RegisterBehavior, } from "@sapphire/framework"; import { GatewayIntentBits } from "discord.js"; export default class Client extends SapphireClient { public constructor() { super({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildPresences, GatewayIntentBits.MessageContent, ], }); } public override login(token?: string) { ApplicationCommandRegistries.setDefaultBehaviorWhenNotIdentical( RegisterBehavior.BulkOverwrite, ); return super.login(token); } } JS import { SapphireClient, ApplicationCommandRegistries, RegisterBehavior, } from "@sapphire/framework"; import { GatewayIntentBits } from "discord.js"; export default class Client extends SapphireClient { public constructor() { super({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildPresences, GatewayIntentBits.MessageContent, ], }); } public override login(token?: string) { ApplicationCommandRegistries.setDefaultBehaviorWhenNotIdentical( RegisterBehavior.BulkOverwrite, ); return super.login(token); } } Открываем index.ts и вызываем метод авторизации с указанием нашего токена из переменной окружения (в нашем случае .env файлик) import "dotenv/config" import Client from "./client/client"; new Client().login(process.env.TOKEN); JS import "dotenv/config" import Client from "./client/client"; new Client().login(process.env.TOKEN); Первый запуск бота Введите в терминал команду `yarn start` Если вы все правильно сделали, то бот должен запустится, но он пока-что ничего не делает. Давайте добавим нашу первую slash-команду Добавляем первую slash-команду Перейдем в ранее созданый пакет commands и создадим файл ping.ts В который мы вставим следующий код: import { isMessageInstance } from '@sapphire/discord.js-utilities'; import { Command } from '@sapphire/framework'; export class PingCommand extends Command { public constructor(context: Command.LoaderContext, options: Command.Options) { super(context, { ...options }); } public override registerApplicationCommands(registry: Command.Registry) { registry.registerChatInputCommand((builder) => builder.setName('ping').setDescription('Ping bot to see if it is alive') ); } public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { const msg = await interaction.reply({ content: `Ping?`, ephemeral: true, fetchReply: true }); if (isMessageInstance(msg)) { const diff = msg.createdTimestamp - interaction.createdTimestamp; const ping = Math.round(this.container.client.ws.ping); return interaction.editReply(`Pong ! (Round trip took: ${diff}ms. Heartbeat: ${ping}ms.)`); } return interaction.editReply('Failed to retrieve ping :('); } } JS import { isMessageInstance } from '@sapphire/discord.js-utilities'; import { Command } from '@sapphire/framework'; export class PingCommand extends Command { public constructor(context: Command.LoaderContext, options: Command.Options) { super(context, { ...options }); } public override registerApplicationCommands(registry: Command.Registry) { registry.registerChatInputCommand((builder) => builder.setName('ping').setDescription('Ping bot to see if it is alive') ); } public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { const msg = await interaction.reply({ content: `Ping?`, ephemeral: true, fetchReply: true }); if (isMessageInstance(msg)) { const diff = msg.createdTimestamp - interaction.createdTimestamp; const ping = Math.round(this.container.client.ws.ping); return interaction.editReply(`Pong ! (Round trip took: ${diff}ms. Heartbeat: ${ping}ms.)`); } return interaction.editReply('Failed to retrieve ping :('); } } Запускаем бота при помощи команды `yarn start`. У нас в консоли должно было показать, что зарегистрирована 1 глобальная команда Для наглядности попробуем активировать команду на сервере. Важное замечание: В пакете commands вы можете группировать команды по папкам. Sapphire же сделает за нас всю работу и зарегистрирует эти команды. Интеракшены (Кнопки, модалы, выпадающие меню) Тут мы рассмотрим как работать с кнопками, модальными окнами и выпадающими меню. Фреймворк Sapphire очень упрощает работу с взаимодействиями и делает половину работы за нас Как работать с кнопками В пакете interaction-handlers создадим `helloworld-btn.ts`, в котором напишем следующий код: import { InteractionHandler, InteractionHandlerTypes } from '@sapphire/framework'; import type { ButtonInteraction } from 'discord.js'; export class ButtonHandler extends InteractionHandler { public constructor(ctx: InteractionHandler.LoaderContext, options: InteractionHandler.Options) { super(ctx, { ...options, interactionHandlerType: InteractionHandlerTypes.Button }); } public override parse(interaction: ButtonInteraction) { if (interaction.customId !== 'my-awesome-button') return this.none(); return this.some(); } public async run(interaction: ButtonInteraction) { await interaction.reply({ content: 'Hello from a button interaction handler!', // Let's make it so only the person who pressed the button can see this message! ephemeral: true }); } } JS import { InteractionHandler, InteractionHandlerTypes } from '@sapphire/framework'; import type { ButtonInteraction } from 'discord.js'; export class ButtonHandler extends InteractionHandler { public constructor(ctx: InteractionHandler.LoaderContext, options: InteractionHandler.Options) { super(ctx, { ...options, interactionHandlerType: InteractionHandlerTypes.Button }); } public override parse(interaction: ButtonInteraction) { if (interaction.customId !== 'my-awesome-button') return this.none(); return this.some(); } public async run(interaction: ButtonInteraction) { await interaction.reply({ content: 'Hello from a button interaction handler!', // Let's make it so only the person who pressed the button can see this message! ephemeral: true }); } } Вместо `my-awesome-button` поставьте будущий айди вашей кнопки, определим мы его чутка позже. В моем случае я поставлю `helloworld-btn` Как теперь использовать это чудо? В ранее созданном ping.ts добавляем данные импорты: import {ActionRowBuilder, ButtonBuilder, ButtonStyle} from "discord.js"; Далее немного видоизменяем ping.ts public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { const msg = await interaction.reply({ content: `Ping?`, ephemeral: true, fetchReply: true }); const row = new ActionRowBuilder<ButtonBuilder>() .addComponents( new ButtonBuilder() .setLabel("Hello world!") .setCustomId("helloworld-btn") .setStyle(ButtonStyle.Success) .setEmoji("") ) if (isMessageInstance(msg)) { const diff = msg.createdTimestamp - interaction.createdTimestamp; const ping = Math.round(this.container.client.ws.ping); return interaction.editReply({ content: `Pong ! (Round trip took: ${diff}ms. Heartbeat: ${ping}ms.)`, components: [row] }); } return interaction.editReply('Failed to retrieve ping :('); } JS public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { const msg = await interaction.reply({ content: `Ping?`, ephemeral: true, fetchReply: true }); const row = new ActionRowBuilder<ButtonBuilder>() .addComponents( new ButtonBuilder() .setLabel("Hello world!") .setCustomId("helloworld-btn") .setStyle(ButtonStyle.Success) .setEmoji("") ) if (isMessageInstance(msg)) { const diff = msg.createdTimestamp - interaction.createdTimestamp; const ping = Math.round(this.container.client.ws.ping); return interaction.editReply({ content: `Pong ! (Round trip took: ${diff}ms. Heartbeat: ${ping}ms.)`, components: [row] }); } return interaction.editReply('Failed to retrieve ping :('); } Результат: Как работать с модалами С модалами дела обстоят похоже, создается сначала само окно с вопросами, потом оно вызывается извне, например по нажатию той же кнопки. Покажу вам пример из моего реального дискорд бота: Окно с вопросами: import { ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, } from "discord.js"; export default class ApplicationModal { public async sendApplicationModal(interaction) { // НАЗВАНИЕ МОДАЛЬНОГО ОКНА const modal = new ModalBuilder() .setCustomId(`modal-open`) .setTitle("Модальное окно"); modal.setComponents( new ActionRowBuilder<TextInputBuilder>().addComponents( // ВОПРОС 1 new TextInputBuilder() .setCustomId("nickInput") .setLabel("Ваш ник в Minecraft") .setRequired(true) .setStyle(TextInputStyle.Short), ), new ActionRowBuilder<TextInputBuilder>().addComponents( new TextInputBuilder() .setCustomId("ageInput") .setLabel("Ваш возраст") .setRequired(true) .setStyle(TextInputStyle.Short), ), new ActionRowBuilder<TextInputBuilder>().addComponents( new TextInputBuilder() .setCustomId("loveInput") .setLabel("Чем вас затягивает Minecraft") .setRequired(true) .setStyle(TextInputStyle.Paragraph), ), new ActionRowBuilder<TextInputBuilder>().addComponents( new TextInputBuilder() .setCustomId("xpInput") .setLabel("Как давно играете в Minecraft") .setRequired(true) .setStyle(TextInputStyle.Short), ), new ActionRowBuilder<TextInputBuilder>().addComponents( new TextInputBuilder() .setCustomId("whereInput") .setLabel("Откуда узнали про нас") .setRequired(true) .setStyle(TextInputStyle.Short), ), ); return await interaction.showModal(modal); } } JS import { ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, } from "discord.js"; export default class ApplicationModal { public async sendApplicationModal(interaction) { // НАЗВАНИЕ МОДАЛЬНОГО ОКНА const modal = new ModalBuilder() .setCustomId(`modal-open`) .setTitle("Модальное окно"); modal.setComponents( new ActionRowBuilder<TextInputBuilder>().addComponents( // ВОПРОС 1 new TextInputBuilder() .setCustomId("nickInput") .setLabel("Ваш ник в Minecraft") .setRequired(true) .setStyle(TextInputStyle.Short), ), new ActionRowBuilder<TextInputBuilder>().addComponents( new TextInputBuilder() .setCustomId("ageInput") .setLabel("Ваш возраст") .setRequired(true) .setStyle(TextInputStyle.Short), ), new ActionRowBuilder<TextInputBuilder>().addComponents( new TextInputBuilder() .setCustomId("loveInput") .setLabel("Чем вас затягивает Minecraft") .setRequired(true) .setStyle(TextInputStyle.Paragraph), ), new ActionRowBuilder<TextInputBuilder>().addComponents( new TextInputBuilder() .setCustomId("xpInput") .setLabel("Как давно играете в Minecraft") .setRequired(true) .setStyle(TextInputStyle.Short), ), new ActionRowBuilder<TextInputBuilder>().addComponents( new TextInputBuilder() .setCustomId("whereInput") .setLabel("Откуда узнали про нас") .setRequired(true) .setStyle(TextInputStyle.Short), ), ); return await interaction.showModal(modal); } } Логика работы окна: [CODE=js]import { InteractionHandler, InteractionHandlerTypes, } from "@sapphire/framework"; import { ButtonBuilder, ModalSubmitInteraction, ActionRowBuilder, ButtonStyle, EmbedBuilder, User, BaseClient, } from "discord.js"; export class ModalHandler extends InteractionHandler { public constructor( ctx: InteractionHandler.LoaderContext, options: InteractionHandler.Options, ) { super(ctx, { ...options, interactionHandlerType: InteractionHandlerTypes.ModalSubmit, }); } public override parse(interaction: ModalSubmitInteraction) { if (interaction.customId !== "modal-open") return this.none(); return this.some(); } public async run(interaction: ModalSubmitInteraction) { const user = await interaction.client.users.fetch("1252079083022581822"); const embed: EmbedBuilder = new EmbedBuilder() .setTitle("Пришла новая заявка!") .setDescription( ` Ник: ${interaction.fields.getTextInputValue("nickInput")} Возраст: ${interaction.fields.getTextInputValue("ageInput")} Чем затягивает: ${interaction.fields.getTextInputValue("loveInput")} Как давно играет: ${interaction.fields.getTextInputValue("xpInput")} Откуда узнал: ${interaction.fields.getTextInputValue("whereInput")} `, ) .setFooter({ text: interaction.user.id, }); await user.send({ embeds: [embed], components: [ new ActionRowBuilder<ButtonBuilder>().addComponents( new ButtonBuilder() .setLabel("Принять") .setEmoji("") .setCustomId("accept-btn") .setStyle(ButtonStyle.Success), new ButtonBuilder() .setLabel("Отклонить") .setEmoji("") .setCustomId("decline-btn") .setStyle(ButtonStyle.Danger), ), ], }); await interaction.reply({ content: `Ваша заявка успешно отправлена на рассмотрение!`, ephemeral: true, }); } }[/CODE]Вывод окна пользователю используя кнопку: [CODE=js]import { InteractionHandler, InteractionHandlerTypes, } from "@sapphire/framework"; import { type StringSelectMenuInteraction, type ButtonInteraction, } from "discord.js"; import ApplicationModal from "../../templates/modals/applicationModal"; export class MessageComponentHandler extends InteractionHandler { public constructor( ctx: InteractionHandler.LoaderContext, options: InteractionHandler.Options, ) { super(ctx, { ...options, interactionHandlerType: InteractionHandlerTypes.MessageComponent, }); } public override parse( interaction: ButtonInteraction | StringSelectMenuInteraction, ) { if (interaction.customId !== "send-app") return this.none(); return this.some(); } public async run( interaction: ButtonInteraction | StringSelectMenuInteraction, ) { if (interaction.isButton()) { await new ApplicationModal().sendApplicationModal(interaction); } } }[/CODE] Как работать с выпадающими меню Выпадающие меню, как способ хранения множества информации в маленьком месте Код обработчика выборочных меню [CODE=js]import { InteractionHandler, InteractionHandlerTypes, } from "@sapphire/framework"; import type { StringSelectMenuInteraction } from "discord.js"; export class MenuHandler extends InteractionHandler { public constructor( ctx: InteractionHandler.LoaderContext, options: InteractionHandler.Options, ) { super(ctx, { ...options, interactionHandlerType: InteractionHandlerTypes.SelectMenu, }); } public override parse(interaction: StringSelectMenuInteraction) { if (interaction.customId !== "our-social") return this.none(); return this.some(); } public async run(interaction: StringSelectMenuInteraction) { await interaction.reply({ // Remember how we can have multiple values? Let's get the first one! content: `${interaction.values[0]}`, ephemeral: true, }); } }[/CODE] Реальный пример использования выборочных меню для отображения социальных сетей дискорд сервера [CODE=js]import { Command } from "@sapphire/framework"; import { type ChatInputCommandInteraction } from "discord.js"; import { ApplyOptions } from "@sapphire/decorators"; import { ApplicationCommandRegistry } from "@sapphire/framework"; import { StringSelectMenuOptionBuilder, EmbedBuilder } from "discord.js"; import { StringSelectMenuBuilder } from "discord.js"; import { ActionRowBuilder } from "discord.js"; @ApplyOptions<Command.Options>({ name: "our-social", description: "Возвращает соц-сети сервера", }) export default class SocialCommand extends Command { public override async registerApplicationCommands( registry: ApplicationCommandRegistry, ) { registry.registerChatInputCommand((command) => { command.setName(this.name).setDescription(this.description); this.container.logger.info(`Command ${this.name} was successfully registered.`) }); } public async chatInputRun(interaction: ChatInputCommandInteraction) { const embed = new EmbedBuilder() .setTitle(`Наши социальные сети`) .setDescription( `Воспользуйтесь выборочным меню ниже,\nчтобы выбрать интересующую вас социальную сеть.`, ); await interaction.reply({ embeds: [embed], ephemeral: true, components: [ new ActionRowBuilder<StringSelectMenuBuilder>({ components: [ new StringSelectMenuBuilder() .setCustomId(`our-social`) .setMinValues(1) .setMaxValues(1) .setOptions( new StringSelectMenuOptionBuilder({ label: `Youtube`, value: `https://www.youtube.com/хуй`, }), new StringSelectMenuOptionBuilder({ label: `TikTok`, value: `https://tiktok.com/хуй`, }), new StringSelectMenuOptionBuilder({ label: `Twitch`, value: `https://www.twitch.tv/хуй`, }), ), ], }), ], }); } } [/CODE] Мы познакомились с богатыми возможностями взаимодействий через фреймворк Sapphire, тут были перечислены не все взаимодействия, подробнее вы можете посмотреть в официальной документации фреймворка: ТЫК Слушатели (Listeners) Слушатели позволяют выполнять определенный кусочек кода при изменений условий на стороне вашего сервера, например: - Зашел новый участник (Написать ему приветственное сообщение в лс) - Бот присоединился к серверу (Отправить справку по использованию) Создание простого слушателя, при запуске бота. В пакете listeners создаете файл ready.ts и записываете следующее [CODE=js]import { Listener } from '@sapphire/framework'; import type { Client } from 'discord.js'; export class ReadyListener extends Listener { public run(client: Client) { const { username, id } = client.user!; this.container.logger.info(`Successfully logged in as ${username} (${id})`); } }[/CODE] Список всех слушателей можно посмотреть тут - ТЫК Использование базы данных MongoDB Создание базы данных MongoDB (Бесплатно) Бесплатная БД подойдет для небольшого бота. Для более серьезных нужно будет покупать тариф получше. Нам пока-что хватит и бесплатного Переходим на сайт MongoDB - тЫк и регистрируемся Тыкаем на Build a Cluster и выставляем следующие настройки У вас вылезет окошко с выбором метода подключения базы данных, выбираете Driver. Далее вам должна показаться ссылка с адресом на вашу базу данных. Эту ссылку мы пропишем в .env файл. Подключаем базу данных к боту В пакете mongo создаем файл mongo.ts и прописываем следующий код, не забудьте установить yarn add mongoose [CODE=js]import mongoose from "mongoose"; export default class MongoDB { public async connect(token?: string) { try { await mongoose.connect(token) console.log(`Successfully connected to MongoDB`) } catch (error) { console.error(error) } } }[/CODE] В index.ts нам нужно немного поменять код, инициализируем метод подключения к БД [CODE=js]import "dotenv/config" import MongoDB from "./mongo/mongo"; new MongoDB().connect(process.env.DATABASE_TOKEN); import Client from "./client/client"; new Client().login(process.env.TOKEN);[/CODE] Пробуем запустить бота и видим следующее: Если вы все сделали правильно, вы получите сообщение об успешном подключении к MongoDB. Создание таблицы, А.К.А userSchema В пакете mongo создаем еще один пакет schemas, а уже в нем файл user.ts Вводим следующий код: [CODE=js]import mongoose from "mongoose"; const userSchema = new mongoose.Schema({ userId: { type: String, required: true }, }) const UserSchema = mongoose.model('User', userSchema); export { UserSchema }; [/CODE] По вашей надобности вы можете добавить больше полей, но в примере пока ограничимся одним. Как теперь это использовать? Например мы можем создавать пользователя, после того, как он зашел на сервер. Начнем с написания слушателя участников. Слушатель новых участников [CODE=js]import { Listener } from '@sapphire/framework'; import { Client } from 'discord.js'; import { UserSchema } from "../../mongo/schemas/user"; import mongoose from "mongoose"; import SendPersonalMessage from '../functions/sendPM'; import AddRole from '../functions/addRole'; import WelcomeEmbed from '../templates/embeds/not_callable/welcome'; export abstract class MemberListener extends Listener { public constructor(context: Listener.LoaderContext, options: Listener.Options) { super(context, { ...options, once: false, event: 'guildMemberAdd' }); } public async run(client: Client) { const { username, id } = client.user!; const embed = await new WelcomeEmbed().embed() this.container.logger.info(`Присоединился новый участник ${username} (${id}).`); userProfile = await new UserSchema({ _id: new mongoose.Types.ObjectId(), userId: id, }); } }[/CODE]После того, как зайдет новый пользователь на сервер, он сразу же добавится в нашу базу данных. Спасибо за прочтение столь длинной статьи, надеюсь она была тебе полезна. Дальше советую полностью прочитать документацию discord.js и sapphire.js, чтобы лучше понять, как работают эти библиотеки и Discord API в целом. ps: не бейте палками если увидите не код, а какую-то хуйню. какие-то приколы редактора лзт
Yowori, походу какое-то форумное ограничение на создание блоков кода. убрал сайзы, но блок все равно не отрисовывается