mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 01:14:33 +00:00
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:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:")) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user