Вы когда-нибудь хотели создать свой бот для игры в крестики-нолики в Discord? Так ещё при помощи 🚀blazingly fast🚀Rust и крейта serenity! Всех заинтересовавшихся прошу под кат. Пытаясь создать Discord сервер и бота к нему для моего новоиспечённого университета, я обнаружил много интересных нововведений в Discord API для разработчиков ботов, с которыми я решил поделиться с вами в своей первой статье на Хабре. В ней я расскажу об основных нюансах написания Discord бота с использованием новых фишек, на языке программирования Rust, используя крейт serenity. Минимальное знание базы языка Rust (Rust Book) и ассинхроного программирования в нём. Сервер в Discord'е как минимум с одним текстовым каналом. Начнём по порядку. Для работы нашего бота ему необходимо создать аккаунт на сайте для разработчиков Discord. Переходим по ссылке, авторизируемся в своём Discord аккаунте и переходим во вкладку Applications. Там нажимаем на кнопку New Application: Вводим название нашего бота и нажимаем Create: Перед нами высвечивается окно общих настроек нашего приложения. В нём я установил иконку для нашего бота. Чтобы использовать все функции нашего приложения, надо создать пользователя-бота во вкладке слева: Создаём бота кнопкой Add Bot и нажимаем на Reset Token: Копируем появившийся токен, и стараемся чтобы он не попал в чужие руки, так как через него будет работать наш бот. Последним штрихом в нашей настройке бота будет раздел Privileged Gateway Intents. Intents позволяют нам включать/выключать некоторые возможности бота такие, как Presence Intent (необходим для приложений), Server Member Intent (позволяет работать с членами гильдий(серверов)) и необходимый нам прямо сейчас Message Content Intent, который позволит нам читать текст сообщений: Создание и настройка бота завершена. Теперь нам необходимо добавить нашего бота на наш сервер. Переходим во вкладку OAuth2/URL Generator: Выбираем bot в Scopes, так как нам остальные не нужны. А в Bot permissions выбираем права доступа Administrator. Они позволят нам творить всё, что мы пожелаем, но если вы собираетесь делать настоящий бот, рекомендуется выбирать только необходимые для вашего бота разрешения. После настройки бота, переходим по сгенерированному адресу, и добавляем бота на наш тестовый сервер: Начнём по порядку. Во-первых нам необходимо добавить крейт serenity в наш Cargo.TOML файл: Примечение:Мы используем библиотеку serenity с ветки next и с определенного коммита (ba3be69166f54c5986e4cc9438bc5bb4606fa4c2). В main.rs напишем тестовый вариант нашего бота, чтобы проверить, работает ли всё, как надо: Исходный код на этой стадии проектирования бота. Не забудьте добавить файл с токеном (token.txt) в директорию проекта Если мы всё сделали правильно, то при вводе команды !ping бот будет нам отвечать "pong!": Вы скажете: "Хорошо, это мы уже видели двести раз в бесконечных туториалах, можешь ли ты показать хоть что-то интересное?" И я отвечу - да. Да начнётся с этого момента самое интересное! В последних версиях Discord появились взаимодействия (Interactions), представленные в следующей табличке: Взаимодействие Краткое описание PING Необходимо для взаимодействия с webhook-based interactions APPLICATION_COMMAND - Ввод с чата (слэш-команды)- Контекстное меню сообщения- Контекстное меню пользователя MESSAGE_COMPONENT Обработка нажатия кнопок, выбора элемента из выпадающего списка APPLICATION_COMMAND_AUTOCOMPLETE Автодополнение слэш-команд MODAL_SUBMIT Обработка ввода текста из всплывающих модальных окон Давайте рассмотрим некоторые из них, необходимые для нашего бота, более подробней. Например, Slash commands выглядят следующим образом: Они позволяют более удобно пользоваться Discord ботами: они дают возможность добавлять кастомные параметры, которые будут подсвечивать необходимые варианты, настраивать уровни доступа к команде в настройках сервера (Server Settings > Apps > Integrations). При помощи них мы сделаем команду по вызову игры:  /play. Также одним из важных нововведений для разработчиков ботов было создание временных сообщений (ephemeral), которые видны только человеку, который отправил слэш-команду. Это позволило создать большое количество интересных ботов, которые теперь также можно подключить при помощи двух кликов (Server Settings > Apps > App Directory). Конкретно в нашей ситуации, нам это пригодится в качестве инструмента для ввода нашего хода. Последний необходимый нам функционал заложен в Message Components. При помощи него, мы можем добавлять добавлять кнопки, выпадающий список и модальное окно ввода текста. Кнопку и раскрывающийся список необходимо добавлять в `ActionRow` сообщения, где количество кнопок может быть максимум пять, а список только один. А самих ActionRow можно добавить тоже только пять к сообщению. Отсюда нам пригодится информация только про ActionRow и кнопки, которые будут позволять нам двигаться по игровому полю (3x3), а также отправлять наш ход противнику. Interaction Callback Type CreateInteractionResponse Описание PONG Pong Подтвердить PING взаимодействие CHANNEL_MESSAGE_WITH_SOURCE Message Ответить на взаимодействие сообщением DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE Defer Подтвердить взаимодействие, и изменить interaction response позже. Пользователь будет видеть состояние загрузки DEFERRED_UPDATE_MESSAGE Acknowledge [Для компонентов] Подтвердить взаимодействие и изменить оригинальное сообщение позже. Пользователь не будет видеть состояние загрузки UPDATE_MESSAGE UpdateMessage [Для компонентов] Изменить сообщение, компонент который был приложен к нему APPLICATION_COMMAND_AUTOCOMPLETE_RESULT Autocomplete Ответить на взаимодействие автодополнения с предложенными вариантами MODAL Modal Ответить на взаимодействие при помощи всплывающего модального окна Здесь я заменил центральный столбец значений Discord API на столбец значений enum CreateInteractionResponse из serenity, чтобы было более наглядно видно, что я предлагаю использовать в нашем боте. В нём будут использоваться только CHANNEL_MESSAGE_WITH_SOURCE (Message) и DEFERRED_UPDATE_MESSAGE (Acknowledge). Первое мы будем использовать всегда, когда надо будет прислать новое сообщение, а последнее только в одном случае, когда мы будем обрабатывать ход игрока, редактируя уже хранящиеся в памяти значения CommandInteraction. Примечание:Эти и другие возможности Discord API можно посмотреть на сайте https://discord.com/developers/docs/intro. Рекомендую! Первое, с чего следовало бы начать, это переделать нашу команду !ping на современные слэш-команды. Для этого добавим файл ping.rs с двумя фунциями, которые позволят нам создать нашу первую слэш-команду!: Функция register позволяет нам присвоить имя нашей слэш-команде и сделать к ней описание (а также указать необходимые параметры для ввода), а в функции application_command мы проводим обработку вызова команды, выдавая в качестве ответа сообщение, которое будет видно только отправителю. Осталось связать наш модуль ping с нашей предыдущей программой. Для этого нам необходимо добавить interaction_create метод в EventHandler трейт, который находится в main.rs файле: Также не забываем зарегистрировать нашу команду в методе ready. Достаточно один раз зарегистрировать команду, и она там будет находится даже после перезапуска бота, но для простоты, мы её будем всегда её регистрировать, когда стартуем бота. Примечание:Если вы хотите удалить слэш-команду, используйте функцию GuildId::delete_application_command. В результате мы получаем следующее: А также слэш-команда появилась в серверных настройках нашего бота (Server Settings > Apps > Integrations): Примечание:Чтобы можно было видеть ID объектов (например, тех же слэш-команд), при нажатии правой кнопки по ним, необходимо включить режим разработчика в User Settings > App Settings > Advanced > Developer Mode. Исходный код текущего этапа. Имея на руках опыт работы со слэш-командами, мы можем предположить, что можно создать команду /play, выводящую ephemeral сообщение для каждого человека, который её введет. Следовательно, каждый игрок, вводя эту команду, будет получать сообщение, сообщающее, что нужно дождаться второго игрока, или сразу будет начинать игровую сессию. Каждую игровую сессию мы будем хранить в Vec<Arc<Mutex<GameSession>>, где, для простоты примера, все элементы GameSession будут обернуты одним Mutex'ом. Для реализации движения на карте будет использоваться кнопки, а также не будем забывать про то, что их можно делать ненажимаемыми, следовательно можно будет уменьшить количество условий для проверки хода. Также, чтобы сделать нашу игру более красочной, мы добавим крейты image и imageproc, которые позволят нам рендерить игровое поле: Главная структура, которая будет хранить загруженные в память изображения крестика, нолика и полосок, которые будут перечеркивать победную троицу. Также мы сразу отрисуем игровое поле и запишем в new_game_canvas, которое мы будем потом переиспользовать каждый раз, когда будет начинаться новая игра. После каждого вызова слэш-команды /play мы будем проверять, хочет ли ещё кто-то поиграть в wait_user, если да, мы запустим новую сессию и добавим её в sessions, иначе положим нашего игрока подождать в поле wait_user: Как можно было заметить, wait_user хранит не только id пользователя, но также CommandInteraction, которое мы будем изменять для изображения холста игры и кнопок. Третья String хранит имя пользователя (либо имя члена сервера, а если его нет - оригинальное имя). В то же время, можно было использовать следующее форматирование: <@USER_ID> - и хранить только UserId, но оно не работает в заголовках embed сообщений, так что мы будем запоминать имя нашего игрока при старте его игровой сессии. Последний элемент кортежа (Message) хранит сообщение, которое будет общедоступно всем, и будет показывать ход игры между двумя игроками. Следующий код описывает структуру игровой сессии: Из интересного, здесь имеется stage переменная, которая будет обозначать, кто сейчас ходит, а также cursor_pos переменная, хранящая позицию виртуального "курсора" игрока, который ходит в данный момент. Простая инициализация, с подгрузкой следующих изображений из директории ресурсов: В функции draw_new_game_canvas() происходит отрисовка нашего игрового поля: Конечно, не забудем инициализировать наши слэш-команды. Также я решил добавить команду-заглушку /stop: Теперь нам предстоит написать один из самых важных методов для нашей программы: command(), который будет обрабатывать ввод слэш-команды /play и запускать игровую сессию: Начнём с отсеивания ненужных нам вариантов. В начале сразу отсеим команду /stop, выводя текст "Unimplemented!": Также проверим, есть ли наш игрок уже в какой-либо игре: Последовательно ищем такой же UserId в self.wait_user, а потом во всех уже идущих игровых сессиях. После того, как мы отсеяли ненужные нам варианты, мы проверяем, ждет ли кто-то ещё старта игры в self.wait_user, иначе мы записываем нашего текущего игрока в эту переменную: После того, как мы дождались двух игроков, мы добавляем их в общий массив всех игровых сессий: И под конец вызываем метод self.process_session(), который будет отображать первый кадр нашей игры: Ниже я сразу покажу три похожие функции, которые обновляют окно у игрока в зависимости от того, ждёт ли он хода или уже ходит: Можно было заметить, что функция update_game_message() использует ComponentInteraction, а не CommandInteraction. Это связано с тем, что нажатия кнопок, выбор элемента из раскрывающего списка и ввод текста вызывают Message Component interaction, а не Application Command interaction, как при вызове слэш-команд. В дальнейшем мы напишем функцию, которая будет обрабатывать нажатия кнопок. Третья функция update_game_message() используется только когда игрок, который ходит, двигается по карте: Для отрисовки красного квадрата выбора клетки, воспользуемся следующей функцией: Где массив CELLS - захардкоженные значения позиций ячеек: Функции (generate_disabled_action_row() и generate_game_action_row()) особого интереса не представляют, кроме того, что я решил не убирать кнопки игроку, ожидающему своей очереди, а просто их отключать: Осталась одна нераскрытая функция, которая позволит создать вложение (CreateAttachment) для нашего сообщения: Осталось уже совсем немного. Обработаем Message Component interaction: Не забываем указать некоторые вспомогательные методы: Мы были обязаны указать у каждой кнопки свой custom id, который нам пригодился здесь, для того, чтобы понять какая кнопка была нажата. Тут же мы проверяем, не закончилась ли наша игра победой одного из игрока, либо ничьей. В конце удаляем у каждого игрока ephemeral сообщения, оставляя только то, которое было доступно всем, в котором пишем результат прошедшей игры: В методе generate_end_attachment() рендеринг происходит следующим интересным образом: мы берём полупрозрачное изображении нашей полоски (которая перечеркивает победную троицу) и накладываем на последнюю версию холста при помощи функции fill_pixel(): Нам этом точно всё. То есть всё с модулем game.rs, мы с ним покончили. Осталось только подправить main.rs и запустить нашего бота!: Исходники бота. Запускаем бота и наблюдаем следующий результат: Можно заметить, что взаимодействия в Discord имеют довольно большой таймаут. Предполагаю, что ещё вносит свою лепту то, что мы меняем изображение, это тоже делает задержку. Так что для каких-то очень динамичных игр это не подойдет. Конечно, сейчас уже доступны (по подписке Nitro) активности в голосовых каналах, правда которые могут разрабатывать только команда Discord. Также я не уверен, что мы в скором времени увидим их общедоступными для всех пользователей и разработчиков, хотя я бы посмотрел на это. Но главную цель этого туториала - познакомиться с уже доступными возможностями для разработки своего собственного бота - мы выполнили. Спасибо, что прочитали эту статью. Могу от себя сказать, что я наконец-то понял, насколько тяжело писать статьи. Надеюсь, и эта кому-то поможет или даст вдохновение. Пишите в комментариях ваши замечения и отправляйте при помощи CTRL + Enter найденные вами очепятки! UPD:@TheDanikReal в комментариях подметил, что можно было создать ряд из кнопок (3x3), т.к. можно создать несколько экземпляров(до 5) ActionRow и добавить их к сообщению. Изначально я не сделал так, так как знал только то, что можно к ActionRow до 5 кнопок. Если реализовать идею @TheDanikReal из комментариев (сделать 9 кнопок, по одной кнопке на одну ячейку поля) то можно получить более динамичный и интересный геймплей из-за уменьшения задержек во время игры. Discord API serenity Репозиторий