Дмитрий Рашевский
18.06.2020
2607

Структура IVR в связке с биллинговой системой и Yandex Speech

Вы когда-нибудь реализовывали сложные схемы IVR? Нет, имеется ввиду не многослойность, а выбор направления в зависимости от полученной информации какого-нибудь стороннего сервиса. Уже сложнее и вы уже начинаете активно думать. В этой статье будет описана схема голосового меню, которое будет направлять на нужные пункты основываясь на полученном результате. Постановка задачи и схема голосового меню Поступила […]

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

Постановка задачи и схема голосового меню

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

  1. При наличии номера в биллинговой системе и положительном балансе
  2. При отрицательном балансе
  3. При аварии на линии. Биллинговая система уже была интегрирована с системой Zabbix
  4. При отсутствии номера

Совместно с клиентом, мы обсудили схему получилось примерно следующее:

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

Скрипт на биллинговой системе

На стороне биллинговой системы находится PHP файл, который обрабатывает входящие запросы в зависимости указанного значения параметра.  Вот список принимаемых значений параметра action:

  • get_vgroup_status — возвращает статус блокировки учетной записи абонента
  • get_account_type — тип абонент — юридическое лицо или физическое
  • get_balance — баланс абонента без копеек
  • get_incident — Возвращается время работы оборудования. -1 — если ничего не найдено
  • cid — номер звонящего
Обращения к скрипту
Обращения к скрипту

Yandex Speech

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

С установкой и настройкой интеграции asterisk и yandex speech вы сможете ознакомиться в статье «Синтез речи средствами Yandex Speech Cloud+Asterisk. Text to Speech».

Скрипт принимает на вход номер телефона и команду say_balance. Получает баланс абонента по этому номеру и передает его Яндексу для озвучки.

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

Сперва создадим файл скрипта. В данном примере скрипт будет располагаться по следующему пути

/opt/scripts/yandex_speechkit.
# vim /opt/scripts/yandex_speechkit/check.php

Начнем написание кода:

  1. Опишем функционал захвата значения аргумента и операции с полученным результатом.
if (preg_match("/^\+?[0-9]{11}$/", $argv[1])) {
    if ($argv[2] == "say_balance") {
        $balance = file_get_contents("https://LK.BILLING.COM/get_client.php?cid=".$argv[1]."&action=get_balance");
        if ($balance == "-1") {
            echo "fail";
            exit();
        }
        $word = "рублей";
        if ($balance == 1) { $word = "рубль"; }
        if ($balance > 1 and $balance < 5) { $word = "рубля"; }
        if (preg_match("/[^1]1$/", $balance)) { $word = "рубль"; }
        $text = "$balance $word.\n";
        $result = generate_voice($text, $balance);
        if ($result == false) {
            echo "fail";
            exit();
        } else {
            echo $result;
        }
    }
} else {
    echo "fail";
}

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

Сначала проверяем указан ли вторым аргументом say_balance и при истинном выражении в переменную $balance записываем полученный результат обращения к биллинговому файлу, рассмотренному в прошлом пункте. При отрицательном балансе выдаем fail и завершаем работу программы. В противном случае создается переменная $word в которой будет храниться спряжение слова рубль. Следующим логическим пунктом стоит отправка полученного ранее баланса и спряжения в yandex, для этого используется созданная функция generate_voice.

Её подробное описание давать не буду. т.к. в интернетах есть примеры отправки текстовой информации в yandex speech в среде PHP. Ниже приведу листинг скрипта.

Полученное значение отправки мы записываем в переменную $result. Программа дополнительно завершает свое выполнение при двух условиях: если результат генерации голоса провалился и вернул False, или просто возвращает значение пути, куда сохранила распознанный звуковой файл.

Именно эта директория нам потребуется для воспроизведения в IVR.

Листинг:

#!/usr/bin/php
<?php

const FORMAT_PCM = "lpcm";
const FORMAT_OPUS = "oggopus";

date_default_timezone_set('Europe/Moscow');

