diff --git a/extensions/voice-call/src/providers/twilio.test.ts b/extensions/voice-call/src/providers/twilio.test.ts index 92cbe0fec32..0a88bdeae07 100644 --- a/extensions/voice-call/src/providers/twilio.test.ts +++ b/extensions/voice-call/src/providers/twilio.test.ts @@ -60,6 +60,76 @@ describe("TwilioProvider", () => { expect(result.providerResponseBody).toContain(""); }); + it("returns queue TwiML for second inbound call when first call is active", () => { + const provider = createProvider(); + const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA111"); + const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA222"); + + const firstResult = provider.parseWebhookEvent(firstInbound); + const secondResult = provider.parseWebhookEvent(secondInbound); + + expect(firstResult.providerResponseBody).toContain(""); + expect(secondResult.providerResponseBody).toContain("Please hold while we connect you."); + expect(secondResult.providerResponseBody).toContain(" { + const provider = createProvider(); + const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA311"); + const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA322"); + + provider.parseWebhookEvent(firstInbound); + provider.unregisterCallStream("CA311"); + const secondResult = provider.parseWebhookEvent(secondInbound); + + expect(secondResult.providerResponseBody).toContain(""); + expect(secondResult.providerResponseBody).not.toContain("hold-queue"); + }); + + it("cleans up active inbound call on completed status callback", () => { + const provider = createProvider(); + const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA411"); + const completed = createContext("CallStatus=completed&Direction=inbound&CallSid=CA411", { + type: "status", + }); + const nextInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA422"); + + provider.parseWebhookEvent(firstInbound); + provider.parseWebhookEvent(completed); + const nextResult = provider.parseWebhookEvent(nextInbound); + + expect(nextResult.providerResponseBody).toContain(""); + expect(nextResult.providerResponseBody).not.toContain("hold-queue"); + }); + + it("cleans up active inbound call on canceled status callback", () => { + const provider = createProvider(); + const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA511"); + const canceled = createContext("CallStatus=canceled&Direction=inbound&CallSid=CA511", { + type: "status", + }); + const nextInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA522"); + + provider.parseWebhookEvent(firstInbound); + provider.parseWebhookEvent(canceled); + const nextResult = provider.parseWebhookEvent(nextInbound); + + expect(nextResult.providerResponseBody).toContain(""); + expect(nextResult.providerResponseBody).not.toContain("hold-queue"); + }); + + it("QUEUE_TWIML references /voice/hold-music waitUrl", () => { + const provider = createProvider(); + const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA611"); + const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA622"); + + provider.parseWebhookEvent(firstInbound); + const result = provider.parseWebhookEvent(secondInbound); + + expect(result.providerResponseBody).toContain('waitUrl="/voice/hold-music"'); + }); + it("uses a stable fallback dedupeKey for identical request payloads", () => { const provider = createProvider(); const rawBody = "CallSid=CA789&Direction=inbound&SpeechResult=hello"; diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 10c68bc93d3..58ddc073273 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -95,6 +95,7 @@ export class TwilioProvider implements VoiceCallProvider { private readonly twimlStorage = new Map(); /** Track notify-mode calls to avoid streaming on follow-up callbacks */ private readonly notifyCalls = new Set(); + private readonly activeStreamCalls = new Set(); /** * Delete stored TwiML for a given `callId`. @@ -167,6 +168,7 @@ export class TwilioProvider implements VoiceCallProvider { unregisterCallStream(callSid: string): void { this.callStreamMap.delete(callSid); + this.activeStreamCalls.delete(callSid); } isValidStreamToken(callSid: string, token?: string): boolean { @@ -338,12 +340,14 @@ export class TwilioProvider implements VoiceCallProvider { case "no-answer": case "failed": this.streamAuthTokens.delete(callSid); + this.activeStreamCalls.delete(callSid); if (callIdOverride) { this.deleteStoredTwiml(callIdOverride); } return { ...baseEvent, type: "call.ended", reason: callStatus }; case "canceled": this.streamAuthTokens.delete(callSid); + this.activeStreamCalls.delete(callSid); if (callIdOverride) { this.deleteStoredTwiml(callIdOverride); } @@ -361,6 +365,12 @@ export class TwilioProvider implements VoiceCallProvider { `; + private static readonly QUEUE_TWIML = ` + + Please hold while we connect you. + hold-queue +`; + /** * Generate TwiML response for webhook. * When a call is answered, connects to media stream for bidirectional audio. @@ -412,7 +422,13 @@ export class TwilioProvider implements VoiceCallProvider { // Handle subsequent webhook requests (status callbacks, etc.) // For inbound calls, answer immediately with stream if (direction === "inbound") { + if (this.activeStreamCalls.size > 0) { + return TwilioProvider.QUEUE_TWIML; + } const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null; + if (streamUrl && callSid) { + this.activeStreamCalls.add(callSid); + } return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML; } @@ -546,6 +562,7 @@ export class TwilioProvider implements VoiceCallProvider { this.callWebhookUrls.delete(input.providerCallId); this.streamAuthTokens.delete(input.providerCallId); + this.activeStreamCalls.delete(input.providerCallId); await this.apiRequest( `/Calls/${input.providerCallId}.json`, diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index 4e1585e269e..43c31d770b8 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -284,6 +284,17 @@ export class VoiceCallWebhookServer { ): Promise { const url = new URL(req.url || "/", `http://${req.headers.host}`); + // Serve hold-music TwiML for call-waiting queue (Twilio waitUrl sends GET or POST) + if (url.pathname === "/voice/hold-music") { + res.setHeader("Content-Type", "text/xml"); + res.end(` + + All agents are currently busy. Please hold. + http://com.twilio.sounds.music.s3.amazonaws.com/MARKOVICHAMP-B8.mp3 +`); + return; + } + // Check path if (!this.isWebhookPathMatch(url.pathname, webhookPath)) { res.statusCode = 404;