Чат-бот для mattermost

от автора

Привет! В этой статье расскажем, как мы в hh.ru сделали удобное общение с корпоративной wiki в привычном формате коммуникации — написали чат-бота для поиска по внутренней базе знаний. Для нас тема оказалась довольно актуальной, может и вам пригодится.

Основное средство коммуникации для продуктовых команд у нас — mattermost. В качестве базы знаний используем Confluence. Но расширенный поиск Confluence имеет специфический интерфейс, а нам хочется получить удобный и быстрый способ расширенного поиска. Для ускорения и упрощения поиска в нашей обширной базе знаний было принято решение написать чат-бота в mattermost. Основной задачей бота стал вывод результатов поиска в личный чат с пользователем. Бота писали на Python: в качестве базы решили использовать фреймворк mmpy_bot, а для работы с confluence использовался confluenceAPI.  

Коротко о mmpy_bot и confluenceAPI

Фреймворк mmpy_bot предоставляет возможность работы с вызовами API mattermost с помощью собственных декораторов и методов через систему подключаемых модулей, реализующих логику бота — плагинов. Также бот имеет встроенный вебхук-сервер, который поможет при работе с интерактивом. С документацией можно ознакомиться здесь

Собственно, для работы нам понадобятся декораторы — @listen_to, отвечающий за обработку сообщений, и @listen_webhook, предназначенный для работы с вебхуками. Для отправки сообщений у драйвера бота есть два метода: create_post(), который создает новое сообщение в канале, и reply_to(), который отвечает за отправку сообщений в тред к имеющемуся сообщению. Мы будем использовать reply_to().

Для работы с confluence будем использовать фреймворк, из которого нам понадобится всего один метод Confluence.cql(), отвечающий за отправку и обработку CQL запроса на сервер confluence. 

Запуск и настройка бота

Перед тем как начать писать бота, нам надо создать токены для него в mattermost и confluence. После получения токенов необходимо создать 2 файла — mm-bot.py, в котором мы запускаем бот и подключаем наш плагин, и plugin.py, где описываем всю логику работы бота.

Начнем с наиболее простого — с инициализации бота:

Показать код
import json import sys  from mmpy_bot import Bot, Settings from plugin import SearchPlugin  try:     with open('config.json', 'r', encoding='utf-8') as config:         settings = json.loads(config.read())['wiki-search-bot'] except IOError as e:     print(f'Unable to read config! Reason: {e}')     sys.exit(1)  bot = Bot(     settings=Settings(         MATTERMOST_URL=settings['mattermost_host'],         MATTERMOST_PORT=settings['mattermost_port'],         MATTERMOST_API_PATH='/api/v4',         BOT_TOKEN=settings['mattermost_token'],         BOT_TEAM=settings['team_name'],         SSL_VERIFY=False,         WEBHOOK_HOST_ENABLED=True,         WEBHOOK_HOST_URL=settings['webhook_host'],         WEBHOOK_HOST_PORT=settings['webhook_self_port'],     ),     plugins=[SearchPlugin()], ) bot.run()

Давайте разбираться что здесь происходит. В самом начале мы считываем настройки нашего бота из json с конфигами. Сам конфиг выглядит следующим образом:

Показать код
{         "mattermost_host": "https://адрес.сервера.маттермост",         "mattermost_port": "порт.сервера.маттермост",         "mattermost_token": "токен_бота_mattermost",         "team_name": "имя команды",         "webhook_host": "http://адрес.хоста",         "webhook_self_port": "8579",         "webhook_external_port": "8579",         "confluence_url": "https://адрес.базы.знаний",         "confluence_token": "токен_бота_confluence" }  

Далее создаем объект нашего бота с указанными параметрами. Из названий параметров очевидно, за что они отвечают и для чего нужны. Дополнительно следует отметить блок параметров WEBHOOK_% — они предназначены для работы встроенного вебхук-сервера. Но об этом позже. После инициализации объекта происходит запуск бота через вызов метода run(). Сам бот можно запустить через команду в консоли python mm_bot.py.

Алгоритм работы бота

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

Начнем с простого. Общаться с ботом будем только через личные сообщения — путем отправки сообщений с ключевыми словами. Для этого необходимо написать соответствующие обработчики в нашем плагине для бота — файле plugin.py. Однако пользователь может и не знать о наличии ключевых слов, поэтому нужно написать обработку любых сообщений, которые не включают ключевые слова. Такой метод-обработка будет выводить небольшую информационную подсказку по использованию бота.

