// 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 { renderCallTestCdrMatchMarkdown, 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 { scheduleCdrMatchEnrichment } = await import('./cdrMatcher.js'); scheduleCdrMatchEnrichment(session, { notifyRoom, renderMarkdown: (s, result) => renderCallTestCdrMatchMarkdown(s, result), }); } async function schedulePostCallEnrichment(session) { const { scheduleTwilioEnrichment } = await import('./twilioEnrichment.js'); scheduleTwilioEnrichment(session, notifyRoom); await scheduleCdrEnrichment(session); } 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)); await schedulePostCallEnrichment(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} 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(); }