В нашей компании используется телефон 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 его не генерирует:

Генерация события CoreShowChannels
...
// дочерний процесс выполняет процесс
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);


Обработчик long-pull запросов
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 для различных браузеров.
  • Персонализация входящих звонков.
  • Мгновенный поиск номера в базе и возможность быстро перейти к карточке клиента.
  • Сотрудники получили полезный сервис оповещения о входящих звонках.

Комментарии (19)


  1. koceg
    09.03.2016 14:38

    Возможно, стоит вынести клиентский код в расширение для браузера — тогда не нужен будет wormhole и уведомления будут работать на любых сайтах.


    1. koceg
      09.03.2016 14:45

      Это, конечно, от задачи зависит. Мы реализовывали подобную систему, но у нас были совсем другие задачи и условия, поэтому и решение в итоге получилось совсем другое.


  1. AlexGx
    09.03.2016 14:44

    Интересный подход, реализовали что-то подобное (у нас не входящие звонки, а пропущенные вываливаются в хром-плагин), но совсем другими средствами.


    1. koceg
      09.03.2016 14:47

      Если пропущенные, значит, смотрите в CDR?
      У вас есть что-нибудь в паблике, а то мне тоже скоро предстоит эта задача, может, удастся сэкономить какое-то время.


      1. AlexGx
        09.03.2016 14:53

        у нас «самодельный» CDR, тк в астерисковом много лишних данных, если интересно можем обменяться опытом, пишите в личку.


        1. Bonio
          09.03.2016 22:49

          Было бы интересно почитать про самодельный CDR.


  1. antirek
    09.03.2016 17:31

    Т.е. вы раз в 1 секунду бомбите астериск запросами CoreShowChannels, чтобы получить актуальные данные?
    А еще зачем вам memcache, если у вас есть демон? Все актуальные данные о звонках вы можете хранить в памяти демона, не так ли?
    Почему выбрали SSE, а не websockets?
    Исходников на гитхабе не будет (попробовал бы ваше решение, но выдергивать из статьи долго)?


    1. DoMoVoY
      09.03.2016 22:26
      +1

      Действительно было бы полезно более целостное решение в виде исходников посмотреть. Сэкономит массу времени. Желательно с версткой.


    1. mendler
      10.03.2016 20:15

      Приходится бомбить, другого решения на тот момент не нашли. При event'ах теряется информация и оповещение приходит намного позже. Интеграция через push api от Asterisk'а в процессе. Возможно получится интегрировать и получение звонка.
      С демонами столкнулся первый раз, тем более на php, поэтому был выбран memcache для передачи данных. А как получать данные на странице из демона тогда?
      SSE как временное быстрое решение, дальше думаем подключить websocket, и вообще переписать на Go.
      Как время появится, выложу на github.


      1. antirek
        15.03.2016 11:41

        А как получать данные на странице из демона тогда?

        демон по идее может делать несколько дел одновременно: 1) пулять запросы в астериск и обрабатывать ответы на них, 2) быть веб-сервером, который делает SSE и/или websocket.

        Как время появится, выложу на github.

        А я стал практиковать другой подход: все временное и быстрое на гитхабе, а уже как становится близко к рабочему варианту уходит в закрытые репо.: ) Поэтому на ранних этапах всегда можно спросить, обсудить, поделиться, а затем уже прятать "сокровище". Например, у меня есть похожий проект: https://github.com/antirek/asti на node.js, который получает от астериска события очередей Queue и отдает их подключившимся клиентам по ws через js-либу https://github.com/antirek/asti.js В итоге на какой-либо странице подключаем asti.js и вешаем обработчики на определенные события, т.е. делаем всплывающие подсказки, моргания, запрос доп.данных из CRM.


  1. fforp
    09.03.2016 20:43
    +1

    Поправьте если ошибаюсь: событие Bridge с состоянием Link генерится когда 2й абонент (которому звонят) поднимает трубку, т.е. и всплывающие сообщения у вас отображаются только после того как сотрудник снимет трубку ?

    Обновите Asterisk, с 12й версии в AMI появилось событие AttendedTransfer, которое позволит вам подставлять/анализировать правильный номер телефона при переводе звонка между сотрудниками.


    1. mendler
      10.03.2016 19:55

      Да, Link генерируется после установки определенного соединения. Это еще одно условие, почему остановились в итоге на CoreShowChannels, при данном подходе сообщение показывается уже при начале звонка.

      Спасибо, я читал про 12-ю версию, там все намного лучше, но к сожалению возможности обновления Asterisk'а нет, так как он находится не под нашим контролем, поэтому приходится писать вот такие штуки :)


      1. fforp
        11.03.2016 11:22

        Попробуйте тогда NewState, это событие уже есть в 11й версии.
        Схема с прослушиванием событий AMI всё таки элегантнее, чем дергать asterisk -rx "core show channels"


  1. Ovoshlook
    09.03.2016 20:45

    можно же перед тем как dial к менеджеру вызывать отправить сообщение на сервер который отправит инфу в веб менеджера.
    Тогда не надо бомбить астериск по АМИ. Он сам все сделает.


    1. mendler
      10.03.2016 19:58

      Интеграция через api в процессе, необходимо было быстрое решение.


  1. fleaump
    10.03.2016 00:09

    Я бы лучше диалплан поправил и вписал бы в него оповещения во внешнюю систему — http://www.voip-info.org/wiki/view/Asterisk+cmd+System, в браузер воткнул бы pushbullet, а через setvar в параметрах пира бы токен вписывал. Трудозатраты вся конструкция бы заняла минут 5.

    Из практики PAMI php на 1.8 астере вызывал иногда проблемы со стабильностью АТС.


    1. koceg
      10.03.2016 09:10
      +1

      Скорее всего, проблемы со стабильностью вызываются тем, что клиенты в конце скрипта не отключаются и их копятся тысячи. Мы наступили на эти грабли, но ни на что, кроме этого, пользователи не жаловались никогда.


  1. dlap
    10.03.2016 09:13
    +1

    Я тоже делал нечто такое, если кому интересно, демка лежит: http://callcenter.softcom.lv/
    Она правда корявая, но если выбирать "зайти под оператором" а не админом то иде будет ясна.

    Мы использовали именно уведомление от астериска, на каждый входящий звонок в диалпланах я делал post запрос на связку NodeJS + laravel, а они фигачат уже уведомления операторам.

    На Git пока выкладывать не хочу, потому что ещё надеюсь на этом немного монет заработать, когда доведу до ума версию :) но консультации дать могу


    1. antirek
      10.03.2016 12:19

      позалипал на анимации подгрузки данных: )