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

Привет, я Максим Королев из Петрович-Теха, цифрового партнера сети строительных магазинов «Петрович». Компания специализируется на продаже стройматериалов, комплектации крупных объектов и комплексном обслуживании, включая доставку и подъем на этаж. В первой статье рассказывал, как мы сделали семейство 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 прошёл успешно, статистика собрана, дальнейшие решения принимаются на основе данных, а не на ощущениях.

Итог

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

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

Комментарии (1)


  1. tklim
    27.04.2026 08:04

    Вы ошиблись хабом