aeStatusPage/index.js
jmcqueen 007086caf6 Initial commit: status-page bridge with Webex bot management
Bridges third-party status pages into Webex spaces via RSS polling and
inbound webhooks (Statuspage / Status.io / Uptime Kuma / generic).

Includes an interactive Webex bot (websocket transport) that lets space
members register sources with an Adaptive Card instead of hand-editing
config/feeds.json: help, add, list, webhook <key>, remove <key>.

Ships with an atomic JSON store (per-file mutex, tmp+rename), parallel
RSS polling, and unit tests via node:test. All secrets are sourced from
environment variables (see .env.example); no credentials in the repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 16:08:34 -04:00

546 lines
20 KiB
JavaScript

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 = `<h2><a href="${feedItem.url}">${feedItem.title}</a></h2><br>`;
} 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 += "<blockquote class='" + statusColor + "'><b><h2>" + statusTextName + "</h2></b><br>" +
update.body + "<br>" +
"<i>Created: " + createdDate.toLocaleString('en-US', { timeZoneName: 'short', timeZone: 'America/New_York' }) + "</i>";
statusText += "</blockquote>\n\n";
/*
if (update.affected_components) {
statusText += "<h3>Affected Components:</h3><br>";
for (const affected of update.affected_components) {
statusText += affected.name + "<br>" +
affected.old_status + " -> " + affected.new_status + "<br>";
}
} */
}
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 += "<blockquote class='" + componentColor + "'><h3>" + component.name + "</h3> (" + component.status + ")<br>";
if (component.description) {
statusText += component.description + "<br>";
}
statusText += "Updated: " + updatedDate.toLocaleString('en-US', { timeZoneName: 'short', timeZone: 'America/New_York' }) + "</blockquote>\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 += "<blockquote class='" + statusColor + "'><h3>" + statusTextName + "</h3><br>" + statusIO.details + "</blockquote>\n\n";
/*
if (update.affected_components) {
statusText += "<h3>Affected Components:</h3><br>";
for (const affected of update.affected_components) {
statusText += affected.name + "<br>" +
affected.old_status + " -> " + affected.new_status + "<br>";
}
} */
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 <key>`, `remove <key>`, 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();
});
});
});