@clawdbot/voice-call 0.1.0 → 2026.1.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/src/runtime.ts CHANGED
@@ -4,6 +4,7 @@ import { validateProviderConfig } from "./config.js";
4
4
  import { CallManager } from "./manager.js";
5
5
  import type { VoiceCallProvider } from "./providers/base.js";
6
6
  import { MockProvider } from "./providers/mock.js";
7
+ import { PlivoProvider } from "./providers/plivo.js";
7
8
  import { TelnyxProvider } from "./providers/telnyx.js";
8
9
  import { OpenAITTSProvider } from "./providers/tts-openai.js";
9
10
  import { TwilioProvider } from "./providers/twilio.js";
@@ -56,6 +57,18 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
56
57
  : undefined,
57
58
  },
58
59
  );
60
+ case "plivo":
61
+ return new PlivoProvider(
62
+ {
63
+ authId: config.plivo?.authId ?? process.env.PLIVO_AUTH_ID,
64
+ authToken: config.plivo?.authToken ?? process.env.PLIVO_AUTH_TOKEN,
65
+ },
66
+ {
67
+ publicUrl: config.publicUrl,
68
+ skipVerification: config.skipSignatureVerification,
69
+ ringTimeoutSec: Math.max(1, Math.floor(config.ringTimeoutMs / 1000)),
70
+ },
71
+ );
59
72
  case "mock":
60
73
  return new MockProvider();
61
74
  default:
package/src/types.ts CHANGED
@@ -6,7 +6,7 @@ import type { CallMode } from "./config.js";
6
6
  // Provider Identifiers
7
7
  // -----------------------------------------------------------------------------
8
8
 
9
- export const ProviderNameSchema = z.enum(["telnyx", "twilio", "mock"]);
9
+ export const ProviderNameSchema = z.enum(["telnyx", "twilio", "plivo", "mock"]);
10
10
  export type ProviderName = z.infer<typeof ProviderNameSchema>;
11
11
 
12
12
  // -----------------------------------------------------------------------------
