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>
347 lines
11 KiB
JavaScript
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);
|
|
});
|