@openclaw/voice-call 2026.7.2-beta.7 → 2026.8.1-beta.3
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 +4 -3
- package/dist/doctor-contract-api.js +11 -8
- package/dist/{guarded-json-api-DS9sZa3C.js → guarded-json-api-j4OEG0fR.js} +24 -46
- package/dist/index.js +644 -913
- package/dist/{mock-YsvBTX-7.js → mock-Dv7KxYGt.js} +5 -4
- package/dist/{plivo-vZ--Wqxv.js → plivo-BbVnahnQ.js} +45 -8
- package/dist/{realtime-handler--i_OERv6.js → realtime-handler-BaMEKbmJ.js} +579 -209
- package/dist/{response-generator-D-7ApVfT.js → response-generator-tjyX4tDq.js} +70 -15
- package/dist/{runtime-entry-D0UJiLfi.js → runtime-entry-DK8kPCGC.js} +687 -355
- package/dist/runtime-entry.js +1 -1
- package/dist/setup-api.js +17 -24
- package/dist/{store-path-nYL-yM0S.js → store-path-51_oC_xn.js} +70 -13
- package/dist/{telnyx-Dx6cfLvV.js → telnyx-DO8dFQcb.js} +14 -9
- package/dist/{twilio-D_D8KysU.js → twilio-JDt_1nlz.js} +58 -51
- package/dist/{webhook-replay-aaP79dI8.js → webhook-replay-FQrEGVSM.js} +20 -6
- package/openclaw.plugin.json +69 -6
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,40 +1,183 @@
|
|
|
1
1
|
import { definePluginEntry, sleep } from "./runtime-api.js";
|
|
2
2
|
import "./api.js";
|
|
3
3
|
import { VOICE_CALL_CLI_DESCRIPTOR } from "./cli-output-mode.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { C as resolveVoiceCallStreamExposurePaths, b as resolveVoiceCallConfig, c as resolveWebhookExposureStatus, d as setupTailscaleExposureRoutes, g as resolveUserPath, l as cleanupTailscaleExposureRoute, t as createVoiceCallRuntime, u as getTailscaleSelfInfo, w as validateProviderConfig, y as VoiceCallConfigSchema } from "./runtime-entry-DK8kPCGC.js";
|
|
5
|
+
import { b as setVoiceCallStateRuntime, d as loadActiveCallsFromStore, l as findCallMatchesInStore, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore } from "./store-path-51_oC_xn.js";
|
|
6
6
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
7
|
-
import { ErrorCodes, callGatewayFromCli, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
|
|
8
|
-
import {
|
|
9
|
-
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
|
10
|
-
import { asOptionalRecord, isRecord, normalizeOptionalLowercaseString, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
7
|
+
import { ErrorCodes, callGatewayFromCli, errorShape, isGatewayClientRequestError, isGatewayTransportError, redactSensitiveUrlLikeString } from "openclaw/plugin-sdk/gateway-runtime";
|
|
8
|
+
import { resolveGlobalSingleton } from "openclaw/plugin-sdk/global-singleton";
|
|
9
|
+
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
|
10
|
+
import { asNonArrayRecord, asOptionalRecord, isRecord, normalizeOptionalLowercaseString, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
11
11
|
import { jsonResult } from "openclaw/plugin-sdk/tool-results";
|
|
12
12
|
import { Type } from "typebox";
|
|
13
|
+
import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, addTimerTimeoutGraceMs, clampTimerTimeoutMs, parseStrictNonNegativeInteger, resolveTimerTimeoutMs, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
|
13
14
|
import fs from "node:fs";
|
|
14
15
|
import { randomUUID } from "node:crypto";
|
|
15
16
|
import path from "node:path";
|
|
16
17
|
import { StringDecoder } from "node:string_decoder";
|
|
17
18
|
import { format } from "node:util";
|
|
18
|
-
//#region extensions/voice-call/src/cli.ts
|
|
19
|
-
|
|
20
|
-
const VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS = 3e4;
|
|
21
|
-
const VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS = 1e4;
|
|
22
|
-
const VOICE_CALL_GATEWAY_POLL_INTERVAL_MS = 1e3;
|
|
23
|
-
function writeStdoutLine(...values) {
|
|
19
|
+
//#region extensions/voice-call/src/cli-command-io.ts
|
|
20
|
+
function writeCliLine(...values) {
|
|
24
21
|
process.stdout.write(`${format(...values)}\n`);
|
|
25
22
|
}
|
|
26
|
-
function
|
|
23
|
+
function writeCliJson(value) {
|
|
27
24
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
28
25
|
}
|
|
29
|
-
function
|
|
26
|
+
function parseCliInteger(raw, optionName, opts) {
|
|
30
27
|
const min = opts?.min ?? 0;
|
|
31
28
|
const parsed = parseStrictNonNegativeInteger(raw?.trim() ?? "");
|
|
32
29
|
if (parsed === void 0 || parsed < min || opts?.max !== void 0 && parsed > opts.max) throw new Error(`Invalid numeric value for ${optionName}: ${raw ?? ""}`);
|
|
33
30
|
return parsed;
|
|
34
31
|
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region extensions/voice-call/src/cli-call-log.ts
|
|
34
|
+
function percentile(values, p) {
|
|
35
|
+
if (values.length === 0) return 0;
|
|
36
|
+
const sorted = [...values].toSorted((a, b) => a - b);
|
|
37
|
+
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1))] ?? 0;
|
|
38
|
+
}
|
|
39
|
+
function summarizeSeries(values) {
|
|
40
|
+
if (values.length === 0) return {
|
|
41
|
+
count: 0,
|
|
42
|
+
minMs: 0,
|
|
43
|
+
maxMs: 0,
|
|
44
|
+
avgMs: 0,
|
|
45
|
+
p50Ms: 0,
|
|
46
|
+
p95Ms: 0
|
|
47
|
+
};
|
|
48
|
+
const minMs = values.reduce((min, value) => value < min ? value : min);
|
|
49
|
+
const maxMs = values.reduce((max, value) => value > max ? value : max);
|
|
50
|
+
const avgMs = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
51
|
+
return {
|
|
52
|
+
count: values.length,
|
|
53
|
+
minMs,
|
|
54
|
+
maxMs,
|
|
55
|
+
avgMs,
|
|
56
|
+
p50Ms: percentile(values, 50),
|
|
57
|
+
p95Ms: percentile(values, 95)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function writeVoiceCallLatencySummary(calls) {
|
|
61
|
+
const turnLatencyMs = [];
|
|
62
|
+
const listenWaitMs = [];
|
|
63
|
+
for (const call of calls) {
|
|
64
|
+
const metadata = isRecord(call) && isRecord(call.metadata) ? call.metadata : void 0;
|
|
65
|
+
const latency = metadata?.lastTurnLatencyMs;
|
|
66
|
+
const listenWait = metadata?.lastTurnListenWaitMs;
|
|
67
|
+
if (typeof latency === "number" && Number.isFinite(latency)) turnLatencyMs.push(latency);
|
|
68
|
+
if (typeof listenWait === "number" && Number.isFinite(listenWait)) listenWaitMs.push(listenWait);
|
|
69
|
+
}
|
|
70
|
+
writeCliJson({
|
|
71
|
+
recordsScanned: calls.length,
|
|
72
|
+
turnLatency: summarizeSeries(turnLatencyMs),
|
|
73
|
+
listenWait: summarizeSeries(listenWaitMs)
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function registerVoiceCallLogs(params) {
|
|
77
|
+
params.root.command("tail").description("Tail voice-call JSONL logs (prints new lines; useful during provider tests)").option("--file <path>", "Path to calls.jsonl", params.defaultFile).option("--since <n>", "Print last N lines first", "25").option("--poll <ms>", "Poll interval in ms", "250").action(async (options) => {
|
|
78
|
+
const file = options.file;
|
|
79
|
+
const since = parseCliInteger(options.since, "--since", { min: 0 });
|
|
80
|
+
const pollMs = parseCliInteger(options.poll, "--poll", { min: 50 });
|
|
81
|
+
const tailSqliteHistory = async (initialLimit) => {
|
|
82
|
+
params.ensureHistoryStateRuntime();
|
|
83
|
+
const seen = /* @__PURE__ */ new Set();
|
|
84
|
+
const printCall = (call) => {
|
|
85
|
+
const line = JSON.stringify(call);
|
|
86
|
+
if (!seen.has(line)) {
|
|
87
|
+
seen.add(line);
|
|
88
|
+
writeCliLine(line);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
if (initialLimit > 0) for (const call of await getCallHistoryFromStore(path.dirname(file), initialLimit)) printCall(call);
|
|
92
|
+
for (;;) {
|
|
93
|
+
try {
|
|
94
|
+
for (const call of await getCallHistoryFromStore(path.dirname(file), 1e3)) printCall(call);
|
|
95
|
+
} catch {}
|
|
96
|
+
await sleep(pollMs);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") {
|
|
100
|
+
const initial = fs.readFileSync(file);
|
|
101
|
+
let decoder = new StringDecoder("utf8");
|
|
102
|
+
const initialLines = decoder.write(initial).split("\n");
|
|
103
|
+
let pendingLine = initialLines.pop() ?? "";
|
|
104
|
+
const lines = initialLines.filter(Boolean);
|
|
105
|
+
for (const line of lines.slice(Math.max(0, lines.length - since))) writeCliLine(line);
|
|
106
|
+
let offset = initial.length;
|
|
107
|
+
let lastObservedSize = initial.length;
|
|
108
|
+
for (;;) {
|
|
109
|
+
try {
|
|
110
|
+
const stat = fs.statSync(file);
|
|
111
|
+
if (stat.size < lastObservedSize) {
|
|
112
|
+
offset = 0;
|
|
113
|
+
decoder = new StringDecoder("utf8");
|
|
114
|
+
pendingLine = "";
|
|
115
|
+
}
|
|
116
|
+
lastObservedSize = stat.size;
|
|
117
|
+
if (stat.size > offset) {
|
|
118
|
+
const fd = fs.openSync(file, "r");
|
|
119
|
+
try {
|
|
120
|
+
const buf = Buffer.alloc(stat.size - offset);
|
|
121
|
+
const bytesRead = fs.readSync(fd, buf, 0, buf.length, offset);
|
|
122
|
+
offset += bytesRead;
|
|
123
|
+
const text = decoder.write(buf.subarray(0, bytesRead));
|
|
124
|
+
const completeLines = `${pendingLine}${text}`.split("\n");
|
|
125
|
+
pendingLine = completeLines.pop() ?? "";
|
|
126
|
+
for (const line of completeLines.filter(Boolean)) writeCliLine(line);
|
|
127
|
+
} finally {
|
|
128
|
+
fs.closeSync(fd);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch {}
|
|
132
|
+
await sleep(pollMs);
|
|
133
|
+
}
|
|
134
|
+
} else await tailSqliteHistory(since);
|
|
135
|
+
});
|
|
136
|
+
params.root.command("latency").description("Summarize turn latency metrics from voice-call JSONL logs").option("--file <path>", "Path to calls.jsonl", params.defaultFile).option("--last <n>", "Analyze last N records", "200").action(async (options) => {
|
|
137
|
+
const file = options.file;
|
|
138
|
+
const last = parseCliInteger(options.last, "--last", { min: 1 });
|
|
139
|
+
if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") writeVoiceCallLatencySummary(fs.readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-last).map((line) => {
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(line);
|
|
142
|
+
return (isRecord(parsed) ? parsed.call : void 0) ?? parsed;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}).filter((call) => call !== null));
|
|
147
|
+
else {
|
|
148
|
+
params.ensureHistoryStateRuntime();
|
|
149
|
+
writeVoiceCallLatencySummary(await getCallHistoryFromStore(path.dirname(file), last));
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region extensions/voice-call/src/cli-gateway-call.ts
|
|
155
|
+
const VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS = 5e3;
|
|
156
|
+
const VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS = 3e4;
|
|
157
|
+
const VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS = 1e4;
|
|
158
|
+
const VOICE_CALL_GATEWAY_POLL_INTERVAL_MS = 1e3;
|
|
35
159
|
function isGatewayUnavailableForLocalFallback(err) {
|
|
160
|
+
return isGatewayTransportError(err) && err.kind === "closed" && (err.code === void 0 || err.code === 1006);
|
|
161
|
+
}
|
|
162
|
+
function isGatewayCredentialFailure(err) {
|
|
163
|
+
return err instanceof Error && (err.name === "GatewayCredentialsRequiredError" || err.name === "GatewayExplicitAuthRequiredError" || err.name === "GatewaySecretRefUnavailableError");
|
|
164
|
+
}
|
|
165
|
+
function gatewayOperationalError(err) {
|
|
36
166
|
const message = formatErrorMessage(err);
|
|
37
|
-
|
|
167
|
+
const detail = (() => {
|
|
168
|
+
if (isGatewayClientRequestError(err)) return `Gateway responded but voicecall failed: ${message}\nThe running Gateway owns the voice-call runtime; check \`openclaw gateway status\` or restart it.`;
|
|
169
|
+
if (isGatewayCredentialFailure(err)) return `Gateway requires credentials: ${message}\nConfigure gateway.auth or pair this device with \`openclaw devices approve --latest\`.`;
|
|
170
|
+
if (isGatewayTransportError(err)) {
|
|
171
|
+
const url = err.connectionDetails.url;
|
|
172
|
+
if (err.kind === "timeout") return `Gateway at ${url} did not answer within ${err.timeoutMs === void 0 ? "the configured timeout" : `${err.timeoutMs}ms`}: ${message}\nIt may be starting or wedged; check \`openclaw gateway status\`.`;
|
|
173
|
+
return `Gateway connection at ${url} failed: ${message}\nCheck gateway.auth and \`openclaw gateway status\`, then retry.`;
|
|
174
|
+
}
|
|
175
|
+
return `Gateway voicecall request failed: ${message}\nCheck \`openclaw gateway status\`, then retry.`;
|
|
176
|
+
})();
|
|
177
|
+
return new Error(redactSensitiveUrlLikeString(detail));
|
|
178
|
+
}
|
|
179
|
+
function isUnknownMethod(err, method) {
|
|
180
|
+
return formatErrorMessage(err).includes(`unknown method: ${method}`);
|
|
38
181
|
}
|
|
39
182
|
async function callVoiceCallGateway(method, params, opts) {
|
|
40
183
|
try {
|
|
@@ -51,21 +194,18 @@ async function callVoiceCallGateway(method, params, opts) {
|
|
|
51
194
|
ok: false,
|
|
52
195
|
error: err
|
|
53
196
|
};
|
|
54
|
-
throw err;
|
|
197
|
+
throw gatewayOperationalError(err);
|
|
55
198
|
}
|
|
56
199
|
}
|
|
57
|
-
function
|
|
200
|
+
function resolveOperationTimeout(config) {
|
|
58
201
|
return Math.max(VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS, addTimerTimeoutGraceMs(config.ringTimeoutMs) ?? 1);
|
|
59
202
|
}
|
|
60
|
-
function
|
|
203
|
+
function resolveContinueTimeout(config) {
|
|
61
204
|
return clampTimerTimeoutMs(config.transcriptTimeoutMs + VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS + VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS) ?? 1;
|
|
62
205
|
}
|
|
63
206
|
function resolveVoiceCallDeadlineMs(timeoutMs, nowMs = Date.now()) {
|
|
64
207
|
return nowMs + (clampTimerTimeoutMs(timeoutMs) ?? MAX_TIMER_TIMEOUT_MS);
|
|
65
208
|
}
|
|
66
|
-
function isUnknownGatewayMethod(err, method) {
|
|
67
|
-
return formatErrorMessage(err).includes(`unknown method: ${method}`);
|
|
68
|
-
}
|
|
69
209
|
function readGatewayOperationId(payload) {
|
|
70
210
|
if (isRecord(payload) && typeof payload.operationId === "string" && payload.operationId) return payload.operationId;
|
|
71
211
|
throw new Error("voicecall gateway response missing operationId");
|
|
@@ -87,7 +227,12 @@ function readCompletedContinueResult(payload) {
|
|
|
87
227
|
};
|
|
88
228
|
throw new Error("voicecall gateway response has unknown operation status");
|
|
89
229
|
}
|
|
90
|
-
async function
|
|
230
|
+
async function pollContinueGateway(payload, fallbackTimeoutMs) {
|
|
231
|
+
if (!isRecord(payload) || typeof payload.operationId !== "string") return payload;
|
|
232
|
+
const params = {
|
|
233
|
+
operationId: readGatewayOperationId(payload),
|
|
234
|
+
timeoutMs: readGatewayPollTimeoutMs(payload, fallbackTimeoutMs)
|
|
235
|
+
};
|
|
91
236
|
const deadlineMs = resolveVoiceCallDeadlineMs(params.timeoutMs);
|
|
92
237
|
for (;;) {
|
|
93
238
|
const remainingMs = deadlineMs - Date.now();
|
|
@@ -103,6 +248,54 @@ async function pollVoiceCallContinueGateway(params) {
|
|
|
103
248
|
}
|
|
104
249
|
throw new Error("voicecall continue timed out waiting for gateway operation");
|
|
105
250
|
}
|
|
251
|
+
async function ensureStandaloneRuntime(params) {
|
|
252
|
+
try {
|
|
253
|
+
return await params.ensureRuntime();
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (err instanceof Error && "code" in err && err.code === "EADDRINUSE") throw new Error(`Voice-call webhook port ${params.config.serve.port} is already in use. A running Gateway probably already serves it; operational commands route through that Gateway. Check \`openclaw gateway status\` and retry.`, { cause: err });
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async function runGatewayManagerCommand(params) {
|
|
260
|
+
const gateway = await params.gatewayCall();
|
|
261
|
+
if (gateway.ok) {
|
|
262
|
+
writeCliJson(params.resolveGatewayPayload ? await params.resolveGatewayPayload(gateway.payload) : gateway.payload);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const runtime = await ensureStandaloneRuntime(params);
|
|
266
|
+
const result = await params.managerFallback(runtime.manager);
|
|
267
|
+
if (!result.success) throw new Error(result.error || `${params.failureLabel} failed`);
|
|
268
|
+
writeCliJson(result);
|
|
269
|
+
}
|
|
270
|
+
function readGatewayCallId(payload, invalidCallIdMessage) {
|
|
271
|
+
if (isRecord(payload) && typeof payload.callId === "string") {
|
|
272
|
+
if (!invalidCallIdMessage || payload.callId) return payload.callId;
|
|
273
|
+
}
|
|
274
|
+
if (invalidCallIdMessage) throw new Error(invalidCallIdMessage);
|
|
275
|
+
if (isRecord(payload) && typeof payload.error === "string") throw new Error(payload.error);
|
|
276
|
+
throw new Error("voicecall gateway response missing callId");
|
|
277
|
+
}
|
|
278
|
+
async function initiateVoiceCall(params) {
|
|
279
|
+
const mode = params.mode === "notify" || params.mode === "conversation" ? params.mode : params.defaultMode;
|
|
280
|
+
const gateway = await callVoiceCallGateway(params.method, {
|
|
281
|
+
...params.to ? { to: params.to } : {},
|
|
282
|
+
...params.message ? { message: params.message } : {},
|
|
283
|
+
...mode ? { mode } : {}
|
|
284
|
+
}, { timeoutMs: resolveOperationTimeout(params.config) });
|
|
285
|
+
if (gateway.ok) return readGatewayCallId(gateway.payload, params.failureMessage);
|
|
286
|
+
const runtime = await ensureStandaloneRuntime(params);
|
|
287
|
+
const to = params.to ?? runtime.config.toNumber;
|
|
288
|
+
if (!to) throw new Error("Missing --to and no toNumber configured");
|
|
289
|
+
const result = await runtime.manager.initiateCall(to, void 0, {
|
|
290
|
+
message: params.message,
|
|
291
|
+
mode
|
|
292
|
+
});
|
|
293
|
+
if (!result.success) throw new Error(result.error || params.failureMessage || "initiate failed");
|
|
294
|
+
if (params.failureMessage && !result.callId) throw new Error(params.failureMessage);
|
|
295
|
+
return result.callId;
|
|
296
|
+
}
|
|
297
|
+
//#endregion
|
|
298
|
+
//#region extensions/voice-call/src/cli.ts
|
|
106
299
|
function resolveMode(input) {
|
|
107
300
|
const raw = normalizeOptionalLowercaseString(input) ?? "";
|
|
108
301
|
if (raw === "serve" || raw === "off") return raw;
|
|
@@ -112,35 +305,6 @@ function resolveDefaultStorePath(config) {
|
|
|
112
305
|
const base = config.store?.trim() ? resolveUserPath(config.store) : resolveDefaultVoiceCallStoreDir();
|
|
113
306
|
return path.join(base, "calls.jsonl");
|
|
114
307
|
}
|
|
115
|
-
function percentile(values, p) {
|
|
116
|
-
if (values.length === 0) return 0;
|
|
117
|
-
const sorted = [...values].toSorted((a, b) => a - b);
|
|
118
|
-
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1))] ?? 0;
|
|
119
|
-
}
|
|
120
|
-
function summarizeSeries(values) {
|
|
121
|
-
if (values.length === 0) return {
|
|
122
|
-
count: 0,
|
|
123
|
-
minMs: 0,
|
|
124
|
-
maxMs: 0,
|
|
125
|
-
avgMs: 0,
|
|
126
|
-
p50Ms: 0,
|
|
127
|
-
p95Ms: 0
|
|
128
|
-
};
|
|
129
|
-
const minMs = values.reduce((min, value) => value < min ? value : min, Number.POSITIVE_INFINITY);
|
|
130
|
-
const maxMs = values.reduce((max, value) => value > max ? value : max, Number.NEGATIVE_INFINITY);
|
|
131
|
-
const avgMs = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
132
|
-
return {
|
|
133
|
-
count: values.length,
|
|
134
|
-
minMs,
|
|
135
|
-
maxMs,
|
|
136
|
-
avgMs,
|
|
137
|
-
p50Ms: percentile(values, 50),
|
|
138
|
-
p95Ms: percentile(values, 95)
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
function resolveCallMode(mode) {
|
|
142
|
-
return mode === "notify" || mode === "conversation" ? mode : void 0;
|
|
143
|
-
}
|
|
144
308
|
function buildSetupStatus(config) {
|
|
145
309
|
const validation = validateProviderConfig(config);
|
|
146
310
|
const webhookExposure = resolveWebhookExposureStatus(config);
|
|
@@ -177,45 +341,8 @@ function buildSetupStatus(config) {
|
|
|
177
341
|
};
|
|
178
342
|
}
|
|
179
343
|
function writeSetupStatus(status) {
|
|
180
|
-
|
|
181
|
-
for (const check of status.checks)
|
|
182
|
-
}
|
|
183
|
-
async function initiateCallAndPrintId(params) {
|
|
184
|
-
const result = await params.runtime.manager.initiateCall(params.to, void 0, {
|
|
185
|
-
message: params.message,
|
|
186
|
-
mode: resolveCallMode(params.mode)
|
|
187
|
-
});
|
|
188
|
-
if (!result.success) throw new Error(result.error || "initiate failed");
|
|
189
|
-
writeStdoutJson({ callId: result.callId });
|
|
190
|
-
}
|
|
191
|
-
function writeGatewayCallId(payload) {
|
|
192
|
-
if (isRecord(payload) && typeof payload.callId === "string") {
|
|
193
|
-
writeStdoutJson({ callId: payload.callId });
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
if (isRecord(payload) && typeof payload.error === "string") throw new Error(payload.error);
|
|
197
|
-
throw new Error("voicecall gateway response missing callId");
|
|
198
|
-
}
|
|
199
|
-
async function initiateCallViaGatewayOrRuntime(params) {
|
|
200
|
-
const mode = resolveCallMode(params.mode);
|
|
201
|
-
const gateway = await callVoiceCallGateway(params.method, {
|
|
202
|
-
...params.to ? { to: params.to } : {},
|
|
203
|
-
...params.message ? { message: params.message } : {},
|
|
204
|
-
...mode ? { mode } : {}
|
|
205
|
-
}, { timeoutMs: resolveGatewayOperationTimeoutMs(params.config) });
|
|
206
|
-
if (gateway.ok) {
|
|
207
|
-
writeGatewayCallId(gateway.payload);
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
const rt = await params.ensureRuntime();
|
|
211
|
-
const to = params.to ?? rt.config.toNumber;
|
|
212
|
-
if (!to) throw new Error("Missing --to and no toNumber configured");
|
|
213
|
-
await initiateCallAndPrintId({
|
|
214
|
-
runtime: rt,
|
|
215
|
-
to,
|
|
216
|
-
message: params.message,
|
|
217
|
-
mode: params.mode
|
|
218
|
-
});
|
|
344
|
+
writeCliLine("Voice Call setup: %s", status.ok ? "OK" : "needs attention");
|
|
345
|
+
for (const check of status.checks) writeCliLine("%s %s: %s", check.ok ? "OK" : "FAIL", check.id, check.message);
|
|
219
346
|
}
|
|
220
347
|
function registerVoiceCallCli(params) {
|
|
221
348
|
const { program, config, ensureRuntime, stateRuntime } = params;
|
|
@@ -226,7 +353,7 @@ function registerVoiceCallCli(params) {
|
|
|
226
353
|
root.command("setup").description("Show Voice Call provider and webhook setup status").option("--json", "Print machine-readable JSON").action((options) => {
|
|
227
354
|
const status = buildSetupStatus(config);
|
|
228
355
|
if (options.json) {
|
|
229
|
-
|
|
356
|
+
writeCliJson(status);
|
|
230
357
|
return;
|
|
231
358
|
}
|
|
232
359
|
writeSetupStatus(status);
|
|
@@ -234,7 +361,7 @@ function registerVoiceCallCli(params) {
|
|
|
234
361
|
root.command("smoke").description("Check Voice Call readiness and optionally place a short outbound test call").option("-t, --to <phone>", "Phone number to call for a live smoke").option("--message <text>", "Message to speak during the smoke call", "OpenClaw voice call smoke test.").option("--mode <mode>", "Call mode: notify or conversation", "notify").option("--yes", "Actually place the live outbound call").option("--json", "Print machine-readable JSON").action(async (options) => {
|
|
235
362
|
const setup = buildSetupStatus(config);
|
|
236
363
|
if (!setup.ok) {
|
|
237
|
-
if (options.json)
|
|
364
|
+
if (options.json) writeCliJson({
|
|
238
365
|
ok: false,
|
|
239
366
|
setup
|
|
240
367
|
});
|
|
@@ -243,19 +370,19 @@ function registerVoiceCallCli(params) {
|
|
|
243
370
|
return;
|
|
244
371
|
}
|
|
245
372
|
if (!options.to) {
|
|
246
|
-
if (options.json)
|
|
373
|
+
if (options.json) writeCliJson({
|
|
247
374
|
ok: true,
|
|
248
375
|
setup,
|
|
249
376
|
liveCall: false
|
|
250
377
|
});
|
|
251
378
|
else {
|
|
252
379
|
writeSetupStatus(setup);
|
|
253
|
-
|
|
380
|
+
writeCliLine("live-call: skipped (pass --to and --yes to place one)");
|
|
254
381
|
}
|
|
255
382
|
return;
|
|
256
383
|
}
|
|
257
384
|
if (!options.yes) {
|
|
258
|
-
if (options.json)
|
|
385
|
+
if (options.json) writeCliJson({
|
|
259
386
|
ok: true,
|
|
260
387
|
setup,
|
|
261
388
|
liveCall: false,
|
|
@@ -263,29 +390,22 @@ function registerVoiceCallCli(params) {
|
|
|
263
390
|
});
|
|
264
391
|
else {
|
|
265
392
|
writeSetupStatus(setup);
|
|
266
|
-
|
|
393
|
+
writeCliLine("live-call: dry run for %s (add --yes to place it)", options.to);
|
|
267
394
|
}
|
|
268
395
|
return;
|
|
269
396
|
}
|
|
270
|
-
const
|
|
271
|
-
|
|
397
|
+
const callId = await initiateVoiceCall({
|
|
398
|
+
ensureRuntime,
|
|
399
|
+
config,
|
|
400
|
+
method: "voicecall.start",
|
|
272
401
|
to: options.to,
|
|
273
|
-
|
|
274
|
-
mode
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
else {
|
|
279
|
-
const result = await (await ensureRuntime()).manager.initiateCall(options.to, void 0, {
|
|
280
|
-
message: options.message,
|
|
281
|
-
mode
|
|
282
|
-
});
|
|
283
|
-
if (!result.success) throw new Error(result.error || "smoke call failed");
|
|
284
|
-
callId = result.callId;
|
|
285
|
-
}
|
|
286
|
-
if (typeof callId !== "string" || !callId) throw new Error("smoke call failed");
|
|
402
|
+
message: options.message,
|
|
403
|
+
mode: options.mode,
|
|
404
|
+
defaultMode: "notify",
|
|
405
|
+
failureMessage: "smoke call failed"
|
|
406
|
+
});
|
|
287
407
|
if (options.json) {
|
|
288
|
-
|
|
408
|
+
writeCliJson({
|
|
289
409
|
ok: true,
|
|
290
410
|
setup,
|
|
291
411
|
liveCall: true,
|
|
@@ -294,245 +414,162 @@ function registerVoiceCallCli(params) {
|
|
|
294
414
|
return;
|
|
295
415
|
}
|
|
296
416
|
writeSetupStatus(setup);
|
|
297
|
-
|
|
417
|
+
writeCliLine("live-call: started %s", callId);
|
|
298
418
|
});
|
|
299
419
|
root.command("call").description("Initiate an outbound voice call").requiredOption("-m, --message <text>", "Message to speak when call connects").option("-t, --to <phone>", "Phone number to call (E.164 format, uses config toNumber if not set)").option("--mode <mode>", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation").action(async (options) => {
|
|
300
|
-
await
|
|
420
|
+
writeCliJson({ callId: await initiateVoiceCall({
|
|
301
421
|
ensureRuntime,
|
|
302
422
|
config,
|
|
303
423
|
method: "voicecall.initiate",
|
|
304
424
|
to: options.to,
|
|
305
425
|
message: options.message,
|
|
306
426
|
mode: options.mode
|
|
307
|
-
});
|
|
427
|
+
}) });
|
|
308
428
|
});
|
|
309
429
|
root.command("start").description("Alias for voicecall call").requiredOption("--to <phone>", "Phone number to call").option("--message <text>", "Message to speak when call connects").option("--mode <mode>", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation").action(async (options) => {
|
|
310
|
-
await
|
|
430
|
+
writeCliJson({ callId: await initiateVoiceCall({
|
|
311
431
|
ensureRuntime,
|
|
312
432
|
config,
|
|
313
433
|
method: "voicecall.start",
|
|
314
434
|
to: options.to,
|
|
315
435
|
message: options.message,
|
|
316
436
|
mode: options.mode
|
|
317
|
-
});
|
|
437
|
+
}) });
|
|
318
438
|
});
|
|
319
439
|
root.command("continue").description("Speak a message and wait for a response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action(async (options) => {
|
|
320
|
-
|
|
321
|
-
try {
|
|
322
|
-
gateway = await callVoiceCallGateway("voicecall.continue.start", {
|
|
323
|
-
callId: options.callId,
|
|
324
|
-
message: options.message
|
|
325
|
-
}, { timeoutMs: resolveGatewayOperationTimeoutMs(config) });
|
|
326
|
-
} catch (err) {
|
|
327
|
-
if (!isUnknownGatewayMethod(err, "voicecall.continue.start")) throw err;
|
|
328
|
-
gateway = await callVoiceCallGateway("voicecall.continue", {
|
|
329
|
-
callId: options.callId,
|
|
330
|
-
message: options.message
|
|
331
|
-
}, { timeoutMs: resolveGatewayContinueTimeoutMs(config) });
|
|
332
|
-
}
|
|
333
|
-
if (gateway.ok) {
|
|
334
|
-
if (isRecord(gateway.payload) && typeof gateway.payload.operationId === "string") {
|
|
335
|
-
writeStdoutJson(await pollVoiceCallContinueGateway({
|
|
336
|
-
operationId: readGatewayOperationId(gateway.payload),
|
|
337
|
-
timeoutMs: readGatewayPollTimeoutMs(gateway.payload, resolveGatewayContinueTimeoutMs(config))
|
|
338
|
-
}));
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
writeStdoutJson(gateway.payload);
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
const result = await (await ensureRuntime()).manager.continueCall(options.callId, options.message);
|
|
345
|
-
if (!result.success) throw new Error(result.error || "continue failed");
|
|
346
|
-
writeStdoutJson(result);
|
|
347
|
-
});
|
|
348
|
-
root.command("speak").description("Speak a message without waiting for response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action(async (options) => {
|
|
349
|
-
const gateway = await callVoiceCallGateway("voicecall.speak", {
|
|
440
|
+
const gatewayParams = {
|
|
350
441
|
callId: options.callId,
|
|
351
442
|
message: options.message
|
|
443
|
+
};
|
|
444
|
+
const continueTimeoutMs = resolveContinueTimeout(config);
|
|
445
|
+
await runGatewayManagerCommand({
|
|
446
|
+
config,
|
|
447
|
+
ensureRuntime,
|
|
448
|
+
gatewayCall: async () => {
|
|
449
|
+
try {
|
|
450
|
+
return await callVoiceCallGateway("voicecall.continue.start", gatewayParams, { timeoutMs: resolveOperationTimeout(config) });
|
|
451
|
+
} catch (err) {
|
|
452
|
+
if (!isUnknownMethod(err, "voicecall.continue.start")) throw err;
|
|
453
|
+
return callVoiceCallGateway("voicecall.continue", gatewayParams, { timeoutMs: continueTimeoutMs });
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
resolveGatewayPayload: (payload) => pollContinueGateway(payload, continueTimeoutMs),
|
|
457
|
+
managerFallback: (manager) => manager.continueCall(options.callId, options.message),
|
|
458
|
+
failureLabel: "continue"
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
root.command("speak").description("Speak a message without waiting for response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action(async (options) => {
|
|
462
|
+
await runGatewayManagerCommand({
|
|
463
|
+
config,
|
|
464
|
+
ensureRuntime,
|
|
465
|
+
gatewayCall: () => callVoiceCallGateway("voicecall.speak", {
|
|
466
|
+
callId: options.callId,
|
|
467
|
+
message: options.message
|
|
468
|
+
}),
|
|
469
|
+
managerFallback: (manager) => manager.speak(options.callId, options.message),
|
|
470
|
+
failureLabel: "speak"
|
|
352
471
|
});
|
|
353
|
-
if (gateway.ok) {
|
|
354
|
-
writeStdoutJson(gateway.payload);
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
const result = await (await ensureRuntime()).manager.speak(options.callId, options.message);
|
|
358
|
-
if (!result.success) throw new Error(result.error || "speak failed");
|
|
359
|
-
writeStdoutJson(result);
|
|
360
472
|
});
|
|
361
473
|
root.command("dtmf").description("Send DTMF digits to an active call").requiredOption("--call-id <id>", "Call ID").requiredOption("--digits <digits>", "DTMF digits").action(async (options) => {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
474
|
+
await runGatewayManagerCommand({
|
|
475
|
+
config,
|
|
476
|
+
ensureRuntime,
|
|
477
|
+
gatewayCall: () => callVoiceCallGateway("voicecall.dtmf", {
|
|
478
|
+
callId: options.callId,
|
|
479
|
+
digits: options.digits
|
|
480
|
+
}),
|
|
481
|
+
managerFallback: (manager) => manager.sendDtmf(options.callId, options.digits),
|
|
482
|
+
failureLabel: "dtmf"
|
|
365
483
|
});
|
|
366
|
-
if (gateway.ok) {
|
|
367
|
-
writeStdoutJson(gateway.payload);
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
const result = await (await ensureRuntime()).manager.sendDtmf(options.callId, options.digits);
|
|
371
|
-
if (!result.success) throw new Error(result.error || "dtmf failed");
|
|
372
|
-
writeStdoutJson(result);
|
|
373
484
|
});
|
|
374
485
|
root.command("end").description("Hang up an active call").requiredOption("--call-id <id>", "Call ID").action(async (options) => {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
writeStdoutJson(result);
|
|
486
|
+
await runGatewayManagerCommand({
|
|
487
|
+
config,
|
|
488
|
+
ensureRuntime,
|
|
489
|
+
gatewayCall: () => callVoiceCallGateway("voicecall.end", { callId: options.callId }),
|
|
490
|
+
managerFallback: (manager) => manager.endCall(options.callId),
|
|
491
|
+
failureLabel: "end"
|
|
492
|
+
});
|
|
383
493
|
});
|
|
384
494
|
root.command("status").description("Show call status").option("--call-id <id>", "Call ID").option("--json", "Print machine-readable JSON").action(async (options) => {
|
|
385
495
|
const gateway = await callVoiceCallGateway("voicecall.status", options.callId ? { callId: options.callId } : void 0);
|
|
386
496
|
if (gateway.ok) {
|
|
387
497
|
if (options.callId && isRecord(gateway.payload)) {
|
|
388
498
|
if (gateway.payload.found === true && "call" in gateway.payload) {
|
|
389
|
-
|
|
499
|
+
writeCliJson(gateway.payload.call);
|
|
390
500
|
return;
|
|
391
501
|
}
|
|
392
502
|
if (gateway.payload.found === false) {
|
|
393
|
-
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
writeStdoutJson(gateway.payload);
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
const rt = await ensureRuntime();
|
|
401
|
-
if (options.callId) {
|
|
402
|
-
writeStdoutJson(await rt.manager.getCallFromMemoryOrStore(options.callId) ?? { found: false });
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
writeStdoutJson({
|
|
406
|
-
found: true,
|
|
407
|
-
calls: rt.manager.getActiveCalls()
|
|
408
|
-
});
|
|
409
|
-
});
|
|
410
|
-
root.command("tail").description("Tail voice-call JSONL logs (prints new lines; useful during provider tests)").option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config)).option("--since <n>", "Print last N lines first", "25").option("--poll <ms>", "Poll interval in ms", "250").action(async (options) => {
|
|
411
|
-
const file = options.file;
|
|
412
|
-
const since = parseVoiceCallIntOption(options.since, "--since", { min: 0 });
|
|
413
|
-
const pollMs = parseVoiceCallIntOption(options.poll, "--poll", { min: 50 });
|
|
414
|
-
const tailSqliteHistory = async (initialLimit) => {
|
|
415
|
-
ensureHistoryStateRuntime();
|
|
416
|
-
const seen = /* @__PURE__ */ new Set();
|
|
417
|
-
const printCall = (call) => {
|
|
418
|
-
const line = JSON.stringify(call);
|
|
419
|
-
if (!seen.has(line)) {
|
|
420
|
-
seen.add(line);
|
|
421
|
-
writeStdoutLine(line);
|
|
422
|
-
}
|
|
423
|
-
};
|
|
424
|
-
if (initialLimit > 0) for (const call of await getCallHistoryFromStore(path.dirname(file), initialLimit)) printCall(call);
|
|
425
|
-
for (;;) {
|
|
426
|
-
try {
|
|
427
|
-
for (const call of await getCallHistoryFromStore(path.dirname(file), 1e3)) printCall(call);
|
|
428
|
-
} catch {}
|
|
429
|
-
await sleep(pollMs);
|
|
430
|
-
}
|
|
431
|
-
};
|
|
432
|
-
if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") {
|
|
433
|
-
const initial = fs.readFileSync(file);
|
|
434
|
-
let decoder = new StringDecoder("utf8");
|
|
435
|
-
const initialLines = decoder.write(initial).split("\n");
|
|
436
|
-
let pendingLine = initialLines.pop() ?? "";
|
|
437
|
-
const lines = initialLines.filter(Boolean);
|
|
438
|
-
for (const line of lines.slice(Math.max(0, lines.length - since))) writeStdoutLine(line);
|
|
439
|
-
let offset = initial.length;
|
|
440
|
-
let lastObservedSize = initial.length;
|
|
441
|
-
for (;;) {
|
|
442
|
-
try {
|
|
443
|
-
const stat = fs.statSync(file);
|
|
444
|
-
if (stat.size < lastObservedSize) {
|
|
445
|
-
offset = 0;
|
|
446
|
-
decoder = new StringDecoder("utf8");
|
|
447
|
-
pendingLine = "";
|
|
448
|
-
}
|
|
449
|
-
lastObservedSize = stat.size;
|
|
450
|
-
if (stat.size > offset) {
|
|
451
|
-
const fd = fs.openSync(file, "r");
|
|
452
|
-
try {
|
|
453
|
-
const buf = Buffer.alloc(stat.size - offset);
|
|
454
|
-
const bytesRead = fs.readSync(fd, buf, 0, buf.length, offset);
|
|
455
|
-
offset += bytesRead;
|
|
456
|
-
const text = decoder.write(buf.subarray(0, bytesRead));
|
|
457
|
-
const completeLines = `${pendingLine}${text}`.split("\n");
|
|
458
|
-
pendingLine = completeLines.pop() ?? "";
|
|
459
|
-
for (const line of completeLines.filter(Boolean)) writeStdoutLine(line);
|
|
460
|
-
} finally {
|
|
461
|
-
fs.closeSync(fd);
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
} catch {}
|
|
465
|
-
await sleep(pollMs);
|
|
466
|
-
}
|
|
467
|
-
} else await tailSqliteHistory(since);
|
|
468
|
-
});
|
|
469
|
-
root.command("latency").description("Summarize turn latency metrics from voice-call JSONL logs").option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config)).option("--last <n>", "Analyze last N records", "200").action(async (options) => {
|
|
470
|
-
const file = options.file;
|
|
471
|
-
const last = parseVoiceCallIntOption(options.last, "--last", { min: 1 });
|
|
472
|
-
if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") writeVoiceCallLatencySummary(fs.readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-last).map((line) => {
|
|
473
|
-
try {
|
|
474
|
-
const parsed = JSON.parse(line);
|
|
475
|
-
return parsed.call ?? parsed;
|
|
476
|
-
} catch {
|
|
477
|
-
return null;
|
|
503
|
+
writeCliJson({ found: false });
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
478
506
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
ensureHistoryStateRuntime();
|
|
482
|
-
writeVoiceCallLatencySummary(await getCallHistoryFromStore(path.dirname(file), last));
|
|
507
|
+
writeCliJson(gateway.payload);
|
|
508
|
+
return;
|
|
483
509
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const listenWait = call.metadata?.lastTurnListenWaitMs;
|
|
491
|
-
if (typeof latency === "number" && Number.isFinite(latency)) turnLatencyMs.push(latency);
|
|
492
|
-
if (typeof listenWait === "number" && Number.isFinite(listenWait)) listenWaitMs.push(listenWait);
|
|
510
|
+
ensureHistoryStateRuntime();
|
|
511
|
+
const storePath = path.dirname(resolveDefaultStorePath(config));
|
|
512
|
+
if (options.callId) {
|
|
513
|
+
const persisted = await findCallMatchesInStore(storePath, options.callId);
|
|
514
|
+
writeCliJson(persisted.byCallId ?? persisted.byProviderCallId ?? { found: false });
|
|
515
|
+
return;
|
|
493
516
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
listenWait: summarizeSeries(listenWaitMs)
|
|
517
|
+
writeCliJson({
|
|
518
|
+
found: true,
|
|
519
|
+
calls: Array.from(loadActiveCallsFromStore(storePath).activeCalls.values())
|
|
498
520
|
});
|
|
499
|
-
}
|
|
521
|
+
});
|
|
522
|
+
registerVoiceCallLogs({
|
|
523
|
+
root,
|
|
524
|
+
defaultFile: resolveDefaultStorePath(config),
|
|
525
|
+
ensureHistoryStateRuntime
|
|
526
|
+
});
|
|
500
527
|
root.command("expose").description("Enable/disable Tailscale serve/funnel for the webhook").option("--mode <mode>", "off | serve (tailnet) | funnel (public)", "funnel").option("--path <path>", "Tailscale path to expose (recommend matching serve.path)").option("--port <port>", "Local webhook port").option("--serve-path <path>", "Local webhook path").action(async (options) => {
|
|
501
528
|
const mode = resolveMode(options.mode ?? "funnel");
|
|
502
|
-
const servePort =
|
|
529
|
+
const servePort = parseCliInteger(options.port ?? String(config.serve.port ?? 3334), "--port", {
|
|
503
530
|
min: 1,
|
|
504
531
|
max: MAX_TCP_PORT
|
|
505
532
|
});
|
|
506
533
|
const servePath = options.servePath ?? config.serve.path ?? "/voice/webhook";
|
|
507
534
|
const tsPath = options.path ?? config.tailscale?.path ?? servePath;
|
|
508
|
-
const
|
|
535
|
+
const streamExposurePaths = resolveVoiceCallStreamExposurePaths(config, {
|
|
536
|
+
publicWebhookPath: tsPath,
|
|
537
|
+
localWebhookPath: servePath
|
|
538
|
+
});
|
|
539
|
+
const streamPaths = streamExposurePaths.map(({ publicPath }) => publicPath);
|
|
540
|
+
const localUrl = `http://127.0.0.1:${servePort}${servePath}`;
|
|
509
541
|
if (mode === "off") {
|
|
510
|
-
await cleanupTailscaleExposureRoute({
|
|
511
|
-
mode:
|
|
512
|
-
|
|
542
|
+
for (const exposurePath of [tsPath, ...streamPaths]) for (const tailscaleMode of ["serve", "funnel"]) await cleanupTailscaleExposureRoute({
|
|
543
|
+
mode: tailscaleMode,
|
|
544
|
+
port: config.tailscale.port,
|
|
545
|
+
path: exposurePath
|
|
513
546
|
});
|
|
514
|
-
|
|
515
|
-
mode: "funnel",
|
|
516
|
-
path: tsPath
|
|
517
|
-
});
|
|
518
|
-
writeStdoutJson({
|
|
547
|
+
writeCliJson({
|
|
519
548
|
ok: true,
|
|
520
549
|
mode: "off",
|
|
521
|
-
path: tsPath
|
|
550
|
+
path: tsPath,
|
|
551
|
+
streamPaths
|
|
522
552
|
});
|
|
523
553
|
return;
|
|
524
554
|
}
|
|
525
|
-
const publicUrl = await
|
|
555
|
+
const publicUrl = await setupTailscaleExposureRoutes({
|
|
526
556
|
mode,
|
|
527
|
-
|
|
528
|
-
|
|
557
|
+
port: config.tailscale.port,
|
|
558
|
+
routes: [{
|
|
559
|
+
path: tsPath,
|
|
560
|
+
localUrl
|
|
561
|
+
}, ...streamExposurePaths.map(({ publicPath, localPath }) => ({
|
|
562
|
+
path: publicPath,
|
|
563
|
+
localUrl: `http://127.0.0.1:${servePort}${localPath}`
|
|
564
|
+
}))]
|
|
529
565
|
});
|
|
530
566
|
const tsInfo = publicUrl ? null : await getTailscaleSelfInfo();
|
|
531
567
|
const enableUrl = tsInfo?.nodeId ? `https://login.tailscale.com/f/${mode}?node=${tsInfo.nodeId}` : null;
|
|
532
|
-
|
|
568
|
+
writeCliJson({
|
|
533
569
|
ok: Boolean(publicUrl),
|
|
534
570
|
mode,
|
|
535
571
|
path: tsPath,
|
|
572
|
+
streamPaths,
|
|
536
573
|
localUrl,
|
|
537
574
|
publicUrl,
|
|
538
575
|
hint: publicUrl ? void 0 : {
|
|
@@ -543,6 +580,126 @@ function registerVoiceCallCli(params) {
|
|
|
543
580
|
});
|
|
544
581
|
}
|
|
545
582
|
//#endregion
|
|
583
|
+
//#region extensions/voice-call/src/command-service.ts
|
|
584
|
+
var VoiceCallCommandInputError = class extends Error {};
|
|
585
|
+
function toVoiceCallStatus(call) {
|
|
586
|
+
return {
|
|
587
|
+
callId: call.callId,
|
|
588
|
+
...call.providerCallId !== void 0 ? { providerCallId: call.providerCallId } : {},
|
|
589
|
+
provider: call.provider,
|
|
590
|
+
direction: call.direction,
|
|
591
|
+
state: call.state,
|
|
592
|
+
startedAt: call.startedAt,
|
|
593
|
+
...call.answeredAt !== void 0 ? { answeredAt: call.answeredAt } : {},
|
|
594
|
+
...call.endedAt !== void 0 ? { endedAt: call.endedAt } : {},
|
|
595
|
+
...call.endReason !== void 0 ? { endReason: call.endReason } : {}
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
function requireInput(value, message) {
|
|
599
|
+
if (!value) throw new VoiceCallCommandInputError(message);
|
|
600
|
+
return value;
|
|
601
|
+
}
|
|
602
|
+
function requireSuccess(result, fallback) {
|
|
603
|
+
if (!result.success) throw new Error(result.error || fallback);
|
|
604
|
+
}
|
|
605
|
+
function createVoiceCallCommandService(ensureRuntime) {
|
|
606
|
+
const describeHistoricalCall = async (rt, callId) => {
|
|
607
|
+
const call = await rt.manager.getCallFromMemoryOrStore(callId);
|
|
608
|
+
if (!call) return;
|
|
609
|
+
const endedAt = timestampMsToIsoString(call.endedAt);
|
|
610
|
+
return `call is not active (${[
|
|
611
|
+
`last state=${call.state}`,
|
|
612
|
+
call.endReason ? `endReason=${call.endReason}` : void 0,
|
|
613
|
+
endedAt ? `endedAt=${endedAt}` : void 0
|
|
614
|
+
].filter(Boolean).join(", ")})`;
|
|
615
|
+
};
|
|
616
|
+
const resolveCallMessage = async (callId, message) => {
|
|
617
|
+
const resolvedCallId = requireInput(callId, "callId and message required");
|
|
618
|
+
const resolvedMessage = requireInput(message, "callId and message required");
|
|
619
|
+
const rt = await ensureRuntime();
|
|
620
|
+
const activeCall = rt.manager.getCall(resolvedCallId) ?? rt.manager.getCallByProviderCallId(resolvedCallId);
|
|
621
|
+
if (!activeCall) throw new VoiceCallCommandInputError(await describeHistoricalCall(rt, resolvedCallId) ?? "Call not found");
|
|
622
|
+
return {
|
|
623
|
+
rt,
|
|
624
|
+
callId: activeCall.callId,
|
|
625
|
+
message: resolvedMessage
|
|
626
|
+
};
|
|
627
|
+
};
|
|
628
|
+
const prepareContinue = async (callId, message) => {
|
|
629
|
+
const request = await resolveCallMessage(callId, message);
|
|
630
|
+
return {
|
|
631
|
+
rt: request.rt,
|
|
632
|
+
callId: request.callId,
|
|
633
|
+
run: async () => {
|
|
634
|
+
const result = await request.rt.manager.continueCall(request.callId, request.message);
|
|
635
|
+
requireSuccess(result, "continue failed");
|
|
636
|
+
return {
|
|
637
|
+
success: true,
|
|
638
|
+
transcript: result.transcript
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
};
|
|
643
|
+
return {
|
|
644
|
+
prepareContinue,
|
|
645
|
+
async initiate(params, missingToMessage = "to required") {
|
|
646
|
+
const rt = await ensureRuntime();
|
|
647
|
+
const to = requireInput(params.to ?? rt.config.toNumber, missingToMessage);
|
|
648
|
+
const result = await rt.manager.initiateCall(to, params.sessionKey, {
|
|
649
|
+
message: params.message,
|
|
650
|
+
mode: params.mode,
|
|
651
|
+
dtmfSequence: params.dtmfSequence,
|
|
652
|
+
...params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {},
|
|
653
|
+
...params.agentId ? { agentId: params.agentId } : {}
|
|
654
|
+
});
|
|
655
|
+
requireSuccess(result, "initiate failed");
|
|
656
|
+
return {
|
|
657
|
+
callId: result.callId,
|
|
658
|
+
initiated: true
|
|
659
|
+
};
|
|
660
|
+
},
|
|
661
|
+
async continueCall(callId, message) {
|
|
662
|
+
return await (await prepareContinue(callId, message)).run();
|
|
663
|
+
},
|
|
664
|
+
async speak(params) {
|
|
665
|
+
const request = await resolveCallMessage(params.callId, params.message);
|
|
666
|
+
if (request.rt.config.realtime.enabled) {
|
|
667
|
+
const realtimeResult = request.rt.webhookServer.speakRealtime(request.callId, request.message);
|
|
668
|
+
if (realtimeResult.success) return { success: true };
|
|
669
|
+
if (params.allowTwimlFallback === false) return {
|
|
670
|
+
success: false,
|
|
671
|
+
error: realtimeResult.error ?? "Realtime bridge is not active"
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
requireSuccess(await request.rt.manager.speak(request.callId, request.message), "speak failed");
|
|
675
|
+
return { success: true };
|
|
676
|
+
},
|
|
677
|
+
async sendDtmf(callId, digits) {
|
|
678
|
+
const resolvedCallId = requireInput(callId, "callId and digits required");
|
|
679
|
+
const resolvedDigits = requireInput(digits, "callId and digits required");
|
|
680
|
+
requireSuccess(await (await ensureRuntime()).manager.sendDtmf(resolvedCallId, resolvedDigits), "dtmf failed");
|
|
681
|
+
return { success: true };
|
|
682
|
+
},
|
|
683
|
+
async endCall(callId) {
|
|
684
|
+
const resolvedCallId = requireInput(callId, "callId required");
|
|
685
|
+
requireSuccess(await (await ensureRuntime()).manager.endCall(resolvedCallId), "end failed");
|
|
686
|
+
return { success: true };
|
|
687
|
+
},
|
|
688
|
+
async status(callId) {
|
|
689
|
+
const rt = await ensureRuntime();
|
|
690
|
+
if (!callId) return {
|
|
691
|
+
found: true,
|
|
692
|
+
calls: rt.manager.getActiveCalls().map(toVoiceCallStatus)
|
|
693
|
+
};
|
|
694
|
+
const call = await rt.manager.getCallFromMemoryOrStore(callId);
|
|
695
|
+
return call ? {
|
|
696
|
+
found: true,
|
|
697
|
+
call: toVoiceCallStatus(call)
|
|
698
|
+
} : { found: false };
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
//#endregion
|
|
546
703
|
//#region extensions/voice-call/src/gateway-continue-operation.ts
|
|
547
704
|
const VOICE_CALL_CONTINUE_OPERATION_BUFFER_MS = 3e4;
|
|
548
705
|
const VOICE_CALL_CONTINUE_OPERATION_CLEANUP_MS = 300 * 1e3;
|
|
@@ -569,21 +726,9 @@ function createVoiceCallContinueOperationStore(params) {
|
|
|
569
726
|
startedAtMs,
|
|
570
727
|
pollTimeoutMs
|
|
571
728
|
});
|
|
572
|
-
request.
|
|
729
|
+
request.run().then((result) => {
|
|
573
730
|
const current = operations.get(operationId);
|
|
574
731
|
if (!current || current.status !== "pending") return;
|
|
575
|
-
if (!result.success) {
|
|
576
|
-
operations.set(operationId, {
|
|
577
|
-
operationId,
|
|
578
|
-
status: "failed",
|
|
579
|
-
callId: request.callId,
|
|
580
|
-
startedAtMs,
|
|
581
|
-
completedAtMs: Date.now(),
|
|
582
|
-
pollTimeoutMs,
|
|
583
|
-
error: result.error || "continue failed"
|
|
584
|
-
});
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
732
|
operations.set(operationId, {
|
|
588
733
|
operationId,
|
|
589
734
|
status: "completed",
|
|
@@ -661,223 +806,15 @@ function createVoiceCallContinueOperationStore(params) {
|
|
|
661
806
|
//#region extensions/voice-call/index.ts
|
|
662
807
|
const VOICE_CALL_WRITE_METHOD_SCOPE = { scope: "operator.write" };
|
|
663
808
|
const VOICE_CALL_READ_METHOD_SCOPE = { scope: "operator.read" };
|
|
664
|
-
const voiceCallConfigSchema = {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
},
|
|
674
|
-
uiHints: {
|
|
675
|
-
provider: {
|
|
676
|
-
label: "Provider",
|
|
677
|
-
help: "Use twilio, telnyx, or mock for dev/no-network."
|
|
678
|
-
},
|
|
679
|
-
fromNumber: {
|
|
680
|
-
label: "From Number",
|
|
681
|
-
placeholder: "+15550001234"
|
|
682
|
-
},
|
|
683
|
-
toNumber: {
|
|
684
|
-
label: "Default To Number",
|
|
685
|
-
placeholder: "+15550001234"
|
|
686
|
-
},
|
|
687
|
-
inboundPolicy: { label: "Inbound Policy" },
|
|
688
|
-
allowFrom: { label: "Inbound Allowlist" },
|
|
689
|
-
inboundGreeting: {
|
|
690
|
-
label: "Inbound Greeting",
|
|
691
|
-
advanced: true
|
|
692
|
-
},
|
|
693
|
-
numbers: {
|
|
694
|
-
label: "Per-number Routing",
|
|
695
|
-
help: "Inbound overrides keyed by dialed E.164 number.",
|
|
696
|
-
advanced: true
|
|
697
|
-
},
|
|
698
|
-
"telnyx.apiKey": {
|
|
699
|
-
label: "Telnyx API Key",
|
|
700
|
-
sensitive: true
|
|
701
|
-
},
|
|
702
|
-
"telnyx.connectionId": { label: "Telnyx Connection ID" },
|
|
703
|
-
"telnyx.publicKey": {
|
|
704
|
-
label: "Telnyx Public Key",
|
|
705
|
-
sensitive: true
|
|
706
|
-
},
|
|
707
|
-
"twilio.accountSid": { label: "Twilio Account SID" },
|
|
708
|
-
"twilio.authToken": {
|
|
709
|
-
label: "Twilio Auth Token",
|
|
710
|
-
sensitive: true
|
|
711
|
-
},
|
|
712
|
-
"twilio.region": {
|
|
713
|
-
label: "Twilio Region",
|
|
714
|
-
advanced: true
|
|
715
|
-
},
|
|
716
|
-
"outbound.defaultMode": { label: "Default Call Mode" },
|
|
717
|
-
"outbound.notifyHangupDelaySec": {
|
|
718
|
-
label: "Notify Hangup Delay (sec)",
|
|
719
|
-
advanced: true
|
|
720
|
-
},
|
|
721
|
-
"serve.port": { label: "Webhook Port" },
|
|
722
|
-
"serve.bind": { label: "Webhook Bind" },
|
|
723
|
-
"serve.path": { label: "Webhook Path" },
|
|
724
|
-
"tailscale.mode": {
|
|
725
|
-
label: "Tailscale Mode",
|
|
726
|
-
advanced: true
|
|
727
|
-
},
|
|
728
|
-
"tailscale.path": {
|
|
729
|
-
label: "Tailscale Path",
|
|
730
|
-
advanced: true
|
|
731
|
-
},
|
|
732
|
-
"tunnel.provider": {
|
|
733
|
-
label: "Tunnel Provider",
|
|
734
|
-
advanced: true
|
|
735
|
-
},
|
|
736
|
-
"tunnel.ngrokAuthToken": {
|
|
737
|
-
label: "ngrok Auth Token",
|
|
738
|
-
sensitive: true,
|
|
739
|
-
advanced: true
|
|
740
|
-
},
|
|
741
|
-
"tunnel.ngrokDomain": {
|
|
742
|
-
label: "ngrok Domain",
|
|
743
|
-
advanced: true
|
|
744
|
-
},
|
|
745
|
-
"tunnel.allowNgrokFreeTierLoopbackBypass": {
|
|
746
|
-
label: "Allow ngrok Free Tier (Loopback Bypass)",
|
|
747
|
-
advanced: true
|
|
748
|
-
},
|
|
749
|
-
"streaming.enabled": {
|
|
750
|
-
label: "Enable Streaming",
|
|
751
|
-
help: "Classic streaming transcription currently requires the Twilio call provider.",
|
|
752
|
-
advanced: true
|
|
753
|
-
},
|
|
754
|
-
"streaming.provider": {
|
|
755
|
-
label: "Streaming Provider",
|
|
756
|
-
help: "Uses the first registered realtime transcription provider when unset.",
|
|
757
|
-
advanced: true
|
|
758
|
-
},
|
|
759
|
-
"streaming.providers": {
|
|
760
|
-
label: "Streaming Provider Config",
|
|
761
|
-
advanced: true
|
|
762
|
-
},
|
|
763
|
-
"streaming.streamPath": {
|
|
764
|
-
label: "Media Stream Path",
|
|
765
|
-
advanced: true
|
|
766
|
-
},
|
|
767
|
-
"realtime.enabled": {
|
|
768
|
-
label: "Enable Realtime Voice",
|
|
769
|
-
advanced: true
|
|
770
|
-
},
|
|
771
|
-
"realtime.provider": {
|
|
772
|
-
label: "Realtime Voice Provider",
|
|
773
|
-
help: "Uses the first registered realtime voice provider when unset.",
|
|
774
|
-
advanced: true
|
|
775
|
-
},
|
|
776
|
-
"realtime.streamPath": {
|
|
777
|
-
label: "Realtime Stream Path",
|
|
778
|
-
advanced: true
|
|
779
|
-
},
|
|
780
|
-
"realtime.instructions": {
|
|
781
|
-
label: "Realtime Instructions",
|
|
782
|
-
advanced: true
|
|
783
|
-
},
|
|
784
|
-
"realtime.toolPolicy": {
|
|
785
|
-
label: "Realtime Tool Policy",
|
|
786
|
-
help: "Controls the shared openclaw_agent_consult tool.",
|
|
787
|
-
advanced: true
|
|
788
|
-
},
|
|
789
|
-
"realtime.consultPolicy": {
|
|
790
|
-
label: "Realtime Consult Policy",
|
|
791
|
-
help: "Guides when the realtime voice model should call openclaw_agent_consult.",
|
|
792
|
-
advanced: true
|
|
793
|
-
},
|
|
794
|
-
"realtime.fastContext.enabled": {
|
|
795
|
-
label: "Enable Fast Realtime Context",
|
|
796
|
-
help: "Searches memory/session context before the full consult agent.",
|
|
797
|
-
advanced: true
|
|
798
|
-
},
|
|
799
|
-
"realtime.fastContext.timeoutMs": {
|
|
800
|
-
label: "Fast Context Timeout",
|
|
801
|
-
advanced: true
|
|
802
|
-
},
|
|
803
|
-
"realtime.fastContext.maxResults": {
|
|
804
|
-
label: "Fast Context Result Limit",
|
|
805
|
-
advanced: true
|
|
806
|
-
},
|
|
807
|
-
"realtime.fastContext.sources": {
|
|
808
|
-
label: "Fast Context Sources",
|
|
809
|
-
advanced: true
|
|
810
|
-
},
|
|
811
|
-
"realtime.fastContext.fallbackToConsult": {
|
|
812
|
-
label: "Fallback To Full Consult",
|
|
813
|
-
advanced: true
|
|
814
|
-
},
|
|
815
|
-
"realtime.agentContext.enabled": {
|
|
816
|
-
label: "Enable Agent Voice Context",
|
|
817
|
-
help: "Injects a compact agent identity and workspace context capsule into realtime voice instructions.",
|
|
818
|
-
advanced: true
|
|
819
|
-
},
|
|
820
|
-
"realtime.agentContext.maxChars": {
|
|
821
|
-
label: "Agent Voice Context Limit",
|
|
822
|
-
advanced: true
|
|
823
|
-
},
|
|
824
|
-
"realtime.agentContext.includeIdentity": {
|
|
825
|
-
label: "Include Agent Identity",
|
|
826
|
-
advanced: true
|
|
827
|
-
},
|
|
828
|
-
"realtime.agentContext.includeWorkspaceFiles": {
|
|
829
|
-
label: "Include Agent Workspace Files",
|
|
830
|
-
advanced: true
|
|
831
|
-
},
|
|
832
|
-
"realtime.agentContext.files": {
|
|
833
|
-
label: "Agent Voice Context Files",
|
|
834
|
-
advanced: true
|
|
835
|
-
},
|
|
836
|
-
"realtime.providers": {
|
|
837
|
-
label: "Realtime Provider Config",
|
|
838
|
-
advanced: true
|
|
839
|
-
},
|
|
840
|
-
"tts.provider": {
|
|
841
|
-
label: "TTS Provider Override",
|
|
842
|
-
help: "Deep-merges with tts (Microsoft is ignored for calls).",
|
|
843
|
-
advanced: true
|
|
844
|
-
},
|
|
845
|
-
"tts.providers": {
|
|
846
|
-
label: "TTS Provider Config",
|
|
847
|
-
advanced: true
|
|
848
|
-
},
|
|
849
|
-
publicUrl: {
|
|
850
|
-
label: "Public Webhook URL",
|
|
851
|
-
advanced: true
|
|
852
|
-
},
|
|
853
|
-
skipSignatureVerification: {
|
|
854
|
-
label: "Skip Signature Verification",
|
|
855
|
-
advanced: true
|
|
856
|
-
},
|
|
857
|
-
store: {
|
|
858
|
-
label: "Call Log Store Path",
|
|
859
|
-
advanced: true
|
|
860
|
-
},
|
|
861
|
-
agentId: {
|
|
862
|
-
label: "Response Agent ID",
|
|
863
|
-
help: "Agent workspace used for voice response generation. Defaults to \"main\".",
|
|
864
|
-
advanced: true
|
|
865
|
-
},
|
|
866
|
-
responseModel: {
|
|
867
|
-
label: "Response Model",
|
|
868
|
-
help: "Optional override. Falls back to the runtime default model when unset.",
|
|
869
|
-
advanced: true
|
|
870
|
-
},
|
|
871
|
-
responseSystemPrompt: {
|
|
872
|
-
label: "Response System Prompt",
|
|
873
|
-
advanced: true
|
|
874
|
-
},
|
|
875
|
-
responseTimeoutMs: {
|
|
876
|
-
label: "Response Timeout (ms)",
|
|
877
|
-
advanced: true
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
};
|
|
809
|
+
const voiceCallConfigSchema = { parse(value) {
|
|
810
|
+
const config = asOptionalRecord(value) ?? {};
|
|
811
|
+
const enabled = typeof config.enabled === "boolean" ? config.enabled : true;
|
|
812
|
+
return VoiceCallConfigSchema.parse({
|
|
813
|
+
...config,
|
|
814
|
+
enabled,
|
|
815
|
+
provider: config.provider ?? (enabled ? "mock" : void 0)
|
|
816
|
+
});
|
|
817
|
+
} };
|
|
881
818
|
const VoiceCallToolSchema = Type.Union([
|
|
882
819
|
Type.Object({
|
|
883
820
|
action: Type.Literal("initiate_call"),
|
|
@@ -885,7 +822,6 @@ const VoiceCallToolSchema = Type.Union([
|
|
|
885
822
|
message: Type.String({ description: "Intro message" }),
|
|
886
823
|
mode: Type.Optional(Type.Union([Type.Literal("notify"), Type.Literal("conversation")])),
|
|
887
824
|
sessionKey: Type.Optional(Type.String({ description: "OpenClaw session key for the call" })),
|
|
888
|
-
requesterSessionKey: Type.Optional(Type.String({ description: "OpenClaw session key that initiated the call" })),
|
|
889
825
|
dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" }))
|
|
890
826
|
}),
|
|
891
827
|
Type.Object({
|
|
@@ -917,38 +853,40 @@ const VoiceCallToolSchema = Type.Union([
|
|
|
917
853
|
sid: Type.Optional(Type.String({ description: "Call SID" })),
|
|
918
854
|
message: Type.Optional(Type.String({ description: "Optional intro message" })),
|
|
919
855
|
sessionKey: Type.Optional(Type.String({ description: "OpenClaw session key for the call" })),
|
|
920
|
-
requesterSessionKey: Type.Optional(Type.String({ description: "OpenClaw session key that initiated the call" })),
|
|
921
856
|
dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" }))
|
|
922
857
|
})
|
|
923
858
|
]);
|
|
924
|
-
function asParamRecord(params) {
|
|
925
|
-
return params && typeof params === "object" && !Array.isArray(params) ? params : {};
|
|
926
|
-
}
|
|
927
859
|
function isCliOnlyProcess() {
|
|
928
860
|
return process.env.OPENCLAW_CLI === "1" && !process.argv.slice(2).includes("gateway");
|
|
929
861
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
}
|
|
862
|
+
const VOICE_CALL_RUNTIME_COORDINATOR_KEY = Symbol.for("openclaw.voice-call.runtimeCoordinator");
|
|
863
|
+
var VoiceCallRuntimeLifecycleError = class extends Error {};
|
|
864
|
+
function getVoiceCallRuntimeCoordinator() {
|
|
865
|
+
return resolveGlobalSingleton(VOICE_CALL_RUNTIME_COORDINATOR_KEY, () => ({ epochCounter: 0 }));
|
|
866
|
+
}
|
|
867
|
+
function activateVoiceCallRuntimeGeneration(coordinator, registration, generation) {
|
|
868
|
+
if (registration.epoch < (coordinator.current?.epoch ?? 0) || registration.generation !== generation) throw new VoiceCallRuntimeLifecycleError("Voice call runtime generation was superseded; use the current plugin registration");
|
|
869
|
+
if (generation.retired) throw new VoiceCallRuntimeLifecycleError("Voice call runtime generation is retired; use the current plugin registration");
|
|
870
|
+
if (coordinator.current !== registration) {
|
|
871
|
+
if (coordinator.current) coordinator.current.generation.retired = true;
|
|
872
|
+
coordinator.current = registration;
|
|
873
|
+
}
|
|
942
874
|
}
|
|
943
|
-
|
|
944
|
-
const
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
875
|
+
function stopVoiceCallRuntimeGeneration(coordinator, generation) {
|
|
876
|
+
const ownedSlot = coordinator.slot?.owner === generation ? coordinator.slot : void 0;
|
|
877
|
+
if (!ownedSlot || ownedSlot.state === "stopping") return ownedSlot?.promise ?? Promise.resolve();
|
|
878
|
+
const stopPromise = Promise.resolve().then(async () => {
|
|
879
|
+
await (ownedSlot.state === "running" ? ownedSlot.runtime : await ownedSlot.promise).stop();
|
|
880
|
+
});
|
|
881
|
+
const stoppingSlot = {
|
|
882
|
+
state: "stopping",
|
|
883
|
+
owner: generation,
|
|
884
|
+
promise: stopPromise
|
|
885
|
+
};
|
|
886
|
+
if (coordinator.slot === ownedSlot) coordinator.slot = stoppingSlot;
|
|
887
|
+
return stopPromise.finally(() => {
|
|
888
|
+
if (coordinator.slot === stoppingSlot) coordinator.slot = void 0;
|
|
889
|
+
});
|
|
952
890
|
}
|
|
953
891
|
var voice_call_default = definePluginEntry({
|
|
954
892
|
id: "voice-call",
|
|
@@ -958,24 +896,56 @@ var voice_call_default = definePluginEntry({
|
|
|
958
896
|
register(api) {
|
|
959
897
|
const config = resolveVoiceCallConfig(voiceCallConfigSchema.parse(api.pluginConfig));
|
|
960
898
|
const validation = validateProviderConfig(config);
|
|
961
|
-
const
|
|
899
|
+
const runtimeCoordinator = getVoiceCallRuntimeCoordinator();
|
|
900
|
+
const runtimeRegistration = api.registrationMode !== "full" && runtimeCoordinator.current ? runtimeCoordinator.current : {
|
|
901
|
+
epoch: ++runtimeCoordinator.epochCounter,
|
|
902
|
+
generation: { retired: false }
|
|
903
|
+
};
|
|
962
904
|
const continueOperationStore = createVoiceCallContinueOperationStore({
|
|
963
905
|
config,
|
|
964
906
|
coreConfig: api.config
|
|
965
907
|
});
|
|
966
|
-
const
|
|
908
|
+
const activateRuntimeGeneration = (generation) => activateVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeRegistration, generation);
|
|
909
|
+
const ensureRuntimeForGeneration = async (runtimeGeneration) => {
|
|
910
|
+
activateRuntimeGeneration(runtimeGeneration);
|
|
967
911
|
if (!config.enabled) throw new Error("Voice call disabled in plugin config");
|
|
968
912
|
if (!validation.valid) throw new Error(validation.errors.join("; "));
|
|
969
913
|
while (true) {
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
914
|
+
activateRuntimeGeneration(runtimeGeneration);
|
|
915
|
+
const slot = runtimeCoordinator.slot;
|
|
916
|
+
if (slot) {
|
|
917
|
+
if (slot.owner !== runtimeGeneration) {
|
|
918
|
+
if (slot.owner.retired) {
|
|
919
|
+
await stopVoiceCallRuntimeGeneration(runtimeCoordinator, slot.owner);
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
throw new VoiceCallRuntimeLifecycleError("A previous voice call runtime generation is still active; retry after it stops");
|
|
923
|
+
}
|
|
924
|
+
if (slot.state === "running") return slot.runtime;
|
|
925
|
+
if (slot.state === "stopping") {
|
|
926
|
+
await slot.promise;
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
let createdRuntime;
|
|
930
|
+
try {
|
|
931
|
+
createdRuntime = await slot.promise;
|
|
932
|
+
} catch (err) {
|
|
933
|
+
if (runtimeCoordinator.slot === slot) runtimeCoordinator.slot = void 0;
|
|
934
|
+
throw err;
|
|
935
|
+
}
|
|
936
|
+
activateRuntimeGeneration(runtimeGeneration);
|
|
937
|
+
if (runtimeCoordinator.slot !== slot) continue;
|
|
938
|
+
runtimeCoordinator.slot = {
|
|
939
|
+
state: "running",
|
|
940
|
+
owner: runtimeGeneration,
|
|
941
|
+
runtime: createdRuntime
|
|
942
|
+
};
|
|
943
|
+
return createdRuntime;
|
|
973
944
|
}
|
|
974
|
-
const
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
runtimePromise = createVoiceCallRuntime({
|
|
945
|
+
const startingSlot = {
|
|
946
|
+
state: "starting",
|
|
947
|
+
owner: runtimeGeneration,
|
|
948
|
+
promise: createVoiceCallRuntime({
|
|
979
949
|
config,
|
|
980
950
|
coreConfig: api.config,
|
|
981
951
|
fullConfig: api.config,
|
|
@@ -983,279 +953,76 @@ var voice_call_default = definePluginEntry({
|
|
|
983
953
|
stateRuntime: api.runtime.state,
|
|
984
954
|
ttsRuntime: api.runtime.tts,
|
|
985
955
|
logger: api.logger
|
|
986
|
-
})
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
try {
|
|
990
|
-
const createdRuntime = await runtimePromise;
|
|
991
|
-
if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) continue;
|
|
992
|
-
if (runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] !== runtimePromise) continue;
|
|
993
|
-
runtimeState[VOICE_CALL_RUNTIME_KEY] = createdRuntime;
|
|
994
|
-
return createdRuntime;
|
|
995
|
-
} catch (err) {
|
|
996
|
-
if (runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] === runtimePromise) {
|
|
997
|
-
runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = null;
|
|
998
|
-
runtimeState[VOICE_CALL_RUNTIME_KEY] = null;
|
|
999
|
-
}
|
|
1000
|
-
throw err;
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
};
|
|
1004
|
-
const respondError = (respond, message, code = ErrorCodes.UNAVAILABLE) => {
|
|
1005
|
-
respond(false, void 0, errorShape(code, message));
|
|
1006
|
-
};
|
|
1007
|
-
const sendError = (respond, err) => {
|
|
1008
|
-
respondError(respond, formatErrorMessage(err));
|
|
1009
|
-
};
|
|
1010
|
-
const describeHistoricalCall = async (rt, callId) => {
|
|
1011
|
-
const call = await rt.manager.getCallFromMemoryOrStore(callId);
|
|
1012
|
-
if (!call) return;
|
|
1013
|
-
const endedAt = timestampMsToIsoString(call.endedAt);
|
|
1014
|
-
return `call is not active (${[
|
|
1015
|
-
`last state=${call.state}`,
|
|
1016
|
-
call.endReason ? `endReason=${call.endReason}` : void 0,
|
|
1017
|
-
endedAt ? `endedAt=${endedAt}` : void 0
|
|
1018
|
-
].filter(Boolean).join(", ")})`;
|
|
1019
|
-
};
|
|
1020
|
-
const resolveCallMessageRequest = async (params) => {
|
|
1021
|
-
const callId = normalizeOptionalString(params?.callId) ?? "";
|
|
1022
|
-
const message = normalizeOptionalString(params?.message) ?? "";
|
|
1023
|
-
if (!callId || !message) return { error: "callId and message required" };
|
|
1024
|
-
const rt = await ensureRuntime();
|
|
1025
|
-
const activeCall = rt.manager.getCall(callId) ?? rt.manager.getCallByProviderCallId(callId);
|
|
1026
|
-
if (activeCall) return {
|
|
1027
|
-
rt,
|
|
1028
|
-
callId: activeCall.callId,
|
|
1029
|
-
message
|
|
1030
|
-
};
|
|
1031
|
-
return { error: await describeHistoricalCall(rt, callId) ?? "Call not found" };
|
|
1032
|
-
};
|
|
1033
|
-
const initiateCallAndRespond = async (params) => {
|
|
1034
|
-
const result = await params.rt.manager.initiateCall(params.to, params.sessionKey, {
|
|
1035
|
-
message: params.message,
|
|
1036
|
-
mode: params.mode,
|
|
1037
|
-
dtmfSequence: params.dtmfSequence,
|
|
1038
|
-
...params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {},
|
|
1039
|
-
...params.agentId ? { agentId: params.agentId } : {}
|
|
1040
|
-
});
|
|
1041
|
-
if (!result.success) {
|
|
1042
|
-
respondError(params.respond, result.error || "initiate failed");
|
|
1043
|
-
return;
|
|
1044
|
-
}
|
|
1045
|
-
params.respond(true, {
|
|
1046
|
-
callId: result.callId,
|
|
1047
|
-
initiated: true
|
|
1048
|
-
});
|
|
1049
|
-
};
|
|
1050
|
-
const respondToCallMessageAction = async (params) => {
|
|
1051
|
-
const request = await resolveCallMessageRequest(params.requestParams);
|
|
1052
|
-
if ("error" in request) {
|
|
1053
|
-
respondError(params.respond, request.error ?? "callId and message required", ErrorCodes.INVALID_REQUEST);
|
|
1054
|
-
return;
|
|
1055
|
-
}
|
|
1056
|
-
const result = await params.action(request);
|
|
1057
|
-
if (!result.success) {
|
|
1058
|
-
respondError(params.respond, result.error || params.failure);
|
|
1059
|
-
return;
|
|
956
|
+
})
|
|
957
|
+
};
|
|
958
|
+
runtimeCoordinator.slot = startingSlot;
|
|
1060
959
|
}
|
|
1061
|
-
params.respond(true, params.includeTranscript ? {
|
|
1062
|
-
success: true,
|
|
1063
|
-
transcript: result.transcript
|
|
1064
|
-
} : { success: true });
|
|
1065
960
|
};
|
|
1066
|
-
|
|
1067
|
-
try {
|
|
1068
|
-
const message = normalizeOptionalString(params?.message) ?? "";
|
|
1069
|
-
if (!message) {
|
|
1070
|
-
respondError(respond, "message required", ErrorCodes.INVALID_REQUEST);
|
|
1071
|
-
return;
|
|
1072
|
-
}
|
|
1073
|
-
const rt = await ensureRuntime();
|
|
1074
|
-
const to = normalizeOptionalString(params?.to) ?? rt.config.toNumber;
|
|
1075
|
-
if (!to) {
|
|
1076
|
-
respondError(respond, "to required", ErrorCodes.INVALID_REQUEST);
|
|
1077
|
-
return;
|
|
1078
|
-
}
|
|
1079
|
-
const mode = params?.mode === "notify" || params?.mode === "conversation" ? params.mode : void 0;
|
|
1080
|
-
await initiateCallAndRespond({
|
|
1081
|
-
rt,
|
|
1082
|
-
respond,
|
|
1083
|
-
to,
|
|
1084
|
-
message,
|
|
1085
|
-
mode,
|
|
1086
|
-
sessionKey: normalizeOptionalString(params?.sessionKey),
|
|
1087
|
-
requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey)
|
|
1088
|
-
});
|
|
1089
|
-
} catch (err) {
|
|
1090
|
-
sendError(respond, err);
|
|
1091
|
-
}
|
|
1092
|
-
}, VOICE_CALL_WRITE_METHOD_SCOPE);
|
|
1093
|
-
api.registerGatewayMethod("voicecall.continue", async ({ params, respond }) => {
|
|
1094
|
-
try {
|
|
1095
|
-
await respondToCallMessageAction({
|
|
1096
|
-
requestParams: params,
|
|
1097
|
-
respond,
|
|
1098
|
-
action: (request) => request.rt.manager.continueCall(request.callId, request.message),
|
|
1099
|
-
failure: "continue failed",
|
|
1100
|
-
includeTranscript: true
|
|
1101
|
-
});
|
|
1102
|
-
} catch (err) {
|
|
1103
|
-
sendError(respond, err);
|
|
1104
|
-
}
|
|
1105
|
-
}, VOICE_CALL_WRITE_METHOD_SCOPE);
|
|
1106
|
-
api.registerGatewayMethod("voicecall.continue.start", async ({ params, respond }) => {
|
|
1107
|
-
try {
|
|
1108
|
-
const request = await resolveCallMessageRequest(params);
|
|
1109
|
-
if ("error" in request) {
|
|
1110
|
-
respondError(respond, request.error ?? "callId and message required", ErrorCodes.INVALID_REQUEST);
|
|
1111
|
-
return;
|
|
1112
|
-
}
|
|
1113
|
-
respond(true, continueOperationStore.start(request));
|
|
1114
|
-
} catch (err) {
|
|
1115
|
-
sendError(respond, err);
|
|
1116
|
-
}
|
|
1117
|
-
}, VOICE_CALL_WRITE_METHOD_SCOPE);
|
|
1118
|
-
api.registerGatewayMethod("voicecall.continue.result", async ({ params, respond }) => {
|
|
961
|
+
const ensureRuntime = async (runtimeGeneration = runtimeRegistration.generation) => {
|
|
1119
962
|
try {
|
|
1120
|
-
const
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
return;
|
|
1124
|
-
}
|
|
1125
|
-
const operation = continueOperationStore.read(operationId);
|
|
1126
|
-
if (!operation.ok) {
|
|
1127
|
-
respondError(respond, operation.error, ErrorCodes.INVALID_REQUEST);
|
|
1128
|
-
return;
|
|
1129
|
-
}
|
|
1130
|
-
respond(true, operation.payload);
|
|
1131
|
-
} catch (err) {
|
|
1132
|
-
sendError(respond, err);
|
|
1133
|
-
}
|
|
1134
|
-
}, VOICE_CALL_READ_METHOD_SCOPE);
|
|
1135
|
-
api.registerGatewayMethod("voicecall.speak", async ({ params, respond }) => {
|
|
1136
|
-
try {
|
|
1137
|
-
const request = await resolveCallMessageRequest(params);
|
|
1138
|
-
if ("error" in request) {
|
|
1139
|
-
respondError(respond, request.error ?? "callId and message required", ErrorCodes.INVALID_REQUEST);
|
|
1140
|
-
return;
|
|
1141
|
-
}
|
|
1142
|
-
if (request.rt.config.realtime.enabled) {
|
|
1143
|
-
const realtimeResult = request.rt.webhookServer.speakRealtime(request.callId, request.message);
|
|
1144
|
-
if (realtimeResult.success) {
|
|
1145
|
-
respond(true, { success: true });
|
|
1146
|
-
return;
|
|
1147
|
-
}
|
|
1148
|
-
if (params?.allowTwimlFallback === false) {
|
|
1149
|
-
respond(true, {
|
|
1150
|
-
success: false,
|
|
1151
|
-
error: realtimeResult.error ?? "Realtime bridge is not active"
|
|
1152
|
-
});
|
|
1153
|
-
return;
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
const result = await request.rt.manager.speak(request.callId, request.message);
|
|
1157
|
-
if (!result.success) {
|
|
1158
|
-
respondError(respond, result.error || "speak failed");
|
|
1159
|
-
return;
|
|
1160
|
-
}
|
|
1161
|
-
respond(true, { success: true });
|
|
1162
|
-
} catch (err) {
|
|
1163
|
-
sendError(respond, err);
|
|
1164
|
-
}
|
|
1165
|
-
}, VOICE_CALL_WRITE_METHOD_SCOPE);
|
|
1166
|
-
api.registerGatewayMethod("voicecall.dtmf", async ({ params, respond }) => {
|
|
1167
|
-
try {
|
|
1168
|
-
const callId = normalizeOptionalString(params?.callId) ?? "";
|
|
1169
|
-
const digits = normalizeOptionalString(params?.digits) ?? "";
|
|
1170
|
-
if (!callId || !digits) {
|
|
1171
|
-
respondError(respond, "callId and digits required", ErrorCodes.INVALID_REQUEST);
|
|
1172
|
-
return;
|
|
1173
|
-
}
|
|
1174
|
-
const result = await (await ensureRuntime()).manager.sendDtmf(callId, digits);
|
|
1175
|
-
if (!result.success) {
|
|
1176
|
-
respondError(respond, result.error || "dtmf failed");
|
|
1177
|
-
return;
|
|
1178
|
-
}
|
|
1179
|
-
respond(true, { success: true });
|
|
963
|
+
const runtime = await ensureRuntimeForGeneration(runtimeGeneration);
|
|
964
|
+
runtimeGeneration.serviceHealth?.clearFailure();
|
|
965
|
+
return runtime;
|
|
1180
966
|
} catch (err) {
|
|
1181
|
-
|
|
967
|
+
if (!(err instanceof VoiceCallRuntimeLifecycleError)) runtimeGeneration.serviceHealth?.reportFailure(err);
|
|
968
|
+
throw err;
|
|
1182
969
|
}
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
if (!result.success) {
|
|
1193
|
-
respondError(respond, result.error || "end failed");
|
|
1194
|
-
return;
|
|
970
|
+
};
|
|
971
|
+
const commands = createVoiceCallCommandService(ensureRuntime);
|
|
972
|
+
const registerGatewayCommand = (method, handler, scope) => {
|
|
973
|
+
api.registerGatewayMethod(method, async (options) => {
|
|
974
|
+
try {
|
|
975
|
+
options.respond(true, await handler(options));
|
|
976
|
+
} catch (err) {
|
|
977
|
+
const code = err instanceof VoiceCallCommandInputError ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE;
|
|
978
|
+
options.respond(false, void 0, errorShape(code, formatErrorMessage(err)));
|
|
1195
979
|
}
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
980
|
+
}, scope);
|
|
981
|
+
};
|
|
982
|
+
registerGatewayCommand("voicecall.initiate", async ({ params }) => {
|
|
983
|
+
const message = normalizeOptionalString(params?.message);
|
|
984
|
+
if (!message) throw new VoiceCallCommandInputError("message required");
|
|
985
|
+
return await commands.initiate({
|
|
986
|
+
to: normalizeOptionalString(params?.to),
|
|
987
|
+
message,
|
|
988
|
+
mode: params?.mode === "notify" || params?.mode === "conversation" ? params.mode : void 0,
|
|
989
|
+
sessionKey: normalizeOptionalString(params?.sessionKey),
|
|
990
|
+
requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey)
|
|
991
|
+
});
|
|
1200
992
|
}, VOICE_CALL_WRITE_METHOD_SCOPE);
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
});
|
|
1210
|
-
return;
|
|
1211
|
-
}
|
|
1212
|
-
const call = await rt.manager.getCallFromMemoryOrStore(raw);
|
|
1213
|
-
if (!call) {
|
|
1214
|
-
respond(true, { found: false });
|
|
1215
|
-
return;
|
|
1216
|
-
}
|
|
1217
|
-
respond(true, {
|
|
1218
|
-
found: true,
|
|
1219
|
-
call: toVoiceCallStatus(call)
|
|
1220
|
-
});
|
|
1221
|
-
} catch (err) {
|
|
1222
|
-
sendError(respond, err);
|
|
1223
|
-
}
|
|
993
|
+
registerGatewayCommand("voicecall.continue", ({ params }) => commands.continueCall(normalizeOptionalString(params?.callId), normalizeOptionalString(params?.message)), VOICE_CALL_WRITE_METHOD_SCOPE);
|
|
994
|
+
registerGatewayCommand("voicecall.continue.start", async ({ params }) => continueOperationStore.start(await commands.prepareContinue(normalizeOptionalString(params?.callId), normalizeOptionalString(params?.message))), VOICE_CALL_WRITE_METHOD_SCOPE);
|
|
995
|
+
registerGatewayCommand("voicecall.continue.result", ({ params }) => {
|
|
996
|
+
const operationId = normalizeOptionalString(params?.operationId);
|
|
997
|
+
if (!operationId) throw new VoiceCallCommandInputError("operationId required");
|
|
998
|
+
const operation = continueOperationStore.read(operationId);
|
|
999
|
+
if (!operation.ok) throw new VoiceCallCommandInputError(operation.error);
|
|
1000
|
+
return operation.payload;
|
|
1224
1001
|
}, VOICE_CALL_READ_METHOD_SCOPE);
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
message: message || void 0,
|
|
1250
|
-
mode,
|
|
1251
|
-
dtmfSequence,
|
|
1252
|
-
sessionKey,
|
|
1253
|
-
...requesterSessionKey ? { requesterSessionKey } : {},
|
|
1254
|
-
...normalizedAgentId ? { agentId: normalizedAgentId } : {}
|
|
1255
|
-
});
|
|
1256
|
-
} catch (err) {
|
|
1257
|
-
sendError(respond, err);
|
|
1258
|
-
}
|
|
1002
|
+
registerGatewayCommand("voicecall.speak", ({ params }) => commands.speak({
|
|
1003
|
+
callId: normalizeOptionalString(params?.callId),
|
|
1004
|
+
message: normalizeOptionalString(params?.message),
|
|
1005
|
+
allowTwimlFallback: params?.allowTwimlFallback !== false
|
|
1006
|
+
}), VOICE_CALL_WRITE_METHOD_SCOPE);
|
|
1007
|
+
registerGatewayCommand("voicecall.dtmf", ({ params }) => commands.sendDtmf(normalizeOptionalString(params?.callId), normalizeOptionalString(params?.digits)), VOICE_CALL_WRITE_METHOD_SCOPE);
|
|
1008
|
+
registerGatewayCommand("voicecall.end", ({ params }) => commands.endCall(normalizeOptionalString(params?.callId)), VOICE_CALL_WRITE_METHOD_SCOPE);
|
|
1009
|
+
registerGatewayCommand("voicecall.status", ({ params }) => commands.status(normalizeOptionalString(params?.callId) ?? normalizeOptionalString(params?.sid)), VOICE_CALL_READ_METHOD_SCOPE);
|
|
1010
|
+
registerGatewayCommand("voicecall.start", async ({ params, client }) => {
|
|
1011
|
+
const to = normalizeOptionalString(params?.to);
|
|
1012
|
+
const requestedAgentId = normalizeOptionalString(params?.agentId);
|
|
1013
|
+
const normalizedAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) : void 0;
|
|
1014
|
+
const pluginOwnerId = normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId);
|
|
1015
|
+
if (requestedAgentId && (!pluginOwnerId || normalizedAgentId !== requestedAgentId.toLowerCase())) throw new VoiceCallCommandInputError("agentId requires a trusted plugin caller and a valid agent id");
|
|
1016
|
+
if (!to) throw new VoiceCallCommandInputError("to required");
|
|
1017
|
+
return await commands.initiate({
|
|
1018
|
+
to,
|
|
1019
|
+
message: normalizeOptionalString(params?.message),
|
|
1020
|
+
mode: params?.mode === "notify" || params?.mode === "conversation" ? params.mode : void 0,
|
|
1021
|
+
dtmfSequence: normalizeOptionalString(params?.dtmfSequence),
|
|
1022
|
+
sessionKey: normalizeOptionalString(params?.sessionKey),
|
|
1023
|
+
requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey),
|
|
1024
|
+
agentId: normalizedAgentId
|
|
1025
|
+
});
|
|
1259
1026
|
}, VOICE_CALL_WRITE_METHOD_SCOPE);
|
|
1260
1027
|
api.registerTool((toolContext) => ({
|
|
1261
1028
|
name: "voice_call",
|
|
@@ -1263,94 +1030,52 @@ var voice_call_default = definePluginEntry({
|
|
|
1263
1030
|
description: "Make phone calls and have voice conversations via the voice-call plugin.",
|
|
1264
1031
|
parameters: VoiceCallToolSchema,
|
|
1265
1032
|
async execute(_toolCallId, params) {
|
|
1266
|
-
const rawParams =
|
|
1267
|
-
const
|
|
1033
|
+
const rawParams = asNonArrayRecord(params);
|
|
1034
|
+
const requesterSessionKey = normalizeOptionalString(toolContext.sessionKey);
|
|
1035
|
+
const contextAgentId = normalizeOptionalString(toolContext.agentId) ?? parseAgentSessionKey(requesterSessionKey)?.agentId;
|
|
1036
|
+
const agentId = contextAgentId ? normalizeAgentId(contextAgentId) : void 0;
|
|
1268
1037
|
try {
|
|
1269
|
-
|
|
1038
|
+
await ensureRuntime();
|
|
1270
1039
|
if (typeof rawParams.action === "string") switch (rawParams.action) {
|
|
1271
1040
|
case "initiate_call": {
|
|
1272
|
-
const message = normalizeOptionalString(rawParams.message)
|
|
1273
|
-
if (!message) throw new
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
const result = await rt.manager.initiateCall(to, void 0, {
|
|
1041
|
+
const message = normalizeOptionalString(rawParams.message);
|
|
1042
|
+
if (!message) throw new VoiceCallCommandInputError("message required");
|
|
1043
|
+
return jsonResult(await commands.initiate({
|
|
1044
|
+
to: normalizeOptionalString(rawParams.to),
|
|
1277
1045
|
message,
|
|
1278
1046
|
dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
|
|
1279
1047
|
mode: rawParams.mode === "notify" || rawParams.mode === "conversation" ? rawParams.mode : void 0,
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
callId: result.callId,
|
|
1285
|
-
initiated: true
|
|
1286
|
-
});
|
|
1287
|
-
}
|
|
1288
|
-
case "continue_call": {
|
|
1289
|
-
const callId = normalizeOptionalString(rawParams.callId) ?? "";
|
|
1290
|
-
const message = normalizeOptionalString(rawParams.message) ?? "";
|
|
1291
|
-
if (!callId || !message) throw new Error("callId and message required");
|
|
1292
|
-
const result = await rt.manager.continueCall(callId, message);
|
|
1293
|
-
if (!result.success) throw new Error(result.error || "continue failed");
|
|
1294
|
-
return jsonResult({
|
|
1295
|
-
success: true,
|
|
1296
|
-
transcript: result.transcript
|
|
1297
|
-
});
|
|
1298
|
-
}
|
|
1299
|
-
case "speak_to_user": {
|
|
1300
|
-
const callId = normalizeOptionalString(rawParams.callId) ?? "";
|
|
1301
|
-
const message = normalizeOptionalString(rawParams.message) ?? "";
|
|
1302
|
-
if (!callId || !message) throw new Error("callId and message required");
|
|
1303
|
-
const result = await rt.manager.speak(callId, message);
|
|
1304
|
-
if (!result.success) throw new Error(result.error || "speak failed");
|
|
1305
|
-
return jsonResult({ success: true });
|
|
1306
|
-
}
|
|
1307
|
-
case "send_dtmf": {
|
|
1308
|
-
const callId = normalizeOptionalString(rawParams.callId) ?? "";
|
|
1309
|
-
const digits = normalizeOptionalString(rawParams.digits) ?? "";
|
|
1310
|
-
if (!callId || !digits) throw new Error("callId and digits required");
|
|
1311
|
-
const result = await rt.manager.sendDtmf(callId, digits);
|
|
1312
|
-
if (!result.success) throw new Error(result.error || "dtmf failed");
|
|
1313
|
-
return jsonResult({ success: true });
|
|
1314
|
-
}
|
|
1315
|
-
case "end_call": {
|
|
1316
|
-
const callId = normalizeOptionalString(rawParams.callId) ?? "";
|
|
1317
|
-
if (!callId) throw new Error("callId required");
|
|
1318
|
-
const result = await rt.manager.endCall(callId);
|
|
1319
|
-
if (!result.success) throw new Error(result.error || "end failed");
|
|
1320
|
-
return jsonResult({ success: true });
|
|
1048
|
+
sessionKey: normalizeOptionalString(rawParams.sessionKey),
|
|
1049
|
+
agentId,
|
|
1050
|
+
requesterSessionKey
|
|
1051
|
+
}));
|
|
1321
1052
|
}
|
|
1053
|
+
case "continue_call": return jsonResult(await commands.continueCall(normalizeOptionalString(rawParams.callId), normalizeOptionalString(rawParams.message)));
|
|
1054
|
+
case "speak_to_user": return jsonResult(await commands.speak({
|
|
1055
|
+
callId: normalizeOptionalString(rawParams.callId),
|
|
1056
|
+
message: normalizeOptionalString(rawParams.message)
|
|
1057
|
+
}));
|
|
1058
|
+
case "send_dtmf": return jsonResult(await commands.sendDtmf(normalizeOptionalString(rawParams.callId), normalizeOptionalString(rawParams.digits)));
|
|
1059
|
+
case "end_call": return jsonResult(await commands.endCall(normalizeOptionalString(rawParams.callId)));
|
|
1322
1060
|
case "get_status": {
|
|
1323
|
-
const callId = normalizeOptionalString(rawParams.callId)
|
|
1324
|
-
if (!callId) throw new
|
|
1325
|
-
|
|
1326
|
-
return jsonResult(call ? {
|
|
1327
|
-
found: true,
|
|
1328
|
-
call: toVoiceCallStatus(call)
|
|
1329
|
-
} : { found: false });
|
|
1061
|
+
const callId = normalizeOptionalString(rawParams.callId);
|
|
1062
|
+
if (!callId) throw new VoiceCallCommandInputError("callId required");
|
|
1063
|
+
return jsonResult(await commands.status(callId));
|
|
1330
1064
|
}
|
|
1331
1065
|
}
|
|
1332
1066
|
if ((rawParams.mode ?? "call") === "status") {
|
|
1333
1067
|
const sid = normalizeOptionalString(rawParams.sid) ?? "";
|
|
1334
1068
|
if (!sid) throw new Error("sid required for status");
|
|
1335
|
-
|
|
1336
|
-
return jsonResult(call ? {
|
|
1337
|
-
found: true,
|
|
1338
|
-
call: toVoiceCallStatus(call)
|
|
1339
|
-
} : { found: false });
|
|
1069
|
+
return jsonResult(await commands.status(sid));
|
|
1340
1070
|
}
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
const result = await rt.manager.initiateCall(to, normalizeOptionalString(rawParams.sessionKey), {
|
|
1071
|
+
return jsonResult(await commands.initiate({
|
|
1072
|
+
to: normalizeOptionalString(rawParams.to),
|
|
1344
1073
|
dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
|
|
1345
1074
|
message: normalizeOptionalString(rawParams.message),
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
return jsonResult({
|
|
1351
|
-
callId: result.callId,
|
|
1352
|
-
initiated: true
|
|
1353
|
-
});
|
|
1075
|
+
sessionKey: normalizeOptionalString(rawParams.sessionKey),
|
|
1076
|
+
agentId,
|
|
1077
|
+
requesterSessionKey
|
|
1078
|
+
}, "to required for call"));
|
|
1354
1079
|
} catch (err) {
|
|
1355
1080
|
return jsonResult({ error: formatErrorMessage(err) });
|
|
1356
1081
|
}
|
|
@@ -1368,35 +1093,41 @@ var voice_call_default = definePluginEntry({
|
|
|
1368
1093
|
});
|
|
1369
1094
|
api.registerService({
|
|
1370
1095
|
id: "voicecall",
|
|
1371
|
-
start: () => {
|
|
1096
|
+
start: (ctx) => {
|
|
1372
1097
|
if (isCliOnlyProcess()) return;
|
|
1098
|
+
try {
|
|
1099
|
+
if (runtimeRegistration.generation.retired) {
|
|
1100
|
+
if (runtimeCoordinator.current !== runtimeRegistration) throw new VoiceCallRuntimeLifecycleError("Voice call runtime generation was superseded; use the current plugin registration");
|
|
1101
|
+
runtimeRegistration.generation = { retired: false };
|
|
1102
|
+
}
|
|
1103
|
+
runtimeRegistration.generation.serviceHealth = ctx.serviceHealth;
|
|
1104
|
+
activateRuntimeGeneration(runtimeRegistration.generation);
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
ctx.serviceHealth?.reportFailure(err);
|
|
1107
|
+
api.logger.error(`[voice-call] Failed to start runtime: ${formatErrorMessage(err)}`);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1373
1110
|
if (!config.enabled) return;
|
|
1374
1111
|
if (!validation.valid) {
|
|
1375
|
-
|
|
1112
|
+
const error = /* @__PURE__ */ new Error(`setup incomplete: ${validation.errors.join("; ")}`);
|
|
1113
|
+
ctx.serviceHealth?.reportFailure(error);
|
|
1114
|
+
api.logger.error(`[voice-call] Runtime not started: ${error.message}`);
|
|
1376
1115
|
return;
|
|
1377
1116
|
}
|
|
1378
|
-
|
|
1117
|
+
const startingGeneration = runtimeRegistration.generation;
|
|
1118
|
+
ensureRuntime(startingGeneration).catch((err) => {
|
|
1119
|
+
if (err instanceof VoiceCallRuntimeLifecycleError) return;
|
|
1120
|
+
ctx.serviceHealth?.reportFailure(err);
|
|
1379
1121
|
api.logger.error(`[voice-call] Failed to start runtime: ${formatErrorMessage(err)}`);
|
|
1380
1122
|
});
|
|
1381
1123
|
},
|
|
1382
1124
|
stop: async () => {
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
return;
|
|
1386
|
-
}
|
|
1387
|
-
const runtime = runtimeState[VOICE_CALL_RUNTIME_KEY];
|
|
1388
|
-
const runtimePromise = runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY];
|
|
1389
|
-
if (!runtime && !runtimePromise) return;
|
|
1390
|
-
runtimeState[VOICE_CALL_RUNTIME_KEY] = null;
|
|
1391
|
-
runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = null;
|
|
1392
|
-
const stopPromise = (async () => {
|
|
1393
|
-
await (runtime ?? await runtimePromise).stop();
|
|
1394
|
-
})();
|
|
1395
|
-
runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] = stopPromise;
|
|
1125
|
+
const runtimeGeneration = runtimeRegistration.generation;
|
|
1126
|
+
runtimeGeneration.retired = true;
|
|
1396
1127
|
try {
|
|
1397
|
-
await
|
|
1128
|
+
await stopVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeGeneration);
|
|
1398
1129
|
} finally {
|
|
1399
|
-
|
|
1130
|
+
runtimeGeneration.serviceHealth = void 0;
|
|
1400
1131
|
}
|
|
1401
1132
|
}
|
|
1402
1133
|
});
|