Чат-бот для mattermost

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

bfdba5148a15038b7d360c83d320a597.jpg

Основное средство коммуникации для продуктовых команд у нас — 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-бот» — остается той же.

© Habrahabr.ru