diff --git a/extensions/voice-call/src/manager.ts b/extensions/voice-call/src/manager.ts index d8ece7c0016..afe3e98638e 100644 --- a/extensions/voice-call/src/manager.ts +++ b/extensions/voice-call/src/manager.ts @@ -20,6 +20,7 @@ import { loadActiveCallsFromStore, persistCallRecord, } from "./manager/store.js"; +import { resolveVoiceCallSecondsTimerDelayMs } from "./manager/timer-delays.js"; import { startMaxDurationTimer } from "./manager/timers.js"; import type { VoiceCallProvider } from "./providers/base.js"; import { @@ -129,7 +130,7 @@ export class CallManager { for (const [callId, call] of verified) { if (call.answeredAt && !TerminalStates.has(call.state)) { const elapsed = Date.now() - call.answeredAt; - const maxDurationMs = this.config.maxDurationSeconds * 1000; + const maxDurationMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds); if (elapsed >= maxDurationMs) { // Already expired — remove instead of keeping verified.delete(callId); @@ -174,7 +175,7 @@ export class CallManager { return new Map(); } - const maxAgeMs = this.config.maxDurationSeconds * 1000; + const maxAgeMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds); const now = Date.now(); const verified = new Map(); const verifyTasks: Array<{ callId: CallId; call: CallRecord; promise: Promise }> = []; diff --git a/extensions/voice-call/src/manager/outbound.test.ts b/extensions/voice-call/src/manager/outbound.test.ts index ee59f62a724..231486feba6 100644 --- a/extensions/voice-call/src/manager/outbound.test.ts +++ b/extensions/voice-call/src/manager/outbound.test.ts @@ -1,3 +1,4 @@ +import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { beforeEach, describe, expect, it, vi } from "vitest"; const { @@ -51,7 +52,7 @@ vi.mock("./twiml.js", () => ({ generateNotifyTwiml: generateNotifyTwimlMock, })); -import { endCall, initiateCall, sendDtmf, speak } from "./outbound.js"; +import { endCall, initiateCall, sendDtmf, speak, speakInitialMessage } from "./outbound.js"; function createActiveCallContext(params: { hangupCall?: ReturnType } = {}) { const call = { callId: "call-1", providerCallId: "provider-1", state: "active" }; @@ -359,6 +360,38 @@ describe("voice-call outbound helpers", () => { }); }); + it("caps notify-mode auto-hangup delay before scheduling", async () => { + const call = { + callId: "call-1", + providerCallId: "provider-1", + state: "active", + metadata: { initialMessage: "hello", mode: "notify" }, + }; + const playTts = vi.fn(async () => {}); + const timeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockReturnValue(1 as unknown as ReturnType); + getCallByProviderCallIdMock.mockReturnValue(call); + const ctx = { + activeCalls: new Map([["call-1", call]]), + providerCallIdMap: new Map([["provider-1", "call-1"]]), + provider: { name: "twilio", playTts }, + initialMessageInFlight: new Set(), + config: { + outbound: { notifyHangupDelaySec: Number.MAX_SAFE_INTEGER }, + tts: { provider: "openai", providers: { openai: { voice: "alloy" } } }, + }, + storePath: "/tmp/voice-call.json", + }; + + try { + await speakInitialMessage(ctx as never, "provider-1"); + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS); + } finally { + timeoutSpy.mockRestore(); + } + }); + it("uses per-number route TTS voice for routed inbound calls", async () => { const call = { callId: "call-1", diff --git a/extensions/voice-call/src/manager/outbound.ts b/extensions/voice-call/src/manager/outbound.ts index a41010ee0ab..5d29e565475 100644 --- a/extensions/voice-call/src/manager/outbound.ts +++ b/extensions/voice-call/src/manager/outbound.ts @@ -19,6 +19,7 @@ import { finalizeCall } from "./lifecycle.js"; import { getCallByProviderCallId } from "./lookup.js"; import { addTranscriptEntry, transitionState } from "./state.js"; import { persistCallRecord } from "./store.js"; +import { resolveVoiceCallSecondsTimerDelayMs } from "./timer-delays.js"; import { clearTranscriptWaiter, waitForFinalTranscript } from "./timers.js"; import { generateDtmfRedirectTwiml, generateNotifyTwiml } from "./twiml.js"; @@ -380,6 +381,7 @@ export async function speakInitialMessage( if (mode === "notify") { const delaySec = ctx.config.outbound.notifyHangupDelaySec; + const delayMs = resolveVoiceCallSecondsTimerDelayMs(delaySec, 0); console.log(`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`); setTimeout(async () => { const currentCall = ctx.activeCalls.get(call.callId); @@ -387,7 +389,7 @@ export async function speakInitialMessage( console.log(`[voice-call] Notify mode: hanging up call ${call.callId}`); await endCall(ctx, call.callId); } - }, delaySec * 1000); + }, delayMs); } else if ( mode === "conversation" && ctx.provider && diff --git a/extensions/voice-call/src/manager/timer-delays.test.ts b/extensions/voice-call/src/manager/timer-delays.test.ts new file mode 100644 index 00000000000..3b6de3268e6 --- /dev/null +++ b/extensions/voice-call/src/manager/timer-delays.test.ts @@ -0,0 +1,17 @@ +import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; +import { describe, expect, it } from "vitest"; +import { + resolveVoiceCallSecondsTimerDelayMs, + resolveVoiceCallTimerDelayMs, +} from "./timer-delays.js"; + +describe("voice-call timer delays", () => { + it("caps second-based delays to timer-safe milliseconds", () => { + expect(resolveVoiceCallSecondsTimerDelayMs(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIMER_TIMEOUT_MS); + expect(resolveVoiceCallSecondsTimerDelayMs(Number.MAX_VALUE)).toBe(MAX_TIMER_TIMEOUT_MS); + }); + + it("caps millisecond delays to timer-safe values", () => { + expect(resolveVoiceCallTimerDelayMs(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIMER_TIMEOUT_MS); + }); +}); diff --git a/extensions/voice-call/src/manager/timer-delays.ts b/extensions/voice-call/src/manager/timer-delays.ts new file mode 100644 index 00000000000..3d279b42ea9 --- /dev/null +++ b/extensions/voice-call/src/manager/timer-delays.ts @@ -0,0 +1,17 @@ +import { MAX_TIMER_TIMEOUT_MS, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; + +export function resolveVoiceCallSecondsTimerDelayMs(seconds: number, minMs = 1): number { + if (!Number.isFinite(seconds)) { + return resolveTimerTimeoutMs(MAX_TIMER_TIMEOUT_MS, MAX_TIMER_TIMEOUT_MS, minMs); + } + const timeoutMs = Math.floor(seconds * 1000); + return resolveTimerTimeoutMs( + Number.isFinite(timeoutMs) ? timeoutMs : MAX_TIMER_TIMEOUT_MS, + minMs, + minMs, + ); +} + +export function resolveVoiceCallTimerDelayMs(timeoutMs: number, fallbackMs = 1): number { + return resolveTimerTimeoutMs(timeoutMs, fallbackMs); +} diff --git a/extensions/voice-call/src/manager/timers.test.ts b/extensions/voice-call/src/manager/timers.test.ts index 99136619d13..0f22e5be238 100644 --- a/extensions/voice-call/src/manager/timers.test.ts +++ b/extensions/voice-call/src/manager/timers.test.ts @@ -1,3 +1,4 @@ +import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { persistCallRecordMock } = vi.hoisted(() => ({ @@ -82,6 +83,38 @@ describe("voice-call manager timers", () => { expect(onTimeout).not.toHaveBeenCalled(); }); + it("caps oversized max duration and transcript timers", () => { + const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const ctx = { + activeCalls: new Map([["call-1", { id: "call-1", state: "active" }]]), + maxDurationTimers: new Map(), + transcriptWaiters: new Map(), + config: { + maxDurationSeconds: Number.MAX_SAFE_INTEGER, + transcriptTimeoutMs: Number.MAX_SAFE_INTEGER, + }, + storePath: "/tmp/voice-call", + }; + + try { + startMaxDurationTimer({ + ctx: ctx as never, + callId: "call-1", + onTimeout: vi.fn(async () => {}), + }); + const transcript = waitForFinalTranscript(ctx as never, "call-2"); + + expect( + timeoutSpy.mock.calls.filter(([, delay]) => delay === MAX_TIMER_TIMEOUT_MS), + ).toHaveLength(2); + clearMaxDurationTimer(ctx as never, "call-1"); + rejectTranscriptWaiter(ctx as never, "call-2", "done"); + void transcript.catch(() => {}); + } finally { + timeoutSpy.mockRestore(); + } + }); + it("waits for transcripts, resolves matching tokens, rejects mismatches and timeouts", async () => { const ctx = { transcriptWaiters: new Map(), diff --git a/extensions/voice-call/src/manager/timers.ts b/extensions/voice-call/src/manager/timers.ts index b086e0dec9e..45568ac94d2 100644 --- a/extensions/voice-call/src/manager/timers.ts +++ b/extensions/voice-call/src/manager/timers.ts @@ -1,6 +1,10 @@ import { TerminalStates, type CallId } from "../types.js"; import type { CallManagerContext } from "./context.js"; import { persistCallRecord } from "./store.js"; +import { + resolveVoiceCallSecondsTimerDelayMs, + resolveVoiceCallTimerDelayMs, +} from "./timer-delays.js"; type TimerContext = Pick< CallManagerContext, @@ -31,7 +35,10 @@ export function startMaxDurationTimer(params: { }): void { clearMaxDurationTimer(params.ctx, params.callId); - const maxDurationMs = params.timeoutMs ?? params.ctx.config.maxDurationSeconds * 1000; + const maxDurationMs = + params.timeoutMs === undefined + ? resolveVoiceCallSecondsTimerDelayMs(params.ctx.config.maxDurationSeconds) + : resolveVoiceCallTimerDelayMs(params.timeoutMs); console.log( `[voice-call] Starting max duration timer (${Math.ceil(maxDurationMs / 1000)}s) for call ${params.callId}`, ); @@ -101,7 +108,7 @@ export function waitForFinalTranscript( return Promise.reject(new Error("Already waiting for transcript")); } - const timeoutMs = ctx.config.transcriptTimeoutMs; + const timeoutMs = resolveVoiceCallTimerDelayMs(ctx.config.transcriptTimeoutMs); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { ctx.transcriptWaiters.delete(callId);