collabSupport/commands/phoneStatus.js
jmcqueen b802383441 Add Prisma SD-WAN voice-quality enrichment for /phonestatus + /voicediag
Introduces a full Palo Alto Prisma SD-WAN integration (dual-mode SASE
OAuth 2.0 / legacy CloudGenix auth, pagination, 429 backoff, session
priming) that surfaces per-path latency/jitter/loss/MOS, site
healthscore, link state, and alarm data for a store. Wired into the
/phonestatus WAN follow-up and eight new /voicediag WAN checks graded
against ITU-T G.114 / RFC 3550 defaults (env-overridable via
WAN_STANDARD_*).

Also adds a shape-aware detail renderer for /voicediag (per-link
tables with verdict icons instead of a stringified JSON dump) and a
--window flag (15m / 1h / 6h / 24h / 1d, env default via
WAN_STANDARD_WINDOW_MINUTES) so operators can widen the look-back
without redeploying. scripts/prismaProbe.js is bundled as a CLI for
schema iteration against a live tenant.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 09:45:29 -04:00

244 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/commands/phoneStatus.js
//
// Chat + HTTP entry point for /phonestatus. The heavy rendering lives in
// services/renderers/phoneStatusRenderer.js so the Jira poller can emit
// the same markdown (see services/jiraPollerService.js). This handler
// stays thin: parse args, call the collector, hand data to the renderer,
// respond.
import { randomUUID } from 'node:crypto';
import { collectPhoneStatus } from '../services/phoneService.js';
import {
renderPhoneStatusMarkdown,
renderDectDiagnosticsMarkdown,
} from '../services/renderers/phoneStatusRenderer.js';
import { renderWanDiagnosticsMarkdown } from '../services/renderers/wanDiagnosticsRenderer.js';
import { buildIgmpFixCard } from './igmpFix.js';
import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js';
import { extractRequester } from '../utils/requester.js';
import { logger } from '../utils/logger.js';
import { discoverDectBases } from '../services/dectDiscovery.js';
import { collectAll } from '../services/dectCollectorService.js';
import { siteNameForStore, findSdwanSiteForStore } from '../integrations/paloalto/sites.js';
import { collectSdwanForStore } from '../services/enrichment/sdwanEnrichment.js';
export async function handlePhoneStatus(bot, trigger) {
logger('phone:status', 'Handler entered', 'debug');
// Support both Webex (args) and HTTP (query) calls
const query = trigger.query || {};
const args = trigger.args || [];
let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s;
const isDetailed = (args[1]?.toLowerCase() === 'detailed') ||
(query.mode === 'detailed') ||
(query.detailed === 'true' || query.detailed === true);
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
const errorMsg = 'Please provide a 24 digit store number.\n' +
'Example: `/phonestatus 782` (or `detailed` / `?detailed=true` for more; includes Meraki links) or `https://.../phonestatus?storeNum=782`';
await bot.say('markdown', errorMsg);
return;
}
logger('phone:status', `Collecting phone status for store ${storeNum}`, 'debug');
try {
const data = await collectPhoneStatus(storeNum);
if (!data) throw new Error('collectPhoneStatus returned undefined');
// JSON alt-output path (kept in the handler because it bypasses
// markdown rendering entirely — no shared renderer applies).
if (query.format === 'json' || (args[1] && args[1].toLowerCase() === 'json')) {
const jsonPayload = {
store: storeNum,
mainNumber: data.locationMainNumber,
timezone: (data.telephonyProfile && data.telephonyProfile.timeZone) || null,
person: data.person ? { displayName: data.person.displayName, phoneNumbers: data.person.phoneNumbers } : null,
timestamp: new Date().toISOString(),
};
await bot.say('markdown', '```json\n' + JSON.stringify(jsonPayload, null, 2) + '\n```');
return;
}
// Discover reachable DECT bases BEFORE rendering so we can tell
// the renderer how many bases the follow-up will cover. Discovery
// is a pure filter over what phoneService already fetched — no
// network calls, so it doesn't slow the main output. Only chat
// triggers get a follow-up; HTTP callers keep the single-message
// contract they had before.
const dectFollowUpEnabled = !!trigger.person;
const { bases: reachableBases, warnings: discoveryWarnings } = dectFollowUpEnabled
? discoverDectBases(data)
: { bases: [], warnings: [] };
if (discoveryWarnings.length > 0) {
logger(
'phone:status',
`DECT discovery warnings for store ${storeNum}: ${discoveryWarnings.map((w) => w.reason).join('; ')}`,
'warn',
);
}
// WAN follow-up discovery. Cheap Prisma-side check: hits the
// 4-hour-cached sites list to confirm this store is Prisma-
// managed before we promise a follow-up. Anything that throws
// (missing env, auth failure, network error) is swallowed and
// treated as "no site" so a broken Prisma integration cannot
// break /phonestatus. Chat-only, same as DECT.
let wanFollowUpEnabled = false;
if (trigger.person) {
const expectedSite = safeSiteName(storeNum);
try {
const site = await findSdwanSiteForStore(storeNum);
wanFollowUpEnabled = !!site;
// Info-level breadcrumb so operators can see the discovery
// outcome without cranking LOG_LEVEL=debug. The success case
// is also logged inside findSdwanSiteForStore; this line
// provides the "why /phonestatus did / didn't schedule a
// WAN follow-up" answer at the phone:status scope.
logger(
'phone:status',
`WAN discovery for store ${storeNum} (expected ${expectedSite}): ` +
`${wanFollowUpEnabled ? 'MATCHED — follow-up scheduled' : 'no match — no follow-up'}`,
);
} catch (err) {
logger(
'phone:status',
`WAN discovery skipped for store ${storeNum} (expected ${expectedSite}): ${err.message}`,
'warn',
);
}
}
const reply = renderPhoneStatusMarkdown(data, {
storeNum,
detailed: isDetailed,
footer: true,
dectFollowUpBaseCount: reachableBases.length,
wanFollowUpEnabled,
});
await bot.say('markdown', reply || 'No data available.');
// Kick off DECT follow-up. Fire-and-forget from this handler's
// perspective — the awaits inside runDectFollowUp() are just so
// failures get logged with a stable scope, they don't propagate
// back to the user's original /phonestatus call. If the relay is
// offline or a base is unreachable we still post the follow-up
// (with per-base error lines) so the user isn't left wondering
// where the promised diagnostics went.
if (dectFollowUpEnabled && reachableBases.length > 0) {
runDectFollowUp(bot, storeNum, reachableBases).catch((err) => {
logger('phone:status', `DECT follow-up failed for store ${storeNum}: ${err.message}`, 'error');
});
}
// WAN follow-up (mirrors the DECT pattern). Only kicked when
// discovery above already confirmed we have a Prisma site for
// this store. Fire-and-forget with a stable log scope.
if (wanFollowUpEnabled) {
runWanFollowUp(bot, storeNum).catch((err) => {
logger('phone:status', `WAN follow-up failed for store ${storeNum}: ${err.message}`, 'error');
});
}
// IGMP-snooping remediation card — only when (a) the multicast
// summary flagged deviation AND (b) we know the networkId (can't
// fix what we can't address) AND (c) the invocation came from
// chat, not HTTP. HTTP callers don't have adaptive-card UX; the
// remediation surface for them is a future gated POST endpoint.
// `trigger.person` is populated by the framework for chat triggers
// and absent for HTTP triggers (see index.js command dispatch).
if (data.multicast?.needsFix && data.multicast.networkId && trigger.person) {
const cardId = randomUUID();
const requester = extractRequester(trigger);
pendingIgmpFixes.set(cardId, {
networkId: data.multicast.networkId,
networkName: data.multicast.networkName,
storeNum,
requester,
// Only the summary is stashed. The fix payload is a constant,
// so we don't need the raw snapshot — keeps the pending-store
// memory footprint tiny and avoids the temptation to
// read-then-mutate at PUT time.
summary: {
defaultSnoopOn: data.multicast.defaultSnoopOn,
defaultFloodOff: data.multicast.defaultFloodOff,
deviatingOverrides: data.multicast.deviatingOverrides || [],
},
});
const card = buildIgmpFixCard({
storeNum,
networkName: data.multicast.networkName,
summary: {
defaultSnoopOn: data.multicast.defaultSnoopOn,
defaultFloodOff: data.multicast.defaultFloodOff,
deviatingOverrides: data.multicast.deviatingOverrides || [],
},
cardId,
});
await bot.say({
markdown: `Multicast policy on store ${storeNum}'s network deviates from DECT-safe defaults. Review and confirm:`,
attachments: [{
contentType: 'application/vnd.microsoft.card.adaptive',
content: card,
}],
});
}
} catch (err) {
logger('phone:status', `Error collecting phone status for store ${storeNum}: ${err.message}`, 'error');
await bot.say('markdown', `Error collecting phone status: ${err.message}`);
}
}
/**
* Run the DECT-diagnostics follow-up as a separate message in the
* same room. Only invoked from chat triggers. Errors are logged
* (never thrown up) — the /phonestatus main output has already been
* sent by the time we get here, so a follow-up crash shouldn't leave
* the user with a broken chat experience.
*
* Renderer emits an empty string only when the results list is empty
* — which shouldn't happen because we already checked reachableBases
* .length > 0 at the call site, but we still guard against it here.
*/
async function runDectFollowUp(bot, storeNum, bases) {
const results = await collectAll(bases);
const md = renderDectDiagnosticsMarkdown(results, { storeNum });
if (!md) return;
await bot.say('markdown', md);
}
/**
* Prisma SD-WAN follow-up. Runs the enrichment composer (which
* hydrates site + elements + healthscore + per-path LQM + alarms in
* parallel), hands the result to the pure WAN renderer, and posts.
*
* The composer never throws — every metric failure is preserved in
* `data.errors[]` and rendered as an inline "partial fetch" warning
* so the operator can see WHAT failed rather than getting silence.
* A missing site (unexpected here since we pre-discovered) short-
* circuits with an empty markdown string, and we no-op.
*/
async function runWanFollowUp(bot, storeNum) {
const data = await collectSdwanForStore(storeNum);
const md = renderWanDiagnosticsMarkdown(data, { storeNum });
if (!md) return;
await bot.say('markdown', md);
}
/**
* Small helper for a log line that runs before we've committed to
* a site lookup — used inside the catch branch of WAN discovery
* where we want to show the expected site name even if the lookup
* failed. Isolated in a function so the try/catch is single-line
* and the intent is obvious.
*/
function safeSiteName(storeNum) {
try {
return siteNameForStore(storeNum);
} catch {
return '<invalid store number>';
}
}