Бот записи на услуги ВК: сценарий 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 остаётся такой же.
Как хранить расписание мастеров в реальном проекте?
Обычно через таблицу слотов в БД (дата, время, мастер, статус). Бот показывает только свободные слоты.
Как избежать двойных записей на одно и то же время?
Нужна атомарная проверка/резерв слота на сервере при подтверждении записи.
Можно ли добавить подтверждение записи кнопкой?
Да. После ввода данных отправляйте сводку и две кнопки: Подтвердить / Изменить.
Нужна версия под ваш бизнес-процесс?
Если хотите внедрить запись с реальным расписанием, защитой от дублей, уведомлениями и поддержкой запуска — оставьте заявку на разработку бота под ключ.
Если нужно запуститься быстрее на типовом сценарии, посмотрите каталог готовых ботов.
Что читать дальше
Реклама
Комментарии
Загрузка...