Add Bird gate call flow unit tests, self-serve machine wash minutes config, relay sync improvements, and OpenAPI updates. Refactor Bird call handling with terminal status detection and timeout normalization.

This commit is contained in:
Jeppe Bundgaard
2026-03-26 13:16:18 +01:00
parent a4654398d0
commit 95063d2a70
20 changed files with 731 additions and 77 deletions
+141 -11
View File
@@ -12,6 +12,8 @@ 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', 'completed'];
private const ACCEPTED_CALL_STATUSES = ['accepted', 'ongoing'];
private const TERMINAL_GATE_FAILURE_STATUSES = ['rejected', 'busy', 'failed', 'cancelled', 'no-answer', 'completed'];
/**
* Configuration of the Bird module
@@ -418,11 +420,7 @@ class bird
$targetNumber = self::TEST_OUTBOUND_NUMBER_E164;
}
$payload['to'] = $targetNumber;
// Ensure Bird terminates an unanswered call after we've stopped polling for it.
if (!isset($payload['timeout'])) {
$payload['timeout'] = $maxPollSeconds;
}
$payload = $this->normalizeCreateVoiceCallPayload($payload, $maxPollSeconds);
$this->logBirdAction(
$logPrefix . '_START',
@@ -443,18 +441,18 @@ class bird
}
$lastCall = null;
$acceptedStates = ['accepted', 'ongoing'];
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$current = $this->getVoiceCall($workspaceId, $channelId, $callId);
$lastCall = $current;
$status = $this->extractStatus($current);
$normalizedStatus = $status === null ? null : strtolower($status);
$this->logBirdAction(
$logPrefix . '_POLL',
'call=' . $callId . ' attempt=' . $attempt . '/' . $maxAttempts . ' status=' . ($status ?? 'unknown')
);
if ($status !== null && in_array(strtolower($status), $acceptedStates, true)) {
if ($normalizedStatus !== null && in_array($normalizedStatus, self::ACCEPTED_CALL_STATUSES, true)) {
$hangupPayload = [];
if (isset($options['hangupCause']) && is_string($options['hangupCause']) && $options['hangupCause'] !== '') {
$normalizedCause = strtolower(trim($options['hangupCause']));
@@ -476,8 +474,26 @@ class bird
];
}
if ($normalizedStatus !== null && in_array($normalizedStatus, self::TERMINAL_GATE_FAILURE_STATUSES, true)) {
$this->logBirdAction(
$logPrefix . '_TERMINAL',
'call=' . $callId . ' status=' . $status
);
return [
'to' => $targetNumber,
'to_e164' => $targetNumber,
'call_id' => $callId,
'final_status' => $status,
'hangup_sent' => false,
'terminal_failure' => true,
'created_call' => $createResponse,
'last_call_snapshot' => $lastCall,
'message' => 'Call reached terminal status before acceptance',
];
}
if ($attempt < $maxAttempts) {
sleep($pollIntervalSeconds);
$this->waitForCallPollInterval($pollIntervalSeconds);
}
}
@@ -499,6 +515,14 @@ class bird
];
}
/**
* Hook point for tests to avoid real waiting during call polling.
*/
protected function waitForCallPollInterval(int $pollIntervalSeconds): void
{
sleep($pollIntervalSeconds);
}
private function voiceBase(string $workspaceId, string $channelId): string
{
return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls';
@@ -626,12 +650,23 @@ class bird
public function callGateAndHangupWhenAccepted(int $countryCode, int $phone, int $timeout)
{
$ws = $this->config->workplaceId->getVariableValue();
$ch = $this->config->channelId->getVariableValue();
$ws = $this->getConfiguredWorkspaceId();
$ch = $this->getConfiguredChannelId();
if ($ws === '') {
throw new Exception('Bird workspaceId is not configured for gate calls');
}
if ($ch === '') {
throw new Exception('Bird channelId is not configured for gate calls');
}
$normalizedRingTimeout = $this->normalizeRingTimeoutValue($timeout);
if ($normalizedRingTimeout === null) {
$normalizedRingTimeout = 30;
}
$options = [
'to' => '+' . $countryCode . $phone,
'maxPollSeconds' => (int)$timeout,
'maxPollSeconds' => max(5, (int)$timeout),
'ringTimeout' => $normalizedRingTimeout,
];
$result = $this->executeCallAndHangupWhenAccepted($ws, $ch, $options, 'BIRD_GATE_CALL');
@@ -640,10 +675,105 @@ class bird
$msg = $result['message'] ?? 'Failed to call gate and hangup when accepted';
if ($result['timed_out_waiting_for_accepted'] ?? false) {
$msg = 'Timed out waiting for gate to accept call';
} elseif ($result['terminal_failure'] ?? false) {
$status = isset($result['final_status']) ? (string)$result['final_status'] : 'unknown';
$msg = 'Gate call reached terminal status: ' . $status;
}
throw new Exception("Failed to call gate (+{$countryCode}{$phone}): " . $msg);
}
}
protected function getConfiguredWorkspaceId(): string
{
if (!is_object($this->config)) {
return '';
}
$workspaceConfig = null;
if (property_exists($this->config, 'workspaceId')) {
$workspaceConfig = $this->config->workspaceId;
} elseif (property_exists($this->config, 'workplaceId')) {
// Backward compatibility with existing Bird module variable naming.
$workspaceConfig = $this->config->workplaceId;
}
if (!is_object($workspaceConfig) || !method_exists($workspaceConfig, 'getVariableValue')) {
return '';
}
return $this->normalizeOptionalString($workspaceConfig->getVariableValue());
}
protected function getConfiguredChannelId(): string
{
if (!is_object($this->config)) {
return '';
}
$channelConfig = null;
if (property_exists($this->config, 'channelId')) {
$channelConfig = $this->config->channelId;
}
if (!is_object($channelConfig) || !method_exists($channelConfig, 'getVariableValue')) {
return '';
}
return $this->normalizeOptionalString($channelConfig->getVariableValue());
}
protected 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;
}
/**
* Align create-call payload with Bird voice call schema.
* - Map legacy `timeout` to documented `ringTimeout`.
* - Clamp `ringTimeout` to documented [3,120] range.
*/
protected function normalizeCreateVoiceCallPayload(array $payload, int $fallbackRingTimeout): array
{
if (array_key_exists('timeout', $payload) && !array_key_exists('ringTimeout', $payload)) {
$payload['ringTimeout'] = $payload['timeout'];
}
unset($payload['timeout']);
$normalizedRingTimeout = null;
if (array_key_exists('ringTimeout', $payload)) {
$normalizedRingTimeout = $this->normalizeRingTimeoutValue($payload['ringTimeout']);
}
if ($normalizedRingTimeout === null) {
$normalizedRingTimeout = $this->normalizeRingTimeoutValue($fallbackRingTimeout);
}
if ($normalizedRingTimeout !== null) {
$payload['ringTimeout'] = $normalizedRingTimeout;
} else {
unset($payload['ringTimeout']);
}
return $payload;
}
protected function normalizeRingTimeoutValue(mixed $value): ?int
{
if (!is_numeric($value)) {
return null;
}
$timeout = (int)$value;
if ($timeout < 3) {
$timeout = 3;
}
if ($timeout > 120) {
$timeout = 120;
}
return $timeout;
}
}