From 5da8d8ad7fbf30ce941c3d34b6b6b3752b62f155 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 18:04:47 +0800 Subject: [PATCH] fix(voice-call): release Twilio provider call state --- extensions/voice-call/src/providers/twilio.ts | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 4378fc542a81..13dead95099c 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -118,23 +118,27 @@ export class TwilioProvider implements VoiceCallProvider { } /** - * Delete stored TwiML for a call, addressed by Twilio's provider call SID. - * - * This is used when we only have `providerCallId` (e.g. hangup). + * Release all process-local metadata owned by one Twilio call. + * Terminal webhooks can be replayed, so this must stay idempotent. */ - private deleteStoredTwimlForProviderCall(providerCallId: string): void { + private releaseCallState(providerCallId: string, callId?: string): void { const webhookUrl = this.callWebhookUrls.get(providerCallId); - if (!webhookUrl) { - return; + let resolvedCallId = callId; + if (!resolvedCallId && webhookUrl) { + try { + resolvedCallId = new URL(webhookUrl).searchParams.get("callId") || undefined; + } catch { + // The provider only stores URLs it constructed, but cleanup must still + // release provider-keyed state if a malformed value is injected. + } } - - const callId = webhookUrl.match(/callId=([^&]+)/)?.[1]; - if (!callId) { - return; + if (resolvedCallId) { + this.deleteStoredTwiml(resolvedCallId); } - - this.deleteStoredTwiml(callId); + this.callWebhookUrls.delete(providerCallId); + this.callStreamMap.delete(providerCallId); this.streamAuthTokens.delete(providerCallId); + this.activeStreamCalls.delete(providerCallId); } constructor(config: TwilioProviderConfig, options: TwilioProviderOptions = {}) { @@ -387,12 +391,9 @@ export class TwilioProvider implements VoiceCallProvider { const endReason = mapProviderStatusToEndReason(callStatus); if (endReason) { - this.streamAuthTokens.delete(callSid); - this.activeStreamCalls.delete(callSid); - if (callIdOverride) { - this.deleteStoredTwiml(callIdOverride); - } - return { ...baseEvent, type: "call.ended", reason: endReason }; + const event = { ...baseEvent, type: "call.ended" as const, reason: endReason }; + this.releaseCallState(callSid, callIdOverride); + return event; } return null; @@ -592,11 +593,7 @@ export class TwilioProvider implements VoiceCallProvider { * Hang up a call via Twilio API. */ async hangupCall(input: HangupCallInput): Promise { - this.deleteStoredTwimlForProviderCall(input.providerCallId); - - this.callWebhookUrls.delete(input.providerCallId); - this.streamAuthTokens.delete(input.providerCallId); - this.activeStreamCalls.delete(input.providerCallId); + this.releaseCallState(input.providerCallId, input.callId); await this.apiRequest( `/Calls/${input.providerCallId}.json`,