Push notifications (#7682)

* Fix Social Push notifications

* Fix code formatting issues

* Fix commented issues

* Fix Syntax errors

* update push notify dependency

* specify push-notify version

* change how apn key is loaded

* feat(push-notifications): improve logging

* feat(push-notifications): disable v2 push notifications

* test(push-notifications): add unit tests and improve integration ones

* fix(push-notifications): throw when required params are missing

* fix(tests): correct descriptions and remove wrong comment

* fix(push-notifications): trim APN key

* fix(apn): log feedback only if it has data

* fix(apn): load cert and key differently

* fix(tests): correctly load apn during tests

* download creds from S3 and create AWS lib

* convert s3 buffer to a string

* fix(apn): remove console.log and do not use cert twice

* invert key and cert, disable failing test

* invert key and cert
This commit is contained in:
Matteo Pagliazzi
2016-06-23 00:19:37 +02:00
committed by GitHub
parent 09c7c45cd5
commit 08d7727881
26 changed files with 626 additions and 293 deletions

View File

@@ -108,10 +108,12 @@
"emailNotifications": "Email Notifications",
"wonChallenge": "You won a Challenge!",
"newPM": "Received Private Message",
"newPMInfo": "New Message from <%= name %>: <%= message %>",
"sentGems": "Sent gems!",
"giftedGems": "Gifted Gems",
"giftedGemsInfo": "<%= amount %> Gems - by <%= name %>",
"giftedGemsInfo": "<%= name %> gifted you <%= amount %> Gems",
"giftedSubscription": "Gifted Subscription",
"giftedSubscriptionInfo": "<%= name %> gifted you a <%= months %> Subscription",
"invitedParty": "Invited To Party",
"invitedGuild": "Invited To Guild",
"importantAnnouncements": "Your account is inactive",
@@ -126,6 +128,7 @@
"unsubscribedTextOthers": "You won't receive any other email from Habitica.",
"unsubscribeAllEmails": "Check to Unsubscribe from Emails",
"unsubscribeAllEmailsText": "By checking this box, I certify that I understand that by unsubscribing from all emails, Habitica will never be able to notify me via email about important changes to the site or my account.",
"unsubscribeAllPush": "Check to Unsubscribe from all Push Notifications",
"correctlyUnsubscribedEmailType": "Correctly unsubscribed from \"<%= emailType %>\" emails.",
"subscriptionRateText": "Recurring $<%= price %> USD every <%= months %> months",
"recurringText": "recurring",
@@ -138,8 +141,8 @@
"promoCode": "Promo Code",
"promoCodeApplied": "Promo Code Applied! Check your inventory",
"promoPlaceholder": "Enter Promotion Code",
"couponText": "We sometimes have events and give out coupon codes for special gear. (eg, those who stop by our Wondercon booth)",
"displayInviteToPartyWhenPartyIs1": "Display Invite To Party button when party has 1 member.",
"couponText": "We sometimes have events and give out coupon codes for special gear. (eg, those who stop by our Wondercon booth)",
"saveCustomDayStart": "Save Custom Day Start",
"registration": "Registration",
"addLocalAuth": "Add local authentication:",
@@ -152,8 +155,11 @@
"invalidUrl": "invalid url",
"invalidEnabled": "the \"enabled\" parameter should be a boolean",
"regIdRequired": "RegId is required",
"invalidPushClient": "Invalid client. Only Official Habitica clients can receive push notifications.",
"pushDeviceAdded": "Push device added successfully",
"pushDeviceAlreadyAdded": "The user already has the push device",
"pushDeviceNotFound": "The user has no push device with this id.",
"pushDeviceRemoved": "Push device removed successfully.",
"add": "Add",
"buyGemsGoldCap": "Cap raised to <%= amount %>",
"mysticHourglass": "<%= amount %> Mystic Hourglass",
@@ -168,5 +174,6 @@
"amazonPayments": "Amazon Payments",
"timezone": "Time Zone",
"timezoneUTC": "Habitica uses the time zone set on your PC, which is: <strong><%= utc %></strong>",
"timezoneInfo": "If that time zone is wrong, first reload this page using your browser's reload or refresh button to ensure that Habitica has the most recent information. If it is still wrong, adjust the time zone on your PC and then reload this page again.<br><br> <strong>If you use Habitica on other PCs or mobile devices, the time zone must be the same on them all.</strong> If your Dailies have been resetting at the wrong time, repeat this check on all other PCs and on a browser on your mobile devices."
"timezoneInfo": "If that time zone is wrong, first reload this page using your browser's reload or refresh button to ensure that Habitica has the most recent information. If it is still wrong, adjust the time zone on your PC and then reload this page again.<br><br> <strong>If you use Habitica on other PCs or mobile devices, the time zone must be the same on them all.</strong> If your Dailies have been resetting at the wrong time, repeat this check on all other PCs and on a browser on your mobile devices.",
"push": "Push"
}

