Попытка создать java Framework для телеграм ботов

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

У меня иногда появлялось желаение делать ботов для телеграм, так мой основной язык Java - выбор не велик и он меня не устраивает. Каждый раз нужно было придумывать какие-то схемы обработки приходящих апдейтов и мучаться с этим всем. Либо был другой выбор - всякие непонятные Abilities / Replies, по которым нет информации нигде, а еще они используют внутри свою странную БД.

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

Все это построено на Spring и работает как простая зависимость.

Демонстрация

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

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

#Включить управление состояниями
slyrack.enableStateManagement=true 
#Включить управление сессиями
slyrack.enableSessionManagement=true 
#Задать время жизни сессии
slyrack.sessionTtlMillis=600000 

Создадим бота:

@Component
@AllArgsConstructor
public class Bot extends TelegramLongPollingBot {
    /*
     * Основной компонент фреймворка, куда нужно передавать все апдейты
     */
    private final UpdateHandler updateHandler;
    @Override
    public String getBotUsername() {
        return "name";
    }

    @Override
    public String getBotToken() {
        return "token";
    }

    @SneakyThrows
    @Override
    public void onUpdateReceived(final Update update) {
        updateHandler.handleUpdate(update, this);
    }
}

Первая команда

Комманда это метод, который аннотирован @Command Данный метод должен находится в классе аннотированом @Controller Аннотация принимает параметры:

  • UpdateType[] value - типы апдейтов, на которые будет реагировать комманда. Это enum, который содержит некоторые типы апдейтов.

  • String[] state - состояния, при которых будет срабатывать комманда.

  • boolean exclusive - значит то что если команд-кандидатов на исполнение будет найдено несколько - будет выполнена только команда которая содержит в данном поле true

Метод-команда может возвращать void / ModelAndView / StatefulModelAndView на выбор. Метод-команда может принимать некоторые параметры на выбор:

  • Update

  • AbsSender

  • Session - если включено управление сессиями, если нет - null

  • Model - если включено управление состояниями и если было предшествующее состояние, в остальных случаях - null

  • Любое кол-во параметров любого типа с аннотацией SessionAtr, если такой не найден - null

  • Любое кол-во параметров любого типа с аннотацией ModelAtr, если не было предшествующего состояние или нету такого атрибута - null

Создадим первую команду, которая будет отвечать на любое сообщение и возвращает некоторое состояние и название view.

@Controller
public class InitController {
    @Command(value = UpdateType.MESSAGE)
    public ModelAndView start() {
        return new StatefulModelAndView(
                "subject-select-state",
                "select-subject-view"
        );
    }
}

Первый view

View это метод, который аннотирован @View Данный метод должен находится в классе аннотированом @Controller Аннотация @View принимает 1 параметр - название view.

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

Создадим первый view с названием select-subject-view:

@ViewController
public class InitViews {
    private static final String SELECT_SUBJECT = "Привествуем. Выберите тему вопроса.";
    
    @SneakyThrows
    @View("select-subject-view")
    public void selectSubject(final AbsSender absSender,
                              @SessionAtr("chat-id") final String chatId) {

        absSender.execute(SendMessage.builder()
                .text(SELECT_SUBJECT)
                .chatId(chatId)
                .replyMarkup(InlineKeyboardMarkup.builder()
                        .keyboardRow(List.of(InlineKeyboardButton.builder()
                                .text("Оплата")
                                .callbackData("Оплата")
                                .build()))
                        .keyboardRow(List.of(InlineKeyboardButton.builder()
                                .text("Тарифы")
                                .callbackData("Тарифы")
                                .build()))
                        .keyboardRow(List.of(InlineKeyboardButton.builder()
                                .text("Интернет не работает")
                                .callbackData("Интернет не работает")
                                .build()))
                        .keyboardRow(List.of(InlineKeyboardButton.builder()
                                .text("Соединить с оператором")
                                .callbackData("Соединить с оператором")
                                .build()))
                        .build())
                .build());
    }
}

Здесь просто отсылается SendMessage с Inline клавиатурой. Но есть один момент - откуда взялся chat-id ? Пока что ему неоткуда взятся. С этого места мы переходим к последнему из освополагающих компонентов фреймворка.

MiddleHandler

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

Создадим такой:

