























@@ -8,7 +8,8 @@ import {
88} from "./config.js";
99import type { CallManager } from "./manager.js";
1010import type { VoiceCallProvider } from "./providers/base.js";
11-import type { TwilioProvider } from "./providers/twilio.js";
11+import { PlivoProvider } from "./providers/plivo.js";
12+import { TwilioProvider } from "./providers/twilio.js";
1213import type { CallRecord, NormalizedEvent } from "./types.js";
1314import { VoiceCallWebhookServer } from "./webhook.js";
1415import type { RealtimeCallHandler } from "./webhook/realtime-handler.js";
@@ -159,6 +160,21 @@ function expectWebhookUrl(url: string, expectedPath: string) {
159160expect(parsed.port).not.toBe("0");
160161}
161162163+function expectNoTwilioStreamState(provider: TwilioProvider) {
164+const state = provider as unknown as {
165+streamAuthTokens: Map<string, string>;
166+activeStreamCalls: Set<string>;
167+};
168+expect(state.streamAuthTokens.size).toBe(0);
169+expect(state.activeStreamCalls.size).toBe(0);
170+}
171+172+async function expectTwilioReplayTwiML(response: Response) {
173+expect(response.status).toBe(200);
174+expect(response.headers.get("content-type")).toContain("text/xml");
175+expect(await response.text()).toBe('<?xml version="1.0" encoding="UTF-8"?><Response></Response>');
176+}
177+162178function createTwilioVerificationProvider(
163179overrides: Partial<TwilioProviderTestDouble> = {},
164180): VoiceCallProvider {
@@ -686,24 +702,25 @@ describe("VoiceCallWebhookServer path matching", () => {
686702687703describe("VoiceCallWebhookServer replay handling", () => {
688704it("acknowledges replayed webhook requests and skips event side effects", async () => {
705+const parseWebhookEvent = vi.fn(() => ({
706+events: [
707+{
708+id: "evt-replay",
709+dedupeKey: "stable-replay",
710+type: "call.speech" as const,
711+callId: "call-1",
712+providerCallId: "provider-call-1",
713+timestamp: Date.now(),
714+transcript: "hello",
715+isFinal: true,
716+},
717+],
718+statusCode: 200,
719+}));
689720const replayProvider: VoiceCallProvider = {
690721 ...provider,
691722verifyWebhook: () => ({ ok: true, isReplay: true, verifiedRequestKey: "mock:req:replay" }),
692-parseWebhookEvent: () => ({
693-events: [
694-{
695-id: "evt-replay",
696-dedupeKey: "stable-replay",
697-type: "call.speech",
698-callId: "call-1",
699-providerCallId: "provider-call-1",
700-timestamp: Date.now(),
701-transcript: "hello",
702-isFinal: true,
703-},
704-],
705-statusCode: 200,
706-}),
723+ parseWebhookEvent,
707724};
708725const { manager, processEvent } = createManager([]);
709726const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } });
@@ -714,14 +731,81 @@ describe("VoiceCallWebhookServer replay handling", () => {
714731const response = await postWebhookForm(server, baseUrl, "CallSid=CA123&SpeechResult=hello");
715732716733expect(response.status).toBe(200);
734+expect(await response.text()).toBe("OK");
735+expect(parseWebhookEvent).toHaveBeenCalledTimes(1);
736+expect(processEvent).not.toHaveBeenCalled();
737+} finally {
738+await server.stop();
739+}
740+});
741+742+it("returns Plivo XML for replayed answer callbacks while skipping event side effects", async () => {
743+const plivoProvider = new PlivoProvider(
744+{
745+authId: "MA000000000000000000",
746+authToken: "test-token",
747+},
748+{ skipVerification: true },
749+);
750+const parseWebhookEvent = vi.spyOn(plivoProvider, "parseWebhookEvent");
751+const { manager, processEvent } = createManager([]);
752+const config = createConfig({
753+provider: "plivo",
754+skipSignatureVerification: true,
755+plivo: {
756+authId: "MA000000000000000000",
757+authToken: "test-token",
758+},
759+});
760+const server = new VoiceCallWebhookServer(config, manager, plivoProvider);
761+762+try {
763+const baseUrl = await server.start();
764+const requestUrl = requireBoundRequestUrl(server, baseUrl);
765+requestUrl.searchParams.set("provider", "plivo");
766+requestUrl.searchParams.set("flow", "answer");
767+requestUrl.searchParams.set("callId", "internal-call-id");
768+const body =
769+"CallUUID=plivo-replay-answer-callback&CallStatus=in-progress&Direction=outbound&From=%2B15550000000&To=%2B15550000001&Event=StartApp";
770+771+const first = await fetch(requestUrl.toString(), {
772+method: "POST",
773+headers: { "content-type": "application/x-www-form-urlencoded" },
774+ body,
775+});
776+expect(first.status).toBe(200);
777+expect(first.headers.get("content-type")).toContain("text/xml");
778+expect(await first.text()).toContain("<Wait");
779+expect(parseWebhookEvent).toHaveBeenCalledTimes(1);
780+expect(processEvent).toHaveBeenCalledTimes(1);
781+782+parseWebhookEvent.mockClear();
783+processEvent.mockClear();
784+785+const replay = await fetch(requestUrl.toString(), {
786+method: "POST",
787+headers: { "content-type": "application/x-www-form-urlencoded" },
788+ body,
789+});
790+791+expect(replay.status).toBe(200);
792+expect(replay.headers.get("content-type")).toContain("text/xml");
793+expect(await replay.text()).toContain("<Wait");
794+expect(parseWebhookEvent).not.toHaveBeenCalled();
717795expect(processEvent).not.toHaveBeenCalled();
718796} finally {
797+parseWebhookEvent.mockRestore();
719798await server.stop();
720799}
721800});
722801723-it("returns realtime TwiML for replayed inbound twilio webhooks", async () => {
802+it("does not return realtime TwiML for replayed inbound twilio webhooks", async () => {
724803const parseWebhookEvent = vi.fn(() => ({ events: [], statusCode: 200 }));
804+const buildTwiMLPayload = vi.fn(() => ({
805+statusCode: 200,
806+headers: { "Content-Type": "text/xml" },
807+body: '<Response><Connect><Stream url="wss://example.test/voice/stream/realtime/token" /></Connect></Response>',
808+}));
725809const twilioProvider: VoiceCallProvider = {
726810 ...provider,
727811name: "twilio",
@@ -743,11 +827,7 @@ describe("VoiceCallWebhookServer replay handling", () => {
743827});
744828const server = new VoiceCallWebhookServer(config, manager, twilioProvider);
745829server.setRealtimeHandler({
746-buildTwiMLPayload: () => ({
747-statusCode: 200,
748-headers: { "Content-Type": "text/xml" },
749-body: '<Response><Connect><Stream url="wss://example.test/voice/stream/realtime/token" /></Connect></Response>',
750-}),
830+ buildTwiMLPayload,
751831getStreamPathPattern: () => "/voice/stream/realtime",
752832handleWebSocketUpgrade: () => {},
753833registerToolHandler: () => {},
@@ -763,8 +843,8 @@ describe("VoiceCallWebhookServer replay handling", () => {
763843{ "x-twilio-signature": "sig" },
764844);
765845766-expect(response.status).toBe(200);
767-expect(await response.text()).toContain("<Connect><Stream");
846+await expectTwilioReplayTwiML(response);
847+expect(buildTwiMLPayload).not.toHaveBeenCalled();
768848expect(parseWebhookEvent).not.toHaveBeenCalled();
769849expect(processEvent).not.toHaveBeenCalled();
770850} finally {
@@ -829,6 +909,127 @@ describe("VoiceCallWebhookServer replay handling", () => {
829909},
830910);
831911912+it.each(["outbound-api", "outbound-dial"] as const)(
913+"does not return realtime TwiML for replayed %s twilio TwiML fetches",
914+async (direction) => {
915+const parseWebhookEvent = vi.fn(() => ({ events: [], statusCode: 200 }));
916+const buildTwiMLPayload = vi.fn(() => ({
917+statusCode: 200,
918+headers: { "Content-Type": "text/xml" },
919+body: '<Response><Connect><Stream url="wss://example.test/voice/stream/realtime/token" /></Connect></Response>',
920+}));
921+const twilioProvider: VoiceCallProvider = {
922+ ...provider,
923+name: "twilio",
924+verifyWebhook: () => ({
925+ok: true,
926+isReplay: true,
927+verifiedRequestKey: "twilio:req:rt-outbound-replay",
928+}),
929+ parseWebhookEvent,
930+};
931+const { manager, processEvent } = createManager([]);
932+const config = createConfig({
933+provider: "twilio",
934+inboundPolicy: "disabled",
935+realtime: {
936+enabled: true,
937+streamPath: "/voice/stream/realtime",
938+instructions: "Be helpful.",
939+toolPolicy: "safe-read-only",
940+tools: [],
941+providers: {},
942+},
943+});
944+const server = new VoiceCallWebhookServer(config, manager, twilioProvider);
945+server.setRealtimeHandler({
946+ buildTwiMLPayload,
947+getStreamPathPattern: () => "/voice/stream/realtime",
948+handleWebSocketUpgrade: () => {},
949+registerToolHandler: () => {},
950+setPublicUrl: () => {},
951+} as unknown as RealtimeCallHandler);
952+953+try {
954+const baseUrl = await server.start();
955+const response = await postWebhookFormWithHeaders(
956+server,
957+baseUrl,
958+`CallSid=CA123&Direction=${direction}&CallStatus=in-progress&From=%2B15550001111&To=%2B15550002222`,
959+{ "x-twilio-signature": "sig" },
960+);
961+962+await expectTwilioReplayTwiML(response);
963+expect(buildTwiMLPayload).not.toHaveBeenCalled();
964+expect(parseWebhookEvent).not.toHaveBeenCalled();
965+expect(processEvent).not.toHaveBeenCalled();
966+} finally {
967+await server.stop();
968+}
969+},
970+);
971+972+it("does not let real Twilio provider parsing mint stream state for replayed realtime requests", async () => {
973+const twilioProvider = new TwilioProvider(
974+{ accountSid: "AC123", authToken: "secret" },
975+{
976+publicUrl: "https://example.test",
977+streamPath: "/voice/stream/realtime",
978+skipVerification: true,
979+},
980+);
981+const parseWebhookEvent = vi.spyOn(twilioProvider, "parseWebhookEvent");
982+const buildTwiMLPayload = vi.fn(() => ({
983+statusCode: 200,
984+headers: { "Content-Type": "text/xml" },
985+body: '<Response><Connect><Stream url="wss://example.test/voice/stream/realtime/server-token" /></Connect></Response>',
986+}));
987+const { manager, processEvent } = createManager([]);
988+const config = createConfig({
989+provider: "twilio",
990+inboundPolicy: "open",
991+realtime: {
992+enabled: true,
993+streamPath: "/voice/stream/realtime",
994+instructions: "Be helpful.",
995+toolPolicy: "safe-read-only",
996+tools: [],
997+providers: {},
998+},
999+});
1000+const server = new VoiceCallWebhookServer(config, manager, twilioProvider);
1001+server.setRealtimeHandler({
1002+ buildTwiMLPayload,
1003+getStreamPathPattern: () => "/voice/stream/realtime",
1004+handleWebSocketUpgrade: () => {},
1005+registerToolHandler: () => {},
1006+setPublicUrl: () => {},
1007+} as unknown as RealtimeCallHandler);
1008+1009+try {
1010+const baseUrl = await server.start();
1011+const body = "CallSid=CAREALREPLAY1&Direction=inbound&CallStatus=ringing";
1012+const first = await postWebhookFormWithHeaders(server, baseUrl, body, {
1013+"x-twilio-signature": "sig",
1014+});
1015+const replay = await postWebhookFormWithHeaders(server, baseUrl, body, {
1016+"x-twilio-signature": "sig",
1017+});
1018+1019+expect(first.status).toBe(200);
1020+const firstBody = await first.text();
1021+expect(firstBody).toContain("server-token");
1022+await expectTwilioReplayTwiML(replay);
1023+expect(buildTwiMLPayload).toHaveBeenCalledTimes(1);
1024+expect(parseWebhookEvent).not.toHaveBeenCalled();
1025+expectNoTwilioStreamState(twilioProvider);
1026+expect(processEvent).not.toHaveBeenCalled();
1027+} finally {
1028+parseWebhookEvent.mockRestore();
1029+await server.stop();
1030+}
1031+});
1032+8321033it("serves initial provider TwiML before the realtime shortcut", async () => {
8331034const parseWebhookEvent = vi.fn(() => ({ events: [], statusCode: 200 }));
8341035const consumeInitialTwiML = vi.fn(
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。