Files
api/services/nginx/app/classes/bird.php
T

513 lines
20 KiB
PHP

<?php
namespace classes;
use bird\bird_c;
use Exception;
use objects\logs_o;
class bird
{
public const TEST_OUTBOUND_NUMBER_RAW = '+45 42 33 11 28';
public const TEST_OUTBOUND_NUMBER_E164 = '+4542331128';
private const ALLOWED_HANGUP_CAUSES = ['rejected', 'busy'];
/**
* Configuration of the Bird module
* @var bird_c|object
*/
public $config;
public function __construct()
{
$this->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.
}
}
}