Меня зовут Ильдар, я техлид в команде Центра развития финансовых технологий (ЦРФТ) Россельхозбанка. Сегодня расскажу о том, как мы внедрили функцию аудиозвонков в наш корпоративный мессенджер для сотрудников.

Немного о проекте

Мы делаем приложение для сотрудников группы РСХБ, которое позволяет получить доступ к популярным корпоративным функциям: новостной ленте, кадровым сервисам, рабочему календарю, справочнику сотрудников и еще многим другим полезным вещам, которые делают рутинные процессы проще и быстрее. Так, например, мессенджер позволяет сотрудникам осуществлять быструю коммуникацию без использования внешних мессенджеров (WhatsApp и Telegram). Сразу отмечу, что это разработка на основе OpenSource‑решения, а именно сервера Synapse от Matirx.

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

..конечно, можем!

У нас уже был наработки в этом направлении: наша команда flutter‑разработки уже проводила проверку гипотезы и нам надо было только всё соединить воедино. Но вот вопрос, с чего стоит начать?...

...конечно, со встречи!

С которой, к слову, мы ушли вот с такой картинкой:

Как это работает?

Описание работы:

  1. Когда пользователь хочет совершить звонок - он переходит в чат с тем, с кем хочет связаться.

  2. В чате он нажимает на кнопку вызова абонента.

  3. После чего отправляется запрос в матрикс на получения данных о сервере-ретрансляторе (стрелка 1).

  4. Матрикс возвращает мобильному приложению (МП) исходящего абонента информацию для подключения к серверу-ретранслятору.

  5. МП исходящего абонента отправляет запрос в Matrix на отправку в чат с вызываемого абонента служебного сообщения (не отображается пользователю) с информацией по подключению к серверу ретранслятору (стрелка 2).

  6. МП звонящего абонента отправляет запрос в Backend приложения для отправки в МП вызываемого абонента silent push-уведомления с информацией, в каком чате находятся данные для подключения к звонку (стрелка 3).

  7. Backend приложения «Цифровой офис сотрудника» отправляет silent push-уведомление в МП вызываемого абонента (стрелка 4).

  8. МП вызываемого абонента получает push-уведомление, забирает информацию из служебного сообщения чата и отображает пользователю звонок (стрелка 5).

  9. Если МП вызываемого абонента отвечает на звонок, то переходим к пункту 10. В противном случае звонок отклоняется.

  10. МП вызываемого и МП звонящего абонента обращаются к серверу-ретранслятору для получения информации об установлении соединения (стрелка 6).

  11. Матрикс отправляет сообщение в чат с вызываемым абонентом.

На фронте мобильного приложения есть flutter-библиотека для работы с Matrix, где также перечислены методы для осуществления функции аудиозвонков, поэтому тут мы использовали существующий пакет, и немного посмотрели реализацию в других проектах ????

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

О вариантах и развилках

Первым решением было использовать обычные push-уведомления, когда мы хотим сигнализировать пользователю, что ему пришло сообщение в чат.

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

После долгих экспериментов решили проверить идею с использованием silent push уведомлений. Они приходят на устройство, даже если приложение свёрнуто или закрыто, и запускают обработчик, в котором мы инициализируем мессенджер. Далее пользователь получает сообщение, и отображается экран с вызовом (со звуковым и вибро-сопровождением). Это решение мы не встречали на просторах Интернета, и, кажется, оно может помочь тем, кто реализует у себя функционал с похожими механизмами. Конечно, чтобы не раздражать пользователей, мы разработали на своей стороне возможность отключения уведомлений о звонках, но уже внутри нашего приложения, а не с помощью ОС.

Ещё одна потребность, с которой мы столкнулись, заключалась в передаче информации о звонке сразу в push-уведомлении, чтобы не надо было лезть в чат за дополнительной информацией. Это позволило бы сразу отвечать на звонок. Но в этом случае у нас не оставалась информация о статусе звонка: состоялся, отклонён, завершён.

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

Как мы это сделали?

Реализация звонка по умолчанию представляет собой обмен IP-адресами, по которым два абонента могут связаться с друг с другом. И, учитывая, что IP-адреса имеют серую адресацию и находятся за NAT-ом, такое взаимодействие возможно только внутри сети.

В Интернете любому абоненту так не позвонишь, поэтому для нахождения абонентов, которые находятся за NAT-ом, используются STUN-сервера. Они позволяют серверу внутри сети определить свой внешний адрес, способ трансляции адреса и порта во внешней сети. И уже этими данными на этапе получения информации о себе обмениваются абоненты.

