post('/bird/voice/calls/webhook/inbound', function (): void { global $response; self::requirePermission('modules_bird_voice_call_webhooks_trigger'); $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), ); } }