@openclaw/voice-call 2026.3.12 → 2026.3.13

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.13
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
3
9
  ## 2026.3.12
4
10
 
5
11
  ### Changes
package/README.md CHANGED
@@ -89,56 +89,18 @@ Notes:
89
89
  - Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
90
90
  - `mock` is a local dev provider (no network calls).
91
91
  - Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true.
92
- - `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
93
-
94
- Streaming security defaults:
95
-
96
- - `streaming.preStartTimeoutMs` closes sockets that never send a valid `start` frame.
97
- - `streaming.maxPendingConnections` caps total unauthenticated pre-start sockets.
98
- - `streaming.maxPendingConnectionsPerIp` caps unauthenticated pre-start sockets per source IP.
99
- - `streaming.maxConnections` caps total open media stream sockets (pending + active).
92
+ - advanced webhook, streaming, and tunnel notes: `https://docs.openclaw.ai/plugins/voice-call`
100
93
 
101
94
  ## Stale call reaper
102
95
 
103
- Use `staleCallReaperSeconds` to end calls that never receive a terminal webhook
104
- (for example, notify-mode calls that never complete). The default is `0`
105
- (disabled).
106
-
107
- Recommended ranges:
108
-
109
- - **Production:** `120`–`300` seconds for notify-style flows.
110
- - Keep this value **higher than `maxDurationSeconds`** so normal calls can
111
- finish. A good starting point is `maxDurationSeconds + 30–60` seconds.
112
-
113
- Example:
114
-
115
- ```json5
116
- {
117
- staleCallReaperSeconds: 360,
118
- }
119
- ```
96
+ See the plugin docs for recommended ranges and production examples:
97
+ `https://docs.openclaw.ai/plugins/voice-call#stale-call-reaper`
120
98
 
121
99
  ## TTS for calls
122
100
 
123
101
  Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
124
- streaming speech on calls. You can override it under the plugin config with the
125
- same shape — overrides deep-merge with `messages.tts`.
126
-
127
- ```json5
128
- {
129
- tts: {
130
- provider: "openai",
131
- openai: {
132
- voice: "alloy",
133
- },
134
- },
135
- }
136
- ```
137
-
138
- Notes:
139
-
140
- - Edge TTS is ignored for voice calls (telephony audio needs PCM; Edge output is unreliable).
141
- - Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
102
+ streaming speech on calls. Override examples and provider caveats live here:
103
+ `https://docs.openclaw.ai/plugins/voice-call#tts-for-calls`
142
104
 
143
105
  ## CLI
144
106
 
package/index.ts CHANGED
@@ -227,6 +227,37 @@ const voiceCallPlugin = {
227
227
  params.respond(true, { callId: result.callId, initiated: true });
228
228
  };
229
229
 
