Аудио-конференции — удобный инструмент для решения ряда бизнес задач, большинство привыкло пользоваться чем-нибудь готовым, например, Skype. Но есть ряд случаев, когда компании нужен свой инструмент с централизованным управлением, чтобы секретарь или координатор могли создать конференцию, собрать в нее людей и управлять этим процессом. Мы рассмотрим как раз такой случай и будем использовать облачную платформу VoxImplant для реализации нужного функционала, а также другие полезные веб-библиотеки а-ля bootstrap и jquery.
Для начала определимся с минимально необходимым функционалом нашего сервиса конференций. Нам нужно, чтобы администратор конференции мог создавать список участников конференции, указывать тип подключения для участника — через входящий на номер доступа + пин, подключение исходящим звонков с сервера конференций (кому-то это может оказаться удобнее), как дополнительный вариант — звонок через веб-приложение, сделанное с помощью Web SDK. Администратор конференции в реальном времени должен иметь возможность наблюдать кто из участников подключен, делать mute/unmute участников, отключать и подключать участников конференции на лету. Соответственно прототип интерфейса администратора будет иметь следующий вид.
Тут все просто: выбор номера доступа к конференции, можно/нельзя подключиться к конференции без авторизации, пытаться авторизоваться по номеру звонящего в конференции. Выбор номера делаем через запрос к HTTP API VoxImplant, получаем список номеров, подключенных к приложению conference (подробнее об этом чуть позднее), остальное — это просто настройки, которые мы сохраняем в БД в таблице conferences.
Знакомый всем bootstrap нам в помощь, логику работы интерфейса и его взаимодействия с простеньким веб-сервисом работающим с БД из 3 таблиц (managers, conferences, participants) мы запилили на Reactjs. Мы не будем вдаваться сейчас в детали разработки интерфейсов, пост немного не про это, если будут пожелания — всегда рады рассказать подробнее, но на это нужна отдельная статья. В конце будут приведены ссылки на SQL-скрипт для развертывания таблиц, PHP-скрипт с веб-сервисами, а также собранный bundle клиентской части сервиса, все это добро мы выложим на GitHub.
С добавлением участников все тоже не особо сложно — есть имя участника, номер телефона (для исходящего подключения или если включена авторизация по А-номеру), email (для отправки уведомлений об участии в конференции и данных для подключения к ней), Outbound — подключить исходящим звонком после старта конференции. Когда список участников сформирован (все данные записаны в БД), то можно начинать конференцию. Schedule Conference позволяет запланировать конференцию на какую-то дату в будущем, разница со Start Conference очевидна — просто записываем данные в БД, включая данные когда конференция будет актуальна и только тогда к ней можно будет подключиться извне + она должна будет при создании попытаться сама вызвонить тех участников, у которых Outbound == true.
Прежде чем приступить к написанию сценариев VoxImplant для конференций нужно разобраться как они устроены. Конференции в VoxImplant бывают 2х типов: одни можно создавать прямо в сценарии с помощью функции createConference и подключать к ним только звонки, которые были созданы в рамках текущей сессии, вторые создаются на специальных серверах специальной командой HTTP API или после переадресации звонка на эти сервера с помощью специального правила и функции callConference. Нам для реализации сервиса потребуется второй вариант. По сути, будет 2 основных сценария — gatekeeper и conference, gatekeeper будет отвечать за авторизацию входящих звонков и перенаправлять их в конференцию при успешной авторизации. А в основном сценарии мы будем собирать все звонки и обрабатывать внешние команды, которые сессия с конференцией будет получать через HTTP-интерфейс.
Сначала в разделе «Приложения» создадим новое, а затем сценарии в нем. Начнем с простого, сценарий ConferenceGatekeeper будет иметь следующий вид:
Чтобы связать сценарий с номером нужно приобрести номер в разделе «Номера» в приложении, прикрепить его к приложению и создать правило в разделе «Роутинг». Назовем его IncomingCall, в котором в маску пишем купленный номер телефона, а в сценарии — наш ранее созданный ConferenceGatekeeper.
Если все сделали правильно, то теперь входящий звонок на номер запустит наш сценарий. Можно переходить к сценарию самой конференции, но перед этим надо учесть одну важную особенность архитектуры VoxImplant — сессия без активных звонков в ней живет ровно 1 минуту, после чего завершается, это в равной степени относится к сессиям с конференцией. Если мы стартанем конференцию через HTTP-запрос StartConference (например, для подключения каких-то из участников исходящими звонками), то media_session_access_url, который вернет этот запрос будет актуален не вечно, а ровно до момента гибели сессии. Аналогично в случае запуска сессии конференции через входящие звонки, поэтому мы будем media_session_access_url хранить в базе данных и удалять его в случае завершения сессии и перезаписывать в случае перезапуска сессии, чтобы у нас там был или актуальный URL или его не было совсем.
Итак, создаем новый сценарий с названием StandaloneConference:
Вы, наверное, заметили, что мы использовали некий класс VoxConference в этом сценарии, это никакой не встроенный класс, это просто отдельный класс, отвечающий за работу конференции, который мы написали в отдельном сценарии и будем прицеплять его к правилу перед нашим StandaloneConference. Давайте посмотрим на него внимательнее:
Если вы дочитали до этого места, то значит вам реально интересно, осталось еще немного :)
Итак, теперь у нас есть еще 2 сценария — StandaloneConference и VoxConference, чтобы подключить их к обработке звонков нужно создать несколько дополнительных правил для нашего приложения. Назовем их FwdToConf — маска будет conf, и StartConfHTTP — маску сделаем .*, в обоих случаях прикрепляем VoxConference и StandaloneConference:
Все сохраняем и переходим к разворачиванию БД и веб-сервиса. Сервис мы напилили по-быстрому на PHP, для БД взяли MySQL. Помимо разворачивания сервиса потребуется веб-клиент для управления всем этим хозяйством — если не хочется руками в БД все время ковыряться, его мы быстро сделали на ReactJS и Bootstrap. Есть правда один нюанс — придется настроить веб-сервер определенным образом, так как мы используем AJAX + сессию на сервере + crossDomain, иначе браузер будет ругаться.
Для Nginx настройка под наш кросс-доменный AJAX будет выглядеть так:
Все остальные файлы, включая структуру БД для mysql и юзером admin / admin от лица которого можно сразу воспользоваться функционалом конференций, а также готовым веб-приложением можно взять с нашего GitHub-аккаунта:
Проект со всеми файлами на GitHub
Для начала определимся с минимально необходимым функционалом нашего сервиса конференций. Нам нужно, чтобы администратор конференции мог создавать список участников конференции, указывать тип подключения для участника — через входящий на номер доступа + пин, подключение исходящим звонков с сервера конференций (кому-то это может оказаться удобнее), как дополнительный вариант — звонок через веб-приложение, сделанное с помощью Web SDK. Администратор конференции в реальном времени должен иметь возможность наблюдать кто из участников подключен, делать mute/unmute участников, отключать и подключать участников конференции на лету. Соответственно прототип интерфейса администратора будет иметь следующий вид.
Настройки конференции
Тут все просто: выбор номера доступа к конференции, можно/нельзя подключиться к конференции без авторизации, пытаться авторизоваться по номеру звонящего в конференции. Выбор номера делаем через запрос к HTTP API VoxImplant, получаем список номеров, подключенных к приложению conference (подробнее об этом чуть позднее), остальное — это просто настройки, которые мы сохраняем в БД в таблице conferences.
Участники конференции
Знакомый всем bootstrap нам в помощь, логику работы интерфейса и его взаимодействия с простеньким веб-сервисом работающим с БД из 3 таблиц (managers, conferences, participants) мы запилили на Reactjs. Мы не будем вдаваться сейчас в детали разработки интерфейсов, пост немного не про это, если будут пожелания — всегда рады рассказать подробнее, но на это нужна отдельная статья. В конце будут приведены ссылки на SQL-скрипт для развертывания таблиц, PHP-скрипт с веб-сервисами, а также собранный bundle клиентской части сервиса, все это добро мы выложим на GitHub.
С добавлением участников все тоже не особо сложно — есть имя участника, номер телефона (для исходящего подключения или если включена авторизация по А-номеру), email (для отправки уведомлений об участии в конференции и данных для подключения к ней), Outbound — подключить исходящим звонком после старта конференции. Когда список участников сформирован (все данные записаны в БД), то можно начинать конференцию. Schedule Conference позволяет запланировать конференцию на какую-то дату в будущем, разница со Start Conference очевидна — просто записываем данные в БД, включая данные когда конференция будет актуальна и только тогда к ней можно будет подключиться извне + она должна будет при создании попытаться сама вызвонить тех участников, у которых Outbound == true.
Конференции VoxImplant
Прежде чем приступить к написанию сценариев VoxImplant для конференций нужно разобраться как они устроены. Конференции в VoxImplant бывают 2х типов: одни можно создавать прямо в сценарии с помощью функции createConference и подключать к ним только звонки, которые были созданы в рамках текущей сессии, вторые создаются на специальных серверах специальной командой HTTP API или после переадресации звонка на эти сервера с помощью специального правила и функции callConference. Нам для реализации сервиса потребуется второй вариант. По сути, будет 2 основных сценария — gatekeeper и conference, gatekeeper будет отвечать за авторизацию входящих звонков и перенаправлять их в конференцию при успешной авторизации. А в основном сценарии мы будем собирать все звонки и обрабатывать внешние команды, которые сессия с конференцией будет получать через HTTP-интерфейс.
Сначала в разделе «Приложения» создадим новое, а затем сценарии в нем. Начнем с простого, сценарий ConferenceGatekeeper будет иметь следующий вид:
var call, // входящий звонок
input = '', // для хранения цифр при донаборе
WEBSERVICE_URL = "path/to/shim.php",
INTRO_MUSIC_URL = "path/to/music.mp3",
t1,
sessionCookie = null; // id сессии для работы с веб-сервисом (shim.php) после успешной авторизации
/**
* Обрабатываем входящий звонок
*/
VoxEngine.addEventListener(AppEvents.CallAlerting, function (e) {
call = e.call;
// Проигрываем музыку пока идет авторизация
call.startEarlyMedia();
call.startPlayback(INTRO_MUSIC_URL, true);
// Вешаем обработчики событий
call.addEventListener(CallEvents.Connected, handleCallConnected);
call.addEventListener(CallEvents.Disconnected, function (e) {
VoxEngine.terminate();
});
call.addEventListener(CallEvents.Failed, function (e) {
VoxEngine.terminate();
});
call.addEventListener(CallEvents.ToneReceived, handleToneReceived);
// Авторизуемся на веб-сервисе (shim.php)
var opts = new Net.HttpRequestOptions();
opts.method = "GET";
opts.headers = ["User-Agent: VoxImplant"];
// Логин и пароль менеджера конференции (хранится в БД)
var authInfo = {
username: "username",
password: "password"
};
Net.httpRequest(WEBSERVICE_URL + '?action=authorize¶ms=' + encodeURIComponent(JSON.stringify(authInfo)), authResult, opts);
});
function authResult(e) {
// HTTP 200 , успешный запрос
if (e.code == 200) {
// Ищем ID-сессии в HTTP response хедерах
for (var i in e.headers) {
if (e.headers[i].key == "Set-Cookie") {
sessionCookie = e.headers[i].value;
sessionCookie = sessionCookie.substr(0, sessionCookie.indexOf(';'));
}
}
// Что-то пошло не так, завершаем сессию (можно что-нибудь сказать звонящему при желании)
if (sessionCookie == null) {
Logger.write("No session header found.");
VoxEngine.terminate();
}
Logger.write("Auth Result: " + e.text + " Session Cookie: " + sessionCookie);
// Если все ОК, то ship.php должен вернуть просто слово AUTHORIZED
if (JSON.parse(e.text).result == "AUTHORIZED") {
// Включаем обработку нажатия клавиш (донабора)
call.handleTones(true);
// Отвечаем на звонок - в результате вызовется CallEvents.CallConnected
call.answer();
} else {
Logger.write("Authorization failed");
VoxEngine.terminate();
}
} else {
Logger.write("Auth HTTP request failed: " + e.code);
VoxEngine.terminate();
}
}
/**
* Обрабатываем событие при соединении звонка
*/
function handleCallConnected(e) {
// Выключаем музыку
call.stopPlayback();
// Проигрываем приветствие, используя TTS и ждем ввода кода доступа от конференции (см. CallEvents.ToneReceived)
call.say("Hello! Welcome to VoxImplant conferencing, please enter your conference access code, " +
"followed by the pound sign", Language.UK_ENGLISH_FEMALE);
// Вешаем обработчик завершения проигрывания TTS
call.addEventListener(CallEvents.PlaybackFinished, handleIntroPlayed);
}
/**
* Обрабатываем событие завершения проигрывания TTS
*/
function handleIntroPlayed(e) {
// Не забываем удалить listener
call.removeEventListener(CallEvents.PlaybackFinished, handleIntroPlayed);
// Через 5 секунд повторим просьбу ввести код доступа от конференции
t1 = setTimeout(function () {
call.say("Please enter your conference access code, " +
"followed by the pound sign", Language.UK_ENGLISH_FEMALE);
call.addEventListener(CallEvents.PlaybackFinished, handleIntroPlayed);
}, 5000);
}
/**
* Обрабатываем ввод с клавиатуры телефона
*/
function handleToneReceived(e) {
clearTimeout(t1);
call.removeEventListener(CallEvents.PlaybackFinished, handleIntroPlayed);
call.stopPlayback();
// Если ввели решетку, то проверяем код
if (e.tone == '#') {
var opts = new Net.HttpRequestOptions();
opts.method = "GET";
opts.headers = ["Cookie: " + sessionCookie];
// Данные для веб-сервиса - введенный код доступа и номер конференции
var requestInfo = {
access_number: call.number().replace(/[\s\+]+/g, ''),
access_code: input
};
Net.httpRequest(WEBSERVICE_URL + "?action=get_conference¶ms=" + encodeURIComponent(JSON.stringify(requestInfo)), getConferenceResult, opts);
} else input += e.tone;
}
/**
* Обработка результатов запроса get_conference
*/
function getConferenceResult(e) {
if (e.code == 200) {
var result = JSON.parse(e.text);
if (typeof result.result != "undefined") {
// Получили id конференции
result = result.result;
// Важно не забыть отключить обработку ввода перед перенаправлением звонка на сервер конференций
call.removeEventListener(CallEvents.ToneReceived, handleToneReceived);
call.handleTones(false);
input = '';
Logger.write('Joining conference conf' + result.conference_id);
// Отправляем вызов в конференцию с названием conf + id-конференции и передаем callerid
var conf = VoxEngine.callConference('conf' + result.conference_id, call.callerid());
VoxEngine.sendMediaBetween(call, conf);
} else {
input = '';
call.say("Sorry, there is no conference with entered access code, please try again.", Language.UK_ENGLISH_FEMALE);
call.addEventListener(CallEvents.PlaybackFinished, handleIntroPlayed);
}
} else {
Logger.write("GetConference HTTP request failed: " + e.code);
input = '';
call.say("Sorry, there is no conference with entered access code, please try again.", Language.UK_ENGLISH_FEMALE);
call.addEventListener(CallEvents.PlaybackFinished, handleIntroPlayed);
}
}
Чтобы связать сценарий с номером нужно приобрести номер в разделе «Номера» в приложении, прикрепить его к приложению и создать правило в разделе «Роутинг». Назовем его IncomingCall, в котором в маску пишем купленный номер телефона, а в сценарии — наш ранее созданный ConferenceGatekeeper.
Если все сделали правильно, то теперь входящий звонок на номер запустит наш сценарий. Можно переходить к сценарию самой конференции, но перед этим надо учесть одну важную особенность архитектуры VoxImplant — сессия без активных звонков в ней живет ровно 1 минуту, после чего завершается, это в равной степени относится к сессиям с конференцией. Если мы стартанем конференцию через HTTP-запрос StartConference (например, для подключения каких-то из участников исходящими звонками), то media_session_access_url, который вернет этот запрос будет актуален не вечно, а ровно до момента гибели сессии. Аналогично в случае запуска сессии конференции через входящие звонки, поэтому мы будем media_session_access_url хранить в базе данных и удалять его в случае завершения сессии и перезаписывать в случае перезапуска сессии, чтобы у нас там был или актуальный URL или его не было совсем.
Итак, создаем новый сценарий с названием StandaloneConference:
var voxConf, // инстанс VoxConference (мы опишем этот класс в отдельном сценарии)
conferenceId = null, // id конференции
startType, // способ запуска конференции
eventType, // тип события для перезапуска конференции
redial_pId, // id участника конференции
authorized = false, // флаг авторизации
WEBSERVICE_URL = "path/to/shim.php", // путь к веб-сервису
sessionCookie = null, // храним тут id сессии после авторизации на веб-сервисе
t3,
ms_url; // media_session_access_url
/**
* Обработка запуска сессии
*/
VoxEngine.addEventListener(AppEvents.Started, function (e) {
// Создаем инстанс класса VoxConference
voxConf = new VoxConference();
// Получаем media_session_access_url
ms_url = e.accessURL;
// Если сессия запущена через HTTP, то customData есть, если входящим звонком, то нет
try {
data = JSON.parse(VoxEngine.customData());
conferenceId = data.conference_id;
startType = data.start_type;
if (typeof data.event != 'undefined') eventType = data.event;
if (typeof data.pId != 'undefined') redial_pId = data.pId;
} catch (e) {
startType = "sip";
}
// Авторизуемся на веб-сервисе (shim.php)
var opts = new Net.HttpRequestOptions();
opts.method = "GET";
opts.headers = ["User-Agent: VoxImplant"];
// Логин и пароль менеджера конференции (хранится в БД)
var authInfo = {
username: "username",
password: "password"
};
Net.httpRequest(WEBSERVICE_URL + "?action=authorize¶ms=" + encodeURIComponent(JSON.stringify(authInfo)), authResult, opts);
});
/**
* Обработка входящего звонка
*/
VoxEngine.addEventListener(AppEvents.CallAlerting, function (e) {
// Если данные по участника еще не загружены из веб-сервиса, то просто добавляем их для последующей обработки в статусе waiting
if (voxConf.participants() == null) {
// e.destination содержит полное название конференции в виде conf + id, получаем id
conferenceId = e.destination.replace('conf', '');
voxConf.addConfCall({
call_id: e.call.id(),
call: e.call,
input: '',
state: 'waiting'
});
} else {
// Если данные по участникам уже есть, то сразу обрабатываем входящий вызов
voxConf.addConfCall({
call_id: e.call.id(),
call: e.call,
input: '',
state: 'in_process'
});
voxConf.processIncomingCall(e.call);
}
});
/**
* Обработка результатов запроса авторизации
*/
function authResult(e) {
if (e.code == 200) {
for (var i in e.headers) {
if (e.headers[i].key == "Set-Cookie") {
sessionCookie = e.headers[i].value;
sessionCookie = sessionCookie.substr(0, sessionCookie.indexOf(';'));
}
}
if (sessionCookie == null) {
Logger.write("No session header found.");
VoxEngine.terminate();
}
if (JSON.parse(e.text).result == "AUTHORIZED") {
// Авторизация прошла успешно
authorized = true;
// Если конференция была запущена по HTTP или уже есть conferenceId
if (startType == 'http' || conferenceId != null) {
// Стартанули через входящий звонок - сохраняем media_session_access_url в БД
if (startType == 'sip') saveMSURL();
// Подгружаем данные про участников конференции
getParticipants();
} else {
// Ждем пока появится conferenceId
t3 = setInterval(checkConferenceId, 1000);
}
} else {
Logger.write("Authorization failed");
VoxEngine.terminate();
}
} else {
Logger.write("Auth HTTP request failed: " + e.code);
VoxEngine.terminate();
}
}
/**
* Функция, чтобы перезвонить участнику конференции
*/
function processRedial(pId) {
var phone = '',
participants = voxConf.participants();
// Ищем участника с id = pId
for (var k in participants) {
if (participants[k].id == pId) {
phone = participants[k].phone;
}
}
// Не нашли
if (phone == '') return false;
// Нашли - звоним и сразу в обработку
var call = VoxEngine.callPSTN(phone, voxConf.getConfNumber());
voxConf.addConfCall({
call_id: call.id(),
call: call,
input: '',
state: 'in_process',
participant_id: pId
});
voxConf.processOutboundCall(call);
return true;
}
/**
* Проверяем conferenceId, если появилось, значит пришел входящий звонок и сессия запустилась
*/
function checkConferenceId() {
if (conferenceId != null) {
clearInterval(t3);
// Сохраняем media_session_access_url в БД
if (startType == 'sip') saveMSURL();
// Получаем данные об участниках конференции
getParticipants();
}
}
/**
* Сохраняем media_session_access_url в БД через веб-сервис
*/
function saveMSURL() {
var opts = new Net.HttpRequestOptions();
opts.method = "POST";
opts.headers = ["Cookie: " + sessionCookie];
var requestInfo = {
conference_id: conferenceId,
ms_url: ms_url
};
Net.httpRequest(WEBSERVICE_URL + "?action=save_ms_url¶ms=" + encodeURIComponent(JSON.stringify(requestInfo)), saveMSURLresult, opts);
}
// Обработчик результатов запроса save_ms_url
function saveMSURLresult(e) {
if (e.code == 200) {
// Все хорошо. TODO: сделать нормальный обработчик
Logger.write(e.text);
} else {
// Все плохо. TODO: сделать нормальный обработчик
Logger("Couldn't save ms_url in DB");
}
}
/**
* Загружаем данные по участника конференции из веб-сервиса
*/
function getParticipants() {
var opts = new Net.HttpRequestOptions();
opts.method = "GET";
opts.headers = ["Cookie: " + sessionCookie];
var requestInfo = {
conference_id: conferenceId
};
Net.httpRequest(WEBSERVICE_URL + "?action=get_participants¶ms=" + encodeURIComponent(JSON.stringify(requestInfo)), getParticipantsResult, opts);
}
/**
* Обрабатываем результаты запроса get_participants
*/
function getParticipantsResult(e) {
if (e.code == 200) {
if (typeof e.text == 'undefined') {
// Что-то пошло не так. TODO: сделать нормальный обработчик
Logger.write('No participants found.');
VoxEngine.terminate();
return;
}
var result = JSON.parse(e.text);
if (typeof result.error != 'undefined') {
// Что-то пошло не так. TODO: сделать нормальный обработчик
} else {
// Данные про конференцию и участников
var participants = result.result.participants,
calleridAuth = (result.result.conference.callerid_auth == "1"),
anonymousAccess = (result.result.conference.anonymous_access == "1"),
accessCode = result.result.conference.access_code,
active = (result.result.conference.active == "1"),
accessNumber = result.result.conference.access_number;
// Инициализируем
voxConf.init(conferenceId, accessCode, anonymousAccess, calleridAuth, active, participants, accessNumber);
// Обрабатываем звонки, которые пришли в конференцию до получения данных от веб-сервиса
voxConf.processWaitingCalls();
// Если сессия запущена через HTTP - делаем исходящие звонки на соответствующих участников
if (startType == "http") {
if (eventType == 'redial') voxConf.makeOutboundCalls(redial_pId); // Если сессия ожила при попытке позвонить конкретному участнику конференции
else voxConf.makeOutboundCalls(); // Сессия ожила при старте всей конференции
}
}
} else Logger.write("Participants HTTP request failed: " + e.code);
}
/**
* Обновление списка участников в случае добавления нового участника конференции
*/
function updateParticipants(e, pId) {
if (e.code == 200) {
if (typeof e.text == 'undefined') return; // Что-то пошло не так. TODO: сделать нормальный обработчик
var result = JSON.parse(e.text);
if (typeof result.error != 'undefined') {
// Что-то пошло не так. TODO: сделать нормальный обработчик
} else {
// Обновляем список участников
voxConf.updateParticipants(result.result.participants);
// Звоним новому участнику
processRedial(pId);
}
}
}
/**
* Обработка комманд передаваемых сценарию через media_session_access_url
*/
VoxEngine.addEventListener(AppEvents.HttpRequest, function (data) {
// Запросы выглядят как media_session_access_url + /command=mute_participant/pId=100
// в data.path будет только command=mute_participant/pId=100
var params = data.path.split("/"),
command = null,
pId = null,
options = null;
// Парсим params и получаем значения command, pId, options
for (var i in params) {
var kv = params[i].split("=");
if (kv.length > 1) {
if (kv[0] == "command") command = kv[1];
if (kv[0] == "pId") pId = kv[1];
if (kv[0] == "options") options = kv[1];
}
}
// Тут содержаться все текущие звонки конференции
var calls = voxConf.calls();
switch (command) {
// Получаем данные по звонкам и возвращаем назад - для визуализации состояния участников в веб-интерфейсе
case "gather_info":
var result = [];
for (var i in calls) {
if (typeof calls[i].participant_id != 'undefined') result.push({
state: calls[i].call.state(),
participant_id: calls[i].participant_id
});
}
return JSON.stringify(result);
break;
// Отключить участника
case "disconnect_participant":
for (var i in calls) {
if (calls[i].participant_id == pId) {
calls[i].call.hangup();
return true;
}
}
return false;
break;
// Выключить передачу аудио от участника в конференцию
case "mute_participant":
for (var i in calls) {
if (calls[i].participant_id == pId) {
calls[i].call.stopMediaTo(voxConf.getConfObj());
return true;
}
}
return false;
break;
// Включить передачу аудио от участника в конференцию
case "unmute_participant":
for (var i in calls) {
if (calls[i].participant_id == pId) {
calls[i].call.sendMediaTo(voxConf.getConfObj());
return true;
}
}
return false;
break;
// Набрать участника еще раз
case "redial_participant":
// При добавлении участника нужно перезагрузить список участников конференции с веб-сервиса
if (options == 'reload_participants') {
var opts = new Net.HttpRequestOptions();
opts.method = "GET";
opts.headers = ["Cookie: " + sessionCookie];
var requestInfo = {
conference_id: conferenceId
};
Net.httpRequest(WEBSERVICE_URL + "?action=get_participants¶ms=" + encodeURIComponent(JSON.stringify(requestInfo)), function(e) {
// Обновляем список участников
updateParticipants(e, pId);
}, opts);
return true;
} else {
// Звоним еще раз уже существующему участнику
return processRedial(pId);
}
break;
}
});
/**
* При завершении сессии конференции отправляем запрос веб-сервису на удаление media_session_access_url (ms_url) из БД
*/
VoxEngine.addEventListener(AppEvents.Terminating, function(e) {
var opts = new Net.HttpRequestOptions();
opts.method = "POST";
opts.headers = ["Cookie: " + sessionCookie];
Logger.write("HEADERS: " + opts.headers);
var requestInfo = {
conference_id: conferenceId,
ms_url: ''
};
Logger.write("Terminating the session, update ms_url in database");
Net.httpRequest(WEBSERVICE_URL + "?action=save_ms_url¶ms=" + encodeURIComponent(JSON.stringify(requestInfo)), function(e) {}, opts);
});
Вы, наверное, заметили, что мы использовали некий класс VoxConference в этом сценарии, это никакой не встроенный класс, это просто отдельный класс, отвечающий за работу конференции, который мы написали в отдельном сценарии и будем прицеплять его к правилу перед нашим StandaloneConference. Давайте посмотрим на него внимательнее:
Если вы дочитали до этого места, то значит вам реально интересно, осталось еще немного :)
// Magic! Подключаем модули VoxImplant
require(Modules.Conference); // Модуль подключает функционал аудио-конференций
require(Modules.Player); // Модуль подключаем функционал проигрывателя
/**
* Вот наш класс VoxConference, который отвечает за ряд функций конференции
*/
VoxConference = function () {
var conferenceId,
accessCode,
anonymousAccess,
calleridAuth,
active,
participants = null,
number,
conf,
calls = [],
t1, t2,
music = null,
BEEP_URL = "path/to/beep.mp3",
MUSIC_URL = "path/to/ambientmusic.mp3";
// Просто определяем переменные
this.init = function (id, code, a_access, c_auth, a, p, num) {
conferenceId = id; // id конференции
number = num; // телефонный номер конференции
accessCode = code; // код доступа
anonymousAccess = a_access; // возможность подключения без авторизации
calleridAuth = c_auth; // авторизации участника по номеру телефона
participants = p; // список участников
active = a;
// Создаем конференцию
conf = VoxEngine.createConference();
}
// Получение списка участников
this.participants = function () {
return participants;
}
// Обновление списка участников
this.updateParticipants = function (newdata) {
participants = newdata;
}
// Получение списка текущих звонков
this.calls = function () {
return calls;
}
// Получение номера конференции
this.getConfNumber = function () {
return number;
}
// Получение инстанса конференции
this.getConfObj = function () {
return conf;
}
// Обработка нового входящего звонка
this.processIncomingCall = function (call) {
// Отвечаем на звонок
call.answer();
// Обработчики событий
this.handleCallConnected = this.handleCallConnected.bind(this);
call.addEventListener(CallEvents.Connected, this.handleCallConnected);
call.addEventListener(CallEvents.Disconnected, function (e) {
// При отключении звонка чистим массив calls и меняем статус участника в participants
var pid = this.getConfCall(e.call).participant_id;
Logger.write("Participant id " + pid + " has left the conf");
this.participantLeft(pid);
for (var i = 0; i < calls.length; i++) {
if (calls[i].call == e.call) {
calls.splice(i, 1);
break;
}
}
// Если остался только один звонок, то играем участнику музыку
this.ambientMusic();
}.bind(this));
}
// Обработка нового исходящего звонка
this.processOutboundCall = function (call) {
// Отвечаем на звонок
call.answer();
// Обработчики событий
this.handleOutboundCallConnected = this.handleOutboundCallConnected.bind(this);
call.addEventListener(CallEvents.Connected, this.handleOutboundCallConnected);
call.addEventListener(CallEvents.Disconnected, function (e) {
// При отключении звонка чистим массив calls и меняем статус участника в participants
var pid = this.getConfCall(e.call).participant_id;
Logger.write("Participant id " + pid + " has left the conf");
this.participantLeft(pid);
for (var i = 0; i < calls.length; i++) {
if (calls[i].call == e.call) {
calls.splice(i, 1);
break;
}
}
this.ambientMusic();
}.bind(this));
call.addEventListener(CallEvents.Failed, function (e) {
// Не смогли дозвониться
var pid = this.getConfCall(e.call).participant_id;
Logger.write("Couldnt connect participant id " + pid);
}.bind(this));
}
// Проверяем существует ли участник с указанным паролем, еще не подключенный к конференции
this.participantExists = function (passcode) {
Logger.write("Check if participant exists, passcode: " + passcode);
for (var i = 0; i < participants.length; i++) {
if (participants[i].passcode == passcode && participants[i].connected != true) {
participants[i].connected = true;
return participants[i].id;
}
}
return false;
}
// Проверяем существует ли участник, еще не подключенный к конференции, с указанными именем пареметра и его значением
this.participantWithParamExists = function (param, value) {
Logger.write("Check if with participant." + param + " = " + value + " exists");
for (var i = 0; i < participants.length; i++) {
if (participants[i][param] == value && participants[i].connected != true) {
participants[i].connected = true;
return participants[i].id;
}
}
return false;
}
// При отключении участника ставим connected = false
this.participantLeft = function (id) {
for (var i = 0; i < participants.length; i++) {
if (participants[i].id == id) {
participants[i].connected = false;
}
}
}
// Запускаем в обработку все звонки, которые ожидали получения данных по конференции
this.processWaitingCalls = function () {
for (var i = 0; i < calls.length; i++) {
if (calls[i].state == 'waiting') {
calls[i].state = 'in_process';
this.processIncomingCall(calls[i].call);
}
}
}
// Дозваниваемся до всех участников, у которых установлен параметр исходящего дозвона (auto_call)
this.makeOutboundCalls = function (pId) {
for (var i in participants) {
if ((participants[i].auto_call == "1" && typeof pId == 'undefined') ||
pId == participants[i].id) {
var call = VoxEngine.callPSTN(participants[i].phone, number);
this.addConfCall({
call_id: call.id(),
call: call,
input: '',
state: 'in_process',
participant_id: participants[i].id
});
Logger.write(JSON.stringify(calls));
this.processOutboundCall(call);
}
}
}
// Получаем звонок из массива calls
this.getConfCall = function (call) {
for (var i in calls) {
if (calls[i].call == call) return calls[i];
}
}
// Добавляем звонок в массив calls
this.addConfCall = function (call_obj) {
calls.push(call_obj);
}
// Обработчик вызываемый при успешном исходящем дозвоне
this.handleOutboundCallConnected = function (e) {
// Обновляем объект в массиве calls
var cCall = this.getConfCall(e.call);
cCall.state = 'connected';
// Подключаем аудио от звонка к конференции (в обе стороны)
VoxEngine.sendMediaBetween(e.call, conf);
// Проигрываем звук в конференцию для оповещения о подключении нового участника
var snd = VoxEngine.createURLPlayer(BEEP_URL);
snd.sendMediaTo(conf);
snd.addEventListener(PlayerEvents.PlaybackFinished, function (ee) {
// Если всего один участник в конференции - играем музыку
this.ambientMusic();
}.bind(this));
}
// Обработчик вызываемый при успешном подключении входящего вызова (уже прошедшего ConferenceGatekeeper)
this.handleCallConnected = function (e) {
// Выключаем проигрывание
e.call.stopPlayback();
// Снова включаем обработку нажатий на клавиши (DTMF)
e.call.handleTones(true);
// Переходим ко второй фазе авторизации - ввод пароля участника (passcode)
this.authStep2(e.call);
}
// Снова проигрываем сообщение о вводе пароля после завершения его проигрывания, если не было ввода
this.handleIntroPlayedStage2 = function (e) {
e.call.removeEventListener(CallEvents.PlaybackFinished, this.handleIntroPlayedStage2);
t2 = setTimeout(function () {
e.call.say("Please specify your passcode, followed by " +
"the pound sign to join the conference.", Language.UK_ENGLISH_FEMALE);
e.call.addEventListener(CallEvents.PlaybackFinished, this.handleIntroPlayedStage2);
}.bind(this), 5000);
}
// Обработчик ввода с клавиатуры
this.handleToneReceivedStage2 = function (e) {
clearTimeout(t2);
e.call.removeEventListener(CallEvents.PlaybackFinished, this.handleIntroPlayedStage2);
e.call.stopPlayback();
var cCall = this.getConfCall(e.call);
// При нажатии на # проверяем пароль
if (e.tone == "#") {
Logger.write("Checking passcode: " + cCall.input);
participant_id = this.participantExists(cCall.input);
if (participant_id != false) {
// Участник с паролем найден
cCall.input = "";
cCall.state = "connected";
cCall.participant_id = participant_id;
e.call.removeEventListener(CallEvents.ToneReceived, this.handleToneReceivedStage2);
Logger.write("Participant id " + participant_id + " has joined the conf");
// Подключаем к конференции
this.joinConf(e.call);
} else {
// Участник не найден - проигрываем сообщение и просим повторить ввод
cCall.input = "";
e.call.say("Sorry, wrong passcode was specified, please try again.", Language.UK_ENGLISH_FEMALE);
e.call.addEventListener(CallEvents.PlaybackFinished, this.handleIntroPlayedStage2);
}
} else cCall.input += e.tone; // пока не нажата # просто добавляем ввод к input
}
// Подключение входящего звонка к конференции после авторизации
this.joinConf = function (call) {
call.say("You have joined the conference.", Language.UK_ENGLISH_FEMALE);
call.addEventListener(CallEvents.PlaybackFinished, function (e) {
// Подключаем аудио от звонка к конференции (в обе стороны)
VoxEngine.sendMediaBetween(call, conf);
// Проигрываем звук в конференцию для оповещения о подключении нового участника
var snd = VoxEngine.createURLPlayer(BEEP_URL);
snd.sendMediaTo(conf);
snd.addEventListener(PlayerEvents.PlaybackFinished, function (ee) {
// Если всего один участник в конференции - играем музыку
this.ambientMusic();
}.bind(this));
}.bind(this));
}
// Функция отвечает за вкл/выкл проигрывания музыки в конференцию во время ожидания
this.ambientMusic = function () {
var p_num = 0;
for (var i in calls) {
if (calls[i].state == 'connected') p_num++;
}
if (p_num == 1) {
// 1 участник - играем музыку
music = VoxEngine.createURLPlayer(MUSIC_URL, true);
music.sendMediaTo(conf);
} else {
// 2+ участников - выключаем музыку
music.stopMediaTo(conf);
}
}
// Начинаем проверку пароля участника (passcode)
this.passcodeCheck = function (call) {
call.say("Thank you! Please specify your passcode, followed by " +
"the pound sign to join the conference.", Language.UK_ENGLISH_FEMALE);
this.handleToneReceivedStage2 = this.handleToneReceivedStage2.bind(this);
call.addEventListener(CallEvents.ToneReceived, this.handleToneReceivedStage2);
this.handleIntroPlayedStage2 = this.handleIntroPlayedStage2.bind(this);
call.addEventListener(CallEvents.PlaybackFinished, this.handleIntroPlayedStage2);
}
// Авторизация входящего вызова - в зависимости от настроек конференции выбираем дальнейшие действия
this.authStep2 = function (call) {
if (anonymousAccess) {
// Анонимные участники - просто подключаем к конференции
this.joinConf(call);
this.getConfCall(call).participant_id = null;
} else {
if (calleridAuth) {
// Проверяем номер телефона участника для авторизации, в случае callerid_auth
var participant_id = this.participantWithParamExists("phone", call.callerid());
if (participant_id != false) {
// Все ок - подключаем участника к конференции
this.joinConf(call);
this.getConfCall(call).participant_id = participant_id;
} else {
// Не удалось найти такого участника - даем возможность ввести пароль участника (passcode)
this.passcodeCheck(call);
}
} else {
// Ввод пароля участника (passcode)
this.passcodeCheck(call);
}
}
}
};
Итак, теперь у нас есть еще 2 сценария — StandaloneConference и VoxConference, чтобы подключить их к обработке звонков нужно создать несколько дополнительных правил для нашего приложения. Назовем их FwdToConf — маска будет conf, и StartConfHTTP — маску сделаем .*, в обоих случаях прикрепляем VoxConference и StandaloneConference:
Все сохраняем и переходим к разворачиванию БД и веб-сервиса. Сервис мы напилили по-быстрому на PHP, для БД взяли MySQL. Помимо разворачивания сервиса потребуется веб-клиент для управления всем этим хозяйством — если не хочется руками в БД все время ковыряться, его мы быстро сделали на ReactJS и Bootstrap. Есть правда один нюанс — придется настроить веб-сервер определенным образом, так как мы используем AJAX + сессию на сервере + crossDomain, иначе браузер будет ругаться.
Для Nginx настройка под наш кросс-доменный AJAX будет выглядеть так:
add_header 'Access-Control-Allow-Origin' $http_origin;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
Все остальные файлы, включая структуру БД для mysql и юзером admin / admin от лица которого можно сразу воспользоваться функционалом конференций, а также готовым веб-приложением можно взять с нашего GitHub-аккаунта:
Проект со всеми файлами на GitHub
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Интересен ли вам данный материал?
60.32% Да, спасибо38
7.94% Нет, не особо5
31.75% Не осилил, много букв20
Проголосовали 63 пользователя. Воздержались 6 пользователей.