diff --git a/extensions/voice-call/openclaw.plugin.json b/extensions/voice-call/openclaw.plugin.json index 04f50218fa6..2a15ac647fd 100644 --- a/extensions/voice-call/openclaw.plugin.json +++ b/extensions/voice-call/openclaw.plugin.json @@ -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" } } }, diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index 68b197c09bb..ab3c91e4477 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -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; @@ -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 diff --git a/extensions/voice-call/src/providers/twilio.test.ts b/extensions/voice-call/src/providers/twilio.test.ts index 3a5652a3563..9a85005052a 100644 --- a/extensions/voice-call/src/providers/twilio.test.ts +++ b/extensions/voice-call/src/providers/twilio.test.ts @@ -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", { diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 45031c35142..0f2b78b629d 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -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 = { + ie1: "dublin", + au1: "sydney", + us1: "ashburn", + }; + return defaults[region.toLowerCase()] ?? "ashburn"; + } + setPublicUrl(url: string): void { this.currentPublicUrl = url; } diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index 19ea3b30b13..734b0233cac 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -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,