@openclaw/voice-call 2026.2.12 → 2026.2.14
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 +12 -0
- package/README.md +9 -0
- package/package.json +1 -1
- package/src/config.test.ts +15 -2
- package/src/config.ts +6 -7
- package/src/manager/context.ts +19 -1
- package/src/manager/events.test.ts +240 -0
- package/src/manager/events.ts +49 -8
- package/src/manager/outbound.ts +36 -5
- package/src/manager/store.ts +4 -1
- package/src/manager/timers.ts +19 -6
- package/src/manager.test.ts +40 -0
- package/src/manager.ts +48 -728
- package/src/providers/telnyx.test.ts +121 -0
- package/src/providers/telnyx.ts +7 -60
- package/src/runtime.ts +7 -2
- package/src/webhook-security.test.ts +35 -3
- package/src/webhook-security.ts +112 -13
- package/src/webhook.ts +12 -37
package/src/manager.ts
CHANGED
|
@@ -1,23 +1,21 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
1
|
import fs from "node:fs";
|
|
3
|
-
import fsp from "node:fs/promises";
|
|
4
2
|
import os from "node:os";
|
|
5
3
|
import path from "node:path";
|
|
6
|
-
import type {
|
|
4
|
+
import type { VoiceCallConfig } from "./config.js";
|
|
5
|
+
import type { CallManagerContext } from "./manager/context.js";
|
|
7
6
|
import type { VoiceCallProvider } from "./providers/base.js";
|
|
8
|
-
import {
|
|
7
|
+
import type { CallId, CallRecord, NormalizedEvent, OutboundCallOptions } from "./types.js";
|
|
8
|
+
import { processEvent as processManagerEvent } from "./manager/events.js";
|
|
9
|
+
import { getCallByProviderCallId as getCallByProviderCallIdFromMaps } from "./manager/lookup.js";
|
|
9
10
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
type TranscriptEntry,
|
|
18
|
-
} from "./types.js";
|
|
11
|
+
continueCall as continueCallWithContext,
|
|
12
|
+
endCall as endCallWithContext,
|
|
13
|
+
initiateCall as initiateCallWithContext,
|
|
14
|
+
speak as speakWithContext,
|
|
15
|
+
speakInitialMessage as speakInitialMessageWithContext,
|
|
16
|
+
} from "./manager/outbound.js";
|
|
17
|
+
import { getCallHistoryFromStore, loadActiveCallsFromStore } from "./manager/store.js";
|
|
19
18
|
import { resolveUserPath } from "./utils.js";
|
|
20
|
-
import { escapeXml, mapVoiceToPolly } from "./voice-mapping.js";
|
|
21
19
|
|
|
22
20
|
function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): string {
|
|
23
21
|
const rawOverride = storePath?.trim() || config.store?.trim();
|
|
@@ -38,12 +36,13 @@ function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): s
|
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
/**
|
|
41
|
-
* Manages voice calls: state
|
|
39
|
+
* Manages voice calls: state ownership and delegation to manager helper modules.
|
|
42
40
|
*/
|
|
43
41
|
export class CallManager {
|
|
44
42
|
private activeCalls = new Map<CallId, CallRecord>();
|
|
45
|
-
private providerCallIdMap = new Map<string, CallId>();
|
|
43
|
+
private providerCallIdMap = new Map<string, CallId>();
|
|
46
44
|
private processedEventIds = new Set<string>();
|
|
45
|
+
private rejectedProviderCallIds = new Set<string>();
|
|
47
46
|
private provider: VoiceCallProvider | null = null;
|
|
48
47
|
private config: VoiceCallConfig;
|
|
49
48
|
private storePath: string;
|
|
@@ -56,12 +55,10 @@ export class CallManager {
|
|
|
56
55
|
timeout: NodeJS.Timeout;
|
|
57
56
|
}
|
|
58
57
|
>();
|
|
59
|
-
/** Max duration timers to auto-hangup calls after configured timeout */
|
|
60
58
|
private maxDurationTimers = new Map<CallId, NodeJS.Timeout>();
|
|
61
59
|
|
|
62
60
|
constructor(config: VoiceCallConfig, storePath?: string) {
|
|
63
61
|
this.config = config;
|
|
64
|
-
// Resolve store path with tilde expansion (like other config values)
|
|
65
62
|
this.storePath = resolveDefaultStoreBase(config, storePath);
|
|
66
63
|
}
|
|
67
64
|
|
|
@@ -72,11 +69,13 @@ export class CallManager {
|
|
|
72
69
|
this.provider = provider;
|
|
73
70
|
this.webhookUrl = webhookUrl;
|
|
74
71
|
|
|
75
|
-
// Ensure store directory exists
|
|
76
72
|
fs.mkdirSync(this.storePath, { recursive: true });
|
|
77
73
|
|
|
78
|
-
|
|
79
|
-
this.
|
|
74
|
+
const persisted = loadActiveCallsFromStore(this.storePath);
|
|
75
|
+
this.activeCalls = persisted.activeCalls;
|
|
76
|
+
this.providerCallIdMap = persisted.providerCallIdMap;
|
|
77
|
+
this.processedEventIds = persisted.processedEventIds;
|
|
78
|
+
this.rejectedProviderCallIds = persisted.rejectedProviderCallIds;
|
|
80
79
|
}
|
|
81
80
|
|
|
82
81
|
/**
|
|
@@ -88,280 +87,27 @@ export class CallManager {
|
|
|
88
87
|
|
|
89
88
|
/**
|
|
90
89
|
* Initiate an outbound call.
|
|
91
|
-
* @param to - The phone number to call
|
|
92
|
-
* @param sessionKey - Optional session key for context
|
|
93
|
-
* @param options - Optional call options (message, mode)
|
|
94
90
|
*/
|
|
95
91
|
async initiateCall(
|
|
96
92
|
to: string,
|
|
97
93
|
sessionKey?: string,
|
|
98
94
|
options?: OutboundCallOptions | string,
|
|
99
95
|
): Promise<{ callId: CallId; success: boolean; error?: string }> {
|
|
100
|
-
|
|
101
|
-
const opts: OutboundCallOptions =
|
|
102
|
-
typeof options === "string" ? { message: options } : (options ?? {});
|
|
103
|
-
const initialMessage = opts.message;
|
|
104
|
-
const mode = opts.mode ?? this.config.outbound.defaultMode;
|
|
105
|
-
if (!this.provider) {
|
|
106
|
-
return { callId: "", success: false, error: "Provider not initialized" };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (!this.webhookUrl) {
|
|
110
|
-
return {
|
|
111
|
-
callId: "",
|
|
112
|
-
success: false,
|
|
113
|
-
error: "Webhook URL not configured",
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Check concurrent call limit
|
|
118
|
-
const activeCalls = this.getActiveCalls();
|
|
119
|
-
if (activeCalls.length >= this.config.maxConcurrentCalls) {
|
|
120
|
-
return {
|
|
121
|
-
callId: "",
|
|
122
|
-
success: false,
|
|
123
|
-
error: `Maximum concurrent calls (${this.config.maxConcurrentCalls}) reached`,
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const callId = crypto.randomUUID();
|
|
128
|
-
const from =
|
|
129
|
-
this.config.fromNumber || (this.provider?.name === "mock" ? "+15550000000" : undefined);
|
|
130
|
-
if (!from) {
|
|
131
|
-
return { callId: "", success: false, error: "fromNumber not configured" };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Create call record with mode in metadata
|
|
135
|
-
const callRecord: CallRecord = {
|
|
136
|
-
callId,
|
|
137
|
-
provider: this.provider.name,
|
|
138
|
-
direction: "outbound",
|
|
139
|
-
state: "initiated",
|
|
140
|
-
from,
|
|
141
|
-
to,
|
|
142
|
-
sessionKey,
|
|
143
|
-
startedAt: Date.now(),
|
|
144
|
-
transcript: [],
|
|
145
|
-
processedEventIds: [],
|
|
146
|
-
metadata: {
|
|
147
|
-
...(initialMessage && { initialMessage }),
|
|
148
|
-
mode,
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
this.activeCalls.set(callId, callRecord);
|
|
153
|
-
this.persistCallRecord(callRecord);
|
|
154
|
-
|
|
155
|
-
try {
|
|
156
|
-
// For notify mode with a message, use inline TwiML with <Say>
|
|
157
|
-
let inlineTwiml: string | undefined;
|
|
158
|
-
if (mode === "notify" && initialMessage) {
|
|
159
|
-
const pollyVoice = mapVoiceToPolly(this.config.tts?.openai?.voice);
|
|
160
|
-
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
|
|
161
|
-
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const result = await this.provider.initiateCall({
|
|
165
|
-
callId,
|
|
166
|
-
from,
|
|
167
|
-
to,
|
|
168
|
-
webhookUrl: this.webhookUrl,
|
|
169
|
-
inlineTwiml,
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
callRecord.providerCallId = result.providerCallId;
|
|
173
|
-
this.providerCallIdMap.set(result.providerCallId, callId); // Map providerCallId to internal callId
|
|
174
|
-
this.persistCallRecord(callRecord);
|
|
175
|
-
|
|
176
|
-
return { callId, success: true };
|
|
177
|
-
} catch (err) {
|
|
178
|
-
callRecord.state = "failed";
|
|
179
|
-
callRecord.endedAt = Date.now();
|
|
180
|
-
callRecord.endReason = "failed";
|
|
181
|
-
this.persistCallRecord(callRecord);
|
|
182
|
-
this.activeCalls.delete(callId);
|
|
183
|
-
if (callRecord.providerCallId) {
|
|
184
|
-
this.providerCallIdMap.delete(callRecord.providerCallId);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return {
|
|
188
|
-
callId,
|
|
189
|
-
success: false,
|
|
190
|
-
error: err instanceof Error ? err.message : String(err),
|
|
191
|
-
};
|
|
192
|
-
}
|
|
96
|
+
return initiateCallWithContext(this.getContext(), to, sessionKey, options);
|
|
193
97
|
}
|
|
194
98
|
|
|
195
99
|
/**
|
|
196
100
|
* Speak to user in an active call.
|
|
197
101
|
*/
|
|
198
102
|
async speak(callId: CallId, text: string): Promise<{ success: boolean; error?: string }> {
|
|
199
|
-
|
|
200
|
-
if (!call) {
|
|
201
|
-
return { success: false, error: "Call not found" };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (!this.provider || !call.providerCallId) {
|
|
205
|
-
return { success: false, error: "Call not connected" };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (TerminalStates.has(call.state)) {
|
|
209
|
-
return { success: false, error: "Call has ended" };
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
try {
|
|
213
|
-
// Update state
|
|
214
|
-
call.state = "speaking";
|
|
215
|
-
this.persistCallRecord(call);
|
|
216
|
-
|
|
217
|
-
// Add to transcript
|
|
218
|
-
this.addTranscriptEntry(call, "bot", text);
|
|
219
|
-
|
|
220
|
-
// Play TTS
|
|
221
|
-
const voice = this.provider?.name === "twilio" ? this.config.tts?.openai?.voice : undefined;
|
|
222
|
-
await this.provider.playTts({
|
|
223
|
-
callId,
|
|
224
|
-
providerCallId: call.providerCallId,
|
|
225
|
-
text,
|
|
226
|
-
voice,
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
return { success: true };
|
|
230
|
-
} catch (err) {
|
|
231
|
-
return {
|
|
232
|
-
success: false,
|
|
233
|
-
error: err instanceof Error ? err.message : String(err),
|
|
234
|
-
};
|
|
235
|
-
}
|
|
103
|
+
return speakWithContext(this.getContext(), callId, text);
|
|
236
104
|
}
|
|
237
105
|
|
|
238
106
|
/**
|
|
239
107
|
* Speak the initial message for a call (called when media stream connects).
|
|
240
|
-
* This is used to auto-play the message passed to initiateCall.
|
|
241
|
-
* In notify mode, auto-hangup after the message is delivered.
|
|
242
108
|
*/
|
|
243
109
|
async speakInitialMessage(providerCallId: string): Promise<void> {
|
|
244
|
-
|
|
245
|
-
if (!call) {
|
|
246
|
-
console.warn(`[voice-call] speakInitialMessage: no call found for ${providerCallId}`);
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const initialMessage = call.metadata?.initialMessage as string | undefined;
|
|
251
|
-
const mode = (call.metadata?.mode as CallMode) ?? "conversation";
|
|
252
|
-
|
|
253
|
-
if (!initialMessage) {
|
|
254
|
-
console.log(`[voice-call] speakInitialMessage: no initial message for ${call.callId}`);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Clear the initial message so we don't speak it again
|
|
259
|
-
if (call.metadata) {
|
|
260
|
-
delete call.metadata.initialMessage;
|
|
261
|
-
this.persistCallRecord(call);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
console.log(`[voice-call] Speaking initial message for call ${call.callId} (mode: ${mode})`);
|
|
265
|
-
const result = await this.speak(call.callId, initialMessage);
|
|
266
|
-
if (!result.success) {
|
|
267
|
-
console.warn(`[voice-call] Failed to speak initial message: ${result.error}`);
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// In notify mode, auto-hangup after delay
|
|
272
|
-
if (mode === "notify") {
|
|
273
|
-
const delaySec = this.config.outbound.notifyHangupDelaySec;
|
|
274
|
-
console.log(`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`);
|
|
275
|
-
setTimeout(async () => {
|
|
276
|
-
const currentCall = this.getCall(call.callId);
|
|
277
|
-
if (currentCall && !TerminalStates.has(currentCall.state)) {
|
|
278
|
-
console.log(`[voice-call] Notify mode: hanging up call ${call.callId}`);
|
|
279
|
-
await this.endCall(call.callId);
|
|
280
|
-
}
|
|
281
|
-
}, delaySec * 1000);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Start max duration timer for a call.
|
|
287
|
-
* Auto-hangup when maxDurationSeconds is reached.
|
|
288
|
-
*/
|
|
289
|
-
private startMaxDurationTimer(callId: CallId): void {
|
|
290
|
-
// Clear any existing timer
|
|
291
|
-
this.clearMaxDurationTimer(callId);
|
|
292
|
-
|
|
293
|
-
const maxDurationMs = this.config.maxDurationSeconds * 1000;
|
|
294
|
-
console.log(
|
|
295
|
-
`[voice-call] Starting max duration timer (${this.config.maxDurationSeconds}s) for call ${callId}`,
|
|
296
|
-
);
|
|
297
|
-
|
|
298
|
-
const timer = setTimeout(async () => {
|
|
299
|
-
this.maxDurationTimers.delete(callId);
|
|
300
|
-
const call = this.getCall(callId);
|
|
301
|
-
if (call && !TerminalStates.has(call.state)) {
|
|
302
|
-
console.log(
|
|
303
|
-
`[voice-call] Max duration reached (${this.config.maxDurationSeconds}s), ending call ${callId}`,
|
|
304
|
-
);
|
|
305
|
-
call.endReason = "timeout";
|
|
306
|
-
this.persistCallRecord(call);
|
|
307
|
-
await this.endCall(callId);
|
|
308
|
-
}
|
|
309
|
-
}, maxDurationMs);
|
|
310
|
-
|
|
311
|
-
this.maxDurationTimers.set(callId, timer);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Clear max duration timer for a call.
|
|
316
|
-
*/
|
|
317
|
-
private clearMaxDurationTimer(callId: CallId): void {
|
|
318
|
-
const timer = this.maxDurationTimers.get(callId);
|
|
319
|
-
if (timer) {
|
|
320
|
-
clearTimeout(timer);
|
|
321
|
-
this.maxDurationTimers.delete(callId);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
private clearTranscriptWaiter(callId: CallId): void {
|
|
326
|
-
const waiter = this.transcriptWaiters.get(callId);
|
|
327
|
-
if (!waiter) {
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
clearTimeout(waiter.timeout);
|
|
331
|
-
this.transcriptWaiters.delete(callId);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
private rejectTranscriptWaiter(callId: CallId, reason: string): void {
|
|
335
|
-
const waiter = this.transcriptWaiters.get(callId);
|
|
336
|
-
if (!waiter) {
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
this.clearTranscriptWaiter(callId);
|
|
340
|
-
waiter.reject(new Error(reason));
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
private resolveTranscriptWaiter(callId: CallId, transcript: string): void {
|
|
344
|
-
const waiter = this.transcriptWaiters.get(callId);
|
|
345
|
-
if (!waiter) {
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
this.clearTranscriptWaiter(callId);
|
|
349
|
-
waiter.resolve(transcript);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
private waitForFinalTranscript(callId: CallId): Promise<string> {
|
|
353
|
-
// Only allow one in-flight waiter per call.
|
|
354
|
-
this.rejectTranscriptWaiter(callId, "Transcript waiter replaced");
|
|
355
|
-
|
|
356
|
-
const timeoutMs = this.config.transcriptTimeoutMs;
|
|
357
|
-
return new Promise((resolve, reject) => {
|
|
358
|
-
const timeout = setTimeout(() => {
|
|
359
|
-
this.transcriptWaiters.delete(callId);
|
|
360
|
-
reject(new Error(`Timed out waiting for transcript after ${timeoutMs}ms`));
|
|
361
|
-
}, timeoutMs);
|
|
362
|
-
|
|
363
|
-
this.transcriptWaiters.set(callId, { resolve, reject, timeout });
|
|
364
|
-
});
|
|
110
|
+
return speakInitialMessageWithContext(this.getContext(), providerCallId);
|
|
365
111
|
}
|
|
366
112
|
|
|
367
113
|
/**
|
|
@@ -371,307 +117,39 @@ export class CallManager {
|
|
|
371
117
|
callId: CallId,
|
|
372
118
|
prompt: string,
|
|
373
119
|
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
|
374
|
-
|
|
375
|
-
if (!call) {
|
|
376
|
-
return { success: false, error: "Call not found" };
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
if (!this.provider || !call.providerCallId) {
|
|
380
|
-
return { success: false, error: "Call not connected" };
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
if (TerminalStates.has(call.state)) {
|
|
384
|
-
return { success: false, error: "Call has ended" };
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
try {
|
|
388
|
-
await this.speak(callId, prompt);
|
|
389
|
-
|
|
390
|
-
call.state = "listening";
|
|
391
|
-
this.persistCallRecord(call);
|
|
392
|
-
|
|
393
|
-
await this.provider.startListening({
|
|
394
|
-
callId,
|
|
395
|
-
providerCallId: call.providerCallId,
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
const transcript = await this.waitForFinalTranscript(callId);
|
|
399
|
-
|
|
400
|
-
// Best-effort: stop listening after final transcript.
|
|
401
|
-
await this.provider.stopListening({
|
|
402
|
-
callId,
|
|
403
|
-
providerCallId: call.providerCallId,
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
return { success: true, transcript };
|
|
407
|
-
} catch (err) {
|
|
408
|
-
return {
|
|
409
|
-
success: false,
|
|
410
|
-
error: err instanceof Error ? err.message : String(err),
|
|
411
|
-
};
|
|
412
|
-
} finally {
|
|
413
|
-
this.clearTranscriptWaiter(callId);
|
|
414
|
-
}
|
|
120
|
+
return continueCallWithContext(this.getContext(), callId, prompt);
|
|
415
121
|
}
|
|
416
122
|
|
|
417
123
|
/**
|
|
418
124
|
* End an active call.
|
|
419
125
|
*/
|
|
420
126
|
async endCall(callId: CallId): Promise<{ success: boolean; error?: string }> {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
reason: "hangup-bot",
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
call.state = "hangup-bot";
|
|
442
|
-
call.endedAt = Date.now();
|
|
443
|
-
call.endReason = "hangup-bot";
|
|
444
|
-
this.persistCallRecord(call);
|
|
445
|
-
this.clearMaxDurationTimer(callId);
|
|
446
|
-
this.rejectTranscriptWaiter(callId, "Call ended: hangup-bot");
|
|
447
|
-
this.activeCalls.delete(callId);
|
|
448
|
-
if (call.providerCallId) {
|
|
449
|
-
this.providerCallIdMap.delete(call.providerCallId);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
return { success: true };
|
|
453
|
-
} catch (err) {
|
|
454
|
-
return {
|
|
455
|
-
success: false,
|
|
456
|
-
error: err instanceof Error ? err.message : String(err),
|
|
457
|
-
};
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* Check if an inbound call should be accepted based on policy.
|
|
463
|
-
*/
|
|
464
|
-
private shouldAcceptInbound(from: string | undefined): boolean {
|
|
465
|
-
const { inboundPolicy: policy, allowFrom } = this.config;
|
|
466
|
-
|
|
467
|
-
switch (policy) {
|
|
468
|
-
case "disabled":
|
|
469
|
-
console.log("[voice-call] Inbound call rejected: policy is disabled");
|
|
470
|
-
return false;
|
|
471
|
-
|
|
472
|
-
case "open":
|
|
473
|
-
console.log("[voice-call] Inbound call accepted: policy is open");
|
|
474
|
-
return true;
|
|
475
|
-
|
|
476
|
-
case "allowlist":
|
|
477
|
-
case "pairing": {
|
|
478
|
-
const normalized = normalizePhoneNumber(from);
|
|
479
|
-
if (!normalized) {
|
|
480
|
-
console.log("[voice-call] Inbound call rejected: missing caller ID");
|
|
481
|
-
return false;
|
|
482
|
-
}
|
|
483
|
-
const allowed = isAllowlistedCaller(normalized, allowFrom);
|
|
484
|
-
const status = allowed ? "accepted" : "rejected";
|
|
485
|
-
console.log(
|
|
486
|
-
`[voice-call] Inbound call ${status}: ${from} ${allowed ? "is in" : "not in"} allowlist`,
|
|
487
|
-
);
|
|
488
|
-
return allowed;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
default:
|
|
492
|
-
return false;
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
/**
|
|
497
|
-
* Create a call record for an inbound call.
|
|
498
|
-
*/
|
|
499
|
-
private createInboundCall(providerCallId: string, from: string, to: string): CallRecord {
|
|
500
|
-
const callId = crypto.randomUUID();
|
|
501
|
-
|
|
502
|
-
const callRecord: CallRecord = {
|
|
503
|
-
callId,
|
|
504
|
-
providerCallId,
|
|
505
|
-
provider: this.provider?.name || "twilio",
|
|
506
|
-
direction: "inbound",
|
|
507
|
-
state: "ringing",
|
|
508
|
-
from,
|
|
509
|
-
to,
|
|
510
|
-
startedAt: Date.now(),
|
|
511
|
-
transcript: [],
|
|
512
|
-
processedEventIds: [],
|
|
513
|
-
metadata: {
|
|
514
|
-
initialMessage: this.config.inboundGreeting || "Hello! How can I help you today?",
|
|
127
|
+
return endCallWithContext(this.getContext(), callId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private getContext(): CallManagerContext {
|
|
131
|
+
return {
|
|
132
|
+
activeCalls: this.activeCalls,
|
|
133
|
+
providerCallIdMap: this.providerCallIdMap,
|
|
134
|
+
processedEventIds: this.processedEventIds,
|
|
135
|
+
rejectedProviderCallIds: this.rejectedProviderCallIds,
|
|
136
|
+
provider: this.provider,
|
|
137
|
+
config: this.config,
|
|
138
|
+
storePath: this.storePath,
|
|
139
|
+
webhookUrl: this.webhookUrl,
|
|
140
|
+
transcriptWaiters: this.transcriptWaiters,
|
|
141
|
+
maxDurationTimers: this.maxDurationTimers,
|
|
142
|
+
onCallAnswered: (call) => {
|
|
143
|
+
this.maybeSpeakInitialMessageOnAnswered(call);
|
|
515
144
|
},
|
|
516
145
|
};
|
|
517
|
-
|
|
518
|
-
this.activeCalls.set(callId, callRecord);
|
|
519
|
-
this.providerCallIdMap.set(providerCallId, callId); // Map providerCallId to internal callId
|
|
520
|
-
this.persistCallRecord(callRecord);
|
|
521
|
-
|
|
522
|
-
console.log(`[voice-call] Created inbound call record: ${callId} from ${from}`);
|
|
523
|
-
return callRecord;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/**
|
|
527
|
-
* Look up a call by either internal callId or providerCallId.
|
|
528
|
-
*/
|
|
529
|
-
private findCall(callIdOrProviderCallId: string): CallRecord | undefined {
|
|
530
|
-
// Try direct lookup by internal callId
|
|
531
|
-
const directCall = this.activeCalls.get(callIdOrProviderCallId);
|
|
532
|
-
if (directCall) {
|
|
533
|
-
return directCall;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// Try lookup by providerCallId
|
|
537
|
-
return this.getCallByProviderCallId(callIdOrProviderCallId);
|
|
538
146
|
}
|
|
539
147
|
|
|
540
148
|
/**
|
|
541
149
|
* Process a webhook event.
|
|
542
150
|
*/
|
|
543
151
|
processEvent(event: NormalizedEvent): void {
|
|
544
|
-
|
|
545
|
-
if (this.processedEventIds.has(event.id)) {
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
this.processedEventIds.add(event.id);
|
|
549
|
-
|
|
550
|
-
let call = this.findCall(event.callId);
|
|
551
|
-
|
|
552
|
-
// Handle inbound calls - create record if it doesn't exist
|
|
553
|
-
if (!call && event.direction === "inbound" && event.providerCallId) {
|
|
554
|
-
// Check if we should accept this inbound call
|
|
555
|
-
if (!this.shouldAcceptInbound(event.from)) {
|
|
556
|
-
void this.rejectInboundCall(event);
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// Create a new call record for this inbound call
|
|
561
|
-
call = this.createInboundCall(
|
|
562
|
-
event.providerCallId,
|
|
563
|
-
event.from || "unknown",
|
|
564
|
-
event.to || this.config.fromNumber || "unknown",
|
|
565
|
-
);
|
|
566
|
-
|
|
567
|
-
// Update the event's callId to use our internal ID
|
|
568
|
-
event.callId = call.callId;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
if (!call) {
|
|
572
|
-
// Still no call record - ignore event
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// Update provider call ID if we got it
|
|
577
|
-
if (event.providerCallId && event.providerCallId !== call.providerCallId) {
|
|
578
|
-
const previousProviderCallId = call.providerCallId;
|
|
579
|
-
call.providerCallId = event.providerCallId;
|
|
580
|
-
this.providerCallIdMap.set(event.providerCallId, call.callId);
|
|
581
|
-
if (previousProviderCallId) {
|
|
582
|
-
const mapped = this.providerCallIdMap.get(previousProviderCallId);
|
|
583
|
-
if (mapped === call.callId) {
|
|
584
|
-
this.providerCallIdMap.delete(previousProviderCallId);
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// Track processed event
|
|
590
|
-
call.processedEventIds.push(event.id);
|
|
591
|
-
|
|
592
|
-
// Process event based on type
|
|
593
|
-
switch (event.type) {
|
|
594
|
-
case "call.initiated":
|
|
595
|
-
this.transitionState(call, "initiated");
|
|
596
|
-
break;
|
|
597
|
-
|
|
598
|
-
case "call.ringing":
|
|
599
|
-
this.transitionState(call, "ringing");
|
|
600
|
-
break;
|
|
601
|
-
|
|
602
|
-
case "call.answered":
|
|
603
|
-
call.answeredAt = event.timestamp;
|
|
604
|
-
this.transitionState(call, "answered");
|
|
605
|
-
// Start max duration timer when call is answered
|
|
606
|
-
this.startMaxDurationTimer(call.callId);
|
|
607
|
-
// Best-effort: speak initial message (for inbound greetings and outbound
|
|
608
|
-
// conversation mode) once the call is answered.
|
|
609
|
-
this.maybeSpeakInitialMessageOnAnswered(call);
|
|
610
|
-
break;
|
|
611
|
-
|
|
612
|
-
case "call.active":
|
|
613
|
-
this.transitionState(call, "active");
|
|
614
|
-
break;
|
|
615
|
-
|
|
616
|
-
case "call.speaking":
|
|
617
|
-
this.transitionState(call, "speaking");
|
|
618
|
-
break;
|
|
619
|
-
|
|
620
|
-
case "call.speech":
|
|
621
|
-
if (event.isFinal) {
|
|
622
|
-
this.addTranscriptEntry(call, "user", event.transcript);
|
|
623
|
-
this.resolveTranscriptWaiter(call.callId, event.transcript);
|
|
624
|
-
}
|
|
625
|
-
this.transitionState(call, "listening");
|
|
626
|
-
break;
|
|
627
|
-
|
|
628
|
-
case "call.ended":
|
|
629
|
-
call.endedAt = event.timestamp;
|
|
630
|
-
call.endReason = event.reason;
|
|
631
|
-
this.transitionState(call, event.reason as CallState);
|
|
632
|
-
this.clearMaxDurationTimer(call.callId);
|
|
633
|
-
this.rejectTranscriptWaiter(call.callId, `Call ended: ${event.reason}`);
|
|
634
|
-
this.activeCalls.delete(call.callId);
|
|
635
|
-
if (call.providerCallId) {
|
|
636
|
-
this.providerCallIdMap.delete(call.providerCallId);
|
|
637
|
-
}
|
|
638
|
-
break;
|
|
639
|
-
|
|
640
|
-
case "call.error":
|
|
641
|
-
if (!event.retryable) {
|
|
642
|
-
call.endedAt = event.timestamp;
|
|
643
|
-
call.endReason = "error";
|
|
644
|
-
this.transitionState(call, "error");
|
|
645
|
-
this.clearMaxDurationTimer(call.callId);
|
|
646
|
-
this.rejectTranscriptWaiter(call.callId, `Call error: ${event.error}`);
|
|
647
|
-
this.activeCalls.delete(call.callId);
|
|
648
|
-
if (call.providerCallId) {
|
|
649
|
-
this.providerCallIdMap.delete(call.providerCallId);
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
break;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
this.persistCallRecord(call);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
private async rejectInboundCall(event: NormalizedEvent): Promise<void> {
|
|
659
|
-
if (!this.provider || !event.providerCallId) {
|
|
660
|
-
return;
|
|
661
|
-
}
|
|
662
|
-
const callId = event.callId || event.providerCallId;
|
|
663
|
-
try {
|
|
664
|
-
await this.provider.hangupCall({
|
|
665
|
-
callId,
|
|
666
|
-
providerCallId: event.providerCallId,
|
|
667
|
-
reason: "hangup-bot",
|
|
668
|
-
});
|
|
669
|
-
} catch (err) {
|
|
670
|
-
console.warn(
|
|
671
|
-
`[voice-call] Failed to reject inbound call ${event.providerCallId}:`,
|
|
672
|
-
err instanceof Error ? err.message : err,
|
|
673
|
-
);
|
|
674
|
-
}
|
|
152
|
+
processManagerEvent(this.getContext(), event);
|
|
675
153
|
}
|
|
676
154
|
|
|
677
155
|
private maybeSpeakInitialMessageOnAnswered(call: CallRecord): void {
|
|
@@ -706,20 +184,11 @@ export class CallManager {
|
|
|
706
184
|
* Get an active call by provider call ID (e.g., Twilio CallSid).
|
|
707
185
|
*/
|
|
708
186
|
getCallByProviderCallId(providerCallId: string): CallRecord | undefined {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
// Fallback: linear search for cases where map wasn't populated
|
|
716
|
-
// (e.g., providerCallId set directly on call record)
|
|
717
|
-
for (const call of this.activeCalls.values()) {
|
|
718
|
-
if (call.providerCallId === providerCallId) {
|
|
719
|
-
return call;
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
return undefined;
|
|
187
|
+
return getCallByProviderCallIdFromMaps({
|
|
188
|
+
activeCalls: this.activeCalls,
|
|
189
|
+
providerCallIdMap: this.providerCallIdMap,
|
|
190
|
+
providerCallId,
|
|
191
|
+
});
|
|
723
192
|
}
|
|
724
193
|
|
|
725
194
|
/**
|
|
@@ -733,155 +202,6 @@ export class CallManager {
|
|
|
733
202
|
* Get call history (from persisted logs).
|
|
734
203
|
*/
|
|
735
204
|
async getCallHistory(limit = 50): Promise<CallRecord[]> {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
try {
|
|
739
|
-
await fsp.access(logPath);
|
|
740
|
-
} catch {
|
|
741
|
-
return [];
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
const content = await fsp.readFile(logPath, "utf-8");
|
|
745
|
-
const lines = content.trim().split("\n").filter(Boolean);
|
|
746
|
-
const calls: CallRecord[] = [];
|
|
747
|
-
|
|
748
|
-
// Parse last N lines
|
|
749
|
-
for (const line of lines.slice(-limit)) {
|
|
750
|
-
try {
|
|
751
|
-
const parsed = CallRecordSchema.parse(JSON.parse(line));
|
|
752
|
-
calls.push(parsed);
|
|
753
|
-
} catch {
|
|
754
|
-
// Skip invalid lines
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
return calls;
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
// States that can cycle during multi-turn conversations
|
|
762
|
-
private static readonly ConversationStates = new Set<CallState>(["speaking", "listening"]);
|
|
763
|
-
|
|
764
|
-
// Non-terminal state order for monotonic transitions
|
|
765
|
-
private static readonly StateOrder: readonly CallState[] = [
|
|
766
|
-
"initiated",
|
|
767
|
-
"ringing",
|
|
768
|
-
"answered",
|
|
769
|
-
"active",
|
|
770
|
-
"speaking",
|
|
771
|
-
"listening",
|
|
772
|
-
];
|
|
773
|
-
|
|
774
|
-
/**
|
|
775
|
-
* Transition call state with monotonic enforcement.
|
|
776
|
-
*/
|
|
777
|
-
private transitionState(call: CallRecord, newState: CallState): void {
|
|
778
|
-
// No-op for same state or already terminal
|
|
779
|
-
if (call.state === newState || TerminalStates.has(call.state)) {
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
// Terminal states can always be reached from non-terminal
|
|
784
|
-
if (TerminalStates.has(newState)) {
|
|
785
|
-
call.state = newState;
|
|
786
|
-
return;
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
// Allow cycling between speaking and listening (multi-turn conversations)
|
|
790
|
-
if (
|
|
791
|
-
CallManager.ConversationStates.has(call.state) &&
|
|
792
|
-
CallManager.ConversationStates.has(newState)
|
|
793
|
-
) {
|
|
794
|
-
call.state = newState;
|
|
795
|
-
return;
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// Only allow forward transitions in state order
|
|
799
|
-
const currentIndex = CallManager.StateOrder.indexOf(call.state);
|
|
800
|
-
const newIndex = CallManager.StateOrder.indexOf(newState);
|
|
801
|
-
|
|
802
|
-
if (newIndex > currentIndex) {
|
|
803
|
-
call.state = newState;
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
/**
|
|
808
|
-
* Add an entry to the call transcript.
|
|
809
|
-
*/
|
|
810
|
-
private addTranscriptEntry(call: CallRecord, speaker: "bot" | "user", text: string): void {
|
|
811
|
-
const entry: TranscriptEntry = {
|
|
812
|
-
timestamp: Date.now(),
|
|
813
|
-
speaker,
|
|
814
|
-
text,
|
|
815
|
-
isFinal: true,
|
|
816
|
-
};
|
|
817
|
-
call.transcript.push(entry);
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
/**
|
|
821
|
-
* Persist a call record to disk (fire-and-forget async).
|
|
822
|
-
*/
|
|
823
|
-
private persistCallRecord(call: CallRecord): void {
|
|
824
|
-
const logPath = path.join(this.storePath, "calls.jsonl");
|
|
825
|
-
const line = `${JSON.stringify(call)}\n`;
|
|
826
|
-
// Fire-and-forget async write to avoid blocking event loop
|
|
827
|
-
fsp.appendFile(logPath, line).catch((err) => {
|
|
828
|
-
console.error("[voice-call] Failed to persist call record:", err);
|
|
829
|
-
});
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
/**
|
|
833
|
-
* Load active calls from persistence (for crash recovery).
|
|
834
|
-
* Uses streaming to handle large log files efficiently.
|
|
835
|
-
*/
|
|
836
|
-
private loadActiveCalls(): void {
|
|
837
|
-
const logPath = path.join(this.storePath, "calls.jsonl");
|
|
838
|
-
if (!fs.existsSync(logPath)) {
|
|
839
|
-
return;
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
// Read file synchronously and parse lines
|
|
843
|
-
const content = fs.readFileSync(logPath, "utf-8");
|
|
844
|
-
const lines = content.split("\n");
|
|
845
|
-
|
|
846
|
-
// Build map of latest state per call
|
|
847
|
-
const callMap = new Map<CallId, CallRecord>();
|
|
848
|
-
|
|
849
|
-
for (const line of lines) {
|
|
850
|
-
if (!line.trim()) {
|
|
851
|
-
continue;
|
|
852
|
-
}
|
|
853
|
-
try {
|
|
854
|
-
const call = CallRecordSchema.parse(JSON.parse(line));
|
|
855
|
-
callMap.set(call.callId, call);
|
|
856
|
-
} catch {
|
|
857
|
-
// Skip invalid lines
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
// Only keep non-terminal calls
|
|
862
|
-
for (const [callId, call] of callMap) {
|
|
863
|
-
if (!TerminalStates.has(call.state)) {
|
|
864
|
-
this.activeCalls.set(callId, call);
|
|
865
|
-
// Populate providerCallId mapping for lookups
|
|
866
|
-
if (call.providerCallId) {
|
|
867
|
-
this.providerCallIdMap.set(call.providerCallId, callId);
|
|
868
|
-
}
|
|
869
|
-
// Populate processed event IDs
|
|
870
|
-
for (const eventId of call.processedEventIds) {
|
|
871
|
-
this.processedEventIds.add(eventId);
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
/**
|
|
878
|
-
* Generate TwiML for notify mode (speak message and hang up).
|
|
879
|
-
*/
|
|
880
|
-
private generateNotifyTwiml(message: string, voice: string): string {
|
|
881
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
882
|
-
<Response>
|
|
883
|
-
<Say voice="${voice}">${escapeXml(message)}</Say>
|
|
884
|
-
<Hangup/>
|
|
885
|
-
</Response>`;
|
|
205
|
+
return getCallHistoryFromStore(this.storePath, limit);
|
|
886
206
|
}
|
|
887
207
|
}
|