diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ac6e7ea..45616ac2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Voice-call/Twilio signature verification: retry signature validation across deterministic URL port variants (with/without port) to handle mixed Twilio signing behavior behind reverse proxies and non-standard ports. (#25140) Thanks @drvoss. - Voice-call/Twilio external outbound: auto-register webhook-first `outbound-api` calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob. - Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu. +- Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx. - Feishu/topic session routing: use `thread_id` as topic session scope fallback when `root_id` is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun. - Feishu/topic root replies: prefer `root_id` as outbound `replyTargetMessageId` when present, and parse millisecond `message_create_time` values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3. - Feishu/DM pairing reply target: send pairing challenge replies to `chat:` instead of `user:` so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky. diff --git a/extensions/voice-call/src/manager.test.ts b/extensions/voice-call/src/manager.test.ts index d8f9d27a8..f945e5835 100644 --- a/extensions/voice-call/src/manager.test.ts +++ b/extensions/voice-call/src/manager.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -5,6 +6,8 @@ import { VoiceCallConfigSchema } from "./config.js"; import { CallManager } from "./manager.js"; import type { VoiceCallProvider } from "./providers/base.js"; import type { + GetCallStatusInput, + GetCallStatusResult, HangupCallInput, InitiateCallInput, InitiateCallResult, @@ -22,6 +25,7 @@ class FakeProvider implements VoiceCallProvider { readonly hangupCalls: HangupCallInput[] = []; readonly startListeningCalls: StartListeningInput[] = []; readonly stopListeningCalls: StopListeningInput[] = []; + getCallStatusResult: GetCallStatusResult = { status: "in-progress", isTerminal: false }; constructor(name: "plivo" | "twilio" = "plivo") { this.name = name; @@ -48,6 +52,9 @@ class FakeProvider implements VoiceCallProvider { async stopListening(input: StopListeningInput): Promise { this.stopListeningCalls.push(input); } + async getCallStatus(_input: GetCallStatusInput): Promise { + return this.getCallStatusResult; + } } let storeSeq = 0; @@ -57,13 +64,13 @@ function createTestStorePath(): string { return path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}-${storeSeq}`); } -function createManagerHarness( +async function createManagerHarness( configOverrides: Record = {}, provider = new FakeProvider(), -): { +): Promise<{ manager: CallManager; provider: FakeProvider; -} { +}> { const config = VoiceCallConfigSchema.parse({ enabled: true, provider: "plivo", @@ -71,7 +78,7 @@ function createManagerHarness( ...configOverrides, }); const manager = new CallManager(config, createTestStorePath()); - manager.initialize(provider, "https://example.com/voice/webhook"); + await manager.initialize(provider, "https://example.com/voice/webhook"); return { manager, provider }; } @@ -87,7 +94,7 @@ function markCallAnswered(manager: CallManager, callId: string, eventId: string) describe("CallManager", () => { it("upgrades providerCallId mapping when provider ID changes", async () => { - const { manager } = createManagerHarness(); + const { manager } = await createManagerHarness(); const { callId, success, error } = await manager.initiateCall("+15550000001"); expect(success).toBe(true); @@ -114,7 +121,7 @@ describe("CallManager", () => { it.each(["plivo", "twilio"] as const)( "speaks initial message on answered for notify mode (%s)", async (providerName) => { - const { manager, provider } = createManagerHarness({}, new FakeProvider(providerName)); + const { manager, provider } = await createManagerHarness({}, new FakeProvider(providerName)); const { callId, success } = await manager.initiateCall("+15550000002", undefined, { message: "Hello there", @@ -137,8 +144,8 @@ describe("CallManager", () => { }, ); - it("rejects inbound calls with missing caller ID when allowlist enabled", () => { - const { manager, provider } = createManagerHarness({ + it("rejects inbound calls with missing caller ID when allowlist enabled", async () => { + const { manager, provider } = await createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); @@ -158,8 +165,8 @@ describe("CallManager", () => { expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-missing"); }); - it("rejects inbound calls with anonymous caller ID when allowlist enabled", () => { - const { manager, provider } = createManagerHarness({ + it("rejects inbound calls with anonymous caller ID when allowlist enabled", async () => { + const { manager, provider } = await createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); @@ -180,8 +187,8 @@ describe("CallManager", () => { expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-anon"); }); - it("rejects inbound calls that only match allowlist suffixes", () => { - const { manager, provider } = createManagerHarness({ + it("rejects inbound calls that only match allowlist suffixes", async () => { + const { manager, provider } = await createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); @@ -202,8 +209,8 @@ describe("CallManager", () => { expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-suffix"); }); - it("rejects duplicate inbound events with a single hangup call", () => { - const { manager, provider } = createManagerHarness({ + it("rejects duplicate inbound events with a single hangup call", async () => { + const { manager, provider } = await createManagerHarness({ inboundPolicy: "disabled", }); @@ -234,8 +241,8 @@ describe("CallManager", () => { expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-dup"); }); - it("accepts inbound calls that exactly match the allowlist", () => { - const { manager } = createManagerHarness({ + it("accepts inbound calls that exactly match the allowlist", async () => { + const { manager } = await createManagerHarness({ inboundPolicy: "allowlist", allowFrom: ["+15550001234"], }); @@ -255,7 +262,7 @@ describe("CallManager", () => { }); it("completes a closed-loop turn without live audio", async () => { - const { manager, provider } = createManagerHarness({ + const { manager, provider } = await createManagerHarness({ transcriptTimeoutMs: 5000, }); @@ -295,7 +302,7 @@ describe("CallManager", () => { }); it("rejects overlapping continueCall requests for the same call", async () => { - const { manager, provider } = createManagerHarness({ + const { manager, provider } = await createManagerHarness({ transcriptTimeoutMs: 5000, }); @@ -327,7 +334,7 @@ describe("CallManager", () => { }); it("ignores speech events with mismatched turnToken while waiting for transcript", async () => { - const { manager, provider } = createManagerHarness( + const { manager, provider } = await createManagerHarness( { transcriptTimeoutMs: 5000, }, @@ -382,7 +389,7 @@ describe("CallManager", () => { }); it("tracks latency metadata across multiple closed-loop turns", async () => { - const { manager, provider } = createManagerHarness({ + const { manager, provider } = await createManagerHarness({ transcriptTimeoutMs: 5000, }); @@ -435,7 +442,7 @@ describe("CallManager", () => { }); it("handles repeated closed-loop turns without waiter churn", async () => { - const { manager, provider } = createManagerHarness({ + const { manager, provider } = await createManagerHarness({ transcriptTimeoutMs: 5000, }); @@ -468,3 +475,152 @@ describe("CallManager", () => { expect(provider.stopListeningCalls).toHaveLength(5); }); }); + +// --------------------------------------------------------------------------- +// Call verification on restore +// --------------------------------------------------------------------------- + +function writeCallsToStore(storePath: string, calls: Record[]): void { + fs.mkdirSync(storePath, { recursive: true }); + const logPath = path.join(storePath, "calls.jsonl"); + const lines = calls.map((c) => JSON.stringify(c)).join("\n") + "\n"; + fs.writeFileSync(logPath, lines); +} + +function makePersistedCall(overrides: Record = {}): Record { + return { + callId: `call-${Date.now()}-${Math.random().toString(36).slice(2)}`, + providerCallId: `prov-${Date.now()}-${Math.random().toString(36).slice(2)}`, + provider: "plivo", + direction: "outbound", + state: "answered", + from: "+15550000000", + to: "+15550000001", + startedAt: Date.now() - 30_000, + answeredAt: Date.now() - 25_000, + transcript: [], + processedEventIds: [], + ...overrides, + }; +} + +describe("CallManager verification on restore", () => { + it("skips stale calls reported terminal by provider", async () => { + const storePath = createTestStorePath(); + const call = makePersistedCall(); + writeCallsToStore(storePath, [call]); + + const provider = new FakeProvider(); + provider.getCallStatusResult = { status: "completed", isTerminal: true }; + + const config = VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + }); + const manager = new CallManager(config, storePath); + await manager.initialize(provider, "https://example.com/voice/webhook"); + + expect(manager.getActiveCalls()).toHaveLength(0); + }); + + it("keeps calls reported active by provider", async () => { + const storePath = createTestStorePath(); + const call = makePersistedCall(); + writeCallsToStore(storePath, [call]); + + const provider = new FakeProvider(); + provider.getCallStatusResult = { status: "in-progress", isTerminal: false }; + + const config = VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + }); + const manager = new CallManager(config, storePath); + await manager.initialize(provider, "https://example.com/voice/webhook"); + + expect(manager.getActiveCalls()).toHaveLength(1); + expect(manager.getActiveCalls()[0]?.callId).toBe(call.callId); + }); + + it("keeps calls when provider returns unknown (transient error)", async () => { + const storePath = createTestStorePath(); + const call = makePersistedCall(); + writeCallsToStore(storePath, [call]); + + const provider = new FakeProvider(); + provider.getCallStatusResult = { status: "error", isTerminal: false, isUnknown: true }; + + const config = VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + }); + const manager = new CallManager(config, storePath); + await manager.initialize(provider, "https://example.com/voice/webhook"); + + expect(manager.getActiveCalls()).toHaveLength(1); + }); + + it("skips calls older than maxDurationSeconds", async () => { + const storePath = createTestStorePath(); + const call = makePersistedCall({ + startedAt: Date.now() - 600_000, // 10 minutes ago + answeredAt: Date.now() - 590_000, + }); + writeCallsToStore(storePath, [call]); + + const provider = new FakeProvider(); + + const config = VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + maxDurationSeconds: 300, // 5 minutes + }); + const manager = new CallManager(config, storePath); + await manager.initialize(provider, "https://example.com/voice/webhook"); + + expect(manager.getActiveCalls()).toHaveLength(0); + }); + + it("skips calls without providerCallId", async () => { + const storePath = createTestStorePath(); + const call = makePersistedCall({ providerCallId: undefined, state: "initiated" }); + writeCallsToStore(storePath, [call]); + + const provider = new FakeProvider(); + + const config = VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + }); + const manager = new CallManager(config, storePath); + await manager.initialize(provider, "https://example.com/voice/webhook"); + + expect(manager.getActiveCalls()).toHaveLength(0); + }); + + it("keeps call when getCallStatus throws (verification failure)", async () => { + const storePath = createTestStorePath(); + const call = makePersistedCall(); + writeCallsToStore(storePath, [call]); + + const provider = new FakeProvider(); + provider.getCallStatus = async () => { + throw new Error("network failure"); + }; + + const config = VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + }); + const manager = new CallManager(config, storePath); + await manager.initialize(provider, "https://example.com/voice/webhook"); + + expect(manager.getActiveCalls()).toHaveLength(1); + }); +}); diff --git a/extensions/voice-call/src/manager.ts b/extensions/voice-call/src/manager.ts index e93e77f21..bf4aad2df 100644 --- a/extensions/voice-call/src/manager.ts +++ b/extensions/voice-call/src/manager.ts @@ -13,8 +13,15 @@ import { speakInitialMessage as speakInitialMessageWithContext, } from "./manager/outbound.js"; import { getCallHistoryFromStore, loadActiveCallsFromStore } from "./manager/store.js"; +import { startMaxDurationTimer } from "./manager/timers.js"; import type { VoiceCallProvider } from "./providers/base.js"; -import type { CallId, CallRecord, NormalizedEvent, OutboundCallOptions } from "./types.js"; +import { + TerminalStates, + type CallId, + type CallRecord, + type NormalizedEvent, + type OutboundCallOptions, +} from "./types.js"; import { resolveUserPath } from "./utils.js"; function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): string { @@ -65,18 +72,126 @@ export class CallManager { /** * Initialize the call manager with a provider. + * Verifies persisted calls with the provider and restarts timers. */ - initialize(provider: VoiceCallProvider, webhookUrl: string): void { + async initialize(provider: VoiceCallProvider, webhookUrl: string): Promise { this.provider = provider; this.webhookUrl = webhookUrl; fs.mkdirSync(this.storePath, { recursive: true }); const persisted = loadActiveCallsFromStore(this.storePath); - this.activeCalls = persisted.activeCalls; - this.providerCallIdMap = persisted.providerCallIdMap; this.processedEventIds = persisted.processedEventIds; this.rejectedProviderCallIds = persisted.rejectedProviderCallIds; + + const verified = await this.verifyRestoredCalls(provider, persisted.activeCalls); + this.activeCalls = verified; + + // Rebuild providerCallIdMap from verified calls only + this.providerCallIdMap = new Map(); + for (const [callId, call] of verified) { + if (call.providerCallId) { + this.providerCallIdMap.set(call.providerCallId, callId); + } + } + + // Restart max-duration timers for restored calls that are past the answered state + for (const [callId, call] of verified) { + if (call.answeredAt && !TerminalStates.has(call.state)) { + const elapsed = Date.now() - call.answeredAt; + const maxDurationMs = this.config.maxDurationSeconds * 1000; + if (elapsed >= maxDurationMs) { + // Already expired — remove instead of keeping + verified.delete(callId); + if (call.providerCallId) { + this.providerCallIdMap.delete(call.providerCallId); + } + console.log( + `[voice-call] Skipping restored call ${callId} (max duration already elapsed)`, + ); + continue; + } + startMaxDurationTimer({ + ctx: this.getContext(), + callId, + onTimeout: async (id) => { + await endCallWithContext(this.getContext(), id); + }, + }); + console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`); + } + } + + if (verified.size > 0) { + console.log(`[voice-call] Restored ${verified.size} active call(s) from store`); + } + } + + /** + * Verify persisted calls with the provider before restoring. + * Calls without providerCallId or older than maxDurationSeconds are skipped. + * Transient provider errors keep the call (rely on timer fallback). + */ + private async verifyRestoredCalls( + provider: VoiceCallProvider, + candidates: Map, + ): Promise> { + if (candidates.size === 0) { + return new Map(); + } + + const maxAgeMs = this.config.maxDurationSeconds * 1000; + const now = Date.now(); + const verified = new Map(); + const verifyTasks: Array<{ callId: CallId; call: CallRecord; promise: Promise }> = []; + + for (const [callId, call] of candidates) { + // Skip calls without a provider ID — can't verify + if (!call.providerCallId) { + console.log(`[voice-call] Skipping restored call ${callId} (no providerCallId)`); + continue; + } + + // Skip calls older than maxDurationSeconds (time-based fallback) + if (now - call.startedAt > maxAgeMs) { + console.log( + `[voice-call] Skipping restored call ${callId} (older than maxDurationSeconds)`, + ); + continue; + } + + const task = { + callId, + call, + promise: provider + .getCallStatus({ providerCallId: call.providerCallId }) + .then((result) => { + if (result.isTerminal) { + console.log( + `[voice-call] Skipping restored call ${callId} (provider status: ${result.status})`, + ); + } else if (result.isUnknown) { + console.log( + `[voice-call] Keeping restored call ${callId} (provider status unknown, relying on timer)`, + ); + verified.set(callId, call); + } else { + verified.set(callId, call); + } + }) + .catch(() => { + // Verification failed entirely — keep the call, rely on timer + console.log( + `[voice-call] Keeping restored call ${callId} (verification failed, relying on timer)`, + ); + verified.set(callId, call); + }), + }; + verifyTasks.push(task); + } + + await Promise.allSettled(verifyTasks.map((t) => t.promise)); + return verified; } /** diff --git a/extensions/voice-call/src/manager/events.test.ts b/extensions/voice-call/src/manager/events.test.ts index 3c292eecf..4c91f9ddd 100644 --- a/extensions/voice-call/src/manager/events.test.ts +++ b/extensions/voice-call/src/manager/events.test.ts @@ -41,6 +41,7 @@ function createProvider(overrides: Partial = {}): VoiceCallPr playTts: async () => {}, startListening: async () => {}, stopListening: async () => {}, + getCallStatus: async () => ({ status: "in-progress", isTerminal: false }), ...overrides, }; } diff --git a/extensions/voice-call/src/providers/base.ts b/extensions/voice-call/src/providers/base.ts index 2d76cc15a..37f2bdd50 100644 --- a/extensions/voice-call/src/providers/base.ts +++ b/extensions/voice-call/src/providers/base.ts @@ -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; + + /** + * 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; } diff --git a/extensions/voice-call/src/providers/mock.ts b/extensions/voice-call/src/providers/mock.ts index 6602d6e71..36211538e 100644 --- a/extensions/voice-call/src/providers/mock.ts +++ b/extensions/voice-call/src/providers/mock.ts @@ -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 { // No-op for mock } + + async getCallStatus(input: GetCallStatusInput): Promise { + 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 }; + } } diff --git a/extensions/voice-call/src/providers/plivo.ts b/extensions/voice-call/src/providers/plivo.ts index 6db603d06..992ed478b 100644 --- a/extensions/voice-call/src/providers/plivo.ts +++ b/extensions/voice-call/src/providers/plivo.ts @@ -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 { + 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:")) { diff --git a/extensions/voice-call/src/providers/telnyx.ts b/extensions/voice-call/src/providers/telnyx.ts index 80a46ce21..1ba53457c 100644 --- a/extensions/voice-call/src/providers/telnyx.ts +++ b/extensions/voice-call/src/providers/telnyx.ts @@ -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 { + 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 }; + } + } } // ----------------------------------------------------------------------------- diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index bf5515677..10c68bc93 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -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 automatically stops on speech end // No explicit action needed } + + async getCallStatus(input: GetCallStatusInput): Promise { + 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 }; + } + } } // ----------------------------------------------------------------------------- diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index 19ea3b30b..c556b7231 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -189,7 +189,7 @@ export async function createVoiceCallRuntime(params: { } } - manager.initialize(provider, webhookUrl); + await manager.initialize(provider, webhookUrl); const stop = async () => { if (tunnelResult) { diff --git a/extensions/voice-call/src/types.ts b/extensions/voice-call/src/types.ts index 6806b7cc7..dede35348 100644 --- a/extensions/voice-call/src/types.ts +++ b/extensions/voice-call/src/types.ts @@ -248,6 +248,23 @@ export type StopListeningInput = { providerCallId: ProviderCallId; }; +// ----------------------------------------------------------------------------- +// Call Status Verification (used on restart to verify persisted calls) +// ----------------------------------------------------------------------------- + +export type GetCallStatusInput = { + providerCallId: ProviderCallId; +}; + +export type GetCallStatusResult = { + /** Provider-specific status string (e.g. "completed", "in-progress") */ + status: string; + /** True when the provider confirms the call has ended */ + isTerminal: boolean; + /** True when the status could not be determined (transient error) */ + isUnknown?: boolean; +}; + // ----------------------------------------------------------------------------- // Outbound Call Options // ----------------------------------------------------------------------------- diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index 992b6ccd2..c6b63719c 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -14,6 +14,7 @@ const provider: VoiceCallProvider = { playTts: async () => {}, startListening: async () => {}, stopListening: async () => {}, + getCallStatus: async () => ({ status: "in-progress", isTerminal: false }), }; const createConfig = (overrides: Partial = {}): VoiceCallConfig => {