У нашего облака Amvera есть активное комьюнити в Telegram-чате, где мы оказываем поддержку нашим пользователям и помогаем им развернуть проекты. И если на почте и сайте обращения очень удобно группируются в цепочки, то про поддержку в Телеграм-группе на более 1500 участников нужно рассказать отдельно.

Что такое Amvera

Амвера, это облако для простого запуска кода. Вы можете деплоить проекты тремя командами в IDE через "git push amvera master" или простой загрузкой кода в интерфейсе. К простому запуску идут бэкапы, алерты, бесплатные домены с SSL и многое другое.

У нас не нужно настраивать виртуальные машины, NGINX, SSL и инфраструктуру. Можно просто сосредоточиться на коде без забот о DevOps.

Наша поддержка с давних пор испытывала неудобство при отслеживании обращений пользователей в нашем чате Телеграм – когда сразу много пользователей одновременно пишут на разные темы в одном месте, немудрено упустить какое то из сообщений. Оно просто уплывает в ленте, пока мы разбираемся с другими обращениями.

У моего коллеги, Ивана Шихалдина, который и занимается поддержкой, родилась идея организовать этот хаос. И он решил в качестве пет-проекта написать небольшую CRM. В ней все обращения пользователей должны раскладываться по своим тредам, ничего не теряется и удобно хранится служебная информация об обращениях.

Иван написал как CRM, так и эту статью, которую с его разрешения я публикую на Хабр.

А код проекта CRM мы выкладываем в открытый доступ на GitHub, как Open Source. Каждый должен иметь возможность организовать работу в больших комьюнити и не терять обращения.

В статье мы расскажем про саму CRM и технические детали её реализации.

В качестве стека решено было использовать Spring Boot приложение в связке с Postgres, и развернуть эту CRM у нас же в Амвера в качестве проекта.

В качестве прототипа была разработана страничка шаблона ThymeLeaf со списком обращений пользователей и страничка с данными конкретного обращения. Специальный бот в нашей группе слушает приходящие сообщения. Дальше они привязываются к пользователю-отправителю и попадают в состав сущности “Обращение пользователя”.

Интерфейс работы CRM. Диалоги из группы разбиты в треды
Интерфейс работы CRM. Диалоги из группы разбиты в треды

В обращении, кроме списка сообщений от пользователя и ответов ему от поддержки, мы решили добавить статусы. Не хотелось проставлять статусы для всех обращений руками, лучше автоматизировать процесс. Автоматизировать, так по полной, чтобы облегчить себе жизнь, а не усложнить. Поэтому первые два статуса “Отвечен” и “Не отвечен” были сделаны полностью автоматически заменяющими друг друга. Написал клиент - обращение перешло в “Не отвечен”, поддержка ответила - обращение перешло в “Отвечен”.

Хотелось, чтобы статусов было минимум, чтобы не путаться в них, но достаточно, чтобы охватить все наши потребности. Например, была необходимость помечать некоторые обращения как важные, если не можем сразу дать ответ и нужно будет в дальнейшем к этому обращению вернуться. Что то вроде пометки обращения звездочкой, как в почте. Так появился статус “Требует внимания”. Обращение в этом статусе выпадает из автоматизированного цикла Отвечен / Не отвечен, и висит в списке обращений в особой группе “требует внимания” до тех пор, пока нужно о нем помнить.

Ну и в какой то момент обращению нужно поставить статус “Завершено”, иначе цепочка сообщений становится слишком длинной, и ее нужно обрезать. Все новые сообщения клиента после завершения обращения будут попадать уже в новое. А неактуальные уже обращения в статусе “Требует внимания” нужно в какой то момент вывести из этого состояния, это удобно делать кликнув по плашке метки статуса “Завершено”.

Теоретически этих четырех статусов должно было хватать, но беда пришла откуда не ждали. Спамеры! В чате работает антиспам бот и активно удаляет все эти предложения быстрого заработка. Но CRM-бот успевал получить эти сообщения до того, как спам был удален! Пришлось обратиться к коллеге, написавшему для нас антиспам на основе нейросети, и попросить его сделать ручку, дернув за которую получалось узнать – спамер или любимый клиент прислал нам сообщение. Так мы получили возможность не помещать спам-сообщения в базу CRM. Но иногда спам все же прорывается, и для него мы завели специальный статус обращения – “Игнорировать

Этих 5 статусов нам пока хватает, чтобы закрывать 99% входящих обращений. Дополнительно мы добавили в шаблоне обращения поле для хранения служебной информации, которую нужно переносить между обращениями. Это общее поле, которое видят все модераторы во всех обращениях конкретного клиента, очень удобно для передачи информации между дежурными сменами.

Пример диалога с пользователем
Пример диалога с пользователем

Получается, на страничке обращения мы видим, от кого пришло обращение, видим весь контекст переписки с ним, видим текущее состояние обращения, примечания, окно ввода ответа на обращение, и в конце выводится список предыдущих обращений от этого автора.

