@openclaw/voice-call 2026.5.2 → 2026.5.3-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/dist/api.js +2 -0
- package/dist/call-status-CXldV5o8.js +32 -0
- package/dist/cli-metadata.js +12 -0
- package/dist/config-7w04YpHh.js +548 -0
- package/dist/config-compat-B0me39_4.js +129 -0
- package/dist/guarded-json-api-Btx5EE4w.js +591 -0
- package/dist/http-headers-BrnxBasF.js +10 -0
- package/dist/index.js +1284 -0
- package/dist/mock-CeKvfVEd.js +135 -0
- package/dist/plivo-B-a7KFoT.js +393 -0
- package/dist/realtime-handler-B63CIDP2.js +325 -0
- package/dist/realtime-transcription.runtime-B2h70y2W.js +2 -0
- package/dist/realtime-voice.runtime-Bkh4nvLn.js +2 -0
- package/dist/response-generator-BrcmwDZU.js +182 -0
- package/dist/response-model-CyF5K80p.js +12 -0
- package/dist/runtime-api.js +6 -0
- package/dist/runtime-entry-88ytYAQa.js +3119 -0
- package/dist/runtime-entry.js +2 -0
- package/dist/setup-api.js +37 -0
- package/dist/telnyx-jjBE8boz.js +260 -0
- package/dist/twilio-1OqbcXLL.js +676 -0
- package/dist/voice-mapping-BYDGdWGx.js +40 -0
- package/package.json +14 -6
- package/api.ts +0 -16
- package/cli-metadata.ts +0 -10
- package/config-api.ts +0 -12
- package/index.test.ts +0 -943
- package/index.ts +0 -794
- package/runtime-api.ts +0 -20
- package/runtime-entry.ts +0 -1
- package/setup-api.ts +0 -47
- package/src/allowlist.test.ts +0 -18
- package/src/allowlist.ts +0 -19
- package/src/cli.ts +0 -845
- package/src/config-compat.test.ts +0 -120
- package/src/config-compat.ts +0 -227
- package/src/config.test.ts +0 -479
- package/src/config.ts +0 -808
- package/src/core-bridge.ts +0 -14
- package/src/deep-merge.test.ts +0 -40
- package/src/deep-merge.ts +0 -23
- package/src/gateway-continue-operation.ts +0 -200
- package/src/http-headers.test.ts +0 -16
- package/src/http-headers.ts +0 -15
- package/src/manager/context.ts +0 -42
- package/src/manager/events.test.ts +0 -581
- package/src/manager/events.ts +0 -288
- package/src/manager/lifecycle.ts +0 -53
- package/src/manager/lookup.test.ts +0 -52
- package/src/manager/lookup.ts +0 -35
- package/src/manager/outbound.test.ts +0 -528
- package/src/manager/outbound.ts +0 -486
- package/src/manager/state.ts +0 -48
- package/src/manager/store.ts +0 -106
- package/src/manager/timers.test.ts +0 -129
- package/src/manager/timers.ts +0 -113
- package/src/manager/twiml.test.ts +0 -13
- package/src/manager/twiml.ts +0 -17
- package/src/manager.closed-loop.test.ts +0 -236
- package/src/manager.inbound-allowlist.test.ts +0 -188
- package/src/manager.notify.test.ts +0 -377
- package/src/manager.restore.test.ts +0 -183
- package/src/manager.test-harness.ts +0 -127
- package/src/manager.ts +0 -392
- package/src/media-stream.test.ts +0 -768
- package/src/media-stream.ts +0 -708
- package/src/providers/base.ts +0 -97
- package/src/providers/mock.test.ts +0 -78
- package/src/providers/mock.ts +0 -185
- package/src/providers/plivo.test.ts +0 -93
- package/src/providers/plivo.ts +0 -601
- package/src/providers/shared/call-status.test.ts +0 -24
- package/src/providers/shared/call-status.ts +0 -24
- package/src/providers/shared/guarded-json-api.test.ts +0 -106
- package/src/providers/shared/guarded-json-api.ts +0 -42
- package/src/providers/telnyx.test.ts +0 -340
- package/src/providers/telnyx.ts +0 -394
- package/src/providers/twilio/api.test.ts +0 -145
- package/src/providers/twilio/api.ts +0 -93
- package/src/providers/twilio/twiml-policy.test.ts +0 -84
- package/src/providers/twilio/twiml-policy.ts +0 -87
- package/src/providers/twilio/webhook.ts +0 -34
- package/src/providers/twilio.test.ts +0 -591
- package/src/providers/twilio.ts +0 -861
- package/src/providers/twilio.types.ts +0 -17
- package/src/realtime-defaults.ts +0 -3
- package/src/realtime-fast-context.test.ts +0 -88
- package/src/realtime-fast-context.ts +0 -165
- package/src/realtime-transcription.runtime.ts +0 -4
- package/src/realtime-voice.runtime.ts +0 -5
- package/src/response-generator.test.ts +0 -321
- package/src/response-generator.ts +0 -318
- package/src/response-model.test.ts +0 -71
- package/src/response-model.ts +0 -23
- package/src/runtime.test.ts +0 -536
- package/src/runtime.ts +0 -510
- package/src/telephony-audio.test.ts +0 -61
- package/src/telephony-audio.ts +0 -12
- package/src/telephony-tts.test.ts +0 -196
- package/src/telephony-tts.ts +0 -235
- package/src/test-fixtures.ts +0 -73
- package/src/tts-provider-voice.test.ts +0 -34
- package/src/tts-provider-voice.ts +0 -21
- package/src/tunnel.test.ts +0 -166
- package/src/tunnel.ts +0 -314
- package/src/types.ts +0 -291
- package/src/utils.test.ts +0 -17
- package/src/utils.ts +0 -14
- package/src/voice-mapping.test.ts +0 -34
- package/src/voice-mapping.ts +0 -68
- package/src/webhook/realtime-handler.test.ts +0 -598
- package/src/webhook/realtime-handler.ts +0 -485
- package/src/webhook/stale-call-reaper.test.ts +0 -88
- package/src/webhook/stale-call-reaper.ts +0 -38
- package/src/webhook/tailscale.test.ts +0 -214
- package/src/webhook/tailscale.ts +0 -129
- package/src/webhook-exposure.test.ts +0 -33
- package/src/webhook-exposure.ts +0 -84
- package/src/webhook-security.test.ts +0 -770
- package/src/webhook-security.ts +0 -994
- package/src/webhook.hangup-once.lifecycle.test.ts +0 -135
- package/src/webhook.test.ts +0 -1470
- package/src/webhook.ts +0 -908
- package/src/webhook.types.ts +0 -5
- package/src/websocket-test-support.ts +0 -72
- package/tsconfig.json +0 -16
package/dist/index.js
ADDED
|
@@ -0,0 +1,1284 @@
|
|
|
1
|
+
import { definePluginEntry, sleep } from "./runtime-api.js";
|
|
2
|
+
import "./api.js";
|
|
3
|
+
import { i as resolveVoiceCallConfig, s as validateProviderConfig } from "./config-7w04YpHh.js";
|
|
4
|
+
import { a as setupTailscaleExposureRoute, i as getTailscaleSelfInfo, n as resolveWebhookExposureStatus, r as cleanupTailscaleExposureRoute, s as resolveUserPath, t as createVoiceCallRuntime } from "./runtime-entry-88ytYAQa.js";
|
|
5
|
+
import { i as parseVoiceCallPluginConfig, r as normalizeVoiceCallLegacyConfigInput, t as formatVoiceCallLegacyConfigWarnings } from "./config-compat-B0me39_4.js";
|
|
6
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
7
|
+
import { ErrorCodes, callGatewayFromCli, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
|
|
8
|
+
import { normalizeOptionalLowercaseString, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { format } from "node:util";
|
|
15
|
+
//#region extensions/voice-call/src/cli.ts
|
|
16
|
+
const VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS = 5e3;
|
|
17
|
+
const VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS = 3e4;
|
|
18
|
+
const VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS = 1e4;
|
|
19
|
+
const VOICE_CALL_GATEWAY_POLL_INTERVAL_MS = 1e3;
|
|
20
|
+
const voiceCallCliDeps = { callGatewayFromCli };
|
|
21
|
+
function writeStdoutLine(...values) {
|
|
22
|
+
process.stdout.write(`${format(...values)}\n`);
|
|
23
|
+
}
|
|
24
|
+
function writeStdoutJson(value) {
|
|
25
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
26
|
+
}
|
|
27
|
+
function isRecord$1(value) {
|
|
28
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
29
|
+
}
|
|
30
|
+
function isGatewayUnavailableForLocalFallback(err) {
|
|
31
|
+
const message = formatErrorMessage(err);
|
|
32
|
+
return message.includes("ECONNREFUSED") || message.includes("ECONNRESET") || message.includes("EHOSTUNREACH") || message.includes("ENOTFOUND") || message.includes("gateway not connected");
|
|
33
|
+
}
|
|
34
|
+
async function callVoiceCallGateway(method, params, opts) {
|
|
35
|
+
try {
|
|
36
|
+
const timeoutMs = typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) ? Math.max(1, Math.ceil(opts.timeoutMs)) : VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS;
|
|
37
|
+
return {
|
|
38
|
+
ok: true,
|
|
39
|
+
payload: await voiceCallCliDeps.callGatewayFromCli(method, {
|
|
40
|
+
json: true,
|
|
41
|
+
timeout: String(timeoutMs)
|
|
42
|
+
}, params, { progress: false })
|
|
43
|
+
};
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (isGatewayUnavailableForLocalFallback(err)) return {
|
|
46
|
+
ok: false,
|
|
47
|
+
error: err
|
|
48
|
+
};
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function resolveGatewayOperationTimeoutMs(config) {
|
|
53
|
+
return Math.max(VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS, config.ringTimeoutMs + 5e3);
|
|
54
|
+
}
|
|
55
|
+
function resolveGatewayContinueTimeoutMs(config) {
|
|
56
|
+
return config.transcriptTimeoutMs + VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS + VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS;
|
|
57
|
+
}
|
|
58
|
+
function isUnknownGatewayMethod(err, method) {
|
|
59
|
+
return formatErrorMessage(err).includes(`unknown method: ${method}`);
|
|
60
|
+
}
|
|
61
|
+
function readGatewayOperationId(payload) {
|
|
62
|
+
if (isRecord$1(payload) && typeof payload.operationId === "string" && payload.operationId) return payload.operationId;
|
|
63
|
+
throw new Error("voicecall gateway response missing operationId");
|
|
64
|
+
}
|
|
65
|
+
function readGatewayPollTimeoutMs(payload, fallbackTimeoutMs) {
|
|
66
|
+
if (isRecord$1(payload) && typeof payload.pollTimeoutMs === "number") return Math.max(1, Math.ceil(payload.pollTimeoutMs));
|
|
67
|
+
return fallbackTimeoutMs;
|
|
68
|
+
}
|
|
69
|
+
function readCompletedContinueResult(payload) {
|
|
70
|
+
if (!isRecord$1(payload)) throw new Error("voicecall gateway response missing operation status");
|
|
71
|
+
if (payload.status === "pending") return { status: "pending" };
|
|
72
|
+
if (payload.status === "failed") return {
|
|
73
|
+
status: "failed",
|
|
74
|
+
error: typeof payload.error === "string" ? payload.error : "continue failed"
|
|
75
|
+
};
|
|
76
|
+
if (payload.status === "completed") return {
|
|
77
|
+
status: "completed",
|
|
78
|
+
result: payload.result
|
|
79
|
+
};
|
|
80
|
+
throw new Error("voicecall gateway response has unknown operation status");
|
|
81
|
+
}
|
|
82
|
+
async function pollVoiceCallContinueGateway(params) {
|
|
83
|
+
const deadlineMs = Date.now() + params.timeoutMs;
|
|
84
|
+
while (Date.now() <= deadlineMs) {
|
|
85
|
+
const gateway = await callVoiceCallGateway("voicecall.continue.result", { operationId: params.operationId }, { timeoutMs: VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS });
|
|
86
|
+
if (!gateway.ok) throw new Error(`gateway unavailable while waiting for voicecall continue result: ${formatErrorMessage(gateway.error)}`);
|
|
87
|
+
const result = readCompletedContinueResult(gateway.payload);
|
|
88
|
+
if (result.status === "completed") return result.result;
|
|
89
|
+
if (result.status === "failed") throw new Error(result.error);
|
|
90
|
+
await sleep(Math.min(VOICE_CALL_GATEWAY_POLL_INTERVAL_MS, Math.max(1, deadlineMs - Date.now())));
|
|
91
|
+
}
|
|
92
|
+
throw new Error("voicecall continue timed out waiting for gateway operation");
|
|
93
|
+
}
|
|
94
|
+
function resolveMode(input) {
|
|
95
|
+
const raw = normalizeOptionalLowercaseString(input) ?? "";
|
|
96
|
+
if (raw === "serve" || raw === "off") return raw;
|
|
97
|
+
return "funnel";
|
|
98
|
+
}
|
|
99
|
+
function resolveDefaultStorePath(config) {
|
|
100
|
+
const resolvedPreferred = resolveUserPath(path.join(os.homedir(), ".openclaw", "voice-calls"));
|
|
101
|
+
const existing = [resolvedPreferred].find((dir) => {
|
|
102
|
+
try {
|
|
103
|
+
return fs.existsSync(path.join(dir, "calls.jsonl")) || fs.existsSync(dir);
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}) ?? resolvedPreferred;
|
|
108
|
+
const base = config.store?.trim() ? resolveUserPath(config.store) : existing;
|
|
109
|
+
return path.join(base, "calls.jsonl");
|
|
110
|
+
}
|
|
111
|
+
function percentile(values, p) {
|
|
112
|
+
if (values.length === 0) return 0;
|
|
113
|
+
const sorted = [...values].toSorted((a, b) => a - b);
|
|
114
|
+
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1))] ?? 0;
|
|
115
|
+
}
|
|
116
|
+
function summarizeSeries(values) {
|
|
117
|
+
if (values.length === 0) return {
|
|
118
|
+
count: 0,
|
|
119
|
+
minMs: 0,
|
|
120
|
+
maxMs: 0,
|
|
121
|
+
avgMs: 0,
|
|
122
|
+
p50Ms: 0,
|
|
123
|
+
p95Ms: 0
|
|
124
|
+
};
|
|
125
|
+
const minMs = values.reduce((min, value) => value < min ? value : min, Number.POSITIVE_INFINITY);
|
|
126
|
+
const maxMs = values.reduce((max, value) => value > max ? value : max, Number.NEGATIVE_INFINITY);
|
|
127
|
+
const avgMs = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
128
|
+
return {
|
|
129
|
+
count: values.length,
|
|
130
|
+
minMs,
|
|
131
|
+
maxMs,
|
|
132
|
+
avgMs,
|
|
133
|
+
p50Ms: percentile(values, 50),
|
|
134
|
+
p95Ms: percentile(values, 95)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function resolveCallMode(mode) {
|
|
138
|
+
return mode === "notify" || mode === "conversation" ? mode : void 0;
|
|
139
|
+
}
|
|
140
|
+
function buildSetupStatus(config) {
|
|
141
|
+
const validation = validateProviderConfig(config);
|
|
142
|
+
const webhookExposure = resolveWebhookExposureStatus(config);
|
|
143
|
+
const checks = [
|
|
144
|
+
{
|
|
145
|
+
id: "plugin-enabled",
|
|
146
|
+
ok: config.enabled,
|
|
147
|
+
message: config.enabled ? "Voice Call plugin is enabled" : "Enable plugins.entries.voice-call.enabled"
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: "provider",
|
|
151
|
+
ok: Boolean(config.provider),
|
|
152
|
+
message: config.provider ? `Provider configured: ${config.provider}` : "Set plugins.entries.voice-call.config.provider"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: "provider-config",
|
|
156
|
+
ok: validation.valid,
|
|
157
|
+
message: validation.valid ? "Provider credentials/config look complete" : validation.errors.join("; ")
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: "webhook-exposure",
|
|
161
|
+
ok: webhookExposure.ok,
|
|
162
|
+
message: webhookExposure.message
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: "mode",
|
|
166
|
+
ok: !(config.streaming.enabled && config.realtime.enabled),
|
|
167
|
+
message: config.streaming.enabled && config.realtime.enabled ? "streaming.enabled and realtime.enabled cannot both be true" : config.realtime.enabled ? `Realtime voice enabled (${config.realtime.provider ?? "first registered provider"})` : config.streaming.enabled ? `Streaming transcription enabled (${config.streaming.provider ?? "first registered provider"})` : "Notify/conversation calls use normal TTS/STT flow"
|
|
168
|
+
}
|
|
169
|
+
];
|
|
170
|
+
return {
|
|
171
|
+
ok: checks.every((check) => check.ok),
|
|
172
|
+
checks
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function writeSetupStatus(status) {
|
|
176
|
+
writeStdoutLine("Voice Call setup: %s", status.ok ? "OK" : "needs attention");
|
|
177
|
+
for (const check of status.checks) writeStdoutLine("%s %s: %s", check.ok ? "OK" : "FAIL", check.id, check.message);
|
|
178
|
+
}
|
|
179
|
+
async function initiateCallAndPrintId(params) {
|
|
180
|
+
const result = await params.runtime.manager.initiateCall(params.to, void 0, {
|
|
181
|
+
message: params.message,
|
|
182
|
+
mode: resolveCallMode(params.mode)
|
|
183
|
+
});
|
|
184
|
+
if (!result.success) throw new Error(result.error || "initiate failed");
|
|
185
|
+
writeStdoutJson({ callId: result.callId });
|
|
186
|
+
}
|
|
187
|
+
function writeGatewayCallId(payload) {
|
|
188
|
+
if (isRecord$1(payload) && typeof payload.callId === "string") {
|
|
189
|
+
writeStdoutJson({ callId: payload.callId });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (isRecord$1(payload) && typeof payload.error === "string") throw new Error(payload.error);
|
|
193
|
+
throw new Error("voicecall gateway response missing callId");
|
|
194
|
+
}
|
|
195
|
+
async function initiateCallViaGatewayOrRuntime(params) {
|
|
196
|
+
const mode = resolveCallMode(params.mode);
|
|
197
|
+
const gateway = await callVoiceCallGateway(params.method, {
|
|
198
|
+
...params.to ? { to: params.to } : {},
|
|
199
|
+
...params.message ? { message: params.message } : {},
|
|
200
|
+
...mode ? { mode } : {}
|
|
201
|
+
}, { timeoutMs: resolveGatewayOperationTimeoutMs(params.config) });
|
|
202
|
+
if (gateway.ok) {
|
|
203
|
+
writeGatewayCallId(gateway.payload);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const rt = await params.ensureRuntime();
|
|
207
|
+
const to = params.to ?? rt.config.toNumber;
|
|
208
|
+
if (!to) throw new Error("Missing --to and no toNumber configured");
|
|
209
|
+
await initiateCallAndPrintId({
|
|
210
|
+
runtime: rt,
|
|
211
|
+
to,
|
|
212
|
+
message: params.message,
|
|
213
|
+
mode: params.mode
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
function registerVoiceCallCli(params) {
|
|
217
|
+
const { program, config, ensureRuntime, logger } = params;
|
|
218
|
+
const root = program.command("voicecall").description("Voice call utilities").addHelpText("after", () => `\nDocs: https://docs.openclaw.ai/cli/voicecall\n`);
|
|
219
|
+
root.command("setup").description("Show Voice Call provider and webhook setup status").option("--json", "Print machine-readable JSON").action((options) => {
|
|
220
|
+
const status = buildSetupStatus(config);
|
|
221
|
+
if (options.json) {
|
|
222
|
+
writeStdoutJson(status);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
writeSetupStatus(status);
|
|
226
|
+
});
|
|
227
|
+
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) => {
|
|
228
|
+
const setup = buildSetupStatus(config);
|
|
229
|
+
if (!setup.ok) {
|
|
230
|
+
if (options.json) writeStdoutJson({
|
|
231
|
+
ok: false,
|
|
232
|
+
setup
|
|
233
|
+
});
|
|
234
|
+
else writeSetupStatus(setup);
|
|
235
|
+
process.exitCode = 1;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!options.to) {
|
|
239
|
+
if (options.json) writeStdoutJson({
|
|
240
|
+
ok: true,
|
|
241
|
+
setup,
|
|
242
|
+
liveCall: false
|
|
243
|
+
});
|
|
244
|
+
else {
|
|
245
|
+
writeSetupStatus(setup);
|
|
246
|
+
writeStdoutLine("live-call: skipped (pass --to and --yes to place one)");
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (!options.yes) {
|
|
251
|
+
if (options.json) writeStdoutJson({
|
|
252
|
+
ok: true,
|
|
253
|
+
setup,
|
|
254
|
+
liveCall: false,
|
|
255
|
+
wouldCall: options.to
|
|
256
|
+
});
|
|
257
|
+
else {
|
|
258
|
+
writeSetupStatus(setup);
|
|
259
|
+
writeStdoutLine("live-call: dry run for %s (add --yes to place it)", options.to);
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const mode = resolveCallMode(options.mode) ?? "notify";
|
|
264
|
+
const gateway = await callVoiceCallGateway("voicecall.start", {
|
|
265
|
+
to: options.to,
|
|
266
|
+
...options.message ? { message: options.message } : {},
|
|
267
|
+
mode
|
|
268
|
+
}, { timeoutMs: resolveGatewayOperationTimeoutMs(config) });
|
|
269
|
+
let callId;
|
|
270
|
+
if (gateway.ok) callId = isRecord$1(gateway.payload) ? gateway.payload.callId : void 0;
|
|
271
|
+
else {
|
|
272
|
+
const result = await (await ensureRuntime()).manager.initiateCall(options.to, void 0, {
|
|
273
|
+
message: options.message,
|
|
274
|
+
mode
|
|
275
|
+
});
|
|
276
|
+
if (!result.success) throw new Error(result.error || "smoke call failed");
|
|
277
|
+
callId = result.callId;
|
|
278
|
+
}
|
|
279
|
+
if (typeof callId !== "string" || !callId) throw new Error("smoke call failed");
|
|
280
|
+
if (options.json) {
|
|
281
|
+
writeStdoutJson({
|
|
282
|
+
ok: true,
|
|
283
|
+
setup,
|
|
284
|
+
liveCall: true,
|
|
285
|
+
callId
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
writeSetupStatus(setup);
|
|
290
|
+
writeStdoutLine("live-call: started %s", callId);
|
|
291
|
+
});
|
|
292
|
+
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) => {
|
|
293
|
+
await initiateCallViaGatewayOrRuntime({
|
|
294
|
+
ensureRuntime,
|
|
295
|
+
config,
|
|
296
|
+
method: "voicecall.initiate",
|
|
297
|
+
to: options.to,
|
|
298
|
+
message: options.message,
|
|
299
|
+
mode: options.mode
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
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) => {
|
|
303
|
+
await initiateCallViaGatewayOrRuntime({
|
|
304
|
+
ensureRuntime,
|
|
305
|
+
config,
|
|
306
|
+
method: "voicecall.start",
|
|
307
|
+
to: options.to,
|
|
308
|
+
message: options.message,
|
|
309
|
+
mode: options.mode
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
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) => {
|
|
313
|
+
let gateway;
|
|
314
|
+
try {
|
|
315
|
+
gateway = await callVoiceCallGateway("voicecall.continue.start", {
|
|
316
|
+
callId: options.callId,
|
|
317
|
+
message: options.message
|
|
318
|
+
}, { timeoutMs: resolveGatewayOperationTimeoutMs(config) });
|
|
319
|
+
} catch (err) {
|
|
320
|
+
if (!isUnknownGatewayMethod(err, "voicecall.continue.start")) throw err;
|
|
321
|
+
gateway = await callVoiceCallGateway("voicecall.continue", {
|
|
322
|
+
callId: options.callId,
|
|
323
|
+
message: options.message
|
|
324
|
+
}, { timeoutMs: resolveGatewayContinueTimeoutMs(config) });
|
|
325
|
+
}
|
|
326
|
+
if (gateway.ok) {
|
|
327
|
+
if (isRecord$1(gateway.payload) && typeof gateway.payload.operationId === "string") {
|
|
328
|
+
writeStdoutJson(await pollVoiceCallContinueGateway({
|
|
329
|
+
operationId: readGatewayOperationId(gateway.payload),
|
|
330
|
+
timeoutMs: readGatewayPollTimeoutMs(gateway.payload, resolveGatewayContinueTimeoutMs(config))
|
|
331
|
+
}));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
writeStdoutJson(gateway.payload);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const result = await (await ensureRuntime()).manager.continueCall(options.callId, options.message);
|
|
338
|
+
if (!result.success) throw new Error(result.error || "continue failed");
|
|
339
|
+
writeStdoutJson(result);
|
|
340
|
+
});
|
|
341
|
+
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) => {
|
|
342
|
+
const gateway = await callVoiceCallGateway("voicecall.speak", {
|
|
343
|
+
callId: options.callId,
|
|
344
|
+
message: options.message
|
|
345
|
+
});
|
|
346
|
+
if (gateway.ok) {
|
|
347
|
+
writeStdoutJson(gateway.payload);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const result = await (await ensureRuntime()).manager.speak(options.callId, options.message);
|
|
351
|
+
if (!result.success) throw new Error(result.error || "speak failed");
|
|
352
|
+
writeStdoutJson(result);
|
|
353
|
+
});
|
|
354
|
+
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) => {
|
|
355
|
+
const gateway = await callVoiceCallGateway("voicecall.dtmf", {
|
|
356
|
+
callId: options.callId,
|
|
357
|
+
digits: options.digits
|
|
358
|
+
});
|
|
359
|
+
if (gateway.ok) {
|
|
360
|
+
writeStdoutJson(gateway.payload);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const result = await (await ensureRuntime()).manager.sendDtmf(options.callId, options.digits);
|
|
364
|
+
if (!result.success) throw new Error(result.error || "dtmf failed");
|
|
365
|
+
writeStdoutJson(result);
|
|
366
|
+
});
|
|
367
|
+
root.command("end").description("Hang up an active call").requiredOption("--call-id <id>", "Call ID").action(async (options) => {
|
|
368
|
+
const gateway = await callVoiceCallGateway("voicecall.end", { callId: options.callId });
|
|
369
|
+
if (gateway.ok) {
|
|
370
|
+
writeStdoutJson(gateway.payload);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const result = await (await ensureRuntime()).manager.endCall(options.callId);
|
|
374
|
+
if (!result.success) throw new Error(result.error || "end failed");
|
|
375
|
+
writeStdoutJson(result);
|
|
376
|
+
});
|
|
377
|
+
root.command("status").description("Show call status").option("--call-id <id>", "Call ID").option("--json", "Print machine-readable JSON").action(async (options) => {
|
|
378
|
+
const gateway = await callVoiceCallGateway("voicecall.status", options.callId ? { callId: options.callId } : void 0);
|
|
379
|
+
if (gateway.ok) {
|
|
380
|
+
if (options.callId && isRecord$1(gateway.payload)) {
|
|
381
|
+
if (gateway.payload.found === true && "call" in gateway.payload) {
|
|
382
|
+
writeStdoutJson(gateway.payload.call);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (gateway.payload.found === false) {
|
|
386
|
+
writeStdoutJson({ found: false });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
writeStdoutJson(gateway.payload);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const rt = await ensureRuntime();
|
|
394
|
+
if (options.callId) {
|
|
395
|
+
writeStdoutJson(rt.manager.getCall(options.callId) ?? { found: false });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
writeStdoutJson({
|
|
399
|
+
found: true,
|
|
400
|
+
calls: rt.manager.getActiveCalls()
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
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) => {
|
|
404
|
+
const file = options.file;
|
|
405
|
+
const since = Math.max(0, Number(options.since ?? 0));
|
|
406
|
+
const pollMs = Math.max(50, Number(options.poll ?? 250));
|
|
407
|
+
if (!fs.existsSync(file)) {
|
|
408
|
+
logger.error(`No log file at ${file}`);
|
|
409
|
+
process.exit(1);
|
|
410
|
+
}
|
|
411
|
+
const initial = fs.readFileSync(file, "utf8");
|
|
412
|
+
const lines = initial.split("\n").filter(Boolean);
|
|
413
|
+
for (const line of lines.slice(Math.max(0, lines.length - since))) writeStdoutLine(line);
|
|
414
|
+
let offset = Buffer.byteLength(initial, "utf8");
|
|
415
|
+
for (;;) {
|
|
416
|
+
try {
|
|
417
|
+
const stat = fs.statSync(file);
|
|
418
|
+
if (stat.size < offset) offset = 0;
|
|
419
|
+
if (stat.size > offset) {
|
|
420
|
+
const fd = fs.openSync(file, "r");
|
|
421
|
+
try {
|
|
422
|
+
const buf = Buffer.alloc(stat.size - offset);
|
|
423
|
+
fs.readSync(fd, buf, 0, buf.length, offset);
|
|
424
|
+
offset = stat.size;
|
|
425
|
+
const text = buf.toString("utf8");
|
|
426
|
+
for (const line of text.split("\n").filter(Boolean)) writeStdoutLine(line);
|
|
427
|
+
} finally {
|
|
428
|
+
fs.closeSync(fd);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
} catch {}
|
|
432
|
+
await sleep(pollMs);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
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) => {
|
|
436
|
+
const file = options.file;
|
|
437
|
+
const last = Math.max(1, Number(options.last ?? 200));
|
|
438
|
+
if (!fs.existsSync(file)) throw new Error("No log file at " + file);
|
|
439
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-last);
|
|
440
|
+
const turnLatencyMs = [];
|
|
441
|
+
const listenWaitMs = [];
|
|
442
|
+
for (const line of lines) try {
|
|
443
|
+
const parsed = JSON.parse(line);
|
|
444
|
+
const latency = parsed.metadata?.lastTurnLatencyMs;
|
|
445
|
+
const listenWait = parsed.metadata?.lastTurnListenWaitMs;
|
|
446
|
+
if (typeof latency === "number" && Number.isFinite(latency)) turnLatencyMs.push(latency);
|
|
447
|
+
if (typeof listenWait === "number" && Number.isFinite(listenWait)) listenWaitMs.push(listenWait);
|
|
448
|
+
} catch {}
|
|
449
|
+
writeStdoutJson({
|
|
450
|
+
recordsScanned: lines.length,
|
|
451
|
+
turnLatency: summarizeSeries(turnLatencyMs),
|
|
452
|
+
listenWait: summarizeSeries(listenWaitMs)
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
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) => {
|
|
456
|
+
const mode = resolveMode(options.mode ?? "funnel");
|
|
457
|
+
const servePort = Number(options.port ?? config.serve.port ?? 3334);
|
|
458
|
+
const servePath = options.servePath ?? config.serve.path ?? "/voice/webhook";
|
|
459
|
+
const tsPath = options.path ?? config.tailscale?.path ?? servePath;
|
|
460
|
+
const localUrl = `http://127.0.0.1:${servePort}`;
|
|
461
|
+
if (mode === "off") {
|
|
462
|
+
await cleanupTailscaleExposureRoute({
|
|
463
|
+
mode: "serve",
|
|
464
|
+
path: tsPath
|
|
465
|
+
});
|
|
466
|
+
await cleanupTailscaleExposureRoute({
|
|
467
|
+
mode: "funnel",
|
|
468
|
+
path: tsPath
|
|
469
|
+
});
|
|
470
|
+
writeStdoutJson({
|
|
471
|
+
ok: true,
|
|
472
|
+
mode: "off",
|
|
473
|
+
path: tsPath
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const publicUrl = await setupTailscaleExposureRoute({
|
|
478
|
+
mode,
|
|
479
|
+
path: tsPath,
|
|
480
|
+
localUrl
|
|
481
|
+
});
|
|
482
|
+
const tsInfo = publicUrl ? null : await getTailscaleSelfInfo();
|
|
483
|
+
const enableUrl = tsInfo?.nodeId ? `https://login.tailscale.com/f/${mode}?node=${tsInfo.nodeId}` : null;
|
|
484
|
+
writeStdoutJson({
|
|
485
|
+
ok: Boolean(publicUrl),
|
|
486
|
+
mode,
|
|
487
|
+
path: tsPath,
|
|
488
|
+
localUrl,
|
|
489
|
+
publicUrl,
|
|
490
|
+
hint: publicUrl ? void 0 : {
|
|
491
|
+
note: "Tailscale serve/funnel may be disabled on this tailnet (or require admin enable).",
|
|
492
|
+
enableUrl
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
//#endregion
|
|
498
|
+
//#region extensions/voice-call/src/gateway-continue-operation.ts
|
|
499
|
+
const VOICE_CALL_CONTINUE_OPERATION_BUFFER_MS = 3e4;
|
|
500
|
+
const VOICE_CALL_CONTINUE_OPERATION_CLEANUP_MS = 300 * 1e3;
|
|
501
|
+
function createVoiceCallContinueOperationStore(params) {
|
|
502
|
+
const operations = /* @__PURE__ */ new Map();
|
|
503
|
+
const resolvePollTimeoutMs = (rt) => {
|
|
504
|
+
const ttsTimeoutMs = rt.config.tts?.timeoutMs ?? params.config.tts?.timeoutMs ?? params.coreConfig.messages?.tts?.timeoutMs ?? 8e3;
|
|
505
|
+
return (rt.config.transcriptTimeoutMs ?? params.config.transcriptTimeoutMs) + ttsTimeoutMs + VOICE_CALL_CONTINUE_OPERATION_BUFFER_MS;
|
|
506
|
+
};
|
|
507
|
+
const scheduleCleanup = (operationId) => {
|
|
508
|
+
setTimeout(() => {
|
|
509
|
+
operations.delete(operationId);
|
|
510
|
+
}, VOICE_CALL_CONTINUE_OPERATION_CLEANUP_MS).unref?.();
|
|
511
|
+
};
|
|
512
|
+
const start = (request) => {
|
|
513
|
+
const operationId = randomUUID();
|
|
514
|
+
const startedAtMs = Date.now();
|
|
515
|
+
const pollTimeoutMs = resolvePollTimeoutMs(request.rt);
|
|
516
|
+
operations.set(operationId, {
|
|
517
|
+
operationId,
|
|
518
|
+
status: "pending",
|
|
519
|
+
callId: request.callId,
|
|
520
|
+
startedAtMs,
|
|
521
|
+
pollTimeoutMs
|
|
522
|
+
});
|
|
523
|
+
request.rt.manager.continueCall(request.callId, request.message).then((result) => {
|
|
524
|
+
const current = operations.get(operationId);
|
|
525
|
+
if (!current || current.status !== "pending") return;
|
|
526
|
+
if (!result.success) {
|
|
527
|
+
operations.set(operationId, {
|
|
528
|
+
operationId,
|
|
529
|
+
status: "failed",
|
|
530
|
+
callId: request.callId,
|
|
531
|
+
startedAtMs,
|
|
532
|
+
completedAtMs: Date.now(),
|
|
533
|
+
pollTimeoutMs,
|
|
534
|
+
error: result.error || "continue failed"
|
|
535
|
+
});
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
operations.set(operationId, {
|
|
539
|
+
operationId,
|
|
540
|
+
status: "completed",
|
|
541
|
+
callId: request.callId,
|
|
542
|
+
startedAtMs,
|
|
543
|
+
completedAtMs: Date.now(),
|
|
544
|
+
pollTimeoutMs,
|
|
545
|
+
result: {
|
|
546
|
+
success: true,
|
|
547
|
+
transcript: result.transcript
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
}).catch((err) => {
|
|
551
|
+
const current = operations.get(operationId);
|
|
552
|
+
if (!current || current.status !== "pending") return;
|
|
553
|
+
operations.set(operationId, {
|
|
554
|
+
operationId,
|
|
555
|
+
status: "failed",
|
|
556
|
+
callId: request.callId,
|
|
557
|
+
startedAtMs,
|
|
558
|
+
completedAtMs: Date.now(),
|
|
559
|
+
pollTimeoutMs,
|
|
560
|
+
error: formatErrorMessage(err)
|
|
561
|
+
});
|
|
562
|
+
}).finally(() => {
|
|
563
|
+
scheduleCleanup(operationId);
|
|
564
|
+
});
|
|
565
|
+
return {
|
|
566
|
+
operationId,
|
|
567
|
+
status: "pending",
|
|
568
|
+
pollTimeoutMs
|
|
569
|
+
};
|
|
570
|
+
};
|
|
571
|
+
const read = (operationId) => {
|
|
572
|
+
const operation = operations.get(operationId);
|
|
573
|
+
if (!operation) return {
|
|
574
|
+
ok: false,
|
|
575
|
+
error: "operation not found"
|
|
576
|
+
};
|
|
577
|
+
if (operation.status === "pending") return {
|
|
578
|
+
ok: true,
|
|
579
|
+
payload: {
|
|
580
|
+
operationId,
|
|
581
|
+
status: "pending",
|
|
582
|
+
pollTimeoutMs: operation.pollTimeoutMs
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
if (operation.status === "failed") {
|
|
586
|
+
operations.delete(operationId);
|
|
587
|
+
return {
|
|
588
|
+
ok: true,
|
|
589
|
+
payload: {
|
|
590
|
+
operationId,
|
|
591
|
+
status: "failed",
|
|
592
|
+
error: operation.error
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
operations.delete(operationId);
|
|
597
|
+
return {
|
|
598
|
+
ok: true,
|
|
599
|
+
payload: {
|
|
600
|
+
operationId,
|
|
601
|
+
status: "completed",
|
|
602
|
+
result: operation.result
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
};
|
|
606
|
+
return {
|
|
607
|
+
start,
|
|
608
|
+
read
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
//#endregion
|
|
612
|
+
//#region extensions/voice-call/index.ts
|
|
613
|
+
const voiceCallConfigSchema = {
|
|
614
|
+
parse(value) {
|
|
615
|
+
const normalized = normalizeVoiceCallLegacyConfigInput(value);
|
|
616
|
+
const enabled = typeof normalized.enabled === "boolean" ? normalized.enabled : true;
|
|
617
|
+
return parseVoiceCallPluginConfig({
|
|
618
|
+
...normalized,
|
|
619
|
+
enabled,
|
|
620
|
+
provider: normalized.provider ?? (enabled ? "mock" : void 0)
|
|
621
|
+
});
|
|
622
|
+
},
|
|
623
|
+
uiHints: {
|
|
624
|
+
provider: {
|
|
625
|
+
label: "Provider",
|
|
626
|
+
help: "Use twilio, telnyx, or mock for dev/no-network."
|
|
627
|
+
},
|
|
628
|
+
fromNumber: {
|
|
629
|
+
label: "From Number",
|
|
630
|
+
placeholder: "+15550001234"
|
|
631
|
+
},
|
|
632
|
+
toNumber: {
|
|
633
|
+
label: "Default To Number",
|
|
634
|
+
placeholder: "+15550001234"
|
|
635
|
+
},
|
|
636
|
+
inboundPolicy: { label: "Inbound Policy" },
|
|
637
|
+
allowFrom: { label: "Inbound Allowlist" },
|
|
638
|
+
inboundGreeting: {
|
|
639
|
+
label: "Inbound Greeting",
|
|
640
|
+
advanced: true
|
|
641
|
+
},
|
|
642
|
+
numbers: {
|
|
643
|
+
label: "Per-number Routing",
|
|
644
|
+
help: "Inbound overrides keyed by dialed E.164 number.",
|
|
645
|
+
advanced: true
|
|
646
|
+
},
|
|
647
|
+
"telnyx.apiKey": {
|
|
648
|
+
label: "Telnyx API Key",
|
|
649
|
+
sensitive: true
|
|
650
|
+
},
|
|
651
|
+
"telnyx.connectionId": { label: "Telnyx Connection ID" },
|
|
652
|
+
"telnyx.publicKey": {
|
|
653
|
+
label: "Telnyx Public Key",
|
|
654
|
+
sensitive: true
|
|
655
|
+
},
|
|
656
|
+
"twilio.accountSid": { label: "Twilio Account SID" },
|
|
657
|
+
"twilio.authToken": {
|
|
658
|
+
label: "Twilio Auth Token",
|
|
659
|
+
sensitive: true
|
|
660
|
+
},
|
|
661
|
+
"outbound.defaultMode": { label: "Default Call Mode" },
|
|
662
|
+
"outbound.notifyHangupDelaySec": {
|
|
663
|
+
label: "Notify Hangup Delay (sec)",
|
|
664
|
+
advanced: true
|
|
665
|
+
},
|
|
666
|
+
"serve.port": { label: "Webhook Port" },
|
|
667
|
+
"serve.bind": { label: "Webhook Bind" },
|
|
668
|
+
"serve.path": { label: "Webhook Path" },
|
|
669
|
+
"tailscale.mode": {
|
|
670
|
+
label: "Tailscale Mode",
|
|
671
|
+
advanced: true
|
|
672
|
+
},
|
|
673
|
+
"tailscale.path": {
|
|
674
|
+
label: "Tailscale Path",
|
|
675
|
+
advanced: true
|
|
676
|
+
},
|
|
677
|
+
"tunnel.provider": {
|
|
678
|
+
label: "Tunnel Provider",
|
|
679
|
+
advanced: true
|
|
680
|
+
},
|
|
681
|
+
"tunnel.ngrokAuthToken": {
|
|
682
|
+
label: "ngrok Auth Token",
|
|
683
|
+
sensitive: true,
|
|
684
|
+
advanced: true
|
|
685
|
+
},
|
|
686
|
+
"tunnel.ngrokDomain": {
|
|
687
|
+
label: "ngrok Domain",
|
|
688
|
+
advanced: true
|
|
689
|
+
},
|
|
690
|
+
"tunnel.allowNgrokFreeTierLoopbackBypass": {
|
|
691
|
+
label: "Allow ngrok Free Tier (Loopback Bypass)",
|
|
692
|
+
advanced: true
|
|
693
|
+
},
|
|
694
|
+
"streaming.enabled": {
|
|
695
|
+
label: "Enable Streaming",
|
|
696
|
+
advanced: true
|
|
697
|
+
},
|
|
698
|
+
"streaming.provider": {
|
|
699
|
+
label: "Streaming Provider",
|
|
700
|
+
help: "Uses the first registered realtime transcription provider when unset.",
|
|
701
|
+
advanced: true
|
|
702
|
+
},
|
|
703
|
+
"streaming.providers": {
|
|
704
|
+
label: "Streaming Provider Config",
|
|
705
|
+
advanced: true
|
|
706
|
+
},
|
|
707
|
+
"streaming.streamPath": {
|
|
708
|
+
label: "Media Stream Path",
|
|
709
|
+
advanced: true
|
|
710
|
+
},
|
|
711
|
+
"realtime.enabled": {
|
|
712
|
+
label: "Enable Realtime Voice",
|
|
713
|
+
advanced: true
|
|
714
|
+
},
|
|
715
|
+
"realtime.provider": {
|
|
716
|
+
label: "Realtime Voice Provider",
|
|
717
|
+
help: "Uses the first registered realtime voice provider when unset.",
|
|
718
|
+
advanced: true
|
|
719
|
+
},
|
|
720
|
+
"realtime.streamPath": {
|
|
721
|
+
label: "Realtime Stream Path",
|
|
722
|
+
advanced: true
|
|
723
|
+
},
|
|
724
|
+
"realtime.instructions": {
|
|
725
|
+
label: "Realtime Instructions",
|
|
726
|
+
advanced: true
|
|
727
|
+
},
|
|
728
|
+
"realtime.toolPolicy": {
|
|
729
|
+
label: "Realtime Tool Policy",
|
|
730
|
+
help: "Controls the shared openclaw_agent_consult tool.",
|
|
731
|
+
advanced: true
|
|
732
|
+
},
|
|
733
|
+
"realtime.fastContext.enabled": {
|
|
734
|
+
label: "Enable Fast Realtime Context",
|
|
735
|
+
help: "Searches memory/session context before the full consult agent.",
|
|
736
|
+
advanced: true
|
|
737
|
+
},
|
|
738
|
+
"realtime.fastContext.timeoutMs": {
|
|
739
|
+
label: "Fast Context Timeout",
|
|
740
|
+
advanced: true
|
|
741
|
+
},
|
|
742
|
+
"realtime.fastContext.maxResults": {
|
|
743
|
+
label: "Fast Context Result Limit",
|
|
744
|
+
advanced: true
|
|
745
|
+
},
|
|
746
|
+
"realtime.fastContext.sources": {
|
|
747
|
+
label: "Fast Context Sources",
|
|
748
|
+
advanced: true
|
|
749
|
+
},
|
|
750
|
+
"realtime.fastContext.fallbackToConsult": {
|
|
751
|
+
label: "Fallback To Full Consult",
|
|
752
|
+
advanced: true
|
|
753
|
+
},
|
|
754
|
+
"realtime.providers": {
|
|
755
|
+
label: "Realtime Provider Config",
|
|
756
|
+
advanced: true
|
|
757
|
+
},
|
|
758
|
+
"tts.provider": {
|
|
759
|
+
label: "TTS Provider Override",
|
|
760
|
+
help: "Deep-merges with messages.tts (Microsoft is ignored for calls).",
|
|
761
|
+
advanced: true
|
|
762
|
+
},
|
|
763
|
+
"tts.providers": {
|
|
764
|
+
label: "TTS Provider Config",
|
|
765
|
+
advanced: true
|
|
766
|
+
},
|
|
767
|
+
publicUrl: {
|
|
768
|
+
label: "Public Webhook URL",
|
|
769
|
+
advanced: true
|
|
770
|
+
},
|
|
771
|
+
skipSignatureVerification: {
|
|
772
|
+
label: "Skip Signature Verification",
|
|
773
|
+
advanced: true
|
|
774
|
+
},
|
|
775
|
+
store: {
|
|
776
|
+
label: "Call Log Store Path",
|
|
777
|
+
advanced: true
|
|
778
|
+
},
|
|
779
|
+
agentId: {
|
|
780
|
+
label: "Response Agent ID",
|
|
781
|
+
help: "Agent workspace used for voice response generation. Defaults to \"main\".",
|
|
782
|
+
advanced: true
|
|
783
|
+
},
|
|
784
|
+
responseModel: {
|
|
785
|
+
label: "Response Model",
|
|
786
|
+
help: "Optional override. Falls back to the runtime default model when unset.",
|
|
787
|
+
advanced: true
|
|
788
|
+
},
|
|
789
|
+
responseSystemPrompt: {
|
|
790
|
+
label: "Response System Prompt",
|
|
791
|
+
advanced: true
|
|
792
|
+
},
|
|
793
|
+
responseTimeoutMs: {
|
|
794
|
+
label: "Response Timeout (ms)",
|
|
795
|
+
advanced: true
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
const VoiceCallToolSchema = Type.Union([
|
|
800
|
+
Type.Object({
|
|
801
|
+
action: Type.Literal("initiate_call"),
|
|
802
|
+
to: Type.Optional(Type.String({ description: "Call target" })),
|
|
803
|
+
message: Type.String({ description: "Intro message" }),
|
|
804
|
+
mode: Type.Optional(Type.Union([Type.Literal("notify"), Type.Literal("conversation")])),
|
|
805
|
+
dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" }))
|
|
806
|
+
}),
|
|
807
|
+
Type.Object({
|
|
808
|
+
action: Type.Literal("continue_call"),
|
|
809
|
+
callId: Type.String({ description: "Call ID" }),
|
|
810
|
+
message: Type.String({ description: "Follow-up message" })
|
|
811
|
+
}),
|
|
812
|
+
Type.Object({
|
|
813
|
+
action: Type.Literal("speak_to_user"),
|
|
814
|
+
callId: Type.String({ description: "Call ID" }),
|
|
815
|
+
message: Type.String({ description: "Message to speak" })
|
|
816
|
+
}),
|
|
817
|
+
Type.Object({
|
|
818
|
+
action: Type.Literal("send_dtmf"),
|
|
819
|
+
callId: Type.String({ description: "Call ID" }),
|
|
820
|
+
digits: Type.String({ description: "DTMF digits to send" })
|
|
821
|
+
}),
|
|
822
|
+
Type.Object({
|
|
823
|
+
action: Type.Literal("end_call"),
|
|
824
|
+
callId: Type.String({ description: "Call ID" })
|
|
825
|
+
}),
|
|
826
|
+
Type.Object({
|
|
827
|
+
action: Type.Literal("get_status"),
|
|
828
|
+
callId: Type.String({ description: "Call ID" })
|
|
829
|
+
}),
|
|
830
|
+
Type.Object({
|
|
831
|
+
mode: Type.Optional(Type.Union([Type.Literal("call"), Type.Literal("status")])),
|
|
832
|
+
to: Type.Optional(Type.String({ description: "Call target" })),
|
|
833
|
+
sid: Type.Optional(Type.String({ description: "Call SID" })),
|
|
834
|
+
message: Type.Optional(Type.String({ description: "Optional intro message" })),
|
|
835
|
+
dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" }))
|
|
836
|
+
})
|
|
837
|
+
]);
|
|
838
|
+
function asParamRecord(params) {
|
|
839
|
+
return params && typeof params === "object" && !Array.isArray(params) ? params : {};
|
|
840
|
+
}
|
|
841
|
+
function isCliOnlyProcess() {
|
|
842
|
+
return process.env.OPENCLAW_CLI === "1" && !process.argv.slice(2).includes("gateway");
|
|
843
|
+
}
|
|
844
|
+
const VOICE_CALL_RUNTIME_KEY = Symbol.for("openclaw.voice-call.runtime");
|
|
845
|
+
const VOICE_CALL_RUNTIME_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimePromise");
|
|
846
|
+
const VOICE_CALL_RUNTIME_STOP_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimeStopPromise");
|
|
847
|
+
function getVoiceCallRuntimeGlobalState() {
|
|
848
|
+
const state = globalThis;
|
|
849
|
+
state[VOICE_CALL_RUNTIME_KEY] ??= null;
|
|
850
|
+
state[VOICE_CALL_RUNTIME_PROMISE_KEY] ??= null;
|
|
851
|
+
state[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] ??= null;
|
|
852
|
+
return state;
|
|
853
|
+
}
|
|
854
|
+
var voice_call_default = definePluginEntry({
|
|
855
|
+
id: "voice-call",
|
|
856
|
+
name: "Voice Call",
|
|
857
|
+
description: "Voice-call plugin with Telnyx/Twilio/Plivo providers",
|
|
858
|
+
configSchema: voiceCallConfigSchema,
|
|
859
|
+
register(api) {
|
|
860
|
+
const config = resolveVoiceCallConfig(voiceCallConfigSchema.parse(api.pluginConfig));
|
|
861
|
+
const validation = validateProviderConfig(config);
|
|
862
|
+
if (api.pluginConfig && typeof api.pluginConfig === "object") for (const warning of formatVoiceCallLegacyConfigWarnings({
|
|
863
|
+
value: api.pluginConfig,
|
|
864
|
+
configPathPrefix: "plugins.entries.voice-call.config",
|
|
865
|
+
doctorFixCommand: "openclaw doctor --fix"
|
|
866
|
+
})) api.logger.warn(warning);
|
|
867
|
+
const runtimeState = getVoiceCallRuntimeGlobalState();
|
|
868
|
+
const continueOperationStore = createVoiceCallContinueOperationStore({
|
|
869
|
+
config,
|
|
870
|
+
coreConfig: api.config
|
|
871
|
+
});
|
|
872
|
+
const ensureRuntime = async () => {
|
|
873
|
+
if (!config.enabled) throw new Error("Voice call disabled in plugin config");
|
|
874
|
+
if (!validation.valid) throw new Error(validation.errors.join("; "));
|
|
875
|
+
while (true) {
|
|
876
|
+
if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) {
|
|
877
|
+
await runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY];
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
const runtime = runtimeState[VOICE_CALL_RUNTIME_KEY];
|
|
881
|
+
if (runtime) return runtime;
|
|
882
|
+
let runtimePromise = runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY];
|
|
883
|
+
if (!runtimePromise) {
|
|
884
|
+
runtimePromise = createVoiceCallRuntime({
|
|
885
|
+
config,
|
|
886
|
+
coreConfig: api.config,
|
|
887
|
+
fullConfig: api.config,
|
|
888
|
+
agentRuntime: api.runtime.agent,
|
|
889
|
+
ttsRuntime: api.runtime.tts,
|
|
890
|
+
logger: api.logger
|
|
891
|
+
});
|
|
892
|
+
runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = runtimePromise;
|
|
893
|
+
}
|
|
894
|
+
try {
|
|
895
|
+
const createdRuntime = await runtimePromise;
|
|
896
|
+
if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) continue;
|
|
897
|
+
if (runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] !== runtimePromise) continue;
|
|
898
|
+
runtimeState[VOICE_CALL_RUNTIME_KEY] = createdRuntime;
|
|
899
|
+
return createdRuntime;
|
|
900
|
+
} catch (err) {
|
|
901
|
+
if (runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] === runtimePromise) {
|
|
902
|
+
runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = null;
|
|
903
|
+
runtimeState[VOICE_CALL_RUNTIME_KEY] = null;
|
|
904
|
+
}
|
|
905
|
+
throw err;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
const respondError = (respond, message, code = ErrorCodes.UNAVAILABLE) => {
|
|
910
|
+
respond(false, void 0, errorShape(code, message));
|
|
911
|
+
};
|
|
912
|
+
const sendError = (respond, err) => {
|
|
913
|
+
respondError(respond, formatErrorMessage(err));
|
|
914
|
+
};
|
|
915
|
+
const describeHistoricalCall = async (rt, callId) => {
|
|
916
|
+
const call = (await rt.manager.getCallHistory(100)).toReversed().find((candidate) => candidate.callId === callId || candidate.providerCallId === callId);
|
|
917
|
+
if (!call) return;
|
|
918
|
+
return `call is not active (${[
|
|
919
|
+
`last state=${call.state}`,
|
|
920
|
+
call.endReason ? `endReason=${call.endReason}` : void 0,
|
|
921
|
+
call.endedAt ? `endedAt=${new Date(call.endedAt).toISOString()}` : void 0
|
|
922
|
+
].filter(Boolean).join(", ")})`;
|
|
923
|
+
};
|
|
924
|
+
const resolveCallMessageRequest = async (params) => {
|
|
925
|
+
const callId = normalizeOptionalString(params?.callId) ?? "";
|
|
926
|
+
const message = normalizeOptionalString(params?.message) ?? "";
|
|
927
|
+
if (!callId || !message) return { error: "callId and message required" };
|
|
928
|
+
const rt = await ensureRuntime();
|
|
929
|
+
const activeCall = rt.manager.getCall(callId) ?? rt.manager.getCallByProviderCallId(callId);
|
|
930
|
+
if (activeCall) return {
|
|
931
|
+
rt,
|
|
932
|
+
callId: activeCall.callId,
|
|
933
|
+
message
|
|
934
|
+
};
|
|
935
|
+
return { error: await describeHistoricalCall(rt, callId) ?? "Call not found" };
|
|
936
|
+
};
|
|
937
|
+
const initiateCallAndRespond = async (params) => {
|
|
938
|
+
const result = await params.rt.manager.initiateCall(params.to, void 0, {
|
|
939
|
+
message: params.message,
|
|
940
|
+
mode: params.mode,
|
|
941
|
+
dtmfSequence: params.dtmfSequence
|
|
942
|
+
});
|
|
943
|
+
if (!result.success) {
|
|
944
|
+
respondError(params.respond, result.error || "initiate failed");
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
params.respond(true, {
|
|
948
|
+
callId: result.callId,
|
|
949
|
+
initiated: true
|
|
950
|
+
});
|
|
951
|
+
};
|
|
952
|
+
const respondToCallMessageAction = async (params) => {
|
|
953
|
+
const request = await resolveCallMessageRequest(params.requestParams);
|
|
954
|
+
if ("error" in request) {
|
|
955
|
+
respondError(params.respond, request.error ?? "callId and message required", ErrorCodes.INVALID_REQUEST);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const result = await params.action(request);
|
|
959
|
+
if (!result.success) {
|
|
960
|
+
respondError(params.respond, result.error || params.failure);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
params.respond(true, params.includeTranscript ? {
|
|
964
|
+
success: true,
|
|
965
|
+
transcript: result.transcript
|
|
966
|
+
} : { success: true });
|
|
967
|
+
};
|
|
968
|
+
api.registerGatewayMethod("voicecall.initiate", async ({ params, respond }) => {
|
|
969
|
+
try {
|
|
970
|
+
const message = normalizeOptionalString(params?.message) ?? "";
|
|
971
|
+
if (!message) {
|
|
972
|
+
respondError(respond, "message required", ErrorCodes.INVALID_REQUEST);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
const rt = await ensureRuntime();
|
|
976
|
+
const to = normalizeOptionalString(params?.to) ?? rt.config.toNumber;
|
|
977
|
+
if (!to) {
|
|
978
|
+
respondError(respond, "to required", ErrorCodes.INVALID_REQUEST);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
await initiateCallAndRespond({
|
|
982
|
+
rt,
|
|
983
|
+
respond,
|
|
984
|
+
to,
|
|
985
|
+
message,
|
|
986
|
+
mode: params?.mode === "notify" || params?.mode === "conversation" ? params.mode : void 0
|
|
987
|
+
});
|
|
988
|
+
} catch (err) {
|
|
989
|
+
sendError(respond, err);
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
api.registerGatewayMethod("voicecall.continue", async ({ params, respond }) => {
|
|
993
|
+
try {
|
|
994
|
+
await respondToCallMessageAction({
|
|
995
|
+
requestParams: params,
|
|
996
|
+
respond,
|
|
997
|
+
action: (request) => request.rt.manager.continueCall(request.callId, request.message),
|
|
998
|
+
failure: "continue failed",
|
|
999
|
+
includeTranscript: true
|
|
1000
|
+
});
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
sendError(respond, err);
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
api.registerGatewayMethod("voicecall.continue.start", async ({ params, respond }) => {
|
|
1006
|
+
try {
|
|
1007
|
+
const request = await resolveCallMessageRequest(params);
|
|
1008
|
+
if ("error" in request) {
|
|
1009
|
+
respondError(respond, request.error ?? "callId and message required", ErrorCodes.INVALID_REQUEST);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
respond(true, continueOperationStore.start(request));
|
|
1013
|
+
} catch (err) {
|
|
1014
|
+
sendError(respond, err);
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
api.registerGatewayMethod("voicecall.continue.result", async ({ params, respond }) => {
|
|
1018
|
+
try {
|
|
1019
|
+
const operationId = normalizeOptionalString(params?.operationId) ?? "";
|
|
1020
|
+
if (!operationId) {
|
|
1021
|
+
respondError(respond, "operationId required", ErrorCodes.INVALID_REQUEST);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const operation = continueOperationStore.read(operationId);
|
|
1025
|
+
if (!operation.ok) {
|
|
1026
|
+
respondError(respond, operation.error, ErrorCodes.INVALID_REQUEST);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
respond(true, operation.payload);
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
sendError(respond, err);
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
api.registerGatewayMethod("voicecall.speak", async ({ params, respond }) => {
|
|
1035
|
+
try {
|
|
1036
|
+
const request = await resolveCallMessageRequest(params);
|
|
1037
|
+
if ("error" in request) {
|
|
1038
|
+
respondError(respond, request.error ?? "callId and message required", ErrorCodes.INVALID_REQUEST);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (request.rt.config.realtime.enabled) {
|
|
1042
|
+
if (request.rt.webhookServer.speakRealtime(request.callId, request.message).success) {
|
|
1043
|
+
respond(true, { success: true });
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
const result = await request.rt.manager.speak(request.callId, request.message);
|
|
1048
|
+
if (!result.success) {
|
|
1049
|
+
respondError(respond, result.error || "speak failed");
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
respond(true, { success: true });
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
sendError(respond, err);
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
api.registerGatewayMethod("voicecall.dtmf", async ({ params, respond }) => {
|
|
1058
|
+
try {
|
|
1059
|
+
const callId = normalizeOptionalString(params?.callId) ?? "";
|
|
1060
|
+
const digits = normalizeOptionalString(params?.digits) ?? "";
|
|
1061
|
+
if (!callId || !digits) {
|
|
1062
|
+
respondError(respond, "callId and digits required", ErrorCodes.INVALID_REQUEST);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const result = await (await ensureRuntime()).manager.sendDtmf(callId, digits);
|
|
1066
|
+
if (!result.success) {
|
|
1067
|
+
respondError(respond, result.error || "dtmf failed");
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
respond(true, { success: true });
|
|
1071
|
+
} catch (err) {
|
|
1072
|
+
sendError(respond, err);
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
api.registerGatewayMethod("voicecall.end", async ({ params, respond }) => {
|
|
1076
|
+
try {
|
|
1077
|
+
const callId = normalizeOptionalString(params?.callId) ?? "";
|
|
1078
|
+
if (!callId) {
|
|
1079
|
+
respondError(respond, "callId required", ErrorCodes.INVALID_REQUEST);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
const result = await (await ensureRuntime()).manager.endCall(callId);
|
|
1083
|
+
if (!result.success) {
|
|
1084
|
+
respondError(respond, result.error || "end failed");
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
respond(true, { success: true });
|
|
1088
|
+
} catch (err) {
|
|
1089
|
+
sendError(respond, err);
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
api.registerGatewayMethod("voicecall.status", async ({ params, respond }) => {
|
|
1093
|
+
try {
|
|
1094
|
+
const raw = normalizeOptionalString(params?.callId) ?? normalizeOptionalString(params?.sid) ?? "";
|
|
1095
|
+
const rt = await ensureRuntime();
|
|
1096
|
+
if (!raw) {
|
|
1097
|
+
respond(true, {
|
|
1098
|
+
found: true,
|
|
1099
|
+
calls: rt.manager.getActiveCalls()
|
|
1100
|
+
});
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
const call = rt.manager.getCall(raw) || rt.manager.getCallByProviderCallId(raw);
|
|
1104
|
+
if (!call) {
|
|
1105
|
+
respond(true, { found: false });
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
respond(true, {
|
|
1109
|
+
found: true,
|
|
1110
|
+
call
|
|
1111
|
+
});
|
|
1112
|
+
} catch (err) {
|
|
1113
|
+
sendError(respond, err);
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
api.registerGatewayMethod("voicecall.start", async ({ params, respond }) => {
|
|
1117
|
+
try {
|
|
1118
|
+
const to = normalizeOptionalString(params?.to) ?? "";
|
|
1119
|
+
const message = normalizeOptionalString(params?.message) ?? "";
|
|
1120
|
+
const dtmfSequence = normalizeOptionalString(params?.dtmfSequence);
|
|
1121
|
+
if (!to) {
|
|
1122
|
+
respondError(respond, "to required", ErrorCodes.INVALID_REQUEST);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
const rt = await ensureRuntime();
|
|
1126
|
+
const mode = params?.mode === "notify" || params?.mode === "conversation" ? params.mode : void 0;
|
|
1127
|
+
await initiateCallAndRespond({
|
|
1128
|
+
rt,
|
|
1129
|
+
respond,
|
|
1130
|
+
to,
|
|
1131
|
+
message: message || void 0,
|
|
1132
|
+
mode,
|
|
1133
|
+
dtmfSequence
|
|
1134
|
+
});
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
sendError(respond, err);
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
api.registerTool({
|
|
1140
|
+
name: "voice_call",
|
|
1141
|
+
label: "Voice Call",
|
|
1142
|
+
description: "Make phone calls and have voice conversations via the voice-call plugin.",
|
|
1143
|
+
parameters: VoiceCallToolSchema,
|
|
1144
|
+
async execute(_toolCallId, params) {
|
|
1145
|
+
const rawParams = asParamRecord(params);
|
|
1146
|
+
const json = (payload) => ({
|
|
1147
|
+
content: [{
|
|
1148
|
+
type: "text",
|
|
1149
|
+
text: JSON.stringify(payload, null, 2)
|
|
1150
|
+
}],
|
|
1151
|
+
details: payload
|
|
1152
|
+
});
|
|
1153
|
+
try {
|
|
1154
|
+
const rt = await ensureRuntime();
|
|
1155
|
+
if (typeof rawParams.action === "string") switch (rawParams.action) {
|
|
1156
|
+
case "initiate_call": {
|
|
1157
|
+
const message = normalizeOptionalString(rawParams.message) ?? "";
|
|
1158
|
+
if (!message) throw new Error("message required");
|
|
1159
|
+
const to = normalizeOptionalString(rawParams.to) ?? rt.config.toNumber;
|
|
1160
|
+
if (!to) throw new Error("to required");
|
|
1161
|
+
const result = await rt.manager.initiateCall(to, void 0, {
|
|
1162
|
+
message,
|
|
1163
|
+
dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
|
|
1164
|
+
mode: rawParams.mode === "notify" || rawParams.mode === "conversation" ? rawParams.mode : void 0
|
|
1165
|
+
});
|
|
1166
|
+
if (!result.success) throw new Error(result.error || "initiate failed");
|
|
1167
|
+
return json({
|
|
1168
|
+
callId: result.callId,
|
|
1169
|
+
initiated: true
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
case "continue_call": {
|
|
1173
|
+
const callId = normalizeOptionalString(rawParams.callId) ?? "";
|
|
1174
|
+
const message = normalizeOptionalString(rawParams.message) ?? "";
|
|
1175
|
+
if (!callId || !message) throw new Error("callId and message required");
|
|
1176
|
+
const result = await rt.manager.continueCall(callId, message);
|
|
1177
|
+
if (!result.success) throw new Error(result.error || "continue failed");
|
|
1178
|
+
return json({
|
|
1179
|
+
success: true,
|
|
1180
|
+
transcript: result.transcript
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
case "speak_to_user": {
|
|
1184
|
+
const callId = normalizeOptionalString(rawParams.callId) ?? "";
|
|
1185
|
+
const message = normalizeOptionalString(rawParams.message) ?? "";
|
|
1186
|
+
if (!callId || !message) throw new Error("callId and message required");
|
|
1187
|
+
const result = await rt.manager.speak(callId, message);
|
|
1188
|
+
if (!result.success) throw new Error(result.error || "speak failed");
|
|
1189
|
+
return json({ success: true });
|
|
1190
|
+
}
|
|
1191
|
+
case "send_dtmf": {
|
|
1192
|
+
const callId = normalizeOptionalString(rawParams.callId) ?? "";
|
|
1193
|
+
const digits = normalizeOptionalString(rawParams.digits) ?? "";
|
|
1194
|
+
if (!callId || !digits) throw new Error("callId and digits required");
|
|
1195
|
+
const result = await rt.manager.sendDtmf(callId, digits);
|
|
1196
|
+
if (!result.success) throw new Error(result.error || "dtmf failed");
|
|
1197
|
+
return json({ success: true });
|
|
1198
|
+
}
|
|
1199
|
+
case "end_call": {
|
|
1200
|
+
const callId = normalizeOptionalString(rawParams.callId) ?? "";
|
|
1201
|
+
if (!callId) throw new Error("callId required");
|
|
1202
|
+
const result = await rt.manager.endCall(callId);
|
|
1203
|
+
if (!result.success) throw new Error(result.error || "end failed");
|
|
1204
|
+
return json({ success: true });
|
|
1205
|
+
}
|
|
1206
|
+
case "get_status": {
|
|
1207
|
+
const callId = normalizeOptionalString(rawParams.callId) ?? "";
|
|
1208
|
+
if (!callId) throw new Error("callId required");
|
|
1209
|
+
const call = rt.manager.getCall(callId) || rt.manager.getCallByProviderCallId(callId);
|
|
1210
|
+
return json(call ? {
|
|
1211
|
+
found: true,
|
|
1212
|
+
call
|
|
1213
|
+
} : { found: false });
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
if ((rawParams.mode ?? "call") === "status") {
|
|
1217
|
+
const sid = normalizeOptionalString(rawParams.sid) ?? "";
|
|
1218
|
+
if (!sid) throw new Error("sid required for status");
|
|
1219
|
+
const call = rt.manager.getCall(sid) || rt.manager.getCallByProviderCallId(sid);
|
|
1220
|
+
return json(call ? {
|
|
1221
|
+
found: true,
|
|
1222
|
+
call
|
|
1223
|
+
} : { found: false });
|
|
1224
|
+
}
|
|
1225
|
+
const to = normalizeOptionalString(rawParams.to) ?? rt.config.toNumber;
|
|
1226
|
+
if (!to) throw new Error("to required for call");
|
|
1227
|
+
const result = await rt.manager.initiateCall(to, void 0, {
|
|
1228
|
+
dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
|
|
1229
|
+
message: normalizeOptionalString(rawParams.message)
|
|
1230
|
+
});
|
|
1231
|
+
if (!result.success) throw new Error(result.error || "initiate failed");
|
|
1232
|
+
return json({
|
|
1233
|
+
callId: result.callId,
|
|
1234
|
+
initiated: true
|
|
1235
|
+
});
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
return json({ error: formatErrorMessage(err) });
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
api.registerCli(({ program }) => registerVoiceCallCli({
|
|
1242
|
+
program,
|
|
1243
|
+
config,
|
|
1244
|
+
ensureRuntime,
|
|
1245
|
+
logger: api.logger
|
|
1246
|
+
}), { commands: ["voicecall"] });
|
|
1247
|
+
api.registerService({
|
|
1248
|
+
id: "voicecall",
|
|
1249
|
+
start: () => {
|
|
1250
|
+
if (isCliOnlyProcess()) return;
|
|
1251
|
+
if (!config.enabled) return;
|
|
1252
|
+
if (!validation.valid) {
|
|
1253
|
+
api.logger.warn(`[voice-call] Runtime not started; setup incomplete: ${validation.errors.join("; ")}`);
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
ensureRuntime().catch((err) => {
|
|
1257
|
+
api.logger.error(`[voice-call] Failed to start runtime: ${formatErrorMessage(err)}`);
|
|
1258
|
+
});
|
|
1259
|
+
},
|
|
1260
|
+
stop: async () => {
|
|
1261
|
+
if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) {
|
|
1262
|
+
await runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY];
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
const runtime = runtimeState[VOICE_CALL_RUNTIME_KEY];
|
|
1266
|
+
const runtimePromise = runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY];
|
|
1267
|
+
if (!runtime && !runtimePromise) return;
|
|
1268
|
+
runtimeState[VOICE_CALL_RUNTIME_KEY] = null;
|
|
1269
|
+
runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = null;
|
|
1270
|
+
const stopPromise = (async () => {
|
|
1271
|
+
await (runtime ?? await runtimePromise).stop();
|
|
1272
|
+
})();
|
|
1273
|
+
runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] = stopPromise;
|
|
1274
|
+
try {
|
|
1275
|
+
await stopPromise;
|
|
1276
|
+
} finally {
|
|
1277
|
+
if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] === stopPromise) runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] = null;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
//#endregion
|
|
1284
|
+
export { voice_call_default as default };
|