collabSupport/services/callTest/callTestService.js
Joseph McQueen 7aa8c37d1d Add Twilio /calltest for store and direct-dial voice path testing.
Enables outbound PSTN probes via TwiML webhooks with Webex result cards, status polling, and optional store CDR enrichment.
2026-07-23 17:59:10 -04:00

403 lines
12 KiB
JavaScript

// services/callTest/callTestService.js
// Orchestrates /calltest Twilio outbound calls, webhooks, and Webex notifications.
import { randomUUID } from 'node:crypto';
import {
createOutboundCall,
isCallTestEnabled,
isTwilioConfigured,
} from '../../integrations/twilio/client.js';
import { logger } from '../../utils/logger.js';
import {
buildStatusWebhookUrl,
buildVoiceWebhookUrl,
getCallTestConfig,
} from './config.js';
import {
createSession,
getSession,
listActiveByDestination,
updateSession,
} from './sessionStore.js';
import {
buildTwiml,
initialStepForMode,
isCoreStep,
} from './twiml.js';
import {
renderCallTestCdrMarkdown,
renderCallTestResultMarkdown,
renderCallTestStartedMarkdown,
} from '../renderers/callTestRenderer.js';
const LOG_SCOPE = 'calltest:service';
const STORE_ENTRY_FAIL_STEPS = ['greeting', 'dtmf', 'wait'];
const TERMINAL_CALL_STATUSES = new Set(['completed', 'busy', 'failed', 'no-answer', 'canceled']);
const FINALIZE_AFTER_DONE_MS = Number(process.env.CALLTEST_FINALIZE_DELAY_MS) || 5000;
const _pendingFinalize = new Map();
export function isCallTestConfigured() {
return isTwilioConfigured() && isCallTestEnabled();
}
function wasCallAnswered(session) {
const events = session.events || {};
return !!(events.answered || events['in-progress'] || session.reachedCore);
}
export function getConfigurationMessage() {
if (!isTwilioConfigured()) {
return (
'Call test is not configured. Set `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, ' +
'`TWILIO_FROM_NUMBER`, and `TWILIO_WEBHOOK_BASE_URL` in `.env`.'
);
}
if (!isCallTestEnabled()) {
return 'Call test is disabled. Set `CALLTEST_ENABLED=true` in `.env`.';
}
return null;
}
async function notifyRoom(roomId, markdown) {
if (!roomId) return;
try {
const { default: botClient } = await import('../../integrations/webex/BotClient.js');
await botClient.sendMarkdown(roomId, markdown);
} catch (err) {
logger(LOG_SCOPE, `Failed to notify room ${roomId}: ${err.message}`, 'error');
}
}
function computeResult(session) {
const status = session.twilioCallStatus;
const answered = wasCallAnswered(session);
const reachedDone = session.lastStep === 'done' || (session.reachedCore && session.lastStep === 'outro');
if (!answered && TERMINAL_CALL_STATUSES.has(status)) {
return { result: 'fail', resultReason: `Call ended: ${status || 'no-answer'}` };
}
if (!answered) {
return { result: 'fail', resultReason: 'Call was never answered' };
}
if (session.failedAt) {
return { result: 'fail', resultReason: `Disconnected during entry (${session.failedAt})` };
}
if (status === 'completed' && (reachedDone || session.reachedCore)) {
const dur = session.durationSec;
if (dur != null && dur < 10) {
return { result: 'warn', resultReason: 'Completed but very short duration (<10s)' };
}
return { result: 'pass', resultReason: 'Call completed through shared test core' };
}
if (status === 'completed') {
return { result: 'fail', resultReason: 'Call completed before test core finished' };
}
if (session.lastStep === 'done' && session.reachedCore) {
return { result: 'pass', resultReason: 'Call completed through shared test core' };
}
if (!session.result && (session.status === 'in_progress' || session.status === 'pending')) {
return { result: null, resultReason: 'Call test still in progress' };
}
return { result: 'fail', resultReason: status ? `Unexpected status: ${status}` : 'Unknown outcome' };
}
async function scheduleCdrEnrichment(session) {
const cfg = getCallTestConfig(session.storeNum);
if (!cfg.cdrEnrich || session.mode !== 'store' || !session.personId) return;
const delay = cfg.cdrDelayMs;
logger(LOG_SCOPE, `Scheduling CDR enrich for ${session.testId} in ${delay}ms`, 'debug');
setTimeout(async () => {
try {
const { getHistoricalCallActivity } = await import('../phoneService.js');
const cdr = await getHistoricalCallActivity(session.personId, 12, {
locationName: session.locationName,
});
const md = renderCallTestCdrMarkdown(session, cdr);
await notifyRoom(session.roomId, md);
} catch (err) {
logger(LOG_SCOPE, `CDR enrich failed for ${session.testId}: ${err.message}`, 'warn');
await notifyRoom(
session.roomId,
`**Call test CDR** (\`${session.testId}\`)\n\n_CDR fetch failed:_ ${err.message}`,
);
}
}, delay).unref?.();
}
async function finalizeSession(testId) {
const session = getSession(testId);
if (!session || session.result || session.status === 'completed' || session.status === 'failed') {
return session;
}
const { result, resultReason } = computeResult(session);
if (result == null) return session;
const next = updateSession(testId, {
status: result === 'pass' || result === 'warn' ? 'completed' : 'failed',
result,
resultReason,
});
if (next?.roomId) {
await notifyRoom(next.roomId, renderCallTestResultMarkdown(next));
}
if (next?.mode === 'store') {
scheduleCdrEnrichment(next);
}
return next;
}
async function tryFinalizeFromVoiceDone(testId) {
const session = getSession(testId);
if (!session || session.result || session.status === 'completed' || session.status === 'failed') {
return session;
}
if (session.lastStep !== 'done' || !session.reachedCore) return session;
const now = new Date().toISOString();
const events = { ...session.events };
if (!events.answered) {
events.answered = events['in-progress'] || events.coreStartedAt || now;
}
if (!events.completed) {
events.completed = now;
}
const patch = { events };
if (!TERMINAL_CALL_STATUSES.has(session.twilioCallStatus)) {
patch.twilioCallStatus = 'completed';
}
updateSession(testId, patch);
logger(LOG_SCOPE, `Finalizing testId=${testId} from voice done step`, 'info');
return finalizeSession(testId);
}
function scheduleFinalizeFromVoiceDone(testId) {
if (_pendingFinalize.has(testId)) return;
const timer = setTimeout(() => {
_pendingFinalize.delete(testId);
void tryFinalizeFromVoiceDone(testId);
}, FINALIZE_AFTER_DONE_MS);
if (typeof timer.unref === 'function') timer.unref();
_pendingFinalize.set(testId, timer);
}
/**
* @param {'store'|'dial'} mode
* @param {object} opts
*/
async function startTest(mode, opts) {
const cfgMsg = getConfigurationMessage();
if (cfgMsg) throw new Error(cfgMsg);
const testId = randomUUID();
const config = getCallTestConfig(opts.storeNum || null);
const initialStep = initialStepForMode(mode);
const active = listActiveByDestination(opts.dialNumber);
if (active.length) {
const existing = active[0];
throw new Error(
`A call test is already in progress for ${opts.dialNumber} (testId \`${existing.testId}\`).`,
);
}
const session = createSession(testId, {
mode,
storeNum: opts.storeNum || null,
dialNumber: opts.dialNumber,
roomId: opts.roomId || null,
requester: opts.requester || null,
personId: opts.personId || null,
locationId: opts.locationId || null,
locationName: opts.locationName || null,
config,
status: 'in_progress',
});
const voiceUrl = buildVoiceWebhookUrl(testId, mode, initialStep);
const statusUrl = buildStatusWebhookUrl(testId);
try {
const call = await createOutboundCall({
to: opts.dialNumber,
voiceUrl,
statusCallback: statusUrl,
timeoutSec: config.dialTimeoutSec,
});
updateSession(testId, { callSid: call.sid, twilioCallStatus: call.status });
} catch (err) {
updateSession(testId, { status: 'failed', result: 'fail', resultReason: err.message });
throw err;
}
return getSession(testId);
}
export async function startStoreTest({ storeNum, dialOverride, roomId, requester }) {
const { resolveStoreMainNumber } = await import('./storeResolver.js');
const resolved = await resolveStoreMainNumber(storeNum);
const dialNumber = dialOverride || resolved.dialNumber;
const session = await startTest('store', {
storeNum: resolved.storeNum,
dialNumber,
roomId,
requester,
personId: resolved.personId,
locationId: resolved.locationId,
locationName: resolved.locationName,
});
if (roomId) {
await notifyRoom(roomId, renderCallTestStartedMarkdown(session));
}
return session;
}
export async function startDialTest({ dialNumber, roomId, requester }) {
const session = await startTest('dial', {
dialNumber,
roomId,
requester,
});
if (roomId) {
await notifyRoom(roomId, renderCallTestStartedMarkdown(session));
}
return session;
}
/**
* @param {string} testId
* @param {'store'|'dial'} mode
* @param {string} step
*/
export function handleVoiceWebhook(testId, mode, step) {
const session = getSession(testId);
if (!session) {
logger(LOG_SCOPE, `Voice webhook for unknown testId ${testId}`, 'warn');
return buildTwiml({
mode: 'dial',
step: 'done',
testId,
config: getCallTestConfig(),
});
}
const config = session.config || getCallTestConfig(session.storeNum);
const patch = {
lastStep: step,
status: 'in_progress',
};
if (isCoreStep(step)) {
patch.reachedCore = true;
if (!session.events?.coreStartedAt) {
patch.events = { ...session.events, coreStartedAt: new Date().toISOString() };
}
}
if (mode === 'store' && step === 'dtmf') {
patch.events = { ...(patch.events || session.events), dtmfStepAt: new Date().toISOString() };
}
updateSession(testId, patch);
if (step === 'done') {
scheduleFinalizeFromVoiceDone(testId);
}
return buildTwiml({ mode, step, testId, config });
}
/**
* @param {string} testId
* @param {Record<string, string>} body Twilio status callback body
*/
export async function handleStatusCallback(testId, body) {
const session = getSession(testId);
if (!session) {
logger(LOG_SCOPE, `Status callback for unknown testId ${testId}`, 'warn');
return;
}
const callStatus = body.CallStatus || body.callStatus;
const callSid = body.CallSid || body.callSid;
const now = new Date().toISOString();
logger(
LOG_SCOPE,
`Status callback testId=${testId} CallStatus=${callStatus} CallSid=${callSid || 'n/a'}`,
'info',
);
const events = { ...session.events };
if (callStatus && !events[callStatus]) {
events[callStatus] = now;
}
if ((callStatus === 'answered' || callStatus === 'in-progress') && !events.answered) {
events.answered = now;
}
const patch = {
twilioCallStatus: callStatus,
callSid: callSid || session.callSid,
events,
};
if (callStatus === 'answered' || callStatus === 'in-progress') {
patch.status = 'in_progress';
}
if (callStatus === 'completed') {
const dur = body.CallDuration || body.Duration;
if (dur != null) patch.durationSec = Number(dur);
}
// If call ends before core and we were still in store entry
if (['completed', 'busy', 'failed', 'no-answer', 'canceled'].includes(callStatus)) {
if (!session.reachedCore && session.mode === 'store' && session.lastStep) {
if (STORE_ENTRY_FAIL_STEPS.includes(session.lastStep)) {
patch.failedAt = session.lastStep;
}
}
}
updateSession(testId, patch);
if (TERMINAL_CALL_STATUSES.has(callStatus)) {
await finalizeSession(testId);
}
}
export function getSessionForStatus(testId) {
const session = getSession(testId);
if (!session) return null;
if (session.result) return session;
const { result, resultReason } = computeResult(session);
return { ...session, result, resultReason };
}
export async function tryFinalizeFromVoiceDoneForTests(testId) {
return tryFinalizeFromVoiceDone(testId);
}
export function _clearPendingFinalizeTimersForTests() {
for (const timer of _pendingFinalize.values()) {
clearTimeout(timer);
}
_pendingFinalize.clear();
}