Версия: 5.x
Инструкция по разработке модулей-адаптеров для IP телефонии

Модуль CRM ReadyScript содержит базовые механизмы для интеграции с сервисами IP телефонии. К таким механизмам можно отнести:

  • Фиксацию информации о звонке
  • Всплывающее уведомление о звонке в административной панели
  • Единый интерфейс инициализации исходящих звонков

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

Заметки
В базовой поставке модуля CRM ReadyScript имеется модуль-адаптер для телефонии Телфин. Его всегда можно использовать как пример реализации.

Для удобства, далее в примерах будем рассматривать пример создания модуля для вымышленной телефонии CustomTelephony. Структура папок модуля рекомендуется следующая:

  • customtelephony //название модуля. Можно использовать любое английское имя
    • config //Здесь должны находиться конфигурационные классы модуля
      • file.inc.php //Конфигурационный файл модуля
      • module.xml //Файл со сведениями о модуле и его настройках по-умолчанию
      • handlers.inc.php //Файл с обработчиками событий, на которые подписан модуль
    • model //Модели модуля
      • telephony //Контейнер для классов телефонии
        • provider.inc.php //класс провайдера
        • providertest.inc.php //класс теста для провайдера

Непосредственно функциональность модуля-адаптера реализована в 2х классах Provider и ProviderTest. Для обоих в системе предусмотрены базовые классы-родители Crm::Model::Telephony::Provider::AbstractProvider и Crm::Model::Telephony::Provider::AbstractProviderTest соответственно.

Класс, описывающий провайдера (provider.inc.php)

Рассмотрим абстрактные методы базового класса Crm::Model::Telephony::Provider::AbstractProvider.

<?php
/**
* Базовый класс провайдера услуг телефонии
*/
abstract class AbstractProvider
{
protected $settings_info_template = '';
protected $last_error;
private $url_secret;
const EVENT_TYPE_DIAL_IN = 'dial-in';
const EVENT_TYPE_DIAL_OUT = 'dial-out';
const EVENT_TYPE_ANSWER = 'answer';
const EVENT_TYPE_HANGOUT = 'hangup';
const METHOD_GET = 'GET';
const METHOD_POST = 'POST';
/**
* Возвращает название провайдера телефонии
*
* @return string
*/
abstract public function getTitle();
/**
* Возвращает внутренний строковый идентификатор провайдера связи
*
* @return string
*/
abstract public function getId();
/**
* Обрабатывает входящий запрос с событием от сервиса телефонии
*
* @param Request $url
* @return CallEvent
*/
abstract public function onEvent(Request $url);
/**
* Возвращает объект, который описывает тесты
* для данного провайдера.
*
* @return AbstractProviderTest
*/
abstract public function getEventTestObject();
/**
* Возвращает список действий, который можно произвести со звонком
* в зависимости от статуса звонка
*
* @param CallHistory $call
* @return array
* [
* ['text' => 'ТЕКСТ КНОПКИ','attr' => ['data-url' => '', АТТРИБУТЫ...]],
* ['text' => 'ТЕКСТ КНОПКИ','attr' => ['data-url' => '', АТТРИБУТЫ...]],
* ]
*
* data-url - может содержать ссылку на контроллер действий звонков crm-callactions, с GET параметрами:
* - call_id ID звонка
* - call_action имя действия. В этом случае должен присутствовать метод do{ИМЯ_ДЕЙСТВИЯ} в классе провайдера
*/
abstract public function getActionsByCall(CallHistory $call);
/**
* Возвращает добавочный номер для администратора user_id, если таковой задан. Иначе - false
*
* @param integer $user_id
* @return integer|bool(false)
*/
abstract public function getExtensionIdByUserId($user_id);
/**
* Возвращает ID пользователя по добавочному номеру
*
* @param $extension_id
* @return mixed
*/
abstract public function getUserIdByExtensionId($extension_id);
/**
* Получает авторизационный токен и устанавливает его в Requester
*
* @param Requester $requester
* @param bool $force
* @return mixed
*/
abstract public function authorizeRequester(Requester $requester, $force = false);
/**
* Возвращает последний полученный AccessToken. Если force=true, то происходит принудительная переполучение токена
*
* @param bool $force
* @return string | bool(false)
*/
abstract public function getAccessToken($params = array(), $force = false);
/**
* Возвращает true, если заполнены все данные для проведения исходящих запросов к API
*
* @return bool
*/
abstract public function canApiRequest();
/**
* Возвращает true, если включена автоматическая загрузка записей разговоров после отбоя
*
* @return bool
* @throws \RS\Exception
*/
abstract public function isEnableAutoDownloadRecord();
/**
* Возвращает путь записи на локальном диске. Или false - в случае, если провайдер
* не поддерживает работу с записями
*
* @param CallHistory $call
* @return string|bool(false)
*/
abstract function getRecordDataLocalPath(CallHistory $call);
/**
* Возвращает true, если запись разговора присутствует локально
*
* @param CallHistory $call
* @return bool
*/
abstract function issetRecordLocal(CallHistory $call);
/**
* Производит попытку загрузки записи на локальный диск
*
* @param CallHistory $call
* @return bool
*/
abstract public function downloadRecord(CallHistory $call);
/**
* Возвращает содержимое файла записи телефонного разговора
*
* @param CallHistory $call
* @param bool $find_local
* @return bool
*/
abstract function getRecordData(CallHistory $call, $find_local = true);
/**
* Возвращает Mime тип аудиозаписи
*
* @return string
*/
abstract public function getRecordContentType();
/**
* Возвращает true, если телфония поддерживает исходящие звонки
*
* @return bool
*/
abstract public function canCalling();
/**
* Отправляет запрос на исходящий вызов
*
* @param $number
* @return bool
*/
abstract public function CallPhoneNumber($number);
/**
* Возвращает true, если удается определить, что это внутренний вызов между сотрудниками телефонии.
* Такие вызовы должны игнорироваться и не регистрироваться в административной панели
*
* @param CallHistory $call Здесь будет объект звонка, который еще не присутствует в базе (ID = null)
* @return mixed
*/
abstract public function isInternalCall(CallHistory $call);
//.... далее идут остальные не абстрактные методы, имеющие реализацию по умолчанию
}

