mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 17:14:33 +00:00
voice-call: hang up rejected inbounds, idempotency and logging (#15892)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 36f826ea23
Co-authored-by: dcantu96 <32658690+dcantu96@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
This commit is contained in:
committed by
GitHub
parent
13aface863
commit
9443c638f4
@@ -12,6 +12,10 @@ export type CallManagerContext = {
|
||||
activeCalls: Map<CallId, CallRecord>;
|
||||
providerCallIdMap: Map<string, CallId>;
|
||||
processedEventIds: Set<string>;
|
||||
/** Provider call IDs we already sent a reject hangup for; avoids duplicate hangup calls. */
|
||||
rejectedProviderCallIds: Set<string>;
|
||||
/** Optional runtime hook invoked after an event transitions a call into answered state. */
|
||||
onCallAnswered?: (call: CallRecord) => void;
|
||||
provider: VoiceCallProvider | null;
|
||||
config: VoiceCallConfig;
|
||||
storePath: string;
|
||||
|
||||
240
extensions/voice-call/src/manager/events.test.ts
Normal file
240
extensions/voice-call/src/manager/events.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HangupCallInput, NormalizedEvent } from "../types.js";
|
||||
import type { CallManagerContext } from "./context.js";
|
||||
import { VoiceCallConfigSchema } from "../config.js";
|
||||
import { processEvent } from "./events.js";
|
||||
|
||||
function createContext(overrides: Partial<CallManagerContext> = {}): CallManagerContext {
|
||||
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-events-test-${Date.now()}`);
|
||||
fs.mkdirSync(storePath, { recursive: true });
|
||||
return {
|
||||
activeCalls: new Map(),
|
||||
providerCallIdMap: new Map(),
|
||||
processedEventIds: new Set(),
|
||||
rejectedProviderCallIds: new Set(),
|
||||
provider: null,
|
||||
config: VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
}),
|
||||
storePath,
|
||||
webhookUrl: null,
|
||||
transcriptWaiters: new Map(),
|
||||
maxDurationTimers: new Map(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("processEvent (functional)", () => {
|
||||
it("calls provider hangup when rejecting inbound call", () => {
|
||||
const hangupCalls: HangupCallInput[] = [];
|
||||
const provider = {
|
||||
name: "plivo" as const,
|
||||
async hangupCall(input: HangupCallInput): Promise<void> {
|
||||
hangupCalls.push(input);
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = createContext({
|
||||
config: VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
inboundPolicy: "disabled",
|
||||
}),
|
||||
provider,
|
||||
});
|
||||
const event: NormalizedEvent = {
|
||||
id: "evt-1",
|
||||
type: "call.initiated",
|
||||
callId: "prov-1",
|
||||
providerCallId: "prov-1",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "+15559999999",
|
||||
to: "+15550000000",
|
||||
};
|
||||
|
||||
processEvent(ctx, event);
|
||||
|
||||
expect(ctx.activeCalls.size).toBe(0);
|
||||
expect(hangupCalls).toHaveLength(1);
|
||||
expect(hangupCalls[0]).toEqual({
|
||||
callId: "prov-1",
|
||||
providerCallId: "prov-1",
|
||||
reason: "hangup-bot",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call hangup when provider is null", () => {
|
||||
const ctx = createContext({
|
||||
config: VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
inboundPolicy: "disabled",
|
||||
}),
|
||||
provider: null,
|
||||
});
|
||||
const event: NormalizedEvent = {
|
||||
id: "evt-2",
|
||||
type: "call.initiated",
|
||||
callId: "prov-2",
|
||||
providerCallId: "prov-2",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "+15551111111",
|
||||
to: "+15550000000",
|
||||
};
|
||||
|
||||
processEvent(ctx, event);
|
||||
|
||||
expect(ctx.activeCalls.size).toBe(0);
|
||||
});
|
||||
|
||||
it("calls hangup only once for duplicate events for same rejected call", () => {
|
||||
const hangupCalls: HangupCallInput[] = [];
|
||||
const provider = {
|
||||
name: "plivo" as const,
|
||||
async hangupCall(input: HangupCallInput): Promise<void> {
|
||||
hangupCalls.push(input);
|
||||
},
|
||||
};
|
||||
const ctx = createContext({
|
||||
config: VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
inboundPolicy: "disabled",
|
||||
}),
|
||||
provider,
|
||||
});
|
||||
const event1: NormalizedEvent = {
|
||||
id: "evt-init",
|
||||
type: "call.initiated",
|
||||
callId: "prov-dup",
|
||||
providerCallId: "prov-dup",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "+15552222222",
|
||||
to: "+15550000000",
|
||||
};
|
||||
const event2: NormalizedEvent = {
|
||||
id: "evt-ring",
|
||||
type: "call.ringing",
|
||||
callId: "prov-dup",
|
||||
providerCallId: "prov-dup",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "+15552222222",
|
||||
to: "+15550000000",
|
||||
};
|
||||
|
||||
processEvent(ctx, event1);
|
||||
processEvent(ctx, event2);
|
||||
|
||||
expect(ctx.activeCalls.size).toBe(0);
|
||||
expect(hangupCalls).toHaveLength(1);
|
||||
expect(hangupCalls[0]?.providerCallId).toBe("prov-dup");
|
||||
});
|
||||
|
||||
it("updates providerCallId map when provider ID changes", () => {
|
||||
const now = Date.now();
|
||||
const ctx = createContext();
|
||||
ctx.activeCalls.set("call-1", {
|
||||
callId: "call-1",
|
||||
providerCallId: "request-uuid",
|
||||
provider: "plivo",
|
||||
direction: "outbound",
|
||||
state: "initiated",
|
||||
from: "+15550000000",
|
||||
to: "+15550000001",
|
||||
startedAt: now,
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
metadata: {},
|
||||
});
|
||||
ctx.providerCallIdMap.set("request-uuid", "call-1");
|
||||
|
||||
processEvent(ctx, {
|
||||
id: "evt-provider-id-change",
|
||||
type: "call.answered",
|
||||
callId: "call-1",
|
||||
providerCallId: "call-uuid",
|
||||
timestamp: now + 1,
|
||||
});
|
||||
|
||||
expect(ctx.activeCalls.get("call-1")?.providerCallId).toBe("call-uuid");
|
||||
expect(ctx.providerCallIdMap.get("call-uuid")).toBe("call-1");
|
||||
expect(ctx.providerCallIdMap.has("request-uuid")).toBe(false);
|
||||
});
|
||||
|
||||
it("invokes onCallAnswered hook for answered events", () => {
|
||||
const now = Date.now();
|
||||
let answeredCallId: string | null = null;
|
||||
const ctx = createContext({
|
||||
onCallAnswered: (call) => {
|
||||
answeredCallId = call.callId;
|
||||
},
|
||||
});
|
||||
ctx.activeCalls.set("call-2", {
|
||||
callId: "call-2",
|
||||
providerCallId: "call-2-provider",
|
||||
provider: "plivo",
|
||||
direction: "inbound",
|
||||
state: "ringing",
|
||||
from: "+15550000002",
|
||||
to: "+15550000000",
|
||||
startedAt: now,
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
metadata: {},
|
||||
});
|
||||
ctx.providerCallIdMap.set("call-2-provider", "call-2");
|
||||
|
||||
processEvent(ctx, {
|
||||
id: "evt-answered-hook",
|
||||
type: "call.answered",
|
||||
callId: "call-2",
|
||||
providerCallId: "call-2-provider",
|
||||
timestamp: now + 1,
|
||||
});
|
||||
|
||||
expect(answeredCallId).toBe("call-2");
|
||||
});
|
||||
|
||||
it("when hangup throws, logs and does not throw", () => {
|
||||
const provider = {
|
||||
name: "plivo" as const,
|
||||
async hangupCall(): Promise<void> {
|
||||
throw new Error("provider down");
|
||||
},
|
||||
};
|
||||
const ctx = createContext({
|
||||
config: VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
inboundPolicy: "disabled",
|
||||
}),
|
||||
provider,
|
||||
});
|
||||
const event: NormalizedEvent = {
|
||||
id: "evt-fail",
|
||||
type: "call.initiated",
|
||||
callId: "prov-fail",
|
||||
providerCallId: "prov-fail",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "+15553333333",
|
||||
to: "+15550000000",
|
||||
};
|
||||
|
||||
expect(() => processEvent(ctx, event)).not.toThrow();
|
||||
expect(ctx.activeCalls.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -94,7 +94,29 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
|
||||
|
||||
if (!call && event.direction === "inbound" && event.providerCallId) {
|
||||
if (!shouldAcceptInbound(ctx.config, event.from)) {
|
||||
// TODO: Could hang up the call here.
|
||||
const pid = event.providerCallId;
|
||||
if (!ctx.provider) {
|
||||
console.warn(
|
||||
`[voice-call] Inbound call rejected by policy but no provider to hang up (providerCallId: ${pid}, from: ${event.from}); call will time out on provider side.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (ctx.rejectedProviderCallIds.has(pid)) {
|
||||
return;
|
||||
}
|
||||
ctx.rejectedProviderCallIds.add(pid);
|
||||
const callId = event.callId ?? pid;
|
||||
console.log(`[voice-call] Rejecting inbound call by policy: ${pid}`);
|
||||
void ctx.provider
|
||||
.hangupCall({
|
||||
callId,
|
||||
providerCallId: pid,
|
||||
reason: "hangup-bot",
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[voice-call] Failed to reject inbound call ${pid}:`, message);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,9 +135,16 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.providerCallId && !call.providerCallId) {
|
||||
if (event.providerCallId && event.providerCallId !== call.providerCallId) {
|
||||
const previousProviderCallId = call.providerCallId;
|
||||
call.providerCallId = event.providerCallId;
|
||||
ctx.providerCallIdMap.set(event.providerCallId, call.callId);
|
||||
if (previousProviderCallId) {
|
||||
const mapped = ctx.providerCallIdMap.get(previousProviderCallId);
|
||||
if (mapped === call.callId) {
|
||||
ctx.providerCallIdMap.delete(previousProviderCallId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
call.processedEventIds.push(event.id);
|
||||
@@ -139,6 +168,7 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
|
||||
await endCall(ctx, callId);
|
||||
},
|
||||
});
|
||||
ctx.onCallAnswered?.(call);
|
||||
break;
|
||||
|
||||
case "call.active":
|
||||
|
||||
@@ -16,6 +16,7 @@ export function loadActiveCallsFromStore(storePath: string): {
|
||||
activeCalls: Map<CallId, CallRecord>;
|
||||
providerCallIdMap: Map<string, CallId>;
|
||||
processedEventIds: Set<string>;
|
||||
rejectedProviderCallIds: Set<string>;
|
||||
} {
|
||||
const logPath = path.join(storePath, "calls.jsonl");
|
||||
if (!fs.existsSync(logPath)) {
|
||||
@@ -23,6 +24,7 @@ export function loadActiveCallsFromStore(storePath: string): {
|
||||
activeCalls: new Map(),
|
||||
providerCallIdMap: new Map(),
|
||||
processedEventIds: new Set(),
|
||||
rejectedProviderCallIds: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,6 +47,7 @@ export function loadActiveCallsFromStore(storePath: string): {
|
||||
const activeCalls = new Map<CallId, CallRecord>();
|
||||
const providerCallIdMap = new Map<string, CallId>();
|
||||
const processedEventIds = new Set<string>();
|
||||
const rejectedProviderCallIds = new Set<string>();
|
||||
|
||||
for (const [callId, call] of callMap) {
|
||||
if (TerminalStates.has(call.state)) {
|
||||
@@ -59,7 +62,7 @@ export function loadActiveCallsFromStore(storePath: string): {
|
||||
}
|
||||
}
|
||||
|
||||
return { activeCalls, providerCallIdMap, processedEventIds };
|
||||
return { activeCalls, providerCallIdMap, processedEventIds, rejectedProviderCallIds };
|
||||
}
|
||||
|
||||
export async function getCallHistoryFromStore(
|
||||
|
||||
Reference in New Issue
Block a user