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