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>
This commit is contained in:
jmcqueen 2026-07-31 16:35:58 -04:00
parent 2ec2b7a486
commit 860615cb89
4 changed files with 451 additions and 3 deletions

1
.gitignore vendored
View file

@ -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/

View file

@ -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 <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);
});

View file

@ -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<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"`.

View file

@ -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 };
}