collabSupport/services/enrichment/merakiEnrichment.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

488 lines
19 KiB
JavaScript

// services/enrichment/merakiEnrichment.js
//
// Shared Meraki enrichment helpers for AV devices.
// Extracted to reduce duplication between deviceService (chat/collect) and avDeviceBuilder (rich build).
// avEnricher relies on pre-enriched data from collect.
//
// Includes:
// - attachMerakiClient (basic client match + common fields + url)
// - enrichWirelessDetails (the detailed wireless summary used in builder)
// - enrichMerakiDeviceDetails (switches + APs details, relevant-only in callers)
// - enrichSwitchesAndAPsShared (relevant switches+APs with port statuses for chat + wireless; used by deviceService)
// - Re-exports or helpers for switches/APs if we pull more.
//
// Uses shared matcher.
import { findBestMerakiClientMatch } from './merakiMatcher.js';
import { getMerakiWirelessStatus, getMerakiDeviceDetail } from '../../integrations/meraki/devices.js';
import { findMerakiNetwork } from '../../integrations/meraki/networks.js';
import {
getWirelessClientConnectionStats,
getWirelessClientHealthScores,
getSwitchPortConfig,
getSwitchPortStatus,
getSwitchPortsStatuses
} from '../../integrations/meraki/clients.js';
import {
getWirelessClientSignalQuality,
getWirelessClientLatency,
getWirelessClientFailedConnections
} from '../../integrations/meraki/devices.js';
import { logger } from '../../utils/logger.js';
/**
* Attach basic Meraki client data to a device (using shared matcher).
* Common across paths. Adds client, connectionType, optional clientUrl.
* Port status/config can be added by callers (differs slightly between chat and build).
*
* @param {Object} device - base device with identifier or friendlyName etc.
* @param {Array} allMerakiClients
* @param {string} [networkUrl=''] - for building clientUrl
* @returns {Object} the device (mutated)
*/
export function attachMerakiClient(device, allMerakiClients, networkUrl = '') {
const merakiClient = findBestMerakiClientMatch(device, allMerakiClients);
if (merakiClient) {
device.meraki = {
client: merakiClient,
connectionType: merakiClient.recentDeviceConnection === 'Wireless' ||
merakiClient.ssid ? 'Wireless' : 'Wired',
};
if (networkUrl && merakiClient.id) {
device.meraki.clientUrl = buildMerakiClientUrl(merakiClient.id, { url: networkUrl });
}
} else {
device.meraki = {
client: null,
connectionType: 'Unknown'
};
}
return device;
}
/**
* Full wireless enrichment (extracted and shared from builder).
* Used for wireless clients in rich builds.
*/
export async function enrichWirelessDetails(devices, networkId) {
if (!networkId) return devices;
for (const device of devices) {
const client = device.meraki?.client;
if (device.meraki?.connectionType !== 'Wireless' || !client?.id) {
continue;
}
try {
const [connStats, health, signalQuality, latency, failedConns] = await Promise.allSettled([
getWirelessClientConnectionStats(networkId, client.id),
getWirelessClientHealthScores(networkId, client.id),
getWirelessClientSignalQuality(networkId, client.id),
getWirelessClientLatency(networkId, client.id),
getWirelessClientFailedConnections(networkId, client.id)
]);
let connectionDurationSeconds = null;
if (client.firstSeen) {
const firstSeenTime = new Date(client.firstSeen).getTime();
connectionDurationSeconds = Math.floor((Date.now() - firstSeenTime) / 1000);
}
device.meraki.wirelessSummary = {
ssid: client.ssid || "—",
apName: client.recentDeviceName || "—",
apSerial: client.recentDeviceSerial || "—",
rssi: signalQuality.status === 'fulfilled' ? signalQuality.value.rssi : null,
snr: signalQuality.status === 'fulfilled' ? signalQuality.value.snr : null,
avgLatencyMs: latency.status === 'fulfilled' ? latency.value.avgLatencyMs : null,
txRate: client.txRate || null,
rxRate: client.rxRate || null,
connectionDurationSeconds,
connectionDurationDisplay: connectionDurationSeconds && connectionDurationSeconds < 86400 * 30
? `${Math.floor(connectionDurationSeconds / 3600)}h ${Math.floor((connectionDurationSeconds % 3600) / 60)}m`
: "Long-term (>30 days)",
connectionSuccess: connStats.status === 'fulfilled' ? (connStats.value?.success || 0) : 0,
failedConnectionsCount: failedConns.status === 'fulfilled' ? (failedConns.value?.length || 0) : 0
};
device.meraki.wirelessDetails = {
connectionStats: connStats.status === 'fulfilled' ? connStats.value : null,
healthScores: health.status === 'fulfilled' ? health.value : null,
signalQuality: signalQuality.status === 'fulfilled' ? signalQuality.value : null,
latency: latency.status === 'fulfilled' ? latency.value : null,
failedConnections: failedConns.status === 'fulfilled' ? failedConns.value : []
};
} catch (err) {
logger('av:meraki', `Wireless enrichment failed for ${device.identifier}`, 'warn');
}
}
return devices;
}
/**
* Enrich list of Meraki devices (switches/APs) with details and wireless status.
* Extracted from avDeviceBuilder.
*/
export async function enrichMerakiDeviceDetails(networkId, allMerakiDevicesList) {
if (!networkId || !allMerakiDevicesList?.length) return {};
const detailsMap = {};
logger('av:meraki', `Enriching ${allMerakiDevicesList.length} Meraki devices (switches + APs details${allMerakiDevicesList.length < 10 ? ' (relevant subset)' : ''})`);
for (const dev of allMerakiDevicesList) {
const serial = dev.serial;
if (!serial) continue;
const isAP = (dev.model || dev.productType || '').toUpperCase().startsWith('MR'); // MR = Meraki wireless AP
try {
const calls = [getMerakiDeviceDetail(serial)];
if (isAP) {
calls.push(getMerakiWirelessStatus(serial));
}
const results = await Promise.allSettled(calls);
const basicRes = results[0];
const wirelessRes = isAP ? results[1] : null;
const fullDevice = basicRes.status === 'fulfilled' ? basicRes.value : dev;
if (wirelessRes && wirelessRes.status === 'fulfilled' && wirelessRes.value) {
fullDevice.wirelessStatus = wirelessRes.value;
}
detailsMap[serial] = fullDevice;
} catch (err) {
logger('av:meraki', `Failed enriching device ${serial}: ${err.message}`, 'warn');
detailsMap[serial] = dev;
}
}
return detailsMap;
}
/**
* Shared enrichment for switches + APs details, port statuses (for chat), wireless status (for APs).
* Computes *relevant* serials only from the provided client list (raw or enriched) to avoid rate limits/429s.
* Uses axios-based integrations (consistent, no SDK client needed).
* Returns maps in the shape expected by deviceService's enrichAllMerakiData (for attaching switchDetails/apDetails
* and for bulk port status lookup to dedup /statuses calls).
*
* Can be used by both collect (chat) and build paths.
*/
export async function enrichSwitchesAndAPsShared(networkId, clientList = []) {
const switches = new Map();
const aps = new Map();
if (!networkId) return { switches, aps };
// Collect relevant serials from raw client records (direct fields: recentDeviceSerial + recentDeviceConnection)
// or from enriched (meraki.recent... or top level after some processing).
const switchSerials = new Set();
const apSerials = new Set();
for (const dev of clientList || []) {
const m = dev.meraki || dev; // support raw client (flat recentDevice*) or post-enrich (under meraki or flat)
const recentSerial = m.recentDeviceSerial || m.recentDeviceSerial;
const conn = (m.recentDeviceConnection || m.connectionType || '').toLowerCase();
const isWireless = conn.includes('wireless');
if (m.switchSerial) switchSerials.add(m.switchSerial);
if (m.apSerial) apSerials.add(m.apSerial);
if (recentSerial) {
if (isWireless) {
apSerials.add(recentSerial);
} else {
switchSerials.add(recentSerial);
}
}
}
const allRelevant = new Set([...switchSerials, ...apSerials]);
if (allRelevant.size === 0) {
logger('av:meraki', 'No relevant switches/APs derived from clients (will return empty maps)');
return { switches, aps };
}
logger('av:meraki', `Enriching switches/APs for ${allRelevant.size} relevant serials (switches: ${switchSerials.size}, APs: ${apSerials.size}) from client list`);
// Enrich switches (detail + batch port *statuses* for the map used by chat port lookup)
for (const serial of switchSerials) {
try {
const deviceInfo = await getMerakiDeviceDetail(serial);
const portStatuses = await getSwitchPortsStatuses(serial);
switches.set(serial, {
serial,
name: deviceInfo.name || 'MS Switch',
model: deviceInfo.model,
status: deviceInfo.status || 'offline',
lastReportedAt: deviceInfo.lastReportedAt,
ports: portStatuses || [],
});
} catch (err) {
logger('device:enrich', `Failed to enrich switch ${serial}: ${err.message}`, 'warn');
switches.set(serial, { serial, name: 'Unknown Switch', status: 'offline', ports: [] });
}
}
// Enrich APs (detail + wireless status + channelInfo shape)
for (const serial of apSerials) {
try {
const [deviceInfo, wirelessStatus] = await Promise.allSettled([
getMerakiDeviceDetail(serial),
getMerakiWirelessStatus(serial)
]);
const dev = deviceInfo.status === 'fulfilled' ? deviceInfo.value : { serial };
const ws = wirelessStatus.status === 'fulfilled' ? wirelessStatus.value : null;
aps.set(serial, {
serial,
name: (ws && ws.name) || dev.name || 'MR Access Point',
status: (ws && ws.status) || dev.status || 'offline',
lastSeen: ws && ws.lastSeen,
basicServiceSets: (ws && ws.basicServiceSets) || [],
channelInfo: ((ws && ws.basicServiceSets) || []).map(bss => ({
ssid: bss.ssidName,
channel: bss.channel,
band: bss.band,
power: bss.power,
channelWidth: bss.channelWidth,
bssid: bss.bssid,
})),
});
} catch (err) {
logger('device:enrich', `Failed to enrich AP ${serial}: ${err.message}`, 'warn');
aps.set(serial, { serial, name: 'Unknown AP', status: 'offline', channelInfo: [] });
}
}
return { switches, aps };
}
/**
* Attach port config and status to a Meraki client entry.
* Extracted from enrichMerakiData and legacy enrichDeviceWithMeraki.
* portConfigs come from getPortsForStore result.
* portStatusCache is for getSwitchPortStatus (to batch /statuses fetches).
*/
export async function attachPortConfigAndStatus(client, portConfigs = [], portStatusCache = new Map()) {
const portNum = client.switchport;
const switchSerial = client.recentDeviceSerial;
const isWireless = (client.recentDeviceConnection || '').toLowerCase() === 'wireless';
let portConfig = null;
let portStatus = null;
if (switchSerial && portNum) {
portConfig = portConfigs.find(p =>
p.deviceSerial === switchSerial &&
String(p.portId || p.number) === String(portNum)
);
// Get real port status (async, uses cache)
try {
portStatus = await getSwitchPortStatus(switchSerial, portNum, portStatusCache);
} catch (e) {
logger('meraki:enrich', `Port status attach failed for ${switchSerial}:${portNum}`);
}
}
// Attach common port fields (merged into the client object)
Object.assign(client, {
portNumber: portNum || (isWireless ? '—' : '?'),
portName: portConfig?.portName || portConfig?.name || (isWireless ? 'Wireless (AP)' : '—'),
portEnabled: portConfig?.enabled,
speed: portConfig?.speed,
duplex: portConfig?.duplex,
poeEnabled: portConfig?.poeEnabled,
poePower: portConfig?.poePower || 0,
accessPolicy: portConfig?.accessPolicyType || portConfig?.accessPolicy || '—',
allowedMacs: portConfig?.stickyMacAllowList || portConfig?.allowedMacs || [],
switchportStatus: portStatus,
switchportConfig: portConfig,
switchSerial: switchSerial || '—',
deviceName: client.recentDeviceName || 'SWITCH',
connectionType: isWireless ? 'Wireless' : 'Wired',
});
return client;
}
/**
* Convenience: attach basic Meraki client + ports in one go (for paths that want ports immediately).
* Uses the shared client attach + port attach.
*/
export async function attachMerakiClientWithPorts(device, allMerakiClients, portConfigs = [], portStatusCache = new Map(), networkUrl = '') {
attachMerakiClient(device, allMerakiClients, networkUrl);
const client = device.meraki?.client;
if (client) {
await attachPortConfigAndStatus(client, portConfigs, portStatusCache);
// Merge back if needed (since we mutated client)
device.meraki.client = client;
}
return device;
}
/**
* Build client URL dynamically from networkInfo.url to avoid hardcoding dashboard host (e.g. n976).
*/
export function buildMerakiClientUrl(clientId, networkInfo) {
if (!clientId || !networkInfo?.url) return '';
const baseMatch = networkInfo.url.match(/^(https?:\/\/[^/]+)/);
const base = baseMatch ? baseMatch[1] : 'https://dashboard.meraki.com';
const networkShort = networkInfo.url.match(/dashboard\.meraki\.com\/([^/]+)/i)?.[1] || '';
const dashboardNode = networkInfo.url.match(/\/n\/([^/]+)/i)?.[1] || '';
if (networkShort && dashboardNode) {
return `${base}/${networkShort}/n/${dashboardNode}/manage/clients/${clientId}/overview`;
}
return '';
}
// ====================== CHAT-ORIENTED FULL MERAKI ENRICHMENT (moved from deviceService for core sharing) ======================
// Produces the "meraki" payload used by collectDeviceStatus (all filtered clients as devices + switches/aps maps).
// Used by core orchestrator and deviceService (thin).
async function enrichMerakiData(merakiClientsResult, merakiPortsResult, networkInfo, switchesForStatus = null) {
const clients = Array.isArray(merakiClientsResult.value)
? merakiClientsResult.value
: (merakiClientsResult.value?.clients || []);
const portConfigs = Array.isArray(merakiPortsResult.value) ? merakiPortsResult.value : [];
// Filter out VLAN 900
const filteredClients = clients.filter(c => c.vlan !== 900 && c.vlan !== '900');
// Use lookup from pre-fetched switches (bulk statuses now done in shared enrichSwitchesAndAPsShared) + shared for config
// This avoids extra /ports/statuses calls.
const enrichedClients = filteredClients.map((client) => {
const portNum = client.switchport;
const switchSerial = client.recentDeviceSerial;
const portConfig = portConfigs.find(p =>
p.deviceSerial === switchSerial &&
String(p.portId || p.number) === String(portNum)
);
let portStatus = null;
if (switchSerial && switchesForStatus && switchesForStatus.has(switchSerial)) {
const sw = switchesForStatus.get(switchSerial);
const portsList = sw?.ports || [];
portStatus = portsList.find(p => String(p.portId || p.number) === String(portNum)) || null;
}
const merakiClientUrl = buildMerakiClientUrl(client.id, networkInfo);
return {
...client,
isWireless: (client.recentDeviceConnection || '').toLowerCase() === 'wireless',
ssid: client.ssid || '—',
apName: client.recentDeviceName || '—',
apSerial: client.recentDeviceSerial || '—',
merakiClientUrl,
portNumber: portNum || ( (client.recentDeviceConnection || '').toLowerCase() === 'wireless' ? '—' : '?'),
portName: portConfig?.portName || portConfig?.name || ( (client.recentDeviceConnection || '').toLowerCase() === 'wireless' ? 'Wireless (AP)' : '—'),
portEnabled: portConfig?.enabled,
speed: portConfig?.speed,
duplex: portConfig?.duplex,
poeEnabled: portConfig?.poeEnabled,
poePower: portConfig?.poePower || 0,
accessPolicy: portConfig?.accessPolicyType || portConfig?.accessPolicy || '—',
allowedMacs: portConfig?.stickyMacAllowList || portConfig?.allowedMacs || [],
switchportStatus: portStatus,
switchportConfig: portConfig,
switchSerial: switchSerial || '—',
deviceName: client.recentDeviceName || 'SWITCH',
connectionType: (client.recentDeviceConnection || '').toLowerCase() === 'wireless' ? 'Wireless' : 'Wired',
networkShortName: networkInfo?.url ? networkInfo.url.match(/dashboard\.meraki\.com\/([^/]+)/i)?.[1] || '' : '',
dashboardNodeId: networkInfo?.url ? networkInfo.url.match(/\/n\/([^/]+)/i)?.[1] || '' : ''
};
});
return {
clients: enrichedClients,
networkInfo
};
}
/**
* Main Meraki enrichment for the chat/collect path (all clients + switches/APs maps).
* Now lives in enrichment for use by avEnrichmentCore and deviceService.
*/
export async function enrichAllMerakiData(networkId, merakiClientsResult, merakiPortsResult, networkInfo = null) {
// Use passed networkInfo if provided (from collectDeviceStatus which has storeNum)
// Falls back only for direct calls (no more dummy "2477")
if (!networkInfo || !networkInfo.url) {
networkInfo = { url: '' };
try {
// Fallback lookup (caller should pass to avoid)
const net = await findMerakiNetwork("");
if (net?.url) networkInfo.url = net.url;
} catch (e) {
logger('device:service', 'Could not get networkInfo for client links', 'warn');
}
}
// Switches/APs now use shared (computes relevant serials from *raw* clients directly, fetches only needed details+statuses+wireless).
// This + prior relevantSwitchSerials for ports + relevant device details = big reduction in Meraki calls (key to 429 fixes).
const clientsRaw = Array.isArray(merakiClientsResult.value) ? merakiClientsResult.value : (merakiClientsResult.value?.clients || []);
const portConfigs = Array.isArray(merakiPortsResult.value) ? merakiPortsResult.value : [];
const { switches, aps } = await enrichSwitchesAndAPsShared(networkId, clientsRaw);
// Step 1 (reordered): Client enrichment, with status lookup from switches map (no extra status fetch)
const clientEnriched = await enrichMerakiData(
merakiClientsResult,
merakiPortsResult,
networkInfo,
switches // pass for status lookup to avoid dupe fetches
);
// Step 3: Build final devices with enriched AP/Switch data
const enrichedDevices = (clientEnriched.clients || []).map(client => {
const m = client || {};
const device = {
id: `D${(client.description || client.user || client.mac || '').replace(/[^a-zA-Z0-9]/g, '')}`,
name: client.description || client.user || client.mac || 'Unknown',
meraki: { ...m },
red: client.red || {},
optisigns: client.optisigns || {},
atlas: null
};
if (m.apSerial && aps.has(m.apSerial)) {
device.apDetails = aps.get(m.apSerial);
device.meraki.apEnriched = true;
}
if (m.switchSerial && switches.has(m.switchSerial)) {
device.switchDetails = switches.get(m.switchSerial);
device.meraki.switchEnriched = true;
}
return device;
});
return {
devices: enrichedDevices,
switches: Array.from(switches.values()),
aps: Array.from(aps.values())
};
}
export default {
attachMerakiClient,
enrichWirelessDetails,
enrichMerakiDeviceDetails,
enrichSwitchesAndAPsShared,
enrichAllMerakiData,
attachPortConfigAndStatus,
attachMerakiClientWithPorts,
buildMerakiClientUrl,
};