惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

IT之家
IT之家
C
CXSECURITY Database RSS Feed - CXSecurity.com
V
Visual Studio Blog
D
Darknet – Hacking Tools, Hacker News & Cyber Security
小众软件
小众软件
L
LangChain Blog
Cyberwarzone
Cyberwarzone
美团技术团队
The Register - Security
The Register - Security
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
T
Tor Project blog
V
V2EX
Security Archives - TechRepublic
Security Archives - TechRepublic
Hacker News: Ask HN
Hacker News: Ask HN
L
LINUX DO - 最新话题
Recent Announcements
Recent Announcements
H
Hackread – Cybersecurity News, Data Breaches, AI and More
酷 壳 – CoolShell
酷 壳 – CoolShell
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
aimingoo的专栏
aimingoo的专栏
人人都是产品经理
人人都是产品经理
F
Full Disclosure
V2EX - 技术
V2EX - 技术
The Cloudflare Blog
博客园 - 叶小钗
T
Threat Research - Cisco Blogs
阮一峰的网络日志
阮一峰的网络日志
G
GRAHAM CLULEY
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Latest news
Latest news
S
Security @ Cisco Blogs
Spread Privacy
Spread Privacy
Project Zero
Project Zero
K
Kaspersky official blog
MyScale Blog
MyScale Blog
Attack and Defense Labs
Attack and Defense Labs
云风的 BLOG
云风的 BLOG
博客园 - 【当耐特】
Hacker News - Newest:
Hacker News - Newest: "LLM"
大猫的无限游戏
大猫的无限游戏
P
Privacy International News Feed
Google DeepMind News
Google DeepMind News
WordPress大学
WordPress大学
C
Cybersecurity and Infrastructure Security Agency CISA
Webroot Blog
Webroot Blog
罗磊的独立博客
Vercel News
Vercel News
N
News and Events Feed by Topic
A
Arctic Wolf
C
CERT Recently Published Vulnerability Notes

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Бот Лифтер. Как мы оцифровали работу мобильной бригады подъема через мессенджер вместо отдельного приложения
karkazi (Пет · 2026-04-27 · via Все публикации подряд на Хабре

Бот Лифтер. Как мы оцифровали работу мобильной бригады подъема через мессенджер вместо отдельного приложения

Уровень сложностиПростой

Время на прочтение12 мин

Охват и читатели3

Кейс

Бот Лифтер. Мессенджер вместо отдельного приложения

Бот Лифтер. Мессенджер вместо отдельного приложения

Привет, я Максим Королев из Петрович-Теха, цифрового партнера сети строительных магазинов «Петрович». Компания специализируется на продаже стройматериалов, комплектации крупных объектов и комплексном обслуживании, включая доставку и подъем на этаж. В первой статье рассказывал, как мы сделали семейство Telegram-ботов для ITSM, во второй — как вынесли бизнес-логику «Дежурного» в CORE и подключили MAX.

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

Подъем как часть сервиса

У «Петровича» есть услуга «Подъём»: покупатель заказывает доставку стройматериалов, а мобильная бригада поднимает их на этаж; в команде есть звеньевой (тимлид) и подъемщики (исполнители). Доставка и подъем — наше ключевое преимущество, сервис уже выстроен по четким временным интервалам и регламентам, что обеспечивает высокий уровень своевременности и качества для клиентов.

Задача — дотянуть цифру до процессов

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

Решение — бот вместо отдельного приложения

Прежде чем запускать большой ИТ-проект, было важно ответить на простой вопрос: действительно ли нужна полноценная мобильная разработка, или можно сделать проще и быстрее. Для проверки гипотезы собрали MVP. Взяли сценарий мобильного приложения и переложили его в Telegram-бота, в результате появился бот «Лифтер», который фиксирует ключевые этапы подъёма в мессенджере и превращает их в структурированные данные для дальнейшей аналитики.

«Лифтер» — не продуктовый бот и не замена мобильному приложению. Это инструмент проверки гипотезы, нужно ли вообще такое приложение. Telegram стал платформой для быстрого MVP, потому что:

  • Бригада уже пользуется мессенджером — не нужно ничего устанавливать.

  • Кнопки и FSM покрывают весь нужный workflow.

  • Jira как бэкенд — задачи, статусы, changelog, отчёты — уже есть, надо только подключиться.

MVP. Как устроена цепочка от заказа до подъема

Триггер — заказ покупателя с услугой «Подъем». Дальше работает цепочка:

  1. видит заказ с подъёмом, определяет по адресу звеньевого и отправляет письмо на специальный ящик в Outlook.

  2. Jira через встроенную автоматизацию мониторит этот ящик и создает задачу в проекте MBP.

  3. «Лифтер» (бот) постоянно опрашивает Jira, ловит новую задачу и отправляет уведомление звеньевому в Telegram.

  4. Звеньевой получает уведомление и через кнопки назначает конкретного подъемщика.

  5. Подъемщик работает только в Telegram: отмечает статусы «В пути» → «На адресе» → «Выполняю» → «Завершено» (или «Отклонено») Все то же самое можно запилить в любом мессенджере, а сейчас расскажу на примере ТГ.

Jira при этом обновляется автоматически: исполнитель, статусы, комментарии — всё синхронизировано. Сам подъемщик в Jira не заходит ни разу.

Архитектура. Модули и роли

Бот разделён на чёткие слои:

  • mbp_tg_bot.py — ядро бота: хэндлеры, фоновые мониторинги Jira, бизнес-логика,

  • jira_client.py — обёртка над Jira REST API: поиск задач, смена статусов, assignee, watchers, комментарии, вложения,

  • mbp_bot_storage.py — файловое хранилище: whitelist пользователей и кэш уведомлений,

  • mbp_bot_keyboards.py — все клавиатуры: меню, списки задач с пагинацией, управление задачей,

  • mbp_bot_states.py — FSM-состояния для комментариев и обязательных действий,

  • liftings_report.py — генерация Excel-отчёта по подъёмам,

  • config.py — загрузка конфигурации из .env.

Три роли

У бота три роли пользователей:

  • admin — администратор: полный доступ, отчёты, управление ролями,

  • zvenevoy — звеньевой: получает уведомления, назначает подъемщиков, управляет участниками,

  • lifter — подъемщик: видит свои задачи, отмечает статусы, оставляет комментарии.

Вместо регистрации — whitelist. Файл mbp_users.json с заранее заведенными пользователями: Telegram ID, логин и email в Jira, роль. Кто не в списке, видит только «Бот недоступен».

Доступ: whitelist и middleware

Пример структуры пользователя и проверки доступа:

# mbp_bot_storage.py (упрощённо)
from dataclasses import dataclass
from typing import Optional, List
@dataclass
class BotUser:
    tg_id: int
    jira_username: str
    jira_display_name: str
    jira_email: str
    role: str  # "lifter" | "zvenevoy" | "admin"
def is_in_whitelist(tg_id: int) -> bool:
    return get_user(tg_id) is not None
def list_lifters() -> List[BotUser]:
    return [u for u in get_all_users() if u.role == "lifter"]
Middleware режет все запросы от неизвестных пользователей ещё до хэндлеров:
# mbp_tg_bot.py (фрагмент)
from aiogram import BaseMiddleware
from aiogram.types import Message
class WhitelistMiddleware(BaseMiddleware):
    async def __call__(self, handler, event: Message, data):
        if not is_in_whitelist(event.from_user.id):
            await event.answer("Бот недоступен.")
            return
        return await handler(event, data)

Почему так:

  • Whitelist вместо регистрации — бригада подъёма фиксирована, новые люди появляются редко, проще завести вручную.

  • Middleware — единая точка проверки, не нужно дублировать if в каждом хэндлере.

  • Роли завязаны на файл, а не на Jira-группы — бот работает автономно.

Мониторинг Jira: как бот узнаёт о новых подъемах

В боте крутятся две фоновые задачи.

Первая — новые задачи в очереди

Бот периодически опрашивает Jira и ищет задачи в статусе «В очереди». Если у задачи есть assignee и уведомление ещё не отправлено, шлет его в Telegram.

# mbp_tg_bot.py (фрагмент monitor_loop)
import asyncio
async def monitor_loop(bot, jira_client, storage):
    while True:
        issues = await jira_client.search_issues(
            jql='project = MBP AND status = "В очереди" ORDER BY created DESC'
        )
        for issue in issues:
            assignee = issue.get("assignee")
            if not assignee:
                continue
            if storage.is_notified(issue["key"], assignee["name"]):
                continue
            tg_id = storage.find_tg_by_jira_username(assignee["name"])
            if tg_id:
                await bot.send_message(
                    tg_id,
                    f"📦 Новая задача: <b>{html_escape(issue['key'])}</b>\n"
                    f"{html_escape(issue['summary'])}",
                    parse_mode="HTML",
                )
                storage.mark_notified(issue["key"], assignee["name"])
        await asyncio.sleep(60)

Вторая задача — изменения assignee и watchers

Отдельный цикл следит за последними обновленными задачами. Если сменился assignee, уведомляет нового исполнителя. Если изменился список watchers, уведомляет добавленных и удаленных.

# mbp_tg_bot.py (фрагмент monitor_assignments_loop, упрощённо)
async def monitor_assignments_loop(bot, jira_client, storage):
    while True:
        issues = await jira_client.search_issues(
            jql='project = MBP ORDER BY updated DESC',
            max_results=50,
        )
        for issue in issues:
            key = issue["key"]
            # Проверяем смену assignee
            current_assignee = (issue.get("assignee") or {}).get("name")
            last_assignee = storage.get_last_assignee(key)
            if last_assignee is None:
                # Первый запуск — запоминаем без уведомления
                storage.set_last_assignee(key, current_assignee)
            elif current_assignee != last_assignee:
                tg_id = storage.find_tg_by_jira_username(current_assignee)
                if tg_id:
                    await bot.send_message(
                        tg_id,
                        f"👤 Вам назначена задача <b>{html_escape(key)}</b>",
                        parse_mode="HTML",
                    )
                storage.set_last_assignee(key, current_assignee)
            # Аналогично для watchers: дельта added/removed
            # ...
        await asyncio.sleep(60)

Почему так:

  • Два цикла, а не один — разная логика и разные JQL, проще поддерживать.

  • Кэш mbp_notified.json хранит, кому что уже отправлено — нет дублей после перезапуска.

  • Watchers, которых нет в whitelist, автоматически удаляются из Jira — чистим мусор без ручного вмешательства.

Работа звеньевого: назначение подъёмщика

Звеньевой получает уведомление о новой задаче и через кнопку «Сменить исполнителя» выбирает подъёмщика из списка. Бот обновляет assignee в Jira.

# mbp_tg_bot.py (фрагмент — назначение исполнителя)
@router.callback_query(F.data.startswith("assign:"))
async def assign_lifter(callback: CallbackQuery):
    _, issue_key, jira_username = callback.data.split(":")
    user = storage.get_user_by_jira(jira_username)
    if not user:
        await callback.answer("Пользователь не найден")
        return
    await jira_client.set_assignee(issue_key, jira_username)
    await callback.message.edit_text(
        f"✅ Исполнитель задачи <b>{html_escape(issue_key)}</b> "
        f"назначен: {html_escape(user.jira_display_name)}",
        parse_mode="HTML",
    )
    # Уведомление новому исполнителю придёт через monitor_assignments_loop

Почему так:

  • Звеньевой видит список только тех подъёмщиков, которые есть в whitelist — не нужно помнить логины.

  • Assignee обновляется в Jira, а уведомление исполнителю приходит через фоновый мониторинг — один путь, без дублирования.

Жизненный цикл подъёма: статусы в боте

Это центральная часть «Лифтера». Подъёмщик работает только через кнопки в Telegram, а бот синхронизирует каждый переход с Jira.

Workflow задачи:

В очереди → В пути → Ожидание на адресе → Выполнение услуги → Завершено

На любом этапе (кроме «В очереди» и финальных статусов) можно «Вернуть в очередь». Из любого активного статуса — перейти в «Отклонено».

Меню смены статуса

Бот запрашивает у Jira доступные переходы и показывает их кнопками:

# mbp_tg_bot.py (фрагмент — меню статусов)
@router.callback_query(F.data.startswith("status_menu:"))
async def status_menu(callback: CallbackQuery):
    issue_key = callback.data.split(":")[1]
    issue = await jira_client.get_issue_details(issue_key)
    current_assignee = (issue.get("assignee") or {}).get("name")
    # Только текущий исполнитель может менять статус
    user = storage.get_user(callback.from_user.id)
    if not user or user.jira_username != current_assignee:
        await callback.answer("Только текущий исполнитель может менять статус")
        return
    transitions = await jira_client.get_transitions(issue_key)
    buttons = []
    for t in transitions:
        target_status = t["to"]["name"]
        buttons.append(
            InlineKeyboardButton(
                text=f"➡️ {target_status}",
                callback_data=f"dotrans:{issue_key}:{t['id']}",
            )
        )
    # Кнопка «Вернуть в очередь», если нет прямого перехода
    current_status = issue["status"]["name"]
    has_queue_transition = any(t["to"]["name"] == "В очереди" for t in transitions)
    if current_status not in ("В очереди", "Завершено", "Отклонено") and not has_queue_transition:
        buttons.append(
            InlineKeyboardButton(
                text="⬅️ Вернуть в очередь",
                callback_data=f"back_to_queue:{issue_key}",
            )
        )
    await callback.message.edit_text(
        f"Выберите новый статус для <b>{html_escape(issue_key)}</b>:",
        parse_mode="HTML",
        reply_markup=InlineKeyboardMarkup(inline_keyboard=[[b] for b in buttons]),
    )

Обычный переход

# mbp_tg_bot.py (фрагмент — выполнение перехода)
@router.callback_query(F.data.startswith("dotrans:"))
async def do_transition(callback: CallbackQuery):
    _, issue_key, transition_id = callback.data.split(":")
    # Проверяем, что пользователь — текущий assignee
    # ...
    # Определяем целевой статус
    transitions = await jira_client.get_transitions(issue_key)
    target = next((t for t in transitions if t["id"] == transition_id), None)
    target_status = target["to"]["name"] if target else "?"
    if target_status == "Отклонено":
        # Для отклонения нужен обязательный комментарий — запускаем FSM
        await state.set_state(RejectCommentStates.waiting_text_or_photo)
        await state.update_data(issue_key=issue_key, transition_id=transition_id)
        await callback.message.edit_text(
            "📝 Укажите причину отклонения (текст или фото).\n"
            "Для отмены — /cancel"
        )
        return
    await jira_client.transition_issue(issue_key, transition_id)
    await callback.message.edit_text(
        f"✅ Статус <b>{html_escape(issue_key)}</b> изменён → <b>{html_escape(target_status)}</b>",
        parse_mode="HTML",
    )
    await send_issue_card(callback.message, issue_key)

Обязательный комментарий при отклонении

Если подъёмщик отклоняет задачу — бот требует объяснить причину. Без текста или фото переход не выполнится.

# mbp_tg_bot.py (фрагмент — обработка причины отклонения)
@router.message(RejectCommentStates.waiting_text_or_photo)
async def reject_comment_receive(message: Message, state: FSMContext):
    data = await state.get_data()
    issue_key = data["issue_key"]
    transition_id = data["transition_id"]
    user = storage.get_user(message.from_user.id)
    author_name = user.jira_display_name if user else message.from_user.full_name
    if message.photo:
        # Скачиваем фото, загружаем в Jira как вложение
        photo = message.photo[-1]
        file = await message.bot.download(photo)
        await jira_client.upload_attachment(issue_key, file)
        comment_text = message.caption or "Фото причины отклонения"
    elif message.text:
        comment_text = message.text
    else:
        await message.reply("❗ Пришлите текст или фото с причиной отклонения.")
        return
    await jira_client.transition_issue(issue_key, transition_id)
    await jira_client.add_comment(
        issue_key,
        f'{author_name}: "{comment_text}"',
    )
    await state.clear()
    await message.answer(
        f"✅ Задача <b>{html_escape(issue_key)}</b> отклонена. Комментарий добавлен.",
        parse_mode="HTML",
    )

Кнопка «Водитель» — быстрое отклонение

Отдельная история — иногда подъём отклоняется не по вине бригады, а потому что водитель уже поднял груз сам. Для admin и zvenevoy есть кнопка «Водитель», которая переводит задачу в «Отклонено» без обязательного комментария.

Почему так:

  • Обычный подъёмщик обязан объяснить причину отклонения — это данные для аналитики.

  • Звеньевой и админ могут быстро закрыть «водительские» случаи без лишних шагов.

  • Все переходы идут через Jira API (get_transitions → transition_issue) — бот не хардкодит ID переходов.

Карточка задачи

Каждая задача отображается карточкой с ключевой информацией и набором кнопок в зависимости от роли:

# mbp_tg_bot.py (фрагмент — карточка задачи, упрощённо)
async def send_issue_card(message, issue_key):
    issue = await jira_client.get_issue_details(issue_key)
    status = issue["status"]["name"]
    assignee = (issue.get("assignee") or {}).get("displayName", "Не назначен")
    summary = issue.get("summary", "")
    description = (issue.get("description") or "")[:200]
    watchers = await jira_client.get_watchers(issue_key)
    watcher_names = ", ".join(w["displayName"] for w in watchers) or "—"
    text = (
        f"📋 <b>{html_escape(issue_key)}</b>\n"
        f"{html_escape(summary)}\n\n"
        f"Статус: <b>{html_escape(status)}</b>\n"
        f"Исполнитель: {html_escape(assignee)}\n"
        f"Участники: {html_escape(watcher_names)}\n"
        f"{html_escape(description)}\n\n"
        f'<a href="https://jira.example.com/browse/{issue_key}">Открыть в Jira</a>'
    )
    kb = task_actions_kb(issue_key, issue, message.from_user.id)
    await message.answer(text, parse_mode="HTML", reply_markup=kb)

Почему так:

  • Карточка — единая точка отображения задачи, используется и при уведомлении, и при обновлении.

  • Кнопки зависят от роли: подъёмщик видит «Сменить статус» и «Комментарий», звеньевой — ещё «Сменить исполнителя» и «Участники».

  • html_escape на всех динамических полях — иначе Telegram ломает разметку на спецсимволах из Jira.

Отчёт по подъёмам: Excel из changelog

Одна из главных причин, зачем вообще нужен был бот — собирать данные для анализа. Отчёт генерируется по запросу админа и строится на основе changelog задач в Jira.

# liftings_report.py (фрагмент — логика извлечения таймингов)
from datetime import datetime
def extract_status_transitions(changelog):
    """Извлекает переходы статусов из changelog задачи Jira."""
    transitions = []
    for history in changelog:
        for item in history.get("items", []):
            if item["field"] == "status":
                transitions.append({
                    "from": item["fromString"],
                    "to": item["toString"],
                    "timestamp": datetime.fromisoformat(
                        history["created"].replace("+0000", "+00:00")
                    ),
                })
    return sorted(transitions, key=lambda t: t["timestamp"])
def calculate_durations(created_time, transitions):
    """Считает время в каждом статусе."""
    durations = {}
    timestamps = {}
    # Время «В очереди» — от создания задачи до первого перехода
    if transitions:
        first = transitions[0]["timestamp"]
        durations["В очереди"] = (first - created_time).total_seconds() / 60
        timestamps["В очереди → " + transitions[0]["to"]] = first
    for i, t in enumerate(transitions):
        status = t["to"]
        timestamps[status] = t["timestamp"]
        # Длительность = время до следующего перехода
        if i + 1 < len(transitions):
            next_time = transitions[i + 1]["timestamp"]
            durations[status] = (next_time - t["timestamp"]).total_seconds() / 60
    return durations, timestamps

Excel-файл содержит колонки:

  • номер заявки (ключ Jira, MBP-XXX),

  • номер заказа (из summary, по шаблону Заказ покупателя №...),

  • время «В очереди» (мин),

  • время перехода «В пути» и длительность (мин),

  • время перехода «Ожидание на адресе» и длительность (мин),

  • время перехода «Выполнение услуги» и длительность (мин),

  • время перехода «Завершено».

Админ запрашивает отчёт прямо из бота:

# mbp_tg_bot.py (фрагмент — генерация отчёта)
@router.callback_query(F.data == "admin_report")
async def admin_generate_report(callback: CallbackQuery):
    await callback.message.edit_text("⏳ Генерирую отчёт по подъёмам...")
    report_path = await generate_liftings_report(jira_client, project_key="MBP")
    await callback.message.answer_document(
        FSInputFile(report_path),
        caption="📊 Отчёт по подъёмам",
    )
    os.remove(report_path)

Почему так:

  • Changelog Jira — единственный источник правды о переходах, не зависит от бота.

  • Отчёт считает реальные тайминги, а не «ожидаемые» — это именно те данные, которые нужны для принятия решения о проекте.

  • Excel выбран потому, что отчёт уходит руководителям, которые привыкли работать с таблицами.

Защита от ошибок Telegram: HTML и экранирование

Короткий, но важный момент. Все карточки и уведомления используют parse_mode="HTML". Все динамические поля — summary, статусы, имена — проходят через html_escape.

Без этого Telegram регулярно ломается на спецсимволах из Jira: кавычки, угловые скобки, амперсанды в описаниях задач. Ошибка can't parse entities — первое, с чем столкнётся любой, кто интегрирует бота с Jira.

Результаты MVP: 500+ подъёмов

Через «Лифтера» было проведено более 500 подъёмов. Этого хватило, чтобы:

  • Собрать статистику по каждому этапу: сколько времени задача в очереди, сколько подъёмщик в пути, сколько длится сам подъём.

  • Увидеть реальную картину отклонений и их причины.

  • Понять, что большая часть потребностей бригады закрывается кнопками в Telegram и не требует полноценного мобильного приложения.

Главный вывод: не нужно идти в дорогой проект с отдельным мобильным приложением. Можно ограничиться расширением текущего функционала. MVP за минимальные ресурсы ответил на вопрос, ради которого раньше запускали полноценный проект.

На данном этапе всё находится на предпроекте. MVP прошёл успешно, статистика собрана, дальнейшие решения принимаются на основе данных, а не на ощущениях.

Итог

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

А как у вас устроены полевые процессы — где вы проводите границу, за которой бот в мессенджере уже не справляется и действительно требуется нативное приложение?