From c8cee2dce4e3e813f71eef8138839f5ab6864cb4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 16 May 2026 09:02:19 +0800 Subject: [PATCH] fix(voice-call): persist rejected replay keys --- CHANGELOG.md | 1 + extensions/voice-call/src/manager/events.ts | 27 ++++++++++++ .../src/webhook.hangup-once.lifecycle.test.ts | 44 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a750005573e..acf101e34c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Config persistence: ignore malformed array/scalar auth profile, cron job state, and session store entries instead of hydrating them into numeric profile ids, crashed cron rows, or invalid session records. - Providers: reject malformed successful Runway, BytePlus, and Ollama embedding responses with provider-owned errors instead of raw parser/type failures, silent bad vectors, or long bogus polling. - Trajectory export: skip and report malformed session/runtime JSONL rows in `manifest.json` instead of letting wrong-shaped session rows crash support bundle export. +- Voice calls: persist rejected inbound-call replay keys so duplicate carrier webhook retries stay ignored after a Gateway restart. - Config/doctor: copy fallback-enabled channel `allowFrom` entries into explicit `groupAllowFrom` allowlists during `openclaw doctor --fix`, preserving current group access without adding runtime fallback-transition flags. - Configure: show one OpenAI provider entry with ChatGPT/Codex sign-in and API key choices, and keep browsed Codex models in the saved `/model` picker allowlist. - Agents/model fallback: preserve auto fallback chains across deferred config reloads when session fallback provenance survives but `modelOverrideSource` is missing. Fixes #81982. Thanks @joshavant. diff --git a/extensions/voice-call/src/manager/events.ts b/extensions/voice-call/src/manager/events.ts index 743166c6502..0de338a1793 100644 --- a/extensions/voice-call/src/manager/events.ts +++ b/extensions/voice-call/src/manager/events.ts @@ -107,6 +107,32 @@ function createWebhookCall(params: { return callRecord; } +function persistRejectedInboundCall(params: { + ctx: EventContext; + event: NormalizedEvent; + dedupeKey: string; + providerCallId: string; +}): void { + const callId = params.event.callId || params.providerCallId; + const now = Date.now(); + const rejectedCall: CallRecord = { + callId, + providerCallId: params.providerCallId, + provider: params.ctx.provider?.name || "twilio", + direction: "inbound", + state: "hangup-bot", + from: params.event.from || "unknown", + to: params.event.to || params.ctx.config.fromNumber || "unknown", + startedAt: params.event.timestamp || now, + endedAt: now, + endReason: "hangup-bot", + transcript: [], + processedEventIds: [params.dedupeKey], + metadata: { rejectionReason: "inbound-policy" }, + }; + persistCallRecord(params.ctx.storePath, rejectedCall); +} + export function processEvent(ctx: EventContext, event: NormalizedEvent): void { const dedupeKey = event.dedupeKey || event.id; if (ctx.processedEventIds.has(dedupeKey)) { @@ -143,6 +169,7 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void { } ctx.rejectedProviderCallIds.add(pid); const callId = event.callId ?? pid; + persistRejectedInboundCall({ ctx, event, dedupeKey, providerCallId: pid }); console.log(`[voice-call] Rejecting inbound call by policy: ${pid}`); void ctx.provider .hangupCall({ diff --git a/extensions/voice-call/src/webhook.hangup-once.lifecycle.test.ts b/extensions/voice-call/src/webhook.hangup-once.lifecycle.test.ts index 2dfaee686d6..f467cdd3b35 100644 --- a/extensions/voice-call/src/webhook.hangup-once.lifecycle.test.ts +++ b/extensions/voice-call/src/webhook.hangup-once.lifecycle.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { VoiceCallConfigSchema, type VoiceCallConfig } from "./config.js"; import { CallManager } from "./manager.js"; import { createTestStorePath, FakeProvider } from "./manager.test-harness.js"; +import { flushPendingCallRecordWritesForTest } from "./manager/store.js"; import type { WebhookContext, WebhookParseOptions } from "./types.js"; import { VoiceCallWebhookServer } from "./webhook.js"; @@ -132,4 +133,47 @@ describe("Voice-call webhook hangup-once lifecycle", () => { const { first, second, manager } = await runDuplicateInboundReplayLifecycleTest(provider); expectSingleRejectedReplayHangup({ first, second, provider, manager }); }); + + it("keeps rejected inbound replay keys after manager restart", async () => { + const storePath = createTestStorePath(); + const config = createConfig(); + const firstProvider = new RejectInboundReplayProvider("plivo"); + const firstManager = new CallManager(config, storePath); + await firstManager.initialize(firstProvider, "https://example.com/voice/webhook"); + const firstServer = new VoiceCallWebhookServer(config, firstManager, firstProvider); + + try { + const baseUrl = await firstServer.start(); + const first = await postWebhookForm( + firstServer, + baseUrl, + "CallSid=CA123&From=%2B15552222222", + ); + expect(first.status).toBe(200); + } finally { + await firstServer.stop(); + } + await flushPendingCallRecordWritesForTest(); + expect(firstProvider.hangupCalls).toHaveLength(1); + + const secondProvider = new RejectInboundReplayProvider("plivo"); + const secondManager = new CallManager(config, storePath); + await secondManager.initialize(secondProvider, "https://example.com/voice/webhook"); + const secondServer = new VoiceCallWebhookServer(config, secondManager, secondProvider); + + try { + const baseUrl = await secondServer.start(); + const replay = await postWebhookForm( + secondServer, + baseUrl, + "CallSid=CA123&From=%2B15552222222", + ); + expect(replay.status).toBe(200); + } finally { + await secondServer.stop(); + } + + expect(secondProvider.hangupCalls).toHaveLength(0); + expect(secondManager.getCallByProviderCallId("provider-inbound-1")).toBeUndefined(); + }); });