Merge pull request #137

Add Bird voice webhook handler for automated gate opening and tighten test call number handling
This commit is contained in:
Jeppe B
2026-03-09 09:47:01 +01:00
committed by GitHub
10 changed files with 609 additions and 347 deletions
+65
View File
@@ -220,6 +220,71 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' }
/bird/voice/calls/{id}/say:
post:
tags:
- Bird
summary: Say a message on an active voice call and hang up afterwards
operationId: birdSayOnVoiceCall
parameters:
- in: query
name: workspaceId
schema:
type: string
required: false
description: Bird Workspace identifier (falls back to module configuration if omitted)
- in: query
name: channelId
schema:
type: string
required: false
description: Bird Channel identifier (falls back to module configuration if omitted)
- in: path
name: id
required: true
schema:
type: string
description: Call identifier
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- text
properties:
text:
type: string
description: The text message to play via TTS
example: "The gate will open shortly."
locale:
type: string
description: The locale to use for the TTS voice (e.g. en-US)
example: "en-US"
voice:
type: string
description: The voice identifier to use
example: "male"
loop:
type: integer
description: Number of times to loop the message
example: 1
timeout:
type: integer
description: Timeout in seconds for the TTS action
example: 1
hangup:
type: boolean
description: Whether to hang up the call after the message finishes playing (defaults to true)
example: true
responses:
'200':
description: TTS action requested
content:
application/json:
schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' }
/bird/voice/calls/test-outbound:
post:
tags:
+151 -20
View File
@@ -116,7 +116,7 @@ class bird
if ($response === '' || $response === false || $response === null) {
return null;
}
$decoded = json_decode($response);
$decoded = json_decode($response, true);
return $decoded ?? $response;
}
@@ -197,7 +197,7 @@ class bird
if ($response === '' || $response === false || $response === null) {
return null;
}
$decoded = json_decode($response);
$decoded = json_decode($response, true);
return $decoded ?? $response;
}
@@ -229,7 +229,7 @@ class bird
if ($response === '' || $response === false || $response === null) {
return null;
}
$decoded = json_decode($response);
$decoded = json_decode($response, true);
return $decoded ?? $response;
}
@@ -264,7 +264,7 @@ class bird
if ($response === '' || $response === false || $response === null) {
return null;
}
$decoded = json_decode($response);
$decoded = json_decode($response, true);
return $decoded ?? $response;
}
@@ -296,6 +296,87 @@ class bird
return $this->sendPostRequest($base . '/' . rawurlencode($callId) . '/hangup', $payload);
}
public function sayMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
$this->logBirdAction('BIRD_VOICE_CALL_SAY', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
/**
* Example payload:
* https://api.bird.com/workspaces/{workspaceId}/channels/{channelId}/calls/{callId}/say
* {
* "text": "text",
* "locale": "en-US",
* "voice": "text",
* "loop": 1,
* "timeout": 1
* }
*/
if (!isset($payload['text']) || !is_string($payload['text']) || trim($payload['text']) === '') {
throw new Exception('The "text" field is required in the payload and must be a non-empty string');
}
$tmp = [
// Default values
'locale' => 'da-DK',
'voice' => 'text',
'loop' => 1,
'timeout' => 1,
...$payload,
];
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
* }
* }
*/
/**
* Example response:
* {
* "id": "123e4567-e89b-12d3-a456-426614174000",
* "callId": "123e4567-e89b-12d3-a456-426614174000",
* "callFlowId": "123e4567-e89b-12d3-a456-426614174000",
* "status": "text",
* "command": "text",
* "conditions": [
* {
* "variable": "keys",
* "operator": "eq",
* "value": "text"
* }
* ]
* }
*/
$tmp = [
// Default values
'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)));
@@ -315,6 +396,15 @@ class bird
}
public function createOutboundTestCallAndHangupWhenAccepted(string $workspaceId, string $channelId, array $options = []): array
{
return $this->executeCallAndHangupWhenAccepted($workspaceId, $channelId, $options, 'BIRD_TEST_OUTBOUND_CALL');
}
/**
* Internal method to place a call, wait for it to be accepted/ongoing, and then hang up.
* Used by both test outbound calls and actual gate calls.
*/
private function executeCallAndHangupWhenAccepted(string $workspaceId, string $channelId, array $options, string $logPrefix): array
{
$pollIntervalSeconds = max(1, (int)($options['pollIntervalSeconds'] ?? 2));
$maxPollSeconds = max(5, (int)($options['maxPollSeconds'] ?? 30));
@@ -323,20 +413,29 @@ 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;
// Ensure Bird terminates an unanswered call after we've stopped polling for it.
if (!isset($payload['timeout'])) {
$payload['timeout'] = $maxPollSeconds;
}
$this->logBirdAction(
'BIRD_TEST_OUTBOUND_CALL_START',
'workspace=' . $workspaceId . ' channel=' . $channelId . ' to=' . self::TEST_OUTBOUND_NUMBER_E164
$logPrefix . '_START',
'workspace=' . $workspaceId . ' channel=' . $channelId . ' to=' . $targetNumber
);
$createResponse = $this->createVoiceCall($workspaceId, $channelId, $payload);
$callId = $this->extractId($createResponse);
if ($callId === null) {
$this->logBirdAction('BIRD_TEST_OUTBOUND_CALL_NO_ID', 'Call accepted but no call ID returned', 0);
$this->logBirdAction($logPrefix . '_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.',
@@ -351,7 +450,7 @@ class bird
$status = $this->extractStatus($current);
$this->logBirdAction(
'BIRD_TEST_OUTBOUND_CALL_POLL',
$logPrefix . '_POLL',
'call=' . $callId . ' attempt=' . $attempt . '/' . $maxAttempts . ' status=' . ($status ?? 'unknown')
);
@@ -364,10 +463,10 @@ class bird
}
}
$hangupResponse = $this->hangupVoiceCall($workspaceId, $channelId, $callId, $hangupPayload);
$this->logBirdAction('BIRD_TEST_OUTBOUND_CALL_HANGUP_SENT', 'call=' . $callId . ' status=' . $status);
$this->logBirdAction($logPrefix . '_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,
@@ -384,13 +483,13 @@ class bird
$lastStatus = $this->extractStatus($lastCall);
$this->logBirdAction(
'BIRD_TEST_OUTBOUND_CALL_TIMEOUT',
$logPrefix . '_TIMEOUT',
'call=' . $callId . ' last_status=' . ($lastStatus ?? 'unknown'),
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,
@@ -405,7 +504,19 @@ class bird
return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls';
}
private function extractId(array|object|string|null $response): ?string
private function sayBase(string $workspaceId, string $channelId, string $callId): string
{
// https://api.bird.com/workspaces/{workspaceId}/channels/{channelId}/calls/{callId}/say
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';
}
protected function extractId(array|object|string|null $response): ?string
{
if (is_object($response) && isset($response->id) && is_string($response->id) && $response->id !== '') {
return $response->id;
@@ -416,7 +527,7 @@ class bird
return null;
}
private function extractStatus(array|object|string|null $response): ?string
protected function extractStatus(array|object|string|null $response): ?string
{
if (is_object($response) && isset($response->status) && is_string($response->status) && $response->status !== '') {
return $response->status;
@@ -512,7 +623,27 @@ class bird
// Logging must never break Bird request flow.
}
}
public function callGateAndHangupWhenAccepted(int $countryCode, int $phone, int $timeout)
{
$ws = $this->config->workplaceId->getVariableValue();
$ch = $this->config->channelId->getVariableValue();
$options = [
'to' => '+' . $countryCode . $phone,
'maxPollSeconds' => (int)$timeout,
];
$result = $this->executeCallAndHangupWhenAccepted($ws, $ch, $options, 'BIRD_GATE_CALL');
if (!($result['hangup_sent'] ?? false)) {
$msg = $result['message'] ?? 'Failed to call gate and hangup when accepted';
if ($result['timed_out_waiting_for_accepted'] ?? false) {
$msg = 'Timed out waiting for gate to accept call';
}
throw new Exception("Failed to call gate (+{$countryCode}{$phone}): " . $msg);
}
}
}
@@ -42,6 +42,8 @@ class department_gate_config
/**
* @throws Exception
* @description Valid example: ["type" => "PHONE_CALL", "phone_number" => "+1234567890", "call_duration_threshold" => 30]
* @description Valid example 2: {"type":"PHONE_CALL","phone_number":"+1234567890","call_duration_threshold":30}
*/
public function validate(): void
{
@@ -127,4 +127,44 @@ class department_gates_o extends db
return $gates;
}
public function getEntranceGate(int $department_id): ?department_gates_o
{
$entrance_gate_data = self::getFieldsWhere(
[
'department' => $department_id,
'is_entrance' => true,
'deleted_at' => null,
],
[
'id',
],
);
if (empty($entrance_gate_data)) {
return null;
}
return (new department_gates_o())->select((int)$entrance_gate_data[0]['id']);
}
public function getExitGate(int $department_id): ?department_gates_o
{
$exit_gate_data = self::getFieldsWhere(
[
'department' => $department_id,
'is_exit' => true,
'deleted_at' => null,
],
[
'id',
],
);
if (empty($exit_gate_data)) {
return null;
}
return (new department_gates_o())->select((int)$exit_gate_data[0]['id']);
}
}
@@ -122,6 +122,73 @@ class birdVoiceCallsRoute
'modules_bird_voice_calls_hangup' => 'Hang up a voice call by ID via Bird',
]);
// Say a message on an active call and hang up afterwards
$this->post('/bird/voice/calls/{id}/say', function () {
global $response;
// Permission: say a message on a voice call via Bird
self::requirePermission('modules_bird_voice_calls_say');
$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']);
// Bird's /say REST API endpoint plays the message.
// To fulfill the requirement of hanging up afterwards, we should ensure the call is terminated.
// Many Bird actions support a 'hangup' field in the payload to terminate the call after the action is complete.
if (!isset($payload['hangup'])) {
$payload['hangup'] = true;
}
$res = $client->sayMessage($ws, $ch, $id, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'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,7 +3,16 @@
namespace routes;
use classes\bird;
use classes\shelly;
use classes\slack;
use modules\shelly\helpers\shelly_device_switch;
use objects\department_gates_o;
use objects\department_relays_o;
use objects\departments_o;
use objects\groups_o;
use objects\subuser_grants_o;
use objects\subusers_o;
use objects\users_o;
use traits\bird_route_helpers_t;
use traits\route_t;
@@ -13,13 +22,13 @@ 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');
//self::requirePermission('modules_bird_voice_call_webhooks_trigger');
$client = new bird();
// Require workspace/channel per Bird API docs
$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'));
if ($ws === '') {
@@ -31,12 +40,280 @@ class birdVoiceWebhooksRoute
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'));
$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 ($digits !== '' && $callId !== '') {
$this->handleGatherResponse($client, $ws, $ch, $callId, $digits, $countryCode, $localPhone);
return;
}
$slack = new Slack();
$slack->send_message('Webhook received for incoming voice call: ' . json_encode($payload), 'Bird Voice Call Webhooks');
$response->success($res ?? ['status' => 'ok']);
$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 portåbning med landekode ' . $countryCode . ' og telefonnummer ' . $localPhone . '. Kontakt venligst support for at blive sat op.');
return;
}
// Identify caller's matching gates
$matchingGates = $this->getCallerMatchingGates($countryCode, $localPhone);
if (empty($matchingGates)) {
$this->say($client, $ws, $ch, $callId, 'Vi kunne ikke finde nogen porte tilknyttet din profil. Kontakt venligst support.');
return;
}
// Prompt for selection
$this->promptForGateSelection($client, $ws, $ch, $callId, $matchingGates);
}, [
'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): void
{
$matchingGates = $this->getCallerMatchingGates($countryCode, $phone);
$index = (int)$digits - 1;
if (!isset($matchingGates[$index])) {
$this->say($client, $ws, $ch, $callId, 'Ugyldigt valg. Ring venligst op igen.', true);
return;
}
$gate = $matchingGates[$index];
if ($this->callGateToOpen($gate)) {
$this->say($client, $ws, $ch, $callId, 'Åbner port: ' . $gate->name->value(), true);
} else {
$this->say($client, $ws, $ch, $callId, 'Kunne ikke aktivere relæet for ' . $gate->name->value() . '. Kontakt venligst support.', true);
}
}
protected function getCallerMatchingGates(int $countryCode, int $phone): array
{
$departments = $this->getCallerDepartments($countryCode, $phone);
$matchingGates = [];
$seenGateIds = [];
foreach ($departments as $deptId) {
$deptGates = (new department_gates_o())->getDepartmentGates($deptId);
foreach ($deptGates as $gate) {
if (!in_array((int)$gate->id, $seenGateIds)) {
$matchingGates[] = $gate;
$seenGateIds[] = (int)$gate->id;
}
}
}
return $matchingGates;
}
protected function promptForGateSelection(bird $client, string $ws, string $ch, string $callId, array $gates): void
{
$options = [];
foreach ($gates as $i => $gate) {
$options[] = 'Tast ' . ($i + 1) . ' for ' . $gate->name->value();
}
$text = 'Der blev fundet flere porte. ' . implode('. ', $options) . '. Afslut med firkantstasten.';
$this->say($client, $ws, $ch, $callId, $text);
header('Content-Type: application/json');
echo json_encode([]);
exit;
}
protected function getCallerDepartments(int $countryCode, int $phone): array
{
// Return all, since callers don't have specific department access, but the gates they have access to are determined by the departments they are in, and we want to include all possible gates for them.
$tmp = (new departments_o())->getFieldsWhere([
'visible' => 1,
], ['id']);
return array_map(function ($row) {
return (int)$row['id'];
}, $tmp);
}
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
{
global $response;
if ($callId !== '') {
try {
$payload = ['text' => $text];
if ($hangup) {
$payload['hangup'] = true;
}
$client->sayMessage($ws, $ch, $callId, $payload);
header('Content-Type: application/json');
echo json_encode([]);
exit;
} 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 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;
}
}
@@ -1,50 +0,0 @@
<?php
// Define app root for direct CLI execution
if (!defined('WD')) {
define('WD', dirname(__DIR__, 2));
}
require_once WD . '/classes/bird.php';
function ok($message): void { echo "\n\033[32m✔ $message\033[0m\n"; }
function fail($message): void { echo "\n\033[31m✖ $message\033[0m\n"; }
// Minimal fakes to avoid DB and network
class FakeBoolVar { private bool $v; public function __construct(bool $v){$this->v=$v;} public function isTrue(): bool { return $this->v; } public function getVariableValue(): string { return $this->v ? 'true' : 'false'; } }
class FakeStringVar { private string $v; public function __construct(string $v){$this->v=$v;} public function getVariableValue(): string { return $this->v; } }
class FakeBirdConfig { public $enabled; public $api_key; public $server_url; }
class TestBird extends \classes\bird {
public function __construct(bool $enabled, string $api_key, string $server_url)
{
// Do not call parent constructor to avoid DB access
$cfg = new FakeBirdConfig();
$cfg->enabled = new FakeBoolVar($enabled);
$cfg->api_key = new FakeStringVar($api_key);
$cfg->server_url = new FakeStringVar($server_url);
$this->config = $cfg;
}
protected function doHttpRequest(string $method, string $url, array $headers, string $body = ''): array
{
return ['status_code' => 200, 'body' => '{}'];
}
}
// Positive checks
$b = new TestBird(true, 'test_token_123', 'https://api.bird.com');
try { $b->requireModuleEnabled(); ok('Module enabled check passes when enabled=true'); } catch (\Exception $e) { fail('Module enabled should pass when enabled=true'); }
try { $b->requireValidApiKey(); ok('API key check passes when key provided'); } catch (\Exception $e) { fail('API key should be considered valid when provided'); }
try { $b->requireValidServerURL(); ok('Server URL check passes when URL provided'); } catch (\Exception $e) { fail('Server URL should be considered valid when provided'); }
// Negative checks
$thrown = false; try { (new TestBird(false, 'x', 'https://api.bird.com'))->requireModuleEnabled(); } catch (\Exception $e) { $thrown = true; }
if ($thrown) { ok('Module enabled guard throws when disabled'); } else { fail('Module enabled guard should throw when disabled'); }
$thrown = false; try { (new TestBird(true, '', 'https://api.bird.com'))->requireValidApiKey(); } catch (\Exception $e) { $thrown = true; }
if ($thrown) { ok('API key guard throws when key is empty'); } else { fail('API key guard should throw when key is empty'); }
$thrown = false; try { (new TestBird(true, 'x', ''))->requireValidServerURL(); } catch (\Exception $e) { $thrown = true; }
if ($thrown) { ok('Server URL guard throws when URL is empty'); } else { fail('Server URL guard should throw when URL is empty'); }
echo "\nBirdConfigTest completed.\n";
@@ -1,57 +0,0 @@
<?php
// Define app root for direct CLI execution
if (!defined('WD')) {
define('WD', dirname(__DIR__, 2));
}
require_once WD . '/classes/bird.php';
function ok($message): void { echo "\n\033[32m✔ $message\033[0m\n"; }
function fail($message): void { echo "\n\033[31m✖ $message\033[0m\n"; }
class FakeBoolVar { private bool $v; public function __construct(bool $v){$this->v=$v;} public function isTrue(): bool { return $this->v; } }
class FakeStringVar { private string $v; public function __construct(string $v){$this->v=$v;} public function getVariableValue(): string { return $this->v; } }
class FakeBirdConfig { public $enabled; public $api_key; public $server_url; }
class InspectableBird extends \classes\bird
{
public array $last = [];
public function __construct()
{
// Inject fake config to avoid DB
$cfg = new FakeBirdConfig();
$cfg->enabled = new FakeBoolVar(true);
$cfg->api_key = new FakeStringVar('secret_token');
$cfg->server_url = new FakeStringVar('https://api.bird.com');
$this->config = $cfg;
}
protected function doHttpRequest(string $method, string $url, array $headers, string $body = ''): array
{
$this->last = compact('method', 'url', 'headers', 'body');
// Return 200 OK with empty JSON object
return ['status_code' => 200, 'body' => '{}'];
}
}
$bird = new InspectableBird();
$payload = ['alpha' => 1, 'beta' => 'two'];
$bird->sendPostRequest('/v1/demo', $payload);
$expectedUrl = 'https://api.bird.com/v1/demo';
if (($bird->last['url'] ?? '') === $expectedUrl) { ok('URL is correctly composed with base + endpoint'); } else { fail('URL should be ' . $expectedUrl . ' but was ' . ($bird->last['url'] ?? '<none>')); }
$headers = $bird->last['headers'] ?? [];
$hasAuth = false; $hasJson = false;
foreach ($headers as $h) {
if (stripos($h, 'authorization:') === 0 && str_contains($h, 'AccessKey secret_token')) { $hasAuth = true; }
if (strcasecmp($h, 'Content-Type: application/json') === 0) { $hasJson = true; }
}
if ($hasAuth) { ok('Authorization header includes AccessKey token'); } else { fail('Missing or invalid Authorization header'); }
if ($hasJson) { ok('Content-Type header is application/json'); } else { fail('Missing Content-Type: application/json header'); }
$expectedBody = json_encode($payload);
if (($bird->last['body'] ?? '') === $expectedBody) { ok('Request body is JSON-encoded as expected'); } else { fail('Request body JSON mismatch'); }
echo "\nBirdHttpHeadersTest completed.\n";
@@ -1,126 +0,0 @@
<?php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
require_once WD . '/classes/bird.php';
use classes\bird as BirdClient;
class DummyVar2
{
public function __construct(private mixed $value) {}
public function isTrue(): bool { return $this->value === true || $this->value === 'true' || $this->value === 1 || $this->value === '1'; }
public function getVariableValue(): mixed { return $this->value; }
}
class DummyConfig2
{
public DummyVar2 $enabled;
public DummyVar2 $api_key;
public DummyVar2 $server_url;
public DummyVar2 $workplaceId;
public function __construct()
{
$this->enabled = new DummyVar2('true');
$this->api_key = new DummyVar2('test_api_key');
$this->server_url = new DummyVar2('https://example.test');
$this->workplaceId = new DummyVar2('test_workspace_id');
}
public function getModuleName(): string { return 'bird'; }
}
class FakeBird2 extends BirdClient
{
public array $last = [];
public function __construct()
{
// Bypass DB-backed config classes with our dummy config
$this->config = new DummyConfig2();
}
protected function doHttpRequest(string $method, string $url, array $headers, string $body = ''): array
{
$this->last = compact('method','url','headers','body');
// Return a canned success response
$resp = ['ok' => true, 'id' => 'flash_123'];
return ['status_code' => 200, 'body' => json_encode($resp)];
}
}
function assert_true2($cond, $msg)
{
if ($cond) {
echo "$msg\n";
} else {
echo "$msg\n"; exit(1);
}
}
// Test: list numbers (GET /workspaces/{ws}/numbers)
$client = new FakeBird2();
$res = $client->listNumbers('test_workspace_id', ['limit' => 10]);
assert_true2(is_object($res) || is_array($res), 'Numbers response is JSON-decodable');
assert_true2(str_starts_with($client->last['url'], 'https://example.test/workspaces/test_workspace_id/numbers'), 'Numbers GET URL composed correctly');
assert_true2(str_contains($client->last['url'], 'limit=10'), 'Query string encoded correctly');
assert_true2($client->last['method'] === 'GET', 'HTTP method is GET (numbers)');
assert_true2(in_array('Authorization: AccessKey test_api_key', $client->last['headers'], true), 'Authorization header is set (numbers)');
assert_true2(empty($client->last['body']), 'GET body is empty (numbers)');
// Test: get number (GET /workspaces/{ws}/numbers/{id})
$clientNum = new FakeBird2();
$resNum = $clientNum->getNumber('test_workspace_id', 'num_123');
assert_true2(str_ends_with($clientNum->last['url'], '/workspaces/test_workspace_id/numbers/num_123'), 'Get number URL composed correctly');
assert_true2($clientNum->last['method'] === 'GET', 'HTTP method is GET (get number)');
// Test: delete number (DELETE /workspaces/{ws}/numbers/{id})
$clientDel = new FakeBird2();
$resDel = $clientDel->deleteNumber('test_workspace_id', 'num_123');
assert_true2(str_ends_with($clientDel->last['url'], '/workspaces/test_workspace_id/numbers/num_123'), 'Delete number URL composed correctly');
assert_true2($clientDel->last['method'] === 'DELETE', 'HTTP method is DELETE (delete number)');
// Test: create flash call (POST /voice/flash-calls)
$client2 = new FakeBird2();
$payload = [
'to' => '+4511122233',
'from' => '+4599988877',
'code' => '1234',
];
$res2 = $client2->sendPostRequest('/voice/flash-calls', $payload);
assert_true2(is_object($res2) || is_array($res2), 'Flash call response is JSON-decodable');
assert_true2(str_starts_with($client2->last['url'], 'https://example.test/voice/flash-calls'), 'Flash POST URL composed correctly');
assert_true2($client2->last['method'] === 'POST', 'HTTP method is POST (flash)');
assert_true2(in_array('Authorization: AccessKey test_api_key', $client2->last['headers'], true), 'Authorization header is set (flash)');
assert_true2(json_decode($client2->last['body'], true)['code'] === '1234', 'Flash POST body encoded correctly');
// Test: list flash calls (GET /voice/flash-calls)
$client3 = new FakeBird2();
$client3->sendGetRequest('/voice/flash-calls', ['page' => 2]);
assert_true2(str_starts_with($client3->last['url'], 'https://example.test/voice/flash-calls'), 'Flash list GET URL composed correctly');
assert_true2(str_contains($client3->last['url'], 'page=2'), 'Flash list query encoded correctly');
assert_true2($client3->last['method'] === 'GET', 'HTTP method is GET (flash list)');
// Test: get flash call by id (GET /voice/flash-calls/{id})
$client4 = new FakeBird2();
$client4->sendGetRequest('/voice/flash-calls/flash_123');
assert_true2(str_ends_with($client4->last['url'], '/voice/flash-calls/flash_123'), 'Flash get-by-id URL composed correctly');
assert_true2($client4->last['method'] === 'GET', 'HTTP method is GET (flash by id)');
// Test: end flash call by id (POST /voice/flash-calls/{id})
$client5 = new FakeBird2();
$payloadEnd = [ 'result' => 'success' ];
$client5->sendPostRequest('/voice/flash-calls/flash_123', $payloadEnd);
assert_true2(str_ends_with($client5->last['url'], '/voice/flash-calls/flash_123'), 'Flash end-by-id URL composed correctly');
assert_true2($client5->last['method'] === 'POST', 'HTTP method is POST (flash end by id)');
assert_true2(json_decode($client5->last['body'], true)['result'] === 'success', 'Flash end-by-id body encoded correctly');
// Test: end flash call by numbers (POST /voice/flash-calls/end)
$client6 = new FakeBird2();
$payloadEndByNums = [ 'from' => '+4599988877', 'to' => '+4511122233' ];
$client6->sendPostRequest('/voice/flash-calls/end', $payloadEndByNums);
assert_true2(str_ends_with($client6->last['url'], '/voice/flash-calls/end'), 'Flash end-by-numbers URL composed correctly');
assert_true2($client6->last['method'] === 'POST', 'HTTP method is POST (flash end by numbers)');
assert_true2(json_decode($client6->last['body'], true)['from'] === '+4599988877', 'Flash end-by-numbers body encoded correctly');
echo "NumbersAndFlashCallsApiTest completed.\n";
@@ -1,87 +0,0 @@
<?php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
require_once WD . '/classes/bird.php';
use classes\bird as BirdClient;
class DummyVar
{
public function __construct(private mixed $value) {}
public function isTrue(): bool { return $this->value === true || $this->value === 'true' || $this->value === 1 || $this->value === '1'; }
public function getVariableValue(): mixed { return $this->value; }
}
class DummyConfig
{
public DummyVar $enabled;
public DummyVar $api_key;
public DummyVar $server_url;
public function __construct()
{
$this->enabled = new DummyVar('true');
$this->api_key = new DummyVar('test_api_key');
$this->server_url = new DummyVar('https://example.test');
}
public function getModuleName(): string { return 'bird'; }
}
class FakeBird extends BirdClient
{
public array $last = [];
public function __construct()
{
// Bypass DB-backed config classes with our dummy config
$this->config = new DummyConfig();
}
protected function doHttpRequest(string $method, string $url, array $headers, string $body = ''): array
{
$this->last = compact('method','url','headers','body');
// Return a canned success response
$resp = ['id' => 'call_123', 'status' => 'queued'];
return ['status_code' => 200, 'body' => json_encode($resp)];
}
}
function assert_true($cond, $msg)
{
if ($cond) {
echo "$msg\n";
} else {
echo "$msg\n"; exit(1);
}
}
// Test: create call (POST /workspaces/{ws}/channels/{ch}/calls)
$client = new FakeBird();
$payload = [
'to' => '+4511122233',
'from' => '+4599988877',
'tts' => [ 'message' => 'Hello from test' ],
];
$res = $client->createVoiceCall('ws_123', 'ch_123', $payload);
assert_true(is_object($res) || is_array($res), 'Response is JSON-decodable');
assert_true(str_contains($client->last['url'], '/workspaces/ws_123/channels/ch_123/calls'), 'POST URL composed correctly');
assert_true($client->last['method'] === 'POST', 'HTTP method is POST');
assert_true(in_array('Authorization: AccessKey test_api_key', $client->last['headers'], true), 'Authorization header is set');
assert_true(json_decode($client->last['body'], true)['to'] === '+4511122233', 'POST body encoded correctly');
// Test: get call (GET /workspaces/{ws}/channels/{ch}/calls/{id})
$client2 = new FakeBird();
$res2 = $client2->getVoiceCall('ws_123', 'ch_123', 'call_123');
assert_true(str_contains($client2->last['url'], '/workspaces/ws_123/channels/ch_123/calls/call_123'), 'GET URL composed correctly');
assert_true($client2->last['method'] === 'GET', 'HTTP method is GET');
assert_true(empty($client2->last['body']), 'GET body is empty');
// Test: hangup with empty payload encodes an object body ({}), not array body ([]).
$client3 = new FakeBird();
$client3->hangupVoiceCall('ws_123', 'ch_123', 'call_123');
assert_true(str_ends_with($client3->last['url'], '/workspaces/ws_123/channels/ch_123/calls/call_123/hangup'), 'Hangup URL composed correctly');
assert_true($client3->last['method'] === 'POST', 'Hangup uses POST');
assert_true(trim((string)$client3->last['body']) === '{}', 'Hangup empty body is encoded as JSON object');
echo "VoiceCallsApiTest completed.\n";