@openclaw/voice-call 2026.3.13 → 2026.5.2-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -5
- package/api.ts +16 -0
- package/cli-metadata.ts +10 -0
- package/config-api.ts +12 -0
- package/index.test.ts +943 -0
- package/index.ts +379 -149
- package/openclaw.plugin.json +384 -157
- package/package.json +35 -5
- package/runtime-api.ts +20 -0
- package/runtime-entry.ts +1 -0
- package/setup-api.ts +47 -0
- package/src/allowlist.test.ts +18 -0
- package/src/cli.ts +533 -68
- package/src/config-compat.test.ts +120 -0
- package/src/config-compat.ts +227 -0
- package/src/config.test.ts +273 -12
- package/src/config.ts +355 -72
- package/src/core-bridge.ts +2 -147
- package/src/deep-merge.test.ts +40 -0
- package/src/gateway-continue-operation.ts +200 -0
- package/src/http-headers.ts +6 -3
- package/src/manager/context.ts +6 -5
- package/src/manager/events.test.ts +243 -19
- package/src/manager/events.ts +61 -31
- package/src/manager/lifecycle.ts +53 -0
- package/src/manager/lookup.test.ts +52 -0
- package/src/manager/outbound.test.ts +528 -0
- package/src/manager/outbound.ts +163 -57
- package/src/manager/store.ts +18 -6
- package/src/manager/timers.test.ts +129 -0
- package/src/manager/timers.ts +4 -3
- package/src/manager/twiml.test.ts +13 -0
- package/src/manager/twiml.ts +8 -0
- package/src/manager.closed-loop.test.ts +30 -12
- package/src/manager.inbound-allowlist.test.ts +77 -10
- package/src/manager.notify.test.ts +344 -20
- package/src/manager.restore.test.ts +95 -8
- package/src/manager.test-harness.ts +8 -6
- package/src/manager.ts +79 -5
- package/src/media-stream.test.ts +578 -81
- package/src/media-stream.ts +235 -54
- package/src/providers/base.ts +19 -0
- package/src/providers/mock.ts +7 -1
- package/src/providers/plivo.test.ts +50 -6
- package/src/providers/plivo.ts +14 -6
- package/src/providers/shared/call-status.ts +2 -1
- package/src/providers/shared/guarded-json-api.test.ts +106 -0
- package/src/providers/shared/guarded-json-api.ts +1 -1
- package/src/providers/telnyx.test.ts +178 -6
- package/src/providers/telnyx.ts +40 -3
- package/src/providers/twilio/api.test.ts +145 -0
- package/src/providers/twilio/api.ts +67 -16
- package/src/providers/twilio/twiml-policy.ts +6 -10
- package/src/providers/twilio/webhook.ts +1 -1
- package/src/providers/twilio.test.ts +425 -25
- package/src/providers/twilio.ts +230 -77
- package/src/providers/twilio.types.ts +17 -0
- package/src/realtime-defaults.ts +3 -0
- package/src/realtime-fast-context.test.ts +88 -0
- package/src/realtime-fast-context.ts +165 -0
- package/src/realtime-transcription.runtime.ts +4 -0
- package/src/realtime-voice.runtime.ts +5 -0
- package/src/response-generator.test.ts +321 -0
- package/src/response-generator.ts +213 -53
- package/src/response-model.test.ts +71 -0
- package/src/response-model.ts +23 -0
- package/src/runtime.test.ts +429 -0
- package/src/runtime.ts +270 -24
- package/src/telephony-audio.test.ts +61 -0
- package/src/telephony-audio.ts +1 -79
- package/src/telephony-tts.test.ts +133 -12
- package/src/telephony-tts.ts +155 -2
- package/src/test-fixtures.ts +28 -7
- package/src/tts-provider-voice.test.ts +34 -0
- package/src/tts-provider-voice.ts +21 -0
- package/src/tunnel.test.ts +166 -0
- package/src/tunnel.ts +1 -1
- package/src/types.ts +24 -37
- package/src/utils.test.ts +17 -0
- package/src/voice-mapping.test.ts +34 -0
- package/src/voice-mapping.ts +3 -2
- package/src/webhook/realtime-handler.test.ts +598 -0
- package/src/webhook/realtime-handler.ts +485 -0
- package/src/webhook/stale-call-reaper.test.ts +88 -0
- package/src/webhook/stale-call-reaper.ts +5 -0
- package/src/webhook/tailscale.test.ts +214 -0
- package/src/webhook/tailscale.ts +19 -5
- package/src/webhook-exposure.test.ts +33 -0
- package/src/webhook-exposure.ts +84 -0
- package/src/webhook-security.test.ts +172 -21
- package/src/webhook-security.ts +43 -29
- package/src/webhook.hangup-once.lifecycle.test.ts +135 -0
- package/src/webhook.test.ts +1145 -27
- package/src/webhook.ts +523 -102
- package/src/webhook.types.ts +5 -0
- package/src/websocket-test-support.ts +72 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -121
- package/src/providers/index.ts +0 -10
- package/src/providers/stt-openai-realtime.test.ts +0 -42
- package/src/providers/stt-openai-realtime.ts +0 -311
- package/src/providers/tts-openai.test.ts +0 -43
- package/src/providers/tts-openai.ts +0 -221
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { createManagerHarness } from "./manager.test-harness.js";
|
|
2
|
+
import { FakeProvider, createManagerHarness } from "./manager.test-harness.js";
|
|
3
3
|
|
|
4
4
|
describe("CallManager inbound allowlist", () => {
|
|
5
5
|
it("rejects inbound calls with missing caller ID when allowlist enabled", async () => {
|
|
@@ -19,8 +19,9 @@ describe("CallManager inbound allowlist", () => {
|
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
expect(manager.getCallByProviderCallId("provider-missing")).toBeUndefined();
|
|
22
|
-
expect(provider.hangupCalls).
|
|
23
|
-
|
|
22
|
+
expect(provider.hangupCalls).toEqual([
|
|
23
|
+
expect.objectContaining({ providerCallId: "provider-missing" }),
|
|
24
|
+
]);
|
|
24
25
|
});
|
|
25
26
|
|
|
26
27
|
it("rejects inbound calls with anonymous caller ID when allowlist enabled", async () => {
|
|
@@ -41,8 +42,9 @@ describe("CallManager inbound allowlist", () => {
|
|
|
41
42
|
});
|
|
42
43
|
|
|
43
44
|
expect(manager.getCallByProviderCallId("provider-anon")).toBeUndefined();
|
|
44
|
-
expect(provider.hangupCalls).
|
|
45
|
-
|
|
45
|
+
expect(provider.hangupCalls).toEqual([
|
|
46
|
+
expect.objectContaining({ providerCallId: "provider-anon" }),
|
|
47
|
+
]);
|
|
46
48
|
});
|
|
47
49
|
|
|
48
50
|
it("rejects inbound calls that only match allowlist suffixes", async () => {
|
|
@@ -63,8 +65,9 @@ describe("CallManager inbound allowlist", () => {
|
|
|
63
65
|
});
|
|
64
66
|
|
|
65
67
|
expect(manager.getCallByProviderCallId("provider-suffix")).toBeUndefined();
|
|
66
|
-
expect(provider.hangupCalls).
|
|
67
|
-
|
|
68
|
+
expect(provider.hangupCalls).toEqual([
|
|
69
|
+
expect.objectContaining({ providerCallId: "provider-suffix" }),
|
|
70
|
+
]);
|
|
68
71
|
});
|
|
69
72
|
|
|
70
73
|
it("rejects duplicate inbound events with a single hangup call", async () => {
|
|
@@ -95,8 +98,60 @@ describe("CallManager inbound allowlist", () => {
|
|
|
95
98
|
});
|
|
96
99
|
|
|
97
100
|
expect(manager.getCallByProviderCallId("provider-dup")).toBeUndefined();
|
|
98
|
-
expect(provider.hangupCalls).
|
|
99
|
-
|
|
101
|
+
expect(provider.hangupCalls).toEqual([
|
|
102
|
+
expect.objectContaining({ providerCallId: "provider-dup" }),
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("retries rejected inbound hangup after a transient provider failure", async () => {
|
|
107
|
+
class FlakyHangupProvider extends FakeProvider {
|
|
108
|
+
hangupFailuresRemaining = 1;
|
|
109
|
+
|
|
110
|
+
override async hangupCall(input: Parameters<FakeProvider["hangupCall"]>[0]): Promise<void> {
|
|
111
|
+
this.hangupCalls.push(input);
|
|
112
|
+
if (this.hangupFailuresRemaining > 0) {
|
|
113
|
+
this.hangupFailuresRemaining -= 1;
|
|
114
|
+
throw new Error("provider down");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const provider = new FlakyHangupProvider();
|
|
120
|
+
const { manager } = await createManagerHarness(
|
|
121
|
+
{
|
|
122
|
+
inboundPolicy: "disabled",
|
|
123
|
+
},
|
|
124
|
+
provider,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
manager.processEvent({
|
|
128
|
+
id: "evt-reject-fail-init",
|
|
129
|
+
type: "call.initiated",
|
|
130
|
+
callId: "provider-flaky",
|
|
131
|
+
providerCallId: "provider-flaky",
|
|
132
|
+
timestamp: Date.now(),
|
|
133
|
+
direction: "inbound",
|
|
134
|
+
from: "+15553333333",
|
|
135
|
+
to: "+15550000000",
|
|
136
|
+
});
|
|
137
|
+
await Promise.resolve();
|
|
138
|
+
|
|
139
|
+
manager.processEvent({
|
|
140
|
+
id: "evt-reject-fail-ring",
|
|
141
|
+
type: "call.ringing",
|
|
142
|
+
callId: "provider-flaky",
|
|
143
|
+
providerCallId: "provider-flaky",
|
|
144
|
+
timestamp: Date.now(),
|
|
145
|
+
direction: "inbound",
|
|
146
|
+
from: "+15553333333",
|
|
147
|
+
to: "+15550000000",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(manager.getCallByProviderCallId("provider-flaky")).toBeUndefined();
|
|
151
|
+
expect(provider.hangupCalls).toEqual([
|
|
152
|
+
expect.objectContaining({ providerCallId: "provider-flaky" }),
|
|
153
|
+
expect.objectContaining({ providerCallId: "provider-flaky" }),
|
|
154
|
+
]);
|
|
100
155
|
});
|
|
101
156
|
|
|
102
157
|
it("accepts inbound calls that exactly match the allowlist", async () => {
|
|
@@ -116,6 +171,18 @@ describe("CallManager inbound allowlist", () => {
|
|
|
116
171
|
to: "+15550000000",
|
|
117
172
|
});
|
|
118
173
|
|
|
119
|
-
|
|
174
|
+
const call = manager.getCallByProviderCallId("provider-exact");
|
|
175
|
+
if (!call) {
|
|
176
|
+
throw new Error("expected exact allowlist match to keep the inbound call");
|
|
177
|
+
}
|
|
178
|
+
expect(call).toMatchObject({
|
|
179
|
+
providerCallId: "provider-exact",
|
|
180
|
+
direction: "inbound",
|
|
181
|
+
from: "+15550001234",
|
|
182
|
+
to: "+15550000000",
|
|
183
|
+
});
|
|
184
|
+
expect(call.callId).toMatch(
|
|
185
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
186
|
+
);
|
|
120
187
|
});
|
|
121
188
|
});
|
|
@@ -1,6 +1,119 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { createManagerHarness, FakeProvider } from "./manager.test-harness.js";
|
|
3
3
|
|
|
4
|
+
class FailFirstPlayTtsProvider extends FakeProvider {
|
|
5
|
+
private failed = false;
|
|
6
|
+
|
|
7
|
+
override async playTts(input: Parameters<FakeProvider["playTts"]>[0]): Promise<void> {
|
|
8
|
+
this.playTtsCalls.push(input);
|
|
9
|
+
if (!this.failed) {
|
|
10
|
+
this.failed = true;
|
|
11
|
+
throw new Error("synthetic tts failure");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class DelayedPlayTtsProvider extends FakeProvider {
|
|
17
|
+
private releasePlayTts: (() => void) | null = null;
|
|
18
|
+
private resolvePlayTtsStarted: (() => void) | null = null;
|
|
19
|
+
readonly playTtsStarted = vi.fn();
|
|
20
|
+
readonly playTtsStartedPromise = new Promise<void>((resolve) => {
|
|
21
|
+
this.resolvePlayTtsStarted = resolve;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
override async playTts(input: Parameters<FakeProvider["playTts"]>[0]): Promise<void> {
|
|
25
|
+
this.playTtsCalls.push(input);
|
|
26
|
+
this.playTtsStarted();
|
|
27
|
+
this.resolvePlayTtsStarted?.();
|
|
28
|
+
this.resolvePlayTtsStarted = null;
|
|
29
|
+
await new Promise<void>((resolve) => {
|
|
30
|
+
this.releasePlayTts = resolve;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
releaseCurrentPlayback(): void {
|
|
35
|
+
this.releasePlayTts?.();
|
|
36
|
+
this.releasePlayTts = null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class FailStartListeningProvider extends FakeProvider {
|
|
41
|
+
override async startListening(
|
|
42
|
+
input: Parameters<FakeProvider["startListening"]>[0],
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
this.startListeningCalls.push(input);
|
|
45
|
+
throw new Error("synthetic start listening failure");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function requireCall(
|
|
50
|
+
manager: Awaited<ReturnType<typeof createManagerHarness>>["manager"],
|
|
51
|
+
callId: string,
|
|
52
|
+
) {
|
|
53
|
+
const call = manager.getCall(callId);
|
|
54
|
+
if (!call) {
|
|
55
|
+
throw new Error(`expected active call ${callId}`);
|
|
56
|
+
}
|
|
57
|
+
return call;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function requireMappedCall(
|
|
61
|
+
manager: Awaited<ReturnType<typeof createManagerHarness>>["manager"],
|
|
62
|
+
providerCallId: string,
|
|
63
|
+
) {
|
|
64
|
+
const call = manager.getCallByProviderCallId(providerCallId);
|
|
65
|
+
if (!call) {
|
|
66
|
+
throw new Error(`expected mapped provider call ${providerCallId}`);
|
|
67
|
+
}
|
|
68
|
+
return call;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function requireFirstPlayTtsCall(provider: FakeProvider) {
|
|
72
|
+
const call = provider.playTtsCalls[0];
|
|
73
|
+
if (!call) {
|
|
74
|
+
throw new Error("expected provider.playTts to be called once");
|
|
75
|
+
}
|
|
76
|
+
return call;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type HarnessManager = Awaited<ReturnType<typeof createManagerHarness>>["manager"];
|
|
80
|
+
|
|
81
|
+
async function waitForPlaybackDispatch() {
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function initiateCallWithMessage(
|
|
86
|
+
manager: HarnessManager,
|
|
87
|
+
to: string,
|
|
88
|
+
message: string,
|
|
89
|
+
mode: "notify" | "conversation",
|
|
90
|
+
) {
|
|
91
|
+
const { callId, success } = await manager.initiateCall(to, undefined, { message, mode });
|
|
92
|
+
expect(success).toBe(true);
|
|
93
|
+
return callId;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function answerCall(
|
|
97
|
+
manager: HarnessManager,
|
|
98
|
+
callId: string,
|
|
99
|
+
eventId: string,
|
|
100
|
+
providerCallId = "call-uuid",
|
|
101
|
+
) {
|
|
102
|
+
manager.processEvent({
|
|
103
|
+
id: eventId,
|
|
104
|
+
type: "call.answered",
|
|
105
|
+
callId,
|
|
106
|
+
providerCallId,
|
|
107
|
+
timestamp: Date.now(),
|
|
108
|
+
});
|
|
109
|
+
await waitForPlaybackDispatch();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function expectFirstPlayTtsText(provider: FakeProvider, text: string) {
|
|
113
|
+
expect(provider.playTtsCalls).toHaveLength(1);
|
|
114
|
+
expect(requireFirstPlayTtsCall(provider).text).toBe(text);
|
|
115
|
+
}
|
|
116
|
+
|
|
4
117
|
describe("CallManager notify and mapping", () => {
|
|
5
118
|
it("upgrades providerCallId mapping when provider ID changes", async () => {
|
|
6
119
|
const { manager } = await createManagerHarness();
|
|
@@ -9,8 +122,8 @@ describe("CallManager notify and mapping", () => {
|
|
|
9
122
|
expect(success).toBe(true);
|
|
10
123
|
expect(error).toBeUndefined();
|
|
11
124
|
|
|
12
|
-
expect(manager
|
|
13
|
-
expect(manager
|
|
125
|
+
expect(requireCall(manager, callId).providerCallId).toBe("request-uuid");
|
|
126
|
+
expect(requireMappedCall(manager, "request-uuid").callId).toBe(callId);
|
|
14
127
|
|
|
15
128
|
manager.processEvent({
|
|
16
129
|
id: "evt-1",
|
|
@@ -20,8 +133,8 @@ describe("CallManager notify and mapping", () => {
|
|
|
20
133
|
timestamp: Date.now(),
|
|
21
134
|
});
|
|
22
135
|
|
|
23
|
-
expect(manager
|
|
24
|
-
expect(manager
|
|
136
|
+
expect(requireCall(manager, callId).providerCallId).toBe("call-uuid");
|
|
137
|
+
expect(requireMappedCall(manager, "call-uuid").callId).toBe(callId);
|
|
25
138
|
expect(manager.getCallByProviderCallId("request-uuid")).toBeUndefined();
|
|
26
139
|
});
|
|
27
140
|
|
|
@@ -30,24 +143,235 @@ describe("CallManager notify and mapping", () => {
|
|
|
30
143
|
async (providerName) => {
|
|
31
144
|
const { manager, provider } = await createManagerHarness({}, new FakeProvider(providerName));
|
|
32
145
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
146
|
+
const callId = await initiateCallWithMessage(
|
|
147
|
+
manager,
|
|
148
|
+
"+15550000002",
|
|
149
|
+
"Hello there",
|
|
150
|
+
"notify",
|
|
151
|
+
);
|
|
152
|
+
await answerCall(manager, callId, `evt-2-${providerName}`);
|
|
38
153
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
154
|
+
expectFirstPlayTtsText(provider, "Hello there");
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
it("speaks initial message on answered for conversation mode with non-stream provider", async () => {
|
|
159
|
+
const { manager, provider } = await createManagerHarness({}, new FakeProvider("plivo"));
|
|
160
|
+
|
|
161
|
+
const callId = await initiateCallWithMessage(
|
|
162
|
+
manager,
|
|
163
|
+
"+15550000003",
|
|
164
|
+
"Hello from conversation",
|
|
165
|
+
"conversation",
|
|
166
|
+
);
|
|
167
|
+
await answerCall(manager, callId, "evt-conversation-plivo");
|
|
168
|
+
|
|
169
|
+
expectFirstPlayTtsText(provider, "Hello from conversation");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("speaks initial message on answered for conversation mode when Twilio streaming is disabled", async () => {
|
|
173
|
+
const { manager, provider } = await createManagerHarness(
|
|
174
|
+
{ streaming: { enabled: false } },
|
|
175
|
+
new FakeProvider("twilio"),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const callId = await initiateCallWithMessage(
|
|
179
|
+
manager,
|
|
180
|
+
"+15550000004",
|
|
181
|
+
"Twilio non-stream",
|
|
182
|
+
"conversation",
|
|
183
|
+
);
|
|
184
|
+
await answerCall(manager, callId, "evt-conversation-twilio-no-stream");
|
|
185
|
+
|
|
186
|
+
expectFirstPlayTtsText(provider, "Twilio non-stream");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("lets realtime conversations own the initial greeting instead of posting legacy TwiML", async () => {
|
|
190
|
+
const { manager, provider } = await createManagerHarness(
|
|
191
|
+
{ realtime: { enabled: true, provider: "openai" } },
|
|
192
|
+
new FakeProvider("twilio"),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const callId = await initiateCallWithMessage(
|
|
196
|
+
manager,
|
|
197
|
+
"+15550000010",
|
|
198
|
+
"Tell Nana dinner is at 6pm.",
|
|
199
|
+
"conversation",
|
|
200
|
+
);
|
|
201
|
+
await answerCall(manager, callId, "evt-conversation-twilio-realtime");
|
|
202
|
+
|
|
203
|
+
expect(provider.playTtsCalls).toHaveLength(0);
|
|
204
|
+
expect(requireCall(manager, callId).metadata).toEqual(
|
|
205
|
+
expect.objectContaining({ initialMessage: "Tell Nana dinner is at 6pm." }),
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("still speaks initial message in notify mode when realtime is enabled", async () => {
|
|
210
|
+
const { manager, provider } = await createManagerHarness(
|
|
211
|
+
{ realtime: { enabled: true, provider: "openai" } },
|
|
212
|
+
new FakeProvider("twilio"),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const callId = await initiateCallWithMessage(manager, "+15550000011", "Notify text", "notify");
|
|
216
|
+
await answerCall(manager, callId, "evt-notify-twilio-realtime");
|
|
217
|
+
|
|
218
|
+
expectFirstPlayTtsText(provider, "Notify text");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("waits for stream connect in conversation mode when Twilio streaming is enabled", async () => {
|
|
222
|
+
const { manager, provider } = await createManagerHarness(
|
|
223
|
+
{ streaming: { enabled: true } },
|
|
224
|
+
new FakeProvider("twilio"),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const callId = await initiateCallWithMessage(
|
|
228
|
+
manager,
|
|
229
|
+
"+15550000005",
|
|
230
|
+
"Twilio stream",
|
|
231
|
+
"conversation",
|
|
232
|
+
);
|
|
233
|
+
await answerCall(manager, callId, "evt-conversation-twilio-stream");
|
|
234
|
+
|
|
235
|
+
expect(provider.playTtsCalls).toHaveLength(0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("speaks on answered when Twilio streaming is enabled but stream-connect path is unavailable", async () => {
|
|
239
|
+
const twilioProvider = new FakeProvider("twilio");
|
|
240
|
+
twilioProvider.twilioStreamConnectEnabled = false;
|
|
241
|
+
const { manager, provider } = await createManagerHarness(
|
|
242
|
+
{ streaming: { enabled: true } },
|
|
243
|
+
twilioProvider,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const callId = await initiateCallWithMessage(
|
|
247
|
+
manager,
|
|
248
|
+
"+15550000009",
|
|
249
|
+
"Twilio stream unavailable",
|
|
250
|
+
"conversation",
|
|
251
|
+
);
|
|
252
|
+
await answerCall(manager, callId, "evt-conversation-twilio-stream-unavailable");
|
|
253
|
+
|
|
254
|
+
expectFirstPlayTtsText(provider, "Twilio stream unavailable");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("starts listening after the initial greeting for Telnyx conversation calls", async () => {
|
|
258
|
+
const { manager, provider } = await createManagerHarness({}, new FakeProvider("telnyx"));
|
|
259
|
+
|
|
260
|
+
const callId = await initiateCallWithMessage(
|
|
261
|
+
manager,
|
|
262
|
+
"+15550000012",
|
|
263
|
+
"Telnyx hello",
|
|
264
|
+
"conversation",
|
|
265
|
+
);
|
|
266
|
+
await answerCall(manager, callId, "evt-conversation-telnyx");
|
|
267
|
+
|
|
268
|
+
expectFirstPlayTtsText(provider, "Telnyx hello");
|
|
269
|
+
expect(provider.startListeningCalls).toEqual([
|
|
270
|
+
expect.objectContaining({
|
|
42
271
|
callId,
|
|
43
272
|
providerCallId: "call-uuid",
|
|
44
|
-
|
|
45
|
-
|
|
273
|
+
}),
|
|
274
|
+
]);
|
|
275
|
+
expect(requireCall(manager, callId).state).toBe("listening");
|
|
276
|
+
});
|
|
46
277
|
|
|
47
|
-
|
|
278
|
+
it("logs fire-and-forget initial-message failures instead of leaking unhandled rejections", async () => {
|
|
279
|
+
const provider = new FailStartListeningProvider("twilio");
|
|
280
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
281
|
+
try {
|
|
282
|
+
const { manager } = await createManagerHarness({ streaming: { enabled: false } }, provider);
|
|
48
283
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
284
|
+
const callId = await initiateCallWithMessage(
|
|
285
|
+
manager,
|
|
286
|
+
"+15550000013",
|
|
287
|
+
"Twilio hello",
|
|
288
|
+
"conversation",
|
|
289
|
+
);
|
|
290
|
+
await answerCall(manager, callId, "evt-initial-message-start-listening-fails");
|
|
291
|
+
|
|
292
|
+
expectFirstPlayTtsText(provider, "Twilio hello");
|
|
293
|
+
expect(provider.startListeningCalls).toEqual([
|
|
294
|
+
expect.objectContaining({
|
|
295
|
+
callId,
|
|
296
|
+
providerCallId: "call-uuid",
|
|
297
|
+
}),
|
|
298
|
+
]);
|
|
299
|
+
expect(warn).toHaveBeenCalledWith(
|
|
300
|
+
expect.stringContaining(
|
|
301
|
+
`[voice-call] Failed to speak initial message for call ${callId}: synthetic start listening failure`,
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
} finally {
|
|
305
|
+
warn.mockRestore();
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("preserves initialMessage after a failed first playback and retries on next trigger", async () => {
|
|
310
|
+
const provider = new FailFirstPlayTtsProvider("plivo");
|
|
311
|
+
const { manager } = await createManagerHarness({}, provider);
|
|
312
|
+
|
|
313
|
+
const callId = await initiateCallWithMessage(manager, "+15550000006", "Retry me", "notify");
|
|
314
|
+
await answerCall(manager, callId, "evt-retry-1");
|
|
315
|
+
|
|
316
|
+
const afterFailure = requireCall(manager, callId);
|
|
317
|
+
expect(provider.playTtsCalls).toHaveLength(1);
|
|
318
|
+
expect(afterFailure.metadata).toEqual(expect.objectContaining({ initialMessage: "Retry me" }));
|
|
319
|
+
expect(afterFailure.state).toBe("listening");
|
|
320
|
+
|
|
321
|
+
await answerCall(manager, callId, "evt-retry-2");
|
|
322
|
+
|
|
323
|
+
const afterSuccess = requireCall(manager, callId);
|
|
324
|
+
expect(provider.playTtsCalls).toHaveLength(2);
|
|
325
|
+
expect(afterSuccess.metadata).not.toHaveProperty("initialMessage");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("speaks initial message only once on repeated stream-connect triggers", async () => {
|
|
329
|
+
const { manager, provider } = await createManagerHarness(
|
|
330
|
+
{ streaming: { enabled: true } },
|
|
331
|
+
new FakeProvider("twilio"),
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const callId = await initiateCallWithMessage(
|
|
335
|
+
manager,
|
|
336
|
+
"+15550000007",
|
|
337
|
+
"Stream hello",
|
|
338
|
+
"conversation",
|
|
339
|
+
);
|
|
340
|
+
await answerCall(manager, callId, "evt-stream-answered");
|
|
341
|
+
expect(provider.playTtsCalls).toHaveLength(0);
|
|
342
|
+
|
|
343
|
+
await manager.speakInitialMessage("call-uuid");
|
|
344
|
+
await manager.speakInitialMessage("call-uuid");
|
|
345
|
+
|
|
346
|
+
expectFirstPlayTtsText(provider, "Stream hello");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("prevents concurrent initial-message replays while first playback is in flight", async () => {
|
|
350
|
+
const provider = new DelayedPlayTtsProvider("twilio");
|
|
351
|
+
const { manager } = await createManagerHarness({ streaming: { enabled: true } }, provider);
|
|
352
|
+
|
|
353
|
+
const callId = await initiateCallWithMessage(
|
|
354
|
+
manager,
|
|
355
|
+
"+15550000008",
|
|
356
|
+
"In-flight hello",
|
|
357
|
+
"conversation",
|
|
358
|
+
);
|
|
359
|
+
await answerCall(manager, callId, "evt-stream-answered-concurrent");
|
|
360
|
+
expect(provider.playTtsCalls).toHaveLength(0);
|
|
361
|
+
|
|
362
|
+
const first = manager.speakInitialMessage("call-uuid");
|
|
363
|
+
await provider.playTtsStartedPromise;
|
|
364
|
+
expect(provider.playTtsStarted).toHaveBeenCalledTimes(1);
|
|
365
|
+
|
|
366
|
+
const second = manager.speakInitialMessage("call-uuid");
|
|
367
|
+
await waitForPlaybackDispatch();
|
|
368
|
+
expect(provider.playTtsCalls).toHaveLength(1);
|
|
369
|
+
|
|
370
|
+
provider.releaseCurrentPlayback();
|
|
371
|
+
await Promise.all([first, second]);
|
|
372
|
+
|
|
373
|
+
const call = requireCall(manager, callId);
|
|
374
|
+
expect(call.metadata).not.toHaveProperty("initialMessage");
|
|
375
|
+
expectFirstPlayTtsText(provider, "In-flight hello");
|
|
376
|
+
});
|
|
53
377
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { VoiceCallConfigSchema } from "./config.js";
|
|
3
3
|
import { CallManager } from "./manager.js";
|
|
4
4
|
import {
|
|
@@ -7,8 +7,23 @@ import {
|
|
|
7
7
|
makePersistedCall,
|
|
8
8
|
writeCallsToStore,
|
|
9
9
|
} from "./manager.test-harness.js";
|
|
10
|
+
import { flushPendingCallRecordWritesForTest, loadActiveCallsFromStore } from "./manager/store.js";
|
|
11
|
+
|
|
12
|
+
function requireSingleActiveCall(manager: CallManager) {
|
|
13
|
+
const activeCalls = manager.getActiveCalls();
|
|
14
|
+
expect(activeCalls).toHaveLength(1);
|
|
15
|
+
const activeCall = activeCalls[0];
|
|
16
|
+
if (!activeCall) {
|
|
17
|
+
throw new Error("expected restored active call");
|
|
18
|
+
}
|
|
19
|
+
return activeCall;
|
|
20
|
+
}
|
|
10
21
|
|
|
11
22
|
describe("CallManager verification on restore", () => {
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.useRealTimers();
|
|
25
|
+
});
|
|
26
|
+
|
|
12
27
|
async function initializeManager(params?: {
|
|
13
28
|
callOverrides?: Parameters<typeof makePersistedCall>[0];
|
|
14
29
|
providerResult?: FakeProvider["getCallStatusResult"];
|
|
@@ -34,7 +49,7 @@ describe("CallManager verification on restore", () => {
|
|
|
34
49
|
const manager = new CallManager(config, storePath);
|
|
35
50
|
await manager.initialize(provider, "https://example.com/voice/webhook");
|
|
36
51
|
|
|
37
|
-
return { call, manager };
|
|
52
|
+
return { call, manager, provider, storePath };
|
|
38
53
|
}
|
|
39
54
|
|
|
40
55
|
it("skips stale calls reported terminal by provider", async () => {
|
|
@@ -50,20 +65,22 @@ describe("CallManager verification on restore", () => {
|
|
|
50
65
|
providerResult: { status: "in-progress", isTerminal: false },
|
|
51
66
|
});
|
|
52
67
|
|
|
53
|
-
|
|
54
|
-
expect(
|
|
68
|
+
const activeCall = requireSingleActiveCall(manager);
|
|
69
|
+
expect(activeCall.callId).toBe(call.callId);
|
|
55
70
|
});
|
|
56
71
|
|
|
57
72
|
it("keeps calls when provider returns unknown (transient error)", async () => {
|
|
58
|
-
const { manager } = await initializeManager({
|
|
73
|
+
const { call, manager } = await initializeManager({
|
|
59
74
|
providerResult: { status: "error", isTerminal: false, isUnknown: true },
|
|
60
75
|
});
|
|
61
76
|
|
|
62
|
-
|
|
77
|
+
const activeCall = requireSingleActiveCall(manager);
|
|
78
|
+
expect(activeCall.callId).toBe(call.callId);
|
|
79
|
+
expect(activeCall.state).toBe(call.state);
|
|
63
80
|
});
|
|
64
81
|
|
|
65
82
|
it("skips calls older than maxDurationSeconds", async () => {
|
|
66
|
-
const { manager } = await initializeManager({
|
|
83
|
+
const { manager, provider, storePath } = await initializeManager({
|
|
67
84
|
callOverrides: {
|
|
68
85
|
startedAt: Date.now() - 600_000,
|
|
69
86
|
answeredAt: Date.now() - 590_000,
|
|
@@ -72,6 +89,14 @@ describe("CallManager verification on restore", () => {
|
|
|
72
89
|
});
|
|
73
90
|
|
|
74
91
|
expect(manager.getActiveCalls()).toHaveLength(0);
|
|
92
|
+
expect(provider.hangupCalls).toEqual([
|
|
93
|
+
expect.objectContaining({
|
|
94
|
+
reason: "timeout",
|
|
95
|
+
}),
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
await flushPendingCallRecordWritesForTest();
|
|
99
|
+
expect(loadActiveCallsFromStore(storePath).activeCalls.size).toBe(0);
|
|
75
100
|
});
|
|
76
101
|
|
|
77
102
|
it("skips calls without providerCallId", async () => {
|
|
@@ -83,7 +108,7 @@ describe("CallManager verification on restore", () => {
|
|
|
83
108
|
});
|
|
84
109
|
|
|
85
110
|
it("keeps call when getCallStatus throws (verification failure)", async () => {
|
|
86
|
-
const { manager } = await initializeManager({
|
|
111
|
+
const { call, manager } = await initializeManager({
|
|
87
112
|
configureProvider: (provider) => {
|
|
88
113
|
provider.getCallStatus = async () => {
|
|
89
114
|
throw new Error("network failure");
|
|
@@ -91,6 +116,68 @@ describe("CallManager verification on restore", () => {
|
|
|
91
116
|
},
|
|
92
117
|
});
|
|
93
118
|
|
|
119
|
+
const activeCall = requireSingleActiveCall(manager);
|
|
120
|
+
expect(activeCall.callId).toBe(call.callId);
|
|
121
|
+
expect(activeCall.state).toBe(call.state);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("uses only remaining max duration for restored answered calls", async () => {
|
|
125
|
+
vi.useFakeTimers();
|
|
126
|
+
const now = new Date("2026-03-17T03:07:00Z");
|
|
127
|
+
vi.setSystemTime(now);
|
|
128
|
+
const { manager, provider } = await initializeManager({
|
|
129
|
+
callOverrides: {
|
|
130
|
+
startedAt: now.getTime() - 290_000,
|
|
131
|
+
answeredAt: now.getTime() - 290_000,
|
|
132
|
+
state: "answered",
|
|
133
|
+
},
|
|
134
|
+
configOverrides: { maxDurationSeconds: 300 },
|
|
135
|
+
});
|
|
136
|
+
|
|
94
137
|
expect(manager.getActiveCalls()).toHaveLength(1);
|
|
138
|
+
await vi.advanceTimersByTimeAsync(9_000);
|
|
139
|
+
expect(manager.getActiveCalls()).toHaveLength(1);
|
|
140
|
+
expect(provider.hangupCalls).toHaveLength(0);
|
|
141
|
+
|
|
142
|
+
await vi.advanceTimersByTimeAsync(1_100);
|
|
143
|
+
expect(manager.getActiveCalls()).toHaveLength(0);
|
|
144
|
+
expect(provider.hangupCalls).toEqual([
|
|
145
|
+
expect.objectContaining({
|
|
146
|
+
reason: "timeout",
|
|
147
|
+
}),
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("restores dedupe keys from terminal persisted calls so replayed webhooks stay ignored", async () => {
|
|
152
|
+
const storePath = createTestStorePath();
|
|
153
|
+
const persisted = makePersistedCall({
|
|
154
|
+
state: "completed",
|
|
155
|
+
endedAt: Date.now() - 5_000,
|
|
156
|
+
endReason: "completed",
|
|
157
|
+
processedEventIds: ["evt-terminal-init"],
|
|
158
|
+
});
|
|
159
|
+
writeCallsToStore(storePath, [persisted]);
|
|
160
|
+
|
|
161
|
+
const provider = new FakeProvider();
|
|
162
|
+
const config = VoiceCallConfigSchema.parse({
|
|
163
|
+
enabled: true,
|
|
164
|
+
provider: "plivo",
|
|
165
|
+
fromNumber: "+15550000000",
|
|
166
|
+
});
|
|
167
|
+
const manager = new CallManager(config, storePath);
|
|
168
|
+
await manager.initialize(provider, "https://example.com/voice/webhook");
|
|
169
|
+
|
|
170
|
+
manager.processEvent({
|
|
171
|
+
id: "evt-terminal-init",
|
|
172
|
+
type: "call.initiated",
|
|
173
|
+
callId: String(persisted.providerCallId),
|
|
174
|
+
providerCallId: String(persisted.providerCallId),
|
|
175
|
+
timestamp: Date.now(),
|
|
176
|
+
direction: "outbound",
|
|
177
|
+
from: "+15550000000",
|
|
178
|
+
to: "+15550000001",
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(manager.getActiveCalls()).toHaveLength(0);
|
|
95
182
|
});
|
|
96
183
|
});
|