// scripts/lib/webexBulk.js // // Shared utilities for bulk Webex admin scripts driven off Control Hub // CSV exports (reclaimWebexHosts.js, removeAdvancedMessaging.js, etc.). // Kept intentionally dependency-free — everything the operator needs is // already in the repo (WebexClient, logger). No dev deps to install. // // Contents: // CSV // parseCsvLine(line) → string[] // readCsv(path) → { header, rows } // detectFormat(header) → 'meetings-inactive' | 'users-export' | null // FORMAT_* constants // // Concurrency + retry // runPool(items, limit, worker) → results[] with { ok, value? , error? } // callWithRetry(fn, opts) → retries 429/503 with Retry-After // // Webex helpers // fetchAllLicenses() → all org licenses // fetchSiteLicenses(siteUrl) → subset with siteUrl matching (case-insensitive) // seatsFree(license) → number // explainWebexError(err) → concise `${apiMsg} (HTTP ${status})` // // All Webex calls go through the shared WebexClient singleton which // handles service-app token refresh; nothing to configure per-script. import fs from 'node:fs'; import webex from '../../integrations/webex/WebexClient.js'; // ───────────────────────────────────────────────────────────────────────────── // CSV parsing (RFC 4180-ish; handles quoted fields, escaped "") // ───────────────────────────────────────────────────────────────────────────── export function parseCsvLine(line) { const cells = []; let cur = ''; let inQ = false; for (let i = 0; i < line.length; i++) { const c = line[i]; if (inQ) { if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; } else if (c === '"') inQ = false; else cur += c; } else { if (c === '"') inQ = true; else if (c === ',') { cells.push(cur); cur = ''; } else cur += c; } } cells.push(cur); return cells; } export function readCsv(filePath) { const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); const lines = raw.split(/\r?\n/).filter((l) => l.length > 0); if (lines.length === 0) return { header: [], rows: [] }; // Preserve original header text — Control Hub exports vary between // UPPER_SNAKE and Title Case With Punctuation, and Users Export // license columns are literally "aeo2go.webex.com - WebEx Meetings // Free [Sub601269]". Case-preserving avoids ambiguity. const header = parseCsvLine(lines[0]).map((h) => h.trim()); const rows = lines.slice(1).map((l) => { const cells = parseCsvLine(l); const row = {}; for (let i = 0; i < header.length; i++) row[header[i]] = cells[i] ?? ''; return row; }); return { header, rows }; } // ───────────────────────────────────────────────────────────────────────────── // Format detection // ───────────────────────────────────────────────────────────────────────────── export const FORMAT_MEETINGS_INACTIVE = 'meetings-inactive'; export const FORMAT_USERS_EXPORT = 'users-export'; export function detectFormat(header) { const set = new Set(header); if (set.has('EMAIL') && set.has('IS_HOST') && set.has('DAYS_SINCE_LAST_ACTIVE')) { return FORMAT_MEETINGS_INACTIVE; } if (set.has('User ID/Email (Required)') && set.has('Days since Last Service Accessed')) { return FORMAT_USERS_EXPORT; } return null; } // ───────────────────────────────────────────────────────────────────────────── // Bounded-concurrency worker pool // ───────────────────────────────────────────────────────────────────────────── // Runs `worker(item, idx)` across `items` with at most `limit` in flight. // Never throws — each slot in the result array is either `{ok: true, value}` // or `{ok: false, error}` so the caller can accumulate a per-item report. export async function runPool(items, limit, worker) { const results = new Array(items.length); let idx = 0; const workers = new Array(Math.min(limit, items.length)).fill(null).map(async () => { while (true) { const i = idx++; if (i >= items.length) return; try { results[i] = { ok: true, value: await worker(items[i], i) }; } catch (err) { results[i] = { ok: false, error: err }; } } }); await Promise.all(workers); return results; } // ───────────────────────────────────────────────────────────────────────────── // 429/503-aware retry helper. Honors Retry-After (seconds). // ───────────────────────────────────────────────────────────────────────────── export async function callWithRetry(fn, { tries = 4, baseDelayMs = 500 } = {}) { let lastErr; for (let attempt = 0; attempt < tries; attempt++) { try { return await fn(); } catch (err) { lastErr = err; const status = err?.response?.status; if (status !== 429 && status !== 503) throw err; const retryAfter = Number(err?.response?.headers?.['retry-after']); const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : baseDelayMs * Math.pow(2, attempt); await new Promise((r) => setTimeout(r, wait)); } } throw lastErr; } // ───────────────────────────────────────────────────────────────────────────── // Webex license helpers // ───────────────────────────────────────────────────────────────────────────── export async function fetchAllLicenses() { const data = await webex.listLicenses(); return Array.isArray(data?.items) ? data.items : []; } export async function fetchSiteLicenses(siteUrl) { const items = await fetchAllLicenses(); const want = (siteUrl || '').toLowerCase(); return items.filter((l) => (l.siteUrl || '').toLowerCase() === want); } export function seatsFree(l) { const total = Number(l.totalUnits ?? 0); const used = Number(l.consumedUnits ?? 0); return Math.max(0, total - used); } // Generic paginated fetch that follows the Webex `Link: <…>; rel="next"` // header cursor. Accumulates every item in the response's `arrayKey` field // (defaults to 'items' — the shape most Webex list endpoints use). Some // endpoints use a different key (e.g. /telephony/config/numbers returns // `phoneNumbers`); pass `arrayKey` in that case. // // Uses WebexClient.requestRaw() directly so we can read headers. First // call is relative (`endpoint`); subsequent calls follow the absolute // URLs from the Link header, which carry the cursor query string. export async function fetchAllPaginated(endpoint, opts = {}) { return fetchAllPaginatedWithClient(webex, endpoint, opts); } /** * Paginated fetch using any client with requestRaw() (WebexClient or * createTokenClient()). */ export async function fetchAllPaginatedWithClient(client, endpoint, { params = null, arrayKey = 'items', pageSize = 1000, retry = { tries: 6, baseDelayMs: 2000 }, } = {}) { const firstParams = { max: pageSize, ...(params || {}) }; let { data, headers } = await callWithRetry( () => client.requestRaw('GET', endpoint, null, firstParams), retry, ); const all = []; const pickArray = (d) => { const arr = d?.[arrayKey]; return Array.isArray(arr) ? arr : []; }; all.push(...pickArray(data)); let nextUrl = parseLinkNext(headers?.link || headers?.Link); while (nextUrl) { ({ data, headers } = await callWithRetry( () => client.requestRaw('GET', nextUrl), retry, )); all.push(...pickArray(data)); nextUrl = parseLinkNext(headers?.link || headers?.Link); } return all; } /** @returns {Promise>} */ export async function fetchAllTelephonyLocations(client) { const rows = await fetchAllPaginatedWithClient(client, 'telephony/config/locations', { arrayKey: 'locations', pageSize: 1000, }); return rows .map((loc) => ({ id: loc?.id || loc?.locationId || '', name: loc?.name || '', })) .filter((loc) => loc.id); } /** @returns {Promise} */ export async function fetchDevicesForLocation(client, locationId) { if (!locationId) return []; return callWithRetry( () => fetchAllPaginatedWithClient(client, 'devices', { params: { locationId }, arrayKey: 'items', pageSize: 100, retry: { tries: 6, baseDelayMs: 2000 }, }), { tries: 3, baseDelayMs: 3000 }, ); } function parseLinkNext(linkHeader) { if (!linkHeader || typeof linkHeader !== 'string') return null; // Tolerate multiple entries (comma-separated); grab the first `rel="next"`. const entries = linkHeader.split(/,\s*(?=<)/); for (const e of entries) { const m = e.match(/^\s*<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i); if (m) return m[1]; } return null; } export function explainWebexError(err) { const status = err?.response?.status; const apiMsg = err?.response?.data?.message || err?.response?.data?.errors?.[0]?.description || err?.message || String(err); return status ? `${apiMsg} (HTTP ${status})` : apiMsg; }