Adjust Bird inbound department prompt to press-digit menu
This commit is contained in:
@@ -323,11 +323,15 @@ class bird
|
||||
$payload = $options;
|
||||
// Keep polling/hangup controls out of create-call payload to avoid Bird validation errors.
|
||||
unset($payload['pollIntervalSeconds'], $payload['maxPollSeconds'], $payload['hangupCause']);
|
||||
$payload['to'] = self::TEST_OUTBOUND_NUMBER_E164;
|
||||
$targetNumber = (string)($payload['to'] ?? self::TEST_OUTBOUND_NUMBER_E164);
|
||||
if ($targetNumber === '') {
|
||||
$targetNumber = self::TEST_OUTBOUND_NUMBER_E164;
|
||||
}
|
||||
$payload['to'] = $targetNumber;
|
||||
|
||||
$this->logBirdAction(
|
||||
'BIRD_TEST_OUTBOUND_CALL_START',
|
||||
'workspace=' . $workspaceId . ' channel=' . $channelId . ' to=' . self::TEST_OUTBOUND_NUMBER_E164
|
||||
'workspace=' . $workspaceId . ' channel=' . $channelId . ' to=' . $targetNumber
|
||||
);
|
||||
|
||||
$createResponse = $this->createVoiceCall($workspaceId, $channelId, $payload);
|
||||
@@ -335,8 +339,8 @@ class bird
|
||||
if ($callId === null) {
|
||||
$this->logBirdAction('BIRD_TEST_OUTBOUND_CALL_NO_ID', 'Call accepted but no call ID returned', 0);
|
||||
return [
|
||||
'to' => self::TEST_OUTBOUND_NUMBER_RAW,
|
||||
'to_e164' => self::TEST_OUTBOUND_NUMBER_E164,
|
||||
'to' => $targetNumber,
|
||||
'to_e164' => $targetNumber,
|
||||
'created_call' => $createResponse,
|
||||
'hangup_sent' => false,
|
||||
'message' => 'Call was created, but no call ID was returned, so hangup could not be sent.',
|
||||
@@ -366,8 +370,8 @@ class bird
|
||||
$hangupResponse = $this->hangupVoiceCall($workspaceId, $channelId, $callId, $hangupPayload);
|
||||
$this->logBirdAction('BIRD_TEST_OUTBOUND_CALL_HANGUP_SENT', 'call=' . $callId . ' status=' . $status);
|
||||
return [
|
||||
'to' => self::TEST_OUTBOUND_NUMBER_RAW,
|
||||
'to_e164' => self::TEST_OUTBOUND_NUMBER_E164,
|
||||
'to' => $targetNumber,
|
||||
'to_e164' => $targetNumber,
|
||||
'call_id' => $callId,
|
||||
'final_status' => $status,
|
||||
'hangup_sent' => true,
|
||||
@@ -389,8 +393,8 @@ class bird
|
||||
0
|
||||
);
|
||||
return [
|
||||
'to' => self::TEST_OUTBOUND_NUMBER_RAW,
|
||||
'to_e164' => self::TEST_OUTBOUND_NUMBER_E164,
|
||||
'to' => $targetNumber,
|
||||
'to_e164' => $targetNumber,
|
||||
'call_id' => $callId,
|
||||
'final_status' => $lastStatus,
|
||||
'hangup_sent' => false,
|
||||
@@ -515,4 +519,3 @@ class bird
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ namespace routes;
|
||||
|
||||
use classes\bird;
|
||||
use classes\slack;
|
||||
use objects\department_gates_o;
|
||||
use objects\departments_o;
|
||||
use objects\subusers_o;
|
||||
use objects\users_o;
|
||||
use traits\bird_route_helpers_t;
|
||||
use traits\route_t;
|
||||
|
||||
@@ -13,13 +17,12 @@ class birdVoiceWebhooksRoute
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// Handle incoming voice call webhooks from Bird (and any other call webhooks that can be triggered via Bird)
|
||||
$this->post('/bird/voice/calls/webhook/inbound', function () {
|
||||
global $response;
|
||||
// Permission: trigger a voice call via Bird webhooks (intended for incoming call webhooks, but can be used for any call webhooks)
|
||||
|
||||
self::requirePermission('modules_bird_voice_call_webhooks_trigger');
|
||||
$client = new bird();
|
||||
// Require workspace/channel per Bird API docs
|
||||
|
||||
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
|
||||
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
|
||||
if ($ws === '') {
|
||||
@@ -31,12 +34,328 @@ class birdVoiceWebhooksRoute
|
||||
if ($ws === '' || $ch === '') {
|
||||
$response->error('Missing required parameters: workspaceId, channelId', 400);
|
||||
}
|
||||
|
||||
$payload = $this->getParametersAsArray();
|
||||
$slack = new Slack();
|
||||
$slack->send_message('Webhook received for incoming voice call: ' . json_encode($payload), 'Bird Voice Call Webhooks');
|
||||
$response->success($res ?? ['status' => 'ok']);
|
||||
|
||||
$caller = $this->extractCallerPhone($payload);
|
||||
if ($caller === null) {
|
||||
$response->success($this->buildVoiceResponse(
|
||||
'We could not identify your caller phone number. Please contact support.',
|
||||
true,
|
||||
['reason' => 'caller_phone_missing']
|
||||
));
|
||||
}
|
||||
|
||||
[$countryCode, $localPhone] = $caller;
|
||||
if (!$this->isRegisteredCaller($countryCode, $localPhone)) {
|
||||
$response->success($this->buildVoiceResponse(
|
||||
'You have not been set up for automated gate opening.',
|
||||
true,
|
||||
['reason' => 'caller_not_registered']
|
||||
));
|
||||
}
|
||||
|
||||
$departmentOptions = $this->getDepartmentMenuOptions();
|
||||
if (empty($departmentOptions)) {
|
||||
$response->success($this->buildVoiceResponse(
|
||||
'No departments are set up for automatic gate opening right now.',
|
||||
true,
|
||||
['reason' => 'no_departments_configured']
|
||||
));
|
||||
}
|
||||
|
||||
$departmentId = $this->extractDepartmentId($payload, $departmentOptions);
|
||||
if ($departmentId === null) {
|
||||
$response->success($this->buildDepartmentPromptResponse($departmentOptions, $countryCode, $localPhone));
|
||||
}
|
||||
|
||||
$selectedOption = $this->getDepartmentOptionByDepartmentId($departmentOptions, $departmentId);
|
||||
if ($selectedOption === null) {
|
||||
$response->success($this->buildDepartmentPromptResponse(
|
||||
$departmentOptions,
|
||||
$countryCode,
|
||||
$localPhone,
|
||||
'Invalid choice. Please try again.'
|
||||
));
|
||||
}
|
||||
|
||||
$gateCallResult = null;
|
||||
$gateCallError = null;
|
||||
try {
|
||||
$gateCallResult = $client->createOutboundTestCallAndHangupWhenAccepted($ws, $ch, [
|
||||
'to' => $selectedOption['gate_phone'],
|
||||
'maxPollSeconds' => 20,
|
||||
'pollIntervalSeconds' => 2,
|
||||
'hangupCause' => 'completed',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$gateCallError = $e->getMessage();
|
||||
}
|
||||
|
||||
if ($gateCallError !== null) {
|
||||
$response->success($this->buildVoiceResponse(
|
||||
'This department is not set up to automatically open the gate without the APP.',
|
||||
true,
|
||||
[
|
||||
'reason' => 'gate_call_failed',
|
||||
'department_id' => $departmentId,
|
||||
'gate_phone' => $selectedOption['gate_phone'],
|
||||
'error' => $gateCallError,
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
$response->success($this->buildVoiceResponse(
|
||||
'The gate will open shortly.',
|
||||
true,
|
||||
[
|
||||
'state' => 'gate_triggered',
|
||||
'department_id' => $departmentId,
|
||||
'department_name' => $selectedOption['department_name'],
|
||||
'gate_phone' => $selectedOption['gate_phone'],
|
||||
'gate_call' => $gateCallResult,
|
||||
]
|
||||
));
|
||||
}, [
|
||||
'modules_bird_voice_call_webhooks_trigger' => 'Trigger a voice call via Bird webhooks',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function extractCallerPhone(array $payload): ?array
|
||||
{
|
||||
$candidates = [
|
||||
$payload['from'] ?? null,
|
||||
$payload['caller'] ?? null,
|
||||
$payload['source'] ?? null,
|
||||
$payload['originator'] ?? null,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$normalized = $this->normalizePhoneCandidate($candidate);
|
||||
if ($normalized !== null) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizePhoneCandidate(mixed $candidate): ?array
|
||||
{
|
||||
if (is_array($candidate)) {
|
||||
$country = isset($candidate['country_code']) ? (int)$candidate['country_code'] : null;
|
||||
$phone = $candidate['phone_number'] ?? $candidate['phone'] ?? $candidate['number'] ?? null;
|
||||
if ($phone !== null) {
|
||||
return $this->normalizePhone((string)$phone, $country);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($candidate) && trim($candidate) !== '') {
|
||||
return $this->normalizePhone($candidate);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizePhone(string $raw, ?int $defaultCountryCode = 45): ?array
|
||||
{
|
||||
$trimmed = trim($raw);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
$digits = preg_replace('/\D+/', '', $trimmed);
|
||||
if (!is_string($digits) || $digits === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$country = $defaultCountryCode;
|
||||
$phone = $digits;
|
||||
|
||||
if (str_starts_with($trimmed, '+') && strlen($digits) > 8) {
|
||||
$country = (int)substr($digits, 0, 2);
|
||||
$phone = substr($digits, 2);
|
||||
}
|
||||
|
||||
if ($country === null || $country <= 0 || $phone === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [$country, (int)$phone];
|
||||
}
|
||||
|
||||
private function isRegisteredCaller(int $countryCode, int $phone): bool
|
||||
{
|
||||
$userRows = (new users_o())->getFieldsWhere([
|
||||
'phone_country_code' => $countryCode,
|
||||
'phone' => $phone,
|
||||
], ['id']);
|
||||
if (!empty($userRows)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (new subusers_o())->getSubuserByPhone($countryCode, $phone) !== null;
|
||||
}
|
||||
|
||||
private function extractDepartmentId(array $payload, array $departmentOptions): ?int
|
||||
{
|
||||
foreach (['department', 'department_id', 'departmentId'] as $key) {
|
||||
if (isset($payload[$key]) && is_numeric($payload[$key]) && (int)$payload[$key] > 0) {
|
||||
return (int)$payload[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$selection = $this->extractDepartmentSelectionDigit($payload);
|
||||
if ($selection === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($departmentOptions as $option) {
|
||||
if ((int)$option['menu_digit'] === $selection) {
|
||||
return (int)$option['department_id'];
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private function extractDepartmentSelectionDigit(array $payload): ?int
|
||||
{
|
||||
$digitCandidates = [
|
||||
$payload['department_option'] ?? null,
|
||||
$payload['departmentOption'] ?? null,
|
||||
$payload['digits'] ?? null,
|
||||
$payload['dtmf'] ?? null,
|
||||
$payload['digit'] ?? null,
|
||||
];
|
||||
|
||||
foreach ($digitCandidates as $candidate) {
|
||||
if (!is_string($candidate) && !is_numeric($candidate)) {
|
||||
continue;
|
||||
}
|
||||
$digits = preg_replace('/\D+/', '', (string)$candidate);
|
||||
if (is_string($digits) && $digits !== '' && (int)$digits > 0) {
|
||||
return (int)$digits;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getDepartmentMenuOptions(): array
|
||||
{
|
||||
$departmentRows = (new departments_o())->list(true);
|
||||
$options = [];
|
||||
|
||||
foreach ($departmentRows as $departmentRow) {
|
||||
$departmentId = (int)($departmentRow['id'] ?? 0);
|
||||
if ($departmentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$gatePhone = $this->findDepartmentEntranceGatePhone($departmentId);
|
||||
if ($gatePhone === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options[] = [
|
||||
'department_id' => $departmentId,
|
||||
'department_name' => (string)($departmentRow['name'] ?? ('Department ' . $departmentId)),
|
||||
'gate_phone' => $gatePhone,
|
||||
];
|
||||
}
|
||||
|
||||
usort($options, static function (array $a, array $b): int {
|
||||
return $a['department_id'] <=> $b['department_id'];
|
||||
});
|
||||
|
||||
$menuDigit = 1;
|
||||
foreach ($options as &$option) {
|
||||
$option['menu_digit'] = $menuDigit;
|
||||
$menuDigit++;
|
||||
if ($menuDigit > 9) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($option);
|
||||
|
||||
return array_values(array_filter($options, static function (array $option): bool {
|
||||
return isset($option['menu_digit']) && (int)$option['menu_digit'] >= 1 && (int)$option['menu_digit'] <= 9;
|
||||
}));
|
||||
}
|
||||
|
||||
private function getDepartmentOptionByDepartmentId(array $departmentOptions, int $departmentId): ?array
|
||||
{
|
||||
foreach ($departmentOptions as $option) {
|
||||
if ((int)$option['department_id'] === $departmentId) {
|
||||
return $option;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function findDepartmentEntranceGatePhone(int $departmentId): ?string
|
||||
{
|
||||
$gateRows = (new department_gates_o())->getFieldsWhere([
|
||||
'department' => $departmentId,
|
||||
'is_entrance' => 1,
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
|
||||
foreach ($gateRows as $gateRow) {
|
||||
$gate = (new department_gates_o())->select((int)$gateRow['id']);
|
||||
if (!$gate->exists()) {
|
||||
continue;
|
||||
}
|
||||
$config = (array)$gate->config->value();
|
||||
$type = strtoupper((string)($config['type'] ?? ''));
|
||||
$phone = trim((string)($config['phone_number'] ?? ''));
|
||||
if ($type === 'PHONE_CALL' && $phone !== '') {
|
||||
return $phone;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function buildDepartmentPromptResponse(array $departmentOptions, int $countryCode, int $phone, string $prefix = ''): array
|
||||
{
|
||||
$parts = [];
|
||||
if ($prefix !== '') {
|
||||
$parts[] = trim($prefix);
|
||||
}
|
||||
foreach ($departmentOptions as $option) {
|
||||
$parts[] = 'Press ' . $option['menu_digit'] . ' for ' . $option['department_name'] . '.';
|
||||
}
|
||||
|
||||
return [
|
||||
'voice_response' => [
|
||||
'say' => implode(' ', $parts),
|
||||
'collect' => [
|
||||
'input' => 'dtmf',
|
||||
'parameter' => 'department_option',
|
||||
'max_digits' => 1,
|
||||
],
|
||||
'hangup' => false,
|
||||
],
|
||||
'automation' => [
|
||||
'state' => 'awaiting_department_selection',
|
||||
'caller' => ['country_code' => $countryCode, 'phone' => $phone],
|
||||
'department_menu' => $departmentOptions,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildVoiceResponse(string $say, bool $hangup, array $automation = []): array
|
||||
{
|
||||
return [
|
||||
'voice_response' => [
|
||||
'say' => $say,
|
||||
'hangup' => $hangup,
|
||||
],
|
||||
'automation' => $automation,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user