Создание 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)
Разберем этот кусок подробнее. Здесь три проверки:
Фильтруем только событие типа 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