Перейти к содержанию

Webhook и маршруты

Этот раздел описывает Flask‑приложение SIP Hook и обработку webhook‑событий от OpenAI Realtime.

Note

Из всех маршрутов SIP Hook публичным обычно должен быть только POST /sip-hook (его вызывает OpenAI) и, при необходимости, GET /health.
POST /cargo-assigned — внутренний маршрут без авторизации: если открыть его наружу, любой сможет попытаться отправить SMS за ваш счёт.

Flask‑приложение

Файл: sip-hook/sip_hook_app/webhook.py

Создаёт приложение:

from flask import Flask

app = Flask(__name__)

Импортируются вспомогательные модули:

  • config.client, config.logger — общий клиент OpenAI и логгер;
  • history_sync.load_history_for_metadata — загрузка истории диалога;
  • mcp.verify_user_for_call — верификация пользователя по телефону;
  • phone.extract_* — извлечение и нормализация номера телефона;
  • runtime.accept_call, runtime.start_runner, runtime.stop_runner — управление Realtime‑раннером;
  • state.call_metadata — словарь для хранения метаданных по активным звонкам.

Определение пользователя по телефону

Вспомогательная функция _resolve_user(call_id, event, metadata) извлекает номер телефона, сопоставляет его с пользователем и подгружает историю диалога.

Параметры _resolve_user

Name Type Description Default
call_id str Идентификатор звонка из события Realtime; используется как ключ для call_metadata и при логировании.
event объект события Realtime Объект события realtime.call.incoming, содержащий заголовки и данные звонка.
metadata dict Словарь метаданных по звонку; функция дополняет его полями remote_party_id, phone_display, user_id, history.

Возвращаемое значение

Name Type Description
Функция ничего не возвращает; все результаты работы сохраняются и используются через словарь metadata.

Что делает _resolve_user:

  1. Вызывает extract_remote_party_id(event), чтобы достать строку SIP‑заголовка Remote-Party-ID. При успехе сохраняет значение в metadata["remote_party_id"].
  2. Пытается найти номер телефона в этой строке через extract_phone_from_remote (использует phonenumbers и регион по умолчанию). Если удалось найти валидный или хотя бы возможный номер, он сохраняется в metadata["phone_display"] и логируется. Если номер определить не удалось, функция записывает это в лог и возвращается, не прерывая обработку звонка.
  3. Нормализует номер до формата E.164 без плюса (normalize_phone_number). При невозможности нормализации пишет предупреждение в лог и завершает работу, не пытаясь вызывать MCP.
  4. В случае успешной нормализации:
user_record = verify_user_for_call(call_id, normalized_phone)

Эта функция сначала пытается вызвать MCP‑инструмент verify_user через Agents SDK (MCPServerStreamableHttp), а при неудаче использует резервный путь через db.verify_user_record, который работает напрямую с таблицей users в MariaDB. Любые непойманные исключения на этом этапе приводят к HTTP‑ответу 502 в sip_hook и логируются.

  1. Если user_record не найден, функция пишет предупреждение и возвращается. При наличии записи:
  2. извлекает user_id и аккуратно приводит его к целому числу;
  3. при некорректном формате user_id записывает предупреждение и завершает работу;
  4. при успехе сохраняет user_id и всю запись пользователя в metadata["user_id"] и metadata["user_record"], логирует сопоставление user_id, tg_id и phone.
  5. Вызывает load_history_for_metadata(metadata, user_id), которая запрашивает историю диалога через fetch_dialogue_history и добавляет её в metadata["history"]. Количество загруженных записей логируется.

Если номер или пользователь не определены, звонок всё равно может быть принят, но голосовой ассистент не будет привязан к конкретной записи в БД, а история диалога не подмешивается в инструкции.

Маршрут /sip-hook

@app.route("/sip-hook", methods=["POST"])
def sip_hook() -> Response:
    ...

Параметры и поведение маршрута /sip-hook

Name Type Description Default
request Request Объект Flask‑запроса; содержит тело события Realtime и заголовки для проверки подписи.
return Response HTTP‑ответ: 200 при успешной обработке события, 400/502 при ошибках подписи или внутренних сбоях.

