Попытка создать 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/articles/570660/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *