You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
nodebb/src/notifications.js

425 lines
14 KiB
JavaScript

'use strict';
const async = require('async');
const winston = require('winston');
const cron = require('cron').CronJob;
const nconf = require('nconf');
const _ = require('lodash');
const db = require('./database');
const User = require('./user');
const posts = require('./posts');
const groups = require('./groups');
const meta = require('./meta');
const batch = require('./batch');
const plugins = require('./plugins');
const utils = require('./utils');
const emailer = require('./emailer');
const Notifications = module.exports;
Notifications.baseTypes = [
'notificationType_upvote',
'notificationType_new-topic',
'notificationType_new-reply',
'notificationType_post-edit',
'notificationType_follow',
'notificationType_new-chat',
'notificationType_group-invite',
'notificationType_group-request-membership',
];
Notifications.privilegedTypes = [
'notificationType_new-register',
'notificationType_post-queue',
'notificationType_new-post-flag',
'notificationType_new-user-flag',
];
Notifications.getAllNotificationTypes = async function () {
const results = await plugins.fireHook('filter:user.notificationTypes', {
types: Notifications.baseTypes.slice(),
privilegedTypes: Notifications.privilegedTypes.slice(),
});
return results.types.concat(results.privilegedTypes);
};
Notifications.startJobs = function () {
winston.verbose('[notifications.init] Registering jobs.');
new cron('*/30 * * * *', Notifications.prune, null, true);
};
Notifications.get = async function (nid) {
const notifications = await Notifications.getMultiple([nid]);
return Array.isArray(notifications) && notifications.length ? notifications[0] : null;
};
Notifications.getMultiple = async function (nids) {
if (!Array.isArray(nids) || !nids.length) {
return [];
}
const keys = nids.map(nid => 'notifications:' + nid);
const notifications = await db.getObjects(keys);
const userKeys = notifications.map(n => n && n.from);
const usersData = await User.getUsersFields(userKeys, ['username', 'userslug', 'picture']);
notifications.forEach(function (notification, index) {
if (notification) {
if (notification.path && !notification.path.startsWith('http')) {
notification.path = nconf.get('relative_path') + notification.path;
}
notification.datetimeISO = utils.toISOString(notification.datetime);
if (notification.bodyLong) {
notification.bodyLong = utils.escapeHTML(notification.bodyLong);
}
notification.user = usersData[index];
if (notification.user) {
notification.image = notification.user.picture || null;
if (notification.user.username === '[[global:guest]]') {
notification.bodyShort = notification.bodyShort.replace(/([\s\S]*?),[\s\S]*?,([\s\S]*?)/, '$1, [[global:guest]], $2');
}
} else if (notification.image === 'brand:logo' || !notification.image) {
notification.image = meta.config['brand:logo'] || nconf.get('relative_path') + '/logo.png';
}
}
});
return notifications;
};
Notifications.filterExists = async function (nids) {
const exists = await db.isSortedSetMembers('notifications', nids);
return nids.filter((nid, idx) => exists[idx]);
};
Notifications.findRelated = async function (mergeIds, set) {
mergeIds = mergeIds.filter(Boolean);
if (!mergeIds.length) {
return [];
}
// A related notification is one in a zset that has the same mergeId
const nids = await db.getSortedSetRevRange(set, 0, -1);
const keys = nids.map(nid => 'notifications:' + nid);
const notificationData = await db.getObjectsFields(keys, ['mergeId']);
const notificationMergeIds = notificationData.map(notifObj => String(notifObj.mergeId));
const mergeSet = new Set(mergeIds.map(id => String(id)));
return nids.filter((nid, idx) => mergeSet.has(notificationMergeIds[idx]));
};
Notifications.create = async function (data) {
if (!data.nid) {
throw new Error('[[error:no-notification-id]]');
}
data.importance = data.importance || 5;
const oldNotif = await db.getObject('notifications:' + data.nid);
if (oldNotif && parseInt(oldNotif.pid, 10) === parseInt(data.pid, 10) && parseInt(oldNotif.importance, 10) > parseInt(data.importance, 10)) {
return null;
}
const now = Date.now();
data.datetime = now;
await Promise.all([
db.sortedSetAdd('notifications', now, data.nid),
db.setObject('notifications:' + data.nid, data),
]);
return data;
};
Notifications.push = async function (notification, uids) {
if (!notification || !notification.nid) {
return;
}
uids = Array.isArray(uids) ? _.uniq(uids) : [uids];
if (!uids.length) {
return;
}
setTimeout(function () {
batch.processArray(uids, async function (uids) {
await pushToUids(uids, notification);
}, { interval: 1000 }, function (err) {
if (err) {
winston.error(err.stack);
}
});
}, 1000);
};
async function pushToUids(uids, notification) {
async function sendNotification(uids) {
if (!uids.length) {
return;
}
const oneWeekAgo = Date.now() - 604800000;
const unreadKeys = uids.map(uid => 'uid:' + uid + ':notifications:unread');
const readKeys = uids.map(uid => 'uid:' + uid + ':notifications:read');
await db.sortedSetsAdd(unreadKeys, notification.datetime, notification.nid);
await db.sortedSetsRemove(readKeys, notification.nid);
await db.sortedSetsRemoveRangeByScore(unreadKeys, '-inf', oneWeekAgo);
await db.sortedSetsRemoveRangeByScore(readKeys, '-inf', oneWeekAgo);
const websockets = require('./socket.io');
if (websockets.server) {
uids.forEach(function (uid) {
websockets.in('uid_' + uid).emit('event:new_notification', notification);
});
}
}
async function sendEmail(uids) {
// Update CTA messaging (as not all notification types need custom text)
if (['new-reply', 'new-chat'].includes(notification.type)) {
notification['cta-type'] = notification.type;
}
let body = notification.bodyLong || '';
body = posts.relativeToAbsolute(body, posts.urlRegex);
body = posts.relativeToAbsolute(body, posts.imgRegex);
await async.eachLimit(uids, 3, function (uid, next) {
emailer.send('notification', uid, {
path: notification.path,
notification_url: notification.path.startsWith('http') ? notification.path : nconf.get('url') + notification.path,
subject: utils.stripHTMLTags(notification.subject || '[[notifications:new_notification]]'),
intro: utils.stripHTMLTags(notification.bodyShort),
body: body,
notification: notification,
showUnsubscribe: true,
}, next);
});
}
async function getUidsBySettings(uids) {
const uidsToNotify = [];
const uidsToEmail = [];
const usersSettings = await User.getMultipleUserSettings(uids);
usersSettings.forEach(function (userSettings) {
const setting = userSettings['notificationType_' + notification.type] || 'notification';
if (setting === 'notification' || setting === 'notificationemail') {
uidsToNotify.push(userSettings.uid);
}
if (setting === 'email' || setting === 'notificationemail') {
uidsToEmail.push(userSettings.uid);
}
});
return { uidsToNotify: uidsToNotify, uidsToEmail: uidsToEmail };
}
// Remove uid from recipients list if they have blocked the user triggering the notification
uids = await User.blocks.filterUids(notification.from, uids);
const data = await plugins.fireHook('filter:notification.push', { notification: notification, uids: uids });
if (!data || !data.notification || !data.uids || !data.uids.length) {
return;
}
notification = data.notification;
let results = { uidsToNotify: data.uids, uidsToEmail: [] };
if (notification.type) {
results = await getUidsBySettings(data.uids);
}
await Promise.all([
sendNotification(results.uidsToNotify),
sendEmail(results.uidsToEmail),
]);
plugins.fireHook('action:notification.pushed', {
notification: notification,
uids: results.uidsToNotify,
uidsNotified: results.uidsToNotify,
uidsEmailed: results.uidsToEmail,
});
}
Notifications.pushGroup = async function (notification, groupName) {
if (!notification) {
return;
}
const members = await groups.getMembers(groupName, 0, -1);
await Notifications.push(notification, members);
};
Notifications.pushGroups = async function (notification, groupNames) {
if (!notification) {
return;
}
let groupMembers = await groups.getMembersOfGroups(groupNames);
groupMembers = _.uniq(_.flatten(groupMembers));
await Notifications.push(notification, groupMembers);
};
Notifications.rescind = async function (nid) {
await Promise.all([
db.sortedSetRemove('notifications', nid),
db.delete('notifications:' + nid),
]);
};
Notifications.markRead = async function (nid, uid) {
if (parseInt(uid, 10) <= 0 || !nid) {
return;
}
await Notifications.markReadMultiple([nid], uid);
};
Notifications.markUnread = async function (nid, uid) {
if (!(parseInt(uid, 10) > 0) || !nid) {
return;
}
const notification = await db.getObject('notifications:' + nid);
if (!notification) {
throw new Error('[[error:no-notification]]');
}
notification.datetime = notification.datetime || Date.now();
await Promise.all([
db.sortedSetRemove('uid:' + uid + ':notifications:read', nid),
db.sortedSetAdd('uid:' + uid + ':notifications:unread', notification.datetime, nid),
]);
};
Notifications.markReadMultiple = async function (nids, uid) {
nids = nids.filter(Boolean);
if (!Array.isArray(nids) || !nids.length || !(parseInt(uid, 10) > 0)) {
return;
}
let notificationKeys = nids.map(nid => 'notifications:' + nid);
let mergeIds = await db.getObjectsFields(notificationKeys, ['mergeId']);
// Isolate mergeIds and find related notifications
mergeIds = _.uniq(mergeIds.map(set => set.mergeId));
const relatedNids = await Notifications.findRelated(mergeIds, 'uid:' + uid + ':notifications:unread');
notificationKeys = _.union(nids, relatedNids).map(nid => 'notifications:' + nid);
let notificationData = await db.getObjectsFields(notificationKeys, ['nid', 'datetime']);
notificationData = notificationData.filter(n => n && n.nid);
nids = notificationData.map(n => n.nid);
const datetimes = notificationData.map(n => (n && n.datetime) || Date.now());
await Promise.all([
db.sortedSetRemove('uid:' + uid + ':notifications:unread', nids),
db.sortedSetAdd('uid:' + uid + ':notifications:read', datetimes, nids),
]);
};
Notifications.markAllRead = async function (uid) {
const nids = await db.getSortedSetRevRange('uid:' + uid + ':notifications:unread', 0, 99);
await Notifications.markReadMultiple(nids, uid);
};
Notifications.prune = async function () {
const week = 604800000;
const cutoffTime = Date.now() - week;
const nids = await db.getSortedSetRangeByScore('notifications', 0, 500, '-inf', cutoffTime);
if (!nids.length) {
return;
}
try {
await Promise.all([
db.sortedSetRemove('notifications', nids),
db.deleteAll(nids.map(nid => 'notifications:' + nid)),
]);
await batch.processSortedSet('users:joindate', async function (uids) {
await Promise.all([
db.sortedSetsRemoveRangeByScore(uids.map(uid => 'uid:' + uid + ':notifications:unread'), '-inf', cutoffTime),
db.sortedSetsRemoveRangeByScore(uids.map(uid => 'uid:' + uid + ':notifications:read'), '-inf', cutoffTime),
]);
}, { batch: 500, interval: 100 });
} catch (err) {
if (err) {
winston.error('Encountered error pruning notifications', err.stack);
}
}
};
Notifications.merge = async function (notifications) {
// When passed a set of notification objects, merge any that can be merged
const mergeIds = [
'notifications:upvoted_your_post_in',
'notifications:user_started_following_you',
'notifications:user_posted_to',
'notifications:user_flagged_post_in',
'notifications:user_flagged_user',
'new_register',
'post-queue',
];
notifications = mergeIds.reduce(function (notifications, mergeId) {
const isolated = notifications.filter(n => n && n.hasOwnProperty('mergeId') && n.mergeId.split('|')[0] === mergeId);
if (isolated.length <= 1) {
return notifications; // Nothing to merge
}
// Each isolated mergeId may have multiple differentiators, so process each separately
const differentiators = isolated.reduce(function (cur, next) {
const differentiator = next.mergeId.split('|')[1] || 0;
if (!cur.includes(differentiator)) {
cur.push(differentiator);
}
return cur;
}, []);
differentiators.forEach(function (differentiator) {
let set;
if (differentiator === 0 && differentiators.length === 1) {
set = isolated;
} else {
set = isolated.filter(n => n.mergeId === (mergeId + '|' + differentiator));
}
const modifyIndex = notifications.indexOf(set[0]);
if (modifyIndex === -1 || set.length === 1) {
return notifications;
}
switch (mergeId) {
case 'notifications:upvoted_your_post_in':
case 'notifications:user_started_following_you':
case 'notifications:user_posted_to':
case 'notifications:user_flagged_post_in':
case 'notifications:user_flagged_user':
var usernames = _.uniq(set.map(notifObj => notifObj && notifObj.user && notifObj.user.username));
var numUsers = usernames.length;
var title = utils.decodeHTMLEntities(notifications[modifyIndex].topicTitle || '');
var titleEscaped = title.replace(/%/g, '&#37;').replace(/,/g, '&#44;');
titleEscaped = titleEscaped ? (', ' + titleEscaped) : '';
if (numUsers === 2) {
notifications[modifyIndex].bodyShort = '[[' + mergeId + '_dual, ' + usernames.join(', ') + titleEscaped + ']]';
} else if (numUsers > 2) {
notifications[modifyIndex].bodyShort = '[[' + mergeId + '_multiple, ' + usernames[0] + ', ' + (numUsers - 1) + titleEscaped + ']]';
}
notifications[modifyIndex].path = set[set.length - 1].path;
break;
case 'new_register':
notifications[modifyIndex].bodyShort = '[[notifications:' + mergeId + '_multiple, ' + set.length + ']]';
break;
}
// Filter out duplicates
notifications = notifications.filter(function (notifObj, idx) {
if (!notifObj || !notifObj.mergeId) {
return true;
}
return !(notifObj.mergeId === (mergeId + (differentiator ? '|' + differentiator : '')) && idx !== modifyIndex);
});
});
return notifications;
}, notifications);
const data = await plugins.fireHook('filter:notifications.merge', {
notifications: notifications,
});
return data && data.notifications;
};
require('./promisify')(Notifications);