@clawdbot/voice-call 0.1.0

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