fix(voice-call): tighten manager outbound behavior
This commit is contained in:
parent
8c1afc4b63
commit
081ab9c99d
@ -46,17 +46,44 @@ class FakeProvider implements VoiceCallProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let storeSeq = 0;
|
||||||
|
|
||||||
|
function createTestStorePath(): string {
|
||||||
|
storeSeq += 1;
|
||||||
|
return path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}-${storeSeq}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createManagerHarness(
|
||||||
|
configOverrides: Record<string, unknown> = {},
|
||||||
|
provider = new FakeProvider(),
|
||||||
|
): {
|
||||||
|
manager: CallManager;
|
||||||
|
provider: FakeProvider;
|
||||||
|
} {
|
||||||
|
const config = VoiceCallConfigSchema.parse({
|
||||||
|
enabled: true,
|
||||||
|
provider: "plivo",
|
||||||
|
fromNumber: "+15550000000",
|
||||||
|
...configOverrides,
|
||||||
|
});
|
||||||
|
const manager = new CallManager(config, createTestStorePath());
|
||||||
|
manager.initialize(provider, "https://example.com/voice/webhook");
|
||||||
|
return { manager, provider };
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCallAnswered(manager: CallManager, callId: string, eventId: string): void {
|
||||||
|
manager.processEvent({
|
||||||
|
id: eventId,
|
||||||
|
type: "call.answered",
|
||||||
|
callId,
|
||||||
|
providerCallId: "request-uuid",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("CallManager", () => {
|
describe("CallManager", () => {
|
||||||
it("upgrades providerCallId mapping when provider ID changes", async () => {
|
it("upgrades providerCallId mapping when provider ID changes", async () => {
|
||||||
const config = VoiceCallConfigSchema.parse({
|
const { manager } = createManagerHarness();
|
||||||
enabled: true,
|
|
||||||
provider: "plivo",
|
|
||||||
fromNumber: "+15550000000",
|
|
||||||
});
|
|
||||||
|
|
||||||
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
|
|
||||||
const manager = new CallManager(config, storePath);
|
|
||||||
manager.initialize(new FakeProvider(), "https://example.com/voice/webhook");
|
|
||||||
|
|
||||||
const { callId, success, error } = await manager.initiateCall("+15550000001");
|
const { callId, success, error } = await manager.initiateCall("+15550000001");
|
||||||
expect(success).toBe(true);
|
expect(success).toBe(true);
|
||||||
@ -81,16 +108,7 @@ describe("CallManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("speaks initial message on answered for notify mode (non-Twilio)", async () => {
|
it("speaks initial message on answered for notify mode (non-Twilio)", async () => {
|
||||||
const config = VoiceCallConfigSchema.parse({
|
const { manager, provider } = createManagerHarness();
|
||||||
enabled: true,
|
|
||||||
provider: "plivo",
|
|
||||||
fromNumber: "+15550000000",
|
|
||||||
});
|
|
||||||
|
|
||||||
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
|
|
||||||
const provider = new FakeProvider();
|
|
||||||
const manager = new CallManager(config, storePath);
|
|
||||||
manager.initialize(provider, "https://example.com/voice/webhook");
|
|
||||||
|
|
||||||
const { callId, success } = await manager.initiateCall("+15550000002", undefined, {
|
const { callId, success } = await manager.initiateCall("+15550000002", undefined, {
|
||||||
message: "Hello there",
|
message: "Hello there",
|
||||||
@ -113,19 +131,11 @@ describe("CallManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects inbound calls with missing caller ID when allowlist enabled", () => {
|
it("rejects inbound calls with missing caller ID when allowlist enabled", () => {
|
||||||
const config = VoiceCallConfigSchema.parse({
|
const { manager, provider } = createManagerHarness({
|
||||||
enabled: true,
|
|
||||||
provider: "plivo",
|
|
||||||
fromNumber: "+15550000000",
|
|
||||||
inboundPolicy: "allowlist",
|
inboundPolicy: "allowlist",
|
||||||
allowFrom: ["+15550001234"],
|
allowFrom: ["+15550001234"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
|
|
||||||
const provider = new FakeProvider();
|
|
||||||
const manager = new CallManager(config, storePath);
|
|
||||||
manager.initialize(provider, "https://example.com/voice/webhook");
|
|
||||||
|
|
||||||
manager.processEvent({
|
manager.processEvent({
|
||||||
id: "evt-allowlist-missing",
|
id: "evt-allowlist-missing",
|
||||||
type: "call.initiated",
|
type: "call.initiated",
|
||||||
@ -142,19 +152,11 @@ describe("CallManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects inbound calls with anonymous caller ID when allowlist enabled", () => {
|
it("rejects inbound calls with anonymous caller ID when allowlist enabled", () => {
|
||||||
const config = VoiceCallConfigSchema.parse({
|
const { manager, provider } = createManagerHarness({
|
||||||
enabled: true,
|
|
||||||
provider: "plivo",
|
|
||||||
fromNumber: "+15550000000",
|
|
||||||
inboundPolicy: "allowlist",
|
inboundPolicy: "allowlist",
|
||||||
allowFrom: ["+15550001234"],
|
allowFrom: ["+15550001234"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
|
|
||||||
const provider = new FakeProvider();
|
|
||||||
const manager = new CallManager(config, storePath);
|
|
||||||
manager.initialize(provider, "https://example.com/voice/webhook");
|
|
||||||
|
|
||||||
manager.processEvent({
|
manager.processEvent({
|
||||||
id: "evt-allowlist-anon",
|
id: "evt-allowlist-anon",
|
||||||
type: "call.initiated",
|
type: "call.initiated",
|
||||||
@ -172,19 +174,11 @@ describe("CallManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects inbound calls that only match allowlist suffixes", () => {
|
it("rejects inbound calls that only match allowlist suffixes", () => {
|
||||||
const config = VoiceCallConfigSchema.parse({
|
const { manager, provider } = createManagerHarness({
|
||||||
enabled: true,
|
|
||||||
provider: "plivo",
|
|
||||||
fromNumber: "+15550000000",
|
|
||||||
inboundPolicy: "allowlist",
|
inboundPolicy: "allowlist",
|
||||||
allowFrom: ["+15550001234"],
|
allowFrom: ["+15550001234"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
|
|
||||||
const provider = new FakeProvider();
|
|
||||||
const manager = new CallManager(config, storePath);
|
|
||||||
manager.initialize(provider, "https://example.com/voice/webhook");
|
|
||||||
|
|
||||||
manager.processEvent({
|
manager.processEvent({
|
||||||
id: "evt-allowlist-suffix",
|
id: "evt-allowlist-suffix",
|
||||||
type: "call.initiated",
|
type: "call.initiated",
|
||||||
@ -202,18 +196,10 @@ describe("CallManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects duplicate inbound events with a single hangup call", () => {
|
it("rejects duplicate inbound events with a single hangup call", () => {
|
||||||
const config = VoiceCallConfigSchema.parse({
|
const { manager, provider } = createManagerHarness({
|
||||||
enabled: true,
|
|
||||||
provider: "plivo",
|
|
||||||
fromNumber: "+15550000000",
|
|
||||||
inboundPolicy: "disabled",
|
inboundPolicy: "disabled",
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
|
|
||||||
const provider = new FakeProvider();
|
|
||||||
const manager = new CallManager(config, storePath);
|
|
||||||
manager.initialize(provider, "https://example.com/voice/webhook");
|
|
||||||
|
|
||||||
manager.processEvent({
|
manager.processEvent({
|
||||||
id: "evt-reject-init",
|
id: "evt-reject-init",
|
||||||
type: "call.initiated",
|
type: "call.initiated",
|
||||||
@ -242,18 +228,11 @@ describe("CallManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("accepts inbound calls that exactly match the allowlist", () => {
|
it("accepts inbound calls that exactly match the allowlist", () => {
|
||||||
const config = VoiceCallConfigSchema.parse({
|
const { manager } = createManagerHarness({
|
||||||
enabled: true,
|
|
||||||
provider: "plivo",
|
|
||||||
fromNumber: "+15550000000",
|
|
||||||
inboundPolicy: "allowlist",
|
inboundPolicy: "allowlist",
|
||||||
allowFrom: ["+15550001234"],
|
allowFrom: ["+15550001234"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
|
|
||||||
const manager = new CallManager(config, storePath);
|
|
||||||
manager.initialize(new FakeProvider(), "https://example.com/voice/webhook");
|
|
||||||
|
|
||||||
manager.processEvent({
|
manager.processEvent({
|
||||||
id: "evt-allowlist-exact",
|
id: "evt-allowlist-exact",
|
||||||
type: "call.initiated",
|
type: "call.initiated",
|
||||||
@ -269,28 +248,14 @@ describe("CallManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("completes a closed-loop turn without live audio", async () => {
|
it("completes a closed-loop turn without live audio", async () => {
|
||||||
const config = VoiceCallConfigSchema.parse({
|
const { manager, provider } = createManagerHarness({
|
||||||
enabled: true,
|
|
||||||
provider: "plivo",
|
|
||||||
fromNumber: "+15550000000",
|
|
||||||
transcriptTimeoutMs: 5000,
|
transcriptTimeoutMs: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
|
|
||||||
const provider = new FakeProvider();
|
|
||||||
const manager = new CallManager(config, storePath);
|
|
||||||
manager.initialize(provider, "https://example.com/voice/webhook");
|
|
||||||
|
|
||||||
const started = await manager.initiateCall("+15550000003");
|
const started = await manager.initiateCall("+15550000003");
|
||||||
expect(started.success).toBe(true);
|
expect(started.success).toBe(true);
|
||||||
|
|
||||||
manager.processEvent({
|
markCallAnswered(manager, started.callId, "evt-closed-loop-answered");
|
||||||
id: "evt-closed-loop-answered",
|
|
||||||
type: "call.answered",
|
|
||||||
callId: started.callId,
|
|
||||||
providerCallId: "request-uuid",
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const turnPromise = manager.continueCall(started.callId, "How can I help?");
|
const turnPromise = manager.continueCall(started.callId, "How can I help?");
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
@ -323,28 +288,14 @@ describe("CallManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects overlapping continueCall requests for the same call", async () => {
|
it("rejects overlapping continueCall requests for the same call", async () => {
|
||||||
const config = VoiceCallConfigSchema.parse({
|
const { manager, provider } = createManagerHarness({
|
||||||
enabled: true,
|
|
||||||
provider: "plivo",
|
|
||||||
fromNumber: "+15550000000",
|
|
||||||
transcriptTimeoutMs: 5000,
|
transcriptTimeoutMs: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
|
|
||||||
const provider = new FakeProvider();
|
|
||||||
const manager = new CallManager(config, storePath);
|
|
||||||
manager.initialize(provider, "https://example.com/voice/webhook");
|
|
||||||
|
|
||||||
const started = await manager.initiateCall("+15550000004");
|
const started = await manager.initiateCall("+15550000004");
|
||||||
expect(started.success).toBe(true);
|
expect(started.success).toBe(true);
|
||||||
|
|
||||||
manager.processEvent({
|
markCallAnswered(manager, started.callId, "evt-overlap-answered");
|
||||||
id: "evt-overlap-answered",
|
|
||||||
type: "call.answered",
|
|
||||||
callId: started.callId,
|
|
||||||
providerCallId: "request-uuid",
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const first = manager.continueCall(started.callId, "First prompt");
|
const first = manager.continueCall(started.callId, "First prompt");
|
||||||
const second = await manager.continueCall(started.callId, "Second prompt");
|
const second = await manager.continueCall(started.callId, "Second prompt");
|
||||||
@ -369,28 +320,14 @@ describe("CallManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("tracks latency metadata across multiple closed-loop turns", async () => {
|
it("tracks latency metadata across multiple closed-loop turns", async () => {
|
||||||
const config = VoiceCallConfigSchema.parse({
|
const { manager, provider } = createManagerHarness({
|
||||||
enabled: true,
|
|
||||||
provider: "plivo",
|
|
||||||
fromNumber: "+15550000000",
|
|
||||||
transcriptTimeoutMs: 5000,
|
transcriptTimeoutMs: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
|
|
||||||
const provider = new FakeProvider();
|
|
||||||
const manager = new CallManager(config, storePath);
|
|
||||||
manager.initialize(provider, "https://example.com/voice/webhook");
|
|
||||||
|
|
||||||
const started = await manager.initiateCall("+15550000005");
|
const started = await manager.initiateCall("+15550000005");
|
||||||
expect(started.success).toBe(true);
|
expect(started.success).toBe(true);
|
||||||
|
|
||||||
manager.processEvent({
|
markCallAnswered(manager, started.callId, "evt-multi-answered");
|
||||||
id: "evt-multi-answered",
|
|
||||||
type: "call.answered",
|
|
||||||
callId: started.callId,
|
|
||||||
providerCallId: "request-uuid",
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstTurn = manager.continueCall(started.callId, "First question");
|
const firstTurn = manager.continueCall(started.callId, "First question");
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
@ -436,28 +373,14 @@ describe("CallManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles repeated closed-loop turns without waiter churn", async () => {
|
it("handles repeated closed-loop turns without waiter churn", async () => {
|
||||||
const config = VoiceCallConfigSchema.parse({
|
const { manager, provider } = createManagerHarness({
|
||||||
enabled: true,
|
|
||||||
provider: "plivo",
|
|
||||||
fromNumber: "+15550000000",
|
|
||||||
transcriptTimeoutMs: 5000,
|
transcriptTimeoutMs: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
|
|
||||||
const provider = new FakeProvider();
|
|
||||||
const manager = new CallManager(config, storePath);
|
|
||||||
manager.initialize(provider, "https://example.com/voice/webhook");
|
|
||||||
|
|
||||||
const started = await manager.initiateCall("+15550000006");
|
const started = await manager.initiateCall("+15550000006");
|
||||||
expect(started.success).toBe(true);
|
expect(started.success).toBe(true);
|
||||||
|
|
||||||
manager.processEvent({
|
markCallAnswered(manager, started.callId, "evt-loop-answered");
|
||||||
id: "evt-loop-answered",
|
|
||||||
type: "call.answered",
|
|
||||||
callId: started.callId,
|
|
||||||
providerCallId: "request-uuid",
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
for (let i = 1; i <= 5; i++) {
|
||||||
const turnPromise = manager.continueCall(started.callId, `Prompt ${i}`);
|
const turnPromise = manager.continueCall(started.callId, `Prompt ${i}`);
|
||||||
|
|||||||
@ -45,6 +45,32 @@ function createProvider(overrides: Partial<VoiceCallProvider> = {}): VoiceCallPr
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createInboundDisabledConfig() {
|
||||||
|
return VoiceCallConfigSchema.parse({
|
||||||
|
enabled: true,
|
||||||
|
provider: "plivo",
|
||||||
|
fromNumber: "+15550000000",
|
||||||
|
inboundPolicy: "disabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInboundInitiatedEvent(params: {
|
||||||
|
id: string;
|
||||||
|
providerCallId: string;
|
||||||
|
from: string;
|
||||||
|
}): NormalizedEvent {
|
||||||
|
return {
|
||||||
|
id: params.id,
|
||||||
|
type: "call.initiated",
|
||||||
|
callId: params.providerCallId,
|
||||||
|
providerCallId: params.providerCallId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
direction: "inbound",
|
||||||
|
from: params.from,
|
||||||
|
to: "+15550000000",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("processEvent (functional)", () => {
|
describe("processEvent (functional)", () => {
|
||||||
it("calls provider hangup when rejecting inbound call", () => {
|
it("calls provider hangup when rejecting inbound call", () => {
|
||||||
const hangupCalls: HangupCallInput[] = [];
|
const hangupCalls: HangupCallInput[] = [];
|
||||||
@ -55,24 +81,14 @@ describe("processEvent (functional)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ctx = createContext({
|
const ctx = createContext({
|
||||||
config: VoiceCallConfigSchema.parse({
|
config: createInboundDisabledConfig(),
|
||||||
enabled: true,
|
|
||||||
provider: "plivo",
|
|
||||||
fromNumber: "+15550000000",
|
|
||||||
inboundPolicy: "disabled",
|
|
||||||
}),
|
|
||||||
provider,
|
provider,
|
||||||
});
|
});
|
||||||
const event: NormalizedEvent = {
|
const event = createInboundInitiatedEvent({
|
||||||
id: "evt-1",
|
id: "evt-1",
|
||||||
type: "call.initiated",
|
|
||||||
callId: "prov-1",
|
|
||||||
providerCallId: "prov-1",
|
providerCallId: "prov-1",
|
||||||
timestamp: Date.now(),
|
|
||||||
direction: "inbound",
|
|
||||||
from: "+15559999999",
|
from: "+15559999999",
|
||||||
to: "+15550000000",
|
});
|
||||||
};
|
|
||||||
|
|
||||||
processEvent(ctx, event);
|
processEvent(ctx, event);
|
||||||
|
|
||||||
@ -87,24 +103,14 @@ describe("processEvent (functional)", () => {
|
|||||||
|
|
||||||
it("does not call hangup when provider is null", () => {
|
it("does not call hangup when provider is null", () => {
|
||||||
const ctx = createContext({
|
const ctx = createContext({
|
||||||
config: VoiceCallConfigSchema.parse({
|
config: createInboundDisabledConfig(),
|
||||||
enabled: true,
|
|
||||||
provider: "plivo",
|
|
||||||
fromNumber: "+15550000000",
|
|
||||||
inboundPolicy: "disabled",
|
|
||||||
}),
|
|
||||||
provider: null,
|
provider: null,
|
||||||
});
|
});
|
||||||
const event: NormalizedEvent = {
|
const event = createInboundInitiatedEvent({
|
||||||
id: "evt-2",
|
id: "evt-2",
|
||||||
type: "call.initiated",
|
|
||||||
callId: "prov-2",
|
|
||||||
providerCallId: "prov-2",
|
providerCallId: "prov-2",
|
||||||
timestamp: Date.now(),
|
|
||||||
direction: "inbound",
|
|
||||||
from: "+15551111111",
|
from: "+15551111111",
|
||||||
to: "+15550000000",
|
});
|
||||||
};
|
|
||||||
|
|
||||||
processEvent(ctx, event);
|
processEvent(ctx, event);
|
||||||
|
|
||||||
@ -119,24 +125,14 @@ describe("processEvent (functional)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const ctx = createContext({
|
const ctx = createContext({
|
||||||
config: VoiceCallConfigSchema.parse({
|
config: createInboundDisabledConfig(),
|
||||||
enabled: true,
|
|
||||||
provider: "plivo",
|
|
||||||
fromNumber: "+15550000000",
|
|
||||||
inboundPolicy: "disabled",
|
|
||||||
}),
|
|
||||||
provider,
|
provider,
|
||||||
});
|
});
|
||||||
const event1: NormalizedEvent = {
|
const event1 = createInboundInitiatedEvent({
|
||||||
id: "evt-init",
|
id: "evt-init",
|
||||||
type: "call.initiated",
|
|
||||||
callId: "prov-dup",
|
|
||||||
providerCallId: "prov-dup",
|
providerCallId: "prov-dup",
|
||||||
timestamp: Date.now(),
|
|
||||||
direction: "inbound",
|
|
||||||
from: "+15552222222",
|
from: "+15552222222",
|
||||||
to: "+15550000000",
|
});
|
||||||
};
|
|
||||||
const event2: NormalizedEvent = {
|
const event2: NormalizedEvent = {
|
||||||
id: "evt-ring",
|
id: "evt-ring",
|
||||||
type: "call.ringing",
|
type: "call.ringing",
|
||||||
@ -228,24 +224,14 @@ describe("processEvent (functional)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const ctx = createContext({
|
const ctx = createContext({
|
||||||
config: VoiceCallConfigSchema.parse({
|
config: createInboundDisabledConfig(),
|
||||||
enabled: true,
|
|
||||||
provider: "plivo",
|
|
||||||
fromNumber: "+15550000000",
|
|
||||||
inboundPolicy: "disabled",
|
|
||||||
}),
|
|
||||||
provider,
|
provider,
|
||||||
});
|
});
|
||||||
const event: NormalizedEvent = {
|
const event = createInboundInitiatedEvent({
|
||||||
id: "evt-fail",
|
id: "evt-fail",
|
||||||
type: "call.initiated",
|
|
||||||
callId: "prov-fail",
|
|
||||||
providerCallId: "prov-fail",
|
providerCallId: "prov-fail",
|
||||||
timestamp: Date.now(),
|
|
||||||
direction: "inbound",
|
|
||||||
from: "+15553333333",
|
from: "+15553333333",
|
||||||
to: "+15550000000",
|
});
|
||||||
};
|
|
||||||
|
|
||||||
expect(() => processEvent(ctx, event)).not.toThrow();
|
expect(() => processEvent(ctx, event)).not.toThrow();
|
||||||
expect(ctx.activeCalls.size).toBe(0);
|
expect(ctx.activeCalls.size).toBe(0);
|
||||||
|
|||||||
@ -51,6 +51,32 @@ type EndCallContext = Pick<
|
|||||||
| "maxDurationTimers"
|
| "maxDurationTimers"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
type ConnectedCallContext = Pick<CallManagerContext, "activeCalls" | "provider">;
|
||||||
|
|
||||||
|
type ConnectedCallLookup =
|
||||||
|
| { kind: "error"; error: string }
|
||||||
|
| { kind: "ended"; call: CallRecord }
|
||||||
|
| {
|
||||||
|
kind: "ok";
|
||||||
|
call: CallRecord;
|
||||||
|
providerCallId: string;
|
||||||
|
provider: NonNullable<ConnectedCallContext["provider"]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function lookupConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallLookup {
|
||||||
|
const call = ctx.activeCalls.get(callId);
|
||||||
|
if (!call) {
|
||||||
|
return { kind: "error", error: "Call not found" };
|
||||||
|
}
|
||||||
|
if (!ctx.provider || !call.providerCallId) {
|
||||||
|
return { kind: "error", error: "Call not connected" };
|
||||||
|
}
|
||||||
|
if (TerminalStates.has(call.state)) {
|
||||||
|
return { kind: "ended", call };
|
||||||
|
}
|
||||||
|
return { kind: "ok", call, providerCallId: call.providerCallId, provider: ctx.provider };
|
||||||
|
}
|
||||||
|
|
||||||
export async function initiateCall(
|
export async function initiateCall(
|
||||||
ctx: InitiateContext,
|
ctx: InitiateContext,
|
||||||
to: string,
|
to: string,
|
||||||
@ -149,26 +175,25 @@ export async function speak(
|
|||||||
callId: CallId,
|
callId: CallId,
|
||||||
text: string,
|
text: string,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
const call = ctx.activeCalls.get(callId);
|
const lookup = lookupConnectedCall(ctx, callId);
|
||||||
if (!call) {
|
if (lookup.kind === "error") {
|
||||||
return { success: false, error: "Call not found" };
|
return { success: false, error: lookup.error };
|
||||||
}
|
}
|
||||||
if (!ctx.provider || !call.providerCallId) {
|
if (lookup.kind === "ended") {
|
||||||
return { success: false, error: "Call not connected" };
|
|
||||||
}
|
|
||||||
if (TerminalStates.has(call.state)) {
|
|
||||||
return { success: false, error: "Call has ended" };
|
return { success: false, error: "Call has ended" };
|
||||||
}
|
}
|
||||||
|
const { call, providerCallId, provider } = lookup;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
transitionState(call, "speaking");
|
transitionState(call, "speaking");
|
||||||
persistCallRecord(ctx.storePath, call);
|
persistCallRecord(ctx.storePath, call);
|
||||||
|
|
||||||
addTranscriptEntry(call, "bot", text);
|
addTranscriptEntry(call, "bot", text);
|
||||||
|
|
||||||
const voice = ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
|
const voice = provider.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
|
||||||
await ctx.provider.playTts({
|
await provider.playTts({
|
||||||
callId,
|
callId,
|
||||||
providerCallId: call.providerCallId,
|
providerCallId,
|
||||||
text,
|
text,
|
||||||
voice,
|
voice,
|
||||||
});
|
});
|
||||||
@ -232,16 +257,15 @@ export async function continueCall(
|
|||||||
callId: CallId,
|
callId: CallId,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
||||||
const call = ctx.activeCalls.get(callId);
|
const lookup = lookupConnectedCall(ctx, callId);
|
||||||
if (!call) {
|
if (lookup.kind === "error") {
|
||||||
return { success: false, error: "Call not found" };
|
return { success: false, error: lookup.error };
|
||||||
}
|
}
|
||||||
if (!ctx.provider || !call.providerCallId) {
|
if (lookup.kind === "ended") {
|
||||||
return { success: false, error: "Call not connected" };
|
|
||||||
}
|
|
||||||
if (TerminalStates.has(call.state)) {
|
|
||||||
return { success: false, error: "Call has ended" };
|
return { success: false, error: "Call has ended" };
|
||||||
}
|
}
|
||||||
|
const { call, providerCallId, provider } = lookup;
|
||||||
|
|
||||||
if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) {
|
if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) {
|
||||||
return { success: false, error: "Already waiting for transcript" };
|
return { success: false, error: "Already waiting for transcript" };
|
||||||
}
|
}
|
||||||
@ -256,13 +280,13 @@ export async function continueCall(
|
|||||||
persistCallRecord(ctx.storePath, call);
|
persistCallRecord(ctx.storePath, call);
|
||||||
|
|
||||||
const listenStartedAt = Date.now();
|
const listenStartedAt = Date.now();
|
||||||
await ctx.provider.startListening({ callId, providerCallId: call.providerCallId });
|
await provider.startListening({ callId, providerCallId });
|
||||||
|
|
||||||
const transcript = await waitForFinalTranscript(ctx, callId);
|
const transcript = await waitForFinalTranscript(ctx, callId);
|
||||||
const transcriptReceivedAt = Date.now();
|
const transcriptReceivedAt = Date.now();
|
||||||
|
|
||||||
// Best-effort: stop listening after final transcript.
|
// Best-effort: stop listening after final transcript.
|
||||||
await ctx.provider.stopListening({ callId, providerCallId: call.providerCallId });
|
await provider.stopListening({ callId, providerCallId });
|
||||||
|
|
||||||
const lastTurnLatencyMs = transcriptReceivedAt - turnStartedAt;
|
const lastTurnLatencyMs = transcriptReceivedAt - turnStartedAt;
|
||||||
const lastTurnListenWaitMs = transcriptReceivedAt - listenStartedAt;
|
const lastTurnListenWaitMs = transcriptReceivedAt - listenStartedAt;
|
||||||
@ -302,21 +326,19 @@ export async function endCall(
|
|||||||
ctx: EndCallContext,
|
ctx: EndCallContext,
|
||||||
callId: CallId,
|
callId: CallId,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
const call = ctx.activeCalls.get(callId);
|
const lookup = lookupConnectedCall(ctx, callId);
|
||||||
if (!call) {
|
if (lookup.kind === "error") {
|
||||||
return { success: false, error: "Call not found" };
|
return { success: false, error: lookup.error };
|
||||||
}
|
}
|
||||||
if (!ctx.provider || !call.providerCallId) {
|
if (lookup.kind === "ended") {
|
||||||
return { success: false, error: "Call not connected" };
|
|
||||||
}
|
|
||||||
if (TerminalStates.has(call.state)) {
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
const { call, providerCallId, provider } = lookup;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ctx.provider.hangupCall({
|
await provider.hangupCall({
|
||||||
callId,
|
callId,
|
||||||
providerCallId: call.providerCallId,
|
providerCallId,
|
||||||
reason: "hangup-bot",
|
reason: "hangup-bot",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -329,9 +351,7 @@ export async function endCall(
|
|||||||
rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot");
|
rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot");
|
||||||
|
|
||||||
ctx.activeCalls.delete(callId);
|
ctx.activeCalls.delete(callId);
|
||||||
if (call.providerCallId) {
|
ctx.providerCallIdMap.delete(providerCallId);
|
||||||
ctx.providerCallIdMap.delete(call.providerCallId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user