Привет, Хабр!
Компания АльфаСтрахование, как и многие другие, столкнулась с необходимостью замены используемых инструментов в связи с санкциями. За последний год мы отказались от 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)
Разберем этот кусок подробнее. Здесь три проверки:
-
Фильтруем только событие типа posted (сообщение)
if msg_obj["event"] == "posted"
-
Проверяем, что явно тегнули нашего бота в сообщении
and bot_mm_tag in msg_obj["data"]["post"]:
-
Проверяем, что пользователь содержится в списке разрешённых (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/
Добавить комментарий