@openclaw/voice-call 2026.3.11 → 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 +13 -0
- package/README.md +5 -43
- package/index.ts +44 -22
- package/package.json +1 -1
- package/src/manager.restore.test.ts +38 -72
- package/src/providers/telnyx.test.ts +31 -29
- package/src/providers/twilio.test.ts +10 -6
- package/src/webhook-security.test.ts +73 -105
- package/src/webhook.test.ts +34 -30
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026.3.13
|
|
4
|
+
|
|
5
|
+
### Changes
|
|
6
|
+
|
|
7
|
+
- Version alignment with core OpenClaw release numbers.
|
|
8
|
+
|
|
9
|
+
## 2026.3.12
|
|
10
|
+
|
|
11
|
+
### Changes
|
|
12
|
+
|
|
13
|
+
- Version alignment with core OpenClaw release numbers.
|
|
14
|
+
|
|
3
15
|
## 2026.3.11
|
|
4
16
|
|
|
5
17
|
### Changes
|
|
18
|
+
|
|
6
19
|
- Version alignment with core OpenClaw release numbers.
|
|
7
20
|
|
|
8
21
|
## 2026.3.10
|
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
|
-
-
|
|
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
|
-
|
|
104
|
-
|
|
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.
|
|
125
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
respond
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
respond
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
@@ -9,121 +9,87 @@ import {
|
|
|
9
9
|
} from "./manager.test-harness.js";
|
|
10
10
|
|
|
11
11
|
describe("CallManager verification on restore", () => {
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
33
|
-
|
|
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
|
|
53
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
94
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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).
|
|
34
|
-
|
|
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).
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
359
|
-
{
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
373
|
-
);
|
|
374
|
-
const second =
|
|
375
|
-
{
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
389
|
-
);
|
|
396
|
+
publicUrl,
|
|
397
|
+
});
|
|
390
398
|
|
|
391
|
-
|
|
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 =
|
|
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
|
|
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/);
|
package/src/webhook.test.ts
CHANGED
|
@@ -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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 () => {
|