Merge d74c96ce9a6c68a0b9b04810d1062103b99bb728 into 5bb5d7dab4b29e68b15bb7665d0736f46499a35c
This commit is contained in:
commit
fd34f441c1
@ -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)",
|
||||
|
||||
@ -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-]+$"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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 (ie1→dublin, au1→sydney).
|
||||
* 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
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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).
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user