@clawdbot/voice-call 2026.1.14 → 2026.1.20
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 +25 -0
- package/clawdbot.plugin.json +405 -0
- package/package.json +4 -2
- package/src/config.ts +23 -8
- package/src/providers/twilio/api.ts +19 -3
- package/src/providers/twilio.ts +86 -15
- package/src/tunnel.ts +5 -4
- package/src/webhook-security.test.ts +53 -1
- package/src/webhook-security.ts +21 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026.1.20
|
|
4
|
+
|
|
5
|
+
### Changes
|
|
6
|
+
- Version alignment with core Clawdbot release numbers.
|
|
7
|
+
|
|
8
|
+
## 2026.1.17-1
|
|
9
|
+
|
|
10
|
+
### Changes
|
|
11
|
+
- Version alignment with core Clawdbot release numbers.
|
|
12
|
+
|
|
13
|
+
## 2026.1.17
|
|
14
|
+
|
|
15
|
+
### Changes
|
|
16
|
+
- Version alignment with core Clawdbot release numbers.
|
|
17
|
+
|
|
18
|
+
## 2026.1.16
|
|
19
|
+
|
|
20
|
+
### Changes
|
|
21
|
+
- Version alignment with core Clawdbot release numbers.
|
|
22
|
+
|
|
23
|
+
## 2026.1.15
|
|
24
|
+
|
|
25
|
+
### Changes
|
|
26
|
+
- Version alignment with core Clawdbot release numbers.
|
|
27
|
+
|
|
3
28
|
## 2026.1.14
|
|
4
29
|
|
|
5
30
|
### Changes
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "voice-call",
|
|
3
|
+
"uiHints": {
|
|
4
|
+
"provider": {
|
|
5
|
+
"label": "Provider",
|
|
6
|
+
"help": "Use twilio, telnyx, or mock for dev/no-network."
|
|
7
|
+
},
|
|
8
|
+
"fromNumber": {
|
|
9
|
+
"label": "From Number",
|
|
10
|
+
"placeholder": "+15550001234"
|
|
11
|
+
},
|
|
12
|
+
"toNumber": {
|
|
13
|
+
"label": "Default To Number",
|
|
14
|
+
"placeholder": "+15550001234"
|
|
15
|
+
},
|
|
16
|
+
"inboundPolicy": {
|
|
17
|
+
"label": "Inbound Policy"
|
|
18
|
+
},
|
|
19
|
+
"allowFrom": {
|
|
20
|
+
"label": "Inbound Allowlist"
|
|
21
|
+
},
|
|
22
|
+
"inboundGreeting": {
|
|
23
|
+
"label": "Inbound Greeting",
|
|
24
|
+
"advanced": true
|
|
25
|
+
},
|
|
26
|
+
"telnyx.apiKey": {
|
|
27
|
+
"label": "Telnyx API Key",
|
|
28
|
+
"sensitive": true
|
|
29
|
+
},
|
|
30
|
+
"telnyx.connectionId": {
|
|
31
|
+
"label": "Telnyx Connection ID"
|
|
32
|
+
},
|
|
33
|
+
"telnyx.publicKey": {
|
|
34
|
+
"label": "Telnyx Public Key",
|
|
35
|
+
"sensitive": true
|
|
36
|
+
},
|
|
37
|
+
"twilio.accountSid": {
|
|
38
|
+
"label": "Twilio Account SID"
|
|
39
|
+
},
|
|
40
|
+
"twilio.authToken": {
|
|
41
|
+
"label": "Twilio Auth Token",
|
|
42
|
+
"sensitive": true
|
|
43
|
+
},
|
|
44
|
+
"outbound.defaultMode": {
|
|
45
|
+
"label": "Default Call Mode"
|
|
46
|
+
},
|
|
47
|
+
"outbound.notifyHangupDelaySec": {
|
|
48
|
+
"label": "Notify Hangup Delay (sec)",
|
|
49
|
+
"advanced": true
|
|
50
|
+
},
|
|
51
|
+
"serve.port": {
|
|
52
|
+
"label": "Webhook Port"
|
|
53
|
+
},
|
|
54
|
+
"serve.bind": {
|
|
55
|
+
"label": "Webhook Bind"
|
|
56
|
+
},
|
|
57
|
+
"serve.path": {
|
|
58
|
+
"label": "Webhook Path"
|
|
59
|
+
},
|
|
60
|
+
"tailscale.mode": {
|
|
61
|
+
"label": "Tailscale Mode",
|
|
62
|
+
"advanced": true
|
|
63
|
+
},
|
|
64
|
+
"tailscale.path": {
|
|
65
|
+
"label": "Tailscale Path",
|
|
66
|
+
"advanced": true
|
|
67
|
+
},
|
|
68
|
+
"tunnel.provider": {
|
|
69
|
+
"label": "Tunnel Provider",
|
|
70
|
+
"advanced": true
|
|
71
|
+
},
|
|
72
|
+
"tunnel.ngrokAuthToken": {
|
|
73
|
+
"label": "ngrok Auth Token",
|
|
74
|
+
"sensitive": true,
|
|
75
|
+
"advanced": true
|
|
76
|
+
},
|
|
77
|
+
"tunnel.ngrokDomain": {
|
|
78
|
+
"label": "ngrok Domain",
|
|
79
|
+
"advanced": true
|
|
80
|
+
},
|
|
81
|
+
"tunnel.allowNgrokFreeTier": {
|
|
82
|
+
"label": "Allow ngrok Free Tier",
|
|
83
|
+
"advanced": true
|
|
84
|
+
},
|
|
85
|
+
"streaming.enabled": {
|
|
86
|
+
"label": "Enable Streaming",
|
|
87
|
+
"advanced": true
|
|
88
|
+
},
|
|
89
|
+
"streaming.openaiApiKey": {
|
|
90
|
+
"label": "OpenAI Realtime API Key",
|
|
91
|
+
"sensitive": true,
|
|
92
|
+
"advanced": true
|
|
93
|
+
},
|
|
94
|
+
"streaming.sttModel": {
|
|
95
|
+
"label": "Realtime STT Model",
|
|
96
|
+
"advanced": true
|
|
97
|
+
},
|
|
98
|
+
"streaming.streamPath": {
|
|
99
|
+
"label": "Media Stream Path",
|
|
100
|
+
"advanced": true
|
|
101
|
+
},
|
|
102
|
+
"tts.model": {
|
|
103
|
+
"label": "TTS Model",
|
|
104
|
+
"advanced": true
|
|
105
|
+
},
|
|
106
|
+
"tts.voice": {
|
|
107
|
+
"label": "TTS Voice",
|
|
108
|
+
"advanced": true
|
|
109
|
+
},
|
|
110
|
+
"tts.instructions": {
|
|
111
|
+
"label": "TTS Instructions",
|
|
112
|
+
"advanced": true
|
|
113
|
+
},
|
|
114
|
+
"publicUrl": {
|
|
115
|
+
"label": "Public Webhook URL",
|
|
116
|
+
"advanced": true
|
|
117
|
+
},
|
|
118
|
+
"skipSignatureVerification": {
|
|
119
|
+
"label": "Skip Signature Verification",
|
|
120
|
+
"advanced": true
|
|
121
|
+
},
|
|
122
|
+
"store": {
|
|
123
|
+
"label": "Call Log Store Path",
|
|
124
|
+
"advanced": true
|
|
125
|
+
},
|
|
126
|
+
"responseModel": {
|
|
127
|
+
"label": "Response Model",
|
|
128
|
+
"advanced": true
|
|
129
|
+
},
|
|
130
|
+
"responseSystemPrompt": {
|
|
131
|
+
"label": "Response System Prompt",
|
|
132
|
+
"advanced": true
|
|
133
|
+
},
|
|
134
|
+
"responseTimeoutMs": {
|
|
135
|
+
"label": "Response Timeout (ms)",
|
|
136
|
+
"advanced": true
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
"configSchema": {
|
|
140
|
+
"type": "object",
|
|
141
|
+
"additionalProperties": false,
|
|
142
|
+
"properties": {
|
|
143
|
+
"enabled": {
|
|
144
|
+
"type": "boolean"
|
|
145
|
+
},
|
|
146
|
+
"provider": {
|
|
147
|
+
"type": "string",
|
|
148
|
+
"enum": [
|
|
149
|
+
"telnyx",
|
|
150
|
+
"twilio",
|
|
151
|
+
"plivo",
|
|
152
|
+
"mock"
|
|
153
|
+
]
|
|
154
|
+
},
|
|
155
|
+
"telnyx": {
|
|
156
|
+
"type": "object",
|
|
157
|
+
"additionalProperties": false,
|
|
158
|
+
"properties": {
|
|
159
|
+
"apiKey": {
|
|
160
|
+
"type": "string"
|
|
161
|
+
},
|
|
162
|
+
"connectionId": {
|
|
163
|
+
"type": "string"
|
|
164
|
+
},
|
|
165
|
+
"publicKey": {
|
|
166
|
+
"type": "string"
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
"twilio": {
|
|
171
|
+
"type": "object",
|
|
172
|
+
"additionalProperties": false,
|
|
173
|
+
"properties": {
|
|
174
|
+
"accountSid": {
|
|
175
|
+
"type": "string"
|
|
176
|
+
},
|
|
177
|
+
"authToken": {
|
|
178
|
+
"type": "string"
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
"plivo": {
|
|
183
|
+
"type": "object",
|
|
184
|
+
"additionalProperties": false,
|
|
185
|
+
"properties": {
|
|
186
|
+
"authId": {
|
|
187
|
+
"type": "string"
|
|
188
|
+
},
|
|
189
|
+
"authToken": {
|
|
190
|
+
"type": "string"
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
"fromNumber": {
|
|
195
|
+
"type": "string",
|
|
196
|
+
"pattern": "^\\+[1-9]\\d{1,14}$"
|
|
197
|
+
},
|
|
198
|
+
"toNumber": {
|
|
199
|
+
"type": "string",
|
|
200
|
+
"pattern": "^\\+[1-9]\\d{1,14}$"
|
|
201
|
+
},
|
|
202
|
+
"inboundPolicy": {
|
|
203
|
+
"type": "string",
|
|
204
|
+
"enum": [
|
|
205
|
+
"disabled",
|
|
206
|
+
"allowlist",
|
|
207
|
+
"pairing",
|
|
208
|
+
"open"
|
|
209
|
+
]
|
|
210
|
+
},
|
|
211
|
+
"allowFrom": {
|
|
212
|
+
"type": "array",
|
|
213
|
+
"items": {
|
|
214
|
+
"type": "string",
|
|
215
|
+
"pattern": "^\\+[1-9]\\d{1,14}$"
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
"inboundGreeting": {
|
|
219
|
+
"type": "string"
|
|
220
|
+
},
|
|
221
|
+
"outbound": {
|
|
222
|
+
"type": "object",
|
|
223
|
+
"additionalProperties": false,
|
|
224
|
+
"properties": {
|
|
225
|
+
"defaultMode": {
|
|
226
|
+
"type": "string",
|
|
227
|
+
"enum": [
|
|
228
|
+
"notify",
|
|
229
|
+
"conversation"
|
|
230
|
+
]
|
|
231
|
+
},
|
|
232
|
+
"notifyHangupDelaySec": {
|
|
233
|
+
"type": "integer",
|
|
234
|
+
"minimum": 0
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
"maxDurationSeconds": {
|
|
239
|
+
"type": "integer",
|
|
240
|
+
"minimum": 1
|
|
241
|
+
},
|
|
242
|
+
"silenceTimeoutMs": {
|
|
243
|
+
"type": "integer",
|
|
244
|
+
"minimum": 1
|
|
245
|
+
},
|
|
246
|
+
"transcriptTimeoutMs": {
|
|
247
|
+
"type": "integer",
|
|
248
|
+
"minimum": 1
|
|
249
|
+
},
|
|
250
|
+
"ringTimeoutMs": {
|
|
251
|
+
"type": "integer",
|
|
252
|
+
"minimum": 1
|
|
253
|
+
},
|
|
254
|
+
"maxConcurrentCalls": {
|
|
255
|
+
"type": "integer",
|
|
256
|
+
"minimum": 1
|
|
257
|
+
},
|
|
258
|
+
"serve": {
|
|
259
|
+
"type": "object",
|
|
260
|
+
"additionalProperties": false,
|
|
261
|
+
"properties": {
|
|
262
|
+
"port": {
|
|
263
|
+
"type": "integer",
|
|
264
|
+
"minimum": 1
|
|
265
|
+
},
|
|
266
|
+
"bind": {
|
|
267
|
+
"type": "string"
|
|
268
|
+
},
|
|
269
|
+
"path": {
|
|
270
|
+
"type": "string"
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
"tailscale": {
|
|
275
|
+
"type": "object",
|
|
276
|
+
"additionalProperties": false,
|
|
277
|
+
"properties": {
|
|
278
|
+
"mode": {
|
|
279
|
+
"type": "string",
|
|
280
|
+
"enum": [
|
|
281
|
+
"off",
|
|
282
|
+
"serve",
|
|
283
|
+
"funnel"
|
|
284
|
+
]
|
|
285
|
+
},
|
|
286
|
+
"path": {
|
|
287
|
+
"type": "string"
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
"tunnel": {
|
|
292
|
+
"type": "object",
|
|
293
|
+
"additionalProperties": false,
|
|
294
|
+
"properties": {
|
|
295
|
+
"provider": {
|
|
296
|
+
"type": "string",
|
|
297
|
+
"enum": [
|
|
298
|
+
"none",
|
|
299
|
+
"ngrok",
|
|
300
|
+
"tailscale-serve",
|
|
301
|
+
"tailscale-funnel"
|
|
302
|
+
]
|
|
303
|
+
},
|
|
304
|
+
"ngrokAuthToken": {
|
|
305
|
+
"type": "string"
|
|
306
|
+
},
|
|
307
|
+
"ngrokDomain": {
|
|
308
|
+
"type": "string"
|
|
309
|
+
},
|
|
310
|
+
"allowNgrokFreeTier": {
|
|
311
|
+
"type": "boolean"
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
"streaming": {
|
|
316
|
+
"type": "object",
|
|
317
|
+
"additionalProperties": false,
|
|
318
|
+
"properties": {
|
|
319
|
+
"enabled": {
|
|
320
|
+
"type": "boolean"
|
|
321
|
+
},
|
|
322
|
+
"sttProvider": {
|
|
323
|
+
"type": "string",
|
|
324
|
+
"enum": [
|
|
325
|
+
"openai-realtime"
|
|
326
|
+
]
|
|
327
|
+
},
|
|
328
|
+
"openaiApiKey": {
|
|
329
|
+
"type": "string"
|
|
330
|
+
},
|
|
331
|
+
"sttModel": {
|
|
332
|
+
"type": "string"
|
|
333
|
+
},
|
|
334
|
+
"silenceDurationMs": {
|
|
335
|
+
"type": "integer",
|
|
336
|
+
"minimum": 1
|
|
337
|
+
},
|
|
338
|
+
"vadThreshold": {
|
|
339
|
+
"type": "number",
|
|
340
|
+
"minimum": 0,
|
|
341
|
+
"maximum": 1
|
|
342
|
+
},
|
|
343
|
+
"streamPath": {
|
|
344
|
+
"type": "string"
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
"publicUrl": {
|
|
349
|
+
"type": "string"
|
|
350
|
+
},
|
|
351
|
+
"skipSignatureVerification": {
|
|
352
|
+
"type": "boolean"
|
|
353
|
+
},
|
|
354
|
+
"stt": {
|
|
355
|
+
"type": "object",
|
|
356
|
+
"additionalProperties": false,
|
|
357
|
+
"properties": {
|
|
358
|
+
"provider": {
|
|
359
|
+
"type": "string",
|
|
360
|
+
"enum": [
|
|
361
|
+
"openai"
|
|
362
|
+
]
|
|
363
|
+
},
|
|
364
|
+
"model": {
|
|
365
|
+
"type": "string"
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
"tts": {
|
|
370
|
+
"type": "object",
|
|
371
|
+
"additionalProperties": false,
|
|
372
|
+
"properties": {
|
|
373
|
+
"provider": {
|
|
374
|
+
"type": "string",
|
|
375
|
+
"enum": [
|
|
376
|
+
"openai"
|
|
377
|
+
]
|
|
378
|
+
},
|
|
379
|
+
"model": {
|
|
380
|
+
"type": "string"
|
|
381
|
+
},
|
|
382
|
+
"voice": {
|
|
383
|
+
"type": "string"
|
|
384
|
+
},
|
|
385
|
+
"instructions": {
|
|
386
|
+
"type": "string"
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
"store": {
|
|
391
|
+
"type": "string"
|
|
392
|
+
},
|
|
393
|
+
"responseModel": {
|
|
394
|
+
"type": "string"
|
|
395
|
+
},
|
|
396
|
+
"responseSystemPrompt": {
|
|
397
|
+
"type": "string"
|
|
398
|
+
},
|
|
399
|
+
"responseTimeoutMs": {
|
|
400
|
+
"type": "integer",
|
|
401
|
+
"minimum": 1
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clawdbot/voice-call",
|
|
3
|
-
"version": "2026.1.
|
|
3
|
+
"version": "2026.1.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Clawdbot voice-call plugin",
|
|
6
6
|
"dependencies": {
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
"zod": "^4.3.5"
|
|
10
10
|
},
|
|
11
11
|
"clawdbot": {
|
|
12
|
-
"extensions": [
|
|
12
|
+
"extensions": [
|
|
13
|
+
"./index.ts"
|
|
14
|
+
]
|
|
13
15
|
}
|
|
14
16
|
}
|
package/src/config.ts
CHANGED
|
@@ -35,30 +35,36 @@ export type InboundPolicy = z.infer<typeof InboundPolicySchema>;
|
|
|
35
35
|
// Provider-Specific Configuration
|
|
36
36
|
// -----------------------------------------------------------------------------
|
|
37
37
|
|
|
38
|
-
export const TelnyxConfigSchema = z
|
|
38
|
+
export const TelnyxConfigSchema = z
|
|
39
|
+
.object({
|
|
39
40
|
/** Telnyx API v2 key */
|
|
40
41
|
apiKey: z.string().min(1).optional(),
|
|
41
42
|
/** Telnyx connection ID (from Call Control app) */
|
|
42
43
|
connectionId: z.string().min(1).optional(),
|
|
43
44
|
/** Public key for webhook signature verification */
|
|
44
45
|
publicKey: z.string().min(1).optional(),
|
|
45
|
-
})
|
|
46
|
+
})
|
|
47
|
+
.strict();
|
|
46
48
|
export type TelnyxConfig = z.infer<typeof TelnyxConfigSchema>;
|
|
47
49
|
|
|
48
|
-
export const TwilioConfigSchema = z
|
|
50
|
+
export const TwilioConfigSchema = z
|
|
51
|
+
.object({
|
|
49
52
|
/** Twilio Account SID */
|
|
50
53
|
accountSid: z.string().min(1).optional(),
|
|
51
54
|
/** Twilio Auth Token */
|
|
52
55
|
authToken: z.string().min(1).optional(),
|
|
53
|
-
})
|
|
56
|
+
})
|
|
57
|
+
.strict();
|
|
54
58
|
export type TwilioConfig = z.infer<typeof TwilioConfigSchema>;
|
|
55
59
|
|
|
56
|
-
export const PlivoConfigSchema = z
|
|
60
|
+
export const PlivoConfigSchema = z
|
|
61
|
+
.object({
|
|
57
62
|
/** Plivo Auth ID (starts with MA/SA) */
|
|
58
63
|
authId: z.string().min(1).optional(),
|
|
59
64
|
/** Plivo Auth Token */
|
|
60
65
|
authToken: z.string().min(1).optional(),
|
|
61
|
-
})
|
|
66
|
+
})
|
|
67
|
+
.strict();
|
|
62
68
|
export type PlivoConfig = z.infer<typeof PlivoConfigSchema>;
|
|
63
69
|
|
|
64
70
|
// -----------------------------------------------------------------------------
|
|
@@ -72,6 +78,7 @@ export const SttConfigSchema = z
|
|
|
72
78
|
/** Whisper model to use */
|
|
73
79
|
model: z.string().min(1).default("whisper-1"),
|
|
74
80
|
})
|
|
81
|
+
.strict()
|
|
75
82
|
.default({ provider: "openai", model: "whisper-1" });
|
|
76
83
|
export type SttConfig = z.infer<typeof SttConfigSchema>;
|
|
77
84
|
|
|
@@ -97,6 +104,7 @@ export const TtsConfigSchema = z
|
|
|
97
104
|
*/
|
|
98
105
|
instructions: z.string().optional(),
|
|
99
106
|
})
|
|
107
|
+
.strict()
|
|
100
108
|
.default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
|
|
101
109
|
export type TtsConfig = z.infer<typeof TtsConfigSchema>;
|
|
102
110
|
|
|
@@ -113,6 +121,7 @@ export const VoiceCallServeConfigSchema = z
|
|
|
113
121
|
/** Webhook path */
|
|
114
122
|
path: z.string().min(1).default("/voice/webhook"),
|
|
115
123
|
})
|
|
124
|
+
.strict()
|
|
116
125
|
.default({ port: 3334, bind: "127.0.0.1", path: "/voice/webhook" });
|
|
117
126
|
export type VoiceCallServeConfig = z.infer<typeof VoiceCallServeConfigSchema>;
|
|
118
127
|
|
|
@@ -128,6 +137,7 @@ export const VoiceCallTailscaleConfigSchema = z
|
|
|
128
137
|
/** Path for Tailscale serve/funnel (should usually match serve.path) */
|
|
129
138
|
path: z.string().min(1).default("/voice/webhook"),
|
|
130
139
|
})
|
|
140
|
+
.strict()
|
|
131
141
|
.default({ mode: "off", path: "/voice/webhook" });
|
|
132
142
|
export type VoiceCallTailscaleConfig = z.infer<
|
|
133
143
|
typeof VoiceCallTailscaleConfigSchema
|
|
@@ -161,6 +171,7 @@ export const VoiceCallTunnelConfigSchema = z
|
|
|
161
171
|
*/
|
|
162
172
|
allowNgrokFreeTier: z.boolean().default(true),
|
|
163
173
|
})
|
|
174
|
+
.strict()
|
|
164
175
|
.default({ provider: "none", allowNgrokFreeTier: true });
|
|
165
176
|
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
|
|
166
177
|
|
|
@@ -183,6 +194,7 @@ export const OutboundConfigSchema = z
|
|
|
183
194
|
/** Seconds to wait after TTS before auto-hangup in notify mode */
|
|
184
195
|
notifyHangupDelaySec: z.number().int().nonnegative().default(3),
|
|
185
196
|
})
|
|
197
|
+
.strict()
|
|
186
198
|
.default({ defaultMode: "notify", notifyHangupDelaySec: 3 });
|
|
187
199
|
export type OutboundConfig = z.infer<typeof OutboundConfigSchema>;
|
|
188
200
|
|
|
@@ -207,6 +219,7 @@ export const VoiceCallStreamingConfigSchema = z
|
|
|
207
219
|
/** WebSocket path for media stream connections */
|
|
208
220
|
streamPath: z.string().min(1).default("/voice/stream"),
|
|
209
221
|
})
|
|
222
|
+
.strict()
|
|
210
223
|
.default({
|
|
211
224
|
enabled: false,
|
|
212
225
|
sttProvider: "openai-realtime",
|
|
@@ -223,7 +236,8 @@ export type VoiceCallStreamingConfig = z.infer<
|
|
|
223
236
|
// Main Voice Call Configuration
|
|
224
237
|
// -----------------------------------------------------------------------------
|
|
225
238
|
|
|
226
|
-
export const VoiceCallConfigSchema = z
|
|
239
|
+
export const VoiceCallConfigSchema = z
|
|
240
|
+
.object({
|
|
227
241
|
/** Enable voice call functionality */
|
|
228
242
|
enabled: z.boolean().default(false),
|
|
229
243
|
|
|
@@ -307,7 +321,8 @@ export const VoiceCallConfigSchema = z.object({
|
|
|
307
321
|
|
|
308
322
|
/** Timeout for response generation in ms (default 30s) */
|
|
309
323
|
responseTimeoutMs: z.number().int().positive().default(30000),
|
|
310
|
-
})
|
|
324
|
+
})
|
|
325
|
+
.strict();
|
|
311
326
|
|
|
312
327
|
export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>;
|
|
313
328
|
|
|
@@ -3,16 +3,33 @@ export async function twilioApiRequest<T = unknown>(params: {
|
|
|
3
3
|
accountSid: string;
|
|
4
4
|
authToken: string;
|
|
5
5
|
endpoint: string;
|
|
6
|
-
body: Record<string, string>;
|
|
6
|
+
body: URLSearchParams | Record<string, string | string[]>;
|
|
7
7
|
allowNotFound?: boolean;
|
|
8
8
|
}): Promise<T> {
|
|
9
|
+
const bodyParams =
|
|
10
|
+
params.body instanceof URLSearchParams
|
|
11
|
+
? params.body
|
|
12
|
+
: Object.entries(params.body).reduce<URLSearchParams>(
|
|
13
|
+
(acc, [key, value]) => {
|
|
14
|
+
if (Array.isArray(value)) {
|
|
15
|
+
for (const entry of value) {
|
|
16
|
+
acc.append(key, entry);
|
|
17
|
+
}
|
|
18
|
+
} else if (typeof value === "string") {
|
|
19
|
+
acc.append(key, value);
|
|
20
|
+
}
|
|
21
|
+
return acc;
|
|
22
|
+
},
|
|
23
|
+
new URLSearchParams(),
|
|
24
|
+
);
|
|
25
|
+
|
|
9
26
|
const response = await fetch(`${params.baseUrl}${params.endpoint}`, {
|
|
10
27
|
method: "POST",
|
|
11
28
|
headers: {
|
|
12
29
|
Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
|
|
13
30
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
14
31
|
},
|
|
15
|
-
body:
|
|
32
|
+
body: bodyParams,
|
|
16
33
|
});
|
|
17
34
|
|
|
18
35
|
if (!response.ok) {
|
|
@@ -26,4 +43,3 @@ export async function twilioApiRequest<T = unknown>(params: {
|
|
|
26
43
|
const text = await response.text();
|
|
27
44
|
return text ? (JSON.parse(text) as T) : (undefined as T);
|
|
28
45
|
}
|
|
29
|
-
|
package/src/providers/twilio.ts
CHANGED
|
@@ -62,6 +62,37 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
62
62
|
/** Map of call SID to stream SID for media streams */
|
|
63
63
|
private callStreamMap = new Map<string, string>();
|
|
64
64
|
|
|
65
|
+
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
|
|
66
|
+
private readonly twimlStorage = new Map<string, string>();
|
|
67
|
+
/** Track notify-mode calls to avoid streaming on follow-up callbacks */
|
|
68
|
+
private readonly notifyCalls = new Set<string>();
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Delete stored TwiML for a given `callId`.
|
|
72
|
+
*
|
|
73
|
+
* We keep TwiML in-memory only long enough to satisfy the initial Twilio
|
|
74
|
+
* webhook request (notify mode). Subsequent webhooks should not reuse it.
|
|
75
|
+
*/
|
|
76
|
+
private deleteStoredTwiml(callId: string): void {
|
|
77
|
+
this.twimlStorage.delete(callId);
|
|
78
|
+
this.notifyCalls.delete(callId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Delete stored TwiML for a call, addressed by Twilio's provider call SID.
|
|
83
|
+
*
|
|
84
|
+
* This is used when we only have `providerCallId` (e.g. hangup).
|
|
85
|
+
*/
|
|
86
|
+
private deleteStoredTwimlForProviderCall(providerCallId: string): void {
|
|
87
|
+
const webhookUrl = this.callWebhookUrls.get(providerCallId);
|
|
88
|
+
if (!webhookUrl) return;
|
|
89
|
+
|
|
90
|
+
const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
|
|
91
|
+
if (!callIdMatch) return;
|
|
92
|
+
|
|
93
|
+
this.deleteStoredTwiml(callIdMatch[1]);
|
|
94
|
+
}
|
|
95
|
+
|
|
65
96
|
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
|
66
97
|
if (!config.accountSid) {
|
|
67
98
|
throw new Error("Twilio Account SID is required");
|
|
@@ -109,7 +140,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
109
140
|
*/
|
|
110
141
|
private async apiRequest<T = unknown>(
|
|
111
142
|
endpoint: string,
|
|
112
|
-
params: Record<string, string>,
|
|
143
|
+
params: Record<string, string | string[]>,
|
|
113
144
|
options?: { allowNotFound?: boolean },
|
|
114
145
|
): Promise<T> {
|
|
115
146
|
return await twilioApiRequest<T>({
|
|
@@ -228,8 +259,14 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
228
259
|
case "busy":
|
|
229
260
|
case "no-answer":
|
|
230
261
|
case "failed":
|
|
262
|
+
if (callIdOverride) {
|
|
263
|
+
this.deleteStoredTwiml(callIdOverride);
|
|
264
|
+
}
|
|
231
265
|
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
|
232
266
|
case "canceled":
|
|
267
|
+
if (callIdOverride) {
|
|
268
|
+
this.deleteStoredTwiml(callIdOverride);
|
|
269
|
+
}
|
|
233
270
|
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
|
234
271
|
default:
|
|
235
272
|
return null;
|
|
@@ -252,13 +289,38 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
252
289
|
if (!ctx) return TwilioProvider.EMPTY_TWIML;
|
|
253
290
|
|
|
254
291
|
const params = new URLSearchParams(ctx.rawBody);
|
|
292
|
+
const type =
|
|
293
|
+
typeof ctx.query?.type === "string" ? ctx.query.type.trim() : undefined;
|
|
294
|
+
const isStatusCallback = type === "status";
|
|
255
295
|
const callStatus = params.get("CallStatus");
|
|
256
296
|
const direction = params.get("Direction");
|
|
297
|
+
const callIdFromQuery =
|
|
298
|
+
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
|
|
299
|
+
? ctx.query.callId.trim()
|
|
300
|
+
: undefined;
|
|
301
|
+
|
|
302
|
+
// Avoid logging webhook params/TwiML (may contain PII).
|
|
303
|
+
|
|
304
|
+
// Handle initial TwiML request (when Twilio first initiates the call)
|
|
305
|
+
// Check if we have stored TwiML for this call (notify mode)
|
|
306
|
+
if (callIdFromQuery && !isStatusCallback) {
|
|
307
|
+
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
|
|
308
|
+
if (storedTwiml) {
|
|
309
|
+
// Clean up after serving (one-time use)
|
|
310
|
+
this.deleteStoredTwiml(callIdFromQuery);
|
|
311
|
+
return storedTwiml;
|
|
312
|
+
}
|
|
313
|
+
if (this.notifyCalls.has(callIdFromQuery)) {
|
|
314
|
+
return TwilioProvider.EMPTY_TWIML;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
257
317
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
318
|
+
// Status callbacks should not receive TwiML.
|
|
319
|
+
if (isStatusCallback) {
|
|
320
|
+
return TwilioProvider.EMPTY_TWIML;
|
|
321
|
+
}
|
|
261
322
|
|
|
323
|
+
// Handle subsequent webhook requests (status callbacks, etc.)
|
|
262
324
|
// For inbound calls, answer immediately with stream
|
|
263
325
|
if (direction === "inbound") {
|
|
264
326
|
const streamUrl = this.getStreamUrl();
|
|
@@ -328,22 +390,29 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
328
390
|
const url = new URL(input.webhookUrl);
|
|
329
391
|
url.searchParams.set("callId", input.callId);
|
|
330
392
|
|
|
331
|
-
//
|
|
332
|
-
const
|
|
393
|
+
// Create separate URL for status callbacks (required by Twilio)
|
|
394
|
+
const statusUrl = new URL(input.webhookUrl);
|
|
395
|
+
statusUrl.searchParams.set("callId", input.callId);
|
|
396
|
+
statusUrl.searchParams.set("type", "status"); // Differentiate from TwiML requests
|
|
397
|
+
|
|
398
|
+
// Store TwiML content if provided (for notify mode)
|
|
399
|
+
// We now serve it from the webhook endpoint instead of sending inline
|
|
400
|
+
if (input.inlineTwiml) {
|
|
401
|
+
this.twimlStorage.set(input.callId, input.inlineTwiml);
|
|
402
|
+
this.notifyCalls.add(input.callId);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Build request params - always use URL-based TwiML.
|
|
406
|
+
// Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter.
|
|
407
|
+
const params: Record<string, string | string[]> = {
|
|
333
408
|
To: input.to,
|
|
334
409
|
From: input.from,
|
|
335
|
-
|
|
336
|
-
|
|
410
|
+
Url: url.toString(), // TwiML serving endpoint
|
|
411
|
+
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
|
|
412
|
+
StatusCallbackEvent: ["initiated", "ringing", "answered", "completed"],
|
|
337
413
|
Timeout: "30",
|
|
338
414
|
};
|
|
339
415
|
|
|
340
|
-
// Use inline TwiML for notify mode (simpler, no webhook needed)
|
|
341
|
-
if (input.inlineTwiml) {
|
|
342
|
-
params.Twiml = input.inlineTwiml;
|
|
343
|
-
} else {
|
|
344
|
-
params.Url = url.toString();
|
|
345
|
-
}
|
|
346
|
-
|
|
347
416
|
const result = await this.apiRequest<TwilioCallResponse>(
|
|
348
417
|
"/Calls.json",
|
|
349
418
|
params,
|
|
@@ -361,6 +430,8 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
361
430
|
* Hang up a call via Twilio API.
|
|
362
431
|
*/
|
|
363
432
|
async hangupCall(input: HangupCallInput): Promise<void> {
|
|
433
|
+
this.deleteStoredTwimlForProviderCall(input.providerCallId);
|
|
434
|
+
|
|
364
435
|
this.callWebhookUrls.delete(input.providerCallId);
|
|
365
436
|
|
|
366
437
|
await this.apiRequest(
|
package/src/tunnel.ts
CHANGED
|
@@ -230,12 +230,13 @@ export async function startTailscaleTunnel(config: {
|
|
|
230
230
|
throw new Error("Could not get Tailscale DNS name. Is Tailscale running?");
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
const
|
|
233
|
+
const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
|
|
234
|
+
const localUrl = `http://127.0.0.1:${config.port}${path}`;
|
|
234
235
|
|
|
235
236
|
return new Promise((resolve, reject) => {
|
|
236
237
|
const proc = spawn(
|
|
237
238
|
"tailscale",
|
|
238
|
-
[config.mode, "--bg", "--yes", "--set-path",
|
|
239
|
+
[config.mode, "--bg", "--yes", "--set-path", path, localUrl],
|
|
239
240
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
240
241
|
);
|
|
241
242
|
|
|
@@ -247,7 +248,7 @@ export async function startTailscaleTunnel(config: {
|
|
|
247
248
|
proc.on("close", (code) => {
|
|
248
249
|
clearTimeout(timeout);
|
|
249
250
|
if (code === 0) {
|
|
250
|
-
const publicUrl = `https://${dnsName}${
|
|
251
|
+
const publicUrl = `https://${dnsName}${path}`;
|
|
251
252
|
console.log(
|
|
252
253
|
`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`,
|
|
253
254
|
);
|
|
@@ -256,7 +257,7 @@ export async function startTailscaleTunnel(config: {
|
|
|
256
257
|
publicUrl,
|
|
257
258
|
provider: `tailscale-${config.mode}`,
|
|
258
259
|
stop: async () => {
|
|
259
|
-
await stopTailscaleTunnel(config.mode,
|
|
260
|
+
await stopTailscaleTunnel(config.mode, path);
|
|
260
261
|
},
|
|
261
262
|
});
|
|
262
263
|
} else {
|
|
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
4
|
|
|
5
|
-
import { verifyPlivoWebhook } from "./webhook-security.js";
|
|
5
|
+
import { verifyPlivoWebhook, verifyTwilioWebhook } from "./webhook-security.js";
|
|
6
6
|
|
|
7
7
|
function canonicalizeBase64(input: string): string {
|
|
8
8
|
return Buffer.from(input, "base64").toString("base64");
|
|
@@ -71,6 +71,26 @@ function plivoV3Signature(params: {
|
|
|
71
71
|
return canonicalizeBase64(digest);
|
|
72
72
|
}
|
|
73
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
|
+
|
|
74
94
|
describe("verifyPlivoWebhook", () => {
|
|
75
95
|
it("accepts valid V2 signature", () => {
|
|
76
96
|
const authToken = "test-auth-token";
|
|
@@ -154,3 +174,35 @@ describe("verifyPlivoWebhook", () => {
|
|
|
154
174
|
});
|
|
155
175
|
});
|
|
156
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
|
+
});
|
package/src/webhook-security.ts
CHANGED
|
@@ -25,7 +25,7 @@ export function validateTwilioSignature(
|
|
|
25
25
|
|
|
26
26
|
// Sort params alphabetically and append key+value
|
|
27
27
|
const sortedParams = Array.from(params.entries()).sort((a, b) =>
|
|
28
|
-
a[0]
|
|
28
|
+
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
|
|
29
29
|
);
|
|
30
30
|
|
|
31
31
|
for (const [key, value] of sortedParams) {
|
|
@@ -98,6 +98,25 @@ export function reconstructWebhookUrl(ctx: WebhookContext): string {
|
|
|
98
98
|
return `${proto}://${host}${path}`;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
function buildTwilioVerificationUrl(
|
|
102
|
+
ctx: WebhookContext,
|
|
103
|
+
publicUrl?: string,
|
|
104
|
+
): string {
|
|
105
|
+
if (!publicUrl) {
|
|
106
|
+
return reconstructWebhookUrl(ctx);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const base = new URL(publicUrl);
|
|
111
|
+
const requestUrl = new URL(ctx.url);
|
|
112
|
+
base.pathname = requestUrl.pathname;
|
|
113
|
+
base.search = requestUrl.search;
|
|
114
|
+
return base.toString();
|
|
115
|
+
} catch {
|
|
116
|
+
return publicUrl;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
101
120
|
/**
|
|
102
121
|
* Get a header value, handling both string and string[] types.
|
|
103
122
|
*/
|
|
@@ -154,7 +173,7 @@ export function verifyTwilioWebhook(
|
|
|
154
173
|
}
|
|
155
174
|
|
|
156
175
|
// Reconstruct the URL Twilio used
|
|
157
|
-
const verificationUrl = options?.publicUrl
|
|
176
|
+
const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl);
|
|
158
177
|
|
|
159
178
|
// Parse the body as URL-encoded params
|
|
160
179
|
const params = new URLSearchParams(ctx.rawBody);
|