Заказать бота

Бот записи на услуги ВК: сценарий FSM, цена внедрения и запуск

Пошагово: как сделать бота записи на услуги во ВКонтакте (салон, барбер, автосервис) — FSM-сценарий, сбор заявки, передача администратору и когда выгодно заказывать внедрение под ключ.

Содержание статьи

Если клиент пишет в сообщения сообщества «хочу записаться», а администратор отвечает через 20 минут — часть заявок теряется.

В этой статье сделаем демо-бота записи для услуг во ВКонтакте: салон красоты, барбершоп, автосервис — логика одинаковая. Бот соберёт заявку по шагам и отправит её менеджеру.

Что реализуем

Бот будет:

  • показывать меню услуг;
  • пошагово собирать запись через FSM;
  • валидировать дату/контакт;
  • уметь отменять сценарий;
  • отправлять готовую заявку администратору.

Архитектура демо

vk-booking-bot/
├─ .env
├─ bot.py
└─ bookings.jsonl   # локальный журнал заявок (демо)

Установка зависимостей:

pip install vkbottle python-dotenv

Подготовка .env

VK_TOKEN=vk1.a.xxxxx
MANAGER_PEER_ID=2000000123
  • VK_TOKEN — токен сообщества;
  • MANAGER_PEER_ID — куда отправлять заявки (личка/рабочая беседа).

Шаг 1. Меню и старт записи

Сделаем кнопки услуг через payload, чтобы сценарий не зависел от текста кнопки.

from vkbottle import Keyboard, Text

main_menu = (
    Keyboard()
    .add(Text("✂️ Стрижка", payload={"service": "haircut"}), color="primary")
    .add(Text("💇‍♂️ Барбер", payload={"service": "barber"}), color="primary")
    .row()
    .add(Text("🚗 Диагностика авто", payload={"service": "car_diagnostic"}), color="secondary")
    .add(Text("❌ Отмена", payload={"cmd": "cancel"}), color="negative")
    .get_json()
)

Обработчик старта:

@bot.on.message(text=["запись", "записаться", "start", "меню", "привет"])
async def start_booking(message: Message):
    await message.answer(
        "Здравствуйте! Выберите услугу для записи 👇",
        keyboard=main_menu,
    )

Шаг 2. FSM-состояния

Нам нужны шаги: услуга → дата/время → контакт → комментарий.

from vkbottle import BaseStateGroup

class BookingState(BaseStateGroup):
    WAIT_DATETIME = "wait_datetime"
    WAIT_CONTACT = "wait_contact"
    WAIT_COMMENT = "wait_comment"

Буфер на время диалога:

booking_buffer: dict[int, dict[str, str]] = {}

Шаг 3. Выбор услуги и переход к дате

import json

SERVICE_LABELS = {
    "haircut": "Стрижка",
    "barber": "Барбер",
    "car_diagnostic": "Диагностика авто",
}

@bot.on.message()
async def route_service(message: Message):
    if not message.payload:
        return

    data = json.loads(message.payload)

    if data.get("cmd") == "cancel":
        await bot.state_dispenser.delete(message.peer_id)
        booking_buffer.pop(message.peer_id, None)
        await message.answer("Сценарий записи отменён. Напишите «запись», чтобы начать заново.")
        return

    service_key = data.get("service")
    if service_key not in SERVICE_LABELS:
        return

    booking_buffer[message.peer_id] = {"service": SERVICE_LABELS[service_key]}
    await bot.state_dispenser.set(message.peer_id, BookingState.WAIT_DATETIME)
    await message.answer(
        "Укажите удобные дату и время, например: 25.03 18:30",
    )

Шаг 4. Валидация даты и контакта

Для демо достаточно простой валидации, чтобы отсеять пустые/случайные вводы.

import re

DATE_RE = re.compile(r"^\d{2}\.\d{2}\s\d{2}:\d{2}$")
PHONE_RE = re.compile(r"^\+?\d{10,15}$")


def valid_datetime(value: str) -> bool:
    return bool(DATE_RE.match(value.strip()))


def valid_contact(value: str) -> bool:
    v = value.strip()
    return bool(PHONE_RE.match(v) or v.startswith("@"))

Шаг даты:

from vkbottle.dispatch.rules.base import StateRule

@bot.on.message(StateRule(BookingState.WAIT_DATETIME))
async def booking_datetime(message: Message):
    text = (message.text or "").strip()
    if not valid_datetime(text):
        await message.answer("Формат неверный. Пример: `25.03 18:30`")
        return

    booking_buffer.setdefault(message.peer_id, {})["datetime"] = text
    await bot.state_dispenser.set(message.peer_id, BookingState.WAIT_CONTACT)
    await message.answer("Оставьте контакт: телефон (+79991234567) или @username.")

