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

Введение


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

Но прогресс не стоял на месте. Место старой атс занял 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.
Поделиться с друзьями
-->

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


  1. bat
    07.10.2016 09:15
    +1

    Несколько лет назад подобную задачу решали на nodejs. Не знаю как сейчас, но в тот момент экосистема nodejs для подобных сервисов была предпочтительнее.


    1. bagiroff777
      07.10.2016 09:21

      Даже мне, как пхпшнику, было проще работать с Asterisk'ом с помощью NodeJS. Как-то проще, что ли.


    1. ArchDemon
      07.10.2016 16:52

      Само веб-приложение работает на apache — php — mysql. Так что подключать туда js не хотелось, да и если признаться не очень хорошо я знаю js. Но соглашусь, с websocket работать в nodejs куда удобнее.


      1. bat
        11.10.2016 05:21

        crm была в облаке (sfdc), в ней таких вещей не сделаешь, поэтому решение должно было хоститься на своих мощностях. По этой причине было больше свободы в выборе инструментов. PHP не рассматривали по вышеописанной причине, хотя 2 из 3х разработчиков пхппешники, третий — perl. Прототип на perl пхпешникам не вкатил )), попробовали на nodejs — покатило, даже без экспертных знаний в JS

        зы
        сейчас бы сделал на GO


  1. x893
    07.10.2016 11:30

    Лет 10 назад делал на C#. Вообще без разницы на чем делать. Если что-то может создать TCP клиента — работать будет.


  1. dolphin4ik
    07.10.2016 11:56

    На самом деле могу начаться холивары про демонов на PHP, но нода действительно удобнее. Сменили всех пхп(pami) демонов на аналогичных js -> nami


    1. ArchDemon
      07.10.2016 17:03

      В моё время js не был мейнстримом, а вот php вполне. Исторически сложилось что в php я знаю чуть больше, чем в js.


  1. VolCh
    07.10.2016 12:28

    Ваше решение превращает браузер в телефон?


    1. Ovoshlook
      07.10.2016 14:11

      То есть то, что написано в статье не отговорило вас задать этот вопрос?)


    1. ArchDemon
      07.10.2016 16:57

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

      Так что даже и не знаю, является ли браузер телефоном. При определённой модификации точно им может стать.


      1. Ovoshlook
        08.10.2016 10:25

        Не сможет.
        Чтобы браузер стал телефоном нужна поддержка webRTC и коннект конечных устройств к АТС по webRTC. То есть это перестроение архитектуры вашего приложения.


  1. antirek
    07.10.2016 12:43

    Уведомление о входящем, ответе на него и завершении звонка:
    https://github.com/antirek/asti — сервер
    https://github.com/antirek/asti.js — клиент для браузера
    и да, nodejs

    выложите ваш пример на гитхаб, можно будет попробовать


    1. ArchDemon
      07.10.2016 17:00

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

      С исходниками хуже. Боюсь в компании не поймут, если я выложу готовое рабочее решение. Но и по тому, что я выложил можно сделать рабочий вариант.


      1. antirek
        09.10.2016 18:58
        +1

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

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

        В общем, развития в решении ваших задач. Не останавливайтесь!
        Присоединяйтесь к чату по астериску http://chat.asterisk-support.ru/


  1. ffs
    07.10.2016 13:12

    У меня подобная штука в фирме используется для оповещения о звонках в ЦРМ (веб). Только с событий newstate/hangup/etc мы ушли на CEL events, оказалось удобнее. Звонок в событиях связан одним linked_id и всегда можно легко отследить все события одного звонка. В newstate/hangup/etc, если память не изменяет, нету общего связующего linked_id.


    1. ArchDemon
      07.10.2016 17:02

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


      1. ffs
        07.10.2016 18:21

        Честно говоря, на 100% утверждать не берусь, но вроде было так. События newstate/etc связаны между собой unique_id, которых в процессе разговора может быть несколько (например при любой переадресации заводится новый), а связующий целиком весь звонок linked_id я получил только в CEL ивентах, после их введения и переработки интеграции под них, путаница отпала.
        Нет, можно конечно отслеживать переадресации по спец событиям, в которых есть unique_id1 и unique_id2, но так больше геморроя.


        1. ssh24
          08.10.2016 09:50

          Вы не правы.
          Как правило, один коннекшен Астериска однозначно представлен channelId. Обычно этого достаточно в 99% кейсах.

          Однако есть два НО:
          1. максимальное значение счетчика, использующегося при генерации channel ID не такой уж большое.
          На сверхзагруженных системах этот счетчик периодически может обнуляться.
          2. при перезагрузке Астериска счетчик тоже обнуляется.

          Вот для этих случаев вводится unique_id, который генерится на основе текущего timestamp. Таким образом, связка channelId + unique_id полностью представляет коннекшен.

          Однако в один и тот же момент времени, в Астериске не может быть несколько коннекшенов с одним channel ID. Потому для управления звонками используется только он.

          А unique_id я лично ни разу не пользовался. Хотя уже сделал 4 контект-центра, в основе которых был Астериск, управляемый по АМИ. Нигде это не надо было. Везде было достаточно channel ID.


    1. ssh24
      08.10.2016 09:37

      За наводку на CEL спасибо. Не знал, не пользовался.

      Но в newstate/hangup/etc есть общий связующий параметр — channel ID.


    1. Ovoshlook
      08.10.2016 10:32

      В СEL есть один серьезный недостаток из зак которого этот механизм довольно неудобно использовать в чем то еще кроме логирования:
      Выборки из CEL очень дорогие операции хотя бы по тому что UniqueID и LinkedID это строковые значения.CEL даже при минимальной конфигурации кладет в бд очень много записей (взять chanStart и chanEnd например — это 4 записи на один звонок) соответственно все это дело может вызвать рассинхронизацию при запросе из за long query со всеми вытекающими.


  1. seryh
    07.10.2016 16:40
    -2

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


    1. ArchDemon
      07.10.2016 16:44

      С asterisk столкнулся впервые. Поэтому было приятно почитать на этом сайте статьи данной тематики для первого шага. А так и не знал с какой стороны подступиться. Думаю не мало людей также сталкиваются с подобным. Я добавил ещё один из вариантов со своими преимуществами и недостатками


  1. sowrong
    07.10.2016 16:45

    Делал что-то похожее, только у меня был питон. От самописных демонов отказался, сделал через AGI.
    Между клиентом и астериском у меня поднят centrifugo. Из астериска через dial plan вызывается скрипт, в который передается внутренний номер клиента и входящий номер (долго искал эти значения, помог verbose режим в консоли астериска). Клиент подключается к той же centrifugo на канал со своим внутренним номером и получает эти сообщения.
    А вот исходящие через AMI сделаны.
    В итоге кода минимум — десяток строк на скрипт, пару строк на исходящий (но тут уже заслуги сторонней библиотеки, так бы чуть больше вышло).


    1. ArchDemon
      07.10.2016 16:50

      Я читал и про ARI и про AGI. Ещё был предложен вариант сделать через веб-интерфейс телефонного аппарата, но его я отбросил сразу по целому ряду причин.
      Обмозговав всё, было решено идти через AMI. В asterisk`e не силен и влезать в dial plan не очень хотелось. Так что от AGI я отказался. В ARI я не нашел всё, что мне было нужно.