diff --git a/classes/Dispatch.js b/classes/Dispatch.js index 6142f24..f631737 100644 --- a/classes/Dispatch.js +++ b/classes/Dispatch.js @@ -14,6 +14,7 @@ class Dispatch { this.templates = reqDir('../routes/messages/Templates/'); this.db = reqDir('../models/db/'); this.helpers = reqDir('../routes/messages/helpers'); + this.state = {}; } async execute(Event, override) { @@ -85,6 +86,38 @@ class Dispatch { return this.import('helpers', ...args); } + setState(Event) { + // Save a single Event object to be pulled later by the app + // ie: the user is going through onboarding, save the Event + // that triggered the onboarding + + // NOTE: Does NOT save across server crashes/restarts at this time + console.log('SAVING STATE'); + this.state[Event.user_id] = { ...Event }; + } + + getState(Event) { + console.log('GETTING STATE'); + const state = this.state[Event.user_id]; + // Can either simply run the method or set Event to the return + Event.insertPreviousState(state); + return Event; + } + + clearState(Event) { + delete this.state[Event.user_id]; + } + + hasOpenState(Event) { + // Returns true is state for user is empty + return !this.state[Event.user_id]; + } + + hasState(Event) { + // Returns truthy if user has data in state + return this.state[Event.user_id]; + } + async sendTemplate(name, ...args) { // If you want to immediately run a template instead of importing it then running it, // run this with any arguments the template wants, everything separated by a comma. @@ -98,6 +131,7 @@ class Dispatch { redirectTo(Event, command) { Event.validatedCommand = command; + Event.type = 'redirect'; this.execute(Event, true); } diff --git a/classes/WebhookEvent.js b/classes/WebhookEvent.js index 0a6cc3f..db5fbb5 100644 --- a/classes/WebhookEvent.js +++ b/classes/WebhookEvent.js @@ -89,31 +89,28 @@ module.exports = class WebhookEvent { return this.bookCount > 1; } - overrideOnUserMessage(command) { - if (this.type === 'message' && !this.isOverridden) { - this.setOverride(command); + isPostback() { + return this.type === 'postback'; + } + + overrideOnUserMessage(command = 'get_started') { + if (this.type === 'message') { + return this.setOverride(command); } } - async overrideIfNotOnboarded() { - // User has completed onboarding - if (this.hasOwnProperty('prefersLongSummaries')) { - // Event.prefersLongSummaries will exist on the postback - // from request_summary_preference (in English: the user sent the final - // piece needed to complete onboarding and should progress) - const { prefersLongSummaries } = this; - const updatedUser = await Users.edit( - { id: this.user_id }, - { prefersLongSummaries } - ); - this.user = updatedUser; + overrideOnMissingProperty(prop, command = 'get_started') { + if (!this[prop]) { + return this.setOverride(command); } + } + async overrideIfNotOnboarded() { if (!this.isOverridden) { const { user_id } = this; const userCategories = await UserCategories.retrieve({ user_id }); if (userCategories.length < 3) { - return this.setOverride('pick_category'); + return this.setOverride('request_categories'); } else if (!this.user.email) { return this.setOverride('request_email'); } else if (this.user.prefersLongSummaries === null) { @@ -166,9 +163,10 @@ module.exports = class WebhookEvent { setOverride(command) { // The first override should receive priority if (!this.isOverridden && !this.override) { - console.log('Overriding to: ', command); + this.type = 'redirect'; this.isOverridden = true; this.override = command; + return this.override; } } @@ -216,11 +214,13 @@ module.exports = class WebhookEvent { this.response = response; } - setUser(u) { + setUser(u, addTimeUser) { const { id, ...user } = u; this.user = user; this.user_id = id; - this.addTimedMessage(); + if (addTimeUser) { + this.addTimedMessage(); + } } setValidatedCommand(command) { @@ -264,6 +264,12 @@ module.exports = class WebhookEvent { return allCategories.filter(c => userCategoryIDs.indexOf(c.id) === -1); } + insertPreviousState(state) { + for (let property in state) { + this[property] = state[property]; + } + } + async addTimedMessage() { const { user_id, page_id } = this; if (!user_id || !page_id) { @@ -310,7 +316,6 @@ module.exports = class WebhookEvent { } async _parseUserAction(entry) { - console.log('Received user action'); const { Event } = entry; const message = entry.messaging[0]; @@ -326,7 +331,7 @@ module.exports = class WebhookEvent { Event.isNewUser = true; } - this.setUser(user); + this.setUser(user, true); // So many commands currently rely on how many books are available // for the page, we've added the bookCount as a default @@ -354,14 +359,14 @@ module.exports = class WebhookEvent { isReferral ? this.setEventData({ + type: 'referral', ...parsed_data, - ...this._handleReferral(message.postback.referral.ref), - type: 'referral' + ...this._handleReferral(message.postback.referral.ref) }) : this.setEventData({ + type: 'postback', ...parsed_data, - ...JSON.parse(payload), - type: 'postback' + ...JSON.parse(payload) }); } else if (message.referral) { // This block will fire under one condition: @@ -371,9 +376,9 @@ module.exports = class WebhookEvent { // http://m.me/109461977131004?ref=command=start_book,book_id=1 this.setEventData({ + type: 'referral', ...parsed_data, - ...this._handleReferral(message.referral.ref), - type: 'referral' + ...this._handleReferral(message.referral.ref) }); } else if (message && message.message) { // Fires whenever the user types something at the bot diff --git a/jobs/timedMessages.js b/jobs/timedMessages.js index da2b26e..4f9adcf 100644 --- a/jobs/timedMessages.js +++ b/jobs/timedMessages.js @@ -1,7 +1,7 @@ const TimedMessages = require('../models/db/timedMessages.js'); const Users = require('models/db/users.js'); const Pages = require('models/db/pages.js'); -const Message = require('classes/WebhookEvent.js'); +const MessageQueue = require('classes/MessageQueue.js'); const GenericTemplate = require('routes/messages/Templates/Generic.js'); module.exports = setInterval(cycleMessages, 1000 * 60 * 30); @@ -16,7 +16,7 @@ async function cycleMessages() { const user = await Users.retrieve({ id: m.user_id }).first(); // This gets us around having to add a page_id column to the timed_messages // table but likely won't work long term if books lose the page_id column - const page = await Pages.retrieve({ id: m.page_id }).first(); + const { access_token } = await Pages.retrieve({ id: m.page_id }).first(); const psid = user.facebook_id; const response = GenericTemplate([ @@ -34,16 +34,14 @@ async function cycleMessages() { } ]); - const timed = new Message( - { - sender: psid, - page, - command: 'timed_message' - }, - [response] - ); + const timed = new MessageQueue({ + sender_id: psid, + access_token, + validatedCommand: 'timed_message' + }); - await timed.respond(); + timed.add(response); + timed.send(); await TimedMessages.remove(m.id); console.log('Sent timed response'); } diff --git a/routes/messages/Templates/Book.js b/routes/messages/Templates/Book.js index 881a399..a410f40 100644 --- a/routes/messages/Templates/Book.js +++ b/routes/messages/Templates/Book.js @@ -37,23 +37,19 @@ module.exports = async (Event, books) => { if (Event.isMultiBookPage()) { const usersLibrary = await UserLibraries.retrieve({ user_id }); - const isInLibrary = usersLibrary.find( + const isAdding = !usersLibrary.find( lib => lib.id === parseInt(book_id, 10) ); - if (awaitEvent.isUserOnboarded()) { - // Temporary measure. Don't let the user save to their - // library if they aren't onboarded - buttons.push({ - type: 'postback', - title: isInLibrary ? 'Remove from Library' : 'Add to Library', - payload: JSON.stringify({ - command: 'toggle_in_library', - book_id, - isAdding: !isInLibrary - }) - }); - } + buttons.push({ + type: 'postback', + title: isAdding ? 'Add to Library' : 'Remove from Library', + payload: JSON.stringify({ + command: 'toggle_in_library', + book_id, + isAdding + }) + }); } buttons.push({ diff --git a/routes/messages/commands/pick_category.js b/routes/messages/commands/pick_category.js deleted file mode 100644 index e39004d..0000000 --- a/routes/messages/commands/pick_category.js +++ /dev/null @@ -1,40 +0,0 @@ -const UserCategories = require('models/db/userCategories.js'); -const QRT = require('../Templates/QuickReply.js'); - -module.exports = async Event => { - const { user_id } = Event; - - const userCategories = await UserCategories.retrieve({ user_id }); - - // Only show categories the user has not saved - const categories = await Event.getNewCategoriesForUser(); - - // If command given is this command, push them toward browse as a default behavior - - const redirect = - Event.validatedCommand === 'pick_category' - ? 'browse' - : Event.validatedCommand; - - const quick_replies = categories.map(c => { - return { - title: c.name, - payload: JSON.stringify({ - command: 'save_category', - category_id: c.id, - redirect - }) - }; - }); - - const intro = - Event.command === 'browse' ? 'To get started, please ' : 'Please '; - - const text = !userCategories.length - ? intro + 'select 3 categories' - : userCategories.length === 1 - ? '2 more to go...' - : 'Last one!'; - - return QRT(text, quick_replies); -}; diff --git a/routes/messages/commands/request_categories.js b/routes/messages/commands/request_categories.js new file mode 100644 index 0000000..d01974b --- /dev/null +++ b/routes/messages/commands/request_categories.js @@ -0,0 +1,34 @@ +const UserCategories = require('models/db/userCategories.js'); + +module.exports = async function(Event) { + if (this.hasState(Event)) { + this.getState(Event); + } + + const { user_id } = Event; + + const userCategories = await UserCategories.retrieve({ user_id }); + + // Only show categories the user has not saved + const categories = await Event.getNewCategoriesForUser(); + + const quick_replies = categories.map(c => ({ + title: c.name, + payload: JSON.stringify({ + command: 'save_category', + category_id: c.id, + redirect: Event.validatedCommand + }) + })); + + const intro = + Event.validatedCommand === 'browse' ? 'To get started, please ' : 'Please '; + + const text = !userCategories.length + ? intro + 'select 3 categories' + : userCategories.length === 1 + ? '2 more to go...' + : 'Last one!'; + + return this.sendTemplate('QuickReply', text, quick_replies); +}; diff --git a/routes/messages/commands/request_email.js b/routes/messages/commands/request_email.js index 2eea1dc..db2e0ad 100644 --- a/routes/messages/commands/request_email.js +++ b/routes/messages/commands/request_email.js @@ -1,17 +1,4 @@ module.exports = async Event => { - // const textResponses = { - // pick_category: - // "Great, I have what I need to make some suggestions! Though first, I'd like to make an account for you so I can remember your preferences across platforms. What email address can I attach to your account?", - // get_started: - // 'I have some suggestions ready to go for you, just respond with a valid email and I can get them right to you!', - // default: - // "We use an email address to identify you across platforms. Type a valid email address at any time and I'll save it to your account so you can proceed!" - // }; - - // const text = textResponses[Event.command] - // ? textResponses[Event.command] - // : textResponses.default; - return [ { text: "And what's your email address?" diff --git a/routes/messages/commands/request_summary_preference.js b/routes/messages/commands/request_summary_preference.js index bd2ef80..f3baaf3 100644 --- a/routes/messages/commands/request_summary_preference.js +++ b/routes/messages/commands/request_summary_preference.js @@ -1,19 +1,24 @@ // Part of onboarding, requires the user to specify if they want long or short summaries. // The value is saved in the users table under "prefersLongSummaries" inside of the "browse" command -module.exports = async function() { + +module.exports = async function(Event) { + // If Event was previously saved to onboard, restore the Event + if (this.hasState(Event)) { + this.getState(Event); + } const text = 'Would you like to read 2-minute or 10-15 minute summaries?'; const buttons = [ { title: '2-minute', payload: JSON.stringify({ - command: 'browse', + command: 'save_summary_preferences', prefersLongSummaries: false }) }, { title: '10-minute', payload: JSON.stringify({ - command: 'browse', + command: 'save_summary_preferences', prefersLongSummaries: true }) } diff --git a/routes/messages/commands/save_email.js b/routes/messages/commands/save_email.js index ed35fec..632517b 100644 --- a/routes/messages/commands/save_email.js +++ b/routes/messages/commands/save_email.js @@ -4,8 +4,18 @@ module.exports = async function(Event) { // When the user types a valid email address, save it to their account. // Then redirect them to 'browse' const { user_id: id } = Event; - Event.user = await Users.edit({ id }, { email: Event.command }); + const email = Event.command; + + const wasPreviousCommand = this.hasState(Event); + if (wasPreviousCommand) { + // Override Event to previous state, if it exists + this.getState(Event); + } + Event.user = await Users.edit({ id }, { email }); + + const nextCommand = wasPreviousCommand ? Event.validatedCommand : 'browse'; + Event.isMultiBookPage() - ? this.redirectTo(Event, 'browse') + ? this.redirectTo(Event, nextCommand) : this.redirectTo(Event, 'get_started'); }; diff --git a/routes/messages/commands/save_summary_preferences.js b/routes/messages/commands/save_summary_preferences.js new file mode 100644 index 0000000..a60ee66 --- /dev/null +++ b/routes/messages/commands/save_summary_preferences.js @@ -0,0 +1,15 @@ +module.exports = async function(Event) { + // User has submitted their preference for short or long summaries + const { prefersLongSummaries } = Event; + const [Users] = this.withDBs('users'); + if (this.hasState(Event)) { + this.getState(Event); + } + const updatedUser = await Users.edit( + { id: Event.user_id }, + { prefersLongSummaries } + ); + Event.setUser(updatedUser); + console.log('REDIRECT TO: ', Event.validatedCommand); + this.redirectTo(Event, Event.validatedCommand); +}; diff --git a/routes/messages/commands/toggle_in_library.js b/routes/messages/commands/toggle_in_library.js index 8bf7a8b..64c8113 100644 --- a/routes/messages/commands/toggle_in_library.js +++ b/routes/messages/commands/toggle_in_library.js @@ -1,11 +1,18 @@ -const UserLibrary = require('models/db/userLibraries.js'); - -module.exports = async Event => { +module.exports = async function(Event) { + if (this.hasState(Event)) { + // If an onboarding detour was taken, retrieve original state then clear it + this.getState(Event); + this.clearState(Event); + } const { user_id, book_id, isAdding } = Event; - const currentLibrary = await UserLibrary.retrieve({ 'ul.user_id': user_id }); - const exists = currentLibrary.find(b => b.id === book_id); + const [Books, UserLibraries] = this.withDBs('books', 'userLibraries'); + const book = Books.retrieve({ 'b.id': book_id }).first(); + const currentLibrary = await UserLibraries.retrieve({ + 'ul.user_id': user_id + }); + const exists = currentLibrary.find(b => b.id === book_id); // Two conditions something will happen: // 1) It's a new item and the user is trying to add it // 2) The user previous saved it and they want to remove it @@ -14,8 +21,11 @@ module.exports = async Event => { !exists && isAdding ? 'add' : exists && !isAdding ? 'remove' : null; if (method) { - await UserLibrary[method]({ user_id, book_id }); + await UserLibraries[method]({ user_id, book_id }); + const firstWord = method === 'add' ? 'Saved' : 'Removed'; + const preposition = method === 'add' ? 'to' : 'from'; + return { text: `${firstWord} ${book.title} ${preposition} your library!` }; } - return { text: 'Saved!' }; + return { text: `That has already been ${isAdding ? 'saved' : 'removed'}` }; }; diff --git a/routes/messages/conditions/browse.js b/routes/messages/conditions/browse.js index 3a40cb2..95fc3a5 100644 --- a/routes/messages/conditions/browse.js +++ b/routes/messages/conditions/browse.js @@ -1 +1,11 @@ -module.exports = async Event => Event.overrideIfNotOnboarded(); +module.exports = async function(Event) { + if (Event.isPostback()) { + this.setState(Event); + } + const isOnboarding = Event.overrideIfNotOnboarded(); + + if (!isOnboarding) { + // Clear state if we don't onboard + this.clearState(Event); + } +}; diff --git a/routes/messages/conditions/get_synopsis.js b/routes/messages/conditions/get_synopsis.js index 4ae328f..c91aea2 100644 --- a/routes/messages/conditions/get_synopsis.js +++ b/routes/messages/conditions/get_synopsis.js @@ -1,8 +1,4 @@ module.exports = async function(Event) { - console.log('Synopsis Override'); Event.overrideOnUserMessage('get_started'); - - if (!Event.book_id) { - Event.setOverride('get_started'); - } + Event.overrideOnMissingProperty('book_id', 'get_started'); }; diff --git a/routes/messages/conditions/save_category.js b/routes/messages/conditions/save_category.js index 180ed0d..482abd1 100644 --- a/routes/messages/conditions/save_category.js +++ b/routes/messages/conditions/save_category.js @@ -1,5 +1,3 @@ -module.exports = ({ category_id }) => { - if (!category_id) { - Event.setOverride('get_started'); - } +module.exports = function(Event) { + Event.overrideOnMissingProperty('category_id', 'get_started'); }; diff --git a/routes/messages/conditions/save_summary_preference.js b/routes/messages/conditions/save_summary_preference.js new file mode 100644 index 0000000..acbaae0 --- /dev/null +++ b/routes/messages/conditions/save_summary_preference.js @@ -0,0 +1,5 @@ +module.exports = async function(Event) { + Event.overrideOnUserMessage(); + + Event.overrideOnMissingProperty('prefersLongSummaries'); +}; diff --git a/routes/messages/conditions/toggle_in_library.js b/routes/messages/conditions/toggle_in_library.js index a5d13ce..22e9a81 100644 --- a/routes/messages/conditions/toggle_in_library.js +++ b/routes/messages/conditions/toggle_in_library.js @@ -1,4 +1,13 @@ -module.exports = async Event => { +module.exports = async function(Event) { Event.overrideOnUserMessage(); - await Event.overrideIfNotOnboarded(); + if (Event.isPostback()) { + // If it's not a postback but it's a valid request, + // it's been redirected. In that case, we don't want to override state + this.setState(Event); + } + const isOnboarding = await Event.overrideIfNotOnboarded(); + if (!isOnboarding) { + // If we didn't onboard the user, clear state + this.clearState(Event); + } }; diff --git a/server.js b/server.js index b5451cf..d4ceee9 100644 --- a/server.js +++ b/server.js @@ -4,7 +4,6 @@ const cors = require('cors'); // Jobs -- Tasks running on a schedule require('./jobs/timedMessages.js'); -// require('./jobs/bookRatings.js')(); // Import routes const messageRouter = require('./routes/messages/'); @@ -47,33 +46,3 @@ server.get('*', function(req, res) { server.use(errorHandler); module.exports = server; - -server.put('/api/users/:id', async (req, res) => { - const { id } = req.params; - const body = req.body; - - if (!body.name || !body.bio) { - return res - .status(400) - .json({ errorMessage: 'Please provide name and bio for the user' }); - } - - const user = await db.findById(id); - - if (!user) { - return res - .status(404) - .json({ message: 'The user with the specified ID does not exist.' }); - } - - const updated = await db.update(id, body); - - if (!updated) { - return res - .status(500) - .json({ error: 'The user information could not be modified' }); - } - - const updatedUser = await db.findById(id); - return res.status(200).json(updatedUser); -});