Files
api/services/nginx/app/routes/birdVoiceWebhooksRoute.php
T

1721 lines
57 KiB
PHP

<?php
namespace routes;
use classes\bird;
use classes\redis;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
use objects\department_gates_o;
use objects\departments_o;
use traits\bird_route_helpers_t;
use traits\route_t;
class birdVoiceWebhooksRoute
{
use route_t, bird_route_helpers_t;
private const IVR_STATE_TTL_SECONDS = 1200;
private const IVR_STATE_PREFIX = 'bird_ivr_state:';
private const IVR_LOCK_PREFIX = 'bird_ivr_lock:';
private const IVR_LOCK_TTL_SECONDS = 180;
private const IVR_STAGE_DEPARTMENT_SELECT = 'department_select';
private const IVR_STAGE_GATE_TYPE_SELECT = 'gate_type_select';
private const IVR_GATE_TYPE_ENTRANCE = 'entrance';
private const IVR_GATE_TYPE_EXIT = 'exit';
private const DEFAULT_WAIT_TIMEOUT = 'PT10M';
private const DEFAULT_GATHER_RETRIES = 3;
private const DEFAULT_GATHER_TIMEOUT_SECONDS = 30;
private const DEFAULT_GATHER_END_KEY = '#';
private const DEFAULT_GATHER_SAY_LOCALE = 'en-US';
private const DEFAULT_GATHER_SAY_VOICE = 'female';
private const RESPONSE_MODE_COMMAND = 'command';
private const RESPONSE_MODE_FLOW = 'flow';
public function run(): void
{
$this->post('/bird/voice/calls/webhook/inbound', function (): void {
global $response;
$client = $this->resolveBirdClient();
$payload = $this->readInboundWebhookPayload();
try {
if (!$this->isSupportedInboundRequest($payload)) {
throw new InvalidArgumentException('Malformed inbound Bird webhook payload');
}
$result = $this->handleInboundCallLifecycle(
$client,
$payload,
$this->resolveInboundCallId($payload),
$this->resolveInboundWorkspaceId($client, $payload),
$this->resolveInboundChannelId($client, $payload),
);
$response->rawJson($result, (int)($result['statusCode'] ?? 200));
} catch (InvalidArgumentException $e) {
$body = $this->buildTransportErrorResponse($payload, 400, $e->getMessage());
$response->rawJson($body, 400);
} catch (\Throwable $e) {
$body = $this->buildTransportErrorResponse($payload, 500, $e->getMessage());
$response->rawJson($body, 500);
}
}, [
'modules_bird_voice_call_webhooks_trigger' => 'Trigger a voice call via Bird webhooks',
]);
}
public function handleInboundCallLifecycle(
bird $client,
array $payload,
string $callId,
string $workspaceId = '',
string $channelId = ''
): array {
$callId = $this->normalizeOptionalString($callId);
if ($callId === '') {
throw new InvalidArgumentException('Missing required parameter: callId');
}
$requestId = $this->extractRequestIdFromPayload($payload);
if ($requestId === '') {
$requestId = $this->generateUuidV4();
}
$resolvedWorkspaceId = $this->normalizeOptionalString($workspaceId);
if ($resolvedWorkspaceId === '') {
$resolvedWorkspaceId = $this->getConfiguredWorkspaceId($client);
}
$resolvedChannelId = $this->normalizeOptionalString($channelId);
if ($resolvedChannelId === '') {
$resolvedChannelId = $this->getConfiguredChannelId($client);
}
if ($resolvedWorkspaceId === '' || $resolvedChannelId === '') {
throw new InvalidArgumentException('Missing required parameters: workspaceId, channelId');
}
$state = $this->normalizeState($this->readIvrState($callId) ?? []);
$responseMode = $this->responseModeForPayload($payload, $state);
if (!$this->acquireIvrLock($callId)) {
return $this->buildLifecycleCompletionResponse(
$responseMode,
$callId,
[
'request_id' => $requestId,
'workspace_id' => $resolvedWorkspaceId,
'channel_id' => $resolvedChannelId,
],
'ignored',
'ignored',
'Call is already being processed.',
false,
null,
null,
null,
'lock_not_acquired',
);
}
try {
if ($this->shouldBootstrapIvrState($payload, $state)) {
$state = $this->buildInitialState(
$callId,
$resolvedWorkspaceId,
$resolvedChannelId,
$payload,
$requestId,
$responseMode,
);
$this->acceptInboundCall($client, $resolvedWorkspaceId, $resolvedChannelId, $callId);
$result = $this->bootstrapIvrState($state);
$state = $result['state'];
if (($result['action'] ?? '') === 'complete') {
$this->clearIvrState($callId);
return $this->buildLifecycleCompletionResponse(
$responseMode,
$callId,
$state,
'completed',
'no_action',
(string)($result['message'] ?? 'No phone-controlled gates are configured.'),
false,
null,
null,
null,
(string)($result['reason'] ?? 'no_eligible_departments'),
);
}
if (($result['action'] ?? '') === 'open_gate') {
return $this->openSelectedGateAndComplete($callId, $state, $responseMode);
}
$this->saveIvrState($callId, $state);
return $this->buildLifecycleGatherResponse(
$responseMode,
$callId,
$state,
$this->buildPromptTextForState($state),
false,
);
}
if ($state === [] || $this->normalizeOptionalString($state['stage'] ?? '') === '') {
return $this->buildLifecycleCompletionResponse(
$responseMode,
$callId,
[
'request_id' => $requestId,
'workspace_id' => $resolvedWorkspaceId,
'channel_id' => $resolvedChannelId,
],
'ignored',
'ignored',
'No active IVR state was found for this call.',
false,
null,
null,
null,
'state_not_found',
);
}
$state['request_id'] = $this->normalizeOptionalString($state['request_id'] ?? '') !== ''
? $state['request_id']
: $requestId;
$state['workspace_id'] = $resolvedWorkspaceId;
$state['channel_id'] = $resolvedChannelId;
$rawInput = $this->extractGatherKeysFromEvent($payload);
if ($rawInput === null) {
$rawInput = $this->extractDtmfInput($payload);
}
$selection = $this->normalizeMenuDigitInput($rawInput);
if ($selection === null) {
$isInvalid = $rawInput !== null && trim($rawInput) !== '';
if ($isInvalid) {
$state['last_invalid_input'] = $rawInput;
$state['invalid_selection_count'] = max(0, (int)($state['invalid_selection_count'] ?? 0)) + 1;
}
$this->saveIvrState($callId, $state);
return $this->buildLifecycleGatherResponse(
$responseMode,
$callId,
$state,
$this->buildPromptTextForState($state, $isInvalid),
true,
);
}
$result = $this->applyMenuSelection($state, $selection);
$state = $result['state'];
if (($result['action'] ?? '') === 'open_gate') {
return $this->openSelectedGateAndComplete($callId, $state, $responseMode);
}
if (($result['action'] ?? '') === 'complete') {
$this->clearIvrState($callId);
return $this->buildLifecycleCompletionResponse(
$responseMode,
$callId,
$state,
'completed',
'no_action',
(string)($result['message'] ?? 'No phone-controlled gates are configured.'),
false,
$this->selectedDepartmentId($state),
$this->selectedGateType($state),
$this->selectedGateId($state),
(string)($result['reason'] ?? 'no_eligible_gates_for_department'),
);
}
if (($result['action'] ?? '') === 'invalid_selection') {
$state['last_invalid_input'] = $selection;
$state['invalid_selection_count'] = max(0, (int)($state['invalid_selection_count'] ?? 0)) + 1;
} else {
$state['last_invalid_input'] = '';
$state['invalid_selection_count'] = 0;
}
$this->saveIvrState($callId, $state);
return $this->buildLifecycleGatherResponse(
$responseMode,
$callId,
$state,
$this->buildPromptTextForState($state, ($result['action'] ?? '') === 'invalid_selection'),
true,
);
} finally {
$this->releaseIvrLock($callId);
}
}
protected function buildInitialState(
string $callId,
string $workspaceId,
string $channelId,
array $payload,
string $requestId,
string $responseMode
): array {
return [
'call_id' => $callId,
'workspace_id' => $workspaceId,
'channel_id' => $channelId,
'request_id' => $requestId,
'response_mode' => $responseMode,
'gather_template' => $this->normalizeGatherTemplate($this->extractInitialGatherTemplate($payload)),
'resume_action' => $this->extractResumeActionFromPayload($payload) ?: 'continue',
'wait_timeout' => $this->extractWaitTimeoutFromPayload($payload) ?: self::DEFAULT_WAIT_TIMEOUT,
'invalid_selection_count' => 0,
'last_invalid_input' => '',
];
}
protected function bootstrapIvrState(array $state): array
{
$eligibleDepartments = $this->loadEligibleDepartmentSummaries();
if ($eligibleDepartments === []) {
return [
'action' => 'complete',
'reason' => 'no_eligible_departments',
'message' => 'No phone-controlled gates are configured.',
'state' => $state,
];
}
$departmentOptions = $this->buildDepartmentOptionMap($eligibleDepartments);
if ($departmentOptions === []) {
return [
'action' => 'complete',
'reason' => 'no_eligible_departments',
'message' => 'No phone-controlled gates are configured.',
'state' => $state,
];
}
$state['stage'] = self::IVR_STAGE_DEPARTMENT_SELECT;
$state['department_options'] = $departmentOptions;
$state['selected_department_id'] = null;
$state['selected_department_name'] = '';
$state['selected_gate_type'] = '';
$state['available_gate_types'] = [];
$state['gate_options'] = [];
$state['gate_id'] = null;
return ['action' => 'gather', 'state' => $state];
}
protected function applyMenuSelection(array $state, string $digit): array
{
$stage = $this->normalizeOptionalString($state['stage'] ?? '');
if ($stage === self::IVR_STAGE_DEPARTMENT_SELECT) {
$departmentOption = $this->resolveSelectedDepartmentOption((array)($state['department_options'] ?? []), $digit);
if ($departmentOption === null) {
return ['action' => 'invalid_selection', 'state' => $state];
}
return $this->advanceStateForDepartment($state, $departmentOption);
}
if ($stage === self::IVR_STAGE_GATE_TYPE_SELECT) {
$gateOptions = $this->normalizeGateOptions((array)($state['gate_options'] ?? []));
$gateType = $this->resolveGateTypeByDigit($digit, $gateOptions);
if ($gateType === null) {
return ['action' => 'invalid_selection', 'state' => $state];
}
$departmentId = $this->selectedDepartmentId($state);
if ($departmentId === null) {
return [
'action' => 'complete',
'reason' => 'missing_department',
'message' => 'The selected department is no longer available.',
'state' => $state,
];
}
$gate = $this->resolvePhoneCallGate($departmentId, $gateType);
if ($gate === null || !$gate->exists()) {
$state['selected_gate_type'] = $gateType;
$state['gate_id'] = null;
return [
'action' => 'complete',
'reason' => 'gate_not_found',
'message' => $this->buildMissingGateMessage($gateType),
'state' => $state,
];
}
$state['selected_gate_type'] = $gateType;
$state['gate_id'] = (int)$gate->id;
return ['action' => 'open_gate', 'state' => $state];
}
return ['action' => 'invalid_selection', 'state' => $state];
}
protected function advanceStateForDepartment(array $state, array $departmentOption): array
{
$departmentId = (int)($departmentOption['department_id'] ?? 0);
if ($departmentId <= 0) {
return [
'action' => 'complete',
'reason' => 'missing_department',
'message' => 'The selected department is no longer available.',
'state' => $state,
];
}
$state['selected_department_id'] = $departmentId;
$state['selected_department_name'] = (string)($departmentOption['department_name'] ?? $this->getDepartmentNameById($departmentId));
$state['department_options'] = (array)($state['department_options'] ?? []);
$state['gate_id'] = null;
$state['selected_gate_type'] = '';
$availableGateTypes = $this->extractAvailableGateTypes($departmentOption);
$state['available_gate_types'] = $availableGateTypes;
$state['gate_options'] = $this->buildGateOptionMap($availableGateTypes);
if ($availableGateTypes === []) {
return [
'action' => 'complete',
'reason' => 'no_eligible_gates_for_department',
'message' => 'The selected department has no phone-controlled gates.',
'state' => $state,
];
}
$sharedGate = $this->resolveSharedPhoneCallGate($departmentId, $availableGateTypes);
if ($sharedGate !== null && $sharedGate->exists()) {
$state['gate_id'] = (int)$sharedGate->id;
$state['gate_options'] = [];
return ['action' => 'open_gate', 'state' => $state];
}
$state['stage'] = self::IVR_STAGE_GATE_TYPE_SELECT;
return ['action' => 'gather', 'state' => $state];
}
protected function openSelectedGateAndComplete(string $callId, array $state, string $responseMode): array
{
$departmentId = $this->selectedDepartmentId($state);
$gateType = $this->selectedGateType($state);
$gateId = $this->selectedGateId($state);
if ($departmentId === null) {
$this->clearIvrState($callId);
return $this->buildLifecycleCompletionResponse(
$responseMode,
$callId,
$state,
'completed',
'no_action',
'The selected gate is no longer available.',
false,
$departmentId,
$gateType,
$gateId,
'gate_not_found',
);
}
$gate = $gateId !== null ? $this->resolvePhoneCallGateById($gateId) : null;
if (($gate === null || !$gate->exists()) && $gateType !== null) {
$gate = $this->resolvePhoneCallGate($departmentId, $gateType);
}
if ($gate === null || !$gate->exists()) {
$this->clearIvrState($callId);
return $this->buildLifecycleCompletionResponse(
$responseMode,
$callId,
$state,
'completed',
'no_action',
$this->buildMissingGateMessage($gateType),
false,
$departmentId,
$gateType,
$gateId,
'gate_not_found',
);
}
try {
$this->triggerGateOpen($gate);
$this->clearIvrState($callId);
return $this->buildLifecycleCompletionResponse(
$responseMode,
$callId,
$state,
'completed',
'gate_opened',
$this->buildGateOpenedMessage($gateType),
true,
$departmentId,
$gateType,
(int)$gate->id,
'gate_opened',
);
} catch (\Throwable $e) {
$this->clearIvrState($callId);
return $this->buildLifecycleCompletionResponse(
$responseMode,
$callId,
$state,
'failed',
'gate_open_failed',
$this->buildGateOpenFailedMessage($gateType, $e->getMessage()),
false,
$departmentId,
$gateType,
(int)$gate->id,
'gate_open_failed',
);
}
}
protected function completeActionResponse(
string $callId,
array $state,
string $status,
string $action,
string $message,
bool $gateOpened,
?int $departmentId,
?string $gateType,
?int $gateId,
string $reason
): array {
return [
'requestId' => $this->requestIdForState($state),
'result' => [
'callId' => $callId,
'status' => $status,
'action' => $action,
'message' => $message,
'departmentId' => $departmentId,
'gateType' => $gateType,
'gateId' => $gateId,
'gateOpened' => $gateOpened,
],
'resumeData' => [
'action' => $this->resumeActionForState($state),
'completed' => true,
'result' => $reason,
'gateOpened' => $gateOpened,
],
'completedAt' => $this->currentIsoTimestamp(),
'statusCode' => 200,
'statusText' => 'OK',
];
}
protected function buildLifecycleCompletionResponse(
string $responseMode,
string $callId,
array $state,
string $status,
string $action,
string $message,
bool $gateOpened,
?int $departmentId,
?string $gateType,
?int $gateId,
string $reason
): array {
if ($responseMode === self::RESPONSE_MODE_COMMAND) {
return $this->completeActionResponse(
$callId,
$state,
$status,
$action,
$message,
$gateOpened,
$departmentId,
$gateType,
$gateId,
$reason,
);
}
return $this->buildFlowCompletionResponse(
$callId,
$state,
$status,
$action,
$message,
$gateOpened,
$departmentId,
$gateType,
$gateId,
$reason,
);
}
protected function buildGatherAcceptedResponse(
string $callId,
array $state,
string $prompt,
bool $resumed
): array {
$commandId = $this->generateUuidV4();
$gatherOptions = $this->buildGatherCommandOptions($state, $prompt);
$gatherPayload = [
'duration' => 0,
'keys' => '',
'speech' => '',
'speechConfidence' => 0,
];
foreach ($gatherOptions as $key => $value) {
$gatherPayload[$key] = $value;
}
$response = [
'event' => [
'callCommand' => [
'callId' => $callId,
'channelId' => $this->normalizeOptionalString($state['channel_id'] ?? ''),
'commandId' => $commandId,
'gather' => $gatherPayload,
'platformId' => 'voice-messagebird',
'platformReferenceId' => $callId,
'type' => 'gather',
],
],
'requestId' => $this->requestIdForState($state),
'result' => [
'callId' => $callId,
'command' => 'gather',
'id' => $commandId,
'status' => 'accepted',
],
'resumeData' => [
'action' => $this->resumeActionForState($state),
],
'statusCode' => 202,
'statusText' => 'Accepted',
'suspendedAt' => $this->currentIsoTimestamp(),
];
if ($resumed) {
$response['resumedAt'] = $this->currentIsoTimestamp();
}
return $response;
}
protected function buildLifecycleGatherResponse(
string $responseMode,
string $callId,
array $state,
string $prompt,
bool $resumed
): array {
if ($responseMode === self::RESPONSE_MODE_COMMAND) {
return $this->buildGatherAcceptedResponse($callId, $state, $prompt, $resumed);
}
return $this->buildFlowGatherResponse($callId, $state, $prompt, $resumed);
}
protected function buildFlowGatherResponse(
string $callId,
array $state,
string $prompt,
bool $resumed
): array {
$gatherOptions = $this->buildGatherCommandOptions($state, $prompt);
return [
'requestId' => $this->requestIdForState($state),
'callId' => $callId,
'status' => 'gather',
'completed' => false,
'stage' => $this->normalizeOptionalString($state['stage'] ?? ''),
'prompt' => $prompt,
'gather' => [
'input' => (string)($gatherOptions['input'] ?? 'dtmf'),
'maxNumKeys' => (int)($gatherOptions['maxNumKeys'] ?? 1),
'endKey' => $this->normalizeOptionalString($gatherOptions['endKey'] ?? self::DEFAULT_GATHER_END_KEY),
'timeout' => (int)($gatherOptions['timeout'] ?? self::DEFAULT_GATHER_TIMEOUT_SECONDS),
'retries' => (int)($gatherOptions['retries'] ?? self::DEFAULT_GATHER_RETRIES),
'say' => [
'locale' => (string)((is_array($gatherOptions['say'] ?? null) ? ($gatherOptions['say']['locale'] ?? '') : '') ?: self::DEFAULT_GATHER_SAY_LOCALE),
'voice' => (string)((is_array($gatherOptions['say'] ?? null) ? ($gatherOptions['say']['voice'] ?? '') : '') ?: self::DEFAULT_GATHER_SAY_VOICE),
'text' => $prompt,
],
],
// Bird's relay step is unreliable with nested JSON paths from HTTP responses,
// so expose the active gather contract as flat aliases as well.
'gatherInput' => (string)($gatherOptions['input'] ?? 'dtmf'),
'gatherMaxNumKeys' => (int)($gatherOptions['maxNumKeys'] ?? 1),
'gatherEndKey' => $this->normalizeOptionalString($gatherOptions['endKey'] ?? self::DEFAULT_GATHER_END_KEY),
'gatherTimeout' => (int)($gatherOptions['timeout'] ?? self::DEFAULT_GATHER_TIMEOUT_SECONDS),
'gatherRetries' => (int)($gatherOptions['retries'] ?? self::DEFAULT_GATHER_RETRIES),
'gatherSayLocale' => (string)((is_array($gatherOptions['say'] ?? null) ? ($gatherOptions['say']['locale'] ?? '') : '') ?: self::DEFAULT_GATHER_SAY_LOCALE),
'gatherSayVoice' => (string)((is_array($gatherOptions['say'] ?? null) ? ($gatherOptions['say']['voice'] ?? '') : '') ?: self::DEFAULT_GATHER_SAY_VOICE),
'selection' => [
'departmentId' => $this->selectedDepartmentId($state),
'departmentName' => $this->normalizeOptionalString($state['selected_department_name'] ?? ''),
'gateType' => $this->selectedGateType($state),
'gateId' => $this->selectedGateId($state),
],
'invalidSelectionCount' => max(0, (int)($state['invalid_selection_count'] ?? 0)),
'resumed' => $resumed,
'statusCode' => 200,
'statusText' => 'OK',
];
}
protected function buildFlowCompletionResponse(
string $callId,
array $state,
string $status,
string $action,
string $message,
bool $gateOpened,
?int $departmentId,
?string $gateType,
?int $gateId,
string $reason
): array {
return [
'requestId' => $this->requestIdForState($state),
'callId' => $callId,
'status' => $status,
'action' => $action,
'message' => $message,
'completed' => true,
'result' => $reason,
'gateOpened' => $gateOpened,
'departmentId' => $departmentId,
'departmentName' => $this->normalizeOptionalString($state['selected_department_name'] ?? ''),
'gateType' => $gateType,
'gateId' => $gateId,
'statusCode' => 200,
'statusText' => 'OK',
'completedAt' => $this->currentIsoTimestamp(),
];
}
protected function buildGatherCommandOptions(array $state, string $prompt): array
{
$options = (array)($state['gather_template'] ?? []);
$options['input'] = 'dtmf';
$options['maxNumKeys'] = $this->maxNumKeysForState($state);
if ((int)$options['maxNumKeys'] > 1) {
$options['endKey'] = '#';
}
$say = is_array($options['say'] ?? null) ? $options['say'] : [];
if (!isset($say['locale']) || !is_scalar($say['locale']) || trim((string)$say['locale']) === '') {
$say['locale'] = self::DEFAULT_GATHER_SAY_LOCALE;
}
if (!isset($say['voice']) || !is_scalar($say['voice']) || trim((string)$say['voice']) === '') {
$say['voice'] = self::DEFAULT_GATHER_SAY_VOICE;
}
$say['text'] = $prompt;
$options['say'] = $say;
return $options;
}
protected function normalizeGatherTemplate(array $payload): array
{
$template = $payload;
$template['input'] = 'dtmf';
$template['maxNumKeys'] = 1;
if (!isset($template['retries']) || !is_numeric($template['retries'])) {
$template['retries'] = self::DEFAULT_GATHER_RETRIES;
}
if (!isset($template['timeout']) || !is_numeric($template['timeout'])) {
$template['timeout'] = self::DEFAULT_GATHER_TIMEOUT_SECONDS;
}
if (!isset($template['endKey']) || !is_scalar($template['endKey']) || trim((string)$template['endKey']) === '') {
$template['endKey'] = self::DEFAULT_GATHER_END_KEY;
}
$say = is_array($template['say'] ?? null) ? $template['say'] : [];
if (!isset($say['locale']) || !is_scalar($say['locale']) || trim((string)$say['locale']) === '') {
$say['locale'] = self::DEFAULT_GATHER_SAY_LOCALE;
}
if (!isset($say['voice']) || !is_scalar($say['voice']) || trim((string)$say['voice']) === '') {
$say['voice'] = self::DEFAULT_GATHER_SAY_VOICE;
}
$say['text'] = '';
$template['say'] = $say;
return $template;
}
protected function extractInitialGatherTemplate(array $payload): array
{
if (isset($payload['payload']) && is_array($payload['payload'])) {
return $payload['payload'];
}
$template = [];
foreach (['endKey', 'input', 'maxNumKeys', 'retries', 'speechLocale', 'timeout'] as $field) {
if (array_key_exists($field, $payload)) {
$template[$field] = $payload[$field];
}
}
if (isset($payload['say']) && is_array($payload['say'])) {
$template['say'] = $payload['say'];
}
return $template;
}
protected function buildPromptTextForState(array $state, bool $withInvalidPrefix = false): string
{
$stage = $this->normalizeOptionalString($state['stage'] ?? '');
if ($stage === self::IVR_STAGE_GATE_TYPE_SELECT) {
$prompt = $this->buildGateTypePromptText(
(string)($state['selected_department_name'] ?? ''),
$this->normalizeGateOptions((array)($state['gate_options'] ?? [])),
);
} else {
$prompt = $this->buildDepartmentPromptText((array)($state['department_options'] ?? []));
}
if ($withInvalidPrefix) {
return 'Invalid selection. ' . $prompt;
}
return $prompt;
}
protected function loadEligibleDepartmentSummaries(): array
{
return (new department_gates_o())->getPhoneCallDepartmentSummaries();
}
protected function resolvePhoneCallGate(int $departmentId, string $gateType): ?department_gates_o
{
$gates = new department_gates_o();
return $gateType === self::IVR_GATE_TYPE_EXIT
? $gates->getExitPhoneCallGate($departmentId)
: $gates->getEntrancePhoneCallGate($departmentId);
}
protected function resolvePhoneCallGateById(int $gateId): ?department_gates_o
{
if ($gateId <= 0) {
return null;
}
$gate = (new department_gates_o())->select($gateId);
return $gate instanceof department_gates_o && $gate->exists() ? $gate : null;
}
protected function resolveSharedPhoneCallGate(int $departmentId, array $availableGateTypes): ?department_gates_o
{
if (
!in_array(self::IVR_GATE_TYPE_ENTRANCE, $availableGateTypes, true)
|| !in_array(self::IVR_GATE_TYPE_EXIT, $availableGateTypes, true)
) {
return null;
}
$entranceGate = $this->resolvePhoneCallGate($departmentId, self::IVR_GATE_TYPE_ENTRANCE);
$exitGate = $this->resolvePhoneCallGate($departmentId, self::IVR_GATE_TYPE_EXIT);
if (
$entranceGate === null
|| !$entranceGate->exists()
|| $exitGate === null
|| !$exitGate->exists()
) {
return null;
}
return (int)$entranceGate->id === (int)$exitGate->id ? $entranceGate : null;
}
protected function triggerGateOpen(department_gates_o $gate): void
{
$gate->openGate();
}
protected function resolveBirdClient(): bird
{
return new bird();
}
protected function acceptInboundCall(
bird $client,
string $workspaceId,
string $channelId,
string $callId
): void {
if (!$this->shouldAcceptInboundCall($client)) {
return;
}
try {
$client->answerVoiceCall($workspaceId, $channelId, $callId, []);
} catch (\Throwable) {
// Flow Builder should answer inbound calls before invoking this webhook.
// Treat backend acceptance as a best-effort fallback.
}
}
protected function shouldAcceptInboundCall(bird $client): bool
{
return $this->isBirdModuleEnabled($client)
&& $this->readBirdConfigValue($client, 'api_key') !== ''
&& $this->readBirdConfigValue($client, 'server_url') !== '';
}
protected function isBirdModuleEnabled(bird $client): bool
{
try {
if (!isset($client->config) || !is_object($client->config) || !property_exists($client->config, 'enabled')) {
return false;
}
$enabledConfig = $client->config->enabled;
if (!is_object($enabledConfig)) {
return false;
}
if (method_exists($enabledConfig, 'isTrue')) {
return $enabledConfig->isTrue();
}
if (method_exists($enabledConfig, 'getVariableValue')) {
return filter_var($enabledConfig->getVariableValue(), FILTER_VALIDATE_BOOLEAN);
}
} catch (\Throwable) {
return false;
}
return false;
}
protected function readBirdConfigValue(bird $client, string $property): string
{
try {
if (
!isset($client->config)
|| !is_object($client->config)
|| !property_exists($client->config, $property)
) {
return '';
}
$configValue = $client->config->{$property};
if (!is_object($configValue) || !method_exists($configValue, 'getVariableValue')) {
return '';
}
return $this->normalizeOptionalString($configValue->getVariableValue());
} catch (\Throwable) {
return '';
}
}
protected function readInboundWebhookPayload(): array
{
$rawBody = file_get_contents('php://input');
if (is_string($rawBody) && trim($rawBody) !== '') {
$decoded = json_decode($rawBody, true);
if (is_array($decoded)) {
return $decoded;
}
}
return is_array($_POST) ? $_POST : [];
}
protected function isSupportedInboundRequest(array $payload): bool
{
if ($this->isInitialGatherRequest($payload) || $this->isResumedGatherRequest($payload)) {
return true;
}
return $this->extractCallIdFromPayload($payload) !== ''
&& $this->extractWorkspaceIdFromPayload($payload) !== ''
&& $this->extractChannelIdFromPayload($payload) !== '';
}
protected function isInitialGatherRequest(array $payload): bool
{
return isset($payload['payload'], $payload['request'], $payload['waitConditions'])
&& is_array($payload['payload'])
&& is_array($payload['request'])
&& is_array($payload['waitConditions']);
}
protected function isResumedGatherRequest(array $payload): bool
{
return isset($payload['event']) && is_array($payload['event']);
}
protected function shouldBootstrapIvrState(array $payload, array $state): bool
{
if ($this->isInitialGatherRequest($payload)) {
return true;
}
if ($this->isResumedGatherRequest($payload)) {
return false;
}
return !$this->hasActiveIvrState($state);
}
protected function hasActiveIvrState(array $state): bool
{
return $state !== [] && $this->normalizeOptionalString($state['stage'] ?? '') !== '';
}
protected function responseModeForPayload(array $payload, array $state = []): string
{
$stateMode = $this->responseModeForState($state);
if ($stateMode !== '') {
return $stateMode;
}
if ($this->isResumedGatherRequest($payload) || $this->isInitialGatherRequest($payload)) {
return self::RESPONSE_MODE_COMMAND;
}
return self::RESPONSE_MODE_FLOW;
}
protected function responseModeForState(array $state): string
{
$mode = $this->normalizeOptionalString($state['response_mode'] ?? '');
return in_array($mode, [self::RESPONSE_MODE_COMMAND, self::RESPONSE_MODE_FLOW], true)
? $mode
: '';
}
protected function resolveInboundCallId(array $payload): string
{
$queryCallId = $this->normalizeOptionalString($this->fromQuery('callId'));
if ($queryCallId !== '') {
return $queryCallId;
}
if (isset($_POST['callId']) && is_scalar($_POST['callId'])) {
$postCallId = $this->normalizeOptionalString((string)$_POST['callId']);
if ($postCallId !== '') {
return $postCallId;
}
}
return $this->extractCallIdFromPayload($payload);
}
protected function resolveInboundWorkspaceId(bird $client, array $payload): string
{
$queryWorkspaceId = $this->normalizeOptionalString($this->fromQuery('workspaceId'));
if ($queryWorkspaceId !== '') {
return $queryWorkspaceId;
}
if (isset($_POST['workspaceId']) && is_scalar($_POST['workspaceId'])) {
$postWorkspaceId = $this->normalizeOptionalString((string)$_POST['workspaceId']);
if ($postWorkspaceId !== '') {
return $postWorkspaceId;
}
}
$payloadWorkspaceId = $this->extractWorkspaceIdFromPayload($payload);
if ($payloadWorkspaceId !== '') {
return $payloadWorkspaceId;
}
return $this->getConfiguredWorkspaceId($client);
}
protected function resolveInboundChannelId(bird $client, array $payload): string
{
$queryChannelId = $this->normalizeOptionalString($this->fromQuery('channelId'));
if ($queryChannelId !== '') {
return $queryChannelId;
}
if (isset($_POST['channelId']) && is_scalar($_POST['channelId'])) {
$postChannelId = $this->normalizeOptionalString((string)$_POST['channelId']);
if ($postChannelId !== '') {
return $postChannelId;
}
}
$payloadChannelId = $this->extractChannelIdFromPayload($payload);
if ($payloadChannelId !== '') {
return $payloadChannelId;
}
return $this->getConfiguredChannelId($client);
}
protected function extractCallIdFromPayload(array $payload): string
{
return $this->extractPayloadStringByPaths($payload, [
['request', 'callId'],
['body', 'callId'],
['event', 'callCommand', 'callId'],
['callId'],
['call_id'],
['call', 'id'],
['data', 'callId'],
['payload', 'callId'],
['event', 'callId'],
['voice', 'callId'],
]);
}
protected function extractWorkspaceIdFromPayload(array $payload): string
{
return $this->extractPayloadStringByPaths($payload, [
['request', 'workspaceId'],
['body', 'workspaceId'],
['workspaceId'],
['workspace_id'],
['call', 'workspaceId'],
['data', 'workspaceId'],
['payload', 'workspaceId'],
]);
}
protected function extractChannelIdFromPayload(array $payload): string
{
return $this->extractPayloadStringByPaths($payload, [
['request', 'channelId'],
['body', 'channelId'],
['event', 'callCommand', 'channelId'],
['channelId'],
['channel_id'],
['call', 'channelId'],
['data', 'channelId'],
['payload', 'channelId'],
]);
}
protected function extractRequestIdFromPayload(array $payload): string
{
return $this->extractPayloadStringByPaths($payload, [
['requestId'],
['request', 'requestId'],
['event', 'requestId'],
]);
}
protected function extractResumeActionFromPayload(array $payload): string
{
return $this->extractPayloadStringByPaths($payload, [
['resumeData', 'action'],
['waitConditions', 'events', 0, 'action'],
]);
}
protected function extractWaitTimeoutFromPayload(array $payload): string
{
return $this->extractPayloadStringByPaths($payload, [
['waitConditions', 'timeout'],
]);
}
protected function extractGatherKeysFromEvent(array $payload): ?string
{
$value = $this->extractPayloadStringByPaths($payload, [
['event', 'callCommand', 'gather', 'keys'],
['event', 'callCommand', 'gather', 'key'],
]);
return $value !== '' ? $value : null;
}
protected function extractDtmfInput(array $payload): ?string
{
$value = $this->extractPayloadStringByPaths($payload, [
['dtmf'],
['digit'],
['digits'],
['keys'],
['key'],
['input'],
['input', 'dtmf'],
['input', 'digit'],
['input', 'digits'],
['input', 'keys'],
['input', 'key'],
['gather', 'dtmf'],
['gather', 'digit'],
['gather', 'digits'],
['gather', 'keys'],
['gather', 'key'],
['event', 'data', 'dtmf'],
['event', 'data', 'digit'],
['event', 'data', 'digits'],
['event', 'data', 'keys'],
['event', 'data', 'key'],
['result', 'dtmf'],
['result', 'digit'],
['result', 'digits'],
['result', 'keys'],
['result', 'key'],
['response', 'dtmf'],
['response', 'digit'],
['response', 'digits'],
['response', 'keys'],
['response', 'key'],
['variables', 'keys'],
['conditions', 0, 'value'],
]);
return $value !== '' ? $value : null;
}
protected function normalizeMenuDigitInput(?string $input): ?string
{
if ($input === null) {
return null;
}
$normalized = trim($input);
while ($normalized !== '' && str_ends_with($normalized, '#')) {
$normalized = substr($normalized, 0, -1);
}
$normalized = preg_replace('/[^0-9]/', '', $normalized);
if (!is_string($normalized) || $normalized === '') {
return null;
}
return preg_match('/^[1-9][0-9]*$/', $normalized) === 1 ? $normalized : null;
}
protected function extractPayloadStringByPaths(array $payload, array $candidatePaths): string
{
foreach ($candidatePaths as $path) {
if (!is_array($path) || $path === []) {
continue;
}
$value = $this->extractPayloadValueByPath($payload, $path);
if (!is_scalar($value)) {
continue;
}
$normalized = $this->normalizeOptionalString((string)$value);
if ($normalized !== '') {
return $normalized;
}
}
return '';
}
protected function extractPayloadValueByPath(array $payload, array $path): mixed
{
$cursor = $payload;
foreach ($path as $segment) {
if (is_array($cursor) && array_key_exists($segment, $cursor)) {
$cursor = $cursor[$segment];
continue;
}
return null;
}
return $cursor;
}
protected function buildDepartmentOptionMap(array $departmentIds): array
{
$map = [];
$index = 1;
foreach ($departmentIds as $departmentEntry) {
$departmentName = '';
$hasEntranceGate = false;
$hasExitGate = false;
if (is_array($departmentEntry)) {
$id = (int)($departmentEntry['department_id'] ?? 0);
$departmentName = $this->normalizeOptionalString($departmentEntry['department_name'] ?? '');
$hasEntranceGate = ($departmentEntry['has_entrance_gate'] ?? false) === true;
$hasExitGate = ($departmentEntry['has_exit_gate'] ?? false) === true;
} else {
$id = (int)$departmentEntry;
}
if ($id <= 0) {
continue;
}
$digit = (string)$index;
$map[$digit] = [
'department_id' => $id,
'department_name' => $departmentName !== '' ? $departmentName : $this->getDepartmentNameById($id),
'has_entrance_gate' => $hasEntranceGate,
'has_exit_gate' => $hasExitGate,
];
$index++;
}
return $map;
}
protected function buildDepartmentPromptText(array $optionMap): string
{
if ($optionMap === []) {
return 'Choose department.';
}
$parts = ['Choose department.'];
if ($this->requiresDepartmentTerminator($optionMap)) {
$parts[] = 'Enter the option number followed by pound.';
}
foreach ($optionMap as $digit => $department) {
$name = (string)($department['department_name'] ?? '');
if ($name === '') {
$name = 'department ' . (string)($department['department_id'] ?? '');
}
$parts[] = 'Press ' . $digit . ' for ' . $name . '.';
}
return implode(' ', $parts);
}
protected function buildGateTypePromptText(string $departmentName, array $gateOptions = []): string
{
$prefix = $departmentName !== '' ? 'You selected ' . $departmentName . '. ' : '';
$gateOptions = $this->normalizeGateOptions($gateOptions);
if ($gateOptions === []) {
return $prefix . 'Press 1 for entrance. Press 2 for exit.';
}
$parts = [];
foreach ($gateOptions as $digit => $gateType) {
$parts[] = 'Press ' . $digit . ' for ' . $gateType . '.';
}
return $prefix . implode(' ', $parts);
}
protected function resolveDepartmentIdByDigit(array $optionMap, string $digits): ?int
{
$key = trim($digits);
if ($key === '' || !isset($optionMap[$key])) {
return null;
}
$id = (int)($optionMap[$key]['department_id'] ?? 0);
return $id > 0 ? $id : null;
}
protected function resolveGateTypeByDigit(string $digits, array $gateOptions = []): ?string
{
$key = trim($digits);
$gateOptions = $this->normalizeGateOptions($gateOptions);
if ($gateOptions !== []) {
return $gateOptions[$key] ?? null;
}
if ($key === '1') {
return self::IVR_GATE_TYPE_ENTRANCE;
}
if ($key === '2') {
return self::IVR_GATE_TYPE_EXIT;
}
return null;
}
protected function resolveSelectedDepartmentOption(array $optionMap, string $digit): ?array
{
$key = trim($digit);
return isset($optionMap[$key]) && is_array($optionMap[$key]) ? $optionMap[$key] : null;
}
protected function extractAvailableGateTypes(array $departmentOption): array
{
$availableGateTypes = [];
if (($departmentOption['has_entrance_gate'] ?? false) === true) {
$availableGateTypes[] = self::IVR_GATE_TYPE_ENTRANCE;
}
if (($departmentOption['has_exit_gate'] ?? false) === true) {
$availableGateTypes[] = self::IVR_GATE_TYPE_EXIT;
}
return $availableGateTypes;
}
protected function buildGateOptionMap(array $availableGateTypes): array
{
$gateOptions = [];
$digit = 1;
foreach ($availableGateTypes as $gateType) {
$normalizedGateType = $this->normalizeOptionalString((string)$gateType);
if ($normalizedGateType === '') {
continue;
}
$gateOptions[(string)$digit] = $normalizedGateType;
$digit++;
}
return $gateOptions;
}
protected function normalizeGateOptions(array $gateOptions): array
{
$normalized = [];
foreach ($gateOptions as $digit => $gateType) {
$normalizedDigit = $this->normalizeMenuDigitInput((string)$digit);
$normalizedGateType = $this->normalizeOptionalString((string)$gateType);
if ($normalizedDigit === null || $normalizedGateType === '') {
continue;
}
$normalized[$normalizedDigit] = $normalizedGateType;
}
return $normalized;
}
protected function maxNumKeysForState(array $state): int
{
$stage = $this->normalizeOptionalString($state['stage'] ?? '');
if ($stage !== self::IVR_STAGE_DEPARTMENT_SELECT) {
return 1;
}
$departmentOptions = is_array($state['department_options'] ?? null) ? $state['department_options'] : [];
$count = count($departmentOptions);
return max(1, strlen((string)$count));
}
protected function requiresDepartmentTerminator(array $optionMap): bool
{
return count($optionMap) > 9;
}
protected function getDepartmentNameById(int $departmentId): string
{
$row = (new departments_o())->getDepartmentById($departmentId);
return trim((string)($row['name'] ?? ('Department ' . $departmentId)));
}
protected function saveIvrState(string $callId, array $state): void
{
if ($callId === '') {
return;
}
try {
$encoded = json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded) || $encoded === '') {
return;
}
$this->writeIvrStateRaw($this->ivrStateKey($callId), $encoded, self::IVR_STATE_TTL_SECONDS);
} catch (\Throwable) {
}
}
protected function readIvrState(string $callId): ?array
{
if ($callId === '') {
return null;
}
try {
$raw = $this->readIvrStateRaw($this->ivrStateKey($callId));
if (!is_string($raw) || trim($raw) === '') {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
} catch (\Throwable) {
return null;
}
}
protected function clearIvrState(string $callId): void
{
if ($callId === '') {
return;
}
try {
$this->deleteIvrStateRaw($this->ivrStateKey($callId));
} catch (\Throwable) {
}
}
protected function normalizeState(array $state): array
{
if ($state === []) {
return [];
}
$state['response_mode'] = $this->responseModeForState($state) ?: self::RESPONSE_MODE_FLOW;
$state['department_options'] = is_array($state['department_options'] ?? null) ? $state['department_options'] : [];
$state['available_gate_types'] = array_values(array_filter(
is_array($state['available_gate_types'] ?? null) ? $state['available_gate_types'] : [],
static fn(mixed $value): bool => is_string($value) && $value !== ''
));
$state['gate_options'] = $this->normalizeGateOptions(
is_array($state['gate_options'] ?? null) ? $state['gate_options'] : []
);
if ($state['gate_options'] === [] && $state['available_gate_types'] !== []) {
$state['gate_options'] = $this->buildGateOptionMap($state['available_gate_types']);
}
$state['gather_template'] = is_array($state['gather_template'] ?? null) ? $state['gather_template'] : [];
return $state;
}
protected function ivrStateKey(string $callId): string
{
return self::IVR_STATE_PREFIX . $callId;
}
protected function ivrLockKey(string $callId): string
{
return self::IVR_LOCK_PREFIX . $callId;
}
protected function acquireIvrLock(string $callId): bool
{
if ($callId === '') {
return false;
}
try {
return $this->acquireIvrLockRaw($this->ivrLockKey($callId), self::IVR_LOCK_TTL_SECONDS);
} catch (\Throwable) {
return true;
}
}
protected function releaseIvrLock(string $callId): void
{
if ($callId === '') {
return;
}
try {
$this->releaseIvrLockRaw($this->ivrLockKey($callId));
} catch (\Throwable) {
}
}
protected function acquireIvrLockRaw(string $key, int $ttlSeconds): bool
{
$redis = $this->resolveRedisClient();
if ($redis === null) {
return true;
}
if ($redis->exists($key)) {
return false;
}
$redis->set($key, $this->generateUuidV4());
$redis->expire($key, $ttlSeconds);
return true;
}
protected function releaseIvrLockRaw(string $key): void
{
$redis = $this->resolveRedisClient();
if ($redis === null) {
return;
}
$redis->delete($key);
}
protected function writeIvrStateRaw(string $key, string $value, int $ttlSeconds): void
{
$redis = $this->resolveRedisClient();
if ($redis === null) {
return;
}
$redis->set($key, $value);
$redis->expire($key, $ttlSeconds);
}
protected function readIvrStateRaw(string $key): ?string
{
$redis = $this->resolveRedisClient();
if ($redis === null) {
return null;
}
return $redis->get($key);
}
protected function deleteIvrStateRaw(string $key): void
{
$redis = $this->resolveRedisClient();
if ($redis === null) {
return;
}
$redis->delete($key);
}
protected function resolveRedisClient(): ?redis
{
try {
return (new redis())->connect();
} catch (\Throwable) {
return null;
}
}
protected function requestIdForState(array $state): string
{
$requestId = $this->normalizeOptionalString($state['request_id'] ?? '');
return $requestId !== '' ? $requestId : $this->generateUuidV4();
}
protected function resumeActionForState(array $state): string
{
$action = $this->normalizeOptionalString($state['resume_action'] ?? '');
return $action !== '' ? $action : 'continue';
}
protected function selectedDepartmentId(array $state): ?int
{
$departmentId = (int)($state['selected_department_id'] ?? 0);
return $departmentId > 0 ? $departmentId : null;
}
protected function selectedGateType(array $state): ?string
{
$gateType = $this->normalizeOptionalString($state['selected_gate_type'] ?? '');
return $gateType !== '' ? $gateType : null;
}
protected function selectedGateId(array $state): ?int
{
$gateId = (int)($state['gate_id'] ?? 0);
return $gateId > 0 ? $gateId : null;
}
protected function buildGateOpenedMessage(?string $gateType): string
{
return $gateType !== null ? 'Opening the ' . $gateType . ' gate now.' : 'Opening the gate now.';
}
protected function buildGateOpenFailedMessage(?string $gateType, string $errorMessage): string
{
$prefix = $gateType !== null ? 'Failed to open the ' . $gateType . ' gate.' : 'Failed to open the gate.';
return $errorMessage !== '' ? $prefix . ' ' . $errorMessage : $prefix;
}
protected function buildMissingGateMessage(?string $gateType): string
{
if ($gateType === null) {
return 'The selected gate is no longer available.';
}
return 'No phone-controlled ' . $gateType . ' gate is configured for the selected department.';
}
protected function buildTransportErrorResponse(array $payload, int $statusCode, string $message): array
{
return [
'requestId' => $this->extractRequestIdFromPayload($payload) ?: $this->generateUuidV4(),
'statusCode' => $statusCode,
'statusText' => $this->statusTextForCode($statusCode),
'error' => [
'message' => $message,
],
];
}
protected function statusTextForCode(int $statusCode): string
{
return match ($statusCode) {
202 => 'Accepted',
400 => 'Bad Request',
500 => 'Internal Server Error',
default => 'OK',
};
}
protected function currentIsoTimestamp(): string
{
return (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.u\Z');
}
protected function generateUuidV4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
$hex = bin2hex($bytes);
return sprintf(
'%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12),
);
}
}