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>
177 lines
5.5 KiB
JavaScript
177 lines
5.5 KiB
JavaScript
// integrations/webex/reportsClient.js
|
|
// Webex Control Hub Reports API (Pro Pack) — Calling Media Quality.
|
|
|
|
import axios from 'axios';
|
|
import AdmZip from 'adm-zip';
|
|
import webex from './WebexClient.js';
|
|
import { logger } from '../../utils/logger.js';
|
|
import {
|
|
filterMediaQualityRows,
|
|
parseMediaQualityCsv,
|
|
reportDateRangeFromWindow,
|
|
} from './reportsCsv.js';
|
|
|
|
export { filterMediaQualityRows, parseMediaQualityCsv, reportDateRangeFromWindow } from './reportsCsv.js';
|
|
|
|
const LOG_SCOPE = 'webex:reports';
|
|
const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
|
let _templateCache = null;
|
|
let _templateCacheAt = 0;
|
|
|
|
function envInt(name, fallback) {
|
|
const v = Number(process.env[name]);
|
|
return Number.isFinite(v) ? v : fallback;
|
|
}
|
|
|
|
function pollIntervalMs() {
|
|
return envInt('VOICEREPORT_REPORT_POLL_MS', 5000);
|
|
}
|
|
|
|
function pollMaxMs() {
|
|
return envInt('VOICEREPORT_REPORT_POLL_MAX_MS', 180_000);
|
|
}
|
|
|
|
export function _clearReportTemplateCacheForTests() {
|
|
_templateCache = null;
|
|
_templateCacheAt = 0;
|
|
}
|
|
|
|
export async function listReportTemplates() {
|
|
const now = Date.now();
|
|
if (_templateCache && now - _templateCacheAt < TEMPLATE_CACHE_TTL_MS) {
|
|
return _templateCache;
|
|
}
|
|
const data = await webex.request('GET', 'reports/templates');
|
|
const items = data?.items || data?.reportTemplates || [];
|
|
_templateCache = items;
|
|
_templateCacheAt = now;
|
|
return items;
|
|
}
|
|
|
|
/**
|
|
* Resolve template id by env override or fuzzy name match.
|
|
*/
|
|
export async function resolveMediaQualityTemplateId() {
|
|
const override = process.env.WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY;
|
|
if (override && String(override).trim()) {
|
|
return Number(override);
|
|
}
|
|
const templates = await listReportTemplates();
|
|
const hit = templates.find((t) => {
|
|
const name = String(t.name || t.title || '').toLowerCase();
|
|
return name.includes('media quality') || name.includes('calling media');
|
|
});
|
|
return hit ? Number(hit.id ?? hit.templateId) : null;
|
|
}
|
|
|
|
export async function createReport(templateId, { startDate, endDate, siteList } = {}) {
|
|
if (!templateId || !Number.isFinite(templateId)) {
|
|
return { ok: false, reason: 'no Media Quality report template id (set WEBEX_REPORT_TEMPLATE_MEDIA_QUALITY)' };
|
|
}
|
|
const body = {
|
|
templateId: Number(templateId),
|
|
};
|
|
if (startDate) body.startDate = startDate;
|
|
if (endDate) body.endDate = endDate;
|
|
if (siteList) body.siteList = siteList;
|
|
|
|
try {
|
|
const data = await webex.request('POST', 'reports', body);
|
|
return { ok: true, report: data };
|
|
} catch (err) {
|
|
const msg = err.response?.data?.message || err.message;
|
|
logger(LOG_SCOPE, `createReport failed: ${msg}`, 'warn');
|
|
return { ok: false, reason: msg };
|
|
}
|
|
}
|
|
|
|
export async function getReport(reportId) {
|
|
return webex.request('GET', `reports/${reportId}`);
|
|
}
|
|
|
|
export async function pollReportUntilDone(reportId) {
|
|
const deadline = Date.now() + pollMaxMs();
|
|
while (Date.now() < deadline) {
|
|
const report = await getReport(reportId);
|
|
const status = String(report?.status || '').toLowerCase();
|
|
if (status === 'done' || status === 'complete' || status === 'completed') {
|
|
return { ok: true, report };
|
|
}
|
|
if (status === 'failed' || status === 'error') {
|
|
return { ok: false, reason: report?.statusMessage || 'report generation failed', report };
|
|
}
|
|
await new Promise((r) => setTimeout(r, pollIntervalMs()));
|
|
}
|
|
return { ok: false, reason: 'report poll timed out' };
|
|
}
|
|
|
|
/**
|
|
* Download report payload (CSV text or first CSV inside ZIP).
|
|
*/
|
|
export async function downloadReportText(downloadURL) {
|
|
if (!downloadURL) return { ok: false, reason: 'no downloadURL' };
|
|
const token = await webex.auth.getAccessToken();
|
|
const resp = await axios.get(downloadURL, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
responseType: 'arraybuffer',
|
|
timeout: 60_000,
|
|
maxContentLength: 50 * 1024 * 1024,
|
|
});
|
|
const buf = Buffer.from(resp.data);
|
|
if (buf[0] === 0x50 && buf[1] === 0x4b) {
|
|
return extractCsvFromZip(buf);
|
|
}
|
|
return { ok: true, text: buf.toString('utf8') };
|
|
}
|
|
|
|
function extractCsvFromZip(buf) {
|
|
try {
|
|
const zip = new AdmZip(buf);
|
|
const entry = zip.getEntries().find((e) => /\.csv$/i.test(e.entryName));
|
|
if (!entry) return { ok: false, reason: 'no CSV inside report ZIP' };
|
|
return { ok: true, text: entry.getData().toString('utf8') };
|
|
} catch (err) {
|
|
return { ok: false, reason: `ZIP extract failed: ${err.message}` };
|
|
}
|
|
}
|
|
|
|
export async function fetchMediaQualityReport({ window, locationName }) {
|
|
const templateId = await resolveMediaQualityTemplateId();
|
|
if (!templateId) {
|
|
return { available: false, reason: 'Media Quality template not found' };
|
|
}
|
|
|
|
const { startDate, endDate } = reportDateRangeFromWindow(window);
|
|
const created = await createReport(templateId, { startDate, endDate });
|
|
if (!created.ok) {
|
|
return { available: false, reason: created.reason };
|
|
}
|
|
|
|
const reportId = created.report?.id;
|
|
if (!reportId) {
|
|
return { available: false, reason: 'create report returned no id' };
|
|
}
|
|
|
|
const polled = await pollReportUntilDone(reportId);
|
|
if (!polled.ok) {
|
|
return { available: false, reason: polled.reason };
|
|
}
|
|
|
|
const downloadURL = polled.report?.downloadURL || polled.report?.downloadUrl;
|
|
const dl = await downloadReportText(downloadURL);
|
|
if (!dl.ok) {
|
|
return { available: false, reason: dl.reason };
|
|
}
|
|
|
|
const parsed = parseMediaQualityCsv(dl.text);
|
|
const filtered = filterMediaQualityRows(parsed.rows, { locationName, window });
|
|
|
|
return {
|
|
available: true,
|
|
reportId,
|
|
rawCount: parsed.rows.length,
|
|
rows: filtered,
|
|
headers: parsed.headers,
|
|
};
|
|
}
|