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>
134 lines
4.2 KiB
JavaScript
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),
|
|
});
|
|
});
|