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