MikoPBX на Aster conf, TTS скрипт для голосования

Совсем недавно завершилась ежегодная конференция Asterconf. Нам посчастливилось в ней участвовать. На этот раз мы приготовили ряд мастер классов по настройке и кастомизации MikoPBX — бесплатной АТС с открытым исходным кодом.

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

Если заинтересовало, то под кат, подробно разберем пример реализации…

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

Итак, имеем установленную MikoPBX. Для начала приведу примера простого класса для генерации речи с использованием API Yandex:

Класс YandexSynthesize для генерации речи

voice);
        $fullFileName            = self::TTS_DIR .'/'. $speech_filename . $result_extension;
        $fullFileNameFromService = self::TTS_DIR .'/'. $speech_filename . $speech_extension;

        // Проверим вдург мы ранее уже генерировали такой файл.
        if (file_exists($fullFileName) && filesize($fullFileName) > 0) {
            return self::TTS_DIR .'/'. $speech_filename;
        }

        // Файла нет в кеше, будем генерировать новый.
        $post_vars = [
            'lang'            => 'ru-RU',
            'format'          => 'lpcm',
            'speed'           => '1.0',
            'sampleRateHertz' => '8000',
            'voice'           => $this->voice,
            'text'            => urldecode($text_to_speech),
        ];

        $fp   = fopen($fullFileNameFromService, 'wb');
        $curl = curl_init();
        curl_setopt($curl, CURLOPT_HTTPHEADER, ["Authorization: Api-Key ".self::API_KEY]);
        curl_setopt($curl, CURLOPT_FILE, $fp);
        curl_setopt($curl, CURLOPT_POST, true);
        curl_setopt($curl, CURLOPT_TIMEOUT, 4);
        curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query($post_vars));
        curl_setopt($curl, CURLOPT_URL, 'https://tts.api.cloud.yandex.net/speech/v1/tts:synthesize');
        curl_exec($curl);
        $http_code = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
        curl_close($curl);
        fclose($fp);

        if (200 === $http_code && file_exists($fullFileNameFromService) && filesize($fullFileNameFromService) > 0) {
            exec("sox -r 8000 -e signed-integer -b 16 -c 1 -t raw $fullFileNameFromService $fullFileName");
            if (file_exists($fullFileName)) {
                // Удалим raw файл.
                @unlink($fullFileNameFromService);
                // Файл успешно сгененрирован
                return self::TTS_DIR.'/'.$speech_filename;
            }
        } elseif (file_exists($fullFileNameFromService)) {
            @unlink($fullFileNameFromService);
        }
        return null;
    }
}

Файл позволяет:

  • Преобразовать текст в речь

  • Для каждого текста проверяет хэш сумму, если файл уже был создан ранее, то обращения к API не происходит

Требования

  • PHP 7.4+

  • В классе необходимо прописать значение »API_KEY» — ключ для генерации речи Yandex

  • При необходимости следует указать диктора $voice, по умолчанию выбран голос »alena»

Итак, начнем работу. Первым делом необходимо ответить на вызов:

set_variable('AGIEXITONHANGUP', 'yes');
$agi->set_variable('AGISIGHUP', 'yes');
$agi->set_variable('__ENDCALLONANSWER', 'yes');
$agi->answer();

Установленные переменные канала необходимы для корректного завершения работы скрипта при hangup на канале.

Поприветствуем клиента:

makeSpeechFromText($infoMessage);

Результаты голосования будут храниться в файлах. Пример структуры каталогов:

/storage/usbdisk1/mikopbx/log/voting
├── 0
│   ├── 74952232222
│   └── 74952293042
└── 1
    └── 79257180000
  • В каталоге »0» сохраняется информация, по номерам, что проголосовали против

  • В каталоге »1», номера, что проголосовали »ЗА»

Добавим проверку на повторное голосование:

