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

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) → запуск голосового агента → запись истории в БД → завершение звонка и остановка раннера.

На последовательной диаграмме этот поток можно представить так:

uml diagram

Роль 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‑приложения.