#!/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); });