Create a Discord bot

This commit is contained in:
Jordan Eldredge 2019-03-27 16:06:25 -07:00
parent 82d7f99814
commit 696fd077fa
14 changed files with 716 additions and 0 deletions

2
experiments/discord-bot/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
config.js
node_modules/

View file

@ -0,0 +1,37 @@
const { approve, getStatus } = require("../s3");
const Utils = require("../utils");
const { getFilename } = require("../info");
const TWEET_BOT_CHANNEL_ID = "445577489834704906";
async function handler(message, args) {
const [md5] = args;
const filename = getFilename(md5);
if (filename == null) {
await message.channel.send(`Could not find a skin matching ${md5}`);
return;
}
const status = await getStatus(md5);
if (status !== "UNREVIEWED") {
await message.channel.send(`This skin has already been reviewed.`);
return;
}
await approve(md5);
const tweetBotChannel = message.client.channels.get(TWEET_BOT_CHANNEL_ID);
await Utils.postSkin({
filename,
md5,
title: `Approved: ${filename}`,
dest: tweetBotChannel
});
await tweetBotChannel.send(
`${filename} was approved by ${message.author.username}`
);
}
module.exports = {
command: "approve",
handler,
usage: "<md5-hash>",
description: "Approve a given skin to be Tweeted by the Twitter bot"
};

View file

@ -0,0 +1,60 @@
const fs = require("fs");
const path = require("path");
const fetch = require("node-fetch");
const md5Buffer = require("md5");
const { getInfo } = require("../info");
const Skin = require("./skin");
async function handler(message, args) {
const { attachments } = message;
if (attachments.length < 1) {
await message.channel.send("Could not archive. No attachment found.");
return;
}
const files = await Promise.all(
attachments.map(async attachment => {
const { filename, url } = attachment;
const response = await fetch(url);
console.log("got response");
const buffer = await response.buffer();
console.log("got buffer");
const md5 = md5Buffer(buffer);
console.log("got md5", md5);
return { filename, buffer, md5 };
})
);
for (const file of files) {
const info = await getInfo(file.md5);
if (info == null) {
fs.writeFileSync(
path.join(
"/Volumes/Mobile Backup/skins/skins/discord-bot",
// TODO: Use a sub directory using md5 to avoid collision
// file.md5,
file.filename
),
file.buffer
);
await message.channel.send(
`Thanks! ${
file.filename
} is a brand new skin. 👏 It has been queued for archiving.`
);
} else {
await message.channel.send(
`We already have ${file.filename} in our collection`
);
await Skin.handler(message, [file.md5]);
}
}
}
module.exports = {
command: "archive",
usage: "",
description:
"Queues the accompanying uploaded skin for inclusion in the Archive",
handler
};

View file

@ -0,0 +1,24 @@
const Utils = require("../utils");
const { getCache, getFilename } = require("../info");
async function handler(message) {
const cache = getCache();
const skins = Object.values(cache).filter(skin => skin.type === "CLASSIC");
const skin = skins[Math.floor(Math.random() * skins.length)];
const { md5 } = skin;
const filename = getFilename(md5);
await Utils.postSkin({
filename,
md5,
title: `${filename}`,
dest: message.channel
});
}
module.exports = {
usage: "",
description: "Show information about a random skin",
command: "random",
handler
};

View file

@ -0,0 +1,37 @@
const { reject, getStatus } = require("../s3");
const Utils = require("../utils");
const { getFilename } = require("../info");
const TWEET_BOT_CHANNEL_ID = "445577489834704906";
async function handler(message, args) {
const [md5] = args;
const filename = getFilename(md5);
if (filename == null) {
await message.channel.send(`Could not find a skin matching ${md5}`);
return;
}
const status = await getStatus(md5);
if (status !== "UNREVIEWED") {
await message.channel.send(`This skin has already been reviewed.`);
return;
}
await reject(md5);
const tweetBotChannel = message.client.channels.get(TWEET_BOT_CHANNEL_ID);
await Utils.postSkin({
filename,
md5,
title: `Rejected: ${filename}`,
dest: tweetBotChannel
});
await tweetBotChannel.send(
`${filename} was rejected by ${message.author.username}`
);
}
module.exports = {
command: "reject",
handler,
usage: "<md5-hash>",
description: "Reject a given skin from being Tweeted by the Twitter bot"
};

View file