View File

@@ -151,7 +151,6 @@ import blockUser from './ops/blockUser';
import clearPMs from './ops/clearPMs';
import deletePM from './ops/deletePM';
import reroll from './ops/reroll';
import addPushDevice from './ops/addPushDevice';
import reset from './ops/reset';
import markPmsRead from './ops/markPMSRead';
@@ -191,7 +190,6 @@ api.ops = {
clearPMs,
deletePM,
reroll,
addPushDevice,
reset,
markPmsRead,
};
@@ -272,7 +270,6 @@ api.wrap = function wrapUser (user, main = true) {
addWebhook: _.partial(importedOps.addWebhook, user),
updateWebhook: _.partial(importedOps.updateWebhook, user),
deleteWebhook: _.partial(importedOps.deleteWebhook, user),
addPushDevice: _.partial(importedOps.addPushDevice, user),
clearPMs: _.partial(importedOps.clearPMs, user),
deletePM: _.partial(importedOps.deletePM, user),
blockUser: _.partial(importedOps.blockUser, user),

View File

@@ -1,41 +0,0 @@
import _ from 'lodash';
import i18n from '../i18n';
import {
BadRequest,
NotAuthorized,
} from '../libs/errors';
// TODO move to server code
module.exports = function addPushDevice (user, req = {}) {
let regId = _.get(req, 'body.regId');
if (!regId) throw new BadRequest(i18n.t('regIdRequired', req.language));
let type = _.get(req, 'body.type');
if (!type) throw new BadRequest(i18n.t('typeRequired', req.language));
if (!user.pushDevices) {
user.pushDevices = [];
}
let pushDevices = user.pushDevices;
let item = {
regId,
type,
};
let indexOfPushDevice = _.findIndex(pushDevices, {
regId: item.regId,
});
if (indexOfPushDevice !== -1) {
throw new NotAuthorized(i18n.t('pushDeviceAlreadyAdded', req.language));
}
pushDevices.push(item);
return [
user.pushDevices,
i18n.t('pushDeviceAdded', req.language),
];
};

View File

@@ -18,7 +18,6 @@ import deleteTag from './deleteTag';
import addWebhook from './addWebhook';
import updateWebhook from './updateWebhook';
import deleteWebhook from './deleteWebhook';
import addPushDevice from './addPushDevice';
import clearPMs from './clearPMs';
import deletePM from './deletePM';
import blockUser from './blockUser';
@@ -68,7 +67,6 @@ module.exports = {
addWebhook,
updateWebhook,
deleteWebhook,
addPushDevice,
clearPMs,
deletePM,
blockUser,

View File

@@ -65,10 +65,7 @@
"LOGGLY_ACCOUNT": "account",
"PUSH_CONFIGS": {
"GCM_SERVER_API_KEY": "",
"APN_PEM_FILES": {
"KEY": "key.pem",
"CERT": "cert.pem"
}
"APN_ENABLED": "true"
},
"FIREBASE": {
"APP": "app-name",

View File

@@ -78,7 +78,7 @@
"paypal-rest-sdk": "^1.2.1",
"pretty-data": "^0.40.0",
"ps-tree": "^1.0.0",
"push-notify": "^1.1.1",
"push-notify": "habitrpg/push-notify#v1.2.0",
"request": "~2.72.0",
"rimraf": "^2.4.3",
"run-sequence": "^1.1.4",

View File

@@ -0,0 +1,32 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
describe('DELETE /user/push-devices', () => {
let user;
let regId = '10';
let type = 'ios';
beforeEach(async () => {
user = await generateUser();
});
it('returns an error if user does not have the push device', async () => {
await expect(user.del(`/user/push-devices/${regId}`))
.to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('pushDeviceNotFound'),
});
});
it('removes a push device from the user', async () => {
await user.post('/user/push-devices', {type, regId});
let response = await user.del(`/user/push-devices/${regId}`);
await user.sync();
expect(response.message).to.equal(t('pushDeviceRemoved'));
expect(user.pushDevices.length).to.equal(0);
});
});

View File

@@ -1,35 +0,0 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
describe('POST /user/addPushDevice', () => {
let user;
let regId = '10';
let type = 'someRandomType';
beforeEach(async () => {
user = await generateUser();
});
it('returns an error if user already has the push device', async () => {
await user.post('/user/addPushDevice', {type, regId});
await expect(user.post('/user/addPushDevice', {type, regId}))
.to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('pushDeviceAlreadyAdded'),
});
});
// More tests in common code unit tests
it('adds a push device to the user', async () => {
let response = await user.post('/user/addPushDevice', {type, regId});
await user.sync();
expect(response.message).to.equal(t('pushDeviceAdded'));
expect(user.pushDevices[0].type).to.equal(type);
expect(user.pushDevices[0].regId).to.equal(regId);
});
});

