Привет, меня зовут Юрий, и я фулстек-разработчик в 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)
}

Разберемся с безопасностью

Регистрация пользователей. Изначально клиенты не имели прямого доступа к Сервисдеску, вся коммуникация шла через почту, прописанную в договоре. Нужно было выбрать способ, как идентифицировать “своих”: 

  1. Стандартно связать Telegram-аккаунт с аккаунтом в системе: предложить человеку, авторизованному на сайте, перейти по глубинной ссылке (Deep Link) в Telegram-бота. Этот способ нам не подошел, так как у клиентов нет авторизации на наших сайтах.

  2. Идентифицировать и регистрировать, спросив у подключившегося к боту пользователя его e-mail, –  так себе безопасность.

  3. Воспользоваться функцией Telegramа “Поделиться своим контактом”. Мы выбрали этот вариант. Таким образом мы можем легко проверить и точно быть уверенными, что человек отправляет номер именно своего телефона, а не чей-то еще.

Логика такая. Если пользователь – клиент DataLine, то информация о нем содержится в корпоративной информационной системе (Мастер-справочнике). Система проверит номер телефона и при необходимости предложит клиенту выбрать из списка организацию или договор, по которому он работает с DataLine. 

Нюанс в том, что человек со своим номером телефона может иметь несколько таких учеток. Он может числиться в нескольких организациях, быть нашим сотрудником и в то же время быть нашим клиентом. Все это надо было учесть: при регистрации после получения номера телефона мы задаем пару дополнительных вопросов, если телефон в системе встречается не один раз.

Если в Telegram и в нашей системе у пользователя разные номера, то ему придется сменить контактный номер в Telegram, иначе с ботом связаться не получится. Если бот по каким-то причинам отказывается разговаривать с клиентом , то он может решить этот вопрос через своего сервис-менеджера.

Чтобы уж наверняка все было безопасно, мы добавили двухфакторную аутентификацию. 

Двухфакторка. В архитектурной схеме блок по 2FA выглядит вот так:

  1. Когда пользователь отправил свой номер телефона и мы выяснили, кто он, генерится 6-значный код. 

  2. Код сохраняется в БД вместе с учетной записью, под которой пользователь хочет быть авторизован в Сервисдеске. 

  3. Через OAPI в Сервисдеск передается запрос на формирование и отправку смс через смс-шлюз. В смс содержится код и ссылка авторизации.

  4. Когда пользователь отправляет в чат команду /start [code] или переходит по ссылке, я сопоставляю информацию в базе данных с тем, что прислал пользователь. 

  5. Если все хорошо, то код авторизации удаляется из БД и пользователь считается авторизованным.

Лонг-поллинг. Итак, благодаря лонг-поллингу мы не светим свой адрес в мире и не позволяем кому попало слать на него всякие пакости. Вместо этого спрашиваем у 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. Будем рады обратной связи и здесь!  

Комментарии (0)