- PVSM.RU - https://www.pvsm.ru -

Дозвонились! Как собрать свою Web-звонилку за час

Дозвонились! Как собрать свою Web-звонилку за час - 1

Друзья, сегодня я хочу поговорить с вами про звонки. Для кого-то это совсем новая тема. Для других — чистой воды фан на уровне «а не зафигачить ли мне свой скайп?». Для третьих — внезапно возникшая жизненная необходимость. Последний вариант — наш вариант.

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

Про технологии и протоколы

Дозвонились! Как собрать свою Web-звонилку за час - 2

На дворе 2019 год, и к нашей радости уже есть готовый инструмент для реализации Real-Time Communication (RTC) для веба, а именно WebRTC [1]. Еще несколько лет назад он был в активной разработке. API до сих пор дорабатывается, но технология де-факто стала стандартом и поддерживается в большинстве популярных браузеров. В этой статье на самой технологии мы останавливаться не будем, можно подробнее почитать на сайте разработчиков или поискать статьи на хабре. Например, вот тут [2].

Но прежде, чем мы начнём, хочу прояснить пару моментов.

  1. Во первых, WebRTC работает поверх пачки протоколов, и даже для p2p взаимодействия вам понадобится какой-то сервер, через который ваши клиенты смогут друг друга найти и подружиться. Наш же пример будет использовать SIP протокол, о котором можно почитать подробнее, скажем, тут [3].
  2. Вам понадобится сервер с поддержкой всего вышеперечисленного добра — вроде FreeSwitch или Asterisk.

Эти штуки оставляем за рамками статьи. Будем считать, что вам повезло так же, как нам, и в распоряжении уже имеется настроенная VoIP телефония.

Ну что, самая длинная часть статьи позади, давайте кодить!

Верстаем страничку

Дозвонились! Как собрать свою Web-звонилку за час - 3

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

<div class="container">

        <div class="input-group mb-6">
            <div class="input-group-prepend">
                <span class="input-group-text">Login</span>
            </div>
            <input id="loginText" type="text" class="form-control">

            <div class="input-group-prepend">
                <span class="input-group-text">Password</span>
            </div>
            <input id="passwordText" type="password" class="form-control">

            <button id="loginButton" type="button" class="btn btn-primary" onclick="login()">Login</button>
            <button id="logOutButton" type="button" class="btn btn-primary d-none" onclick="logout()">LogOut</button>

        </div>

        <div class="input-group mb-6 d-none" id="callPanel">
            <input id="callNumberText" type="text" class="form-control" placeholder="Call number">
            <button id="callNumberButton" type="button" class="btn btn-success" onclick="call()">Call</button>
            <button id="hangUpButton" type="button" class="btn btn-danger d-none" onclick="hangUp()">Hang Up</button>
        </div>

        <audio id="localAudio" autoPlay muted></audio>
        <audio id="remoteAudio" autoPlay></audio>
        <audio id="sounds" autoPlay></audio>

    </div>

Аудиоэлементы будут «отправлять» и «принимать» звук, ну и для красоты через sounds проигрывать звуки дозвона.

UI готов, к UX не придерешься, давайте заставим его работать.

Прикручиваем JSSIP

Дозвонились! Как собрать свою Web-звонилку за час - 4

Воспользуемся библиотекой, в которой уже реализовано всё, что требуется, — JSSIP [4]. Можно посмотреть документацию: там всё довольно подробно описано и есть даже готовый пример реализации. То есть нам практически ничего не нужно делать — лишь всё максимально упростить и разобраться, что к чему.

После ввода логина/пароля (должны быть зарегистрированы на вашем сервере телефонии) нужно залогиниться на сервере. Делаем так:

socket = new JsSIP.WebSocketInterface("wss://webrtcserver:port/ws");
    _ua = new JsSIP.UA(
        {
            uri: "sip:" + this.loginText.val() + "@webrtcserver",
            password: this.passwordText.val(),
            display_name: this.loginText.val(),
            sockets: [socket]
        });

Попутно можно подписаться на события connecting и connected и сделать там что-то полезное. Но перейдём сразу к событию регистрации:

his._ua.on('registered', () => {
        console.log("UA registered");

        this.loginButton.addClass('d-none');
        this.logOutButton.removeClass('d-none');
        this.loginText.prop('disabled', true);
        this.passwordText.prop('disabled', true);

        $("#callPanel").removeClass('d-none');
    });

Тут нам просто нужно поменять статусы кнопочек: показать нужное, скрыть ненужное. И если вдруг что-то пошло не так с логином, плюнем ошибку в лог:

this._ua.on('registrationFailed', (data) => {
        console.error("UA registrationFailed", data.cause);
    });

Этого достаточно для логина. Осталось завести шарманку с помощью
this._ua.start();

Если сервер указан правильно и ваш логин/пароль им приняты — появится поле для ввода телефона и кнопка Call.

Для разлогина нужно вызвать this._ua.stop(), всё просто.

Делаем звонок

Теперь — самое главное: нужно позвонить на введённый номер.

this.session = this._ua.call(number, {
        pcConfig:
        {
            hackStripTcp: true, // Важно для хрома, чтоб он не тупил при звонке
            rtcpMuxPolicy: 'negotiate', // Важно для хрома, чтоб работал multiplexing. Эту штуку обязательно нужно включить на астере.
            iceServers: []
        },
        mediaConstraints:
        {
            audio: true, // Поддерживаем только аудио
            video: false
        },
        rtcOfferConstraints:
        {
            offerToReceiveAudio: 1, // Принимаем только аудио
            offerToReceiveVideo: 0
        }
    });