Шаг контакта:

@bot.on.message(StateRule(BookingState.WAIT_CONTACT))
async def booking_contact(message: Message):
    text = (message.text or "").strip()
    if not valid_contact(text):
        await message.answer("Контакт не распознан. Укажите телефон в международном формате или @username.")
        return

    booking_buffer.setdefault(message.peer_id, {})["contact"] = text
    await bot.state_dispenser.set(message.peer_id, BookingState.WAIT_COMMENT)
    await message.answer("Комментарий к записи (или напишите: нет).")

Шаг 5. Финал: отправка администратору

import os
import json
from datetime import datetime

MANAGER_PEER_ID = int(os.environ["MANAGER_PEER_ID"])


def save_booking_local(payload: dict[str, str]) -> None:
    with open("bookings.jsonl", "a", encoding="utf-8") as f:
        f.write(json.dumps(payload, ensure_ascii=False) + "\n")


@bot.on.message(StateRule(BookingState.WAIT_COMMENT))
async def booking_finish(message: Message):
    peer_id = message.peer_id
    user_id = message.from_id or 0
    comment = (message.text or "").strip()

    data = booking_buffer.get(peer_id, {})
    data["comment"] = "—" if comment.lower() == "нет" else comment
    data["user_id"] = str(user_id)
    data["peer_id"] = str(peer_id)
    data["created_at"] = datetime.now().isoformat(timespec="seconds")

    admin_text = (
        "📌 Новая запись из VK-бота\n"
        f"Услуга: {data.get('service', '-')}\n"
        f"Дата/время: {data.get('datetime', '-')}\n"
        f"Контакт: {data.get('contact', '-')}\n"
        f"Комментарий: {data.get('comment', '-')}\n"
        f"User ID: {data.get('user_id', '-')}"
    )

    await bot.api.messages.send(
        peer_id=MANAGER_PEER_ID,
        random_id=0,
        message=admin_text,
    )

    save_booking_local(data)
    await bot.state_dispenser.delete(peer_id)
    booking_buffer.pop(peer_id, None)

    await message.answer(
        "Спасибо! ✅ Запись принята. Администратор свяжется с вами для подтверждения.",
    )

Полный демо-файл bot.py

import json
import os
import re
from datetime import datetime

from dotenv import load_dotenv
from vkbottle import BaseStateGroup, Keyboard, Text
from vkbottle.bot import Bot, Message
from vkbottle.dispatch.rules.base import StateRule

load_dotenv()

TOKEN = os.environ["VK_TOKEN"]
MANAGER_PEER_ID = int(os.environ["MANAGER_PEER_ID"])

bot = Bot(token=TOKEN)


class BookingState(BaseStateGroup):
    WAIT_DATETIME = "wait_datetime"
    WAIT_CONTACT = "wait_contact"
    WAIT_COMMENT = "wait_comment"


booking_buffer: dict[int, dict[str, str]] = {}

SERVICE_LABELS = {
    "haircut": "Стрижка",
    "barber": "Барбер",
    "car_diagnostic": "Диагностика авто",
}

DATE_RE = re.compile(r"^\d{2}\.\d{2}\s\d{2}:\d{2}$")
PHONE_RE = re.compile(r"^\+?\d{10,15}$")

main_menu = (
    Keyboard()
    .add(Text("✂️ Стрижка", payload={"service": "haircut"}), color="primary")
    .add(Text("💇‍♂️ Барбер", payload={"service": "barber"}), color="primary")
    .row()
    .add(Text("🚗 Диагностика авто", payload={"service": "car_diagnostic"}), color="secondary")
    .add(Text("❌ Отмена", payload={"cmd": "cancel"}), color="negative")
    .get_json()
)


def valid_datetime(value: str) -> bool:
    return bool(DATE_RE.match(value.strip()))


def valid_contact(value: str) -> bool:
    v = value.strip()
    return bool(PHONE_RE.match(v) or v.startswith("@"))


def save_booking_local(payload: dict[str, str]) -> None:
    with open("bookings.jsonl", "a", encoding="utf-8") as f:
        f.write(json.dumps(payload, ensure_ascii=False) + "\n")


@bot.on.message(text=["запись", "записаться", "start", "меню", "привет"])
async def start_booking(message: Message):
    await message.answer(
        "Здравствуйте! Выберите услугу для записи 👇",
        keyboard=main_menu,
    )