Рассмотрим некоторые методы более детально.

Метод onEvent(Request $url) - должен получать на вход параметры, которые приходят на URL для входящих уведомлений, а на выходе отдавать в ReadyScript унифицированный объект события телефонии, класса Crm::Model::Telephony::CallEvent.

Пример реализации метода onEvent:

/**
* Обрабатывает входящий запрос с событием от сервиса телефонии
*
* @param Request $url
* @return CallEvent Возвращает унифицированный объект события для RS
*/
public function onEvent(Request $url)
{
$call_event = new CallEvent($this);
$call_event
->setEventType($url->request('EventType', TYPE_STRING))
->setCallerNumber($url->request('CallerIDNum', TYPE_STRING))
->setCalledNumber($url->request('CalledNumber', TYPE_STRING))
->setCallFlow($url->request('CallFlow', TYPE_STRING))
->setCalledDID($url->request('CalledDID', TYPE_STRING))
->setCallID($url->request('CallID', TYPE_STRING))
->setSubCallID($url->request('SubCallID', TYPE_STRING))
->setRecID($url->request('RecID', TYPE_STRING))
->setDuration($url->request('Duration', TYPE_STRING))
->setCallAPIID($url->request('CallAPIID', TYPE_STRING))
->setEventTime(date('Y-m-d H:i:s', $url->request('EventTime', TYPE_STRING)/1000000 ))
->setCallerId($url->request('CallerExtensionID', TYPE_STRING))
->setCalledId($url->request('CalledExtensionID', TYPE_STRING))
->setData([
'CalledExtension' => $url->request('CalledExtension', TYPE_STRING),
'CallerExtension' => $url->request('CallerExtension', TYPE_STRING),
])
->setReturnData('OK');
if ($call_event->getEventType() == self::EVENT_TYPE_HANGOUT) {
$call_event->setCallSubStatus($url->request('CallStatus', TYPE_STRING));
}
return $call_event;
}

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

Метод getActionsByCall, позволяет установить какие кнопки отображать во всплывающем уведомлении о звонке, при различных состояниях звонка, которые можно получить из объекта CallHistory. Объект CallHistory содержит в себе сведения о текущем звонке. Пример реализации метода getActionsByCall:

/**
* Возвращает массив действий для различных статусов звонка
*
* @param CallHistory $call
* @return array
*/
public function getActionsByCall(CallHistory $call)
{
$actions = array();
if ($this->canApiRequest()) {
$router = Manager::obj();
$hangup_url = $router->getAdminUrl('doAction', [
'call_id' => $call['call_id'],
'call_action' => 'hangup'
], 'crm-callactions');
switch ($call['call_status']) {
case CallHistory::CALL_STATUS_ANSWER:
case CallHistory::CALL_STATUS_CALLING:
$actions[] = [
'text' => t('Отклонить'),
'attr' => [
'data-url' => $hangup_url,
'class' => 'btn btn-danger tel-action'
]
];
break;
}
}
return $actions;
}

