diff --git a/docs_bird_voice_calls.html b/docs_bird_voice_calls.html
new file mode 100644
index 00000000..ad99188c
--- /dev/null
+++ b/docs_bird_voice_calls.html
@@ -0,0 +1,210 @@
+
Voice Calls API | Bird API Docs
diff --git a/docs_bird_voice_calls.md b/docs_bird_voice_calls.md
new file mode 100644
index 00000000..5ea1293b
--- /dev/null
+++ b/docs_bird_voice_calls.md
@@ -0,0 +1,10 @@
+# Voice Calls API
+
+## Overview
+
+Voice Calls API makes it possible to initiate, receive, and control voice calls using a REST API.
+
+## API Access
+
+The following API requests can only be made using a valid access key, and attached to an access role, with an access policy that at least specifies the permissions to the resources outlined in each section below. Learn more about [API access](https://docs.messagebird.com/api/api-access).
+
diff --git a/openapi.yaml b/openapi.yaml
index 504cfe3f..ccc448f5 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -80,8 +80,363 @@ tags:
description: Department goals management
- name: Subusers
description: Subuser registration and setup
+ - name: Bird
+ description: Voice Calls via Bird
paths:
+ # Bird Voice Calls
+ /bird/voice/calls:
+ post:
+ tags:
+ - Bird
+ summary: Create/place a voice call via Bird
+ operationId: birdCreateVoiceCall
+ parameters:
+ - in: query
+ name: workspaceId
+ schema:
+ type: string
+ required: true
+ description: Bird Workspace identifier
+ - in: query
+ name: channelId
+ schema:
+ type: string
+ required: true
+ description: Bird Channel identifier
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ properties:
+ to:
+ type: string
+ description: E.164 phone number of the callee
+ example: "+4511122233"
+ from:
+ type: string
+ description: E.164 phone number of the caller (sender)
+ example: "+4599988877"
+ responses:
+ '200':
+ description: Call created
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ get:
+ tags:
+ - Bird
+ summary: List voice calls
+ operationId: birdListVoiceCalls
+ parameters:
+ - in: query
+ name: workspaceId
+ schema:
+ type: string
+ required: true
+ description: Bird Workspace identifier
+ - in: query
+ name: channelId
+ schema:
+ type: string
+ required: true
+ description: Bird Channel identifier
+ - in: query
+ name: page
+ schema:
+ type: integer
+ required: false
+ responses:
+ '200':
+ description: A list of calls
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+
+ /bird/voice/calls/{id}:
+ get:
+ tags:
+ - Bird
+ summary: Get a voice call by ID
+ operationId: birdGetVoiceCall
+ parameters:
+ - in: query
+ name: workspaceId
+ schema:
+ type: string
+ required: true
+ description: Bird Workspace identifier
+ - in: query
+ name: channelId
+ schema:
+ type: string
+ required: true
+ description: Bird Channel identifier
+ - in: path
+ name: id
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Call details
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+
+ /bird/voice/calls/{id}/hangup:
+ post:
+ tags:
+ - Bird
+ summary: Hang up a voice call by ID
+ operationId: birdHangupVoiceCall
+ parameters:
+ - in: query
+ name: workspaceId
+ schema:
+ type: string
+ required: true
+ description: Bird Workspace identifier
+ - in: query
+ name: channelId
+ schema:
+ type: string
+ required: true
+ description: Bird Channel identifier
+ - in: path
+ name: id
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Hangup requested
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+
+ # Bird Numbers
+ /bird/numbers:
+ get:
+ tags:
+ - Bird
+ summary: List your numbers
+ operationId: birdListNumbers
+ parameters:
+ - in: query
+ name: page
+ required: false
+ schema:
+ type: integer
+ - in: query
+ name: limit
+ required: false
+ schema:
+ type: integer
+ responses:
+ '200':
+ description: A list of numbers
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+
+ # Bird Voice Flash Calling
+ /bird/voice/flash-calls:
+ post:
+ tags:
+ - Bird
+ summary: Create/place a flash call via Bird
+ operationId: birdCreateFlashCall
+ parameters:
+ - in: query
+ name: workspaceId
+ schema:
+ type: string
+ required: true
+ description: Bird Workspace identifier
+ - in: query
+ name: channelId
+ schema:
+ type: string
+ required: true
+ description: Bird Channel identifier
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ responses:
+ '200':
+ description: Flash call created
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ get:
+ tags:
+ - Bird
+ summary: List flash calls
+ operationId: birdListFlashCalls
+ parameters:
+ - in: query
+ name: workspaceId
+ schema:
+ type: string
+ required: true
+ description: Bird Workspace identifier
+ - in: query
+ name: channelId
+ schema:
+ type: string
+ required: true
+ description: Bird Channel identifier
+ - in: query
+ name: page
+ schema:
+ type: integer
+ required: false
+ responses:
+ '200':
+ description: A list of flash calls
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+
+ /bird/voice/flash-calls/{id}:
+ get:
+ tags:
+ - Bird
+ summary: Get a flash call by ID
+ operationId: birdGetFlashCall
+ parameters:
+ - in: query
+ name: workspaceId
+ schema:
+ type: string
+ required: true
+ description: Bird Workspace identifier
+ - in: query
+ name: channelId
+ schema:
+ type: string
+ required: true
+ description: Bird Channel identifier
+ - in: path
+ name: id
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Flash call details
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ post:
+ tags:
+ - Bird
+ summary: Complete/end a flash call by ID
+ description: Posts a completion/update payload to the flash call resource to finalize verification.
+ operationId: birdEndFlashCall
+ parameters:
+ - in: query
+ name: workspaceId
+ schema:
+ type: string
+ required: true
+ description: Bird Workspace identifier
+ - in: query
+ name: channelId
+ schema:
+ type: string
+ required: true
+ description: Bird Channel identifier
+ - in: path
+ name: id
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ responses:
+ '200':
+ description: Flash call completed
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+
+ /bird/voice/flash-calls/end:
+ post:
+ tags:
+ - Bird
+ summary: Complete/end a flash call using from/to numbers
+ description: Ends an ongoing flash call by specifying the originating and destination numbers.
+ operationId: birdEndFlashCallByNumbers
+ parameters:
+ - in: query
+ name: workspaceId
+ schema:
+ type: string
+ required: true
+ description: Bird Workspace identifier
+ - in: query
+ name: channelId
+ schema:
+ type: string
+ required: true
+ description: Bird Channel identifier
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ properties:
+ from:
+ type: string
+ description: E.164 formatted caller number
+ example: "+4599988877"
+ to:
+ type: string
+ description: E.164 formatted callee number
+ example: "+4511122233"
+ responses:
+ '200':
+ description: Flash call completed (by numbers)
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+
# Subusers (public registration + setup)
/subusers:
get:
diff --git a/services/nginx/app/classes/bird.php b/services/nginx/app/classes/bird.php
new file mode 100644
index 00000000..424dbca4
--- /dev/null
+++ b/services/nginx/app/classes/bird.php
@@ -0,0 +1,182 @@
+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;
+ }
+}
diff --git a/services/nginx/app/modules/bird/bird-flash-calls.md b/services/nginx/app/modules/bird/bird-flash-calls.md
new file mode 100644
index 00000000..2fc54b62
--- /dev/null
+++ b/services/nginx/app/modules/bird/bird-flash-calls.md
@@ -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.
+
+## 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:
+
+## 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"}}}}}}
+```
diff --git a/services/nginx/app/modules/bird/bird_c.php b/services/nginx/app/modules/bird/bird_c.php
new file mode 100644
index 00000000..2aa53d12
--- /dev/null
+++ b/services/nginx/app/modules/bird/bird_c.php
@@ -0,0 +1,49 @@
+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();
+ }
+}
diff --git a/services/nginx/app/modules/bird/config/bird_api_key_c.php b/services/nginx/app/modules/bird/config/bird_api_key_c.php
new file mode 100644
index 00000000..89a22ef6
--- /dev/null
+++ b/services/nginx/app/modules/bird/config/bird_api_key_c.php
@@ -0,0 +1,31 @@
+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',
+ ]);
+ }
+}
diff --git a/services/nginx/app/routes/birdVoiceCallsRoute.php b/services/nginx/app/routes/birdVoiceCallsRoute.php
new file mode 100644
index 00000000..5766acf5
--- /dev/null
+++ b/services/nginx/app/routes/birdVoiceCallsRoute.php
@@ -0,0 +1,99 @@
+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',
+ ]);
+ }
+}
diff --git a/services/nginx/app/routes/birdVoiceFlashCallsRoute.php b/services/nginx/app/routes/birdVoiceFlashCallsRoute.php
new file mode 100644
index 00000000..40333fe6
--- /dev/null
+++ b/services/nginx/app/routes/birdVoiceFlashCallsRoute.php
@@ -0,0 +1,123 @@
+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',
+ ]);
+ }
+}
diff --git a/services/nginx/app/tests/bird/BirdConfigTest.php b/services/nginx/app/tests/bird/BirdConfigTest.php
new file mode 100644
index 00000000..64794319
--- /dev/null
+++ b/services/nginx/app/tests/bird/BirdConfigTest.php
@@ -0,0 +1,50 @@
+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";
diff --git a/services/nginx/app/tests/bird/BirdHttpHeadersTest.php b/services/nginx/app/tests/bird/BirdHttpHeadersTest.php
new file mode 100644
index 00000000..225d7c5d
--- /dev/null
+++ b/services/nginx/app/tests/bird/BirdHttpHeadersTest.php
@@ -0,0 +1,57 @@
+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'] ?? '')); }
+
+$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";
diff --git a/services/nginx/app/tests/bird/NumbersAndFlashCallsApiTest.php b/services/nginx/app/tests/bird/NumbersAndFlashCallsApiTest.php
new file mode 100644
index 00000000..58727aa5
--- /dev/null
+++ b/services/nginx/app/tests/bird/NumbersAndFlashCallsApiTest.php
@@ -0,0 +1,111 @@
+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";
diff --git a/services/nginx/app/tests/bird/VoiceCallsApiTest.php b/services/nginx/app/tests/bird/VoiceCallsApiTest.php
new file mode 100644
index 00000000..f66a63a2
--- /dev/null
+++ b/services/nginx/app/tests/bird/VoiceCallsApiTest.php
@@ -0,0 +1,79 @@
+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";