MAATRIX / Блог / Бот поддержки клиентов с тикетами

Бот поддержки клиентов с тикетами

Бот поддержки клиентов с тикетами

MAATRIX

Когда обращения клиентов идут просто в личку менеджера, рано или поздно что-то теряется: сообщение прочитали и забыли ответить, два оператора одновременно взялись за один вопрос, а найти историю переписки с конкретным клиентом за прошлый месяц — та ещё задача. Бот с тикетами закрывает это без внедрения тяжёлой helpdesk-системы: каждое обращение превращается в тикет с номером и статусом, вся переписка сохраняется в базе, а операторы отвечают из одной группы. Дальше — рабочая схема, которую можно поднять за вечер на своём VPS.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →

Архитектура: из чего состоит бот

Схема простая и без лишних движущихся частей:

  • Клиентский бот — тот, с кем переписывается клиент. Один Telegram-бот, токен от @BotFather.
  • Группа операторов — обычная группа или супергруппа с включёнными топиками (Forum), куда бот пересылает обращения.
  • База данных — таблица тикетов и таблица сообщений с привязкой к тикету.
  • Роутинг сообщений — правило «сообщение клиента → в группу с пометкой номера тикета» и обратное «ответ оператора (reply на пересланное) → клиенту».

Два варианта пересылки:

  1. Простой — бот форвардит сообщение клиента в общий чат операторов с подписью #T-1042, оператор отвечает reply на это сообщение, бот по reply_to_message_id находит тикет и шлёт ответ клиенту.
  2. Через топики форума — на каждый тикет создаётся отдельный топик (create_forum_topic) в супергруппе с включённым Forum. Удобнее при потоке от 15-20 тикетов в день: не нужно листать общий чат, у каждого клиента своя ветка. Логика пересылки та же, просто добавляется message_thread_id.

Для старта хватает варианта 1 — он проще в реализации и достаточен, пока тикетов в день немного.

Модель данных: тикеты и сообщения

Две таблицы решают задачу. Пример на PostgreSQL (подойдёт и SQLite для совсем небольшого объёма, но для группы операторов и параллельных ответов лучше сразу Postgres — как поднять её на сервере, разбирали в статье про установку PostgreSQL на VPS):

CREATE TABLE tickets (
    id              SERIAL PRIMARY KEY,
    ticket_number   TEXT UNIQUE NOT NULL,      -- 'T-1042'
    client_chat_id  BIGINT NOT NULL,
    status          TEXT NOT NULL DEFAULT 'new',  -- new / in_progress / closed
    assigned_to     BIGINT,                    -- telegram id оператора
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    first_reply_at  TIMESTAMPTZ,
    closed_at       TIMESTAMPTZ
);

