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:
- Вызывает
extract_remote_party_id(event), чтобы достать строку SIP‑заголовкаRemote-Party-ID. При успехе сохраняет значение вmetadata["remote_party_id"]. - Пытается найти номер телефона в этой строке через
extract_phone_from_remote(используетphonenumbersи регион по умолчанию). Если удалось найти валидный или хотя бы возможный номер, он сохраняется вmetadata["phone_display"]и логируется. Если номер определить не удалось, функция записывает это в лог и возвращается, не прерывая обработку звонка. - Нормализует номер до формата E.164 без плюса (
normalize_phone_number). При невозможности нормализации пишет предупреждение в лог и завершает работу, не пытаясь вызывать MCP. - В случае успешной нормализации:
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 и логируются.
- Если
user_recordне найден, функция пишет предупреждение и возвращается. При наличии записи: - извлекает
user_idи аккуратно приводит его к целому числу; - при некорректном формате
user_idзаписывает предупреждение и завершает работу; - при успехе сохраняет
user_idи всю запись пользователя вmetadata["user_id"]иmetadata["user_record"], логирует сопоставлениеuser_id,tg_idиphone. - Вызывает
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 в зависимости от результата проверки подписи и обработки события. |
Шаги обработки:
- Проверка подписи:
- используется
client.webhooks.unwrap(request.data, request.headers); -
при
InvalidWebhookSignatureErrorвозвращается HTTP 400. -
Логирование события:
- payload события превращается в словарь (
model_dump()илиjson()), -
записывается в лог.
-
Разбор типа события:
-
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.
- извлекает
-
realtime.call.ended- извлекает
call_id; - логирует факт завершения звонка и причину (если доступна);
- вызывает
stop_runner(call_id)для остановки раннера; - удаляет запись
call_metadata[call_id]; - возвращает HTTP 200.
- извлекает
-
прочие события
- возвращает 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‑уведомления клиенту, когда по его заявке найден перевозчик. Внутри сервис:
- валидирует JSON payload;
- генерирует короткий текст SMS через OpenAI Responses API (см.
CARGO_ASSIGNED_OPENAI_MODEL); - отправляет 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‑проверки (в оркестраторе или внешнем мониторинге).