UNPKG

wingbot

Version:

Enterprise Messaging Bot Conversation Engine

729 lines (639 loc) 19.9 kB
/** * @author David Menger */ 'use strict'; const uuid = require('uuid'); /** * @typedef {object} Tag * @prop {string} tag * @prop {number} subscribtions */ /** * @typedef {object} Target * @prop {string} senderId * @prop {string} pageId * @prop {Object<string,object>} [meta] */ /** * @typedef {object} Subscribtion * @prop {string} senderId * @prop {string} pageId * @prop {string[]} subs * @prop {Object<string,object>} [meta] */ /** * @typedef {object} Campaign * @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} Task * @prop {string} id * @prop {string} pageId * @prop {string} senderId * @prop {string} campaignId * @prop {number} enqueue * @prop {number} [read] * @prop {number} [delivery] * @prop {number} [sent] * @prop {number} [insEnqueue] * @prop {boolean} [reaction] - user reacted * @prop {number} [leaved] - time the event was not sent because user left */ /** * @typedef {object} Subscription * @prop {string} tag * @prop {object} meta */ /** * @typedef {object} SubscriptionData * @prop {string} pageId * @prop {string} senderId * @prop {string[]} tags * @prop {boolean} [remove] * @prop {Object<string,object>} [meta] */ const MAX_TS = 9999999999999; class NotificationsStorage { constructor () { /** * @type {Task[]} */ this._tasks = []; /** * @type {Map<string,Campaign>} */ this._campaigns = new Map(); /** * @type {Map<string,Subscribtion>} */ this._subscribtions = new Map(); } /** * * @param {object} tasks * @returns {Promise<Task[]>} */ pushTasks (tasks) { // upsert through unique KEY (only single sliding campaign in queue) // [campaignId,senderId,pageId,sent] // maybe without unique key at dynamodb const ret = []; this._tasks = this._tasks .map((task) => { const overrideIndex = tasks .findIndex((t) => t.campaignId === task.campaignId && t.pageId === task.pageId && t.senderId === task.senderId && t.sent === task.sent); if (overrideIndex === -1) { return task; } let [override] = tasks.splice(overrideIndex, 1); override = { ...task, ...override, insEnqueue: Math.min(task.insEnqueue, override.enqueue), enqueue: override.enqueue === task.insEnqueue && task.insEnqueue !== MAX_TS ? task.insEnqueue + 1 : override.enqueue }; ret.push(override); return override; }); const insert = tasks.map((t) => ({ ...t, id: uuid.v4(), insEnqueue: t.enqueue })); ret.push(...insert); this._tasks.push(...insert); this._tasks.sort((a, b) => a.enqueue - b.enqueue); return Promise.resolve(ret); } popTasks (limit, until = Date.now()) { const pop = []; this._tasks = this._tasks .map((task) => { if (task.enqueue <= until && pop.length < limit) { const upTask = { ...task, enqueue: MAX_TS, insEnqueue: MAX_TS }; pop.push(upTask); return upTask; } return task; }); return Promise.resolve(pop); } /** * * @param {string} taskId * @param {object} data */ updateTask (taskId, data) { let ret = null; this._tasks = this._tasks .map((task) => { if (task.id !== taskId) { return task; } ret = { ...task, ...data }; return ret; }); return Promise.resolve(ret); } /** * Get last sent task from campaign * * @param {string} pageId * @param {string} senderId * @param {string} campaignId * @returns {Promise<Task|null>} */ getSentTask (pageId, senderId, campaignId) { const task = this._tasks.find((t) => t.sent && t.pageId === pageId && t.senderId === senderId && t.campaignId === campaignId); return Promise.resolve(task); } /** * Return Task By Id * * @param {string} taskId * @returns {Promise<Task|null>} */ async getTaskById (taskId) { const task = this._tasks .find((t) => t.id === taskId); return Promise.resolve(task); } /** * * @param {string} campaignId * @param {boolean} [sentWithoutReaction] * @param {string} [pageId] */ getUnsuccessfulSubscribersByCampaign (campaignId, sentWithoutReaction = false, pageId = null) { let tasks; if (sentWithoutReaction) { tasks = this._tasks.filter((t) => t.campaignId === campaignId && t.leaved === null && t.reaction === false); } else { tasks = this._tasks.filter((t) => t.campaignId === campaignId && t.leaved > 0); } if (pageId) { tasks = tasks.filter((t) => t.pageId === pageId); } return Promise.resolve(tasks.map(({ senderId, pageId: p }) => ({ senderId, pageId: p }))); } /** * * @param {string} pageId * @param {string} senderId * @param {string[]} checkCampaignIds * @returns {Promise<string[]>} */ getSentCampagnIds (pageId, senderId, checkCampaignIds) { const res = this._tasks .filter((t) => t.sent && t.pageId === pageId && t.senderId === senderId && checkCampaignIds.includes(t.campaignId)) .map((t) => t.campaignId); return Promise.resolve(res); } /** * * @param {string} senderId * @param {string} pageId * @param {number} watermark * @param {('read'|'delivery')} eventType * @param {number} ts * @returns {Promise<Task[]>} */ updateTasksByWatermark (senderId, pageId, watermark, eventType, ts = Date.now()) { const updated = []; this._tasks = this._tasks .map((task) => { if (task.senderId !== senderId || task.pageId !== pageId || !task.sent || task[eventType] || task.sent > watermark) { return task; } const upTask = { ...task, [eventType]: ts }; updated.push(upTask); return upTask; }); return Promise.resolve(updated); } /** * * @param {object} campaign * @param {object} [updateCampaign] * @returns {Promise<Campaign>} */ async upsertCampaign (campaign, updateCampaign = null) { let insert = campaign; if (!insert.id) { insert = { ...insert, id: uuid.v4() }; } if (!this._campaigns.has(insert.id)) { if (updateCampaign) Object.assign(insert, updateCampaign); this._campaigns.set(insert.id, insert); } else { insert = this._campaigns.get(insert.id); if (updateCampaign) { insert = { ...insert, ...updateCampaign }; this._campaigns.set(insert.id, insert); } } return Promise.resolve(insert); } /** * * @param {string} campaignId * @returns {Promise} */ removeCampaign (campaignId) { if (this._campaigns.has(campaignId)) { this._campaigns.delete(campaignId); } return Promise.resolve(); } /** * * @param {string} campaignId * @param {object} increment * @returns {Promise} */ incrementCampaign (campaignId, increment = {}) { let campaign = this._campaigns.get(campaignId) || null; if (campaign !== null) { campaign = { ...campaign }; Object.keys(increment) .forEach((key) => { campaign[key] = (campaign[key] || 0) + increment[key]; }); this._campaigns.set(campaignId, campaign); } return Promise.resolve(); } /** * * @param {string} campaignId * @param {object} data * @returns {Promise<Campaign|null>} */ updateCampaign (campaignId, data) { let ret = this._campaigns.get(campaignId) || null; if (ret !== null) { ret = { ...ret, ...data }; this._campaigns.set(campaignId, ret); } return Promise.resolve(ret); } /** * * @param {string} campaignId * @returns {Promise<null|Campaign>} */ getCampaignById (campaignId) { const ret = this._campaigns.get(campaignId) || null; return Promise.resolve(ret); } /** * * @param {string[]} campaignIds * @returns {Promise<Campaign[]>} */ getCampaignByIds (campaignIds) { const campaigns = Array.from(this._campaigns.values()) .filter((c) => campaignIds.includes(c.id)); return Promise.resolve(campaigns); } /** * * @param {number} [now] * @returns {Promise<Campaign|null>} */ popCampaign (now = Date.now()) { let campaign = null; for (const camp of this._campaigns.values()) { if (camp.active && camp.startAt && camp.startAt <= now) { campaign = camp; this._campaigns.set(campaign.id, { ...camp, startAt: null }); break; } } return Promise.resolve(campaign); } /** * * @param {object} condition * @param {number} [limit] * @param {object} [lastKey] * @returns {Promise<{data:Campaign[],lastKey:string}>} */ getCampaigns (condition, limit = null, lastKey = null) { let reachedKey = lastKey === null; let filtered = 0; let hasNext = false; const key = lastKey !== null ? JSON.parse(Buffer.from(lastKey, 'base64').toString('utf8')) : null; const conditionKeys = Object.keys(condition); const data = Array.from(this._campaigns.values()) .filter((campaign) => { const matches = conditionKeys .every((k) => campaign[k] === condition[k]); if (!matches) { return false; } if (limit !== null && filtered >= limit) { hasNext = true; return false; } if (reachedKey) { filtered++; return true; } reachedKey = key.id === campaign.id; return false; }); let nextLastKey = null; if (limit && hasNext) { const last = data[data.length - 1]; nextLastKey = Buffer.from(JSON.stringify({ id: last.id })).toString('base64'); } return Promise.resolve({ data, lastKey: nextLastKey }); } /** * * @param {string} senderId * @param {string} pageId * @param {string} tag * @param {{}} [meta={}] */ _subscribe (senderId, pageId, tag, meta = {}) { const key = `${senderId}|${pageId}`; let subscribtion = this._subscribtions.get(key); if (!subscribtion) { subscribtion = { senderId, pageId, subs: [] }; } if (!subscribtion.subs.includes(tag)) { subscribtion = { ...subscribtion, subs: [...subscribtion.subs, tag] }; if (meta && Object.keys(meta).length) { Object.assign(subscribtion, { meta: { ...subscribtion.meta, [tag]: meta } }); } else if (subscribtion.meta && subscribtion.meta[tag]) { delete subscribtion.meta; } } this._subscribtions.set(key, subscribtion); } /** * * @param {SubscriptionData[]} subscriptionData * @param {boolean} [onlyToKnown] * @returns {Promise} */ // eslint-disable-next-line no-unused-vars async batchSubscribe (subscriptionData, onlyToKnown = null) { subscriptionData.forEach(({ senderId, pageId, tags, remove = false, meta = {} }) => { if (remove) { tags.forEach((tag) => { this._unsubscribe(senderId, pageId, tag); }); } else { tags.forEach((tag) => { this._subscribe(senderId, pageId, tag, meta[tag]); }); } }); return Promise.resolve(); } /** * * @param {string|string[]} senderId * @param {string} pageId * @param {string} tag * @param {boolean} [onlyToKnown] * @returns {Promise} */ // eslint-disable-next-line no-unused-vars subscribe (senderId, pageId, tag, onlyToKnown = null) { const insert = Array.isArray(senderId) ? senderId : [senderId]; insert.forEach((sender) => this._subscribe(sender, pageId, tag)); return Promise.resolve(); } /** * * @param {string} senderId * @param {string} pageId * @param {string} [tag] * @returns {Promise<string[]>} */ unsubscribe (senderId, pageId, tag = null) { const unsubscribtions = this._unsubscribe(senderId, pageId, tag); return Promise.resolve(unsubscribtions); } _unsubscribe (senderId, pageId, tag = null) { const key = `${senderId}|${pageId}`; if (!this._subscribtions.has(key)) { return []; } const unsubscribtions = []; let subscribtion = this._subscribtions.get(key); subscribtion = { ...subscribtion, subs: subscribtion.subs .filter((sub) => { const out = tag === null || sub === tag; if (out) { unsubscribtions.push(sub); } return !out; }) }; if ('meta' in subscribtion && subscribtion.meta[tag]) { delete subscribtion.meta[tag]; } if (subscribtion.subs.length === 0) { this._subscribtions.delete(key); } else { this._subscribtions.set(key, subscribtion); } return unsubscribtions; } /** * * @param {string[]} include * @param {string[]} exclude * @param {string} [pageId] * @returns {Promise<number>} */ getSubscribtionsCount (include, exclude, pageId = null) { // let's make this simple const filtered = Array.from(this._subscribtions.values()) .filter((sub) => { const subMatches = (pageId === null || sub.pageId === pageId) && (include.length === 0 || sub.subs.some((s) => include.includes(s))) && !sub.subs.some((s) => exclude.includes(s)); return subMatches; }); return Promise.resolve(filtered.length); } /** * * @param {string[]} include * @param {string[]} exclude * @param {number} limit * @param {string} [pageId] * @param {*} lastKey * @returns {Promise<{data: Target[], lastKey: string }>} */ getSubscribtions (include, exclude, limit, pageId = null, lastKey = null) { let keyReached = lastKey === null; let found = 0; let hasNext; const key = lastKey !== null ? JSON.parse(Buffer.from(lastKey, 'base64').toString('utf8')) : null; const ret = Array.from(this._subscribtions.values()) .filter((sub) => { const subMatches = (pageId === null || sub.pageId === pageId) && (include.length === 0 || sub.subs.some((s) => include.includes(s))) && !sub.subs.some((s) => exclude.includes(s)); if (!subMatches) { return false; } if (keyReached && found < limit) { found++; return true; } if (keyReached && found === limit) { hasNext = true; } keyReached = keyReached || (key.pageId === sub.pageId && key.senderId === sub.senderId); return false; }); const data = ret.map((sub) => ({ senderId: sub.senderId, pageId: sub.pageId, ...(sub.meta ? { meta: sub.meta } : {}) })); let nextLastKey = null; if (hasNext) { const last = data[data.length - 1]; nextLastKey = Buffer.from(JSON.stringify({ pageId: last.pageId, senderId: last.senderId })).toString('base64'); } return Promise.resolve({ data, lastKey: nextLastKey }); } /** * @param {string} senderId * @param {string} pageId * @returns {Promise<Subscription[]>} */ async getSenderSubscriptions (senderId, pageId) { const key = `${senderId}|${pageId}`; if (!this._subscribtions.has(key)) { return Promise.resolve([]); } const sub = this._subscribtions.get(key); return sub.subs.map((tag) => ({ tag, meta: (sub.meta && sub.meta[tag]) || {} })); } /** * * @param {string} senderId * @param {string} pageId * @returns {Promise<string[]>} */ async getSenderSubscribtions (senderId, pageId) { const subs = await this.getSenderSubscriptions(senderId, pageId); return subs.map((s) => s.tag); } /** * * @param {string} [pageId] * @returns {Promise<Tag[]>} */ getTags (pageId = null) { /** @type Map<string,Tag> */ const res = new Map(); this._subscribtions.forEach((subscribtion) => { if (pageId && subscribtion.pageId !== pageId) { return; } subscribtion.subs.forEach((sub) => { if (!res.has(sub)) { res.set(sub, { tag: sub, subscribtions: 1 }); } else { res.get(sub).subscribtions++; } }); }); const tags = Array.from(res.values()); tags.sort((a, b) => b.subscribtions - a.subscribtions); return Promise.resolve(tags); } } module.exports = NotificationsStorage;