В условиях пандемии курьерские сервисы стали востребованы как никогда прежде. Чтобы клиент и курьер могли созвониться для уточнения информации по заказу, им нужно знать номера телефонов друг друга. А что насчет соблюдения прайваси? Многие сервисы доставок уже озаботились этим вопросом после не очень приятных инцидентов, о которых вы могли читать в новостях.
Каждый сервис использует свои решения для маскировки номеров клиентов и курьеров. В данной статье я расскажу, как сделать это с помощью key-value хранилища в Voximplant.
Как это будет работать
Мы создадим сценарий, который позволит курьеру и клиенту созваниваться, не зная при этом личные номера телефонов друг друга.
У нас будет только один «нейтральный» номер, на который будут звонить и клиент, и курьер. Номер мы арендуем в панели Voximplant. Затем создадим некую структуру данных, где клиент и курьер будут связаны между собой номером заказа (то есть ключом в терминологии key-value store).
Так при звонке на арендованный номер звонящий введёт номер заказа, и если такой заказ есть в базе, наш сценарий проверит номера телефонов, привязанные к нему. Далее если номер звонящего будет идентифицирован как номер клиента, произойдет соединение с курьером, ответственным за заказ, и наоборот.
Например, звонок курьера клиенту будет выглядеть следующим образом:
Если номер телефона звонящего не будет найден в базе, ему предложат перезвонить с того номера, который использовался при оформлении заказа, или переключиться на оператора.
Перейдем непосредственно к реализации.
Вам понадобятся
верифицированный аккаунт Voximplant, который можно создать здесь;
приложение Voximplant со сценарием и правилом для сценария (их мы создадим вместе);
телефонные номера для тестов: арендованный в панели номер, номер клиента, курьера и оператора (в тестовой реализации номер оператора можно не указывать).
1) Чтобы начать разработку, войдите в свой аккаунт: manage.voximplant.com/auth. В меню слева нажмите «Приложения», затем «Создать приложение» в правом верхнем углу. Дайте ему имя, например, numberMasking и снова кликните «Создать».
2) Зайдите в новое приложение, переключитесь на вкладку «Сценарии» и создайте сценарий, нажав на «+». Назовём его kvs-scenario. Здесь мы будем писать код, но об этом чуть позже.
3) Сначала перейдем во вкладку «Роутинг» и создадим правило для нашего сценария. Маску (регулярное выражение) оставим «.*» по умолчанию, так правило будет срабатывать для всех номеров.
4) Далее арендуем реальный городской номер. Для этого перейдем в раздел «Номера», выберем и оплатим номер. На него будут звонить и клиент, и курьер, и он будет отображаться вместо их настоящих номеров.
В Voximplant вы можете приобретать в том числе тестовые номера, которые удобно использовать при знакомстве с платформой, но в нашем случае потребуется реальный для совершения исходящего звонка с платформы.
5) Осталось привязать его к нашему приложению. Заходим в приложение, открываем вкладку «Номера» > «Доступные» и нажимаем «Прикрепить». В открывшемся окне можно также прикрепить наше правило, тогда оно будет автоматически назначено для входящих вызовов, а все остальные правила будут проигнорированы.
6) Далее необходимо верифицировать аккаунт, чтобы использовать этот номер для звонков.
Отлично, структура готова, осталось заполнить key-value хранилище и добавить код в сценарий.
Key-value хранилище
Чтобы сценарий заработал, нужно положить что-то в хранилище. Это можно сделать, воспользовавшись Voximplant Management API. Я буду использовать Python API client, он работает с Python 2.x или 3.x с установленным pip и setuptools> = 18.5.
1) Зайдем в папку проекта и установим SDK, используя pip
:
python -m pip install --user voximplant-apiclient
2) Создадим файл с расширением .py и добавим в него код, при выполнении которого данные о заказе попадут в key-value хранилище. Для этого применим метод set_key_value_item:
from voximplant.apiclient import VoximplantAPI, VoximplantException
if __name__ == "__main__":
voxapi = VoximplantAPI("credentials.json")
# SetKeyValueItem example.
KEY = 12345
VALUE = '{"courier": "79991111111", "client": "79992222222"}'
APPLICATION_ID = 1
TTL = 864000
try:
res = voxapi.set_key_value_item(KEY,
VALUE,
APPLICATION_ID,
ttl=TTL)
print(res)
except VoximplantException as e:
print("Error: {}".format(e.message))
Файл credentials.json вы сможете сгенерировать при создании сервисного аккаунта в разделе «Служебные аккаунты» в настройках панели.
APPLICATION_ID появится в адресной строке при переходе в ваше приложение.
В качестве ключа (KEY) будет использоваться пятизначный номер заказа, а в качестве значений телефонные номера: courier – номер курьера, client – номер клиента. TTL нам здесь необходим для указания срока хранения значений.
3) Осталось запустить файл, чтобы сохранить данные о заказе:
python kvs.py
Если мы больше не захотим, чтобы клиент и курьер беспокоили друг друга, можно будет удалить их данные из хранилища. Информацию о всех доступных методах key-value store вы найдёте в нашей документации: management API и VoxEngine.
Код сценария
Код для сценария kvs-scenario представлен ниже, его можно смело копировать as is. Единственное, что нужно сдалать, чтобы он заработал – указать в качесте callid номер, арендованный в панели, в виде "74990000000":
Полный код сценария
require(Modules.ApplicationStorage);
/**
* @param {boolean} repeatAskForInput - была ли просьба ввода произнесена повторно
* @param longInputTimerId - таймер на отсутствие ввода
* @param shortInputTimerId - таймер на срабатывание фразы для связи с оператором
* @param {boolean} firstTimeout - индикатор срабатывания первого таймаута
* @param {boolean} wrongPhone - индикатор совпадения номера звонящего с номером, полученным из хранилища
* @param {boolean} inputRecieved - получен ли ввод от пользователя
*
*/
let repeatAskForInput;
let longInputTimerId;
let shortInputTimerId;
let firstTimeout = true;
let wrongPhone;
let inputRecieved;
const store = {
call: null,
caller: '',
callee: '',
callid: 'номер, арендованный в панели',
operator_call: null,
operatorNumber: '',
input: '',
data: {
call_operator: '',
order_number: '',
order_search: '',
phone_search: '',
sub_status: '',
sub_available: '',
need_operator: '',
call_record: ''
}
}
const phrases = {
start: 'Здр+авствуйтте. Пожалуйста, -- введите пятизначный номер заказa в тт+ооновом режиме.',
repeat: 'Пожалуйста , , - - введите пятизначный номер заказа в т+оновом режиме,, или нажмите решетку для соединения со специалистом',
noInputGoodbye: 'Вы - ничего не выбрали. Вы можете посмотреть номер заказа в смс-сообщении и позвонить нам снова. Всего д+обровоо до свидания.',
connectToOpearator: 'Для соединения со специалистом,, нажмите решетку',
connectingToOpearator: 'Ожидайте, соединяю со специалистом',
operatorUnavailable: 'К сожалению,, все операторы заняты. Пожалуйста,,, перезвоните позднее. Всего д+обровоо до свидания.',
wrongOrder: 'Номер заказа не найден. Посмотрите номер заказа в смс-сообщении и введите его в т+оновом режиме. Или свяжитесь со специалистом,, нажав клавишу решетка.',
wrongOrderGoodbye: 'Вы ничего не выбрали, всего д+обровоо до свидания.',
wrongPhone: 'Номер телефона не найден. Если вы кли+ент, перезвоните с номера, который использовали для оформления заказа. Если вы курьер, перезвоните с номера, который зарегистрирован в нашей системе. Или свяжитесь со специалистом,,- нажав клавишу решетка.',
wrongPhoneGoodbye: 'Вы ничего не выбрали. Всего доброго, до свидания!',
courierIsCalling: `Вам звонит курьер по поводу доставки вашего заказа, - - ${store.data.order_number}`,
clientIsCalling: `Вам звонит клиент по поводу доставки заказа, - - ${store.data.order_number} `,
courierUnavailable: 'Похоже,,, курь+ер недоступен. Пожалуйста,,, перезвоните через п+ару мин+ут. Всего д+обровоо до свидания.',
clientUnavailable: 'Похоже,,, абонент недоступен. Пожалуйста,,, перезвоните через пп+ару мин+ут. Всего д+обровоо до свидания.',
waitForCourier: 'Ожидайте на линии,, - соединяю с курьером.',
waitForClient: 'Ожидайте на линии,, соединяю с клиентом.'
}
VoxEngine.addEventListener(AppEvents.Started, async e => {
VoxEngine.addEventListener(AppEvents.CallAlerting, callAlertingHandler);
})
async function callAlertingHandler(e) {
store.call = e.call;
store.caller = e.callerid;
store.call.addEventListener(CallEvents.Connected, callConnectedHandler);
store.call.addEventListener(CallEvents.Disconnected, callDisconnectedHandler);
store.call.answer();
}
async function callDisconnectedHandler(e) {
await sendResultToDb();
VoxEngine.terminate();
}
async function callConnectedHandler() {
store.call.handleTones(true);
store.call.addEventListener(CallEvents.RecordStarted, (e) => {
store.data.call_record = e.url;
});
store.call.record();
store.call.addEventListener(CallEvents.ToneReceived, dtmfHandler);
await say(phrases.start);
addInputTimeouts();
}
function dtmfHandler(e) {
clearInputTimeouts();
store.input += e.tone;
Logger.write('Введена цифра ' + e.tone)
Logger.write('Полный код ' + store.input)
if (e.tone === '#') {
store.data.need_operator = "Да";
store.call.removeEventListener(CallEvents.ToneReceived);
store.call.handleTones(false);
callOperator();
return;
}
if (!wrongPhone) {
if (store.input.length >= 5) {
repeatAskForInput = true;
Logger.write(`Получен код ${store.input}. `);
store.call.handleTones(false);
store.call.removeEventListener(CallEvents.ToneReceived);
handleInput(store.input);
return;
}
}
addInputTimeouts();
}
function addInputTimeouts() {
clearInputTimeouts();
if (firstTimeout) {
Logger.write('Запущен таймер на срабатывание фразы для связи с оператором');
shortInputTimerId = setTimeout(async () => {
await say(phrases.connectToOpearator);
}, 1500);
firstTimeout = false;
}
longInputTimerId = setTimeout(async () => {
Logger.write('Сработал таймер на отсутствие ввода от пользователя ' + longInputTimerId);
store.call.removeEventListener(CallEvents.ToneReceived);
store.call.handleTones(false);
if (store.input) {
handleInput(store.input);
return;
}
if (!repeatAskForInput) {
Logger.write('Просим пользователя повторно ввести код');
store.call.handleTones(true);
store.call.addEventListener(CallEvents.ToneReceived, dtmfHandler);
await say(phrases.repeat);
addInputTimeouts();
repeatAskForInput = true;
} else {
Logger.write('Код не введен. Завершаем звонок.');
await say(inputRecieved ? phrases.wrongOrderGoodbye : phrases.noInputGoodbye);
store.call.hangup();
}
}, 8000);
Logger.write('Запущен таймер на отсутствие ввода от пользователя ' + longInputTimerId);
}
function clearInputTimeouts() {
Logger.write(`Очищаем таймер ${longInputTimerId}. `);
if (longInputTimerId) clearTimeout(longInputTimerId);
if (shortInputTimerId) clearTimeout(shortInputTimerId);
}
async function handleInput() {
store.data.order_number = store.input;
Logger.write('Ищем совпадение в kvs по введенному коду: ' + store.input)
inputRecieved = true;
let kvsAnswer = await ApplicationStorage.get(store.input);
if (kvsAnswer) {
store.data.order_search = 'Заказ найден';
Logger.write('Получили ответ от kvs: ' + kvsAnswer.value)
let { courier, client } = JSON.parse(kvsAnswer.value);
if (store.caller == courier) {
Logger.write('Звонит курьер')
store.callee = client;
store.data.sub_status = 'Курьер';
store.data.phone_search = 'Телефон найден';
callCourierOrClient();
} else if (store.caller == client) {
Logger.write('Звонит клиент')
store.callee = courier;
store.data.sub_status = 'Клиент';
store.data.phone_search = 'Телефон найден';
callCourierOrClient();
} else {
Logger.write('Номер звонящего не совпадает с номерами, полученными из kvs');
wrongPhone = true;
store.data.phone_search = 'Телефон не найден';
store.input = '';
store.call.handleTones(true);
store.call.addEventListener(CallEvents.ToneReceived, dtmfHandler);
await say(phrases.wrongPhone);
addInputTimeouts();
}
} else {
Logger.write('Совпадение в kvs по введенному коду не найдено');
store.data.order_search = 'Заказ не найден';
store.input = '';
store.call.handleTones(true);
store.call.addEventListener(CallEvents.ToneReceived, dtmfHandler);
await say(phrases.wrongOrder);
Logger.write(`Очищаем таймер ${longInputTimerId}. `);
addInputTimeouts();
}
}
async function callCourierOrClient() {
clearInputTimeouts();
Logger.write('Начинаем звонок курьеру/клиенту');
await say(store.data.sub_status === 'Курьер' ? phrases.waitForClient : phrases.waitForCourier, store.call);
const secondCall = VoxEngine.callPSTN(store.callee, store.callid);
store.call.startPlayback('http://cdn.voximplant.com/toto.mp3');
secondCall.addEventListener(CallEvents.Connected, async () => {
store.data.sub_available = 'Да';
await say(store.data.sub_status === 'Курьер' ? phrases.courierIsCalling : phrases.clientIsCalling, secondCall);
store.call.stopPlayback();
VoxEngine.sendMediaBetween(store.call, secondCall);
});
secondCall.addEventListener(CallEvents.Disconnected, () => {
store.call.hangup();
});
secondCall.addEventListener(CallEvents.Failed, async () => {
store.data.sub_available = 'Нет';
store.call.stopPlayback();
await say(store.data.sub_status === 'Курьер' ? phrases.clientUnavailable : phrases.courierUnavailable, store.call);
store.call.hangup();
});
}
async function callOperator() {
Logger.write('Начинаем звонок оператору');
await say(phrases.connectingToOpearator, store.call);
store.call.startPlayback('http://cdn.voximplant.com/toto.mp3');
store.operator_call = VoxEngine.callPSTN(store.operatorNumber, store.callid);
store.operator_call.addEventListener(CallEvents.Connected, async () => {
store.data.call_operator = 'Оператор свободен';
VoxEngine.sendMediaBetween(store.call, store.operator_call);
});
store.operator_call.addEventListener(CallEvents.Disconnected, () => {
store.call.hangup();
});
store.operator_call.addEventListener(CallEvents.Failed, async () => {
store.data.call_operator = 'Оператор занят';
await say(phrases.operatorUnavailable, store.call);
store.call.hangup();
});
}
async function sendResultToDb() {
Logger.write('Данные для отправки в БД');
Logger.write(JSON.stringify(store.data));
const options = new Net.HttpRequestOptions();
options.headers = ['Content-Type: application/json'];
options.method = 'POST';
options.postData = JSON.stringify(store.data);
await Net.httpRequestAsync('https://voximplant.com/', options);
}
function say(text, call = store.call, lang = VoiceList.Yandex.Neural.ru_RU_alena) {
return new Promise((resolve) => {
call.say(text, lang);
call.addEventListener(CallEvents.PlaybackFinished, function callback(e) {
resolve(call.removeEventListener(CallEvents.PlaybackFinished, callback));
});
});
};
Код тщательно прокомментирован, но в некоторые моменты углубимся подробнее.
Вводим номер заказа
Первое, что мы делаем при звонке – просим звонящего ввести номер заказа и обрабатываем введенное значение с помощью функции dtmfHandler
.
store.input += e.tone;
Если звонящий ввел #, сразу соединяем его с оператором:
if (e.tone === '#') {
store.data.need_operator = "Да";
store.call.removeEventListener(CallEvents.ToneReceived);
store.call.handleTones(false);
callOperator();
return;
}
Если он ввел последовательность из 5 цифр, вызываем функцию handleInput
:
if (store.input.length >= 5) {
repeatAskForInput = true;
Logger.write('Получен код ${store.input}. ');
store.call.handleTones(false);
store.call.removeEventListener(CallEvents.ToneReceived);
handleInput(store.input);
return;
}
Ищем заказ в хранилище
Здесь мы будем сравнивать введенный номер заказа с номером в хранилище, используя метод ApplicationStorage.get(), в качестве ключа используем введенную последовательность:
store.data.order_number = store.input;
Logger.write('Ищем совпадение в kvs по введенному коду: ' + store.input)
inputRecieved = true;
let kvsAnswer = await ApplicationStorage.get(store.input);
Если заказ найден, получаем для него номера клиента и курьера:
if (kvsAnswer) {
store.data.order_search = 'Заказ найден';
Logger.write('Получили ответ от kvs: ' + kvsAnswer.value)
let { courier, client } = JSON.parse(kvsAnswer.value);
Теперь осталось разобраться, кому звонить. Если номер звонящего принадлежит курьеру, будем выполнять переадресацию на клиента, если клиенту – на курьера. В этом нам поможет функция callCourierOrClient
:
if (store.caller == courier) {
Logger.write('Звонит курьер')
store.callee = client;
store.data.sub_status = 'Курьер';
store.data.phone_search = 'Телефон найден';
callCourierOrClient();
} else if (store.caller == client) {
Logger.write('Звонит клиент')
store.callee = courier;
store.data.sub_status = 'Клиент';
store.data.phone_search = 'Телефон найден';
callCourierOrClient();
}
Если номера нет в хранилище, просим перезвонить с другого номера, который указывался при оформлении заказа:
else {
Logger.write('Номер звонящего не совпадает с номерами, полученными из kvs');
wrongPhone = true;
store.data.phone_search = 'Телефон не найден';
store.input = '';
store.call.handleTones(true);
store.call.addEventListener(CallEvents.ToneReceived, dtmfHandler);
await say(phrases.wrongPhone);
addInputTimeouts();
}
И наконец, обрабатываем вариант, когда номер заказа не был найден в базе. В этом случае просим попробовать ввести его снова, предварительно удостоверившись, что номер верный:
else {
Logger.write('Совпадение в kvs по введенному коду не найдено');
store.data.order_search = 'Заказ не найден';
store.input = '';
store.call.handleTones(true);
store.call.addEventListener(CallEvents.ToneReceived, dtmfHandler);
await say(phrases.wrongOrder);
Logger.write(`Очищаем таймер ${longInputTimerId}. `);
addInputTimeouts();
}
Звоним клиенту/курьеру
Переходим непосредственно к звонку клиенту/курьеру, то есть к логике функции callCourierOrClient
. Здесь мы сообщим звонящему, что переводим его звонок на курьера/клиента, и включим музыку на ожидание. С помощью метода callPSTN позвоним клиенту или курьеру (в зависимости от того, чей номер был ранее идентифицирован как номер звонящего):
await say(store.data.sub_status === 'Курьер' ? phrases.waitForClient : phrases.waitForCourier, store.call);
const secondCall = VoxEngine.callPSTN(store.callee, store.callid);
store.call.startPlayback('http://cdn.voximplant.com/toto.mp3');
В этот же момент сообщим второй стороне о том, что звонок касается уточнения информации по заказу:
secondCall.addEventListener(CallEvents.Connected, async () => {
store.data.sub_available = 'Да';
await say(store.data.sub_status === 'Курьер' ? phrases.courierIsCalling : phrases.clientIsCalling, secondCall);
store.call.stopPlayback();
VoxEngine.sendMediaBetween(store.call, secondCall);
});
Обработаем событие дисконнекта:
secondCall.addEventListener(CallEvents.Disconnected, () => {
store.call.hangup();
});
И оповестим звонящего, если вторая сторона недоступна:
secondCall.addEventListener(CallEvents.Failed, async () => {
store.data.sub_available = 'Нет';
store.call.stopPlayback();
await say(store.data.sub_status === 'Курьер' ? phrases.clientUnavailable : phrases.courierUnavailable, store.call);
store.call.hangup();
});
За все фразы, который произносит робот, отвечает функция say
, а сами фразы перечислены в ассоциативном массиве phrases. В качестве TTS провайдера мы используем Yandex, голос Alena:
function say(text, call = store.call, lang = VoiceList.Yandex.Neural.ru_RU_alena) {
return new Promise((resolve) => {
call.say(text, lang);
call.addEventListener(CallEvents.PlaybackFinished, function callback(e) {
resolve(call.removeEventListener(CallEvents.PlaybackFinished, callback));
});
});
};
Кроме всего прочего, наш сценарий записывает звонки, используя метод record, и показывает, как можно сохранить статистику в базу данных (в нашем коде за это отвечает функция sendResultToDb
). Это очень важно для бизнеса, поскольку позволяет анализировать статистику, обеспечивать контроль качества и оперативно решать спорные ситуации, которые могли возникнуть в процессе доставки заказа.
Тестируем
Когда полный код добавлен в сценарий, а данные по заказу – в хранилище, смело начинайте тестировать.
Позвоним с телефона клиента или курьера на номер, арендованный в панели. Затем введем номер заказа (в нашем случае это 12345) и будем ждать соединения со второй стороной.
Если все сделано верно, клиент и курьер смогут созваниваться и обсуждать детали заказа, не зная настоящих номеров друг друга, а значит, не нарушая прайваси. Круто, не так ли?) Желаем вам успешной разработки и беспроблемной доставки!
P.S. Также мой коллега недавно рассказал, как обезопасить общение клиента и курьера с помощью Voximplant Kit (наш low-code/no-code продукт). Если эта тема вас заинтересовала, вебинар доступен по ссылке :)
Kalobok
Пережмите заглавную картинку. Примитивная графика на 2.5Мб — это совсем за гранью.
imaximova Автор
Действительно, её я пропустила, когда уменьшала размер всех остальных, теперь всё в порядке.
Надеюсь, ваш негатив по отношению к обложке не отразится на мнении обо всей статье. Если, конечно, вы зашли сюда, чтобы её прочитать, а не только за картинкой.
eso1011
Кажется это проблема Хабра, что нет авто-сжатия, а не проблема авторов.
P.S. Интересный у Вас профиль кста. Надеюсь Вам хорошо платят за бдение размеров медиа-контента в статьях ;D
Kalobok
Меня просто достали тормозящие картинки при просмотре ленты feedly. Если бы это был единичный случай, я бы не парился. Но их десятки в день.