Понятно, что для того, чтобы получать информацию об узлах в интернете необходимо самому там находится, т.е. иметь белый IP и не скрываться за NAT-ом. Для обхождения этого ограничения есть TURN-сервера (сервер-ретранслятор), который позволяет в любой топологии сети двум устройствам найти друг друга. Но при этом все пакеты проходят через TURN-сервер (в отличии от STUN, где сервер только сообщает абонентам адреса друг друга), и на него идёт большая нагрузка.

Мы остановились на этом варианте.

В качестве сервера-ретранслятора мы использовали coturn-сервер, а рекомендации по его настройки взяли из документации сервера Synapse, как и рекомендации по настройке самого Synapse.
Особенность работы TURN-сервера и сервера Synapse - это обмен секретами, где обе системы должны иметь одинаковый секрет (turn_shared_secret), по которому TURN-сервер определяет, что абоненты пришли от конкретного сервера Matrix. Т.к. взаимодействие абонентов идёт по UDP, и соединений может быть несколько, то необходимо открыть достаточно большое количество портов на TURN-сервере (мы открыли 10.000).

Работа с Matrix

Какие методы мы использовали при создании аудиозвонков:

  • /_matrix/client/v3/voip/turnServer - получаем информацию о turn-сервере, информацию о котором указали в настройках Synapse. Этот метод вызывается у абонента, который начинает звонок и у вызываемого абонента, когда он получает информацию о входящем звонке и принимает вызов.

Пример
GET https://host/_matrix/client/r0/voip/turnServer
RESPONSE
{
"username": "1661934991@user11",
"password": "v5p2MuHkCsapZYsNJWblUJN2nps=",
"ttl": 3600,
"uris": [
"turn:turn-server-host:5349?transport=udp",
"turn:turn-server-host:5349?transport=tcp"
]
}

  • m.call.invite - сообщение, которое мы шлём вызываемому абоненту (event в чат (room) с вызываемым абонентом), передаёт id-звонка, информацию о чате, и о том, кто совершает вызов.

Пример
PUT https://host/_matrix/client/r0/rooms/!rbjspIjsgOZDMsYzqH%3A/send/m.call.invite/txid1661931419706
REQUEST
{
"call_id": "cid1661931419390",
"room_id": "!rbjspIjsgOZDMsYzqH",
"party_id": "76950f68be5c2d42",
"version": "1",
"lifetime": 10000,
"offer": {
"sdp": "v=0\r\no=- 5446971621540926831 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=.......",
"type": "offer"
},
"caller_id": @user11",
"caller_name": "Тестеров",
"invitee_id": @user22",
"capabilities": {
"m.call.transferee": false,
"m.call.dtmf": false
},
"org.matrix.msc3077.sdp_stream_metadata": {
"25b69ef3-efca-473a-b3c4-13b07cfe4daa": {
"purpose": "m.usermedia",
"audio_muted": false,
"video_muted": true
}
}
}
RESPONSE
{
  ""event_id"": ""$cxGVWhpScYFtwKSTRjnAZe7_R-eoX2DXIze2qzMezAg""
}

  • m.call.answer - ответное сообщение, которое также отправляется в чат (room) с тем абонентом с которым начинается аудиозвонок.

Пример
PUT https://host/_matrix/client/r0/rooms/!rbjspIjsgOZDMsYzqH%3A/send/m.call.answer/txid1661931423825
REQUEST
{
"call_id": "cid1661931419390",
"room_id": "!rbjspIjsgOZDMsYzqH",
"party_id": "98668b2feb14c0d5",
"version": "1",
"answer": {
"sdp": "v=0\r\no=- 2841543015153184625 2 IN IP4 127.0.0.1....",
"type": "answer"
},
"capabilities": {
"m.call.transferee": false,
"m.call.dtmf": false
},
"org.matrix.msc3077.sdp_stream_metadata": {
"6d082aa5-7e23-44d0-bb74-5c9aceae2bae": {
"purpose": "m.usermedia",
"audio_muted": false,
"video_muted": true
}
}
}
RESPONSE
{
"event_id": "$sVH6wSKFg4bFUe1PWzb0NunSbX04fuFuayjqq5LMec8"
}

  • m.call.select_answer - выбор сообщения в котором содержится подтверждение о начале звонка.

