Скрываем номера курьеров и клиентов с помощью key-value хранилища

934d76ea9184c563fc8a1d3afe14a375.png

В условиях пандемии курьерские сервисы стали востребованы как никогда прежде. Чтобы клиент и курьер могли созвониться для уточнения информации по заказу, им нужно знать номера телефонов друг друга. А что насчет соблюдения прайваси? Многие сервисы доставок уже озаботились этим вопросом после не очень приятных инцидентов, о которых вы могли читать в новостях.

Каждый сервис использует свои решения для маскировки номеров клиентов и курьеров. В данной статье я расскажу, как сделать это с помощью key-value хранилища в Voximplant.

Как это будет работать

Мы создадим сценарий, который позволит курьеру и клиенту созваниваться, не зная при этом личные номера телефонов друг друга.

У нас будет только один «нейтральный» номер, на который будут звонить и клиент, и курьер. Номер мы арендуем в панели Voximplant. Затем создадим некую структуру данных, где клиент и курьер будут связаны между собой номером заказа (то есть ключом в терминологии key-value storage).

Так при звонке на арендованный номер звонящий введёт номер заказа, и если такой заказ есть в базе, наш сценарий проверит номера телефонов, привязанные к нему. Далее если номер звонящего будет идентифицирован как номер клиента, произойдет соединение с курьером, ответственным за заказ, и наоборот.

Например, звонок курьера клиенту будет выглядеть следующим образом:

21d23e95c4cd76bfee9b35a5215a8830.png

Если номер телефона звонящего не будет найден в базе, ему предложат перезвонить с того номера, который использовался при оформлении заказа, или переключиться на оператора.

Перейдем непосредственно к реализации.

Вам понадобятся

1) Чтобы начать разработку, войдите в свой аккаунт: manage.voximplant.com/auth. В меню слева нажмите «Приложения», затем «Создать приложение» в правом верхнем углу. Дайте ему имя, например, numberMasking и снова кликните «Создать».

2) Зайдите в новое приложение, переключитесь на вкладку «Сценарии» и создайте сценарий, нажав на »+». Назовём его kvs-scenario. Здесь мы будем писать код, но об этом чуть позже.

3) Сначала перейдем во вкладку «Роутинг» и создадим правило для нашего сценария. Маску (регулярное выражение) оставим ».*» по умолчанию, так правило будет срабатывать для всех номеров.

5dbf44efe9cdc7d5969bea780fd28e6c.png

4) Далее арендуем реальный городской номер. Для этого перейдем в раздел «Номера», выберем и оплатим номер. На него будут звонить и клиент, и курьер, и он будет отображаться вместо их настоящих номеров.

В Voximplant вы можете приобретать в том числе тестовые номера, которые удобно использовать при знакомстве с платформой, но в нашем случае потребуется реальный для совершения исходящего звонка с платформы.

a5a8e0a4c8e8f1941185c2a1033cb28d.png

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 вы сможете сгенерировать при создании сервисного аккаунта в разделе «Служебные аккаунты» в настройках панели. 

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 продукт). Если эта тема вас заинтересовала, переходите по ссылке:)

© Habrahabr.ru