Русский
Русский
English
Статистика
Реклама

Bot

Из песочницы Создание Discord-бота, используя библиотеку discord.js Часть 1

23.06.2020 20:12:31 | Автор: admin

Введение


В этой статье я подробно расскажу о том, как работать с библиотекой discord.js, создать своего Discord-бота, а также покажу несколько интересных и полезных команд.

Сразу хочу отметить, что я планирую сделать ряд подобных статей, начиная с простых команд, заканчивая музыкой, системой экономики и распознаванием голоса ботом.

Начало работы


Если вы уже знакомы с приведёнными ниже материалами, смело можете пролистать этот раздел.

Установка среды разработки
Для начала работы с кодом нам нужно установить среду разработки, это может быть:


и так далее.

Среда разработки выбирается по удобству использования и практичности, она у вас может быть любая, но мы рассмотрим её на примере Visual Studio Code, так как она является одной из самых приемлемых для новичков, а также для опытных программистов.

Для установки переходим по этой ссылке.


Выбираем свою операционную систему и запускаем скачивание.

Установка среды выполнения
Для создания бота мы используем среду выполнения node.js. Для её установки нам необходимо перейти на этот сайт.



На данный момент нас интересует версия долгосрочной поддержки (LTS), скачиваем её.

Установка полезных расширений
В Visual Studio Code присутствует возможность устанавливать расширения.
Для этого, кликните по отмеченной ниже иконке.



В открывшемся окне вписываем название название/идентификатор нужного нам расширения, после чего устанавливаем его.



Из полезных расширений могу отметить:

  1. Discord Presence расширение, позволяющее отображать рабочую область и файл, в котором вы работаете в вашей игровой активности (функция работает только при использовании приложения Discord).

    Идентификатор расширения: icrawl.discord-vscode


  2. Code runner расширение, с помощью которого предоставляется возможность запускать определённые фрагменты кода.
    Идентификатор расширения: formulahendry.code-runner


Создание бота


Теперь, когда вы установили все нужные компоненты, мы можем приступить к созданию самого бота.

Здесь всё просто. Переходим на портал разработчиков и нажимаем на кнопку с надписью New Application она находится в правом верхнем углу.

В открывшемся окне вписываем имя бота, после чего, нажимаем на кнопку с надписью Create.



На этой странице мы можем изменить имя бота, загрузить для него иконку, заполнить описание.

Теперь наша задача воплотить бота в жизнь. Для этого переходим во вкладку Bot.



Нажимаем на кнопку с надписью Add Bot и воплощаем бота в жизнь.

Поздравляю! Вы создали аккаунт для вашего бота. Теперь у него есть тег, токен, ник и иконка.

Подготовка к написанию кода


После создания аккаунта для бота, мы должны установить нужные пакеты и модули, чтобы в дальнейшем он корректно работал.

Первым делом создаём папку, после чего открываем её в VS Code (Файл > Открыть папку) / (Ctrl + K Ctrl + O)

