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