managers_StatusPageChecker.js
'use strict';
const EventEmitter = require('node:events');
const FileSystem = require('node:fs');
const { setInterval } = require('node:timers');
const { EmbedBuilder } = require('@discordjs/builders');
const { Collection, WebhookClient, resolveColor } = require('discord.js');
const { DateTime } = require('luxon');
const Fetch = import('node-fetch');
const { DisGroupDevError, Messages } = require('../errors/DisGroupDevError');
let loadedIncidents = false;
/**
* @typedef {Object} IncidentData
* @property {String} id The id of the incident
* @property {String} lastUpdate The last update of the incident
* @property {Snowflake} messageId The id of the message of the incident
* @property {Boolean} resolved If the incident is resolved
*/
/**
* @typedef {Object} IncidentDataRaw
* @property {IncidentDataRawComponent[]} components The components of the incident
* @property {String} created_at The creation date of the incident
* @property {String} id The id of the incident
* @property {String} impact The impact of the incident
* @property {IncidentDataRawComponentUpdate[]} incident_updates The updates of the incident
* @property {String} monitoring_at The monitoring date of the incident
* @property {String} name The name of the incident
* @property {String} page_id The id of the status page
* @property {String} resolved_at The resolved date of the incident
* @property {String} shortlink The link of the incident
* @property {String} started_at The start date of the incident
* @property {String} status The status of the incident
* @property {String} updated_at The last update of the incident
*/
/**
* @typedef {Object} IncidentDataRawComponent
* @property {String} created_at The creation date of the component
* @property {String} description The description of the component
* @property {Boolean} group If the component is a group
* @property {String|null} group_id The id of the group of the component
* @property {String} id The id of the component
* @property {String} name The name of the component
* @property {Boolean} only_show_if_degraded If the component is only shown if degraded
* @property {String} page_id The id of the status page
* @property {Number} position The position of the component
* @property {Boolean} showcase If the component is in the showcase
* @property {String|null} start_date The start date of the component
* @property {String} status The status of the component
* @property {String} updated_at The last update of the component
*/
/**
* @typedef {Object} IncidentDataRawComponentUpdate
* @property {String} code The code of the component update
* @property {String} name The name of the component update
* @property {String} new_status The new status of the component update
* @property {String} old_status The old status of the component update
*/
/**
* @typedef {Object} IncidentDataRawUpdate
* @property {IncidentDataRawComponentUpdate[]} affected_components The affected components of the update
* @property {String} body The body of the update
* @property {String} created_at The creation date of the update
* @property {String|null} custom_tweet The custom tweet of the update
* @property {Boolean} deliver_notifications If the update should be delivered to the users
* @property {String} display_at The display date of the update
* @property {String} id The id of the incident update
* @property {String} incident_id The id of the incident
* @property {String} status The status of the incident update
* @property {String|null} tweet_id The id of the tweet of the update
* @property {String} update_at The date of the incident update
*/
/**
* @typedef {Object} StatusPageCheckerOptions
* @property {Number} checkInterval The time in ms the checker should check (default: 60000)
* @property {StatusPageCheckerOptionsColors} colors The colors of the embeds
* @property {String} storage The json file to save the incidents to
* @property {String} url The URL of the status page you want to monitore
* @property {WebhookClient} webhook webhook client
*/
/**
* @typedef {Object} StatusPageCheckerOptionsColors
* @property {String} BLACK (default: #000000)
* @property {String} GREEN (default: #51f34d)
* @property {String} ORANGE (default: #fcb22d)
* @property {String} RED (default: #fe6b61)
* @property {String} YELLOW (default: #ffde22)
*/
/**
* The status page checker.
* Inspired by @almostSouji
* @extends {EventEmitter}
* @class
*/
class StatusPageChecker extends EventEmitter {
/**
* The constructor of the status page checker class.
* @param {StatusPageCheckerOptions} options The options of the status page checker
*/
constructor(options = { colors: { BLACK: '#000000', GREEN: '#51f34d', ORANGE: '#fcb22d', RED: '#fe6b61', YELLOW: '#ffde22' } }) {
super();
/**
* The cache with all page incidents
* @type {Collection<String, IncidentData>}
* @public
*/
this.cache = new Collection();
/**
* The options of the status page checker.
* @type {StatusPageCheckerOptions}
* @public
*/
this.options = options;
if (!options?.storage || typeof options.storage !== 'string') throw new DisGroupDevError(Messages.INVALID_LOCATION);
if (!options?.url || typeof options.url !== 'string') throw new DisGroupDevError(Messages.INVALID_URL);
if (!options?.webhook || !(options.webhook instanceof WebhookClient)) throw new DisGroupDevError(Messages.NOT_INSTANCE_OF(options?.webhook, WebhookClient));
/**
* The webhook client.
* @type {WebhookClient}
* @public
*/
this.webhook = options.webhook;
this.check();
setInterval(() => this.check(), this.options.checkInterval ?? 60_000);
}
/**
* Fetches the status page
* @returns {Promise<Fetch.Response>}
* @private
*/
async _fetch() {
const data = await (await Fetch).default(`${this.options.url}/api/v2/incidents.json`).then((r => r.json()));
return data;
}
/**
* Generates an incident embed
* @param {IncidentDataRaw} incident The raw incident data
* @returns {Promise<EmbedBuilder>}
* @private
*/
_generateEmbed(incident) {
return new Promise(resolve => {
try {
const embedColor =
incident.status === 'resolved' || incident.status === 'postmortem'
? this.options.colors.GREEN : incident.impact === 'critical'
? this.options.colors.RED : incident.impact === 'major'
? this.options.colors.ORANGE : incident.impact === 'minor'
? this.options.colors.YELLOW : this.options.colors.BLACK;
const affectedComponentsNames = incident.components.map(c => c.name);
const embed = new EmbedBuilder()
.setTitle(incident.name)
.setColor(resolveColor(embedColor))
.setFooter({ text: `ID: ${incident.id}` })
.setTimestamp(new Date(incident.started_at))
.setURL(incident.shortlink);
for (const incidentUpdate of incident.incident_updates.reverse()) {
const incidentUpdateDate = DateTime.fromISO(incidentUpdate.created_at);
const incidentUpdateDateTimeStamp = `<t:${Math.floor(incidentUpdateDate.toSeconds())}:R>`;
embed.addFields({
name: `${incidentUpdate.status.charAt(0).toUpperCase()}${incidentUpdate.status.slice(1)} (${incidentUpdateDateTimeStamp})`,
value: incidentUpdate.body,
});
}
const embedDescriptionParts = [`・ Impact: ${incident.impact}`];
if (affectedComponentsNames.length) embedDescriptionParts.push(`・ Affected components: ${affectedComponentsNames.join(', ')}`);
embed.setDescription(embedDescriptionParts.join('\n'));
resolve(embed);
} catch (e) {
throw new DisGroupDevError(e);
}
});
}
/**
* Loads all incidents from the storage file
* @returns {Promise<Boolean|DisGroupDevError>}
* @private
*/
_loadIncidents() {
return new Promise(async resolve => {
try {
const rawData = await this._loadRawIncidents();
await rawData.forEach(rawDataIncident => {
this.cache.set(rawDataIncident.id, {
id: rawDataIncident.id,
lastUpdate: rawDataIncident.lastUpdate,
messageId: rawDataIncident.messageId,
resolved: rawDataIncident.resolved,
});
});
loadedIncidents = true;
resolve(true);
} catch (e) {
throw new DisGroupDevError(e);
}
});
}
/**
* Loads all incidents raw from the storage file
* @returns {Promise<Array<IncidentData>>}
* @private
*/
_loadRawIncidents() {
// eslint-disable-next-line arrow-parens
return new Promise(async (resolve) => {
const storage = await require('util').promisify(FileSystem.exists)(this.options.storage);
if (!storage) {
await require('util').promisify(FileSystem.writeFile)(this.options.storage, JSON.stringify([]), 'utf-8');
resolve([]);
} else {
const storageContent = await require('util').promisify(FileSystem.readFile)(this.options.storage);
try {
const storageIncidents = await JSON.parse(storageContent.toString());
if (Array.isArray(storageIncidents)) {
resolve(storageIncidents);
} else {
resolve([]);
}
} catch (e) {
resolve([]);
}
}
});
}
/**
* Saves the incidents to the file
* @returns {Promise<Boolean>}
* @private
*/
async _save() {
await require('util').promisify(FileSystem.writeFile)(this.options.storage, JSON.stringify(this.cache, null, 4), 'utf-8');
return true;
}
/**
* The check function
* @returns {Promise<Boolean|DisGroupDevError>}
* @public
*/
async check() {
if (!loadedIncidents) {
await this._loadIncidents();
return this.check();
}
/**
* Emitted when a check has started.
* @event StatusPageChecker#incidentCheck
*/
this.emit('incidentCheck');
try {
const fetched = await this._fetch();
/** @type {IncidentDataRaw[]} */
const fetchedIncidents = fetched.incidents;
// eslint-disable-next-line no-unsafe-optional-chaining
for (const incident of fetchedIncidents?.reverse()) {
const incidentData = this.cache.get(incident.id);
if (!incidentData) {
/**
* Emitted when a new incident has been found
* @event StatusPageChecker#incidentCreate
* @param {IncidentData} incidentData
*/
this.emit('incidentCreate', incidentData);
return this.updateIncident(incident);
}
const incidentUpdate = DateTime.fromISO(incident.updated_at ?? incident.created_at);
if (DateTime.fromISO(incidentData.lastUpdate) < incidentUpdate) {
/**
* Emitted when an incident has been updated
* @event StatusPageChecker#incidentUpdate
* @param {IncidentData} incidentData
*/
this.emit('incidentUpdate', incidentData);
return this.updateIncident(incident, incidentData.messageId);
}
}
return true;
} catch (e) {
throw new DisGroupDevError(e);
}
}
/**
* Updates an incident
* @param {IncidentDataRaw} incident The raw data of the incident
* @param {?String} messageId The id of the webhook message
* @returns {Promise<Boolean|DisGroupDevError>}
*/
updateIncident(incident, messageId = null) {
return new Promise(async resolve => {
const embed = await this._generateEmbed(incident);
try {
const webhookMessage = await ((messageId && typeof messageId === 'string') ? this.webhook.editMessage(messageId, { embeds: [embed] }) : this.webhook.send({ embeds: [embed] }));
this.cache.set(incident.id, {
id: incident.id,
lastUpdate: DateTime.now().toISO(),
messageId: webhookMessage.id,
resolved: incident.status === 'resolved' || incident.status === 'postmortem',
});
await this._save();
resolve(true);
} catch (e) {
throw new DisGroupDevError(e);
}
});
}
}
module.exports = StatusPageChecker;