diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index ad63cf1f52a..1b887707119 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -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)", diff --git a/extensions/voice-call/openclaw.plugin.json b/extensions/voice-call/openclaw.plugin.json index ff85a30a947..0076fc3c479 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,14 @@ }, "authToken": { "type": "string" + }, + "region": { + "type": "string", + "pattern": "^[a-z0-9]+$" + }, + "edge": { + "type": "string", + "pattern": "^[a-z0-9-]+$" } } }, diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index 5ecd4f01bd3..dd15c37b6f6 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -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; @@ -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 diff --git a/extensions/voice-call/src/providers/twilio.test.ts b/extensions/voice-call/src/providers/twilio.test.ts index 4e23783b93a..e0e17a0d66e 100644 --- a/extensions/voice-call/src/providers/twilio.test.ts +++ b/extensions/voice-call/src/providers/twilio.test.ts @@ -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(' { + 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 e09367eb3fa..92d3f1c9b1a 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -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 = { + 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", }); diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index 384ac209a76..8a038c2b152 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -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, diff --git a/skills/voice-call/SKILL.md b/skills/voice-call/SKILL.md index 7cfb5769252..bfc0a516596 100644 --- a/skills/voice-call/SKILL.md +++ b/skills/voice-call/SKILL.md @@ -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).