request['agi_callerid']);
if($res === 0){
    $filenameAlert = $ys->makeSpeechFromText('Вы уже голосовали ранее. Результат голосования:');
    $agi->exec('Playback', $filenameAlert);
    
    $yes = shell_exec('ls -l '.$logDir.'/1/ | grep -v total | wc -l');
    $agi->exec('Playback', $ys->makeSpeechFromText('Поддержали '.$yes));

    $no  = shell_exec('ls -l '.$logDir.'/0/ | grep -v total | wc -l');
    $agi->exec('Playback', $ys->makeSpeechFromText('Против '.$no));

    $agi->hangup();
    exit(0);
}

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

Теперь добавим проверку, что звонящий не является роботом. Предложим решить простой пример.

makeSpeechFromText($checkRobots);

Проверим результат ввода:

exec('Playback', $filenameInfo);
$result 		 = $agi->getData($filenameCheck, 3000, 1);
$selectedNum = $result['result']??'';
if (empty($selectedNum) || (int)$selectedNum !== ($a + $b)) {
    $filenameAlert = $ys->makeSpeechFromText("Ответ не верный");
    $agi->exec('Playback', $filenameAlert);
    $filenameAlert = $ys->makeSpeechFromText("Вы ввели цифру " . $selectedNum);
    $agi->exec('Playback', $filenameAlert);
    $agi->hangup();
    exit(0);
}

$filenameAlert = $ys->makeSpeechFromText("Пример решен верно.");
$agi->exec('Playback', $filenameAlert);

Зафиксируем результат голосования:

makeSpeechFromText($text);

$result = $agi->getData($filenameAlert, 3000, 1);
$selectedNum = (int)($result['result']??'0');

$resultDir = $logDir.'/'.$selectedNum;
Util::mwMkdir($resultDir);
file_put_contents("$resultDir/{$agi->request['agi_callerid']}", '1');

$filenameAlert = $ys->makeSpeechFromText('Спасибо, ваш голос учтен!');
$agi->exec('Playback', $filenameAlert);
$agi->hangup();

Итоговый вариант скрипта:

voice);
        $fullFileName            = self::TTS_DIR .'/'. $speech_filename . $result_extension;
        $fullFileNameFromService = self::TTS_DIR .'/'. $speech_filename . $speech_extension;

        // Проверим вдург мы ранее уже генерировали такой файл.
        if (file_exists($fullFileName) && filesize($fullFileName) > 0) {
            return self::TTS_DIR .'/'. $speech_filename;
        }

        // Файла нет в кеше, будем генерировать новый.
        $post_vars = [
            'lang'            => 'ru-RU',
            'format'          => 'lpcm',
            'speed'           => '1.0',
            'sampleRateHertz' => '8000',
            'voice'           => $this->voice,
            'text'            => urldecode($text_to_speech),
        ];

        $fp   = fopen($fullFileNameFromService, 'wb');
        $curl = curl_init();
        curl_setopt($curl, CURLOPT_HTTPHEADER, ["Authorization: Api-Key ".self::API_KEY]);
        curl_setopt($curl, CURLOPT_FILE, $fp);
        curl_setopt($curl, CURLOPT_POST, true);
        curl_setopt($curl, CURLOPT_TIMEOUT, 4);
        curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query($post_vars));
        curl_setopt($curl, CURLOPT_URL, 'https://tts.api.cloud.yandex.net/speech/v1/tts:synthesize');
        curl_exec($curl);
        $http_code = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
        curl_close($curl);
        fclose($fp);

        if (200 === $http_code && file_exists($fullFileNameFromService) && filesize($fullFileNameFromService) > 0) {
            exec("sox -r 8000 -e signed-integer -b 16 -c 1 -t raw $fullFileNameFromService $fullFileName");
            if (file_exists($fullFileName)) {
                // Удалим raw файл.
                @unlink($fullFileNameFromService);
                // Файл успешно сгененрирован
                return self::TTS_DIR.'/'.$speech_filename;
            }
        } elseif (file_exists($fullFileNameFromService)) {
            @unlink($fullFileNameFromService);
        }
        return null;
    }
}

