SIP Hook
SIP Hook — это Flask‑приложение (sip-hook/), которое принимает webhook‑события от OpenAI Realtime для SIP‑звонков и подключает голосового ассистента к общей истории пользователя. Дополнительно в сервисе есть эндпоинт POST /cargo-assigned для отправки SMS‑уведомлений через шлюз Dinstar.
Жизненный цикл звонка
При входящем звонке OpenAI Realtime отправляет событие realtime.call.incoming на эндпоинт /sip-hook. Flask‑приложение из sip_hook_app/webhook.py сначала проверяет подпись webhook через client.webhooks.unwrap, а затем по полю type события определяет, что нужно обработать новый вызов. Для call_id = event.data.call_id в глобальном словаре call_metadata создаётся (или извлекается) словарь метаданных, после чего вызывается _resolve_user: из SIP‑заголовка Remote-Party-ID извлекается телефон, приводится к нормализованному виду и через mcp.verify_user_for_call (MCP‑инструмент verify_user + fallback в MariaDB) определяется user_id. Если пользователь найден, в metadata сохраняются user_id, исходная запись users и загруженная история диалога (history_sync.load_history_for_metadata).
После того как пользователь определён, приложение формирует инструкции для голосового агента, добавляя в них номер телефона, user_id и последние строки из dialogue_history. Затем вызывается runtime.accept_call: в Realtime API отправляется запрос POST /realtime/calls/{call_id}/accept с моделью, голосом, настройками VAD и параметрами онлайн‑транскрипции. Если вызов успешно принят, runtime.start_runner запускает отдельный поток с асинхронной функцией run_realtime_session, которая создаёт Realtime‑агента (model_setup.build_voice_agent), подключается к Realtime‑сессии и начинает слушать события истории (history_added, history_updated) и ошибки. Каждый завершённый элемент истории через history_sync.handle_history_event превращается в запись в dialogue_history с каналом sip, так что текстовые и голосовые диалоги оказываются в одной таблице.
Когда модель или система связи завершает звонок, Realtime отправляет событие realtime.call.ended. Тот же эндпоинт /sip-hook обрабатывает его, вызывает runtime.stop_runner(call_id), устанавливает флаг остановки для соответствующего раннера и, при необходимости, закрывает Realtime‑сессию. После этого метаданные call_metadata[call_id] очищаются, а поток раннера завершает работу. В результате для каждого звонка есть понятный жизненный цикл: входящий webhook → определение номера и пользователя → принятие звонка (accept_call) → запуск голосового агента → запись истории в БД → завершение звонка и остановка раннера.
На последовательной диаграмме этот поток можно представить так:
Роль SIP Hook в системе
Задача сервиса — связать голосовой канал с остальными компонентами системы. При входящем звонке приложение определяет пользователя по номеру телефона, загружает его историю диалога из таблицы dialogue_history, принимает звонок через Realtime API и запускает голосовой раннер. Во время разговора новые реплики автоматически дописываются в ту же таблицу, чтобы их затем мог использовать Telegram‑бот и другие клиенты.
Основной HTTP‑эндпоинт SIP Hook — /sip-hook. На него OpenAI Realtime отправляет события, связанные с SIP‑вызовами. Приложение проверяет подпись webhook с помощью OPENAI_WEBHOOK_SECRET и по типу события решает, нужно ли принимать звонок или завершать его. При входящем звонке SIP Hook извлекает номер телефона из SIP‑заголовков, нормализует его и пытается сопоставить с записью пользователя через MCP‑инструмент verify_user. Если MCP временно недоступен, используется резервный путь через чтение таблицы users напрямую.
Фрагмент Flask‑приложения из sip_hook_app/webhook.py:
app = Flask(__name__)
@app.route("/sip-hook", methods=["POST"])
def sip_hook() -> Response:
event = client.webhooks.unwrap(request.data, request.headers)
event_payload = event.model_dump()
event_type = getattr(event, "type", None)
if event_type == "realtime.call.incoming":
call_id = event.data.call_id
metadata = call_metadata.setdefault(call_id, {})
_resolve_user(call_id, event, metadata)
accept_call(call_id, metadata)
start_runner(call_id, metadata)
return Response(status=200)
if event_type == "realtime.call.ended":
call_id = event.data.call_id
stop_runner(call_id)
call_metadata.pop(call_id, None)
return Response(status=200)
return Response(status=200)
После того как пользователь определён, сервис загружает его историю диалога из dialogue_history и формирует инструкции для голосового агента. Затем отправляется запрос accept в Realtime API с настройками модели и аудио. Для каждого звонка в отдельном потоке запускается Realtime‑раннер, который ведёт диалог, принимает реплики пользователя, отвечает голосом и по мере поступления событий истории записывает текст или транскрипт в базу данных. Дополнительно раннер логирует статистику использования токенов, что помогает анализировать нагрузку и стоимость.
Подробности по настройке окружения и интеграций приведены в разделе о конфигурации. Отдельные страницы описывают запуск и устройство Realtime‑раннера, обработку истории диалога и работу webhook‑маршрутов Flask‑приложения.