diff --git a/.env.example b/.env.example
index 72654cc..c4cd0df 100644
--- a/.env.example
+++ b/.env.example
@@ -20,5 +20,21 @@ WEBEX_ROOM_ID=
APPROVAL_ROOM_ID=
SUPPORT_ROOM_ID=
+# Webex inbound webhooks (POST /webex/messages only — Jira webhooks remain unsigned)
+WEBEX_INBOUND_ENABLED=false
+WEBEX_INBOUND_DRY_RUN=true
+WEBEX_WEBHOOK_SECRET=
+WEBEX_WEBHOOK_TARGET_URL=https://bot.joesjavajoint.com/jiracloud/webex/messages
+
+# Webex OAuth Integration (for room message visibility + webhook registration)
+WEBEX_INTEGRATION_CLIENT_ID=
+WEBEX_INTEGRATION_CLIENT_SECRET=
+WEBEX_INTEGRATION_REDIRECT_URI=https://bot.joesjavajoint.com/jiracloud/webex/oauth/callback
+WEBEX_INTEGRATION_SCOPES=spark:messages_read spark:webhooks_read spark:webhooks_write
+# WEBEX_OAUTH_TOKEN_FILE=./config/webex-oauth.json
+
+# Optional: skip messages from the bot itself (loop prevention)
+WEBEX_BOT_PERSON_ID=
+
# Only set if you have a specific TLS/certificate issue to work around
# NODE_TLS_REJECT_UNAUTHORIZED=0
diff --git a/.gitignore b/.gitignore
index e26abd3..ef02aaa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ logs/
# Local secrets — use config.example.json + .env instead
config/config.json
config/config.local.json
+config/webex-oauth.json
diff --git a/app.js b/app.js
index a3e377b..eea2877 100644
--- a/app.js
+++ b/app.js
@@ -3,6 +3,11 @@ import bodyParser from 'body-parser';
export function createApp() {
const app = express();
- app.use(bodyParser.json({ limit: '200mb' }));
+ app.use(bodyParser.json({
+ limit: '200mb',
+ verify: (req, res, buf) => {
+ req.rawBody = buf;
+ },
+ }));
return app;
}
diff --git a/config/webexRooms.json b/config/webexRooms.json
new file mode 100644
index 0000000..d432340
--- /dev/null
+++ b/config/webexRooms.json
@@ -0,0 +1,26 @@
+{
+ "dcOpsJobs": {
+ "roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vNjY2ZDQwNzAtZTcyZC0xMWU3LWE1ZDAtYjk1NTA1MDlkZWI5",
+ "enabled": true,
+ "jobNamePattern": "\\b[A-Z][A-Z0-9]{4,}\\d\\b",
+ "jiraDefaults": {
+ "serviceDeskId": "171",
+ "requestTypeId": "289",
+ "portalGroupId": "159",
+ "environment": "PROD"
+ },
+ "intents": {
+ "rerun": {
+ "keywords": ["rerun"],
+ "summaryTemplate": "Rerun job {{jobName}}",
+ "descriptionTemplate": "Requested via Webex by {{personEmail}}:\n\n{{originalText}}"
+ },
+ "force_ok": {
+ "keywords": ["force ok", "force okay", "forceok"],
+ "summaryTemplate": "Force OK job {{jobName}}",
+ "descriptionTemplate": "Requested via Webex by {{personEmail}}:\n\n{{originalText}}",
+ "urgency": "High"
+ }
+ }
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 8254a5d..487f19e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -20,6 +20,8 @@ services:
- ./logs:/usr/src/app/logs
- ./config/config.json:/usr/src/app/config/config.json:ro
- ./config/requests.json:/usr/src/app/config/requests.json:ro
+ - ./config/webexRooms.json:/usr/src/app/config/webexRooms.json:ro
+ - ./config/webex-oauth.json:/usr/src/app/config/webex-oauth.json
restart: unless-stopped
healthcheck:
test:
diff --git a/index.js b/index.js
index 0175b1b..21576b6 100644
--- a/index.js
+++ b/index.js
@@ -1,5 +1,5 @@
import fs from 'fs';
-import { loadConfig, loadRequests, getMissingSecrets, describeJiraIdentity } from './lib/config.js';
+import { loadConfig, loadRequests, loadWebexRooms, getMissingSecrets, describeJiraIdentity } from './lib/config.js';
import { createLogger } from './lib/logger.js';
import { createJiraClient } from './services/jiraClient.js';
import { createJiraService } from './services/jira.js';
@@ -7,11 +7,19 @@ import { createWebexService } from './services/webex.js';
import { createJiraProcessor } from './services/jiraProcessor.js';
import { createPendingApprovalsJob, cleanupOldLogs } from './jobs/pendingApprovals.js';
import { scheduleJobs } from './jobs/cron.js';
+import { createJiraTicketsService } from './services/jiraTickets.js';
+import { createWebexOAuthService } from './services/webexOAuth.js';
+import { createWebexWebhookManager } from './services/webexWebhookManager.js';
import { createWebhookRoutes } from './routes/webhooks.js';
+import { createWebexWebhookRoutes } from './routes/webexWebhooks.js';
+import { createWebexOAuthRoutes } from './routes/webexOAuth.js';
import { createApp } from './app.js';
+const OAUTH_START_URL = 'https://bot.joesjavajoint.com/jiracloud/webex/oauth/start';
+
const config = loadConfig();
const requests = loadRequests();
+const webexRooms = loadWebexRooms();
const log = createLogger(config);
if (!fs.existsSync('./logs')) {
@@ -26,22 +34,93 @@ if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
log.writeLog('warn', 'startup', 'NODE_TLS_REJECT_UNAUTHORIZED=0 is set — TLS certificate verification is disabled');
}
log.logger('startup', `Jira identity: ${describeJiraIdentity(config)}`);
+if (config.webex.inboundEnabled) {
+ log.logger(
+ 'startup',
+ `Webex inbound enabled (dryRun=${config.webex.inboundDryRun}, rooms=${Object.keys(webexRooms).length})`
+ );
+ if (!config.webex.webhookSecret) {
+ log.writeLog('warn', 'startup', 'WEBEX_WEBHOOK_SECRET is not set — inbound Webex webhooks will be rejected');
+ }
+ if (!config.webex.integration?.clientId || !config.webex.integration?.clientSecret) {
+ log.writeLog('warn', 'startup', 'WEBEX_INTEGRATION_CLIENT_ID/SECRET not set — OAuth flow unavailable');
+ }
+}
const jiraClient = createJiraClient(config);
const jiraService = createJiraService(jiraClient, log);
+const jiraTicketsService = createJiraTicketsService(config, log);
const webexService = createWebexService(config, log);
+const webexOAuth = createWebexOAuthService(config, log);
+const webexWebhookManager = createWebexWebhookManager({ webexOAuth, log });
const jiraProcessor = createJiraProcessor({ config, requests, jiraService, webexService, log });
const pendingApprovalsJob = createPendingApprovalsJob({ jiraService, webexService, log });
scheduleJobs({
runPendingApprovals: pendingApprovalsJob.runPendingApprovals,
cleanupOldLogs: () => cleanupOldLogs(log),
+ runWebexOAuthMaintenance: async () => {
+ if (!config.webex.inboundEnabled || !webexOAuth.isAuthenticated()) {
+ return;
+ }
+ await webexOAuth.refreshIfNeeded();
+ const accessToken = await webexOAuth.getAccessToken();
+ if (accessToken) {
+ await webexWebhookManager.ensureWebhooks({ accessToken, webexRooms, config });
+ }
+ },
log,
});
+async function initializeWebexInbound() {
+ if (!config.webex.inboundEnabled) {
+ return;
+ }
+
+ if (!webexOAuth.isAuthenticated()) {
+ log.writeLog('warn', 'startup', `Webex integration not authenticated — visit ${OAUTH_START_URL}`);
+ return;
+ }
+
+ try {
+ await webexOAuth.refreshIfNeeded();
+ const accessToken = await webexOAuth.getAccessToken();
+ if (!accessToken) {
+ log.writeLog('warn', 'startup', `Webex integration token unavailable — visit ${OAUTH_START_URL}`);
+ return;
+ }
+ const results = await webexWebhookManager.ensureWebhooks({ accessToken, webexRooms, config });
+ log.logger('startup', `Webex webhooks ensured for ${results.length} room(s)`);
+ } catch (error) {
+ log.logError('startup', 'Failed to initialize Webex inbound OAuth/webhooks', error);
+ }
+}
+
const app = createApp();
const webhookRoutes = createWebhookRoutes({ config, jiraProcessor, webexService, log });
+const webexWebhookRoutes = createWebexWebhookRoutes({
+ config,
+ webexRooms,
+ jiraTicketsService,
+ webexService,
+ webexOAuth,
+ log,
+});
+const webexOAuthRoutes = createWebexOAuthRoutes({
+ config,
+ webexOAuth,
+ webexWebhookManager,
+ webexRooms,
+ log,
+});
webhookRoutes.registerRoutes(app);
+webexWebhookRoutes.registerRoutes(app);
+webexOAuthRoutes.registerRoutes(app);
+webhookRoutes.registerErrorHandler(app);
+
+initializeWebexInbound().catch(error => {
+ log.logError('startup', 'Webex inbound initialization failed', error);
+});
const server = app.listen(config.server.port, () => {
log.logger('startup', `${config.server.name} running on port ${config.server.port}.`);
diff --git a/jobs/cron.js b/jobs/cron.js
index 0013d3b..7213edd 100644
--- a/jobs/cron.js
+++ b/jobs/cron.js
@@ -1,7 +1,7 @@
import cron from 'node-cron';
import { createCorrelationId, runWithCorrelationId } from '../lib/correlation.js';
-export function scheduleJobs({ runPendingApprovals, cleanupOldLogs, log }) {
+export function scheduleJobs({ runPendingApprovals, cleanupOldLogs, runWebexOAuthMaintenance, log }) {
cron.schedule('0 30 9 * * *', async function () {
const correlationId = createCorrelationId({ routeName: 'cron' });
await runWithCorrelationId(correlationId, async () => {
@@ -15,4 +15,19 @@ export function scheduleJobs({ runPendingApprovals, cleanupOldLogs, log }) {
}
});
});
+
+ if (runWebexOAuthMaintenance) {
+ cron.schedule('0 0 8 * * *', async function () {
+ const correlationId = createCorrelationId({ routeName: 'cron-webex-oauth' });
+ await runWithCorrelationId(correlationId, async () => {
+ try {
+ log.logger('cron', 'Starting Webex OAuth maintenance');
+ await runWebexOAuthMaintenance();
+ log.logger('cron', 'Webex OAuth maintenance completed');
+ } catch (error) {
+ log.logError('cron', 'Webex OAuth maintenance failed', error);
+ }
+ });
+ });
+ }
}
diff --git a/lib/config.js b/lib/config.js
index c0d46b5..45cf654 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -11,6 +11,14 @@ function envOrConfig(envKey, configValue) {
return configValue;
}
+function envBool(envKey, defaultValue = false) {
+ const envValue = process.env[envKey];
+ if (envValue === undefined || envValue === '') {
+ return defaultValue;
+ }
+ return envValue === 'true' || envValue === '1';
+}
+
function resolveJiraHost(fileConfig) {
const cloudId = envOrConfig('JIRA_CLOUD_ID', fileConfig.jiraCloud?.cloudId)?.trim();
let baseUrl = envOrConfig('JIRA_BASE_URL', fileConfig.jiraCloud?.host || 'https://aeo.atlassian.net')?.trim();
@@ -36,6 +44,30 @@ export function loadConfig() {
webex: {
...fileConfig.webex,
token: envOrConfig('WEBEX_TOKEN', fileConfig.webex?.token),
+ webhookSecret: envOrConfig('WEBEX_WEBHOOK_SECRET', fileConfig.webex?.webhookSecret),
+ webhookTargetUrl: envOrConfig(
+ 'WEBEX_WEBHOOK_TARGET_URL',
+ fileConfig.webex?.webhookTargetUrl || 'https://bot.joesjavajoint.com/jiracloud/webex/messages'
+ ),
+ inboundEnabled: envBool('WEBEX_INBOUND_ENABLED', false),
+ inboundDryRun: envBool('WEBEX_INBOUND_DRY_RUN', true),
+ botPersonId: envOrConfig('WEBEX_BOT_PERSON_ID', fileConfig.webex?.botPersonId),
+ integration: {
+ clientId: envOrConfig('WEBEX_INTEGRATION_CLIENT_ID', fileConfig.webex?.integration?.clientId),
+ clientSecret: envOrConfig('WEBEX_INTEGRATION_CLIENT_SECRET', fileConfig.webex?.integration?.clientSecret),
+ redirectUri: envOrConfig(
+ 'WEBEX_INTEGRATION_REDIRECT_URI',
+ fileConfig.webex?.integration?.redirectUri || 'https://bot.joesjavajoint.com/jiracloud/webex/oauth/callback'
+ ),
+ scopes: envOrConfig(
+ 'WEBEX_INTEGRATION_SCOPES',
+ fileConfig.webex?.integration?.scopes || 'spark:messages_read spark:webhooks_read spark:webhooks_write'
+ ),
+ tokenFile: envOrConfig(
+ 'WEBEX_OAUTH_TOKEN_FILE',
+ fileConfig.webex?.integration?.tokenFile || './config/webex-oauth.json'
+ ),
+ },
room: {
...fileConfig.webex?.room,
id: envOrConfig('WEBEX_ROOM_ID', fileConfig.webex?.room?.id),
@@ -73,6 +105,28 @@ export function loadRequests() {
return JSON.parse(fs.readFileSync('./config/requests.json'));
}
+export function loadWebexRooms() {
+ const roomsPath = './config/webexRooms.json';
+ if (!fs.existsSync(roomsPath)) {
+ return {};
+ }
+ return JSON.parse(fs.readFileSync(roomsPath));
+}
+
+export function findWebexRoomById(webexRooms, roomId) {
+ if (!roomId || !webexRooms) {
+ return null;
+ }
+
+ for (const [configKey, room] of Object.entries(webexRooms)) {
+ if (room.roomId === roomId && room.enabled !== false) {
+ return { configKey, ...room };
+ }
+ }
+
+ return null;
+}
+
export function getMissingSecrets(config) {
const missing = [];
if (!config.jiraCloud?.email) missing.push('JIRA_EMAIL');
diff --git a/package.json b/package.json
index 46dd281..efbd251 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"main": "index.js",
"scripts": {
"start": "node index.js",
- "test": "echo \"Error: no test specified\" && exit 1"
+ "test": "node --test test/*.test.js"
},
"type": "module",
"author": "",
diff --git a/routes/webexOAuth.js b/routes/webexOAuth.js
new file mode 100644
index 0000000..482783e
--- /dev/null
+++ b/routes/webexOAuth.js
@@ -0,0 +1,139 @@
+const pendingStates = new Map();
+const STATE_TTL_MS = 10 * 60 * 1000;
+
+function cleanupStates() {
+ const now = Date.now();
+ for (const [state, createdAt] of pendingStates.entries()) {
+ if (now - createdAt > STATE_TTL_MS) {
+ pendingStates.delete(state);
+ }
+ }
+}
+
+function renderHtml(title, body) {
+ return `
+
+
${title}
+
+ ${title}
+ ${body}
+
+`;
+}
+
+export function createWebexOAuthRoutes({ config, webexOAuth, webexWebhookManager, webexRooms, log }) {
+ const { logger, logError } = log;
+ const oauthStartUrl = config.webex.integration.redirectUri.replace('/oauth/callback', '/oauth/start');
+
+ function registerRoutes(app) {
+ app.get('/webex/oauth/start', (req, res) => {
+ if (!config.webex.integration.clientId || !config.webex.integration.clientSecret) {
+ res.status(503).send(renderHtml(
+ 'Webex OAuth Not Configured',
+ 'Set WEBEX_INTEGRATION_CLIENT_ID and WEBEX_INTEGRATION_CLIENT_SECRET in .env.
'
+ ));
+ return;
+ }
+
+ cleanupStates();
+ const state = webexOAuth.createOAuthState();
+ pendingStates.set(state, Date.now());
+ res.redirect(webexOAuth.buildAuthorizeUrl(state));
+ });
+
+ app.get('/webex/oauth/callback', async (req, res) => {
+ const { code, state, error, error_description: errorDescription } = req.query;
+
+ if (error) {
+ res.status(400).send(renderHtml(
+ 'Webex OAuth Failed',
+ `${errorDescription || error}
`
+ ));
+ return;
+ }
+
+ cleanupStates();
+ if (!state || !pendingStates.has(state)) {
+ res.status(400).send(renderHtml(
+ 'Webex OAuth Failed',
+ 'Invalid or expired OAuth state. Try again.
'
+ ));
+ return;
+ }
+
+ pendingStates.delete(state);
+
+ if (!code) {
+ res.status(400).send(renderHtml(
+ 'Webex OAuth Failed',
+ 'Missing authorization code.
'
+ ));
+ return;
+ }
+
+ try {
+ await webexOAuth.exchangeCode(code);
+ const accessToken = await webexOAuth.getAccessToken();
+ const webhookResults = await webexWebhookManager.ensureWebhooks({
+ accessToken,
+ webexRooms,
+ config,
+ });
+
+ logger('webexOAuth', `OAuth complete; webhooks ensured for ${webhookResults.length} room(s)`);
+
+ const webhookList = webhookResults.map(result =>
+ `${result.configKey}: ${result.action} (${result.webhookId})`
+ ).join('');
+
+ res.status(200).send(renderHtml(
+ 'Webex OAuth Complete',
+ `Authentication succeeded and webhooks were registered.
+
+ View status
`
+ ));
+ } catch (err) {
+ logError('webexOAuth', 'OAuth callback failed', err);
+ res.status(500).send(renderHtml(
+ 'Webex OAuth Failed',
+ `${err.message}
Try again
`
+ ));
+ }
+ });
+
+ app.get('/webex/oauth/status', (req, res) => {
+ const status = webexOAuth.getStatus();
+ const wantsJson = req.accepts(['json', 'html']) === 'json' || req.query.format === 'json';
+
+ if (wantsJson) {
+ res.json({
+ ...status,
+ oauthStartUrl,
+ inboundEnabled: config.webex.inboundEnabled,
+ inboundDryRun: config.webex.inboundDryRun,
+ webhookTargetUrl: config.webex.webhookTargetUrl,
+ });
+ return;
+ }
+
+ const webhookItems = Object.entries(status.webhookIds)
+ .map(([roomId, webhookId]) => `${roomId}: ${webhookId}`)
+ .join('') || 'None registered yet';
+
+ res.status(200).send(renderHtml(
+ 'Webex OAuth Status',
+ `Authenticated: ${status.authenticated}
+ Access token expires: ${status.expiresAt || 'n/a'}
+ Refresh token expires: ${status.refreshExpiresAt || 'n/a'}
+ Inbound enabled: ${config.webex.inboundEnabled}
+ Dry run: ${config.webex.inboundDryRun}
+ Webhook target: ${config.webex.webhookTargetUrl}
+ Registered webhooks
+
+ Re-authenticate
`
+ ));
+ });
+ }
+
+ return { registerRoutes };
+}
diff --git a/routes/webexWebhooks.js b/routes/webexWebhooks.js
new file mode 100644
index 0000000..4fe51ce
--- /dev/null
+++ b/routes/webexWebhooks.js
@@ -0,0 +1,244 @@
+import { findWebexRoomById } from '../lib/config.js';
+import { buildTicketFields, parseJobMessage } from '../services/jobMessageParser.js';
+import {
+ createMessageDeduper,
+ extractMessageFromWebhook,
+ fetchWebexMessage,
+ verifyWebhookSignature,
+} from '../services/webexInbound.js';
+
+const messageDeduper = createMessageDeduper();
+
+function buildAuditRecord(fields) {
+ return {
+ timestamp: new Date().toISOString(),
+ ...fields,
+ };
+}
+
+export function createWebexWebhookRoutes({
+ config,
+ webexRooms,
+ jiraTicketsService,
+ webexService,
+ webexOAuth,
+ log,
+}) {
+ const { logger, logWarn, logError, safeProcess, logFile } = log;
+
+ function isBotMessage(message, botPersonId) {
+ if (!botPersonId) {
+ return false;
+ }
+ return message.personId === botPersonId;
+ }
+
+ async function processInboundMessage(messageMeta) {
+ const {
+ messageId,
+ roomId,
+ personId,
+ personEmail,
+ text,
+ } = messageMeta;
+
+ const correlationMeta = { routeName: 'webex-messages', messageId, roomId };
+ const roomConfig = findWebexRoomById(webexRooms, roomId);
+
+ if (!roomConfig) {
+ const record = buildAuditRecord({
+ outcome: 'skip',
+ skipReason: 'unconfigured_room',
+ roomId,
+ messageId,
+ personEmail,
+ });
+ logFile('webex', record);
+ logger('webex-messages', `Skip unconfigured room ${roomId}`);
+ return;
+ }
+
+ if (messageDeduper.has(messageId)) {
+ logWarn('webex-messages', `Skip duplicate messageId=${messageId}`);
+ return;
+ }
+
+ messageDeduper.add(messageId);
+
+ if (isBotMessage({ personId }, config.webex.botPersonId)) {
+ const record = buildAuditRecord({
+ outcome: 'skip',
+ skipReason: 'bot_message',
+ roomId,
+ messageId,
+ personEmail,
+ });
+ logFile('webex', record);
+ logger('webex-messages', `Skip bot message ${messageId}`);
+ return;
+ }
+
+ const parseResult = parseJobMessage({ text, roomConfig });
+
+ if (parseResult.outcome === 'skip') {
+ const record = buildAuditRecord({
+ outcome: 'skip',
+ skipReason: parseResult.skipReason,
+ roomId,
+ messageId,
+ personEmail,
+ intent: parseResult.intent,
+ originalText: parseResult.originalText,
+ });
+ logFile('webex', record);
+ logger(
+ 'webex-messages',
+ `Skip messageId=${messageId} reason=${parseResult.skipReason}${parseResult.intent ? ` intent=${parseResult.intent}` : ''}`
+ );
+ return;
+ }
+
+ const proposedPayload = buildTicketFields({
+ roomConfig,
+ parseResult,
+ personEmail,
+ });
+
+ if (config.webex.inboundDryRun) {
+ const record = buildAuditRecord({
+ outcome: 'would_create',
+ skipReason: null,
+ roomId,
+ messageId,
+ personEmail,
+ intent: parseResult.intent,
+ jobName: parseResult.jobName,
+ proposedPayload,
+ });
+ logFile('webex', record);
+ logger(
+ 'webex-messages',
+ `Dry-run would_create intent=${parseResult.intent} job=${parseResult.jobName} room=${roomId}`
+ );
+ return;
+ }
+
+ try {
+ const created = await jiraTicketsService.createJobRequest(proposedPayload);
+ const issueKey = created.issueKey || created.key;
+
+ const record = buildAuditRecord({
+ outcome: 'created',
+ roomId,
+ messageId,
+ personEmail,
+ intent: parseResult.intent,
+ jobName: parseResult.jobName,
+ issueKey,
+ proposedPayload,
+ });
+ logFile('webex', record);
+ logger('webex-messages', `Created ${issueKey} for job ${parseResult.jobName}`);
+
+ if (issueKey) {
+ const siteUrl = config.jiraCloud.siteUrl || 'https://aeo.atlassian.net';
+ await webexService.replyToMessage({
+ roomId,
+ parentId: messageId,
+ markdown: `Created [${issueKey}](${siteUrl}/browse/${issueKey}): ${proposedPayload.requestFieldValues.summary}`,
+ });
+ }
+ } catch (error) {
+ logError('webex-messages', `Failed to create ticket for messageId=${messageId}`, error);
+ const record = buildAuditRecord({
+ outcome: 'error',
+ roomId,
+ messageId,
+ personEmail,
+ intent: parseResult.intent,
+ jobName: parseResult.jobName,
+ proposedPayload,
+ error: error.message,
+ });
+ logFile('webex', record);
+ }
+ }
+
+ function handleWebexWebhook(req, res) {
+ if (!config.webex.inboundEnabled) {
+ res.status(503).json({ error: 'Webex inbound processing is disabled' });
+ return;
+ }
+
+ const rawBody = req.rawBody;
+ const signature = req.get('x-spark-signature');
+
+ if (!config.webex.webhookSecret) {
+ logWarn('webex-messages', 'WEBEX_WEBHOOK_SECRET is not set — rejecting webhook');
+ res.status(401).json({ error: 'Webhook signature validation is not configured' });
+ return;
+ }
+
+ if (!verifyWebhookSignature(rawBody, signature, config.webex.webhookSecret)) {
+ logWarn('webex-messages', 'Invalid Webex webhook signature');
+ res.status(401).json({ error: 'Invalid signature' });
+ return;
+ }
+
+ res.status(200).send();
+
+ const webhookMeta = extractMessageFromWebhook(req.body);
+ if (!webhookMeta.supported) {
+ logWarn('webex-messages', `Unsupported webhook event: ${webhookMeta.reason}`);
+ return;
+ }
+
+ const correlationMeta = {
+ routeName: 'webex-messages',
+ messageId: webhookMeta.messageId,
+ roomId: webhookMeta.roomId,
+ };
+
+ safeProcess('webex-messages', async () => {
+ let message = webhookMeta;
+
+ const accessToken = await webexOAuth.getAccessToken();
+ if (!accessToken) {
+ const record = buildAuditRecord({
+ outcome: 'skip',
+ skipReason: 'integration_not_authenticated',
+ roomId: message.roomId,
+ messageId: message.messageId,
+ personEmail: message.personEmail,
+ });
+ logFile('webex', record);
+ logWarn('webex-messages', 'Integration not authenticated — visit /webex/oauth/start');
+ return;
+ }
+
+ if (!message.text && message.messageId) {
+ try {
+ const fullMessage = await fetchWebexMessage(accessToken, message.messageId);
+ message = {
+ ...message,
+ text: fullMessage.text,
+ personEmail: fullMessage.personEmail || message.personEmail,
+ personId: fullMessage.personId || message.personId,
+ roomId: fullMessage.roomId || message.roomId,
+ };
+ } catch (error) {
+ logError('webex-messages', `Failed to fetch message ${message.messageId}`, error);
+ return;
+ }
+ }
+
+ await processInboundMessage(message);
+ }, correlationMeta);
+ }
+
+ function registerRoutes(app) {
+ app.post('/webex/messages', handleWebexWebhook);
+ }
+
+ return { registerRoutes, processInboundMessage };
+}
diff --git a/routes/webhooks.js b/routes/webhooks.js
index 8458377..cf8c26f 100644
--- a/routes/webhooks.js
+++ b/routes/webhooks.js
@@ -59,7 +59,9 @@ export function createWebhookRoutes({ config, jiraProcessor, webexService, log }
webexService.processHighSeverity(req.body, config.rooms.support);
}, correlationMeta);
});
+ }
+ function registerErrorHandler(app) {
app.use((err, req, res, next) => {
log.logError('express', `${req.method} ${req.path}`, err);
if (!res.headersSent) {
@@ -68,5 +70,5 @@ export function createWebhookRoutes({ config, jiraProcessor, webexService, log }
});
}
- return { registerRoutes };
+ return { registerRoutes, registerErrorHandler };
}
diff --git a/scripts/discover-jsm-fields.js b/scripts/discover-jsm-fields.js
new file mode 100644
index 0000000..4a8f176
--- /dev/null
+++ b/scripts/discover-jsm-fields.js
@@ -0,0 +1,70 @@
+// Dump JSM request-type field definitions for validation and config review.
+//
+// Usage:
+// JIRA_SERVICE_DESK_ID=171 node scripts/discover-jsm-fields.js 289
+// node scripts/discover-jsm-fields.js 289 270
+
+import dotenv from 'dotenv';
+import fetch from 'node-fetch';
+import fs from 'fs/promises';
+import path from 'path';
+
+dotenv.config();
+
+const cloudId = process.env.JIRA_CLOUD_ID?.trim();
+const baseUrl = cloudId
+ ? `https://api.atlassian.com/ex/jira/${cloudId}`
+ : (process.env.JIRA_BASE_URL || 'https://aeo.atlassian.net').replace(/\/$/, '');
+const serviceDeskId = process.env.JIRA_SERVICE_DESK_ID || '171';
+const email = process.env.JIRA_EMAIL;
+const token = process.env.JIRA_API_TOKEN;
+
+if (!email || !token) {
+ console.error('JIRA_EMAIL and JIRA_API_TOKEN must be set in .env');
+ process.exit(1);
+}
+
+const requestTypeIds = process.argv.slice(2).map(Number).filter(Boolean);
+if (requestTypeIds.length === 0) {
+ console.error('Usage: node scripts/discover-jsm-fields.js [requestTypeId...]');
+ process.exit(1);
+}
+
+const headers = {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`,
+};
+
+async function getFields(requestTypeId) {
+ console.log(`Fetching fields for service desk ${serviceDeskId}, request type ${requestTypeId}...`);
+ const url = `${baseUrl}/rest/servicedeskapi/servicedesk/${serviceDeskId}/requesttype/${requestTypeId}/field`;
+ const response = await fetch(url, { headers });
+ const data = await response.json();
+
+ if (!response.ok) {
+ console.error(` error (${response.status}):`, data);
+ return null;
+ }
+
+ const outputPath = path.join('config', `jsm-fields-${requestTypeId}.json`);
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
+ await fs.writeFile(outputPath, JSON.stringify(data, null, 2));
+ console.log(` saved ${outputPath}`);
+
+ const required = (data.requestTypeFields || []).filter(field => field.required);
+ if (required.length > 0) {
+ console.log(' required fields:');
+ for (const field of required) {
+ console.log(` - ${field.fieldId} (${field.name})`);
+ }
+ }
+
+ return data;
+}
+
+for (const requestTypeId of requestTypeIds) {
+ await getFields(requestTypeId);
+}
+
+console.log('\nDone.');
diff --git a/services/jiraTickets.js b/services/jiraTickets.js
new file mode 100644
index 0000000..ac8419c
--- /dev/null
+++ b/services/jiraTickets.js
@@ -0,0 +1,61 @@
+import fetch from 'node-fetch';
+
+function buildAuthHeader(config) {
+ const jira = config.jiraCloud;
+
+ if (jira.authType === 'bearer') {
+ return `Bearer ${jira.token}`;
+ }
+
+ return `Basic ${Buffer.from(`${jira.email}:${jira.token}`).toString('base64')}`;
+}
+
+export function createJiraTicketsService(config, log) {
+ const { logError } = log;
+
+ async function createJsmRequest(payload) {
+ const response = await fetch(`${config.jiraCloud.host}/rest/servicedeskapi/request`, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ Authorization: buildAuthHeader(config),
+ },
+ body: JSON.stringify(payload),
+ });
+
+ const responseText = await response.text();
+ let data;
+
+ try {
+ data = responseText ? JSON.parse(responseText) : {};
+ } catch {
+ data = { raw: responseText };
+ }
+
+ if (!response.ok) {
+ const message = data.errorMessage || data.message || responseText || 'Unknown JSM error';
+ const error = new Error(message);
+ error.status = response.status;
+ error.details = data;
+ throw error;
+ }
+
+ return data;
+ }
+
+ async function createJobRequest(payload) {
+ try {
+ const data = await createJsmRequest(payload);
+ return data;
+ } catch (error) {
+ logError('jiraTickets', 'Failed to create JSM request', error);
+ throw error;
+ }
+ }
+
+ return {
+ createJsmRequest,
+ createJobRequest,
+ };
+}
diff --git a/services/jobMessageParser.js b/services/jobMessageParser.js
new file mode 100644
index 0000000..9a4ffe4
--- /dev/null
+++ b/services/jobMessageParser.js
@@ -0,0 +1,145 @@
+function escapeRegex(value) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function buildKeywordPattern(keywords) {
+ const sorted = [...keywords].sort((a, b) => b.length - a.length);
+ const parts = sorted.map(keyword => escapeRegex(keyword).replace(/\s+/g, '\\s+'));
+ return new RegExp(`(?:${parts.join('|')})`, 'i');
+}
+
+function findIntentMatch(textLower, intents) {
+ let bestMatch = null;
+
+ for (const [intentKey, intentConfig] of Object.entries(intents || {})) {
+ const keywords = intentConfig?.keywords || [];
+ if (keywords.length === 0) {
+ continue;
+ }
+
+ const pattern = buildKeywordPattern(keywords);
+ const match = textLower.match(pattern);
+ if (!match) {
+ continue;
+ }
+
+ const matchIndex = match.index ?? -1;
+ if (!bestMatch || matchIndex < bestMatch.matchIndex) {
+ bestMatch = {
+ intentKey,
+ intentConfig,
+ matchIndex,
+ matchedKeyword: match[0],
+ };
+ }
+ }
+
+ return bestMatch;
+}
+
+function extractJobNames(text, jobNamePattern) {
+ const pattern = new RegExp(jobNamePattern, 'g');
+ const matches = [];
+ let match;
+
+ while ((match = pattern.exec(text)) !== null) {
+ matches.push({
+ jobName: match[0],
+ index: match.index,
+ });
+ }
+
+ return matches;
+}
+
+function pickJobName(jobMatches, intentMatchIndex) {
+ if (jobMatches.length === 0) {
+ return null;
+ }
+
+ if (jobMatches.length === 1) {
+ return jobMatches[0].jobName;
+ }
+
+ let closest = jobMatches[0];
+ let closestDistance = Math.abs(jobMatches[0].index - intentMatchIndex);
+
+ for (const candidate of jobMatches.slice(1)) {
+ const distance = Math.abs(candidate.index - intentMatchIndex);
+ if (distance < closestDistance) {
+ closest = candidate;
+ closestDistance = distance;
+ }
+ }
+
+ return closest.jobName;
+}
+
+function applyTemplate(template, values) {
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => values[key] ?? '');
+}
+
+export function parseJobMessage({ text, roomConfig }) {
+ const originalText = (text || '').trim();
+
+ if (!originalText) {
+ return { outcome: 'skip', skipReason: 'empty_message' };
+ }
+
+ const textLower = originalText.toLowerCase();
+ const intentMatch = findIntentMatch(textLower, roomConfig?.intents);
+
+ if (!intentMatch) {
+ return { outcome: 'skip', skipReason: 'no_intent', originalText };
+ }
+
+ const jobMatches = extractJobNames(originalText, roomConfig.jobNamePattern);
+ const jobName = pickJobName(jobMatches, intentMatch.matchIndex);
+
+ if (!jobName) {
+ return {
+ outcome: 'skip',
+ skipReason: 'no_job_name',
+ intent: intentMatch.intentKey,
+ originalText,
+ };
+ }
+
+ return {
+ outcome: 'match',
+ intent: intentMatch.intentKey,
+ intentConfig: intentMatch.intentConfig,
+ jobName,
+ originalText,
+ matchedKeyword: intentMatch.matchedKeyword,
+ };
+}
+
+export function buildTicketFields({ roomConfig, parseResult, personEmail }) {
+ const { intent, intentConfig, jobName, originalText } = parseResult;
+ const jiraDefaults = roomConfig.jiraDefaults || {};
+
+ const summary = applyTemplate(intentConfig.summaryTemplate, { jobName, personEmail, originalText });
+ const description = applyTemplate(intentConfig.descriptionTemplate, {
+ jobName,
+ personEmail: personEmail || 'unknown',
+ originalText,
+ });
+
+ const requestFieldValues = {
+ summary,
+ description,
+ customfield_10231: [{ value: jiraDefaults.environment || 'PROD' }],
+ };
+
+ if (intentConfig.urgency) {
+ requestFieldValues.customfield_10264 = { value: intentConfig.urgency };
+ }
+
+ return {
+ serviceDeskId: String(jiraDefaults.serviceDeskId),
+ requestTypeId: String(jiraDefaults.requestTypeId),
+ requestFieldValues,
+ raiseOnBehalfOf: personEmail || undefined,
+ };
+}
diff --git a/services/webex.js b/services/webex.js
index 263f0be..4264876 100644
--- a/services/webex.js
+++ b/services/webex.js
@@ -74,6 +74,38 @@ export function createWebexService(config, log) {
.catch(error => logError('sendHighPriorityMessage', 'Network error sending to Webex', error));
}
+ function replyToMessage({ roomId, parentId, markdown }) {
+ return sendWebexMessage({
+ roomId,
+ parentId,
+ markdown,
+ });
+ }
+
+ async function getMessage(messageId) {
+ const response = await fetch(`https://webexapis.com/v1/messages/${encodeURIComponent(messageId)}`, {
+ headers: {
+ Authorization: `Bearer ${config.webex.token}`,
+ Accept: 'application/json',
+ },
+ });
+
+ const responseText = await response.text();
+ let data;
+
+ try {
+ data = responseText ? JSON.parse(responseText) : {};
+ } catch {
+ throw new Error(`Invalid JSON response from Webex: ${responseText}`);
+ }
+
+ if (!response.ok) {
+ throw new Error(data.message || `Webex API error (HTTP ${response.status}): ${responseText}`);
+ }
+
+ return data;
+ }
+
function processHighSeverity(jiraTicket, roomId) {
const jiraKey = jiraTicket.issue.key;
@@ -116,6 +148,8 @@ export function createWebexService(config, log) {
sendTicketCard,
sendWebexMessage,
sendHighPriorityMessage,
+ replyToMessage,
+ getMessage,
processHighSeverity,
};
}
diff --git a/services/webexInbound.js b/services/webexInbound.js
new file mode 100644
index 0000000..76e09b7
--- /dev/null
+++ b/services/webexInbound.js
@@ -0,0 +1,100 @@
+import crypto from 'crypto';
+import fetch from 'node-fetch';
+
+const DEDUPE_TTL_MS = 24 * 60 * 60 * 1000;
+
+export function verifyWebhookSignature(rawBody, signatureHeader, secret) {
+ if (!secret) {
+ return false;
+ }
+
+ if (!signatureHeader || !rawBody) {
+ return false;
+ }
+
+ const expected = crypto.createHmac('sha1', secret).update(rawBody).digest('hex');
+
+ try {
+ const expectedBuffer = Buffer.from(expected, 'utf8');
+ const actualBuffer = Buffer.from(signatureHeader, 'utf8');
+
+ if (expectedBuffer.length !== actualBuffer.length) {
+ return false;
+ }
+
+ return crypto.timingSafeEqual(expectedBuffer, actualBuffer);
+ } catch {
+ return false;
+ }
+}
+
+export function createMessageDeduper() {
+ const seen = new Map();
+
+ function cleanup() {
+ const now = Date.now();
+ for (const [messageId, expiresAt] of seen.entries()) {
+ if (expiresAt <= now) {
+ seen.delete(messageId);
+ }
+ }
+ }
+
+ function has(messageId) {
+ cleanup();
+ return seen.has(messageId);
+ }
+
+ function add(messageId) {
+ cleanup();
+ seen.set(messageId, Date.now() + DEDUPE_TTL_MS);
+ }
+
+ return { has, add };
+}
+
+export async function fetchWebexMessage(token, messageId) {
+ const response = await fetch(`https://webexapis.com/v1/messages/${encodeURIComponent(messageId)}`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: 'application/json',
+ },
+ });
+
+ const responseText = await response.text();
+ let data;
+
+ try {
+ data = responseText ? JSON.parse(responseText) : {};
+ } catch {
+ throw new Error(`Invalid JSON from Webex messages API: ${responseText}`);
+ }
+
+ if (!response.ok) {
+ const error = new Error(data.message || responseText || `Webex API error (${response.status})`);
+ error.status = response.status;
+ throw error;
+ }
+
+ return data;
+}
+
+export function extractMessageFromWebhook(body) {
+ const resource = body?.resource;
+ const event = body?.event;
+ const data = body?.data || {};
+
+ if (resource !== 'messages' || event !== 'created') {
+ return { supported: false, reason: 'unsupported_event' };
+ }
+
+ return {
+ supported: true,
+ messageId: data.id,
+ roomId: data.roomId,
+ personId: data.personId,
+ personEmail: data.personEmail,
+ text: data.text,
+ parentId: data.parentId,
+ };
+}
diff --git a/services/webexOAuth.js b/services/webexOAuth.js
new file mode 100644
index 0000000..87df72c
--- /dev/null
+++ b/services/webexOAuth.js
@@ -0,0 +1,206 @@
+import crypto from 'crypto';
+import fs from 'fs';
+import path from 'path';
+import fetch from 'node-fetch';
+
+const TOKEN_URL = 'https://webexapis.com/v1/access_token';
+const AUTHORIZE_URL = 'https://webexapis.com/v1/authorize';
+const REFRESH_BUFFER_MS = 5 * 60 * 1000;
+
+export function isTokenExpiringSoon(expiresAt, bufferMs = REFRESH_BUFFER_MS) {
+ if (!expiresAt) {
+ return true;
+ }
+ return Date.parse(expiresAt) <= Date.now() + bufferMs;
+}
+
+function defaultTokenData() {
+ return {
+ accessToken: null,
+ refreshToken: null,
+ expiresAt: null,
+ refreshExpiresAt: null,
+ personEmail: null,
+ webhookIds: {},
+ };
+}
+
+function computeExpiry(expiresInSeconds) {
+ if (!expiresInSeconds) {
+ return null;
+ }
+ return new Date(Date.now() + Number(expiresInSeconds) * 1000).toISOString();
+}
+
+export function createWebexOAuthService(config, log) {
+ const integration = config.webex.integration;
+ const tokenFile = integration.tokenFile;
+ let tokenData = loadTokenData();
+
+ function loadTokenData() {
+ try {
+ if (!fs.existsSync(tokenFile)) {
+ return defaultTokenData();
+ }
+ const parsed = JSON.parse(fs.readFileSync(tokenFile, 'utf8'));
+ return { ...defaultTokenData(), ...parsed };
+ } catch (error) {
+ log.logError('webexOAuth', `Failed to load token file ${tokenFile}`, error);
+ return defaultTokenData();
+ }
+ }
+
+ function saveTokenData() {
+ const dir = path.dirname(tokenFile);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ fs.writeFileSync(tokenFile, JSON.stringify(tokenData, null, 2));
+ }
+
+ function applyTokenResponse(responseData) {
+ tokenData = {
+ ...tokenData,
+ accessToken: responseData.access_token,
+ refreshToken: responseData.refresh_token || tokenData.refreshToken,
+ expiresAt: computeExpiry(responseData.expires_in),
+ refreshExpiresAt: computeExpiry(responseData.refresh_token_expires_in) || tokenData.refreshExpiresAt,
+ };
+ saveTokenData();
+ return tokenData;
+ }
+
+ async function postTokenRequest(body) {
+ const response = await fetch(TOKEN_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams(body).toString(),
+ });
+
+ const responseText = await response.text();
+ let data;
+
+ try {
+ data = responseText ? JSON.parse(responseText) : {};
+ } catch {
+ throw new Error(`Invalid token response: ${responseText}`);
+ }
+
+ if (!response.ok) {
+ const error = new Error(data.message || data.error_description || responseText || 'Token request failed');
+ error.status = response.status;
+ error.details = data;
+ throw error;
+ }
+
+ return data;
+ }
+
+ function buildAuthorizeUrl(state) {
+ const params = new URLSearchParams({
+ client_id: integration.clientId,
+ response_type: 'code',
+ redirect_uri: integration.redirectUri,
+ scope: integration.scopes,
+ state,
+ });
+ return `${AUTHORIZE_URL}?${params.toString()}`;
+ }
+
+ function createOAuthState() {
+ return crypto.randomBytes(24).toString('hex');
+ }
+
+ async function exchangeCode(code) {
+ const data = await postTokenRequest({
+ grant_type: 'authorization_code',
+ client_id: integration.clientId,
+ client_secret: integration.clientSecret,
+ code,
+ redirect_uri: integration.redirectUri,
+ });
+ applyTokenResponse(data);
+ log.logger('webexOAuth', 'OAuth tokens stored after authorization');
+ return tokenData;
+ }
+
+ async function refreshIfNeeded() {
+ if (!tokenData.refreshToken) {
+ return false;
+ }
+
+ if (!isTokenExpiringSoon(tokenData.expiresAt)) {
+ return false;
+ }
+
+ const data = await postTokenRequest({
+ grant_type: 'refresh_token',
+ client_id: integration.clientId,
+ client_secret: integration.clientSecret,
+ refresh_token: tokenData.refreshToken,
+ });
+ applyTokenResponse(data);
+ log.logger('webexOAuth', 'OAuth access token refreshed');
+ return true;
+ }
+
+ async function getAccessToken() {
+ if (!tokenData.accessToken && !tokenData.refreshToken) {
+ return null;
+ }
+
+ await refreshIfNeeded();
+
+ if (!tokenData.accessToken) {
+ return null;
+ }
+
+ return tokenData.accessToken;
+ }
+
+ function isAuthenticated() {
+ return Boolean(tokenData.refreshToken || tokenData.accessToken);
+ }
+
+ function getStatus() {
+ return {
+ authenticated: isAuthenticated(),
+ expiresAt: tokenData.expiresAt,
+ refreshExpiresAt: tokenData.refreshExpiresAt,
+ personEmail: tokenData.personEmail || null,
+ webhookIds: { ...tokenData.webhookIds },
+ tokenFile,
+ };
+ }
+
+ function setPersonEmail(email) {
+ tokenData.personEmail = email;
+ saveTokenData();
+ }
+
+ function setWebhookId(roomId, webhookId) {
+ tokenData.webhookIds = {
+ ...tokenData.webhookIds,
+ [roomId]: webhookId,
+ };
+ saveTokenData();
+ }
+
+ function getWebhookIds() {
+ return { ...tokenData.webhookIds };
+ }
+
+ return {
+ buildAuthorizeUrl,
+ createOAuthState,
+ exchangeCode,
+ refreshIfNeeded,
+ getAccessToken,
+ isAuthenticated,
+ getStatus,
+ setPersonEmail,
+ setWebhookId,
+ getWebhookIds,
+ loadTokenData,
+ };
+}
diff --git a/services/webexWebhookManager.js b/services/webexWebhookManager.js
new file mode 100644
index 0000000..5f17cdb
--- /dev/null
+++ b/services/webexWebhookManager.js
@@ -0,0 +1,153 @@
+import fetch from 'node-fetch';
+
+const WEBHOOKS_URL = 'https://webexapis.com/v1/webhooks';
+
+export function findWebhookForRoom(webhooks, { targetUrl, roomId }) {
+ const filter = `roomId=${roomId}`;
+ return (webhooks || []).find(webhook =>
+ webhook.targetUrl === targetUrl
+ && webhook.resource === 'messages'
+ && webhook.event === 'created'
+ && webhook.filter === filter
+ ) || null;
+}
+
+export function createWebexWebhookManager({ webexOAuth, log }) {
+ async function listWebhooks(accessToken) {
+ const response = await fetch(WEBHOOKS_URL, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ Accept: 'application/json',
+ },
+ });
+
+ const responseText = await response.text();
+ let data;
+
+ try {
+ data = responseText ? JSON.parse(responseText) : {};
+ } catch {
+ throw new Error(`Invalid webhooks list response: ${responseText}`);
+ }
+
+ if (!response.ok) {
+ const error = new Error(data.message || responseText || 'Failed to list webhooks');
+ error.status = response.status;
+ throw error;
+ }
+
+ return data.items || [];
+ }
+
+ async function createWebhook(accessToken, payload) {
+ const response = await fetch(WEBHOOKS_URL, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(payload),
+ });
+
+ const responseText = await response.text();
+ let data;
+
+ try {
+ data = responseText ? JSON.parse(responseText) : {};
+ } catch {
+ throw new Error(`Invalid webhook create response: ${responseText}`);
+ }
+
+ if (!response.ok) {
+ const error = new Error(data.message || responseText || 'Failed to create webhook');
+ error.status = response.status;
+ error.details = data;
+ throw error;
+ }
+
+ return data;
+ }
+
+ async function updateWebhook(accessToken, webhookId, payload) {
+ const response = await fetch(`${WEBHOOKS_URL}/${encodeURIComponent(webhookId)}`, {
+ method: 'PUT',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(payload),
+ });
+
+ const responseText = await response.text();
+ let data;
+
+ try {
+ data = responseText ? JSON.parse(responseText) : {};
+ } catch {
+ throw new Error(`Invalid webhook update response: ${responseText}`);
+ }
+
+ if (!response.ok) {
+ const error = new Error(data.message || responseText || 'Failed to update webhook');
+ error.status = response.status;
+ error.details = data;
+ throw error;
+ }
+
+ return data;
+ }
+
+ async function ensureWebhooks({ accessToken, webexRooms, config }) {
+ if (!accessToken) {
+ throw new Error('Integration access token is required to manage webhooks');
+ }
+
+ const targetUrl = config.webex.webhookTargetUrl;
+ const secret = config.webex.webhookSecret;
+ const existingWebhooks = await listWebhooks(accessToken);
+ const results = [];
+
+ for (const [configKey, room] of Object.entries(webexRooms || {})) {
+ if (room.enabled === false || !room.roomId) {
+ continue;
+ }
+
+ const filter = `roomId=${room.roomId}`;
+ const payload = {
+ name: `jiracloud-${configKey}`,
+ targetUrl,
+ resource: 'messages',
+ event: 'created',
+ filter,
+ secret,
+ };
+
+ let webhook = findWebhookForRoom(existingWebhooks, { targetUrl, roomId: room.roomId });
+
+ if (!webhook) {
+ webhook = await createWebhook(accessToken, payload);
+ log.logger('webexWebhookManager', `Created webhook ${webhook.id} for room ${configKey}`);
+ results.push({ configKey, roomId: room.roomId, action: 'created', webhookId: webhook.id });
+ } else if (webhook.status !== 'active') {
+ webhook = await updateWebhook(accessToken, webhook.id, { status: 'active' });
+ log.logger('webexWebhookManager', `Reactivated webhook ${webhook.id} for room ${configKey}`);
+ results.push({ configKey, roomId: room.roomId, action: 'reactivated', webhookId: webhook.id });
+ } else {
+ log.logger('webexWebhookManager', `Webhook already active for room ${configKey} (${webhook.id})`);
+ results.push({ configKey, roomId: room.roomId, action: 'exists', webhookId: webhook.id });
+ }
+
+ webexOAuth.setWebhookId(room.roomId, webhook.id);
+ }
+
+ return results;
+ }
+
+ return {
+ ensureWebhooks,
+ findWebhookForRoom,
+ listWebhooks,
+ };
+}
diff --git a/test/jobMessageParser.test.js b/test/jobMessageParser.test.js
new file mode 100644
index 0000000..30d41d8
--- /dev/null
+++ b/test/jobMessageParser.test.js
@@ -0,0 +1,139 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { buildTicketFields, parseJobMessage } from '../services/jobMessageParser.js';
+
+const roomConfig = {
+ jobNamePattern: '\\b[A-Z][A-Z0-9]{4,}\\d\\b',
+ jiraDefaults: {
+ serviceDeskId: '171',
+ requestTypeId: '289',
+ environment: 'PROD',
+ },
+ intents: {
+ rerun: {
+ keywords: ['rerun'],
+ summaryTemplate: 'Rerun job {{jobName}}',
+ descriptionTemplate: 'Requested via Webex by {{personEmail}}:\n\n{{originalText}}',
+ },
+ force_ok: {
+ keywords: ['force ok', 'force okay', 'forceok'],
+ summaryTemplate: 'Force OK job {{jobName}}',
+ descriptionTemplate: 'Requested via Webex by {{personEmail}}:\n\n{{originalText}}',
+ urgency: 'High',
+ },
+ },
+};
+
+describe('parseJobMessage', () => {
+ it('matches rerun with job name', () => {
+ const result = parseJobMessage({
+ text: 'Please rerun this job ADWTFRCETRNS1',
+ roomConfig,
+ });
+
+ assert.equal(result.outcome, 'match');
+ assert.equal(result.intent, 'rerun');
+ assert.equal(result.jobName, 'ADWTFRCETRNS1');
+ });
+
+ it('matches casual rerun phrasing', () => {
+ const result = parseJobMessage({
+ text: 'Heya OPs can we rerun AMSSHIPDEX1, thanks',
+ roomConfig,
+ });
+
+ assert.equal(result.outcome, 'match');
+ assert.equal(result.intent, 'rerun');
+ assert.equal(result.jobName, 'AMSSHIPDEX1');
+ });
+
+ it('matches force ok intent', () => {
+ const result = parseJobMessage({
+ text: 'Please force okay the job AIFDB4APPRECYCLE1',
+ roomConfig,
+ });
+
+ assert.equal(result.outcome, 'match');
+ assert.equal(result.intent, 'force_ok');
+ assert.equal(result.jobName, 'AIFDB4APPRECYCLE1');
+ });
+
+ it('skips general chat without intent', () => {
+ const result = parseJobMessage({
+ text: 'Good morning team',
+ roomConfig,
+ });
+
+ assert.equal(result.outcome, 'skip');
+ assert.equal(result.skipReason, 'no_intent');
+ });
+
+ it('skips dashboard question without job name', () => {
+ const result = parseJobMessage({
+ text: 'Can someone look at the dashboard?',
+ roomConfig,
+ });
+
+ assert.equal(result.outcome, 'skip');
+ assert.equal(result.skipReason, 'no_intent');
+ });
+
+ it('skips intent-only messages', () => {
+ const result = parseJobMessage({
+ text: 'Please rerun this job soon',
+ roomConfig,
+ });
+
+ assert.equal(result.outcome, 'skip');
+ assert.equal(result.skipReason, 'no_job_name');
+ assert.equal(result.intent, 'rerun');
+ });
+
+ it('skips job-only messages', () => {
+ const result = parseJobMessage({
+ text: 'ADWTFRCETRNS1 failed overnight',
+ roomConfig,
+ });
+
+ assert.equal(result.outcome, 'skip');
+ assert.equal(result.skipReason, 'no_intent');
+ });
+});
+
+describe('buildTicketFields', () => {
+ it('builds JSM payload with PROD environment', () => {
+ const parseResult = parseJobMessage({
+ text: 'Please rerun this job ADWTFRCETRNS1',
+ roomConfig,
+ });
+
+ const payload = buildTicketFields({
+ roomConfig,
+ parseResult,
+ personEmail: 'user@ae.com',
+ });
+
+ assert.equal(payload.serviceDeskId, '171');
+ assert.equal(payload.requestTypeId, '289');
+ assert.equal(payload.requestFieldValues.summary, 'Rerun job ADWTFRCETRNS1');
+ assert.match(payload.requestFieldValues.description, /user@ae\.com/);
+ assert.deepEqual(payload.requestFieldValues.customfield_10231, [{ value: 'PROD' }]);
+ assert.equal(payload.raiseOnBehalfOf, 'user@ae.com');
+ });
+
+ it('includes urgency for force_ok', () => {
+ const parseResult = parseJobMessage({
+ text: 'Please force ok job AMSSHIPDEX1',
+ roomConfig,
+ });
+
+ const payload = buildTicketFields({
+ roomConfig,
+ parseResult,
+ personEmail: 'ops@ae.com',
+ });
+
+ assert.equal(payload.requestFieldValues.summary, 'Force OK job AMSSHIPDEX1');
+ assert.deepEqual(payload.requestFieldValues.customfield_10264, { value: 'High' });
+ });
+});
diff --git a/test/webexInbound.test.js b/test/webexInbound.test.js
new file mode 100644
index 0000000..5c830ea
--- /dev/null
+++ b/test/webexInbound.test.js
@@ -0,0 +1,24 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'crypto';
+import { verifyWebhookSignature } from '../services/webexInbound.js';
+
+describe('verifyWebhookSignature', () => {
+ it('accepts valid signatures', () => {
+ const secret = 'super-secret-webhook-key';
+ const rawBody = Buffer.from(JSON.stringify({ hello: 'world' }));
+ const signature = crypto.createHmac('sha1', secret).update(rawBody).digest('hex');
+
+ assert.equal(verifyWebhookSignature(rawBody, signature, secret), true);
+ });
+
+ it('rejects invalid signatures', () => {
+ const rawBody = Buffer.from(JSON.stringify({ hello: 'world' }));
+ assert.equal(verifyWebhookSignature(rawBody, 'bad-signature', 'secret'), false);
+ });
+
+ it('rejects when secret is missing', () => {
+ const rawBody = Buffer.from('{}');
+ assert.equal(verifyWebhookSignature(rawBody, 'abc', ''), false);
+ });
+});
diff --git a/test/webexOAuth.test.js b/test/webexOAuth.test.js
new file mode 100644
index 0000000..094feff
--- /dev/null
+++ b/test/webexOAuth.test.js
@@ -0,0 +1,54 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { isTokenExpiringSoon } from '../services/webexOAuth.js';
+import { findWebhookForRoom } from '../services/webexWebhookManager.js';
+
+describe('isTokenExpiringSoon', () => {
+ it('returns true when expiresAt is missing', () => {
+ assert.equal(isTokenExpiringSoon(null), true);
+ });
+
+ it('returns true when token expires within buffer', () => {
+ const expiresAt = new Date(Date.now() + 2 * 60 * 1000).toISOString();
+ assert.equal(isTokenExpiringSoon(expiresAt, 5 * 60 * 1000), true);
+ });
+
+ it('returns false when token is still valid beyond buffer', () => {
+ const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString();
+ assert.equal(isTokenExpiringSoon(expiresAt, 5 * 60 * 1000), false);
+ });
+});
+
+describe('findWebhookForRoom', () => {
+ const targetUrl = 'https://bot.joesjavajoint.com/jiracloud/webex/messages';
+ const roomId = 'room-123';
+
+ const webhooks = [
+ {
+ id: 'wh-1',
+ targetUrl,
+ resource: 'messages',
+ event: 'created',
+ filter: `roomId=${roomId}`,
+ status: 'active',
+ },
+ {
+ id: 'wh-2',
+ targetUrl: 'https://example.com/other',
+ resource: 'messages',
+ event: 'created',
+ filter: `roomId=${roomId}`,
+ status: 'active',
+ },
+ ];
+
+ it('finds matching webhook by targetUrl and room filter', () => {
+ const match = findWebhookForRoom(webhooks, { targetUrl, roomId });
+ assert.equal(match?.id, 'wh-1');
+ });
+
+ it('returns null when no webhook matches', () => {
+ const match = findWebhookForRoom(webhooks, { targetUrl, roomId: 'other-room' });
+ assert.equal(match, null);
+ });
+});