diff --git a/.eslintrc.json b/.eslintrc.json index c34b295..3b41d57 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -11,13 +11,9 @@ }, "rules": { "indent": [ - "error", + "warn", "tab" ], - "linebreak-style": [ - "error", - "windows" - ], "quotes": [ "warn", "double" @@ -26,7 +22,7 @@ "error", "always" ], - "no-undef": "warn", + "no-undef": "error", "no-unused-vars": "warn" } } \ No newline at end of file diff --git a/.gitignore b/.gitignore index 961e6b0..699ef30 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,5 @@ jspm_packages # Output of 'npm pack' *.tgz -/botConfig.json \ No newline at end of file +/botConfig.json +/bot-config.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e8eb5db --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## 1.1.0 pre + +### 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 + +### Changed +- 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 diff --git a/README.md b/README.md index 7d8d18a..f314600 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,20 @@ # Features - Posts latest link from RSS feed into specified Discord channel -- Doesn't post the link if it has already been posted in last 100 messages - Configurable polling interval - -# Planned features - -- Addition of user-defined URLs to match as 'sent' (ie if a user posts a youtu.be link, the bot will still post a youtube.com link, even if they point to the same palce - I would like to add a setting whereby you can specify alternate hosts to match) +- Doesn't post the link if it has already been posted in last 100 messages +- YouTube mode - detects both youtube.com and youtu.be links, and doesn't post again if *either* have already been posted (BETA) Feel free to contact me with suggestions and feature requests - if you need a new feature, just let me know and I will see what I can do! (No promises though :p) # Installation 1. Make sure you have nodejs (v6+) and npm installed -2. Clone repo or download zip and extract somewhere -3. Open a terminal in cloned/extracted folder +2. Download the zip from [releases](https://github.com/benji7425/discord-feed-bot/releases) and extract +3. Open a terminal in extracted folder 4. Run `npm install` and wait for it to finish 5. Edit *config.json* to include your RSS feed and channel ID -6. Create *botConfig.json* to include your bot token: +6. Create *bot-config.json* to include your bot token: `{ "token": "abc123blahblahblahyourtokengoeshere" }` diff --git a/config.json b/config.json index 596b3ea..2822763 100644 --- a/config.json +++ b/config.json @@ -2,5 +2,6 @@ "feedUrl": "https://www.youtube.com/feeds/videos.xml?user=EthosLab", "channelID": "241238530376990722", "pollingInterval": 5000, - "numLinksToCache": 10 + "numLinksToCache": 10, + "youtubeMode": true } \ No newline at end of file diff --git a/feed-bot.js b/feed-bot.js index 2cfe5b0..30b05c0 100644 --- a/feed-bot.js +++ b/feed-bot.js @@ -1,148 +1,208 @@ -var console = require("console"); //for console logging +//external library imports var Dns = require("dns"); //for connectivity checking var Url = require("url"); //for url parsing var Uri = require("urijs"); //for finding urls within message strings var Discord = require("discord.io"); //for obvious reasons var FeedRead = require("feed-read"); //for rss feed reading -var BotConfig = require("./botConfig.json"); //bot config file containing bot token + +//my imports +var Log = require("./log.js"); //some very simple logging functions I made +var BotConfig = require("./bot-config.json"); //bot config file containing bot token var Config = require("./config.json"); //config file containing other settings -var verboseLogging = false; +var IS_FIRST_RUN = true; -//get a URL object from the feedUrl so we can examine it and check connectivity later -var url = Url.parse(Config.feedUrl); - -//placeholder for our bot - we need to check for connectivity before assigning this though -var bot; -var latestFeedLink = ""; -var linkRegExp = new RegExp(["http", "https", "www"].join("|")); -var cachedLinks = []; -//caches a link so we can check again later -function cacheLink(link) { - //cheaty way to get around http and https not matching - link = link.replace("https://", "http://"); - //store the new link if not stored already - if (!cachedLinks.includes(link)) { - cachedLinks.push(link); - logEvent("Cached URL: " + link); - } - //get rid of the first array element if we have reached our cache limit - if (cachedLinks.length > (Config.numLinksToCache || 10)) - cachedLinks.shift(); -} - -//check if we can connect to discordapp.com to authenticate the bot -Dns.resolve("discordapp.com", function (err) { - if (err) reportError("CONNECTION ERROR: Unable to locate discordapp.com to authenticate the bot (you are probably not connected to the internet). Details: " + (err.message || err)); - else { - //if there was no error, go ahead and create and authenticate the bot - bot = new Discord.Client({ - token: BotConfig.token, - autorun: true - }); - - //when the bot is ready, set a polling interval for the rss feed - bot.on("ready", function () { - logEvent(new Date().toLocaleString() + " Registered bot " + bot.username + " - (" + bot.id + ")"); - - //as we don't have any links cached, we need to check recent messages - checkPreviousMessagesForLinks(); - - logEvent("Setting up timer to check feed every " + Config.pollingInterval + " milliseconds"); - setInterval(checkFeedAndPost, Config.pollingInterval); - }); - - bot.on("disconnect", function (err, code) { - logEvent("Bot was disconnected! " + code != null ? code : "No disconnect code provided"); - if (err) reportError("Bot disconnect error: " + (err.message || err)); - logEvent("Trying to reconnect bot"); - bot.connect(); - }); - - bot.on("message", function (user, userID, channelID, message) { - //check if the message contains a link, in the right channel, and not the latest link from the rss feed - if (channelID === Config.channelID && linkRegExp.test(message) && (message !== latestFeedLink)) { - logEvent("Detected posted link in this message: " + message); - //detect the url inside the string, and cache it - Uri.withinString(message, function (url) { - cacheLink(url); - return url; +var Bot = { + bot: null, + startup: function () { + //check if we can connect to discordapp.com to authenticate the bot + Dns.resolve("discordapp.com", function (err) { + if (err) Log.error("CONNECTION ERROR: Unable to locate discordapp.com to authenticate the bot (you are probably not connected to the internet).", err); + else { + //if there was no error, go ahead and create and authenticate the bot + Bot.bot = new Discord.Client({ + token: BotConfig.token, + autorun: true }); + + //set up the bot's event handlers + Bot.bot.on("ready", Bot.onReady); + Bot.bot.on("disconnect", Bot.onDisconnect); + Bot.bot.on("message", Bot.onMessage); + } + }); + }, + onReady: function () { + if (IS_FIRST_RUN) { + IS_FIRST_RUN = false; + + Log.info("Registered bot " + Bot.bot.username + " - (" + Bot.bot.id + ")"); + Log.info("Setting up timer to check feed every " + Config.pollingInterval + " milliseconds"); + + //set up the timer to check the feed + setInterval(Feed.checkAndPost, Config.pollingInterval); + } + else { + Log.info("Bot reconnected!"); + } + + //we need to check past messages for links on startup, but also on reconnect because we don't know what has happened during the downtime + Bot.checkPastMessagesForLinks(); + }, + onDisconnect: function (err, code) { + //do a bunch of logging + Log.event("Bot was disconnected! " + code ? code : "No disconnect code provided", "Discord.io"); + if (err) Log.error("Bot disconnected!", err); + Log.info("Trying to reconnect bot"); + + //then actually attempt to reconnect + Bot.bot.connect(); + }, + onMessage: function (user, userID, channelID, message) { + //check if the message contains a link, in the right channel, and not the latest link from the rss feed + if (channelID === Config.channelID && Links.regExp.test(message) && (message !== Links.latestFromFeed)) { + Log.event("Detected posted link in this message: " + message, "Discord.io"); + //detect the url inside the string, and cache it + Uri.withinString(message, function (url) { + Links.cache(url); + return url; + }); + } + }, + //gets last 100 messages and extracts any links found (for use on startup) + checkPastMessagesForLinks: function () { + var limit = 100; + Log.info("Attempting to check past " + limit + " messages for links"); + + //get the last however many messsages from our discord channel + Bot.bot.getMessages({ + channelID: Config.channelID, + limit: limit + }, function (err, messages) { + if (err) Log.error("Error fetching discord messages.", err); + else { + Log.info("Pulled last " + messages.length + " messages, scanning for links"); + + //extract an array of strings from the array of message objects + var messageContents = messages.map((x) => { return x.content; }).reverse(); + + for (var messageIdx in messageContents) { + var message = messageContents[messageIdx]; + + //test if the message contains a url + if (Links.regExp.test(message)) + //detect the url inside the string, and cache it + Uri.withinString(message, function (url) { + Links.cache(url); + return url; + }); + } } }); } -}); +}; -function checkFeedAndPost() { - //check that we have an internet connection (well not exactly - check that we have a connection to the host of the feedUrl) - Dns.resolve(url.host, function (err) { - if (err) reportError("CONNECTION ERROR: Cannot resolve host (you are probably not connected to the internet). Details: " + (err.message || err)); - else FeedRead(Config.feedUrl, checkLinkAndPost); - }); -} +var YouTube = { + url: { + share: "http://youtu.be/", + full: "http://www.youtube.com/watch?v=", + convertShareToFull: function (shareUrl) { + return shareUrl.replace(YouTube.url.share, YouTube.url.full); + }, + convertFullToShare: function (fullUrl) { + var shareUrl = fullUrl.replace(YouTube.url.full, YouTube.url.share); -//checks if the link has been posted previously, posts if not -function checkLinkAndPost(err, articles) { - if (err) reportError("FEED ERROR: Error reading RSS feed. Details: " + (err.message || err)); - else { - //get the latest link and check if it has already been posted and cached - var latestLink = articles[0].link.replace("https", "http"); + if (shareUrl.includes("&")) + shareUrl = shareUrl.slice(0, fullUrl.indexOf("&")); - if (!cachedLinks.includes(latestLink)) { - logEvent("Attempting to post new link: " + latestLink); - bot.sendMessage({ - to: Config.channelID, - message: latestLink - }, function (err, message) { - if(err) reportError("ERROR: Failed to send message: " + (err.message || err) + " " + message); - logEvent("Checking bot connectivity"); - if (bot.connected) - logEvent("Connectivity seems fine - I have no idea why the message didn't post"); - else { - reportError("Bot appears to be disconnected! Attempting to reconnect...") - bot.connect(); - } - - }); - cacheLink(latestLink); + return shareUrl; } - else if (latestFeedLink != latestLink) - logEvent("Didn't post new feed link because already detected as posted " + latestLink); + }, +}; - latestFeedLink = latestLink; - } -} +var Links = { + regExp: new RegExp(["http", "https", "www"].join("|")), + cached: [], + latestFromFeed: "", + cache: function (link) { + //cheaty way to get around http and https not matching + link = link.replace("https://", "http://"); -//gets last 100 messages and extracts any links found (for use on startup) -function checkPreviousMessagesForLinks() { - var limit = 100; - logEvent("Attempting to check past " + limit + " messages for links"); - bot.getMessages({ - channelID: Config.channelID, - limit: limit - }, function (err, messages) { - if (err) reportError("Error fetching discord messages. Details: " + (err.message || err)); + if(Config.youtubeMode && link.includes(YouTube.url.full)){ + link = YouTube.url.convertFullToShare(link); + } + + //store the new link if not stored already + if (!Links.cached.includes(link)) { + Links.cached.push(link); + Log.info("Cached URL: " + link); + } + //get rid of the first array element if we have reached our cache limit + if (Links.cached.length > (Config.numLinksToCache || 10)) + Links.cached.shift(); + }, + checkCache: function (link) { + if (Config.youtubeMode && link.includes(link)) { + return Links.cached.includes(YouTube.url.convertFullToShare(link)); + } + return Links.cached.includes(link); + }, + validateAndPost: function (err, articles) { + if (err) Log.error("FEED ERROR: Error reading RSS feed.", err); else { - logEvent("Pulled last " + messages.length + " messages, scanning for links"); - var messageContents = messages.map((x) => { return x.content; }).reverse(); - for (var message in messageContents) { - message = messageContents[message]; - if (linkRegExp.test(message)) - //detect the url inside the string, and cache it - Uri.withinString(message, function (url) { - cacheLink(url); - return url; - }); + //get the latest link and check if it has already been posted and cached + var latestLink = articles[0].link.replace("https", "http"); + + //check whether the latest link out the feed exists in our cache + if (!Links.checkCache(latestLink)) { + if (Config.youtubeMode && latestLink.includes(YouTube.url.full)) + latestLink = YouTube.url.convertFullToShare(latestLink); + Log.info("Attempting to post new link: " + latestLink); + + //send a messsage containing the new feed link to our discord channel + Bot.bot.sendMessage({ + to: Config.channelID, + message: latestLink + }, function (err, message) { + if (err) { + Log.error("ERROR: Failed to send message: " + message.substring(0, 15) + "...", err); + //if there is an error posting the message, check if it is because the bot isn't connected + if (Bot.bot.connected) + Log.info("Connectivity seems fine - I have no idea why the message didn't post"); + else { + Log.error("Bot appears to be disconnected! Attempting to reconnect...", err); + + //attempt to reconnect + Bot.bot.connect(); + } + } + }); + + //finally make sure the link is cached, so it doesn't get posted again + Links.cache(latestLink); } + else if (Links.latestFromFeed != latestLink) + //alternatively, if we have a new link from the feed, but its been posted already, just alert the console + Log.info("Didn't post new feed link because already detected as posted " + latestLink); + + //ensure our latest feed link variable is up to date, so we can track when the feed updates + Links.latestFromFeed = latestLink; } - }); -} + } +}; -function logEvent(message) { - console.log(new Date().toLocaleString() + " " + message); -} +var Feed = { + urlObj: Url.parse(Config.feedUrl), + checkAndPost: function () { + //check that we have an internet connection (well not exactly - check that we have a connection to the host of the feedUrl) + Dns.resolve(Feed.urlObj.host, function (err) { + if (err) Log.error("CONNECTION ERROR: Cannot resolve host (you are probably not connected to the internet)", err); + else FeedRead(Config.feedUrl, Links.validateAndPost); + }); + } +}; -//logs error to console with a timestamp -function reportError(message) { - console.log(new Date().toLocaleString() + " ERROR: " + message); -} \ No newline at end of file +//IIFE to kickstart the bot when the app loads +(function(){ + Bot.startup(); +})(); \ No newline at end of file diff --git a/log.js b/log.js new file mode 100644 index 0000000..8a8b115 --- /dev/null +++ b/log.js @@ -0,0 +1,26 @@ +var console = require("console"); + +function log(message) { + if (message) + //attach a formatted date string to the beginning of everything we log + console.log(new Date().toLocaleString() + " " + message); +} + +module.exports = { + info: function (message) { + if (message) + log("INFO: " + message); + }, + event: function (message, sender) { + //if we received a message, log it - include sender information if it was passed + if (message) { + log("EVENT: " + (sender ? sender + " has sent an event: " : "") + message); + } + }, + error: function (message, innerEx) { + if (message) { + //log the message, attach innerEx information if it was passed + log("ERROR: " + message + (innerEx ? ". Inner exception details: " + (innerEx.message || innerEx) : "")); + } + } +}; \ No newline at end of file