import fs from 'fs'; import path from 'path'; import fetch from 'node-fetch'; import express from 'express'; import bodyParser from 'body-parser'; import cron from 'node-cron'; import { parseFeed } from '@rowanmanning/feed-parser'; import { readJSON, writeJSON } from './src/atomicJson.js'; import { ProviderStore } from './src/providerStore.js'; import { startBot } from './src/webexBot.js'; var config = readJSON('./config/config.json'); var store = ProviderStore.load('./config/feeds.json'); var feedProviders = store.raw; var rssFeedCache = readJSON('./config/rssFeedCache.json'); var webhookEvents = {}; // Secrets and per-deployment overrides come from the environment, never from // the checked-in config. Keep the tree shape (config.webex.bot.token) so the // rest of the code can keep reading it the way it always has. config.webex = config.webex || {}; config.webex.bot = config.webex.bot || {}; config.webex.bot.token = process.env.WEBEX_BOT_TOKEN || config.webex.bot.token; if (!config.webex.bot.token) { console.error("FATAL: WEBEX_BOT_TOKEN is not set. Refusing to start."); process.exit(1); } config.server = config.server || {}; config.server.name = process.env.SERVER_NAME || config.server.name || "aeStatusPage"; config.server.port = Number(process.env.SERVER_PORT || config.server.port) || 1449; // Public URL where inbound webhooks reach this service. Used only to show the // user the URL they should configure in their upstream status provider. If // unset the bot degrades gracefully by showing a placeholder. config.publicBaseUrl = process.env.PUBLIC_BASE_URL || null; // Enable/disable the Webex chat bot control channel. Defaults to on when a // token is present. Set BOT_ENABLED=false to run in webhook-only mode. var botEnabled = String(process.env.BOT_ENABLED || 'true').toLowerCase() !== 'false'; var app = express(); app.use(bodyParser.json({ limit: '50mb' })); var server = app.listen(config.server.port, function () { logger("startup", `Started ${config.server.name} on *:${config.server.port}...`) }); app.get('/healthCheck', function (req, res) { res.status(200).send({ "status": "alive" }); }); app.get('/feeds', (req, res) => { logger(`\/feeds`, `Requested the feeds file.`) res.status(200).send(feedProviders); }) app.get('/webhookEvents', (req, res) => { logger(`\/webhookEvents`, `Requested the webhookEvents data.`) res.status(200).send(webhookEvents); }) app.get('/:provider/go', (req, res) => { if (feedProviders.providers[req.params.provider] && feedProviders.providers[req.params.provider].siteUrl) { logger(`\/${req.params.provider}\/go`, `Redirect to ${feedProviders.providers[req.params.provider].siteUrl}`) res.status(301).redirect(feedProviders.providers[req.params.provider].siteUrl) } else { res.status(404).send(`${req.params.provider} could not be found.`); } }) app.get('/checkRSSFeed', (req, res) => { if (!req.query.site) { return res.status(400).send({ error: "Missing required query parameter 'site'." }); } fetch(req.query.site) .then(response => response.text()) .then(text => parseFeed(text)) .then(feed => res.status(200).send(feed)) .catch(error => { logger('/checkRSSFeed', `Error fetching/parsing ${req.query.site}: ${error}`); res.status(502).send({ error: `Failed to fetch or parse feed: ${error.message}` }); }); }) app.post('/rssTest', (req, res) => { var output = testRss(req.body, req.query.provider) res.status(200).send(output) }) app.get('/cleanOldFiles', (req, res) => { cleanOldFiles() res.status(201).send() }) app.post('/:provider', (req, res) => { logger(`\/${req.params.provider}`, `Received webhook.`) res.status(201).send(); if (feedProviders.providers[req.params.provider] && feedProviders.providers[req.params.provider].webhook) { processWebhook(req.params.provider, req.body); logFile(req.params.provider, req.body); } else { logger(`\/${req.params.provider}`, `${JSON.stringify(req.body)}`) } }) app.get('/refreshFeeds', function (req, res) { try { store.reload(); logger('/refreshFeeds', 'Refreshed feeds.'); res.status(200).send('Refreshed feeds.'); } catch (error) { logger('/refreshFeeds', `Error: ${error}`); res.status(500).send({ error: `Failed to reload feeds: ${error.message}` }); } }); cron.schedule('20 */5 * * * *', async () => { var startTime = Date.now(); logger('cron', 'Checking for RSS updates.'); try { await processRSSFeeds(); await cleanRSSCache(); await Promise.all([ store.save(), writeJSON('./config/rssFeedCache.json', rssFeedCache), ]); logger('cron', `processFeeds completed. (${(Date.now() - startTime) / 1000}s)`); } catch (error) { logger('cron', `Error in cron cycle: ${error}`); } cleanOldFiles(); }); async function processRSSFeeds() { var startTime = Date.now(); var rssEntries = Object.entries(feedProviders.providers).filter(([, p]) => p && p.rss); var results = await Promise.allSettled(rssEntries.map(async ([key, provider]) => { var response; try { response = await fetch(provider.rss.url); } catch (error) { throw new Error(`fetch ${provider.name}: ${error.message}`); } if (!response.ok) { throw new Error(`fetch ${provider.name}: HTTP ${response.status}`); } return parseRSSFeed(key, response); })); var cntTotalFeeds = 0; for (var i = 0; i < results.length; i++) { var r = results[i]; if (r.status === 'fulfilled') { cntTotalFeeds += r.value || 0; } else { logger('processFeeds', `${r.reason.message || r.reason}`); } } if (cntTotalFeeds > 0) { logger('processFeeds', `Found ${cntTotalFeeds} new feeds total (${((Date.now() - startTime) / 1000)}s across ${rssEntries.length} providers).`); } } async function parseRSSFeed(feedProvider, response) { var cntFeeds = 0; const feed = parseFeed(await response.text()); var startFeedTime = new Date().getTime(); var cutoff = startFeedTime - (24 * 60 * 60 * 1000); for (var feedItem of feed.items) { // Only consider items updated in the last 24 hours; skip anything already in cache. if (new Date(feedItem.updated).getTime() <= cutoff) continue; if (!rssFeedCache.providers[feedProvider]) { rssFeedCache.providers[feedProvider] = []; } var rssitemFound = 0; for (var rssItem of rssFeedCache.providers[feedProvider]) { if (rssItem.id == feedItem.id && rssItem.content == feedItem.content) { rssitemFound = 1; break; } } if (rssitemFound) continue; rssFeedCache.providers[feedProvider].push(feedItem); var msg = feedItem.url ? `## [${feedItem.title}](${feedItem.url})\n` : `## ${feedItem.title}`; if (feedItem.content) { msg += `${feedItem.content}`; } else if (feedItem.description) { msg += `${feedItem.description}`; } var body = { "roomId": feedProviders.providers[feedProvider].roomId, "markdown": msg } logger('parseRSSFeed', `Found: ${feedProvider} - ${feed.id}`); sendWebexAPI("https://webexapis.com/v1/messages", "POST", body, feedProvider); cntFeeds++; } feedProviders.providers[feedProvider].rss.lastCheck = new Date(startFeedTime); if (cntFeeds > 0) { logger('parseRSSFeed', `Found ${cntFeeds} new items for ${feedProviders.providers[feedProvider].name} (${(new Date().getTime() - startFeedTime)}ms).`) } return cntFeeds; } function testRss(feedItem, feedProvider) { logger(`testRss (${feedProvider})`, `${JSON.stringify(feedItem)}`); if (feedItem.url) { var msg = `## [${feedItem.title}](${feedItem.url})\n`; //var msg = `

