Skip to content

Commit 797495e

Browse files
jodoksteipete
authored andcommitted
feat(voice-call): support Twilio regions (openclaw#95832)
Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Jodok Batlogg <158125+jodok@users.noreply.github.com>
1 parent db2a1cf commit 797495e

12 files changed

Lines changed: 125 additions & 26 deletions

File tree

docs/plugins/voice-call.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ Voice-call credentials accept SecretRefs. `plugins.entries.voice-call.config.twi
121121
twilio: {
122122
accountSid: "ACxxxxxxxx",
123123
authToken: "...",
124+
// region: "ie1", // optional: us1 | ie1 | au1; defaults to us1
124125
},
125126
telnyx: {
126127
apiKey: "...",
@@ -187,6 +188,11 @@ Top-level keys under `plugins.entries.voice-call.config` not shown above:
187188
| `responseSystemPrompt` | generated | Custom system prompt for classic responses. |
188189
| `responseTimeoutMs` | `30000` | Timeout for classic response generation (ms). |
189190

191+
Twilio defaults to its US1 REST endpoint. To process calls in a supported
192+
non-US Region, set `twilio.region` to `ie1` or `au1` and use credentials from
193+
that Region. See
194+
[Twilio's non-US REST API guide](https://www.twilio.com/docs/global-infrastructure/using-the-twilio-rest-api-in-a-non-us-region).
195+
190196
<AccordionGroup>
191197
<Accordion title="Provider exposure and security notes">
192198
- Twilio, Telnyx, and Plivo all require a **publicly reachable** webhook URL.

extensions/voice-call/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ Put under `plugins.entries.voice-call.config`:
9898
Notes:
9999

100100
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
101+
- Twilio defaults to US1. For a non-US Region, set `twilio.region` to `ie1` or `au1` and use credentials created in that Region; see [Twilio's regional REST API guide](https://www.twilio.com/docs/global-infrastructure/using-the-twilio-rest-api-in-a-non-us-region).
101102
- `mock` is a local dev provider (no network calls).
102103
- Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true.
103104
- If older configs still use `provider: "log"`, `twilio.from`, or legacy `streaming.*` OpenAI keys, run `openclaw doctor --fix` to rewrite them.

extensions/voice-call/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const voiceCallConfigSchema = {
5959
"telnyx.publicKey": { label: "Telnyx Public Key", sensitive: true },
6060
"twilio.accountSid": { label: "Twilio Account SID" },
6161
"twilio.authToken": { label: "Twilio Auth Token", sensitive: true },
62+
"twilio.region": { label: "Twilio Region", advanced: true },
6263
"outbound.defaultMode": { label: "Default Call Mode" },
6364
"outbound.notifyHangupDelaySec": {
6465
label: "Notify Hangup Delay (sec)",

extensions/voice-call/openclaw.plugin.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@
7171
"label": "Twilio Auth Token",
7272
"sensitive": true
7373
},
74+
"twilio.region": {
75+
"label": "Twilio Region",
76+
"advanced": true
77+
},
7478
"outbound.defaultMode": {
7579
"label": "Default Call Mode"
7680
},
@@ -286,6 +290,10 @@
286290
},
287291
"authToken": {
288292
"type": ["string", "object"]
293+
},
294+
"region": {
295+
"type": "string",
296+
"enum": ["us1", "ie1", "au1"]
289297
}
290298
}
291299
},

extensions/voice-call/src/config.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,30 @@ describe("validateProviderConfig", () => {
9292
});
9393

9494
describe("twilio provider", () => {
95+
it("accepts supported Twilio Regions and rejects unknown ones", () => {
96+
const baseConfig = {
97+
enabled: true,
98+
provider: "twilio",
99+
fromNumber: "+15550001234",
100+
twilio: {
101+
accountSid: "AC123",
102+
authToken: "secret",
103+
},
104+
} as const;
105+
106+
const regional = VoiceCallConfigSchema.parse({
107+
...baseConfig,
108+
twilio: { ...baseConfig.twilio, region: "ie1" },
109+
});
110+
expect(regional.twilio?.region).toBe("ie1");
111+
expect(
112+
VoiceCallConfigSchema.safeParse({
113+
...baseConfig,
114+
twilio: { ...baseConfig.twilio, region: "de1" },
115+
}).success,
116+
).toBe(false);
117+
});
118+
95119
it("accepts SecretRef-backed auth tokens before runtime resolution", () => {
96120
const config = VoiceCallConfigSchema.parse({
97121
enabled: true,

extensions/voice-call/src/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
1515
import { z } from "zod";
1616
import { TtsConfigSchema } from "../api.js";
1717
import { deepMergeDefined } from "./deep-merge.js";
18+
import { TWILIO_REGIONS } from "./providers/twilio-region.js";
1819
import { DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS } from "./realtime-defaults.js";
1920

2021
// -----------------------------------------------------------------------------
@@ -66,6 +67,8 @@ const TwilioConfigSchema = z
6667
accountSid: z.string().min(1).optional(),
6768
/** Twilio Auth Token */
6869
authToken: SecretInputSchema.optional(),
70+
/** Twilio processing Region (for example, ie1) */
71+
region: z.enum(TWILIO_REGIONS).optional(),
6972
})
7073
.strict();
7174

extensions/voice-call/src/providers/twilio-region.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,10 @@ const TWILIO_API_HOSTNAME_BY_REGION = {
88
au1: "api.sydney.au1.twilio.com",
99
} satisfies Record<TwilioRegion, string>;
1010

11-
const TWILIO_API_HOSTNAMES = new Set(Object.values(TWILIO_API_HOSTNAME_BY_REGION));
12-
13-
function resolveTwilioApiHostname(region?: TwilioRegion): string {
14-
return TWILIO_API_HOSTNAME_BY_REGION[region ?? "us1"];
15-
}
16-
1711
export function resolveTwilioApiBaseUrl(params: {
1812
accountSid: string;
1913
region?: TwilioRegion;
2014
}): string {
21-
const hostname = resolveTwilioApiHostname(params.region);
15+
const hostname = TWILIO_API_HOSTNAME_BY_REGION[params.region ?? "us1"];
2216
return `https://${hostname}/2010-04-01/Accounts/${params.accountSid}`;
2317
}
24-
25-
export function requireSupportedTwilioApiHostname(baseUrl: string): string {
26-
const hostname = new URL(baseUrl).hostname;
27-
if (!TWILIO_API_HOSTNAMES.has(hostname)) {
28-
throw new Error(`Unsupported Twilio API hostname: ${hostname}`);
29-
}
30-
return hostname;
31-
}

