From cb7edb669faf84fc6991fff8ca011efe7c092fec Mon Sep 17 00:00:00 2001 From: Ghost Date: Sun, 18 Jan 2026 18:59:58 -0800 Subject: [PATCH 1/4] Voice-call: fix tailscale tunnel path --- extensions/voice-call/src/tunnel.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/extensions/voice-call/src/tunnel.ts b/extensions/voice-call/src/tunnel.ts index ad69eebf0..973c7b70f 100644 --- a/extensions/voice-call/src/tunnel.ts +++ b/extensions/voice-call/src/tunnel.ts @@ -230,12 +230,13 @@ export async function startTailscaleTunnel(config: { throw new Error("Could not get Tailscale DNS name. Is Tailscale running?"); } - const localUrl = `http://127.0.0.1:${config.port}`; + const path = config.path.startsWith("/") ? config.path : `/${config.path}`; + const localUrl = `http://127.0.0.1:${config.port}${path}`; return new Promise((resolve, reject) => { const proc = spawn( "tailscale", - [config.mode, "--bg", "--yes", "--set-path", config.path, localUrl], + [config.mode, "--bg", "--yes", "--set-path", path, localUrl], { stdio: ["ignore", "pipe", "pipe"] }, ); @@ -247,7 +248,7 @@ export async function startTailscaleTunnel(config: { proc.on("close", (code) => { clearTimeout(timeout); if (code === 0) { - const publicUrl = `https://${dnsName}${config.path}`; + const publicUrl = `https://${dnsName}${path}`; console.log( `[voice-call] Tailscale ${config.mode} active: ${publicUrl}`, ); @@ -256,7 +257,7 @@ export async function startTailscaleTunnel(config: { publicUrl, provider: `tailscale-${config.mode}`, stop: async () => { - await stopTailscaleTunnel(config.mode, config.path); + await stopTailscaleTunnel(config.mode, path); }, }); } else { From b04b51d2c4bbc9a595336b8c5f9f66efc2f4c1e6 Mon Sep 17 00:00:00 2001 From: Ghost Date: Sun, 18 Jan 2026 20:03:13 -0800 Subject: [PATCH 2/4] Voice-call: fix Twilio signature ordering --- extensions/voice-call/src/webhook-security.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index 2471d8841..79bd96099 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -25,7 +25,7 @@ export function validateTwilioSignature( // Sort params alphabetically and append key+value const sortedParams = Array.from(params.entries()).sort((a, b) => - a[0].localeCompare(b[0]), + a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0, ); for (const [key, value] of sortedParams) { From 60b87826bbed38db44526377776bc2502d93a17c Mon Sep 17 00:00:00 2001 From: Ghost Date: Sun, 18 Jan 2026 20:20:53 -0800 Subject: [PATCH 3/4] Voice-call: fix Twilio status callbacks --- extensions/voice-call/src/providers/twilio.ts | 4 ++-- .../voice-call/src/providers/twilio/api.ts | 22 ++++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 62a65eb4c..6e68556e3 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -389,12 +389,12 @@ export class TwilioProvider implements VoiceCallProvider { // Build request params - always use URL-based TwiML. // Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter. - const params: Record = { + const params: Record = { To: input.to, From: input.from, Url: url.toString(), // TwiML serving endpoint StatusCallback: statusUrl.toString(), // Separate status callback endpoint - StatusCallbackEvent: "initiated ringing answered completed", + StatusCallbackEvent: ["initiated", "ringing", "answered", "completed"], Timeout: "30", }; diff --git a/extensions/voice-call/src/providers/twilio/api.ts b/extensions/voice-call/src/providers/twilio/api.ts index 5cf9cfd28..9fcb202a8 100644 --- a/extensions/voice-call/src/providers/twilio/api.ts +++ b/extensions/voice-call/src/providers/twilio/api.ts @@ -3,16 +3,33 @@ export async function twilioApiRequest(params: { accountSid: string; authToken: string; endpoint: string; - body: Record; + body: URLSearchParams | Record; allowNotFound?: boolean; }): Promise { + const bodyParams = + params.body instanceof URLSearchParams + ? params.body + : Object.entries(params.body).reduce( + (acc, [key, value]) => { + if (Array.isArray(value)) { + for (const entry of value) { + acc.append(key, entry); + } + } else if (typeof value === "string") { + acc.append(key, value); + } + return acc; + }, + new URLSearchParams(), + ); + const response = await fetch(`${params.baseUrl}${params.endpoint}`, { method: "POST", headers: { Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`, "Content-Type": "application/x-www-form-urlencoded", }, - body: new URLSearchParams(params.body), + body: bodyParams, }); if (!response.ok) { @@ -26,4 +43,3 @@ export async function twilioApiRequest(params: { const text = await response.text(); return text ? (JSON.parse(text) as T) : (undefined as T); } - From 80dae2e5e88d948c8d6bafc52843bc136cd010b0 Mon Sep 17 00:00:00 2001 From: Ghost Date: Sun, 18 Jan 2026 20:27:23 -0800 Subject: [PATCH 4/4] Voice-call: avoid streaming on notify callbacks --- extensions/voice-call/src/providers/twilio.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 6e68556e3..06115d662 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -64,6 +64,8 @@ export class TwilioProvider implements VoiceCallProvider { /** Storage for TwiML content (for notify mode with URL-based TwiML) */ private readonly twimlStorage = new Map(); + /** Track notify-mode calls to avoid streaming on follow-up callbacks */ + private readonly notifyCalls = new Set(); /** * Delete stored TwiML for a given `callId`. @@ -73,6 +75,7 @@ export class TwilioProvider implements VoiceCallProvider { */ private deleteStoredTwiml(callId: string): void { this.twimlStorage.delete(callId); + this.notifyCalls.delete(callId); } /** @@ -137,7 +140,7 @@ export class TwilioProvider implements VoiceCallProvider { */ private async apiRequest( endpoint: string, - params: Record, + params: Record, options?: { allowNotFound?: boolean }, ): Promise { return await twilioApiRequest({ @@ -286,6 +289,9 @@ export class TwilioProvider implements VoiceCallProvider { if (!ctx) return TwilioProvider.EMPTY_TWIML; const params = new URLSearchParams(ctx.rawBody); + const type = + typeof ctx.query?.type === "string" ? ctx.query.type.trim() : undefined; + const isStatusCallback = type === "status"; const callStatus = params.get("CallStatus"); const direction = params.get("Direction"); const callIdFromQuery = @@ -297,13 +303,21 @@ export class TwilioProvider implements VoiceCallProvider { // Handle initial TwiML request (when Twilio first initiates the call) // Check if we have stored TwiML for this call (notify mode) - if (callIdFromQuery) { + if (callIdFromQuery && !isStatusCallback) { const storedTwiml = this.twimlStorage.get(callIdFromQuery); if (storedTwiml) { // Clean up after serving (one-time use) this.deleteStoredTwiml(callIdFromQuery); return storedTwiml; } + if (this.notifyCalls.has(callIdFromQuery)) { + return TwilioProvider.EMPTY_TWIML; + } + } + + // Status callbacks should not receive TwiML. + if (isStatusCallback) { + return TwilioProvider.EMPTY_TWIML; } // Handle subsequent webhook requests (status callbacks, etc.) @@ -385,6 +399,7 @@ export class TwilioProvider implements VoiceCallProvider { // We now serve it from the webhook endpoint instead of sending inline if (input.inlineTwiml) { this.twimlStorage.set(input.callId, input.inlineTwiml); + this.notifyCalls.add(input.callId); } // Build request params - always use URL-based TwiML.