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:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user