Add bird_flash_calls_client implementation with endpoint builder, request schemas, and validator for managing flash call functionality.

This commit is contained in:
Jeppe Bundgaard
2026-03-26 15:18:55 +01:00
parent a292c8a277
commit fcf9924adc
34 changed files with 3825 additions and 424 deletions
+1332 -143
View File
File diff suppressed because it is too large Load Diff
+149 -15
View File
@@ -2,12 +2,25 @@
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
class bird implements bird_i
{
public const TEST_OUTBOUND_NUMBER_RAW = '+45 42 33 11 28';
public const TEST_OUTBOUND_NUMBER_E164 = '+4542331128';
@@ -28,11 +41,48 @@ class bird
*/
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
@@ -279,30 +329,50 @@ class bird
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);
return $this->voiceCallsClient()->createVoiceCall($workspaceId, $channelId, $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);
return $this->voiceCallsClient()->listVoiceCalls($workspaceId, $channelId, $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));
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
{
$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);
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
@@ -330,7 +400,7 @@ class bird
'timeout' => 1,
...$payload,
];
return $this->sendPostRequest($this->sayBase($workspaceId, $channelId, $callId), $tmp);
return $this->voiceCallsClient()->sayVoiceCall($workspaceId, $channelId, $callId, $tmp);
}
public function gatherMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
@@ -383,7 +453,55 @@ class bird
'input' => 'dtmf',
...$payload,
];
return $this->sendPostRequest($this->gatherBase($workspaceId, $channelId, $callId), $tmp);
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
@@ -406,16 +524,32 @@ class bird
public function createFlashCall(string $workspaceId, string $channelId, array $payload): array|object|null
{
$base = $this->flashBase($workspaceId, $channelId);
$this->logBirdAction('BIRD_FLASH_CALL_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId);
return $this->sendPostRequest($base, $payload);
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
{
$base = $this->flashBase($workspaceId, $channelId);
$this->logBirdAction('BIRD_FLASH_CALL_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
return $this->sendGetRequest($base . '/' . rawurlencode($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
@@ -63,6 +63,7 @@ class selfserve_schema_bootstrap
machine_relay_enabled_at DATETIME NULL,
machine_start_triggered TINYINT(1) NOT NULL DEFAULT 0,
machine_start_triggered_at DATETIME NULL,
wash_started_at DATETIME NULL,
order_id INT NULL,
completed_at DATETIME NULL,
metadata_json JSON NULL,
@@ -149,6 +150,11 @@ class selfserve_schema_bootstrap
'dynamic_images_vehicle_type',
'ALTER TABLE selfserve_wash_session_tasks ADD COLUMN dynamic_images_vehicle_type INT NULL AFTER buttons'
);
self::ensureColumn(
'selfserve_wash_sessions',
'wash_started_at',
'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'
);
self::$initialized = true;
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace interfaces;
require_once WD . '/interfaces/universal_module_i.php';
interface bird_i extends universal_module_i
{
public function requireValidApiKey(): void;
public function requireValidServerURL(): void;
public function sendRequest(string $endpoint, array $data = [], string $method = 'POST'): object|array|null;
public function sendPostRequest(string $endpoint, array $data): array|object|null;
public function sendGetRequest(string $endpoint, array $query = []): array|object|null;
public function sendPatchRequest(string $endpoint, array $data = []): array|object|null;
public function sendDeleteRequest(string $endpoint, array $query = []): array|object|null;
public function createVoiceCall(string $workspaceId, string $channelId, array $payload): array|object|null;
public function listVoiceCalls(string $workspaceId, string $channelId, array $query = []): array|object|null;
public function getVoiceCall(string $workspaceId, string $channelId, string $callId): array|object|null;
public function updateVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null;
public function answerVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null;
public function ringVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null;
public function hangupVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null;
public function playbackVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null;
public function sayMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null;
public function gatherMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null;
public function bridgeVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null;
public function recordVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null;
public function createVoiceCallRecordingSession(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null;
public function listVoiceCallRecordings(string $workspaceId, string $channelId, string $callId, array $query = []): array|object|null;
public function getVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId): array|object|null;
public function updateVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId, array $payload): array|object|null;
public function getVoiceCallInsights(string $workspaceId, string $channelId, string $callId): array|object|null;
public function getVoiceCallsLog(string $workspaceId, array $query = []): array|object|null;
public function listNumbers(string $workspaceId, array $query = []): array|object|null;
public function getNumber(string $workspaceId, string $numberId): array|object|null;
public function deleteNumber(string $workspaceId, string $numberId): array|object|null;
public function createFlashCall(string $workspaceId, string $channelId, array $payload): array|object|null;
public function listFlashCalls(string $workspaceId, string $channelId, array $query = []): array|object|null;
public function getFlashCall(string $workspaceId, string $channelId, string $callId): array|object|null;
public function endFlashCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null;
public function hangupFlashCall(string $workspaceId, string $channelId, array $payload = []): array|object|null;
public function createOutboundTestCallAndHangupWhenAccepted(string $workspaceId, string $channelId, array $options = []): array;
public function callGateAndHangupWhenAccepted(int $countryCode, int $phone, int $timeout);
public function callGateViaFlashCall(int $countryCode, int $phone, int $ringTimeout): void;
public function callGatePreferringFlashCall(int $countryCode, int $phone, int $timeout): void;
}
@@ -0,0 +1,41 @@
<?php
namespace bird\classes;
require_once WD . '/classes/bird.php';
require_once WD . '/modules/bird/traits/bird_endpoint_builder_t.php';
use bird\traits\bird_endpoint_builder_t;
use classes\bird;
abstract class bird_api_client
{
use bird_endpoint_builder_t;
protected bird $bird;
public function __construct(bird $bird)
{
$this->bird = $bird;
}
protected function get(string $endpoint, array $query = []): array|object|null
{
return $this->bird->sendGetRequest($endpoint, $query);
}
protected function post(string $endpoint, array $payload = []): array|object|null
{
return $this->bird->sendPostRequest($endpoint, $payload);
}
protected function patch(string $endpoint, array $payload = []): array|object|null
{
return $this->bird->sendPatchRequest($endpoint, $payload);
}
protected function delete(string $endpoint, array $query = []): array|object|null
{
return $this->bird->sendDeleteRequest($endpoint, $query);
}
}
@@ -0,0 +1,36 @@
<?php
namespace bird\classes;
require_once WD . '/modules/bird/interfaces/bird_flash_calls_client_i.php';
require_once WD . '/modules/bird/classes/bird_api_client.php';
use bird\interfaces\bird_flash_calls_client_i;
class bird_flash_calls_client extends bird_api_client implements bird_flash_calls_client_i
{
public function createFlashCall(string $workspaceId, string $channelId, array $payload): array|object|null
{
return $this->post($this->birdFlashCallsBasePath($workspaceId, $channelId), $payload);
}
public function listFlashCalls(string $workspaceId, string $channelId, array $query = []): array|object|null
{
return $this->get($this->birdFlashCallsBasePath($workspaceId, $channelId), $query);
}
public function getFlashCall(string $workspaceId, string $channelId, string $callId): array|object|null
{
return $this->get($this->birdFlashCallPath($workspaceId, $channelId, $callId));
}
public function endFlashCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
{
return $this->post($this->birdFlashCallPath($workspaceId, $channelId, $callId), $payload);
}
public function hangupFlashCall(string $workspaceId, string $channelId, array $payload = []): array|object|null
{
return $this->post($this->birdFlashCallsHangupPath($workspaceId, $channelId), $payload);
}
}
@@ -0,0 +1,71 @@
<?php
namespace bird\classes;
require_once WD . '/modules/bird/interfaces/bird_voice_calls_client_i.php';
require_once WD . '/modules/bird/classes/bird_api_client.php';
use bird\interfaces\bird_voice_calls_client_i;
class bird_voice_calls_client extends bird_api_client implements bird_voice_calls_client_i
{
public function createVoiceCall(string $workspaceId, string $channelId, array $payload): array|object|null
{
return $this->post($this->birdVoiceCallsBasePath($workspaceId, $channelId), $payload);
}
public function listVoiceCalls(string $workspaceId, string $channelId, array $query = []): array|object|null
{
return $this->get($this->birdVoiceCallsBasePath($workspaceId, $channelId), $query);
}
public function getVoiceCall(string $workspaceId, string $channelId, string $callId): array|object|null
{
return $this->get($this->birdVoiceCallPath($workspaceId, $channelId, $callId));
}
public function updateVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
return $this->patch($this->birdVoiceCallPath($workspaceId, $channelId, $callId), $payload);
}
public function answerVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
{
return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'answer'), $payload);
}
public function ringVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
{
return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'ringing'), $payload);
}
public function hangupVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
{
return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'hangup'), $payload);
}
public function playbackVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'playback'), $payload);
}
public function sayVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'say'), $payload);
}
public function gatherVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'gather'), $payload);
}
public function bridgeVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'bridge'), $payload);
}
public function recordVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
return $this->post($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'record'), $payload);
}
}
@@ -0,0 +1,21 @@
<?php
namespace bird\classes;
require_once WD . '/modules/bird/interfaces/bird_voice_insights_client_i.php';
require_once WD . '/modules/bird/classes/bird_api_client.php';
use bird\interfaces\bird_voice_insights_client_i;
class bird_voice_insights_client extends bird_api_client implements bird_voice_insights_client_i
{
public function getVoiceCallInsights(string $workspaceId, string $channelId, string $callId): array|object|null
{
return $this->get($this->birdVoiceCallCommandPath($workspaceId, $channelId, $callId, 'insights'));
}
public function getVoiceCallsLog(string $workspaceId, array $query = []): array|object|null
{
return $this->get($this->birdVoiceCallsLogPath($workspaceId), $query);
}
}
@@ -0,0 +1,31 @@
<?php
namespace bird\classes;
require_once WD . '/modules/bird/interfaces/bird_voice_recordings_client_i.php';
require_once WD . '/modules/bird/classes/bird_api_client.php';
use bird\interfaces\bird_voice_recordings_client_i;
class bird_voice_recordings_client extends bird_api_client implements bird_voice_recordings_client_i
{
public function createVoiceCallRecordingSession(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
return $this->post($this->birdVoiceCallRecordingsPath($workspaceId, $channelId, $callId), $payload);
}
public function listVoiceCallRecordings(string $workspaceId, string $channelId, string $callId, array $query = []): array|object|null
{
return $this->get($this->birdVoiceCallRecordingsPath($workspaceId, $channelId, $callId), $query);
}
public function getVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId): array|object|null
{
return $this->get($this->birdVoiceCallRecordingPath($workspaceId, $channelId, $callId, $recordingId));
}
public function updateVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId, array $payload): array|object|null
{
return $this->patch($this->birdVoiceCallRecordingPath($workspaceId, $channelId, $callId, $recordingId), $payload);
}
}
@@ -0,0 +1,404 @@
<?php
namespace bird\helpers;
class bird_request_schemas
{
public static function noBody(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'properties' => [],
];
}
public static function voiceCreateCallBody(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'required' => ['to'],
'properties' => [
'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'ringTimeout' => ['type' => 'integer', 'min' => 3, 'max' => 120],
'maxDuration' => ['type' => 'integer', 'min' => 1],
'sendKeys' => ['type' => 'string', 'maxLength' => 20, 'pattern' => '^[0-9*#]+$'],
'record' => ['type' => 'boolean'],
'recordStart' => ['type' => 'string', 'enum' => ['record-from-answer', 'record-from-ringing']],
'flowStart' => ['type' => 'string', 'enum' => ['from-answer', 'from-ringing']],
'stereo' => ['type' => 'boolean'],
'callFlow' => self::callFlowCommands(),
'scheduledFor' => ['type' => 'string', 'format' => 'date-time'],
'notification' => self::notificationSchema(),
'amdSettings' => self::amdSettingsSchema(),
'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64]],
],
];
}
public static function voiceListCallsQuery(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'properties' => [
'limit' => ['type' => 'integer', 'min' => 1, 'max' => 1000],
'pageToken' => ['type' => 'string', 'maxLength' => 8000],
'startAt' => ['type' => 'string', 'format' => 'date-time'],
'endAt' => ['type' => 'string', 'format' => 'date-time'],
'status' => ['type' => 'string', 'enum' => self::callStatuses()],
'type' => ['type' => 'string', 'enum' => ['pstn', 'sip', 'webrtc']],
'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'duration' => ['type' => 'integer', 'min' => 0],
'direction' => ['type' => 'string', 'enum' => ['incoming', 'outgoing']],
'id' => ['type' => 'string', 'format' => 'uuid'],
'tag' => ['type' => 'array', 'csv' => true, 'items' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64]],
],
];
}
public static function voiceUpdateCallBody(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'properties' => [
'status' => ['type' => 'string', 'nullable' => true, 'enum' => ['completed']],
'callFlow' => self::callFlowCommands(),
],
];
}
public static function voiceHangupBody(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'properties' => [
'cause' => ['type' => 'string', 'enum' => ['rejected', 'busy']],
],
];
}
public static function voicePlaybackBody(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'required' => ['media'],
'properties' => [
'media' => ['type' => 'array', 'minItems' => 1, 'maxItems' => 80, 'items' => ['type' => 'string', 'minLength' => 1]],
'loop' => ['type' => 'integer', 'min' => 0],
'timeout' => ['type' => 'integer', 'min' => 0],
'pauseMilliseconds' => ['type' => 'integer', 'min' => 0, 'max' => 30000],
],
];
}
public static function voiceSayBody(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'required' => ['text'],
'properties' => [
'text' => ['type' => 'string', 'minLength' => 1],
'locale' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 20],
'voice' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64],
'loop' => ['type' => 'integer', 'min' => 0],
'timeout' => ['type' => 'integer', 'min' => 0],
'hangup' => ['type' => 'boolean'],
],
];
}
public static function voiceGatherBody(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'properties' => [
'maxNumKeys' => ['type' => 'integer', 'min' => 1],
'endKey' => ['type' => 'string', 'enum' => self::digitEnums()],
'timeout' => ['type' => 'integer', 'min' => 0],
'retries' => ['type' => 'integer', 'min' => 0],
'input' => ['type' => 'string', 'enum' => ['dtmf', 'speech', 'dtmf speech']],
'speechLocale' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 20],
'playback' => self::voicePlaybackBody(),
'say' => self::voiceSayBody(),
],
];
}
public static function voiceBridgeBody(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'required' => ['to'],
'properties' => [
'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'ringTimeout' => ['type' => 'integer', 'min' => 3, 'max' => 120],
'maxDuration' => ['type' => 'integer', 'min' => 1],
'ringTone' => ['type' => 'string', 'enum' => ['be', 'ca', 'cn', 'cy', 'cz', 'de', 'dk', 'dz', 'eg', 'fi', 'fr', 'hk', 'hu', 'il', 'in', 'jp', 'ko', 'pk', 'pl', 'ro', 'rs', 'ru', 'sa', 'tr', 'uk', 'us']],
'hangupAfterBridge' => ['type' => 'boolean'],
'record' => ['type' => 'boolean'],
'recordStart' => ['type' => 'string', 'enum' => ['record-from-answer', 'record-from-ringing']],
'recordStereo' => ['type' => 'boolean'],
'callFlow' => self::callFlowCommands(),
'notification' => self::notificationSchema(),
'amdSettings' => self::amdSettingsSchema(),
],
];
}
public static function voiceRecordBody(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'properties' => [
'endKey' => ['type' => 'string', 'enum' => self::digitEnums()],
'maxLength' => ['type' => 'integer', 'min' => 1],
'timeout' => ['type' => 'integer', 'min' => 0],
'beep' => ['type' => 'boolean'],
'transcribe' => ['type' => 'boolean'],
'transcribeLocale' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 20],
],
];
}
public static function voiceRecordingsCreateBody(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'properties' => [
'maxLength' => ['type' => 'integer', 'min' => 1],
'stereo' => ['type' => 'boolean'],
],
];
}
public static function voiceRecordingsListQuery(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'properties' => [
'limit' => ['type' => 'integer', 'min' => 1, 'max' => 1000],
'pageToken' => ['type' => 'string', 'maxLength' => 8000],
],
];
}
public static function voiceRecordingUpdateBody(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'required' => ['status'],
'properties' => [
'status' => ['type' => 'string', 'enum' => ['paused', 'ongoing', 'completed']],
],
];
}
public static function voiceCallsLogQuery(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'properties' => [
'limit' => ['type' => 'integer', 'min' => 1, 'max' => 1000],
'pageToken' => ['type' => 'string', 'maxLength' => 8000],
'startAt' => ['type' => 'string', 'format' => 'date-time'],
'endAt' => ['type' => 'string', 'format' => 'date-time'],
'channelId' => ['type' => 'array', 'csv' => true, 'items' => ['type' => 'string', 'format' => 'uuid']],
'status' => ['type' => 'string', 'enum' => self::callStatuses()],
'type' => ['type' => 'string', 'enum' => ['pstn', 'sip', 'webrtc']],
'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'duration' => ['type' => 'integer', 'min' => 0],
'direction' => ['type' => 'string', 'enum' => ['incoming', 'outgoing']],
'id' => ['type' => 'string', 'format' => 'uuid'],
'tag' => ['type' => 'array', 'csv' => true, 'items' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64]],
],
];
}
public static function voiceTestOutboundBody(): array
{
$create = self::voiceCreateCallBody();
$properties = isset($create['properties']) && is_array($create['properties']) ? $create['properties'] : [];
$properties['timeout'] = ['type' => 'integer', 'min' => 1];
$properties['pollIntervalSeconds'] = ['type' => 'integer', 'min' => 1];
$properties['maxPollSeconds'] = ['type' => 'integer', 'min' => 5];
$properties['hangupCause'] = ['type' => 'string', 'enum' => ['rejected', 'busy']];
return [
'type' => 'object',
'additionalProperties' => false,
'properties' => $properties,
];
}
public static function flashCreateBody(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'required' => ['to'],
'properties' => [
'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'ringTimeout' => ['type' => 'integer', 'min' => 3, 'max' => 120],
],
];
}
public static function flashListQuery(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'properties' => [
'limit' => ['type' => 'integer', 'min' => 1, 'max' => 1000],
'pageToken' => ['type' => 'string', 'maxLength' => 8000],
'startAt' => ['type' => 'string', 'format' => 'date-time'],
'endAt' => ['type' => 'string', 'format' => 'date-time'],
'status' => ['type' => 'string', 'enum' => self::callStatuses()],
'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'duration' => ['type' => 'integer', 'min' => 0],
'id' => ['type' => 'string', 'format' => 'uuid'],
],
];
}
public static function flashEndBody(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'required' => ['result'],
'properties' => [
'receivedCli' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'result' => ['type' => 'string', 'enum' => ['unknown', 'verified', 'canceled', 'timeout', 'wrong_cli']],
],
];
}
public static function flashHangupBody(): array
{
return [
'oneOf' => [
[
'type' => 'object',
'additionalProperties' => false,
'required' => ['result'],
'properties' => [
'receivedCli' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'result' => ['type' => 'string', 'enum' => ['unknown', 'verified', 'canceled', 'timeout', 'wrong_cli']],
],
],
[
'type' => 'object',
'additionalProperties' => false,
'required' => ['from', 'to'],
'properties' => [
'from' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'to' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'receivedCli' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100],
'result' => ['type' => 'string', 'enum' => ['unknown', 'verified', 'canceled', 'timeout', 'wrong_cli']],
],
],
],
];
}
private static function callFlowCommands(): array
{
return [
'type' => 'array',
'maxItems' => 20,
'items' => self::callFlowCommandSchema(),
];
}
private static function callFlowCommandSchema(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'required' => ['command'],
'properties' => [
'command' => [
'type' => 'string',
'enum' => ['answer', 'hangup', 'playback', 'say', 'gather', 'record', 'bridge', 'pause', 'ringing'],
],
'conditions' => [
'type' => 'array',
'items' => [
'type' => 'object',
'additionalProperties' => false,
'required' => ['variable', 'operator', 'value'],
'properties' => [
'variable' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64],
'operator' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 16],
'value' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 255],
],
],
],
'options' => [
'type' => 'object',
'additionalProperties' => true,
'properties' => [],
],
],
];
}
private static function notificationSchema(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'required' => ['url'],
'properties' => [
'url' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 2048],
],
];
}
private static function amdSettingsSchema(): array
{
return [
'type' => 'object',
'additionalProperties' => false,
'properties' => [
'enabled' => ['type' => 'boolean'],
'wordCount' => ['type' => 'integer', 'min' => 1],
'speechTimeout' => ['type' => 'integer', 'min' => 1],
'speechLocale' => ['type' => 'string', 'minLength' => 2, 'maxLength' => 20],
'beepTimeout' => ['type' => 'integer', 'min' => 1],
'ifMachineNotifyAfter' => ['type' => 'string', 'enum' => ['wordCount', 'beep']],
],
];
}
private static function callStatuses(): array
{
return ['accepted', 'starting', 'ringing', 'ongoing', 'completed', 'no-answer', 'busy', 'failed', 'cancelled', 'scheduled'];
}
private static function digitEnums(): array
{
return ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'];
}
}
@@ -0,0 +1,272 @@
<?php
namespace bird\helpers;
class bird_request_validator
{
public static function validate(mixed $value, array $schema, string $path = '$'): array
{
$errors = [];
self::validateValue($value, $schema, $path, $errors);
return $errors;
}
public static function isUuid(string $value): bool
{
return preg_match('/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/', $value) === 1;
}
private static function validateValue(mixed $value, array $schema, string $path, array &$errors): void
{
if (($schema['nullable'] ?? false) && $value === null) {
return;
}
if (isset($schema['oneOf']) && is_array($schema['oneOf'])) {
self::validateOneOf($value, $schema['oneOf'], $path, $errors);
return;
}
$type = isset($schema['type']) && is_string($schema['type']) ? $schema['type'] : null;
if ($type !== null) {
match ($type) {
'object' => self::validateObject($value, $schema, $path, $errors),
'array' => self::validateArray($value, $schema, $path, $errors),
'string' => self::validateString($value, $schema, $path, $errors),
'int', 'integer' => self::validateInteger($value, $schema, $path, $errors),
'number' => self::validateNumber($value, $schema, $path, $errors),
'bool', 'boolean' => self::validateBoolean($value, $schema, $path, $errors),
default => $errors[] = $path . ' has unsupported schema type "' . $type . '"',
};
}
if (isset($schema['enum']) && is_array($schema['enum'])) {
self::validateEnum($value, $schema['enum'], $path, $errors);
}
}
private static function validateOneOf(mixed $value, array $schemas, string $path, array &$errors): void
{
$bestErrors = null;
foreach ($schemas as $schema) {
if (!is_array($schema)) {
continue;
}
$tmp = [];
self::validateValue($value, $schema, $path, $tmp);
if ($tmp === []) {
return;
}
if ($bestErrors === null || count($tmp) < count($bestErrors)) {
$bestErrors = $tmp;
}
}
$errors[] = $path . ' does not match any of the allowed schemas';
if (is_array($bestErrors)) {
foreach ($bestErrors as $err) {
$errors[] = $err;
}
}
}
private static function validateObject(mixed $value, array $schema, string $path, array &$errors): void
{
if (!is_array($value) || !self::isAssoc($value)) {
$errors[] = $path . ' must be an object';
return;
}
$required = isset($schema['required']) && is_array($schema['required']) ? $schema['required'] : [];
foreach ($required as $requiredKey) {
if (!array_key_exists((string)$requiredKey, $value)) {
$errors[] = $path . '.' . $requiredKey . ' is required';
}
}
$properties = isset($schema['properties']) && is_array($schema['properties']) ? $schema['properties'] : [];
$allowAdditional = (bool)($schema['additionalProperties'] ?? true);
if (!$allowAdditional) {
foreach ($value as $key => $_unused) {
if (!array_key_exists((string)$key, $properties)) {
$errors[] = $path . '.' . $key . ' is not allowed';
}
}
}
foreach ($properties as $key => $propertySchema) {
if (!array_key_exists((string)$key, $value)) {
continue;
}
if (!is_array($propertySchema)) {
continue;
}
self::validateValue($value[(string)$key], $propertySchema, $path . '.' . $key, $errors);
}
}
private static function validateArray(mixed $value, array $schema, string $path, array &$errors): void
{
$arrayValue = $value;
if (is_string($arrayValue) && ($schema['csv'] ?? false)) {
$parts = array_map('trim', explode(',', $arrayValue));
$arrayValue = array_values(array_filter($parts, static fn($part) => $part !== ''));
}
if (!is_array($arrayValue) || self::isAssoc($arrayValue)) {
$errors[] = $path . ' must be an array';
return;
}
$count = count($arrayValue);
if (isset($schema['minItems']) && is_numeric($schema['minItems']) && $count < (int)$schema['minItems']) {
$errors[] = $path . ' must have at least ' . (int)$schema['minItems'] . ' items';
}
if (isset($schema['maxItems']) && is_numeric($schema['maxItems']) && $count > (int)$schema['maxItems']) {
$errors[] = $path . ' must have at most ' . (int)$schema['maxItems'] . ' items';
}
$itemSchema = isset($schema['items']) && is_array($schema['items']) ? $schema['items'] : null;
if ($itemSchema === null) {
return;
}
foreach ($arrayValue as $index => $item) {
self::validateValue($item, $itemSchema, $path . '[' . $index . ']', $errors);
}
}
private static function validateString(mixed $value, array $schema, string $path, array &$errors): void
{
if (!is_string($value)) {
$errors[] = $path . ' must be a string';
return;
}
$length = strlen($value);
if (isset($schema['minLength']) && is_numeric($schema['minLength']) && $length < (int)$schema['minLength']) {
$errors[] = $path . ' must be at least ' . (int)$schema['minLength'] . ' characters';
}
if (isset($schema['maxLength']) && is_numeric($schema['maxLength']) && $length > (int)$schema['maxLength']) {
$errors[] = $path . ' must be at most ' . (int)$schema['maxLength'] . ' characters';
}
if (isset($schema['pattern']) && is_string($schema['pattern']) && @preg_match('/' . $schema['pattern'] . '/', $value) !== 1) {
$errors[] = $path . ' has invalid format';
}
if (isset($schema['format']) && is_string($schema['format'])) {
if ($schema['format'] === 'uuid' && !self::isUuid($value)) {
$errors[] = $path . ' must be a UUID';
}
if ($schema['format'] === 'date-time' && strtotime($value) === false) {
$errors[] = $path . ' must be a valid date-time';
}
}
}
private static function validateInteger(mixed $value, array $schema, string $path, array &$errors): void
{
$intValue = self::toInt($value);
if ($intValue === null) {
$errors[] = $path . ' must be an integer';
return;
}
if (isset($schema['min']) && is_numeric($schema['min']) && $intValue < (int)$schema['min']) {
$errors[] = $path . ' must be at least ' . (int)$schema['min'];
}
if (isset($schema['max']) && is_numeric($schema['max']) && $intValue > (int)$schema['max']) {
$errors[] = $path . ' must be at most ' . (int)$schema['max'];
}
}
private static function validateNumber(mixed $value, array $schema, string $path, array &$errors): void
{
$numberValue = self::toFloat($value);
if ($numberValue === null) {
$errors[] = $path . ' must be a number';
return;
}
if (isset($schema['min']) && is_numeric($schema['min']) && $numberValue < (float)$schema['min']) {
$errors[] = $path . ' must be at least ' . (float)$schema['min'];
}
if (isset($schema['max']) && is_numeric($schema['max']) && $numberValue > (float)$schema['max']) {
$errors[] = $path . ' must be at most ' . (float)$schema['max'];
}
}
private static function validateBoolean(mixed $value, array $schema, string $path, array &$errors): void
{
if (self::toBool($value) === null) {
$errors[] = $path . ' must be a boolean';
}
}
private static function validateEnum(mixed $value, array $allowedValues, string $path, array &$errors): void
{
if (in_array($value, $allowedValues, true)) {
return;
}
if (is_scalar($value)) {
foreach ($allowedValues as $allowed) {
if (is_scalar($allowed) && (string)$allowed === (string)$value) {
return;
}
}
}
$errors[] = $path . ' must be one of: ' . implode(', ', array_map(static fn($item) => (string)$item, $allowedValues));
}
private static function toInt(mixed $value): ?int
{
if (is_int($value)) {
return $value;
}
if (is_string($value) && preg_match('/^-?[0-9]+$/', $value) === 1) {
return (int)$value;
}
return null;
}
private static function toFloat(mixed $value): ?float
{
if (is_int($value) || is_float($value)) {
return (float)$value;
}
if (is_string($value) && is_numeric($value)) {
return (float)$value;
}
return null;
}
private static function toBool(mixed $value): ?bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) && ($value === 0 || $value === 1)) {
return $value === 1;
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
if ($normalized === 'true' || $normalized === '1') {
return true;
}
if ($normalized === 'false' || $normalized === '0') {
return false;
}
}
return null;
}
private static function isAssoc(array $array): bool
{
return array_keys($array) !== range(0, count($array) - 1);
}
}
@@ -0,0 +1,17 @@
<?php
namespace bird\interfaces;
interface bird_flash_calls_client_i
{
public function createFlashCall(string $workspaceId, string $channelId, array $payload): array|object|null;
public function listFlashCalls(string $workspaceId, string $channelId, array $query = []): array|object|null;
public function getFlashCall(string $workspaceId, string $channelId, string $callId): array|object|null;
public function endFlashCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null;
public function hangupFlashCall(string $workspaceId, string $channelId, array $payload = []): array|object|null;
}
@@ -0,0 +1,31 @@
<?php
namespace bird\interfaces;
interface bird_voice_calls_client_i
{
public function createVoiceCall(string $workspaceId, string $channelId, array $payload): array|object|null;
public function listVoiceCalls(string $workspaceId, string $channelId, array $query = []): array|object|null;
public function getVoiceCall(string $workspaceId, string $channelId, string $callId): array|object|null;
public function updateVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null;
public function answerVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null;
public function ringVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null;
public function hangupVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null;
public function playbackVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null;
public function sayVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null;
public function gatherVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null;
public function bridgeVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null;
public function recordVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null;
}
@@ -0,0 +1,11 @@
<?php
namespace bird\interfaces;
interface bird_voice_insights_client_i
{
public function getVoiceCallInsights(string $workspaceId, string $channelId, string $callId): array|object|null;
public function getVoiceCallsLog(string $workspaceId, array $query = []): array|object|null;
}
@@ -0,0 +1,15 @@
<?php
namespace bird\interfaces;
interface bird_voice_recordings_client_i
{
public function createVoiceCallRecordingSession(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null;
public function listVoiceCallRecordings(string $workspaceId, string $channelId, string $callId, array $query = []): array|object|null;
public function getVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId): array|object|null;
public function updateVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId, array $payload): array|object|null;
}
@@ -0,0 +1,73 @@
<?php
namespace bird\traits;
trait bird_endpoint_builder_t
{
protected function birdEncodePathSegment(string $segment): string
{
return rawurlencode($segment);
}
protected function birdVoiceCallsBasePath(string $workspaceId, string $channelId): string
{
return '/workspaces/'
. $this->birdEncodePathSegment($workspaceId)
. '/channels/'
. $this->birdEncodePathSegment($channelId)
. '/calls';
}
protected function birdVoiceCallPath(string $workspaceId, string $channelId, string $callId): string
{
return $this->birdVoiceCallsBasePath($workspaceId, $channelId)
. '/'
. $this->birdEncodePathSegment($callId);
}
protected function birdVoiceCallCommandPath(string $workspaceId, string $channelId, string $callId, string $command): string
{
return $this->birdVoiceCallPath($workspaceId, $channelId, $callId)
. '/'
. trim($command, '/');
}
protected function birdVoiceCallRecordingsPath(string $workspaceId, string $channelId, string $callId): string
{
return $this->birdVoiceCallPath($workspaceId, $channelId, $callId) . '/recordings';
}
protected function birdVoiceCallRecordingPath(string $workspaceId, string $channelId, string $callId, string $recordingId): string
{
return $this->birdVoiceCallRecordingsPath($workspaceId, $channelId, $callId)
. '/'
. $this->birdEncodePathSegment($recordingId);
}
protected function birdVoiceCallsLogPath(string $workspaceId): string
{
return '/workspaces/' . $this->birdEncodePathSegment($workspaceId) . '/channels/calls';
}
protected function birdFlashCallsBasePath(string $workspaceId, string $channelId): string
{
return '/workspaces/'
. $this->birdEncodePathSegment($workspaceId)
. '/channels/'
. $this->birdEncodePathSegment($channelId)
. '/flashcalls';
}
protected function birdFlashCallPath(string $workspaceId, string $channelId, string $callId): string
{
return $this->birdFlashCallsBasePath($workspaceId, $channelId)
. '/'
. $this->birdEncodePathSegment($callId);
}
protected function birdFlashCallsHangupPath(string $workspaceId, string $channelId): string
{
return $this->birdFlashCallsBasePath($workspaceId, $channelId) . '/hangup';
}
}
@@ -147,9 +147,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
if ((int)$lane->getWashStartTime() <= 0) {
$lane->setWashStartTime(time());
}
$washStartedAt = (int)$lane->getWashStartTime();
$this->enableCleanerRelayForStartedWash($lane);
$session->markMachineStartTriggered();
$session->markMachineStartTriggered(
$washStartedAt > 0 ? date('Y-m-d H:i:s', $washStartedAt) : null
);
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_START_TRIGGERED, $payload + [
'lane_id' => $laneId,
'reg' => $effectiveReg,
@@ -6,6 +6,7 @@ use classes\db;
use classes\object_property;
use classes\selfserve;
use classes\selfserve_schema_bootstrap;
use DateTime;
use modules\selfserve\helpers\selfserve_wash_session_status;
use traits\db_object_t;
@@ -26,6 +27,7 @@ class selfserve_wash_sessions_o extends db
public object_property $machine_relay_enabled_at;
public object_property $machine_start_triggered;
public object_property $machine_start_triggered_at;
public object_property $wash_started_at;
public object_property $order_id;
public object_property $completed_at;
public object_property $metadata_json;
@@ -84,6 +86,7 @@ class selfserve_wash_sessions_o extends db
$this->machine_relay_enabled_at = new object_property($this->table, $this->id, 'machine_relay_enabled_at', 'datetime', false);
$this->machine_start_triggered = new object_property($this->table, $this->id, 'machine_start_triggered', 'bool', false);
$this->machine_start_triggered_at = new object_property($this->table, $this->id, 'machine_start_triggered_at', 'datetime', false);
$this->wash_started_at = new object_property($this->table, $this->id, 'wash_started_at', 'datetime', false);
$this->order_id = new object_property($this->table, $this->id, 'order_id', 'int', false);
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'datetime', false);
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false);
@@ -116,11 +119,13 @@ class selfserve_wash_sessions_o extends db
$this->machine_relay_enabled_at->set(null);
}
public function markMachineStartTriggered(): void
public function markMachineStartTriggered(?string $washStartedAt = null): void
{
$now = date('Y-m-d H:i:s');
$resolvedWashStartedAt = $washStartedAt ?? $now;
$this->machine_start_triggered->set(true);
$this->machine_start_triggered_at->set($now);
$this->wash_started_at->set($resolvedWashStartedAt);
if ((bool)$this->machine_relay_enabled->value() !== true) {
$this->machine_relay_enabled->set(true);
$this->machine_relay_enabled_at->set($now);
@@ -189,6 +194,7 @@ class selfserve_wash_sessions_o extends db
'machine_relay_enabled_at' => $this->machine_relay_enabled_at->value() === null ? null : (string)$this->machine_relay_enabled_at->value(),
'machine_start_triggered' => (bool)$this->machine_start_triggered->value(),
'machine_start_triggered_at' => $this->machine_start_triggered_at->value() === null ? null : (string)$this->machine_start_triggered_at->value(),
'wash_started_at' => $this->wash_started_at->value() === null ? null : (string)$this->wash_started_at->value(),
'order_id' => $this->order_id->value() === null ? null : (int)$this->order_id->value(),
'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
'metadata' => (array)($this->metadata_json->value() ?? []),
@@ -196,4 +202,26 @@ class selfserve_wash_sessions_o extends db
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
];
}
public function getElapsedMinutes(): int
{
$startAt = $this->wash_started_at->value() ?? $this->machine_start_triggered_at->value();
if ($startAt === null || trim((string)$startAt) === '') {
return 0;
}
try {
$start = new DateTime((string)$startAt);
$now = new DateTime();
} catch (\Throwable) {
return 0;
}
if ($start > $now) {
return 0;
}
$diff = $start->diff($now);
return (int)(($diff->days * 24 * 60) + ($diff->h * 60) + $diff->i);
}
}
+274 -133
View File
@@ -2,158 +2,199 @@
namespace routes;
require_once WD . '/classes/bird.php';
require_once WD . '/traits/route_t.php';
require_once WD . '/traits/bird_route_helpers_t.php';
require_once WD . '/traits/bird_route_validation_t.php';
require_once WD . '/modules/bird/helpers/bird_request_schemas.php';
use bird\helpers\bird_request_schemas;
use classes\bird;
use traits\bird_route_helpers_t;
use traits\bird_route_validation_t;
use traits\route_t;
class birdVoiceCallsRoute
{
use route_t, bird_route_helpers_t;
use route_t, bird_route_helpers_t, bird_route_validation_t;
public function run(): void
{
// List workspace call log
$this->get('/bird/voice/calls/log', function () {
global $response;
self::requirePermission('modules_bird_voice_calls_log_list');
$client = new bird();
$workspaceId = $this->birdResolveWorkspaceId($client);
$query = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$query = $this->birdNormalizeCsvField($query, 'channelId');
$query = $this->birdNormalizeCsvField($query, 'tag');
$query = $this->birdValidateSchema($query, bird_request_schemas::voiceCallsLogQuery());
$res = $client->getVoiceCallsLog($workspaceId, $query);
$response->success($res ?? []);
}, [
'modules_bird_voice_calls_log_list' => 'List voice call log entries via Bird',
]);
// Create a voice call
$this->post('/bird/voice/calls', function () {
global $response;
// Permission: create/place a voice call via Bird
self::requirePermission('modules_bird_voice_calls_create');
$client = new bird();
// Require workspace/channel per Bird API docs
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($ws === '') {
$ws = $this->getConfiguredWorkspaceId($client);
}
if ($ch === '') {
$ch = $this->getConfiguredChannelId($client);
}
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$payload = $this->getParametersAsArray();
unset($payload['workspaceId'], $payload['channelId']);
$res = $client->createVoiceCall($ws, $ch, $payload);
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceCreateCallBody());
$res = $client->createVoiceCall($workspaceId, $channelId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_create' => 'Create/place a voice call via Bird',
]);
// List voice calls (passthrough of query filters)
// List voice calls
$this->get('/bird/voice/calls', function () {
global $response;
// Permission: list voice calls via Bird
self::requirePermission('modules_bird_voice_calls_list');
$client = new bird();
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($ws === '') {
$ws = $this->getConfiguredWorkspaceId($client);
}
if ($ch === '') {
$ch = $this->getConfiguredChannelId($client);
}
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$query = $this->getParametersAsArray();
unset($query['workspaceId'], $query['channelId']);
$res = $client->listVoiceCalls($ws, $ch, $query);
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$query = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$query = $this->birdNormalizeCsvField($query, 'tag');
$query = $this->birdValidateSchema($query, bird_request_schemas::voiceListCallsQuery());
$res = $client->listVoiceCalls($workspaceId, $channelId, $query);
$response->success($res ?? []);
}, [
'modules_bird_voice_calls_list' => 'List voice calls via Bird',
]);
// Get a specific call by ID
// Get a voice call by ID
$this->get('/bird/voice/calls/{id}', function () {
global $response;
// Permission: get a specific voice call via Bird
self::requirePermission('modules_bird_voice_calls_get');
$client = new bird();
$id = (string)$this->fromRoute('id');
if ($id === null || $id === '') {
$response->error('Missing id', 400);
}
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($ws === '') {
$ws = $this->getConfiguredWorkspaceId($client);
}
if ($ch === '') {
$ch = $this->getConfiguredChannelId($client);
}
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$res = $client->getVoiceCall($ws, $ch, $id);
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$res = $client->getVoiceCall($workspaceId, $channelId, $callId);
$response->success($res ?? []);
}, [
'modules_bird_voice_calls_get' => 'Get a voice call by ID via Bird',
]);
// Update a voice call by ID
$this->patch('/bird/voice/calls/{id}', function () {
global $response;
self::requirePermission('modules_bird_voice_calls_update');
$client = new bird();
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceUpdateCallBody());
$res = $client->updateVoiceCall($workspaceId, $channelId, $callId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_update' => 'Update a voice call by ID via Bird',
]);
// Answer an incoming call by ID
$this->post('/bird/voice/calls/{id}/answer', function () {
global $response;
self::requirePermission('modules_bird_voice_calls_answer');
$client = new bird();
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::noBody());
$res = $client->answerVoiceCall($workspaceId, $channelId, $callId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_answer' => 'Answer a voice call by ID via Bird',
]);
// Mark call as ringing by ID
$this->post('/bird/voice/calls/{id}/ringing', function () {
global $response;
self::requirePermission('modules_bird_voice_calls_ringing');
$client = new bird();
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::noBody());
$res = $client->ringVoiceCall($workspaceId, $channelId, $callId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_ringing' => 'Mark a voice call as ringing via Bird',
]);
// Hang up an active call by ID
$this->post('/bird/voice/calls/{id}/hangup', function () {
global $response;
// Permission: hang up a specific voice call via Bird
self::requirePermission('modules_bird_voice_calls_hangup');
$client = new bird();
$id = (string)$this->fromRoute('id');
if ($id === null || $id === '') {
$response->error('Missing id', 400);
}
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($ws === '') {
$ws = $this->getConfiguredWorkspaceId($client);
}
if ($ch === '') {
$ch = $this->getConfiguredChannelId($client);
}
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$payload = [];
$cause = $this->normalizeOptionalString($this->fromRequest('cause') ?? $this->fromQuery('cause'));
if ($cause !== '') {
$payload['cause'] = $cause;
}
$res = $client->hangupVoiceCall($ws, $ch, $id, $payload);
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceHangupBody());
$res = $client->hangupVoiceCall($workspaceId, $channelId, $callId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_hangup' => 'Hang up a voice call by ID via Bird',
]);
// Say a message on an active call and hang up afterwards
// Playback media on an active call by ID
$this->post('/bird/voice/calls/{id}/playback', function () {
global $response;
self::requirePermission('modules_bird_voice_calls_playback');
$client = new bird();
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::voicePlaybackBody());
$res = $client->playbackVoiceCall($workspaceId, $channelId, $callId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_playback' => 'Playback media on a voice call by ID via Bird',
]);
// Say a message on an active call
$this->post('/bird/voice/calls/{id}/say', function () {
global $response;
// Permission: say a message on a voice call via Bird
self::requirePermission('modules_bird_voice_calls_say');
$client = new bird();
$id = (string)$this->fromRoute('id');
if ($id === null || $id === '') {
$response->error('Missing id', 400);
}
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($ws === '') {
$ws = $this->getConfiguredWorkspaceId($client);
}
if ($ch === '') {
$ch = $this->getConfiguredChannelId($client);
}
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$payload = $this->getParametersAsArray();
unset($payload['workspaceId'], $payload['channelId']);
// Bird's /say REST API endpoint plays the message.
// To fulfill the requirement of hanging up afterwards, we should ensure the call is terminated.
// Many Bird actions support a 'hangup' field in the payload to terminate the call after the action is complete.
if (!isset($payload['hangup'])) {
$client = new bird();
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceSayBody());
if (!array_key_exists('hangup', $payload)) {
$payload['hangup'] = true;
}
$res = $client->sayMessage($ws, $ch, $id, $payload);
$res = $client->sayMessage($workspaceId, $channelId, $callId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_say' => 'Say a message on a voice call by ID via Bird',
@@ -162,56 +203,156 @@ class birdVoiceCallsRoute
// Gather input on an active call
$this->post('/bird/voice/calls/{id}/gather', function () {
global $response;
// Permission: gather input on a voice call via Bird
self::requirePermission('modules_bird_voice_calls_gather');
$client = new bird();
$id = (string)$this->fromRoute('id');
if ($id === null || $id === '') {
$response->error('Missing id', 400);
}
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($ws === '') {
$ws = $this->getConfiguredWorkspaceId($client);
}
if ($ch === '') {
$ch = $this->getConfiguredChannelId($client);
}
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$payload = $this->getParametersAsArray();
unset($payload['workspaceId'], $payload['channelId']);
$res = $client->gatherMessage($ws, $ch, $id, $payload);
$client = new bird();
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceGatherBody());
$res = $client->gatherMessage($workspaceId, $channelId, $callId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_gather' => 'Gather input on a voice call by ID via Bird',
]);
// Bridge current call to another destination
$this->post('/bird/voice/calls/{id}/bridge', function () {
global $response;
self::requirePermission('modules_bird_voice_calls_bridge');
$client = new bird();
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceBridgeBody());
$res = $client->bridgeVoiceCall($workspaceId, $channelId, $callId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_bridge' => 'Bridge a voice call by ID via Bird',
]);
// Record call command endpoint
$this->post('/bird/voice/calls/{id}/record', function () {
global $response;
self::requirePermission('modules_bird_voice_calls_record');
$client = new bird();
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceRecordBody());
$res = $client->recordVoiceCall($workspaceId, $channelId, $callId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_record' => 'Record a voice call by ID via Bird',
]);
// Start call recording session
$this->post('/bird/voice/calls/{id}/recordings', function () {
global $response;
self::requirePermission('modules_bird_voice_calls_recordings_create');
$client = new bird();
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceRecordingsCreateBody());
$res = $client->createVoiceCallRecordingSession($workspaceId, $channelId, $callId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_recordings_create' => 'Create a voice call recording session via Bird',
]);
// List call recordings
$this->get('/bird/voice/calls/{id}/recordings', function () {
global $response;
self::requirePermission('modules_bird_voice_calls_recordings_list');
$client = new bird();
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$query = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$query = $this->birdValidateSchema($query, bird_request_schemas::voiceRecordingsListQuery());
$res = $client->listVoiceCallRecordings($workspaceId, $channelId, $callId, $query);
$response->success($res ?? []);
}, [
'modules_bird_voice_calls_recordings_list' => 'List voice call recordings via Bird',
]);
// Get single call recording
$this->get('/bird/voice/calls/{id}/recordings/{recordingId}', function () {
global $response;
self::requirePermission('modules_bird_voice_calls_recordings_get');
$client = new bird();
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$recordingId = $this->birdResolveRouteRecordingId();
$res = $client->getVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId);
$response->success($res ?? []);
}, [
'modules_bird_voice_calls_recordings_get' => 'Get a voice call recording by ID via Bird',
]);
// Update single call recording
$this->patch('/bird/voice/calls/{id}/recordings/{recordingId}', function () {
global $response;
self::requirePermission('modules_bird_voice_calls_recordings_update');
$client = new bird();
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$recordingId = $this->birdResolveRouteRecordingId();
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceRecordingUpdateBody());
$res = $client->updateVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_recordings_update' => 'Update a voice call recording via Bird',
]);
// Get call insights
$this->get('/bird/voice/calls/{id}/insights', function () {
global $response;
self::requirePermission('modules_bird_voice_calls_insights_get');
$client = new bird();
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$res = $client->getVoiceCallInsights($workspaceId, $channelId, $callId);
$response->success($res ?? []);
}, [
'modules_bird_voice_calls_insights_get' => 'Get voice call insights by call ID via Bird',
]);
// Place test outbound call and hang up when accepted
$this->post('/bird/voice/calls/test-outbound', function () {
global $response;
self::requirePermission('modules_bird_voice_calls_test_outbound');
$client = new bird();
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($ws === '') {
$ws = $this->getConfiguredWorkspaceId($client);
}
if ($ch === '') {
$ch = $this->getConfiguredChannelId($client);
}
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$payload = $this->getParametersAsArray();
unset($payload['workspaceId'], $payload['channelId']);
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::voiceTestOutboundBody());
try {
$result = $client->createOutboundTestCallAndHangupWhenAccepted($ws, $ch, $payload);
$result = $client->createOutboundTestCallAndHangupWhenAccepted($workspaceId, $channelId, $payload);
$response->success($result);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 500);
@@ -2,38 +2,36 @@
namespace routes;
require_once WD . '/classes/bird.php';
require_once WD . '/traits/route_t.php';
require_once WD . '/traits/bird_route_helpers_t.php';
require_once WD . '/traits/bird_route_validation_t.php';
require_once WD . '/modules/bird/helpers/bird_request_schemas.php';
use bird\helpers\bird_request_schemas;
use classes\bird;
use traits\bird_route_helpers_t;
use traits\bird_route_validation_t;
use traits\route_t;
class birdVoiceFlashCallsRoute
{
use route_t, bird_route_helpers_t;
use route_t, bird_route_helpers_t, bird_route_validation_t;
public function run(): void
{
// Create a flash call
$this->post('/bird/voice/flash-calls', function () {
global $response;
// Permission: create/place a flash call via Bird
self::requirePermission('modules_bird_voice_flash_calls_create');
$client = new bird();
// Require workspace/channel per Bird API docs
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($ws === '') {
$ws = $this->getConfiguredWorkspaceId($client);
}
if ($ch === '') {
$ch = $this->getConfiguredChannelId($client);
}
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls';
$payload = $this->getParametersAsArray();
unset($payload['workspaceId'], $payload['channelId']);
$res = $client->sendPostRequest($base, $payload);
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::flashCreateBody());
$res = $client->createFlashCall($workspaceId, $channelId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_flash_calls_create' => 'Create/place a flash call via Bird',
@@ -42,24 +40,15 @@ class birdVoiceFlashCallsRoute
// List flash calls
$this->get('/bird/voice/flash-calls', function () {
global $response;
// Permission: list flash calls via Bird
self::requirePermission('modules_bird_voice_flash_calls_list');
$client = new bird();
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($ws === '') {
$ws = $this->getConfiguredWorkspaceId($client);
}
if ($ch === '') {
$ch = $this->getConfiguredChannelId($client);
}
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls';
$query = $this->getParametersAsArray();
unset($query['workspaceId'], $query['channelId']);
$res = $client->sendGetRequest($base, $query);
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$query = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$query = $this->birdValidateSchema($query, bird_request_schemas::flashListQuery());
$res = $client->listFlashCalls($workspaceId, $channelId, $query);
$response->success($res ?? []);
}, [
'modules_bird_voice_flash_calls_list' => 'List flash calls via Bird',
@@ -68,87 +57,68 @@ class birdVoiceFlashCallsRoute
// Get a specific flash call by ID
$this->get('/bird/voice/flash-calls/{id}', function () {
global $response;
// Permission: get a specific flash call via Bird
self::requirePermission('modules_bird_voice_flash_calls_get');
$client = new bird();
$id = (string)$this->fromRoute('id');
if ($id === null || $id === '') {
$response->error('Missing id', 400);
}
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($ws === '') {
$ws = $this->getConfiguredWorkspaceId($client);
}
if ($ch === '') {
$ch = $this->getConfiguredChannelId($client);
}
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls';
$res = $client->sendGetRequest($base . '/' . rawurlencode($id));
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$res = $client->getFlashCall($workspaceId, $channelId, $callId);
$response->success($res ?? []);
}, [
'modules_bird_voice_flash_calls_get' => 'Get a flash call by ID via Bird',
]);
// Complete/end a flash call by ID (POST to the resource)
// Complete/end a flash call by ID
$this->post('/bird/voice/flash-calls/{id}', function () {
global $response;
// Permission: complete/end a flash call via Bird
self::requirePermission('modules_bird_voice_flash_calls_end');
$client = new bird();
$id = (string)$this->fromRoute('id');
if ($id === null || $id === '') {
$response->error('Missing id', 400);
}
// Forward any body fields (e.g., result/status) transparently
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($ws === '') {
$ws = $this->getConfiguredWorkspaceId($client);
}
if ($ch === '') {
$ch = $this->getConfiguredChannelId($client);
}
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls';
$payload = $this->getParametersAsArray();
unset($payload['workspaceId'], $payload['channelId']);
$res = $client->sendPostRequest($base . '/' . rawurlencode($id), $payload);
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$callId = $this->birdResolveRouteCallId();
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::flashEndBody());
$res = $client->endFlashCall($workspaceId, $channelId, $callId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_flash_calls_end' => 'Complete/end a flash call by ID via Bird',
]);
// Complete/end a flash call by using from/to numbers
$this->post('/bird/voice/flash-calls/end', function () {
// Hang up flash calls by payload
$this->post('/bird/voice/flash-calls/hangup', function () {
global $response;
// Permission: complete/end a flash call by numbers via Bird
self::requirePermission('modules_bird_voice_flash_calls_end_by_numbers');
self::requirePermission('modules_bird_voice_flash_calls_hangup');
$client = new bird();
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($ws === '') {
$ws = $this->getConfiguredWorkspaceId($client);
}
if ($ch === '') {
$ch = $this->getConfiguredChannelId($client);
}
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls';
$payload = $this->getParametersAsArray();
unset($payload['workspaceId'], $payload['channelId']);
// Basic validation hints: expect 'from' and 'to' but let Bird validate strictly
$res = $client->sendPostRequest($base . '/end', $payload);
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::flashHangupBody());
$res = $client->hangupFlashCall($workspaceId, $channelId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_flash_calls_end_by_numbers' => 'Complete/end a flash call using from/to numbers via Bird',
'modules_bird_voice_flash_calls_hangup' => 'Hang up flash calls via Bird',
]);
// Compatibility alias for flash hangup endpoint
$this->post('/bird/voice/flash-calls/end', function () {
global $response;
self::requirePermission('modules_bird_voice_flash_calls_end_by_numbers');
$client = new bird();
[$workspaceId, $channelId] = $this->birdResolveWorkspaceAndChannelIds($client);
$payload = $this->birdPayloadWithout(['workspaceId', 'channelId']);
$payload = $this->birdValidateSchema($payload, bird_request_schemas::flashHangupBody());
$res = $client->hangupFlashCall($workspaceId, $channelId, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_flash_calls_end_by_numbers' => 'Compatibility alias for hanging up flash calls via Bird',
]);
}
}
@@ -11,6 +11,7 @@ use classes\stripe;
use modules\selfserve\helpers\selfserve_lane_command;
use modules\selfserve\helpers\selfserve_lane_port;
use modules\selfserve\helpers\selfserve_lane_relay;
use modules\selfserve\helpers\selfserve_wash_session_status;
use objects\departments_o;
use objects\logs_o;
use objects\orders_o;
@@ -220,13 +221,30 @@ class moduleSelfServeRoute
$customer = $build_customer($customer_number);
$vehicle = $build_vehicle($vehicle_id, $session_reg);
$machine_start_triggered_at = $session->machine_start_triggered_at->value() === null ? null : (string)$session->machine_start_triggered_at->value();
$wash_started_at = $session->wash_started_at->value() === null ? null : (string)$session->wash_started_at->value();
if ($wash_started_at === null) {
$wash_started_at = $machine_start_triggered_at;
}
if ($wash_started_at === null) {
$wash_started_at = $format_wash_started_at($selfserve->lane($lane_id)->getWashStartTime());
}
$machine_relay_enabled = (bool)$session->machine_relay_enabled->value();
$included_minutes = $machine_relay_enabled ? $included_minutes_when_machine_enabled : null;
$status = $session->status->value();
$in_progress_statusses = [
selfserve_wash_session_status::MACHINE_RELAY_ENABLED->value,
selfserve_wash_session_status::READY_FOR_MACHINE_START->value,
selfserve_wash_session_status::MACHINE_STARTED->value,
selfserve_wash_session_status::PENDING_QUESTIONS->value,
selfserve_wash_session_status::MACHINE_NOT_ALLOWED->value
];
$in_progress = in_array($status, $in_progress_statusses);
$response->success([
'lane_id' => $lane_id,
'in_progress' => true,
'elapsed_minutes' => 0, // TODO: Start when the session was created.
'status' => (string)$session->status->value(),
'in_progress' => $in_progress,
'elapsed_minutes' => $session->getElapsedMinutes(),
'session' => [
'id' => (int)$session->id,
'status' => (string)$session->status->value(),
@@ -234,13 +252,13 @@ class moduleSelfServeRoute
'customer_number' => $customer_number,
'vehicle_id' => $vehicle_id,
'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(),
'included_minutes' => $included_minutes,
'included_minutes' => $included_minutes ?? 0,
'machine_type_id' => $session->machine_type_id->value() === null ? null : (int)$session->machine_type_id->value(),
'machine_relay_enabled' => $machine_relay_enabled,
'machine_relay_enabled_at' => $session->machine_relay_enabled_at->value() === null ? null : (string)$session->machine_relay_enabled_at->value(),
'machine_start_triggered' => (bool)$session->machine_start_triggered->value(),
'machine_start_triggered_at' => $machine_start_triggered_at,
'wash_started_at' => $machine_start_triggered_at,
'wash_started_at' => $wash_started_at,
'created_at' => (string)$session->created_at->value(),
'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(),
],
@@ -27,10 +27,17 @@ class moduleWeatherAPIRoute
private const DEPARTMENT_WEATHER_REFRESH_LOCK_PREFIX = 'departments_weather:refresh_lock:v1:';
private const DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY = 'weather_status_degraded_threshold';
private const DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY = 'weather_status_healthy_threshold';
private const DEFAULT_DEPARTMENT_WEATHER_DEGRADED_THRESHOLD = 1.0;
private const DEFAULT_DEPARTMENT_WEATHER_HEALTHY_THRESHOLD = 1.3;
private const DEPARTMENT_WEATHER_STATUS_SEVERITY = [
'healthy' => 1,
'degraded' => 2,
'unhealthy' => 3,
/**
* Below 1.0 cars/workhour = red
* 1.0-1.3 cars/workhour = yellow
* Above 1.3 cars/workhour = green
*/
'healthy' => self::DEFAULT_DEPARTMENT_WEATHER_HEALTHY_THRESHOLD,
'degraded' => self::DEFAULT_DEPARTMENT_WEATHER_DEGRADED_THRESHOLD,
'unhealthy' => 0.0,
];
public function run(): void
@@ -905,8 +912,8 @@ class moduleWeatherAPIRoute
}
$target = $this->normalizeDepartmentWeatherTarget([
'degraded_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY),
'healthy_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY),
'degraded_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_DEGRADED_KEY) ?? self::DEFAULT_DEPARTMENT_WEATHER_DEGRADED_THRESHOLD,
'healthy_threshold' => $department->variables->getVariable(self::DEPARTMENT_WEATHER_TARGET_HEALTHY_KEY) ?? self::DEFAULT_DEPARTMENT_WEATHER_HEALTHY_THRESHOLD,
]);
if ($target !== null) {
$targets_by_department[$department_id] = $target;
@@ -0,0 +1,283 @@
<?php
app_require('classes/bird.php');
app_require('modules/bird/bird_c.php');
app_require('modules/bird/config/bird_enabled_c.php');
app_require('modules/bird/config/bird_api_key_c.php');
app_require('modules/bird/config/bird_server_url_c.php');
use bird\bird_c;
use bird\config\bird_api_key_c;
use bird\config\bird_enabled_c;
use bird\config\bird_server_url_c;
use classes\bird;
if (!class_exists('BirdApiTransportCapture')) {
class BirdApiTransportCapture extends bird
{
/** @var array<int,array{method:string,endpoint:string,payload:array}> */
public array $calls = [];
public function __construct()
{
}
public function sendPostRequest(string $endpoint, array $data): array|object|null
{
$this->calls[] = ['method' => 'POST', 'endpoint' => $endpoint, 'payload' => $data];
return ['method' => 'POST', 'endpoint' => $endpoint, 'payload' => $data];
}
public function sendGetRequest(string $endpoint, array $query = []): array|object|null
{
$this->calls[] = ['method' => 'GET', 'endpoint' => $endpoint, 'payload' => $query];
return ['method' => 'GET', 'endpoint' => $endpoint, 'payload' => $query];
}
public function sendPatchRequest(string $endpoint, array $data = []): array|object|null
{
$this->calls[] = ['method' => 'PATCH', 'endpoint' => $endpoint, 'payload' => $data];
return ['method' => 'PATCH', 'endpoint' => $endpoint, 'payload' => $data];
}
public function sendDeleteRequest(string $endpoint, array $query = []): array|object|null
{
$this->calls[] = ['method' => 'DELETE', 'endpoint' => $endpoint, 'payload' => $query];
return ['method' => 'DELETE', 'endpoint' => $endpoint, 'payload' => $query];
}
}
}
if (!class_exists('BirdEnabledConfigStub')) {
class BirdEnabledConfigStub extends bird_enabled_c
{
public function __construct(private bool $enabled = true)
{
}
public function isTrue(): bool
{
return $this->enabled;
}
public function getVariableValue(): mixed
{
return $this->enabled ? 'true' : 'false';
}
}
}
if (!class_exists('BirdApiKeyConfigStub')) {
class BirdApiKeyConfigStub extends bird_api_key_c
{
public function __construct(private string $apiKey = 'test-key')
{
}
public function getVariableValue(): mixed
{
return $this->apiKey;
}
}
}
if (!class_exists('BirdServerUrlConfigStub')) {
class BirdServerUrlConfigStub extends bird_server_url_c
{
public function __construct(private string $baseUrl = 'https://api.bird.test')
{
}
public function getVariableValue(): mixed
{
return $this->baseUrl;
}
}
}
if (!class_exists('BirdModuleConfigStub')) {
class BirdModuleConfigStub extends bird_c
{
public function __construct(string $apiKey = 'test-key', string $baseUrl = 'https://api.bird.test')
{
$this->enabled = new BirdEnabledConfigStub(true);
$this->api_key = new BirdApiKeyConfigStub($apiKey);
$this->server_url = new BirdServerUrlConfigStub($baseUrl);
}
}
}
if (!class_exists('BirdHttpHarness')) {
class BirdHttpHarness extends bird
{
/** @var array<int,array{method:string,url:string,headers:array,body:string}> */
public array $httpCalls = [];
private int $stubStatus;
private string|false|null $stubBody;
public function __construct(int $status = 200, string|false|null $body = '{"ok":true}', string $apiKey = 'test-key', string $baseUrl = 'https://api.bird.test')
{
$this->stubStatus = $status;
$this->stubBody = $body;
$this->config = new BirdModuleConfigStub($apiKey, $baseUrl);
}
protected function doHttpRequest(string $method, string $url, array $headers, string $body = ''): array
{
$this->httpCalls[] = [
'method' => strtoupper($method),
'url' => $url,
'headers' => $headers,
'body' => $body,
];
return [
'status_code' => $this->stubStatus,
'body' => $this->stubBody,
];
}
}
}
it('maps voice call facade methods to documented endpoints and methods', function (): void {
$client = new BirdApiTransportCapture();
$client->updateVoiceCall('ws', 'ch', 'call', ['status' => 'completed']);
$client->answerVoiceCall('ws', 'ch', 'call');
$client->ringVoiceCall('ws', 'ch', 'call');
$client->hangupVoiceCall('ws', 'ch', 'call', ['cause' => 'busy']);
$client->playbackVoiceCall('ws', 'ch', 'call', ['media' => ['https://example.com/a.mp3']]);
$client->sayMessage('ws', 'ch', 'call', ['text' => 'hello']);
$client->gatherMessage('ws', 'ch', 'call', ['maxNumKeys' => 1]);
$client->bridgeVoiceCall('ws', 'ch', 'call', ['to' => '+4511122233']);
$client->recordVoiceCall('ws', 'ch', 'call', ['maxLength' => 60]);
expect($client->calls[0])->toMatchArray([
'method' => 'PATCH',
'endpoint' => '/workspaces/ws/channels/ch/calls/call',
]);
expect($client->calls[1])->toMatchArray([
'method' => 'POST',
'endpoint' => '/workspaces/ws/channels/ch/calls/call/answer',
]);
expect($client->calls[2])->toMatchArray([
'method' => 'POST',
'endpoint' => '/workspaces/ws/channels/ch/calls/call/ringing',
]);
expect($client->calls[3])->toMatchArray([
'method' => 'POST',
'endpoint' => '/workspaces/ws/channels/ch/calls/call/hangup',
]);
expect($client->calls[4])->toMatchArray([
'method' => 'POST',
'endpoint' => '/workspaces/ws/channels/ch/calls/call/playback',
]);
expect($client->calls[5])->toMatchArray([
'method' => 'POST',
'endpoint' => '/workspaces/ws/channels/ch/calls/call/say',
]);
expect($client->calls[6])->toMatchArray([
'method' => 'POST',
'endpoint' => '/workspaces/ws/channels/ch/calls/call/gather',
]);
expect($client->calls[7])->toMatchArray([
'method' => 'POST',
'endpoint' => '/workspaces/ws/channels/ch/calls/call/bridge',
]);
expect($client->calls[8])->toMatchArray([
'method' => 'POST',
'endpoint' => '/workspaces/ws/channels/ch/calls/call/record',
]);
});
it('maps recordings insights log and flash methods to documented resources', function (): void {
$client = new BirdApiTransportCapture();
$client->createVoiceCallRecordingSession('ws', 'ch', 'call', ['maxLength' => 30]);
$client->listVoiceCallRecordings('ws', 'ch', 'call', ['limit' => 10]);
$client->getVoiceCallRecording('ws', 'ch', 'call', 'rec');
$client->updateVoiceCallRecording('ws', 'ch', 'call', 'rec', ['status' => 'completed']);
$client->getVoiceCallInsights('ws', 'ch', 'call');
$client->getVoiceCallsLog('ws', ['limit' => 25]);
$client->listFlashCalls('ws', 'ch', ['limit' => 5]);
$client->endFlashCall('ws', 'ch', 'flash-call', ['result' => 'verified']);
$client->hangupFlashCall('ws', 'ch', ['from' => '+4511122233', 'to' => '+4544455566']);
expect($client->calls[0])->toMatchArray([
'method' => 'POST',
'endpoint' => '/workspaces/ws/channels/ch/calls/call/recordings',
]);
expect($client->calls[1])->toMatchArray([
'method' => 'GET',
'endpoint' => '/workspaces/ws/channels/ch/calls/call/recordings',
]);
expect($client->calls[2])->toMatchArray([
'method' => 'GET',
'endpoint' => '/workspaces/ws/channels/ch/calls/call/recordings/rec',
]);
expect($client->calls[3])->toMatchArray([
'method' => 'PATCH',
'endpoint' => '/workspaces/ws/channels/ch/calls/call/recordings/rec',
]);
expect($client->calls[4])->toMatchArray([
'method' => 'GET',
'endpoint' => '/workspaces/ws/channels/ch/calls/call/insights',
]);
expect($client->calls[5])->toMatchArray([
'method' => 'GET',
'endpoint' => '/workspaces/ws/channels/calls',
]);
expect($client->calls[6])->toMatchArray([
'method' => 'GET',
'endpoint' => '/workspaces/ws/channels/ch/flashcalls',
]);
expect($client->calls[7])->toMatchArray([
'method' => 'POST',
'endpoint' => '/workspaces/ws/channels/ch/flashcalls/flash-call',
]);
expect($client->calls[8])->toMatchArray([
'method' => 'POST',
'endpoint' => '/workspaces/ws/channels/ch/flashcalls/hangup',
]);
});
it('encodes path segments before building outbound endpoints', function (): void {
$client = new BirdApiTransportCapture();
$client->getVoiceCall('workspace a', 'channel/a', 'call 1');
$client->getVoiceCallRecording('workspace a', 'channel/a', 'call 1', 'recording/1');
$client->getFlashCall('workspace a', 'channel/a', 'flash 1');
expect($client->calls[0]['endpoint'])->toBe('/workspaces/workspace%20a/channels/channel%2Fa/calls/call%201');
expect($client->calls[1]['endpoint'])->toBe('/workspaces/workspace%20a/channels/channel%2Fa/calls/call%201/recordings/recording%2F1');
expect($client->calls[2]['endpoint'])->toBe('/workspaces/workspace%20a/channels/channel%2Fa/flashcalls/flash%201');
});
it('serializes query parameters for GET transport requests', function (): void {
$client = new BirdHttpHarness();
$response = $client->sendGetRequest('/workspaces/ws/channels/calls', [
'limit' => 10,
'tag' => ['vip', 'north'],
'id' => '123',
]);
expect($response)->toBeArray();
expect($client->httpCalls)->toHaveCount(1);
expect($client->httpCalls[0]['method'])->toBe('GET');
expect($client->httpCalls[0]['url'])->toContain('/workspaces/ws/channels/calls?');
expect($client->httpCalls[0]['url'])->toContain('limit=10');
expect($client->httpCalls[0]['url'])->toContain('tag%5B0%5D=vip');
expect($client->httpCalls[0]['url'])->toContain('tag%5B1%5D=north');
expect($client->httpCalls[0]['url'])->toContain('id=123');
});
it('maps 4xx and 5xx transport failures into informative exceptions', function (): void {
$client4xx = new BirdHttpHarness(422, '{"message":"invalid payload","errors":[{"message":"to is required"}]}');
$client5xx = new BirdHttpHarness(503, '{"error":"upstream unavailable"}');
expect(fn() => $client4xx->sendPostRequest('/workspaces/ws/channels/ch/calls', ['from' => '+4511122233']))
->toThrow(Exception::class, 'Bird API request failed with status 422');
expect(fn() => $client5xx->sendGetRequest('/workspaces/ws/channels/ch/calls'))
->toThrow(Exception::class, 'Bird API request failed with status 503');
});
@@ -0,0 +1,96 @@
<?php
function bird_openapi_content_or_skip(): string
{
$candidates = [
dirname(__DIR__, 6) . DIRECTORY_SEPARATOR . 'openapi.yaml',
dirname(__DIR__, 5) . DIRECTORY_SEPARATOR . 'openapi.yaml',
dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'openapi.yaml',
dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'openapi.yaml',
dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'openapi.yaml',
];
foreach ($candidates as $candidate) {
if (!is_file($candidate)) {
continue;
}
$content = file_get_contents($candidate);
if ($content !== false) {
return $content;
}
}
test()->markTestSkipped('openapi.yaml is not available in this runtime environment.');
}
function bird_openapi_path_block_or_fail(string $content, string $path): string
{
$pathMarker = ' ' . $path . ':';
$start = strpos($content, $pathMarker);
if ($start === false) {
throw new RuntimeException('OpenAPI path block not found: ' . $path);
}
$rest = substr($content, $start + strlen($pathMarker));
$nextPath = strpos($rest, "\n /");
if ($nextPath === false) {
return substr($content, $start);
}
return substr($content, $start, strlen($pathMarker) + $nextPath);
}
it('documents all Bird voice call parity endpoints including gather recordings insights and log', function (): void {
$content = bird_openapi_content_or_skip();
expect($content)->toContain('/bird/voice/calls:');
expect($content)->toContain('/bird/voice/calls/log:');
expect($content)->toContain('/bird/voice/calls/{id}:');
expect($content)->toContain('/bird/voice/calls/{id}/answer:');
expect($content)->toContain('/bird/voice/calls/{id}/ringing:');
expect($content)->toContain('/bird/voice/calls/{id}/hangup:');
expect($content)->toContain('/bird/voice/calls/{id}/playback:');
expect($content)->toContain('/bird/voice/calls/{id}/say:');
expect($content)->toContain('/bird/voice/calls/{id}/gather:');
expect($content)->toContain('/bird/voice/calls/{id}/bridge:');
expect($content)->toContain('/bird/voice/calls/{id}/record:');
expect($content)->toContain('/bird/voice/calls/{id}/recordings:');
expect($content)->toContain('/bird/voice/calls/{id}/recordings/{recordingId}:');
expect($content)->toContain('/bird/voice/calls/{id}/insights:');
expect($content)->toContain('/bird/voice/calls/test-outbound:');
});
it('documents flash hangup endpoint and marks end alias as deprecated', function (): void {
$content = bird_openapi_content_or_skip();
$aliasPath = bird_openapi_path_block_or_fail($content, '/bird/voice/flash-calls/end');
expect($content)->toContain('/bird/voice/flash-calls:');
expect($content)->toContain('/bird/voice/flash-calls/{id}:');
expect($content)->toContain('/bird/voice/flash-calls/hangup:');
expect($content)->toContain('/bird/voice/flash-calls/end:');
expect($aliasPath)->toContain('deprecated: true');
expect($aliasPath)->toContain('birdEndFlashCallByNumbers');
});
it('defines request and response schemas for Bird call command recording insight log and flash payloads', function (): void {
$content = bird_openapi_content_or_skip();
expect($content)->toContain('BirdVoiceCallCommandResponse:');
expect($content)->toContain('BirdVoiceCallBridgeResponse:');
expect($content)->toContain('BirdVoiceCallRecording:');
expect($content)->toContain('BirdVoiceCallRecordingListResponse:');
expect($content)->toContain('BirdVoiceCallRecordingSingleResponse:');
expect($content)->toContain('BirdVoiceCallInsightsResponse:');
expect($content)->toContain('BirdVoiceCallsLogResponse:');
expect($content)->toContain('BirdFlashCallHangupRequest:');
expect($content)->toContain('BirdFlashCallHangupResponse:');
expect($content)->toContain('BirdVoiceCallCreateRequest:');
expect($content)->toContain('BirdVoiceCallUpdateRequest:');
expect($content)->toContain('BirdVoiceCallGatherRequest:');
expect($content)->toContain('BirdVoiceCallRecordingUpdateRequest:');
expect($content)->toContain('BirdFlashCallCreateRequest:');
expect($content)->toContain('BirdFlashCallEndRequest:');
expect($content)->toContain('BirdTestOutboundCallRequest:');
});
@@ -0,0 +1,136 @@
<?php
app_require('modules/bird/helpers/bird_request_validator.php');
app_require('modules/bird/helpers/bird_request_schemas.php');
app_require('traits/bird_route_validation_t.php');
use bird\helpers\bird_request_schemas;
use bird\helpers\bird_request_validator;
use traits\bird_route_validation_t;
if (!class_exists('BirdRouteValidationHarness')) {
class BirdRouteValidationHarness
{
use bird_route_validation_t {
birdValidateSchema as public validateSchema;
}
public int $forwardCalls = 0;
public function validateThenForward(array $payload, array $schema): void
{
$this->validateSchema($payload, $schema);
$this->forwardCalls++;
}
}
}
beforeEach(function (): void {
global $response;
$response = new class {
public function error(mixed $data, int $status = null): void
{
$encoded = is_string($data) ? $data : json_encode($data);
throw new RuntimeException('HTTP_' . (string)$status . ':' . $encoded);
}
};
});
it('accepts valid payloads for create and nested call flow commands', function (): void {
$payload = [
'from' => '+4532330288',
'to' => '+4542331128',
'ringTimeout' => 20,
'notification' => ['url' => 'https://example.com/webhook'],
'callFlow' => [
[
'command' => 'say',
'conditions' => [
['variable' => 'keys', 'operator' => 'eq', 'value' => '1'],
],
'options' => ['text' => 'hello'],
],
],
];
$errors = bird_request_validator::validate($payload, bird_request_schemas::voiceCreateCallBody());
expect($errors)->toBe([]);
});
it('rejects unknown fields and invalid enum values in strict schemas', function (): void {
$unknownErrors = bird_request_validator::validate(
['to' => '+4542331128', 'unknownField' => 'x'],
bird_request_schemas::voiceCreateCallBody()
);
$enumErrors = bird_request_validator::validate(
['cause' => 'completed'],
bird_request_schemas::voiceHangupBody()
);
expect($unknownErrors)->not->toBe([]);
expect(implode(' | ', $unknownErrors))->toContain('$.unknownField is not allowed');
expect($enumErrors)->not->toBe([]);
expect(implode(' | ', $enumErrors))->toContain('$.cause must be one of: rejected, busy');
});
it('supports CSV normalization in schema validation for log filters', function (): void {
$validQuery = [
'channelId' => '2cbde4fd-8899-4f2d-95ea-2ab7cc6b8c97,5a6bd8c1-32b7-4a03-8b5a-b6a4a9d7a8b2',
'tag' => 'north,vip',
];
$invalidQuery = [
'channelId' => 'not-a-uuid',
];
$validErrors = bird_request_validator::validate($validQuery, bird_request_schemas::voiceCallsLogQuery());
$invalidErrors = bird_request_validator::validate($invalidQuery, bird_request_schemas::voiceCallsLogQuery());
expect($validErrors)->toBe([]);
expect($invalidErrors)->not->toBe([]);
expect(implode(' | ', $invalidErrors))->toContain('$.channelId[0] must be a UUID');
});
it('validates flash hangup payloads for both supported request shapes', function (): void {
$resultMode = [
'result' => 'verified',
'receivedCli' => '+4542331128',
];
$numbersMode = [
'from' => '+4532330288',
'to' => '+4542331128',
'result' => 'verified',
];
$invalid = [
'result' => 'ok',
];
$resultErrors = bird_request_validator::validate($resultMode, bird_request_schemas::flashHangupBody());
$numbersErrors = bird_request_validator::validate($numbersMode, bird_request_schemas::flashHangupBody());
$invalidErrors = bird_request_validator::validate($invalid, bird_request_schemas::flashHangupBody());
expect($resultErrors)->toBe([]);
expect($numbersErrors)->toBe([]);
expect($invalidErrors)->not->toBe([]);
expect(implode(' | ', $invalidErrors))->toContain('does not match any of the allowed schemas');
});
it('fails before forward step when strict validation fails and forwards when valid', function (): void {
$harness = new BirdRouteValidationHarness();
try {
$harness->validateThenForward(['unexpected' => true], bird_request_schemas::noBody());
$caught = null;
} catch (RuntimeException $e) {
$caught = $e;
}
expect($caught)->toBeInstanceOf(RuntimeException::class);
expect($caught?->getMessage())->toContain('HTTP_400');
expect($harness->forwardCalls)->toBe(0);
$harness->validateThenForward([], bird_request_schemas::noBody());
expect($harness->forwardCalls)->toBe(1);
});
@@ -0,0 +1,79 @@
<?php
it('registers full Bird voice call route surface with strict validation and permissions', function (): void {
$content = file_get_contents(app_path('routes/birdVoiceCallsRoute.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('/bird/voice/calls');
expect($content)->toContain('/bird/voice/calls/log');
expect($content)->toContain('/bird/voice/calls/{id}');
expect($content)->toContain('/bird/voice/calls/{id}/answer');
expect($content)->toContain('/bird/voice/calls/{id}/ringing');
expect($content)->toContain('/bird/voice/calls/{id}/hangup');
expect($content)->toContain('/bird/voice/calls/{id}/playback');
expect($content)->toContain('/bird/voice/calls/{id}/say');
expect($content)->toContain('/bird/voice/calls/{id}/gather');
expect($content)->toContain('/bird/voice/calls/{id}/bridge');
expect($content)->toContain('/bird/voice/calls/{id}/record');
expect($content)->toContain('/bird/voice/calls/{id}/recordings');
expect($content)->toContain('/bird/voice/calls/{id}/recordings/{recordingId}');
expect($content)->toContain('/bird/voice/calls/{id}/insights');
expect($content)->toContain('/bird/voice/calls/test-outbound');
expect($content)->toContain('modules_bird_voice_calls_create');
expect($content)->toContain('modules_bird_voice_calls_list');
expect($content)->toContain('modules_bird_voice_calls_get');
expect($content)->toContain('modules_bird_voice_calls_update');
expect($content)->toContain('modules_bird_voice_calls_answer');
expect($content)->toContain('modules_bird_voice_calls_ringing');
expect($content)->toContain('modules_bird_voice_calls_hangup');
expect($content)->toContain('modules_bird_voice_calls_playback');
expect($content)->toContain('modules_bird_voice_calls_say');
expect($content)->toContain('modules_bird_voice_calls_gather');
expect($content)->toContain('modules_bird_voice_calls_bridge');
expect($content)->toContain('modules_bird_voice_calls_record');
expect($content)->toContain('modules_bird_voice_calls_recordings_create');
expect($content)->toContain('modules_bird_voice_calls_recordings_list');
expect($content)->toContain('modules_bird_voice_calls_recordings_get');
expect($content)->toContain('modules_bird_voice_calls_recordings_update');
expect($content)->toContain('modules_bird_voice_calls_insights_get');
expect($content)->toContain('modules_bird_voice_calls_log_list');
expect($content)->toContain('modules_bird_voice_calls_test_outbound');
expect($content)->toContain('birdValidateSchema');
expect($content)->toContain('birdResolveWorkspaceAndChannelIds');
expect($content)->toContain('birdResolveRouteCallId');
expect($content)->toContain('birdResolveRouteRecordingId');
});
it('registers flash hangup endpoint and keeps end alias wired as compatibility path', function (): void {
$content = file_get_contents(app_path('routes/birdVoiceFlashCallsRoute.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('/bird/voice/flash-calls');
expect($content)->toContain('/bird/voice/flash-calls/{id}');
expect($content)->toContain('/bird/voice/flash-calls/hangup');
expect($content)->toContain('/bird/voice/flash-calls/end');
expect($content)->toContain('modules_bird_voice_flash_calls_create');
expect($content)->toContain('modules_bird_voice_flash_calls_list');
expect($content)->toContain('modules_bird_voice_flash_calls_get');
expect($content)->toContain('modules_bird_voice_flash_calls_end');
expect($content)->toContain('modules_bird_voice_flash_calls_hangup');
expect($content)->toContain('modules_bird_voice_flash_calls_end_by_numbers');
expect($content)->toContain('flashHangupBody');
expect($content)->toContain('hangupFlashCall($workspaceId, $channelId, $payload)');
});
it('keeps Bird number and webhook routes intact', function (): void {
$numbers = file_get_contents(app_path('routes/birdNumbersRoute.php'));
$webhooks = file_get_contents(app_path('routes/birdVoiceWebhooksRoute.php'));
expect($numbers)->not->toBeFalse();
expect($numbers)->toContain('/bird/numbers');
expect($numbers)->toContain('/bird/numbers/{id}');
expect($webhooks)->not->toBeFalse();
expect($webhooks)->toContain('/bird/voice/calls/webhook/inbound');
});
@@ -2,11 +2,18 @@
function economic_v2_openapi_content_or_skip(): string
{
$candidates = [
dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'openapi.yaml', // monorepo root in local workspace
dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'openapi.yaml',
dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'openapi.yaml',
];
$candidates = [];
for ($depth = 1; $depth <= 8; $depth++) {
$candidates[] = dirname(__DIR__, $depth) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
$cwd = getcwd();
if (is_string($cwd) && $cwd !== '') {
$candidates[] = $cwd . DIRECTORY_SEPARATOR . 'openapi.yaml';
$candidates[] = dirname($cwd) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
$candidates = array_values(array_unique($candidates));
foreach ($candidates as $candidate) {
if (is_file($candidate)) {
@@ -1,18 +1,24 @@
<?php
namespace classes {
class db {}
if (!class_exists('classes\\db')) {
class db {}
}
class object_property
{
public function __construct(...$args) {}
public function value() { return null; }
public function set($v) {}
if (!class_exists('classes\\object_property')) {
class object_property
{
public function __construct(...$args) {}
public function value() { return null; }
public function set($v) {}
}
}
}
namespace traits {
trait db_object_t {}
if (!trait_exists('traits\\db_object_t')) {
trait db_object_t {}
}
}
namespace {
@@ -55,4 +61,3 @@ namespace {
expect($names)->toContain('MACHINE');
});
}
@@ -58,10 +58,10 @@ it('documents self-serve machine type, eligibility, summary, and webhook endpoin
expect($content)->toContain('/modules/self-serve/lane/relay/machine_program_picker/set:');
expect($content)->toContain('/modules/self-serve/lane/relay/machine_cleaner/status:');
expect($content)->toContain('/modules/self-serve/lane/relay/machine_cleaner/set:');
expect($allowedPathBlock)->toContain('vehicle_type_id:');
expect($allowedPathBlock)->toContain('vehicle_type:');
expect($summaryPathBlock)->toContain('vehicle_type_id:');
expect($summaryPathBlock)->toContain('vehicle_type:');
expect($allowedPathBlock)->toContain('name: vehicle_type_id');
expect($allowedPathBlock)->toContain('name: vehicle_type');
expect($summaryPathBlock)->toContain('name: vehicle_type_id');
expect($summaryPathBlock)->toContain('name: vehicle_type');
});
it('defines reusable self-serve wash and machine type schemas', function (): void {
@@ -74,6 +74,7 @@ it('defines reusable self-serve wash and machine type schemas', function (): voi
expect($content)->toContain('DepartmentSelfserveVehicleConditionMutationResponse:');
expect($content)->toContain('machine_type_id:');
expect($content)->toContain('SelfServeLaneMachineRelayStatus:');
expect($content)->toContain(' wash_started_at:');
});
it('documents in-progress self-serve wash start and machine relay fields', function (): void {
@@ -8,3 +8,12 @@ it('adds dynamic_images_vehicle_type column for legacy selfserve wash session ta
expect($bootstrapContent)->toContain("'dynamic_images_vehicle_type'");
expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_session_tasks ADD COLUMN dynamic_images_vehicle_type INT NULL AFTER buttons');
});
it('adds wash_started_at column for legacy selfserve wash session schemas', function (): void {
$bootstrapContent = file_get_contents(app_path('classes/selfserve_schema_bootstrap.php'));
expect($bootstrapContent)->not->toBeFalse();
expect($bootstrapContent)->toContain("'selfserve_wash_sessions'");
expect($bootstrapContent)->toContain("'wash_started_at'");
expect($bootstrapContent)->toContain('ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at');
});
@@ -0,0 +1,30 @@
<?php
it('stores wash_started_at in self-serve wash sessions when machine start is triggered', function (): void {
$sessionsObject = file_get_contents(app_path('objects/selfserve_wash_sessions_o.php'));
expect($sessionsObject)->not->toBeFalse();
expect($sessionsObject)->toContain('public object_property $wash_started_at;');
expect($sessionsObject)->toContain("\$this->wash_started_at = new object_property(");
expect($sessionsObject)->toContain("\$this->wash_started_at->set(\$resolvedWashStartedAt);");
expect($sessionsObject)->toContain("'wash_started_at' =>");
});
it('passes lane wash start time into the session machine-start marker', function (): void {
$washFlow = file_get_contents(app_path('modules/selfserve/classes/selfserve_wash_flow.php'));
expect($washFlow)->not->toBeFalse();
expect($washFlow)->toContain('$washStartedAt = (int)$lane->getWashStartTime();');
expect($washFlow)->toContain('$session->markMachineStartTriggered(');
expect($washFlow)->toContain("date('Y-m-d H:i:s', \$washStartedAt)");
});
it('resolves in-progress wash_started_at from session with compatibility fallbacks', function (): void {
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
expect($moduleSelfServeRoute)->not->toBeFalse();
expect($moduleSelfServeRoute)->toContain('$wash_started_at = $session->wash_started_at->value() === null ? null : (string)$session->wash_started_at->value();');
expect($moduleSelfServeRoute)->toContain('$wash_started_at = $machine_start_triggered_at;');
expect($moduleSelfServeRoute)->toContain('$wash_started_at = $format_wash_started_at($selfserve->lane($lane_id)->getWashStartTime());');
expect($moduleSelfServeRoute)->toContain('\'wash_started_at\' => $wash_started_at');
});
@@ -2,11 +2,16 @@
function department_weather_targets_openapi_content_or_skip(): string
{
$candidates = [
WD . '/openapi.yaml',
dirname(WD) . '/openapi.yaml',
dirname(WD, 2) . '/openapi.yaml',
];
$candidates = [WD . '/openapi.yaml'];
for ($depth = 1; $depth <= 8; $depth++) {
$candidates[] = dirname(WD, $depth) . '/openapi.yaml';
}
$cwd = getcwd();
if (is_string($cwd) && $cwd !== '') {
$candidates[] = $cwd . DIRECTORY_SEPARATOR . 'openapi.yaml';
$candidates[] = dirname($cwd) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
$candidates = array_values(array_unique($candidates));
foreach ($candidates as $candidate) {
if (!is_file($candidate)) {
@@ -9,11 +9,16 @@ it('includes a date key in department weather timeline entries', function (): vo
});
it('documents the date key in the openapi department weather timeline schema', function (): void {
$candidates = [
WD . '/openapi.yaml',
dirname(WD) . '/openapi.yaml',
dirname(WD, 2) . '/openapi.yaml',
];
$candidates = [WD . '/openapi.yaml'];
for ($depth = 1; $depth <= 8; $depth++) {
$candidates[] = dirname(WD, $depth) . '/openapi.yaml';
}
$cwd = getcwd();
if (is_string($cwd) && $cwd !== '') {
$candidates[] = $cwd . DIRECTORY_SEPARATOR . 'openapi.yaml';
$candidates[] = dirname($cwd) . DIRECTORY_SEPARATOR . 'openapi.yaml';
}
$candidates = array_values(array_unique($candidates));
$openApiFile = null;
foreach ($candidates as $candidate) {
@@ -0,0 +1,143 @@
<?php
namespace traits;
require_once WD . '/classes/bird.php';
require_once WD . '/modules/bird/helpers/bird_request_validator.php';
use bird\helpers\bird_request_validator;
use classes\bird;
trait bird_route_validation_t
{
private function birdFailValidation(string|array $errors): void
{
global $response;
$normalizedErrors = [];
if (is_array($errors)) {
foreach ($errors as $error) {
if (!is_string($error)) {
continue;
}
$trimmed = trim($error);
if ($trimmed !== '') {
$normalizedErrors[] = $trimmed;
}
}
} else {
$trimmed = trim($errors);
if ($trimmed !== '') {
$normalizedErrors[] = $trimmed;
}
}
if ($normalizedErrors === []) {
$normalizedErrors[] = 'Validation failed';
}
$response->error([
'message' => 'Validation failed',
'errors' => array_values(array_unique($normalizedErrors)),
], 400);
}
private function birdAssertUuid(string $value, string $field): string
{
$normalized = trim($value);
if ($normalized === '') {
$this->birdFailValidation($field . ' is required');
}
if (!bird_request_validator::isUuid($normalized)) {
$this->birdFailValidation($field . ' must be a UUID');
}
return $normalized;
}
private function birdResolveWorkspaceId(bird $client): string
{
$workspaceId = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
if ($workspaceId === '') {
$workspaceId = $this->getConfiguredWorkspaceId($client);
}
if ($workspaceId === '') {
$this->birdFailValidation('Missing required parameter: workspaceId');
}
return $this->birdAssertUuid($workspaceId, 'workspaceId');
}
private function birdResolveWorkspaceAndChannelIds(bird $client): array
{
$workspaceId = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$channelId = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($workspaceId === '') {
$workspaceId = $this->getConfiguredWorkspaceId($client);
}
if ($channelId === '') {
$channelId = $this->getConfiguredChannelId($client);
}
if ($workspaceId === '' || $channelId === '') {
$this->birdFailValidation('Missing required parameters: workspaceId, channelId');
}
return [
$this->birdAssertUuid($workspaceId, 'workspaceId'),
$this->birdAssertUuid($channelId, 'channelId'),
];
}
private function birdResolveRouteCallId(string $key = 'id'): string
{
$callId = $this->normalizeOptionalString((string)$this->fromRoute($key));
if ($callId === '') {
$this->birdFailValidation('Missing required parameter: callId');
}
return $this->birdAssertUuid($callId, 'callId');
}
private function birdResolveRouteRecordingId(string $key = 'recordingId'): string
{
$recordingId = $this->normalizeOptionalString((string)$this->fromRoute($key));
if ($recordingId === '') {
$this->birdFailValidation('Missing required parameter: recordingId');
}
return $this->birdAssertUuid($recordingId, 'recordingId');
}
private function birdPayloadWithout(array $keys): array
{
$payload = $this->getParametersAsArray();
foreach ($keys as $key) {
unset($payload[$key]);
}
return $payload;
}
private function birdNormalizeCsvField(array $payload, string $key): array
{
if (!array_key_exists($key, $payload)) {
return $payload;
}
$value = $payload[$key];
if (is_string($value)) {
$parts = array_map('trim', explode(',', $value));
$payload[$key] = array_values(array_filter($parts, static fn($part) => $part !== ''));
return $payload;
}
if (is_array($value)) {
$payload[$key] = array_values(array_filter(array_map(static fn($part) => is_scalar($part) ? trim((string)$part) : '', $value), static fn($part) => $part !== ''));
return $payload;
}
return $payload;
}
private function birdValidateSchema(array $payload, array $schema): array
{
$errors = bird_request_validator::validate($payload, $schema);
if ($errors !== []) {
$this->birdFailValidation($errors);
}
return $payload;
}
}