Reapply "Update Bird module to use AccessKey authorization header instead of Bearer, improve error handling, and add support for workspace/channel configuration."

This reverts commit 1087ffb413.
This commit is contained in:
Jeppe Bundgaard
2026-02-27 03:15:38 +01:00
parent 1087ffb413
commit cc77066617
8 changed files with 218 additions and 50 deletions
+85 -12
View File
@@ -11,6 +11,7 @@ class bird
{
public const TEST_OUTBOUND_NUMBER_RAW = '+45 42 33 11 28';
public const TEST_OUTBOUND_NUMBER_E164 = '+4542331128';
private const ALLOWED_HANGUP_CAUSES = ['rejected', 'busy'];
/**
* Configuration of the Bird module
@@ -96,9 +97,9 @@ class bird
$url = rtrim($this->config->server_url->getVariableValue(), '/') . $endpoint;
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->config->api_key->getVariableValue(),
$this->buildAuthorizationHeader(),
];
$body = json_encode($data);
$body = $this->encodeJsonBody($data);
$this->logBirdAction(
'BIRD_HTTP_REQUEST',
@@ -110,7 +111,7 @@ class bird
$this->logBirdAction('BIRD_HTTP_RESPONSE', 'method=POST endpoint=' . $endpoint . ' status=' . $status);
if ($status >= 400) {
$this->logBirdAction('BIRD_HTTP_ERROR', 'method=POST endpoint=' . $endpoint . ' status=' . $status, 0);
throw new Exception('Bird API request failed with status ' . $status);
throw new Exception($this->buildHttpErrorMessage($status, $response));
}
if ($response === '' || $response === false || $response === null) {
return null;
@@ -179,7 +180,7 @@ class bird
}
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->config->api_key->getVariableValue(),
$this->buildAuthorizationHeader(),
];
$this->logBirdAction(
'BIRD_HTTP_REQUEST',
@@ -191,7 +192,7 @@ class bird
$this->logBirdAction('BIRD_HTTP_RESPONSE', 'method=GET endpoint=' . $endpoint . ' status=' . $status);
if ($status >= 400) {
$this->logBirdAction('BIRD_HTTP_ERROR', 'method=GET endpoint=' . $endpoint . ' status=' . $status, 0);
throw new Exception('Bird API request failed with status ' . $status);
throw new Exception($this->buildHttpErrorMessage($status, $response));
}
if ($response === '' || $response === false || $response === null) {
return null;
@@ -209,9 +210,9 @@ class bird
$url = rtrim($this->config->server_url->getVariableValue(), '/') . $endpoint;
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->config->api_key->getVariableValue(),
$this->buildAuthorizationHeader(),
];
$body = json_encode($data);
$body = $this->encodeJsonBody($data);
$this->logBirdAction(
'BIRD_HTTP_REQUEST',
@@ -223,7 +224,7 @@ class bird
$this->logBirdAction('BIRD_HTTP_RESPONSE', 'method=PATCH endpoint=' . $endpoint . ' status=' . $status);
if ($status >= 400) {
$this->logBirdAction('BIRD_HTTP_ERROR', 'method=PATCH endpoint=' . $endpoint . ' status=' . $status, 0);
throw new Exception('Bird API request failed with status ' . $status);
throw new Exception($this->buildHttpErrorMessage($status, $response));
}
if ($response === '' || $response === false || $response === null) {
return null;
@@ -246,7 +247,7 @@ class bird
}
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->config->api_key->getVariableValue(),
$this->buildAuthorizationHeader(),
];
$this->logBirdAction(
'BIRD_HTTP_REQUEST',
@@ -258,7 +259,7 @@ class bird
$this->logBirdAction('BIRD_HTTP_RESPONSE', 'method=DELETE endpoint=' . $endpoint . ' status=' . $status);
if ($status >= 400) {
$this->logBirdAction('BIRD_HTTP_ERROR', 'method=DELETE endpoint=' . $endpoint . ' status=' . $status, 0);
throw new Exception('Bird API request failed with status ' . $status);
throw new Exception($this->buildHttpErrorMessage($status, $response));
}
if ($response === '' || $response === false || $response === null) {
return null;
@@ -314,7 +315,8 @@ class bird
$maxAttempts = (int)max(1, floor($maxPollSeconds / $pollIntervalSeconds));
$payload = $options;
unset($payload['pollIntervalSeconds'], $payload['maxPollSeconds']);
// Keep polling/hangup controls out of create-call payload to avoid Bird validation errors.
unset($payload['pollIntervalSeconds'], $payload['maxPollSeconds'], $payload['hangupCause']);
$payload['to'] = self::TEST_OUTBOUND_NUMBER_E164;
$this->logBirdAction(
@@ -350,7 +352,10 @@ class bird
if ($status !== null && in_array(strtolower($status), $acceptedStates, true)) {
$hangupPayload = [];
if (isset($options['hangupCause']) && is_string($options['hangupCause']) && $options['hangupCause'] !== '') {
$hangupPayload['cause'] = $options['hangupCause'];
$normalizedCause = strtolower(trim($options['hangupCause']));
if (in_array($normalizedCause, self::ALLOWED_HANGUP_CAUSES, true)) {
$hangupPayload['cause'] = $normalizedCause;
}
}
$hangupResponse = $this->hangupVoiceCall($workspaceId, $channelId, $callId, $hangupPayload);
$this->logBirdAction('BIRD_TEST_OUTBOUND_CALL_HANGUP_SENT', 'call=' . $callId . ' status=' . $status);
@@ -416,6 +421,71 @@ class bird
return null;
}
private function buildHttpErrorMessage(int $status, string|false|null $response): string
{
$base = 'Bird API request failed with status ' . $status;
if (!is_string($response) || trim($response) === '') {
return $base;
}
$decoded = json_decode($response, true);
if (is_array($decoded)) {
$details = [];
foreach (['message', 'error', 'description'] as $key) {
if (isset($decoded[$key]) && is_string($decoded[$key]) && trim($decoded[$key]) !== '') {
$details[] = trim($decoded[$key]);
}
}
if (isset($decoded['errors'])) {
if (is_string($decoded['errors']) && trim($decoded['errors']) !== '') {
$details[] = trim($decoded['errors']);
} elseif (is_array($decoded['errors'])) {
foreach ($decoded['errors'] as $error) {
if (is_string($error) && trim($error) !== '') {
$details[] = trim($error);
} elseif (is_array($error) && isset($error['message']) && is_string($error['message']) && trim($error['message']) !== '') {
$details[] = trim($error['message']);
}
}
}
}
$details = array_values(array_unique(array_filter($details)));
if (!empty($details)) {
return $base . ': ' . implode(' | ', $details);
}
}
$snippet = preg_replace('/\s+/', ' ', trim($response));
if (!is_string($snippet) || $snippet === '') {
return $base;
}
if (strlen($snippet) > 220) {
$snippet = substr($snippet, 0, 220) . '...';
}
return $base . ': ' . $snippet;
}
private function buildAuthorizationHeader(): string
{
$apiKey = trim((string)$this->config->api_key->getVariableValue());
if ($apiKey === '') {
return 'Authorization: AccessKey';
}
if (preg_match('/^(Bearer|AccessKey)\s+/i', $apiKey) === 1) {
return 'Authorization: ' . $apiKey;
}
return 'Authorization: AccessKey ' . $apiKey;
}
private function encodeJsonBody(array $data): string
{
// Bird control endpoints expect an object payload; use {} for empty body.
if ($data === []) {
return '{}';
}
return json_encode($data);
}
private function logBirdAction(string $action, string $message, int $type = 1): void
{
try {
@@ -437,3 +507,6 @@ class bird
}
}
}
@@ -6,10 +6,14 @@ require_once WD . '/traits/module_config_t.php';
require_once WD . '/modules/bird/config/bird_enabled_c.php';
require_once WD . '/modules/bird/config/bird_api_key_c.php';
require_once WD . '/modules/bird/config/bird_server_url_c.php';
require_once WD . '/modules/bird/config/bird_workplaceId_c.php';
require_once WD . '/modules/bird/config/bird_channelId_c.php';
use bird\config\bird_enabled_c;
use bird\config\bird_api_key_c;
use bird\config\bird_server_url_c;
use bird\config\bird_workplaceId_c;
use bird\config\bird_channelId_c;
use traits\module_config_t;
class bird_c
@@ -34,6 +38,18 @@ class bird_c
*/
public bird_server_url_c $server_url;
/**
* Default Bird workplace identifier
* @var bird_workplaceId_c
*/
public bird_workplaceId_c $workplaceId;
/**
* Default Bird channel identifier
* @var bird_channelId_c
*/
public bird_channelId_c $channelId;
public function __construct()
{
$this->setupConfig('bird');
@@ -41,9 +57,13 @@ class bird_c
bird_enabled_c::class,
bird_api_key_c::class,
bird_server_url_c::class,
bird_workplaceId_c::class,
bird_channelId_c::class,
]);
$this->enabled = new bird_enabled_c();
$this->api_key = new bird_api_key_c();
$this->server_url = new bird_server_url_c();
$this->workplaceId = new bird_workplaceId_c();
$this->channelId = new bird_channelId_c();
}
}
@@ -9,6 +9,49 @@ class birdVoiceCallsRoute
{
use route_t;
private function normalizeOptionalString(mixed $value): string
{
if (!is_scalar($value)) {
return '';
}
$normalized = trim((string)$value);
if ($normalized === '') {
return '';
}
$lower = strtolower($normalized);
if ($lower === 'undefined' || $lower === 'null') {
return '';
}
return $normalized;
}
private function getConfiguredWorkspaceId(bird $client): string
{
$workspaceConfig = null;
if (property_exists($client->config, 'workspaceId')) {
$workspaceConfig = $client->config->workspaceId;
} elseif (property_exists($client->config, 'workplaceId')) {
// Backward compatibility with existing config key naming.
$workspaceConfig = $client->config->workplaceId;
}
if (is_object($workspaceConfig) && method_exists($workspaceConfig, 'getVariableValue')) {
return $this->normalizeOptionalString($workspaceConfig->getVariableValue());
}
return '';
}
private function getConfiguredChannelId(bird $client): string
{
$channelConfig = null;
if (property_exists($client->config, 'channelId')) {
$channelConfig = $client->config->channelId;
}
if (is_object($channelConfig) && method_exists($channelConfig, 'getVariableValue')) {
return $this->normalizeOptionalString($channelConfig->getVariableValue());
}
return '';
}
public function run(): void
{
// Create a voice call
@@ -18,8 +61,8 @@ class birdVoiceCallsRoute
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') ?? '');
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId'));
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
@@ -37,8 +80,8 @@ class birdVoiceCallsRoute
// 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') ?? '');
$ws = $this->normalizeOptionalString($this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromQuery('channelId'));
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
@@ -60,8 +103,8 @@ class birdVoiceCallsRoute
if ($id === null || $id === '') {
$response->error('Missing id', 400);
}
$ws = (string)($this->fromQuery('workspaceId') ?? '');
$ch = (string)($this->fromQuery('channelId') ?? '');
$ws = $this->normalizeOptionalString($this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromQuery('channelId'));
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
@@ -81,12 +124,17 @@ class birdVoiceCallsRoute
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') ?? '');
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
$res = $client->hangupVoiceCall($ws, $ch, $id);
$payload = [];
$cause = $this->normalizeOptionalString($this->fromRequest('cause') ?? $this->fromQuery('cause'));
if ($cause !== '') {
$payload['cause'] = $cause;
}
$res = $client->hangupVoiceCall($ws, $ch, $id, $payload);
$response->success($res ?? ['status' => 'ok']);
}, [
'modules_bird_voice_calls_hangup' => 'Hang up a voice call by ID via Bird',
@@ -98,8 +146,14 @@ class birdVoiceCallsRoute
self::requirePermission('modules_bird_voice_calls_test_outbound');
$client = new bird();
$ws = (string)($this->fromRequest('workspaceId') ?? '');
$ch = (string)($this->fromRequest('channelId') ?? '');
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
if ($ws === '') {
$ws = $this->getConfiguredWorkspaceId($client);
}
if ($ch === '') {
$ch = $this->getConfiguredChannelId($client);
}
if ($ws === '' || $ch === '') {
$response->error('Missing required parameters: workspaceId, channelId', 400);
}
@@ -107,8 +161,12 @@ class birdVoiceCallsRoute
$payload = $this->getParametersAsArray();
unset($payload['workspaceId'], $payload['channelId']);
$result = $client->createOutboundTestCallAndHangupWhenAccepted($ws, $ch, $payload);
$response->success($result);
try {
$result = $client->createOutboundTestCallAndHangupWhenAccepted($ws, $ch, $payload);
$response->success($result);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 500);
}
}, [
'modules_bird_voice_calls_test_outbound' => 'Place a test outbound call and hang up when accepted',
]);
@@ -45,10 +45,10 @@ if (($bird->last['url'] ?? '') === $expectedUrl) { ok('URL is correctly composed
$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 (stripos($h, 'authorization:') === 0 && str_contains($h, 'AccessKey secret_token')) { $hasAuth = true; }
if (strcasecmp($h, 'Content-Type: application/json') === 0) { $hasJson = true; }
}
if ($hasAuth) { ok('Authorization header includes Bearer token'); } else { fail('Missing or invalid Authorization header'); }
if ($hasAuth) { ok('Authorization header includes AccessKey token'); } else { fail('Missing or invalid Authorization header'); }
if ($hasJson) { ok('Content-Type header is application/json'); } else { fail('Missing Content-Type: application/json header'); }
$expectedBody = json_encode($payload);
@@ -62,7 +62,7 @@ assert_true2(is_object($res) || is_array($res), 'Numbers response is JSON-decoda
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(in_array('Authorization: AccessKey test_api_key', $client->last['headers'], true), 'Authorization header is set (numbers)');
assert_true2(empty($client->last['body']), 'GET body is empty (numbers)');
// Test: create flash call (POST /voice/flash-calls)
@@ -76,7 +76,7 @@ $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(in_array('Authorization: AccessKey test_api_key', $client2->last['headers'], true), 'Authorization header is set (flash)');
assert_true2(json_decode($client2->last['body'], true)['code'] === '1234', 'Flash POST body encoded correctly');
// Test: list flash calls (GET /voice/flash-calls)
@@ -109,3 +109,4 @@ assert_true2($client6->last['method'] === 'POST', 'HTTP method is POST (flash en
assert_true2(json_decode($client6->last['body'], true)['from'] === '+4599988877', 'Flash end-by-numbers body encoded correctly');
echo "NumbersAndFlashCallsApiTest completed.\n";
@@ -66,7 +66,7 @@ $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(in_array('Authorization: AccessKey test_api_key', $client->last['headers'], true), 'Authorization header is set');
assert_true(json_decode($client->last['body'], true)['to'] === '+4511122233', 'POST body encoded correctly');
// Test: get call (GET /voice/calls/{id})
@@ -76,4 +76,12 @@ assert_true(str_starts_with($client2->last['url'], 'https://example.test/voice/c
assert_true($client2->last['method'] === 'GET', 'HTTP method is GET');
assert_true(empty($client2->last['body']), 'GET body is empty');
// Test: hangup with empty payload encodes an object body ({}), not array body ([]).
$client3 = new FakeBird();
$client3->hangupVoiceCall('ws_123', 'ch_123', 'call_123');
assert_true(str_ends_with($client3->last['url'], '/workspaces/ws_123/channels/ch_123/calls/call_123/hangup'), 'Hangup URL composed correctly');
assert_true($client3->last['method'] === 'POST', 'Hangup uses POST');
assert_true(trim((string)$client3->last['body']) === '{}', 'Hangup empty body is encoded as JSON object');
echo "VoiceCallsApiTest completed.\n";