// 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 { handleDectStatusAction, DECT_STATUS_CARD_ACTIONS, } from './commands/dectDiag.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 { applyVoiceDiagRemediation, cancelVoiceDiagRemediation, applyAllVoiceDiagRemediations, cancelAllVoiceDiagRemediations, } from './commands/voiceDiag.js'; import { pendingVoiceFixes } from './utils/pendingVoiceFixes.js'; import { applyResetVmPinConfirmation, cancelResetVmPinCard, } from './commands/resetVmPin.js'; import { pendingVmPinResets } from './utils/pendingVmPinResets.js'; import { applyResetVpPinConfirmation, cancelResetVpPinCard, } from './commands/resetVpPin.js'; import { pendingVpPinResets } from './utils/pendingVpPinResets.js'; import { extractRequester } from './utils/requester.js'; import { getDectRelayHub } from './services/dectRelayHub.js'; import { getCommand, ALL_HTTP_COMMAND_KEYS, MUTATING_COMMAND_KEYS, } from './commands/registry.js'; import twilioCallTestRouter from './routes/twilioCallTest.js'; import { isCallTestConfigured } from './services/callTest/callTestService.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(); }); // Twilio /calltest webhooks — after path-prefix strip, before /:command catch-all. app.use(twilioCallTestRouter); // 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 }); } }); import { captureDectRawXml } from './services/dectStatus/captureRawXml.js'; app.get('/api/dect/raw-xml/:storeNumber', dataAuthGate, async (req, res) => { const storeNumber = (req.params.storeNumber || '').trim(); const baseFilter = (req.query.base || req.query.ip || req.query.mac || '').trim(); try { const result = await captureDectRawXml(storeNumber, { baseFilter: baseFilter || undefined }); res.json(result); } catch (err) { const code = err?.code || 'CAPTURE_FAILED'; const status = code === 'INVALID_STORE' ? 400 : code === 'BASE_NOT_FOUND' ? 404 : code === 'RELAY_NOT_CONFIGURED' ? 503 : 500; logger('dect:capture', `raw-xml failed for store ${storeNumber}: ${err.message}`, 'error'); res.status(status).json({ success: false, code, message: err.message, knownBases: err.knownBases || undefined, }); } }); import { capturePhoneProbe, capturePhoneProbeDirect } from './services/phoneStatus/capturePhoneProbe.js'; app.get('/api/phone/probe/:storeNumber', dataAuthGate, async (req, res) => { const storeNumber = (req.params.storeNumber || '').trim(); const phoneFilter = (req.query.phone || req.query.ip || req.query.mac || '').trim(); try { const result = await capturePhoneProbe(storeNumber, { phoneFilter: phoneFilter || undefined }); res.json(result); } catch (err) { const code = err?.code || 'CAPTURE_FAILED'; const status = code === 'INVALID_STORE' ? 400 : code === 'PHONE_NOT_FOUND' ? 404 : code === 'RELAY_NOT_CONFIGURED' ? 503 : 500; logger('phone:capture', `probe failed for store ${storeNumber}: ${err.message}`, 'error'); res.status(status).json({ success: false, code, message: err.message, knownPhones: err.knownPhones || undefined, }); } }); app.get('/api/phone/probe-direct/:targetIp', dataAuthGate, async (req, res) => { const targetIp = (req.params.targetIp || '').trim(); try { const result = await capturePhoneProbeDirect(targetIp); res.json(result); } catch (err) { const code = err?.code || 'CAPTURE_FAILED'; const status = code === 'INVALID_IP' ? 400 : code === 'RELAY_NOT_CONFIGURED' ? 503 : 500; logger('phone:capture', `probe-direct failed for ${targetIp}: ${err.message}`, 'error'); res.status(status).json({ success: false, code, 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']); const VOICEDIAG_ACTIONS = new Set([ 'confirm_voicediag', 'cancel_voicediag', // "Apply all N fixes" combined-card variants — see // commands/voiceDiag.js#postCombinedRemediationCard. Same pending-map // + same dispatcher branch; the payload shape distinguishes the two // (single-remediation vs. `entries` array). 'confirm_voicediag_all', 'cancel_voicediag_all', ]); const RESET_VMPIN_ACTIONS = new Set(['confirm_reset_vmpin', 'cancel_reset_vmpin']); const RESET_VPPIN_ACTIONS = new Set(['confirm_reset_vppin', 'cancel_reset_vppin']); // 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; } // ── DECT status (reboot / factory-reset confirm cards) ── if (DECT_STATUS_CARD_ACTIONS.has(actionType)) { try { // Censor the card that was clicked so double-clicks can't re-fire. // Request cards don't use the pending map until confirm is posted, // so we always censor here for request + confirm/cancel alike. await censorActionCard(bot, trigger, 'dect:status:action'); await handleDectStatusAction(bot, trigger); } catch (err) { logger('dect:status', `Dect status action error: ${err.message}`, 'error'); try { await bot.say('markdown', `⚠️ Error handling DECT action: ${err.message}`); } catch { /* ignore secondary say failure */ } } 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; } // ── /voicediag confirm / cancel ── // Mirrors the IGMP branch above. Every /voicediag remediation card // shares the same two action types (`confirm_voicediag` / // `cancel_voicediag`) — the specific remediation to apply is // carried in the pending payload's `remediationId` so that adding // new remediations to a check module never requires touching this // dispatcher. if (VOICEDIAG_ACTIONS.has(actionType)) { const { cardId } = action.inputs; const roomId = trigger.roomId || action.roomId; if (!cardId) { logger('voicediag:action', `Missing cardId on ${actionType} — ignoring`); return; } if (!pendingVoiceFixes.has(cardId)) { logger('voicediag:action', `Card ${cardId} is expired or unknown`); return; } const voiceData = pendingVoiceFixes.get(cardId); pendingVoiceFixes.delete(cardId); // one-shot logger( 'voicediag:action', `Received ${actionType} for card ${cardId} ` + `(store: ${voiceData.storeNum}, ` + (voiceData.combined ? `combined N=${(voiceData.entries || []).length}` : `remediation: ${voiceData.remediationId}`) + `)`, ); await censorActionCard(bot, trigger, 'voicediag:action'); const requester = extractRequester(trigger); try { switch (actionType) { case 'confirm_voicediag': await applyVoiceDiagRemediation(bot, voiceData, roomId, requester); break; case 'cancel_voicediag': await cancelVoiceDiagRemediation(bot, voiceData, roomId, requester); break; case 'confirm_voicediag_all': await applyAllVoiceDiagRemediations(bot, voiceData, roomId, requester); break; case 'cancel_voicediag_all': await cancelAllVoiceDiagRemediations(bot, voiceData, roomId, requester); break; } } catch (err) { logger( 'voicediag:action', `Error processing ${actionType} for store ${voiceData.storeNum} ` + `remediation ${voiceData.remediationId || 'combined'}: ${err.message}`, 'error', ); await bot.say('markdown', `⚠️ Error during voice diagnostic remediation: ${err.message}`); } return; } // ── /resetvmpin confirm / cancel ── if (RESET_VMPIN_ACTIONS.has(actionType)) { const { cardId } = action.inputs; const roomId = trigger.roomId || action.roomId; if (!cardId) { logger('resetvmpin:action', `Missing cardId on ${actionType} — ignoring`); return; } if (!pendingVmPinResets.has(cardId)) { logger('resetvmpin:action', `Card ${cardId} is expired or unknown`); return; } const vmPinData = pendingVmPinResets.get(cardId); pendingVmPinResets.delete(cardId); logger( 'resetvmpin:action', `Received ${actionType} for card ${cardId} (user: ${vmPinData.email})`, ); await censorActionCard(bot, trigger, 'resetvmpin:action'); const requester = extractRequester(trigger); try { if (actionType === 'confirm_reset_vmpin') { await applyResetVmPinConfirmation(bot, vmPinData, roomId, requester); } else { await cancelResetVmPinCard(bot, vmPinData, roomId, requester); } } catch (err) { logger( 'resetvmpin:action', `Error processing ${actionType} for ${vmPinData.email}: ${err.message}`, 'error', ); await bot.say('markdown', `⚠️ Error during voicemail PIN reset: ${err.message}`); } return; } // ── /resetvppin confirm / cancel ── if (RESET_VPPIN_ACTIONS.has(actionType)) { const { cardId } = action.inputs; const roomId = trigger.roomId || action.roomId; if (!cardId) { logger('resetvppin:action', `Missing cardId on ${actionType} — ignoring`); return; } if (!pendingVpPinResets.has(cardId)) { logger('resetvppin:action', `Card ${cardId} is expired or unknown`); return; } const vpPinData = pendingVpPinResets.get(cardId); pendingVpPinResets.delete(cardId); logger( 'resetvppin:action', `Received ${actionType} for card ${cardId} (store: ${vpPinData.storeNum})`, ); await censorActionCard(bot, trigger, 'resetvppin:action'); const requester = extractRequester(trigger); try { if (actionType === 'confirm_reset_vppin') { await applyResetVpPinConfirmation(bot, vpPinData, roomId, requester); } else { await cancelResetVpPinCard(bot, vpPinData, roomId, requester); } } catch (err) { logger( 'resetvppin:action', `Error processing ${actionType} for store ${vpPinData.storeNum}: ${err.message}`, 'error', ); await bot.say('markdown', `⚠️ Error during voice portal PIN reset: ${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]'); // Patch @webex/plugin-messages so a 404 on Hydra messages.get during // mercury event enrichment cannot take down the process. See // hardenWebexMessagesPlugin() below. try { hardenWebexMessagesPlugin(framework.webex); } catch (err) { logger('webex:sdk', `Failed to harden messages plugin: ${err.message}`, 'warn'); } }); // ────────────────────────────────────────────── // Webex SDK hardening // ────────────────────────────────────────────── // // @webex/plugin-messages does this for every mercury activity: // // this.getMessageEvent(activity, type).then(this.fire(type)); // // with NO .catch(). getMessageEvent() calls Hydra messages.get(). When that // returns 404 (message already deleted, room the bot left, or an // eventual-consistency race after we post a summary via BotClient REST — // exactly the path the hourly Jira poller takes), the rejection becomes an // unhandledRejection and our process-level handler would shut the bot down. // // Reproduce from logs (2026-07-10 11:00): // [webex:bot] Sent markdown message to room aa9c1e50... // [uncaught] Unhandled promise rejection: Unable to get message. (NotFound) // [shutdown] Shutting down — unhandledRejection // // We re-bind onWebexApiEvent with an equivalent verb→type map and a .catch() // so enrichment failures are logged and dropped. The unhandledRejection // handler below also treats these as non-fatal as a belt-and-suspenders // guard for other plugins with the same pattern. /** * @param {unknown} reason * @returns {boolean} */ function isBenignWebexSdkRejection(reason) { if (!reason) return false; const name = reason.name || ''; const message = (reason.message != null ? String(reason.message) : String(reason)); const status = reason.statusCode ?? reason.body?.statusCode ?? reason.status; if (/unable to get message/i.test(message)) return true; if ((name === 'NotFound' || status === 404) && /not found|unable to get/i.test(message)) { return true; } return false; } /** * Replace the floating-promise onWebexApiEvent on the messages plugin. * @param {object|null|undefined} webex */ function hardenWebexMessagesPlugin(webex) { const plugin = webex?.messages; if (!plugin || plugin.__collabfinderHardened) return; if ( typeof plugin.onWebexApiEvent !== 'function' || typeof plugin.getMessageEvent !== 'function' || typeof plugin.fire !== 'function' ) { logger('webex:sdk', 'messages plugin missing expected methods — skip harden', 'warn'); return; } // Same mapping as @webex/plugin-messages verbToType (share/post → created, // delete → deleted). Keep in sync if the SDK adds verbs. const verbToType = { share: 'created', post: 'created', delete: 'deleted', }; const getMessageEvent = plugin.getMessageEvent.bind(plugin); const fire = plugin.fire.bind(plugin); plugin.onWebexApiEvent = function collabfinderOnWebexApiEvent(event) { const activity = event?.data?.activity; if (!activity) return; const type = verbToType[activity.verb]; if (!type) return; getMessageEvent(activity, type) .then(fire(type)) .catch((err) => { logger( 'webex:sdk', `Dropped message:${type} event (Hydra enrichment failed): ${err.message}`, 'warn', ); }); }; plugin.__collabfinderHardened = true; logger('webex:sdk', 'Hardened messages plugin event handler (404-safe)'); } // ────────────────────────────────────────────── // 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); // Capture the http.Server so we can attach the DECT relay WebSocket // upgrade handler to it after listen(). Express's app.listen() returns // the underlying Node http.Server — previously we discarded that // handle because HTTP-only routes don't need it. Adding WSS support // changes that: the ws package hooks into the raw 'upgrade' event on // the http.Server, so we need the reference here. const httpServer = 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' ); } }); // DECT relay hub — accepts ONE long-lived WSS connection from the // data-center relay agent. Gated on DECT_RELAY_AGENT_TOKEN being set: // if not configured, we log a warning and skip attaching so the bot // stays usable for everything else (Jira poller, Meraki, Webex admin // commands). See services/dectRelayHub.js for the wire protocol and // dect-relay-agent/README.md for how to run the DC-side process. if (process.env.DECT_RELAY_AGENT_TOKEN) { const relayHub = getDectRelayHub(); relayHub.attachTo(httpServer); logger('startup', `DECT relay hub listening for agent at ${process.env.DECT_RELAY_PATH || '/dect-relay/ws'}`); } else { logger( 'startup', 'DECT relay disabled — set DECT_RELAY_AGENT_TOKEN in .env (and share the same value with dect-relay-agent) to enable /phonediag, /dectdiag, and /voicediag relay probes', 'warn', ); } if (isCallTestConfigured()) { logger('startup', '/calltest enabled (Twilio voice webhooks active)'); } else { logger( 'startup', '/calltest disabled — set TWILIO_* env vars and CALLTEST_ENABLED=true to enable', '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'); } // Close the DECT relay hub so in-flight RPCs get rejected with // DISCONNECTED and the agent's socket gets a clean 1001 shutdown // frame (instead of a ripped-out TCP that would leave the agent // reconnecting in a loop until its keepalive times out). if (process.env.DECT_RELAY_AGENT_TOKEN) { try { await getDectRelayHub().close(); logger('shutdown', 'DECT relay hub closed'); } catch (err) { logger('shutdown', `Error during DECT relay hub close: ${err.message}`, 'warn'); } } 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); // Webex SDK / framework noise — log and keep running. A true app bug // still takes us down below. if (isBenignWebexSdkRejection(reason)) { logger( 'uncaught', `Ignoring benign Webex SDK rejection (bot stays up): ${reason?.message || reason}`, 'warn', ); return; } logger('uncaught', `Unhandled promise rejection: ${msg}`, 'error'); shutdown('unhandledRejection', 1); });