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

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 accessarrow-up-right.

Last updated

Was this helpful?

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";