@openclaw/voice-call 2026.3.13 → 2026.5.2-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.
- package/README.md +27 -5
- package/api.ts +16 -0
- package/cli-metadata.ts +10 -0
- package/config-api.ts +12 -0
- package/index.test.ts +943 -0
- package/index.ts +379 -149
- package/openclaw.plugin.json +384 -157
- package/package.json +35 -5
- package/runtime-api.ts +20 -0
- package/runtime-entry.ts +1 -0
- package/setup-api.ts +47 -0
- package/src/allowlist.test.ts +18 -0
- package/src/cli.ts +533 -68
- package/src/config-compat.test.ts +120 -0
- package/src/config-compat.ts +227 -0
- package/src/config.test.ts +273 -12
- package/src/config.ts +355 -72
- package/src/core-bridge.ts +2 -147
- package/src/deep-merge.test.ts +40 -0
- package/src/gateway-continue-operation.ts +200 -0
- package/src/http-headers.ts +6 -3
- package/src/manager/context.ts +6 -5
- package/src/manager/events.test.ts +243 -19
- package/src/manager/events.ts +61 -31
- package/src/manager/lifecycle.ts +53 -0
- package/src/manager/lookup.test.ts +52 -0
- package/src/manager/outbound.test.ts +528 -0
- package/src/manager/outbound.ts +163 -57
- package/src/manager/store.ts +18 -6
- package/src/manager/timers.test.ts +129 -0
- package/src/manager/timers.ts +4 -3
- package/src/manager/twiml.test.ts +13 -0
- package/src/manager/twiml.ts +8 -0
- package/src/manager.closed-loop.test.ts +30 -12
- package/src/manager.inbound-allowlist.test.ts +77 -10
- package/src/manager.notify.test.ts +344 -20
- package/src/manager.restore.test.ts +95 -8
- package/src/manager.test-harness.ts +8 -6
- package/src/manager.ts +79 -5
- package/src/media-stream.test.ts +578 -81
- package/src/media-stream.ts +235 -54
- package/src/providers/base.ts +19 -0
- package/src/providers/mock.ts +7 -1
- package/src/providers/plivo.test.ts +50 -6
- package/src/providers/plivo.ts +14 -6
- package/src/providers/shared/call-status.ts +2 -1
- package/src/providers/shared/guarded-json-api.test.ts +106 -0
- package/src/providers/shared/guarded-json-api.ts +1 -1
- package/src/providers/telnyx.test.ts +178 -6
- package/src/providers/telnyx.ts +40 -3
- package/src/providers/twilio/api.test.ts +145 -0
- package/src/providers/twilio/api.ts +67 -16
- package/src/providers/twilio/twiml-policy.ts +6 -10
- package/src/providers/twilio/webhook.ts +1 -1
- package/src/providers/twilio.test.ts +425 -25
- package/src/providers/twilio.ts +230 -77
- package/src/providers/twilio.types.ts +17 -0
- package/src/realtime-defaults.ts +3 -0
- package/src/realtime-fast-context.test.ts +88 -0
- package/src/realtime-fast-context.ts +165 -0
- package/src/realtime-transcription.runtime.ts +4 -0
- package/src/realtime-voice.runtime.ts +5 -0
- package/src/response-generator.test.ts +321 -0
- package/src/response-generator.ts +213 -53
- package/src/response-model.test.ts +71 -0
- package/src/response-model.ts +23 -0
- package/src/runtime.test.ts +429 -0
- package/src/runtime.ts +270 -24
- package/src/telephony-audio.test.ts +61 -0
- package/src/telephony-audio.ts +1 -79
- package/src/telephony-tts.test.ts +133 -12
- package/src/telephony-tts.ts +155 -2
- package/src/test-fixtures.ts +28 -7
- package/src/tts-provider-voice.test.ts +34 -0
- package/src/tts-provider-voice.ts +21 -0
- package/src/tunnel.test.ts +166 -0
- package/src/tunnel.ts +1 -1
- package/src/types.ts +24 -37
- package/src/utils.test.ts +17 -0
- package/src/voice-mapping.test.ts +34 -0
- package/src/voice-mapping.ts +3 -2
- package/src/webhook/realtime-handler.test.ts +598 -0
- package/src/webhook/realtime-handler.ts +485 -0
- package/src/webhook/stale-call-reaper.test.ts +88 -0
- package/src/webhook/stale-call-reaper.ts +5 -0
- package/src/webhook/tailscale.test.ts +214 -0
- package/src/webhook/tailscale.ts +19 -5
- package/src/webhook-exposure.test.ts +33 -0
- package/src/webhook-exposure.ts +84 -0
- package/src/webhook-security.test.ts +172 -21
- package/src/webhook-security.ts +43 -29
- package/src/webhook.hangup-once.lifecycle.test.ts +135 -0
- package/src/webhook.test.ts +1145 -27
- package/src/webhook.ts +523 -102
- package/src/webhook.types.ts +5 -0
- package/src/websocket-test-support.ts +72 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -121
- package/src/providers/index.ts +0 -10
- package/src/providers/stt-openai-realtime.test.ts +0 -42
- package/src/providers/stt-openai-realtime.ts +0 -311
- package/src/providers/tts-openai.test.ts +0 -43
- package/src/providers/tts-openai.ts +0 -221
package/src/webhook-security.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
200
|
-
return hostname
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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${
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
+
});
|