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

от автора

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

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

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

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

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

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

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

Получение сообщений через 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))

Результаты

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

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

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

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

Также, есть планы доработать код, чтобы при отключении 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


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


Комментарии

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

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