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).
145 lines
No EOL
4.7 KiB
JavaScript
145 lines
No EOL
4.7 KiB
JavaScript
// 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 0–1 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;
|
||
} |