В нашей компании используется телефон 8800, для того, чтобы клиенты могли сделать заказ без доступа к сайту. Для обслуживания большинства входящих звонков используется call-центр, а также при необходимости происходит перенаправление на внутреннего сотрудника.
Для удобства сотрудников и возможности персонализированного ответа была внедрена система распознавания входящего звонка по внутренней базе клиентов.
Так как cron задания были бы слишком редкими (максимум 1 раз в секунду), то за основу был взят демон на php, который сканирует каналы и отправляет информацию о звонке во временное хранилище. Для временного хранилища был использован memcached.
Используемая версия Asterisk’a — 11.15.1.
В качестве API связки php и Asteriska’a — модуль PAMI.
Основной класс демона прослушки:
class AsteriskDaemon
{
private $asterisk;
private $memcache;
public function __construct()
{
$this->asterisk = new ClientImpl([
...
]);
$memcache = new Memcached;
$memcache->connect('127.0.0.1', '11211');
$this->memcache = $memcache;
}
public function start()
{
$asterisk = $this->asterisk;
$loop = Factory::create();
// add periodic timer
$loop->addPeriodicTimer(1, function () use (&$asterisk) {
$pid = pcntl_fork();
if ($pid < 0) { // ошибка создания exit;
}elseif ($pid) { // родитель, ждет выполнения потомков
pcntl_waitpid($pid, $status, WUNTRACED);
if ($status > 0) {
// если произошла ошибка в канале, пересоздаем
$asterisk->close();
usleep(1000);
$asterisk->open();
}
return;
} else {
// выполнение дочернего процесса
try {
$asterisk->process();
exit(0);
} catch (Exception $e) {
exit(1);
}
}
});
// восстановление подпроцессов
$loop->addPeriodicTimer(30, function () {
while (($pid = pcntl_waitpid(0, $status, WNOHANG)) > 0) {
echo "process exit. pid:" . $pid . ". exit code:" . $status . "n";
}
});
$loop->run();
}
}
Существует два возможных варианта распознавания: прослушивание событий каналов и ручной разбор информации в CoreShowChannel, рассмотрим все по порядку.
Прослушивание событий
В конструктор демона добавляем инициализацию слушателя событий AsteriskEventListener:
...
$this->asterisk->registerEventListener(new AsteriskEventListener($memcache), function (EventMessage $event) {
// Прослушивание только события операций с каналами
return $event instanceof BridgeEvent;
});
$this->asterisk->open();
...
И соответственно сам класс прослушивания и работы с временным хранилищем:
class AsteriskEventListener implements IEventListener
{
private $memcache;
private $bridges = [];
public function __construct($memcache)
{
$this->memcache = $memcache;
}
private function addBridge($phone1, $phone2)
{
$bFind = false;
if ($this->bridges) {
foreach ($this->bridges as $bridge) {
if (in_array($phone1, $bridge) && in_array($phone2, $bridge)) {
$bFind = true;
}
}
}
if (!$bFind) {
$this->bridges[] = [
$phone1,
$phone2
];
$bFind = true;
}
return $bFind;
}
private function deleteBridge($phone1, $phone2 = null)
{
if ($this->bridges) {
foreach ($this->bridges as $key => $bridge) {
if (in_array($phone1, $bridge) && (!$phone2 || ($phone2 && in_array($phone2, $bridge)))) {
unset($this->bridges[$key]);
}
}
}
}
public function handle(EventMessage $event)
{
// Делаем распознавание, если пришло событие создания/удаления канала
if ($event instanceof BridgeEvent) {
$this->bridges = $this->memcache->getKey('asterisk-bridges');
$state = $event->getBridgeState();
$caller1 = $event->getCallerID1();
$caller2 = $event->getCallerID2();
if ($state == 'Link') { // Создание канала
$this->addBridge($caller1, $caller2);
} else { // Удаление канала
$this->deleteBridge($caller1, $caller2);
}
$this->memcache->setKey('asterisk-bridges', $this->bridges);
}
}
}
В данном варианте возможны проблемы при создании каналов. Дело в том, что когда происходит перенаправление звонка между сотрудниками или перенаправление с call-центра на сотрудника оба канала будут созданы в связке с тем, кто перенаправлял, и никакой информации о результирующей связке оператора и клиента не будет.
Ручной разбор информации CoreShowChannel
Для работы данного способа необходимо несколько модифицировать демон, вызываем событие CoreShowChannel принудительно, так как сам Asterisk его не генерирует:
...
// дочерний процесс выполняет процесс
try {
$message = $asterisk->send(new CoreShowChannelsAction());
$events = $message->getEvents();
$this->parse($events);
$asterisk->process();
exit(0);
} catch (Exception $e) {
exit(1);
}
...
<source>
И добавляем функцию разбора:
<source lang="php">
private function parse($events)
{
foreach ($events as $event) {
if ($event instanceof CoreShowChannelEvent) {
$caller1 = $event->getKey('CallerIDnum');
$caller2 = $event->getKey('ConnectedLineNum');
$this->bridges = $this->memcache->getKey('asterisk-bridges');
$this->addBridge($caller1, $caller2);
$this->memcache->setKey('asterisk-bridges', $this->bridges);
}
}
}
В данном способе есть проблема удаления номера телефона при отключении клиента от канала. Для решения можно использовать событие разрыва соединения:
...
$this->asterisk->registerEventListener(new AsteriskEventListener(), function (EventMessage $event) {
return $event instanceof HangupEvent;
});
$this->asterisk->open();
...
Обработка события разрыва соединения:
...
public function handle(EventMessage $event)
{
if ($event instanceof HangupEvent) {
$this->bridges = $this->memcache->getKey('asterisk-bridges');
$caller1 = $event->getKey('CallerIDNum');
$caller2 = $event->getKey('ConnectedLineNum');
$this->deleteBridge($caller1);
$this->deleteBridge($caller2);
$this->memcache->setKey('asterisk-bridges', $this->bridges);
}
}
...
В итоге оказалось, что второй способ является более эффективным, так как при работе с событиями asterisk часто падал, и, в результате, терялись некоторые звонки. Так же в первом способе не распознавались звонки при перенаправлении с call-центра, так как номер сотрудника и клиента были в разных каналах (Первый канал связывает call-центр и сотрудника, второй канал связывает call-центр и клиента).
Информация о звонке через Notifications
Для получения информации о входящих звонках был использован плагин event-source-polyfill и long-pull запросы на сервер. Напомню мы храним входящие звонки в memcached.
Практика показала, что если сотрудник открывает много вкладок то генерируется большое количество запросов. Для предотвращения этого был использован плагин wormhole, который передает информацию о канале между вкладками.
Получился следующий скрипт:
(function ($) {
$.getCall = function () {
if (localStorage.callTitle !== undefined && localStorage.callSuccess === undefined) {
var notification,
title = localStorage.callTitle,
options = {
body: localStorage.callText,
icon: localStorage.callImage
},
eventNotification = function () {
window.open(localStorage.callUrl);
};
if (!('Notification' in window)) {
console.error('This browser does not support desktop notification');
} else if (Notification.permission === 'granted') {
notification = new Notification(title, options);
notification.onclick = eventNotification;
} else if (Notification.permission !== 'denied') {
Notification.requestPermission(function (permission) {
if (permission === 'granted') {
notification = new Notification(title, options);
notification.onclick = eventNotification;
}
});
}
localStorage.callSuccess = true;
}
};
// запросы к серверу только на главной вкладке
wormhole().on('master', function () {
var es = new EventSource('/check-call');
es.addEventListener('message', function (res) {
var data = JSON.parse(res.data);
if (data['id']) {
localStorage.callTitle = data['title'];
localStorage.callText = data['text'];
localStorage.callImage = data['img'];
localStorage.callUrl = data['url'];
} else {
delete localStorage.callTitle;
delete localStorage.callText;
delete localStorage.callImage;
delete localStorage.callUrl;
delete localStorage.callSuccess;
}
});
});
})(jQuery);
setInterval(function () {
$.getCall();
}, 1000);
И собственно сам обработчик запросов:
public function checkCall()
{
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Access-Control-Allow-Origin: *');
// получение номера текущего оператора
$managerPhone = $_SESSION['phone'];
$user = null;
$clientPhone = $this->getPhone($managerPhone);
if ($clientPhone) {
$user = User::find()->where(['phone' => $clientPhone])->one();
}
if ($user) { // Увеличиваем время до следующего вызова если клиент найден
echo "retry: 30000n";
} else {
echo "retry: 3000n";
}
echo 'id: ' . $managerPhone . "n";
$data = [];
if ($user) {
$data = [
'id' => $user['id'],
'title' => 'Новый звонок от ' . $user['name'],
'text' => 'Перейти к карточке клиента',
'img' => '/phone.png',
'url' => '/user/' . $user['id']
];
}
echo "data: " . json_encode($data) . "nn";
}
// Получение телефона клиента
public function getPhone($managerPhone)
{
$memcache = new Memcached;
$memcache->addServer('127.0.0.1', '11211');
$extPhone = '';
if (!$managerPhone) {
return $extPhone;
}
$bridges = $memcache->getKey('asterisk-bridges');
if (!isset($bridges) || !is_array($bridges)) {
return $extPhone;
}
foreach ($bridges as $bridge) {
if (($key = array_search($managerPhone, $bridge)) !== false) {
$extPhone = $bridge[!$key];
break;
}
}
return $extPhone;
}
Итоги внедрения
- Достаточно интересный опыт работы с Asterisk’ом и системой Notifications для различных браузеров.
- Персонализация входящих звонков.
- Мгновенный поиск номера в базе и возможность быстро перейти к карточке клиента.
- Сотрудники получили полезный сервис оповещения о входящих звонках.
Автор: mendler