Голосовой робот для записи в салон красоты

В салонах красоты администраторы вручную обрабатывают записи клиентов к специалистам. Если сотрудник занят или отлучился, пропущенный звонок превращается в упущенную выручку.
В статье соберём решение, которое автоматически записывает клиентов к мастерам по телефону без участия персонала.
Пример диалога:
Клиент: «Хочу записаться на стрижку завтра после шести»
Робот: «У Анны свободно в 18:30 и 19:00»
Клиент: «Давайте в 18:30»
Сервис проверяет слот, резервирует его в базе данных и создаёт событие в календаре мастера.
Стек проекта: Python 3.10+, Flask, SQLAlchemy, SQLite и Google Calendar API, Голосовой робот МТС Exolve.
Общая схема работы
Клиент звонит на номер салона. Голосовой робот принимает вызов и отправляет на Flask-сервис вебхук с номером телефона входящего звонка. Бэкенд создаёт локальную сессию, проверяет, есть ли у этого клиента предстоящие записи, и возвращает первую реплику вместе с указанием следующего шага диалога.
После распознавания намерения платформа вызывает второй вебхук. Сервис выбирает одну из четырёх веток: новая запись, перенос, отмена или соединение с администратором.
Для новой записи робот последовательно получает услугу, мастера и желаемое время. Длительность берётся из справочника услуг: стрижка занимает 60 минут, маникюр — 90, окрашивание — 180. Затем бэкенд проверяет календарь конкретного мастера и либо подтверждает слот, либо предлагает две ближайшие альтернативы.
При переносе записи бэкенд сначала находит активный визит в SQLite, а затем проверяет новое время. Текущее событие передаётся в проверку как исключение: иначе запись будет конфликтовать сама с собой.
При отмене сервис сначала удаляет событие из Google Calendar и только после успешного ответа помечает локальную запись отменённой. Это уменьшает риск ситуации, когда SQLite уже сообщает об отмене, а слот в календаре по-прежнему занят.
Поток данных выглядит так:
Клиент
│ голосовой звонок
▼
Голосовая платформа
│ HTTPS-вебхуки: начало звонка, намерение, параметры записи
▼
Flask-сервис
├─ создаёт и обновляет сессию звонка
├─ проверяет услугу, мастера и время
├─ управляет созданием, переносом и отменой записи
├─ формирует реплику для синтеза речи
│
├──────────────► SQLite
│ ├─ клиенты
│ ├─ история звонков
│ └─ связь записи с событием календаря
│
└──────────────► Google Calendar API
├─ фактическая занятость мастеров
├─ создание и перенос событий
└─ удаление событий
Запрос клиента проходит через следующие эндпоинты:
POST /webhook/voice/incoming-call — создаёт сессию звонка
POST /webhook/voice/intent — выбирает ветку диалога
POST /api/v1/booking/check-slot — проверяет время и возвращает альтернативы
POST /api/v1/booking/confirm — создаёт запись
POST /api/v1/booking/active — возвращает активные записи клиента
POST /api/v1/booking/reschedule — переносит существующий визит
POST /api/v1/booking/cancel — отменяет запись
POST /webhook/voice/finish — фиксирует итог звонка
Если запрос не распознан после нескольких попыток или календарь недоступен, Flask возвращает команду на перевод звонка администратору.
Архитектура решения
Бэкенд связывает голосовую платформу, локальную базу и Google Calendar. Голосовая платформа обеспечивает телефонное соединение, распознаёт речь и синтезирует ответы. Flask обрабатывает вебхуки и управляет операциями с записями: проверкой слотов, созданием, переносом или отменой визитов.
SQLite хранит внутреннее состояние сервиса: данные клиентов и мастеров, историю вызовов и связи локальных записей с событиями в календаре. SQLAlchemy описывает сущности в коде. Это позволяет сменить СУБД без переработки бизнес-логики.
Google Calendar хранит расписание мастеров. Перед операциями с визитом сервис проверяет свободные слоты через API. После подтверждения записи система автоматически создаёт или обновляет событие в календаре.
Такое разделение ответственности делает сценарий прозрачным. Flask управляет логикой диалога, SQLite хранит контекст, а Google Calendar отвечает за актуальность расписания.
Голосовая платформа
│
│ JSON + X-Webhook-Secret
▼
app.py
├─ маршруты и HTTP-контракты
├─ состояние звонка
├─ проверка бизнес-правил
└─ резервный перевод на администратора
│ │
▼ ▼
database.py services/google_calendar_client.py
│ │
▼ ▼
SQLite Google Calendar API
Кто за что отвечает:
app.py — вебхуки, ветвление диалога, валидация запросов и операции с записью
database.py — модели SQLAlchemy, индексы, настройки SQLite и тестовые данные
services/google_calendar_client.py — проверка пересечений, поиск альтернатив и работа с событиями
config.py — чтение переменных окружения и проверка обязательных настроек
Модели записей и звонков
Основные сущности — Appointment и CallRecord. Первая хранит параметры визита и идентификатор события календаря, вторая фиксирует путь звонка и его результат.
#database.py
class Appointment(Base):
__tablename__ = "appointments"
id = Column(Integer, primary_key=True)
client_id = Column(Integer, ForeignKey("clients.id"), nullable=False)
master_id = Column(Integer, ForeignKey("masters.id"), nullable=False)
service_id = Column(Integer, ForeignKey("beauty_services.id"), nullable=False)
client_phone = Column(String(20), nullable=False, index=True)
service_name = Column(String(100), nullable=False)
master_name = Column(String(100), nullable=False)
starts_at = Column(DateTime(timezone=True), nullable=False)
ends_at = Column(DateTime(timezone=True), nullable=False)
status = Column(String(30), default="active")
google_calendar_id = Column(String(255), nullable=False)
calendar_event_id = Column(String(255), nullable=True)
idempotency_key = Column(String(80), nullable=True)
class CallRecord(Base):
__tablename__ = "call_records"
id = Column(Integer, primary_key=True)
phone = Column(String(20), nullable=False, index=True)
intent = Column(String(50), nullable=True)
appointment_id = Column(Integer, ForeignKey("appointments.id"), nullable=True)
call_result = Column(String(50), nullable=True)
transfer_reason = Column(String(255), nullable=True)У визита статусы простые: active и cancelled. У звонка статусов больше, потому что CallRecord фиксирует этап и итог диалога: успешное создание, перенос или отмену записи, перевод на администратора и финальное завершение после вебхука /webhook/voice/finish.
├─ appointment_created
├─ appointment_rescheduled
├─ appointment_cancelled
└─ transfer_to_admin
/webhook/voice/finish
├─ result="success" -> finished
├─ result="transfer" -> transfer_to_admin
└─ другой result -> status=resultЗащита целостности
На уровне SQLite используются два частичных уникальных индекса. Первый запрещает две активные записи одному мастеру с одинаковым временем начала. Второй не позволяет повторно использовать один ключ идемпотентности.
__table_args__ = (
Index(
"uq_appt_active_slot",
"master_id", "starts_at",
unique=True,
sqlite_where=text("status = 'active'"),
postgresql_where=text("status = 'active'"),
),
Index(
"uq_appt_idem",
"idempotency_key",
unique=True,
sqlite_where=text("idempotency_key IS NOT NULL"),
postgresql_where=text("idempotency_key IS NOT NULL"),
),
)Индекс по слоту не обнаруживает все возможные пересечения. Записи на 18:00–19:00 и 18:30–20:00 имеют разное время начала, поэтому конфликт интервалов проверяется через Google Calendar перед созданием события.
Чтобы два параллельных запроса на создание записи не заняли один и тот же слот, записи к конкретному мастеру обрабатываются по очереди. Для запуска небольшого пилота этого достаточно. Если сервис запускать с несколькими воркерами или контейнерами, блокировку нужно переносить на уровень базы данных или внешнего хранилища.
Пререквизит
Проект использует синтаксис объединённых типов str | None, поэтому потребуется Python 3.10 или новее. Создайте виртуальное окружение и установите зависимости:
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtДалее настройте Google Calendar API:
Создайте проект в Google Cloud
Включите Google Calendar API
Создайте сервисный аккаунт и скачайте JSON-файл с ключом
Предоставьте адресу сервисного аккаунта права на просмотр и редактирование календарей мастеров
Замените тестовые calendar_id в database.py на реальные идентификаторы календарей
Создайте .env со следующими параметрами:
DEBUG=false
PORT=5000
BASE_URL=https://example.com
DATABASE_URL=sqlite:///beauty_salon.db
SALON_TIMEZONE=Europe/Moscow
GOOGLE_SERVICE_ACCOUNT_FILE=service-account.json
ADMIN_PHONE=79991112233
WEBHOOK_SECRET=random-long-secret-value
WORK_START_HOUR=9
WORK_END_HOUR=21
SLOT_STEP_MINUTES=30
SLOT_SEARCH_DAYS=7BASE_URL — публичный HTTPS-адрес, доступный голосовой платформе.
WEBHOOK_SECRET передаётся в заголовке X-Webhook-Secret. При старте приложение проверяет, что секрет содержит не менее 16 символов и не похож на заглушку.
Также конфигурация валидирует:
Наличие файла сервисного аккаунта
Номер администратора
Корректность временной зоны
Рабочий интервал
Положительный шаг сетки и горизонт поиска
Если обязательный параметр не заполнен, приложение завершится до запуска веб-сервера.
Шаг 1. Принимаем звонок и создаём сессию
Входящий вызов активирует POST /webhook/voice/incoming-call. Сервис проверяет формат JSON, нормализует номер телефона и отклоняет запрос, если номер не удалось привести к 11 цифрам, начинающимся с 7.
#app.py
@app.route("/webhook/voice/incoming-call", methods=["POST"])
@require_secret
def incoming_call():
data = read_json_object()
if data is None:
return jsonify({"error": "json_body_must_be_object"}), 400
phone = normalize_phone(data.get("phone"))
if not phone:
return jsonify({"error": "invalid_phone"}), 400Декоратор require_secret сравнивает значение заголовка через hmac.compare_digest. Это не заменяет сетевые ограничения и проверку подписей платформы, но защищает эндпоинты от случайных и неавторизованных запросов.
После валидации сервис ищет клиента по номеру. Если его нет, создаётся новый профиль. Затем в call_records появляется запись со статусом started.
#app.py
call_record = CallRecord(
phone=phone,
client_id=client.id,
client_name=client.name,
status="started",
)
db.add(call_record)
db.commit()
has_active = get_active_appointment(db, phone) is not NoneПервая реплика зависит от двух признаков: известно ли имя клиента и есть ли у него активный визит. Перенос предлагается только тогда, когда в базе действительно есть запись, которую можно перенести.
В ответе сервис возвращает call_id. Голосовая платформа должна передавать его во всех последующих запросах этой сессии, чтобы операции с записью можно было связать с конкретным звонком.
Шаг 2. Выбираем сценарий по намерению клиента
После распознавания речи платформа вызывает POST /webhook/voice/intent и передаёт поле intent. В текущем примере используется простой набор допустимых значений на русском и английском языках.
#app.py
if intent in {"book", "booking", "запись", "записаться"}:
return jsonify({
"status": "ok",
"branch": "booking",
"phrase": "На какую услугу хотите записаться?",
"next_step": "collect_service",
}), 200Для переноса и отмены сервис ищет все активные визиты клиента. Если запись одна, её идентификатор сразу возвращается платформе. Если визитов несколько, ответ содержит массив appointments, и робот попросит клиента выбрать конкретную запись.
Если активных визитов нет, звонок переходит администратору. Неизвестное намерение обрабатывается лестницей повторов:
Первая попытка — обычный уточняющий вопрос
Вторая — просьба ответить одним словом
Третья и последующие — перевод на администратора
Это не даёт диалогу зациклиться, если речь распознана неверно или пользователь задаёт вопрос не по сценарию.
Шаг 3. Проверяем услугу, мастера и время
Когда голосовая платформа собрала параметры записи, она отправляет их в POST /api/v1/booking/check-slot. Бэкенд проверяет обязательные поля, разбирает ISO 8601 и приводит время к UTC.
Если строка не содержит временную зону, код считает, что значение указано в SALON_TIMEZONE. Это удобно для внутреннего сценария, но в HTTP-контракте лучше всегда передавать явное смещение, например +03:00.
#app.py
service_name = str(data.get("service_name") or "").strip()
master_name = str(data.get("master_name") or "").strip()
start_at = parse_datetime(data.get("start_time"))
if not service_name or not master_name or not start_at:
return jsonify({"error": "invalid_params"}), 400
if not is_future_time(start_at):
return jsonify({"error": "time_must_be_in_future"}), 400Затем сервис ищет услугу и активного мастера в справочниках. При неизвестном значении робот может переспросить клиента.
Проверка расписания обёрнута в try/except. Если Google Calendar недоступен, сервис возвращает бизнес-ответ manual_check с HTTP-кодом 200.
#app.py
try:
is_free = calendar_client.is_slot_free(
calendar_id=master.calendar_id,
start_at=start_at,
duration_minutes=service.duration_minutes,
)
except Exception as e:
logger.error("Ошибка проверки слота: %s", e)
return jsonify({
"status": "manual_check",
"phrase": "Не получилось проверить расписание. Перевожу на администратора.",
"transfer_phone": Config.ADMIN_PHONE,
}), 200Если время для записи свободно, платформа получает подтверждение и переходит к финальному вопросу. Если слот занят — сервис запускает поиск ближайших альтернатив.
Шаг 4. Ищем свободные окна в Google Calendar
Метод is_slot_free запрашивает события, которые попадают в интервал услуги, преобразует их во временные диапазоны и проверяет пересечения.
#services/google_calendar_client.py
def is_slot_free(
self,
calendar_id: str,
start_at: datetime,
duration_minutes: int,
ignore_event_id: str | None = None,
) -> bool:
end_at = start_at + timedelta(minutes=duration_minutes)
events = self._list_raw_events(calendar_id, start_at, end_at)
return not _overlaps(_busy_intervals(events, ignore_event_id), start_at, end_at)При проверке занятости сервис находит события, которые блокируют время мастера. Система игнорирует отменённые встречи и записи со статусом «свободен». При переносе визита сервис исключает текущую запись из проверки, чтобы избежать конфликтов.
Если выбранное время занято, find_nearest_free_slots ищет ближайшие варианты по рабочей сетке с шагом SLOT_STEP_MINUTES: например, каждые 30 минут в пределах графика мастера. Поиск начинается с желаемого времени и, если в этот день свободных окон нет, переходит на следующие дни.
def find_nearest_free_slots(
self,
calendar_id: str,
preferred_start: datetime,
duration_minutes: int,
limit: int = 2,
work_start_hour: int | None = None,
work_end_hour: int | None = None,
) -> list[datetime]:
salon_tz = _salon_tz()
ws = Config.WORK_START_HOUR if work_start_hour is None else work_start_hour
we = Config.WORK_END_HOUR if work_end_hour is None else work_end_hour
step = Config.SLOT_STEP_MINUTES
duration = timedelta(minutes=duration_minutes)
now_utc = datetime.now(timezone.utc)
preferred_local = preferred_start.astimezone(salon_tz)
variants: list[datetime] = []
for day_offset in range(Config.SLOT_SEARCH_DAYS):
day = preferred_local + timedelta(days=day_offset)
day_start = day.replace(hour=ws, minute=0, second=0, microsecond=0)
day_end = day.replace(hour=we, minute=0, second=0, microsecond=0)
lower = max(preferred_local, day_start) if day_offset == 0 else day_start
current = _ceil_to_step(lower, step, day_start)
events = self._list_raw_events(
calendar_id,
day_start.astimezone(timezone.utc),
day_end.astimezone(timezone.utc),
)
busy = _busy_intervals(events, None)
while current + duration <= day_end:
cur_utc = current.astimezone(timezone.utc)
slot_end = cur_utc + duration
if cur_utc > now_utc and not _overlaps(busy, cur_utc, slot_end):
variants.append(cur_utc)
if len(variants) >= limit:
return variants
current += timedelta(minutes=step)
return variantsЕсли время занято, сервис ищет альтернативы без лишних запросов к Google Calendar. Система один раз загружает события мастера за рабочий день и локально проверяет свободные слоты по сетке. Если в текущую дату окон нет, поиск переходит на следующий день.
Рабочие часы мастеров хранятся в таблице masters: у каждого сотрудника могут быть собственные значения, а переменные окружения задают значения по умолчанию. Такая схема подходит для простого графика. Перерывы, отпуска, кабинеты и смены лучше моделировать отдельными сущностями или событиями календаря.
Шаг 5. Бронируем слот без дублей
Эндпоинт POST /api/v1/booking/confirm принимает данные о клиенте, мастере, услуге, времени визита и опционально ключ идемпотентности.
Если Голосовой робот не получит подтверждение вовремя, платформа отправит запрос снова — без проверки ключа сервис создаст лишнюю запись. Сначала приложение проверяет наличие визита с таким ключом в базе данных.
if idem_key:
existing = db.query(Appointment).filter_by(idempotency_key=idem_key).first()
if existing:
return jsonify(confirmed_payload(existing)), 200Если записи нет, бэкенд повторно проверяет занятость слота в Google Calendar. Это предотвращает накладки, если время успели занять между этапами сценария. Система блокирует операции для конкретного мастера на время проверки и формирования записи.
with master_lock(master.id):
is_free = calendar_client.is_slot_free(
calendar_id=master.calendar_id,
start_at=start_at,
duration_minutes=service.duration_minutes,
)
if not is_free:
return jsonify(busy_response(
master, start_at, service.duration_minutes,
status="slot_busy",
prefix="Это время уже заняли. Могу предложить",
)), 200
appointment = Appointment(
client_id=client.id,
master_id=master.id,
service_id=service.id,
client_phone=phone,
client_name=client.name,
service_name=service.name,
master_name=master.name,
starts_at=start_at,
ends_at=start_at + timedelta(minutes=service.duration_minutes),
status="active",
google_calendar_id=master.calendar_id,
calendar_event_id=None,
idempotency_key=idem_key,
)
db.add(appointment)
try:
db.flush()
except IntegrityError:
db.rollback()
if idem_key:
existing = db.query(Appointment).filter_by(idempotency_key=idem_key).first()
if existing:
return jsonify(confirmed_payload(existing)), 200
return jsonify(busy_response(
master, start_at, service.duration_minutes,
status="slot_busy",
prefix="Это время уже заняли. Могу предложить",
)), 200
event_id = calendar_client.create_appointment_event(
calendar_id=master.calendar_id,
client_name=client.name,
client_phone=phone,
service_name=service.name,
master_name=master.name,
start_at=start_at,
duration_minutes=service.duration_minutes,
)
appointment.calendar_event_id = event_id
db.commit()db.flush() вносит данные в SQLite до обращения к внешнему календарю, но не завершает транзакцию. Если возникнет конфликт уникальных индексов, приложение откатит изменения и не создаст лишнее событие в Google Calendar.
После ответа от API календаря сервис сохраняет идентификатор события в calendar_event_id и фиксирует транзакцию. При повторном запросе с тем же ключом идемпотентности система вернёт данные существующей записи.
Шаг 6. Отменяем запись без ложного подтверждения
Сначала сервис ищет активные записи по номеру телефона. Опциональный appointment_id позволяет выбрать конкретный визит. Если у клиента несколько визитов и вы не передали идентификатор записи, приложение возвращает список. Это исключает случайную отмену не того слота.
После выбора записи приложение удаляет событие из Google Calendar:
if appointment.calendar_event_id:
try:
calendar_client.delete_appointment_event(
calendar_id=appointment.google_calendar_id,
event_id=appointment.calendar_event_id,
)
except Exception as calendar_error:
logger.error("Ошибка удаления события из календаря: %s", calendar_error)
return jsonify({
"status": "manual_check",
"phrase": "Не получилось отменить запись автоматически. Перевожу на администратора.",
"transfer_phone": Config.ADMIN_PHONE,
}), 200
appointment.status = "cancelled"
db.commit()Приложение обновляет локальный статус только после подтверждения от Google Calendar. Благодаря этому Голосовой робот сообщает об успехе, когда система уже освободила слот во внешнем расписании.
Так как это не распределённая транзакция, возможен рассинхрон данных. Если сервис удаляет внешнее событие, а транзакция в SQLite завершается ошибкой, база данных и календарь разойдутся. Чтобы восстановить целостность, настройте периодическую сверку локальных записей с расписанием в Google Calendar.
Шаг 7. Переносим визит без конфликта с самим собой
Приложение находит запись по номеру телефона клиента, а если активных записей несколько — по appointment_id. Сначала сервис проверяет доступность нового времени. Чтобы исходная запись не блокировала слот сама себе, система передаёт её идентификатор в параметре ignore_event_id.
is_free = calendar_client.is_slot_free(
calendar_id=master.calendar_id,
start_at=new_start_at,
duration_minutes=service.duration_minutes,
ignore_event_id=appointment.calendar_event_id,
)Без этого параметра старое событие попадёт в список занятых интервалов и создаст ложный конфликт.
После проверки бэкенд резервирует новое время в локальной транзакции. flush() позволяет обнаружить совпадение начала слота до обращения к Google Calendar. Затем событие переносится методом events.patch.
appointment.starts_at = new_start_at
appointment.ends_at = new_start_at + timedelta(minutes=service.duration_minutes)
try:
db.flush()
except IntegrityError:
db.rollback()
return jsonify(busy_response(
master, new_start_at, service.duration_minutes,
status="slot_busy", prefix="Это время заняли. Могу предложить",
)), 200
try:
calendar_client.update_appointment_event(
calendar_id=appointment.google_calendar_id,
event_id=appointment.calendar_event_id,
start_at=new_start_at,
duration_minutes=service.duration_minutes,
)
except Exception as calendar_error:
db.rollback()
logger.error("Ошибка обновления события в календаре: %s", calendar_error)
return jsonify({
"status": "manual_check",
"phrase": "Не получилось перенести запись автоматически. Перевожу на администратора.",
"transfer_phone": Config.ADMIN_PHONE,
}), 200
db.commit()Если API календаря возвращает ошибку, приложение откатывает локальную транзакцию и сохраняет прежнее время в SQLite. Как и в сценарии отмены, сетевой сбой после изменения внешнего события может привести к рассинхронизации данных.
Шаг 8. Закрываем сессию и фиксируем результат
Когда разговор заканчивается, МТС Exolve отправляет вебхук POST /webhook/voice/finish. Этот запрос закрывает лог звонка и сохраняет итог: успешное завершение, перевод или другой статус, переданный платформой.
if call_record:
call_record.call_result = result
if transfer_reason:
call_record.transfer_reason = str(transfer_reason)
if result == "transfer":
call_record.status = "transfer_to_admin"
elif result == "success":
call_record.status = "finished"
else:
call_record.status = result
db.commit()История вызовов позволяет рассчитать конверсию в запись и долю переводов на администратора. Чтобы расширить аналитику, можно логировать содержание диалога и длительность каждого этапа. Текущая структура уже объединяет данные звонка, визита и события в Google Calendar.
Запуск и проверка
Перед запуском проверьте JSON-файл сервисного аккаунта и замените тестовый идентификатор календаря рабочим значением. Система проверит обязательные переменные окружения при старте, а корректность calendar_id — при первом обращении к Google Calendar API.
Запустите Flask:
python app.pyПосле запуска проверьте технический эндпоинт /healthz, а затем пройдите основной сценарий записи через curl.
curl http://localhost:5000/healthzОжидаемый ответ:
{
"status": "ok"
}1. Создаём сессию звонка
Система проверит номер телефона 79991112233 в базе данных и вернёт стартовую фразу.
curl -X POST http://localhost:5000/webhook/voice/incoming-call \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: random-long-secret-value" \
-d '{"phone": "+7 999 111-22-33"}'Ответ содержит идентификатор звонка, флаг наличия активных записей и следующий шаг сценария:
{
"call_id": 1,
"client_found": true,
"client_name": "Елена",
"has_active_appointment": false,
"phrase": "Елена, здравствуйте. Хотите записаться или соединить с администратором?",
"next_step": "recognize_intent"
}2. Передаём распознанное намерение
curl -X POST http://localhost:5000/webhook/voice/intent \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: random-long-secret-value" \
-d '{
"call_id": 1,
"phone": "+7 999 111-22-33",
"intent": "записаться",
"attempt": 1
}'Сервис переключает контекст на бронирование и запрашивает название услуги:
{
"status": "ok",
"branch": "booking",
"phrase": "На какую услугу хотите записаться?",
"next_step": "collect_service"
}3. Проверяем время
Для примера используем дату в будущем. В базе есть мастер Анна и услуга Стрижка.
curl -X POST http://localhost:5000/api/v1/booking/check-slot \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: random-long-secret-value" \
-d '{
"service_name": "Стрижка",
"master_name": "Анна",
"start_time": "2030-08-03T18:30:00+03:00"
}'Если календарь свободен, приложение вернёт:
{
"status": "free",
"phrase": "Есть свободное время у Анна на 03.08.2030 в 18:30. Записать вас?",
"service_name": "Стрижка",
"master_name": "Анна",
"start_time": "2030-08-03T15:30:00+00:00"
}Если время занято, ответ предложит две альтернативы по календарю мастера:
{
"status": "busy",
"phrase": "Это время занято. Могу предложить 03.08.2030 в 19:00 или 03.08.2030 в 19:30.",
"alternatives": [
{
"start_time": "2030-08-03T16:00:00+00:00",
"label": "03.08.2030 в 19:00"
},
{
"start_time": "2030-08-03T16:30:00+00:00",
"label": "03.08.2030 в 19:30"
}
]
}4. Подтверждаем запись и проверяем идемпотентность
curl -X POST http://localhost:5000/api/v1/booking/confirm \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: random-long-secret-value" \
-d '{
"phone": "+7 999 111-22-33",
"call_id": 1,
"client_name": "Елена",
"service_name": "Стрижка",
"master_name": "Анна",
"start_time": "2030-08-03T18:30:00+03:00",
"idempotency_key": "call-1-anna-haircut-20300803T1830"
}'Успешный ответ:
{
"status": "confirmed",
"phrase": "Готово. Вы записаны на Стрижка к мастеру Анна на 03.08.2030 в 18:30.",
"appointment_id": 1,
"calendar_event_id": "google-event-id"
}Повторите тот же запрос с тем же idempotency_key. Сервис должен вернуть тот же appointment_id и calendar_event_id, не создавая второе событие.
5. Получаем активную запись
curl -X POST http://localhost:5000/api/v1/booking/active \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: random-long-secret-value" \
-d '{"phone": "+7 999 111-22-33"}'Запрос вернёт список актуальных визитов клиента. Полученный appointment_id используется в сценариях переноса или отмены встречи.
6. Переносим визит
curl -X POST http://localhost:5000/api/v1/booking/reschedule \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: random-long-secret-value" \
-d '{
"phone": "+7 999 111-22-33",
"call_id": 1,
"appointment_id": 1,
"new_start_time": "2030-08-03T19:30:00+03:00"
}'При успешном переносе сервис ответит:
{
"status": "rescheduled",
"phrase": "Готово. Перенёс запись на 03.08.2030 в 19:30."
}7. Отменяем визит
curl -X POST http://localhost:5000/api/v1/booking/cancel \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: random-long-secret-value" \
-d '{
"phone": "+7 999 111-22-33",
"call_id": 1,
"appointment_id": 1
}'Ожидаемый ответ:
{
"status": "cancelled",
"phrase": "Готово. Запись отменена."
}8. Закрываем звонок
curl -X POST http://localhost:5000/webhook/voice/finish \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: random-long-secret-value" \
-d '{
"call_id": 1,
"result": "success"
}'Сервис вернёт {"status":"ok"} и обновит запись в call_records.
Заключение
Мы собрали сервис для автоматической записи к мастеру в салоне красоты. Голосовой робот принимает звонки, распознаёт намерения клиентов и озвучивает реплики, а Flask-сервис управляет бизнес-логикой и визитами.
С Голосовым роботом клиент быстрее получает ответ, особенно в пиковые часы, а администратор подключается только там, где автоматический сценарий не справился.
Стек из Flask и SQLite подходит для запуска пилота в одном заведении, но промышленным запуском её нужно усилить по части хранения данных, фоновой обработки внешних вызовов, мониторинга и восстановления после сбоев.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.