Привет, меня зовут Юрий, и я фулстек-разработчик в DataLine. В компании занимаюсь созданием и развитием внутренних и внешних ИТ-сервисов: Сервисдеска, мастер-справочников, учета оборудования.
Но, как говорится, каждый разработчик в жизни должен сделать 3 вещи: развернуть дерево, распарсить DOM и вырастить своего чат-бота. О последнем и поговорим: расскажу, как делал своего первого чат-бота для нашего Сервисдеска, какие задачи и как решал, с какими трудностями и способами преодоления столкнулся.
Задачи чат-бота и вводные
По умолчанию все обращения клиентов в нашу службу Сервисдеска собираются в почте – это официальный канал коммуникации. В договоре с каждой компанией мы прописываем электронную почту для связи, и на выбранный ящик приходят все оповещения: о регистрации запроса в техподдержку, об изменении ее статуса, о запросе дополнительной информации и решении вопроса.
Но в почте клиенту не всегда удобно отслеживать статус работ и переписываться с инженерами, особенно при работе “в полях”. Все-таки на электронные ящики приходит много информации, письма могут “теряться”. Поэтому идея бота назрела уже давно.
Вот какие возможности мы хотели предложить пользователям бота:
авторизовать существующих клиентов, у которых нет учетной записи в системе;
создавать заявки (с вложениями и без);
получать информацию по статусу заявок;
вести и просматривать переписку с исполнителем заявки;
получать все вложения по заявке и добавлять их в переписке;
просматривать заявки своих коллег, подписываться на их изменения и участвовать в переписке;
просматривать перечень заявок, отфильтрованных по разным признакам (Мои заявки, Заявки организации и т.д.);
расширять функциональность параллельно с развитием Сервисдеска.
Для реализации задумок выбрали такой технологический стек:
Java 11,
Kotlin,
org.telegram:telegrambots,
Jackson,
Spring (boot, JPA, mail, web, aop, cache, RestTemplate),
Hibernate,
PostgreSQL,
Flywaydb,
Gradle,
API Servicedesk*.
* API Servicedesk (или, на внутреннем жаргоне, oapi.dtln.ru) – это опубликованный корпоративный API нашего Сервисдеска (на самом деле не только Сервисдеска, а и многих других внутренних сервисов компании). Его используют для общения с заявками различные внутренние сервисы компании: системы мониторинга, личный кабинет клиента, внутренние системы учета, различные администраторские скрипты и т.д.
Данный сервис имеет OpenAPI-описание в Swagger и вполне удобен для интеграции.
Подробнее про oapi.dtln.ru и проблемы интеграции корпоративных систем в единую точку входа для сервисов компании напишем в следующих статьях.
В проекте было два вызова для разработчика. Во-первых, это новые инструменты разработки. Изначально я Java-разработчик, занят на проектах, написанных на Lucee. Для меня это был первый самостоятельный проект в компании, собранный с нуля. Во-вторых, изменившийся бэклог: стартовых задач нам показалось мало, и мы расширили их пул. Но обо всем по порядку.
Начнем с базовой функциональности
Проектируем интерфейс. Первые сложности были в том, что конечных планов у проекта нет и надо делать достаточно легко расширяемую архитектуру. С нее и начал.
На одном листе расписал всю бизнес-логику: для какого пользовательского сценария какие запросы используются. Вышло что-то такое.
Это помогло разобраться в архитектуре приложения, иерархии создаваемых классов и их взаимосвязях, чтобы не наделать ошибок на старте разработки.
Как пример понятного взаимодействия с пользователем я взял BotFather. По реализации решил не размениваться на простые команды типа: /start, /мои_заявки, /твои_заявки, – их может быть больше десятка, и это будет неудобно в использовании. Вместо этого стал формировать полноценные менюшки с кнопочками (CallBackQuery), благодаря чему получилось достаточно симпатичное и интуитивное меню.
В разных частях меню от бота может потребоваться разная реакция на одно и то же пользовательское сообщение. Например, если пользователь напишет в чат что-то из списка “Мои заявки”, то бот отфильтрует перечень в поиске совпадений. А если пользователь напишет это же сообщение из меню “Чат заявки”, то бот отреагирует так, будто пользователь хочет отправить сообщение в чат. Чтобы это реализовать, я закрепил за каждым меню “Состояние” (State) и сохранял его у пользователя. Покажу подробнее на примерах.
Реализуем первые команды. Самое первое, что нужно предложить клиентам, – создание заявок через бота и информирование по их исполнению. А все остальное уже потом.
В качестве способа взаимодействия с сервером Сервисдеска используется REST OAPI.
Для взаимодействия с сервером Telegram мы выбрали лонг-поллинг: так бот периодически сам опрашивает сервер в надежде на Update. Решение приняли из соображений безопасности, чтобы не нужно было публиковать сервер наружу для трансляции обновлений.
Схема взаимодействия (сетевая схема):
Что по коду. Bot наследует TelegramLongPollingBot с настройками и передачей прилетающего Update в сортировщик UpdateReceiver:
updateReceiver.handle(update)
UpdateReceiver определяет, что прилетело, и выбирает для конкретного запроса обработчик Handler (или хендлер).
Тут нам помогает магия Spring. Чтобы все обработчики автоматом добавлялись в наш UpdateReceiver, я создал интерфейс Handler.
@Service
interface Handler {
// основной метод, который будет обрабатывать действия пользователя
fun handle(user: User, update: Update): List<PartialBotApiMethod<out Serializable?>>
// метод, который позволяет узнать, можем ли мы обработать текущий State у пользователя
fun operatedBotState(state: State): Boolean
// метод, который позволяет узнать, какие команды CallBackQuery мы можем обработать в этом классе
fun operatedCallBackQuery(): List<String>
}
Каждый обработчик наследуется от него, например, HelpHandler.
@Component
class HelpHandler : Handler {
@Autowired
private lateinit var globalMenu: GlobalMenu
companion object {
//Храним поддерживаемые CallBackQuery в виде констант
const val MENU_MAIN = "/help"
}
override fun handle(user: User, update: Update): List<PartialBotApiMethod<out Serializable?>> {
return globalMenu.menuSD(user,update)
}
override fun operatedBotState(state: State): Boolean {
return when (state) {
State.MENU_MAIN -> true
else -> false
}
}
override fun operatedCallBackQuery(): List<String> {
return listOf(MENU_MAIN)
}
}
Это способствует расширяемости: можем заинжектить все обработчики всего 2 строчками и не беспокоиться в дальнейшем о появлении новых.
@Autowired // Храним доступные хендлеры в списке
private lateinit var handlers: List<Handler>
Если пользователь что-то пишет в чат, то остается все обработать по такому алгоритму:
1) проходимся по списку наших обработчиков в UpdateReceiver:
fun handle(update: Update): List<PartialBotApiMethod<out Serializable?>> {
val messages: MutableList<PartialBotApiMethod<out Serializable?>> = ArrayList()
if (update.hasMessage()) {
user.botState.let { (getHandlerByState(it).handle(user, update)) }.let { messages.addAll(it) }
return messages
}
}
2) спрашиваем у них, могут ли они обработать пришедший update:
private fun getHandlerByState(state: State): Handler {
return handlers.stream()
.filter { h: Handler -> h.operatedBotState(state) }
.findAny()
.orElseThrow { UnsupportedOperationException() }
}
3) если кто-то отвечает “да”, то вызываем метод handle() у нужного хендлера.
Дополнительные фичи. Все реплики бота я вынес из кода и поместил в отдельный файл (messages_ru_RU.properties) в таком виде:
button.cancel= ❌ Отмена
button.cancel-en= ❌ Cancel
Это помогло легко соорудить RU/EN меню и затем отдать тексты на редактирование профессиональному копирайтеру для формирования более грамотной речи у бота :-).
Для получения реплик из файла создаем класс:
@Service
class LocaleMessageService(@Value("\${localeTag}") localeTag: String, private val messageSource: MessageSource) {
private val locale: Locale = Locale.forLanguageTag(localeTag)
fun message(message: String, user: User): String {
val mes:String = if(user.langEn) "${message}-en" else message
return messageSource.getMessage(mes, null, locale)
}
}
Сам localeTag: ru-RU прописывается в application.yaml.
Далее инжектим класс в наш хендлер и создаем кнопку:
val btn = InlineKeyboardButton()
btn.text = localeMessageService.message("button.cancel",user)
btn.callbackData = command
Также я добавил сквозную функцию, по которой бот показывает свою реакцию на запрос пользователя, например: “... печатает” или ”>> отправляет файл”. Эта фишечка реализовалась благодаря Spring AOP. Функция срабатывает, как только запрос попадает в какой-либо хендлер.
@Aspect
@Configuration
class AppAspects {
@Autowired
private lateinit var bot: Bot
@Autowired
private lateinit var telegramUtil: TelegramUtil
// * - путь
// * - класс
// * - имя метода
// (..) - параметры методов
@Before("execution(* ru.путь.handler.*.handle(..))")
fun handler2Before(joinPoint: JoinPoint) {
val user = joinPoint.args[0] as User
val update = joinPoint.args[1] as Update
bot.executeWithExceptionCheck(telegramUtil.sendAction(user))
}
}
Если что-то сломалось, у заявителя выскочит сообщение типа:
“Упс! Что-то пошло не так, но это не страшно!”
В это время нам на почту придет сообщение об ошибке:
@AfterThrowing (pointcut = "execution(* ru.путь.*.*(..))",throwing = "ex")
fun throwingError(joinPoint: JoinPoint,ex: Exception){
sendErrorOnMail(joinPoint, ex)
}
Разберемся с безопасностью
Регистрация пользователей. Изначально клиенты не имели прямого доступа к Сервисдеску, вся коммуникация шла через почту, прописанную в договоре. Нужно было выбрать способ, как идентифицировать “своих”:
Стандартно связать Telegram-аккаунт с аккаунтом в системе: предложить человеку, авторизованному на сайте, перейти по глубинной ссылке (Deep Link) в Telegram-бота. Этот способ нам не подошел, так как у клиентов нет авторизации на наших сайтах.
Идентифицировать и регистрировать, спросив у подключившегося к боту пользователя его e-mail, – так себе безопасность.
Воспользоваться функцией Telegramа “Поделиться своим контактом”. Мы выбрали этот вариант. Таким образом мы можем легко проверить и точно быть уверенными, что человек отправляет номер именно своего телефона, а не чей-то еще.
Логика такая. Если пользователь – клиент DataLine, то информация о нем содержится в корпоративной информационной системе (Мастер-справочнике). Система проверит номер телефона и при необходимости предложит клиенту выбрать из списка организацию или договор, по которому он работает с DataLine.
Нюанс в том, что человек со своим номером телефона может иметь несколько таких учеток. Он может числиться в нескольких организациях, быть нашим сотрудником и в то же время быть нашим клиентом. Все это надо было учесть: при регистрации после получения номера телефона мы задаем пару дополнительных вопросов, если телефон в системе встречается не один раз.
Если в Telegram и в нашей системе у пользователя разные номера, то ему придется сменить контактный номер в Telegram, иначе с ботом связаться не получится. Если бот по каким-то причинам отказывается разговаривать с клиентом , то он может решить этот вопрос через своего сервис-менеджера.
Чтобы уж наверняка все было безопасно, мы добавили двухфакторную аутентификацию.
Двухфакторка. В архитектурной схеме блок по 2FA выглядит вот так:
Когда пользователь отправил свой номер телефона и мы выяснили, кто он, генерится 6-значный код.
Код сохраняется в БД вместе с учетной записью, под которой пользователь хочет быть авторизован в Сервисдеске.
Через OAPI в Сервисдеск передается запрос на формирование и отправку смс через смс-шлюз. В смс содержится код и ссылка авторизации.
Когда пользователь отправляет в чат команду /start [code] или переходит по ссылке, я сопоставляю информацию в базе данных с тем, что прислал пользователь.
Если все хорошо, то код авторизации удаляется из БД и пользователь считается авторизованным.
Лонг-поллинг. Итак, благодаря лонг-поллингу мы не светим свой адрес в мире и не позволяем кому попало слать на него всякие пакости. Вместо этого спрашиваем у api.telegram.org: “Есть чё?”
Чуть подробнее, как это сделано. Сам бот напрямую не имеет доступа к БД Сервисдеска, а получает информацию из него через OAPI при помощи REST-запросов. Для этого он предварительно авторизуется в OAPI и периодически обновляет свой token.
При каждом запросе необходимо проверять уже авторизованного клиента на актуальность данных. Например, если сотрудник уволился, нужно обнулить его авторизацию. Каждая такая проверка – это дополнительный REST-запрос, что неслабо нагружает систему. Поэтому такой запрос пришлось кешировать. Для этого воспользовался возможностями Spring. Создал соответствующий бин:
@Configuration
@EnableCaching
class CacheConfig {
@Bean
fun cacheManager(): CacheManager? {
val simpleCacheManager = SimpleCacheManager()
simpleCacheManager.setCaches(
listOf(
ConcurrentMapCache(
"source",
CacheBuilder.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(10000)
.build<Any, Any>().asMap(), false
)
)
)
return simpleCacheManager
}
}
И добавил аннотацию над необходимой мне функцией:
@Cacheable(value = ["source"], key = "#sourceId")
fun checkAuth(sourceId: String): List<Source> {}
“Подключим сюда сотрудников?”
Теперь немного про развитие функциональности. Изначально бот разрабатывался в первую очередь для клиентов. У сотрудников есть свое приложение с доступом к Сервисдеску и другими свистелками, которых нет в боте. Но в приложении есть свои минусы, в частности: оно доступно только для Android и требует включенного VPN. Так что нам захотелось связать клиента с сотрудниками через интерфейс чат-бота.
Конечно, перенести всю функциональность приложения в чат-бот не получится. Мы решили добавить в чат-бот для сотрудников уведомления по заявкам и переписку в заявке – через бота это получается быстрее, так как Telegram доступен всегда и на любых устройствах.
На первом этапе разработки разделения на сотрудников и клиентов не предполагалось, так что писать разные интерфейсы для разных категорий я не стал. Но с самого начала проекта у нас не было конечного перечня необходимых возможностей. Поэтому на каждом шаге общения с ботом я учитывал, какому пользователю предназначается ответ бота. В коде интерфейс был один, просто для клиентов и сотрудников я показываю либо скрываю разные элементы интерфейса.
***
Все клиенты DataLine уже получили приглашение в чат-бот и уже делятся своим фидбэком. Познакомиться с ботом можно вот тут. Если у вас возникнут предложения или замечания по работе бота, ждем их на support@dtln.ru. Будем рады обратной связи и здесь!