collabSupport/services/enrichment/avEnrichmentCore.js
jmcqueen 351f89a9a4 Initial commit: CollabFinder Webex bot
Multi-integration Webex chat/HTTP bot that unifies phone, AV, and
network status for retail store support. Consolidates data from
Webex Calling, Meraki, Workspace ONE (MDM), Atlas AMP, RED digital
signage, and OptiSigns into rich per-store status commands.

Key surfaces:
- /phonestatus, /avstatus — per-store phone & AV device reports with
  clickable Meraki deep-links and per-port detail.
- /webexhost — check/assign Webex Meetings host licenses via the
  Service App; adaptive-card confirmation flow, HTTP-API-gated.
- /offboarduser — full Webex Admin offboarding (auth revoke, device
  wipe, license removal); adaptive-card confirmation.
- /jirapoll — on-demand trigger for the hourly Jira poller.
- /bulkavstatuscsv — bulk store CSV export with concurrency limits.

Automation:
- Hourly Jira poller (node-cron) with an X.AI (Grok) ticket classifier
  that categorizes unassigned tickets as phone/av/skip, extracts store
  numbers from free-text, and enriches Jira with the same detailed
  markdown the chat commands emit (converted to Jira ADF, preserves
  bold + Meraki links). Idempotent via a `bot-enriched` Jira label.

