From 945eec9940ab766bb5d345359744b4369040a62e Mon Sep 17 00:00:00 2001 From: James Seconde Date: Wed, 27 Nov 2024 15:44:01 +0000 Subject: [PATCH] Voice Additions, DTMF + NCCO (#527) * added DTMF mode with logic * added DTMF events subscribe and stop --- src/Voice/Client.php | 16 +++++ src/Voice/NCCO/Action/Input.php | 44 ++++++++++++- test/Voice/ClientTest.php | 44 +++++++++++++ test/Voice/NCCO/Action/InputTest.php | 72 ++++++++++++++++++++- test/Voice/responses/dtmf-subscribed.json | 0 test/Voice/responses/dtmf-unsubscribed.json | 0 6 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 test/Voice/responses/dtmf-subscribed.json create mode 100644 test/Voice/responses/dtmf-unsubscribed.json diff --git a/src/Voice/Client.php b/src/Voice/Client.php index 7f7f26cc..c50bf1e4 100644 --- a/src/Voice/Client.php +++ b/src/Voice/Client.php @@ -152,6 +152,22 @@ public function playDTMF(string $callId, string $digits): array ]); } + public function subscribeToDtmfEventsById(string $id, array $payload): bool + { + $this->api->update($id . '/input/dtmf', [ + 'eventUrl' => $payload + ]); + + return true; + } + + public function unsubscribeToDtmfEventsById(string $id): bool + { + $this->api->delete($id . '/input/dtmf'); + + return true; + } + /** * @throws ClientExceptionInterface * @throws \Vonage\Client\Exception\Exception diff --git a/src/Voice/NCCO/Action/Input.php b/src/Voice/NCCO/Action/Input.php index d5de32e4..d909d9d7 100644 --- a/src/Voice/NCCO/Action/Input.php +++ b/src/Voice/NCCO/Action/Input.php @@ -4,6 +4,7 @@ namespace Vonage\Voice\NCCO\Action; +use phpDocumentor\Reflection\Types\This; use RuntimeException; use Vonage\Voice\Webhook; @@ -14,6 +15,13 @@ class Input implements ActionInterface { + public const ASYNCHRONOUS_MODE = 'asynchronous'; + public const SYNCHRONOUS_MODE = 'synchronous'; + + public array $allowedModes = [ + self::SYNCHRONOUS_MODE, + self::ASYNCHRONOUS_MODE, + ]; protected ?int $dtmfTimeout = null; protected ?int $dtmfMaxDigits = null; @@ -26,6 +34,8 @@ class Input implements ActionInterface protected ?string $speechLanguage = null; + protected ?string $mode = null; + /** * @var ?array */ @@ -70,6 +80,10 @@ public static function factory(array $data): Input } } + if (array_key_exists('mode', $data)) { + $action->setMode($data['mode']); + } + if (array_key_exists('speech', $data)) { $speech = $data['speech']; $action->setEnableSpeech(true); @@ -136,7 +150,10 @@ public function toNCCOArray(): array 'action' => 'input', ]; - if ($this->getEnableDtmf() === false && $this->getEnableSpeech() === false) { + if ( + $this->getEnableDtmf() === false && $this->getEnableSpeech() === false && $this->getMode() !== + self::ASYNCHRONOUS_MODE + ) { throw new RuntimeException('Input NCCO action must have either speech or DTMF enabled'); } @@ -198,6 +215,10 @@ public function toNCCOArray(): array $data['eventMethod'] = $eventWebhook->getMethod(); } + if ($this->getMode()) { + $data['mode'] = $this->getMode(); + } + return $data; } @@ -365,4 +386,25 @@ public function setEnableDtmf(bool $enableDtmf): Input return $this; } + + public function getMode(): ?string + { + return $this->mode; + } + + public function setMode(?string $mode): self + { + if ($this->getEnableDtmf()) { + if ($mode == self::ASYNCHRONOUS_MODE) { + throw new \InvalidArgumentException('Cannot have DTMF input when using Asynchronous mode.'); + } + } + + if (!in_array($mode, $this->allowedModes)) { + throw new \InvalidArgumentException('Mode not a valid string'); + } + + $this->mode = $mode; + return $this; + } } diff --git a/test/Voice/ClientTest.php b/test/Voice/ClientTest.php index 8d24212c..40e42365 100644 --- a/test/Voice/ClientTest.php +++ b/test/Voice/ClientTest.php @@ -570,6 +570,50 @@ public function testCanPlayTTSIntoCall(): void $this->assertEquals('Talk started', $response['message']); } + public function testCanSubscribeToDtmfEvents(): void + { + $id = '63f61863-4a51-4f6b-86e1-46edebcf9356'; + + $payload = [ + 'https://example.com/events' + ]; + + $this->vonageClient->send(Argument::that(function (RequestInterface $request) use ($id) { + $uri = $request->getUri(); + $uriString = $uri->__toString(); + $this->assertEquals( + 'https://api.nexmo.com/v1/calls/63f61863-4a51-4f6b-86e1-46edebcf9356/input/dtmf', + $uriString + ); + $this->assertEquals('PUT', $request->getMethod()); + + $this->assertRequestJsonBodyContains('eventUrl', ['https://example.com/events'], $request); + + return true; + }))->willReturn($this->getResponse('dtmf-subscribed')); + + $this->voiceClient->subscribeToDtmfEventsById($id, $payload); + } + + public function testCanUnsubscribeToDtmfEvents(): void + { + $id = '63f61863-4a51-4f6b-86e1-46edebcf9356'; + + $this->vonageClient->send(Argument::that(function (RequestInterface $request) use ($id) { + $uri = $request->getUri(); + $uriString = $uri->__toString(); + $this->assertEquals( + 'https://api.nexmo.com/v1/calls/63f61863-4a51-4f6b-86e1-46edebcf9356/input/dtmf', + $uriString + ); + $this->assertEquals('DELETE', $request->getMethod()); + + return true; + }))->willReturn($this->getResponse('dtmf-unsubscribed')); + + $this->voiceClient->unsubscribeToDtmfEventsById($id); + } + /** * @throws ClientExceptionInterface * @throws Client\Exception\Exception diff --git a/test/Voice/NCCO/Action/InputTest.php b/test/Voice/NCCO/Action/InputTest.php index 60928659..3161b341 100644 --- a/test/Voice/NCCO/Action/InputTest.php +++ b/test/Voice/NCCO/Action/InputTest.php @@ -128,6 +128,76 @@ public function testThrowsRuntimeExceptionIfNoInputDefined(): void $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Input NCCO action must have either speech or DTMF enabled'); - (new Input())->toNCCOArray(); + $input = new Input(); + $array = $input->toNCCOArray(); + } + + public function testCanCreateInputSyncNCCOCorrectly(): void + { + $data = [ + 'action' => 'input', + 'eventUrl' => ['https://test.domain/events'], + 'dtmf' => [ + 'maxDigits' => 4, + ], + 'mode' => 'synchronous' + ]; + + $action = Input::factory($data); + $ncco = $action->toNCCOArray(); + + $this->assertSame($data['dtmf']['maxDigits'], $action->getDtmfMaxDigits()); + $this->assertSame($data['dtmf']['maxDigits'], $ncco['dtmf']->maxDigits); + $this->assertSame($data['mode'], $ncco['mode']); + $this->assertSame('POST', $action->getEventWebhook()->getMethod()); + $this->assertSame($data['mode'], $action->getMode()); + } + + public function testCanCreateInputAsyncNCCOCorrectly(): void + { + $data = [ + 'action' => 'input', + 'eventUrl' => ['https://test.domain/events'], + 'mode' => 'asynchronous' + ]; + + $action = Input::factory($data); + $ncco = $action->toNCCOArray(); + + $this->assertSame($data['mode'], $ncco['mode']); + $this->assertSame('POST', $action->getEventWebhook()->getMethod()); + $this->assertSame($data['mode'], $action->getMode()); + } + + public function testCannotCreateInputNCCOWithDtmfAndAsyncMode(): void + { + $this->expectException(\InvalidArgumentException::class); + + $data = [ + 'action' => 'input', + 'eventUrl' => ['https://test.domain/events'], + 'dtmf' => [ + 'maxDigits' => 4, + ], + 'mode' => 'asynchronous' + ]; + + $action = Input::factory($data); + } + + public function testErrorsOnInvalidInput(): void + { + $this->expectException(\InvalidArgumentException::class); + + $data = [ + 'action' => 'input', + 'eventUrl' => ['https://test.domain/events'], + 'dtmf' => [ + 'maxDigits' => 4, + ], + 'mode' => 'syncronus' + ]; + + $action = Input::factory($data); } } diff --git a/test/Voice/responses/dtmf-subscribed.json b/test/Voice/responses/dtmf-subscribed.json new file mode 100644 index 00000000..e69de29b diff --git a/test/Voice/responses/dtmf-unsubscribed.json b/test/Voice/responses/dtmf-unsubscribed.json new file mode 100644 index 00000000..e69de29b