extensions/voice-call/src/providers/twilio.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
// Voice Call tests cover twilio plugin behavior.
22
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const { guardedJsonApiRequestMock } = vi.hoisted(() => ({
5+
guardedJsonApiRequestMock: vi.fn(),
6+
}));
7+
8+
vi.mock("./shared/guarded-json-api.js", () => ({
9+
guardedJsonApiRequest: guardedJsonApiRequestMock,
10+
}));
11+
312
import type { WebhookContext } from "../types.js";
413
import { TwilioProvider } from "./twilio.js";
514
import { TwilioApiError } from "./twilio/api.js";
@@ -8,6 +17,7 @@ const STREAM_URL = "wss://example.ngrok.app/voice/stream";
817

918
beforeEach(() => {
1019
vi.useRealTimers();
20+
guardedJsonApiRequestMock.mockReset();
1121
});
1222

1323
function createProvider(): TwilioProvider {
@@ -113,6 +123,26 @@ function configureTelephonyTwiMlFallback(params: { providerCallId: string; strea
113123
}
114124

115125
describe("TwilioProvider", () => {
126+
it("uses the derived regional hostname for call status", async () => {
127+
guardedJsonApiRequestMock.mockResolvedValue({ status: "completed" });
128+
const provider = new TwilioProvider({
129+
accountSid: "AC123",
130+
authToken: "secret",
131+
region: "ie1",
132+
});
133+
134+
await expect(provider.getCallStatus({ providerCallId: "CA123" })).resolves.toEqual({
135+
status: "completed",
136+
isTerminal: true,
137+
});
138+
expect(guardedJsonApiRequestMock).toHaveBeenCalledWith(
139+
expect.objectContaining({
140+
url: "https://api.dublin.ie1.twilio.com/2010-04-01/Accounts/AC123/Calls/CA123.json",
141+
allowedHostnames: ["api.dublin.ie1.twilio.com"],
142+
}),
143+
);
144+
});
145+
116146
it("sends direct initial TwiML for notify-mode outbound calls", async () => {
117147
const provider = createProvider();
118148
const apiRequest = createApiRequestMock(async () => ({ sid: "CA123", status: "queued" }));

extensions/voice-call/src/providers/twilio.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
normalizeProviderStatus,
3232
} from "./shared/call-status.js";
3333
import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
34+
import { resolveTwilioApiBaseUrl, type TwilioRegion } from "./twilio-region.js";
3435
import type { TwilioProviderOptions } from "./twilio.types.js";
3536
import { TwilioApiError, twilioApiRequest } from "./twilio/api.js";
3637
import { decideTwimlResponse, readTwimlRequestView } from "./twilio/twiml-policy.js";
@@ -72,6 +73,7 @@ type StreamSendResult = {
7273
type TwilioProviderConfig = {
7374
accountSid?: string;
7475
authToken?: string;
76+
region?: TwilioRegion;
7577
};
7678

7779
export class TwilioProvider implements VoiceCallProvider {
@@ -144,7 +146,10 @@ export class TwilioProvider implements VoiceCallProvider {
144146

145147
this.accountSid = config.accountSid;
146148
this.authToken = config.authToken;
147-
this.baseUrl = `https://api.twilio.com/2010-04-01/Accounts/${this.accountSid}`;
149+
this.baseUrl = resolveTwilioApiBaseUrl({
150+
accountSid: this.accountSid,
151+
region: config.region,
152+
});
148153
this.options = options;
149154

150155
if (options.publicUrl) {
@@ -830,7 +835,7 @@ export class TwilioProvider implements VoiceCallProvider {
830835
Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString("base64")}`,
831836
},
832837
allowNotFound: true,
833-
allowedHostnames: ["api.twilio.com"],
838+
allowedHostnames: [new URL(this.baseUrl).hostname],
834839
auditContext: "twilio-get-call-status",
835840
errorPrefix: "Twilio get call status error",
836841
});

extensions/voice-call/src/providers/twilio/api.test.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ vi.mock("../../../api.js", () => ({
99
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
1010
}));
1111

12+
import { resolveTwilioApiBaseUrl } from "../twilio-region.js";
1213
import { TwilioApiError, twilioApiRequest } from "./api.js";
1314

15+
const DEFAULT_BASE_URL = resolveTwilioApiBaseUrl({ accountSid: "AC123" });
16+
1417
type FetchGuardRequest = {
1518
url?: string;
1619
init?: RequestInit;
@@ -67,7 +70,7 @@ describe("twilioApiRequest", () => {
6770

6871
await expect(
6972
twilioApiRequest({
70-
baseUrl: "https://api.twilio.com",
73+
baseUrl: DEFAULT_BASE_URL,
7174
accountSid: "AC123",
7275
authToken: "secret",
7376
endpoint: "/Calls.json",
@@ -79,7 +82,7 @@ describe("twilioApiRequest", () => {
7982
).resolves.toEqual({ sid: "CA123" });
8083

8184
const { url, init, auditContext, policy, timeoutMs } = requireFirstFetchGuardRequest();
82-
expect(url).toBe("https://api.twilio.com/Calls.json");
85+
expect(url).toBe("https://api.twilio.com/2010-04-01/Accounts/AC123/Calls.json");
8386
expect(auditContext).toBe("voice-call.twilio.api");
8487
expect(policy).toEqual({ allowedHostnames: ["api.twilio.com"] });
8588
expect(timeoutMs).toBe(30_000);
@@ -98,6 +101,37 @@ describe("twilioApiRequest", () => {
98101
expect(release).toHaveBeenCalledTimes(1);
99102
});
100103

104+
it("derives the regional hostname for the request and SSRF policy", async () => {
105+
const release = vi.fn(async () => {});
106+
fetchWithSsrFGuardMock.mockResolvedValue({
107+
response: new Response(JSON.stringify({ sid: "CA123" }), { status: 200 }),
108+
release,
109+
});
110+
const baseUrl = resolveTwilioApiBaseUrl({
111+
accountSid: "AC123",
112+
region: "ie1",
113+
});
114+
115+
await twilioApiRequest({
116+
baseUrl,
117+
accountSid: "AC123",
118+
authToken: "secret",
119+
endpoint: "/Calls.json",
120+
body: {},
121+
});
122+
123+
const { url, policy } = requireFirstFetchGuardRequest();
124+
expect(url).toBe("https://api.dublin.ie1.twilio.com/2010-04-01/Accounts/AC123/Calls.json");
125+
expect(policy).toEqual({ allowedHostnames: ["api.dublin.ie1.twilio.com"] });
126+
expect(release).toHaveBeenCalledTimes(1);
127+
});
128+
129+
it("maps AU1 to Twilio's Sydney regional hostname", () => {
130+
expect(resolveTwilioApiBaseUrl({ accountSid: "AC123", region: "au1" })).toBe(
131+
"https://api.sydney.au1.twilio.com/2010-04-01/Accounts/AC123",
132+
);
133+
});
134+
101135
it("passes through URLSearchParams, allows 404s, and returns undefined for empty bodies", async () => {
102136
const missing = cancelTrackedTextResponse("missing", { status: 404 });
103137
const responses = [new Response(null, { status: 204 }), missing.response];
@@ -109,7 +143,7 @@ describe("twilioApiRequest", () => {
109143

110144
await expect(
111145
twilioApiRequest({
112-
baseUrl: "https://api.twilio.com",
146+
baseUrl: DEFAULT_BASE_URL,
113147
accountSid: "AC123",
114148
authToken: "secret",
115149
endpoint: "/Calls.json",
@@ -119,7 +153,7 @@ describe("twilioApiRequest", () => {
119153

120154
await expect(
121155
twilioApiRequest({
122-
baseUrl: "https://api.twilio.com",
156+
baseUrl: DEFAULT_BASE_URL,
123157
accountSid: "AC123",
124158
authToken: "secret",
125159
endpoint: "/Calls/missing.json",
@@ -140,7 +174,7 @@ describe("twilioApiRequest", () => {
140174

141175
await expect(
142176
twilioApiRequest({
143-
baseUrl: "https://api.twilio.com",
177+
baseUrl: DEFAULT_BASE_URL,
144178
accountSid: "AC123",
145179
authToken: "secret",
146180
endpoint: "/Calls.json",
@@ -160,7 +194,7 @@ describe("twilioApiRequest", () => {
160194

161195
try {
162196
await twilioApiRequest({
163-
baseUrl: "https://api.twilio.com",
197+
baseUrl: DEFAULT_BASE_URL,
164198
accountSid: "AC123",
165199
authToken: "secret",
166200
endpoint: "/Calls.json",
@@ -187,7 +221,7 @@ describe("twilioApiRequest", () => {
187221

188222
await expect(
189223
twilioApiRequest({
190-
baseUrl: "https://api.twilio.com",
224+
baseUrl: DEFAULT_BASE_URL,
191225
accountSid: "AC123",
192226
authToken: "secret",
193227
endpoint: "/Calls.json",
@@ -212,7 +246,7 @@ describe("twilioApiRequest", () => {
212246

213247
try {
214248
await twilioApiRequest({
215-
baseUrl: "https://api.twilio.com",
249+
baseUrl: DEFAULT_BASE_URL,
216250
accountSid: "AC123",
217251
authToken: "secret",
218252
endpoint: "/Calls/CA123.json",

0 commit comments

Comments
 (0)