From 860615cb89275431bf8a6fd22d5e54d2b5b9107e Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Fri, 31 Jul 2026 16:35:58 -0400 Subject: [PATCH] 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 --- .gitignore | 1 + scripts/enableMppWebAccess.js | 347 ++++++++++++++++++++++++++++++++ scripts/lib/webexBulk.js | 49 ++++- scripts/lib/webexTokenClient.js | 57 ++++++ 4 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 scripts/enableMppWebAccess.js create mode 100644 scripts/lib/webexTokenClient.js diff --git a/.gitignore b/.gitignore index a765ae6..eb6906e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ scripts/* !scripts/reclaimWebexHosts.js !scripts/removeAdvancedMessaging.js !scripts/findEmptyLocations.js +!scripts/enableMppWebAccess.js !scripts/rebootStoreIpads.js !scripts/prismaProbe.js !scripts/lib/ diff --git a/scripts/enableMppWebAccess.js b/scripts/enableMppWebAccess.js new file mode 100644 index 0000000..e00752c --- /dev/null +++ b/scripts/enableMppWebAccess.js @@ -0,0 +1,347 @@ +#!/usr/bin/env node +/** + * Bulk-enable MPP User Web Access on desk phones in Store* locations. + * + * Per-device flow: + * GET telephony/config/devices/{deviceId}/settings + * PUT ... (mppUserWebAccessEnabled + webAccess) + * POST .../actions/applyChanges/invoke + * + * Usage: + * node scripts/enableMppWebAccess.js + * node scripts/enableMppWebAccess.js --location-id --execute + * node scripts/enableMppWebAccess.js --execute --concurrency 3 --report enable-web-access.csv + * + * Auth: uses the bot's Webex service-app token (WEBEX_CLIENT_ID/SECRET + + * tokens/webex-service-tokens.json), same as findEmptyLocations.js. + * + * Required service-app scopes: + * spark-admin:telephony_config_read + * spark-admin:telephony_config_write + * spark-admin:devices_read + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import path from 'node:path'; +import { isMppDeskPhone } from '../services/phoneDiscovery.js'; +import webex from '../integrations/webex/WebexClient.js'; +import { + fetchAllTelephonyLocations, + fetchDevicesForLocation, + runPool, + callWithRetry, + explainWebexError, +} from './lib/webexBulk.js'; + +// Device-level PUT schema (PutDeviceSettingsRequest) requires top-level +// customEnabled + customizations — webAccess is a GET-response field only. +function buildEnableBody() { + return { + customEnabled: true, + customizations: { + mpp: { + mppUserWebAccessEnabled: true, + }, + }, + }; +} + +function parseArgs(argv) { + const out = { + execute: false, + prefix: 'Store', + locationId: null, + limit: null, + offset: 0, + concurrency: 3, + locationDelayMs: 400, + report: null, + help: false, + }; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const next = () => argv[++i]; + switch (a) { + case '--execute': out.execute = true; break; + case '--prefix': out.prefix = next(); break; + case '--location-id': out.locationId = next(); break; + case '--limit': out.limit = Number(next()); break; + case '--offset': out.offset = Number(next()); break; + case '--concurrency': out.concurrency = Math.max(1, Number(next()) || 3); break; + case '--location-delay-ms': out.locationDelayMs = Math.max(0, Number(next()) || 0); break; + case '--report': out.report = next(); break; + case '-h': + case '--help': + out.help = true; + break; + default: + throw new Error(`Unknown argument: ${a}`); + } + } + return out; +} + +function printHelp() { + console.log(`Usage: node scripts/enableMppWebAccess.js [options] + +Options: + --execute Apply changes (default is dry-run) + --prefix Location name prefix filter (default: Store) + --location-id Process a single telephony location only + --limit Max locations to process (after offset) + --offset Skip first N matching locations + --concurrency Parallel phones (default: 3) + --location-delay-ms Pause between location device-list calls (default: 400) + --report Write per-phone audit CSV + -h, --help Show this help +`); +} + +function isWebAccessEnabled(settings) { + return settings?.customEnabled === true + && settings?.customizations?.mpp?.mppUserWebAccessEnabled === true; +} + +function deviceLabel(device) { + return device.displayName || device.product || device.mac || device.id || '?'; +} + +function candidateDeviceIds(device) { + const ids = []; + if (device.callingDeviceId) ids.push(device.callingDeviceId); + if (device.id && !ids.includes(device.id)) ids.push(device.id); + return ids; +} + +async function resolveTelephonyDeviceId(client, device) { + const ids = candidateDeviceIds(device); + let lastErr = null; + for (const deviceId of ids) { + try { + await callWithRetry(() => client.request( + 'GET', + `telephony/config/devices/${encodeURIComponent(deviceId)}/settings`, + )); + return deviceId; + } catch (err) { + lastErr = err; + if (err?.response?.status !== 404) throw err; + } + } + const err = new Error(`No telephony device settings found for ${deviceLabel(device)}`); + err.cause = lastErr; + throw err; +} + +async function getDeviceSettings(client, deviceId) { + return callWithRetry(() => client.request( + 'GET', + `telephony/config/devices/${encodeURIComponent(deviceId)}/settings`, + )); +} + +async function putDeviceSettings(client, deviceId) { + return callWithRetry(() => client.request( + 'PUT', + `telephony/config/devices/${encodeURIComponent(deviceId)}/settings`, + buildEnableBody(), + )); +} + +async function applyDeviceChanges(client, deviceId) { + return callWithRetry(() => client.requestRaw( + 'POST', + `telephony/config/devices/${encodeURIComponent(deviceId)}/actions/applyChanges/invoke`, + {}, + )); +} + +function filterLocations(locations, { prefix, locationId, offset, limit }) { + let list = locations; + if (locationId) { + list = list.filter((loc) => loc.id === locationId); + } else if (prefix) { + const p = String(prefix); + list = list.filter((loc) => (loc.name || '').startsWith(p)); + } + list = list.slice(offset); + if (Number.isFinite(limit) && limit > 0) list = list.slice(0, limit); + return list; +} + +function toCsvRow(cells) { + return cells.map((c) => { + const s = String(c ?? ''); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }).join(','); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function processPhone(client, workItem, execute) { + const { location, device } = workItem; + const base = { + locationId: location.id, + locationName: location.name, + deviceId: null, + displayName: deviceLabel(device), + mac: device.mac || '', + product: device.product || '', + status: 'unknown', + detail: '', + }; + + try { + const deviceId = await resolveTelephonyDeviceId(client, device); + base.deviceId = deviceId; + + const settings = await getDeviceSettings(client, deviceId); + if (settings?.updateInProgress === true) { + return { ...base, status: 'update_in_progress', detail: 'device settings update already in flight' }; + } + if (isWebAccessEnabled(settings)) { + return { ...base, status: 'already_enabled', detail: 'mppUserWebAccessEnabled already true' }; + } + + if (!execute) { + return { ...base, status: 'would_enable', detail: 'dry-run' }; + } + + await putDeviceSettings(client, deviceId); + const applyRes = await applyDeviceChanges(client, deviceId); + const applied = applyRes.status === 204 || (applyRes.status >= 200 && applyRes.status < 300); + return { + ...base, + status: applied ? 'enabled' : 'enabled_settings_only', + detail: applied ? 'PUT + applyChanges' : `PUT ok; applyChanges HTTP ${applyRes.status}`, + }; + } catch (err) { + return { ...base, status: 'error', detail: explainWebexError(err) }; + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + printHelp(); + return; + } + const client = webex; + const mode = args.execute ? 'EXECUTE' : 'DRY-RUN'; + console.log(`[enableMppWebAccess] mode=${mode} auth=service-app prefix="${args.prefix}" concurrency=${args.concurrency} locationDelayMs=${args.locationDelayMs}`); + + const allLocations = await callWithRetry( + () => fetchAllTelephonyLocations(client), + { tries: 6, baseDelayMs: 2000 }, + ); + const locations = filterLocations(allLocations, args); + if (locations.length === 0) { + console.log('No matching locations found.'); + return; + } + console.log(`Processing ${locations.length} location(s)…`); + + const work = []; + const locationErrors = []; + for (let i = 0; i < locations.length; i++) { + const location = locations[i]; + if (i > 0 && args.locationDelayMs > 0) { + await sleep(args.locationDelayMs); + } + let devices; + try { + devices = await fetchDevicesForLocation(client, location.id); + } catch (err) { + const detail = explainWebexError(err); + locationErrors.push({ location, detail }); + console.error(` ✗ ${location.name}: device list failed — ${detail}`); + continue; + } + const mppPhones = devices.filter((d) => isMppDeskPhone({ + product: d.product, + model: d.model, + name: d.displayName, + displayName: d.displayName, + })); + if (mppPhones.length === 0) { + console.log(` ${location.name}: no MPP desk phones`); + continue; + } + console.log(` ${location.name}: ${mppPhones.length} MPP phone(s)`); + for (const device of mppPhones) { + work.push({ location, device }); + } + } + + if (work.length === 0 && locationErrors.length === 0) { + console.log('No MPP desk phones to process.'); + return; + } + if (work.length === 0 && locationErrors.length > 0) { + console.log(`\nNo phones collected; ${locationErrors.length} location(s) failed during device list.`); + console.log('Re-run with --offset to resume past successful locations.'); + process.exit(1); + } + + console.log(`\nScanning/updating ${work.length} phone(s)…\n`); + const results = await runPool(work, args.concurrency, (item) => processPhone(client, item, args.execute)); + + const rows = results.map((r) => (r.ok ? r.value : { + locationId: '', + locationName: '', + deviceId: '', + displayName: '', + mac: '', + product: '', + status: 'error', + detail: explainWebexError(r.error), + })); + + const counts = {}; + for (const row of rows) { + counts[row.status] = (counts[row.status] || 0) + 1; + const icon = row.status === 'error' ? '✗' : row.status === 'enabled' ? '✓' : '·'; + console.log(`${icon} ${row.locationName} / ${row.displayName} (${row.mac || 'no-mac'}) → ${row.status}${row.detail ? ` — ${row.detail}` : ''}`); + } + + console.log('\nSummary:'); + for (const [status, n] of Object.entries(counts).sort()) { + console.log(` ${status}: ${n}`); + } + if (locationErrors.length > 0) { + console.log(` location_fetch_error: ${locationErrors.length}`); + for (const e of locationErrors) { + console.log(` - ${e.location.name}: ${e.detail}`); + } + } + + if (args.report) { + const header = ['locationId', 'locationName', 'deviceId', 'displayName', 'mac', 'product', 'status', 'detail']; + const phoneLines = rows.map((r) => toCsvRow(header.map((k) => r[k]))); + const locErrLines = locationErrors.map((e) => toCsvRow([ + e.location.id, + e.location.name, + '', + '', + '', + '', + 'location_fetch_error', + e.detail, + ])); + const lines = [toCsvRow(header), ...phoneLines, ...locErrLines]; + const outPath = path.resolve(args.report); + fs.writeFileSync(outPath, `${lines.join('\n')}\n`, 'utf8'); + console.log(`\nReport written: ${outPath}`); + } + + if (counts.error > 0 || locationErrors.length > 0) process.exit(1); +} + +main().catch((err) => { + console.error(`Fatal: ${explainWebexError(err)}`); + process.exit(1); +}); diff --git a/scripts/lib/webexBulk.js b/scripts/lib/webexBulk.js index 197a7fc..870d16c 100644 --- a/scripts/lib/webexBulk.js +++ b/scripts/lib/webexBulk.js @@ -166,13 +166,25 @@ export function seatsFree(l) { // 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, { +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 webex.requestRaw('GET', endpoint, null, firstParams); + let { data, headers } = await callWithRetry( + () => client.requestRaw('GET', endpoint, null, firstParams), + retry, + ); const all = []; const pickArray = (d) => { const arr = d?.[arrayKey]; @@ -182,13 +194,44 @@ export async function fetchAllPaginated(endpoint, { let nextUrl = parseLinkNext(headers?.link || headers?.Link); while (nextUrl) { - ({ data, headers } = await webex.requestRaw('GET', 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"`. diff --git a/scripts/lib/webexTokenClient.js b/scripts/lib/webexTokenClient.js new file mode 100644 index 0000000..90e2b54 --- /dev/null +++ b/scripts/lib/webexTokenClient.js @@ -0,0 +1,57 @@ +// scripts/lib/webexTokenClient.js +// +// Lightweight Webex REST client for one-off scripts that accept an +// external bearer token (personal admin token) instead of the bot's +// service-app OAuth flow. + +import axios from 'axios'; + +const DEFAULT_BASE_URL = process.env.WEBEX_BASE_URL || 'https://webexapis.com/v1'; + +/** + * @param {string} accessToken + * @param {object} [opts] + * @param {string} [opts.baseUrl] + * @returns {{ request: Function, requestRaw: Function }} + */ +export function createTokenClient(accessToken, { baseUrl = DEFAULT_BASE_URL } = {}) { + const token = String(accessToken || '').trim(); + if (!token) { + throw new Error('createTokenClient: access token is required'); + } + + const base = baseUrl.replace(/\/$/, ''); + + async function requestRaw(method, endpointOrUrl, data = null, params = null) { + const isAbsolute = /^https?:\/\//i.test(endpointOrUrl); + const url = isAbsolute ? endpointOrUrl : `${base}/${endpointOrUrl.replace(/^\//, '')}`; + + const response = await axios({ + method, + url, + headers: { Authorization: `Bearer ${token}` }, + data, + params: isAbsolute ? undefined : params, + validateStatus: () => true, + }); + + if (response.status >= 200 && response.status < 300) { + return { data: response.data, headers: response.headers, status: response.status }; + } + + const err = new Error( + response.data?.message + || response.data?.errors?.[0]?.description + || `Webex API ${method} ${endpointOrUrl} failed with HTTP ${response.status}`, + ); + err.response = response; + throw err; + } + + async function request(method, endpointOrUrl, data = null, params = null) { + const { data: body } = await requestRaw(method, endpointOrUrl, data, params); + return body; + } + + return { request, requestRaw }; +}