@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +13 -9
  3. package/index.ts +45 -49
  4. package/openclaw.plugin.json +11 -53
  5. package/package.json +6 -3
  6. package/src/cli.ts +80 -113
  7. package/src/config.test.ts +1 -4
  8. package/src/config.ts +88 -110
  9. package/src/core-bridge.ts +14 -12
  10. package/src/manager/context.ts +1 -1
  11. package/src/manager/events.ts +18 -9
  12. package/src/manager/lookup.ts +3 -1
  13. package/src/manager/outbound.ts +46 -19
  14. package/src/manager/state.ts +4 -6
  15. package/src/manager/store.ts +6 -3
  16. package/src/manager/timers.ts +11 -8
  17. package/src/manager.test.ts +7 -10
  18. package/src/manager.ts +53 -75
  19. package/src/media-stream.test.ts +0 -1
  20. package/src/media-stream.ts +12 -26
  21. package/src/providers/mock.ts +13 -16
  22. package/src/providers/plivo.test.ts +0 -1
  23. package/src/providers/plivo.ts +27 -29
  24. package/src/providers/stt-openai-realtime.ts +8 -8
  25. package/src/providers/telnyx.ts +5 -11
  26. package/src/providers/tts-openai.ts +9 -14
  27. package/src/providers/twilio/api.ts +9 -12
  28. package/src/providers/twilio/webhook.ts +2 -4
  29. package/src/providers/twilio.test.ts +1 -5
  30. package/src/providers/twilio.ts +34 -46
  31. package/src/response-generator.ts +7 -20
  32. package/src/runtime.ts +12 -25
  33. package/src/telephony-audio.ts +14 -12
  34. package/src/telephony-tts.ts +21 -12
  35. package/src/tunnel.ts +7 -24
  36. package/src/types.ts +0 -1
  37. package/src/utils.ts +3 -1
  38. package/src/voice-mapping.ts +3 -1
  39. package/src/webhook-security.test.ts +12 -21
  40. package/src/webhook-security.ts +25 -29
  41. package/src/webhook.ts +22 -57
@@ -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(config: CallManagerContext["config"], from: string | undefined): boolean {
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)) return;
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) return;
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) ctx.providerCallIdMap.delete(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) ctx.providerCallIdMap.delete(call.providerCallId);
178
+ if (call.providerCallId) {
179
+ ctx.providerCallIdMap.delete(call.providerCallId);
180
+ }
172
181
  }
173
182
  break;
174
183
  }
@@ -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) return directCall;
27
+ if (directCall) {
28
+ return directCall;
29
+ }
28
30
  return getCallByProviderCallId({
29
31
  activeCalls: params.activeCalls,
30
32
  providerCallIdMap: params.providerCallIdMap,
@@ -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 { clearMaxDurationTimer, clearTranscriptWaiter, rejectTranscriptWaiter, waitForFinalTranscript } from "./timers.js";
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) return { success: false, error: "Call not found" };
114
- if (!ctx.provider || !call.providerCallId) return { success: false, error: "Call not connected" };
115
- if (TerminalStates.has(call.state)) return { success: false, error: "Call has ended" };
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) return { success: false, error: "Call not found" };
193
- if (!ctx.provider || !call.providerCallId) return { success: false, error: "Call not connected" };
194
- if (TerminalStates.has(call.state)) return { success: false, error: "Call has ended" };
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) return { success: false, error: "Call not found" };
223
- if (!ctx.provider || !call.providerCallId) return { success: false, error: "Call not connected" };
224
- if (TerminalStates.has(call.state)) return { success: true };
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) ctx.providerCallIdMap.delete(call.providerCallId);
267
+ if (call.providerCallId) {
268
+ ctx.providerCallIdMap.delete(call.providerCallId);
269
+ }
243
270
 
244
271
  return { success: true };
245
272
  } catch (err) {
@@ -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)) return;
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,
@@ -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()) continue;
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)) continue;
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);
@@ -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) return;
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) return;
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) return;
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
 
@@ -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 type { VoiceCallProvider } from "./providers/base.js";
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
- "+15550000002",
90
- undefined,
91
- { message: "Hello there", mode: "notify" },
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) return resolveUserPath(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) return;
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) return;
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) return;
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) return 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) return;
660
+ if (!initialMessage) {
661
+ return;
662
+ }
688
663
 
689
- if (!this.provider || !call.providerCallId) return;
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") return;
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)) return;
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)) return;
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()) continue;
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);
@@ -1,5 +1,4 @@
1
1
  import { describe, expect, it } from "vitest";
2
-
3
2
  import type {
4
3
  OpenAIRealtimeSTTProvider,
5
4
  RealtimeSTTSession,