В этой статье речь пойдет об анатомии отдельного вызова. Что происходит, когда вызов поступает в систему, как система перенаправляет вызов, почему появляются сегменты A и B, как они соединяются и какие данные необходимо сохранить в бэкенде.
Я использую Jambonz как конкретный пример CPaaS/SIP application layer. В отдельной статье о Jambonz и Voice AI я разбирал более широкий путь: PSTN/SIP trunk -> Jambonz -> backend -> AI/LLM. Здесь фокус уже другой: как backend моделирует один production-звонок.
Коротко о терминах: SIP (Session Initiation Protocol) — это signaling protocol, который помогает telephony layer установить, изменить и завершить звонок: кто кому звонит, куда направить call, когда сторона ответила или завершила сеанс. CPaaS (Communications Platform as a Service) — это слой, который превращает телефонию в API: принимает inbound calls, вызывает backend webhooks и выполняет команды вроде
say,gather,dialиhangup. Jambonz — пример такой voice platform.
Представим внутреннюю телефонию продукта
На публичный номер приходит входящий звонок — inbound call. Backend получает его через telephony layer и может выполнить несколько действий до соединения с человеком: проиграть IVR, попросить нажать цифру, послушать короткую фразу, проанализировать ее с помощью AI или просто выбрать маршрут по номеру и настройкам продукта.
После этого backend инициирует второй звонок — outbound call: например оператору, PBX, SIP endpoint или внешнему номеру. Когда вторая сторона отвечает, telephony layer соединяет inbound и outbound стороны в один разговор.
На уровне продукта мы хотим вести учет таких звонков: что сейчас активно, кто звонит, куда мы перенаправляем звонок, был ли ответ, сколько длился разговор и почему он завершился.
Важный момент: для пользователя это один разговор, а для backend — минимум два звонка. Первый входит в нашу систему. Второй создается нашей системой, когда мы звоним дальше выбранному destination.
Поэтому сначала нужно разделить inbound call и outbound call, а уже потом говорить про Leg A, Leg B, bridge и модель состояния.
Inbound call
Inbound call — это звонок, который входит в вашу систему:
caller -> public phone number -> SIP trunk / carrier -> Jambonz / CPaaS -> backend
Для caller это просто звонок на номер компании. Для backend это момент, когда нужно создать локальную запись о звонке: Jambonz отправляет webhook на наш backend о том, что сейчас инициируется звонок, backend создает новую запись, например в таблице active_calls, и присваивает ей уникальный activeCallId внутри нашей системы.
На этом этапе backend еще не обязан знать, кто ответит. Он знает только caller, dialed number, выбранный phone config и следующие действия для provider.
Эта первая часть звонка обычно становится Leg A.
Outbound call
Outbound call — это второй звонок, который система делает к выбранному destination после решения о маршруте.
Например, backend посмотрел на настройки номера, IVR input или результат короткой AI-классификации, выбрал destination и попросил Jambonz позвонить дальше: оператору, SIP endpoint, PBX или внешнему номеру.
Outbound leg может:
-
начать ringing;
-
получить busy;
-
получить no answer;
-
быть answered;
-
быть transferred;
-
завершиться раньше inbound leg;
-
иметь отдельный provider id.
Поэтому его нельзя записать как «тот же call стал connected»: у outbound leg должен быть свой lifecycle.
Эта вторая часть звонка обычно становится Leg B.
Leg A и Leg B
Теперь можно дать этим двум частям короткие имена:
Caller -- Leg A --> Platform -- Leg B --> Destination
Эту схему можно читать так: caller уже соединен с нашей platform через Leg A, а platform создает Leg B, чтобы дозвониться до выбранного destination.
Leg A обычно описывает входящую сторону: external caller -> platform.
Leg B обычно описывает исходящую сторону: platform -> operator / SIP endpoint / PBX / external number.
Эти legs нужно хранить отдельно, потому что backend может управлять ими отдельно. У каждой leg могут быть свои:
-
provider call id;
-
direction;
-
start time;
-
answer time;
-
end time;
-
status events;
-
failure reason.
На практике это может быть одна запись activeCall, но внутри нее должны быть отдельные поля для Leg A и Leg B. Например, у Leg B не будет answerTime, пока вторая сторона еще не ответила.
Bridge
Bridge — это момент, когда Leg A и Leg B соединяются так, что стороны слышат друг друга.
Упрощенно:
Leg A audio <-> Leg B audio
До bridge caller может слышать greeting, hold music, IVR или ringing.
После bridge caller и destination разговаривают друг с другом.
В базовом сценарии bridge появляется после того, как outbound сторона ответила и telephony layer соединяет Leg A с Leg B.
Bridge не означает, что звонок только что начался. Звонок уже начался раньше, когда появился inbound Leg A.
Call control
Programmable telephony отличается от обычной телефонии тем, что backend может управлять звонком через call-control commands.
Типичные действия на этом уровне:
-
say— проиграть текст или аудио; -
gather— собрать ввод от caller; -
dial— позвонить на destination и после ответа соединить стороны; -
transfer— перевести call; -
hangup— завершить; -
config— настроить поведение звонка, например параметры записи или speech settings.
В Jambonz это выражается через JSON verbs, которые backend возвращает прямо в webhook response.
Упрощенно это можно читать как такой диалог:
Jambonz: "Incoming call happened. What should I do?"Backend: "Say greeting, then dial this target."Jambonz: "Done. Here are status events."
Тот же flow можно записать более технически:
Jambonz -> Backend: call-initiate webhookBackend -> Backend: create activeCallId and active_calls rowBackend -> Jambonz: JSON verbs: say / dial
В типичном Jambonz flow это выглядит примерно так:
app.post('/call-initiate-webhook', async (context) => { // Jambonz вызывает этот webhook, когда inbound call только начинается. const event = context.req.valid('json') // Backend создает свою локальную запись активного звонка. const activeCallId = generateNanoId() await activeCallService.createActiveCall({ id: activeCallId, phoneNumberConfigId: numberConfig.id, legACallId: event.call_sid, legADirection: 'inbound', legAStartTime: new Date().toISOString() }, CallType.legA) // Backend возвращает Jambonz список verbs, которые нужно выполнить дальше. const verbs = [ { verb: 'say', text: 'Thanks for calling. Please hold while we connect you.' }, { verb: 'dial', actionHook: '/jambonz/dial-end-webhook', callerId: event.from, target: [ numberConfig.routeToResourceType === 'sip' ? { type: 'sip', sipUri: numberConfig.routeToResource } : { type: 'phone', number: numberConfig.routeToResource } ] } ] return context.json(verbs, 200)})
В production-коде такой JSON обычно собирают через небольшие helpers: например say() добавляет say verb, а addDialVerbs() добавляет dial verb с нужным target. Это не меняет модель: backend не передает голос сам. Он принимает событие, обновляет состояние звонка и отвечает Jambonz JSON-инструкциями, что делать дальше.
Start time, answer time, end time
Одна из главных ошибок в call modeling — смешать timestamps.
Нужны минимум три разных понятия:
startTime = leg/call attempt existsanswerTime = leg was answeredendTime = leg/call ended
Для inbound Leg A:
legAStartTime = provider delivered incoming calllegAAnswerTime = platform/application answered the inbound leg, if trackedlegAEndTime = caller side ended or call was completed
Для outbound Leg B:
legBStartTime = platform started dialing destinationlegBAnswerTime = destination endpoint/system answeredlegBEndTime = destination leg ended
Почему это важно?
Если startTime принять за answerTime, вы будете считать звонки отвеченными, когда они только начали ringing.
Если answerTime поставить слишком рано, missed call станет answered call.
Если endTime поставить только на общую запись звонка, но не на legs, вы не поймете, какая сторона завершила call.
Правильная модель должна позволять ответить:
-
сколько caller ждал;
-
был ли destination answered;
-
сколько длился разговор;
-
кто завершил звонок;
-
был ли outbound leg вообще создан;
-
был ли call abandoned before answer.
Provider IDs для Leg A и Leg B
Leg A и Leg B — это реальные звонки в telephony layer. Когда CPaaS создает каждый из них, у него появляется свой provider id. В Jambonz это call_sid; у другого CPaaS название может быть другим.
Backend должен сохранить эти ids отдельно:
activeCallId = id внутри вашего продуктаlegACallId = provider id для inbound leglegBCallId = provider id для outbound leg
activeCallId нужен UI, базе и истории звонков.
legACallId и legBCallId нужны, чтобы обращаться к конкретному звонку в telephony layer:
-
hangup;
-
redirect;
-
fetch status;
-
correlate status webhook.
Например, если нужно завершить outbound side, backend должен знать именно legBCallId. Если вместо этого хранить один абстрактный callId, быстро становится непонятно, что это за id: локальная запись, inbound leg или outbound leg.
Backend-модель на практике
В реальном продукте модель часто начинается с одной записи активного звонка. Это не универсальная production schema, а упрощенная форма для понимания anatomy одного обычного звонка:
type ActiveCall = { id: string phoneNumberConfigId: number currentStatus: 'incoming' | 'routing' | 'ringing' | 'connected' | 'completed' | 'failed' status: 'active' | 'completed' | 'missed' | 'failed' fromPhoneNumber: string toPhoneNumber: string transferTo?: string | null legACallId: string legAStartTime: string legAAnswerTime?: string | null legAEndTime?: string | null legADirection: 'inbound' legBCallId?: string | null legBStartTime?: string | null legBAnswerTime?: string | null legBEndTime?: string | null legBDirection?: 'outbound' | null createdAt: string updatedAt: string}
Сам phoneNumberConfig обычно живет отдельно. Это настройки публичного номера: какой номер принимает звонок, куда его перенаправлять и какие дополнительные шаги включены.
type PhoneNumberConfig = { id: number destinationNumber: string routeToResource: string routeToResourceType: 'phone' | 'sip' | 'user' configuration: { customGreeting?: string recordCalls?: boolean skipGreetingAtStartOfCall?: boolean useAiAgent?: boolean }}
Такая форма честно показывает практику production-системы: даже в одной таблице звонок приходится хранить как отдельные группы полей для Leg A и Leg B.
Позже такую модель можно расширять: добавить, кто взял звонок, в какую очередь он попал, был ли пройден IVR, был ли подключен AI-agent, какой была причина завершения. Но базовая anatomy остается той же: одна локальная запись звонка и отдельные поля для inbound/outbound legs.
После завершения active_calls может переехать в active_calls_history, чтобы UI и аналитика могли искать уже завершенные звонки.
Простая end-to-end схема
Соберем все вместе.
В этой схеме важна не каждая деталь status events, а разделение ответственности. Jambonz принимает звонок и выполняет verbs. Backend создает локальную запись, выбирает маршрут и хранит состояние. Destination — это то, куда мы звоним вторым leg: оператор, SIP endpoint, PBX или внешний номер.
Вывод
Пользователь воспринимает звонок как один непрерывный разговор. Backend не может позволить себе такую модель.
Для backend звонок начинается как inbound call, затем может породить outbound call, а разговор появляется только после bridge между Leg A и Leg B. У каждой стороны есть свои timestamps, provider id и lifecycle, и именно это нужно отразить в модели состояния.
Практичная backend-модель начинается с простой идеи: хранить один локальный activeCallId, но не смешивать inbound и outbound стороны внутри него. Даже если в базе это одна строка active_calls, внутри нее должны быть отдельные поля для Leg A и Leg B.
Это и есть базовая anatomy production-звонка: входящий звонок, исходящий звонок, bridge и честная запись состояния. Все следующие темы — queues, claim flow, realtime UI, supervisor view, Voice AI и reconciliation — строятся поверх этой модели.
Источники
-
Связанная статья: Voice AI Systems Powered by Jambonz.
-
Jambonz docs: Verbs overview, New Call webhook, Dial verb, Config verb, call:status websocket event.
-
IETF RFCs: RFC 3261 — SIP.
ссылка на оригинал статьи https://habr.com/ru/articles/1050446/