Add flash call functionality to Bird class with support for gate flash call handling and unit tests.
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -269,7 +269,7 @@ class department_gates_o extends db
|
||||
|
||||
$client = new bird();
|
||||
try {
|
||||
$client->callGateAndHangupWhenAccepted(
|
||||
$client->callGateViaFlashCall(
|
||||
(int)$countryCode,
|
||||
(int)$phone,
|
||||
$timeout,
|
||||
|
||||
@@ -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<int,array<string,mixed>> */
|
||||
public array $hangupPayloads = [];
|
||||
/** @var array<int,array<string,mixed>> */
|
||||
public array $createPayloads = [];
|
||||
/** @var array<int,array<string,mixed>> */
|
||||
public array $flashCreatePayloads = [];
|
||||
/** @var array<string,mixed>|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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user