netanalyzer/server.js
Joseph McQueen b3c37bd7df feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent
Rebrand NetAnalyzer -> StoreHealthAnalyzer and consolidate the store
reporting surface into a single `st [number]` command with focused
sub-modes.

Commands
- st [number]                - general info (SIW + brands + Meraki net link)
- st [number] network        - switches, APs, store server
- st [number] pos            - registers, payment terminals, customer display
- st [number] ios            - MDM-tracked iOS hardware
- st [number] phone          - wired 78xx + DECT basestations/handsets with
                               registration state, extensions and main DID
- st [number] av             - Atlas AMPs + MDM-tracked Apple TVs, video
                               walls, music players, LED displays
- Removed `analyze` in favor of the unified `st` surface

Integrations
- integrations/webex: Service App OAuth with rotating refresh tokens,
  seed + cleanup scripts, tokens/ storage (git-ignored)
- integrations/atlas: Xyte client + cached device discovery keyed on
  zero-padded 6-digit store numbers, cold-cache failure -> unavailable
  banner instead of a misleading empty result
- services/webexPhone, services/webexService, services/avService: shape
  raw upstream data into the report layer's contract
- utils/merakiMatcher: FQDN hostname extraction so payment terminals
  match Meraki descriptions; case-insensitive lookup
- utils/chunkReport: split long markdown replies at 7000-char boundaries

Reliability / ops
- server.js: awaited framework.stop() + 8s hard-kill timer so nodemon /
  Docker restarts don't leak WDM device registrations ("excessive device
  registrations")
- nodemon.json: SIGINT so the graceful path always runs
- scripts/cleanupWebexDevices.js: one-shot WDM cleanup utility
- Group-space routing: hears() regexes tolerate the leading @BotName
  prefix Webex prepends to mentions
- Replaced HTML-unsafe <number> placeholders with [number] in all help
  strings

Remote agent containerization
- docker/remote-agent/: multi-stage node:22-alpine image, non-root user,
  tini for signal handling, minimal deps (ws/axios/dotenv)
- docker/remote-agent/package.sh: docker buildx build defaulting to
  linux/amd64 (with override), saves image + assembles deploy/ + writes
  SHA256 + zips for offline transfer
- docker/remote-agent/deploy/: runtime docker-compose.yml, install.sh
  with platform sanity check, remote-host README
- .dockerignore + .gitignore updates for build artifacts and dist bundles
- npm run agent:package convenience script

Cleanup
- Dropped storeHealth.js / HealthReport.js and their tests/mocks in favor
  of the shared storeDetail pipeline
- Store model handles null SIW records gracefully; toSummary always
  ends with a newline so the Meraki link sits on its own line

Tests
- 144 tests across 14 suites passing; new coverage for atlasClient,
  atlasDevices, avService, avCategory classification, webexPhone,
  webexServiceAppAuth, storeDetail integration, siw, chunkReport and
  the updated meraki matcher

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 09:54:41 -04:00

134 lines
4.2 KiB
JavaScript

const Framework = require('webex-node-bot-framework');
const config = require('./config');
const { startWebSocketServer, stopWebSocketServer } = require('./services/websocket');
const { handleStoreCommand, handleHelpCommand, getCommandText } = require('./bot/handlers');
const logger = require('./utils/logger');
const framework = new Framework({
token: config.webex.token,
removeWebhooksOnStart: true,
logLevel: config.logLevel,
});
logger.info('Starting StoreHealthAnalyzer framework', { botName: config.webex.name });
// Register command handlers. The `(?:\S+\s+)?` prefix allows the leading
// "@BotName " that Webex prepends to group-space mentions, while still
// anchoring at the start (so "please run st 305" or "stop"/"start" don't
// trigger). DMs (no bot-name prefix) are matched by the empty optional group.
framework.hears(
/^(?:\S+\s+)?help\b/i,
handleHelpCommand,
'Show available commands (try: help, help st)'
);
framework.hears(
/^(?:\S+\s+)?st\b/i,
handleStoreCommand,
'st [number] — info | st [number] network — switches/APs/server | st [number] pos — POS devices | st [number] ios — iOS devices'
);
// Friendly fallback for anything that didn't match the commands above.
framework.hears(
/.*/,
(bot, trigger) => {
const heard = getCommandText(trigger);
logger.info('Unhandled message', { text: heard });
bot.say(
'markdown',
[
`I heard: _${heard}_`,
'',
'Try `st [number]`, `st [number] network`, `st [number] pos`, `st [number] ios`, or `help` for commands.',
].join('\n')
);
},
99999
);
framework.on('initialized', () => {
logger.info('Framework initialized and connected via WebSocket');
});
framework.on('spawn', (bot, _id, addedBy) => {
logger.info('Bot spawned in room', { room: bot.room.title || 'Unknown' });
if (addedBy) {
bot.say(
'markdown',
'StoreHealthAnalyzer is ready! Try `st 782`, `st 782 network`, `st 782 pos`, `st 782 ios`, or type `help`.'
);
}
});
framework
.start()
.then(() => {
logger.info('WebSocket server for remote agent starting');
startWebSocketServer();
})
.catch(err => {
logger.error('Error starting framework', { error: err.message });
process.exit(1);
});
// ==================== Graceful Shutdown ====================
// IMPORTANT: framework.stop() unregisters this process's WDM device with
// Webex. If we don't await it, those devices accumulate and Webex eventually
// rejects new registrations with "User has excessive device registrations".
// nodemon.json sets signal=SIGINT for the same reason.
let isShuttingDown = false;
const SHUTDOWN_HARD_TIMEOUT_MS = 8000;
async function shutdown(signal) {
if (isShuttingDown) return;
isShuttingDown = true;
logger.info('Shutting down gracefully', { signal });
// Belt-and-suspenders: if cleanup hangs (e.g. the Webex websocket is stuck),
// force-exit so nodemon/Docker can move on. SIGKILL from the orchestrator
// would skip the device unregister and leak a WDM registration.
const hardKill = setTimeout(() => {
logger.error('Shutdown exceeded hard timeout; forcing exit', {
timeoutMs: SHUTDOWN_HARD_TIMEOUT_MS,
});
process.exit(1);
}, SHUTDOWN_HARD_TIMEOUT_MS);
hardKill.unref();
try {
stopWebSocketServer();
if (framework && typeof framework.stop === 'function') {
try {
await framework.stop();
logger.info('Webex framework stopped (device registration released)');
} catch (err) {
logger.error('framework.stop() failed; device registration may leak', {
error: err.message,
});
}
}
logger.info('Cleanup complete. Exiting.');
} catch (err) {
logger.error('Error during shutdown', { error: err.message });
} finally {
clearTimeout(hardKill);
process.exit(0);
}
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('uncaughtException', err => {
logger.error('Uncaught Exception', { error: err.message, stack: err.stack });
shutdown('uncaughtException');
});
process.on('unhandledRejection', reason => {
logger.error('Unhandled Rejection', {
reason: reason instanceof Error ? reason.message : String(reason),
});
});