Привет! Меня зовут Андрей Нелюбин, я руководитель функции фронтенд‑разработки. В рамках проекта для заказчика требовалось доработать сценарий работы колл‑центра: связать телефонию на Asterisk с операторским интерфейсом, где рядом со звонком доступны данные клиента, заказы, статусы и действия управления.

Для этого в 2022 году была реализована собственная прослойка поверх Asterisk: backend на Python/Django, обработка событий через Django Channels, WebSocket для передачи изменений в интерфейс и frontend на Vue. В статье расскажу, как устроили архитектуру решения, как обрабатывали события звонков, зачем понадобился BFF‑слой на Django и почему в рабочей конфигурации аудио осталось в SIP‑клиенте, а контекст и управление — в операторском интерфейсе.

От звонка к контексту: с чего началась задача

В 2021 году колл‑центр заказчика работал на Asterisk старой версии. Поддержка такого решения была проблематичной: специалистов, которые умели работать с этой версией, практически не осталось, а обновление требовало значительных ресурсов. Параллельно был запрос на разработку решения, которое можно было бы передать более широкому кругу специалистов, а не держать поддержку на узком наборе людей.

Отдельная сложность была связана с повседневной работой оператора. Когда поступал звонок по номеру пользователя, он приходил в отдельный сервис в 1С. Чтобы собрать информацию по обращению, оператору нужно было: зайти в админку, найти пользователя, найти его заказ.

При этом данные находились в разных местах. Такие переключения занимали время: пока оператор собирал информацию по клиенту и заказу, звонок уже шёл.

В рамках требований проекта при входящем звонке в интерфейсе нужно было отобразить:

  • уведомление о звонке с действиями «принять» / «отклонить»;

  • информацию о пользователе;

  • последний заказ;

  • связанные SMS, заказы и другие данные;

  • действия со звонком: завершить, создать новый звонок, поставить на удержание, переадресовать.

В этом же интерфейсе оператор должен был управлять рабочим статусом: уходить на паузу, переходить в онлайн или офлайн. Дополнительно учитывались ограничения рабочей среды: 1С сильно тормозила, а возможностей гибко доработать пользовательский сценарий почти не было — не хватало кастомизации.

Поэтому требовалось собрать операторский интерфейс, где рядом со звонком доступны данные клиента, информация по заказам, рабочие статусы и действия управления звонком.

Выбор подхода: готовое решение или собственная реализация

В проекте рассматривались два технических подхода: готовое решение для колл‑центра или собственная прослойка поверх Asterisk.

Вариант «писать своё» был привлекательным, поскольку его можно было максимально адаптировать под согласованные сценарии работы операторов. У собственного решения нет жёсткого потолка по функциональности: если нужно действие со звонком, статусами или логикой распределения, его можно реализовать на стороне сервиса. Туда же относится свобода в выборе стека: команда не была привязана к конкретным технологиям и могла использовать стек, который проще развивать и поддерживать в рамках проекта.

Но у этого пути была очевидная цена. Собственная реализация — это время и ресурсы на проектирование, разработку, отладку и последующую поддержку.

Коробочное решение на бумаге выглядело проще и могло ускорить запуск, но почти сразу появлялись ограничения. Всё, что выходит за рамки базового сценария, обычно превращается в дополнительные платные компоненты или зависит от доступных возможностей кастомизации. Даже если продукт закрывает 80% потребностей, оставшиеся 20% могут быть именно тем, что критично для операторского UX.

С учётом этих ограничений была реализована собственная прослойка поверх Asterisk.

Техническая реализация

Собственную прослойку начали реализовывать на Python и Django. В команде уже была экспертиза по этим технологиям, поэтому их можно было быстро применить в проекте.

При этом для телефонии был нужен асинхронный сервер, поскольку звонки — это поток событий в реальном времени. Под эту задачу взяли Django Channels: он закрывает сценарий, когда нужно принимать события и передавать их в интерфейс без постоянного опроса.

На фронтенде выбрали Vue, чтобы собрать операторский интерфейс, в котором звонок, данные клиента, заказы и связанные обращения находятся в одном месте.

Дальше конструкция выглядела так: Asterisk остаётся ядром телефонии, а слой на Django работает посредником между ним и операторским интерфейсом. Он получает события от Asterisk, обрабатывает их и передаёт данные оператору, а действия из интерфейса переводит в запросы к Asterisk.

С технической стороны Asterisk давал две опоры.

