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