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>
254 lines
10 KiB
JavaScript
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;
|
|
}
|