collabSupport/scripts/enableMppWebAccess.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

347 lines
11 KiB
JavaScript

#!/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 <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 <text> Location name prefix filter (default: Store)
--location-id <id> Process a single telephony location only
--limit <n> Max locations to process (after offset)
--offset <n> Skip first N matching locations
--concurrency <n> Parallel phones (default: 3)
--location-delay-ms <n> Pause between location device-list calls (default: 400)
--report <file.csv> 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);
});