1721 lines
57 KiB
PHP
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),
|
|
);
|
|
}
|
|
}
|