
Привет, я Максим Королев из Петрович-Теха. В прошлой статье про «Дежурного» я рассказывал, как мы ушли от «толстых» обработчиков aiogram к архитектуре CORE + два адаптера — Telegram и MAX.
С замедлением Telegram стало понятно, что нам нужен стабильный резервный канал. Часть команды уже перешла на MAX, часть осталась в Telegram. И вот мы столкнулись с классической проблемой — один коллега пишет в TG, другой отвечает из MAX, диалог разрывается, контекст теряется, инцидент ведется «вслепую». Нужен был мост. И мы ввели параллельную работу в Telegram и в MAX, соединили эти два мира между собой и глубже встроили бота в процессы ITIL 4/ITSM.
Главное сделали так, что:
коллеги могут общаться между собой «TG ↔ MAX», не теряя диалог
при закрытии инцидента бот автоматически архивирует историю общения в Jira — для дальнейшего RCA
В этой статье расскажу:
как развивался «Дежурный»
как устроен мост между чатами Telegram и MAX (с примером кода)
как реализована автоархивация диалога в Jira (с примером кода)
и почему всё это хорошо ложится на ITIL 4 / ITSM
Один CORE, два мессенджера и «единый диалог»
Архитектурная база осталась той же.
CORE: ничего не знает про Telegram и MAX; оперирует инцидентами, регламентными работами, приоритетами, SLA; принимает reply_fn и при необходимости telegram_bot.
Адаптеры Telegram/MAX: разбирают вход (команды, payload, сообщения); проверяют права, вызывают функции CORE и/или пересылают сообщения.
Когда мы решили сделать единый диалог поверх двух мессенджеров, базовая идея была простой. Есть один логический «чат по инциденту» и два его «физических отображения» — чат в Telegram и чат в MAX. И бот знает, какие чаты связаны, и ретранслирует сообщения между ними.
Так как CORE ничего не знает о конкретных каналах связи, просто пишешь адаптер к любому мессенджеру, не трогая логику управления инцидентами. Это сильно экономит время на тестирование и снижает риск сломать что-то в бою при добавлении нового канала.
Пример: мост между Telegram и MAX
Хранилище связок чатов
Сначала нам нужен простой маппинг «инцидент → TG-чат и MAX-чат»:
# bridges.py from dataclasses import dataclass from typing import Dict, Optional @dataclass class ChatBridge: incident_id: str telegram_chat_id: int max_chat_id: str # формат зависит от maxapi # incident_id -> ChatBridge _bridges: Dict[str, ChatBridge] = {} def register_bridge(incident_id: str, telegram_chat_id: int, max_chat_id: str) -> None: _bridges[incident_id] = ChatBridge( incident_id=incident_id, telegram_chat_id=telegram_chat_id, max_chat_id=max_chat_id, ) def get_bridge_by_incident(incident_id: str) -> Optional[ChatBridge]: return _bridges.get(incident_id) def get_bridge_by_telegram_chat(chat_id: int) -> Optional[ChatBridge]: for bridge in _bridges.values(): if bridge.telegram_chat_id == chat_id: return bridge return None def get_bridge_by_max_chat(chat_id: str) -> Optional[ChatBridge]: for bridge in _bridges.values(): if bridge.max_chat_id == chat_id: return bridge return None
Регистрация связки может происходить:
при создании инцидента (когда бот создаёт/привязывает рабочие чаты);
или при явной команде «связать этот TG-чат и этот MAX-чат с инцидентом X».
Telegram: прием сообщения и пересылка в MAX
Пример адаптера на aiogram:
# adapters/telegram/bridge_handlers.py from aiogram import Router, F from aiogram.types import Message from .max_client import send_message_to_max_chat from .bridges import get_bridge_by_telegram_chat router = Router() @router.message(F.text) async def on_telegram_message(message: Message): # Системные команды обрабатываются своими хендлерами if message.text.startswith("/"): return bridge = get_bridge_by_telegram_chat(message.chat.id) if not bridge: # Этот чат ни с чем не связан — обычный диалог, ничего не шлём в MAX return text = message.text # Добавляем лёгкий маркер, кто и откуда пишет prefix = f"[TG] {message.from_user.full_name}: " relay_text = prefix + text await send_message_to_max_chat( chat_id=bridge.max_chat_id, text=relay_text, ) send_message_to_max_chat — это тонкая обёртка над maxapi: # adapters/telegram/max_client.py from maxapi import MaxBotClient # пример, зависит от вашей обёртки max_client = MaxBotClient(token="...") async def send_message_to_max_chat(chat_id: str, text: str): await max_client.send_text( chat_id=chat_id, text=text, )
MAX: прием сообщения и пересылка в Telegram
Со стороны maxapi идея та же:
# adapters/max/bridge_handlers.py from maxapi.dispatcher import Dispatcher from maxapi.events import MessageCreated from .telegram_client import send_message_to_telegram_chat from .bridges import get_bridge_by_max_chat dp = Dispatcher() @dp.message_created() async def on_max_message(event: MessageCreated): message = event.message text = getattr(message, "text", "") or "" # Команды, callback-и и служебные вещи обрабатываются отдельно if text.startswith("/"): return chat_id = getattr(message.chat, "chat_id", None) if not chat_id: return bridge = get_bridge_by_max_chat(chat_id) if not bridge: return user = getattr(message, "from_", None) user_name = getattr(user, "display_name", "MAX user") prefix = f"[MAX] {user_name}: " relay_text = prefix + text await send_message_to_telegram_chat( chat_id=bridge.telegram_chat_id, text=relay_text, )
И небольшой Telegram-клиент:
# adapters/max/telegram_client.py from aiogram import Bot telegram_bot = Bot(token="...") async def send_message_to_telegram_chat(chat_id: int, text: str): await telegram_bot.send_message( chat_id=chat_id, text=text, disable_web_page_preview=True, )
В итоге:
Коллега А пишет в TG — сообщение уходит в связанный MAX-чат.
Коллега Б отвечает из MAX — сообщение уходит в TG.
Все видят единый диалог, просто в своём любимом клиенте.
Про медиа-контент. Сейчас бот пересылает только текст. Фото, видео, документы в планах, но пока не реализовано (нет острой нужды). Если у вас в команде активно шлют скриншоты в инцидент-чаты, это важно учесть заранее.
В коде мы отсекаем сообщения, начинающиеся с / (служебные команды). Они обрабатываются собственными хендлерами и в MAX не уходят. Это сделано намеренно, чтобы не засорять чат командным мусором.
Пример: автоархивация диалога в Jira при закрытии инцидента
Теперь к второй важной части — автоархивации диалога.
Сформулировали идею — пока инцидент «живой», в связанных чатах идёт обсуждение. При закрытии инцидента бот собирает историю диалога (по связанным чатам и ID инцидента), красиво форматирует, сохраняет в Jira как комментарий (наш вариант) или как вложение.
Хранилище событий чата по инциденту
Лог завязан не на конкретный чат, а на incident_id: бот пишет все сообщения из связанных TG- и MAX-чатов в одну ленту, получается единый диалог поверх всех каналов. Пример простого in‑memory-хранилища (у вас ваша БД):
# conversation_log.py from dataclasses import dataclass from datetime import datetime from typing import Dict, List @dataclass class ChatMessage: timestamp: datetime source: str # "TG" или "MAX" user: str text: str # incident_id -> список сообщений _logs: Dict[str, List[ChatMessage]] = {} def append_message(incident_id: str, source: str, user: str, text: str) -> None: _logs.setdefault(incident_id, []).append( ChatMessage( timestamp=datetime.utcnow(), source=source, user=user, text=text, ) ) def get_messages(incident_id: str) -> List[ChatMessage]: return _logs.get(incident_id, []) def clear_log(incident_id: str) -> None: _logs.pop(incident_id, None) Теперь доработаем мосты, чтобы они писали в лог: # adapters/telegram/bridge_handlers.py (фрагмент) from conversation_log import append_message from .bridges import get_bridge_by_telegram_chat @router.message(F.text) async def on_telegram_message(message: Message): if message.text.startswith("/"): return bridge = get_bridge_by_telegram_chat(message.chat.id) if not bridge: return user_name = message.from_user.full_name text = message.text # 1. Пишем в лог общения по инциденту append_message( incident_id=bridge.incident_id, source="TG", user=user_name, text=text, ) # 2. Ретранслируем в MAX, как раньше prefix = f"[TG] {user_name}: " relay_text = prefix + text await send_message_to_max_chat( chat_id=bridge.max_chat_id, text=relay_text, ) И аналогично для MAX: # adapters/max/bridge_handlers.py (фрагмент) from conversation_log import append_message from .bridges import get_bridge_by_max_chat @dp.message_created() async def on_max_message(event: MessageCreated): message = event.message text = getattr(message, "text", "") or "" if text.startswith("/"): return chat_id = getattr(message.chat, "chat_id", None) if not chat_id: return bridge = get_bridge_by_max_chat(chat_id) if not bridge: return user = getattr(message, "from_", None) user_name = getattr(user, "display_name", "MAX user") # 1. Пишем в лог append_message( incident_id=bridge.incident_id, source="MAX", user=user_name, text=text, ) # 2. Ретранслируем в Telegram prefix = f"[MAX] {user_name}: " relay_text = prefix + text await send_message_to_telegram_chat( chat_id=bridge.telegram_chat_id, text=relay_text, )
Формирование текста архива
При закрытии инцидента нам нужен легко читаемый лог:
# conversation_log_formatter.py from typing import List from conversation_log import ChatMessage def format_log_markdown(messages: List[ChatMessage]) -> str: if not messages: return "Диалог по инциденту отсутствует." lines = ["h2. Диалог по инциденту (Telegram + MAX)", ""] for msg in sorted(messages, key=lambda m: m.timestamp): ts = msg.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC") prefix = f"*{ts}* [{msg.source}] {msg.user}:" # Простейший эскейп спецсимволов Jira/Markdown можно добавить по вкусу lines.append(f"{prefix}\n {msg.text}") return "\n".join(lines)
Здесь пример для wiki-разметки Jira (h2., жирный и т.п.), можно адаптировать под ваш формат.
Встраивание в CORE: закрытие инцидента
Теперь в функции CORE, которая отвечает за остановку/закрытие инцидента, мы добавляем шаг «собрать и сохранить лог». Упрощенный пример:
# core/actions.py from typing import Awaitable, Callable, Any from conversation_log import get_messages, clear_log from conversation_log_formatter import format_log_markdown from integrations.jira_client import add_comment_to_issue ReplyFn = Callable[[str], Awaitable[Any]] # Функция stop_alarm здесь играет роль шага Resolve/Close в процессе Incident Management: CORE завершает инцидент, а лог из Telegram и MAX превращается в официальные рабочие материалы для разбора инцидента (evidence) прямо в задаче Jira async def stop_alarm( alarm_id: str, telegram_bot: Any, reply_fn: ReplyFn, ) -> bool: # 1. Бизнес-логика закрытия: SCM, каналы, обновление состояния и т.д. # ... # alarm = bot_state.active_alarms.pop(alarm_id, None) # ... # 2. Автоархивация диалога в Jira jira_key = get_jira_key_by_alarm_id(alarm_id) # ваша реализация маппинга if jira_key: messages = get_messages(alarm_id) log_text = format_log_markdown(messages) try: await add_comment_to_issue( issue_key=jira_key, comment_text=log_text, ) except Exception as e: # Не ломаем остановку инцидента из-за проблемы с Jira await reply_fn( "⚠️ Инцидент остановлен, но не удалось прикрепить диалог в Jira. " "Пожалуйста, проверьте интеграцию." ) else: # После успешной архивации чистим лог clear_log(alarm_id) # 3. Ответ пользователю await reply_fn(f"✅ Сбой {alarm_id} остановлен.") return True add_comment_to_issue — минимальная обёртка над Jira API: # integrations/jira_client.py from jira import JIRA # пример: python-jira _jira = JIRA( server="https://jira.example.com", basic_auth=("bot_user", "bot_password"), ) async def add_comment_to_issue(issue_key: str, comment_text: str) -> None: # Если нет async-клиента, можно вынести в executor / отдельный сервис _jira.add_comment(issue_key, comment_text)
В результате, как только инцидент закрывается через CORE, диалог по нему (из TG и MAX) упаковывается в один лог, лог автоматически прикрепляется к задаче Jira. Дежурному не нужно выискивать чаты, копировать куски переписок, вспоминать, кто что предлагал. Весь контекст уже лежит в задаче, остается структурировать выводы.
Почему это хорошо по ITIL 4 / ITSM
Incident Management:
единый вход и единый процесс закрытия инцидента;
меньше потерь на коммуникацию за счёт моста «TG ↔ MAX»;
прозрачное завершение с зафиксированным итогом в Jira.
Major Incident & коммуникации:
все участники видят одну и ту же картину, независимо от клиента;
история реагирования не живёт «в чатах», а попадает в инцидентную задачу.
Problem Management / RCA:
разбор полётов опирается не на воспоминания, а на точную хронологию действий;
анализируются реальные шаги: какие гипотезы поднимали, в каком порядке, что сработало.
Continual Improvement:
логи по инцидентам становятся источником улучшений: паттерны ошибок, узкие места в коммуникации, задержки в реакции.
Итог
Мы не отключали Telegram. Вместо этого добавили MAX и связали их мостом, чтобы коллеги могли продолжать общение там, где им удобно, не теряя единого диалога по инциденту. Мост между TG и MAX и автоархивация диалога в Jira превратили «Дежурного» из простого бота в интерфейс к процессам ITIL 4/ITSM:
быстрый и стандартный старт инцидента;
согласованное общение при реагировании;
готовый материал для RCA, пост‑мортемов и улучшения процессов.
В планах — научить бота прикреплять к задаче в Jira не только текст, но и файлы из переписки, а также добавить возможность «вынуть» конкретное сообщение из архива обратно в чат.
А как у вас организовано хранение переписок по инцидентам? Храните всё в чатах и потом героически ищете? Или тоже автоматизируете?
Буду рад пообщаться в комментариях.