Аппетит приходит во время еды. Оказалось, что всем "пуш-мастерам" (это у нас так называются сотрудники поддержки, помогающие клиентам запускать проекты в Amvera) отвечать в чате от имени одного чат-бота не очень удобно. Возникла мысль привязать к каждому модератору своего бота. Но поскольку система аутентификации была сделана в минимальном варианте на коленке, пришлось написать еще админку к CRM для создания и редактирования пользователей и раздачи полномочий.

Админка управления ролями
Админка управления ролями

Потом оказалось, что одних текстовых сообщений от пользователей недостаточно. Оказывается, часто клиенты присылают скриншоты, без которых частенько понять контекст переписки довольно сложно. Пришлось думать, как и где хранить медиа-файлы из Телеграм, и добавлять их отрисовку в шаблоне рядом с сообщениями. Телеграм для одного скриншота создает целый веер картинок разного размера. Мы использовали самую ��аленькую картинку для превью (примерно 320 на 200). Но она была слишком мелкая, чтобы разглядеть детали, пришлось прикрутить к шаблону lightbox, и добавить возможность при клике на миниатюру отображать во всплывающем окне полноразмерное изображение.

В ближайших планах прикрутить отправку скриншотов прямо из CRM, поработать над админкой и регистрацией пользователей, возможно, выкатить подобное приложение CRM как преднастроенное для наших клиентов. Если у вас есть потребность в подобном, откликнитесь пожалуйста в комментариях, нам важно понимать потребность)

Ну и немного скринов из мониторинга. Сейчас CRM работает на тарифе Стандартный, с 2,5 Гб ОЗУ. Его хватает с запасом.

Потребление оперативной памяти
Потребление оперативной памяти

По процессору высокое потребление при запуске. Возможно, это вызвано запуском миграций Liquibase во время рантайма (у нас временно недоступны переменные окружения на этапе развертывания, пришлось перенести миграции в рантайм, надеюсь это не надолго). В целом потребление процессора минимальное.

Потребление CPU
Потребление CPU

И можно наблюдать троттлинг процессора в момент запуска, который постепенно сходит на нет.

Рассмотрим техническую реализацию некоторых интересных моментов.

Как сделана группировка чатов по пользователям

Когда мы получаем новое входящее сообщение, оно проходит три этапа обработки.

Сначала мы проверяем, не является ли это сообщение ответом на какое то другое, для которого уже есть зарегистрированное обращение пользователя в системе. Если это ответ, и у нас уже есть цепочка сообщений для этого обращения, то мы новое сообщение тоже добавляем в эту цепочку. Если же вдруг оказывается, что обращение к которому привязана цепочка сообщений уже закрыто (т.е. в статусе Завершено или Игнорировать), заводим новое обращение и в нем начинаем собирать новую цепочку сообщений от пользователя.

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

Если совсем никаких открытых обращений подходящих по этим условиям не нашли, то просто открываем новое обращение от этого клиента и начинаем формировать в нем новую цепочку сообщений.

Получается, для одного пользователя может быть сколько угодно завершенных обращений и только одно активное в статусе "Не отвечено", "Отвечено" или "Обратить внимание". На страничке каждого обращения в конце есть список предыдущих обращений этого же клиента. Иногда бывает полезно вспомнить, какие вопросы поднимал клиент месяц назад например, чтобы не мучать его каждый раз одними и теми же типовыми вопросами.

Как сделана командная возможность ответа из нескольких ботов

Удобно, когда пользователь видит, кто именно из пушмастеров и модераторов ему отвечает. Поэтому мы создали и добавили в нашу группу несколько ботов, от имени которых могут отвечать коллеги. Получился один основной бот, который слушает через пуллинг входящие сообщения в группе и отправляет их на обработку в БД, и несколько ботов от имени которых пишутся в чат ответы.

Технически это реализовано через слушателя TelegramUpdateReceiverService и через класс работы с API Telegram TelegramApiService для отправки сообщений от лица бота. Токены каждого привязанного к пользователю бота хранятся в постгресе в сущности AppUser. Для каждого пользователя создается свой кешируемый через фабрику экземляр telegramClient.

Чтобы каждый раз не думать, какой же пользователь сейчас отправляет сообщение через CRM, мы воспользовались в фабрике хранящимся в спринговом SecurityContext принципалом. Принципал, это объект, который представляет текущего аутентифицированного пользователя, запрашивающего доступ к ресурсу. Выглядит это примерно так:

//Получим из контекста botToken  
String appUserBotToken;  
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();  
if (principal instanceof AppUser appUser) {  
   appUserBotToken = appUser.getBotToken(); // <-- Получаем ID напрямую из сущности  
}else{  
   // В случае анонимного доступа, токена или другой проблемы аутентификации  
   throw new IllegalStateException("AppUser not found in security context: principal is not AppUser.");  
}

Тут мы проверяем, что наш принципал является экземпляром класса AppUser, т.е. нашим пользователем CRM, и получаем из него хранящийся там токен привязанного бота. Для этого нам пришлось объявить его как class AppUser implements UserDetails, чтобы реализовать спринговый интерфейс UserDetails из фреймворка SpringSecurity.

