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:
parent
2ec2b7a486
commit
860615cb89
4 changed files with 451 additions and 3 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
347
scripts/enableMppWebAccess.js
Normal file
347
scripts/enableMppWebAccess.js
Normal 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);
|
||||
});
|
||||
|
|
@ -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"`.
|
||||
|
|
|
|||
57
scripts/lib/webexTokenClient.js
Normal file
57
scripts/lib/webexTokenClient.js
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue