@openclaw/voice-call 2026.3.13 → 2026.5.2-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -5
- package/api.ts +16 -0
- package/cli-metadata.ts +10 -0
- package/config-api.ts +12 -0
- package/index.test.ts +943 -0
- package/index.ts +379 -149
- package/openclaw.plugin.json +384 -157
- package/package.json +35 -5
- package/runtime-api.ts +20 -0
- package/runtime-entry.ts +1 -0
- package/setup-api.ts +47 -0
- package/src/allowlist.test.ts +18 -0
- package/src/cli.ts +533 -68
- package/src/config-compat.test.ts +120 -0
- package/src/config-compat.ts +227 -0
- package/src/config.test.ts +273 -12
- package/src/config.ts +355 -72
- package/src/core-bridge.ts +2 -147
- package/src/deep-merge.test.ts +40 -0
- package/src/gateway-continue-operation.ts +200 -0
- package/src/http-headers.ts +6 -3
- package/src/manager/context.ts +6 -5
- package/src/manager/events.test.ts +243 -19
- package/src/manager/events.ts +61 -31
- package/src/manager/lifecycle.ts +53 -0
- package/src/manager/lookup.test.ts +52 -0
- package/src/manager/outbound.test.ts +528 -0
- package/src/manager/outbound.ts +163 -57
- package/src/manager/store.ts +18 -6
- package/src/manager/timers.test.ts +129 -0
- package/src/manager/timers.ts +4 -3
- package/src/manager/twiml.test.ts +13 -0
- package/src/manager/twiml.ts +8 -0
- package/src/manager.closed-loop.test.ts +30 -12
- package/src/manager.inbound-allowlist.test.ts +77 -10
- package/src/manager.notify.test.ts +344 -20
- package/src/manager.restore.test.ts +95 -8
- package/src/manager.test-harness.ts +8 -6
- package/src/manager.ts +79 -5
- package/src/media-stream.test.ts +578 -81
- package/src/media-stream.ts +235 -54
- package/src/providers/base.ts +19 -0
- package/src/providers/mock.ts +7 -1
- package/src/providers/plivo.test.ts +50 -6
- package/src/providers/plivo.ts +14 -6
- package/src/providers/shared/call-status.ts +2 -1
- package/src/providers/shared/guarded-json-api.test.ts +106 -0
- package/src/providers/shared/guarded-json-api.ts +1 -1
- package/src/providers/telnyx.test.ts +178 -6
- package/src/providers/telnyx.ts +40 -3
- package/src/providers/twilio/api.test.ts +145 -0
- package/src/providers/twilio/api.ts +67 -16
- package/src/providers/twilio/twiml-policy.ts +6 -10
- package/src/providers/twilio/webhook.ts +1 -1
- package/src/providers/twilio.test.ts +425 -25
- package/src/providers/twilio.ts +230 -77
- package/src/providers/twilio.types.ts +17 -0
- package/src/realtime-defaults.ts +3 -0
- package/src/realtime-fast-context.test.ts +88 -0
- package/src/realtime-fast-context.ts +165 -0
- package/src/realtime-transcription.runtime.ts +4 -0
- package/src/realtime-voice.runtime.ts +5 -0
- package/src/response-generator.test.ts +321 -0
- package/src/response-generator.ts +213 -53
- package/src/response-model.test.ts +71 -0
- package/src/response-model.ts +23 -0
- package/src/runtime.test.ts +429 -0
- package/src/runtime.ts +270 -24
- package/src/telephony-audio.test.ts +61 -0
- package/src/telephony-audio.ts +1 -79
- package/src/telephony-tts.test.ts +133 -12
- package/src/telephony-tts.ts +155 -2
- package/src/test-fixtures.ts +28 -7
- package/src/tts-provider-voice.test.ts +34 -0
- package/src/tts-provider-voice.ts +21 -0
- package/src/tunnel.test.ts +166 -0
- package/src/tunnel.ts +1 -1
- package/src/types.ts +24 -37
- package/src/utils.test.ts +17 -0
- package/src/voice-mapping.test.ts +34 -0
- package/src/voice-mapping.ts +3 -2
- package/src/webhook/realtime-handler.test.ts +598 -0
- package/src/webhook/realtime-handler.ts +485 -0
- package/src/webhook/stale-call-reaper.test.ts +88 -0
- package/src/webhook/stale-call-reaper.ts +5 -0
- package/src/webhook/tailscale.test.ts +214 -0
- package/src/webhook/tailscale.ts +19 -5
- package/src/webhook-exposure.test.ts +33 -0
- package/src/webhook-exposure.ts +84 -0
- package/src/webhook-security.test.ts +172 -21
- package/src/webhook-security.ts +43 -29
- package/src/webhook.hangup-once.lifecycle.test.ts +135 -0
- package/src/webhook.test.ts +1145 -27
- package/src/webhook.ts +523 -102
- package/src/webhook.types.ts +5 -0
- package/src/websocket-test-support.ts +72 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -121
- package/src/providers/index.ts +0 -10
- package/src/providers/stt-openai-realtime.test.ts +0 -42
- package/src/providers/stt-openai-realtime.ts +0 -311
- package/src/providers/tts-openai.test.ts +0 -43
- package/src/providers/tts-openai.ts +0 -221
|
@@ -19,14 +19,15 @@ import type {
|
|
|
19
19
|
} from "./types.js";
|
|
20
20
|
|
|
21
21
|
export class FakeProvider implements VoiceCallProvider {
|
|
22
|
-
readonly name: "plivo" | "twilio";
|
|
22
|
+
readonly name: "plivo" | "twilio" | "telnyx";
|
|
23
|
+
twilioStreamConnectEnabled = true;
|
|
23
24
|
readonly playTtsCalls: PlayTtsInput[] = [];
|
|
24
25
|
readonly hangupCalls: HangupCallInput[] = [];
|
|
25
26
|
readonly startListeningCalls: StartListeningInput[] = [];
|
|
26
27
|
readonly stopListeningCalls: StopListeningInput[] = [];
|
|
27
28
|
getCallStatusResult: GetCallStatusResult = { status: "in-progress", isTerminal: false };
|
|
28
29
|
|
|
29
|
-
constructor(name: "plivo" | "twilio" = "plivo") {
|
|
30
|
+
constructor(name: "plivo" | "twilio" | "telnyx" = "plivo") {
|
|
30
31
|
this.name = name;
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -61,13 +62,14 @@ export class FakeProvider implements VoiceCallProvider {
|
|
|
61
62
|
async getCallStatus(_input: GetCallStatusInput): Promise<GetCallStatusResult> {
|
|
62
63
|
return this.getCallStatusResult;
|
|
63
64
|
}
|
|
64
|
-
}
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
isConversationStreamConnectEnabled(): boolean {
|
|
67
|
+
return this.name === "twilio" && this.twilioStreamConnectEnabled;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
67
70
|
|
|
68
71
|
export function createTestStorePath(): string {
|
|
69
|
-
|
|
70
|
-
return path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}-${storeSeq}`);
|
|
72
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-voice-call-test-"));
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
export async function createManagerHarness(
|
package/src/manager.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
5
|
+
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
|
4
6
|
import type { VoiceCallConfig } from "./config.js";
|
|
5
7
|
import type { CallManagerContext } from "./manager/context.js";
|
|
6
8
|
import { processEvent as processManagerEvent } from "./manager/events.js";
|
|
@@ -9,10 +11,15 @@ import {
|
|
|
9
11
|
continueCall as continueCallWithContext,
|
|
10
12
|
endCall as endCallWithContext,
|
|
11
13
|
initiateCall as initiateCallWithContext,
|
|
14
|
+
sendDtmf as sendDtmfWithContext,
|
|
12
15
|
speak as speakWithContext,
|
|
13
16
|
speakInitialMessage as speakInitialMessageWithContext,
|
|
14
17
|
} from "./manager/outbound.js";
|
|
15
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
getCallHistoryFromStore,
|
|
20
|
+
loadActiveCallsFromStore,
|
|
21
|
+
persistCallRecord,
|
|
22
|
+
} from "./manager/store.js";
|
|
16
23
|
import { startMaxDurationTimer } from "./manager/timers.js";
|
|
17
24
|
import type { VoiceCallProvider } from "./providers/base.js";
|
|
18
25
|
import {
|
|
@@ -24,6 +31,12 @@ import {
|
|
|
24
31
|
} from "./types.js";
|
|
25
32
|
import { resolveUserPath } from "./utils.js";
|
|
26
33
|
|
|
34
|
+
function markRestoredCallSkipped(call: CallRecord, endReason: "completed" | "timeout"): void {
|
|
35
|
+
call.endedAt = Date.now();
|
|
36
|
+
call.endReason = endReason;
|
|
37
|
+
call.state = endReason;
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): string {
|
|
28
41
|
const rawOverride = storePath?.trim() || config.store?.trim();
|
|
29
42
|
if (rawOverride) {
|
|
@@ -64,6 +77,7 @@ export class CallManager {
|
|
|
64
77
|
}
|
|
65
78
|
>();
|
|
66
79
|
private maxDurationTimers = new Map<CallId, NodeJS.Timeout>();
|
|
80
|
+
private initialMessageInFlight = new Set<CallId>();
|
|
67
81
|
|
|
68
82
|
constructor(config: VoiceCallConfig, storePath?: string) {
|
|
69
83
|
this.config = config;
|
|
@@ -114,8 +128,9 @@ export class CallManager {
|
|
|
114
128
|
startMaxDurationTimer({
|
|
115
129
|
ctx: this.getContext(),
|
|
116
130
|
callId,
|
|
131
|
+
timeoutMs: maxDurationMs - elapsed,
|
|
117
132
|
onTimeout: async (id) => {
|
|
118
|
-
await endCallWithContext(this.getContext(), id);
|
|
133
|
+
await endCallWithContext(this.getContext(), id, { reason: "timeout" });
|
|
119
134
|
},
|
|
120
135
|
});
|
|
121
136
|
console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`);
|
|
@@ -157,6 +172,20 @@ export class CallManager {
|
|
|
157
172
|
console.log(
|
|
158
173
|
`[voice-call] Skipping restored call ${callId} (older than maxDurationSeconds)`,
|
|
159
174
|
);
|
|
175
|
+
markRestoredCallSkipped(call, "timeout");
|
|
176
|
+
persistCallRecord(this.storePath, call);
|
|
177
|
+
await provider
|
|
178
|
+
.hangupCall({
|
|
179
|
+
callId,
|
|
180
|
+
providerCallId: call.providerCallId,
|
|
181
|
+
reason: "timeout",
|
|
182
|
+
})
|
|
183
|
+
.catch((err) => {
|
|
184
|
+
console.warn(
|
|
185
|
+
`[voice-call] Failed to hang up expired restored call ${callId}:`,
|
|
186
|
+
err instanceof Error ? err.message : String(err),
|
|
187
|
+
);
|
|
188
|
+
});
|
|
160
189
|
continue;
|
|
161
190
|
}
|
|
162
191
|
|
|
@@ -170,6 +199,8 @@ export class CallManager {
|
|
|
170
199
|
console.log(
|
|
171
200
|
`[voice-call] Skipping restored call ${callId} (provider status: ${result.status})`,
|
|
172
201
|
);
|
|
202
|
+
markRestoredCallSkipped(call, "completed");
|
|
203
|
+
persistCallRecord(this.storePath, call);
|
|
173
204
|
} else if (result.isUnknown) {
|
|
174
205
|
console.log(
|
|
175
206
|
`[voice-call] Keeping restored call ${callId} (provider status unknown, relying on timer)`,
|
|
@@ -219,6 +250,13 @@ export class CallManager {
|
|
|
219
250
|
return speakWithContext(this.getContext(), callId, text);
|
|
220
251
|
}
|
|
221
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Send DTMF digits to an active call.
|
|
255
|
+
*/
|
|
256
|
+
async sendDtmf(callId: CallId, digits: string): Promise<{ success: boolean; error?: string }> {
|
|
257
|
+
return sendDtmfWithContext(this.getContext(), callId, digits);
|
|
258
|
+
}
|
|
259
|
+
|
|
222
260
|
/**
|
|
223
261
|
* Speak the initial message for a call (called when media stream connects).
|
|
224
262
|
*/
|
|
@@ -256,6 +294,7 @@ export class CallManager {
|
|
|
256
294
|
activeTurnCalls: this.activeTurnCalls,
|
|
257
295
|
transcriptWaiters: this.transcriptWaiters,
|
|
258
296
|
maxDurationTimers: this.maxDurationTimers,
|
|
297
|
+
initialMessageInFlight: this.initialMessageInFlight,
|
|
259
298
|
onCallAnswered: (call) => {
|
|
260
299
|
this.maybeSpeakInitialMessageOnAnswered(call);
|
|
261
300
|
},
|
|
@@ -269,19 +308,54 @@ export class CallManager {
|
|
|
269
308
|
processManagerEvent(this.getContext(), event);
|
|
270
309
|
}
|
|
271
310
|
|
|
311
|
+
private shouldDeferConversationInitialMessageUntilStreamConnect(): boolean {
|
|
312
|
+
if (!this.provider || this.provider.name !== "twilio" || !this.config.streaming.enabled) {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const streamAwareProvider = this.provider as VoiceCallProvider & {
|
|
317
|
+
isConversationStreamConnectEnabled?: () => boolean;
|
|
318
|
+
};
|
|
319
|
+
if (typeof streamAwareProvider.isConversationStreamConnectEnabled !== "function") {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return streamAwareProvider.isConversationStreamConnectEnabled();
|
|
324
|
+
}
|
|
325
|
+
|
|
272
326
|
private maybeSpeakInitialMessageOnAnswered(call: CallRecord): void {
|
|
273
|
-
const initialMessage =
|
|
274
|
-
typeof call.metadata?.initialMessage === "string" ? call.metadata.initialMessage.trim() : "";
|
|
327
|
+
const initialMessage = normalizeOptionalString(call.metadata?.initialMessage) ?? "";
|
|
275
328
|
|
|
276
329
|
if (!initialMessage) {
|
|
277
330
|
return;
|
|
278
331
|
}
|
|
279
332
|
|
|
333
|
+
// Notify mode should speak as soon as the provider reports "answered".
|
|
334
|
+
// Conversation mode should defer only when the Twilio stream-connect path
|
|
335
|
+
// is actually available; otherwise speak immediately on answered.
|
|
336
|
+
const mode = (call.metadata?.mode as string | undefined) ?? "conversation";
|
|
337
|
+
if (mode === "conversation") {
|
|
338
|
+
if (this.config.realtime.enabled) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const shouldWaitForStreamConnect =
|
|
342
|
+
this.shouldDeferConversationInitialMessageUntilStreamConnect();
|
|
343
|
+
if (shouldWaitForStreamConnect) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
} else if (mode !== "notify") {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
280
350
|
if (!this.provider || !call.providerCallId) {
|
|
281
351
|
return;
|
|
282
352
|
}
|
|
283
353
|
|
|
284
|
-
void this.speakInitialMessage(call.providerCallId)
|
|
354
|
+
void this.speakInitialMessage(call.providerCallId).catch((err) => {
|
|
355
|
+
console.warn(
|
|
356
|
+
`[voice-call] Failed to speak initial message for call ${call.callId}: ${formatErrorMessage(err)}`,
|
|
357
|
+
);
|
|
358
|
+
});
|
|
285
359
|
}
|
|
286
360
|
|
|
287
361
|
/**
|