@openclaw/voice-call 2026.1.29 → 2026.2.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/CHANGELOG.md +31 -0
- package/README.md +13 -9
- package/index.ts +45 -49
- package/openclaw.plugin.json +11 -53
- package/package.json +6 -3
- package/src/cli.ts +80 -113
- package/src/config.test.ts +1 -4
- package/src/config.ts +88 -110
- package/src/core-bridge.ts +14 -12
- package/src/manager/context.ts +1 -1
- package/src/manager/events.ts +18 -9
- package/src/manager/lookup.ts +3 -1
- package/src/manager/outbound.ts +46 -19
- package/src/manager/state.ts +4 -6
- package/src/manager/store.ts +6 -3
- package/src/manager/timers.ts +11 -8
- package/src/manager.test.ts +7 -10
- package/src/manager.ts +53 -75
- package/src/media-stream.test.ts +0 -1
- package/src/media-stream.ts +12 -26
- package/src/providers/mock.ts +13 -16
- package/src/providers/plivo.test.ts +0 -1
- package/src/providers/plivo.ts +27 -29
- package/src/providers/stt-openai-realtime.ts +8 -8
- package/src/providers/telnyx.ts +5 -11
- package/src/providers/tts-openai.ts +9 -14
- package/src/providers/twilio/api.ts +9 -12
- package/src/providers/twilio/webhook.ts +2 -4
- package/src/providers/twilio.test.ts +1 -5
- package/src/providers/twilio.ts +34 -46
- package/src/response-generator.ts +7 -20
- package/src/runtime.ts +12 -25
- package/src/telephony-audio.ts +14 -12
- package/src/telephony-tts.ts +21 -12
- package/src/tunnel.ts +7 -24
- package/src/types.ts +0 -1
- package/src/utils.ts +3 -1
- package/src/voice-mapping.ts +3 -1
- package/src/webhook-security.test.ts +12 -21
- package/src/webhook-security.ts +25 -29
- package/src/webhook.ts +22 -57
package/src/manager/events.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
|
|
3
|
-
import type { CallId, CallRecord, CallState, NormalizedEvent } from "../types.js";
|
|
4
|
-
import { TerminalStates } from "../types.js";
|
|
2
|
+
import type { CallRecord, CallState, NormalizedEvent } from "../types.js";
|
|
5
3
|
import type { CallManagerContext } from "./context.js";
|
|
6
4
|
import { findCall } from "./lookup.js";
|
|
5
|
+
import { endCall } from "./outbound.js";
|
|
7
6
|
import { addTranscriptEntry, transitionState } from "./state.js";
|
|
8
7
|
import { persistCallRecord } from "./store.js";
|
|
9
8
|
import {
|
|
@@ -12,9 +11,11 @@ import {
|
|
|
12
11
|
resolveTranscriptWaiter,
|
|
13
12
|
startMaxDurationTimer,
|
|
14
13
|
} from "./timers.js";
|
|
15
|
-
import { endCall } from "./outbound.js";
|
|
16
14
|
|
|
17
|
-
function shouldAcceptInbound(
|
|
15
|
+
function shouldAcceptInbound(
|
|
16
|
+
config: CallManagerContext["config"],
|
|
17
|
+
from: string | undefined,
|
|
18
|
+
): boolean {
|
|
18
19
|
const { inboundPolicy: policy, allowFrom } = config;
|
|
19
20
|
|
|
20
21
|
switch (policy) {
|
|
@@ -78,7 +79,9 @@ function createInboundCall(params: {
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): void {
|
|
81
|
-
if (ctx.processedEventIds.has(event.id))
|
|
82
|
+
if (ctx.processedEventIds.has(event.id)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
82
85
|
ctx.processedEventIds.add(event.id);
|
|
83
86
|
|
|
84
87
|
let call = findCall({
|
|
@@ -104,7 +107,9 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
|
|
|
104
107
|
event.callId = call.callId;
|
|
105
108
|
}
|
|
106
109
|
|
|
107
|
-
if (!call)
|
|
110
|
+
if (!call) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
108
113
|
|
|
109
114
|
if (event.providerCallId && !call.providerCallId) {
|
|
110
115
|
call.providerCallId = event.providerCallId;
|
|
@@ -157,7 +162,9 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
|
|
|
157
162
|
clearMaxDurationTimer(ctx, call.callId);
|
|
158
163
|
rejectTranscriptWaiter(ctx, call.callId, `Call ended: ${event.reason}`);
|
|
159
164
|
ctx.activeCalls.delete(call.callId);
|
|
160
|
-
if (call.providerCallId)
|
|
165
|
+
if (call.providerCallId) {
|
|
166
|
+
ctx.providerCallIdMap.delete(call.providerCallId);
|
|
167
|
+
}
|
|
161
168
|
break;
|
|
162
169
|
|
|
163
170
|
case "call.error":
|
|
@@ -168,7 +175,9 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
|
|
|
168
175
|
clearMaxDurationTimer(ctx, call.callId);
|
|
169
176
|
rejectTranscriptWaiter(ctx, call.callId, `Call error: ${event.error}`);
|
|
170
177
|
ctx.activeCalls.delete(call.callId);
|
|
171
|
-
if (call.providerCallId)
|
|
178
|
+
if (call.providerCallId) {
|
|
179
|
+
ctx.providerCallIdMap.delete(call.providerCallId);
|
|
180
|
+
}
|
|
172
181
|
}
|
|
173
182
|
break;
|
|
174
183
|
}
|
package/src/manager/lookup.ts
CHANGED
|
@@ -24,7 +24,9 @@ export function findCall(params: {
|
|
|
24
24
|
callIdOrProviderCallId: string;
|
|
25
25
|
}): CallRecord | undefined {
|
|
26
26
|
const directCall = params.activeCalls.get(params.callIdOrProviderCallId);
|
|
27
|
-
if (directCall)
|
|
27
|
+
if (directCall) {
|
|
28
|
+
return directCall;
|
|
29
|
+
}
|
|
28
30
|
return getCallByProviderCallId({
|
|
29
31
|
activeCalls: params.activeCalls,
|
|
30
32
|
providerCallIdMap: params.providerCallIdMap,
|
package/src/manager/outbound.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
|
|
3
|
-
import { TerminalStates, type CallId, type CallRecord, type OutboundCallOptions } from "../types.js";
|
|
4
2
|
import type { CallMode } from "../config.js";
|
|
5
|
-
import { mapVoiceToPolly } from "../voice-mapping.js";
|
|
6
3
|
import type { CallManagerContext } from "./context.js";
|
|
4
|
+
import {
|
|
5
|
+
TerminalStates,
|
|
6
|
+
type CallId,
|
|
7
|
+
type CallRecord,
|
|
8
|
+
type OutboundCallOptions,
|
|
9
|
+
} from "../types.js";
|
|
10
|
+
import { mapVoiceToPolly } from "../voice-mapping.js";
|
|
7
11
|
import { getCallByProviderCallId } from "./lookup.js";
|
|
8
|
-
import { generateNotifyTwiml } from "./twiml.js";
|
|
9
12
|
import { addTranscriptEntry, transitionState } from "./state.js";
|
|
10
13
|
import { persistCallRecord } from "./store.js";
|
|
11
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
clearMaxDurationTimer,
|
|
16
|
+
clearTranscriptWaiter,
|
|
17
|
+
rejectTranscriptWaiter,
|
|
18
|
+
waitForFinalTranscript,
|
|
19
|
+
} from "./timers.js";
|
|
20
|
+
import { generateNotifyTwiml } from "./twiml.js";
|
|
12
21
|
|
|
13
22
|
export async function initiateCall(
|
|
14
23
|
ctx: CallManagerContext,
|
|
@@ -38,8 +47,7 @@ export async function initiateCall(
|
|
|
38
47
|
|
|
39
48
|
const callId = crypto.randomUUID();
|
|
40
49
|
const from =
|
|
41
|
-
ctx.config.fromNumber ||
|
|
42
|
-
(ctx.provider?.name === "mock" ? "+15550000000" : undefined);
|
|
50
|
+
ctx.config.fromNumber || (ctx.provider?.name === "mock" ? "+15550000000" : undefined);
|
|
43
51
|
if (!from) {
|
|
44
52
|
return { callId: "", success: false, error: "fromNumber not configured" };
|
|
45
53
|
}
|
|
@@ -110,9 +118,15 @@ export async function speak(
|
|
|
110
118
|
text: string,
|
|
111
119
|
): Promise<{ success: boolean; error?: string }> {
|
|
112
120
|
const call = ctx.activeCalls.get(callId);
|
|
113
|
-
if (!call)
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
if (!call) {
|
|
122
|
+
return { success: false, error: "Call not found" };
|
|
123
|
+
}
|
|
124
|
+
if (!ctx.provider || !call.providerCallId) {
|
|
125
|
+
return { success: false, error: "Call not connected" };
|
|
126
|
+
}
|
|
127
|
+
if (TerminalStates.has(call.state)) {
|
|
128
|
+
return { success: false, error: "Call has ended" };
|
|
129
|
+
}
|
|
116
130
|
|
|
117
131
|
try {
|
|
118
132
|
transitionState(call, "speaking");
|
|
@@ -120,8 +134,7 @@ export async function speak(
|
|
|
120
134
|
|
|
121
135
|
addTranscriptEntry(call, "bot", text);
|
|
122
136
|
|
|
123
|
-
const voice =
|
|
124
|
-
ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
|
|
137
|
+
const voice = ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
|
|
125
138
|
await ctx.provider.playTts({
|
|
126
139
|
callId,
|
|
127
140
|
providerCallId: call.providerCallId,
|
|
@@ -189,9 +202,15 @@ export async function continueCall(
|
|
|
189
202
|
prompt: string,
|
|
190
203
|
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
|
191
204
|
const call = ctx.activeCalls.get(callId);
|
|
192
|
-
if (!call)
|
|
193
|
-
|
|
194
|
-
|
|
205
|
+
if (!call) {
|
|
206
|
+
return { success: false, error: "Call not found" };
|
|
207
|
+
}
|
|
208
|
+
if (!ctx.provider || !call.providerCallId) {
|
|
209
|
+
return { success: false, error: "Call not connected" };
|
|
210
|
+
}
|
|
211
|
+
if (TerminalStates.has(call.state)) {
|
|
212
|
+
return { success: false, error: "Call has ended" };
|
|
213
|
+
}
|
|
195
214
|
|
|
196
215
|
try {
|
|
197
216
|
await speak(ctx, callId, prompt);
|
|
@@ -219,9 +238,15 @@ export async function endCall(
|
|
|
219
238
|
callId: CallId,
|
|
220
239
|
): Promise<{ success: boolean; error?: string }> {
|
|
221
240
|
const call = ctx.activeCalls.get(callId);
|
|
222
|
-
if (!call)
|
|
223
|
-
|
|
224
|
-
|
|
241
|
+
if (!call) {
|
|
242
|
+
return { success: false, error: "Call not found" };
|
|
243
|
+
}
|
|
244
|
+
if (!ctx.provider || !call.providerCallId) {
|
|
245
|
+
return { success: false, error: "Call not connected" };
|
|
246
|
+
}
|
|
247
|
+
if (TerminalStates.has(call.state)) {
|
|
248
|
+
return { success: true };
|
|
249
|
+
}
|
|
225
250
|
|
|
226
251
|
try {
|
|
227
252
|
await ctx.provider.hangupCall({
|
|
@@ -239,7 +264,9 @@ export async function endCall(
|
|
|
239
264
|
rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot");
|
|
240
265
|
|
|
241
266
|
ctx.activeCalls.delete(callId);
|
|
242
|
-
if (call.providerCallId)
|
|
267
|
+
if (call.providerCallId) {
|
|
268
|
+
ctx.providerCallIdMap.delete(call.providerCallId);
|
|
269
|
+
}
|
|
243
270
|
|
|
244
271
|
return { success: true };
|
|
245
272
|
} catch (err) {
|
package/src/manager/state.ts
CHANGED
|
@@ -13,7 +13,9 @@ const StateOrder: readonly CallState[] = [
|
|
|
13
13
|
|
|
14
14
|
export function transitionState(call: CallRecord, newState: CallState): void {
|
|
15
15
|
// No-op for same state or already terminal.
|
|
16
|
-
if (call.state === newState || TerminalStates.has(call.state))
|
|
16
|
+
if (call.state === newState || TerminalStates.has(call.state)) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
17
19
|
|
|
18
20
|
// Terminal states can always be reached from non-terminal.
|
|
19
21
|
if (TerminalStates.has(newState)) {
|
|
@@ -35,11 +37,7 @@ export function transitionState(call: CallRecord, newState: CallState): void {
|
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
export function addTranscriptEntry(
|
|
39
|
-
call: CallRecord,
|
|
40
|
-
speaker: "bot" | "user",
|
|
41
|
-
text: string,
|
|
42
|
-
): void {
|
|
40
|
+
export function addTranscriptEntry(call: CallRecord, speaker: "bot" | "user", text: string): void {
|
|
43
41
|
const entry: TranscriptEntry = {
|
|
44
42
|
timestamp: Date.now(),
|
|
45
43
|
speaker,
|
package/src/manager/store.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import fsp from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
|
|
5
4
|
import { CallRecordSchema, TerminalStates, type CallId, type CallRecord } from "../types.js";
|
|
6
5
|
|
|
7
6
|
export function persistCallRecord(storePath: string, call: CallRecord): void {
|
|
@@ -32,7 +31,9 @@ export function loadActiveCallsFromStore(storePath: string): {
|
|
|
32
31
|
|
|
33
32
|
const callMap = new Map<CallId, CallRecord>();
|
|
34
33
|
for (const line of lines) {
|
|
35
|
-
if (!line.trim())
|
|
34
|
+
if (!line.trim()) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
36
37
|
try {
|
|
37
38
|
const call = CallRecordSchema.parse(JSON.parse(line));
|
|
38
39
|
callMap.set(call.callId, call);
|
|
@@ -46,7 +47,9 @@ export function loadActiveCallsFromStore(storePath: string): {
|
|
|
46
47
|
const processedEventIds = new Set<string>();
|
|
47
48
|
|
|
48
49
|
for (const [callId, call] of callMap) {
|
|
49
|
-
if (TerminalStates.has(call.state))
|
|
50
|
+
if (TerminalStates.has(call.state)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
50
53
|
activeCalls.set(callId, call);
|
|
51
54
|
if (call.providerCallId) {
|
|
52
55
|
providerCallIdMap.set(call.providerCallId, callId);
|
package/src/manager/timers.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { TerminalStates, type CallId } from "../types.js";
|
|
2
1
|
import type { CallManagerContext } from "./context.js";
|
|
2
|
+
import { TerminalStates, type CallId } from "../types.js";
|
|
3
3
|
import { persistCallRecord } from "./store.js";
|
|
4
4
|
|
|
5
5
|
export function clearMaxDurationTimer(ctx: CallManagerContext, callId: CallId): void {
|
|
@@ -40,7 +40,9 @@ export function startMaxDurationTimer(params: {
|
|
|
40
40
|
|
|
41
41
|
export function clearTranscriptWaiter(ctx: CallManagerContext, callId: CallId): void {
|
|
42
42
|
const waiter = ctx.transcriptWaiters.get(callId);
|
|
43
|
-
if (!waiter)
|
|
43
|
+
if (!waiter) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
44
46
|
clearTimeout(waiter.timeout);
|
|
45
47
|
ctx.transcriptWaiters.delete(callId);
|
|
46
48
|
}
|
|
@@ -51,7 +53,9 @@ export function rejectTranscriptWaiter(
|
|
|
51
53
|
reason: string,
|
|
52
54
|
): void {
|
|
53
55
|
const waiter = ctx.transcriptWaiters.get(callId);
|
|
54
|
-
if (!waiter)
|
|
56
|
+
if (!waiter) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
55
59
|
clearTranscriptWaiter(ctx, callId);
|
|
56
60
|
waiter.reject(new Error(reason));
|
|
57
61
|
}
|
|
@@ -62,15 +66,14 @@ export function resolveTranscriptWaiter(
|
|
|
62
66
|
transcript: string,
|
|
63
67
|
): void {
|
|
64
68
|
const waiter = ctx.transcriptWaiters.get(callId);
|
|
65
|
-
if (!waiter)
|
|
69
|
+
if (!waiter) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
66
72
|
clearTranscriptWaiter(ctx, callId);
|
|
67
73
|
waiter.resolve(transcript);
|
|
68
74
|
}
|
|
69
75
|
|
|
70
|
-
export function waitForFinalTranscript(
|
|
71
|
-
ctx: CallManagerContext,
|
|
72
|
-
callId: CallId,
|
|
73
|
-
): Promise<string> {
|
|
76
|
+
export function waitForFinalTranscript(ctx: CallManagerContext, callId: CallId): Promise<string> {
|
|
74
77
|
// Only allow one in-flight waiter per call.
|
|
75
78
|
rejectTranscriptWaiter(ctx, callId, "Transcript waiter replaced");
|
|
76
79
|
|
package/src/manager.test.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
|
|
4
3
|
import { describe, expect, it } from "vitest";
|
|
5
|
-
|
|
6
|
-
import { VoiceCallConfigSchema } from "./config.js";
|
|
7
|
-
import { CallManager } from "./manager.js";
|
|
4
|
+
import type { VoiceCallProvider } from "./providers/base.js";
|
|
8
5
|
import type {
|
|
9
6
|
HangupCallInput,
|
|
10
7
|
InitiateCallInput,
|
|
@@ -16,7 +13,8 @@ import type {
|
|
|
16
13
|
WebhookContext,
|
|
17
14
|
WebhookVerificationResult,
|
|
18
15
|
} from "./types.js";
|
|
19
|
-
import
|
|
16
|
+
import { VoiceCallConfigSchema } from "./config.js";
|
|
17
|
+
import { CallManager } from "./manager.js";
|
|
20
18
|
|
|
21
19
|
class FakeProvider implements VoiceCallProvider {
|
|
22
20
|
readonly name = "plivo" as const;
|
|
@@ -85,11 +83,10 @@ describe("CallManager", () => {
|
|
|
85
83
|
const manager = new CallManager(config, storePath);
|
|
86
84
|
manager.initialize(provider, "https://example.com/voice/webhook");
|
|
87
85
|
|
|
88
|
-
const { callId, success } = await manager.initiateCall(
|
|
89
|
-
"
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
);
|
|
86
|
+
const { callId, success } = await manager.initiateCall("+15550000002", undefined, {
|
|
87
|
+
message: "Hello there",
|
|
88
|
+
mode: "notify",
|
|
89
|
+
});
|
|
93
90
|
expect(success).toBe(true);
|
|
94
91
|
|
|
95
92
|
manager.processEvent({
|
package/src/manager.ts
CHANGED
|
@@ -3,8 +3,6 @@ import fs from "node:fs";
|
|
|
3
3
|
import fsp from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
|
|
7
|
-
import { resolveUserPath } from "./utils.js";
|
|
8
6
|
import type { CallMode, VoiceCallConfig } from "./config.js";
|
|
9
7
|
import type { VoiceCallProvider } from "./providers/base.js";
|
|
10
8
|
import {
|
|
@@ -17,11 +15,14 @@ import {
|
|
|
17
15
|
TerminalStates,
|
|
18
16
|
type TranscriptEntry,
|
|
19
17
|
} from "./types.js";
|
|
18
|
+
import { resolveUserPath } from "./utils.js";
|
|
20
19
|
import { escapeXml, mapVoiceToPolly } from "./voice-mapping.js";
|
|
21
20
|
|
|
22
21
|
function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): string {
|
|
23
22
|
const rawOverride = storePath?.trim() || config.store?.trim();
|
|
24
|
-
if (rawOverride)
|
|
23
|
+
if (rawOverride) {
|
|
24
|
+
return resolveUserPath(rawOverride);
|
|
25
|
+
}
|
|
25
26
|
const preferred = path.join(os.homedir(), ".openclaw", "voice-calls");
|
|
26
27
|
const candidates = [preferred].map((dir) => resolveUserPath(dir));
|
|
27
28
|
const existing =
|
|
@@ -124,8 +125,7 @@ export class CallManager {
|
|
|
124
125
|
|
|
125
126
|
const callId = crypto.randomUUID();
|
|
126
127
|
const from =
|
|
127
|
-
this.config.fromNumber ||
|
|
128
|
-
(this.provider?.name === "mock" ? "+15550000000" : undefined);
|
|
128
|
+
this.config.fromNumber || (this.provider?.name === "mock" ? "+15550000000" : undefined);
|
|
129
129
|
if (!from) {
|
|
130
130
|
return { callId: "", success: false, error: "fromNumber not configured" };
|
|
131
131
|
}
|
|
@@ -157,9 +157,7 @@ export class CallManager {
|
|
|
157
157
|
if (mode === "notify" && initialMessage) {
|
|
158
158
|
const pollyVoice = mapVoiceToPolly(this.config.tts?.openai?.voice);
|
|
159
159
|
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
|
|
160
|
-
console.log(
|
|
161
|
-
`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
|
|
162
|
-
);
|
|
160
|
+
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
|
|
163
161
|
}
|
|
164
162
|
|
|
165
163
|
const result = await this.provider.initiateCall({
|
|
@@ -196,10 +194,7 @@ export class CallManager {
|
|
|
196
194
|
/**
|
|
197
195
|
* Speak to user in an active call.
|
|
198
196
|
*/
|
|
199
|
-
async speak(
|
|
200
|
-
callId: CallId,
|
|
201
|
-
text: string,
|
|
202
|
-
): Promise<{ success: boolean; error?: string }> {
|
|
197
|
+
async speak(callId: CallId, text: string): Promise<{ success: boolean; error?: string }> {
|
|
203
198
|
const call = this.activeCalls.get(callId);
|
|
204
199
|
if (!call) {
|
|
205
200
|
return { success: false, error: "Call not found" };
|
|
@@ -222,8 +217,7 @@ export class CallManager {
|
|
|
222
217
|
this.addTranscriptEntry(call, "bot", text);
|
|
223
218
|
|
|
224
219
|
// Play TTS
|
|
225
|
-
const voice =
|
|
226
|
-
this.provider?.name === "twilio" ? this.config.tts?.openai?.voice : undefined;
|
|
220
|
+
const voice = this.provider?.name === "twilio" ? this.config.tts?.openai?.voice : undefined;
|
|
227
221
|
await this.provider.playTts({
|
|
228
222
|
callId,
|
|
229
223
|
providerCallId: call.providerCallId,
|
|
@@ -248,9 +242,7 @@ export class CallManager {
|
|
|
248
242
|
async speakInitialMessage(providerCallId: string): Promise<void> {
|
|
249
243
|
const call = this.getCallByProviderCallId(providerCallId);
|
|
250
244
|
if (!call) {
|
|
251
|
-
console.warn(
|
|
252
|
-
`[voice-call] speakInitialMessage: no call found for ${providerCallId}`,
|
|
253
|
-
);
|
|
245
|
+
console.warn(`[voice-call] speakInitialMessage: no call found for ${providerCallId}`);
|
|
254
246
|
return;
|
|
255
247
|
}
|
|
256
248
|
|
|
@@ -258,9 +250,7 @@ export class CallManager {
|
|
|
258
250
|
const mode = (call.metadata?.mode as CallMode) ?? "conversation";
|
|
259
251
|
|
|
260
252
|
if (!initialMessage) {
|
|
261
|
-
console.log(
|
|
262
|
-
`[voice-call] speakInitialMessage: no initial message for ${call.callId}`,
|
|
263
|
-
);
|
|
253
|
+
console.log(`[voice-call] speakInitialMessage: no initial message for ${call.callId}`);
|
|
264
254
|
return;
|
|
265
255
|
}
|
|
266
256
|
|
|
@@ -270,29 +260,21 @@ export class CallManager {
|
|
|
270
260
|
this.persistCallRecord(call);
|
|
271
261
|
}
|
|
272
262
|
|
|
273
|
-
console.log(
|
|
274
|
-
`[voice-call] Speaking initial message for call ${call.callId} (mode: ${mode})`,
|
|
275
|
-
);
|
|
263
|
+
console.log(`[voice-call] Speaking initial message for call ${call.callId} (mode: ${mode})`);
|
|
276
264
|
const result = await this.speak(call.callId, initialMessage);
|
|
277
265
|
if (!result.success) {
|
|
278
|
-
console.warn(
|
|
279
|
-
`[voice-call] Failed to speak initial message: ${result.error}`,
|
|
280
|
-
);
|
|
266
|
+
console.warn(`[voice-call] Failed to speak initial message: ${result.error}`);
|
|
281
267
|
return;
|
|
282
268
|
}
|
|
283
269
|
|
|
284
270
|
// In notify mode, auto-hangup after delay
|
|
285
271
|
if (mode === "notify") {
|
|
286
272
|
const delaySec = this.config.outbound.notifyHangupDelaySec;
|
|
287
|
-
console.log(
|
|
288
|
-
`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`,
|
|
289
|
-
);
|
|
273
|
+
console.log(`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`);
|
|
290
274
|
setTimeout(async () => {
|
|
291
275
|
const currentCall = this.getCall(call.callId);
|
|
292
276
|
if (currentCall && !TerminalStates.has(currentCall.state)) {
|
|
293
|
-
console.log(
|
|
294
|
-
`[voice-call] Notify mode: hanging up call ${call.callId}`,
|
|
295
|
-
);
|
|
277
|
+
console.log(`[voice-call] Notify mode: hanging up call ${call.callId}`);
|
|
296
278
|
await this.endCall(call.callId);
|
|
297
279
|
}
|
|
298
280
|
}, delaySec * 1000);
|
|
@@ -341,21 +323,27 @@ export class CallManager {
|
|
|
341
323
|
|
|
342
324
|
private clearTranscriptWaiter(callId: CallId): void {
|
|
343
325
|
const waiter = this.transcriptWaiters.get(callId);
|
|
344
|
-
if (!waiter)
|
|
326
|
+
if (!waiter) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
345
329
|
clearTimeout(waiter.timeout);
|
|
346
330
|
this.transcriptWaiters.delete(callId);
|
|
347
331
|
}
|
|
348
332
|
|
|
349
333
|
private rejectTranscriptWaiter(callId: CallId, reason: string): void {
|
|
350
334
|
const waiter = this.transcriptWaiters.get(callId);
|
|
351
|
-
if (!waiter)
|
|
335
|
+
if (!waiter) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
352
338
|
this.clearTranscriptWaiter(callId);
|
|
353
339
|
waiter.reject(new Error(reason));
|
|
354
340
|
}
|
|
355
341
|
|
|
356
342
|
private resolveTranscriptWaiter(callId: CallId, transcript: string): void {
|
|
357
343
|
const waiter = this.transcriptWaiters.get(callId);
|
|
358
|
-
if (!waiter)
|
|
344
|
+
if (!waiter) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
359
347
|
this.clearTranscriptWaiter(callId);
|
|
360
348
|
waiter.resolve(transcript);
|
|
361
349
|
}
|
|
@@ -368,9 +356,7 @@ export class CallManager {
|
|
|
368
356
|
return new Promise((resolve, reject) => {
|
|
369
357
|
const timeout = setTimeout(() => {
|
|
370
358
|
this.transcriptWaiters.delete(callId);
|
|
371
|
-
reject(
|
|
372
|
-
new Error(`Timed out waiting for transcript after ${timeoutMs}ms`),
|
|
373
|
-
);
|
|
359
|
+
reject(new Error(`Timed out waiting for transcript after ${timeoutMs}ms`));
|
|
374
360
|
}, timeoutMs);
|
|
375
361
|
|
|
376
362
|
this.transcriptWaiters.set(callId, { resolve, reject, timeout });
|
|
@@ -491,10 +477,7 @@ export class CallManager {
|
|
|
491
477
|
const normalized = from?.replace(/\D/g, "") || "";
|
|
492
478
|
const allowed = (allowFrom || []).some((num) => {
|
|
493
479
|
const normalizedAllow = num.replace(/\D/g, "");
|
|
494
|
-
return (
|
|
495
|
-
normalized.endsWith(normalizedAllow) ||
|
|
496
|
-
normalizedAllow.endsWith(normalized)
|
|
497
|
-
);
|
|
480
|
+
return normalized.endsWith(normalizedAllow) || normalizedAllow.endsWith(normalized);
|
|
498
481
|
});
|
|
499
482
|
const status = allowed ? "accepted" : "rejected";
|
|
500
483
|
console.log(
|
|
@@ -511,11 +494,7 @@ export class CallManager {
|
|
|
511
494
|
/**
|
|
512
495
|
* Create a call record for an inbound call.
|
|
513
496
|
*/
|
|
514
|
-
private createInboundCall(
|
|
515
|
-
providerCallId: string,
|
|
516
|
-
from: string,
|
|
517
|
-
to: string,
|
|
518
|
-
): CallRecord {
|
|
497
|
+
private createInboundCall(providerCallId: string, from: string, to: string): CallRecord {
|
|
519
498
|
const callId = crypto.randomUUID();
|
|
520
499
|
|
|
521
500
|
const callRecord: CallRecord = {
|
|
@@ -530,8 +509,7 @@ export class CallManager {
|
|
|
530
509
|
transcript: [],
|
|
531
510
|
processedEventIds: [],
|
|
532
511
|
metadata: {
|
|
533
|
-
initialMessage:
|
|
534
|
-
this.config.inboundGreeting || "Hello! How can I help you today?",
|
|
512
|
+
initialMessage: this.config.inboundGreeting || "Hello! How can I help you today?",
|
|
535
513
|
},
|
|
536
514
|
};
|
|
537
515
|
|
|
@@ -539,9 +517,7 @@ export class CallManager {
|
|
|
539
517
|
this.providerCallIdMap.set(providerCallId, callId); // Map providerCallId to internal callId
|
|
540
518
|
this.persistCallRecord(callRecord);
|
|
541
519
|
|
|
542
|
-
console.log(
|
|
543
|
-
`[voice-call] Created inbound call record: ${callId} from ${from}`,
|
|
544
|
-
);
|
|
520
|
+
console.log(`[voice-call] Created inbound call record: ${callId} from ${from}`);
|
|
545
521
|
return callRecord;
|
|
546
522
|
}
|
|
547
523
|
|
|
@@ -551,7 +527,9 @@ export class CallManager {
|
|
|
551
527
|
private findCall(callIdOrProviderCallId: string): CallRecord | undefined {
|
|
552
528
|
// Try direct lookup by internal callId
|
|
553
529
|
const directCall = this.activeCalls.get(callIdOrProviderCallId);
|
|
554
|
-
if (directCall)
|
|
530
|
+
if (directCall) {
|
|
531
|
+
return directCall;
|
|
532
|
+
}
|
|
555
533
|
|
|
556
534
|
// Try lookup by providerCallId
|
|
557
535
|
return this.getCallByProviderCallId(callIdOrProviderCallId);
|
|
@@ -663,10 +641,7 @@ export class CallManager {
|
|
|
663
641
|
call.endReason = "error";
|
|
664
642
|
this.transitionState(call, "error");
|
|
665
643
|
this.clearMaxDurationTimer(call.callId);
|
|
666
|
-
this.rejectTranscriptWaiter(
|
|
667
|
-
call.callId,
|
|
668
|
-
`Call error: ${event.error}`,
|
|
669
|
-
);
|
|
644
|
+
this.rejectTranscriptWaiter(call.callId, `Call error: ${event.error}`);
|
|
670
645
|
this.activeCalls.delete(call.callId);
|
|
671
646
|
if (call.providerCallId) {
|
|
672
647
|
this.providerCallIdMap.delete(call.providerCallId);
|
|
@@ -680,17 +655,21 @@ export class CallManager {
|
|
|
680
655
|
|
|
681
656
|
private maybeSpeakInitialMessageOnAnswered(call: CallRecord): void {
|
|
682
657
|
const initialMessage =
|
|
683
|
-
typeof call.metadata?.initialMessage === "string"
|
|
684
|
-
? call.metadata.initialMessage.trim()
|
|
685
|
-
: "";
|
|
658
|
+
typeof call.metadata?.initialMessage === "string" ? call.metadata.initialMessage.trim() : "";
|
|
686
659
|
|
|
687
|
-
if (!initialMessage)
|
|
660
|
+
if (!initialMessage) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
688
663
|
|
|
689
|
-
if (!this.provider || !call.providerCallId)
|
|
664
|
+
if (!this.provider || !call.providerCallId) {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
690
667
|
|
|
691
668
|
// Twilio has provider-specific state for speaking (<Say> fallback) and can
|
|
692
669
|
// fail for inbound calls; keep existing Twilio behavior unchanged.
|
|
693
|
-
if (this.provider.name === "twilio")
|
|
670
|
+
if (this.provider.name === "twilio") {
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
694
673
|
|
|
695
674
|
void this.speakInitialMessage(call.providerCallId);
|
|
696
675
|
}
|
|
@@ -759,10 +738,7 @@ export class CallManager {
|
|
|
759
738
|
}
|
|
760
739
|
|
|
761
740
|
// States that can cycle during multi-turn conversations
|
|
762
|
-
private static readonly ConversationStates = new Set<CallState>([
|
|
763
|
-
"speaking",
|
|
764
|
-
"listening",
|
|
765
|
-
]);
|
|
741
|
+
private static readonly ConversationStates = new Set<CallState>(["speaking", "listening"]);
|
|
766
742
|
|
|
767
743
|
// Non-terminal state order for monotonic transitions
|
|
768
744
|
private static readonly StateOrder: readonly CallState[] = [
|
|
@@ -779,7 +755,9 @@ export class CallManager {
|
|
|
779
755
|
*/
|
|
780
756
|
private transitionState(call: CallRecord, newState: CallState): void {
|
|
781
757
|
// No-op for same state or already terminal
|
|
782
|
-
if (call.state === newState || TerminalStates.has(call.state))
|
|
758
|
+
if (call.state === newState || TerminalStates.has(call.state)) {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
783
761
|
|
|
784
762
|
// Terminal states can always be reached from non-terminal
|
|
785
763
|
if (TerminalStates.has(newState)) {
|
|
@@ -808,11 +786,7 @@ export class CallManager {
|
|
|
808
786
|
/**
|
|
809
787
|
* Add an entry to the call transcript.
|
|
810
788
|
*/
|
|
811
|
-
private addTranscriptEntry(
|
|
812
|
-
call: CallRecord,
|
|
813
|
-
speaker: "bot" | "user",
|
|
814
|
-
text: string,
|
|
815
|
-
): void {
|
|
789
|
+
private addTranscriptEntry(call: CallRecord, speaker: "bot" | "user", text: string): void {
|
|
816
790
|
const entry: TranscriptEntry = {
|
|
817
791
|
timestamp: Date.now(),
|
|
818
792
|
speaker,
|
|
@@ -840,7 +814,9 @@ export class CallManager {
|
|
|
840
814
|
*/
|
|
841
815
|
private loadActiveCalls(): void {
|
|
842
816
|
const logPath = path.join(this.storePath, "calls.jsonl");
|
|
843
|
-
if (!fs.existsSync(logPath))
|
|
817
|
+
if (!fs.existsSync(logPath)) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
844
820
|
|
|
845
821
|
// Read file synchronously and parse lines
|
|
846
822
|
const content = fs.readFileSync(logPath, "utf-8");
|
|
@@ -850,7 +826,9 @@ export class CallManager {
|
|
|
850
826
|
const callMap = new Map<CallId, CallRecord>();
|
|
851
827
|
|
|
852
828
|
for (const line of lines) {
|
|
853
|
-
if (!line.trim())
|
|
829
|
+
if (!line.trim()) {
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
854
832
|
try {
|
|
855
833
|
const call = CallRecordSchema.parse(JSON.parse(line));
|
|
856
834
|
callMap.set(call.callId, call);
|