refactor(voice-call): split manager
This commit is contained in:
178
extensions/voice-call/src/manager/events.ts
Normal file
178
extensions/voice-call/src/manager/events.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import type { CallId, CallRecord, CallState, NormalizedEvent } from "../types.js";
|
||||
import { TerminalStates } from "../types.js";
|
||||
import type { CallManagerContext } from "./context.js";
|
||||
import { findCall } from "./lookup.js";
|
||||
import { addTranscriptEntry, transitionState } from "./state.js";
|
||||
import { persistCallRecord } from "./store.js";
|
||||
import {
|
||||
clearMaxDurationTimer,
|
||||
rejectTranscriptWaiter,
|
||||
resolveTranscriptWaiter,
|
||||
startMaxDurationTimer,
|
||||
} from "./timers.js";
|
||||
import { endCall } from "./outbound.js";
|
||||
|
||||
function shouldAcceptInbound(config: CallManagerContext["config"], from: string | undefined): boolean {
|
||||
const { inboundPolicy: policy, allowFrom } = config;
|
||||
|
||||
switch (policy) {
|
||||
case "disabled":
|
||||
console.log("[voice-call] Inbound call rejected: policy is disabled");
|
||||
return false;
|
||||
|
||||
case "open":
|
||||
console.log("[voice-call] Inbound call accepted: policy is open");
|
||||
return true;
|
||||
|
||||
case "allowlist":
|
||||
case "pairing": {
|
||||
const normalized = from?.replace(/\D/g, "") || "";
|
||||
const allowed = (allowFrom || []).some((num) => {
|
||||
const normalizedAllow = num.replace(/\D/g, "");
|
||||
return normalized.endsWith(normalizedAllow) || normalizedAllow.endsWith(normalized);
|
||||
});
|
||||
const status = allowed ? "accepted" : "rejected";
|
||||
console.log(
|
||||
`[voice-call] Inbound call ${status}: ${from} ${allowed ? "is in" : "not in"} allowlist`,
|
||||
);
|
||||
return allowed;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createInboundCall(params: {
|
||||
ctx: CallManagerContext;
|
||||
providerCallId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}): CallRecord {
|
||||
const callId = crypto.randomUUID();
|
||||
|
||||
const callRecord: CallRecord = {
|
||||
callId,
|
||||
providerCallId: params.providerCallId,
|
||||
provider: params.ctx.provider?.name || "twilio",
|
||||
direction: "inbound",
|
||||
state: "ringing",
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
startedAt: Date.now(),
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
metadata: {
|
||||
initialMessage: params.ctx.config.inboundGreeting || "Hello! How can I help you today?",
|
||||
},
|
||||
};
|
||||
|
||||
params.ctx.activeCalls.set(callId, callRecord);
|
||||
params.ctx.providerCallIdMap.set(params.providerCallId, callId);
|
||||
persistCallRecord(params.ctx.storePath, callRecord);
|
||||
|
||||
console.log(`[voice-call] Created inbound call record: ${callId} from ${params.from}`);
|
||||
return callRecord;
|
||||
}
|
||||
|
||||
export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): void {
|
||||
if (ctx.processedEventIds.has(event.id)) return;
|
||||
ctx.processedEventIds.add(event.id);
|
||||
|
||||
let call = findCall({
|
||||
activeCalls: ctx.activeCalls,
|
||||
providerCallIdMap: ctx.providerCallIdMap,
|
||||
callIdOrProviderCallId: event.callId,
|
||||
});
|
||||
|
||||
if (!call && event.direction === "inbound" && event.providerCallId) {
|
||||
if (!shouldAcceptInbound(ctx.config, event.from)) {
|
||||
// TODO: Could hang up the call here.
|
||||
return;
|
||||
}
|
||||
|
||||
call = createInboundCall({
|
||||
ctx,
|
||||
providerCallId: event.providerCallId,
|
||||
from: event.from || "unknown",
|
||||
to: event.to || ctx.config.fromNumber || "unknown",
|
||||
});
|
||||
|
||||
// Normalize event to internal ID for downstream consumers.
|
||||
event.callId = call.callId;
|
||||
}
|
||||
|
||||
if (!call) return;
|
||||
|
||||
if (event.providerCallId && !call.providerCallId) {
|
||||
call.providerCallId = event.providerCallId;
|
||||
ctx.providerCallIdMap.set(event.providerCallId, call.callId);
|
||||
}
|
||||
|
||||
call.processedEventIds.push(event.id);
|
||||
|
||||
switch (event.type) {
|
||||
case "call.initiated":
|
||||
transitionState(call, "initiated");
|
||||
break;
|
||||
|
||||
case "call.ringing":
|
||||
transitionState(call, "ringing");
|
||||
break;
|
||||
|
||||
case "call.answered":
|
||||
call.answeredAt = event.timestamp;
|
||||
transitionState(call, "answered");
|
||||
startMaxDurationTimer({
|
||||
ctx,
|
||||
callId: call.callId,
|
||||
onTimeout: async (callId) => {
|
||||
await endCall(ctx, callId);
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case "call.active":
|
||||
transitionState(call, "active");
|
||||
break;
|
||||
|
||||
case "call.speaking":
|
||||
transitionState(call, "speaking");
|
||||
break;
|
||||
|
||||
case "call.speech":
|
||||
if (event.isFinal) {
|
||||
addTranscriptEntry(call, "user", event.transcript);
|
||||
resolveTranscriptWaiter(ctx, call.callId, event.transcript);
|
||||
}
|
||||
transitionState(call, "listening");
|
||||
break;
|
||||
|
||||
case "call.ended":
|
||||
call.endedAt = event.timestamp;
|
||||
call.endReason = event.reason;
|
||||
transitionState(call, event.reason as CallState);
|
||||
clearMaxDurationTimer(ctx, call.callId);
|
||||
rejectTranscriptWaiter(ctx, call.callId, `Call ended: ${event.reason}`);
|
||||
ctx.activeCalls.delete(call.callId);
|
||||
if (call.providerCallId) ctx.providerCallIdMap.delete(call.providerCallId);
|
||||
break;
|
||||
|
||||
case "call.error":
|
||||
if (!event.retryable) {
|
||||
call.endedAt = event.timestamp;
|
||||
call.endReason = "error";
|
||||
transitionState(call, "error");
|
||||
clearMaxDurationTimer(ctx, call.callId);
|
||||
rejectTranscriptWaiter(ctx, call.callId, `Call error: ${event.error}`);
|
||||
ctx.activeCalls.delete(call.callId);
|
||||
if (call.providerCallId) ctx.providerCallIdMap.delete(call.providerCallId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user