collabSupport/integrations/atlas/devices.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

145 lines
No EOL
4.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/integrations/atlas/devices.js
import { atlasGet } from './client.js';
import { logger } from '../../utils/logger.js';
// In-memory cache (→ replace with node-cache/redis later if scale needed)
let cachedDeviceList = []; // full list from /organization/devices
let lastCacheTime = 0;
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour adjust based on how often devices change
/**
* Refresh full organization device list (called by cron)
* Handles pagination (API defaults to 100 items/page, max per_page=100; uses ?page=N and next_page in body)
*/
export async function refreshAtlasDevicesCache() {
const start = Date.now();
logger('atlas:devices', 'Refreshing full device cache', 'debug');
try {
let allItems = [];
let page = 1;
let hasMore = true;
const PAGE_SIZE = 100;
while (hasMore) {
const data = await atlasGet('/organization/devices', { page, per_page: PAGE_SIZE });
const items = Array.isArray(data.items) ? data.items : [];
allItems = allItems.concat(items);
const nextPage = data.next_page;
hasMore = !!nextPage && items.length > 0;
logger('atlas:devices', `Page ${page}: ${items.length} items (running total ${allItems.length}) next_page=${nextPage}`, 'debug');
if (items.length < PAGE_SIZE) {
hasMore = false;
}
if (hasMore) {
page = Number(nextPage) || (page + 1);
await new Promise(r => setTimeout(r, 80)); // be nice between pages
}
if (page > 100) { // hard safety
logger('atlas:devices', 'Safety stop: exceeded 100 pages');
break;
}
}
cachedDeviceList = allItems;
lastCacheTime = Date.now();
logger('atlas:devices', `Cached ${cachedDeviceList.length} devices (${Date.now() - start} ms)`, 'debug');
} catch (err) {
logger('atlas:devices', `Cache refresh failed: ${err.message}`);
// Keep old cache if possible
}
}
/**
* Get cached device list (auto-refresh if stale/empty)
*/
export async function getAtlasDeviceList(forceRefresh = false) {
const now = Date.now();
if (forceRefresh || !cachedDeviceList.length || (now - lastCacheTime > CACHE_TTL_MS)) {
await refreshAtlasDevicesCache();
}
return cachedDeviceList;
}
/**
* Find devices matching a store number (name contains 6-digit padded storeNum, e.g. "002477" in "US002477AMP")
* @param {string|number} storeNumber e.g. "2477", 305 or "000305"
* @returns {Promise<Array>} matching device summaries (name, id, status, etc.)
*/
export async function findAtlasDevicesForStore(storeNumber) {
// Always use 6-digit zero-padded form for name matching in Atlas (e.g. 2477 → 002477, 305 → 000305).
// Raw/short numbers like "305" can substring-match unrelated devices (e.g. "00305x"), causing
// multiple/incorrect Atlas AMP results. Names follow US00NNNNAMP pattern.
const padded = String(storeNumber).trim().padStart(6, '0');
logger('atlas:devices', `Looking up Atlas devices for store ${storeNumber} (padded ${padded})`, 'debug');
const devices = await getAtlasDeviceList();
const matches = devices.filter(dev => {
const name = String(dev.name || '').toUpperCase();
return name.includes(padded);
});
logger('atlas:findDevices',
`Searched for store ${padded} → Found ${matches.length} Atlas AMP device(s)`, 'debug');
if (matches.length > 0) {
logger('atlas:findDevices', `Matched: ${matches.map(m => m.name).join(', ')}`, 'debug');
}
return matches;
}
/**
* Get detailed info for a single Atlas device by its ID
* @param {string} deviceId
* @returns {Promise<object|null>}
*/
export async function getAtlasDeviceDetail(deviceId) {
if (!deviceId) return null;
const start = Date.now();
logger('atlas:detail', `Fetching detail for device ${deviceId}`, 'debug');
try {
const data = await atlasGet(`/organization/devices/${deviceId}`);
logger('atlas:detail', `Detail fetched (${Date.now() - start} ms)`, 'debug');
return data;
} catch (err) {
logger('atlas:detail', `Failed for ${deviceId}: ${err.message}`);
return null;
}
}
/**
* Convenience: Get detailed device(s) for a store (find → fetch detail)
* Returns array (usually 01 items in practice)
*/
export async function getAtlasDeviceForStore(storeNumber) {
const candidates = await findAtlasDevicesForStore(storeNumber);
if (candidates.length === 0) {
return [];
}
// Fetch details for all matches (parallel)
const details = await Promise.allSettled(
candidates.map(c => getAtlasDeviceDetail(c.id))
);
const successful = details
.filter(r => r.status === 'fulfilled')
.map(r => r.value)
.filter(Boolean);
if (successful.length === 0) {
logger('atlas:getForStore', `No successful detail fetches for store ${storeNumber}`);
}
return successful;
}