function get_token() {

    $now = time();
    $token_file = "/tmp/yc_iam_token";

    file_put_contents("yc_aster.log", date("Y-m-d H:i:s"). "WEHE ARE HER-1.91", FILE_APPEND);

    // Нет файла токена. Получим токен и сохраним его
    if (!file_exists($token_file)) {
        try {
            $token = chop(shell_exec("/home/asterisk/yandex-cloud/bin/yc iam create-token"));
            file_put_contents($token_file, $token);
            return $token;
        } catch (Exception $e) {
	    file_put_contents("yc_aster.log", date("Y-m-d H:i:s"). "[ERROR] TOKEN_FILE error: ". $token, FILE_APPEND);
            return false;
        }
    }

    $diff = $now - filemtime('/tmp/yc_iam_token');

    // Прошло больше трех часов с обновления токена, пора полуичть новый
    if ($diff > 10800) {
        try {
            $token = chop(shell_exec("/home/asterisk/yandex-cloud/bin/yc iam create-token"));
            file_put_contents($token_file, $token);
            return $token;
        } catch (Exception $e) {
	    file_put_contents("yc_aster.log", date("Y-m-d H:i:s"). "[ERROR] time_diff error: ". $token, FILE_APPEND);
            return false;
        }
    // Прошло менее трех часов с обновления токена, токен в файле еще актуален
 	   } else {
        file_put_contents("yc_aster.log", date("Y-m-d H:i:s"). "[ERROR] time_diff error: $diff". $token, FILE_APPEND);
        return file_get_contents('/tmp/yc_iam_token');
    }

    file_put_contents("yc_aster.log", date("Y-m-d H:i:s"). "[ERROR] TOKEN_FILE error: ". $token_file, FILE_APPEND);
    return false;

}

function generate_voice($speech, $balance) {

    // Если у нас уже был такой баланс, то можем сообщить его без обращения в Яндекс
    if (file_exists("/opt/scripts/yandex_speechkit/voice/balance/$balance.wav")) {
	    file_put_contents("yc_aster.log","we are here-1.5\r\n",FILE_APPEND);
        return "/opt/scripts/yandex_speechkit/voice/balance/$balance";
    }

    // Токен работает 12 часов

    $token =  get_token();

    if ($token == false) {
        file_put_contents("yc_aster.log", date("Y-m-d H:i:s"). "[ERROR] GET TOKEN error: ". $token, FILE_APPEND);
        return false;
    } else {
    	file_put_contents("yc_aster.log","we are here\r\n",FILE_APPEND);
    }

    $folderId = "YANDEX_FOLDER_ID"; # Идентификатор каталога
    $url = "https://tts.api.cloud.yandex.net/speech/v1/tts:synthesize";
    $post = "text=" . urlencode($speech) . "&voice=jane&folderId=${folderId}&sampleRateHertz=8000&format=" . FORMAT_PCM;
    $headers = ['Authorization: Bearer ' . $token];
    $ch = curl_init();

    curl_setopt($ch, CURLOPT_AUTOREFERER, TRUE);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, TRUE);
    curl_setopt($ch, CURLOPT_HEADER, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    if ($post !== false) {
        curl_setopt($ch, CURLOPT_POST, 1);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $post);
    }
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

    $response = curl_exec($ch);
    //echo "$response";
    if (curl_errno($ch)) {
    	file_put_contents("yc_aster.log", date("Y-m-d H:i:s"). "[ERROR] CURL error: ". $response, FILE_APPEND);
        return false;
        print "Error: " . curl_error($ch);
    }

    if (curl_getinfo($ch, CURLINFO_HTTP_CODE) != 200) {
        $decodedResponse = json_decode($response, true);
	    file_put_contents("yc_aster.log", date("Y-m-d H:i:s"). "[ERROR] JSON error: ". $decodedResponse, FILE_APPEND);
        return false;
        echo "Error code: " . $decodedResponse["error_code"] . "\r\n";
        echo "Error message: " . $decodedResponse["error_message"] . "\r\n";
    } else {
        try {
            file_put_contents("/tmp/$balance.raw", $response);
        } catch (Exception $e) {
	    file_put_contents("yc_aster.log", date("Y-m-d H:i:s"). "[ERROR] error to try add raw file: ". $e, FILE_APPEND);
            return false;
        }
    }

    curl_close($ch);

    if (file_exists("/tmp/$balance.raw")) {
        shell_exec("sox -r 8000 -b 16 -e signed-integer -c 1 /tmp/$balance.raw /opt/scripts/yandex_speechkit/voice/balance/$balance.wav");
        if (file_exists("/opt/scripts/yandex_speechkit/voice/balance/$balance.wav")) {
            return "/opt/scripts/yandex_speechkit/voice/balance/$balance";
        } else {
	    file_put_contents("yc_aster.log", date("Y-m-d H:i:s"). "NOT file /opt/scripts/yandex_speechkit/voice/balance/$balance.wav exist", FILE_APPEND);
            return false;
        }
    } else {
        file_put_contents("yc_aster.log", date("Y-m-d H:i:s"). "NOT file /tmp/$balance.raw exist", FILE_APPEND);
        return false;
    }

     file_put_contents("yc_aster.log","we are here-6\r\n",FILE_APPEND);
}


