rulesx/alertManager.js

/**
 * Copyright (c) 2025 Florian Hotze
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0
 *
 * SPDX-License-Identifier: EPL-2.0
 */
const { log } = require('openhab');

const logger = log('org.openhab.automation.js.hotzware_openhab_tools.rulesx.AlertManager');

/**
 * Callback for sending an alert.
 *
 * @callback SendAlertCallback
 * @param {string} id the unique identifier for the alert
 * @param {string} message the message to be displayed in the alert
 */

/**
 * Callback for revoking an alert.
 *
 * @callback RevokeAlertCallback
 * @param {string} id the unique identifier of the alert to be revoked
 */

/**
 * Callback for revalidating an alert.
 *
 * @callback RevalidateAlertCallback
 * @returns {boolean} true if the alert should be sent, false otherwise
 */

/**
 * The AlertManager class is responsible for managing alerts.
 * It allows sending alerts immediately, scheduling them for later, and revoking them.
 *
 * @memberOf rulesx
 */
class AlertManager {
  /**
   * The modes for rescheduling alerts.
   * @type {{NO_RESCHEDULE: string, RESCHEDULE_IF_DELAY_CHANGED: string, RESCHEDULE: string}}
   */
  static RESCHEDULE_MODE = {
    /**
     * Do not reschedule the alert if it is already scheduled or active.
     */
    NO_RESCHEDULE: 'NO_RESCHEDULE',
    /**
     * Reschedule the alert only if the delay has changed.
     */
    RESCHEDULE_IF_DELAY_CHANGED: 'RESCHEDULE_IF_DELAY_CHANGED',
    /**
     * Reschedule the alert.
     */
    RESCHEDULE: 'RESCHEDULE'
  };

  /**
   * @type {string}
   */
  #id;
  /**
   * @type {SendAlertCallback}
   */
  #sendAlert;
  /**
   * @type {RevokeAlertCallback}
   */
  #revokeAlert;
  /**
   * Stores the IDs of currently active alerts.
   * @type {Set<string>}
   */
  #activeAlerts = new Set();
  /**
   * Stores the scheduled alerts by their ID along with scheduling arguments.
   * @type {Map<string, { delay: number, expiresAt: number, message: string, repeat: boolean, revalidate: RevalidateAlertCallback, id: NodeJS.Timeout }>}
   */
  #scheduledAlerts = new Map();
  /**
   * Stores the muted alerts by their ID along with the timeout ID used for removing the mute.
   * @type {Map<string, NodeJS.Timeout>}
   */
  #mutedAlerts = new Map();

  /**
   * Creates a new AlertManager instance.
   *
   * @param {string} id the identifier for this AlertManager instance, used for logging
   * @param {SendAlertCallback} sendAlert the function to call when an alert should be sent
   * @param {RevokeAlertCallback} revokeAlert the function to call when an alert should be revoked
   */
  constructor (id, sendAlert, revokeAlert) {
    if (typeof sendAlert !== 'function') {
      throw new Error('sendAlert must be a function');
    }
    if (typeof revokeAlert !== 'function') {
      throw new Error('revokeAlert must be a function');
    }

    this.#id = id;
    this.#sendAlert = sendAlert;
    this.#revokeAlert = revokeAlert;
  }

  /**
   * Issues an alert immediately.
   *
   * If the alert is muted, it will not be sent, except if `important` is set to `true`.
   *
   * If the alert is already active, do nothing by default.
   * If `reissue` is set to `true`, issue the alert again.
   *
   * @param {string} id the unique identifier for the alert
   * @param {string} message the message to be displayed in the alert
   * @param {boolean} [reissue=false] whether to re-issue the alert if it already has been issued
   * @param {boolean} [important=false] whether the alert is important and should be sent even if muted
   * @return {boolean} true if the alert was issued, else false
   */
  issueAlert (id, message, reissue = false, important = false) {
    if (this.#activeAlerts.has(id) && !reissue) {
      logger.debug(`${this.#id}: Alert ${id} already active, not sending again.`);
      return false;
    }
    if (this.#scheduledAlerts.has(id) && !this.#scheduledAlerts.get(id).repeat) {
      this.#cancelScheduledAlert(id);
      logger.debug(`${this.#id}: Alert ${id} was scheduled, but now being issued immediately.`);
    }
    if (this.#mutedAlerts.has(id) && !important) {
      logger.debug(`${this.#id}: Alert ${id} is muted, not sending alert.`);
      return false;
    }
    this.#activeAlerts.add(id);
    this.#sendAlert(id, message);
    logger.debug(`${this.#id}: Alert ${id} (re-)issued with message: "${message}"`);
    return true;
  }

  /**
   * Cancels a scheduled alert by its ID.
   * If the alert is repeated, it will be stopped.
   *
   * @param {string} id the unique identifier of the alert to be cancelled
   * @return {boolean} true if the alert was cancelled, else false
   */
  #cancelScheduledAlert (id) {
    const alertData = this.#scheduledAlerts.get(id);
    if (!alertData) return false;
    if (alertData.repeat) {
      clearInterval(alertData.id);
      logger.debug(`${this.#id}: Cancelled scheduled repeating alert ${id}.`);
    } else {
      clearTimeout(alertData.id);
      logger.debug(`${this.#id}: Cancelled scheduled alert ${id}.`);
    }
    return true;
  }

  /**
   * Schedules an alert to be issued after the specified delay.
   *
   * If an alert with the same ID is already scheduled, do nothing by default.
   * Rescheduling behaviour can be controlled with the `reschedule` parameter.
   * For values of `reschedule`, see {@link #RESCHEDULE_MODE}.
   *
   * @param {string} id the unique identifier for the alert
   * @param {string} message the message to be displayed in the alert
   * @param {number} delay the delay in minutes before the alert should become active
   * @param {boolean} [repeat=false] whether to repeat the alert after the delay, defaults to false
   * @param {string} [reschedule] whether to reschedule an already scheduled alert, defaults to NO_RESCHEDULE
   * @param {RevalidateAlertCallback} [revalidate] function to revalidate if the alert should be sent once the delay is over
   * @return {boolean} true if the alert was (re-)scheduled, else false
   */
  scheduleAlert (id, message, delay, repeat = false, reschedule = AlertManager.RESCHEDULE_MODE.NO_RESCHEDULE, revalidate = () => true) {
    if (this.#scheduledAlerts.has(id) || this.#activeAlerts.has(id)) {
      switch (reschedule) {
        case AlertManager.RESCHEDULE_MODE.RESCHEDULE_IF_DELAY_CHANGED:
          logger.debug(`${this.#id}: Rescheduling alert ${id} with new delay of ${delay} minutes ...`);
          if (this.#scheduledAlerts.get(id)?.delay === delay) {
            logger.debug(`${this.#id}: Alert ${id} already scheduled with the same delay, not rescheduling.`);
            return false;
          }
          this.#cancelScheduledAlert(id);
          break;
        case AlertManager.RESCHEDULE_MODE.RESCHEDULE:
          logger.debug(`${this.#id}: Rescheduling alert ${id} ...`);
          this.#cancelScheduledAlert(id);
          break;
        default:
          logger.debug(`${this.#id}: Skipping scheduling alert ${id}, already scheduled or active.`);
          return false;
      }
    }

    const delayMs = delay * 60 * 1000;

    const handler = () => {
      if (!repeat) this.#scheduledAlerts.delete(id);
      if (typeof revalidate === 'function' && !revalidate()) {
        logger.debug(`${this.#id}: Alert ${id} was not revalidated, not sending alert.`);
        return;
      }
      this.issueAlert(id, message, repeat);
    };

    let timeoutId;
    if (repeat) {
      timeoutId = setInterval(handler, delayMs);
    } else {
      timeoutId = setTimeout(handler, delayMs);
    }
    this.#scheduledAlerts.set(id, { delay, expiresAt: Date.now() + delayMs, message, repeat, revalidate, id: timeoutId });
    logger.debug(`${this.#id}: Scheduled alert ${id} with a delay of ${delay} minutes.`);
    return true;
  }

  /**
   * Changes the delay of a scheduled alert.
   *
   * If no alert with the given ID is scheduled, do nothing.
   *
   * @param {string} id the unique identifier for the alert
   * @param {number} newDelay the new delay in minutes before the alert should become active
   * @return {boolean} true if the delay was changed, else false
   */
  changeDelayForScheduledAlert (id, newDelay) {
    if (!this.#scheduledAlerts.has(id)) {
      logger.debug(`${this.#id}: Attempted to change delay for alert ${id}, but it was not scheduled.`);
      return false;
    }
    logger.debug(`${this.#id}: Changing delay for alert ${id} to ${newDelay} minutes ...`);
    const alertData = this.#scheduledAlerts.get(id);
    if (newDelay === alertData.delay) return false;
    const delay = ((alertData.expiresAt - Date.now()) / 60 / 1000) - alertData.delay + newDelay;
    this.scheduleAlert(id, alertData.message, delay, alertData.repeat, AlertManager.RESCHEDULE_MODE.RESCHEDULE, alertData.revalidate);
    return true;
  }

  /**
   * Mutes an alert by its ID for the specified duration.
   * @param {string} id the unique identifier for the alert
   * @param {number} duration the duration in minutes for which the alert should be muted
   */
  muteAlert (id, duration) {
    if (this.#mutedAlerts.has(id)) {
      clearTimeout(this.#mutedAlerts.get(id));
      logger.debug(`${this.#id}: Alert ${id} was already muted, re-muting for ${duration} minutes ...`);
    }
    const timeoutId = setTimeout(() => {
      this.#mutedAlerts.delete(id);
      logger.debug(`${this.#id}: Alert ${id} is no longer muted.`);
    }, duration * 60 * 1000);
    this.#mutedAlerts.set(id, timeoutId);
    logger.debug(`${this.#id}: Alert ${id} has been muted for ${duration} minutes.`);
  }

  /**
   * Revokes an alert, no matter it has only been scheduled or already become active.
   *
   * If no alert with the given ID is scheduled or active, do nothing.
   *
   * @param {string} id the unique identifier of the alert
   * @return {boolean} true if the alert was revoked, else false
   */
  revokeAlert (id) {
    if (this.#scheduledAlerts.has(id)) {
      this.#cancelScheduledAlert(id);
      logger.debug(`${this.#id}: Scheduled alert ${id} has been cancelled.`);
    } else if (this.#activeAlerts.has(id)) {
      this.#revokeAlert(id);
      this.#activeAlerts.delete(id);
      logger.debug(`${this.#id}: Alert ${id} has been revoked from active alerts.`);
    } else {
      logger.debug(`${this.#id}: Attempted to revoke alert ${id}, but it was not found in scheduled or active alerts.`);
      return false;
    }
    return true;
  }

  /**
   * Revokes all alerts that have been scheduled or already become active.
   *
   * @return {number} the number of alerts that have been revoked
   */
  revokeAllAlerts () {
    let count = 0;
    for (const id of this.#activeAlerts) {
      if (this.revokeAlert(id)) count++;
    }
    for (const id of this.#scheduledAlerts.keys()) {
      if (this.revokeAlert(id)) count++;
    }
    return count;
  }
}

module.exports = AlertManager;