From c79267845230774680844dc1ae84231697833e31 Mon Sep 17 00:00:00 2001 From: benji7425 Date: Sat, 9 Sep 2017 20:26:04 +0100 Subject: [PATCH] Add v2 functionality --- app/bot.js | 88 +++++++++++++++++++++++++++++++++++---- app/config.json | 4 +- app/models/feed-data.js | 89 ++++++++++++++++++++++++++++++++++++++++ app/models/guild-data.js | 20 ++++++++- 4 files changed, 191 insertions(+), 10 deletions(-) create mode 100644 app/models/feed-data.js diff --git a/app/bot.js b/app/bot.js index 30ef1cd..3cc2706 100644 --- a/app/bot.js +++ b/app/bot.js @@ -1,4 +1,12 @@ +//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) { @@ -19,17 +27,83 @@ module.exports = { } }, onNonCommandMsg(message, guildData) { - return; + 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() { - //todo +function addFeed(client, guildsData, 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 (!guildsData[message.guild.id]) + guildsData[message.guild.id] = new GuildData({ id: message.guild.id, feeds: [] }); + + guildsData[message.guild.id].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() { - //todo +function removeFeed(guildsData, 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 guildData = guildsData[message.guild.id]; + 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); + reject("Feed removed!"); + } + } + }); } -function viewFeeds() { - //todo + +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/config.json b/app/config.json index 34468f9..b1fd888 100644 --- a/app/config.json +++ b/app/config.json @@ -4,7 +4,9 @@ "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" }, + "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" + }, + "feedCheckIntervalSec": 30, "commands": { "version": { "command": "version", diff --git a/app/models/feed-data.js b/app/models/feed-data.js new file mode 100644 index 0000000..da5a90c --- /dev/null +++ b/app/models/feed-data.js @@ -0,0 +1,89 @@ +//my imports +const DiscordUtil = require("discordjs-util"); + +//external lib imports +const Dns = require("dns"); //for host resolution checking +const Url = require("url"); //for url parsing +const FeedRead = require("feed-read"); //for extracing new links from RSS feeds +const GetUrls = require("get-urls"); //for extracting urls from messages +const ShortID = require("shortid"); //to provide ids for each feed, allowing guilds to remove them + +module.exports = class FeedData { + constructor({ id, url, channelName, roleName, cachedLinks, maxCacheSize }) { + this.id = id || ShortID.generate(); + this.url = url; + this.channelName = channelName; + this.roleName = roleName; + this.cachedLinks = cachedLinks || []; + this.maxCacheSize = maxCacheSize || 10; + + this.cachedLinks.push = (...elements) => { + const unique = elements + .map(el => normaliseUrl(el)) //normalise all the urls + .filter(el => !this.cachedLinks.includes(el)); //filter out any already cached + Array.prototype.push.apply(this.cachedLinks, unique); + + if (this.cachedLinks.length > this.maxCacheSize) + this.cachedLinks.splice(0, this.cachedLinks.length - this.maxCacheSize); //remove the # of elements above the max from the beginning + }; + } + + /** + * Returns a promise providing all the links posted in the last 100 messages + * @param {Discord.Guild} guild The guild this feed belongs to + * @returns {Promise} Links posted in last 100 messages + */ + updatePastPostedLinks(guild) { + const channel = guild.channels.find(ch => ch.type === "text" && ch.name === this.channelName); + + return new Promise((resolve, reject) => { + channel.fetchMessages({ limit: 100 }) + .then(messages => { + new Map([...messages].reverse()).forEach(m => this.cachedLinks.push(...GetUrls(m.content))); //push all the links in each message into our links array + resolve(this); + }) + .catch(reject); + }); + } + + check(guild) { + Dns.resolve(Url.parse(this.url).host || "", err => { //check we can resolve the host, so we can throw an appropriate error if it fails + if (err) + DiscordUtil.dateError("Connection Error: Can't resolve host", err); //log our error if we can't resolve the host + else + FeedRead(this.url, (err, articles) => { //check the feed + if (err) + DiscordUtil.dateError(err); + else { + let latest = articles[0].link; //extract the latest link + latest = normaliseUrl(latest); //standardise it a bit + + //if we don't have it cached already, cache it and callback + if (!this.cachedLinks.includes(latest)) { + this.cachedLinks.push(latest); + + const channel = guild.channels.find(ch => ch.type === "text" && ch.name.toLowerCase() === this.channelName.toLowerCase()); + const role = this.roleName ? guild.roles.find(role => role.name.toLowerCase() === this.roleName.toLowerCase()) : null; + channel.send((role ? role + " " : "") + latest); + } + } + }); + }); + } + + toString() { + const blacklist = ["cachedLinks", "maxCacheSize"]; + return `\`\`\`JavaScript\n ${JSON.stringify(this, (k, v) => !blacklist.includes(k) ? v : undefined, "\t")} \`\`\``; + } +}; + +function normaliseUrl(url) { + url = url.replace("https://", "http://"); //cheaty way to get around http and https not matching + + if (Url.parse(url).host.includes("youtu")) //detect youtu.be and youtube.com - yes I know it's hacky + url = url.split("&")[0]; //quick way to chop off stuff like &feature=youtube + + url = url.replace(/(www.)?youtube.com\/watch\?v=/, "youtu.be/"); //turn full url into share url + + return url; +} \ No newline at end of file diff --git a/app/models/guild-data.js b/app/models/guild-data.js index 2060d58..d1ea352 100644 --- a/app/models/guild-data.js +++ b/app/models/guild-data.js @@ -1,7 +1,23 @@ -const DiscordUtil = require("discordjs-util"); +const FeedData = require("./feed-data.js"); +const Util = require("discordjs-util"); module.exports = class GuildData { - constructor({ id }) { + constructor({ id, feeds }) { 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