From 1d1a9f2ec02b76627527203efe7aa92e1e39243f Mon Sep 17 00:00:00 2001 From: giumex Date: Wed, 18 Feb 2026 13:39:01 +0100 Subject: [PATCH 1/5] feat(voice-call): add Twilio non-US region support (region/edge config) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- extensions/voice-call/openclaw.plugin.json | 16 +++++ extensions/voice-call/src/config.ts | 24 ++++++- .../voice-call/src/providers/twilio.test.ts | 68 +++++++++++++++++++ extensions/voice-call/src/providers/twilio.ts | 57 +++++++++++++++- extensions/voice-call/src/runtime.ts | 2 + 5 files changed, 165 insertions(+), 2 deletions(-) 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, From 5f3b4b17c67a862b3bf3afedc2dc40791181c4aa Mon Sep 17 00:00:00 2001 From: Giuliano Date: Sat, 28 Feb 2026 13:17:47 +0000 Subject: [PATCH 2/5] Update extensions/voice-call/src/config.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- extensions/voice-call/src/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index ab3c91e4477..b7b1ab80f69 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -65,7 +65,7 @@ export const TwilioConfigSchema = z * @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(), + 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. @@ -73,7 +73,7 @@ export const TwilioConfigSchema = z * 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(), + edge: z.string().regex(/^[a-z-]+$/).optional(), }) .strict(); export type TwilioConfig = z.infer; From e5efc5bb870dedf3282c55f1ba337675afb9a846 Mon Sep 17 00:00:00 2001 From: giumex Date: Sat, 28 Feb 2026 14:38:31 +0100 Subject: [PATCH 3/5] style(voice-call): fix oxfmt formatting in config.ts Break chained .string().regex().optional() calls across multiple lines to satisfy oxfmt --check. --- extensions/voice-call/src/config.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index b7b1ab80f69..dd74b3d4a5c 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -65,7 +65,10 @@ export const TwilioConfigSchema = z * @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(), + 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. @@ -73,7 +76,10 @@ export const TwilioConfigSchema = z * 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-z-]+$/).optional(), + edge: z + .string() + .regex(/^[a-z-]+$/) + .optional(), }) .strict(); export type TwilioConfig = z.infer; From 91d1065f52a0f8dca8a440f85935f35a1094aae4 Mon Sep 17 00:00:00 2001 From: giumex Date: Sat, 28 Feb 2026 14:51:55 +0100 Subject: [PATCH 4/5] fix(voice-call): add missing region/edge uiHints, schema patterns, docs - Add twilio.region and twilio.edge uiHints to index.ts (was only in openclaw.plugin.json) - Add pattern constraints to JSON schema to match Zod validation - Allow digits in edge regex (^[a-z0-9-]+$) for future-proofing - Mention region/edge in SKILL.md Twilio config line --- extensions/voice-call/index.ts | 10 ++++++++++ extensions/voice-call/openclaw.plugin.json | 6 ++++-- extensions/voice-call/src/config.ts | 2 +- skills/voice-call/SKILL.md | 2 +- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index d110dcc9c24..8947de7e59f 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -46,6 +46,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 2a15ac647fd..8e80d5b3a11 100644 --- a/extensions/voice-call/openclaw.plugin.json +++ b/extensions/voice-call/openclaw.plugin.json @@ -206,10 +206,12 @@ "type": "string" }, "region": { - "type": "string" + "type": "string", + "pattern": "^[a-z0-9]+$" }, "edge": { - "type": "string" + "type": "string", + "pattern": "^[a-z0-9-]+$" } } }, diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index dd74b3d4a5c..26852d2b3d0 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -78,7 +78,7 @@ export const TwilioConfigSchema = z */ edge: z .string() - .regex(/^[a-z-]+$/) + .regex(/^[a-z0-9-]+$/) .optional(), }) .strict(); 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). From f3928a9ebfce567584ab1fd2abc3730f6ff5696c Mon Sep 17 00:00:00 2001 From: Giuliano Date: Fri, 6 Mar 2026 20:52:09 +0100 Subject: [PATCH 5/5] fix(voice-call): derive SSRF allowedHostnames from baseUrl for non-US regions getCallStatus had a hardcoded allowedHostnames of ['api.twilio.com'], which causes the SSRF guard to reject requests for non-US regional endpoints (e.g. api.dublin.ie1.twilio.com). The error was silently swallowed, breaking call status polling for all non-US region deployments. Derive the hostname dynamically from this.baseUrl instead. The baseUrl is constructed from Zod-validated config (region/edge with regex constraints), so SSRF protection is preserved. --- extensions/voice-call/src/providers/twilio.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 523b3082c82..92d3f1c9b1a 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -731,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", });