Голосовые звонки в Laravel используя Nexmo

Это перевод второй части из серии статей написанных Michael Heap, посвященных созданию многопользовательской "Help Desk" системы на Laravel.

В прошлой статье мы научились отправлять и принимать SMS с помощью Laravel и Nexmo. Вам стоит прочитать первую статью перед тем как вы продолжите чтение этой.

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

Что вам потребуется

Как и в первой статье, вам потребуется рабочий аккаунт Nexmo и Nexmo Command Line tool. Так же вам потребуется весь код который мы написали в прошлой статье.

Так же не забудьте, что вам необходимо чтобы ваш локальный сервер был доступен из вне (чтобы сервер Nexmo мог отправлять запросы на ваш Webhook). Информацию о том, как настроить проброс портов, вы так же можете найти в первой части.

Выбор способа нотификации

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

Откройте файл resources/views/ticket/create.blade.php и добавьте этот код между recipient и submit полями:

<div class="form-group">
  <div class="radio">
    <label>
      <input type="radio" name="notification_method" value="sms">
      SMS
    </label>
  </div>
  <div class="radio">
    <label>
      <input type="radio" name="notification_method" value="voice">
      Voice
    </label>
  </div>
</div>

Этот код добавит radio-кнопки для выбора типа нотификации.

Кроме этого, нам так же нужно внести изменения в TicketController, чтобы он знал как обрабатывать наши новые кнопки. Откройте app/Http/Controllers/TicketController.php и измените метод store. Нам нужно указать что поле notification_method является обязательным:

$data = $request->validate([
  'title' => 'required',
  'content' => 'required',
  'recipient' => 'required|exists:users,id',
  'channel' => 'required',
  'notification_method' => 'required',
]);

Далее найдите место (в том же store методе) где происходит отправка SMS, и оберните его с помощью if, добавив проверку на то, какой метод нотификации выбран:

if ($data['notification_method'] === 'sms') {
  Notification::send($ticket->subscribedUsers()->get(), new TicketCreated($entry));
} elseif ($data['notification_method'] === 'voice') {
  // Make a voice call here
} else {
  throw new \Exception('Invalid notification method provided');
}

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

Когда мы совершаем голосовой звонок, Nexmo будет информировать нас в двух случаях: на звонок ответили, и статус звонка изменился.

На звонок ответили

Когда на наш звонок кто-то ответил (подняли трубку), Nexmo отправит GET запрос на наш answer_url. Мы, в свою очередь, должны будем вернуть ответ в JSON формате, в котором будет информацию о том, что делать дальше. Такой JSON ответ так же называется - Nexmo Call Controll Object (NCCO).

Первое, что нам необходимо сделать, создать WebhookController который будет обрабатывать входящие запросы:

$ php artisan make:controller WebhookController

Если мы хотим чтобы робот проговорил наше сообщение пользователю, нам необходимо использовать talk action. В JSON это выглядит так:

[
 {
  "action": "talk",
  "text": "This is an example talk action"
 }
]

Как видите, мы указываем "talk" в качестве действия которое необходимо выполнить, и наше сообщение в поле "text".

Теперь давайте возьмем пример выше, и реализуем метод [email protected], в котором будем возвращать наш NCCO. Мы будем возвращать содержимое ответа на заявку, или сообщение об ошибке (если такого ответа на заявку не найдено). Добавьте метод answer в ваш WebhookController:

public function answer(TicketEntry $ticket) {
  if (!$ticket->exists) {
    return response()->json([
      [
        'action' => 'talk',
        'text' => 'Sorry, there has been an error fetching your ticket information'
      ]
    ]);
  }

  return response()->json([
    [
      'action' => 'talk',
      'text' => $ticket->content
    ]
  ]);
}

И не забудьте добавить use App\TicketEntry; в начале файла в WebhookController. Пока не забыли, добавим маршрут, чтобы наш проект знал куда отправлять запрос. Поместите эту строчку в конец файла routes/web.php:

Route::get('/webhook/answer/{ticket?}', '[email protected]');

Теперь вы можете перейти в вашем браузере по адресу http://localhost:8000/webhook/answer/{ticket} (замените {ticket} на ID существующей или не существующей заявки, и вы должны увидеть соответственно либо содержимое заявки, либо сообщение об ошибке.

На заметку: не делайте так в реальной жизни. Доступ к webhook/answer/{ticket} должен быть ограничен только для Nexmo, иначе злоумышленники могут вам напакостить.

События во время звонка

В дополнение к тому, что Nexmo присылает нам GET запрос на anwser_url, он так же будет присылать POST запрос о событиях связанных с этим звонком на event_url. Nexmo запрашивает event_url в следующих случаях:

  • Идет вызов
  • На звонок ответили
  • Все участники положили трубку
  • Доступна запись звонка

Информация об этих событиях может нам потребоваться в будущем, а пока что мы просто будем их логировать.

Добавьте импорт в WebhookController:

use Log;

После этого вам нужно добавить метод event, который будет обрабатывать входящие запросы о событиях:

public function event(Request $request) {
  Log::info('Call event', $request->all());
  return response('', 204);
}