@@ -0,0 +1,156 @@
1
+ import crypto from "node:crypto";
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { verifyPlivoWebhook } from "./webhook-security.js";
6
+
7
+ function canonicalizeBase64(input: string): string {
8
+ return Buffer.from(input, "base64").toString("base64");
9
+ }
10
+
11
+ function plivoV2Signature(params: {
12
+ authToken: string;
13
+ urlNoQuery: string;
14
+ nonce: string;
15
+ }): string {
16
+ const digest = crypto
17
+ .createHmac("sha256", params.authToken)
18
+ .update(params.urlNoQuery + params.nonce)
19
+ .digest("base64");
20
+ return canonicalizeBase64(digest);
21
+ }
22
+
23
+ function plivoV3Signature(params: {
24
+ authToken: string;
25
+ urlWithQuery: string;
26
+ postBody: string;
27
+ nonce: string;
28
+ }): string {
29
+ const u = new URL(params.urlWithQuery);
30
+ const baseNoQuery = `${u.protocol}//${u.host}${u.pathname}`;
31
+ const queryPairs: Array<[string, string]> = [];
32
+ for (const [k, v] of u.searchParams.entries()) queryPairs.push([k, v]);
33
+
34
+ const queryMap = new Map<string, string[]>();
35
+ for (const [k, v] of queryPairs) {
36
+ queryMap.set(k, (queryMap.get(k) ?? []).concat(v));
37
+ }
38
+
39
+ const sortedQuery = Array.from(queryMap.keys())
40
+ .sort()
41
+ .flatMap((k) =>
42
+ [...(queryMap.get(k) ?? [])].sort().map((v) => `${k}=${v}`),
43
+ )
44
+ .join("&");
45
+
46
+ const postParams = new URLSearchParams(params.postBody);
47
+ const postMap = new Map<string, string[]>();
48
+ for (const [k, v] of postParams.entries()) {
49
+ postMap.set(k, (postMap.get(k) ?? []).concat(v));
50
+ }
51
+
52
+ const sortedPost = Array.from(postMap.keys())
53
+ .sort()
54
+ .flatMap((k) => [...(postMap.get(k) ?? [])].sort().map((v) => `${k}${v}`))
55
+ .join("");
56
+
57
+ const hasPost = sortedPost.length > 0;
58
+ let baseUrl = baseNoQuery;
59
+ if (sortedQuery.length > 0 || hasPost) {
60
+ baseUrl = `${baseNoQuery}?${sortedQuery}`;
61
+ }
62
+ if (sortedQuery.length > 0 && hasPost) {
63
+ baseUrl = `${baseUrl}.`;
64
+ }
65
+ baseUrl = `${baseUrl}${sortedPost}`;
66
+
67
+ const digest = crypto
68
+ .createHmac("sha256", params.authToken)
69
+ .update(`${baseUrl}.${params.nonce}`)
70
+ .digest("base64");
71
+ return canonicalizeBase64(digest);
72
+ }
73
+
74
+ describe("verifyPlivoWebhook", () => {
75
+ it("accepts valid V2 signature", () => {
76
+ const authToken = "test-auth-token";
77
+ const nonce = "nonce-123";
78
+
79
+ const ctxUrl = "http://local/voice/webhook?flow=answer&callId=abc";
80
+ const verificationUrl = "https://example.com/voice/webhook";
81
+ const signature = plivoV2Signature({
82
+ authToken,
83
+ urlNoQuery: verificationUrl,
84
+ nonce,
85
+ });
86
+
87
+ const result = verifyPlivoWebhook(
88
+ {
89
+ headers: {
90
+ host: "example.com",
91
+ "x-forwarded-proto": "https",
92
+ "x-plivo-signature-v2": signature,
93
+ "x-plivo-signature-v2-nonce": nonce,
94
+ },
95
+ rawBody: "CallUUID=uuid&CallStatus=in-progress",
96
+ url: ctxUrl,
97
+ method: "POST",
98
+ query: { flow: "answer", callId: "abc" },
99
+ },
100
+ authToken,
101
+ );
102
+
103
+ expect(result.ok).toBe(true);
104
+ expect(result.version).toBe("v2");
105
+ });
106
+
107
+ it("accepts valid V3 signature (including multi-signature header)", () => {
108
+ const authToken = "test-auth-token";
109
+ const nonce = "nonce-456";
110
+
111
+ const urlWithQuery = "https://example.com/voice/webhook?flow=answer&callId=abc";
112
+ const postBody = "CallUUID=uuid&CallStatus=in-progress&From=%2B15550000000";
113
+
114
+ const good = plivoV3Signature({
115
+ authToken,
116
+ urlWithQuery,
117
+ postBody,
118
+ nonce,
119
+ });
120
+
121
+ const result = verifyPlivoWebhook(
122
+ {
123
+ headers: {
124
+ host: "example.com",
125
+ "x-forwarded-proto": "https",
126
+ "x-plivo-signature-v3": `bad, ${good}`,
127
+ "x-plivo-signature-v3-nonce": nonce,
128
+ },
129
+ rawBody: postBody,
130
+ url: urlWithQuery,
131
+ method: "POST",
132
+ query: { flow: "answer", callId: "abc" },
133
+ },
134
+ authToken,
135
+ );
136
+
137
+ expect(result.ok).toBe(true);
138
+ expect(result.version).toBe("v3");
139
+ });
140
+
141
+ it("rejects missing signatures", () => {
142
+ const result = verifyPlivoWebhook(
143
+ {
144
+ headers: { host: "example.com", "x-forwarded-proto": "https" },
145
+ rawBody: "",
146
+ url: "https://example.com/voice/webhook",
147
+ method: "POST",
148
+ },
149
+ "token",
150
+ );
151
+
152
+ expect(result.ok).toBe(false);
153
+ expect(result.reason).toMatch(/Missing Plivo signature headers/);
154
+ });
155
+ });
156
+
@@ -195,3 +195,245 @@ export function verifyTwilioWebhook(
195
195
  isNgrokFreeTier,
196
196
  };
197
197
  }
