Merge d74c96ce9a6c68a0b9b04810d1062103b99bb728 into 5bb5d7dab4b29e68b15bb7665d0736f46499a35c

This commit is contained in:
Giuliano 2026-03-20 22:36:00 -07:00 committed by GitHub
commit fd34f441c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 184 additions and 4 deletions

View File

@ -50,6 +50,16 @@ const voiceCallConfigSchema = {
"telnyx.publicKey": { label: "Telnyx Public Key", sensitive: true },
"twilio.accountSid": { label: "Twilio Account SID" },
"twilio.authToken": { label: "Twilio Auth Token", sensitive: true },
"twilio.region": {
label: "Twilio Region",
help: "Processing region: ie1 (Ireland) or au1 (Australia). Omit for US1 (default). Requires region-specific auth credentials.",
advanced: true,
},
"twilio.edge": {
label: "Twilio Edge Location",
help: "Edge location (e.g. dublin, sydney, ashburn). Auto-inferred from region if omitted.",
advanced: true,
},
"outbound.defaultMode": { label: "Default Call Mode" },
"outbound.notifyHangupDelaySec": {
label: "Notify Hangup Delay (sec)",

View File

@ -41,6 +41,16 @@
"label": "Twilio Auth Token",
"sensitive": true
},
"twilio.region": {
"label": "Twilio Region",
"help": "Processing region: ie1 (Ireland) or au1 (Australia). Omit for US1 (default). Requires region-specific auth credentials.",
"advanced": true
},
"twilio.edge": {
"label": "Twilio Edge Location",
"help": "Edge location (e.g. dublin, sydney, ashburn). Auto-inferred from region if omitted.",
"advanced": true
},
"outbound.defaultMode": {
"label": "Default Call Mode"
},
@ -194,6 +204,14 @@
},
"authToken": {
"type": "string"
},
"region": {
"type": "string",
"pattern": "^[a-z0-9]+$"
},
"edge": {
"type": "string",
"pattern": "^[a-z0-9-]+$"
}
}
},

View File

@ -48,8 +48,34 @@ export const TwilioConfigSchema = z
.object({
/** Twilio Account SID */
accountSid: z.string().min(1).optional(),
/** Twilio Auth Token */
/** Twilio Auth Token (must match the target region — each region has its own credentials) */
authToken: z.string().min(1).optional(),
/**
* Twilio Region for data processing (e.g. "ie1" for Ireland, "au1" for Australia).
* When set, API calls target `api.{edge}.{region}.twilio.com` instead of the
* default US1 endpoint (`api.twilio.com`). Requires region-specific auth credentials.
*
* Supported processing regions: "ie1" (Ireland), "au1" (Australia).
* US1 is the default when region is not set.
*
* @see https://www.twilio.com/docs/global-infrastructure/understanding-twilio-regions
* @see https://www.twilio.com/docs/global-infrastructure/api-domain-migration-guide
*/
region: z
.string()
.regex(/^[a-z0-9]+$/)
.optional(),
/**
* Twilio Edge Location (e.g. "dublin", "sydney", "ashburn").
* Used together with `region` to construct the API FQDN.
* Auto-inferred from region if omitted (ie1dublin, au1sydney).
* Must be specified with `region` to avoid the deprecated domain pattern.
* @see https://www.twilio.com/docs/global-infrastructure/understanding-edge-locations
*/
edge: z
.string()
.regex(/^[a-z0-9-]+$/)
.optional(),
})
.strict();
export type TwilioConfig = z.infer<typeof TwilioConfigSchema>;
@ -418,6 +444,8 @@ export function resolveVoiceCallConfig(config: VoiceCallConfigInput): VoiceCallC
resolved.twilio = resolved.twilio ?? {};
resolved.twilio.accountSid = resolved.twilio.accountSid ?? process.env.TWILIO_ACCOUNT_SID;
resolved.twilio.authToken = resolved.twilio.authToken ?? process.env.TWILIO_AUTH_TOKEN;
resolved.twilio.region = resolved.twilio.region ?? process.env.TWILIO_REGION;
resolved.twilio.edge = resolved.twilio.edge ?? process.env.TWILIO_EDGE;
}
// Plivo

