collabSupport/index.js
jmcqueen d112e45eb6 Detect and inline-remediate Meraki IGMP snooping in /phonestatus
DECT basestations rely on multicast for handset discovery/registration.
When Meraki switches have IGMP snooping enabled without a querier —
or per-switch overrides that deviate from a DECT-safe policy — those
frames are pruned and DECT handsets silently fail to register.

- integrations/meraki/switches.js: getSwitchMulticastSettings,
  setSwitchMulticastSettings, and pure summarizeMulticast verdict fn.
- services/phoneService.js: fires the multicast fetch as soon as the
  network id is known, overlapping DECT enrichment; attaches
  data.multicast summary to the collector output. Non-fatal on error.
- services/renderers/phoneStatusRenderer.js: emits a single warning
  line inside the DECT Basestations section only when needsFix is
  true, itemizing which parts deviate (default snoop, default flood,
  N overrides).
- commands/igmpFix.js: frozen DECT_SAFE_MULTICAST_PAYLOAD constant
  ({snoop:false, flood:true, overrides:[]}), adaptive-card builder,
  and confirm/cancel handlers.
- commands/phoneStatus.js: appends the adaptive card when needsFix
  and the trigger came from chat (skipped on HTTP path).
- index.js: IGMP_FIX_ACTIONS set + dispatch branch mirroring the
  HOST_ASSIGN pattern (one-shot pending lookup, censorActionCard,
  domain call).
- utils/pendingIgmpFixes.js: 15-min TTL pending-card store.
- tests: 11 summarizer cases (all deviation permutations + malformed
  input), 4 renderer cases (each warning-line shape + silence when
  needsFix false or no DECT), and a frozen-constant regression guard
  on the PUT payload. Full suite: 47/47 passing.
2026-07-02 14:10:33 -04:00

667 lines
No EOL
26 KiB
JavaScript

