@openclaw/voice-call 2026.3.13 → 2026.5.1-beta.1

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 (103) hide show
  1. package/README.md +25 -5
  2. package/api.ts +16 -0
  3. package/cli-metadata.ts +10 -0
  4. package/config-api.ts +12 -0
  5. package/index.test.ts +866 -0
  6. package/index.ts +353 -148
  7. package/openclaw.plugin.json +336 -157
  8. package/package.json +33 -5
  9. package/runtime-api.ts +20 -0
  10. package/runtime-entry.ts +1 -0
  11. package/setup-api.ts +47 -0
  12. package/src/allowlist.test.ts +18 -0
  13. package/src/cli.ts +533 -68
  14. package/src/config-compat.test.ts +120 -0
  15. package/src/config-compat.ts +227 -0
  16. package/src/config.test.ts +160 -12
  17. package/src/config.ts +243 -74
  18. package/src/core-bridge.ts +2 -147
  19. package/src/deep-merge.test.ts +40 -0
  20. package/src/gateway-continue-operation.ts +200 -0
  21. package/src/http-headers.ts +6 -3
  22. package/src/manager/context.ts +6 -5
  23. package/src/manager/events.test.ts +179 -19
  24. package/src/manager/events.ts +48 -30
  25. package/src/manager/lifecycle.ts +53 -0
  26. package/src/manager/lookup.test.ts +52 -0
  27. package/src/manager/outbound.test.ts +464 -0
  28. package/src/manager/outbound.ts +148 -55
  29. package/src/manager/store.ts +18 -6
  30. package/src/manager/timers.test.ts +129 -0
  31. package/src/manager/timers.ts +4 -3
  32. package/src/manager/twiml.test.ts +13 -0
  33. package/src/manager/twiml.ts +8 -0
  34. package/src/manager.closed-loop.test.ts +30 -12
  35. package/src/manager.inbound-allowlist.test.ts +77 -10
  36. package/src/manager.notify.test.ts +344 -20
  37. package/src/manager.restore.test.ts +95 -8
  38. package/src/manager.test-harness.ts +8 -6
  39. package/src/manager.ts +79 -5
  40. package/src/media-stream.test.ts +578 -81
  41. package/src/media-stream.ts +235 -54
  42. package/src/providers/base.ts +19 -0
  43. package/src/providers/mock.ts +7 -1
  44. package/src/providers/plivo.test.ts +50 -6
  45. package/src/providers/plivo.ts +14 -6
  46. package/src/providers/shared/call-status.ts +2 -1
  47. package/src/providers/shared/guarded-json-api.test.ts +106 -0
  48. package/src/providers/shared/guarded-json-api.ts +1 -1
  49. package/src/providers/telnyx.test.ts +178 -6
  50. package/src/providers/telnyx.ts +40 -3
  51. package/src/providers/twilio/api.test.ts +145 -0
  52. package/src/providers/twilio/api.ts +67 -16
  53. package/src/providers/twilio/twiml-policy.ts +6 -10
  54. package/src/providers/twilio/webhook.ts +1 -1
  55. package/src/providers/twilio.test.ts +425 -25
  56. package/src/providers/twilio.ts +230 -77
  57. package/src/providers/twilio.types.ts +17 -0
  58. package/src/realtime-defaults.ts +3 -0
  59. package/src/realtime-fast-context.test.ts +88 -0
  60. package/src/realtime-fast-context.ts +165 -0
  61. package/src/realtime-transcription.runtime.ts +4 -0
  62. package/src/realtime-voice.runtime.ts +5 -0
  63. package/src/response-generator.test.ts +277 -0
  64. package/src/response-generator.ts +186 -40
  65. package/src/response-model.test.ts +71 -0
  66. package/src/response-model.ts +23 -0
  67. package/src/runtime.test.ts +351 -0
  68. package/src/runtime.ts +254 -24
  69. package/src/telephony-audio.test.ts +61 -0
  70. package/src/telephony-audio.ts +1 -79
  71. package/src/telephony-tts.test.ts +133 -12
  72. package/src/telephony-tts.ts +155 -2
  73. package/src/test-fixtures.ts +26 -7
  74. package/src/tts-provider-voice.test.ts +34 -0
  75. package/src/tts-provider-voice.ts +21 -0
  76. package/src/tunnel.test.ts +166 -0
  77. package/src/tunnel.ts +1 -1
  78. package/src/types.ts +24 -37
  79. package/src/utils.test.ts +17 -0
  80. package/src/voice-mapping.test.ts +34 -0
  81. package/src/voice-mapping.ts +3 -2
  82. package/src/webhook/realtime-handler.test.ts +598 -0
  83. package/src/webhook/realtime-handler.ts +485 -0
  84. package/src/webhook/stale-call-reaper.test.ts +88 -0
  85. package/src/webhook/stale-call-reaper.ts +5 -0
  86. package/src/webhook/tailscale.test.ts +214 -0
  87. package/src/webhook/tailscale.ts +19 -5
  88. package/src/webhook-exposure.test.ts +33 -0
  89. package/src/webhook-exposure.ts +84 -0
  90. package/src/webhook-security.test.ts +172 -21
  91. package/src/webhook-security.ts +43 -29
  92. package/src/webhook.hangup-once.lifecycle.test.ts +135 -0
  93. package/src/webhook.test.ts +1145 -27
  94. package/src/webhook.ts +513 -100
  95. package/src/webhook.types.ts +5 -0
  96. package/src/websocket-test-support.ts +72 -0
  97. package/tsconfig.json +16 -0
  98. package/CHANGELOG.md +0 -121
  99. package/src/providers/index.ts +0 -10
  100. package/src/providers/stt-openai-realtime.test.ts +0 -42
  101. package/src/providers/stt-openai-realtime.ts +0 -311
  102. package/src/providers/tts-openai.test.ts +0 -43
  103. package/src/providers/tts-openai.ts +0 -221
