From efc64a227c24bda54ec2669c884e36e01a0a822f Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Tue, 28 Jul 2026 08:27:52 -0400 Subject: [PATCH] Add Webex inbound OAuth flow and room-to-Jira job request pipeline. Introduces integration-based webhook registration, message parsing, dry-run monitoring, JSM ticket creation, and OAuth token refresh for DC Ops spaces. Co-authored-by: Cursor --- .env.example | 16 +++ .gitignore | 1 + app.js | 7 +- config/webexRooms.json | 26 ++++ docker-compose.yml | 2 + index.js | 81 ++++++++++- jobs/cron.js | 17 ++- lib/config.js | 54 +++++++ package.json | 2 +- routes/webexOAuth.js | 139 ++++++++++++++++++ routes/webexWebhooks.js | 244 ++++++++++++++++++++++++++++++++ routes/webhooks.js | 4 +- scripts/discover-jsm-fields.js | 70 +++++++++ services/jiraTickets.js | 61 ++++++++ services/jobMessageParser.js | 145 +++++++++++++++++++ services/webex.js | 34 +++++ services/webexInbound.js | 100 +++++++++++++ services/webexOAuth.js | 206 +++++++++++++++++++++++++++ services/webexWebhookManager.js | 153 ++++++++++++++++++++ test/jobMessageParser.test.js | 139 ++++++++++++++++++ test/webexInbound.test.js | 24 ++++ test/webexOAuth.test.js | 54 +++++++ 22 files changed, 1574 insertions(+), 5 deletions(-) create mode 100644 config/webexRooms.json create mode 100644 routes/webexOAuth.js create mode 100644 routes/webexWebhooks.js create mode 100644 scripts/discover-jsm-fields.js create mode 100644 services/jiraTickets.js create mode 100644 services/jobMessageParser.js create mode 100644 services/webexInbound.js create mode 100644 services/webexOAuth.js create mode 100644 services/webexWebhookManager.js create mode 100644 test/jobMessageParser.test.js create mode 100644 test/webexInbound.test.js create mode 100644 test/webexOAuth.test.js 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); + }); +});