Создание Chat-Ops бота в Mattermost на python

Привет, Хабр!

Компания АльфаСтрахование, как и многие другие, столкнулась с необходимостью замены используемых инструментов в связи с санкциями. За последний год мы отказались от Slack в пользу open-source аналога Mattermost, а Jira не без сложностей была заменена на Kaiten.

В нашей команде, которая занимается системой электронного документооборота в части операционных процессов, часто на голосовых встречах и при обсуждении в мессенджере Mattermost возникала необходимость накидать черновики задач в таск-трекер, чтобы потом их дозаполнить. Эта потребность наложилась на мое желание попробовать написать что-то на python, связанное с Chat-Ops.

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

Общее описание

Коллеги из другой команды подсказали, что слушать сообщения в Mattermost можно через websocket. Язык python был выбран, так как он используется в некоторых решениях нашей команды, также сыграло роль наличие готовой библиотеки для создания websocket-сервисов.

Бота сделал довольно простым, без сохранения контекста предыдущих сообщений, интерактивных кнопочек, к которым пришлось писать бы Web API — по сути, хотелось бы создать гибкий прототип, к которому это всё можно будет относительно безболезненно прикрутить в будущем. Схема получилась такая:

9fb75114d2cb65c1478cc90459804469.jpg

Получение сообщений через websocket — прототип

