Elma365

Примечание

РАЗДЕЛ НАХОДИТСЯ НА МОДЕРАЦИИ

Подключение интеграции со стороны Elma365

Токен

Откройте личный кабинет Elma365 и перейдите в раздел «Администрирование». Перейдите в подраздел «Токены» и кнопкой token_elma создайте токен.

../_images/token_elma365_1.png

Укажите пользователя из доступных у Вас в Elma365, введите название токена (любое удобное) и нажмите кнопку create_token

../_images/token_elma365_2.png

Модуль

В рамках этого же раздела перейдите в подраздел «Модули». И добавьте новый модуль.

../_images/modul_elma365_1.png

Нажмите «Создать».

../_images/modul_elma365_2.png

Укажите любое удобное название для модуля, выберите икноку (если необходимо), добавьте краткое описание и нажмите «Создать». После этого модуль откроется.

../_images/modul_elma365_3.png
/**
Здесь вы можете написать скрипты для сложной серверной обработки контекста во время выполнения процесса.
Для написания скриптов используйте TypeScript (https://www.typescriptlang.org).
Документация TS SDK доступна на сайте https://tssdk.elma365.com.
**/

/** Загрузить значение поля "Ключ для авторизации в облачной АТС" из настроек модуля.
* @returns Ключ для авторизации в облачной АТС
*/
function getApiKey() {
    const apiKey = Namespace.params.data.apiKey;
    if (!apiKey) {
        throw new Error('invalid API key');
    }
    return apiKey;
}

/** Загрузить значение поля "Адрес облачной АТС" из настроек модуля.
* @returns Адрес облачной АТС
*/
function getEndpoint() {
    const endpoint = Namespace.params.data.endpoint;
    if (!endpoint) {
        throw new Error('invalid endpoint URL');
    }
    return endpoint;
}

// Проверить соединение к телефонии (вызывается по нажатию на кнопку "Проверить соединение" на странице модуля).
async function VoipTestConnection(): Promise<VoipTestConnectionResult> {
    try {
        const resp = await fetch(getEndpoint(), {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': getApiKey()
            },
            body: JSON.stringify({
                action: "test",
                obj: "ELMA365",
                action_id: "123",
                params: {}
            }),
        });
        const message = await resp.json();
        if (resp.status == 200) {
            const item = Global.ns.telephony.app.calls.create();
            item.data.__name = 'VoipTestConnection';
            await item.save();
            const parse = JSON.stringify(message)
            throw new Error(`${resp.status} ${resp.statusText}:${parse} ${message.error}`);

        }

        return { success: true };

    } catch (e) {
        return {
            success: false,
            failReason: e.message,
        };
    }
}

