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>
244 lines
8 KiB
JavaScript
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 };
|
|
}
|