440 lines
17 KiB
PHP
440 lines
17 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';
|
|
|
|
/**
|
|
* 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',
|
|
'Authorization: Bearer ' . $this->config->api_key->getVariableValue(),
|
|
];
|
|
$body = json_encode($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('Bird API request failed with status ' . $status);
|
|
}
|
|
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',
|
|
'Authorization: Bearer ' . $this->config->api_key->getVariableValue(),
|
|
];
|
|
$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('Bird API request failed with status ' . $status);
|
|
}
|
|
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',
|
|
'Authorization: Bearer ' . $this->config->api_key->getVariableValue(),
|
|
];
|
|
$body = json_encode($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('Bird API request failed with status ' . $status);
|
|
}
|
|
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',
|
|
'Authorization: Bearer ' . $this->config->api_key->getVariableValue(),
|
|
];
|
|
$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('Bird API request failed with status ' . $status);
|
|
}
|
|
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;
|
|
unset($payload['pollIntervalSeconds'], $payload['maxPollSeconds']);
|
|
$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'] !== '') {
|
|
$hangupPayload['cause'] = $options['hangupCause'];
|
|
}
|
|
$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 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.
|
|
}
|
|
}
|
|
}
|