View File

@@ -0,0 +1,60 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
describe('POST /user/push-devices', () => {
let user;
let regId = '10';
let type = 'ios';
beforeEach(async () => {
user = await generateUser();
});
it('returns an error when regId is not provided', async () => {
await expect(user.post('/user/push-devices'), {type})
.to.eventually.be.rejected.and.to.eql({
code: 400,
error: 'BadRequest',
message: 'Invalid request parameters.',
});
});
it('returns an error when type is not provided', async () => {
await expect(user.post('/user/push-devices', {regId}))
.to.eventually.be.rejected.and.to.eql({
code: 400,
error: 'BadRequest',
message: 'Invalid request parameters.',
});
});
it('returns an error when type is not valid', async () => {
await expect(user.post('/user/push-devices', {regId, type: 'invalid'}))
.to.eventually.be.rejected.and.to.eql({
code: 400,
error: 'BadRequest',
message: 'Invalid request parameters.',
});
});
it('returns an error if user already has the push device', async () => {
await user.post('/user/push-devices', {type, regId});
await expect(user.post('/user/push-devices', {type, regId}))
.to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('pushDeviceAlreadyAdded'),
});
});
it('adds a push device to the user', async () => {
let response = await user.post('/user/push-devices', {type, regId});
await user.sync();
expect(response.message).to.equal(t('pushDeviceAdded'));
expect(user.pushDevices[0].type).to.equal(type);
expect(user.pushDevices[0].regId).to.equal(regId);
});
});

View File

@@ -0,0 +1,185 @@
import { model as User } from '../../../../../website/server/models/user';
import requireAgain from 'require-again';
import pushNotify from 'push-notify';
import nconf from 'nconf';
describe('pushNotifications', () => {
let user;
let sendPushNotification;
let pathToPushNotifications = '../../../../../website/server/libs/api-v3/pushNotifications';
let gcmSendSpy;
let apnSendSpy;
let identifier = 'identifier';
let title = 'title';
let message = 'message';
beforeEach(() => {
user = new User();
gcmSendSpy = sinon.spy();
apnSendSpy = sinon.spy();
sandbox.stub(nconf, 'get').returns('true');
sandbox.stub(pushNotify, 'gcm').returns({
on: () => null,
send: gcmSendSpy,
});
sandbox.stub(pushNotify, 'apn').returns({
on: () => null,
send: apnSendSpy,
});
sendPushNotification = requireAgain(pathToPushNotifications);
});
afterEach(() => {
sandbox.restore();
});
it('throws if user is not supplied', () => {
expect(sendPushNotification).to.throw;
expect(gcmSendSpy).to.not.have.been.called;
expect(apnSendSpy).to.not.have.been.called;
});
it('throws if user.preferences.pushNotifications.unsubscribeFromAll is true', () => {
user.preferences.pushNotifications.unsubscribeFromAll = true;
expect(() => sendPushNotification(user)).to.throw;
expect(gcmSendSpy).to.not.have.been.called;
expect(apnSendSpy).to.not.have.been.called;
});
it('throws if details.identifier is not supplied', () => {
expect(() => sendPushNotification(user, {
title,
message,
})).to.throw;
expect(gcmSendSpy).to.not.have.been.called;
expect(apnSendSpy).to.not.have.been.called;
});
it('throws if details.title is not supplied', () => {
expect(() => sendPushNotification(user, {
identifier,
message,
})).to.throw;
expect(gcmSendSpy).to.not.have.been.called;
expect(apnSendSpy).to.not.have.been.called;
});
it('throws if details.message is not supplied', () => {
expect(() => sendPushNotification(user, {
identifier,
title,
})).to.throw;
expect(gcmSendSpy).to.not.have.been.called;
expect(apnSendSpy).to.not.have.been.called;
});
it('returns if no device is registered', () => {
sendPushNotification(user, {
identifier,
title,
message,
});
expect(gcmSendSpy).to.not.have.been.called;
expect(apnSendSpy).to.not.have.been.called;
});
it('uses GCM for Android devices', () => {
user.pushDevices.push({
type: 'android',
regId: '123',
});
let details = {
identifier,
title,
message,
payload: {
a: true,
b: true,
},
timeToLive: 23,
};
sendPushNotification(user, details);
expect(gcmSendSpy).to.have.been.calledOnce;
expect(gcmSendSpy).to.have.been.calledWithMatch({
registrationId: '123',
delayWhileIdle: true,
timeToLive: 23,
data: {
identifier,
title,
message,
a: true,
b: true,
},
});
expect(apnSendSpy).to.not.have.been.called;
});
it('defaults timeToLive to 15', () => {
user.pushDevices.push({
type: 'android',
regId: '123',
});
let details = {
identifier,
title,
message,
};
sendPushNotification(user, details);
expect(gcmSendSpy).to.have.been.calledOnce;
expect(gcmSendSpy).to.have.been.calledWithMatch({
registrationId: '123',
delayWhileIdle: true,
timeToLive: 15,
data: {
identifier,
title,
message,
},
});
expect(apnSendSpy).to.not.have.been.called;
});
// TODO disabled because APN relies on a Promise
xit('uses APN for iOS devices', () => {
user.pushDevices.push({
type: 'ios',
regId: '123',
});
let details = {
identifier,
title,
message,
category: 'fun',
payload: {
a: true,
b: true,
},
};
sendPushNotification(user, details);
expect(apnSendSpy).to.have.been.calledOnce;
expect(apnSendSpy).to.have.been.calledWithMatch({
token: '123',
alert: message,
sound: 'default',
category: 'fun',
payload: {
identifier,
a: true,
b: true,
},
});
expect(gcmSendSpy).to.not.have.been.called;
});
});

