├── 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 |
--------------------------------------------------------------------------------