Enables outbound PSTN probes via TwiML webhooks with Webex result cards, status polling, and optional store CDR enrichment.
403 lines
12 KiB
JavaScript
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();
|
|
}
|