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>
289 lines
12 KiB
JavaScript
289 lines
12 KiB
JavaScript
// 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 { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.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 { discoverDeskPhones } from '../services/phoneDiscovery.js';
|
||
import { collectAll } from '../services/dectCollectorService.js';
|
||
import { probeAll } from '../services/phoneCollectorService.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 = argIncludes(args, 'detailed')
|
||
|| (query.mode === 'detailed')
|
||
|| (query.detailed === 'true' || query.detailed === true);
|
||
|
||
const isVerbose = argIncludes(args, 'verbose') || argIncludes(args, 'debug')
|
||
|| query.verbose === 'true' || query.verbose === true
|
||
|| query.debug === 'true' || query.debug === true;
|
||
|
||
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
|
||
const errorMsg = 'Please provide a 2–4 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: [] };
|
||
const { phones: mppPhones, warnings: mppDiscoveryWarnings } = dectFollowUpEnabled
|
||
? discoverDeskPhones(data)
|
||
: { phones: [], warnings: [] };
|
||
if (discoveryWarnings.length > 0) {
|
||
logger(
|
||
'phone:status',
|
||
`DECT discovery warnings for store ${storeNum}: ${discoveryWarnings.map((w) => w.reason).join('; ')}`,
|
||
'warn',
|
||
);
|
||
}
|
||
if (mppDiscoveryWarnings.length > 0) {
|
||
logger(
|
||
'phone:status',
|
||
`MPP discovery warnings for store ${storeNum}: ${mppDiscoveryWarnings.map((w) => w.reason).join('; ')}`,
|
||
'warn',
|
||
);
|
||
}
|
||
if (dectFollowUpEnabled) {
|
||
logger(
|
||
'phone:status',
|
||
`MPP discovery for store ${storeNum}: ` +
|
||
`${mppPhones.length > 0
|
||
? `${mppPhones.length} desk phone(s) selected — follow-up scheduled (${mppPhones.map((p) => p.ip).join(', ')})`
|
||
: 'no MPP desk phones selected — no follow-up'}`,
|
||
);
|
||
}
|
||
|
||
// 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,
|
||
mppFollowUpPhoneCount: mppPhones.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');
|
||
});
|
||
}
|
||
|
||
if (dectFollowUpEnabled && mppPhones.length > 0) {
|
||
runMppPhoneFollowUp(bot, storeNum, mppPhones, { verbose: isVerbose }).catch((err) => {
|
||
logger('phone:status', `MPP phone 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);
|
||
}
|
||
|
||
async function runMppPhoneFollowUp(bot, storeNum, phones, { verbose = false } = {}) {
|
||
const results = await probeAll(phones);
|
||
const md = renderMppPhoneDiagnosticsMarkdown(results, { storeNum, verbose });
|
||
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>';
|
||
}
|
||
}
|
||
|
||
function argIncludes(args, token) {
|
||
const needle = String(token).toLowerCase();
|
||
return (args || []).some((a) => String(a).toLowerCase() === needle);
|
||
}
|