Введение
В нашей компании запись клиентов осуществлялась по телефону через мини-атс (я в этом деле не силен и могу ошибаться). Все заказы сохранялись в базу данных, интерфейсом служит веб-приложение. Плотность звонков в определенные моменты бывает очень высока и диспетчеры, в силу человеческого фактора, не всегда правильно или не с первого раза записывают телефон клиента (когда он отображается на экране телефона).
Но прогресс не стоял на месте. Место старой атс занял Asterisk 13. Мне же необходимо было:
- пробросить информацию о входящем в веб-приложение
- добавить возможность исходящего вызова из веб-приложения
Чего хотели этим добиться:
- Сократить время обработки звонков
- Сократить количество ошибок при записи клиентов
- Сократить время на обзвон клиентов
Инструменты
Прочитав несколько статей, например, вот эту решил «а чем я хуже?» и нашёл свое видение решения задачи.
Решил остановиться на связке asterisk — pami — ratchet
Концепция
Демон с pami прослушивает asterisk на предмет входящих звонков. Параллельно крутиться websocket сервер. При поступлении входящего звонка информация разбирается и отправляется websocket клиенту (если таковой имеется).
Реализация
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 станет понятнее ниже.
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. Здесь стоит обратить внимание, что у клиентов перезаписывается соединение с сервером. Это позволяет не плодить ответы при открытии многих вкладок в браузере.
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.
Комментарии (24)
x893
07.10.2016 11:30Лет 10 назад делал на C#. Вообще без разницы на чем делать. Если что-то может создать TCP клиента — работать будет.
dolphin4ik
07.10.2016 11:56На самом деле могу начаться холивары про демонов на PHP, но нода действительно удобнее. Сменили всех пхп(pami) демонов на аналогичных js -> nami
ArchDemon
07.10.2016 17:03В моё время js не был мейнстримом, а вот php вполне. Исторически сложилось что в php я знаю чуть больше, чем в js.
VolCh
07.10.2016 12:28Ваше решение превращает браузер в телефон?
ArchDemon
07.10.2016 16:57Моё решение позволяет видеть телефонный номер в веб-приложении и искать по номеру клиента.
Моё решение позволяет дозваниваться с телефонного аппарата (физического) до клиента. При этом вызов инициируется из веб-приложения т.е. диспетчеру не нужно набирать номер на телефонном аппарате (физически тыкать пальцами не нужно), а разговор всё также идёт через трубку (не софт телефон).
Так что даже и не знаю, является ли браузер телефоном. При определённой модификации точно им может стать.Ovoshlook
08.10.2016 10:25Не сможет.
Чтобы браузер стал телефоном нужна поддержка webRTC и коннект конечных устройств к АТС по webRTC. То есть это перестроение архитектуры вашего приложения.
antirek
07.10.2016 12:43Уведомление о входящем, ответе на него и завершении звонка:
https://github.com/antirek/asti — сервер
https://github.com/antirek/asti.js — клиент для браузера
и да, nodejs
выложите ваш пример на гитхаб, можно будет попробоватьArchDemon
07.10.2016 17:00Вашу библиотеку видел. Но как я писал выше — nodejs не моё. Поэтому пришлось отказаться.
С исходниками хуже. Боюсь в компании не поймут, если я выложу готовое рабочее решение. Но и по тому, что я выложил можно сделать рабочий вариант.antirek
09.10.2016 18:58+1nodejs и не моё: ) просто как инструмент для подобной задачи оказался проще, чем php (phpDaemon, Ratchet), python (twisted, tornado) (тем более что это все в той или иной мере использую достаточно регулярно). Но всему свое время.
По поводу рабочего решения — вы же уже выложили все, только по кусочкам, уже могут не понять — теперь эти же файлы в репо на гитхаб. И всё.
В общем, развития в решении ваших задач. Не останавливайтесь!
Присоединяйтесь к чату по астериску http://chat.asterisk-support.ru/
ffs
07.10.2016 13:12У меня подобная штука в фирме используется для оповещения о звонках в ЦРМ (веб). Только с событий newstate/hangup/etc мы ушли на CEL events, оказалось удобнее. Звонок в событиях связан одним linked_id и всегда можно легко отследить все события одного звонка. В newstate/hangup/etc, если память не изменяет, нету общего связующего linked_id.
ArchDemon
07.10.2016 17:02Вот это интересно. Я если честно дампил все события и выбирал те, что мне подходят. И нужную мне информацию можно выло взять из многих событий. Спасибо за наводку.
ffs
07.10.2016 18:21Честно говоря, на 100% утверждать не берусь, но вроде было так. События newstate/etc связаны между собой unique_id, которых в процессе разговора может быть несколько (например при любой переадресации заводится новый), а связующий целиком весь звонок linked_id я получил только в CEL ивентах, после их введения и переработки интеграции под них, путаница отпала.
Нет, можно конечно отслеживать переадресации по спец событиям, в которых есть unique_id1 и unique_id2, но так больше геморроя.ssh24
08.10.2016 09:50Вы не правы.
Как правило, один коннекшен Астериска однозначно представлен channelId. Обычно этого достаточно в 99% кейсах.
Однако есть два НО:
1. максимальное значение счетчика, использующегося при генерации channel ID не такой уж большое.
На сверхзагруженных системах этот счетчик периодически может обнуляться.
2. при перезагрузке Астериска счетчик тоже обнуляется.
Вот для этих случаев вводится unique_id, который генерится на основе текущего timestamp. Таким образом, связка channelId + unique_id полностью представляет коннекшен.
Однако в один и тот же момент времени, в Астериске не может быть несколько коннекшенов с одним channel ID. Потому для управления звонками используется только он.
А unique_id я лично ни разу не пользовался. Хотя уже сделал 4 контект-центра, в основе которых был Астериск, управляемый по АМИ. Нигде это не надо было. Везде было достаточно channel ID.
ssh24
08.10.2016 09:37За наводку на CEL спасибо. Не знал, не пользовался.
Но в newstate/hangup/etc есть общий связующий параметр — channel ID.
Ovoshlook
08.10.2016 10:32В СEL есть один серьезный недостаток из зак которого этот механизм довольно неудобно использовать в чем то еще кроме логирования:
Выборки из CEL очень дорогие операции хотя бы по тому что UniqueID и LinkedID это строковые значения.CEL даже при минимальной конфигурации кладет в бд очень много записей (взять chanStart и chanEnd например — это 4 записи на один звонок) соответственно все это дело может вызвать рассинхронизацию при запросе из за long query со всеми вытекающими.
seryh
07.10.2016 16:40-2Тоже решал подобную задачу еще года 3 тому назад на nodejs, даже в мыслях не было что подобная поделка на пару часов работы, заинтересует кого-либо на хабре.
ArchDemon
07.10.2016 16:44С asterisk столкнулся впервые. Поэтому было приятно почитать на этом сайте статьи данной тематики для первого шага. А так и не знал с какой стороны подступиться. Думаю не мало людей также сталкиваются с подобным. Я добавил ещё один из вариантов со своими преимуществами и недостатками
sowrong
07.10.2016 16:45Делал что-то похожее, только у меня был питон. От самописных демонов отказался, сделал через AGI.
Между клиентом и астериском у меня поднят centrifugo. Из астериска через dial plan вызывается скрипт, в который передается внутренний номер клиента и входящий номер (долго искал эти значения, помог verbose режим в консоли астериска). Клиент подключается к той же centrifugo на канал со своим внутренним номером и получает эти сообщения.
А вот исходящие через AMI сделаны.
В итоге кода минимум — десяток строк на скрипт, пару строк на исходящий (но тут уже заслуги сторонней библиотеки, так бы чуть больше вышло).ArchDemon
07.10.2016 16:50Я читал и про ARI и про AGI. Ещё был предложен вариант сделать через веб-интерфейс телефонного аппарата, но его я отбросил сразу по целому ряду причин.
Обмозговав всё, было решено идти через AMI. В asterisk`e не силен и влезать в dial plan не очень хотелось. Так что от AGI я отказался. В ARI я не нашел всё, что мне было нужно.
bat
Несколько лет назад подобную задачу решали на nodejs. Не знаю как сейчас, но в тот момент экосистема nodejs для подобных сервисов была предпочтительнее.
bagiroff777
Даже мне, как пхпшнику, было проще работать с Asterisk'ом с помощью NodeJS. Как-то проще, что ли.
ArchDemon
Само веб-приложение работает на apache — php — mysql. Так что подключать туда js не хотелось, да и если признаться не очень хорошо я знаю js. Но соглашусь, с websocket работать в nodejs куда удобнее.
bat
crm была в облаке (sfdc), в ней таких вещей не сделаешь, поэтому решение должно было хоститься на своих мощностях. По этой причине было больше свободы в выборе инструментов. PHP не рассматривали по вышеописанной причине, хотя 2 из 3х разработчиков пхппешники, третий — perl. Прототип на perl пхпешникам не вкатил )), попробовали на nodejs — покатило, даже без экспертных знаний в JS
зы
сейчас бы сделал на GO