[NEW] Better Push and Email Notification logic (#17357)

Co-authored-by: Diego Sampaio <chinello@gmail.com>
pull/17333/head
Rodrigo Nascimento 6 years ago committed by GitHub
parent 80f596922c
commit 8084de841e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      .eslintrc
  2. 8
      .github/workflows/build_and_test.yml
  3. 19
      app/lib/server/functions/notifications/email.js
  4. 24
      app/lib/server/functions/notifications/mobile.js
  5. 42
      app/lib/server/lib/sendNotificationsOnMessage.js
  6. 34
      app/lib/server/startup/settings.js
  7. 13
      app/models/server/models/NotificationQueue.js
  8. 18
      app/models/server/models/Sessions.tests.js
  9. 4
      app/models/server/raw/BaseRaw.js
  10. 77
      app/models/server/raw/NotificationQueue.ts
  11. 3
      app/models/server/raw/index.js
  12. 144
      app/notification-queue/server/NotificationQueue.ts
  13. 23
      app/push-notifications/server/lib/PushNotification.js
  14. 2
      app/push/server/index.js
  15. 174
      app/push/server/push.js
  16. 4
      app/statistics/server/lib/statistics.js
  17. 44
      definition/INotification.ts
  18. 160
      package-lock.json
  19. 2
      package.json
  20. 5
      packages/rocketchat-i18n/i18n/en.i18n.json
  21. 6
      server/lib/pushConfig.js
  22. 7
      server/methods/readMessages.js
  23. 2
      server/startup/migrations/index.js
  24. 17
      server/startup/migrations/v181.js
  25. 66
      server/startup/migrations/v187.js
  26. 2
      tsconfig.json

@ -90,7 +90,8 @@
"@typescript-eslint/interface-name-prefix": [
"error",
"always"
]
],
"@typescript-eslint/explicit-function-return-type": "off"
},
"env": {
"browser": true,

@ -250,6 +250,14 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Free disk space
run: |
sudo swapoff -a
sudo rm -f /swapfile
sudo apt clean
docker rmi $(docker image ls -aq)
df -h
- name: Cache node modules
id: cache-nodemodules
uses: actions/cache@v1

@ -110,7 +110,7 @@ const getButtonUrl = (room, subscription, message) => {
});
};
export function sendEmail({ message, user, subscription, room, emailAddress, hasMentionToUser }) {
export function getEmailData({ message, user, subscription, room, emailAddress, hasMentionToUser }) {
const username = settings.get('UI_Use_Real_Name') ? message.u.name || message.u.username : message.u.username;
let subjectKey = 'Offline_Mention_All_Email';
@ -152,12 +152,20 @@ export function sendEmail({ message, user, subscription, room, emailAddress, has
}
metrics.notificationsSent.inc({ notification_type: 'email' });
return Mailer.send(email);
return email;
}
export function sendEmailFromData(data) {
metrics.notificationsSent.inc({ notification_type: 'email' });
return Mailer.send(data);
}
export function sendEmail({ message, user, subscription, room, emailAddress, hasMentionToUser }) {
return sendEmailFromData(getEmailData({ message, user, subscription, room, emailAddress, hasMentionToUser }));
}
export function shouldNotifyEmail({
disableAllMessageNotifications,
statusConnection,
emailNotifications,
isHighlighted,
hasMentionToUser,
@ -170,11 +178,6 @@ export function shouldNotifyEmail({
return false;
}
// use connected (don't need to send him an email)
if (statusConnection === 'online') {
return false;
}
// user/room preference to nothing
if (emailNotifications === 'nothing') {
return false;

@ -3,16 +3,10 @@ import { Meteor } from 'meteor/meteor';
import { settings } from '../../../../settings';
import { Subscriptions } from '../../../../models';
import { roomTypes } from '../../../../utils';
import { PushNotification } from '../../../../push-notifications/server';
const CATEGORY_MESSAGE = 'MESSAGE';
const CATEGORY_MESSAGE_NOREPLY = 'MESSAGE_NOREPLY';
let alwaysNotifyMobileBoolean;
settings.get('Notifications_Always_Notify_Mobile', (key, value) => {
alwaysNotifyMobileBoolean = value;
});
let SubscriptionRaw;
Meteor.startup(() => {
SubscriptionRaw = Subscriptions.model.rawCollection();
@ -46,32 +40,25 @@ function enableNotificationReplyButton(room, username) {
return !room.muted.includes(username);
}
export async function sendSinglePush({ room, message, userId, receiverUsername, senderUsername, senderName, notificationMessage }) {
export async function getPushData({ room, message, userId, receiverUsername, senderUsername, senderName, notificationMessage }) {
let username = '';
if (settings.get('Push_show_username_room')) {
username = settings.get('UI_Use_Real_Name') === true ? senderName : senderUsername;
}
PushNotification.send({
roomId: message.rid,
return {
payload: {
host: Meteor.absoluteUrl(),
rid: message.rid,
sender: message.u,
type: room.t,
name: room.name,
messageType: message.t,
messageId: message._id,
},
roomName: settings.get('Push_show_username_room') && roomTypes.getConfig(room.t).isGroupChat(room) ? `#${ roomTypes.getRoomName(room.t, room) }` : '',
username,
message: settings.get('Push_show_message') ? notificationMessage : ' ',
badge: await getBadgeCount(userId),
usersTo: {
userId,
},
category: enableNotificationReplyButton(room, receiverUsername) ? CATEGORY_MESSAGE : CATEGORY_MESSAGE_NOREPLY,
});
};
}
export function shouldNotifyMobile({
@ -81,7 +68,6 @@ export function shouldNotifyMobile({
isHighlighted,
hasMentionToUser,
hasReplyToThread,
statusConnection,
roomType,
}) {
if (disableAllMessageNotifications && mobilePushNotifications == null && !isHighlighted && !hasMentionToUser && !hasReplyToThread) {
@ -92,10 +78,6 @@ export function shouldNotifyMobile({
return false;
}
if (!alwaysNotifyMobileBoolean && statusConnection === 'online') {
return false;
}
if (!mobilePushNotifications) {
if (settings.get('Accounts_Default_User_Preferences_mobileNotifications') === 'all') {
return true;

@ -7,10 +7,11 @@ import { callbacks } from '../../../callbacks/server';
import { Subscriptions, Users } from '../../../models/server';
import { roomTypes } from '../../../utils';
import { callJoinRoom, messageContainsHighlight, parseMessageTextPerUser, replaceMentionedUsernamesWithFullNames } from '../functions/notifications';
import { sendEmail, shouldNotifyEmail } from '../functions/notifications/email';
import { sendSinglePush, shouldNotifyMobile } from '../functions/notifications/mobile';
import { getEmailData, shouldNotifyEmail } from '../functions/notifications/email';
import { getPushData, shouldNotifyMobile } from '../functions/notifications/mobile';
import { notifyDesktopUser, shouldNotifyDesktop } from '../functions/notifications/desktop';
import { notifyAudioUser, shouldNotifyAudio } from '../functions/notifications/audio';
import { Notification } from '../../../notification-queue/server/NotificationQueue';
let TroubleshootDisableNotifications;
@ -115,6 +116,8 @@ export const sendNotification = async ({
});
}
const queueItems = [];
if (shouldNotifyMobile({
disableAllMessageNotifications,
mobilePushNotifications,
@ -122,23 +125,24 @@ export const sendNotification = async ({
isHighlighted,
hasMentionToUser,
hasReplyToThread,
statusConnection: receiver.statusConnection,
roomType,
})) {
sendSinglePush({
notificationMessage,
room,
message,
userId: subscription.u._id,
senderUsername: sender.username,
senderName: sender.name,
receiverUsername: receiver.username,
queueItems.push({
type: 'push',
data: await getPushData({
notificationMessage,
room,
message,
userId: subscription.u._id,
senderUsername: sender.username,
senderName: sender.name,
receiverUsername: receiver.username,
}),
});
}
if (receiver.emails && shouldNotifyEmail({
disableAllMessageNotifications,
statusConnection: receiver.statusConnection,
emailNotifications,
isHighlighted,
hasMentionToUser,
@ -148,13 +152,25 @@ export const sendNotification = async ({
})) {
receiver.emails.some((email) => {
if (email.verified) {
sendEmail({ message, receiver, subscription, room, emailAddress: email.address, hasMentionToUser });
queueItems.push({
type: 'email',
data: getEmailData({ message, receiver, subscription, room, emailAddress: email.address, hasMentionToUser }),
});
return true;
}
return false;
});
}
if (queueItems.length) {
Notification.scheduleItem({
uid: subscription.u._id,
rid: room._id,
mid: message._id,
items: queueItems,
});
}
};
const project = {

@ -925,12 +925,6 @@ settings.addGroup('General', function() {
public: true,
i18nDescription: 'Notifications_Max_Room_Members_Description',
});
this.add('Notifications_Always_Notify_Mobile', false, {
type: 'boolean',
public: true,
i18nDescription: 'Notifications_Always_Notify_Mobile_Description',
});
});
this.section('REST API', function() {
return this.add('API_User_Limit', 500, {
@ -1178,33 +1172,7 @@ settings.addGroup('Push', function() {
public: true,
alert: 'Push_Setting_Requires_Restart_Alert',
});
this.add('Push_debug', false, {
type: 'boolean',
public: true,
alert: 'Push_Setting_Requires_Restart_Alert',
enableQuery: {
_id: 'Push_enable',
value: true,
},
});
this.add('Push_send_interval', 2000, {
type: 'int',
public: true,
alert: 'Push_Setting_Requires_Restart_Alert',
enableQuery: {
_id: 'Push_enable',
value: true,
},
});
this.add('Push_send_batch_size', 100, {
type: 'int',
public: true,
alert: 'Push_Setting_Requires_Restart_Alert',
enableQuery: {
_id: 'Push_enable',
value: true,
},
});
this.add('Push_enable_gateway', true, {
type: 'boolean',
alert: 'Push_Setting_Requires_Restart_Alert',

@ -0,0 +1,13 @@
import { Base } from './_Base';
export class NotificationQueue extends Base {
constructor() {
super('notification_queue');
this.tryEnsureIndex({ uid: 1 });
this.tryEnsureIndex({ ts: 1 }, { expireAfterSeconds: 2 * 60 * 60 });
this.tryEnsureIndex({ schedule: 1 }, { sparse: true });
this.tryEnsureIndex({ sending: 1 }, { sparse: true });
}
}
export default new NotificationQueue();

@ -245,21 +245,23 @@ describe('Sessions Aggregates', () => {
after(() => { mongoUnit.stop(); });
}
before(function() {
return MongoClient.connect(process.env.MONGO_URL)
.then((client) => { db = client.db('test'); });
});
before(async () => {
const client = await MongoClient.connect(process.env.MONGO_URL);
db = client.db('test');
after(() => {
client.close();
});
await db.dropDatabase();
before(() => db.dropDatabase().then(() => {
const sessions = db.collection('sessions');
const sessions_dates = db.collection('sessions_dates');
return Promise.all([
sessions.insertMany(DATA.sessions),
sessions_dates.insertMany(DATA.sessions_dates),
]);
}));
after(() => { db.close(); });
});
it('should have sessions_dates data saved', () => {
const collection = db.collection('sessions_dates');

@ -19,10 +19,6 @@ export class BaseRaw {
return this.col.find(...args);
}
insert(...args) {
return this.col.insert(...args);
}
update(...args) {
return this.col.update(...args);
}

@ -0,0 +1,77 @@
import {
Collection,
ObjectId,
} from 'mongodb';
import { BaseRaw } from './BaseRaw';
import { INotification } from '../../../../definition/INotification';
export class NotificationQueueRaw extends BaseRaw {
public readonly col!: Collection<INotification>;
unsetSendingById(_id: string) {
return this.col.updateOne({ _id }, {
$unset: {
sending: 1,
},
});
}
removeById(_id: string) {
return this.col.deleteOne({ _id });
}
clearScheduleByUserId(uid: string) {
return this.col.updateMany({
uid,
schedule: { $exists: true },
}, {
$unset: {
schedule: 1,
},
});
}
async clearQueueByUserId(uid: string): Promise<number | undefined> {
const op = await this.col.deleteMany({
uid,
});
return op.deletedCount;
}
async findNextInQueueOrExpired(expired: Date): Promise<INotification | undefined> {
const now = new Date();
const result = await this.col.findOneAndUpdate({
$and: [{
$or: [
{ sending: { $exists: false } },
{ sending: { $lte: expired } },
],
}, {
$or: [
{ schedule: { $exists: false } },
{ schedule: { $lte: now } },
],
}],
}, {
$set: {
sending: now,
},
}, {
sort: {
ts: 1,
},
});
return result.value;
}
insertOne(data: Omit<INotification, '_id'>) {
return this.col.insertOne({
_id: new ObjectId().toHexString(),
...data,
});
}
}

@ -46,6 +46,8 @@ import LivechatAgentActivityModel from '../models/LivechatAgentActivity';
import { LivechatAgentActivityRaw } from './LivechatAgentActivity';
import StatisticsModel from '../models/Statistics';
import { StatisticsRaw } from './Statistics';
import NotificationQueueModel from '../models/NotificationQueue';
import { NotificationQueueRaw } from './NotificationQueue';
export const Permissions = new PermissionsRaw(PermissionsModel.model.rawCollection());
export const Roles = new RolesRaw(RolesModel.model.rawCollection());
@ -71,3 +73,4 @@ export const CustomSounds = new CustomSoundsRaw(CustomSoundsModel.model.rawColle
export const CustomUserStatus = new CustomUserStatusRaw(CustomUserStatusModel.model.rawCollection());
export const LivechatAgentActivity = new LivechatAgentActivityRaw(LivechatAgentActivityModel.model.rawCollection());
export const Statistics = new StatisticsRaw(StatisticsModel.model.rawCollection());
export const NotificationQueue = new NotificationQueueRaw(NotificationQueueModel.model.rawCollection());

@ -0,0 +1,144 @@
import { Meteor } from 'meteor/meteor';
import { INotification, INotificationItemPush, INotificationItemEmail, NotificationItem } from '../../../definition/INotification';
import { NotificationQueue, Users } from '../../models/server/raw';
import { sendEmailFromData } from '../../lib/server/functions/notifications/email';
import { PushNotification } from '../../push-notifications/server';
const {
NOTIFICATIONS_WORKER_TIMEOUT = 2000,
NOTIFICATIONS_BATCH_SIZE = 100,
NOTIFICATIONS_SCHEDULE_DELAY = 120,
} = process.env;
class NotificationClass {
private running = false;
private cyclePause = Number(NOTIFICATIONS_WORKER_TIMEOUT);
private maxBatchSize = Number(NOTIFICATIONS_BATCH_SIZE);
private maxScheduleDelaySeconds = Number(NOTIFICATIONS_SCHEDULE_DELAY);
initWorker(): void {
this.running = true;
this.executeWorkerLater();
}
stopWorker(): void {
this.running = false;
}
executeWorkerLater(): void {
if (!this.running) {
return;
}
setTimeout(this.worker.bind(this), this.cyclePause);
}
async worker(counter = 0): Promise<void> {
const notification = await this.getNextNotification();
if (!notification) {
return this.executeWorkerLater();
}
// Once we start notifying the user we anticipate all the schedules
const flush = await NotificationQueue.clearScheduleByUserId(notification.uid);
// start worker again it queue flushed
if (flush.modifiedCount) {
await NotificationQueue.unsetSendingById(notification._id);
return this.worker(counter);
}
console.log('processing', notification._id);
try {
for (const item of notification.items) {
switch (item.type) {
case 'push':
this.push(notification, item);
break;
case 'email':
this.email(item);
break;
}
}
NotificationQueue.removeById(notification._id);
} catch (e) {
console.error(e);
await NotificationQueue.unsetSendingById(notification._id);
}
if (counter >= this.maxBatchSize) {
return this.executeWorkerLater();
}
this.worker(counter++);
}
getNextNotification(): Promise<INotification | undefined> {
const expired = new Date();
expired.setMinutes(expired.getMinutes() - 5);
return NotificationQueue.findNextInQueueOrExpired(expired);
}
push({ uid, rid, mid }: INotification, item: INotificationItemPush): void {
PushNotification.send({
rid,
uid,
mid,
...item.data,
});
}
email(item: INotificationItemEmail): void {
sendEmailFromData(item.data);
}
async scheduleItem({ uid, rid, mid, items }: {uid: string; rid: string; mid: string; items: NotificationItem[]}): Promise<void> {
const user = await Users.findOneById(uid, {
projection: {
statusConnection: 1,
_updatedAt: 1,
},
});
if (!user) {
return;
}
const delay = this.maxScheduleDelaySeconds;
let schedule: Date | undefined;
if (user.statusConnection === 'online') {
schedule = new Date();
schedule.setSeconds(schedule.getSeconds() + delay);
} else if (user.statusConnection === 'away') {
const elapsedSeconds = Math.floor((Date.now() - user._updatedAt) / 1000);
if (elapsedSeconds < delay) {
schedule = new Date();
schedule.setSeconds(schedule.getSeconds() + delay - elapsedSeconds);
}
}
await NotificationQueue.insertOne({
uid,
rid,
mid,
ts: new Date(),
schedule,
items,
});
}
}
export const Notification = new NotificationClass();
Meteor.startup(() => {
Notification.initWorker();
});

@ -1,7 +1,9 @@
import { Meteor } from 'meteor/meteor';
import { Push } from '../../../push/server';
import { settings } from '../../../settings';
import { metrics } from '../../../metrics';
import { RocketChatAssets } from '../../../assets';
import { settings } from '../../../settings/server';
import { metrics } from '../../../metrics/server';
import { RocketChatAssets } from '../../../assets/server';
export class PushNotification {
getNotificationId(roomId) {
@ -20,7 +22,7 @@ export class PushNotification {
return hash;
}
send({ roomName, roomId, username, message, usersTo, payload, badge = 1, category }) {
send({ rid, uid: userId, mid: messageId, roomName, username, message, payload, badge = 1, category }) {
let title;
if (roomName && roomName !== '') {
title = `${ roomName }`;
@ -28,6 +30,7 @@ export class PushNotification {
} else {
title = `${ username }`;
}
const config = {
from: 'push',
badge,
@ -35,12 +38,16 @@ export class PushNotification {
priority: 10,
title,
text: message,
payload,
query: usersTo,
notId: this.getNotificationId(roomId),
payload: {
host: Meteor.absoluteUrl(),
rid,
messageId,
...payload,
},
userId,
notId: this.getNotificationId(rid),
gcm: {
style: 'inbox',
summaryText: '%n% new messages',
image: RocketChatAssets.getURL('Assets_favicon_192'),
},
};

@ -1,3 +1,3 @@
import './methods';
export { Push, appTokensCollection, notificationsCollection } from './push';
export { Push, appTokensCollection } from './push';

@ -10,14 +10,9 @@ import { sendGCM } from './gcm';
import { logger, LoggerManager } from './logger';
export const _matchToken = Match.OneOf({ apn: String }, { gcm: String });
export const notificationsCollection = new Mongo.Collection('_raix_push_notifications');
export const appTokensCollection = new Mongo.Collection('_raix_push_app_tokens');
appTokensCollection._ensureIndex({ userId: 1 });
notificationsCollection._ensureIndex({ createdAt: 1 });
notificationsCollection._ensureIndex({ sent: 1 });
notificationsCollection._ensureIndex({ sending: 1 });
notificationsCollection._ensureIndex({ delayUntil: 1 });
export class PushClass {
options = {}
@ -53,116 +48,6 @@ export class PushClass {
if (this.options.apn) {
initAPN({ options: this.options, absoluteUrl: Meteor.absoluteUrl() });
}
// This interval will allow only one notification to be sent at a time, it
// will check for new notifications at every `options.sendInterval`
// (default interval is 15000 ms)
//
// It looks in notifications collection to see if theres any pending
// notifications, if so it will try to reserve the pending notification.
// If successfully reserved the send is started.
//
// If notification.query is type string, it's assumed to be a json string
// version of the query selector. Making it able to carry `$` properties in
// the mongo collection.
//
// Pr. default notifications are removed from the collection after send have
// completed. Setting `options.keepNotifications` will update and keep the
// notification eg. if needed for historical reasons.
//
// After the send have completed a "send" event will be emitted with a
// status object containing notification id and the send result object.
//
let isSendingNotification = false;
const sendNotification = (notification) => {
logger.debug('Sending notification', notification);
// Reserve notification
const now = Date.now();
const timeoutAt = now + this.options.sendTimeout;
const reserved = notificationsCollection.update({
_id: notification._id,
sent: false, // xxx: need to make sure this is set on create
sending: { $lt: now },
}, {
$set: {
sending: timeoutAt,
},
});
// Make sure we only handle notifications reserved by this instance
if (reserved) {
// Check if query is set and is type String
if (notification.query && notification.query === String(notification.query)) {
try {
// The query is in string json format - we need to parse it
notification.query = JSON.parse(notification.query);
} catch (err) {
// Did the user tamper with this??
throw new Error(`Error while parsing query string, Error: ${ err.message }`);
}
}
// Send the notification
const result = this.serverSend(notification, this.options);
if (!this.options.keepNotifications) {
// Pr. Default we will remove notifications
notificationsCollection.remove({ _id: notification._id });
} else {
// Update the notification
notificationsCollection.update({ _id: notification._id }, {
$set: {
// Mark as sent
sent: true,
// Set the sent date
sentAt: new Date(),
// Count
count: result,
// Not being sent anymore
sending: 0,
},
});
}
}
};
this.sendWorker(() => {
if (isSendingNotification) {
return;
}
try {
// Set send fence
isSendingNotification = true;
const batchSize = this.options.sendBatchSize || 1;
// Find notifications that are not being or already sent
notificationsCollection.find({
sent: false,
sending: { $lt: Date.now() },
$or: [
{ delayUntil: { $exists: false } },
{ delayUntil: { $lte: new Date() } },
],
}, {
sort: { createdAt: 1 },
limit: batchSize,
}).forEach((notification) => {
try {
sendNotification(notification);
} catch (error) {
logger.debug(`Could not send notification id: "${ notification._id }", Error: ${ error.message }`);
logger.debug(error.stack);
}
});
} finally {
// Remove the send fence
isSendingNotification = false;
}
}, this.options.sendInterval || 15000); // Default every 15th sec
}
sendWorker(task, interval) {
@ -185,7 +70,7 @@ export class PushClass {
appTokensCollection.rawCollection().deleteOne({ token });
}
serverSendNative(app, notification, countApn, countGcm) {
sendNotificationNative(app, notification, countApn, countGcm) {
logger.debug('send to token', app.token);
notification.payload = notification.payload ? { ejson: EJSON.stringify(notification.payload) } : {};
@ -255,7 +140,7 @@ export class PushClass {
});
}
serverSendGateway(app, notification, countApn, countGcm) {
sendNotificationGateway(app, notification, countApn, countGcm) {
for (const gateway of this.options.gateways) {
logger.debug('send to token', app.token);
@ -272,7 +157,9 @@ export class PushClass {
}
}
serverSend(notification = { badge: 0 }) {
sendNotification(notification = { badge: 0 }) {
logger.debug('Sending notification', notification);
const countApn = [];
const countGcm = [];
@ -286,30 +173,24 @@ export class PushClass {
throw new Error('Push.send: option "text" not a string');
}
logger.debug(`send message "${ notification.title }" via query`, notification.query);
logger.debug(`send message "${ notification.title }" to userId`, notification.userId);
const query = {
$and: [notification.query, {
$or: [{
'token.apn': {
$exists: true,
},
}, {
'token.gcm': {
$exists: true,
},
}],
}],
userId: notification.userId,
$or: [
{ 'token.apn': { $exists: true } },
{ 'token.gcm': { $exists: true } },
],
};
appTokensCollection.find(query).forEach((app) => {
logger.debug('send to token', app.token);
if (this.options.gateways) {
return this.serverSendGateway(app, notification, countApn, countGcm);
return this.sendNotificationGateway(app, notification, countApn, countGcm);
}
return this.serverSendNative(app, notification, countApn, countGcm);
return this.sendNotificationNative(app, notification, countApn, countGcm);
});
if (LoggerManager.logLevel === 2) {
@ -376,23 +257,15 @@ export class PushClass {
notId: Match.Optional(Match.Integer),
}),
android_channel_id: Match.Optional(String),
query: Match.Optional(String),
token: Match.Optional(_matchToken),
tokens: Match.Optional([_matchToken]),
userId: String,
payload: Match.Optional(Object),
delayUntil: Match.Optional(Date),
createdAt: Date,
createdBy: Match.OneOf(String, null),
});
// Make sure a token selector or query have been set
if (!notification.token && !notification.tokens && !notification.query) {
throw new Error('No token selector or query found');
}
// If tokens array is set it should not be empty
if (notification.tokens && !notification.tokens.length) {
throw new Error('No tokens in array');
if (!notification.userId) {
throw new Error('No userId found');
}
}
@ -409,7 +282,7 @@ export class PushClass {
createdBy: currentUser,
sent: false,
sending: 0,
}, _.pick(options, 'from', 'title', 'text'));
}, _.pick(options, 'from', 'title', 'text', 'userId'));
// Add extra
Object.assign(notification, _.pick(options, 'payload', 'badge', 'sound', 'notId', 'delayUntil', 'android_channel_id'));
@ -422,11 +295,6 @@ export class PushClass {
notification.gcm = _.pick(options.gcm, 'image', 'style', 'summaryText', 'picture', 'from', 'title', 'text', 'badge', 'sound', 'notId', 'actions', 'android_channel_id');
}
// Set one token selector, this can be token, array of tokens or query
if (options.query) {
notification.query = JSON.stringify(options.query);
}
if (options.contentAvailable != null) {
notification.contentAvailable = options.contentAvailable;
}
@ -438,8 +306,12 @@ export class PushClass {
// Validate the notification
this._validateDocument(notification);
// Try to add the notification to send, we return an id to keep track
return notificationsCollection.insert(notification);
try {
this.sendNotification(notification);
} catch (error) {
logger.debug(`Could not send notification id: "${ notification._id }", Error: ${ error.message }`);
logger.debug(error.stack);
}
}
}

@ -4,7 +4,6 @@ import _ from 'underscore';
import { Meteor } from 'meteor/meteor';
import { InstanceStatus } from 'meteor/konecty:multiple-instances-status';
import { notificationsCollection } from '../../../push/server';
import {
Sessions,
Settings,
@ -22,6 +21,7 @@ import { Info, getMongoInfo } from '../../../utils/server';
import { Migrations } from '../../../migrations/server';
import { Apps } from '../../../apps/server';
import { getStatistics as federationGetStatistics } from '../../../federation/server/functions/dashboard';
import { NotificationQueue } from '../../../models/server/raw';
const wizardFields = [
'Organization_Type',
@ -166,7 +166,7 @@ export const statistics = {
totalWithScriptEnabled: integrations.filter((integration) => integration.scriptEnabled === true).length,
};
statistics.pushQueue = notificationsCollection.find().count();
statistics.pushQueue = Promise.await(NotificationQueue.col.estimatedDocumentCount());
return statistics;
},

@ -0,0 +1,44 @@
export interface INotificationItemPush {
type: 'push';
data: {
payload: {
sender: {
_id: string;
username: string;
name?: string;
};
type: string;
};
roomName: string;
username: string;
message: string;
badge: number;
category: string;
};
}
export interface INotificationItemEmail {
type: 'email';
data: {
to: string;
subject: string;
html: string;
data: {
room_path: string;
};
from: string;
};
}
export type NotificationItem = INotificationItemPush | INotificationItemEmail;
export interface INotification {
_id: string;
uid: string;
rid: string;
mid: string;
ts: Date;
schedule?: Date;
sending?: Date;
items: NotificationItem[];
}

160
package-lock.json generated

@ -19,6 +19,50 @@
"requires": {
"lodash": "^4.17.4",
"mongodb": "^2.2.22"
},
"dependencies": {
"es6-promise": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.2.1.tgz",
"integrity": "sha1-7FYjOGgDKQkgcXDDlEjiREndH8Q="
},
"mongodb": {
"version": "2.2.36",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-2.2.36.tgz",
"integrity": "sha512-P2SBLQ8Z0PVx71ngoXwo12+FiSfbNfGOClAao03/bant5DgLNkOPAck5IaJcEk4gKlQhDEURzfR3xuBG1/B+IA==",
"requires": {
"es6-promise": "3.2.1",
"mongodb-core": "2.1.20",
"readable-stream": "2.2.7"
}
},
"process-nextick-args": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz",
"integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M="
},
"readable-stream": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.7.tgz",
"integrity": "sha1-BwV6y+JGeyIELTb5jFrVBwVOlbE=",
"requires": {
"buffer-shims": "~1.0.0",
"core-util-is": "~1.0.0",
"inherits": "~2.0.1",
"isarray": "~1.0.0",
"process-nextick-args": "~1.0.6",
"string_decoder": "~1.0.0",
"util-deprecate": "~1.0.1"
}
},
"string_decoder": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz",
"integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==",
"requires": {
"safe-buffer": "~5.1.0"
}
}
}
},
"@accounts/server": {
@ -6159,6 +6203,15 @@
"@types/node": "*"
}
},
"@types/bson": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.2.tgz",
"integrity": "sha512-+uWmsejEHfmSjyyM/LkrP0orfE2m5Mx9Xel4tXNeqi1ldK5XMQcDsFkBmLDtuyKUbxj2jGDo0H240fbCRJZo7Q==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/caseless": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.1.tgz",
@ -6292,6 +6345,16 @@
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz",
"integrity": "sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw=="
},
"@types/mongodb": {
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.5.8.tgz",
"integrity": "sha512-2yOociZaXyiJ9CvGp/svjtlMCIPdl82XIRVmx35ehuWA046bipLwwcXfwVyvTYIU98yWYK5p44knCVQ+ZS4Bdw==",
"dev": true,
"requires": {
"@types/bson": "*",
"@types/node": "*"
}
},
"@types/node": {
"version": "9.6.40",
"resolved": "https://registry.npmjs.org/@types/node/-/node-9.6.40.tgz",
@ -12915,6 +12978,11 @@
"integrity": "sha1-OjYof1A05pnnV3kBBSwubJQlFjE=",
"dev": true
},
"denque": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-1.4.1.tgz",
"integrity": "sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ=="
},
"depd": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
@ -20735,6 +20803,12 @@
"readable-stream": "^2.0.1"
}
},
"memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
"optional": true
},
"mensch": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.3.tgz",
@ -22147,38 +22221,40 @@
"ms": "^2.1.1"
}
},
"es6-promise": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.2.1.tgz",
"integrity": "sha1-7FYjOGgDKQkgcXDDlEjiREndH8Q=",
"dev": true
},
"mongodb": {
"version": "2.2.36",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-2.2.36.tgz",
"integrity": "sha512-P2SBLQ8Z0PVx71ngoXwo12+FiSfbNfGOClAao03/bant5DgLNkOPAck5IaJcEk4gKlQhDEURzfR3xuBG1/B+IA==",
"dev": true,
"requires": {
"es6-promise": "3.2.1",
"mongodb-core": "2.1.20",
"readable-stream": "2.2.7"
}
},
"ms": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
"integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==",
"dev": true
}
}
},
"mongodb": {
"version": "2.2.36",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-2.2.36.tgz",
"integrity": "sha512-P2SBLQ8Z0PVx71ngoXwo12+FiSfbNfGOClAao03/bant5DgLNkOPAck5IaJcEk4gKlQhDEURzfR3xuBG1/B+IA==",
"requires": {
"es6-promise": "3.2.1",
"mongodb-core": "2.1.20",
"readable-stream": "2.2.7"
},
"dependencies": {
"es6-promise": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.2.1.tgz",
"integrity": "sha1-7FYjOGgDKQkgcXDDlEjiREndH8Q="
},
"process-nextick-args": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz",
"integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M="
"integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=",
"dev": true
},
"readable-stream": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.7.tgz",
"integrity": "sha1-BwV6y+JGeyIELTb5jFrVBwVOlbE=",
"dev": true,
"requires": {
"buffer-shims": "~1.0.0",
"core-util-is": "~1.0.0",
@ -22193,12 +22269,42 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz",
"integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==",
"dev": true,
"requires": {
"safe-buffer": "~5.1.0"
}
}
}
},
"mongodb": {
"version": "3.5.6",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.5.6.tgz",
"integrity": "sha512-sh3q3GLDLT4QmoDLamxtAECwC3RGjq+oNuK1ENV8+tnipIavss6sMYt77hpygqlMOCt0Sla5cl7H4SKCVBCGEg==",
"requires": {
"bl": "^2.2.0",
"bson": "^1.1.4",
"denque": "^1.4.1",
"require_optional": "^1.0.1",
"safe-buffer": "^5.1.2",
"saslprep": "^1.0.0"
},
"dependencies": {
"bl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-2.2.0.tgz",
"integrity": "sha512-wbgvOpqopSr7uq6fJrLH8EsvYMJf9gzfo2jCsL2eTy75qXPukA4pCgHamOQkZtY5vmfVtjB+P3LNlMHW5CEZXA==",
"requires": {
"readable-stream": "^2.3.5",
"safe-buffer": "^5.1.1"
}
},
"bson": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/bson/-/bson-1.1.4.tgz",
"integrity": "sha512-S/yKGU1syOMzO86+dGpg2qGoDL0zvzcb262G+gqEy6TgP6rt6z6qxSFX/8X6vLC91P7G7C3nLs0+bvDzmvBA3Q=="
}
}
},
"mongodb-core": {
"version": "2.1.20",
"resolved": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-2.1.20.tgz",
@ -26931,6 +27037,15 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"saslprep": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz",
"integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==",
"optional": true,
"requires": {
"sparse-bitfield": "^3.0.3"
}
},
"sax": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz",
@ -27699,6 +27814,15 @@
"integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==",
"dev": true
},
"sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=",
"optional": true,
"requires": {
"memory-pager": "^1.0.2"
}
},
"spawn-sync": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/spawn-sync/-/spawn-sync-1.0.15.tgz",

@ -74,6 +74,7 @@
"@storybook/react": "^5.2.8",
"@types/bcrypt": "^3.0.0",
"@types/meteor": "^1.4.37",
"@types/mongodb": "^3.5.8",
"@typescript-eslint/eslint-plugin": "^2.11.0",
"@typescript-eslint/parser": "^2.11.0",
"acorn": "^6.4.1",
@ -202,6 +203,7 @@
"mkdirp": "^0.5.1",
"moment": "^2.22.2",
"moment-timezone": "^0.5.27",
"mongodb": "^3.5.6",
"node-dogstatsd": "^0.0.7",
"node-gcm": "0.14.4",
"node-rsa": "^1.0.5",

@ -2515,8 +2515,6 @@
"Notification_RequireInteraction_Description": "Works only with Chrome browser versions > 50. Utilizes the parameter <i>requireInteraction</i> to show the desktop notification to indefinite until the user interacts with it.",
"Notification_Mobile_Default_For": "Push Mobile Notifications For",
"Notifications": "Notifications",
"Notifications_Always_Notify_Mobile": "Always notify mobile",
"Notifications_Always_Notify_Mobile_Description": "Choose to always notify mobile device regardless of presence status.",
"Notifications_Duration": "Notifications Duration",
"Notifications_Max_Room_Members": "Max Room Members Before Disabling All Message Notifications",
"Notifications_Max_Room_Members_Description": "Max number of members in room when notifications for all messages gets disabled. Users can still change per room setting to receive all notifications on an individual basis. (0 to disable)",
@ -2735,9 +2733,6 @@
"Push_apn_dev_passphrase": "APN Dev Passphrase",
"Push_apn_key": "APN Key",
"Push_apn_passphrase": "APN Passphrase",
"Push_debug": "Debug",
"Push_send_interval": "Interval to check the queue for new push notifications",
"Push_send_batch_size": "Batch size to be processed every tick",
"Push_enable": "Enable",
"Push_enable_gateway": "Enable Gateway",
"Push_gateway": "Gateway",

@ -61,9 +61,7 @@ Meteor.methods({
text: `@${ user.username }:\n${ TAPi18n.__('This_is_a_push_test_messsage') }`,
},
sound: 'default',
query: {
userId: user._id,
},
userId: user._id,
});
return {
@ -112,8 +110,6 @@ function configurePush() {
apn,
gcm,
production: settings.get('Push_production'),
sendInterval: settings.get('Push_send_interval'),
sendBatchSize: settings.get('Push_send_batch_size'),
gateways: settings.get('Push_enable_gateway') === true ? settings.get('Push_gateway').split('\n') : undefined,
uniqueId: settings.get('uniqueID'),
getAuthorization() {

@ -1,8 +1,9 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { callbacks } from '../../app/callbacks';
import { Subscriptions } from '../../app/models';
import { callbacks } from '../../app/callbacks/server';
import { Subscriptions } from '../../app/models/server';
import { NotificationQueue } from '../../app/models/server/raw';
Meteor.methods({
readMessages(rid) {
@ -29,6 +30,8 @@ Meteor.methods({
Subscriptions.setAsReadByRoomIdAndUserId(rid, userId);
NotificationQueue.clearQueueByUserId(userId);
Meteor.defer(() => {
callbacks.run('afterReadMessages', rid, { userId, lastSeen: userSubscription.ls });
});

@ -178,10 +178,10 @@ import './v177';
import './v178';
import './v179';
import './v180';
import './v181';
import './v182';
import './v183';
import './v184';
import './v185';
import './v186';
import './v187';
import './xrun';

@ -1,17 +0,0 @@
import { notificationsCollection } from '../../../app/push/server';
import { Migrations } from '../../../app/migrations/server';
import { Settings } from '../../../app/models/server';
Migrations.add({
version: 181,
async up() {
Settings.update({ _id: 'Push_send_interval', value: 5000 }, { $set: { value: 2000 } });
Settings.update({ _id: 'Push_send_batch_size', value: 10 }, { $set: { value: 100 } });
const date = new Date();
date.setHours(date.getHours() - 2); // 2 hours ago;
// Remove all records older than 2h
notificationsCollection.rawCollection().removeMany({ createdAt: { $lt: date } });
},
});

@ -0,0 +1,66 @@
import { Mongo } from 'meteor/mongo';
import { Migrations } from '../../../app/migrations/server';
import { Settings } from '../../../app/models/server';
import { NotificationQueue } from '../../../app/models/server/raw';
function convertNotification(notification) {
try {
const { userId } = JSON.parse(notification.query);
const username = notification.payload.sender?.username;
const roomName = notification.title !== username ? notification.title : '';
const message = roomName === '' ? notification.text : notification.text.replace(`${ username }: `, '');
return {
_id: notification._id,
uid: userId,
rid: notification.payload.rid,
mid: notification.payload.messageId,
ts: notification.createdAt,
items: [{
type: 'push',
data: {
payload: notification.payload,
roomName,
username,
message,
badge: notification.badge,
category: notification.apn?.category,
},
}],
};
} catch (e) {
//
}
}
async function migrateNotifications() {
const notificationsCollection = new Mongo.Collection('_raix_push_notifications');
const date = new Date();
date.setHours(date.getHours() - 2); // 2 hours ago;
const cursor = notificationsCollection.rawCollection().find({
createdAt: { $gte: date },
});
for await (const notification of cursor) {
const newNotification = convertNotification(notification);
if (newNotification) {
await NotificationQueue.insertOne(newNotification);
}
}
return notificationsCollection.rawCollection().drop();
}
Migrations.add({
version: 187,
up() {
Settings.remove({ _id: 'Push_send_interval' });
Settings.remove({ _id: 'Push_send_batch_size' });
Settings.remove({ _id: 'Push_debug' });
Settings.remove({ _id: 'Notifications_Always_Notify_Mobile' });
Promise.await(migrateNotifications());
},
});

@ -29,7 +29,7 @@
},
"moduleResolution": "node",
"resolveJsonModule": true,
"types": ["node", "mocha"],
"types": ["node"],
"esModuleInterop": true,
"preserveSymlinks": true,

Loading…
Cancel
Save