// 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, }; }