Первая — асинхронные события. Asterisk отправляет WebSocket‑ивенты при действиях со звонком и оператором: входящий, исходящий, ответ, hold, unhold — по сути всё, что происходит в жизни звонка. Для интерфейса это было критично: оператор должен был видеть, что звонок пришёл, принят, поставлен на удержание, возвращён и так далее.

Вторая — REST‑интерфейс управления. Через REST можно выполнять действия со звонком и оператором: получать текущие звонки, отвечать на звонок, запускать IVR и так далее.

Отдельно рассматривали готовый клиент для работы с Asterisk REST Interface — ari‑py. Но у этого варианта были ограничения: клиент давно не поддерживается и работает только с Python 2. Поэтому была реализована собственная обвязка для работы с ARI.

Важно, что операторский интерфейс не обращается напрямую к Asterisk. Между ними находится BFF‑слой на Django: он принимает события, содержит прикладную логику и вызывает нужные операции через REST‑интерфейс Asterisk.

В итоге собрали такую конструкцию:

  • Asterisk отдаёт события о том, что происходит со звонками и операторами;

  • сервис на Django Channels принимает эти события, обрабатывает их и передаёт данные в операторский интерфейс;

  • когда оператор выполняет действие в интерфейсе, сервис вызывает нужные операции через REST‑интерфейс Asterisk.

Так звонок перестаёт быть отдельной «телефонной частью» и становится элементом операторского интерфейса — с управлением и данными рядом.

На стороне сервиса работает Django Channels: он принимает и обрабатывает ивенты от Asterisk. Дальше всё устроено довольно прямолинейно: сервис смотрит на тип события и передаёт его в соответствующий обработчик, который выполняет нужное действие.

Например, по ивенту входящего звонка запускается распределение между свободными операторами. При этом логика распределения не была зашита как единственно возможная: её можно адаптировать под нужный сценарий. В базовом варианте, если один оператор не ответил на звонок, сервис передаёт его следующему.

То же самое работает и для остальных событий: каждый ивент попадает в свой обработчик, а оператор дальше управляет звонком через интерфейс.

Пример обработки событий

Ниже — упрощённый пример того, как сервис подключается к Asterisk Stasis и начинает слушать ивенты.

Старт сервера выглядит так:

loop = asyncio.get_event_loop()
loop.run_until_complete(serve())

Основная логика находится в функции serve: она устанавливает WebSocket‑соединение с Asterisk, получает события и передаёт их в обработчик.

async def serve():
   url = '{protocol}://{host}:{port}/ari/events?app={app}'.format(
       protocol=settings.ASTERISK_WS_PROTOCOL,
       host=settings.ASTERISK_HOST,
       port=settings.ASTERISK_PORT,
       app=settings.ASTERISK_STASIS_APP,
   )

   auth_hash = base64.b64encode(bytes(f'{settings.ASTERISK_USER}:{settings.ASTERISK_PASSWORD}', 'utf8')).decode(
       'ascii',
   )

   connection_options = dict(extra_headers={'Authorization': f'Basic {auth_hash}'})

   if settings.ASTERISK_WS_PROTOCOL == 'wss':
       connection_options.update(dict(ssl=ssl.SSLContext()))

   async with websockets.connect(
       url,
       **connection_options,
   ) as ws:
       while True:
           string_data = await ws.recv()
           parsed_data = await sync_to_async(json.loads)(string_data)

           if parsed_data['type'] not in [
               'StasisStart',
               'StasisEnd',
               'ChannelHold',
               'ChannelUnhold',
               'Dial',
               'ChannelDestroyed',
               'ChannelDtmfReceived',
               'ChannelEnteredBridge',
               'PlaybackFinished',
               'RecordingFinished',
           ]:
               continue
           try:
               await handle_stasis_event(parsed_data)
           except Exception as e:
               # обработка ошибок

Функция обработки Stasis‑ивентов.

async def handle_stasis_event(data):
   event_type = data['type']
   try:
       event_route = importlib.import_module(f'core.stasis.event_router.events.{event_type}')
       await event_route.handle(data)
   except ImportError:
       raise StasisEventRouterException(f'cannot find listener for event {event_type}')
   except Exception as e:
       raise StasisEventRouterException(f'route {event_type} throws error {e}')

Суть простая: обработчики ивентов лежат внутри core.stasis.event_router.events. Для каждого обработчика есть договорённость: он должен содержать функцию handle, которая принимает входящий ивент и выполняет нужную логику.

Например, так выглядит обработчик входящего ивента StasisStart. Это событие означает начало звонка: звонок поступил от клиента и попал в Stasis‑приложение.

