fix(voice-call): persist rejected replay keys

This commit is contained in:
Vincent Koc
2026-05-16 09:02:19 +08:00
parent 7ccd3b8e8e
commit c8cee2dce4
3 changed files with 72 additions and 0 deletions

View File

@@ -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.

View File

@@ -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({

View File

@@ -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();
});
});