config = new bird_c(); } /** * Ensure module is enabled * @throws Exception */ function requireModuleEnabled(): void { if (!$this->config->enabled->isTrue()) { throw new Exception('The bird module is not enabled'); } } /** * Ensure API key is present * @throws Exception */ function requireValidApiKey(): void { $k = $this->config->api_key->getVariableValue(); if ($k === null || $k === '') { throw new Exception('Invalid API key defined in the config (bird_api_key_c)'); } } /** * Ensure base URL is defined * @throws Exception */ function requireValidServerURL(): void { $u = $this->config->server_url->getVariableValue(); if ($u === null || $u === '') { throw new Exception('Invalid server URL defined in the config (bird_server_url_c)'); } } /** * Send a request to Bird API * @param string $endpoint e.g. "/v1/devices" * @param array $data request body * @param string $method HTTP method (currently only POST supported) * @return object|array|null * @throws Exception */ function sendRequest(string $endpoint, array $data = [], string $method = 'POST'): object|array|null { $this->requireModuleEnabled(); $this->requireValidApiKey(); $this->requireValidServerURL(); return match (strtoupper($method)) { 'POST' => $this->sendPostRequest($endpoint, $data), 'GET' => $this->sendGetRequest($endpoint, $data), 'PATCH' => $this->sendPatchRequest($endpoint, $data), 'DELETE' => $this->sendDeleteRequest($endpoint, $data), default => throw new Exception('Invalid request method'), }; } /** * Send POST request * @param string $endpoint * @param array $data * @return array|object|null * @throws Exception */ function sendPostRequest(string $endpoint, array $data): array|object|null { $this->requireModuleEnabled(); $this->requireValidApiKey(); $this->requireValidServerURL(); $url = rtrim($this->config->server_url->getVariableValue(), '/') . $endpoint; $headers = [ 'Content-Type: application/json', $this->buildAuthorizationHeader(), ]; $body = $this->encodeJsonBody($data); $this->logBirdAction( 'BIRD_HTTP_REQUEST', 'method=POST endpoint=' . $endpoint . ' payload_keys=' . implode(',', array_keys($data)) ); $result = $this->doHttpRequest('POST', $url, $headers, $body); $status = (int)($result['status_code'] ?? 0); $response = $result['body'] ?? ''; $this->logBirdAction('BIRD_HTTP_RESPONSE', 'method=POST endpoint=' . $endpoint . ' status=' . $status); if ($status >= 400) { $this->logBirdAction('BIRD_HTTP_ERROR', 'method=POST endpoint=' . $endpoint . ' status=' . $status, 0); throw new Exception($this->buildHttpErrorMessage($status, $response)); } if ($response === '' || $response === false || $response === null) { return null; } $decoded = json_decode($response, true); return $decoded ?? $response; } /** * Low-level HTTP transport (curl). Tests can override this to stub network. * @param string $method * @param string $url * @param array $headers * @param string $body * @return array{status_code:int, body:string|false} * @throws Exception */ protected function doHttpRequest(string $method, string $url, array $headers, string $body = ''): array { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); curl_setopt($ch, CURLOPT_TIMEOUT, 40); $upper = strtoupper($method); if ($upper === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } else { curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $upper); } curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); if (curl_errno($ch)) { $err = curl_error($ch); curl_close($ch); $this->logBirdAction('BIRD_HTTP_CURL_ERROR', 'method=' . $upper . ' url=' . $url . ' error=' . $err, 0); throw new Exception('cURL error: ' . $err); } curl_close($ch); return [ 'status_code' => (int)$code, 'body' => $resp, ]; } /** * Send GET request * @param string $endpoint * @param array $query * @return array|object|null * @throws Exception */ function sendGetRequest(string $endpoint, array $query = []): array|object|null { $this->requireModuleEnabled(); $this->requireValidApiKey(); $this->requireValidServerURL(); $base = rtrim($this->config->server_url->getVariableValue(), '/'); $url = $base . $endpoint; if (!empty($query)) { $qs = http_build_query($query); $url .= (str_contains($url, '?') ? '&' : '?') . $qs; } $headers = [ 'Content-Type: application/json', $this->buildAuthorizationHeader(), ]; $this->logBirdAction( 'BIRD_HTTP_REQUEST', 'method=GET endpoint=' . $endpoint . ' query_keys=' . implode(',', array_keys($query)) ); $result = $this->doHttpRequest('GET', $url, $headers); $status = (int)($result['status_code'] ?? 0); $response = $result['body'] ?? ''; $this->logBirdAction('BIRD_HTTP_RESPONSE', 'method=GET endpoint=' . $endpoint . ' status=' . $status); if ($status >= 400) { $this->logBirdAction('BIRD_HTTP_ERROR', 'method=GET endpoint=' . $endpoint . ' status=' . $status, 0); throw new Exception($this->buildHttpErrorMessage($status, $response)); } if ($response === '' || $response === false || $response === null) { return null; } $decoded = json_decode($response, true); return $decoded ?? $response; } function sendPatchRequest(string $endpoint, array $data = []): array|object|null { $this->requireModuleEnabled(); $this->requireValidApiKey(); $this->requireValidServerURL(); $url = rtrim($this->config->server_url->getVariableValue(), '/') . $endpoint; $headers = [ 'Content-Type: application/json', $this->buildAuthorizationHeader(), ]; $body = $this->encodeJsonBody($data); $this->logBirdAction( 'BIRD_HTTP_REQUEST', 'method=PATCH endpoint=' . $endpoint . ' payload_keys=' . implode(',', array_keys($data)) ); $result = $this->doHttpRequest('PATCH', $url, $headers, $body); $status = (int)($result['status_code'] ?? 0); $response = $result['body'] ?? ''; $this->logBirdAction('BIRD_HTTP_RESPONSE', 'method=PATCH endpoint=' . $endpoint . ' status=' . $status); if ($status >= 400) { $this->logBirdAction('BIRD_HTTP_ERROR', 'method=PATCH endpoint=' . $endpoint . ' status=' . $status, 0); throw new Exception($this->buildHttpErrorMessage($status, $response)); } if ($response === '' || $response === false || $response === null) { return null; } $decoded = json_decode($response, true); return $decoded ?? $response; } function sendDeleteRequest(string $endpoint, array $query = []): array|object|null { $this->requireModuleEnabled(); $this->requireValidApiKey(); $this->requireValidServerURL(); $base = rtrim($this->config->server_url->getVariableValue(), '/'); $url = $base . $endpoint; if (!empty($query)) { $qs = http_build_query($query); $url .= (str_contains($url, '?') ? '&' : '?') . $qs; } $headers = [ 'Content-Type: application/json', $this->buildAuthorizationHeader(), ]; $this->logBirdAction( 'BIRD_HTTP_REQUEST', 'method=DELETE endpoint=' . $endpoint . ' query_keys=' . implode(',', array_keys($query)) ); $result = $this->doHttpRequest('DELETE', $url, $headers); $status = (int)($result['status_code'] ?? 0); $response = $result['body'] ?? ''; $this->logBirdAction('BIRD_HTTP_RESPONSE', 'method=DELETE endpoint=' . $endpoint . ' status=' . $status); if ($status >= 400) { $this->logBirdAction('BIRD_HTTP_ERROR', 'method=DELETE endpoint=' . $endpoint . ' status=' . $status, 0); throw new Exception($this->buildHttpErrorMessage($status, $response)); } if ($response === '' || $response === false || $response === null) { return null; } $decoded = json_decode($response, true); return $decoded ?? $response; } public function createVoiceCall(string $workspaceId, string $channelId, array $payload): array|object|null { $base = $this->voiceBase($workspaceId, $channelId); $this->logBirdAction('BIRD_VOICE_CALL_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId); return $this->sendPostRequest($base, $payload); } public function listVoiceCalls(string $workspaceId, string $channelId, array $query = []): array|object|null { $base = $this->voiceBase($workspaceId, $channelId); $this->logBirdAction('BIRD_VOICE_CALL_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId); return $this->sendGetRequest($base, $query); } public function getVoiceCall(string $workspaceId, string $channelId, string $callId): array|object|null { $base = $this->voiceBase($workspaceId, $channelId); $this->logBirdAction('BIRD_VOICE_CALL_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); return $this->sendGetRequest($base . '/' . rawurlencode($callId)); } public function hangupVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null { $base = $this->voiceBase($workspaceId, $channelId); $this->logBirdAction('BIRD_VOICE_CALL_HANGUP', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); return $this->sendPostRequest($base . '/' . rawurlencode($callId) . '/hangup', $payload); } public function sayMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null { $this->logBirdAction('BIRD_VOICE_CALL_SAY', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); /** * Example payload: * https://api.bird.com/workspaces/{workspaceId}/channels/{channelId}/calls/{callId}/say * { * "text": "text", * "locale": "en-US", * "voice": "text", * "loop": 1, * "timeout": 1 * } */ if (!isset($payload['text']) || !is_string($payload['text']) || trim($payload['text']) === '') { throw new Exception('The "text" field is required in the payload and must be a non-empty string'); } $tmp = [ // Default values 'locale' => 'da-DK', 'voice' => 'text', 'loop' => 1, 'timeout' => 1, ...$payload, ]; return $this->sendPostRequest($this->sayBase($workspaceId, $channelId, $callId), $tmp); } public function gatherMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null { $this->logBirdAction('BIRD_VOICE_CALL_GATHER', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId); /** * Example payload: * https://api.bird.com/workspaces/{workspaceId}/channels/{channelId}/calls/{callId}/gather * { * "maxNumKeys": 1, * "endKey": "0", * "timeout": 1, * "retries": 1, * "input": "dtmf", * "speechLocale": "en-US", * "playback": { * "media": [ "text" ], * "loop": 1, * "timeout": 1, * "pauseMilliseconds": 1 * }, * "say": { * "text": "text", * "locale": "en-US", * "voice": "text", * "loop": 1, * "timeout": 1 * } * } */ /** * Example response: * { * "id": "123e4567-e89b-12d3-a456-426614174000", * "callId": "123e4567-e89b-12d3-a456-426614174000", * "callFlowId": "123e4567-e89b-12d3-a456-426614174000", * "status": "text", * "command": "text", * "conditions": [ * { * "variable": "keys", * "operator": "eq", * "value": "text" * } * ] * } */ $tmp = [ // Default values 'input' => 'dtmf', ...$payload, ]; return $this->sendPostRequest($this->gatherBase($workspaceId, $channelId, $callId), $tmp); } public function listNumbers(string $workspaceId, array $query = []): array|object|null { $this->logBirdAction('BIRD_NUMBERS_LIST', 'workspace=' . $workspaceId . ' query_keys=' . implode(',', array_keys($query))); return $this->sendGetRequest('/workspaces/' . rawurlencode($workspaceId) . '/numbers', $query); } public function getNumber(string $workspaceId, string $numberId): array|object|null { $this->logBirdAction('BIRD_NUMBER_GET', 'workspace=' . $workspaceId . ' number=' . $numberId); return $this->sendGetRequest('/workspaces/' . rawurlencode($workspaceId) . '/numbers/' . rawurlencode($numberId)); } public function deleteNumber(string $workspaceId, string $numberId): array|object|null { $this->logBirdAction('BIRD_NUMBER_DELETE', 'workspace=' . $workspaceId . ' number=' . $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 { return $this->executeCallAndHangupWhenAccepted($workspaceId, $channelId, $options, 'BIRD_TEST_OUTBOUND_CALL'); } /** * Internal method to place a call, wait for it to be accepted/ongoing, and then hang up. * Used by both test outbound calls and actual gate calls. */ private function executeCallAndHangupWhenAccepted(string $workspaceId, string $channelId, array $options, string $logPrefix): array { $pollIntervalSeconds = max(1, (int)($options['pollIntervalSeconds'] ?? 2)); $maxPollSeconds = max(5, (int)($options['maxPollSeconds'] ?? 30)); $maxAttempts = (int)max(1, floor($maxPollSeconds / $pollIntervalSeconds)); $payload = $options; // Keep polling/hangup controls out of create-call payload to avoid Bird validation errors. unset($payload['pollIntervalSeconds'], $payload['maxPollSeconds'], $payload['hangupCause']); $targetNumber = (string)($payload['to'] ?? self::TEST_OUTBOUND_NUMBER_E164); if ($targetNumber === '') { $targetNumber = self::TEST_OUTBOUND_NUMBER_E164; } $payload['to'] = $targetNumber; $payload = $this->normalizeCreateVoiceCallPayload($payload, $maxPollSeconds); $this->logBirdAction( $logPrefix . '_START', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' to=' . $targetNumber ); $createResponse = $this->createVoiceCall($workspaceId, $channelId, $payload); $callId = $this->extractId($createResponse); if ($callId === null) { $this->logBirdAction($logPrefix . '_NO_ID', 'Call accepted but no call ID returned', 0); return [ 'to' => $targetNumber, 'to_e164' => $targetNumber, 'created_call' => $createResponse, 'hangup_sent' => false, 'message' => 'Call was created, but no call ID was returned, so hangup could not be sent.', ]; } $lastCall = null; for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { $current = $this->getVoiceCall($workspaceId, $channelId, $callId); $lastCall = $current; $status = $this->extractStatus($current); $normalizedStatus = $status === null ? null : strtolower($status); $this->logBirdAction( $logPrefix . '_POLL', 'call=' . $callId . ' attempt=' . $attempt . '/' . $maxAttempts . ' status=' . ($status ?? 'unknown') ); if ($normalizedStatus !== null && in_array($normalizedStatus, self::ACCEPTED_CALL_STATUSES, true)) { $hangupPayload = []; if (isset($options['hangupCause']) && is_string($options['hangupCause']) && $options['hangupCause'] !== '') { $normalizedCause = strtolower(trim($options['hangupCause'])); if (in_array($normalizedCause, self::ALLOWED_HANGUP_CAUSES, true)) { $hangupPayload['cause'] = $normalizedCause; } } $hangupResponse = $this->hangupVoiceCall($workspaceId, $channelId, $callId, $hangupPayload); $this->logBirdAction($logPrefix . '_HANGUP_SENT', 'call=' . $callId . ' status=' . $status); return [ 'to' => $targetNumber, 'to_e164' => $targetNumber, 'call_id' => $callId, 'final_status' => $status, 'hangup_sent' => true, 'created_call' => $createResponse, 'last_call_snapshot' => $lastCall, 'hangup_response' => $hangupResponse, ]; } if ($normalizedStatus !== null && in_array($normalizedStatus, self::TERMINAL_GATE_FAILURE_STATUSES, true)) { $this->logBirdAction( $logPrefix . '_TERMINAL', 'call=' . $callId . ' status=' . $status ); return [ 'to' => $targetNumber, 'to_e164' => $targetNumber, 'call_id' => $callId, 'final_status' => $status, 'hangup_sent' => false, 'terminal_failure' => true, 'created_call' => $createResponse, 'last_call_snapshot' => $lastCall, 'message' => 'Call reached terminal status before acceptance', ]; } if ($attempt < $maxAttempts) { $this->waitForCallPollInterval($pollIntervalSeconds); } } $lastStatus = $this->extractStatus($lastCall); $this->logBirdAction( $logPrefix . '_TIMEOUT', 'call=' . $callId . ' last_status=' . ($lastStatus ?? 'unknown'), 0 ); return [ 'to' => $targetNumber, 'to_e164' => $targetNumber, 'call_id' => $callId, 'final_status' => $lastStatus, 'hangup_sent' => false, 'timed_out_waiting_for_accepted' => true, 'created_call' => $createResponse, 'last_call_snapshot' => $lastCall, ]; } /** * Hook point for tests to avoid real waiting during call polling. */ protected function waitForCallPollInterval(int $pollIntervalSeconds): void { sleep($pollIntervalSeconds); } private function voiceBase(string $workspaceId, string $channelId): string { 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 return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls/' . rawurlencode($callId) . '/say'; } private function gatherBase(string $workspaceId, string $channelId, string $callId): string { // https://api.bird.com/workspaces/{workspaceId}/channels/{channelId}/calls/{callId}/gather return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls/' . rawurlencode($callId) . '/gather'; } protected function extractId(array|object|string|null $response): ?string { if (is_object($response) && isset($response->id) && is_string($response->id) && $response->id !== '') { return $response->id; } if (is_array($response) && isset($response['id']) && is_string($response['id']) && $response['id'] !== '') { return $response['id']; } return null; } protected function extractStatus(array|object|string|null $response): ?string { if (is_object($response) && isset($response->status) && is_string($response->status) && $response->status !== '') { return $response->status; } if (is_array($response) && isset($response['status']) && is_string($response['status']) && $response['status'] !== '') { return $response['status']; } 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; if (!is_string($response) || trim($response) === '') { return $base; } $decoded = json_decode($response, true); if (is_array($decoded)) { $details = []; foreach (['message', 'error', 'description'] as $key) { if (isset($decoded[$key]) && is_string($decoded[$key]) && trim($decoded[$key]) !== '') { $details[] = trim($decoded[$key]); } } if (isset($decoded['errors'])) { if (is_string($decoded['errors']) && trim($decoded['errors']) !== '') { $details[] = trim($decoded['errors']); } elseif (is_array($decoded['errors'])) { foreach ($decoded['errors'] as $error) { if (is_string($error) && trim($error) !== '') { $details[] = trim($error); } elseif (is_array($error) && isset($error['message']) && is_string($error['message']) && trim($error['message']) !== '') { $details[] = trim($error['message']); } } } } $details = array_values(array_unique(array_filter($details))); if (!empty($details)) { return $base . ': ' . implode(' | ', $details); } } $snippet = preg_replace('/\s+/', ' ', trim($response)); if (!is_string($snippet) || $snippet === '') { return $base; } if (strlen($snippet) > 220) { $snippet = substr($snippet, 0, 220) . '...'; } return $base . ': ' . $snippet; } private function buildAuthorizationHeader(): string { $apiKey = trim((string)$this->config->api_key->getVariableValue()); if ($apiKey === '') { return 'Authorization: AccessKey'; } if (preg_match('/^(Bearer|AccessKey)\s+/i', $apiKey) === 1) { return 'Authorization: ' . $apiKey; } return 'Authorization: AccessKey ' . $apiKey; } private function encodeJsonBody(array $data): string { // Bird control endpoints expect an object payload; use {} for empty body. if ($data === []) { return '{}'; } return json_encode($data); } private function logBirdAction(string $action, string $message, int $type = 1): void { try { $userId = 0; if (isset($_SERVER['HTTP_AUTHORIZATION']) || isset($_SERVER['Authorization'])) { try { $auth = new authentication(); $user = $auth->get_user(); if ($user && isset($user->id)) { $userId = (int)$user->id; } } catch (\Throwable) { $userId = 0; } } (new logs_o())->add('bird', 'global', $type, $userId, $action, $message); } catch (\Throwable) { // Logging must never break Bird request flow. } } public function callGateAndHangupWhenAccepted(int $countryCode, int $phone, int $timeout) { $ws = $this->getConfiguredWorkspaceId(); $ch = $this->getConfiguredChannelId(); if ($ws === '') { throw new Exception('Bird workspaceId is not configured for gate calls'); } if ($ch === '') { throw new Exception('Bird channelId is not configured for gate calls'); } $normalizedRingTimeout = $this->normalizeRingTimeoutValue($timeout); if ($normalizedRingTimeout === null) { $normalizedRingTimeout = 30; } $options = [ 'from' => self::OUTGOING_NUMBER_E164, 'to' => '+' . $countryCode . $phone, 'maxPollSeconds' => max(5, (int)$timeout), 'ringTimeout' => $normalizedRingTimeout, ]; $result = $this->executeCallAndHangupWhenAccepted($ws, $ch, $options, 'BIRD_GATE_CALL'); if (!($result['hangup_sent'] ?? false)) { $msg = $result['message'] ?? 'Failed to call gate and hangup when accepted'; if ($result['timed_out_waiting_for_accepted'] ?? false) { $msg = 'Timed out waiting for gate to accept call'; } elseif ($result['terminal_failure'] ?? false) { $status = isset($result['final_status']) ? (string)$result['final_status'] : 'unknown'; $msg = 'Gate call reached terminal status: ' . $status; } throw new Exception("Failed to call gate (+{$countryCode}{$phone}): " . $msg); } } 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, [ '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)) { 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); $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)) { 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'); } 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)) { return ''; } $workspaceConfig = null; if (property_exists($this->config, 'workspaceId')) { $workspaceConfig = $this->config->workspaceId; } elseif (property_exists($this->config, 'workplaceId')) { // Backward compatibility with existing Bird module variable naming. $workspaceConfig = $this->config->workplaceId; } if (!is_object($workspaceConfig) || !method_exists($workspaceConfig, 'getVariableValue')) { return ''; } return $this->normalizeOptionalString($workspaceConfig->getVariableValue()); } protected function getConfiguredChannelId(): string { if (!is_object($this->config)) { return ''; } $channelConfig = null; if (property_exists($this->config, 'channelId')) { $channelConfig = $this->config->channelId; } if (!is_object($channelConfig) || !method_exists($channelConfig, 'getVariableValue')) { return ''; } return $this->normalizeOptionalString($channelConfig->getVariableValue()); } protected function normalizeOptionalString(mixed $value): string { if (!is_scalar($value)) { return ''; } $normalized = trim((string)$value); if ($normalized === '') { return ''; } $lower = strtolower($normalized); if ($lower === 'undefined' || $lower === 'null') { return ''; } return $normalized; } /** * Align create-call payload with Bird voice call schema. * - Map legacy `timeout` to documented `ringTimeout`. * - Clamp `ringTimeout` to documented [3,120] range. */ protected function normalizeCreateVoiceCallPayload(array $payload, int $fallbackRingTimeout): array { if (array_key_exists('timeout', $payload) && !array_key_exists('ringTimeout', $payload)) { $payload['ringTimeout'] = $payload['timeout']; } unset($payload['timeout']); $normalizedRingTimeout = null; if (array_key_exists('ringTimeout', $payload)) { $normalizedRingTimeout = $this->normalizeRingTimeoutValue($payload['ringTimeout']); } if ($normalizedRingTimeout === null) { $normalizedRingTimeout = $this->normalizeRingTimeoutValue($fallbackRingTimeout); } if ($normalizedRingTimeout !== null) { $payload['ringTimeout'] = $normalizedRingTimeout; } else { unset($payload['ringTimeout']); } return $payload; } protected function normalizeRingTimeoutValue(mixed $value): ?int { if (!is_numeric($value)) { return null; } $timeout = (int)$value; if ($timeout < 3) { $timeout = 3; } if ($timeout > 120) { $timeout = 120; } 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; } }