@ -0,0 +1,35 @@
const { getSkinToReview } = require("../s3");
const Utils = require("../utils");
async function reviewSkin(message) {
const { filename, md5 } = await getSkinToReview();
await Utils.postSkin({
filename,
md5,
title: `Review: ${filename}`,
dest: message.client.channels
});
}
async function handler(message, args) {
const [arg1] = args;
const count = arg1 ? Math.min(arg1, 10) : 1;
if (count > 1) {
message.channel.send(`Going to show ${count} skins to review`);
}
let i = Number(count);
while (i--) {
await reviewSkin(message);
}
if (count > 1) {
message.channel.send(`Done reviewing ${count} skins. Thanks!`);
}
}
module.exports = {
command: "review",
handler,
usage: "[<number>]",
description:
"Post a <number> of skins to be reviewed for inclusion in the Twitter bot. Defaults to 1"
};

View file

@ -0,0 +1,19 @@
const { getFilename } = require("../info");
const Utils = require("../utils");
async function handler(message, args) {
const [md5] = args;
const filename = getFilename(md5);
await Utils.postSkin({
filename,
md5,
title: `${filename}`,
dest: message.channel
});
}
module.exports = {
usage: "<md5-skin>",
description: "Show information about a skin",
command: "skin",
handler
};

View file

@ -0,0 +1,25 @@
const { getCache } = require("../info");
const { getStats } = require("../s3");
async function handler(message, args) {
const info = getCache();
let classic = 0;
const { tweeted, approved, rejected } = await getStats();
Object.values(info).forEach(skin => {
if (skin.type === "CLASSIC") {
classic++;
}
});
await message.channel.send(`Unique Skins: ${classic.toLocaleString()}
Tweeted: ${tweeted.toLocaleString()}
Rejected: ${rejected.toLocaleString()}
Approved: ${approved.toLocaleString()}
`);
}
module.exports = {
command: "stats",
handler,
usage: "",
description: "Give some statistics about the skin archive"
};

View file

@ -0,0 +1,68 @@
const fs = require("fs");
const Discord = require("discord.js");
const config = require("./config");
const client = new Discord.Client();
function handleStats() {}
const handlers = {
stats: handleStats,
help: handleHelp
};
const commands = fs
.readdirSync("./commands")
.filter(file => file.endsWith(".js"))
.map(file => {
return require(`./commands/${file}`);
});
for (const command of commands) {
handlers[command.command] = command.handler;
}
async function handleHelp(message, args) {
const commandHelp = commands
.map(
command =>
`\`!${command.command} ${command.usage || ""}\` -- ${
command.description
}`
)
.join("\n");
const help = `
I am a helpful bot that can do things with Winamp skins. Here are the commands I know:
${commandHelp}
\`!help\` Show this message
You can issue these commands in a DM to me or in any channel. For tasks relating to approving skins, I will respond in the #tweet-bot channel.
`.trim();
message.channel.send(help);
}
client.on("message", async message => {
if (message.author.bot) {
return;
}
const [rawCommand, ...args] = message.content.split(" ");
if (!rawCommand.startsWith("!")) {
return;
}
const command = rawCommand.slice(1);
console.log("New command: ", command);
console.log("User: ", message.author.username);
console.log("Arguments: ", args);
const handler = handlers[command];
if (handler != null) {
handler(message, args);
}
});
client.on("error", error => {
console.error("The WebSocket encountered an error:", error);
});
client.login(config.discordToken);

View file

@ -0,0 +1,27 @@
const path = require("path");
const fs = require("fs");
let cache = null;
function getCache() {
if (cache == null) {
cache = JSON.parse(
fs.readFileSync(
path.join("/Volumes/Mobile Backup/skins/cache", "info.json"),
"utf8"
)
);
}
return cache;
}
function getInfo(md5) {
return getCache()[md5];
}
function getFilename(md5) {
const info = getInfo(md5);
return info.filePaths.map(filepath => path.basename(filepath))[0];
}
module.exports = { getInfo, getCache, getFilename };

View file

@ -0,0 +1,17 @@
{
"name": "discord-bot",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"aws-sdk": "^2.429.0",
"discord.js": "^11.4.2",
"md5": "^2.2.1",
"node-fetch": "^2.3.0",
"rgb-hex": "^2.1.0"
},
"scripts": {
"start": "node index.js"
},
"prettier": {}
}

View file

