Merge pull request #1213 from andrew-kurin/fix/voicecall-tailscale-path

Voice-call: fix tailscale tunnel, Twilio signatures, and callbacks
This commit is contained in:
Peter Steinberger
2026-01-19 06:00:33 +00:00
committed by GitHub
4 changed files with 44 additions and 12 deletions

View File

@@ -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<string, string>();
/** Track notify-mode calls to avoid streaming on follow-up callbacks */
private readonly notifyCalls = new Set<string>();
/**
* 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<T = unknown>(
endpoint: string,
params: Record<string, string>,
params: Record<string, string | string[]>,
options?: { allowNotFound?: boolean },
): Promise<T> {
return await twilioApiRequest<T>({
@@ -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,16 +399,17 @@ 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.
// Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter.
const params: Record<string, string> = {
const params: Record<string, string | string[]> = {
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",
};

View File

@@ -3,16 +3,33 @@ export async function twilioApiRequest<T = unknown>(params: {
accountSid: string;
authToken: string;
endpoint: string;
body: Record<string, string>;
body: URLSearchParams | Record<string, string | string[]>;
allowNotFound?: boolean;
}): Promise<T> {
const bodyParams =
params.body instanceof URLSearchParams
? params.body
: Object.entries(params.body).reduce<URLSearchParams>(
(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<T = unknown>(params: {
const text = await response.text();
return text ? (JSON.parse(text) as T) : (undefined as T);
}

View File

@@ -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 {

View File

@@ -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) {