Surface handset registrations and RSSI, tighten reboot health to 7 days, and consolidate Base / Handsets & RF / Network & RTP sections. Co-authored-by: Cursor <cursoragent@cursor.com>
947 lines
No EOL
37 KiB
JavaScript
947 lines
No EOL
37 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 {
|
|
handleDectStatusAction,
|
|
DECT_STATUS_CARD_ACTIONS,
|
|
} from './commands/dectStatus.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 { 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,
|
|
});
|
|
}
|
|
});
|
|
|
|
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',
|
|
]);
|
|
|
|
// 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;
|
|
}
|
|
|
|
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 /phonestatus DECT follow-up + /dectstatus',
|
|
'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);
|
|
}); |