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>
This commit is contained in:
parent
449550cb54
commit
efc64a227c
22 changed files with 1574 additions and 5 deletions
16
.env.example
16
.env.example
|
|
@ -20,5 +20,21 @@ WEBEX_ROOM_ID=
|
||||||
APPROVAL_ROOM_ID=
|
APPROVAL_ROOM_ID=
|
||||||
SUPPORT_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
|
# Only set if you have a specific TLS/certificate issue to work around
|
||||||
# NODE_TLS_REJECT_UNAUTHORIZED=0
|
# NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@ logs/
|
||||||
# Local secrets — use config.example.json + .env instead
|
# Local secrets — use config.example.json + .env instead
|
||||||
config/config.json
|
config/config.json
|
||||||
config/config.local.json
|
config/config.local.json
|
||||||
|
config/webex-oauth.json
|
||||||
|
|
|
||||||
7
app.js
7
app.js
|
|
@ -3,6 +3,11 @@ import bodyParser from 'body-parser';
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(bodyParser.json({ limit: '200mb' }));
|
app.use(bodyParser.json({
|
||||||
|
limit: '200mb',
|
||||||
|
verify: (req, res, buf) => {
|
||||||
|
req.rawBody = buf;
|
||||||
|
},
|
||||||
|
}));
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
config/webexRooms.json
Normal file
26
config/webexRooms.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,8 @@ services:
|
||||||
- ./logs:/usr/src/app/logs
|
- ./logs:/usr/src/app/logs
|
||||||
- ./config/config.json:/usr/src/app/config/config.json:ro
|
- ./config/config.json:/usr/src/app/config/config.json:ro
|
||||||
- ./config/requests.json:/usr/src/app/config/requests.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
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
|
|
|
||||||
81
index.js
81
index.js
|
|
@ -1,5 +1,5 @@
|
||||||
import fs from 'fs';
|
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 { createLogger } from './lib/logger.js';
|
||||||
import { createJiraClient } from './services/jiraClient.js';
|
import { createJiraClient } from './services/jiraClient.js';
|
||||||
import { createJiraService } from './services/jira.js';
|
import { createJiraService } from './services/jira.js';
|
||||||
|
|
@ -7,11 +7,19 @@ import { createWebexService } from './services/webex.js';
|
||||||
import { createJiraProcessor } from './services/jiraProcessor.js';
|
import { createJiraProcessor } from './services/jiraProcessor.js';
|
||||||
import { createPendingApprovalsJob, cleanupOldLogs } from './jobs/pendingApprovals.js';
|
import { createPendingApprovalsJob, cleanupOldLogs } from './jobs/pendingApprovals.js';
|
||||||
import { scheduleJobs } from './jobs/cron.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 { createWebhookRoutes } from './routes/webhooks.js';
|
||||||
|
import { createWebexWebhookRoutes } from './routes/webexWebhooks.js';
|
||||||
|
import { createWebexOAuthRoutes } from './routes/webexOAuth.js';
|
||||||
import { createApp } from './app.js';
|
import { createApp } from './app.js';
|
||||||
|
|
||||||
|
const OAUTH_START_URL = 'https://bot.joesjavajoint.com/jiracloud/webex/oauth/start';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const requests = loadRequests();
|
const requests = loadRequests();
|
||||||
|
const webexRooms = loadWebexRooms();
|
||||||
const log = createLogger(config);
|
const log = createLogger(config);
|
||||||
|
|
||||||
if (!fs.existsSync('./logs')) {
|
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.writeLog('warn', 'startup', 'NODE_TLS_REJECT_UNAUTHORIZED=0 is set — TLS certificate verification is disabled');
|
||||||
}
|
}
|
||||||
log.logger('startup', `Jira identity: ${describeJiraIdentity(config)}`);
|
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 jiraClient = createJiraClient(config);
|
||||||
const jiraService = createJiraService(jiraClient, log);
|
const jiraService = createJiraService(jiraClient, log);
|
||||||
|
const jiraTicketsService = createJiraTicketsService(config, log);
|
||||||
const webexService = createWebexService(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 jiraProcessor = createJiraProcessor({ config, requests, jiraService, webexService, log });
|
||||||
const pendingApprovalsJob = createPendingApprovalsJob({ jiraService, webexService, log });
|
const pendingApprovalsJob = createPendingApprovalsJob({ jiraService, webexService, log });
|
||||||
|
|
||||||
scheduleJobs({
|
scheduleJobs({
|
||||||
runPendingApprovals: pendingApprovalsJob.runPendingApprovals,
|
runPendingApprovals: pendingApprovalsJob.runPendingApprovals,
|
||||||
cleanupOldLogs: () => cleanupOldLogs(log),
|
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,
|
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 app = createApp();
|
||||||
const webhookRoutes = createWebhookRoutes({ config, jiraProcessor, webexService, log });
|
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);
|
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, () => {
|
const server = app.listen(config.server.port, () => {
|
||||||
log.logger('startup', `${config.server.name} running on port ${config.server.port}.`);
|
log.logger('startup', `${config.server.name} running on port ${config.server.port}.`);
|
||||||
|
|
|
||||||
17
jobs/cron.js
17
jobs/cron.js
|
|
@ -1,7 +1,7 @@
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { createCorrelationId, runWithCorrelationId } from '../lib/correlation.js';
|
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 () {
|
cron.schedule('0 30 9 * * *', async function () {
|
||||||
const correlationId = createCorrelationId({ routeName: 'cron' });
|
const correlationId = createCorrelationId({ routeName: 'cron' });
|
||||||
await runWithCorrelationId(correlationId, async () => {
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,14 @@ function envOrConfig(envKey, configValue) {
|
||||||
return 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) {
|
function resolveJiraHost(fileConfig) {
|
||||||
const cloudId = envOrConfig('JIRA_CLOUD_ID', fileConfig.jiraCloud?.cloudId)?.trim();
|
const cloudId = envOrConfig('JIRA_CLOUD_ID', fileConfig.jiraCloud?.cloudId)?.trim();
|
||||||
let baseUrl = envOrConfig('JIRA_BASE_URL', fileConfig.jiraCloud?.host || 'https://aeo.atlassian.net')?.trim();
|
let baseUrl = envOrConfig('JIRA_BASE_URL', fileConfig.jiraCloud?.host || 'https://aeo.atlassian.net')?.trim();
|
||||||
|
|
@ -36,6 +44,30 @@ export function loadConfig() {
|
||||||
webex: {
|
webex: {
|
||||||
...fileConfig.webex,
|
...fileConfig.webex,
|
||||||
token: envOrConfig('WEBEX_TOKEN', fileConfig.webex?.token),
|
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: {
|
room: {
|
||||||
...fileConfig.webex?.room,
|
...fileConfig.webex?.room,
|
||||||
id: envOrConfig('WEBEX_ROOM_ID', fileConfig.webex?.room?.id),
|
id: envOrConfig('WEBEX_ROOM_ID', fileConfig.webex?.room?.id),
|
||||||
|
|
@ -73,6 +105,28 @@ export function loadRequests() {
|
||||||
return JSON.parse(fs.readFileSync('./config/requests.json'));
|
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) {
|
export function getMissingSecrets(config) {
|
||||||
const missing = [];
|
const missing = [];
|
||||||
if (!config.jiraCloud?.email) missing.push('JIRA_EMAIL');
|
if (!config.jiraCloud?.email) missing.push('JIRA_EMAIL');
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "node --test test/*.test.js"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|
|
||||||
139
routes/webexOAuth.js
Normal file
139
routes/webexOAuth.js
Normal file
|
|
@ -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 `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><title>${title}</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>${title}</h1>
|
||||||
|
${body}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
'<p>Set WEBEX_INTEGRATION_CLIENT_ID and WEBEX_INTEGRATION_CLIENT_SECRET in .env.</p>'
|
||||||
|
));
|
||||||
|
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',
|
||||||
|
`<p>${errorDescription || error}</p>`
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupStates();
|
||||||
|
if (!state || !pendingStates.has(state)) {
|
||||||
|
res.status(400).send(renderHtml(
|
||||||
|
'Webex OAuth Failed',
|
||||||
|
'<p>Invalid or expired OAuth state. <a href="/webex/oauth/start">Try again</a>.</p>'
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingStates.delete(state);
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
res.status(400).send(renderHtml(
|
||||||
|
'Webex OAuth Failed',
|
||||||
|
'<p>Missing authorization code.</p>'
|
||||||
|
));
|
||||||
|
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 =>
|
||||||
|
`<li>${result.configKey}: ${result.action} (${result.webhookId})</li>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
res.status(200).send(renderHtml(
|
||||||
|
'Webex OAuth Complete',
|
||||||
|
`<p>Authentication succeeded and webhooks were registered.</p>
|
||||||
|
<ul>${webhookList}</ul>
|
||||||
|
<p><a href="/webex/oauth/status">View status</a></p>`
|
||||||
|
));
|
||||||
|
} catch (err) {
|
||||||
|
logError('webexOAuth', 'OAuth callback failed', err);
|
||||||
|
res.status(500).send(renderHtml(
|
||||||
|
'Webex OAuth Failed',
|
||||||
|
`<p>${err.message}</p><p><a href="/webex/oauth/start">Try again</a></p>`
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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]) => `<li>${roomId}: ${webhookId}</li>`)
|
||||||
|
.join('') || '<li>None registered yet</li>';
|
||||||
|
|
||||||
|
res.status(200).send(renderHtml(
|
||||||
|
'Webex OAuth Status',
|
||||||
|
`<p>Authenticated: <strong>${status.authenticated}</strong></p>
|
||||||
|
<p>Access token expires: ${status.expiresAt || 'n/a'}</p>
|
||||||
|
<p>Refresh token expires: ${status.refreshExpiresAt || 'n/a'}</p>
|
||||||
|
<p>Inbound enabled: ${config.webex.inboundEnabled}</p>
|
||||||
|
<p>Dry run: ${config.webex.inboundDryRun}</p>
|
||||||
|
<p>Webhook target: ${config.webex.webhookTargetUrl}</p>
|
||||||
|
<h2>Registered webhooks</h2>
|
||||||
|
<ul>${webhookItems}</ul>
|
||||||
|
<p><a href="${oauthStartUrl}">Re-authenticate</a></p>`
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { registerRoutes };
|
||||||
|
}
|
||||||
244
routes/webexWebhooks.js
Normal file
244
routes/webexWebhooks.js
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -59,7 +59,9 @@ export function createWebhookRoutes({ config, jiraProcessor, webexService, log }
|
||||||
webexService.processHighSeverity(req.body, config.rooms.support);
|
webexService.processHighSeverity(req.body, config.rooms.support);
|
||||||
}, correlationMeta);
|
}, correlationMeta);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerErrorHandler(app) {
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
log.logError('express', `${req.method} ${req.path}`, err);
|
log.logError('express', `${req.method} ${req.path}`, err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
|
|
@ -68,5 +70,5 @@ export function createWebhookRoutes({ config, jiraProcessor, webexService, log }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { registerRoutes };
|
return { registerRoutes, registerErrorHandler };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
70
scripts/discover-jsm-fields.js
Normal file
70
scripts/discover-jsm-fields.js
Normal file
|
|
@ -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> [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.');
|
||||||
61
services/jiraTickets.js
Normal file
61
services/jiraTickets.js
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
145
services/jobMessageParser.js
Normal file
145
services/jobMessageParser.js
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,38 @@ export function createWebexService(config, log) {
|
||||||
.catch(error => logError('sendHighPriorityMessage', 'Network error sending to Webex', error));
|
.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) {
|
function processHighSeverity(jiraTicket, roomId) {
|
||||||
const jiraKey = jiraTicket.issue.key;
|
const jiraKey = jiraTicket.issue.key;
|
||||||
|
|
||||||
|
|
@ -116,6 +148,8 @@ export function createWebexService(config, log) {
|
||||||
sendTicketCard,
|
sendTicketCard,
|
||||||
sendWebexMessage,
|
sendWebexMessage,
|
||||||
sendHighPriorityMessage,
|
sendHighPriorityMessage,
|
||||||
|
replyToMessage,
|
||||||
|
getMessage,
|
||||||
processHighSeverity,
|
processHighSeverity,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
100
services/webexInbound.js
Normal file
100
services/webexInbound.js
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
206
services/webexOAuth.js
Normal file
206
services/webexOAuth.js
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
153
services/webexWebhookManager.js
Normal file
153
services/webexWebhookManager.js
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
139
test/jobMessageParser.test.js
Normal file
139
test/jobMessageParser.test.js
Normal file
|
|
@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
24
test/webexInbound.test.js
Normal file
24
test/webexInbound.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
54
test/webexOAuth.test.js
Normal file
54
test/webexOAuth.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue