У меня иногда появлялось желаение делать ботов для телеграм, так мой основной язык 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);
}
}
@Controller
public class InitController {
@Command(value = UpdateType.MESSAGE)
public ModelAndView start() {
return new StatefulModelAndView(
"subject-select-state",
"select-subject-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 ? Пока что ему неоткуда взятся. С этого места мы переходим к последнему из освополагающих компонентов фреймворка.
Создадим такой:
@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 если сессия существует и эти атрибуты ранее не были установлены. В дальнейшем мы можем их использовать по своему усмотрению.
@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());
}
Что делает данный метод:
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:
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()
);
}
Исходный код бота
Ссылка на сам framework
Еще нужно добавить что по умолчанию для поддержки сессий и состояний используются in-memory хранилища. Если вы хотите использовать это не только для тестов - вам необходимо реализовать два интерфейса - SessionManager & StateManager и зарегистрировать их как бины.
С сериализацией и десериализацией отлично справляется jackson, если будет необходимо - могу предоставить пример как я использую его.
По этим причинам у меня в голове давно витает мысль сделать какую-то библиотеку / фреймворк, что бы можно было нормально и без мучений делать ботов. На данный момент уже есть небольшой 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
- Update
- AbsSender
- Session - если включено управление сессиями, если нет - null
- Model - если включено управление состояниями и если было предшествующее состояние, в остальных случаях - null
- Любое кол-во параметров любого типа с аннотацией SessionAtr, если такой не найден - null
- Любое кол-во параметров любого типа с аннотацией ModelAtr, если не было предшествующего состояние или нету такого атрибута - null
@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
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, если будет необходимо - могу предоставить пример как я использую его.
Попытка создать java Framework для телеграм ботов
У меня иногда появлялось желание делать ботов для телеграм, так мой основной язык Java - выбор не велик и он меня не устраивает. Каждый раз нужно было придумывать какие-то схемы обработки приходящих...
habr.com