// 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, onQueued: session.onCdrQueued, }); 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, onCdrQueued: async ({ runAt, waitMs }) => { const secs = Math.max(1, Math.ceil(waitMs / 1000)); await notifyRoom( session.roomId, `⏳ **Call test CDR** — query queued for \`${session.testId}\`. ` + `Runs at **${runAt.toLocaleTimeString()}** (~${secs}s).`, ); }, }); 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(); }