@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/index.ts +27 -13
  3. package/package.json +1 -1
  4. package/src/cli.ts +1 -1
  5. package/src/http-headers.test.ts +16 -0
  6. package/src/http-headers.ts +12 -0
  7. package/src/manager/events.test.ts +75 -0
  8. package/src/manager/events.ts +25 -9
  9. package/src/manager.closed-loop.test.ts +218 -0
  10. package/src/manager.inbound-allowlist.test.ts +121 -0
  11. package/src/manager.notify.test.ts +53 -0
  12. package/src/manager.restore.test.ts +130 -0
  13. package/src/manager.test-harness.ts +125 -0
  14. package/src/manager.ts +119 -10
  15. package/src/providers/base.ts +12 -1
  16. package/src/providers/mock.ts +15 -1
  17. package/src/providers/plivo.test.ts +22 -0
  18. package/src/providers/plivo.ts +60 -27
  19. package/src/providers/shared/call-status.test.ts +24 -0
  20. package/src/providers/shared/call-status.ts +23 -0
  21. package/src/providers/shared/guarded-json-api.ts +42 -0
  22. package/src/providers/telnyx.test.ts +27 -0
  23. package/src/providers/telnyx.ts +56 -17
  24. package/src/providers/twilio/twiml-policy.test.ts +84 -0
  25. package/src/providers/twilio/twiml-policy.ts +91 -0
  26. package/src/providers/twilio/webhook.ts +1 -0
  27. package/src/providers/twilio.test.ts +93 -2
  28. package/src/providers/twilio.ts +111 -91
  29. package/src/runtime.test.ts +147 -0
  30. package/src/runtime.ts +123 -76
  31. package/src/tunnel.ts +1 -1
  32. package/src/types.ts +24 -0
  33. package/src/webhook/stale-call-reaper.ts +33 -0
  34. package/src/webhook/tailscale.ts +115 -0
  35. package/src/webhook-security.test.ts +135 -4
  36. package/src/webhook-security.ts +142 -42
  37. package/src/webhook.test.ts +168 -14
  38. package/src/webhook.ts +118 -203
  39. 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
- runtime = await runtimePromise;
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 callId = typeof params?.callId === "string" ? params.callId.trim() : "";
232
- const message = typeof params?.message === "string" ? params.message.trim() : "";
233
- if (!callId || !message) {
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 rt = await ensureRuntime();
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 callId = typeof params?.callId === "string" ? params.callId.trim() : "";
255
- const message = typeof params?.message === "string" ? params.message.trim() : "";
256
- if (!callId || !message) {
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 rt = await ensureRuntime();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.2.25",
3
+ "version": "2026.3.2",
4
4
  "description": "OpenClaw voice-call plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
package/src/cli.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  cleanupTailscaleExposureRoute,
11
11
  getTailscaleSelfInfo,
12
12
  setupTailscaleExposureRoute,
13
- } from "./webhook.js";
13
+ } from "./webhook/tailscale.js";
14
14
 
15
15
  type Logger = {
16
16
  info: (message: string) => void;
@@ -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();
@@ -59,9 +59,10 @@ function shouldAcceptInbound(config: EventContext["config"], from: string | unde
59
59
  }
60
60
  }
61
61
 
62
- function createInboundCall(params: {
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: "inbound",
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: params.ctx.config.inboundGreeting || "Hello! How can I help you today?",
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(`[voice-call] Created inbound call record: ${callId} from ${params.from}`);
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
- if (!call && event.direction === "inbound" && event.providerCallId) {
108
- if (!shouldAcceptInbound(ctx.config, event.from)) {
109
- const pid = event.providerCallId;
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 = createInboundCall({
150
+ call = createWebhookCall({
136
151
  ctx,
137
- providerCallId: event.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
+ });