Add Bird API integration with voice call and flash call support

- Implement Bird API client (`bird.php`) for handling HTTP requests to Bird services.
- Add routes for voice and flash call management (`birdVoiceFlashCallsRoute.php`, `birdNumbersRoute.php`).
- Introduce test cases for voice calls, flash calls, and numbers (`VoiceCallsApiTest.php`, `NumbersAndFlashCallsApiTest.php`).
- Include configuration management classes and APIs for enabling the Bird module and managing API keys (`bird_c.php`).
- Provide OpenAPI specifications for flash call endpoints (`bird-flash-calls.md`).
This commit is contained in:
Jeppe Bundgaard
2026-02-19 12:19:36 +01:00
parent 6d09e5449e
commit 5a59562e35
16 changed files with 1479 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
<?php
namespace classes;
require_once WD . '/modules/bird/bird_c.php';
use bird\bird_c;
use Exception;
class bird
{
/**
* Configuration of the Bird module
* @var bird_c|object
*/
public $config;
public function __construct()
{
$this->config = new bird_c();
}
/**
* Ensure module is enabled
* @throws Exception
*/
function requireModuleEnabled(): void
{
if (!$this->config->enabled->isTrue()) {
throw new Exception('The bird module is not enabled');
}
}
/**
* Ensure API key is present
* @throws Exception
*/
function requireValidApiKey(): void
{
$k = $this->config->api_key->getVariableValue();
if ($k === null || $k === '') {
throw new Exception('Invalid API key defined in the config (bird_api_key_c)');
}
}
/**
* Ensure base URL is defined
* @throws Exception
*/
function requireValidServerURL(): void
{
$u = $this->config->server_url->getVariableValue();
if ($u === null || $u === '') {
throw new Exception('Invalid server URL defined in the config (bird_server_url_c)');
}
}
/**
* Send a request to Bird API
* @param string $endpoint e.g. "/v1/devices"
* @param array $data request body
* @param string $method HTTP method (currently only POST supported)
* @return object|array|null
* @throws Exception
*/
function sendRequest(string $endpoint, array $data = [], string $method = 'POST'): object|array|null
{
$this->requireModuleEnabled();
$this->requireValidApiKey();
$this->requireValidServerURL();
return match (strtoupper($method)) {
'POST' => $this->sendPostRequest($endpoint, $data),
'GET' => $this->sendGetRequest($endpoint, $data),
default => throw new Exception('Invalid request method'),
};
}
/**
* Send POST request
* @param string $endpoint
* @param array $data
* @return array|object|null
* @throws Exception
*/
function sendPostRequest(string $endpoint, array $data): array|object|null
{
$this->requireModuleEnabled();
$this->requireValidApiKey();
$this->requireValidServerURL();
$url = rtrim($this->config->server_url->getVariableValue(), '/') . $endpoint;
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->config->api_key->getVariableValue(),
];
$body = json_encode($data);
$result = $this->doHttpRequest('POST', $url, $headers, $body);
$status = (int)($result['status_code'] ?? 0);
$response = $result['body'] ?? '';
if ($status >= 400) {
throw new Exception('Bird API request failed with status ' . $status);
}
if ($response === '' || $response === false || $response === null) {
return null;
}
$decoded = json_decode($response);
return $decoded ?? $response;
}
/**
* Low-level HTTP transport (curl). Tests can override this to stub network.
* @param string $method
* @param string $url
* @param array $headers
* @param string $body
* @return array{status_code:int, body:string|false}
* @throws Exception
*/
protected function doHttpRequest(string $method, string $url, array $headers, string $body = ''): array
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$upper = strtoupper($method);
if ($upper === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
} else {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $upper);
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
$err = curl_error($ch);
curl_close($ch);
throw new Exception('cURL error: ' . $err);
}
curl_close($ch);
return [
'status_code' => (int)$code,
'body' => $resp,
];
}
/**
* Send GET request
* @param string $endpoint
* @param array $query
* @return array|object|null
* @throws Exception
*/
function sendGetRequest(string $endpoint, array $query = []): array|object|null
{
$this->requireModuleEnabled();
$this->requireValidApiKey();
$this->requireValidServerURL();
$base = rtrim($this->config->server_url->getVariableValue(), '/');
$url = $base . $endpoint;
if (!empty($query)) {
$qs = http_build_query($query);
$url .= (str_contains($url, '?') ? '&' : '?') . $qs;
}
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->config->api_key->getVariableValue(),
];
$result = $this->doHttpRequest('GET', $url, $headers);
$status = (int)($result['status_code'] ?? 0);
$response = $result['body'] ?? '';
if ($status >= 400) {
throw new Exception('Bird API request failed with status ' . $status);
}
if ($response === '' || $response === false || $response === null) {
return null;
}
$decoded = json_decode($response);
return $decoded ?? $response;
}
}
@@ -0,0 +1,34 @@
# Flash Calling API
This API enables users to leverage flash calls for quick and efficient number validation or authentication processes.
This API initiates a call to the destination number. If the call is answered, it will be immediately terminated.
## Create flash call
> Create a new channel flash call
```json
{"openapi":"3.0.3","info":{"title":"Channels","version":"v1"},"tags":[],"servers":[{"url":"https://api.bird.com","description":"Production API"}],"security":[{"accessKey":[]}],"components":{"securitySchemes":{"accessKey":{"description":"Uses the Authorization header: 'AccessKey ' followed by your access key token (e.g., 'Authorization: AccessKey AbCdEfGhIjKlMnOpQrStUvWxYzAbCdEfGhIj')","scheme":"AccessKey","type":"http"}},"schemas":{"CreateFlashCall":{"type":"object","title":"ChannelFlashCallCreate","additionalProperties":false,"properties":{"from":{"type":"string"},"to":{"type":"string"},"ringTimeout":{"type":"integer","minimum":3,"maximum":120,"default":30}},"required":["to"]},"FlashCall":{"type":"object","title":"ChannelFlashCall","additionalProperties":false,"properties":{"id":{"$ref":"#/components/schemas/Id-2"},"channelId":{"$ref":"#/components/schemas/ChannelId"},"from":{"type":"string"},"to":{"type":"string"},"status":{"$ref":"#/components/schemas/Status-4"},"ringTimeout":{"type":"integer"},"attemptPrice":{"type":"object","properties":{"amount":{"type":"integer"},"exponent":{"type":"integer"},"currency":{"type":"string"}}},"connectionPrice":{"type":"object","properties":{"amount":{"type":"integer"},"exponent":{"type":"integer"},"currency":{"type":"string"}}},"reason":{"type":"string"},"duration":{"type":"integer"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"ringingAt":{"type":"string","format":"date-time"},"answeredAt":{"type":"string","format":"date-time"},"endedAt":{"type":"string","format":"date-time"}},"required":["id","channelId","from","to","status","createdAt","updatedAt"]},"Id-2":{"type":"string","format":"uuid"},"ChannelId":{"type":"string","format":"uuid"},"Status-4":{"type":"string","enum":["accepted","starting","ringing","ongoing","completed","no-answer","busy","failed","cancelled","scheduled"]},"RequestError":{"type":"object","properties":{"code":{"type":"string","description":"A unique code that identifies the error. This code can be used to programmatically identify the error.\n"},"message":{"type":"string","description":"A human-readable message that describes the error. An example is 'The requested resource does not exist: channel not found'.\n"}},"required":["code","message"]},"ValidationError":{"type":"object","properties":{"code":{"type":"string","description":"A unique code that identifies the error. This code can be used to programmatically identify the error.\n"},"message":{"type":"string","description":"A human-readable message that describes the error. An example is 'The requested resource does not exist: channel not found'.\n"},"details":{"type":"object","description":"Additional details about the error. This object can contain any additional information that may be useful for debugging.\n","additionalProperties":{"type":"array","items":{"type":"string"}}}},"required":["code","message"]}},"responses":{"requestError":{"description":"The request did not pass validation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestError"}}}},"validationError":{"description":"The request did not pass validation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}}}}},"paths":{"/workspaces/{workspaceId}/channels/{channelId}/flashcalls":{"post":{"summary":"Create flash call","operationId":"createChannelFlashCall","description":"Create a new channel flash call","tags":["channel_flashcall"],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateFlashCall"}}}},"responses":{"202":{"description":"Flash Call was accepted for processing","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FlashCall"}}}},"400":{"$ref":"#/components/responses/requestError"},"404":{"$ref":"#/components/responses/requestError"},"422":{"$ref":"#/components/responses/validationError"}}}}}}
```
To terminate a flash call resource, or update the result of the verification, user can do a POST to a flash call resource, using this endpoint.<br>
## End flash call
> Completes the channel flash call
```json
{"openapi":"3.0.3","info":{"title":"Channels","version":"v1"},"tags":[],"servers":[{"url":"https://api.bird.com","description":"Production API"}],"security":[{"accessKey":[]}],"components":{"securitySchemes":{"accessKey":{"description":"Uses the Authorization header: 'AccessKey ' followed by your access key token (e.g., 'Authorization: AccessKey AbCdEfGhIjKlMnOpQrStUvWxYzAbCdEfGhIj')","scheme":"AccessKey","type":"http"}},"schemas":{"HangupFlashCall":{"type":"object","title":"ChannelFlashCallHangup","additionalProperties":false,"properties":{"receivedCli":{"type":"string"},"result":{"type":"string","enum":["unknown","verified","canceled","timeout","wrong_cli"]}},"required":["result"]},"RequestError":{"type":"object","properties":{"code":{"type":"string","description":"A unique code that identifies the error. This code can be used to programmatically identify the error.\n"},"message":{"type":"string","description":"A human-readable message that describes the error. An example is 'The requested resource does not exist: channel not found'.\n"}},"required":["code","message"]},"ValidationError":{"type":"object","properties":{"code":{"type":"string","description":"A unique code that identifies the error. This code can be used to programmatically identify the error.\n"},"message":{"type":"string","description":"A human-readable message that describes the error. An example is 'The requested resource does not exist: channel not found'.\n"},"details":{"type":"object","description":"Additional details about the error. This object can contain any additional information that may be useful for debugging.\n","additionalProperties":{"type":"array","items":{"type":"string"}}}},"required":["code","message"]}},"responses":{"requestError":{"description":"The request did not pass validation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestError"}}}},"validationError":{"description":"The request did not pass validation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}}}}},"paths":{"/workspaces/{workspaceId}/channels/{channelId}/flashcalls/{callId}":{"post":{"summary":"End flash call","operationId":"endChannelFlashCall","description":"Completes the channel flash call","tags":["channel_flashcall"],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HangupFlashCall"}}}},"responses":{"202":{"description":"Hangup Flash Call was accepted for processing","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HangupFlashCall"}}}},"400":{"$ref":"#/components/responses/requestError"},"404":{"$ref":"#/components/responses/requestError"},"422":{"$ref":"#/components/responses/validationError"}}}}}}
```
\
User can also chose to hangup calls, using a combination of From and To numbers:<br>
## End flash call
> Completes the channel flash call
```json
{"openapi":"3.0.3","info":{"title":"Channels","version":"v1"},"tags":[],"servers":[{"url":"https://api.bird.com","description":"Production API"}],"security":[{"accessKey":[]}],"components":{"securitySchemes":{"accessKey":{"description":"Uses the Authorization header: 'AccessKey ' followed by your access key token (e.g., 'Authorization: AccessKey AbCdEfGhIjKlMnOpQrStUvWxYzAbCdEfGhIj')","scheme":"AccessKey","type":"http"}},"schemas":{"HangupFlashCallFromTo":{"anyOf":[{"$ref":"#/components/schemas/HangupFlashCall"},{"type":"object","properties":{"from":{"type":"string","description":"The phone number that initiated the call."},"to":{"type":"string","description":"The phone number that received the call."}},"required":["from","to"]}]},"HangupFlashCall":{"type":"object","title":"ChannelFlashCallHangup","additionalProperties":false,"properties":{"receivedCli":{"type":"string"},"result":{"type":"string","enum":["unknown","verified","canceled","timeout","wrong_cli"]}},"required":["result"]},"RequestError":{"type":"object","properties":{"code":{"type":"string","description":"A unique code that identifies the error. This code can be used to programmatically identify the error.\n"},"message":{"type":"string","description":"A human-readable message that describes the error. An example is 'The requested resource does not exist: channel not found'.\n"}},"required":["code","message"]},"ValidationError":{"type":"object","properties":{"code":{"type":"string","description":"A unique code that identifies the error. This code can be used to programmatically identify the error.\n"},"message":{"type":"string","description":"A human-readable message that describes the error. An example is 'The requested resource does not exist: channel not found'.\n"},"details":{"type":"object","description":"Additional details about the error. This object can contain any additional information that may be useful for debugging.\n","additionalProperties":{"type":"array","items":{"type":"string"}}}},"required":["code","message"]}},"responses":{"requestError":{"description":"The request did not pass validation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestError"}}}},"validationError":{"description":"The request did not pass validation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}}}}},"paths":{"/workspaces/{workspaceId}/channels/{channelId}/flashcalls/hangup":{"post":{"summary":"End flash call","operationId":"endChannelFlashCallFromTo","description":"Completes the channel flash call","tags":["channel_flashcall"],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HangupFlashCallFromTo"}}}},"responses":{"202":{"description":"Hangup Flash Call was accepted for processing","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HangupFlashCallFromTo"}}}},"400":{"$ref":"#/components/responses/requestError"},"404":{"$ref":"#/components/responses/requestError"},"422":{"$ref":"#/components/responses/validationError"}}}}}}
```
@@ -0,0 +1,49 @@
<?php
namespace bird;
require_once WD . '/traits/module_config_t.php';
require_once WD . '/modules/bird/config/bird_enabled_c.php';
require_once WD . '/modules/bird/config/bird_api_key_c.php';
require_once WD . '/modules/bird/config/bird_server_url_c.php';
use bird\config\bird_enabled_c;
use bird\config\bird_api_key_c;
use bird\config\bird_server_url_c;
use traits\module_config_t;
class bird_c
{
use module_config_t;
/**
* Whether the Bird module is enabled
* @var bird_enabled_c
*/
public bird_enabled_c $enabled;
/**
* API key/token for Bird API (secret)
* @var bird_api_key_c
*/
public bird_api_key_c $api_key;
/**
* Base server URL for Bird API
* @var bird_server_url_c
*/
public bird_server_url_c $server_url;
public function __construct()
{
$this->setupConfig('bird');
$this->allowUpdate([
bird_enabled_c::class,
bird_api_key_c::class,
bird_server_url_c::class,
]);
$this->enabled = new bird_enabled_c();
$this->api_key = new bird_api_key_c();
$this->server_url = new bird_server_url_c();
}
}
@@ -0,0 +1,31 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_api_key_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'bird',
'api_key',
'string',
false,
null,
'The API key/token for authenticating against Bird API',
'sk_test_XXXXXXXXXXXXXXXXXXXXXXXX',
true,
''
);
}
}
@@ -0,0 +1,31 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'bird',
'enabled',
'bool',
true,
null,
'Whether the Bird module is enabled or not',
'true',
false,
'false'
);
}
}
@@ -0,0 +1,31 @@
<?php
namespace bird\config;
require_once WD . '/traits/module_config_variable_t.php';
use Exception;
use traits\module_config_variable;
class bird_server_url_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'bird',
'server_url',
'string',
true,
null,
'The base URL for the Bird API',
'https://api.bird.com',
false,
'https://api.bird.com'
);
}
}
@@ -0,0 +1,27 @@
<?php
namespace routes;
use classes\bird;
use traits\route_t;
class birdNumbersRoute
{
use route_t;
public function run(): void
{
// List owned numbers
$this->get('/bird/numbers', function () {
global $response;
// Permission: list numbers via Bird
self::requirePermission('modules_bird_numbers_list');
$client = new bird();
$query = $this->getParametersAsArray();
$res = $client->sendGetRequest('/numbers', $query);
$response->success($res ?? []);
}, [
'modules_bird_numbers_list' => 'List your numbers via Bird',
]);
}
}
@@ -0,0 +1,99 @@
<?php
namespace routes;
use classes\bird;
use traits\route_t;
class birdVoiceCallsRoute
{
use route_t;
public function run(): void
{
// Create a voice call
$this->post('/bird/voice/calls', function () {
global $response;
// Permission: create/place a voice call via Bird
self::requirePermission('modules_bird_voice_calls_create');
$client = new bird();
// Require workspace/channel per Bird API docs
$ws = (string)($this->fromRequest('workspaceId') ?? '');
$ch = (string)($this->fromRequest('channelId') ?? '');
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/calls';
$payload = $this->getParametersAsArray();
unset($payload['workspaceId'], $payload['channelId']);
$res = $client->sendPostRequest($base, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_create' => 'Create/place a voice call via Bird',
]);
// List voice calls (passthrough of query filters)
$this->get('/bird/voice/calls', function () {
global $response;
// Permission: list voice calls via Bird
self::requirePermission('modules_bird_voice_calls_list');
$client = new bird();
$ws = (string)($this->fromQuery('workspaceId') ?? '');
$ch = (string)($this->fromQuery('channelId') ?? '');
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/calls';
$query = $this->getParametersAsArray();
unset($query['workspaceId'], $query['channelId']);
$res = $client->sendGetRequest($base, $query);
$response->success($res ?? []);
}, [
'modules_bird_voice_calls_list' => 'List voice calls via Bird',
]);
// Get a specific call by ID
$this->get('/bird/voice/calls/{id}', function () {
global $response;
// Permission: get a specific voice call via Bird
self::requirePermission('modules_bird_voice_calls_get');
$client = new bird();
$id = (string)$this->fromRoute('id');
if ($id === null || $id === '') {
$response->error('Missing id', 400);
}
$ws = (string)($this->fromQuery('workspaceId') ?? '');
$ch = (string)($this->fromQuery('channelId') ?? '');
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/calls';
$res = $client->sendGetRequest($base . '/' . rawurlencode($id));
$response->success($res ?? []);
}, [
'modules_bird_voice_calls_get' => 'Get a voice call by ID via Bird',
]);
// Hang up an active call by ID
$this->post('/bird/voice/calls/{id}/hangup', function () {
global $response;
// Permission: hang up a specific voice call via Bird
self::requirePermission('modules_bird_voice_calls_hangup');
$client = new bird();
$id = (string)$this->fromRoute('id');
if ($id === null || $id === '') {
$response->error('Missing id', 400);
}
$ws = (string)($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId') ?? '');
$ch = (string)($this->fromRequest('channelId') ?? $this->fromQuery('channelId') ?? '');
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/calls';
$res = $client->sendPostRequest($base . '/' . rawurlencode($id) . '/hangup', []);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_hangup' => 'Hang up a voice call by ID via Bird',
]);
}
}
@@ -0,0 +1,123 @@
<?php
namespace routes;
use classes\bird;
use traits\route_t;
class birdVoiceFlashCallsRoute
{
use route_t;
public function run(): void
{
// Create a flash call
$this->post('/bird/voice/flash-calls', function () {
global $response;
// Permission: create/place a flash call via Bird
self::requirePermission('modules_bird_voice_flash_calls_create');
$client = new bird();
// Require workspace/channel per Bird API docs
$ws = (string)($this->fromRequest('workspaceId') ?? '');
$ch = (string)($this->fromRequest('channelId') ?? '');
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls';
$payload = $this->getParametersAsArray();
unset($payload['workspaceId'], $payload['channelId']);
$res = $client->sendPostRequest($base, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_flash_calls_create' => 'Create/place a flash call via Bird',
]);
// List flash calls
$this->get('/bird/voice/flash-calls', function () {
global $response;
// Permission: list flash calls via Bird
self::requirePermission('modules_bird_voice_flash_calls_list');
$client = new bird();
$ws = (string)($this->fromQuery('workspaceId') ?? '');
$ch = (string)($this->fromQuery('channelId') ?? '');
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls';
$query = $this->getParametersAsArray();
unset($query['workspaceId'], $query['channelId']);
$res = $client->sendGetRequest($base, $query);
$response->success($res ?? []);
}, [
'modules_bird_voice_flash_calls_list' => 'List flash calls via Bird',
]);
// Get a specific flash call by ID
$this->get('/bird/voice/flash-calls/{id}', function () {
global $response;
// Permission: get a specific flash call via Bird
self::requirePermission('modules_bird_voice_flash_calls_get');
$client = new bird();
$id = (string)$this->fromRoute('id');
if ($id === null || $id === '') {
$response->error('Missing id', 400);
}
$ws = (string)($this->fromQuery('workspaceId') ?? '');
$ch = (string)($this->fromQuery('channelId') ?? '');
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls';
$res = $client->sendGetRequest($base . '/' . rawurlencode($id));
$response->success($res ?? []);
}, [
'modules_bird_voice_flash_calls_get' => 'Get a flash call by ID via Bird',
]);
// Complete/end a flash call by ID (POST to the resource)
$this->post('/bird/voice/flash-calls/{id}', function () {
global $response;
// Permission: complete/end a flash call via Bird
self::requirePermission('modules_bird_voice_flash_calls_end');
$client = new bird();
$id = (string)$this->fromRoute('id');
if ($id === null || $id === '') {
$response->error('Missing id', 400);
}
// Forward any body fields (e.g., result/status) transparently
$ws = (string)($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId') ?? '');
$ch = (string)($this->fromRequest('channelId') ?? $this->fromQuery('channelId') ?? '');
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls';
$payload = $this->getParametersAsArray();
unset($payload['workspaceId'], $payload['channelId']);
$res = $client->sendPostRequest($base . '/' . rawurlencode($id), $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_flash_calls_end' => 'Complete/end a flash call by ID via Bird',
]);
// Complete/end a flash call by using from/to numbers
$this->post('/bird/voice/flash-calls/end', function () {
global $response;
// Permission: complete/end a flash call by numbers via Bird
self::requirePermission('modules_bird_voice_flash_calls_end_by_numbers');
$client = new bird();
$ws = (string)($this->fromRequest('workspaceId') ?? '');
$ch = (string)($this->fromRequest('channelId') ?? '');
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$base = '/workspaces/' . rawurlencode($ws) . '/channels/' . rawurlencode($ch) . '/flashcalls';
$payload = $this->getParametersAsArray();
unset($payload['workspaceId'], $payload['channelId']);
// Basic validation hints: expect 'from' and 'to' but let Bird validate strictly
$res = $client->sendPostRequest($base . '/end', $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_flash_calls_end_by_numbers' => 'Complete/end a flash call using from/to numbers via Bird',
]);
}
}
@@ -0,0 +1,50 @@
<?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";
@@ -0,0 +1,57 @@
<?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, 'Bearer secret_token')) { $hasAuth = true; }
if (strcasecmp($h, 'Content-Type: application/json') === 0) { $hasJson = true; }
}
if ($hasAuth) { ok('Authorization header includes Bearer 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";
@@ -0,0 +1,111 @@
<?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 function __construct()
{
$this->enabled = new DummyVar2('true');
$this->api_key = new DummyVar2('test_api_key');
$this->server_url = new DummyVar2('https://example.test');
}
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 /numbers)
$client = new FakeBird2();
$res = $client->sendGetRequest('/numbers', ['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/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: Bearer test_api_key', $client->last['headers'], true), 'Authorization header is set (numbers)');
assert_true2(empty($client->last['body']), 'GET body is empty (numbers)');
// 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: Bearer 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";
@@ -0,0 +1,79 @@
<?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 /voice/calls)
$client = new FakeBird();
$payload = [
'to' => '+4511122233',
'from' => '+4599988877',
'tts' => [ 'message' => 'Hello from test' ],
];
$res = $client->sendPostRequest('/voice/calls', $payload);
assert_true(is_object($res) || is_array($res), 'Response is JSON-decodable');
assert_true(str_starts_with($client->last['url'], 'https://example.test/voice/calls'), 'POST URL composed correctly');
assert_true($client->last['method'] === 'POST', 'HTTP method is POST');
assert_true(in_array('Authorization: Bearer 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 /voice/calls/{id})
$client2 = new FakeBird();
$res2 = $client2->sendGetRequest('/voice/calls/call_123');
assert_true(str_starts_with($client2->last['url'], 'https://example.test/voice/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');
echo "VoiceCallsApiTest completed.\n";