@Controller
public class SessionConfigurer {
    @MiddleHandler
    public void configureSession(final Update update, final Session session) {
        if (session == null)
            return;

        if (!session.containsAttribute("chat-id"))
            Util.getChatId(update)
                    .ifPresent(chatId -> session.setAttribute("chat-id", String.valueOf(chatId)));

        if (!session.containsAttribute("user"))
            Util.getUser(update)
                    .ifPresent(user -> session.setAttribute("user", user));
    }
}

В данном методе мы устанавливаем 2 атрибута в сессию: chat-id и user если сессия существует и эти атрибуты ранее не были установлены. В дальнейшем мы можем их использовать по своему усмотрению.

Добавление функций

Добавим в наш InitController метод обработки нажатий по Inline клавиатуре:

    @Command(value = UpdateType.CALLBACK_QUERY, state = "subject-select-state")
    public ModelAndView selectSubject(final Update update) {

        return new StatefulModelAndView(
                "enter-mobile-state",
                "enter-mobile-view",
                new Model("subject", update.getCallbackQuery().getData())
        );
    }

Эта команда будет обрабатывать только те апдейты, которые содержат CallbackQuery, и если пользователь имеет состояние subject-select-state. Возвращает она новое состояние и название view. Так же она сохраняет тему вопроса в модель. (в данном случае лучше это делать в сессию, но сделано так для демонстрации)

Можем еще добавить метод, который будет входящие удалять сообщения пока не была нажата кнопка на Inline клавиаутуре:

    @SneakyThrows
    @Command(value = UpdateType.MESSAGE, state = "subject-select-state")
    public void removeMessages(final Update update,
                               final AbsSender absSender,
                               @SessionAtr("chat-id") final String chatId) {

        absSender.execute(DeleteMessage.builder()
                .chatId(chatId)
                .messageId(update.getMessage().getMessageId())
                .build());
    }

Теперь нужно добавить метод в InitViews, который будет обрабатывать новый view:

    private static final String SUBJECT = "Тема вопроса: ";
    private static final String ENTER_MOBILE_TEXT = "Введите ваш номер телефона для обратной связи.";
    
    @SneakyThrows
    @View("enter-mobile-view")
    public void enterMobile(final Update update,
                            final AbsSender absSender,
                            @SessionAtr("chat-id") final String chatId) {

        // answer callback select subject
        absSender.execute(AnswerCallbackQuery.builder()
                .callbackQueryId(update.getCallbackQuery().getId())
                .build());

        // edit select subject message
        absSender.execute(EditMessageText.builder()
                .text(SUBJECT.concat(update.getCallbackQuery().getData()))
                .chatId(chatId)
                .messageId(update.getCallbackQuery().getMessage().getMessageId())
                .build());

        // send enter mobile message
        absSender.execute(SendMessage.builder()
                .text(ENTER_MOBILE_TEXT)
                .chatId(chatId)
                .build());
    }

Что делает данный метод:

  • Отвечает на inline query как этого требует документация telegram bots.

  • Редактирует сообщение с клавиатурой. Новое сообщение будет содержать выбранную тему вопроса.

  • Отправляет новое сообщение с запросом ввести номер телефона.

Теперь нужно добавить метод в контроллер, который будет обрабатывать ввод номера телефона:

    private static final Pattern MOBILE_PHONE_PATTERN = Pattern.compile("^\\d{5,12}$");

    @Command(value = UpdateType.MESSAGE, state = "enter-mobile-state")
    public ModelAndView enterMobile(final Update update, final Model model) {
        if (update.getMessage().hasText()) {
            final String text = update.getMessage().getText();
            if (MOBILE_PHONE_PATTERN.matcher(text).matches()) {
                model.setAttribute("mobile-phone", text);
                return new StatefulModelAndView(
                        "support-dialog",
                        "start-support-dialog-view",
                        model
                );
            }
        }

        return new StatefulModelAndView(
                "enter-mobile-state",
                "enter-mobile-bad-view",
                model
        );
    }

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

Добавим соотвествующие view:

    private static final String ENTER_MOBILE_BAD = "Вы ввели некорректный номер телефона, повторите попытку.";
    private static final String START_DIALOG = "Специалист подключен. Напишите нам о вашей проблеме.";
    
    @SneakyThrows
    @View("enter-mobile-bad-view")
    public void enterMobileBad(final AbsSender absSender,
                               @SessionAtr("chat-id") final String chatId) {

        absSender.execute(SendMessage.builder()
                .text(ENTER_MOBILE_BAD)
                .chatId(chatId)
                .build());
    }

    @SneakyThrows
    @View("start-support-dialog-view")
    public void startSupportDialog(final AbsSender absSender,
                                   @SessionAtr("chat-id") final String chatId) {

        absSender.execute(SendMessage.builder()
                .text(START_DIALOG)
                .chatId(chatId)
                .build());
    }

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

