@openclaw/voice-call 2026.2.17 → 2026.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +0 -132
- package/package.json +1 -1
- package/src/config.test.ts +69 -116
- package/src/telephony-tts.test.ts +75 -0
- package/src/telephony-tts.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,83 +1,5 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 2026.2.17
|
|
4
|
-
|
|
5
|
-
### Changes
|
|
6
|
-
|
|
7
|
-
- Version alignment with core OpenClaw release numbers.
|
|
8
|
-
|
|
9
|
-
## 2026.2.16
|
|
10
|
-
|
|
11
|
-
### Changes
|
|
12
|
-
|
|
13
|
-
- Version alignment with core OpenClaw release numbers.
|
|
14
|
-
|
|
15
|
-
## 2026.2.15
|
|
16
|
-
|
|
17
|
-
### Changes
|
|
18
|
-
|
|
19
|
-
- Version alignment with core OpenClaw release numbers.
|
|
20
|
-
|
|
21
|
-
## 2026.2.14
|
|
22
|
-
|
|
23
|
-
### Changes
|
|
24
|
-
|
|
25
|
-
- Version alignment with core OpenClaw release numbers.
|
|
26
|
-
|
|
27
|
-
## 2026.2.13
|
|
28
|
-
|
|
29
|
-
### Changes
|
|
30
|
-
|
|
31
|
-
- Version alignment with core OpenClaw release numbers.
|
|
32
|
-
|
|
33
|
-
## 2026.2.6-3
|
|
34
|
-
|
|
35
|
-
### Changes
|
|
36
|
-
|
|
37
|
-
- Version alignment with core OpenClaw release numbers.
|
|
38
|
-
|
|
39
|
-
## 2026.2.6-2
|
|
40
|
-
|
|
41
|
-
### Changes
|
|
42
|
-
|
|
43
|
-
- Version alignment with core OpenClaw release numbers.
|
|
44
|
-
|
|
45
|
-
## 2026.2.6
|
|
46
|
-
|
|
47
|
-
### Changes
|
|
48
|
-
|
|
49
|
-
- Version alignment with core OpenClaw release numbers.
|
|
50
|
-
|
|
51
|
-
## 2026.2.4
|
|
52
|
-
|
|
53
|
-
### Changes
|
|
54
|
-
|
|
55
|
-
- Version alignment with core OpenClaw release numbers.
|
|
56
|
-
|
|
57
|
-
## 2026.2.2
|
|
58
|
-
|
|
59
|
-
### Changes
|
|
60
|
-
|
|
61
|
-
- Version alignment with core OpenClaw release numbers.
|
|
62
|
-
|
|
63
|
-
## 2026.1.31
|
|
64
|
-
|
|
65
|
-
### Changes
|
|
66
|
-
|
|
67
|
-
- Version alignment with core OpenClaw release numbers.
|
|
68
|
-
|
|
69
|
-
## 2026.1.30
|
|
70
|
-
|
|
71
|
-
### Changes
|
|
72
|
-
|
|
73
|
-
- Version alignment with core OpenClaw release numbers.
|
|
74
|
-
|
|
75
|
-
## 2026.1.29
|
|
76
|
-
|
|
77
|
-
### Changes
|
|
78
|
-
|
|
79
|
-
- Version alignment with core OpenClaw release numbers.
|
|
80
|
-
|
|
81
3
|
## 2026.1.26
|
|
82
4
|
|
|
83
5
|
### Changes
|
|
@@ -87,60 +9,6 @@
|
|
|
87
9
|
- Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields.
|
|
88
10
|
- Ngrok free-tier bypass renamed to `tunnel.allowNgrokFreeTierLoopbackBypass` and gated to loopback + `tunnel.provider="ngrok"`.
|
|
89
11
|
|
|
90
|
-
## 2026.1.23
|
|
91
|
-
|
|
92
|
-
### Changes
|
|
93
|
-
|
|
94
|
-
- Version alignment with core OpenClaw release numbers.
|
|
95
|
-
|
|
96
|
-
## 2026.1.22
|
|
97
|
-
|
|
98
|
-
### Changes
|
|
99
|
-
|
|
100
|
-
- Version alignment with core OpenClaw release numbers.
|
|
101
|
-
|
|
102
|
-
## 2026.1.21
|
|
103
|
-
|
|
104
|
-
### Changes
|
|
105
|
-
|
|
106
|
-
- Version alignment with core OpenClaw release numbers.
|
|
107
|
-
|
|
108
|
-
## 2026.1.20
|
|
109
|
-
|
|
110
|
-
### Changes
|
|
111
|
-
|
|
112
|
-
- Version alignment with core OpenClaw release numbers.
|
|
113
|
-
|
|
114
|
-
## 2026.1.17-1
|
|
115
|
-
|
|
116
|
-
### Changes
|
|
117
|
-
|
|
118
|
-
- Version alignment with core OpenClaw release numbers.
|
|
119
|
-
|
|
120
|
-
## 2026.1.17
|
|
121
|
-
|
|
122
|
-
### Changes
|
|
123
|
-
|
|
124
|
-
- Version alignment with core OpenClaw release numbers.
|
|
125
|
-
|
|
126
|
-
## 2026.1.16
|
|
127
|
-
|
|
128
|
-
### Changes
|
|
129
|
-
|
|
130
|
-
- Version alignment with core OpenClaw release numbers.
|
|
131
|
-
|
|
132
|
-
## 2026.1.15
|
|
133
|
-
|
|
134
|
-
### Changes
|
|
135
|
-
|
|
136
|
-
- Version alignment with core OpenClaw release numbers.
|
|
137
|
-
|
|
138
|
-
## 2026.1.14
|
|
139
|
-
|
|
140
|
-
### Changes
|
|
141
|
-
|
|
142
|
-
- Version alignment with core OpenClaw release numbers.
|
|
143
|
-
|
|
144
12
|
## 0.1.0
|
|
145
13
|
|
|
146
14
|
### Highlights
|
package/package.json
CHANGED
package/src/config.test.ts
CHANGED
|
@@ -44,9 +44,7 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
|
|
|
44
44
|
|
|
45
45
|
describe("validateProviderConfig", () => {
|
|
46
46
|
const originalEnv = { ...process.env };
|
|
47
|
-
|
|
48
|
-
beforeEach(() => {
|
|
49
|
-
// Clear all relevant env vars before each test
|
|
47
|
+
const clearProviderEnv = () => {
|
|
50
48
|
delete process.env.TWILIO_ACCOUNT_SID;
|
|
51
49
|
delete process.env.TWILIO_AUTH_TOKEN;
|
|
52
50
|
delete process.env.TELNYX_API_KEY;
|
|
@@ -54,6 +52,10 @@ describe("validateProviderConfig", () => {
|
|
|
54
52
|
delete process.env.TELNYX_PUBLIC_KEY;
|
|
55
53
|
delete process.env.PLIVO_AUTH_ID;
|
|
56
54
|
delete process.env.PLIVO_AUTH_TOKEN;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
clearProviderEnv();
|
|
57
59
|
});
|
|
58
60
|
|
|
59
61
|
afterEach(() => {
|
|
@@ -61,29 +63,43 @@ describe("validateProviderConfig", () => {
|
|
|
61
63
|
process.env = { ...originalEnv };
|
|
62
64
|
});
|
|
63
65
|
|
|
64
|
-
describe("
|
|
65
|
-
it("passes validation when credentials
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
66
|
+
describe("provider credential sources", () => {
|
|
67
|
+
it("passes validation when credentials come from config or environment", () => {
|
|
68
|
+
for (const provider of ["twilio", "telnyx", "plivo"] as const) {
|
|
69
|
+
clearProviderEnv();
|
|
70
|
+
const fromConfig = createBaseConfig(provider);
|
|
71
|
+
if (provider === "twilio") {
|
|
72
|
+
fromConfig.twilio = { accountSid: "AC123", authToken: "secret" };
|
|
73
|
+
} else if (provider === "telnyx") {
|
|
74
|
+
fromConfig.telnyx = {
|
|
75
|
+
apiKey: "KEY123",
|
|
76
|
+
connectionId: "CONN456",
|
|
77
|
+
publicKey: "public-key",
|
|
78
|
+
};
|
|
79
|
+
} else {
|
|
80
|
+
fromConfig.plivo = { authId: "MA123", authToken: "secret" };
|
|
81
|
+
}
|
|
82
|
+
expect(validateProviderConfig(fromConfig)).toMatchObject({ valid: true, errors: [] });
|
|
83
|
+
|
|
84
|
+
clearProviderEnv();
|
|
85
|
+
if (provider === "twilio") {
|
|
86
|
+
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
|
87
|
+
process.env.TWILIO_AUTH_TOKEN = "secret";
|
|
88
|
+
} else if (provider === "telnyx") {
|
|
89
|
+
process.env.TELNYX_API_KEY = "KEY123";
|
|
90
|
+
process.env.TELNYX_CONNECTION_ID = "CONN456";
|
|
91
|
+
process.env.TELNYX_PUBLIC_KEY = "public-key";
|
|
92
|
+
} else {
|
|
93
|
+
process.env.PLIVO_AUTH_ID = "MA123";
|
|
94
|
+
process.env.PLIVO_AUTH_TOKEN = "secret";
|
|
95
|
+
}
|
|
96
|
+
const fromEnv = resolveVoiceCallConfig(createBaseConfig(provider));
|
|
97
|
+
expect(validateProviderConfig(fromEnv)).toMatchObject({ valid: true, errors: [] });
|
|
98
|
+
}
|
|
85
99
|
});
|
|
100
|
+
});
|
|
86
101
|
|
|
102
|
+
describe("twilio provider", () => {
|
|
87
103
|
it("passes validation with mixed config and env vars", () => {
|
|
88
104
|
process.env.TWILIO_AUTH_TOKEN = "secret";
|
|
89
105
|
let config = createBaseConfig("twilio");
|
|
@@ -96,57 +112,27 @@ describe("validateProviderConfig", () => {
|
|
|
96
112
|
expect(result.errors).toEqual([]);
|
|
97
113
|
});
|
|
98
114
|
|
|
99
|
-
it("fails validation when
|
|
115
|
+
it("fails validation when required twilio credentials are missing", () => {
|
|
100
116
|
process.env.TWILIO_AUTH_TOKEN = "secret";
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const result = validateProviderConfig(config);
|
|
105
|
-
|
|
106
|
-
expect(result.valid).toBe(false);
|
|
107
|
-
expect(result.errors).toContain(
|
|
117
|
+
const missingSid = validateProviderConfig(resolveVoiceCallConfig(createBaseConfig("twilio")));
|
|
118
|
+
expect(missingSid.valid).toBe(false);
|
|
119
|
+
expect(missingSid.errors).toContain(
|
|
108
120
|
"plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)",
|
|
109
121
|
);
|
|
110
|
-
});
|
|
111
122
|
|
|
112
|
-
|
|
123
|
+
delete process.env.TWILIO_AUTH_TOKEN;
|
|
113
124
|
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
expect(result.valid).toBe(false);
|
|
120
|
-
expect(result.errors).toContain(
|
|
125
|
+
const missingToken = validateProviderConfig(
|
|
126
|
+
resolveVoiceCallConfig(createBaseConfig("twilio")),
|
|
127
|
+
);
|
|
128
|
+
expect(missingToken.valid).toBe(false);
|
|
129
|
+
expect(missingToken.errors).toContain(
|
|
121
130
|
"plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)",
|
|
122
131
|
);
|
|
123
132
|
});
|
|
124
133
|
});
|
|
125
134
|
|
|
126
135
|
describe("telnyx provider", () => {
|
|
127
|
-
it("passes validation when credentials are in config", () => {
|
|
128
|
-
const config = createBaseConfig("telnyx");
|
|
129
|
-
config.telnyx = { apiKey: "KEY123", connectionId: "CONN456", publicKey: "public-key" };
|
|
130
|
-
|
|
131
|
-
const result = validateProviderConfig(config);
|
|
132
|
-
|
|
133
|
-
expect(result.valid).toBe(true);
|
|
134
|
-
expect(result.errors).toEqual([]);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("passes validation when credentials are in environment variables", () => {
|
|
138
|
-
process.env.TELNYX_API_KEY = "KEY123";
|
|
139
|
-
process.env.TELNYX_CONNECTION_ID = "CONN456";
|
|
140
|
-
process.env.TELNYX_PUBLIC_KEY = "public-key";
|
|
141
|
-
let config = createBaseConfig("telnyx");
|
|
142
|
-
config = resolveVoiceCallConfig(config);
|
|
143
|
-
|
|
144
|
-
const result = validateProviderConfig(config);
|
|
145
|
-
|
|
146
|
-
expect(result.valid).toBe(true);
|
|
147
|
-
expect(result.errors).toEqual([]);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
136
|
it("fails validation when apiKey is missing everywhere", () => {
|
|
151
137
|
process.env.TELNYX_CONNECTION_ID = "CONN456";
|
|
152
138
|
let config = createBaseConfig("telnyx");
|
|
@@ -160,69 +146,36 @@ describe("validateProviderConfig", () => {
|
|
|
160
146
|
);
|
|
161
147
|
});
|
|
162
148
|
|
|
163
|
-
it("
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
expect(result.valid).toBe(false);
|
|
171
|
-
expect(result.errors).toContain(
|
|
149
|
+
it("requires a public key unless signature verification is skipped", () => {
|
|
150
|
+
const missingPublicKey = createBaseConfig("telnyx");
|
|
151
|
+
missingPublicKey.inboundPolicy = "allowlist";
|
|
152
|
+
missingPublicKey.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
|
|
153
|
+
const missingPublicKeyResult = validateProviderConfig(missingPublicKey);
|
|
154
|
+
expect(missingPublicKeyResult.valid).toBe(false);
|
|
155
|
+
expect(missingPublicKeyResult.errors).toContain(
|
|
172
156
|
"plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)",
|
|
173
157
|
);
|
|
174
|
-
});
|
|
175
158
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
config.telnyx = {
|
|
159
|
+
const withPublicKey = createBaseConfig("telnyx");
|
|
160
|
+
withPublicKey.inboundPolicy = "allowlist";
|
|
161
|
+
withPublicKey.telnyx = {
|
|
180
162
|
apiKey: "KEY123",
|
|
181
163
|
connectionId: "CONN456",
|
|
182
164
|
publicKey: "public-key",
|
|
183
165
|
};
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
config.skipSignatureVerification = true;
|
|
194
|
-
config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
|
|
195
|
-
|
|
196
|
-
const result = validateProviderConfig(config);
|
|
197
|
-
|
|
198
|
-
expect(result.valid).toBe(true);
|
|
199
|
-
expect(result.errors).toEqual([]);
|
|
166
|
+
expect(validateProviderConfig(withPublicKey)).toMatchObject({ valid: true, errors: [] });
|
|
167
|
+
|
|
168
|
+
const skippedVerification = createBaseConfig("telnyx");
|
|
169
|
+
skippedVerification.skipSignatureVerification = true;
|
|
170
|
+
skippedVerification.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
|
|
171
|
+
expect(validateProviderConfig(skippedVerification)).toMatchObject({
|
|
172
|
+
valid: true,
|
|
173
|
+
errors: [],
|
|
174
|
+
});
|
|
200
175
|
});
|
|
201
176
|
});
|
|
202
177
|
|
|
203
178
|
describe("plivo provider", () => {
|
|
204
|
-
it("passes validation when credentials are in config", () => {
|
|
205
|
-
const config = createBaseConfig("plivo");
|
|
206
|
-
config.plivo = { authId: "MA123", authToken: "secret" };
|
|
207
|
-
|
|
208
|
-
const result = validateProviderConfig(config);
|
|
209
|
-
|
|
210
|
-
expect(result.valid).toBe(true);
|
|
211
|
-
expect(result.errors).toEqual([]);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it("passes validation when credentials are in environment variables", () => {
|
|
215
|
-
process.env.PLIVO_AUTH_ID = "MA123";
|
|
216
|
-
process.env.PLIVO_AUTH_TOKEN = "secret";
|
|
217
|
-
let config = createBaseConfig("plivo");
|
|
218
|
-
config = resolveVoiceCallConfig(config);
|
|
219
|
-
|
|
220
|
-
const result = validateProviderConfig(config);
|
|
221
|
-
|
|
222
|
-
expect(result.valid).toBe(true);
|
|
223
|
-
expect(result.errors).toEqual([]);
|
|
224
|
-
});
|
|
225
|
-
|
|
226
179
|
it("fails validation when authId is missing everywhere", () => {
|
|
227
180
|
process.env.PLIVO_AUTH_TOKEN = "secret";
|
|
228
181
|
let config = createBaseConfig("plivo");
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import type { VoiceCallTtsConfig } from "./config.js";
|
|
3
|
+
import type { CoreConfig } from "./core-bridge.js";
|
|
4
|
+
import { createTelephonyTtsProvider } from "./telephony-tts.js";
|
|
5
|
+
|
|
6
|
+
function createCoreConfig(): CoreConfig {
|
|
7
|
+
const tts: VoiceCallTtsConfig = {
|
|
8
|
+
provider: "openai",
|
|
9
|
+
openai: {
|
|
10
|
+
model: "gpt-4o-mini-tts",
|
|
11
|
+
voice: "alloy",
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
return { messages: { tts } };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function mergeOverride(override: unknown): Promise<Record<string, unknown>> {
|
|
18
|
+
let mergedConfig: CoreConfig | undefined;
|
|
19
|
+
const provider = createTelephonyTtsProvider({
|
|
20
|
+
coreConfig: createCoreConfig(),
|
|
21
|
+
ttsOverride: override as VoiceCallTtsConfig,
|
|
22
|
+
runtime: {
|
|
23
|
+
textToSpeechTelephony: async ({ cfg }) => {
|
|
24
|
+
mergedConfig = cfg;
|
|
25
|
+
return {
|
|
26
|
+
success: true,
|
|
27
|
+
audioBuffer: Buffer.alloc(2),
|
|
28
|
+
sampleRate: 8000,
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await provider.synthesizeForTelephony("hello");
|
|
35
|
+
expect(mergedConfig?.messages?.tts).toBeDefined();
|
|
36
|
+
return mergedConfig?.messages?.tts as Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
delete (Object.prototype as Record<string, unknown>).polluted;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("createTelephonyTtsProvider deepMerge hardening", () => {
|
|
44
|
+
it("merges safe nested overrides", async () => {
|
|
45
|
+
const tts = await mergeOverride({
|
|
46
|
+
openai: { voice: "coral" },
|
|
47
|
+
});
|
|
48
|
+
const openai = tts.openai as Record<string, unknown>;
|
|
49
|
+
|
|
50
|
+
expect(openai.voice).toBe("coral");
|
|
51
|
+
expect(openai.model).toBe("gpt-4o-mini-tts");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("blocks top-level __proto__ keys", async () => {
|
|
55
|
+
const tts = await mergeOverride(
|
|
56
|
+
JSON.parse('{"__proto__":{"polluted":"top"},"openai":{"voice":"coral"}}'),
|
|
57
|
+
);
|
|
58
|
+
const openai = tts.openai as Record<string, unknown>;
|
|
59
|
+
|
|
60
|
+
expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
|
|
61
|
+
expect(tts.polluted).toBeUndefined();
|
|
62
|
+
expect(openai.voice).toBe("coral");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("blocks nested __proto__ keys", async () => {
|
|
66
|
+
const tts = await mergeOverride(
|
|
67
|
+
JSON.parse('{"openai":{"model":"safe","__proto__":{"polluted":"nested"}}}'),
|
|
68
|
+
);
|
|
69
|
+
const openai = tts.openai as Record<string, unknown>;
|
|
70
|
+
|
|
71
|
+
expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
|
|
72
|
+
expect(openai.polluted).toBeUndefined();
|
|
73
|
+
expect(openai.model).toBe("safe");
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/telephony-tts.ts
CHANGED
|
@@ -20,6 +20,8 @@ export type TelephonyTtsProvider = {
|
|
|
20
20
|
synthesizeForTelephony: (text: string) => Promise<Buffer>;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
const BLOCKED_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
|
24
|
+
|
|
23
25
|
export function createTelephonyTtsProvider(params: {
|
|
24
26
|
coreConfig: CoreConfig;
|
|
25
27
|
ttsOverride?: VoiceCallTtsConfig;
|
|
@@ -86,7 +88,7 @@ function deepMerge<T>(base: T, override: T): T {
|
|
|
86
88
|
}
|
|
87
89
|
const result: Record<string, unknown> = { ...base };
|
|
88
90
|
for (const [key, value] of Object.entries(override)) {
|
|
89
|
-
if (value === undefined) {
|
|
91
|
+
if (BLOCKED_MERGE_KEYS.has(key) || value === undefined) {
|
|
90
92
|
continue;
|
|
91
93
|
}
|
|
92
94
|
const existing = (base as Record<string, unknown>)[key];
|