feat(voice-call): add call-waiting queue for inbound Twilio calls

This commit is contained in:
Hershey Goldberger
2026-02-27 18:42:52 -05:00
committed by Peter Steinberger
parent 8824565c2a
commit dee7cda1ec
3 changed files with 98 additions and 0 deletions

View File

@@ -60,6 +60,76 @@ describe("TwilioProvider", () => {
expect(result.providerResponseBody).toContain("<Connect>");
});
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("<Connect>");
expect(secondResult.providerResponseBody).toContain("Please hold while we connect you.");
expect(secondResult.providerResponseBody).toContain("<Enqueue");
expect(secondResult.providerResponseBody).toContain("hold-queue");
});
it("connects next inbound call after unregisterCallStream cleanup", () => {
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("<Connect>");
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("<Connect>");
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("<Connect>");
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";

View File

@@ -95,6 +95,7 @@ export class TwilioProvider implements VoiceCallProvider {
private readonly twimlStorage = new Map<string, string>();
/** Track notify-mode calls to avoid streaming on follow-up callbacks */
private readonly notifyCalls = new Set<string>();
private readonly activeStreamCalls = new Set<string>();
/**
* 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 {
<Pause length="30"/>
</Response>`;
private static readonly QUEUE_TWIML = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="alice">Please hold while we connect you.</Say>
<Enqueue waitUrl="/voice/hold-music">hold-queue</Enqueue>
</Response>`;
/**
* 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`,

View File

@@ -284,6 +284,17 @@ export class VoiceCallWebhookServer {
): Promise<void> {
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(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="alice">All agents are currently busy. Please hold.</Say>
<Play loop="0">http://com.twilio.sounds.music.s3.amazonaws.com/MARKOVICHAMP-B8.mp3</Play>
</Response>`);
return;
}
// Check path
if (!this.isWebhookPathMatch(url.pathname, webhookPath)) {
res.statusCode = 404;