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_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');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user