mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 22:12:32 +08:00
fix(voice-call): terminate expired restored calls
This commit is contained in:
@@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/OpenAI-compatible: forward `prompt_cache_key` on Completions requests only for providers that opt in with `compat.supportsPromptCacheKey`, keeping default proxy payloads unchanged. Fixes #69272.
|
||||
- Providers/ElevenLabs: omit the MP3-only `Accept` header for PCM telephony synthesis, so Voice Call requests for `pcm_22050` no longer receive MP3 audio. Fixes #67340. Thanks @marcchabot.
|
||||
- Plugins/Voice Call: reap stale pre-answer calls by default, honor configured TTS timeouts for Twilio media-stream playback, and fail empty telephony audio instead of completing as silence. Fixes #42071; supersedes #60957. Thanks @Ryce and @sliekens.
|
||||
- Plugins/Voice Call: terminate expired restored call sessions with the provider and restart restored max-duration timers with only the remaining duration, preventing stale outbound retry loops after Gateway restarts. Fixes #48739. Thanks @mira-solari.
|
||||
- Skills: honor legacy `metadata.clawdbot` requirements and installer hints when `metadata.openclaw` is absent, so older skills no longer appear ready when required binaries are missing. Fixes #71323. Thanks @chen-zhang-cs-code.
|
||||
- Browser/config: expand `~` in `browser.executablePath` before Chromium launch, so home-relative custom browser paths no longer fail with `ENOENT`. Fixes #67264. Thanks @Quratulain-bilal.
|
||||
- Telegram/streaming: hide tool-progress status updates by default while keeping explicit `streaming.preview.toolProgress` opt-in support for edited preview messages. Fixes #71320. Thanks @neeravmakwana.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { VoiceCallConfigSchema } from "./config.js";
|
||||
import { CallManager } from "./manager.js";
|
||||
import {
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
makePersistedCall,
|
||||
writeCallsToStore,
|
||||
} from "./manager.test-harness.js";
|
||||
import { flushPendingCallRecordWritesForTest, loadActiveCallsFromStore } from "./manager/store.js";
|
||||
|
||||
function requireSingleActiveCall(manager: CallManager) {
|
||||
const activeCalls = manager.getActiveCalls();
|
||||
@@ -19,6 +20,10 @@ function requireSingleActiveCall(manager: CallManager) {
|
||||
}
|
||||
|
||||
describe("CallManager verification on restore", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function initializeManager(params?: {
|
||||
callOverrides?: Parameters<typeof makePersistedCall>[0];
|
||||
providerResult?: FakeProvider["getCallStatusResult"];
|
||||
@@ -44,7 +49,7 @@ describe("CallManager verification on restore", () => {
|
||||
const manager = new CallManager(config, storePath);
|
||||
await manager.initialize(provider, "https://example.com/voice/webhook");
|
||||
|
||||
return { call, manager };
|
||||
return { call, manager, provider, storePath };
|
||||
}
|
||||
|
||||
it("skips stale calls reported terminal by provider", async () => {
|
||||
@@ -75,7 +80,7 @@ describe("CallManager verification on restore", () => {
|
||||
});
|
||||
|
||||
it("skips calls older than maxDurationSeconds", async () => {
|
||||
const { manager } = await initializeManager({
|
||||
const { manager, provider, storePath } = await initializeManager({
|
||||
callOverrides: {
|
||||
startedAt: Date.now() - 600_000,
|
||||
answeredAt: Date.now() - 590_000,
|
||||
@@ -84,6 +89,14 @@ describe("CallManager verification on restore", () => {
|
||||
});
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(0);
|
||||
expect(provider.hangupCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
reason: "timeout",
|
||||
}),
|
||||
]);
|
||||
|
||||
await flushPendingCallRecordWritesForTest();
|
||||
expect(loadActiveCallsFromStore(storePath).activeCalls.size).toBe(0);
|
||||
});
|
||||
|
||||
it("skips calls without providerCallId", async () => {
|
||||
@@ -108,6 +121,33 @@ describe("CallManager verification on restore", () => {
|
||||
expect(activeCall.state).toBe(call.state);
|
||||
});
|
||||
|
||||
it("uses only remaining max duration for restored answered calls", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-03-17T03:07:00Z");
|
||||
vi.setSystemTime(now);
|
||||
const { manager, provider } = await initializeManager({
|
||||
callOverrides: {
|
||||
startedAt: now.getTime() - 290_000,
|
||||
answeredAt: now.getTime() - 290_000,
|
||||
state: "answered",
|
||||
},
|
||||
configOverrides: { maxDurationSeconds: 300 },
|
||||
});
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(1);
|
||||
await vi.advanceTimersByTimeAsync(9_000);
|
||||
expect(manager.getActiveCalls()).toHaveLength(1);
|
||||
expect(provider.hangupCalls).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_100);
|
||||
expect(manager.getActiveCalls()).toHaveLength(0);
|
||||
expect(provider.hangupCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
reason: "timeout",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("restores dedupe keys from terminal persisted calls so replayed webhooks stay ignored", async () => {
|
||||
const storePath = createTestStorePath();
|
||||
const persisted = makePersistedCall({
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
speak as speakWithContext,
|
||||
speakInitialMessage as speakInitialMessageWithContext,
|
||||
} from "./manager/outbound.js";
|
||||
import { getCallHistoryFromStore, loadActiveCallsFromStore } from "./manager/store.js";
|
||||
import {
|
||||
getCallHistoryFromStore,
|
||||
loadActiveCallsFromStore,
|
||||
persistCallRecord,
|
||||
} from "./manager/store.js";
|
||||
import { startMaxDurationTimer } from "./manager/timers.js";
|
||||
import type { VoiceCallProvider } from "./providers/base.js";
|
||||
import {
|
||||
@@ -26,6 +30,12 @@ import {
|
||||
} from "./types.js";
|
||||
import { resolveUserPath } from "./utils.js";
|
||||
|
||||
function markRestoredCallSkipped(call: CallRecord, endReason: "completed" | "timeout"): void {
|
||||
call.endedAt = Date.now();
|
||||
call.endReason = endReason;
|
||||
call.state = endReason;
|
||||
}
|
||||
|
||||
function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): string {
|
||||
const rawOverride = storePath?.trim() || config.store?.trim();
|
||||
if (rawOverride) {
|
||||
@@ -117,6 +127,7 @@ export class CallManager {
|
||||
startMaxDurationTimer({
|
||||
ctx: this.getContext(),
|
||||
callId,
|
||||
timeoutMs: maxDurationMs - elapsed,
|
||||
onTimeout: async (id) => {
|
||||
await endCallWithContext(this.getContext(), id, { reason: "timeout" });
|
||||
},
|
||||
@@ -160,6 +171,20 @@ export class CallManager {
|
||||
console.log(
|
||||
`[voice-call] Skipping restored call ${callId} (older than maxDurationSeconds)`,
|
||||
);
|
||||
markRestoredCallSkipped(call, "timeout");
|
||||
persistCallRecord(this.storePath, call);
|
||||
await provider
|
||||
.hangupCall({
|
||||
callId,
|
||||
providerCallId: call.providerCallId,
|
||||
reason: "timeout",
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
`[voice-call] Failed to hang up expired restored call ${callId}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -173,6 +198,8 @@ export class CallManager {
|
||||
console.log(
|
||||
`[voice-call] Skipping restored call ${callId} (provider status: ${result.status})`,
|
||||
);
|
||||
markRestoredCallSkipped(call, "completed");
|
||||
persistCallRecord(this.storePath, call);
|
||||
} else if (result.isUnknown) {
|
||||
console.log(
|
||||
`[voice-call] Keeping restored call ${callId} (provider status unknown, relying on timer)`,
|
||||
|
||||
@@ -27,12 +27,13 @@ export function startMaxDurationTimer(params: {
|
||||
ctx: MaxDurationTimerContext;
|
||||
callId: CallId;
|
||||
onTimeout: (callId: CallId) => Promise<void>;
|
||||
timeoutMs?: number;
|
||||
}): void {
|
||||
clearMaxDurationTimer(params.ctx, params.callId);
|
||||
|
||||
const maxDurationMs = params.ctx.config.maxDurationSeconds * 1000;
|
||||
const maxDurationMs = params.timeoutMs ?? params.ctx.config.maxDurationSeconds * 1000;
|
||||
console.log(
|
||||
`[voice-call] Starting max duration timer (${params.ctx.config.maxDurationSeconds}s) for call ${params.callId}`,
|
||||
`[voice-call] Starting max duration timer (${Math.ceil(maxDurationMs / 1000)}s) for call ${params.callId}`,
|
||||
);
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
@@ -40,7 +41,7 @@ export function startMaxDurationTimer(params: {
|
||||
const call = params.ctx.activeCalls.get(params.callId);
|
||||
if (call && !TerminalStates.has(call.state)) {
|
||||
console.log(
|
||||
`[voice-call] Max duration reached (${params.ctx.config.maxDurationSeconds}s), ending call ${params.callId}`,
|
||||
`[voice-call] Max duration reached (${Math.ceil(maxDurationMs / 1000)}s), ending call ${params.callId}`,
|
||||
);
|
||||
call.endReason = "timeout";
|
||||
persistCallRecord(params.ctx.storePath, call);
|
||||
|
||||
Reference in New Issue
Block a user