Add store/email/phone filtering, richer call-line formatting, CDR feed pagination and queueing, and split Jira poller enrichment into testable modules. Co-authored-by: Cursor <cursoragent@cursor.com>
210 lines
6 KiB
JavaScript
210 lines
6 KiB
JavaScript
// services/cdrFeedParser.js
|
|
// Parse cdr_feed JSON payloads (field names vary by Webex release).
|
|
|
|
function normKey(key) {
|
|
return String(key).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
}
|
|
|
|
function indexCdrRecord(item) {
|
|
if (!item || typeof item !== 'object') return {};
|
|
const idx = {};
|
|
for (const [k, v] of Object.entries(item)) {
|
|
idx[normKey(k)] = v;
|
|
}
|
|
return idx;
|
|
}
|
|
|
|
/** Read a CDR field by any known alias (camelCase or report column label). */
|
|
export function cdrField(item, ...aliases) {
|
|
if (!item) return null;
|
|
const idx = indexCdrRecord(item);
|
|
for (const alias of aliases) {
|
|
const v = idx[normKey(alias)];
|
|
if (v != null && v !== '') return v;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function cdrRecordId(item) {
|
|
return cdrField(
|
|
item,
|
|
'reportId', 'Report ID', 'reportID',
|
|
'callRecordingId',
|
|
);
|
|
}
|
|
|
|
export function cdrStartTime(item) {
|
|
return cdrField(
|
|
item,
|
|
'startTime', 'start', 'callStartTime',
|
|
'Start time', 'Start Time',
|
|
'answerTime', 'Answer time', 'Answer Time',
|
|
);
|
|
}
|
|
|
|
export function cdrDurationSeconds(item) {
|
|
const raw = cdrField(
|
|
item,
|
|
'durationSeconds', 'duration', 'callDuration', 'talkDuration', 'length',
|
|
'Duration',
|
|
);
|
|
return Number(raw || 0);
|
|
}
|
|
|
|
export function cdrDirectionValue(item) {
|
|
return String(cdrField(
|
|
item,
|
|
'direction', 'callDirection', 'callType', 'directionIndicator',
|
|
'Direction', 'Call direction', 'Call Direction',
|
|
) || '').toUpperCase();
|
|
}
|
|
|
|
export function cdrDispositionValue(item) {
|
|
return String(cdrField(
|
|
item,
|
|
'status', 'result', 'callResult', 'callOutcome', 'callStatus',
|
|
'Disposition', 'Answer indicator', 'Call outcome',
|
|
) || 'unknown');
|
|
}
|
|
|
|
export function cdrCallingNumber(item) {
|
|
return cdrField(
|
|
item,
|
|
'callingNumber', 'callingParty', 'callingName',
|
|
'Calling number', 'Calling Number',
|
|
'User number', 'User Number',
|
|
);
|
|
}
|
|
|
|
export function cdrCalledNumber(item) {
|
|
return cdrField(
|
|
item,
|
|
'calledNumber', 'calledParty', 'calledName',
|
|
'Called number', 'Called Number',
|
|
);
|
|
}
|
|
|
|
export function cdrCallingLineId(item) {
|
|
return cdrField(item, 'callingLineId', 'Calling line ID', 'calling line id');
|
|
}
|
|
|
|
export function cdrCalledLineId(item) {
|
|
return cdrField(item, 'calledLineId', 'Called line ID', 'called line id');
|
|
}
|
|
|
|
export function cdrDialedDigits(item) {
|
|
return cdrField(item, 'dialedDigits', 'Dialed digits', 'dialed digits');
|
|
}
|
|
|
|
export function cdrCallOutcome(item) {
|
|
return cdrField(item, 'Call outcome', 'call outcome');
|
|
}
|
|
|
|
export function cdrCallOutcomeReason(item) {
|
|
return cdrField(item, 'Call outcome reason', 'call outcome reason');
|
|
}
|
|
|
|
export function cdrCallerIdNumber(item) {
|
|
return cdrField(item, 'Caller ID number', 'caller id number', 'callerIdNumber');
|
|
}
|
|
|
|
export function cdrUserName(item) {
|
|
return cdrField(item, 'User', 'user');
|
|
}
|
|
|
|
export function cdrUserNumber(item) {
|
|
return cdrField(item, 'User number', 'user number');
|
|
}
|
|
|
|
export function cdrModel(item) {
|
|
return cdrField(item, 'Model', 'model');
|
|
}
|
|
|
|
export function cdrSiteMainNumber(item) {
|
|
return cdrField(item, 'Site main number', 'site main number');
|
|
}
|
|
|
|
export function formatCallDisposition(outcome, reason) {
|
|
const o = String(outcome || '').trim();
|
|
const r = String(reason || '').trim();
|
|
if (!o) return 'unknown';
|
|
if (!r || r.toLowerCase() === 'normal') return o;
|
|
return `${o} / ${r}`;
|
|
}
|
|
|
|
export function cdrDedupKey(item) {
|
|
const id = cdrRecordId(item);
|
|
if (id) return `id:${id}`;
|
|
return [
|
|
cdrStartTime(item) || '',
|
|
cdrCallingNumber(item) || '',
|
|
cdrCalledNumber(item) || '',
|
|
cdrDurationSeconds(item) || '',
|
|
].join('|');
|
|
}
|
|
|
|
export function extractCdrFeedList(data) {
|
|
if (!data) return [];
|
|
if (Array.isArray(data)) return data;
|
|
if (Array.isArray(data.items)) return data.items;
|
|
if (data.items && typeof data.items === 'object' && data.items !== null) {
|
|
const keys = Object.keys(data.items).filter((k) => /^\d+$/.test(k)).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
|
if (keys.length > 0) return keys.map((k) => data.items[k]);
|
|
}
|
|
const topKeys = Object.keys(data).filter((k) => /^\d+$/.test(k)).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
|
if (topKeys.length > 0) return topKeys.map((k) => data[k]);
|
|
for (const key of ['records', 'cdrRecords', 'callHistory', 'calls']) {
|
|
if (Array.isArray(data[key])) return data[key];
|
|
}
|
|
if (data.data) {
|
|
if (Array.isArray(data.data)) return data.data;
|
|
if (Array.isArray(data.data.items)) return data.data.items;
|
|
}
|
|
for (const v of Object.values(data)) {
|
|
if (Array.isArray(v)) return v;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export function describeCdrFeedPayload(data) {
|
|
if (!data) return 'empty body';
|
|
if (Array.isArray(data)) return `array[${data.length}]`;
|
|
const keys = Object.keys(data);
|
|
const parts = [`keys=${keys.slice(0, 12).join(',')}`];
|
|
for (const k of ['items', 'records', 'cdrRecords', 'next', 'data']) {
|
|
const v = data[k];
|
|
if (Array.isArray(v)) parts.push(`${k}.length=${v.length}`);
|
|
else if (v && typeof v === 'object') parts.push(`${k}.keys=${Object.keys(v).slice(0, 6).join(',')}`);
|
|
}
|
|
return parts.join(' ');
|
|
}
|
|
|
|
/**
|
|
* Parse Webex RFC5988 Link header and return the `rel="next"` URL.
|
|
* @param {string|undefined} linkHeader
|
|
* @returns {string|null}
|
|
*/
|
|
export function parseCdrFeedLinkNext(linkHeader) {
|
|
if (!linkHeader || typeof linkHeader !== 'string') return null;
|
|
for (const part of linkHeader.split(',')) {
|
|
const m = part.match(/<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i);
|
|
if (m) return m[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the next cdr_feed page from response body and/or Link header.
|
|
* @returns {{ url: string, params: object|null }|null}
|
|
*/
|
|
export function resolveCdrFeedNextPage(data, headers, baseUrl, baseParams) {
|
|
const linkNext = parseCdrFeedLinkNext(headers?.link || headers?.Link);
|
|
if (linkNext) return { url: linkNext, params: null };
|
|
|
|
const token = data?.next || data?.['next'] || data?.metadata?.next || null;
|
|
if (!token) return null;
|
|
if (typeof token === 'string' && token.startsWith('http')) {
|
|
return { url: token, params: null };
|
|
}
|
|
return { url: baseUrl, params: { ...baseParams, next: token } };
|
|
}
|