Для начала, я научился читать сообщения через websocket с помощью библиотеки websocket-client (https://pypi.org/project/websocket-client/).

Из примера, который представлен по ссылке выше, и документации Mattermost касательно websocket, я слепил примерно такое:

import logging
import logging.config
import websocket

class WebSocketMattermostApp:
    """
    Приложение, слушающее Mattermost через WebSocket
    """

    mm_ws_headers = dict()
    """
    Словарь заголовков для WebSocket-запросов к API Mattermost
    """

    connection: websocket.WebSocketApp
    """
    Соединение с WebSocket
    """

    def connect():
        """
        Подключается к WebSocket
        """

        # Заголовки для подключения к Mattermost через WebSocket
        WebSocketMattermostApp.mm_ws_headers["Authorization"] = "Bearer ****************"

        WebSocketMattermostApp.connection = websocket.WebSocketApp("wss://*************/api/v4/websocket",
                                    header=WebSocketMattermostApp.mm_ws_headers,
                                    on_open=WebSocketMattermostApp.ws_on_open,
                                    on_message=WebSocketMattermostApp.ws_on_message,
                                    on_error=WebSocketMattermostApp.ws_on_error,
                                    on_close=WebSocketMattermostApp.ws_on_close)
        WebSocketMattermostApp.connection.run_forever(reconnect=5)

    def disconnect():
        """
        Отключается от WebSocket
        """
        WebSocketMattermostApp.connection.close()

    def ws_on_message(ws, message):
        """
        Обрабатывает поступающие сообщения
        """
        print(message)
    def ws_on_error(ws, error):
        """
        Выполняет действия при ошибке
        """
        logging.error(f"Error: {error}")

    def ws_on_close(ws, close_status_code, close_msg):
        """
        Выполняет действия при закрытии соединения
        """
        logging.info(
            f"Connection closed {close_status_code} | {close_msg}")

    def ws_on_open(ws):
        """
        Выполняет действия при открытии соединения
        """
        logging.info("Connection opened")

# При запуске файла напрямую соединяется автоматически 
if __name__ == "__main__":
    WebSocketMattermostApp.connect()

В заголовке авторизации указывается токен бот-аккаунта Mattermost, а в URI для настройки подключения к websocket — хост инстанса Mattermost.

После запуска файла выше, если все настройки указаны правильно, приложение запустится и начнет писать в консоль все сообщения из всех каналов, которые может читать наша учетка бота.

Сущности, получаемые через websocket, бывают разные. Нас интересует тип posted (т.е. сообщение), пример которого приведен ниже:

{
    "event": "posted",
    "data": {
        "channel_display_name": "my_mm_channel",
        "channel_name": "my_mm_channel",
        "channel_type": "P",
        "post":"{\"id\":\"1qaz2wsx3edc4rfv\",\"create_at\":123456789,\"update_at\":123456789,\"edit_at\":0,\"delete_at\":0,\"is_pinned\":false,\"user_id\":\"1qaz2wsx3edc\",\"channel_id\":\"1qaz2wsx3edc\",\"root_id\":\"\",\"original_id\":\"\",\"message\":\"Test message\",\"type\":\"slack_attachment\",\"props\":{\"attachments\":[{\"id\":0,\"fallback\":\"\",\"color\":\"#CF0A2C\",\"pretext\":\"\",\"author_name\":\"\",\"author_link\":\"\",\"author_icon\":\"\",\"title\":\"Message attachment title\",\"title_link\":\"\",\"text\":\"\",\"fields\":null,\"image_url\":\"\",\"thumb_url\":\"\",\"footer\":\"message footer\",\"footer_icon\":\"\",\"ts\":null}],\"from_bot\":\"true\",\"from_webhook\":\"true\",\"override_icon_emoji\":\":love_letter:\",\"override_icon_url\":\"/static/emoji/1f48c.png\",\"override_username\":\"my_mm_bot\",\"webhook_display_name\":\"my_mm_bot\"},\"hashtags\":\"\",\"pending_post_id\":\"\",\"reply_count\":0,\"last_reply_at\":0,\"participants\":null,\"metadata\":{\"embeds\":[{\"type\":\"message_attachment\"}]}}",
        "sender_name": "my_mm_bot",
        "set_online": false,
        "team_id": "1qaz2wsx3edc4rfv"
    },
    "broadcast": {
        "omit_users": null,
        "user_id": "",
        "channel_id": "1qaz2wsx3edc4rfv",
        "team_id": "",
        "connection_id": "",
        "omit_connection_id": ""
    },
    "seq": 6
}

Как мы видим, в поле data.post пришедшего JSON находится второй, вложенный JSON; именно в нём и находится интересующая нас информация: текст сообщения, вложения к нему, ИД автора сообщения и т.д.:

{
    "id": "1qaz2wsx3edc4rfv",
    "create_at": 123456789,
    "update_at": 123456789,
    "edit_at": 0,
    "delete_at": 0,
    "is_pinned": false,
    "user_id": "1qaz2wsx3edc",
    "channel_id": "1qaz2wsx3edc",
    "root_id": "",
    "original_id": "",
    "message": "Test message",
    "type": "slack_attachment",
    "props": {
        "attachments": [
            {
                "id": 0,
                "fallback": "",
                "color": "#CF0A2C",
                "pretext": "",
                "author_name": "",
                "author_link": "",
                "author_icon": "",
                "title": "Message attachment title",
                "title_link": "",
                "text": "",
                "fields": null,
                "image_url": "",
                "thumb_url": "",
                "footer": "message footer",
                "footer_icon": "",
                "ts": null
            }
        ],
        "from_bot": "true",
        "from_webhook": "true",
        "override_icon_emoji": ":love_letter:",
        "override_icon_url": "/static/emoji/1f48c.png",
        "override_username": "my_mm_bot",
        "webhook_display_name": "my_mm_bot"
    },
    "hashtags": "",
    "pending_post_id": "",
    "reply_count": 0,
    "last_reply_at": 0,
    "participants": null,
    "metadata": {
        "embeds": [
            {
                "type": "message_attachment"
            }
        ]
    }
}

Разнесение функциональности

Разнесем функциональность работы с Кайтен, Mattermost (для отправки ответа) и обработки сообщения по разным файлам.

Логика обработки сообщения в Кайтен довольно проста — парсим текст сообщения регулярным выражением, извлекаем текст, взятый в кавычки — это и будет формулировка для заголовка создаваемый в Кайтен карточки. Указываем доску, дорожку и тип создаваемой задачи. Возвращаем числовой ИД созданной карточки в Кайтен. Переменные класса headers и client заполнит вызывающая сторона. Получается примерно так:

import http.client
import json
import re

class KaitenHelper:

    """
    Помощник для обращения к API Kaiten
    """

    headers = dict()
    """
    Словарь заголовков для запросов к API Kaiten
    """

    client: http.client.HTTPSConnection = None
    """
    Клиент к API Kaiten
    """

    def create_kaiten_card(message, creator_id):
        """
        Создает карточку в Kaiten
        """

        # Параметры по умолчанию
        board_id = 1  # ИД Доски
        lane_id = 2  # ИД Дорожки на Доске
        type_id = 26  # ИД типа карточки
        properties = {}

        # Текст задачи
        double_quotes_title_regex = re.compile(
            r'"(.+)"',
            flags=re.I | re.M)
        single_quotes_title_regex = re.compile(
            r'\'(.+)\'',
            flags=re.I | re.M)
        task_title_regex = re.compile(
            r'(задач|таск|kaiten|кайтен).*? (.+)',
            flags=re.I | re.M)
        title_search = double_quotes_title_regex.search(message)
        if title_search == None:
            title_search = single_quotes_title_regex.search(message)
        if title_search == None:
            title_search = task_title_regex.search(message)
        if title_search == None or len(title_search.groups()) == 0:
            return None
        title = title_search.groups()[-1]

        # Меняем часть значений для техдолговых задач
        tech_debt_regex = re.compile(
            r'(в тех.*долг|тех.*долг.+(задач|таск))',
            flags=re.I | re.M)
        if tech_debt_regex.search(message):
            # Параметры для задач Технического долга
            board_id = 4  # Доска: Технический долг
            lane_id = 2  # Дорожка: Важные
            type_id = 7  # Тип для технического долга

        body = json.dumps({
            "title": title,
            "board_id": board_id,
            "lane_id": lane_id,
            "owner_id": creator_id,
            "type_id": type_id,
            "properties": {}
        })

        KaitenHelper.client.request("POST", "/api/latest/cards",
                              body, KaitenHelper.headers)
        response = KaitenHelper.client.getresponse()
        response_obj = json.loads(response.read().decode())
        KaitenHelper.client.close()
        return response_obj["id"]

Аналогичным образом, создаем вторую утилиту для ответа нашему пользователю через API Mattermost:

import http.client
import json

class MmHelper:
    """
    Помощник для обращения к API Mattermost
    """

    headers = dict()
    """
    Словарь заголовков для запросов к API Mattermost
    """

    client: http.client.HTTPSConnection = None
    """
    Клиент к API Mattermost
    """

    def post_to_mm(message, channel_id, root_id="", props={}):
        """
        Отправляет в Mattermost сообщение
        """

        body = json.dumps({
            "channel_id": channel_id,
            "root_id": root_id,
            "message": message,
            "props": props
        })
        MmHelper.client.request("POST", "/api/v4/posts", body, MmHelper.headers)
        MmHelper.client.close()

Свяжет эти сущности воедино обработчик сообщений:

import re

from utils.kaiten_helper import KaitenHelper
from utils.mm_helper import MmHelper

bot_mm_tag = "@bot"
"""
Тэг бота в Mattermost
"""

create_kaiten_card_regex = re.compile(
    r'(созда|завед|нов[ау]|полож).+(задач|таск|kaiten|кайтен)', flags=re.I | re.M)
"""
Регулярное выражение для идентификации сценария создания карточки в Kaiten
"""

class IncomingPostHandler:
    """
    Обработчик входящих сообщений
    """

    users_of_bot = {}
    """
    Пользователи бота Mattermost
    """

    def handle(post_obj):
        """
        Обрабатывает входящее сообщение
        """
        reply = ""
        if create_kaiten_card_regex.search(post_obj['message']):
            reply = IncomingPostHandler.handle_create_kaiten_card(post_obj)
        else:
            reply = IncomingPostHandler.handle_help()

        mm_root_id = post_obj["id"] if post_obj["root_id"] == "" else post_obj["root_id"]
        MmHelper.post_to_mm(reply, post_obj["channel_id"], mm_root_id)

    def handle_create_kaiten_card(post_obj):
        """
        Обрабатывает сценарий создания карточки в Kaiten
        """
        kaiten_creator_id: int = next(
            (user["kaiten_id"]
             for user in IncomingPostHandler.users_of_bot if user["id"] == post_obj["user_id"]),
            None)
        created_id = KaitenHelper.create_kaiten_card(
            post_obj['message'], kaiten_creator_id)
        if created_id == None:
            return ":( Не смог определить текст задачи"
        else:
            return f":white_check_mark: Создал задачу {created_id}\n:earth_africa: [Открыть в Kaiten](https://kaiten.mycompany.com/space/36/card/{created_id})"

    def handle_help():
        """
        Обрабатывает сценарий приветствия / просьбы о помощи
        """
        return "Привет! Не смог распознать вашу команду"

Обработчик (опять же, с помощью регулярных выражений) определяет, что данным сообщением пользователь хочет создать карточку задачи в Кайтен:

create_kaiten_card_regex = re.compile(
    r'(созда|завед|нов[ау]|полож).+(задач|таск|kaiten|кайтен)', flags=re.I | re.M)

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

        mm_root_id = post_obj["id"] if post_obj["root_id"] == "" else post_obj["root_id"]
        MmHelper.post_to_mm(reply, post_obj["channel_id"], mm_root_id)

Осталось правильно сконфигурировать эти сущности, поэтому доработаем класс WebSocketMattermostApp, добавив в него метод configure ():

    def configure():
        """
        Конфигурирует приложение из файла config.json
        """

        # Настраиваем путь к конфигам аналогично данному файлу
        os.chdir(os.path.dirname(os.path.abspath(__file__)))

        # Добавляем логирование на основе файла
        with open('logging_config.json') as file:
            logging.config.dictConfig(json.load(file))

        with open('config.json') as file:
            global config
            config = json.load(file)
        logging.info("Configuration files loaded")

        # Клиент к API Mattermost
        MmHelper.client = http.client.HTTPSConnection(config["mattermost"]["host"])
        mm_auth = f"Bearer {config['mattermost']['token']}"
        MmHelper.headers["Authorization"] = mm_auth
        MmHelper.headers["Content-Type"] = "application/json"

        # Заголовки для подключения к Mattermost через WebSocket
        WebSocketMattermostApp.mm_ws_headers["Authorization"] = mm_auth

        # Адрес для подключения к Mattermost через WebSocket
        global mm_ws_url
        mm_ws_url = f"wss://{config['mattermost']['host']}/api/v4/websocket"

        # Клиент к API Kaiten
        KaitenHelper.client = http.client.HTTPSConnection(config["kaiten"]["host"])

        KaitenHelper.headers["Authorization"] = f"Bearer {config['kaiten']['token']}"
        KaitenHelper.headers["Content-Type"] = "application/json"

        IncomingPostHandler.users_of_bot = config['mattermost_allowed_users']

        logging.info("Configuration completed")

Файл конфигурации выглядит так:

{
    "mattermost_allowed_users": [
        {
            "id": "1f84d516a1494c1b9057f89fb2eab2d0",
            "name": "ivanovii",
            "kaiten_id": 123
        },
        {
            "id": "287422ffa5984bfd8fc720c0e1960fef",
            "name": "petrovapp",
            "kaiten_id": 456
        },
    ],
    "mattermost": {
        "host": "mattermost.mycompany.com",
        "token": "4256f55502e64dd59471e424653b1d4d"
    },
    "kaiten": {
        "host": "kaiten.mycompany.com",
        "token": "e90c03b3-cddb-4053-a7ba-201b0e581e08"
    }
}

Сразу скажу, что я пока не заморачивался с моделями тех объектов, которые получаю из конфигурации, так как приведенное решение — лишь прототип.

Поле mattermost_allowed_users в конфиге выше содержит массив объектов, представляющих собой пользователя Mattermost, для которого примаплен идентификатор пользователя из Kaiten.

Собственно, вот кусок, где мы конфигурируем как MmHelper, так и подключение к Websocket:

        # Клиент к API Mattermost
        MmHelper.client = http.client.HTTPSConnection(config["mattermost"]["host"])
        mm_auth = f"Bearer {config['mattermost']['token']}"
        MmHelper.headers["Authorization"] = mm_auth
        MmHelper.headers["Content-Type"] = "application/json"

        # Заголовки для подключения к Mattermost через WebSocket
        WebSocketMattermostApp.mm_ws_headers["Authorization"] = mm_auth

Тогда метод connect () будет использовать то, что прилетело из конфига:

    def connect():
        """
        Подключается к WebSocket
        """

        WebSocketMattermostApp.configure()
        WebSocketMattermostApp.connection = websocket.WebSocketApp(mm_ws_url,
                                    header=WebSocketMattermostApp.mm_ws_headers,
                                    on_open=WebSocketMattermostApp.ws_on_open,
                                    on_message=WebSocketMattermostApp.ws_on_message,
                                    on_error=WebSocketMattermostApp.ws_on_error,
                                    on_close=WebSocketMattermostApp.ws_on_close)
        WebSocketMattermostApp.connection.run_forever(reconnect=5)

KaitenHelper конфигурируем аналогично:

        # Клиент к API Kaiten
        KaitenHelper.client = http.client.HTTPSConnection(config["kaiten"]["host"])

        KaitenHelper.headers["Authorization"] = f"Bearer {config['kaiten']['token']}"
        KaitenHelper.headers["Content-Type"] = "application/json"

И, наконец, передаем список пользователей в IncomingPostHandler, это нужно для сопоставления пользователя, приславшего сообщение, и создателя карточки в Kaiten:

        IncomingPostHandler.users_of_bot = config['mattermost_allowed_users']

Вернемся к изменениям в WebSocketMattermostApp. Поменяем метод обработки сообщения:

    def ws_on_message(ws, message):
        """
        Обрабатывает поступающие сообщения
        """
        msg_obj = json.loads(message)
        
        # Отбираем входящие сообщения с упоминанием my_tag
        if msg_obj["event"] == "posted" and bot_mm_tag in msg_obj["data"]["post"]:
            post_obj = json.loads(msg_obj["data"]["post"])
            
            # Ищем пользователя в списке разрешенных
            found_user = next(
                (mm_user for mm_user in config["mattermost_allowed_users"] if mm_user["id"] == post_obj["user_id"]),
                None)
            if found_user != None:
                IncomingPostHandler.handle(post_obj)

Разберем этот кусок подробнее. Здесь три проверки:

  1. Фильтруем только событие типа posted (сообщение)

        if msg_obj["event"] == "posted" 
  1. Проверяем, что явно тегнули нашего бота в сообщении

       and bot_mm_tag in msg_obj["data"]["post"]:
  1. Проверяем, что пользователь содержится в списке разрешённых (mattermost_allowed_users в конфиге).

            if found_user != None:

Если все проверки прошли, передаем сообщение IncomingPostHandler:

                IncomingPostHandler.handle(post_obj)

Обратим внимание на последний блок кода в классе WebSocketMattermostApp:

# При запуске файла напрямую соединяется автоматически 
if __name__ == "__main__":
    WebSocketMattermostApp.connect()

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

Развертывание

Для развертывания бота был выбран один из корпоративных Kubernetes кластеров, соответственно, чтобы приложение могло успешно репортить о своем состоянии, его пришлось обернуть во Flask. Соответственно, реализовал элементарные пробы, вынес конфигурирование логирования в этот файл, и запускаю WebSocketMattermopstApp в отдельном потоке:

import json
import logging
import os
from threading import Thread
from flask import Flask
from ws_mm_app import WebSocketMattermostApp

app = Flask('mattermost-chat-ops-bot')

@app.route('/liveness')
@app.route('/readyness')
def getProbe():
    """
    Возвращает результат пробы для k8s
    """
    return "Healthy"

with app.app_context():
    # Настраиваем рабочую папку
    os.chdir(os.path.dirname(os.path.abspath(__file__)))

    # Добавляем логирование на основе файла
    with open('logging_config.json') as file:
        logging.config.dictConfig(json.load(file))
    logging.info("Logging configuration completed")

    # Стартуем WebSocket-приложение в отдельном потоке
    ws_thread = Thread(target=WebSocketMattermostApp.connect)
    ws_thread.start()

# Запускаем Flask-приложение при прямом вызове файла
if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

Чтобы файлы конфига всегда подхватывались правильно, командой

        os.chdir(os.path.dirname(os.path.abspath(__file__)))

меняется рабочая директория — на ту, где лежит сам файл.

Конфигурация логирования вынесена в отдельный json-файл, который имеет следующую структуру:

{
    "version": 1,
    "formatters": {
        "default": {
            "format": "%(asctime)s|%(levelname)s|%(module)s|%(message)s"
        }
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "stream": "ext://sys.stdout",
            "formatter": "default"
        }
    },
    "root": {
        "level": "INFO",
        "handlers": [
            "console"
        ]
    }
}

В коде просто считываем этот файл, десериализуем и передаем объект в стандартный метод настройки logging:

        with open('logging_config.json') as file:
            logging.config.dictConfig(json.load(file))

Результаты

Взаимодействие с ботом выглядит примерно так:

ddc76ba9ad42894bfbe4b67aec97c7ba.jpg

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

Мне же в итоге удалось немного разбавить привычный стек и узнать что-то новое.

В будущем нужно вынести в конфиг переменные Кайтена, тэг бота и другие значения из кода.

Также, есть планы доработать код, чтобы при отключении Mattermost бот через какое-то время мог повторить попытку подключения. Состояние подключения к вебсокету я осознанно не стал отражать в пробах, поскольку, если Mattermost не работает, кластер kubernetes явно не сможет решить эту проблему, а пользователи всё равно не смогут написать боту. В общем, в плане устойчивости есть еще куда двигаться.

Ссылки

https://github.com/AlfaInsurance/mattermost_chatops — Репозиторий с кодом из статьи

https://developers.kaiten.ru/ — API Kaiten

https://api.mattermost.com/ — API Mattermost (в т. ч. про WebSocket)

https://pypi.org/project/websocket-client/ — Описание библиотеки websocket-client

© Habrahabr.ru