[Из песочницы] Asterisk и информация о входящих звонках в браузере

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

Введение


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

Но прогресс не стоял на месте. Место старой атс занял Asterisk 13. Мне же необходимо было:
  • пробросить информацию о входящем в веб-приложение
  • добавить возможность исходящего вызова из веб-приложения

Чего хотели этим добиться:
  • Сократить время обработки звонков
  • Сократить количество ошибок при записи клиентов
  • Сократить время на обзвон клиентов

Инструменты


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

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

Концепция


Демон с 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 = $('
', {id: 'asterisk-content', style: 'text-align: center;'}); var widget = $('
', {id: 'asterisk-popup', class: 'popup-box noprint', style: 'min-height: 180px;'}) .append($('
', {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($('

', {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($('

').html('Входящий
' + p)) .append($('

').html($('', {href: '?module=clients&search=' + p, class: 'button'}) .html($('', {src: '/images/icons/find.png'})).append(' Поиск'))); input.val(p); show(); isIdle = false; break; case 'hangup': if (!isIdle) { content.html($('

', {text: 'Завершено'})); hide(); isIdle = true; } break; default: console.log('Unknown event' + msg.event); } }; ws.onerror = function(evt) { content.html($('

', {text: 'Ошибка'})); hide(); console.log('Asterisk: error', evt); }; return ws; }; };



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

Заключение


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

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


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

P.S.


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

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

  • 7 октября 2016 в 09:15

    0

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

      0

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

© Habrahabr.ru