347 lines
9.9 KiB
JavaScript
347 lines
9.9 KiB
JavaScript
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 HIGH_CRYPTO_BALANCE = 'HIGH_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',
|
|
HIGH_CRYPTO_BALANCE: 'High 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, smsEnabled, emailEnabled) {
|
|
const subject = '[Lamassu] All clear'
|
|
|
|
let rec = {}
|
|
if (smsEnabled) {
|
|
rec = _.set(['sms', 'body'])(subject)(rec)
|
|
}
|
|
|
|
if (emailEnabled) {
|
|
rec = _.set(['email', 'subject'])(subject)(rec)
|
|
rec = _.set(['email', 'body'])('No errors are reported for your machines.')(rec)
|
|
}
|
|
|
|
return plugins.sendMessage(rec)
|
|
}
|
|
|
|
function checkNotification (plugins) {
|
|
const notifications = plugins.getNotificationConfig()
|
|
const isActive = it => it.active && (it.balance || it.errors)
|
|
const smsEnabled = isActive(notifications.sms)
|
|
const emailEnabled = isActive(notifications.email)
|
|
|
|
if (!smsEnabled && !emailEnabled) return Promise.resolve()
|
|
|
|
return checkStatus(plugins)
|
|
.then(alertRec => {
|
|
const currentAlertFingerprint = buildAlertFingerprint(alertRec, notifications)
|
|
if (!currentAlertFingerprint) {
|
|
const inAlert = !!alertFingerprint
|
|
alertFingerprint = null
|
|
lastAlertTime = null
|
|
if (inAlert) return sendNoAlerts(plugins, smsEnabled, emailEnabled)
|
|
}
|
|
|
|
const alertChanged = currentAlertFingerprint === alertFingerprint &&
|
|
lastAlertTime - Date.now() < ALERT_SEND_INTERVAL
|
|
if (alertChanged) return
|
|
|
|
let rec = {}
|
|
if (smsEnabled) {
|
|
rec = _.set(['sms', 'body'])(printSmsAlerts(alertRec, notifications.sms))(rec)
|
|
}
|
|
|
|
if (emailEnabled) {
|
|
rec = _.set(['email', 'subject'])(alertSubject(alertRec, notifications.email))(rec)
|
|
rec = _.set(['email', 'body'])(printEmailAlerts(alertRec, notifications.email))(rec)
|
|
}
|
|
|
|
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 ping = pings[deviceId] || []
|
|
const stuckScreen = checkStuckScreen(deviceEvents, deviceName)
|
|
|
|
if (!alerts.devices[deviceId]) alerts.devices[deviceId] = {}
|
|
alerts.devices[deviceId].balanceAlerts = _.filter(['deviceId', deviceId], balances)
|
|
alerts.devices[deviceId].deviceAlerts = _.isEmpty(ping) ? stuckScreen : ping
|
|
|
|
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 HIGH_CRYPTO_BALANCE:
|
|
const highBalance = formatCurrency(alert.fiatBalance.balance, alert.fiatCode)
|
|
return `High balance in ${alert.cryptoCode} [${highBalance}]`
|
|
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, config) {
|
|
let body = 'Errors were reported by your Lamassu Machines.\n'
|
|
|
|
if (config.balance && alertRec.general.length !== 0) {
|
|
body = body + '\nGeneral errors:\n'
|
|
body = body + emailAlerts(alertRec.general) + '\n'
|
|
}
|
|
|
|
_.keys(alertRec.devices).forEach(function (device) {
|
|
const deviceName = alertRec.deviceNames[device]
|
|
body = body + '\nErrors for ' + deviceName + ':\n'
|
|
|
|
let alerts = []
|
|
if (config.balance) {
|
|
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
|
}
|
|
|
|
if (config.errors) {
|
|
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
|
}
|
|
|
|
body = body + emailAlerts(alerts)
|
|
})
|
|
|
|
return body
|
|
}
|
|
|
|
function alertSubject (alertRec, config) {
|
|
let alerts = []
|
|
|
|
if (config.balance) {
|
|
alerts = _.concat(alerts, alertRec.general)
|
|
}
|
|
|
|
_.keys(alertRec.devices).forEach(function (device) {
|
|
if (config.balance) {
|
|
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
|
}
|
|
|
|
if (config.errors) {
|
|
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
|
}
|
|
})
|
|
|
|
if (alerts.length === 0) return null
|
|
|
|
const alertTypes = _.map(codeDisplay, _.uniq(_.map('code', alerts))).sort()
|
|
return '[Lamassu] Errors reported: ' + alertTypes.join(', ')
|
|
}
|
|
|
|
function printSmsAlerts (alertRec, config) {
|
|
let alerts = []
|
|
|
|
if (config.balance) {
|
|
alerts = _.concat(alerts, alertRec.general)
|
|
}
|
|
|
|
_.keys(alertRec.devices).forEach(function (device) {
|
|
if (config.balance) {
|
|
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
|
}
|
|
|
|
if (config.errors) {
|
|
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
|
}
|
|
})
|
|
|
|
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 getAlertTypes (alertRec, config) {
|
|
let alerts = []
|
|
|
|
if (!config.active || (!config.balance && !config.errors)) return alerts
|
|
|
|
if (config.balance) {
|
|
alerts = _.concat(alerts, alertRec.general)
|
|
}
|
|
|
|
_.keys(alertRec.devices).forEach(function (device) {
|
|
if (config.balance) {
|
|
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
|
}
|
|
|
|
if (config.errors) {
|
|
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
|
}
|
|
})
|
|
|
|
return alerts
|
|
}
|
|
|
|
function buildAlertFingerprint (alertRec, notifications) {
|
|
const sms = getAlertTypes(alertRec, notifications.sms)
|
|
const email = getAlertTypes(alertRec, notifications.email)
|
|
|
|
if (sms.length === 0 && email.length === 0) return null
|
|
|
|
const smsTypes = _.map(codeDisplay, _.uniq(_.map('code', sms))).sort()
|
|
const emailTypes = _.map(codeDisplay, _.uniq(_.map('code', email))).sort()
|
|
|
|
const subject = _.concat(smsTypes, emailTypes).join(', ')
|
|
|
|
return crypto.createHash('sha256').update(subject).digest('hex')
|
|
}
|
|
|
|
module.exports = { checkNotification }
|