В условиях пандемии курьерские сервисы стали востребованы как никогда прежде. Чтобы клиент и курьер могли созвониться для уточнения информации по заказу, им нужно знать номера телефонов друг друга. А что насчет соблюдения прайваси? Многие сервисы доставок уже озаботились этим вопросом после не очень приятных инцидентов, о которых вы могли читать в новостях.
Каждый сервис использует свои решения для маскировки номеров клиентов и курьеров. В данной статье я расскажу, как сделать это с помощью key-value хранилища в Voximplant.
Как это будет работать
Мы создадим сценарий, который позволит курьеру и клиенту созваниваться, не зная при этом личные номера телефонов друг друга.
У нас будет только один нейтральный номер, на который будут
звонить и клиент, и курьер. Номер мы арендуем в панели Voximplant.
Затем создадим некую структуру данных, где клиент и курьер будут
связаны между собой номером заказа (то есть ключом
в терминологии key-value storage).
Так при звонке на арендованный номер звонящий введёт номер заказа,
и если такой заказ есть в базе, наш сценарий проверит номера
телефонов, привязанные к нему. Далее если номер звонящего будет
идентифицирован как номер клиента, произойдет соединение с
курьером, ответственным за заказ, и наоборот.
Например, звонок курьера клиенту будет выглядеть следующим образом:
Если номер телефона звонящего не будет найден в базе, ему предложат перезвонить с того номера, который использовался при оформлении заказа, или переключиться на оператора.
Перейдем непосредственно к реализации.
Вам понадобятся
-
верифицированный аккаунт 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, VoximplantExceptionif __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 вы сможете сгенерировать при создании сервисного аккаунта в разделе Служебные аккаунты в настройках панели.
APPLICATION_ID появится в адресной строке при переходе в ваше приложение.
В качестве ключа (KEY) будет использоваться пятизначный номер заказа, а в качестве значений телефонные номера: courier номер курьера, client номер клиента. TTL нам здесь необходимо для указания срока хранения значений.
3) Осталось запустить файл, чтобы сохранить данные заказа:
python3 kvs.py
Если мы больше не захотим, чтобы клиент и курьер беспокоили друг друга, можно будет удалить их данные из хранилища. Информацию о всех доступных методах key-value storage вы найдёте в нашей документации: management API и VoxEngine.
Код сценария
Код, который необходимо вставить в сценарий kvs-scenario, представлен ниже, его можно смело копировать as is:
Полный код сценария
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: '74990000000', 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 продукт). Если эта тема вас заинтересовала, переходите по ссылке :)