179 lines
5.1 KiB
TypeScript
179 lines
5.1 KiB
TypeScript
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);
|
|
}
|
|
|