if (preg_match("/^\+?[0-9]{11}$/", $argv[1])) {
    if ($argv[2] == "say_balance") {
    	$balance = file_get_contents("https://LK.BILLING.COM/get_client.php?cid=".$argv[1]."&action=get_balance");
        if ($balance == "-1") {
            echo "fail";
            exit();
        }
        $word = "рублей";
        if ($balance == 1) { $word = "рубль"; }
        if ($balance > 1 and $balance < 5) { $word = "рубля"; }
        if (preg_match("/[^1]1$/", $balance)) { $word = "рубль"; }
        $text = "$balance $word.\n";
        $result = generate_voice($text, $balance);
        if ($result == false) {
	        file_put_contents("yc_aster.log", date("Y-m-d H:i:s")." результат обюращения в яндекс: $result", FILE_APPEND);
	        echo "fail";
            exit();
        } else {
            echo $result;
        }
    }
} else {
    echo "fail";
}

Логика диалплана

Чтобы при входящем звонке, сразу отрабатывалось разпознавание номера, то нужно в параметрах транка изменить/добавить параметр context и его значение указать свое собственное. В данном примере — это контекст [from-trunk-pre]

Приступим к написанию диалплана укажем шаблон для входящих DID номеров. которые у вас на АТС. он может быть и один. И первым приоритетом отметим для себя “комментарий” в диалплане приложением NoOp. Следующим по списку будет поиск в биллинге номера и результат занесем в переменную exist c помощью команды Set. И в зависимости от полученных значений выбираем направление: ivr-1 или ivr-2.

