Refactor birdVoiceWebhooksRoute to simplify gate handling by removing single-gate auto-selection logic, enhance multi-gate prompt text, and improve configuration parsing in callGateToOpen.

This commit is contained in:
Jeppe Bundgaard
2026-03-04 16:11:54 +01:00
parent d24a2c7cec
commit 5d1980768a
6 changed files with 53 additions and 407 deletions
+6 -9
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;
}
@@ -371,9 +371,6 @@ class bird
*/
$tmp = [
// Default values
'maxNumKeys' => 1,
'timeout' => 5,
'retries' => 1,
'input' => 'dtmf',
...$payload,
];
@@ -519,7 +516,7 @@ class bird
return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls/' . rawurlencode($callId) . '/gather';
}
private function extractId(array|object|string|null $response): ?string
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;
@@ -530,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;
@@ -3,7 +3,6 @@
namespace routes;
use classes\bird;
use classes\redis;
use classes\shelly;
use classes\slack;
use modules\shelly\helpers\shelly_device_switch;
@@ -46,14 +45,6 @@ class birdVoiceWebhooksRoute
$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');
$caller = $this->extractCallerPhone($payload);
if ($caller === null) {
$this->say($client, $ws, $ch, $callId, 'Vi kunne ikke identificere dit telefonnummer. Kontakt venligst support.');
@@ -61,30 +52,22 @@ class birdVoiceWebhooksRoute
}
[$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 (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 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;
}
// Ask the caller to select what department they want to use with digit(s)
$matchingGates = [];
foreach (
$departments as $deptId
) {
$deptGates = (new department_gates_o())->getDepartmentGates($deptId);
foreach ($deptGates as $gate) {
$matchingGates[] = $gate;
}
}
// 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;
@@ -98,65 +81,60 @@ class birdVoiceWebhooksRoute
]);
}
protected function handleGatherResponse(bird $client, string $ws, string $ch, string $callId, string $digits): void
protected function handleGatherResponse(bird $client, string $ws, string $ch, string $callId, string $digits, int $countryCode, int $phone): void
{
$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;
}
$matchingGates = $this->getCallerMatchingGates($countryCode, $phone);
$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.');
if (!isset($matchingGates[$index])) {
$this->say($client, $ws, $ch, $callId, 'Ugyldigt valg. Ring venligst op igen.', true);
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;
}
$gate = $matchingGates[$index];
if ($this->callGateToOpen($gate)) {
$this->say($client, $ws, $ch, $callId, 'Åbner port: ' . $gate->name->value());
$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.');
$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
{
$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) . '. Afslut med firkantstasten.';
$this->say($client, $ws, $ch, $callId, $text);
$selection = $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
{
// 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']);
@@ -167,17 +145,6 @@ class birdVoiceWebhooksRoute
protected function doesDepartmentHaveSeperateEntranceAndExitGates(department_gates_o $gate): bool
{
// Check if there's both an entrance and exit gate
$entranceGate = (new department_gates_o())->getEntranceGate($gate->department->value());
$exitGate = (new department_gates_o())->getExitGate($gate->department->value());
if ($entranceGate && $exitGate && $entranceGate->id !== $exitGate->id) {
return true;
}
return false;
}
public function callGateToOpen(department_gates_o $gate): bool
{
$config = (array)$gate->config->value();
@@ -301,14 +268,16 @@ class birdVoiceWebhooksRoute
return (new subusers_o())->getSubuserByPhone($countryCode, $phone) !== null;
}
protected function say(bird $client, string $ws, string $ch, string $callId, string $text): void
protected function say(bird $client, string $ws, string $ch, string $callId, string $text, bool $hangup = false): void
{
global $response;
if ($callId !== '') {
try {
$client->sayMessage($ws, $ch, $callId, [
'text' => $text,
]);
$payload = ['text' => $text];
if ($hangup) {
$payload['hangup'] = true;
}
$client->sayMessage($ws, $ch, $callId, $payload);
header('Content-Type: application/json');
echo json_encode([]);
exit;
@@ -317,7 +286,7 @@ class birdVoiceWebhooksRoute
throw new \Exception('Failed to say message and hang up: ' . $e->getMessage(), 0, $e);
}
} else {
$response->error('Call ID is missing, cannot say message', 400);
throw new \Exception('Call ID is missing, cannot say message');
}
}
@@ -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";