From b6cc8298bc82fd7c8ae2438a87ddcff8b67ca6be Mon Sep 17 00:00:00 2001 From: benji7425 Date: Thu, 14 Sep 2017 13:09:43 +0100 Subject: [PATCH] Sqaush merge and adapt for template update using shared core code --- .gitignore | 2 +- .npmrc | 1 - .vscode/launch.json | 2 +- .vscode/settings.json | 2 +- CHANGELOG.md | 128 +---------------- README.md | 55 +++----- app/bot.js | 108 -------------- app/commands.json | 20 +++ app/config.json | 41 +----- app/index.js | 212 +++++++++++++--------------- app/models/guild-data.js | 20 +-- bootstrap.js | 20 --- discord-bot-core/.gitignore | 79 +++++++++++ discord-bot-core/.gitrepo | 12 ++ discord-bot-core/.npmrc | 2 + discord-bot-core/bootstrapper.js | 74 ++++++++++ discord-bot-core/config.json | 21 +++ discord-bot-core/index.js | 12 ++ discord-bot-core/message-handler.js | 99 +++++++++++++ discord-bot-core/package.json | 23 +++ discord-bot-core/util.js | 49 +++++++ package.json | 3 +- 22 files changed, 513 insertions(+), 472 deletions(-) delete mode 100644 app/bot.js create mode 100644 app/commands.json delete mode 100644 bootstrap.js create mode 100644 discord-bot-core/.gitignore create mode 100644 discord-bot-core/.gitrepo create mode 100644 discord-bot-core/.npmrc create mode 100644 discord-bot-core/bootstrapper.js create mode 100644 discord-bot-core/config.json create mode 100644 discord-bot-core/index.js create mode 100644 discord-bot-core/message-handler.js create mode 100644 discord-bot-core/package.json create mode 100644 discord-bot-core/util.js diff --git a/.gitignore b/.gitignore index 00b213c..0cde457 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ ### Discord bots #### -guilds.json +*-data.json token.json log diff --git a/.npmrc b/.npmrc index 799e6bc..f5357d5 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,2 @@ save=true save-exact=true -cache=node_cache diff --git a/.vscode/launch.json b/.vscode/launch.json index 120515c..7ff9bca 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,7 @@ "type": "node", "request": "launch", "name": "Launch Program", - "program": "${workspaceRoot}/bootstrap.js" + "program": "${workspaceRoot}/app/index.js" } ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 6b3efaf..261d736 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,4 +4,4 @@ "token.json": true, "_config.yml": true } -} \ No newline at end of file +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b67469..8deb76e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,129 +1,3 @@ # Changelog -## v3.0.0-b2 - -### Fixed - -- Full and short youtube urls not being properly converted - -## v3.0.0-b1 - -### Added -- Fancy new @bot help command - -### Updated -- Significant back-end updates -- Commands now invoked with an @mention to the bot - -## v2.0.0-b1 - -### Added -- Multi-guild support -- In-chat commands for setup and configuration - - Add a new feed - - View a list of feeds - - Remove an existing feed - -### Updated - -- Make save file configurable to allow use as a module with other bots -- Update config file structure -- Now uses discord.js instead of discord.io -- YouTube links automatically handled; no more separate "YouTube mode" config item - -### Fixed - -- Crash if trying to view feeds list before any feeds have been set up - -## v1.4.0 - -### Added - -- Support for posting links from multiple feeds -- Tagging of separate roles for each feed being checked - -### Updated - -- Updated bot connection code to use my discord-bot-wrapper - -### Removed - -- !logsplease command removed as the OTT logging was just being annoying - -## v1.3.2 - -### Fixed - -- Fixed list posting channel messages being ignored - -## v1.3.1 - -### Fixed - -- Developer commands can now be used from any channel or PM - -## v1.3.0 - -### Added - -- Deletion of "You have successfully subscribed" messages after a short delay (configurable) -- 'Developer' commands that can only be accessed by specified users -- !cacheList developer command to view the cached URLs - -### Updated -- !logsplease is now a developer command -- Subscriptions are now done using a role - - !subscribe and !unsibscribe add and remove the user from the role - - !sublist command is now removed - - The role is mentioned when the link is posted, rather than a long chain of user IDs - -## v1.2.1 - -### Fixed - -- Fixed multiple users being unsubscribed when one user unsubscribes - -## v1.2.0 - -### Added - -- Chat message/command to request a list of subscribed users -- The ability for users to 'subscribe' so they are tagged whenever a new link is posted -- Logging to a file -- Ability for user to request an upload of the logs file - -### Updated - -- Added basic spam reduction when logging so the same message won't get logged multiple times in a row -- Refactored a bunch of code to improve efficiency -- Updated timer logic to only ever use a single timer, and share it between posting and reconnecting - -## v1.1.2 - -### Updated -- Updated reconnect logic to hopefully be more stable - -## v1.1.1 - -### Added -- Reconnect timer to repeatedly try reconnect at intervals - -### Updated - -- Updated support for https conversion to http to hopefully be more consistent - -## v1.1.0 - -### Added - -- Added togglable YouTube mode - - Converts full URLs to YouTube share URLs - - Checks against both YouTube full and share URLs to ensure same video not posted twice -- New logging class to handle logging - -### Updated -- Major refactor of a significant portion of the bot's code - should be easier to maintain now, but may have introduced some new bugs -- Changed expected name for bot config file to bot-config.json rather than botConfig.json - -### Fixed -- New timer being created every time the bot reconnected +## Unreleased \ No newline at end of file diff --git a/README.md b/README.md index 4a2bc49..a705629 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,43 @@ -# Discord rss bot +# Template README -Posts the latest URLs from an RSS feed, optionally @mention-ing a role when posted +Summary goes here ## Features -- Multiple feeds per server -- Configurable channel per feed -- Optional, configurable role per feed, mentioned when a URL is posted -- In-chat setup commands -- Detects if a user "beats me to it" and posts the URL before the feed updates (useful for slow feeds) -- Detects users posting both full and short YouTube urls if using a YouTube RSS feed +- Feature 1 +- Feature 2 +- Feature 3 ## Invite By inviting this bot to your server you agree to the [terms and conditions](#privacy-statement) laid out in the privacy section of this document. -If you agree, invite to your server with [this link](https://discordapp.com/oauth2/authorize?client_id=343909688045469698&scope=bot&permissions=0x00010c00). +If you agree, invite to your server with [this link](https://link-goes-here.com/). ## Setup -You can ask the bot for help with commands by typing `@RSS_Bot help` +You can ask the bot for help with commands by typing `@bot help` -### Add a new feed +### Use case 1 -`@RSS_Bot add-feed <#channel> [@role]` -- *url* must be an RSS feed URL -- *#channel* must be a channel mention -- *@role* must be a role mention (make sure "Anyone can mention this role" is turned on during setup) +`@bot command ` +Explanation Example: -`@RSS_Bot add-feed http://lorem-rss.herokuapp.com/feed?unit=second&interval=30 #rss-posts @subscribers` - -### View feeds configured for this server - -`@RSS_Bot view-feeds` -This will display a list of RSS feeds configured for this server, along with a unique ID for each - -### Remove a configured feed - -`@RSS_Bot remove-feed ` - -To remove a feed you will need it's unique ID, which you can find by running the above *view-feeds* command - -Example: -`@RSS_Bot remove-feed ABc-123dEF` +`@bot command example-param` ## Permissions The bot requires certain permissions, which you are prompted for on the invite screen. Each permission has a reason for being required, explained below. -| Permission | Reason | -|----------------------|-------------------------------------------------------------| -| Read messages | Detect when you use commands | -| Send messages | Respond when you use commands; post new RSS links | -| Read message history | Check if any new RSS links have been posted during downtime | +| Permission | Reason | +|---------------|-------------------------------| +| Read messages | Detect when you use commands | +| Send messages | Respond when you use commands | ## Privacy statement @@ -70,8 +50,7 @@ In accordance with the [Discord developer Terms of Service](https://discordapp.c This bot will only collect data which is necessary to function. No data collected will be shared with any third parties. -Should you wish for the data stored about your server to be removed, please contact me via [my support Discord](https://discordapp.com/invite/SSkbwSJ) and I will oblige as soon as I am able. Please note that this will require you to remove the bot from your server. - +Should you wish for the data stored about your server to be removed, please contact me via [my support Discord server](https://discordapp.com/invite/SSkbwSJ) and I will oblige as soon as I am able. Please note that this will require you to remove the bot from your server. ## Want to host your own instance? @@ -83,4 +62,4 @@ Should you wish for the data stored about your server to be removed, please cont ## Need help? -I am available for contact via my [support Discord server](https://discordapp.com/invite/SSkbwSJ). I will always do my best to respond, however I am often busy so can't always be available right away, and as this is a free service I may not always be able to resolve your query. +I am available for contact via my [support Discord server](https://discordapp.com/invite/SSkbwSJ). I will always do my best to respond, however I am often busy so can't always be available right away, and as this is a free service I may not always be able to resolve your query. \ 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..6340129 100644 --- a/app/index.js +++ b/app/index.js @@ -1,123 +1,103 @@ -//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); - } +function onReady(client, guildsData) { + 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 }); - - 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); - - const embed = new Discord.RichEmbed().setTitle("__Help__"); - - commandsArr.forEach(command => { - embed.addField(command.command, `${command.description}\n**Usage:** *${name} ${command.syntax}*${userIsAdmin && command.admin ? "\n***Admin only***" : ""}`); +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 }); +} - embed.addField("__Need more help?__", `[Visit my website](${config.generic.website}) or [Join my Discord](${config.generic.discordInvite})`, true); +function addFeed(client, guildData, message, maxCacheSize) { + return new Promise((resolve, reject) => { + const feedUrl = [...GetUrls(message.content)][0]; + const channel = message.mentions.channels.first(); - return { embed }; -} \ No newline at end of file + 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 + Core.util.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); +} + +module.exports = { + onReady, + onTextMessage, + addFeed, + removeFeed, + viewFeeds +}; + +Core.bootstrap(module.exports, GuildData, require("./commands.json")); \ No newline at end of file diff --git a/app/models/guild-data.js b/app/models/guild-data.js index 17bb793..9fc920e 100644 --- a/app/models/guild-data.js +++ b/app/models/guild-data.js @@ -1,23 +1,7 @@ -const FeedData = require("./feed-data.js"); -const Util = require("discordjs-util"); +const DiscordUtil = require("../../discord-bot-core").util; module.exports = class GuildData { - constructor({ id, feeds }) { + constructor({ id }) { this.id = id; - this.feeds = (feeds || []).map(feed => new FeedData(feed)); - } - - cachePastPostedLinks(guild) { - const promises = []; - - this.feeds.forEach(feed => { - promises.push(feed.updatePastPostedLinks(guild).catch(Util.dateError)); - }); - - return Promise.all(promises); - } - - checkFeeds(guilds) { - this.feeds.forEach(feed => feed.check(guilds.get(this.id))); } }; \ No newline at end of file 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..cfd16dd --- /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 = 270d24d2119404ba42364584868a3ed364e87678 + 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/bootstrapper.js b/discord-bot-core/bootstrapper.js new file mode 100644 index 0000000..545dcf9 --- /dev/null +++ b/discord-bot-core/bootstrapper.js @@ -0,0 +1,74 @@ +//node imports +const FileSystem = require("fs"); //manage files +const Util = require("util"); + +//external lib imports +const Discord = require("discord.js"); +const JsonFile = require("jsonfile"); //save/load data to/from json + +const Config = require("./config.json"); +const ParentPackageJSON = require("../package.json"); //used to provide some info about the bot +const DiscordUtil = require("./util.js"); //some discordjs helper functions of mine +const MessageHandler = require("./message-handler.js"); + +function bootstrap(component, guildDataModel, commands) { + process.on("uncaughtException", (err) => { + DiscordUtil.dateError("Uncaught exception!", err); + }); + + const client = new Discord.Client(); + + client.on("ready", () => { + onReady(client, component, guildDataModel, commands); + + client.user.setGame("benji7425.github.io"); + DiscordUtil.dateLog("Registered bot " + client.user.username); + }); + + client.on("disconnect", eventData => { + DiscordUtil.dateError("Bot was disconnected!", eventData.code, eventData.reason); + }); + + client.login(require("../token.json").token); +} + +function onReady(client, component, guildDataModel, commands) { + const saveFile = Util.format(Config.saveFile, ParentPackageJSON.name + ""); + + const guildsData = + FileSystem.existsSync(saveFile) ? + fromJSON(JsonFile.readFileSync(saveFile), guildDataModel) : {}; + + const writeFile = () => + JsonFile.writeFile( + saveFile, + guildsData, + err => { + if (err) DiscordUtil.dateError("Error writing file", err); + }); + + setInterval(() => writeFile(), Config.saveIntervalSec * 1000); + + client.on("message", message => { + if (message.author.id !== client.user.id) { + if (message.channel.type === "dm") + MessageHandler.handleDirectMessage({ client, message }); + else if (message.channel.type === "text" && message.member) + MessageHandler.handleTextMessage({ client, commands, message, guildDataModel, guildsData, component, writeFile }); + } + }); + + component.onReady(client, guildsData) + .then(() => writeFile()) + .catch(err => DiscordUtil.dateError(err)); +} + +function fromJSON(json, guildDataModel) { + const guildsData = Object.keys(json); + guildsData.forEach(guildID => { json[guildID] = new guildDataModel(json[guildID]); }); + return json; +} + +module.exports = { + bootstrap +}; \ No newline at end of file diff --git a/discord-bot-core/config.json b/discord-bot-core/config.json new file mode 100644 index 0000000..542a675 --- /dev/null +++ b/discord-bot-core/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/index.js b/discord-bot-core/index.js new file mode 100644 index 0000000..cc68233 --- /dev/null +++ b/discord-bot-core/index.js @@ -0,0 +1,12 @@ +const Config = require("./config.json"); + +module.exports = { + bootstrap(component, guildDataModel, commands) { + require("./bootstrapper.js").bootstrap(component, guildDataModel, commands); + }, + util: require("./util.js"), + details: { + website: Config.website, + discordInvite: Config.discordInvite + } +}; diff --git a/discord-bot-core/message-handler.js b/discord-bot-core/message-handler.js new file mode 100644 index 0000000..51c8158 --- /dev/null +++ b/discord-bot-core/message-handler.js @@ -0,0 +1,99 @@ +//node imports +const Util = require("util"); + +//external lib imports +const Discord = require("discord.js"); + +//lib components +const Config = require("./config.json"); //generic lib configuration +const DiscordUtil = require("./util.js"); //some discordjs helper functions of mine +const ParentPackageJSON = require("../package.json"); //used to provide some info about the bot + +function handleDirectMessage(client, message) { + message.reply(Util.format(Config.generic.defaultDMResponse, Config.generic.website, Config.generic.discordInvite)); +} + +function handleTextMessage({ client, commands, message, guildDataModel, guildsData, component, writeFile }) { + const isCommand = message.content.startsWith(message.guild.me.toString()); + let guildData = guildsData[message.guild.id]; + + if (!guildData) + guildData = guildsData[message.guild.id] = new guildDataModel({ id: message.guild.id }); + + if (isCommand) { + Object.assign(commands, Config.commands); + const userIsAdmin = message.member.permissions.has("ADMINISTRATOR"); + const botName = "@" + (message.guild.me.nickname || client.user.username); + const { command, commandProp, params, expectedParamCount } = getCommandDetails(message, commands, userIsAdmin) || { command: null, commandProp: null, params: null, expectedParamCount: null }; + const invoke = component[commandProp]; + + if (!command || !params || isNaN(expectedParamCount)) + return; + + switch (command) { + case Config.commands.version: + message.reply(`${ParentPackageJSON.name} v${ParentPackageJSON.version}`); + break; + case Config.commands.help: + message.channel.send(createHelpEmbed(botName, commands, userIsAdmin)); + break; + default: + if (invoke && params.length >= expectedParamCount) { + invoke({ command, params: params, guildData, botName, message, client }) + .then(msg => { + message.reply(msg); + writeFile(); + }) + .catch(err => { + message.reply(err); + DiscordUtil.dateError(err); + }); + } + else + message.reply(`Incorrect syntax!\n**Expected:** *${botName} ${command.syntax}*\n**Need help?** *${botName} ${commands.help.command}*`); + break; + } + } + else + component.onTextMessage(message, guildData); +} + +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 }; +} + +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..e360982 --- /dev/null +++ b/discord-bot-core/package.json @@ -0,0 +1,23 @@ +{ + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "discord.js": "11.2.0", + "discordjs-util": "git+https://github.com/benji7425/discordjs-util.git", + "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..5967445 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" }, "dependencies": { "discord.js": "11.1.0",