fix(voice-call): verify call status with provider before loading stale calls

On gateway restart, persisted non-terminal calls are now verified with
the provider (Twilio/Plivo/Telnyx) before being restored to memory.
This prevents phantom calls from blocking the concurrent call limit.

- Add getCallStatus() to VoiceCallProvider interface
- Implement for all providers with SSRF-guarded fetch
- Transient errors (5xx, network) keep the call with timer fallback
- 404/known-terminal statuses drop the call
- Restart max-duration timers for restored answered calls
- Skip calls older than maxDurationSeconds or without providerCallId
This commit is contained in:
garnetlyx
2026-03-01 22:13:24 -08:00
parent 3049ca840f
commit ffa7c13c9b
11 changed files with 436 additions and 26 deletions

View File

@@ -1,4 +1,6 @@
import type {
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
@@ -65,4 +67,12 @@ export interface VoiceCallProvider {
* Stop listening for user speech (deactivate STT).
*/
stopListening(input: StopListeningInput): Promise<void>;
/**
* Query provider for current call status.
* Used to verify persisted calls are still active on restart.
* Must return `isUnknown: true` for transient errors (network, 5xx)
* so the caller can keep the call and rely on timer-based fallback.
*/
getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult>;
}

View File

@@ -1,6 +1,8 @@
import crypto from "node:crypto";
import type {
EndReason,
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
@@ -166,4 +168,12 @@ export class MockProvider implements VoiceCallProvider {
async stopListening(_input: StopListeningInput): Promise<void> {
// No-op for mock
}
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
const id = input.providerCallId.toLowerCase();
if (id.includes("stale") || id.includes("ended") || id.includes("completed")) {
return { status: "completed", isTerminal: true };
}
return { status: "in-progress", isTerminal: false };
}
}

View File

@@ -2,6 +2,8 @@ import crypto from "node:crypto";
import type { PlivoConfig, WebhookSecurityConfig } from "../config.js";
import { getHeader } from "../http-headers.js";
import type {
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
@@ -441,6 +443,41 @@ export class PlivoProvider implements VoiceCallProvider {
// GetInput ends automatically when speech ends.
}
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
const terminalStatuses = new Set([
"completed",
"busy",
"failed",
"timeout",
"no-answer",
"cancel",
"machine",
"hangup",
]);
try {
const data = await guardedJsonApiRequest<{ call_status?: string }>({
url: `${this.baseUrl}/Call/${input.providerCallId}/`,
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`,
},
allowNotFound: true,
allowedHostnames: [this.apiHost],
auditContext: "plivo-get-call-status",
errorPrefix: "Plivo get call status error",
});
if (!data) {
return { status: "not-found", isTerminal: true };
}
const status = data.call_status ?? "unknown";
return { status, isTerminal: terminalStatuses.has(status) };
} catch {
return { status: "error", isTerminal: false, isUnknown: true };
}
}
private static normalizeNumber(numberOrSip: string): string {
const trimmed = numberOrSip.trim();
if (trimmed.toLowerCase().startsWith("sip:")) {

View File

@@ -2,6 +2,8 @@ import crypto from "node:crypto";
import type { TelnyxConfig } from "../config.js";
import type {
EndReason,
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
@@ -291,6 +293,37 @@ export class TelnyxProvider implements VoiceCallProvider {
{ allowNotFound: true },
);
}
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
try {
const data = await guardedJsonApiRequest<{ data?: { state?: string; is_alive?: boolean } }>({
url: `${this.baseUrl}/calls/${input.providerCallId}`,
method: "GET",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
allowNotFound: true,
allowedHostnames: [this.apiHost],
auditContext: "telnyx-get-call-status",
errorPrefix: "Telnyx get call status error",
});
if (!data) {
return { status: "not-found", isTerminal: true };
}
const state = data.data?.state ?? "unknown";
const isAlive = data.data?.is_alive;
// If is_alive is missing, treat as unknown rather than terminal (P1 fix)
if (isAlive === undefined) {
return { status: state, isTerminal: false, isUnknown: true };
}
return { status: state, isTerminal: !isAlive };
} catch {
return { status: "error", isTerminal: false, isUnknown: true };
}
}
}
// -----------------------------------------------------------------------------

View File

@@ -5,6 +5,8 @@ import type { MediaStreamHandler } from "../media-stream.js";
import { chunkAudio } from "../telephony-audio.js";
import type { TelephonyTtsProvider } from "../telephony-tts.js";
import type {
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
@@ -19,6 +21,7 @@ import type {
} from "../types.js";
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
import type { VoiceCallProvider } from "./base.js";
import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
import { twilioApiRequest } from "./twilio/api.js";
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
@@ -671,6 +674,33 @@ export class TwilioProvider implements VoiceCallProvider {
// Twilio's <Gather> automatically stops on speech end
// No explicit action needed
}
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
const terminalStatuses = new Set(["completed", "failed", "busy", "no-answer", "canceled"]);
try {
const data = await guardedJsonApiRequest<{ status?: string }>({
url: `${this.baseUrl}/Calls/${input.providerCallId}.json`,
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString("base64")}`,
},
allowNotFound: true,
allowedHostnames: ["api.twilio.com"],
auditContext: "twilio-get-call-status",
errorPrefix: "Twilio get call status error",
});
if (!data) {
return { status: "not-found", isTerminal: true };
}
const status = data.status ?? "unknown";
return { status, isTerminal: terminalStatuses.has(status) };
} catch {
// Transient error — keep the call and rely on timer fallback
return { status: "error", isTerminal: false, isUnknown: true };
}
}
}
// -----------------------------------------------------------------------------