View File

@@ -1,59 +0,0 @@
import addPushDevice from '../../../common/script/ops/addPushDevice';
import i18n from '../../../common/script/i18n';
import {
generateUser,
} from '../../helpers/common.helper';
import {
NotAuthorized,
BadRequest,
} from '../../../common/script/libs/errors';
describe('shared.ops.addPushDevice', () => {
let user;
let regId = '10';
let type = 'someRandomType';
beforeEach(() => {
user = generateUser();
user.stats.hp = 0;
});
it('returns an error when regId is not provided', (done) => {
try {
addPushDevice(user);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('regIdRequired'));
done();
}
});
it('returns an error when type is not provided', (done) => {
try {
addPushDevice(user, {body: {regId}});
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('typeRequired'));
done();
}
});
it('adds a push device', () => {
let [, message] = addPushDevice(user, {body: {regId, type}});
expect(message).to.equal(i18n.t('pushDeviceAdded'));
expect(user.pushDevices[0].type).to.equal(type);
expect(user.pushDevices[0].regId).to.equal(regId);
});
it('does not a push device twice', (done) => {
try {
addPushDevice(user, {body: {regId, type}});
addPushDevice(user, {body: {regId, type}});
} catch (err) {
expect(err).to.be.an.instanceof(NotAuthorized);
expect(err.message).to.equal(i18n.t('pushDeviceAlreadyAdded'));
done();
}
});
});

View File

@@ -1,58 +1,6 @@
// TODO move to /api-v2
var api = module.exports;
var _ = require('lodash');
var nconf = require('nconf');
var pushNotify = require('push-notify');
var gcmApiKey = nconf.get("PUSH_CONFIGS:GCM_SERVER_API_KEY");
var gcm = gcmApiKey ? pushNotify.gcm({
apiKey: gcmApiKey,
retries: 3
}) : undefined;
if(gcm){
gcm.on('transmitted', function (result, message, registrationId) {
//console.info("transmitted", result, message, registrationId);
});
gcm.on('transmissionError', function (error, message, registrationId) {
//console.info("transmissionError", error, message, registrationId);
});
gcm.on('updated', function (result, registrationId) {
//console.info("updated", result, registrationId);
});
}
api.sendNotify = function(user, title, msg, timeToLive){
timeToLive = timeToLive || 15;
// need investigation:
// https://github.com/HabitRPG/habitrpg/issues/5252
if(!user)
return;
_.forEach(user.pushDevices, function(pushDevice){
switch(pushDevice.type){
case "android":
if(gcm){
gcm.send({
registrationId: pushDevice.regId,
//collapseKey: 'COLLAPSE_KEY',
delayWhileIdle: true,
timeToLive: timeToLive,
data: {
title: title,
message: msg
}
});
}
break;
case "ios":
break;
}
});
// Disabled, see libs/api-v3/pushNotifications
};

View File

