Adjust Bird inbound department prompt to press-digit menu

This commit is contained in:
Jeppe B
2026-03-04 11:17:57 +01:00
parent a69df10b77
commit 37f0f280d8
2 changed files with 336 additions and 14 deletions
+12 -9
View File
@@ -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,
];
}
}