collabSupport/services/callTest/cdrMatcher.js
jmcqueen 1eaabbcee1 Add /voicereport store digest and harden Webex CDR feed handling.
Groups cdr_feed legs by Correlation ID for call-level summaries, fixes report-column field parsing and Docker proxy routing, and adds /calltest post-call Twilio and CDR enrichment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 13:43:11 -04:00

214 lines
6.3 KiB
JavaScript

// services/callTest/cdrMatcher.js
// Narrow-window Webex CDR fetch + match the specific /calltest leg.
import { logger } from '../../utils/logger.js';
import { getCallTestConfig } from './config.js';
import { getSession, updateSession } from './sessionStore.js';
import {
cdrCalledNumber,
cdrCallingNumber,
cdrDirectionValue,
cdrDispositionValue,
cdrDurationSeconds,
cdrStartTime,
} from '../cdrFeedParser.js';
const LOG_SCOPE = 'calltest:cdr';
const FIVE_MIN_MS = 5 * 60 * 1000;
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
export function phoneDigits(value) {
const d = String(value || '').replace(/\D/g, '');
if (d.length === 10) return `1${d}`;
if (d.length === 11 && d.startsWith('1')) return d;
return d;
}
/**
* Compute cdr_feed window from session events (Webex API rules applied).
*/
export function computeCdrWindow(session, bufferMs) {
const events = session.events || {};
const startMs = new Date(
events.initiated || events.coreStartedAt || session.createdAt || Date.now(),
).getTime() - bufferMs;
let endMs = new Date(events.completed || Date.now()).getTime() + bufferMs;
const latestAllowedEnd = Date.now() - FIVE_MIN_MS;
if (endMs > latestAllowedEnd) endMs = latestAllowedEnd;
let start = startMs;
if (endMs - start > TWELVE_HOURS_MS) {
start = endMs - TWELVE_HOURS_MS;
}
if (start > endMs) {
start = endMs - Math.min(TWELVE_HOURS_MS, 30 * 60 * 1000);
}
return {
startTime: new Date(start).toISOString(),
endTime: new Date(endMs).toISOString(),
};
}
/**
* Score CDR leg by destination (called number) matching the dialed test number.
* Time proximity is only a tiebreaker when multiple legs hit the same DID.
*/
export function scoreCdrLeg(item, session) {
const dialDigits = phoneDigits(session.dialNumber);
if (!dialDigits) return 0;
const called = phoneDigits(cdrCalledNumber(item) || '');
if (called !== dialDigits) return 0;
let score = 100;
const itemStart = new Date(cdrStartTime(item) || 0).getTime();
const anchor = session.events?.initiated
|| session.events?.answered
|| session.events?.coreStartedAt;
if (anchor && itemStart) {
const delta = Math.abs(itemStart - new Date(anchor).getTime());
if (delta < 120_000) score += 20;
else if (delta < 300_000) score += 5;
}
return score;
}
export function pickBestCdrLeg(items, session) {
if (!items?.length) return null;
let best = null;
let bestScore = 0;
for (const item of items) {
const s = scoreCdrLeg(item, session);
if (s > bestScore) {
bestScore = s;
best = item;
}
}
if (!best || bestScore < 100) return null;
return { leg: best, score: bestScore };
}
export function formatCdrLeg(item) {
if (!item) return null;
return {
start: cdrStartTime(item),
direction: cdrDirectionValue(item),
duration: cdrDurationSeconds(item),
status: cdrDispositionValue(item),
callingNumber: cdrCallingNumber(item),
calledNumber: cdrCalledNumber(item),
otherParty: item.otherParty || item.remoteParty,
};
}
async function resolveCdrContext(session) {
if (session.locationName && session.personId) {
return {
personId: session.personId,
locationName: session.locationName,
};
}
if (session.locationName) {
return { personId: session.personId || null, locationName: session.locationName };
}
if (session.mode === 'dial') {
const { resolveLocationForDialNumber } = await import('./locationResolver.js');
const loc = await resolveLocationForDialNumber(session.dialNumber);
if (loc) {
return { personId: null, locationName: loc.locationName, locationId: loc.locationId };
}
}
return null;
}
/**
* Fetch CDR and match the test call leg.
*/
export async function fetchMatchedCdrForSession(session) {
const ctx = await resolveCdrContext(session);
if (!ctx?.locationName) {
return {
available: false,
reason: session.mode === 'dial'
? 'dialed number is not a known Webex location main number'
: 'no locationName on session',
match: null,
};
}
const cfg = session.config || getCallTestConfig(session.storeNum);
const { startTime, endTime } = computeCdrWindow(session, cfg.cdrMatchBufferMs);
const { getHistoricalCallActivity } = await import('../phoneService.js');
const cdr = await getHistoricalCallActivity(ctx.personId, 12, {
locationName: ctx.locationName,
startTime,
endTime,
returnRawItems: true,
skipPersonFilter: !ctx.personId,
});
if (!cdr.available) {
return { available: false, reason: cdr.reason, match: null, window: { startTime, endTime } };
}
const items = cdr.rawItems || [];
const picked = pickBestCdrLeg(items, session);
return {
available: true,
location: ctx.locationName,
window: { startTime, endTime },
rawCount: items.length,
match: picked ? { ...formatCdrLeg(picked.leg), score: picked.score } : null,
reason: picked ? null : `no leg matched (${items.length} records in window)`,
};
}
const _cdrTimers = new Map();
export function scheduleCdrMatchEnrichment(session, { notifyRoom, renderMarkdown }) {
const cfg = session.config || getCallTestConfig(session.storeNum);
if (!cfg.cdrEnrich || !session.roomId) return;
const minDelay = Math.max(cfg.cdrDelayMs, FIVE_MIN_MS + 30_000);
if (_cdrTimers.has(session.testId)) return;
const timer = setTimeout(async () => {
_cdrTimers.delete(session.testId);
const cur = getSession(session.testId);
if (!cur) return;
try {
logger(LOG_SCOPE, `CDR match fetch for testId=${session.testId}`, 'info');
const result = await fetchMatchedCdrForSession(cur);
updateSession(session.testId, {
enrichment: {
...(cur.enrichment || {}),
cdr: { ...result, fetchedAt: new Date().toISOString() },
},
});
await notifyRoom(session.roomId, renderMarkdown(getSession(session.testId), result));
} catch (err) {
logger(LOG_SCOPE, `CDR match failed for ${session.testId}: ${err.message}`, 'warn');
await notifyRoom(
session.roomId,
`**Call test CDR** (\`${session.testId}\`)\n\n_CDR match failed:_ ${err.message}`,
);
}
}, minDelay);
if (typeof timer.unref === 'function') timer.unref();
_cdrTimers.set(session.testId, timer);
}
export function _clearCdrTimersForTests() {
for (const t of _cdrTimers.values()) clearTimeout(t);
_cdrTimers.clear();
}