@@ -20,7 +20,6 @@ import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
import * as firebase from '../../libs/api-v3/firebase';
import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email';
import { encrypt } from '../../libs/api-v3/encryption';
import common from '../../../../common';
import sendPushNotification from '../../libs/api-v3/pushNotifications';
let api = {};
@@ -537,11 +536,18 @@ async function _inviteByUUID (uuid, group, inviter, req, res) {
sendTxnEmail(userToInvite, `invited-${groupTemplate}`, emailVars);
}
sendPushNotification(
userToInvite,
common.i18n.t(group.type === 'guild' ? 'invitedGuild' : 'invitedParty'),
group.name
);
if (userToInvite.preferences.pushNotifications[`invited${groupLabel}`] !== false) {
let identifier = group.type === 'guild' ? 'invitedGuild' : 'invitedParty';
sendPushNotification(
userToInvite,
{
title: group.name,
message: res.t(identifier),
identifier,
payload: {groupID: group._id},
}
);
}
let userInvited = await userToInvite.save();
if (group.type === 'guild') {

View File

@@ -302,6 +302,18 @@ api.sendPrivateMessage = {
{name: 'PMS_INBOX_URL', content: '/#/options/groups/inbox'},
]);
}
if (receiver.preferences.pushNotifications.newPM !== false) {
sendPushNotification(
receiver,
{
title: res.t('newPM'),
message: res.t('newPMInfo', {name: getUserInfo(sender, ['name']).name, message}),
identifier: 'newPM',
category: 'newPM',
payload: {replyTo: sender._id},
}
);
}
res.respond(200, {});
},
@@ -372,8 +384,15 @@ api.transferGems = {
{name: 'X_GEMS_GIFTED', content: gemAmount},
]);
}
sendPushNotification(sender, res.t('giftedGems'), res.t('giftedGemsInfo', { amount: gemAmount, name: byUsername }));
if (receiver.preferences.pushNotifications.giftedGems !== false) {
sendPushNotification(receiver,
{
title: res.t('giftedGems'),
message: res.t('giftedGemsInfo', {amount: gemAmount, name: byUsername}),
identifier: 'giftedGems',
payload: {replyTo: sender._id},
});
}
res.respond(200, {});
},

View File

@@ -0,0 +1,95 @@
import { authWithHeaders } from '../../middlewares/api-v3/auth';
import {
NotAuthorized,
NotFound,
} from '../../libs/api-v3/errors';
let api = {};
/**
* @apiIgnore
* @api {post} /api/v3/user/push-devices Add a push device to a user
* @apiVersion 3.0.0
* @apiName UserAddPushDevice
* @apiGroup User
*
* @apiParam {string} regId The id of the push device
* @apiParam {string} type The type of push device
*
* @apiSuccess {Object} data List of push devices
* @apiSuccess {string} message Success message
*/
api.addPushDevice = {
method: 'POST',
url: '/user/push-devices',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkBody('regId', res.t('regIdRequired')).notEmpty();
req.checkBody('type', res.t('typeRequired')).notEmpty().isIn(['ios', 'android']);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let pushDevices = user.pushDevices;
let item = {
regId: req.body.regId,
type: req.body.type,
};
if (pushDevices.find(device => device.regId === item.regId)) {
throw new NotAuthorized(res.t('pushDeviceAlreadyAdded'));
}
pushDevices.push(item);
await user.save();
res.respond(200, user.pushDevices, res.t('pushDeviceAdded'));
},
};
/**
* @apiIgnore
* @api {delete} /api/v3/user/push-devices remove a push device from a user
* @apiVersion 3.0.0
* @apiName UserRemovePushDevice
* @apiGroup User
*
* @apiParam {string} regId The id of the push device
*
* @apiSuccess {Object} data List of push devices
* @apiSuccess {string} message Success message
*/
api.removePushDevice = {
method: 'DELETE',
url: '/user/push-devices/:regId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('regId', res.t('regIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let regId = req.params.regId;
let pushDevices = user.pushDevices;
let indexOfPushDevice = pushDevices.findIndex((element) => {
return element.regId === regId;
});
if (indexOfPushDevice === -1) {
throw new NotFound(res.t('pushDeviceNotFound'));
}
pushDevices.splice(indexOfPushDevice, 1);
await user.save();
res.respond(200, user.pushDevices, res.t('pushDeviceRemoved'));
},
};
module.exports = api;

View File

