Enhance Bird class with flash call fallback logic, caller ID validation, and unit test coverage. Update gate call handling to prefer flash calls with fallback to regular calls.

This commit is contained in:
Jeppe Bundgaard
2026-03-26 13:51:41 +01:00
parent 6208f0ee1c
commit a292c8a277
3 changed files with 153 additions and 20 deletions
+67
View File
@@ -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;
}
}
@@ -269,7 +269,7 @@ class department_gates_o extends db
$client = new bird();
try {
$client->callGateViaFlashCall(
$client->callGatePreferringFlashCall(
(int)$countryCode,
(int)$phone,
$timeout,
@@ -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);
});