async def handle(data):
   await Call.create_from_channel(
       data['channel'],
       call_type=CallType.INCOMING_STANDARD,
       line_type=OperatorLine.LineTypeChoices.CALLS,
   )

   AsteriskAPI.Channels.answer(data['channel']['id'])
   AsteriskAPI.Channels.moh(data['channel']['id'])

AsteriskAPI — это внутренняя обвязка над requests для запросов в ARI по моделям из документации Asterisk REST Interface. В неё сейчас углубляться не будем.

Call.create_from_channel создаёт в базе объект Call и сохраняет id канала, чтобы дальше с этим звонком можно было выполнять нужные операции.

После этого звонок попадает в обработку роутера — функции, которая распределяет входящие звонки между свободными операторами, подключёнными к ARI. В нашем случае это периодическая Celery‑задача: она отслеживает входящие звонки, учитывает их приоритет и распределяет между свободными операторам по принципу «первый освободившийся».

Главное преимущество такого подхода — гибкость обработки входящих и исходящих звонков. Логику можно адаптировать под нужный сценарий: например, приоритизировать звонки по VIP‑статусу, проигрывать сообщения, обрабатывать DTMF‑сигналы. По сути, вариативность ограничена возможностями самого Asterisk.

Звонок в интерфейсе, аудио — в SIP

В проекте была реализована собственная прослойка поверх Asterisk: серверная часть на Python/Django, интерфейс на Vue, а события по звонкам заведены через WebSocket, чтобы в реальном времени получать всё, что происходит со звонком и оператором.

На уровне оператора идея была простой: управление звонками и рабочими состояниями не должно быть размазано по разным местам. Поэтому в одном интерфейсе были реализованы и действия со звонком, и управление статусами оператора: офлайн, онлайн, готов, пауза.

Параллельно при реализации проверяли браузерный сценарий с использованием WebRTC, но идея с браузером не взлетела. На длинных звонках появлялась заметная задержка звука между оператором и клиентом, поэтому в рабочей конфигурации появилась SIP‑звонилка. Она используется только как инструмент для получения аудиосигнала: при входящем звонке оператору нужно снять трубку, а всё остальное происходит уже в нашем интерфейсе.

Важно отметить, что решение с SIP было принято в 2022 году. Возможно, за это время улучшилось взаимодействие браузера с SIP over WebSocket / WebRTC, и в планах — проверить этот сценарий ещё раз. Всё‑таки удобнее держать один браузерный интерфейс, чем связку из браузера и SIP‑клиента.

Выводы

В статье я рассказал, как в проекте была реализована прослойка поверх Asterisk: она связала телефонию с операторским интерфейсом и сократила лишние переключения в работе операторов.

Asterisk остался ядром телефонии, а прикладная логика была вынесена в отдельный слой: обработка событий, статусы, действия со звонком и связь с данными клиента.

В итоге в рамках проекта звонок стал частью рабочего интерфейса оператора, а дальнейшие изменения можно развивать на стороне собственного слоя, не затрагивая ядро телефонии.


Комментарии (3)


  1. plp-kolyan
    29.07.2026 05:44

    У вас AsteriskAPI  работает на requests, т.е. блокирующий код в корутине. Тут надо либо использовать асинхронные библиотеки для запросов вот так например

    import aiohttp

    class Ari: APP = "auto-calls" PORT = 8088 ARI_URL = f"http://{AST_HOST}:{PORT}" AUTH = (AST_USERNAME, AST_PASSWORD) session = None @classmethod async def getsession(cls): if cls._session: return cls._session """Получить или создать единую сессию""" cls._session = aiohttp.ClientSession( auth=aiohttp.BasicAuth(AST_USERNAME, AST_PASSWORD), ) return cls._session @classmethod @with_session async def channels_answer(cls, session, id): async with session.post( f"{cls.ARI_URL}/ari/channels/{id}/answer", ) as response: if response.status == 204: return { 'success': True, } return {'success': False}


    1. Kreeg Автор
      29.07.2026 05:44

      Да, вы правы, обязательно исправим. Подскажите, вы тоже внедряли у себя подобную систему? Если да, то звук в браузере? Не было ли проблем, как у нас?


      1. plp-kolyan
        29.07.2026 05:44

        Да я сам разработал софтфон на sip.js и vue3 и бек так же на django есть проблема с браузером, но не такая как у вас, хотя возможно природа этих багов одна и та же. У нас начинает после нескольких часов работы тормозить браузер, операторы просто перезагружают страницу и всё. Хотел бы у вас поинтересоваться существует ли спрос на подобного рода разработку (vue3+django+asterisk ari)