Код выше описывает, что во время, когда звонок находится в статусе Идет вызов, Идет разговор - необходимо отобразить кнопку Отклонить, которая будет вызывать стандартный URL действия. В call_action мы видим hangup - это означает, что в классе provider должен быть реализован метод doHangup, который выполнит необходимое действие (в данном случае выполнит запрос к телефонии на отбой звонка).

Таким образом для всех идентификаторов действий, которые вернет метод getActionsByCall, должны быть реализованы методы doИМЯ-ДЕЙСТВИЯ.

Метод getEventTestObject - должен возвращать объект класса тестирования, наследника от Crm::Model::Telephony::Provider::AbstractProviderTest. Класс тестирования должен содержать сведения о форме, которую следует показать пользователю в административной панели, а также реализацию метода onTest, который будет вызываться, когда пользователь будет отправлять заполненную форму.

Для выполнения запросов к API телефонии внутри методов класса провайдера, необходимо получать объект класса Crm::Model::Telephony::Requester, с помощью метода $this->getRequester(). Работа через данный объект позволит логировать обмен данными в общем логе телефонии. Если возможностей данного класса будет недостаточно, можно его унаследовать и добавить собственную логику.

Метод authorizeRequester будет вызываться внутри объекта, полученного через $this->getRequester(), во время выполнения запроса. Данный метод должен устанавливать в запрос необходимые заголовки или параметры для выполнения авторизованных запросов к телефонии. Сперва метод будет вызван с аргументом $force=false, что позволит в случае наличия действующего AccessToken в кэше установить его в объект Requester. В случае если запрос будет отклонен по причине некорректного AccessToken, будет повторно один раз вызван метод authorizeRequester с аргументом $force=true, что будет означать, что токен нужно будет переполучить и обновить его в кэше.

Метод authorizeRequester, должен в своем теле непосредственно получать токен через метод getAccessToken и станавливать его в Requester. Метод getAccessToken, будет вызываться напрямую, если в административной панели будет использован механизм "проверки авторизации".

Реализуйте в стандартном конфигурационном классе модуля CustomTelephony\Config\File все настройки, которые необходимы для работы модуля телефонии, включая получение авторизационных токенов или сведений, позволяющих это сделать в автоматическом режиме далее.

Класс теста провайдера (providertest.inc.php)

Суть тестов состоит в том, чтобы предоставить возможность администратору эмитировать входящие события, без поступления реальных событий от телефонии. Так, например, можно эмитировать входящий/исходящий звонок, ответ на звонок, прекращение вызова.

Класс теста должен "подсказать" системе, какую форму запросить у пользователя в окне тестирования и как обработать POST запрос с данными этой формы. А точнее какой входящий запрос сэмитировать на основе введенных пользователем данных в форму.

Рассмотрим абстрактный класс теста для провайдера телефонии Crm::Model::Telephony::Provider::AbstractProviderTest.

