mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 17:34:07 +00:00
fix(voice-call): cap manager timer delays
This commit is contained in:
@@ -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<CallId, CallRecord>();
|
||||
const verifyTasks: Array<{ callId: CallId; call: CallRecord; promise: Promise<void> }> = [];
|
||||
|
||||
@@ -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<typeof vi.fn> } = {}) {
|
||||
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<typeof setTimeout>);
|
||||
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",
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
17
extensions/voice-call/src/manager/timer-delays.test.ts
Normal file
17
extensions/voice-call/src/manager/timer-delays.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
17
extensions/voice-call/src/manager/timer-delays.ts
Normal file
17
extensions/voice-call/src/manager/timer-delays.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user