Для кеширования telegramClient (в котором мы и вызываем execute() для отправки сообщения от имени бота) мы используем поле ConcurrentMap clientCache внутри фабрики TelegramClientFactory, где ключем для telegramClient выступает как раз наш строковый токен бота, полученный из принципала. computeIfAbsent гарантирует, что клиент будет создан только один раз для каждого токена:

TelegramClient telegramClient;  
if(appUserBotToken!=null&&!appUserBotToken.equals("")){  
   // computeIfAbsent гарантирует, что клиент будет создан только один раз для каждого токена  
   telegramClient = clientCache.computeIfAbsent(appUserBotToken, OkHttpTelegramClient::new);  
}else{  
   // Если у пользователя токен не указан, возвращаем дефолт клиента  
   var defaultBotToken = appConfig.getTelegram().getToken();  
   log.warn("AppUser {} has no botToken in settings. Will use default!",appUser.getUsername());  
   telegramClient = clientCache.computeIfAbsent(defaultBotToken, OkHttpTelegramClient::new);  
}  
return telegramClient;

При этом, нам не обязательно к каждому пользователю CRM привязывать отдельного телеграм-бота для ответов, потому что по умолчанию для ответов используется токен основного бота-слушателя, который просто хранится в переменной окружения TELEGRAM_TOKEN, подключаемой в конфигурационном файле application.yml. Но в целом мы можем добавить нового бота одним нажатием кнопки сохранения токена в карточке пользователя в админке.

Добавление пользователя
Добавление пользователя

Как реализована авторизация?

Наверное, у вас мог возникнуть вопрос, а что с безопасностью? Ведь если нас взломают, то зловред сможет писать от нашего лица нашим клиентам в телеграм-чате?

Безопасность обеспечивается уже упомянутым модулем SpringSecurity.

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

@Configuration  
public class SecurityConfig {

   /**  
    * Конфигурирует цепочку фильтров безопасности (SecurityFilterChain).  
    */  
   @Bean  
   public SecurityFilterChain filterChain(  
           HttpSecurity http  
   ) throws Exception {  
       http  
               // Настраиваем авторизацию запросов  
               .authorizeHttpRequests(authorize -> authorize  
                               // 1. Только пользователи с ролью ROLE\_ADMIN имеют доступ ко всем путям /admin/\*\*  
                               .requestMatchers("/admin/**").hasRole("ADMIN")

                               // 2. Все остальные запросы требуют только аутентификации (входа в систему)  
                               .anyRequest().authenticated() // Все запросы требуют аутентификации  
               )  
               .formLogin(AbstractAuthenticationFilterConfigurer::permitAll)  
               .logout(LogoutConfigurer::permitAll);

       return http.build();  
   }

   /**  
    * Бин для шифрования паролей.  
    */  
   @Bean  
   public PasswordEncoder passwordEncoder() {  
       return new BCryptPasswordEncoder();  
   }  
}

Как проставляются статусы?

В интерфейсе статусы обращений изменяются одним кликом по соответствующему пузырю (баблу):

Статусы
Статусы

Технически, при нажатии на бабл открывается эндпойнт /support-request/update-status
в который �� качестве параметров передаются id обращения и новый статус, после чего статус сообщения в базе изменяется и страничка просто загружается уже с новым статусом.

<div class="top-info-bar">  
   <a class="back-button" href="/reviews"><i class="fas fa-arrow-left"></i></a>  
   <div class="status-buttons">  
       <a class="status-button"></a>  
   </div>  
</div>

Все статусы берутся из специального перечисления SupportRequestStatusType

@RequiredArgsConstructor  
@Getter  
public enum SupportRequestStatusType {  
   REQUIRES_ATTENTION("Требует внимания"),  
   ANSWERED("Отвечен"),  
   UNANSWERED("Не отвечен"),  
   COMPLETED("Завершен"),  
   IGNORE("Игнорировать");

   private final String displayName;  
}  

В виде enum эти же статусы хранятся в базе данных.

Вместо послесловия

Пожалуй, можно было бы еще рассказать, как мы боролись с “состоянием гонки” (race condition), когда возникало несколько открытых обращений от одного клиента одновременно. Следы борьбы с этим до сих пор можно найти в коде, хотя как оказалось, ошибку вызывало вовсе не состояние гонки, но мы долго были уверены, что наши пользователи слишком быстро присылают свои сообщения и программа не успевает их обработать и задваивает обращения из-за распараллеливания обработки.

Благодарю, что дочитали этот лонгрид. Буду рад вашим мыслям на тему в комментариях к статье.

Код проекта вы можете скачать по ссылке на GitHub.

А если проект будет вам полезен, лучшая благодарность, это его размещение в нашем облаке для простого хостинга - Amvera. Для этого достаточно сделать git push в привязанный у нас репозиторий и задать несколько переменных. Наш сервис всё остальное настроит и запустит самостоятельно.

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