[from-trunk-pre]
exten => s,1,Set(exist=${SHELL(/usr/bin/curl --max-time 1 -k https://LK.BILLING>COM/get_client.php?cid=${CALLERID(num)})})
same => n,GotoIf($[ "${exist}" =~ "^(not_exist|exist)$" ]?${exist})

same => n(not_exist),NoOp('Клиент - не найден')
same => n,Goto(ivr-1,s,1)

same => n(exist),NoOp('Клиент - найден')
same => n,Goto(ivr-2,s,1)

Для IVR, где клиента нет в биллинговой системе описывать её реализацию не буду. Как настроить IVR вы сможете прочитать в статье «Настройка Интерактивного голосового меню (IVR)».

Перейдем к описанию интерактивного голосового меню, при условии, когда номер нашелся. Первым приоритетом будем снова обращаться к биллинговой системе с параметром get_vgroup_status и вторым обращение получим баланс на лицевом счете.

exten => s,1,Set(blocked=${SHELL(curl --max-time 1 -k "https://LK.BILLING>COM/get_client.php?cid=${CALLERID(num)}&action=get_vgroup_status")})
same  => n,Verbose(2, "get_vgroup_status returns: ${blocked}")
same  => n,Set(balance=${SHELL(curl --max-time 1 -k "https://LK.BILLING>COM/get_client.php?cid=${CALLERID(num)}&action=get_balance")})

Теперь мы установим в переменную путь к звуковому файлу с сообщением о балансе и будем проверять полученный ответ в этой переменной. Будет это устанавливаться выполнением php скриптом, описанным в предыдущем пункте. При fail — направим на сообщение, которое озвучит что не удалось распознать баланс или такого клиента не существует. При противоположном результате, будем сверять проводить проверку с балансом и наличие блокировки.

same  => n,Set(balance_voice=${SHELL(/opt/scripts/yandex_speechkit/check.php ${CALLERID(num)} say_balance)})
same  => n,Verbose(2, "BALANCE: ${balance_voice}")
same  => n,GotoIf($[ "${balance_voice}" = "fail" ]?fail:check_balance)

same  => n(fail),Goto(app-announcement-2,s,1)

same  => n(check_balance),GotoIf($["${blocked}" = 4 || ${balance} < 0]?blocked:continue)

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

same  => n(blocked),Playback(${balance_voice})
same  => n,Goto(ivr-3,s,1)

Метка continue означает, что у звонящего с балансом всё отлично и воспроизведем ему файл. где предлагается выбрать 1 или 2, которые будут указывать на прослушивание его текущего счета и наличии аварии на линии соответственно

same  => n(continue),NoOp(оставайтесь на линии)
same  => n,Background(ivr-2)
same  => n,WaitExten(5)

exten  => 1,1,Playback(${balance_voice})
same  => n,Hangup()

exten  => 2,1,NoOp(====== Проверка наличия аварии на линии ==========)
same  => n,Set(incident=${SHELL(curl --max-time 1 -k "https://LK.BILLING>COM/get_client.php?cid=${CALLERID(num)}&action=get_incident")})

Переменная ${incident} получит статусы аварии на линии, и он будет принимать одно из значений:

  • -1 — Не найдено или пробелема с проверкой
  • 0 — В наличии инцидент
  • 1 — Все хорошо
same => n,GotoIf($[ "${incident}" = "-1" ]?notfound:moveon)
same => n(notfound),Goto(ivr-1,s,1)

same => n(moveon),GotoIf($[ "${incident}" = "0" ]?incident:allright)
same => n(allright),Goto(app-announcement-9,s,1) ; аварий нет
same => n(incident),Goto(app-announcement-10,s,1) ; проводятся ремонтные работы

Листинг диалплана:

[from-trunk-pre]
exten => s,1,Set(exist=${SHELL(/usr/bin/curl --max-time 1 -k https://LK.BILLING>COM/get_client.php?cid=${CALLERID(num)})})
same => n,GotoIf($[ "${exist}" =~ "^(not_exist|exist)$" ]?${exist})

same => n(not_exist),NoOp('Клиент - не найден')
same => n,Goto(ivr-1,s,1)

same => n(exist),NoOp('Клиент - найден')
same => n,Goto(ivr-2,s,1)

[ivr-2]
exten => s,1,Set(blocked=${SHELL(curl --max-time 1 -k "https://LK.BILLING>COM/get_client.php?cid=${CALLERID(num)}&action=get_vgroup_status")})
same  => n,Verbose(2, "get_vgroup_status returns: ${blocked}")
same  => n,Set(balance=${SHELL(curl --max-time 1 -k "https://LK.BILLING>COM/get_client.php?cid=${CALLERID(num)}&action=get_balance")})

same  => n,Set(balance_voice=${SHELL(/opt/scripts/yandex_speechkit/check.php ${CALLERID(num)} say_balance)})
same  => n,Verbose(2, "BALANCE: ${balance_voice}")
same  => n,GotoIf($[ "${balance_voice}" = "fail" ]?fail:check_balance)

same  => n(fail),Goto(app-announcement-2,s,1)

same  => n(check_balance),GotoIf($["${blocked}" = 4 || ${balance} < 0]?blocked:continue)

same  => n(blocked),Playback(${balance_voice})
same  => n,Goto(ivr-3,s,1)

same  => n(continue),NoOp(оставайтесь на линии)
same  => n,Background(ivr-2)
same  => n,WaitExten(5)

exten  => 1,1,Playback(${balance_voice})
same  => n,Hangup()

exten  => 2,1,NoOp(====== Проверка наличия аварии на линии ==========)
same  => n,Set(incident=${SHELL(curl --max-time 1 -k "https://LK.BILLING>COM/get_client.php?cid=${CALLERID(num)}&action=get_incident")})

same => n,GotoIf($[ "${incident}" = "-1" ]?notfound:moveon)
same => n(notfound),Goto(ivr-1,s,1)

same => n(moveon),GotoIf($[ "${incident}" = "0" ]?incident:allright)
same => n(allright),Goto(app-announcement-9,s,1) ; аварий нет
same => n(incident),Goto(app-announcement-10,s,1) ; проводятся ремонтные работы
Подписаться
Уведомление о
guest
0 Комментарий
Inline Feedbacks
View all comments

Остались вопросы?

Я - Виталий Шелест, менеджер компании Voxlink. Хотите уточнить детали или готовы оставить заявку? Укажите номер телефона, я перезвоню в течение 3-х секунд.

VoIP оборудование

ближайшие курсы

ближайшие Вебинары

ONLINE

10 доводов в пользу Asterisk

Распространяется бесплатно.

Asterisk – программное обеспечение с открытым исходным кодом, распространяется по лицензии GPL. Следовательно, установив один раз Asterisk вам не придется дополнительно платить за новых абонентов, подключение новых транков, расширение функционала и прочие лицензии. Это приближает стоимость владения станцией к нулю.

Безопасен в использовании.

Любое программное обеспечение может стать объектом интереса злоумышленников, в том числе телефонная станция. Однако, сам Asterisk, а также операционная система, на которой он работает, дают множество инструментов защиты от любых атак. При грамотной настройке безопасности у злоумышленников нет никаких шансов попасть на станцию.

Надежен в эксплуатации.

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

Гибкий в настройке.

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

Имеет огромный функционал.

Во многом именно Asterisk показал какой должна быть современная телефонная станция. За многие годы развития функциональность Asterisk расширилась, а все основные возможности по-прежнему доступны бесплатно сразу после установки.

Интегрируется с любыми системами.

То, что Asterisk не умеет сам, он позволяет реализовать за счет интеграции. Это могут быть интеграции с коммерческими телефонными станциями, CRM, ERP системами, биллингом, сервисами колл-трекинга, колл-бэка и модулями статистики и аналитики.

Позволяет телефонизировать офис за считанные часы.

В нашей практике были проекты, реализованные за один рабочий день. Это значит, что утром к нам обращался клиент, а уже через несколько часов он пользовался новой IP-АТС. Безусловно, такая скорость редкость, ведь АТС – инструмент зарабатывания денег для многих компаний и спешка во внедрении не уместна. Но в случае острой необходимости Asterisk готов к быстрому старту.

Отличная масштабируемость.

Очень утомительно постоянно возвращаться к одному и тому же вопросу. Такое часто бывает в случае некачественного исполнения работ или выбора заведомо неподходящего бизнес-решения. С Asterisk точно не будет такой проблемы! Телефонная станция, построенная на Asterisk может быть масштабируема до немыслимых размеров. Главное – правильно подобрать оборудование.

Повышает управляемость бизнеса.

Asterisk дает не просто набор полезных функций, он повышает управляемость организации, качества и комфортности управления, а также увеличивает прозрачность бизнеса для руководства. Достичь этого можно, например, за счет автоматизации отчетов, подключения бота в Telegram, санкционированного доступа к станции из любой точки мира.

Снижает расходы на связь.

Связь между внутренними абонентами IP-АТС бесплатна всегда, независимо от их географического расположения. Также к Asterisk можно подключить любых операторов телефонии, в том числе GSM сим-карты и настроить маршрутизацию вызовов по наиболее выгодному тарифу. Всё это позволяет экономить с первых минут пользования станцией.