@ -0,0 +1,115 @@
const AWS = require("aws-sdk");
AWS.config.update({ region: "us-west-2" });
const s3 = new AWS.S3();
function getFile(key) {
return new Promise((resolve, reject) => {
const bucketName = "winamp2-js-skins";
s3.getObject({ Bucket: bucketName, Key: key }, (err, data) => {
if (err) {
reject(err);
return;
}
const body = Buffer.from(data.Body).toString("utf8");
resolve(body);
});
});
}
function putFile(key, body) {
return new Promise((resolve, reject) => {
const bucketName = "winamp2-js-skins";
s3.putObject({ Bucket: bucketName, Key: key, Body: body }, (err, data) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
function getLines(body) {
return body.split("\n").map(line => line.trim());
}
async function getStatus(md5) {
const [approved, rejected, tweeted] = await Promise.all([
getFile("approved.txt"),
getFile("rejected.txt"),
getFile("tweeted.txt")
]);
const approvedSet = new Set(getLines(approved));
const rejectedSet = new Set(getLines(rejected));
const tweetedSet = new Set(getLines(tweeted));
if (tweetedSet.has(md5)) {
return "TWEETED";
}
if (rejectedSet.has(md5)) {
return "REJECTED";
}
if (approvedSet.has(md5)) {
return "APPROVED";
}
return "UNREVIEWED";
}
async function getStats() {
const [approved, rejected, tweeted] = await Promise.all([
getFile("approved.txt"),
getFile("rejected.txt"),
getFile("tweeted.txt")
]);
return {
approved: new Set(approved).size - new Set(tweeted).size,
rejected: new Set(rejected).size,
tweeted: new Set(tweeted).size
};
}
async function getSkinToReview() {
console.log("Reading from s3...");
const [filenames, approved, rejected, tweeted] = await Promise.all([
getFile("filenames.txt"),
getFile("approved.txt"),
getFile("rejected.txt"),
getFile("tweeted.txt")
]);
console.log("Got all files");
const approvedSet = new Set(getLines(approved));
const rejectedSet = new Set(getLines(rejected));
const tweetedSet = new Set(getLines(tweeted));
const filenameLines = getLines(filenames);
const skins = filenameLines.map(line => {
const [md5, ...filename] = line.split(" ");
return { md5, filename: filename.join(" ") };
});
const toReview = skins.filter(({ md5 }) => {
return !(
approvedSet.has(md5) ||
rejectedSet.has(md5) ||
tweetedSet.has(md5)
);
});
console.log(toReview.length, "skins to review");
return toReview[0];
}
async function appendLine(key, line) {
const currentContent = await getFile(key);
const newContent = `${currentContent}${line}\n`;
return putFile(key, newContent);
}
async function approve(md5) {
return appendLine("approved.txt", md5);
}
async function reject(md5) {
return appendLine("rejected.txt", md5);
}
module.exports = { getSkinToReview, approve, reject, getStatus, getStats };

View file

@ -0,0 +1,94 @@
const Discord = require("discord.js");
const rgbHex = require("rgb-hex");
const { getInfo } = require("./info");
const { approve, reject, getStatus } = require("./s3");
const filter = reaction => {
return ["👍", "👎"].some(name => reaction.emoji.name === name);
};
async function postSkin({ filename, md5, title, dest }) {
const screenshotUrl = `https://s3.amazonaws.com/webamp-uploaded-skins/screenshots/${md5}.png`;
const skinUrl = `https://s3.amazonaws.com/webamp-uploaded-skins/skins/${md5}.wsz`;
const webampUrl = `https://webamp.org?skinUrl=${skinUrl}`;
const embed = new Discord.RichEmbed()
.setTitle(title)
.addField("Try Online", `[webamp.org](${webampUrl})`, true)
.addField("Download", `[${filename}](${skinUrl})`, true)
.addField("Md5", md5, true)
.setImage(screenshotUrl);
const info = getInfo(md5);
if (info == null) {
embed.addField("Warning", "Cached metadata not found", true);
} else {
if (info.averageColor) {
const color = rgbHex(info.averageColor);
if (String(color).length === 6) {
embed.setColor(`#${color}`);
} else {
console.log("Did not get a safe color from ", info.averageColor);
console.log("Got ", color);
}
}
if (info.emails != null && info.emails.length) {
const uniqueEmails = Array.from(new Set(info.emails));
// TODO: Fix email parsing
embed.addField("Emails", uniqueEmails.join(", "), true);
}
if (info.tweetUrl != null) {
let likes = "";
if (info.twitterLikes != null) {
likes = `(${Number(info.twitterLikes).toLocaleString()} likes) `;
}
embed.addField(
"Tweet Status",
`[Tweeted](${info.tweetUrl}) ${likes}🐦`,
true
);
} else {
const status = await getStatus(md5);
if (status === "UNREVIEWED") {
embed.setFooter("React with 👍 or 👎 to approve or deny");
}
embed.addField("Tweet Status", getPrettyTwitterStatus(status), true);
}
}
const msg = await dest.send(embed);
await msg.awaitReactions(filter, { max: 1 }).then(async collected => {
const vote = collected.first();
const user = vote.users.first();
switch (vote.emoji.name) {
case "👍":
await approve(md5);
console.log(`${user.username} approved ${md5}`);
await msg.channel.send(`${filename} was approved by ${user.username}`);
msg.react("✅");
break;
case "👎":
await reject(md5);
console.log(`${user.username} rejected ${md5}`);
await msg.channel.send(`${filename} was rejected by ${user.username}`);
msg.react("❌");
break;
}
});
}
function getPrettyTwitterStatus(status) {
switch (status) {
case "APPROVED":
return "Approved ✅";
case "REJECTED":
return "Rejected ❌";
case "UNREVIEWED":
return "Unreviewed ❔";
case "TWEETED":
return "Tweeted 🐦";
}
}
module.exports = { postSkin };

View file

@ -0,0 +1,156 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
async-limiter@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8"
aws-sdk@^2.429.0:
version "2.429.0"
resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.429.0.tgz#27c32734f6473f69a39c29a694d54d15b5beb68a"
dependencies:
buffer "4.9.1"
events "1.1.1"
ieee754 "1.1.8"
jmespath "0.15.0"
querystring "0.2.0"
sax "1.2.1"
url "0.10.3"
uuid "3.3.2"
xml2js "0.4.19"
base64-js@^1.0.2:
version "1.3.0"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3"
buffer@4.9.1:
version "4.9.1"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298"
dependencies:
base64-js "^1.0.2"
ieee754 "^1.1.4"
isarray "^1.0.0"
charenc@~0.0.1:
version "0.0.2"
resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
crypt@~0.0.1:
version "0.0.2"
resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
discord.js@^11.4.2:
version "11.4.2"
resolved "https://registry.yarnpkg.com/discord.js/-/discord.js-11.4.2.tgz#54586981926521572051f2a30b984aad2b49786e"
dependencies:
long "^4.0.0"
prism-media "^0.0.3"
snekfetch "^3.6.4"
tweetnacl "^1.0.0"
ws "^4.0.0"
events@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
ieee754@1.1.8:
version "1.1.8"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4"
ieee754@^1.1.4:
version "1.1.13"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
is-buffer@~1.1.1:
version "1.1.6"
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
isarray@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
jmespath@0.15.0:
version "0.15.0"
resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217"
long@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
md5@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/md5/-/md5-2.2.1.tgz#53ab38d5fe3c8891ba465329ea23fac0540126f9"
dependencies:
charenc "~0.0.1"
crypt "~0.0.1"
is-buffer "~1.1.1"
node-fetch@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.3.0.tgz#1a1d940bbfb916a1d3e0219f037e89e71f8c5fa5"
prism-media@^0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/prism-media/-/prism-media-0.0.3.tgz#8842d4fae804f099d3b48a9a38e3c2bab6f4855b"
punycode@1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d"
querystring@0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620"
rgb-hex@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/rgb-hex/-/rgb-hex-2.1.0.tgz#c773c5fe2268a25578d92539a82a7a5ce53beda6"
safe-buffer@~5.1.0:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
sax@1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a"
sax@>=0.6.0:
version "1.2.4"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
snekfetch@^3.6.4:
version "3.6.4"
resolved "https://registry.yarnpkg.com/snekfetch/-/snekfetch-3.6.4.tgz#d13e80a616d892f3d38daae4289f4d258a645120"
tweetnacl@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.1.tgz#2594d42da73cd036bd0d2a54683dd35a6b55ca17"
url@0.10.3:
version "0.10.3"
resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64"
dependencies:
punycode "1.3.2"
querystring "0.2.0"
uuid@3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
ws@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-4.1.0.tgz#a979b5d7d4da68bf54efe0408967c324869a7289"
dependencies:
async-limiter "~1.0.0"
safe-buffer "~5.1.0"
xml2js@0.4.19:
version "0.4.19"
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7"
dependencies:
sax ">=0.6.0"
xmlbuilder "~9.0.1"
xmlbuilder@~9.0.1:
version "9.0.7"
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"