@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/src/runtime.ts
DELETED
|
@@ -1,510 +0,0 @@
|
|
|
1
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
|
2
|
-
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
3
|
-
import {
|
|
4
|
-
consultRealtimeVoiceAgent,
|
|
5
|
-
REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME,
|
|
6
|
-
resolveRealtimeVoiceAgentConsultTools,
|
|
7
|
-
resolveRealtimeVoiceAgentConsultToolsAllow,
|
|
8
|
-
type RealtimeVoiceAgentConsultTranscriptEntry,
|
|
9
|
-
type ResolvedRealtimeVoiceProvider,
|
|
10
|
-
} from "openclaw/plugin-sdk/realtime-voice";
|
|
11
|
-
import type { VoiceCallConfig } from "./config.js";
|
|
12
|
-
import {
|
|
13
|
-
resolveVoiceCallEffectiveConfig,
|
|
14
|
-
resolveVoiceCallSessionKey,
|
|
15
|
-
resolveTwilioAuthToken,
|
|
16
|
-
resolveVoiceCallConfig,
|
|
17
|
-
validateProviderConfig,
|
|
18
|
-
} from "./config.js";
|
|
19
|
-
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
|
|
20
|
-
import { CallManager } from "./manager.js";
|
|
21
|
-
import type { VoiceCallProvider } from "./providers/base.js";
|
|
22
|
-
import type { TwilioProvider } from "./providers/twilio.js";
|
|
23
|
-
import { resolveRealtimeFastContextConsult } from "./realtime-fast-context.js";
|
|
24
|
-
import { resolveVoiceResponseModel } from "./response-model.js";
|
|
25
|
-
import type { TelephonyTtsRuntime } from "./telephony-tts.js";
|
|
26
|
-
import { createTelephonyTtsProvider } from "./telephony-tts.js";
|
|
27
|
-
import { startTunnel, type TunnelResult } from "./tunnel.js";
|
|
28
|
-
import {
|
|
29
|
-
isProviderUnreachableWebhookUrl,
|
|
30
|
-
providerRequiresPublicWebhook,
|
|
31
|
-
} from "./webhook-exposure.js";
|
|
32
|
-
import { VoiceCallWebhookServer } from "./webhook.js";
|
|
33
|
-
import type { ToolHandlerContext } from "./webhook/realtime-handler.js";
|
|
34
|
-
import { cleanupTailscaleExposure, setupTailscaleExposure } from "./webhook/tailscale.js";
|
|
35
|
-
|
|
36
|
-
export type VoiceCallRuntime = {
|
|
37
|
-
config: VoiceCallConfig;
|
|
38
|
-
provider: VoiceCallProvider;
|
|
39
|
-
manager: CallManager;
|
|
40
|
-
webhookServer: VoiceCallWebhookServer;
|
|
41
|
-
webhookUrl: string;
|
|
42
|
-
publicUrl: string | null;
|
|
43
|
-
stop: () => Promise<void>;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
type Logger = {
|
|
47
|
-
info: (message: string) => void;
|
|
48
|
-
warn: (message: string) => void;
|
|
49
|
-
error: (message: string) => void;
|
|
50
|
-
debug?: (message: string) => void;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
type ResolvedRealtimeProvider = ResolvedRealtimeVoiceProvider;
|
|
54
|
-
|
|
55
|
-
type TelnyxProviderModule = typeof import("./providers/telnyx.js");
|
|
56
|
-
type TwilioProviderModule = typeof import("./providers/twilio.js");
|
|
57
|
-
type PlivoProviderModule = typeof import("./providers/plivo.js");
|
|
58
|
-
type MockProviderModule = typeof import("./providers/mock.js");
|
|
59
|
-
type RealtimeVoiceRuntimeModule = typeof import("./realtime-voice.runtime.js");
|
|
60
|
-
type RealtimeHandlerModule = typeof import("./webhook/realtime-handler.js");
|
|
61
|
-
|
|
62
|
-
const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
|
|
63
|
-
"You are a behind-the-scenes consultant for a live phone voice agent.",
|
|
64
|
-
"Prioritize a fast, speakable answer over exhaustive investigation.",
|
|
65
|
-
"For tool-backed status checks, prefer one or two bounded read-only queries before answering.",
|
|
66
|
-
"Do not print secret values or dump environment variables; only check whether required configuration is present.",
|
|
67
|
-
"Be accurate, brief, and speakable.",
|
|
68
|
-
].join(" ");
|
|
69
|
-
|
|
70
|
-
let telnyxProviderPromise: Promise<TelnyxProviderModule> | undefined;
|
|
71
|
-
let twilioProviderPromise: Promise<TwilioProviderModule> | undefined;
|
|
72
|
-
let plivoProviderPromise: Promise<PlivoProviderModule> | undefined;
|
|
73
|
-
let mockProviderPromise: Promise<MockProviderModule> | undefined;
|
|
74
|
-
let realtimeVoiceRuntimePromise: Promise<RealtimeVoiceRuntimeModule> | undefined;
|
|
75
|
-
let realtimeHandlerPromise: Promise<RealtimeHandlerModule> | undefined;
|
|
76
|
-
|
|
77
|
-
function loadTelnyxProvider(): Promise<TelnyxProviderModule> {
|
|
78
|
-
telnyxProviderPromise ??= import("./providers/telnyx.js");
|
|
79
|
-
return telnyxProviderPromise;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function loadTwilioProvider(): Promise<TwilioProviderModule> {
|
|
83
|
-
twilioProviderPromise ??= import("./providers/twilio.js");
|
|
84
|
-
return twilioProviderPromise;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function loadPlivoProvider(): Promise<PlivoProviderModule> {
|
|
88
|
-
plivoProviderPromise ??= import("./providers/plivo.js");
|
|
89
|
-
return plivoProviderPromise;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function loadMockProvider(): Promise<MockProviderModule> {
|
|
93
|
-
mockProviderPromise ??= import("./providers/mock.js");
|
|
94
|
-
return mockProviderPromise;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function loadRealtimeVoiceRuntime(): Promise<RealtimeVoiceRuntimeModule> {
|
|
98
|
-
realtimeVoiceRuntimePromise ??= import("./realtime-voice.runtime.js");
|
|
99
|
-
return realtimeVoiceRuntimePromise;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function loadRealtimeHandler(): Promise<RealtimeHandlerModule> {
|
|
103
|
-
realtimeHandlerPromise ??= import("./webhook/realtime-handler.js");
|
|
104
|
-
return realtimeHandlerPromise;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function resolveVoiceCallConsultSessionKey(call: {
|
|
108
|
-
config: VoiceCallConfig;
|
|
109
|
-
sessionKey?: string;
|
|
110
|
-
from?: string;
|
|
111
|
-
to?: string;
|
|
112
|
-
direction?: "inbound" | "outbound";
|
|
113
|
-
callId: string;
|
|
114
|
-
}): string {
|
|
115
|
-
if (call.sessionKey) {
|
|
116
|
-
return call.sessionKey;
|
|
117
|
-
}
|
|
118
|
-
const phone = call.direction === "outbound" ? call.to : call.from;
|
|
119
|
-
return resolveVoiceCallSessionKey({
|
|
120
|
-
config: call.config,
|
|
121
|
-
callId: call.callId,
|
|
122
|
-
phone,
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function mapVoiceCallConsultTranscript(
|
|
127
|
-
call: {
|
|
128
|
-
transcript?: Array<{ speaker: "user" | "bot"; text: string }>;
|
|
129
|
-
},
|
|
130
|
-
context?: ToolHandlerContext,
|
|
131
|
-
): RealtimeVoiceAgentConsultTranscriptEntry[] {
|
|
132
|
-
const transcript: RealtimeVoiceAgentConsultTranscriptEntry[] = (call.transcript ?? []).map(
|
|
133
|
-
(entry) => ({
|
|
134
|
-
role: entry.speaker === "bot" ? "assistant" : "user",
|
|
135
|
-
text: entry.text,
|
|
136
|
-
}),
|
|
137
|
-
);
|
|
138
|
-
const partial = context?.partialUserTranscript?.trim();
|
|
139
|
-
if (partial && transcript.at(-1)?.text !== partial) {
|
|
140
|
-
transcript.push({ role: "user", text: partial });
|
|
141
|
-
}
|
|
142
|
-
return transcript;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function createRuntimeResourceLifecycle(params: {
|
|
146
|
-
config: VoiceCallConfig;
|
|
147
|
-
webhookServer: VoiceCallWebhookServer;
|
|
148
|
-
}): {
|
|
149
|
-
setTunnelResult: (result: TunnelResult | null) => void;
|
|
150
|
-
stop: (opts?: { suppressErrors?: boolean }) => Promise<void>;
|
|
151
|
-
} {
|
|
152
|
-
let tunnelResult: TunnelResult | null = null;
|
|
153
|
-
let stopped = false;
|
|
154
|
-
|
|
155
|
-
const runStep = async (step: () => Promise<void>, suppressErrors: boolean) => {
|
|
156
|
-
if (suppressErrors) {
|
|
157
|
-
await step().catch(() => {});
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
await step();
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
return {
|
|
164
|
-
setTunnelResult: (result) => {
|
|
165
|
-
tunnelResult = result;
|
|
166
|
-
},
|
|
167
|
-
stop: async (opts) => {
|
|
168
|
-
if (stopped) {
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
stopped = true;
|
|
172
|
-
const suppressErrors = opts?.suppressErrors ?? false;
|
|
173
|
-
await runStep(async () => {
|
|
174
|
-
if (tunnelResult) {
|
|
175
|
-
await tunnelResult.stop();
|
|
176
|
-
}
|
|
177
|
-
}, suppressErrors);
|
|
178
|
-
await runStep(async () => {
|
|
179
|
-
await cleanupTailscaleExposure(params.config);
|
|
180
|
-
}, suppressErrors);
|
|
181
|
-
await runStep(async () => {
|
|
182
|
-
await params.webhookServer.stop();
|
|
183
|
-
}, suppressErrors);
|
|
184
|
-
},
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function isLoopbackBind(bind: string | undefined): boolean {
|
|
189
|
-
if (!bind) {
|
|
190
|
-
return false;
|
|
191
|
-
}
|
|
192
|
-
return bind === "127.0.0.1" || bind === "::1" || bind === "localhost";
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
async function resolveProvider(config: VoiceCallConfig): Promise<VoiceCallProvider> {
|
|
196
|
-
const allowNgrokFreeTierLoopbackBypass =
|
|
197
|
-
config.tunnel?.provider === "ngrok" &&
|
|
198
|
-
isLoopbackBind(config.serve?.bind) &&
|
|
199
|
-
(config.tunnel?.allowNgrokFreeTierLoopbackBypass ?? false);
|
|
200
|
-
|
|
201
|
-
switch (config.provider) {
|
|
202
|
-
case "telnyx": {
|
|
203
|
-
const { TelnyxProvider } = await loadTelnyxProvider();
|
|
204
|
-
return new TelnyxProvider(
|
|
205
|
-
{
|
|
206
|
-
apiKey: config.telnyx?.apiKey,
|
|
207
|
-
connectionId: config.telnyx?.connectionId,
|
|
208
|
-
publicKey: config.telnyx?.publicKey,
|
|
209
|
-
},
|
|
210
|
-
{
|
|
211
|
-
skipVerification: config.skipSignatureVerification,
|
|
212
|
-
},
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
case "twilio": {
|
|
216
|
-
const { TwilioProvider } = await loadTwilioProvider();
|
|
217
|
-
return new TwilioProvider(
|
|
218
|
-
{
|
|
219
|
-
accountSid: config.twilio?.accountSid,
|
|
220
|
-
authToken: resolveTwilioAuthToken(config),
|
|
221
|
-
},
|
|
222
|
-
{
|
|
223
|
-
allowNgrokFreeTierLoopbackBypass,
|
|
224
|
-
publicUrl: config.publicUrl,
|
|
225
|
-
skipVerification: config.skipSignatureVerification,
|
|
226
|
-
streamPath: config.streaming?.enabled ? config.streaming.streamPath : undefined,
|
|
227
|
-
webhookSecurity: config.webhookSecurity,
|
|
228
|
-
},
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
case "plivo": {
|
|
232
|
-
const { PlivoProvider } = await loadPlivoProvider();
|
|
233
|
-
return new PlivoProvider(
|
|
234
|
-
{
|
|
235
|
-
authId: config.plivo?.authId,
|
|
236
|
-
authToken: config.plivo?.authToken,
|
|
237
|
-
},
|
|
238
|
-
{
|
|
239
|
-
publicUrl: config.publicUrl,
|
|
240
|
-
skipVerification: config.skipSignatureVerification,
|
|
241
|
-
ringTimeoutSec: Math.max(1, Math.floor(config.ringTimeoutMs / 1000)),
|
|
242
|
-
webhookSecurity: config.webhookSecurity,
|
|
243
|
-
},
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
case "mock": {
|
|
247
|
-
const { MockProvider } = await loadMockProvider();
|
|
248
|
-
return new MockProvider();
|
|
249
|
-
}
|
|
250
|
-
default:
|
|
251
|
-
throw new Error(`Unsupported voice-call provider: ${String(config.provider)}`);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
async function resolveRealtimeProvider(params: {
|
|
256
|
-
config: VoiceCallConfig;
|
|
257
|
-
fullConfig: OpenClawConfig;
|
|
258
|
-
}): Promise<ResolvedRealtimeProvider> {
|
|
259
|
-
const { resolveConfiguredRealtimeVoiceProvider } = await loadRealtimeVoiceRuntime();
|
|
260
|
-
return resolveConfiguredRealtimeVoiceProvider({
|
|
261
|
-
configuredProviderId: params.config.realtime.provider,
|
|
262
|
-
providerConfigs: params.config.realtime.providers,
|
|
263
|
-
cfg: params.fullConfig,
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
export async function createVoiceCallRuntime(params: {
|
|
268
|
-
config: VoiceCallConfig;
|
|
269
|
-
coreConfig: CoreConfig;
|
|
270
|
-
fullConfig?: OpenClawConfig;
|
|
271
|
-
agentRuntime: CoreAgentDeps;
|
|
272
|
-
ttsRuntime?: TelephonyTtsRuntime;
|
|
273
|
-
logger?: Logger;
|
|
274
|
-
}): Promise<VoiceCallRuntime> {
|
|
275
|
-
const { config: rawConfig, coreConfig, fullConfig, agentRuntime, ttsRuntime, logger } = params;
|
|
276
|
-
const log = logger ?? {
|
|
277
|
-
info: console.log,
|
|
278
|
-
warn: console.warn,
|
|
279
|
-
error: console.error,
|
|
280
|
-
debug: console.debug,
|
|
281
|
-
};
|
|
282
|
-
|
|
283
|
-
const config = resolveVoiceCallConfig(rawConfig);
|
|
284
|
-
const cfg = fullConfig ?? (coreConfig as OpenClawConfig);
|
|
285
|
-
|
|
286
|
-
if (!config.enabled) {
|
|
287
|
-
throw new Error("Voice call disabled. Enable the plugin entry in config.");
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
if (config.skipSignatureVerification) {
|
|
291
|
-
log.warn(
|
|
292
|
-
"[voice-call] SECURITY WARNING: skipSignatureVerification=true disables webhook signature verification (development only). Do not use in production.",
|
|
293
|
-
);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const validation = validateProviderConfig(config);
|
|
297
|
-
if (!validation.valid) {
|
|
298
|
-
throw new Error(`Invalid voice-call config: ${validation.errors.join("; ")}`);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const provider = await resolveProvider(config);
|
|
302
|
-
const manager = new CallManager(config);
|
|
303
|
-
const realtimeProvider = config.realtime.enabled
|
|
304
|
-
? await resolveRealtimeProvider({
|
|
305
|
-
config,
|
|
306
|
-
fullConfig: cfg,
|
|
307
|
-
})
|
|
308
|
-
: null;
|
|
309
|
-
const webhookServer = new VoiceCallWebhookServer(
|
|
310
|
-
config,
|
|
311
|
-
manager,
|
|
312
|
-
provider,
|
|
313
|
-
coreConfig,
|
|
314
|
-
fullConfig ?? (coreConfig as OpenClawConfig),
|
|
315
|
-
agentRuntime,
|
|
316
|
-
log,
|
|
317
|
-
);
|
|
318
|
-
if (realtimeProvider) {
|
|
319
|
-
const { RealtimeCallHandler } = await loadRealtimeHandler();
|
|
320
|
-
const realtimeConfig = {
|
|
321
|
-
...config.realtime,
|
|
322
|
-
tools: resolveRealtimeVoiceAgentConsultTools(
|
|
323
|
-
config.realtime.toolPolicy,
|
|
324
|
-
config.realtime.tools,
|
|
325
|
-
),
|
|
326
|
-
};
|
|
327
|
-
const realtimeHandler = new RealtimeCallHandler(
|
|
328
|
-
realtimeConfig,
|
|
329
|
-
manager,
|
|
330
|
-
provider,
|
|
331
|
-
realtimeProvider.provider,
|
|
332
|
-
realtimeProvider.providerConfig,
|
|
333
|
-
config.serve.path,
|
|
334
|
-
);
|
|
335
|
-
if (config.realtime.toolPolicy !== "none") {
|
|
336
|
-
realtimeHandler.registerToolHandler(
|
|
337
|
-
REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME,
|
|
338
|
-
async (args, callId, handlerContext) => {
|
|
339
|
-
const call = manager.getCall(callId);
|
|
340
|
-
if (!call) {
|
|
341
|
-
return { error: `Call "${callId}" not found` };
|
|
342
|
-
}
|
|
343
|
-
const numberRouteKey =
|
|
344
|
-
typeof call.metadata?.numberRouteKey === "string"
|
|
345
|
-
? call.metadata.numberRouteKey
|
|
346
|
-
: call.to;
|
|
347
|
-
const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
|
|
348
|
-
const agentId = effectiveConfig.agentId ?? "main";
|
|
349
|
-
const sessionKey = resolveVoiceCallConsultSessionKey({
|
|
350
|
-
...call,
|
|
351
|
-
config: effectiveConfig,
|
|
352
|
-
});
|
|
353
|
-
const fastContext = await resolveRealtimeFastContextConsult({
|
|
354
|
-
cfg,
|
|
355
|
-
agentId,
|
|
356
|
-
sessionKey,
|
|
357
|
-
config: effectiveConfig.realtime.fastContext,
|
|
358
|
-
args,
|
|
359
|
-
logger: log,
|
|
360
|
-
});
|
|
361
|
-
if (fastContext.handled) {
|
|
362
|
-
return fastContext.result;
|
|
363
|
-
}
|
|
364
|
-
const { provider: agentProvider, model } = resolveVoiceResponseModel({
|
|
365
|
-
voiceConfig: effectiveConfig,
|
|
366
|
-
agentRuntime,
|
|
367
|
-
});
|
|
368
|
-
const thinkLevel = agentRuntime.resolveThinkingDefault({
|
|
369
|
-
cfg,
|
|
370
|
-
provider: agentProvider,
|
|
371
|
-
model,
|
|
372
|
-
});
|
|
373
|
-
return await consultRealtimeVoiceAgent({
|
|
374
|
-
cfg,
|
|
375
|
-
agentRuntime,
|
|
376
|
-
logger: log,
|
|
377
|
-
agentId,
|
|
378
|
-
sessionKey,
|
|
379
|
-
messageProvider: "voice",
|
|
380
|
-
lane: "voice",
|
|
381
|
-
runIdPrefix: `voice-realtime-consult:${callId}`,
|
|
382
|
-
args,
|
|
383
|
-
transcript: mapVoiceCallConsultTranscript(call, handlerContext),
|
|
384
|
-
surface: "a live phone call",
|
|
385
|
-
userLabel: "Caller",
|
|
386
|
-
assistantLabel: "Agent",
|
|
387
|
-
questionSourceLabel: "caller",
|
|
388
|
-
provider: agentProvider,
|
|
389
|
-
model,
|
|
390
|
-
thinkLevel,
|
|
391
|
-
timeoutMs: effectiveConfig.responseTimeoutMs,
|
|
392
|
-
toolsAllow: resolveRealtimeVoiceAgentConsultToolsAllow(
|
|
393
|
-
effectiveConfig.realtime.toolPolicy,
|
|
394
|
-
),
|
|
395
|
-
extraSystemPrompt: REALTIME_VOICE_CONSULT_SYSTEM_PROMPT,
|
|
396
|
-
});
|
|
397
|
-
},
|
|
398
|
-
);
|
|
399
|
-
}
|
|
400
|
-
webhookServer.setRealtimeHandler(realtimeHandler);
|
|
401
|
-
}
|
|
402
|
-
const lifecycle = createRuntimeResourceLifecycle({ config, webhookServer });
|
|
403
|
-
|
|
404
|
-
const localUrl = await webhookServer.start();
|
|
405
|
-
|
|
406
|
-
// Wrap remaining initialization in try/catch so the webhook server is
|
|
407
|
-
// properly stopped if any subsequent step fails. Without this, the server
|
|
408
|
-
// keeps the port bound while the runtime promise rejects, causing
|
|
409
|
-
// EADDRINUSE on the next attempt. See: #32387
|
|
410
|
-
try {
|
|
411
|
-
// Determine public URL - priority: config.publicUrl > tunnel > legacy tailscale
|
|
412
|
-
let publicUrl: string | null = config.publicUrl ?? null;
|
|
413
|
-
|
|
414
|
-
if (!publicUrl && config.tunnel?.provider && config.tunnel.provider !== "none") {
|
|
415
|
-
try {
|
|
416
|
-
const nextTunnelResult = await startTunnel({
|
|
417
|
-
provider: config.tunnel.provider,
|
|
418
|
-
port: config.serve.port,
|
|
419
|
-
path: config.serve.path,
|
|
420
|
-
ngrokAuthToken: config.tunnel.ngrokAuthToken,
|
|
421
|
-
ngrokDomain: config.tunnel.ngrokDomain,
|
|
422
|
-
});
|
|
423
|
-
lifecycle.setTunnelResult(nextTunnelResult);
|
|
424
|
-
publicUrl = nextTunnelResult?.publicUrl ?? null;
|
|
425
|
-
} catch (err) {
|
|
426
|
-
log.error(`[voice-call] Tunnel setup failed: ${formatErrorMessage(err)}`);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
if (!publicUrl && config.tailscale?.mode !== "off") {
|
|
431
|
-
publicUrl = await setupTailscaleExposure(config);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
const webhookUrl = publicUrl ?? localUrl;
|
|
435
|
-
|
|
436
|
-
if (
|
|
437
|
-
providerRequiresPublicWebhook(provider.name) &&
|
|
438
|
-
isProviderUnreachableWebhookUrl(webhookUrl)
|
|
439
|
-
) {
|
|
440
|
-
throw new Error(
|
|
441
|
-
`[voice-call] ${provider.name} requires a publicly reachable webhook URL. ` +
|
|
442
|
-
`Refusing to use local-only webhook ${webhookUrl}. ` +
|
|
443
|
-
"Set plugins.entries.voice-call.config.publicUrl or enable tunnel/tailscale exposure.",
|
|
444
|
-
);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
if (publicUrl && provider.name === "twilio") {
|
|
448
|
-
(provider as TwilioProvider).setPublicUrl(publicUrl);
|
|
449
|
-
}
|
|
450
|
-
if (publicUrl && realtimeProvider) {
|
|
451
|
-
webhookServer.getRealtimeHandler()?.setPublicUrl(publicUrl);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
if (provider.name === "twilio" && config.streaming?.enabled) {
|
|
455
|
-
const twilioProvider = provider as TwilioProvider;
|
|
456
|
-
if (ttsRuntime?.textToSpeechTelephony) {
|
|
457
|
-
try {
|
|
458
|
-
const ttsProvider = createTelephonyTtsProvider({
|
|
459
|
-
coreConfig,
|
|
460
|
-
ttsOverride: config.tts,
|
|
461
|
-
runtime: ttsRuntime,
|
|
462
|
-
logger: log,
|
|
463
|
-
});
|
|
464
|
-
twilioProvider.setTTSProvider(ttsProvider);
|
|
465
|
-
log.info("[voice-call] Telephony TTS provider configured");
|
|
466
|
-
} catch (err) {
|
|
467
|
-
log.warn(`[voice-call] Failed to initialize telephony TTS: ${formatErrorMessage(err)}`);
|
|
468
|
-
}
|
|
469
|
-
} else {
|
|
470
|
-
log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled");
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
const mediaHandler = webhookServer.getMediaStreamHandler();
|
|
474
|
-
if (mediaHandler) {
|
|
475
|
-
twilioProvider.setMediaStreamHandler(mediaHandler);
|
|
476
|
-
log.info("[voice-call] Media stream handler wired to provider");
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
if (realtimeProvider) {
|
|
481
|
-
log.info(`[voice-call] Realtime voice provider: ${realtimeProvider.provider.id}`);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
await manager.initialize(provider, webhookUrl);
|
|
485
|
-
|
|
486
|
-
const stop = async () => await lifecycle.stop();
|
|
487
|
-
|
|
488
|
-
log.info("[voice-call] Runtime initialized");
|
|
489
|
-
log.info(`[voice-call] Webhook URL: ${webhookUrl}`);
|
|
490
|
-
if (publicUrl && publicUrl !== webhookUrl) {
|
|
491
|
-
log.info(`[voice-call] Public URL: ${publicUrl}`);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
return {
|
|
495
|
-
config,
|
|
496
|
-
provider,
|
|
497
|
-
manager,
|
|
498
|
-
webhookServer,
|
|
499
|
-
webhookUrl,
|
|
500
|
-
publicUrl,
|
|
501
|
-
stop,
|
|
502
|
-
};
|
|
503
|
-
} catch (err) {
|
|
504
|
-
// If any step after the server started fails, clean up every provisioned
|
|
505
|
-
// resource (tunnel, tailscale exposure, and webhook server) so retries
|
|
506
|
-
// don't leak processes or keep the port bound.
|
|
507
|
-
await lifecycle.stop({ suppressErrors: true });
|
|
508
|
-
throw err;
|
|
509
|
-
}
|
|
510
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { convertPcmToMulaw8k, resamplePcmTo8k } from "./telephony-audio.js";
|
|
3
|
-
|
|
4
|
-
function makeSinePcm(
|
|
5
|
-
sampleRate: number,
|
|
6
|
-
frequencyHz: number,
|
|
7
|
-
durationSeconds: number,
|
|
8
|
-
amplitude = 12_000,
|
|
9
|
-
): Buffer {
|
|
10
|
-
const samples = Math.floor(sampleRate * durationSeconds);
|
|
11
|
-
const output = Buffer.alloc(samples * 2);
|
|
12
|
-
for (let i = 0; i < samples; i++) {
|
|
13
|
-
const value = Math.round(Math.sin((2 * Math.PI * frequencyHz * i) / sampleRate) * amplitude);
|
|
14
|
-
output.writeInt16LE(value, i * 2);
|
|
15
|
-
}
|
|
16
|
-
return output;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function rmsPcm(buffer: Buffer): number {
|
|
20
|
-
const samples = Math.floor(buffer.length / 2);
|
|
21
|
-
if (samples === 0) {
|
|
22
|
-
return 0;
|
|
23
|
-
}
|
|
24
|
-
let sum = 0;
|
|
25
|
-
for (let i = 0; i < samples; i++) {
|
|
26
|
-
const sample = buffer.readInt16LE(i * 2);
|
|
27
|
-
sum += sample * sample;
|
|
28
|
-
}
|
|
29
|
-
return Math.sqrt(sum / samples);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe("telephony-audio resamplePcmTo8k", () => {
|
|
33
|
-
it("returns identical buffer for 8k input", () => {
|
|
34
|
-
const pcm8k = makeSinePcm(8_000, 1_000, 0.2);
|
|
35
|
-
const resampled = resamplePcmTo8k(pcm8k, 8_000);
|
|
36
|
-
expect(resampled).toBe(pcm8k);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("preserves low-frequency speech-band energy when downsampling", () => {
|
|
40
|
-
const input = makeSinePcm(48_000, 1_000, 0.6);
|
|
41
|
-
const output = resamplePcmTo8k(input, 48_000);
|
|
42
|
-
expect(output.length).toBe(9_600);
|
|
43
|
-
expect(rmsPcm(output)).toBeGreaterThan(7_500);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("attenuates out-of-band high frequencies before 8k telephony conversion", () => {
|
|
47
|
-
const lowTone = resamplePcmTo8k(makeSinePcm(48_000, 1_000, 0.6), 48_000);
|
|
48
|
-
const highTone = resamplePcmTo8k(makeSinePcm(48_000, 6_000, 0.6), 48_000);
|
|
49
|
-
const ratio = rmsPcm(highTone) / rmsPcm(lowTone);
|
|
50
|
-
expect(ratio).toBeLessThan(0.1);
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
describe("telephony-audio convertPcmToMulaw8k", () => {
|
|
55
|
-
it("converts to 8k mu-law frame length", () => {
|
|
56
|
-
const input = makeSinePcm(24_000, 1_000, 0.5);
|
|
57
|
-
const mulaw = convertPcmToMulaw8k(input, 24_000);
|
|
58
|
-
// 0.5s @ 8kHz => 4000 8-bit samples
|
|
59
|
-
expect(mulaw.length).toBe(4_000);
|
|
60
|
-
});
|
|
61
|
-
});
|
package/src/telephony-audio.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
export { convertPcmToMulaw8k, resamplePcmTo8k } from "openclaw/plugin-sdk/realtime-voice";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Chunk audio buffer into 20ms frames for streaming (8kHz mono mu-law).
|
|
5
|
-
*/
|
|
6
|
-
export function chunkAudio(audio: Buffer, chunkSize = 160): Generator<Buffer, void, unknown> {
|
|
7
|
-
return (function* () {
|
|
8
|
-
for (let i = 0; i < audio.length; i += chunkSize) {
|
|
9
|
-
yield audio.subarray(i, Math.min(i + chunkSize, audio.length));
|
|
10
|
-
}
|
|
11
|
-
})();
|
|
12
|
-
}
|