@openclaw/voice-call 2026.1.29 → 2026.2.1
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 +31 -0
- package/README.md +13 -9
- package/index.ts +45 -49
- package/openclaw.plugin.json +11 -53
- package/package.json +6 -3
- package/src/cli.ts +80 -113
- package/src/config.test.ts +1 -4
- package/src/config.ts +88 -110
- package/src/core-bridge.ts +14 -12
- package/src/manager/context.ts +1 -1
- package/src/manager/events.ts +18 -9
- package/src/manager/lookup.ts +3 -1
- package/src/manager/outbound.ts +46 -19
- package/src/manager/state.ts +4 -6
- package/src/manager/store.ts +6 -3
- package/src/manager/timers.ts +11 -8
- package/src/manager.test.ts +7 -10
- package/src/manager.ts +53 -75
- package/src/media-stream.test.ts +0 -1
- package/src/media-stream.ts +12 -26
- package/src/providers/mock.ts +13 -16
- package/src/providers/plivo.test.ts +0 -1
- package/src/providers/plivo.ts +27 -29
- package/src/providers/stt-openai-realtime.ts +8 -8
- package/src/providers/telnyx.ts +5 -11
- package/src/providers/tts-openai.ts +9 -14
- package/src/providers/twilio/api.ts +9 -12
- package/src/providers/twilio/webhook.ts +2 -4
- package/src/providers/twilio.test.ts +1 -5
- package/src/providers/twilio.ts +34 -46
- package/src/response-generator.ts +7 -20
- package/src/runtime.ts +12 -25
- package/src/telephony-audio.ts +14 -12
- package/src/telephony-tts.ts +21 -12
- package/src/tunnel.ts +7 -24
- package/src/types.ts +0 -1
- package/src/utils.ts +3 -1
- package/src/voice-mapping.ts +3 -1
- package/src/webhook-security.test.ts +12 -21
- package/src/webhook-security.ts +25 -29
- package/src/webhook.ts +22 -57
package/src/cli.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
|
|
5
|
-
import type { Command } from "commander";
|
|
6
|
-
|
|
7
5
|
import type { VoiceCallConfig } from "./config.js";
|
|
8
6
|
import type { VoiceCallRuntime } from "./runtime.js";
|
|
9
7
|
import { resolveUserPath } from "./utils.js";
|
|
@@ -21,7 +19,9 @@ type Logger = {
|
|
|
21
19
|
|
|
22
20
|
function resolveMode(input: string): "off" | "serve" | "funnel" {
|
|
23
21
|
const raw = input.trim().toLowerCase();
|
|
24
|
-
if (raw === "serve" || raw === "off")
|
|
22
|
+
if (raw === "serve" || raw === "off") {
|
|
23
|
+
return raw;
|
|
24
|
+
}
|
|
25
25
|
return "funnel";
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -31,10 +31,7 @@ function resolveDefaultStorePath(config: VoiceCallConfig): string {
|
|
|
31
31
|
const existing =
|
|
32
32
|
[resolvedPreferred].find((dir) => {
|
|
33
33
|
try {
|
|
34
|
-
return (
|
|
35
|
-
fs.existsSync(path.join(dir, "calls.jsonl")) ||
|
|
36
|
-
fs.existsSync(dir)
|
|
37
|
-
);
|
|
34
|
+
return fs.existsSync(path.join(dir, "calls.jsonl")) || fs.existsSync(dir);
|
|
38
35
|
} catch {
|
|
39
36
|
return false;
|
|
40
37
|
}
|
|
@@ -62,10 +59,7 @@ export function registerVoiceCallCli(params: {
|
|
|
62
59
|
root
|
|
63
60
|
.command("call")
|
|
64
61
|
.description("Initiate an outbound voice call")
|
|
65
|
-
.requiredOption(
|
|
66
|
-
"-m, --message <text>",
|
|
67
|
-
"Message to speak when call connects",
|
|
68
|
-
)
|
|
62
|
+
.requiredOption("-m, --message <text>", "Message to speak when call connects")
|
|
69
63
|
.option(
|
|
70
64
|
"-t, --to <phone>",
|
|
71
65
|
"Phone number to call (E.164 format, uses config toNumber if not set)",
|
|
@@ -75,27 +69,23 @@ export function registerVoiceCallCli(params: {
|
|
|
75
69
|
"Call mode: notify (hangup after message) or conversation (stay open)",
|
|
76
70
|
"conversation",
|
|
77
71
|
)
|
|
78
|
-
.action(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
mode:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// eslint-disable-next-line no-console
|
|
96
|
-
console.log(JSON.stringify({ callId: result.callId }, null, 2));
|
|
97
|
-
},
|
|
98
|
-
);
|
|
72
|
+
.action(async (options: { message: string; to?: string; mode?: string }) => {
|
|
73
|
+
const rt = await ensureRuntime();
|
|
74
|
+
const to = options.to ?? rt.config.toNumber;
|
|
75
|
+
if (!to) {
|
|
76
|
+
throw new Error("Missing --to and no toNumber configured");
|
|
77
|
+
}
|
|
78
|
+
const result = await rt.manager.initiateCall(to, undefined, {
|
|
79
|
+
message: options.message,
|
|
80
|
+
mode:
|
|
81
|
+
options.mode === "notify" || options.mode === "conversation" ? options.mode : undefined,
|
|
82
|
+
});
|
|
83
|
+
if (!result.success) {
|
|
84
|
+
throw new Error(result.error || "initiate failed");
|
|
85
|
+
}
|
|
86
|
+
// eslint-disable-next-line no-console
|
|
87
|
+
console.log(JSON.stringify({ callId: result.callId }, null, 2));
|
|
88
|
+
});
|
|
99
89
|
|
|
100
90
|
root
|
|
101
91
|
.command("start")
|
|
@@ -107,23 +97,19 @@ export function registerVoiceCallCli(params: {
|
|
|
107
97
|
"Call mode: notify (hangup after message) or conversation (stay open)",
|
|
108
98
|
"conversation",
|
|
109
99
|
)
|
|
110
|
-
.action(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
mode:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
// eslint-disable-next-line no-console
|
|
124
|
-
console.log(JSON.stringify({ callId: result.callId }, null, 2));
|
|
125
|
-
},
|
|
126
|
-
);
|
|
100
|
+
.action(async (options: { to: string; message?: string; mode?: string }) => {
|
|
101
|
+
const rt = await ensureRuntime();
|
|
102
|
+
const result = await rt.manager.initiateCall(options.to, undefined, {
|
|
103
|
+
message: options.message,
|
|
104
|
+
mode:
|
|
105
|
+
options.mode === "notify" || options.mode === "conversation" ? options.mode : undefined,
|
|
106
|
+
});
|
|
107
|
+
if (!result.success) {
|
|
108
|
+
throw new Error(result.error || "initiate failed");
|
|
109
|
+
}
|
|
110
|
+
// eslint-disable-next-line no-console
|
|
111
|
+
console.log(JSON.stringify({ callId: result.callId }, null, 2));
|
|
112
|
+
});
|
|
127
113
|
|
|
128
114
|
root
|
|
129
115
|
.command("continue")
|
|
@@ -132,10 +118,7 @@ export function registerVoiceCallCli(params: {
|
|
|
132
118
|
.requiredOption("--message <text>", "Message to speak")
|
|
133
119
|
.action(async (options: { callId: string; message: string }) => {
|
|
134
120
|
const rt = await ensureRuntime();
|
|
135
|
-
const result = await rt.manager.continueCall(
|
|
136
|
-
options.callId,
|
|
137
|
-
options.message,
|
|
138
|
-
);
|
|
121
|
+
const result = await rt.manager.continueCall(options.callId, options.message);
|
|
139
122
|
if (!result.success) {
|
|
140
123
|
throw new Error(result.error || "continue failed");
|
|
141
124
|
}
|
|
@@ -185,86 +168,70 @@ export function registerVoiceCallCli(params: {
|
|
|
185
168
|
|
|
186
169
|
root
|
|
187
170
|
.command("tail")
|
|
188
|
-
.description(
|
|
189
|
-
"Tail voice-call JSONL logs (prints new lines; useful during provider tests)",
|
|
190
|
-
)
|
|
171
|
+
.description("Tail voice-call JSONL logs (prints new lines; useful during provider tests)")
|
|
191
172
|
.option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config))
|
|
192
173
|
.option("--since <n>", "Print last N lines first", "25")
|
|
193
174
|
.option("--poll <ms>", "Poll interval in ms", "250")
|
|
194
|
-
.action(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const pollMs = Math.max(50, Number(options.poll ?? 250));
|
|
175
|
+
.action(async (options: { file: string; since?: string; poll?: string }) => {
|
|
176
|
+
const file = options.file;
|
|
177
|
+
const since = Math.max(0, Number(options.since ?? 0));
|
|
178
|
+
const pollMs = Math.max(50, Number(options.poll ?? 250));
|
|
199
179
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
180
|
+
if (!fs.existsSync(file)) {
|
|
181
|
+
logger.error(`No log file at ${file}`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
204
184
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
185
|
+
const initial = fs.readFileSync(file, "utf8");
|
|
186
|
+
const lines = initial.split("\n").filter(Boolean);
|
|
187
|
+
for (const line of lines.slice(Math.max(0, lines.length - since))) {
|
|
188
|
+
// eslint-disable-next-line no-console
|
|
189
|
+
console.log(line);
|
|
190
|
+
}
|
|
211
191
|
|
|
212
|
-
|
|
192
|
+
let offset = Buffer.byteLength(initial, "utf8");
|
|
213
193
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
} finally {
|
|
232
|
-
fs.closeSync(fd);
|
|
194
|
+
for (;;) {
|
|
195
|
+
try {
|
|
196
|
+
const stat = fs.statSync(file);
|
|
197
|
+
if (stat.size < offset) {
|
|
198
|
+
offset = 0;
|
|
199
|
+
}
|
|
200
|
+
if (stat.size > offset) {
|
|
201
|
+
const fd = fs.openSync(file, "r");
|
|
202
|
+
try {
|
|
203
|
+
const buf = Buffer.alloc(stat.size - offset);
|
|
204
|
+
fs.readSync(fd, buf, 0, buf.length, offset);
|
|
205
|
+
offset = stat.size;
|
|
206
|
+
const text = buf.toString("utf8");
|
|
207
|
+
for (const line of text.split("\n").filter(Boolean)) {
|
|
208
|
+
// eslint-disable-next-line no-console
|
|
209
|
+
console.log(line);
|
|
233
210
|
}
|
|
211
|
+
} finally {
|
|
212
|
+
fs.closeSync(fd);
|
|
234
213
|
}
|
|
235
|
-
} catch {
|
|
236
|
-
// ignore and retry
|
|
237
214
|
}
|
|
238
|
-
|
|
215
|
+
} catch {
|
|
216
|
+
// ignore and retry
|
|
239
217
|
}
|
|
240
|
-
|
|
241
|
-
|
|
218
|
+
await sleep(pollMs);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
242
221
|
|
|
243
222
|
root
|
|
244
223
|
.command("expose")
|
|
245
224
|
.description("Enable/disable Tailscale serve/funnel for the webhook")
|
|
246
225
|
.option("--mode <mode>", "off | serve (tailnet) | funnel (public)", "funnel")
|
|
247
|
-
.option(
|
|
248
|
-
"--path <path>",
|
|
249
|
-
"Tailscale path to expose (recommend matching serve.path)",
|
|
250
|
-
)
|
|
226
|
+
.option("--path <path>", "Tailscale path to expose (recommend matching serve.path)")
|
|
251
227
|
.option("--port <port>", "Local webhook port")
|
|
252
228
|
.option("--serve-path <path>", "Local webhook path")
|
|
253
229
|
.action(
|
|
254
|
-
async (options: {
|
|
255
|
-
mode?: string;
|
|
256
|
-
port?: string;
|
|
257
|
-
path?: string;
|
|
258
|
-
servePath?: string;
|
|
259
|
-
}) => {
|
|
230
|
+
async (options: { mode?: string; port?: string; path?: string; servePath?: string }) => {
|
|
260
231
|
const mode = resolveMode(options.mode ?? "funnel");
|
|
261
232
|
const servePort = Number(options.port ?? config.serve.port ?? 3334);
|
|
262
|
-
const servePath = String(
|
|
263
|
-
|
|
264
|
-
);
|
|
265
|
-
const tsPath = String(
|
|
266
|
-
options.path ?? config.tailscale?.path ?? servePath,
|
|
267
|
-
);
|
|
233
|
+
const servePath = String(options.servePath ?? config.serve.path ?? "/voice/webhook");
|
|
234
|
+
const tsPath = String(options.path ?? config.tailscale?.path ?? servePath);
|
|
268
235
|
|
|
269
236
|
const localUrl = `http://127.0.0.1:${servePort}`;
|
|
270
237
|
|
package/src/config.test.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
-
|
|
3
2
|
import { validateProviderConfig, resolveVoiceCallConfig, type VoiceCallConfig } from "./config.js";
|
|
4
3
|
|
|
5
|
-
function createBaseConfig(
|
|
6
|
-
provider: "telnyx" | "twilio" | "plivo" | "mock",
|
|
7
|
-
): VoiceCallConfig {
|
|
4
|
+
function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): VoiceCallConfig {
|
|
8
5
|
return {
|
|
9
6
|
enabled: true,
|
|
10
7
|
provider,
|
package/src/config.ts
CHANGED
|
@@ -23,12 +23,7 @@ export const E164Schema = z
|
|
|
23
23
|
* - "pairing": Unknown callers can request pairing (future)
|
|
24
24
|
* - "open": Accept all inbound calls (dangerous!)
|
|
25
25
|
*/
|
|
26
|
-
export const InboundPolicySchema = z.enum([
|
|
27
|
-
"disabled",
|
|
28
|
-
"allowlist",
|
|
29
|
-
"pairing",
|
|
30
|
-
"open",
|
|
31
|
-
]);
|
|
26
|
+
export const InboundPolicySchema = z.enum(["disabled", "allowlist", "pairing", "open"]);
|
|
32
27
|
export type InboundPolicy = z.infer<typeof InboundPolicySchema>;
|
|
33
28
|
|
|
34
29
|
// -----------------------------------------------------------------------------
|
|
@@ -37,33 +32,33 @@ export type InboundPolicy = z.infer<typeof InboundPolicySchema>;
|
|
|
37
32
|
|
|
38
33
|
export const TelnyxConfigSchema = z
|
|
39
34
|
.object({
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
})
|
|
35
|
+
/** Telnyx API v2 key */
|
|
36
|
+
apiKey: z.string().min(1).optional(),
|
|
37
|
+
/** Telnyx connection ID (from Call Control app) */
|
|
38
|
+
connectionId: z.string().min(1).optional(),
|
|
39
|
+
/** Public key for webhook signature verification */
|
|
40
|
+
publicKey: z.string().min(1).optional(),
|
|
41
|
+
})
|
|
47
42
|
.strict();
|
|
48
43
|
export type TelnyxConfig = z.infer<typeof TelnyxConfigSchema>;
|
|
49
44
|
|
|
50
45
|
export const TwilioConfigSchema = z
|
|
51
46
|
.object({
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
})
|
|
47
|
+
/** Twilio Account SID */
|
|
48
|
+
accountSid: z.string().min(1).optional(),
|
|
49
|
+
/** Twilio Auth Token */
|
|
50
|
+
authToken: z.string().min(1).optional(),
|
|
51
|
+
})
|
|
57
52
|
.strict();
|
|
58
53
|
export type TwilioConfig = z.infer<typeof TwilioConfigSchema>;
|
|
59
54
|
|
|
60
55
|
export const PlivoConfigSchema = z
|
|
61
56
|
.object({
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
})
|
|
57
|
+
/** Plivo Auth ID (starts with MA/SA) */
|
|
58
|
+
authId: z.string().min(1).optional(),
|
|
59
|
+
/** Plivo Auth Token */
|
|
60
|
+
authToken: z.string().min(1).optional(),
|
|
61
|
+
})
|
|
67
62
|
.strict();
|
|
68
63
|
export type PlivoConfig = z.infer<typeof PlivoConfigSchema>;
|
|
69
64
|
|
|
@@ -190,9 +185,7 @@ export const VoiceCallTailscaleConfigSchema = z
|
|
|
190
185
|
})
|
|
191
186
|
.strict()
|
|
192
187
|
.default({ mode: "off", path: "/voice/webhook" });
|
|
193
|
-
export type VoiceCallTailscaleConfig = z.infer<
|
|
194
|
-
typeof VoiceCallTailscaleConfigSchema
|
|
195
|
-
>;
|
|
188
|
+
export type VoiceCallTailscaleConfig = z.infer<typeof VoiceCallTailscaleConfigSchema>;
|
|
196
189
|
|
|
197
190
|
// -----------------------------------------------------------------------------
|
|
198
191
|
// Tunnel Configuration (unified ngrok/tailscale)
|
|
@@ -207,9 +200,7 @@ export const VoiceCallTunnelConfigSchema = z
|
|
|
207
200
|
* - "tailscale-serve": Tailscale serve (private to tailnet)
|
|
208
201
|
* - "tailscale-funnel": Tailscale funnel (public HTTPS)
|
|
209
202
|
*/
|
|
210
|
-
provider: z
|
|
211
|
-
.enum(["none", "ngrok", "tailscale-serve", "tailscale-funnel"])
|
|
212
|
-
.default("none"),
|
|
203
|
+
provider: z.enum(["none", "ngrok", "tailscale-serve", "tailscale-funnel"]).default("none"),
|
|
213
204
|
/** ngrok auth token (optional, enables longer sessions and more features) */
|
|
214
205
|
ngrokAuthToken: z.string().min(1).optional(),
|
|
215
206
|
/** ngrok custom domain (paid feature, e.g., "myapp.ngrok.io") */
|
|
@@ -283,9 +274,7 @@ export const VoiceCallStreamingConfigSchema = z
|
|
|
283
274
|
vadThreshold: 0.5,
|
|
284
275
|
streamPath: "/voice/stream",
|
|
285
276
|
});
|
|
286
|
-
export type VoiceCallStreamingConfig = z.infer<
|
|
287
|
-
typeof VoiceCallStreamingConfigSchema
|
|
288
|
-
>;
|
|
277
|
+
export type VoiceCallStreamingConfig = z.infer<typeof VoiceCallStreamingConfigSchema>;
|
|
289
278
|
|
|
290
279
|
// -----------------------------------------------------------------------------
|
|
291
280
|
// Main Voice Call Configuration
|
|
@@ -293,90 +282,90 @@ export type VoiceCallStreamingConfig = z.infer<
|
|
|
293
282
|
|
|
294
283
|
export const VoiceCallConfigSchema = z
|
|
295
284
|
.object({
|
|
296
|
-
|
|
297
|
-
|
|
285
|
+
/** Enable voice call functionality */
|
|
286
|
+
enabled: z.boolean().default(false),
|
|
298
287
|
|
|
299
|
-
|
|
300
|
-
|
|
288
|
+
/** Active provider (telnyx, twilio, plivo, or mock) */
|
|
289
|
+
provider: z.enum(["telnyx", "twilio", "plivo", "mock"]).optional(),
|
|
301
290
|
|
|
302
|
-
|
|
303
|
-
|
|
291
|
+
/** Telnyx-specific configuration */
|
|
292
|
+
telnyx: TelnyxConfigSchema.optional(),
|
|
304
293
|
|
|
305
|
-
|
|
306
|
-
|
|
294
|
+
/** Twilio-specific configuration */
|
|
295
|
+
twilio: TwilioConfigSchema.optional(),
|
|
307
296
|
|
|
308
|
-
|
|
309
|
-
|
|
297
|
+
/** Plivo-specific configuration */
|
|
298
|
+
plivo: PlivoConfigSchema.optional(),
|
|
310
299
|
|
|
311
|
-
|
|
312
|
-
|
|
300
|
+
/** Phone number to call from (E.164) */
|
|
301
|
+
fromNumber: E164Schema.optional(),
|
|
313
302
|
|
|
314
|
-
|
|
315
|
-
|
|
303
|
+
/** Default phone number to call (E.164) */
|
|
304
|
+
toNumber: E164Schema.optional(),
|
|
316
305
|
|
|
317
|
-
|
|
318
|
-
|
|
306
|
+
/** Inbound call policy */
|
|
307
|
+
inboundPolicy: InboundPolicySchema.default("disabled"),
|
|
319
308
|
|
|
320
|
-
|
|
321
|
-
|
|
309
|
+
/** Allowlist of phone numbers for inbound calls (E.164) */
|
|
310
|
+
allowFrom: z.array(E164Schema).default([]),
|
|
322
311
|
|
|
323
|
-
|
|
324
|
-
|
|
312
|
+
/** Greeting message for inbound calls */
|
|
313
|
+
inboundGreeting: z.string().optional(),
|
|
325
314
|
|
|
326
|
-
|
|
327
|
-
|
|
315
|
+
/** Outbound call configuration */
|
|
316
|
+
outbound: OutboundConfigSchema,
|
|
328
317
|
|
|
329
|
-
|
|
330
|
-
|
|
318
|
+
/** Maximum call duration in seconds */
|
|
319
|
+
maxDurationSeconds: z.number().int().positive().default(300),
|
|
331
320
|
|
|
332
|
-
|
|
333
|
-
|
|
321
|
+
/** Silence timeout for end-of-speech detection (ms) */
|
|
322
|
+
silenceTimeoutMs: z.number().int().positive().default(800),
|
|
334
323
|
|
|
335
|
-
|
|
336
|
-
|
|
324
|
+
/** Timeout for user transcript (ms) */
|
|
325
|
+
transcriptTimeoutMs: z.number().int().positive().default(180000),
|
|
337
326
|
|
|
338
|
-
|
|
339
|
-
|
|
327
|
+
/** Ring timeout for outbound calls (ms) */
|
|
328
|
+
ringTimeoutMs: z.number().int().positive().default(30000),
|
|
340
329
|
|
|
341
|
-
|
|
342
|
-
|
|
330
|
+
/** Maximum concurrent calls */
|
|
331
|
+
maxConcurrentCalls: z.number().int().positive().default(1),
|
|
343
332
|
|
|
344
|
-
|
|
345
|
-
|
|
333
|
+
/** Webhook server configuration */
|
|
334
|
+
serve: VoiceCallServeConfigSchema,
|
|
346
335
|
|
|
347
|
-
|
|
348
|
-
|
|
336
|
+
/** Tailscale exposure configuration (legacy, prefer tunnel config) */
|
|
337
|
+
tailscale: VoiceCallTailscaleConfigSchema,
|
|
349
338
|
|
|
350
|
-
|
|
351
|
-
|
|
339
|
+
/** Tunnel configuration (unified ngrok/tailscale) */
|
|
340
|
+
tunnel: VoiceCallTunnelConfigSchema,
|
|
352
341
|
|
|
353
|
-
|
|
354
|
-
|
|
342
|
+
/** Real-time audio streaming configuration */
|
|
343
|
+
streaming: VoiceCallStreamingConfigSchema,
|
|
355
344
|
|
|
356
|
-
|
|
357
|
-
|
|
345
|
+
/** Public webhook URL override (if set, bypasses tunnel auto-detection) */
|
|
346
|
+
publicUrl: z.string().url().optional(),
|
|
358
347
|
|
|
359
|
-
|
|
360
|
-
|
|
348
|
+
/** Skip webhook signature verification (development only, NOT for production) */
|
|
349
|
+
skipSignatureVerification: z.boolean().default(false),
|
|
361
350
|
|
|
362
|
-
|
|
363
|
-
|
|
351
|
+
/** STT configuration */
|
|
352
|
+
stt: SttConfigSchema,
|
|
364
353
|
|
|
365
|
-
|
|
366
|
-
|
|
354
|
+
/** TTS override (deep-merges with core messages.tts) */
|
|
355
|
+
tts: TtsConfigSchema,
|
|
367
356
|
|
|
368
|
-
|
|
369
|
-
|
|
357
|
+
/** Store path for call logs */
|
|
358
|
+
store: z.string().optional(),
|
|
370
359
|
|
|
371
|
-
|
|
372
|
-
|
|
360
|
+
/** Model for generating voice responses (e.g., "anthropic/claude-sonnet-4", "openai/gpt-4o") */
|
|
361
|
+
responseModel: z.string().default("openai/gpt-4o-mini"),
|
|
373
362
|
|
|
374
|
-
|
|
375
|
-
|
|
363
|
+
/** System prompt for voice responses */
|
|
364
|
+
responseSystemPrompt: z.string().optional(),
|
|
376
365
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
})
|
|
366
|
+
/** Timeout for response generation in ms (default 30s) */
|
|
367
|
+
responseTimeoutMs: z.number().int().positive().default(30000),
|
|
368
|
+
})
|
|
380
369
|
.strict();
|
|
381
370
|
|
|
382
371
|
export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>;
|
|
@@ -395,30 +384,23 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
|
|
|
395
384
|
// Telnyx
|
|
396
385
|
if (resolved.provider === "telnyx") {
|
|
397
386
|
resolved.telnyx = resolved.telnyx ?? {};
|
|
398
|
-
resolved.telnyx.apiKey =
|
|
399
|
-
|
|
400
|
-
resolved.telnyx.
|
|
401
|
-
resolved.telnyx.connectionId ?? process.env.TELNYX_CONNECTION_ID;
|
|
402
|
-
resolved.telnyx.publicKey =
|
|
403
|
-
resolved.telnyx.publicKey ?? process.env.TELNYX_PUBLIC_KEY;
|
|
387
|
+
resolved.telnyx.apiKey = resolved.telnyx.apiKey ?? process.env.TELNYX_API_KEY;
|
|
388
|
+
resolved.telnyx.connectionId = resolved.telnyx.connectionId ?? process.env.TELNYX_CONNECTION_ID;
|
|
389
|
+
resolved.telnyx.publicKey = resolved.telnyx.publicKey ?? process.env.TELNYX_PUBLIC_KEY;
|
|
404
390
|
}
|
|
405
391
|
|
|
406
392
|
// Twilio
|
|
407
393
|
if (resolved.provider === "twilio") {
|
|
408
394
|
resolved.twilio = resolved.twilio ?? {};
|
|
409
|
-
resolved.twilio.accountSid =
|
|
410
|
-
|
|
411
|
-
resolved.twilio.authToken =
|
|
412
|
-
resolved.twilio.authToken ?? process.env.TWILIO_AUTH_TOKEN;
|
|
395
|
+
resolved.twilio.accountSid = resolved.twilio.accountSid ?? process.env.TWILIO_ACCOUNT_SID;
|
|
396
|
+
resolved.twilio.authToken = resolved.twilio.authToken ?? process.env.TWILIO_AUTH_TOKEN;
|
|
413
397
|
}
|
|
414
398
|
|
|
415
399
|
// Plivo
|
|
416
400
|
if (resolved.provider === "plivo") {
|
|
417
401
|
resolved.plivo = resolved.plivo ?? {};
|
|
418
|
-
resolved.plivo.authId =
|
|
419
|
-
|
|
420
|
-
resolved.plivo.authToken =
|
|
421
|
-
resolved.plivo.authToken ?? process.env.PLIVO_AUTH_TOKEN;
|
|
402
|
+
resolved.plivo.authId = resolved.plivo.authId ?? process.env.PLIVO_AUTH_ID;
|
|
403
|
+
resolved.plivo.authToken = resolved.plivo.authToken ?? process.env.PLIVO_AUTH_TOKEN;
|
|
422
404
|
}
|
|
423
405
|
|
|
424
406
|
// Tunnel Config
|
|
@@ -427,13 +409,9 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
|
|
|
427
409
|
allowNgrokFreeTierLoopbackBypass: false,
|
|
428
410
|
};
|
|
429
411
|
resolved.tunnel.allowNgrokFreeTierLoopbackBypass =
|
|
430
|
-
resolved.tunnel.allowNgrokFreeTierLoopbackBypass ||
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
resolved.tunnel.ngrokAuthToken =
|
|
434
|
-
resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
|
|
435
|
-
resolved.tunnel.ngrokDomain =
|
|
436
|
-
resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN;
|
|
412
|
+
resolved.tunnel.allowNgrokFreeTierLoopbackBypass || resolved.tunnel.allowNgrokFreeTier || false;
|
|
413
|
+
resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
|
|
414
|
+
resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN;
|
|
437
415
|
|
|
438
416
|
return resolved;
|
|
439
417
|
}
|
package/src/core-bridge.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
-
|
|
5
4
|
import type { VoiceCallTtsConfig } from "./config.js";
|
|
6
5
|
|
|
7
6
|
export type CoreConfig = {
|
|
@@ -51,10 +50,7 @@ type CoreAgentDeps = {
|
|
|
51
50
|
ensureAgentWorkspace: (params?: { dir: string }) => Promise<void>;
|
|
52
51
|
resolveStorePath: (store?: string, opts?: { agentId?: string }) => string;
|
|
53
52
|
loadSessionStore: (storePath: string) => Record<string, unknown>;
|
|
54
|
-
saveSessionStore: (
|
|
55
|
-
storePath: string,
|
|
56
|
-
store: Record<string, unknown>,
|
|
57
|
-
) => Promise<void>;
|
|
53
|
+
saveSessionStore: (storePath: string, store: Record<string, unknown>) => Promise<void>;
|
|
58
54
|
resolveSessionFilePath: (
|
|
59
55
|
sessionId: string,
|
|
60
56
|
entry: unknown,
|
|
@@ -75,19 +71,25 @@ function findPackageRoot(startDir: string, name: string): string | null {
|
|
|
75
71
|
if (fs.existsSync(pkgPath)) {
|
|
76
72
|
const raw = fs.readFileSync(pkgPath, "utf8");
|
|
77
73
|
const pkg = JSON.parse(raw) as { name?: string };
|
|
78
|
-
if (pkg.name === name)
|
|
74
|
+
if (pkg.name === name) {
|
|
75
|
+
return dir;
|
|
76
|
+
}
|
|
79
77
|
}
|
|
80
78
|
} catch {
|
|
81
79
|
// ignore parse errors and keep walking
|
|
82
80
|
}
|
|
83
81
|
const parent = path.dirname(dir);
|
|
84
|
-
if (parent === dir)
|
|
82
|
+
if (parent === dir) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
85
|
dir = parent;
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
function resolveOpenClawRoot(): string {
|
|
90
|
-
if (coreRootCache)
|
|
90
|
+
if (coreRootCache) {
|
|
91
|
+
return coreRootCache;
|
|
92
|
+
}
|
|
91
93
|
const override = process.env.OPENCLAW_ROOT?.trim();
|
|
92
94
|
if (override) {
|
|
93
95
|
coreRootCache = override;
|
|
@@ -116,9 +118,7 @@ function resolveOpenClawRoot(): string {
|
|
|
116
118
|
}
|
|
117
119
|
}
|
|
118
120
|
|
|
119
|
-
throw new Error(
|
|
120
|
-
"Unable to resolve core root. Set OPENCLAW_ROOT to the package root.",
|
|
121
|
-
);
|
|
121
|
+
throw new Error("Unable to resolve core root. Set OPENCLAW_ROOT to the package root.");
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
async function importCoreModule<T>(relativePath: string): Promise<T> {
|
|
@@ -133,7 +133,9 @@ async function importCoreModule<T>(relativePath: string): Promise<T> {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
export async function loadCoreAgentDeps(): Promise<CoreAgentDeps> {
|
|
136
|
-
if (coreDepsPromise)
|
|
136
|
+
if (coreDepsPromise) {
|
|
137
|
+
return coreDepsPromise;
|
|
138
|
+
}
|
|
137
139
|
|
|
138
140
|
coreDepsPromise = (async () => {
|
|
139
141
|
const [
|
package/src/manager/context.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { CallId, CallRecord } from "../types.js";
|
|
2
1
|
import type { VoiceCallConfig } from "../config.js";
|
|
3
2
|
import type { VoiceCallProvider } from "../providers/base.js";
|
|
3
|
+
import type { CallId, CallRecord } from "../types.js";
|
|
4
4
|
|
|
5
5
|
export type TranscriptWaiter = {
|
|
6
6
|
resolve: (text: string) => void;
|