@openclaw/voice-call 2026.2.25 → 2026.3.2
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/CHANGELOG.md +18 -0
- package/index.ts +27 -13
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/http-headers.test.ts +16 -0
- package/src/http-headers.ts +12 -0
- package/src/manager/events.test.ts +75 -0
- package/src/manager/events.ts +25 -9
- package/src/manager.closed-loop.test.ts +218 -0
- package/src/manager.inbound-allowlist.test.ts +121 -0
- package/src/manager.notify.test.ts +53 -0
- package/src/manager.restore.test.ts +130 -0
- package/src/manager.test-harness.ts +125 -0
- package/src/manager.ts +119 -10
- package/src/providers/base.ts +12 -1
- package/src/providers/mock.ts +15 -1
- package/src/providers/plivo.test.ts +22 -0
- package/src/providers/plivo.ts +60 -27
- package/src/providers/shared/call-status.test.ts +24 -0
- package/src/providers/shared/call-status.ts +23 -0
- package/src/providers/shared/guarded-json-api.ts +42 -0
- package/src/providers/telnyx.test.ts +27 -0
- package/src/providers/telnyx.ts +56 -17
- package/src/providers/twilio/twiml-policy.test.ts +84 -0
- package/src/providers/twilio/twiml-policy.ts +91 -0
- package/src/providers/twilio/webhook.ts +1 -0
- package/src/providers/twilio.test.ts +93 -2
- package/src/providers/twilio.ts +111 -91
- package/src/runtime.test.ts +147 -0
- package/src/runtime.ts +123 -76
- package/src/tunnel.ts +1 -1
- package/src/types.ts +24 -0
- package/src/webhook/stale-call-reaper.ts +33 -0
- package/src/webhook/tailscale.ts +115 -0
- package/src/webhook-security.test.ts +135 -4
- package/src/webhook-security.ts +142 -42
- package/src/webhook.test.ts +168 -14
- package/src/webhook.ts +118 -203
- package/src/manager.test.ts +0 -467
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026.3.2
|
|
4
|
+
|
|
5
|
+
### Changes
|
|
6
|
+
|
|
7
|
+
- Version alignment with core OpenClaw release numbers.
|
|
8
|
+
|
|
9
|
+
## 2026.3.1
|
|
10
|
+
|
|
11
|
+
### Changes
|
|
12
|
+
|
|
13
|
+
- Version alignment with core OpenClaw release numbers.
|
|
14
|
+
|
|
15
|
+
## 2026.2.26
|
|
16
|
+
|
|
17
|
+
### Changes
|
|
18
|
+
|
|
19
|
+
- Version alignment with core OpenClaw release numbers.
|
|
20
|
+
|
|
3
21
|
## 2026.2.25
|
|
4
22
|
|
|
5
23
|
### Changes
|
package/index.ts
CHANGED
|
@@ -181,7 +181,15 @@ const voiceCallPlugin = {
|
|
|
181
181
|
logger: api.logger,
|
|
182
182
|
});
|
|
183
183
|
}
|
|
184
|
-
|
|
184
|
+
try {
|
|
185
|
+
runtime = await runtimePromise;
|
|
186
|
+
} catch (err) {
|
|
187
|
+
// Reset so the next call can retry instead of caching the
|
|
188
|
+
// rejected promise forever (which also leaves the port orphaned
|
|
189
|
+
// if the server started before the failure). See: #32387
|
|
190
|
+
runtimePromise = null;
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
185
193
|
return runtime;
|
|
186
194
|
};
|
|
187
195
|
|
|
@@ -189,6 +197,16 @@ const voiceCallPlugin = {
|
|
|
189
197
|
respond(false, { error: err instanceof Error ? err.message : String(err) });
|
|
190
198
|
};
|
|
191
199
|
|
|
200
|
+
const resolveCallMessageRequest = async (params: GatewayRequestHandlerOptions["params"]) => {
|
|
201
|
+
const callId = typeof params?.callId === "string" ? params.callId.trim() : "";
|
|
202
|
+
const message = typeof params?.message === "string" ? params.message.trim() : "";
|
|
203
|
+
if (!callId || !message) {
|
|
204
|
+
return { error: "callId and message required" } as const;
|
|
205
|
+
}
|
|
206
|
+
const rt = await ensureRuntime();
|
|
207
|
+
return { rt, callId, message } as const;
|
|
208
|
+
};
|
|
209
|
+
|
|
192
210
|
api.registerGatewayMethod(
|
|
193
211
|
"voicecall.initiate",
|
|
194
212
|
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
@@ -228,14 +246,12 @@ const voiceCallPlugin = {
|
|
|
228
246
|
"voicecall.continue",
|
|
229
247
|
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
230
248
|
try {
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
respond(false, { error: "callId and message required" });
|
|
249
|
+
const request = await resolveCallMessageRequest(params);
|
|
250
|
+
if ("error" in request) {
|
|
251
|
+
respond(false, { error: request.error });
|
|
235
252
|
return;
|
|
236
253
|
}
|
|
237
|
-
const
|
|
238
|
-
const result = await rt.manager.continueCall(callId, message);
|
|
254
|
+
const result = await request.rt.manager.continueCall(request.callId, request.message);
|
|
239
255
|
if (!result.success) {
|
|
240
256
|
respond(false, { error: result.error || "continue failed" });
|
|
241
257
|
return;
|
|
@@ -251,14 +267,12 @@ const voiceCallPlugin = {
|
|
|
251
267
|
"voicecall.speak",
|
|
252
268
|
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
253
269
|
try {
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
respond(false, { error: "callId and message required" });
|
|
270
|
+
const request = await resolveCallMessageRequest(params);
|
|
271
|
+
if ("error" in request) {
|
|
272
|
+
respond(false, { error: request.error });
|
|
258
273
|
return;
|
|
259
274
|
}
|
|
260
|
-
const
|
|
261
|
-
const result = await rt.manager.speak(callId, message);
|
|
275
|
+
const result = await request.rt.manager.speak(request.callId, request.message);
|
|
262
276
|
if (!result.success) {
|
|
263
277
|
respond(false, { error: result.error || "speak failed" });
|
|
264
278
|
return;
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getHeader } from "./http-headers.js";
|
|
3
|
+
|
|
4
|
+
describe("getHeader", () => {
|
|
5
|
+
it("returns first value when header is an array", () => {
|
|
6
|
+
expect(getHeader({ "x-test": ["first", "second"] }, "x-test")).toBe("first");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("matches headers case-insensitively", () => {
|
|
10
|
+
expect(getHeader({ "X-Twilio-Signature": "sig-1" }, "x-twilio-signature")).toBe("sig-1");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns undefined for missing header", () => {
|
|
14
|
+
expect(getHeader({ host: "example.com" }, "x-missing")).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type HttpHeaderMap = Record<string, string | string[] | undefined>;
|
|
2
|
+
|
|
3
|
+
export function getHeader(headers: HttpHeaderMap, name: string): string | undefined {
|
|
4
|
+
const target = name.toLowerCase();
|
|
5
|
+
const direct = headers[target];
|
|
6
|
+
const value =
|
|
7
|
+
direct ?? Object.entries(headers).find(([key]) => key.toLowerCase() === target)?.[1];
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
return value[0];
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
@@ -41,6 +41,7 @@ function createProvider(overrides: Partial<VoiceCallProvider> = {}): VoiceCallPr
|
|
|
41
41
|
playTts: async () => {},
|
|
42
42
|
startListening: async () => {},
|
|
43
43
|
stopListening: async () => {},
|
|
44
|
+
getCallStatus: async () => ({ status: "in-progress", isTerminal: false }),
|
|
44
45
|
...overrides,
|
|
45
46
|
};
|
|
46
47
|
}
|
|
@@ -235,6 +236,80 @@ describe("processEvent (functional)", () => {
|
|
|
235
236
|
expect(ctx.activeCalls.size).toBe(0);
|
|
236
237
|
});
|
|
237
238
|
|
|
239
|
+
it("auto-registers externally-initiated outbound-api calls with correct direction", () => {
|
|
240
|
+
const ctx = createContext();
|
|
241
|
+
const event: NormalizedEvent = {
|
|
242
|
+
id: "evt-external-1",
|
|
243
|
+
type: "call.initiated",
|
|
244
|
+
callId: "CA-external-123",
|
|
245
|
+
providerCallId: "CA-external-123",
|
|
246
|
+
timestamp: Date.now(),
|
|
247
|
+
direction: "outbound",
|
|
248
|
+
from: "+15550000000",
|
|
249
|
+
to: "+15559876543",
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
processEvent(ctx, event);
|
|
253
|
+
|
|
254
|
+
// Call should be registered in activeCalls and providerCallIdMap
|
|
255
|
+
expect(ctx.activeCalls.size).toBe(1);
|
|
256
|
+
expect(ctx.providerCallIdMap.get("CA-external-123")).toBeDefined();
|
|
257
|
+
const call = [...ctx.activeCalls.values()][0];
|
|
258
|
+
expect(call?.providerCallId).toBe("CA-external-123");
|
|
259
|
+
expect(call?.direction).toBe("outbound");
|
|
260
|
+
expect(call?.from).toBe("+15550000000");
|
|
261
|
+
expect(call?.to).toBe("+15559876543");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("does not reject externally-initiated outbound calls even with disabled inbound policy", () => {
|
|
265
|
+
const { ctx, hangupCalls } = createRejectingInboundContext();
|
|
266
|
+
const event: NormalizedEvent = {
|
|
267
|
+
id: "evt-external-2",
|
|
268
|
+
type: "call.initiated",
|
|
269
|
+
callId: "CA-external-456",
|
|
270
|
+
providerCallId: "CA-external-456",
|
|
271
|
+
timestamp: Date.now(),
|
|
272
|
+
direction: "outbound",
|
|
273
|
+
from: "+15550000000",
|
|
274
|
+
to: "+15559876543",
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
processEvent(ctx, event);
|
|
278
|
+
|
|
279
|
+
// External outbound calls bypass inbound policy — they should be accepted
|
|
280
|
+
expect(ctx.activeCalls.size).toBe(1);
|
|
281
|
+
expect(hangupCalls).toHaveLength(0);
|
|
282
|
+
const call = [...ctx.activeCalls.values()][0];
|
|
283
|
+
expect(call?.direction).toBe("outbound");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("preserves inbound direction for auto-registered inbound calls", () => {
|
|
287
|
+
const ctx = createContext({
|
|
288
|
+
config: VoiceCallConfigSchema.parse({
|
|
289
|
+
enabled: true,
|
|
290
|
+
provider: "plivo",
|
|
291
|
+
fromNumber: "+15550000000",
|
|
292
|
+
inboundPolicy: "open",
|
|
293
|
+
}),
|
|
294
|
+
});
|
|
295
|
+
const event: NormalizedEvent = {
|
|
296
|
+
id: "evt-inbound-dir",
|
|
297
|
+
type: "call.initiated",
|
|
298
|
+
callId: "CA-inbound-789",
|
|
299
|
+
providerCallId: "CA-inbound-789",
|
|
300
|
+
timestamp: Date.now(),
|
|
301
|
+
direction: "inbound",
|
|
302
|
+
from: "+15554444444",
|
|
303
|
+
to: "+15550000000",
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
processEvent(ctx, event);
|
|
307
|
+
|
|
308
|
+
expect(ctx.activeCalls.size).toBe(1);
|
|
309
|
+
const call = [...ctx.activeCalls.values()][0];
|
|
310
|
+
expect(call?.direction).toBe("inbound");
|
|
311
|
+
});
|
|
312
|
+
|
|
238
313
|
it("deduplicates by dedupeKey even when event IDs differ", () => {
|
|
239
314
|
const now = Date.now();
|
|
240
315
|
const ctx = createContext();
|
package/src/manager/events.ts
CHANGED
|
@@ -59,9 +59,10 @@ function shouldAcceptInbound(config: EventContext["config"], from: string | unde
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
function
|
|
62
|
+
function createWebhookCall(params: {
|
|
63
63
|
ctx: EventContext;
|
|
64
64
|
providerCallId: string;
|
|
65
|
+
direction: "inbound" | "outbound";
|
|
65
66
|
from: string;
|
|
66
67
|
to: string;
|
|
67
68
|
}): CallRecord {
|
|
@@ -71,7 +72,7 @@ function createInboundCall(params: {
|
|
|
71
72
|
callId,
|
|
72
73
|
providerCallId: params.providerCallId,
|
|
73
74
|
provider: params.ctx.provider?.name || "twilio",
|
|
74
|
-
direction:
|
|
75
|
+
direction: params.direction,
|
|
75
76
|
state: "ringing",
|
|
76
77
|
from: params.from,
|
|
77
78
|
to: params.to,
|
|
@@ -79,7 +80,10 @@ function createInboundCall(params: {
|
|
|
79
80
|
transcript: [],
|
|
80
81
|
processedEventIds: [],
|
|
81
82
|
metadata: {
|
|
82
|
-
initialMessage:
|
|
83
|
+
initialMessage:
|
|
84
|
+
params.direction === "inbound"
|
|
85
|
+
? params.ctx.config.inboundGreeting || "Hello! How can I help you today?"
|
|
86
|
+
: undefined,
|
|
83
87
|
},
|
|
84
88
|
};
|
|
85
89
|
|
|
@@ -87,7 +91,9 @@ function createInboundCall(params: {
|
|
|
87
91
|
params.ctx.providerCallIdMap.set(params.providerCallId, callId);
|
|
88
92
|
persistCallRecord(params.ctx.storePath, callRecord);
|
|
89
93
|
|
|
90
|
-
console.log(
|
|
94
|
+
console.log(
|
|
95
|
+
`[voice-call] Created ${params.direction} call record: ${callId} from ${params.from}`,
|
|
96
|
+
);
|
|
91
97
|
return callRecord;
|
|
92
98
|
}
|
|
93
99
|
|
|
@@ -104,9 +110,18 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
|
|
|
104
110
|
callIdOrProviderCallId: event.callId,
|
|
105
111
|
});
|
|
106
112
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
const providerCallId = event.providerCallId;
|
|
114
|
+
const eventDirection =
|
|
115
|
+
event.direction === "inbound" || event.direction === "outbound" ? event.direction : undefined;
|
|
116
|
+
|
|
117
|
+
// Auto-register untracked calls arriving via webhook. This covers both
|
|
118
|
+
// true inbound calls and externally-initiated outbound-api calls (e.g. calls
|
|
119
|
+
// placed directly via the Twilio REST API pointing at our webhook URL).
|
|
120
|
+
if (!call && providerCallId && eventDirection) {
|
|
121
|
+
// Apply inbound policy for true inbound calls; external outbound-api calls
|
|
122
|
+
// are implicitly trusted because the caller controls the webhook URL.
|
|
123
|
+
if (eventDirection === "inbound" && !shouldAcceptInbound(ctx.config, event.from)) {
|
|
124
|
+
const pid = providerCallId;
|
|
110
125
|
if (!ctx.provider) {
|
|
111
126
|
console.warn(
|
|
112
127
|
`[voice-call] Inbound call rejected by policy but no provider to hang up (providerCallId: ${pid}, from: ${event.from}); call will time out on provider side.`,
|
|
@@ -132,9 +147,10 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
|
|
|
132
147
|
return;
|
|
133
148
|
}
|
|
134
149
|
|
|
135
|
-
call =
|
|
150
|
+
call = createWebhookCall({
|
|
136
151
|
ctx,
|
|
137
|
-
providerCallId
|
|
152
|
+
providerCallId,
|
|
153
|
+
direction: eventDirection === "outbound" ? "outbound" : "inbound",
|
|
138
154
|
from: event.from || "unknown",
|
|
139
155
|
to: event.to || ctx.config.fromNumber || "unknown",
|
|
140
156
|
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createManagerHarness, FakeProvider, markCallAnswered } from "./manager.test-harness.js";
|
|
3
|
+
|
|
4
|
+
describe("CallManager closed-loop turns", () => {
|
|
5
|
+
it("completes a closed-loop turn without live audio", async () => {
|
|
6
|
+
const { manager, provider } = await createManagerHarness({
|
|
7
|
+
transcriptTimeoutMs: 5000,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const started = await manager.initiateCall("+15550000003");
|
|
11
|
+
expect(started.success).toBe(true);
|
|
12
|
+
|
|
13
|
+
markCallAnswered(manager, started.callId, "evt-closed-loop-answered");
|
|
14
|
+
|
|
15
|
+
const turnPromise = manager.continueCall(started.callId, "How can I help?");
|
|
16
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
17
|
+
|
|
18
|
+
manager.processEvent({
|
|
19
|
+
id: "evt-closed-loop-speech",
|
|
20
|
+
type: "call.speech",
|
|
21
|
+
callId: started.callId,
|
|
22
|
+
providerCallId: "request-uuid",
|
|
23
|
+
timestamp: Date.now(),
|
|
24
|
+
transcript: "Please check status",
|
|
25
|
+
isFinal: true,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const turn = await turnPromise;
|
|
29
|
+
expect(turn.success).toBe(true);
|
|
30
|
+
expect(turn.transcript).toBe("Please check status");
|
|
31
|
+
expect(provider.startListeningCalls).toHaveLength(1);
|
|
32
|
+
expect(provider.stopListeningCalls).toHaveLength(1);
|
|
33
|
+
|
|
34
|
+
const call = manager.getCall(started.callId);
|
|
35
|
+
expect(call?.transcript.map((entry) => entry.text)).toEqual([
|
|
36
|
+
"How can I help?",
|
|
37
|
+
"Please check status",
|
|
38
|
+
]);
|
|
39
|
+
const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
|
|
40
|
+
expect(typeof metadata.lastTurnLatencyMs).toBe("number");
|
|
41
|
+
expect(typeof metadata.lastTurnListenWaitMs).toBe("number");
|
|
42
|
+
expect(metadata.turnCount).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rejects overlapping continueCall requests for the same call", async () => {
|
|
46
|
+
const { manager, provider } = await createManagerHarness({
|
|
47
|
+
transcriptTimeoutMs: 5000,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const started = await manager.initiateCall("+15550000004");
|
|
51
|
+
expect(started.success).toBe(true);
|
|
52
|
+
|
|
53
|
+
markCallAnswered(manager, started.callId, "evt-overlap-answered");
|
|
54
|
+
|
|
55
|
+
const first = manager.continueCall(started.callId, "First prompt");
|
|
56
|
+
const second = await manager.continueCall(started.callId, "Second prompt");
|
|
57
|
+
expect(second.success).toBe(false);
|
|
58
|
+
expect(second.error).toBe("Already waiting for transcript");
|
|
59
|
+
|
|
60
|
+
manager.processEvent({
|
|
61
|
+
id: "evt-overlap-speech",
|
|
62
|
+
type: "call.speech",
|
|
63
|
+
callId: started.callId,
|
|
64
|
+
providerCallId: "request-uuid",
|
|
65
|
+
timestamp: Date.now(),
|
|
66
|
+
transcript: "Done",
|
|
67
|
+
isFinal: true,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const firstResult = await first;
|
|
71
|
+
expect(firstResult.success).toBe(true);
|
|
72
|
+
expect(firstResult.transcript).toBe("Done");
|
|
73
|
+
expect(provider.startListeningCalls).toHaveLength(1);
|
|
74
|
+
expect(provider.stopListeningCalls).toHaveLength(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("ignores speech events with mismatched turnToken while waiting for transcript", async () => {
|
|
78
|
+
const { manager, provider } = await createManagerHarness(
|
|
79
|
+
{
|
|
80
|
+
transcriptTimeoutMs: 5000,
|
|
81
|
+
},
|
|
82
|
+
new FakeProvider("twilio"),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const started = await manager.initiateCall("+15550000004");
|
|
86
|
+
expect(started.success).toBe(true);
|
|
87
|
+
|
|
88
|
+
markCallAnswered(manager, started.callId, "evt-turn-token-answered");
|
|
89
|
+
|
|
90
|
+
const turnPromise = manager.continueCall(started.callId, "Prompt");
|
|
91
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
92
|
+
|
|
93
|
+
const expectedTurnToken = provider.startListeningCalls[0]?.turnToken;
|
|
94
|
+
expect(typeof expectedTurnToken).toBe("string");
|
|
95
|
+
|
|
96
|
+
manager.processEvent({
|
|
97
|
+
id: "evt-turn-token-bad",
|
|
98
|
+
type: "call.speech",
|
|
99
|
+
callId: started.callId,
|
|
100
|
+
providerCallId: "request-uuid",
|
|
101
|
+
timestamp: Date.now(),
|
|
102
|
+
transcript: "stale replay",
|
|
103
|
+
isFinal: true,
|
|
104
|
+
turnToken: "wrong-token",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const pendingState = await Promise.race([
|
|
108
|
+
turnPromise.then(() => "resolved"),
|
|
109
|
+
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)),
|
|
110
|
+
]);
|
|
111
|
+
expect(pendingState).toBe("pending");
|
|
112
|
+
|
|
113
|
+
manager.processEvent({
|
|
114
|
+
id: "evt-turn-token-good",
|
|
115
|
+
type: "call.speech",
|
|
116
|
+
callId: started.callId,
|
|
117
|
+
providerCallId: "request-uuid",
|
|
118
|
+
timestamp: Date.now(),
|
|
119
|
+
transcript: "final answer",
|
|
120
|
+
isFinal: true,
|
|
121
|
+
turnToken: expectedTurnToken,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const turnResult = await turnPromise;
|
|
125
|
+
expect(turnResult.success).toBe(true);
|
|
126
|
+
expect(turnResult.transcript).toBe("final answer");
|
|
127
|
+
|
|
128
|
+
const call = manager.getCall(started.callId);
|
|
129
|
+
expect(call?.transcript.map((entry) => entry.text)).toEqual(["Prompt", "final answer"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("tracks latency metadata across multiple closed-loop turns", async () => {
|
|
133
|
+
const { manager, provider } = await createManagerHarness({
|
|
134
|
+
transcriptTimeoutMs: 5000,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const started = await manager.initiateCall("+15550000005");
|
|
138
|
+
expect(started.success).toBe(true);
|
|
139
|
+
|
|
140
|
+
markCallAnswered(manager, started.callId, "evt-multi-answered");
|
|
141
|
+
|
|
142
|
+
const firstTurn = manager.continueCall(started.callId, "First question");
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
144
|
+
manager.processEvent({
|
|
145
|
+
id: "evt-multi-speech-1",
|
|
146
|
+
type: "call.speech",
|
|
147
|
+
callId: started.callId,
|
|
148
|
+
providerCallId: "request-uuid",
|
|
149
|
+
timestamp: Date.now(),
|
|
150
|
+
transcript: "First answer",
|
|
151
|
+
isFinal: true,
|
|
152
|
+
});
|
|
153
|
+
await firstTurn;
|
|
154
|
+
|
|
155
|
+
const secondTurn = manager.continueCall(started.callId, "Second question");
|
|
156
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
157
|
+
manager.processEvent({
|
|
158
|
+
id: "evt-multi-speech-2",
|
|
159
|
+
type: "call.speech",
|
|
160
|
+
callId: started.callId,
|
|
161
|
+
providerCallId: "request-uuid",
|
|
162
|
+
timestamp: Date.now(),
|
|
163
|
+
transcript: "Second answer",
|
|
164
|
+
isFinal: true,
|
|
165
|
+
});
|
|
166
|
+
const secondResult = await secondTurn;
|
|
167
|
+
|
|
168
|
+
expect(secondResult.success).toBe(true);
|
|
169
|
+
|
|
170
|
+
const call = manager.getCall(started.callId);
|
|
171
|
+
expect(call?.transcript.map((entry) => entry.text)).toEqual([
|
|
172
|
+
"First question",
|
|
173
|
+
"First answer",
|
|
174
|
+
"Second question",
|
|
175
|
+
"Second answer",
|
|
176
|
+
]);
|
|
177
|
+
const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
|
|
178
|
+
expect(metadata.turnCount).toBe(2);
|
|
179
|
+
expect(typeof metadata.lastTurnLatencyMs).toBe("number");
|
|
180
|
+
expect(typeof metadata.lastTurnListenWaitMs).toBe("number");
|
|
181
|
+
expect(provider.startListeningCalls).toHaveLength(2);
|
|
182
|
+
expect(provider.stopListeningCalls).toHaveLength(2);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("handles repeated closed-loop turns without waiter churn", async () => {
|
|
186
|
+
const { manager, provider } = await createManagerHarness({
|
|
187
|
+
transcriptTimeoutMs: 5000,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const started = await manager.initiateCall("+15550000006");
|
|
191
|
+
expect(started.success).toBe(true);
|
|
192
|
+
|
|
193
|
+
markCallAnswered(manager, started.callId, "evt-loop-answered");
|
|
194
|
+
|
|
195
|
+
for (let i = 1; i <= 5; i++) {
|
|
196
|
+
const turnPromise = manager.continueCall(started.callId, `Prompt ${i}`);
|
|
197
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
198
|
+
manager.processEvent({
|
|
199
|
+
id: `evt-loop-speech-${i}`,
|
|
200
|
+
type: "call.speech",
|
|
201
|
+
callId: started.callId,
|
|
202
|
+
providerCallId: "request-uuid",
|
|
203
|
+
timestamp: Date.now(),
|
|
204
|
+
transcript: `Answer ${i}`,
|
|
205
|
+
isFinal: true,
|
|
206
|
+
});
|
|
207
|
+
const result = await turnPromise;
|
|
208
|
+
expect(result.success).toBe(true);
|
|
209
|
+
expect(result.transcript).toBe(`Answer ${i}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const call = manager.getCall(started.callId);
|
|
213
|
+
const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
|
|
214
|
+
expect(metadata.turnCount).toBe(5);
|
|
215
|
+
expect(provider.startListeningCalls).toHaveLength(5);
|
|
216
|
+
expect(provider.stopListeningCalls).toHaveLength(5);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createManagerHarness } from "./manager.test-harness.js";
|
|
3
|
+
|
|
4
|
+
describe("CallManager inbound allowlist", () => {
|
|
5
|
+
it("rejects inbound calls with missing caller ID when allowlist enabled", async () => {
|
|
6
|
+
const { manager, provider } = await createManagerHarness({
|
|
7
|
+
inboundPolicy: "allowlist",
|
|
8
|
+
allowFrom: ["+15550001234"],
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
manager.processEvent({
|
|
12
|
+
id: "evt-allowlist-missing",
|
|
13
|
+
type: "call.initiated",
|
|
14
|
+
callId: "call-missing",
|
|
15
|
+
providerCallId: "provider-missing",
|
|
16
|
+
timestamp: Date.now(),
|
|
17
|
+
direction: "inbound",
|
|
18
|
+
to: "+15550000000",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(manager.getCallByProviderCallId("provider-missing")).toBeUndefined();
|
|
22
|
+
expect(provider.hangupCalls).toHaveLength(1);
|
|
23
|
+
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-missing");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("rejects inbound calls with anonymous caller ID when allowlist enabled", async () => {
|
|
27
|
+
const { manager, provider } = await createManagerHarness({
|
|
28
|
+
inboundPolicy: "allowlist",
|
|
29
|
+
allowFrom: ["+15550001234"],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
manager.processEvent({
|
|
33
|
+
id: "evt-allowlist-anon",
|
|
34
|
+
type: "call.initiated",
|
|
35
|
+
callId: "call-anon",
|
|
36
|
+
providerCallId: "provider-anon",
|
|
37
|
+
timestamp: Date.now(),
|
|
38
|
+
direction: "inbound",
|
|
39
|
+
from: "anonymous",
|
|
40
|
+
to: "+15550000000",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(manager.getCallByProviderCallId("provider-anon")).toBeUndefined();
|
|
44
|
+
expect(provider.hangupCalls).toHaveLength(1);
|
|
45
|
+
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-anon");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects inbound calls that only match allowlist suffixes", async () => {
|
|
49
|
+
const { manager, provider } = await createManagerHarness({
|
|
50
|
+
inboundPolicy: "allowlist",
|
|
51
|
+
allowFrom: ["+15550001234"],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
manager.processEvent({
|
|
55
|
+
id: "evt-allowlist-suffix",
|
|
56
|
+
type: "call.initiated",
|
|
57
|
+
callId: "call-suffix",
|
|
58
|
+
providerCallId: "provider-suffix",
|
|
59
|
+
timestamp: Date.now(),
|
|
60
|
+
direction: "inbound",
|
|
61
|
+
from: "+99915550001234",
|
|
62
|
+
to: "+15550000000",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(manager.getCallByProviderCallId("provider-suffix")).toBeUndefined();
|
|
66
|
+
expect(provider.hangupCalls).toHaveLength(1);
|
|
67
|
+
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-suffix");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("rejects duplicate inbound events with a single hangup call", async () => {
|
|
71
|
+
const { manager, provider } = await createManagerHarness({
|
|
72
|
+
inboundPolicy: "disabled",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
manager.processEvent({
|
|
76
|
+
id: "evt-reject-init",
|
|
77
|
+
type: "call.initiated",
|
|
78
|
+
callId: "provider-dup",
|
|
79
|
+
providerCallId: "provider-dup",
|
|
80
|
+
timestamp: Date.now(),
|
|
81
|
+
direction: "inbound",
|
|
82
|
+
from: "+15552222222",
|
|
83
|
+
to: "+15550000000",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
manager.processEvent({
|
|
87
|
+
id: "evt-reject-ring",
|
|
88
|
+
type: "call.ringing",
|
|
89
|
+
callId: "provider-dup",
|
|
90
|
+
providerCallId: "provider-dup",
|
|
91
|
+
timestamp: Date.now(),
|
|
92
|
+
direction: "inbound",
|
|
93
|
+
from: "+15552222222",
|
|
94
|
+
to: "+15550000000",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(manager.getCallByProviderCallId("provider-dup")).toBeUndefined();
|
|
98
|
+
expect(provider.hangupCalls).toHaveLength(1);
|
|
99
|
+
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-dup");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("accepts inbound calls that exactly match the allowlist", async () => {
|
|
103
|
+
const { manager } = await createManagerHarness({
|
|
104
|
+
inboundPolicy: "allowlist",
|
|
105
|
+
allowFrom: ["+15550001234"],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
manager.processEvent({
|
|
109
|
+
id: "evt-allowlist-exact",
|
|
110
|
+
type: "call.initiated",
|
|
111
|
+
callId: "call-exact",
|
|
112
|
+
providerCallId: "provider-exact",
|
|
113
|
+
timestamp: Date.now(),
|
|
114
|
+
direction: "inbound",
|
|
115
|
+
from: "+15550001234",
|
|
116
|
+
to: "+15550000000",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(manager.getCallByProviderCallId("provider-exact")).toBeDefined();
|
|
120
|
+
});
|
|
121
|
+
});
|