From 6208f0ee1cbe4c9dffb3eabcdbf31eebf252b211 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Thu, 26 Mar 2026 13:40:26 +0100 Subject: [PATCH] Add flash call functionality to `Bird` class with support for gate flash call handling and unit tests. --- services/nginx/app/classes/bird.php | 87 ++++++++++++++++++- .../nginx/app/objects/department_gates_o.php | 2 +- .../tests/Unit/Bird/BirdGateCallFlowTest.php | 81 +++++++++++++++++ 3 files changed, 167 insertions(+), 3 deletions(-) diff --git a/services/nginx/app/classes/bird.php b/services/nginx/app/classes/bird.php index 49609cff..5a7d2ebe 100644 --- a/services/nginx/app/classes/bird.php +++ b/services/nginx/app/classes/bird.php @@ -11,15 +11,22 @@ class bird { public const TEST_OUTBOUND_NUMBER_RAW = '+45 42 33 11 28'; public const TEST_OUTBOUND_NUMBER_E164 = '+4542331128'; + public const OUTGOING_NUMBER_E164 = '+4532330288'; + public const OUTGOING_NUMBER_RAW = '+45 32 33 02 88'; + + public const OUTGOING_NUMBER = '+4532330288'; + private const ALLOWED_HANGUP_CAUSES = ['rejected', 'busy']; private const ACCEPTED_CALL_STATUSES = ['accepted', 'ongoing']; private const TERMINAL_GATE_FAILURE_STATUSES = ['rejected', 'busy', 'failed', 'cancelled', 'no-answer', 'completed']; + private const FLASH_GATE_SUCCESS_STATUSES = ['accepted', 'ongoing', 'completed']; + private const FLASH_GATE_FAILURE_STATUSES = ['rejected', 'busy', 'failed', 'cancelled', 'no-answer']; /** * Configuration of the Bird module - * @var bird_c|object + * @var bird_c */ - public $config; + public bird_c $config; public function __construct() { @@ -397,6 +404,20 @@ class bird return $this->sendDeleteRequest('/workspaces/' . rawurlencode($workspaceId) . '/numbers/' . rawurlencode($numberId)); } + public function createFlashCall(string $workspaceId, string $channelId, array $payload): array|object|null + { + $base = $this->flashBase($workspaceId, $channelId); + $this->logBirdAction('BIRD_FLASH_CALL_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId); + return $this->sendPostRequest($base, $payload); + } + + public function getFlashCall(string $workspaceId, string $channelId, string $callId): array|object|null + { + $base = $this->flashBase($workspaceId, $channelId); + $this->logBirdAction('BIRD_FLASH_CALL_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); + return $this->sendGetRequest($base . '/' . rawurlencode($callId)); + } + public function createOutboundTestCallAndHangupWhenAccepted(string $workspaceId, string $channelId, array $options = []): array { return $this->executeCallAndHangupWhenAccepted($workspaceId, $channelId, $options, 'BIRD_TEST_OUTBOUND_CALL'); @@ -528,6 +549,11 @@ class bird return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls'; } + private function flashBase(string $workspaceId, string $channelId): string + { + return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/flashcalls'; + } + private function sayBase(string $workspaceId, string $channelId, string $callId): string { // https://api.bird.com/workspaces/{workspaceId}/channels/{channelId}/calls/{callId}/say @@ -683,6 +709,63 @@ class bird } } + public function callGateViaFlashCall(int $countryCode, int $phone, int $ringTimeout): void + { + $ws = $this->getConfiguredWorkspaceId(); + $ch = $this->getConfiguredChannelId(); + if ($ws === '') { + throw new Exception('Bird workspaceId is not configured for gate flash calls'); + } + if ($ch === '') { + throw new Exception('Bird channelId is not configured for gate flash calls'); + } + + $normalizedRingTimeout = $this->normalizeRingTimeoutValue($ringTimeout); + if ($normalizedRingTimeout === null) { + $normalizedRingTimeout = 30; + } + + $createResponse = $this->createFlashCall($ws, $ch, [ + 'to' => '+' . $countryCode . $phone, + 'ringTimeout' => $normalizedRingTimeout, + ]); + + $initialStatus = $this->extractStatus($createResponse); + $normalizedInitialStatus = $initialStatus === null ? null : strtolower($initialStatus); + if ($normalizedInitialStatus !== null && in_array($normalizedInitialStatus, self::FLASH_GATE_FAILURE_STATUSES, true)) { + throw new Exception('Gate flash call failed with status: ' . $initialStatus); + } + if ($normalizedInitialStatus !== null && in_array($normalizedInitialStatus, self::FLASH_GATE_SUCCESS_STATUSES, true)) { + return; + } + + $callId = $this->extractId($createResponse); + if ($callId === null) { + throw new Exception('Failed to create gate flash call: no call id returned'); + } + + $pollIntervalSeconds = 1; + $maxPollSeconds = max(5, $normalizedRingTimeout + 5); + $maxAttempts = (int)max(1, floor($maxPollSeconds / $pollIntervalSeconds)); + + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + $current = $this->getFlashCall($ws, $ch, $callId); + $status = $this->extractStatus($current); + $normalizedStatus = $status === null ? null : strtolower($status); + if ($normalizedStatus !== null && in_array($normalizedStatus, self::FLASH_GATE_SUCCESS_STATUSES, true)) { + return; + } + if ($normalizedStatus !== null && in_array($normalizedStatus, self::FLASH_GATE_FAILURE_STATUSES, true)) { + throw new Exception('Gate flash call failed with status: ' . $status); + } + if ($attempt < $maxAttempts) { + $this->waitForCallPollInterval($pollIntervalSeconds); + } + } + + throw new Exception('Timed out waiting for gate flash call completion'); + } + protected function getConfiguredWorkspaceId(): string { if (!is_object($this->config)) { diff --git a/services/nginx/app/objects/department_gates_o.php b/services/nginx/app/objects/department_gates_o.php index 7c170061..ed38b3af 100644 --- a/services/nginx/app/objects/department_gates_o.php +++ b/services/nginx/app/objects/department_gates_o.php @@ -269,7 +269,7 @@ class department_gates_o extends db $client = new bird(); try { - $client->callGateAndHangupWhenAccepted( + $client->callGateViaFlashCall( (int)$countryCode, (int)$phone, $timeout, diff --git a/services/nginx/app/tests/Unit/Bird/BirdGateCallFlowTest.php b/services/nginx/app/tests/Unit/Bird/BirdGateCallFlowTest.php index 36cb169f..ecea5f92 100644 --- a/services/nginx/app/tests/Unit/Bird/BirdGateCallFlowTest.php +++ b/services/nginx/app/tests/Unit/Bird/BirdGateCallFlowTest.php @@ -20,11 +20,19 @@ class BirdGateCallClientFake extends bird public array $statusQueue = []; /** @var string[] */ public array $statusesSeen = []; + /** @var string[] */ + public array $flashStatusQueue = []; + /** @var string[] */ + public array $flashStatusesSeen = []; public int $hangupCalls = 0; /** @var array> */ public array $hangupPayloads = []; /** @var array> */ public array $createPayloads = []; + /** @var array> */ + public array $flashCreatePayloads = []; + /** @var array|null */ + public ?array $flashCreateResponse = null; /** * @param string[] $statusQueue @@ -70,6 +78,30 @@ class BirdGateCallClientFake extends bird return ['status' => 'completed']; } + public function createFlashCall(string $workspaceId, string $channelId, array $payload): array|object|null + { + $this->flashCreatePayloads[] = [ + 'workspaceId' => $workspaceId, + 'channelId' => $channelId, + 'payload' => $payload, + ]; + return $this->flashCreateResponse ?? ['id' => 'flash_123', 'status' => 'starting']; + } + + public function getFlashCall(string $workspaceId, string $channelId, string $callId): array|object|null + { + $status = array_shift($this->flashStatusQueue); + if (!is_string($status) || trim($status) === '') { + $status = 'ringing'; + } + $this->flashStatusesSeen[] = $status; + + return [ + 'id' => $callId, + 'status' => $status, + ]; + } + protected function waitForCallPollInterval(int $pollIntervalSeconds): void { // Avoid real sleeps in tests. @@ -204,3 +236,52 @@ it('drops unsupported hangup cause values from request payload', function (): vo expect($client->hangupCalls)->toBe(1); expect($client->hangupPayloads[0])->toBe([]); }); + +it('fails fast when workspace id is missing for gate flash calls', function (): void { + $client = new BirdGateCallClientFake(workspaceId: '', channelId: 'channel_1'); + + expect(fn() => $client->callGateViaFlashCall(45, 12345678, 10)) + ->toThrow(\Exception::class, 'Bird workspaceId is not configured for gate flash calls'); +}); + +it('creates gate flash call with documented ringTimeout payload', function (): void { + $client = new BirdGateCallClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1' + ); + $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'accepted']; + + $client->callGateViaFlashCall(45, 12345678, 10); + + expect($client->flashCreatePayloads)->toHaveCount(1); + expect($client->flashCreatePayloads[0]['workspaceId'])->toBe('workspace_1'); + expect($client->flashCreatePayloads[0]['channelId'])->toBe('channel_1'); + expect($client->flashCreatePayloads[0]['payload']['to'])->toBe('+4512345678'); + expect($client->flashCreatePayloads[0]['payload']['ringTimeout'])->toBe(10); + expect(array_key_exists('timeout', $client->flashCreatePayloads[0]['payload']))->toBeFalse(); +}); + +it('polls flash call and succeeds once accepted', function (): void { + $client = new BirdGateCallClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1' + ); + $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'starting']; + $client->flashStatusQueue = ['ringing', 'accepted', 'completed']; + + $client->callGateViaFlashCall(45, 12345678, 10); + + expect($client->flashStatusesSeen)->toBe(['ringing', 'accepted']); +}); + +it('fails flash gate flow when terminal failure status is returned', function (): void { + $client = new BirdGateCallClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1' + ); + $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'starting']; + $client->flashStatusQueue = ['busy']; + + expect(fn() => $client->callGateViaFlashCall(45, 12345678, 10)) + ->toThrow(\Exception::class, 'Gate flash call failed with status: busy'); +});