Elma365¶
Примечание
РАЗДЕЛ НАХОДИТСЯ НА МОДЕРАЦИИ
Подключение интеграции со стороны Elma365¶
Токен¶
Откройте личный кабинет Elma365 и перейдите в раздел «Администрирование». Перейдите в подраздел «Токены» и кнопкой создайте токен.

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

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

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

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

/**
Здесь вы можете написать скрипты для сложной серверной обработки контекста во время выполнения процесса.
Для написания скриптов используйте 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 и вставьте её в поле «Токен модуля» в ЛК.

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

Нажмите кнопку в ЛК для сохранения настроек и перезайдите в интеграцию с Elma365.
Шаг 2. Настройка параметров¶
Синхронизация контактов с CRM - синхронизация контактов между АТС и Elma365.
Статус - включение/выключение интеграции.
- Настройка номеров - настройка работы телефонных номеров в CRM-системе, которую можно сделать как одинаковой для всех, так и для каждого Номера индивидуально.
По умолчанию для всех номеров настройки задаются на вкладке «Настройки для всех номеров». Для указания настроек конкретного номера нужно выбрать его из раздела АТС Номера и добавить кнопкой
для создания вкладки с этим номером.
Примечание
Если у Вас один номер на АТС, то Вы также можете его не выбирать.

Разрешить обработку номера - включение/отключение обработки вызовов в Envy.
Для удаления вкладки с номером нужно нажать на кнопку
Шаг 3. Сохранение ключей в CRM¶
Вернитесь в созданный модуль Elma365.
Скопируйте значение из поля «Ссылка на API ATC» в ЛК и вставьте его в поле «Адрес облачной АТС» на странице Elma365. Скопируйте значение из поля «API-ключ» в ЛК и вставьте его в поле «Ключ для авторизации в облачной АТС» на странице Elma365.

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