﻿# Интеграция с IP-телефонией через пользовательский модуль

> [HTML Version](ip-tel-integration.html)

Вы можете настроить интеграцию с провайдерами IP-телефонии, чтобы сотрудники могли совершать и принимать звонки от клиентов. Для этого используйте:

- готовые модули из каталога [ELMA365 Store](https://store.elma365.ru/): [Гравител](telephony-gravitel.md), [SIPNET](telephony-sipnet.md), [Zadarma](telephony-zadarma.md), [MANGO OFFICE](mango.md), [Sipuni](sipuni.md), [Билайн](beeline.md), [Asterisk](asterisk.md) (доступен только для ELMA365 On-Premises);

- пользовательский модуль. Чтобы принимать и инициировать вызовы, нужно соединить модуль с провайдером.

````
Начало внимание

````
Функциональные возможности телефонии доступны при активации лицензии решения **ELMA365 CRM** или **ELMA365 Omni**.

````
Конец внимание

````
Чтобы настроить интеграцию с помощью пользовательского модуля:

1. Выполните [предварительные настройки](#prepair), например, проверьте доступ к аккаунту телефонии, установите SIP-софтфон и т. д.

2. [Создайте модуль](#create-extention) и в его скриптах пропишите методы Web API, чтобы получить сгенерированный Webhook и токен для обмена данными с провайдером телефонии. 

3. [Соедините модуль с провайдером](#integrate) — в настройках на стороне провайдера укажите токен и Webhook, а в модуль скопируйте ключ авторизации и URL телефонной станции.   
  
Чтобы пользователи могли [обработать звонок](work-with-call.md#call-recording) в его карточке, например принять или переназначить вызов и т. д., добавьте в скрипты модуля соответствующие методы Web API.

Подробнее о реализации интеграции с телефонией при помощи пользовательского модуля читайте в [справке ELMA365 TS SDK](https://tssdk.elma365.com/ru/latest/modules/_201_integration_voip_.html). 

## Предварительные настройки для интеграции с телефонией

Перед началом настройки:

1. Проверьте, что ваш аккаунт у выбранного провайдера телефонии активен.

2. Ознакомьтесь с условиями и сроком хранения записи звонка на сервере провайдера. В течение этого срока в интерфейсе ELMA365 пользователь может прослушать и загрузить запись себе на компьютер. Если запись удалена, появится сообщение об ошибке.

3. Убедитесь, что в настройках провайдера можно указать Webhook системы. С его помощью будут обрабатываться HTTP-запросы о событиях телефонии. Если такой возможности нет, создайте промежуточный сервис, который преобразует данные провайдера в запрос на сервер ELMA365. Подробнее читайте в статье [«Переносимые сервисы в модулях»](portable-microservices.md).

4. Для совершения и приёма звонков на рабочих местах сотрудников убедитесь, что провайдер предоставляет SIP-софтфон. В противном случае установите облачную или локальную программу, которая поддерживает SIP-протокол и обеспечивает голосовую связь, например, MicroSip.

5. Проверьте, поддерживает ли провайдер телефонии технологию WebRTC. Если поддерживает, в пользовательском модуле можно настроить возможность использования CTI‑панели: пользователи смогут совершать звонки прямо из интерфейса ELMA365 без стороннего софтфона.  
  
[Настроить CTI-панель](https://tssdk.elma365.com/ru/latest/modules/_201_integration_voip_.html#%D1%80%D0%B5%D0%B0%D0%BB%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D1%8F-%D0%BF%D0%BE%D0%B4%D0%B4%D0%B5%D1%80%D0%B6%D0%BA%D0%B8-cti-%D0%BF%D0%B0%D0%BD%D0%B5%D0%BB%D0%B8-%D0%B4%D0%BB%D1%8F-%D0%B7%D0%B2%D0%BE%D0%BD%D0%BA%D0%BE%D0%B2-%D1%87%D0%B5%D1%80%D0%B5%D0%B7-%D0%B1%D1%80%D0%B0%D1%83%D0%B7%D0%B5%D1%80) можно после подключения модуля к провайдеру.

6. Чтобы использовать телефонию для работы с продажами, активируйте одно из [системных решений CRM](crm-functions.md). Без лицензии функциональные возможности CRM недоступны.

## Создать пользовательский модуль интеграции с телефонией

1. ## Перейдите в раздел Администрирование > Модули и в правом верхнем углу нажмите кнопку + Модуль.

2. В открывшемся окне выберите опцию **Создать**, введите название и описание модуля. Нажмите кнопку **Создать**.

3. На странице управления модулем перейдите на вкладку **Методы API** и нажмите кнопку **Редактировать**. На открывшейся странице перейдите на вкладку **Скрипты**.

4. Вставьте код-заготовку для методов, которые реализуют интеграцию и обмен данными с телефонией.

Код-заготовка для методов

````
// Проверить соединение с телефонией  
async function VoipTestConnection(): Promise<VoipTestConnectionResult> \{  
   return \{  
       success: false,  
       failReason: 'Функция проверки соединения не реализована.',  
   \};  
\}  
// Обработать запрос от провайдера IP-телефонии  
async function VoipParseWebhookRequest(request: FetchRequest): Promise<VoipWebhookParseResult> \{  
   return \{  
       response: new HttpResponse()  
           .status(400)  
           .content('Hello, world\!'),  
   \};  
\}   
// Получить список пользователей IP-телефонии  
async function VoipGetMembers(): Promise<VoipMember\[\]> \{  
   throw new Error('Функция не реализована.');  
\}  
// Сгенерировать звонок  
async function VoipGenerateCall(srcPhone: string, dstPhone: string): Promise<void> \{  
   throw new Error('Функция не реализована.');  
\}  
// Получить ссылку на запись звонка  
async function VoipGetCallLink(callData: any): Promise<string> \{  
   throw new Error('Функция не реализована.');  
\}
````

5. ````
Сохраните и опубликуйте изменения. 

На странице модуля отобразятся разделы:

- **Настройки телефонии**, где указаны автоматически сгенерированные токен и Webhook;

- **Настройки обработки входящего звонка**, где по умолчанию выбрано приложение для хранения контактных данных клиентов. 

**(IP-Tel-integration-1.png)**

## Соединить модуль с провайдером телефонии

Рассмотрим настройку интеграции пользовательского модуля с провайдером на примере телефонии Гравител.

1. На странице модуля перейдите на вкладку **Настройки** и добавьте два строковых поля **Ключ для авторизации в облачной АТС** (\[OBJECT\]) и **Адрес облачной АТС** (\[OBJECT\]). В эти поля позднее вы запишете данные из настроек провайдера для подключения к Гравител. 

**(IP-Tel-integration-3.png)**

2. Вернитесь на страницу модуля — на ней появятся добавленные в предыдущем пункте поля. Cкопируйте значения полей **Webhook URL** и **Токен**.

3. Перейдите в панель управления Гравител. Интеграция настраивается по Web API Гравител предыдущего поколения. В зависимости от версии личного кабинета, которую вы используете, выполните следующие шаги:

- для актуальной версии — перейдите в **Интеграции **и выберите блок **API Гравител**. Нажмите** Установить**;

- для предыдущей версии — перейдите в **Интеграция с CRM **и на открывшейся странице в правом нижнем углу выберите **Интеграции с вашей CRM**.

4. На открывшейся странице заполните поля **Название интеграции, Ваша CRM**, **Адрес вашей CRM **и **Ключ для авторизации в** **вашей CRM** значениями из настроек модуля. 

Затем скопируйте значения полей **Ключ для авторизации в Облачной АТС **и **Адрес Облачной АТС**. Перейдите в ELMA365 и вставьте их в соответствующие поля на странице модуля. 

**(IP-Tel-integration-6.png)**

Подробнее о настройке интеграции в личном кабинете Гравител читайте в статье [«Телефония Гравител»](telephony-gravitel.md).

5. Сохраните настройки в панели управления Гравител и на странице модуля.

6. Перейдите на страницу управления модулем и откройте вкладку **Методы API > Скрипты**. Нажмите кнопку **Редактировать** и добавьте в начало скрипта следующий код:

Код

````
// Загрузить значение поля Ключ для авторизации в облачной АТС из настроек модуля  
function getApiKey() \{  
   const apiKey = Namespace.params.data.apiKey;  
   if (\!apiKey) \{  
       throw new Error('invalid API key');  
   \}  
   return apiKey;    
\}  
// Загрузить значение поля Адрес облачной АТС из настроек модуля  
function getEndpoint() \{  
   const endpoint = Namespace.params.data.endpoint;  
   if (\!endpoint) \{  
       throw new Error('invalid endpoint URL');  
   \}  
   return endpoint;  
\}  
// Выполнить запрос к Гравител  
async function fetchEndpoint(command: string, body: Object = \{\}): Promise<FetchResponse> \{  
   return fetch(getEndpoint(), \{  
       method: 'POST',     
       headers: \{  
           'Content-Type': 'application/json',  
       \},  
       body: JSON.stringify(\{  
           cmd: command,  
           token: getApiKey(),  
           ...body,  
       \}),  
   \});  
\}  
// Распарсить контент с типом application/x-www-form-urlencoded  
function parseUrlEncoded(body: string): Record<string, string> \{  
    const result: Record<string, string> = \{\};  
    for (const pair of body.split('\&')) \{  
        const keyValue = pair.split('=');  
        if (keyValue.length === 2) \{  
            const key = decodeURIComponent(keyValue\[0\]);  
            const value = decodeURIComponent(keyValue\[1\]);  
            result\[key\] = value;  
        \}  
    \}  
    return result;  
\}  
// Сообщение от Гравител  
interface GravitelWebhookRequest \{  
    cmd: string; // Тип операции  
    type: string; // Тип события, связанного со звонком  
    phone: string; // Номер телефона клиента (в формате E.164)  
    ext?: string; // Внутренний номер пользователя облачной АТС, если есть  
\}  
// Данные, передающиеся с командой history  
interface GravitelWebhookHistoryCommand extends GravitelWebhookRequest \{  
    duration: string; // Общая длительность звонка в секундах  
    callid: string; // Уникальный id звонка  
    link?: string; // Ссылка на запись звонка, если она включена в Облачной АТС  
    status: string; // Статус входящего или исходящего звонка  
\}  
// Данные, которые сохраняются с каждым звонком  
interface GravitelCallData \{  
    id: string;  
    link: string;  
\}  
// Сконвертировать статус звонка Гравител в статус звонка ELMA365  
function toCallDisposition(status: string): VoipCallDisposition \{  
    switch (status) \{  
        case 'Success':  
            return VoipCallDisposition.Answered;  
        case 'Busy':  
            return VoipCallDisposition.Busy;  
        case 'Missed':  
            return VoipCallDisposition.Cancel;  
        case 'NotAvailable':  
            return VoipCallDisposition.NoAnswer;  
        case 'NotAllowed':  
        default:  
            return VoipCallDisposition.Unknown;  
    \}  
\}
````

7. ````
Добавьте или замените метод \[OBJECT\], который позволяет проверить соединение с телефонией. В данном примере тестируется команда для получения списка пользователей. Вы можете указать другую команду.

Команда для получения списка пользователей

````
// Проверить соединение к телефонии  
async function VoipTestConnection(): Promise<VoipTestConnectionResult> \{  
   try \{  
       // Выполняем команду для получения списка пользователей телефонии  
       const response = await fetchEndpoint('accounts');  
       const message = await response.json();  
       if (response.status \!== 200) \{  
           throw new Error(\`\$\{response.status\} \$\{response.statusText\}: \$\{message.error\}\`);  
       \}  
       return \{ success: true \};  
   \} catch (e) \{  
       return \{  
           success: false,  
           failReason: e.message,  
       \};  
   \}  
\}
````

8. ````
Сохраните и опубликуйте скрипты, после чего перейдите на страницу модуля и нажмите кнопку **Проверить соединение**. Появится сообщение об успешном подключении к телефонии. 

Если вы видите ошибку при проверке соединения:

- убедитесь, что поля на странице модуля и в панели управления Гравител заполнены корректно. Например, ошибка «Invalid token» означает, что неверно заполнено поле **Токен**;

- проверьте, что скрипты составлены без ошибок, а также успешно сохранены и опубликованы;

- убедитесь, что ваш аккаунт Гравител активен.

9. После этого добавьте или замените следующие методы для интеграции с Гравител:

- метод \[OBJECT\] — позволяет модулю обработать данные из HTTP-запроса от провайдера и передать их в ELMA365;

Метод для обработки данных из HTTP-запроса

````
// Обработать запрос от провайдера IP-телефонии  
async function VoipParseWebhookRequest(request: FetchRequest): Promise<VoipWebhookParseResult> \{  
    try \{  
        const headers = request.headers;  
        const contentType = headers ? headers\['Content-Type'\] : undefined;  
        if (contentType \!== 'application/x-www-form-urlencoded') \{  
            throw new Error(\`Expected Content-Type to be "application/x-www-form-urlencoded" (got: "\$\{contentType\}"\`);  
        \}  
        if (typeof request.body \!== 'string') \{  
            throw new Error('Expected request body to be string')  
        \}  
        let event: VoipWebhookRequest | undefined;  
        let callRecord: VoipCallRecord | undefined;  
        let response: HttpResponse | undefined;  
        const data = <GravitelWebhookRequest><unknown> parseUrlEncoded(request.body);  
        switch (data.cmd) \{  
            case 'event': \{  
// Облачная АТС отправляет в вашу CRM уведомления о событиях входящих звонков пользователям:   
появлении, принятии или завершении звонка. Команда может быть использована для отображения   
всплывающей карточки клиента в интерфейсе CRM  
                const dstPhone = data.ext ?? '';  
                switch (data.type) \{  
                    case 'INCOMING': \{  
// Пришёл входящий звонок. В это время у менеджера должен начать звонить телефон  
                        event = \{  
                            event: VoipWebhookEvent.NotifyStart,  
                            direction: VoipCallDirection.In,  
                            dstPhone: dstPhone,  
                            srcPhone: data.phone,  
                            disposition: VoipCallDisposition.Unknown,  
                        \};  
                    \} break;  
                    case 'ACCEPTED': \{  
// Звонок успешно принят, т. е. менеджер снял трубку. В этот момент можно убрать всплывающую   
карточку контакта в CRM  
                        event = \{  
                            event: VoipWebhookEvent.NotifyAnswer,  
                            direction: VoipCallDirection.In,  
                            dstPhone: dstPhone,  
                            srcPhone: data.phone,  
                            disposition: VoipCallDisposition.Unknown,  
                        \}  
                    \} break;  
                    case 'COMPLETED': \{  
// Звонок успешно завершён, т. е. менеджер или клиент положили трубку после разговора  
                        event = \{  
                            event: VoipWebhookEvent.NotifyEnd,  
                            direction: VoipCallDirection.In,  
                            dstPhone: dstPhone,  
                            srcPhone: data.phone,  
                            disposition: VoipCallDisposition.Unknown,  
                        \}  
                    \} break;  
                    case 'CANCELLED': \{  
// Звонок сброшен, т.е. клиент не дождался пока менеджер снимет трубку. Либо, если это был   
звонок сразу на группу менеджеров, на звонок мог ответить кто-то ещё  
                        event = \{  
                            event: VoipWebhookEvent.NotifyEnd,  
                            direction: VoipCallDirection.In,  
                            dstPhone: dstPhone,  
                            srcPhone: data.phone,  
                            disposition: VoipCallDisposition.Cancel,   
                        \}  
                    \} break;  
                    case 'OUTGOING': \{  
// Менеджер совершает исходящий звонок. В это время облачная АТС пытается дозвониться до клиента  
                        event = \{  
                            event: VoipWebhookEvent.NotifyStart,  
                            direction: VoipCallDirection.Out,  
                            dstPhone: dstPhone,  
                            srcPhone: data.phone,  
                            disposition: VoipCallDisposition.Unknown,   
                        \}  
                    \} break;  
                    default: throw new Error(\`Unknown event type "\$\{data.type\}"\`)  
                \}  
            \} break;  
            case 'history': \{  
// После успешного звонка в CRM отправляется запрос с данными о звонке и ссылкой на запись   
разговора. Команда может быть использована для сохранения в данных ваших клиентов истории   
и записей входящих и исходящих звонков  
                const cmd = <GravitelWebhookHistoryCommand> data;  
                callRecord = \{  
                   srcPhone: cmd.phone,  
                   dstPhone: cmd.ext ?? '',  
                   direction: cmd.type === 'out' ? VoipCallDirection.Out : VoipCallDirection.In,  
                   duration: parseInt(cmd.duration),  
// Данные из этого поля будут доступны в функции VoipGetCallLink  
                    call: <GravitelCallData>\{  
                        link: cmd.link,  
                        id: cmd.callid,  
                    \},  
                    disposition: toCallDisposition(cmd.status),  
                \}  
            \} break;  
        \}  
        return \{   
            event: event,  
            callRecord: callRecord,  
            response: response,  
        \};  
    \} catch (e) \{  
        return \{  
            response: new HttpResponse()  
                .status(400)  
                .content(JSON.stringify(\{  
                    error: e.message ?? 'internal error',  
                \}))  
        \};  
    \}  
\}
````

- ````
метод \[OBJECT\] — позволяет получить данные о пользователях телефонии. Эти данные отображаются на странице модуля при сопоставлении пользователей провайдера и пользователей ELMA365;

Метод для получения данных о пользователях телефонии

````
// Получить список пользователей от провайдера IP-телефонии  
async function VoipGetMembers(): Promise<VoipMember\[\]> \{  
    const response = await fetchEndpoint('accounts');  
    if (response.status \!== 200) \{  
        throw new Error(\`received error response \$\{response.status\}: \$\{response.statusText\}\`);  
    \}  
    interface GravitelVoipUser \{  
        name: string;  
        ext: string;  
    \}  
    const voipUsers = <GravitelVoipUser\[\]> (await response.json());  
    return voipUsers.map(user => (\{  
        id: user.ext,  
        label: user.name,  
    \}));  
\}
````

- ````
метод \[OBJECT\] — позволяет инициировать звонок из интерфейса ELMA365;

Метод для инициирования звонка из системы

````
// Сгенерировать звонок  
async function VoipGenerateCall(srcPhone: string, dstPhone: string): Promise<void> \{  
    const response = await fetchEndpoint('makeCall', \{  
        phone: dstPhone,  
        user: srcPhone,  
    \});  
    if (response.status \!== 200) \{  
        throw new Error(\`received error response \$\{response.status\}: \$\{response.statusText\}\`);  
    \}  
\}
````

- ````
метод \[OBJECT\] — позволяет сохранить запись разговора. В качестве аргумента передаются данные из поля **call** параметра \[OBJECT\] (заполняется в методе \[OBJECT\]).

Метод для сохранения записи разговора

````
// Получить ссылку на запись разговора  
async function VoipGetCallLink(callData: GravitelCallData): Promise<string> \{  
    return callData.link;  
\}
````

10. ````
Чтобы реализовать функциональность кнопок для обработки звонка в его карточке, добавьте методы, перечисленные в [справке ELMA365 TS SDK](https://tssdk.elma365.com/ru/latest/modules/_201_integration_voip_.html#2). 

11. Сохраните и опубликуйте скрипты. 

12. На странице модуля нажмите на появившуюся кнопку **Настроить** и сопоставьте номера пользователей из настроек провайдера с пользователями ELMA365. Подробнее читайте в статье [«Телефония Гравител»](telephony-gravitel.md#users).

(IP-Tel-integration-7.png)

13. При необходимости [настройте обработку входящего звонка](telephony-gravitel.md#call-page). По умолчанию для связи звонка с клиентом используется приложение **Контакты**. Когда поступает звонок, в этом приложении выполняется поиск элемента по номеру телефона. Если входящий номер совпадает со значением в поле типа **Номер телефона**, откроется нужная карточка.

Вы также можете настроить поиск элемента по другому строковому полю приложения. Подробнее об этом читайте ниже.

14. Сохраните настройки. 

Теперь интеграция с Гравител полностью функционирует.

### Настроить поиск элемента по строковому полю

При поступлении звонка выполняется поиск элемента из приложения, выбранного в настройках модуля для хранения контактных данных клиентов. По умолчанию для идентификации элемента используется номер телефона. Откроется карточка того контакта, где указан номер, совпадающий с входящим.

В пользовательском модуле телефонии можно настроить поиск элемента по другому параметру, который передаёт провайдер, например, по идентификатору звонка или имени звонящего. Для этого в настройках переменная провайдера сопоставляется со строковым свойством приложения.

Чтобы реализовать такую возможность, выполните следующие шаги:

1. После того как соединение с провайдером установится, на странице управления модулем перейдите на вкладку **Настройки** и добавьте переменную **Идентификатор звонка** (`link\_call\_id`) с типом **Строка**. При входящем звонке в это свойство запишется значение, переданное от провайдера IP‑телефонии, с нужной информацией о звонке.

2. Перейдите на страницу модуля. Здесь отобразится свойство, добавленное на предыдущем шаге. Также в блоке **Настройки обработки входящего звонка** появится **Поле для связи**.

3. В **Поле для связи** укажите свойство с типом **Строка** из контекста приложения, выбранного в поле **Связать с приложением\***.

4. Чтобы поиск выполнялся корректно, свяжите строковое свойство приложения с переменной, которую передаёт провайдер. Для этого вверху страницы модуля в поле **Идентификатор звонка** укажите переменную провайдера в фигурных скобках, например `\{Id\}` или `\{Caller.Name\}`.

5. Сохраните настройки.

Когда поступит звонок, значение переменной из поля **Идентификатор звонка** сравнится со значением выбранного свойства в **Поле для связи** во всех элементах приложения. При совпадении откроется нужный элемент.