198
+
199
+ // -----------------------------------------------------------------------------
200
+ // Plivo webhook verification
201
+ // -----------------------------------------------------------------------------
202
+
203
+ /**
204
+ * Result of Plivo webhook verification with detailed info.
205
+ */
206
+ export interface PlivoVerificationResult {
207
+ ok: boolean;
208
+ reason?: string;
209
+ verificationUrl?: string;
210
+ /** Signature version used for verification */
211
+ version?: "v3" | "v2";
212
+ }
213
+
214
+ function normalizeSignatureBase64(input: string): string {
215
+ // Canonicalize base64 to match Plivo SDK behavior (decode then re-encode).
216
+ return Buffer.from(input, "base64").toString("base64");
217
+ }
218
+
219
+ function getBaseUrlNoQuery(url: string): string {
220
+ const u = new URL(url);
221
+ return `${u.protocol}//${u.host}${u.pathname}`;
222
+ }
223
+
224
+ function timingSafeEqualString(a: string, b: string): boolean {
225
+ if (a.length !== b.length) {
226
+ const dummy = Buffer.from(a);
227
+ crypto.timingSafeEqual(dummy, dummy);
228
+ return false;
229
+ }
230
+ return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
231
+ }
232
+
233
+ function validatePlivoV2Signature(params: {
234
+ authToken: string;
235
+ signature: string;
236
+ nonce: string;
237
+ url: string;
238
+ }): boolean {
239
+ const baseUrl = getBaseUrlNoQuery(params.url);
240
+ const digest = crypto
241
+ .createHmac("sha256", params.authToken)
242
+ .update(baseUrl + params.nonce)
243
+ .digest("base64");
244
+ const expected = normalizeSignatureBase64(digest);
245
+ const provided = normalizeSignatureBase64(params.signature);
246
+ return timingSafeEqualString(expected, provided);
247
+ }
248
+
249
+ type PlivoParamMap = Record<string, string[]>;
250
+
251
+ function toParamMapFromSearchParams(sp: URLSearchParams): PlivoParamMap {
252
+ const map: PlivoParamMap = {};
253
+ for (const [key, value] of sp.entries()) {
254
+ if (!map[key]) map[key] = [];
255
+ map[key].push(value);
256
+ }
257
+ return map;
258
+ }
259
+
260
+ function sortedQueryString(params: PlivoParamMap): string {
261
+ const parts: string[] = [];
262
+ for (const key of Object.keys(params).sort()) {
263
+ const values = [...params[key]].sort();
264
+ for (const value of values) {
265
+ parts.push(`${key}=${value}`);
266
+ }
267
+ }
268
+ return parts.join("&");
269
+ }
270
+
271
+ function sortedParamsString(params: PlivoParamMap): string {
272
+ const parts: string[] = [];
273
+ for (const key of Object.keys(params).sort()) {
274
+ const values = [...params[key]].sort();
275
+ for (const value of values) {
276
+ parts.push(`${key}${value}`);
277
+ }
278
+ }
279
+ return parts.join("");
280
+ }
281
+
282
+ function constructPlivoV3BaseUrl(params: {
283
+ method: "GET" | "POST";
284
+ url: string;
285
+ postParams: PlivoParamMap;
286
+ }): string {
287
+ const hasPostParams = Object.keys(params.postParams).length > 0;
288
+ const u = new URL(params.url);
289
+ const baseNoQuery = `${u.protocol}//${u.host}${u.pathname}`;
290
+
291
+ const queryMap = toParamMapFromSearchParams(u.searchParams);
292
+ const queryString = sortedQueryString(queryMap);
293
+
294
+ // In the Plivo V3 algorithm, the query portion is always sorted, and if we
295
+ // have POST params we add a '.' separator after the query string.
296
+ let baseUrl = baseNoQuery;
297
+ if (queryString.length > 0 || hasPostParams) {
298
+ baseUrl = `${baseNoQuery}?${queryString}`;
299
+ }
300
+ if (queryString.length > 0 && hasPostParams) {
301
+ baseUrl = `${baseUrl}.`;
302
+ }
303
+
304
+ if (params.method === "GET") {
305
+ return baseUrl;
306
+ }
307
+
308
+ return baseUrl + sortedParamsString(params.postParams);
309
+ }
310
+
311
+ function validatePlivoV3Signature(params: {
312
+ authToken: string;
313
+ signatureHeader: string;
314
+ nonce: string;
315
+ method: "GET" | "POST";
316
+ url: string;
317
+ postParams: PlivoParamMap;
318
+ }): boolean {
319
+ const baseUrl = constructPlivoV3BaseUrl({
320
+ method: params.method,
321
+ url: params.url,
322
+ postParams: params.postParams,
323
+ });
324
+
325
+ const hmacBase = `${baseUrl}.${params.nonce}`;
326
+ const digest = crypto
327
+ .createHmac("sha256", params.authToken)
328
+ .update(hmacBase)
329
+ .digest("base64");
330
+ const expected = normalizeSignatureBase64(digest);
331
+
332
+ // Header can contain multiple signatures separated by commas.
333
+ const provided = params.signatureHeader
334
+ .split(",")
335
+ .map((s) => s.trim())
336
+ .filter(Boolean)
337
+ .map((s) => normalizeSignatureBase64(s));
338
+
339
+ for (const sig of provided) {
340
+ if (timingSafeEqualString(expected, sig)) return true;
341
+ }
342
+ return false;
343
+ }
344
+
345
+ /**
346
+ * Verify Plivo webhooks using V3 signature if present; fall back to V2.
347
+ *
348
+ * Header names (case-insensitive; Node provides lower-case keys):
349
+ * - V3: X-Plivo-Signature-V3 / X-Plivo-Signature-V3-Nonce
350
+ * - V2: X-Plivo-Signature-V2 / X-Plivo-Signature-V2-Nonce
351
+ */
352
+ export function verifyPlivoWebhook(
353
+ ctx: WebhookContext,
354
+ authToken: string,
355
+ options?: {
356
+ /** Override the public URL origin (host) used for verification */
357
+ publicUrl?: string;
358
+ /** Skip verification entirely (only for development) */
359
+ skipVerification?: boolean;
360
+ },
361
+ ): PlivoVerificationResult {
362
+ if (options?.skipVerification) {
363
+ return { ok: true, reason: "verification skipped (dev mode)" };
364
+ }
365
+
366
+ const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3");
367
+ const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
368
+ const signatureV2 = getHeader(ctx.headers, "x-plivo-signature-v2");
369
+ const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
370
+
371
+ const reconstructed = reconstructWebhookUrl(ctx);
372
+ let verificationUrl = reconstructed;
373
+ if (options?.publicUrl) {
374
+ try {
375
+ const req = new URL(reconstructed);
376
+ const base = new URL(options.publicUrl);
377
+ base.pathname = req.pathname;
378
+ base.search = req.search;
379
+ verificationUrl = base.toString();
380
+ } catch {
381
+ verificationUrl = reconstructed;
382
+ }
383
+ }
384
+
385
+ if (signatureV3 && nonceV3) {
386
+ const method =
387
+ ctx.method === "GET" || ctx.method === "POST" ? ctx.method : null;
388
+
389
+ if (!method) {
390
+ return {
391
+ ok: false,
392
+ version: "v3",
393
+ verificationUrl,
394
+ reason: `Unsupported HTTP method for Plivo V3 signature: ${ctx.method}`,
395
+ };
396
+ }
397
+
398
+ const postParams = toParamMapFromSearchParams(new URLSearchParams(ctx.rawBody));
399
+ const ok = validatePlivoV3Signature({
400
+ authToken,
401
+ signatureHeader: signatureV3,
402
+ nonce: nonceV3,
403
+ method,
404
+ url: verificationUrl,
405
+ postParams,
406
+ });
407
+ return ok
408
+ ? { ok: true, version: "v3", verificationUrl }
409
+ : {
410
+ ok: false,
411
+ version: "v3",
412
+ verificationUrl,
413
+ reason: "Invalid Plivo V3 signature",
414
+ };
415
+ }
416
+
417
+ if (signatureV2 && nonceV2) {
418
+ const ok = validatePlivoV2Signature({
419
+ authToken,
420
+ signature: signatureV2,
421
+ nonce: nonceV2,
422
+ url: verificationUrl,
423
+ });
424
+ return ok
425
+ ? { ok: true, version: "v2", verificationUrl }
426
+ : {
427
+ ok: false,
428
+ version: "v2",
429
+ verificationUrl,
430
+ reason: "Invalid Plivo V2 signature",
431
+ };
432
+ }
433
+
434
+ return {
435
+ ok: false,
436
+ reason: "Missing Plivo signature headers (V3 or V2)",
437
+ verificationUrl,
438
+ };
439
+ }