Пример
PUT https://host/_matrix/client/r0/rooms/!rbjspIjsgOZDMsYzqH%3A/send/m.call.select_answer/txid1661931422309
REQUEST
{
"call_id": "cid1661931419390",
"room_id": "!rbjspIjsgOZDMsYzqH",
"party_id": "76950f68be5c2d42",
"version": "1",
"lifetime": 10000,
"selected_party_id": "98668b2feb14c0d5"
}
RESPONSE
{
"event_id": "$dQGojd7Iln2D4lijnnaOKqysLGIiWmhIc6icrkmyAMc"
}

  • m.call.hangup - завершить звонок, сообщение в чат (room) для прекращения звонка.

Пример
PUT https://host/_matrix/client/r0/rooms/!rbjspIjsgOZDMsYzqH%3A/send/m.call.hangup/txid1661931433211
REQUSET
{
"call_id": "cid1661931419390",
"room_id": "!rbjspIjsgOZDMsYzqH",
"party_id": "98668b2feb14c0d5",
"version": "1",
"reason": "user_hangup"
}
RESPONSE
{
"event_id": "$tQ8CcvfNYBuzIgoDl1tIMKNDOnFGV02Eyzcws9WM8Aw"
}

  • m.call.candidates - обмен ICE-кандидатов для установки соединения, которые отправляют друг другу оба вызывающих абонента. Отправляется при старте звонка, а также при изменении состояния абонентов, например, при переключении на другую сеть (с wi-fi на мобильную связь)

Пример
PUT https://host/_matrix/client/r0/rooms/!rbjspIjsgOZDMsYzqH%3A/send/m.call.candidates/txid1661931427153
REQUEST
{
"call_id": "cid1661931419390",
"room_id": "!rbjspIjsgOZDMsYzqH",
"party_id": "98668b2feb14c0d5",
"version": "1",
"candidates": [{
"candidate": "candidate:917381266 1 udp 2122260223 100.88.179.67 49140 typ host generation 0 ufrag avZP network-id 3 network-cost 900",
"sdpMid": "0",
"sdpMLineIndex": 0
}, {
"candidate": "candidate:559267639 1 udp 2122202367 ::1 41729 typ host generation 0 ufrag avZP network-id 2",
"sdpMid": "0",
"sdpMLineIndex": 0
}, {
"candidate": "candidate:1510613869 1 udp 2122129151 127.0.0.1 48274 typ host generation 0 ufrag avZP network-id 1",
"sdpMid": "0",
"sdpMLineIndex": 0
}, {
"candidate": "candidate:842163049 1 udp 1686052607 185.211.159.23 18090 typ srflx raddr 100.88.179.67 rport 49140 generation 0 ufrag avZP network-id 3 network-cost 900",
"sdpMid": "0",
"sdpMLineIndex": 0
}, {
"candidate": "candidate:1876313031 1 tcp 1518222591 ::1 44125 typ host tcptype passive generation 0 ufrag avZP network-id 2",
"sdpMid": "0",
"sdpMLineIndex": 0
}, {
"candidate": "candidate:344579997 1 tcp 1518149375 127.0.0.1 38925 typ host tcptype passive generation 0 ufrag avZP network-id 1",
"sdpMid": "0",
"sdpMLineIndex": 0
}, {
"candidate": "candidate:4259704537 1 udp 41885695 178.57.74.4 63897 typ relay raddr 185.211.159.23 rport 18090 generation 0 ufrag avZP network-id 3 network-cost 900",
"sdpMid": "0",
"sdpMLineIndex": 0
}, {
"candidate": "candidate:3009810985 1 udp 25108223 178.57.74.4 64242 typ relay raddr 185.211.159.23 rport 27509 generation 0 ufrag avZP network-id 3 network-cost 900",
"sdpMid": "0",
"sdpMLineIndex": 0
}
]
}
RESPONSE
{
"event_id": "$tj7eYkhbVGKUTwAraxo_jNklwktKOSaxnDKpvJZBtcA"
}

Конечно, мы не вызывали методы Матрикс напрямую, а использовали библиотеку, которую я упоминал выше: matrix_api_lite. Но если вы пишете не на flutter, то можно использовать сразу методы API Matrix.

Работа с WebRTC

Для взаимодействия по WebRTC мы также использовали библиотеку для flutter. Для создания звонка сперва необходим объект соединения RTCPeerConnection, который осуществит связь между устройствами.

Он содержит в себе контент (track), которым обмениваются пользователи (голос, видео) и ICE-кандидатов - адреса через которые можно передать этот контент, например, ip-адрес и порт, первоначально ICE-кандидатами мы обмениваемся через сервер Matrix.

