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); 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); 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); 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); 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 listNumbers(array $query = []): array|object|null { $this->logBirdAction('BIRD_NUMBERS_LIST', 'query_keys=' . implode(',', array_keys($query))); return $this->sendGetRequest('/numbers', $query); } public function getNumber(string $numberId): array|object|null { $this->logBirdAction('BIRD_NUMBER_GET', 'number=' . $numberId); return $this->sendGetRequest('/numbers/' . rawurlencode($numberId)); } public function createOutboundTestCallAndHangupWhenAccepted(string $workspaceId, string $channelId, array $options = []): 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']); $payload['to'] = self::TEST_OUTBOUND_NUMBER_E164; $this->logBirdAction( 'BIRD_TEST_OUTBOUND_CALL_START', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' to=' . self::TEST_OUTBOUND_NUMBER_E164 ); $createResponse = $this->createVoiceCall($workspaceId, $channelId, $payload); $callId = $this->extractId($createResponse); if ($callId === null) { $this->logBirdAction('BIRD_TEST_OUTBOUND_CALL_NO_ID', 'Call accepted but no call ID returned', 0); return [ 'to' => self::TEST_OUTBOUND_NUMBER_RAW, 'to_e164' => self::TEST_OUTBOUND_NUMBER_E164, 'created_call' => $createResponse, 'hangup_sent' => false, 'message' => 'Call was created, but no call ID was returned, so hangup could not be sent.', ]; } $lastCall = null; $acceptedStates = ['accepted', 'ongoing']; for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { $current = $this->getVoiceCall($workspaceId, $channelId, $callId); $lastCall = $current; $status = $this->extractStatus($current); $this->logBirdAction( 'BIRD_TEST_OUTBOUND_CALL_POLL', 'call=' . $callId . ' attempt=' . $attempt . '/' . $maxAttempts . ' status=' . ($status ?? 'unknown') ); if ($status !== null && in_array(strtolower($status), $acceptedStates, 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('BIRD_TEST_OUTBOUND_CALL_HANGUP_SENT', 'call=' . $callId . ' status=' . $status); return [ 'to' => self::TEST_OUTBOUND_NUMBER_RAW, 'to_e164' => self::TEST_OUTBOUND_NUMBER_E164, 'call_id' => $callId, 'final_status' => $status, 'hangup_sent' => true, 'created_call' => $createResponse, 'last_call_snapshot' => $lastCall, 'hangup_response' => $hangupResponse, ]; } if ($attempt < $maxAttempts) { sleep($pollIntervalSeconds); } } $lastStatus = $this->extractStatus($lastCall); $this->logBirdAction( 'BIRD_TEST_OUTBOUND_CALL_TIMEOUT', 'call=' . $callId . ' last_status=' . ($lastStatus ?? 'unknown'), 0 ); return [ 'to' => self::TEST_OUTBOUND_NUMBER_RAW, 'to_e164' => self::TEST_OUTBOUND_NUMBER_E164, 'call_id' => $callId, 'final_status' => $lastStatus, 'hangup_sent' => false, 'timed_out_waiting_for_accepted' => true, 'created_call' => $createResponse, 'last_call_snapshot' => $lastCall, ]; } private function voiceBase(string $workspaceId, string $channelId): string { return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls'; } private 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; } private 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; } 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. } } }