Начнем с того, что перед нашим методом разместим декоратор @listen_to(). Он указывает боту “слушать” каналы, в которые он добавлен, на появление сообщения указанного в качестве параметра декоратора. В текущем примере — это регулярное выражение обрабатывающее любые сообщения кроме “Найди”. Количество параметров декоратора может варьироваться в зависимости от количества capture group в регулярке. 

Показать код
@listen_to("^((?!Найди).)*$", re.IGNORECASE) async def hello(self, message: Message, status):         blocks = [             Section(                 title=f'Приветствую!',                 text=f'Я осуществляю поиск по базе знаний [wiki]({confluence_url}). Поиск осуществляется только по публичным страницам.\nЧтобы начать поиск используйте слово **Найди** и ваш запрос, пример: **Найди цели и планы**',                 ])))         ]         mes_json = {'attachments': [block.asdict() for block in blocks]}         self.driver.reply_to(message, '', props=mes_json) 

Тут необходимы пояснения. В самом методе происходит что-то непонятное. На самом деле так всего лишь формируются интерактивные сообщения. Для удобства формирования таких сообщений были созданы датаклассы:

Показать код
@dataclass class Field:     title: str     value: str     short: bool = True  @dataclass class Section:     title: Optional[str] = None     text: Optional[str] = None     fields: Optional[List[Field]] = None      def asdict(self):         res = {}         if self.fields:             res['fields'] = [asdict(field) for field in self.fields]         if self.title:             res['title'] = str(self.title)         if self.text:             res['text'] = str(self.text)          return res

Теперь давайте добавим в информационное сообщение данные по настройкам в боте поиска по умолчанию:

