Enables outbound PSTN probes via TwiML webhooks with Webex result cards, status polling, and optional store CDR enrichment.
923 lines
No EOL
36 KiB
JavaScript
923 lines
No EOL
36 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
|
|
});
|
|
}
|
|
});
|
|
|
|
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);
|
|
}); |