Asterisk и информация о входящих звонках в браузере

Прочитав заголовок, вы, наверное, подумаете «Избитая тема, да сколько можно об это писать», но всё равно не смог не поделиться своими велосипедами с костылями наработками.

Введение


В нашей компании запись клиентов осуществлялась по телефону через мини-атс (я в этом деле не силен и могу ошибаться). Все заказы сохранялись в базу данных, интерфейсом служит веб-приложение. Плотность звонков в определенные моменты бывает очень высока и диспетчеры, в силу человеческого фактора, не всегда правильно или не с первого раза записывают телефон клиента (когда он отображается на экране телефона).

Но прогресс не стоял на месте. Место старой атс занял Asterisk 13. Мне же необходимо было:

  • пробросить информацию о входящем в веб-приложение
  • добавить возможность исходящего вызова из веб-приложения

Чего хотели этим добиться:

  • Сократить время обработки звонков
  • Сократить количество ошибок при записи клиентов
  • Сократить время на обзвон клиентов

Инструменты


Прочитав несколько статей, например, вот эту решил «а чем я хуже?» и нашёл свое видение решения задачи.

Решил остановиться на связке asterisk — pamiratchet

Концепция


Демон с pami прослушивает asterisk на предмет входящих звонков. Параллельно крутиться websocket сервер. При поступлении входящего звонка информация разбирается и отправляется websocket клиенту (если таковой имеется).

Реализация


Демон asteriska
namespace Asterisk;

use PAMI\Client\Impl\ClientImpl as PamiClient;
use PAMI\Message\Event\EventMessage;
use PAMI\Message\Event\HangupEvent;

use PAMI\Message\Event\NewstateEvent;
use PAMI\Message\Event\OriginateResponseEvent;
use PAMI\Message\Action\OriginateAction;
use React\EventLoop\Factory;

class AsteriskDaemon {
    private $asterisk;
    private $server;
    private $loop;
    private $interval = 0.1;
    private $retries = 10;

    private $options = array(
        'host' => 'host',
        'scheme' => 'tcp://',
        'port' => 5038,
        'username' => 'user',
        'secret' => ' password',
        'connect_timeout' => 10000,
        'read_timeout' => 10000
    );

    private $opened = FALSE;
    private $runned = FALSE;

    public function __construct(Server $server)
    {
        $this->server = $server;
        $this->asterisk = new PamiClient($this->options);
        $this->loop = Factory::create();

        $this->asterisk->registerEventListener(new AsteriskEventListener($this->server),
                function (EventMessage $event) {
            return $event instanceof NewstateEvent
                    || $event instanceof HangupEvent;
        });

        $this->asterisk->open();
        $this->opened = TRUE;
        $asterisk = $this->asterisk;
        $retries = $this->retries;
        $this->loop->addPeriodicTimer($this->interval, function () use ($asterisk, $retries) {
            try {
                $asterisk->process();
            } catch (Exception $exc) {
                if ($retries-- <= 0) {
                    throw new \RuntimeException('Exit from loop', 1, $exc);
                }
                sleep(10);
            }
        });
    }

    public function __destruct() {
        if ($this->loop && $this->runned) {
            $this->loop->stop();
        }

        if ($this->asterisk && $this->opened) {
            $this->asterisk->close();
        }
    }

    public function run() {
        $this->runned = TRUE;
        $this->loop->run();
    }

    public function getLoop() {
        return $this->loop;
    }
}


