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:
jmcqueen 2026-07-28 08:27:52 -04:00
parent 449550cb54
commit efc64a227c
22 changed files with 1574 additions and 5 deletions

View file

@ -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

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ logs/
# Local secrets — use config.example.json + .env instead
config/config.json
config/config.local.json
config/webex-oauth.json

7
app.js
View file

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

26
config/webexRooms.json Normal file
View 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"
}
}
}
}

View file

@ -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:

View file

@ -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}.`);

View file

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

View file

@ -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');

View file

@ -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": "",

139
routes/webexOAuth.js Normal file
View 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
View 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 };
}

View file

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

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

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

View file

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

100
services/webexInbound.js Normal file
View 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
View 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,
};
}

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

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