// Copyright 2018-2020 Campbell Crowley. All rights reserved. // Author: Campbell Crowley (dev@campbellcrowley.com) const dialogflow = require('dialogflow'); const auth = require('../auth.js'); require('./subModule.js').extend(ChatBot); // Extends the SubModule class. /** * @classdesc Manages natural language interaction. * @class * @augments SubModule * @listens Discord#message * @listens Command#chat */ function ChatBot() { const self = this; /** @inheritdoc */ this.myName = 'ChatBot'; /** * @description The guilds that have changed their settings since last save. * * @private * @type {object.<boolean>} * @default */ const settingsUpdated = {}; /** * The guilds that have disabled the chatbot feature. * * @private * @type {object.<boolean>} */ const disabledChatBot = {}; /** * Regexp to match a mention mentioning the bot. * * @private * @type {RegExp} */ let selfMentionRegex; /** @inheritdoc */ this.initialize = function() { self.command.on('chat', onChatMessage); self.command.on(new self.command.SingleCommand( 'togglechatbot', commandToggleChatBot, new self.command.CommandSetting({ validOnlyInGuild: true, defaultDisabled: true, permissions: self.Discord.PermissionsBitField.Flags.ManageRoles | self.Discord.PermissionsBitField.Flags.ManageGuild | self.Discord.PermissionsBitField.Flags.BanMembers, }))); self.client.on('messageCreate', onMessage); selfMentionRegex = new RegExp(`\\s*<@!?${self.client.user.id}>\\s*`); if (self.bot.getBotName()) { process.env.GOOGLE_APPLICATION_CREDENTIALS = './gApiCredentials-' + self.bot.getBotName() + '.json'; } else { process.env.GOOGLE_APPLICATION_CREDENTIALS = './gApiCredentials.json'; } sessionClient = new dialogflow.SessionsClient(); self.client.guilds.cache.forEach((g) => { self.common.readFile( `${self.common.guildSaveDir}${g.id}/chatbot-config.json`, (err, file) => { if (err) return; let parsed; try { parsed = JSON.parse(file); } catch (e) { return; } disabledChatBot[g.id] = parsed.disabledChatBot || false; }); }); }; /** @inheritdoc */ this.shutdown = function() { self.command.deleteEvent('chat'); self.command.deleteEvent('togglechatbot'); self.client.removeListener('messageCreate', onMessage); }; /** * @override * @inheritdoc */ this.save = function(opt) { self.client.guilds.cache.forEach((g) => { if (!settingsUpdated[g.id]) return; delete settingsUpdated[g.id]; const dir = `${self.common.guildSaveDir}${g.id}`; const filename = `${dir}/chatbot-config.json`; const obj = {disabledChatBot: disabledChatBot[g.id]}; if (opt == 'async') { self.common.mkAndWrite(filename, dir, JSON.stringify(obj)); } else { self.common.mkAndWriteSync(filename, dir, JSON.stringify(obj)); } }); }; let sessionClient; const reqTemplate = { session: 'default-session', queryInput: { text: { text: 'Hello World!', languageCode: 'en-US', }, }, }; /** * Respond to messages where I've been mentioned. * * @private * @param {Discord~Message} msg Message was sent. * @listens Discord#message */ function onMessage(msg) { if (msg.author.bot || !msg.guild) return; msg.prefix = self.bot.getPrefix(msg.guild); if (msg.mentions.users.get(self.client.user.id) && !self.command.find(msg.content.match(/^\S+/)[0], msg)) { const withoutMe = msg.content.replace(selfMentionRegex, '').trim(); const withoutMeMatch = withoutMe.match(/^\S+/); let author; if (msg.guild !== null) { author = `${msg.guild.id}#${msg.channel.id}@${msg.author.id}`; } else { author = `PM:${msg.author.id}@${msg.author.tag}`; } if (withoutMeMatch && self.command.find(withoutMeMatch[0], msg)) { self.log(`${author} ${msg.content}`); msg.content = `${msg.prefix}${withoutMe}`; if (!self.command.trigger(msg)) { self.warn(`Command "${msg.content}" failed!`); } return; } else if (withoutMe.length < 2) { return; } else if (disabledChatBot[msg.guild.id]) { return; } self.log(`${author} ${msg.content}`); msg.text = ' ' + msg.cleanContent .replace( new RegExp( '\\s*@' + escapeRegExp( msg.guild.members.me.nickname || self.client.user.username) + '\\s*', 'g'), ' SpikeyBot ') .trim(); onChatMessage(msg); } } /** * Send message text content to dialogflow for handling. * * @private * @type {commandHandler} * @param {Discord~Message} msg Message that triggered command. * @listens Command#chat */ function onChatMessage(msg) { if (msg.guild && disabledChatBot[msg.guild.id]) return; const perms = (msg.channel.permissionsFor && msg.channel.permissionsFor(self.client.user)); if (perms && !perms.has(self.Discord.PermissionsBitField.Flags.SendMessages)) { return; } if (!msg.text || msg.text.length < 2) return; const request = Object.assign({}, reqTemplate); request.session = sessionClient.sessionPath( auth['dialogflowProjectId' + (self.bot.getBotName() || '')], msg.channel.id); request.queryInput.text.text = msg.text.slice(1); if (request.queryInput.text.text.length > 256) { request.queryInput.text.text = request.queryInput.text.text.substr(0, 256); } // msg.channel.startTyping().catch(() => {}); const startTime = Date.now(); sessionClient.detectIntent(request) .then((responses) => { self.debug( 'Dialogflow response delay: ' + (Date.now() - startTime) + 'ms'); const result = responses[0].queryResult; if (result.parameters.fields.thing) { const list = result.parameters.fields.thing.listValue.values; const chosen = list[Math.floor(list.length * Math.random())].stringValue; result.fulfillmentText = result.fulfillmentText.replace(/~thing/g, chosen); } if (result.fulfillmentText) { msg.channel .send({content: result.fulfillmentText.replace(/\\n/g, '\n')}) .catch((err) => { self.error( 'Unable to reply to chat message: ' + msg.channel.id); console.error(err); }); } if (result.parameters.fields.loopback) { msg.text = result.parameters.fields.loopback.stringValue; onChatMessage(msg); } if (result.parameters.fields.command) { let cmd = result.parameters.fields.command.stringValue.replace( /^command /, msg.prefix); // Replace parameters in the command with the values matched by // dialogflow. Object.entries(result.parameters.fields).forEach((el) => { if (el[0] == 'command') return; cmd = cmd.replace( new RegExp(escapeRegExp('$' + el[0]), 'g'), el[1].stringValue); }); let author; if (msg.guild !== null) { author = `${msg.guild.id}#${msg.channel.id}@${msg.author.id}`; } else { author = `PM:${msg.author.id}@${msg.author.tag}`; } self.log(`${author} ${msg.content}`); msg.content = cmd; if (!self.command.trigger(msg)) { self.warn('Command "' + cmd + '" failed!'); } } }) .catch((err) => { self.debug( 'Dialogflow response delay: ' + (Date.now() - startTime) + 'ms'); self.error('Dialogflow failed request: ' + JSON.stringify(request)); console.error('ERROR:', err); msg.channel.send( {content: 'Failed to contact DialogFlow: ' + err.details}); }); } /** * Toggles the chatbot feature on a guild. * * @private * @type {commandHandler} * @param {Discord~Message} msg Message that triggered command. * @listens Command#togglechatbot */ function commandToggleChatBot(msg) { settingsUpdated[msg.guild.id] = true; if (disabledChatBot[msg.guild.id]) { disabledChatBot[msg.guild.id] = false; self.common.reply(msg, 'Enabled chatbot feature.'); } else { disabledChatBot[msg.guild.id] = true; self.common.reply(msg, 'Disabled chatbot feature.'); } } /** * Escape a given string to be passed into a regular expression. * * @private * * @param {string} str Input to escape. * @returns {string} Escaped string. */ function escapeRegExp(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } } module.exports = new ChatBot();