<?php
/**
* Класс описывает объект, который содержит сведения
* для окна тестирования телефонии
*/
abstract class AbstractProviderTest
{
const CALL_FLOW_IN = 'in';
const CALL_FLOW_OUT = 'out';
const CALL_EVENT_TYPE_DIAL = 'dial';
const CALL_EVENT_TYPE_ANSWER = 'answer';
const CALL_EVENT_TYPE_HANGUP = 'hangup';
private $provider;
protected $last_event_result = '';
protected $last_event_error = '';
function __construct(AbstractProvider $provider)
{
$this->provider = $provider;
}
/**
* Возвращает объект провайдера телефонии
*
* @return AbstractProvider
*/
public function getProvider()
{
return $this->provider;
}
/**
* Возвращает объект, описывающий форму запроса данных у пользователя для тестирования
*
* @return null | FormObject
*/
public function getFormObject()
{
$form_object = new FormObject(new PropertyIterator(array(
'call_flow' => new Type\Varchar(array(
'description' => t('Направление'),
'listFromArray' => array(array(
self::CALL_FLOW_IN => t('Входящий звонок'),
self::CALL_FLOW_OUT => t('Исходящий звонок'),
))
)),
'call_event_type' => new Type\Varchar(array(
'description' => t('Действие'),
'listFromArray' => array(array(
self::CALL_EVENT_TYPE_DIAL => t('Звонок'),
self::CALL_EVENT_TYPE_ANSWER => t('Ответ на звонок'),
self::CALL_EVENT_TYPE_HANGUP => t('Завершение вызова'),
))
)),
'called_id' => new Type\Varchar(array(
'description' => t('Добавочный номер абонента')
)),
'caller_number' => new Type\Varchar(array(
'description' => t('Номер звонящего'),
'attr' => array(array(
'placeholder' => t('Например, +7(XXX)xxx-xx-xx')
))
)),
'show_request' => new Type\Integer(array(
'description' => t('Показать запрос в уведомлении'),
'checkboxView' => array(1,0)
))
)));
return $form_object;
}
/**
* Возвращает HTML форму данного типа оплаты, для ввода дополнительных параметров
*
* @return string
*/
function getFormHtml()
{
if ($params = $this->getFormObject()) {
$params->getPropertyIterator()->arrayWrap('provider_fields');
$params->setFormTemplate(strtolower(str_replace('\\', '_', get_class($this))));
$tpl_folder = \Setup::$PATH.\Setup::$MODULE_FOLDER.'/'.$module.\Setup::$MODULE_TPL_FOLDER;
return $params->getForm(array('provider_test' => $this), null, false, null, '%system%/coreobject/tr_form.tpl', $tpl_folder);
}
}
/**
* Обрабатывает запрос на тестирование
*
* @param array $data
*/
abstract public function onTest(array $data);
/**
* Возвращает успешный результат последнего вызова onEvent
*
* @return string
*/
public function getEventLastResult()
{
}
/**
* Возвращает текст ошибки последнего вызова onEvent
*
* @return string
*/
public function getEventLastError()
{
}
}

Как видно из кода класс содержит реализацию по умолчанию почти всех методов, кроме одного абстрактного метода onTest. В данном методе необходимо реализовать обработку входящих данных от отправки формы, предоставленной методом getFormObject.

Вот пример реализации данного метода у телефонии Телфин.

<?php
/**
* Класс, содержащий набор методов для тестирования телефонии Телфин, эмитации запросов
*/
class TelphinTest extends AbstractProviderTest
{
/**
* Обрабатывает запрос на тестирование
*
* @param array $data
* @return bool
*/
public function onTest(array $data)
{
$this->last_event_error = null;
$this->last_event_result = null;
$event_data = $this->getEventData($data);
$url = $this->getProvider()->getEventGateUrl( $event_data['EventType'] );
$context = stream_context_create(array(
'http' => array(
'method' => 'POST',
'header' => 'Content-Type: application/x-www-form-urlencoded' . PHP_EOL,
'content' => http_build_query($event_data),
),
));
$result = @file_get_contents($url, false, $context);
if (!empty($data['show_request'])) {
$request = t('. Запрос: %request', array(
'request' => $url.'?'.http_build_query($event_data)
));
}
if ($result !== false) {
$this->last_event_result = t('Уведомление о событии отправлено');
if (!empty($data['show_request'])) {
$this->last_event_result .= $request;
}
return true;
} else {
$this->last_event_error = t('Не удалось выполнить POST запрос на URL `%url`. Статус ответа: %status', [
'url' => $url,
'status' => isset($http_response_header[0]) ? $http_response_header[0] : ''
]);
if (!empty($data['show_request'])) {
$this->last_event_error .= $request;
}
return false;
}
}
/**
* Возвращает тестовые данные для генерации события от телефонии
*
* @param array $data Данные формы тестирования телефонии
* @return array
*/
private function getEventData(array $data)
{
$call_id = md5($data['called_id'].$data['caller_number']);
$params = [
'CallID' => $call_id,
'SubCallID' => '193958-'.$call_id,
'CallAPIID' => '3584709139-3849618a-cd61-11e9-b297-bd083c9cfa95',
'EventTime' => time() * 1000000
];
if ($data['call_flow'] == self::CALL_FLOW_IN) {
$params += [
'CalledExtension' => $data['called_id'].'@sipproxy.telphin.ru',
'CalledExtensionID' => '193958',
'CallerExtension' => '0000*000@sipproxy.telphin.ru',
'CallerExtensionID' => '193978',
'CalledNumber' => $data['called_id'],
'CalledDID' => '70000000000',
'CallerIDNum' => $data['caller_number'],
'CallerIDName' => 'Testing call',
'CallFlow' => 'in'
];
} else {
$params += [
'CallerExtension' => $data['called_id'].'@sipproxy.telphin.ru',
'CallerExtensionID' => '193958',
'CalledNumber' => $data['caller_number'],
'CallerIDNum' => $data['called_id'],
'CallerIDName' => 'Testing call',
'CallFlow' => 'out',
];
}
switch($data['call_event_type']) {
case self::CALL_EVENT_TYPE_DIAL:
$params += [
'EventType' => ($data['call_flow'] == self::CALL_FLOW_IN) ? 'dial-in' : 'dial-out',
'CallStatus' => 'CALLING',
];
break;
case self::CALL_EVENT_TYPE_ANSWER:
$params += [
'EventType' => 'answer',
'CallStatus' => 'ANSWER',
];
break;
case self::CALL_EVENT_TYPE_HANGUP:
$params += [
'EventType' => 'hangup',
'CallStatus' => 'ANSWER',
'RecID' => '193958-3a849fa6cb6111e9b6b1bd083c9cfa95',
'Duration' => 7529999
];
break;
}
return $params;
}
}

