У меня иногда появлялось желание делать ботов для телеграм, так мой основной язык 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.
Еще нужно добавить что по умолчанию для поддержки сессий и состояний используются in-memory хранилища. Если вы хотите использовать это не только для тестов — вам необходимо реализовать два интерфейса — SessionManager & StateManager и зарегистрировать их как бины.
С сериализацией и десериализацией отлично справляется jackson, если будет необходимо — могу предоставить пример как я использую его.
ссылка на оригинал статьи https://habr.com/ru/articles/570660/
Добавить комментарий