UNPKG

wingbot

Version:

Enterprise Messaging Bot Conversation Engine

1,031 lines (876 loc) 31.7 kB
/* * @author David Menger */ 'use strict'; const { EventEmitter } = require('events'); const Request = require('../Request'); const NotificationsStorage = require('./NotificationsStorage'); const api = require('./api'); const customFn = require('../utils/customFn'); const customCondition = require('../utils/customCondition'); const DEFAULT_LIMIT = 400; const DEFAULT_SEND_LIMIT = 20; const DAY = 86400000; const WINDOW_24_HOURS = DAY; // 24 hours const REMOVED_CAMPAIGN = '<removed campaign>'; const MAX_TS = 9999999999999; const DEFAULT_24_CLEARANCE = 600000; // ten minutes const SUBSCRIBE = '_$subscribe'; const UNSUBSCRIBE = '_$unsubscribe'; const DEFAULT_CAMPAIGN_DATA = { sent: 0, failed: 0, delivery: 0, read: 0, notSent: 0, leaved: 0, queued: 0, positive: 0, negative: 0, startAt: null, sliding: false, delay: 0, slide: null, slideRound: null, allowRepeat: false, type: null, hasCondition: false, condition: null }; /** * @typedef {object} CampaignTarget * @prop {string} senderId - sender identifier * @prop {string} pageId - page identifier * @prop {string} campaignId - campaign identifier * @prop {object} [data] - custom action data for specific target * @prop {number} [enqueue] - custom enqueue time, now will be used by default */ /** * @typedef Task {object} * @prop {string} id - task identifier * @prop {string} pageId - page identifier * @prop {string} senderId - user identifier * @prop {string} campaignId - campaign identifer * @prop {number} enqueue - when the task will be processed with queue * @prop {object} [data] - custom action data for specific target * @prop {number} [read] - time of read * @prop {number} [delivery] - time of delivery * @prop {number} [sent] - time of send * @prop {boolean} [reaction] - user reacted * @prop {number} [leaved] - time the event was not sent because user left */ /** * @typedef Campaign {object} * @prop {string} id * @prop {string} name * * Tatgeting * * @prop {string[]} include * @prop {string[]} exclude * @prop {string} pageId * * Stats * * @prop {number} sent * @prop {number} failed * @prop {number} delivery * @prop {number} read * @prop {number} notSent * @prop {number} leaved * @prop {number} queued * * Interaction * * @prop {string} action * @prop {object} [data] * * Setup * * @prop {boolean} sliding * @prop {number} delay * @prop {number} slide * @prop {boolean} active * @prop {boolean} in24hourWindow * @prop {boolean} allowRepeat * @prop {number} startAt * @prop {number} slideRound */ /** * @typedef {object} SenderSubscription * @prop {string} tag * @prop {object} meta */ /** * @typedef {object} Logger * @prop {Function} log * @prop {Function} error */ /** @typedef {import('./api/notificationsApiFactory').NotificationsApiOptions} ApiOptions */ /** * @typedef {object} NotificationsServiceOptions * @prop {Logger} [log] * @prop {number} [options.default24Clearance] - use this clearance to ensure delivery in 24h * @prop {string} [options.allAudienceTag] - tag to mark all users * * @typedef {ApiOptions & NotificationsServiceOptions} NotificationsOptions */ /** * Experimental notifications service * * @class Notifications */ class Notifications extends EventEmitter { /** * * Creates a new instance on notification service * * @memberof Notifications * * @param {NotificationsStorage} notificationStorage * @param {NotificationsOptions} options */ constructor (notificationStorage = new NotificationsStorage(), options = {}) { super(); this._storage = notificationStorage; this._log = options.log || console; /** @type {number} */ this.limit = DEFAULT_LIMIT; /** @type {number} */ this.sendLimit = DEFAULT_SEND_LIMIT; this._default24Clearance = options.default24Clearance || DEFAULT_24_CLEARANCE; this._allAudienceTag = typeof options.allAudienceTag !== 'undefined' ? options.allAudienceTag : '#all'; // ensure unique timestamps for messages this._lts = new Map(); this._preprocessSubscribe = options.preprocessSubscribe; this._preprocessSubscribers = options.preprocessSubscribers; this._preprocessSubscriptions = options.preprocessSubscriptions; } /** * API Factory * * @memberof Notifications * * @param {string[]|Function} [acl] - limit api to array of groups or use auth function * @returns {object} - the graphql api object */ api (acl = null) { const options = { preprocessSubscribers: this._preprocessSubscribers, preprocessSubscriptions: this._preprocessSubscriptions, preprocessSubscribe: this._preprocessSubscribe }; return api(this._storage, this, acl, options); } /** * Upsert the campaign * If the campaing does not exists add new. Otherwise, update it. * * @param {string} name * @param {string} action * @param {object} [data] * @param {object} options - use { id: '...' } to make campaign accessible from code * @returns {Promise<Campaign>} */ async createCampaign ( name, action, data = {}, options = {} ) { const campaign = { pageId: null }; const update = { name, action, data }; Object.assign(update, options); Object.assign(campaign, DEFAULT_CAMPAIGN_DATA, { startAt: null, active: true, in24hourWindow: true, include: [], exclude: [] }); Object.keys(options) .forEach((option) => { if (option === 'id') { Object.assign(campaign, { id: options.id }); } else if (typeof campaign[option] !== 'undefined') { delete campaign[option]; } }); return this._storage.upsertCampaign(campaign, update); } /** * Add tasks to process by queue * * @memberof Notifications * * @param {CampaignTarget[]} campaignTargets * @returns {Promise<Task[]>} */ async pushTasksToQueue (campaignTargets) { if (campaignTargets.length === 0) { return []; } const campaigns = new Map(); const defEnqueue = Date.now(); const tasks = campaignTargets .map((target) => ({ campaignId: target.campaignId, senderId: target.senderId, pageId: target.pageId, data: target.data || null, enqueue: target.enqueue || defEnqueue, // time, when campaign should be fired, sent: null, read: null, delivery: null, reaction: null, leaved: null })); const ret = await this._storage.pushTasks(tasks); ret.forEach((task) => { // has been enqueued previously if (task.insEnqueue !== task.enqueue) { return; } if (!campaigns.has(task.campaignId)) { campaigns.set(task.campaignId, { i: 1 }); } else { campaigns.get(task.campaignId).i++; } }); for (const [campaignId, { i }] of campaigns.entries()) { await this._storage.incrementCampaign(campaignId, { queued: i }); } return ret; } // eslint-disable-next-line jsdoc/require-param /** * Subscribe user under certain tag * * @memberof Notifications * * @param {string} senderId * @param {string} pageId * @param {string} tag */ async subscribe (senderId, pageId, tag, req = null, res = null, cmps = null) { if (req && !req.subscribtions.includes(tag)) { req.subscribtions.push(tag); this._updateResDataWithSubscribtions(req, res); // re-evalutate campaigns await Promise.all([ this._storage.subscribe(`${senderId}`, pageId, tag), this._postponeTasksOnInteraction(cmps, req, res) ]); if (tag !== this._allAudienceTag) { this._reportEvent('subscribed', tag, { senderId, pageId }); } } else { await this._storage.subscribe(`${senderId}`, pageId, tag); } } // eslint-disable-next-line jsdoc/require-param /** * Unsubscribe user from certain tag or from all tags * * @memberof Notifications * * @param {string} senderId * @param {string} pageId * @param {string} [tag] * @param {object} [req] * @param {object} [res] */ async unsubscribe (senderId, pageId, tag = null, req = null, res = null, cmps = null) { let unsubscibtions; if (req && req.subscribtions.includes(tag)) { req.subscribtions = req.subscribtions.filter((s) => s !== tag); this._updateResDataWithSubscribtions(req, res); // re-evalutate campaigns [unsubscibtions] = await Promise.all([ this._storage.unsubscribe(senderId, pageId, tag), this._postponeTasksOnInteraction(cmps, req, res) ]); } else { unsubscibtions = await this._storage .unsubscribe(senderId, pageId, tag); } unsubscibtions .forEach((sub) => this._reportEvent('unsubscribed', sub, { senderId, pageId })); } /** * Preprocess message - for read and delivery * * @memberof Notifications * * @param {object} event * @param {string} pageId * @returns {Promise<{status:number}>} */ async processMessage (event, pageId) { if (!event.sender || (!event.read && !event.delivery)) { return { status: 204 }; } const eventType = event.read ? 'read' : 'delivery'; const { watermark } = event[eventType]; const { timestamp: ts = Date.now() } = event; const senderId = event.sender.id; const tasks = await this._storage .updateTasksByWatermark(senderId, pageId, watermark, eventType, ts); await Promise.all(tasks .map((task) => this._messageDeliveryByMid( task.campaignId, eventType, senderId, pageId ))); return { status: 200 }; } async _messageDeliveryByMid (campaignId, eventType, senderId, pageId) { const campaign = await this._storage.incrementCampaign(campaignId, { [eventType]: 1 }); const campaignName = campaign ? campaign.name : REMOVED_CAMPAIGN; this._reportEvent(eventType, campaignName, { senderId, pageId }); } /** * * Get user subscribtions * * @param {string} senderId * @param {string} pageId * @returns {Promise<string[]>} */ async getSubscribtions (senderId, pageId) { return this._storage.getSenderSubscribtions(senderId, pageId); } /** * * Get user subscribtions * * @param {string} senderId * @param {string} pageId * @returns {Promise<SenderSubscription[]>} */ async getSubscriptions (senderId, pageId) { if (typeof this._storage.getSenderSubscriptions !== 'function') { const tags = await this._storage.getSenderSubscribtions(senderId, pageId); return tags.map((tag) => ({ tag, meta: {} })); } return this._storage.getSenderSubscriptions(senderId, pageId); } async _preloadSubscribtions (req, res) { if (res.data._requestSubscribtions) { req.subscribtions = res.data._requestSubscribtions; return; } const { senderId, pageId } = req; req.subscribtions = await this._storage.getSenderSubscribtions(senderId, pageId); this._updateResDataWithSubscribtions(req, res); if (this._allAudienceTag && !req.subscribtions.includes(this._allAudienceTag)) { await this.subscribe(senderId, pageId, this._allAudienceTag, req, res); } } _updateResDataWithSubscribtions (req, res) { const notificationSubscribtions = {}; req.subscribtions.forEach((subscribtion) => { Object.assign(notificationSubscribtions, { [subscribtion]: true }); }); res.setData({ _notificationSubscribtions: notificationSubscribtions, _requestSubscribtions: req.subscribtions }); } async beforeProcessMessage (req, res) { const notifications = this; // load sliding campaigns and postpone/insert their actions const [{ data: slidingCampaigns }] = await Promise.all([ this._storage.getCampaigns({ sliding: true, active: true }), this._preloadSubscribtions(req, res) ]); Object.assign(res, { subscribe (tag) { notifications .subscribe(req.senderId, req.pageId, tag, req, res, slidingCampaigns) .catch((e) => notifications._log.error(e)); }, unsubscribe (tag = null) { notifications .unsubscribe(req.senderId, req.pageId, tag, req, res, slidingCampaigns) .catch((e) => notifications._log.error(e)); } }); // process setState variables if (req.state[SUBSCRIBE]) { req.state[SUBSCRIBE].forEach((t) => res.subscribe(t)); delete req.state[SUBSCRIBE]; delete res.newState[SUBSCRIBE]; } if (req.state[UNSUBSCRIBE]) { req.state[UNSUBSCRIBE].forEach((t) => res.unsubscribe(t)); delete req.state[UNSUBSCRIBE]; delete res.newState[UNSUBSCRIBE]; } // is optin with token if (req.isOptin() && req.event.optin.one_time_notif_token) { const { one_time_notif_token: token } = req.event.optin; const { _ntfOneTimeTokens: tokens = [] } = req.state; const { _ntfTag: tag = null, ...data } = req.actionData(); if (tag) { res.subscribe(tag); } res.setState({ _ntfOneTimeTokens: [...tokens, { token, tag, data }] }); } // is action const { _ntfCampaign: campaign = req.campaign } = res.data; if (!campaign) { // track campaign success const { _ntfLastCampaignId: lastCampaignId, _ntfLastTask: taskId } = req.state; const { _trackAsNegative: isNegative = false, _localpostback: isLocal = false } = req.actionData(); if (lastCampaignId && !isLocal) { res.setState({ _ntfLastCampaignId: null, _ntfLastCampaignName: null, _ntfLastTask: null }); this._reportCampaignSuccess( isNegative ? 'negative' : 'positive', lastCampaignId, req.state._ntfLastCampaignName, { senderId: req.senderId, pageId: req.pageId }, taskId ); } await this._postponeTasksOnInteraction(slidingCampaigns, req, res); return true; } // ensure again the user has corresponding tags if (res.data._fromInitialEvent && !this._isTargetGroup(campaign, req.subscribtions, req.pageId)) { res.trackAs(false); res.keepPreviousContext(req, false, true); return false; } if (res.data._fromInitialEvent && !campaign.allowRepeat) { const task = await this._storage.getSentTask(req.pageId, req.senderId, campaign.id); if (task) { res.trackAs(false); res.keepPreviousContext(req, false, true); return false; } } if (res.data._fromInitialEvent && campaign.hasCondition) { let fn; if (!campaign.hasEditableCondition) { fn = customFn(campaign.condition, `Campaign "${campaign.name}" condition`); } else { fn = customCondition(campaign.editableCondition, req.configuration, `Campaign "${campaign.name}" condition`); } const fnRes = fn(req, res); if (!fnRes) { res.trackAs(false); res.keepPreviousContext(req, false, true); return false; } } res.setMessagingType(campaign.type || 'UPDATE'); // one time token with campaign token if (this._findAndUseToken(req, res, campaign)) { this._setLastCampaign(res, campaign, req.taskId); return true; } if (!campaign.in24hourWindow) { this._setLastCampaign(res, campaign, req.taskId); return true; } const { _ntfLastInteraction = Date.now() } = req.state; const inTimeFrame = Date.now() < (_ntfLastInteraction + WINDOW_24_HOURS); // do not send one message over, because of future campaigns if (inTimeFrame) { this._setLastCampaign(res, campaign, req.taskId); return true; } // one time token WITHOUT campaign token if (this._findAndUseToken(req, res)) { this._setLastCampaign(res, campaign, req.taskId); return true; } throw Object.assign(new Error('User fell out of 24h window'), { code: 402 }); } _findAndUseToken (req, res, campaign = null) { // one time token logic const { _ntfOneTimeTokens: tokens = [] } = req.state; // the campaign uses same tag as the token has const useToken = tokens.find((t) => { if (campaign) { return campaign.include.includes(t.tag); } return t.tag === null; }); if (useToken) { // pop the token res.setState({ _ntfOneTimeTokens: tokens.filter((t) => t.token !== useToken.token) }); res.setNotificationRecipient({ one_time_notif_token: useToken.token }); } return useToken; } _isTargetGroup (campaign, subscribtions, pageId) { // if there's page id it should match if (campaign.pageId && campaign.pageId !== pageId) { return false; } // if there's exclusion, it should also match if (subscribtions.some((s) => campaign.exclude.includes(s))) { return false; } return campaign.include.length === 0 || subscribtions.some((s) => campaign.include.includes(s)); } /* _isTargetGroup (campaign, subscribtions, pageId) { return ((campaign.include.length === 0 && (!campaign.pageId || campaign.pageId === pageId) && subscribtions.length !== 0) || subscribtions.some(s => campaign.include.includes(s))) && !subscribtions.some(s => campaign.exclude.includes(s)); } */ _reportCampaignSuccess (eventName, campaignId, campaignName, meta, taskId) { this._storage.incrementCampaign(campaignId, { [eventName]: 1 }) .catch((e) => this._log.error('report campaign success store', e)); if (taskId) { this._storage.updateTask(taskId, { reaction: true }); } this._reportEvent(eventName, campaignName, meta); } _setLastCampaign (res, campaign, taskId = null) { res.setState({ _ntfLastCampaignId: campaign.id, _ntfLastCampaignName: campaign.name, _ntfLastTask: taskId }); } _calculateSlide (timestamp, { slide, delay, slideRound = null, in24hourWindow = false }) { const time = timestamp + (slide || 0) + (delay || 0); if (typeof slideRound !== 'number') { return time; } // the next closest matching hour after the slide let zeroDaysTime = time - (time % DAY) + slideRound; if (zeroDaysTime < time) zeroDaysTime += DAY; if (!in24hourWindow) { return zeroDaysTime; } return Math.min(zeroDaysTime, timestamp + DAY - this._default24Clearance); } async _postponeTasksOnInteraction (data, req, res = null) { if (!data) { return; } const slidingCampaigns = data .filter((c) => this._isTargetGroup(c, req.subscribtions, req.pageId)); let { _ntfSlidingCampTasks: cache = [] } = req.state; const oldTasksToRemove = cache.filter((t) => t.enqueue < req.timestamp && slidingCampaigns.some((c) => c.id === t.campaignId)); // check the old tasks, because they're maybe waiting for send const oldTasks = await Promise.all( oldTasksToRemove .map(({ id }) => this._storage.getTaskById(id)) ); const taskIdsWaitingToBeSent = oldTasks .filter((t) => t && !t.sent && !t.leaved && !t.failed && !t.notSent) .map((t) => t.id); // remove the old tasks or tasks without campaigns cache = cache.filter((t) => slidingCampaigns.some((c) => c.id === t.campaignId) && (t.enqueue >= req.timestamp || taskIdsWaitingToBeSent.includes(t.id))); // postpone existing if it's not an action if (!res || !res.data._ntfCampaign) { cache = cache.map((t) => { const campaign = slidingCampaigns.find((c) => c.id === t.campaignId); if (!campaign.slide) { return t; } const enqueue = this._calculateSlide(req.timestamp, campaign); return { ...t, enqueue }; }); } await Promise.all(cache // update only sliding campaigns .filter(({ campaignId }) => { const campaign = slidingCampaigns.find((c) => c.id === campaignId); return !!campaign.slide; }) .map(({ id, enqueue }) => this._storage.updateTask(id, { enqueue }))); // missing tasks in cache const { senderId, pageId } = req; const insert = slidingCampaigns .filter((c) => !cache.some((t) => t.campaignId === c.id)); const checkCids = insert .filter((c) => !c.allowRepeat) .map((c) => c.id); // load the sent tasks const sentCampaigns = await this._storage .getSentCampagnIds(req.pageId, req.senderId, checkCids); const insertTasks = insert .filter((c) => c.allowRepeat || !sentCampaigns.includes(c.id)) .map((c) => ({ senderId, pageId, campaignId: c.id, enqueue: this._calculateSlide(req.timestamp, c) })); // insert tasks for sliding campaigns const insertedTasks = await this.pushTasksToQueue(insertTasks); cache.push(...insertedTasks.map((t) => ({ id: t.id, campaignId: t.campaignId, enqueue: t.enqueue }))); const set = { _ntfLastInteraction: req.timestamp, _ntfOverMessageSent: false, _ntfSlidingCampTasks: cache }; Object.assign(req.state, set); if (res) { res.setState(set); } } /** * Run the campaign now (push tasks into the queue) * * @memberof Notifications * * @param {object} campaign * @returns {Promise<{queued:number}>} */ async runCampaign (campaign) { let hasUsers = true; let lastKey = null; const { include, exclude } = campaign; const enqueue = campaign.startAt || Date.now(); let queued = 0; const campaignData = campaign.data || {}; while (hasUsers) { const { data: targets, lastKey: key } = await this._storage .getSubscribtions(include, exclude, this.limit, campaign.pageId, lastKey); lastKey = key; const campaignTargets = targets.map((target) => { const data = include.length === 0 ? campaignData : Object.assign( {}, campaignData, // @ts-ignore ...include.map((t) => (target.meta && target.meta[t]) || {}) ); return { senderId: target.senderId, pageId: target.pageId, campaignId: campaign.id, enqueue, data }; }); const actions = await this.pushTasksToQueue(campaignTargets); queued += actions.length; hasUsers = targets.length > 0 && !!lastKey; } return { queued }; } async processQueue (processor, timeLimit = 45000, timeStart = Date.now()) { // first, check out shedulled campaigns let run = true; const begin = Date.now(); while (run) { const now = timeStart + (Date.now() - begin); const pop = await this._storage.popCampaign(now); if (pop) { await this.runCampaign(pop); } else { run = false; } } run = true; while (run) { const now = timeStart + (Date.now() - begin); const pop = await this._storage.popTasks(this.sendLimit, now); await this._processPoppedTasks(pop, processor); run = pop.length !== 0 && (timeStart + timeLimit) > Date.now(); } } async _processPoppedTasks (pop, processor) { const campaignIds = pop.reduce((arr, task) => { if (arr.indexOf(task.campaignId) === -1) { arr.push(task.campaignId); } return arr; }, []); const campaigns = await this._storage.getCampaignByIds(campaignIds); return Promise.all(pop .map((task) => { const campaign = campaigns.find((c) => c.id === task.campaignId); return this._processTask(processor, task, campaign); })); } _uniqueTs (senderId) { // campaign should be in active state let ts = Date.now(); if (this._lts.has(senderId)) { const lts = this._lts.get(senderId); if (lts >= ts) { ts = lts + 1; } } this._lts.set(senderId, ts); // compact, if it's large if (this._lts.size > 50) { const keep = ts - 1000; this._lts = new Map(Array.from(this._lts.entries()) .filter((e) => e[1] > keep)); } return ts; } /** * Sends the message directly (without queue) * and records it's delivery status at campaign stats * * @param {object} campaign - campaign * @param {object} processor - channel processor instance * @param {string} pageId - page * @param {string} senderId - user * @param {object} [data] - override the data of campaign * @returns {Promise<{ status: number }>} * @example * const campaign = await notifications * .createCampaign('Custom campaign', 'camp-action', {}, { id: 'custom-campaign' }); * * await notifications.sendCampaignMessage(campaign, channel, pageId, senderId); */ async sendCampaignMessage (campaign, processor, pageId, senderId, data = null) { const campaignTarget = { senderId, pageId, campaignId: campaign.id, data, enqueue: MAX_TS // mark as processed }; const [task] = await this.pushTasksToQueue([campaignTarget]); return this._processTask(processor, task, campaign); } async _processTask (connector, task, campaign) { const ts = this._uniqueTs(task.senderId); if (!campaign || !campaign.active) { await this._finishTask('notSent', campaign, task, ts); return { status: 204 }; } const message = Request.campaignPostBack( task.senderId, campaign, ts, task.data, task.id, task.data ); let status; let mid; let result; try { result = await connector .processMessage(message, task.senderId, task.pageId, { _ntfCampaign: campaign }); status = result.status; // eslint-disable-line prefer-destructuring mid = result.results && result.results.length && result.results[result.results.length - 1].message_id; } catch (e) { this._log.error('send notification error', e); const { code = 500 } = e; status = code; result = { status }; } try { await this._storeSuccess(campaign, status, task, mid, ts); } catch (e) { this._log.error('store notification state error', e); } return result; } async _storeSuccess (campaign, status, task, mid, ts) { switch (status) { case 200: await this._finishTask('sent', campaign, task, ts, mid); break; case 204: case 400: await this._finishTask('notSent', campaign, task, ts); break; case 403: case 402: await this._finishTask('leaved', campaign, task, ts); break; case 500: default: await this._finishTask('failed', campaign, task, ts); break; } } async _finishTask (eventName, campaign, task, ts, mid = null) { const { senderId, pageId } = task; const promises = []; if (mid !== null) { promises.push(this._storage.updateTask(task.id, { mid, sent: ts, reaction: false })); } else { promises.push(this._storage.updateTask(task.id, { [eventName]: ts })); } if (campaign) { promises.push(this._storage.incrementCampaign(campaign.id, { [eventName]: 1 })); } await Promise.all(promises); const campaignName = campaign ? campaign.name : REMOVED_CAMPAIGN; this._reportEvent(eventName, campaignName, { senderId, pageId }); } _reportEvent (event, campaignNameOrTag, meta) { try { this.emit('report', event, campaignNameOrTag, meta); } catch (e) { this._log.error('report event emit error', e); } } } Notifications.SUBSCRIBE = SUBSCRIBE; Notifications.UNSUBSCRIBE = UNSUBSCRIBE; module.exports = Notifications;