├── views ├── index.html ├── layouts │ └── main.handlebars └── recent.handlebars ├── watch.json ├── test.js ├── bot ├── db.js ├── permissions.js ├── dm-commands.js ├── utils.js ├── index.js └── commands.js ├── package.json ├── server.js ├── .glitch-assets ├── plugins ├── messageCount.js └── topPosters.js ├── utils ├── db.js └── image.js ├── README.md ├── libs └── blockhash.js └── shrinkwrap.yaml /views/index.html: -------------------------------------------------------------------------------- 1 | hi -------------------------------------------------------------------------------- /watch.json: -------------------------------------------------------------------------------- 1 | { 2 | "install": { 3 | "include": [ 4 | "^.trigger-rebuild$" 5 | ] 6 | }, 7 | "throttle": 100 8 | } 9 | -------------------------------------------------------------------------------- /views/layouts/main.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{{body}}} 10 | 11 | 12 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | const db = require('./bot/db.js'); 2 | const probeSize = require('probe-image-size'); 3 | 4 | /* 5 | const Discord = require('discord.js'); 6 | const client = new Discord.Client(); 7 | 8 | 9 | client.on('ready', async () => { 10 | let msg = await client.channels.get('557308996701257772').fetchMessage('674642702981267456'); 11 | console.log(msg.embeds[0].thumbnail, msg.embeds[0].provider) 12 | }) 13 | */ 14 | 15 | 16 | client.login(process.env.TOKEN).catch(ex => {throw ex}); 17 | 18 | -------------------------------------------------------------------------------- /views/recent.handlebars: -------------------------------------------------------------------------------- 1 | {{#each servers}} 2 |
3 |

4 | {{name}} ({{count}}) 5 |

6 |
7 | {{#each images}} 8 |
9 | 10 |
11 | {{/each}} 12 |
13 |
14 | {{/each}} 15 | 16 | -------------------------------------------------------------------------------- /bot/db.js: -------------------------------------------------------------------------------- 1 | var Datastore = require('nedb-promises') 2 | 3 | module.exports.images = Datastore.create({ filename: '.data/images', autoload: true, timestampData: false }); 4 | module.exports.users = Datastore.create({ filename: '.data/users', autoload: true, timestampData: false }); 5 | module.exports.channels = Datastore.create({ filename: '.data/channels', autoload: true, timestampData: false }); 6 | module.exports.servers = Datastore.create({filename: '.data/servers', autoload: true, timestampData: false}) 7 | 8 | module.exports.images.ensureIndex({ fieldName: 'hash' }, console.log); 9 | module.exports.images.ensureIndex({ fieldName: 'createdTimestamp' }, console.log); 10 | module.exports.images.ensureIndex({ fieldName: 'channelId' }, console.log); 11 | module.exports.images.ensureIndex({ fieldName: 'guildId' }, console.log); 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "//1": "describes your app and its dependencies", 3 | "//2": "https://docs.npmjs.com/files/package.json", 4 | "//3": "updating this file will download and update your packages", 5 | "name": "discord-image-dupe", 6 | "version": "1.0.0", 7 | "description": "Discord bot for detecting duplicate image posts", 8 | "main": "server.js", 9 | "scripts": { 10 | "start": "node server.js" 11 | }, 12 | "dependencies": { 13 | "axios": "*", 14 | "discord.js": "11.6.4", 15 | "express": "*", 16 | "express-handlebars": "*", 17 | "jpeg-js": "*", 18 | "nedb": "*", 19 | "nedb-promises": "*", 20 | "pngjs": "*", 21 | "pretty-bytes": "5.6.0", 22 | "probe-image-size": "*", 23 | "timeago.js": "*", 24 | "xhr2": "*" 25 | }, 26 | "repository": { 27 | "url": "https://github.com/juvian/discord-image-dupe" 28 | }, 29 | "license": "MIT", 30 | "keywords": [ 31 | "node", 32 | "discord", 33 | "bot", 34 | "duplicate" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /bot/permissions.js: -------------------------------------------------------------------------------- 1 | const dbUtils = require('../utils/db.js') 2 | const bot = require('../bot/index.js') 3 | 4 | function permission(verify, action) { 5 | return async function (message) { 6 | message.config = message.config || (await dbUtils.getServerConfig(message.guild.id)); 7 | if (message.isCommand && message.author.id != process.env.BOT_OWNER && message.config && !verify(message, message.config)) { 8 | return await bot.notify(message, "Insufficient Permissions"); 9 | } else { 10 | await action.apply(null, Array.from(arguments)); 11 | } 12 | } 13 | } 14 | 15 | function admin (func) { 16 | return permission((message, config) => message.member.hasPermission('ADMINISTRATOR', false, true, true) || message.member.roles.map(r => r.name.toLowerCase()).includes(config['adminRole'].toLowerCase()) ,func); 17 | } 18 | 19 | function mod (func) { 20 | return permission((message, config) => message.member.hasPermission('MANAGE_GUILD', false, true, true) || message.member.roles.map(r => r.name.toLowerCase()).includes(config['modRole'].toLowerCase()), func); 21 | } 22 | 23 | function owner (func) { 24 | return permission((message, config) => message.guild.owner.id == message.author.id, func); 25 | } 26 | 27 | module.exports = { 28 | admin: admin, 29 | mod: mod, 30 | owner: owner 31 | } 32 | -------------------------------------------------------------------------------- /bot/dm-commands.js: -------------------------------------------------------------------------------- 1 | const bot = require('../bot/index.js'); 2 | const db = require('../bot/db.js'); 3 | const botUtils = require('../bot/utils.js'); 4 | 5 | async function recentServerActivity (message) { 6 | let images = await db.images.find({}).sort({createdTimestamp: -1}).limit(500); 7 | let count = {} 8 | 9 | for (let image of images) { 10 | count[image.guildId] = (count[image.guildId] || 0) + 1 11 | } 12 | 13 | let top = Object.keys(count).sort((a, b) => count[b] - count[a]).filter(id => bot.client.guilds.get(id)).slice(0, 20); 14 | 15 | return bot.notify(message, top.map(id => bot.client.guilds.get(id).name + ' - ' + count[id] + ' - ' + bot.client.guilds.get(id).owner.user.tag + ' - ' + bot.client.guilds.get(id).memberCount).join('\n')); 16 | } 17 | 18 | async function showServerInfo (message, args) { 19 | let guild = bot.client.guilds.find(g => g.id == args[0] || g.name.toLowerCase() == args[0].toLowerCase()); 20 | 21 | if (guild) { 22 | return bot.notify(message, guild.name + ' owned by ' + guild.owner.user.tag); 23 | } 24 | 25 | return bot.notify(message, "guild not found"); 26 | } 27 | 28 | async function showProcessing (message) { 29 | console.log(botUtils.processing); 30 | } 31 | 32 | module.exports.commands = { 33 | 'processing': showProcessing, 34 | 'recent': recentServerActivity, 35 | 'server info': showServerInfo 36 | } -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | // server.js 2 | // where your node app starts 3 | 4 | // init project 5 | const express = require('express'); 6 | const app = express(); 7 | const hbars = require('express-handlebars'); 8 | const bot = require("./bot/index"); 9 | const db = require('./bot/db.js'); 10 | const imageUtils = require('./utils/image'); 11 | const probeSize = require('probe-image-size'); 12 | 13 | // http://expressjs.com/en/starter/static-files.html 14 | app.use(express.static('public')); 15 | app.engine('handlebars', hbars({defaultLayout: 'main'})); 16 | app.set('view engine', 'handlebars'); 17 | app.enable('trust proxy') 18 | 19 | // http://expressjs.com/en/starter/basic-routing.html 20 | app.get('/', function(request, response) { 21 | response.sendFile(__dirname + '/views/index.html'); 22 | }); 23 | 24 | app.get('/recent', async function(req, res) { 25 | if (req.ip != process.env.ADMIN_IP) return res.status(404).send({message: 'error'}); 26 | 27 | let images = await db.images.find({}).sort({createdTimestamp: -1}).limit(500); 28 | let servers = images.reduce((servers, im) => { 29 | servers[im.guildId] = servers[im.guildId] || {images: [], count: 0, name: (bot.client.guilds.get(im.guildId) || {name: im.guildId}).name}; 30 | if(servers[im.guildId].images.length < 10) servers[im.guildId].images.push(im); 31 | servers[im.guildId].count++; 32 | 33 | im.urls = imageUtils.getResizedUrls(im, im); 34 | 35 | return servers; 36 | }, {}); 37 | 38 | res.render('recent', {servers: servers}); 39 | }) 40 | 41 | // listen for requests :) 42 | const listener = app.listen(process.env.PORT, function() { 43 | console.log('Your app is listening on port ' + listener.address().port); 44 | }); 45 | -------------------------------------------------------------------------------- /bot/utils.js: -------------------------------------------------------------------------------- 1 | class CustomError extends Error {} 2 | 3 | let processing = {} 4 | 5 | function getChannel (channel, message) { 6 | let channels = message.guild.channels.array().filter(c => c.id == channel || c.name == channel || "<#" + c.id + ">" == channel); 7 | if (channels.length == 0) { 8 | throw new CustomError(channel + " does not exist"); 9 | } 10 | return channels[0]; 11 | } 12 | 13 | function lock (func, key) { 14 | let cache = processing[func.name] = processing[func.name] || {}; 15 | 16 | return async function () { 17 | let id = key instanceof Function ? key(Array.from(arguments)) : key; 18 | if (cache[id]) { 19 | if (key != "global") throw "Command already running" 20 | else return; 21 | } 22 | cache[id] = true; 23 | try { 24 | await func.apply(func, Array.from(arguments)); 25 | } catch (ex) { 26 | throw ex; 27 | } finally { 28 | delete cache[id]; 29 | } 30 | } 31 | } 32 | 33 | function serverLock (func) { 34 | return lock(func, (args) => args[0].guild.id); 35 | } 36 | 37 | function channelLock (func) { 38 | return lock(func, (args) => args[0].channel.id); 39 | } 40 | 41 | function globalLock (func) { 42 | return lock(func, "global"); 43 | } 44 | 45 | function getAttachmentsAndEmbedsFrom (message) { 46 | let msgs = message.attachments.array(); 47 | 48 | message.embeds.forEach(function(embed,index){ 49 | let image = embed.image || embed.thumbnail; 50 | if ((embed.type == 'image' || (embed.type == 'rich' && embed.url)) && image) { 51 | image.id = message.id + '-' + index; 52 | msgs.push(image) 53 | } 54 | }); 55 | 56 | return msgs; 57 | } 58 | 59 | function getDefaultChannel (guild) { 60 | return guild.channels.get(guild.id) || 61 | guild.channels.find(channel => channel.name === "general") || 62 | guild.channels 63 | .filter(c => c.type === "text" && 64 | c.permissionsFor(guild.client.user).has("SEND_MESSAGES")) 65 | .sort((a, b) => a.position - b.position) 66 | .first(); 67 | } 68 | 69 | module.exports = { 70 | getChannel: getChannel, 71 | serverLock: serverLock, 72 | channelLock: channelLock, 73 | globalLock: globalLock, 74 | lock: lock, 75 | getAttachmentsAndEmbedsFrom: getAttachmentsAndEmbedsFrom, 76 | processing: processing, 77 | CustomError: CustomError, 78 | getDefaultChannel: getDefaultChannel 79 | } -------------------------------------------------------------------------------- /.glitch-assets: -------------------------------------------------------------------------------- 1 | {"name":"drag-in-files.svg","date":"2016-10-22T16:17:49.954Z","url":"https://cdn.hyperdev.com/drag-in-files.svg","type":"image/svg","size":7646,"imageWidth":276,"imageHeight":276,"thumbnail":"https://cdn.hyperdev.com/drag-in-files.svg","thumbnailWidth":276,"thumbnailHeight":276,"dominantColor":"rgb(102, 153, 205)","uuid":"adSBq97hhhpFNUna"} 2 | {"name":"click-me.svg","date":"2016-10-23T16:17:49.954Z","url":"https://cdn.hyperdev.com/click-me.svg","type":"image/svg","size":7116,"imageWidth":276,"imageHeight":276,"thumbnail":"https://cdn.hyperdev.com/click-me.svg","thumbnailWidth":276,"thumbnailHeight":276,"dominantColor":"rgb(243, 185, 186)","uuid":"adSBq97hhhpFNUnb"} 3 | {"name":"paste-me.svg","date":"2016-10-24T16:17:49.954Z","url":"https://cdn.hyperdev.com/paste-me.svg","type":"image/svg","size":7242,"imageWidth":276,"imageHeight":276,"thumbnail":"https://cdn.hyperdev.com/paste-me.svg","thumbnailWidth":276,"thumbnailHeight":276,"dominantColor":"rgb(42, 179, 185)","uuid":"adSBq97hhhpFNUnc"} 4 | {"uuid":"adSBq97hhhpFNUna","deleted":true} 5 | {"name":"image.png","date":"2019-01-26T21:12:44.231Z","url":"https://cdn.glitch.com/1b172c5d-9791-4068-928a-e482def19c13%2Fimage.png","type":"image/png","size":1242698,"imageWidth":751,"imageHeight":1050,"thumbnail":"https://cdn.glitch.com/1b172c5d-9791-4068-928a-e482def19c13%2Fthumbnails%2Fimage.png","thumbnailWidth":237,"thumbnailHeight":330,"dominantColor":"rgb(103,56,30)","uuid":"luPmOqSvEOoAPK9B"} 6 | {"uuid":"luPmOqSvEOoAPK9B","deleted":true} 7 | {"name":"images","date":"2020-04-22T01:19:52.039Z","url":"https://cdn.glitch.com/1b172c5d-9791-4068-928a-e482def19c13%2Fimages","type":"","size":29333668,"thumbnail":"https://cdn.glitch.com/1b172c5d-9791-4068-928a-e482def19c13%2Fthumbnails%2Fimages","thumbnailWidth":210,"thumbnailHeight":210,"uuid":"fMZ1YbgW04s4YByu"} 8 | {"uuid":"fMZ1YbgW04s4YByu","deleted":true} 9 | {"name":"images","date":"2020-04-22T01:20:06.165Z","url":"https://cdn.glitch.com/1b172c5d-9791-4068-928a-e482def19c13%2Fimages","type":"","size":29333668,"thumbnail":"https://cdn.glitch.com/1b172c5d-9791-4068-928a-e482def19c13%2Fthumbnails%2Fimages","thumbnailWidth":210,"thumbnailHeight":210,"uuid":"E78m5noyxuKkWEYk"} 10 | {"uuid":"E78m5noyxuKkWEYk","deleted":true} 11 | {"name":"images","date":"2020-04-22T01:21:25.240Z","url":"https://cdn.glitch.com/1b172c5d-9791-4068-928a-e482def19c13%2Fimages","type":"","size":29333668,"thumbnail":"https://cdn.glitch.com/1b172c5d-9791-4068-928a-e482def19c13%2Fthumbnails%2Fimages","thumbnailWidth":210,"thumbnailHeight":210,"uuid":"Cj1pL5inJ1vXFfgw"} 12 | {"name":"[AnimeOut] AnimatorExpo - 31 Girl [720p][kuru].mkv","date":"2020-04-22T01:23:25.643Z","url":"https://cdn.glitch.com/1b172c5d-9791-4068-928a-e482def19c13%2F%5BAnimeOut%5D%20AnimatorExpo%20-%2031%20Girl%20%5B720p%5D%5Bkuru%5D.mkv","type":"video/x-matroska","size":80019052,"thumbnail":"https://cdn.glitch.com/1b172c5d-9791-4068-928a-e482def19c13%2Fthumbnails%2F%5BAnimeOut%5D%20AnimatorExpo%20-%2031%20Girl%20%5B720p%5D%5Bkuru%5D.mkv","thumbnailWidth":210,"thumbnailHeight":210,"uuid":"sYAq32mHrrur3f35"} 13 | {"uuid":"Cj1pL5inJ1vXFfgw","deleted":true} 14 | {"uuid":"sYAq32mHrrur3f35","deleted":true} 15 | -------------------------------------------------------------------------------- /plugins/messageCount.js: -------------------------------------------------------------------------------- 1 | const bot = require('../bot/index.js'); 2 | const botUtils = require('../bot/utils.js'); 3 | 4 | async function messageCount (msg, params) { 5 | if (params.length < 2) return bot.notify(msg, 'Not enough parameters. Usage: !count messages 540405532197126163 2014-04-07T13:58:10.104Z 2019-04-07T13:58:10.104Z') 6 | params[1] = new Date(params[1]) 7 | params[2] = params.length > 2 ? new Date(params[2]) : new Date(); 8 | 9 | params[1].setMinutes(params[1].getMinutes() + params[1].getTimezoneOffset()) 10 | params[2].setMinutes(params[2].getMinutes() + params[2].getTimezoneOffset()) 11 | 12 | let message = await bot.client.channels.get(process.env.MESSAGE_COUNT_CHANNEL).fetchMessage(params[0]); 13 | 14 | let role = message.guild.roles.find(role => role.name === "Giveaway Entry"); 15 | let winnersRole = message.guild.roles.find(role => role.name === "Giveaway Winner"); 16 | if (!role || !winnersRole) return bot.notify(msg, 'role not found'); 17 | 18 | let response = ''; 19 | let errors = '' 20 | 21 | let reaction = message.reactions.find((r) => r.emoji.name == '☑' || r.emoji.name == '☑️'); 22 | 23 | if (reaction) { 24 | let interestingUsers = (await reaction.fetchUsers()).filter((u) => message.guild.members.get(u.id) && !message.guild.members.get(u.id).roles.has(winnersRole)).map((u) => u.id) 25 | let users = {} 26 | let channels = message.guild.channels.filter(c => message.guild.me.permissionsIn(c).has('VIEW_CHANNEL') && c.type == 'text').map(c => c.id); 27 | 28 | for (let channelId of channels) { 29 | let messages = await message.guild.channels.get(channelId).fetchMessages({limit: 100}); 30 | while (messages.size) { 31 | messages.array().forEach((message) => { 32 | if (message.createdTimestamp <= params[2].getTime() && message.createdTimestamp >= params[1].getTime()) { 33 | users[message.author.id] = users[message.author.id] || {count: 0, username: message.author.username} 34 | users[message.author.id].count++; 35 | } 36 | }) 37 | if (messages.array()[0].createdTimestamp < params[1].getTime()) break; 38 | messages = await bot.client.channels.get(channelId).fetchMessages({limit: 100, before: messages.lastKey()}); 39 | } 40 | }; 41 | 42 | let newUsers = new Set(); 43 | 44 | for (let userId in users) { 45 | if (users[userId].count >= 100 && interestingUsers.includes(userId)) { 46 | response += users[userId].username + ": " + users[userId].count + '\n'; 47 | newUsers.add(userId); 48 | try{ 49 | await message.guild.members.get(userId).addRole(role); 50 | } catch (ex) { 51 | errors += 'Could not add role to ' + users[userId].username + '\n'; 52 | } 53 | } 54 | } 55 | 56 | for (let member of role.members.array()) { 57 | if (newUsers.has(member.user.id) == false) { 58 | try { 59 | await member.removeRole(role); 60 | } catch (ex) { 61 | errors += 'Could not remove role from ' + member.user.username + '\n'; 62 | } 63 | } 64 | } 65 | 66 | bot.notify(msg, "Count messages done \n" + response + '\n' + errors); 67 | } else { 68 | bot.notify(msg, "No ☑ reactions found on message"); 69 | } 70 | } 71 | 72 | messageCount.visibility = (message) => message.guild.id == process.env.COUNT_SERVER; 73 | 74 | module.exports = { 75 | commands: { 76 | 'count messages': botUtils.serverLock(messageCount) 77 | } 78 | } -------------------------------------------------------------------------------- /plugins/topPosters.js: -------------------------------------------------------------------------------- 1 | const bot = require('../bot/index.js'); 2 | const botUtils = require('../bot/utils.js'); 3 | const dbUtils = require('../utils/db.js'); 4 | const db = require('../bot/db.js'); 5 | const RichEmbed = require('discord.js').RichEmbed; 6 | const permissions = require('../bot/permissions.js'); 7 | 8 | const EMOJI_LEFT = process.env.EMOJI_LEFT || '⏪'; 9 | const EMOJI_RIGHT = process.env.EMOJI_RIGHT || '⏩'; 10 | const PAGE_LIMIT = Number(process.env.TOP_PAGE_LIMIT || "10"); 11 | const ALLOW_TOP = process.env.ALLOW_TOP == 'true'; 12 | const COUNTER_TEXT = process.env.COUNTER_TEXT || 'images'; 13 | 14 | const isSameEmoji = (emoji, str) => emoji.id == str || emoji.name == str; 15 | 16 | async function paginate(message, newMessage, embed, page, filters, hasNext) { 17 | let emojis = await newMessage.awaitReactions((reaction, user) => ((isSameEmoji(reaction.emoji, EMOJI_LEFT) && page > 0) || (isSameEmoji(reaction.emoji, EMOJI_RIGHT) && hasNext)) && user.id == message.author.id, {max: 1, time: 60000}).catch(_ => {}); 18 | if (!emojis || !emojis.first()) return; 19 | page = isSameEmoji(emojis.first()._emoji, EMOJI_LEFT) ? page - 1 : page + 1; 20 | embed = await createEmbed(message.guild.id, page, filters); 21 | await newMessage.edit(embed); 22 | 23 | paginate(message, newMessage, embed, page, filters, embed._qty == PAGE_LIMIT); 24 | } 25 | 26 | async function createEmbed(guildId, page, filters) { 27 | const topUsers = await getTop(guildId, page, filters); 28 | const usersMap = await dbUtils.getUsers(topUsers.map(u => u.userId)); 29 | const richEmbed = new RichEmbed(); 30 | 31 | richEmbed.setTitle('Scoreboard page ' + (page + 1)); 32 | let lines = []; 33 | 34 | for (let i = 0; i < topUsers.length; i++) { 35 | const name = usersMap.has(topUsers[i].userId) ? usersMap.get(topUsers[i].userId).tag : topUsers[i].userId; 36 | lines.push(`${i + (page * PAGE_LIMIT) + 1}. ${name} - ${topUsers[i].posts} ${COUNTER_TEXT}`); 37 | } 38 | 39 | richEmbed.setDescription(lines.join('\n')); 40 | 41 | richEmbed._qty = topUsers.length; 42 | 43 | return richEmbed; 44 | } 45 | 46 | async function topPosters(message, params) { 47 | const filters = { 48 | from: params.length > 0 ? new Date(params[0]).getTime() : 0, 49 | to: params.length > 1 ? new Date(params[1]) : new Date().getTime() 50 | } 51 | 52 | if (params.length == 0) { 53 | const server = await dbUtils.getServerConfig(message.guild.id); 54 | if (server.scoreboardTimeFrom) filters.from = +server.scoreboardTimeFrom; 55 | } 56 | 57 | const embed = await createEmbed(message.guild.id, 0, filters); 58 | const newMessage = await message.channel.send({ content: '', embed }); 59 | 60 | if (embed._qty == PAGE_LIMIT) { 61 | newMessage.react(EMOJI_LEFT).then(() => newMessage.react(EMOJI_RIGHT)); 62 | paginate(message, newMessage, embed, 0, filters, true); 63 | } 64 | } 65 | 66 | function topPostersHelp(message) { 67 | return bot.notify(message, 'Command top can be used by itself or provide an optional date range such as !top 2014-04-07T13:58:10.104Z 2019-04-07T13:58:10.104Z'); 68 | } 69 | 70 | async function getTop(guildId, page, filters) { 71 | console.log("getTop", filters) 72 | const images = await db.images.find({guildId, createdTimestamp: {$gte: filters.from, $lte: filters.to}}); 73 | const postsByUser = images.reduce((obj, img) => { 74 | obj[img.author] = (obj[img.author] || 0) + 1; 75 | return obj; 76 | }, {}); 77 | return Object.keys(postsByUser).sort((a, b) => postsByUser[b] - postsByUser[a]).slice(page * PAGE_LIMIT, (page + 1) * PAGE_LIMIT).map(userId => ({userId, posts: postsByUser[userId]})); 78 | } 79 | 80 | async function resetTop(message, params) { 81 | const dt = params.length > 0 ? new Date(params[0]) : new Date().getTime(); 82 | await db.servers.update({_id: message.guild.id}, {$set: {scoreboardTimeFrom: dt}}); 83 | await bot.react(message, "✅"); 84 | } 85 | 86 | function resetTopHelp(message) { 87 | return bot.notify(message, 'Command top can be used by itself or provide an optional date such as !reset top 2021-11-22T13:58:10.104Z. It will make !top have that date as the default from range'); 88 | } 89 | 90 | getTop.visibility = topPostersHelp.visibility = resetTopHelp.visibility = resetTop.visibility = ALLOW_TOP; 91 | 92 | module.exports = { 93 | commands: { 94 | 'top': botUtils.serverLock(topPosters), 95 | 'top?': topPostersHelp, 96 | 'reset top': permissions.admin(resetTop), 97 | 'reset top?': resetTopHelp 98 | } 99 | } -------------------------------------------------------------------------------- /utils/db.js: -------------------------------------------------------------------------------- 1 | const db = require('../bot/db.js'); 2 | const bot = require('../bot/index.js'); 3 | 4 | async function getServerConfig (serverId) { 5 | let config = await db.servers.findOne({_id: serverId}); 6 | if (config == null) { 7 | config = await db.servers.insert({_id: serverId, prefix: '!', history: 30, modRole: 'Futaba Mod', adminRole: 'Futaba Admin'}); 8 | } 9 | return config; 10 | } 11 | 12 | async function getChannelConfig (channel) { 13 | let server = await getServerConfig(channel.guild.id); 14 | let config = await db.channels.findOne({_id: channel.id}); 15 | let parent = await db.channels.findOne({_id: channel.parentID}); 16 | 17 | if (parent && config == null) { 18 | config = await db.channels.insert({_id: channel.id}); 19 | } 20 | 21 | if (!parent && !config) return null; 22 | 23 | return Object.assign(server, parent || {}, config); 24 | } 25 | 26 | async function getGroupChannels(channelId, guildId) { 27 | if (bot.client.guilds.get(guildId) == null) return []; 28 | 29 | let serverConfig = await getServerConfig(guildId); 30 | let channels = await bot.client.guilds.get(guildId).channels.array(); 31 | let dbChannels = await db.channels.find({_id: {$in: channels.map(c => c.id)}}); 32 | 33 | let configs = solveConfigs(channels, dbChannels, serverConfig); 34 | 35 | let channel = configs.filter(c => c._id == channelId); 36 | if (channel.length == 0 || !channel[0].group) return configs; 37 | else return configs.filter(c => c.group == channel[0].group); 38 | } 39 | 40 | function solveConfigs(channels, dbChannels, serverConfig) { 41 | let configs = dbChannels.reduce((tot, cur) => { 42 | tot[cur._id] = cur; 43 | return tot; 44 | }, {}); 45 | 46 | return channels.map(c => Object.assign({}, configs[c.parentID] || {}, configs[c.id] || {_id: c.id})) 47 | } 48 | 49 | async function getImageHashes (imageIds) { 50 | return db.images.find({$or: [{_id: {$in: imageIds}}, {messageId: {$in: imageIds}}]}); 51 | } 52 | 53 | async function getUsers(userIds) { 54 | let users = await db.users.find({_id: {$in: Array.from(userIds)}}); 55 | let usersMap = new Map(users.map(u => [u._id, u])); 56 | 57 | userIds.forEach(async userId => { 58 | if (usersMap.get(userId) == null) { 59 | try { 60 | let user = await bot.client.fetchUser(userId); 61 | user = await addUser(user); 62 | usersMap.set(user._id, user); 63 | } catch (e) {} 64 | } 65 | }) 66 | 67 | return usersMap; 68 | } 69 | 70 | async function addRelatedInfo (images) { 71 | let userIds = new Set(images.map(i => i.author)) 72 | let usersMap = await getUsers(userIds); 73 | 74 | images.forEach(message => { 75 | message.author = usersMap.get(message.author.toString()); 76 | message.channelName = (bot.client.channels.get(message.channelId) || {name: ""}).name; 77 | }); 78 | } 79 | 80 | function getLeeway(config) { 81 | return config.timeLeeway == null ? 5 : config.timeLeeway; 82 | } 83 | 84 | function compactImages () { 85 | db.images.persistence.compactDatafile(); 86 | } 87 | 88 | async function deleteOldImages (channelIds) { 89 | for (let channelId of channelIds) { 90 | let channel = bot.client.channels.get(channelId); 91 | if (channel) { 92 | let config = await getChannelConfig(channel); 93 | let days = (config || {history: 0}).history; 94 | 95 | let now = new Date(); 96 | now.setDate(now.getDate() - days); 97 | await db.images.remove({createdTimestamp: {$lt: now.getTime()}, channelId: channelId}, {multi: true}); 98 | } 99 | } 100 | } 101 | 102 | async function deleteVeryOldImages () { 103 | let now = new Date(); 104 | now.setDate(now.getDate() - 90); 105 | await db.images.remove({createdTimestamp: {$lt: now.getTime()}}, {multi: true}); 106 | } 107 | 108 | async function markAsProcessed (image) { 109 | await db.images.update({_id: image._id}, {$set: {processed: true}}); 110 | } 111 | 112 | async function addUser (author, member) { 113 | let user = { 114 | _id: author.id, 115 | avatarUrl: author.avatarUrl, 116 | displayAvatarURL: author.displayAvatarURL, 117 | displayName: member? member.displayName : "", 118 | username: author.username, 119 | tag: author.tag 120 | }; 121 | await db.users.update({_id: user._id}, {$set: user}, {upsert: true}); 122 | return user; 123 | } 124 | 125 | module.exports = { 126 | getServerConfig, 127 | compactImages, 128 | getImageHashes, 129 | addRelatedInfo, 130 | deleteOldImages, 131 | markAsProcessed, 132 | addUser, 133 | getChannelConfig, 134 | deleteVeryOldImages, 135 | getLeeway, 136 | getGroupChannels, 137 | getUsers 138 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Discord Image Duplicate Detection 2 | ================= 3 | 4 | This is a bot that keeps track of images in configured channels and lets you know when a duplicate is posted. Useful on servers with image posting channels such as wallpapers. Multiple servers supported. To check for duplicate images it uses the [blockhash](https://github.com/commonsmachinery/blockhash-js) library. When a duplicate is detected, a ♻ reaction is made on the message and if configured, a detailed embed is made on log channel. 5 | 6 | Getting Started 7 | -------------- 8 | 9 | After inviting bot to your server, use !watch command to start tracking images from selected channel. It will take a while before it scans older messages from the channel to identify old images. By default it tracks images up to 30 days ago, you can change this with !history command. You can check progress with !missing hashes and !missing process. It is also recommended to use !set log command to choose a channel where the duplicate image info will be posted. Without this, only a ♻ reaction will be seen on new duplicated content. 10 | 11 | Feel free to dm me any questions to **juvian#4621** 12 | 13 | 14 | Commands 15 | ------------ 16 | 17 | - commands: Shows available commands depending on your permissions 18 | - missing hashes: Shows how many images are currently queued for calculating its hash (necessary for duplicate detection). Only available to mods or admins 19 | - delete image: takes an image id or message id as parameter and deletes from internal database the referenced images. Only available to mods or admins 20 | - missing process: Shows how many images already have their hash but have not been compared against others to check for duplicates. Only available to mods or admins 21 | - difference: takes 2 image ids or message ids and calculates the hash difference between them (under 10 is usually the same or a slight variation) 22 | - history: set the amount of days the server/channel will keep track of images. Category channels also work. If the history of a channel has not been defined, it uses the configuration of its category. If that is not defined either, it uses the configuration of the server. Default for server is 30. Messages will need to be reprocessed if this changes, use sparingly. Only available to admins 23 | - closest: takes 1 image id or message id and returns info on closest image to that one 24 | - set admin role: takes a role name to be considered the admin role. All users with this role can use admin commands. The default one is Futaba Admin. Only available to server owner 25 | - set mod role: takes a role name to be considered the mod role. All users with this role can use mod commands. The default one is Futaba Mod. Only available to server owner 26 | - set log: takes a channel id or name and sets it as the log channel. Here detailed information on duplicates will be posted. It is important to have this set to avoid thinking an image with a ♻ reaction is a duplicate when it could be just a false positive (if not, can use closest command to know). Only availale to admins 27 | - unset log: removes log channel from server configuration. Only availale to admins 28 | - set prefix: Sets command prefix. Default is `!`. Only availale to admins 29 | - watch: takes a channel id or name and starts keeping track of its images according to history. Only availale to admins 30 | - unwatch: takes a channel id or name and stops keeping track of its images. Only availale to admins 31 | - config: shows current server configuration. Only available to mods 32 | - help: displays link to github 33 | - set time leeway: sets the amount of minutes to allow same author to post images without getting flagged. The default is 5 and this prevents cases where an user posts similar images from a set like girl with eyes open and then closed. Set to 0 for no leeway 34 | - group: if you don't want to compare images against all others from server, you can make groups! An image will be compared against all images from channels with same group as channel it was posted. 35 | 36 | **Note: admin is either someone with the configured admin role or with administrator permissions. A mod is someone with the configured mod role or with manage guild permission** 37 | 38 | Plugins 39 | ------------- 40 | 41 | Plugins extend the functionality of the bot, and are great to build upon without changing other files that might conflict with future bot changes. 42 | 43 | **topPosters** 44 | ------------- 45 | This plugin adds a way to view how many images were posted by each user in a scoreboard. Commands: 46 | - top: shows scoreboard. Can optionally add date from and date to for only counting within range. Note that the count is over the images bot has already scanned and kept, so the amount of history days it keeps is relevant for it 47 | - top?: shows example of usage with date range 48 | - reset top: resets scoreboard. Actually, it just changes the default from date of !top command to current date. Can be changed to a specific date adding the date parameter 49 | - reset top?: shows example using date 50 | 51 | There are a few .env settings related to this plugin: 52 | - ALLOW_TOP=true (enables commands) 53 | - TOP_PAGE_LIMIT=10 (sets the amount of users to show for each page with !top. 10 is the default so unless you want another amount line is not needed) 54 | - EMOJI_LEFT=778664915085819914 (emoji id to use for scoreboard pagination to go to previous page) 55 | - EMOJI_RIGHT=778664914650791969 (emoji id to use for scoreboard pagination to go to next page) 56 | 57 | To get the id of an emoji you can either right click and open link and see the number before the .png part or you can write add a \ character before the emoji and submit it in a message and it will show its id 58 | 59 | For Developers 60 | ------------ 61 | You can add commands as plugins by adding a file in plugins folder, look at plugins/messageCount.js for an example. 62 | Necessary variables to setup in .env: 63 | 64 | - BOT_OWNER= user id of bot owner 65 | - TOKEN= bot token to login 66 | 67 | **How to host the bot yourself** 68 | ------------------------------- 69 | - Get a server where you will host it 70 | - Install node 71 | - Download code 72 | - Run npm install in code directory 73 | - Go to discord developer portal, create an app and then create a bot. Get the token. Also select your application and go to bot section then activate "MESSAGE CONTENT INTENT" from Privileged Gateway Intents. 74 | - Create a .env file setting BOT_OWNER and TOKEN as stated above 75 | - Create a folder named .data (mkdir .data in cmd) 76 | - Install dotenv: npm install dotenv --save 77 | - Run bot: node -r dotenv/config bot/index.js 78 | 79 | Common issues 80 | ---------------- 81 | Sometimes things don't install well and it does not work, in that case you can try: 82 | - Remove node_modules folder 83 | - Remove package-lock.json file 84 | - Run npm cache clean --force 85 | - Run again npm install 86 | - Install dotenv again 87 | - Run bot again 88 | 89 | -------------------------------------------------------------------------------- /bot/index.js: -------------------------------------------------------------------------------- 1 | const Discord = require('discord.js'); 2 | const client = new Discord.Client(); 3 | const RichEmbed = Discord.RichEmbed; 4 | const dbUtils = require('../utils/db.js') 5 | const imageUtils = require('../utils/image.js') 6 | const commands = require('../bot/commands.js') 7 | const db = require('../bot/db.js'); 8 | const botUtils = require('../bot/utils.js'); 9 | const axios = require('axios'); 10 | 11 | client.on('ready', async () => { 12 | console.log(`Logged in as ${client.user.tag}! in ${client.guilds.size} servers`); 13 | client.user.setActivity("!help", {type: "PLAYING"}) 14 | dbUtils.deleteVeryOldImages(); 15 | notifyGuildCount(); 16 | doWork(); 17 | }); 18 | 19 | client.on('guildCreate', async guild => { 20 | console.log(`joined ${guild.name} - ${guild.members.size}`) 21 | notifyGuildCount(); 22 | await joinedGuildMessage(guild); 23 | }); 24 | 25 | async function notifyGuildCount () { 26 | //axios.post("https://bots.ondiscord.xyz/bot-api/bots/" + client.user.id + "/guilds", {guildCount: client.guilds.size}, {headers: {"Authorization": process.env.BOT_LIST_API_KEY}}).catch(e => console.log("failed to update bot count")); 27 | } 28 | 29 | client.on("message", async message => { 30 | if (!message.guild) return await commands.processDM(message.content, message); 31 | try{ 32 | message.channel.config = await dbUtils.getChannelConfig(message.channel); 33 | await checkCommands(message); 34 | if (message.channel.config) { 35 | await updateMessagesFromChannel(message); 36 | await doWork(); 37 | } 38 | } catch (ex){ 39 | if (ex.toString().includes("already running") == false) { 40 | console.log(ex) 41 | client.users.get(process.env.BOT_OWNER).send(ex); 42 | } 43 | } 44 | }); 45 | 46 | client.on("messageUpdate", async (oldMessage, newMessage) => { 47 | let config = await dbUtils.getChannelConfig(newMessage.channel); 48 | processMessages([newMessage], config); 49 | }); 50 | 51 | setInterval(doWork, 60000) 52 | 53 | client.on("disconnect", function () { 54 | console.log("disconnected"); 55 | process.exit(1); 56 | }) 57 | 58 | async function doWork () { 59 | try { 60 | await imageUtils.calculateMissingHashes() 61 | await imageUtils.findDuplicates(); 62 | dbUtils.compactImages(); 63 | } catch (ex){console.log(ex)} 64 | } 65 | 66 | client.on("messageDelete", async message => { 67 | await db.images.remove({messageId: message.id}, {multi: true}); 68 | }) 69 | 70 | 71 | async function joinedGuildMessage (guild) { 72 | await botUtils.getDefaultChannel(guild).send(`Thanks for inviting me to the server! To start using me, set up channels to watch for image duplicates with !watch. Use !help for further information.`).catch(console.log); 73 | } 74 | 75 | async function updateMessagesFromChannel (message) { 76 | if (message.channel.config) { 77 | await processMessagesFromChannel(message, message.channel.config, "after"); 78 | } 79 | } 80 | 81 | async function processMessagesFromChannel (message, config, field) { 82 | let days = config.history; 83 | let now = new Date(); 84 | now.setDate(now.getDate() - days); 85 | 86 | while (true) { 87 | let opts = {limit: 100, [field]: config[field]} 88 | let messages = await client.channels.get(config._id).fetchMessages(opts); 89 | let updates = {_id: config._id} 90 | await processMessages(messages.array(), config); 91 | 92 | if (messages.size) { 93 | updates[field] = config[field] = (field == 'before' ? messages.lastKey() : messages.firstKey()); 94 | } 95 | 96 | await db.channels.update({_id: config._id}, {$set: updates}); 97 | 98 | if (messages.size < 100 || (now.getTime() > messages.last().createdTimestamp)) { 99 | if (field == "after") field = "before"; 100 | else { 101 | await db.channels.update({_id: config._id}, {$set: {scannedOnce: true}}); 102 | break; 103 | } 104 | } 105 | } 106 | 107 | await dbUtils.deleteOldImages([message.channel.id], message.guild.id); 108 | } 109 | 110 | processMessagesFromChannel = botUtils.channelLock(processMessagesFromChannel); 111 | 112 | async function processMessages (messages, config) { 113 | if (!config) return; 114 | 115 | messages.forEach(async message => { 116 | if(message.author.id !== client.user.id) { 117 | let added = false; 118 | botUtils.getAttachmentsAndEmbedsFrom(message).forEach(async attachment => { 119 | let extension = attachment.url.split(".").pop(); 120 | 121 | if (attachment.width && attachment.height && extension.startsWith('jpg') || extension.startsWith('png')) { 122 | let image = { 123 | _id: attachment.id, 124 | url: attachment.url, 125 | createdTimestamp: message.createdTimestamp, 126 | author: message.author.id, 127 | messageId: message.id, 128 | channelId: message.channel.id, 129 | guildId: message.guild.id, 130 | proxyURL: attachment.proxyURL 131 | } 132 | 133 | if (config.scannedOnce != true) image.processed = true; 134 | 135 | /*let count = await db.images.count({guildId: message.guild.id}); 136 | while (count > 10000) { 137 | let im = await db.images.find({guildId: message.guild.id}).sort({createdTimestamp: 1}).limit(1) 138 | await db.images.remove({_id: im._id}); 139 | count--; 140 | }*/ 141 | 142 | await db.images.update({_id: image._id}, {$set: image}, {upsert: true}); 143 | 144 | if (added == false) { 145 | added = true; 146 | await dbUtils.addUser(message.author, message.member); 147 | } 148 | } 149 | }); 150 | } 151 | }); 152 | } 153 | 154 | async function checkCommands (message) { 155 | let serverConfig = await dbUtils.getServerConfig(message.guild.id); 156 | if (message.content.startsWith(serverConfig.prefix) && !message.author.bot) { 157 | await commands.processCommand(message.content.substring(serverConfig.prefix.length), message); 158 | } 159 | } 160 | 161 | module.exports.logError = async function (msg, error, ex) { 162 | console.log("logError", error, ex) 163 | if (error && error.toString().trim()) 164 | try { 165 | await client.channels.get(msg.channel.id).send(error.toString().trim()); 166 | } catch (ex) {console.log("logError 2", ex)}; 167 | } 168 | 169 | module.exports.notify = async function (msg, message) { 170 | if (message && (message instanceof RichEmbed || message.toString().trim())) 171 | try { 172 | await client.channels.get(msg.channel.id).send(message instanceof RichEmbed ? message : message.toString().trim()); 173 | } catch (ex) {console.log("notify", ex)} 174 | } 175 | 176 | module.exports.react = async function (msg, reaction) { 177 | if (msg) 178 | await msg.react(reaction); 179 | } 180 | 181 | module.exports.client = client; 182 | module.exports.updateMessagesFromChannel = updateMessagesFromChannel; 183 | client.login(process.env.TOKEN).then(() => console.log("logged in")).catch(ex => {throw ex}); 184 | 185 | -------------------------------------------------------------------------------- /utils/image.js: -------------------------------------------------------------------------------- 1 | const db = require('../bot/db.js') 2 | const blockHash = require('../libs/blockhash.js') 3 | const timeago = require("timeago.js"); 4 | const imageUtils = require('../utils/image.js'); 5 | const botUtils = require('../bot/utils.js'); 6 | const RichEmbed = require('discord.js').RichEmbed; 7 | const dbUtils = require('../utils/db.js') 8 | const bot = require('../bot/index.js'); 9 | const probeSize = require('probe-image-size'); 10 | const prettyBytes = require('pretty-bytes'); 11 | 12 | function imageUrl(image) { 13 | return `https://discordapp.com/channels/${image.guildId}/${image.channelId}/${image.messageId}` 14 | } 15 | 16 | function imageInfo (image, useMarkdown) { 17 | image.author = image.author || {username: '?', displayName: '?'} 18 | return (useMarkdown ? '[' : '') + `${image.width} x ${image.height} (${prettyBytes(image.fileSize || 0)}) posted by ${image.author.displayName || image.author.username} in ${image.channelName} ${timeago.format(image.createdTimestamp)} ${image.diff != null ? '(' + image.diff + ' bits)' : ''}` + (useMarkdown ? `](${imageUrl(image)})` : "") 19 | } 20 | 21 | function getResizedUrls(imageData, image) { 22 | let ratio = imageData.width > 800 ? imageData.width / 800 : 1; 23 | let params = `?width=${Math.floor(imageData.width / ratio)}&height=${Math.floor(imageData.height / ratio)}`; 24 | 25 | let urls = []; 26 | 27 | if (image.url.includes("discordapp") && imageData.width) { 28 | urls.push(image.url.replace("cdn.discordapp.com", "media.discordapp.net") + (image.url.includes("cdn.discordapp.com") ? params : '')) 29 | } else { 30 | if (image.proxyURL) urls.push(image.proxyURL + params); 31 | //urls.push("https://rsz.io/" + image.url.replace("https://", "").replace("http://", "") + params); 32 | urls.push("https://images.weserv.nl/?url=" + image.url + params.replace("width", "w").replace("height", "h")) 33 | //urls.push("http://www.picresize.com/api" + params + "&fetch=" + image.url); 34 | urls.push(image.url + params); 35 | } 36 | 37 | urls.push(image.url); 38 | 39 | return urls; 40 | } 41 | 42 | async function updateImageHash (image, index) { 43 | if (image && image.hash == null) { 44 | console.log("updateImageHash", image.url, "start") 45 | try { 46 | if (image.tries >= 3) throw "Too many tries for " + image.url; 47 | await db.images.update({_id: image._id}, {$inc: {tries: 1}}); 48 | if (index % 100 == 0) dbUtils.compactImages(); 49 | 50 | let imageData = await probeSize(image.url); 51 | let urls = getResizedUrls(imageData, image); 52 | let url; 53 | 54 | while (urls.length && !url) { 55 | let tempUrl = urls.shift(); 56 | try { 57 | await probeSize(tempUrl); 58 | url = tempUrl; 59 | } catch (ex) {} 60 | } 61 | 62 | let hash = await blockHash.blockhash(url, imageData, 16, 2) 63 | await db.images.update({_id: image._id}, {$set: {hash: hash, type: imageData.type, fileSize: imageData.length || image.fileSize, width: imageData.width, height: imageData.height}}); 64 | } catch (ex) { 65 | if (image.tries >= 3) { 66 | console.log(ex, "image removed", image.url); 67 | await db.images.remove({_id: image._id}); 68 | let config = await dbUtils.getServerConfig(image.guildId); 69 | if (config.logChannel) await bot.notify({channel : {id: config.logChannel}}, new RichEmbed().setDescription(`failed to process image ${image.messageId}`)); 70 | } else console.log(ex) 71 | } 72 | console.log("updateImageHash", image.url, "end") 73 | } 74 | } 75 | 76 | 77 | async function getImageWithoutHash () { 78 | return await db.images.findOne({hash: {$exists: false}}); 79 | } 80 | 81 | async function calculateMissingHashes (message) { 82 | let image = await getImageWithoutHash(); 83 | let index = 0; 84 | 85 | while (image != null) { 86 | await updateImageHash(image, index++); 87 | image = await getImageWithoutHash(); 88 | } 89 | } 90 | 91 | function isHigherQuality (img, image) { 92 | let area1 = img.width * img.height; 93 | let area2 = image.width * image.height; 94 | return area1 >= area2 // || (area1 == area2 && img.fileSize > image.fileSize * 0.95); 95 | } 96 | 97 | 98 | function ratioDiff (img, image) { 99 | let a1 = img.width * img.height; 100 | let a2 = image.width * image.height; 101 | return Math.max(a1 / a2, a2 / a1); 102 | } 103 | 104 | function isSimilar (img, image) { 105 | return blockHash.hammingDistance(img.hash, image.hash) <= Math.min(Math.ceil(6 * ratioDiff(img, image)), 12); 106 | } 107 | 108 | let show = i => ({id: i._id, processed: i.processed}) 109 | 110 | async function findDuplicateHashes () { 111 | let image = await db.images.find({hash: {$exists: true}, processed: {$ne: true}}).sort({createdTimestamp: 1}).limit(1); 112 | if (!image.length) return; 113 | image = image[0]; 114 | let filters = {createdTimestamp: {$lt: image.createdTimestamp}, guildId: image.guildId, hash: {$exists: true}}; 115 | 116 | try { 117 | let channelIds = (await dbUtils.getGroupChannels(image.channelId, image.guildId)).map(c => c._id); 118 | filters.channelId = {$in: channelIds}; 119 | } catch(ex) {console.log("rip", ex)} 120 | 121 | let images = (await db.images.find(filters)).filter(im => isSimilar(im, image) && im._id != image._id); 122 | let messages = {originals: []} 123 | let config = await dbUtils.getServerConfig(image.guildId); 124 | 125 | images.forEach(img => { 126 | if (Math.abs(img.createdTimestamp - image.createdTimestamp) / 60000 >= dbUtils.getLeeway(config) || img.author != image.author) { 127 | img.diff = blockHash.hammingDistance(img.hash, image.hash); 128 | if (isHigherQuality(img, image)) { 129 | messages.originals.push(img); 130 | } 131 | } 132 | }) 133 | 134 | await dbUtils.addRelatedInfo([image].concat(messages.originals)); 135 | return {originals: messages.originals, image: image, config: config}; 136 | } 137 | 138 | async function findDuplicates () { 139 | let data = await findDuplicateHashes(); 140 | while (data != null) { 141 | let config = data.config; 142 | if (data.originals.length > 0) { 143 | const embed = new RichEmbed().setDescription(imageInfo(data.image, true)); 144 | embed.setThumbnail(data.image.url); 145 | 146 | embed.addField('Duplicate of', data.originals.map(v => `${imageInfo(v, true)}`).join("\n").substring(0, 1024)) 147 | let imageMessage; 148 | 149 | try { 150 | imageMessage = await bot.client.channels.get(data.image.channelId).fetchMessage(data.image.messageId); 151 | } catch (ex) { 152 | await db.images.remove({_id: data.image._id}); 153 | } 154 | 155 | await bot.react(imageMessage, "♻️").catch(e => console.log(e)); 156 | if (config.logChannel) await bot.notify({channel : {id: config.logChannel}}, embed); 157 | } 158 | await dbUtils.markAsProcessed(data.image); 159 | data = await findDuplicateHashes(); 160 | } 161 | } 162 | 163 | 164 | 165 | module.exports = { 166 | updateImageHash: updateImageHash, 167 | calculateMissingHashes: botUtils.globalLock(calculateMissingHashes), 168 | findDuplicates: botUtils.globalLock(findDuplicates), 169 | imageInfo: imageInfo, 170 | getResizedUrls: getResizedUrls 171 | } -------------------------------------------------------------------------------- /libs/blockhash.js: -------------------------------------------------------------------------------- 1 | var PNG = require('pngjs').PNG; 2 | var jpeg = require('jpeg-js'); 3 | 4 | var one_bits = [0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4]; 5 | 6 | var XMLHttpRequest = require('xhr2'); 7 | 8 | /* Calculate the hamming distance for two hashes in hex format */ 9 | var hammingDistance = function(hash1, hash2) { 10 | var d = 0; 11 | var i; 12 | 13 | if (hash1.length !== hash2.length) { 14 | throw new Error("Can't compare hashes with different length"); 15 | } 16 | 17 | for (i = 0; i < hash1.length; i++) { 18 | var n1 = parseInt(hash1[i], 16); 19 | var n2 = parseInt(hash2[i], 16); 20 | d += one_bits[n1 ^ n2]; 21 | } 22 | return d; 23 | }; 24 | 25 | var median = function(data) { 26 | var mdarr = data.slice(0); 27 | mdarr.sort(function(a, b) { return a-b; }); 28 | if (mdarr.length % 2 === 0) { 29 | return (mdarr[mdarr.length/2 - 1] + mdarr[mdarr.length/2]) / 2.0; 30 | } 31 | return mdarr[Math.floor(mdarr.length/2)]; 32 | }; 33 | 34 | var translate_blocks_to_bits = function(blocks, pixels_per_block) { 35 | var half_block_value = pixels_per_block * 256 * 3 / 2; 36 | var bandsize = blocks.length / 4; 37 | 38 | // Compare medians across four horizontal bands 39 | for (var i = 0; i < 4; i++) { 40 | var m = median(blocks.slice(i * bandsize, (i + 1) * bandsize)); 41 | for (var j = i * bandsize; j < (i + 1) * bandsize; j++) { 42 | var v = blocks[j]; 43 | 44 | // Output a 1 if the block is brighter than the median. 45 | // With images dominated by black or white, the median may 46 | // end up being 0 or the max value, and thus having a lot 47 | // of blocks of value equal to the median. To avoid 48 | // generating hashes of all zeros or ones, in that case output 49 | // 0 if the median is in the lower value space, 1 otherwise 50 | blocks[j] = Number(v > m || (Math.abs(v - m) < 1 && m > half_block_value)); 51 | } 52 | } 53 | }; 54 | 55 | var bits_to_hexhash = function(bitsArray) { 56 | var hex = []; 57 | for (var i = 0; i < bitsArray.length; i += 4) { 58 | var nibble = bitsArray.slice(i, i + 4); 59 | hex.push(parseInt(nibble.join(''), 2).toString(16)); 60 | } 61 | 62 | return hex.join(''); 63 | }; 64 | 65 | var bmvbhash_even = function(data, bits) { 66 | var blocksize_x = Math.floor(data.width / bits); 67 | var blocksize_y = Math.floor(data.height / bits); 68 | 69 | var result = []; 70 | 71 | for (var y = 0; y < bits; y++) { 72 | for (var x = 0; x < bits; x++) { 73 | var total = 0; 74 | 75 | for (var iy = 0; iy < blocksize_y; iy++) { 76 | for (var ix = 0; ix < blocksize_x; ix++) { 77 | var cx = x * blocksize_x + ix; 78 | var cy = y * blocksize_y + iy; 79 | var ii = (cy * data.width + cx) * 4; 80 | 81 | var alpha = data.data[ii+3]; 82 | if (alpha === 0) { 83 | total += 765; 84 | } else { 85 | total += data.data[ii] + data.data[ii+1] + data.data[ii+2]; 86 | } 87 | } 88 | } 89 | 90 | result.push(total); 91 | } 92 | } 93 | 94 | translate_blocks_to_bits(result, blocksize_x * blocksize_y); 95 | return bits_to_hexhash(result); 96 | }; 97 | 98 | var bmvbhash = function(data, bits) { 99 | var result = []; 100 | 101 | var i, j, x, y; 102 | var block_width, block_height; 103 | var weight_top, weight_bottom, weight_left, weight_right; 104 | var block_top, block_bottom, block_left, block_right; 105 | var y_mod, y_frac, y_int; 106 | var x_mod, x_frac, x_int; 107 | var blocks = []; 108 | 109 | var even_x = data.width % bits === 0; 110 | var even_y = data.height % bits === 0; 111 | 112 | if (even_x && even_y) { 113 | return bmvbhash_even(data, bits); 114 | } 115 | 116 | // initialize blocks array with 0s 117 | for (i = 0; i < bits; i++) { 118 | blocks.push([]); 119 | for (j = 0; j < bits; j++) { 120 | blocks[i].push(0); 121 | } 122 | } 123 | 124 | block_width = data.width / bits; 125 | block_height = data.height / bits; 126 | 127 | for (y = 0; y < data.height; y++) { 128 | if (even_y) { 129 | // don't bother dividing y, if the size evenly divides by bits 130 | block_top = block_bottom = Math.floor(y / block_height); 131 | weight_top = 1; 132 | weight_bottom = 0; 133 | } else { 134 | y_mod = (y + 1) % block_height; 135 | y_frac = y_mod - Math.floor(y_mod); 136 | y_int = y_mod - y_frac; 137 | 138 | weight_top = (1 - y_frac); 139 | weight_bottom = (y_frac); 140 | 141 | // y_int will be 0 on bottom/right borders and on block boundaries 142 | if (y_int > 0 || (y + 1) === data.height) { 143 | block_top = block_bottom = Math.floor(y / block_height); 144 | } else { 145 | block_top = Math.floor(y / block_height); 146 | block_bottom = Math.ceil(y / block_height); 147 | } 148 | } 149 | 150 | for (x = 0; x < data.width; x++) { 151 | var ii = (y * data.width + x) * 4; 152 | 153 | var avgvalue, alpha = data.data[ii+3]; 154 | if (alpha === 0) { 155 | avgvalue = 765; 156 | } else { 157 | avgvalue = data.data[ii] + data.data[ii+1] + data.data[ii+2]; 158 | } 159 | 160 | if (even_x) { 161 | block_left = block_right = Math.floor(x / block_width); 162 | weight_left = 1; 163 | weight_right = 0; 164 | } else { 165 | x_mod = (x + 1) % block_width; 166 | x_frac = x_mod - Math.floor(x_mod); 167 | x_int = x_mod - x_frac; 168 | 169 | weight_left = (1 - x_frac); 170 | weight_right = x_frac; 171 | 172 | // x_int will be 0 on bottom/right borders and on block boundaries 173 | if (x_int > 0 || (x + 1) === data.width) { 174 | block_left = block_right = Math.floor(x / block_width); 175 | } else { 176 | block_left = Math.floor(x / block_width); 177 | block_right = Math.ceil(x / block_width); 178 | } 179 | } 180 | 181 | // add weighted pixel value to relevant blocks 182 | blocks[block_top][block_left] += avgvalue * weight_top * weight_left; 183 | blocks[block_top][block_right] += avgvalue * weight_top * weight_right; 184 | blocks[block_bottom][block_left] += avgvalue * weight_bottom * weight_left; 185 | blocks[block_bottom][block_right] += avgvalue * weight_bottom * weight_right; 186 | } 187 | } 188 | 189 | for (i = 0; i < bits; i++) { 190 | for (j = 0; j < bits; j++) { 191 | result.push(blocks[i][j]); 192 | } 193 | } 194 | 195 | translate_blocks_to_bits(result, block_width * block_height); 196 | return bits_to_hexhash(result); 197 | }; 198 | 199 | var blockhashData = function(imgData, bits, method) { 200 | var hash; 201 | 202 | if (method === 1) { 203 | hash = bmvbhash_even(imgData, bits); 204 | } 205 | else if (method === 2) { 206 | hash = bmvbhash(imgData, bits); 207 | } 208 | else { 209 | throw new Error("Bad hashing method"); 210 | } 211 | 212 | return hash; 213 | }; 214 | 215 | var blockhash = function(src, imageData, bits, method) { 216 | var xhr; 217 | xhr = new XMLHttpRequest(); 218 | xhr.open('GET', src, true); 219 | xhr.responseType = "arraybuffer"; 220 | return new Promise((resolve, reject) => { 221 | xhr.onload = function() { 222 | var data, contentType, imgData, jpg, png, hash; 223 | data = Buffer.from(xhr.response || xhr.mozResponseArrayBuffer); 224 | contentType = xhr.getResponseHeader('content-type'); 225 | try { 226 | let funcs = [PNG.sync.read, jpeg.decode] 227 | if (imageData.type == "jpg") funcs = [jpeg.decode, PNG.sync.read]; 228 | 229 | try { 230 | imgData = funcs[0](data) 231 | } catch (ex) { 232 | imgData = funcs[1](data); 233 | } 234 | 235 | resolve(blockhashData(imgData, bits, method)); 236 | } catch (err) { 237 | reject("Couldn't decode image <" + src + "> " + contentType + ' ' + err); 238 | } 239 | }; 240 | 241 | xhr.onerror = function(err) { 242 | reject("Couldn't decode image <" + src + ">"); 243 | }; 244 | 245 | xhr.send(); 246 | }); 247 | }; 248 | 249 | module.exports = { 250 | hammingDistance: hammingDistance, 251 | blockhash: blockhash, 252 | blockhashData: blockhashData 253 | }; -------------------------------------------------------------------------------- /bot/commands.js: -------------------------------------------------------------------------------- 1 | const dbUtils = require('../utils/db.js'); 2 | const RichEmbed = require('discord.js').RichEmbed; 3 | const blockHash = require('../libs/blockhash.js'); 4 | const bot = require('../bot/index.js'); 5 | const db = require('../bot/db.js'); 6 | const permissions = require('../bot/permissions.js'); 7 | const botUtils = require('../bot/utils.js'); 8 | const imageUtils = require('../utils/image.js'); 9 | const fs = require('fs'); 10 | const dmCommands = require('../bot/dm-commands.js').commands; 11 | 12 | async function showMissingHashes (message) { 13 | let qty = await db.images.count({hash: {$exists: false}, guildId: message.guild.id}); 14 | await bot.notify(message, qty + ' images left without hash'); 15 | } 16 | 17 | async function showMissingProcess (message) { 18 | let qty = await db.images.count({processed: {$ne: true}, guildId: message.guild.id}); 19 | await bot.notify(message, qty + ' images left without checking duplicates'); 20 | } 21 | 22 | async function deleteImage (message, args) { 23 | if (args.length == 0) return bot.notify(message, "Missing parameters. Usage: !delete image id where id is the image id or message id") 24 | let qty = await db.images.remove({$or: [{_id: args[0], guildId: message.guild.id}, {messageId: args[0], guildId: message.guild.id}]}); 25 | 26 | if (qty) return bot.notify(message, "Deleted " + qty + " images"); 27 | return bot.notify(message, "No image found"); 28 | } 29 | 30 | 31 | async function calculateDifference (message, args) { 32 | if (args.length < 2) return bot.notify(message, "Missing parameters. Usage: !difference id id") 33 | 34 | let hashes = (await dbUtils.getImageHashes(args.slice(0, 2))).map(i => i.hash) 35 | 36 | if (hashes.length == 2) 37 | bot.notify(message, "There is a difference of " + blockHash.hammingDistance(hashes[0], hashes[1]) + " bits"); 38 | else 39 | bot.notify(message, "Could only find " + hashes.length + " images with those ids"); 40 | } 41 | 42 | async function setHistoryDuration (message, args) { 43 | if (args.length < 1 || parseInt(args[1]) < 0) return bot.notify(message, "Invalid usage. Usage: !history channel days where days is the amount of days to preserve and channel is server/channelId/channelName") 44 | 45 | //if (parseInt(args[1]) > 90) return bot.notify(message, "90 days is the maximum"); 46 | 47 | if (args[0] == 'server' && parseInt(args[1])) { 48 | await db.servers.update({_id: message.guild.id}, {$set: {history: parseInt(args[1])}}); 49 | await bot.react(message, "✅"); 50 | return await dbUtils.deleteOldImages(message.guild.channels.array().map(c => c.id)); 51 | } 52 | 53 | let channel = botUtils.getChannel(args[0], message); 54 | let config = await dbUtils.getChannelConfig(channel); 55 | if (!config) return bot.notify(message, "Channel is not on watch list, add it first using !watch"); 56 | 57 | await db.channels.update({_id: channel.id}, {$set: {history: parseInt(args[1])}, $unset: {after: true, before: true}}); 58 | await bot.react(message, "✅"); 59 | await dbUtils.deleteOldImages([channel.id]); 60 | } 61 | 62 | async function closestMatch (message, args) { 63 | if (args.length == 0) return bot.notify(message, "Missing parameters. Usage: !closest id where id is image id or message id") 64 | let id = args[0]; 65 | 66 | let images = await dbUtils.getImageHashes([id]) 67 | 68 | if (images.length == 1) { 69 | let records = await db.images.find({hash: {$exists: true}, guildId: images[0].guildId}); 70 | let closest = {img: null, dist: 10000}; 71 | 72 | records.forEach((record) => { 73 | let dist = blockHash.hammingDistance(images[0].hash, record.hash); 74 | if (closest.dist > dist && images[0]._id != record._id) { 75 | closest = {img: record, dist: dist} 76 | } 77 | }); 78 | 79 | if (closest.img == null) { 80 | return bot.notify(message, "There are no images to compare") 81 | } 82 | await dbUtils.addRelatedInfo([images[0], closest.img]); 83 | 84 | const embed = new RichEmbed().setDescription("Closest image to " +`${imageUtils.imageInfo(images[0], true)} is \n ${imageUtils.imageInfo(closest.img, true)} with ${closest.dist} bits of difference`); 85 | embed.setThumbnail(closest.img.url); 86 | await bot.notify(message, embed); 87 | } else if (images.length > 1) { 88 | await bot.notify(message, "Too many images found with id " + id); 89 | } else { 90 | await bot.notify(message, "Could find no image with id " + id); 91 | } 92 | } 93 | 94 | async function setRole (message, args) { 95 | if (args.length == 0) return bot.notify(message, `Not enough parameters. Usage: !set ${this} role rolename`); 96 | if (message.guild.roles.some(r => r.name == args[0])) { 97 | await db.servers.update({_id: message.guild.id}, {$set: {[this + "Role"]: args[0]}}); 98 | await bot.react(message, "✅"); 99 | } else { 100 | return bot.notify(message, `There is no role named ${args[0]} on server`) 101 | } 102 | } 103 | 104 | async function setLogChannel (message, args) { 105 | if (args.length == 0) return bot.notify(message, "Invalid usage. Usage: !set log channelName where channelName is either the name or id of the channel") 106 | let channel = botUtils.getChannel(args[0], message); 107 | await db.servers.update({_id: message.guild.id}, {$set: {logChannel: channel.id}}); 108 | await bot.react(message, "✅"); 109 | } 110 | 111 | async function unsetLogChannel (message) { 112 | await db.servers.update({_id: message.guild.id}, {$unset: {logChannel: true}}); 113 | await bot.react(message, "✅"); 114 | } 115 | 116 | async function setCommandsPrefix (message, args) { 117 | if (args.length == 0) return bot.notify(message, "Invalid usage. Usage: !set prefix prefixString") 118 | await db.servers.update({_id: message.guild.id}, {$set: {prefix: args[0]}}); 119 | await bot.react(message, "✅"); 120 | } 121 | 122 | async function watchChannel (message, args) { 123 | if (args.length == 0) return bot.notify(message, "Missing parameters. Usage: !watch channel where channel is either the name or id of the channel") 124 | let channel = botUtils.getChannel(args[0], message); 125 | 126 | if ((await db.channels.findOne({_id: channel.id})) == null) { 127 | await db.channels.insert({_id: channel.id}); 128 | } 129 | 130 | await bot.react(message, "✅"); 131 | } 132 | 133 | async function unwatchChannel (message, args) { 134 | if (args.length == 0) return bot.notify(message, "Missing parameters. Usage: !unwatch channel where channel is either the name or id of the channel") 135 | let channel = botUtils.getChannel(args[0], message); 136 | let ids = []; 137 | 138 | await db.channels.remove({_id: channel.id}); 139 | 140 | if (channel.type == "category") { 141 | for (let child of channel.children.array()) { 142 | await db.channels.remove({_id: child.id}); 143 | ids.push(child.id); 144 | } 145 | } else { 146 | ids.push(channel.id); 147 | } 148 | 149 | await bot.react(message, "✅"); 150 | await dbUtils.deleteOldImages(ids, message.guild.id); 151 | } 152 | 153 | async function showConfig (message) { 154 | let server = await dbUtils.getServerConfig(message.guild.id); 155 | 156 | let status = []; 157 | 158 | status.push(`Command prefix: **${server.prefix}**`) 159 | status.push(`Server history: **${server.history}** days`); 160 | status.push(`Bot mod role: **${server.modRole}**. Bot admin role: **${server.adminRole}**`); 161 | 162 | for (let channel of message.guild.channels.array()) { 163 | let config = await dbUtils.getChannelConfig(channel); 164 | if (config) { 165 | status.push(`${channel.name} history **${config.history}** days ${config.group ? ' group: **' + config.group + '**' : ''}`); 166 | } 167 | }; 168 | 169 | status.push(`Time leeway: ` + dbUtils.getLeeway(server) + ' minutes'); 170 | 171 | status.push(server.logChannel ? `Log channel: **${message.guild.channels.get(server.logChannel).name}**` : 'No log channel configured' ); 172 | 173 | await bot.notify(message, status.join('\n')); 174 | } 175 | 176 | 177 | 178 | async function help (message) { 179 | return bot.notify(message, "") 180 | } 181 | 182 | async function setTimeLeeway (message, args) { 183 | if (args.length == 0 || parseInt(args[0]) < 0) return bot.notify(message, "Missing parameters. Usage: !set time leeway minutes where minutes is the amount of minutes to allow the same user to post similar images without getting marked as duplicate") 184 | 185 | await db.servers.update({_id: message.guild.id}, {$set: {timeLeeway: parseInt(args[0])}}); 186 | await bot.react(message, "✅"); 187 | } 188 | 189 | async function setGroup(message, args) { 190 | if (args.length <= 1) return bot.notify(message, "Missing parameters. Usage: !group channel groupname where channel is either the name or id of the channel. Images are only compared against channels with same group"); 191 | let channel = botUtils.getChannel(args[0], message); 192 | 193 | let config = await dbUtils.getChannelConfig(channel); 194 | if (!config) return bot.notify(message, "Channel is not on watch list, add it first using !watch"); 195 | 196 | await db.channels.update({_id: channel.id}, {$set: {group: args[1]}}); 197 | await bot.react(message, "✅"); 198 | } 199 | 200 | var commands = { 201 | 'missing hashes': permissions.mod(showMissingHashes), 202 | 'delete image': permissions.mod(deleteImage), 203 | 'missing process' : permissions.mod(showMissingProcess), 204 | 'difference' : calculateDifference, 205 | 'history' : permissions.admin(setHistoryDuration), 206 | 'closest': closestMatch, 207 | 'set admin role': permissions.owner(setRole.bind('admin')), 208 | 'set mod role': permissions.owner(setRole.bind('mod')), 209 | 'set log': permissions.admin(setLogChannel), 210 | 'unset log': permissions.admin(unsetLogChannel), 211 | 'set prefix': permissions.admin(setCommandsPrefix), 212 | 'watch': permissions.admin(watchChannel), 213 | 'unwatch': permissions.admin(unwatchChannel), 214 | 'config': permissions.mod(showConfig), 215 | 'set time leeway': permissions.mod(setTimeLeeway), 216 | 'group': permissions.mod(setGroup), 217 | 'help': help 218 | } 219 | 220 | 221 | function isVisible (command, message) { 222 | return command && command.visibility instanceof Function ? command.visibility(message) : true; 223 | } 224 | 225 | async function processCommands(commands, command, message) { 226 | try{ 227 | if (command == "commands") return bot.notify(message, Object.keys(commands).filter(command => isVisible(commands[command], message)).join(', ')); 228 | if (commands.hasOwnProperty(command.toLowerCase()) && isVisible(commands[command], message)) { 229 | return await commands[command](message, command.toLowerCase().split(command)[1].split(/ +/).filter(s => s.length)); 230 | } else { 231 | for (var cmd in commands) { 232 | if (command.toLowerCase().startsWith(cmd + ' ') && isVisible(commands[cmd], message)) { 233 | return await commands[cmd](message, command.split(cmd)[1].split(/ +/).filter(s => s.length)) 234 | } 235 | } 236 | return bot.notify(message, "Command not recognized, do !commands for list of commands"); 237 | } 238 | } catch (ex) { 239 | if (ex instanceof botUtils.CustomError) return bot.notify(message, ex.message); 240 | return bot.logError(message, "An error occured processing " + command + ": " + ex.message, ex); 241 | } 242 | } 243 | 244 | module.exports.processCommand = function (command, message) { 245 | message.isCommand = true; 246 | return processCommands(commands, command, message); 247 | message.isCommand = false; 248 | } 249 | 250 | module.exports.processDM = function (command, message) { 251 | if (message.author.id == process.env.BOT_OWNER) { 252 | message.isCommand = true; 253 | return processCommands(dmCommands, command, message) 254 | message.isCommand = false; 255 | } 256 | } 257 | 258 | fs.readdirSync("./plugins/").forEach(function(file) { 259 | if (file == "index.js") return; 260 | var name = file.substr(0, file.indexOf('.')); 261 | let cmds = require('../' + 'plugins/' + name + '.js'); 262 | Object.assign(commands, cmds.commands); 263 | Object.assign(dmCommands, cmds.dmCommands); 264 | }); 265 | 266 | 267 | module.exports.commands = commands; 268 | -------------------------------------------------------------------------------- /shrinkwrap.yaml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | axios: 0.18.0 3 | discord.js: 11.4.2 4 | express: 4.16.4 5 | express-handlebars: 3.0.0 6 | jpeg-js: 0.3.4 7 | nedb: 1.8.0 8 | nedb-promises: 3.0.2 9 | pngjs: 3.3.3 10 | pretty-bytes: 5.3.0 11 | probe-image-size: 4.0.0 12 | timeago.js: 4.0.0-beta.2 13 | xhr2: 0.1.4 14 | packages: 15 | /accepts/1.3.5: 16 | dependencies: 17 | mime-types: 2.1.21 18 | negotiator: 0.6.1 19 | dev: false 20 | engines: 21 | node: '>= 0.6' 22 | resolution: 23 | integrity: sha1-63d99gEXI6OxTopywIBcjoZ0a9I= 24 | /ajv/6.10.0: 25 | dependencies: 26 | fast-deep-equal: 2.0.1 27 | fast-json-stable-stringify: 2.0.0 28 | json-schema-traverse: 0.4.1 29 | uri-js: 4.2.2 30 | dev: false 31 | resolution: 32 | integrity: sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg== 33 | /any-promise/1.3.0: 34 | dev: false 35 | resolution: 36 | integrity: sha1-q8av7tzqUugJzcA3au0845Y10X8= 37 | /array-flatten/1.1.1: 38 | dev: false 39 | resolution: 40 | integrity: sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= 41 | /asap/2.0.6: 42 | dev: false 43 | resolution: 44 | integrity: sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= 45 | /asn1/0.2.4: 46 | dependencies: 47 | safer-buffer: 2.1.2 48 | dev: false 49 | resolution: 50 | integrity: sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== 51 | /assert-plus/1.0.0: 52 | dev: false 53 | engines: 54 | node: '>=0.8' 55 | resolution: 56 | integrity: sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= 57 | /async-limiter/1.0.0: 58 | dev: false 59 | resolution: 60 | integrity: sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg== 61 | /async/0.2.10: 62 | dev: false 63 | resolution: 64 | integrity: sha1-trvgsGdLnXGXCMo43owjfLUmw9E= 65 | /async/2.6.1: 66 | dependencies: 67 | lodash: 4.17.11 68 | dev: false 69 | resolution: 70 | integrity: sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ== 71 | /asynckit/0.4.0: 72 | dev: false 73 | resolution: 74 | integrity: sha1-x57Zf380y48robyXkLzDZkdLS3k= 75 | /aws-sign2/0.7.0: 76 | dev: false 77 | resolution: 78 | integrity: sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= 79 | /aws4/1.8.0: 80 | dev: false 81 | resolution: 82 | integrity: sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ== 83 | /axios/0.18.0: 84 | dependencies: 85 | follow-redirects: 1.7.0 86 | is-buffer: 1.1.6 87 | dev: false 88 | resolution: 89 | integrity: sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI= 90 | /balanced-match/1.0.0: 91 | dev: false 92 | resolution: 93 | integrity: sha1-ibTRmasr7kneFk6gK4nORi1xt2c= 94 | /bcrypt-pbkdf/1.0.2: 95 | dependencies: 96 | tweetnacl: 0.14.5 97 | dev: false 98 | resolution: 99 | integrity: sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= 100 | /binary-search-tree/0.2.5: 101 | dependencies: 102 | underscore: 1.4.4 103 | dev: false 104 | resolution: 105 | integrity: sha1-fbs7IQ/coIJFDa0jNMMErzm9x4Q= 106 | /body-parser/1.18.3: 107 | dependencies: 108 | bytes: 3.0.0 109 | content-type: 1.0.4 110 | debug: 2.6.9 111 | depd: 1.1.2 112 | http-errors: 1.6.3 113 | iconv-lite: 0.4.23 114 | on-finished: 2.3.0 115 | qs: 6.5.2 116 | raw-body: 2.3.3 117 | type-is: 1.6.16 118 | dev: false 119 | engines: 120 | node: '>= 0.8' 121 | resolution: 122 | integrity: sha1-WykhmP/dVTs6DyDe0FkrlWlVyLQ= 123 | /brace-expansion/1.1.11: 124 | dependencies: 125 | balanced-match: 1.0.0 126 | concat-map: 0.0.1 127 | dev: false 128 | resolution: 129 | integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== 130 | /bytes/3.0.0: 131 | dev: false 132 | engines: 133 | node: '>= 0.8' 134 | resolution: 135 | integrity: sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg= 136 | /caseless/0.12.0: 137 | dev: false 138 | resolution: 139 | integrity: sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= 140 | /combined-stream/1.0.7: 141 | dependencies: 142 | delayed-stream: 1.0.0 143 | dev: false 144 | engines: 145 | node: '>= 0.8' 146 | resolution: 147 | integrity: sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w== 148 | /commander/2.17.1: 149 | dev: false 150 | optional: true 151 | resolution: 152 | integrity: sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg== 153 | /concat-map/0.0.1: 154 | dev: false 155 | resolution: 156 | integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= 157 | /content-disposition/0.5.2: 158 | dev: false 159 | engines: 160 | node: '>= 0.6' 161 | resolution: 162 | integrity: sha1-DPaLud318r55YcOoUXjLhdunjLQ= 163 | /content-type/1.0.4: 164 | dev: false 165 | engines: 166 | node: '>= 0.6' 167 | resolution: 168 | integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== 169 | /cookie-signature/1.0.6: 170 | dev: false 171 | resolution: 172 | integrity: sha1-4wOogrNCzD7oylE6eZmXNNqzriw= 173 | /cookie/0.3.1: 174 | dev: false 175 | engines: 176 | node: '>= 0.6' 177 | resolution: 178 | integrity: sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s= 179 | /core-util-is/1.0.2: 180 | dev: false 181 | resolution: 182 | integrity: sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= 183 | /dashdash/1.14.1: 184 | dependencies: 185 | assert-plus: 1.0.0 186 | dev: false 187 | engines: 188 | node: '>=0.10' 189 | resolution: 190 | integrity: sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= 191 | /debug/2.6.9: 192 | dependencies: 193 | ms: 2.0.0 194 | dev: false 195 | resolution: 196 | integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== 197 | /debug/3.2.6: 198 | dependencies: 199 | ms: 2.1.1 200 | dev: false 201 | resolution: 202 | integrity: sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== 203 | /deepmerge/2.2.1: 204 | dev: false 205 | engines: 206 | node: '>=0.10.0' 207 | resolution: 208 | integrity: sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA== 209 | /define-properties/1.1.3: 210 | dependencies: 211 | object-keys: 1.0.12 212 | dev: false 213 | engines: 214 | node: '>= 0.4' 215 | resolution: 216 | integrity: sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== 217 | /delayed-stream/1.0.0: 218 | dev: false 219 | engines: 220 | node: '>=0.4.0' 221 | resolution: 222 | integrity: sha1-3zrhmayt+31ECqrgsp4icrJOxhk= 223 | /depd/1.1.2: 224 | dev: false 225 | engines: 226 | node: '>= 0.6' 227 | resolution: 228 | integrity: sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= 229 | /destroy/1.0.4: 230 | dev: false 231 | resolution: 232 | integrity: sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= 233 | /discord.js/11.4.2: 234 | dependencies: 235 | long: 4.0.0 236 | prism-media: 0.0.3 237 | snekfetch: 3.6.4 238 | tweetnacl: 1.0.1 239 | ws: 4.1.0 240 | dev: false 241 | engines: 242 | node: '>=6.0.0' 243 | peerDependencies: 244 | bufferutil: ^3.0.3 245 | erlpack: discordapp/erlpack 246 | libsodium-wrappers: ^0.7.3 247 | node-opus: ^0.2.7 248 | opusscript: ^0.0.6 249 | sodium: ^2.0.3 250 | uws: ^9.14.0 251 | resolution: 252 | integrity: sha512-MDwpu0lMFTjqomijDl1Ed9miMQe6kB4ifKdP28QZllmLv/HVOJXhatRgjS8urp/wBlOfx+qAYSXcdI5cKGYsfg== 253 | /ecc-jsbn/0.1.2: 254 | dependencies: 255 | jsbn: 0.1.1 256 | safer-buffer: 2.1.2 257 | dev: false 258 | resolution: 259 | integrity: sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= 260 | /ee-first/1.1.1: 261 | dev: false 262 | resolution: 263 | integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= 264 | /encodeurl/1.0.2: 265 | dev: false 266 | engines: 267 | node: '>= 0.8' 268 | resolution: 269 | integrity: sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= 270 | /escape-html/1.0.3: 271 | dev: false 272 | resolution: 273 | integrity: sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= 274 | /etag/1.8.1: 275 | dev: false 276 | engines: 277 | node: '>= 0.6' 278 | resolution: 279 | integrity: sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= 280 | /express-handlebars/3.0.0: 281 | dependencies: 282 | glob: 6.0.4 283 | graceful-fs: 4.1.15 284 | handlebars: 4.0.12 285 | object.assign: 4.1.0 286 | promise: 7.3.1 287 | dev: false 288 | engines: 289 | node: '>=0.10' 290 | resolution: 291 | integrity: sha1-gKBwu4GbCeSvLKbQeA91zgXnXC8= 292 | /express/4.16.4: 293 | dependencies: 294 | accepts: 1.3.5 295 | array-flatten: 1.1.1 296 | body-parser: 1.18.3 297 | content-disposition: 0.5.2 298 | content-type: 1.0.4 299 | cookie: 0.3.1 300 | cookie-signature: 1.0.6 301 | debug: 2.6.9 302 | depd: 1.1.2 303 | encodeurl: 1.0.2 304 | escape-html: 1.0.3 305 | etag: 1.8.1 306 | finalhandler: 1.1.1 307 | fresh: 0.5.2 308 | merge-descriptors: 1.0.1 309 | methods: 1.1.2 310 | on-finished: 2.3.0 311 | parseurl: 1.3.2 312 | path-to-regexp: 0.1.7 313 | proxy-addr: 2.0.4 314 | qs: 6.5.2 315 | range-parser: 1.2.0 316 | safe-buffer: 5.1.2 317 | send: 0.16.2 318 | serve-static: 1.13.2 319 | setprototypeof: 1.1.0 320 | statuses: 1.4.0 321 | type-is: 1.6.16 322 | utils-merge: 1.0.1 323 | vary: 1.1.2 324 | dev: false 325 | engines: 326 | node: '>= 0.10.0' 327 | resolution: 328 | integrity: sha512-j12Uuyb4FMrd/qQAm6uCHAkPtO8FDTRJZBDd5D2KOL2eLaz1yUNdUB/NOIyq0iU4q4cFarsUCrnFDPBcnksuOg== 329 | /extend/3.0.2: 330 | dev: false 331 | resolution: 332 | integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== 333 | /extsprintf/1.3.0: 334 | dev: false 335 | engines: 336 | '0': node >=0.6.0 337 | resolution: 338 | integrity: sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= 339 | /extsprintf/1.4.0: 340 | dev: false 341 | engines: 342 | '0': node >=0.6.0 343 | resolution: 344 | integrity: sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= 345 | /fast-deep-equal/2.0.1: 346 | dev: false 347 | resolution: 348 | integrity: sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= 349 | /fast-json-stable-stringify/2.0.0: 350 | dev: false 351 | resolution: 352 | integrity: sha1-1RQsDK7msRifh9OnYREGT4bIu/I= 353 | /finalhandler/1.1.1: 354 | dependencies: 355 | debug: 2.6.9 356 | encodeurl: 1.0.2 357 | escape-html: 1.0.3 358 | on-finished: 2.3.0 359 | parseurl: 1.3.2 360 | statuses: 1.4.0 361 | unpipe: 1.0.0 362 | dev: false 363 | engines: 364 | node: '>= 0.8' 365 | resolution: 366 | integrity: sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg== 367 | /follow-redirects/1.7.0: 368 | dependencies: 369 | debug: 3.2.6 370 | dev: false 371 | engines: 372 | node: '>=4.0' 373 | resolution: 374 | integrity: sha512-m/pZQy4Gj287eNy94nivy5wchN3Kp+Q5WgUPNy5lJSZ3sgkVKSYV/ZChMAQVIgx1SqfZ2zBZtPA2YlXIWxxJOQ== 375 | /forever-agent/0.6.1: 376 | dev: false 377 | resolution: 378 | integrity: sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= 379 | /form-data/2.3.3: 380 | dependencies: 381 | asynckit: 0.4.0 382 | combined-stream: 1.0.7 383 | mime-types: 2.1.22 384 | dev: false 385 | engines: 386 | node: '>= 0.12' 387 | resolution: 388 | integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== 389 | /forwarded/0.1.2: 390 | dev: false 391 | engines: 392 | node: '>= 0.6' 393 | resolution: 394 | integrity: sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ= 395 | /fresh/0.5.2: 396 | dev: false 397 | engines: 398 | node: '>= 0.6' 399 | resolution: 400 | integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= 401 | /function-bind/1.1.1: 402 | dev: false 403 | resolution: 404 | integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== 405 | /getpass/0.1.7: 406 | dependencies: 407 | assert-plus: 1.0.0 408 | dev: false 409 | resolution: 410 | integrity: sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= 411 | /glob/6.0.4: 412 | dependencies: 413 | inflight: 1.0.6 414 | inherits: 2.0.3 415 | minimatch: 3.0.4 416 | once: 1.4.0 417 | path-is-absolute: 1.0.1 418 | dev: false 419 | resolution: 420 | integrity: sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI= 421 | /graceful-fs/4.1.15: 422 | dev: false 423 | resolution: 424 | integrity: sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA== 425 | /handlebars/4.0.12: 426 | dependencies: 427 | async: 2.6.1 428 | optimist: 0.6.1 429 | source-map: 0.6.1 430 | dev: false 431 | engines: 432 | node: '>=0.4.7' 433 | hasBin: true 434 | optionalDependencies: 435 | uglify-js: 3.4.9 436 | resolution: 437 | integrity: sha512-RhmTekP+FZL+XNhwS1Wf+bTTZpdLougwt5pcgA1tuz6Jcx0fpH/7z0qd71RKnZHBCxIRBHfBOnio4gViPemNzA== 438 | /har-schema/2.0.0: 439 | dev: false 440 | engines: 441 | node: '>=4' 442 | resolution: 443 | integrity: sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= 444 | /har-validator/5.1.3: 445 | dependencies: 446 | ajv: 6.10.0 447 | har-schema: 2.0.0 448 | dev: false 449 | engines: 450 | node: '>=6' 451 | resolution: 452 | integrity: sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g== 453 | /has-symbols/1.0.0: 454 | dev: false 455 | engines: 456 | node: '>= 0.4' 457 | resolution: 458 | integrity: sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q= 459 | /http-errors/1.6.3: 460 | dependencies: 461 | depd: 1.1.2 462 | inherits: 2.0.3 463 | setprototypeof: 1.1.0 464 | statuses: 1.5.0 465 | dev: false 466 | engines: 467 | node: '>= 0.6' 468 | resolution: 469 | integrity: sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0= 470 | /http-signature/1.2.0: 471 | dependencies: 472 | assert-plus: 1.0.0 473 | jsprim: 1.4.1 474 | sshpk: 1.16.1 475 | dev: false 476 | engines: 477 | node: '>=0.8' 478 | npm: '>=1.3.7' 479 | resolution: 480 | integrity: sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= 481 | /iconv-lite/0.4.23: 482 | dependencies: 483 | safer-buffer: 2.1.2 484 | dev: false 485 | engines: 486 | node: '>=0.10.0' 487 | resolution: 488 | integrity: sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA== 489 | /immediate/3.0.6: 490 | dev: false 491 | resolution: 492 | integrity: sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= 493 | /inflight/1.0.6: 494 | dependencies: 495 | once: 1.4.0 496 | wrappy: 1.0.2 497 | dev: false 498 | resolution: 499 | integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= 500 | /inherits/2.0.3: 501 | dev: false 502 | resolution: 503 | integrity: sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= 504 | /ipaddr.js/1.8.0: 505 | dev: false 506 | engines: 507 | node: '>= 0.10' 508 | resolution: 509 | integrity: sha1-6qM9bd16zo9/b+DJygRA5wZzix4= 510 | /is-buffer/1.1.6: 511 | dev: false 512 | resolution: 513 | integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== 514 | /is-typedarray/1.0.0: 515 | dev: false 516 | resolution: 517 | integrity: sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= 518 | /isstream/0.1.2: 519 | dev: false 520 | resolution: 521 | integrity: sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= 522 | /jpeg-js/0.3.4: 523 | dev: false 524 | resolution: 525 | integrity: sha512-6IzjQxvnlT8UlklNmDXIJMWxijULjqGrzgqc0OG7YadZdvm7KPQ1j0ehmQQHckgEWOfgpptzcnWgESovxudpTA== 526 | /jsbn/0.1.1: 527 | dev: false 528 | resolution: 529 | integrity: sha1-peZUwuWi3rXyAdls77yoDA7y9RM= 530 | /json-schema-traverse/0.4.1: 531 | dev: false 532 | resolution: 533 | integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== 534 | /json-schema/0.2.3: 535 | dev: false 536 | resolution: 537 | integrity: sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= 538 | /json-stringify-safe/5.0.1: 539 | dev: false 540 | resolution: 541 | integrity: sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= 542 | /jsprim/1.4.1: 543 | dependencies: 544 | assert-plus: 1.0.0 545 | extsprintf: 1.3.0 546 | json-schema: 0.2.3 547 | verror: 1.10.0 548 | dev: false 549 | engines: 550 | '0': node >=0.6.0 551 | resolution: 552 | integrity: sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= 553 | /lie/3.1.1: 554 | dependencies: 555 | immediate: 3.0.6 556 | dev: false 557 | resolution: 558 | integrity: sha1-mkNrLMd0bKWd56QfpGmz77dr2H4= 559 | /localforage/1.7.3: 560 | dependencies: 561 | lie: 3.1.1 562 | dev: false 563 | resolution: 564 | integrity: sha512-1TulyYfc4udS7ECSBT2vwJksWbkwwTX8BzeUIiq8Y07Riy7bDAAnxDaPU/tWyOVmQAcWJIEIFP9lPfBGqVoPgQ== 565 | /lodash/4.17.11: 566 | dev: false 567 | resolution: 568 | integrity: sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== 569 | /long/4.0.0: 570 | dev: false 571 | resolution: 572 | integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== 573 | /media-typer/0.3.0: 574 | dev: false 575 | engines: 576 | node: '>= 0.6' 577 | resolution: 578 | integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= 579 | /merge-descriptors/1.0.1: 580 | dev: false 581 | resolution: 582 | integrity: sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= 583 | /methods/1.1.2: 584 | dev: false 585 | engines: 586 | node: '>= 0.6' 587 | resolution: 588 | integrity: sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= 589 | /mime-db/1.37.0: 590 | dev: false 591 | engines: 592 | node: '>= 0.6' 593 | resolution: 594 | integrity: sha512-R3C4db6bgQhlIhPU48fUtdVmKnflq+hRdad7IyKhtFj06VPNVdk2RhiYL3UjQIlso8L+YxAtFkobT0VK+S/ybg== 595 | /mime-db/1.38.0: 596 | dev: false 597 | engines: 598 | node: '>= 0.6' 599 | resolution: 600 | integrity: sha512-bqVioMFFzc2awcdJZIzR3HjZFX20QhilVS7hytkKrv7xFAn8bM1gzc/FOX2awLISvWe0PV8ptFKcon+wZ5qYkg== 601 | /mime-types/2.1.21: 602 | dependencies: 603 | mime-db: 1.37.0 604 | dev: false 605 | engines: 606 | node: '>= 0.6' 607 | resolution: 608 | integrity: sha512-3iL6DbwpyLzjR3xHSFNFeb9Nz/M8WDkX33t1GFQnFOllWk8pOrh/LSrB5OXlnlW5P9LH73X6loW/eogc+F5lJg== 609 | /mime-types/2.1.22: 610 | dependencies: 611 | mime-db: 1.38.0 612 | dev: false 613 | engines: 614 | node: '>= 0.6' 615 | resolution: 616 | integrity: sha512-aGl6TZGnhm/li6F7yx82bJiBZwgiEa4Hf6CNr8YO+r5UHr53tSTYZb102zyU50DOWWKeOv0uQLRL0/9EiKWCog== 617 | /mime/1.4.1: 618 | dev: false 619 | hasBin: true 620 | resolution: 621 | integrity: sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ== 622 | /minimatch/3.0.4: 623 | dependencies: 624 | brace-expansion: 1.1.11 625 | dev: false 626 | resolution: 627 | integrity: sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== 628 | /minimist/0.0.10: 629 | dev: false 630 | resolution: 631 | integrity: sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8= 632 | /minimist/0.0.8: 633 | dev: false 634 | resolution: 635 | integrity: sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= 636 | /mkdirp/0.5.1: 637 | dependencies: 638 | minimist: 0.0.8 639 | dev: false 640 | hasBin: true 641 | resolution: 642 | integrity: sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= 643 | /ms/2.0.0: 644 | dev: false 645 | resolution: 646 | integrity: sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= 647 | /ms/2.1.1: 648 | dev: false 649 | resolution: 650 | integrity: sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== 651 | /nedb-promises/3.0.2: 652 | dependencies: 653 | nedb: 1.8.0 654 | dev: false 655 | resolution: 656 | integrity: sha512-9GyvhZF7LRHMbOsk+6peJc9IdmWifZAGG/er0sE8VZSOsNJII+Aywcrm5OcesSbu9GZ7E1PxnL63kYqypeZw9Q== 657 | /nedb/1.8.0: 658 | dependencies: 659 | async: 0.2.10 660 | binary-search-tree: 0.2.5 661 | localforage: 1.7.3 662 | mkdirp: 0.5.1 663 | underscore: 1.4.4 664 | dev: false 665 | resolution: 666 | integrity: sha1-DjUCzYLABNU1WkPJ5VV3vXvZHYg= 667 | /negotiator/0.6.1: 668 | dev: false 669 | engines: 670 | node: '>= 0.6' 671 | resolution: 672 | integrity: sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk= 673 | /next-tick/1.0.0: 674 | dev: false 675 | resolution: 676 | integrity: sha1-yobR/ogoFpsBICCOPchCS524NCw= 677 | /oauth-sign/0.9.0: 678 | dev: false 679 | resolution: 680 | integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== 681 | /object-keys/1.0.12: 682 | dev: false 683 | engines: 684 | node: '>= 0.4' 685 | resolution: 686 | integrity: sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag== 687 | /object.assign/4.1.0: 688 | dependencies: 689 | define-properties: 1.1.3 690 | function-bind: 1.1.1 691 | has-symbols: 1.0.0 692 | object-keys: 1.0.12 693 | dev: false 694 | engines: 695 | node: '>= 0.4' 696 | resolution: 697 | integrity: sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== 698 | /on-finished/2.3.0: 699 | dependencies: 700 | ee-first: 1.1.1 701 | dev: false 702 | engines: 703 | node: '>= 0.8' 704 | resolution: 705 | integrity: sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= 706 | /once/1.4.0: 707 | dependencies: 708 | wrappy: 1.0.2 709 | dev: false 710 | resolution: 711 | integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E= 712 | /optimist/0.6.1: 713 | dependencies: 714 | minimist: 0.0.10 715 | wordwrap: 0.0.3 716 | dev: false 717 | resolution: 718 | integrity: sha1-2j6nRob6IaGaERwybpDrFaAZZoY= 719 | /parseurl/1.3.2: 720 | dev: false 721 | engines: 722 | node: '>= 0.8' 723 | resolution: 724 | integrity: sha1-/CidTtiZMRlGDBViUyYs3I3mW/M= 725 | /path-is-absolute/1.0.1: 726 | dev: false 727 | engines: 728 | node: '>=0.10.0' 729 | resolution: 730 | integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18= 731 | /path-to-regexp/0.1.7: 732 | dev: false 733 | resolution: 734 | integrity: sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= 735 | /performance-now/2.1.0: 736 | dev: false 737 | resolution: 738 | integrity: sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= 739 | /pngjs/3.3.3: 740 | dev: false 741 | engines: 742 | node: '>=4.0.0' 743 | resolution: 744 | integrity: sha512-1n3Z4p3IOxArEs1VRXnZ/RXdfEniAUS9jb68g58FIXMNkPJeZd+Qh4Uq7/e0LVxAQGos1eIUrqrt4FpjdnEd+Q== 745 | /pretty-bytes/5.3.0: 746 | dev: false 747 | engines: 748 | node: '>=6' 749 | resolution: 750 | integrity: sha512-hjGrh+P926p4R4WbaB6OckyRtO0F0/lQBiT+0gnxjV+5kjPBrfVBFCsCLbMqVQeydvIoouYTCmmEURiH3R1Bdg== 751 | /prism-media/0.0.3: 752 | dev: false 753 | resolution: 754 | integrity: sha512-c9KkNifSMU/iXT8FFTaBwBMr+rdVcN+H/uNv1o+CuFeTThNZNTOrQ+RgXA1yL/DeLk098duAeRPP3QNPNbhxYQ== 755 | /probe-image-size/4.0.0: 756 | dependencies: 757 | any-promise: 1.3.0 758 | deepmerge: 2.2.1 759 | inherits: 2.0.3 760 | next-tick: 1.0.0 761 | request: 2.88.0 762 | stream-parser: 0.3.1 763 | dev: false 764 | resolution: 765 | integrity: sha512-nm7RvWUxps+2+jZKNLkd04mNapXNariS6G5WIEVzvAqjx7EUuKcY1Dp3e6oUK7GLwzJ+3gbSbPLFAASHFQrPcQ== 766 | /promise/7.3.1: 767 | dependencies: 768 | asap: 2.0.6 769 | dev: false 770 | resolution: 771 | integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== 772 | /proxy-addr/2.0.4: 773 | dependencies: 774 | forwarded: 0.1.2 775 | ipaddr.js: 1.8.0 776 | dev: false 777 | engines: 778 | node: '>= 0.10' 779 | resolution: 780 | integrity: sha512-5erio2h9jp5CHGwcybmxmVqHmnCBZeewlfJ0pex+UW7Qny7OOZXTtH56TGNyBizkgiOwhJtMKrVzDTeKcySZwA== 781 | /psl/1.1.31: 782 | dev: false 783 | resolution: 784 | integrity: sha512-/6pt4+C+T+wZUieKR620OpzN/LlnNKuWjy1iFLQ/UG35JqHlR/89MP1d96dUfkf6Dne3TuLQzOYEYshJ+Hx8mw== 785 | /punycode/1.4.1: 786 | dev: false 787 | resolution: 788 | integrity: sha1-wNWmOycYgArY4esPpSachN1BhF4= 789 | /punycode/2.1.1: 790 | dev: false 791 | engines: 792 | node: '>=6' 793 | resolution: 794 | integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== 795 | /qs/6.5.2: 796 | dev: false 797 | engines: 798 | node: '>=0.6' 799 | resolution: 800 | integrity: sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== 801 | /range-parser/1.2.0: 802 | dev: false 803 | engines: 804 | node: '>= 0.6' 805 | resolution: 806 | integrity: sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4= 807 | /raw-body/2.3.3: 808 | dependencies: 809 | bytes: 3.0.0 810 | http-errors: 1.6.3 811 | iconv-lite: 0.4.23 812 | unpipe: 1.0.0 813 | dev: false 814 | engines: 815 | node: '>= 0.8' 816 | resolution: 817 | integrity: sha512-9esiElv1BrZoI3rCDuOuKCBRbuApGGaDPQfjSflGxdy4oyzqghxu6klEkkVIvBje+FF0BX9coEv8KqW6X/7njw== 818 | /request/2.88.0: 819 | dependencies: 820 | aws-sign2: 0.7.0 821 | aws4: 1.8.0 822 | caseless: 0.12.0 823 | combined-stream: 1.0.7 824 | extend: 3.0.2 825 | forever-agent: 0.6.1 826 | form-data: 2.3.3 827 | har-validator: 5.1.3 828 | http-signature: 1.2.0 829 | is-typedarray: 1.0.0 830 | isstream: 0.1.2 831 | json-stringify-safe: 5.0.1 832 | mime-types: 2.1.22 833 | oauth-sign: 0.9.0 834 | performance-now: 2.1.0 835 | qs: 6.5.2 836 | safe-buffer: 5.1.2 837 | tough-cookie: 2.4.3 838 | tunnel-agent: 0.6.0 839 | uuid: 3.3.2 840 | dev: false 841 | engines: 842 | node: '>= 4' 843 | resolution: 844 | integrity: sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== 845 | /safe-buffer/5.1.2: 846 | dev: false 847 | resolution: 848 | integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== 849 | /safer-buffer/2.1.2: 850 | dev: false 851 | resolution: 852 | integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== 853 | /send/0.16.2: 854 | dependencies: 855 | debug: 2.6.9 856 | depd: 1.1.2 857 | destroy: 1.0.4 858 | encodeurl: 1.0.2 859 | escape-html: 1.0.3 860 | etag: 1.8.1 861 | fresh: 0.5.2 862 | http-errors: 1.6.3 863 | mime: 1.4.1 864 | ms: 2.0.0 865 | on-finished: 2.3.0 866 | range-parser: 1.2.0 867 | statuses: 1.4.0 868 | dev: false 869 | engines: 870 | node: '>= 0.8.0' 871 | resolution: 872 | integrity: sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw== 873 | /serve-static/1.13.2: 874 | dependencies: 875 | encodeurl: 1.0.2 876 | escape-html: 1.0.3 877 | parseurl: 1.3.2 878 | send: 0.16.2 879 | dev: false 880 | engines: 881 | node: '>= 0.8.0' 882 | resolution: 883 | integrity: sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw== 884 | /setprototypeof/1.1.0: 885 | dev: false 886 | resolution: 887 | integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== 888 | /snekfetch/3.6.4: 889 | deprecated: use node-fetch instead 890 | dev: false 891 | resolution: 892 | integrity: sha512-NjxjITIj04Ffqid5lqr7XdgwM7X61c/Dns073Ly170bPQHLm6jkmelye/eglS++1nfTWktpP6Y2bFXjdPlQqdw== 893 | /source-map/0.6.1: 894 | dev: false 895 | engines: 896 | node: '>=0.10.0' 897 | resolution: 898 | integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== 899 | /sshpk/1.16.1: 900 | dependencies: 901 | asn1: 0.2.4 902 | assert-plus: 1.0.0 903 | bcrypt-pbkdf: 1.0.2 904 | dashdash: 1.14.1 905 | ecc-jsbn: 0.1.2 906 | getpass: 0.1.7 907 | jsbn: 0.1.1 908 | safer-buffer: 2.1.2 909 | tweetnacl: 0.14.5 910 | dev: false 911 | engines: 912 | node: '>=0.10.0' 913 | hasBin: true 914 | resolution: 915 | integrity: sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== 916 | /statuses/1.4.0: 917 | dev: false 918 | engines: 919 | node: '>= 0.6' 920 | resolution: 921 | integrity: sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew== 922 | /statuses/1.5.0: 923 | dev: false 924 | engines: 925 | node: '>= 0.6' 926 | resolution: 927 | integrity: sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= 928 | /stream-parser/0.3.1: 929 | dependencies: 930 | debug: 2.6.9 931 | dev: false 932 | resolution: 933 | integrity: sha1-FhhUhpRCACGhGC/wrxkRwSl2F3M= 934 | /timeago.js/4.0.0-beta.2: 935 | dev: false 936 | resolution: 937 | integrity: sha512-MQkHiYGoB6qZC4DNWsLc9bav+L9hpdulL6sL7okzKR8r1Ipask/tXKAio8T+4jeby8FbpbDvbnCKGrh1bLop3g== 938 | /tough-cookie/2.4.3: 939 | dependencies: 940 | psl: 1.1.31 941 | punycode: 1.4.1 942 | dev: false 943 | engines: 944 | node: '>=0.8' 945 | resolution: 946 | integrity: sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ== 947 | /tunnel-agent/0.6.0: 948 | dependencies: 949 | safe-buffer: 5.1.2 950 | dev: false 951 | resolution: 952 | integrity: sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= 953 | /tweetnacl/0.14.5: 954 | dev: false 955 | resolution: 956 | integrity: sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= 957 | /tweetnacl/1.0.1: 958 | dev: false 959 | resolution: 960 | integrity: sha512-kcoMoKTPYnoeS50tzoqjPY3Uv9axeuuFAZY9M/9zFnhoVvRfxz9K29IMPD7jGmt2c8SW7i3gT9WqDl2+nV7p4A== 961 | /type-is/1.6.16: 962 | dependencies: 963 | media-typer: 0.3.0 964 | mime-types: 2.1.21 965 | dev: false 966 | engines: 967 | node: '>= 0.6' 968 | resolution: 969 | integrity: sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q== 970 | /uglify-js/3.4.9: 971 | dependencies: 972 | commander: 2.17.1 973 | source-map: 0.6.1 974 | dev: false 975 | engines: 976 | node: '>=0.8.0' 977 | hasBin: true 978 | optional: true 979 | resolution: 980 | integrity: sha512-8CJsbKOtEbnJsTyv6LE6m6ZKniqMiFWmm9sRbopbkGs3gMPPfd3Fh8iIA4Ykv5MgaTbqHr4BaoGLJLZNhsrW1Q== 981 | /underscore/1.4.4: 982 | dev: false 983 | resolution: 984 | integrity: sha1-YaajIBBiKvoHljvzJSA88SI51gQ= 985 | /unpipe/1.0.0: 986 | dev: false 987 | engines: 988 | node: '>= 0.8' 989 | resolution: 990 | integrity: sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= 991 | /uri-js/4.2.2: 992 | dependencies: 993 | punycode: 2.1.1 994 | dev: false 995 | resolution: 996 | integrity: sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ== 997 | /utils-merge/1.0.1: 998 | dev: false 999 | engines: 1000 | node: '>= 0.4.0' 1001 | resolution: 1002 | integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= 1003 | /uuid/3.3.2: 1004 | dev: false 1005 | hasBin: true 1006 | resolution: 1007 | integrity: sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== 1008 | /vary/1.1.2: 1009 | dev: false 1010 | engines: 1011 | node: '>= 0.8' 1012 | resolution: 1013 | integrity: sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= 1014 | /verror/1.10.0: 1015 | dependencies: 1016 | assert-plus: 1.0.0 1017 | core-util-is: 1.0.2 1018 | extsprintf: 1.4.0 1019 | dev: false 1020 | engines: 1021 | '0': node >=0.6.0 1022 | resolution: 1023 | integrity: sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= 1024 | /wordwrap/0.0.3: 1025 | dev: false 1026 | engines: 1027 | node: '>=0.4.0' 1028 | resolution: 1029 | integrity: sha1-o9XabNXAvAAI03I0u68b7WMFkQc= 1030 | /wrappy/1.0.2: 1031 | dev: false 1032 | resolution: 1033 | integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= 1034 | /ws/4.1.0: 1035 | dependencies: 1036 | async-limiter: 1.0.0 1037 | safe-buffer: 5.1.2 1038 | dev: false 1039 | resolution: 1040 | integrity: sha512-ZGh/8kF9rrRNffkLFV4AzhvooEclrOH0xaugmqGsIfFgOE/pIz4fMc4Ef+5HSQqTEug2S9JZIWDR47duDSLfaA== 1041 | /xhr2/0.1.4: 1042 | dev: false 1043 | engines: 1044 | node: '>= 0.6' 1045 | resolution: 1046 | integrity: sha1-f4dliEdxbbUCYyOBL4GMras4el8= 1047 | registry: 'https://registry.npmjs.org/' 1048 | shrinkwrapMinorVersion: 9 1049 | shrinkwrapVersion: 3 1050 | specifiers: 1051 | axios: '*' 1052 | discord.js: '*' 1053 | express: ^4.16.4 1054 | express-handlebars: '*' 1055 | jpeg-js: '*' 1056 | nedb: '*' 1057 | nedb-promises: '*' 1058 | pngjs: '*' 1059 | pretty-bytes: ^5.3.0 1060 | probe-image-size: '*' 1061 | timeago.js: '*' 1062 | xhr2: '*' 1063 | --------------------------------------------------------------------------------