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>
546 lines
20 KiB
JavaScript
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();
|
|
});
|
|
});
|
|
});
|