const crypto = require('crypto') const _ = require('lodash/fp') const prettyMs = require('pretty-ms') const numeral = require('numeral') const dbm = require('./postgresql_interface') const db = require('./db') const T = require('./time') const logger = require('./logger') const STALE_STATE = 7 * T.minute const NETWORK_DOWN_TIME = 1 * T.minute const ALERT_SEND_INTERVAL = T.hour const PING = 'PING' const STALE = 'STALE' const LOW_CRYPTO_BALANCE = 'LOW_CRYPTO_BALANCE' const CASH_BOX_FULL = 'CASH_BOX_FULL' const LOW_CASH_OUT = 'LOW_CASH_OUT' const CODES_DISPLAY = { PING: 'Machine Down', STALE: 'Machine Stuck', LOW_CRYPTO_BALANCE: 'Low Crypto Balance', CASH_BOX_FULL: 'Cash box full', LOW_CASH_OUT: 'Low Cash-out' } let alertFingerprint let lastAlertTime function codeDisplay (code) { return CODES_DISPLAY[code] } function jsonParse (event) { return _.set('note', JSON.parse(event.note), event) } function sameState (a, b) { return a.note.txId === b.note.txId && a.note.state === b.note.state } function sendNoAlerts (plugins) { const subject = '[Lamassu] All clear' const rec = { sms: { body: subject }, email: { subject, body: 'No errors are reported for your machines.' } } return plugins.sendMessage(rec) } function checkNotification (plugins) { if (!plugins.notificationsEnabled()) return Promise.resolve() return checkStatus(plugins) .then(alertRec => { const currentAlertFingerprint = buildAlertFingerprint(alertRec) if (!currentAlertFingerprint) { const inAlert = !!alertFingerprint alertFingerprint = null lastAlertTime = null if (inAlert) return sendNoAlerts(plugins) } const alertChanged = currentAlertFingerprint === alertFingerprint && lastAlertTime - Date.now() < ALERT_SEND_INTERVAL if (alertChanged) return const rec = { sms: { body: printSmsAlerts(alertRec) }, email: { subject: alertSubject(alertRec), body: printEmailAlerts(alertRec) } } alertFingerprint = currentAlertFingerprint lastAlertTime = Date.now() return plugins.sendMessage(rec) }) .then(results => { if (results && results.length > 0) logger.debug('Successfully sent alerts') }) .catch(logger.error) } const getDeviceTime = _.flow(_.get('device_time'), Date.parse) function dropRepeatsWith (comparator, arr) { const iteratee = (acc, val) => val === acc.last ? acc : { arr: _.concat(acc.arr, val), last: val } return _.reduce(iteratee, { arr: [] }, arr).arr } function checkStuckScreen (deviceEvents, machineName) { const sortedEvents = _.sortBy(getDeviceTime, _.map(jsonParse, deviceEvents)) const noRepeatEvents = dropRepeatsWith(sameState, sortedEvents) const lastEvent = _.last(noRepeatEvents) if (!lastEvent) { return [] } const state = lastEvent.note.state const isIdle = lastEvent.note.isIdle if (isIdle) { return [] } const age = Math.floor(lastEvent.age) if (age > STALE_STATE) { return [{ code: STALE, state, age, machineName }] } return [] } function checkPing (device) { const sql = `select (EXTRACT(EPOCH FROM (now() - updated))) * 1000 AS age from machine_pings where device_id=$1` const deviceId = device.deviceId return db.oneOrNone(sql, [deviceId]) .then(row => { if (!row) return [{ code: PING }] if (row.age > NETWORK_DOWN_TIME) return [{ code: PING, age: row.age, machineName: device.name }] return [] }) } function checkPings (devices) { const deviceIds = _.map('deviceId', devices) const promises = _.map(checkPing, devices) return Promise.all(promises) .then(_.zipObject(deviceIds)) } function checkStatus (plugins) { const alerts = { devices: {}, deviceNames: {} } return Promise.all([plugins.checkBalances(), dbm.machineEvents(), plugins.getMachineNames()]) .then(([balances, events, devices]) => { return checkPings(devices) .then(pings => { alerts.general = _.filter(r => !r.deviceId, balances) devices.forEach(function (device) { const deviceId = device.deviceId const deviceName = device.name const deviceEvents = events.filter(function (eventRow) { return eventRow.device_id === deviceId }) const balanceAlerts = _.filter(['deviceId', deviceId], balances) const ping = pings[deviceId] || [] const stuckScreen = checkStuckScreen(deviceEvents, deviceName) const deviceAlerts = _.isEmpty(ping) ? stuckScreen : ping alerts.devices[deviceId] = _.concat(deviceAlerts, balanceAlerts) alerts.deviceNames[deviceId] = deviceName }) return alerts }) }) } function formatCurrency (num, code) { return numeral(num).format('0,0.00') + ' ' + code } function emailAlert (alert) { switch (alert.code) { case PING: if (alert.age) { const pingAge = prettyMs(alert.age, { compact: true, verbose: true }) return `Machine down for ${pingAge}` } return 'Machine down for a while.' case STALE: const stuckAge = prettyMs(alert.age, { compact: true, verbose: true }) return `Machine is stuck on ${alert.state} screen for ${stuckAge}` case LOW_CRYPTO_BALANCE: const balance = formatCurrency(alert.fiatBalance.balance, alert.fiatCode) return `Low balance in ${alert.cryptoCode} [${balance}]` case CASH_BOX_FULL: return `Cash box full on ${alert.machineName} [${alert.notes} banknotes]` case LOW_CASH_OUT: return `Cassette for ${alert.denomination} ${alert.fiatCode} low [${alert.notes} banknotes]` } } function emailAlerts (alerts) { return alerts.map(emailAlert).join('\n') + '\n' } function printEmailAlerts (alertRec) { let body = 'Errors were reported by your Lamassu Machines.\n' if (alertRec.general.length !== 0) { body = body + '\nGeneral errors:\n' body = body + emailAlerts(alertRec.general) } _.keys(alertRec.devices).forEach(function (device) { const deviceName = alertRec.deviceNames[device] body = body + '\nErrors for ' + deviceName + ':\n' body = body + emailAlerts(alertRec.devices[device]) }) return body } function alertSubject (alertRec) { let alerts = alertRec.general _.keys(alertRec.devices).forEach(function (device) { alerts = _.concat(alerts, alertRec.devices[device]) }) if (alerts.length === 0) return null const alertTypes = _.map(codeDisplay, _.uniq(_.map('code', alerts))).sort() return '[Lamassu] Errors reported: ' + alertTypes.join(', ') } function printSmsAlerts (alertRec) { let alerts = alertRec.general _.keys(alertRec.devices).forEach(function (device) { alerts = _.concat(alerts, alertRec.devices[device]) }) if (alerts.length === 0) return null const alertsMap = _.groupBy('code', alerts) const alertTypes = _.map(entry => { const code = entry[0] const machineNames = _.filter(_.negate(_.isEmpty), _.map('machineName', entry[1])) return { codeDisplay: codeDisplay(code), machineNames } }, _.toPairs(alertsMap)) const mapByCodeDisplay = _.map(it => _.isEmpty(it.machineNames) ? it.codeDisplay : `${it.codeDisplay} (${it.machineNames.join(', ')})` ) const displayAlertTypes = _.compose(_.uniq, mapByCodeDisplay, _.sortBy('codeDisplay'))(alertTypes) return '[Lamassu] Errors reported: ' + displayAlertTypes.join(', ') } function buildAlertFingerprint (alertRec) { const subject = alertSubject(alertRec) if (!subject) return null return crypto.createHash('sha256').update(subject).digest('hex') } module.exports = { checkNotification }