collabSupport/scripts/lib/webexBulk.js
jmcqueen 860615cb89 Add bulk MPP web access enablement script for store desk phones.
Includes telephony pagination helpers and a token-based Webex client for dry-run/execute runs across Store locations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 16:35:58 -04:00

254 lines
10 KiB
JavaScript

// 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<Array<{ id: string, name: string }>>} */
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<object[]>} */
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;
}