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

Прочитавши заголовок, ви, напевно, подумаєте «Побита тема, та скільки можна про це писати, але все одно не зміг не поділитися своїми велосипедами з милицями напрацюваннями.

Введення
У нашій компанії запис клієнтів здійснювалася по телефону через міні-атс (я в цьому ділі не сильний і можу помилятися). Всі замовлення зберігалися в базу даних, служить інтерфейсом веб-додаток. Щільність дзвінків в певні моменти буває дуже висока і диспетчери, в силу людського фактора, не завжди правильно чи не з першого разу записують телефон клієнта (коли він відображається на екрані телефону).

Але прогрес не стояв на місці. Місце старої атс зайняв 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.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.