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), }); });