Друзья, сегодня я хочу поговорить с вами про звонки. Для кого-то это совсем новая тема. Для других — чистой воды фан на уровне «а не зафигачить ли мне свой скайп?». Для третьих — внезапно возникшая жизненная необходимость. Последний вариант — наш вариант.
В этой статье я покажу вам маленький, но очень жизнеспособный пример реализации, который позволит вам буквально на коленке из нескольких десятков строк java-скрипта сделать собственную WEB-звонилку и позвонить другу прямо из браузера.
Про технологии и протоколы
На дворе 2019 год, и к нашей радости уже есть готовый инструмент для реализации Real-Time Communication (RTC) для веба, а именно WebRTC. Еще несколько лет назад он был в активной разработке. API до сих пор дорабатывается, но технология де-факто стала стандартом и поддерживается в большинстве популярных браузеров. В этой статье на самой технологии мы останавливаться не будем, можно подробнее почитать на сайте разработчиков или поискать статьи на хабре. Например, вот тут.
Но прежде, чем мы начнём, хочу прояснить пару моментов.
- Во первых, WebRTC работает поверх пачки протоколов, и даже для p2p взаимодействия вам понадобится какой-то сервер, через который ваши клиенты смогут друг друга найти и подружиться. Наш же пример будет использовать SIP протокол, о котором можно почитать подробнее, скажем, тут.
- Вам понадобится сервер с поддержкой всего вышеперечисленного добра — вроде 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'] — играем звуки дозвона. Как только дозвонились ['accepted'] — играем звук answered. Как только абонент снимет трубку, мы получим его звуковой поток и засунем его в элемент remoteAudio ['connecting' и 'addstream'].
В конце звонка делаем closeMediaStream. Можно расслабиться.
Немного про эксплуатацию
При тестировании были обнаружены две вещи.
- В хроме в начале дозвона была задержка на несколько секунд, что сильно раздражало. По логам выяснили, что он ходил на ice сервера, что совершенно не требовалось, так как сервер у нас свой. Поэтому в конфиге для JSSIP мы их просто убрали, и сразу похорошело. Смотри pcConfig.iceServers и pcConfig.hackStripTcp.
- На нашем астериске настроен 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
Автор: Лобода Владимир