UNPKG

node-red-contrib-chatbot

Version:

REDBot a Chat bot for a full featured chat bot for Telegram, Facebook Messenger and Slack. Almost no coding skills required

463 lines (419 loc) 17.2 kB
/* eslint-disable no-console */ const serveStatic = require('serve-static'); const path = require('path'); const events = require('events'); const fs = require('fs'); const session = require('express-session'); const _ = require('lodash'); const fileupload = require('express-fileupload'); const cloudinary = require('cloudinary').v2; const fetch = require('node-fetch'); const lcd = require('../lib/lcd/index'); const DatabaseSchema = require('../database/index'); const validators = require('../lib/helpers/validators'); const uploadFromBuffer = require('../lib/helpers/upload-from-buffer'); const chatbotIdGenerator = require('../lib/utils/chatbot-id-generator'); const GetEnvironment = require('../lib/helpers/get-environment'); const { getMigrations } = require('../lib/utils/migration'); const { isEmptyDatabase, tableExists } = require('../lib/utils/database'); const { defineQueueTable } = require('../lib/queues-store/index'); const { REDBOT_ENABLE_MISSION_CONTROL, REDBOT_ENABLE_OTP } = require('../src/env'); let initialized = false; const Events = new events.EventEmitter(); function sendMessage(topic, payload) { Events.emit('message', topic, payload); } // webpack https://webpack.js.org/guides/getting-started/ // from https://github.com/node-red/node-red-dashboard/blob/63da162998c421b43a6e5ebf447ed90e04040aa3/ui.js#L309 // web socket docs // https://github.com/websockets/ws#api-docs // design // https://adminlte.io/themes/v3/index2.html // Inspiration design // https://colorlib.com/wp/free-dashboard-templates/ // clone schema https://demo.uifort.com/bamburgh-admin-dashboard-pro/ // React grid // https://github.com/STRML/react-grid-layout#installation // useQuery // https://www.apollographql.com/docs/react/data/queries/ let _mcSettings = null; // cache it function getMissionControlConfiguration(redSettings) { if (_mcSettings != null) { return _mcSettings; } const mcSettings = redSettings.RedBot || {}; // get current version const jsonPackage = fs.readFileSync(__dirname + '/../package.json'); let packageJson; try { packageJson = JSON.parse(jsonPackage.toString()); } catch(e) { lcd.error('Unable to open node-red-contrib-chatbot/package.json'); } // front end evironment mcSettings.version = packageJson.version; let frontendEnvironment = 'production'; if (process.env.REDBOT_DEVELOPMENT_MODE != null && ( process.env.REDBOT_DEVELOPMENT_MODE.toLowerCase() === 'true' || process.env.REDBOT_DEVELOPMENT_MODE.toLowerCase() === 'dev' || process.env.REDBOT_DEVELOPMENT_MODE.toLowerCase() === 'development' )) { frontendEnvironment = 'development'; } else if (process.env.REDBOT_DEVELOPMENT_MODE != null && process.env.REDBOT_DEVELOPMENT_MODE.toLowerCase() === 'plugin') { frontendEnvironment = 'plugin'; } mcSettings.frontendEnvironment = frontendEnvironment; mcSettings.salt = !_.isEmpty(redSettings.credentialSecret) ? redSettings.credentialSecret : 'redbot-salt'; if (!_.isEmpty(process.env.REDBOT_DB_PATH)) { mcSettings.dbPath = path.join(process.env.REDBOT_DB_PATH, 'mission-control.sqlite'); mcSettings.dbQueuePath = path.join(process.env.REDBOT_DB_PATH, 'queues.sqlite'); } else if (mcSettings.dbPath == null) { mcSettings.dbPath = path.join(redSettings.userDir, 'mission-control.sqlite'); mcSettings.dbQueuePath = path.join(redSettings.userDir, 'queues.sqlite'); } else { mcSettings.dbPath = mcSettings.dbPath.replace(/\/$/, '') + '/mission-control.sqlite'; mcSettings.dbQueuePath = mcSettings.dbPath.replace(/\/$/, '') + '/queues.sqlite'; } if (mcSettings.pluginsPath == null && !fs.existsSync(mcSettings.pluginsPath)) { mcSettings.pluginsPath = path.join(redSettings.userDir, 'dist-plugins'); } if (!fs.existsSync(mcSettings.pluginsPath)) { // try to create it try { fs.mkdirSync(mcSettings.pluginsPath); } catch(e) { console.log(lcd.timestamp() + ' ' + lcd.orange(`Unable to create plugins dir: ${mcSettings.pluginsPath}`)); } } // get root if (mcSettings.root == null) { mcSettings.root = '/mc'; } else { mcSettings.root = mcSettings.root.replace(/\/$/, ''); } if (!_.isEmpty(redSettings.httpAdminRoot)) { mcSettings.root = redSettings.httpAdminRoot.replace(/\/$/, '') + mcSettings.root; } // get host if (mcSettings.host == null) { mcSettings.host = 'localhost'; } // get port mcSettings.port = redSettings.uiPort; _mcSettings = mcSettings; return mcSettings; } async function bootstrap(server, app, log, redSettings, RED) { const mcSettings = getMissionControlConfiguration(redSettings); const { frontendEnvironment } = mcSettings; // check if mission control is enabled if (!(mcSettings.enableMissionControl || REDBOT_ENABLE_MISSION_CONTROL === 'true')) { console.log(lcd.timestamp() + 'Red Bot Mission Control is not enabled.'); console.log(lcd.timestamp() + ' ' + lcd.grey('Enable it running with the REDBOT_ENABLE_MISSION_CONTROL environment variable:')); console.log(lcd.timestamp() + ' ' + lcd.grey(' REDBOT_ENABLE_MISSION_CONTROL=true node-red -u /my-user-dir')); console.log(''); return; } console.log(lcd.timestamp() + 'Red Bot Mission Control configuration:'); console.log(lcd.timestamp() + ' ' + lcd.green('admin root: ') + lcd.grey(redSettings.httpAdminRoot)); console.log(lcd.timestamp() + ' ' + lcd.green('backend environment: ') + lcd.grey(GetEnvironment(RED)())); console.log(lcd.timestamp() + ' ' + lcd.green('front end environment: ') + lcd.grey(frontendEnvironment)); if (mcSettings.salt == 'redbot-salt') { console.log(lcd.timestamp() + ' ' + lcd.green('salt: ') + lcd.grey('default')); } else { console.log(lcd.timestamp() + ' ' + lcd.green('salt: ') + lcd.grey('****')); } console.log(lcd.timestamp() + ' ' + lcd.green('dbPath: ') + lcd.grey(mcSettings.dbPath)); const { passportMiddlewares, passport } = require('../lib/authentication/index')({ dbPath: mcSettings.dbPath, salt: mcSettings.salt }); console.log(lcd.timestamp() + ' ' + lcd.green('pluginsPath: ') + lcd.grey(mcSettings.pluginsPath)); if (mcSettings.pluginsPath == path.join(__dirname, 'dist-plugins')) { console.log(lcd.timestamp() + ' ' + lcd.orange('Warning: external plugin path is the default one in the npm package, the external plugins')); console.log(lcd.timestamp() + ' ' + lcd.orange('will be overwritten if the package is reinstalled, this is good for development but dangerous')); console.log(lcd.timestamp() + ' ' + lcd.orange('for production. Select a different directory with permission rights.')) } console.log(lcd.timestamp() + ' ' + lcd.green('MC root: ') + lcd.grey(mcSettings.root)); console.log(lcd.timestamp() + ' ' + lcd.green('host: ') + lcd.grey(mcSettings.host)); console.log(lcd.timestamp() + ' ' + lcd.green('port: ') + lcd.grey(mcSettings.port)); // get google maps key if (mcSettings.googleMapsKey != null) { console.log(lcd.timestamp() + ' ' + lcd.green('googleMapsKey: ') + lcd.grey(mcSettings.googleMapsKey)); } if (REDBOT_ENABLE_OTP === 'true') { mcSettings.enableOTP = true; } else { mcSettings.enableOTP = false; } console.log(lcd.timestamp() + ' ' + lcd.green('OTP: ') + lcd.grey(mcSettings.enableOTP)); if (validators.credentials.cloudinary(mcSettings.cloudinary)) { console.log(lcd.timestamp() + ' ' + lcd.green('cloudinary name: ') + lcd.grey(mcSettings.cloudinary.cloudName)); console.log(lcd.timestamp() + ' ' + lcd.green('cloudinary apiKey: ') + lcd.grey(mcSettings.cloudinary.apiKey)); console.log(lcd.timestamp() + ' ' + lcd.green('cloudinary apiSecret: ') + lcd.grey('****')); cloudinary.config({ cloud_name: mcSettings.cloudinary.cloudName, api_key: mcSettings.cloudinary.apiKey, api_secret: mcSettings.cloudinary.apiSecret }); } else { mcSettings.cloudinary = null; } const databaseSchema = DatabaseSchema(mcSettings) const { graphQLServer, Category, Content, Admin, ChatBot, Plugin, sequelize, sequelizeTasks } = databaseSchema; // if database doesn't exist, then create it and run sync to create blank tables if (!fs.existsSync(mcSettings.dbPath) || await isEmptyDatabase(sequelize)) { await sequelize.sync({ force: true }); await Admin.create({ username: 'admin', password: '', permissions: '*', chatbotIds: '*' }); await ChatBot.create({ name: 'MyChatbot' }); await Category.create({ name: 'A category', language: 'en', namespace: 'content' }); await Content.create({ title: 'A content', slug: 'my_slug', language: 'en', namespace: 'content', categoryId: 1, body: `A sample content. Some **formatting** is _allowed_!` }); // if database doesn't exist, then upgrade to the latest // update the database to the latest if (sequelize.isDefined('version')) { console.log(lcd.timestamp() + 'Applying migrations...'); const migrations = getMigrations(`${__dirname}/../migrations`); const Version = sequelize.model('version'); await Version.create({ version: _.last(migrations) }); } } // create the default queue if not exists if (!(await tableExists('tasks', sequelizeTasks))) { const tasksModel = defineQueueTable(sequelizeTasks, 'tasks'); await tasksModel.sync({ force: true }); } app.use(session({ secret: mcSettings.salt, resave: true, saveUninitialized: false })); app.use(passportMiddlewares); app.post( `${mcSettings.root}/login`, passport.authenticate('local', { failureRedirect: `${mcSettings.root}/login` }), async function(req, res) { // if not enable, just go if (!mcSettings.enableOTP) { res.redirect(mcSettings.root); return; } const otp = req.body.otp; const otps = await Content.findAll({ where: { namespace: 'otp' } }); // check if there's a valid otp let isValidOTP = false; let k = 0; if (otps.length === 0) { isValidOTP = true; } else { for(k = 0; k < otps.length; k++) { let json; try { json = JSON.parse(otps[k].payload); if (json.otp === otp) { isValidOTP = true; // remove the otp await otps[k].destroy(); } } catch(e) { // do nothing } } } // redirect if (isValidOTP) { res.redirect(mcSettings.root); } else { // destroy session req.logout(); // send failed status res.redirect(`${mcSettings.root}/login`); } } ); // mount graphql endpoints to Node-RED app app.use(async function (req, res, next) { if (req.path === '/graphql' && req.connection.remoteAddress !== '127.0.0.1') { // if not auth with Password (ui) if (!req.isAuthenticated()) { const authHeader = req.headers['Authorization'] || req.headers['authorization']; if (!_.isEmpty(authHeader) && authHeader.includes(' ')) { const authToken = authHeader.split(' ')[1]; const contents = await Content.findAll({ where: { namespace: 'tokens' }}); const authorized = contents.some(content => { let json; try { json = JSON.parse(content.payload); } catch(e) { return false; } return authToken === json.token; }); if (!authorized) { res .status(401) .json({ status: 401, message: 'Unauthorized' }); //res.send('Unauthorized!'); return; } } else { res .status(401) .json({ status: 401, message: 'Unauthorized' }); return; } } } next(); }); graphQLServer.applyMiddleware({ app }); // eslint-disable-next-line no-console console.log(lcd.timestamp() + ' ' + lcd.green('GraphQL URL: ') + lcd.grey(`http://localhost:${mcSettings.port}${graphQLServer.graphqlPath}`)); // handle upload file app.post(`${mcSettings.root}/api/upload`, fileupload(), async (req, res) => { if (mcSettings.cloudinary == null) { res.status(400).send('Missing or invalid Cloudinary credentials'); return; } let result = await uploadFromBuffer(req.files.file.data, cloudinary); res.send({ id: result.public_id, name: result.public_id, width: result.width, height: result.height, format: result.format, size: result.bytes, url: result.url, secure_url: result.secure_url }); }); // serve plugins chunk, only used in dev mode, it changes position everytime, that's the reason of the wildcard // not used in prod app.get(/plugins_js\.main\.js$/, async (req, res) => { const response = await fetch('http://localhost:8080/plugins_js.main.js'); res.send(await response.text()); }); app.use(`${mcSettings.root}/plugins`, serveStatic(mcSettings.pluginsPath, { 'index': false })); app.get(`${mcSettings.root}/chatbotIdGenerator`, (_req, res) => res.send(chatbotIdGenerator())); // serve the login page app.get( `${mcSettings.root}/login`, async (_req, res) => { const admins = await Admin.findAll(); const isDefaultUser = admins.length === 1 && _.isEmpty(admins[0].password); // store in settings if any otps const hasOTPs = (await Content.count({ where: { namespace: 'otp' }})) !== 0; fs.readFile(`${__dirname}/../src/login.html`, (err, data) => { const template = data.toString(); const assets = frontendEnvironment === 'development' || frontendEnvironment === 'plugin' ? 'http://localhost:8080/login.js' : `${mcSettings.root}/assets/login.js`; const bootstrap = { settings: { ...mcSettings, isDefaultUser, hasOTPs, environment: frontendEnvironment } }; const json = `<script> window.process = { env: { NODE_ENV: 'development' }}; var bootstrap = ${JSON.stringify(bootstrap)};var mc_environment='${frontendEnvironment}';</script>`; res.send(template .replace('{{assets}}', assets) .replace('{{data}}', json) ); }); } ); app.use( `${mcSettings.root}/assets`, serveStatic(path.join(__dirname, '../webpack/dist')) ); app.post(`${mcSettings.root}/logout`, function(req, res){ req.logout(); res.redirect('/'); }); // relay messages coming from useSocket, unfortunately Node-RED is not listening for them in /comms app.post( `${mcSettings.root}/publish`, async (req, res) => { if (!_.isEmpty(req.body.topic)) { Events.emit('message', req.body.topic, req.body.payload); } res.sendStatus(200); } ); // serve mission control page and assets app.use( '^' + mcSettings.root, async (req, res) => { // redirect to login page if (!req.isAuthenticated()) { res.redirect(`${mcSettings.root}/login`); return; } // parse the cookies const cookies = req.get('Cookie') .split(';') .map(str => str.trim()) .map(str => str.split('=')) .reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {}); const chatbot = await ChatBot.findOne(); const plugins = !_.isEmpty(cookies.chatbotId) ? await Plugin.findAll({ where: { chatbotId: cookies.chatbotId }}) : []; console.log('Including plugins: ', plugins.map(plugin => plugin.plugin)); // inject user info into template fs.readFile(`${__dirname}/../src/index.html`, (err, data) => { const template = data.toString(); const bootstrap = { chatbot: { ...chatbot.toJSON(), plugins: plugins.map(plugin => plugin.toJSON()) }, user: req.user, settings: { ...mcSettings, environment: frontendEnvironment } }; const assets = frontendEnvironment === 'development' || frontendEnvironment === 'plugin' ? 'http://localhost:8080/main.js' : `${mcSettings.root}/assets/main.js`; // link external plugin scripts only in plugin mode let pluginsScript = []; if (frontendEnvironment === 'plugin' || frontendEnvironment === 'production') { pluginsScript = plugins.map(plugin => `<script src="${mcSettings.root}/plugins/${plugin.filename}"></script>`); } const json = `<script>var bootstrap = ${JSON.stringify(bootstrap)};var mc_environment='${frontendEnvironment}';</script>`; res.send(template.replace('{{data}}', json).replace('{{assets}}', assets).replace('{{plugins}}', pluginsScript.join(''))); }); } ); } module.exports = function(RED) { if (!initialized) { initialized = true; bootstrap(RED.server, RED.httpNode || RED.httpAdmin, RED.log, RED.settings, RED); } // exposed methods return { Events, sendMessage: sendMessage, getMissionControlConfiguration: () => getMissionControlConfiguration(RED.settings) }; };