Обратите внимание: мы явно включаем мультиплексирование, эту настройку нужно включить и на вашем сервере. В случае астериска это rtcp_mux=yes в настройках sip.conf.

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

// В процессе дозвона
    this.session.on('progress', () => {
        console.log("UA session progress");
        playSound("ringback.ogg", true);
    });

// Астер нас соединил с абонентом
this.session.on('connecting', () => {
        console.log("UA session connecting");
        playSound("ringback.ogg", true);

        // Тут мы подключаемся к микрофону и цепляем к нему поток, который пойдёт в астер
        let peerconnection = this.session.connection;
        let localStream = peerconnection.getLocalStreams()[0];

        // Handle local stream
        if (localStream) {
            // Clone local stream
            this._localClonedStream = localStream.clone();

            console.log('UA set local stream');

            let localAudioControl = document.getElementById("localAudio");
            localAudioControl.srcObject = this._localClonedStream;
        }

        // Как только астер отдаст нам поток абонента, мы его засунем к себе в наушники
        peerconnection.addEventListener('addstream', (event) => {
            console.log("UA session addstream");

            let remoteAudioControl = document.getElementById("remoteAudio");
            remoteAudioControl.srcObject = event.stream;
        });
    });

// Дозвон завершился неудачно, например, абонент сбросил звонок
    this.session.on('failed', (data) => {
        console.log("UA session failed");
        stopSound("ringback.ogg");
        playSound("rejected.mp3", false);

        this.callButton.removeClass('d-none');
        this.hangUpButton.addClass('d-none');
    });

    // Поговорили, разбежались
    this.session.on('ended', () => {
        console.log("UA session ended");
        playSound("rejected.mp3", false);
        JsSIP.Utils.closeMediaStream(this._localClonedStream);

        this.callButton.removeClass('d-none');
        this.hangUpButton.addClass('d-none');
    });

    // Звонок принят, можно начинать говорить
    this.session.on('accepted', () => {
        console.log("UA session accepted");
        stopSound("ringback.ogg");
        playSound("answered.mp3", false);
    });

В общем, всё довольно логично. Пока дозваниваемся ['progress'] — играем звуки дозвона. Как только дозвонились ['accepted'] — играем звук answered. Как только абонент снимет трубку, мы получим его звуковой поток и засунем его в элемент remoteAudio ['connecting' и 'addstream'].
В конце звонка делаем closeMediaStream. Можно расслабиться.

Немного про эксплуатацию

При тестировании были обнаружены две вещи.

  1. В хроме в начале дозвона была задержка на несколько секунд, что сильно раздражало. По логам выяснили, что он ходил на ice сервера, что совершенно не требовалось, так как сервер у нас свой. Поэтому в конфиге для JSSIP мы их просто убрали, и сразу похорошело. Смотри pcConfig.iceServers и pcConfig.hackStripTcp.
  2. На нашем астериске настроен WSS протокол с шифрованием для SIP. Этого требует браузер при использовании на сайте HTTPS. Но астериск использует WS, основываясь на параметрах контакта, в которых библиотека JSSIP содержит захардкоженный дескриптор WS. Разработчики библиотеки при этом указывают на стандарты, в которых действительно нет никаких требований по этому поводу. А коллеги из астера упорно не хотят ничего исправлять. В общем, тупик. Ну а мы в это время находим в исходниках строку this._configuration.contact_uri = new URI(...), меняем transport: 'ws' на transport: 'wss' и продолжаем радоваться жизни.

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

Еще библиотека позволяет набирать дополнительные номера в тоновом режиме. То есть вы вполне можете позвонить, например, в колл-центр банка и добраться до нужного пункта по голосовому меню. Для этого достаточно выполнить такую команду:

this._call.sendDTMF(‘доп. номер’)

Про факапы

Дозвонились! Как собрать свою Web-звонилку за час - 5

Было несколько моментов, которые реально заставили понервничать.

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

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

Заключение

Напоследок хочется отметить, что работу связки JSSIP + Asterisk мы протестировали на своём колл-центре, всё работает отлично, по крайней мере в хроме, что нас полностью устраивает. Главное — разрешить браузеру доступ к медиа-устройствам и зарегистрировать пользователей на сервере звонилки.

Готовый пример можно посмотреть на гитхабе [5].

Полезные ссылки

Про webrct [1]
Про SIP: тыц [6], тыц [3]
Про Asterisk [7]
Библиотека JSSIP [4]

Автор: Лобода Владимир

Источник [8]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/314836

Ссылки в тексте:

[1] WebRTC: https://webrtc.org/

[2] тут: https://habr.com/ru/company/Voximplant/blog/344794/

[3] тут: https://habr.com/ru/post/188352/

[4] JSSIP: https://jssip.net/

[5] гитхабе: https://github.com/viloboda/WebRTCCall

[6] тыц: https://ru.wikipedia.org/wiki/%D0%9F%D1%80%D0%BE%D1%82%D0%BE%D0%BA%D0%BE%D0%BB_%D1%83%D1%81%D1%82%D0%B0%D0%BD%D0%BE%D0%B2%D0%BB%D0%B5%D0%BD%D0%B8%D1%8F_%D1%81%D0%B5%D0%B0%D0%BD%D1%81%D0%B0

[7] Про Asterisk: https://habr.com/ru/post/54751/

[8] Источник: https://habr.com/ru/post/448266/?utm_source=habrahabr&utm_medium=rss&utm_campaign=448266