mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
feat(voice-call): add call-waiting queue for inbound Twilio calls
This commit is contained in:
committed by
Peter Steinberger
parent
8824565c2a
commit
dee7cda1ec
@@ -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";
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user