Метод onTest выполняет запросы с данными, точно соответствующими формату входящих запросов от провайдера телефонии.

Регистрация нового модуля-адаптера телефонии в системе

Для регистрации в системе нового модуля-адаптера, необходимо обработать событие crm.telephony.getproviders. Согласно формату моделй в ReadyScript, регистрация обработчика событий производится в файле /config/handlers.inc.php.

Пример регистрации нового модуля-адаптера для телефонии CustomTelephony.

<?php
namespace CustomTelephony\Config;
/**
* Класс содержит обработчики событий, на которые подписан модуль
*/
class Handlers extends \RS\Event\HandlerAbstract
{
/**
* Добавляет подписку на события
*
* @return void
*/
function init()
{
$this->bind('crm.telephony.getproviders');
}
/**
* Регистрирует в системе класс модуля-адаптера для телефонии
*
* @param $list
* @return array
*/
public static function crmTelephonyGetProviders($list)
{
$list[] = new \CustomTelephony\Model\Telephony\Provider();
return $list;
}
}

Контроллеры, обрабатывающие запросы телефонии

Для всех внешних и внутренних запросов, в ReadyScript предусмотрены единые точки входа (фронт-контроллеры). Данные контроллеры уже вызывают необходимые методы у классов провайдеров.

Рассмотрим имеющиеся контроллеры:

Фронт-контроллер для запросов от провайдера телефонии: crm-front-telephonyevents. Пример получения URL для данного контроллера:

$url = \RS\Router\Manager::obj()->getUrl('crm-front-telephonyevents', [
'secret' => 'секретный ключ из настроек CRM для телефонии',
'provider' => 'строковый id провайдера'
]);

Внутри шаблонов получить данный URL можно так:

1 {$router->getUrl('crm-front-telephonyevents', [
2  'secret' => 'секретный ключ из настроек CRM для телефонии',
3  'provider' => 'строковый id провайдера'
4  ])}

Контроллер административной панели для выполнения действий из всплывающего окна уведомлений о звонке: crm-callactions. Пример получения URL для данного контроллера:

//Вернет URL для инициирования исходящего запроса на номер
$url = \RS\Router\Manager::obj()->getAdminUrl('calling', ['number' => 'номер телефона'], 'crm-callactions');
//Вернет URL для выполнения действия со звонком
$url = \RS\Router\Manager::obj()->getAdminUrl('doAction', [
'call_id' => 'ID объекта CallHistory',
'call_action' => 'действие'
], 'crm-callactions');

Внутри шаблонов получить данные URL можно так:

1 //Вернет URL для выполнения действия со звонком
2 {adminUrl do="doAction" mod_controller="crm-callactions" call_id="ID объекта CallHistory" call_action="действие"}
3 
4 //Вернет URL для инициирования исходящего запроса на номер
5 {adminUrl do="calling" mod_controller="crm-callactions" number='номер телефона'}

Пример тестирования работы телефонии

Разработка модуля-адаптера для телефонии должна начинаться с разработки класса для тестирования, далее необходимо включить логирование в разделе Веб-сайт->Настройка модулей->CRM->Вкладка Телефония->Включить логирование запросов. После чего можно запускать утилиту "Проверка работы телефонии", расположенную в этом же разделе и эмитировать входящие запросы от телефонии.

Необходимо добиться, чтобы при получение запросов о поступившем или отвеченном звонке выплывало окно с уведомлением, а при поступлении запроса на завершение звонка окно успешно скрывалось.

С помощью утилиты "Просмотреть лог запросов телефонии" можно детально просмотреть на обработку входящих и исходящих запросов.