diff --git a/.gitignore b/.gitignore index 00b213c..8ac4226 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -### Discord bots #### guilds.json token.json log diff --git a/.npmrc b/.npmrc index 799e6bc..cda16d5 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,2 @@ save=true -save-exact=true -cache=node_cache +save-exact=true \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 120515c..61fafd7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,11 @@ "type": "node", "request": "launch", "name": "Launch Program", - "program": "${workspaceRoot}/bootstrap.js" + "program": "${workspaceRoot}/app/index.js", + "args":[ + "token.json", + "guilds.json" + ] } ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 728ef00..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "files.exclude": { - "log": true, - ".npmrc": true, - "node_modules": true, - "node_cache": true, - "token.json": true - } -} \ No newline at end of file diff --git a/app/bot.js b/app/bot.js deleted file mode 100644 index fe185a3..0000000 --- a/app/bot.js +++ /dev/null @@ -1,108 +0,0 @@ -//external lib imports -const GetUrls = require("get-urls"); //for extracting urls from messages - -//my imports -const DiscordUtil = require("discordjs-util"); - -//app component imports -const GuildData = require("./models/guild-data.js"); -const FeedData = require("./models/feed-data.js"); - -module.exports = { - onReady(client, guildsData, config) { - return new Promise((resolve, reject) => { - parseLinksInGuilds(client.guilds, guildsData) - .then(() => checkFeedsInGuilds(client.guilds, guildsData)) - .then(() => setInterval(() => checkFeedsInGuilds(client.guilds, guildsData), config.feedCheckIntervalSec * 1000)); //set up an interval to check all the feeds - }); - }, - onCommand(commandObj, commandsObj, params, guildData, message, config, client, botName) { - switch (commandObj.command) { - case commandsObj.addFeed.command: - return addFeed(client, guildData, message, config.maxCacheSize); - case commandsObj.removeFeed.command: - return removeFeed(guildData, message, botName); - case commandsObj.viewFeeds.command: - return viewFeeds(guildData); - } - }, - onNonCommandMsg(message, guildData) { - guildData.feeds.forEach(feedData => { - if (message.channel.name === feedData.channelName) - feedData.cachedLinks.push(...GetUrls(message.content)); //spread the urlSet returned by GetUrls into the cache array - }); - } -}; - -function addFeed(client, guildData, message, maxCacheSize) { - return new Promise((resolve, reject) => { - const feedUrl = [...GetUrls(message.content)][0]; - const channel = message.mentions.channels.first(); - - if (!feedUrl || !channel) - reject("Please provide both a channel and an RSS feed URL. You can optionally @mention a role also."); - - const role = message.mentions.roles.first(); - - const feedData = new FeedData({ - url: feedUrl, - channelName: channel.name, - roleName: role ? role.name : null, - maxCacheSize: maxCacheSize - }); - - //ask the user if they're happy with the details they set up, save if yes, don't if no - DiscordUtil.ask(client, message.channel, message.member, "Are you happy with this?\n" + feedData.toString()) - .then(responseMessage => { - - //if they responded yes, save the feed and let them know, else tell them to start again - if (responseMessage.content.toLowerCase() === "yes") { - if (!guildData) - guildData = new GuildData({ id: message.guild.id, feeds: [] }); - - guildData.feeds.push(feedData); - resolve("Your new feed has been saved!"); - } - else - reject("Your feed has not been saved, please add it again with the correct details"); - }); - }); -} - -function removeFeed(guildData, message, botName) { - return new Promise((resolve, reject) => { - const parameters = message.content.split(" "); - if (parameters.length !== 3) - resolve(`Please use the command as such:\n\`\`\` ${botName} remove-feed feedid\`\`\``); - else { - const idx = guildData.feeds.findIndex(feed => feed.id === parameters[2]); - if (!Number.isInteger(idx)) - reject("Can't find feed with id " + parameters[2]); - else { - guildData.feeds.splice(idx, 1); - resolve("Feed removed!"); - } - } - }); -} - -function viewFeeds(guildData) { - if (!guildData) - return Promise.reject("Guild not setup"); - - return Promise.resolve(guildData.feeds.map(f => f.toString()).join("\n")); -} - -function checkFeedsInGuilds(guilds, guildsData) { - Object.keys(guildsData).forEach(key => guildsData[key].checkFeeds(guilds)); -} - -function parseLinksInGuilds(guilds, guildsData) { - const promises = []; - for (let guildId of guilds.keys()) { - const guildData = guildsData[guildId]; - if (guildData) - promises.push(guildData.cachePastPostedLinks(guilds.get(guildId))); - } - return Promise.all(promises); -} \ No newline at end of file diff --git a/app/commands.json b/app/commands.json new file mode 100644 index 0000000..05a7070 --- /dev/null +++ b/app/commands.json @@ -0,0 +1,20 @@ +{ + "addFeed": { + "command": "add-feed", + "description": "Add an RSS feed to be posted in a channel, with an optional role to tag", + "syntax": "add-feed <#channel> [@role]", + "admin": true + }, + "removeFeed": { + "command": "remove-feed", + "description": "Remove an RSS feed by it's ID", + "syntax": "remove-feed ", + "admin": true + }, + "viewFeeds": { + "command": "view-feeds", + "description": "View a list of configured feeds and their associated details", + "syntax": "view-feed", + "admin": true + } +} \ No newline at end of file diff --git a/app/config.json b/app/config.json index e10434f..1164d79 100644 --- a/app/config.json +++ b/app/config.json @@ -1,43 +1,4 @@ { - "generic": { - "saveFile": "./guilds.json", - "saveIntervalSec": 60, - "website": "https://benji7425.github.io", - "discordInvite": "https://discord.gg/SSkbwSJ", - "defaultDMResponse": "This bot does not have any handling for direct messages. To learn more or get help please visit %s, or join my Discord server here: %s" - }, "maxCacheSize": 100, - "feedCheckIntervalSec": 30, - "commands": { - "version": { - "command": "version", - "description": "Returns the bot version", - "syntax": "version", - "admin": false - }, - "help": { - "command": "help", - "description": "Display information about commands available to you", - "syntax": "help", - "admin": false - }, - "addFeed": { - "command": "add-feed", - "description": "Add an RSS feed to be posted in a channel, with an optional role to tag", - "syntax": "add-feed <#channel> [@role]", - "admin": true - }, - "removeFeed": { - "command": "remove-feed", - "description": "Remove an RSS feed by it's ID", - "syntax": "remove-feed ", - "admin": true - }, - "viewFeeds": { - "command": "view-feeds", - "description": "View a list of configured feeds and their associated details", - "syntax": "view-feed", - "admin": true - } - } + "feedCheckIntervalSec": 30 } \ No newline at end of file diff --git a/app/index.js b/app/index.js index 594819a..4b137d4 100644 --- a/app/index.js +++ b/app/index.js @@ -1,123 +1,105 @@ -//node imports -const FileSystem = require("fs"); //manage files -const Util = require("util"); //various node utilities +const GetUrls = require("get-urls"); //for extracting urls from messages +const Core = require("../discord-bot-core"); +const GuildData = require("./models/guild-data.js"); +const FeedData = require("./models/feed-data.js"); +const Config = require("./config.json"); -//external lib imports -const Discord = require("discord.js"); -const JsonFile = require("jsonfile"); //save/load data to/from json - -//my imports -const DiscordUtil = require("discordjs-util"); //some discordjs helper functions of mine - -//app components -const GuildData = require("./models/guild-data.js"); //data structure for guilds -const PackageJSON = require("../package.json"); //used to provide some info about the bot -const Bot = require("./bot.js"); - -//global vars -let writeFile = null; - -//use module.exports as a psuedo "onready" function -module.exports = (client, config = null) => { - config = config || require("./config.json"); //load config file - const guildsData = FileSystem.existsSync(config.generic.saveFile) ? fromJSON(JsonFile.readFileSync(config.generic.saveFile)) : {}; //read data from file, or generate new one if file doesn't exist - - //create our writeFile function that will allow other functions to save data to json without needing access to the full guildsData or config objects - //then set an interval to automatically save data to file - writeFile = () => JsonFile.writeFile(config.generic.saveFile, guildsData, err => { if (err) DiscordUtil.dateError("Error writing file", err); }); - setInterval(() => writeFile(), config.generic.saveIntervalSec * 1000); - - //handle messages - client.on("message", message => { - if (message.author.id !== client.user.id) { //check the bot isn't triggering itself - - //check whether we need to use DM or text channel handling - if (message.channel.type === "dm") - HandleMessage.dm(client, config, message); - else if (message.channel.type === "text" && message.member) - HandleMessage.text(client, config, message, guildsData); - } +//IMPLEMENTATIONS// +function onReady(coreClient) { + return new Promise((resolve, reject) => { + parseLinksInGuilds(coreClient.actual.guilds, coreClient.guildsData) + .then(() => checkFeedsInGuilds(coreClient.actual.guilds, coreClient.guildsData)) + .then(() => setInterval(() => checkFeedsInGuilds(coreClient.actual.guilds, coreClient.guildsData), Config.feedCheckIntervalSec * 1000)) + .then(resolve) + .catch(reject); }); - - Bot.onReady(client, guildsData, config).then(() => writeFile).catch(err => DiscordUtil.dateError(err)); -}; - -const HandleMessage = { - dm: (client, config, message) => { - message.reply(Util.format(config.generic.defaultDMResponse, config.generic.website, config.generic.discordInvite)); - }, - text: (client, config, message, guildsData) => { - const isCommand = message.content.startsWith(message.guild.me.toString()); - let guildData = guildsData[message.guild.id]; - - if (!guildData) - guildData = guildsData[message.guild.id] = new GuildData({ id: message.guild.id }); - - if (isCommand) { - const userIsAdmin = message.member.permissions.has("ADMINISTRATOR"); - const botName = "@" + (message.guild.me.nickname || client.user.username); - - const split = message.content.toLowerCase().split(/\ +/); //split the message at whitespace - const command = split[1]; //extract the command used - const commandObj = config.commands[Object.keys(config.commands).find(x => config.commands[x].command.toLowerCase() === command)]; //find the matching command object - - if (!commandObj || (!commandObj.admin && !userIsAdmin)) - return; - - const params = split.slice(2, split.length); //extract the parameters passed for the command - const expectedParamCount = commandObj.syntax.split(/\ +/).length - 1; //calculate the number of expected command params - - let finalisedParams; - if (params.length > expectedParamCount) //if we have more params than needed - finalisedParams = params.slice(0, expectedParamCount - 1).concat([params.slice(expectedParamCount - 1, params.length).join(" ")]); - else //else we either have exactly the right amount, or not enough - finalisedParams = params; - - //find which command was used and handle it - switch (command) { - case config.commands.version.command: - message.reply(`${PackageJSON.name} v${PackageJSON.version}`); - break; - case config.commands.help.command: - message.channel.send(createHelpEmbed(botName, config, userIsAdmin)); - break; - default: - if (finalisedParams.length >= expectedParamCount) - Bot.onCommand(commandObj, config.commands, finalisedParams, guildData, message, config, client, botName) - .then(msg => { - message.reply(msg); - writeFile(); - }) - .catch(err => { - message.reply(err); - DiscordUtil.dateError(err); - }); - else - message.reply(`Incorrect syntax!\n**Expected:** *${botName} ${commandObj.syntax}*\n**Need help?** *${botName} ${config.commands.help.command}*`); - break; - } - } - else - Bot.onNonCommandMsg(message, guildData); - } -}; - -function fromJSON(json) { - const guildsData = Object.keys(json); - guildsData.forEach(guildID => { json[guildID] = new GuildData(json[guildID]); }); - return json; } -function createHelpEmbed(name, config, userIsAdmin) { - const commandsArr = Object.keys(config.commands).map(x => config.commands[x]).filter(x => userIsAdmin || !x.admin); +function onTextMessage(message, guildData) { + guildData.feeds.forEach(feedData => { + if (message.channel.name === feedData.channelName) + feedData.cachedLinks.push(...GetUrls(message.content)); //spread the urlSet returned by GetUrls into the cache array + }); + return Promise.resolve(); +} - const embed = new Discord.RichEmbed().setTitle("__Help__"); +function addFeed({ command, params, guildData, botName, message, coreClient }) { + const feedUrl = [...GetUrls(message.content)][0]; + const channel = message.mentions.channels.first(); - commandsArr.forEach(command => { - embed.addField(command.command, `${command.description}\n**Usage:** *${name} ${command.syntax}*${userIsAdmin && command.admin ? "\n***Admin only***" : ""}`); + if (!feedUrl || !channel) + return Promise.reject("Please provide both a channel and an RSS feed URL. You can optionally @mention a role also."); + + const role = message.mentions.roles.first(); + + const feedData = new FeedData({ + url: feedUrl, + channelName: channel.name, + roleName: role ? role.name : null, + maxCacheSize: Config.maxCacheSize }); - embed.addField("__Need more help?__", `[Visit my website](${config.generic.website}) or [Join my Discord](${config.generic.discordInvite})`, true); + return new Promise((resolve, reject) => { + //ask the user if they're happy with the details they set up, save if yes, don't if no + Core.util.ask(coreClient.actual, message.channel, message.member, "Are you happy with this (yes/no)?\n" + feedData.toString()) + .then(responseMessage => { - return { embed }; -} \ No newline at end of file + //if they responded yes, save the feed and let them know, else tell them to start again + if (responseMessage.content.toLowerCase() === "yes") { + if (!guildData) + guildData = new GuildData({ id: message.guild.id, feeds: [] }); + + guildData.feeds.push(feedData); + guildData.cachePastPostedLinks(message.guild) + .then(() => resolve("Your new feed has been saved!")); + } + else + reject("Your feed has not been saved, please add it again with the correct details"); + }); + }); +} + +function removeFeed({ command, params, guildData, botName, message, coreClient }) { + const idx = guildData.feeds.findIndex(feed => feed.id === params[2]); + if (!Number.isInteger(idx)) + return Promise.reject("Can't find feed with id " + params[2]); + + guildData.feeds.splice(idx, 1); + return Promise.resolve("Feed removed!"); +} + +function viewFeeds({ command, params, guildData, botName, message, coreClient }) { + if (!guildData) + return Promise.reject("Guild not setup"); + + return Promise.resolve(guildData.feeds.map(f => f.toString()).join("\n")); +} + +//INTERNAL FUNCTIONS// +function checkFeedsInGuilds(guilds, guildsData) { + Object.keys(guildsData).forEach(key => guildsData[key].checkFeeds(guilds)); +} + +function parseLinksInGuilds(guilds, guildsData) { + const promises = []; + for (let guildId of guilds.keys()) { + const guildData = guildsData[guildId]; + if (guildData) + promises.push(guildData.cachePastPostedLinks(guilds.get(guildId))); + } + return Promise.all(promises); +} + +//CLIENT SETUP// +const token = require("../" + process.argv[2]).token, + dataFile = process.argv[3], + commands = require("./commands.json"), + implementations = { + onReady, + onTextMessage, + addFeed, + removeFeed, + viewFeeds + }; +const client = new Core.Client(token, dataFile, commands, implementations, GuildData); +client.bootstrap(); \ No newline at end of file diff --git a/app/models/feed-data.js b/app/models/feed-data.js index 223a499..9afe706 100644 --- a/app/models/feed-data.js +++ b/app/models/feed-data.js @@ -1,5 +1,5 @@ //my imports -const DiscordUtil = require("discordjs-util"); +const DiscordUtil = require("../../discord-bot-core").util; //external lib imports const Dns = require("dns"); //for host resolution checking @@ -80,10 +80,14 @@ module.exports = class FeedData { function normaliseUrl(url) { url = url.replace("https://", "http://"); //hacky way to treat http and https the same - if (Url.parse(url).host.startsWith("http://youtu")) - url = url.split("?")[0]; //quick way to chop off stuff like ?feature=youtube - - url = url.replace(/(www.)?youtube.com\/watch\?v=/, "youtu.be/"); //convert youtube full url to short + const parsedUrl = Url.parse(url); + if (parsedUrl.host.includes("youtube.com")) { + const videoIDParam = parsedUrl.query.split("&").find(x => x.startsWith("v=")); + if (videoIDParam) { + const videoID = videoIDParam.substring(videoIDParam.indexOf("=") + 1, videoIDParam.length); + url = "http://youtu.be/" + videoID; + } + } return url; } \ No newline at end of file diff --git a/app/models/guild-data.js b/app/models/guild-data.js index 17bb793..51adcb4 100644 --- a/app/models/guild-data.js +++ b/app/models/guild-data.js @@ -1,5 +1,5 @@ +const DiscordUtil = require("../../discord-bot-core").util; const FeedData = require("./feed-data.js"); -const Util = require("discordjs-util"); module.exports = class GuildData { constructor({ id, feeds }) { @@ -11,7 +11,7 @@ module.exports = class GuildData { const promises = []; this.feeds.forEach(feed => { - promises.push(feed.updatePastPostedLinks(guild).catch(Util.dateError)); + promises.push(feed.updatePastPostedLinks(guild).catch(DiscordUtil.dateError)); }); return Promise.all(promises); diff --git a/bootstrap.js b/bootstrap.js deleted file mode 100644 index a78913e..0000000 --- a/bootstrap.js +++ /dev/null @@ -1,20 +0,0 @@ -const Discord = require("discord.js"); -const DiscordUtil = require("discordjs-util"); - -const client = new Discord.Client(); - -process.on("uncaughtException", (err) => { - DiscordUtil.dateError("Uncaught exception!", err); -}); - -client.login(require("./token.json").token); - -client.on("ready", () => { - DiscordUtil.dateLog("Registered bot " + client.user.username); - require("./app/index.js")(client); - client.user.setPresence({ game: { name: "benji7425.github.io", type: 0 } }); -}); - -client.on("disconnect", eventData => { - DiscordUtil.dateError("Bot was disconnected!", eventData.code, eventData.reason); -}); \ No newline at end of file diff --git a/discord-bot-core/.gitignore b/discord-bot-core/.gitignore new file mode 100644 index 0000000..00b213c --- /dev/null +++ b/discord-bot-core/.gitignore @@ -0,0 +1,79 @@ +### Discord bots #### +guilds.json +token.json +log + + +# Created by https://www.gitignore.io/api/node,visualstudiocode + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_cache +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history + +# End of https://www.gitignore.io/api/node,visualstudiocode diff --git a/discord-bot-core/.gitrepo b/discord-bot-core/.gitrepo new file mode 100644 index 0000000..0b1b881 --- /dev/null +++ b/discord-bot-core/.gitrepo @@ -0,0 +1,12 @@ +; DO NOT EDIT (unless you know what you are doing) +; +; This subdirectory is a git "subrepo", and this file is maintained by the +; git-subrepo command. See https://github.com/git-commands/git-subrepo#readme +; +[subrepo] + remote = git@github.com:benji7425/discord-bot-core.git + branch = master + commit = 8e6d6ce99e581f5ac5fb899112ef33bcf3e16a34 + parent = 341964a90ddca9afc684fd9331fc4177b8b68c04 + method = merge + cmdver = 0.3.1 diff --git a/discord-bot-core/.npmrc b/discord-bot-core/.npmrc new file mode 100644 index 0000000..cda16d5 --- /dev/null +++ b/discord-bot-core/.npmrc @@ -0,0 +1,2 @@ +save=true +save-exact=true \ No newline at end of file diff --git a/discord-bot-core/client.js b/discord-bot-core/client.js new file mode 100644 index 0000000..fbe284f --- /dev/null +++ b/discord-bot-core/client.js @@ -0,0 +1,109 @@ +//node imports +const FileSystem = require("fs"); //checking if files exist + +//external lib imports +const Discord = require("discord.js"); //discord interaction +const JsonFile = require("jsonfile"); //saving to/reading from json + +//component imports +const DiscordUtil = require("./util.js"); //some helper methods +const MessageHandler = require("./message-handler.js"); //message handling +const Config = require("./internal-config.json"); //some configuration values + +class CoreClient { + /** + * @param {string} token + * @param {string} dataFile + * @param {object} guildDataModel + * @param {object[]} commands + */ + constructor(token, dataFile, commands, implementations, guildDataModel) { + this.actual = new Discord.Client(); + + this.token = token; + this.dataFile = dataFile; + this.commands = commands; + this.implementations = implementations; + this.guildDataModel = guildDataModel; + this.guildsData = FileSystem.existsSync(this.dataFile) ? + fromJSON(JsonFile.readFileSync(this.dataFile), this.guildDataModel) : {}; + + process.on("uncaughtException", err => onUncaughtException(this, err)); + } + + writeFile() { + JsonFile.writeFile( + this.dataFile, + this.guildsData, + err => { + if (err) DiscordUtil.dateError("Error writing file", err); + }); + } + + bootstrap() { + this.actual.on("ready", () => onReady(this)); + + this.actual.on("disconnect", eventData => DiscordUtil.dateError("Disconnect!", eventData.code, eventData.reason)); + + this.actual.on("message", message => { + if (message.author.id === this.actual.user.id) + return; + if (message.channel.type === "dm") + MessageHandler.handleDirectMessage(this, message); + else if (message.channel.type === "text" && message.member) + MessageHandler.handleTextMessage(this, message, this.guildsData) + .then(msg => { + if (msg) message.reply(msg); + }) + .catch(err => { + message.reply(err); + DiscordUtil.dateError(`Command error in guild ${message.guild.name}\n`, err.message || err); + }) + .then(() => this.writeFile()); + }); + + this.actual.login(this.token); + } +} + +/** + * @param {*} coreClient + */ +function onReady(coreClient) { + coreClient.actual.user.setGame("benji7425.github.io"); + DiscordUtil.dateLog("Registered bot " + coreClient.actual.user.username); + + setInterval(() => coreClient.writeFile(), Config.saveIntervalSec * 1000); + + if (coreClient.implementations.onReady) + coreClient.implementations.onReady(coreClient) + .then(() => coreClient.writeFile()) + .catch(err => DiscordUtil.dateError(err)); +} + +/** + * @param {*} coreClient + * @param {*} err + */ +function onUncaughtException(coreClient, err) { + DiscordUtil.dateError(err.message || err); + DiscordUtil.dateLog("Destroying existing client..."); + coreClient.actual.destroy().then(() => { + DiscordUtil.dateLog("Client destroyed, recreating..."); + coreClient.actual = new Discord.Client(); + coreClient.bootstrap(); + }); +} + +/** + * Convert json from file to a usable format + * @param {object} json json from file + * @param {*} guildDataModel + */ +function fromJSON(json, guildDataModel) { + const guildsData = Object.keys(json); + guildsData.forEach(guildID => { json[guildID] = new guildDataModel(json[guildID]); }); + return json; +} + +module.exports = CoreClient; \ No newline at end of file diff --git a/discord-bot-core/index.js b/discord-bot-core/index.js new file mode 100644 index 0000000..826b29b --- /dev/null +++ b/discord-bot-core/index.js @@ -0,0 +1,10 @@ +const Config = require("./internal-config.json"); + +module.exports = { + Client: require("./client.js"), + util: require("./util.js"), + details: { + website: Config.website, + discordInvite: Config.discordInvite + } +}; diff --git a/discord-bot-core/internal-config.json b/discord-bot-core/internal-config.json new file mode 100644 index 0000000..542a675 --- /dev/null +++ b/discord-bot-core/internal-config.json @@ -0,0 +1,21 @@ +{ + "saveFile": "./%s-data.json", + "saveIntervalSec": 60, + "website": "https://benji7425.github.io", + "discordInvite": "https://discord.gg/SSkbwSJ", + "defaultDMResponse": "This bot does not have any handling for direct messages. To learn more or get help please visit %s, or join my Discord server here: %s", + "commands": { + "version": { + "command": "version", + "description": "Returns the bot version", + "syntax": "version", + "admin": false + }, + "help": { + "command": "help", + "description": "Display information about commands available to you", + "syntax": "help", + "admin": false + } + } +} \ No newline at end of file diff --git a/discord-bot-core/message-handler.js b/discord-bot-core/message-handler.js new file mode 100644 index 0000000..7c91670 --- /dev/null +++ b/discord-bot-core/message-handler.js @@ -0,0 +1,112 @@ +//node imports +const Util = require("util"); + +//external lib imports +const Discord = require("discord.js"); + +//component imports +const Config = require("./internal-config.json"); //generic lib configuration +const ParentPackageJSON = require("../package.json"); //used to provide some info about the bot + +/** + * Handle a direct message to the bot + * @param {*} coreClient Core.Client + * @param {*} message Discord.Message + */ +function handleDirectMessage(coreClient, message) { + message.reply(Util.format(Config.defaultDMResponse, Config.website, Config.discordInvite)); +} + +/** + * + * @param {*} coreClient Core.Client + * @param {*} message Discord.Message + * @param {*[]} guildsData GuildData[] + */ +function handleTextMessage(coreClient, message, guildsData) { + return new Promise((resolve, reject) => { + const isCommand = message.content.startsWith(message.guild.me.toString()); + let guildData = guildsData[message.guild.id]; + + if (!guildData) + guildData = guildsData[message.guild.id] = new coreClient.guildDataModel({ id: message.guild.id }); + + if (!isCommand) + return coreClient.implementations.onTextMessage(message, guildData).then(msg => resolve(msg)); + + Object.assign(coreClient.commands, Config.commands); + const userIsAdmin = message.member.permissions.has("ADMINISTRATOR"); + const botName = "@" + (message.guild.me.nickname || coreClient.actual.user.username); + const { command, commandProp, params, expectedParamCount } = getCommandDetails(message, coreClient.commands, userIsAdmin) || { command: null, commandProp: null, params: null, expectedParamCount: null }; + const invoke = coreClient.implementations[commandProp]; + + if (!command || !params || isNaN(expectedParamCount)) + return reject(`'${message.content.split(" ")[1]}' is not a recognised command`); + + if (command === Config.commands.version) + resolve(`${ParentPackageJSON.name} v${ParentPackageJSON.version}`); + else if (command === Config.commands.help) + message.channel.send(createHelpEmbed(botName, coreClient.commands, userIsAdmin)); + else { + if (invoke && params.length >= expectedParamCount) + invoke({ command, params: params, guildData, botName, message, coreClient }) + .then(msg => + resolve(msg)) + .catch(err => reject(err)); + else + reject(`Incorrect syntax!\n**Expected:** *${botName} ${command.syntax}*\n**Need help?** *${botName} ${coreClient.commands.help.command}*`); + } + }); +} + +/** + * Determine details about a command invoked via a message + * @param {*} message Discord.Message + * @param {*[]} commands commands array (probably from commands.json) + * @param {boolean} userIsAdmin whether the user is an admin + */ +function getCommandDetails(message, commands, userIsAdmin) { + const splitMessage = message.content.toLowerCase().split(/ +/); + const commandStr = splitMessage[1]; + const commandProp = Object.keys(commands).find(x => commands[x].command.toLowerCase() === commandStr); + const command = commands[commandProp]; + + if (!command || (command.admin && !userIsAdmin)) + return; + + const params = splitMessage.slice(2, splitMessage.length); + const expectedParamCount = command.syntax.split(/ +/).length - 1; + + let finalisedParams; + if (params.length > expectedParamCount) + finalisedParams = params.slice(0, expectedParamCount - 1).concat([params.slice(expectedParamCount - 1, params.length).join(" ")]); + else + finalisedParams = params; + + return { command, commandProp, params: finalisedParams, expectedParamCount }; +} + +/** + * Create a help embed for available commands + * @param {string} name name of the bot + * @param {*[]} commands commands array + * @param {boolean} userIsAdmin whether the user is admin + */ +function createHelpEmbed(name, commands, userIsAdmin) { + const commandsArr = Object.keys(commands).map(x => commands[x]).filter(x => userIsAdmin || !x.admin); + + const embed = new Discord.RichEmbed().setTitle(`__Help__ for ${(ParentPackageJSON.name + "").replace("discord-bot-", "")}`); + + commandsArr.forEach(command => { + embed.addField(command.command, `${command.description}\n**Usage:** *${name} ${command.syntax}*${userIsAdmin && command.admin ? "\n***Admin only***" : ""}`); + }); + + embed.addField("__Need more help?__", `[Visit my website](${Config.website}) or [Join my Discord](${Config.discordInvite})`, true); + + return { embed }; +} + +module.exports = { + handleDirectMessage, + handleTextMessage +}; \ No newline at end of file diff --git a/discord-bot-core/package.json b/discord-bot-core/package.json new file mode 100644 index 0000000..3b81dca --- /dev/null +++ b/discord-bot-core/package.json @@ -0,0 +1,22 @@ +{ + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "discord.js": "11.2.0", + "jsonfile": "3.0.1", + "parent-package-json": "2.0.1", + "simple-file-writer": "2.0.0" + }, + "name": "discord-bot-core", + "repository": { + "type": "git", + "url": "git+https://github.com/benji7425/discord-bot-core.git" + }, + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/benji7425/discord-bot-core/issues" + }, + "homepage": "https://github.com/benji7425/discord-bot-core#readme", + "description": "Core code shared amongst my Discord bots" +} diff --git a/discord-bot-core/util.js b/discord-bot-core/util.js new file mode 100644 index 0000000..9240244 --- /dev/null +++ b/discord-bot-core/util.js @@ -0,0 +1,49 @@ +const Console = require("console"); +const SimpleFileWriter = require("simple-file-writer"); + +const logWriter = new SimpleFileWriter("./log"); + +/** + * Returns a promise that the user will answer + * @param {TextChannel} textChannel discord.js TextChannel to ask the question in + * @param {GuildMember} member discord.js Member to ask the question to + * @param {string} question question to ask + */ +function ask(client, textChannel, member, question) { + //return a promise which will resolve once the user next sends a message in this textChannel + return new Promise((resolve, reject) => { + const handler = responseMessage => { + if (responseMessage.channel.id === textChannel.id && + responseMessage.member.id === member.id) { + client.removeListener("message", handler); + resolve(responseMessage); + } + }; + + client.on("message", handler); + + textChannel.send(member.toString() + " " + question).catch(reject); + }); +} + +function dateLog(...args) { + args = formatArgs(args); + Console.log.apply(this, args); + logWriter.write(args.join("") + "\n"); +} + +function dateError(...args) { + args = formatArgs(args); + Console.error.apply(this, args); + logWriter.write(args.join("") + "\n"); +} + +function formatArgs(args) { + return ["[", new Date().toUTCString(), "] "].concat(args); +} + +module.exports = { + dateError, + dateLog, + ask +}; \ No newline at end of file diff --git a/package.json b/package.json index aca7e83..47fcf3c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "version": "3.0.0-b2", "main": "app/index.js", "scripts": { - "start": "node bootstrap.js" + "postinstall": "cd ./discord-bot-core && npm install", + "start": "node app/index.js token.json guilds.json" }, "dependencies": { "discord.js": "11.1.0",