230
+ const respondToCallMessageAction = async (params: {
231
+ requestParams: GatewayRequestHandlerOptions["params"];
232
+ respond: GatewayRequestHandlerOptions["respond"];
233
+ action: (
234
+ request: Exclude<Awaited<ReturnType<typeof resolveCallMessageRequest>>, { error: string }>,
235
+ ) => Promise<{
236
+ success: boolean;
237
+ error?: string;
238
+ transcript?: string;
239
+ }>;
240
+ failure: string;
241
+ includeTranscript?: boolean;
242
+ }) => {
243
+ const request = await resolveCallMessageRequest(params.requestParams);
244
+ if ("error" in request) {
245
+ params.respond(false, { error: request.error });
246
+ return;
247
+ }
248
+ const result = await params.action(request);
249
+ if (!result.success) {
250
+ params.respond(false, { error: result.error || params.failure });
251
+ return;
252
+ }
253
+ params.respond(
254
+ true,
255
+ params.includeTranscript
256
+ ? { success: true, transcript: result.transcript }
257
+ : { success: true },
258
+ );
259
+ };
260
+
230
261
  api.registerGatewayMethod(
231
262
  "voicecall.initiate",
232
263
  async ({ params, respond }: GatewayRequestHandlerOptions) => {
@@ -264,17 +295,13 @@ const voiceCallPlugin = {
264
295
  "voicecall.continue",
265
296
  async ({ params, respond }: GatewayRequestHandlerOptions) => {
266
297
  try {
267
- const request = await resolveCallMessageRequest(params);
268
- if ("error" in request) {
269
- respond(false, { error: request.error });
270
- return;
271
- }
272
- const result = await request.rt.manager.continueCall(request.callId, request.message);
273
- if (!result.success) {
274
- respond(false, { error: result.error || "continue failed" });
275
- return;
276
- }
277
- respond(true, { success: true, transcript: result.transcript });
298
+ await respondToCallMessageAction({
299
+ requestParams: params,
300
+ respond,
301
+ action: (request) => request.rt.manager.continueCall(request.callId, request.message),
302
+ failure: "continue failed",
303
+ includeTranscript: true,
304
+ });
278
305
  } catch (err) {
279
306
  sendError(respond, err);
280
307
  }
@@ -285,17 +312,12 @@ const voiceCallPlugin = {
285
312
  "voicecall.speak",
286
313
  async ({ params, respond }: GatewayRequestHandlerOptions) => {
287
314
  try {
288
- const request = await resolveCallMessageRequest(params);
289
- if ("error" in request) {
290
- respond(false, { error: request.error });
291
- return;
292
- }
293
- const result = await request.rt.manager.speak(request.callId, request.message);
294
- if (!result.success) {
295
- respond(false, { error: result.error || "speak failed" });
296
- return;
297
- }
298
- respond(true, { success: true });
315
+ await respondToCallMessageAction({
316
+ requestParams: params,
317
+ respond,
318
+ action: (request) => request.rt.manager.speak(request.callId, request.message),
319
+ failure: "speak failed",
320
+ });
299
321
  } catch (err) {
300
322
  sendError(respond, err);
301
323
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.3.12",
3
+ "version": "2026.3.13",
4
4
  "description": "OpenClaw voice-call plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -9,121 +9,87 @@ import {
9
9
  } from "./manager.test-harness.js";
10
10
 
11
11
  describe("CallManager verification on restore", () => {
12
- it("skips stale calls reported terminal by provider", async () => {
12
+ async function initializeManager(params?: {
13
+ callOverrides?: Parameters<typeof makePersistedCall>[0];
14
+ providerResult?: FakeProvider["getCallStatusResult"];
15
+ configureProvider?: (provider: FakeProvider) => void;
16
+ configOverrides?: Partial<{ maxDurationSeconds: number }>;
17
+ }) {
13
18
  const storePath = createTestStorePath();
14
- const call = makePersistedCall();
19
+ const call = makePersistedCall(params?.callOverrides);
15
20
  writeCallsToStore(storePath, [call]);
16
21
 
17
22
  const provider = new FakeProvider();
18
- provider.getCallStatusResult = { status: "completed", isTerminal: true };
23
+ if (params?.providerResult) {
24
+ provider.getCallStatusResult = params.providerResult;
25
+ }
26
+ params?.configureProvider?.(provider);
19
27
 
20
28
  const config = VoiceCallConfigSchema.parse({
21
29
  enabled: true,
22
30
  provider: "plivo",
23
31
  fromNumber: "+15550000000",
32
+ ...params?.configOverrides,
24
33
  });
25
34
  const manager = new CallManager(config, storePath);
26
35
  await manager.initialize(provider, "https://example.com/voice/webhook");
27
36
 
37
+ return { call, manager };
38
+ }
39
+
40
+ it("skips stale calls reported terminal by provider", async () => {
41
+ const { manager } = await initializeManager({
42
+ providerResult: { status: "completed", isTerminal: true },
43
+ });
44
+
28
45
  expect(manager.getActiveCalls()).toHaveLength(0);
29
46
  });
30
47
 
31
48
  it("keeps calls reported active by provider", async () => {
32
- const storePath = createTestStorePath();
33
- const call = makePersistedCall();
34
- writeCallsToStore(storePath, [call]);
35
-
36
- const provider = new FakeProvider();
37
- provider.getCallStatusResult = { status: "in-progress", isTerminal: false };
38
-
39
- const config = VoiceCallConfigSchema.parse({
40
- enabled: true,
41
- provider: "plivo",
42
- fromNumber: "+15550000000",
49
+ const { call, manager } = await initializeManager({
50
+ providerResult: { status: "in-progress", isTerminal: false },
43
51
  });
44
- const manager = new CallManager(config, storePath);
45
- await manager.initialize(provider, "https://example.com/voice/webhook");
46
52
 
47
53
  expect(manager.getActiveCalls()).toHaveLength(1);
48
54
  expect(manager.getActiveCalls()[0]?.callId).toBe(call.callId);
49
55
  });
50
56
 
51
57
  it("keeps calls when provider returns unknown (transient error)", async () => {
52
- const storePath = createTestStorePath();
53
- const call = makePersistedCall();
54
- writeCallsToStore(storePath, [call]);
55
-
56
- const provider = new FakeProvider();
57
- provider.getCallStatusResult = { status: "error", isTerminal: false, isUnknown: true };
58
-
59
- const config = VoiceCallConfigSchema.parse({
60
- enabled: true,
61
- provider: "plivo",
62
- fromNumber: "+15550000000",
58
+ const { manager } = await initializeManager({
59
+ providerResult: { status: "error", isTerminal: false, isUnknown: true },
63
60
  });
64
- const manager = new CallManager(config, storePath);
65
- await manager.initialize(provider, "https://example.com/voice/webhook");
66
61
 
67
62
  expect(manager.getActiveCalls()).toHaveLength(1);
68
63
  });
69
64
 
70
65
  it("skips calls older than maxDurationSeconds", async () => {
71
- const storePath = createTestStorePath();
72
- const call = makePersistedCall({
73
- startedAt: Date.now() - 600_000,
74
- answeredAt: Date.now() - 590_000,
66
+ const { manager } = await initializeManager({
67
+ callOverrides: {
68
+ startedAt: Date.now() - 600_000,
69
+ answeredAt: Date.now() - 590_000,
70
+ },
71
+ configOverrides: { maxDurationSeconds: 300 },
75
72
  });
76
- writeCallsToStore(storePath, [call]);
77
-
78
- const provider = new FakeProvider();
79
-
80
- const config = VoiceCallConfigSchema.parse({
81
- enabled: true,
82
- provider: "plivo",
83
- fromNumber: "+15550000000",
84
- maxDurationSeconds: 300,
85
- });
86
- const manager = new CallManager(config, storePath);
87
- await manager.initialize(provider, "https://example.com/voice/webhook");
88
73
 
89
74
  expect(manager.getActiveCalls()).toHaveLength(0);
90
75
  });
91
76
 
92
77
  it("skips calls without providerCallId", async () => {
93
- const storePath = createTestStorePath();
94
- const call = makePersistedCall({ providerCallId: undefined, state: "initiated" });
95
- writeCallsToStore(storePath, [call]);
96
-
97
- const provider = new FakeProvider();
98
-
99
- const config = VoiceCallConfigSchema.parse({
100
- enabled: true,
101
- provider: "plivo",
102
- fromNumber: "+15550000000",
78
+ const { manager } = await initializeManager({
79
+ callOverrides: { providerCallId: undefined, state: "initiated" },
103
80
  });
104
- const manager = new CallManager(config, storePath);
105
- await manager.initialize(provider, "https://example.com/voice/webhook");
106
81
 
107
82
  expect(manager.getActiveCalls()).toHaveLength(0);
108
83
  });
109
84
 
110
85
  it("keeps call when getCallStatus throws (verification failure)", async () => {
111
- const storePath = createTestStorePath();
112
- const call = makePersistedCall();
113
- writeCallsToStore(storePath, [call]);
114
-
115
- const provider = new FakeProvider();
116
- provider.getCallStatus = async () => {
117
- throw new Error("network failure");
118
- };
119
-
120
- const config = VoiceCallConfigSchema.parse({
121
- enabled: true,
122
- provider: "plivo",
123
- fromNumber: "+15550000000",
86
+ const { manager } = await initializeManager({
87
+ configureProvider: (provider) => {
88
+ provider.getCallStatus = async () => {
89
+ throw new Error("network failure");
90
+ };
91
+ },
124
92
  });
125
- const manager = new CallManager(config, storePath);
126
- await manager.initialize(provider, "https://example.com/voice/webhook");
127
93
 
128
94
  expect(manager.getActiveCalls()).toHaveLength(1);
129
95
  });
@@ -22,6 +22,34 @@ function decodeBase64Url(input: string): Buffer {
22
22
  return Buffer.from(padded, "base64");
23
23
  }
24
24
 
25
+ function createSignedTelnyxCtx(params: {
26
+ privateKey: crypto.KeyObject;
27
+ rawBody: string;
28
+ }): WebhookContext {
29
+ const timestamp = String(Math.floor(Date.now() / 1000));
30
+ const signedPayload = `${timestamp}|${params.rawBody}`;
31
+ const signature = crypto
32
+ .sign(null, Buffer.from(signedPayload), params.privateKey)
33
+ .toString("base64");
34
+
35
+ return createCtx({
36
+ rawBody: params.rawBody,
37
+ headers: {
38
+ "telnyx-signature-ed25519": signature,
39
+ "telnyx-timestamp": timestamp,
40
+ },
41
+ });
42
+ }
43
+
44
+ function expectReplayVerification(
45
+ results: Array<{ ok: boolean; isReplay?: boolean; verifiedRequestKey?: string }>,
46
+ ) {
47
+ expect(results.map((result) => result.ok)).toEqual([true, true]);
48
+ expect(results.map((result) => Boolean(result.isReplay))).toEqual([false, true]);
49
+ expect(results[0]?.verifiedRequestKey).toEqual(expect.any(String));
50
+ expect(results[1]?.verifiedRequestKey).toBe(results[0]?.verifiedRequestKey);
51
+ }
52
+
25
53
  function expectWebhookVerificationSucceeds(params: {
26
54
  publicKey: string;
27
55
  privateKey: crypto.KeyObject;
@@ -35,20 +63,8 @@ function expectWebhookVerificationSucceeds(params: {
35
63
  event_type: "call.initiated",
36
64
  payload: { call_control_id: "x" },
37
65
  });
38
- const timestamp = String(Math.floor(Date.now() / 1000));
39
- const signedPayload = `${timestamp}|${rawBody}`;
40
- const signature = crypto
41
- .sign(null, Buffer.from(signedPayload), params.privateKey)
42
- .toString("base64");
43
-
44
66
  const result = provider.verifyWebhook(
45
- createCtx({
46
- rawBody,
47
- headers: {
48
- "telnyx-signature-ed25519": signature,
49
- "telnyx-timestamp": timestamp,
50
- },
51
- }),
67
+ createSignedTelnyxCtx({ privateKey: params.privateKey, rawBody }),
52
68
  );
53
69
  expect(result.ok).toBe(true);
54
70
  }
@@ -117,26 +133,12 @@ describe("TelnyxProvider.verifyWebhook", () => {
117
133
  payload: { call_control_id: "call-replay-test" },
118
134
  nonce: crypto.randomUUID(),
119
135
  });
120
- const timestamp = String(Math.floor(Date.now() / 1000));
121
- const signedPayload = `${timestamp}|${rawBody}`;
122
- const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
123
- const ctx = createCtx({
124
- rawBody,
125
- headers: {
126
- "telnyx-signature-ed25519": signature,
127
- "telnyx-timestamp": timestamp,
128
- },
129
- });
136
+ const ctx = createSignedTelnyxCtx({ privateKey, rawBody });
130
137
 
131
138
  const first = provider.verifyWebhook(ctx);
132
139
  const second = provider.verifyWebhook(ctx);
133
140
 
134
- expect(first.ok).toBe(true);
135
- expect(first.isReplay).toBeFalsy();
136
- expect(first.verifiedRequestKey).toBeTruthy();
137
- expect(second.ok).toBe(true);
138
- expect(second.isReplay).toBe(true);
139
- expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
141
+ expectReplayVerification([first, second]);
140
142
  });
141
143
  });
142
144
 
@@ -21,6 +21,12 @@ function createContext(rawBody: string, query?: WebhookContext["query"]): Webhoo
21
21
  };
22
22
  }
23
23
 
24
+ function expectStreamingTwiml(body: string) {
25
+ expect(body).toContain(STREAM_URL);
26
+ expect(body).toContain('<Parameter name="token" value="');
27
+ expect(body).toContain("<Connect>");
28
+ }
29
+
24
30
  describe("TwilioProvider", () => {
25
31
  it("returns streaming TwiML for outbound conversation calls before in-progress", () => {
26
32
  const provider = createProvider();
@@ -30,9 +36,8 @@ describe("TwilioProvider", () => {
30
36
 
31
37
  const result = provider.parseWebhookEvent(ctx);
32
38
 
33
- expect(result.providerResponseBody).toContain(STREAM_URL);
34
- expect(result.providerResponseBody).toContain('<Parameter name="token" value="');
35
- expect(result.providerResponseBody).toContain("<Connect>");
39
+ expect(result.providerResponseBody).toBeDefined();
40
+ expectStreamingTwiml(result.providerResponseBody ?? "");
36
41
  });
37
42
 
38
43
  it("returns empty TwiML for status callbacks", () => {
@@ -55,9 +60,8 @@ describe("TwilioProvider", () => {
55
60
 
56
61
  const result = provider.parseWebhookEvent(ctx);
57
62
 
58
- expect(result.providerResponseBody).toContain(STREAM_URL);
59
- expect(result.providerResponseBody).toContain('<Parameter name="token" value="');
60
- expect(result.providerResponseBody).toContain("<Connect>");
63
+ expect(result.providerResponseBody).toBeDefined();
64
+ expectStreamingTwiml(result.providerResponseBody ?? "");
61
65
  });
62
66
 
63
67
  it("returns queue TwiML for second inbound call when first call is active", () => {
@@ -98,6 +98,51 @@ function expectReplayResultPair(
98
98
  expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
99
99
  }
100
100
 
101
+ function expectAcceptedWebhookVersion(
102
+ result: { ok: boolean; version?: string },
103
+ version: "v2" | "v3",
104
+ ) {
105
+ expect(result).toMatchObject({ ok: true, version });
106
+ }
107
+
108
+ function verifyTwilioNgrokLoopback(signature: string) {
109
+ return verifyTwilioWebhook(
110
+ {
111
+ headers: {
112
+ host: "127.0.0.1:3334",
113
+ "x-forwarded-proto": "https",
114
+ "x-forwarded-host": "local.ngrok-free.app",
115
+ "x-twilio-signature": signature,
116
+ },
117
+ rawBody: "CallSid=CS123&CallStatus=completed&From=%2B15550000000",
118
+ url: "http://127.0.0.1:3334/voice/webhook",
119
+ method: "POST",
120
+ remoteAddress: "127.0.0.1",
121
+ },
122
+ "test-auth-token",
123
+ { allowNgrokFreeTierLoopbackBypass: true },
124
+ );
125
+ }
126
+
127
+ function verifyTwilioSignedRequest(params: {
128
+ headers: Record<string, string>;
129
+ rawBody: string;
130
+ authToken: string;
131
+ publicUrl: string;
132
+ }) {
133
+ return verifyTwilioWebhook(
134
+ {
135
+ headers: params.headers,
136
+ rawBody: params.rawBody,
137
+ url: "http://local/voice/webhook?callId=abc",
138
+ method: "POST",
139
+ query: { callId: "abc" },
140
+ },
141
+ params.authToken,
142
+ { publicUrl: params.publicUrl },
143
+ );
144
+ }
145
+
101
146
  describe("verifyPlivoWebhook", () => {
102
147
  it("accepts valid V2 signature", () => {
103
148
  const authToken = "test-auth-token";
@@ -127,8 +172,7 @@ describe("verifyPlivoWebhook", () => {
127
172
  authToken,
128
173
  );
129
174
 
130
- expect(result.ok).toBe(true);
131
- expect(result.version).toBe("v2");
175
+ expectAcceptedWebhookVersion(result, "v2");
132
176
  });
133
177
 
134
178
  it("accepts valid V3 signature (including multi-signature header)", () => {
@@ -161,8 +205,7 @@ describe("verifyPlivoWebhook", () => {
161
205
  authToken,
162
206
  );
163
207
 
164
- expect(result.ok).toBe(true);
165
- expect(result.version).toBe("v3");
208
+ expectAcceptedWebhookVersion(result, "v3");
166
209
  });
167
210
 
168
211
  it("rejects missing signatures", () => {
@@ -317,35 +360,10 @@ describe("verifyTwilioWebhook", () => {
317
360
  "i-twilio-idempotency-token": "idem-replay-1",
318
361
  };
319
362
 
320
- const first = verifyTwilioWebhook(
321
- {
322
- headers,
323
- rawBody: postBody,
324
- url: "http://local/voice/webhook?callId=abc",
325
- method: "POST",
326
- query: { callId: "abc" },
327
- },
328
- authToken,
329
- { publicUrl },
330
- );
331
- const second = verifyTwilioWebhook(
332
- {
333
- headers,
334
- rawBody: postBody,
335
- url: "http://local/voice/webhook?callId=abc",
336
- method: "POST",
337
- query: { callId: "abc" },
338
- },
339
- authToken,
340
- { publicUrl },
341
- );
363
+ const first = verifyTwilioSignedRequest({ headers, rawBody: postBody, authToken, publicUrl });
364
+ const second = verifyTwilioSignedRequest({ headers, rawBody: postBody, authToken, publicUrl });
342
365
 
343
- expect(first.ok).toBe(true);
344
- expect(first.isReplay).toBeFalsy();
345
- expect(first.verifiedRequestKey).toBeTruthy();
346
- expect(second.ok).toBe(true);
347
- expect(second.isReplay).toBe(true);
348
- expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
366
+ expectReplayResultPair(first, second);
349
367
  });
350
368
 
351
369
  it("treats changed idempotency header as replay for identical signed requests", () => {
@@ -355,45 +373,30 @@ describe("verifyTwilioWebhook", () => {
355
373
  const postBody = "CallSid=CS778&CallStatus=completed&From=%2B15550000000";
356
374
  const signature = twilioSignature({ authToken, url: urlWithQuery, postBody });
357
375
 
358
- const first = verifyTwilioWebhook(
359
- {
360
- headers: {
361
- host: "example.com",
362
- "x-forwarded-proto": "https",
363
- "x-twilio-signature": signature,
364
- "i-twilio-idempotency-token": "idem-replay-a",
365
- },
366
- rawBody: postBody,
367
- url: "http://local/voice/webhook?callId=abc",
368
- method: "POST",
369
- query: { callId: "abc" },
376
+ const first = verifyTwilioSignedRequest({
377
+ headers: {
378
+ host: "example.com",
379
+ "x-forwarded-proto": "https",
380
+ "x-twilio-signature": signature,
381
+ "i-twilio-idempotency-token": "idem-replay-a",
370
382
  },
383
+ rawBody: postBody,
371
384
  authToken,
372
- { publicUrl },
373
- );
374
- const second = verifyTwilioWebhook(
375
- {
376
- headers: {
377
- host: "example.com",
378
- "x-forwarded-proto": "https",
379
- "x-twilio-signature": signature,
380
- "i-twilio-idempotency-token": "idem-replay-b",
381
- },
382
- rawBody: postBody,
383
- url: "http://local/voice/webhook?callId=abc",
384
- method: "POST",
385
- query: { callId: "abc" },
385
+ publicUrl,
386
+ });
387
+ const second = verifyTwilioSignedRequest({
388
+ headers: {
389
+ host: "example.com",
390
+ "x-forwarded-proto": "https",
391
+ "x-twilio-signature": signature,
392
+ "i-twilio-idempotency-token": "idem-replay-b",
386
393
  },
394
+ rawBody: postBody,
387
395
  authToken,
388
- { publicUrl },
389
- );
396
+ publicUrl,
397
+ });
390
398
 
391
- expect(first.ok).toBe(true);
392
- expect(first.isReplay).toBe(false);
393
- expect(first.verifiedRequestKey).toBeTruthy();
394
- expect(second.ok).toBe(true);
395
- expect(second.isReplay).toBe(true);
396
- expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
399
+ expectReplayResultPair(first, second);
397
400
  });
398
401
 
399
402
  it("rejects invalid signatures even when attacker injects forwarded host", () => {
@@ -422,57 +425,22 @@ describe("verifyTwilioWebhook", () => {
422
425
  });
423
426
 
424
427
  it("accepts valid signatures for ngrok free tier on loopback when compatibility mode is enabled", () => {
425
- const authToken = "test-auth-token";
426
- const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
427
428
  const webhookUrl = "https://local.ngrok-free.app/voice/webhook";
428
429
 
429
430
  const signature = twilioSignature({
430
- authToken,
431
+ authToken: "test-auth-token",
431
432
  url: webhookUrl,
432
- postBody,
433
+ postBody: "CallSid=CS123&CallStatus=completed&From=%2B15550000000",
433
434
  });
434
435
 
435
- const result = verifyTwilioWebhook(
436
- {
437
- headers: {
438
- host: "127.0.0.1:3334",
439
- "x-forwarded-proto": "https",
440
- "x-forwarded-host": "local.ngrok-free.app",
441
- "x-twilio-signature": signature,
442
- },
443
- rawBody: postBody,
444
- url: "http://127.0.0.1:3334/voice/webhook",
445
- method: "POST",
446
- remoteAddress: "127.0.0.1",
447
- },
448
- authToken,
449
- { allowNgrokFreeTierLoopbackBypass: true },
450
- );
436
+ const result = verifyTwilioNgrokLoopback(signature);
451
437
 
452
438
  expect(result.ok).toBe(true);
453
439
  expect(result.verificationUrl).toBe(webhookUrl);
454
440
  });
455
441
 
456
442
  it("does not allow invalid signatures for ngrok free tier on loopback", () => {
457
- const authToken = "test-auth-token";
458
- const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
459
-
460
- const result = verifyTwilioWebhook(
461
- {
462
- headers: {
463
- host: "127.0.0.1:3334",
464
- "x-forwarded-proto": "https",
465
- "x-forwarded-host": "local.ngrok-free.app",
466
- "x-twilio-signature": "invalid",
467
- },
468
- rawBody: postBody,
469
- url: "http://127.0.0.1:3334/voice/webhook",
470
- method: "POST",
471
- remoteAddress: "127.0.0.1",
472
- },
473
- authToken,
474
- { allowNgrokFreeTierLoopbackBypass: true },
475
- );
443
+ const result = verifyTwilioNgrokLoopback("invalid");
476
444
 
477
445
  expect(result.ok).toBe(false);
478
446
  expect(result.reason).toMatch(/Invalid signature/);
@@ -56,6 +56,28 @@ const createManager = (calls: CallRecord[]) => {
56
56
  return { manager, endCall, processEvent };
57
57
  };
58
58
 
59
+ async function runStaleCallReaperCase(params: {
60
+ callAgeMs: number;
61
+ staleCallReaperSeconds: number;
62
+ advanceMs: number;
63
+ }) {
64
+ const now = new Date("2026-02-16T00:00:00Z");
65
+ vi.setSystemTime(now);
66
+
67
+ const call = createCall(now.getTime() - params.callAgeMs);
68
+ const { manager, endCall } = createManager([call]);
69
+ const config = createConfig({ staleCallReaperSeconds: params.staleCallReaperSeconds });
70
+ const server = new VoiceCallWebhookServer(config, manager, provider);
71
+
72
+ try {
73
+ await server.start();
74
+ await vi.advanceTimersByTimeAsync(params.advanceMs);
75
+ return { call, endCall };
76
+ } finally {
77
+ await server.stop();
78
+ }
79
+ }
80
+
59
81
  async function postWebhookForm(server: VoiceCallWebhookServer, baseUrl: string, body: string) {
60
82
  const address = (
61
83
  server as unknown as { server?: { address?: () => unknown } }
@@ -81,39 +103,21 @@ describe("VoiceCallWebhookServer stale call reaper", () => {
81
103
  });
82
104
 
83
105
  it("ends calls older than staleCallReaperSeconds", async () => {
84
- const now = new Date("2026-02-16T00:00:00Z");
85
- vi.setSystemTime(now);
86
-
87
- const call = createCall(now.getTime() - 120_000);
88
- const { manager, endCall } = createManager([call]);
89
- const config = createConfig({ staleCallReaperSeconds: 60 });
90
- const server = new VoiceCallWebhookServer(config, manager, provider);
91
-
92
- try {
93
- await server.start();
94
- await vi.advanceTimersByTimeAsync(30_000);
95
- expect(endCall).toHaveBeenCalledWith(call.callId);
96
- } finally {
97
- await server.stop();
98
- }
106
+ const { call, endCall } = await runStaleCallReaperCase({
107
+ callAgeMs: 120_000,
108
+ staleCallReaperSeconds: 60,
109
+ advanceMs: 30_000,
110
+ });
111
+ expect(endCall).toHaveBeenCalledWith(call.callId);
99
112
  });
100
113
 
101
114
  it("skips calls that are younger than the threshold", async () => {
102
- const now = new Date("2026-02-16T00:00:00Z");
103
- vi.setSystemTime(now);
104
-
105
- const call = createCall(now.getTime() - 10_000);
106
- const { manager, endCall } = createManager([call]);
107
- const config = createConfig({ staleCallReaperSeconds: 60 });
108
- const server = new VoiceCallWebhookServer(config, manager, provider);
109
-
110
- try {
111
- await server.start();
112
- await vi.advanceTimersByTimeAsync(30_000);
113
- expect(endCall).not.toHaveBeenCalled();
114
- } finally {
115
- await server.stop();
116
- }
115
+ const { endCall } = await runStaleCallReaperCase({
116
+ callAgeMs: 10_000,
117
+ staleCallReaperSeconds: 60,
118
+ advanceMs: 30_000,
119
+ });
120
+ expect(endCall).not.toHaveBeenCalled();
117
121
  });
118
122
 
119
123
  it("does not run when staleCallReaperSeconds is disabled", async () => {