Возвращаемое значение

Name Type Description
response Response HTTP‑ответ с кодом 200, 400 или 502 в зависимости от результата проверки подписи и обработки события.

Шаги обработки:

  1. Проверка подписи:
  2. используется client.webhooks.unwrap(request.data, request.headers);
  3. при InvalidWebhookSignatureError возвращается HTTP 400.

  4. Логирование события:

  5. payload события превращается в словарь (model_dump() или json()),
  6. записывается в лог.

  7. Разбор типа события:

  8. realtime.call.incoming

    • извлекает call_id из event.data.call_id;
    • получает или создаёт metadata = call_metadata.setdefault(call_id, {});
    • вызывает _resolve_user(call_id, event, metadata):
    • при ошибке возвращает HTTP 502 ("verify_user failed");
    • вызывает accept_call(call_id, metadata):
    • при ошибке возвращает HTTP 502 ("call accept failed");
    • запускает Realtime‑раннер start_runner(call_id, metadata);
    • возвращает HTTP 200.
  9. realtime.call.ended

    • извлекает call_id;
    • логирует факт завершения звонка и причину (если доступна);
    • вызывает stop_runner(call_id) для остановки раннера;
    • удаляет запись call_metadata[call_id];
    • возвращает HTTP 200.
  10. прочие события

    • возвращает HTTP 200 без дополнительной логики.

Таким образом, эндпоинт /sip-hook обрабатывает только два ключевых сценария:

  • входящий звонок: сопоставление пользователя, accept и запуск раннера;
  • завершение звонка: остановка раннера и очистка метаданных.

Note

Даже если номер/пользователь не определились (например, не получилось вытащить телефон из SIP‑заголовков), SIP Hook всё равно попытается принять звонок и запустить раннер.
В этом случае голосовой агент будет работать без привязки к user_id и без истории из dialogue_history.

Маршрут /cargo-assigned (SMS‑уведомление)

Файл: sip-hook/sip_hook_app/webhook.py, sip-hook/sip_hook_app/notifications.py

Эндпоинт POST /cargo-assigned предназначен для отправки SMS‑уведомления клиенту, когда по его заявке найден перевозчик. Внутри сервис:

  1. валидирует JSON payload;
  2. генерирует короткий текст SMS через OpenAI Responses API (см. CARGO_ASSIGNED_OPENAI_MODEL);
  3. отправляет SMS через HTTP API шлюза Dinstar UC2000 (DINSTAR_GATEWAY_*).

Есть два важных ограничения, из‑за которых запрос может закончиться 502 даже при «валидном» payload:

  • сервис требует, чтобы SMS было коротким (до 300 символов);
  • в тексте должен быть контакт в Telegram — @nika_logist (это проверяется кодом).

Если первая генерация не прошла проверку, сервис делает ещё несколько попыток (укорачивая ответ), и только потом возвращает ошибку.

Warning

Этот маршрут реально отправляет SMS через шлюз Dinstar. Это может быть платно и необратимо: сообщение уйдёт на реальный номер.
Тестируйте только на тестовых номерах/в стейдж‑контуре и не публикуйте /cargo-assigned наружу.

Ожидаемый payload

{
  "request_id": 123456,
  "phone": "380XXXXXXXXX",
  "cargo": { "...": "..." }
}
  • request_id — положительное целое (допускается строка с цифрами).
  • phone — строка с номером; сервис извлекает только цифры и отправляет в шлюз в формате +<digits>.
  • cargo — произвольный объект с данными груза (используется только как контекст для генерации текста; может отсутствовать).

Ответы

  • 200 {"success": true} — SMS отправлено.
  • 400 — не JSON / неверная структура payload.
  • 502 — не удалось сгенерировать SMS через OpenAI или отправить в шлюз.
  • 500 — непредвиденная ошибка парсинга/обработки.

Маршрут /health

@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok"}

Простой эндпоинт для health‑проверки (в оркестраторе или внешнем мониторинге).