// src/index.js
import 'dotenv/config';
import { logger } from './utils/logger.js';
// ──────────────────────────────────────────────
// Integrations & Clients
// ──────────────────────────────────────────────
import { refreshMerakiNetworksCache } from './integrations/meraki/networks.js';
import { refreshAtlasDevicesCache } from './integrations/atlas/devices.js';
import { pendingOffboards } from './utils/pendingOffboards.js';
import { requireApiToken, requireAuthForAllCommands } from './utils/httpAuth.js';
// ──────────────────────────────────────────────
// Commands
// ──────────────────────────────────────────────
// All command handlers live in commands/registry.js (single source of truth
// for both Webex chat and HTTP dispatch). Only the entries imported here are
// referenced directly by index.js — adaptive-card actions and the unknown-
// command fallback.
import { handleUnknown } from './commands/unknownCommand.js';
import { handleDectProvisionAction } from './commands/provisionDect.js';
import {
applyOffboardConfirmation,
cancelOffboardCard,
} from './commands/offboardUser.js';
import {
applyHostAssignConfirmation,
cancelHostAssignCard,
} from './commands/webexHost.js';
import { pendingHostAssigns } from './utils/pendingHostAssigns.js';
import {
applyDectSafeMulticast,
cancelIgmpFixCard,
} from './commands/igmpFix.js';
import { pendingIgmpFixes } from './utils/pendingIgmpFixes.js';
import { extractRequester } from './utils/requester.js';
import {
getCommand,
ALL_HTTP_COMMAND_KEYS,
MUTATING_COMMAND_KEYS,
} from './commands/registry.js';
// ──────────────────────────────────────────────
// Express setup
// ──────────────────────────────────────────────
import express from 'express';
const app = express();
const PORT = process.env.SERVER_PORT || 1800;
// Basic middleware
// Capture the raw request body during JSON parsing so downstream handlers can
// verify webhook signatures (e.g. a future ServiceChannel HMAC check on
// processServiceChannelWebhook). Previously this was attempted via a second
// `bodyParser.json({ verify })` after `express.json()`, but the first parser
// consumed the body so `req.rawBody` was never set.
app.use(express.json({
limit: '5mb',
verify: (req, res, buf) => { req.rawBody = buf; }
}));
app.use(express.urlencoded({ extended: true }));
// Support subpath proxy (e.g. /CollabSupport/ via NGINX) by stripping the prefix
// so internal routes like /phone/devices/build work whether prefix is passed through or stripped by proxy.
// This runs very early so static + all routes (including /:command and build endpoints) see clean paths.
app.use((req, res, next) => {
if (req.url.startsWith('/CollabSupport')) {
req.url = req.url.replace(/^\/CollabSupport/, '') || '/';
}
next();
});
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
});
// In your main app file (where you set up Express)
app.use(express.static('public')); // Serve files from /public folder
// NOTE: There is intentionally no /bot HTTP route here. The webex-node-bot-framework
// is initialized without `webhookUrl`, so it connects to Webex over websockets
// (see lib/framework.js → "There was no webhookUrl specified so we will use websockets instead").
// The previous `app.post('/bot', …)` was a no-op left over from a webhook-mode prototype
// and Webex never actually POSTed to it. If you ever switch back to webhook delivery,
// re-add the handler using `webhook(framework)` from 'webex-node-bot-framework/webhook'
// and configure `frameworkConfig.webhookUrl` + `webhookSecret`.
// ========================
// HTTP API ENDPOINTS - Dynamic Command Router (registry-driven)
// ========================
//
// Both this `/:command` route and the Webex `framework.hears` block at the
// bottom of the file dispatch through the same registry (commands/registry.js).
// The registry's `mutating` flag drives whether HTTP_API_TOKEN is required.
const commandAuth = requireApiToken({ scope: 'command' });
const dataAuth = requireApiToken({ scope: 'data' });
// For read-only dashboard/data endpoints (/api/av/*, /av/devices/build/*,
// /phone/devices/build/*). Open by default; locks down only when the operator
// explicitly opts in with HTTP_API_REQUIRE_AUTH=true.
function dataAuthGate(req, res, next) {
if (!requireAuthForAllCommands()) return next();
return dataAuth(req, res, next);
}
// In your routes file (e.g. app.js or routes/av.js)
import { getAVDevicesForStore, getShapedDeviceData } from './services/avDeviceService.js';
app.get('/api/av/devices/:storeNumber', dataAuthGate, async (req, res) => {
const result = await getAVDevicesForStore(req.params.storeNumber);
res.json(result);
});
import { buildAVDevices } from './services/avDeviceBuilder.js';
// Example: inside your AV router
// src/routes/av.js (or wherever your AV routes are)
app.get('/av/devices/build/:storeNumber', dataAuthGate, async (req, res) => {
const storeNumber = req.params.storeNumber;
try {
const result = await buildAVDevices(storeNumber); // direct pass-through
res.json(result);
} catch (err) {
logger('av:route', `Build endpoint failed for ${storeNumber}: ${err.message}`, 'error');
res.status(500).json({
success: false,
message: err.message
});
}
});
import { buildPhoneDevices } from './services/phoneDeviceBuilder.js';
app.get('/phone/devices/build/:storeNumber', dataAuthGate, async (req, res) => {
const storeNumber = req.params.storeNumber;
try {
const result = await buildPhoneDevices(storeNumber);
res.json(result);
} catch (err) {
logger('phone:route', `Build endpoint failed for ${storeNumber}: ${err.message}`, 'error');
res.status(500).json({
success: false,
message: err.message
});
}
});
app.get('/api/av/device/:storeNumber/:identifier', dataAuthGate, async (req, res) => {
const result = await getShapedDeviceData(req.params.storeNumber, req.params.identifier);
res.json(result);
});
// Auth gate that runs before the dispatcher. Mutating commands always require
// a valid HTTP_API_TOKEN; everything else does too when HTTP_API_REQUIRE_AUTH
// is on. Unknown commands fall through to the dispatcher (which returns 404).
function commandAuthGate(req, res, next) {
const name = (req.params.command || '').toLowerCase().trim();
const cmd = getCommand(name);
const isMutating = MUTATING_COMMAND_KEYS.has(name) || (cmd && cmd.mutating);
const needsAuth = isMutating || requireAuthForAllCommands();
if (!needsAuth) return next();
return commandAuth(req, res, next);
}
app.get('/:command', commandAuthGate, async (req, res) => {
const name = req.params.command.toLowerCase().trim();
const query = req.query;
logger('http:endpoint', `Received HTTP request → ${name}`, 'debug');
logger('http:endpoint', query, 'debug');
if (!name) {
return res.status(400).json({ error: 'Command is required (e.g. /avstatus)' });
}
const cmd = getCommand(name);
if (!cmd || cmd.http === false) {
return res.status(404).json({
error: `Unknown command: ${name}`,
supported: ALL_HTTP_COMMAND_KEYS,
});
}
// Adapter so chat handlers (which expect a bot + trigger) work over HTTP.
// Handlers should read inputs from trigger.query in this mode. `source:
// 'http'` lets handlers branch on origin (e.g. for audit log lines).
const fakeTrigger = {
args: [],
query,
rawQuery: req.originalUrl,
source: 'http',
};
let output = '';
const mockBot = {
say: async (formatOrPayload, message) => {
// Handlers call either bot.say('markdown', msg) or bot.say({markdown, attachments})
if (typeof formatOrPayload === 'object' && formatOrPayload !== null) {
output = formatOrPayload.markdown || formatOrPayload.text || JSON.stringify(formatOrPayload);
} else {
output = message;
}
},
};
try {
await cmd.handler(mockBot, fakeTrigger);
res.setHeader('Content-Type', 'text/markdown');
res.send(output || '(No output generated)');
} catch (err) {
logger('http:endpoint', `Error executing ${name}: ${err.message}`, 'error');
res.status(500).json({
error: 'Internal server error',
command: name,
message: err.message,
});
}
});
// ──────────────────────────────────────────────
// Webex Framework
// ──────────────────────────────────────────────
import Framework from 'webex-node-bot-framework';
// The framework connects to Webex over websockets (it only registers a
// webhook when `webhookUrl` is set, which we intentionally don't). `app` is
// still passed in case any framework feature wants to mount a route, but no
// HTTP ingress is required for inbound events to reach the bot.
//
// `removeDeviceRegistrationsOnStart: true` issues a `DELETE /wdm/api/v1/devices`
// against the bot's WDM record set during framework startup, BEFORE registering
// the new device. This is the framework-blessed cure for the "Forbidden: User
// has excessive device registrations" error: every nodemon restart, every
// container redeploy, and every crash leaves the prior WDM registration alive
// on Cisco's side (TTL ~2h, hard cap ~100). Wiping them on each start is safe
// for a single-instance bot — if multi-instance deploys are ever needed,
// gate this on a `WEBEX_DEDUP_DEVICES_ON_START` env var instead.
const frameworkConfig = {
token: process.env.WEBEX_BOT_TOKEN,
app,
removeDeviceRegistrationsOnStart: true,
};
if (!frameworkConfig.token) {
logger('startup', 'WEBEX_BOT_TOKEN is required', 'error');
process.exit(1);
}
const framework = new Framework(frameworkConfig);
// Framework debug logging is very chatty. Gate it on either WEBEX_FRAMEWORK_DEBUG=true
// or LOG_LEVEL=debug so production stays quiet by default but operators can still
// turn it on without code changes.
const FRAMEWORK_DEBUG =
(process.env.WEBEX_FRAMEWORK_DEBUG || '').toLowerCase() === 'true' ||
(process.env.LOG_LEVEL || '').toLowerCase() === 'debug';
framework.debug(FRAMEWORK_DEBUG);
// `framework.start()` returns a `when`-style promise. Previously this was
// fire-and-forget — a token failure or transient Webex outage at startup would
// throw an unhandledRejection (now caught by the process-level handler) but
// without a clear "framework failed to start" log to anchor the diagnosis.
Promise.resolve(framework.start()).catch((err) => {
logger('framework', `Failed to start: ${err.message}`, 'error');
// Re-throw so unhandledRejection -> shutdown() converges on the same exit
// path as any other unrecoverable startup error.
setImmediate(() => { throw err; });
});
// ──────────────────────────────────────────────
// Adaptive Card submit handler
// ──────────────────────────────────────────────
// Single dispatch point for all attachmentAction events. We classify by the
// `action` input set and route to the right subsystem. Previously there were
// two separate framework.on('attachmentAction', …) listeners — one for
// offboard cards and one for DECT provisioning — with overlapping early-return
// logic that produced misleading "Offboard cancelled" messages for unrelated
// cards. One handler with explicit routing is easier to reason about.
const DECT_ACTIONS = new Set([
'add-bases',
'add-handset',
'remove-bases',
'remove-handsets',
'refresh',
'confirm-remove-bases',
'confirm-remove-handsets',
'cancel-remove',
]);
const OFFBOARD_ACTIONS = new Set(['confirm_offboard', 'cancel_offboard']);
const HOST_ASSIGN_ACTIONS = new Set(['confirm_host_assign', 'cancel_host_assign']);
const IGMP_FIX_ACTIONS = new Set(['confirm_igmp_fix', 'cancel_igmp_fix']);
// Best-effort delete of the adaptive-card message that fired this action.
// Removing the card prevents users from clicking Confirm/Cancel a second time
// (which would otherwise just be silently no-op'd by the pending-card map's
// .delete() one-shot guard, but would still look interactive in the UI).
//
// bot.censor() (see node_modules/webex-node-bot-framework/lib/bot.js:1368)
// requires the bot be the author of the message — which we always are for
// these cards — so this should never fail in practice. We still wrap in
// try/catch so a transient Webex API hiccup doesn't block the actual
// confirm/cancel operation that the user clicked.
async function censorActionCard(bot, trigger, scope) {
const messageId = trigger.attachmentAction?.messageId;
if (!messageId) return;
try {
await bot.censor(messageId);
logger(scope, `Removed card message ${messageId} after action`, 'debug');
} catch (err) {
logger(
scope,
`Could not remove card message ${messageId}: ${err.message}`,
'warn',
);
}
}
framework.on('attachmentAction', async (bot, trigger) => {
const action = trigger.attachmentAction;
if (!action || !action.inputs) {
logger('action', 'Received attachmentAction without inputs', 'debug');
return;
}
const actionType = action.inputs.action;
if (!actionType) {
logger('action', 'attachmentAction inputs missing `action` field', 'debug');
return;
}
// ── DECT provisioning ──
if (DECT_ACTIONS.has(actionType)) {
try {
await handleDectProvisionAction(bot, trigger);
} catch (err) {
logger('phone:provision', `Dect action error: ${err.message}`, 'error');
}
return;
}
// ── Offboard confirm / cancel ──
if (OFFBOARD_ACTIONS.has(actionType)) {
const { cardId } = action.inputs;
const roomId = trigger.roomId || action.roomId;
if (!cardId) {
logger('offboard:action', `Missing cardId on ${actionType} — ignoring`);
return;
}
if (!pendingOffboards.has(cardId)) {
logger('offboard:action', `Card ${cardId} is expired or unknown`);
return;
}
const offboardData = pendingOffboards.get(cardId);
pendingOffboards.delete(cardId); // one-shot
logger('offboard:action', `Received ${actionType} for card ${cardId} (user: ${offboardData.email})`);
// Remove the card so a second click can't re-fire (defence in depth
// alongside the pendingOffboards one-shot guard above).
await censorActionCard(bot, trigger, 'offboard:action');
// The clicker on an adaptive card is the requester for audit purposes —
// not necessarily the same person who originally posted /offboarduser.
// See utils/requester.js for why we read trigger.person, not the
// never-populated trigger.personEmail field.
const requester = extractRequester(trigger);
try {
if (actionType === 'confirm_offboard') {
await applyOffboardConfirmation(bot, offboardData, roomId, requester);
} else {
await cancelOffboardCard(bot, offboardData, roomId, requester);
}
} catch (err) {
logger(
'offboard:action',
`Error processing ${actionType} for ${offboardData.email}: ${err.message}`,
'error',
);
await bot.say('markdown', `⚠️ Error during offboard processing: ${err.message}`);
}
return;
}
// ── Webex host license assign confirm / cancel ──
if (HOST_ASSIGN_ACTIONS.has(actionType)) {
const { cardId } = action.inputs;
const roomId = trigger.roomId || action.roomId;
if (!cardId) {
logger('webexhost:action', `Missing cardId on ${actionType} — ignoring`);
return;
}
if (!pendingHostAssigns.has(cardId)) {
logger('webexhost:action', `Card ${cardId} is expired or unknown`);
return;
}
const hostData = pendingHostAssigns.get(cardId);
pendingHostAssigns.delete(cardId); // one-shot
logger(
'webexhost:action',
`Received ${actionType} for card ${cardId} (user: ${hostData.email}, license: ${hostData.licenseName})`,
);
// Remove the card so a second click can't re-fire (defence in depth
// alongside the pendingHostAssigns one-shot guard above).
await censorActionCard(bot, trigger, 'webexhost:action');
const requester = extractRequester(trigger);
try {
if (actionType === 'confirm_host_assign') {
await applyHostAssignConfirmation(bot, hostData, roomId, requester);
} else {
await cancelHostAssignCard(bot, hostData, roomId, requester);
}
} catch (err) {
logger(
'webexhost:action',
`Error processing ${actionType} for ${hostData.email}: ${err.message}`,
'error',
);
// bot is already room-scoped — do not pass roomId as a 3rd positional arg.
await bot.say('markdown', `⚠️ Error during host assignment: ${err.message}`);
}
return;
}
// ── IGMP-snooping / DECT-safe multicast confirm / cancel ──
// Mirrors the HOST_ASSIGN branch above: look up the pending card,
// one-shot delete, censor the card in the room, then dispatch to
// the domain function with the pending payload + resolved requester.
if (IGMP_FIX_ACTIONS.has(actionType)) {
const { cardId } = action.inputs;
const roomId = trigger.roomId || action.roomId;
if (!cardId) {
logger('igmp:action', `Missing cardId on ${actionType} — ignoring`);
return;
}
if (!pendingIgmpFixes.has(cardId)) {
logger('igmp:action', `Card ${cardId} is expired or unknown`);
return;
}
const igmpData = pendingIgmpFixes.get(cardId);
pendingIgmpFixes.delete(cardId); // one-shot
logger(
'igmp:action',
`Received ${actionType} for card ${cardId} (network: ${igmpData.networkName}, store: ${igmpData.storeNum})`,
);
await censorActionCard(bot, trigger, 'igmp:action');
const requester = extractRequester(trigger);
try {
if (actionType === 'confirm_igmp_fix') {
await applyDectSafeMulticast(bot, igmpData, roomId, requester);
} else {
await cancelIgmpFixCard(bot, igmpData, roomId, requester);
}
} catch (err) {
logger(
'igmp:action',
`Error processing ${actionType} for network ${igmpData.networkName}: ${err.message}`,
'error',
);
// bot is already room-scoped — do not pass roomId as a 3rd positional arg.
await bot.say('markdown', `⚠️ Error during multicast update: ${err.message}`);
}
return;
}
logger('action', `Ignoring unhandled attachmentAction: ${actionType}`, 'debug');
});
framework.on("initialized", () => {
logger('framework', 'Webex Framework is all fired up! [Press CTRL-C to quit]');
});
// ──────────────────────────────────────────────
// Command Handler (registry-driven)
// ──────────────────────────────────────────────
// Strips bot mentions, picks off the first token as the command name, and
// dispatches through commands/registry.js. Unknown commands fall through to
// handleUnknown (which replies with the help text).
const BOT_MENTION_PATTERNS = [
/^CollabSupport\s+/i,
/^@CollabSupport\s+/i,
/^aeCollabSupport@webex\.bot\s+/i,
];
framework.hears(/.*/, async (bot, trigger) => {
let rawText = trigger.text?.trim() || '';
for (const pattern of BOT_MENTION_PATTERNS) {
rawText = rawText.replace(pattern, '').trim();
}
logger('command-parser', `Cleaned input: ${rawText}`, 'debug');
const parts = rawText.split(/\s+/);
const name = parts[0]?.toLowerCase().replace(/^\//, '') || '';
const args = parts.slice(1);
trigger.args = args;
logger('command-parser', `Detected command: ${name} | Args: ${args.join(', ') || 'none'}`, 'debug');
const cmd = getCommand(name);
try {
if (cmd) {
await cmd.handler(bot, trigger);
} else {
await handleUnknown(bot, trigger);
}
} catch (err) {
logger('command', `Error executing ${name}: ${err.message}`, 'error');
await bot.say('markdown', `Error executing command: ${err.message}`);
}
}, null, 1);
app.listen(PORT, () => {
logger('server', `🚀 Express server running on port ${PORT}`);
console.log(`🚀 HTTP API server listening on http://localhost:${PORT}`);
const tokenConfigured = !!process.env.HTTP_API_TOKEN;
const lockEverything = requireAuthForAllCommands();
if (tokenConfigured) {
logger(
'server',
lockEverything
? 'HTTP API auth: ALL endpoints require HTTP_API_TOKEN (HTTP_API_REQUIRE_AUTH=true)'
: `HTTP API auth: token required for mutating commands (${[...MUTATING_COMMAND_KEYS].sort().join(', ')})`
);
} else {
logger(
'server',
'HTTP_API_TOKEN is not set — mutating /:command endpoints will refuse all requests with 503. ' +
'Set HTTP_API_TOKEN to enable them.',
'warn'
);
}
});
// ──────────────────────────────────────────────
// Cron Jobs
// ──────────────────────────────────────────────
import cron from 'node-cron';
import { pollNewTickets } from './services/jiraPollerService.js';
cron.schedule('15 0,8,16 * * *', async () => {
logger('cron', 'Starting scheduled cache refresh');
try {
await Promise.allSettled([
refreshMerakiNetworksCache(),
refreshAtlasDevicesCache(),
]);
logger('cron', 'Cache refresh completed');
} catch (err) {
logger('cron', `Cache refresh error: ${err.message}`, 'error');
}
});
// Hourly Jira poller — see services/jiraPollerService.js. Gated on
// JIRA_POLLER_ROOM_ID so an unconfigured bot doesn't schedule dead work
// (the poller needs a room to post its "N new tickets" summary to).
// The single source of truth for "already seen" is a Jira label, not
// local state, so the cron is safe to fire even across bot restarts /
// concurrent instances.
if (process.env.JIRA_POLLER_ROOM_ID) {
cron.schedule('0 * * * *', async () => {
logger('cron', 'Starting hourly Jira poll');
try {
await pollNewTickets();
} catch (err) {
logger('jira:poller', `Unhandled poll error: ${err.message}`, 'error');
}
});
logger('startup', `Jira poller scheduled — hourly on the top of the hour → room ${process.env.JIRA_POLLER_ROOM_ID.slice(0, 8)}...`);
} else {
logger('startup', 'Jira poller disabled — set JIRA_POLLER_ROOM_ID to enable', 'warn');
}
// Initial warm-up
(async () => {
logger('startup', 'Warming up caches...');
await Promise.allSettled([
refreshMerakiNetworksCache(),
refreshAtlasDevicesCache(),
]);
logger('startup', 'Initial cache warm-up done');
// One-shot prime pass: flip JIRA_POLLER_PRIME_ON_START=true for a
// single deploy to bulk-label everything currently in the queue
// WITHOUT enriching or notifying (avoids a giant day-one spam). Flip
// back to false after the run. Requires the poller to be enabled.
if (process.env.JIRA_POLLER_ROOM_ID && process.env.JIRA_POLLER_PRIME_ON_START === 'true') {
logger('startup', 'JIRA_POLLER_PRIME_ON_START=true — running one-shot prime pass');
try {
const result = await pollNewTickets({ prime: true });
logger('startup', `Prime pass complete: primed=${result.primed ?? 0}, skipped=${result.skipped}. Remove JIRA_POLLER_PRIME_ON_START from .env before next restart.`);
} catch (err) {
logger('startup', `Prime pass failed: ${err.message}`, 'error');
}
}
})();
// ──────────────────────────────────────────────
// Graceful Shutdown & Process-level Error Handling
// ──────────────────────────────────────────────
// Single graceful-stop path so SIGINT/SIGTERM/uncaughtException all converge
// to the same behavior: stop the Webex framework, then exit. We intentionally
// exit after uncaughtException/unhandledRejection — Node's docs warn that
// continuing after an uncaught exception leaves the process in an undefined
// state, and Docker's `restart: unless-stopped` will recover us cleanly.
let isShuttingDown = false;
async function shutdown(reason, exitCode) {
if (isShuttingDown) return;
isShuttingDown = true;
logger('shutdown', `Shutting down — ${reason}`);
try {
await framework.stop();
logger('shutdown', 'Webex Framework stopped');
} catch (err) {
logger('shutdown', `Error during framework.stop(): ${err.message}`, 'error');
} finally {
process.exit(exitCode);
}
}
process.on('SIGINT', () => shutdown('SIGINT', 0));
process.on('SIGTERM', () => shutdown('SIGTERM', 0));
// nodemon's default restart signal is SIGUSR2. Without an explicit handler,
// Node terminates immediately and framework.stop() never runs — leaving the
// WDM device registration alive on Cisco's side. Each leaked registration
// counts toward the per-bot 100-device cap and the bot eventually 403s on
// startup ("Forbidden: User has excessive device registrations"). Handling
// SIGUSR2 here gives framework.stop() a chance to call
// webex.internal.device.unregister() before exit. We also ship a
// nodemon.json that prefers SIGTERM, but this remains a defensive backstop.
process.on('SIGUSR2', () => shutdown('SIGUSR2', 0));
process.on('uncaughtException', (err) => {
logger('uncaught', `Uncaught exception: ${err.message}\n${err.stack}`, 'error');
// Exit fast — Docker will restart us. Continuing risks operating on torn-down state.
shutdown('uncaughtException', 1);
});
process.on('unhandledRejection', (reason) => {
const msg = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason);
logger('uncaught', `Unhandled promise rejection: ${msg}`, 'error');
shutdown('unhandledRejection', 1);
});