${feedItem.title}


`; } else { var msg = `## ${feedItem.title}`; } if (feedItem.content) { msg += `${feedItem.content}`; } else if (feedItem.description) { msg += `${feedItem.description}`; } var body = { "roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vYTRjZGMyMjAtNTRiMC0xMWVmLWI1ZDItMjdmZTlmZjVlNmFj", "markdown": msg } sendWebexAPI("https://webexapis.com/v1/messages", "POST", body, feedProvider) return msg; } function processWebhook(provider, webhook) { logger('processWebhook', `Payload is ${feedProviders.providers[provider].webhook.format} format.`) if (feedProviders.providers[provider].webhook.format == "statusPage") { sendStatusPageUpdate(provider, webhook) } else if (feedProviders.providers[provider].webhook.format == "statusIO") { sendStatusIOUpdate(provider, webhook); } else if (feedProviders.providers[provider].webhook.format == "uptimeKuma") { sendUptimeKuma(provider, webhook); } else { logger(`processWebhook (${provider})`, `${JSON.stringify(webhook)}`) } } function sendUptimeKuma(provider, webhook) { return new Promise(async function (resolve, reject) { if (webhook.msg) { var body = { "roomId": feedProviders.providers[provider].roomId, "markdown": webhook.msg } sendWebexAPI("https://webexapis.com/v1/messages", "POST", body, provider) } }) } function cleanRSSCache() { return new Promise(async function (resolve, reject) { for (var provider in rssFeedCache.providers) { var clearCache = []; for (var rssItem of rssFeedCache.providers[provider]) { if (new Date(rssItem.updated).getTime() > new Date(new Date().getTime() - (2 * 24 * 60 * 60 * 1000)).getTime()) { clearCache.push(rssItem); } } rssFeedCache.providers[provider] = clearCache; } resolve(); }) } function sendStatusPageUpdate(provider, statusPage) { return new Promise(async function (resolve, reject) { //console.log("In sendStatusPageUpdate function"); if (statusPage.incident) { //console.log("Doing Status Page Update."); var statusColor = ""; var statusTextName = ""; var componentColor = ""; var statusText = "- - -\n# [" + statusPage.incident.name + "](" + statusPage.incident.shortlink + ")\n" + "## " + statusPage.page.status_description + "\n\n"; statusText += "### Updates:\n"; for (const update of statusPage.incident.incident_updates) { if (update.status == "completed") { statusColor = "success"; statusTextName = "Completed"; }; if (update.status == "scheduled") { statusColor = "info"; statusTextName = "Scheduled"; }; if (update.status == "in_progress") { statusColor = "warning"; statusTextName = "In Progress"; }; if (update.status == "investigating") { statusColor = "danger"; statusTextName = "Investigating"; } if (update.status == "identified") { statusColor = "warning"; statusTextName = "Identified"; } if (update.status == "monitoring") { statusColor = "info"; statusTextName = "Monitoring"; } if (update.status == "resolved") { statusColor = "success"; statusTextName = "Resolved"; } var createdDate = new Date(update.created_at); statusText += "

" + statusTextName + "


" + update.body + "
" + "Created: " + createdDate.toLocaleString('en-US', { timeZoneName: 'short', timeZone: 'America/New_York' }) + ""; statusText += "
\n\n"; /* if (update.affected_components) { statusText += "

Affected Components:


"; for (const affected of update.affected_components) { statusText += affected.name + "
" + affected.old_status + " -> " + affected.new_status + "
"; } } */ } statusText += "### Components:\n"; for (const component of statusPage.incident.components) { if (component.status == "operational") { componentColor = "success"; } if (component.status == "major_outage") { componentColor = "danger"; } if (component.status == "degraded_performance") { componentColor = "warning"; } if (component.status == "partial_outage") { componentColor = "warning"; } if (component.status == "under_maintenance") { componentColor = "info"; } var updatedDate = new Date(component.updated_at); statusText += "

" + component.name + "

(" + component.status + ")
"; if (component.description) { statusText += component.description + "
"; } statusText += "Updated: " + updatedDate.toLocaleString('en-US', { timeZoneName: 'short', timeZone: 'America/New_York' }) + "
\n\n"; } //statusText += "[Link](" + statusPage.incident.shortlink + ")"; var body = { "roomId": feedProviders.providers[provider].roomId, "markdown": statusText } sendWebexAPI("https://webexapis.com/v1/messages", "POST", body, provider) } }) } function sendStatusIOUpdate(provider, statusIO) { return new Promise(async function (resolve, reject) { if (statusIO.incident_url) { var statusColor = ""; var statusTextName = ""; var componentColor = ""; var statusText = "- - -\n# " + statusIO.title + "\n" + "## " + statusIO.current_status + "\n\n"; statusText += "### Updates:\n"; if (statusIO.current_state == "Identified") { statusColor = "info"; statusTextName = statusIO.current_state; }; statusText += "

" + statusTextName + "


" + statusIO.details + "
\n\n"; /* if (update.affected_components) { statusText += "

Affected Components:


"; for (const affected of update.affected_components) { statusText += affected.name + "
" + affected.old_status + " -> " + affected.new_status + "
"; } } */ statusText += "### Components:\n"; for (var affected of statusIO.infrastructure_affected) { for (var component of statusIO.components) { if (affected.component == component._id) { statusText += component.name; } } for (var container of statusIO.containers) { if (affected.container == container._id) { statusText += " (" + container.name + ")\n" } } } statusText += "\n[Link](" + statusIO.incident_url + ")"; var body = { "roomId": feedProviders.providers[provider].roomId, "markdown": statusText } sendWebexAPI("https://webexapis.com/v1/messages", "POST", body, provider) } }) } function sendWebexAPI(url, method, body, feedProviderName) { var myHeaders = { "Authorization": "Bearer " + config.webex.bot.token, "Content-Type": "application/json" } var requestOptions = { method: method, headers: myHeaders, body: JSON.stringify(body), redirect: 'follow' }; //console.log("Request options: " + JSON.stringify(requestOptions, null, 4)); fetchWithRateLimit(url, requestOptions) .then(response => response.json()) .then(result => logFile(feedProviderName, result)) .catch(error => logger("sendWebexAPI", `Error during send to Webex: ${error}`)); } async function fetchWithRateLimit(url, requestOptions) { const response = await fetch(url, requestOptions) if (response.status === 429) { //console.log(response.headers) const secondsToWait = Number(response.headers.get('retry-after')) console.log("Waiting for " + secondsToWait.toString() + " due to 429 message: " + url); await new Promise(resolve => setTimeout(resolve, secondsToWait * 1000)) console.log("Finished waiting for " + secondsToWait.toString() + " due to 429 message: " + url); return await fetchWithRateLimit(url, requestOptions) } return response } function cleanOldFiles() { var logDir = './logs/'; var files; try { files = fs.readdirSync(logDir); } catch (error) { logger('cleanOldFiles', `Error reading ${logDir}: ${error}`); return; } var cutoff = Date.now() - 259200000; // 3 days for (var file of files) { var fullPath = logDir + file; try { var fileStats = fs.statSync(fullPath); if (fileStats.mtime.getTime() <= cutoff) { logger('cleanOldFiles', `Removing file '${fullPath}'`); fs.unlinkSync(fullPath); } } catch (error) { logger('cleanOldFiles', `Error handling ${fullPath}: ${error}`); } } } function logFile(provider, jsonData) { var d = new Date(); var year = d.getFullYear(); var month = (d.getMonth() + 1).toString().padStart(2, "0"); var day = d.getDate().toString().padStart(2, "0"); let logFile = path.join(`./logs/${provider}-${year}${month}${day}.log`); fs.appendFileSync(logFile, d.toLocaleString() + "\n"); fs.appendFileSync(logFile, JSON.stringify(jsonData) + "\n"); } function logger(activeFunction, logLine) { var d = new Date(); console.log(d.toLocaleString() + " " + activeFunction + ": " + logLine); var year = d.getFullYear(); var month = (d.getMonth() + 1).toString().padStart(2, "0"); var day = d.getDate().toString().padStart(2, "0"); let logFile = path.join(`./logs/${year}${month}${day}.log`); fs.appendFileSync(logFile, d.toLocaleString() + " " + activeFunction + ": " + logLine + "\n"); } // Boot the Webex chat control channel. Websocket mode: no inbound webhook // URL required, so no additional network exposure. Users can then type // `add`, `list`, `webhook `, `remove `, or `help` in any space // the bot has been invited to. var bot = null; if (botEnabled) { startBot({ token: config.webex.bot.token, store, publicBaseUrl: config.publicBaseUrl, log: logger, }).then(fw => { bot = fw; logger('startup', 'Webex bot enabled (websocket mode).'); }).catch(error => { logger('startup', `Failed to start Webex bot: ${error}. Continuing without bot.`); }); } else { logger('startup', 'Webex bot disabled via BOT_ENABLED=false.'); } // gracefully shutdown (ctrl-c) process.on('SIGINT', function () { var stopBot = bot && typeof bot.stop === 'function' ? bot.stop() : Promise.resolve(); Promise.resolve(stopBot).finally(() => { server.close(() => { logger('shutdown', config.server.name + ' stopped!'); process.exit(); }); }); });