$agi    = new AGI();
$agi->set_variable('AGIEXITONHANGUP', 'yes');
$agi->set_variable('AGISIGHUP', 'yes');
$agi->set_variable('__ENDCALLONANSWER', 'yes');
$agi->answer();

$logDir = '/storage/usbdisk1/mikopbx/log/voting';
$ys = new YandexSynthesize();
$cmd = 'ls -l '.$logDir.'/*/'.$agi->request['agi_callerid'];
$res = Processes::mwExec($cmd);
if($res === 0){
    $filenameAlert = $ys->makeSpeechFromText('Вы уже голосовали ранее. Результат голосования:');
    $agi->exec('Playback', $filenameAlert);

    $yes = shell_exec('ls -l '.$logDir.'/1/ | grep -v total | wc -l');
    $agi->exec('Playback', $ys->makeSpeechFromText('Поддержали '.$yes));

    $no  = shell_exec('ls -l '.$logDir.'/0/ | grep -v total | wc -l');
    $agi->exec('Playback', $ys->makeSpeechFromText('Против '.$no));

    $agi->hangup();
    exit(0);
}


$infoMessage = 'Добрый день, 
я голосовой помощник для интерактивного голосования 
по строительству гаражного кооператива.
';
$filenameInfo  = $ys->makeSpeechFromText($infoMessage);

$a = random_int(1, 4);
$b = random_int(1, 5);
$checkRobots = "Проверим, что Вы не робот. 
Введите верный ответ в тональном режиме. 
Решите пример!
$a плюс $b";
$filenameCheck = $ys->makeSpeechFromText($checkRobots);

$agi->exec('Playback', $filenameInfo);
$result = $agi->getData($filenameCheck, 3000, 1);
$selectedNum = $result['result']??'';
if (empty($selectedNum) || (int)$selectedNum !== ($a + $b)) {
    $filenameAlert = $ys->makeSpeechFromText("Ответ не верный");
    $agi->exec('Playback', $filenameAlert);
    $filenameAlert = $ys->makeSpeechFromText("Вы ввели цифру " . $selectedNum);
    $agi->exec('Playback', $filenameAlert);
    $agi->hangup();
    exit(0);
}

$filenameAlert = $ys->makeSpeechFromText("Пример решен верно.");
$agi->exec('Playback', $filenameAlert);

$text = 'Если Вы "ЗА" строительство, 
то нажмите ОДИН, если против, то НОЛЬ';
$filenameAlert = $ys->makeSpeechFromText($text);
$result = $agi->getData($filenameAlert, 3000, 1);
$selectedNum = (int)($result['result']??'0');

$resultDir = $logDir.'/'.$selectedNum;
Util::mwMkdir($resultDir);
file_put_contents("$resultDir/{$agi->request['agi_callerid']}", '1');

$filenameAlert = $ys->makeSpeechFromText('Спасибо, ваш голос учтен!');
$agi->exec('Playback', $filenameAlert);
$agi->hangup();

Теперь распустим скрипт в работу на MikoPBX:

Добавим новое приложение dialplan:

image-loader.svg

Заполните поля »Название»,»Номер для вызова приложения»,»тип кода»

image-loader.svg

На вкладке «Программный код» вставьте текст скрипта и выполните действие »Сохранить».

image-loader.svg

Направьте входящий маршрут на созданное приложение:

image-loader.svg

Теперь можно начать тестировать:)

На приложение можно позвонить и с внутреннего номера, набрав его добавочный (2200110).

Источники знаний

Итоги

Используя функционал »Приложения dialplan» возможно реализовать произвольные сценарии значительно расширяющие возможности АТС. К примеру из PHP возможно обратиться к REST API стороннего сервиса и получив ответ выполнить маршрутизацию канала.

Данный пример не претендует на завершенное решение, но является неплохой отправной точкой для интересных решений :)

Спасибо всем, кто присутствовал на нашем мастер классе Asterconf, и конечно большое спасибо организатором за три интересных, увлекательных дня, было очень круто!

© Habrahabr.ru