// 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 ''; } } function argIncludes(args, token) { const needle = String(token).toLowerCase(); return (args || []).some((a) => String(a).toLowerCase() === needle); }