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

184 lines
7.3 KiB
JavaScript

// services/enrichment/merakiMatcher.js
//
// Unified, high-quality Meraki client matching logic.
// This is the single source of truth for matching MDM/Atlas devices
// to Meraki clients across the entire application.
//
// Extracted and unified from multiple locations (avDeviceBuilder, deviceService, etc.)
// to eliminate duplication and make improvements in one place.
import { logger } from '../../utils/logger.js';
import { normalizePlayerName } from '../../utils/normalize.js';
import { normalizeMac } from './normalizers.js';
/**
* Finds the best matching Meraki client for a given device (MDM or Atlas).
* Uses multiple strategies in priority order for maximum reliability,
* especially with country-prefixed identifiers (US, CA, etc.).
*
* @param {Object} device - The source device (from MDM or Atlas)
* @param {Array} allMerakiClients - List of Meraki clients
* @param {Object} [options]
* @param {boolean} [options.debug] - Enable extra debug logging for a specific device
* @returns {Object|null} The best matching Meraki client, or null
*/
export function findBestMerakiClientMatch(device, allMerakiClients, options = {}) {
if (!device || !allMerakiClients || allMerakiClients.length === 0) {
return null;
}
const identifier = (device.identifier || '').toUpperCase().trim();
const username = (
device.mdmData?.UserName ||
device.mdmData?.DeviceFriendlyName ||
device.mdmData?.DeviceReportedName ||
''
).toUpperCase().trim();
// Extract country prefix if present (US, CA, MX, EU, AU, ...)
const countryMatch = identifier.match(/^(US|CA|MX|EU|AU)/i);
const countryPrefix = countryMatch ? countryMatch[1].toUpperCase() : 'US';
// Stripped version without country prefix (e.g. 001024MSCAE)
const strippedId = identifier.replace(/^(US|CA|MX|EU|AU)/i, '').trim();
// Atlas support: extract IP/MAC from atlasData or summary (for AMPs in rich/build paths)
// This allows IP-based matching when name/desc doesn't match (common for AMPs)
const atlasIp = (
device.atlasData?.state?.IpAddress ||
device.atlasData?.ipAddress ||
device.atlasDataSummary?.ipAddress ||
device.ip ||
''
).trim().toLowerCase();
const atlasMac = normalizeMac(
device.atlasData?.mac ||
device.atlasDataSummary?.macAddress ||
''
);
const shouldDebug = options.debug || identifier.startsWith('CA');
if (shouldDebug) {
logger('enrichment:meraki', `Matching attempt for ${identifier}`);
logger('enrichment:meraki', `Username: ${username}, Stripped: ${strippedId}, atlasIp: ${atlasIp}, atlasMac: ${atlasMac}`);
}
// Track the best (most recent by lastSeen) match across *all* strategies.
// This handles the case of multiple client entries for the same AV identifier (e.g. historical
// connections on different ports/APs over the timespan window). We return the freshest one.
let bestMatch = null;
let bestTime = -1;
for (const listItem of allMerakiClients) {
// Unwrap: in chat shape, the "clients" list items from enrichAll are wrapped {name, meraki: {...client data...}, ...}
// In rich shape, they are raw client objects with description/user/mac/ip at top.
// Support both so matching works uniformly for MDM and Atlas in both paths.
const c = listItem.meraki || listItem; // the inner client data if wrapped
const desc = (c.description || listItem.name || '').toUpperCase().trim();
const userField = (c.user || '').toUpperCase().trim();
const clientMac = normalizeMac(c.mac);
let matchedThis = false;
// Strategy 1: Exact full identifier match (best case)
if (desc === identifier || userField === identifier) {
matchedThis = true;
if (shouldDebug) logger('enrichment:meraki', `✓ Exact match for ${identifier}`);
}
// Strategy 2: Username / friendly name match
if (username && (desc.includes(username) || userField.includes(username))) {
matchedThis = true;
if (shouldDebug) logger('enrichment:meraki', `✓ Username match for ${identifier}`);
}
// Strategy 3: Stripped ID match (handles CA001024MSCAE vs 001024MSCAE)
if (strippedId && (desc.includes(strippedId) || userField.includes(strippedId))) {
matchedThis = true;
if (shouldDebug) logger('enrichment:meraki', `✓ Stripped ID match for ${identifier}`);
}
// Strategy 4: Country + stripped fallback
if (countryPrefix && strippedId) {
const countryStripped = `${countryPrefix}${strippedId}`;
if (desc.includes(countryStripped) || userField.includes(countryStripped)) {
matchedThis = true;
if (shouldDebug) logger('enrichment:meraki', `✓ Country+stripped match for ${identifier}`);
}
}
// Strategy 5: MAC address fallback (very reliable)
// Support various shapes: mdmData.MacAddress (AV), direct .mac or .MacAddress (phones, Webex, etc.)
// Also atlas for AMPs (from atlasData or summary in base prepared devices)
const deviceMac = device.mdmData?.MacAddress || device.mac || device.MacAddress || atlasMac;
if (deviceMac) {
const devMac = normalizeMac(deviceMac);
if (clientMac && clientMac === devMac) {
matchedThis = true;
if (shouldDebug) logger('enrichment:meraki', `✓ MAC match for ${identifier}`);
}
}
// Strategy 6: IP address match (for Atlas AMPs that may not expose friendly name in Meraki clients)
if (atlasIp) {
const clientIp = (c.ip || '').trim().toLowerCase();
if (clientIp && clientIp === atlasIp) {
matchedThis = true;
if (shouldDebug) logger('enrichment:meraki', `✓ IP match for ${identifier}`);
}
}
if (matchedThis) {
// Pick the most recent by lastSeen (Meraki provides lastSeen on client records).
// This ensures that when multiple entries exist for the same device (different ports/sessions),
// we attach the current/freshest one (e.g. the one on port 35 for VW01).
const thisTime = c.lastSeen ? Date.parse(c.lastSeen) || 0 : 0;
if (bestMatch === null || thisTime > bestTime) {
bestMatch = listItem;
bestTime = thisTime;
}
// Continue scanning to find any even fresher match via other strategies or later records.
}
}
if (bestMatch) {
const chosen = bestMatch.meraki || bestMatch;
if (shouldDebug || identifier.includes('2477')) {
logger('enrichment:meraki', `✓ Most recent Meraki client chosen for ${identifier} (lastSeen=${chosen.lastSeen || 'n/a'}, port=${chosen.switchport || chosen.recentDevicePort || 'n/a'})`);
}
return bestMatch; // return the list item (wrapped or raw) for consistent downstream handling
}
if (shouldDebug) {
logger('enrichment:meraki', `✗ No match found for ${identifier}`, 'debug');
}
return null;
}
/**
* Convenience wrapper that also enriches the device object in place
* (for backward compatibility during migration).
*/
export async function enrichDeviceWithBestMerakiMatch(device, allMerakiClients, portStatusCache) {
const match = findBestMerakiClientMatch(device, allMerakiClients);
if (!match) {
return device;
}
// Basic enrichment (can be extended later)
device.meraki = {
...(device.meraki || {}),
client: match,
connectionType:
match.recentDeviceConnection === 'Wireless' || match.ssid ? 'Wireless' : 'Wired',
mac: match.mac,
ip: match.ip,
// Note: port enrichment can be added here or kept separate
};
return device;
}