Далее нам нужно открыть терминал (Терминал > Создать терминал) / (Ctrl + Shift + `)



Теперь мы должны создать файл с неким описанием нашего бота, сделаем это через терминал.

Вписываем данную строку в терминал и нажимаем Enter:

npm init

После каждой появившейся строки нажимаем Enter или вписываем свои значения.
Значения в этом файле можно будет изменить в любой момент.

Далее, мы должны поочерёдно вводить в терминал эти строки:

npm install

npm install discord.js

Install также можно сокращать в I, но необязательно.

Итого, если вы следовали инструкциям и всё сделали правильно, в вашей папке должны были появиться 3 объекта:



Написание кода


Об удобстве написания кода
Для того, чтобы наш бот появился в сети и мог реагировать на команды, нам нужно написать для него код.

Существует множество вариантов для его написания: используя один файл, два, несколько, и т.д

Мы рассмотрим вариант с двумя файлами, так как его использовать очень легко и удобно, но у каждого варианта есть свои недостатки например, у этого недостатком является сложность в написании начального кода.

Но не волнуйтесь, весь код вам писать не придётся.

О хранении данных
Для начала, нам нужно где-то хранить основные параметры и информацию о боте.

Мы можем сделать это двумя способами:

  1. Создать отдельный файл
  2. Записать всё в константы

Я не советую вам использовать второй вариант, так как в будущем вам придётся работать с большим объёмом информации, и такая запись будет доставлять неудобства.

Разберём хранение параметров в отдельном файле.

Итак, создаем файл config.json

Вставляем в него следующий код:

{    "token" : "Ваш_токен",    "prefix" : "Ваш_префикс"}

* Для получения токена зайдите на портал разработчиков, перейдите во вкладку Bot и скопируйте его.



* Самым распространённым среди разработчиков префиксом является !

Далее нам нужно создать файл bot.js и вставить в него данный код:

const Discord = require('discord.js'); // Подключаем библиотеку discord.jsconst robot = new Discord.Client(); // Объявляем, что robot - ботconst comms = require("./comms.js"); // Подключаем файл с командами для ботаconst fs = require('fs'); // Подключаем родной модуль файловой системы node.js  let config = require('./config.json'); // Подключаем файл с параметрами и информациейlet token = config.token; // Вытаскиваем из него токенlet prefix = config.prefix; // Вытаскиваем из него префиксrobot.on("ready", function(){ /* Бот при запуске должен отправить в терминал сообщение [Имя бота] запустился! */console.log(robot.user.username + " запустился!");});robot.on('message', (msg) => { // Реагирование на сообщенияif(msg.author.username != robot.user.username && msg.author.discriminator != robot.user.discriminator){    var comm = msg.content.trim()+" ";    var ok = false;    var comm_name = comm.slice(0, comm.indexOf(" "));    var messArr = comm.split(" ");    for(comm_count in comms.comms){    var comm2 = prefix + comms.comms[comm_count].name;    if(comm2 == comm_name){    comms.comms[comm_count].out(robot, msg, messArr);    }    }    } });robot.login(token); // Авторизация бота

Теперь создаём файл comms.js, в нём будут сами команды.

В нём должен быть следующий код:

const config = require('./config.json'); // Подключаем файл с параметрами и информациейconst Discord = require('discord.js'); // Подключаем библиотеку discord.jsconst prefix = config.prefix; // Вытаскиваем префикс// Команды //    function test(robot, mess , args) {        mess.channel.send('Test!')    } // Список комманд //var comms_list = [{name: "test", out: test, about: "Тестовая команда"}];// Name - название команды, на которую будет реагировать бот// Out - название функции с командой// About - описание команды module.exports.comms = comms_list;

Чтобы добавить больше команд просто объявляйте больше функций и добавляйте их в список, например:

const config = require('./config.json');const Discord = require('discord.js');const prefix = config.prefix;const versions = config.versions;// Команды //    function test(robot, mess, args) {        mess.channel.send("Тест!")    }    function hello(robot, mess, args) {        mess.reply("Привет!")    } // Список комманд //var comms_list = [{name: "test", out: test, about: "Тестовая команда"},        {name: "hello", out: hello, about: "Команда для приветствия!"}}module.exports.comms = comms_list;

И вот, мы вышли на финишную прямую!

Осталось всего ничего запустить бота.

Для этого открываем терминал и вставляем в него следующую строку:

node bot.js



Готово! Бот запущен и вы можете им пользоваться, ура!

Чтобы пригласить бота на свой сервер, воспользуемся нам уже известным порталом разработчиков.

Перейдём во вкладку OAuth2, пролистаем чуть ниже, выберем Bot и отметим нужные боту привилегии.

Теперь осталось скопировать ссылку-приглашение и добавить бота на свой сервер.



Как вывести ссылку-приглашение в терминал, при запуске бота?
Существует два способа:

  1. Заранее отметить нужные привилегии.

    Для этого, сначала мы должны скопировать ссылку-приглашение.
    После чего перейти в файл bot.js и вставить данную строчку кода сюда:

    robot.on("ready", function(){    console.log(robot.user.username + " запустился!");    console.log("Ссылка-приглашение")  // << //})
    

    Итоговый код должен быть таким:

    const Discord = require('discord.js'); const robot = new Discord.Client();var comms = require("./comms.js");const fs = require('fs');let config = require('./config.json');let token = config.token;let prefix = config.prefix; robot.on("ready", function(){    console.log(robot.user.username + " запустился!");    console.log("Ссылка-приглашение")})robot.on('message', (msg) => {if(msg.author.username != robot.user.username && msg.author.discriminator != robot.user.discriminator){    var comm = msg.content.trim()+" ";    var ok = false;    var comm_name = comm.slice(0, comm.indexOf(" "));    var messArr = comm.split(" ");    for(comm_count in comms.comms){    var comm2 = prefix + comms.comms[comm_count].name;    if(comm2 == comm_name){    comms.comms[comm_count].out(robot, msg, messArr);    }    }    } });robot.login(token)  robot.login(token);
    

  2. Отметить нужные привилегии в самом коде.

    Повторяем процедуры из первого способа, но уже с другими строками кода:

    robot.on("ready", function(){    console.log(robot.user.username + " запустился!");    robot.generateInvite(["ADMINISTRATOR"]).then((link) => { // < //        console.log(link); // < //})})
    

    Итоговый код:

    const Discord = require('discord.js'); const robot = new Discord.Client();var comms = require("./comms.js");const fs = require('fs');let config = require('./config.json');let token = config.token;let prefix = config.prefix;robot.on("ready", function(){    console.log(robot.user.username + " запустился!");    robot.generateInvite(["ADMINISTRATOR"]).then((link) => {         console.log(link);})})robot.on('message', (msg) => {if(msg.author.username != robot.user.username && msg.author.discriminator != robot.user.discriminator){    var comm = msg.content.trim()+" ";    var ok = false;    var comm_name = comm.slice(0, comm.indexOf(" "));    var messArr = comm.split(" ");    for(comm_count in comms.comms){    var comm2 = prefix + comms.comms[comm_count].name;    if(comm2 == comm_name){    comms.comms[comm_count].out(robot, msg, messArr);    }    }    } });   robot.login(token);
    

    Чтобы указать несколько привилегий, мы должны перечислить их в квадратных скобках, через запятую:

        robot.generateInvite(['KICK_MEMBERS', 'BAN_MEMBERS', 'SEND_MESSAGES']).then((link) => {         console.log(link);
    

    * Все привилегии указываются заглавными буквами

    Список доступных привилегий:

    ADMINISTRATOR
    CREATE_INSTANT_INVITE
    KICK_MEMBERS
    BAN_MEMBERS
    MANAGE_CHANNELS
    MANAGE_GUILD
    ADD_REACTIONS
    VIEW_AUDIT_LOG
    PRIORITY_SPEAKER
    STREAM
    VIEW_CHANNEL
    SEND_MESSAGES
    SEND_TTS_MESSAGES
    MANAGE_MESSAGES
    EMBED_LINKS
    ATTACH_FILES
    READ_MESSAGE_HISTORY
    MENTION_EVERYONE
    USE_EXTERNAL_EMOJIS
    VIEW_GUILD_INSIGHTS
    CONNECT
    SPEAK
    MUTE_MEMBERS
    DEAFEN_MEMBERS
    MOVE_MEMBERS
    USE_VAD
    CHANGE_NICKNAME
    MANAGE_NICKNAMES
    MANAGE_ROLES
    MANAGE_WEBHOOKS
    MANAGE_EMOJIS


    Я не советую вам из привилегий выбирать только ADMINISTRATOR, поэтому лучше указать только те привилегии, которые бот действительно использует для корректной работы


Полезные и интересные команды


В предыдущем разделе я показал вам, как запустить бота и как писать для него команды.
Теперь я хочу поделиться с вами несколькими своими командами.

  1. !say с помощью этой команды бот может повторить ваше сообщение.



    Код:

        if(!mess.member.hasPermission("MANAGE_MESSAGES")) return mess.channel.send("У  вас нет прав"); /* Если у исполнителя команды нету привилегии MANGAGE_MESSAGES, он не сможет её использовать */        let robotmessage = args = mess.content.split(' '); // Пробелы между словами     args.shift();    args = args.join(' ');    mess.delete().catch(); // Удаление сообщения пользователя после отправки     mess.channel.send(robotmessage).then(mess.channel.send(mess.author)) /*             Отправление в чат сообщения бота */
    

  2. !heads_or_tails игра Орёл или Решка.



    Код:

            mess.channel.send('Монета подбрасывается...')        var random = Math.floor(Math.random() * 4); // Объявление переменной random - она вычисляет случайное число от 1 до 3        if (random == 1) { // Если вычислено число 1, то выпадает орёл.            mess.channel.send(':full_moon: Орёл!')        } else if (random == 2) { // Если вычислено число 2, то выпадает решка.            mess.channel.send(':new_moon: Решка!')        } else if (random == 3) { // Если вычислено число 3, то монета падает         ребром.            mess.channel.send(':last_quarter_moon: Монета упала ребром!')        }
    

  3. !clear удаление определённого количества сообщений.



    Код:

            const arggs = mess.content.split(' ').slice(1); // Все аргументы за именем команды с префиксом        const amount = arggs.join(' '); // Количество сообщений, которые должны быть удалены        if (!amount) return mess.channel.send('Вы не указали, сколько сообщений нужно удалить!'); // Проверка, задан ли параметр количества        if (isNaN(amount)) return mess.channel.send('Это не число!'); // Проверка, является ли числом ввод пользователя                 if (amount > 100) return mess.channel.send('Вы не можете удалить 100 сообщений за раз'); // Проверка, является ли ввод пользователя числом больше 100        if (amount < 1) return mess.channel.send('Вы должны ввести число больше чем 1'); // Проверка, является ли ввод пользователя числом меньше 1                async function delete_messages() { // Объявление асинхронной функции        await mess.channel.messages.fetch({ limit: amount }).then(messages => {            mess.channel.bulkDelete(messages)            mess.channel.send(`Удалено ${amount} сообщений!`)        })};        delete_messages(); // Вызов асинхронной функции
    
  4. !random_name генерация случайного имени.



    Не стоит пугаться большого кода, здесь всё предельно просто.

    Код:

            var name = new Array( // Объявление массива name и занесение в него большого количества имён            'Абрам',' Аваз',' Аввакум',' Август',' Августин',            ' Авдей',' Авраам',' Автандил',' Агап',' Агафон',            ' Аггей',' Адам',' Адис',' Адольф',' Адриан',            ' Азамат',' Айдар',' Айнур',' Айрат',' Аким',            ' Алан',' Алей',' Александр',' Алексей',' Али',            ' Альберт',' Альфред',' Амадей',' Амадеус',            ' Амаяк',' Амвросий',' Ананий',' Анастасий',            ' Анатолий',' Анвар',' Ангел',' Андоим',' Андрей',            ' Аникита',' Антон',' Арам',' Арий',' Аристарх',            ' Аркадий',' Арман',' Арно',' Арнольд',' Арон',' Арсен',            ' Арсений',' Арслан',' Артем',' Артемий',' Артур',' Архип'            ,' Аскар',' Аскольд',' Аслан',' Афанасий',' Ахмет',' Ашот'            ,' Бальтазар',' Бежен',' Бенедикт',' Берек',' Бернард',            ' Бертран',' Богдан',' Болеслав',' Борис',' Бронислав',            ' Булат',' Вадим',' Валентин',' Валерий',' Вальтер',            ' Варфоломей',' Василий',' Вацлав',' Велизар',' Венедикт',' Вениамин',' Викентий',' Виктор',' Вилли',' Вильгельм',' Виссарион',' Виталий',' Витольд',' Владимир',' Владислав',' Владлен',' Володар',' Вольдемар',' Всеволод',' Вячеслав',' Гавриил',' Галактион',' Гарри',' Гастон',' Гаяс',' Гевор',' Геннадий',' Генрих',' Георгий',' Геракл',' Геральд',' Герасим',' Герман',' Глеб',' Гордей',' Гордон',' Горислав',' Градимир',' Григорий',' Гурий',' Густав',' Давид',' Дамир',' Даниил',' Даниэль',' Данияр',' Дарий',' Дементий',' Демид',' Демосфен',' Демьян',' Денис',' Джамал',' Джордан',' Дмитрий',' Добрыня',' Дональд',' Донат',' Дорофей',' Евгений',' Евграф',' Евдоким',' Евсевий',' Евсей',' Евстафий',' Егор',' Елеазар',' Елисей',' Емельян',' Еремей',' Ермолай',' Ерофей',' Ефим',' Ефрем',' Жан',' Ждан',' Жорж',' Захар',' Зиновий',' Ибрагим',' Иван',' Игнатий',' Игорь',' Илларион',' Ильдар',' Ильнар',' Ильнур',' Илья',' Ильяс',' Иннокентий',' Иоанн',' Иосиф',' Ипполит',' Искандер',' Ислам',' Камиль',' Карим',' Карл',' Кирилл',' Клим',' Кондрат',' Константин',' Корней',' Кузьма',' Лавр',' Лаврентий',' Лев',' Леон',' Леонид',' Леонтий',' Леопольд',' Лука',' Лукьян',' Любим',' Макар',' Максим',' Максимилиан',' Марат',' Марк',' Марсель',' Мартин',' Матвей',' Мирон',' Мирослав',' Митрофан',' Михаил',' Михей',' Мишель',' Мстислав',' Мурат',' Муслим',' Назар','Абрам',' Аваз',' Аввакум',' Август',' Августин',' Авдей',' Авраам',' Автандил',' Агап',' Агафон',' Аггей',' Адам',' Адис',' Адольф',' Адриан',' Азамат',' Айдар',' Айнур',' Айрат',' Аким',' Алан',' Алей',' Александр',            ' Алексей',' Али',' Альберт',' Альфред',' Амадей',' Амадеус',' Амаяк',' Амвросий',' Ананий',' Анастасий',' Анатолий',' Анвар',' Ангел',' Андоим',' Андрей',' Аникита',' Антон',' Арам',' Арий',' Аристарх',' Аркадий',' Арман',' Арно',' Арнольд',' Арон',' Арсен',' Арсений',' Арслан',' Артем',' Артемий',' Артур',' Архип',' Аскар',' Аскольд',' Аслан',' Афанасий',' Ахмет',' Ашот',' Бальтазар',' Бежен',' Бенедикт',' Берек',' Бернард',' Бертран',' Богдан',' Болеслав',' Борис',' Бронислав',' Булат',' Вадим',' Валентин',' Валерий',' Вальтер',' Варфоломей',' Василий',' Вацлав',' Велизар',' Венедикт',' Вениамин',' Викентий',' Виктор',' Вилли',' Вильгельм',' Виссарион',' Виталий',' Витольд',' Владимир',' Владислав',' Владлен',' Володар',' Вольдемар',' Всеволод',' Вячеслав',' Гавриил',' Галактион',' Гарри',' Гастон',            ' Гаяс',' Гевор',' Геннадий',' Генрих',' Георгий',' Геракл',            ' Геральд',' Герасим',' Герман',' Глеб',' Гордей',' Гордон',            ' Горислав',' Градимир',' Григорий',' Гурий',' Густав',            ' Давид',' Дамир',' Даниил',' Даниэль',' Данияр',            ' Дарий',' Дементий',' Демид',' Демосфен',            ' Демьян',' Денис',' Джамал',' Джордан',' Дмитрий',' Добрыня',            ' Дональд',' Донат',' Дорофей',' Евгений',' Евграф',' Евдоким',' Евсевий',' Евсей',' Евстафий',' Егор',' Елеазар',' Елисей',' Емельян',' Еремей',' Ермолай',' Ерофей',' Ефим',' Ефрем',' Жан',' Ждан',' Жорж',' Захар',' Зиновий',' Ибрагим',' Иван',' Игнатий',' Игорь',' Илларион',' Ильдар',' Ильнар',' Ильнур',' Илья',' Ильяс',' Иннокентий',' Иоанн',' Иосиф',' Ипполит',' Искандер',' Ислам',' Камиль',' Карим',' Карл',' Кирилл',' Клим',' Кондрат',' Константин',' Корней',' Кузьма',' Лавр',' Лаврентий',' Лев',' Леон',' Леонид',' Леонтий',' Леопольд',' Лука',' Лукьян',' Любим',' Макар',' Максим',' Максимилиан',' Марат',' Марк',' Марсель',' Мартин',' Матвей',' Мирон',' Мирослав',' Митрофан',' Михаил',' Михей',' Мишель',' Мстислав',' Мурат',            ' Муслим',' Назар'        );        var RandElement = name[Math.floor(Math.random()*(name.length))]; // Выбор случайного элемента из массива        mess.channel.send(RandElement) // Отправка сообщения со случайным элементом из массива в чат
    


Заключение


Вот и подошла к концу первая часть обучения, как вы могли заметить, создать бота, используя библиотеку discord.js очень просто.

Итого, из этой статьи мы выяснили:

  • Как установить нужные модули и пакеты
  • Как установить среду разработки
  • Как установить среду выполнения
  • Как установить полезные расширения
  • Как создать аккаунт для бота
  • Как воплотить бота в жизнь
  • Как запустить бота
  • Как научить бота реагировать на команды
  • Как пригласить бота на свой сервер
  • Как писать код для работы команд

А также научились некоторым интересным и полезным командам.

Надеюсь, что вам понравилась моя статья и вы узнали из неё что-то новое.

Я постарался объяснить всё максимально доходчиво и подробно.

Следующую часть обучения я планирую сделать об использовании аргументов, а также покажу вам команды для модерации (!kick, !ban, !warn, !mute и т.д).

Сайты для самостоятельного изучения


Основная документация discord.js
Документация discord.js 2
Руководство discord.js
Руководство discord.js 2
Подробнее..
Категории: Javascript , Node.js , Discord , Bot , Боты

Создание Discord-бота, используя библиотеку discord.js Часть 2 Аргументы

21.07.2020 20:05:11 | Автор: admin

Введение


Аргументы являются важнейшей частью 70% команд их изредка называют вводом пользователя, что вполне является правильным.

В данной статье я подробно расскажу вам об их использовании, видах, а также o применении в командах для модерации.

Если вы не читали мою предыдущую статью о создании бота и делаете по другой схеме, добавьте в начало вашего кода данную строку:

const args = message.content.slice(prefix.length).split(/ +/);

Если же вы создаёте бота по моим статьям и пишите код в функциях, вам не придётся ничего добавлять.

Конкретный аргумент


Конкретный аргумент можно получить таким образом:

args[0]

В квадратных скобках указывается номер значения в сообщении:



if (mess.content === `${prefix}test`, args[0]){     mess.channel.send(args[0]);}

В Javascript отсчёт всегда идёт с нуля, поэтому, если мы в квадратных скобках укажем 0, бот отправит в чат сообщение с командой, которую мы вписали нулевой аргумент:



Примеры:

args[1]



args[10]



if (mess.content === `${prefix}test`, args[0]){     mess.channel.send(args[1] + ' ' + args[2]);}



Множество аргументов


Все аргументы в сообщении (или по-другому, содержание сообщения) можно получить следующим образом:

args

if (mess.content === `${prefix}test`, args){     mess.channel.send(args)}



Как видите, бот вывел в чат полное содержание сообщения.

Разделение аргументов


Теперь, когда вы уже немного ознакомились с аргументами, мы можем перейти к наиболее интересной части их разделению.
Для начала, попробуем разделить содержание сообщения на команду и аргументы после неё:

if (!args[1]) { // Если пользователь не ввёл аргументов после команды, бот отправляет следующее сообщение:     mess.channel.send('Вы не указали никаких аргументов.');}if (args[1]) { // Если пользователь ввёл хоть один аргумент, бот отправляет введённую команду:    mess.channel.send(`Команда: ${args[0]}`);    args.shift(); // Удаление введённой пользователем команды     mess.channel.send(`Аргументы: ${args}`) // Отправление сообщения с аргументами после введённой пользователем команды}

Результат:



Также вы можете добавить определение количества аргументов:

mess.channel.send(`Количество аргументов: ${args.length}`);



Работа с упоминаниями


Для того, чтобы получить упоминание пользователя, мы должны добавить данную строку:

const mention = message.mentions.users.first();

С помощью неё мы получим первого упомянутого пользователя.
Далее, выведем в чат имя упомянутого пользователя:

mess.channel.send(`Пользователь, которого вы упомянули: ${mention.username}`);



Но теперь, если мы пропишем команду без упоминания, в консоли появится ошибка:

TypeError: Cannot read property 'username' of undefined

Это легко исправить, добавим следующие строки:

if (!message.mentions.users.size) {     return mess.channel.send('Вы не указали пользователя!');}



Команды для модерации


Наконец, мы можем приступить к командам для модерации.

Начнём, пожалуй, с команды для предупреждения пользователя.

Для того, чтобы мы в будущем смогли сделать кик/бан после определённого количества предупреждений, нам нужно создать JSON-файл, в котором будет храниться информация о предупреждённом участнике: его ID и количество предупреждений.

Назовём файл data.json и вставим в него две фигурных скобки:

{}

После чего, вернёмся к файлу с нашей командой и добавим в начало кода следующие строки:

const fs = require('fs'); // Подключаем родной модуль файловой системы node.jsvar warns = JSON.parse(fs.readFileSync("./data.json", "utf8")); // Объявляем переменную warns, с помощью которой бот сможет прочитать файл data.json

Несколько проверок:

if (!mess.member.hasPermission("KICK_MEMBERS")) return mess.reply("У вас нет прав для использования данной команды"); // Если пользователь попытается предупредить участника сервера без привилегии KICK_MEMBERS, ему будет в этом отказано.if (!mess.guild.me.hasPermission("KICK_MEMBERS")) return mess.reply("У меня нет прав!") // Если у бота нету привилегии KICK_MEMBERS, он отправит соответствующее сообщениеlet wUser = mess.guild.member(mess.mentions.users.first()) || mess.guild.members.cache.get(args[0]) // Восприятие упоминания и аргументаif(wUser.id === mess.author.id) return message.channel.send("Вы не можете выдать предупреждение самому себе!"); // Если пользователь попытается предупредить самого себя, ему будет в этом отказано.if (!wUser) return mess.reply("Вы не указали пользователя") // Если пользователь не найден/не указан - в предупреждении пользователю будет отказано

Запись данных в .json файл:

if (!warns[wUser.id]) warns[wUser.id] = { // Если ID пользователя не найден, количество предупреждений устанавливается на 0     warns: 0};warns[wUser.id].warns++; // Если все проверки прошли успешно, к текущему количеству предупреждений пользователя прибавляется +1fs.writeFile("./data.json", JSON.stringify(warns), (err) => { // Все данные сохраняются в .json файле        if (err) console.log(err)});

Перейдём к самой большой и заключительной части кода она отвечает за кик участника сервера после обнаружения трёх предупреждений, за обнуление счётчика предупреждений и за отправку embed'ов.

if (warns[wUser.id].warns >= 3) { // Если обнаружено 3+ предупреждений, то...    wUser.kick("3/3 предупреждений") // Кикнуть участника сервера по причине "3/3 предупреждений"    if (warns[wUser.id].warns >= 3) warns[wUser.id] = { // Если обнаружено 3+ предупреждений, их количество устанавливается на 0            warns: 0    };    fs.writeFile("./data.json", JSON.stringify(warns), (err) => { // Всё сохраняется в .json файл          if (err) console.log(err)    });    var warn_embed1 = new Discord.MessageEmbed() // Embed, отправляющийся при третьем предупреждении         .setColor('#db0f0f')         .addFields(                { name: 'Пользователь', value: `<@${mess.author.id}>` },                { name: 'Выдал предупреждение', value: wUser },                { name: 'Количество предупреждений', value: '3/3 **[Кик]**' }    );    mess.channel.send(warn_embed1)    } else { // Иначе...    var warn_embed2 = new Discord.MessageEmbed() // Embed, отправляющийся при 1 и 2 предупреждении         .setColor('#db0f0f')         .addFields(                { name: 'Пользователь', value: `<@${mess.author.id}>` },                { name: 'Выдал предупреждение', value: wUser },                { name: 'Количество предупреждений', value: `${warns[wUser.id].warns}/3` }    );    mess.channel.send(warn_embed2)    if (warns[wUser.id].warns >= 3) warns[wUser.id] = { // Если обнаружено 3+ предупреждений, их количество устанавливается на 0          warns: 0    };    fs.writeFile("./data.json", JSON.stringify(warns), (err) => { // Всё сохраняется в .json файле        if (err) console.log(err)    });   }}

Результат:



После выдачи предупреждения, в файле data.json должна появиться подобная строка:

{"123456789012345678":{"warns":1}}

Это и есть ID предупреждённого пользователя и количество предупреждений.
После достижения трёх предупреждений, их количество устанавливается на 0.

Перейдём к команде для бана, она значительно проще в написании и компактнее в объёме, т.к нам не понадобится .json файл и запись в него данных.

Для начала, добавим уже знакомые нам строки, но немного видоизменённые:

if (!mess.member.hasPermission("BAN_MEMBERS")) return mess.reply("У вас нет прав для использования данной команды");let bUser = mess.guild.member(mess.mentions.users.first()) || mess.guild.members.cache.get(args[0])if(bUser.id === mess.author.id) return message.channel.send("Вы не можете забанить самого себя!"); if (!mess.guild.me.hasPermission("BAN_MEMBERS")) return mess.reply("У меня нет прав!")

Переназначим переменную args для последующего добавления причины бана:

args = mess.content.split(' '); // Занесли в массив всё содержание сообщенияargs.splice(0, 2); // Начиная с позиции 0, удалили 2 элемента из массиваargs = args.join(' '); // Объединили все элементы в массиве

Добавим пару проверок и функцию бана:

if (!bUser) return mess.reply("Вы не указали пользователя!") if (!args) return mess.reply("Вы не указали причину!")bUser.ban()

И наконец, напишем заключительный embed:

var ban_embed = new Discord.MessageEmbed()     .setColor('#db0f0f')     .addFields(         { name: 'Пользователь', value: `<@${mess.author.id}>` },         { name: 'Забанил', value: bUser },         { name: 'По причине', value: args }     );mess.channel.send(ban_embed)

Результат:



На команде для кика мы заострять внимание не будем, так как она является точной копией команды для бана.
Код
if (!mess.member.hasPermission("KICK_MEMBERS")) return mess.reply("У вас нет прав для использования данной команды");if (!mess.guild.me.hasPermission("KICK_MEMBERS")) return mess.reply("У меня нет прав!")let kUser = mess.guild.member(mess.mentions.users.first()) || mess.guild.members.cache.get(args[0])if(kUser.id === mess.author.id) return message.channel.send("Вы не можете кикнуть самого себя!"); args = mess.content.split(' ');args.splice(0, 2);args = args.join(' ');if (!kUser) return mess.reply("Вы не указали пользователя!")if (!args) return mess.reply("Вы не указали причину!")kUser.kick(args)var kick_embed = new Discord.MessageEmbed()     .setColor('#db0f0f')     .addFields(         { name: 'Пользователь', value: `<@${mess.author.id}>` },         { name: 'Кикнул', value: kUser },         { name: 'По причине', value: args }     );mess.channel.send(kick_embed)


Следующая по счёту команда для заглушения пользователя.

Код для данной команды почти ничем не отличается от предыдущих, добавляется лишь несколько строк.

Начнём с объявления привычной нам уже переменной, переназначения переменной args:

let mUser = mess.guild.member(mess.mentions.users.first()) || mess.guild.members.cache.get(args[0])args = mess.content.split(' ');args.splice(0, 2);args = args.join(' ');

Добавим обнаружение роли Muted, несколько проверок, выдача роли Muted пользователю:

let muterole = mess.guild.roles.cache.find(role => role.name === "Muted");if (!mUser) return mess.reply("Вы не указали пользователя!")if (!args) return mess.reply("Вы не указали причину!")if (!muterole) return mess.reply("На этом сервере нету роли **Muted**, создайте её!");mUser.roles.add(muterole)

Два embed'а и их отправка:

var mute_embed = new Discord.MessageEmbed()    .setColor('#db0f0f')    .addFields(        { name: 'Пользователь', value: `<@${mess.author.id}>` },        { name: 'Выдал мут', value: mUser },        { name: 'По причине', value: args });var dm_mute_embed = new Discord.MessageEmbed()    .setColor('#db0f0f')    .addFields(        { name: 'Пользователь', value: `<@${mess.author.id}>` },        { name: 'Выдал мут', value: `Вам, ${mUser}` },        { name: 'На сервере', value: mUser.guild },        { name: 'По причине', value: args });mUser.send(dm_mute_embed) // Embed отправляется пользователю, которому выдан мут в ЛСmess.channel.send(mute_embed) // Embed отправляется в чат

Заключение


Из этой статьи мы узнали:

  • Как работать с конкретным аргументом
  • Как работать с множеством аргументов
  • Как разделять аргументы
  • Как работать с упоминаниями

А также написали несколько команд для модерации.
Следующая часть будет посвящена embed'ам и работе с ними.

Спасибо за прочтение!

Сайты для самостоятельного изучения



Предыдущие статьи


Подробнее..
Категории: Javascript , Node.js , Discord , Bot , Боты

Botsman новая платформа для разработки Telegram-ботов

30.12.2020 12:20:04 | Автор: admin

Сегодня, в предпоследний день уходящего года, хочу рассказать о созданном мной сервисе, помогающем быстро проектировать, отлаживать и следить за работоспособностью ботов в мессенджере Телеграм. Надеюсь, он окажется удобным инструментом. Под катом довольно подробный рассказ о том, как этот сервис зародился, какие технологии я для него выбрал и обзор того, что он сейчас умеет.

Для тех же, кому уже захотелось ознакомиться с Botsman (но не очень хочется много читать) вот ссылка, милости прошу: https://bots.mn/. Главное, о чём стоит помнить платформа только-только запустилась, и (пока что) не стоит переносить на неё что-то серьёзное и масштабное.

Предыстория: путь к созданию Ботсмана

Пять лет назад в Telegram появилась возможность создавать ботов автоматизированные аккаунты, которыми можно управлять с помощью своих скриптов. Меня это сразу заинтересовало: я люблю делать маленькие, но полезные утилитки.

Правда, в отличие от десктопных приложений, браузерных расширений и простых веб-сервисов, с ботами есть некоторая сложность в их разработке: в отличие от традиционных веб-сервисов, их труднее отлаживать. Даже если настроить логирование, бывает трудно понять, что именно происходит с ботом и всё ли работает так, как нужно.

Поэтому почти сразу пришла в голову идея сделать такую платформу, которая возьмёт весь мониторинг на себя: будет логировать и входящие обновления, и то, как бот на них реагирует (и какие возникают при этом ошибки). Заодно можно и графики удобные строить.

Но, к сожалению, дальше придумывания красивого названия Botsman и покупки короткого домена bots.mn дело поначалу не зашло. Проект оказался непривычно большим для того, чтобы осилить его в одиночку, и за пять лет я то брался за него, то снова откладывал на потом.

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

Итак, что же сейчас предлагает данная платформа?

Проксирование запросов

Во-первых, мой сервис умеет работать как прокси: вы подключаете бота к Botsman, он ловит все приходящие от Telegram события, логирует, и передаёт вашему серверу. Это удобно, если у вас уже написан и где-то крутится бот: ваш код может даже не подозревать про эту прослойку.

Выбор способа обработки запросов ботомВыбор способа обработки запросов ботом

У этой фичи есть очевидный недостаток: небольшое увеличение времени отклика (поскольку в цепочке Telegram ваш сервер появляется дополнительное звено).

Но этой осенью Telegram сделал крутую вещь: они выложили в открытый доступ код сервера-посредника Bot API. По своей сути это такое приложение, которое внутри общается с Телеграмом как клиент по их протоколу MTProto, а снаружи у него торчит уже простое и понятное Bot API. И когда вы обращаетесь к публичному Bot API по HTTPS запрос на самом деле идёт к инстансу такого сервера, а теперь стало можно поднять его самому. И конечно же, внутри Ботсмана я так и сделал (и это новшество оказалось ещё одним мотиватором закончить проект).

Таким образом, вашего бота можно настроить так, что цепочка не станет длиннее: вместо
Telegram сервер Bot API ваш сервер будет
Telegram Botsman ваш сервер.

Правда, тут уже потребуются правки в коде вашего бота: исходящие запросы придётся делать не на api.telegram.org, а на api.bots.mn/telegram. Зато Botsman сможет логировать и их тоже!

Собственно, поговорим о логировании:

Живая лента обновлений

После настройки бота в Botsman, можно сразу открыть страницу Events, отправить что-то в Телеграме своему боту, и увидеть, как это сообщение появилось на экране в реальном времени. Если у вас настроен прокси вы увидите и результат перенаправления запроса вашему серверу. Если ваш сервер шлёт запросы через проксирующий эндпоинт api.bots.mn/telegram они тоже туда попадут.

Так в интерфейсе Botsman выглядит лог всех происходящих с ботом событийТак в интерфейсе Botsman выглядит лог всех происходящих с ботом событий

С технической стороны, конечно, тут всё просто: я использую веб-сокеты чтобы проталкивать все обновления на клиент, если у вас открыта соответствующая вкладка (если открытых страниц нет, лишней нагрузки это создавать не должно). Возможно, если бы я начинал этот проект сейчас, я бы попробовал использовать Server-sent events вместо веб-сокетов, но вряд ли есть какой-то повод переходить на них.

Кроме бесконечного лога, куда все события сыпятся вперемешку, Botsman ещё умеет показывать интерфейс, похожий на обычный список диалогов в самом мессенджере то, как их видит ваш бот. При желании можно даже никак не обрабатывать запросы, а просто вручную открывать диалоги и отвечать от имени бота (этакий режим техподдержки возможно, впоследствии я добавлю поддержку управления ботами с нескольких аккаунтов с разными ролями, если это действительно будет актуально).

Слежение за показателями бота

Ну и конечно же, статистика и графики, куда без них. Честно говоря, аналитик из меня так себе, поэтому сейчас Botsman показывает только довольно базовые метрики общее число апдейтов, число чатов, число пользователей, дневную и месячную аудиторию (DAU и MAU). Графики по числу апдейтов на каждый день/час, и по среднему времени обработки запросов. Было бы, конечно, интересно смотреть на всякую демографию, но в Telegram в этом плане мало информации о пользователях.

Графики в разделе Stats. Как видно, через одного из моих ботов уже прошло почти 20 млн апдейтов.Графики в разделе Stats. Как видно, через одного из моих ботов уже прошло почти 20 млн апдейтов.

Для подсчёта статистики я с самого начала выбрал старый добрый Redis, а вот на клиентской стороне решил рисовать графики с помощью библиотеки, которую до этого сам же написал на конкурс того же Телеграма. Не пропадать же добру :)

Скриптинг

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

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

Поэтому я выбрал JavaScript: моя изначальная идея была взять встроенную в Node песочницу, немного доработать (как это сделано в библиотеках Sandcastle или vm2), чтобы сделать её безопаснее, и выполнять код ботов в ней.

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

В итоге я обратил внимание на библиотеку isolated-vm: она тоже реализует песочницу в JS, но делает это другим, более безопасным (и, что важно, многопоточным) образом. По сути это обёртка над присутствующим в V8 механизмом изолятов независимых контекстов, которые ничего не знают друг про друга. Эта же библиотека, кстати, используется в игре Screeps, где игрокам тоже нужно писать своих ботов.

Что особенно хорошо для ботов: состояние одного такого изолята можно держать в памяти как кусок данных (не расходуя процессорное время), и только когда боту понадобится обработать прилетевший апдейт от Телеграма оживлять это состояние, вызывая в нём функцию-обработчик. И конечно можно ограничить как потребляемую память, так и процессорное время (с этим, помнится, в нодовской песочнице были некоторые сложности).

Скриптинг: внутреннее API, обработчики событий

Дальше одним из камней преткновения было дать удобное внутреннее API для написания ботов. Конечно, можно было сказать вашему коду будет доступна переменная update и метод callMethod, а дальше делайте, что хотите. Но раз уж я проектирую всю песочницу, нужно идти до конца.

В частности, мне хотелось реализовать механизм состояний: часто боты рассчитаны на пошаговый диалог, и это надо учесть, как-то запоминать контекст, даже если пользователь бросил диалог и вернулся к нему на следующий день. В итоге я решил, что для каждого чата, пользователя или сообщения я позволю хранить объект с произвольными полями. А у чатов (как личных, так и групповых) в придачу есть понятие пути, route оно позволяет раскидать обработчики событий по тому, на каком шаге находится пользователь.

Само добавление обработчиков делается довольно просто:

on(ctx => {  ctx.log('Some update received: ', update);});

Это самый универсальный обработчик: он вызывается при любом событии (напомню, кроме события пришло сообщение Telegram присылает и ряд других оповещений).

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

on('message', ctx => {  ctx.message.reply('Hi!');});

А что будет, если объявить два обработчика и они оба подходят для текущего апдейта? Botsman вызовет только первый из них но можно передать управление следующему, если вернуть false (ну или промис, резолвящийся в false разумеется, всё делалось с расчётом на асинхронный код).

Ещё есть удобные способы обработывать только текстовые сообщения с помощью on.text (их можно заодно ещё и фильтровать по регэкспу), только команды с помощью on.command, инлайн-запросы on.inline, и коллбэк-запросы (нажатия на кнопки под отправленными сообщениями) on.callback. О них можно почитать в документации.

В примерах выше видно, что обработчику передаётся единственный параметр экземпляр класса EventContext, и через него делаются все вызовы. Это позволяет Ботсману связывать, например, вызовы API или возникающие ошибки с конкретным апдейтом, который к ним привёл.

Ну а как разграничить обработчики для разных состояний (путей) чата? Для этого предназначена глобальная функция state:

state('step1', (on, state) => {  // Этот обработчик вызовется для любого сообщения,  // если наш чат в состоянии 'step1' - и переведёт его  // в состояние 'step2'  on.text(ctx => {    ctx.route.to('step2');   });});state('step2', (on, state) => {  // А этот обработчик вызывается, если наш чат в  // состоянии 'step2' и возвращает его в 'step1'  on.text(ctx => {    ctx.route.to('step1');   });});

Обратите внимание: функция state немедленно вызывает переданный ей коллбэк с двумя аргументами, которые заменяют собой глобальные функции on и state. Добавленный с помощью локальной функции on обработчик будет вызываться только в указанном состоянии, а с помощью локальной функции state можно создавать вложенные состояния (хотя их можно создать и вручную, просто записывая путь, разделённый слэшами: 'step1/substep1/branchA'). Пока что, впрочем, иерархическая структура состояний особых преимуществ по сравнению с линейной не имеет (но может помочь их логически упорядочить).

Скриптинг: форматируем сообщения с помощью tagged template literals

Отдельно поделюсь одной, казалось бы, незначительной, но весьма радующей лично меня деталью. Если вы уже пробовали использовать Telegram API, то возможно сталкивались со сложностями при отправке текста с форматированием особенно когда в него нужно подставить пользовательские данные. Telegram умеет принимать и HTML, и Markdown, но и в том, и в другом случае подставляемые данные нужно обрабатывать, эскейпить управляющие символы, что не очень удобно.

К счастью, не так давно при отправке сообщения (и в других методах, где можно отправлять форматированный текст) появилась возможность просто указать отдельно, какие участки в нём нужно отформатировать. Добавляем к этому tagged templates из ES6 и получаем вот что:

await ctx.call('sendMessage', {  chat_id: 12345,  ...fmt`Hello ${fmt.bold(foo)}! You can combine ${fmt.italic(bar).bold()} styles together.Links are supported ${fmt.text_link(linkLabel, linkUrl)}.`,});

Выглядит немного непривычно, зато а) не нужно ничего эскейпить, б) невозможно сломать вёрстку, потеряв какой-нибудь HTML-тэг или символ разметки Markdown. Если у вас валидный JS будет и валидная вёрстка. Под капотом запись fmt`something` возвращает объект с двумя полями text и entities поэтому его нужно распаковывать с помощью ... (spread syntax). Ну или его можно передать напрямую в короткие методы типа ctx.message.reply(fmt`something`) или ctx.chat.say(fmt`something`).

Мне кажется, что у tagged template literals вообще не очень много уместных применений в реальном мире, но тут у меня получилось найти одно из них :)

Скриптинг: код по расписанию и запросы к внешним API

Должен сделать важную оговорку: так как код выполняется в изолированных контекстах, у скриптов нет ни доступа к API самой Node, ни возможности импортировать внешние модули. Однако я реализовал метод fetch (по аналогии с одноимённым браузерным API) он позволяет делать не слишком тяжёлые запросы к внешним серверам. Кроме того, доступна глобальная функция cron с помощью неё можно запланировать регулярное выполнение повторяющихся действий:

cron('0 0 * * FRI', ctx => {  ctx.log('This function should execute each Friday at midnight');});

Первый параметр тут либо привычный формат crontab, либо интервал в секундах (правда, и то, и другое с разрешением не чаще раза в пять минут). Я думаю, что возможности выполнять код по расписанию и делать запросы к внешним API позволят реализовать множество практических задач. И не исключено, что в будущем список возможностей (например, импорт каких-то проверенных модулей) будет расширяться.

Скриптинг: веб-интерфейс

Писать код прямо в браузере не самое комфортное занятие, но я стремлюсь сделать его хотя бы сравнимым с разработкой в IDE. Сейчас в качестве редактора я использую CodeMirror (в первую очередь из-за его относительно небольшого веса), в дальнейшем, вероятно, добавлю возможность переключиться на Monaco он ощутимо тяжелее, зато должен быть шустрее и функциональнее (именно он используется внутри VS Code).

Так сейчас выглядит редактор кода в BotsmanТак сейчас выглядит редактор кода в Botsman

Пока что в Botsman нет разбивки кода на отдельные файлы в этом тоже может быть неудобство, если вы собираетесь писать сложного бота. Зато код сохраняется автоматически, и есть возможность хранить все его предыдущие версии это ещё не Git, но всё же.

И ещё одна небольшая, но довольно ценная возможность: проверка кода перед деплоем с помощью тестов. При этом Botsman по нажатию одной кнопки сразу покажет подробный лог выполнения (см. скриншот выше): время инициализации, обработки запроса, отладочный вывод и возникшие ошибки (код с синтаксическими ошибками задеплоить не выйдет в принципе).

Песочница для запросов к Telegram

Ну и последняя небольшая, но довольно полезная фича Botsman, о которой хотелось бы рассказать панель для формирования запросов к Telegram. Нередко социальные сети предоставляют собственный инструмент для этого, но у Телеграма его, к сожалению, нет. Поэтому я сделал страницу, где можно выбрать нужный метод API, заполнить его параметры и наглядно увидеть результат вызова. Заодно можно сразу скопировать эквивалентный код, делающий этот запрос.

Панель вызова методов Telegram APIПанель вызова методов Telegram API

Будущие планы

Как было сказано ранее, Botsman находится в самом начале своего пути. Возможно, его даже настигнет Хабраэффект (надеюсь, что нет!). Возможно, ему станет тяжко, если созданные на нём боты наберут популярность не исключено, что тогда придётся вводить платные возможности. Поскольку занимаюсь им я сейчас в одиночку, сложно сказать, что с ним будет.

В очень примерных планах сейчас такие фичи:

  • Визуальный конструктор в дополнение к скриптингу

  • Глобальное key-value хранилище + создание собственных хранилищ

  • Поддержка других платформ, кроме Telegram

  • Доступный снаружи эндпоинт для вызова кода бота

  • Управление ботом с нескольких аккаунтов

  • Навигация по коду, разбивка на модули, поддержка сторонних модулей

  • Более гибкое тестирование кода, автоматические тесты

  • Больше статистики и графиков

  • Оповещения (если с ботом что-то не так)

  • Улучшение вида чатов

  • Улучшение работы с файлами (скачивание, загрузка), в том числе в песочнице

В любом случае, надеюсь, что из этого сервиса выйдет что-то хорошее! Буду рад услышать ваши мысли, пожелания и соображения в комментариях.

Подробнее..

Распознавание команд

03.06.2021 20:19:56 | Автор: admin

При разработке ботов для Telegram и других месенджеров, периодически возникает задача распознавания и выполнения запросов, высказанных человеческим языком. Именно эта "фишка", по некоторому мнению, и является главным отличием ботов от приложений командной строки. Под катом описан собственный фреймворк для исполнения произвольных речевых команд. Описания ключевых концепций сопровождены примерами на языке Kotlin.

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

/** Правило проверяет лексему на соответствие */typealias Rule = (String) -> Boolean/** Нормализованное семантическое представление */open class Semnorm(vararg val rules: Rule)/** Правило задает стемы для семантических представлений */fun stem(vararg stems: String): Rule = { stems.any(it::startsWith) }/** Правило задает точные соответствия для семантических представлений */fun word(vararg words: String): Rule = { words.any(it::equals) }/** Проверяем слово на соответствие семантике */fun String.matches(norm: Semnorm) = norm.rules.any { it(this) }

Теперь у нас появилась возможность задавать предопределенные нормализованные семантические представления в виде объектов:

object Day : Semnorm(stem("day", "суток", "сутк", "дня", "ден", "дне"))

Фреймворк ставит их в соответствие лексемам входящих фраз, и предложение начинает выглядеть, например так:

assertThat(  "забань васю на 5 минут".tokenize(),   equalTo(   listOf(     Token("забань", Ban),      Token("васю", null),     Token("на", null),      Token("5", Number),     Token("минут", Minute)   )  ))

С распознаванием речи мы разобрались. Код токенизатора приложен в репозитории, доступном в конце статьи. Перейдем к исполнению команд из речи. А вот здесь и начинается самое интересное: фреймворк позволяет для каждого семантического представления навесить заданное поведение. Снова простейший пример, как распознать запрос справки на двух языках:

object Help : ExecutableSemnorm(stem(  "помощ", "справк", "правил", "help",   "rule", "faq", "start", "старт",)) {  override fun execute(bot: Botm: Message) {    val faq = message.from.relatedFaq()    bot.sendMessage(m.chat.id, faq)  }}

Что насчет более сложного поведения, зависящего от различных слов в предложении? Оно тоже поддерживается, вот как, например, исполняется, уже известное из тестов предложение забанить Васю:

object Ban : DurableSemonrm(stem(  "ban", "block", "mute", "бан", "блок",  "забан", "завали", "замьют",)) {  override fun execute(    bot: Bot, attackerMessage: Message, duration: Duration) {    val victimMessage = attackerMessage.replyToMessage    val victimId = victimMessage.from.id    val untilSecond = now().epochSecond + duration.inWholeSeconds    bot.restrictChatMember(      attackerMessage.chat.id, victimId, untilSecond)  }}

Откуда это семантическое представление знает о своей продолжительности? Дело в том, что ему вовсе не обязательно парсить всю цепочку токенов целиком. Достаточно задать только минимально необходимое поведение для каждого представления, например для времени:

object Week : Semnorm(stem("week", "недел")) {  override fun toDuration(number: Long) =     days(number) * 7}

Или для любых команд, зависящих от времени:

class DurableSemnorm(vararg rules: Rule) : ExecutableSemnorm(*rules) {  final override fun execute(    token: Iterator<Token>, bot: Bot, m: Message) =       execute(bot, message, token.parseDuration())  abstract fun execute(bot: Bot, m: Message, duration: Duration)}

Благодаря такой архитектуре, нам больше не приходится думать о запутанной логике работы интерпретатора. Достаточно просто определить желаемые атрибуты для семантических представлений и наслаждаться результатом. Пример бота, использующего эту концепцию, можно посмотреть на Github: https://github.com/demidko/timecobot

Подробнее..

Пошаговый туториал по написанию Telegram бота на Ruby (native)

30.12.2020 02:06:40 | Автор: admin

Приветики-омлетики, как-то недавно у меня появилась идея написать Telegram бота наRubyна специфическую тематику, в двух словах этот бот должен был поднимать онлайн чатах по средством развлекательных событий которые этим же ботом вбрасывались в чат в рандомное время с рандомным контекстом.

И вот пока я занимался написанием этого бота то познакомился с библиотекой (gem) telegram-bot-ruby, научился её использовать вместе с gem 'sqlite3-ruby и кроме того проникся многими возможностями Telegram ботов чем и хочу поделится с уважаемыми читателями этого форума, внести вклад так сказать.

Много людей хочет писать Telegram боты, ведь это весело и просто.

На момент же написания своего бота я столкнулся с тем что сложно было найти хороший материал на тему Ruby бота для Telegram. Под хорошим я подразумеваю такой, где рассказывается про функционал изящные и красивый, такой, какой он есть в Telegram API.

Сразу кидаю ссылку на свой репозиторий по этому посту:here,
Ибо во время тестирования были баги, которые я мог сюда и не перенести, вдруг чего смотреть прямо в репозиторий.

В следствии прочтения этого топика, я надеюсь читатель сможет улучшить своего уже написаного бота, или прямо сейчас скачать Ruby, Telegram и создать что-то новое и прекрасное. Ведь как уже было сказано в Декларации Киберпространства:

В нашем же мире все, что способен создать человеческий ум, может репродуцироваться и распространяться до бесконечности безо всякой платы. Для глобальной передачи мысли ваши заводы больше не требуются.

  • Предлагаю начать :

    У меня версия Ruby - 2.7.2, но не исключено что всё будет работать и с более ранними/поздними версиями.

  • Примерная структура приложения будет выглядеть вот так

  • Первым делом создадим Gemfile - основной держатель зависимостей для сторонних gems в Ruby.

  • ФайлGemfile:

    source 'https://rubygems.org'gem 'json'gem 'net-http-persistent', '~> 2.9'gem 'sqlite3'#gem для БДgem 'telegram-bot-ruby'#основной гем для создания соеденения с Telegram ботом
    

    Сохраняем файл и выполняем в терминале операцию

    bundle install
    

    Увидим успешную установку всех гемов (ну это же прелесть Ruby) и на этом сGemfileбудет покончено.

  • Если вы (как и я) лабораторная крыса GitHuba, то создаем .gitignoreдля нашего репозитория, у меня прописан классический для продуктов JetBrains файл:

  • Файл .gitignore:

    /.idea/
    
  • Далее создадим первый класс в корне проекта, называем как хотим этот класс будет выступать в роли инициализатора, в моем случае этоFishSocket:

  • файлFishSocket.rb:

    require 'telegram/bot'require './library/mac-shake'require './library/database'require './modules/listener'require './modules/security'require './modules/standart_messages'require './modules/response'Entry point classclass FishSocket  include Database  def initialize    super    # Initialize BD    Database.setup    # Establishing webhook via @gem telegram/bot, using API-KEY    Telegram::Bot::Client.run(TelegramOrientedInfo::APIKEY) do |bot|      # Start time variable, for exclude message what was sends before bot starts      startbottime = Time.now.toi      # Active socket listener      bot.listen do |message|        # Processing the new income message    #if that message sent after bot run.        Listener.catchnewmessage(message,bot) if Listener::Security.messageisnew(startbottime,message)      end    end  endendBot startFishSocket.new
    

    Как видим в этот файле упомянуты сразу 5 различных файлов :Gem telegram/bot,Модули mac-shake, listener, security, database.

  • Поэтому предлагаю сразу их создать и показать что к чему:

  • Файлmac-shake.rb:

    # frozenstringliteral: truemodule TelegramOrientedInfoAPIKEY = ''end
    
  • Как видим в этом файле используется API-KEY для связи с нашим ботом, предлагаю сразу его получить, для этого обратимся к боту от Telegram API :@BotFather

    API-Key который нам вернул бот, следует вставить в константу API-Key, упомянутую ранее.

  • Файлsecurity.rb:

    class FishSocket  module Listener    # Module for checks    module Security      def messageisnew(starttime, message)        messagetime = (defined? message.date) ? message.date : message.message.date        messagetime.toi > starttime      end  def message_too_far    message_date = (defined? Listener.message.date) ? Listener.message.date : Listener.message.message.date    message_delay = Time.now.to_i - message_date.to_i    # if message delay less then 5 min then processing message, else ignore    message_delay > (5 * 60)  end  module_function :message_is_new, :message_too_farendendend
    

    В этом файле происходит две проверки : на то, что бы сообщение было отпарвлено после старта бота (не обрабатывать команды которые были отпраленны в прошлой сессии). И вторая проверка, что бы не обрабатывать сообщение которым больше 5 минут (вдруг вы добавите очередь, и таким образом мы ограничиваем её длину)

  • Файлlistener.rb:

    class FishSocket  # Sorting new message module  module Listener    attr_accessor :message, :botdef catch_new_message(message,bot)  self.message = message  self.bot = bot  return false if Security.message_too_far  case self.message  when Telegram::Bot::Types::CallbackQuery    CallbackMessages.process  when Telegram::Bot::Types::Message    StandartMessages.process  endendmodule_function(  :catch_new_message,  :message,  :message=,  :bot,  :bot=)endend
    

    В этом файле мы делим сообщения на две группы, являются ли они ответом на callback функцию, или они обычные.Сейчас проясню что такое callback сообщение в телеграме.Telegram API версии 2.0 предоставляет достаточно обширную поддержку InlineMessages. Это такие сообщение, которые в себе содержает UI элементы взаемодействия с пользователем, я в своем боте использоватInlineKeyboardMarkupэто кнопки, после нажатия на которые сообщение которые прийдет на бота, будет типаCallbackMessage, и текст сообщение будет равен тому, который мы указали в атрибут кнопки, при отправке запроса на Telegram API. Позже мы ешё вернёмся к этому принципу.

  • ФайлDatabase.rb

    # This module assigned to all database operationsmodule Database  attr_accessor :dbrequire 'sqlite3'  # This module assigned to create table action  module Create    def steamaccountlist      Database.db.execute <<-SQL    CREATE TABLE steamaccountlist (    accesses VARCHAR (128),    used INTEGER (1))      SQL      true    rescue SQLite3::SQLException      false    end    modulefunction(        :steamaccount_list    )  enddef setup    # Initializing database file    self.db = SQLite3::Database.open 'autosteam.db'    # Try to get custom table, if table not exists - create this one    unless gettable('steamaccountlist')      Create.steamaccount_list    end  end# Get all from the selected table  # @var tablename  def gettable(tablename)    db.execute <<-SQL    Select * from #{tablename}    SQL  rescue SQLite3::SQLException    false  endmodulefunction(    :gettable,    :setup,    :db,    :db=  )end
    

    В этом файле просто происходит инициализация бд и проверка/создание таблиц которые мы хотим использовать.

  • Можем попытатся запустить нашего бота, посредством выполнения файлаfishsocket.rbЕсли мы всё сделали правильно, то не должны увидеть никакого сообщения о завершеной работе, так как происходит Active Socket прослушывания ответа от Telegram API.Мы по-сути реестрируем наш локальный сервер прикрепляя его к Webhook от Telegram API, на который будут приходить сообщения о любых изменениях.

  • Попробуем добавить примитивный ответ на какое-то сообщение в боте

    Создадим файлstandartmessages.rb, модуль который будет обрабатывать Стандартные (текстовые) сообщение нашего бота. Как помним сообщение бывают двух типов : Standart и Callback.

    Файлstandartmessages.rb:

    class FishSocket  module Listener    # This module assigned to processing all standart messages    module StandartMessages      def process        case Listener.message.text        when '/getaccount'          Response.stdmessage 'Very sorry, нету аккаунтов на данный момент'        else          Response.stdmessage 'Первый раз такое слышу, попробуй другой текст'        end      end  module_function(      :process  )endendend
    

    В этом примере мы обрабатываем примитивный запрос /getaccount, и возвращаем ответ что на данный момент аккаунтов нету, ведь их дейстительно ещё нету.

  • Ах да, ответ мы отправляем с помощью модуляResponse, который прямо сейчас и создадим

    Файлresponse.rb

    class FishSocket  module Listener    # This module assigned to responses from bot    module Response      def stdmessage(message, chatid = false )        chat = (defined?Listener.message.chat.id) ? Listener.message.chat.id : Listener.message.message.chat.id        chat = chatid if chatid        Listener.bot.api.sendmessage(          parsemode: 'html',          chatid: chat,          text: message        )      end  module_function(    :std_message  )endendend
    

    В этом файле мы обращаемся к API Telegrama согласно документации, но уже используя gem telegram-ruby, а именно его функциюapi.sendmessage. Все атрибуты можно посмотреть в Telegram API и поигратся с ними, скажу только лишь что этот метод может отправлять только обычные сообщения.

  • Запускаем бота и тестируем две команды :(Бота можно найти по ссылке которую вам вернул BotFather, вместе с API ключем.

    Привет
    
    /getaccount
    

    Как видим всё отработала так как мы и хотели.

  • Предлагаю увеличить обороты и сразу создать Inline кнопку, добавить реакцию на неё, добавить метод для отправки сообщения с Inline кнопкой.

  • Создадим подпапку assets/ в ней модуль inlinebutton.Файлinlinebutton.rb:

    class FishSocket  # This module assigned to creating InlineKeyboardButton  module InlineButton    GETACCOUNT = Telegram::Bot::Types::InlineKeyboardButton.new(text: 'Получить account', callbackdata: 'getaccount')  endend
    

    Сдесь мы обращаемся всё к тому жеtelegram-ruby-gemчто бы создать обьект типа InlineKeyboardButton.

  • Разширим наш файлReponseновыми методоми :

    def inlinemessage(message, inlinemarkup,editless = false, chatid = false)  chat = (defined?Listener.message.chat.id) ? Listener.message.chat.id : Listener.message.message.chat.id  chat = chatid if chatid  Listener.bot.api.sendmessage(    chatid: chat,    parsemode: 'html',    text: message,    replymarkup: inlinemarkup)enddef generateinlinemarkup(kb, force = false)  Telegram::Bot::Types::InlineKeyboardMarkup.new(    inlinekeyboard: kb  )end
    

    Не стоит забывать выносить новые методы в modulefunction() :

    modulefunction(  :stdmessage,  :generateinlinemarkup,  :inlinemessage)
    
  • Добавим на действия

    /start
    

    , вывод нашей кнопки, для этого разширим сначала модульStandartMessages

    def process  case Listener.message.text  when '/getaccount'    Response.stdmessage 'Very sorry, нету аккаунтов на данный момент'  when '/start'    Response.inlinemessage 'Привет, выбери из доступных действий', Response::generateinlinemarkup(        InlineButton::GETACCOUNT    )  else    Response.stdmessage 'Первый раз такое слышу, попробуй другой текст'  endend
    
  • Создадим файлcallbackmessages.rbдля обработки Callback сообщений :Файлcallbackmessages.rb

    class FishSocket  module Listener    # This module assigned to processing all callback messages    module CallbackMessages      attraccessor :callback_message  def process    self.callback_message = Listener.message.message    case Listener.message.data    when 'get_account'      Listener::Response.std_message('Нету аккаунтов на данный момент')    end  end  module_function(      :process,      :callback_message,      :callback_message=  )endendend
    

    По своей сути роботы отличия от StandartMessages обработчика только в том, что Telegram возвращает разную структуру сообщений для этих двух типов сообщений, и что бы не создаватьспагетти-кодвыносим разную логику в разные файлы.

  • Не забываем обновить список подключаемых модулей, новыми модулями.Файлfishsocket.rb

    require 'telegram/bot'require './library/mac-shake'require './library/database'require './modules/listener'require './modules/security'require './modules/standartmessages'require './modules/response'require './modules/callbackmessages'require './modules/assets/inlinebutton'Entry point classclass FishSocket  include Database  def initialize    super
    
  • Пытаемся запустить бота и посмотреть что будет когда напишем

    /start
    

    Нажимая на кнопку мы видим то - что хотели увидеть.

  • Я бы ещё очень много чем хотел поделится, но тогда это будет бесконечная статья по своей сути - мы же рассмотрим ещё буквально 2 примера на создание ForceReply кнопки, и на использование EditInlineMessage функции


  • ForceReply, создадим соответствующий метод в нашемResponseмодуле

    def forcereplymessage(text, chatid = false)  chat = (defined?Listener.message.chat.id) ? Listener.message.chat.id : Listener.message.message.chat.id  chat = chatid if chatid  Listener.bot.api.sendmessage(    parsemode: 'html',    chatid: chat,    text: text,    replymarkup: Telegram::Bot::Types::ForceReply.new(      forcereply: true,      selective: true    )  )end
    

    Не нужно забывать обновлять modulefunction нашего модуля после изминения кол-ва методов.

    Попробуем сделать банальную реакцию на ввод промокода (хз зачем, для примера)

  • Добавим новую кнопку :

    module InlineButton  GETACCOUNT = Telegram::Bot::Types::InlineKeyboardButton.new(text: 'Получить account', callbackdata: 'getaccount')  HAVEPROMO = Telegram::Bot::Types::InlineKeyboardButton.new(text: 'Есть промокод?', callbackdata: 'forcepromo')end
    
  • Добавить её в вывод по команде

    /start
    

    МодульStandartMessages

    when '/start'  Response.inlinemessage 'Привет, выбери из доступных действий', Response::generateinlinemarkup(    [        InlineButton::GETACCOUNT,        InlineButton::HAVEPROMO    ]  )
    

    Поскольку теперь используется больше одной кнопки, их стоит поместить в массив.

  • Добавим реакцию на нажатие на кнопку, с использованиемForceReply:МодульCallbackMessages

    def process  self.callbackmessage = Listener.message.message  case Listener.message.data  when 'getaccount'    Listener::Response.stdmessage('Нету аккаунтов на данный момент')  when 'forcepromo'    Listener::Response.forcereplymessage('Отправьте промокод')  endend
    
  • Проверим то что мы написали,

    На сообщение от бота сработал ForceReply, что это значит : сообщение выбрано как сообщение для ответа (Reply) так, как если бы мы сами выбрали ответим на сообщение. Оченьюзефулесли речь о пошаговых операциях где нам нужно наверняка знать что именно хочет сказатьюзер.

  • Добавим реакцию на ответ пользователя на сообщение "Отправьте промкод." Поскольку человек отправляет текст, то реагировать мы будем в StandartMessages :МодульStandartMessages

    def process  case Listener.message.text  when '/getaccount'    Response.stdmessage 'Very sorry, нету аккаунтов на данный момент'  when '/start'    Response.inlinemessage 'Привет, выбери из доступных действий', Response::generateinlinemarkup(      [          InlineButton::GETACCOUNT,          InlineButton::HAVEPROMO      ]    )  else    unless Listener.message.replytomessage.nil?      case Listener.message.replytomessage.text      when /Отправьте промокод/        return Listener::Response.std_message 'Промокод существует, вот бесплатный аккаунт :' if Promos::validate Listener.message.text    return Listener::Response.std_message 'Промокод не найден'  endendResponse.std_message 'Первый раз такое слышу, попробуй другой текст'endend
    
  • Создадим файлpromos.rbдля обрабоки промокодовФайлpromos.rb

    class FishSocket  module Listener    # This module assigned to processing all promo-codes    module Promos      def validate(code)        return true if code =~ /^1[a-zA-Z]*0$/        false      end  module_function(      :validate  )endendend
    

    Здесь мы используем регулярное выражение для проверки промокода.НЕ забываем подключить новый модуль в FishSocket модуле :МодульFishSocket

    require 'telegram/bot'require './library/mac-shake'require './library/database'require './modules/listener'require './modules/security'require './modules/standartmessages'require './modules/response'require './modules/callbackmessages'require './modules/assets/inline_button'require './modules/promos'Entry point classclass FishSocket  include Database  def initialize
    
  • Предлагаю протестировать с заведомо не рабочим промокодом, и правильно написаным:

    Функционал работает как и ожидалось, перейдем к последнему пункту: изминения InlineMessages:

  • Вынесем промокоды в отдельное "Меню", для этого добавим новую кнопку на ответ на сообщение

    /start
    

    заменив её кнопку "Есть промкод?"МодульInlineButton

    module InlineButton  GETACCOUNT = Telegram::Bot::Types::InlineKeyboardButton.new(text: 'Получить account', callbackdata: 'getaccount')  HAVEPROMO = Telegram::Bot::Types::InlineKeyboardButton.new(text: 'Есть промокод?', callbackdata: 'forcepromo')  ADDITIONMENU = Telegram::Bot::Types::InlineKeyboardButton.new(text: 'Ништяки', callbackdata: 'advancedmenu')end
    

    Модуль StandartMessages

    when '/start'  Response.inlinemessage 'Привет, выбери из доступных действий', Response::generateinlinemarkup(    [        InlineButton::GETACCOUNT,        InlineButton::ADDITIONMENU    ]  )
    

    Отлично

  • Теперь добавим реакцию на новую кнопку в модуль СallbackMessages:МодульCallbackMessages

    def process  self.callbackmessage = Listener.message.message  case Listener.message.data  when 'getaccount'    Listener::Response.stdmessage('Нету аккаунтов на данный момент')  when 'forcepromo'    Listener::Response.forcereplyC222Cmenu'    Listener::Response.inlineC223CinlineC224CButton::HAVEC225Cmessage
    
  • Предлагаю реализовать обработку этого атрибута в модулеResponse, немного изменив методinlinemessageМодульResponse

    def inlinemessage(message, inlinemarkup, editless = false, chatid = false)  chat = (defined?Listener.message.chat.id) ? Listener.message.chat.id : Listener.message.message.chat.id  chat = chatid if chatid  if editless    return Listener.bot.api.editmessagetext(      chatid: chat,      parsemode: 'html',      messageid: Listener.message.message.messageid,      text: message,      replymarkup: inlinemarkup    )  end  Listener.bot.api.sendmessage(    chatid: chat,    parsemode: 'html',    text: message,    replymarkup: inline_markup  )end
    

    Какова идея? - Мы заменяем уже существующее сообщение на новое, с новым интерфейсом, этот переход позволяет меньше растягивать историю сообщений, и создавать модульные сообщения - такие как меню, оплата, список участников, витрина итд.

  • Что ж, попробуем :

    После того как нажали на кнопку, сообщение измененилось, отобразив другой ReplyKeyboard.
    И если мы клацнем на неё :

    Собственно всё работает как часы.

Послесловие:Много чего тут не было затронуто, но ведь на всё есть руки и документация, лично мне, было не достаточно описания либы на GitHub. Я считаю, что в наше время стать ботоводом может любой желающий, и теперь этот желающий знает что нужно делать. Всем мир.

Подробнее..

Устройство игрового бота 16-е место в финале Russian AI Cup 2020 (и 5-е после)

19.02.2021 22:06:58 | Автор: admin

Эта статья об участии в чемпионате по написанию игрового искусственного интеллекта Russian AI Cup


Игра


Дисклеймер, пока все не разбежались


Хоть в финале я и был 16-м, статья описывает бота, удерживавшего 5-е место в общем зачете песочницы на момент её остановки.


5 место в песочнице


Я не планировал писать статью о 16-м месте, но другие участники попросили, а потому, дабы не было стыдно никому смотреть в глаза, я потратил ещё немного времени уже после завершения чемпионата на исправление тех вещей, которые не успел исправить во время чемпионата. Результат на скриншоте.


Вступление


Меня зовут Андрей Рыбалка (вдруг Вы робот и не смогли распознать текст на картинке выше), я уже восьмой год подряд участвую в Russian AI Cup. Это чемпионат для программистов по написанию игрового искусственного интеллекта. Задачей является написание бота, который будет играть в игру против ботов, написанных другими участниками.


Короче говоря,


  • Если Вы хотите подтянуть алгоритмы, но не имеете подходящих задач или достаточной мотивации
  • Если Вам всегда был интересен геймдев, но не знали, с какой стороны к нему подойти
  • Если любите задачи, где нужно больше думать, чем программировать
  • Если любите состояние, когда засыпая в постели, продолжаешь обдумывать задачу
  • Если Вам нравится писать что-то сложное, а затем наблюдать, как оно красиво всё делает само :)
  • Если у Вас закончились футболки и Вы хотите новую совершенно бесплатно
  • Если у Вас закончились деньги и Вы хотите новые совершенно бесплатно

Если хотя бы один из описанных выше пунктов про Вас, переходите на сайт, выбирайте на нём удобную Вам соцсеть, подписывайтесь и ждите анонсов.


А пока...


О задаче


Постараюсь описать покороче. Более подробное описание есть в статье GreenTea, занявшего 4-е место. Вообще, задача этого года предполагала стратегию в реальном времени (RTS) в космическом сеттинге. Но космический арт получился неудачным, юниты были практически неотличимы друг от друга визуально, поэтому все переключали визуализатор в упрощённый режим, который Вы видели на картинке в начале статьи, и забывали о космических кораблях как о страшном сне. А в упрощённом режиме на квадратиках юнитов были нарисованы меч, лук и молоток, поэтому все воспринимали игру именно в средневековом сеттинге. Так же поступлю и я в этой статье.


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


Есть юниты-рабочие, они же строители, они же добытчики, они же ремонтники, Есть юниты ближнего боя мечники. Они, по факту, в этом чемпионате практически не использовались. И есть юниты дальнего боя лучники. Именно их все строили и далее, описывая бои, я подразумеваю именно бои лучников.


Все игроки ходят одновременно. Лучник может выстрелить в цель на расстоянии до пяти клеток. Для измерения расстояний использовалась манхэттенская метрика. Лучник убивает другого лучника с двух выстрелов, причем, выстрелы просчитываются раньше, чем ходы. Таким образом, если два лучника оказываются в 5 клетках друг от друга, это ведёт к обоюдному гарантированному уничтожению в 2 хода. Если два лучника выходят против одного, он успевает сделать один выстрел и получить два в ответ, что ведёт к смерти одиночки в один ход и потере половины хит-поинтов (далее ХП) у одного из двух атакующих.


Бой 2х1


А вот в бою 2 на 2 юнита или более, исход зависит от того, на чьей стороне перевес, у кого лучше расстановка, кто правильнее переместит свои войска и оптимальнее распределит цели при стрельбе.


Очень важную роль играет экономическая составляющая. Она состоит из добычи ресурсов, строительства зданий и строительства юнитов. Полагаю, большинство читающих этот текст играли в своей жизни в хоть раз в RTS и имеют представление о стандартных базовых механиках, А потому, не буду на этом слишком детально останавливаться. Если вкратце, то:


Рабочие добывают ресурсы, на эти ресурсы мы покупаем новых рабочих, либо строим здания, либо строим армию. Зданий в игре всего 4 типа: база рабочих, база мечников, база лучников и дом. Первые три умеют производить юниты соответствующих типов. То есть, чтобы начать строить лучников, нужно в начале построить их базу. Дома нужны для того, чтобы увеличивать лимит юнитов, которых можно произвести. Каждый дом позволяет купить 5 дополнительных юнитов.


Стоимость покупки юнитов растёт на $1 за каждого уже существующего юнита этого типа. Таким образом, первый рабочий стоит $10, второй $11 и т.д. Поэтому, если строить слишком много юнитов, в какой-то момент каждый последующий получается непомерно дорогим и это тоже нужно контролировать.


Также в игре есть турели. Они строятся рабочими и обладают большим количеством ХП, что делает их эффективным орудием, так что в зону их поражения можно входить только большой толпой, но турели не могут двигаться, и потому чаще всего их можно просто обойти.


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


Этапы


Чемпионат состоит из двух раундов и финала. В каждом раунде правила несколько меняются.


  • В 1 раунде играют 4 участника. Нам видна вся карта, у нас изначально построены все три базы, так что из зданий строить нужно только дома.

Раунд 1


  • Во 2 раунде также 4 участника, изначально у каждого построена только база рабочих и в игре действует туман войны.

Раунд 2


  • Правила финала повторяют правила второго раунда, но играем 1 на 1. Выглядит примерно так:

Раунд 2


Техническая часть


Стратегия состоит из следующих основных модулей:


  1. Подготовка
  2. Экономика
  3. Строительство и ремонт
  4. Сбор ресурсов
  5. Производство юнитов
  6. Бой
  7. Перемещение по миру (поиск пути; отправка юнитов к различным целям; контроль карты)

О них и поговорим подробнее.


В поисках грааля


Я, традиционно, писал на Java. Так что таймауты мой вечный попутчик на этом чемпионате. Но в этот раз почему-то ситуация с таймаутами была гораздо плачевнее, чем в предыдущие годы. По словам организаторов, они не меняли инфраструктуру, поэтому я не знаю, чем объяснить случившееся, но я ловил таймауты даже при минимуме вычислений. Локально стратегия летает, а на сервере превышает лимит в 40 секунд процессорного времени на игру. В попытках бороться с этим, я добавил логирование суммарного реального времени и был, мягко говоря, удивлён, увидев, что локально на домашнем ПК, моя стратегия тратит суммарно на все вычисления 3 секунды на всю игру, и при этом не укладывается на сервере в отведённые 40 сек. Дебагер показал, что более 90% всего времени сжирает VM джавы, с стратегии остаются лишь оставшиеся жалкие 7-10%. Я начал бить тревогу. И выяснилось, что примерно ту же самую картину видят все, кто пишет на Java или Kotlin.


Поскольку я не джавист и совершенно не разбираюсь в настройке VM, я пытался скооперироваться с теми, кто что-нибудь в этом понимает. К примеру, в воскресенье между 1 и 2 раундом мы просидели несколько часов в скайпе с победителем этого года, Commandos-ом (который давно плюнул на эти проблемы и перешёл на C++), пытаясь добиться вменяемого быстродействия. Настройкой VM, получилось ускорить примерно вдвое, но этого тоже было слишком мало.


Чтобы было понятно, к тому моменту большая часть время разработки ушла именно на быстродействие, а в плане полезной логики в моём коде ещё конь не валялся. Даже поиск пути использовался встроенный, ибо я не мог себе позволить роскошь в виде собственной реализации. В общем, нужно было что-то делать.


Решение нашёл участник под ником karloid, писавший на котлине. Он предложил собирать нативный PE файл средствами GraalVM.


Грааль, как и полагается граалю, сотворил чудо. Собранный exe файл у меня тратил в 5 раз меньше процессорного времени. Ещё спустя пару дней организаторы добавили поддержку GraalVM в тестирующей системе. В общем, только с того момента у меня началось полноценное участие. К сожалению, на тот момент прошло уже 2.5 недели, а это больше половины чемпионата, и оставалось всего пару дней до старта второго раунда. В общей сложности, на протяжении всего чемпионата, примерно треть всего времени ушла на всевозможные оптимизации, а не на написание стратегии. А учитывая, что на поднятие с 16 на 5 место понадобилось суммарно 10-12 часов программирования, я именно с этими проблемами связываю не самый хороший результат финала. В общем, имеем что имеем, дарёному коню в зубы не смотрят, да и поскольку решение теперь известно, я полагаю, в следующий раз Грааль будет доступен изначально.


0. Подготовка


В начале происходят некоторые предпросчёты и обновления состояния. В основном, тут всё скучно. Приведу несколько наиболее интересных моментов:


Обновление мира


Помимо очевидного обновления списка сущностей, я зеркалил ресурсы (как наверное и большинство участников на более или менее высоких позициях). Рабочих своих я не зеркалил. Если вражеский юнит скрывался в тумане войны, я запоминал его позицию, и считал, что он находится там же, где был виден в последний раз, до тех пор, пока эта клетка не будет увидена снова или пока этот юнит не будет замечен в другом месте.


Контролируемые области


Изначально, вспомнив статью xathis о Google Ants, я подумал, что здесь вычисление линий фронта и движение к ним также может быть весьма действенным и это было одной из первых реализованных фич.


Вычислялось элементарно пускаем поиск в ширину (далее, BFS) от всех видимых боевых юнитов. Если очередная просматриваемая клетка ещё не обработана ранее, отмечаем, что именно этот юнит её контролирует. Когда область, контролируемые моими юнитами, сталкивается с областью, контролируемой противником, это и считается линией фронта.


В более поздних версиях я изменил этот алгоритм и пускал BFS от вражеских боевых юнитов, но для своей команды только от рабочих. Таким образом, линии фронта теперь оказывались линиями где-то между моими рабочими и вражескими войсками. Это позволяло более эффективно охранять рабочих и удерживать занятую область, а не бежать всё время вперёд до упора, в результате того, что контролиируемая область сдвигается вместе с юнитом, который по ней и бежит.


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


Линия фронта


Линия из квадратиков некрасивого цвета это и есть линия фронта.


Слоты для добычи ресурсов


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


Карта проходимости


До тех пор, пока не добавили GraalVM, я не мог себе позволить нормальный поиск пути, и я стал изобретать свой. До конца я его не доделал, ибо был найден грааль. Но поскольку то, что успел реализовать, работало достаточно быстро, я его использовал в качестве карты проходимости. Т.е. только для проверки, существует ли вообще известный путь между двумя клетками, без прохода через ресурсы.


Карта проходимости


Как видно по картинке, я заполняю всю карту квадратами как можно большего размера и получаю граф, затем соединяю вершины в случае, если соответствующие им квадраты соприкасаются. Ну и по полученному графу ищу BFS-ом. Граф выходит гораздо меньше, чем при обычном BFS, а потому, работает быстро.


Внутри узла (квадрата) поиск пути не нужен достаточно просто идти по кратчайшему пути от точки входа к следующему узлу. В том объёме, в котором я его успел реализовать, путь находился, только он часто был не кратчайшим, поскольку я не дописал нормальную оценку стоимости прохождения через узел.


1. Экономика


Здесь особо нечего описывать. Жуткие эвристические формулы из кучи составляющих. Общая суть такова:


  • Если нужно, строим базу лучников. Есть несколько условий, когда провоцируют её строительство у нас уже есть определенное количество рабочих, либо враг собрал $350+ денег (т.е. вот-вот начнёт строить базу), либо достигнут 220-й тик.
  • Если осталось меньше X юнитов до лимита, строим дома. X = 5 до тех пор, пока количество рабочих < 15, затем X = 10 (т.е. можно строить 2 дома одновременно)
  • Если мы активно дерёмся, строим армию
  • Если нет, производим рабочих, если ещё не упёрлись в текущий лимит. Лимит вычисляем так:


    double scale = Game.duel_mode ? 0.2 : (Game.fog_of_war ? 0.25 : 0.1);boolean builders_limit_not_reached = num_builders < Math.max(Game.duel_mode ? 60 : 50, World.food_slots.size() * scale);
    

    Коэффициенты подобраны экспериментально. Причем вот этой простой формулой я заменил гораздо более сложную уже после окончания чемпионата, и это было одним из тех немногих изменений, которые подняли бота на 5-е место.



2. Строительство и ремонт


Строительство домов и баз работает по-разному.


База лучников


В дуэли, как только будут построены 20 рабочих, группа из 6 юнитов бежит по направлению к центру карты, до тех пор, пока клетка [35, 35] не будет разведана, если только раньше не выполнится какое-то из условий срочного строительства базы. Затем они пытаются построить базу в координатах, приближенных к клетке [30, 30]. Я видел, что у большинства других участников на строительство базы выделяется 10+ рабочих, но мои тесты показывали наилучший результат именно при количестве 6. Также, я почти в самом начале резервирую какое-то место для базы лучников возле базы рабочих, на тот случай, если карта окажется "закрытой" и со свободным местом будут проблемы. Чтобы не пришлось строить базу лучников где-то на фланге, ибо это сильно снижает возможность оборонять второй фланг и в большинстве случаев ведёт к поражению.


Строители выбираются простым BFS, пущенным одновременно из всех слотов вокруг постройки.


Дома


Для домов всё несколько сложнее. Дом имеет размер 3х3. Я ищу пустую область этого размера, окружённую границей 1х1. На линии границы не должно быть других домов, но могут быть юниты или ресурсы. Таким образом, дома строятся с отступом не менее 1 клетки друг от друга, чтобы между ними могли пройти юниты. Вдоль границ карты строю без проходов. Также, слежу, чтобы мои юниты не могли быть заблокированы в левом нижнем углу карты между двумя домами.


Далее, я проверяю, не заблокирует ли дом единственный проход между двумя областями карты. Для этого я не придумал ничего лучше, чем выбирать по свободной клетке слева и справа от дома и искать между ними путь A*-ом, считая область, где я планирую строить, занятой. Затем беру пару клеток сверху и снизу и делаю то же самое. Если оба пути найдены, можно строить. У этого подхода есть недостатки. К примеру, если в упор к дому будет "карман", то я не смогу найти из него путь и дом построен не будет, даже если на самом деле он ничего не блокирует. Всё это можно было легко исправить, но были более срочные задачи, так что руки так и не дошли.


Из подходящих локаций для строительства я выбираю ту, которая находится как можно ближе к 4-м ближайшим рабочим. Решая, нужно ли назначать рабочего на стройку дома, я пытаюсь учесть его потенциальный вклад, чтобы не получалось, что он будет бежать издалека, а тем временем стройка завершится без него. Оценка является эвристической, поэтому работает неидеально, но лучше, чем вообще без неё.


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


Проход через клетки, где намечается строительство, сильно штрафуется, так что юниты стараются обходить это место, если есть другие пути к их целям.


Турели


Турели я в последней версии не строю, но в более старых версиях строил. Для этого я выбирал клетки, окружённые как можно большим количеством ресурсов. Они использовались для охраны добывающих рабочих. Вообще с турелями немного странно получилось до какой-то версии отключение турелей сильно снижало силу стратегии, а в какой-то момент картина изменилась на противоположную и теперь уже наоборот, с турелями стал играть гораздо хуже, чем без. И как я ни пытался снова добиться от них пользы, у меня не вышло, так что в итоге они отключены.


3. Сбор ресурсов


Возможно я изобрёл велосипед. Ещё более вероятно, что классические алгоритмы гораздо лучше моего. Это я теперь уже знаю, что гуглить нужно было про паросочетания в двудольном графе, а тогда я этого ещё не знал, так что писал свой алгоритм:


  1. Собираем все слоты для добычи ресурсов в список (на картинке отмечены желтыми крестиками).


    Слоты для добычи еды


  2. Сортирую слоты по количеству других свободных слотов рядом с ними. Идея в том, что если для рабочего есть несколько слотов на одинаковом удалении, то лучше добывать там, откуда в будущем понадобится меньше времени, чтобы перебраться к следующему слоту.


  3. Из каждого слота параллельно инициируем отдельный BFS. Отдельный потому, что нам нужно найти не одно паросочетание слот-рабочий, а множество, поэтому для каждого слота будут свои открытый и закрытый списки.


  4. Итерируем по количеству шагов от 1 до 20 (моя максимальная дистанция поиска).


  5. Для каждого шага, для каждого слота, обрабатываем все клетки открытого списка, которые находятся на удалении, совпадающем с шагом.


  6. Если в очередной клетке находится свободный рабочий, назначаем его в текущий слот.


  7. Для тех рабочих, которые после окончания этого алгоритма остались незадействоанными, просто ищем ближайшие свободные слоты и идём к ним. Когда расстояние станет <= 20, его подхватит вышеописанный алгоритм.



Таким образом, в начале мы находим и назначаем всех рабочих, которые уже стоят на своих местах, потом всех тех, кто находится в одном шаге от ресурсов, затем тех, кто в двух шагах и т.д.


Кроме того, если вокруг рабочего есть более одного ресурса, он добывает тот их них, который имеет меньше всего граничащих слотов


Добыча самого замурованного ресурса


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


Если после всех манипуляций рабочий оставался без приказа, я отправлял его атаковать базу либо рабочих противника. Побочным эффектом этого действия было движение к противнику в случаях, если на первом тике в поле зрения нет ресурсов.


Также, рабочие умели убегать от вражеских боевых юнитов. Особых алгоритмов конкретно для убегания у меня не было. Я просто отмечал некоторые клетки опасными. Слоты для добычи еды в этих клетках не регистрировались. Таким образом, рабочий искал ближайший свободный слот, игнорируя опасные, а при поиске пути за проход по опасным клеткам назначался большой штраф. Как следствие, оказавшись в опасной области, рабочие бросали добычу ресурсов и бежали добывать в более безопасное место наиболее безопасным путём.


Убегание рабочих от врага


На картинке оранжевыми квадратиками показана опасная область. Как видно, несколько строителей (зелёно-жёлтые юниты), оказавшись внутри опасной области, бросают свои слоты и бегут добывать в более безопасное место. Оранжевая линия показывает путь одного из рабочих к новому слоту в обход опасных клеток.


А вот само вычисление опасных клеток было немножко интереснее.


  1. Отмечаем все клетки в радиусе поражения вражеских юнитов как опасные.
  2. Для вражеских лучников, вычисляем все клетки, куда они могут дойти за количество тиков, равное радиусу поражения, и добавляем их в открытый и закрытый списки. Т.е. для лучников все клетки, до которых он доходит ровно за 5 шагов. Ниже объясню, зачем это надо.
  3. Добавляем в эти же списки позиции всех моих войск.
  4. Пускаем BFS из всех клеток в открытом списке. Рассматриваем только клетки в радиусе 7 единиц от юнитов. В свойство каждой просмотренной клетки я записываю, была ли она достигнута из моей клетки или из вражеской.

Таким образом, в радиусе семи клеток от каждого вражеского лучника, я оценивал, может ли враг атаковать эту клетку раньше, чем в неё подойдут мои войска. При этом, врагу достаточно было оказаться на расстоянии выстрела от клетки, а моим лучникам нужно было её занять. Т.е. у вражеских лучников была "фора" в 5 ходов. Именно поэтому во 2-м пункте я добавлял в список клетки, достижимые ими за 5 ходов, в то время, как для моих войск я добавлял только их реальные позиции. Расстояние в 7 клеток было получено путём тестирования. При значениях больше мои рабочие погибали гораздо реже, но и еды добывали меньше. При 7 клетках коэффициент побед был наивысшим.


Ещё рабочие могли было ремонтировать (лечить) других юнитов. Мало кто из участников активно использовал эту возможность. У меня, как и у многих других, лечение было случайным. То есть если раненый юнит проходил мимо рабочего, рабочий его лечил, но специально ни врачи к пациентам ни пациенты к врачам не ходили. Лечил я только на протяжении одного тика, с 5 ХП до 6 (при максимуме в 10). Так что поваляться на больничном у них особой возможности не было. Я не видел смысла тратить 5 тиков на полное восстановление ХП лучника, который, будучи вылеченным, умрёт с двух выстрелов (выстрел снимает 5 ХП), если можно было вылечивать всего 1 ХП за 1 тик с точно таким же исходом: лучник умрёт с двух выстрелов.


4. Производство юнитов


Тут мало что можно рассказать. Войска строю в клетке, ближайшей к врагу. С рабочими тоже всё просто. Если в пределах двух клеток от базы есть строящееся здание, произвожу рабочего в смежном слоте, если нет строю поближе к слоту с едой (BFS от всех слотов еды пока не наткнусь на слот вокруг базы)


5. Бой


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


Боёвку я переписывал три раза, но до финала (включительно) всё равно работала первая версия, ибо остальные показывали худший результат. Первая версия минимакс с альфа-бета отсечением.


Юниты делятся на группы. В финале работало следующее разбиение:


  1. Сортирую своих юнитов по количеству противников в радиусе 5, затем 6, затем 7
  2. Создаю бой и добавляю в него первого из отсортированных юнитов, затем рекурсивно всех его врагов, всех врагов его врагов и т.д., пока есть кого добавлять
  3. Если моих юнитов в бою уже 5, больше в этот бой не добавляю. То же самое с противниками.

Таким образом, максимально возможный бой 5х5. Если дерется больше юнитов значит будет несколько боёв. Поскольку при переборе уменьшение количества ходов даже для одного юнита на один значительно снижает общее количество комбинацией, имели место быть некоторые оптимизации:


  • Юниты на расстоянии 7 клеток от врага не идут в клетки на 8
  • Генерация всех возможных ходов для моих и вражеских юнитов была достаточно дорогой операцией, и в раздумьях о том, как её оптимизировать, пришёл в голову следующий ход конём: я понял, что моя команда никогда не подходит в упор к вражеской команде (не считая варианта с мечниками, которых по факту практически не использовали). А потому, каким бы ни был ход моей команды, он никак не влияет на возможные ходы врага. Это позволило генерировать вражеские ходы только один раз за тик и затем тянуть их из кеша.
  • Юниты не умеют меняться местами и два юнита не могут идти в одну и ту же клетку.

В оценочной функции я пытаюсь прикинуть, сколько будет убитых и раненых в обеих командах. Затем, на основании этих цифр, оценивал, выгодно ли мне атаковать. И вот множество тестов показали, что наилучший вариант заключается в том, чтобы нападать когда количество моих юнитов в бою меньше либо равно количеству юнитов противника. Короче говоря, при оценке я умножал оценку силы моей армии на коэффициент 0.99 если моих юнитов больше и на 1.01 в противном случае. Почему именно так могу лишь предполагать.


Одним из немногих изменений, которые были сделаны уже после финала и которые подняли меня с 16 на 5 место, было то, что я изменил принцип разбиения всех воюющих юнитов на отдельные бои. Так что в последней версии работает так:


  1. Сортирую своих юнитов по количеству противников в радиусе 5, затем 6, затем 7
  2. Беру первого юнита из отсортированного списка, рекурсивно добавляю вместе с ним в бой всех своих юнитов на расстоянии 3. Затем обхожу всех моих юнитов в этом бою и добавляю в него всех врагов на расстоянии <= 7

Вместе с этим я сделал ещё одно: включил одну из двух альтернативных боевых систем, упомянутых выше, которые до этого никогда не были в релизных версиях. Но включаю её только для боёв с числом юнитов больше чем 5х5. А бои 5х5 и меньше, как и ранее, обрабатываются минимаксом. Как результат, драться мой бот стал заметно эффективнее.


Эта вот альтернативная боевая система работала на общих эвристических принципах, схожих с теми, которые были у многих других участников. Она не очень интересна, но вкратце опишу:


  1. Сортирую своих юнитов по количеству противников в радиусе 5, затем 6, затем 7
  2. Те, у кого уже есть противники в радиусе 5, никуда не ходят и просто стреляют
  3. Обхожу моих юнитов в отсортированном списке
  4. Для каждого, считаю общее количество врагов в зонах 6 и 7 клеток
  5. Беру ближайшего к нему врага и считаю количество моих юнитов только в зоне 6 клеток
  6. Если число из пункта 5 больше числа из пункта 4, юнит считается атакующим.
  7. Если меньше убегаем, если равное количество стоим на месте.

Первыми ходят те, у кого меньше возможных атакующих/отступательных ходов. Вот эта нехитрая поделка играла лучше моего лимитированного минимакса в массивных боях. Не спрашивайте про 4-7 пункты. В этом была какая-то логика, но я не помню, какая :) Но я пробовал много разных вариантов и этот работал лучше остальных.


Последним этапом боевой стратегии идёт функция, которая пытается перераспределить выстрелы более оптимально. Чтобы не выходило, что какой-то юнит, имеющий более одной цели для стрельбы, стреляет в того, кто в любом случае будет убит и без него.


Перераспределение выстрелов


Юнит "C" имеет только одну цель в радиусе выстрела "3", в то время, как юниты "A" и "B" имеют по 2 цели. Если бы юниты "A" и "B" стреляли в цель "3", выстрел юнита "C" не принёс бы никакой пользы. Поэтому у меня первым стреляет юнит "C", ибо у него всего одна возможная цель, а затем "A" и "B" решают, куда стрелять им, чтобы максимизировать потери противника.


6. Перемещение по миру


Движение рабочих описано выше, так что здесь речь о войсках. Точнее, для тех из войск, кто не находится в состоянии боя. Первым делом в дело вступал алгоритм спецназа Commandos (все совпадения с топ1 участниками этого года случайны). Он состоял из четырёх задач:


Охота на вражеских рабочих


  1. Разбиваю всех видимых вражеских рабочих на группы (в цикле, если рабочий находится в пределах 5 единиц он какой-нибудь группы, добавляю его в неё и пересчитываю её центр. Иначе, добавляю в новую группу)
  2. Считаю score каждой группы: по 3 очка за каждого юнита, который добывает еду, и по 1 очку за остальных
  3. Сортирую по убыванию score и уже привычным движением руки, добавляю их в открытый и закрытый списки.
  4. BFS-ом ищу ближайшего свободного лучника.
  5. Скачу пугать и убивать.

К моему удивлению, одним из самых значимых изменений после окончания чемпионата, которое подняло меня с 8-10 мест места на 4-5, было изменение одной единственной константы, которая заставила охотников при поиске пути бояться вражеских солдатов.
Причём это было в последний день, за несколько часов до остановки песочницы, и локальные тесты показали улучшение всего на 30%, так что я даже сомневался, релизить ли, чтобы не потерять имеющуюся позицию. Дело в том, что в этом году у меня на протяжении всего чемпионата постоянно случалось такое, что новая версия локально выигрывала от 65% до 95% игр, а будучи залитой на сайт, против других играла так же, как предыдущая, или хуже. Вообще практически все мои релизные версии выигрывали не менее 2/3 игр против предыдущей. А тут всего-лишь 30%. В общем, я рискнул и риск оправдался.


Round 1 Opening


Алгоритм начала первого раунда. Одна из немногих вещей, где поведение реализовано хардкодом. Само поведение подсмотрено у GreenTea. Суть в том, что в игре по правилам 1-го раунда (без тумана войны), у нас изначально есть 1 лучник и 1 мечник. Их обоих я сразу направляю в атаку на базу врага справа от меня. При этом, они по возможности стараются избегать встречи с вражескими юнитами. До тех пор, пока их миссия выполнима, базу врага они тоже не атакуют, ибо в этом нет особого смысла, их наверняка убьют раньше. Их задача состоит в распугивании или убийстве вражеской фауны в лице рабочих. Это приводит к просадке в экономике в начальной фазе игры, что с большой вероятностью приводит к проигрышу.


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


Это единственный случай, когда я вообще строю мечников. Более того, базу мечников я даже не ремонтирую.


Защита базы


К каждому врагу, который находится недалеко от моей базы, выбирается ближайший мой свободный лучник в определенном радиусе. Если враг находится дальше 30 клеток от базы рабочих, для этой задачи выбираются только юниты с нечетными id. Без этого условия работало хуже, ибо часто подхватывались юниты, которые могли принести больше пользы на других задачах.


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


Обход по флангам


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


Для этой задачи я пускал 3 луча из точки [79, 79] (это угол на базе противника) влево и столько же вниз, с поворотом в 9 градусов между ними. И отправлял по одному лучнику вдоль каждого луча. Точнее, луч бился на сегменты и юнит стремился к дальнему от вражеской базы сегменту. Если этот сегмент недавно был посещён, юнит шёл к следующему и таким образом продвигался к вражеской базе по флангу.


Обход по флангам


В левом нижем углу картинки три моих юнита идут к жёлтым точкам, которые находятся на лучах, расходящихся от базы противника.


Перемещение по карте


В остальном же, ещё впервые увидев задачу, я первым делом подумал "старый конь борозды не портит", и стал прикручивать старые добрые потенциальные поля. К моему удивлению, по окончанию чемпионата, когда участники в телеграме стали делиться своими подходами, оказалось, что мой подход к использованию ПП в подобных задачах сильно отличается от того, как их используют другие. Во всяком случае, те "другие", которые высказались на этот счёт. До сих пор я считал, что большинство используют их примерно так же, как я.


Итак, перемещение:


  1. Инициализирую ПП. По сути это просто двумерный массив чисел, хранящий потенциалы каждой клетки. Из-за моих проблем с быстродействием до того момента, когда был найден грааль, я использовал сетку размером в 2 клетки. Позже ресурсов уже хватало и на нормальную сетку, но весь остальной код на тот момент уже полагался именно на этот размер, а времени переписывать уже не было. Короче говоря, на переправе коней не меняют. Поэтому, до самого конца у меня так и используется сетка 40х40 поверх поля 80х80.
  2. Расставляем эмиттеры. Т.е. точки, которые излучают положительный или отрицательный потенциал в определенной области. Все эмиттеры были линейными. Угадайте, почему. Правильно быстродействие! Считать квадратные корни или возводить в иные степени дорогое удовольствие. С граалем я уже мог себе это позволить, но это нарушило бы всю хрупкую экосистему и пришлось бы искать новый баланс.
    Эмиттеров было достаточно много. Вот несколько основных:
    • Отталкивающее поле радиусом в несколько клеток в позиции каждого лучника. Я изначально решил, что мои юниты будут разбредаться по всей карте, чтобы во-первых, минимизировать туман войны и во-вторых, я стремился к тому, чтобы в любой точке карты, где срочно понадобятся дополнительные юниты, кто-нибудь оказался поблизости.
    • Притягивающие поля на вражеских юнитах и зданиях, на моих турелях, на моих строящихся зданиях
    • Отталкивающее поле в точке [0, 0], чтобы при прочих равных, юниты не толпились на базе
    • Кроме того, как я уже упоминал выше, здесь также работал алгоритм с лучами, только из точки [0, 0]. Я запускал 6 лучей по флангам и ставил эмиттеры в тех местах, где эти лучи пересекались с линией фронта (с некоторым сдвигом вперёд). это заставляло юнитов стремиться в позиции между моими рабочими и вражеской армией.

Эмиттеры


Блеклые зелёные окружности показывают притягивающие эмиттеры. Красные диски это эмиттеры на лучах. После начала строительства боевых юнитов, эмиттеров становилось намного больше:


Эмиттеры


Блеклые красные это эмиттеры с отрицательным потенциалом.


Таким образом, у меня было единое потенциальное поле для всех боевых юнитов размером во всю карту. Далее, для каждого свободного боевого юнита, я рассматривал все клетки в радиусе 25 ходов и оценивал путь к этой клетке как сумму потенциалов на пути к ней. Нужно сказать, что на момент финала я всё ещё оценивал путь по прямой линии, что явно было неправильно, но мне не хватало ресурсов для более правдоподобной оценки. Уже после окончания, когда я сделал микс двух моих боевых систем, описанный выше, это позволило выиграть немного ресурсов и я смог себе позволить оценивать не прямой путь, а использовать более честный BFS на 25 ходов.


То есть из каждого юнита я поиском в ширину делал 25 шагов и затем рассматривал все пройдённые клетки. Потенциал каждой клетки относительно юнита считался как сумма_потенциалов_всех_пройденных_клеток + потенциал_в_целевой_клетке * (25 - количество_сделанных_шагов). То есть я "дошагивал" все оставшиеся ходы в конечной клетке, для того, чтобы я затем смог полноценно сравнивать все эти пути, имея цифры одного порядка. Ну и как несложно догадаться, я выбирал клетку с наивысшим относительным потенциалом и шёл к ней.


Путь к ней прокладывался отдельно, ибо мой поиск пути учитывал гораздо больше факторов, чем этот BFS. Но всё равно, вариант с BFS работал намного лучше, чем оценка по прямой. Это одно из улучшений, которые позволили подняться на 5 место. Причём этот BFS был реализован ещё до финала, но был отключен ввиду нехватки ресурсов.


После того, как очередной юнит определился, куда он идёт, я делал ещё одну важную вещь помещал отталкивающий эмиттер в конечной точке его пути. Те самые красные окружности на скриншоте выше. Это нужно было для того, чтобы все юниты не бежали в одну точку с наивысшим потенциалом, ибо для каждого последующего юнита потенциал этой точки снижался. Одна эта строка кода заставляла юнитов более-менее равномерно разбредаться по карте.


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


Поиск пути


Банальный A* со штрафами. Штрафы давались за прохождение через юнитов, через места, где планируется стройка, большой штраф давался за проход в зоне поражения вражескими турелями. За прохождение через ресурсы давался штраф, равный количеству ходов, которые потребуются, чтобы прорубить через них проход. И так далее. Некоторые из этих штрафов применялись или игнорировались в зависимости от аргументов. К примеру, рабочие сильно штрафовались за прохождение через зону стрельбы вражеских лучников, а войска нет.


Также, юниты умели толкать рабочих. Тут есть два варианта:


  • Рабочий толкает рабочего: При поиске пути для рабочего, я накладывал маленький штраф за прохождение через клетку, "забитую" другим рабочим. Штрафа хватало только на то, чтобы при выборе между двумя одинаковыми по длине путями, выбрать тот, где нет рабочих. В остальном же, при поиске пути, клетка с рабочим считалась пустой. Когда рабочий должен был на текущем ходу шагнуть в клетку, занятую другим рабочим, он сдвигался вперёд, вдоль пути, толкая паровозиком всех остальных рабочих перед собой до первой свободной клетки.


    Проталкивание рабочих


    Юнит "A" идёт в клетку "B". Черные стрелки показывают, кто куда будет смещён. На следующем ходу уже дальний вытолкнутый юнит окажется ближайшим свободным и вероятно, продолжит миссию. Непосредственного обмена приказами у меня не было, это получалось само собой.


  • Воин толкает рабочего: Схоже с предыдущим пунктом, только в этом случае рабочие наоборот, стремятся разбегаться не вдоль найденного пути, а в стороны. Ибо в отличие от случая выше, воин не заменит собой рабочего, поэтому желательно иметь возможность в кратчайший срок вернуть рабочих на свои места.



Тестирование


Тесты я гонял на 3-х компьютерах. В этом году даже не пришлось считать, является ли результат статистически значимым, ибо игры считались достаточно быстро, так что гонять их можно было много, и при этом, практически каждая моя следующая версия выигрывала у предыдущей 2/3 игр или больше, при количестве сыгранных игр не менее 500. Т.е. результат был заведомо статистически значим и без вычислений. При этом, как я уже упоминал выше, в этом году постоянно получалось так, что моя новая версия, без шансов обыгрывающая предыдущую, но против других противников играет лишь немногим лучше (если повезёт), а то и хуже (если нет). Апогеем стала версия, которая в локальных тестах выиграла у предыдущей со счётом 480:20, но после релиза показала нулевое преимущество против других участников.


Для режимов на четыре игрока можно было использовать тот вариант оценки, который использовался в раундах, но я поступил проще и просто создавал игры с двумя копиям новой версии против двух копий старой. Выигрыш любой из двух копий новой версии я приплюсовывал к победам.


Подбор констант, коих было много, осуществлялся скриптом на python. Он брал значения из командной строки и для каждого набора создавал некоторое количество игр (обычно 200) против той же версии с дефолтными константами. Что-то типа такого:


python search.py run &mdash;p1 test.exe &mdash;p2 prev.exe &mdash;count 200 &mdash;teams 2 &mdash;nthreads 3 &mdash;level Finals &mdash;params "CMD_MAX_DIST_FROM_BASE_TO_COUNTER:50/100|ENEMY_UNIT_ATTRACTION:100/300|FRIENDLY_PUSH_OFF_MULT:2.5/7.5" &mdash;output tests_v42-r3-1

Конкретно эта строка создала бы 6 сетов по 200 игр в 3 потока в режиме дуэли. Первый сет игр был бы со значением CMD_MAX_DIST_FROM_BASE_TO_COUNTER = 50, второй CMD_MAX_DIST_FROM_BASE_TO_COUNTER = 100 и т.д. Можно было передавать и несколько констант за один раз. Сами тестируемые значения я обычно проверял парами брал значение заметно больше текущего и заметно меньше. В примере выше, дефолтное значение константы CMD_MAX_DIST_FROM_BASE_TO_COUNTER было 75, поэтому я тестировал значения 50 и 100.


Визуализация


Как и все предыдущие годы, я использовал свой самописный визуализатор. Тем, кто читал мои предыдущие статьи он уже знаком. Вот традиционное видео:



Заключение


На этом у меня всё. Спасибо организаторам за очередной крутой контест. Задача этого года, как по мне, была одной из самых интересных.
Ждём новых чемпионатов!

Подробнее..

Пишем Slack бота для Scrum покера на Go. Часть 1

04.03.2021 00:14:15 | Автор: admin

Здравствуйте! Сегодня мы напишем Slack бота для Scrum покера на языке Go. Писать будем по возможности без фреймворков и внешних библиотек, так как наша цель разобраться с языком программирования Go и проверить, насколько этот язык удобен для разработки подобных проектов.

Дисклеймер

Я только познаю Go и многих вещей еще не знаю. Мой основной язык разработки Python. Поэтому часто буду отсылать к нему в тех местах, где по моему мнению в Python что-то сделано удобнее или проще. Цель этих отсылок в том, чтобы породить дискуссию, ведь вполне вероятно, что эти "удобные вещи" также присутствуют в Go, просто я их не нашел.

Также, отмечу, что все что будет описано ниже, можно было бы сделать гораздо проще (без разделения на слои и так далее), но мне показалось интересным написать больше с целью обучения и практики в "чистой" архитектуре. Да и тестировать так проще.

Хватит прелюдий, вперед в бой!

Итоговый результат

Анимация работы будущего бота

Для тех, кому читать код интересней, чем статью прошу сюда.

Структура приложения

Разобьем нашу программу на следующие слои. У нас предполагается слой взаимодействия (web), слой для рисования интерфейса средствами Slack UI Block Kit (ui), слой для сохранения / получения результатов (storage), а также место для хранения настроек (config). Давайте создадим следующие папки в проекте:

config/storage/ui/web/-- clients/-- server/main.go

Сервер

Для сервера будем использовать стандартный сервер из пакета http. Создадим структуру Server следующего вида в web -> server:

server.go
package serverimport ("context""log""net/http""os""os/signal""sync/atomic""time")type Server struct {  // Здесь мы будем определять все необходимые нам зависимости и передавать их на старте приложения в main.gohealthy        int32logger         *log.Logger}func NewServer(logger *log.Logger) *Server {return &Server{logger: logger,}}

Эта структура будет выступать хранилищем зависимостей для наших хэндлеров. Есть несколько подходов для организации работы с хэндлерами и их зависимостями. Например, можно объявлять и запускать все в main.go, там же где мы создаем экземпляры наших структур и интерфейсов. Но это плохой путь. Еще есть вариант использовать глобальные переменные и просто их импортировать. Но в таком случае становится сложно покрывать проект тестами. Дальше мы увидим плюсы выбранного мной подхода. Итак, нам нужно запустить наш сервер. Напишем метод:

server.go
func (s *Server) setupRouter() http.Handler {  // TODOrouter := http.NewServeMux()  return router}func (s *Server) Serve(address string) {server := &http.Server{Addr:         address,    Handler:      s.setupRouter(),ErrorLog:     s.logger, // Наш логгерReadTimeout:  5 * time.Second,WriteTimeout: 10 * time.Second,IdleTimeout:  15 * time.Second,}  // Создаем каналы для корректного завершения процессаdone := make(chan bool)quit := make(chan os.Signal, 1)  // Настраиваем сигнал для корректного завершения процессаsignal.Notify(quit, os.Interrupt)go func() {<-quits.logger.Println("Server is shutting down...")    // Эта переменная пригодится для healthcheck'а напримерatomic.StoreInt32(&s.healthy, 0)    // Даем клиентам 30 секунд для завершения всех операций, прежде чем сервер будет остановленctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)defer cancel()    // Информируем сервер о том, что не нужно держать существующие коннектыserver.SetKeepAlivesEnabled(false)    // Выключаем серверif err := server.Shutdown(ctx); err != nil {s.logger.Fatalf("Could not gracefully shutdown the server: %v\n", err)}close(done)}()s.logger.Println("Server is ready to handle requests at", address)  // Переменная для проверки того, что сервер запустился и все хорошоatomic.StoreInt32(&s.healthy, 1)  // Запускаем серверif err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {s.logger.Fatalf("Could not listen on %s: %v\n", address, err)}  // Когда сервер остановлен и все хорошо, снова получаем управление и логируем результат<-dones.logger.Println("Server stopped")}

Теперь давайте создадим первый хэндлер. Создадим папку в web -> server -> handlers:

healthcheck.go
package handlersimport ("net/http")func Healthcheck() http.Handler {return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {w.Write("OK")})}

Добавим наш хэндлер в роутер:

server.go
// Наш код вышеfunc (s *Server) setupRouter() http.Handler {router := http.NewServeMux()router.Handle("/healthcheck",handlers.Healthcheck(),)  return router}// Наш код ниже

Идем в main.go и пробуем запустить наш сервер:

package mainimport ("log"  "os"  "go-scrum-poker-bot/web/server")func main() {  // Создаем логгер со стандартными флагами и префиксом "INFO:".   // Писать он будет только в stdoutlogger := log.New(os.Stdout, "INFO: ", log.LstdFlags)app := server.NewServer(logger)app.Serve(":8000")}

Пробуем запустить проект:

go run main.go

Если все хорошо, то сервер запустится на :8000 порту. Наш текущий подход к созданию хэндлеров позволяет передавать в них любые зависимости. Это нам еще пригодится, когда мы будем писать тесты. ;) Прежде чем идти дальше, нам нужно немного настроить нашу локальную среду, чтобы Slack смог с нами взаимодействовать.

NGROK

Для того, чтобы можно было локально проверять работу нашего бота, нам нужно установить себе туннель ngrok. Вообще можно любой другой, но этот вариант удобный и прост в использовании. Да и Slack его советует. В общем, когда все будет готово, запустите его командой:

ngrok http 8000

Если все хорошо, то вы увидите что-то вроде этого:

ngrok by @inconshreveable                                                                                                            (Ctrl+C to quit)                                                                                                                                                     Session Status                online                                                                                                                 Account                       Sayakhov Ilya (Plan: Free)                                                                                             Version                       2.3.35                                                                                                                 Region                        United States (us)                                                                                                     Web Interface                 http://127.0.0.1:4040                                                                                                  Forwarding                    http://ffd3cfcc460c.ngrok.io -> http://localhost:8000                                                                  Forwarding                    https://ffd3cfcc460c.ngrok.io -> http://localhost:8000                                                                                                                                                                                                                      Connections                   ttl     opn     rt1     rt5     p50     p90                                                                                                          0       0       0.00    0.00    0.00    0.00     

Нас интересует строчка https://ffd3cfcc460c.ngrok.io. Она нам понадобится дальше.

Slash commands

Создадим наше приложение в Slack. Для этого нужно перейти сюда -> Create New App. Далее указываем имя GoScrumPokerBot и добавляем его в свой Workspace. Далее, нам нужно дать нашему боту права. Для этого идем в OAuth & Permissions -> Scopes и добавляем следующие права: chat:write, commands. Первый набор прав нужен, чтобы бот мог писать в каналы, а второй для slash команд. И наконец нажимаем на Reinstall to Workspace. Готово! Теперь идем в раздел Slash commands и добавляем нашу команду /poker .

В Request URL нужно вписать адрес из пункта выше + путь. Пусть будет так: https://ffd3cfcc460c.ngrok.io/play-poker.

Slash command handler

Теперь создадим хэндлер для обработки событий на только созданную команду. Идем в web -> server -> handlers и создаем файл play_poker.go:

func PlayPokerCommand() http.Handler {return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {    w.Header().Set("Content-Type", "application/json")    w.Write([]byte(`{"response_type": "ephemeral", "text": "Hello world!"}`))})}

Добавляем наш хэндлер в роутер:

server.go
func (s *Server) setupRouter() http.Handler {router := http.NewServeMux()router.Handle("/healthcheck",handlers.Healthcheck(),)router.Handle("/play-poker",handlers.PlayPokerCommand(),)  return router}

Идем в Slack и пробуем выполнить эту команду: /poker. В ответ вы должны получить что-то вроде этого:

Но это не единственный вариант взаимодействия со Slack. Мы также можем слать сообщения в канал. Этот вариант мне понравился больше и плюс у него больше возможностей в сравнении с ответом на команду. Например вы можете послать сообщение в фоне (если оно требует долгих вычислений). Давайте напишем наш http клиента. Идем в web -> clients. Создаем файл client.go:

client.go
package clients// Создадим новый тип для наших хэндлеровtype Handler func(request *Request) *Response// Создадим новый тип для middleware (о них чуть позже)type Middleware func(handler Handler, request *Request) Handler// Создадим интерфейс http клиентаtype Client interface {Make(request *Request) *Response}// Наша реализация клиентаtype BasicClient struct {client     *http.Clientmiddleware []Middleware}func NewBasicClient(client *http.Client, middleware []Middleware) Client {return &BasicClient{client: client, middleware: middleware}}// Приватный метод для всей грязной работыfunc (c *BasicClient) makeRequest(request *Request) *Response {payload, err := request.ToBytes() // TODOif err != nil {return &Response{Error: err}}  // Создаем новый request, передаем в него данныеreq, err := http.NewRequest(request.Method, request.URL, bytes.NewBuffer(payload))if err != nil {return &Response{Error: err}}  // Применяем заголовкиfor name, value := range request.Headers {req.Header.Add(name, value)}  // Выполняем запросresp, err := c.client.Do(req)if err != nil {return &Response{Error: err}}defer resp.Body.Close()  // Читаем тело ответаbody, err := ioutil.ReadAll(resp.Body)if err != nil {return &Response{Error: err}}err = nil  // Если вернулось что-то отличное выше или ниже 20x, то ошибкаif resp.StatusCode > http.StatusIMUsed || resp.StatusCode < http.StatusOK {err = fmt.Errorf("Bad response. Status: %d, Body: %s", resp.StatusCode, string(body))}return &Response{Status:  resp.StatusCode,Body:    body,Headers: resp.Header,Error:   err,}}// Наш публичный метод для запросовfunc (c *BasicClient) Make(request *Request) *Response {if request.Headers == nil {request.Headers = make(map[string]string)}    // Применяем middlewarehandler := c.makeRequestfor _, middleware := range c.middleware {handler = middleware(handler, request)}return handler(request)}

Теперь создадим файл web -> clients:

request.go
package clientsimport "encoding/json"type Request struct {URL     stringMethod  stringHeaders map[string]stringJson    interface{}}func (r *Request) ToBytes() ([]byte, error) {if r.Json != nil {result, err := json.Marshal(r.Json)if err != nil {return []byte{}, err}return result, nil}return []byte{}, nil}

Сразу напишем тесты к методу ToBytes(). Для тестов я взял testify/assert, так как без нее была бы куча if'ов, а меня они напрягают :) . К тому же, я привык к pytest и его assert, да и как-то глазу приятнее:

request_test.go
package clients_testimport ("encoding/json""go-scrum-poker-bot/web/clients""reflect""testing""github.com/stretchr/testify/assert")func TestRequestToBytes(t *testing.T) {  // Здесь мы делаем что-то вроде pytest.parametrize (жаль, что в Go нет сахара для декораторов, это было бы удобнее)testCases := []struct {json interface{}data []byteerr  error}{{map[string]string{"test_key": "test_value"}, []byte("{\"test_key\":\"test_value\"}"), nil},{nil, []byte{}, nil},{make(chan int), []byte{}, &json.UnsupportedTypeError{Type: reflect.TypeOf(make(chan int))}},}  // Проходимся по нашим тест кейсамfor _, testCase := range testCases {request := clients.Request{URL:     "https://example.com",Method:  "GET",Headers: nil,Json:    testCase.json,}actual, err := request.ToBytes()    // Проверяем результатыassert.Equal(t, testCase.err, err)assert.Equal(t, testCase.data, actual)}}

И нам нужен web -> clients:

response.go
package clientsimport "encoding/json"type Response struct {Status  intHeaders map[string][]stringBody    []byteError   error}// Я намеренно сделал универсальный метод, чтобы можно было привезти любой ответ к нужному и не писать каждый раз эти богомерзкие if err != nilfunc (r *Response) Json(to interface{}) error {if r.Error != nil {return r.Error}return json.Unmarshal(r.Body, to)}

И также, напишем тесты для метода Json(to interface{}):

response_test.go
package clients_testimport ("errors""go-scrum-poker-bot/web/clients""testing""github.com/stretchr/testify/assert")// Один тест на позитивный кейсfunc TestResponseJson(t *testing.T) {to := struct {TestKey string `json:"test_key"`}{}response := clients.Response{Status:  200,Headers: nil,Body:    []byte(`{"test_key": "test_value"}`),Error:   nil,}err := response.Json(&to)assert.Equal(t, nil, err)assert.Equal(t, "test_value", to.TestKey)}// Один тест на ошибкуfunc TestResponseJsonError(t *testing.T) {expectedErr := errors.New("Error!")response := clients.Response{Status:  200,Headers: nil,Body:    nil,Error:   expectedErr,}err := response.Json(map[string]string{})assert.Equal(t, expectedErr, err)}

Теперь, когда у нас есть все необходимое, нам нужно написать тесты для клиента. Есть несколько вариантов написания тестов для http клиента. Я выбрал вариант с подменой http транспорта. Однако есть и другие варианты, но этот мне показался удобнее:

client_test.go
package clients_testimport ("bytes""go-scrum-poker-bot/web/clients""io/ioutil""net/http""testing""github.com/stretchr/testify/assert")// Для удобства объявим новый типtype RoundTripFunc func(request *http.Request) *http.Responsefunc (f RoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {return f(request), nil}// Создание mock тестового клиентаfunc NewTestClient(fn RoundTripFunc) *http.Client {return &http.Client{Transport: RoundTripFunc(fn),}}// Валидный тестfunc TestMakeRequest(t *testing.T) {url := "https://example.com/ok"  // Создаем mock клиента и пишем нужный нам ответhttpClient := NewTestClient(func(req *http.Request) *http.Response {assert.Equal(t, req.URL.String(), url)return &http.Response{StatusCode: http.StatusOK,Body:       ioutil.NopCloser(bytes.NewBufferString("OK")),Header:     make(http.Header),}})  // Создаем нашего http клиента с замоканным http клиентомwebClient := clients.NewBasicClient(httpClient, nil)response := webClient.Make(&clients.Request{URL:     url,Method:  "GET",Headers: map[string]string{"Content-Type": "application/json"},Json:    nil,})assert.Equal(t, http.StatusOK, response.Status)}// Тест на ошибочный responsefunc TestMakeRequestError(t *testing.T) {url := "https://example.com/error"httpClient := NewTestClient(func(req *http.Request) *http.Response {assert.Equal(t, req.URL.String(), url)return &http.Response{StatusCode: http.StatusBadGateway,Body:       ioutil.NopCloser(bytes.NewBufferString("Bad gateway")),Header:     make(http.Header),}})webClient := clients.NewBasicClient(httpClient, nil)response := webClient.Make(&clients.Request{URL:     url,Method:  "GET",Headers: map[string]string{"Content-Type": "application/json"},Json:    nil,})assert.Equal(t, http.StatusBadGateway, response.Status)}

Отлично! Теперь давайте напишем middleware. Я привык для каждой, даже самой маленькой задачи, писать отдельную маленькую middleware. Так можно легко переиспользовать такой код в разных проектах / для разных API с разными требованиями к заголовкам / авторизации и так далее. Slack требует при отправке сообщений в канал указывать Authorization заголовок с токеном, который вы сможете найти в разделе OAuth & Permissions. Создаем в web -> clients -> middleware:

auth.go
package middlewareimport ("fmt""go-scrum-poker-bot/web/clients")// Токен будем передавать при определении middleware на этапе инициализации клиентаfunc Auth(token string) clients.Middleware {return func(handler clients.Handler, request *clients.Request) clients.Handler {return func(request *clients.Request) *clients.Response {request.Headers["Authorization"] = fmt.Sprintf("Bearer %s", token)return handler(request)}}}

И напишем тест к ней:

auth_test.go
package middleware_testimport ("fmt""go-scrum-poker-bot/web/clients""go-scrum-poker-bot/web/clients/middleware""testing""github.com/stretchr/testify/assert")func TestAuthMiddleware(t *testing.T) {token := "test"request := &clients.Request{Headers: map[string]string{},}handler := middleware.Auth(token)(func(request *clients.Request) *clients.Response {return &clients.Response{}},request,)handler(request)assert.Equal(t, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", token)}, request.Headers)}

Также в репозитории вы сможете найти middleware для логирования и установки Content-Type: application/json. Здесь я не буду приводить этот код в целях экономии времени и места :).

Давайте перепишем наш PlayPoker хэндлер:

play_poker.go
package handlersimport ("errors""go-scrum-poker-bot/ui""go-scrum-poker-bot/web/clients""go-scrum-poker-bot/web/server/models""net/http""github.com/google/uuid")func PlayPokerCommand(webClient clients.Client, uiBuilder *ui.Builder) http.Handler {return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {    // Добавим проверку, что нам пришли данные из POST Form с текстом и ID каналаif r.PostFormValue("channel_id") == "" || r.PostFormValue("text") == "" {w.Write(models.ResponseError(errors.New("Please write correct subject"))) // TODOreturn}resp := webClient.Make(&clients.Request{URL:    "https://slack.com/api/chat.postMessage",Method: "POST",      Json: uiBuilder.Build( // TODO: Напишем builder позжеr.PostFormValue("channel_id"),uuid.New().String(),r.PostFormValue("text"),nil,false,),})if resp.Error != nil {w.Write(models.ResponseError(resp.Error)) // TODOreturn}})}

И создадим в web -> server -> models . Файл errors.go для быстрого формирования ошибок:

errors.go
package modelsimport ("encoding/json""fmt")type SlackError struct {ResponseType string `json:"response_type"`Text         string `json:"text"`}func ResponseError(err error) []byte {resp, err := json.Marshal(SlackError{ResponseType: "ephemeral",Text:         fmt.Sprintf("Sorry, there is some error happened. Error: %s", err.Error()),},)if err != nil {return []byte("Sorry. Some error happened")}return resp}

Напишем тесты для хэндлера:

play_poker_test.go
package handlers_testimport ("errors""go-scrum-poker-bot/config""go-scrum-poker-bot/ui""go-scrum-poker-bot/web/server/handlers""go-scrum-poker-bot/web/server/models""net/http""net/http/httptest""net/url""strings""testing""github.com/stretchr/testify/assert")func TestPlayPokerHandler(t *testing.T) {config := config.NewConfig() // TODOmockClient := &MockClient{}uiBuilder := ui.NewBuilder(config) // TODOresponseRec := httptest.NewRecorder()router := http.NewServeMux()router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))payload := url.Values{"channel_id": {"test"}, "text": {"test"}}.Encode()request, err := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))request.Header.Set("Content-Type", "application/x-www-form-urlencoded")router.ServeHTTP(responseRec, request)assert.Nil(t, err)assert.Equal(t, http.StatusOK, responseRec.Code)assert.Empty(t, responseRec.Body.String())assert.Equal(t, true, mockClient.Called)}func TestPlayPokerHandlerEmptyBodyError(t *testing.T) {config := config.NewConfig()mockClient := &MockClient{}uiBuilder := ui.NewBuilder(config)responseRec := httptest.NewRecorder()router := http.NewServeMux()router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))payload := url.Values{}.Encode()request, _ := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))request.Header.Set("Content-Type", "application/x-www-form-urlencoded")router.ServeHTTP(responseRec, request)expected := string(models.ResponseError(errors.New("Please write correct subject")))assert.Equal(t, http.StatusOK, responseRec.Code)assert.Equal(t, expected, responseRec.Body.String())assert.Equal(t, false, mockClient.Called)}func TestPlayPokerHandlerRequestError(t *testing.T) {errMsg := "Error msg"config := config.NewConfig() // TODOmockClient := &MockClient{Error: errMsg}uiBuilder := ui.NewBuilder(config) // TODOresponseRec := httptest.NewRecorder()router := http.NewServeMux()router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))payload := url.Values{"channel_id": {"test"}, "text": {"test"}}.Encode()request, _ := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))request.Header.Set("Content-Type", "application/x-www-form-urlencoded")router.ServeHTTP(responseRec, request)expected := string(models.ResponseError(errors.New(errMsg)))assert.Equal(t, http.StatusOK, responseRec.Code)assert.Equal(t, expected, responseRec.Body.String())assert.Equal(t, true, mockClient.Called)}

Теперь нам нужно написать mock для нашего http клиента:

common_test.go
package handlers_testimport ("errors""go-scrum-poker-bot/web/clients")type MockClient struct {Called boolError  string}func (c *MockClient) Make(request *clients.Request) *clients.Response {c.Called = truevar err error = nilif c.Error != "" {err = errors.New(c.Error)}return &clients.Response{Error: err}}

Как видите, код хэндлера PlayPoker аккуратный и его просто покрывать тестами и не страшно в случае чего изменять.

Теперь можно приступить к написанию UI строителя интерфейсов для Slack UI Block Kit. Там все довольно просто, но много однотипного кода. Отмечу лишь, что Slack API мне не очень понравился и было тяжело с ним работать. Сам UI Builder можно глянуть в папке ui здесь. А здесь, в целях экономии времени, я не буду на нем заострять внимания. Отмечу лишь, что в качестве якоря для понимания того, событие от какого сообщения пришло и какой был текст для голосования (его мы не будем сохранять у себя, а будем брать непосредственно из события) будем использовать block_id. А для определения типа события будем смотреть на action_id.

Давайте создадим конфиг для нашего приложения. Идем в config и создаем:

config.go
package configtype Config struct {App   *AppSlack *SlackRedis *Redis}func NewConfig() *Config {return &Config{App: &App{ServerAddress: getStrEnv("WEB_SERVER_ADDRESS", ":8000"),PokerRanks:    getListStrEnv("POKER_RANKS", "?,0,0.5,1,2,3,5,8,13,20,40,100"),},Slack: &Slack{Token: getStrEnv("SLACK_TOKEN", "FILL_ME"),},    // Скоро понадобитсяRedis: &Redis{Host: getStrEnv("REDIS_HOST", "0.0.0.0"),Port: getIntEnv("REDIS_PORT", "6379"),DB:   getIntEnv("REDIS_DB", "0"),},}}// Получаем значение из env или выставляем defaultfunc getStrEnv(key string, defaultValue string) string {if value, ok := os.LookupEnv(key); ok {return value}return defaultValue}// Получаем int значение из env или выставляем defaultfunc getIntEnv(key string, defaultValue string) int {value, err := strconv.Atoi(getStrEnv(key, defaultValue))if err != nil {panic(fmt.Sprintf("Incorrect env value for %s", key))}return value}// Получаем список (e.g. 0,1,2,3,4,5) из env или выставляем defaultfunc getListStrEnv(key string, defaultValue string) []string {value := []string{}for _, item := range strings.Split(getStrEnv(key, defaultValue), ",") {value = append(value, strings.TrimSpace(item))}return value}

И напишем тесты к нему. Будем тестировать только публичные методы:

config_test.go
package config_testimport (    "go-scrum-poker-bot/config"    "os"    "testing"    "github.com/stretchr/testify/assert")func TestNewConfig(t *testing.T) {    c := config.NewConfig()    assert.Equal(t, "0.0.0.0", c.Redis.Host)    assert.Equal(t, 6379, c.Redis.Port)    assert.Equal(t, 0, c.Redis.DB)    assert.Equal(t, []string{"?", "0", "0.5", "1", "2", "3", "5", "8", "13", "20", "40", "100"}, c.App.PokerRanks)}func TestNewConfigIncorrectIntFromEnv(t *testing.T) {    os.Setenv("REDIS_PORT", "-")    assert.Panics(t, func() { config.NewConfig() })}

Я намеренно сделал обязательность выставления значений по умолчанию, хотя это не самый правильный путь. Изменим main.go:

main.go
package mainimport ("fmt""go-scrum-poker-bot/config""go-scrum-poker-bot/ui""go-scrum-poker-bot/web/clients"clients_middleware "go-scrum-poker-bot/web/clients/middleware""go-scrum-poker-bot/web/server"  "log""net/http""os""time")func main() {logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)config := config.NewConfig()builder := ui.NewBuilder(config)webClient := clients.NewBasicClient(&http.Client{Timeout: 5 * time.Second,},[]clients.Middleware{ // Наши middlewareclients_middleware.Auth(config.Slack.Token),clients_middleware.JsonContentType,clients_middleware.Log(logger),},)app := server.NewServer(logger,webClient,builder,)app.Serve(config.App.ServerAddress)}

Теперь при запуске команды /poker мы в ответ получим наш симпатичный минималистичный интерфейс.

Slack Interactivity

Давайте научимся реагировать на события при взаимодействии пользователя с ним. Зайдем Your apps -> Наш бот -> Interactivity & Shortcuts. В Request URL введем:

https://ffd3cfcc460c.ngrok.io/interactivity

Создадим еще один хэндлер InteractionCallback в web -> server -> handlers:

interaction_callback.go
package handlersimport ("go-scrum-poker-bot/storage""go-scrum-poker-bot/ui""go-scrum-poker-bot/ui/blocks""go-scrum-poker-bot/web/clients""go-scrum-poker-bot/web/server/models""net/http")func InteractionCallback(userStorage storage.UserStorage,sessionStorage storage.SessionStorage,uiBuilder *ui.Builder,webClient clients.Client,) http.Handler {return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {var callback models.Callback    // Об этом нижеdata, err := callback.SerializedData([]byte(r.PostFormValue("payload")))if err != nil {http.Error(w, err.Error(), http.StatusBadRequest)return}    // TODO: Скоро доберемся до нихusers := userStorage.All(data.SessionID)visible := sessionStorage.GetVisibility(data.SessionID)err = nil    // Определяем какое событие к нам поступило и реализуем немного логики исходя из негоswitch data.Action.ActionID {case ui.VOTE_ACTION_ID:users[callback.User.Username] = data.Action.SelectedOption.Valueerr = userStorage.Save(data.SessionID, callback.User.Username, data.Action.SelectedOption.Value)case ui.RESULTS_VISIBILITY_ACTION_ID:visible = !visibleerr = sessionStorage.SetVisibility(data.SessionID, visible)}if err != nil {http.Error(w, err.Error(), http.StatusInternalServerError)return}    // Шлем ответ перерисовывая интерфейс сообщения через response URL. Для пользователя все пройдет незаметноresp := webClient.Make(&clients.Request{URL:    callback.ResponseURL,Method: "POST",Json: &blocks.Interactive{ReplaceOriginal: true,Blocks:          uiBuilder.BuildBlocks(data.Subject, users, data.SessionID, visible),LinkNames:       true,},})if resp.Error != nil {http.Error(w, resp.Error.Error(), http.StatusInternalServerError)return}})}

Мы пока не определили наше хранилище. Давайте определим их интерфейсы и напишем тест на этот хэндлер. Идем в storage:

storage.go
package storagetype UserStorage interface {All(sessionID string) map[string]stringSave(sessionID string, username string, value string) error}type SessionStorage interface {GetVisibility(sessionID string) boolSetVisibility(sessionID string, state bool) error}

Я намеренно разбил логику на два хранилища, поскольку так удобнее тестировать и если будет нужно, то легко можно будет перевести например хранение голосов пользователей в базу данных, а настройки сессии оставить в Redis (как пример).

Теперь нужно создать модель Callback. Идем в web -> server -> models:

callback.go
package modelsimport ("encoding/json""errors""go-scrum-poker-bot/ui")type User struct {Username string `json:"username"`}type Text struct {Type string `json:"type"`Text string `json:"text"`}type Block struct {Type    string `json:"type"`BlockID string `json:"block_id"`Text    *Text  `json:"text,omitempty"`}type Message struct {Blocks []*Block `json:"blocks,omitempty"`}type SelectedOption struct {Value string `json:"value"`}type Action struct {BlockID        string          `json:"block_id"`ActionID       string          `json:"action_id"`Value          string          `json:"value,omitempty"`SelectedOption *SelectedOption `json:"selected_option,omitempty"`}type SerializedData struct {SessionID stringSubject   stringAction    *Action}type Callback struct {ResponseURL string    `json:"response_url"`User        *User     `json:"user"`Actions     []*Action `json:"actions"`Message     *Message  `json:"message,omitempty"`}// Грязно достаем ID сессии, но другого способа я не смог придуматьfunc (c *Callback) getSessionID() (string, error) {for _, action := range c.Actions {if action.BlockID != "" {return action.BlockID, nil}}return "", errors.New("Invalid session ID")}// Текст для голосованияfunc (c *Callback) getSubject() (string, error) {for _, block := range c.Message.Blocks {if block.BlockID == ui.SUBJECT_BLOCK_ID && block.Text != nil {return block.Text.Text, nil}}return "", errors.New("Invalid subject")}// Какое событие к нам пришлоfunc (c *Callback) getAction() (*Action, error) {for _, action := range c.Actions {if action.ActionID == ui.VOTE_ACTION_ID || action.ActionID == ui.RESULTS_VISIBILITY_ACTION_ID {return action, nil}}return nil, errors.New("Invalid action")}func (c *Callback) SerializedData(data []byte) (*SerializedData, error) {err := json.Unmarshal(data, c)if err != nil {return nil, err}sessionID, err := c.getSessionID()if err != nil {return nil, err}subject, err := c.getSubject()if err != nil {return nil, err}action, err := c.getAction()if err != nil {return nil, err}return &SerializedData{SessionID: sessionID,Subject:   subject,Action:    action,}, nil}

Давайте напишем тест на наш хэндлер:

interaction_callback_test.go
package handlers_testimport ("encoding/json""go-scrum-poker-bot/config""go-scrum-poker-bot/ui""go-scrum-poker-bot/web/server/handlers""go-scrum-poker-bot/web/server/models""net/http""net/http/httptest""net/url""strings""testing""github.com/stretchr/testify/assert")func TestInteractionCallbackHandlerActions(t *testing.T) {config := config.NewConfig()mockClient := &MockClient{}mockUserStorage := &MockUserStorage{}mockSessionStorage := &MockSessionStorage{}uiBuilder := ui.NewBuilder(config)router := http.NewServeMux()router.Handle("/interactivity",handlers.InteractionCallback(mockUserStorage, mockSessionStorage, uiBuilder, mockClient),)actions := []*models.Action{{BlockID:        "test",ActionID:       ui.RESULTS_VISIBILITY_ACTION_ID,Value:          "test",SelectedOption: nil,},{BlockID:        "test",ActionID:       ui.VOTE_ACTION_ID,Value:          "test",SelectedOption: &models.SelectedOption{Value: "1"},},}  // Проверяем на двух разных типах событийfor _, action := range actions {responseRec := httptest.NewRecorder()data, _ := json.Marshal(models.Callback{ResponseURL: "test",User:        &models.User{Username: "test"},Actions:     []*models.Action{action},Message: &models.Message{Blocks: []*models.Block{{Type:    "test",BlockID: ui.SUBJECT_BLOCK_ID,Text:    &models.Text{Type: "test", Text: "test"},},},},})payload := url.Values{"payload": {string(data)}}.Encode()request, err := http.NewRequest("POST", "/interactivity", strings.NewReader(payload))request.Header.Set("Content-Type", "application/x-www-form-urlencoded")router.ServeHTTP(responseRec, request)assert.Nil(t, err)assert.Equal(t, http.StatusOK, responseRec.Code)assert.Empty(t, responseRec.Body.String())assert.Equal(t, true, mockClient.Called)}}

Осталось определить mock для наших хранилищ. Обновим файл common_test.go:

common_test.go
// Существующий кодtype MockUserStorage struct{}func (s *MockUserStorage) All(sessionID string) map[string]string {return map[string]string{"user": "1"}}func (s *MockUserStorage) Save(sessionID string, username string, value string) error {return nil}type MockSessionStorage struct{}func (s *MockSessionStorage) GetVisibility(sessionID string) bool {return true}func (s *MockSessionStorage) SetVisibility(sessionID string, state bool) error {return nil}

Добавив в роутер новый хэндлер:

server.go
// Существующий кодfunc (s *Server) setupRouter() http.Handler {router := http.NewServeMux()router.Handle("/healthcheck",handlers.Healthcheck(),)router.Handle("/play-poker",handlers.PlayPokerCommand(s.webClient, s.uiBuilder),)router.Handle("/interactivity",handlers.InteractionCallback(s.userStorage, s.sessionStorage, s.uiBuilder, s.webClient),)return router}// Существующий код

Все хорошо, но наш сервер никак не уведомляет нас о том, что к нему поступил запрос + если мы где-то поймаем панику, то сервер может упасть. Давайте это исправим через middleware. Создаем папку web -> server -> middleware:

log.go
package middlewareimport ("log""net/http")func Log(logger *log.Logger) func(http.Handler) http.Handler {return func(next http.Handler) http.Handler {return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {defer func() {logger.Printf("Handle request: [%s]: %s - %s - %s",r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent(),)}()next.ServeHTTP(w, r)})}}

И напишем для нее тест:

log_test.go
package middleware_testimport ("bytes""go-scrum-poker-bot/web/server/middleware""log""net/http""net/http/httptest""os""strings""testing""github.com/stretchr/testify/assert")type logHandler struct{}func (h *logHandler) ServeHTTP(http.ResponseWriter, *http.Request) {}func TestLogMiddleware(t *testing.T) {var buf bytes.Bufferlogger := log.New(os.Stdout, "INFO: ", log.LstdFlags)  // Выставляем для логгера output наш буффер, чтобы все писалось в негоlogger.SetOutput(&buf)handler := &logHandler{}  // Берем mock recorder из стандартной библиотеки GoresponseRec := httptest.NewRecorder()router := http.NewServeMux()router.Handle("/test", middleware.Log(logger)(handler))request, err := http.NewRequest("GET", "/test", strings.NewReader(""))router.ServeHTTP(responseRec, request)assert.Nil(t, err)assert.Equal(t, http.StatusOK, responseRec.Code)  // Проверяем, что в буффер что-то пришло. Этого нам достаточно, чтобы понять, что middleware успешно отработалаassert.NotEmpty(t, buf.String())}

Остальные middleware можете найти здесь.

Ну и наконец слой хранения данных. Я решил взять Redis, так как это проще, да и не нужно для такого рода задач что-то большее, как мне кажется. Воспользуемся библиотекой go-redis и там же возьмем redismock для тестов.

Для начала научимся сохранять и получать всех пользователей переданной Scrum Poker сессии. Идем в storage:

users.go
package storageimport ("context""fmt""github.com/go-redis/redis/v8")// Шаблоны ключейconst SESSION_USERS_TPL = "SESSION:%s:USERS"const USER_VOTE_TPL = "SESSION:%s:USERNAME:%s:VOTE"type UserRedisStorage struct {redis   *redis.Clientcontext context.Context}func NewUserRedisStorage(redisClient *redis.Client) *UserRedisStorage {return &UserRedisStorage{redis:   redisClient,context: context.Background(),}}func (s *UserRedisStorage) All(sessionID string) map[string]string {users := make(map[string]string)  // Пользователей будем хранить в set, так как сортировка для нас не принципиальна.   // Заодно избавимся от необходимости искать дубликатыfor _, username := range s.redis.SMembers(s.context, fmt.Sprintf(SESSION_USERS_TPL, sessionID)).Val() {users[username] = s.redis.Get(s.context, fmt.Sprintf(USER_VOTE_TPL, sessionID, username)).Val()}return users}func (s *UserRedisStorage) Save(sessionID string, username string, value string) error {err := s.redis.SAdd(s.context, fmt.Sprintf(SESSION_USERS_TPL, sessionID), username).Err()if err != nil {return err}  // Голоса пользователей будем хранить в обычных ключах.   // Я сделал вечное хранение, но это легко можно поменять, изменив -1 на нужное значениеerr = s.redis.Set(s.context, fmt.Sprintf(USER_VOTE_TPL, sessionID, username), value, -1).Err()if err != nil {return err}return nil}

Напишем тесты:

users_test.go
package storage_testimport ("errors""fmt""go-scrum-poker-bot/storage""testing""github.com/go-redis/redismock/v8""github.com/stretchr/testify/assert")func TestAll(t *testing.T) {sessionID, username, value := "test", "user", "1"redisClient, mock := redismock.NewClientMock()usersStorage := storage.NewUserRedisStorage(redisClient)  // Redis mock требует обязательного указания всех ожидаемых команд и результаты их выполненияmock.ExpectSMembers(fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),).SetVal([]string{username})mock.ExpectGet(fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),).SetVal(value)assert.Equal(t, map[string]string{username: value}, usersStorage.All(sessionID))}func TestSave(t *testing.T) {sessionID, username, value := "test", "user", "1"redisClient, mock := redismock.NewClientMock()usersStorage := storage.NewUserRedisStorage(redisClient)mock.ExpectSAdd(fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),username,).SetVal(1)mock.ExpectSet(fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),value,-1,).SetVal(value)assert.Equal(t, nil, usersStorage.Save(sessionID, username, value))}func TestSaveSAddErr(t *testing.T) {sessionID, username, value, err := "test", "user", "1", errors.New("ERROR")redisClient, mock := redismock.NewClientMock()usersStorage := storage.NewUserRedisStorage(redisClient)mock.ExpectSAdd(fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),username,).SetErr(err)assert.Equal(t, err, usersStorage.Save(sessionID, username, value))}func TestSaveSetErr(t *testing.T) {sessionID, username, value, err := "test", "user", "1", errors.New("ERROR")redisClient, mock := redismock.NewClientMock()usersStorage := storage.NewUserRedisStorage(redisClient)mock.ExpectSAdd(fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),username,).SetVal(1)mock.ExpectSet(fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),value,-1,).SetErr(err)assert.Equal(t, err, usersStorage.Save(sessionID, username, value))}

Теперь определим хранилище для "покерной" сессии. Пока там будет лежать статус видимости голосов:

sessions.go
package storageimport ("context""fmt""strconv""github.com/go-redis/redis/v8")// Шаблон для ключейconst SESSION_VOTES_HIDDEN_TPL = "SESSION:%s:VOTES_HIDDEN"type SessionRedisStorage struct {redis   *redis.Clientcontext context.Context}func NewSessionRedisStorage(redisClient *redis.Client) *SessionRedisStorage {return &SessionRedisStorage{redis:   redisClient,context: context.Background(),}}func (s *SessionRedisStorage) GetVisibility(sessionID string) bool {value, _ := strconv.ParseBool(s.redis.Get(s.context, fmt.Sprintf(SESSION_VOTES_HIDDEN_TPL, sessionID)).Val(),)return value}func (s *SessionRedisStorage) SetVisibility(sessionID string, state bool) error {return s.redis.Set(s.context,fmt.Sprintf(SESSION_VOTES_HIDDEN_TPL, sessionID),strconv.FormatBool(state),-1,).Err()}

И сразу напишем тесты для только что созданных методов:

sessions_test.go
package storage_testimport ("errors""fmt""go-scrum-poker-bot/storage""strconv""testing""github.com/go-redis/redismock/v8""github.com/stretchr/testify/assert")func TestGetVisibility(t *testing.T) {sessionID, state := "test", trueredisClient, mock := redismock.NewClientMock()mock.ExpectGet(fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),).SetVal(strconv.FormatBool(state))sessionStorage := storage.NewSessionRedisStorage(redisClient)assert.Equal(t, state, sessionStorage.GetVisibility(sessionID))}func TestSetVisibility(t *testing.T) {sessionID, state := "test", trueredisClient, mock := redismock.NewClientMock()mock.ExpectSet(fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),strconv.FormatBool(state),-1,).SetVal("1")sessionStorage := storage.NewSessionRedisStorage(redisClient)assert.Equal(t, nil, sessionStorage.SetVisibility(sessionID, state))}func TestSetVisibilityErr(t *testing.T) {sessionID, state, err := "test", true, errors.New("ERROR")redisClient, mock := redismock.NewClientMock()mock.ExpectSet(fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),strconv.FormatBool(state),-1,).SetErr(err)sessionStorage := storage.NewSessionRedisStorage(redisClient)assert.Equal(t, err, sessionStorage.SetVisibility(sessionID, state))}

Отлично! Осталось изменить main.go и server.go:

server.go
package serverimport ("context""go-scrum-poker-bot/storage""go-scrum-poker-bot/ui""go-scrum-poker-bot/web/clients""go-scrum-poker-bot/web/server/handlers""log""net/http""os""os/signal""sync/atomic""time")// Новый тип для middlewaretype Middleware func(next http.Handler) http.Handler// Все зависимости здесьtype Server struct {healthy        int32middleware     []Middlewarelogger         *log.LoggerwebClient      clients.ClientuiBuilder      *ui.BuilderuserStorage    storage.UserStoragesessionStorage storage.SessionStorage}// Добавляем их при инициализации сервераfunc NewServer(logger *log.Logger,webClient clients.Client,uiBuilder *ui.Builder,userStorage storage.UserStorage,sessionStorage storage.SessionStorage,middleware []Middleware,) *Server {return &Server{logger:         logger,webClient:      webClient,uiBuilder:      uiBuilder,userStorage:    userStorage,sessionStorage: sessionStorage,middleware:     middleware,}}func (s *Server) setupRouter() http.Handler {router := http.NewServeMux()router.Handle("/healthcheck",handlers.Healthcheck(),)router.Handle("/play-poker",handlers.PlayPokerCommand(s.webClient, s.uiBuilder),)router.Handle("/interactivity",handlers.InteractionCallback(s.userStorage, s.sessionStorage, s.uiBuilder, s.webClient),)return router}func (s *Server) setupMiddleware(router http.Handler) http.Handler {handler := routerfor _, middleware := range s.middleware {handler = middleware(handler)}return handler}func (s *Server) Serve(address string) {server := &http.Server{Addr:         address,Handler:      s.setupMiddleware(s.setupRouter()),ErrorLog:     s.logger,ReadTimeout:  5 * time.Second,WriteTimeout: 10 * time.Second,IdleTimeout:  15 * time.Second,}done := make(chan bool)quit := make(chan os.Signal, 1)signal.Notify(quit, os.Interrupt)go func() {<-quits.logger.Println("Server is shutting down...")atomic.StoreInt32(&s.healthy, 0)ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)defer cancel()server.SetKeepAlivesEnabled(false)if err := server.Shutdown(ctx); err != nil {s.logger.Fatalf("Could not gracefully shutdown the server: %v\n", err)}close(done)}()s.logger.Println("Server is ready to handle requests at", address)atomic.StoreInt32(&s.healthy, 1)if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {s.logger.Fatalf("Could not listen on %s: %v\n", address, err)}<-dones.logger.Println("Server stopped")}
main.go
package mainimport ("fmt""go-scrum-poker-bot/config""go-scrum-poker-bot/storage""go-scrum-poker-bot/ui""go-scrum-poker-bot/web/clients"clients_middleware "go-scrum-poker-bot/web/clients/middleware""go-scrum-poker-bot/web/server"server_middleware "go-scrum-poker-bot/web/server/middleware""log""net/http""os""time""github.com/go-redis/redis/v8")func main() {logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)config := config.NewConfig()  // Объявляем Redis клиентredisCLI := redis.NewClient(&redis.Options{Addr: fmt.Sprintf("%s:%d", config.Redis.Host, config.Redis.Port),DB:   config.Redis.DB,})  // Наш users storageuserStorage := storage.NewUserRedisStorage(redisCLI)  // Наш sessions storagesessionStorage := storage.NewSessionRedisStorage(redisCLI)builder := ui.NewBuilder(config)webClient := clients.NewBasicClient(&http.Client{Timeout: 5 * time.Second,},[]clients.Middleware{clients_middleware.Auth(config.Slack.Token),clients_middleware.JsonContentType,clients_middleware.Log(logger),},)  // В Server теперь есть middlewareapp := server.NewServer(logger,webClient,builder,userStorage,sessionStorage,[]server.Middleware{server_middleware.Recover(logger), server_middleware.Log(logger), server_middleware.Json},)app.Serve(config.App.ServerAddress)}

Запустим тесты:

go test ./... -race -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic

Результат:

go tool cover -func coverage.txt
$ go tool cover -func coverage.txtgo-scrum-poker-bot/config/config.go:9:                                  NewConfig               100.0%go-scrum-poker-bot/config/helpers.go:10:                                getStrEnv               100.0%go-scrum-poker-bot/config/helpers.go:17:                                getIntEnv               100.0%go-scrum-poker-bot/config/helpers.go:26:                                getListStrEnv           100.0%go-scrum-poker-bot/main.go:22:                                          main                    0.0%go-scrum-poker-bot/storage/sessions.go:18:                              NewSessionRedisStorage  100.0%go-scrum-poker-bot/storage/sessions.go:25:                              GetVisibility           100.0%go-scrum-poker-bot/storage/sessions.go:33:                              SetVisibility           100.0%go-scrum-poker-bot/storage/users.go:18:                                 NewUserRedisStorage     100.0%go-scrum-poker-bot/storage/users.go:25:                                 All                     100.0%go-scrum-poker-bot/storage/users.go:34:                                 Save                    100.0%go-scrum-poker-bot/ui/blocks/action.go:9:                               BlockType               100.0%go-scrum-poker-bot/ui/blocks/button.go:11:                              BlockType               100.0%go-scrum-poker-bot/ui/blocks/context.go:9:                              BlockType               100.0%go-scrum-poker-bot/ui/blocks/section.go:9:                              BlockType               100.0%go-scrum-poker-bot/ui/blocks/select.go:10:                              BlockType               100.0%go-scrum-poker-bot/ui/builder.go:14:                                    NewBuilder              100.0%go-scrum-poker-bot/ui/builder.go:18:                                    getGetResultsText       100.0%go-scrum-poker-bot/ui/builder.go:26:                                    getResults              100.0%go-scrum-poker-bot/ui/builder.go:41:                                    getOptions              100.0%go-scrum-poker-bot/ui/builder.go:50:                                    BuildBlocks             100.0%go-scrum-poker-bot/ui/builder.go:100:                                   Build                   100.0%go-scrum-poker-bot/web/clients/client.go:22:                            NewBasicClient          100.0%go-scrum-poker-bot/web/clients/client.go:26:                            makeRequest             78.9%go-scrum-poker-bot/web/clients/client.go:65:                            Make                    66.7%go-scrum-poker-bot/web/clients/middleware/auth.go:8:                    Auth                    100.0%go-scrum-poker-bot/web/clients/middleware/json.go:5:                    JsonContentType         100.0%go-scrum-poker-bot/web/clients/middleware/log.go:8:                     Log                     87.5%go-scrum-poker-bot/web/clients/request.go:12:                           ToBytes                 100.0%go-scrum-poker-bot/web/clients/response.go:12:                          Json                    100.0%go-scrum-poker-bot/web/server/handlers/healthcheck.go:10:               Healthcheck             66.7%go-scrum-poker-bot/web/server/handlers/interaction_callback.go:12:      InteractionCallback     71.4%go-scrum-poker-bot/web/server/handlers/play_poker.go:13:                PlayPokerCommand        100.0%go-scrum-poker-bot/web/server/middleware/json.go:5:                     Json                    100.0%go-scrum-poker-bot/web/server/middleware/log.go:8:                      Log                     100.0%go-scrum-poker-bot/web/server/middleware/recover.go:9:                  Recover                 100.0%go-scrum-poker-bot/web/server/models/callback.go:52:                    getSessionID            100.0%go-scrum-poker-bot/web/server/models/callback.go:62:                    getSubject              100.0%go-scrum-poker-bot/web/server/models/callback.go:72:                    getAction               100.0%go-scrum-poker-bot/web/server/models/callback.go:82:                    SerializedData          92.3%go-scrum-poker-bot/web/server/models/errors.go:13:                      ResponseError           75.0%go-scrum-poker-bot/web/server/server.go:31:                             NewServer               0.0%go-scrum-poker-bot/web/server/server.go:49:                             setupRouter             0.0%go-scrum-poker-bot/web/server/server.go:67:                             setupMiddleware         0.0%go-scrum-poker-bot/web/server/server.go:76:                             Serve                   0.0%total:                                                                  (statements)            75.1%

Неплохо, но нам не нужно учитывать в coverage main.go (мое мнение) и server.go (здесь можно поспорить), поэтому есть хак :). Нужно добавить в начало файлов, которые мы хотим исключить из оценки следующую строчку с тегами:

//+build !test

Перезапустим с тегом:

go test ./... -race -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic -tags=test

Результат:

go tool cover -func coverage.txt
$ go tool cover -func coverage.txtgo-scrum-poker-bot/config/config.go:9:                                  NewConfig               100.0%go-scrum-poker-bot/config/helpers.go:10:                                getStrEnv               100.0%go-scrum-poker-bot/config/helpers.go:17:                                getIntEnv               100.0%go-scrum-poker-bot/config/helpers.go:26:                                getListStrEnv           100.0%go-scrum-poker-bot/storage/sessions.go:18:                              NewSessionRedisStorage  100.0%go-scrum-poker-bot/storage/sessions.go:25:                              GetVisibility           100.0%go-scrum-poker-bot/storage/sessions.go:33:                              SetVisibility           100.0%go-scrum-poker-bot/storage/users.go:18:                                 NewUserRedisStorage     100.0%go-scrum-poker-bot/storage/users.go:25:                                 All                     100.0%go-scrum-poker-bot/storage/users.go:34:                                 Save                    100.0%go-scrum-poker-bot/ui/blocks/action.go:9:                               BlockType               100.0%go-scrum-poker-bot/ui/blocks/button.go:11:                              BlockType               100.0%go-scrum-poker-bot/ui/blocks/context.go:9:                              BlockType               100.0%go-scrum-poker-bot/ui/blocks/section.go:9:                              BlockType               100.0%go-scrum-poker-bot/ui/blocks/select.go:10:                              BlockType               100.0%go-scrum-poker-bot/ui/builder.go:14:                                    NewBuilder              100.0%go-scrum-poker-bot/ui/builder.go:18:                                    getGetResultsText       100.0%go-scrum-poker-bot/ui/builder.go:26:                                    getResults              100.0%go-scrum-poker-bot/ui/builder.go:41:                                    getOptions              100.0%go-scrum-poker-bot/ui/builder.go:50:                                    BuildBlocks             100.0%go-scrum-poker-bot/ui/builder.go:100:                                   Build                   100.0%go-scrum-poker-bot/web/clients/client.go:22:                            NewBasicClient          100.0%go-scrum-poker-bot/web/clients/client.go:26:                            makeRequest             78.9%go-scrum-poker-bot/web/clients/client.go:65:                            Make                    66.7%go-scrum-poker-bot/web/clients/middleware/auth.go:8:                    Auth                    100.0%go-scrum-poker-bot/web/clients/middleware/json.go:5:                    JsonContentType         100.0%go-scrum-poker-bot/web/clients/middleware/log.go:8:                     Log                     87.5%go-scrum-poker-bot/web/clients/request.go:12:                           ToBytes                 100.0%go-scrum-poker-bot/web/clients/response.go:12:                          Json                    100.0%go-scrum-poker-bot/web/server/handlers/healthcheck.go:10:               Healthcheck             66.7%go-scrum-poker-bot/web/server/handlers/interaction_callback.go:12:      InteractionCallback     71.4%go-scrum-poker-bot/web/server/handlers/play_poker.go:13:                PlayPokerCommand        100.0%go-scrum-poker-bot/web/server/middleware/json.go:5:                     Json                    100.0%go-scrum-poker-bot/web/server/middleware/log.go:8:                      Log                     100.0%go-scrum-poker-bot/web/server/middleware/recover.go:9:                  Recover                 100.0%go-scrum-poker-bot/web/server/models/callback.go:52:                    getSessionID            100.0%go-scrum-poker-bot/web/server/models/callback.go:62:                    getSubject              100.0%go-scrum-poker-bot/web/server/models/callback.go:72:                    getAction               100.0%go-scrum-poker-bot/web/server/models/callback.go:82:                    SerializedData          92.3%go-scrum-poker-bot/web/server/models/errors.go:13:                      ResponseError           75.0%total:                                                                  (statements)            90.9%

Такой результат мне нравится больше :)

На этом пожалуй остановлюсь. Весь код можете найти здесь. Спасибо за внимание!

Подробнее..

Telegram bot ML универсальный алгоритм совмещения

31.03.2021 00:12:34 | Автор: admin

Пишу модели для кейсов на Kaggle, изучаю чужие и вдохновляюсь. Все статьи с описанием того, как внедрить их в веб-проект, для меня, школьника Junior Frontend'а, дают overhead сложной инфы, я же хочу просто "позаимствовать" любую крутую модель и быстро внедрить в свой сервис. Руки зачесались придумать универсальный алгоритм, так что решение было найдено быстро.

Приступаем. 1 шаг

Я хочу взять формат большинства моделей с Kaggle, чтобы в дальнейшем было легко заимствовать чужой код любой сложности не разбираясь в нем. Бот для телеги пишем на Python 3.9, с помощью либы pyTelegramBotAPI, для решения проблемы совместимости расширений .py и .ipynb юзаем ipynb.

И так, устанавливаем зависимости:

pip install pyTelegramBotAPIpip install ipynb

Заходим на Kaggle и выбираем понравившуюся модель. Я начну с классического кейса Titanic - Machine Learning from Disaster, заимствую это решение (Titanic Random Forest: 82.78%), перетаскиваю в проект с ботом.

Устанавливаем появившиеся зависимости:

pip install <имя>

Пилим бота. 2 шаг

Создаем новый файл и импортируем в него наши либы:

import telebotfrom ipynb.fs.defs.ml import is_user_alive

В ipynb.fs.defs.ml вместо ml впишите имя модуля, в котором находится модель, а вместо is_user_alive имя будущей функции, которую будет эта модель реализовывать. В нашем случае она предсказывает, выживет ли пассажир.

Инициализируем бота, вставляем токен (получите его через @BotFather):

bot = telebot.TeleBot('token')

Пишем хэндлер на команду /start, описываем в нем как юзать бота и формат ввода дынных. Чтобы его определить, изучите тестовый датасет и поэксперементируйте с вводом своих строк.

@bot.messagehandler(commands=['start'])def welcome(message):      bot.sendmessage(message.chat.id, 'Привет! Назови Класс билета, Имя (одним словом), '                  'Пол (male/female), Возраст, Прибыл ли он с супругом (1-да, 0-нет), '                  'с Ребенком (1-да, 0-нет), Номер билета, его Стоимость и Порт '                  'пасадки, - а я предскажу, выжил ли этот пассажир на Титанике!')

Теперь какой бы текст юзер не ввел, мы воспринимаем его как данные пассажира. Превращаем их в лист и приводим к формату тестового датасета:

@bot.messagehandler(contenttypes=['text'])def answer(message):      bot.sendmessage(message.chat.id, 'Анализируем')      passengerdata = message.text.split()      passengerdata.insert(0, 0)      passengerdata.insert(9, ',')      passengerdata[2] = '"', passengerdata[2], '"'

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

Модифицируем модель. 3 шаг

В файле модели поиском cntrl+f выделяем и удаляем все разделители на ячейки #%% кроме первого. Переносим все импорты в начало и табаем массив кода после них. Теперь оборачиваем его в нашу функцию с аргументом в виде пользовательских данных:

<импорты>def is_user_alive(user_data):  <весь остальной код>

Куда-нибудь в самое начало добавляем код, вписывающий в тестовый датасет наши данные:

with open(os.path.join('input', 'test.csv'), "a") as fp:      wr = csv.writer(fp, dialect='excel')      wr.writerow(user_data)

Predictions чаще всего содержит результат работы модели (проверьте ваш случай). Так как данные пользователя содержались в конце тестового датасета, возвращаем последнюю его строку (return пишем в конце файла):

return predictions[len(predictions)-1:]['Survived']

Допиливаем бота. 4 шаг

В функции answer создаем переменную с результатом анализа, она будет содержать 0 или 1 в странном формате. Короче, в нашем случае необходимо ответ дополнительно перевести в числовой тип:

answer = is_user_alive(passenger_data)if int(answer) == 1:      bot.sendmessage(message.chat.id, 'Везунчик! Видимо, этот пассажир успел на спасательную шлюпку.') elif int(answer) == 0:      bot.send_message(message.chat.id, 'Увы, но Титаник ваш пассажир не пережил бы.')

Создаем функцию с предложением чекнуть кого-нибудь еще, вызываем ее из answer с message аргументом:

def doagain(message):      bot.sendmessage(message.chat.id, 'Проверить живучесть кого-нибудь еще?')

Запускаем полинг:

while True:      try:            bot.polling(none_stop=True)      except ():            time.sleep(5)
Результат

Все! Довольно просто, правда?

Если не очень, можешь глянуть видео версию:

Код: https://github.com/freakssha/ml-bot-titanic

Это спидран от чешущихся рук, он не оптимизированный и его можно много где улучшить, но я пока не знаю как. Если вы понимаете, как это сделать, не теряя простоты и универсальности - напишите, прошу!

GitHub, Inst, VK

Подробнее..

Перевод Как байпасить reCaptcha V3 с помощью Selenium Python?

10.06.2021 16:07:13 | Автор: admin

*bypass - обход

Мы будем использовать библиотеку python Selenium для байпаса google reCaptcha v3. Следуйте пошаговой инструкции, чтобы получить результат.

Для примера мы будем использовать демо-версию Google reCaptcha api.

Здесь ссылка: https://www.google.com/recaptcha/api2/demo

Сначала необходимо отключить настройку защиты контента в браузере Chrome.

Для этого зайдите в Настройки в Chrome. И напишите "настройки сайта" в строке поиска.

Перейдите в настройки сайта и найдите "Защищенный контент".

Перейдите к защищенному контенту и отключите его.

Теперь перейдем к части кодирования.

В этой статье мы будем работать с Python 3. Мы будем использовать две библиотеки. Если вы хотите настроить Selenium и узнать, как это сделать - изучите эту статью: https://medium.com/@mrabdulbasit1999/selenium-with-python-web-automation-f85dfa2e58fa

Двигаемся дальше,

Установите библиотеку Beautiful Soup для скрипта.

pip install beautifulsoup4

Откройте файл-скрипт и импортируйте в него упомянутые библиотеки.

from selenium import webdriverfrom selenium.webdriver.common.keys import Keysfrom webdriver_manager.chrome import ChromeDriverManagerfrom selenium.webdriver.common.by import Byfrom http_request_randomizer.requests.proxy.requestProxy import RequestProxyimport os, sysimport time,requestsfrom bs4 import BeautifulSoup

Установите "delayTime" и "audioToTextDelay" в соответствии с вашей скоростью интернета. Установленные значения работают для всех.

delayTime = 2audioToTextDelay = 10

byPassUrl - это URL, на который вам нужно ориентироваться. Опция используется для выбора драйвера chrome, и ей передаются некоторые аргументы.

filename = 1.mp3byPassUrl = https://www.google.com/recaptcha/api2/demo'googleIBMLink = https://speech-to-text-demo.ng.bluemix.net/'option = webdriver.ChromeOptions()option.add_argument('--disable-notifications')option.add_argument("--mute-audio")

Остальная часть кода приведена ниже. Теперь я объясню, как это работает.

Когда скрипт запускается, проверяется поле I'm not a robot.

И дальше все появляется (как обычно).

После по скрипту выбирается кнопка аудио внизу слева.

И появляется вот это. После этого загружается аудио с именем "1.mp3".

Это займет несколько секунд, не волнуйтесь. После этого в браузере откроется новая вкладка, которая перейдет от речи watson к конвертеру в текст и загрузит файл.

Как видите, аудиофайл преобразуется в текст. Он копирует текст и вставляет его в текстовое поле.

И далее нажимается кнопка "Проверить".

Вот, смотрите... Проблема решена. Если у вас есть какие-либо проблемы и вопросы, пишите. Я отвечу на них как только смогу.

Код


Всех читателей нашего блога приглашаем ознакомиться с курсами по тестированию от OTUS.

- Demo Day курса "Python QA Engineer"

- Demo Day курса "Java QA Automation Engineer".

Подробнее..

Бот для автопостинга VK

27.09.2020 20:23:04 | Автор: admin
ВНИМАНИЕ: статья создана только в обучающих целях, я не призываю Вас использовать продукт полученный в конце урока для принесения неудобств или собственной выгоды


Что будем делать


Бота для автопостинга записей на стене сообщества или страницы Vk

Зачем


Для ознакомительных целей

Что нам понадобится




Начнем


Для работы нам понадобится токен с разрешениями wall и offline. Для получения токена создайте свое Standalone-приложение Vk. И сохраните его ID.

Далее перейдите по ссылке:
oauth.vk.com/authorize?client_id=IDAPP&scope=wall,offline&redirect_uri=http://api.vk.com/blank.html&response_type=token
И вместо IDAPP подставьте ID своего приложения. Или воспользуйтесь ссылкой, которую я подготовил специально для Вас.

Если все сделано правильно Вас перекинет на другой сайт, а в URL странице в GET параметре access_token будет токен, который нам и нужен, сохраняем его.

Работа XML


Для хранения настроек созданим файл формата .xml со следующем контентом:
<settings>  <token>token</token>  <textPost>Text post</textPost>  <interval>120</interval>    <post>    <attachments>      <attachment>attachment</attachment>    </attachments>    <copyright>copyright</copyright>    <v>5.122</v>  </post>    <groups>    <group>short name group</group>  </groups></settings>


Замените:
  • token на токен, который мы получили выше
  • Text post на сообщение, которое должно быть в записи
  • attachment на объект, который будет прикреплен к записи
  • copyright на ссылку источника
  • short name group на короткое имя(без vk.com) страницу сообщества/ пользователя, где будет проходить публикация(стена должна быть открытой для публикации)


Начнем писать код


Импортируем все нужные библиотеки, создадим экземпляр нашего модуля и авторизуемся во вконтакте от лица пользователя через токен.
import vk_apiimport timefrom modules import XML as moduleXmlXML = moduleXml.XML("settings")VK = vk_api.VkApi(token=XML.parsingFile("token"))


Далее получим все короткие адреса, где будут публиковаться записи.
import vk_apiimport timefrom modules import XML as moduleXmlXML = moduleXml.XML("settings")VK = vk_api.VkApi(token=XML.parsingFile("token"))groupsId = []groupsShortName = ""for child in XML.parsingFile("groups", False):    groupsShortName += child.text + ","for group in VK.method("groups.getById", {"group_ids": groupsShortName}):    groupsId.append(group["id"] * -1)del groupsShortName


Теперь получим сообщение, которое будет в записи, интервал, с которым будут публиковаться записи и источник записи.
import vk_apiimport timefrom modules import XML as moduleXmlXML = moduleXml.XML("settings")VK = vk_api.VkApi(token=XML.parsingFile("token"))groupsId = []groupsShortName = ""for child in XML.parsingFile("groups", False):    groupsShortName += child.text + ","for group in VK.method("groups.getById", {"group_ids": groupsShortName}):    groupsId.append(group["id"] * -1)del groupsShortNametextPost = XML.parsingFile("textPost")intervalPost = int(XML.parsingFile("interval"))


Теперь получим все объекты, которые будут прикреплены к записи.
import vk_apiimport timefrom modules import XML as moduleXmlXML = moduleXml.XML("settings")VK = vk_api.VkApi(token=XML.parsingFile("token"))groupsId = []groupsShortName = ""for child in XML.parsingFile("groups", False):    groupsShortName += child.text + ","for group in VK.method("groups.getById", {"group_ids": groupsShortName}):    groupsId.append(group["id"] * -1)del groupsShortNametextPost = XML.parsingFile("textPost")intervalPost = int(XML.parsingFile("interval"))attachments = [attachment.text for attachment in XML.parsingFile("attachments", False)]copyright = XML.parsingFile("copyright")v = XML.parsingFile("v")


У нас уже есть все данные, которые нам понадобятся для публикации. Осталось только сделать функцию для публикации и цикл, который будет вызывать функцию публикации.

Сначала сделаем цикл, а функцию оставим пустой. Так же код будет работать, только если будет запущен из консоли.

import vk_apiimport timefrom modules import XML as moduleXmlXML = moduleXml.XML("settings")VK = vk_api.VkApi(token=XML.parsingFile("token"))groupsId = []groupsShortName = ""for child in XML.parsingFile("groups", False):    groupsShortName += child.text + ","for group in VK.method("groups.getById", {"group_ids": groupsShortName}):    groupsId.append(group["id"] * -1)del groupsShortNametextPost = XML.parsingFile("textPost")intervalPost = int(XML.parsingFile("interval"))attachments = [attachment.text for attachment in XML.parsingFile("attachments", False)]copyright = XML.parsingFile("copyright")v = XML.parsingFile("v")done = Falsedef publicPosts():    passif __name__ == "__main__":    done = Truewhile done:    publicPosts()    time.sleep(intervalPost)


Чтобы публиковать запись будем вызвать метод API wall.post и передавать параметры получение раньше. Если все сработает правильно будет выводится соответствующее сообщение в консоль.

import vk_apiimport timefrom modules import XML as moduleXmlXML = moduleXml.XML("settings")VK = vk_api.VkApi(token=XML.parsingFile("token"))groupsId = []groupsShortName = ""for child in XML.parsingFile("groups", False):    groupsShortName += child.text + ","for group in VK.method("groups.getById", {"group_ids": groupsShortName}):    groupsId.append(group["id"] * -1)del groupsShortNametextPost = XML.parsingFile("textPost")intervalPost = int(XML.parsingFile("interval"))attachments = [attachment.text for attachment in XML.parsingFile("attachments", False)]copyright = XML.parsingFile("copyright")v = XML.parsingFile("v")done = Falsedef publicPosts():    for groupId in groupsId:        for i in range(1, 5):            result = VK.method("wall.post", {                "owner_id": groupId,                "message": textPost,                "attachments": attachments,                "copyright": copyright,                "v": v            })            if result["post_id"]:                print("Good post, id post - " + str(result["post_id"]))            else:                print("Error posting")if __name__ == "__main__":    done = Truewhile done:    publicPosts()    time.sleep(intervalPost)


Заключение


Вот и все, весь код готов. Скажу Вам сразу же мне 13 лет. И я хочу рассказать и поделиться тем, что я умею и считаю интересным для других. Так мою прошлую публикацию прочитали 2к+ человек, а 40 человек сохранили в закладки, хотя там и есть, что доработать. Это меня замотивировала, спасибо Вам большое.

Проект на gitHub.

ВНИМАНИЕ: статья создана только в обучающих целях, я не призываю Вас использовать продукт полученный в конце урока для принесения неудобств или собственной выгоды
Подробнее..

Телеграмм-бот на Python

12.04.2021 20:16:10 | Автор: admin

Недавно я попал на стажировку в новую для себя IT-компанию и наш (моей команды) проект был - бот для телеграмма, который автоматизирует часть работы hr-менеджеров. Первую неделю нам дали на самостоятельное изучение всего, что мы посчитаем нужным (а я убежден, что лучший способ что-то изучить - это практика), так что я начал действовать. Язык программирования был выбран python (наверное понятно из обложки почему), так что в этой статьи я разберу пример именно с ним.

BotFather

Чтобы создать телеграмм-бота, достаточно написать пользователю @BotFather команду /newbot. Он запросит название и @username для будущего бота. Тут ничего сложного - он все подсказывает (главное, чтобы @username был не занят и заканчивался на "bot"). BotFather пришлет HTTP API токен, который мы и будем использовать для работы с ботом.

Создание ботаСоздание бота

Telebot и сила python

Мне всегда казалось, что создавать бота - это не так просто. Честно говоря, давно хотел попробовать, но то ли не хватало времени (думал, что это займет не один вечер), то ли не мог выбрать технологию (как-то смотрел туториал для c#), ну а скорее всего было просто лень. Но тут мне понадобилось это для работы, так что я больше не смел откладывать.

Сила python заключается в его популярности. А, как следствие, в появлении огромного количества сторонних библиотек практически под все нужды. Именно это сделало возможным написание примитивного бота (который просто отвечает однотипно на сообщения) в 6 (ШЕСТЬ!) строчек кода. А именно:

import telebotbot = telebot.TeleBot('1111105161:AAHIjyAKY4fj62whM5vEAfotuixC5syA-j8')@bot.message_handler(commands=['start'])def start_command(message):    bot.send_message(message.chat.id, "Hello!")bot.polling()
Первое сообщениеПервое сообщение

На самом деле бот будет отвечать только на команду /start, но для начала неплохо. Здесь все довольно просто: в первой строчке импортируется библиотека telebot (для ее работы необходимо установить пакет pyTelegramBotAPI командой pip install pyTelegramBotAPI (НЕ pip install telebot!), далее создаем объекта бот, используя токен, который нам прислал BotFather. Третья строчка проверяет, что присылают пользователи (в данном случае это только команда /start), и, если проверка пройдена, то бот отправляет ответ с текстом Hello!. Последняя строчка, наверное, самая сложная для понимания, и в следующих разделах я ее подробно разберу. Сейчас же я только скажу о ее предназначении - она заставляет бота работать, то есть "реагировать" на полученные сообщения.

Flask & Requests

Telebot, конечно, круто, но есть одно важное НО. По предположению нашего проекта, у hr-ов должен быть сервис (сайт), где они будут работать и через него отправлять/получать информацию пользователям/от них. Соответственно, нам нужно самим контролировать сервер и обрабатывать запросы. На мой взгляд самый простой способ создания сервера на python - фреймворк flask. Так выглядит простейший сервер, запускаемый локально на 5000-ом порту (http://localhost:5000/):

from flask import Flask app = Flask(__name__)@app.route("/", methods=["GET"])def index():    return "Hello, World!"  if __name__ == "__main__":    app.run()

Для работы бота нужно немного больше, а именно нужно добавить функцию отправки сообщений. Я не хочу полностью переписывать статью (habr), а воспользуюсь результатом и пойду с конца. Так выглядит программа, которая заставляет бота посылать Hello! на любое входящее сообщение:

from flask import Flask, requestimport requestsapp = Flask(__name__)def send_message(chat_id, text):    method = "sendMessage"    token = "1111105161:AAHIjyAKY4fj62whM5vEAfotuixC5syA-j8"    url = f"https://api.telegram.org/bot{token}/{method}"    data = {"chat_id": chat_id, "text": text}    requests.post(url, data=data)@app.route("/", methods=["POST"])def receive_update():    chat_id = request.json["message"]["chat"]["id"]    send_message(chat_id, "Hello!")    return "ok"if __name__ == "__main__":    app.run()

К сожалению, в таком варианте программа работать не будет. Точнее будет, но не сразу. Проблема заключается в том, что телеграмм пока что не знает, куда посылать информацию о полученных сообщениях. Для ее решения у telegram API есть метод setWebhook. Суть метода заключается в том, что мы просто отправляем телеграмму url, по которому мы хотим получать информацию о новых обращениях к боту (в нашем случае это http://localhost:5000/). Однако, мы не можем просто сказать телеграмму: "Посылай запросы на localhost", ведь для каждого сервера localhost будет разным. Еще одна проблема заключается в том, что метод setWebhook поддерживает только https url-ы. Для решения этих проблем можно воспользоваться программой ngrok, которая строит туннель до локального хоста. Скачать ее можно по ссылке ngrok, а для запуска туннеля достаточно ввести команду ngrok http 5000. Должно получиться так:

ngrokngrok

Теперь можно задействовать метод setWebhook, например, через postman. Нужно отправить post запрос на https://api.telegram.org/bot<ТОКЕН>/setWebhook с указанием в теле нужного url. Должно получиться аналогично:

setWebhooksetWebhook

Соединение

Чем больше я работал с библиотекой telebot, тем больше она мне нравилась. Хотелось бы, используя приложение на flaske, не терять эту возможность. Но как это сделать? Во-первых, мы можем вместо нашей функции send_message использовать готовую из библиотеки. Это будет выглядеть так:

from flask import Flask, requestimport telebotapp = Flask(__name__) bot = telebot.TeleBot('1111105161:AAHIjyAKY4fj62whM5vEAfotuixC5syA-j8')@app.route("/", methods=["POST"])def receive_update():    chat_id = request.json["message"]["chat"]["id"]    bot.send_message(chat_id, "Hello!")    return "ok"if __name__ == "__main__":    app.run()

Но, если присмотреться, можно заметить, что мы потеряли часть функционала, а именно @bot.message_handler - декораторы, которые отслеживают тип введенного боту сообщения (картинка, документ, текст, команда и т. д.). Получается, что если мы используем в качестве сервера наше flask приложение, то мы теряем некоторый функционал библиотеки telebot. Если же мы используем bot.polling(), то мы не можем обращаться к серверу со стороны. Конечно, хотелось бы как-то все соединить без потерь. Для этого я нашел немного костыльный способ, однако рабочий:

from flask import Flask, requestimport telebotbot = telebot.TeleBot('1111105161:AAHIjyAKY4fj62whM5vEAfotuixC5syA-j8')bot.set_webhook(url="http://personeltest.ru/aways/8c6f687b75c9.ngrok.io")app = Flask(__name__)@app.route('/', methods=["POST"])def webhook():    bot.process_new_updates(        [telebot.types.Update.de_json(request.stream.read().decode("utf-8"))]    )    return "ok"@bot.message_handler(commands=['start'])def start_command(message):    bot.send_message(message.chat.id, 'Hello!')if __name__ == "__main__":    app.run()

Здесь мы пользуемся методом set_webhook, аналогично тому, как мы делали это ранее через postman, а на пустом роуте прописываем "немного магии", чтобы успешно получать обновления бота. Конечно, это не очень хороший способ, и в дальнейшем лучше самостоятельно прописывать функционал для обработки входящих сообщений. Но для начала, я считаю, это лучшее решение.

Заключение

Написать телеграмм-бота на python и правда не так уж сложно, однако есть и подводные камни, о которых я постарался рассказать в данной статье. Конечно, это только начало, но последний фрагмент кода вполне можно использовать, как шаблон для серверной части, которая будет работать с телеграмм-ботом. Дальше остается только наращивать функционал по Вашему усмотрению.

Подробнее..
Категории: Python , Bot , Telegram , Flask

Как я сделал Discord бота для игровой гильдии с помощью .NET Core

05.06.2021 18:10:05 | Автор: admin
Батрак предупреждает о том что к гильдии присоединился игрокБатрак предупреждает о том что к гильдии присоединился игрок

Вступление

Всем привет! Недавно я написал Discord бота для World of Warcraft гильдии. Он регулярно забирает данные об игроках с серверов игры и пишет сообщения в Discord о том что к гильдии присоединился новый игрок или о том что гильдию покинул старый игрок. Между собой мы прозвали этого бота Батрак.

В этой статье я решил поделиться опытом и рассказать как сделать такой проект. По сути мы будем реализовывать микросервис на .NET Core: напишем логику, проведем интеграцию с api сторонних сервисов, покроем тестами, упакуем в Docker и разместим в Heroku. Кроме этого я покажу как реализовать continuous integration с помощью Github Actions.

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

Для понимания материала, от вас ожидается хотя бы минимальный опыт создания веб сервисов с помощью фреймворка ASP.NET и небольшой опыт работы с Docker.

План

На каждом шаге будем постепенно наращивать функционал.

  1. Создадим новый web api проект с одним контроллером /check. При обращении к этому адресу будем отправлять строку Hello! в Discord чат.

  2. Научимся получать данные о составе гильдии с помощью готовой библиотеки или заглушки.

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

  4. Напишем Dockerfile для нашего проекта и разместим проект на хостинге Heroku.

  5. Посмотрим на несколько способов сделать периодическое выполнение кода.

  6. Реализуем автоматическую сборку, запуск тестов и публикацию проекта после каждого коммита в master

Шаг 1. Отправляем сообщение в Discord

Нам потребуется создать новый ASP.NET Core Web API проект.

Создание нового проекта - это одна из фундаментальных вещей которые я не буду подробно расписывать. При работе над проектом используйте сервис Github для хранения кода. В дальнейшем мы воспользуемся несколькими возможностями Github.

Добавим к проекту новый контроллер

[ApiController]public class GuildController : ControllerBase{    [HttpGet("/check")]    public async Task<IActionResult> Check(CancellationToken ct)    {        return Ok();    }}

Затем нам понадобится webhook от вашего Discord сервера. Webhook - это механизм отправки событий. В данном случае, то это адрес к которому можно слать простые http запросы с сообщениями внутри.

Получить его можно в пункте integrations в настройках любого текстового канала вашего Discord сервера.

Создание webhookСоздание webhook

Добавим webhook в appsettings.json нашего проекта. Позже мы унесем его в переменные окружения Heroku. Если вы не знакомы с тем как работать с конфигурацией в ASP Core проектах предварительно изучите эту тему.

{"DiscordWebhook":"https://discord.com/api/webhooks/****/***"}

Теперь создадим новый сервис DiscordBroker, который умеет отправлять сообщения в Discord. Создайте папку Services и поместите туда новый класс, эта папка нам еще пригодится.

По сути этот новый сервис делает post запрос по адресу из webhook и содержит сообщение в теле запроса.

public class DiscordBroker : IDiscordBroker{    private readonly string _webhook;    private readonly HttpClient _client;    public DiscordBroker(IHttpClientFactory clientFactory, IConfiguration configuration)    {        _client = clientFactory.CreateClient();        _webhook = configuration["DiscordWebhook"];    }    public async Task SendMessage(string message, CancellationToken ct)    {        var request = new HttpRequestMessage        {            Method = HttpMethod.Post,            RequestUri = new Uri(_webhook),            Content = new FormUrlEncodedContent(new[] {new KeyValuePair<string, string>("content", message)})        };        await _client.SendAsync(request, ct);    }}

Как видите, мы используем внедрение зависимостей. IConfiguration позволит нам достать webhook из конфигов, а IHttpClientFactory создать новый HttpClient.

Кроме того, я извлек интерфейс этого класса, чтобы в дальнейшем можно было сделать его заглушку при тестировании. Делайте это для всех сервисов которые мы будем создавать далее.

Не забудьте что новый класс нужно будет зарегистрировать в Startup.

services.AddScoped<IDiscordBroker, DiscordBroker>();

А также нужно будет зарегистрировать HttpClient, для работы IHttpClientFactory.

services.AddHttpClient();

Теперь можно воспользоваться новым классом в контроллере.

private readonly IDiscordBroker _discordBroker;public GuildController(IDiscordBroker discordBroker){  _discordBroker = discordBroker;}[HttpGet("/check")]public async Task<IActionResult> Check(CancellationToken ct){  await _discordBroker.SendMessage("Hello", ct);  return Ok();}

Запустите проект, зайдите по адресу /check в браузере и убедитесь что в Discord пришло новое сообщение.

Шаг 2. Получаем данные из Battle.net

У нас есть два варианта: получать данные из настоящих серверов battle.net или из моей заглушки. Если у вас нет аккаунта в battle.net, то пропустите следующий кусок статьи до момента где приводится реализация заглушки.

Получаем реальные данные

Вам понадобится зайти на https://develop.battle.net/ и получить там две персональных строки BattleNetId и BattleNetSecret. Они будут нужны нам чтобы авторизоваться в api перед отправкой запросов. Поместите их в appsettings.

Подключим к проекту библиотеку ArgentPonyWarcraftClient.

Создадим новый класс BattleNetApiClient в папке Services.

public class BattleNetApiClient{   private readonly string _guildName;   private readonly string _realmName;   private readonly IWarcraftClient _warcraftClient;   public BattleNetApiClient(IHttpClientFactory clientFactory, IConfiguration configuration)   {       _warcraftClient = new WarcraftClient(           configuration["BattleNetId"],           configuration["BattleNetSecret"],           Region.Europe,           Locale.ru_RU,           clientFactory.CreateClient()       );       _realmName = configuration["RealmName"];       _guildName = configuration["GuildName"];   }}

В конструкторе мы создаем новый экземпляр класса WarcraftClient.
Этот класс относится к библиотеке, которую мы установили ранее. С его помощью можно получать данные об игроках.

Кроме этого, нужно создать в appsettings проекта две новых записи RealmName и GuildName. RealmName это название игрового мира, а GuildName это название гильдии. Их будем использовать как параметры при запросе.

Сделаем метод GetGuildMembers чтобы получать состав гильдии и создадим модель WowCharacterToken которая будет представлять собой информацию об игроке.

public async Task<WowCharacterToken[]> GetGuildMembers(){   var roster = await _warcraftClient.GetGuildRosterAsync(_realmName, _guildName, "profile-eu");   if (!roster.Success) throw new ApplicationException("get roster failed");   return roster.Value.Members.Select(x => new WowCharacterToken   {       WowId = x.Character.Id,       Name = x.Character.Name   }).ToArray();}
public class WowCharacterToken{  public int WowId { get; set; }  public string Name { get; set; }}

Класс WowCharacterToken следует поместить в папку Models.

Не забудьте подключить BattleNetApiClient в Startup.

services.AddScoped<IBattleNetApiClient, BattleNetApiClient>();

Берем данные из заглушки

Для начала создадим модель WowCharacterToken и поместим ее в папку Models. Она представляет собой информацию об игроке.

public class WowCharacterToken{  public int WowId { get; set; }  public string Name { get; set; }}

Дальше сделаем вот такой класс

public class BattleNetApiClient{    private bool _firstTime = true;    public Task<WowCharacterToken[]> GetGuildMembers()    {        if (_firstTime)        {            _firstTime = false;            return Task.FromResult(new[]            {                new WowCharacterToken                {                    WowId = 1,                    Name = "Артас"                },                new WowCharacterToken                {                    WowId = 2,                    Name = "Сильвана"                }            });        }        return Task.FromResult(new[]        {            new WowCharacterToken            {                WowId = 1,                Name = "Артас"            },            new WowCharacterToken            {                WowId = 3,                Name = "Непобедимый"            }        });    }}

Он возвращает зашитый в него список игроков. При первом вызове метода мы вернем один список, при последующих другой. Это нужно нам что смоделировать изменчивое поведение api. Этой заглушки хватит чтобы продолжить делать проект.

Сделайте интерфейс и подключите все что мы создали в Startup.

services.AddScoped<IBattleNetApiClient, BattleNetApiClient>();

Выведем результаты в Discord

После того как мы сделали BattleNetApiClient, им можно воспользоваться в контроллере чтобы вывести кол-во игроков в Discord.

[ApiController]public class GuildController : ControllerBase{  private readonly IDiscordBroker _discordBroker;  private readonly IBattleNetApiClient _battleNetApiClient;  public GuildController(IDiscordBroker discordBroker, IBattleNetApiClient battleNetApiClient)  {     _discordBroker = discordBroker;     _battleNetApiClient = battleNetApiClient;  }  [HttpGet("/check")]  public async Task<IActionResult> Check(CancellationToken ct)  {     var members = await _battleNetApiClient.GetGuildMembers();     await _discordBroker.SendMessage($"Members count: {members.Length}", ct);     return Ok();  }}

Шаг 3. Находим новых и ушедших игроков

Нужно научиться определять какие игроки появились или пропали из списка при последующих запросах к api. Для этого мы можем закэшировать список в InMemory кэше (в оперативной памяти) или во внешнем хранилище.

Если закэшировать список в InMemory кэше, то мы потеряем его при перезапуске приложения. Поэтому позже мы подключим базу данных Redis как аддон в Heroku и будем кешировать туда.

А пока что подключим InMemory кэш в Startup.

services.AddMemoryCache(); 

Теперь в нашем распоряжении есть IDistributedCache, который можно подключить через конструктор. Я предпочел не использовать его напрямую , а написать для него обертку. Создайте класс GuildRepository и поместите его в новую папку Repositories.

public class GuildRepository : IGuildRepository{    private readonly IDistributedCache _cache;    private const string Key = "wowcharacters";    public GuildRepository(IDistributedCache cache)    {        _cache = cache;    }    public async Task<WowCharacterToken[]> GetCharacters(CancellationToken ct)    {        var value = await _cache.GetAsync(Key, ct);        if (value == null) return Array.Empty<WowCharacterToken>();        return await Deserialize(value);    }    public async Task SaveCharacters(WowCharacterToken[] characters, CancellationToken ct)    {        var value = await Serialize(characters);        await _cache.SetAsync(Key, value, ct);    }        private static async Task<byte[]> Serialize(WowCharacterToken[] tokens)    {        var binaryFormatter = new BinaryFormatter();        await using var memoryStream = new MemoryStream();        binaryFormatter.Serialize(memoryStream, tokens);        return memoryStream.ToArray();    }    private static async Task<WowCharacterToken[]> Deserialize(byte[] bytes)    {        await using var memoryStream = new MemoryStream();        var binaryFormatter = new BinaryFormatter();        memoryStream.Write(bytes, 0, bytes.Length);        memoryStream.Seek(0, SeekOrigin.Begin);        return (WowCharacterToken[]) binaryFormatter.Deserialize(memoryStream);    }}

Теперь можно написать сервис который будет сравнивать новый список игроков с сохраненным.

public class GuildService{    private readonly IBattleNetApiClient _battleNetApiClient;    private readonly IGuildRepository _repository;    public GuildService(IBattleNetApiClient battleNetApiClient, IGuildRepository repository)    {        _battleNetApiClient = battleNetApiClient;        _repository = repository;    }    public async Task<Report> Check(CancellationToken ct)    {        var newCharacters = await _battleNetApiClient.GetGuildMembers();        var savedCharacters = await _repository.GetCharacters(ct);        await _repository.SaveCharacters(newCharacters, ct);        if (!savedCharacters.Any())            return new Report            {                JoinedMembers = Array.Empty<WowCharacterToken>(),                DepartedMembers = Array.Empty<WowCharacterToken>(),                TotalCount = newCharacters.Length            };        var joined = newCharacters.Where(x => savedCharacters.All(y => y.WowId != x.WowId)).ToArray();        var departed = savedCharacters.Where(x => newCharacters.All(y => y.Name != x.Name)).ToArray();        return new Report        {            JoinedMembers = joined,            DepartedMembers = departed,            TotalCount = newCharacters.Length        };    }}

В качестве возвращаемого результата используется модель Report. Ее нужно создать и поместить в папку Models.

public class Report{   public WowCharacterToken[] JoinedMembers { get; set; }   public WowCharacterToken[] DepartedMembers { get; set; }   public int TotalCount { get; set; }}

Применим GuildService в контроллере.

[HttpGet("/check")]public async Task<IActionResult> Check(CancellationToken ct){   var report = await _guildService.Check(ct);   return new JsonResult(report, new JsonSerializerOptions   {      Encoder = JavaScriptEncoder.Create(UnicodeRanges.BasicLatin, UnicodeRanges.Cyrillic)   });}

Теперь отправим в Discord какие игроки присоединились или покинули гильдию.

if (joined.Any() || departed.Any()){   foreach (var c in joined)      await _discordBroker.SendMessage(         $":smile: **{c.Name}** присоединился к гильдии",         ct);   foreach (var c in departed)      await _discordBroker.SendMessage(         $":smile: **{c.Name}** покинул гильдию",         ct);}

Эту логику я добавил в GuildService в конец метода Check. Писать бизнес логику в контроллере не стоит, у него другое назначение. В самом начале мы делали там отправку сообщения в Discord потому что еще не существовало GuildService.

На первом скриншоте в статье вы видели что я вывел больше информации об игроке. Ее можно получить если воспользоваться библиотекой ArgentPonyWarcraftClient

await _warcraftClient.GetCharacterProfileSummaryAsync(_realmName, name.ToLower(), Namespace);

Я решил не добавлять в статью больше кода в BattleNetApiClient, чтобы статья не разрослась до безумных размеров.

Unit тесты

У нас появился класс GuildService с нетривиальной логикой, который будет изменяться и расширяться в будущем. Стоит написать на него тесты. Для этого нужно будет сделать заглушки для BattleNetApiClient, GuildRepository и DiscordBroker. Я специально просил создавать интерфейсы для этих классов чтобы можно было сделать их фейки.

Создайте новый проект для Unit тестов. Заведите в нем папку Fakes и сделайте три фейка.

public class DiscordBrokerFake : IDiscordBroker{   public List<string> SentMessages { get; } = new();   public Task SendMessage(string message, CancellationToken ct)   {      SentMessages.Add(message);      return Task.CompletedTask;   }}
public class GuildRepositoryFake : IGuildRepository{    public List<WowCharacterToken> Characters { get; } = new();    public Task<WowCharacterToken[]> GetCharacters(CancellationToken ct)    {        return Task.FromResult(Characters.ToArray());    }    public Task SaveCharacters(WowCharacterToken[] characters, CancellationToken ct)    {        Characters.Clear();        Characters.AddRange(characters);        return Task.CompletedTask;    }}
public class BattleNetApiClientFake : IBattleNetApiClient{   public List<WowCharacterToken> GuildMembers { get; } = new();   public List<WowCharacter> Characters { get; } = new();   public Task<WowCharacterToken[]> GetGuildMembers()   {      return Task.FromResult(GuildMembers.ToArray());   }}

Эти фейки позволяют заранее задать возвращаемое значение для методов. Для этих же целей можно использовать популярную библиотеку Moq. Но для нашего простого примера достаточно самодельных фейков.

Первый тест на GuildService будет выглядеть так:

[Test]public async Task SaveNewMembers_WhenCacheIsEmpty(){   var wowCharacterToken = new WowCharacterToken   {      WowId = 100,      Name = "Sam"   };      var battleNetApiClient = new BattleNetApiApiClientFake();   battleNetApiClient.GuildMembers.Add(wowCharacterToken);   var guildRepositoryFake = new GuildRepositoryFake();   var guildService = new GuildService(battleNetApiClient, null, guildRepositoryFake);   var changes = await guildService.Check(CancellationToken.None);   changes.JoinedMembers.Length.Should().Be(0);   changes.DepartedMembers.Length.Should().Be(0);   changes.TotalCount.Should().Be(1);   guildRepositoryFake.Characters.Should().BeEquivalentTo(wowCharacterToken);}

Как видно из названия, тест позволяет проверить что мы сохраним список игроков, если кэш пуст. Заметьте, в конце теста используется специальный набор методов Should, Be... Это методы из библиотеки FluentAssertions, которые помогают нам сделать Assertion более читабельным.

Теперь у нас есть база для написания тестов. Я показал вам основную идею, дальнейшее написание тестов оставляю вам.

Главный функционал проекта готов. Теперь можно подумать о его публикации.

Шаг 4. Привет Docker и Heroku!

Мы будем размещать проект на платформе Heroku. Heroku не позволяет запускать .NET проекты из коробки, но она позволяет запускать Docker образы.

Чтобы упаковать проект в Docker нам понадобится создать в корне репозитория Dockerfile со следующим содержимым

FROM mcr.microsoft.com/dotnet/sdk:5.0 AS builderWORKDIR /sourcesCOPY *.sln .COPY ./src/peon.csproj ./src/COPY ./tests/tests.csproj ./tests/RUN dotnet restoreCOPY . .RUN dotnet publish --output /app/ --configuration ReleaseFROM mcr.microsoft.com/dotnet/core/aspnet:3.1WORKDIR /appCOPY --from=builder /app .CMD ["dotnet", "peon.dll"]

peon.dll это название моего Solution. Peon переводится как батрак.

О том как работать с Docker и Heroku можно прочитать здесь. Но я все же опишу последовательность действий.

Вам понадобится создать аккаунт в Heroku, установить Heroku CLI.

Создайте новый проект в heroku и свяжите его с вашим репозиторием.

heroku git:remote -a project_name

Теперь нам необходимо создать файл heroku.yml в папке с проектом. У него будет такое содержимое:

build:  docker:    web: Dockerfile

Дальше выполним небольшую череду команд:

# Залогинимся в heroku registryheroku container:login# Соберем и запушим образ в registryheroku container:push web# Зарелизим приложение из образаheroku container:release web

Можете открыть приложение в браузере с помощью команды:

heroku open

После того как мы разместили приложение в Heroku, нужно подключить базу данных Redis для кэша. Как вы помните InMemory кэш будет исчезать после перезапуска приложения.

Установите для нашего Heroku приложения бесплатный аддон RedisCloud.

Строку подключения для Redis можно будет получить через переменную окружения REDISCLOUD_URL. Она будет доступна, когда приложение будет запущено в экосистеме Heroku.

Нам нужно получить эту переменную в коде приложения.

Установите библиотеку Microsoft.Extensions.Caching.StackExchangeRedis.

С помощью нее можно зарегистрировать Redis реализацию для IDistributedCache в Startup.

services.AddStackExchangeRedisCache(o =>{   o.InstanceName = "PeonCache";   var redisCloudUrl = Environment.GetEnvironmentVariable("REDISCLOUD_URL");   if (string.IsNullOrEmpty(redisCloudUrl))   {      throw new ApplicationException("redis connection string was not found");   }   var (endpoint, password) = RedisUtils.ParseConnectionString(redisCloudUrl);   o.ConfigurationOptions = new ConfigurationOptions   {      EndPoints = {endpoint},      Password = password   };});

В этом коде мы получили переменную REDISCLOUD_URL из переменных окружения системы. После этого мы извлекли адрес и пароль базы данных с помощью класса RedisUtils. Его написал я сам:

public static class RedisUtils{   public static (string endpoint, string password) ParseConnectionString(string connectionString)   {      var bodyPart = connectionString.Split("://")[1];      var authPart = bodyPart.Split("@")[0];      var password = authPart.Split(":")[1];      var endpoint = bodyPart.Split("@")[1];      return (endpoint, password);   }}

На этот класс можно сделать простой Unit тест.

[Test]public void ParseConnectionString(){   const string example = "redis://user:password@url:port";   var (endpoint, password) = RedisUtils.ParseConnectionString(example);   endpoint.Should().Be("url:port");   password.Should().Be("password");}

После того что мы сделали, GuildRepository будет сохранять кэш не в оперативную память, а в Redis. Нам даже не нужно ничего менять в коде приложения.

Опубликуйте новую версию приложения.

Шаг 5. Реализуем циклическое выполнение

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

Есть несколько способов это реализовать:

Самый простой способ - это сделать задание на сайте https://cron-job.org. Этот сервис будет слать get запрос на /check вашего приложения каждые N минут.

Второй способ - это использовать Hosted Services. В этой статье подробно описано как создать повторяющееся задание в ASP.NET Core проекте. Учтите, бесплатный тариф в Heroku подразумевает что ваше приложение будет засыпать после того как к нему некоторое время не делали запросов. Hosted Service перестанет работать после того как приложение заснет. В этом варианте вам следует перейти на платный тариф. Кстати, так сейчас работает мой бот.

Третий способ - это подключить к проекту специальные Cron аддоны. Например Heroku Scheduler. Можете пойти этим путем и разобраться как создать cron job в Heroku.

Шаг 6. Автоматическая сборка, прогон тестов и публикация

Во-первых, зайдите в настройки приложения в Heroku.

Там есть пункт Deploy. Подключите там свой Github аккаунт и включите Automatic deploys после каждого коммита в master.

Поставьте галочку у пункта Wait for CI to pass before deploy. Нам нужно чтобы Heroku дожидался сборки и прогонки тестов. Если тесты покраснеют, то публикация не случится.

Сделаем сборку и прогонку тестов в Github Actions.

Зайдите в репозиторий и перейдите в пункт Actions. Теперь создайте новый workflow на основе шаблона .NET

В репозитории появится новый файл dotnet.yml. Он описывает процесс сборки.

Как видите по его содержимому, задание build будет запускаться после пуша в ветку master.

on:  push:    branches: [ master ]  pull_request:    branches: [ master ]

Содержимое самого задания нас полностью устраивает. Если вы вчитаетесь в то что там происходит, то увидите что там происходит запуск команд dotnet build и dotnet test.

    steps:    - uses: actions/checkout@v2    - name: Setup .NET      uses: actions/setup-dotnet@v1      with:        dotnet-version: 5.0.x    - name: Restore dependencies      run: dotnet restore    - name: Build      run: dotnet build --no-restore    - name: Test      run: dotnet test --no-build --verbosity normal

Менять в этом файле ничего не нужно, все уже будет работать из коробки.

Запуште что-нибудь в master и посмотрите что задание запускается. Кстати, оно уже должно было запуститься после создания нового workflow.

Отлично! Вот мы и сделали микросервис на .NET Core который собирается и публикуется в Heroku. У проекта есть множество точек для развития: можно было бы добавить логирование, прокачать тесты, повесить метрики и. т. д.

Надеюсь данная статья подкинула вам пару новых идей и тем для изучения. Спасибо за внимание. Удачи вам в ваших проектах!

Подробнее..
Категории: C , Net , Api , Docker , Dotnet , Discord , Bot , Бот , Heroku , Микросервис , Wow

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru