feat(voice-call): add Twilio non-US region support (region/edge config)
Add optional `region` and `edge` fields to the Twilio provider config,
enabling EU (IE1/Dublin) and other non-US Twilio regions.
When configured, API requests target `api.{edge}.{region}.twilio.com`
instead of the default US1 endpoint. If only `region` is set, the edge
is auto-inferred (ie1→dublin, au1→sydney).
Supported processing regions: ie1 (Ireland), au1 (Australia).
US1 is the default when region is omitted.
Changes:
- openclaw.plugin.json: Add region/edge to configSchema + uiHints
- config.ts: Add region/edge to TwilioConfigSchema + env var resolution
(TWILIO_REGION, TWILIO_EDGE)
- providers/twilio.ts: Static buildBaseUrl() for dynamic FQDN construction
with defaultEdgeForRegion() fallback for supported regions only
- runtime.ts: Forward region/edge from config to TwilioProvider constructor
- providers/twilio.test.ts: 9 new tests for URL construction
Fully backward compatible — no region/edge = US1 as before.
Legacy region codes (br1, de1, jp1, sg1, us2) are intentionally excluded
from the default edge map per Twilio's API domain migration guide.
Ref: https://www.twilio.com/docs/global-infrastructure/api-domain-migration-guide
Ref: https://www.twilio.com/docs/global-infrastructure/using-the-twilio-rest-api-in-a-non-us-region
This commit is contained in:
parent
514e318df9
commit
1d1a9f2ec0
@ -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,12 @@
|
||||
},
|
||||
"authToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"region": {
|
||||
"type": "string"
|
||||
},
|
||||
"edge": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -52,8 +52,28 @@ 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().min(1).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().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
export type TwilioConfig = z.infer<typeof TwilioConfigSchema>;
|
||||
@ -360,6 +380,8 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
|
||||
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,7 +21,75 @@ 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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", {
|
||||
|
||||
@ -111,7 +111,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) {
|
||||
@ -119,6 +119,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;
|
||||
}
|
||||
|
||||
@ -63,6 +63,8 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
|
||||
{
|
||||
accountSid: config.twilio?.accountSid,
|
||||
authToken: config.twilio?.authToken,
|
||||
region: config.twilio?.region,
|
||||
edge: config.twilio?.edge,
|
||||
},
|
||||
{
|
||||
allowNgrokFreeTierLoopbackBypass,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user