@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.
@@ -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
- return { ok: true, verificationUrl };
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
- return ok
757
- ? { ok: true, version: "v3", verificationUrl }
758
- : {
759
- ok: false,
760
- version: "v3",
761
- verificationUrl,
762
- reason: "Invalid Plivo V3 signature",
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
- return ok
774
- ? { ok: true, version: "v2", verificationUrl }
775
- : {
776
- ok: false,
777
- version: "v2",
778
- verificationUrl,
779
- reason: "Invalid Plivo V2 signature",
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 {
@@ -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 url = new URL(request.url || "/", `http://${request.headers.host}`);
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
- for (const event of result.events) {
338
- try {
339
- this.manager.processEvent(event);
340
- } catch (err) {
341
- console.error(`[voice-call] Error processing event ${event.type}:`, err);
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