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

650 lines
25 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', 'completed'];
/**
* 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, 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 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;
// Ensure Bird terminates an unanswered call after we've stopped polling for it.
if (!isset($payload['timeout'])) {
$payload['timeout'] = $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;
$acceptedStates = ['accepted', 'ongoing'];
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$current = $this->getVoiceCall($workspaceId, $channelId, $callId);
$lastCall = $current;
$status = $this->extractStatus($current);
$this->logBirdAction(
$logPrefix . '_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($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 ($attempt < $maxAttempts) {
sleep($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,
];
}
private function voiceBase(string $workspaceId, string $channelId): string
{
return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls';
}
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;
}
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->config->workplaceId->getVariableValue();
$ch = $this->config->channelId->getVariableValue();
$options = [
'to' => '+' . $countryCode . $phone,
'maxPollSeconds' => (int)$timeout,
];
$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';
}
throw new Exception("Failed to call gate (+{$countryCode}{$phone}): " . $msg);
}
}
}