Однажды ко мне пришли с запросом. Нужно было поднять горячую линию, в которую могли бы обращаться люди и получать ответы на свои вопросы, переписываясь с волонтёрами. Звучит как саппорт-система? Да, потому что это она и есть.
Осложнялась задача тем, что её нужно было сделать к завтрашнему дню. А, ну и, конечно, бесплатно!
Так у меня на руках оказался прототип системы, которая отлично справилась с поставленной задачей и которую я в качестве упражнения аккуратно переписал в open-source проект, который представляю вам сегодня — Suppgram. В статье я расскажу, чем оказались удобны Telegram-боты, как я подошёл к архитектуре проекта и как мне (не?) помогло знание паттернов проектирования.
Как это работает?
Telegram как канал связи
Использование Telegram как канала связи с клиентами само по себе неудивительно. Мессенджер популярен в русскоязычном пространстве, в нём есть удобная система ботов, с которой большинство пользователей так или иначе знакомо; заведение ботов — бесплатно и не требует прохождения адских верификаций, как, например, в Instagram/Facebook. Заставляя клиента писать боту, а не в личку агенту, мы упрощаем перенос заявок между агентами, учёт заявок и контроль качества, а также защищаем личные данные и клиентов, и агентов.
Соответственно, со стороны клиента система выглядит предельно прозрачно — это просто бот, передающий сообщения и обладающий парой дополнительных функций вроде оценки работы:
Telegram как рабочее место
Полноценные саппорт-системы вроде HelpShift реализуют свои, достаточно нетривиальные интерфейсы для агентов. У агента много чатов, в чатах много функций — папки, теги, быстрые ответы и так далее. Для бесплатной минималистичной самописной системы это, конечно, слишком амбициозно. Хочется остановиться на простой среде, под которую мы уже умеем разрабатывать — в идеале на том же Telegram.
Но как организовать рабочее пространство агента в мессенджере? В MVP-варианте у агента всё ещё должна быть возможность переписываться со множеством пользователей одновременно, с минимумом риска ответить не туда, и некоторое количество вспомогательных функций — например, возможность зарезолвить заявку или отправить в список ожидающих ответа.
Боты в Telegram всё ещё бесплатные. Так почему бы не завести несколько ботов?
Алгоритм работы получается таким:
заводим одного бота для пользователей и пачку ботов для агентов, чем больше — тем лучше: количество ботов определяет возможное количество одновременных чатов;
агенты видят поступающие вопросы от клиентов в отдельном чате;
когда агент берёт вопрос в работу, один из его чатов с агентскими ботами становится чатом с клиентом — бот начинает пересылать клиентские сообщения ему, а его сообщения — обратно клиенту;
когда агент решает клиентскую проблему, чат с ботом освобождается и ждёт взятия в работу следующего вопроса.
Подробнее про то, как всё запустить и какая есть функциональность, можно почитать в документации на ReadTheDocs.
Telegram как менеджер доступа
Ботам в Telegram могут писать любые пользователи без ограничений; мы же хотим, чтобы агентские боты работали только с нашими агентами. Можно придумать хитрое имя, чтобы бота было трудно найти, но это security through obscurity — крайне ненадёжный паттерн. Делать команды в боте для выдачи доступов — тоже не самый удобный вариант: достать идентификаторы пользователей затруднительно, а юзернейма у агента может и не быть.
Но! В Telegram есть групповые чаты. Доступ в закрытый чат ограничен, поддержано много разных настроек для контроля доступа. Так я взял фичу Telegram и превратил в свою. Агентом считается любой участник чата, помеченного через специальную команду как чат агентов:
Особенности open-source
Когда я делал прототип, я, с учётом сильно сжатых сроков, конечно, не заморачивался — это был один скрипт длиной в добрую тысячу строк на Python, с простыми SQLAlchemy-модельками и файловой базой данных SQLite. Это чудо было на удивление легко разрабатывать (ну, первое время) и деплоить (scp + killall + nohup — готово). Думаю, это часть мастерства разработчика — в нужный момент отбросить все условности и склепать полный треш, идеально выполняющий задачу.
Однако, это хорошо работает, только когда условия задачи полностью известны. Как только речь заходит о проекте для сторонних потребителей, неопределённость стремительно возрастает, а с ней — кратно возрастает сложность и количество абстракций, нужных, чтобы перебороть её (я писал об этом в статье про энтропию).
Когда я думал о том, что нужно, чтобы мой код оказался полезен кому-то ещё, я собрал такой список требований:
Поддержка разных каналов связи с пользователями. Telegram-бот — это, конечно, здорово, но, например, в моём проекте Stry все чаты в приложении сделаны через SDK PubNub и, соответственно, чат с поддержкой будет тоже.
Поддержка разных хранилищ. Я не знаю, какая база данных будет под рукой у стороннего пользователя в уже существующей инфраструктуре, а без опоры на базу данных не получится. В Stry (и на моём прошлом месте работы) используется MongoDB, так что одними адаптерами SQLAlchemy не обойтись.
Простота использования. Чтобы все заинтересованные могли легко опробовать функциональность, запуск должен быть предельно простым — в идеале pip install и запуск пакета через python -m либо Docker-контейнер.
Минимум зависимостей. Каждая лишняя зависимость увеличивает вес дистрибутива (а я не знаю, как сторонний пользователь будет деплоить приложение — вдруг там один контейнер на все сервисы) и риск конфликтов версий.
Документация. Хотя мы знаем, что хорошо написанный код по большей части является самодокументирующимся, это работает, только когда все работающие с кодом разработчики активно вовлечены в кодовую базу — например, когда являются фуллтайм-сотрудниками разрабатывающей проект продуктовой компании. В случае же открытого проекта важно обеспечить быстрый вход для разработчиков, видящих код в первый раз.
Также из эгоистических соображений практики нового я решил сделать проект полностью асинхронным.
Поскольку работа с open-source — это новый для меня опыт, здесь может многого не хватать. Конструктивная критика приветствуется!
Архитектура проекта
Необходимость поддерживать разные хранилища данных и разные пользовательские интерфейсы автоматически подталкивает нас к стандартной трёхуровневой архитектуре:
Из нюансов — изобилие фронтендов. У нас торчит наружу сразу три интерфейса — интерфейс для клиентов (возможно, не один), интерфейс для агентов и интерфейс для управления системой.
Ещё один нюанс — события. Когда клиент пишет боту, мы должны незамедлительно переправить его сообщение агенту от имени агентского бота. Этой логикой должен заниматься бэкенд, но вызвать отправку сообщения агенту он не может, потому что этим занимается только агентский фронтенд. А если бэкенд будет вызывать методы фронтендов, получатся циклические зависимости. Отсюда в коде бэкенда большое количество событий, на которые подписываются фронтенды (паттерн Observer).
Следующий вопрос — как должны выглядеть сущности, которыми обмениваются слои. Здесь моим архитектурным решением было не перемудрить!
Несмотря на то, что для разных фронтендов нужны разные данные (из Telegram неплохо бы вытащить юзернейм и имя/фамилию, для PubNub же достаточно внутренних идентификаторов пользователя и канала), слои обмениваются плоскими frozen датаклассами с one of-семантикой:
@dataclass(frozen=True)
class Customer:
id: Any # в разных хранилищах разные идентификаторы,
# например, int в реляционках и ObjectId в MongoDB.
telegram_user_id: Optional[int] = None
telegram_first_name: Optional[str] = None
telegram_last_name: Optional[str] = None
telegram_username: Optional[str] = None
pubnub_user_id: Optional[str] = None
pubnub_channel_id: Optional[str] = None
Подразумевается, что в зависимости от того, через какую платформу с нами коммуницирует кастомер, будет заполнен разный набор полей.
Более подробно основные сущности описаны в документации.
После дизайна сущностей (очень недооценённый навык в разработке!) можно перейти к дизайну компонент. Для всех слоёв я определил абстрактные базовые классы:
# suppgram/backend.py
class Backend(abc.ABC):
on_new_conversation: Observable[ConversationEvent]
on_conversation_assignment: Observable[ConversationEvent]
on_conversation_resolution: Observable[ConversationEvent]
...
@abc.abstractmethod
async def create_or_update_agent(
self, identification: AgentIdentification, diff: Optional[AgentDiff] = None
) -> Agent:
pass
...
# suppgram/storage.py
class Storage(abc.ABC):
@abc.abstractmethod
async def create_or_update_customer(
self,
identification: CustomerIdentification,
diff: Optional[CustomerDiff] = None,
) -> Customer:
pass
@abc.abstractmethod
async def get_agent(self, identification: AgentIdentification) -> Agent:
pass
@abc.abstractmethod
def find_all_agents(self) -> AsyncIterator[Agent]:
pass
...
В отдельных пакетах лежат их реализации. Реализаций хранилища три — SQLAlchemy-based, MongoDB-based и in-memory для тестов. Реализация бэкенда, реализующего основную бизнес-логику, одна, называющаяся LocalBackend, но подразумевается, что когда-нибудь может потребоваться добавить удалённый бэкенд, который будет задеплоен отдельно и доступен по RPC. Фронтенды для Telegram написаны поверх python-telegram-bot.
Для быстрого запуска всей этой красоты без лишних мучений я сделал довольно мощный command line interface (CLI). Но для сколько-то нетривиального тюнинга через свои нужды придётся собрать свой скрипт, используя встроенный билдер.
Весь код можно посмотреть в репозитории на Github.
Что там с паттернами?
Паттерн... как много в этом звуке для разработчика сплелось.
Ситуация с паттернами в целом в разработке интересная. Мы как бы знаем, что middle+ разработчики должны их знать. Но на собеседованиях мы их (обычно) не спрашиваем, а избыточное налегание на паттерны порой делает код хуже, чем их незнание.
Конкретно в этом проекте список требований, который я для себя выписал, в значительной мере определил архитектуру; но, возможно, это просто опыт. При этом многие компоненты реализуют известные паттерны:
Компонент Storage, отвечающий за хранение — не что иное, как паттерн Repository.
К каждой реализации Storage у меня выделен ещё отдельный объект, отвечающий за названия коллекций/таблиц и их структуру — это близко к DAO (разница между этими двумя паттернами неочевидна, но есть). Этот компонент можно кастомизировать, если хочется что-то ещё хранить в таблицах, которыми управляет Suppgram, или изменить структуру для каких-то ещё нужд (например, аналитических).
Регистрация и обработка событий — это вышеупомянутый паттерн Observer. Как будто не нужно знать слово observer, чтобы догадаться сделать события, верно?
Компонент Builder, предназначенный для запуска Suppgram со своими текстами и другими кастомизациями — реализация, неожиданно, паттерна Builder. Если вы когда-нибудь кодили на Java, этот подход вряд ли будет сюрпризом.
В целом ощущение, что знать паттерны по названиям круто. Но вроде бы на этом и всё)
Роадмап
Я успел реализовать MVP — работу в Telegram и PubNub, элементарные функции управления и статистику. Есть много возможностей, которые я хотел бы реализовать, если проект окажется востребованным:
общение с клиентами через электронную почту и Instagram;
выгрузка событий в xls для аналитики;
запуск фронтендов и бэкенда в отдельных контейнерах с коммуникацией через RPC и message broker;
...
Если вы работали с поддержкой, пишите, что ещё можно добавить, чтобы расширить применимость.
Заключение
Это статья-релиз, которую я постарался сделать технически интересной, но без излишнего погружения в технические детали — надеюсь, это получилось. Будет здорово, если читателю будет полезен сам продукт; но если и просто архитектурные соображения были полезны, это тоже очень круто. Это мой первый опыт выкладывания чего-то в open-source на всеобщее обозрение, так что любой конструктивный фидбек будет полезен. Спасибо за внимание!
ebt
Привет, очень вкусный OSS с отличной лицензией, немного загружу вас вопросами, ничего?)
Выглядит как серьёзное и не совсем понятное ограничение — почему нельзя обойтись одним-единственным ботом? Вообще, предусмотрена ли возможность подключить другой мессенджер, например, вайбер или рокет-чат? Какой класс необходимо расширять если да?
И куда будет встраиваться LLM-ка? Опять же, она может быть локальная или удалённая. Сейчас без неё на подобной движухе никуда.
saluev Автор
Привет!
Одним-единственным ботом обойтись неудобно, если нужно одновременно чатиться с несколькими клиентами. Нужно как-то организовать в мессенджере множество чатов для агента. Можно было бы через группы с одним ботом сделать, но тогда их на каждого агента придётся индивидуальные плодить.
Подключить другой мессенджер можно. Нужно реализовать соответствующий фронтенд (клиентский или агентский). Можно по образцу телеграмного посмотреть, как он должен работать.
LLM-ка будет встраиваться в зависимости от того, как именно вы хотите её использовать. Подозреваю, что это будет изменение в бэкенде — классификация и либо отправление в очередь заявок как обычно, либо автоответ.