Показать код
@listen_to("^((?!Найди).)*$", re.IGNORECASE) async def hello(self, message: Message, status):         blocks = [             Section(                 title=f'Приветствую!',                 text=f'Я осуществляю поиск по базе знаний [wiki]({confluence_url}). Поиск осуществляется только по публичным страницам.\nЧтобы начать поиск используйте слово **Найди** и ваш запрос, пример: **Найди цели и планы**',                 fields=list(filter(None, [                     Field(title='Поиск осуществляется с предустановленными настройками:', value='', short=False),                     Field(title='Пространства', value='QA, DEV'),                     Field(title='Период', value='Последний год'),                     Field(title='Содержимое', value='Страница'),                 )         ]         mes_json = {'attachments': [block.asdict() for block in blocks]}         self.driver.reply_to(message, '', props=mes_json)

Пользователей мы проинформировали. Теперь надо реализовать поиск в базе знаний. Для этого напишем обработчик ключевого слова “Найди”:

Показать код
    @listen_to("Найди (.*)", re.IGNORECASE)     async def search(self, message: Message, text_to_search):         search_result = []         log.info(f'Запрошен поиск "{text_to_search}"')         search = Search(search_text=text_to_search)         query = SearchQuery(search_text=text_to_search)          label_response = search_by_label(text_to_search)         find_response = self.query(query)          if label_response != '':             label_search_result = label_response             for res in find_response['results']:                 for label_res in label_search_result['results']:                     if label_res['content']['id'] == res['content']['id']:                         search_result.append(res)              for res in find_response['results']:                 flag = False                 for label_res in label_search_result['results']:                     if label_res['content']['id'] == res['content']['id']:                         flag = True                 if not flag:                     search_result.append(res)         else:             search_result = find_response['results']          search.search_results = search_result         self.print_search_result(message, search)

В методе осуществляется вызов двух методов search_by_label() и self.query():

Показать код
def search_by_label(label_text):     confluence = Confluence(url=confluence_url, token=confluence_token)     cql = f'type="page" AND label="{label_text}"'     try:         response = confluence.cql(cql, start=0, limit=100, expand=None, include_archived_spaces=None, excerpt=None)         return response     except Exception as e:         log.error(f'Unable to parse cql query. Reason: {e} cql: {cql}')     return ''

Оба метода отвечают за поиск в базе знаний, но search_by_label() формирует особый cql-запрос, отвечающий за поиск по меткам/лейблам (labels) статей, то есть нужная фраза ищется среди меток. Такое разделение обосновано желанием приоритезировать выдачу статей с подходящими метками в ответе бота. В методе query() осуществляется формирование cql-запроса в зависимости от выбранных нами параметров и вызов метода advanced_search_on_wiki() для осуществления поиска.

Показать код
def query(self, query: SearchQuery):         period_postfix = ''         space_list = []         content_list = []         content_postfix = ''         space_postfix = ''         label_postfix = ''         title_postfix = ''          period_postfix = f' and lastmodified > {query.modify_period}'         if query.HHQA:             space_list.append('"HHQA"')         if query.HHDEV:             space_list.append('"HHDEV"')         space_postfix = f' and space in ({",".join(space_list)})'          if query.page:             content_list.append('"page"')         if query.blogpost:             content_list.append('"blogpost"')         if query.comment:             content_list.append('"comment"')         if query.attachment:             content_list.append('"attachment"')         content_postfix = f' and type in ({",".join(content_list)})'          if query.label_text != '' and query.label_text is not None:             label_postfix = f' and label = "{query.label_text}"'         if query.title_text != '' and query.title_text is not None:             label_postfix = f' and title ~ "{query.title_text}"'          query.search_request = f'"{query.search_text}"{space_postfix}{content_postfix}{period_postfix}{label_postfix}{title_postfix}'         response = advanced_search_on_wiki(query.search_request)          return response

Интерактив с пользователем

Итак, наш бот теперь умеет не только информировать, но и отправлять поисковые запросы. Но мы хотим пойти дальше и наладить “диалог” с ним. Для этого расширим используемую функциональность интерактивных сообщений mattermost в наш чат-бот. 

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

Для этого отправим специальное сообщение, которое будет предлагать нам вывести оставшиеся результаты запроса или сформировать расширенный запрос в специальном диалоговом окне:

Показать код
 def print_search_result(self, message: Message, search: Search):         total_count = len(search.search_results)          if total_count > 0:             blocks = [                 Section(                     text=f'Вот **ТОП-{max(total_count, 5)}** того что я нашел я нашёл по запросу "***{search.search_text}***":'                 )             ]             message_json = {'attachments': [block.asdict() for block in blocks]}             self.driver.reply_to(message, '', props=message_json)              for result in search.search_results[0:5]:                 title = result['title'].replace("@@@hl@@@", "**").replace("@@@endhl@@@", "**")                 url = result['url']                 excerpt = result['excerpt'].replace("@@@hl@@@", "**").replace("@@@endhl@@@", "**")                 blocks = [                     Section(title=f'[{title}]({confluence_url + url})',                             text=f'{excerpt}'                             )                 ]                 mes_json = {'attachments': [block.asdict() for block in blocks]}                 self.driver.reply_to(message, '', props=mes_json)              if total_count > 5:                 self.driver.reply_to(                     message,                     "",                     props={                         "attachments": [                             {                                 "pretext": None,                                 "text": f"Всего найдено **{total_count}** записей. Вывести остальные {total_count - 5} результаты поиска?",                                 "actions": [                                     {                                         "id": "yes",                                         "name": "Да",                                         "integration": {                                             "url": f"{webhook_host}:{webhook_external_port}/hooks/yes",                                             "context": dict(channel_id=message.channel_id,                                                             reply_id=message.reply_id,                                                             search_response=search.search_results)                                         },                                     },                                     {                                         "id": "advanced",                                         "name": "Расширенный поиск",                                         "integration": {                                             "url": f"{webhook_host}:{webhook_external_port}"                                                    "/hooks/advanced",                                             "context": dict(channel_id=message.channel_id,                                                             reply_id=message.reply_id,                                                             search_text=search.search_text)                                         },                                     },                                 ],                             }                         ]                     },                 )             else:                 blocks = [                     Section(                         text=f'Всего найдено **{total_count}** записей.'                     )                 ]                 message_json = {'attachments': [block.asdict() for block in blocks]}                 self.driver.reply_to(message, '', props=message_json)          else:             blocks = [                 Section(                     text=f'По запросу "***{search.search_text}***" ничего не найдено.'                 )             ]             message_json = {'attachments': [block.asdict() for block in blocks]}             self.driver.reply_to(message, '', props=message_json) 

Для работы полноценного интерактива воспользуемся механизмом интерактивных диалогов самого mattermost. Его суть проста — как и в случае с интерактивными сообщениями, мы отправляем post-запрос по адресу {mattermost_host}:{mattermost_port}/api/v4/actions/dialogs/open, в теле которого идет специально сформированный json. На основе этого json mattermost создает диалоговое окно с заданными параметрами. После заполнения полей диалога и нажатии на кнопку отправки сервер mattermost отправляет запрос на наш вебхук.

Показать код
    @listen_webhook("advanced")     async def advanced_search_form(self, event: WebHookEvent):         msg_body = dict(data=dict(             post=dict(channel_id=event.body['context']['channel_id'], root_id=event.body['context']['reply_id'])))         search_text = event.body['context']['search_text']         msg = Message(msg_body)         if isinstance(event, ActionEvent):             payload = {                 "trigger_id": event.body['trigger_id'],                 "url": f"{webhook_host}:{webhook_external_port}/hooks/adv_search",                 "dialog": {                     "callback_id": f'{msg_body}',                     "title": "Расширенный поиск",                     "elements": [                         {                             "display_name": "Строка поиска",                             "placeholder": "Искать указанную фразу",                             "default": f'{search_text}',                             "name": "search_text",                             "type": "text",                             "optional": False                         },                         {                             "display_name": "Пространства:",                             "name": "QA",                             "placeholder": "QA",                             "type": "bool",                             "optional": True,                             "default": "True"                         },                         {                             "display_name": "",                             "name": "DEV",                             "placeholder": "DEV",                             "type": "bool",                             "optional": True,                             "default": "True"                         },                         {                             "display_name": "Дата последних изменений",                             "name": "modify_period",                             "type": "radio",                             "optional": False,                             "options": [                                 {                                     "text": "День",                                     "value": "now(\"-1d\")"                                 },                                 {                                     "text": "Неделя",                                     "value": "now(\"-1w\")"                                 },                                 {                                     "text": "Месяц",                                     "value": "now(\"-1M\")"                                 },                                 {                                     "text": "Год",                                     "value": "now(\"-1y\")"                                 }                             ],                             "default": "now(\"-1y\")"                         },                         {                             "display_name": "Искать в метках?",                             "placeholder": "Поиск по меткам",                             "name": "label_text",                             "help_text": "Поиск будет осуществляться только в статьях с указанной меткой",                             "type": "text",                             "optional": True                         },                         {                             "display_name": "Искать в заголовках?",                             "placeholder": "Поиск по заголовкам",                             "name": "title_text",                             "help_text": "Поиск будет осуществляться только в статьях с указанным заголовком",                             "type": "text",                             "optional": True                         },                         {                             "display_name": "Содержимое:",                             "name": "page",                             "placeholder": "Страница",                             "type": "bool",                             "optional": False,                             "default": "true"                         },                         {                             "display_name": "",                             "name": "blogpost",                             "placeholder": "Блог",                             "type": "bool",                             "optional": True,                             "default": "false"                         },                         {                             "display_name": "",                             "name": "comment",                             "placeholder": "Комментарий",                             "type": "bool",                             "optional": True,                             "default": "false"                         },                         {                             "display_name": "",                             "name": "attachment",                             "placeholder": "Приложение",                             "type": "bool",                             "optional": True,                             "default": "false"                         },                     ],                     "submit_label": "Искать",                     "state": "somestate"                 }             }             requests.post(f"{mattermost_host}:{mattermost_port}/api/v4/actions/dialogs/open",                           json=payload)          else:             self.driver.reply_to(msg, "Что-то пошло не так") 

Webhook-сервер

Бот позволяет развернуть вебхук-сервер. Он нужен для работы с интерактивными сообщениями и диалогами mattermost. Для создания вебхука используется декоратор @listen_webhook(«»), в параметрах которого прописывается конечный адрес хука:

Показать код
    @listen_webhook("adv_search")     async def form_listener(self, event: WebHookEvent):         search_query = SearchQuery(**event.body['submission'])         msg_body = event.body['callback_id']         msg = Message(json.loads(msg_body.replace("'", "\"")))         log.info(f'Запрошен поиск (расширенный) "{search_query.search_text}"')         search_result = Search(search_text=search_query.search_text, search_results=self.query(search_query)['results'])         self.print_search_result(msg, search_result) 

Выше приведен код вебхука, отвечающего за “расширенный поиск” в нашей базе знаний. Кнопка отправки в диалоге отправляет запрос на этот хук. Этот момент мы уже указали в параметре “url” json, отвечающем за генерацию диалогового окна:

"url": f"{webhook_host}:{webhook_external_port}/hooks/adv_search"

Условно получившуюся схему можно визуализировать следующим образом:

Следует отметить, что в случае если наш бот запускается не на машине, где развернут mattermost, то у его сервера должен быть доступ к адресу и порту, где запускается вебхук-сервер бота. А внутри самого сервера mattermost должно стоять разрешение на принятие запросов с адреса вебхук-сервера.

Ну вот и бот

Вот так у нас и появился чат-бот, готовый круглосуточно предоставлять требуемую информацию в личный чат пользователя. 

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


ссылка на оригинал статьи https://habr.com/ru/articles/727246/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *