@clawdbot/voice-call 2026.1.16 → 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 CHANGED
@@ -1,5 +1,20 @@
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
+
3
18
  ## 2026.1.16
4
19
 
5
20
  ### 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.16",
3
+ "version": "2026.1.20",
4
4
  "type": "module",
5
5
  "description": "Clawdbot voice-call plugin",
6
6
  "dependencies": {
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.object({
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.object({
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.object({
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.object({
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: new URLSearchParams(params.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
-
@@ -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
- console.log(
259
- `[voice-call] generateTwimlResponse: status=${callStatus} direction=${direction}`,
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
- // Build request params
332
- const params: Record<string, string> = {
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
- StatusCallback: url.toString(),
336
- StatusCallbackEvent: "initiated ringing answered completed",
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 localUrl = `http://127.0.0.1:${config.port}`;
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", config.path, localUrl],
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}${config.path}`;
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, config.path);
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
+ });
@@ -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].localeCompare(b[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 || reconstructWebhookUrl(ctx);
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);