[Из песочницы] Asterisk и информация о входящих звонках в браузере
Прочитав заголовок, вы, наверное, подумаете «Избитая тема, да сколько можно об это писать», но всё равно не смог не поделиться своими велосипедами с костылями наработками.
В нашей компании запись клиентов осуществлялась по телефону через мини-атс (я в этом деле не силен и могу ошибаться). Все заказы сохранялись в базу данных, интерфейсом служит веб-приложение. Плотность звонков в определенные моменты бывает очень высока и диспетчеры, в силу человеческого фактора, не всегда правильно или не с первого раза записывают телефон клиента (когда он отображается на экране телефона).
Но прогресс не стоял на месте. Место старой атс занял Asterisk 13. Мне же необходимо было:
Чего хотели этим добиться:
Прочитав несколько статей, например, вот эту решил «а чем я хуже?» и нашёл свое видение решения задачи.
Демон с pami прослушивает asterisk на предмет входящих звонков. Параллельно крутиться websocket сервер. При поступлении входящего звонка информация разбирается и отправляется websocket клиенту (если таковой имеется).
Служит для периодического опроса asterisk`a на предмет нужных нам событий. Я если честно, не буду утверждать правильные ли я события взял, но с этими всё работало. Просто похожую информацию можно достать из многих событий в зависимости от того, что именно вам нужно.
Ну тут тоже всё понятно. События мы получили. Теперь их нужно обработать. Кто такой server станет понятнее ниже.
Собственно наш websocket сервер. Не стал заморачиваться с форматом обмена, выбрал JSON. Здесь стоит обратить внимание, что у клиентов перезаписывается соединение с сервером. Это позволяет не плодить ответы при открытии многих вкладок в браузере.
Ну тут не знаю что и добавить. id — идентификатор телефона диспетчера. Необходим, чтобы определять к какому именно из диспетчеров поступил вызов.
Тут стоить отметить что websocket сервер и наш asterisk демон используют общий поток (loop). Иначе кто-то бы из них не заработал.
Ну тут всё просто. Не буду грузить информацией о том, как вытащить информацию о клиенте по номеру телефона и прочей ерундой.
phone — идентификатор телефона диспетчера.
Поставленных целей я добился. Работает местами даже лучше чем я предполагал.
Не суди строго за качество кода. Пример показывает исключительно концепцию, хотя успешно работает в продакшене. Для меня это был прекрасный опыт работы с asterisk и websocket.
Введение
В нашей компании запись клиентов осуществлялась по телефону через мини-атс (я в этом деле не силен и могу ошибаться). Все заказы сохранялись в базу данных, интерфейсом служит веб-приложение. Плотность звонков в определенные моменты бывает очень высока и диспетчеры, в силу человеческого фактора, не всегда правильно или не с первого раза записывают телефон клиента (когда он отображается на экране телефона).
Но прогресс не стоял на месте. Место старой атс занял 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. Как-то проще, что ли.