@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/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 { CallMode, VoiceCallConfig } from "./config.js";
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 { isAllowlistedCaller, normalizePhoneNumber } from "./allowlist.js";
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
- type CallId,
11
- type CallRecord,
12
- CallRecordSchema,
13
- type CallState,
14
- type NormalizedEvent,
15
- type OutboundCallOptions,
16
- TerminalStates,
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 machine, persistence, and provider coordination.
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>(); // providerCallId -> internal 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
- // Load any persisted active calls
79
- this.loadActiveCalls();
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
- // Support legacy string argument for initialMessage
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
- const call = this.activeCalls.get(callId);
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
- const call = this.getCallByProviderCallId(providerCallId);
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
- const call = this.activeCalls.get(callId);
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
- const call = this.activeCalls.get(callId);
422
- if (!call) {
423
- return { success: false, error: "Call not found" };
424
- }
425
-
426
- if (!this.provider || !call.providerCallId) {
427
- return { success: false, error: "Call not connected" };
428
- }
429
-
430
- if (TerminalStates.has(call.state)) {
431
- return { success: true }; // Already ended
432
- }
433
-
434
- try {
435
- await this.provider.hangupCall({
436
- callId,
437
- providerCallId: call.providerCallId,
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
- // Idempotency check
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
- // Fast path: use the providerCallIdMap for O(1) lookup
710
- const callId = this.providerCallIdMap.get(providerCallId);
711
- if (callId) {
712
- return this.activeCalls.get(callId);
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
- const logPath = path.join(this.storePath, "calls.jsonl");
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
  }