diff --git a/.gitignore b/.gitignore index cbccd41..d5cbb5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -settings.json +*.db *.code-workspace # Logs @@ -73,7 +73,7 @@ typings/ # dotenv environment variables file .env -.env.test +.env.* # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/README.md b/README.md index 53250e5..b44eef5 100644 --- a/README.md +++ b/README.md @@ -2,43 +2,26 @@ A simple Discord bot for getting game discount info from [isthereanydeal.com](https://isthereanydeal.com). -## Requirements +## Usage -- Node.js v12 -- A [Discord bot token](https://discord.com/developers/applications) -- An [isthereanydeal.com API key](https://isthereanydeal.com/dev/app) +1. [Click here][invite-link] to invite the bot to your server. +2. Once the bot has joined, use the commands below in any channel! -## Setup +## Support the Project -Clone the repository to any local folder. - -Create a `.env` file in the folder with the following contents: - -``` -BOT_TOKEN=[token] -ITAD_KEY=[key] -``` - -Replace `[token]` with your Discord bot token. Replace `[key]` with your ITAD API key. - -Finally, run the following commands: - -```sh -$ npm install -$ node lib/index.js -``` +If you like what you see, consider helping with monthly server costs by clicking the "Sponsor" button on the repo or by following [this link][donate-link]. Any amount helps! ## Features - `$help` -Help me help you! +Forgot the commands again? - `$deals [game]` Gets a list of current deals for the specified game. Lookup relies on spelling, so misspellings may return nothing. If an exact match is not found, the bot will attempt to suggest something similar. - `$sellers` Lists all sellers. - `$ignoredsellers` Lists all ignored sellers. Ignored sellers do not appear in `$deals` lists. -- `$ignoredsellers [add|remove] [seller]` +- `$ignoredsellers [add|remove|clear] [seller]` Adds or removes an ignored seller. Seller must be spelled exactly as it appears in the `$sellers` command. - `$top [waitlisted|collected|popular]` Gets the top waitlisted, collected, or popular games. @@ -48,3 +31,6 @@ Gets the top waitlisted, collected, or popular games. ## License IsThereAnyDeal Lookup is licensed under the [MIT License](http://www.opensource.org/licenses/mit-license.php). + +[invite-link]: https://discord.com/api/oauth2/authorize?client_id=722942824999288924&permissions=93248&redirect_uri=https%3A%2F%2Fgithub.com%2Facdvs%2Fisthereanydeal-lookup&scope=bot +[donate-link]: https://www.patreon.com/acdvs \ No newline at end of file diff --git a/lib/commands/deals.js b/lib/commands/deals.js index 59ae918..28ecf1f 100644 --- a/lib/commands/deals.js +++ b/lib/commands/deals.js @@ -2,6 +2,7 @@ const Discord = require('discord.js'); const apiUtil = require('../util/api'); +const dbUtil = require('../util/db/db'); const SEARCH_EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣']; const CHAR_LIMIT = 1024; @@ -10,19 +11,18 @@ const CHAR_LIMIT = 1024; * Get game deals * @param {Object} msg Discord message * @param {string} game - * @param {Array} ignoredSellers */ -module.exports = async (msg, game, ignoredSellers) => { +module.exports = async (msg, game) => { try { const gameId = await apiUtil.getGameId(game); if (gameId) { - generateEmbed(msg, game, gameId, ignoredSellers); + generateEmbed(msg, game, gameId); } else { let similarGames = await apiUtil.search(game, 5); if (similarGames.length > 0) { - getAlternative(msg, similarGames, game, ignoredSellers); + getAlternative(msg, similarGames, game); } else { msg.channel.send(`No results were found for "${game}". Did you spell it correctly?`); } @@ -37,10 +37,9 @@ module.exports = async (msg, game, ignoredSellers) => { * @param {Discord.Message} msg * @param {Array} similarGames * @param {string} game - * @param {Aray} ignoredSellers * @returns {integer} */ -async function getAlternative(msg, similarGames, game, ignoredSellers) { +async function getAlternative(msg, similarGames, game) { const reply = await msg.channel.send( new Discord.MessageEmbed({ author: { @@ -71,7 +70,7 @@ async function getAlternative(msg, similarGames, game, ignoredSellers) { const alternativeId = similarGames[listIndex].plain; const alternativeName = similarGames[listIndex].title; - generateEmbed(msg, alternativeName, alternativeId, ignoredSellers); + generateEmbed(msg, alternativeName, alternativeId); }); collector.on('end', () => reply.reactions.removeAll()); @@ -82,10 +81,10 @@ async function getAlternative(msg, similarGames, game, ignoredSellers) { * @param {Discord.Message} msg Discord message * @param {string} game * @param {integer} gameId - * @param {Array} ignoredSellers */ -async function generateEmbed(msg, game, gameId, ignoredSellers) { +async function generateEmbed(msg, game, gameId) { try { + const ignoredSellers = await dbUtil.getIgnoredSellers(msg.guild.id); const gameData = await apiUtil.getGameData(gameId, ignoredSellers); const list = gameData && gameData.list.filter(x => x.price_new < x.price_old); @@ -112,15 +111,17 @@ async function generateEmbed(msg, game, gameId, ignoredSellers) { color: 0x23B2D5 }); + let rowCount = 0; + let sellersFieldVal; + // Check for field value overflow and add an extra line // with a link to ITAD for deals that don't fit. if (sellers.join('\n').length > CHAR_LIMIT) { // Overflow row needs to be predetermined to account for - // it's possible length for insertion. - const getOverflowText = rowCount => `[...and ${sellers.length - rowCount} more deals](${gameData.urls.game})`; + // its possible length for insertion. + const getOverflowText = currRowCount => `[...and ${sellers.length - currRowCount} more deals](${gameData.urls.game})`; let charTotal = getOverflowText(100).length; - let rowCount = 0; for (let i = 0; i < sellers.length; i++) { charTotal += sellers[i].length; @@ -131,46 +132,33 @@ async function generateEmbed(msg, game, gameId, ignoredSellers) { } } - embed.addFields([ - { - name: 'Seller', - value: [ - ...sellers.slice(0, rowCount), - getOverflowText(rowCount) - ].join('\n'), - inline: true - }, - { - name: 'New Price', - value: newPrices.slice(0, rowCount).join('\n'), - inline: true - }, - { - name: 'Old Price', - value: oldPrices.slice(0, rowCount).join('\n'), - inline: true - } - ]); + sellersFieldVal = [ + ...sellers.slice(0, rowCount), + getOverflowText(rowCount) + ].join('\n'); } else { - embed.addFields([ - { - name: 'Seller', - value: sellers.join('\n'), - inline: true - }, - { - name: 'New Price', - value: newPrices.join('\n'), - inline: true - }, - { - name: 'Old Price', - value: oldPrices.join('\n'), - inline: true - } - ]); + rowCount = sellers.length; + sellersFieldVal = sellers.join('\n'); } + embed.addFields([ + { + name: 'Seller', + value: sellersFieldVal, + inline: true + }, + { + name: 'New Price', + value: newPrices.slice(0, rowCount).join('\n'), + inline: true + }, + { + name: 'Old Price', + value: oldPrices.slice(0, rowCount).join('\n'), + inline: true + } + ]); + const histLowData = await apiUtil.getHistoricalLow(gameId); if (histLowData) { diff --git a/lib/commands/help.js b/lib/commands/help.js index 917a254..710e958 100644 --- a/lib/commands/help.js +++ b/lib/commands/help.js @@ -10,6 +10,10 @@ const Discord = require('discord.js'); module.exports = (msg, prefix) => { const embed = new Discord.MessageEmbed({ title: 'IsThereAnyDeal Lookup Help', + description: [ + 'If you like the bot, consider helping with monthly server costs.', + '[Donate via Patreon](https://www.patreon.com/acdvs). Any amount helps!' + ].join('\n'), color: 0x23B2D5, fields: [ { @@ -32,7 +36,7 @@ module.exports = (msg, prefix) => { ].join('\n') }, { - name: `${prefix}ignoredsellers [add|remove] [seller]`, + name: `${prefix}ignoredsellers [add|remove|clear] [seller]`, value: [ 'Adds or removes an ignored seller.', 'Seller must be spelled exactly as it appears in the `$sellers` command.' diff --git a/lib/commands/ignoredSellers.js b/lib/commands/ignoredSellers.js index 92a09ec..337f89d 100644 --- a/lib/commands/ignoredSellers.js +++ b/lib/commands/ignoredSellers.js @@ -1,22 +1,22 @@ 'use strict'; const Discord = require('discord.js'); -const apiUtil = require('../util/api'); -const settingsUtil = require('../util/settings'); +const dbUtil = require('../util/db/db'); /** * Get, add, or remove ignored sellers * @param {Object} msg Discord message * @param {string} options Message options - * @param {Object} settings Instance settings */ -module.exports = async (msg, options, settings) => { +module.exports = async (msg, options) => { if (!options) { - if (settings.ignoredSellers.length > 0) { + const ignoredSellers = await dbUtil.getIgnoredSellers(msg.guild.id); + + if (ignoredSellers.length > 0) { msg.channel.send( new Discord.MessageEmbed({ - title: `Ignored sellers (${settings.ignoredSellers.length})`, - description: settings.ignoredSellers.map(x => x.title).join('\n'), + title: `Ignored sellers (${ignoredSellers.length})`, + description: ignoredSellers.map(x => x.title).join('\n'), color: 0x23B2D5 }) ); @@ -30,32 +30,25 @@ module.exports = async (msg, options, settings) => { let [operation, seller] = options.split(/\s+(.*)/); let changed = false; - try { + if (seller) { if (operation === 'add') { - const sellerIsIgnored = !!settings.ignoredSellers.find(x => x.title === seller); - const sellers = await apiUtil.getSellers(); - - seller = sellers.filter(x => x.title === seller); - seller = seller && seller[0]; + const sellerIsIgnored = await dbUtil.hasIgnoredSeller(msg.guild.id, seller); - if (seller && !sellerIsIgnored) { - settings.ignoredSellers.push({ id: seller.id, title: seller.title }); - changed = true; + if (!sellerIsIgnored) { + changed = await dbUtil.addIgnoredSeller(msg.guild.id, seller); } } else if (operation === 'remove') { - const index = settings.ignoredSellers.findIndex(x => x.title === seller); + const sellerIsIgnored = await dbUtil.hasIgnoredSeller(msg.guild.id, seller); - if (index > -1) { - settings.ignoredSellers.splice(index, 1); - changed = true; + if (sellerIsIgnored) { + changed = await dbUtil.removeIgnoredSeller(msg.guild.id, seller); } } - } catch (e) { - console.error(e); + } else if (operation === 'clear') { + changed = await dbUtil.clearIgnoredSellers(msg.guild.id); } if (changed) { - settingsUtil.save(settings); msg.react('✅'); } }; \ No newline at end of file diff --git a/lib/commands/index.js b/lib/commands/index.js index 72b535d..56fda9f 100644 --- a/lib/commands/index.js +++ b/lib/commands/index.js @@ -8,5 +8,7 @@ module.exports = { top: require('./top') }; +// Command prefix +module.exports.PREFIX = '$'; // Seconds before another command can run -module.exports.LIMIT_SECONDS = 2; \ No newline at end of file +module.exports.LIMIT_SECONDS = 3; \ No newline at end of file diff --git a/lib/commands/sellers.js b/lib/commands/sellers.js index 269a38f..465a6ff 100644 --- a/lib/commands/sellers.js +++ b/lib/commands/sellers.js @@ -1,7 +1,7 @@ 'use strict'; const Discord = require('discord.js'); -const apiUtil = require('../util/api'); +const dbUtil = require('../util/db/db'); /** * Get all sellers for a region @@ -9,7 +9,7 @@ const apiUtil = require('../util/api'); */ module.exports = async (msg) => { try { - const sellers = await apiUtil.getSellers(); + const sellers = await dbUtil.getSellers(); msg.channel.send( new Discord.MessageEmbed({ diff --git a/lib/index.js b/lib/index.js index ee57c28..b7c6be7 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,71 +1,60 @@ 'use strict'; -require('dotenv').config(); +const path = require('path'); +const DEV = process.env.NODE_ENV !== 'prod'; +const envFile = DEV ? '.env.dev' : '.env'; -const Discord = require('discord.js'); -const bot = new Discord.Client(); +require('dotenv').config({ path: path.resolve(__dirname, `../${envFile}`) }); +const Discord = require('discord.js'); +const dbUtil = require('./util/db/db'); const commands = require('./commands'); -const settingsUtil = require('./util/settings'); -let settings; -let gate = true; +const bot = new Discord.Client(); +let gates = {}; // ========================= // Start bot // ========================= bot.login(process.env.BOT_TOKEN) - .then(() => console.log(`${bot.user.username} logged in`)) + .then(() => { + console.log(`${bot.user.username} logged in`); + dbUtil.openDatabase(); + }) .catch(err => console.error(err)); -bot.on('ready', async () => { - console.log(`${bot.user.username} successfully started`); - - try { - settings = await settingsUtil.load(); - - // If mismatch between current and default settings, - // merge missing defaults into current settings and save. - if (!keysMatch(settings, settingsUtil.DEFAULT_SETTINGS)) { - settings = Object.assign({}, settingsUtil.DEFAULT_SETTINGS, settings); - settingsUtil.save(settings); +// ========================= +// Events +// ========================= - console.log('Updated missing settings'); - } - } catch (e) { - settings = settingsUtil.DEFAULT_SETTINGS; - console.error(e); - } +bot.on('ready', () => { + console.log(`${bot.user.username} successfully started`); bot.user.setPresence({ activity: { type: 'WATCHING', - name: `for ${settings.prefix}deals` + name: `for ${commands.PREFIX}deals` } }); }); -// ========================= -// On message -// ========================= - bot.on('message', (msg) => { const [command, options] = msg.content.split(/\s+(.*)/); - if (!gate || !command.startsWith(settings.prefix)) { + if (!msg.guild || gates[msg.guild.id] || !command.startsWith(commands.PREFIX)) { return; } - switch (command.substring(settings.prefix.length)) { + switch (command.substring(commands.PREFIX.length)) { case 'help': - commands.help(msg, settings.prefix); + commands.help(msg, commands.PREFIX); break; case 'deals': - if (options) commands.deals(msg, options, settings.ignoredSellers); + if (options) commands.deals(msg, options); break; case 'ignoredsellers': - commands.ignoredSellers(msg, options, settings); + commands.ignoredSellers(msg, options); break; case 'sellers': commands.sellers(msg); @@ -75,26 +64,19 @@ bot.on('message', (msg) => { break; } - // Disallow new commands - gate = false; - - // Allow next command after a delay - setTimeout(() => { - gate = true; - }, commands.LIMIT_SECONDS * 1000); + // Limit command usage + gates[msg.guild.id] = true; + setTimeout(() => delete gates[msg.guild.id], commands.LIMIT_SECONDS * 1000); }); -// ========================= -// Helpers -// ========================= +bot.on('guildCreate', (guild) => { + if (DEV) console.log(`Server joined. COUNT: ${bot.guilds.cache.array().length}`); -function keysMatch(a, b) { - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); + dbUtil.insertServer(guild.id); +}); - if (a === b) return true; - if (a === null || b === null) return false; - if (aKeys.length !== bKeys.length) return false; +bot.on('guildDelete', (guild) => { + if (DEV) console.log(`Server left. COUNT: ${bot.guilds.cache.array().length}`); - return aKeys.every(key => !!b[key]); -} \ No newline at end of file + dbUtil.removeServer(guild.id); +}); \ No newline at end of file diff --git a/lib/util/db/db.js b/lib/util/db/db.js new file mode 100644 index 0000000..4f09768 --- /dev/null +++ b/lib/util/db/db.js @@ -0,0 +1,134 @@ +'use strict'; + +const path = require('path'); +const Database = require('sqlite-async'); +const apiUtil = require('../api'); + +let db; + +const openDatabase = async () => { + db = await Database.open(path.join(__dirname, 'settings.db')); + db.run('PRAGMA foreign_keys = ON'); + + console.log('Connected to database'); +}; + +/** + * Insert server ID + * @param {integer} id Discord guild ID + * @return {boolean} + */ +const insertServer = async (id) => { + const res = await db.run('INSERT INTO servers (id) VALUES (?)', id); + + return res && res.changes === 1; +}; + +/** + * Remove server ID + * @param {integer} id Discord guild ID + * @return {boolean} + */ +const removeServer = async (id) => { + const res = await db.run('DELETE FROM servers WHERE id = ?', id); + + return res && res.changes === 1; +}; + +/** + * Get all sellers + * @return {Array} + */ +const getSellers = async () => { + const rows = await db.all('SELECT title FROM sellers'); + + return rows; +}; + +/** + * Get ignored sellers for a server + * @param {integer} id Discord guild ID + * @return {Array} + */ +const getIgnoredSellers = async (id) => { + const rows = await db.all(` + SELECT * FROM sellers s + LEFT JOIN ignoredsellers i ON s.id = i.sellerid + WHERE i.serverid = ? + `, id); + + return rows; +}; + +/** + * Check if server has an ignored seller + * @param {integer} serverId Discord guild ID + * @param {integer} sellerTitle ITAD seller title + * @returns {boolean} + */ +const hasIgnoredSeller = async (serverId, sellerTitle) => { + const sellers = await apiUtil.getSellers(); + const seller = sellers.find(x => x.title === sellerTitle); + + const row = await db.get(` + SELECT * FROM ignoredsellers + WHERE serverid = ? AND sellerid IN ( + SELECT s.id FROM sellers s WHERE s.title = ? + )`, serverId, sellerTitle); + + return seller && !!row; +}; + +/** + * Add ignored seller to server + * @param {integer} serverId Discord guild ID + * @param {string} sellerTitle ITAD seller title + * @returns {boolean} + */ +const addIgnoredSeller = async (serverId, sellerTitle) => { + const res = await db.run(` + INSERT INTO ignoredsellers (serverid, sellerid) + VALUES (?, (SELECT id FROM sellers WHERE title = ?)) + `, serverId, sellerTitle); + + return res && res.changes === 1; +}; + +/** + * Remove ignored seller from server + * @param {integer} serverId Discord guild ID + * @param {string} sellerTitle ITAD seller title + * @returns {boolean} + */ +const removeIgnoredSeller = async (serverId, sellerTitle) => { + const res = await db.run(` + DELETE FROM ignoredsellers + WHERE serverid = ? AND sellerid IN ( + SELECT s.id FROM sellers s WHERE s.title = ? + )`, serverId, sellerTitle); + + return res && res.changes === 1; +}; + +/** + * Clear all ignored sellers from server + * @param {integer} serverId Discord guild ID + * @returns {boolean} + */ +const clearIgnoredSellers = async (serverId) => { + const res = await db.run('DELETE FROM ignoredsellers WHERE serverid = ?', serverId); + + return res && res.changes > 0; +}; + +module.exports = { + openDatabase, + insertServer, + removeServer, + getSellers, + getIgnoredSellers, + hasIgnoredSeller, + addIgnoredSeller, + removeIgnoredSeller, + clearIgnoredSellers +}; \ No newline at end of file diff --git a/lib/util/db/setup.js b/lib/util/db/setup.js new file mode 100644 index 0000000..632cb74 --- /dev/null +++ b/lib/util/db/setup.js @@ -0,0 +1,40 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const dbPath = path.join(__dirname, 'settings.db'); + +if (!fs.existsSync(dbPath)) { + const sqlite3 = require('sqlite3').verbose(); + const db = new sqlite3.Database(dbPath); + + const apiUtil = require('../api'); + + db.serialize(async function() { + db.run('CREATE TABLE servers (id INTEGER PRIMARY KEY)'); + db.run('CREATE TABLE sellers (id TEXT PRIMARY KEY, title TEXT NOT NULL)'); + db.run(`CREATE TABLE ignoredsellers ( + serverid INTEGER NOT NULL, + sellerid TEXT NOT NULL, + FOREIGN KEY (serverid) REFERENCES servers(id) ON DELETE CASCADE, + FOREIGN KEY (sellerid) REFERENCES sellers(id) ON DELETE CASCADE + )`); + + db.run('CREATE UNIQUE INDEX ignoredindex ON ignoredsellers (serverid, sellerid)'); + + const insertSeller = db.prepare('INSERT INTO sellers (id, title) VALUES (?, ?)'); + const sellers = await apiUtil.getSellers(); + + for (const seller of sellers) { + insertSeller.run(seller.id, seller.title); + } + + insertSeller.finalize(); + + db.close(); + + console.log('Database created'); + }); +} else { + console.log('Database already created'); +} diff --git a/lib/util/settings.js b/lib/util/settings.js deleted file mode 100644 index e30d0db..0000000 --- a/lib/util/settings.js +++ /dev/null @@ -1,46 +0,0 @@ -'use strict'; - -const util = require('util'); -const fs = require('fs'); -const path = require('path'); - -const readFile = util.promisify(fs.readFile); - -const DEFAULT_SETTINGS = { - prefix: '$', - ignoredSellers: [] -}; - -/** - * Save settings to local file - * @param {Object} settings - */ -const save = (settings) => { - fs.writeFile('settings.json', JSON.stringify(settings, null, 2), (err) => { - if (err) console.error('SETTINGS SAVE ERROR: ', err); - }); -}; - -/** - * Load settings from local file - * @returns {Object} - */ -const load = async () => { - let settings = DEFAULT_SETTINGS; - - try { - const data = await readFile(path.join(__dirname, '../../settings.json'), 'utf8'); - settings = JSON.parse(data); - } catch (e) { - console.error('No existing settings file found. Creating...'); - save(settings); - } - - return settings; -}; - -module.exports = { - DEFAULT_SETTINGS, - save, - load -}; \ No newline at end of file diff --git a/package.json b/package.json index 00e90a6..4cc17dd 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,25 @@ { "name": "isthereanydeal-lookup", - "version": "1.0.1", + "version": "1.3.1", "description": "Discord bot for getting game discount info from isthereanydeal.com", "author": "Adam Davies (https://adam-davies.me)", "main": "lib/index.js", "license": "MIT", "scripts": { - "test": "eslint lib/** --fix" + "db:create": "node lib/util/db/setup.js", + "start": "npm run db:create && node lib/index.js NODE_ENV=dev", + "test": "eslint lib/**.js --fix" }, "repository": { "type": "git", - "url": "https://github.com/adamdavies001/isthereanydeal-lookup.git" + "url": "https://github.com/acdvs/isthereanydeal-lookup.git" }, - "homepage": "https://github.com/adamdavies001/isthereanydeal-lookup#readme", + "homepage": "https://github.com/acdvs/isthereanydeal-lookup#readme", "dependencies": { - "axios": "^0.19.2", - "discord.js": "^12.2.0", + "axios": "^0.21.1", + "discord.js": "^12.5.1", "dotenv": "^8.2.0", - "eslint": "^7.2.0" + "eslint": "^7.17.0", + "sqlite-async": "^1.1.1" } }