diff --git a/config.js b/config.js index a4e82a4..c3887e4 100644 --- a/config.js +++ b/config.js @@ -45,6 +45,13 @@ var config = { url: 'https://test-insight.bitpay.com:443', }, }, + pushNotificationsOpts: { + templatePath: './lib/templates', + defaultLanguage: 'en', + defaultUnit: 'btc', + subjectPrefix: '', + pushServerUrl: 'http://localhost:8000/send', + }, // To use email notifications uncomment this: // emailOpts: { // host: 'localhost', @@ -60,5 +67,6 @@ var config = { // testnet: 'https://test-insight.bitpay.com/tx/{{txid}}', // }, //}, + }; module.exports = config; diff --git a/lib/pushnotificationsservice.js b/lib/pushnotificationsservice.js new file mode 100644 index 0000000..8d8b81c --- /dev/null +++ b/lib/pushnotificationsservice.js @@ -0,0 +1,353 @@ +'use strict'; + +var _ = require('lodash'); +var async = require('async'); +var Mustache = require('mustache'); +var defaultRequest = require('request'); +var MessageBroker = require('./messagebroker'); +var Storage = require('./storage'); +var fs = require('fs'); +var path = require('path'); +var Utils = require('./common/utils'); +var Model = require('./model'); +var log = require('npmlog'); +log.debug = log.verbose; + +var PUSHNOTIFICATIONS_TYPES = { + 'NewCopayer': { + filename: 'new_copayer', + }, + 'WalletComplete': { + filename: 'wallet_complete', + }, + 'NewTxProposal': { + filename: 'new_tx_proposal', + }, + 'NewOutgoingTx': { + filename: 'new_outgoing_tx', + }, + 'NewIncomingTx': { + filename: 'new_incoming_tx', + }, + 'TxProposalFinallyRejected': { + filename: 'txp_finally_rejected', + }, +}; + +function PushNotificationsService() {}; + +PushNotificationsService.prototype.start = function(opts, cb) { + var self = this; + opts = opts || {}; + self.request = opts.request || defaultRequest; + + function _readDirectories(basePath, cb) { + fs.readdir(basePath, function(err, files) { + if (err) return cb(err); + async.filter(files, function(file, next) { + fs.stat(path.join(basePath, file), function(err, stats) { + return next(!err && stats.isDirectory()); + }); + }, function(dirs) { + return cb(null, dirs); + }); + }); + }; + + self.templatePath = path.normalize((opts.pushNotificationsOpts.templatePath || (__dirname + '/templates')) + '/'); + self.defaultLanguage = opts.pushNotificationsOpts.defaultLanguage || 'en'; + self.defaultUnit = opts.pushNotificationsOpts.defaultUnit || 'btc'; + self.subjectPrefix = opts.pushNotificationsOpts.subjectPrefix || ''; + self.pushServerUrl = opts.pushNotificationsOpts.pushServerUrl; + async.parallel([ + + function(done) { + _readDirectories(self.templatePath, function(err, res) { + self.availableLanguages = res; + done(err); + }); + }, + function(done) { + if (opts.storage) { + self.storage = opts.storage; + done(); + } else { + self.storage = new Storage(); + self.storage.connect(opts.storageOpts, done); + } + }, + function(done) { + self.messageBroker = opts.messageBroker || new MessageBroker(opts.messageBrokerOpts); + self.messageBroker.onMessage(_.bind(self._sendPushNotifications, self)); + done(); + }, + ], function(err) { + if (err) { + log.error(err); + } + return cb(err); + }); + +}; + +PushNotificationsService.prototype._sendPushNotifications = function(notification, cb) { + var self = this; + cb = cb || function() {}; + + var notifType = PUSHNOTIFICATIONS_TYPES[notification.type]; + if (!notifType) return cb(); + + // console.log(notification); + + self._checkShouldSendNotif(notification, function(err, should) { + if (err) return cb(err); + if (!should) return cb(); + + self._getRecipientsList(notification, function(err, recipientsList) { + if (err) return cb(err); + + async.waterfall([ + + function(next) { + self._readAndApplyTemplates(notification, notifType, recipientsList, next); + }, + function(contents, next) { + async.map(recipientsList, function(recipient, next) { + var opts = {}; + var content = contents[recipient.language]; + opts.users = [notification.walletId + '$' + recipient.copayerId]; + opts.android = { + "data": { + "title": content.plain.subject, + "message": content.plain.body + } + }; + opts.ios = { + "alert": content.plain.body, + "sound": "" + }; + return next(err, opts); + }, next); + }, + function(optsList, next) { + async.each(optsList, + function(opts, next) { + self._makeRequest(opts, function(err, response) { + if (err) log.error(err); + log.debug('Post status : ', response); + next(); + }); + }, + function(err) { + return next(err); + } + ); + }, + ], function(err) { + if (err) { + log.error('An error ocurred generating notification', err); + } + return cb(err); + }); + }); + }); +}; + +PushNotificationsService.prototype._checkShouldSendNotif = function(notification, cb) { + var self = this; + + if (notification.type != 'NewTxProposal') return cb(null, true); + self.storage.fetchWallet(notification.walletId, function(err, wallet) { + return cb(err, wallet.m > 1); + }); +}; + +PushNotificationsService.prototype._getRecipientsList = function(notification, cb) { + var self = this; + + self.storage.fetchWallet(notification.walletId, function(err, wallet) { + if (err) return cb(err); + + self.storage.fetchPreferences(notification.walletId, null, function(err, preferences) { + + if (err) log.error(err); + if (_.isEmpty(preferences)) preferences = []; + + var recipientPreferences = _.compact(_.map(preferences, function(p) { + + if (!_.contains(self.availableLanguages, p.language)) { + if (p.language) + log.warn('Language for notifications "' + p.language + '" not available.'); + p.language = self.defaultLanguage; + } + + return { + copayerId: p.copayerId, + language: p.language, + unit: p.unit, + }; + })); + + recipientPreferences = _.indexBy(recipientPreferences, 'copayerId'); + + var recipientsList = _.reject(_.map(wallet.copayers, function(copayer) { + var p = recipientPreferences[copayer.id] || {}; + return { + copayerId: copayer.id, + language: p.language || self.defaultLanguage, + unit: p.unit || self.defaultUnit, + } + }), { + copayerId: notification.creatorId + }); + + return cb(null, recipientsList); + }); + }); +}; + +PushNotificationsService.prototype._readAndApplyTemplates = function(notification, notifType, recipientsList, cb) { + var self = this; + + async.map(recipientsList, function(recipient, next) { + async.waterfall([ + + function(next) { + self._getDataForTemplate(notification, recipient, next); + }, + function(data, next) { + async.map(['plain', 'html'], function(type, next) { + self._loadTemplate(notifType, recipient, '.' + type, function(err, template) { + if (err && type == 'html') return next(); + if (err) return next(err); + + self._applyTemplate(template, data, function(err, res) { + return next(err, [type, res]); + }); + }); + }, function(err, res) { + return next(err, _.zipObject(res)); + }); + }, + function(result, next) { + next(null, result); + }, + ], function(err, res) { + next(err, [recipient.language, res]); + }); + }, function(err, res) { + return cb(err, _.zipObject(res)); + }); +}; + +PushNotificationsService.prototype._getDataForTemplate = function(notification, recipient, cb) { + var self = this; + var UNIT_LABELS = { + btc: 'BTC', + bit: 'bits' + }; + + var data = _.cloneDeep(notification.data); + data.subjectPrefix = _.trim(self.subjectPrefix + ' '); + if (data.amount) { + try { + var unit = recipient.unit.toLowerCase(); + data.amount = Utils.formatAmount(+data.amount, unit) + ' ' + UNIT_LABELS[unit]; + } catch (ex) { + return cb(new Error('Could not format amount', ex)); + } + } + + self.storage.fetchWallet(notification.walletId, function(err, wallet) { + if (err) return cb(err); + + data.walletId = wallet.id; + data.walletName = wallet.name; + data.walletM = wallet.m; + data.walletN = wallet.n; + + var copayer = _.find(wallet.copayers, { + id: notification.creatorId + }); + + if (copayer) { + data.copayerId = copayer.id; + data.copayerName = copayer.name; + } + + if (notification.type == 'TxProposalFinallyRejected' && data.rejectedBy) { + var rejectors = _.map(data.rejectedBy, function(copayerId) { + return _.find(wallet.copayers, { + id: copayerId + }).name + }); + data.rejectorsNames = rejectors.join(', '); + } + + return cb(null, data); + }); +}; + +PushNotificationsService.prototype._applyTemplate = function(template, data, cb) { + if (!data) return cb(new Error('Could not apply template to empty data')); + + var error; + var result = _.mapValues(template, function(t) { + try { + return Mustache.render(t, data); + } catch (e) { + log.error('Could not apply data to template', e); + error = e; + } + }); + + if (error) return cb(error); + return cb(null, result); +}; + +PushNotificationsService.prototype._loadTemplate = function(notifType, recipient, extension, cb) { + var self = this; + + self._readTemplateFile(recipient.language, notifType.filename + extension, function(err, template) { + if (err) return cb(err); + return cb(null, self._compileTemplate(template, extension)); + }); +}; + +PushNotificationsService.prototype._readTemplateFile = function(language, filename, cb) { + var self = this; + + var fullFilename = path.join(self.templatePath, language, filename); + fs.readFile(fullFilename, 'utf8', function(err, template) { + if (err) { + return cb(new Error('Could not read template file ' + fullFilename, err)); + } + return cb(null, template); + }); +}; + +PushNotificationsService.prototype._compileTemplate = function(template, extension) { + var lines = template.split('\n'); + if (extension == '.html') { + lines.unshift(''); + } + return { + subject: lines[0], + body: _.rest(lines).join('\n'), + }; +}; + +PushNotificationsService.prototype._makeRequest = function(opts, cb) { + var self = this; + + self.request({ + url: self.pushServerUrl, + method: 'POST', + json: true, + body: opts + }, function(err, response) { + return cb(err, response); + }); +}; + +module.exports = PushNotificationsService; diff --git a/pushnotificationsservice/pushnotificationsservice.js b/pushnotificationsservice/pushnotificationsservice.js new file mode 100644 index 0000000..b0acaeb --- /dev/null +++ b/pushnotificationsservice/pushnotificationsservice.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node + +'use strict'; + +var log = require('npmlog'); +log.debug = log.verbose; + +var config = require('../config'); +var PushNotificationsService = require('../lib/pushnotificationsservice'); + +var pushNotificationsService = new PushNotificationsService(); +pushNotificationsService.start(config, function(err) { + if (err) throw err; + + console.log('Push Notification Service started'); +}); diff --git a/start.sh b/start.sh index f979313..bfe1e55 100755 --- a/start.sh +++ b/start.sh @@ -33,5 +33,6 @@ run_program locker/locker.js pids/locker.pid logs/locker.log run_program messagebroker/messagebroker.js pids/messagebroker.pid logs/messagebroker.log run_program bcmonitor/bcmonitor.js pids/bcmonitor.pid logs/bcmonitor.log run_program emailservice/emailservice.js pids/emailservice.pid logs/emailservice.log +run_program pushnotificationsservice/pushnotificationsservice.js pids/pushnotificationsservice.pid logs/pushnotificationsservice.log run_program bws.js pids/bws.pid logs/bws.log diff --git a/stop.sh b/stop.sh index 4b80f0b..b15de9d 100755 --- a/stop.sh +++ b/stop.sh @@ -13,6 +13,7 @@ stop_program () stop_program pids/bws.pid stop_program pids/emailservice.pid stop_program pids/bcmonitor.pid +stop_program pids/pushnotificationsservice.pid stop_program pids/messagebroker.pid stop_program pids/locker.pid diff --git a/test/integration/pushNotifications.js b/test/integration/pushNotifications.js new file mode 100644 index 0000000..c71d837 --- /dev/null +++ b/test/integration/pushNotifications.js @@ -0,0 +1,490 @@ +'use strict'; + +var _ = require('lodash'); +var async = require('async'); + +var chai = require('chai'); +var sinon = require('sinon'); +var should = chai.should(); +var log = require('npmlog'); +log.debug = log.verbose; +log.level = 'info'; + +var WalletService = require('../../lib/server'); +var PushNotificationsService = require('../../lib/pushnotificationsservice'); + +var TestData = require('../testdata'); +var helpers = require('./helpers'); + +describe('Push notifications', function() { + var server, wallet, requestStub, pushNotificationsService, walletId; + + before(function(done) { + helpers.before(done); + }); + after(function(done) { + helpers.after(done); + }); + + describe('Single wallet', function() { + beforeEach(function(done) { + helpers.beforeEach(function(res) { + helpers.createAndJoinWallet(1, 1, function(s, w) { + server = s; + wallet = w; + + var i = 0; + async.eachSeries(w.copayers, function(copayer, next) { + helpers.getAuthServer(copayer.id, function(server) { + server.savePreferences({ + email: 'copayer' + (++i) + '@domain.com', + language: 'en', + unit: 'bit', + }, next); + }); + }, function(err) { + should.not.exist(err); + + requestStub = sinon.stub(); + requestStub.yields(); + + pushNotificationsService = new PushNotificationsService(); + pushNotificationsService.start({ + lockOpts: {}, + messageBroker: server.messageBroker, + storage: helpers.getStorage(), + request: requestStub, + pushNotificationsOpts: { + templatePath: './lib/templates', + defaultLanguage: 'en', + defaultUnit: 'btc', + subjectPrefix: '', + + pushServerUrl: 'http://localhost:8000/send', + }, + }, function(err) { + should.not.exist(err); + done(); + }); + }); + }); + }); + }); + + it('should build each notifications using preferences of the copayers', function(done) { + server.savePreferences({ + language: 'en', + unit: 'bit', + }, function(err) { + server.createAddress({}, function(err, address) { + should.not.exist(err); + + // Simulate incoming tx notification + server._notify('NewIncomingTx', { + txid: '999', + address: address, + amount: 12300000, + }, { + isGlobal: true + }, function(err) { + setTimeout(function() { + var calls = requestStub.getCalls(); + var args = _.map(calls, function(c) { + return c.args[0]; + }); + calls.length.should.equal(1); + args[0].body.android.data.title.should.contain('New payment received'); + args[0].body.android.data.message.should.contain('123,000'); + done(); + }, 100); + }); + }); + }); + }); + + it('should not notify auto-payments to creator', function(done) { + server.createAddress({}, function(err, address) { + should.not.exist(err); + + // Simulate incoming tx notification + server._notify('NewIncomingTx', { + txid: '999', + address: address, + amount: 12300000, + }, { + isGlobal: false + }, function(err) { + setTimeout(function() { + var calls = requestStub.getCalls(); + calls.length.should.equal(0); + done(); + }, 100); + }); + }); + }); + + it('should notify copayers when payment is received', function(done) { + server.createAddress({}, function(err, address) { + should.not.exist(err); + + // Simulate incoming tx notification + server._notify('NewIncomingTx', { + txid: '999', + address: address, + amount: 12300000, + }, { + isGlobal: true + }, function(err) { + setTimeout(function() { + var calls = requestStub.getCalls(); + calls.length.should.equal(1); + done(); + }, 100); + }); + }); + }); + }); + + describe('Shared wallet', function() { + beforeEach(function(done) { + helpers.beforeEach(function(res) { + helpers.createAndJoinWallet(2, 3, function(s, w) { + server = s; + wallet = w; + var i = 0; + async.eachSeries(w.copayers, function(copayer, next) { + helpers.getAuthServer(copayer.id, function(server) { + server.savePreferences({ + email: 'copayer' + (++i) + '@domain.com', + language: 'en', + unit: 'bit', + }, next); + }); + }, function(err) { + should.not.exist(err); + + requestStub = sinon.stub(); + requestStub.yields(); + + pushNotificationsService = new PushNotificationsService(); + pushNotificationsService.start({ + lockOpts: {}, + messageBroker: server.messageBroker, + storage: helpers.getStorage(), + request: requestStub, + pushNotificationsOpts: { + templatePath: './lib/templates', + defaultLanguage: 'en', + defaultUnit: 'btc', + subjectPrefix: '', + + pushServerUrl: 'http://localhost:8000/send', + }, + }, function(err) { + should.not.exist(err); + done(); + }); + }); + }); + }); + }); + + it('should build each notifications using preferences of the copayers', function(done) { + server.savePreferences({ + email: 'copayer1@domain.com', + language: 'es', + unit: 'btc', + }, function(err) { + server.createAddress({}, function(err, address) { + should.not.exist(err); + + // Simulate incoming tx notification + server._notify('NewIncomingTx', { + txid: '999', + address: address, + amount: 12300000, + }, { + isGlobal: true + }, function(err) { + setTimeout(function() { + var calls = requestStub.getCalls(); + var args = _.map(calls, function(c) { + return c.args[0]; + }); + + calls.length.should.equal(3); + + args[0].body.android.data.title.should.contain('Nuevo pago recibido'); + args[0].body.android.data.message.should.contain('0.123'); + + args[1].body.android.data.title.should.contain('New payment received'); + args[1].body.android.data.message.should.contain('123,000'); + + args[2].body.android.data.title.should.contain('New payment received'); + args[2].body.android.data.message.should.contain('123,000'); + done(); + }, 100); + }); + }); + }); + }); + + it('should notify copayers when payment is received', function(done) { + server.createAddress({}, function(err, address) { + should.not.exist(err); + + // Simulate incoming tx notification + server._notify('NewIncomingTx', { + txid: '999', + address: address, + amount: 12300000, + }, { + isGlobal: true + }, function(err) { + setTimeout(function() { + var calls = requestStub.getCalls(); + calls.length.should.equal(3); + + done(); + }, 100); + }); + }); + }); + + it('should not notify auto-payments to creator', function(done) { + server.createAddress({}, function(err, address) { + should.not.exist(err); + + // Simulate incoming tx notification + server._notify('NewIncomingTx', { + txid: '999', + address: address, + amount: 12300000, + }, { + isGlobal: false + }, function(err) { + setTimeout(function() { + var calls = requestStub.getCalls(); + calls.length.should.equal(2); + + done(); + }, 100); + }); + }); + }); + + it('should notify copayers a new tx proposal has been created', function(done) { + helpers.stubUtxos(server, wallet, [1, 1], function() { + var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.8, TestData.copayers[0].privKey_1H_0, { + message: 'some message' + }); + server.createAddress({}, function(err, address) { + should.not.exist(err); + server._notify('NewTxProposal', { + txid: '999', + address: address, + amount: 12300000, + }, { + isGlobal: false + }, function(err) { + setTimeout(function() { + var calls = requestStub.getCalls(); + calls.length.should.equal(2); + + done(); + }, 100); + }); + }); + }); + }); + + it('should notify copayers a tx has been finally rejected', function(done) { + helpers.stubUtxos(server, wallet, 1, function() { + var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.8, TestData.copayers[0].privKey_1H_0, { + message: 'some message' + }); + + var txpId; + async.waterfall([ + + function(next) { + server.createTxLegacy(txOpts, next); + }, + function(txp, next) { + txpId = txp.id; + async.eachSeries(_.range(1, 3), function(i, next) { + var copayer = TestData.copayers[i]; + helpers.getAuthServer(copayer.id44, function(server) { + server.rejectTx({ + txProposalId: txp.id, + }, next); + }); + }, next); + }, + ], function(err) { + should.not.exist(err); + + setTimeout(function() { + var calls = requestStub.getCalls(); + var args = _.map(_.takeRight(calls, 2), function(c) { + return c.args[0]; + }); + + args[0].body.android.data.title.should.contain('Payment proposal rejected'); + args[0].body.android.data.message.should.contain('copayer 2, copayer 3'); + args[0].body.android.data.message.should.not.contain('copayer 1'); + done(); + }, 100); + }); + }); + }); + + it('should notify copayers a new outgoing tx has been created', function(done) { + helpers.stubUtxos(server, wallet, 1, function() { + var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.8, TestData.copayers[0].privKey_1H_0, { + message: 'some message' + }); + + var txp; + async.waterfall([ + + function(next) { + server.createTxLegacy(txOpts, next); + }, + function(t, next) { + txp = t; + async.eachSeries(_.range(1, 3), function(i, next) { + var copayer = TestData.copayers[i]; + helpers.getAuthServer(copayer.id44, function(s) { + server = s; + var signatures = helpers.clientSign(txp, copayer.xPrivKey_44H_0H_0H); + server.signTx({ + txProposalId: txp.id, + signatures: signatures, + }, function(err, t) { + txp = t; + next(); + }); + }); + }, next); + }, + function(next) { + helpers.stubBroadcast(); + server.broadcastTx({ + txProposalId: txp.id, + }, next); + }, + ], function(err) { + should.not.exist(err); + + setTimeout(function() { + var calls = requestStub.getCalls(); + var args = _.map(_.takeRight(calls, 2), function(c) { + return c.args[0]; + }); + + args[0].body.android.data.title.should.contain('Payment sent'); + args[1].body.android.data.title.should.contain('Payment sent'); + + server.copayerId.should.not.equal((args[0].body.users[0]).split('$')[1]); + server.copayerId.should.not.equal((args[1].body.users[0]).split('$')[1]); + done(); + }, 100); + }); + }); + }); + }); + + describe('joinWallet', function() { + beforeEach(function(done) { + helpers.beforeEach(function(res) { + server = new WalletService(); + var walletOpts = { + name: 'my wallet', + m: 1, + n: 3, + pubKey: TestData.keyPair.pub, + }; + server.createWallet(walletOpts, function(err, wId) { + should.not.exist(err); + walletId = wId; + should.exist(walletId); + requestStub = sinon.stub(); + requestStub.yields(); + + pushNotificationsService = new PushNotificationsService(); + pushNotificationsService.start({ + lockOpts: {}, + messageBroker: server.messageBroker, + storage: helpers.getStorage(), + request: requestStub, + pushNotificationsOpts: { + templatePath: './lib/templates', + defaultLanguage: 'en', + defaultUnit: 'btc', + subjectPrefix: '', + pushServerUrl: 'http://localhost:8000/send', + }, + }, function(err) { + should.not.exist(err); + done(); + }); + }); + }); + }); + + it('should notify copayers when a new copayer just joined into your wallet except the one who joined', function(done) { + async.eachSeries(_.range(3), function(i, next) { + var copayerOpts = helpers.getSignedCopayerOpts({ + walletId: walletId, + name: 'copayer ' + (i + 1), + xPubKey: TestData.copayers[i].xPubKey_44H_0H_0H, + requestPubKey: TestData.copayers[i].pubKey_1H_0, + customData: 'custom data ' + (i + 1), + }); + + server.joinWallet(copayerOpts, next); + }, function(err) { + should.not.exist(err); + setTimeout(function() { + var calls = requestStub.getCalls(); + var args = _.map(calls, function(c) { + return c.args[0]; + }); + + var argu = _.compact(_.map(args, function(a) { + if (a.body.android.data.title == 'New copayer') + return a; + })); + + server.getWallet(null, function(err, w) { + /* + First call - copayer2 joined + copayer2 should notify to copayer1 + copayer2 should NOT be notifyed + */ + w.copayers[0].id.should.contain((argu[0].body.users[0]).split('$')[1]); + w.copayers[1].id.should.not.contain((argu[0].body.users[0]).split('$')[1]); + + /* + Second call - copayer3 joined + copayer3 should notify to copayer1 + */ + w.copayers[0].id.should.contain((argu[1].body.users[0]).split('$')[1]); + + /* + Third call - copayer3 joined + copayer3 should notify to copayer2 + */ + w.copayers[1].id.should.contain((argu[2].body.users[0]).split('$')[1]); + + // copayer3 should NOT notify any other copayer + w.copayers[2].id.should.not.contain((argu[1].body.users[0]).split('$')[1]); + w.copayers[2].id.should.not.contain((argu[2].body.users[0]).split('$')[1]); + done(); + }); + }, 100); + }); + }); + }); +});