169 lines
4.1 KiB
TypeScript
169 lines
4.1 KiB
TypeScript
import crypto from "node:crypto";
|
|
|
|
import type {
|
|
EndReason,
|
|
HangupCallInput,
|
|
InitiateCallInput,
|
|
InitiateCallResult,
|
|
NormalizedEvent,
|
|
PlayTtsInput,
|
|
ProviderWebhookParseResult,
|
|
StartListeningInput,
|
|
StopListeningInput,
|
|
WebhookContext,
|
|
WebhookVerificationResult,
|
|
} from "../types.js";
|
|
import type { VoiceCallProvider } from "./base.js";
|
|
|
|
/**
|
|
* Mock voice call provider for local testing.
|
|
*
|
|
* Events are driven via webhook POST with JSON body:
|
|
* - { events: NormalizedEvent[] } for bulk events
|
|
* - { event: NormalizedEvent } for single event
|
|
*/
|
|
export class MockProvider implements VoiceCallProvider {
|
|
readonly name = "mock" as const;
|
|
|
|
verifyWebhook(_ctx: WebhookContext): WebhookVerificationResult {
|
|
return { ok: true };
|
|
}
|
|
|
|
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
|
|
try {
|
|
const payload = JSON.parse(ctx.rawBody);
|
|
const events: NormalizedEvent[] = [];
|
|
|
|
if (Array.isArray(payload.events)) {
|
|
for (const evt of payload.events) {
|
|
const normalized = this.normalizeEvent(evt);
|
|
if (normalized) events.push(normalized);
|
|
}
|
|
} else if (payload.event) {
|
|
const normalized = this.normalizeEvent(payload.event);
|
|
if (normalized) events.push(normalized);
|
|
}
|
|
|
|
return { events, statusCode: 200 };
|
|
} catch {
|
|
return { events: [], statusCode: 400 };
|
|
}
|
|
}
|
|
|
|
private normalizeEvent(
|
|
evt: Partial<NormalizedEvent>,
|
|
): NormalizedEvent | null {
|
|
if (!evt.type || !evt.callId) return null;
|
|
|
|
const base = {
|
|
id: evt.id || crypto.randomUUID(),
|
|
callId: evt.callId,
|
|
providerCallId: evt.providerCallId,
|
|
timestamp: evt.timestamp || Date.now(),
|
|
};
|
|
|
|
switch (evt.type) {
|
|
case "call.initiated":
|
|
case "call.ringing":
|
|
case "call.answered":
|
|
case "call.active":
|
|
return { ...base, type: evt.type };
|
|
|
|
case "call.speaking": {
|
|
const payload = evt as Partial<NormalizedEvent & { text?: string }>;
|
|
return {
|
|
...base,
|
|
type: evt.type,
|
|
text: payload.text || "",
|
|
};
|
|
}
|
|
|
|
case "call.speech": {
|
|
const payload = evt as Partial<
|
|
NormalizedEvent & {
|
|
transcript?: string;
|
|
isFinal?: boolean;
|
|
confidence?: number;
|
|
}
|
|
>;
|
|
return {
|
|
...base,
|
|
type: evt.type,
|
|
transcript: payload.transcript || "",
|
|
isFinal: payload.isFinal ?? true,
|
|
confidence: payload.confidence,
|
|
};
|
|
}
|
|
|
|
case "call.silence": {
|
|
const payload = evt as Partial<
|
|
NormalizedEvent & { durationMs?: number }
|
|
>;
|
|
return {
|
|
...base,
|
|
type: evt.type,
|
|
durationMs: payload.durationMs || 0,
|
|
};
|
|
}
|
|
|
|
case "call.dtmf": {
|
|
const payload = evt as Partial<NormalizedEvent & { digits?: string }>;
|
|
return {
|
|
...base,
|
|
type: evt.type,
|
|
digits: payload.digits || "",
|
|
};
|
|
}
|
|
|
|
case "call.ended": {
|
|
const payload = evt as Partial<
|
|
NormalizedEvent & { reason?: EndReason }
|
|
>;
|
|
return {
|
|
...base,
|
|
type: evt.type,
|
|
reason: payload.reason || "completed",
|
|
};
|
|
}
|
|
|
|
case "call.error": {
|
|
const payload = evt as Partial<
|
|
NormalizedEvent & { error?: string; retryable?: boolean }
|
|
>;
|
|
return {
|
|
...base,
|
|
type: evt.type,
|
|
error: payload.error || "unknown error",
|
|
retryable: payload.retryable,
|
|
};
|
|
}
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
|
|
return {
|
|
providerCallId: `mock-${input.callId}`,
|
|
status: "initiated",
|
|
};
|
|
}
|
|
|
|
async hangupCall(_input: HangupCallInput): Promise<void> {
|
|
// No-op for mock
|
|
}
|
|
|
|
async playTts(_input: PlayTtsInput): Promise<void> {
|
|
// No-op for mock
|
|
}
|
|
|
|
async startListening(_input: StartListeningInput): Promise<void> {
|
|
// No-op for mock
|
|
}
|
|
|
|
async stopListening(_input: StopListeningInput): Promise<void> {
|
|
// No-op for mock
|
|
}
|
|
}
|