502 lines
17 KiB
PHP
502 lines
17 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;
|
|
}
|
|
|
|
try {
|
|
$gate->openGate();
|
|
$this->say($client, $ws, $ch, $callId, 'Aabner port: ' . $gate->name->value(), true);
|
|
return;
|
|
} catch (\Throwable) {
|
|
$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;
|
|
}
|
|
}
|
|
|
|
protected function extractCallerPhone(array $payload): ?array
|
|
{
|
|
$candidates = [
|
|
$payload['phone_number'] ?? null,
|
|
$payload['source'] ?? null,
|
|
$payload['from'] ?? null,
|
|
];
|
|
|
|
foreach ($candidates as $candidate) {
|
|
$normalized = department_gates_o::normalizePhoneCandidate($candidate);
|
|
if ($normalized !== null) {
|
|
return $normalized;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
}
|