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





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


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


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




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


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


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

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


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


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




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


<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




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


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


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'] — играем звуки дозвона. В нашем примере мы играем свой звук, но как справедливо заметил pvsur, его можно так же получить с вызываемой стороны и услышать ответ автоинформатора вида "оставьте сообщение после звукового сигнала", если он будет.
Как только дозвонились ['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(‘доп. номер’)

Про факапы




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


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


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


Заключение


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


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


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


Про webrct
Про SIP: тыц, тыц
Про Asterisk
Библиотека JSSIP

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (14):

  1. Ovoshlook
    /#20036276

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

    Напишите нормально, что хотели сказать. Из написанного вами не понятно в чем дело…
    Напишите просто, что астер исходя из параметра в contact выбирает не шифрованный ws для доставки сообщений, когда нужен шифрованный.

    • vloboda
      /#20036364 / +1

      Спасибо за замечание, поправил, надеюсь, стало понятнее.

      • pvsur
        /#20038540

        Кстати, если страничка со звонилкой секурная (https), то транспорт ОБЯЗАТЕЛЬНО должен быть wss. По крайней мере sipml5 поступает именно так и блокирует ws. И это правильно.

        • Ovoshlook
          /#20039152

          Не правильно. Уровень приложения не в ответе за транспортный уровень.

          • Levhav
            /#20041096 / -1

            Нет практического смысла в звонилке расположенной на http странице так как браузеры дают доступ к камере и микрофону только на https страницах.
            А вот с страницы открытой по https нельзя обращаться к ресурсам по http
            Так что это скорее всего не sipml5 блокирует ws а он просто понимает что нечего не будет работать на по ws вместо wss.

            • Ovoshlook
              /#20041254

              при чем тут практический смысл и название протокола?
              Есть протокол WS он может использовать как транспорт транспорт TCP (WS) так и TLS (WSS). Сам протокол при этом остается тем же самым, отличаются только заголовки согласования соединения на этапе HTTP запросов, так что обозначать WS или WSS с практической точки зрения да и с точки зрения здравого смысла — как на клиенте так и на сервере НЕТ, так как данные после установления соединения будут идти по установленному траcпортному каналу, коим будет являться или TCP или TLS.

              Более того, WS может не обязательно использоваться в web
              многие предпочитают этот протокол как межсерверный (тот же астериск для отсылки events по ARI)
              И там вполне можно это делать по незащищенному каналу ибо нет смысла нагружать приложение в локальной среде.

  2. JC_IIB
    /#20037014

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

    буквально на коленке из нескольких десятков строк java-скрипта сделать собственную WEB-звонилку

    Вам понадобится сервер с поддержкой всего вышеперечисленного добра — вроде FreeSwitch или Asterisk.[...] Будем считать, что вам повезло так же, как нам, и в распоряжении уже имеется настроенная VoIP телефония.

    Охохо.

    • vloboda
      /#20038010

      Ну это не прям таки «охохо», но настройка окружения выходит за рамки статьи:)
      Как настроить астериск можно почитать, например, тут.

  3. pvsur
    /#20037338

    Я хорошо помню этот момент несколько лет назад, когда хромовский движок втихую включил мультиплексирование, а на астериске реализации этой фичи еще не было. И несколько месяцев жалобы клиентов, пока не раскопал в чем причина.
    Так что для веб-ртс необходим астериск версии не ниже 13.15.
    Кроме того, есть неплохая аналогичная библитека sipml5, о ней уже писали на Хабре.

    Кстати, проблему задержки вначале вызова так и не удалось на 100% победить, хотя все ice вроде убраны…

    • vloboda
      /#20038094

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

      • pvsur
        /#20038498

        Ну да, там не все так просто с этим прогрессом. Обычно должна играть удаленная станция выслав сообщение SIP\183, но она может выслать только SIP\180 и тогда играть КПВ должен аппарат вызывающего, иначе будет тишина… Кажется так. Но лучше всегда прокидывать голос с удаленной стороны, а в случае тишины при дозвоне сдавать ТТ оператору.

        And thanks to rfc3960, some policies for these messes are recommended:

        1. Unless a 180 (Ringing) response is received, never generate
        local ringing.

        2. If a 180 (Ringing) has been received but there are no incoming
        media packets, generate local ringing.

        3. If a 180 (Ringing) has been received and there are incoming
        media packets, play them and do not generate local ringing.

  4. prs123
    /#20038614

    С хромом нужно быть очень осторожным. Для доступа к микрофону, просим хром запросить разрешение. Но этот запрос на разрешение он показывает только при работе по https

  5. GreyCrew
    /#20039444 / +1

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


    Как наивно, помню ставил астериск на сервер… с телефонией аля 3g модем(chan_dongle).
    Передача данных по sip протоколу через webrtc по мне дак, это самая простая ее часть

    • vloboda
      /#20039468

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