@Controller
public class SupportController {
    @Command(value = UpdateType.MESSAGE, state = "support-dialog")
    public ModelAndView supportDialog(final Model model) {
        return new StatefulModelAndView(
                "support-dialog",
                "support-answer",
                model);
    }
}

И соотвествующий view:

@ViewController
public class SupportView {

    @SneakyThrows
    @View("support-answer")
    public void supportAnswer(final AbsSender absSender,
                              final Update update,
                              @SessionAtr("chat-id") final String chatId,
                              @SessionAtr("user") final User user,
                              @ModelAtr("subject") final String subject,
                              @ModelAtr("mobile-phone") final String mobilePhone) {

        final String digest = DigestUtils.md5Hex(
                update.toString() + user.toString() +
                        chatId + subject + mobilePhone
        );

        absSender.execute(
                SendMessage.builder()
                        .text(digest)
                        .chatId(chatId)
                        .build()
        );
    }
}

В этом view мы собираем собранные данные о пользователе и просто их хэшируем, для наглядности.

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

    @Command(
            value = UpdateType.MESSAGE,
            state = {
                    "subject-select-state",
                    "enter-mobile-state",
                    "support-dialog"
            },
            exclusive = true
    )
    @HasText(textTarget = TextTarget.MESSAGE_TEXT, equals = "/cancel")
    public ModelAndView cancelDialog(final Session session) {
        session.stop();
        return new ModelAndView("cancel-dialog");
    }

Здесь можно увидеть что команда будет отрабатывать только если есть Message и пользователь находится в одном из перечисленных состояний, а так же exclusive true, что значит выполнение только этой команды, даже если подходят к обработке и другие.

Так же здесь появилась новая аннотация - @HasText, которая служит фильтром по тексту. Аннотация принимает TextTarget enum, в котором находятся несколько источников текста. А так же метод обработки текста. Всего их 5:

  • equals

  • equalsIgnoreCase

  • contains

  • startsWith

  • endsWith

Ну и добавим соотвествующий view в InitViews:

    private static final String CANCEL_DIALOG_TEXT = "Спасибо за ваше обращение!\n" +
            "Если у вас снова возникнут вопросы мы будем рады вам помочь!";
            
    @SneakyThrows
    @View("cancel-dialog")
    public void cancelDialog(final AbsSender absSender,
                             @SessionAtr("chat-id") final String chatId) {

        absSender.execute(
                SendMessage.builder()
                        .text(CANCEL_DIALOG_TEXT)
                        .chatId(chatId)
                        .build()
        );
    }

Бот готов

Ознакомится с ботом можно по ссылке

Исходный код бота

Заключение

Статья получилась не очень читабельная, прошу прощения. Но мне бы хотелось что бы было что-то подобное для java.

Ссылка на сам framework

Еще нужно добавить что по умолчанию для поддержки сессий и состояний используются in-memory хранилища. Если вы хотите использовать это не только для тестов - вам необходимо реализовать два интерфейса - SessionManager & StateManager и зарегистрировать их как бины.
С сериализацией и десериализацией отлично справляется jackson, если будет необходимо - могу предоставить пример как я использую его.

Источник: https://habr.com/ru/post/570660/


Интересные статьи

Интересные статьи

В своей прошлой статье я прикидывал, какие namespace'ы мне нужны для упорядочивания кода в ES6-модулях. В этой статье я описываю, какие namespace'ы у меня получились и ка...
Доброго времени суток, друзья! Представляю вашему вниманию перевод статьи «Understanding (all) JavaScript module formats and tools» автора Dixin. При создании приложения часто возникает...
Привет, Хабр! Предлагаю вашему вниманию перевод замечательной статьи из цикла статей небезызвестного Джейка Вортона о том, как происходит поддержка Андроидом Java 8. Оригинал статьи лежит...
В процессе работы над очередным проектом в команде возникли споры по поводу использования формата XML или SQL в Liquibase. Естественно про Liquibase уже написано много статей, но как всегда хочет...
Как обновить ядро 1С-Битрикс без единой секунды простоя и с гарантией работоспособности платформы? Если вы не можете закрыть сайт на техобслуживание, и не хотите экстренно разворачивать сайт из бэкапа...