Как стать автором
Обновить
69.84
Voximplant
Облачная платформа голосовой и видеотелефонии

Сервис управляемых аудио-конференций своими руками

Время на прочтение20 мин
Количество просмотров8.4K
Аудио-конференции — удобный инструмент для решения ряда бизнес задач, большинство привыкло пользоваться чем-нибудь готовым, например, 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 для конференций нужно разобраться как они устроены. Конференции в 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&params=' + 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&params=" + 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&params=" + 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&params=" + 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&params=" + 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&params=" + 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&params=" + 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 пользователей.
Теги:
Хабы:
Всего голосов 4: ↑3 и ↓1+2
Комментарии0

Публикации

Информация

Сайт
www.voximplant.com
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия

Истории