Architecture:
- Node.js 20+, ESM, Express 5, webex-node-bot-framework.
- Layered integrations (integrations/*), services (services/*),
  commands (commands/*), utils (utils/*).
- Shared markdown renderers (services/renderers/*) feed both chat
  handlers and the Jira poller so the two surfaces stay in sync.
- Hand-rolled markdown-to-ADF converter (utils/markdownToAdf.js) —
  no new npm dependency.
- Node built-in test runner (`node --test tests/*.test.js`), 30 tests
  covering the converter, renderers, and poller ADF assembly.

Docker + docker-compose deployment. Config via .env
(see .env.example for the full option surface).
2026-07-01 16:55:03 -04:00

470 lines
19 KiB
JavaScript

// services/enrichment/avEnrichmentCore.js
//
// Core orchestrator for AV enrichment.
// Single place that composes:
// - base collection (MDM + Atlas via shared filters)
// - relevant Meraki prefilters for ports/clients
// - parallel domain (RED/Opti)
// - full Meraki enrichment (clients + switches/APs via shared)
// - attach domain data with shape adapters
// - final assembly for different output shapes
//
// Shapes:
// 'chat' -> same shape as old collectDeviceStatus (for /avstatus, commands)
// 'rich' -> same shape as old buildAVDevices (for /av/devices/build + topology)
// 'dashboard'-> the enriched list (for modals / avEnricher) - fully unified now
//
// This centralizes rate-limit smarts (relevant only) and makes future expansion (new sources, new commands)
// much easier without copy/paste drift.
//
// Thin callers in deviceService / avDeviceBuilder / avEnricher delegate here + adapt if needed.
import { logger } from '../../utils/logger.js';
import { collectBaseAVData, filterAVDevices } from './filters.js';
import {
getClientsForStore,
getPortsForStore
} from '../../integrations/meraki/clients.js';
import { getREDStatusForStore } from '../../integrations/red/players.js';
import { getOptiSignStatus } from '../../integrations/optisigns/client.js';
import { findMerakiNetwork } from '../../integrations/meraki/networks.js';
import { enrichAllMerakiData, attachMerakiClientWithPorts, enrichWirelessDetails, enrichMerakiDeviceDetails } from './merakiEnrichment.js';
import { attachDomainData } from './domainEnrichment.js';
import { enrichDomainData as sharedEnrichDomainData } from './domainEnrichment.js';
import { findBestMerakiClientMatch } from './merakiMatcher.js';
import { normalizeMac } from './normalizers.js';
import { normalizePlayerName } from '../../utils/normalize.js';
import {
getAllMerakiDevices,
getMerakiTopology,
} from '../../integrations/meraki/devices.js';
// ====================== CHAT ATTACHERS (MDM + Atlas + domain) ======================
// Moved here from deviceService to allow core to own the chat composition without cycles.
// These are chat-slim specific (full red object etc flow via domain attach).
export async function enrichMdmDevices(mdmResult, enrichedMeraki, redResult, optisignsResult) {
let mdmDevices = mdmResult.value || [];
const redPlayers = redResult.value || [];
const optiDevices = optisignsResult.value?.devices || [];
// Base collection already filtered (unified), no need to re-filter here
logger('device:service', `MDM from unified base: ${mdmDevices.length} (already filtered)`, 'debug');
// First pass: meraki matching per MDM device (using shared)
const mdmWithMeraki = await Promise.all(mdmDevices.map(async (mdmDevice) => {
// Be defensive: raw MDM objects may expose UserName / DeviceFriendlyName instead of friendlyName
const friendly = mdmDevice.friendlyName || mdmDevice.DeviceFriendlyName || mdmDevice.UserName || mdmDevice.name || '';
const normName = normalizePlayerName(friendly).toLowerCase().trim();
const mdmMac = normalizeMac(mdmDevice.mac || mdmDevice.serial || mdmDevice.MacAddress || '');
const matchDevice = {
identifier: friendly,
mdmData: mdmDevice
};
const matched = findBestMerakiClientMatch(matchDevice, enrichedMeraki.devices || []);
let matchedClient = matched || (enrichedMeraki.devices || []).find(c => {
const clientMeraki = c.meraki || c;
const cName = clientMeraki.name || clientMeraki.description || clientMeraki.UserName || '';
return (
normalizeMac(clientMeraki.mac) === mdmMac ||
normalizePlayerName(cName).toLowerCase().trim() === normName
);
});
const merakiData = matchedClient ? { ... (matchedClient.meraki || matchedClient) } : {};
return {
...mdmDevice,
meraki: merakiData
};
}));
// Second pass: unified domain attach (RED+Opti) for the whole list (slim for chat)
attachDomainData(mdmWithMeraki, redPlayers, optiDevices, {
identifierField: 'friendlyName',
optiShape: 'display', // slim display for /avstatus
onlyForMSCRed: false,
onlyForVWLEDopti: false
});
return mdmWithMeraki;
}
export function enrichAtlasDevices(atlasResult, enrichedMeraki) {
const atlasDevices = atlasResult.value || [];
return atlasDevices.map(atlasDev => {
const state = atlasDev.state || {};
const atlasIp = (state.IpAddress || '').trim();
const atlasNameLower = (atlasDev.name || atlasDev.displayName || '').toLowerCase();
// Match Atlas to enriched client using shared advanced matcher (for name strategies) + ip fallback
// Pass ip so the general matcher can use its IP strategy (consistent with rich path)
let matchedClient = findBestMerakiClientMatch({
identifier: atlasDev.name || atlasDev.displayName || '',
ip: atlasIp
}, enrichedMeraki.devices || []);
if (!matchedClient) {
// Fallback search like in enrichMdmDevices, to ensure we attach Meraki for AMPs even if
// exact matcher misses due to list shape or naming (by mac, name, or ip).
matchedClient = (enrichedMeraki.devices || []).find(c => {
const clientMeraki = c.meraki || c;
const cName = clientMeraki.name || clientMeraki.description || clientMeraki.UserName || '';
const cIp = (clientMeraki.ip || '').trim();
const devMac = normalizeMac( atlasDev.mac || (atlasDev.state && atlasDev.state.MacAddress) || '' );
return (
(devMac && normalizeMac(clientMeraki.mac) === devMac) ||
normalizePlayerName(cName).toLowerCase().trim() === normalizePlayerName(atlasDev.name || '').toLowerCase().trim() ||
(atlasIp && cIp === atlasIp)
);
});
}
const merakiData = matchedClient
? { ...(matchedClient.meraki || matchedClient) }
: {};
return {
...atlasDev,
meraki: merakiData, // ← flat meraki object
apDetails: matchedClient?.apDetails || null,
switchDetails: matchedClient?.switchDetails || null
};
});
}
// ====================== MAIN ENTRY ======================
/**
* Main entry: enrich AV data for a store, returning shape-specific result.
* For 'chat' we preserve exact prior collectDeviceStatus contract (so existing commands + char + avEnricher unchanged).
*/
export async function enrichAVForStore(storeNumber, { shape = 'chat' } = {}) {
const storeNum = String(storeNumber).trim();
logger('av:core', `enrichAVForStore(${storeNum}, shape=${shape})`, 'debug');
if (shape === 'chat') {
return collectDeviceStatusViaCore(storeNum);
}
if (shape === 'rich' || shape === 'build') {
return buildAVDevicesViaCore(storeNum);
}
if (shape === 'dashboard' || shape === 'enricher') {
const chatShape = await collectDeviceStatusViaCore(storeNum);
return buildDashboardList(chatShape);
}
throw new Error(`Unknown shape: ${shape}`);
}
/**
* Chat/collect shape implemented via the shared pieces (no behavior change).
* This is the body that used to live in deviceService.collectDeviceStatus.
*/
async function collectDeviceStatusViaCore(storeNum) {
logger('device:service', `Starting device status collection for store ${storeNum} (via core)`, 'debug');
// Get clients first so we can prefilter switches for ports (to interrogate fewer switches and avoid rate limits)
const clientsData = await getClientsForStore(storeNum);
const merakiClientsResultRaw = { value: clientsData };
const clientsForPorts = Array.isArray(clientsData) ? clientsData : (clientsData?.clients || []);
const relevantSwitchesForPorts = new Set();
for (const c of clientsForPorts) {
const conn = (c.recentDeviceConnection || '').toLowerCase();
if (c.recentDeviceSerial && !conn.includes('wireless')) {
relevantSwitchesForPorts.add(c.recentDeviceSerial);
}
}
// Use unified base collection for MDM + Atlas
const baseAV = await collectBaseAVData(storeNum, { includeDomain: true });
// For chat/enrichMdmDevices + avStatus consumers, ensure top-level friendlyName/name + lastSeen (camelCase)
// (the raw MDM objects from integration use PascalCase like UserName / DeviceFriendlyName / LastSeen / LastSystemSampleTime;
// prepareMDMBaseDevice normalizes into mdmDataSummary but for chat we extract .mdmData raw for full original data.
// Patch the expected top-level fields so display (last seen, name) + matching in enrichMdm + domain attach all work correctly.
// This keeps the per-device Meraki/port attachment distinct.)
const mdmBase = baseAV.filter(d => d.source === 'mdm').map(d => {
const raw = { ...(d.mdmData || d) };
const bestName = raw.friendlyName || raw.DeviceFriendlyName || raw.UserName || d.identifier || raw.name || '';
if (!raw.friendlyName) raw.friendlyName = bestName;
if (!raw.name) raw.name = bestName;
raw.lastSeen = raw.lastSeen || raw.LastSeen || raw.LastSystemSampleTime || '';
return raw;
});
const atlasBase = baseAV.filter(d => d.source === 'atlas').map(d => d.atlasData);
const [
merakiPortsResultRaw,
redResult,
optisignsResult
] = await Promise.allSettled([
getPortsForStore(storeNum, relevantSwitchesForPorts),
getREDStatusForStore(storeNum),
getOptiSignStatus(storeNum)
]);
const mdmResult = { status: 'fulfilled', value: mdmBase };
const atlasResult = { status: 'fulfilled', value: atlasBase };
const networkInfo = await findMerakiNetwork(storeNum);
// FULL MERAKI via the shared (now in enrichment layer)
const merakiEnriched = await enrichAllMerakiData(
networkInfo?.id,
merakiClientsResultRaw,
merakiPortsResultRaw,
networkInfo
);
// Enrich MDM with Meraki + RED + OptiSigns (chat slim shape)
const enrichedMdm = await enrichMdmDevices(
mdmResult,
merakiEnriched,
redResult,
optisignsResult
);
const enrichedAtlas = enrichAtlasDevices(atlasResult, merakiEnriched);
logger('device:service',
`Collected for store ${storeNum}: MDM=${enrichedMdm.length}, Atlas=${enrichedAtlas.length}, ` +
`Meraki clients=${merakiEnriched.devices.length}`);
return {
mdm: { status: mdmResult.status, data: enrichedMdm },
atlas: { status: atlasResult.status, data: enrichedAtlas },
optisigns: prepareResult(optisignsResult, 'optisigns'),
red: prepareResult(redResult, 'red'),
meraki: merakiEnriched,
topology: null
};
}
/**
* Rich/build shape implemented via shared pieces (no behavior change from old avDeviceBuilder).
* Replicates the orchestration but delegates client/ports/wireless/details/domain to shared,
* keeps relevant-only for rate limits, assembles exact prior result shape.
*/
async function buildAVDevicesViaCore(storeNum) {
logger('av:builder', `Building AV devices for store ${storeNum} (via core)`, 'debug');
try {
const baseDevices = await collectBaseAVData(storeNum, { includeDomain: false });
if (!baseDevices || baseDevices.length === 0) {
logger('av:builder', `No base devices found for store ${storeNum}`, 'warn');
}
const merakiData = await getClientsForStore(storeNum);
// Prefilter switches for ports using the clients' connected device serials (only wired ones need switch ports)
const relevantSwitchesForPorts = new Set();
for (const c of (merakiData.clients || [])) {
const conn = (c.recentDeviceConnection || '').toLowerCase();
if (c.recentDeviceSerial && !conn.includes('wireless')) {
relevantSwitchesForPorts.add(c.recentDeviceSerial);
}
}
const portsData = await getPortsForStore(storeNum, relevantSwitchesForPorts);
// Step 1: Meraki client + port enrichment (uses shared)
let enrichedDevices = await enrichWithMerakiViaCore(
baseDevices,
merakiData.clients || [],
merakiData.network?.url || '',
portsData
);
// Step 2: Wireless details (only for wireless clients) - shared
enrichedDevices = await enrichWirelessDetails(enrichedDevices, merakiData.network?.id);
// Step 3: Meraki device details (switches + APs with wirelessStatus) - relevant only
const allMerakiDevices = merakiData.network
? await getAllMerakiDevices(merakiData.network.id)
: [];
const relevantSerials = new Set();
for (const device of enrichedDevices) {
const c = device.meraki?.client || {};
if (c.recentDeviceSerial) relevantSerials.add(c.recentDeviceSerial);
if (c.switchSerial) relevantSerials.add(c.switchSerial);
if (device.meraki?.apSerial) relevantSerials.add(device.meraki.apSerial);
if (device.meraki?.switchSerial) relevantSerials.add(device.meraki.switchSerial);
}
const relevantDevices = allMerakiDevices.filter(d => relevantSerials.has(d.serial));
logger('av:builder', `Enriching details only for ${relevantDevices.length}/${allMerakiDevices.length} relevant Meraki devices (to reduce rate limits)`);
const merakiDeviceDetails = await enrichMerakiDeviceDetails(merakiData.network?.id, relevantDevices);
// Step 4: Topology
const topology = await getMerakiTopology(merakiData.network?.id);
// Step 5: Domain data (RED + OptiSigns) — MUST be last, rich shape
enrichedDevices = await sharedEnrichDomainData(enrichedDevices, storeNum, { optiShape: 'rich' });
const result = {
success: true,
storeNumber: storeNum,
network: merakiData.network || null,
merakiDevices: allMerakiDevices,
merakiDeviceDetails,
merakiTopology: topology,
deviceCount: enrichedDevices.length,
devices: enrichedDevices,
lastUpdated: new Date().toISOString()
};
logger('av:builder', `Build complete for store ${storeNum} (${enrichedDevices.length} devices)`);
return result;
} catch (err) {
logger('av:builder', `Build failed for store ${storeNum}: ${err.message}`, 'error');
throw err;
}
}
async function enrichWithMerakiViaCore(baseDevices, allMerakiClients, networkUrl = '', portsData = null) {
const enriched = [];
const portStatusCache = new Map();
const portConfigs = (portsData && (portsData.ports || portsData)) || [];
logger('av:meraki', `Enriching ${baseDevices.length} devices with Meraki data`, 'debug');
for (const device of baseDevices) {
await attachMerakiClientWithPorts(device, allMerakiClients, portConfigs, portStatusCache, networkUrl);
enriched.push(device);
}
// Extra visibility for the reported case (multiple historical client records for same AV)
enriched.filter(d => /2477/i.test(d.identifier || '')).forEach(d => {
const c = d.meraki?.client || {};
const mdm = d.mdmData || d.mdm || {};
const mdmLast = mdm.LastSeen || mdm.lastSeen || mdm.LastSystemSampleTime || (mdm.mdmDataSummary && mdm.mdmDataSummary.lastSeen) || 'n/a';
logger('av:meraki', `2477 device after attach: ${d.identifier} → client lastSeen=${c.lastSeen || 'n/a'} port/switchport=${c.switchport || c.portNumber || c.recentDevicePort || 'n/a'} status=${c.status || 'n/a'} recentDev=${c.recentDeviceSerial || 'n/a'} mdmLastSeen=${mdmLast}`);
});
logger('av:meraki', `Meraki enrichment complete: ${enriched.filter(d => d.meraki?.client).length} matched`, 'debug');
return enriched;
}
// ====================== DASHBOARD / AV ENRICHER LIST BUILDER ======================
// Moved here from avEnricher.js for full unification. 'dashboard' shape now returns the
// enriched list directly (used by avModalService, and avEnricher for compat).
// Uses 'raw' opti shape + identifier 'name' on the built objects.
const normalizeName = name => normalizePlayerName(name || '').toLowerCase().trim();
function getCategory(name) {
const u = name.toUpperCase();
if (u.includes('AMP')) return 'AMP';
if (u.includes('VW')) return 'VW';
if (u.includes('LED')) return 'LED';
if (u.includes('MSC')) return 'MSC';
return 'Other';
}
function findMerakiFallback(deviceName, merakiClients) {
if (!Array.isArray(merakiClients)) return {};
const normName = normalizeName(deviceName);
return merakiClients.find(c => {
const m = c.meraki || c || {};
return normalizeName(m.description || m.user || m.name || '') === normName;
}) || {};
}
function buildDashboardList(rawData) {
const { mdm, atlas, meraki, red, optisigns } = rawData;
logger('av:dashboard', `Enriching AV devices - MDM:${mdm?.data?.length || 0}, Atlas:${atlas?.data?.length || 0}`);
const avDevices = [];
// MDM Players (VW, LED, MSC) - use shared filter (strict, no domain here)
const mdmPlayers = filterAVDevices(mdm?.data || [], false);
for (const device of mdmPlayers) {
avDevices.push(enrichOneDevice(device, 'MDM', rawData));
}
// Atlas AMPs
const atlasAmps = (atlas?.data || []).filter(d =>
(d.name || d.displayName || '').toLowerCase().includes('amp')
);
for (const device of atlasAmps) {
avDevices.push(enrichOneDevice(device, 'Atlas', rawData));
}
// Domain (red/opti) attachment now unified via shared (use 'raw' shape to preserve previous dashboard behavior)
attachDomainData(avDevices, rawData.red?.data || [], rawData.optisigns?.data?.devices || [], {
identifierField: 'name',
optiShape: 'raw',
onlyForMSCRed: false,
onlyForVWLEDopti: false
});
logger('av:dashboard', `Final enriched AV devices: ${avDevices.length}`);
return avDevices;
}
function enrichOneDevice(sourceDevice, sourceType, rawData) {
const name = sourceDevice.friendlyName || sourceDevice.name || sourceDevice.displayName || 'Unknown';
const category = getCategory(name);
const isAtlasAmp = category === 'AMP';
// Use the meraki object already attached (best source)
let merakiData = sourceDevice.meraki || {};
// If it's empty or not an object, do a strict fallback search using the shared advanced matcher
if (!merakiData || typeof merakiData !== 'object' || Object.keys(merakiData).length < 5) {
const matchDevice = { identifier: name };
const matched = findBestMerakiClientMatch(matchDevice, rawData.meraki?.devices || []);
merakiData = matched || findMerakiFallback(name, rawData.meraki?.devices || []);
}
const switchportStatus = (merakiData && merakiData.switchportStatus) || {};
const switchportConfig = (merakiData && merakiData.switchportConfig) || {};
return {
identifier: name,
name: name,
deviceCategory: category,
source: sourceType,
isAtlasAmp,
ip: merakiData.ip || sourceDevice.state?.IpAddress || sourceDevice.ip,
mac: merakiData.mac || sourceDevice.mac || sourceDevice.serialNumber,
vlan: merakiData.vlan,
switchport: merakiData.switchport || merakiData.portNumber,
status: merakiData.status || 'Online',
lastSeen: merakiData.lastSeen || sourceDevice.lastSeen,
meraki: merakiData,
switchportStatus,
switchportConfig,
mdm: sourceType === 'MDM' ? sourceDevice : null,
atlas: isAtlasAmp ? sourceDevice : null,
// red/optisigns attached post-build via shared attachDomainData (raw shape for dashboard)
// (removed from here to unify)
usage: merakiData.usage || { sent: 0, recv: 0 }
};
}
function prepareResult(result, name) {
if (result.status === 'fulfilled') return { status: 'success', data: result.value };
logger('device:service', `${name} fetch failed: ${result.reason?.message || result.reason}`, 'warn');
return { status: 'failed', error: result.reason?.message || result.reason || 'Unknown error' };
}
export { buildDashboardList }; // for avEnricher compat / direct dashboard list use
export default {
enrichAVForStore,
};