Class commands, improved sharding, and many other changes (#88)

* Load commands recursively

* Sort commands

* Missed a couple of spots

* missed even more spots apparently

* Ported commands in "fun" category to new class-based format, added babel eslint plugin

* Ported general commands, removed old/unneeded stuff, replaced moment with day, many more fixes I lost track of

* Missed a spot

* Removed unnecessary abort-controller package, add deprecation warning for mongo database

* Added imagereload, clarified premature end message

* Fixed docker-compose path issue, added total bot uptime to stats, more fixes for various parts

* Converted image commands into classes, fixed reload, ignore another WS event, cleaned up command handler and image runner

* Converted music/soundboard commands to class format

* Cleanup unnecessary logs

* awful tag command class port

* I literally somehow just learned that you can leave out the constructor in classes

* Pass client directly to commands/events, cleaned up command handler

* Migrated bot to eris-sharder, fixed some error handling stuff

* Remove unused modules

* Fixed type returning

* Switched back to Eris stable

* Some fixes and cleanup

* might wanna correct this

* Implement image command ratelimiting

* Added Bot token prefix, added imagestats, added running endpoint to API
This commit is contained in:
Essem 2021-04-12 11:16:12 -05:00 committed by GitHub
parent ff8a24d0e8
commit 40223ec8b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
291 changed files with 5296 additions and 5171 deletions

View file

@ -1,24 +0,0 @@
// separate the client from app.js so we can call it later
const { Client } = require("eris");
const client = new Client(process.env.TOKEN, {
disableEvents: {
CHANNEL_DELETE: true,
CHANNEL_UPDATE: true,
GUILD_BAN_REMOVE: true,
GUILD_MEMBER_ADD: true,
GUILD_MEMBER_REMOVE: true,
GUILD_MEMBER_UPDATE: true,
GUILD_ROLE_CREATE: true,
GUILD_ROLE_DELETE: true,
GUILD_ROLE_UPDATE: true,
TYPING_START: true
},
maxShards: "auto",
allowedMentions: {
everyone: false,
roles: false,
users: true,
repliedUser: true
}
});
module.exports = client;

View file

@ -1,7 +1,19 @@
exports.commands = new Map();
exports.paths = new Map();
exports.aliases = new Map();
exports.info = new Map();
class TimedMap extends Map {
set(key, value) {
super.set(key, value);
setTimeout(() => {
if (super.has(key)) super.delete(key);
}, 5000);
}
}
exports.runningCommands = new TimedMap();
class Cache extends Map {
constructor(values) {
super(values);

View file

@ -2,6 +2,8 @@ const collections = require("../collections.js");
const logger = require("../logger.js");
const misc = require("../misc.js");
logger.warn("\x1b[1m\x1b[31m\x1b[40m" + "The MongoDB database driver has been deprecated and will be removed in a future release. Please migrate your database to PostgreSQL as soon as possible." + "\x1b[0m");
const mongoose = require("mongoose");
mongoose.connect(process.env.DB, {
poolSize: 10,

View file

@ -68,6 +68,8 @@ exports.addCount = async (command) => {
};
exports.addGuild = async (guild) => {
const query = await this.getGuild(guild);
if (query) return query;
await connection.query("INSERT INTO guilds (guild_id, tags, prefix, disabled, tags_disabled) VALUES ($1, $2, $3, $4, $5)", [guild.id, misc.tagDefaults, process.env.PREFIX, [], false]);
return await this.getGuild(guild.id);
};

View file

@ -1,12 +0,0 @@
// dbl api client
const poster = require("topgg-autoposter");
const logger = require("./logger.js");
const client = require("./client.js");
const dbl = poster(process.env.DBL, client);
dbl.on("posted", () => {
logger.log("Posted stats to top.gg");
});
dbl.on("error", e => {
logger.error(e);
});
module.exports = dbl;

View file

@ -3,21 +3,28 @@ const logger = require("./logger.js");
// load command into memory
exports.load = async (command, soundStatus) => {
const props = require(`../commands/${command}`);
if (props.requires === "google" && process.env.GOOGLE === "") return logger.log("info", `Google info not provided in config, skipped loading command ${command}...`);
if (props.requires === "cat" && process.env.CAT === "") return logger.log("info", `Cat API info not provided in config, skipped loading command ${command}...`);
if (props.requires === "mashape" && process.env.MASHAPE === "") return logger.log("info", `Mashape/RapidAPI info not provided in config, skipped loading command ${command}...`);
if (props.requires === "sound" && soundStatus) return logger.log("info", `Failed to connect to some Lavalink nodes, skipped loading command ${command}...`);
collections.commands.set(command.split(".")[0], props.run);
collections.info.set(command.split(".")[0], {
category: props.category,
description: props.help,
const props = require(`../${command}`);
if (props.requires.includes("google") && process.env.GOOGLE === "") return logger.log("warn", `Google info not provided in config, skipped loading command ${command}...`);
if (props.requires.includes("cat") && process.env.CAT === "") return logger.log("warn", `Cat API info not provided in config, skipped loading command ${command}...`);
if (props.requires.includes("mashape") && process.env.MASHAPE === "") return logger.log("warn", `Mashape/RapidAPI info not provided in config, skipped loading command ${command}...`);
if (props.requires.includes("sound") && soundStatus) return logger.log("warn", `Failed to connect to some Lavalink nodes, skipped loading command ${command}...`);
const commandArray = command.split("/");
const commandName = commandArray[commandArray.length - 1].split(".")[0];
collections.paths.set(commandName, command);
collections.commands.set(commandName, props);
collections.info.set(commandName, {
category: commandArray[2],
description: props.description,
aliases: props.aliases,
params: props.params
params: props.arguments
});
if (props.aliases) {
for (const alias of props.aliases) {
collections.aliases.set(alias, command.split(".")[0]);
collections.aliases.set(alias, commandName);
collections.paths.set(alias, command);
}
}
return false;
@ -32,11 +39,12 @@ exports.unload = async (command) => {
cmd = collections.commands.get(collections.aliases.get(command));
}
if (!cmd) return `The command \`${command}\` doesn't seem to exist, nor is it an alias.`;
const mod = require.cache[require.resolve(`../commands/${command}`)];
delete require.cache[require.resolve(`../commands/${command}.js`)];
for (let i = 0; i < mod.parent.children.length; i++) {
if (mod.parent.children[i] === mod) {
mod.parent.children.splice(i, 1);
const path = collections.paths.get(command);
const mod = require.cache[require.resolve(`../${path}`)];
delete require.cache[require.resolve(`../${path}`)];
for (let i = 0; i < module.children.length; i++) {
if (module.children[i] === mod) {
module.children.splice(i, 1);
break;
}
}

View file

@ -1,10 +1,34 @@
const collections = require("./collections.js");
const logger = require("./logger.js");
const fs = require("fs");
module.exports = async (output) => {
const template = `# <img src="https://raw.githubusercontent.com/esmBot/esmBot/master/esmbot.png" width="64"> esmBot${process.env.NODE_ENV === "development" ? " Dev" : ""} Command List
${process.env.NODE_ENV === "development" ? "\n**You are currently using esmBot Dev! Things may change at any time without warning and there will be bugs. Many bugs. If you find one, [report it here](https://github.com/esmBot/esmBot/issues) or in the esmBot Support server.**\n" : ""}
const categoryTemplate = {
general: [],
tags: ["> **Every command in this category is a subcommand of the tag command.**\n"],
"image-editing": ["> **These commands support the PNG, JPEG, WEBP (static), and GIF (animated or static) formats.**\n"]
};
exports.categories = categoryTemplate;
exports.generateList = async () => {
this.categories = categoryTemplate;
for (const [command] of collections.commands) {
const category = collections.info.get(command).category;
const description = collections.info.get(command).description;
const params = collections.info.get(command).params;
if (category === "tags") {
const subCommands = [...Object.keys(description)];
for (const subCommand of subCommands) {
this.categories.tags.push(`**tags${subCommand !== "default" ? ` ${subCommand}` : ""}**${params[subCommand] ? ` ${params[subCommand]}` : ""} - ${description[subCommand]}`);
}
} else {
if (!this.categories[category]) this.categories[category] = [];
this.categories[category].push(`**${command}**${params ? ` ${params}` : ""} - ${description}`);
}
}
};
exports.createPage = async (output) => {
let template = `# <img src="https://raw.githubusercontent.com/esmBot/esmBot/master/esmbot.png" width="64"> esmBot${process.env.NODE_ENV === "development" ? " Dev" : ""} Command List
This page was last generated on \`${new Date().toString()}\`.
\`[]\` means an argument is required, \`{}\` means an argument is optional.
@ -13,47 +37,33 @@ Default prefix is \`&\`.
**Want to help support esmBot's development? Consider donating on Patreon!** https://patreon.com/TheEssem
> Tip: You can get more info about a command by using \`help [command]\`.
## Table of Contents
+ [**General**](#💻-general)
+ [**Tags**](#🏷-tags)
+ [**Fun**](#👌-fun)
+ [**Image Editing**](#🖼-image-editing)
+ [**Soundboard**](#🔊-soundboard)
+ [**Music**](#🎤-music)
> Tip: You can get more info about a command by using \`help [command]\` in the bot itself.
`;
const commands = collections.commands;
const categories = {
general: ["## 💻 General"],
tags: ["## 🏷️ Tags"],
fun: ["## 👌 Fun"],
images: ["## 🖼️ Image Editing", "> These commands support the PNG, JPEG, WEBP, and GIF formats.\n"],
soundboard: ["## 🔊 Soundboard"],
music: ["## 🎤 Music"]
};
for (const [command] of commands) {
const category = collections.info.get(command).category;
const description = collections.info.get(command).description;
const params = collections.info.get(command).params;
if (category === 1) {
categories.general.push(`+ **${command}**${params ? ` ${params}` : ""} - ${description}`);
} else if (category === 3) {
const subCommands = [...Object.keys(description)];
for (const subCommand of subCommands) {
categories.tags.push(`+ **tags${subCommand !== "default" ? ` ${subCommand}` : ""}**${params[subCommand] ? ` ${params[subCommand]}` : ""} - ${description[subCommand]}`);
template += "\n## Table of Contents\n";
for (const category of Object.keys(this.categories)) {
const categoryStringArray = category.split("-");
for (const index of categoryStringArray.keys()) {
categoryStringArray[index] = categoryStringArray[index].charAt(0).toUpperCase() + categoryStringArray[index].slice(1);
}
template += `+ [**${categoryStringArray.join(" ")}**](#${category})\n`;
}
// hell
for (const category of Object.keys(this.categories)) {
const categoryStringArray = category.split("-");
for (const index of categoryStringArray.keys()) {
categoryStringArray[index] = categoryStringArray[index].charAt(0).toUpperCase() + categoryStringArray[index].slice(1);
}
template += `\n## ${categoryStringArray.join(" ")}\n`;
for (const command of this.categories[category]) {
if (command.startsWith(">")) {
template += `${command}\n`;
} else {
template += `+ ${command}\n`;
}
} else if (category === 4) {
categories.fun.push(`+ **${command}**${params ? ` ${params}` : ""} - ${description}`);
} else if (category === 5) {
categories.images.push(`+ **${command}**${params ? ` ${params}` : ""} - ${description}`);
} else if (category === 6) {
categories.soundboard.push(`+ **${command}**${params ? ` ${params}` : ""} - ${description}`);
} else if (category === 7) {
categories.music.push(`+ **${command}**${params ? ` ${params}` : ""} - ${description}`);
}
}
fs.writeFile(output, `${template}\n${categories.general.join("\n")}\n\n${categories.tags.join("\n")}\n\n${categories.fun.join("\n")}\n\n${categories.images.join("\n")}\n\n${categories.soundboard.join("\n")}\n\n${categories.music.join("\n")}`, () => {
logger.log("The help docs have been generated.");
});
await fs.promises.writeFile(output, template);
};

View file

@ -1,9 +1,8 @@
const magick = require("../build/Release/image.node");
const { promisify } = require("util");
const { isMainThread, parentPort, workerData } = require("worker_threads");
exports.run = async object => {
return new Promise(async (resolve, reject) => {
exports.run = object => {
return new Promise((resolve, reject) => {
// If the image has a path, it must also have a type
if (object.path) {
if (object.type !== "image/gif" && object.onlyGIF) resolve({
@ -16,25 +15,23 @@ exports.run = async object => {
// If no image type is given (say, the command generates its own image), make it a PNG.
const fileExtension = object.type ? object.type.split("/")[1] : "png";
const objectWithFixedType = Object.assign({}, object, {type: fileExtension});
try {
const data = await promisify(magick[object.cmd])(objectWithFixedType);
magick[object.cmd](objectWithFixedType, (error, data, type) => {
if (error) reject(error);
const returnObject = {
buffer: data,
fileExtension
fileExtension: type
};
resolve(returnObject);
} catch (e) {
reject(e);
}
});
});
};
if (!isMainThread) {
this.run(workerData)
// eslint-disable-next-line promise/always-return
.then(returnObject => {
parentPort.postMessage(returnObject);
process.exit();
return;
})
.catch(err => {
// turn promise rejection into normal error

View file

@ -1,10 +1,9 @@
const magick = require("../build/Release/image.node");
const { Worker } = require("worker_threads");
const fetch = require("node-fetch");
const AbortController = require("abort-controller");
const fs = require("fs");
const net = require("net");
const fileType = require("file-type");
exports.servers = require("../servers.json").image;
const path = require("path");
const { EventEmitter } = require("events");
const logger = require("./logger.js");
@ -13,10 +12,12 @@ const formats = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const jobs = {};
const connections = [];
exports.connections = [];
const statuses = {};
exports.servers = JSON.parse(fs.readFileSync("./servers.json", { encoding: "utf8" })).image;
const chooseServer = async (ideal) => {
if (ideal.length === 0) throw "No available servers";
const sorted = ideal.sort((a, b) => {
@ -25,12 +26,40 @@ const chooseServer = async (ideal) => {
return sorted[0];
};
exports.repopulate = async () => {
const data = await fs.promises.readFile("./servers.json", { encoding: "utf8" });
this.servers = JSON.parse(data).image;
return;
};
exports.getStatus = () => {
return new Promise((resolve, reject) => {
let serversLeft = this.connections.length;
const statuses = [];
const timeout = setTimeout(() => {
resolve(statuses);
}, 5000);
for (const connection of this.connections) {
if (!connection.remoteAddress) continue;
fetch(`http://${connection.remoteAddress}:8081/running`).then(statusRequest => statusRequest.json()).then((status) => {
serversLeft--;
statuses.push(status);
if (!serversLeft) {
clearTimeout(timeout);
resolve(statuses);
}
return;
}).catch(e => reject(e));
}
});
};
exports.connect = (server) => {
return new Promise((resolve, reject) => {
const connection = net.createConnection(8080, server);
const timeout = setTimeout(() => {
const connectionIndex = connections.indexOf(connection);
if (connectionIndex < 0) delete connections[connectionIndex];
const connectionIndex = this.connections.indexOf(connection);
if (connectionIndex < 0) delete this.connections[connectionIndex];
reject(`Failed to connect to ${server}`);
}, 5000);
connection.once("connect", () => {
@ -66,24 +95,35 @@ exports.connect = (server) => {
connection.on("error", (e) => {
console.error(e);
});
connections.push(connection);
this.connections.push(connection);
resolve();
});
};
exports.disconnect = async () => {
for (const connection of this.connections) {
connection.destroy();
}
for (const uuid of Object.keys(jobs)) {
jobs[uuid].emit("error", new Error("Job ended prematurely (not really an error; just run your image job again)"));
}
this.connections = [];
return;
};
const getIdeal = () => {
return new Promise((resolve, reject) => {
let serversLeft = connections.length;
let serversLeft = this.connections.length;
const idealServers = [];
const timeout = setTimeout(async () => {
try {
const server = await chooseServer(idealServers);
resolve(connections.find(val => val.remoteAddress === server.addr));
resolve(this.connections.find(val => val.remoteAddress === server.addr));
} catch (e) {
reject(e);
}
}, 5000);
for (const connection of connections) {
for (const connection of this.connections) {
if (!connection.remoteAddress) continue;
fetch(`http://${connection.remoteAddress}:8081/status`).then(statusRequest => statusRequest.text()).then(async (status) => {
serversLeft--;
@ -94,7 +134,7 @@ const getIdeal = () => {
if (!serversLeft) {
clearTimeout(timeout);
const server = await chooseServer(idealServers);
resolve(connections.find(val => val.remoteAddress === server.addr));
resolve(this.connections.find(val => val.remoteAddress === server.addr));
}
return;
}).catch(e => reject(e));
@ -150,7 +190,7 @@ exports.getType = async (image) => {
return undefined;
}
let type;
const controller = new AbortController();
const controller = new AbortController(); // eslint-disable-line no-undef
const timeout = setTimeout(() => {
controller.abort();
}, 25000);
@ -183,13 +223,15 @@ exports.run = object => {
const num = Math.floor(Math.random() * 100000).toString().slice(0, 5);
const timeout = setTimeout(() => {
if (jobs[num]) delete jobs[num];
reject("Request timed out");
reject("the image request timed out after 25 seconds. Try uploading your image elsewhere.");
}, 25000);
start(object, num).catch(err => { // incredibly hacky code incoming
clearTimeout(timeout);
if (err instanceof Error) return reject(err);
return err;
}).then((data) => {
clearTimeout(timeout);
if (!data.event) reject("Not connected to image server");
data.event.once("image", (image, type) => {
delete jobs[data.uuid];
const payload = {

View file

@ -1,4 +1,3 @@
const client = require("./client.js");
const fetch = require("node-fetch");
const url = require("url");
const { getType } = require("./image.js");
@ -93,7 +92,7 @@ const checkImages = async (message) => {
};
// this checks for the latest message containing an image and returns the url of the image
module.exports = async (cmdMessage) => {
module.exports = async (client, cmdMessage) => {
// we start by checking the current message for images
const result = await checkImages(cmdMessage);
if (result !== false) return result;

View file

@ -1,15 +1,4 @@
const moment = require("moment");
const winston = require("winston");
const logger = winston.createLogger({
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: "logs/error.log", level: "error" }),
new winston.transports.File({ filename: "logs/main.log" }),
],
format: winston.format.printf(log => `[${moment().format("YYYY-MM-DD HH:mm:ss")}]: [${log.level.toUpperCase()}] - ${log.message}`)
});
exports.log = (type, content) => content ? logger.log(type, content) : logger.log("info", type);
exports.log = (type, content) => content ? process.send({ name: type, msg: content }) : process.send({ name: "info", msg: type });
exports.error = (...args) => this.log("error", ...args);

View file

@ -1,5 +1,4 @@
const util = require("util");
const client = require("./client.js");
// random(array) to select a random entry in array
exports.random = (array) => {
@ -34,14 +33,6 @@ exports.clean = async (text) => {
return text;
};
exports.getRandomMessage = async () => {
const messages = await client.guilds.get("631290275456745502").channels.get("631290275888627713").getMessages(50);
const randomMessage = this.random(messages);
if (randomMessage.content.length > 144) return await this.getRandomMessage();
if (randomMessage.content.match(/<@!?\d+>/g)) return await this.getRandomMessage();
return randomMessage.content;
};
// regexEscape(string) to escape characters in a string for use in a regex
exports.regexEscape = (string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string

View file

@ -1,9 +1,8 @@
// eris doesn't come with an awaitMessages method by default, so we make our own
const EventEmitter = require("events").EventEmitter;
const client = require("../client.js");
class MessageCollector extends EventEmitter {
constructor(channel, filter, options = {}) {
constructor(client, channel, filter, options = {}) {
super();
this.filter = filter;
this.channel = channel;

View file

@ -1,9 +1,8 @@
// eris doesn't come with an awaitReactions method by default, so we make our own
const EventEmitter = require("events").EventEmitter;
const client = require("../client.js");
class ReactionCollector extends EventEmitter {
constructor(message, filter, options = {}) {
constructor(client, message, filter, options = {}) {
super();
this.filter = filter;
this.message = message;
@ -20,7 +19,7 @@ class ReactionCollector extends EventEmitter {
if (this.message.id !== message.id) return false;
if (this.filter(message, emoji, member)) {
this.collected.push({ message: message, emoji: emoji, member: member });
this.emit("reaction", await client.getMessage(message.channel.id, message.id), emoji, member);
this.emit("reaction", await this.bot.getMessage(message.channel.id, message.id), emoji, member);
if (this.collected.length >= this.options.maxMatches) this.stop("maxMatches");
return true;
}

View file

@ -1,8 +1,7 @@
const ReactionCollector = require("./awaitreactions.js");
const MessageCollector = require("./awaitmessages.js");
const client = require("../client.js");
module.exports = async (message, pages, timeout = 120000) => {
module.exports = async (client, message, pages, timeout = 120000) => {
const manageMessages = message.channel.guild && message.channel.permissionsOf(client.user.id).has("manageMessages") ? true : false;
let page = 0;
let currentPage = await message.channel.createMessage(pages[page]);
@ -10,18 +9,18 @@ module.exports = async (message, pages, timeout = 120000) => {
for (const emoji of emojiList) {
await currentPage.addReaction(emoji);
}
const reactionCollector = new ReactionCollector(currentPage, (message, reaction, member) => emojiList.includes(reaction.name) && !member.bot, { time: timeout });
const reactionCollector = new ReactionCollector(client, currentPage, (message, reaction, member) => emojiList.includes(reaction.name) && !member.bot, { time: timeout });
reactionCollector.on("reaction", async (msg, reaction, member) => {
if (member.id === message.author.id) {
if (member === message.author.id) {
switch (reaction.name) {
case "◀":
page = page > 0 ? --page : pages.length - 1;
currentPage = await currentPage.edit(pages[page]);
if (manageMessages) msg.removeReaction("◀", member.id);
if (manageMessages) msg.removeReaction("◀", member);
break;
case "🔢":
message.channel.createMessage(`${message.author.mention}, what page do you want to jump to?`).then(askMessage => {
const messageCollector = new MessageCollector(askMessage.channel, (response) => response.author.id === message.author.id && !isNaN(response.content) && Number(response.content) <= pages.length && Number(response.content) > 0, {
const messageCollector = new MessageCollector(client, askMessage.channel, (response) => response.author.id === message.author.id && !isNaN(response.content) && Number(response.content) <= pages.length && Number(response.content) > 0, {
time: timeout,
maxMatches: 1
});
@ -30,7 +29,7 @@ module.exports = async (message, pages, timeout = 120000) => {
if (manageMessages) await response.delete();
page = Number(response.content) - 1;
currentPage = await currentPage.edit(pages[page]);
if (manageMessages) msg.removeReaction("🔢", member.id);
if (manageMessages) msg.removeReaction("🔢", member);
});
}).catch(error => {
throw error;
@ -39,7 +38,7 @@ module.exports = async (message, pages, timeout = 120000) => {
case "▶":
page = page + 1 < pages.length ? ++page : 0;
currentPage = await currentPage.edit(pages[page]);
if (manageMessages) msg.removeReaction("▶", member.id);
if (manageMessages) msg.removeReaction("▶", member);
break;
case "🗑":
reactionCollector.emit("end");

View file

@ -1,23 +0,0 @@
# if your install supports docker and you don't want to run chromium directly then you can use this dockerfile instead
# this also comes with a squid proxy to automatically handle errors and some privacy stuff
# adapted from https://github.com/westy92/headless-chrome-alpine/blob/master/Dockerfile
FROM alpine:edge
RUN apk --no-cache upgrade && apk add --no-cache chromium squid sudo libstdc++ harfbuzz nss freetype ttf-freefont zlib-dev wait4ports dbus chromium-chromedriver grep
ENV ALL_PROXY "http://localhost:3128"
RUN echo -e "\nvisible_hostname esmBot\nforwarded_for delete\nvia off\nfollow_x_forwarded_for deny all \
\nrequest_header_access X-Forwarded-For deny all\nerror_default_language en\n" >> /etc/squid/squid.conf
COPY ./ERR_DNS_FAIL /usr/share/squid/errors/en/
RUN adduser esmBot -s /bin/sh -D
WORKDIR /home/esmBot/.internal
COPY ./start.sh .
RUN chmod +x start.sh
RUN echo -e "\nesmBot ALL=(ALL) NOPASSWD:ALL\n" >> /etc/sudoers
USER esmBot
EXPOSE 9222
ENTRYPOINT ["sh", "./start.sh"]

View file

@ -1,63 +0,0 @@
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="utf8">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji;
}
.page {
height: 100%;
width: 100%;
align-items: center;
justify-content: center;
flex-flow: row wrap;
align-content: center;
display: flex;
}
.text {
text-align: center;
font-weight: 400;
width: 100%;
margin: 5px;
}
.img {
text-align: center;
margin: 10px auto 20px;
display: block;
width: 256px;
}
.container {
padding: 2em;
}
* {
box-sizing: border-box;
}
.large {
font-size: 5em;
}
.med {
font-size: 3em;
}
</style>
</head>
<body>
<div class="page">
<div class="container">
<img src="https://projectlounge.pw/pictures/esmbot.png" width="256" class="text img"></img>
<p class="text large" id="page">An error was encountered while trying to load %H:</p>
<p class="text med" id="error">%z</p>
</div>
</div>
</body>
</html>

View file

@ -1,16 +0,0 @@
sudo squid -N & chromium-browser \
--disable-background-networking \
--disable-default-apps \
--disable-extensions \
--disable-gpu \
--disable-sync \
--disable-translate \
--headless \
--hide-scrollbars \
--metrics-recording-only \
--mute-audio \
--no-first-run \
--no-sandbox \
--remote-debugging-address=0.0.0.0 \
--remote-debugging-port=9222 \
--safebrowsing-disable-auto-update

View file

@ -1,23 +1,19 @@
const client = require("./client.js");
const logger = require("./logger.js");
const paginator = require("./pagination/pagination.js");
const fetch = require("node-fetch");
const fs = require("fs");
const moment = require("moment");
require("moment-duration-format");
const day = require("dayjs");
const duration = require("dayjs/plugin/duration");
day.extend(duration);
const { Manager } = require("lavacord");
let nodes;
exports.players = new Map();
exports.queues = new Map();
const skipVotes = new Map();
exports.skipVotes = new Map();
exports.manager;
exports.status = false;
exports.connected = false;
exports.checkStatus = async () => {
@ -29,7 +25,7 @@ exports.checkStatus = async () => {
const response = await fetch(`http://${node.host}:${node.port}/version`, { headers: { Authorization: node.password } }).then(res => res.text());
if (response) newNodes.push(node);
} catch {
logger.log(`Failed to get status of Lavalink node ${node.host}.`);
logger.error(`Failed to get status of Lavalink node ${node.host}.`);
}
}
nodes = newNodes;
@ -37,7 +33,7 @@ exports.checkStatus = async () => {
return this.status;
};
exports.connect = async () => {
exports.connect = async (client) => {
this.manager = new Manager(nodes, {
user: client.user.id,
shards: client.shards.size || 1,
@ -56,11 +52,11 @@ exports.connect = async () => {
return length;
};
exports.play = async (sound, message, music = false) => {
exports.play = async (client, sound, message, music = false) => {
if (!this.manager) return `${message.author.mention}, the sound commands are still starting up!`;
if (!message.channel.guild) return `${message.author.mention}, this command only works in servers!`;
if (!message.member.voiceState.channelID) return `${message.author.mention}, you need to be in a voice channel first!`;
if (!message.channel.guild.members.get(client.user.id).permissions.has("voiceConnect") || !message.channel.permissionsOf(client.user.id).has("voiceConnect")) return `${message.author.mention}, I can't join this voice channel!`;
if (!message.channel.permissionsOf(client.user.id).has("voiceConnect")) return `${message.author.mention}, I can't join this voice channel!`;
const voiceChannel = message.channel.guild.channels.get(message.member.voiceState.channelID);
if (!voiceChannel.permissionsOf(client.user.id).has("voiceConnect")) return `${message.author.mention}, I don't have permission to join this voice channel!`;
const player = this.players.get(message.channel.guild.id);
@ -87,40 +83,45 @@ exports.play = async (sound, message, music = false) => {
}
if (oldQueue && music) {
return `${message.author.mention}, your tune has been added to the queue!`;
return `${message.author.mention}, your tune \`${tracks[0].info.title}\` has been added to the queue!`;
} else {
this.nextSong(message, connection, tracks[0].track, tracks[0].info, music, voiceChannel, player ? player.loop : false);
this.nextSong(client, message, connection, tracks[0].track, tracks[0].info, music, voiceChannel, player ? player.loop : false);
return;
}
};
exports.nextSong = async (message, connection, track, info, music, voiceChannel, loop = false, inQueue = false) => {
exports.nextSong = async (client, message, connection, track, info, music, voiceChannel, loop = false, inQueue = false, lastTrack = null, oldPlaying = null) => {
const parts = Math.floor((0 / info.length) * 10);
const playingMessage = await client.createMessage(message.channel.id, !music ? "🔊 Playing sound..." : {
"embed": {
"color": 16711680,
"author": {
"name": "Now Playing",
"icon_url": client.user.avatarURL
},
"fields": [{
"name": " Title:",
"value": info.title
},
{
"name": "🎤 Artist:",
"value": info.author
},
{
"name": "💬 Channel:",
"value": voiceChannel.name
},
{
"name": `${"▬".repeat(parts)}🔘${"▬".repeat(10 - parts)}`,
"value": `${moment.duration(0).format("m:ss", { trim: false })}/${info.isStream ? "∞" : moment.duration(info.length).format("m:ss", { trim: false })}`
}]
}
});
let playingMessage;
if (lastTrack === track) {
playingMessage = oldPlaying;
} else {
playingMessage = await client.createMessage(message.channel.id, !music ? "🔊 Playing sound..." : {
"embed": {
"color": 16711680,
"author": {
"name": "Now Playing",
"icon_url": client.user.avatarURL
},
"fields": [{
"name": " Title:",
"value": info.title
},
{
"name": "🎤 Artist:",
"value": info.author
},
{
"name": "💬 Channel:",
"value": voiceChannel.name
},
{
"name": `${"▬".repeat(parts)}🔘${"▬".repeat(10 - parts)}`,
"value": `${day.duration(0).format("m:ss", { trim: false })}/${info.isStream ? "∞" : day.duration(info.length).format("m:ss", { trim: false })}`
}]
}
});
}
await connection.play(track);
this.players.set(voiceChannel.guild.id, { player: connection, type: music ? "music" : "sound", host: message.author.id, voiceChannel: voiceChannel, originalChannel: message.channel, loop: loop });
if (inQueue && connection.listeners("error").length === 0) {
@ -154,152 +155,10 @@ exports.nextSong = async (message, connection, track, info, music, voiceChannel,
if (music) await client.createMessage(message.channel.id, "🔊 The current voice channel session has ended.");
if (playingMessage.channel.messages.get(playingMessage.id)) await playingMessage.delete();
} else {
const track = await fetch(`http://${connection.node.host}:${connection.node.port}/decodetrack?track=${encodeURIComponent(newQueue[0])}`, { headers: { Authorization: connection.node.password } }).then(res => res.json());
this.nextSong(message, connection, newQueue[0], track, music, voiceChannel, isLooping, true);
if (playingMessage.channel.messages.get(playingMessage.id)) await playingMessage.delete();
const newTrack = await fetch(`http://${connection.node.host}:${connection.node.port}/decodetrack?track=${encodeURIComponent(newQueue[0])}`, { headers: { Authorization: connection.node.password } }).then(res => res.json());
this.nextSong(client, message, connection, newQueue[0], newTrack, music, voiceChannel, isLooping, true, track, playingMessage);
if (newQueue[0] !== track && playingMessage.channel.messages.get(playingMessage.id)) await playingMessage.delete();
}
});
}
};
exports.stop = async (message) => {
if (!message.channel.guild) return `${message.author.mention}, this command only works in servers!`;
if (!message.member.voiceState.channelID) return `${message.author.mention}, you need to be in a voice channel first!`;
if (!message.channel.guild.members.get(client.user.id).voiceState.channelID) return `${message.author.mention}, I'm not in a voice channel!`;
if (this.players.get(message.channel.guild.id).host !== message.author.id) return `${message.author.mention}, only the current voice session host can stop the music!`;
this.manager.leave(message.channel.guild.id);
const connection = this.players.get(message.channel.guild.id).player;
connection.destroy();
this.players.delete(message.channel.guild.id);
this.queues.delete(message.channel.guild.id);
return "🔊 The current voice channel session has ended.";
};
exports.skip = async (message) => {
if (!message.channel.guild) return `${message.author.mention}, this command only works in servers!`;
if (!message.member.voiceState.channelID) return `${message.author.mention}, you need to be in a voice channel first!`;
if (!message.channel.guild.members.get(client.user.id).voiceState.channelID) return `${message.author.mention}, I'm not in a voice channel!`;
const player = this.players.get(message.channel.guild.id);
if (player.host !== message.author.id) {
const votes = skipVotes.has(message.channel.guild.id) ? skipVotes.get(message.channel.guild.id) : { count: 0, ids: [] };
if (votes.ids.includes(message.author.id)) return `${message.author.mention}, you've already voted to skip!`;
const newObject = {
count: votes.count + 1,
ids: [...votes.ids, message.author.id].filter(item => !!item)
};
if (votes.count + 1 === 3) {
player.player.stop(message.channel.guild.id);
skipVotes.set(message.channel.guild.id, { count: 0, ids: [] });
} else {
skipVotes.set(message.channel.guild.id, newObject);
return `🔊 Voted to skip song (${votes.count + 1}/3 people have voted).`;
}
} else {
player.player.stop(message.channel.guild.id);
return;
}
};
exports.pause = async (message) => {
if (!message.channel.guild) return `${message.author.mention}, this command only works in servers!`;
if (!message.member.voiceState.channelID) return `${message.author.mention}, you need to be in a voice channel first!`;
if (!message.channel.guild.members.get(client.user.id).voiceState.channelID) return `${message.author.mention}, I'm not in a voice channel!`;
if (this.players.get(message.channel.guild.id).host !== message.author.id) return `${message.author.mention}, only the current voice session host can pause/resume the music!`;
const player = this.players.get(message.channel.guild.id).player;
player.pause(!player.paused ? true : false);
return `🔊 The player has been ${!player.paused ? "paused" : "resumed"}.`;
};
exports.playing = async (message) => {
if (!message.channel.guild) return `${message.author.mention}, this command only works in servers!`;
if (!message.member.voiceState.channelID) return `${message.author.mention}, you need to be in a voice channel first!`;
if (!message.channel.guild.members.get(client.user.id).voiceState.channelID) return `${message.author.mention}, I'm not in a voice channel!`;
const player = this.players.get(message.channel.guild.id).player;
if (!player) return `${message.author.mention}, I'm not playing anything!`;
const track = await fetch(`http://${player.node.host}:${player.node.port}/decodetrack?track=${encodeURIComponent(player.track)}`, { headers: { Authorization: player.node.password } }).then(res => res.json());
const parts = Math.floor((player.state.position / track.length) * 10);
return {
"embed": {
"color": 16711680,
"author": {
"name": "Now Playing",
"icon_url": client.user.avatarURL
},
"fields": [{
"name": " Title:",
"value": track.title ? track.title : "Unknown"
},
{
"name": "🎤 Artist:",
"value": track.author ? track.author : "Unknown"
},
{
"name": "💬 Channel:",
"value": message.channel.guild.channels.get(message.member.voiceState.channelID).name
},
{
"name": `${"▬".repeat(parts)}🔘${"▬".repeat(10 - parts)}`,
"value": `${moment.duration(player.state.position).format("m:ss", { trim: false })}/${track.isStream ? "∞" : moment.duration(track.length).format("m:ss", { trim: false })}`
}]
}
};
};
exports.queue = async (message) => {
if (!message.channel.guild) return `${message.author.mention}, this command only works in servers!`;
if (!message.member.voiceState.channelID) return `${message.author.mention}, you need to be in a voice channel first!`;
if (!message.channel.guild.members.get(client.user.id).voiceState.channelID) return `${message.author.mention}, I'm not in a voice channel!`;
if (!message.channel.guild.members.get(client.user.id).permissions.has("addReactions") && !message.channel.permissionsOf(client.user.id).has("addReactions")) return `${message.author.mention}, I don't have the \`Add Reactions\` permission!`;
if (!message.channel.guild.members.get(client.user.id).permissions.has("embedLinks") && !message.channel.permissionsOf(client.user.id).has("embedLinks")) return `${message.author.mention}, I don't have the \`Embed Links\` permission!`;
const queue = this.queues.get(message.channel.guild.id);
const player = this.players.get(message.channel.guild.id);
const tracks = await fetch(`http://${player.player.node.host}:${player.player.node.port}/decodetracks`, { method: "POST", body: JSON.stringify(queue), headers: { Authorization: player.player.node.password, "Content-Type": "application/json" } }).then(res => res.json());
const trackList = [];
const firstTrack = tracks.shift();
for (const [i, track] of tracks.entries()) {
trackList.push(`${i + 1}. ${track.info.author} - **${track.info.title}** (${track.info.isStream ? "∞" : moment.duration(track.info.length).format("m:ss", { trim: false })})`);
}
const pageSize = 5;
const embeds = [];
const groups = trackList.map((item, index) => {
return index % pageSize === 0 ? trackList.slice(index, index + pageSize) : null;
}).filter(Boolean);
if (groups.length === 0) groups.push("del");
for (const [i, value] of groups.entries()) {
embeds.push({
"embed": {
"author": {
"name": "Queue",
"icon_url": client.user.avatarURL
},
"color": 16711680,
"footer": {
"text": `Page ${i + 1} of ${groups.length}`
},
"fields": [{
"name": "🎶 Now Playing",
"value": `${firstTrack.info.author} - **${firstTrack.info.title}** (${firstTrack.info.isStream ? "∞" : moment.duration(firstTrack.info.length).format("m:ss", { trim: false })})`
}, {
"name": "🔁 Looping?",
"value": player.loop ? "Yes" : "No"
}, {
"name": "🗒️ Queue",
"value": value !== "del" ? value.join("\n") : "There's nothing in the queue!"
}]
}
});
}
if (embeds.length === 0) return `${message.author.mention}, there's nothing in the queue!`;
return paginator(message, embeds);
};
exports.loop = async (message) => {
if (!message.channel.guild) return `${message.author.mention}, this command only works in servers!`;
if (!message.member.voiceState.channelID) return `${message.author.mention}, you need to be in a voice channel first!`;
if (!message.channel.guild.members.get(client.user.id).voiceState.channelID) return `${message.author.mention}, I'm not in a voice channel!`;
if (this.players.get(message.channel.guild.id).host !== message.author.id) return `${message.author.mention}, only the current voice session host can loop the music!`;
const object = this.players.get(message.channel.guild.id);
object.loop = !object.loop;
this.players.set(message.channel.guild.id, object);
return object.loop ? "🔊 The player is now looping." : "🔊 The player is no longer looping.";
};