mirror of https://github.com/openclaw/openclaw.git
fix: dedupe voice call lifecycle cleanup
This commit is contained in:
parent
0825ff9619
commit
07d386c2bb
|
|
@ -116,7 +116,7 @@ export class CallManager {
|
|||
ctx: this.getContext(),
|
||||
callId,
|
||||
onTimeout: async (id) => {
|
||||
await endCallWithContext(this.getContext(), id);
|
||||
await endCallWithContext(this.getContext(), id, { reason: "timeout" });
|
||||
},
|
||||
});
|
||||
console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`);
|
||||
|
|
|
|||
|
|
@ -2,16 +2,12 @@ import crypto from "node:crypto";
|
|||
import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js";
|
||||
import type { CallRecord, CallState, NormalizedEvent } from "../types.js";
|
||||
import type { CallManagerContext } from "./context.js";
|
||||
import { finalizeCall } from "./lifecycle.js";
|
||||
import { findCall } from "./lookup.js";
|
||||
import { endCall } from "./outbound.js";
|
||||
import { addTranscriptEntry, transitionState } from "./state.js";
|
||||
import { persistCallRecord } from "./store.js";
|
||||
import {
|
||||
clearMaxDurationTimer,
|
||||
rejectTranscriptWaiter,
|
||||
resolveTranscriptWaiter,
|
||||
startMaxDurationTimer,
|
||||
} from "./timers.js";
|
||||
import { resolveTranscriptWaiter, startMaxDurationTimer } from "./timers.js";
|
||||
|
||||
type EventContext = Pick<
|
||||
CallManagerContext,
|
||||
|
|
@ -193,7 +189,7 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
|
|||
ctx,
|
||||
callId: call.callId,
|
||||
onTimeout: async (callId) => {
|
||||
await endCall(ctx, callId);
|
||||
await endCall(ctx, callId, { reason: "timeout" });
|
||||
},
|
||||
});
|
||||
ctx.onCallAnswered?.(call);
|
||||
|
|
@ -228,28 +224,24 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
|
|||
break;
|
||||
|
||||
case "call.ended":
|
||||
call.endedAt = event.timestamp;
|
||||
call.endReason = event.reason;
|
||||
transitionState(call, event.reason as CallState);
|
||||
clearMaxDurationTimer(ctx, call.callId);
|
||||
rejectTranscriptWaiter(ctx, call.callId, `Call ended: ${event.reason}`);
|
||||
ctx.activeCalls.delete(call.callId);
|
||||
if (call.providerCallId) {
|
||||
ctx.providerCallIdMap.delete(call.providerCallId);
|
||||
}
|
||||
break;
|
||||
finalizeCall({
|
||||
ctx,
|
||||
call,
|
||||
endReason: event.reason,
|
||||
endedAt: event.timestamp,
|
||||
});
|
||||
return;
|
||||
|
||||
case "call.error":
|
||||
if (!event.retryable) {
|
||||
call.endedAt = event.timestamp;
|
||||
call.endReason = "error";
|
||||
transitionState(call, "error");
|
||||
clearMaxDurationTimer(ctx, call.callId);
|
||||
rejectTranscriptWaiter(ctx, call.callId, `Call error: ${event.error}`);
|
||||
ctx.activeCalls.delete(call.callId);
|
||||
if (call.providerCallId) {
|
||||
ctx.providerCallIdMap.delete(call.providerCallId);
|
||||
}
|
||||
finalizeCall({
|
||||
ctx,
|
||||
call,
|
||||
endReason: "error",
|
||||
endedAt: event.timestamp,
|
||||
transcriptRejectReason: `Call error: ${event.error}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import type { CallRecord, EndReason } from "../types.js";
|
||||
import type { CallManagerContext } from "./context.js";
|
||||
import { transitionState } from "./state.js";
|
||||
import { persistCallRecord } from "./store.js";
|
||||
import { clearMaxDurationTimer, rejectTranscriptWaiter } from "./timers.js";
|
||||
|
||||
type CallLifecycleContext = Pick<
|
||||
CallManagerContext,
|
||||
"activeCalls" | "providerCallIdMap" | "storePath"
|
||||
> &
|
||||
Partial<Pick<CallManagerContext, "transcriptWaiters" | "maxDurationTimers">>;
|
||||
|
||||
function removeProviderCallMapping(
|
||||
providerCallIdMap: Map<string, string>,
|
||||
call: Pick<CallRecord, "callId" | "providerCallId">,
|
||||
): void {
|
||||
if (!call.providerCallId) {
|
||||
return;
|
||||
}
|
||||
const mappedCallId = providerCallIdMap.get(call.providerCallId);
|
||||
if (mappedCallId === call.callId) {
|
||||
providerCallIdMap.delete(call.providerCallId);
|
||||
}
|
||||
}
|
||||
|
||||
export function finalizeCall(params: {
|
||||
ctx: CallLifecycleContext;
|
||||
call: CallRecord;
|
||||
endReason: EndReason;
|
||||
endedAt?: number;
|
||||
transcriptRejectReason?: string;
|
||||
}): void {
|
||||
const { ctx, call, endReason } = params;
|
||||
|
||||
call.endedAt = params.endedAt ?? Date.now();
|
||||
call.endReason = endReason;
|
||||
transitionState(call, endReason);
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
|
||||
if (ctx.maxDurationTimers) {
|
||||
clearMaxDurationTimer({ maxDurationTimers: ctx.maxDurationTimers }, call.callId);
|
||||
}
|
||||
if (ctx.transcriptWaiters) {
|
||||
rejectTranscriptWaiter(
|
||||
{ transcriptWaiters: ctx.transcriptWaiters },
|
||||
call.callId,
|
||||
params.transcriptRejectReason ?? `Call ended: ${endReason}`,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.activeCalls.delete(call.callId);
|
||||
removeProviderCallMapping(ctx.providerCallIdMap, call);
|
||||
}
|
||||
|
|
@ -231,10 +231,11 @@ describe("voice-call outbound helpers", () => {
|
|||
});
|
||||
expect(call).toEqual(
|
||||
expect.objectContaining({
|
||||
state: "hangup-bot",
|
||||
endReason: "hangup-bot",
|
||||
endedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(transitionStateMock).toHaveBeenCalledWith(call, "hangup-bot");
|
||||
expect(clearMaxDurationTimerMock).toHaveBeenCalledWith(ctx, "call-1");
|
||||
expect(rejectTranscriptWaiterMock).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
|
|
@ -245,6 +246,36 @@ describe("voice-call outbound helpers", () => {
|
|||
expect(ctx.providerCallIdMap.size).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves timeout reasons when ending timed out calls", async () => {
|
||||
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
|
||||
const hangupCall = vi.fn(async () => {});
|
||||
const ctx = {
|
||||
activeCalls: new Map([["call-1", call]]),
|
||||
providerCallIdMap: new Map([["provider-1", "call-1"]]),
|
||||
provider: { hangupCall },
|
||||
storePath: "/tmp/voice-call.json",
|
||||
transcriptWaiters: new Map(),
|
||||
maxDurationTimers: new Map(),
|
||||
};
|
||||
|
||||
await expect(endCall(ctx as never, "call-1", { reason: "timeout" })).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(hangupCall).toHaveBeenCalledWith({
|
||||
callId: "call-1",
|
||||
providerCallId: "provider-1",
|
||||
reason: "timeout",
|
||||
});
|
||||
expect(call).toEqual(
|
||||
expect.objectContaining({
|
||||
endReason: "timeout",
|
||||
endedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(transitionStateMock).toHaveBeenCalledWith(call, "timeout");
|
||||
expect(rejectTranscriptWaiterMock).toHaveBeenCalledWith(ctx, "call-1", "Call ended: timeout");
|
||||
});
|
||||
|
||||
it("handles missing, disconnected, and already-ended calls", async () => {
|
||||
await expect(
|
||||
speak(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import crypto from "node:crypto";
|
||||
import type { CallMode } from "../config.js";
|
||||
import {
|
||||
type EndReason,
|
||||
TerminalStates,
|
||||
type CallId,
|
||||
type CallRecord,
|
||||
|
|
@ -8,15 +9,11 @@ import {
|
|||
} from "../types.js";
|
||||
import { mapVoiceToPolly } from "../voice-mapping.js";
|
||||
import type { CallManagerContext } from "./context.js";
|
||||
import { finalizeCall } from "./lifecycle.js";
|
||||
import { getCallByProviderCallId } from "./lookup.js";
|
||||
import { addTranscriptEntry, transitionState } from "./state.js";
|
||||
import { persistCallRecord } from "./store.js";
|
||||
import {
|
||||
clearMaxDurationTimer,
|
||||
clearTranscriptWaiter,
|
||||
rejectTranscriptWaiter,
|
||||
waitForFinalTranscript,
|
||||
} from "./timers.js";
|
||||
import { clearTranscriptWaiter, waitForFinalTranscript } from "./timers.js";
|
||||
import { generateNotifyTwiml } from "./twiml.js";
|
||||
|
||||
type InitiateContext = Pick<
|
||||
|
|
@ -186,14 +183,11 @@ export async function initiateCall(
|
|||
|
||||
return { callId, success: true };
|
||||
} catch (err) {
|
||||
callRecord.state = "failed";
|
||||
callRecord.endedAt = Date.now();
|
||||
callRecord.endReason = "failed";
|
||||
persistCallRecord(ctx.storePath, callRecord);
|
||||
ctx.activeCalls.delete(callId);
|
||||
if (callRecord.providerCallId) {
|
||||
ctx.providerCallIdMap.delete(callRecord.providerCallId);
|
||||
}
|
||||
finalizeCall({
|
||||
ctx,
|
||||
call: callRecord,
|
||||
endReason: "failed",
|
||||
});
|
||||
|
||||
return {
|
||||
callId,
|
||||
|
|
@ -369,6 +363,7 @@ export async function continueCall(
|
|||
export async function endCall(
|
||||
ctx: EndCallContext,
|
||||
callId: CallId,
|
||||
options?: { reason?: EndReason },
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const lookup = lookupConnectedCall(ctx, callId);
|
||||
if (lookup.kind === "error") {
|
||||
|
|
@ -378,24 +373,20 @@ export async function endCall(
|
|||
return { success: true };
|
||||
}
|
||||
const { call, providerCallId, provider } = lookup;
|
||||
const reason = options?.reason ?? "hangup-bot";
|
||||
|
||||
try {
|
||||
await provider.hangupCall({
|
||||
callId,
|
||||
providerCallId,
|
||||
reason: "hangup-bot",
|
||||
reason,
|
||||
});
|
||||
|
||||
call.state = "hangup-bot";
|
||||
call.endedAt = Date.now();
|
||||
call.endReason = "hangup-bot";
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
|
||||
clearMaxDurationTimer(ctx, callId);
|
||||
rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot");
|
||||
|
||||
ctx.activeCalls.delete(callId);
|
||||
ctx.providerCallIdMap.delete(providerCallId);
|
||||
finalizeCall({
|
||||
ctx,
|
||||
call,
|
||||
endReason: reason,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -8,14 +8,6 @@ import {
|
|||
waitForFinalTranscript,
|
||||
} from "./timers.js";
|
||||
|
||||
const { persistCallRecordMock } = vi.hoisted(() => ({
|
||||
persistCallRecordMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./store.js", () => ({
|
||||
persistCallRecord: persistCallRecordMock,
|
||||
}));
|
||||
|
||||
describe("voice-call manager timers", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
|
@ -26,13 +18,12 @@ describe("voice-call manager timers", () => {
|
|||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts and clears max duration timers, persisting timed out active calls", async () => {
|
||||
it("starts and clears max duration timers, delegating timeout handling", async () => {
|
||||
const call = { id: "call-1", state: "active" };
|
||||
const ctx = {
|
||||
activeCalls: new Map([["call-1", call]]),
|
||||
maxDurationTimers: new Map(),
|
||||
config: { maxDurationSeconds: 5 },
|
||||
storePath: "/tmp/voice-call.json",
|
||||
};
|
||||
const onTimeout = vi.fn(async () => {});
|
||||
|
||||
|
|
@ -46,8 +37,7 @@ describe("voice-call manager timers", () => {
|
|||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(call).toEqual({ id: "call-1", state: "active", endReason: "timeout" });
|
||||
expect(persistCallRecordMock).toHaveBeenCalledWith("/tmp/voice-call.json", call);
|
||||
expect(call).toEqual({ id: "call-1", state: "active" });
|
||||
expect(onTimeout).toHaveBeenCalledWith("call-1");
|
||||
expect(ctx.maxDurationTimers.has("call-1")).toBe(false);
|
||||
|
||||
|
|
@ -65,7 +55,6 @@ describe("voice-call manager timers", () => {
|
|||
activeCalls: new Map([["call-1", { id: "call-1", state: "completed" }]]),
|
||||
maxDurationTimers: new Map(),
|
||||
config: { maxDurationSeconds: 5 },
|
||||
storePath: "/tmp/voice-call.json",
|
||||
};
|
||||
const onTimeout = vi.fn(async () => {});
|
||||
|
||||
|
|
@ -77,7 +66,6 @@ describe("voice-call manager timers", () => {
|
|||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(persistCallRecordMock).not.toHaveBeenCalled();
|
||||
expect(onTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
import { TerminalStates, type CallId } from "../types.js";
|
||||
import type { CallManagerContext } from "./context.js";
|
||||
import { persistCallRecord } from "./store.js";
|
||||
|
||||
type TimerContext = Pick<
|
||||
CallManagerContext,
|
||||
"activeCalls" | "maxDurationTimers" | "config" | "storePath" | "transcriptWaiters"
|
||||
>;
|
||||
type MaxDurationTimerContext = Pick<
|
||||
TimerContext,
|
||||
"activeCalls" | "maxDurationTimers" | "config" | "storePath"
|
||||
"activeCalls" | "maxDurationTimers" | "config" | "transcriptWaiters"
|
||||
>;
|
||||
type MaxDurationTimerContext = Pick<TimerContext, "activeCalls" | "maxDurationTimers" | "config">;
|
||||
type TranscriptWaiterContext = Pick<TimerContext, "transcriptWaiters">;
|
||||
|
||||
export function clearMaxDurationTimer(
|
||||
|
|
@ -42,8 +38,6 @@ export function startMaxDurationTimer(params: {
|
|||
console.log(
|
||||
`[voice-call] Max duration reached (${params.ctx.config.maxDurationSeconds}s), ending call ${params.callId}`,
|
||||
);
|
||||
call.endReason = "timeout";
|
||||
persistCallRecord(params.ctx.storePath, call);
|
||||
await params.onTimeout(params.callId);
|
||||
}
|
||||
}, maxDurationMs);
|
||||
|
|
|
|||
Loading…
Reference in New Issue