// Обработать запрос от провайдера IP-телефонии.
async function VoipParseWebhookRequest(request: FetchRequest): Promise<VoipWebhookParseResult> {
    const response = new HttpResponse();
    let event: VoipWebhookRequest | undefined;
    let callRecord: VoipCallRecord | undefined;

    const req = JSON.parse(request.body! as string);
    const action = req.action;
    let direction: any;
    if (req.body?.direction !== null && (req.body?.direction == '0' || req.body?.direction == '1')) {
        direction = req.body?.direction == '0' ? VoipCallDirection.Out : VoipCallDirection.In;
    }

    const item = Global.ns.telephony.app.calls.create();
    item.data.__name = action;
    item.data.recording = request.body! as string;
    await item.save();

    switch (action) {
        case 'test': {
            response
                .status(200)
                .json({
                    action: '9999999999999',
                })
                .set('Content-Type', 'application/json')
        } break;

        case 'get_users': {
            const users: { id: string, name: string }[] = []
            const search = req.params?.search ?? '';
            const id_list = req.params?.id_list ?? [];
            const limit = req.params?.limit ?? [];
            const offset = req.params?.offset ?? [];
            let result: any;

            if (search.length) {
                result = await System.users.search().size(10000).where((f, g) => g.and(
                    f.__deletedAt.eq(null),
                    f.__name.like(search)
                )).all();

            } else {
                result = await System.users.search().size(10000).where((f, g) => g.and(
                    f.__deletedAt.eq(null)
                )).all()
            }

            if (result) {
                for (let user of result) {
                    users.push({ id: user.id, name: user.data.__name })
                }
            }
            response
                .status(200)
                .json({
                    action: action,
                    obj: "Elma365",
                    code: 200,
                    body: users,
                    search: search ?? 'search have no',
                })
                .set('Content-Type', 'application/json')
        } break;

        case 'call_ringing': {
            // let dstPhone: any;
            // let srcPhone: any;
            // if (direction == VoipCallDirection.Out) {
            //     srcPhone = req.body.domain_user;
            //     dstPhone = req.body.other_leg;
            // } else {
            //     srcPhone = req.body.other_leg;
            //     dstPhone = req.body.domain_user;
            // }

            // srcPhone = req.body.other_leg;
            // dstPhone = req.body.domain_user;

            event = {
                event: VoipWebhookEvent.NotifyStart,
                direction: VoipCallDirection.In,
                dstPhone: req.body.domain_user,
                srcPhone: req.body.other_leg,
                disposition: VoipCallDisposition.Unknown,
            };
            response
                .status(200)
                .json({
                    action: action,
                    direction: req.body.direction,
                    obj: "Elma365",
                    code: 200
                })
                .set('Content-Type', 'application/json')
        } break;

        case 'call-answer': {
            const dstPhone = req.body.domain_user ?? '';
            event = {
                event: VoipWebhookEvent.NotifyAnswer,
                direction: VoipCallDirection.In,
                dstPhone: dstPhone,
                srcPhone: req.body.other_leg,
                disposition: VoipCallDisposition.Unknown,
            };
            response
                .status(200)
                .json({
                    action: action,
                    obj: "Elma365",
                    code: 200
                })
        } break;

        case 'call-hungup': {
            const dstPhone = req.body.domain_user ?? '';
            event = {
                event: VoipWebhookEvent.NotifyEnd,
                direction: VoipCallDirection.In,
                dstPhone: dstPhone,
                srcPhone: req.body.other_leg,
                disposition: VoipCallDisposition.Unknown,
            }
            response
                .status(200)
                .json({
                    action: action,
                    obj: "Elma365",
                    code: 200
                })
        } break;
        case 'cdr_append': {
            // После успешного звонка в CRM отправляется запрос с данными о звонке и ссылкой на запись разговора.
            // Команда может быть использована для сохранения в данных ваших клиентов истории и записей входящих и исходящих звонков.
            callRecord = {
                srcPhone: req.body.dst,
                dstPhone: req.body.src ?? '',
                direction: parseInt(req.body.direction) === 0 ? VoipCallDirection.Out : VoipCallDirection.In,
                // direction: direction,
                duration: parseInt(req.body.duration),
                // Данные из этого поля будут доступны в функции VoipGetCallLink
                call: <RuntelCallData>{
                    link: req.body.link,
                    id: req.body.id,
                },
                disposition: toCallDisposition(req.body.dial_status),
            };
            response
                .status(200)
                .json({
                    action: action,
                    obj: "Elma365",
                    code: 200
                })
        } break;
    }

    return {
        event: event,
        callRecord: callRecord,
        response: response,
    };
}
// Пользователи сопоставляются у провайдера, убрали
// Получить список пользователей IP-телефонии (используется для сопоставления пользователей по нажатию кнопки "Настроить" на странице модуля).
async function VoipGetMembers(): Promise<VoipMember[]> {
    const response = await fetch(getEndpoint(), {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': getApiKey()
        },
        body: JSON.stringify({
            action: "get_domain_user_list",
            obj: "ELMA365",
            action_id: "123",
            params: {}
        }),
    });

    if (response.status !== 200) {
        const message = await response.json();
        const parse = JSON.stringify(message)
        throw new Error(`${response.status} ${response.statusText}: ${message} ${parse} ${message.error}`);
    }

    interface RuntelVoipUser {
        user_name: string;
        uid: string;
    }

    const voipUsers = <RuntelVoipUser[]>(await response.json()).body;
    return voipUsers.map(user => ({
        id: user.uid,
        label: user.user_name,
    }));
}

// Сгенерировать звонок.
async function VoipGenerateCall(srcPhone: string, dstPhone: string): Promise<void> {
    const response = await fetch(getEndpoint(), {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': getApiKey()
        },
        body: JSON.stringify({
            action: "make_call",
            obj: "ELMA365",
            action_id: "123",
            params: {
                // уточнить у архитектора вендора
                "src": srcPhone,
                "dst": dstPhone
            }
        }),
    });

    if (response.status !== 200) {
        throw new Error(`received error response ${response.status}: ${response.statusText}`);
    }
}

// Получить ссылку на запись звонка.
async function VoipGetCallLink(callData: any): Promise<string> {
    throw new Error('Функция не реализована.');
}

