diff --git a/services/nginx/app/classes/bird.php b/services/nginx/app/classes/bird.php index 5a7d2ebe..c37ecd95 100644 --- a/services/nginx/app/classes/bird.php +++ b/services/nginx/app/classes/bird.php @@ -588,6 +588,23 @@ class bird return null; } + protected function extractFrom(array|object|string|null $response): ?string + { + if (is_object($response)) { + if (isset($response->from) && is_string($response->from)) { + return $this->normalizePhoneIdentifier($response->from); + } + return null; + } + if (is_array($response)) { + if (array_key_exists('from', $response) && is_string($response['from'])) { + return $this->normalizePhoneIdentifier($response['from']); + } + return null; + } + return null; + } + private function buildHttpErrorMessage(int $status, string|false|null $response): string { $base = 'Bird API request failed with status ' . $status; @@ -690,6 +707,7 @@ class bird $normalizedRingTimeout = 30; } $options = [ + 'from' => self::OUTGOING_NUMBER_E164, 'to' => '+' . $countryCode . $phone, 'maxPollSeconds' => max(5, (int)$timeout), 'ringTimeout' => $normalizedRingTimeout, @@ -726,10 +744,20 @@ class bird } $createResponse = $this->createFlashCall($ws, $ch, [ + 'from' => self::OUTGOING_NUMBER_E164, 'to' => '+' . $countryCode . $phone, 'ringTimeout' => $normalizedRingTimeout, ]); + $expectedFrom = $this->normalizePhoneIdentifier(self::OUTGOING_NUMBER_E164); + $flashFrom = $this->extractFrom($createResponse); + if ($flashFrom === null) { + throw new Exception('Gate flash call did not confirm caller id'); + } + if ($expectedFrom !== null && $flashFrom !== $expectedFrom) { + throw new Exception('Gate flash call used unexpected caller id: ' . $flashFrom); + } + $initialStatus = $this->extractStatus($createResponse); $normalizedInitialStatus = $initialStatus === null ? null : strtolower($initialStatus); if ($normalizedInitialStatus !== null && in_array($normalizedInitialStatus, self::FLASH_GATE_FAILURE_STATUSES, true)) { @@ -750,6 +778,10 @@ class bird for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { $current = $this->getFlashCall($ws, $ch, $callId); + $currentFrom = $this->extractFrom($current); + if ($expectedFrom !== null && $currentFrom !== null && $currentFrom !== $expectedFrom) { + throw new Exception('Gate flash call switched to unexpected caller id: ' . $currentFrom); + } $status = $this->extractStatus($current); $normalizedStatus = $status === null ? null : strtolower($status); if ($normalizedStatus !== null && in_array($normalizedStatus, self::FLASH_GATE_SUCCESS_STATUSES, true)) { @@ -766,6 +798,22 @@ class bird throw new Exception('Timed out waiting for gate flash call completion'); } + public function callGatePreferringFlashCall(int $countryCode, int $phone, int $timeout): void + { + try { + $this->callGateViaFlashCall($countryCode, $phone, $timeout); + return; + } catch (\Throwable $flashError) { + $this->logBirdAction( + 'BIRD_GATE_FLASH_FALLBACK', + 'Flash gate call failed, falling back to regular call. reason=' . $flashError->getMessage(), + 0 + ); + } + + $this->callGateAndHangupWhenAccepted($countryCode, $phone, $timeout); + } + protected function getConfiguredWorkspaceId(): string { if (!is_object($this->config)) { @@ -857,6 +905,25 @@ class bird } return $timeout; } + + protected function normalizePhoneIdentifier(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + + $trimmed = trim($value); + if ($trimmed === '') { + return null; + } + + $digits = preg_replace('/\D+/', '', $trimmed); + if (!is_string($digits) || $digits === '') { + return null; + } + + return '+' . $digits; + } } diff --git a/services/nginx/app/objects/department_gates_o.php b/services/nginx/app/objects/department_gates_o.php index ed38b3af..845dd262 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->callGateViaFlashCall( + $client->callGatePreferringFlashCall( (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 ecea5f92..20139ea1 100644 --- a/services/nginx/app/tests/Unit/Bird/BirdGateCallFlowTest.php +++ b/services/nginx/app/tests/Unit/Bird/BirdGateCallFlowTest.php @@ -4,18 +4,11 @@ app_require('classes/bird.php'); use classes\bird; -class BirdGateCallConfigValueFake -{ - public function __construct(private readonly mixed $value) {} - - public function getVariableValue(): mixed - { - return $this->value; - } -} - class BirdGateCallClientFake extends bird { + private string $workspaceIdValue = ''; + private string $channelIdValue = ''; + /** @var string[] */ public array $statusQueue = []; /** @var string[] */ @@ -40,11 +33,18 @@ class BirdGateCallClientFake extends bird public function __construct(mixed $workspaceId = 'workspace_1', mixed $channelId = 'channel_1', array $statusQueue = ['accepted']) { $this->statusQueue = $statusQueue; - $this->config = (object)[ - 'workspaceId' => new BirdGateCallConfigValueFake($workspaceId), - 'workplaceId' => new BirdGateCallConfigValueFake($workspaceId), - 'channelId' => new BirdGateCallConfigValueFake($channelId), - ]; + $this->workspaceIdValue = is_string($workspaceId) ? $workspaceId : ''; + $this->channelIdValue = is_string($channelId) ? $channelId : ''; + } + + protected function getConfiguredWorkspaceId(): string + { + return $this->workspaceIdValue; + } + + protected function getConfiguredChannelId(): string + { + return $this->channelIdValue; } public function createVoiceCall(string $workspaceId, string $channelId, array $payload): array|object|null @@ -85,7 +85,11 @@ class BirdGateCallClientFake extends bird 'channelId' => $channelId, 'payload' => $payload, ]; - return $this->flashCreateResponse ?? ['id' => 'flash_123', 'status' => 'starting']; + return $this->flashCreateResponse ?? [ + 'id' => 'flash_123', + 'status' => 'starting', + 'from' => '+4532330288', + ]; } public function getFlashCall(string $workspaceId, string $channelId, string $callId): array|object|null @@ -108,6 +112,26 @@ class BirdGateCallClientFake extends bird } } +class BirdGateCallFlashFallbackClientFake extends BirdGateCallClientFake +{ + public bool $shouldFlashFail = false; + public int $flashCallAttempts = 0; + public int $regularCallAttempts = 0; + + public function callGateViaFlashCall(int $countryCode, int $phone, int $ringTimeout): void + { + $this->flashCallAttempts++; + if ($this->shouldFlashFail) { + throw new \Exception('Flash call failed'); + } + } + + public function callGateAndHangupWhenAccepted(int $countryCode, int $phone, int $timeout) + { + $this->regularCallAttempts++; + } +} + it('fails fast when workspace id is missing for gate calls', function (): void { $client = new BirdGateCallClientFake(workspaceId: '', channelId: 'channel_1'); @@ -155,6 +179,7 @@ it('hangs up when call reaches accepted state', function (): void { expect($client->createPayloads)->toHaveCount(1); expect($client->createPayloads[0]['workspaceId'])->toBe('workspace_1'); expect($client->createPayloads[0]['channelId'])->toBe('channel_1'); + expect($client->createPayloads[0]['payload']['from'])->toBe('+4532330288'); expect($client->createPayloads[0]['payload']['to'])->toBe('+4512345678'); expect($client->createPayloads[0]['payload']['ringTimeout'])->toBe(10); expect(array_key_exists('timeout', $client->createPayloads[0]['payload']))->toBeFalse(); @@ -249,13 +274,14 @@ it('creates gate flash call with documented ringTimeout payload', function (): v workspaceId: 'workspace_1', channelId: 'channel_1' ); - $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'accepted']; + $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'accepted', 'from' => '+4532330288']; $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']['from'])->toBe('+4532330288'); 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(); @@ -266,7 +292,7 @@ it('polls flash call and succeeds once accepted', function (): void { workspaceId: 'workspace_1', channelId: 'channel_1' ); - $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'starting']; + $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'starting', 'from' => '+4532330288']; $client->flashStatusQueue = ['ringing', 'accepted', 'completed']; $client->callGateViaFlashCall(45, 12345678, 10); @@ -279,9 +305,49 @@ it('fails flash gate flow when terminal failure status is returned', function () workspaceId: 'workspace_1', channelId: 'channel_1' ); - $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'starting']; + $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'starting', 'from' => '+4532330288']; $client->flashStatusQueue = ['busy']; expect(fn() => $client->callGateViaFlashCall(45, 12345678, 10)) ->toThrow(\Exception::class, 'Gate flash call failed with status: busy'); }); + +it('falls back to regular gate call when flash caller id is not confirmed', function (): void { + $client = new BirdGateCallClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1', + statusQueue: ['accepted'] + ); + $client->flashCreateResponse = ['id' => 'flash_123', 'status' => 'accepted']; + + $client->callGatePreferringFlashCall(45, 12345678, 10); + + expect($client->flashCreatePayloads)->toHaveCount(1); + expect($client->createPayloads)->toHaveCount(1); + expect($client->createPayloads[0]['payload']['from'])->toBe('+4532330288'); +}); + +it('does not fallback to regular gate call when flash succeeds', function (): void { + $client = new BirdGateCallFlashFallbackClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1' + ); + + $client->callGatePreferringFlashCall(45, 12345678, 10); + + expect($client->flashCallAttempts)->toBe(1); + expect($client->regularCallAttempts)->toBe(0); +}); + +it('falls back to regular gate call when flash fails', function (): void { + $client = new BirdGateCallFlashFallbackClientFake( + workspaceId: 'workspace_1', + channelId: 'channel_1' + ); + $client->shouldFlashFail = true; + + $client->callGatePreferringFlashCall(45, 12345678, 10); + + expect($client->flashCallAttempts)->toBe(1); + expect($client->regularCallAttempts)->toBe(1); +});