@@ -106,11 +106,18 @@ api.inviteToQuest = {
let inviterVars = getUserInfo(user, ['name', 'email']);
let membersToEmail = members.filter(member => {
// send push notifications while filtering members before sending emails
sendPushNotification(
member,
common.i18n.t('questInvitationTitle'),
common.i18n.t('questInvitationInfo', { quest: quest.text() })
);
if (member.preferences.pushNotifications.invitedQuest !== false) {
sendPushNotification(
member,
{
title: res.t('questInvitationTitle'),
message: res.t('questInvitationInfo', {quest: quest.text(req.language)}),
identifier: 'questInvitation',
category: 'questInvitation',
}
);
}
return member.preferences.emailNotifications.invitedQuest !== false;
});

View File

@@ -1266,32 +1266,6 @@ api.userReroll = {
},
};
/**
* @api {post} /api/v3/user/addPushDevice Add a push device to a user
* @apiVersion 3.0.0
* @apiName UserAddPushDevice
* @apiGroup User
*
* @apiParam {string} regId The id of the push device
* @apiParam {string} uuid The type of push device
*
* @apiSuccess {Object} data List of push devices
* @apiSuccess {string} message Success message
*/
api.userAddPushDevice = {
method: 'POST',
middlewares: [authWithHeaders()],
url: '/user/addPushDevice',
async handler (req, res) {
let user = res.locals.user;
let addPushDeviceRes = common.ops.addPushDevice(user, req);
await user.save();
res.respond(200, ...addPushDeviceRes);
},
};
/**
* @api {post} /api/v3/user/reset Reset user
* @apiVersion 3.0.0

View File

@@ -9,16 +9,14 @@ import csvStringify from '../../libs/api-v3/csvStringify';
import moment from 'moment';
import js2xml from 'js2xmlparser';
import Pageres from 'pageres';
import AWS from 'aws-sdk';
import nconf from 'nconf';
import got from 'got';
import Bluebird from 'bluebird';
import locals from '../../middlewares/api-v3/locals';
import {
S3,
} from '../../libs/api-v3/aws';
let S3 = new AWS.S3({
accessKeyId: nconf.get('S3:accessKeyId'),
secretAccessKey: nconf.get('S3:secretAccessKey'),
});
const S3_BUCKET = nconf.get('S3:bucket');
const BASE_URL = nconf.get('BASE_URL');

View File

@@ -0,0 +1,7 @@
import AWS from 'aws-sdk';
import nconf from 'nconf';
export const S3 = new AWS.S3({
accessKeyId: nconf.get('S3:accessKeyId'),
secretAccessKey: nconf.get('S3:secretAccessKey'),
});

View File

@@ -105,7 +105,16 @@ api.createSubscription = async function createSubscription (data) {
}
if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself
sendPushNotification(data.gift.member, shared.i18n.t('giftedSubscription'), `${months} months - by ${byUserName}`);
if (data.gift.member.preferences.pushNotifications.giftedSubscription !== false) {
sendPushNotification(data.gift.member,
{
title: shared.i18n.t('giftedSubscription'),
message: shared.i18n.t('giftedSubscriptionInfo', {months, name: byUserName}),
identifier: 'giftedSubscription',
payload: {replyTo: data.user._id},
}
);
}
}
}
@@ -178,7 +187,16 @@ api.buyGems = async function buyGems (data) {
}
if (data.gift.member._id !== data.user._id) { // Only send push notifications if sending to a user other than yourself
sendPushNotification(data.gift.member, shared.i18n.t('giftedGems'), `${gemAmount} Gems - by ${byUsername}`);
if (data.gift.member.preferences.pushNotifications.giftedGems !== false) {
sendPushNotification(
data.gift.member,
{
title: shared.i18n.t('giftedGems'),
message: shared.i18n.t('giftedGemsInfo', {amount: gemAmount, name: byUsername}),
identifier: 'giftedGems',
}
);
}
}
await data.gift.member.save();

View File

@@ -1,8 +1,12 @@
/* eslint-disable */
import _ from 'lodash';
import nconf from 'nconf';
import pushNotify from 'push-notify';
import apnLib from 'apn';
import logger from './logger';
import Bluebird from 'bluebird';
import {
S3,
} from './aws';
const GCM_API_KEY = nconf.get('PUSH_CONFIGS:GCM_SERVER_API_KEY');
@@ -11,47 +15,95 @@ let gcm = GCM_API_KEY ? pushNotify.gcm({
retries: 3,
}) : undefined;
// TODO review and test this file when push notifications are added back
if (gcm) {
gcm.on('transmitted', (/* result, message, registrationId */) => {
// console.info("transmitted", result, message, registrationId);
});
gcm.on('transmissionError', (/* error, message, registrationId */) => {
// console.info("transmissionError", error, message, registrationId);
});
gcm.on('updated', (/* result, registrationId */) => {
// console.info("updated", result, registrationId);
gcm.on('transmissionError', (err, message, registrationId) => {
logger.error('GCM Error', err, message, registrationId);
});
}
module.exports = function sendNotification (user, title, message, timeToLive = 15) {
return; // TODO push notifications are not currently enabled
let apn;
if (!user) return;
// Load APN certificate and key from S3
const APN_ENABLED = nconf.get('PUSH_CONFIGS:APN_ENABLED') === 'true';
const S3_BUCKET = nconf.get('S3:bucket');
if (APN_ENABLED) {
Bluebird.all([
S3.getObject({
Bucket: S3_BUCKET,
Key: 'apple_apn/cert.pem',
}).promise(),
S3.getObject({
Bucket: S3_BUCKET,
Key: 'apple_apn/key.pem',
}).promise(),
])
.then(([certObj, keyObj]) => {
let cert = certObj.Body.toString();
let key = keyObj.Body.toString();
apn = pushNotify.apn({
key,
cert,
});
apn.on('error', err => logger.error('APN error', err));
apn.on('transmissionError', (errorCode, notification, device) => {
logger.error('APN transmissionError', errorCode, notification, device);
});
let feedback = new apnLib.Feedback({
key,
cert,
batchFeedback: true,
interval: 3600, // Check for feedback once an hour
});
feedback.on('feedback', (devices) => {
if (devices && devices.length > 0) {
logger.info('Delivery of push notifications failed for some Apple devices.', devices);
}
});
});
}
module.exports = function sendNotification (user, details = {}) {
if (!user) throw new Error('User is required.');
if (user.preferences.pushNotifications.unsubscribeFromAll === true) return;
let pushDevices = user.pushDevices.toObject ? user.pushDevices.toObject() : user.pushDevices;
if (!details.identifier) throw new Error('details.identifier is required.');
if (!details.title) throw new Error('details.title is required.');
if (!details.message) throw new Error('details.message is required.');
let payload = details.payload ? details.payload : {};
payload.identifier = details.identifier;
_.each(pushDevices, pushDevice => {
switch (pushDevice.type) {
case 'android':
if (gcm) {
payload.title = details.title;
payload.message = details.message;
gcm.send({
registrationId: pushDevice.regId,
// collapseKey: 'COLLAPSE_KEY',
delayWhileIdle: true,
timeToLive,
data: {
title,
message,
},
timeToLive: details.timeToLive ? details.timeToLive : 15,
data: payload,
});
}
break;
case 'ios':
if (apn) {
apn.send({
token: pushDevice.regId,
alert: details.message,
sound: 'default',
category: details.category,
payload,
});
}
break;
}
});

View File

@@ -296,8 +296,14 @@ schema.methods.closeChal = async function closeChal (broken = {}) {
{name: 'CHALLENGE_NAME', content: challenge.name},
]);
}
sendPushNotification(savedWinner, shared.i18n.t('wonChallenge'), challenge.name);
if (savedWinner.preferences.pushNotifications.wonChallenge !== false) {
sendPushNotification(savedWinner,
{
title: challenge.name,
message: shared.i18n.t('wonChallenge'),
identifier: 'wonChallenge',
});
}
}
// Run some operations in the background withouth blocking the thread

View File

@@ -405,18 +405,29 @@ schema.methods.startQuest = async function startQuest (user) {
// send notifications in the background without blocking
User.find(
{ _id: { $in: nonUserQuestMembers } },
'party.quest items.quests auth.facebook auth.local preferences.emailNotifications pushDevices profile.name'
'party.quest items.quests auth.facebook auth.local preferences.emailNotifications preferences.pushNotifications pushDevices profile.name'
).exec().then((membersToNotify) => {
let membersToEmail = _.filter(membersToNotify, (member) => {
// send push notifications and filter users that disabled emails
sendPushNotification(member, 'HabitRPG', `${shared.i18n.t('questStarted')}: ${quest.text()}`);
return member.preferences.emailNotifications.questStarted !== false &&
member._id !== user._id;
});
sendTxnEmail(membersToEmail, 'quest-started', [
{ name: 'PARTY_URL', content: '/#/options/groups/party' },
]);
let membersToPush = _.filter(membersToNotify, (member) => {
// send push notifications and filter users that disabled emails
return member.preferences.pushNotifications.questStarted !== false &&
member._id !== user._id;
});
_.each(membersToPush, (member) => {
sendPushNotification(member,
{
title: quest.text(),
message: `${shared.i18n.t('questStarted')}: ${quest.text()}`,
identifier: 'questStarted',
});
});
});
};

View File

@@ -0,0 +1,21 @@
import mongoose from 'mongoose';
import baseModel from '../libs/api-v3/baseModel';
const Schema = mongoose.Schema;
export let schema = new Schema({
regId: {type: String, required: true},
type: {type: String, required: true, enum: ['ios', 'android']},
}, {
strict: true,
minimize: false, // So empty objects are returned
_id: false,
});
schema.plugin(baseModel, {
noSet: ['_id', 'regId'],
timestamps: true,
_id: false,
});
export let model = mongoose.model('PushDevice', schema);

View File

@@ -3,6 +3,7 @@ import shared from '../../../../common';
import _ from 'lodash';
import validator from 'validator';
import { schema as TagSchema } from '../tag';
import { schema as PushDeviceSchema } from '../pushDevice';
import {
schema as UserNotificationSchema,
} from '../userNotification';
@@ -431,6 +432,17 @@ let schema = new Schema({
importantAnnouncements: {type: Boolean, default: true},
weeklyRecaps: {type: Boolean, default: true},
},
pushNotifications: {
unsubscribeFromAll: {type: Boolean, default: false},
newPM: {type: Boolean, default: true},
wonChallenge: {type: Boolean, default: true},
giftedGems: {type: Boolean, default: true},
giftedSubscription: {type: Boolean, default: true},
invitedParty: {type: Boolean, default: true},
invitedGuild: {type: Boolean, default: true},
questStarted: {type: Boolean, default: true},
invitedQuest: {type: Boolean, default: true},
},
suppressModals: {
levelUp: {type: Boolean, default: false},
hatchPet: {type: Boolean, default: false},
@@ -505,10 +517,7 @@ let schema = new Schema({
extra: {type: Schema.Types.Mixed, default: () => {
return {};
}},
pushDevices: [{
regId: {type: String},
type: {type: String},
}],
pushDevices: [PushDeviceSchema],
}, {
strict: true,
minimize: false, // So empty objects are returned

View File

@@ -321,25 +321,46 @@ script(id='partials/options.settings.notifications.html', type="text/ng-template
.personal-options.col-md-6
.panel.panel-default
.panel-heading
=env.t('emailNotifications')
=env.t('notifications')
.panel-body
each notification in ['newPM', 'wonChallenge', 'giftedGems', 'giftedSubscription', 'invitedParty', 'invitedGuild', 'kickedGroup', 'questStarted', 'invitedQuest', 'importantAnnouncements', 'weeklyRecaps']
-var preference = 'user.preferences.emailNotifications.' + notification
-var unsubscribeFromAll = 'user.preferences.emailNotifications.unsubscribeFromAll'
.checkbox: label
input(type='checkbox', ng-model='#{preference}',
ng-disabled='#{unsubscribeFromAll} === true',
ng-checked='#{unsubscribeFromAll} === false && #{preference} === true',
ng-change='set({"preferences.emailNotifications.#{notification}": #{preference} ? true: false})')
span=env.t(notification)
table.table
tr
td
th
span=env.t("email")
th
span=env.t("push")
-var unsubscribeFromAllEmails = 'user.preferences.emailNotifications.unsubscribeFromAll'
-var unsubscribeFromAllPush = 'user.preferences.pushNotifications.unsubscribeFromAll'
each notification in ['newPM', 'wonChallenge', 'giftedGems', 'giftedSubscription', 'invitedParty', 'invitedGuild', 'kickedGroup', 'questStarted', 'invitedQuest', 'importantAnnouncements', 'weeklyRecaps']
tr
td
span=env.t(notification)
td
-var preference = 'user.preferences.emailNotifications.' + notification
input(type='checkbox', ng-model='#{preference}',
ng-disabled='#{unsubscribeFromAllEmails} === true || #{preference} === undefined',
ng-checked='#{unsubscribeFromAllEmails} === false && #{preference} === true',
ng-change='set({"preferences.emailNotifications.#{notification}": #{preference} ? true: false})')
td
-var preference = 'user.preferences.pushNotifications.' + notification
input(type='checkbox', ng-model='#{preference}',
ng-disabled='#{unsubscribeFromAllPush} === true || #{preference} === undefined',
ng-checked='#{unsubscribeFromAllPush} === false && #{preference} === true',
ng-change='set({"preferences.pushNotifications.#{notification}": #{preference} ? true: false})')
hr
.checkbox
label
input(type='checkbox', ng-model='user.preferences.emailNotifications.unsubscribeFromAll', ng-change='set({"preferences.emailNotifications.unsubscribeFromAll": user.preferences.emailNotifications.unsubscribeFromAll ? true: false})')
input(type='checkbox', ng-model='user.preferences.pushNotifications.unsubscribeFromAll',
ng-change='set({"preferences.pushNotifications.unsubscribeFromAll": user.preferences.pushNotifications.unsubscribeFromAll ? true: false})')
span=env.t('unsubscribeAllPush')
.checkbox
label
input(type='checkbox', ng-model='user.preferences.emailNotifications.unsubscribeFromAll',
ng-change='set({"preferences.emailNotifications.unsubscribeFromAll": user.preferences.emailNotifications.unsubscribeFromAll ? true: false})')
span=env.t('unsubscribeAllEmails')
small=env.t('unsubscribeAllEmailsText')