@openclaw/voice-call 2026.1.29
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 +78 -0
- package/README.md +135 -0
- package/index.ts +497 -0
- package/openclaw.plugin.json +601 -0
- package/package.json +16 -0
- package/src/cli.ts +312 -0
- package/src/config.test.ts +204 -0
- package/src/config.ts +502 -0
- package/src/core-bridge.ts +198 -0
- package/src/manager/context.ts +21 -0
- package/src/manager/events.ts +177 -0
- package/src/manager/lookup.ts +33 -0
- package/src/manager/outbound.ts +248 -0
- package/src/manager/state.ts +50 -0
- package/src/manager/store.ts +88 -0
- package/src/manager/timers.ts +86 -0
- package/src/manager/twiml.ts +9 -0
- package/src/manager.test.ts +108 -0
- package/src/manager.ts +888 -0
- package/src/media-stream.test.ts +97 -0
- package/src/media-stream.ts +393 -0
- package/src/providers/base.ts +67 -0
- package/src/providers/index.ts +10 -0
- package/src/providers/mock.ts +168 -0
- package/src/providers/plivo.test.ts +28 -0
- package/src/providers/plivo.ts +504 -0
- package/src/providers/stt-openai-realtime.ts +311 -0
- package/src/providers/telnyx.ts +364 -0
- package/src/providers/tts-openai.ts +264 -0
- package/src/providers/twilio/api.ts +45 -0
- package/src/providers/twilio/webhook.ts +30 -0
- package/src/providers/twilio.test.ts +64 -0
- package/src/providers/twilio.ts +595 -0
- package/src/response-generator.ts +171 -0
- package/src/runtime.ts +217 -0
- package/src/telephony-audio.ts +88 -0
- package/src/telephony-tts.ts +95 -0
- package/src/tunnel.ts +331 -0
- package/src/types.ts +273 -0
- package/src/utils.ts +12 -0
- package/src/voice-mapping.ts +65 -0
- package/src/webhook-security.test.ts +260 -0
- package/src/webhook-security.ts +469 -0
- package/src/webhook.ts +491 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { verifyPlivoWebhook, verifyTwilioWebhook } 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
|
+
function twilioSignature(params: {
|
|
75
|
+
authToken: string;
|
|
76
|
+
url: string;
|
|
77
|
+
postBody: string;
|
|
78
|
+
}): string {
|
|
79
|
+
let dataToSign = params.url;
|
|
80
|
+
const sortedParams = Array.from(
|
|
81
|
+
new URLSearchParams(params.postBody).entries(),
|
|
82
|
+
).sort((a, b) => a[0].localeCompare(b[0]));
|
|
83
|
+
|
|
84
|
+
for (const [key, value] of sortedParams) {
|
|
85
|
+
dataToSign += key + value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return crypto
|
|
89
|
+
.createHmac("sha1", params.authToken)
|
|
90
|
+
.update(dataToSign)
|
|
91
|
+
.digest("base64");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe("verifyPlivoWebhook", () => {
|
|
95
|
+
it("accepts valid V2 signature", () => {
|
|
96
|
+
const authToken = "test-auth-token";
|
|
97
|
+
const nonce = "nonce-123";
|
|
98
|
+
|
|
99
|
+
const ctxUrl = "http://local/voice/webhook?flow=answer&callId=abc";
|
|
100
|
+
const verificationUrl = "https://example.com/voice/webhook";
|
|
101
|
+
const signature = plivoV2Signature({
|
|
102
|
+
authToken,
|
|
103
|
+
urlNoQuery: verificationUrl,
|
|
104
|
+
nonce,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const result = verifyPlivoWebhook(
|
|
108
|
+
{
|
|
109
|
+
headers: {
|
|
110
|
+
host: "example.com",
|
|
111
|
+
"x-forwarded-proto": "https",
|
|
112
|
+
"x-plivo-signature-v2": signature,
|
|
113
|
+
"x-plivo-signature-v2-nonce": nonce,
|
|
114
|
+
},
|
|
115
|
+
rawBody: "CallUUID=uuid&CallStatus=in-progress",
|
|
116
|
+
url: ctxUrl,
|
|
117
|
+
method: "POST",
|
|
118
|
+
query: { flow: "answer", callId: "abc" },
|
|
119
|
+
},
|
|
120
|
+
authToken,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(result.ok).toBe(true);
|
|
124
|
+
expect(result.version).toBe("v2");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("accepts valid V3 signature (including multi-signature header)", () => {
|
|
128
|
+
const authToken = "test-auth-token";
|
|
129
|
+
const nonce = "nonce-456";
|
|
130
|
+
|
|
131
|
+
const urlWithQuery = "https://example.com/voice/webhook?flow=answer&callId=abc";
|
|
132
|
+
const postBody = "CallUUID=uuid&CallStatus=in-progress&From=%2B15550000000";
|
|
133
|
+
|
|
134
|
+
const good = plivoV3Signature({
|
|
135
|
+
authToken,
|
|
136
|
+
urlWithQuery,
|
|
137
|
+
postBody,
|
|
138
|
+
nonce,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const result = verifyPlivoWebhook(
|
|
142
|
+
{
|
|
143
|
+
headers: {
|
|
144
|
+
host: "example.com",
|
|
145
|
+
"x-forwarded-proto": "https",
|
|
146
|
+
"x-plivo-signature-v3": `bad, ${good}`,
|
|
147
|
+
"x-plivo-signature-v3-nonce": nonce,
|
|
148
|
+
},
|
|
149
|
+
rawBody: postBody,
|
|
150
|
+
url: urlWithQuery,
|
|
151
|
+
method: "POST",
|
|
152
|
+
query: { flow: "answer", callId: "abc" },
|
|
153
|
+
},
|
|
154
|
+
authToken,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
expect(result.ok).toBe(true);
|
|
158
|
+
expect(result.version).toBe("v3");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("rejects missing signatures", () => {
|
|
162
|
+
const result = verifyPlivoWebhook(
|
|
163
|
+
{
|
|
164
|
+
headers: { host: "example.com", "x-forwarded-proto": "https" },
|
|
165
|
+
rawBody: "",
|
|
166
|
+
url: "https://example.com/voice/webhook",
|
|
167
|
+
method: "POST",
|
|
168
|
+
},
|
|
169
|
+
"token",
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
expect(result.ok).toBe(false);
|
|
173
|
+
expect(result.reason).toMatch(/Missing Plivo signature headers/);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("verifyTwilioWebhook", () => {
|
|
178
|
+
it("uses request query when publicUrl omits it", () => {
|
|
179
|
+
const authToken = "test-auth-token";
|
|
180
|
+
const publicUrl = "https://example.com/voice/webhook";
|
|
181
|
+
const urlWithQuery = `${publicUrl}?callId=abc`;
|
|
182
|
+
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
|
183
|
+
|
|
184
|
+
const signature = twilioSignature({
|
|
185
|
+
authToken,
|
|
186
|
+
url: urlWithQuery,
|
|
187
|
+
postBody,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const result = verifyTwilioWebhook(
|
|
191
|
+
{
|
|
192
|
+
headers: {
|
|
193
|
+
host: "example.com",
|
|
194
|
+
"x-forwarded-proto": "https",
|
|
195
|
+
"x-twilio-signature": signature,
|
|
196
|
+
},
|
|
197
|
+
rawBody: postBody,
|
|
198
|
+
url: "http://local/voice/webhook?callId=abc",
|
|
199
|
+
method: "POST",
|
|
200
|
+
query: { callId: "abc" },
|
|
201
|
+
},
|
|
202
|
+
authToken,
|
|
203
|
+
{ publicUrl },
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
expect(result.ok).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("rejects invalid signatures even with ngrok free tier enabled", () => {
|
|
210
|
+
const authToken = "test-auth-token";
|
|
211
|
+
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
|
212
|
+
|
|
213
|
+
const result = verifyTwilioWebhook(
|
|
214
|
+
{
|
|
215
|
+
headers: {
|
|
216
|
+
host: "127.0.0.1:3334",
|
|
217
|
+
"x-forwarded-proto": "https",
|
|
218
|
+
"x-forwarded-host": "attacker.ngrok-free.app",
|
|
219
|
+
"x-twilio-signature": "invalid",
|
|
220
|
+
},
|
|
221
|
+
rawBody: postBody,
|
|
222
|
+
url: "http://127.0.0.1:3334/voice/webhook",
|
|
223
|
+
method: "POST",
|
|
224
|
+
remoteAddress: "203.0.113.10",
|
|
225
|
+
},
|
|
226
|
+
authToken,
|
|
227
|
+
{ allowNgrokFreeTierLoopbackBypass: true },
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
expect(result.ok).toBe(false);
|
|
231
|
+
expect(result.isNgrokFreeTier).toBe(true);
|
|
232
|
+
expect(result.reason).toMatch(/Invalid signature/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("allows invalid signatures for ngrok free tier only on loopback", () => {
|
|
236
|
+
const authToken = "test-auth-token";
|
|
237
|
+
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
|
238
|
+
|
|
239
|
+
const result = verifyTwilioWebhook(
|
|
240
|
+
{
|
|
241
|
+
headers: {
|
|
242
|
+
host: "127.0.0.1:3334",
|
|
243
|
+
"x-forwarded-proto": "https",
|
|
244
|
+
"x-forwarded-host": "local.ngrok-free.app",
|
|
245
|
+
"x-twilio-signature": "invalid",
|
|
246
|
+
},
|
|
247
|
+
rawBody: postBody,
|
|
248
|
+
url: "http://127.0.0.1:3334/voice/webhook",
|
|
249
|
+
method: "POST",
|
|
250
|
+
remoteAddress: "127.0.0.1",
|
|
251
|
+
},
|
|
252
|
+
authToken,
|
|
253
|
+
{ allowNgrokFreeTierLoopbackBypass: true },
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(result.ok).toBe(true);
|
|
257
|
+
expect(result.isNgrokFreeTier).toBe(true);
|
|
258
|
+
expect(result.reason).toMatch(/compatibility mode/);
|
|
259
|
+
});
|
|
260
|
+
});
|