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

в 5:24, , рубрики: asterisk, pami, php, ratchet, Клиентская оптимизация, Разработка веб-сайтов

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

Введение

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

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

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

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

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

Инструменты

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

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

Концепция

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

Реализация

Демон asteriska
namespace Asterisk;

use PAMIClientImplClientImpl as PamiClient;
use PAMIMessageEventEventMessage;
use PAMIMessageEventHangupEvent;

use PAMIMessageEventNewstateEvent;
use PAMIMessageEventOriginateResponseEvent;
use PAMIMessageActionOriginateAction;
use ReactEventLoopFactory;

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 PAMIMessageEventEventMessage;
use PAMIListenerIEventListener;
use PAMIMessageEventNewstateEvent;
use PAMIMessageEventHangupEvent;
use PAMIMessageEventOriginateResponseEvent;

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 RatchetMessageComponentInterface;
use RatchetConnectionInterface;

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 "Openn";
    }

    public function onMessage(ConnectionInterface $from, $msg)
    {
        //echo "Messagen";
        $json = json_decode($msg);
        if (json_last_error()) {
            echo "Json error: " . json_last_error_msg() . "n";
            return;
        }
        switch ($json->Action) {
            case 'Register':
                //echo "Register clientn";
                $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 "Closen";
        $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 AsteriskClient 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 connectionn";
                return $client;
            }
            $this->clients->next();
        }

        return NULL;
    }

    /**
     *
     * @param string $id
     * @return AsteriskClient 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 idn";
                return $client;
            }
            $this->clients->next();
        }

        return NULL;
    }
}

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

Websocket клиент
namespace Asterisk;

use RatchetConnectionInterface;
use PAMIMessageEventEventMessage;
use PAMIMessageEventNewstateEvent;
use PAMIMessageEventHangupEvent;
use PAMIMessageEventOriginateResponseEvent;

class Client {
    /**
     * Последнее сообщения
     * @var PAMIMessageEventEventMessage
     */
    private $message;
    /**
     * Соединение с сокетом
     * @var RatchetConnectionInterface
     */
    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 RatchetServerEchoServer;
use AsteriskServer;

try {
    $server = new Server();

    $app = new RatchetApp('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() . "nn";
    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.

Автор: ArchDemon

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js