mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-15 21:57:22 +01:00
Update to new method for fcm (#15238)
* begin moving to new fcm library * Add error handling * Add opening notification to correct screen * Fix tests and make async * lint fix * Rename pushNotificationstest..js to pushNotifications.test.js * fix(potions): remove Fungi Potion time banner * 5.24.3 * update(content): add 2024-06 content prebuild (#15231) * update sprites * add 2024-06 content * add 2024-06 enchanted armoire items * update sprites * update sprites * fix errors found in testing * Fix liveliness probes being rate limited (#15236) * Do not rate limit any liveliness probes * update example config * Translated using Weblate (German) Currently translated at 96.2% (181 of 188 strings) Translated using Weblate (Japanese) Currently translated at 99.4% (769 of 773 strings) Translated using Weblate (German) Currently translated at 93.6% (176 of 188 strings) Translated using Weblate (Japanese) Currently translated at 96.2% (2972 of 3089 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (232 of 232 strings) Translated using Weblate (Japanese) Currently translated at 96.8% (841 of 868 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (94 of 94 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (113 of 113 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (131 of 131 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (286 of 286 strings) Translated using Weblate (German) Currently translated at 86.7% (163 of 188 strings) Translated using Weblate (German) Currently translated at 85.1% (160 of 188 strings) Translated using Weblate (German) Currently translated at 84.0% (158 of 188 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (94 of 94 strings) Translated using Weblate (German) Currently translated at 83.5% (157 of 188 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (113 of 113 strings) Translated using Weblate (German) Currently translated at 82.9% (156 of 188 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (113 of 113 strings) Translated using Weblate (German) Currently translated at 81.9% (154 of 188 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (113 of 113 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (8 of 8 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (8 of 8 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (188 of 188 strings) Translated using Weblate (German) Currently translated at 79.2% (149 of 188 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (189 of 189 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (8 of 8 strings) Translated using Weblate (German) Currently translated at 90.6% (2799 of 3089 strings) Translated using Weblate (German) Currently translated at 77.6% (146 of 188 strings) Translated using Weblate (German) Currently translated at 90.5% (2797 of 3089 strings) Translated using Weblate (German) Currently translated at 90.4% (2794 of 3089 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (131 of 131 strings) Translated using Weblate (German) Currently translated at 90.1% (2786 of 3089 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (188 of 188 strings) Translated using Weblate (German) Currently translated at 77.1% (145 of 188 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 98.7% (763 of 773 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (868 of 868 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (131 of 131 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (286 of 286 strings) Translated using Weblate (German) Currently translated at 90.0% (2782 of 3089 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (773 of 773 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (378 of 378 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (868 of 868 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (167 of 167 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (259 of 259 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (259 of 259 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (131 of 131 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (286 of 286 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (239 of 239 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (188 of 188 strings) Translated using Weblate (French) Currently translated at 100.0% (188 of 188 strings) Translated using Weblate (German) Currently translated at 75.0% (141 of 188 strings) Translated using Weblate (Spanish) Currently translated at 99.0% (766 of 773 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (189 of 189 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (868 of 868 strings) Translated using Weblate (Japanese) Currently translated at 98.8% (764 of 773 strings) Translated using Weblate (Japanese) Currently translated at 99.6% (258 of 259 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (378 of 378 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (188 of 188 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (140 of 140 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (131 of 131 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (286 of 286 strings) Translated using Weblate (Ukrainian) Currently translated at 62.5% (1931 of 3089 strings) Translated using Weblate (German) Currently translated at 89.8% (2777 of 3089 strings) Translated using Weblate (French) Currently translated at 100.0% (188 of 188 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 98.5% (762 of 773 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (868 of 868 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (286 of 286 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (286 of 286 strings) Translated using Weblate (French) Currently translated at 82.9% (156 of 188 strings) Translated using Weblate (German) Currently translated at 93.0% (241 of 259 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (286 of 286 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (8 of 8 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (427 of 427 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 98.5% (762 of 773 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (868 of 868 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (167 of 167 strings) Translated using Weblate (Japanese) Currently translated at 99.2% (257 of 259 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 98.5% (762 of 773 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (868 of 868 strings) Translated using Weblate (German) Currently translated at 92.2% (239 of 259 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (286 of 286 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (239 of 239 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (188 of 188 strings) Translated using Weblate (German) Currently translated at 91.8% (238 of 259 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (131 of 131 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 98.5% (762 of 773 strings) Translated using Weblate (German) Currently translated at 90.3% (234 of 259 strings) Co-authored-by: Finrod <963505255@qq.com> Co-authored-by: Jaime Martí <jaumemarti77@icloud.com> Co-authored-by: Kem Kembo <medamamef@gmail.com> Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com> Co-authored-by: TOMA Mitsuru <toma0001@gmail.com> Co-authored-by: Tetiana <merekka13@gmail.com> Co-authored-by: Toro Mor <thomas.bizer@gmx.de> Co-authored-by: Weblate <noreply@weblate.org> Translate-URL: https://translate.habitica.com/projects/habitica/achievements/es/ Translate-URL: https://translate.habitica.com/projects/habitica/achievements/zh_Hans/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/zh_Hans/ Translate-URL: https://translate.habitica.com/projects/habitica/character/uk/ Translate-URL: https://translate.habitica.com/projects/habitica/character/zh_Hans/ Translate-URL: https://translate.habitica.com/projects/habitica/content/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/content/uk/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/de/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/fr/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/zh_Hans/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/uk/ Translate-URL: https://translate.habitica.com/projects/habitica/generic/zh_Hans/ Translate-URL: https://translate.habitica.com/projects/habitica/groups/zh_Hans/ Translate-URL: https://translate.habitica.com/projects/habitica/inventory/uk/ Translate-URL: https://translate.habitica.com/projects/habitica/limited/es/ Translate-URL: https://translate.habitica.com/projects/habitica/limited/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/limited/uk/ Translate-URL: https://translate.habitica.com/projects/habitica/limited/zh_Hans/ Translate-URL: https://translate.habitica.com/projects/habitica/npc/uk/ Translate-URL: https://translate.habitica.com/projects/habitica/overview/uk/ Translate-URL: https://translate.habitica.com/projects/habitica/pets/uk/ Translate-URL: https://translate.habitica.com/projects/habitica/quests/uk/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/es/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/zh_Hans/ Translate-URL: https://translate.habitica.com/projects/habitica/settings/de/ Translate-URL: https://translate.habitica.com/projects/habitica/settings/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/settings/uk/ Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/tasks/zh_Hans/ Translation: Habitica/Achievements Translation: Habitica/Backgrounds Translation: Habitica/Character Translation: Habitica/Content Translation: Habitica/Faq Translation: Habitica/Gear Translation: Habitica/Generic Translation: Habitica/Groups Translation: Habitica/Inventory Translation: Habitica/Limited Translation: Habitica/Npc Translation: Habitica/Overview Translation: Habitica/Pets Translation: Habitica/Quests Translation: Habitica/Questscontent Translation: Habitica/Settings Translation: Habitica/Subscriber Translation: Habitica/Tasks * 5.25.0 * Fix dockerfile (#15241) * Fix issue with l4p not resetting properly (#15240) * actually clear out seeking field on user. Even when creating a party * Add tests to ensure party.seeking is cleared * fix(lint): don't assign unused const --------- Co-authored-by: Sabe Jones <sabe@habitica.com> --------- Co-authored-by: Sabe Jones <sabe@habitica.com> Co-authored-by: Natalie <78037386+CuriousMagpie@users.noreply.github.com> Co-authored-by: Weblate <noreply@weblate.org> Co-authored-by: Finrod <963505255@qq.com> Co-authored-by: Jaime Martí <jaumemarti77@icloud.com> Co-authored-by: Kem Kembo <medamamef@gmail.com> Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com> Co-authored-by: TOMA Mitsuru <toma0001@gmail.com> Co-authored-by: Tetiana <merekka13@gmail.com> Co-authored-by: Toro Mor <thomas.bizer@gmx.de> Co-authored-by: Rafał Jagielski <jagielski.rafal.uwm@gmail.com>
This commit is contained in:
1123
package-lock.json
generated
1123
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@
|
||||
"express": "^4.19.2",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"express-validator": "^5.2.0",
|
||||
"firebase-admin": "^12.1.1",
|
||||
"glob": "^8.1.0",
|
||||
"got": "^11.8.6",
|
||||
"gulp": "^4.0.0",
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import apn from '@parse/node-apn/mock';
|
||||
import _ from 'lodash';
|
||||
import nconf from 'nconf';
|
||||
import gcmLib from 'node-gcm'; // works with FCM notifications too
|
||||
import { model as User } from '../../../../website/server/models/user';
|
||||
import {
|
||||
sendNotification as sendPushNotification,
|
||||
MAX_MESSAGE_LENGTH,
|
||||
} from '../../../../website/server/libs/pushNotifications';
|
||||
|
||||
describe('pushNotifications', () => {
|
||||
let user;
|
||||
let fcmSendSpy;
|
||||
let apnSendSpy;
|
||||
|
||||
const identifier = 'identifier';
|
||||
const title = 'title';
|
||||
const message = 'message';
|
||||
|
||||
beforeEach(() => {
|
||||
user = new User();
|
||||
fcmSendSpy = sinon.spy();
|
||||
apnSendSpy = sinon.spy();
|
||||
|
||||
sandbox.stub(nconf, 'get').returns('true-key');
|
||||
|
||||
sandbox.stub(gcmLib.Sender.prototype, 'send').callsFake(fcmSendSpy);
|
||||
|
||||
sandbox.stub(apn.Provider.prototype, 'send').returns({
|
||||
on: () => null,
|
||||
send: apnSendSpy,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
it('throws if user is not supplied', () => {
|
||||
expect(sendPushNotification).to.throw;
|
||||
expect(fcmSendSpy).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(fcmSendSpy).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(fcmSendSpy).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(fcmSendSpy).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(fcmSendSpy).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(fcmSendSpy).to.not.have.been.called;
|
||||
expect(apnSendSpy).to.not.have.been.called;
|
||||
});
|
||||
|
||||
it('cuts the message to 300 chars', () => {
|
||||
const longMessage = `12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345`;
|
||||
|
||||
expect(longMessage.length > MAX_MESSAGE_LENGTH).to.equal(true);
|
||||
|
||||
const details = {
|
||||
identifier,
|
||||
title,
|
||||
message: longMessage,
|
||||
payload: {
|
||||
message: longMessage,
|
||||
},
|
||||
};
|
||||
|
||||
sendPushNotification(user, details);
|
||||
|
||||
expect(details.message).to.equal(_.truncate(longMessage, { length: MAX_MESSAGE_LENGTH }));
|
||||
expect(details.payload.message)
|
||||
.to.equal(_.truncate(longMessage, { length: MAX_MESSAGE_LENGTH }));
|
||||
|
||||
expect(details.message.length).to.equal(MAX_MESSAGE_LENGTH);
|
||||
expect(details.payload.message.length).to.equal(MAX_MESSAGE_LENGTH);
|
||||
});
|
||||
|
||||
it('cuts the message to 300 chars (no payload)', () => {
|
||||
const longMessage = `12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345`;
|
||||
|
||||
expect(longMessage.length > MAX_MESSAGE_LENGTH).to.equal(true);
|
||||
|
||||
const details = {
|
||||
identifier,
|
||||
title,
|
||||
message: longMessage,
|
||||
};
|
||||
|
||||
sendPushNotification(user, details);
|
||||
|
||||
expect(details.message).to.equal(_.truncate(longMessage, { length: MAX_MESSAGE_LENGTH }));
|
||||
expect(details.message.length).to.equal(MAX_MESSAGE_LENGTH);
|
||||
});
|
||||
|
||||
// TODO disabled because APN relies on a Promise
|
||||
xit('uses APN for iOS devices', () => {
|
||||
user.pushDevices.push({
|
||||
type: 'ios',
|
||||
regId: '123',
|
||||
});
|
||||
|
||||
const details = {
|
||||
identifier,
|
||||
title,
|
||||
message,
|
||||
category: 'fun',
|
||||
payload: {
|
||||
a: true,
|
||||
b: true,
|
||||
},
|
||||
};
|
||||
|
||||
const expectedNotification = new apn.Notification({
|
||||
alert: message,
|
||||
sound: 'default',
|
||||
category: 'fun',
|
||||
payload: {
|
||||
identifier,
|
||||
a: true,
|
||||
b: true,
|
||||
},
|
||||
});
|
||||
|
||||
sendPushNotification(user, details);
|
||||
expect(apnSendSpy).to.have.been.calledOnce;
|
||||
expect(apnSendSpy).to.have.been.calledWithMatch(expectedNotification, '123');
|
||||
expect(fcmSendSpy).to.not.have.been.called;
|
||||
});
|
||||
});
|
||||
354
test/api/unit/libs/pushNotifications.test.js
Normal file
354
test/api/unit/libs/pushNotifications.test.js
Normal file
@@ -0,0 +1,354 @@
|
||||
import apn from '@parse/node-apn';
|
||||
import _ from 'lodash';
|
||||
import nconf from 'nconf';
|
||||
import admin from 'firebase-admin';
|
||||
import { model as User } from '../../../../website/server/models/user';
|
||||
import {
|
||||
MAX_MESSAGE_LENGTH,
|
||||
} from '../../../../website/server/libs/pushNotifications';
|
||||
|
||||
let sendPushNotification;
|
||||
|
||||
describe('pushNotifications', () => {
|
||||
let user;
|
||||
let fcmSendSpy;
|
||||
let apnSendSpy;
|
||||
let updateStub;
|
||||
let classStubbedInstance;
|
||||
|
||||
const identifier = 'identifier';
|
||||
const title = 'title';
|
||||
const message = 'message';
|
||||
|
||||
beforeEach(() => {
|
||||
user = new User();
|
||||
fcmSendSpy = sinon.stub().returns(Promise.resolve('success'));
|
||||
apnSendSpy = sinon.stub().returns(Promise.resolve());
|
||||
|
||||
nconf.set('PUSH_CONFIGS_APN_ENABLED', 'true');
|
||||
|
||||
classStubbedInstance = sandbox.createStubInstance(apn.Provider, {
|
||||
send: apnSendSpy,
|
||||
});
|
||||
sandbox.stub(apn, 'Provider').returns(classStubbedInstance);
|
||||
|
||||
delete require.cache[require.resolve('../../../../website/server/libs/pushNotifications')];
|
||||
// eslint-disable-next-line global-require
|
||||
sendPushNotification = require('../../../../website/server/libs/pushNotifications').sendNotification;
|
||||
|
||||
updateStub = sandbox.stub(User, 'updateOne').resolves();
|
||||
sandbox.stub(admin, 'messaging').get(() => () => ({ send: fcmSendSpy }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
describe('validates supplied data', () => {
|
||||
it('throws if user is not supplied', () => {
|
||||
expect(sendPushNotification).to.throw;
|
||||
expect(fcmSendSpy).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(fcmSendSpy).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(fcmSendSpy).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(fcmSendSpy).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(fcmSendSpy).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(fcmSendSpy).to.not.have.been.called;
|
||||
expect(apnSendSpy).to.not.have.been.called;
|
||||
});
|
||||
});
|
||||
|
||||
it('cuts the message to 300 chars', () => {
|
||||
const longMessage = `12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345`;
|
||||
|
||||
expect(longMessage.length > MAX_MESSAGE_LENGTH).to.equal(true);
|
||||
|
||||
const details = {
|
||||
identifier,
|
||||
title,
|
||||
message: longMessage,
|
||||
payload: {
|
||||
message: longMessage,
|
||||
},
|
||||
};
|
||||
|
||||
sendPushNotification(user, details);
|
||||
|
||||
expect(details.message).to.equal(_.truncate(longMessage, { length: MAX_MESSAGE_LENGTH }));
|
||||
expect(details.payload.message)
|
||||
.to.equal(_.truncate(longMessage, { length: MAX_MESSAGE_LENGTH }));
|
||||
|
||||
expect(details.message.length).to.equal(MAX_MESSAGE_LENGTH);
|
||||
expect(details.payload.message.length).to.equal(MAX_MESSAGE_LENGTH);
|
||||
});
|
||||
|
||||
it('cuts the message to 300 chars (no payload)', () => {
|
||||
const longMessage = `12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345
|
||||
12345 12345 12345 12345 12345 12345 12345 12345 12345 12345`;
|
||||
|
||||
expect(longMessage.length > MAX_MESSAGE_LENGTH).to.equal(true);
|
||||
|
||||
const details = {
|
||||
identifier,
|
||||
title,
|
||||
message: longMessage,
|
||||
};
|
||||
|
||||
sendPushNotification(user, details);
|
||||
|
||||
expect(details.message).to.equal(_.truncate(longMessage, { length: MAX_MESSAGE_LENGTH }));
|
||||
expect(details.message.length).to.equal(MAX_MESSAGE_LENGTH);
|
||||
});
|
||||
|
||||
describe('sends notifications', () => {
|
||||
let details;
|
||||
|
||||
beforeEach(() => {
|
||||
details = {
|
||||
identifier,
|
||||
title,
|
||||
message,
|
||||
category: 'fun',
|
||||
payload: {
|
||||
a: true,
|
||||
b: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('uses APN for iOS devices', async () => {
|
||||
user.pushDevices.push({
|
||||
type: 'ios',
|
||||
regId: '123',
|
||||
});
|
||||
|
||||
const expectedNotification = new apn.Notification({
|
||||
alert: {
|
||||
title,
|
||||
body: message,
|
||||
},
|
||||
sound: 'default',
|
||||
category: 'fun',
|
||||
payload: {
|
||||
identifier,
|
||||
a: true,
|
||||
b: true,
|
||||
},
|
||||
});
|
||||
|
||||
await sendPushNotification(user, details);
|
||||
expect(apnSendSpy).to.have.been.calledOnce;
|
||||
expect(apnSendSpy).to.have.been.calledWithMatch(expectedNotification, '123');
|
||||
expect(fcmSendSpy).to.not.have.been.called;
|
||||
});
|
||||
|
||||
it('uses FCM for Android devices', async () => {
|
||||
user.pushDevices.push({
|
||||
type: 'android',
|
||||
regId: '123',
|
||||
});
|
||||
|
||||
const expectedMessage = {
|
||||
notification: {
|
||||
title,
|
||||
body: message,
|
||||
},
|
||||
data: {
|
||||
identifier,
|
||||
notificationIdentifier: identifier,
|
||||
},
|
||||
token: '123',
|
||||
};
|
||||
|
||||
await sendPushNotification(user, details);
|
||||
expect(fcmSendSpy).to.have.been.calledOnce;
|
||||
expect(fcmSendSpy).to.have.been.calledWithMatch(expectedMessage);
|
||||
expect(apnSendSpy).to.not.have.been.called;
|
||||
});
|
||||
|
||||
it('handles multiple devices', async () => {
|
||||
user.pushDevices.push({
|
||||
type: 'android',
|
||||
regId: '123',
|
||||
});
|
||||
user.pushDevices.push({
|
||||
type: 'ios',
|
||||
regId: '456',
|
||||
});
|
||||
user.pushDevices.push({
|
||||
type: 'android',
|
||||
regId: '789',
|
||||
});
|
||||
|
||||
await sendPushNotification(user, details);
|
||||
expect(fcmSendSpy).to.have.been.calledTwice;
|
||||
expect(apnSendSpy).to.have.been.calledOnce;
|
||||
});
|
||||
});
|
||||
|
||||
describe('handles sending errors', () => {
|
||||
let clock;
|
||||
|
||||
beforeEach(() => {
|
||||
clock = sinon.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
it('removes unregistered fcm devices', async () => {
|
||||
user.pushDevices.push({
|
||||
type: 'android',
|
||||
regId: '123',
|
||||
});
|
||||
|
||||
const error = new Error();
|
||||
error.code = 'messaging/registration-token-not-registered';
|
||||
fcmSendSpy.rejects(error);
|
||||
|
||||
await sendPushNotification(user, {
|
||||
identifier,
|
||||
title,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(fcmSendSpy).to.have.been.calledOnce;
|
||||
expect(apnSendSpy).to.not.have.been.called;
|
||||
await clock.tick(10);
|
||||
expect(updateStub).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
it('removes invalid fcm devices', async () => {
|
||||
user.pushDevices.push({
|
||||
type: 'android',
|
||||
regId: '123',
|
||||
});
|
||||
|
||||
const error = new Error();
|
||||
error.code = 'messaging/registration-token-not-registered';
|
||||
fcmSendSpy.rejects(error);
|
||||
|
||||
await sendPushNotification(user, {
|
||||
identifier,
|
||||
title,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(fcmSendSpy).to.have.been.calledOnce;
|
||||
expect(apnSendSpy).to.not.have.been.called;
|
||||
expect(updateStub).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
it('removes unregistered apn devices', async () => {
|
||||
user.pushDevices.push({
|
||||
type: 'ios',
|
||||
regId: '123',
|
||||
});
|
||||
|
||||
const error = {
|
||||
failed: [
|
||||
{
|
||||
device: '123',
|
||||
response: { reason: 'Unregistered' },
|
||||
},
|
||||
],
|
||||
};
|
||||
apnSendSpy.resolves(error);
|
||||
|
||||
await sendPushNotification(user, {
|
||||
identifier,
|
||||
title,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(fcmSendSpy).to.not.have.been.called;
|
||||
expect(apnSendSpy).to.have.been.calledOnce;
|
||||
expect(updateStub).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
it('removes invalid apn devices', async () => {
|
||||
user.pushDevices.push({
|
||||
type: 'ios',
|
||||
regId: '123',
|
||||
});
|
||||
|
||||
const error = {
|
||||
failed: [
|
||||
{
|
||||
device: '123',
|
||||
response: { reason: 'BadDeviceToken' },
|
||||
},
|
||||
],
|
||||
};
|
||||
apnSendSpy.resolves(error);
|
||||
|
||||
await sendPushNotification(user, {
|
||||
identifier,
|
||||
title,
|
||||
message,
|
||||
});
|
||||
|
||||
expect(fcmSendSpy).to.not.have.been.called;
|
||||
expect(apnSendSpy).to.have.been.calledOnce;
|
||||
expect(updateStub).to.have.been.calledOnce;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1362,8 +1362,8 @@ describe('Group Model', () => {
|
||||
sandbox.spy(User, 'updateMany');
|
||||
});
|
||||
|
||||
it('formats message', () => {
|
||||
const chatMessage = party.sendChat({
|
||||
it('formats message', async () => {
|
||||
const chatMessage = await party.sendChat({
|
||||
message: 'a _new_ message with *markdown*',
|
||||
user: {
|
||||
_id: 'user-id',
|
||||
@@ -1396,8 +1396,8 @@ describe('Group Model', () => {
|
||||
expect(chat.user).to.eql('user name');
|
||||
});
|
||||
|
||||
it('formats message as system if no user is passed in', () => {
|
||||
const chat = party.sendChat({ message: 'a system message' });
|
||||
it('formats message as system if no user is passed in', async () => {
|
||||
const chat = await party.sendChat({ message: 'a system message' });
|
||||
|
||||
expect(chat.text).to.eql('a system message');
|
||||
expect(validator.isUUID(chat.id)).to.eql(true);
|
||||
@@ -1411,8 +1411,8 @@ describe('Group Model', () => {
|
||||
expect(chat.user).to.not.exist;
|
||||
});
|
||||
|
||||
it('updates users about new messages in party', () => {
|
||||
party.sendChat({ message: 'message' });
|
||||
it('updates users about new messages in party', async () => {
|
||||
await party.sendChat({ message: 'message' });
|
||||
|
||||
expect(User.updateMany).to.be.calledOnce;
|
||||
expect(User.updateMany).to.be.calledWithMatch({
|
||||
@@ -1421,12 +1421,12 @@ describe('Group Model', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates users about new messages in group', () => {
|
||||
it('updates users about new messages in group', async () => {
|
||||
const group = new Group({
|
||||
type: 'guild',
|
||||
});
|
||||
|
||||
group.sendChat({ message: 'message' });
|
||||
await group.sendChat({ message: 'message' });
|
||||
|
||||
expect(User.updateMany).to.be.calledOnce;
|
||||
expect(User.updateMany).to.be.calledWithMatch({
|
||||
@@ -1435,8 +1435,8 @@ describe('Group Model', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not send update to user that sent the message', () => {
|
||||
party.sendChat({ message: 'message', user: { _id: 'user-id', profile: { name: 'user' } } });
|
||||
it('does not send update to user that sent the message', async () => {
|
||||
await party.sendChat({ message: 'message', user: { _id: 'user-id', profile: { name: 'user' } } });
|
||||
|
||||
expect(User.updateMany).to.be.calledOnce;
|
||||
expect(User.updateMany).to.be.calledWithMatch({
|
||||
@@ -1445,18 +1445,18 @@ describe('Group Model', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('skips sending new message notification for guilds with > 5000 members', () => {
|
||||
it('skips sending new message notification for guilds with > 5000 members', async () => {
|
||||
party.memberCount = 5001;
|
||||
|
||||
party.sendChat({ message: 'message' });
|
||||
await party.sendChat({ message: 'message' });
|
||||
|
||||
expect(User.updateMany).to.not.be.called;
|
||||
});
|
||||
|
||||
it('skips sending messages to the tavern', () => {
|
||||
it('skips sending messages to the tavern', async () => {
|
||||
party._id = TAVERN_ID;
|
||||
|
||||
party.sendChat({ message: 'message' });
|
||||
await party.sendChat({ message: 'message' });
|
||||
|
||||
expect(User.updateMany).to.not.be.called;
|
||||
});
|
||||
@@ -2326,7 +2326,7 @@ describe('Group Model', () => {
|
||||
|
||||
await guild.save();
|
||||
|
||||
const groupMessage = guild.sendChat({ message: 'Test message.' });
|
||||
const groupMessage = await guild.sendChat({ message: 'Test message.' });
|
||||
await groupMessage.save();
|
||||
|
||||
await sleep();
|
||||
|
||||
@@ -214,7 +214,7 @@ api.postChat = {
|
||||
});
|
||||
}
|
||||
|
||||
const newChatMessage = group.sendChat({
|
||||
const newChatMessage = await group.sendChat({
|
||||
message,
|
||||
user,
|
||||
flagCount,
|
||||
|
||||
@@ -756,7 +756,7 @@ api.transferGems = {
|
||||
]);
|
||||
}
|
||||
if (receiver.preferences.pushNotifications.giftedGems !== false) {
|
||||
sendPushNotification(
|
||||
await sendPushNotification(
|
||||
receiver,
|
||||
{
|
||||
title: res.t('giftedGems', receiverLang),
|
||||
|
||||
@@ -120,10 +120,10 @@ api.inviteToQuest = {
|
||||
|
||||
// send out invites
|
||||
const inviterVars = getUserInfo(user, ['name', 'email']);
|
||||
const membersToEmail = members.filter(member => {
|
||||
const membersToEmail = members.filter(async member => {
|
||||
// send push notifications while filtering members before sending emails
|
||||
if (member.preferences.pushNotifications.invitedQuest !== false) {
|
||||
sendPushNotification(
|
||||
await sendPushNotification(
|
||||
member,
|
||||
{
|
||||
title: quest.text(member.preferences.language),
|
||||
@@ -394,7 +394,7 @@ api.cancelQuest = {
|
||||
if (group.quest.active) throw new NotAuthorized(res.t('cantCancelActiveQuest'));
|
||||
|
||||
const questName = questScrolls[group.quest.key].text('en');
|
||||
const newChatMessage = group.sendChat({
|
||||
const newChatMessage = await group.sendChat({
|
||||
message: `\`${user.profile.name} cancelled the party quest ${questName}.\``,
|
||||
info: {
|
||||
type: 'quest_cancel',
|
||||
@@ -456,7 +456,7 @@ api.abortQuest = {
|
||||
if (user._id !== group.leader && user._id !== group.quest.leader) throw new NotAuthorized(res.t('onlyLeaderAbortQuest'));
|
||||
|
||||
const questName = questScrolls[group.quest.key].text('en');
|
||||
const newChatMessage = group.sendChat({
|
||||
const newChatMessage = await group.sendChat({
|
||||
message: `\`${common.i18n.t('chatQuestAborted', { username: user.profile.name, questName }, 'en')}\``,
|
||||
info: {
|
||||
type: 'quest_abort',
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function sendChatPushNotifications (user, group, message, mentions,
|
||||
.select('preferences.pushNotifications preferences.language profile.name pushDevices auth.local.username')
|
||||
.exec();
|
||||
|
||||
members.forEach(member => {
|
||||
members.forEach(async member => {
|
||||
if (member.preferences.pushNotifications.partyActivity !== false) {
|
||||
if (mentions && mentions.includes(`@${member.auth.local.username}`) && member.preferences.pushNotifications.mentionParty !== false) {
|
||||
return;
|
||||
@@ -33,7 +33,7 @@ export async function sendChatPushNotifications (user, group, message, mentions,
|
||||
|
||||
if (!message.unformattedText) return;
|
||||
|
||||
sendPushNotification(
|
||||
await sendPushNotification(
|
||||
member,
|
||||
{
|
||||
title: translate('groupActivityNotificationTitle', { user: message.user, group: group.name }, member.preferences.language),
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function sentMessage (sender, receiver, message, translate) {
|
||||
}
|
||||
|
||||
if (receiver.preferences.pushNotifications.newPM !== false && messageSent.unformattedText) {
|
||||
sendPushNotification(
|
||||
await sendPushNotification(
|
||||
receiver,
|
||||
{
|
||||
title: translate(
|
||||
|
||||
@@ -16,12 +16,12 @@ import {
|
||||
model as Group,
|
||||
} from '../../models/group';
|
||||
|
||||
function sendInvitePushNotification (userToInvite, groupLabel, group, publicGuild, res) {
|
||||
async function sendInvitePushNotification (userToInvite, groupLabel, group, publicGuild, res) {
|
||||
if (userToInvite.preferences.pushNotifications[`invited${groupLabel}`] === false) return;
|
||||
|
||||
const identifier = group.type === 'guild' ? 'invitedGuild' : 'invitedParty';
|
||||
|
||||
sendPushNotification(
|
||||
await sendPushNotification(
|
||||
userToInvite,
|
||||
{
|
||||
title: group.name,
|
||||
@@ -110,7 +110,7 @@ async function addInvitationToUser (userToInvite, group, inviter, res) {
|
||||
|
||||
const groupLabel = group.type === 'guild' ? 'Guild' : 'Party';
|
||||
sendInviteEmail(userToInvite, groupLabel, group, inviter);
|
||||
sendInvitePushNotification(userToInvite, groupLabel, group, publicGuild, res);
|
||||
await sendInvitePushNotification(userToInvite, groupLabel, group, publicGuild, res);
|
||||
|
||||
const userInvited = await userToInvite.save();
|
||||
if (group.type === 'guild') {
|
||||
|
||||
@@ -50,7 +50,7 @@ async function buyGemGift (data) {
|
||||
data.gift.member._id !== data.user._id
|
||||
&& data.gift.member.preferences.pushNotifications.giftedGems !== false
|
||||
) {
|
||||
sendPushNotification(
|
||||
await sendPushNotification(
|
||||
data.gift.member,
|
||||
{
|
||||
title: shared.i18n.t('giftedGems', languages[1]),
|
||||
|
||||
@@ -367,7 +367,7 @@ async function createSubscription (data) {
|
||||
}
|
||||
|
||||
if (data.gift.member.preferences.pushNotifications.giftedSubscription !== false) {
|
||||
sendPushNotification(
|
||||
await sendPushNotification(
|
||||
data.gift.member,
|
||||
{
|
||||
title: shared.i18n.t('giftedSubscription', languages[1]),
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import _ from 'lodash';
|
||||
import nconf from 'nconf';
|
||||
import apn from '@parse/node-apn';
|
||||
import gcmLib from 'node-gcm'; // works with FCM notifications too
|
||||
import admin from 'firebase-admin';
|
||||
import logger from './logger';
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
model as User,
|
||||
} from '../models/user';
|
||||
|
||||
const FCM_API_KEY = nconf.get('PUSH_CONFIGS_FCM_SERVER_API_KEY');
|
||||
const fcmSender = FCM_API_KEY ? new gcmLib.Sender(FCM_API_KEY) : undefined;
|
||||
|
||||
const APN_ENABLED = nconf.get('PUSH_CONFIGS_APN_ENABLED') === 'true';
|
||||
const apnProvider = APN_ENABLED ? new apn.Provider({
|
||||
token: {
|
||||
@@ -30,7 +27,91 @@ function removePushDevice (user, pushDevice) {
|
||||
|
||||
export const MAX_MESSAGE_LENGTH = 300;
|
||||
|
||||
export function sendNotification (user, details = {}) {
|
||||
async function sendFCMNotification (user, pushDevice, payload) {
|
||||
const messaging = admin.messaging();
|
||||
if (messaging === undefined) {
|
||||
return;
|
||||
}
|
||||
const message = {
|
||||
notification: {
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
},
|
||||
data: {
|
||||
identifier: payload.identifier,
|
||||
notificationIdentifier: payload.identifier,
|
||||
},
|
||||
token: pushDevice.regId,
|
||||
};
|
||||
|
||||
try {
|
||||
await messaging.send(message);
|
||||
} catch (error) {
|
||||
if (error.code === 'messaging/registration-token-not-registered') {
|
||||
removePushDevice(user, pushDevice);
|
||||
logger.error(new Error('FCM error, unregistered pushDevice'), {
|
||||
regId: pushDevice.regId, userId: user._id,
|
||||
});
|
||||
} else if (error.code === 'messaging/invalid-registration-token') {
|
||||
removePushDevice(user, pushDevice);
|
||||
logger.error(new Error('FCM error, invalid pushDevice'), {
|
||||
regId: pushDevice.regId, userId: user._id,
|
||||
});
|
||||
} else {
|
||||
logger.error(error, 'Unhandled FCM error.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAPNNotification (user, pushDevice, details, payload) {
|
||||
if (apnProvider) {
|
||||
const notification = new apn.Notification({
|
||||
alert: {
|
||||
title: details.title,
|
||||
body: details.message,
|
||||
},
|
||||
sound: 'default',
|
||||
category: details.category,
|
||||
topic: 'com.habitrpg.ios.Habitica',
|
||||
payload,
|
||||
});
|
||||
try {
|
||||
const response = await apnProvider.send(notification, pushDevice.regId);
|
||||
// Handle failed push notifications deliveries
|
||||
response.failed.forEach(failure => {
|
||||
if (failure.error) { // generic error
|
||||
logger.error(new Error('Unhandled APN error'), {
|
||||
response, regId: pushDevice.regId, userId: user._id,
|
||||
});
|
||||
} else { // rejected
|
||||
// see https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html#//apple_ref/doc/uid/TP40008194-CH11-SW17
|
||||
// for a list of rejection reasons
|
||||
const { reason } = failure.response;
|
||||
if (reason === 'Unregistered') {
|
||||
removePushDevice(user, pushDevice);
|
||||
logger.error(new Error('APN error, unregistered pushDevice'), {
|
||||
regId: pushDevice.regId, userId: user._id,
|
||||
});
|
||||
} else {
|
||||
if (reason === 'BadDeviceToken') {
|
||||
// An invalid token was registered by mistake
|
||||
// Remove it but log the error differently so that it can be distinguished
|
||||
// from when reason === Unregistered
|
||||
removePushDevice(user, pushDevice);
|
||||
}
|
||||
logger.error(new Error('APN error'), {
|
||||
response, regId: pushDevice.regId, userId: user._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err, 'Unhandled APN error.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendNotification (user, details = {}) {
|
||||
if (!user) throw new Error('User is required.');
|
||||
if (user.preferences.pushNotifications.unsubscribeFromAll === true) return;
|
||||
const pushDevices = user.pushDevices.toObject ? user.pushDevices.toObject() : user.pushDevices;
|
||||
@@ -51,110 +132,16 @@ export function sendNotification (user, details = {}) {
|
||||
payload.message = _.truncate(payload.message, { length: MAX_MESSAGE_LENGTH });
|
||||
}
|
||||
|
||||
_.each(pushDevices, pushDevice => {
|
||||
await _.each(pushDevices, async pushDevice => {
|
||||
switch (pushDevice.type) { // eslint-disable-line default-case
|
||||
case 'android':
|
||||
// Required for fcm to be received in background
|
||||
payload.title = details.title;
|
||||
payload.body = details.message;
|
||||
|
||||
if (fcmSender) {
|
||||
const message = new gcmLib.Message({
|
||||
data: payload,
|
||||
});
|
||||
|
||||
fcmSender.send(message, {
|
||||
registrationTokens: [pushDevice.regId],
|
||||
}, 5, (err, response) => {
|
||||
if (err) {
|
||||
logger.error(err, 'Unhandled FCM error.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle failed push notifications deliveries
|
||||
// Note that we're always sending to one device, not multiple
|
||||
const failed = response
|
||||
&& response.results && response.results[0] && response.results[0].error;
|
||||
|
||||
if (failed) {
|
||||
// See https://firebase.google.com/docs/cloud-messaging/http-server-ref#table9
|
||||
// for the list of errors
|
||||
|
||||
// The regId is not valid anymore, remove it
|
||||
if (failed === 'NotRegistered') {
|
||||
removePushDevice(user, pushDevice);
|
||||
logger.error(new Error('FCM error, unregistered pushDevice'), {
|
||||
regId: pushDevice.regId, userId: user._id,
|
||||
});
|
||||
} else {
|
||||
// An invalid token was registered by mistake
|
||||
// Remove it but log the error differently so that it can be distinguished
|
||||
// from when failed === NotRegistered
|
||||
// Blacklisted can happen in some rare cases,
|
||||
// see https://stackoverflow.com/questions/42136122/why-does-firebase-push-token-return-blacklisted
|
||||
// MismatchSenderId could be due to old tokens,
|
||||
// see https://stackoverflow.com/questions/11313342/why-do-i-get-mismatchsenderid-from-gcm-server-side
|
||||
if (
|
||||
failed === 'InvalidRegistration'
|
||||
|| failed === 'MismatchSenderId'
|
||||
|| pushDevice.regId === 'BLACKLISTED'
|
||||
) {
|
||||
removePushDevice(user, pushDevice);
|
||||
}
|
||||
logger.error(new Error('FCM error'), {
|
||||
response, regId: pushDevice.regId, userId: user._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
await sendFCMNotification(user, pushDevice, payload);
|
||||
break;
|
||||
|
||||
case 'ios':
|
||||
if (apnProvider) {
|
||||
const notification = new apn.Notification({
|
||||
alert: {
|
||||
title: details.title,
|
||||
body: details.message,
|
||||
},
|
||||
sound: 'default',
|
||||
category: details.category,
|
||||
topic: 'com.habitrpg.ios.Habitica',
|
||||
payload,
|
||||
});
|
||||
apnProvider.send(notification, pushDevice.regId)
|
||||
.then(response => {
|
||||
// Handle failed push notifications deliveries
|
||||
response.failed.forEach(failure => {
|
||||
if (failure.error) { // generic error
|
||||
logger.error(new Error('Unhandled APN error'), {
|
||||
response, regId: pushDevice.regId, userId: user._id,
|
||||
});
|
||||
} else { // rejected
|
||||
// see https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html#//apple_ref/doc/uid/TP40008194-CH11-SW17
|
||||
// for a list of rejection reasons
|
||||
const { reason } = failure.response;
|
||||
if (reason === 'Unregistered') {
|
||||
removePushDevice(user, pushDevice);
|
||||
logger.error(new Error('APN error, unregistered pushDevice'), {
|
||||
regId: pushDevice.regId, userId: user._id,
|
||||
});
|
||||
} else {
|
||||
if (reason === 'BadDeviceToken') {
|
||||
// An invalid token was registered by mistake
|
||||
// Remove it but log the error differently so that it can be distinguished
|
||||
// from when reason === Unregistered
|
||||
removePushDevice(user, pushDevice);
|
||||
}
|
||||
logger.error(new Error('APN error'), {
|
||||
response, regId: pushDevice.regId, userId: user._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(err => logger.error(err, 'Unhandled APN error.'));
|
||||
}
|
||||
sendAPNNotification(user, pushDevice, details, payload);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
14
website/server/libs/setupFirebase.js
Normal file
14
website/server/libs/setupFirebase.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import admin from 'firebase-admin';
|
||||
import nconf from 'nconf';
|
||||
|
||||
if (nconf.get('FIREBASE_PROJECT_ID') !== undefined && nconf.get('FIREBASE_PROJECT_ID') !== '') {
|
||||
if (!global.firebaseApp) {
|
||||
global.firebaseApp = admin.initializeApp({
|
||||
credential: admin.credential.cert({
|
||||
projectId: nconf.get('FIREBASE_PROJECT_ID'),
|
||||
clientEmail: nconf.get('FIREBASE_CLIENT_EMAIL'),
|
||||
privateKey: nconf.get('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -254,7 +254,7 @@ async function castSpell (req, res, { isV3 = false }) {
|
||||
if (lastMessage && lastMessage.info.spell === spellId
|
||||
&& lastMessage.info.user === user.profile.name
|
||||
&& lastMessage.info.target === partyMembers.profile.name) {
|
||||
const newChatMessage = party.sendChat({
|
||||
const newChatMessage = await party.sendChat({
|
||||
message: `\`${common.i18n.t('chatCastSpellUserTimes', {
|
||||
username: user.profile.name,
|
||||
spell: spell.text(),
|
||||
@@ -273,7 +273,7 @@ async function castSpell (req, res, { isV3 = false }) {
|
||||
await newChatMessage.save();
|
||||
await lastMessage.deleteOne();
|
||||
} else { // Single target spell, not repeated
|
||||
const newChatMessage = party.sendChat({
|
||||
const newChatMessage = await party.sendChat({
|
||||
message: `\`${common.i18n.t('chatCastSpellUser', { username: user.profile.name, spell: spell.text(), target: partyMembers.profile.name }, 'en')}\``,
|
||||
info: {
|
||||
type: 'spell_cast_user',
|
||||
@@ -288,7 +288,7 @@ async function castSpell (req, res, { isV3 = false }) {
|
||||
}
|
||||
} else if (lastMessage && lastMessage.info.spell === spellId // Party spell, check for repeat
|
||||
&& lastMessage.info.user === user.profile.name) {
|
||||
const newChatMessage = party.sendChat({
|
||||
const newChatMessage = await party.sendChat({
|
||||
message: `\`${common.i18n.t('chatCastSpellPartyTimes', {
|
||||
username: user.profile.name,
|
||||
spell: spell.text(),
|
||||
@@ -305,7 +305,7 @@ async function castSpell (req, res, { isV3 = false }) {
|
||||
await newChatMessage.save();
|
||||
await lastMessage.deleteOne();
|
||||
} else {
|
||||
const newChatMessage = party.sendChat({ // Non-repetitive partywide spell
|
||||
const newChatMessage = await party.sendChat({ // Non-repetitive partywide spell
|
||||
message: `\`${common.i18n.t('chatCastSpellParty', { username: user.profile.name, spell: spell.text() }, 'en')}\``,
|
||||
info: {
|
||||
type: 'spell_cast_party',
|
||||
|
||||
@@ -403,7 +403,7 @@ schema.methods.closeChal = async function closeChal (broken = {}) {
|
||||
]);
|
||||
}
|
||||
if (savedWinner.preferences.pushNotifications.wonChallenge !== false) {
|
||||
sendPushNotification(
|
||||
await sendPushNotification(
|
||||
savedWinner,
|
||||
{
|
||||
title: challenge.name,
|
||||
|
||||
@@ -526,7 +526,7 @@ schema.methods.getMemberCount = async function getMemberCount () {
|
||||
return User.countDocuments(query).exec();
|
||||
};
|
||||
|
||||
schema.methods.sendChat = function sendChat (options = {}) {
|
||||
schema.methods.sendChat = async function sendChat (options = {}) {
|
||||
const {
|
||||
message, user, metaData,
|
||||
client, flagCount = 0, info = {},
|
||||
@@ -596,7 +596,7 @@ schema.methods.sendChat = function sendChat (options = {}) {
|
||||
sendChatPushNotifications(user, this, newChatMessage, mentions, translate);
|
||||
}
|
||||
if (mentionedMembers) {
|
||||
mentionedMembers.forEach(member => {
|
||||
await mentionedMembers.forEach(async member => {
|
||||
if (member._id === user._id) return;
|
||||
const pushNotifPrefs = member.preferences.pushNotifications;
|
||||
if (this.type === 'party') {
|
||||
@@ -617,7 +617,7 @@ schema.methods.sendChat = function sendChat (options = {}) {
|
||||
}
|
||||
|
||||
if (newChatMessage.unformattedText) {
|
||||
sendPushNotification(member, {
|
||||
await sendPushNotification(member, {
|
||||
identifier: 'chatMention',
|
||||
title: `${user.profile.name} mentioned you in ${this.name}`,
|
||||
message: newChatMessage.unformattedText,
|
||||
@@ -751,7 +751,7 @@ schema.methods.startQuest = async function startQuest (user) {
|
||||
_id: { $in: nonMembers },
|
||||
}, _cleanQuestParty()).exec();
|
||||
|
||||
const newMessage = this.sendChat({
|
||||
const newMessage = await this.sendChat({
|
||||
message: `\`${shared.i18n.t('chatQuestStarted', { questName: quest.text('en') }, 'en')}\``,
|
||||
metaData: {
|
||||
participatingMembers: this.getParticipatingQuestMembers().join(', '),
|
||||
@@ -766,7 +766,7 @@ schema.methods.startQuest = async function startQuest (user) {
|
||||
const membersToEmail = [];
|
||||
|
||||
// send notifications and webhooks in the background without blocking
|
||||
members.forEach(member => {
|
||||
await members.forEach(async member => {
|
||||
if (member._id !== user._id) {
|
||||
// send push notifications and filter users that disabled emails
|
||||
if (member.preferences.emailNotifications.questStarted !== false) {
|
||||
@@ -776,7 +776,7 @@ schema.methods.startQuest = async function startQuest (user) {
|
||||
// send push notifications and filter users that disabled emails
|
||||
if (member.preferences.pushNotifications.questStarted !== false) {
|
||||
const memberLang = member.preferences.language;
|
||||
sendPushNotification(member, {
|
||||
await sendPushNotification(member, {
|
||||
title: quest.text(memberLang),
|
||||
message: shared.i18n.t('questStarted', memberLang),
|
||||
identifier: 'questStarted',
|
||||
@@ -1021,7 +1021,7 @@ schema.methods._processBossQuest = async function processBossQuest (options) {
|
||||
|
||||
group.quest.progress.hp -= progress.up;
|
||||
if (CRON_SAFE_MODE || CRON_SEMI_SAFE_MODE) {
|
||||
const groupMessage = group.sendChat({
|
||||
const groupMessage = await group.sendChat({
|
||||
message: `\`${shared.i18n.t('chatBossDontAttack', { bossName: quest.boss.name('en') }, 'en')}\``,
|
||||
info: {
|
||||
type: 'boss_dont_attack',
|
||||
@@ -1032,7 +1032,7 @@ schema.methods._processBossQuest = async function processBossQuest (options) {
|
||||
});
|
||||
promises.push(groupMessage.save());
|
||||
} else {
|
||||
const groupMessage = group.sendChat({
|
||||
const groupMessage = await group.sendChat({
|
||||
message: `\`${shared.i18n.t('chatBossDamage', {
|
||||
username: user.profile.name, bossName: quest.boss.name('en'), userDamage: progress.up.toFixed(1), bossDamage: Math.abs(down).toFixed(1),
|
||||
}, user.preferences.language)}\``,
|
||||
@@ -1051,7 +1051,7 @@ schema.methods._processBossQuest = async function processBossQuest (options) {
|
||||
if (quest.boss.rage) {
|
||||
group.quest.progress.rage += Math.abs(down);
|
||||
if (group.quest.progress.rage >= quest.boss.rage.value) {
|
||||
const rageMessage = group.sendChat({
|
||||
const rageMessage = await group.sendChat({
|
||||
message: quest.boss.rage.effect('en'),
|
||||
info: {
|
||||
type: 'boss_rage',
|
||||
@@ -1094,7 +1094,7 @@ schema.methods._processBossQuest = async function processBossQuest (options) {
|
||||
|
||||
// Boss slain, finish quest
|
||||
if (group.quest.progress.hp <= 0) {
|
||||
const questFinishChat = group.sendChat({
|
||||
const questFinishChat = await group.sendChat({
|
||||
message: `\`${shared.i18n.t('chatBossDefeated', { bossName: quest.boss.name('en') }, 'en')}\``,
|
||||
info: {
|
||||
type: 'boss_defeated',
|
||||
@@ -1148,7 +1148,7 @@ schema.methods._processCollectionQuest = async function processCollectionQuest (
|
||||
}, []);
|
||||
|
||||
foundText = foundText.join(', ');
|
||||
const foundChat = group.sendChat({
|
||||
const foundChat = await group.sendChat({
|
||||
message: `\`${shared.i18n.t('chatFindItems', { username: user.profile.name, items: foundText }, 'en')}\``,
|
||||
info: {
|
||||
type: 'user_found_items',
|
||||
@@ -1164,7 +1164,7 @@ schema.methods._processCollectionQuest = async function processCollectionQuest (
|
||||
const questFinished = collectedItems.length === remainingItems.length;
|
||||
if (questFinished) {
|
||||
await group.finishQuest(quest);
|
||||
const allItemsFoundChat = group.sendChat({
|
||||
const allItemsFoundChat = await group.sendChat({
|
||||
message: `\`${shared.i18n.t('chatItemQuestFinish', 'en')}\``,
|
||||
info: {
|
||||
type: 'all_items_found',
|
||||
@@ -1236,7 +1236,7 @@ schema.statics.tavernBoss = async function tavernBoss (user, progress) {
|
||||
const chatPromises = [];
|
||||
|
||||
if (tavern.quest.progress.hp <= 0) {
|
||||
const completeChat = tavern.sendChat({
|
||||
const completeChat = await tavern.sendChat({
|
||||
message: quest.completionChat('en'),
|
||||
info: {
|
||||
type: 'tavern_quest_completed',
|
||||
@@ -1273,7 +1273,7 @@ schema.statics.tavernBoss = async function tavernBoss (user, progress) {
|
||||
}
|
||||
|
||||
if (!scene) {
|
||||
const tiredChat = tavern.sendChat({
|
||||
const tiredChat = await tavern.sendChat({
|
||||
message: `\`${shared.i18n.t('tavernBossTired', { rageName: quest.boss.rage.title('en'), bossName: quest.boss.name('en') }, 'en')}\``,
|
||||
info: {
|
||||
type: 'tavern_boss_rage_tired',
|
||||
@@ -1283,7 +1283,7 @@ schema.statics.tavernBoss = async function tavernBoss (user, progress) {
|
||||
chatPromises.push(tiredChat.save());
|
||||
tavern.quest.progress.rage = 0; // quest.boss.rage.value;
|
||||
} else {
|
||||
const rageChat = tavern.sendChat({
|
||||
const rageChat = await tavern.sendChat({
|
||||
message: quest.boss.rage[scene]('en'),
|
||||
info: {
|
||||
type: 'tavern_boss_rage',
|
||||
@@ -1306,7 +1306,7 @@ schema.statics.tavernBoss = async function tavernBoss (user, progress) {
|
||||
&& tavern.quest.progress.hp < quest.boss.desperation.threshold
|
||||
&& !tavern.quest.extra.desperate
|
||||
) {
|
||||
const progressChat = tavern.sendChat({
|
||||
const progressChat = await tavern.sendChat({
|
||||
message: quest.boss.desperation.text('en'),
|
||||
info: {
|
||||
type: 'tavern_boss_desperation',
|
||||
|
||||
@@ -12,6 +12,7 @@ import attachMiddlewares from './middlewares/index';
|
||||
// Load config files
|
||||
import './libs/setupMongoose';
|
||||
import './libs/setupPassport';
|
||||
import './libs/setupFirebase';
|
||||
|
||||
// Load some schemas & models
|
||||
import './models/challenge';
|
||||
|
||||
Reference in New Issue
Block a user