collabSupport/integrations/cisco-mpp-phone/probes.js
jmcqueen f7953b8eb5 Add MPP desk phone diagnostics follow-up to /phonestatus via relay.
Wire CP-78xx probe discovery, relay phone-probe commands, and a chat follow-up message so store desk phones get registration, switch, and provisioning detail alongside DECT and WAN diagnostics.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 18:01:01 -04:00

183 lines
6.2 KiB
JavaScript

// integrations/cisco-mpp-phone/probes.js
//
// Read-only probes against Cisco MPP desk phones (Webex Calling).
// Current-release Webex MPP firmware uses JSON endpoints on the SPA
// web UI (not legacy /admin/status.xml).
import { tryRequest } from './client.js';
import { parseCfgXml } from './statusXml.js';
export const READ_PROBE_PATHS = [
{ path: '/', purpose: 'user web landing (SPA shell)' },
{ path: '/Status.json', purpose: 'primary status JSON (Info > Status tab)' },
{ path: '/Download%20Status.json', purpose: 'provisioning + firmware upgrade history' },
{ path: '/ns.json', purpose: 'network statistics JSON' },
{ path: '/basic/System.json', purpose: 'system configuration JSON (admin fields)' },
// Legacy fallbacks (older MPP loads / CUCM-style)
{ path: '/admin/status.xml', purpose: 'legacy admin status XML' },
{ path: '/status.xml', purpose: 'legacy user status XML' },
{ path: '/admin/cfg.xml', purpose: 'legacy admin config XML' },
];
export const EXPLORE_PROBE_PATHS = [
{ path: '/Debug%20Info.json', purpose: 'debug info JSON' },
{ path: '/basic/init.json', purpose: 'UI layout metadata' },
{ path: '/admin/advanced', purpose: 'legacy admin HTML landing' },
];
export const MUTATING_ACTION_PATHS = Object.freeze({
REBOOT: '/admin/reboot',
RESYNC: '/admin/resync',
UPGRADE: '/admin/upgrade',
});
export async function runProbeList(client, pathList) {
const results = [];
for (const { path, purpose } of pathList) {
const r = await tryRequest(client, { method: 'GET', path });
results.push({ ...r, purpose });
}
return results;
}
export async function runReadProbes(client) {
return runProbeList(client, READ_PROBE_PATHS);
}
export async function runExploreProbes(client) {
return runProbeList(client, [...READ_PROBE_PATHS, ...EXPLORE_PROBE_PATHS]);
}
export async function runPhoneProbe(client, { includeBodies = false } = {}) {
const started = Date.now();
const probes = await runReadProbes(client);
const statusJson = await fetchBodyIfOk(probes, client, '/Status.json');
const statusXml = !statusJson
? (await fetchBodyIfOk(probes, client, '/admin/status.xml')
|| await fetchBodyIfOk(probes, client, '/status.xml'))
: null;
const downloadStatusJson = await fetchBodyIfOk(probes, client, '/Download%20Status.json');
const nsJson = await fetchBodyIfOk(probes, client, '/ns.json');
const systemJson = await fetchBodyIfOk(probes, client, '/basic/System.json');
const cfgXml = await fetchBodyIfOk(probes, client, '/admin/cfg.xml');
const cfgParsed = cfgXml ? parseCfgXml(cfgXml) : null;
const summary = summarizeProbes(probes, { statusJson, statusXml });
return {
probes: includeBodies ? await attachBodies(client, probes) : probes,
summary,
statusJson,
downloadStatusJson: downloadStatusJson || null,
nsJson: nsJson || null,
systemJson: systemJson || null,
statusXml,
cfgXml: includeBodies ? cfgXml : (cfgXml ? truncate(cfgXml, 4000) : null),
cfgParsed: cfgParsed?.highlights || null,
byteLength: statusJson
? Buffer.byteLength(statusJson, 'utf8')
: (statusXml ? Buffer.byteLength(statusXml, 'utf8') : null),
elapsedMs: Date.now() - started,
};
}
export async function fetchStatusJson(client) {
const r = await tryRequest(client, { method: 'GET', path: '/Status.json' });
if (r.status === 200 && r.sizeBytes > 0) {
const body = await fetchBody(client, '/Status.json');
return {
rawJson: body,
rawXml: null,
byteLength: Buffer.byteLength(body || '', 'utf8'),
probe: r,
path: '/Status.json',
};
}
const xml = await fetchStatusXml(client);
return {
rawJson: null,
rawXml: xml.rawXml,
byteLength: xml.byteLength,
probe: xml.probe,
path: xml.path,
};
}
export async function fetchStatusXml(client) {
for (const path of ['/admin/status.xml', '/status.xml']) {
const r = await tryRequest(client, { method: 'GET', path });
if (r.status === 200 && r.sizeBytes > 0) {
const body = await fetchBody(client, path);
return { rawXml: body, byteLength: Buffer.byteLength(body || '', 'utf8'), probe: r, path };
}
}
const err = new Error('phone returned no Status.json or status.xml');
err.code = 'PHONE_BAD_STATUS';
throw err;
}
async function fetchBodyIfOk(probes, client, path) {
const p = probes.find((row) => row.path === path);
if (p?.status === 200 && p.sizeBytes > 0) {
return fetchBody(client, path);
}
return null;
}
async function fetchBody(client, path) {
try {
const res = await client.get(path);
return typeof res.data === 'string' ? res.data : JSON.stringify(res.data ?? '');
} catch {
return null;
}
}
async function attachBodies(client, probes) {
const out = [];
for (const p of probes) {
if (p.status === 200 && p.sizeBytes > 0) {
const body = await fetchBody(client, p.path);
out.push({ ...p, body: body ?? null });
} else {
out.push({ ...p, body: null });
}
}
return out;
}
function truncate(str, max) {
if (!str || str.length <= max) return str;
return `${str.slice(0, max)}\n<!-- truncated ${str.length - max} bytes -->`;
}
export function summarizeProbes(probes, { statusJson = null, statusXml = null } = {}) {
const okCount = probes.filter((p) => p.status === 200).length;
const authOk = probes.some((p) => p.status === 200);
const statusJsonFound = probes.some(
(p) => p.path === '/Status.json' && p.status === 200 && p.sizeBytes > 0,
) || !!statusJson;
const statusXmlFound = probes.some(
(p) => (p.path === '/admin/status.xml' || p.path === '/status.xml')
&& p.status === 200 && p.sizeBytes > 0,
) || !!statusXml;
return {
okCount,
total: probes.length,
authOk,
statusJsonFound,
statusXmlFound,
statusJsonBytes: statusJson ? Buffer.byteLength(statusJson, 'utf8') : 0,
statusXmlBytes: statusXml ? Buffer.byteLength(statusXml, 'utf8') : 0,
forbidden: probes.filter((p) => p.status === 403).map((p) => p.path),
unauthorized: probes.filter((p) => p.status === 401).map((p) => p.path),
notFound: probes.filter((p) => p.status === 404).map((p) => p.path),
okPaths: probes.filter((p) => p.status === 200).map((p) => p.path),
};
}
export async function getPath(client, path) {
return tryRequest(client, { method: 'GET', path });
}