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 = `
\n\n"; /* if (update.affected_components) { statusText += "" + statusTextName + "
" + update.body + "
" + "Created: " + createdDate.toLocaleString('en-US', { timeZoneName: 'short', timeZone: 'America/New_York' }) + ""; statusText += "
\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 += "" + component.name + "
(" + component.status + ")
"; if (component.description) { statusText += component.description + "
"; } statusText += "Updated: " + updatedDate.toLocaleString('en-US', { timeZoneName: 'short', timeZone: 'America/New_York' }) + "
\n\n"; /* if (update.affected_components) { statusText += "" + statusTextName + "
" + statusIO.details + "