View File

@ -21,6 +21,63 @@ function createContext(rawBody: string, query?: WebhookContext["query"]): Webhoo
};
}
describe("TwilioProvider.buildBaseUrl", () => {
it("defaults to US1 when no region is specified", () => {
expect(TwilioProvider.buildBaseUrl("AC123")).toBe(
"https://api.twilio.com/2010-04-01/Accounts/AC123",
);
});
it("defaults to US1 when region and edge are both undefined", () => {
expect(TwilioProvider.buildBaseUrl("AC123", undefined, undefined)).toBe(
"https://api.twilio.com/2010-04-01/Accounts/AC123",
);
});
it("builds IE1 Dublin URL when region=ie1 and edge=dublin", () => {
expect(TwilioProvider.buildBaseUrl("AC123", "ie1", "dublin")).toBe(
"https://api.dublin.ie1.twilio.com/2010-04-01/Accounts/AC123",
);
});
it("builds AU1 Sydney URL when region=au1 and edge=sydney", () => {
expect(TwilioProvider.buildBaseUrl("AC123", "au1", "sydney")).toBe(
"https://api.sydney.au1.twilio.com/2010-04-01/Accounts/AC123",
);
});
it("builds URL with explicit edge and region for any combination", () => {
// Even for non-standard combinations, both edge and region are used
expect(TwilioProvider.buildBaseUrl("AC123", "jp1", "tokyo")).toBe(
"https://api.tokyo.jp1.twilio.com/2010-04-01/Accounts/AC123",
);
});
it("infers default edge for supported processing regions when edge is omitted", () => {
expect(TwilioProvider.buildBaseUrl("AC123", "ie1")).toBe(
"https://api.dublin.ie1.twilio.com/2010-04-01/Accounts/AC123",
);
expect(TwilioProvider.buildBaseUrl("AC123", "au1")).toBe(
"https://api.sydney.au1.twilio.com/2010-04-01/Accounts/AC123",
);
expect(TwilioProvider.buildBaseUrl("AC123", "us1")).toBe(
"https://api.ashburn.us1.twilio.com/2010-04-01/Accounts/AC123",
);
});
it("falls back to ashburn edge for unknown regions", () => {
expect(TwilioProvider.buildBaseUrl("AC123", "xx9")).toBe(
"https://api.ashburn.xx9.twilio.com/2010-04-01/Accounts/AC123",
);
});
it("allows overriding the default edge for a region", () => {
// Use ashburn edge with IE1 region (unusual but valid)
expect(TwilioProvider.buildBaseUrl("AC123", "ie1", "ashburn")).toBe(
"https://api.ashburn.ie1.twilio.com/2010-04-01/Accounts/AC123",
);
});
});
function expectStreamingTwiml(body: string) {
expect(body).toContain(STREAM_URL);
expect(body).toContain('<Parameter name="token" value="');
@ -28,6 +85,16 @@ function expectStreamingTwiml(body: string) {
}
describe("TwilioProvider", () => {
it("uses regional base URL when region/edge are configured", () => {
const provider = new TwilioProvider(
{ accountSid: "AC123", authToken: "secret", region: "ie1", edge: "dublin" },
{ publicUrl: "https://example.ngrok.app", streamPath: "/voice/stream" },
);
// Provider should have constructed the correct regional base URL internally.
// We verify indirectly: the provider should initialize without error.
expect(provider.name).toBe("twilio");
});
it("returns streaming TwiML for outbound conversation calls before in-progress", () => {
const provider = createProvider();
const ctx = createContext("CallStatus=initiated&Direction=outbound-api&CallSid=CA123", {

View File

@ -144,7 +144,7 @@ export class TwilioProvider implements VoiceCallProvider {
this.accountSid = config.accountSid;
this.authToken = config.authToken;
this.baseUrl = `https://api.twilio.com/2010-04-01/Accounts/${this.accountSid}`;
this.baseUrl = TwilioProvider.buildBaseUrl(this.accountSid, config.region, config.edge);
this.options = options;
if (options.publicUrl) {
@ -152,6 +152,61 @@ export class TwilioProvider implements VoiceCallProvider {
}
}
/**
* Build the Twilio API base URL for a given region/edge combination.
*
* FQDN format: `{product}.{edge}.{region}.twilio.com`
*
* When both region and edge are specified, targets that specific region.
* When only region is specified, edge defaults to the canonical edge for that region.
* When neither is specified, falls back to the default US1 endpoint (`api.twilio.com`).
*
* Supported processing regions (as of 2026):
* - **us1** (default) United States
* - **ie1** Ireland (edge: dublin)
* - **au1** Australia (edge: sydney)
*
* Note: The legacy `api.{region}.twilio.com` pattern (without edge) is deprecated
* and will stop working on April 28, 2026. This method always includes the edge
* in the FQDN to use the correct, non-deprecated format.
*
* @see https://www.twilio.com/docs/global-infrastructure/using-the-twilio-rest-api-in-a-non-us-region
* @see https://www.twilio.com/docs/global-infrastructure/api-domain-migration-guide
*/
static buildBaseUrl(accountSid: string, region?: string, edge?: string): string {
if (!region) {
// Default US1 endpoint — no region/edge needed
return `https://api.twilio.com/2010-04-01/Accounts/${accountSid}`;
}
// When region is set, edge must also be specified to avoid the deprecated
// api.{region}.twilio.com pattern (which routes to US1 and stops working
// April 28, 2026). If the user omitted edge, infer the canonical one.
const resolvedEdge = edge || TwilioProvider.defaultEdgeForRegion(region);
const host = `api.${resolvedEdge}.${region}.twilio.com`;
return `https://${host}/2010-04-01/Accounts/${accountSid}`;
}
/**
* Map Twilio processing regions to their canonical edge location.
* Used as fallback when `edge` is not explicitly configured.
*
* Only includes regions that support regional data processing.
* Legacy region codes (br1, de1, jp1, sg1, us2) are NOT included
* those were edge-only shortcuts that always processed in US1 and are
* deprecated as of April 28, 2026.
*
* @see https://www.twilio.com/docs/global-infrastructure/api-domain-migration-guide
*/
private static defaultEdgeForRegion(region: string): string {
const defaults: Record<string, string> = {
ie1: "dublin",
au1: "sydney",
us1: "ashburn",
};
return defaults[region.toLowerCase()] ?? "ashburn";
}
setPublicUrl(url: string): void {
this.currentPublicUrl = url;
}
@ -676,7 +731,7 @@ export class TwilioProvider implements VoiceCallProvider {
Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString("base64")}`,
},
allowNotFound: true,
allowedHostnames: ["api.twilio.com"],
allowedHostnames: [new URL(this.baseUrl).hostname],
auditContext: "twilio-get-call-status",
errorPrefix: "Twilio get call status error",
});

View File

@ -103,6 +103,8 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
{
accountSid: config.twilio?.accountSid,
authToken: config.twilio?.authToken,
region: config.twilio?.region,
edge: config.twilio?.edge,
},
{
allowNgrokFreeTierLoopbackBypass,

View File

@ -39,7 +39,7 @@ Notes:
- Requires the voice-call plugin to be enabled.
- Plugin config lives under `plugins.entries.voice-call.config`.
- Twilio config: `provider: "twilio"` + `twilio.accountSid/authToken` + `fromNumber`.
- Twilio config: `provider: "twilio"` + `twilio.accountSid/authToken` + `fromNumber`. Optional `twilio.region` (ie1, au1) and `twilio.edge` (dublin, sydney) for non-US regions.
- Telnyx config: `provider: "telnyx"` + `telnyx.apiKey/connectionId` + `fromNumber`.
- Plivo config: `provider: "plivo"` + `plivo.authId/authToken` + `fromNumber`.
- Dev fallback: `provider: "mock"` (no network).