@@ -1,4 +1,7 @@
1
1
  import crypto from "node:crypto";
2
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3
+ import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
4
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
2
5
  import { getHeader } from "./http-headers.js";
3
6
  import type { WebhookContext } from "./types.js";
4
7
 
@@ -76,7 +79,7 @@ function markReplay(cache: ReplayCache, replayKey: string): boolean {
76
79
  *
77
80
  * @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
78
81
  */
79
- export function validateTwilioSignature(
82
+ function validateTwilioSignature(
80
83
  authToken: string,
81
84
  signature: string | undefined,
82
85
  url: string,
@@ -120,22 +123,13 @@ function buildCanonicalTwilioParamString(params: URLSearchParams): string {
120
123
  * Timing-safe string comparison to prevent timing attacks.
121
124
  */
122
125
  function timingSafeEqual(a: string, b: string): boolean {
123
- if (a.length !== b.length) {
124
- // Still do comparison to maintain constant time
125
- const dummy = Buffer.from(a);
126
- crypto.timingSafeEqual(dummy, dummy);
127
- return false;
128
- }
129
-
130
- const bufA = Buffer.from(a);
131
- const bufB = Buffer.from(b);
132
- return crypto.timingSafeEqual(bufA, bufB);
126
+ return safeEqualSecret(a, b);
133
127
  }
134
128
 
135
129
  /**
136
130
  * Configuration for secure URL reconstruction.
137
131
  */
138
- export interface WebhookUrlOptions {
132
+ interface WebhookUrlOptions {
139
133
  /**
140
134
  * Whitelist of allowed hostnames. If provided, only these hosts will be
141
135
  * accepted from forwarding headers. This prevents host header injection attacks.
@@ -196,8 +190,8 @@ function extractHostname(hostHeader: string): string | null {
196
190
  if (endBracket === -1) {
197
191
  return null; // Malformed IPv6
198
192
  }
199
- hostname = hostHeader.substring(1, endBracket);
200
- return hostname.toLowerCase();
193
+ hostname = hostHeader.slice(1, endBracket);
194
+ return normalizeLowercaseStringOrEmpty(hostname);
201
195
  }
202
196
 
203
197
  // Handle IPv4/domain with optional port
@@ -213,7 +207,7 @@ function extractHostname(hostHeader: string): string | null {
213
207
  return null;
214
208
  }
215
209
 
216
- return hostname.toLowerCase();
210
+ return normalizeLowercaseStringOrEmpty(hostname);
217
211
  }
218
212
 
219
213
  function extractHostnameFromHeader(headerValue: string): string | null {
@@ -417,7 +411,7 @@ function extractPortFromHostHeader(hostHeader?: string): string | undefined {
417
411
  /**
418
412
  * Result of Twilio webhook verification with detailed info.
419
413
  */
420
- export interface TwilioVerificationResult {
414
+ interface TwilioVerificationResult {
421
415
  ok: boolean;
422
416
  reason?: string;
423
417
  /** The URL that was used for verification (for debugging) */
@@ -430,7 +424,7 @@ export interface TwilioVerificationResult {
430
424
  verifiedRequestKey?: string;
431
425
  }
432
426
 
433
- export interface TelnyxVerificationResult {
427
+ interface TelnyxVerificationResult {
434
428
  ok: boolean;
435
429
  reason?: string;
436
430
  /** Request is cryptographically valid but was already processed recently. */
@@ -526,7 +520,7 @@ export function verifyTelnyxWebhook(
526
520
  return { ok: false, reason: "Missing signature or timestamp header" };
527
521
  }
528
522
 
529
- const eventTimeSec = parseInt(timestamp, 10);
523
+ const eventTimeSec = Number.parseInt(timestamp, 10);
530
524
  if (!Number.isFinite(eventTimeSec)) {
531
525
  return { ok: false, reason: "Invalid timestamp header" };
532
526
  }
@@ -534,6 +528,8 @@ export function verifyTelnyxWebhook(
534
528
  try {
535
529
  const signedPayload = `${timestamp}|${ctx.rawBody}`;
536
530
  const signatureBuffer = decodeBase64OrBase64Url(signature);
531
+ // Canonicalize equivalent Base64/Base64URL encodings before replay hashing.
532
+ const canonicalSignature = signatureBuffer.toString("base64");
537
533
  const key = importEd25519PublicKey(publicKey);
538
534
 
539
535
  const isValid = crypto.verify(null, Buffer.from(signedPayload), key, signatureBuffer);
@@ -548,13 +544,13 @@ export function verifyTelnyxWebhook(
548
544
  return { ok: false, reason: "Timestamp too old" };
549
545
  }
550
546
 
551
- const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${signature}\n${ctx.rawBody}`)}`;
547
+ const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${canonicalSignature}\n${ctx.rawBody}`)}`;
552
548
  const isReplay = markReplay(telnyxReplayCache, replayKey);
553
549
  return { ok: true, isReplay, verifiedRequestKey: replayKey };
554
550
  } catch (err) {
555
551
  return {
556
552
  ok: false,
557
- reason: `Verification error: ${err instanceof Error ? err.message : String(err)}`,
553
+ reason: `Verification error: ${formatErrorMessage(err)}`,
558
554
  };
559
555
  }
560
556
  }
@@ -702,7 +698,7 @@ export function verifyTwilioWebhook(
702
698
  /**
703
699
  * Result of Plivo webhook verification with detailed info.
704
700
  */
705
- export interface PlivoVerificationResult {
701
+ interface PlivoVerificationResult {
706
702
  ok: boolean;
707
703
  reason?: string;
708
704
  verificationUrl?: string;
@@ -724,13 +720,26 @@ function getBaseUrlNoQuery(url: string): string {
724
720
  return `${u.protocol}//${u.host}${u.pathname}`;
725
721
  }
726
722
 
723
+ function createPlivoV2ReplayKey(url: string, nonce: string): string {
724
+ return `plivo:v2:${sha256Hex(`${getBaseUrlNoQuery(url)}\n${nonce}`)}`;
725
+ }
726
+
727
+ function createPlivoV3ReplayKey(params: {
728
+ method: "GET" | "POST";
729
+ url: string;
730
+ postParams: PlivoParamMap;
731
+ nonce: string;
732
+ }): string {
733
+ const baseUrl = constructPlivoV3BaseUrl({
734
+ method: params.method,
735
+ url: params.url,
736
+ postParams: params.postParams,
737
+ });
738
+ return `plivo:v3:${sha256Hex(`${baseUrl}\n${params.nonce}`)}`;
739
+ }
740
+
727
741
  function timingSafeEqualString(a: string, b: string): boolean {
728
- if (a.length !== b.length) {
729
- const dummy = Buffer.from(a);
730
- crypto.timingSafeEqual(dummy, dummy);
731
- return false;
732
- }
733
- return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
742
+ return safeEqualSecret(a, b);
734
743
  }
735
744
 
736
745
  function validatePlivoV2Signature(params: {
@@ -947,7 +956,12 @@ export function verifyPlivoWebhook(
947
956
  reason: "Invalid Plivo V3 signature",
948
957
  };
949
958
  }
950
- const replayKey = `plivo:v3:${sha256Hex(`${verificationUrl}\n${nonceV3}`)}`;
959
+ const replayKey = createPlivoV3ReplayKey({
960
+ method,
961
+ url: verificationUrl,
962
+ postParams,
963
+ nonce: nonceV3,
964
+ });
951
965
  const isReplay = markReplay(plivoReplayCache, replayKey);
952
966
  return { ok: true, version: "v3", verificationUrl, isReplay, verifiedRequestKey: replayKey };
953
967
  }
@@ -967,7 +981,7 @@ export function verifyPlivoWebhook(
967
981
  reason: "Invalid Plivo V2 signature",
968
982
  };
969
983
  }
970
- const replayKey = `plivo:v2:${sha256Hex(`${verificationUrl}\n${nonceV2}`)}`;
984
+ const replayKey = createPlivoV2ReplayKey(verificationUrl, nonceV2);
971
985
  const isReplay = markReplay(plivoReplayCache, replayKey);
972
986
  return { ok: true, version: "v2", verificationUrl, isReplay, verifiedRequestKey: replayKey };
973
987
  }
@@ -0,0 +1,135 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { VoiceCallConfigSchema, type VoiceCallConfig } from "./config.js";
3
+ import { CallManager } from "./manager.js";
4
+ import { createTestStorePath, FakeProvider } from "./manager.test-harness.js";
5
+ import type { WebhookContext, WebhookParseOptions } from "./types.js";
6
+ import { VoiceCallWebhookServer } from "./webhook.js";
7
+
8
+ const createConfig = (overrides: Partial<VoiceCallConfig> = {}): VoiceCallConfig => {
9
+ const base = VoiceCallConfigSchema.parse({
10
+ enabled: true,
11
+ provider: "plivo",
12
+ fromNumber: "+15550000000",
13
+ inboundPolicy: "disabled",
14
+ });
15
+ base.serve.port = 0;
16
+
17
+ return {
18
+ ...base,
19
+ ...overrides,
20
+ serve: {
21
+ ...base.serve,
22
+ ...overrides.serve,
23
+ },
24
+ };
25
+ };
26
+
27
+ async function postWebhookForm(server: VoiceCallWebhookServer, baseUrl: string, body: string) {
28
+ const address = (
29
+ server as unknown as { server?: { address?: () => unknown } }
30
+ ).server?.address?.();
31
+ const requestUrl = new URL(baseUrl);
32
+ if (
33
+ !address ||
34
+ typeof address !== "object" ||
35
+ !("port" in address) ||
36
+ (typeof address.port !== "number" && typeof address.port !== "string") ||
37
+ !address.port
38
+ ) {
39
+ throw new Error("voice webhook server did not expose a bound port");
40
+ }
41
+ requestUrl.port = String(address.port);
42
+ return await fetch(requestUrl.toString(), {
43
+ method: "POST",
44
+ headers: {
45
+ "content-type": "application/x-www-form-urlencoded",
46
+ "x-plivo-signature-v2": "sig",
47
+ "x-plivo-signature-v2-nonce": "nonce",
48
+ },
49
+ body,
50
+ });
51
+ }
52
+
53
+ async function runDuplicateInboundReplayLifecycleTest(provider: FakeProvider) {
54
+ const config = createConfig();
55
+ const manager = new CallManager(config, createTestStorePath());
56
+ await manager.initialize(provider, "https://example.com/voice/webhook");
57
+ const server = new VoiceCallWebhookServer(config, manager, provider);
58
+
59
+ try {
60
+ const baseUrl = await server.start();
61
+ const first = await postWebhookForm(server, baseUrl, "CallSid=CA123&From=%2B15552222222");
62
+ const second = await postWebhookForm(server, baseUrl, "CallSid=CA123&From=%2B15552222222");
63
+ return { first, second, manager };
64
+ } finally {
65
+ await server.stop();
66
+ }
67
+ }
68
+
69
+ function expectSingleRejectedReplayHangup(params: {
70
+ first: Response;
71
+ second: Response;
72
+ provider: FakeProvider;
73
+ manager: CallManager;
74
+ }) {
75
+ expect(params.first.status).toBe(200);
76
+ expect(params.second.status).toBe(200);
77
+ expect(params.provider.hangupCalls).toHaveLength(1);
78
+ expect(params.provider.hangupCalls[0]).toEqual(
79
+ expect.objectContaining({
80
+ providerCallId: "provider-inbound-1",
81
+ reason: "hangup-bot",
82
+ }),
83
+ );
84
+ expect(params.manager.getCallByProviderCallId("provider-inbound-1")).toBeUndefined();
85
+ }
86
+
87
+ class RejectInboundReplayProvider extends FakeProvider {
88
+ override verifyWebhook() {
89
+ return { ok: true, verifiedRequestKey: "verified:req:reject-once" };
90
+ }
91
+
92
+ override parseWebhookEvent(_ctx: WebhookContext, options?: WebhookParseOptions) {
93
+ return {
94
+ statusCode: 200,
95
+ events: [
96
+ {
97
+ id: "evt-reject-once",
98
+ dedupeKey: options?.verifiedRequestKey,
99
+ type: "call.initiated" as const,
100
+ callId: "provider-inbound-1",
101
+ providerCallId: "provider-inbound-1",
102
+ timestamp: Date.now(),
103
+ direction: "inbound" as const,
104
+ from: "+15552222222",
105
+ to: "+15550000000",
106
+ },
107
+ ],
108
+ };
109
+ }
110
+ }
111
+
112
+ class RejectInboundReplayWithHangupFailureProvider extends RejectInboundReplayProvider {
113
+ override async hangupCall(input: Parameters<FakeProvider["hangupCall"]>[0]): Promise<void> {
114
+ this.hangupCalls.push(input);
115
+ throw new Error("hangup failed");
116
+ }
117
+ }
118
+
119
+ describe("Voice-call webhook hangup-once lifecycle", () => {
120
+ afterEach(() => {
121
+ // Each test uses an isolated store path, so only server cleanup is needed.
122
+ });
123
+
124
+ it("hangs up a rejected inbound replay only once across duplicate webhook delivery", async () => {
125
+ const provider = new RejectInboundReplayProvider("plivo");
126
+ const { first, second, manager } = await runDuplicateInboundReplayLifecycleTest(provider);
127
+ expectSingleRejectedReplayHangup({ first, second, provider, manager });
128
+ });
129
+
130
+ it("does not attempt a second hangup when replay arrives after the first hangup fails", async () => {
131
+ const provider = new RejectInboundReplayWithHangupFailureProvider("plivo");
132
+ const { first, second, manager } = await runDuplicateInboundReplayLifecycleTest(provider);
133
+ expectSingleRejectedReplayHangup({ first, second, provider, manager });
134
+ });
135
+ });