@openclaw/voice-call 2026.3.13 → 2026.5.2-beta.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/README.md +27 -5
- package/api.ts +16 -0
- package/cli-metadata.ts +10 -0
- package/config-api.ts +12 -0
- package/index.test.ts +943 -0
- package/index.ts +379 -149
- package/openclaw.plugin.json +384 -157
- package/package.json +35 -5
- package/runtime-api.ts +20 -0
- package/runtime-entry.ts +1 -0
- package/setup-api.ts +47 -0
- package/src/allowlist.test.ts +18 -0
- package/src/cli.ts +533 -68
- package/src/config-compat.test.ts +120 -0
- package/src/config-compat.ts +227 -0
- package/src/config.test.ts +273 -12
- package/src/config.ts +355 -72
- package/src/core-bridge.ts +2 -147
- package/src/deep-merge.test.ts +40 -0
- package/src/gateway-continue-operation.ts +200 -0
- package/src/http-headers.ts +6 -3
- package/src/manager/context.ts +6 -5
- package/src/manager/events.test.ts +243 -19
- package/src/manager/events.ts +61 -31
- package/src/manager/lifecycle.ts +53 -0
- package/src/manager/lookup.test.ts +52 -0
- package/src/manager/outbound.test.ts +528 -0
- package/src/manager/outbound.ts +163 -57
- package/src/manager/store.ts +18 -6
- package/src/manager/timers.test.ts +129 -0
- package/src/manager/timers.ts +4 -3
- package/src/manager/twiml.test.ts +13 -0
- package/src/manager/twiml.ts +8 -0
- package/src/manager.closed-loop.test.ts +30 -12
- package/src/manager.inbound-allowlist.test.ts +77 -10
- package/src/manager.notify.test.ts +344 -20
- package/src/manager.restore.test.ts +95 -8
- package/src/manager.test-harness.ts +8 -6
- package/src/manager.ts +79 -5
- package/src/media-stream.test.ts +578 -81
- package/src/media-stream.ts +235 -54
- package/src/providers/base.ts +19 -0
- package/src/providers/mock.ts +7 -1
- package/src/providers/plivo.test.ts +50 -6
- package/src/providers/plivo.ts +14 -6
- package/src/providers/shared/call-status.ts +2 -1
- package/src/providers/shared/guarded-json-api.test.ts +106 -0
- package/src/providers/shared/guarded-json-api.ts +1 -1
- package/src/providers/telnyx.test.ts +178 -6
- package/src/providers/telnyx.ts +40 -3
- package/src/providers/twilio/api.test.ts +145 -0
- package/src/providers/twilio/api.ts +67 -16
- package/src/providers/twilio/twiml-policy.ts +6 -10
- package/src/providers/twilio/webhook.ts +1 -1
- package/src/providers/twilio.test.ts +425 -25
- package/src/providers/twilio.ts +230 -77
- package/src/providers/twilio.types.ts +17 -0
- package/src/realtime-defaults.ts +3 -0
- package/src/realtime-fast-context.test.ts +88 -0
- package/src/realtime-fast-context.ts +165 -0
- package/src/realtime-transcription.runtime.ts +4 -0
- package/src/realtime-voice.runtime.ts +5 -0
- package/src/response-generator.test.ts +321 -0
- package/src/response-generator.ts +213 -53
- package/src/response-model.test.ts +71 -0
- package/src/response-model.ts +23 -0
- package/src/runtime.test.ts +429 -0
- package/src/runtime.ts +270 -24
- package/src/telephony-audio.test.ts +61 -0
- package/src/telephony-audio.ts +1 -79
- package/src/telephony-tts.test.ts +133 -12
- package/src/telephony-tts.ts +155 -2
- package/src/test-fixtures.ts +28 -7
- package/src/tts-provider-voice.test.ts +34 -0
- package/src/tts-provider-voice.ts +21 -0
- package/src/tunnel.test.ts +166 -0
- package/src/tunnel.ts +1 -1
- package/src/types.ts +24 -37
- package/src/utils.test.ts +17 -0
- package/src/voice-mapping.test.ts +34 -0
- package/src/voice-mapping.ts +3 -2
- package/src/webhook/realtime-handler.test.ts +598 -0
- package/src/webhook/realtime-handler.ts +485 -0
- package/src/webhook/stale-call-reaper.test.ts +88 -0
- package/src/webhook/stale-call-reaper.ts +5 -0
- package/src/webhook/tailscale.test.ts +214 -0
- package/src/webhook/tailscale.ts +19 -5
- package/src/webhook-exposure.test.ts +33 -0
- package/src/webhook-exposure.ts +84 -0
- package/src/webhook-security.test.ts +172 -21
- package/src/webhook-security.ts +43 -29
- package/src/webhook.hangup-once.lifecycle.test.ts +135 -0
- package/src/webhook.test.ts +1145 -27
- package/src/webhook.ts +523 -102
- package/src/webhook.types.ts +5 -0
- package/src/websocket-test-support.ts +72 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -121
- package/src/providers/index.ts +0 -10
- package/src/providers/stt-openai-realtime.test.ts +0 -42
- package/src/providers/stt-openai-realtime.ts +0 -311
- package/src/providers/tts-openai.test.ts +0 -43
- package/src/providers/tts-openai.ts +0 -221
package/src/cli.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { format } from "node:util";
|
|
4
5
|
import type { Command } from "commander";
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
6
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
7
|
+
import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime";
|
|
8
|
+
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
|
9
|
+
import { sleep } from "../api.js";
|
|
10
|
+
import { validateProviderConfig, type VoiceCallConfig } from "./config.js";
|
|
7
11
|
import type { VoiceCallRuntime } from "./runtime.js";
|
|
8
12
|
import { resolveUserPath } from "./utils.js";
|
|
13
|
+
import { resolveWebhookExposureStatus } from "./webhook-exposure.js";
|
|
9
14
|
import {
|
|
10
15
|
cleanupTailscaleExposureRoute,
|
|
11
16
|
getTailscaleSelfInfo,
|
|
@@ -18,8 +23,183 @@ type Logger = {
|
|
|
18
23
|
error: (message: string) => void;
|
|
19
24
|
};
|
|
20
25
|
|
|
26
|
+
type SetupCheck = {
|
|
27
|
+
id: string;
|
|
28
|
+
ok: boolean;
|
|
29
|
+
message: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type SetupStatus = {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
checks: SetupCheck[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type VoiceCallGatewayMethod =
|
|
38
|
+
| "voicecall.initiate"
|
|
39
|
+
| "voicecall.start"
|
|
40
|
+
| "voicecall.continue"
|
|
41
|
+
| "voicecall.continue.start"
|
|
42
|
+
| "voicecall.continue.result"
|
|
43
|
+
| "voicecall.speak"
|
|
44
|
+
| "voicecall.dtmf"
|
|
45
|
+
| "voicecall.end"
|
|
46
|
+
| "voicecall.status";
|
|
47
|
+
|
|
48
|
+
type VoiceCallGatewayCallResult = { ok: true; payload: unknown } | { ok: false; error: unknown };
|
|
49
|
+
|
|
50
|
+
const VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS = 5000;
|
|
51
|
+
const VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS = 30000;
|
|
52
|
+
const VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS = 10000;
|
|
53
|
+
const VOICE_CALL_GATEWAY_POLL_INTERVAL_MS = 1000;
|
|
54
|
+
|
|
55
|
+
const voiceCallCliDeps = {
|
|
56
|
+
callGatewayFromCli,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const __testing = {
|
|
60
|
+
setCallGatewayFromCliForTests(next?: typeof callGatewayFromCli): void {
|
|
61
|
+
voiceCallCliDeps.callGatewayFromCli = next ?? callGatewayFromCli;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function writeStdoutLine(...values: unknown[]): void {
|
|
66
|
+
process.stdout.write(`${format(...values)}\n`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeStdoutJson(value: unknown): void {
|
|
70
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
74
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isGatewayUnavailableForLocalFallback(err: unknown): boolean {
|
|
78
|
+
const message = formatErrorMessage(err);
|
|
79
|
+
return (
|
|
80
|
+
message.includes("ECONNREFUSED") ||
|
|
81
|
+
message.includes("ECONNRESET") ||
|
|
82
|
+
message.includes("EHOSTUNREACH") ||
|
|
83
|
+
message.includes("ENOTFOUND") ||
|
|
84
|
+
message.includes("gateway not connected")
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function callVoiceCallGateway(
|
|
89
|
+
method: VoiceCallGatewayMethod,
|
|
90
|
+
params?: Record<string, unknown>,
|
|
91
|
+
opts?: { timeoutMs?: number },
|
|
92
|
+
): Promise<VoiceCallGatewayCallResult> {
|
|
93
|
+
try {
|
|
94
|
+
const timeoutMs =
|
|
95
|
+
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
|
|
96
|
+
? Math.max(1, Math.ceil(opts.timeoutMs))
|
|
97
|
+
: VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS;
|
|
98
|
+
const payload = await voiceCallCliDeps.callGatewayFromCli(
|
|
99
|
+
method,
|
|
100
|
+
{ json: true, timeout: String(timeoutMs) },
|
|
101
|
+
params,
|
|
102
|
+
{ progress: false },
|
|
103
|
+
);
|
|
104
|
+
return { ok: true, payload };
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (isGatewayUnavailableForLocalFallback(err)) {
|
|
107
|
+
return { ok: false, error: err };
|
|
108
|
+
}
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveGatewayOperationTimeoutMs(config: VoiceCallConfig): number {
|
|
114
|
+
return Math.max(VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS, config.ringTimeoutMs + 5000);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resolveGatewayContinueTimeoutMs(config: VoiceCallConfig): number {
|
|
118
|
+
return (
|
|
119
|
+
config.transcriptTimeoutMs +
|
|
120
|
+
VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS +
|
|
121
|
+
VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isUnknownGatewayMethod(err: unknown, method: VoiceCallGatewayMethod): boolean {
|
|
126
|
+
return formatErrorMessage(err).includes(`unknown method: ${method}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readGatewayOperationId(payload: unknown): string {
|
|
130
|
+
if (isRecord(payload) && typeof payload.operationId === "string" && payload.operationId) {
|
|
131
|
+
return payload.operationId;
|
|
132
|
+
}
|
|
133
|
+
throw new Error("voicecall gateway response missing operationId");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readGatewayPollTimeoutMs(payload: unknown, fallbackTimeoutMs: number): number {
|
|
137
|
+
if (isRecord(payload) && typeof payload.pollTimeoutMs === "number") {
|
|
138
|
+
return Math.max(1, Math.ceil(payload.pollTimeoutMs));
|
|
139
|
+
}
|
|
140
|
+
return fallbackTimeoutMs;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readCompletedContinueResult(
|
|
144
|
+
payload: unknown,
|
|
145
|
+
):
|
|
146
|
+
| { status: "pending" }
|
|
147
|
+
| { status: "completed"; result: unknown }
|
|
148
|
+
| { status: "failed"; error: string } {
|
|
149
|
+
if (!isRecord(payload)) {
|
|
150
|
+
throw new Error("voicecall gateway response missing operation status");
|
|
151
|
+
}
|
|
152
|
+
if (payload.status === "pending") {
|
|
153
|
+
return { status: "pending" };
|
|
154
|
+
}
|
|
155
|
+
if (payload.status === "failed") {
|
|
156
|
+
return {
|
|
157
|
+
status: "failed",
|
|
158
|
+
error: typeof payload.error === "string" ? payload.error : "continue failed",
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (payload.status === "completed") {
|
|
162
|
+
return { status: "completed", result: payload.result };
|
|
163
|
+
}
|
|
164
|
+
throw new Error("voicecall gateway response has unknown operation status");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function pollVoiceCallContinueGateway(params: {
|
|
168
|
+
operationId: string;
|
|
169
|
+
timeoutMs: number;
|
|
170
|
+
}): Promise<unknown> {
|
|
171
|
+
const deadlineMs = Date.now() + params.timeoutMs;
|
|
172
|
+
|
|
173
|
+
while (Date.now() <= deadlineMs) {
|
|
174
|
+
const gateway = await callVoiceCallGateway(
|
|
175
|
+
"voicecall.continue.result",
|
|
176
|
+
{ operationId: params.operationId },
|
|
177
|
+
{ timeoutMs: VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS },
|
|
178
|
+
);
|
|
179
|
+
if (!gateway.ok) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`gateway unavailable while waiting for voicecall continue result: ${formatErrorMessage(
|
|
182
|
+
gateway.error,
|
|
183
|
+
)}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const result = readCompletedContinueResult(gateway.payload);
|
|
187
|
+
if (result.status === "completed") {
|
|
188
|
+
return result.result;
|
|
189
|
+
}
|
|
190
|
+
if (result.status === "failed") {
|
|
191
|
+
throw new Error(result.error);
|
|
192
|
+
}
|
|
193
|
+
await sleep(
|
|
194
|
+
Math.min(VOICE_CALL_GATEWAY_POLL_INTERVAL_MS, Math.max(1, deadlineMs - Date.now())),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
throw new Error("voicecall continue timed out waiting for gateway operation");
|
|
199
|
+
}
|
|
200
|
+
|
|
21
201
|
function resolveMode(input: string): "off" | "serve" | "funnel" {
|
|
22
|
-
const raw = input
|
|
202
|
+
const raw = normalizeOptionalLowercaseString(input) ?? "";
|
|
23
203
|
if (raw === "serve" || raw === "off") {
|
|
24
204
|
return raw;
|
|
25
205
|
}
|
|
@@ -45,7 +225,7 @@ function percentile(values: number[], p: number): number {
|
|
|
45
225
|
if (values.length === 0) {
|
|
46
226
|
return 0;
|
|
47
227
|
}
|
|
48
|
-
const sorted = [...values].
|
|
228
|
+
const sorted = [...values].toSorted((a, b) => a - b);
|
|
49
229
|
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
|
|
50
230
|
return sorted[idx] ?? 0;
|
|
51
231
|
}
|
|
@@ -85,6 +265,62 @@ function resolveCallMode(mode?: string): "notify" | "conversation" | undefined {
|
|
|
85
265
|
return mode === "notify" || mode === "conversation" ? mode : undefined;
|
|
86
266
|
}
|
|
87
267
|
|
|
268
|
+
function buildSetupStatus(config: VoiceCallConfig): SetupStatus {
|
|
269
|
+
const validation = validateProviderConfig(config);
|
|
270
|
+
const webhookExposure = resolveWebhookExposureStatus(config);
|
|
271
|
+
const checks: SetupCheck[] = [
|
|
272
|
+
{
|
|
273
|
+
id: "plugin-enabled",
|
|
274
|
+
ok: config.enabled,
|
|
275
|
+
message: config.enabled
|
|
276
|
+
? "Voice Call plugin is enabled"
|
|
277
|
+
: "Enable plugins.entries.voice-call.enabled",
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
id: "provider",
|
|
281
|
+
ok: Boolean(config.provider),
|
|
282
|
+
message: config.provider
|
|
283
|
+
? `Provider configured: ${config.provider}`
|
|
284
|
+
: "Set plugins.entries.voice-call.config.provider",
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: "provider-config",
|
|
288
|
+
ok: validation.valid,
|
|
289
|
+
message: validation.valid
|
|
290
|
+
? "Provider credentials/config look complete"
|
|
291
|
+
: validation.errors.join("; "),
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
id: "webhook-exposure",
|
|
295
|
+
ok: webhookExposure.ok,
|
|
296
|
+
message: webhookExposure.message,
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
id: "mode",
|
|
300
|
+
ok: !(config.streaming.enabled && config.realtime.enabled),
|
|
301
|
+
message:
|
|
302
|
+
config.streaming.enabled && config.realtime.enabled
|
|
303
|
+
? "streaming.enabled and realtime.enabled cannot both be true"
|
|
304
|
+
: config.realtime.enabled
|
|
305
|
+
? `Realtime voice enabled (${config.realtime.provider ?? "first registered provider"})`
|
|
306
|
+
: config.streaming.enabled
|
|
307
|
+
? `Streaming transcription enabled (${config.streaming.provider ?? "first registered provider"})`
|
|
308
|
+
: "Notify/conversation calls use normal TTS/STT flow",
|
|
309
|
+
},
|
|
310
|
+
];
|
|
311
|
+
return {
|
|
312
|
+
ok: checks.every((check) => check.ok),
|
|
313
|
+
checks,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function writeSetupStatus(status: SetupStatus): void {
|
|
318
|
+
writeStdoutLine("Voice Call setup: %s", status.ok ? "OK" : "needs attention");
|
|
319
|
+
for (const check of status.checks) {
|
|
320
|
+
writeStdoutLine("%s %s: %s", check.ok ? "OK" : "FAIL", check.id, check.message);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
88
324
|
async function initiateCallAndPrintId(params: {
|
|
89
325
|
runtime: VoiceCallRuntime;
|
|
90
326
|
to: string;
|
|
@@ -98,8 +334,56 @@ async function initiateCallAndPrintId(params: {
|
|
|
98
334
|
if (!result.success) {
|
|
99
335
|
throw new Error(result.error || "initiate failed");
|
|
100
336
|
}
|
|
101
|
-
|
|
102
|
-
|
|
337
|
+
writeStdoutJson({ callId: result.callId });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function writeGatewayCallId(payload: unknown): void {
|
|
341
|
+
if (isRecord(payload) && typeof payload.callId === "string") {
|
|
342
|
+
writeStdoutJson({ callId: payload.callId });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (isRecord(payload) && typeof payload.error === "string") {
|
|
346
|
+
throw new Error(payload.error);
|
|
347
|
+
}
|
|
348
|
+
throw new Error("voicecall gateway response missing callId");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function initiateCallViaGatewayOrRuntime(params: {
|
|
352
|
+
ensureRuntime: () => Promise<VoiceCallRuntime>;
|
|
353
|
+
config: VoiceCallConfig;
|
|
354
|
+
method: "voicecall.initiate" | "voicecall.start";
|
|
355
|
+
to?: string;
|
|
356
|
+
message?: string;
|
|
357
|
+
mode?: string;
|
|
358
|
+
}) {
|
|
359
|
+
const mode = resolveCallMode(params.mode);
|
|
360
|
+
const gateway = await callVoiceCallGateway(
|
|
361
|
+
params.method,
|
|
362
|
+
{
|
|
363
|
+
...(params.to ? { to: params.to } : {}),
|
|
364
|
+
...(params.message ? { message: params.message } : {}),
|
|
365
|
+
...(mode ? { mode } : {}),
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
timeoutMs: resolveGatewayOperationTimeoutMs(params.config),
|
|
369
|
+
},
|
|
370
|
+
);
|
|
371
|
+
if (gateway.ok) {
|
|
372
|
+
writeGatewayCallId(gateway.payload);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const rt = await params.ensureRuntime();
|
|
377
|
+
const to = params.to ?? rt.config.toNumber;
|
|
378
|
+
if (!to) {
|
|
379
|
+
throw new Error("Missing --to and no toNumber configured");
|
|
380
|
+
}
|
|
381
|
+
await initiateCallAndPrintId({
|
|
382
|
+
runtime: rt,
|
|
383
|
+
to,
|
|
384
|
+
message: params.message,
|
|
385
|
+
mode: params.mode,
|
|
386
|
+
});
|
|
103
387
|
}
|
|
104
388
|
|
|
105
389
|
export function registerVoiceCallCli(params: {
|
|
@@ -114,6 +398,105 @@ export function registerVoiceCallCli(params: {
|
|
|
114
398
|
.description("Voice call utilities")
|
|
115
399
|
.addHelpText("after", () => `\nDocs: https://docs.openclaw.ai/cli/voicecall\n`);
|
|
116
400
|
|
|
401
|
+
root
|
|
402
|
+
.command("setup")
|
|
403
|
+
.description("Show Voice Call provider and webhook setup status")
|
|
404
|
+
.option("--json", "Print machine-readable JSON")
|
|
405
|
+
.action((options: { json?: boolean }) => {
|
|
406
|
+
const status = buildSetupStatus(config);
|
|
407
|
+
if (options.json) {
|
|
408
|
+
writeStdoutJson(status);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
writeSetupStatus(status);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
root
|
|
415
|
+
.command("smoke")
|
|
416
|
+
.description("Check Voice Call readiness and optionally place a short outbound test call")
|
|
417
|
+
.option("-t, --to <phone>", "Phone number to call for a live smoke")
|
|
418
|
+
.option(
|
|
419
|
+
"--message <text>",
|
|
420
|
+
"Message to speak during the smoke call",
|
|
421
|
+
"OpenClaw voice call smoke test.",
|
|
422
|
+
)
|
|
423
|
+
.option("--mode <mode>", "Call mode: notify or conversation", "notify")
|
|
424
|
+
.option("--yes", "Actually place the live outbound call")
|
|
425
|
+
.option("--json", "Print machine-readable JSON")
|
|
426
|
+
.action(
|
|
427
|
+
async (options: {
|
|
428
|
+
to?: string;
|
|
429
|
+
message?: string;
|
|
430
|
+
mode?: string;
|
|
431
|
+
yes?: boolean;
|
|
432
|
+
json?: boolean;
|
|
433
|
+
}) => {
|
|
434
|
+
const setup = buildSetupStatus(config);
|
|
435
|
+
if (!setup.ok) {
|
|
436
|
+
if (options.json) {
|
|
437
|
+
writeStdoutJson({ ok: false, setup });
|
|
438
|
+
} else {
|
|
439
|
+
writeSetupStatus(setup);
|
|
440
|
+
}
|
|
441
|
+
process.exitCode = 1;
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (!options.to) {
|
|
445
|
+
if (options.json) {
|
|
446
|
+
writeStdoutJson({ ok: true, setup, liveCall: false });
|
|
447
|
+
} else {
|
|
448
|
+
writeSetupStatus(setup);
|
|
449
|
+
writeStdoutLine("live-call: skipped (pass --to and --yes to place one)");
|
|
450
|
+
}
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (!options.yes) {
|
|
454
|
+
if (options.json) {
|
|
455
|
+
writeStdoutJson({ ok: true, setup, liveCall: false, wouldCall: options.to });
|
|
456
|
+
} else {
|
|
457
|
+
writeSetupStatus(setup);
|
|
458
|
+
writeStdoutLine("live-call: dry run for %s (add --yes to place it)", options.to);
|
|
459
|
+
}
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const mode = resolveCallMode(options.mode) ?? "notify";
|
|
463
|
+
const gateway = await callVoiceCallGateway(
|
|
464
|
+
"voicecall.start",
|
|
465
|
+
{
|
|
466
|
+
to: options.to,
|
|
467
|
+
...(options.message ? { message: options.message } : {}),
|
|
468
|
+
mode,
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
timeoutMs: resolveGatewayOperationTimeoutMs(config),
|
|
472
|
+
},
|
|
473
|
+
);
|
|
474
|
+
let callId: unknown;
|
|
475
|
+
if (gateway.ok) {
|
|
476
|
+
callId = isRecord(gateway.payload) ? gateway.payload.callId : undefined;
|
|
477
|
+
} else {
|
|
478
|
+
const rt = await ensureRuntime();
|
|
479
|
+
const result = await rt.manager.initiateCall(options.to, undefined, {
|
|
480
|
+
message: options.message,
|
|
481
|
+
mode,
|
|
482
|
+
});
|
|
483
|
+
if (!result.success) {
|
|
484
|
+
throw new Error(result.error || "smoke call failed");
|
|
485
|
+
}
|
|
486
|
+
callId = result.callId;
|
|
487
|
+
}
|
|
488
|
+
if (typeof callId !== "string" || !callId) {
|
|
489
|
+
throw new Error("smoke call failed");
|
|
490
|
+
}
|
|
491
|
+
if (options.json) {
|
|
492
|
+
writeStdoutJson({ ok: true, setup, liveCall: true, callId });
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
writeSetupStatus(setup);
|
|
496
|
+
writeStdoutLine("live-call: started %s", callId);
|
|
497
|
+
},
|
|
498
|
+
);
|
|
499
|
+
|
|
117
500
|
root
|
|
118
501
|
.command("call")
|
|
119
502
|
.description("Initiate an outbound voice call")
|
|
@@ -128,14 +511,11 @@ export function registerVoiceCallCli(params: {
|
|
|
128
511
|
"conversation",
|
|
129
512
|
)
|
|
130
513
|
.action(async (options: { message: string; to?: string; mode?: string }) => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
await initiateCallAndPrintId({
|
|
137
|
-
runtime: rt,
|
|
138
|
-
to,
|
|
514
|
+
await initiateCallViaGatewayOrRuntime({
|
|
515
|
+
ensureRuntime,
|
|
516
|
+
config,
|
|
517
|
+
method: "voicecall.initiate",
|
|
518
|
+
to: options.to,
|
|
139
519
|
message: options.message,
|
|
140
520
|
mode: options.mode,
|
|
141
521
|
});
|
|
@@ -152,9 +532,10 @@ export function registerVoiceCallCli(params: {
|
|
|
152
532
|
"conversation",
|
|
153
533
|
)
|
|
154
534
|
.action(async (options: { to: string; message?: string; mode?: string }) => {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
535
|
+
await initiateCallViaGatewayOrRuntime({
|
|
536
|
+
ensureRuntime,
|
|
537
|
+
config,
|
|
538
|
+
method: "voicecall.start",
|
|
158
539
|
to: options.to,
|
|
159
540
|
message: options.message,
|
|
160
541
|
mode: options.mode,
|
|
@@ -167,13 +548,54 @@ export function registerVoiceCallCli(params: {
|
|
|
167
548
|
.requiredOption("--call-id <id>", "Call ID")
|
|
168
549
|
.requiredOption("--message <text>", "Message to speak")
|
|
169
550
|
.action(async (options: { callId: string; message: string }) => {
|
|
551
|
+
let gateway: VoiceCallGatewayCallResult;
|
|
552
|
+
try {
|
|
553
|
+
gateway = await callVoiceCallGateway(
|
|
554
|
+
"voicecall.continue.start",
|
|
555
|
+
{
|
|
556
|
+
callId: options.callId,
|
|
557
|
+
message: options.message,
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
timeoutMs: resolveGatewayOperationTimeoutMs(config),
|
|
561
|
+
},
|
|
562
|
+
);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
if (!isUnknownGatewayMethod(err, "voicecall.continue.start")) {
|
|
565
|
+
throw err;
|
|
566
|
+
}
|
|
567
|
+
gateway = await callVoiceCallGateway(
|
|
568
|
+
"voicecall.continue",
|
|
569
|
+
{
|
|
570
|
+
callId: options.callId,
|
|
571
|
+
message: options.message,
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
timeoutMs: resolveGatewayContinueTimeoutMs(config),
|
|
575
|
+
},
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
if (gateway.ok) {
|
|
579
|
+
if (isRecord(gateway.payload) && typeof gateway.payload.operationId === "string") {
|
|
580
|
+
const result = await pollVoiceCallContinueGateway({
|
|
581
|
+
operationId: readGatewayOperationId(gateway.payload),
|
|
582
|
+
timeoutMs: readGatewayPollTimeoutMs(
|
|
583
|
+
gateway.payload,
|
|
584
|
+
resolveGatewayContinueTimeoutMs(config),
|
|
585
|
+
),
|
|
586
|
+
});
|
|
587
|
+
writeStdoutJson(result);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
writeStdoutJson(gateway.payload);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
170
593
|
const rt = await ensureRuntime();
|
|
171
594
|
const result = await rt.manager.continueCall(options.callId, options.message);
|
|
172
595
|
if (!result.success) {
|
|
173
596
|
throw new Error(result.error || "continue failed");
|
|
174
597
|
}
|
|
175
|
-
|
|
176
|
-
console.log(JSON.stringify(result, null, 2));
|
|
598
|
+
writeStdoutJson(result);
|
|
177
599
|
});
|
|
178
600
|
|
|
179
601
|
root
|
|
@@ -182,13 +604,42 @@ export function registerVoiceCallCli(params: {
|
|
|
182
604
|
.requiredOption("--call-id <id>", "Call ID")
|
|
183
605
|
.requiredOption("--message <text>", "Message to speak")
|
|
184
606
|
.action(async (options: { callId: string; message: string }) => {
|
|
607
|
+
const gateway = await callVoiceCallGateway("voicecall.speak", {
|
|
608
|
+
callId: options.callId,
|
|
609
|
+
message: options.message,
|
|
610
|
+
});
|
|
611
|
+
if (gateway.ok) {
|
|
612
|
+
writeStdoutJson(gateway.payload);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
185
615
|
const rt = await ensureRuntime();
|
|
186
616
|
const result = await rt.manager.speak(options.callId, options.message);
|
|
187
617
|
if (!result.success) {
|
|
188
618
|
throw new Error(result.error || "speak failed");
|
|
189
619
|
}
|
|
190
|
-
|
|
191
|
-
|
|
620
|
+
writeStdoutJson(result);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
root
|
|
624
|
+
.command("dtmf")
|
|
625
|
+
.description("Send DTMF digits to an active call")
|
|
626
|
+
.requiredOption("--call-id <id>", "Call ID")
|
|
627
|
+
.requiredOption("--digits <digits>", "DTMF digits")
|
|
628
|
+
.action(async (options: { callId: string; digits: string }) => {
|
|
629
|
+
const gateway = await callVoiceCallGateway("voicecall.dtmf", {
|
|
630
|
+
callId: options.callId,
|
|
631
|
+
digits: options.digits,
|
|
632
|
+
});
|
|
633
|
+
if (gateway.ok) {
|
|
634
|
+
writeStdoutJson(gateway.payload);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const rt = await ensureRuntime();
|
|
638
|
+
const result = await rt.manager.sendDtmf(options.callId, options.digits);
|
|
639
|
+
if (!result.success) {
|
|
640
|
+
throw new Error(result.error || "dtmf failed");
|
|
641
|
+
}
|
|
642
|
+
writeStdoutJson(result);
|
|
192
643
|
});
|
|
193
644
|
|
|
194
645
|
root
|
|
@@ -196,24 +647,55 @@ export function registerVoiceCallCli(params: {
|
|
|
196
647
|
.description("Hang up an active call")
|
|
197
648
|
.requiredOption("--call-id <id>", "Call ID")
|
|
198
649
|
.action(async (options: { callId: string }) => {
|
|
650
|
+
const gateway = await callVoiceCallGateway("voicecall.end", {
|
|
651
|
+
callId: options.callId,
|
|
652
|
+
});
|
|
653
|
+
if (gateway.ok) {
|
|
654
|
+
writeStdoutJson(gateway.payload);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
199
657
|
const rt = await ensureRuntime();
|
|
200
658
|
const result = await rt.manager.endCall(options.callId);
|
|
201
659
|
if (!result.success) {
|
|
202
660
|
throw new Error(result.error || "end failed");
|
|
203
661
|
}
|
|
204
|
-
|
|
205
|
-
console.log(JSON.stringify(result, null, 2));
|
|
662
|
+
writeStdoutJson(result);
|
|
206
663
|
});
|
|
207
664
|
|
|
208
665
|
root
|
|
209
666
|
.command("status")
|
|
210
667
|
.description("Show call status")
|
|
211
|
-
.
|
|
212
|
-
.
|
|
668
|
+
.option("--call-id <id>", "Call ID")
|
|
669
|
+
.option("--json", "Print machine-readable JSON")
|
|
670
|
+
.action(async (options: { callId?: string; json?: boolean }) => {
|
|
671
|
+
const gateway = await callVoiceCallGateway(
|
|
672
|
+
"voicecall.status",
|
|
673
|
+
options.callId ? { callId: options.callId } : undefined,
|
|
674
|
+
);
|
|
675
|
+
if (gateway.ok) {
|
|
676
|
+
if (options.callId && isRecord(gateway.payload)) {
|
|
677
|
+
if (gateway.payload.found === true && "call" in gateway.payload) {
|
|
678
|
+
writeStdoutJson(gateway.payload.call);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (gateway.payload.found === false) {
|
|
682
|
+
writeStdoutJson({ found: false });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
writeStdoutJson(gateway.payload);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
213
689
|
const rt = await ensureRuntime();
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
690
|
+
if (options.callId) {
|
|
691
|
+
const call = rt.manager.getCall(options.callId);
|
|
692
|
+
writeStdoutJson(call ?? { found: false });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
writeStdoutJson({
|
|
696
|
+
found: true,
|
|
697
|
+
calls: rt.manager.getActiveCalls(),
|
|
698
|
+
});
|
|
217
699
|
});
|
|
218
700
|
|
|
219
701
|
root
|
|
@@ -235,8 +717,7 @@ export function registerVoiceCallCli(params: {
|
|
|
235
717
|
const initial = fs.readFileSync(file, "utf8");
|
|
236
718
|
const lines = initial.split("\n").filter(Boolean);
|
|
237
719
|
for (const line of lines.slice(Math.max(0, lines.length - since))) {
|
|
238
|
-
|
|
239
|
-
console.log(line);
|
|
720
|
+
writeStdoutLine(line);
|
|
240
721
|
}
|
|
241
722
|
|
|
242
723
|
let offset = Buffer.byteLength(initial, "utf8");
|
|
@@ -255,8 +736,7 @@ export function registerVoiceCallCli(params: {
|
|
|
255
736
|
offset = stat.size;
|
|
256
737
|
const text = buf.toString("utf8");
|
|
257
738
|
for (const line of text.split("\n").filter(Boolean)) {
|
|
258
|
-
|
|
259
|
-
console.log(line);
|
|
739
|
+
writeStdoutLine(line);
|
|
260
740
|
}
|
|
261
741
|
} finally {
|
|
262
742
|
fs.closeSync(fd);
|
|
@@ -306,18 +786,11 @@ export function registerVoiceCallCli(params: {
|
|
|
306
786
|
}
|
|
307
787
|
}
|
|
308
788
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
turnLatency: summarizeSeries(turnLatencyMs),
|
|
315
|
-
listenWait: summarizeSeries(listenWaitMs),
|
|
316
|
-
},
|
|
317
|
-
null,
|
|
318
|
-
2,
|
|
319
|
-
),
|
|
320
|
-
);
|
|
789
|
+
writeStdoutJson({
|
|
790
|
+
recordsScanned: lines.length,
|
|
791
|
+
turnLatency: summarizeSeries(turnLatencyMs),
|
|
792
|
+
listenWait: summarizeSeries(listenWaitMs),
|
|
793
|
+
});
|
|
321
794
|
});
|
|
322
795
|
|
|
323
796
|
root
|
|
@@ -331,16 +804,15 @@ export function registerVoiceCallCli(params: {
|
|
|
331
804
|
async (options: { mode?: string; port?: string; path?: string; servePath?: string }) => {
|
|
332
805
|
const mode = resolveMode(options.mode ?? "funnel");
|
|
333
806
|
const servePort = Number(options.port ?? config.serve.port ?? 3334);
|
|
334
|
-
const servePath =
|
|
335
|
-
const tsPath =
|
|
807
|
+
const servePath = options.servePath ?? config.serve.path ?? "/voice/webhook";
|
|
808
|
+
const tsPath = options.path ?? config.tailscale?.path ?? servePath;
|
|
336
809
|
|
|
337
810
|
const localUrl = `http://127.0.0.1:${servePort}`;
|
|
338
811
|
|
|
339
812
|
if (mode === "off") {
|
|
340
813
|
await cleanupTailscaleExposureRoute({ mode: "serve", path: tsPath });
|
|
341
814
|
await cleanupTailscaleExposureRoute({ mode: "funnel", path: tsPath });
|
|
342
|
-
|
|
343
|
-
console.log(JSON.stringify({ ok: true, mode: "off", path: tsPath }, null, 2));
|
|
815
|
+
writeStdoutJson({ ok: true, mode: "off", path: tsPath });
|
|
344
816
|
return;
|
|
345
817
|
}
|
|
346
818
|
|
|
@@ -355,26 +827,19 @@ export function registerVoiceCallCli(params: {
|
|
|
355
827
|
? `https://login.tailscale.com/f/${mode}?node=${tsInfo.nodeId}`
|
|
356
828
|
: null;
|
|
357
829
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
enableUrl,
|
|
372
|
-
},
|
|
373
|
-
},
|
|
374
|
-
null,
|
|
375
|
-
2,
|
|
376
|
-
),
|
|
377
|
-
);
|
|
830
|
+
writeStdoutJson({
|
|
831
|
+
ok: Boolean(publicUrl),
|
|
832
|
+
mode,
|
|
833
|
+
path: tsPath,
|
|
834
|
+
localUrl,
|
|
835
|
+
publicUrl,
|
|
836
|
+
hint: publicUrl
|
|
837
|
+
? undefined
|
|
838
|
+
: {
|
|
839
|
+
note: "Tailscale serve/funnel may be disabled on this tailnet (or require admin enable).",
|
|
840
|
+
enableUrl,
|
|
841
|
+
},
|
|
842
|
+
});
|
|
378
843
|
},
|
|
379
844
|
);
|
|
380
845
|
}
|