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>
214 lines
6.3 KiB
JavaScript
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();
|
|
}
|