Add input gathering feature to Bird API client and routes, integrate multi-gate selection flow in birdVoiceWebhooksRoute, and enhance error handling for invalid or expired sessions.

This commit is contained in:
Jeppe Bundgaard
2026-03-04 13:43:19 +01:00
parent 84ba2a1fad
commit 342209fd2f
3 changed files with 159 additions and 45 deletions
+45
View File
@@ -324,6 +324,45 @@ class bird
return $this->sendPostRequest($this->sayBase($workspaceId, $channelId, $callId), $tmp);
}
public function gatherMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
$this->logBirdAction('BIRD_VOICE_CALL_GATHER', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
/**
* Example payload:
* https://api.bird.com/workspaces/{workspaceId}/channels/{channelId}/calls/{callId}/gather
* {
* "maxNumKeys": 1,
* "endKey": "0",
* "timeout": 1,
* "retries": 1,
* "input": "dtmf",
* "speechLocale": "en-US",
* "playback": {
* "media": [ "text" ],
* "loop": 1,
* "timeout": 1,
* "pauseMilliseconds": 1
* },
* "say": {
* "text": "text",
* "locale": "en-US",
* "voice": "text",
* "loop": 1,
* "timeout": 1
* }
* }
*/
$tmp = [
// Default values
'maxNumKeys' => 1,
'timeout' => 5,
'retries' => 1,
'input' => 'dtmf',
...$payload,
];
return $this->sendPostRequest($this->gatherBase($workspaceId, $channelId, $callId), $tmp);
}
public function listNumbers(string $workspaceId, array $query = []): array|object|null
{
$this->logBirdAction('BIRD_NUMBERS_LIST', 'workspace=' . $workspaceId . ' query_keys=' . implode(',', array_keys($query)));
@@ -443,6 +482,12 @@ class bird
return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls/' . rawurlencode($callId) . '/say';
}
private function gatherBase(string $workspaceId, string $channelId, string $callId): string
{
// https://api.bird.com/workspaces/{workspaceId}/channels/{channelId}/calls/{callId}/gather
return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls/' . rawurlencode($callId) . '/gather';
}
private function extractId(array|object|string|null $response): ?string
{
if (is_object($response) && isset($response->id) && is_string($response->id) && $response->id !== '') {
@@ -159,6 +159,36 @@ class birdVoiceCallsRoute
'modules_bird_voice_calls_say' => 'Say a message on a voice call by ID via Bird',
]);
// Gather input on an active call
$this->post('/bird/voice/calls/{id}/gather', function () {
global $response;
// Permission: gather input on a voice call via Bird
self::requirePermission('modules_bird_voice_calls_gather');
$client = new bird();
$id = (string)$this->fromRoute('id');
if ($id === null || $id === '') {
$response->error('Missing id', 400);
}
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($ws === '') {
$ws = $this->getConfiguredWorkspaceId($client);
}
if ($ch === '') {
$ch = $this->getConfiguredChannelId($client);
}
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$payload = $this->getParametersAsArray();
unset($payload['workspaceId'], $payload['channelId']);
$res = $client->gatherMessage($ws, $ch, $id, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_gather' => 'Gather input on a voice call by ID via Bird',
]);
// Place test outbound call and hang up when accepted
$this->post('/bird/voice/calls/test-outbound', function () {
global $response;
@@ -3,6 +3,7 @@
namespace routes;
use classes\bird;
use classes\redis;
use classes\shelly;
use classes\slack;
use modules\shelly\helpers\shelly_device_switch;
@@ -27,6 +28,7 @@ class birdVoiceWebhooksRoute
//self::requirePermission('modules_bird_voice_call_webhooks_trigger');
$client = new bird();
$support_phone_number_forwarding = "+4555299500"; // Used when the user is not found, and the caller stays on the line to be forwarded to support instead of the gate opening.
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
@@ -42,6 +44,12 @@ class birdVoiceWebhooksRoute
$payload = $this->getParametersAsArray();
$callId = $this->normalizeOptionalString($this->fromRequest('call_id') ?? $this->fromQuery('call_id'));
$digits = $this->normalizeOptionalString($payload['digits'] ?? $this->fromRequest('digits') ?? $this->fromQuery('digits'));
if ($digits !== '' && $callId !== '') {
$this->handleGatherResponse($client, $ws, $ch, $callId, $digits);
return;
}
$slack = new Slack();
$slack->send_message('Webhook received for incoming voice call (ID: ' . $callId . '): ' . json_encode($payload), 'Bird Voice Call Webhooks');
@@ -49,36 +57,43 @@ class birdVoiceWebhooksRoute
$caller = $this->extractCallerPhone($payload);
if ($caller === null) {
$this->say($client, $ws, $ch, $callId, 'Vi kunne ikke identificere dit telefonnummer. Kontakt venligst support.');
return;
}
[$countryCode, $localPhone] = $caller;
if (!$this->isRegisteredCaller($countryCode, $localPhone)) {
$this->say($client, $ws, $ch, $callId, 'Du er ikke sat op til automatisk portåbning med landekode ' . $countryCode . ' og telefonnummer ' . $localPhone . '. Kontakt venligst support for at blive sat op.');
return;
}
// Identify caller's departments
$departments = $this->getCallerDepartments($countryCode, $localPhone);
if (empty($departments)) {
$this->say($client, $ws, $ch, $callId, 'Dit telefonnummer er registreret, men vi kunne ikke finde nogen afdelinger, du har adgang til. Kontakt venligst support.');
return;
}
// Extract destination (the Bird number called)
$destination = $this->extractDestinationPhone($payload);
if ($destination === null) {
$this->say($client, $ws, $ch, $callId, 'Vi kunne ikke identificere det nummer, du har ringet til. Kontakt venligst support.');
// Ask the caller to select what department they want to use with digit(s)
foreach (
$departments as $deptId
) {
$deptGates = (new department_gates_o())->getDepartmentGates($deptId);
foreach ($deptGates as $gate) {
$matchingGates[] = $gate;
}
}
// Find matching gate
$gate = $this->findGateByDestination($departments, $destination);
if ($gate === null) {
$this->say($client, $ws, $ch, $callId, 'Vi kunne ikke finde en port, der matcher det nummer, du har ringet til for dine afdelinger. Kontakt venligst support.');
}
// Trigger relay
if ($this->triggerRelayForGate($gate)) {
$this->say($client, $ws, $ch, $callId, 'Åbner port: ' . $gate->name->value());
if (count($matchingGates) === 1) {
$gate = $matchingGates[0];
if ($this->triggerRelayForGate($gate)) {
$this->say($client, $ws, $ch, $callId, 'Åbner port: ' . $gate->name->value());
} else {
$this->say($client, $ws, $ch, $callId, 'Vi fandt porten ' . $gate->name->value() . ', men kunne ikke aktivere relæet. Kontakt venligst support.');
}
} else {
$this->say($client, $ws, $ch, $callId, 'Vi fandt porten ' . $gate->name->value() . ', men kunne ikke aktivere relæet. Kontakt venligst support.');
// Multiple matching gates, prompt for selection
$this->promptForGateSelection($client, $ws, $ch, $callId, $matchingGates);
}
}, [
@@ -86,22 +101,64 @@ class birdVoiceWebhooksRoute
]);
}
protected function extractDestinationPhone(array $payload): ?array
protected function handleGatherResponse(bird $client, string $ws, string $ch, string $callId, string $digits): void
{
$candidates = [
$payload['to'] ?? null,
$payload['destination'] ?? null,
$payload['address'] ?? null,
];
foreach ($candidates as $candidate) {
$normalized = $this->normalizePhoneCandidate($candidate);
if ($normalized !== null) {
return $normalized;
}
$redis = new redis();
$gateIdsJson = $redis->get('bird_voice_call_selection:' . $callId);
if ($gateIdsJson === null) {
$this->say($client, $ws, $ch, $callId, 'Sessionen er udløbet. Ring venligst op igen.');
return;
}
return null;
$gateIds = json_decode($gateIdsJson, true);
$index = (int)$digits - 1;
if (!isset($gateIds[$index])) {
$this->say($client, $ws, $ch, $callId, 'Ugyldigt valg. Ring venligst op igen.');
return;
}
$gateId = $gateIds[$index];
$gate = (new department_gates_o())->select((int)$gateId);
if (!$gate->exists()) {
$this->say($client, $ws, $ch, $callId, 'Porten kunne ikke findes. Kontakt venligst support.');
return;
}
if ($this->triggerRelayForGate($gate)) {
$this->say($client, $ws, $ch, $callId, 'Åbner port: ' . $gate->name->value());
} else {
$this->say($client, $ws, $ch, $callId, 'Kunne ikke aktivere relæet for ' . $gate->name->value() . '. Kontakt venligst support.');
}
}
protected function promptForGateSelection(bird $client, string $ws, string $ch, string $callId, array $gates): void
{
$gateIds = array_map(function (department_gates_o $gate) {
return (int)$gate->id;
}, $gates);
$redis = new redis();
$redis->set('bird_voice_call_selection:' . $callId, json_encode($gateIds));
$redis->expire('bird_voice_call_selection:' . $callId, 300); // 5 minutes
$options = [];
foreach ($gates as $i => $gate) {
$options[] = 'Tast ' . ($i + 1) . ' for ' . $gate->name->value();
}
$text = 'Der blev fundet flere porte. ' . implode('. ', $options) . '.';
$client->gatherMessage($ws, $ch, $callId, [
'say' => [
'text' => $text,
'locale' => 'da-DK',
],
'maxNumKeys' => 1,
'timeout' => 10,
]);
header('Content-Type: application/json');
echo json_encode([]);
exit;
}
protected function getCallerDepartments(int $countryCode, int $phone): array
@@ -114,25 +171,7 @@ class birdVoiceWebhooksRoute
}, $tmp);
}
protected function findGateByDestination(array $departmentIds, array $destination): ?department_gates_o
{
[$destCountry, $destPhone] = $destination;
foreach ($departmentIds as $deptId) {
$gates = (new department_gates_o())->getDepartmentGates((int)$deptId);
foreach ($gates as $gate) {
$config = $gate->config->value();
if (($config['type'] ?? '') === 'PHONE_CALL') {
$gatePhoneCandidate = $config['phone_number'] ?? '';
$gatePhone = $this->normalizePhoneCandidate($gatePhoneCandidate);
if ($gatePhone !== null && $gatePhone[0] === $destCountry && $gatePhone[1] === $destPhone) {
return $gate;
}
}
}
}
return null;
}
protected function triggerRelayForGate(department_gates_o $gate): bool
{