← Гордеев Виталий · все кейсы
Кейс · клуб и ресторан
Чат-боты · ВКонтакте и Telegram

Боты, которые отвечают за заведение

Для ночного клуба и ресторана я собрал своих ботов вместо Senler: сценарии на кнопках, подарок новым гостям, рассылки по базе и консультант, который отвечает на свободные вопросы по базе знаний заведения и ничего не выдумывает. Всё живёт на своём сервере, база подписчиков остаётся у заведения.

1 885подписчиков у бота клуба ВКонтакте
8 705сообщений доставлено в 18 рассылках по базе
104ответа консультанта ресторана на 189 вопросов гостей
243шага в 30 сценариях на холсте конструктора
Конструктор бота: условие «если тег подарок_получен», ветки да и нет, тег подписчику и два сообщения с кнопками
Конструктор · сценарий «Подарок гостю»Условие по тегу. Если подарок уже получен, бот вежливо отказывает, если нет, ставит тег и выдаёт купон. Собирается мышкой.
Реальный диалог с консультантом ресторана: бронь и вопрос о цене банкета
Ресторан · ВКонтактеРеальный диалог. Сумму банкета бот сочинять не стал и перевёл гостя на администратора.
Реальный диалог с ботом ночного клуба: дресс-код, подарок и этажи
Ночной клуб · ВКонтактеБот здоровается по имени, выдаёт подарок по сценарию и отвечает в тоне клуба. Имя гостя скрыто.
01 · Как устроено

Сценарии на кнопках,
остальное отвечает модель

Сначала бот ищет сценарий по кнопке, ключевому слову или первому сообщению. Если не нашёл, отдаёт вопрос модели вместе с базой знаний заведения.

PythonFastAPISQLModelVK API · LongPollTelegram Bot APIGeminiReactReact Flow

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

У каждого заведения свои боты и своя база знаний. Консультант ресторана знает меню, цены и часы работы. Знает и то, что скидок, доставки и кальяна там нет, и прямо так гостям и отвечает. Вопросы про цену банкета, розыгрыши и всё, чего нет в базе, он отдаёт администратору.

02 · Разбор

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

С живой базой ошибаться нельзя. Пришло сообщение дважды, и люди жалуются на спам, а сообществу грозит бан.

01

Рассылка ушла части базы дважды

Симптом
Рассылка на 950 подписчиков: часть людей получила одно и то же сообщение два раза.
Причина
Статус «отправлено» записывался в базу пачкой по пять. Ночью база была занята, и запись пачки упала. Сообщения к тому моменту уже ушли, а статус остался «ждёт», поэтому повтор отправил их ещё раз.
Статус теперь пишется сразу после каждой отправки. Промежутка, в котором мог появиться дубль, больше нет.
broadcast_worker.py · дословно
                bs.status = "sent"
                bs.sent_at = datetime.utcnow()
                sent_local += 1
            except Exception as exc:
                bs.status = "failed"
                bs.error = str(exc)[:500]

            sess.add(bs)
            # КОММИТ status ПОСЛЕ КАЖДОГО send (24.07 фикс дублей): раньше commit пачкой по 5 —
            # при БД-локе между send() и commit сообщение уходило в ВК, но status оставался
            # "pending" → сбой → retry слал ПОВТОРНО = дубль (спам, бан-риск).
            bc.sent_count = (bc.sent_count or 0) + sent_local
            sess.add(bc)
            _safe_commit(sess)
02

Рассылки зависали на старте

Симптом
11 рассылок клуба висели в статусе «отправляется» и успели дойти до 0–113 человек из тысячи с лишним.
Причина
Повтор записи шёл без отката сломанной транзакции, поэтому каждая следующая попытка падала мгновенно. А планировщик брал только те рассылки, которые ещё не стартовали.
Добавил откат перед повтором, а планировщик теперь подбирает зависшие рассылки старше 15 минут. Старые рассылки закрыл до перезапуска, чтобы гостям не ушла программа двухнедельной давности. Холостой прогон прошёл по всем 1 312 адресатам.
broadcast_worker.py · дословно
for i in range(tries):
    try:
        sess.commit()
        return
    except (_OpErr, _PendErr) as e:
        last = i >= tries - 1
        locked = "locked" in str(e).lower() or isinstance(e, _PendErr)
        if locked and not last:
            # ⚠️ Без rollback следующий commit падает сразу с PendingRollbackError:
            # транзакция уже помечена сломанной. Именно этого не хватало рассылкам.
            sess.rollback()
            _t.sleep(0.8 * (i + 1))
            continue
        raise
03

Ресторану досталась база знаний клуба

Симптом
Бот ресторана должен был отвечать про меню и бронь, а модель получала инструкцию, написанную для ночного клуба.
Причина
Системный промпт у консультанта был один на все заведения: движок изначально делался под клуб.
Теперь у каждого заведения своя база знаний, и нужная выбирается по подписчику. Ресторану сделал отдельную, с жёстким правилом: если не знаешь ответа, переведи на администратора.
interpreter.py · дословно
_AI_SYSTEM_BY_ORG = {"chili": _CHILI_AI_SYSTEM}

def _ai_system_for(org_id):
    return _AI_SYSTEM_BY_ORG.get(org_id, _AI_SYSTEM_PROMPT)

# Контекст: последние 5 сообщений (in/out) этого подписчика
prompt = (
    f"{_ai_system_for(getattr(subscriber, 'org_id', ''))}\n\n"
    f"История диалога:\n{history_str}\n\n"
    f"Последнее сообщение пользователя: {user_text}\n\n"
    f"Твой ответ:"
)
04

Любая кнопка роняла бота

Симптом
Сообщение с кнопками не отправлялось, и сценарий обрывался на этом шаге.
Причина
Редактор хранил кнопки плоским списком, а сборщик клавиатуры ВКонтакте ждал список строк. Ошибка сидела ещё до конструктора и ломала сценарии и в старом редакторе.
На входе кнопки приводятся к одному виду: плоский список раскладывается по одной кнопке в строку. Прогнал сценарий с кнопками и ветвлением на тестовом отправителе, обе ветки отработали.
sender.py · дословно
# Нормализация формата: и редактор /chatbots, и визуальный конструктор
# хранят кнопки ПЛОСКИМ списком [{...}], а тут ожидаются СТРОКИ [[{...}]].
# Плоский список → по одной кнопке в строку (вертикально).
if buttons and isinstance(buttons[0], dict):
    buttons = [[b] for b in buttons]

vk_rows: list[list[dict]] = []
for row in buttons:
    vk_row: list[dict] = []
    for btn in row:
        url = btn.get("url")
03 · Правила

Что я забрал
из этого проекта

Бот говорит от имени заведения. Хватит одной выдуманной цены или лишнего сообщения, чтобы гость перестал ему верить.

01

Отправил и сразу записал

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

02

Не знает ответа, зовёт человека

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

03

База остаётся у заведения

Бот свой, поэтому подписчики и участники розыгрышей лежат в базе заведения, чужому сервису они не достаются.

04

Один движок на все заведения

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

Про бота, который выводит текст гостя на экран клуба, рассказал отдельно: экраны клуба.

Нужен бот для бизнеса?

Соберу бота ВКонтакте или Telegram: сценарии, рассылки по базе, консультант по вашей базе знаний. Работаю удалённо, из Самары.

Telegram · @vitaldemort