CREATE TABLE ticket_messages (
    id                 SERIAL PRIMARY KEY,
    ticket_id          INTEGER REFERENCES tickets(id),
    sender             TEXT NOT NULL,           -- 'client' / 'operator'
    telegram_message_id BIGINT,                 -- id сообщения в группе операторов
    text               TEXT,
    created_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_ticket_msg_tgid ON ticket_messages(telegram_message_id);
CREATE INDEX idx_tickets_status ON tickets(status);

Поле telegram_message_id — ключевое: именно по нему бот распознаёт, на какое пересланное сообщение ответил оператор, и находит нужный тикет.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Клиент пишет — тикет создаётся

Логика на стороне клиентского бота (пример на aiogram 3.x):

from datetime import datetime

async def handle_client_message(message: types.Message, db):
    client_id = message.from_user.id

    ticket = await db.fetchrow(
        "SELECT * FROM tickets WHERE client_chat_id=$1 AND status != 'closed'",
        client_id
    )

    if ticket is None:
        number = await next_ticket_number(db)  # напр. T-1042
        ticket = await db.fetchrow(
            """INSERT INTO tickets (ticket_number, client_chat_id, status)
               VALUES ($1, $2, 'new') RETURNING *""",
            number, client_id
        )
        await message.answer(f"Заявка принята, номер {ticket['ticket_number']}. Ответим в ближайшее время.")

    sent = await bot.send_message(
        OPERATORS_GROUP_ID,
        f"#{ticket['ticket_number']} от {message.from_user.full_name}:\n\n{message.text}"
    )

    await db.execute(
        """INSERT INTO ticket_messages (ticket_id, sender, telegram_message_id, text)
           VALUES ($1, 'client', $2, $3)""",
        ticket['id'], sent.message_id, message.text
    )

Номер тикета проще всего генерировать инкрементом на основе последовательности в базе (SELECT nextval('ticket_seq')) — так гарантированно нет коллизий даже при параллельных обращениях.

Оператор отвечает — сообщение уходит клиенту

В группе операторов бот слушает reply на свои сообщения:

async def handle_operator_reply(message: types.Message, db):
    if not message.reply_to_message:
        return

    row = await db.fetchrow(
        """SELECT t.* FROM ticket_messages m
           JOIN tickets t ON t.id = m.ticket_id
           WHERE m.telegram_message_id = $1""",
        message.reply_to_message.message_id
    )
    if row is None:
        return

    await bot.send_message(row['client_chat_id'], message.text)

    await db.execute(
        """INSERT INTO ticket_messages (ticket_id, sender, telegram_message_id, text)
           VALUES ($1, 'operator', $2, $3)""",
        row['id'], message.message_id, message.text
    )

    if row['first_reply_at'] is None:
        await db.execute(
            "UPDATE tickets SET first_reply_at=now(), status='in_progress' WHERE id=$1",
            row['id']
        )

Плюс пара команд для управления статусом прямо в группе — их проще всего вешать на текст-триггер после ключевого слова в reply, например /close и /reopen:

async def handle_close(message: types.Message, db):
    row = await db.fetchrow(
        """SELECT t.id FROM ticket_messages m JOIN tickets t ON t.id=m.ticket_id
           WHERE m.telegram_message_id=$1""",
        message.reply_to_message.message_id
    )
    if row:
        await db.execute("UPDATE tickets SET status='closed', closed_at=now() WHERE id=$1", row['id'])
        await message.reply("Тикет закрыт.")

Если оператор ответит обычным сообщением без reply, бот не поймёт, к какому тикету оно относится — стоит явно приучить команду отвечать через reply, иначе сообщение просто потеряется.

Статистика: что реально нужно знать

Три цифры покрывают 90% потребностей небольшой поддержки: сколько тикетов открыто, сколько закрыто за период, среднее время первого ответа. Всё это — простые SQL-запросы:

-- открытые/в работе
SELECT status, count(*) FROM tickets WHERE status != 'closed' GROUP BY status;

-- закрыто за сегодня
SELECT count(*) FROM tickets WHERE status='closed' AND closed_at::date = current_date;

-- среднее время до первого ответа за неделю
SELECT avg(first_reply_at - created_at)
FROM tickets
WHERE created_at > now() - interval '7 days' AND first_reply_at IS NOT NULL;

Команду /stats можно повесить на бота в группе операторов, а можно раз в сутки отправлять сводку в чат по крону — это дешевле по нагрузке и не зависит от того, вспомнит ли кто-то её вызвать. Точные цифры среднего времени ответа сильно зависят от загрузки конкретной команды — ориентируйтесь на свою динамику за первые пару недель, а не на чужие бенчмарки.

Деплой: systemd, база и бэкапы

На VPS процесс держится тем же способом, что и любой другой Telegram-бот на Python — через systemd, чтобы он поднимался сам после перезагрузки и не зависел от открытой сессии терминала (это подробно разбирали в статье про запуск Telegram-бота на Python на VPS):

[Unit]
Description=Support ticket bot
After=network.target postgresql.service

[Service]
Type=simple
User=botuser
WorkingDirectory=/opt/support-bot
EnvironmentFile=/opt/support-bot/.env
ExecStart=/opt/support-bot/venv/bin/python bot.py
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Токен бота и данные подключения к базе — в .env, не в коде. База — на этом же сервере или на соседнем; если тикетов и переписки много, стоит сразу продумать регулярный бэкап (pg_dump по крону с ротацией). Для оценки, какой VPS вообще нужен под такую нагрузку, полезна статья сколько RAM нужно для Telegram-бота — для бота с тикетами и Postgres на несколько сотен обращений в день обычно хватает начальных тарифов, узкое место скорее в диске под растущую историю переписки, чем в CPU или памяти.

Отдельно стоит учитывать типовые грабли самого бота — обрыв polling-соединения, падение после рестарта сервера, конфликт нескольких запущенных инстансов — они разобраны в статье про частые ошибки Telegram-бота на сервере.

Когда бота уже мало

Здесь стоит быть честным: такой бот закрывает старт и небольшой поток обращений — условно, до нескольких десятков тикетов в день на одного-двух операторов. Дальше начинают всплывать ограничения, которые дописывать в бота своими силами уже невыгодно:

  • Нет ролей и прав — любой в группе операторов может закрыть чужой тикет, разграничить это без отдельной таблицы прав и мидлвара не получится.
  • Нет SLA и эскалаций — просроченный тикет не подсветится и не переназначится сам.
  • Один канал — если поддержка нужна ещё и по email или на сайте, бот эту часть не покроет, придётся городить интеграции вручную.
  • Нет тегов, очередей, авто-маршрутизации по теме — всё разбирается вручную, глазами.
  • Отчётность ограничена тем, что вы сами написали в SQL — никакой аналитики по операторам, каналам, типам обращений из коробки.

Когда команда вырастет до полноценного отдела поддержки, разумнее переезжать на специализированную helpdesk-систему (Zendesk, Jira Service Management, самохостовую вроде Zammad или Chatwoot) — они закрывают ровно эти пробелы. Такие системы тоже разворачиваются на своём сервере и требуют больше ресурсов, чем лёгкий бот на Python — если объём поддержки уже требует полноценной CRM-подобной инфраструктуры, стоит сразу закладываться на сервер помощнее, чем для одного бота.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно обойтись без группы и присылать тикеты в личку оператору?

Технически да, но тогда при нескольких операторах два человека рискуют одновременно взяться за один тикет, а статистика по команде теряет смысл — общая группа снимает эту проблему сразу.

Что если оператор ответит обычным сообщением, а не через reply?

Бот не сможет определить, к какому тикету оно относится, и ответ клиенту не уйдёт. Стоит либо явно приучить команду отвечать только через reply, либо добавить fallback: если в группе есть ровно один открытый тикет за последние несколько минут — привязывать к нему, но это менее надёжно.

Хватит ли SQLite вместо PostgreSQL?

Для одного оператора и небольшого потока — да, файл SQLite прекрасно справится. Как только пишут параллельно несколько операторов или тикетов становится много, лучше сразу Postgres — блокировки SQLite на запись начнут мешать.

Как поддержать вложения — фото, документы?

Пересылка photo/document работает так же, как текст: bot.send_photo/send_document с сохранением file_id в базе вместо текста, а обратная пересылка — тем же способом через reply.

Можно на одном VPS держать бота поддержки и другие боты компании?

Да, если ресурсов хватает — несколько процессов на systemd прекрасно уживаются на одном сервере, лишь бы у каждого был свой токен и своя база или отдельная схема в общей.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →