prey/prey-node-client

View on GitHub
lib/agent/actions/triggers/index.js

Summary

Maintainability
F
1 wk
Test Coverage
const schedule = require('node-schedule');
const { EventEmitter } = require('events');
const logger = require('../../common').logger.prefix('triggers');
const api = require('../../control-panel/api');
const hooks = require('../../hooks');
const commands = require('../../commands');
const storage = require('../../utils/storage');

let emitter;
let runningTriggers = [];
let eventTriggers = {};
exports.currentTriggers = [];

let timeoutStartTrigger;
const websocket = require('../../control-panel/websockets');

const eventsList = [
  'connected',
  'disconnected',
  'geofencing_in',
  'geofencing_out',
  'new_location',
  'mac_address_changed',
  'ssid_changed',
  'private_ip_changed',
  'low_battery',
  'started_charging',
  'stopped_charging',
  'hardware_changed',
  'device_unseen',
];

const errorStatusList = {
  0: 'Unknown error',
  1: 'Success!',
  2: 'Invalid trigger format',
  3: 'Cant set trigger into the past!',
  4: "The execution range dates doesn't make sense.",
  5: 'Unavailable event for Node Client.',
  6: 'Persisting action!',
};

const fetchTriggers = (cb) => {
  api.devices.get.triggers(cb);
};

const done = (id, err, cb) => {
  if (emitter) {
    setTimeout(() => {
      emitter.emit('end', id, err);
    }, 1000);
  }

  if (cb && typeof cb === 'function') return cb(err);
  return null;
};

const sendResponse = (status, idList) => {
  logger.debug(`sending ${status} w/ code ${JSON.stringify(idList)} to prey' control panel`);

  const data = {
    status,
    command: 'start',
    target: 'triggers',
    reason: JSON.stringify(idList),
  };
  // eslint-disable-next-line dot-notation
  api.push.methods['response'](data);
};

const cancelHooks = () => {
  if (Object.keys(eventTriggers).length === 0) return;

  Object.keys(eventTriggers).forEach((event) => {
    hooks.remove(event);
  });
};

const checkRepeat = (date, repeat) => {
  let limit;
  try {
    const hourFrom = repeat.hour_from;
    const hourUntil = repeat.hour_until;
    const dateFrom = date.setHours(
      hourFrom.slice(0, 2),
      hourFrom.slice(2, 4),
      hourFrom.slice(4, 6),
    );
    const dateUntil = date.setHours(
      hourUntil.slice(0, 2),
      hourUntil.slice(2, 4),
      hourUntil.slice(4, 6),
    );

    limit = { from: dateFrom, until: dateUntil };
  } catch (e) {
    limit = {};
  }
  return limit;
};

const checkRangeFormat = (from, until) => {
  if (from > until) return false;
  return true;
};

const checkRepeatFormat = (days, hourFrom, hourUntil, until) => {
  if (days.some(Number.isNaN)) return false;
  if (hourFrom.length !== 6 || hourUntil.length !== 6) return false;
  if (until && until.length !== 8) return false;
  return true;
};

const checkRules = (rule) => {
  if (rule.second && (rule.second < 0 || rule.second > 60)) return false;
  if (rule.minute && (rule.minute < 0 || rule.minute > 60)) return false;
  if (rule.hour && (rule.hour < 0 || rule.hour > 24)) return false;
  if (rule.dayOfWeek.some((elem) => elem > 6 || elem < 0)) return false;
  return true;
};

const runAction = (trigger, actionFrom) => {
  const action = actionFrom;
  let timeout = 0;

  if (action.delay && action.delay > 0) timeout = action.delay;

  setTimeout(() => {
    if (action.action.options) action.action.options.trigger_id = trigger.id;
    commands.perform(action.action, trigger.persist);
  }, timeout);
};

const runTriggerActions = (trigger) => {
  // Update last_exec
  storage.do(
    'update',
    {
      type: 'triggers',
      id: trigger.id,
      columns: 'last_exec',
      values: new Date().getTime(),
    },
    () => {
      sendResponse('stopped', [trigger.id]);
      // storage.do('del', { type: 'triggers', id: trigger.id });
    },
  );

  trigger.automation_actions.forEach((action) => {
    runAction(trigger, action);
  });
};

exports.cancel_all = () => {
  if (runningTriggers.length > 0) {
    runningTriggers.forEach((trigger) => {
      trigger.cancel();
    });
    runningTriggers = [];
  }
  cancelHooks();
};

const toUnix = (dateData) => {
  let date = dateData;
  let newDate;
  try {
    if (/^\d+$/.test(date)) {
      const year = parseInt(date.slice(0, 4), 10);
      const month = parseInt(date.slice(4, 6), 10) - 1; // January its 0
      const day = parseInt(date.slice(6, 8), 10);
      const hour = parseInt(date.slice(8, 10), 10);
      const minute = parseInt(date.slice(10, 12), 10);
      const second = parseInt(date.slice(12, 14), 10);
      const timezoneOffset = new Date().getTimezoneOffset() * 60 * 1000; // miliseconds

      // eslint-disable-next-line max-len
      newDate = new Date(Date.UTC(year, month, day, hour, minute, second)).getTime() + timezoneOffset;
    } else {
      date = date.split('Z');
      if (date.length > 1) {
        if (date[1].length > 0) date = date[1].charAt(0) === '-' ? date.join('') : date.join('+');
        // eslint-disable-next-line prefer-destructuring
        else date = date[0];
      }
      newDate = new Date(date).getTime();
    }
  } catch (e) {
    newDate = NaN;
  }
  return parseInt(newDate, 10);
};

const setUpHooks = () => {
  if (Object.keys(eventTriggers).length === 0) return;

  Object.keys(eventTriggers).forEach((event) => {
    hooks.on(event.split('-')[0], (info) => {
      if (info && info.id && event.split('-')[1] !== info.id) return;
      eventTriggers[event].forEach((actionElement) => {
        const action = actionElement;
        let timeout = 0;
        if (action.delay && action.delay > 0) timeout = action.delay;

        const date = new Date();
        if (action.repeat) {
          if (action.repeat.days_of_week.indexOf(date.getDay()) === -1) return;

          const auxDate = new Date(date.valueOf());
          const dates = checkRepeat(auxDate, action.repeat);

          if (Object.keys(dates).length === 0
          || date.getTime() < dates.from || date.getTime() > dates.until) return;
        }

        if (action.after) {
          const lastConnection = websocket.lastConnection();
          if (!lastConnection) return;
          if (lastConnection + action.after > Math.round(Date.now() / 1000)) return;
          // Don't do if it was already executed
          if (!exports.currentTriggers) return;
          const triggerIndex = exports.currentTriggers.findIndex(
            (obj) => obj.id === action.trigger_id,
          );
          if (exports.currentTriggers[triggerIndex].last_exec
            // eslint-disable-next-line no-mixed-operators
            && (typeof exports.currentTriggers[triggerIndex].last_exec === 'string'
            && exports.currentTriggers[triggerIndex].last_exec.localeCompare('null') !== 0)
            // eslint-disable-next-line no-mixed-operators
            || (typeof exports.currentTriggers[triggerIndex].last_exec === 'number')) return;
          const execTime = new Date().getTime();
          exports.currentTriggers[triggerIndex].last_exec = execTime;

          const current = exports.currentTriggers[triggerIndex];
          storage.do(
            'update',
            {
              type: 'triggers',
              id: current.id,
              columns: 'last_exec',
              values: execTime,
            },
            (err) => {
              if (err) logger.error('Unable to update the execution time');
            },
          );
        }

        if (action.range) {
          const dateFrom = action.range.from;
          const dateUntil = action.range.until;

          if (Number.isNaN(dateFrom) || Number.isNaN(dateUntil) || date.getTime() < dateFrom
          || date.getTime() > dateUntil) return;
        }

        setTimeout(() => {
          commands.perform(action.action);
        }, timeout);
      });
    });
  });
};

exports.activate_event = (trigger) => {
  let eventIndex;
  try {
    eventIndex = trigger.automation_events.findIndex((obj) => eventsList.indexOf(obj.type) > -1);
  } catch (e) {
    return 2;
  }
  if (eventIndex === -1) return 5;

  let event = trigger.automation_events[eventIndex].type;
  const { info } = trigger.automation_events[eventIndex];

  if (!eventTriggers[event]) {
    if (info && info.id) event = [event, info.id].join('-');
    eventTriggers[event] = [];
  }

  // eslint-disable-next-line consistent-return
  trigger.automation_actions.forEach((actionElementAutomation) => {
    const action = actionElementAutomation;
    // If there's an element with type 'repeat_range_time' we keep the index
    const indexRepeat = trigger.automation_events.findIndex((obj) => obj.type === 'repeat_range_time');
    const indexRange = trigger.automation_events.findIndex((obj) => obj.type === 'range_time');
    const indexAfter = trigger.automation_events.findIndex((obj) => obj.type === 'after_time');

    if (indexAfter > -1) {
      const { seconds } = trigger.automation_events[indexAfter].info;
      action.after = seconds;
    }
    let currentDate;
    if (indexRepeat > -1) {
      const days = JSON.parse(trigger.automation_events[indexRepeat].info.days_of_week);
      const hourFrom = trigger.automation_events[indexRepeat].info.hour_from;
      const hourUntil = `${trigger.automation_events[indexRepeat].info.hour_until.slice(0, -2)}59`; // Include that last minute
      const { until } = trigger.automation_events[indexRepeat].info;

      if (until) {
        const year = parseInt(until.slice(0, 4), 10);
        const month = parseInt(until.slice(4, 6), 10) - 1; // January its 0
        const day = parseInt(until.slice(6, 8), 10) + 1; // One more day, until next day at 00:00

        const endDate = new Date(year, month, day);
        currentDate = new Date();

        if (currentDate > endDate) return 3;
      }

      if (!checkRepeatFormat(days, hourFrom, hourUntil, until)) return 4;

      action.repeat = {
        days_of_week: days,
        hour_from: hourFrom,
        hour_until: hourUntil,
        until,
      };
    }

    if (indexRange > -1) {
      const dateFrom = toUnix(`${trigger.automation_events[indexRange].info.from}000000`);
      const dateUntil = toUnix(`${trigger.automation_events[indexRange].info.until}235959`);

      if (currentDate > dateUntil) return 2;
      if (!checkRangeFormat(dateFrom, dateUntil)) return 4;
      action.range = { from: dateFrom, until: dateUntil };
    }

    action.trigger_id = trigger.id;
    eventTriggers[event].push(action);
  });

  return 1;
};

/**
 * activates the trigger in prey's control panel
 * @param {object} trigger - the object with trigger info
 */
exports.activate = (trigger) => {
  let index;
  let info;
  let opts;
  try {
    index = trigger.automation_events.findIndex((obj) => obj.type === 'exact_time' || obj.type === 'repeat_time');
    info = trigger.automation_events[index].info;
  } catch (e) {
    return 2;
  }

  // EXACT TIME!!
  if (info.date) {
    opts = toUnix(info.date);

    if (Number.isNaN(opts)) return 2;

    const currentDate = new Date().getTime();

    if (currentDate > opts) {
      if ((trigger.persist === true || trigger.persist === 1) && (!trigger.last_exec || trigger.last_exec.toLowerCase() === 'null')) {
        runTriggerActions(trigger);
        return 6;
      }
      return 3;
    }

    // REPEAT TIME
  } else if (info.days_of_week && info.hour) {
    // At least the days and hour

    try {
      const rule = new schedule.RecurrenceRule();
      rule.second = parseInt(info.second, 10) || 0;
      rule.minute = parseInt(info.minute, 10) || null;
      rule.hour = parseInt(info.hour, 10) || null;
      rule.dayOfWeek = JSON.parse(info.days_of_week) || null;

      if (!checkRules(rule)) return 2;

      opts = { rule };

      if (info.until) {
        const untilDate = info.until;
        const year = parseInt(untilDate.slice(0, 4), 10);
        const month = parseInt(untilDate.slice(4, 6), 10) - 1; // January its 0
        // eslint-disable-next-line max-len
        const day = parseInt(untilDate.slice(6, 8), 10) + 1; // One more day, until next day at 00:00

        const endDate = new Date(year, month, day);
        const currentDate = new Date();

        if (currentDate > endDate) return 3;
        opts.end = endDate;
      }
    } catch (e) {
      return 2;
    }
  } else return 2;
  let indexRepeat;
  let indexRange;
  try {
    indexRepeat = trigger.automation_events.findIndex((obj) => obj.type === 'repeat_range_time');
    indexRange = trigger.automation_events.findIndex((obj) => obj.type === 'range_time');
  } catch (e) {
    return 2;
  }
  let repeat;
  if (indexRepeat > -1) {
    const repeatParams = trigger.automation_events[indexRepeat].info;
    const days = JSON.parse(repeatParams.days_of_week);
    // eslint-disable-next-line camelcase
    const { hour_from } = repeatParams;
    // eslint-disable-next-line camelcase
    const { hour_until } = repeatParams;
    const { until } = repeatParams.info;

    if (!checkRepeatFormat(days, hour_from, hour_until, until)) return 4;
    repeat = {
      days_of_week: days,
      // eslint-disable-next-line camelcase
      hour_from,
      // eslint-disable-next-line camelcase
      hour_until,
      until,
    };
  }
  let range;
  if (indexRange > -1) {
    const rangeParams = trigger.automation_events[indexRange].info;
    const dateFrom = toUnix(rangeParams.from);
    const dateUntil = toUnix(rangeParams.until);

    if (!checkRangeFormat(dateFrom, dateUntil)) return 4;
    range = { from: dateFrom, until: dateUntil };
  }

  const daTrigger = schedule.scheduleJob(opts, () => {
    const date = new Date();
    if (repeat) {
      if (repeat.days_of_week.indexOf(date.getDay()) === -1) return;

      const auxDate = new Date(date.valueOf());
      const dates = checkRepeat(auxDate, repeat);
      // eslint-disable-next-line max-len
      if (Object.keys(dates).length === 0 || date.getTime() < dates.from || date.getTime() > dates.until) return;
    }

    if (range) {
      const dateFrom = range.from;
      const dateUntil = range.until;

      if (Number.isNaN(dateFrom) || Number.isNaN(dateUntil)
        || date.getTime() < dateFrom || date.getTime() > dateUntil) return;
    }

    runTriggerActions(trigger);
  });

  if (daTrigger) {
    runningTriggers.push(daTrigger);
    return 1;
  }
  return 0;
};

const uniqueElementsFromArray = (arr1, arr2) => {
  // eslint-disable-next-line max-len
  const uniqueElements = arr1.filter((item) => !arr2.some((elem) => parseInt(elem.id, 10) === parseInt(item.id, 10)));
  return uniqueElements;
};

exports.sync = (success, id, err, triggersSync, storedSync, cb) => {
  let triggers = [...triggersSync];
  const stored = [...storedSync];
  const watching = [];

  if (err || !success) {
    if (err) logger.error(`error starting async: ${err}`);
    triggers = stored;
  }
  if (success) {
    const deletedElements = uniqueElementsFromArray(stored, triggers);
    if (deletedElements.length > 0) {
      deletedElements.forEach((elemTrigger) => {
        storage.do('del', { type: 'triggers', id: elemTrigger.id });
      });
    }
  }

  exports.currentTriggers = [...triggers];
  const lookup = {};
  stored.forEach((element) => {
    lookup[element.id] = element;
  });
  exports.currentTriggers = exports.currentTriggers.map((elementA) => {
    const elementB = lookup[elementA.id];
    if (elementB) {
      return { ...elementA, last_exec: elementB.last_exec, synced_at: elementB.synced_at };
    }
    return elementA;
  });
  // eslint-disable-next-line max-len
  exports.cancel_all();
  eventTriggers = {};

  // iterate over only active and filtered triggers
  exports.currentTriggers.forEach((triggerToWatch, index) => {
    const trigger = triggerToWatch;
    if (typeof triggerToWatch.automation_events === 'string') {
      try {
        trigger.automation_events = JSON.parse(triggerToWatch.automation_events);
        trigger.automation_actions = JSON.parse(triggerToWatch.automation_actions);
      } catch (e) {
        logger.warn(`Error parsing trigger options: ${e.message}`);
      }
    }

    if (trigger.options && trigger.options.persist === true) {
      trigger.persist = 1;
    }

    if (!trigger.synced_at) {
      trigger.synced_at = new Date().getTime();
      trigger.last_exec = null;
    }

    let state;

    try {
      if (trigger.automation_events.some((obj) => obj.type === 'exact_time' || obj.type === 'repeat_time')) {
        state = exports.activate(trigger);
      } else {
        state = exports.activate_event(trigger);
      }
    } catch (e) {
      state = 0;
    }

    const finish = () => {
      if (index === triggers.length - 1 && watching.length > 0) {
        sendResponse('started', watching);
      }
    };

    const storedIndex = stored.findIndex((x) => x.id === trigger.id);
    if (storedIndex === -1 || state !== 1) {
      watching.push({ id: trigger.id, state });
    }

    if (state !== 1 && state !== 6) {
      logger.info(`Unable to set up trigger "${trigger.name}": ${errorStatusList[state]}`);
      if (trigger.persist === false || trigger.persist == null || trigger.persist === 0) {
        sendResponse('stopped', [trigger.id]);
      }

      if (index === triggers.length - 1) done(id, null, cb);
      finish();
    } else if (stored.filter((x) => x.id === trigger.id).length === 0) {
      logger.debug(`saving stored trigger ID: ${trigger.id}`);
      if (state === 6) {
        logger.warn(`Persisting action for ${trigger.name}`);
      }

      if (!trigger.persist) trigger.persist = 0;

      const data = {
        id: trigger.id,
        name: trigger.name,
        persist: trigger.persist, // persist as initial state? // valid values are 0 or 1
        synced_at: trigger.synced_at,
        last_exec: trigger.last_exec,
        automation_events: trigger.automation_events,
        automation_actions: trigger.automation_actions,
      };

      if (!trigger.persist || trigger.persist === false || trigger.persist === 0) data.persist = 0;
      if (trigger.persist && (trigger.persist === true || trigger.persist === 1)) data.persist = 1;
      if (lookup[trigger.id]) {
        storage.do(
          'update',
          {
            type: 'triggers',
            id: trigger.id,
            columns:
            ['name', 'persist', 'automation_events', 'automation_actions'],
            values: [
              trigger.name, trigger.persist, JSON.stringify(trigger.automation_events),
              JSON.stringify(trigger.automation_actions),
            ],
          },
          (errStorageDo) => {
            if (errStorageDo) {
              logger.error(`Error updating triggers: ${errStorageDo}`);
            }
            if (index === triggers.length - 1) {
              done(id, null, cb);
            }
            finish();
          },
        );
      } else {
        storage.do(
          'set',
          { type: 'triggers', id: trigger.id, data },
          (errStorageDo) => {
            if (errStorageDo) {
              logger.error(`Error storing triggers: ${errStorageDo}`);
            }
            if (index === triggers.length - 1) {
              done(id, null, cb);
            }
            finish();
          },
        );
      }
    }
  });
  setUpHooks();
};

const handleTriggersSuccesfully = (success, id, cb, triggers = null) => {
  // eslint-disable-next-line consistent-return
  storage.do('all', { type: 'triggers' }, (error, storedTriggers) => {
    if (error || !storedTriggers) {
      return done(id, error, cb);
    }

    logger.debug(`triggers fetched from API successfully: ${success}`);
    const errData = null;
    let triggersToSync = [];

    if (success) {
      triggersToSync = triggers;
    }
    exports.sync(success, id, errData, triggersToSync, storedTriggers, () => {
      if (cb && typeof (cb) === 'function') cb(null, emitter);
    });
  });
};

const refreshTriggers = (id, cb) => {
  logger.info('retrieving triggers from API');

  emitter = emitter || new EventEmitter();
  if (timeoutStartTrigger) clearTimeout(timeoutStartTrigger);
  timeoutStartTrigger = setTimeout(() => {
    // eslint-disable-next-line consistent-return
    fetchTriggers((err, res) => {
      if (err) {
        handleTriggersSuccesfully(false, id, cb);
      } else {
        const fetchedTriggers = res.body;

        if (!(fetchedTriggers instanceof Array)) {
          return done(id, new Error('Triggers list is not an array'), cb);
        }
        handleTriggersSuccesfully(true, id, cb, fetchedTriggers);
      }
    });
  }, 8000);
};

exports.clear_triggers = (cb) => {
  logger.debug('cleaning triggers from local db');
  storage.do('clear', { type: 'triggers' }, (err) => {
    if (err) logger.error(err.message);
    return cb() && cb(err);
  });
};

// eslint-disable-next-line no-multi-assign
exports.start = exports.stop = refreshTriggers;
exports.logger = logger;