Тут мы просто логируем все данные переданные в запросе, и отправляем ответ что запрос обработан успешно.

Не забываем добавить маршрут в routes/web.php, который будет обрабатывать запрос:

Route::post('/webhook/event', '[email protected]');

Так как это POST запрос, нам нужно отключить СSRF защиту для этого URL, чтобы Nexmo мог отправлять нам запросы:

protected $except = [
  'ticket-entry',
  'webhook/event'
];

Чтобы убедиться что все работает как надо, отправьте POST запрос на http://localhost:8000/webhook/event с любым содержимым, и вы должны будете увидеть данные которые вы послали в файле storage/logs/laravel.log.

Настройка Nexmo, создание приложения

Теперь, когда мы подготовили все маршруты, по которым к нам будет обращаться Nexmo, нам необходимо настроить Nexmo и рассказать ему о наших маршрутах. Для простоты вещей мы будем использовать Nexmo CLI утилиту. (Как ее установить и настроить читайте в первой части статьи).

Нам понадобится команда app:create которая принимает answer_url и event_url в качестве параметров, и возвращает нам приватный ключ, который мы будем использовать при обращении к Nexmo API. Выполните эту команду, заменив Ngrok URL на ваш собственный (выполнять нужно в корне проекта):

$ nexmo app:create LaravelNews http://abc123.ngrok.io/webhook/answer http://abc123.ngrok.io/webhook/event --keyfile private.key

После того как мы создали приложение, нам нужно ассоциировать его с нашим телефонным номером, чтобы Nexmo знал какой именно answer_url запрашивать при звонке на конкретный номер. Нам потребуется Application ID, который нам вернула команда app:create и наш номер телефона который мы купили в первой части:

$ nexmo link:app {YOUR_NUMBER} {APPLICATION_ID}

nexmo/laravel

Установите эту библиотеку с помощью Composer. Она позволит с легкостью использовать Nexmo API из вашего проекта.

$ composer require nexmo/laravel

Ранее мы добавили secret и public API ключи в наш .env файл, но для совершения голосовых звонков нам потребуется другой ключ, который мы сгенерировали выше с помощью команды app:create). Откройте файл .env и укажите путь к файлу private.key и application_id которые вы получили ранее:

NEXMO_APPLICATION_ID={application_id}
NEXMO_PRIVATE_KEY=./private.key

Обычно, при использование библиотеки nexmo/laravel, вся настройка происходит в файле config/nexmo.php, но в нашем случае настройку необходимо производить в файле config/services.php:

// config/services.php
'nexmo' => [
  'key' => env('NEXMO_KEY'),
  'secret' => env('NEXMO_SECRET'),
  'sms_from' => env('NEXMO_NUMBER'),
  'private_key' => env('NEXMO_PRIVATE_KEY'),
  'application_id' => env('NEXMO_APPLICATION_ID'),
],

Совершаем голосовой звонок

Теперь, когда вы все настроили, настало время совершить ваш первый звонок из Laravel. Необходимо изменить TicketControler, и добавить в него код для совершения звонка, если пользователь выбрал метод "voice" в качестве способа нотификации.

Чтобы совершить голосовой звонок, вам потребуеются: номер кому звонить, номер с которого звонить, а так же answer_url и event_url для данного звонка. Возможность указывать кастомные answer_url и event_url очень важна, так как таким образом мы можем указывать уникальные URL в которых будет ID заявки для которой был совершен звонок.

Откройте TicketController и прокрутите до места где мы оставили комменатарий // Make a voice call here. Замените этот комментарий следующим кодом (убедитесь что вы заменили ngrok url на ваш):

$currentHost = 'http://abc123.ngrok.io';
Nexmo::calls()->create([
  'to' => [[
    'type' => 'phone',
    'number' => $cc->user->phone_number
  ]],
  'from' => [
    'type' => 'phone',
    'number' => config('services.nexmo.sms_from')
  ],
  'answer_url' => [$currentHost.'/webhook/answer/'.$entry->id],
  'event_url' => [$currentHost.'/webhook/event']
]);

И не забудьте добавить импорт use Nexmo; вверху контроллера.

Наконец-то мы закончили, и можем совершить наш первый звонок. Создайте новую заявку и выберите метод "voice" в качестве способа нотификации. Ваш телефон должен зазвонить, и когда вы ответите, вы должны будете услышать как робот произнесет текст вашей заявки.

Получение ответа от пользователя

Все хорошо, но это был только первый шаг. Так же как с SMS нотификация, мы хотим чтобы пользователь мог оставить ответ, просто произнеся его голосом.

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

Чтобы Nexmo записал звонок, нам необходимо изменить наш NCCO ответ, и попросить Nexmo сделать это. Откройте ваш WebhookController и измените метод answer, чтобы он возвращал код ниже:

return response()->json([
  [
    'action' => 'talk',
    'text' => $ticket->content
  ],
  [
    'action' => 'talk',
    'text' => 'To add a reply, please leave a message after the beep, then press the pound key',
    'voiceName' => 'Brian'
  ],
  [
    'action' => 'record',
    'endOnKey' => '#',
    'beepStart' => true
  ]
]);

