├── app ├── logs │ └── .gitkeep ├── commands │ ├── mainMenu.json │ ├── moreStories.json │ ├── cancelActivity.json │ ├── doAdvertiseFlow.json │ ├── doSubmitStoryFlow.json │ ├── doLatestStoriesFlow.json │ ├── resubscribe.json │ ├── downloadFeed.json │ └── unsubscribe.json ├── config │ ├── staging.config.json │ ├── development.config.json │ └── production.config.json ├── models │ ├── globalSettings.js │ ├── breakingNewsQueuedItem.js │ └── article.js ├── entryPoint.js ├── hooks │ ├── newsNotifications.js │ └── moreStories.js ├── modules │ ├── initConfig.js │ └── miscellaneous.js ├── bot.js └── readServer.js ├── lib └── hippocamp │ ├── lib │ ├── webviews │ │ ├── multiselect │ │ │ ├── styles.css │ │ │ ├── data.json │ │ │ ├── script.js │ │ │ └── webview.html │ │ └── .eslintrc.js │ ├── matches │ │ ├── ok.json │ │ ├── no.json │ │ └── yes.json │ ├── workflow │ │ ├── .eslintrc.js │ │ ├── actions │ │ │ ├── changeFlow.js │ │ │ ├── sendMessage.js │ │ │ ├── markAsTyping.js │ │ │ ├── disableBot.js │ │ │ ├── enableBot.js │ │ │ ├── wipeMemory.js │ │ │ ├── trackUser.js │ │ │ ├── trackEvent.js │ │ │ ├── delay.js │ │ │ ├── scheduleTask.js │ │ │ ├── updateMemory.js │ │ │ ├── executeHook.js │ │ │ └── index.js │ │ ├── hooks.js │ │ ├── misunderstood.js │ │ ├── models.js │ │ ├── tracking.js │ │ ├── matching.js │ │ ├── variables.js │ │ ├── webviews │ │ │ └── compileWebview.js │ │ ├── workflowManager.js │ │ ├── memory │ │ │ └── index.js │ │ ├── fileManagement.js │ │ ├── miscellaneous.js │ │ └── linkTracking.js │ ├── commands │ │ ├── flowinfo.json │ │ ├── userinfo.json │ │ ├── restart.json │ │ ├── whoami.json │ │ ├── start.json │ │ └── debug.json │ ├── modules │ │ ├── schema │ │ │ ├── reference.js │ │ │ ├── property.js │ │ │ └── schema.js │ │ ├── handlebarsHelpers.js │ │ ├── sharedLogger.js │ │ ├── userProfileRefresher.js │ │ └── messageObject.js │ ├── handlers │ │ ├── schedulers │ │ │ └── schedulerBase.js │ │ ├── adapters │ │ │ ├── facebook │ │ │ │ ├── miscellaneous.js │ │ │ │ ├── setSenderAction.js │ │ │ │ ├── convertToFacebookPersistentMenu.js │ │ │ │ ├── convertToFacebookButtons.js │ │ │ │ ├── convertToInternalMessages.js │ │ │ │ └── sendMessage.js │ │ │ └── adapterBase.js │ │ ├── custom │ │ │ └── customInputHandler.js │ │ ├── nlp │ │ │ ├── nlpBase.js │ │ │ └── luis.js │ │ ├── analytics │ │ │ ├── dashbot.js │ │ │ ├── analyticsBase.js │ │ │ └── segment.js │ │ ├── handlerBase.js │ │ ├── periodic │ │ │ ├── profileFetcher.js │ │ │ └── periodicBase.js │ │ ├── loggers │ │ │ ├── loggerBase.js │ │ │ ├── terminal.js │ │ │ └── filesystem.js │ │ └── databases │ │ │ └── databaseBase.js │ ├── models │ │ ├── message.js │ │ ├── task.js │ │ ├── user.js │ │ └── flow.js │ ├── hooks │ │ ├── flowinfo.js │ │ └── userinfo.js │ ├── middleware │ │ ├── parseCommand.js │ │ ├── trackUser.js │ │ ├── getExistingUser.js │ │ ├── markAsHumanToHuman.js │ │ ├── customInputHandlerMiddleware.js │ │ ├── refreshUserProfile.js │ │ ├── continueConversation.js │ │ ├── logMessage.js │ │ ├── populateMessageVariables.js │ │ ├── createShellUser.js │ │ ├── saveMessage.js │ │ └── trackifyLinks.js │ └── server │ │ └── server.js │ └── package.json ├── .gitignore ├── .eslintrc.js ├── empty.env ├── Dockerfile ├── Dockerfile.staging ├── Dockerfile.development ├── docker-compose.yml ├── package.json └── README.md /app/logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/webviews/multiselect/styles.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .idea 4 | .env 5 | .*.env 6 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/webviews/multiselect/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Multiselect", 3 | "items": [] 4 | } 5 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/matches/ok.json: -------------------------------------------------------------------------------- 1 | { 2 | "^k*$": "regexp", 3 | "^ok$": "regexp", 4 | "^okay$": "regexp" 5 | } 6 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "rules": { 3 | "filenames/no-index": 0, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "eslint-config-recombix", 3 | "rules": { 4 | "no-process-exit": 0, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/commands/flowinfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "matches": { 3 | "$flowinfo": "string" 4 | }, 5 | "actions": [{ 6 | "type": "execute-hook", 7 | "hook": "flowinfo" 8 | }] 9 | } 10 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/commands/userinfo.json: -------------------------------------------------------------------------------- 1 | { 2 | "matches": { 3 | "$userinfo": "string" 4 | }, 5 | "actions": [{ 6 | "type": "execute-hook", 7 | "hook": "userinfo" 8 | }] 9 | } 10 | -------------------------------------------------------------------------------- /app/commands/mainMenu.json: -------------------------------------------------------------------------------- 1 | { 2 | "matches": { 3 | "main menu": "string", 4 | "menu": "string" 5 | }, 6 | "actions": [{ 7 | "type": "change-flow", 8 | "nextUri": "dynamic:///introduction/entry-point-switch" 9 | }] 10 | } 11 | -------------------------------------------------------------------------------- /app/commands/moreStories.json: -------------------------------------------------------------------------------- 1 | { 2 | "matches": { 3 | "more stories": "string", 4 | "more": "string" 5 | }, 6 | "actions": [{ 7 | "type": "change-flow", 8 | "nextUri": "dynamic:///activities/latest-stories/more" 9 | }] 10 | } 11 | -------------------------------------------------------------------------------- /app/commands/cancelActivity.json: -------------------------------------------------------------------------------- 1 | { 2 | "matches": { 3 | "cancel": "string", 4 | "cancel activity": "string" 5 | }, 6 | "actions": [{ 7 | "type": "change-flow", 8 | "nextUri": "dynamic:///activities/cancel-activity" 9 | }] 10 | } 11 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/modules/schema/reference.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * SCHEMA: Reference 5 | */ 6 | 7 | module.exports = class Reference { 8 | 9 | constructor (_referencedModel) { 10 | 11 | this.referencedModel = _referencedModel; 12 | 13 | } 14 | 15 | }; 16 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/modules/schema/property.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * SCHEMA: Property 5 | */ 6 | 7 | module.exports = class Property { 8 | 9 | constructor (_dataType, _defaultValue) { 10 | 11 | this.dataType = _dataType; 12 | this.defaultValue = _defaultValue; 13 | 14 | } 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /app/commands/doAdvertiseFlow.json: -------------------------------------------------------------------------------- 1 | { 2 | "matches": { 3 | "do advertise flow": "string" 4 | }, 5 | "intents": { 6 | "ADVERTISE": { 7 | "threshold": 0.50, 8 | "aggressive": true 9 | } 10 | }, 11 | "actions": [{ 12 | "type": "change-flow", 13 | "nextUri": "dynamic:///activities/advertise" 14 | }] 15 | } 16 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/modules/schema/schema.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * SCHEMA 5 | */ 6 | 7 | module.exports = class Schema { 8 | 9 | constructor (_modelName, _fields, _options) { 10 | 11 | this.modelName = _modelName; 12 | this.fields = _fields; 13 | this.options = _options || {}; 14 | 15 | } 16 | 17 | }; 18 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/webviews/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "eslint-config-recombix", 3 | "globals": { 4 | 5 | }, 6 | "rules": { 7 | "comma-dangle": [2, "never"], 8 | "prefer-arrow-callback": 0, 9 | "prefer-template": 0, 10 | "quotes": [2, "single"], 11 | "strict": 0, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /app/commands/doSubmitStoryFlow.json: -------------------------------------------------------------------------------- 1 | { 2 | "matches": { 3 | "do submit story flow": "string" 4 | }, 5 | "intents": { 6 | "SUBMIT_A_STORY": { 7 | "threshold": 0.50, 8 | "aggressive": true 9 | } 10 | }, 11 | "actions": [{ 12 | "type": "change-flow", 13 | "nextUri": "dynamic:///activities/submit-story" 14 | }] 15 | } 16 | -------------------------------------------------------------------------------- /app/commands/doLatestStoriesFlow.json: -------------------------------------------------------------------------------- 1 | { 2 | "matches": { 3 | "do latest stories flow": "string" 4 | }, 5 | "intents": { 6 | "SEE_LATEST_STORIES": { 7 | "threshold": 0.50, 8 | "aggressive": true 9 | } 10 | }, 11 | "actions": [{ 12 | "type": "change-flow", 13 | "nextUri": "dynamic:///activities/latest-stories" 14 | }] 15 | } 16 | -------------------------------------------------------------------------------- /app/config/staging.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "loggers": { 3 | "terminal": { 4 | "logLevel": "verbose" 5 | } 6 | }, 7 | "nlp": { 8 | "luis": { 9 | "isStagingEnv": true 10 | } 11 | }, 12 | "scheduledTasks": { 13 | "feed-ingester": { 14 | "runEvery": "1 minute" 15 | }, 16 | "news-notifications": { 17 | "runEvery": "1 minute" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/commands/resubscribe.json: -------------------------------------------------------------------------------- 1 | { 2 | "matches": { 3 | "resubscribe": "string" 4 | }, 5 | "actions": [{ 6 | "type": "update-memory", 7 | "memory": { 8 | "unsubscribed": { 9 | "operation": "unset" 10 | } 11 | } 12 | }, { 13 | "type": "send-message", 14 | "message": { 15 | "text": "You are now subscribed to breaking news alerts." 16 | } 17 | }] 18 | } 19 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/actions/changeFlow.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * Move to the given flow. 5 | */ 6 | module.exports = async function __executeActionChangeFlow (action, recUser, message) { 7 | if (!recUser) { throw new Error(`Cannot execute action "change flow" unless a user is provided.`); } 8 | await this.executeFlow(action.nextUri, recUser, message); 9 | }; 10 | -------------------------------------------------------------------------------- /app/config/development.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "loggers": { 3 | "terminal": { 4 | "logLevel": "verbose", 5 | "pretty": true 6 | } 7 | }, 8 | "nlp": { 9 | "luis": { 10 | "isStagingEnv": true 11 | } 12 | }, 13 | "scheduledTasks": { 14 | "feed-ingester": { 15 | "runEvery": "1 minute" 16 | }, 17 | "news-notifications": { 18 | "runEvery": "1 minute" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/schedulers/schedulerBase.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * SCHEDULER BASE 5 | */ 6 | 7 | const HandlerBase = require(`../handlerBase`); 8 | 9 | module.exports = class SchedulerBase extends HandlerBase { 10 | 11 | /* 12 | * Initialises a new scheduler handler. 13 | */ 14 | constructor (type, handlerId) { 15 | super(type, handlerId); 16 | } 17 | 18 | }; 19 | -------------------------------------------------------------------------------- /app/models/globalSettings.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * SCHEMA: GlobalSettings 5 | */ 6 | 7 | module.exports = function (Schema, Property, Reference) { 8 | 9 | return new Schema(`GlobalSettings`, { 10 | _defaultFlow: new Reference(`Flow`), 11 | _stopFlow: new Reference(`Flow`), 12 | _helpFlow: new Reference(`Flow`), 13 | _feedbackFlow: new Reference(`Flow`), 14 | }); 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/commands/restart.json: -------------------------------------------------------------------------------- 1 | { 2 | "matches": { 3 | "restart": "string", 4 | "restart bot": "string", 5 | "restart chatbot": "string", 6 | "reset": "string", 7 | "quit": "string" 8 | }, 9 | "intents": { 10 | "RESTART_THE_BOT": { 11 | "threshold": 0.95, 12 | "aggressive": true 13 | } 14 | }, 15 | "actions": [{ 16 | "type": "change-flow", 17 | "nextUri": "/" 18 | }] 19 | } 20 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/commands/whoami.json: -------------------------------------------------------------------------------- 1 | { 2 | "matches": { 3 | "$whoami": "string" 4 | }, 5 | "intents": { 6 | "ABOUT_THE_BOT": { 7 | "threshold": 0.75, 8 | "aggressive": true 9 | } 10 | }, 11 | "actions": [{ 12 | "type": "send-message", 13 | "message": { 14 | "text": "I am {{appInfo.name}} {{appInfo.version}} running in the {{appInfo.environment}} environment." 15 | } 16 | }] 17 | } 18 | -------------------------------------------------------------------------------- /app/models/breakingNewsQueuedItem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * SCHEMA: Breaking News Queue 5 | */ 6 | 7 | module.exports = function (Schema, Property) { 8 | 9 | return new Schema(`BreakingNewsQueuedItem`, { 10 | userData: new Property(`flexible`), 11 | articleData: new Property(`flexible`), 12 | addedDate: new Property(`date`, Date.now), 13 | numTries: new Property(`integer`, 0), 14 | }); 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/commands/start.json: -------------------------------------------------------------------------------- 1 | { 2 | "matches": { 3 | "get started": "string", 4 | "start": "string", 5 | "go": "string", 6 | "hello": "string", 7 | "helo": "string", 8 | "hi": "string", 9 | "yo": "string", 10 | "boo": "string" 11 | }, 12 | "intents": { 13 | "GREETING": { 14 | "threshold": 0.95, 15 | "aggressive": true 16 | } 17 | }, 18 | "actions": [{ 19 | "type": "change-flow", 20 | "nextUri": "/" 21 | }] 22 | } 23 | -------------------------------------------------------------------------------- /app/commands/downloadFeed.json: -------------------------------------------------------------------------------- 1 | { 2 | "matches": { 3 | "download feed": "string" 4 | }, 5 | "actions": [{ 6 | "type": "send-message", 7 | "message": { 8 | "text": "Attempting to download the news feed..." 9 | } 10 | }, { 11 | "type": "execute-hook", 12 | "hook": "feedIngester", 13 | "errorMessage": "❌ Failed to download the feed." 14 | }, { 15 | "type": "send-message", 16 | "message": { 17 | "text": "✅ Successfully downloaded the feed." 18 | } 19 | }] 20 | } 21 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/modules/handlebarsHelpers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * HANDLEBARS HELPERS 5 | */ 6 | 7 | /* 8 | * Allows the case of strings to be changed. 9 | */ 10 | function strCaseHelper (value, newCase) { 11 | 12 | switch (newCase) { 13 | case `upper`: return value.toUpperCase(); 14 | case `lower`: return value.toLowerCase(); 15 | default: return value; 16 | } 17 | 18 | } 19 | 20 | /* 21 | * Export. 22 | */ 23 | module.exports = { 24 | strCaseHelper, 25 | }; 26 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/adapters/facebook/miscellaneous.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * Respond with 403 if the verify token is incorrect. 5 | */ 6 | function validateVerifyToken (req, res, correctVerifyToken) { 7 | 8 | if (req.query[`hub.mode`] === `subscribe` && req.query[`hub.verify_token`] === correctVerifyToken) { 9 | return res.status(200).respond(req.query[`hub.challenge`], false); 10 | } 11 | else { 12 | return res.status(403).respond(); 13 | } 14 | 15 | } 16 | 17 | /* 18 | * 19 | */ 20 | module.exports = { 21 | validateVerifyToken, 22 | }; 23 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/actions/sendMessage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * Send the given message using the given sendMessage method (which will be primed with the user ID). 5 | */ 6 | module.exports = async function __executeActionSendMessage (action, recUser) { 7 | 8 | if (!recUser) { throw new Error(`Cannot execute action "send message" unless a user is provided.`); } 9 | 10 | const MessageObject = this.__dep(`MessageObject`); 11 | const newMessage = MessageObject.outgoing(recUser, action.message); 12 | 13 | await this.sendMessage(recUser, newMessage); 14 | 15 | }; 16 | -------------------------------------------------------------------------------- /empty.env: -------------------------------------------------------------------------------- 1 | LOGGERS_TERMINAL_LEVEL=verbose 2 | DB_MONGO_CONNECTION_STR= 3 | ANALYTICS_DASHBOT_API_KEY= 4 | ANALYTICS_SEGMENT_WRITE_KEY= 5 | ADAPTER_FB_VERIFY_TOKEN= 6 | ADAPTER_FB_ACCESS_TOKEN= 7 | ADAPTER_WEB_ACCESS_TOKEN= 8 | NLP_LUIS_DISABLED=true 9 | NLP_LUIS_APP_ID= 10 | NLP_LUIS_API_KEY= 11 | NLP_LUIS_APP_REGION= 12 | SERVER_URI_BOT= 13 | SERVER_URI_READ= 14 | SERVER_URI_UI= 15 | PROVIDER_NAME= 16 | PROVIDER_FEED_URI= 17 | PROVIDER_TIMEZONE_OFFSET= 18 | PROVIDER_ITEM_PRIORITY_FIELD= 19 | PROVIDER_ITEM_PRIORITY_VALUE= 20 | GREETING_TEXT= 21 | PRIVACY_POLICY_URI=https://atchai.com/docs/eyewitness-policy/ 22 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/commands/debug.json: -------------------------------------------------------------------------------- 1 | { 2 | "matches": { 3 | "$debug": "string" 4 | }, 5 | "intents": { 6 | 7 | }, 8 | "actions": [{ 9 | "type": "send-message", 10 | "message": { 11 | "text": "Project: {{appInfo.name}} v{{appInfo.version}}." 12 | } 13 | }, { 14 | "type": "send-message", 15 | "message": { 16 | "text": "Engine: {{engineInfo.name}} v{{engineInfo.version}}." 17 | } 18 | }, { 19 | "type": "send-message", 20 | "message": { 21 | "text": "Environment: {{appInfo.environment}}." 22 | } 23 | }, { 24 | "type": "execute-hook", 25 | "hook": "userinfo" 26 | }] 27 | } 28 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/webviews/multiselect/script.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Validates the webview form. 3 | */ 4 | window.validateForm = function (form) { 5 | 6 | var numSelected = 0; 7 | 8 | // Count the number of selected items. 9 | for (var index = 0; index < form.elements.length; index++) { 10 | var field = form.elements[index]; 11 | if (field.checked) { numSelected++; } 12 | } 13 | 14 | // Check the minimum number of items has been selected. 15 | if (numSelected < 1) { 16 | event.preventDefault(); 17 | alert('You must select at least one item!'); 18 | return false; 19 | } 20 | 21 | return true; 22 | 23 | }; 24 | -------------------------------------------------------------------------------- /app/commands/unsubscribe.json: -------------------------------------------------------------------------------- 1 | { 2 | "matches": { 3 | "unsubscribe": "string" 4 | }, 5 | "actions": [{ 6 | "type": "send-message", 7 | "conditional": "", 8 | "message": { 9 | "text": "You're already unsubscribed from breaking news alerts." 10 | } 11 | }, { 12 | "type": "send-message", 13 | "conditional": "!", 14 | "message": { 15 | "text": "You've been unsubscribed from breaking news alerts." 16 | } 17 | }, { 18 | "type": "update-memory", 19 | "conditional": "!", 20 | "memory": { 21 | "unsubscribed": { 22 | "value": true 23 | } 24 | } 25 | }] 26 | } 27 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/actions/markAsTyping.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * Marks typing on/off if the adapter supports it. Defaults to marking as "on". 5 | */ 6 | module.exports = async function __executeActionMarkAsTyping (action, recUser) { 7 | 8 | if (!recUser) { throw new Error(`Cannot execute action "mark as typing" unless a user is provided.`); } 9 | 10 | const adapter = this.__dep(`adapter-${recUser.channel.name}`); 11 | const state = (typeof action.state === `undefined` ? true : action.state); 12 | const method = (state ? `markAsTypingOn` : `markAsTypingOff`); 13 | 14 | await adapter[method](recUser); 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /app/entryPoint.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * ENTRY POINT 5 | * Loads either the bot or the read server depending on environment config. 6 | */ 7 | 8 | /* eslint-disable global-require */ 9 | 10 | // Ensure we always work relative to this script. 11 | process.chdir(__dirname); 12 | 13 | // Include the appropriate entry point. 14 | let entryPointFilename; 15 | 16 | switch (process.env.ENTRY_POINT) { 17 | case `read-server`: entryPointFilename = `./readServer`; break; 18 | case `bot`: entryPointFilename = `./bot`; break; 19 | default: entryPointFilename = null; break; 20 | } 21 | 22 | if (entryPointFilename) { 23 | require(entryPointFilename); 24 | } 25 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/actions/disableBot.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * Disable the bot for the given user. 5 | */ 6 | module.exports = async function __executeActionDisableBot (action, recUser) { 7 | 8 | const database = this.__dep(`database`); 9 | const sharedLogger = this.__dep(`sharedLogger`); 10 | 11 | sharedLogger.debug({ 12 | text: `Disabling bot for user.`, 13 | userId: recUser._id, 14 | firstName: recUser.profile.firstName, 15 | lastName: recUser.profile.lastName, 16 | }); 17 | 18 | // Update the user record. 19 | recUser.bot = recUser.bot || {}; 20 | recUser.bot.disabled = true; 21 | await database.update(`User`, recUser, { bot: { disabled: true } }); 22 | 23 | }; 24 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/actions/enableBot.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * Enable the bot for the given user. 5 | */ 6 | module.exports = async function __executeActionEnableBot (action, recUser) { 7 | 8 | const database = this.__dep(`database`); 9 | const sharedLogger = this.__dep(`sharedLogger`); 10 | 11 | sharedLogger.debug({ 12 | text: `Enabling bot for user.`, 13 | userId: recUser._id, 14 | firstName: recUser.profile.firstName, 15 | lastName: recUser.profile.lastName, 16 | }); 17 | 18 | // Update the user record. 19 | recUser.bot = recUser.bot || {}; 20 | recUser.bot.disabled = false; 21 | await database.update(`User`, recUser, { bot: { disabled: false } }); 22 | 23 | }; 24 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/webviews/multiselect/webview.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | {{#each items}} 5 | 12 | {{/each}} 13 |
14 | 15 |
16 | {{#if cancelButton}}{{/if}} 17 | 18 |
19 | 20 |
21 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/models/message.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * SCHEMA: Message 5 | */ 6 | 7 | module.exports = function (Schema, Property, Reference) { 8 | 9 | return new Schema(`Message`, { 10 | _user: new Reference(`User`), 11 | _admin: new Reference(`User`, null), 12 | direction: new Property(`string`, null), 13 | sentAt: new Property(`date`, Date.now), 14 | sendDelay: new Property(`integer`, null), 15 | humanToHuman: new Property(`boolean`, false), 16 | batchable: new Property(`boolean`, false), 17 | data: new Property(`flexible`), 18 | }, { 19 | indices: [ 20 | { direction: 1, _user: 1, sentAt: -1 }, 21 | { humanToHuman: 1, _user: 1, sentAt: -1 }, 22 | { _user: 1, sentAt: -1 }, 23 | ], 24 | }); 25 | 26 | }; 27 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/hooks/flowinfo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * HOOK: $flowinfo 5 | */ 6 | 7 | /* eslint no-cond-assign: 0 */ 8 | 9 | module.exports = async function flowinfo (action, variables, { MessageObject, recUser, sendMessage, flows }) { 10 | 11 | // General user info. 12 | await sendMessage(recUser, MessageObject.outgoing(recUser, { 13 | text: [ 14 | `Previous Flow URI: ${recUser.conversation.previousStepUri}`, 15 | `Current Flow URI: ${recUser.conversation.currentStepUri}`, 16 | ].join(`\n`), 17 | })); 18 | 19 | await sendMessage(recUser, MessageObject.outgoing(recUser, { 20 | text: [ 21 | `Available Flows:`, 22 | ...Object.values(flows).map(flow => `${flow.uri} (${flow.dynamicId || flow.filename})`), 23 | ].join(`\n`), 24 | })); 25 | 26 | }; 27 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/matches/no.json: -------------------------------------------------------------------------------- 1 | { 2 | "^n$": "regexp", 3 | "^no+$": "regexp", 4 | "^absolutely not$": "regexp", 5 | "^cancel$": "regexp", 6 | "^certainly not$": "regexp", 7 | "^decline$": "regexp", 8 | "^denied$": "regexp", 9 | "^deny$": "regexp", 10 | "^disagreed?$": "regexp", 11 | "^fail$": "regexp", 12 | "^false$": "regexp", 13 | "^forget it$": "regexp", 14 | "^impossible$": "regexp", 15 | "^na+$": "regexp", 16 | "^nada+$": "regexp", 17 | "^nah+$": "regexp", 18 | "^naw+$": "regexp", 19 | "^nay+$": "regexp", 20 | "^negative$": "regexp", 21 | "^negatory$": "regexp", 22 | "^nein$": "regexp", 23 | "^never$": "regexp", 24 | "^no way$": "regexp", 25 | "^nope$": "regexp", 26 | "^not a chance$": "regexp", 27 | "^pass$": "regexp", 28 | "^reject$": "regexp", 29 | "^zero$": "regexp" 30 | } 31 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/models/task.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * SCHEMA: Task 5 | */ 6 | 7 | module.exports = function (Schema, Property, Reference) { 8 | 9 | return new Schema(`Task`, { 10 | hash: new Property(`string`, null), 11 | _user: new Reference(`User`), 12 | _admin: new Reference(`User`, null), 13 | actions: [ new Property(`flexible`) ], 14 | lastRunDate: new Property(`date`, null), 15 | nextRunDate: new Property(`date`, 0), 16 | runEvery: new Property(`string`, null), 17 | runTime: new Property(`string`, null), 18 | ignoreDays: [ new Property(`integer`) ], 19 | maxRuns: new Property(`integer`, 0), 20 | numRuns: new Property(`integer`, 0), 21 | allowConcurrent: new Property(`boolean`), 22 | lockedSinceDate: new Property(`date`, null), 23 | dateCreated: new Property(`date`, 0), 24 | }); 25 | 26 | }; 27 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/middleware/parseCommand.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * MIDDLEWARE: Parse Command 5 | */ 6 | 7 | module.exports = function parseCommandMiddleware (database, sharedLogger, workflowManager) { 8 | 9 | // The actual middleware. 10 | return async (message, adapter, recUser, next, stop) => { 11 | 12 | sharedLogger.debug({ 13 | text: `Running middleware "parseCommandMiddleware".`, 14 | direction: message.direction, 15 | message: message.text, 16 | userId: recUser._id.toString(), 17 | channelName: recUser.channel.name, 18 | channelUserId: recUser.channel.userId, 19 | }); 20 | 21 | // If we successfully manage to execute a command lets break the middleware chain here. 22 | if (await workflowManager.handleCommandIfPresent(message, recUser)) { return stop(null); } 23 | 24 | // Otherwise continue. 25 | return next(null, recUser); 26 | 27 | }; 28 | 29 | }; 30 | -------------------------------------------------------------------------------- /app/models/article.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * SCHEMA: Article 5 | */ 6 | 7 | module.exports = function (Schema, Property, Reference) { 8 | 9 | return new Schema(`Article`, { 10 | feedId: new Property(`string`), 11 | articleId: new Property(`string`), 12 | articleUrl: new Property(`string`), 13 | articleDate: new Property(`date`), 14 | imageUrl: new Property(`string`), 15 | title: new Property(`string`), 16 | description: new Property(`string`), 17 | isPriority: new Property(`boolean`), 18 | isPublished: new Property(`boolean`, true), 19 | _receivedByUsers: [ new Reference(`User`) ], 20 | _readByUsers: [ new Reference(`User`) ], 21 | ingestedDate: new Property(`date`, Date.now), 22 | breakingNewsMessageText: new Property(`string`), 23 | }, { 24 | indices: [ 25 | { isPriority: 1, articleDate: -1, isPublished: 1, _receivedByUsers: 1 }, 26 | ], 27 | }); 28 | 29 | }; 30 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/adapters/facebook/setSenderAction.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const makeRequest = require(`./makeRequest`); 4 | 5 | /* 6 | * Mark the bot as typing or not typing for the given user. 7 | */ 8 | module.exports = async function setSenderAction (_senderAction, apiUri, channelUserId, resources) { 9 | 10 | let senderAction; 11 | 12 | switch (_senderAction) { 13 | case `TYPING_ON`: senderAction = `typing_on`; break; 14 | case `TYPING_OFF`: senderAction = `typing_off`; break; 15 | case `MESSAGES_READ`: senderAction = `mark_seen`; break; 16 | default: throw new Error(`Invalid sender action "${_senderAction}".`); 17 | } 18 | 19 | const postData = { 20 | recipient: { 21 | id: channelUserId, 22 | }, 23 | sender_action: senderAction, // eslint-disable-line camelcase 24 | tag: `NON_PROMOTIONAL_SUBSCRIPTION`, 25 | }; 26 | 27 | await makeRequest(apiUri, postData, resources); 28 | 29 | }; 30 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/custom/customInputHandler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * CUSTOM INPUT HANDLER BASE. 5 | * 6 | * A handler to detect input and take some action. Designed to be extended by specific apps. 7 | * 8 | * Any sub-class must override handleInput(message) 9 | */ 10 | 11 | const HandlerBase = require(`../handlerBase`); 12 | 13 | module.exports = class CustomInputHandlerBase extends HandlerBase { 14 | 15 | /* 16 | * Initialises a new custom input handler. 17 | */ 18 | constructor (handlerId) { 19 | super(`custom`, handlerId); 20 | 21 | } 22 | 23 | /** 24 | * Sub-class must override this. Runs once when the periodic handler is configured. 25 | * returns true if the input has been handled and should stop further processing 26 | */ 27 | async handleInput (message, recUser) { // eslint-disable-line no-unused-vars 28 | throw new Error(`handleInput(...) must be overridden.`); 29 | } 30 | 31 | }; 32 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/middleware/trackUser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * MIDDLEWARE: Track User 5 | */ 6 | 7 | module.exports = function trackUserMiddleware (database, sharedLogger, workflowManager, hippocampOptions) { 8 | 9 | // The actual middleware. 10 | return async (message, adapter, recUser, next/* , stop */) => { 11 | 12 | sharedLogger.debug({ 13 | text: `Running middleware "trackUserMiddleware".`, 14 | direction: message.direction, 15 | message: message.text, 16 | userId: recUser._id.toString(), 17 | channelName: recUser.channel.name, 18 | channelUserId: recUser.channel.userId, 19 | }); 20 | 21 | // Skip if the user tracking functionality has been disabled. 22 | if (!hippocampOptions.enableUserTracking) { return next(null, recUser); } 23 | 24 | // Identify the user to the analytics provider. 25 | await workflowManager.trackUser(recUser); 26 | 27 | return next(null, recUser); 28 | 29 | }; 30 | 31 | }; 32 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/actions/wipeMemory.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * Update the in-memory user record AND the database with the new (blank) memories. 5 | */ 6 | module.exports = async function __executeActionWipeMemory (action, recUser) { 7 | 8 | if (!recUser) { throw new Error(`Cannot execute action "wipe memory" unless a user is provided.`); } 9 | 10 | const database = this.__dep(`database`); 11 | const scheduler = this.__dep(`scheduler`); 12 | const wipeProfile = (typeof action.wipeProfile === `undefined` ? true : action.wipeProfile); 13 | const wipeMessages = (typeof action.wipeMessages === `undefined` ? true : action.wipeMessages); 14 | 15 | await this.__wipeUserMemory(wipeProfile, recUser); 16 | 17 | // Wipe the user's messages. 18 | if (wipeMessages) { await database.deleteWhere(`Message`, { _user: recUser._id }); } 19 | 20 | // Remove all scheduled tasks. 21 | if (scheduler) { await scheduler.removeAllTasksForUser(recUser._id); } 22 | 23 | }; 24 | -------------------------------------------------------------------------------- /app/hooks/newsNotifications.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * HOOK: News Notifications 5 | */ 6 | 7 | const moment = require(`moment`); 8 | const breakingNews = require(`../modules/breakingNews`); 9 | 10 | /* 11 | * The hook itself. 12 | */ 13 | module.exports = async function newsNotifications (action, variables, resources) { 14 | 15 | const { database, MessageObject, sendMessage, sharedLogger } = resources; 16 | const notBeforeHour = variables.provider.notifications.notBeforeHour; 17 | const notAfterHour = variables.provider.notifications.notAfterHour; 18 | const now = moment.utc().add(variables.provider.timezoneOffset, `hours`); 19 | const hours = now.hours(); 20 | 21 | // Skip if we're outside the allowed notification hours. 22 | if (hours < notBeforeHour || hours > notAfterHour) { return; } 23 | 24 | sharedLogger.verbose(`Sending breaking news...`); 25 | 26 | await breakingNews.sendOutstanding(database, sharedLogger, MessageObject, sendMessage); 27 | 28 | }; 29 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/actions/trackUser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * Track a user using the analytics provider. 5 | */ 6 | module.exports = async function __executeActionTrackUser (action, recUser) { 7 | 8 | if (!recUser) { 9 | throw new Error(`Cannot execute action "track user" unless a user is provided.`); 10 | } 11 | 12 | const sharedLogger = this.__dep(`sharedLogger`); 13 | 14 | // Is user tracking allowed? 15 | if (!this.options.enableUserTracking) { 16 | sharedLogger.debug(`Skipping tracking user because "enableUserTracking" is disabled.`); 17 | return; 18 | } 19 | 20 | const analytics = this.__dep(`analytics`); 21 | 22 | if (!Object.keys(analytics).length) { 23 | throw new Error(`Cannot track users because an analytics handler has not been configured.`); 24 | } 25 | 26 | const trackPromises = Object.values(analytics).map( 27 | handler => handler.trackUser(recUser, action.traits) 28 | ); 29 | 30 | await Promise.all(trackPromises); 31 | 32 | }; 33 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/adapters/facebook/convertToFacebookPersistentMenu.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { convertToFacebookButtons } = require(`./convertToFacebookButtons`); 4 | 5 | /* 6 | * Converts the Hippocamp menu format to the Facebook persistent menu format. 7 | */ 8 | module.exports = function convertToFacebookPersistentMenu (menuItems) { 9 | 10 | const maxTopLevelItems = 3; 11 | const maxSubLevelItems = 5; 12 | 13 | let menu = convertToFacebookButtons(menuItems, 1); 14 | 15 | // Ensure we don't have too many top-level items. 16 | menu = (menu.length > maxTopLevelItems ? menu.slice(0, maxTopLevelItems) : menu); 17 | 18 | // Ensure we don't have too many sub items. 19 | menu = menu.map(item => { 20 | if (item.type === `nested` && item.call_to_actions.length > maxSubLevelItems) { 21 | item.call_to_actions = item.call_to_actions.slice(0, maxSubLevelItems); // eslint-disable-line camelcase 22 | } 23 | 24 | return item; 25 | }); 26 | 27 | return menu; 28 | 29 | }; 30 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/actions/trackEvent.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * Track an action using the analytics provider. 5 | */ 6 | module.exports = async function __executeActionTrackEvent (action, recUser) { 7 | 8 | if (!recUser) { 9 | throw new Error(`Cannot execute action "track event" unless a user is provided.`); 10 | } 11 | 12 | const sharedLogger = this.__dep(`sharedLogger`); 13 | 14 | // Is event tracking allowed? 15 | if (!this.options.enableEventTracking) { 16 | sharedLogger.debug(`Skipping tracking event because "enableEventTracking" is disabled.`); 17 | return; 18 | } 19 | 20 | const analytics = this.__dep(`analytics`); 21 | 22 | if (!Object.keys(analytics).length) { 23 | throw new Error(`Cannot track events because an analytics handler has not been configured.`); 24 | } 25 | 26 | const trackPromises = Object.values(analytics).map( 27 | handler => handler.trackEvent(recUser, action.event.name, action.event.data) 28 | ); 29 | 30 | await Promise.all(trackPromises); 31 | 32 | }; 33 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/middleware/getExistingUser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * MIDDLEWARE: Get Existing User 5 | */ 6 | 7 | module.exports = function getExistingUserMiddleware (database, sharedLogger) { 8 | 9 | // The actual middleware. 10 | return async (message, adapter, _recUser, next/* , stop */) => { // _recUser will be undefined as this is the first middleware in the chain. 11 | 12 | // Check if we have the given user in the database. 13 | const recUser = await database.get(`User`, { 14 | 'channel.name': message.channelName, 15 | 'channel.userId': message.channelUserId, 16 | }); 17 | 18 | sharedLogger.debug({ 19 | text: `Running middleware "getExistingUserMiddleware".`, 20 | direction: message.direction, 21 | message: message.text, 22 | userId: (recUser ? recUser._id.toString() : null), 23 | channelName: (recUser ? recUser.channel.name : null), 24 | channelUserId: (recUser ? recUser.channel.userId : null), 25 | }); 26 | 27 | return next(null, recUser || null); 28 | 29 | }; 30 | 31 | }; 32 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/actions/delay.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const utilities = require(`../../modules/utilities`); 4 | 5 | /* 6 | * Wait for the delay to be over. 7 | */ 8 | module.exports = async function __executeActionDelay (action, recUser) { 9 | 10 | if (!recUser) { throw new Error(`Cannot execute action "delay" unless a user is provided.`); } 11 | 12 | const sharedLogger = this.__dep(`sharedLogger`); 13 | const maxDelay = 15; 14 | const markAsTyping = (typeof action.markAsTyping === `undefined` ? true : action.markAsTyping); 15 | const delay = (action.delay <= maxDelay ? action.delay : maxDelay); 16 | 17 | // Warn about exceeding the max delay. 18 | if (action.delay > maxDelay) { 19 | sharedLogger.warn(`Action "delay" of "${action.delay}" seconds is too long, capped at ${maxDelay}.`); 20 | } 21 | 22 | // Mark as typing and then wait. 23 | if (markAsTyping) { 24 | const adapter = this.__dep(`adapter-${recUser.channel.name}`); 25 | await adapter.markAsTypingOn(recUser); 26 | } 27 | 28 | await utilities.delay(delay); 29 | 30 | }; 31 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/middleware/markAsHumanToHuman.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * MIDDLEWARE: Mark As Human To Human 5 | */ 6 | 7 | module.exports = function markAsHumanToHumanMiddleware (database, sharedLogger, hippocampOptions) { 8 | 9 | // The actual middleware. 10 | return async (message, adapter, recUser, next/* , stop */) => { 11 | 12 | // Skip this middleware if we actually have a misunderstood text and still managed to get here. 13 | if (hippocampOptions.misunderstoodText) { return next(null, recUser); } 14 | 15 | sharedLogger.debug({ 16 | text: `Running middleware "markAsHumanToHumanMiddleware".`, 17 | direction: message.direction, 18 | message: message.text, 19 | userId: recUser._id.toString(), 20 | channelName: recUser.channel.name, 21 | channelUserId: recUser.channel.userId, 22 | }); 23 | 24 | // Mark the message as human to human. 25 | message.humanToHuman = true; 26 | await database.update(`Message`, message.messageId, { 27 | humanToHuman: true, 28 | }); 29 | 30 | return next(null, recUser); 31 | 32 | }; 33 | 34 | }; 35 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/middleware/customInputHandlerMiddleware.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * MIDDLEWARE: Custom Input Handler 5 | */ 6 | 7 | module.exports = function customInputHandlerMiddleware (sharedLogger, customInputHandlers) { 8 | 9 | // The actual middleware. 10 | return async (message, adapter, recUser, next, stop) => { 11 | 12 | sharedLogger.debug({ 13 | text: `Running middleware "customInputHandlerMiddleware".`, 14 | direction: message.direction, 15 | message: message.text, 16 | userId: recUser._id.toString(), 17 | channelName: recUser.channel.name, 18 | channelUserId: recUser.channel.userId, 19 | }); 20 | 21 | for (const inputHandler of Object.values(customInputHandlers)) { 22 | /* 23 | * Run custom input handlers sequentially to avoid any possible conflicts 24 | * with running in parellel as we do not know the implementation of each hanlder. 25 | */ 26 | if (await inputHandler.handleInput(message, recUser)) { // eslint-disable-line no-await-in-loop 27 | return stop(null); 28 | } 29 | } 30 | 31 | return next(null, recUser); 32 | 33 | }; 34 | 35 | }; 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # EYEWITNESS CHATBOT - PRODUCTION DOCKERFILE 2 | 3 | # Use the official node base image and allow upgrades to any new minor version on redeploy. 4 | FROM node:10 5 | MAINTAINER Atchai 6 | ENV NODE_ENV=production 7 | 8 | # Prepare the software inside the container. 9 | RUN apt-get update && apt-get upgrade -y 10 | RUN apt-get install -y \ 11 | vim \ 12 | ntp \ 13 | && rm -rf /var/lib/apt/lists/* 14 | # ^^ Keep the image size down by removing the packages list. 15 | 16 | # Fix the time inside the container by starting the ntp service and setting the timezone. 17 | RUN ntpd -gq && service ntp start 18 | RUN echo Europe/London >/etc/timezone && dpkg-reconfigure -f noninteractive tzdata 19 | 20 | # Ensure we run commands inside the correct directory. 21 | WORKDIR /src 22 | 23 | # Install our depedencies. 24 | COPY lib /src/lib 25 | COPY package.json /src/package.json 26 | COPY package-lock.json /src/package-lock.json 27 | RUN npm install --production 28 | 29 | # Add all our application files. 30 | COPY app /src/app 31 | 32 | # Run the application. 33 | CMD ["npm", "run", "start-production"] 34 | -------------------------------------------------------------------------------- /Dockerfile.staging: -------------------------------------------------------------------------------- 1 | # EYEWITNESS CHATBOT - STAGING DOCKERFILE 2 | 3 | # Use the official node base image and allow upgrades to any new minor version on redeploy. 4 | FROM node:10 5 | MAINTAINER Atchai 6 | ENV NODE_ENV=staging 7 | 8 | # Prepare the software inside the container. 9 | RUN apt-get update && apt-get upgrade -y 10 | RUN apt-get install -y \ 11 | vim \ 12 | ntp \ 13 | && rm -rf /var/lib/apt/lists/* 14 | # ^^ Keep the image size down by removing the packages list. 15 | 16 | # Fix the time inside the container by starting the ntp service and setting the timezone. 17 | RUN ntpd -gq && service ntp start 18 | RUN echo Europe/London >/etc/timezone && dpkg-reconfigure -f noninteractive tzdata 19 | 20 | # Ensure we run commands inside the correct directory. 21 | WORKDIR /src 22 | 23 | # Install our depedencies. 24 | COPY lib /src/lib 25 | COPY package.json /src/package.json 26 | COPY package-lock.json /src/package-lock.json 27 | RUN npm install --production 28 | 29 | # Add all our application files. 30 | COPY app /src/app 31 | 32 | # Run the application. 33 | CMD ["npm", "run", "start-production"] 34 | -------------------------------------------------------------------------------- /Dockerfile.development: -------------------------------------------------------------------------------- 1 | # EYEWITNESS CHATBOT - DEVELOPMENT DOCKERFILE 2 | 3 | # Use the official node base image and allow upgrades to any new minor version on redeploy. 4 | FROM node:10 5 | MAINTAINER Atchai 6 | ENV NODE_ENV=development 7 | 8 | # Prepare the software inside the container. 9 | RUN apt-get update && apt-get upgrade -y 10 | RUN apt-get install -y \ 11 | vim \ 12 | ntp \ 13 | && rm -rf /var/lib/apt/lists/* 14 | # ^^ Keep the image size down by removing the packages list. 15 | 16 | # Fix the time inside the container by starting the ntp service and setting the timezone. 17 | RUN ntpd -gq && service ntp start 18 | RUN echo Europe/London >/etc/timezone && dpkg-reconfigure -f noninteractive tzdata 19 | 20 | # Ensure we run commands inside the correct directory. 21 | WORKDIR /src 22 | 23 | # Install our depedencies. 24 | COPY .env /src/.env 25 | COPY lib /src/lib 26 | COPY package.json /src/package.json 27 | COPY package-lock.json /src/package-lock.json 28 | RUN npm install 29 | 30 | # Add all our application files. 31 | COPY app /src/app 32 | 33 | # Run the application. 34 | CMD ["npm", "run", "start-development"] 35 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/actions/scheduleTask.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * Adds a scheduled task. 5 | */ 6 | module.exports = async function __executeActionScheduleTask (action, recUser) { 7 | 8 | const scheduler = this.__dep(`scheduler`); 9 | const task = action.task; 10 | 11 | if (!scheduler) { 12 | throw new Error(`You cannot schedule a task because no scheduler handler has been configured.`); 13 | } 14 | 15 | if (!task.taskId) { 16 | throw new Error(`Cannot schedule a task without a unique task ID.`); 17 | } 18 | 19 | if (!task.nextRunDate && !task.runEvery) { 20 | throw new Error(`Cannot schedule a task without one of "nextRunDate" or "runEvery".`); 21 | } 22 | 23 | const userId = (recUser ? recUser._id.toString() : null); 24 | const timezoneUtcOffset = (recUser && recUser.profile ? (recUser.profile.timezoneUtcOffset || 0) : 0); 25 | 26 | await scheduler.addTask(task.taskId, userId, task.actions, { 27 | nextRunDate: task.nextRunDate, 28 | runEvery: task.runEvery, 29 | runTime: task.runTime, 30 | maxRuns: task.maxRuns, 31 | ignoreDays: task.ignoreDays || [], 32 | allowConcurrent: task.allowConcurrent, 33 | }, timezoneUtcOffset); 34 | 35 | }; 36 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/actions/updateMemory.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const extender = require(`object-extender`); 4 | 5 | /* 6 | * Execute the memory update operations specified in the action. 7 | */ 8 | module.exports = async function __executeActionUpdateMemory (action, recUser) { 9 | 10 | if (!recUser) { throw new Error(`Cannot execute action "update memory" unless a user is provided.`); } 11 | 12 | const MessageObject = this.__dep(`MessageObject`); 13 | const sharedLogger = this.__dep(`sharedLogger`); 14 | const variables = extender.merge(this.options.messageVariables, recUser.profile, recUser.appData); 15 | const errorMessage = action.errorMessage || `There was a problem saving data into memory.`; 16 | 17 | try { 18 | await this.updateUserMemory(variables, action.memory, recUser, errorMessage); 19 | } 20 | catch (err) { 21 | 22 | const validationErrorMessage = MessageObject.outgoing(recUser, { 23 | text: err.message, 24 | }); 25 | 26 | await this.sendMessage(recUser, validationErrorMessage); 27 | sharedLogger.error(`Failed to execute update memory from action because of "${err}".`); 28 | return false; 29 | 30 | } 31 | 32 | return true; 33 | 34 | }; 35 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/middleware/refreshUserProfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * MIDDLEWARE: Refresh User Profile 5 | */ 6 | 7 | const { refreshUserProfile } = require(`../modules/userProfileRefresher`); 8 | 9 | module.exports = function refreshUserProfileMiddleware (database, sharedLogger, triggerEvent, hippocampOptions) { 10 | 11 | // The actual middleware. 12 | return async (message, adapter, recUser, next/* , stop */) => { 13 | 14 | sharedLogger.debug({ 15 | text: `Running middleware "refreshUserProfileMiddleware".`, 16 | direction: message.direction, 17 | message: message.text, 18 | userId: recUser._id.toString(), 19 | channelName: recUser.channel.name, 20 | channelUserId: recUser.channel.userId, 21 | }); 22 | 23 | // Skip if the user profile functionality has been disabled. 24 | if (!hippocampOptions.enableUserProfile) { return next(null, recUser); } 25 | 26 | const options = { 27 | refreshEvery: hippocampOptions.refreshUsersEvery, 28 | whitelistProfileFields: hippocampOptions.whitelistProfileFields, 29 | }; 30 | 31 | await refreshUserProfile(recUser, adapter, database, triggerEvent, options); 32 | 33 | return next(null, recUser); 34 | 35 | }; 36 | 37 | }; 38 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/middleware/continueConversation.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * MIDDLEWARE: Continue Conversation 5 | */ 6 | 7 | module.exports = function continueConversationMiddleware (database, sharedLogger, workflowManager) { 8 | 9 | // The actual middleware. 10 | return async (message, adapter, recUser, next, stop) => { 11 | 12 | sharedLogger.debug({ 13 | text: `Running middleware "continueConversationMiddleware".`, 14 | direction: message.direction, 15 | message: message.text, 16 | userId: recUser._id.toString(), 17 | channelName: recUser.channel.name, 18 | channelUserId: recUser.channel.userId, 19 | }); 20 | 21 | // Continue with the previous step and stop the flow here. 22 | if (recUser.conversation.waitingOnPrompt) { 23 | await workflowManager.handlePromptReply(message, recUser); 24 | return stop(null); 25 | } 26 | 27 | // If we get here then we don't understand the message. 28 | const sentMisunderstood = await workflowManager.sendMisunderstoodMessage(recUser, message); 29 | 30 | // Stop here if we sent the misunderstood message to the user, or otherwise continue on to the next middleware. 31 | if (sentMisunderstood) { return stop(null); } 32 | 33 | return next(null, recUser); 34 | 35 | }; 36 | 37 | }; 38 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/nlp/nlpBase.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * NLP BASE 5 | */ 6 | 7 | const HandlerBase = require(`../handlerBase`); 8 | 9 | module.exports = class NlpBase extends HandlerBase { 10 | 11 | /* 12 | * Initialises a new NLP handler. 13 | */ 14 | constructor (type, handlerId) { // eslint-disable-line no-useless-constructor 15 | super(type, handlerId); 16 | 17 | this.ROUNDING_NUM_DECIMALS = 6; 18 | } 19 | 20 | /* 21 | * Logs out a message if an NLP handler hasn't overridden this method. 22 | */ 23 | async parseMessage (/* messageText */) { 24 | const sharedLogger = this.__dep(`sharedLogger`); 25 | sharedLogger.silly(`NLP handler "${this.getHandlerId()}" has not implemented message parsing.`); 26 | } 27 | 28 | /* 29 | * Ensures the intent name is a JavaScript key-friendly value. 30 | */ 31 | formatObjectName (input) { 32 | return input.toUpperCase().replace(/[^a-z0-9_]/ig, `_`).replace(/^(\d)/i, `_$1`); 33 | } 34 | 35 | /* 36 | * Rounds scores to a sensible number of decimal places. 37 | */ 38 | roundScore (input) { 39 | 40 | const number = parseFloat(input); 41 | const multiplier = Math.pow(10, this.ROUNDING_NUM_DECIMALS); 42 | 43 | return Math.round(number * multiplier) / multiplier; 44 | 45 | } 46 | 47 | }; 48 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/models/user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * SCHEMA: User 5 | */ 6 | 7 | module.exports = function (Schema, Property) { 8 | 9 | return new Schema(`User`, { 10 | profile: { 11 | firstName: new Property(`string`, ``), 12 | lastName: new Property(`string`, ``), 13 | gender: new Property(`string`, ``), 14 | email: new Property(`string`, ``), 15 | tel: new Property(`string`, ``), 16 | age: new Property(`integer`, null), 17 | profilePicUrl: new Property(`string`, ``), 18 | timezoneUtcOffset: new Property(`integer`, 0), 19 | referral: new Property(`string`, null), 20 | created: new Property(`date`, Date.now), 21 | lastUpdated: new Property(`date`, 0), 22 | }, 23 | channel: { 24 | name: new Property(`string`), 25 | userId: new Property(`string`), 26 | }, 27 | conversation: { 28 | previousStepUri: new Property(`string`, null), 29 | currentStepUri: new Property(`string`, null), 30 | waitingOnPrompt: new Property(`boolean`, false), 31 | lastMessageReceivedAt: new Property(`date`, Date.now), 32 | lastMessageSentAt: new Property(`date`, 0), 33 | }, 34 | bot: { 35 | disabled: new Property(`boolean`, false), 36 | removed: new Property(`boolean`, false), 37 | }, 38 | appData: new Property(`flexible`), 39 | }); 40 | 41 | }; 42 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/middleware/logMessage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * MIDDLEWARE: Log Message 5 | */ 6 | 7 | const extender = require(`object-extender`); 8 | 9 | module.exports = function logMessageMiddleware (sharedLogger) { 10 | 11 | // The actual middleware. 12 | return async (message, adapter, recUser, next/* , stop */) => { 13 | 14 | sharedLogger.debug({ 15 | text: `Running middleware "logMessageMiddleware".`, 16 | direction: message.direction, 17 | message: message.text, 18 | userId: (recUser ? recUser._id.toString() : null), 19 | channelName: (recUser ? recUser.channel.name : null), 20 | channelUserId: (recUser ? recUser.channel.userId : null), 21 | }); 22 | 23 | const messageClone = extender.clone(message); 24 | 25 | // Remove attachment data, if present. We don't want a huge data dump in the logs. 26 | if (Array.isArray(messageClone.attachments)) { 27 | messageClone.attachments.forEach(attachment => { 28 | if (attachment.data) { attachment.data = ``; } 29 | }); 30 | } 31 | 32 | // Log out! 33 | const logDirection = `${message.direction[0].toUpperCase()}${message.direction.substr(1)}`; 34 | await sharedLogger.debug({ text: `${logDirection} message`, message: messageClone }); 35 | 36 | return next(null, recUser); 37 | 38 | }; 39 | 40 | }; 41 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/matches/yes.json: -------------------------------------------------------------------------------- 1 | { 2 | "^y$": "regexp", 3 | "^yes+$": "regexp", 4 | "^absolutely$": "regexp", 5 | "^affirmative$": "regexp", 6 | "^agree$": "regexp", 7 | "^aight$": "regexp", 8 | "^all right$": "regexp", 9 | "^alright$": "regexp", 10 | "^aye$": "regexp", 11 | "^certainly$": "regexp", 12 | "^confirm$": "regexp", 13 | "^cool$": "regexp", 14 | "^it'?s cool$": "regexp", 15 | "^correct$": "regexp", 16 | "^definitely$": "regexp", 17 | "^exactly$": "regexp", 18 | "^fine$": "regexp", 19 | "^gladly$": "regexp", 20 | "^good$": "regexp", 21 | "^indeed$": "regexp", 22 | "^indubitably$": "regexp", 23 | "^maybe$": "regexp", 24 | "^of course$": "regexp", 25 | "^ofc$": "regexp", 26 | "^kk?$": "regexp", 27 | "^ok$": "regexp", 28 | "^okay+$": "regexp", 29 | "^precisely$": "regexp", 30 | "^right on$": "regexp", 31 | "^right$": "regexp", 32 | "^sure thing$": "regexp", 33 | "^sure$": "regexp", 34 | "^surely$": "regexp", 35 | "^true$": "regexp", 36 | "^uh(?:-| )huh$": "regexp", 37 | "^very well$": "regexp", 38 | "^ya+$": "regexp", 39 | "^ya+s+$": "regexp", 40 | "^yah+$": "regexp", 41 | "^ye+$": "regexp", 42 | "^yea+$": "regexp", 43 | "^yeah+$": "regexp", 44 | "^yeh+$": "regexp", 45 | "^yep+$": "regexp", 46 | "^yessir$": "regexp", 47 | "^yessum$": "regexp", 48 | "^yis+$": "regexp", 49 | "^you bet$": "regexp", 50 | "^yup+$": "regexp", 51 | "^yus+$": "regexp" 52 | } 53 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/analytics/dashbot.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * ANALYTICS: Dashbot 5 | */ 6 | 7 | const dashbot = require(`dashbot`); 8 | const extender = require(`object-extender`); 9 | const AnalyticsBase = require(`./analyticsBase`); 10 | 11 | module.exports = class AnalyticsDashbot extends AnalyticsBase { 12 | 13 | /* 14 | * Instantiates the handler. 15 | */ 16 | constructor (_options) { 17 | 18 | // Configure the handler. 19 | super(`analytics`, `dashbot`); 20 | 21 | // Default config for this handler. 22 | this.options = extender.defaults({ 23 | apiKey: null, 24 | obfuscateMessages: false, 25 | isDisabled: false, 26 | }, _options); 27 | 28 | this.isDisabled = this.options.isDisabled; 29 | 30 | // Instantiate Dashbot. 31 | this.analytics = dashbot(this.options.apiKey).generic; 32 | 33 | } 34 | 35 | /* 36 | * Push the given message into Dashbot. 37 | */ 38 | trackMessage (recUser, message) { 39 | 40 | if (this.checkIfDisabled()) { return; } 41 | 42 | const methodName = `log${message.direction[0].toUpperCase()}${message.direction.substr(1)}`; 43 | const logMessage = this.analytics[methodName]; 44 | let text = message.text || `-not set-`; 45 | let platformJson = message; 46 | 47 | if (this.options.obfuscateMessages) { 48 | text = `-obfuscated-`; 49 | platformJson = { obfuscated: true }; 50 | } 51 | 52 | logMessage({ 53 | userId: recUser._id.toString(), 54 | text, 55 | platformJson, 56 | }); 57 | 58 | } 59 | 60 | }; 61 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/modules/sharedLogger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * LOGGER 5 | * Makes the configured logger available to all other modules by taking advantage of Node's module caching. 6 | */ 7 | 8 | const loggers = []; 9 | 10 | /* 11 | * Adds a new logger to the cache. 12 | */ 13 | module.exports.add = function addLogger (logger) { 14 | loggers.push(logger); 15 | }; 16 | 17 | /* 18 | * Call the appropriate method on every logger in turn. 19 | */ 20 | const executeAllLoggers = async (method, ...args) => { 21 | 22 | const promises = loggers.map( 23 | logger => logger[method](...args).then(output => Object({ type: logger.getHandlerId(), output })) 24 | ); 25 | 26 | // Execute all loggers at once. 27 | const results = await Promise.all(promises); 28 | const output = {}; 29 | 30 | // Convert the array of results to a hash so we can access like "output.terminal". 31 | results.forEach(result => output[result.type] = result.output); 32 | return output; 33 | 34 | }; 35 | 36 | module.exports.fatal = (...args) => executeAllLoggers(`fatal`, ...args); 37 | module.exports.error = (...args) => executeAllLoggers(`error`, ...args); 38 | module.exports.warn = (...args) => executeAllLoggers(`warn`, ...args); 39 | module.exports.info = (...args) => executeAllLoggers(`info`, ...args); 40 | module.exports.debug = (...args) => executeAllLoggers(`debug`, ...args); 41 | module.exports.verbose = (...args) => executeAllLoggers(`verbose`, ...args); 42 | module.exports.silly = (...args) => executeAllLoggers(`silly`, ...args); 43 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/hooks.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * WORKFLOW: HOOKS 5 | * Functions for dealing with hooks. 6 | */ 7 | 8 | const path = require(`path`); 9 | 10 | /* 11 | * Load and cache all the hooks in the top-level hooks directory (not its subdirectories). 12 | */ 13 | async function __loadHooks (directory) { 14 | 15 | const sharedLogger = this.__dep(`sharedLogger`); 16 | 17 | sharedLogger.debug(`Loading default and app hooks...`); 18 | 19 | const defaultHooksDirectory = path.join(__dirname, `../hooks`); 20 | const defaultHookFiles = await this.discoverFiles(defaultHooksDirectory, `js`, false); 21 | const appHookFiles = (directory ? await this.discoverFiles(directory, `js`, false) : []); 22 | const allHookFiles = defaultHookFiles.concat(...appHookFiles); 23 | 24 | // Load all of the hook files in turn. 25 | for (const hookFile of allHookFiles) { 26 | const hookName = this.parseFilename(hookFile, `js`); 27 | 28 | if (this.__getHook(hookName)) { 29 | throw new Error(`A hook with the name "${hookName}" has already been defined.`); 30 | } 31 | 32 | this.hooks[hookName] = { 33 | hookName, 34 | definition: require(hookFile), // eslint-disable-line global-require 35 | }; 36 | } 37 | 38 | sharedLogger.debug(`${defaultHookFiles.length} default and ${appHookFiles.length} app hooks loaded.`); 39 | 40 | } 41 | 42 | /* 43 | * Returns the given hook by its name. 44 | */ 45 | function __getHook (hookName) { 46 | return this.hooks[(hookName || ``).toLowerCase()] || null; 47 | } 48 | 49 | /* 50 | * Export. 51 | */ 52 | module.exports = { 53 | __loadHooks, 54 | __getHook, 55 | }; 56 | -------------------------------------------------------------------------------- /app/config/production.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "loggers": { 3 | "terminal": { 4 | "logLevel": "debug", 5 | "pretty": false 6 | } 7 | }, 8 | "databases": { 9 | "mongo": { 10 | "connectionString": null 11 | } 12 | }, 13 | "analytics": { 14 | "dashbot": { 15 | "apiKey": null 16 | }, 17 | "segment": { 18 | "writeKey": null 19 | } 20 | }, 21 | "adapters": { 22 | "facebook": { 23 | "verifyToken": null, 24 | "accessToken": null 25 | }, 26 | "web": { 27 | "endpoint": null, 28 | "accessToken": null 29 | } 30 | }, 31 | "nlp": { 32 | "luis": { 33 | "isDisabled": true, 34 | "appId": null, 35 | "apiKey": null, 36 | "region": null, 37 | "spellCheck": true, 38 | "isStagingEnv": false 39 | } 40 | }, 41 | "greetingText": null, 42 | "privacyPolicyUrl": null, 43 | "messageVariables": { 44 | "provider": { 45 | "name": "Unknown", 46 | "rssFeedUrl": null, 47 | "timezoneOffset": 0, 48 | "itemPriorityField": "category", 49 | "itemPriorityValue": "breaking-news", 50 | "notifications": { 51 | "notBeforeHour": 8, 52 | "notAfterHour": 20 53 | } 54 | } 55 | }, 56 | "scheduledTasks": { 57 | "feed-ingester": { 58 | "runEvery": "5 minutes" 59 | }, 60 | "news-notifications": { 61 | "runEvery": "5 minutes" 62 | } 63 | }, 64 | "breakingNews": { 65 | "maxTries": 5 66 | }, 67 | "hippocampServer": { 68 | "baseUrl": null, 69 | "ports": { 70 | "internal": 5000 71 | } 72 | }, 73 | "readServer": { 74 | "baseUrl": null, 75 | "ports": { 76 | "internal": 5001 77 | } 78 | }, 79 | "uiServer": { 80 | "baseUrl": null 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/analytics/analyticsBase.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * ANALYTICS BASE 5 | */ 6 | 7 | const HandlerBase = require(`../handlerBase`); 8 | 9 | module.exports = class AnalyticsBase extends HandlerBase { 10 | 11 | /* 12 | * Initialises a new analytics handler. 13 | */ 14 | constructor (type, handlerId) { // eslint-disable-line no-useless-constructor 15 | super(type, handlerId); 16 | } 17 | 18 | /* 19 | * Logs out a message if an analytics handler hasn't overridden this method. 20 | */ 21 | trackUser (/* recUser, extraTraits */) { 22 | const sharedLogger = this.__dep(`sharedLogger`); 23 | sharedLogger.silly(`Analytics handler "${this.getHandlerId()}" does not support tracking users.`); 24 | } 25 | 26 | /* 27 | * Logs out a message if an analytics handler hasn't overridden this method. 28 | */ 29 | trackEvent (/* recUser, eventName, eventData */) { 30 | const sharedLogger = this.__dep(`sharedLogger`); 31 | sharedLogger.silly(`Analytics handler "${this.getHandlerId()}" does not support tracking events.`); 32 | } 33 | 34 | /* 35 | * Logs out a message if an analytics handler hasn't overridden this method. 36 | */ 37 | trackMessage (/* recUser, message */) { 38 | const sharedLogger = this.__dep(`sharedLogger`); 39 | sharedLogger.silly(`Analytics handler "${this.getHandlerId()}" does not support tracking messages.`); 40 | } 41 | 42 | /* 43 | * Logs out a message if an analytics handler hasn't overridden this method. 44 | */ 45 | trackPageView (/* recUser, url, title */) { 46 | const sharedLogger = this.__dep(`sharedLogger`); 47 | sharedLogger.silly(`Analytics handler "${this.getHandlerId()}" does not support tracking page views.`); 48 | } 49 | 50 | }; 51 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/middleware/populateMessageVariables.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * MIDDLEWARE: Populate Message Variables 5 | */ 6 | 7 | const extender = require(`object-extender`); 8 | const utilities = require(`../modules/utilities`); 9 | 10 | module.exports = function populateMessageVariablesMiddleware (database, sharedLogger, hippocampOptions) { 11 | 12 | // The actual middleware. 13 | return async (message, adapter, recUser, next/* , stop */) => { 14 | 15 | sharedLogger.debug({ 16 | text: `Running middleware "populateMessageVariablesMiddleware".`, 17 | direction: message.direction, 18 | message: message.text, 19 | userId: recUser._id.toString(), 20 | channelName: recUser.channel.name, 21 | channelUserId: recUser.channel.userId, 22 | }); 23 | 24 | // Prepare variables. 25 | const variables = extender.merge( 26 | hippocampOptions.messageVariables, 27 | recUser.profile, 28 | recUser.appData 29 | ); 30 | 31 | // Compile the various templates. 32 | if (message.text) { message.text = utilities.compileTemplate(message.text, variables); } 33 | 34 | if (message.options) { 35 | message.options = message.options.map(option => { 36 | option.label = utilities.compileTemplate(option.label, variables); 37 | option.payload = utilities.compileTemplate(option.payload, variables); 38 | return option; 39 | }); 40 | } 41 | 42 | if (message.buttons) { 43 | message.buttons = message.buttons.map(button => { 44 | button.label = utilities.compileTemplate(button.label, variables); 45 | button.payload = utilities.compileTemplate(button.payload, variables); 46 | return button; 47 | }); 48 | } 49 | 50 | return next(null, recUser); 51 | 52 | }; 53 | 54 | }; 55 | -------------------------------------------------------------------------------- /app/modules/initConfig.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * INITIALISE CONFIG 5 | */ 6 | 7 | const path = require(`path`); 8 | 9 | const config = require(`config-ninja`).init(`eyewitness-bot-config`, `./config`, { 10 | environmentVariables: { 11 | enableDotenv: (process.env.NODE_ENV === `development`), 12 | dotenvPath: path.join(__dirname, `..`, `..`, `.env`), 13 | mapping: { 14 | LOGGERS_TERMINAL_LEVEL: `loggers.terminal.logLevel`, 15 | DB_MONGO_CONNECTION_STR: `databases.mongo.connectionString`, 16 | ANALYTICS_DASHBOT_API_KEY: `analytics.dashbot.apiKey`, 17 | ANALYTICS_SEGMENT_WRITE_KEY: `analytics.segment.writeKey`, 18 | ADAPTER_FB_VERIFY_TOKEN: `adapters.facebook.verifyToken`, 19 | ADAPTER_FB_ACCESS_TOKEN: `adapters.facebook.accessToken`, 20 | ADAPTER_WEB_ENDPOINT: `adapters.web.endpoint`, 21 | ADAPTER_WEB_ACCESS_TOKEN: `adapters.web.accessToken`, 22 | NLP_LUIS_DISABLED: `nlp.luis.isDisabled`, 23 | NLP_LUIS_APP_ID: `nlp.luis.appId`, 24 | NLP_LUIS_API_KEY: `nlp.luis.apiKey`, 25 | NLP_LUIS_APP_REGION: `nlp.luis.region`, 26 | SERVER_URI_BOT: `hippocampServer.baseUrl`, 27 | SERVER_URI_READ: `readServer.baseUrl`, 28 | SERVER_URI_UI: `uiServer.baseUrl`, 29 | PROVIDER_NAME: `messageVariables.provider.name`, 30 | PROVIDER_FEED_URI: `messageVariables.provider.rssFeedUrl`, 31 | PROVIDER_TIMEZONE_OFFSET: `messageVariables.provider.timezoneOffset`, 32 | PROVIDER_ITEM_PRIORITY_FIELD: `messageVariables.provider.itemPriorityField`, 33 | PROVIDER_ITEM_PRIORITY_VALUE: `messageVariables.provider.itemPriorityValue`, 34 | GREETING_TEXT: `greetingText`, 35 | PRIVACY_POLICY_URI: `privacyPolicyUrl`, 36 | }, 37 | }, 38 | }); 39 | 40 | /* 41 | * EXPORT 42 | */ 43 | module.exports = config; 44 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/hooks/userinfo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * HOOK: $userinfo 5 | */ 6 | 7 | /* eslint no-cond-assign: 0 */ 8 | 9 | module.exports = async function userinfo (action, variables, { MessageObject, recUser, sendMessage }) { 10 | 11 | // General user info. 12 | const messageGeneralInfo = MessageObject.outgoing(recUser, { 13 | text: [ 14 | `User ID: ${recUser._id}`, 15 | ].join(`\n`), 16 | }); 17 | 18 | await sendMessage(recUser, messageGeneralInfo); 19 | 20 | // User profile info. 21 | const messageChannelInfo = MessageObject.outgoing(recUser, { 22 | text: [ 23 | `Channel:`, 24 | JSON.stringify(recUser.channel, null, 2), 25 | ].join(`\n`), 26 | }); 27 | 28 | await sendMessage(recUser, messageChannelInfo); 29 | // User profile info. 30 | const messageUserProfile = MessageObject.outgoing(recUser, { 31 | text: [ 32 | `Profile:`, 33 | JSON.stringify(recUser.profile, null, 2), 34 | ].join(`\n`), 35 | }); 36 | 37 | await sendMessage(recUser, messageUserProfile); 38 | 39 | // App data - split into chunks of 500 chars each. 40 | const maxLen = 500; 41 | const fullAppDataText = [ 42 | `App Data:`, 43 | JSON.stringify(recUser.appData || {}, null, 2), 44 | ].join(`\n`); 45 | let nextChunk; 46 | let index = 0; 47 | const appDataTextParts = []; 48 | 49 | while (nextChunk = fullAppDataText.substr(index, maxLen)) { 50 | appDataTextParts.push(nextChunk); 51 | index += maxLen; 52 | } 53 | 54 | // Send the app data chunks in order. 55 | await appDataTextParts.reduce((chain, chunk) => { 56 | 57 | const messagAppData = MessageObject.outgoing(recUser, { 58 | text: chunk, 59 | }); 60 | 61 | return chain.then(() => sendMessage(recUser, messagAppData)); 62 | 63 | }, Promise.resolve()); 64 | 65 | }; 66 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/handlerBase.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * HANDLER 5 | */ 6 | 7 | const MiddlewareEngine = require(`middleware-engine`); 8 | 9 | module.exports = class HandlerBase extends MiddlewareEngine { 10 | 11 | /* 12 | * Instantiates a new handler. 13 | */ 14 | constructor (_type, _handlerId, middlewareEngineConfig = null) { 15 | 16 | // Configure the middleware engine. 17 | super(middlewareEngineConfig); 18 | 19 | this.type = _type; 20 | this.handlerId = _handlerId; 21 | this.isDisabled = false; 22 | 23 | } 24 | 25 | /* 26 | * Returns the type of the currently instantiated handler. 27 | */ 28 | getType () { 29 | return this.type; 30 | } 31 | 32 | /* 33 | * Returns the type of the currently instantiated handler. 34 | */ 35 | getHandlerId () { 36 | return this.handlerId; 37 | } 38 | 39 | /* 40 | * Logs out a message and returns true if the handler is disabled. 41 | */ 42 | checkIfDisabled () { 43 | 44 | if (!this.isDisabled) { return false; } 45 | 46 | const sharedLogger = this.__dep(`sharedLogger`); 47 | 48 | sharedLogger.silly(`The ${this.getType()} handler "${this.getHandlerId()}" is disabled.`); 49 | return true; 50 | 51 | } 52 | 53 | /* 54 | * Creates an error with the given message and fixes the stack trace (otherwise the stack trace would start at this 55 | * method instead of the actual place the error gets thrown). 56 | */ 57 | __error (errorId, message) { 58 | 59 | const err = new Error(message); 60 | 61 | // Remove the first two lines from the stack trace. 62 | const stack = err.stack.split(/\n/g); 63 | stack.splice(1, 2); 64 | err.stack = stack.join(`\n`); 65 | 66 | // Add the error ID. 67 | err.errorId = errorId; 68 | 69 | return err; 70 | 71 | } 72 | 73 | }; 74 | -------------------------------------------------------------------------------- /app/modules/miscellaneous.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* 3 | * MISCELLANEOUS 4 | */ 5 | 6 | const config = require(`config-ninja`).use(`eyewitness-bot-config`); 7 | 8 | const RequestNinja = require(`request-ninja`); 9 | 10 | /* 11 | * Push new incoming and outgoing messages to the Eyewitness UI. 12 | */ 13 | async function pushNewMessagesToUI (data) { 14 | 15 | const uiServerUrl = `${config.uiServer.baseUrl}/webhooks/new-message`; 16 | 17 | const req = new RequestNinja(uiServerUrl, { 18 | timeout: (1000 * 30), 19 | returnResponseObject: true, 20 | }); 21 | 22 | const res = await req.postJson({ 23 | userId: data.recUser._id.toString(), 24 | message: data.message, 25 | }); 26 | 27 | if (res.statusCode !== 200) { 28 | throw new Error(`Non 200 HTTP status code "${res.statusCode}" returned by the Eyewitness UI.`); 29 | } 30 | 31 | if (!res.body || !res.body.success) { 32 | throw new Error(`The Eyewitness UI returned an error: "${res.body.error}".`); 33 | } 34 | 35 | } 36 | 37 | /* 38 | * Push memory change events to the Eyewitness UI. 39 | */ 40 | async function pushMemoryChangeToUI (data) { 41 | 42 | const uiServerUrl = `${config.uiServer.baseUrl}/webhooks/memory-change`; 43 | 44 | const req = new RequestNinja(uiServerUrl, { 45 | timeout: (1000 * 30), 46 | returnResponseObject: true, 47 | }); 48 | 49 | const res = await req.postJson({ 50 | userId: data.recUser._id.toString(), 51 | memory: data.memory, 52 | }); 53 | 54 | if (res.statusCode !== 200) { 55 | throw new Error(`Non 200 HTTP status code "${res.statusCode}" returned by the Eyewitness UI.`); 56 | } 57 | 58 | if (!res.body || !res.body.success) { throw new Error(`The Eyewitness UI returned an error: "${res.body.error}".`); } 59 | 60 | } 61 | 62 | /* 63 | * Export. 64 | */ 65 | module.exports = { 66 | pushNewMessagesToUI, 67 | pushMemoryChangeToUI, 68 | }; 69 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/adapters/facebook/convertToFacebookButtons.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * Convert an array of internal buttons to an array of Facebook buttons. 5 | */ 6 | function convertToFacebookButtons (items, maxNestingLevel = 0, curNestingLevel = 0) { 7 | 8 | const output = []; 9 | 10 | for (let index = 0; index < items.length; index++) { 11 | const item = items[index]; 12 | const button = { 13 | title: item.label, 14 | }; 15 | 16 | switch (item.type) { 17 | case `nested`: 18 | if (curNestingLevel >= maxNestingLevel) { continue; } // Skip further nesting if we are already nested to the max. 19 | button.type = `nested`; 20 | button.call_to_actions = convertToFacebookButtons(item.items, maxNestingLevel, curNestingLevel + 1); // eslint-disable-line camelcase 21 | break; 22 | 23 | case `url`: 24 | button.type = `web_url`; 25 | button.url = item.payload; 26 | button.webview_height_ratio = `full`; // eslint-disable-line camelcase 27 | button.webview_share_button = (item.sharing ? `show` : `hide`); // eslint-disable-line camelcase 28 | button.messenger_extensions = Boolean(item.trusted); // eslint-disable-line camelcase 29 | break; 30 | 31 | case `call`: 32 | button.type = `phone_number`; 33 | button.payload = item.payload; 34 | break; 35 | 36 | case `basic`: 37 | default: 38 | button.type = `postback`; 39 | button.payload = item.payload || item.label; 40 | break; 41 | } 42 | 43 | output.push(button); 44 | } 45 | 46 | return output; 47 | 48 | } 49 | 50 | /* 51 | * Helper method to convert a single button at a time. 52 | */ 53 | function convertToFacebookSingleButton (item) { 54 | return convertToFacebookButtons([ item ])[0]; 55 | } 56 | 57 | /* 58 | * Export. 59 | */ 60 | module.exports = { 61 | convertToFacebookButtons, 62 | convertToFacebookSingleButton, 63 | }; 64 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/misunderstood.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * WORKFLOW: MISUNDERSTOOD 5 | * Functions for handling the case when the bot doesn't understand the user's input. 6 | */ 7 | 8 | /* 9 | * Sends a message to the user explaining that the bot doesn't know what they meant. 10 | */ 11 | async function sendMisunderstoodMessage (recUser, message) { 12 | 13 | const sharedLogger = this.__dep(`sharedLogger`); 14 | 15 | // Skip if the bot has been disabled for this user. 16 | if (this.skipIfBotDisabled(`send misunderstood message`, recUser)) { 17 | return false; 18 | } 19 | 20 | // Last ditch attempt to match the user's input. 21 | if (this.options.enableNlp) { 22 | const matchedCommand = await this.__findMatchingCommandByIntent(message.text, false); 23 | 24 | if (matchedCommand) { 25 | await this.__executeCommand(matchedCommand, message, recUser); 26 | return true; 27 | } 28 | } 29 | 30 | if (this.options.misunderstoodFlowUri) { 31 | await this.executeFlow(this.options.misunderstoodFlowUri, recUser, message); 32 | return true; 33 | } 34 | else if (this.options.misunderstoodText) { 35 | // Send the misunderstood text. 36 | const MessageObject = this.__dep(`MessageObject`); 37 | const misunderstoodMessage = MessageObject.outgoing(recUser, { 38 | text: this.options.misunderstoodText, 39 | options: this.options.misunderstoodOptions, 40 | }); 41 | await this.sendMessage(recUser, misunderstoodMessage); 42 | // Misunderstood message sent. 43 | return true; 44 | } 45 | else { // We failed to match any commands AND there's no misunderstood text so we just ignore this situation. 46 | sharedLogger.verbose(`The misunderstood text is not configured so we won't send anything to the user.`); 47 | return false; 48 | } 49 | 50 | } 51 | 52 | /* 53 | * Export. 54 | */ 55 | module.exports = { 56 | sendMisunderstoodMessage, 57 | }; 58 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | networks: 4 | 5 | eyewitness-network: 6 | driver: bridge 7 | 8 | volumes: 9 | 10 | mongodb-data: 11 | driver: local 12 | 13 | services: 14 | 15 | bot: 16 | build: 17 | context: . 18 | dockerfile: Dockerfile.development 19 | environment: 20 | - NODE_ENV=development 21 | - TZ=Europe/London 22 | - ENTRY_POINT=bot 23 | volumes: 24 | - ./app:/src/app 25 | - ./lib:/src/lib 26 | - ./.env:/src/.env 27 | - ./package.json:/src/package.json 28 | - ./package-lock.json:/src/package-lock.json 29 | ports: 30 | - "5000:5000" 31 | networks: 32 | - eyewitness-network 33 | command: npm run start-development 34 | tty: true 35 | restart: on-failure 36 | 37 | read-server: 38 | build: 39 | context: . 40 | dockerfile: Dockerfile.development 41 | environment: 42 | - NODE_ENV=development 43 | - TZ=Europe/London 44 | - ENTRY_POINT=read-server 45 | volumes: 46 | - ./app:/src/app 47 | - ./lib:/src/lib 48 | - ./.env:/src/.env 49 | - ./package.json:/src/package.json 50 | - ./package-lock.json:/src/package-lock.json 51 | ports: 52 | - "5001:5001" 53 | networks: 54 | - eyewitness-network 55 | command: npm run start-development 56 | tty: true 57 | restart: on-failure 58 | 59 | mongodb: 60 | image: mongo:3.4 61 | networks: 62 | - eyewitness-network 63 | command: mongod 64 | volumes: 65 | - mongodb-data:/data/db 66 | restart: on-failure 67 | 68 | mongoclient: 69 | image: mongoclient/mongoclient:2.2.0 70 | environment: 71 | - MONGO_URL=mongodb://mongodb:27017 72 | - MONGOCLIENT_DEFAULT_CONNECTION_URL=mongodb://mongodb:27017/eyewitness 73 | networks: 74 | - eyewitness-network 75 | ports: 76 | - "3000:3000" 77 | restart: on-failure 78 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/middleware/createShellUser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * MIDDLEWARE: Create Shell User 5 | */ 6 | 7 | module.exports = function createShellUserMiddleware (database, sharedLogger, triggerEvent, hippocampOptions) { 8 | 9 | // The actual middleware. 10 | return async (message, adapter, _recUser, next/* , stop */) => { 11 | 12 | // We don't want to create a new shell user if we already have a user record. 13 | if (_recUser) { 14 | 15 | let recUser; 16 | 17 | // If the user record exists but has been wiped we are allowed to update the reference. 18 | if (!_recUser.profile.lastUpdated && message.referral) { 19 | const dbChanges = { 'profile.referral': message.referral }; 20 | 21 | // If profile refreshing is disabled the "lastUpdated" flag will never get updated, so we update it here. 22 | if (!hippocampOptions.enableUserProfile) { dbChanges[`profile.lastUpdated`] = Date.now(); } 23 | 24 | recUser = await database.update(`User`, _recUser, dbChanges); 25 | } 26 | 27 | else { 28 | recUser = _recUser; 29 | } 30 | 31 | // Otherwise we just need to skip creating a new shell user. 32 | return next(null, recUser); 33 | 34 | } 35 | 36 | // Insert a new shell user. 37 | const recUser = await database.insert(`User`, { 38 | channel: { 39 | name: message.channelName, 40 | userId: message.channelUserId, 41 | }, 42 | profile: { 43 | referral: message.referral || null, 44 | }, 45 | }); 46 | 47 | sharedLogger.debug({ 48 | text: `Running middleware "createShellUserMiddleware".`, 49 | direction: message.direction, 50 | message: message.text, 51 | userId: recUser._id.toString(), 52 | channelName: recUser.channel.name, 53 | channelUserId: recUser.channel.userId, 54 | }); 55 | 56 | // Trigger event listeners. 57 | await triggerEvent(`encountered-new-user`, { recUser }); 58 | 59 | return next(null, recUser); 60 | 61 | }; 62 | 63 | }; 64 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/middleware/saveMessage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * MIDDLEWARE: Save Message 5 | */ 6 | 7 | const extender = require(`object-extender`); 8 | 9 | module.exports = function saveMessageMiddleware ( 10 | database, sharedLogger, triggerEvent, workflowManager, hippocampOptions 11 | ) { 12 | 13 | // The actual middleware. 14 | return async (message, adapter, recUser, next/* , stop */) => { 15 | 16 | sharedLogger.debug({ 17 | text: `Running middleware "saveMessageMiddleware".`, 18 | direction: message.direction, 19 | message: message.text, 20 | userId: recUser._id.toString(), 21 | channelName: recUser.channel.name, 22 | channelUserId: recUser.channel.userId, 23 | }); 24 | 25 | // Only if we haven't disabled saving messages. 26 | if (hippocampOptions.saveMessagesToDatabase) { 27 | 28 | // Create a raw copy of the message to save into the database. 29 | const messageClone = extender.clone(message); 30 | 31 | // Don't store attachment data in the database. 32 | if (messageClone.attachments) { 33 | messageClone.attachments.forEach(attachment => (attachment.data ? delete attachment.data : void (0))); 34 | } 35 | 36 | // Insert the new message. 37 | const recMessage = await database.insert(`Message`, { 38 | _user: recUser._id, 39 | direction: message.direction, 40 | sentAt: message.sentAt, 41 | sendDelay: message.sendDelay, 42 | humanToHuman: (recUser.bot && recUser.bot.disabled), 43 | batchable: message.batchable, 44 | data: messageClone, 45 | }); 46 | 47 | // Add the record's message ID to the message object so we can use it later if needed. 48 | message.messageId = recMessage._id.toString(); 49 | 50 | } 51 | 52 | // Update the last conversation timestamps on the user record. 53 | let timestampProperty; 54 | 55 | switch (message.direction) { 56 | case `incoming`: timestampProperty = `lastMessageReceivedAt`; break; 57 | case `outgoing`: timestampProperty = `lastMessageSentAt`; break; 58 | default: timestampProperty = null; break; 59 | } 60 | 61 | if (timestampProperty) { 62 | await database.update(`User`, recUser, { 63 | [`conversation.${timestampProperty}`]: Date.now(), 64 | }); 65 | } 66 | 67 | // Track the message via the analytics handler. 68 | if (hippocampOptions.enableMessageTracking) { 69 | await workflowManager.trackMessage(recUser, message); 70 | } 71 | 72 | // Trigger event listeners. 73 | await triggerEvent(`new-${message.direction}-message`, { recUser, message }); 74 | 75 | return next(null, recUser); 76 | 77 | }; 78 | 79 | }; 80 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/models.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * WORKFLOW: MODELS 5 | * Functions for dealing with database models. 6 | */ 7 | 8 | const path = require(`path`); 9 | 10 | /* 11 | * Load and all the models in the top-level models directory (not its subdirectories) and add to the database handler. 12 | */ 13 | async function __loadModels (directory, extensionsDirectory) { 14 | 15 | const sharedLogger = this.__dep(`sharedLogger`); 16 | const database = this.__dep(`database`); 17 | 18 | sharedLogger.debug(`Loading default and app models...`); 19 | 20 | const defaultModelsDirectory = path.join(__dirname, `../models`); 21 | const defaultModelFiles = await this.discoverFiles(defaultModelsDirectory, `js`, false); 22 | const appModelFiles = (directory ? await this.discoverFiles(directory, `js`, false) : []); 23 | const allModelFiles = defaultModelFiles.concat(...appModelFiles); 24 | const extensionFiles = (extensionsDirectory ? await this.discoverFiles(extensionsDirectory, `js`, false) : []); 25 | 26 | // Load all the model extension files. 27 | const extensionsMap = {}; 28 | for (const extensionFile of extensionFiles) { 29 | extensionsMap[this.parseFilename(extensionFile, `js`)] = require(extensionFile); // eslint-disable-line global-require 30 | } 31 | 32 | // Load all of the model files in turn. 33 | for (const modelFile of allModelFiles) { 34 | const Model = require(modelFile); // eslint-disable-line global-require 35 | const extensionOptions = extensionsMap[this.parseFilename(modelFile, `js`)]; 36 | database.addModel(Model, extensionOptions); 37 | } 38 | 39 | sharedLogger.debug(`${defaultModelFiles.length} default and ${appModelFiles.length} app models loaded.`); 40 | 41 | } 42 | 43 | /* 44 | * Returns only the default models without adding them to the database. 45 | */ 46 | async function getDefaultModelsByKey () { 47 | 48 | const defaultModelsDirectory = path.join(__dirname, `../models`); 49 | const defaultModelFiles = await this.discoverFiles(defaultModelsDirectory, `js`, false); 50 | const modelsByKey = []; 51 | 52 | // Load all of the model files in turn. 53 | for (const modelFile of defaultModelFiles) { 54 | modelsByKey[this.parseFilename(modelFile, `js`)] = require(modelFile); // eslint-disable-line global-require 55 | } 56 | 57 | return modelsByKey; 58 | 59 | } 60 | 61 | /* 62 | * Returns only the default models without adding them to the database. 63 | */ 64 | async function getDefaultModels () { 65 | return Object.values(await this.getDefaultModelsByKey()); 66 | } 67 | 68 | /* 69 | * Export. 70 | */ 71 | module.exports = { 72 | __loadModels, 73 | getDefaultModelsByKey, 74 | getDefaultModels, 75 | }; 76 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/periodic/profileFetcher.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * PERIODIC HANDLER: PROFILE FETCHER 5 | * 6 | * Fetches user profiles on a periodic basis. 7 | * Notifies the scheduler if a user's timezone changes. 8 | * User profiles may updated from elsewhere so this configures an event listener. 9 | * 10 | */ 11 | 12 | const PeriodicBase = require(`./periodicBase`); 13 | const { refreshAllProfiles } = require(`../../modules/userProfileRefresher`); 14 | 15 | module.exports = class ProfileFetcher extends PeriodicBase { 16 | 17 | /* 18 | * Instantiates the handler. 19 | */ 20 | constructor (_options) { 21 | 22 | // Configure the handler. 23 | super(`periodic`, `profile-fetcher`, _options); 24 | 25 | this.options.refreshUsersEvery = _options.refreshUsersEvery || `30 minutes`; 26 | this.options.whitelistProfileFields = _options.whitelistProfileFields; 27 | 28 | } 29 | 30 | /* 31 | * Sets up a listener to watch for user profile updates 32 | */ 33 | async __startPeriodic () { 34 | const sharedLogger = this.__dep(`sharedLogger`); 35 | const scheduler = this.__dep(`scheduler`); 36 | const hippocamp = this.__dep(`hippocamp`); 37 | 38 | hippocamp.on(`refreshed-user-profile`, this.__updatedTimezoneListener(scheduler, sharedLogger)); 39 | 40 | } 41 | 42 | /** 43 | * Gets an event listener for `refreshed-user-profile`, to check if timezone changed. 44 | * If timezone changed, tell the scheduler to reschedule all tasks. 45 | */ 46 | __updatedTimezoneListener (scheduler, sharedLogger) { 47 | return async (eventData) => { 48 | const recUser = eventData.recUser; 49 | const oldTimezoneUtcOffset = eventData.oldProfile.timezoneUtcOffset; 50 | const newTimezoneUtcOffset = recUser.profile.timezoneUtcOffset; 51 | 52 | sharedLogger.verbose(`Checking for timezone changes for user ${recUser._id}: 53 | ${oldTimezoneUtcOffset} => ${newTimezoneUtcOffset}`); 54 | 55 | if (oldTimezoneUtcOffset !== newTimezoneUtcOffset) { 56 | await scheduler.rescheduleAllTasksForUser(recUser._id, newTimezoneUtcOffset); 57 | } 58 | }; 59 | } 60 | 61 | /* 62 | * Fetches user profiles from chatbot platform and replaces them in the DB. 63 | */ 64 | async __executePeriodic () { 65 | 66 | const sharedLogger = this.__dep(`sharedLogger`); 67 | const database = this.__dep(`database`); 68 | const hippocamp = this.__dep(`hippocamp`); 69 | const adapter = this.__dep(`adapter`); 70 | 71 | sharedLogger.verbose(`Fetching user profiles`); 72 | 73 | const triggerEvent = hippocamp.triggerEvent.bind(hippocamp); 74 | 75 | refreshAllProfiles(adapter, database, sharedLogger, triggerEvent, { 76 | refreshEvery: this.options.refreshUsersEvery, 77 | whitelistProfileFields: this.options.whitelistProfileFields, 78 | }); 79 | 80 | } 81 | 82 | }; 83 | -------------------------------------------------------------------------------- /lib/hippocamp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@atchai/hippocamp", 3 | "version": "0.11.1", 4 | "description": "Chatbot conversation engine.", 5 | "keywords": [ 6 | "chat", 7 | "bot", 8 | "chatbot", 9 | "convo", 10 | "conversation" 11 | ], 12 | "main": "lib/hippocamp.js", 13 | "scripts": { 14 | "start-example-app": "npm install && npm update -S && ./node_modules/.bin/nodemon --ext js,json,html,css ./examples/one/app/app.js", 15 | "ngrok": "ngrok http 5000 --region eu -subdomain=hippocamp", 16 | "test": "npm run test-unit && npm run test-functional", 17 | "test-functional": "npm run test-workflow && npm run test-storage && npm run test-restarted && npm run test-scheduler && npm run test-profile", 18 | "test-workflow": "mocha ./tests/functional/workflowBot.js", 19 | "test-storage": "mocha ./tests/functional/storageBot.js", 20 | "test-restarted": "mocha --exit ./tests/functional/restartedBot.js", 21 | "test-scheduler": "mocha ./tests/functional/schedulerBot.js", 22 | "test-profile": "mocha ./tests/functional/profileBot.js", 23 | "test-unit": "mocha ./tests/unit/*.js" 24 | }, 25 | "author": "Josh Cole (http://www.JoshuaCole.me)", 26 | "contributors": [ 27 | { 28 | "name": "Josh Cole", 29 | "email": "hello@recombix.com" 30 | }, 31 | { 32 | "name": "Justin Emery", 33 | "email": "justin@recombix.com" 34 | } 35 | ], 36 | "dependencies": { 37 | "analytics-node": "^3.3.0", 38 | "chance": "^1.0.16", 39 | "cli-color": "^1.2.0", 40 | "dashbot": "^9.9.2", 41 | "deep-property": "^1.1.0", 42 | "deep-sort": "^0.1.2", 43 | "escape-regexp": "^0.0.1", 44 | "form-data": "^2.3.2", 45 | "handlebars": "^4.0.11", 46 | "html-minifier": "^3.5.19", 47 | "jsome": "^2.5.0", 48 | "longjohn": "^0.2.12", 49 | "middleware-engine": "^0.1.1", 50 | "mime": "^2.3.1", 51 | "mockgoose": "^7.3.5", 52 | "moment": "^2.22.2", 53 | "mongoose": "^5.2.5", 54 | "object-extender": "^2.0.3", 55 | "request-ninja": "^0.3.2", 56 | "safe-eval": "^0.3.0", 57 | "semver": "^5.5.0", 58 | "shortid": "^2.2.12" 59 | }, 60 | "devDependencies": { 61 | "chai": "^4.1.2", 62 | "eslint": "^4.19.0", 63 | "eslint-config-recombix": "latest", 64 | "eslint-config-vue": "latest", 65 | "eslint-plugin-disable": "^1.0.2", 66 | "eslint-plugin-filenames": "^1.3.2", 67 | "eslint-plugin-html": "^4.0.5", 68 | "eslint-plugin-json": "latest", 69 | "eslint-plugin-node": "^7.0.1", 70 | "eslint-plugin-promise": "latest", 71 | "eslint-plugin-vue": "^4.7.1", 72 | "mocha": "^5.2.0", 73 | "ngrok": "^2.3.0", 74 | "nodemon": "^1.18.3" 75 | }, 76 | "engines": { 77 | "node": ">=10", 78 | "npm": ">=6" 79 | }, 80 | "license": "MIT" 81 | } 82 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/tracking.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * WORKFLOW: TRACKING 5 | * Functions for tracking users, messages, events, etc. 6 | */ 7 | 8 | /* 9 | * Allows a user to be tracked via the enabled analytics handler. 10 | */ 11 | async function trackUser (recUser, extraTraits = {}) { 12 | 13 | const sharedLogger = this.__dep(`sharedLogger`); 14 | 15 | // Is user tracking allowed? 16 | if (!this.options.enableUserTracking) { 17 | sharedLogger.debug(`Skipping tracking user because "enableUserTracking" is disabled.`); 18 | return false; 19 | } 20 | 21 | const analytics = this.__dep(`analytics`); 22 | 23 | if (!Object.keys(analytics).length) { 24 | throw new Error(`Cannot track users because an analytics handler has not been configured.`); 25 | } 26 | 27 | const trackPromises = Object.values(analytics).map( 28 | handler => handler.trackUser(recUser, extraTraits) 29 | ); 30 | 31 | await Promise.all(trackPromises); 32 | return true; 33 | 34 | } 35 | 36 | /* 37 | * Allows an event to be tracked via the enabled analytics handler. 38 | */ 39 | async function trackEvent (recUser, eventName, eventData) { 40 | 41 | const sharedLogger = this.__dep(`sharedLogger`); 42 | 43 | // Is event tracking allowed? 44 | if (!this.options.enableEventTracking) { 45 | sharedLogger.debug(`Skipping tracking event because "enableEventTracking" is disabled.`); 46 | return false; 47 | } 48 | 49 | const analytics = this.__dep(`analytics`); 50 | 51 | if (!Object.keys(analytics).length) { 52 | throw new Error(`Cannot track events because an analytics handler has not been configured.`); 53 | } 54 | 55 | const trackPromises = Object.values(analytics).map( 56 | handler => handler.trackEvent(recUser, eventName, eventData) 57 | ); 58 | 59 | await Promise.all(trackPromises); 60 | return true; 61 | 62 | } 63 | 64 | /* 65 | * Allows a message to be tracked via the enabled analytics handler. 66 | */ 67 | async function trackMessage (recUser, message) { 68 | 69 | const sharedLogger = this.__dep(`sharedLogger`); 70 | 71 | // Is message tracking allowed? 72 | if (!this.options.enableMessageTracking) { 73 | sharedLogger.debug(`Skipping tracking user because "enableMessageTracking" is disabled.`); 74 | return false; 75 | } 76 | 77 | const analytics = this.__dep(`analytics`); 78 | 79 | if (!Object.keys(analytics).length) { 80 | throw new Error(`Cannot track messages because an analytics handler has not been configured.`); 81 | } 82 | 83 | const trackPromises = Object.values(analytics).map( 84 | handler => handler.trackMessage(recUser, message) 85 | ); 86 | 87 | await Promise.all(trackPromises); 88 | return true; 89 | 90 | } 91 | 92 | /* 93 | * Export. 94 | */ 95 | module.exports = { 96 | trackUser, 97 | trackEvent, 98 | trackMessage, 99 | }; 100 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/analytics/segment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * ANALYTICS: Segment 5 | */ 6 | 7 | const Analytics = require(`analytics-node`); // Segment. 8 | const moment = require(`moment`); 9 | const extender = require(`object-extender`); 10 | const AnalyticsBase = require(`./analyticsBase`); 11 | 12 | module.exports = class AnalyticsSegment extends AnalyticsBase { 13 | 14 | /* 15 | * Instantiates the handler. 16 | */ 17 | constructor (_options) { 18 | 19 | // Configure the handler. 20 | super(`analytics`, `segment`); 21 | 22 | // Default config for this handler. 23 | this.options = extender.defaults({ 24 | writeKey: null, 25 | isDisabled: false, 26 | }, _options); 27 | 28 | this.isDisabled = this.options.isDisabled; 29 | 30 | // Instantiate Segment. 31 | this.analytics = new Analytics(this.options.writeKey); 32 | 33 | } 34 | 35 | /* 36 | * Push the user data into Segment. 37 | */ 38 | trackUser (recUser, extraTraits = null) { 39 | 40 | if (this.checkIfDisabled()) { return; } 41 | 42 | const appData = recUser.appData || {}; 43 | const profile = recUser.profile || {}; 44 | const firstName = appData.firstName || profile.firstName || ``; 45 | const lastName = appData.lastName || profile.lastName || ``; 46 | const dateCreated = appData.created || profile.created || null; 47 | const dateLastUpdated = appData.lastUpdated || profile.lastUpdated || null; 48 | 49 | const defaultTraits = { 50 | firstName: firstName || void (0), 51 | lastName: lastName || void (0), 52 | name: `${firstName} ${lastName}`.trim() || recUser._id.toString(), 53 | gender: appData.gender || profile.gender || void (0), 54 | email: appData.email || profile.email || void (0), 55 | phone: appData.tel || profile.tel || void (0), 56 | age: appData.age || profile.age || void (0), 57 | }; 58 | 59 | const readOnlyTraits = { 60 | created: (dateCreated ? moment(dateCreated).toISOString() : void (0)), 61 | lastUpdated: (dateLastUpdated ? moment(dateLastUpdated).toISOString() : void (0)), 62 | }; 63 | 64 | const traits = extender.defaults(defaultTraits, extraTraits, readOnlyTraits); 65 | 66 | // Make the request. 67 | this.analytics.identify({ 68 | userId: recUser._id.toString(), 69 | traits, 70 | }); 71 | 72 | } 73 | 74 | /* 75 | * Push the event data into Segment. 76 | */ 77 | trackEvent (recUser, eventName, eventData) { 78 | 79 | if (this.checkIfDisabled()) { return; } 80 | 81 | this.analytics.track({ 82 | userId: recUser._id.toString(), 83 | event: eventName, 84 | properties: eventData || {}, 85 | }); 86 | 87 | } 88 | 89 | /* 90 | * Push the page view data into Segment. 91 | */ 92 | trackPageView (recUser, url, title = ``, _properties = {}) { 93 | 94 | if (this.checkIfDisabled()) { return; } 95 | 96 | const userId = recUser._id.toString(); 97 | 98 | this.analytics.page({ 99 | userId, 100 | name: title, 101 | title, 102 | url, 103 | properties: { 104 | ..._properties, 105 | userId, 106 | name: title, 107 | title, 108 | url, 109 | }, 110 | }); 111 | 112 | } 113 | 114 | }; 115 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/matching.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * WORKFLOW: MATCHING 5 | * Functions for dealing with matching user input against a dictionary of matches. 6 | */ 7 | 8 | const path = require(`path`); 9 | const escapeRegExp = require(`escape-regexp`); 10 | 11 | /* 12 | * Returns the given match file by its name. 13 | */ 14 | function __getMatchFile (matchFileName) { 15 | return this.matchFiles[(matchFileName || ``).toLowerCase()] || null; 16 | } 17 | 18 | /* 19 | * Load and cache all the match files in the top-level matches directory (not its subdirectories). 20 | */ 21 | async function __loadMatchFiles (directory) { 22 | 23 | const sharedLogger = this.__dep(`sharedLogger`); 24 | 25 | sharedLogger.debug(`Loading default and app match files...`); 26 | 27 | const defaultMatchesDirectory = path.join(__dirname, `../matches`); 28 | const defaultMatchFiles = await this.discoverFiles(defaultMatchesDirectory, `json`, false); 29 | const appMatchFiles = (directory ? await this.discoverFiles(directory, `json`, false) : []); 30 | const allMatchFiles = defaultMatchFiles.concat(...appMatchFiles); 31 | 32 | // Load all of the match files in turn. 33 | for (const matchFile of allMatchFiles) { 34 | const matchName = this.parseFilename(matchFile, `json`); 35 | 36 | if (this.__getMatchFile(matchName)) { 37 | throw new Error(`A match file with the name "${matchName}" has already been defined.`); 38 | } 39 | 40 | this.matchFiles[matchName] = { 41 | matchName, 42 | definition: await this.parseJsonFile(matchFile), // eslint-disable-line no-await-in-loop 43 | }; 44 | } 45 | 46 | sharedLogger.debug(`${defaultMatchFiles.length} default and ${appMatchFiles.length} app match files loaded.`); 47 | 48 | } 49 | 50 | /* 51 | * Returns true if one of the given match values matches the given message text. 52 | */ 53 | async function doesTextMatch (matches, text) { 54 | 55 | // Check each of the matches. 56 | for (const matchValue in matches) { 57 | if (!matches.hasOwnProperty(matchValue)) { continue; } 58 | 59 | const matchType = matches[matchValue]; 60 | 61 | switch (matchType) { 62 | 63 | // If the value is falsy we just skip this match. 64 | case false: 65 | case null: 66 | case ``: 67 | continue; 68 | 69 | // Escape the regexp and match against it. 70 | case `regexp`: 71 | if (text.match(new RegExp(matchValue, `i`))) { return true; } 72 | break; 73 | 74 | // Load the value list file and match against it recursively. 75 | case `match-file`: { 76 | const matchFile = this.__getMatchFile(matchValue); 77 | if (!matchFile) { throw new Error(`Match file "${matchValue}" is not defined.`); } 78 | if (await doesTextMatch(matchFile.definition, text)) { return true; } // eslint-disable-line no-await-in-loop 79 | break; 80 | } 81 | 82 | // An other types are considered to be string matches. 83 | default: 84 | case `string`: 85 | if (text.match(new RegExp(`^${escapeRegExp(matchValue)}$`, `i`))) { return true; } 86 | break; 87 | 88 | } 89 | } 90 | 91 | // No matches for this message. 92 | return false; 93 | 94 | } 95 | 96 | /* 97 | * Export. 98 | */ 99 | module.exports = { 100 | __getMatchFile, 101 | __loadMatchFiles, 102 | doesTextMatch, 103 | }; 104 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/loggers/loggerBase.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * LOGGER BASE 5 | */ 6 | 7 | const moment = require(`moment`); 8 | const extender = require(`object-extender`); 9 | const HandlerBase = require(`../handlerBase`); 10 | 11 | module.exports = class LoggerBase extends HandlerBase { 12 | 13 | /* 14 | * Returns the integer that corresponds to the log level string. 15 | */ 16 | __getLogLevelInteger (logLevelString) { 17 | 18 | switch (logLevelString) { 19 | case `fatal`: return 0; 20 | case `error`: return 1; 21 | case `warn`: return 2; 22 | case `info`: return 3; 23 | case `debug`: return 4; 24 | case `verbose`: return 5; 25 | case `silly`: return 6; 26 | default: throw new Error(`Invalid log level string.`); 27 | } 28 | 29 | } 30 | 31 | /* 32 | * Returns true if we are allowed to log at the configred log level. 33 | */ 34 | __isLoggableAt (logLevelString) { 35 | const checkLogLevel = this.__getLogLevelInteger(logLevelString); 36 | const configuredLogLevel = this.__getLogLevelInteger(this.options.logLevel); 37 | return Boolean(checkLogLevel <= configuredLogLevel); 38 | } 39 | 40 | /* 41 | * Ensure all log data is in the same format. 42 | */ 43 | __prepareLogData (level, data) { 44 | 45 | const logData = { 46 | text: null, 47 | data: {}, 48 | error: null, 49 | }; 50 | 51 | if (data instanceof Error) { 52 | logData.error = { 53 | name: data.name, 54 | code: data.code || data.errorId || null, 55 | message: data.message, 56 | extraData: data.extraData || null, 57 | stack: data.stack.split(/\n/g), 58 | }; 59 | } 60 | else if (typeof data === `string`) { 61 | logData.text = data; 62 | } 63 | else if (typeof data === `object`) { 64 | if (data.text) { 65 | logData.text = data.text; 66 | delete data.text; 67 | } 68 | logData.data = new Object(data); 69 | } 70 | else { 71 | throw new Error(`The log data is an invalid type.`); 72 | } 73 | 74 | // Remove stuff that clogs up the logs. 75 | if (!logData.text) { delete logData.text; } 76 | if (!logData.data || !Object.keys(logData.data).length) { delete logData.data; } 77 | if (!logData.error) { delete logData.error; } 78 | 79 | return extender.merge(logData, { 80 | level, 81 | timestamp: moment.utc().toISOString(), 82 | }); 83 | 84 | } 85 | 86 | fatal (...args) { 87 | if (!this.__isLoggableAt(`fatal`)) { return Promise.resolve(); } 88 | return this.__writeErr(`fatal`, ...args); 89 | } 90 | 91 | error (...args) { 92 | if (!this.__isLoggableAt(`error`)) { return Promise.resolve(); } 93 | return this.__writeErr(`error`, ...args); 94 | } 95 | 96 | warn (...args) { 97 | if (!this.__isLoggableAt(`warn`)) { return Promise.resolve(); } 98 | return this.__writeErr(`warn`, ...args); 99 | } 100 | 101 | info (...args) { 102 | if (!this.__isLoggableAt(`info`)) { return Promise.resolve(); } 103 | return this.__writeLog(`info`, ...args); 104 | } 105 | 106 | debug (...args) { 107 | if (!this.__isLoggableAt(`debug`)) { return Promise.resolve(); } 108 | return this.__writeLog(`debug`, ...args); 109 | } 110 | 111 | verbose (...args) { 112 | if (!this.__isLoggableAt(`verbose`)) { return Promise.resolve(); } 113 | return this.__writeLog(`verbose`, ...args); 114 | } 115 | 116 | silly (...args) { 117 | if (!this.__isLoggableAt(`silly`)) { return Promise.resolve(); } 118 | return this.__writeLog(`silly`, ...args); 119 | } 120 | 121 | }; 122 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/periodic/periodicBase.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * PERIODIC BASE. 5 | * 6 | * A periodic handler represents some task that needs to run on a periodically, such 7 | * as fetching an external API. 8 | * 9 | * Any sub-class must override __startPeriodic and __executePeriodic 10 | */ 11 | 12 | const extender = require(`object-extender`); 13 | const moment = require(`moment`); 14 | const HandlerBase = require(`../handlerBase`); 15 | const utilities = require(`../../modules/utilities`); 16 | 17 | module.exports = class PeriodicBase extends HandlerBase { 18 | 19 | /* 20 | * Initialises a new periodic task handler. 21 | */ 22 | constructor (type, handlerId, _options) { 23 | super(type, handlerId); 24 | 25 | // Default options. 26 | this.options = extender.defaults({ 27 | executeEvery: `1 minutes`, 28 | isDisabled: false, 29 | adapterId: `facebook`, 30 | }, _options); 31 | 32 | this.isDisabled = this.options.isDisabled; 33 | 34 | // Split out the different elements from the "execute every" string. 35 | const { frequency, units } = utilities.parseFrequencyString(this.options.executeEvery); 36 | if (!frequency || !units) { throw new Error(`Invalid execution frequency "${this.options.executeEvery}".`); } 37 | 38 | this.executionFrequency = frequency; 39 | this.executionUnits = units; 40 | 41 | } 42 | 43 | /* 44 | * Start the periodic task. 45 | */ 46 | async start () { 47 | 48 | const sharedLogger = this.__dep(`sharedLogger`); 49 | 50 | // Start immediately. 51 | sharedLogger.debug(`Started periodic handler: ${this.getHandlerId()}`); 52 | 53 | // __startPeriodic must be implemented by the sub-class 54 | this.__startPeriodic(); 55 | 56 | this.__enqueueNextRun(); 57 | 58 | } 59 | 60 | /** 61 | * Sub-class must override this. Runs once when the periodic handler is configured. 62 | */ 63 | async __startPeriodic () { 64 | throw new Error(`__startPeriodic must be overridden.`); 65 | } 66 | 67 | /* 68 | * Sets up the scheduler to run after a timeout, or immediately. 69 | */ 70 | __enqueueNextRun (immediate = false) { 71 | 72 | // Are we running the periodic task immediately? 73 | if (immediate) { 74 | this.__execute(); 75 | } 76 | else { 77 | // Get the number of milliseconds until the next run of the periodic. 78 | const nextExecutionTime = moment.utc().add(this.executionFrequency, this.executionUnits); 79 | const timeoutMilliseconds = nextExecutionTime.diff(moment.utc()); 80 | 81 | // Add a new timeout. 82 | if (this.timeoutId) { clearTimeout(this.timeoutId); } 83 | this.timeoutId = setTimeout(this.__execute.bind(this), timeoutMilliseconds); 84 | } 85 | } 86 | 87 | /* 88 | * Run the periodic task. 89 | */ 90 | async __execute () { 91 | /* eslint no-await-in-loop: 0 */ 92 | 93 | if (this.checkIfDisabled()) { return; } 94 | 95 | const sharedLogger = this.__dep(`sharedLogger`); 96 | 97 | sharedLogger.verbose(`Executing periodic handler task: ${this.getHandlerId()}.`); 98 | 99 | // __executePeriodic must be implemented by the sub-class 100 | await this.__executePeriodic(); 101 | 102 | this.__enqueueNextRun(); 103 | 104 | } 105 | 106 | /** 107 | * Sub-class must override this. This defines to task to be run periodically. 108 | */ 109 | async __executePeriodic () { 110 | throw new Error(`__executePeriodic must be overridden.`); 111 | } 112 | 113 | getAdapterId () { 114 | return this.options.adapterId; 115 | } 116 | 117 | }; 118 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/middleware/trackifyLinks.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * MIDDLEWARE: Trackify Links 5 | */ 6 | 7 | /* 8 | * Removes any trailing slashes from the end of the given URL. 9 | */ 10 | function trimTrailingSlashes (url) { 11 | return (url || ``).trim().replace(/\/+$/, ``); 12 | } 13 | 14 | /* 15 | * Returns a trackable link. 16 | */ 17 | function generateTrackableLink (hippocampOptions, message, originalUrl, { linkType, linkIndex1, linkIndex2 }) { 18 | 19 | const { linkTracking, baseUrl } = hippocampOptions; 20 | const serverBaseUrl = trimTrailingSlashes(linkTracking.serverUrl) || `${trimTrailingSlashes(baseUrl)}/track-link`; 21 | const encodedUrl = encodeURIComponent(originalUrl); 22 | const queryParams = [ `originalUrl=${encodedUrl}` ]; 23 | 24 | // Can we add the message details? 25 | if (message.messageId && linkType && typeof linkIndex1 !== `undefined`) { 26 | queryParams.push(`messageId=${message.messageId}`, `linkType=${linkType}`, `linkIndex1=${linkIndex1}`); 27 | if (linkIndex2) { queryParams.push(`linkIndex2=${linkIndex2}`); } 28 | } 29 | 30 | const trackableUrl = `${serverBaseUrl}/?${queryParams.join(`&`)}`; 31 | 32 | return trackableUrl; 33 | 34 | } 35 | 36 | /* 37 | * Updates button payloads with trackable links. 38 | */ 39 | function updateButton (hippocampOptions, message, button, linkMeta) { 40 | 41 | if (button.type === `url`) { 42 | button.payload = generateTrackableLink(hippocampOptions, message, button.payload, linkMeta); 43 | } 44 | 45 | return button; 46 | 47 | } 48 | 49 | /* 50 | * Updates carousel payloads with trackable links. 51 | */ 52 | function updateCarousel (updateButtonForMessage, element, linkIndex1) { 53 | 54 | if (element.defaultAction) { 55 | element.defaultAction = updateButtonForMessage(element.defaultAction, { 56 | linkType: `carousel-default`, 57 | linkIndex1, 58 | }); 59 | } 60 | 61 | element.buttons = element.buttons.map((button, linkIndex2) => 62 | updateButtonForMessage(button, { 63 | linkType: `carousel-button`, 64 | linkIndex1, 65 | linkIndex2, 66 | }) 67 | ); 68 | 69 | return element; 70 | 71 | } 72 | 73 | /* 74 | * The middleware itself. 75 | */ 76 | module.exports = function trackifyLinksMiddleware ( 77 | sharedLogger, hippocampOptions 78 | ) { 79 | 80 | // The actual middleware. 81 | return async (message, adapter, recUser, next/* , stop */) => { 82 | 83 | sharedLogger.debug({ 84 | text: `Running middleware "trackifyLinksMiddleware".`, 85 | direction: message.direction, 86 | message: message.text, 87 | userId: recUser._id.toString(), 88 | channelName: recUser.channel.name, 89 | channelUserId: recUser.channel.userId, 90 | }); 91 | 92 | // Skip if link tracking is not enabled. 93 | if (!hippocampOptions.linkTracking || !hippocampOptions.linkTracking.enabled) { 94 | return next(null, recUser); 95 | } 96 | 97 | const updateButtonForMessage = updateButton.bind(null, hippocampOptions, message); 98 | const updateCarouselForMessage = updateCarousel.bind(null, updateButtonForMessage); 99 | 100 | // Buttons. 101 | if (message.buttons) { 102 | message.buttons = message.buttons.map((button, linkIndex1) => 103 | updateButtonForMessage(button, { 104 | linkType: `button`, 105 | linkIndex1, 106 | }) 107 | ); 108 | } 109 | 110 | // Carousels. 111 | if (message.carousel) { 112 | message.carousel.elements = message.carousel.elements.map(updateCarouselForMessage); 113 | } 114 | 115 | return next(null, recUser); 116 | 117 | }; 118 | 119 | }; 120 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/loggers/terminal.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * LOGGER: Terminal 5 | */ 6 | 7 | const clc = require(`cli-color`); 8 | const jsome = require(`jsome`); 9 | const moment = require(`moment`); 10 | const extender = require(`object-extender`); 11 | const LoggerBase = require(`./loggerBase`); 12 | 13 | module.exports = class LoggerTerminal extends LoggerBase { 14 | 15 | /* 16 | * Instantiates the handler. 17 | */ 18 | constructor (_options) { 19 | 20 | // Configure the handler. 21 | super(`logger`, `terminal`); 22 | 23 | // Default config for this handler. 24 | this.options = extender.defaults({ 25 | logLevel: `verbose`, 26 | pretty: true, 27 | colours: true, 28 | isDisabled: false, 29 | }, _options); 30 | 31 | this.isDisabled = this.options.isDisabled; 32 | 33 | // Pretty colours for each log level. 34 | this.colourDictionary = { 35 | fatal: clc.redBright, 36 | error: clc.red, 37 | warn: clc.xterm(208), 38 | info: clc.green, 39 | debug: clc.xterm(20), 40 | verbose: clc.cyan, 41 | silly: clc.magenta, 42 | }; 43 | 44 | // Setup for jsome module. 45 | if (!this.options.colours) { jsome.params.colored = false; } 46 | 47 | } 48 | 49 | /* 50 | * Writes the log data to the standard log channel. 51 | */ 52 | __writeLog (level, data) { 53 | if (this.checkIfDisabled()) { return; } 54 | return this.__write(process.stdout, level, data); 55 | } 56 | 57 | /* 58 | * Writes the log data to the error log channel. 59 | */ 60 | __writeErr (level, data) { 61 | if (this.checkIfDisabled()) { return; } 62 | return this.__write(process.stderr, level, data); 63 | } 64 | 65 | /* 66 | * Writes the log data to the given stream. Returns a promise so that we can wait for logs to be written before 67 | * continuing with execution if we need to. 68 | */ 69 | __write (stream, level, data) { 70 | 71 | if (this.checkIfDisabled()) { return; } 72 | 73 | return new Promise(resolve => { 74 | 75 | const preparedData = this.__prepareLogData(level, data); 76 | let output = ``; 77 | 78 | // The pretty option makes the terminal output easier to read for a human. 79 | if (this.options.pretty) { 80 | 81 | const timestamp = moment(preparedData.timestamp).format(`DD/MM/YYYY @ HH:mm:ss Z`); 82 | 83 | output += `\n`; 84 | 85 | if (this.options.colours) { 86 | const levelFormatted = this.colourDictionary[level].bold(`[${level}]`); 87 | output += this.colourDictionary[level](`${levelFormatted} ${timestamp}\n`); 88 | } 89 | else { 90 | output += `${level} ${timestamp}\n`; 91 | } 92 | 93 | if (preparedData.text) { output += `${preparedData.text}\n`; } 94 | 95 | if (preparedData.error) { 96 | const prettyStack = preparedData.error.stack.slice(1).join(`\n\t`); 97 | preparedData.error.stack = `\n\t${prettyStack}`; 98 | 99 | const errJson = jsome.getColoredString(preparedData.error); 100 | output += `Error: ${errJson}\n`; 101 | } 102 | 103 | if (preparedData.data && Object.keys(preparedData.data).length) { 104 | const dataJson = jsome.getColoredString(preparedData.data); 105 | output += `Data: ${dataJson}\n`; 106 | } 107 | 108 | } 109 | 110 | // Turning off the pretty option makes the output easier for a logging module to injest the data. 111 | else { 112 | const json = JSON.stringify(preparedData); 113 | output = `${json}\n`; 114 | } 115 | 116 | process.stdout.write(output, () => resolve(preparedData)); 117 | 118 | }); 119 | 120 | } 121 | 122 | }; 123 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/variables.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * WORKFLOW: VARIABLES 5 | * Functions for dealing with variables in templated strings. 6 | */ 7 | 8 | const deepProperty = require(`deep-property`); 9 | const escapeRegExp = require(`escape-regexp`); 10 | const extender = require(`object-extender`); 11 | const safeEval = require(`safe-eval`); 12 | 13 | /* 14 | * Match against a referenced variable in the input, if any. 15 | */ 16 | function __matchReferencedVariableName (input, preserveContext) { 17 | 18 | const refPattern = `<([a-z0-9_\\-.]+)>`; 19 | const refRegExp = new RegExp(preserveContext ? refPattern : `^${refPattern}$`, `i`); 20 | const [ , refVariableName ] = input.match(refRegExp) || []; 21 | 22 | return refVariableName || null; 23 | 24 | } 25 | 26 | /* 27 | * Evaluates referenced variables in a string and returns the output, and optional context. 28 | */ 29 | function evaluateReferencedVariables (input, variables, preserveContext = false) { 30 | 31 | let output = null; 32 | let context = {}; 33 | 34 | // If the input is not a string then there is no chance it contains referenced variable names. 35 | if (typeof input !== `string`) { return null; } 36 | 37 | // If a wildcard is present we return all the user input variables as-is. 38 | if (input === `*`) { return { output: variables.__userInput, context }; } 39 | 40 | // First check if we can match against a referenced variable in the input. 41 | const refVariableName = this.__matchReferencedVariableName(input, preserveContext); 42 | if (!refVariableName) { return null; } 43 | 44 | // Pull out the value of the referenced variable. 45 | const value = deepProperty.get(variables, refVariableName); 46 | 47 | // If preserving the context, substitute the variable name into the string and add the value to the context object. 48 | if (preserveContext) { 49 | const replaceRegExp = new RegExp(`<${escapeRegExp(refVariableName)}>`, `m`); 50 | output = input.replace(replaceRegExp, refVariableName); 51 | deepProperty.set(context, refVariableName, value); 52 | } 53 | 54 | // If not preserving the context, just return the value straight out. 55 | else { 56 | output = (typeof value === `undefined` ? null : value); 57 | } 58 | 59 | // Check there are no more referenced variables that need replacing. 60 | const result = this.evaluateReferencedVariables(output, variables, preserveContext); 61 | 62 | if (result) { 63 | output = result.output; 64 | context = extender.merge(context, result.context); 65 | } 66 | 67 | return { output, context }; 68 | 69 | } 70 | 71 | /* 72 | * Returns true if there is no conditional, or the conditional evaluates to a truthy value. 73 | */ 74 | function evaluateConditional (conditional, variables) { 75 | 76 | if (!conditional) { return true; } 77 | 78 | // Prepare the conditional by substituting variables. 79 | const evalResult = this.evaluateReferencedVariables(conditional, variables, true); 80 | if (!evalResult) { return false; } 81 | 82 | // Evaluate the conditional. 83 | let conditionalResult; 84 | 85 | try { 86 | conditionalResult = safeEval(evalResult.output, evalResult.context, { 87 | filename: `pseudo`, 88 | displayErrors: true, 89 | }); 90 | } 91 | catch (err) { 92 | throw new Error(`Failed to evaluate action conditional because of "${err}".`); 93 | } 94 | 95 | return Boolean(conditionalResult); 96 | 97 | } 98 | 99 | /* 100 | * Export. 101 | */ 102 | module.exports = { 103 | __matchReferencedVariableName, 104 | evaluateReferencedVariables, 105 | evaluateConditional, 106 | }; 107 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/databases/databaseBase.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * DATABASE BASE 5 | */ 6 | 7 | const HandlerBase = require(`../handlerBase`); 8 | 9 | module.exports = class DatabaseBase extends HandlerBase { 10 | 11 | /* 12 | * Initialises a new database handler. 13 | */ 14 | constructor (type, handlerId) { 15 | 16 | // Configure the handler. 17 | super(type, handlerId); 18 | 19 | this.models = {}; 20 | 21 | } 22 | 23 | /* 24 | * Returns true if the given model name already exists. 25 | */ 26 | hasModel (modelName) { 27 | return Boolean(this.models[modelName]); 28 | } 29 | 30 | /* 31 | * Returns the given model or throws an error if it doesn't exit. 32 | * The main reason to get a model is to use static convenience methods such as getAll() or getById(id). 33 | */ 34 | getModel (modelName) { 35 | 36 | const Model = this.models[modelName]; 37 | 38 | if (!Model) { 39 | throw new Error(`Model "${modelName}" has not been added to the "${this.getHandlerId()}" database handler.`); 40 | } 41 | 42 | return Model; 43 | 44 | } 45 | 46 | get (modelName, conditions, options = {}) { 47 | const sharedLogger = this.__dep(`sharedLogger`); 48 | sharedLogger.silly({ 49 | text: `Running "get" query via "${this.getHandlerId()}" database handler.`, 50 | modelName, 51 | conditions, 52 | options, 53 | }); 54 | } 55 | 56 | find (modelName, conditions, options = {}) { 57 | const sharedLogger = this.__dep(`sharedLogger`); 58 | sharedLogger.silly({ 59 | text: `Running "find" query via "${this.getHandlerId()}" database handler.`, 60 | modelName, 61 | conditions, 62 | options, 63 | }); 64 | } 65 | 66 | insert (modelName, properties) { 67 | const sharedLogger = this.__dep(`sharedLogger`); 68 | sharedLogger.silly({ 69 | text: `Running "insert" query via "${this.getHandlerId()}" database handler.`, 70 | modelName, 71 | properties, 72 | }); 73 | } 74 | 75 | update (modelName, input, changes, options) { 76 | const sharedLogger = this.__dep(`sharedLogger`); 77 | sharedLogger.silly({ 78 | text: `Running "update" query via "${this.getHandlerId()}" database handler.`, 79 | modelName, 80 | input, 81 | changes, 82 | options, 83 | }); 84 | } 85 | 86 | delete (modelName, input) { 87 | const sharedLogger = this.__dep(`sharedLogger`); 88 | sharedLogger.silly({ 89 | text: `Running "delete" query via "${this.getHandlerId()}" database handler.`, 90 | modelName, 91 | input, 92 | }); 93 | } 94 | 95 | deleteWhere (modelName, conditions) { 96 | const sharedLogger = this.__dep(`sharedLogger`); 97 | sharedLogger.silly({ 98 | text: `Running "deleteWhere" query via "${this.getHandlerId()}" database handler.`, 99 | modelName, 100 | conditions, 101 | }); 102 | } 103 | 104 | /* 105 | * Creates an error base on an error ID and a set of values to transpose into a predefined message. 106 | */ 107 | __error (errorId, values) { 108 | /* eslint max-len: 0 */ 109 | 110 | let message; 111 | 112 | switch (errorId) { 113 | case `EXISTING_MODEL`: message = `You have already added the "${values.modelName}" model.`; break; 114 | case `MOCK_FAILED`: message = `Failed to mock the database because of "${values.err}".`; break; 115 | case `CONNECT_FAILED`: message = `Failed to connect to the database because of "${values.err}".`; break; 116 | case `UPDATE_MISSING_DOCUMENT`: message = `Cannot update ${values.modelName} document "${values.documentID}" because it doesn't already exist in the database.`; break; 117 | default: throw new Error(`Invalid error ID.`); 118 | } 119 | 120 | return super.__error(errorId, message); 121 | 122 | } 123 | 124 | }; 125 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/webviews/compileWebview.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const htmlMinifier = require(`html-minifier`); 4 | const extender = require(`object-extender`); 5 | const utilities = require(`../../modules/utilities`); 6 | 7 | /* 8 | * Merge together all the sources of data. 9 | */ 10 | function createWebviewVariables (recUser, webview, flow) { 11 | 12 | const cancelButton = flow.definition.prompt.webview.cancelButton; 13 | const cancelButtonText = (typeof cancelButton === `undefined` ? `Cancel` : cancelButton); 14 | 15 | return extender.merge( 16 | this.options.messageVariables, 17 | webview.data, 18 | flow.definition.prompt.webview.data, 19 | recUser.profile, 20 | recUser.appData, 21 | { 22 | webviewBaseUrl: this.__generateWebviewBaseUrl(webview.webviewName, flow.uri, recUser), 23 | webviewCss: webview.css, 24 | webviewJs: webview.jss, 25 | styleName: flow.definition.prompt.webview.style || `default`, 26 | title: flow.definition.prompt.webview.title || `Webview`, 27 | instructions: flow.definition.prompt.webview.instructions || flow.definition.prompt.text, 28 | cancelButton: cancelButtonText, 29 | submitButton: flow.definition.prompt.webview.submitButton || `Save & Close`, 30 | } 31 | ); 32 | 33 | } 34 | 35 | /* 36 | * Compile the templates for the other values used in the webview. 37 | */ 38 | function compileVariableTemplates (templateVariables) { 39 | 40 | templateVariables.title = utilities.compileTemplate(templateVariables.title, templateVariables); 41 | templateVariables.instructions = utilities.compileTemplate(templateVariables.instructions, templateVariables); 42 | templateVariables.cancelButton = utilities.compileTemplate(templateVariables.cancelButton, templateVariables); 43 | templateVariables.submitButton = utilities.compileTemplate(templateVariables.submitButton, templateVariables); 44 | 45 | } 46 | 47 | /* 48 | * If there are any references in the variables (e.g. "") then replace with the correct values. 49 | */ 50 | function evaluateReferencesInVariables (templateVariables) { 51 | 52 | for (const key in templateVariables) { 53 | if (!templateVariables.hasOwnProperty(key)) { continue; } 54 | 55 | const string = templateVariables[key]; 56 | const evalResult = this.evaluateReferencedVariables(string, templateVariables, false); 57 | 58 | if (evalResult) { templateVariables[key] = evalResult.output; } 59 | } 60 | 61 | } 62 | 63 | /* 64 | * Compiles the webview template. 65 | */ 66 | function compileWebview (webview, flow, recUser) { 67 | 68 | // Prepare template variables. 69 | const templateVariables = this.createWebviewVariables(recUser, webview, flow); 70 | this.compileVariableTemplates(templateVariables); 71 | this.evaluateReferencesInVariables(templateVariables); 72 | 73 | // Compile the webview itself. 74 | templateVariables.webviewHtml = utilities.compileTemplate(webview.html, templateVariables); 75 | const html = utilities.compileTemplate(this.webviewLayoutHtml, templateVariables); 76 | 77 | // Minify the output. 78 | const output = htmlMinifier.minify(html, { 79 | collapseBooleanAttributes: true, 80 | collapseInlineTagWhitespace: true, 81 | collapseWhitespace: true, 82 | maxLineLength: 500, 83 | minifyCSS: true, 84 | minifyJS: true, 85 | removeAttributeQuotes: true, 86 | removeComments: true, 87 | removeEmptyAttributes: true, 88 | removeRedundantAttributes: true, 89 | removeScriptTypeAttributes: true, 90 | removeStyleLinkTypeAttributes: true, 91 | }); 92 | 93 | return output; 94 | 95 | } 96 | 97 | /* 98 | * Export. 99 | */ 100 | module.exports = { 101 | createWebviewVariables, 102 | compileVariableTemplates, 103 | evaluateReferencesInVariables, 104 | compileWebview, 105 | }; 106 | -------------------------------------------------------------------------------- /app/hooks/moreStories.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * HOOK: More Stories 5 | */ 6 | 7 | const config = require(`config-ninja`).use(`eyewitness-bot-config`); 8 | 9 | /* 10 | * Returns an array of all the articles the given user has not yet received, sorted newest first. 11 | */ 12 | async function getUnreceivedArticles (database, recUser, limit = 0) { 13 | 14 | const conditions = { 15 | _receivedByUsers: { $nin: [ recUser._id ] }, 16 | isPublished: { $ne: false }, 17 | }; 18 | const options = { 19 | sort: { articleDate: `desc` }, 20 | limit: limit || null, 21 | }; 22 | 23 | const recArticles = await database.find(`Article`, conditions, options); 24 | 25 | return (limit ? recArticles.slice(0, limit) : recArticles); 26 | 27 | } 28 | 29 | /* 30 | * Returns the message stating there are no more stories to read. 31 | */ 32 | function prepareNoArticlesMessage (MessageObject, recUser) { 33 | 34 | return new MessageObject({ 35 | direction: `outgoing`, 36 | channelName: recUser.channel.name, 37 | channelUserId: recUser.channel.userId, 38 | text: `Whoops! There are no more stories to read just yet!`, 39 | options: [{ 40 | label: `More stories`, 41 | }, { 42 | label: `Main menu`, 43 | }], 44 | }); 45 | 46 | } 47 | 48 | /* 49 | * Converts the given article into a carousel element. 50 | */ 51 | function prepareArticleElement (variables, recUser, recArticle) { 52 | 53 | const baseUrl = config.readServer.baseUrl; 54 | const articleId = recArticle._id; 55 | const feedId = recArticle.feedId; 56 | const userId = recUser._id; 57 | const readUrl = `${baseUrl}/${feedId}/${articleId}/${userId}`; 58 | 59 | return Object({ 60 | label: recArticle.title, 61 | text: recArticle.description, 62 | imageUrl: recArticle.imageUrl, 63 | buttons: [{ 64 | type: `url`, 65 | label: `Read`, 66 | payload: readUrl, 67 | sharing: true, 68 | }], 69 | }); 70 | 71 | } 72 | 73 | /* 74 | * Returns the message containing the prepared carousel element. 75 | */ 76 | function prepareCarouselMessage (MessageObject, variables, recUser, recArticles) { 77 | 78 | return new MessageObject({ 79 | direction: `outgoing`, 80 | channelName: recUser.channel.name, 81 | channelUserId: recUser.channel.userId, 82 | carousel: { 83 | sharing: true, 84 | elements: recArticles.map(recArticle => prepareArticleElement(variables, recUser, recArticle)), 85 | }, 86 | options: [{ 87 | label: `More stories`, 88 | }, { 89 | label: `Main menu`, 90 | }], 91 | }); 92 | 93 | } 94 | 95 | /* 96 | * Marks the given articles as received by the given user. 97 | */ 98 | async function markArticlesAsReceived (database, recUser, recArticles) { 99 | 100 | const markPromises = recArticles.map(recArticle => 101 | database.update(`Article`, recArticle, { $push: { _receivedByUsers: recUser._id } }) 102 | ); 103 | 104 | return await Promise.all(markPromises); 105 | 106 | } 107 | 108 | /* 109 | * The hook itself. 110 | */ 111 | module.exports = async function moreStories (action, variables, { database, MessageObject, recUser, sendMessage }) { 112 | 113 | const maxArticles = 5; 114 | const recArticles = await getUnreceivedArticles(database, recUser, maxArticles); 115 | 116 | // Stop here if we have no stories to send. 117 | if (!recArticles || !recArticles.length) { 118 | const noArticlesMessage = prepareNoArticlesMessage(MessageObject, recUser); 119 | await sendMessage(recUser, noArticlesMessage); 120 | return; 121 | } 122 | 123 | // Send the stories. 124 | const message = prepareCarouselMessage(MessageObject, variables, recUser, recArticles); 125 | await sendMessage(recUser, message); 126 | 127 | // Mark stories as recieved by the user. 128 | await markArticlesAsReceived(database, recUser, recArticles); 129 | 130 | }; 131 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/adapters/adapterBase.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * ADAPTER BASE 5 | */ 6 | 7 | const HandlerBase = require(`../handlerBase`); 8 | const utilities = require(`../../modules/utilities`); 9 | 10 | module.exports = class AdapterBase extends HandlerBase { 11 | 12 | /* 13 | * Instantiates a new adapter handler. 14 | */ 15 | constructor (type, handlerId) { 16 | super(type, handlerId, { chainMiddlewareResults: true }); 17 | } 18 | 19 | /* 20 | * If the message only contains the channelName and channelUserId properties we need to add the Hippocamp user ID to 21 | * it, and if it only contains the userId, we need to add channelName and channelUserId. Modifies the message object 22 | * in-place and returns the user record. 23 | */ 24 | async populateOppositeUserId (message) { 25 | 26 | let recUser; 27 | 28 | // Get the user record. 29 | if (message.userId) { 30 | recUser = await this.getUserById(message.userId); 31 | } 32 | else { 33 | recUser = await this.getUserByChannel(message.channelName, message.channelUserId); 34 | } 35 | 36 | // Ensure we have all the user IDs on the message object. 37 | if (recUser) { 38 | message.userId = recUser._id.toString(); 39 | message.channelName = recUser.channel.name; 40 | message.channelUserId = recUser.channel.userId; 41 | } 42 | 43 | return recUser; 44 | 45 | } 46 | 47 | /* 48 | * Returns the given user that matches the channel information. 49 | */ 50 | async getUserByChannel (channelName, channelUserId) { 51 | 52 | const database = this.__dep(`database`); 53 | 54 | return await database.get(`User`, { 55 | 'channel.name': channelName, 56 | 'channel.userId': channelUserId, 57 | }); 58 | 59 | } 60 | 61 | /* 62 | * Returns the given user by their Hippocamp user ID. 63 | */ 64 | async getUserById (userId) { 65 | 66 | const database = this.__dep(`database`); 67 | 68 | return await database.get(`User`, { 69 | _id: userId, 70 | }); 71 | 72 | } 73 | 74 | /* 75 | * Updates the "bot.disabled" property for the given user (where input is a user ID or full user record). 76 | */ 77 | async setBotDisabledForUser (input, disabled) { 78 | 79 | const database = this.__dep(`database`); 80 | 81 | return await database.update(`User`, input, { 82 | 'bot.disabled': disabled, 83 | }); 84 | 85 | } 86 | 87 | /* 88 | * Makes the given user's bot as "removed" so we don't consider the user for sending messages in future. 89 | */ 90 | async markBotRemovedForUser (input) { 91 | 92 | const database = this.__dep(`database`); 93 | 94 | return await database.update(`User`, input, { 95 | 'bot.removed': true, 96 | }); 97 | 98 | } 99 | 100 | /* 101 | * Makes the given user's bot as "removed" so we don't consider the user for sending messages in future. 102 | */ 103 | async markBotRemovedForUserByChannel (channelUserId, _channelName = null) { 104 | 105 | const channelName = _channelName || this.getHandlerId(); 106 | 107 | return await this.markBotRemovedForUser({ 108 | 'channel.name': channelName, 109 | 'channel.userId': channelUserId, 110 | }); 111 | 112 | } 113 | 114 | /* 115 | * Allows a message to be sent via the adapter specified in the message in the "channelName" property. 116 | */ 117 | async sendMessageViaAnotherAdapter (input, message) { 118 | 119 | const adapters = this.__dep(`adapters`); 120 | const recUser = await (input._id ? input : this.getUserByChannel(input)); 121 | const otherAdapter = adapters[message.channelName]; 122 | 123 | if (!otherAdapter) { 124 | throw new Error(`Cannot sent message via other adapter "${message.channelName}" because it does not exist.`); 125 | } 126 | 127 | // Figure out the message send delay. 128 | message.sendDelay = utilities.calculateSendMessageDelay(message, this.sendMessageDelay); 129 | 130 | await otherAdapter.markAsTypingOn(recUser); 131 | await utilities.delay(message.sendDelay); 132 | return await otherAdapter.sendMessage(recUser, message); 133 | 134 | } 135 | 136 | }; 137 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/actions/executeHook.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const extender = require(`object-extender`); 4 | const utilities = require(`../../modules/utilities`); 5 | 6 | /* 7 | * Allows all dependencies to be obtained on a single line. 8 | */ 9 | function prepareDependencies () { 10 | 11 | return { 12 | database: this.__dep(`database`), 13 | MessageObject: this.__dep(`MessageObject`), 14 | sharedLogger: this.__dep(`sharedLogger`), 15 | scheduler: this.__dep(`scheduler`), 16 | }; 17 | 18 | } 19 | 20 | /* 21 | * Updates the bot's memory with the result of the hook execution. 22 | */ 23 | async function updateBotMemory (hookResult, action, recUser) { 24 | 25 | // Do we need to remember the result? 26 | if (!action.memory || !recUser) { return; } 27 | 28 | // Update the bot's memory. 29 | try { 30 | await this.updateUserMemory(hookResult, action.memory, recUser, action.errorMessage); 31 | } 32 | catch (err) { 33 | throw new Error(`Failed to update memory after hook because of "${err}".`); 34 | } 35 | 36 | } 37 | 38 | /* 39 | * Do something useful with the error message coming from the hook. 40 | */ 41 | async function handleHookError (err, action, recUser, sharedLogger, MessageObject) { 42 | 43 | // If an error message has been specified lets send it to the user. 44 | if (action.errorMessage && recUser) { 45 | const newMessage = MessageObject.outgoing(recUser, { text: action.errorMessage }); 46 | await this.sendMessage(recUser, newMessage); 47 | } 48 | 49 | sharedLogger.error(err); 50 | 51 | } 52 | 53 | /* 54 | * Execute the hook specified in the action and wait for it to resolve. 55 | */ 56 | module.exports = async function __executeActionExecuteHook (action, recUser, message) { 57 | 58 | const hook = this.__getHook(action.hook); 59 | 60 | if (!hook) { throw new Error(`The hook "${action.hook}" has not been defined.`); } 61 | 62 | const hookFunc = hook.definition; 63 | 64 | if (typeof hookFunc !== `function`) { throw new Error(`The hook "${action.hook}" does not export a function.`); } 65 | 66 | const { database, MessageObject, sharedLogger, scheduler } = prepareDependencies.call(this); 67 | const actionCopy = extender.clone(action); 68 | const variables = this.prepareVariables(recUser); 69 | let finishMode = null; 70 | 71 | // All the items and methods that get made available to the hook. 72 | const resources = { 73 | database, 74 | MessageObject, 75 | sharedLogger, 76 | recUser, 77 | sendMessage: this.sendMessage.bind(this), 78 | scheduler, 79 | message, 80 | flows: this.flows, 81 | evaluateReferencedVariables: this.evaluateReferencedVariables.bind(this), 82 | evaluateConditional: this.evaluateConditional.bind(this), 83 | updateUserMemory: this.updateUserMemory.bind(this), 84 | doesTextMatch: this.doesTextMatch.bind(this), 85 | executeAnotherHook: this.executeAnotherHook.bind(this, recUser, message), 86 | delay: utilities.delay, 87 | trackUser: this.trackUser.bind(this), 88 | trackEvent: this.trackEvent.bind(this), 89 | handleCommandIfPresent: this.handleCommandIfPresent.bind(this), 90 | finishFlowAfterHook: () => finishMode = `after-hook`, 91 | changeFlow: nextUri => { 92 | finishMode = `immediately`; 93 | return this.__executeActionChangeFlow({ type: `change-flow`, nextUri }, recUser, message); 94 | }, 95 | }; 96 | 97 | // Attempt to execute the hook. 98 | try { 99 | 100 | const hookResult = await hookFunc(actionCopy, variables, resources); 101 | 102 | // If we changed flow or otherwise caused a break, then we don't do anything with the hook result and must stop. 103 | if (finishMode === `immediately`) { return false; } 104 | 105 | await updateBotMemory.call(this, hookResult, action, recUser); 106 | 107 | } 108 | catch (err) { 109 | handleHookError.call(this, err, action, recUser, sharedLogger, MessageObject); 110 | return false; 111 | } 112 | 113 | // Allow the flow to continue onto the next action as long as the hook didn't trigger a break. 114 | return (finishMode !== `after-hook`); 115 | 116 | }; 117 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/models/flow.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const shortid = require(`shortid`); 4 | 5 | /* 6 | * SCHEMA: Flow 7 | */ 8 | 9 | module.exports = function (Schema, Property) { 10 | 11 | /* 12 | * Miscellaneous. 13 | */ 14 | 15 | const schUiMetaConditional = new Schema(`FlowUiMetaConditional`, { 16 | matchType: new Property(`string`, `memory-key`), 17 | memoryKey: new Property(`string`), 18 | operator: new Property(`string`), // set, not-set, equals, not-equals, contains, notify-admin, starts-with, ends-with, expression 19 | value: new Property(`string`), 20 | expression: new Property(`string`), 21 | }); 22 | 23 | /* 24 | * Actions. 25 | */ 26 | 27 | const schFlowAction = new Schema(`FlowAction`, { 28 | shortId: new Property(`string`, shortid.generate), // TODO: unique: true 29 | type: new Property(`string`), 30 | conditional: new Property(`string`, ``), 31 | nextUri: new Property(`string`), 32 | message: new Property(`flexible`), 33 | delay: new Property(`integer`), 34 | markAsTyping: new Property(`boolean`), 35 | hook: new Property(`string`), 36 | wipeProfile: new Property(`boolean`), 37 | wipeMessages: new Property(`boolean`), 38 | state: new Property(`boolean`), 39 | memory: new Property(`flexible`), 40 | event: { 41 | name: new Property(`string`), 42 | data: new Property(`flexible`), 43 | }, 44 | traits: new Property(`flexible`), 45 | task: { 46 | taskId: new Property(`string`), 47 | nextRunDate: new Property(`date`), 48 | runEvery: new Property(`string`), 49 | runTime: new Property(`string`), 50 | maxRuns: new Property(`integer`), 51 | ignoreDays: [ new Property(`string`) ], 52 | allowConcurrent: new Property(`boolean`), 53 | actions: [ new Property(`flexible`) ], 54 | }, 55 | errorMessage: new Property(`string`), 56 | uiMeta: { 57 | stepType: new Property(`string`), 58 | conditional: schUiMetaConditional, 59 | returnAfterLoadFlow: new Property(`boolean`, false), 60 | }, 61 | }); 62 | 63 | /* 64 | * Prompts. 65 | */ 66 | 67 | const schPromptText = new Schema(`FlowPromptText`, { 68 | conditional: new Property(`string`, ``), 69 | value: new Property(`string`), 70 | uiMeta: { 71 | conditional: schUiMetaConditional, 72 | }, 73 | }); 74 | 75 | const schFlowOption = new Schema(`FlowOption`, { 76 | conditional: new Property(`string`, ``), 77 | label: new Property(`string`), 78 | payload: new Property(`string`), 79 | matches: new Property(`flexible`), 80 | nextUri: new Property(`string`), 81 | uiMeta: { 82 | conditional: schUiMetaConditional, 83 | actionType: new Property(`string`), 84 | }, 85 | }); 86 | 87 | const schFlowPrompt = new Schema(`FlowPrompt`, { 88 | type: new Property(`string`, `basic`), 89 | text: [ schPromptText ], 90 | options: [ schFlowOption ], 91 | memory: new Property(`flexible`), 92 | webview: { 93 | type: new Property(`string`, `basic`), 94 | style: new Property(`string`, `default`), 95 | title: new Property(`string`), 96 | instructions: new Property(`string`), 97 | openButton: new Property(`string`), 98 | cancelButton: new Property(`string`), 99 | submitButton: new Property(`string`), 100 | data: new Property(`flexible`), 101 | }, 102 | trackResponse: { 103 | eventName: new Property(`string`), 104 | fieldName: new Property(`string`), 105 | }, 106 | nextUri: new Property(`string`), 107 | errorMessage: new Property(`string`), 108 | uiMeta: { 109 | answerType: new Property(`string`), 110 | }, 111 | }); 112 | 113 | /* 114 | * Flows. 115 | */ 116 | 117 | return new Schema(`Flow`, { 118 | uri: new Property(`string`, null), // Optional for dynamic flows. 119 | name: new Property(`string`), 120 | type: new Property(`string`, `basic`), 121 | nextUri: new Property(`string`, null), 122 | actions: [ schFlowAction ], 123 | prompt: schFlowPrompt, 124 | interruptions: { 125 | whenAgent: new Property(`string`, `ask-user`), 126 | whenSubject: new Property(`string`, `ask-user`), 127 | }, 128 | uiMeta: { 129 | 130 | }, 131 | created: new Property(`date`, Date.now), 132 | updated: new Property(`date`, Date.now), 133 | }); 134 | 135 | }; 136 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/loggers/filesystem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * LOGGER: Filesystem 5 | */ 6 | 7 | const filesystem = require(`fs`); 8 | const path = require(`path`); 9 | const moment = require(`moment`); 10 | const extender = require(`object-extender`); 11 | const LoggerBase = require(`./loggerBase`); 12 | 13 | module.exports = class LoggerFilesystem extends LoggerBase { 14 | 15 | /* 16 | * Instantiates the handler. 17 | */ 18 | constructor (_options) { 19 | 20 | // Configure the handler. 21 | super(`logger`, `filesystem`); 22 | 23 | // Default config for this handler. 24 | this.options = extender.defaults({ 25 | logLevel: `verbose`, 26 | directory: null, 27 | rotation: `daily`, 28 | isDisabled: false, 29 | }, _options); 30 | 31 | this.isDisabled = this.options.isDisabled; 32 | 33 | // Ensure a directory path has been specified and that it is an absolute path. 34 | if (!this.options.directory) { 35 | throw new Error(`You must specify the "directory" option when instantiating the filesystem logger.`); 36 | } 37 | else if (!path.isAbsolute(this.options.directory)) { 38 | this.options.directory = path.join(process.cwd(), this.options.directory); 39 | } 40 | 41 | // The cache for the log file streams. 42 | this.logFileStreams = { 43 | standard: { filename: null, stream: null }, 44 | error: { filename: null, stream: null }, 45 | }; 46 | 47 | } 48 | 49 | /* 50 | * Writes the log data to the standard log channel. 51 | */ 52 | __writeLog (level, data) { 53 | if (this.checkIfDisabled()) { return; } 54 | return this.__write(`standard`, level, data); 55 | } 56 | 57 | /* 58 | * Writes the log data to the error log channel. 59 | */ 60 | __writeErr (level, data) { 61 | if (this.checkIfDisabled()) { return; } 62 | return this.__write(`error`, level, data); 63 | } 64 | 65 | /* 66 | * Writes the log data to the given stream. 67 | */ 68 | __write (type, level, data) { 69 | 70 | if (this.checkIfDisabled()) { return; } 71 | 72 | return new Promise(resolve => { 73 | 74 | const stream = this.__getFileStream(type); 75 | const preparedData = this.__prepareLogData(level, data); 76 | const json = JSON.stringify(preparedData); 77 | const output = `${json}\n`; 78 | 79 | stream.write(output, () => resolve(preparedData)); 80 | 81 | }); 82 | 83 | } 84 | 85 | /* 86 | * Returns the correct log file stream for the given type, taking into account log file rotation if it's configured. 87 | */ 88 | __getFileStream (type) { 89 | 90 | const logFilename = this.__getLogFilename(type); 91 | 92 | // If the filename is different we need to rotate the log file. 93 | if (this.logFileStreams[type].filename !== logFilename) { 94 | 95 | // Kill the old stream if one exists. 96 | if (this.logFileStreams[type].stream) { this.logFileStreams[type].stream.end(); } 97 | 98 | // Open a new write stream. 99 | this.logFileStreams[type].filename = logFilename; 100 | this.logFileStreams[type].stream = filesystem.createWriteStream(logFilename, { flags: `a` }); 101 | 102 | } 103 | 104 | return this.logFileStreams[type].stream; 105 | 106 | } 107 | 108 | /* 109 | * Returns the filename of the given type of log file, taking into account log rotation. 110 | */ 111 | __getLogFilename (type) { 112 | 113 | let dateType = this.options.rotation; 114 | let dateFormatted; 115 | 116 | switch (this.options.rotation) { 117 | 118 | case `monthly`: 119 | dateFormatted = moment.utc().format(`YYYY-MM`); 120 | break; 121 | 122 | case `weekly`: 123 | dateFormatted = moment.utc().format(`YYYY-MM_WW`); 124 | break; 125 | 126 | case `daily`: 127 | dateFormatted = moment.utc().format(`YYYY-MM-DD`); 128 | break; 129 | 130 | case `hourly`: 131 | dateFormatted = moment.utc().format(`YYYY-MM-DD_HH`); 132 | break; 133 | 134 | default: // Do not rotate. 135 | dateFormatted = null; 136 | dateType = `norotate`; 137 | break; 138 | 139 | } 140 | 141 | // Construct the absolute filename. 142 | const filename = `${type}_${dateType}${dateFormatted ? `_${dateFormatted}` : ``}.log`; 143 | return path.join(this.options.directory, filename); 144 | 145 | } 146 | 147 | }; 148 | -------------------------------------------------------------------------------- /app/bot.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * EYEWITNESS CHATBOT 5 | */ 6 | 7 | /* eslint no-console: 0 */ 8 | 9 | const config = require(`./modules/initConfig`); 10 | 11 | const Hippocamp = require(`@atchai/hippocamp`); // eslint-disable-line node/no-missing-require 12 | const LoggerTerminal = Hippocamp.require(`loggers/terminal`); 13 | const LoggerFilesystem = Hippocamp.require(`loggers/filesystem`); 14 | const DatabaseMongo = Hippocamp.require(`databases/mongo`); 15 | const SchedulerSimple = Hippocamp.require(`schedulers/simple`); 16 | const AdapterFacebook = Hippocamp.require(`adapters/facebook`); 17 | const AdapterWeb = Hippocamp.require(`adapters/web`); 18 | const AnalyticsDashbot = Hippocamp.require(`analytics/dashbot`); 19 | const AnalyticsSegment = Hippocamp.require(`analytics/segment`); 20 | const NlpLuis = Hippocamp.require(`nlp/luis`); 21 | const { pushNewMessagesToUI, pushMemoryChangeToUI } = require(`./modules/miscellaneous`); 22 | 23 | /* 24 | * The main function. 25 | */ 26 | async function main () { 27 | 28 | // A new chatbot! 29 | const chatbot = new Hippocamp({ 30 | packageJsonPath: `../package.json`, 31 | baseUrl: config.hippocampServer.baseUrl, 32 | port: process.env.PORT || config.hippocampServer.ports.internal, 33 | enableUserProfile: true, 34 | enableUserTracking: true, 35 | enableEventTracking: true, 36 | enableMessageTracking: true, 37 | enableNlp: !config.nlp.luis.isDisabled, 38 | greetingText: config.greetingText, 39 | misunderstoodText: null, 40 | menu: [{ 41 | type: `basic`, 42 | label: `See latest stories`, 43 | payload: `latest stories`, 44 | }, { 45 | type: `nested`, 46 | label: `Contact us`, 47 | items: [{ 48 | type: `basic`, 49 | label: `Send us a story`, 50 | payload: `do submit story flow`, 51 | }, { 52 | type: `basic`, 53 | label: `Advertise`, 54 | payload: `do advertise flow`, 55 | }, { 56 | type: `basic`, 57 | label: `Unsubscribe`, 58 | }], 59 | }, { 60 | type: `url`, 61 | label: `Privacy policy`, 62 | payload: config.privacyPolicyUrl, 63 | sharing: true, 64 | }], 65 | messageVariables: config.messageVariables, 66 | allowUserTextReplies: true, 67 | directories: { 68 | commands: `./commands`, 69 | hooks: `./hooks`, 70 | models: `./models`, 71 | }, 72 | debugMode: (config.env.id === `development`), 73 | }); 74 | 75 | // Loggers. 76 | await chatbot.configure(new LoggerTerminal(config.loggers.terminal)); 77 | if (config.loggers.filesystem) { await chatbot.configure(new LoggerFilesystem(config.loggers.filesystem)); } 78 | 79 | // Databases. 80 | await chatbot.configure(new DatabaseMongo(config.databases.mongo)); 81 | 82 | // Scheduler. 83 | await chatbot.configure(new SchedulerSimple({ 84 | executeEvery: `minute`, 85 | tasks: [{ 86 | taskId: `feed-ingester`, 87 | actions: [{ 88 | type: `execute-hook`, 89 | hook: `feedIngester`, 90 | }], 91 | runEvery: config.scheduledTasks[`feed-ingester`].runEvery, 92 | maxRuns: 0, 93 | }, { 94 | taskId: `news-notifications`, 95 | actions: [{ 96 | type: `execute-hook`, 97 | hook: `newsNotifications`, 98 | }], 99 | runEvery: config.scheduledTasks[`news-notifications`].runEvery, 100 | maxRuns: 0, 101 | }], 102 | })); 103 | 104 | // Adapters. 105 | await chatbot.configure(new AdapterFacebook(config.adapters.facebook)); 106 | await chatbot.configure(new AdapterWeb(config.adapters.web)); 107 | 108 | // Analytics. 109 | await chatbot.configure(new AnalyticsDashbot(config.analytics.dashbot)); 110 | await chatbot.configure(new AnalyticsSegment(config.analytics.segment)); 111 | 112 | // NLP services. 113 | if (config.nlp.luis) { chatbot.configure(new NlpLuis(config.nlp.luis)); } 114 | 115 | // Register event listeners. 116 | chatbot.on(`new-incoming-message`, pushNewMessagesToUI); 117 | chatbot.on(`new-outgoing-message`, pushNewMessagesToUI); 118 | chatbot.on(`memory-change`, pushMemoryChangeToUI); 119 | 120 | await chatbot.start(); 121 | 122 | } 123 | 124 | /* 125 | * Run task. 126 | */ 127 | main() 128 | .catch(err => { // eslint-disable-line promise/prefer-await-to-callbacks 129 | console.error(err.stack); // eslint-disable-line no-console 130 | process.exit(1); 131 | }); 132 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eyewitness", 3 | "version": "4.0.7", 4 | "private": true, 5 | "description": "Chatbot to allow people to receive news from providers and submit their own stories.", 6 | "main": "app/entryPoint.js", 7 | "scripts": { 8 | "deploy-all-production": "npm run deploy-demo-production && npm run deploy-sabc-production && npm run deploy-thestar-production", 9 | "deploy-demo-production": "docker build -f Dockerfile -t registry.heroku.com/eyewitness-bot-demo/web -t registry.heroku.com/eyewitness-rs-demo/web . && docker push registry.heroku.com/eyewitness-bot-demo/web && docker push registry.heroku.com/eyewitness-rs-demo/web && heroku container:release -a eyewitness-bot-demo web && heroku container:release -a eyewitness-rs-demo web", 10 | "deploy-sabc-production": "docker build -f Dockerfile -t registry.heroku.com/eyewitness-bot-sabc/web -t registry.heroku.com/eyewitness-rs-sabc/web . && docker push registry.heroku.com/eyewitness-bot-sabc/web && docker push registry.heroku.com/eyewitness-rs-sabc/web && heroku container:release -a eyewitness-bot-sabc web && heroku container:release -a eyewitness-rs-sabc web", 11 | "deploy-thestar-production": "docker build -f Dockerfile -t registry.heroku.com/eyewitness-bot-thestar/web -t registry.heroku.com/eyewitness-rs-thestar/web . && docker push registry.heroku.com/eyewitness-bot-thestar/web && docker push registry.heroku.com/eyewitness-rs-thestar/web && heroku container:release -a eyewitness-bot-thestar web && heroku container:release -a eyewitness-rs-thestar web", 12 | "deploy-all-staging": "npm run deploy-demo-staging && npm run deploy-sabc-staging && npm run deploy-thestar-staging", 13 | "deploy-demo-staging": "docker build -f Dockerfile.staging -t registry.heroku.com/eyewitness-bot-demo-staging/web -t registry.heroku.com/eyewitness-rs-demo-staging/web . && docker push registry.heroku.com/eyewitness-bot-demo-staging/web && docker push registry.heroku.com/eyewitness-rs-demo-staging/web && heroku container:release -a eyewitness-bot-demo-staging web && heroku container:release -a eyewitness-rs-demo-staging web", 14 | "deploy-sabc-staging": "docker build -f Dockerfile.staging -t registry.heroku.com/eyewitness-bot-sabc-staging/web -t registry.heroku.com/eyewitness-rs-sabc-staging/web . && docker push registry.heroku.com/eyewitness-bot-sabc-staging/web && docker push registry.heroku.com/eyewitness-rs-sabc-staging/web && heroku container:release -a eyewitness-bot-sabc-staging web && heroku container:release -a eyewitness-rs-sabc-staging web", 15 | "deploy-thestar-staging": "docker build -f Dockerfile.staging -t registry.heroku.com/eyewitness-bot-thestar-staging/web -t registry.heroku.com/eyewitness-rs-thestar-staging/web . && docker push registry.heroku.com/eyewitness-bot-thestar-staging/web && docker push registry.heroku.com/eyewitness-rs-thestar-staging/web && heroku container:release -a eyewitness-bot-thestar-staging web && heroku container:release -a eyewitness-rs-thestar-staging web", 16 | "ngrok": "ngrok http 5000 --region eu -subdomain=eyewitness", 17 | "start": "export ENTRY_POINT='bot' && npm run start-local", 18 | "start-local": "./node_modules/.bin/nodemon ./app/entryPoint.js", 19 | "start-development": "npm install && ./node_modules/.bin/nodemon ./app/entryPoint.js", 20 | "start-production": "node ./app/entryPoint.js", 21 | "test": "echo \"Placeholder tests.\"" 22 | }, 23 | "author": "Atchai (https://atchai.com)", 24 | "dependencies": { 25 | "@atchai/hippocamp": "file:./lib/hippocamp", 26 | "cheerio": "^1.0.0-rc.2", 27 | "config-ninja": "^1.3.2", 28 | "dotenv": "^6.0.0", 29 | "escape-regexp": "0.0.1", 30 | "moment": "^2.22.1", 31 | "object-extender": "^2.0.3", 32 | "request-ninja": "^0.3.2", 33 | "xml2js": "^0.4.19" 34 | }, 35 | "devDependencies": { 36 | "eslint": "^5.2.0", 37 | "eslint-config-recombix": "latest", 38 | "eslint-config-vue": "latest", 39 | "eslint-plugin-disable": "^1.0.2", 40 | "eslint-plugin-filenames": "^1.3.2", 41 | "eslint-plugin-html": "^4.0.5", 42 | "eslint-plugin-json": "latest", 43 | "eslint-plugin-node": "^7.0.1", 44 | "eslint-plugin-promise": "latest", 45 | "eslint-plugin-vue": "^4.7.1", 46 | "ngrok": "^2.2.23", 47 | "nodemon": "^1.18.3" 48 | }, 49 | "engines": { 50 | "node": ">=10", 51 | "npm": ">=6" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/workflowManager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * WORKFLOW MANAGER 5 | * The main entry point for the Workflow Manager class. When instantiated, this class includes the methods from the 6 | * various sub-modules so they can be accessed with 'this'. 7 | */ 8 | 9 | const MiddlewareEngine = require(`middleware-engine`); 10 | const extender = require(`object-extender`); 11 | const workflowActions = require(`./actions`); 12 | const workflowCommands = require(`./commands`); 13 | const workflowFileManagement = require(`./fileManagement`); 14 | const workflowFlows = require(`./flows`); 15 | const workflowHooks = require(`./hooks`); 16 | const workflowLinkTracking = require(`./linkTracking`); 17 | const workflowMatching = require(`./matching`); 18 | const workflowMemory = require(`./memory`); 19 | const workflowMiscellaneous = require(`./miscellaneous`); 20 | const workflowMisunderstood = require(`./misunderstood`); 21 | const workflowModels = require(`./models`); 22 | const workflowPrompts = require(`./prompts`); 23 | const workflowTracking = require(`./tracking`); 24 | const workflowVariables = require(`./variables`); 25 | const workflowWebviews = require(`./webviews`); 26 | 27 | module.exports = class WorkflowManager extends MiddlewareEngine { 28 | 29 | /* 30 | * Instantiate a new workflow manager. 31 | */ 32 | constructor (_options, _triggerEvent) { 33 | 34 | // Configure the middleware engine. 35 | super(); 36 | 37 | // Default values for just the options we care about. 38 | this.options = extender.defaults({ 39 | baseUrl: null, 40 | }, _options); 41 | 42 | // Maintain the scope of trigger event from the Hippocamp class. 43 | this.triggerEvent = function (...args) { 44 | return _triggerEvent(...args); 45 | }; 46 | 47 | // In-memory caches. 48 | this.commands = []; 49 | this.hooks = {}; 50 | this.matchFiles = {}; 51 | this.flows = {}; 52 | this.webviews = {}; 53 | this.webviewLayoutHtml = null; 54 | 55 | // Assign methods from sub-modules. 56 | Object.assign( 57 | this, 58 | workflowActions, 59 | workflowCommands, 60 | workflowFileManagement, 61 | workflowFlows, 62 | workflowHooks, 63 | workflowLinkTracking, 64 | workflowMatching, 65 | workflowMemory, 66 | workflowMiscellaneous, 67 | workflowMisunderstood, 68 | workflowModels, 69 | workflowPrompts, 70 | workflowTracking, 71 | workflowVariables, 72 | workflowWebviews 73 | ); 74 | 75 | } 76 | 77 | /* 78 | * Start the workflow manager by loading in all the required files. 79 | */ 80 | async start (directories) { 81 | 82 | const database = this.__dep(`database`); 83 | const sharedLogger = this.__dep(`sharedLogger`); 84 | 85 | try { 86 | 87 | // Setup the database connection before we do anything else. 88 | await this.__loadModels(directories.models, directories.modelsExtensions); 89 | await database.connect(); 90 | 91 | // Then load all other resources in parallel. 92 | await Promise.all([ 93 | this.__loadCommands(directories.commands), 94 | this.__loadFlows(directories.flows), 95 | this.__loadHooks(directories.hooks), 96 | this.__loadMatchFiles(directories.matches), 97 | this.__loadWebviews(directories.webviews), 98 | ]); 99 | 100 | } 101 | catch (err) { 102 | sharedLogger.fatal(err.stack); 103 | throw new Error(`Failed to start the workflow manager because of "${err}".`); 104 | } 105 | 106 | } 107 | 108 | /* 109 | * Logs out the reason we are skipping executing a function. 110 | */ 111 | skipIfBotDisabled (functionInfo, recUser) { 112 | 113 | // Don't skip if no bot properties are set on the user record. 114 | if (!recUser || !recUser.bot) { return false; } 115 | 116 | const sharedLogger = this.__dep(`sharedLogger`); 117 | const userId = recUser._id; 118 | let reason = null; 119 | 120 | // Figure out the reason for skipping, if any. 121 | if (recUser.bot.removed) { reason = `the bot has been removed by the user "${userId}"`; } 122 | else if (recUser.bot.disabled) { reason = `the bot is disabled for the user "${userId}"`; } 123 | 124 | // Don't skip if no reason to skip was found. 125 | if (!reason) { return false; } 126 | 127 | // Yes we are skipping. 128 | sharedLogger.debug(`Skipping ${functionInfo} because ${reason}.`); 129 | return true; 130 | 131 | } 132 | 133 | }; 134 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/adapters/facebook/convertToInternalMessages.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const mime = require(`mime`); 4 | 5 | /* 6 | * Convert the Facebook message to our internal representation. 7 | */ 8 | async function convertToInternalMessage (senderId, timestamp, fbMessage, resources) { 9 | 10 | const { MessageObject, populateOppositeUserId, handlerId } = resources; 11 | let attachments; 12 | 13 | // Deal with incoming attachments. 14 | if (fbMessage.attachments && fbMessage.attachments.length) { 15 | 16 | attachments = fbMessage.attachments.map(fbAttachment => { 17 | 18 | const payloadUrl = (fbAttachment.payload && fbAttachment.payload.url) || ``; 19 | let [ , filename ] = payloadUrl.match(/\/([a-z0-9\-_.]+)\?/i) || []; 20 | const mimeType = (filename ? mime.getType(filename) : void (0)); 21 | let type; 22 | let remoteUrl = payloadUrl; 23 | 24 | switch (fbAttachment.type) { 25 | 26 | case `audio`: type = `audio`; break; 27 | case `file`: type = `file`; break; 28 | case `image`: type = `image`; break; 29 | case `video`: type = `video`; break; 30 | 31 | case `location`: { 32 | type = `location`; 33 | filename = fbAttachment.title; 34 | const [ , encodedMapUrl ] = fbAttachment.url.match(/\.php\?u=(.+)$/i); 35 | remoteUrl = decodeURIComponent(encodedMapUrl); 36 | break; 37 | } 38 | 39 | case `fallback`: 40 | type = `link`; 41 | filename = fbAttachment.title; 42 | remoteUrl = fbAttachment.url; 43 | break; 44 | 45 | default: throw new Error(`Unknown attachment type "${fbAttachment.type}" coming from Facebook.`); 46 | 47 | } 48 | 49 | return Object({ 50 | type, 51 | filename: filename || `untitled`, 52 | mimeType, 53 | remoteUrl: remoteUrl || void (0), 54 | }); 55 | 56 | }); 57 | 58 | } 59 | 60 | // Construct the message. 61 | const properties = { 62 | direction: `incoming`, 63 | channelName: handlerId, 64 | channelUserId: senderId, 65 | channelMessageId: fbMessage.mid, 66 | referral: fbMessage.referral || null, 67 | sentAt: timestamp, 68 | text: (fbMessage.quick_reply && fbMessage.quick_reply.payload) || fbMessage.text || ``, 69 | attachments, 70 | }; 71 | 72 | // Ensure we have the both the Hippocamp user ID and the channel user ID on the message. 73 | await populateOppositeUserId(properties); 74 | 75 | // Turn properties into a real message. 76 | return new MessageObject(properties); 77 | 78 | } 79 | 80 | /* 81 | * Collate and convert all incoming messages in the webhook into Hippocamp's internal message representation. 82 | */ 83 | module.exports = async function convertToInternalMessages (bodyData, resources) { 84 | 85 | const { sharedLogger } = resources; 86 | const messages = []; 87 | 88 | // No messages included? Nothing to do! 89 | if (!bodyData.entry || !bodyData.entry.length) { return messages; } 90 | 91 | // Collate messages. 92 | for (let entryIndex = 0; entryIndex < bodyData.entry.length; entryIndex++) { 93 | const entry = bodyData.entry[entryIndex]; 94 | // const pageId = entry.id; 95 | const timestamp = entry.time; 96 | const messaging = entry.messaging || []; 97 | 98 | for (let messageIndex = 0; messageIndex < messaging.length; messageIndex++) { 99 | const event = messaging[messageIndex]; 100 | const senderId = event.sender.id; 101 | 102 | // Read in messages and postbacks. 103 | if ((event.message && !event.message.is_echo) || event.postback) { 104 | let fbMessage; 105 | 106 | // If the event is a postback, convert it to a text message. 107 | if (event.postback) { 108 | fbMessage = { 109 | text: event.postback.payload, 110 | referral: event.postback.referral && event.postback.referral.ref, 111 | }; 112 | } 113 | 114 | // Otherwise assume it's just a message. 115 | else { 116 | fbMessage = event.message; 117 | } 118 | 119 | // Convert the message to the internal representation. 120 | const message = await convertToInternalMessage(senderId, timestamp, fbMessage, resources); // eslint-disable-line no-await-in-loop 121 | messages.push(message); 122 | } 123 | 124 | else { 125 | sharedLogger.warn({ 126 | text: `Unknown webhook from Facebook API.`, 127 | event, 128 | }); 129 | } 130 | 131 | } 132 | 133 | } 134 | 135 | return messages; 136 | 137 | }; 138 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/memory/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * WORKFLOW: MEMORY 5 | * Functions for dealing with bot memory on a per-user basis. 6 | */ 7 | 8 | const extender = require(`object-extender`); 9 | const deepProperty = require(`deep-property`); 10 | const prepareMemoryChanges = require(`./prepareMemoryChanges`); 11 | 12 | /* 13 | * Triggers events for the memory changes about to occur. 14 | */ 15 | function __triggerMemoryChangeEvents (recUser, memoryValues) { 16 | 17 | // Trigger events for any values that are changing. 18 | for (const key in memoryValues.set) { 19 | if (!memoryValues.set.hasOwnProperty(key)) { continue; } 20 | 21 | const oldValue = deepProperty.get(recUser, key); // Preserve the old value EXACTLY as it is (even undefined). 22 | const newValue = memoryValues.set[key]; 23 | 24 | this.triggerEvent(`memory-change`, { 25 | recUser, 26 | memory: { 27 | key, 28 | operation: `set`, 29 | oldValue, 30 | newValue, 31 | }, 32 | }); 33 | } 34 | 35 | // Trigger events for any values that are being unset. 36 | for (const key in memoryValues.unset) { 37 | if (!memoryValues.unset.hasOwnProperty(key)) { continue; } 38 | 39 | const oldValue = deepProperty.get(recUser, key); // Preserve the old value EXACTLY as it is (even undefined). 40 | 41 | this.triggerEvent(`memory-change`, { 42 | recUser, 43 | memory: { 44 | key, 45 | operation: `unset`, 46 | oldValue, 47 | newValue: void (0), 48 | }, 49 | }); 50 | 51 | } 52 | 53 | } 54 | 55 | /* 56 | * We must also update the in-memory copy of the user record at the same time as we write changes to the database. 57 | */ 58 | function __updateInMemoryUserRecord (recUser, memoryValues) { 59 | 60 | // Overwrite any values that are changing. 61 | for (const key in memoryValues.set) { 62 | if (!memoryValues.set.hasOwnProperty(key)) { continue; } 63 | const value = memoryValues.set[key]; 64 | deepProperty.set(recUser, key, value); 65 | } 66 | 67 | // Remove any values that are being unset. 68 | for (const key in memoryValues.unset) { 69 | if (!memoryValues.unset.hasOwnProperty(key)) { continue; } 70 | deepProperty.remove(recUser, key); 71 | } 72 | 73 | } 74 | 75 | /* 76 | * Prepares and updates the user's bot memory. 77 | */ 78 | async function updateUserMemory (input, memory, recUser, errorMessage) { 79 | 80 | const database = this.__dep(`database`); 81 | const variables = extender.merge(this.options.messageVariables, recUser.profile, recUser.appData); 82 | const memoryValues = this.prepareMemoryChanges(input, memory, recUser.appData, errorMessage, variables); 83 | 84 | // Trigger event listeners for each change (must be before the changes are made). 85 | this.__triggerMemoryChangeEvents(recUser, memoryValues); 86 | 87 | // Update the user record with the new memories. 88 | const changes = extender.merge( 89 | memoryValues.set, 90 | (Object.keys(memoryValues.unset).length ? { $unset: memoryValues.unset } : null) 91 | ); 92 | 93 | this.__updateInMemoryUserRecord(recUser, memoryValues); 94 | await database.update(`User`, recUser, changes); 95 | 96 | return true; 97 | 98 | } 99 | 100 | /* 101 | * Removes all of the bot's memory for the given user, and optionally wipes the user's profile data too. 102 | */ 103 | async function __wipeUserMemory (wipeProfile, recUser) { 104 | 105 | const database = this.__dep(`database`); 106 | const dbChanges = { $set: {} }; 107 | 108 | // Always wipe the app data. 109 | recUser.appData = {}; // In-memory user record. 110 | dbChanges.$set.appData = {}; // Database user record. 111 | 112 | // Always re-enable the bot. 113 | recUser.bot.disabled = false; // In-memory user record. 114 | dbChanges.$set[`bot.disabled`] = false; // Database user record. 115 | 116 | // Wipe the user's profile. 117 | if (wipeProfile) { 118 | const created = Date.now(); 119 | recUser.profile = { created }; // In-memory user record. 120 | dbChanges.$set.profile = { created }; // Database user record. 121 | } 122 | 123 | // Update the user document in the database. 124 | await database.update(`User`, recUser, dbChanges); 125 | 126 | } 127 | 128 | /* 129 | * Export. 130 | */ 131 | module.exports = { 132 | __triggerMemoryChangeEvents, 133 | __updateInMemoryUserRecord, 134 | prepareMemoryChanges, 135 | updateUserMemory, 136 | __wipeUserMemory, 137 | ...prepareMemoryChanges, 138 | }; 139 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/modules/userProfileRefresher.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const moment = require(`moment`); 4 | 5 | /* 6 | * Returns true if we are allowed to refresh the user's profile at this time. 7 | */ 8 | function isAllowedToRefresh (recUser, refreshEvery) { 9 | 10 | const [ refreshFrequency, refreshUnits ] = (refreshEvery ? refreshEvery.split(/\s+/g) : [ null, null ]); 11 | 12 | // If we have loaded the user profile at least once, and the config is set to refresh never. 13 | if (recUser.profile.lastUpdated > 0 && (!refreshFrequency || !refreshUnits)) { 14 | return false; 15 | } 16 | 17 | const diffFrequency = moment.utc().diff(moment.utc(recUser.profile.lastUpdated), refreshUnits, true); 18 | 19 | // If we have loaded the user profile at least once, and we have not reached the next refresh time yet. 20 | if (recUser.profile.lastUpdated > 0 && diffFrequency <= parseFloat(refreshFrequency)) { 21 | return false; 22 | } 23 | 24 | return true; 25 | 26 | } 27 | 28 | /* 29 | * Prepare a list of profile changes and update the user record in memory at the same time. 30 | */ 31 | function prepareProfileChanges (recUser, changes, newProfile, whitelistProfileFields) { 32 | 33 | Object.entries(newProfile).forEach(([ key, value ]) => { 34 | if (!value || (whitelistProfileFields && !whitelistProfileFields.includes(key))) { return; } 35 | changes[`profile.${key}`] = value; 36 | recUser.profile[key] = value; 37 | }); 38 | 39 | changes[`profile.lastUpdated`] = Date.now(); 40 | 41 | } 42 | 43 | /** 44 | * @typedef {Object} RefreshProfileOptions 45 | * @property {string} refreshEvery - minimum time period to refresh a profile (eg. "30 minutes") 46 | * @property {string} whitelistProfileFields - list of field names to refresh 47 | */ 48 | 49 | /** 50 | * Functions for refreshing profiles for a single user or all users 51 | * 52 | * @param {Object} recUser - record of user 53 | * @param {AdapterBase} adapter - adapter to fetch profile with 54 | * @param {DatabaseBase} database - database wrapper 55 | * @param {Function} triggerEvent - function to trigger an event 56 | * @param {RefreshProfileOptions} options - how often and which fields to refresh 57 | * @returns {undefined} 58 | */ 59 | async function refreshUserProfile (recUser, adapter, database, triggerEvent, options) { 60 | 61 | const refreshEvery = options.refreshEvery; 62 | const whitelistProfileFields = options.whitelistProfileFields || null; 63 | 64 | // Before we do anything else make sure we are allowed to refresh the user's profile. 65 | if (!isAllowedToRefresh(recUser, refreshEvery)) { 66 | return; 67 | } 68 | 69 | // Get the user's profile. 70 | recUser.profile = recUser.profile || {}; 71 | 72 | const oldProfile = Object.assign({}, recUser.profile); 73 | const newProfile = await adapter.getUserProfile(recUser.channel.userId); 74 | const changes = {}; 75 | 76 | if (!newProfile) { 77 | return; 78 | } 79 | 80 | // Make changes to the user's profile. 81 | prepareProfileChanges(recUser, changes, newProfile, whitelistProfileFields); 82 | await database.update(`User`, recUser, changes); 83 | 84 | // Trigger event listeners. 85 | await triggerEvent(`refreshed-user-profile`, { recUser, oldProfile }); 86 | 87 | } 88 | 89 | /** 90 | * Refresh user profiles for all users. 91 | * Currently the implementation just calls refreshUserProfile for every user, could be optimised. 92 | * 93 | * @param {AdapterBase} adapter - adapter to fetch profile with 94 | * @param {DatabaseBase} database - database wrapper 95 | * @param {LoggerBase} sharedLogger - logger 96 | * @param {Function} triggerEvent - function to trigger an event 97 | * @param {RefreshProfileOptions} options - how often and which fields to refresh 98 | * @returns {Promise} nothing 99 | */ 100 | async function refreshAllProfiles (adapter, database, sharedLogger, triggerEvent, options) { 101 | 102 | const recUsers = await database.find(`User`, {}, {}); 103 | 104 | for (const recUser of recUsers) { 105 | 106 | try { 107 | await refreshUserProfile(recUser, adapter, database, triggerEvent, options); // eslint-disable-line no-await-in-loop 108 | } 109 | catch (err) { 110 | sharedLogger.error(`Failed to refresh user profile "${recUser._id}" because of "${err}".`); 111 | } 112 | 113 | } 114 | 115 | } 116 | 117 | /* 118 | * Export. 119 | */ 120 | module.exports = { 121 | refreshUserProfile, 122 | refreshAllProfiles, 123 | }; 124 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/fileManagement.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * WORKFLOW: FILE MANAGEMENT 5 | * Functions for loading bot resource files from disk and storing them in the in-memory caches. 6 | */ 7 | 8 | const filesystem = require(`fs`); 9 | const path = require(`path`); 10 | 11 | /* 12 | * Pull the name out of the filename. 13 | */ 14 | function parseFilename (filename, ext) { 15 | 16 | const normalisedFilename = filename.replace(/\\/g, `/`); // Windows uses backslashes. 17 | const regexp = new RegExp(`/([^/]+).${ext}`, `i`); 18 | const match = normalisedFilename.match(regexp); 19 | 20 | return match[1].toLowerCase(); 21 | 22 | } 23 | 24 | /* 25 | * Returns an array of all the files with the given extension in the given directory (and optionally its 26 | * subdirectories). 27 | */ 28 | async function discoverFiles (directory, ext, searchSubDirectories = true) { 29 | 30 | const fileList = []; 31 | const items = await this.listDirectory(directory); 32 | const directoryList = []; 33 | const regexp = new RegExp(`.${ext}$`, `i`); 34 | 35 | // Add all the files and remember the directories for recursion later. 36 | for (const item of items) { 37 | const itemAbsolute = path.join(directory, item); 38 | const isDir = (searchSubDirectories ? await this.isDirectory(itemAbsolute) : false); // eslint-disable-line no-await-in-loop 39 | 40 | if (isDir) { directoryList.push(itemAbsolute); } 41 | else if (itemAbsolute.match(regexp)) { fileList.push(itemAbsolute); } 42 | } 43 | 44 | // Recursively get all the files from each of the subdirectories. 45 | for (const dir of directoryList) { 46 | const subFiles = await this.discoverFiles(dir, ext, searchSubDirectories); // eslint-disable-line no-await-in-loop 47 | fileList.push(...subFiles); 48 | } 49 | 50 | return fileList; 51 | 52 | } 53 | 54 | /* 55 | * Returns an array of all the directories in the top level of the given directory (not its subdirectories). 56 | */ 57 | async function discoverDirectories (directory) { 58 | 59 | const items = await this.listDirectory(directory); 60 | const directoryList = []; 61 | 62 | // Add all the directories. 63 | for (const item of items) { 64 | const itemAbsolute = path.join(directory, item); 65 | const isDir = await this.isDirectory(itemAbsolute); // eslint-disable-line no-await-in-loop 66 | 67 | if (isDir) { directoryList.push(itemAbsolute); } 68 | } 69 | 70 | return directoryList; 71 | 72 | } 73 | 74 | /* 75 | * Returns a list of items in the given directory, excluding any hidden files. 76 | */ 77 | function listDirectory (directory) { 78 | 79 | return new Promise((resolve, reject) => { 80 | 81 | filesystem.readdir(directory, (err, _files) => { // eslint-disable-line promise/prefer-await-to-callbacks 82 | if (err) { return reject(err); } 83 | const files = _files.filter(file => file[0] !== `.`); 84 | return resolve(files); 85 | }); 86 | 87 | }); 88 | 89 | } 90 | 91 | /* 92 | * Returns true if the given filename is in fact a directory./ 93 | */ 94 | function isDirectory (filename) { 95 | 96 | return new Promise((resolve, reject) => { 97 | filesystem.stat(filename, (err, stats) => (err ? reject(err) : resolve(stats.isDirectory()))); // eslint-disable-line promise/prefer-await-to-callbacks 98 | }); 99 | 100 | } 101 | 102 | /* 103 | * Load and parse the given file. 104 | */ 105 | async function parseJsonFile (filename, ignoreNotFoundErrors = false) { 106 | 107 | const data = await this.loadFile(filename, ignoreNotFoundErrors); 108 | if (!data) { return null; } 109 | 110 | try { 111 | return JSON.parse(data); 112 | } 113 | catch (err) { 114 | throw new Error(`Failed to parse JSON file "${filename}" because of "${err}".`); 115 | } 116 | 117 | } 118 | 119 | /* 120 | * Read in the given file. 121 | */ 122 | function loadFile (filename, ignoreNotFoundErrors = false) { 123 | 124 | return new Promise((resolve, reject) => { 125 | filesystem.readFile(filename, { encoding: `utf8` }, (err, data) => { // eslint-disable-line promise/prefer-await-to-callbacks 126 | if (err && (err.code !== `ENOENT` || !ignoreNotFoundErrors)) { return reject(err); } 127 | return resolve(data || null); 128 | }); 129 | }); 130 | 131 | } 132 | 133 | /* 134 | * Export. 135 | */ 136 | module.exports = { 137 | parseFilename, 138 | discoverFiles, 139 | discoverDirectories, 140 | listDirectory, 141 | isDirectory, 142 | parseJsonFile, 143 | loadFile, 144 | }; 145 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/miscellaneous.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * WORKFLOW: MISCELLANEOUS 5 | * Functions that don't fit into any other sub module of the Workflow Manager. 6 | */ 7 | 8 | const extender = require(`object-extender`); 9 | const utilities = require(`../modules/utilities`); 10 | 11 | /* 12 | * Returns just the base flow URL without the action index. 13 | */ 14 | const __getBaseFlowUri = function __getBaseFlowUri (uri) { 15 | return uri.split(`#`)[0]; 16 | }; 17 | 18 | /* 19 | * Ensures the given input (URI or dynamic flow Object ID) is normalised into a standard flow URI format. 20 | * E.g. "/some/flow" or "/some/flow#4". 21 | */ 22 | function __normaliseFlowUri (input, favourDynamic = false) { 23 | 24 | let normalisedUri = (input || ``).toLowerCase(); 25 | const hasProtocol = Boolean(normalisedUri.match(/^[a-z]+:\/\//i)); 26 | 27 | // Add the protocol if it's missing. 28 | if (!hasProtocol) { 29 | const baseUri = __getBaseFlowUri(normalisedUri); 30 | const protocol = ((!baseUri || normalisedUri.match(/\//)) && !favourDynamic ? `static` : `dynamic`); 31 | normalisedUri = `${protocol}://${normalisedUri.match(/^\//) ? `` : `/`}${normalisedUri}`; 32 | } 33 | 34 | // Remove trailing slashes and hashes, if any, as well as "#0" ("/some/flow#0" is the same as "/some/flow"). 35 | normalisedUri = normalisedUri.replace(/(:\/\/\/(?:.*))\/(#|$)/g, `$1$2`).replace(/(#0?)$/g, ``); 36 | 37 | return normalisedUri; 38 | 39 | } 40 | 41 | /* 42 | * Pulls the flow's URI from its path and normalises it. 43 | */ 44 | function __parseFlowUriFromPath (baseDir, flowFilePath) { 45 | 46 | let uri = flowFilePath.toLowerCase(); 47 | 48 | uri = uri.replace(baseDir.toLowerCase(), ``); 49 | uri = uri.replace(/\//g, `/`); 50 | uri = uri.replace(/\.json$/i, ``); 51 | uri = uri.replace(/\/index$/i, ``); 52 | uri = (uri[0] !== `/` ? `/${uri}` : uri); 53 | 54 | return this.__normaliseFlowUri(uri); 55 | 56 | } 57 | 58 | /* 59 | * Returns true if the two given flow URIs match. If strict is true then we'll match against the full URI including the 60 | * action index e.g. "/some/flow#2" 61 | */ 62 | function __doesFlowUriMatch (inputA, inputB, strict = false) { 63 | 64 | let flowUriA = this.__normaliseFlowUri(inputA); 65 | let flowUriB = this.__normaliseFlowUri(inputB); 66 | 67 | // If we're not in strict mode just match against the base flow URI and not the action index. 68 | if (!strict) { 69 | flowUriA = this.__getBaseFlowUri(flowUriA); 70 | flowUriB = this.__getBaseFlowUri(flowUriB); 71 | } 72 | 73 | return Boolean(flowUriA === flowUriB); 74 | 75 | } 76 | 77 | /* 78 | * Filters the array of options by their conditionals, if any. 79 | */ 80 | function __filterConditionalOptions (options, recUser) { 81 | 82 | if (!options || !options.length) { return []; } 83 | 84 | const variables = extender.merge(this.options.messageVariables, recUser.profile, recUser.appData); 85 | return options.filter(option => this.evaluateConditional(option.conditional, variables)); 86 | 87 | } 88 | 89 | /* 90 | * Returns a dictionary of variables to pass to the hook. 91 | */ 92 | function prepareVariables (recUser) { 93 | 94 | const { profile, appData } = recUser || {}; 95 | const variables = extender.merge(this.options.messageVariables, profile, appData); 96 | 97 | return variables; 98 | 99 | } 100 | 101 | /* 102 | * Executes another hook within the same context of the current hook. 103 | */ 104 | async function executeAnotherHook (recUser, message, input) { 105 | const action = (typeof input === `string` ? { hook: input } : input); 106 | return this.__executeActionExecuteHook(action, recUser, message); 107 | } 108 | 109 | /* 110 | * Send a message to the given user. 111 | */ 112 | async function sendMessage (recUser, message) { 113 | 114 | // Skip if the bot has been disabled for this user. 115 | if (this.skipIfBotDisabled(`send message`, recUser)) { 116 | return false; 117 | } 118 | 119 | const adapterName = recUser.channel.name; 120 | const adapter = this.__dep(`adapter-${adapterName}`); 121 | message.sendDelay = utilities.calculateSendMessageDelay(message, this.options.sendMessageDelay); 122 | 123 | await adapter.markAsTypingOn(recUser); 124 | await utilities.delay(message.sendDelay); 125 | return await adapter.sendMessage(recUser, message); 126 | 127 | } 128 | 129 | /* 130 | * Export. 131 | */ 132 | module.exports = { 133 | __normaliseFlowUri, 134 | __getBaseFlowUri, 135 | __parseFlowUriFromPath, 136 | __doesFlowUriMatch, 137 | __filterConditionalOptions, 138 | prepareVariables, 139 | executeAnotherHook, 140 | sendMessage, 141 | }; 142 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/nlp/luis.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * NLP: LUIS 5 | */ 6 | 7 | const extender = require(`object-extender`); 8 | const RequestNinja = require(`request-ninja`); 9 | const NlpBase = require(`./nlpBase`); 10 | 11 | module.exports = class NlpLuis extends NlpBase { 12 | 13 | /* 14 | * Instantiates the handler. 15 | */ 16 | constructor (_options) { 17 | 18 | // Configure the handler. 19 | super(`nlp`, `luis`); 20 | 21 | // Default config for this handler. 22 | this.options = extender.defaults({ 23 | appId: null, 24 | apiKey: null, 25 | region: `westus`, 26 | spellCheck: true, 27 | timezoneOffset: 0, 28 | isStagingEnv: false, 29 | isDisabled: false, 30 | }, _options); 31 | 32 | this.isDisabled = this.options.isDisabled; 33 | 34 | this.endpoint = [ 35 | `https://${this.options.region}.api.cognitive.microsoft.com/luis/v2.0/apps/${this.options.appId}/`, 36 | `?subscription-key=${this.options.apiKey}&bing-spell-check-subscription-key=${this.options.apiKey}&verbose=true`, 37 | `&staging=${this.options.isStagingEnv}`, 38 | `&spellCheck=${this.options.spellCheck}`, 39 | `&timezoneOffset=${this.options.timezoneOffset}`, 40 | ].join(``); 41 | 42 | } 43 | 44 | /* 45 | * Parse some useful information from the given message. 46 | */ 47 | async parseMessage (messageText) { 48 | 49 | if (this.checkIfDisabled()) { return null; } 50 | 51 | let data; 52 | 53 | // Make the request to the LUIS service and catch any errors. 54 | try { 55 | const req = new RequestNinja(`${this.endpoint}&q=${encodeURIComponent(messageText)}`); 56 | data = await req.get(); 57 | 58 | if (!data || typeof data.query === `undefined`) { 59 | throw new Error((data && (data.Message || data.error)) || 60 | `Unexpected data was returned by LUIS: ${JSON.stringify(data)}`); 61 | } 62 | } 63 | catch (err) { 64 | throw new Error(`Request to LUIS service failed because of "${err}".`); 65 | } 66 | 67 | // Convert LUIS data to Hippocamp format. 68 | return { 69 | intents: this.convertIntentsToInternalFormat(data.intents), 70 | entities: this.convertEntitiesToInternalFormat(data.entities), 71 | 72 | // LUIS does not provide this information without adding the MS Text Analytics API. 73 | language: null, 74 | sentiment: null, 75 | keyPhrases: null, 76 | }; 77 | 78 | } 79 | 80 | /* 81 | * Converts an array of LUIS intents to our internal intent format. 82 | */ 83 | convertIntentsToInternalFormat (luisIntents) { 84 | 85 | const internalIntents = { 86 | $winner: (luisIntents[0] ? this.convertSingleIntentToInternalFormat(luisIntents[0], 0) : null), 87 | $order: [], 88 | }; 89 | 90 | luisIntents.forEach((intentObject, index) => { 91 | const data = this.convertSingleIntentToInternalFormat(intentObject, index); 92 | internalIntents[data.name] = data; 93 | internalIntents.$order.push(data.name); 94 | }); 95 | 96 | return internalIntents; 97 | 98 | } 99 | 100 | /* 101 | * Converts the LUIS intent format for a single given intent to our internal intent format. 102 | */ 103 | convertSingleIntentToInternalFormat (intentObject, index) { 104 | return { 105 | name: this.formatObjectName(intentObject.intent), 106 | $name: intentObject.intent, 107 | score: this.roundScore(intentObject.score), 108 | $score: intentObject.score, 109 | order: index, 110 | }; 111 | } 112 | 113 | /* 114 | * Converts an array of LUIS entities to our internal entity format. 115 | */ 116 | convertEntitiesToInternalFormat (luisEntities) { 117 | 118 | const internalEntities = { 119 | $winner: (luisEntities[0] ? this.convertSingleEntityToInternalFormat(luisEntities[0], 0) : null), 120 | $order: [], 121 | }; 122 | 123 | luisEntities.forEach((entityObject, index) => { 124 | const data = this.convertSingleEntityToInternalFormat(entityObject, index); 125 | internalEntities[data.name] = data; 126 | internalEntities.$order.push(data.name); 127 | }); 128 | 129 | return internalEntities; 130 | 131 | } 132 | 133 | /* 134 | * Converts the LUIS entity format for a single given entity to our internal entity format. 135 | */ 136 | convertSingleEntityToInternalFormat (entityObject, index) { 137 | return { 138 | name: this.formatObjectName(entityObject.type), 139 | $name: entityObject.type, 140 | score: this.roundScore(entityObject.score), 141 | $score: entityObject.score, 142 | matchedText: entityObject.entity, 143 | startIndex: entityObject.startIndex, 144 | endIndex: entityObject.endIndex, 145 | order: index, 146 | }; 147 | } 148 | 149 | }; 150 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/modules/messageObject.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * MESSAGE OBJECT 5 | */ 6 | 7 | const mime = require(`mime`); 8 | const moment = require(`moment`); 9 | 10 | module.exports = class MessageObject { 11 | 12 | /* 13 | * Instantiate a new message. 14 | */ 15 | constructor (inputProperties) { 16 | 17 | const defaultProperties = { 18 | direction: null, 19 | userId: null, 20 | channelName: null, 21 | channelUserId: null, 22 | channelMessageId: null, 23 | sendDelay: null, 24 | humanToHuman: null, 25 | batchable: false, 26 | referral: null, 27 | fromAdmin: null, 28 | sentAt: new Date(), 29 | text: ``, 30 | options: null, 31 | buttons: null, 32 | carousel: null, 33 | attachments: null, 34 | }; 35 | 36 | // Add in each allowed property in turn transforming them if necessary. 37 | for (const key in defaultProperties) { 38 | if (!defaultProperties.hasOwnProperty(key)) { continue; } 39 | 40 | const input = inputProperties[key]; 41 | let value; 42 | 43 | // Property was not set. 44 | if (typeof input === `undefined`) { continue; } 45 | 46 | // Prerpare the property's value. 47 | switch (key) { 48 | 49 | case `text`: 50 | value = (input || defaultProperties[key] || ``).trim(); 51 | break; 52 | 53 | case `sentAt`: 54 | value = moment(input || defaultProperties[key]).toDate(); 55 | break; 56 | 57 | case `options`: 58 | if (input && !Array.isArray(input)) { throw new Error(`The "options" property must be an array.`); } 59 | 60 | // Check each option. 61 | value = input.map(option => { 62 | if (!option.label) { throw new Error(`All options must have a label!`); } 63 | return option; 64 | }); 65 | break; 66 | 67 | case `buttons`: 68 | if (input && !Array.isArray(input)) { throw new Error(`The "options" property must be an array.`); } 69 | 70 | // Check each button. 71 | value = input.map(button => { 72 | if (!button.label) { throw new Error(`All buttons must have a label!`); } 73 | button.type = button.type || `basic`; 74 | return button; 75 | }); 76 | break; 77 | 78 | case `attachments`: 79 | if (input && !Array.isArray(input)) { throw new Error(`The "attachments" property must be an array.`); } 80 | 81 | // Check each attachment, add missing mime types and convert data to buffers. 82 | value = input.map(attachment => { 83 | 84 | if (!attachment.filename) { throw new Error(`All attachments must have a filename!`); } 85 | 86 | attachment.type = attachment.type || `file`; 87 | attachment.mimeType = attachment.mimeType || mime.getType(attachment.filename); 88 | 89 | if (attachment.data) { 90 | attachment.data = Buffer.from(attachment.data); 91 | } 92 | else if (!attachment.remoteUrl) { 93 | throw new Error(`Attachments must have either "data" or "remoteUrl" set.`); 94 | } 95 | 96 | return attachment; 97 | 98 | }); 99 | break; 100 | 101 | // All other properties can just be added directly without being transformed. 102 | default: 103 | value = inputProperties[key]; 104 | break; 105 | 106 | } 107 | 108 | // Set the prepared value on this message. 109 | this[key] = value; 110 | 111 | } 112 | 113 | // Check for required properties. 114 | if (!this.direction) { throw new Error(`The "direction" property is always required for messages.`); } 115 | if (!this.userId && (!this.channelName || !this.channelUserId)) { 116 | throw new Error(`Messages always require either "userId" or "channelName" and "channelUserId" properties.`); 117 | } 118 | 119 | if ( 120 | !this.text && 121 | (!Array.isArray(this.options) || !this.options.length) && 122 | (!Array.isArray(this.buttons) || !this.buttons.length) && 123 | (!Array.isArray(this.attachments) || !this.attachments.length) && 124 | !this.carousel 125 | ) { 126 | throw new Error( 127 | `The "text" propery is required for messages that don't have options, buttons, attachments or a carousel.` 128 | ); 129 | } 130 | 131 | } 132 | 133 | /** 134 | * Static factory method for constructing an outgoing MessageObject. 135 | * 136 | * @param {Object} recUser - User record 137 | * @param {Object} options - Options for the new MessageObject. Direction and user options are filled automatically 138 | * @returns {module.MessageObject} A new outgoing MessageObject with given options. 139 | */ 140 | static outgoing (recUser, options = {}) { 141 | options.direction = `outgoing`; 142 | options.channelName = recUser.channel.name; 143 | options.channelUserId = recUser.channel.userId; 144 | return new MessageObject(options); 145 | } 146 | 147 | }; 148 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/handlers/adapters/facebook/sendMessage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const convertToFacebookMessage = require(`./convertToFacebookMessage`); 4 | const makeRequest = require(`./makeRequest`); 5 | 6 | /* 7 | * Sends a single message within a single request to the Facebook API. 8 | */ 9 | async function sendSingleMessage (recUser, message, resources) { 10 | 11 | const { 12 | sharedLogger, 13 | apiUri, 14 | executeOutgoingMessageMiddleware, 15 | } = resources; 16 | const postDataList = []; 17 | 18 | // Run outgoing middleware and start typing. 19 | await executeOutgoingMessageMiddleware(message, null); 20 | 21 | // Convert the message to the Facebook shape. 22 | postDataList.push(... await convertToFacebookMessage(recUser.channel.userId, message, resources)); 23 | 24 | // Send messages in order. 25 | const sendMessages = postDataList.reduce( 26 | (chain, postData) => chain.then(() => makeRequest(apiUri, postData, resources)), // eslint-disable-line promise/prefer-await-to-then 27 | Promise.resolve() 28 | ); 29 | 30 | // Wait for them all to complete sending. 31 | try { 32 | await sendMessages; 33 | } 34 | catch (err) { 35 | sharedLogger.error(`Failed to send a single message via the Facebook API because of "${err}".`); 36 | sharedLogger.error(err); 37 | } 38 | 39 | } 40 | 41 | /* 42 | * Sends all messages in the queue as a single batch to the Facebook API. 43 | */ 44 | async function sendBatchOfMessages (queue) { 45 | 46 | // Nothing to do if there are no items waiting in the queue. 47 | if (!queue.length) { return; } 48 | 49 | const resources = queue[0].resources; 50 | const { 51 | sharedLogger, 52 | apiUri, 53 | executeOutgoingMessageMiddleware, 54 | } = resources; 55 | const postDataList = []; 56 | 57 | // Convert messages to Facebook format and run outgoing middleware. 58 | const conversionPromises = queue.map(async queuedItem => { 59 | 60 | await executeOutgoingMessageMiddleware(queuedItem.message, null); 61 | const fbMessages = await convertToFacebookMessage(queuedItem.message.channelUserId, queuedItem.message, resources); 62 | postDataList.push(...fbMessages); 63 | 64 | }); 65 | 66 | await Promise.all(conversionPromises); 67 | 68 | // Send messages in one big batch. 69 | try { 70 | await makeRequest(apiUri, postDataList, resources); 71 | } 72 | catch (err) { 73 | sharedLogger.error(`Failed to send a batch of messages via the Facebook API because of "${err}".`); 74 | sharedLogger.error(err); 75 | } 76 | 77 | } 78 | 79 | /* 80 | * Returns true if batching is enabled and the given message is batchable. 81 | */ 82 | function batchingIsAllowed (message, messageBatching) { 83 | return Boolean(message.batchable && messageBatching.maxQueueSize > 1); 84 | } 85 | 86 | /* 87 | * Returns true if batching is enabled. 88 | */ 89 | function queueIsFull (messageBatching) { 90 | return Boolean(messageBatching.queue.length >= messageBatching.maxQueueSize); 91 | } 92 | 93 | /* 94 | * Sends all the messages waiting in the queue in the order they were added. 95 | */ 96 | async function flushQueue (messageBatching) { 97 | 98 | // Kill any existing timeout. 99 | if (messageBatching.timeoutId) { 100 | clearTimeout(messageBatching.timeoutId); 101 | messageBatching.timeoutId = null; 102 | } 103 | 104 | // Send all messages in the queue. 105 | await sendBatchOfMessages(messageBatching.queue); 106 | 107 | // Empty the queue. 108 | messageBatching.queue.splice(0); 109 | 110 | // Prepare the next timeout to drain the queue. 111 | const executeFn = flushQueue.bind(this, messageBatching); 112 | messageBatching.timeoutId = setTimeout(executeFn, messageBatching.queueFlushIntervalMs); 113 | 114 | } 115 | 116 | /* 117 | * Adds a new message to the queue. 118 | */ 119 | async function enqueueMessage (recUser, message, messageBatching, resources) { 120 | 121 | // Flush the existing queue immediately before adding the new message if the queue is already full. 122 | if (queueIsFull(messageBatching)) { 123 | await flushQueue(messageBatching); 124 | } 125 | 126 | // Add new message to queue. 127 | messageBatching.queue.push({ 128 | channelUserId: recUser.channel.userId, 129 | queueEntryTime: Date.now(), 130 | message, 131 | resources, 132 | }); 133 | 134 | } 135 | 136 | /* 137 | * Sends a message immediately or with batching if appropriate. 138 | */ 139 | module.exports = async function sendMessage (recUser, message, messageBatching, resources) { 140 | 141 | // Send message immediately if batching is not allowed for any reason. 142 | if (!batchingIsAllowed(message, messageBatching)) { 143 | await sendSingleMessage(recUser, message, resources); 144 | return; 145 | } 146 | 147 | // Send message in a batch. 148 | await enqueueMessage(recUser, message, messageBatching, resources); 149 | 150 | }; 151 | -------------------------------------------------------------------------------- /app/readServer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * READ SERVER 5 | */ 6 | 7 | /* eslint no-console: 0 */ 8 | 9 | const config = require(`./modules/initConfig`); 10 | 11 | const http = require(`http`); 12 | const Hippocamp = require(`@atchai/hippocamp`); 13 | const DatabaseMongo = Hippocamp.require(`databases/mongo`); 14 | const AnalyticsSegment = Hippocamp.require(`analytics/segment`); 15 | const ArticleModel = require(`./models/article`); 16 | const UserModel = require(`@atchai/hippocamp/lib/models/user`); 17 | 18 | // Instantiate the database. 19 | const database = new DatabaseMongo(config.databases.mongo); 20 | Hippocamp.prepareDependencies(database); 21 | database.addModel(ArticleModel); 22 | database.addModel(UserModel); 23 | 24 | // Initialise Segment. 25 | const analytics = new AnalyticsSegment(config.analytics.segment); 26 | 27 | /* 28 | * Pulls the feed, article and user IDs from the URL. 29 | * URL format: https://eyewitness.the-amity-gazette.com:5000/{{FEED_ID}}/{{ARTICLE_ID}}/{{USER_ID}}/ 30 | */ 31 | function parseIncomingUrl (url) { 32 | 33 | const [ , feedId, articleId, userId, noTrackStr ] = 34 | url.match(/^\/([a-z0-9]+)\/([a-z0-9]+)\/([a-z0-9]+)\/?(?:\?(notrack(?:\=\d)?))?$/i) || []; 35 | 36 | return { 37 | feedId, 38 | articleId, 39 | userId, 40 | noTrack: Boolean(noTrackStr === `notrack=1` || noTrackStr === `notrack`), 41 | }; 42 | 43 | } 44 | 45 | /* 46 | * Handles requests to the health check endpoint. 47 | */ 48 | function handleHealthCheckRoute (res) { 49 | 50 | const body = `{"healthy":true}`; 51 | 52 | res.writeHead(200, { 53 | 'Content-Length': Buffer.byteLength(body), 54 | 'Content-Type': `application/json`, 55 | }); 56 | 57 | res.end(body); 58 | 59 | } 60 | 61 | /* 62 | * Sends an error response to the client. 63 | */ 64 | function sendErrorResponse (res, statusCode = 400, message = `An unknown error occured.`) { 65 | res.statusCode = statusCode; 66 | res.end(message); 67 | } 68 | 69 | /* 70 | * Tracks the user's reading of an article in our database and via the analytics handler(s). 71 | */ 72 | async function trackUserArticleRead (recUser, recArticle) { 73 | 74 | // Mark the article as read by the given user. 75 | await database.update(`Article`, recArticle, { 76 | $addToSet: { _readByUsers: recUser._id }, 77 | }); 78 | 79 | analytics.trackPageView(recUser, recArticle.articleUrl, recArticle.title, { 80 | articleId: recArticle._id.toString(), 81 | priority: (recArticle.isPriority ? `breaking-news` : `normal`), 82 | status: (recArticle.isPublished ? `published` : `unpublished`), 83 | }); 84 | 85 | } 86 | 87 | /* 88 | * Handles incoming requests. 89 | */ 90 | async function handleRequests (req, res) { 91 | 92 | if (req.url === `/health-check`) { 93 | handleHealthCheckRoute(res); 94 | return; 95 | } 96 | 97 | // Pull the IDs from the URL. 98 | const { feedId, articleId, userId, noTrack } = parseIncomingUrl(req.url); 99 | 100 | if (!feedId || !articleId || !userId) { 101 | sendErrorResponse(res, 400, `Invalid URL.`); 102 | return; 103 | } 104 | 105 | // Check the user exists in the database. 106 | const recUser = await database.get(`User`, { 107 | _id: userId, 108 | }); 109 | 110 | if (!recUser) { 111 | sendErrorResponse(res, 404, `User not found.`); 112 | return; 113 | } 114 | 115 | // Check the article exists in the database. 116 | const recArticle = await database.get(`Article`, { 117 | _id: articleId, 118 | feedId, 119 | }); 120 | 121 | if (!recArticle) { 122 | sendErrorResponse(res, 404, `Article not found.`); 123 | return; 124 | } 125 | 126 | // Track the read, if allowed. 127 | if (!noTrack) { 128 | await trackUserArticleRead(recUser, recArticle); 129 | } 130 | 131 | // Redirect the user to the article URL. 132 | res.writeHead(302, { 'Location': recArticle.articleUrl }); 133 | res.end(); 134 | 135 | } 136 | 137 | /* 138 | * Boots the read server and starts listening for incoming requests. 139 | */ 140 | async function startReadServer () { 141 | 142 | return await new Promise((resolve, reject) => { 143 | 144 | const port = process.env.PORT || config.readServer.ports.internal; 145 | const server = http.createServer(handleRequests); 146 | 147 | server.listen(port, err => { 148 | 149 | if (err) { 150 | console.error(`Failed to start read server on port ${port}.`); 151 | return reject(err); 152 | } 153 | 154 | console.log(`Read server running on port ${port}.`); 155 | return resolve(); 156 | 157 | }); 158 | 159 | }); 160 | 161 | } 162 | 163 | /* 164 | * Run task. 165 | */ 166 | Promise.resolve() 167 | .then(() => database.connect()) // eslint-disable-line promise/prefer-await-to-then 168 | .then(() => startReadServer()) // eslint-disable-line promise/prefer-await-to-then 169 | .catch(err => { // eslint-disable-line promise/prefer-await-to-callbacks 170 | console.error(err.stack); 171 | process.exit(1); 172 | }); 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eyewitness Chatbot 2 | Chatbot to allow people to receive news from providers and submit their own stories. 3 | 4 | ## Dependencies 5 | 6 | #### Required 7 | * Node.js (see the "engines" property in package.json for supported versions). 8 | * Npm package manager. 9 | 10 | #### Optional 11 | * [Docker](https://www.docker.com/community-edition#/download) 17+ (for local testing) 12 | * [Ngrok](https://ngrok.com/) (for local testing) 13 | * MongoDB (if not using Docker for local testing) 14 | 15 | ## Local Development 16 | When developing locally I like to use Docker (for environment encapsulation). I also use multiple terminal windows/tabs rather than starting all the Docker containers in one window as this makes it easier to read the application's terminal output. 17 | 18 | ### Install 19 | 20 | ### Run 21 | 22 | 1. Open a terminal window and run ngrok with: `npm run ngrok` or `ngrok http 5000 --region eu -subdomain={{CUSTOM_SUBDOMAIN}}`. 23 | 2. Open a second terminal window and run MongoDB and MongoClient with: `docker-compose up mongodb mongoclient`. 24 | 3. Open a third terminal window and run the example chatbot with: `docker-compose up bot`. 25 | 26 | ## Deploying Eyewitness 27 | You must use one of the "deploy" scripts to deploy Eyewitness automatically. For instructions on how to setup the hosting, please refer to the DEPLOY.md file. 28 | 29 | Deployments need to be performed for: 30 | 31 | - The Eyewitness bot and Read Server - using deploy commands in this repo. 32 | - The UI in the [Eyewitness UI repo](https://github.com/atchai/eyewitness-ui) 33 | 34 | The deployment commands (described below) are identical for both repositories. 35 | 36 | ### Configuration files 37 | 38 | There are a number of configuration files for the different providers and environments (development/staging/production). 39 | The configuration files use a system of inheritance to avoid duplication, managed by the [Config-Ninja](https://github.com/saikojosh/Config-Ninja) package. 40 | 41 | The config for development is `app/config/development.config.json` which inherits from `app/config/production.config.json`. 42 | You may optionally add `app/config/local.config.json` to override the standard development configuration. 43 | 44 | For each provider there is a config for staging 45 | `app/config/providers/[provider ID].staging.config.json` which inherits from 46 | `app/config/staging.config.json` which inherits from 47 | `app/config/production.config.json` 48 | 49 | For each provider there is a config for production 50 | `app/config/providers/[provider ID].production.config.json` which inherits from 51 | `app/config/production.config.json` 52 | 53 | ### Pre-deployment commands 54 | 55 | [Install Heroku CLI](https://cli.heroku.com) and then login: 56 | 57 | ``` 58 | heroku login 59 | heroku container:login 60 | ``` 61 | 62 | ### Staging Deployment Commands 63 | To deploy one of the media providers' services to staging run the appropriate command: 64 | 65 | ``` 66 | npm run deploy-demo-staging 67 | npm run deploy-battabox-staging 68 | npm run deploy-sabc-staging 69 | npm run deploy-thestar-staging 70 | ``` 71 | or to deploy all: 72 | 73 | ``` 74 | npm run deploy-all-staging 75 | ``` 76 | 77 | ### Production Deployment Commands 78 | To deploy one of the media providers' services to production run the appropriate command: 79 | 80 | ``` 81 | npm run deploy-demo-production 82 | npm run deploy-battabox-production 83 | npm run deploy-sabc-production 84 | npm run deploy-thestar-production 85 | ``` 86 | 87 | or to deploy all: 88 | 89 | ``` 90 | npm run deploy-all-production 91 | ``` 92 | 93 | ### Verifying a deployment 94 | 95 | After deploying to a staging or production environment you should check it is working. 96 | 97 | To verify the bot: 98 | 99 | 1. Find the provider name from the bot config file for the provider. 100 | 2. Search for the bot with that name on Facebook messenger (you need to be given access for non-production environments) 101 | 3. You can then type `$whoami` or `$debug` to check version info, and chat to the bot to test features that were changed. 102 | 103 | To verify the UI: 104 | 105 | 1. Find the URL for the UI in the `uiServer` property of the bot config file for the provider. 106 | 2. Open the URL in the browser, log-in with the credentials under `basicAuth` from the config file in the UI repo 107 | 3. Also try appending `/health-check` the URL to check the version number. 108 | 109 | ## Handy Database Queries 110 | 111 | ### Total users 112 | Returns the total number of user documents in the database. 113 | `db.user.count()` 114 | 115 | ### Total articles 116 | Returns the total number of article documents in the database. 117 | `db.article.count()` 118 | 119 | ### Total aggregate article reads 120 | Returns the aggregated number of article reads across all articles. 121 | ``` 122 | db.article.aggregate([ 123 | { $unwind: "$_readByUsers" }, 124 | { $group: { _id: {}, count: { "$sum": 1 } } }, 125 | { $group: { _id: {}, numReads: { $push: { _readByUsers: "$_id.readByUsers", count: "$count" } } } } 126 | ]) 127 | ``` 128 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/actions/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * WORKFLOW: ACTIONS 5 | * Functions for executing actions specified in conversation flows. 6 | */ 7 | 8 | const __executeActionChangeFlow = require(`./changeFlow`); 9 | const __executeActionDelay = require(`./delay`); 10 | const __executeActionDisableBot = require(`./disableBot`); 11 | const __executeActionEnableBot = require(`./enableBot`); 12 | const __executeActionExecuteHook = require(`./executeHook`); 13 | const __executeActionMarkAsTyping = require(`./markAsTyping`); 14 | const __executeActionScheduleTask = require(`./scheduleTask`); 15 | const __executeActionSendMessage = require(`./sendMessage`); 16 | const __executeActionTrackEvent = require(`./trackEvent`); 17 | const __executeActionTrackUser = require(`./trackUser`); 18 | const __executeActionUpdateMemory = require(`./updateMemory`); 19 | const __executeActionWipeMemory = require(`./wipeMemory`); 20 | 21 | /* 22 | * Executes a single action. Returns true if subsequent actions are allowed to be executed, or false otherwise. 23 | */ 24 | async function executeSingleAction (action, recUser, resources, sharedLogger) { 25 | 26 | const { index, typeName, typeValue, message } = resources; 27 | const templateVariables = this.prepareVariables(recUser); 28 | 29 | // If we have a conditional, make sure it evaluates to a truthy value, otherwise we skip this action. 30 | if (!this.evaluateConditional(action.conditional, templateVariables)) { 31 | sharedLogger.silly(`Skipping action #${index + 1} "${action.type}" in ${typeName} "${typeValue}".`); 32 | return true; 33 | } 34 | 35 | sharedLogger.silly(`Executing action #${index + 1} "${action.type}" in ${typeName} "${typeValue}"...`); 36 | 37 | // What do we need to do for this action? 38 | switch (action.type) { 39 | 40 | case `change-flow`: 41 | await this.__executeActionChangeFlow(action, recUser, message); // eslint-disable-line no-await-in-loop 42 | return false; // No further actions should be executed after moving to another flow. 43 | 44 | case `delay`: 45 | await this.__executeActionDelay(action, recUser); // eslint-disable-line no-await-in-loop 46 | break; 47 | 48 | case `disable-bot`: 49 | await this.__executeActionDisableBot(action, recUser); // eslint-disable-line no-await-in-loop 50 | return false; // No further actions should be executed after disabling the bot. 51 | 52 | case `enable-bot`: 53 | await this.__executeActionEnableBot(action, recUser); // eslint-disable-line no-await-in-loop 54 | break; 55 | 56 | case `execute-hook`: { 57 | const continueWithActions = await this.__executeActionExecuteHook(action, recUser, message); // eslint-disable-line no-await-in-loop 58 | if (!continueWithActions) { return false; } // No further actions should be executed if a hook fails. 59 | break; 60 | } 61 | 62 | case `mark-as-typing`: 63 | await this.__executeActionMarkAsTyping(action, recUser); // eslint-disable-line no-await-in-loop 64 | break; 65 | 66 | case `schedule-task`: 67 | await this.__executeActionScheduleTask(action, recUser); // eslint-disable-line no-await-in-loop 68 | break; 69 | 70 | case `send-message`: 71 | await this.__executeActionSendMessage(action, recUser); // eslint-disable-line no-await-in-loop 72 | break; 73 | 74 | case `track-event`: 75 | await this.__executeActionTrackEvent(action, recUser); // eslint-disable-line no-await-in-loop 76 | break; 77 | 78 | case `track-user`: 79 | await this.__executeActionTrackUser(action, recUser); // eslint-disable-line no-await-in-loop 80 | break; 81 | 82 | case `update-memory`: { 83 | const continueWithActions = await this.__executeActionUpdateMemory(action, recUser); // eslint-disable-line no-await-in-loop 84 | if (!continueWithActions) { return false; } // No further actions should be executed if memory update fails. 85 | break; 86 | } 87 | 88 | case `wipe-memory`: 89 | await this.__executeActionWipeMemory(action, recUser); // eslint-disable-line no-await-in-loop 90 | break; 91 | 92 | default: throw new Error(`Invalid action "${action.type}".`); 93 | 94 | } 95 | 96 | // Allow any subsequent actions to be executed. 97 | return true; 98 | 99 | } 100 | 101 | /* 102 | * Execute all the given actions in order. Returns true if the current flow can continue, or false if the current flow 103 | * must stop because one of the actions redirected us to a new flow. 104 | */ 105 | async function executeActions (typeName, typeValue, actions, recUser, message) { 106 | 107 | if (!actions || !actions.length) { return true; } 108 | 109 | const sharedLogger = this.__dep(`sharedLogger`); 110 | 111 | // Skip if the bot has been disabled for this user. 112 | if (this.skipIfBotDisabled(`execute actions`, recUser)) { 113 | return false; 114 | } 115 | 116 | // Iterate over the actions in the order they are defined. 117 | for (let index = 0; index < actions.length; index++) { 118 | const action = actions[index]; 119 | const resources = { index, typeName, typeValue, message }; 120 | const proceedToNextAction = await executeSingleAction.call(this, action, recUser, resources, sharedLogger); // eslint-disable-line no-await-in-loop 121 | 122 | if (!proceedToNextAction) { return false; } 123 | } 124 | 125 | return true; 126 | 127 | } 128 | 129 | /* 130 | * Export. 131 | */ 132 | module.exports = { 133 | executeActions, 134 | __executeActionChangeFlow, 135 | __executeActionDelay, 136 | __executeActionDisableBot, 137 | __executeActionEnableBot, 138 | __executeActionExecuteHook, 139 | __executeActionMarkAsTyping, 140 | __executeActionScheduleTask, 141 | __executeActionSendMessage, 142 | __executeActionTrackEvent, 143 | __executeActionTrackUser, 144 | __executeActionUpdateMemory, 145 | __executeActionWipeMemory, 146 | }; 147 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/workflow/linkTracking.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * WORKFLOW: LINK TRACKING 5 | * Functions for dealing with tracking links. 6 | */ 7 | 8 | const { URL } = require(`url`); 9 | const moment = require(`moment`); 10 | 11 | /* 12 | * Returns decoded variables from the given tracking URL. 13 | */ 14 | function __extractTrackingLinkVariables (input) { 15 | 16 | const fullUrl = new URL(input, this.options.baseUrl); 17 | const originalUrl = decodeURI(fullUrl.searchParams.get(`originalUrl`) || ``) || null; 18 | const messageId = fullUrl.searchParams.get(`messageId`) || null; 19 | const linkType = fullUrl.searchParams.get(`linkType`) || null; 20 | const linkIndex1 = fullUrl.searchParams.get(`linkIndex1`) || null; 21 | const linkIndex2 = fullUrl.searchParams.get(`linkIndex2`) || null; 22 | 23 | return { originalUrl, messageId, linkType, linkIndex1, linkIndex2 }; 24 | 25 | } 26 | 27 | /* 28 | * Returns a dictionary of changes to apply to the message record. 29 | */ 30 | function __prepareMessageRecordChanges (linkType, linkIndex1, linkIndex2, recMessage) { 31 | 32 | const changes = {}; 33 | const nowDate = moment.utc().toDate(); 34 | let propertyBasePath; 35 | let existingObject; 36 | 37 | // Find the base path to the object we must update and the object itself. 38 | switch (linkType) { 39 | 40 | case `button`: 41 | propertyBasePath = `data.buttons.${linkIndex1}`; 42 | existingObject = recMessage.data.buttons[linkIndex1]; 43 | break; 44 | 45 | case `carousel-default`: 46 | propertyBasePath = `data.carousel.elements.${linkIndex1}.defaultAction`; 47 | existingObject = recMessage.data.carousel.elements[linkIndex1].defaultAction; 48 | break; 49 | 50 | case `carousel-button`: 51 | propertyBasePath = `data.carousel.elements.${linkIndex1}.buttons.${linkIndex2}`; 52 | existingObject = recMessage.data.carousel.elements[linkIndex1].buttons[linkIndex2]; 53 | break; 54 | 55 | default: throw new Error(`Invalid link type "${linkType}".`); 56 | 57 | } 58 | 59 | changes.$inc = { [`${propertyBasePath}.numVisits`]: 1 }; 60 | changes[`${propertyBasePath}.lastVisit`] = nowDate; 61 | 62 | if (!existingObject.firstVisit) { 63 | changes[`${propertyBasePath}.firstVisit`] = nowDate; 64 | } 65 | 66 | return changes; 67 | 68 | } 69 | 70 | /* 71 | * Returns the label of the button clicked for the link we're tracking. 72 | */ 73 | function __getLinkTrackButtonLabel (linkType, linkIndex1, linkIndex2, recMessage) { 74 | 75 | switch (linkType) { 76 | case `button`: return recMessage.data.buttons[linkIndex1].label || null; 77 | case `carousel-default`: return recMessage.data.carousel.elements[linkIndex1].defaultAction.label || null; 78 | case `carousel-button`: return recMessage.data.carousel.elements[linkIndex1].buttons[linkIndex2].label || null; 79 | default: throw new Error(`Invalid link type "${linkType}".`); 80 | } 81 | 82 | } 83 | 84 | /* 85 | * Mark the link in the message as visited by the user, if we have the message details. Returns the message record or 86 | * null if we don't have the message details required to find it. 87 | */ 88 | async function __markMessageLinkAsVisited (database, linkVariables, trackingVariables) { 89 | 90 | const { messageId, linkType, linkIndex1, linkIndex2 } = linkVariables; 91 | 92 | if (!messageId || !linkType || !linkIndex1) { 93 | return null; 94 | } 95 | 96 | const recMessage = await database.get(`Message`, { _id: messageId }); 97 | trackingVariables.messageText = recMessage.data.text || null; 98 | trackingVariables.buttonLabel = this.__getLinkTrackButtonLabel(linkType, linkIndex1, linkIndex2, recMessage); 99 | 100 | const changes = this.__prepareMessageRecordChanges(linkType, linkIndex1, linkIndex2, recMessage); 101 | await database.update(`Message`, recMessage, changes); 102 | 103 | return recMessage; 104 | 105 | } 106 | 107 | /* 108 | * Track the analytics event for tracking links, if analytics is configured and enabled. 109 | */ 110 | async function __trackLinkClickThroughAnalyticsHandlers (database, recMessage, trackingVariables) { 111 | 112 | const analytics = this.__dep(`analytics`); 113 | const analyticsEventName = this.options.linkTracking.analyticsEventName; 114 | 115 | if (!Object.keys(analytics).length || !this.options.enableEventTracking || !analyticsEventName || !recMessage) { 116 | return; 117 | } 118 | 119 | const recUser = await database.get(`User`, { _id: recMessage._user }); 120 | trackingVariables.userId = recUser._id.toString(); 121 | 122 | const analyticsPromises = Object.values(analytics) 123 | .map(handler => handler.trackEvent(recUser, analyticsEventName, trackingVariables)); 124 | 125 | await Promise.all(analyticsPromises); 126 | 127 | } 128 | 129 | /* 130 | * Handle requests to the link tracking server. 131 | */ 132 | async function handleLinkTrackingRequests (req, res) { 133 | 134 | const sharedLogger = this.__dep(`sharedLogger`); 135 | 136 | try { 137 | 138 | sharedLogger.debug(`Tracking a link click.`); 139 | 140 | const database = this.__dep(`database`); 141 | const linkVariables = this.__extractTrackingLinkVariables(req.url); 142 | const trackingVariables = { ...linkVariables }; 143 | 144 | sharedLogger.verbose({ text: `Link variables.`, ...linkVariables }); 145 | 146 | const recMessage = await this.__markMessageLinkAsVisited(database, linkVariables, trackingVariables); 147 | await this.__trackLinkClickThroughAnalyticsHandlers(database, recMessage, trackingVariables); 148 | 149 | sharedLogger.silly({ text: `Tracking variables.`, ...trackingVariables }); 150 | 151 | // Trigger any attached external event listeners. 152 | await this.triggerEvent(`track-link`, trackingVariables); 153 | 154 | // Redirect to the original URL now that we've done tracking the visit; 155 | res.redirect(303, linkVariables.originalUrl); 156 | 157 | } 158 | catch (err) { 159 | sharedLogger.error(`Failed to track link.`); 160 | sharedLogger.error(err); 161 | res.status(500).respond(err.message, false); 162 | } 163 | 164 | } 165 | 166 | /* 167 | * Export. 168 | */ 169 | module.exports = { 170 | __extractTrackingLinkVariables, 171 | __prepareMessageRecordChanges, 172 | __getLinkTrackButtonLabel, 173 | __markMessageLinkAsVisited, 174 | __trackLinkClickThroughAnalyticsHandlers, 175 | handleLinkTrackingRequests, 176 | }; 177 | -------------------------------------------------------------------------------- /lib/hippocamp/lib/server/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * SERVER 5 | */ 6 | 7 | const http = require(`http`); 8 | const url = require(`url`); 9 | const escapeRegExp = require(`escape-regexp`); 10 | const MiddlewareEngine = require(`middleware-engine`); 11 | const extender = require(`object-extender`); 12 | 13 | module.exports = class Server extends MiddlewareEngine { 14 | 15 | /* 16 | * Instantiates a new server. 17 | */ 18 | constructor (_options) { 19 | 20 | // Configure the middleware engine. 21 | super(); 22 | 23 | // Default values for just the options we care about. 24 | this.options = extender.defaults({ 25 | port: null, 26 | }, _options); 27 | 28 | this.server = http.createServer(this.handleIncomingRequest.bind(this)); 29 | this.mountPoints = {}; 30 | 31 | } 32 | 33 | /* 34 | * Mounts the given request handler function using the given base path as the mount point. Any requests that match the 35 | * base path will be passed over to the request handler. 36 | */ 37 | mount (_basePath, requestHandler) { 38 | 39 | const basePath = (_basePath[0] === `/` ? _basePath : `/${_basePath}`); 40 | const mountPoint = escapeRegExp(basePath); 41 | 42 | // Doesn't make sense to allow two request handlers to be mounted to the same base path. 43 | if (this.mountPoints[mountPoint]) { 44 | throw new Error(`A request handler is already mounted on "${basePath}".`); 45 | } 46 | 47 | this.mountPoints[mountPoint] = { basePath, requestHandler }; 48 | 49 | } 50 | 51 | /* 52 | * Start the server listing, if no port is provided we'll use the one provided to the constructor. 53 | */ 54 | listen (_port = null) { 55 | 56 | const sharedLogger = this.__dep(`sharedLogger`); 57 | const port = _port || this.options.port; 58 | 59 | sharedLogger.info(`Starting server...`); 60 | 61 | if (!port) { throw new Error(`You must specify a port for the server!`); } 62 | 63 | return new Promise((resolve, reject) => { 64 | this.server.listen(port, err => { // eslint-disable-line promise/prefer-await-to-callbacks 65 | if (err) { return reject(err); } 66 | sharedLogger.info(`Server ready!`); 67 | return resolve(); 68 | }); 69 | }); 70 | 71 | } 72 | 73 | /* 74 | * Processes each request as it comes in. 75 | */ 76 | handleIncomingRequest (req, res) { 77 | /* eslint promise/no-promise-in-callback: 0 */ 78 | 79 | const sharedLogger = this.__dep(`sharedLogger`); 80 | const chunks = []; 81 | 82 | req.on(`error`, err => this.__onIncomingRequestError(err, req, res).catch(err => sharedLogger.error(err))); // eslint-disable-line promise/prefer-await-to-callbacks 83 | req.on(`data`, nextChunk => chunks.push(nextChunk)); 84 | req.on(`end`, () => this.__onIncomingRequestSuccess(req, res, chunks).catch(err => sharedLogger.error(err))); // eslint-disable-line promise/prefer-await-to-callbacks 85 | 86 | } 87 | 88 | /* 89 | * Respond to the consumer after processing the request. 90 | */ 91 | async __onIncomingRequestSuccess (req, res, chunks) { 92 | 93 | const sharedLogger = this.__dep(`sharedLogger`); 94 | const data = chunks.join(``); 95 | const contentType = req.headers[`content-type`] || null; 96 | let requestHandler; 97 | 98 | // Add handy methods to the response object. 99 | res.status = this.__status.bind(this, res); 100 | res.setHeaders = this.__setHeaders.bind(this, res); 101 | res.redirect = this.__redirect.bind(this, res); 102 | res.respond = this.__respond.bind(this, res); 103 | 104 | // Find a handler for this route. 105 | try { 106 | requestHandler = this.__findMountedRequestHandler(req.url); 107 | } 108 | catch (err) { 109 | sharedLogger.error(err); 110 | return res.status(404).respond(`This URL is not correct.`, false); 111 | } 112 | 113 | // Parse the incoming data. 114 | req.body = (contentType && contentType.match(/application\/json/i) ? JSON.parse(data) : data); 115 | req.query = url.parse(req.url, true).query; 116 | 117 | // Log the request as we can handle it. 118 | sharedLogger.verbose({ 119 | text: `Incoming request`, 120 | incomingRequest: { 121 | method: req.method, 122 | url: req.url, 123 | contentType, 124 | body: req.body, 125 | }, 126 | }); 127 | 128 | return requestHandler(req, res); 129 | 130 | } 131 | 132 | /* 133 | * Respond to the consumer if an error occurs whilst processing an incoming request. 134 | */ 135 | async __onIncomingRequestError (_err, req, res) { 136 | 137 | const sharedLogger = this.__dep(`sharedLogger`); 138 | const err = new Error(`Unable to handle the incoming request "${req.url}" because of "${_err}".`); 139 | let logMethod; 140 | 141 | if (_err instanceof Error) { 142 | logMethod = `error`; 143 | err.stack = _err.stack; 144 | } 145 | else { 146 | logMethod = `warn`; 147 | } 148 | 149 | const output = await sharedLogger[logMethod](err); 150 | 151 | // Don't allow the consumer to see the stack trace. 152 | delete output.terminal.error.stack; 153 | res.status(500).respond(output); 154 | 155 | } 156 | 157 | /* 158 | * Returns the first matching request handler that is mounted to the given path. 159 | */ 160 | __findMountedRequestHandler (path) { 161 | 162 | for (const mountPoint in this.mountPoints) { 163 | if (!this.mountPoints.hasOwnProperty(mountPoint)) { continue; } 164 | 165 | const mounted = this.mountPoints[mountPoint]; 166 | const regex = new RegExp(`^${mountPoint}`, `i`); 167 | 168 | if (path.match(regex)) { return mounted.requestHandler; } 169 | } 170 | 171 | throw new Error(`There is no request handler mounted on the URL "${path}" that can handle the request.`); 172 | 173 | } 174 | 175 | /* 176 | * Set the status of the response. 177 | */ 178 | __status (res, code) { 179 | res.statusCode = code; 180 | return res; 181 | } 182 | 183 | /* 184 | * Allows setting multiple headers at once by passing in dictionary. 185 | */ 186 | __setHeaders (res, headers) { 187 | 188 | for (const key in headers) { 189 | if (!headers.hasOwnProperty(key)) { continue; } 190 | 191 | const value = headers[key]; 192 | res.setHeader(key, value); 193 | } 194 | 195 | return res; 196 | 197 | } 198 | 199 | /* 200 | * Helper method for redirecting the request to a new URL. 201 | */ 202 | __redirect (res, code, redirectUrl) { 203 | 204 | res.status(code); 205 | res.setHeaders({ 'Location': redirectUrl }); 206 | res.end(`Redirect ${code}: ${redirectUrl}`); 207 | 208 | } 209 | 210 | /* 211 | * Converts the given data to JSON and sends it. 212 | */ 213 | __respond (res, data, formatJson = true) { 214 | 215 | this.__dep(`sharedLogger`).verbose({ 216 | text: `Sending response to incoming request...`, 217 | responseBody: data || null, 218 | }); 219 | 220 | // If we aren't formatting as JSON just output the data as-is. 221 | if (!formatJson) { return res.end(data); } 222 | 223 | const json = JSON.stringify(data); 224 | 225 | res.setHeaders({ 'Content-Type': `application/json` }); 226 | return res.end(json); 227 | 228 | } 229 | 230 | }; 231 | --------------------------------------------------------------------------------