@bot.on.message()
async def route_service(message: Message):
    if not message.payload:
        return

    data = json.loads(message.payload)

    if data.get("cmd") == "cancel":
        await bot.state_dispenser.delete(message.peer_id)
        booking_buffer.pop(message.peer_id, None)
        await message.answer("Сценарий записи отменён. Напишите «запись», чтобы начать заново.")
        return

    service_key = data.get("service")
    if service_key not in SERVICE_LABELS:
        return

    booking_buffer[message.peer_id] = {"service": SERVICE_LABELS[service_key]}
    await bot.state_dispenser.set(message.peer_id, BookingState.WAIT_DATETIME)
    await message.answer("Укажите удобные дату и время, например: 25.03 18:30")


@bot.on.message(StateRule(BookingState.WAIT_DATETIME))
async def booking_datetime(message: Message):
    text = (message.text or "").strip()
    if not valid_datetime(text):
        await message.answer("Формат неверный. Пример: `25.03 18:30`")
        return

    booking_buffer.setdefault(message.peer_id, {})["datetime"] = text
    await bot.state_dispenser.set(message.peer_id, BookingState.WAIT_CONTACT)
    await message.answer("Оставьте контакт: телефон (+79991234567) или @username.")


@bot.on.message(StateRule(BookingState.WAIT_CONTACT))
async def booking_contact(message: Message):
    text = (message.text or "").strip()
    if not valid_contact(text):
        await message.answer("Контакт не распознан. Укажите телефон в международном формате или @username.")
        return

    booking_buffer.setdefault(message.peer_id, {})["contact"] = text
    await bot.state_dispenser.set(message.peer_id, BookingState.WAIT_COMMENT)
    await message.answer("Комментарий к записи (или напишите: нет).")


@bot.on.message(StateRule(BookingState.WAIT_COMMENT))
async def booking_finish(message: Message):
    peer_id = message.peer_id
    user_id = message.from_id or 0
    comment = (message.text or "").strip()

    data = booking_buffer.get(peer_id, {})
    data["comment"] = "—" if comment.lower() == "нет" else comment
    data["user_id"] = str(user_id)
    data["peer_id"] = str(peer_id)
    data["created_at"] = datetime.now().isoformat(timespec="seconds")

    admin_text = (
        "📌 Новая запись из VK-бота\n"
        f"Услуга: {data.get('service', '-')}\n"
        f"Дата/время: {data.get('datetime', '-')}\n"
        f"Контакт: {data.get('contact', '-')}\n"
        f"Комментарий: {data.get('comment', '-')}\n"
        f"User ID: {data.get('user_id', '-')}"
    )

    await bot.api.messages.send(
        peer_id=MANAGER_PEER_ID,
        random_id=0,
        message=admin_text,
    )

    save_booking_local(data)
    await bot.state_dispenser.delete(peer_id)
    booking_buffer.pop(peer_id, None)

    await message.answer("Спасибо! ✅ Запись принята. Администратор свяжется с вами для подтверждения.")


if __name__ == "__main__":
    bot.run_forever()

Запуск

python bot.py

Напишите боту запись и пройдите сценарий до конца.

Частые ошибки

1) FSM «залип» на шаге

Добавьте команду сброса:

@bot.on.message(text=["стоп", "отмена", "cancel"])
async def cancel_flow(message: Message):
    await bot.state_dispenser.delete(message.peer_id)
    booking_buffer.pop(message.peer_id, None)
    await message.answer("Сценарий сброшен. Напишите «запись».")

2) Заявка не ушла менеджеру

Проверьте MANAGER_PEER_ID и права токена на сообщения сообщества.

3) Пользователь ввёл дату «как попало»

Для прод-режима замените regex-валидацию на разбор через datetime.strptime и учтите таймзону.

Что улучшить в прод-версии

  • заменить bookings.jsonl на БД (SQLite/PostgreSQL);
  • добавить расписание свободных слотов;
  • включить напоминания за 24 часа и за 2 часа;
  • добавить антиспам и ограничение частоты на пользователя.

Мини-FAQ по боту записи

Подойдёт ли этот демо-сценарий для стоматологии, клиники, автосервиса?

Да. Нужно только заменить список услуг и тексты подтверждения. Логика FSM остаётся такой же.

Как хранить расписание мастеров в реальном проекте?

Обычно через таблицу слотов в БД (дата, время, мастер, статус). Бот показывает только свободные слоты.

Как избежать двойных записей на одно и то же время?

Нужна атомарная проверка/резерв слота на сервере при подтверждении записи.

Можно ли добавить подтверждение записи кнопкой?

Да. После ввода данных отправляйте сводку и две кнопки: Подтвердить / Изменить.

Нужна версия под ваш бизнес-процесс?

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

Если нужно запуститься быстрее на типовом сценарии, посмотрите каталог готовых ботов.

Что читать дальше

Реклама

Комментарии

Загрузка...