Служит для периодического опроса asterisk`a на предмет нужных нам событий. Я если честно, не буду утверждать правильные ли я события взял, но с этими всё работало. Просто похожую информацию можно достать из многих событий в зависимости от того, что именно вам нужно.

Слушатель событий
namespace Asterisk;

use PAMI\Message\Event\EventMessage;
use PAMI\Listener\IEventListener;
use PAMI\Message\Event\NewstateEvent;
use PAMI\Message\Event\HangupEvent;
use PAMI\Message\Event\OriginateResponseEvent;

class AsteriskEventListener implements IEventListener
{
    private $server;

    public function __construct(Server $server)
    {
        $this->server = $server;
    }

    public function handle(EventMessage $event)
    {
        // getChannelState 6 = Up getChannelStateDesc()
        // TODO можно попробовать событие BridgeEnterEvent
        if ($event instanceof NewstateEvent && $event->getChannelState() == 6) {
            $client = $this->server->getClientById($event->getCallerIDNum());
            if (!$client) {
                return;
            }

            $client->setMessage($event);
        // TODO можно попробовать событие BridgeLeaveEvent
        } elseif ($event instanceof HangupEvent) {
            $client = $this->server->getClientById($event->getCallerIDNum());
            if (!$client) {
                return;
            }

            $client->setMessage($event);
        } 
    }
}


Ну тут тоже всё понятно. События мы получили. Теперь их нужно обработать. Кто такой server станет понятнее ниже.

Websocket сервер
namespace Asterisk;

use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;

class Server implements MessageComponentInterface
{
    /**
     * Клиенты соединения
     * @var SplObjectStorage
     */
    private $clients;
    /**
     * Клиент для подключения к asterisk
     * @var AsteriskDaemon
     */
    private $daemon;

    public function __construct()
    {
        $this->clients = new \SplObjectStorage;
        $this->daemon = new AsteriskDaemon($this);
    }

    function getLoop() {
        return $this->daemon->getLoop();
    }

    public function onOpen(ConnectionInterface $conn)
    {
        //echo "Open\n";
    }

    public function onMessage(ConnectionInterface $from, $msg)
    {
        //echo "Message\n";
        $json = json_decode($msg);
        if (json_last_error()) {
            echo "Json error: " . json_last_error_msg() . "\n";
            return;
        }
        switch ($json->Action) {
            case 'Register':
                //echo "Register client\n";
                $client = $this->getClientById($json->Id);
                if ($client) {
                    if ($client->getConnection() != $from) {
                        $client->setConnection($from);
                    }
                    $client->process();
                } else {
                    $this->clients->attach(new Client($from, $json->Id));
                }
                break;

            default:
                break;
        }
    }

    public function onClose(ConnectionInterface $conn)
    {
        //echo "Close\n";
        $client = $this->getClientByConnection($conn);
        if ($client) {
            $client->closeConnection();
        }
    }

    public function onError(ConnectionInterface $conn, \Exception $e)
    {
        echo "Error: " . $e->getMessage() . "\n";
        $client = $this->getClientByConnection($conn);
        if ($client) {
            $client->closeConnection();
        }
    }

    /**
     *
     * @param ConnectionInterface $conn
     * @return \Asterisk\Client or NULL
     */
    public function getClientByConnection(ConnectionInterface $conn) {
        $this->clients->rewind();
        while($this->clients->valid()) {
            $client = $this->clients->current();
            if ($client->getConnection() == $conn) {
                //echo "Client found by connection\n";
                return $client;
            }
            $this->clients->next();
        }

        return NULL;
    }

    /**
     *
     * @param string $id
     * @return \Asterisk\Client or NULL
     */
    public function getClientById($id) {
        $this->clients->rewind();
        while($this->clients->valid()) {
            $client = $this->clients->current();
            if ($client->getId() == $id) {
                //echo "Client found by id\n";
                return $client;
            }
            $this->clients->next();
        }

        return NULL;
    }
}


Собственно наш websocket сервер. Не стал заморачиваться с форматом обмена, выбрал JSON. Здесь стоит обратить внимание, что у клиентов перезаписывается соединение с сервером. Это позволяет не плодить ответы при открытии многих вкладок в браузере.

Websocket клиент
namespace Asterisk;

use Ratchet\ConnectionInterface;
use PAMI\Message\Event\EventMessage;
use PAMI\Message\Event\NewstateEvent;
use PAMI\Message\Event\HangupEvent;
use PAMI\Message\Event\OriginateResponseEvent;

class Client {
    /**
     * Последнее сообщения
     * @var PAMI\Message\Event\EventMessage
     */
    private $message;
    /**
     * Соединение с сокетом
     * @var Ratchet\ConnectionInterface
     */
    private $connection;
    /**
     * Идентификатор телефонной линии
     * @var string
     */
    private $id;
    /**
     * Дата последней активности. Не используется
     * @var int
     */
    private $lastactive;

    public function __construct(ConnectionInterface $connection, $id=NULL) {
        $this->connection = $connection;

        if ($id) {
            $this->id = $id;
        }

        $this->lastactive = time();
    }

    function getConnection() {
        return $this->connection;
    }

    function setConnection($connection) {
        $this->connection = $connection;
    }

    function closeConnection() {
        $this->connection->close();
        $this->connection = NULL;
    }

    public function getMessage() {
        return $this->message;
    }

    public function setMessage(EventMessage $message) {
        $this->message = $message;
        $this->process();
    }

    public function process() {
        if (!$this->connection || !$this->message) {
            return;
        }

        if ($this->message instanceof NewstateEvent) {
            $message = array('event' => 'incoming',
                'value' => $this->message->getConnectedLineNum());
        } elseif ($this->message instanceof HangupEvent) {
            $message = array('event' => 'hangup');
        } else {
            return;
        }

        $json = json_encode($message);
        $this->connection->send($json);
    }

    function getId() {
        return $this->id;
    }

    function setId($id) {
        $this->id = $id;
    }
}


Ну тут не знаю что и добавить. id — идентификатор телефона диспетчера. Необходим, чтобы определять к какому именно из диспетчеров поступил вызов.

Теперь запускаем ракету
require_once implode(DIRECTORY_SEPARATOR, array(__DIR__ , 'vendor', 'autoload.php'));

//use Ratchet\Server\EchoServer;
use Asterisk\Server;

try {
    $server = new Server();

    $app = new Ratchet\App('192.168.0.241', 8080, '192.168.0.241', $server->getLoop());
    $app->route('/asterisk', $server, array('*'));
    $app->run();

} catch (Exception $exc) {
    $error = "Exception raised: " . $exc->getMessage()
            . "\nFile: " . $exc->getFile()
            . "\nLine: " . $exc->getLine() . "\n\n";
    echo $error;
    exit(1);
}


Тут стоить отметить что websocket сервер и наш asterisk демон используют общий поток (loop). Иначе кто-то бы из них не заработал.

А как там дела в веб-приложении?


Ну тут всё просто. Не буду грузить информацией о том, как вытащить информацию о клиенте по номеру телефона и прочей ерундой.

Скрипт уведомления
function Asterisk(address, phone) {
    var delay = 3000;
    var isIdle = true, isConnected = false;

    var content = $('<div/>', {id: 'asterisk-content', style: 'text-align: center;'});
    var widget = $('<div/>', {id: 'asterisk-popup', class: 'popup-box noprint', style: 'min-height: 180px;'})
                .append($('<div/>', {class: 'header', text: 'Телефон'}))
                .append(content).hide();
    var input = $('#popup-addorder').find('input[name=phone]');

    var client = connect(address, phone);

    $('body').append(widget);

    function show() { widget.stop(true).show(); };
    function hide() { widget.show().delay(delay).fadeOut(); };

    function connect(a, p) {
        if (!a || !p) {
            console.log('Asterisk: no address or phone');
            return null;
        }

        var ws = new WebSocket('wss://' + a + '/wss/asterisk');
        ws.onopen = function() {
            isConnected = true;
            this.send(JSON.stringify({Action: 'Register', Id: p}));
        };
        ws.onclose = function() {
            isConnected = false;
            content.html($('<p/>', {text: 'Отключено'}));
            hide();
        };
        ws.onmessage = function(evt) {
            var msg = JSON.parse(evt.data);
            if (!msg || !msg.event) {
                return;
            }

            switch (msg.event) {
                case 'incoming':
                    var p = msg.value;
                    content.html($('<p/>').html('Входящий<br>' + p))
                            .append($('<p/>').html($('<a/>', {href: '?module=clients&search=' + p, class: 'button'})
                            .html($('<img/>', {src: '/images/icons/find.png'})).append(' Поиск')));
                    input.val(p);
                    show();
                    isIdle = false;
                    break;
                case 'hangup':
                    if (!isIdle) {
                        content.html($('<p/>', {text: 'Завершено'}));
                        hide();
                        isIdle = true;
                    }
                    break;
                default:
                    console.log('Unknown event' + msg.event);
            }
        };
        ws.onerror = function(evt) {
            content.html($('<p/>', {text: 'Ошибка'}));
            hide();
            console.log('Asterisk: error', evt);
        };

        return ws;
    };
};


phone — идентификатор телефона диспетчера.

Заключение


Поставленных целей я добился. Работает местами даже лучше чем я предполагал.

Что не вошло в статью, но что было сделано


  • Настройка asterisk`a для подключения через ami
  • Исходящий вызов через originate
  • Bash скрипт для мониторинга работы демона и его подъема при падении

P.S.


Не суди строго за качество кода. Пример показывает исключительно концепцию, хотя успешно работает в продакшене. Для меня это был прекрасный опыт работы с asterisk и websocket.
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 25

    +1
    Несколько лет назад подобную задачу решали на nodejs. Не знаю как сейчас, но в тот момент экосистема nodejs для подобных сервисов была предпочтительнее.
      0
      Даже мне, как пхпшнику, было проще работать с Asterisk'ом с помощью NodeJS. Как-то проще, что ли.
        0
        Само веб-приложение работает на apache — php — mysql. Так что подключать туда js не хотелось, да и если признаться не очень хорошо я знаю js. Но соглашусь, с websocket работать в nodejs куда удобнее.
          0
          crm была в облаке (sfdc), в ней таких вещей не сделаешь, поэтому решение должно было хоститься на своих мощностях. По этой причине было больше свободы в выборе инструментов. PHP не рассматривали по вышеописанной причине, хотя 2 из 3х разработчиков пхппешники, третий — perl. Прототип на perl пхпешникам не вкатил )), попробовали на nodejs — покатило, даже без экспертных знаний в JS

          зы
          сейчас бы сделал на GO
        0
        Лет 10 назад делал на C#. Вообще без разницы на чем делать. Если что-то может создать TCP клиента — работать будет.
          0
          На самом деле могу начаться холивары про демонов на PHP, но нода действительно удобнее. Сменили всех пхп(pami) демонов на аналогичных js -> nami
            0
            В моё время js не был мейнстримом, а вот php вполне. Исторически сложилось что в php я знаю чуть больше, чем в js.
            0
            Ваше решение превращает браузер в телефон?
              0
              То есть то, что написано в статье не отговорило вас задать этот вопрос?)
                0
                Моё решение позволяет видеть телефонный номер в веб-приложении и искать по номеру клиента.
                Моё решение позволяет дозваниваться с телефонного аппарата (физического) до клиента. При этом вызов инициируется из веб-приложения т.е. диспетчеру не нужно набирать номер на телефонном аппарате (физически тыкать пальцами не нужно), а разговор всё также идёт через трубку (не софт телефон).

                Так что даже и не знаю, является ли браузер телефоном. При определённой модификации точно им может стать.
                  0
                  Не сможет.
                  Чтобы браузер стал телефоном нужна поддержка webRTC и коннект конечных устройств к АТС по webRTC. То есть это перестроение архитектуры вашего приложения.
                0
                Уведомление о входящем, ответе на него и завершении звонка:
                https://github.com/antirek/asti — сервер
                https://github.com/antirek/asti.js — клиент для браузера
                и да, nodejs

                выложите ваш пример на гитхаб, можно будет попробовать
                  0
                  Вашу библиотеку видел. Но как я писал выше — nodejs не моё. Поэтому пришлось отказаться.

                  С исходниками хуже. Боюсь в компании не поймут, если я выложу готовое рабочее решение. Но и по тому, что я выложил можно сделать рабочий вариант.
                    +1
                    nodejs и не моё: ) просто как инструмент для подобной задачи оказался проще, чем php (phpDaemon, Ratchet), python (twisted, tornado) (тем более что это все в той или иной мере использую достаточно регулярно). Но всему свое время.

                    По поводу рабочего решения — вы же уже выложили все, только по кусочкам, уже могут не понять — теперь эти же файлы в репо на гитхаб. И всё.

                    В общем, развития в решении ваших задач. Не останавливайтесь!
                    Присоединяйтесь к чату по астериску http://chat.asterisk-support.ru/
                  0
                  У меня подобная штука в фирме используется для оповещения о звонках в ЦРМ (веб). Только с событий newstate/hangup/etc мы ушли на CEL events, оказалось удобнее. Звонок в событиях связан одним linked_id и всегда можно легко отследить все события одного звонка. В newstate/hangup/etc, если память не изменяет, нету общего связующего linked_id.
                    0
                    Вот это интересно. Я если честно дампил все события и выбирал те, что мне подходят. И нужную мне информацию можно выло взять из многих событий. Спасибо за наводку.
                      0
                      Честно говоря, на 100% утверждать не берусь, но вроде было так. События newstate/etc связаны между собой unique_id, которых в процессе разговора может быть несколько (например при любой переадресации заводится новый), а связующий целиком весь звонок linked_id я получил только в CEL ивентах, после их введения и переработки интеграции под них, путаница отпала.
                      Нет, можно конечно отслеживать переадресации по спец событиям, в которых есть unique_id1 и unique_id2, но так больше геморроя.
                      • UFO just landed and posted this here
                    • UFO just landed and posted this here
                        0
                        В СEL есть один серьезный недостаток из зак которого этот механизм довольно неудобно использовать в чем то еще кроме логирования:
                        Выборки из CEL очень дорогие операции хотя бы по тому что UniqueID и LinkedID это строковые значения.CEL даже при минимальной конфигурации кладет в бд очень много записей (взять chanStart и chanEnd например — это 4 записи на один звонок) соответственно все это дело может вызвать рассинхронизацию при запросе из за long query со всеми вытекающими.
                        –2

                        Тоже решал подобную задачу еще года 3 тому назад на nodejs, даже в мыслях не было что подобная поделка на пару часов работы, заинтересует кого-либо на хабре.

                          0
                          С asterisk столкнулся впервые. Поэтому было приятно почитать на этом сайте статьи данной тематики для первого шага. А так и не знал с какой стороны подступиться. Думаю не мало людей также сталкиваются с подобным. Я добавил ещё один из вариантов со своими преимуществами и недостатками
                          0
                          Делал что-то похожее, только у меня был питон. От самописных демонов отказался, сделал через AGI.
                          Между клиентом и астериском у меня поднят centrifugo. Из астериска через dial plan вызывается скрипт, в который передается внутренний номер клиента и входящий номер (долго искал эти значения, помог verbose режим в консоли астериска). Клиент подключается к той же centrifugo на канал со своим внутренним номером и получает эти сообщения.
                          А вот исходящие через AMI сделаны.
                          В итоге кода минимум — десяток строк на скрипт, пару строк на исходящий (но тут уже заслуги сторонней библиотеки, так бы чуть больше вышло).
                            0
                            Я читал и про ARI и про AGI. Ещё был предложен вариант сделать через веб-интерфейс телефонного аппарата, но его я отбросил сразу по целому ряду причин.
                            Обмозговав всё, было решено идти через AMI. В asterisk`e не силен и влезать в dial plan не очень хотелось. Так что от AGI я отказался. В ARI я не нашел всё, что мне было нужно.
                            0

                            В свое время тоже написал три варианта для различных интеграций — демон, прослушивающий ami; вызовы agi из диаплана; асинхронные вызовы скриптов через вебсервер из диаплана через system — wget.


                            Последнее показало лучшую производительность при прочих равных. Один нагруженный проект прошел эти три стадии и остановился на последнем варианте.

                            Only users with full accounts can post comments. Log in, please.