Попробуйте создать еще одну заявку. На этот раз вы должны будете услышать как робот попросит вас оставить ответ и нажать на кнопку '#'.

Загружаем запись звонка

После того как звонок будет завершен, Nexmo отправит запрос на ваш event_url, в котором будет содержаться recording_url по которому вы сможете скачать запись звонка. Выглядит это примерно так:

{
  "start_time": "2018-02-11T15:02:28Z",
  "recording_url": "https://api.nexmo.com/v1/files/092c732b-19b0-468c-bcd6-3f069650ddaf",
  "size": 28350,
  "recording_uuid": "8c618cc3-5bf5-42af-91cd-b628857f7fea",
  "end_time": "2018-02-11T15:02:35Z",
  "conversation_uuid": "CON-9ff341d8-fb45-47c7-aa27-9144c8db0447",
  "timestamp": "2018-02-11T15:02:35.889Z"
}

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

Опять откройте WebhookController и измените метод event так, чтобы он проверял есть ли в запросе поле recording_url, и если есть вызывал transcribeRecording метод (мы добавим его чуть позже):

public function event(Request $request) {
  $params = $request->all();
  Log::info('Call event', $params);
  if (isset($params['recording_url'])) {
    $voiceResponse = $this->transcribeRecording($params['recording_url']);
  }
  return response('', 204);
}

Теперь добавьте метод transcribeRecording:

public function transcribeRecording($recordingUrl) {
  $audio = \Nexmo::get($recordingUrl)->getBody();
}

В переменной $audio у нас теперь хранится аудио запись звонка, которую мы чуть позже отправим в переводчик.

Переводим аудио в текст

Чтобы перевести аудио в текст, мы будем использовать сервис IBM speech-to-text. Если у вас еще нету там аккаунта - самое время создать его.

После того, как вы создадите Bluemix аккаунт и зайдете в систему, перейдите на страницу проектов и нажмите на Create project кнопку. В появившемся модальном окне нажмите на Get Watson Services, выберите Speech to Text и нажмите Add Services справа. Введите имя вашего проекта и создайте его.

Внизу вы должны увидеть секцию Credentials, нажмите на Show справа и запишите ваши username и password.

У IBM нету официальной PHP библиотеки, поэтому мы будем использовать Guzzle для совершения запросов к их API. Мы просто будем посылать POST запрос с аудио файлом который мы получили от Nexmo, и получать в ответ JSON с текстом.

Добавьте в ваш [email protected] метод следующий код (замените username и password на ваши):

$client = new \GuzzleHttp\Client([
  'base_uri' => 'https://stream.watsonplatform.net/'
]);

$transcriptionResponse = $client->request('POST', 'speech-to-text/api/v1/recognize', [
  'auth' => ['username', 'password'],
  'headers' => [
    'Content-Type' => 'audio/mpeg',
  ],
  'body' => $audio
]);

$transcription = json_decode($transcriptionResponse->getBody());

IBM возвращает нам JSON, который выглядит примерно так:

{
 "results": [
  {
   "alternatives": [
    {
     "confidence": 0.767,
     "transcript": "hello "
    }
   ],
   "final": true
  },
  {
   "alternatives": [
    {
     "confidence": 0.982,
     "transcript": "this is a test "
    }
   ],
   "final": true
  }
 ],
 "result_index": 0
}

Нам нужно разобрать этот ответ, составить из него строку и добавить ее в качестве ответа на нашу заявку. Добавьте это в конец transcribeRecording метода:

$voiceResponse = '';
foreach ($transcription->results as $result) {
  $voiceResponse .= $result->alternatives[0]->transcript.' ';
}

return $voiceResponse;

Теперь когда у нас есть расшифрованный текст, мы можем добавить его в ответ на заявку. К сожалению, мы не можем соотнести recoding_url и номер телефона пользователя который оставил заявку, поэтому мы не можем узнать к какой заявке оставлять ответ.

На заметку: Для простоты, мы будем добавлять ответ к последней созданной заявке. В реальном мире, вам потребуется отслеживать поле conversation_uuid когда вы совершаете звонок, и сохранять его, дабы отследить какой пользователь оставил заявку.

Измените [email protected] метод, и добавьте в него создание нового TicketEntry если в запросе присутствует поле recording_url:

if (isset($params['recording_url'])) {
  $voiceResponse = $this->transcribeRecording($params['recording_url']);

  $ticket = Ticket::all()->last();
  $user = $ticket->subscribedUsers()->first();

  $entry = new TicketEntry([
    'content' => $voiceResponse,
    'channel' => 'voice',
  ]);

  $entry->user()->associate($user);
  $entry->ticket()->associate($ticket);
  $entry->save();
}

Так же добавьте импорт use App\Ticket; в начале WebhookController'а.

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

Заключение

Поздравляю, вы добрались до конца! Мы взяли наш проект, который умел только отправлять SMS, и добавили в него возможность совершать голосовые звонки, принимать ответ от пользователя и даже переводить аудио в текст с помощью сервиса IBM.

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

Спасибо что дочитали до этого места.

Опубликовано:

Категории: Статьи