Add flash call functionality to Bird class with support for gate flash call handling and unit tests.

This commit is contained in:
Jeppe Bundgaard
2026-03-26 13:40:26 +01:00
parent b4ef513ff4
commit 6208f0ee1c
3 changed files with 167 additions and 3 deletions
+85 -2
View File
@@ -11,15 +11,22 @@ class bird
{ {
public const TEST_OUTBOUND_NUMBER_RAW = '+45 42 33 11 28'; public const TEST_OUTBOUND_NUMBER_RAW = '+45 42 33 11 28';
public const TEST_OUTBOUND_NUMBER_E164 = '+4542331128'; 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 ALLOWED_HANGUP_CAUSES = ['rejected', 'busy'];
private const ACCEPTED_CALL_STATUSES = ['accepted', 'ongoing']; private const ACCEPTED_CALL_STATUSES = ['accepted', 'ongoing'];
private const TERMINAL_GATE_FAILURE_STATUSES = ['rejected', 'busy', 'failed', 'cancelled', 'no-answer', 'completed']; 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 * Configuration of the Bird module
* @var bird_c|object * @var bird_c
*/ */
public $config; public bird_c $config;
public function __construct() public function __construct()
{ {
@@ -397,6 +404,20 @@ class bird
return $this->sendDeleteRequest('/workspaces/' . rawurlencode($workspaceId) . '/numbers/' . rawurlencode($numberId)); 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 public function createOutboundTestCallAndHangupWhenAccepted(string $workspaceId, string $channelId, array $options = []): array
{ {
return $this->executeCallAndHangupWhenAccepted($workspaceId, $channelId, $options, 'BIRD_TEST_OUTBOUND_CALL'); return $this->executeCallAndHangupWhenAccepted($workspaceId, $channelId, $options, 'BIRD_TEST_OUTBOUND_CALL');
@@ -528,6 +549,11 @@ class bird
return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls'; 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 private function sayBase(string $workspaceId, string $channelId, string $callId): string
{ {
// https://api.bird.com/workspaces/{workspaceId}/channels/{channelId}/calls/{callId}/say // 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 protected function getConfiguredWorkspaceId(): string
{ {
if (!is_object($this->config)) { if (!is_object($this->config)) {
@@ -269,7 +269,7 @@ class department_gates_o extends db
$client = new bird(); $client = new bird();
try { try {
$client->callGateAndHangupWhenAccepted( $client->callGateViaFlashCall(
(int)$countryCode, (int)$countryCode,
(int)$phone, (int)$phone,
$timeout, $timeout,
@@ -20,11 +20,19 @@ class BirdGateCallClientFake extends bird
public array $statusQueue = []; public array $statusQueue = [];
/** @var string[] */ /** @var string[] */
public array $statusesSeen = []; public array $statusesSeen = [];
/** @var string[] */
public array $flashStatusQueue = [];
/** @var string[] */
public array $flashStatusesSeen = [];
public int $hangupCalls = 0; public int $hangupCalls = 0;
/** @var array<int,array<string,mixed>> */ /** @var array<int,array<string,mixed>> */
public array $hangupPayloads = []; public array $hangupPayloads = [];
/** @var array<int,array<string,mixed>> */ /** @var array<int,array<string,mixed>> */
public array $createPayloads = []; public array $createPayloads = [];
/** @var array<int,array<string,mixed>> */
public array $flashCreatePayloads = [];
/** @var array<string,mixed>|null */
public ?array $flashCreateResponse = null;
/** /**
* @param string[] $statusQueue * @param string[] $statusQueue
@@ -70,6 +78,30 @@ class BirdGateCallClientFake extends bird
return ['status' => 'completed']; 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 protected function waitForCallPollInterval(int $pollIntervalSeconds): void
{ {
// Avoid real sleeps in tests. // 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->hangupCalls)->toBe(1);
expect($client->hangupPayloads[0])->toBe([]); 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');
});