Привет! В этой статье расскажем, как мы в 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/
Добавить комментарий