@openclaw/voice-call 2026.2.21 → 2026.2.23
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 +6 -0
- package/README.md +13 -0
- package/package.json +1 -1
- package/src/cli.ts +29 -16
- package/src/config.test.ts +4 -0
- package/src/config.ts +15 -0
- package/src/manager/context.ts +1 -0
- package/src/manager/events.test.ts +100 -71
- package/src/manager/events.ts +17 -4
- package/src/manager/outbound.ts +76 -36
- package/src/manager/timers.ts +13 -4
- package/src/manager.test.ts +109 -127
- package/src/media-stream.test.ts +175 -0
- package/src/media-stream.ts +110 -0
- package/src/providers/plivo.ts +84 -39
- package/src/providers/twilio/webhook.ts +1 -0
- package/src/providers/twilio.test.ts +34 -0
- package/src/providers/twilio.ts +54 -3
- package/src/types.ts +8 -0
- package/src/webhook-security.test.ts +76 -0
- package/src/webhook-security.ts +100 -17
- package/src/webhook.test.ts +51 -1
- package/src/webhook.ts +24 -8
package/src/webhook-security.ts
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import type { WebhookContext } from "./types.js";
|
|
3
3
|
|
|
4
|
+
const REPLAY_WINDOW_MS = 10 * 60 * 1000;
|
|
5
|
+
const REPLAY_CACHE_MAX_ENTRIES = 10_000;
|
|
6
|
+
const REPLAY_CACHE_PRUNE_INTERVAL = 64;
|
|
7
|
+
|
|
8
|
+
type ReplayCache = {
|
|
9
|
+
seenUntil: Map<string, number>;
|
|
10
|
+
calls: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const twilioReplayCache: ReplayCache = {
|
|
14
|
+
seenUntil: new Map<string, number>(),
|
|
15
|
+
calls: 0,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const plivoReplayCache: ReplayCache = {
|
|
19
|
+
seenUntil: new Map<string, number>(),
|
|
20
|
+
calls: 0,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function sha256Hex(input: string): string {
|
|
24
|
+
return crypto.createHash("sha256").update(input).digest("hex");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function pruneReplayCache(cache: ReplayCache, now: number): void {
|
|
28
|
+
for (const [key, expiresAt] of cache.seenUntil) {
|
|
29
|
+
if (expiresAt <= now) {
|
|
30
|
+
cache.seenUntil.delete(key);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
while (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) {
|
|
34
|
+
const oldest = cache.seenUntil.keys().next().value;
|
|
35
|
+
if (!oldest) {
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
cache.seenUntil.delete(oldest);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function markReplay(cache: ReplayCache, replayKey: string): boolean {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
cache.calls += 1;
|
|
45
|
+
if (cache.calls % REPLAY_CACHE_PRUNE_INTERVAL === 0) {
|
|
46
|
+
pruneReplayCache(cache, now);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const existing = cache.seenUntil.get(replayKey);
|
|
50
|
+
if (existing && existing > now) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
cache.seenUntil.set(replayKey, now + REPLAY_WINDOW_MS);
|
|
55
|
+
if (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) {
|
|
56
|
+
pruneReplayCache(cache, now);
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
4
61
|
/**
|
|
5
62
|
* Validate Twilio webhook signature using HMAC-SHA1.
|
|
6
63
|
*
|
|
@@ -328,6 +385,8 @@ export interface TwilioVerificationResult {
|
|
|
328
385
|
verificationUrl?: string;
|
|
329
386
|
/** Whether we're running behind ngrok free tier */
|
|
330
387
|
isNgrokFreeTier?: boolean;
|
|
388
|
+
/** Request is cryptographically valid but was already processed recently. */
|
|
389
|
+
isReplay?: boolean;
|
|
331
390
|
}
|
|
332
391
|
|
|
333
392
|
export interface TelnyxVerificationResult {
|
|
@@ -335,6 +394,20 @@ export interface TelnyxVerificationResult {
|
|
|
335
394
|
reason?: string;
|
|
336
395
|
}
|
|
337
396
|
|
|
397
|
+
function createTwilioReplayKey(params: {
|
|
398
|
+
ctx: WebhookContext;
|
|
399
|
+
signature: string;
|
|
400
|
+
verificationUrl: string;
|
|
401
|
+
}): string {
|
|
402
|
+
const idempotencyToken = getHeader(params.ctx.headers, "i-twilio-idempotency-token");
|
|
403
|
+
if (idempotencyToken) {
|
|
404
|
+
return `twilio:idempotency:${idempotencyToken}`;
|
|
405
|
+
}
|
|
406
|
+
return `twilio:fallback:${sha256Hex(
|
|
407
|
+
`${params.verificationUrl}\n${params.signature}\n${params.ctx.rawBody}`,
|
|
408
|
+
)}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
338
411
|
function decodeBase64OrBase64Url(input: string): Buffer {
|
|
339
412
|
// Telnyx docs say Base64; some tooling emits Base64URL. Accept both.
|
|
340
413
|
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
@@ -505,7 +578,9 @@ export function verifyTwilioWebhook(
|
|
|
505
578
|
const isValid = validateTwilioSignature(authToken, signature, verificationUrl, params);
|
|
506
579
|
|
|
507
580
|
if (isValid) {
|
|
508
|
-
|
|
581
|
+
const replayKey = createTwilioReplayKey({ ctx, signature, verificationUrl });
|
|
582
|
+
const isReplay = markReplay(twilioReplayCache, replayKey);
|
|
583
|
+
return { ok: true, verificationUrl, isReplay };
|
|
509
584
|
}
|
|
510
585
|
|
|
511
586
|
// Check if this is ngrok free tier - the URL might have different format
|
|
@@ -533,6 +608,8 @@ export interface PlivoVerificationResult {
|
|
|
533
608
|
verificationUrl?: string;
|
|
534
609
|
/** Signature version used for verification */
|
|
535
610
|
version?: "v3" | "v2";
|
|
611
|
+
/** Request is cryptographically valid but was already processed recently. */
|
|
612
|
+
isReplay?: boolean;
|
|
536
613
|
}
|
|
537
614
|
|
|
538
615
|
function normalizeSignatureBase64(input: string): string {
|
|
@@ -753,14 +830,17 @@ export function verifyPlivoWebhook(
|
|
|
753
830
|
url: verificationUrl,
|
|
754
831
|
postParams,
|
|
755
832
|
});
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
833
|
+
if (!ok) {
|
|
834
|
+
return {
|
|
835
|
+
ok: false,
|
|
836
|
+
version: "v3",
|
|
837
|
+
verificationUrl,
|
|
838
|
+
reason: "Invalid Plivo V3 signature",
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
const replayKey = `plivo:v3:${sha256Hex(`${verificationUrl}\n${nonceV3}`)}`;
|
|
842
|
+
const isReplay = markReplay(plivoReplayCache, replayKey);
|
|
843
|
+
return { ok: true, version: "v3", verificationUrl, isReplay };
|
|
764
844
|
}
|
|
765
845
|
|
|
766
846
|
if (signatureV2 && nonceV2) {
|
|
@@ -770,14 +850,17 @@ export function verifyPlivoWebhook(
|
|
|
770
850
|
nonce: nonceV2,
|
|
771
851
|
url: verificationUrl,
|
|
772
852
|
});
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
853
|
+
if (!ok) {
|
|
854
|
+
return {
|
|
855
|
+
ok: false,
|
|
856
|
+
version: "v2",
|
|
857
|
+
verificationUrl,
|
|
858
|
+
reason: "Invalid Plivo V2 signature",
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
const replayKey = `plivo:v2:${sha256Hex(`${verificationUrl}\n${nonceV2}`)}`;
|
|
862
|
+
const isReplay = markReplay(plivoReplayCache, replayKey);
|
|
863
|
+
return { ok: true, version: "v2", verificationUrl, isReplay };
|
|
781
864
|
}
|
|
782
865
|
|
|
783
866
|
return {
|
package/src/webhook.test.ts
CHANGED
|
@@ -45,12 +45,14 @@ const createCall = (startedAt: number): CallRecord => ({
|
|
|
45
45
|
|
|
46
46
|
const createManager = (calls: CallRecord[]) => {
|
|
47
47
|
const endCall = vi.fn(async () => ({ success: true }));
|
|
48
|
+
const processEvent = vi.fn();
|
|
48
49
|
const manager = {
|
|
49
50
|
getActiveCalls: () => calls,
|
|
50
51
|
endCall,
|
|
52
|
+
processEvent,
|
|
51
53
|
} as unknown as CallManager;
|
|
52
54
|
|
|
53
|
-
return { manager, endCall };
|
|
55
|
+
return { manager, endCall, processEvent };
|
|
54
56
|
};
|
|
55
57
|
|
|
56
58
|
describe("VoiceCallWebhookServer stale call reaper", () => {
|
|
@@ -116,3 +118,51 @@ describe("VoiceCallWebhookServer stale call reaper", () => {
|
|
|
116
118
|
}
|
|
117
119
|
});
|
|
118
120
|
});
|
|
121
|
+
|
|
122
|
+
describe("VoiceCallWebhookServer replay handling", () => {
|
|
123
|
+
it("acknowledges replayed webhook requests and skips event side effects", async () => {
|
|
124
|
+
const replayProvider: VoiceCallProvider = {
|
|
125
|
+
...provider,
|
|
126
|
+
verifyWebhook: () => ({ ok: true, isReplay: true }),
|
|
127
|
+
parseWebhookEvent: () => ({
|
|
128
|
+
events: [
|
|
129
|
+
{
|
|
130
|
+
id: "evt-replay",
|
|
131
|
+
dedupeKey: "stable-replay",
|
|
132
|
+
type: "call.speech",
|
|
133
|
+
callId: "call-1",
|
|
134
|
+
providerCallId: "provider-call-1",
|
|
135
|
+
timestamp: Date.now(),
|
|
136
|
+
transcript: "hello",
|
|
137
|
+
isFinal: true,
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
statusCode: 200,
|
|
141
|
+
}),
|
|
142
|
+
};
|
|
143
|
+
const { manager, processEvent } = createManager([]);
|
|
144
|
+
const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } });
|
|
145
|
+
const server = new VoiceCallWebhookServer(config, manager, replayProvider);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const baseUrl = await server.start();
|
|
149
|
+
const address = (
|
|
150
|
+
server as unknown as { server?: { address?: () => unknown } }
|
|
151
|
+
).server?.address?.();
|
|
152
|
+
const requestUrl = new URL(baseUrl);
|
|
153
|
+
if (address && typeof address === "object" && "port" in address && address.port) {
|
|
154
|
+
requestUrl.port = String(address.port);
|
|
155
|
+
}
|
|
156
|
+
const response = await fetch(requestUrl.toString(), {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
159
|
+
body: "CallSid=CA123&SpeechResult=hello",
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(response.status).toBe(200);
|
|
163
|
+
expect(processEvent).not.toHaveBeenCalled();
|
|
164
|
+
} finally {
|
|
165
|
+
await server.stop();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
package/src/webhook.ts
CHANGED
|
@@ -77,6 +77,10 @@ export class VoiceCallWebhookServer {
|
|
|
77
77
|
|
|
78
78
|
const streamConfig: MediaStreamConfig = {
|
|
79
79
|
sttProvider,
|
|
80
|
+
preStartTimeoutMs: this.config.streaming?.preStartTimeoutMs,
|
|
81
|
+
maxPendingConnections: this.config.streaming?.maxPendingConnections,
|
|
82
|
+
maxPendingConnectionsPerIp: this.config.streaming?.maxPendingConnectionsPerIp,
|
|
83
|
+
maxConnections: this.config.streaming?.maxConnections,
|
|
80
84
|
shouldAcceptStream: ({ callId, token }) => {
|
|
81
85
|
const call = this.manager.getCallByProviderCallId(callId);
|
|
82
86
|
if (!call) {
|
|
@@ -192,9 +196,8 @@ export class VoiceCallWebhookServer {
|
|
|
192
196
|
// Handle WebSocket upgrades for media streams
|
|
193
197
|
if (this.mediaStreamHandler) {
|
|
194
198
|
this.server.on("upgrade", (request, socket, head) => {
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
if (url.pathname === streamPath) {
|
|
199
|
+
const path = this.getUpgradePathname(request);
|
|
200
|
+
if (path === streamPath) {
|
|
198
201
|
console.log("[voice-call] WebSocket upgrade for media stream");
|
|
199
202
|
this.mediaStreamHandler?.handleUpgrade(request, socket, head);
|
|
200
203
|
} else {
|
|
@@ -269,6 +272,15 @@ export class VoiceCallWebhookServer {
|
|
|
269
272
|
});
|
|
270
273
|
}
|
|
271
274
|
|
|
275
|
+
private getUpgradePathname(request: http.IncomingMessage): string | null {
|
|
276
|
+
try {
|
|
277
|
+
const host = request.headers.host || "localhost";
|
|
278
|
+
return new URL(request.url || "/", `http://${host}`).pathname;
|
|
279
|
+
} catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
272
284
|
/**
|
|
273
285
|
* Handle incoming HTTP request.
|
|
274
286
|
*/
|
|
@@ -334,11 +346,15 @@ export class VoiceCallWebhookServer {
|
|
|
334
346
|
const result = this.provider.parseWebhookEvent(ctx);
|
|
335
347
|
|
|
336
348
|
// Process each event
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
349
|
+
if (verification.isReplay) {
|
|
350
|
+
console.warn("[voice-call] Replay detected; skipping event side effects");
|
|
351
|
+
} else {
|
|
352
|
+
for (const event of result.events) {
|
|
353
|
+
try {
|
|
354
|
+
this.manager.processEvent(event);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
console.error(`[voice-call] Error processing event ${event.type}:`, err);
|
|
357
|
+
}
|
|
342
358
|
}
|
|
343
359
|
}
|
|
344
360
|
|