1064 lines
43 KiB
PHP
1064 lines
43 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
require_once WD . '/interfaces/bird_i.php';
|
|
require_once WD . '/modules/bird/bird_c.php';
|
|
require_once WD . '/modules/bird/classes/bird_api_client.php';
|
|
require_once WD . '/modules/bird/classes/bird_voice_calls_client.php';
|
|
require_once WD . '/modules/bird/classes/bird_voice_recordings_client.php';
|
|
require_once WD . '/modules/bird/classes/bird_voice_insights_client.php';
|
|
require_once WD . '/modules/bird/classes/bird_flash_calls_client.php';
|
|
|
|
|
|
use bird\bird_c;
|
|
use bird\classes\bird_flash_calls_client;
|
|
use bird\classes\bird_voice_calls_client;
|
|
use bird\classes\bird_voice_insights_client;
|
|
use bird\classes\bird_voice_recordings_client;
|
|
use Exception;
|
|
use interfaces\bird_i;
|
|
use objects\logs_o;
|
|
|
|
class bird implements bird_i
|
|
{
|
|
public const TEST_OUTBOUND_NUMBER_RAW = '+45 42 33 11 28';
|
|
public const TEST_OUTBOUND_NUMBER_E164 = '+4542331128';
|
|
public const OUTGOING_NUMBER_E164 = '+4532330288';
|
|
public const OUTGOING_NUMBER_RAW = '+45 32 33 02 88';
|
|
|
|
public const OUTGOING_NUMBER = '+4532330288';
|
|
|
|
private const ALLOWED_HANGUP_CAUSES = ['rejected', 'busy'];
|
|
private const ACCEPTED_CALL_STATUSES = ['accepted', 'ongoing'];
|
|
private const TERMINAL_GATE_FAILURE_STATUSES = ['rejected', 'busy', 'failed', 'cancelled', 'no-answer', 'completed'];
|
|
private const FLASH_GATE_SUCCESS_STATUSES = ['accepted', 'ongoing', 'completed'];
|
|
private const FLASH_GATE_FAILURE_STATUSES = ['rejected', 'busy', 'failed', 'cancelled', 'no-answer'];
|
|
|
|
/**
|
|
* Configuration of the Bird module
|
|
* @var bird_c
|
|
*/
|
|
public bird_c $config;
|
|
|
|
private ?bird_voice_calls_client $voice_calls_client = null;
|
|
private ?bird_voice_recordings_client $voice_recordings_client = null;
|
|
private ?bird_voice_insights_client $voice_insights_client = null;
|
|
private ?bird_flash_calls_client $flash_calls_client = null;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->config = new bird_c();
|
|
}
|
|
|
|
private function voiceCallsClient(): bird_voice_calls_client
|
|
{
|
|
if ($this->voice_calls_client === null) {
|
|
$this->voice_calls_client = new bird_voice_calls_client($this);
|
|
}
|
|
return $this->voice_calls_client;
|
|
}
|
|
|
|
private function voiceRecordingsClient(): bird_voice_recordings_client
|
|
{
|
|
if ($this->voice_recordings_client === null) {
|
|
$this->voice_recordings_client = new bird_voice_recordings_client($this);
|
|
}
|
|
return $this->voice_recordings_client;
|
|
}
|
|
|
|
private function voiceInsightsClient(): bird_voice_insights_client
|
|
{
|
|
if ($this->voice_insights_client === null) {
|
|
$this->voice_insights_client = new bird_voice_insights_client($this);
|
|
}
|
|
return $this->voice_insights_client;
|
|
}
|
|
|
|
private function flashCallsClient(): bird_flash_calls_client
|
|
{
|
|
if ($this->flash_calls_client === null) {
|
|
$this->flash_calls_client = new bird_flash_calls_client($this);
|
|
}
|
|
return $this->flash_calls_client;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId);
|
|
return $this->voiceCallsClient()->createVoiceCall($workspaceId, $channelId, $payload);
|
|
}
|
|
|
|
public function listVoiceCalls(string $workspaceId, string $channelId, array $query = []): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId);
|
|
return $this->voiceCallsClient()->listVoiceCalls($workspaceId, $channelId, $query);
|
|
}
|
|
|
|
public function getVoiceCall(string $workspaceId, string $channelId, string $callId): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
|
|
return $this->voiceCallsClient()->getVoiceCall($workspaceId, $channelId, $callId);
|
|
}
|
|
|
|
public function updateVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_UPDATE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
|
|
return $this->voiceCallsClient()->updateVoiceCall($workspaceId, $channelId, $callId, $payload);
|
|
}
|
|
|
|
public function answerVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_ANSWER', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
|
|
return $this->voiceCallsClient()->answerVoiceCall($workspaceId, $channelId, $callId, $payload);
|
|
}
|
|
|
|
public function ringVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_RINGING', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
|
|
return $this->voiceCallsClient()->ringVoiceCall($workspaceId, $channelId, $callId, $payload);
|
|
}
|
|
|
|
public function hangupVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_HANGUP', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
|
|
return $this->voiceCallsClient()->hangupVoiceCall($workspaceId, $channelId, $callId, $payload);
|
|
}
|
|
|
|
public function playbackVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_PLAYBACK', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
|
|
return $this->voiceCallsClient()->playbackVoiceCall($workspaceId, $channelId, $callId, $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->voiceCallsClient()->sayVoiceCall($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->voiceCallsClient()->gatherVoiceCall($workspaceId, $channelId, $callId, $tmp);
|
|
}
|
|
|
|
public function bridgeVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_BRIDGE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
|
|
return $this->voiceCallsClient()->bridgeVoiceCall($workspaceId, $channelId, $callId, $payload);
|
|
}
|
|
|
|
public function recordVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_RECORD', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
|
|
return $this->voiceCallsClient()->recordVoiceCall($workspaceId, $channelId, $callId, $payload);
|
|
}
|
|
|
|
public function createVoiceCallRecordingSession(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_RECORDING_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
|
|
return $this->voiceRecordingsClient()->createVoiceCallRecordingSession($workspaceId, $channelId, $callId, $payload);
|
|
}
|
|
|
|
public function listVoiceCallRecordings(string $workspaceId, string $channelId, string $callId, array $query = []): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_RECORDING_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
|
|
return $this->voiceRecordingsClient()->listVoiceCallRecordings($workspaceId, $channelId, $callId, $query);
|
|
}
|
|
|
|
public function getVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_RECORDING_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId . ' recording=' . $recordingId);
|
|
return $this->voiceRecordingsClient()->getVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId);
|
|
}
|
|
|
|
public function updateVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId, array $payload): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_RECORDING_UPDATE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId . ' recording=' . $recordingId);
|
|
return $this->voiceRecordingsClient()->updateVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId, $payload);
|
|
}
|
|
|
|
public function getVoiceCallInsights(string $workspaceId, string $channelId, string $callId): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_INSIGHTS_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
|
|
return $this->voiceInsightsClient()->getVoiceCallInsights($workspaceId, $channelId, $callId);
|
|
}
|
|
|
|
public function getVoiceCallsLog(string $workspaceId, array $query = []): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_VOICE_CALL_LOG_LIST', 'workspace=' . $workspaceId);
|
|
return $this->voiceInsightsClient()->getVoiceCallsLog($workspaceId, $query);
|
|
}
|
|
|
|
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
|
|
{
|
|
$this->logBirdAction('BIRD_FLASH_CALL_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId);
|
|
return $this->flashCallsClient()->createFlashCall($workspaceId, $channelId, $payload);
|
|
}
|
|
|
|
public function listFlashCalls(string $workspaceId, string $channelId, array $query = []): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_FLASH_CALL_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId);
|
|
return $this->flashCallsClient()->listFlashCalls($workspaceId, $channelId, $query);
|
|
}
|
|
|
|
public function getFlashCall(string $workspaceId, string $channelId, string $callId): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_FLASH_CALL_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
|
|
return $this->flashCallsClient()->getFlashCall($workspaceId, $channelId, $callId);
|
|
}
|
|
|
|
public function endFlashCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_FLASH_CALL_END', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
|
|
return $this->flashCallsClient()->endFlashCall($workspaceId, $channelId, $callId, $payload);
|
|
}
|
|
|
|
public function hangupFlashCall(string $workspaceId, string $channelId, array $payload = []): array|object|null
|
|
{
|
|
$this->logBirdAction('BIRD_FLASH_CALL_HANGUP', 'workspace=' . $workspaceId . ' channel=' . $channelId);
|
|
return $this->flashCallsClient()->hangupFlashCall($workspaceId, $channelId, $payload);
|
|
}
|
|
|
|
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 = 5;
|
|
$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;
|
|
}
|
|
}
|
|
|
|
|