// не используем, а надо бы?
// async function fetchEndpoint(command: string, body: Object = {}): Promise<FetchResponse> {
//    return fetch(getEndpoint(), {
//       method: 'POST',
//       headers: {
//          'Content-Type': 'application/json',
//          'Authorization': getApiKey()
//        },
//       body: JSON.stringify({
//          action: command,
//          obj: "",
//          ...body,
//       }),
//    });
// }

// Распарсить контент с типом "application/x-www-form-urlencoded"
// НЕ используем, контент Runtel "application/json"
// 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;
// }

// Сообщение от Runtel.
interface RuntelWebhookRequest {
    // Тип операции
    action: string;
    // Тип события, связанного со звонком
    type: string;
    // Номер телефона клиента (в формате E.164)
    phone: string;
    // Внутренний номер пользователя облачной АТС, если есть
    ext?: string;
}
// Данные, передающиеся с командой "history".
interface RuntelWebhookHistoryCommand extends RuntelWebhookRequest {
    // Общая длительность звонка в секундах
    duration: string;
    // Уникальный id звонка
    callid: string;
    // Ссылка на запись звонка, если она включена в Облачной АТС
    link?: string;
    // Статус входящего или исходящего звонка
    status: string;
}
// Данные, которые сохраняются с каждым звонком.
interface RuntelCallData {
    id: string;
    link: string;
}
// Сконвертировать статуса звонка Runtel в статус звонка ELMA365
function toCallDisposition(status: string): VoipCallDisposition {
    switch (status) {
        case '0':
            return VoipCallDisposition.Answered;
        case '1':
            return VoipCallDisposition.NoAnswer;
        case '2':
            return VoipCallDisposition.Busy;
        case '3':
        // transfer
            return VoipCallDisposition.Failed;
        case '4':
        // forward
            return VoipCallDisposition.Cancel;

        case 'NotAllowed':
        default:
            return VoipCallDisposition.Unknown;
    }
}

Не закрывайте страницу Elma365, она ещё понадобится.

Интеграция со стороны личного кабинета

Перейдите в раздел Маркетплейс, найдите Elma365 и провалитесь внутрь.

Шаг 1. Авторизация

В поле «Домен» в ЛК вставьте выделенную на скрине часть значения из поля «Webhook URL» страницы Elma365. В поле «Токен» в ЛК вставьте значение из поля «Токен» страницы Elma365.

Домен и токен

Выделите на странице Elma365 поле «Webhook URL» и переместите курсор вправо, до extension/. Скопируйте часть значения от extension/ до ?token и вставьте её в поле «Токен модуля» в ЛК.

Токен модуля

Нажмите кнопку save_elma для сохранения текущих настроек.

На странице Elma365 перейдите в раздел «Администрирование», подраздел «Токены». Скопируйте ранее созданный токен в поле «Токен для синхронизации пользователей».

Токен для синхронизации пользователей

Нажмите кнопку save в ЛК для сохранения настроек и перезайдите в интеграцию с Elma365.

Шаг 2. Настройка параметров

  • Синхронизация контактов с CRM - синхронизация контактов между АТС и Elma365.

  • Статус - включение/выключение интеграции.

  • Настройка номеров - настройка работы телефонных номеров в CRM-системе, которую можно сделать как одинаковой для всех, так и для каждого Номера индивидуально.

    По умолчанию для всех номеров настройки задаются на вкладке «Настройки для всех номеров». Для указания настроек конкретного номера нужно выбрать его из раздела АТС Номера и добавить кнопкой bitrix_plus для создания вкладки с этим номером.

    Примечание

    Если у Вас один номер на АТС, то Вы также можете его не выбирать.

../_images/numbers_envy.png
  • Разрешить обработку номера - включение/отключение обработки вызовов в Envy.

Для удаления вкладки с номером нужно нажать на кнопку delete_btn_circle

Шаг 3. Сохранение ключей в CRM

Вернитесь в созданный модуль Elma365.

Скопируйте значение из поля «Ссылка на API ATC» в ЛК и вставьте его в поле «Адрес облачной АТС» на странице Elma365. Скопируйте значение из поля «API-ключ» в ЛК и вставьте его в поле «Ключ для авторизации в облачной АТС» на странице Elma365.

Ссылка на API и адрес облачной АТС

Нажмите кнопку save в ЛК для сохранения настроек. Интеграция завершена.

Итоговое окно интеграции в ЛК