@openclaw/voice-call 2026.2.12 → 2026.2.14
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 +12 -0
- package/README.md +9 -0
- package/package.json +1 -1
- package/src/config.test.ts +15 -2
- package/src/config.ts +6 -7
- package/src/manager/context.ts +19 -1
- package/src/manager/events.test.ts +240 -0
- package/src/manager/events.ts +49 -8
- package/src/manager/outbound.ts +36 -5
- package/src/manager/store.ts +4 -1
- package/src/manager/timers.ts +19 -6
- package/src/manager.test.ts +40 -0
- package/src/manager.ts +48 -728
- package/src/providers/telnyx.test.ts +121 -0
- package/src/providers/telnyx.ts +7 -60
- package/src/runtime.ts +7 -2
- package/src/webhook-security.test.ts +35 -3
- package/src/webhook-security.ts +112 -13
- package/src/webhook.ts +12 -37
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { WebhookContext } from "../types.js";
|
|
4
|
+
import { TelnyxProvider } from "./telnyx.js";
|
|
5
|
+
|
|
6
|
+
function createCtx(params?: Partial<WebhookContext>): WebhookContext {
|
|
7
|
+
return {
|
|
8
|
+
headers: {},
|
|
9
|
+
rawBody: "{}",
|
|
10
|
+
url: "http://localhost/voice/webhook",
|
|
11
|
+
method: "POST",
|
|
12
|
+
query: {},
|
|
13
|
+
remoteAddress: "127.0.0.1",
|
|
14
|
+
...params,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function decodeBase64Url(input: string): Buffer {
|
|
19
|
+
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
20
|
+
const padLen = (4 - (normalized.length % 4)) % 4;
|
|
21
|
+
const padded = normalized + "=".repeat(padLen);
|
|
22
|
+
return Buffer.from(padded, "base64");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("TelnyxProvider.verifyWebhook", () => {
|
|
26
|
+
it("fails closed when public key is missing and skipVerification is false", () => {
|
|
27
|
+
const provider = new TelnyxProvider(
|
|
28
|
+
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: undefined },
|
|
29
|
+
{ skipVerification: false },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const result = provider.verifyWebhook(createCtx());
|
|
33
|
+
expect(result.ok).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("allows requests when skipVerification is true (development only)", () => {
|
|
37
|
+
const provider = new TelnyxProvider(
|
|
38
|
+
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: undefined },
|
|
39
|
+
{ skipVerification: true },
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const result = provider.verifyWebhook(createCtx());
|
|
43
|
+
expect(result.ok).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("fails when signature headers are missing (with public key configured)", () => {
|
|
47
|
+
const provider = new TelnyxProvider(
|
|
48
|
+
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: "public-key" },
|
|
49
|
+
{ skipVerification: false },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const result = provider.verifyWebhook(createCtx({ headers: {} }));
|
|
53
|
+
expect(result.ok).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("verifies a valid signature with a raw Ed25519 public key (Base64)", () => {
|
|
57
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
58
|
+
|
|
59
|
+
const jwk = publicKey.export({ format: "jwk" }) as JsonWebKey;
|
|
60
|
+
expect(jwk.kty).toBe("OKP");
|
|
61
|
+
expect(jwk.crv).toBe("Ed25519");
|
|
62
|
+
expect(typeof jwk.x).toBe("string");
|
|
63
|
+
|
|
64
|
+
const rawPublicKey = decodeBase64Url(jwk.x as string);
|
|
65
|
+
const rawPublicKeyBase64 = rawPublicKey.toString("base64");
|
|
66
|
+
|
|
67
|
+
const provider = new TelnyxProvider(
|
|
68
|
+
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: rawPublicKeyBase64 },
|
|
69
|
+
{ skipVerification: false },
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const rawBody = JSON.stringify({
|
|
73
|
+
event_type: "call.initiated",
|
|
74
|
+
payload: { call_control_id: "x" },
|
|
75
|
+
});
|
|
76
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
77
|
+
const signedPayload = `${timestamp}|${rawBody}`;
|
|
78
|
+
const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
|
|
79
|
+
|
|
80
|
+
const result = provider.verifyWebhook(
|
|
81
|
+
createCtx({
|
|
82
|
+
rawBody,
|
|
83
|
+
headers: {
|
|
84
|
+
"telnyx-signature-ed25519": signature,
|
|
85
|
+
"telnyx-timestamp": timestamp,
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
expect(result.ok).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("verifies a valid signature with a DER SPKI public key (Base64)", () => {
|
|
93
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
94
|
+
const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer;
|
|
95
|
+
const spkiDerBase64 = spkiDer.toString("base64");
|
|
96
|
+
|
|
97
|
+
const provider = new TelnyxProvider(
|
|
98
|
+
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDerBase64 },
|
|
99
|
+
{ skipVerification: false },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const rawBody = JSON.stringify({
|
|
103
|
+
event_type: "call.initiated",
|
|
104
|
+
payload: { call_control_id: "x" },
|
|
105
|
+
});
|
|
106
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
107
|
+
const signedPayload = `${timestamp}|${rawBody}`;
|
|
108
|
+
const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
|
|
109
|
+
|
|
110
|
+
const result = provider.verifyWebhook(
|
|
111
|
+
createCtx({
|
|
112
|
+
rawBody,
|
|
113
|
+
headers: {
|
|
114
|
+
"telnyx-signature-ed25519": signature,
|
|
115
|
+
"telnyx-timestamp": timestamp,
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
expect(result.ok).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
});
|
package/src/providers/telnyx.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
WebhookVerificationResult,
|
|
15
15
|
} from "../types.js";
|
|
16
16
|
import type { VoiceCallProvider } from "./base.js";
|
|
17
|
+
import { verifyTelnyxWebhook } from "../webhook-security.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Telnyx Voice API provider implementation.
|
|
@@ -22,8 +23,8 @@ import type { VoiceCallProvider } from "./base.js";
|
|
|
22
23
|
* @see https://developers.telnyx.com/docs/api/v2/call-control
|
|
23
24
|
*/
|
|
24
25
|
export interface TelnyxProviderOptions {
|
|
25
|
-
/**
|
|
26
|
-
|
|
26
|
+
/** Skip webhook signature verification (development only, NOT for production) */
|
|
27
|
+
skipVerification?: boolean;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export class TelnyxProvider implements VoiceCallProvider {
|
|
@@ -82,65 +83,11 @@ export class TelnyxProvider implements VoiceCallProvider {
|
|
|
82
83
|
* Verify Telnyx webhook signature using Ed25519.
|
|
83
84
|
*/
|
|
84
85
|
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return { ok: true, reason: "verification skipped (no public key configured)" };
|
|
89
|
-
}
|
|
90
|
-
return {
|
|
91
|
-
ok: false,
|
|
92
|
-
reason: "Missing telnyx.publicKey (configure to verify webhooks)",
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const signature = ctx.headers["telnyx-signature-ed25519"];
|
|
97
|
-
const timestamp = ctx.headers["telnyx-timestamp"];
|
|
98
|
-
|
|
99
|
-
if (!signature || !timestamp) {
|
|
100
|
-
return { ok: false, reason: "Missing signature or timestamp header" };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const signatureStr = Array.isArray(signature) ? signature[0] : signature;
|
|
104
|
-
const timestampStr = Array.isArray(timestamp) ? timestamp[0] : timestamp;
|
|
105
|
-
|
|
106
|
-
if (!signatureStr || !timestampStr) {
|
|
107
|
-
return { ok: false, reason: "Empty signature or timestamp" };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
const signedPayload = `${timestampStr}|${ctx.rawBody}`;
|
|
112
|
-
const signatureBuffer = Buffer.from(signatureStr, "base64");
|
|
113
|
-
const publicKeyBuffer = Buffer.from(this.publicKey, "base64");
|
|
114
|
-
|
|
115
|
-
const isValid = crypto.verify(
|
|
116
|
-
null, // Ed25519 doesn't use a digest
|
|
117
|
-
Buffer.from(signedPayload),
|
|
118
|
-
{
|
|
119
|
-
key: publicKeyBuffer,
|
|
120
|
-
format: "der",
|
|
121
|
-
type: "spki",
|
|
122
|
-
},
|
|
123
|
-
signatureBuffer,
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
if (!isValid) {
|
|
127
|
-
return { ok: false, reason: "Invalid signature" };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Check timestamp is within 5 minutes
|
|
131
|
-
const eventTime = parseInt(timestampStr, 10) * 1000;
|
|
132
|
-
const now = Date.now();
|
|
133
|
-
if (Math.abs(now - eventTime) > 5 * 60 * 1000) {
|
|
134
|
-
return { ok: false, reason: "Timestamp too old" };
|
|
135
|
-
}
|
|
86
|
+
const result = verifyTelnyxWebhook(ctx, this.publicKey, {
|
|
87
|
+
skipVerification: this.options.skipVerification,
|
|
88
|
+
});
|
|
136
89
|
|
|
137
|
-
|
|
138
|
-
} catch (err) {
|
|
139
|
-
return {
|
|
140
|
-
ok: false,
|
|
141
|
-
reason: `Verification error: ${err instanceof Error ? err.message : String(err)}`,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
90
|
+
return { ok: result.ok, reason: result.reason };
|
|
144
91
|
}
|
|
145
92
|
|
|
146
93
|
/**
|
package/src/runtime.ts
CHANGED
|
@@ -55,8 +55,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
|
|
|
55
55
|
publicKey: config.telnyx?.publicKey,
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
|
-
|
|
59
|
-
config.inboundPolicy === "open" || config.inboundPolicy === "disabled",
|
|
58
|
+
skipVerification: config.skipSignatureVerification,
|
|
60
59
|
},
|
|
61
60
|
);
|
|
62
61
|
case "twilio":
|
|
@@ -113,6 +112,12 @@ export async function createVoiceCallRuntime(params: {
|
|
|
113
112
|
throw new Error("Voice call disabled. Enable the plugin entry in config.");
|
|
114
113
|
}
|
|
115
114
|
|
|
115
|
+
if (config.skipSignatureVerification) {
|
|
116
|
+
log.warn(
|
|
117
|
+
"[voice-call] SECURITY WARNING: skipSignatureVerification=true disables webhook signature verification (development only). Do not use in production.",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
116
121
|
const validation = validateProviderConfig(config);
|
|
117
122
|
if (!validation.valid) {
|
|
118
123
|
throw new Error(`Invalid voice-call config: ${validation.errors.join("; ")}`);
|
|
@@ -222,9 +222,16 @@ describe("verifyTwilioWebhook", () => {
|
|
|
222
222
|
expect(result.reason).toMatch(/Invalid signature/);
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
-
it("
|
|
225
|
+
it("accepts valid signatures for ngrok free tier on loopback when compatibility mode is enabled", () => {
|
|
226
226
|
const authToken = "test-auth-token";
|
|
227
227
|
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
|
228
|
+
const webhookUrl = "https://local.ngrok-free.app/voice/webhook";
|
|
229
|
+
|
|
230
|
+
const signature = twilioSignature({
|
|
231
|
+
authToken,
|
|
232
|
+
url: webhookUrl,
|
|
233
|
+
postBody,
|
|
234
|
+
});
|
|
228
235
|
|
|
229
236
|
const result = verifyTwilioWebhook(
|
|
230
237
|
{
|
|
@@ -232,7 +239,7 @@ describe("verifyTwilioWebhook", () => {
|
|
|
232
239
|
host: "127.0.0.1:3334",
|
|
233
240
|
"x-forwarded-proto": "https",
|
|
234
241
|
"x-forwarded-host": "local.ngrok-free.app",
|
|
235
|
-
"x-twilio-signature":
|
|
242
|
+
"x-twilio-signature": signature,
|
|
236
243
|
},
|
|
237
244
|
rawBody: postBody,
|
|
238
245
|
url: "http://127.0.0.1:3334/voice/webhook",
|
|
@@ -244,8 +251,33 @@ describe("verifyTwilioWebhook", () => {
|
|
|
244
251
|
);
|
|
245
252
|
|
|
246
253
|
expect(result.ok).toBe(true);
|
|
254
|
+
expect(result.verificationUrl).toBe(webhookUrl);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("does not allow invalid signatures for ngrok free tier on loopback", () => {
|
|
258
|
+
const authToken = "test-auth-token";
|
|
259
|
+
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
|
260
|
+
|
|
261
|
+
const result = verifyTwilioWebhook(
|
|
262
|
+
{
|
|
263
|
+
headers: {
|
|
264
|
+
host: "127.0.0.1:3334",
|
|
265
|
+
"x-forwarded-proto": "https",
|
|
266
|
+
"x-forwarded-host": "local.ngrok-free.app",
|
|
267
|
+
"x-twilio-signature": "invalid",
|
|
268
|
+
},
|
|
269
|
+
rawBody: postBody,
|
|
270
|
+
url: "http://127.0.0.1:3334/voice/webhook",
|
|
271
|
+
method: "POST",
|
|
272
|
+
remoteAddress: "127.0.0.1",
|
|
273
|
+
},
|
|
274
|
+
authToken,
|
|
275
|
+
{ allowNgrokFreeTierLoopbackBypass: true },
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
expect(result.ok).toBe(false);
|
|
279
|
+
expect(result.reason).toMatch(/Invalid signature/);
|
|
247
280
|
expect(result.isNgrokFreeTier).toBe(true);
|
|
248
|
-
expect(result.reason).toMatch(/compatibility mode/);
|
|
249
281
|
});
|
|
250
282
|
|
|
251
283
|
it("ignores attacker X-Forwarded-Host without allowedHosts or trustForwardingHeaders", () => {
|
package/src/webhook-security.ts
CHANGED
|
@@ -330,6 +330,111 @@ export interface TwilioVerificationResult {
|
|
|
330
330
|
isNgrokFreeTier?: boolean;
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
export interface TelnyxVerificationResult {
|
|
334
|
+
ok: boolean;
|
|
335
|
+
reason?: string;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function decodeBase64OrBase64Url(input: string): Buffer {
|
|
339
|
+
// Telnyx docs say Base64; some tooling emits Base64URL. Accept both.
|
|
340
|
+
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
341
|
+
const padLen = (4 - (normalized.length % 4)) % 4;
|
|
342
|
+
const padded = normalized + "=".repeat(padLen);
|
|
343
|
+
return Buffer.from(padded, "base64");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function base64UrlEncode(buf: Buffer): string {
|
|
347
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function importEd25519PublicKey(publicKey: string): crypto.KeyObject | string {
|
|
351
|
+
const trimmed = publicKey.trim();
|
|
352
|
+
|
|
353
|
+
// PEM (spki) support.
|
|
354
|
+
if (trimmed.startsWith("-----BEGIN")) {
|
|
355
|
+
return trimmed;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Base64-encoded raw Ed25519 key (32 bytes) or Base64-encoded DER SPKI key.
|
|
359
|
+
const decoded = decodeBase64OrBase64Url(trimmed);
|
|
360
|
+
if (decoded.length === 32) {
|
|
361
|
+
// JWK is the easiest portable way to import raw Ed25519 keys in Node crypto.
|
|
362
|
+
return crypto.createPublicKey({
|
|
363
|
+
key: { kty: "OKP", crv: "Ed25519", x: base64UrlEncode(decoded) },
|
|
364
|
+
format: "jwk",
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return crypto.createPublicKey({
|
|
369
|
+
key: decoded,
|
|
370
|
+
format: "der",
|
|
371
|
+
type: "spki",
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Verify Telnyx webhook signature using Ed25519.
|
|
377
|
+
*
|
|
378
|
+
* Telnyx signs `timestamp|payload` and provides:
|
|
379
|
+
* - `telnyx-signature-ed25519` (Base64 signature)
|
|
380
|
+
* - `telnyx-timestamp` (Unix seconds)
|
|
381
|
+
*/
|
|
382
|
+
export function verifyTelnyxWebhook(
|
|
383
|
+
ctx: WebhookContext,
|
|
384
|
+
publicKey: string | undefined,
|
|
385
|
+
options?: {
|
|
386
|
+
/** Skip verification entirely (only for development) */
|
|
387
|
+
skipVerification?: boolean;
|
|
388
|
+
/** Maximum allowed clock skew (ms). Defaults to 5 minutes. */
|
|
389
|
+
maxSkewMs?: number;
|
|
390
|
+
},
|
|
391
|
+
): TelnyxVerificationResult {
|
|
392
|
+
if (options?.skipVerification) {
|
|
393
|
+
return { ok: true, reason: "verification skipped (dev mode)" };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!publicKey) {
|
|
397
|
+
return { ok: false, reason: "Missing telnyx.publicKey (configure to verify webhooks)" };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const signature = getHeader(ctx.headers, "telnyx-signature-ed25519");
|
|
401
|
+
const timestamp = getHeader(ctx.headers, "telnyx-timestamp");
|
|
402
|
+
|
|
403
|
+
if (!signature || !timestamp) {
|
|
404
|
+
return { ok: false, reason: "Missing signature or timestamp header" };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const eventTimeSec = parseInt(timestamp, 10);
|
|
408
|
+
if (!Number.isFinite(eventTimeSec)) {
|
|
409
|
+
return { ok: false, reason: "Invalid timestamp header" };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const signedPayload = `${timestamp}|${ctx.rawBody}`;
|
|
414
|
+
const signatureBuffer = decodeBase64OrBase64Url(signature);
|
|
415
|
+
const key = importEd25519PublicKey(publicKey);
|
|
416
|
+
|
|
417
|
+
const isValid = crypto.verify(null, Buffer.from(signedPayload), key, signatureBuffer);
|
|
418
|
+
if (!isValid) {
|
|
419
|
+
return { ok: false, reason: "Invalid signature" };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const maxSkewMs = options?.maxSkewMs ?? 5 * 60 * 1000;
|
|
423
|
+
const eventTimeMs = eventTimeSec * 1000;
|
|
424
|
+
const now = Date.now();
|
|
425
|
+
if (Math.abs(now - eventTimeMs) > maxSkewMs) {
|
|
426
|
+
return { ok: false, reason: "Timestamp too old" };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return { ok: true };
|
|
430
|
+
} catch (err) {
|
|
431
|
+
return {
|
|
432
|
+
ok: false,
|
|
433
|
+
reason: `Verification error: ${err instanceof Error ? err.message : String(err)}`,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
333
438
|
/**
|
|
334
439
|
* Verify Twilio webhook with full context and detailed result.
|
|
335
440
|
*/
|
|
@@ -339,7 +444,13 @@ export function verifyTwilioWebhook(
|
|
|
339
444
|
options?: {
|
|
340
445
|
/** Override the public URL (e.g., from config) */
|
|
341
446
|
publicUrl?: string;
|
|
342
|
-
/**
|
|
447
|
+
/**
|
|
448
|
+
* Allow ngrok free tier compatibility mode (loopback only).
|
|
449
|
+
*
|
|
450
|
+
* IMPORTANT: This does NOT bypass signature verification.
|
|
451
|
+
* It only enables trusting forwarded headers on loopback so we can
|
|
452
|
+
* reconstruct the public ngrok URL that Twilio used for signing.
|
|
453
|
+
*/
|
|
343
454
|
allowNgrokFreeTierLoopbackBypass?: boolean;
|
|
344
455
|
/** Skip verification entirely (only for development) */
|
|
345
456
|
skipVerification?: boolean;
|
|
@@ -401,18 +512,6 @@ export function verifyTwilioWebhook(
|
|
|
401
512
|
const isNgrokFreeTier =
|
|
402
513
|
verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
|
|
403
514
|
|
|
404
|
-
if (isNgrokFreeTier && options?.allowNgrokFreeTierLoopbackBypass && isLoopback) {
|
|
405
|
-
console.warn(
|
|
406
|
-
"[voice-call] Twilio signature validation failed (ngrok free tier compatibility, loopback only)",
|
|
407
|
-
);
|
|
408
|
-
return {
|
|
409
|
-
ok: true,
|
|
410
|
-
reason: "ngrok free tier compatibility mode (loopback only)",
|
|
411
|
-
verificationUrl,
|
|
412
|
-
isNgrokFreeTier: true,
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
|
|
416
515
|
return {
|
|
417
516
|
ok: false,
|
|
418
517
|
reason: `Invalid signature for URL: ${verificationUrl}`,
|
package/src/webhook.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import http from "node:http";
|
|
3
3
|
import { URL } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
isRequestBodyLimitError,
|
|
6
|
+
readRequestBodyWithLimit,
|
|
7
|
+
requestBodyErrorToText,
|
|
8
|
+
} from "openclaw/plugin-sdk";
|
|
4
9
|
import type { VoiceCallConfig } from "./config.js";
|
|
5
10
|
import type { CoreConfig } from "./core-bridge.js";
|
|
6
11
|
import type { CallManager } from "./manager.js";
|
|
@@ -244,11 +249,16 @@ export class VoiceCallWebhookServer {
|
|
|
244
249
|
try {
|
|
245
250
|
body = await this.readBody(req, MAX_WEBHOOK_BODY_BYTES);
|
|
246
251
|
} catch (err) {
|
|
247
|
-
if (err
|
|
252
|
+
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
|
|
248
253
|
res.statusCode = 413;
|
|
249
254
|
res.end("Payload Too Large");
|
|
250
255
|
return;
|
|
251
256
|
}
|
|
257
|
+
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
|
|
258
|
+
res.statusCode = 408;
|
|
259
|
+
res.end(requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
252
262
|
throw err;
|
|
253
263
|
}
|
|
254
264
|
|
|
@@ -303,42 +313,7 @@ export class VoiceCallWebhookServer {
|
|
|
303
313
|
maxBytes: number,
|
|
304
314
|
timeoutMs = 30_000,
|
|
305
315
|
): Promise<string> {
|
|
306
|
-
return
|
|
307
|
-
let done = false;
|
|
308
|
-
const finish = (fn: () => void) => {
|
|
309
|
-
if (done) {
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
done = true;
|
|
313
|
-
clearTimeout(timer);
|
|
314
|
-
fn();
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
const timer = setTimeout(() => {
|
|
318
|
-
finish(() => {
|
|
319
|
-
const err = new Error("Request body timeout");
|
|
320
|
-
req.destroy(err);
|
|
321
|
-
reject(err);
|
|
322
|
-
});
|
|
323
|
-
}, timeoutMs);
|
|
324
|
-
|
|
325
|
-
const chunks: Buffer[] = [];
|
|
326
|
-
let totalBytes = 0;
|
|
327
|
-
req.on("data", (chunk: Buffer) => {
|
|
328
|
-
totalBytes += chunk.length;
|
|
329
|
-
if (totalBytes > maxBytes) {
|
|
330
|
-
finish(() => {
|
|
331
|
-
req.destroy();
|
|
332
|
-
reject(new Error("PayloadTooLarge"));
|
|
333
|
-
});
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
chunks.push(chunk);
|
|
337
|
-
});
|
|
338
|
-
req.on("end", () => finish(() => resolve(Buffer.concat(chunks).toString("utf-8"))));
|
|
339
|
-
req.on("error", (err) => finish(() => reject(err)));
|
|
340
|
-
req.on("close", () => finish(() => reject(new Error("Connection closed"))));
|
|
341
|
-
});
|
|
316
|
+
return readRequestBodyWithLimit(req, { maxBytes, timeoutMs });
|
|
342
317
|
}
|
|
343
318
|
|
|
344
319
|
/**
|