diff --git a/.eslintrc b/.eslintrc index efef5b7d68d80..7bb7c4d7da9ca 100644 --- a/.eslintrc +++ b/.eslintrc @@ -90,7 +90,8 @@ "@typescript-eslint/interface-name-prefix": [ "error", "always" - ] + ], + "@typescript-eslint/explicit-function-return-type": "off" }, "env": { "browser": true, diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 6d0c06c5f360b..2d74d00f0e178 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -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 diff --git a/app/lib/server/functions/notifications/email.js b/app/lib/server/functions/notifications/email.js index 13a4652072d38..1640977bd0ed3 100644 --- a/app/lib/server/functions/notifications/email.js +++ b/app/lib/server/functions/notifications/email.js @@ -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; diff --git a/app/lib/server/functions/notifications/mobile.js b/app/lib/server/functions/notifications/mobile.js index 1e7a5d54849d3..fdc5dda7b4df4 100644 --- a/app/lib/server/functions/notifications/mobile.js +++ b/app/lib/server/functions/notifications/mobile.js @@ -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; diff --git a/app/lib/server/lib/sendNotificationsOnMessage.js b/app/lib/server/lib/sendNotificationsOnMessage.js index 0394dc99b191b..834eac239b0e7 100644 --- a/app/lib/server/lib/sendNotificationsOnMessage.js +++ b/app/lib/server/lib/sendNotificationsOnMessage.js @@ -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 = { diff --git a/app/lib/server/startup/settings.js b/app/lib/server/startup/settings.js index 56020606dc8ce..241db8151ea86 100644 --- a/app/lib/server/startup/settings.js +++ b/app/lib/server/startup/settings.js @@ -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', diff --git a/app/models/server/models/NotificationQueue.js b/app/models/server/models/NotificationQueue.js new file mode 100644 index 0000000000000..1210f5e73127f --- /dev/null +++ b/app/models/server/models/NotificationQueue.js @@ -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(); diff --git a/app/models/server/models/Sessions.tests.js b/app/models/server/models/Sessions.tests.js index 5e9a49589bf47..b033284946c2b 100644 --- a/app/models/server/models/Sessions.tests.js +++ b/app/models/server/models/Sessions.tests.js @@ -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'); diff --git a/app/models/server/raw/BaseRaw.js b/app/models/server/raw/BaseRaw.js index 1a65a2c9cebce..614c196ee7bad 100644 --- a/app/models/server/raw/BaseRaw.js +++ b/app/models/server/raw/BaseRaw.js @@ -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); } diff --git a/app/models/server/raw/NotificationQueue.ts b/app/models/server/raw/NotificationQueue.ts new file mode 100644 index 0000000000000..90dce3ebba9f6 --- /dev/null +++ b/app/models/server/raw/NotificationQueue.ts @@ -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; + + 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 { + const op = await this.col.deleteMany({ + uid, + }); + + return op.deletedCount; + } + + async findNextInQueueOrExpired(expired: Date): Promise { + 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) { + return this.col.insertOne({ + _id: new ObjectId().toHexString(), + ...data, + }); + } +} diff --git a/app/models/server/raw/index.js b/app/models/server/raw/index.js index 042900293a67c..ed74d56e7ec05 100644 --- a/app/models/server/raw/index.js +++ b/app/models/server/raw/index.js @@ -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()); diff --git a/app/notification-queue/server/NotificationQueue.ts b/app/notification-queue/server/NotificationQueue.ts new file mode 100644 index 0000000000000..758294542826c --- /dev/null +++ b/app/notification-queue/server/NotificationQueue.ts @@ -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 { + 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 { + 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 { + 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(); +}); diff --git a/app/push-notifications/server/lib/PushNotification.js b/app/push-notifications/server/lib/PushNotification.js index 7a91478698eb8..76847b6f4c3c5 100644 --- a/app/push-notifications/server/lib/PushNotification.js +++ b/app/push-notifications/server/lib/PushNotification.js @@ -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'), }, }; diff --git a/app/push/server/index.js b/app/push/server/index.js index 35f25a9251d96..32ac42bd31776 100644 --- a/app/push/server/index.js +++ b/app/push/server/index.js @@ -1,3 +1,3 @@ import './methods'; -export { Push, appTokensCollection, notificationsCollection } from './push'; +export { Push, appTokensCollection } from './push'; diff --git a/app/push/server/push.js b/app/push/server/push.js index 381ecbf497b32..69a094ddcc674 100644 --- a/app/push/server/push.js +++ b/app/push/server/push.js @@ -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); + } } } diff --git a/app/statistics/server/lib/statistics.js b/app/statistics/server/lib/statistics.js index 141a79b9e49f2..06dcca34dbfff 100644 --- a/app/statistics/server/lib/statistics.js +++ b/app/statistics/server/lib/statistics.js @@ -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; }, diff --git a/definition/INotification.ts b/definition/INotification.ts new file mode 100644 index 0000000000000..6359f33dd612c --- /dev/null +++ b/definition/INotification.ts @@ -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[]; +} diff --git a/package-lock.json b/package-lock.json index 653443192d1f1..b7cbefa15a7dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index fa44c0dd0f43a..84992469d2da8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index af5e9215cb6e8..98ad6ec394663 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -2515,8 +2515,6 @@ "Notification_RequireInteraction_Description": "Works only with Chrome browser versions > 50. Utilizes the parameter requireInteraction 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", diff --git a/server/lib/pushConfig.js b/server/lib/pushConfig.js index e1828bf2cfe80..eb59ff138681b 100644 --- a/server/lib/pushConfig.js +++ b/server/lib/pushConfig.js @@ -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() { diff --git a/server/methods/readMessages.js b/server/methods/readMessages.js index b33c61cd544d0..08b394f8c51da 100644 --- a/server/methods/readMessages.js +++ b/server/methods/readMessages.js @@ -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 }); }); diff --git a/server/startup/migrations/index.js b/server/startup/migrations/index.js index b8b54a6630a8f..104c783c77cf9 100644 --- a/server/startup/migrations/index.js +++ b/server/startup/migrations/index.js @@ -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'; diff --git a/server/startup/migrations/v181.js b/server/startup/migrations/v181.js deleted file mode 100644 index 8f285d7293842..0000000000000 --- a/server/startup/migrations/v181.js +++ /dev/null @@ -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 } }); - }, -}); diff --git a/server/startup/migrations/v187.js b/server/startup/migrations/v187.js new file mode 100644 index 0000000000000..6b430b8f7629e --- /dev/null +++ b/server/startup/migrations/v187.js @@ -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()); + }, +}); diff --git a/tsconfig.json b/tsconfig.json index 84d64aaa3aaa0..3ac6fd928b035 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,7 +29,7 @@ }, "moduleResolution": "node", "resolveJsonModule": true, - "types": ["node", "mocha"], + "types": ["node"], "esModuleInterop": true, "preserveSymlinks": true,