@openclaw/voice-call 2026.3.13 → 2026.5.1-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 +25 -5
- package/api.ts +16 -0
- package/cli-metadata.ts +10 -0
- package/config-api.ts +12 -0
- package/index.test.ts +866 -0
- package/index.ts +353 -148
- package/openclaw.plugin.json +336 -157
- package/package.json +33 -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 +160 -12
- package/src/config.ts +243 -74
- 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 +179 -19
- package/src/manager/events.ts +48 -30
- package/src/manager/lifecycle.ts +53 -0
- package/src/manager/lookup.test.ts +52 -0
- package/src/manager/outbound.test.ts +464 -0
- package/src/manager/outbound.ts +148 -55
- 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 +277 -0
- package/src/response-generator.ts +186 -40
- package/src/response-model.test.ts +71 -0
- package/src/response-model.ts +23 -0
- package/src/runtime.test.ts +351 -0
- package/src/runtime.ts +254 -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 +26 -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 +513 -100
- package/src/webhook.types.ts +5 -0
- package/src/websocket-test-support.ts +72 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -121
- package/src/providers/index.ts +0 -10
- package/src/providers/stt-openai-realtime.test.ts +0 -42
- package/src/providers/stt-openai-realtime.ts +0 -311
- package/src/providers/tts-openai.test.ts +0 -43
- package/src/providers/tts-openai.ts +0 -221
package/src/manager/events.ts
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
2
3
|
import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js";
|
|
3
|
-
import type { CallRecord,
|
|
4
|
+
import type { CallRecord, NormalizedEvent } from "../types.js";
|
|
4
5
|
import type { CallManagerContext } from "./context.js";
|
|
6
|
+
import { finalizeCall } from "./lifecycle.js";
|
|
5
7
|
import { findCall } from "./lookup.js";
|
|
6
8
|
import { endCall } from "./outbound.js";
|
|
7
9
|
import { addTranscriptEntry, transitionState } from "./state.js";
|
|
8
10
|
import { persistCallRecord } from "./store.js";
|
|
9
|
-
import {
|
|
10
|
-
clearMaxDurationTimer,
|
|
11
|
-
rejectTranscriptWaiter,
|
|
12
|
-
resolveTranscriptWaiter,
|
|
13
|
-
startMaxDurationTimer,
|
|
14
|
-
} from "./timers.js";
|
|
11
|
+
import { resolveTranscriptWaiter, startMaxDurationTimer } from "./timers.js";
|
|
15
12
|
|
|
16
13
|
type EventContext = Pick<
|
|
17
14
|
CallManagerContext,
|
|
@@ -102,7 +99,6 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
|
|
|
102
99
|
if (ctx.processedEventIds.has(dedupeKey)) {
|
|
103
100
|
return;
|
|
104
101
|
}
|
|
105
|
-
ctx.processedEventIds.add(dedupeKey);
|
|
106
102
|
|
|
107
103
|
let call = findCall({
|
|
108
104
|
activeCalls: ctx.activeCalls,
|
|
@@ -128,6 +124,7 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
|
|
|
128
124
|
);
|
|
129
125
|
return;
|
|
130
126
|
}
|
|
127
|
+
ctx.processedEventIds.add(dedupeKey);
|
|
131
128
|
if (ctx.rejectedProviderCallIds.has(pid)) {
|
|
132
129
|
return;
|
|
133
130
|
}
|
|
@@ -141,7 +138,8 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
|
|
|
141
138
|
reason: "hangup-bot",
|
|
142
139
|
})
|
|
143
140
|
.catch((err) => {
|
|
144
|
-
|
|
141
|
+
ctx.rejectedProviderCallIds.delete(pid);
|
|
142
|
+
const message = formatErrorMessage(err);
|
|
145
143
|
console.warn(`[voice-call] Failed to reject inbound call ${pid}:`, message);
|
|
146
144
|
});
|
|
147
145
|
return;
|
|
@@ -175,11 +173,29 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
|
|
|
175
173
|
}
|
|
176
174
|
}
|
|
177
175
|
|
|
178
|
-
call.
|
|
176
|
+
const shouldCommitReplayKey = !(event.type === "call.error" && event.retryable);
|
|
177
|
+
if (shouldCommitReplayKey) {
|
|
178
|
+
ctx.processedEventIds.add(dedupeKey);
|
|
179
|
+
call.processedEventIds.push(dedupeKey);
|
|
180
|
+
}
|
|
179
181
|
|
|
180
182
|
switch (event.type) {
|
|
181
183
|
case "call.initiated":
|
|
182
184
|
transitionState(call, "initiated");
|
|
185
|
+
if (call.direction === "inbound" && call.providerCallId && ctx.provider?.answerCall) {
|
|
186
|
+
void ctx.provider
|
|
187
|
+
.answerCall({
|
|
188
|
+
callId: call.callId,
|
|
189
|
+
providerCallId: call.providerCallId,
|
|
190
|
+
})
|
|
191
|
+
.catch((err) => {
|
|
192
|
+
const message = formatErrorMessage(err);
|
|
193
|
+
console.warn(
|
|
194
|
+
`[voice-call] Failed to answer inbound call ${call.providerCallId}:`,
|
|
195
|
+
message,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
183
199
|
break;
|
|
184
200
|
|
|
185
201
|
case "call.ringing":
|
|
@@ -193,7 +209,7 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
|
|
|
193
209
|
ctx,
|
|
194
210
|
callId: call.callId,
|
|
195
211
|
onTimeout: async (callId) => {
|
|
196
|
-
await endCall(ctx, callId);
|
|
212
|
+
await endCall(ctx, callId, { reason: "timeout" });
|
|
197
213
|
},
|
|
198
214
|
});
|
|
199
215
|
ctx.onCallAnswered?.(call);
|
|
@@ -227,30 +243,32 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
|
|
|
227
243
|
transitionState(call, "listening");
|
|
228
244
|
break;
|
|
229
245
|
|
|
230
|
-
case "call.
|
|
231
|
-
|
|
232
|
-
call.endReason = event.reason;
|
|
233
|
-
transitionState(call, event.reason as CallState);
|
|
234
|
-
clearMaxDurationTimer(ctx, call.callId);
|
|
235
|
-
rejectTranscriptWaiter(ctx, call.callId, `Call ended: ${event.reason}`);
|
|
236
|
-
ctx.activeCalls.delete(call.callId);
|
|
237
|
-
if (call.providerCallId) {
|
|
238
|
-
ctx.providerCallIdMap.delete(call.providerCallId);
|
|
239
|
-
}
|
|
246
|
+
case "call.silence":
|
|
247
|
+
case "call.dtmf":
|
|
240
248
|
break;
|
|
241
249
|
|
|
250
|
+
case "call.ended":
|
|
251
|
+
finalizeCall({
|
|
252
|
+
ctx,
|
|
253
|
+
call,
|
|
254
|
+
endReason: event.reason,
|
|
255
|
+
endedAt: event.timestamp,
|
|
256
|
+
});
|
|
257
|
+
return;
|
|
258
|
+
|
|
242
259
|
case "call.error":
|
|
243
260
|
if (!event.retryable) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
261
|
+
finalizeCall({
|
|
262
|
+
ctx,
|
|
263
|
+
call,
|
|
264
|
+
endReason: "error",
|
|
265
|
+
endedAt: event.timestamp,
|
|
266
|
+
transcriptRejectReason: `Call error: ${event.error}`,
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
253
269
|
}
|
|
270
|
+
// Keep retryable provider errors replayable so a redelivery can still
|
|
271
|
+
// drive later recovery or terminal handling for the same event key.
|
|
254
272
|
break;
|
|
255
273
|
}
|
|
256
274
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { CallRecord, EndReason } from "../types.js";
|
|
2
|
+
import type { CallManagerContext } from "./context.js";
|
|
3
|
+
import { transitionState } from "./state.js";
|
|
4
|
+
import { persistCallRecord } from "./store.js";
|
|
5
|
+
import { clearMaxDurationTimer, rejectTranscriptWaiter } from "./timers.js";
|
|
6
|
+
|
|
7
|
+
type CallLifecycleContext = Pick<
|
|
8
|
+
CallManagerContext,
|
|
9
|
+
"activeCalls" | "providerCallIdMap" | "storePath"
|
|
10
|
+
> &
|
|
11
|
+
Partial<Pick<CallManagerContext, "transcriptWaiters" | "maxDurationTimers">>;
|
|
12
|
+
|
|
13
|
+
function removeProviderCallMapping(
|
|
14
|
+
providerCallIdMap: Map<string, string>,
|
|
15
|
+
call: Pick<CallRecord, "callId" | "providerCallId">,
|
|
16
|
+
): void {
|
|
17
|
+
if (!call.providerCallId) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const mappedCallId = providerCallIdMap.get(call.providerCallId);
|
|
21
|
+
if (mappedCallId === call.callId) {
|
|
22
|
+
providerCallIdMap.delete(call.providerCallId);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function finalizeCall(params: {
|
|
27
|
+
ctx: CallLifecycleContext;
|
|
28
|
+
call: CallRecord;
|
|
29
|
+
endReason: EndReason;
|
|
30
|
+
endedAt?: number;
|
|
31
|
+
transcriptRejectReason?: string;
|
|
32
|
+
}): void {
|
|
33
|
+
const { ctx, call, endReason } = params;
|
|
34
|
+
|
|
35
|
+
call.endedAt = params.endedAt ?? Date.now();
|
|
36
|
+
call.endReason = endReason;
|
|
37
|
+
transitionState(call, endReason);
|
|
38
|
+
persistCallRecord(ctx.storePath, call);
|
|
39
|
+
|
|
40
|
+
if (ctx.maxDurationTimers) {
|
|
41
|
+
clearMaxDurationTimer({ maxDurationTimers: ctx.maxDurationTimers }, call.callId);
|
|
42
|
+
}
|
|
43
|
+
if (ctx.transcriptWaiters) {
|
|
44
|
+
rejectTranscriptWaiter(
|
|
45
|
+
{ transcriptWaiters: ctx.transcriptWaiters },
|
|
46
|
+
call.callId,
|
|
47
|
+
params.transcriptRejectReason ?? `Call ended: ${endReason}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
ctx.activeCalls.delete(call.callId);
|
|
52
|
+
removeProviderCallMapping(ctx.providerCallIdMap, call);
|
|
53
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { findCall, getCallByProviderCallId } from "./lookup.js";
|
|
3
|
+
|
|
4
|
+
describe("voice-call manager lookup", () => {
|
|
5
|
+
it("resolves provider call ids from the explicit map first", () => {
|
|
6
|
+
const activeCalls = new Map([
|
|
7
|
+
["call-1", { id: "call-1", providerCallId: "prov-1" }],
|
|
8
|
+
["call-2", { id: "call-2", providerCallId: "prov-2" }],
|
|
9
|
+
]);
|
|
10
|
+
const providerCallIdMap = new Map([["provider-lookup", "call-2"]]);
|
|
11
|
+
|
|
12
|
+
expect(
|
|
13
|
+
getCallByProviderCallId({
|
|
14
|
+
activeCalls: activeCalls as never,
|
|
15
|
+
providerCallIdMap,
|
|
16
|
+
providerCallId: "provider-lookup",
|
|
17
|
+
}),
|
|
18
|
+
).toEqual({ id: "call-2", providerCallId: "prov-2" });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("falls back to scanning active calls and supports direct call ids", () => {
|
|
22
|
+
const activeCalls = new Map([
|
|
23
|
+
["call-1", { id: "call-1", providerCallId: "prov-1" }],
|
|
24
|
+
["call-2", { id: "call-2", providerCallId: "prov-2" }],
|
|
25
|
+
]);
|
|
26
|
+
const providerCallIdMap = new Map<string, string>();
|
|
27
|
+
|
|
28
|
+
expect(
|
|
29
|
+
getCallByProviderCallId({
|
|
30
|
+
activeCalls: activeCalls as never,
|
|
31
|
+
providerCallIdMap,
|
|
32
|
+
providerCallId: "prov-1",
|
|
33
|
+
}),
|
|
34
|
+
).toEqual({ id: "call-1", providerCallId: "prov-1" });
|
|
35
|
+
|
|
36
|
+
expect(
|
|
37
|
+
findCall({
|
|
38
|
+
activeCalls: activeCalls as never,
|
|
39
|
+
providerCallIdMap,
|
|
40
|
+
callIdOrProviderCallId: "call-2",
|
|
41
|
+
}),
|
|
42
|
+
).toEqual({ id: "call-2", providerCallId: "prov-2" });
|
|
43
|
+
|
|
44
|
+
expect(
|
|
45
|
+
findCall({
|
|
46
|
+
activeCalls: activeCalls as never,
|
|
47
|
+
providerCallIdMap,
|
|
48
|
+
callIdOrProviderCallId: "missing",
|
|
49
|
+
}),
|
|
50
|
+
).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
});
|