Поэтому в объекте RTCPeerConnection мы переопределяли следующие методы:

  • onIceCandidate - срабатывает при появлении нового ICE-кандидата в RTCPeerConnection. Соответвенно другая сторона перед этим добавляет кандидатов peerConnection?.addCandidate(candidate)

  • onIceGatheringState -срабатывает, когда у ICE-кандидата меняется состояние, как только оно в статусе ready - добавляем в список кандидатов

  • onIceConnectionState - срабатывает при изменении состояния соединения, как только статус connected - фиксируем флажок, что соединение установлено

  • onTrack - срабатывает при добавлении нового контента в RTCPeerConnection

Далее мы создаём описание предложения (RTCSessionDescription) к вызываемому абоненту с помощью метода createOffer объекта RTCPeerConnection.

RTCSessionDescription description = await _peerConnection?.createOffer({});
_peerConnection?.setLocalDescription(description);

Когда мы соответственно хотим ответить мы также отправляем в другую сторону свое описание ответа, но только уже для удаленного абонента (remote):

RTCSessionDescription description = await _peerConnection?.createAnswer({});
_peerConnection?.setRemoteDescription(description);

Далее для добавления нового контента на передачу вызываемому абоненту мы используем метод addTrack, что вызывает у вызываемого абонента обработчик onTrack, где stream - это контент полученный от устройства абонента:

for (final track in stream.getTracks()) {
await peerConnection!.addTrack(track, stream);
}

Это первые методы, которые необходимо применить для старта звонка.

Теперь необходимо добавить слушателей чата абонентов и как только в чате появится сообщение с приглашением - мы инициализируем соответствующий description, получаем ICE-кандидатов для соединения и добавляем свой поток в peerConnection и забираем поток из RemoteStream.

Чтобы узнать побольше и посмотреть детальнее примеры по работе с библиотекой для flutter, советую почитать тут, тут и ещё вот тут.

Напоследок

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

Цель статьи - дать общее представление о том, как это работает и что это вообще можно реализовать. К слову сказать, можно на этих же принципах стартовать видеозвонки, но это наши планы на будущее.

Ещё пару полезных ссылок:

Коллекция Postman для запросов к Матрикс-серверам

Видео о том как оразнивана ip-телефония

Рассказ про звонки от ребят из VK

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


  1. Ivnika
    00.00.0000 00:00
    +5

    Понимаю что вопрос скорее не к вам, но — зачем вы делали и чат и звонки? Можете привести статистику использования?
    Поясню — не первую компанию встречаю, которая в том или ином виде делает внутренний чат со звонками и… никто этим не пользуется (только если все другое запретить прям совсем — совсем). В итоге выброшенные деньги на разработку, с трудом функционирующий софт, недовольные сотрудники — расскажите о профите от всего этого?!


    1. csharpreader
      00.00.0000 00:00
      +1

      Пользу своего мессенджера для крупного корпората представить легко (Слак ушёл, Маттермост не вывозит по многим показателям), но про звонки я присоединяюсь к вашему вопросу: паркуа́?

      Упомянутая в посте «очевидность» не очевидна совершенно.


      1. idrats
        00.00.0000 00:00
        +1

        Когда команда распределенная, часто возникает необходимость созвона с коллегами. Иметь резервный канал связи для банка - только этого достаточно для того, чтобы повысить приоритет у задачки


    1. zildarius Автор
      00.00.0000 00:00

      Сейчас многие перешли на формат удалёнки, гибрида, и не все коллеги указывают свои номера телефонов для связи. Мы сделали удобную возможность поговорить голосом.


    1. evgen609
      00.00.0000 00:00

      Я конечно не автор, но могу предположить что иногда важна конфиденциальность.


      1. serzius
        00.00.0000 00:00
        +1

        Скорее наоборот: фиксация и анализ записанных разговоров сотрудников.


        1. evgen609
          00.00.0000 00:00

          Имею ввиду конфиденциальность по отношению к владельцем внешних сервисов. Внутри компании конечно все доступно будет кому нужно.


  1. PackRuble
    00.00.0000 00:00
    -1

    Спасибо за статью, на ру-пространстве кот наплакал материалов на данную тематику. Хотелось бы конечно узнать больше подробностей реализации dart-кода (из раздела 'напоследок'). По итогу, в платформенный код пришлось лезть, или библиотеки покрывают?


    1. idrats
      00.00.0000 00:00
      +4

      Дьявол как обычно кроется в деталях. 99.9% покрыто чисто дарт-кодом, но в нейтивную часть для ряда нюансов сходить пришлось, именно для донастройки голосовой телефонии. Ну и в эти самые библиотеки немного поконтрибьютили, чтобы поправить ошибки на стороне пакетов