В нашей компании используется телефон 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); } ...
И добавляем функцию разбора:
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: 30000\n"; } else { echo "retry: 3000\n"; } 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) . "\n\n"; } // Получение телефона клиента 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 для различных браузеров.
- Персонализация входящих звонков.
- Мгновенный поиск номера в базе и возможность быстро перейти к карточке клиента.
- Сотрудники получили полезный сервис оповещения о входящих звонках.