jiraCloud/routes/webexWebhooks.js
jmcqueen efc64a227c 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 <cursoragent@cursor.com>
2026-07-28 08:27:52 -04:00

244 lines
8 KiB
JavaScript

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 };
}