Files
api/services/nginx/app/routes/birdVoiceWebhooksRoute.php
T

618 lines
21 KiB
PHP

<?php
namespace routes;
use classes\bird;
use classes\redis;
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;
class birdVoiceWebhooksRoute
{
use route_t, bird_route_helpers_t;
private const IVR_STATE_TTL_SECONDS = 600;
private const IVR_STATE_PREFIX = 'bird_ivr_state:';
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';
public function run(): void
{
$this->post('/bird/voice/calls/webhook/inbound', function () {
global $response;
//self::requirePermission('modules_bird_voice_call_webhooks_trigger');
$client = new bird();
$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();
$callId = $this->normalizeOptionalString($this->fromRequest('call_id') ?? $this->fromQuery('call_id'));
$digits = $this->normalizeOptionalString($payload['digits'] ?? $this->fromRequest('digits') ?? $this->fromQuery('digits'));
if ($callId === '') {
$response->error('Missing required parameter: call_id', 400);
}
$caller = $this->extractCallerPhone($payload);
if ($caller === null) {
$this->say($client, $ws, $ch, $callId, 'Vi kunne ikke identificere dit telefonnummer. Kontakt venligst support.', true);
return;
}
[$countryCode, $localPhone] = $caller;
$slack = new Slack();
$slack->send_message('Webhook received for incoming voice call (ID: ' . $callId . '): ' . json_encode($payload), 'Bird Voice Call Webhooks');
if (!$this->isRegisteredCaller($countryCode, $localPhone)) {
$this->say($client, $ws, $ch, $callId, 'Du er ikke sat op til automatisk portaabning med landekode ' . $countryCode . ' og telefonnummer ' . $localPhone . '. Kontakt venligst support for at blive sat op.', true);
return;
}
$departments = $this->getCallerDepartments($countryCode, $localPhone);
if (empty($departments)) {
$this->clearIvrState($callId);
$this->say($client, $ws, $ch, $callId, 'Vi kunne ikke finde nogen afdelinger tilknyttet din profil. Kontakt venligst support.', true);
return;
}
if ($digits !== '' && $callId !== '') {
$this->handleGatherResponse($client, $ws, $ch, $callId, $digits, $countryCode, $localPhone, $departments);
return;
}
$this->promptForDepartmentSelection($client, $ws, $ch, $callId, $departments, $countryCode, $localPhone);
}, [
'modules_bird_voice_call_webhooks_trigger' => 'Trigger a voice call via Bird webhooks',
]);
}
protected function handleGatherResponse(
bird $client,
string $ws,
string $ch,
string $callId,
string $digits,
int $countryCode,
int $phone,
array $departments
): void {
$state = $this->readIvrState($callId);
if ($state === null || !$this->isStateForCaller($state, $countryCode, $phone)) {
$this->promptForDepartmentSelection($client, $ws, $ch, $callId, $departments, $countryCode, $phone);
return;
}
$stage = (string)($state['stage'] ?? '');
if ($stage === self::IVR_STAGE_DEPARTMENT_SELECT) {
$this->handleDepartmentSelectionDigit($client, $ws, $ch, $callId, $digits, $state, $countryCode, $phone);
return;
}
if ($stage === self::IVR_STAGE_GATE_TYPE_SELECT) {
$this->handleGateTypeSelectionDigit($client, $ws, $ch, $callId, $digits, $state);
return;
}
$this->clearIvrState($callId);
$this->promptForDepartmentSelection($client, $ws, $ch, $callId, $departments, $countryCode, $phone);
}
protected function handleDepartmentSelectionDigit(
bird $client,
string $ws,
string $ch,
string $callId,
string $digits,
array $state,
int $countryCode,
int $phone
): void {
$optionMap = is_array($state['department_options'] ?? null) ? $state['department_options'] : [];
$selected = $this->resolveDepartmentIdByDigit($optionMap, $digits);
if ($selected === null) {
$this->promptForDepartmentRetry($client, $ws, $ch, $callId, $state);
return;
}
$state['stage'] = self::IVR_STAGE_GATE_TYPE_SELECT;
$state['selected_department'] = $selected;
$state['country_code'] = $countryCode;
$state['phone'] = $phone;
$state['selected_department_name'] = $this->getDepartmentNameById($selected);
$this->saveIvrState($callId, $state);
$this->promptForGateTypeSelection($client, $ws, $ch, $callId, $selected, (string)$state['selected_department_name']);
}
protected function handleGateTypeSelectionDigit(
bird $client,
string $ws,
string $ch,
string $callId,
string $digits,
array $state
): void {
$departmentId = (int)($state['selected_department'] ?? 0);
if ($departmentId <= 0) {
$this->clearIvrState($callId);
$this->say($client, $ws, $ch, $callId, 'Sessionen er udloeebet. Ring venligst op igen.', true);
return;
}
$gateType = $this->resolveGateTypeByDigit($digits);
if ($gateType === null) {
$departmentName = (string)($state['selected_department_name'] ?? $this->getDepartmentNameById($departmentId));
$this->promptForGateTypeRetry($client, $ws, $ch, $callId, $departmentId, $departmentName);
return;
}
$gate = $gateType === self::IVR_GATE_TYPE_ENTRANCE
? (new department_gates_o())->getEntranceGate($departmentId)
: (new department_gates_o())->getExitGate($departmentId);
$this->clearIvrState($callId);
if ($gate === null || !$gate->exists()) {
$directionLabel = $gateType === self::IVR_GATE_TYPE_ENTRANCE ? 'indgang' : 'udgang';
$this->say($client, $ws, $ch, $callId, 'Vi kunne ikke finde en ' . $directionLabel . 'sport for den valgte afdeling.', true);
return;
}
if ($this->callGateToOpen($gate)) {
$this->say($client, $ws, $ch, $callId, 'Aabner port: ' . $gate->name->value(), true);
return;
}
$this->say($client, $ws, $ch, $callId, 'Kunne ikke aktivere relaeet for ' . $gate->name->value() . '. Kontakt venligst support.', true);
}
protected function getCallerDepartments(int $countryCode, int $phone): array
{
// Return all visible departments.
$tmp = (new departments_o())->getFieldsWhere([
'visible' => 1,
], ['id']);
return array_map(function ($row) {
return (int)$row['id'];
}, $tmp);
}
protected function promptForDepartmentSelection(
bird $client,
string $ws,
string $ch,
string $callId,
array $departments,
int $countryCode,
int $phone
): void {
$optionMap = $this->buildDepartmentOptionMap($departments);
if (empty($optionMap)) {
$this->say($client, $ws, $ch, $callId, 'Der er ingen valgbare afdelinger lige nu. Kontakt venligst support.', true);
return;
}
$state = [
'stage' => self::IVR_STAGE_DEPARTMENT_SELECT,
'department_options' => $optionMap,
'selected_department' => null,
'country_code' => $countryCode,
'phone' => $phone,
];
$this->saveIvrState($callId, $state);
$text = $this->buildDepartmentPromptText($optionMap);
$this->gather($client, $ws, $ch, $callId, $text);
}
protected function promptForDepartmentRetry(bird $client, string $ws, string $ch, string $callId, array $state): void
{
$optionMap = is_array($state['department_options'] ?? null) ? $state['department_options'] : [];
if (empty($optionMap)) {
$this->clearIvrState($callId);
$this->say($client, $ws, $ch, $callId, 'Sessionen er udloeebet. Ring venligst op igen.', true);
return;
}
$text = 'Ugyldigt valg. ' . $this->buildDepartmentPromptText($optionMap);
$this->gather($client, $ws, $ch, $callId, $text);
}
protected function promptForGateTypeSelection(
bird $client,
string $ws,
string $ch,
string $callId,
int $departmentId,
string $departmentName
): void {
$text = $this->buildGateTypePromptText($departmentName);
$this->gather($client, $ws, $ch, $callId, $text);
}
protected function promptForGateTypeRetry(
bird $client,
string $ws,
string $ch,
string $callId,
int $departmentId,
string $departmentName
): void {
$text = 'Ugyldigt valg. ' . $this->buildGateTypePromptText($departmentName);
$this->gather($client, $ws, $ch, $callId, $text);
}
protected function buildDepartmentOptionMap(array $departmentIds): array
{
$map = [];
$index = 1;
foreach ($departmentIds as $departmentId) {
if ($index > 9) {
break;
}
$id = (int)$departmentId;
if ($id <= 0) {
continue;
}
$digit = (string)$index;
$map[$digit] = [
'department_id' => $id,
'department_name' => $this->getDepartmentNameById($id),
];
$index++;
}
return $map;
}
protected function buildDepartmentPromptText(array $optionMap): string
{
$parts = [];
foreach ($optionMap as $digit => $department) {
$name = (string)($department['department_name'] ?? '');
if ($name === '') {
$name = 'afdeling ' . (string)($department['department_id'] ?? '');
}
$parts[] = 'Tast ' . $digit . ' for ' . $name;
}
return 'Vaelg afdeling. ' . implode('. ', $parts) . '. Afslut med firkantstasten.';
}
protected function buildGateTypePromptText(string $departmentName): string
{
$prefix = $departmentName !== '' ? 'Du valgte ' . $departmentName . '. ' : '';
return $prefix . 'Tast 1 for indgang. Tast 2 for udgang. Afslut med firkantstasten.';
}
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): ?string
{
$key = trim($digits);
if ($key === '1') {
return self::IVR_GATE_TYPE_ENTRANCE;
}
if ($key === '2') {
return self::IVR_GATE_TYPE_EXIT;
}
return null;
}
protected function getDepartmentNameById(int $departmentId): string
{
$row = (new departments_o())->getDepartmentById($departmentId);
return trim((string)($row['name'] ?? 'Afdeling ' . $departmentId));
}
protected function gather(bird $client, string $ws, string $ch, string $callId, string $text): void
{
if ($callId === '') {
throw new \Exception('Call ID is missing, cannot gather input');
}
$client->gatherMessage($ws, $ch, $callId, [
'input' => 'dtmf',
'maxNumKeys' => 1,
'retries' => 1,
'timeout' => 8,
'endKey' => '#',
'say' => [
'text' => $text,
],
]);
$this->respondJsonAndExit();
}
protected function saveIvrState(string $callId, array $state): void
{
if ($callId === '') {
return;
}
try {
$encoded = json_encode($state);
if (!is_string($encoded) || $encoded === '') {
return;
}
$this->writeIvrStateRaw($this->ivrStateKey($callId), $encoded, self::IVR_STATE_TTL_SECONDS);
} catch (\Throwable) {
// State caching should not break webhook flow.
}
}
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) {
// Best effort only.
}
}
protected function isStateForCaller(array $state, int $countryCode, int $phone): bool
{
return (int)($state['country_code'] ?? 0) === $countryCode
&& (int)($state['phone'] ?? 0) === $phone;
}
protected function ivrStateKey(string $callId): string
{
return self::IVR_STATE_PREFIX . $callId;
}
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;
}
}
public function callGateToOpen(department_gates_o $gate): bool
{
$config = (array)$gate->config->value();
if (!isset($config['type']) || $config['type'] !== 'PHONE_CALL') {
return false;
}
if (!isset($config['phone_number'])) {
return false;
}
$phoneConfig = $config['phone_number'];
$normalized = $this->normalizePhoneCandidate($phoneConfig);
if ($normalized === null) {
return false;
}
[$countryCode, $phone] = $normalized;
// Use the configured threshold or default to 10 seconds.
$timeout = (int)($config['call_duration_threshold'] ?? 10);
// Use bird voice call to call the phone number, and then hang up after it is accepted to trigger the gate.
$client = new bird();
try {
$client->callGateAndHangupWhenAccepted(
(int)$countryCode,
(int)$phone,
$timeout,
);
return true;
} catch (\Throwable $e) {
// If the call fails, log the error and return false
$slack = new Slack();
$slack->send_message('Failed to call gate for phone ' . $countryCode . $phone . ': ' . $e->getMessage(), 'Bird Voice Call Webhooks');
return false;
}
}
protected function extractCallerPhone(array $payload): ?array
{
$candidates = [
$payload['phone_number'] ?? null,
$payload['source'] ?? null,
$payload['from'] ?? null,
];
foreach ($candidates as $candidate) {
$normalized = $this->normalizePhoneCandidate($candidate);
if ($normalized !== null) {
return $normalized;
}
}
return null;
}
protected function normalizePhoneCandidate(mixed $candidate): ?array
{
if (is_array($candidate)) {
// Determine the country code from the phone number if possible, otherwise default to 45 (Denmark)
$phone = $candidate['phone_number'] ?? null; // E.g. +4512345678
// If the phone number starts with a + followed by the country code and then the local phone number, we can extract the country code and local phone number
if ($phone !== null) {
$country = $this->extractCountryCodeFromPhoneNumber($phone);
return $this->normalizePhone((string)$phone, $country);
}
return null;
}
if (is_string($candidate) && trim($candidate) !== '') {
return $this->normalizePhone($candidate);
}
return null;
}
protected 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, '+')) {
$extractedCountry = $this->extractCountryCodeFromPhoneNumber($trimmed);
if ($extractedCountry !== null) {
$country = $extractedCountry;
$phone = substr($digits, strlen((string)$extractedCountry));
} elseif (strlen($digits) > 8) {
$country = (int)substr($digits, 0, 2);
$phone = substr($digits, 2);
}
} elseif ($country !== null && str_starts_with($digits, (string)$country) && strlen($digits) > 8) {
$phone = substr($digits, strlen((string)$country));
}
if ($country === null || $country <= 0 || $phone === '') {
return null;
}
return [$country, (int)$phone];
}
protected 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;
}
protected function say(bird $client, string $ws, string $ch, string $callId, string $text, bool $hangup = false): void
{
if ($callId !== '') {
try {
$payload = ['text' => $text];
if ($hangup) {
$payload['hangup'] = true;
}
$client->sayMessage($ws, $ch, $callId, $payload);
$this->respondJsonAndExit();
} catch (\Throwable $e) {
// If REST API fails, fallback to standard response
throw new \Exception('Failed to say message and hang up: ' . $e->getMessage(), 0, $e);
}
} else {
throw new \Exception('Call ID is missing, cannot say message');
}
}
protected function respondJsonAndExit(): void
{
header('Content-Type: application/json');
echo json_encode([]);
exit;
}
protected function extractCountryCodeFromPhoneNumber(string $phone): ?int
{
if (str_starts_with($phone, '+45')) {
return 45;
}
if (str_starts_with($phone, '+46')) {
return 46;
}
if (str_starts_with($phone, '+47')) {
return 47;
}
if (str_starts_with($phone, '+358')) {
return 358;
}
if (str_starts_with($phone, '+49')) {
return 49;
}
if (str_starts_with($phone, '+44')) {
return 44;
}
if (str_starts_with($phone, '+1')) {
return 1;
}
return null;
}
}