Replace eris-fleet with a pm2-based cluster system, overhaul image handling, removed azure image api

This commit is contained in:
Essem 2022-09-21 00:05:03 -05:00
parent 5a3364736d
commit db0decf71a
No known key found for this signature in database
GPG key ID: 7D497397CC3A2A8C
45 changed files with 1777 additions and 857 deletions

View file

@ -48,12 +48,7 @@ METRICS=
# The image API type to be used # The image API type to be used
# Set this to `none` to process all images locally # Set this to `none` to process all images locally
# Set this to `ws` if you want to use the external image API script, located in api/index.js # Set this to `ws` if you want to use the external image API script, located in api/index.js
# Set this to `azure` to use the Azure Functions API
API_TYPE=none API_TYPE=none
# If API_TYPE is `azure`, set this to your Azure webhook URL
AZURE_URL=
# If API_TYPE is `azure`, set an optional password for webhook responses
AZURE_PASS=
# Put ID of server to limit owner-only commands to # Put ID of server to limit owner-only commands to
ADMIN_SERVER= ADMIN_SERVER=

13
.gitignore vendored
View file

@ -117,15 +117,4 @@ libvips/
# Databases # Databases
data/ data/
*.sqlite *.sqlite
# Azure Functions artifacts
bin
obj
appsettings.json
local.settings.json
# Azurite artifacts
__blobstorage__
__queuestorage__
__azurite_db*__.json

View file

@ -6,6 +6,9 @@ The esmBot image API is a combined HTTP and WebSocket API. The default port to a
### GET `/image/?id=<job id>` ### GET `/image/?id=<job id>`
Get image data after job is finished running. The Content-Type header is properly set. Get image data after job is finished running. The Content-Type header is properly set.
### GET `/count`
Get the current amount of running jobs. Response is a plaintext number value.
## WebSockets ## WebSockets
A client sends *requests* (T-messages) to a server, which subsequently *replies* (R-messages) to the client. A client sends *requests* (T-messages) to a server, which subsequently *replies* (R-messages) to the client.
### Message IDs ### Message IDs
@ -24,11 +27,11 @@ A client sends *requests* (T-messages) to a server, which subsequently *replies*
[j] means JSON data that goes until the end of the message. [j] means JSON data that goes until the end of the message.
`tag` is used to identify a request/response pair, like `lock` in the original API. `jid` is used to identify a job. `job` is a job object. `tag` is used to identify a request/response pair, like `lock` in the original API. `jid` is used to identify a job. `job` is a job object.
- Rerror tag[2] error[s] - Rerror tag[2] error[s]
- Tqueue tag[2] jid[4] job[j] - Tqueue tag[2] jid[8] job[j]
- Rqueue tag[2] - Rqueue tag[2]
- Tcancel tag[2] jid[4] - Tcancel tag[2] jid[8]
- Rcancel tag[2] - Rcancel tag[2]
- Twait tag[2] jid[4] - Twait tag[2] jid[8]
- Rwait tag[2] - Rwait tag[2]
- Rinit tag[2] max_jobs[2] running_jobs[2] formats[j] - Rinit tag[2] max_jobs[2] running_jobs[2] formats[j]
@ -42,6 +45,7 @@ The job object is formatted like this:
"params": { // content varies depending on the command, some common parameters are listed here "params": { // content varies depending on the command, some common parameters are listed here
"type": string, // mime type of output, should usually be the same as input "type": string, // mime type of output, should usually be the same as input
... ...
} },
"name": string // filename of the image, without extension
} }
``` ```

View file

@ -107,8 +107,8 @@ wss.on("connection", (ws, request) => {
const tag = msg.slice(1, 3); const tag = msg.slice(1, 3);
const req = msg.toString().slice(3); const req = msg.toString().slice(3);
if (opcode == Tqueue) { if (opcode == Tqueue) {
const id = msg.readUInt32LE(3); const id = msg.readBigInt64LE(3);
const obj = msg.slice(7); const obj = msg.slice(11);
const job = { msg: obj, num: jobAmount, verifyEvent: new EventEmitter() }; const job = { msg: obj, num: jobAmount, verifyEvent: new EventEmitter() };
jobs.set(id, job); jobs.set(id, job);
queue.push(id); queue.push(id);
@ -128,7 +128,7 @@ wss.on("connection", (ws, request) => {
const cancelResponse = Buffer.concat([Buffer.from([Rcancel]), tag]); const cancelResponse = Buffer.concat([Buffer.from([Rcancel]), tag]);
ws.send(cancelResponse); ws.send(cancelResponse);
} else if (opcode == Twait) { } else if (opcode == Twait) {
const id = msg.readUInt32LE(3); const id = msg.readBigUInt64LE(3);
const job = jobs.get(id); const job = jobs.get(id);
if (!job) { if (!job) {
const errorResponse = Buffer.concat([Buffer.from([Rerror]), tag, Buffer.from("Invalid job ID")]); const errorResponse = Buffer.concat([Buffer.from([Rerror]), tag, Buffer.from("Invalid job ID")]);
@ -178,7 +178,7 @@ httpServer.on("request", async (req, res) => {
res.statusCode = 400; res.statusCode = 400;
return res.end("400 Bad Request"); return res.end("400 Bad Request");
} }
const id = parseInt(reqUrl.searchParams.get("id")); const id = BigInt(reqUrl.searchParams.get("id"));
if (!jobs.has(id)) { if (!jobs.has(id)) {
res.statusCode = 410; res.statusCode = 410;
return res.end("410 Gone"); return res.end("410 Gone");
@ -208,6 +208,11 @@ httpServer.on("request", async (req, res) => {
return res.end(data, (err) => { return res.end(data, (err) => {
if (err) error(err); if (err) error(err);
}); });
} else if (reqUrl.pathname === "/count" && req.method === "GET") {
log(`Sending job count to ${req.socket.remoteAddress}:${req.socket.remotePort} via HTTP`);
return res.end(jobAmount.toString(), (err) => {
if (err) error(err);
});
} else { } else {
res.statusCode = 404; res.statusCode = 404;
return res.end("404 Not Found"); return res.end("404 Not Found");

329
app.js
View file

@ -1,12 +1,15 @@
if (process.platform === "win32") console.error("\x1b[1m\x1b[31m\x1b[40m" + `WIN32 IS NOT OFFICIALLY SUPPORTED! if (process.versions.node.split(".")[0] < 16) {
Although there's a (very) slim chance of it working, multiple aspects of the bot are built with UNIX-like systems in mind and could break on Win32-based systems. If you want to run the bot on Windows, using Windows Subsystem for Linux is highly recommended.
The bot will continue to run past this message, but keep in mind that it could break at any time. Continue running at your own risk; alternatively, stop the bot using Ctrl+C and install WSL.` + "\x1b[0m");
if (process.versions.node.split(".")[0] < 15) {
console.error(`You are currently running Node.js version ${process.version}. console.error(`You are currently running Node.js version ${process.version}.
esmBot requires Node.js version 15 or above. esmBot requires Node.js version 16 or above.
Please refer to step 3 of the setup guide.`); Please refer to step 3 of the setup guide.`);
process.exit(1); process.exit(1);
} }
if (process.platform === "win32") {
console.error("\x1b[1m\x1b[31m\x1b[40m" + `WINDOWS IS NOT OFFICIALLY SUPPORTED!
Although there's a (very) slim chance of it working, multiple aspects of the bot are built with UNIX-like systems in mind and could break on Win32-based systems. If you want to run the bot on Windows, using Windows Subsystem for Linux is highly recommended.
The bot will continue to run past this message in 5 seconds, but keep in mind that it could break at any time. Continue running at your own risk; alternatively, stop the bot using Ctrl+C and install WSL.` + "\x1b[0m");
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5000);
}
// load config from .env file // load config from .env file
import { resolve, dirname } from "path"; import { resolve, dirname } from "path";
@ -14,61 +17,58 @@ import { fileURLToPath } from "url";
import { config } from "dotenv"; import { config } from "dotenv";
config({ path: resolve(dirname(fileURLToPath(import.meta.url)), ".env") }); config({ path: resolve(dirname(fileURLToPath(import.meta.url)), ".env") });
// main sharding manager import { generateList, createPage } from "./utils/help.js";
import { Fleet } from "eris-fleet"; import { reloadImageConnections } from "./utils/image.js";
import { isMaster } from "cluster";
// main services // main services
import Shard from "./shard.js"; import Eris from "eris";
import ImageWorker from "./utils/services/image.js"; import pm2 from "pm2";
import PrometheusWorker from "./utils/services/prometheus.js";
// some utils // some utils
import { promises, readFileSync } from "fs"; import { promises, readFileSync } from "fs";
import winston from "winston"; import { logger } from "./utils/logger.js";
import "winston-daily-rotate-file";
import { exec as baseExec } from "child_process"; import { exec as baseExec } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
const exec = promisify(baseExec); const exec = promisify(baseExec);
// initialize command loader
import { load, send } from "./utils/handler.js";
// command collections
import { paths } from "./utils/collections.js";
// database stuff // database stuff
import database from "./utils/database.js"; import database from "./utils/database.js";
// dbl posting // lavalink stuff
import { Api } from "@top-gg/sdk"; import { checkStatus, connect, reload, status, connected } from "./utils/soundplayer.js";
const dbl = process.env.NODE_ENV === "production" && process.env.DBL ? new Api(process.env.DBL) : null; // events
import { endBroadcast, startBroadcast, activityChanger, checkBroadcast } from "./utils/misc.js";
import { parseThreshold } from "./utils/tempimages.js";
const { types } = JSON.parse(readFileSync(new URL("./config/commands.json", import.meta.url))); const { types } = JSON.parse(readFileSync(new URL("./config/commands.json", import.meta.url)));
if (isMaster) { const esmBotVersion = JSON.parse(readFileSync(new URL("./package.json", import.meta.url))).version;
const esmBotVersion = JSON.parse(readFileSync(new URL("./package.json", import.meta.url))).version; exec("git rev-parse HEAD").then(output => output.stdout.substring(0, 7), () => "unknown commit").then(o => process.env.GIT_REV = o).then(() => {
const erisFleetVersion = JSON.parse(readFileSync(new URL("./node_modules/eris-fleet/package.json", import.meta.url))).version; // a bit of a hacky way to get the eris-fleet version
console.log(` console.log(`
,*\`$ z\`"v ,*\`$ z\`"v
F zBw\`% A ,W "W F zBw\`% A ,W "W
,\` ,EBBBWp"%. ,-=~~==-,+* 4BBE T ,\` ,EBBBWp"%. ,-=~~==-,+* 4BBE T
M BBBBBBBB* ,w=####Wpw 4BBBBB# 1 M BBBBBBBB* ,w=####Wpw 4BBBBB# 1
F BBBBBBBMwBBBBBBBBBBBBB#wXBBBBBH E F BBBBBBBMwBBBBBBBBBBBBB#wXBBBBBH E
F BBBBBBkBBBBBBBBBBBBBBBBBBBBE4BL k F BBBBBBkBBBBBBBBBBBBBBBBBBBBE4BL k
# BFBBBBBBBBBBBBF" "RBBBW F # BFBBBBBBBBBBBBF" "RBBBW F
V ' 4BBBBBBBBBBM TBBL F V ' 4BBBBBBBBBBM TBBL F
F BBBBBBBBBBF JBB L F BBBBBBBBBBF JBB L
F FBBBBBBBEB BBL 4 F FBBBBBBBEB BBL 4
E [BB4BBBBEBL BBL 4 E [BB4BBBBEBL BBL 4
I #BBBBBBBEB 4BBH *w I #BBBBBBBEB 4BBH *w
A 4BBBBBBBBBEW, ,BBBB W [ A 4BBBBBBBBBEW, ,BBBB W [
.A ,k 4BBBBBBBBBBBEBW####BBBBBBM BF F .A ,k 4BBBBBBBBBBBEBW####BBBBBBM BF F
k <BBBw BBBBEBBBBBBBBBBBBBBBBBQ4BM # k <BBBw BBBBEBBBBBBBBBBBBBBBBBQ4BM #
5, REBBB4BBBBB#BBBBBBBBBBBBP5BFF ,F 5, REBBB4BBBBB#BBBBBBBBBBBBP5BFF ,F
*w \`*4BBW\`"FF#F##FFFF"\` , * +" *w \`*4BBW\`"FF#F##FFFF"\` , * +"
*+, " F'"'*^~~~^"^\` V+*^ *+, " F'"'*^~~~^"^\` V+*^
\`""" \`"""
esmBot ${esmBotVersion} (${(await exec("git rev-parse HEAD").then(output => output.stdout.substring(0, 7), () => "unknown commit"))}), powered by eris-fleet ${erisFleetVersion} esmBot ${esmBotVersion} (${process.env.GIT_REV})
`); `);
} });
const services = [
{ name: "image", ServiceWorker: ImageWorker }
];
if (process.env.METRICS && process.env.METRICS !== "") services.push({ name: "prometheus", ServiceWorker: PrometheusWorker });
const intents = [ const intents = [
"guildVoiceStates", "guildVoiceStates",
@ -80,115 +80,148 @@ if (types.classic) {
intents.push("messageContent"); intents.push("messageContent");
} }
const Admiral = new Fleet({ // PM2-specific handling
BotWorker: Shard, if (process.env.PM2_USAGE) {
token: `Bot ${process.env.TOKEN}`, pm2.launchBus((err, pm2Bus) => {
fetchTimeout: 900000, if (err) {
maxConcurrencyOverride: 1, logger.error(err);
startingStatus: { return;
status: "idle",
game: {
name: "Starting esmBot..."
} }
},
whatToLog: { pm2Bus.on("process:msg", async (packet) => {
blacklist: ["stats_update"] switch (packet.data?.type) {
}, case "reload":
clientOptions: { var path = paths.get(packet.data.message);
allowedMentions: { await load(bot, path, await checkStatus(), true);
everyone: false, break;
roles: false, case "soundreload":
users: true, var soundStatus = await checkStatus();
repliedUser: true if (!soundStatus) {
}, reload();
restMode: true, }
messageLimit: 50, break;
intents, case "imagereload":
stats: { await reloadImageConnections();
requestTimeout: 30000 break;
}, case "broadcastStart":
connectionTimeout: 30000 startBroadcast(bot, packet.data.message);
}, break;
useCentralRequestHandler: process.env.DEBUG_LOG ? false : true, // workaround for eris-fleet weirdness case "broadcastEnd":
services endBroadcast(bot);
break;
case "serverCounts":
pm2.sendDataToProcessId(0, {
id: 0,
type: "process:msg",
data: {
type: "serverCounts",
guilds: bot.guilds.size,
shards: bot.shards.size
},
topic: true
}, (err) => {
if (err) logger.error(err);
});
break;
}
});
});
}
database.upgrade(logger).then(result => {
if (result === 1) return process.exit(1);
}); });
if (isMaster) { // process the threshold into bytes early
const logger = winston.createLogger({ if (process.env.TEMPDIR && process.env.THRESHOLD) {
levels: { parseThreshold();
error: 0, }
warn: 1,
info: 2,
main: 3,
debug: 4
},
transports: [
new winston.transports.Console({ format: winston.format.colorize({ all: true }), stderrLevels: ["error", "warn"] }),
new winston.transports.DailyRotateFile({ filename: "logs/error-%DATE%.log", level: "error", zippedArchive: true, maxSize: 4194304, maxFiles: 8 }),
new winston.transports.DailyRotateFile({ filename: "logs/main-%DATE%.log", zippedArchive: true, maxSize: 4194304, maxFiles: 8 })
],
level: process.env.DEBUG_LOG ? "debug" : "main",
format: winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
winston.format.printf((info) => {
const {
timestamp, level, message, ...args
} = info;
return `[${timestamp}]: [${level.toUpperCase()}] - ${message} ${Object.keys(args).length ? JSON.stringify(args, null, 2) : ""}`; if (!types.classic && !types.application) {
}), logger.error("Both classic and application commands are disabled! Please enable at least one command type in config/commands.json.");
) process.exit(1);
}); }
winston.addColors({ const bot = new Eris(`Bot ${process.env.TOKEN}`, {
info: "green", allowedMentions: {
main: "gray", everyone: false,
debug: "magenta", roles: false,
warn: "yellow", users: true,
error: "red" repliedUser: true
}); },
restMode: true,
maxShards: "auto",
messageLimit: 50,
intents,
connectionTimeout: 30000
});
database.upgrade(logger).then(result => { bot.once("ready", async () => {
if (result === 1) return process.exit(1); // register commands and their info
}); const soundStatus = await checkStatus();
logger.log("info", "Attempting to load commands...");
Admiral.on("log", (m) => logger.main(m)); for await (const commandFile of getFiles(resolve(dirname(fileURLToPath(import.meta.url)), "./commands/"))) {
Admiral.on("info", (m) => logger.info(m)); logger.log("main", `Loading command from ${commandFile}...`);
Admiral.on("debug", (m) => logger.debug(m)); try {
Admiral.on("warn", (m) => logger.warn(m)); await load(bot, commandFile, soundStatus);
Admiral.on("error", (m) => logger.error(m)); } catch (e) {
logger.error(`Failed to register command from ${commandFile}: ${e}`);
if (dbl) { }
Admiral.on("stats", async (m) => { }
await dbl.postStats({ if (types.application) {
serverCount: m.guilds, try {
shardCount: m.shardCount await send(bot);
}); } catch (e) {
}); logger.log("error", e);
} logger.log("error", "Failed to send command data to Discord, slash/message commands may be unavailable.");
}
// process the threshold into bytes early }
if (process.env.TEMPDIR && process.env.THRESHOLD) { logger.log("info", "Finished loading commands.");
const matched = process.env.THRESHOLD.match(/(\d+)([KMGT])/);
const sizes = { if (process.env.API_TYPE === "ws") await reloadImageConnections();
K: 1024, await database.setup();
M: 1048576,
G: 1073741824, // register events
T: 1099511627776 logger.log("info", "Attempting to load events...");
}; for await (const file of getFiles(resolve(dirname(fileURLToPath(import.meta.url)), "./events/"))) {
if (matched && matched[1] && matched[2]) { logger.log("main", `Loading event from ${file}...`);
process.env.THRESHOLD = matched[1] * sizes[matched[2]]; const eventArray = file.split("/");
} else { const eventName = eventArray[eventArray.length - 1].split(".")[0];
logger.error("Invalid THRESHOLD config."); if (eventName === "interactionCreate" && !types.application) {
process.env.THRESHOLD = undefined; logger.log("warn", `Skipped loading event from ${file} because application commands are disabled`);
continue;
}
const { default: event } = await import(file);
bot.on(eventName, event.bind(null, bot));
}
logger.log("info", "Finished loading events.");
// generate docs
if (process.env.OUTPUT && process.env.OUTPUT !== "") {
generateList();
await createPage(process.env.OUTPUT);
logger.log("info", "The help docs have been generated.");
}
// connect to lavalink
if (!status && !connected) connect(bot);
checkBroadcast(bot);
activityChanger(bot);
logger.log("info", "Started esmBot.");
});
async function* getFiles(dir) {
const dirents = await promises.readdir(dir, { withFileTypes: true });
for (const dirent of dirents) {
const name = dir + (dir.charAt(dir.length - 1) !== "/" ? "/" : "") + dirent.name;
if (dirent.isDirectory()) {
yield* getFiles(name);
} else if (dirent.name.endsWith(".js")) {
yield name;
} }
const dirstat = (await promises.readdir(process.env.TEMPDIR)).map((file) => {
return promises.stat(`${process.env.TEMPDIR}/${file}`).then((stats) => stats.size);
});
const size = await Promise.all(dirstat);
const reduced = size.reduce((a, b) => {
return a + b;
}, 0);
Admiral.centralStore.set("dirSizeCache", reduced);
} }
} }
bot.connect();

View file

@ -1,10 +1,7 @@
class Command { class Command {
success = true; success = true;
constructor(client, cluster, worker, ipc, options) { constructor(client, options) {
this.client = client; this.client = client;
this.cluster = cluster;
this.worker = worker;
this.ipc = ipc;
this.origOptions = options; this.origOptions = options;
this.type = options.type; this.type = options.type;
this.args = options.args; this.args = options.args;
@ -50,7 +47,7 @@ class Command {
async acknowledge() { async acknowledge() {
if (this.type === "classic") { if (this.type === "classic") {
await this.client.sendChannelTyping(this.channel.id); await this.client.sendChannelTyping(this.channel.id);
} else { } else if (!this.interaction.acknowledged) {
await this.interaction.acknowledge(); await this.interaction.acknowledge();
} }
} }

View file

@ -1,5 +1,6 @@
import Command from "./command.js"; import Command from "./command.js";
import imageDetect from "../utils/imagedetect.js"; import imageDetect from "../utils/imagedetect.js";
import { runImageJob } from "../utils/image.js";
import { runningCommands } from "../utils/collections.js"; import { runningCommands } from "../utils/collections.js";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
const { emotes } = JSON.parse(readFileSync(new URL("../config/messages.json", import.meta.url))); const { emotes } = JSON.parse(readFileSync(new URL("../config/messages.json", import.meta.url)));
@ -22,7 +23,7 @@ class ImageCommand extends Command {
// before awaiting the command result, add this command to the set of running commands // before awaiting the command result, add this command to the set of running commands
runningCommands.set(this.author.id, timestamp); runningCommands.set(this.author.id, timestamp);
const magickParams = { const imageParams = {
cmd: this.constructor.command, cmd: this.constructor.command,
params: {} params: {}
}; };
@ -36,7 +37,7 @@ class ImageCommand extends Command {
if (selection) selectedImages.delete(this.author.id); if (selection) selectedImages.delete(this.author.id);
if (image === undefined) { if (image === undefined) {
runningCommands.delete(this.author.id); runningCommands.delete(this.author.id);
return this.constructor.noImage; return `${this.constructor.noImage} (Tip: try right-clicking/holding on a message and press Apps -> Select Image, then try again.)`;
} else if (image.type === "large") { } else if (image.type === "large") {
runningCommands.delete(this.author.id); runningCommands.delete(this.author.id);
return "That image is too large (>= 25MB)! Try using a smaller image."; return "That image is too large (>= 25MB)! Try using a smaller image.";
@ -44,11 +45,12 @@ class ImageCommand extends Command {
runningCommands.delete(this.author.id); runningCommands.delete(this.author.id);
return "I've been rate-limited by Tenor. Please try uploading your GIF elsewhere."; return "I've been rate-limited by Tenor. Please try uploading your GIF elsewhere.";
} }
magickParams.path = image.path; imageParams.path = image.path;
magickParams.params.type = image.type; imageParams.params.type = image.type;
magickParams.url = image.url; // technically not required but can be useful for text filtering imageParams.url = image.url; // technically not required but can be useful for text filtering
magickParams.name = image.name; imageParams.name = image.name;
if (this.constructor.requiresGIF) magickParams.onlyGIF = true; imageParams.id = (this.interaction ?? this.message).id;
if (this.constructor.requiresGIF) imageParams.onlyGIF = true;
} catch (e) { } catch (e) {
runningCommands.delete(this.author.id); runningCommands.delete(this.author.id);
throw e; throw e;
@ -57,31 +59,31 @@ class ImageCommand extends Command {
if (this.constructor.requiresText) { if (this.constructor.requiresText) {
const text = this.options.text ?? this.args.join(" ").trim(); const text = this.options.text ?? this.args.join(" ").trim();
if (text.length === 0 || !await this.criteria(text, magickParams.url)) { if (text.length === 0 || !await this.criteria(text, imageParams.url)) {
runningCommands.delete(this.author.id); runningCommands.delete(this.author.id);
return this.constructor.noText; return this.constructor.noText;
} }
} }
if (typeof this.params === "function") { if (typeof this.params === "function") {
Object.assign(magickParams.params, this.params(magickParams.url, magickParams.name)); Object.assign(imageParams.params, this.params(imageParams.url, imageParams.name));
} else if (typeof this.params === "object") { } else if (typeof this.params === "object") {
Object.assign(magickParams.params, this.params); Object.assign(imageParams.params, this.params);
} }
let status; let status;
if (magickParams.params.type === "image/gif" && this.type === "classic") { if (imageParams.params.type === "image/gif" && this.type === "classic") {
status = await this.processMessage(this.message); status = await this.processMessage(this.message);
} }
try { try {
const { buffer, type } = await this.ipc.serviceCommand("image", { type: "run", obj: magickParams }, true, 9000000); const { arrayBuffer, type } = await runImageJob(imageParams);
if (type === "nogif" && this.constructor.requiresGIF) { if (type === "nogif" && this.constructor.requiresGIF) {
return "That isn't a GIF!"; return "That isn't a GIF!";
} }
this.success = true; this.success = true;
return { return {
file: Buffer.from(buffer.data), file: Buffer.from(arrayBuffer),
name: `${this.constructor.command}.${type}` name: `${this.constructor.command}.${type}`
}; };
} catch (e) { } catch (e) {

View file

@ -2,8 +2,8 @@ import Command from "./command.js";
import { players, queues } from "../utils/soundplayer.js"; import { players, queues } from "../utils/soundplayer.js";
class MusicCommand extends Command { class MusicCommand extends Command {
constructor(client, cluster, worker, ipc, options) { constructor(client, options) {
super(client, cluster, worker, ipc, options); super(client, options);
if (this.channel.guild) { if (this.channel.guild) {
this.connection = players.get(this.channel.guild.id); this.connection = players.get(this.channel.guild.id);
this.queue = queues.get(this.channel.guild.id); this.queue = queues.get(this.channel.guild.id);

View file

@ -7,20 +7,23 @@ class AvatarCommand extends Command {
const self = await this.client.getRESTUser(this.author.id); const self = await this.client.getRESTUser(this.author.id);
if (this.type === "classic" && this.message.mentions[0]) { if (this.type === "classic" && this.message.mentions[0]) {
return this.message.mentions[0].dynamicAvatarURL(null, 512); return this.message.mentions[0].dynamicAvatarURL(null, 512);
} else if (await this.ipc.fetchUser(member)) { } else if (member) {
let user = await this.ipc.fetchUser(member); const user = await this.client.getRESTUser(member);
if (!user) user = await this.client.getRESTUser(member); if (user) {
return user?.avatar ? this.client._formatImage(`/avatars/${user.id}/${user.avatar}`, null, 512) : `https://cdn.discordapp.com/embed/avatars/${user.discriminator % 5}.png`; // hacky "solution" return user?.avatar ? this.client._formatImage(`/avatars/${user.id}/${user.avatar}`, null, 512) : `https://cdn.discordapp.com/embed/avatars/${user.discriminator % 5}.png`; // hacky "solution"
} else if (mentionRegex.test(member)) { } else if (mentionRegex.text(member)) {
const id = member.match(mentionRegex)[1]; const id = member.match(mentionRegex)[1];
if (id < 21154535154122752n) { if (id < 21154535154122752n) {
this.success = false; this.success = false;
return "That's not a valid mention!"; return "That's not a valid mention!";
} }
try { try {
const user = await this.client.getRESTUser(id); const user = await this.client.getRESTUser(id);
return user.avatar ? this.client._formatImage(`/avatars/${user.id}/${user.avatar}`, null, 512) : `https://cdn.discordapp.com/embed/avatars/${user.discriminator % 5}.png`; // repeat of hacky "solution" from above return user.avatar ? this.client._formatImage(`/avatars/${user.id}/${user.avatar}`, null, 512) : `https://cdn.discordapp.com/embed/avatars/${user.discriminator % 5}.png`; // repeat of hacky "solution" from above
} catch { } catch {
return self.dynamicAvatarURL(null, 512);
}
} else {
return self.dynamicAvatarURL(null, 512); return self.dynamicAvatarURL(null, 512);
} }
} else if (this.args.join(" ") !== "" && this.channel.guild) { } else if (this.args.join(" ") !== "" && this.channel.guild) {

View file

@ -7,20 +7,24 @@ class BannerCommand extends Command {
const self = await this.client.getRESTUser(this.author.id); const self = await this.client.getRESTUser(this.author.id);
if (this.type === "classic" && this.message.mentions[0]) { if (this.type === "classic" && this.message.mentions[0]) {
return this.message.mentions[0].dynamicBannerURL(null, 512) ?? "This user doesn't have a banner!"; return this.message.mentions[0].dynamicBannerURL(null, 512) ?? "This user doesn't have a banner!";
} else if (await this.ipc.fetchUser(member)) { } else if (member) {
const user = await this.client.getRESTUser(member); const user = await this.client.getRESTUser(member);
return user.dynamicBannerURL(null, 512) ?? "This user doesn't have a banner!"; if (user) {
} else if (mentionRegex.test(member)) {
const id = member.match(mentionRegex)[1];
if (id < 21154535154122752n) {
this.success = false;
return "That's not a valid mention!";
}
try {
const user = await this.client.getRESTUser(id);
return user.dynamicBannerURL(null, 512) ?? "This user doesn't have a banner!"; return user.dynamicBannerURL(null, 512) ?? "This user doesn't have a banner!";
} catch { } else if (mentionRegex.text(member)) {
return self.dynamicBannerURL(null, 512) ?? "You don't have a banner!"; const id = member.match(mentionRegex)[1];
if (id < 21154535154122752n) {
this.success = false;
return "That's not a valid mention!";
}
try {
const user = await this.client.getRESTUser(id);
return user.dynamicBannerURL(null, 512) ?? "This user doesn't have a banner!";
} catch {
return self.dynamicBannerURL(null, 512) ?? "You don't have a banner!";
}
} else {
return "This user doesn't have a banner!";
} }
} else if (this.args.join(" ") !== "" && this.channel.guild) { } else if (this.args.join(" ") !== "" && this.channel.guild) {
const searched = await this.channel.guild.searchMembers(this.args.join(" ")); const searched = await this.channel.guild.searchMembers(this.args.join(" "));

View file

@ -1,32 +1,38 @@
import Command from "../../classes/command.js"; import Command from "../../classes/command.js";
import { endBroadcast, startBroadcast } from "../../utils/misc.js";
class BroadcastCommand extends Command { class BroadcastCommand extends Command {
// yet another very hacky command async run() {
run() { const owners = process.env.OWNER.split(",");
return new Promise((resolve) => { if (!owners.includes(this.author.id)) {
const owners = process.env.OWNER.split(","); this.success = false;
if (!owners.includes(this.author.id)) { return "Only the bot owner can broadcast messages!";
this.success = false; }
resolve("Only the bot owner can broadcast messages!"); const message = this.options.message ?? this.args.join(" ");
return; if (message?.trim()) {
} startBroadcast(this.client, message);
const message = this.options.message ?? this.args.join(" "); if (process.env.PM2_USAGE) {
if (message?.trim()) { process.send({
this.ipc.centralStore.set("broadcast", message); type: "process:msg",
this.ipc.broadcast("playbroadcast", message); data: {
this.ipc.register("broadcastSuccess", () => { type: "broadcastStart",
this.ipc.unregister("broadcastSuccess"); message
resolve("Successfully broadcasted message."); }
});
} else {
this.ipc.centralStore.delete("broadcast");
this.ipc.broadcast("broadcastend");
this.ipc.register("broadcastEnd", () => {
this.ipc.unregister("broadcastEnd");
resolve("Successfully ended broadcast.");
}); });
} }
}); return "Started broadcast.";
} else {
endBroadcast(this.client);
if (process.env.PM2_USAGE) {
process.send({
type: "process:msg",
data: {
type: "broadcastEnd"
}
});
}
return "Ended broadcast.";
}
} }
static flags = [{ static flags = [{

View file

@ -1,4 +1,5 @@
import Command from "../../classes/command.js"; import Command from "../../classes/command.js";
import { reloadImageConnections } from "../../utils/image.js";
class ImageReloadCommand extends Command { class ImageReloadCommand extends Command {
async run() { async run() {
@ -7,9 +8,18 @@ class ImageReloadCommand extends Command {
this.success = false; this.success = false;
return "Only the bot owner can reload the image servers!"; return "Only the bot owner can reload the image servers!";
} }
const amount = await this.ipc.serviceCommand("image", { type: "reload" }, true); await this.acknowledge();
if (amount > 0) { const length = await reloadImageConnections();
return `Successfully connected to ${amount} image servers.`; if (!length) {
if (process.env.PM2_USAGE) {
process.send({
type: "process:msg",
data: {
type: "imagereload"
}
});
}
return `Successfully connected to ${length} image server(s).`;
} else { } else {
return "I couldn't connect to any image servers!"; return "I couldn't connect to any image servers!";
} }

View file

@ -1,9 +1,9 @@
import Command from "../../classes/command.js"; import Command from "../../classes/command.js";
import { connections } from "../../utils/image.js";
class ImageStatsCommand extends Command { class ImageStatsCommand extends Command {
async run() { async run() {
await this.acknowledge(); await this.acknowledge();
const servers = await this.ipc.serviceCommand("image", { type: "stats" }, true);
const embed = { const embed = {
embeds: [{ embeds: [{
"author": { "author": {
@ -11,14 +11,17 @@ class ImageStatsCommand extends Command {
"icon_url": this.client.user.avatarURL "icon_url": this.client.user.avatarURL
}, },
"color": 16711680, "color": 16711680,
"description": `The bot is currently connected to ${servers.length} image server(s).`, "description": `The bot is currently connected to ${connections.size} image server(s).`,
"fields": [] "fields": []
}] }]
}; };
for (let i = 0; i < servers.length; i++) { let i = 0;
for (const connection of connections.values()) {
const count = await connection.getCount();
if (!count) continue;
embed.embeds[0].fields.push({ embed.embeds[0].fields.push({
name: `Server ${i + 1}`, name: `Server ${i++}`,
value: `Running Jobs: ${Math.min(servers[i].runningJobs, servers[i].max)}\nQueued: ${Math.max(0, servers[i].runningJobs - servers[i].max)}\nMax Jobs: ${servers[i].max}` value: `Running Jobs: ${count}`
}); });
} }
return embed; return embed;

View file

@ -5,13 +5,14 @@ const { version } = JSON.parse(readFileSync(new URL("../../package.json", import
import Command from "../../classes/command.js"; import Command from "../../classes/command.js";
import { exec as baseExec } from "child_process"; import { exec as baseExec } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import { getServers } from "../../utils/misc.js";
const exec = promisify(baseExec); const exec = promisify(baseExec);
class InfoCommand extends Command { class InfoCommand extends Command {
async run() { async run() {
let owner = await this.ipc.fetchUser(process.env.OWNER.split(",")[0]); const owner = await this.client.getRESTUser(process.env.OWNER.split(",")[0]);
if (!owner) owner = await this.client.getRESTUser(process.env.OWNER.split(",")[0]); const servers = await getServers();
const stats = await this.ipc.getStats(); await this.acknowledge();
return { return {
embeds: [{ embeds: [{
color: 16711680, color: 16711680,
@ -30,7 +31,7 @@ class InfoCommand extends Command {
}, },
{ {
name: "💬 Total Servers:", name: "💬 Total Servers:",
value: stats?.guilds ? stats.guilds : `${this.client.guilds.size} (for this cluster only)` value: servers ? servers : `${this.client.guilds.size} (for this process only)`
}, },
{ {
name: "✅ Official Server:", name: "✅ Official Server:",

View file

@ -1,27 +1,29 @@
import Command from "../../classes/command.js"; import Command from "../../classes/command.js";
import { load } from "../../utils/handler.js";
import { checkStatus } from "../../utils/soundplayer.js";
import { paths } from "../../utils/collections.js";
class ReloadCommand extends Command { class ReloadCommand extends Command {
// quite possibly one of the hackiest commands in the bot async run() {
run() { const owners = process.env.OWNER.split(",");
return new Promise((resolve) => { if (!owners.includes(this.author.id)) return "Only the bot owner can reload commands!";
const owners = process.env.OWNER.split(","); const commandName = this.options.cmd ?? this.args.join(" ");
if (!owners.includes(this.author.id)) return resolve("Only the bot owner can reload commands!"); if (!commandName || !commandName.trim()) return "You need to provide a command to reload!";
const commandName = this.options.cmd ?? this.args.join(" "); await this.acknowledge();
if (!commandName || !commandName.trim()) return resolve("You need to provide a command to reload!"); const path = paths.get(commandName);
this.acknowledge().then(() => { if (!path) return "I couldn't find that command!";
this.ipc.broadcast("reload", commandName); const result = await load(this.client, path, await checkStatus(), true);
this.ipc.register("reloadSuccess", () => { if (result !== commandName) return "I couldn't reload that command!";
this.ipc.unregister("reloadSuccess"); if (process.env.PM2_USAGE) {
this.ipc.unregister("reloadFail"); process.send({
resolve(`The command \`${commandName}\` has been reloaded.`); type: "process:msg",
}); data: {
this.ipc.register("reloadFail", (message) => { type: "reload",
this.ipc.unregister("reloadSuccess"); message: commandName
this.ipc.unregister("reloadFail"); }
resolve(message.result);
});
}); });
}); }
return `The command \`${commandName}\` has been reloaded.`;
} }
static flags = [{ static flags = [{

View file

@ -10,8 +10,7 @@ class RestartCommand extends Command {
await this.client.createMessage(this.channel.id, Object.assign({ await this.client.createMessage(this.channel.id, Object.assign({
content: "esmBot is restarting." content: "esmBot is restarting."
}, this.reference)); }, this.reference));
this.ipc.restartAllClusters(true); process.exit(1);
//this.ipc.broadcast("restart");
} }
static description = "Restarts me"; static description = "Restarts me";

View file

@ -1,29 +1,29 @@
import Command from "../../classes/command.js"; import Command from "../../classes/command.js";
import { checkStatus, reload } from "../../utils/soundplayer.js";
class SoundReloadCommand extends Command { class SoundReloadCommand extends Command {
// another very hacky command async run() {
run() { const owners = process.env.OWNER.split(",");
return new Promise((resolve) => { if (!owners.includes(this.author.id)) {
const owners = process.env.OWNER.split(","); this.success = false;
if (!owners.includes(this.author.id)) { return "Only the bot owner can reload Lavalink!";
this.success = false; }
return "Only the bot owner can reload Lavalink!"; await this.acknowledge();
const soundStatus = await checkStatus();
if (!soundStatus) {
const length = reload();
if (process.env.PM2_USAGE) {
process.send({
type: "process:msg",
data: {
type: "soundreload"
}
});
} }
this.acknowledge().then(() => { return `Successfully connected to ${length} Lavalink node(s).`;
this.ipc.broadcast("soundreload"); } else {
this.ipc.register("soundReloadSuccess", (msg) => { return "I couldn't connect to any Lavalink nodes!";
this.ipc.unregister("soundReloadSuccess"); }
this.ipc.unregister("soundReloadFail");
resolve(`Successfully connected to ${msg.length} Lavalink node(s).`);
});
this.ipc.register("soundReloadFail", () => {
this.ipc.unregister("soundReloadSuccess");
this.ipc.unregister("soundReloadFail");
resolve("I couldn't connect to any Lavalink nodes!");
});
});
});
} }
static description = "Attempts to reconnect to all available Lavalink nodes"; static description = "Attempts to reconnect to all available Lavalink nodes";

View file

@ -7,6 +7,8 @@ import Command from "../../classes/command.js";
import { VERSION } from "eris"; import { VERSION } from "eris";
import { exec as baseExec } from "child_process"; import { exec as baseExec } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import pm2 from "pm2";
import { getServers } from "../../utils/misc.js";
const exec = promisify(baseExec); const exec = promisify(baseExec);
class StatsCommand extends Command { class StatsCommand extends Command {
@ -14,7 +16,7 @@ class StatsCommand extends Command {
const uptime = process.uptime() * 1000; const uptime = process.uptime() * 1000;
const connUptime = this.client.uptime; const connUptime = this.client.uptime;
const owner = await this.client.getRESTUser(process.env.OWNER.split(",")[0]); const owner = await this.client.getRESTUser(process.env.OWNER.split(",")[0]);
const stats = await this.ipc.getStats(); const servers = await getServers();
return { return {
embeds: [{ embeds: [{
"author": { "author": {
@ -28,13 +30,13 @@ class StatsCommand extends Command {
"value": `v${version}${process.env.NODE_ENV === "development" ? `-dev (${(await exec("git rev-parse HEAD", { cwd: dirname(fileURLToPath(import.meta.url)) })).stdout.substring(0, 7)})` : ""}` "value": `v${version}${process.env.NODE_ENV === "development" ? `-dev (${(await exec("git rev-parse HEAD", { cwd: dirname(fileURLToPath(import.meta.url)) })).stdout.substring(0, 7)})` : ""}`
}, },
{ {
"name": "Cluster Memory Usage", "name": "Process Memory Usage",
"value": stats?.clusters[this.cluster] ? `${stats.clusters[this.cluster].ram.toFixed(2)} MB` : `${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2)} MB`, "value": `${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2)} MB`,
"inline": true "inline": true
}, },
{ {
"name": "Total Memory Usage", "name": "Total Memory Usage",
"value": stats?.totalRam ? `${stats.totalRam.toFixed(2)} MB` : "Unknown", "value": process.env.PM2_USAGE ? `${((await this.list()).reduce((prev, cur) => prev + cur.monit.memory, 0) / 1024 / 1024).toFixed(2)} MB` : "Unknown",
"inline": true "inline": true
}, },
{ {
@ -65,14 +67,9 @@ class StatsCommand extends Command {
"value": this.channel.guild ? this.client.guildShardMap[this.channel.guild.id] : "N/A", "value": this.channel.guild ? this.client.guildShardMap[this.channel.guild.id] : "N/A",
"inline": true "inline": true
}, },
{
"name": "Cluster",
"value": this.cluster,
"inline": true
},
{ {
"name": "Servers", "name": "Servers",
"value": stats?.guilds ? stats.guilds : `${this.client.guilds.size} (for this cluster only)`, "value": servers ? servers : `${this.client.guilds.size} (for this process only)`,
"inline": true "inline": true
} }
] ]
@ -80,6 +77,15 @@ class StatsCommand extends Command {
}; };
} }
list() {
return new Promise((resolve, reject) => {
pm2.list((err, list) => {
if (err) return reject(err);
resolve(list.filter((v) => v.name === "esmBot"));
});
});
}
static description = "Gets some statistics about me"; static description = "Gets some statistics about me";
static aliases = ["status", "stat"]; static aliases = ["status", "stat"];
} }

View file

@ -2,7 +2,7 @@ import Command from "../../classes/command.js";
class UserInfoCommand extends Command { class UserInfoCommand extends Command {
async run() { async run() {
const getUser = this.message.mentions.length >= 1 ? this.message.mentions[0] : (this.args.length !== 0 ? await this.ipc.fetchUser(this.args[0]) : this.author); const getUser = this.message.mentions.length >= 1 ? this.message.mentions[0] : (this.args.length !== 0 ? this.client.users.get(this.args[0]) : this.author);
let user; let user;
if (getUser) { if (getUser) {
user = getUser; user = getUser;

View file

@ -12,7 +12,7 @@ class HostCommand extends MusicCommand {
if (input?.trim()) { if (input?.trim()) {
let user; let user;
if (this.type === "classic") { if (this.type === "classic") {
const getUser = this.message.mentions.length >= 1 ? this.message.mentions[0] : (await this.ipc.fetchUser(input)); const getUser = this.message.mentions.length >= 1 ? this.message.mentions[0] : this.client.users.get(input);
if (getUser) { if (getUser) {
user = getUser; user = getUser;
} else if (input.match(/^<?[@#]?[&!]?\d+>?$/) && input >= 21154535154122752n) { } else if (input.match(/^<?[@#]?[&!]?\d+>?$/) && input >= 21154535154122752n) {

View file

@ -15,7 +15,7 @@ class MusicAIOCommand extends Command {
if (aliases.has(cmd)) cmd = aliases.get(cmd); if (aliases.has(cmd)) cmd = aliases.get(cmd);
if (commands.has(cmd) && info.get(cmd).category === "music") { if (commands.has(cmd) && info.get(cmd).category === "music") {
const command = commands.get(cmd); const command = commands.get(cmd);
const inst = new command(this.client, this.cluster, this.worker, this.ipc, this.origOptions); const inst = new command(this.client, this.origOptions);
const result = await inst.run(); const result = await inst.run();
this.success = inst.success; this.success = inst.success;
return result; return result;

View file

@ -44,7 +44,7 @@ class TagsCommand extends Command {
if (!tagName || !tagName.trim()) return "You need to provide the name of the tag you want to check the owner of!"; if (!tagName || !tagName.trim()) return "You need to provide the name of the tag you want to check the owner of!";
const getResult = await database.getTag(this.channel.guild.id, tagName); const getResult = await database.getTag(this.channel.guild.id, tagName);
if (!getResult) return "This tag doesn't exist!"; if (!getResult) return "This tag doesn't exist!";
const user = await this.ipc.fetchUser(getResult.author); const user = this.client.users.get(getResult.author);
this.success = true; this.success = true;
if (!user) { if (!user) {
try { try {

View file

@ -22,9 +22,7 @@ These variables that are not necessarily required for the bot to run, but can gr
- `TMP_DOMAIN`: The root domain/directory that the images larger than 8MB are stored at. Example: `https://projectlounge.pw/tmp` - `TMP_DOMAIN`: The root domain/directory that the images larger than 8MB are stored at. Example: `https://projectlounge.pw/tmp`
- `THRESHOLD`: A filesize threshold that the bot will start deleting old files in `TEMPDIR` at. - `THRESHOLD`: A filesize threshold that the bot will start deleting old files in `TEMPDIR` at.
- `METRICS`: The HTTP port to serve [Prometheus](https://prometheus.io/)-compatible metrics on. - `METRICS`: The HTTP port to serve [Prometheus](https://prometheus.io/)-compatible metrics on.
- `API_TYPE`: Set this to "none" if you want to process all images locally. Alternatively, set it to "ws" to use an image API server specified in the `image` block of `config/servers.json`, or "azure" to use the Azure Functions-based API. - `API_TYPE`: Set this to "none" if you want to process all images locally. Alternatively, set it to "ws" to use an image API server specified in the `image` block of `config/servers.json`.
- `AZURE_URL`: Your Azure webhook URL. Only applies if `API` is set to "azure".
- `AZURE_PASS`: An optional password used for Azure requests. Only applies if `API` is set to "azure".
- `ADMIN_SERVER`: A Discord server/guild ID to limit owner-only commands such as eval to. - `ADMIN_SERVER`: A Discord server/guild ID to limit owner-only commands such as eval to.
## JSON ## JSON

View file

@ -45,9 +45,6 @@ The default command name is the same as the filename that you save it as, exclud
The parameters available to your command consist of the following: The parameters available to your command consist of the following:
- `this.client`: An instance of an Eris [`Client`](https://abal.moe/Eris/docs/Client), useful for getting info or performing lower-level communication with the Discord API. - `this.client`: An instance of an Eris [`Client`](https://abal.moe/Eris/docs/Client), useful for getting info or performing lower-level communication with the Discord API.
- `this.cluster`: The ID of the eris-fleet cluster that the command is being run from. This should be a number greater than or equal to 0.
- `this.worker`: The ID of the current eris-fleet worker. This should be a number greater than or equal to 0.
- `this.ipc`: An eris-fleet [`IPC`](https://danclay.github.io/eris-fleet/classes/IPC.html) instance, useful for communication between worker processes.
- `this.origOptions`: The raw options object provided to the command by the command handler. - `this.origOptions`: The raw options object provided to the command by the command handler.
- `this.type`: The type of message that activated the command. Can be "classic" (a regular message) or "application" (slash/context menu commands). - `this.type`: The type of message that activated the command. Can be "classic" (a regular message) or "application" (slash/context menu commands).
- `this.channel`: An Eris [`TextChannel`](https://abal.moe/Eris/docs/TextChannel) object of the channel that the command was run in, useful for getting info about a server and how to respond to a message. - `this.channel`: An Eris [`TextChannel`](https://abal.moe/Eris/docs/TextChannel) object of the channel that the command was run in, useful for getting info about a server and how to respond to a message.

17
ecosystem.config.cjs Normal file
View file

@ -0,0 +1,17 @@
module.exports = {
apps: [{
name: "esmBot-manager",
script: "ext.js",
autorestart: true,
exp_backoff_restart_delay: 1000,
watch: false,
exec_mode: "fork"
}, {
name: "esmBot",
script: "app.js",
autorestart: true,
exp_backoff_restart_delay: 1000,
watch: false,
exec_mode: "cluster"
}]
};

View file

@ -1,5 +1,5 @@
import { debug } from "../utils/logger.js"; import { debug } from "../utils/logger.js";
export default async (client, cluster, worker, ipc, message) => { export default async (client, message) => {
debug(message); debug(message);
}; };

5
events/error.js Normal file
View file

@ -0,0 +1,5 @@
import { error } from "../utils/logger.js";
export default async (client, message) => {
error(message);
};

View file

@ -2,7 +2,7 @@ import db from "../utils/database.js";
import { log } from "../utils/logger.js"; import { log } from "../utils/logger.js";
// run when the bot is added to a guild // run when the bot is added to a guild
export default async (client, cluster, worker, ipc, guild) => { export default async (client, guild) => {
log(`[GUILD JOIN] ${guild.name} (${guild.id}) added the bot.`); log(`[GUILD JOIN] ${guild.name} (${guild.id}) added the bot.`);
const guildDB = await db.getGuild(guild.id); const guildDB = await db.getGuild(guild.id);
if (!guildDB) await db.addGuild(guild); if (!guildDB) await db.addGuild(guild);

View file

@ -1,6 +1,6 @@
import { log } from "../utils/logger.js"; import { log } from "../utils/logger.js";
// run when the bot is removed from a guild // run when the bot is removed from a guild
export default async (client, cluster, worker, ipc, guild) => { export default async (client, guild) => {
log(`[GUILD LEAVE] ${guild.name} (${guild.id}) removed the bot.`); log(`[GUILD LEAVE] ${guild.name} (${guild.id}) removed the bot.`);
}; };

View file

@ -5,7 +5,7 @@ import { clean } from "../utils/misc.js";
import { upload } from "../utils/tempimages.js"; import { upload } from "../utils/tempimages.js";
// run when a slash command is executed // run when a slash command is executed
export default async (client, cluster, worker, ipc, interaction) => { export default async (client, interaction) => {
if (interaction?.type !== 2) return; if (interaction?.type !== 2) return;
// check if command exists and if it's enabled // check if command exists and if it's enabled
@ -23,7 +23,7 @@ export default async (client, cluster, worker, ipc, interaction) => {
try { try {
await database.addCount(command); await database.addCount(command);
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const commandClass = new cmd(client, cluster, worker, ipc, { type: "application", interaction }); const commandClass = new cmd(client, { type: "application", interaction });
const result = await commandClass.run(); const result = await commandClass.run();
const replyMethod = interaction.acknowledged ? "editOriginalMessage" : "createMessage"; const replyMethod = interaction.acknowledged ? "editOriginalMessage" : "createMessage";
if (typeof result === "string") { if (typeof result === "string") {
@ -39,7 +39,7 @@ export default async (client, cluster, worker, ipc, interaction) => {
const fileSize = 8388119; const fileSize = 8388119;
if (result.file.length > fileSize) { if (result.file.length > fileSize) {
if (process.env.TEMPDIR && process.env.TEMPDIR !== "") { if (process.env.TEMPDIR && process.env.TEMPDIR !== "") {
await upload(client, ipc, result, interaction, true); await upload(client, result, interaction, true);
} else { } else {
await interaction[replyMethod]({ await interaction[replyMethod]({
content: "The resulting image was more than 8MB in size, so I can't upload it.", content: "The resulting image was more than 8MB in size, so I can't upload it.",

View file

@ -6,7 +6,7 @@ import { clean } from "../utils/misc.js";
import { upload } from "../utils/tempimages.js"; import { upload } from "../utils/tempimages.js";
// run when someone sends a message // run when someone sends a message
export default async (client, cluster, worker, ipc, message) => { export default async (client, message) => {
// ignore other bots // ignore other bots
if (message.author.bot) return; if (message.author.bot) return;
@ -104,7 +104,7 @@ export default async (client, cluster, worker, ipc, message) => {
await database.addCount(aliases.get(command) ?? command); await database.addCount(aliases.get(command) ?? command);
const startTime = new Date(); const startTime = new Date();
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const commandClass = new cmd(client, cluster, worker, ipc, { type: "classic", message, args: parsed._, content: message.content.substring(prefix.length).trim().replace(command, "").trim(), specialArgs: (({ _, ...o }) => o)(parsed) }); // we also provide the message content as a parameter for cases where we need more accuracy const commandClass = new cmd(client, { type: "classic", message, args: parsed._, content: message.content.substring(prefix.length).trim().replace(command, "").trim(), specialArgs: (({ _, ...o }) => o)(parsed) }); // we also provide the message content as a parameter for cases where we need more accuracy
const result = await commandClass.run(); const result = await commandClass.run();
const endTime = new Date(); const endTime = new Date();
if ((endTime - startTime) >= 180000) reference.allowedMentions.repliedUser = true; if ((endTime - startTime) >= 180000) reference.allowedMentions.repliedUser = true;
@ -129,7 +129,7 @@ export default async (client, cluster, worker, ipc, message) => {
} }
if (result.file.length > fileSize) { if (result.file.length > fileSize) {
if (process.env.TEMPDIR && process.env.TEMPDIR !== "") { if (process.env.TEMPDIR && process.env.TEMPDIR !== "") {
await upload(client, ipc, result, message); await upload(client, result, message);
} else { } else {
await client.createMessage(message.channel.id, "The resulting image was more than 8MB in size, so I can't upload it."); await client.createMessage(message.channel.id, "The resulting image was more than 8MB in size, so I can't upload it.");
} }

View file

@ -4,7 +4,7 @@ import { random } from "../utils/misc.js";
const isWaiting = new Map(); const isWaiting = new Map();
export default async (client, cluster, worker, ipc, member, oldChannel) => { export default async (client, member, oldChannel) => {
if (!oldChannel) return; if (!oldChannel) return;
const connection = players.get(oldChannel.guild.id); const connection = players.get(oldChannel.guild.id);
if (oldChannel.id === connection?.voiceChannel.id) { if (oldChannel.id === connection?.voiceChannel.id) {

View file

@ -1,5 +1,5 @@
import leaveHandler from "./voiceChannelLeave.js"; import leaveHandler from "./voiceChannelLeave.js";
export default async (client, cluster, worker, ipc, member, newChannel, oldChannel) => { export default async (client, member, newChannel, oldChannel) => {
await leaveHandler(client, cluster, worker, ipc, member, oldChannel); await leaveHandler(client, member, oldChannel);
}; };

5
events/warn.js Normal file
View file

@ -0,0 +1,5 @@
import { warn } from "../utils/logger.js";
export default async (client, message) => {
warn(message);
};

View file

@ -32,7 +32,6 @@
"dotenv": "^16.0.2", "dotenv": "^16.0.2",
"emoji-regex": "^10.1.0", "emoji-regex": "^10.1.0",
"eris": "github:esmBot/eris#dev", "eris": "github:esmBot/eris#dev",
"eris-fleet": "github:esmBot/eris-fleet#a19920f",
"file-type": "^17.1.6", "file-type": "^17.1.6",
"format-duration": "^2.0.0", "format-duration": "^2.0.0",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
@ -57,6 +56,7 @@
"better-sqlite3": "^7.6.2", "better-sqlite3": "^7.6.2",
"bufferutil": "^4.0.6", "bufferutil": "^4.0.6",
"erlpack": "github:abalabahaha/erlpack", "erlpack": "github:abalabahaha/erlpack",
"pm2": "^5.2.0",
"postgres": "^3.2.4", "postgres": "^3.2.4",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"ws": "^8.8.1", "ws": "^8.8.1",

File diff suppressed because it is too large Load diff

178
shard.js
View file

@ -1,178 +0,0 @@
// shard base
import { BaseClusterWorker } from "eris-fleet";
// path stuff
import { readdir } from "fs/promises";
import { readFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
// fancy loggings
import { log, error } from "./utils/logger.js";
// initialize command loader
import { load, send } from "./utils/handler.js";
// lavalink stuff
import { checkStatus, connect, reload, status, connected } from "./utils/soundplayer.js";
// database stuff
import database from "./utils/database.js";
// command collections
import { paths } from "./utils/collections.js";
// playing messages
const { messages } = JSON.parse(readFileSync(new URL("./config/messages.json", import.meta.url)));
// command config
const { types } = JSON.parse(readFileSync(new URL("./config/commands.json", import.meta.url)));
// other stuff
import { random } from "./utils/misc.js";
// generate help page
import { generateList, createPage } from "./utils/help.js";
// whether a broadcast is currently in effect
let broadcast = false;
class Shard extends BaseClusterWorker {
constructor(bot) {
super(bot);
console.info = (str) => this.ipc.sendToAdmiral("info", str);
this.playingSuffix = types.classic ? ` | @${this.bot.user.username} help` : "";
this.init();
}
async init() {
if (!types.classic && !types.application) {
error("Both classic and application commands are disabled! Please enable at least one command type in config/commands.json.");
this.ipc.totalShutdown(true);
return;
}
// register commands and their info
const soundStatus = await checkStatus();
log("info", "Attempting to load commands...");
for await (const commandFile of this.getFiles(resolve(dirname(fileURLToPath(import.meta.url)), "./commands/"))) {
log("log", `Loading command from ${commandFile}...`);
try {
await load(this.bot, commandFile, soundStatus);
} catch (e) {
error(`Failed to register command from ${commandFile}: ${e}`);
}
}
if (types.application) {
try {
await send(this.bot);
} catch (e) {
log("error", e);
log("error", "Failed to send command data to Discord, slash/message commands may be unavailable.");
}
}
log("info", "Finished loading commands.");
await database.setup(this.ipc);
// register events
log("info", "Attempting to load events...");
for await (const file of this.getFiles(resolve(dirname(fileURLToPath(import.meta.url)), "./events/"))) {
log("log", `Loading event from ${file}...`);
const eventArray = file.split("/");
const eventName = eventArray[eventArray.length - 1].split(".")[0];
if (eventName === "interactionCreate" && !types.application) {
log("warn", `Skipped loading event from ${file} because application commands are disabled`);
continue;
}
const { default: event } = await import(file);
this.bot.on(eventName, event.bind(null, this.bot, this.clusterID, this.workerID, this.ipc));
}
log("info", "Finished loading events.");
// generate docs
if (process.env.OUTPUT && process.env.OUTPUT !== "") {
generateList();
if (this.clusterID === 0) {
await createPage(process.env.OUTPUT);
log("info", "The help docs have been generated.");
}
}
this.ipc.register("reload", async (message) => {
const path = paths.get(message);
if (!path) return this.ipc.broadcast("reloadFail", { result: "I couldn't find that command!" });
try {
const result = await load(this.bot, path, await checkStatus(), true);
if (result !== message) return this.ipc.broadcast("reloadFail", { result });
return this.ipc.broadcast("reloadSuccess");
} catch (result) {
return this.ipc.broadcast("reloadFail", { result });
}
});
this.ipc.register("soundreload", async () => {
const soundStatus = await checkStatus();
if (!soundStatus) {
const length = reload();
return this.ipc.broadcast("soundReloadSuccess", { length });
} else {
return this.ipc.broadcast("soundReloadFail");
}
});
this.ipc.register("playbroadcast", (message) => {
this.bot.editStatus("dnd", {
name: message + this.playingSuffix,
});
broadcast = true;
return this.ipc.broadcast("broadcastSuccess");
});
this.ipc.register("broadcastend", () => {
this.bot.editStatus("dnd", {
name: random(messages) + this.playingSuffix,
});
broadcast = false;
return this.ipc.broadcast("broadcastEnd");
});
// connect to lavalink
if (!status && !connected) connect(this.bot);
const broadcastMessage = await this.ipc.centralStore.get("broadcast");
if (broadcastMessage) {
broadcast = true;
this.bot.editStatus("dnd", {
name: broadcastMessage + this.playingSuffix,
});
}
this.activityChanger();
log("info", `Started worker ${this.workerID}.`);
}
// set activity (a.k.a. the gamer code)
activityChanger() {
if (!broadcast) {
this.bot.editStatus("dnd", {
name: random(messages) + this.playingSuffix,
});
}
setTimeout(this.activityChanger.bind(this), 900000);
}
async* getFiles(dir) {
const dirents = await readdir(dir, { withFileTypes: true });
for (const dirent of dirents) {
const name = dir + (dir.charAt(dir.length - 1) !== "/" ? "/" : "") + dirent.name;
if (dirent.isDirectory()) {
yield* this.getFiles(name);
} else if (dirent.name.endsWith(".js")) {
yield name;
}
}
}
shutdown(done) {
log("warn", "Shutting down...");
this.bot.editStatus("dnd", {
name: "Restarting/shutting down..."
});
database.stop();
done();
}
}
export default Shard;

View file

@ -37,11 +37,7 @@ This page was last generated on \`${new Date().toString()}\`.
\`[]\` means an argument is required, \`{}\` means an argument is optional. \`[]\` means an argument is required, \`{}\` means an argument is optional.
Default prefix is \`&\`.
**Want to help support esmBot's development? Consider donating on Patreon!** https://patreon.com/TheEssem **Want to help support esmBot's development? Consider donating on Patreon!** https://patreon.com/TheEssem
> Tip: You can get much more info about a command by using \`help [command]\` in the bot itself.
`; `;
template += "\n## Table of Contents\n"; template += "\n## Table of Contents\n";

View file

@ -1,14 +1,22 @@
import { request } from "undici"; import { request } from "undici";
import fs from "fs"; import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { Worker } from "worker_threads";
import { createRequire } from "module";
import { fileTypeFromBuffer, fileTypeFromFile } from "file-type"; import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
import * as logger from "./logger.js";
import ImageConnection from "./imageConnection.js";
// only requiring this to work around an issue regarding worker threads
const nodeRequire = createRequire(import.meta.url);
if (!process.env.API_TYPE || process.env.API_TYPE === "none") {
nodeRequire(`../build/${process.env.DEBUG && process.env.DEBUG === "true" ? "Debug" : "Release"}/image.node`);
}
const formats = ["image/jpeg", "image/png", "image/webp", "image/gif", "video/mp4", "video/webm", "video/quicktime"]; const formats = ["image/jpeg", "image/png", "image/webp", "image/gif", "video/mp4", "video/webm", "video/quicktime"];
export const jobs = {};
export const connections = new Map(); export const connections = new Map();
export let servers = process.env.API_TYPE === "ws" ? JSON.parse(fs.readFileSync(new URL("../config/servers.json", import.meta.url), { encoding: "utf8" })).image : null;
export const servers = JSON.parse(fs.readFileSync(new URL("../config/servers.json", import.meta.url), { encoding: "utf8" })).image;
export async function getType(image, extraReturnTypes) { export async function getType(image, extraReturnTypes) {
if (!image.startsWith("http")) { if (!image.startsWith("http")) {
@ -65,3 +73,98 @@ export async function getType(image, extraReturnTypes) {
} }
return type; return type;
} }
function connect(server, auth) {
const connection = new ImageConnection(server, auth);
connections.set(server, connection);
}
function disconnect() {
for (const connection of connections.values()) {
connection.close();
}
connections.clear();
}
async function repopulate() {
const data = await fs.promises.readFile(new URL("../config/servers.json", import.meta.url), { encoding: "utf8" });
servers = JSON.parse(data).image;
}
export async function reloadImageConnections() {
disconnect();
await repopulate();
let amount = 0;
for (const server of servers) {
try {
connect(server.server, server.auth);
amount += 1;
} catch (e) {
logger.error(e);
}
}
return amount;
}
function chooseServer(ideal) {
if (ideal.length === 0) throw "No available servers";
const sorted = ideal.sort((a, b) => {
return a.load - b.load;
});
return sorted[0];
}
async function getIdeal(object) {
const idealServers = [];
for (const [address, connection] of connections) {
if (connection.conn.readyState !== 0 && connection.conn.readyState !== 1) {
continue;
}
if (object.params.type && !connection.formats[object.cmd]?.includes(object.params.type)) continue;
idealServers.push({
addr: address,
load: await connection.getCount()
});
}
const server = chooseServer(idealServers);
return connections.get(server.addr);
}
function waitForWorker(worker) {
return new Promise((resolve, reject) => {
worker.once("message", (data) => {
resolve({
buffer: Buffer.from([...data.buffer]),
type: data.fileExtension
});
});
worker.once("error", reject);
});
}
export async function runImageJob(params) {
if (process.env.API_TYPE === "ws") {
for (let i = 0; i < 3; i++) {
const currentServer = await getIdeal(params);
try {
await currentServer.queue(BigInt(params.id), params);
await currentServer.wait(BigInt(params.id));
const output = await currentServer.getOutput(params.id);
return output;
} catch (e) {
if (i < 2 && e === "Request ended prematurely due to a closed connection") {
continue;
} else {
if (e === "No available servers" && i >= 2) throw "Request ended prematurely due to a closed connection";
throw e;
}
}
}
} else {
// Called from command (not using image API)
const worker = new Worker(path.join(path.dirname(fileURLToPath(import.meta.url)), "./image-runner.js"), {
workerData: params
});
return await waitForWorker(worker);
}
}

View file

@ -22,8 +22,6 @@ class ImageConnection {
this.auth = auth; this.auth = auth;
this.tag = 0; this.tag = 0;
this.disconnected = false; this.disconnected = false;
this.njobs = 0;
this.max = 0;
this.formats = {}; this.formats = {};
this.wsproto = null; this.wsproto = null;
if (tls) { if (tls) {
@ -43,17 +41,15 @@ class ImageConnection {
} else { } else {
httpproto = "http"; httpproto = "http";
} }
this.httpurl = `${httpproto}://${host}/image`; this.httpurl = `${httpproto}://${host}`;
this.conn.on("message", (msg) => this.onMessage(msg)); this.conn.on("message", (msg) => this.onMessage(msg));
this.conn.once("error", (err) => this.onError(err)); this.conn.once("error", (err) => this.onError(err));
this.conn.once("close", () => this.onClose()); this.conn.once("close", () => this.onClose());
} }
onMessage(msg) { async onMessage(msg) {
const op = msg.readUint8(0); const op = msg.readUint8(0);
if (op === Rinit) { if (op === Rinit) {
this.max = msg.readUint16LE(3);
this.njobs = msg.readUint16LE(5);
this.formats = JSON.parse(msg.toString("utf8", 7)); this.formats = JSON.parse(msg.toString("utf8", 7));
return; return;
} }
@ -64,10 +60,7 @@ class ImageConnection {
return; return;
} }
this.requests.delete(tag); this.requests.delete(tag);
if (op === Rqueue) this.njobs++;
if (op === Rcancel || op === Rwait) this.njobs--;
if (op === Rerror) { if (op === Rerror) {
this.njobs--;
promise.reject(new Error(msg.slice(3, msg.length).toString())); promise.reject(new Error(msg.slice(3, msg.length).toString()));
return; return;
} }
@ -82,9 +75,7 @@ class ImageConnection {
for (const [tag, obj] of this.requests.entries()) { for (const [tag, obj] of this.requests.entries()) {
obj.reject("Request ended prematurely due to a closed connection"); obj.reject("Request ended prematurely due to a closed connection");
this.requests.delete(tag); this.requests.delete(tag);
if (obj.op === Twait || obj.op === Tcancel) this.njobs--;
} }
//this.requests.clear();
if (!this.disconnected) { if (!this.disconnected) {
logger.warn(`Lost connection to ${this.host}, attempting to reconnect in 5 seconds...`); logger.warn(`Lost connection to ${this.host}, attempting to reconnect in 5 seconds...`);
await setTimeout(5000); await setTimeout(5000);
@ -107,25 +98,25 @@ class ImageConnection {
queue(jobid, jobobj) { queue(jobid, jobobj) {
const str = JSON.stringify(jobobj); const str = JSON.stringify(jobobj);
const buf = Buffer.alloc(4); const buf = Buffer.alloc(8);
buf.writeUint32LE(jobid); buf.writeBigUint64LE(jobid);
return this.do(Tqueue, jobid, Buffer.concat([buf, Buffer.from(str)])); return this.do(Tqueue, jobid, Buffer.concat([buf, Buffer.from(str)]));
} }
wait(jobid) { wait(jobid) {
const buf = Buffer.alloc(4); const buf = Buffer.alloc(8);
buf.writeUint32LE(jobid); buf.writeBigUint64LE(jobid);
return this.do(Twait, jobid, buf); return this.do(Twait, jobid, buf);
} }
cancel(jobid) { cancel(jobid) {
const buf = Buffer.alloc(4); const buf = Buffer.alloc(8);
buf.writeUint32LE(jobid); buf.writeBigUint64LE(jobid);
return this.do(Tcancel, jobid, buf); return this.do(Tcancel, jobid, buf);
} }
async getOutput(jobid) { async getOutput(jobid) {
const req = await request(`${this.httpurl}?id=${jobid}`, { const req = await request(`${this.httpurl}/image?id=${jobid}`, {
headers: { headers: {
authentication: this.auth || undefined authentication: this.auth || undefined
} }
@ -149,7 +140,18 @@ class ImageConnection {
type = contentType; type = contentType;
break; break;
} }
return { buffer: Buffer.from(await req.body.arrayBuffer()), type }; return { arrayBuffer: await req.body.arrayBuffer(), type };
}
async getCount() {
const req = await request(`${this.httpurl}/count`, {
headers: {
authentication: this.auth || undefined
}
});
if (req.statusCode !== 200) return;
const res = parseInt(await req.body.text());
return res;
} }
async do(op, id, data) { async do(op, id, data) {

View file

@ -1,4 +1,41 @@
export function log(type, content) { return content ? console[type](content) : console.info(type); } import winston from "winston";
import "winston-daily-rotate-file";
export const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
info: 2,
main: 3,
debug: 4
},
transports: [
new winston.transports.Console({ format: winston.format.colorize({ all: true }), stderrLevels: ["error", "warn"] }),
new winston.transports.DailyRotateFile({ filename: "logs/error-%DATE%.log", level: "error", zippedArchive: true, maxSize: 4194304, maxFiles: 8 }),
new winston.transports.DailyRotateFile({ filename: "logs/main-%DATE%.log", zippedArchive: true, maxSize: 4194304, maxFiles: 8 })
],
level: process.env.DEBUG_LOG ? "debug" : "main",
format: winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
winston.format.printf((info) => {
const {
timestamp, level, message, ...args
} = info;
return `[${timestamp}]: [${level.toUpperCase()}] - ${message} ${Object.keys(args).length ? JSON.stringify(args, null, 2) : ""}`;
}),
)
});
winston.addColors({
info: "green",
main: "gray",
debug: "magenta",
warn: "yellow",
error: "red"
});
export function log(type, content) { return content ? logger.log(type === "log" ? "main" : type, content) : logger.info(type); }
export function error(...args) { return log("error", ...args); } export function error(...args) { return log("error", ...args); }

View file

@ -1,7 +1,14 @@
import util from "util"; import util from "util";
import fs from "fs"; import fs from "fs";
import pm2 from "pm2";
import { config } from "dotenv"; import { config } from "dotenv";
// playing messages
const { messages } = JSON.parse(fs.readFileSync(new URL("../config/messages.json", import.meta.url)));
const { types } = JSON.parse(fs.readFileSync(new URL("../config/commands.json", import.meta.url)));
let broadcast = false;
// random(array) to select a random entry in array // random(array) to select a random entry in array
export function random(array) { export function random(array) {
if (!array || array.length < 1) return null; if (!array || array.length < 1) return null;
@ -41,4 +48,62 @@ export function clean(text) {
// textEncode(string) to encode characters for image processing // textEncode(string) to encode characters for image processing
export function textEncode(string) { export function textEncode(string) {
return string.replaceAll("&", "&amp;").replaceAll(">", "&gt;").replaceAll("<", "&lt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;").replaceAll("\\n", "\n").replaceAll("\\:", ":"); return string.replaceAll("&", "&amp;").replaceAll(">", "&gt;").replaceAll("<", "&lt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;").replaceAll("\\n", "\n").replaceAll("\\:", ":");
}
// set activity (a.k.a. the gamer code)
export function activityChanger(bot) {
if (!broadcast) {
bot.editStatus("dnd", {
name: random(messages) + (types.classic ? ` | @${bot.user.username} help` : ""),
});
}
setTimeout(() => activityChanger(bot), 900000);
}
export function checkBroadcast(bot) {
/*if () {
startBroadcast(bot, message);
}*/
}
export function startBroadcast(bot, message) {
bot.editStatus("dnd", {
name: message + (types.classic ? ` | @${bot.user.username} help` : ""),
});
broadcast = true;
}
export function endBroadcast(bot) {
bot.editStatus("dnd", {
name: random(messages) + (types.classic ? ` | @${bot.user.username} help` : ""),
});
broadcast = false;
}
export function getServers() {
return new Promise((resolve, reject) => {
if (process.env.PM2_USAGE) {
pm2.launchBus((err, pm2Bus) => {
const listener = (packet) => {
if (packet.data?.type === "countResponse") {
resolve(packet.data.serverCount);
pm2Bus.off("process:msg");
}
};
pm2Bus.on("process:msg", listener);
});
pm2.sendDataToProcessId(0, {
id: 0,
type: "process:msg",
data: {
type: "getCount"
},
topic: true
}, (err) => {
if (err) reject(err);
});
} else {
resolve(0);
}
});
} }

125
utils/pm2/ext.js Normal file
View file

@ -0,0 +1,125 @@
import pm2 from "pm2";
import { Api } from "@top-gg/sdk";
import winston from "winston";
// load config from .env file
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import { config } from "dotenv";
config({ path: resolve(dirname(fileURLToPath(import.meta.url)), "../../.env") });
const dbl = process.env.NODE_ENV === "production" && process.env.DBL ? new Api(process.env.DBL) : null;
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
info: 2,
main: 3,
debug: 4
},
transports: [
new winston.transports.Console({ format: winston.format.colorize({ all: true }), stderrLevels: ["error", "warn"] })
],
level: process.env.DEBUG_LOG ? "debug" : "main",
format: winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
winston.format.printf((info) => {
const {
timestamp, level, message, ...args
} = info;
return `[${timestamp}]: [${level.toUpperCase()}] - ${message} ${Object.keys(args).length ? JSON.stringify(args, null, 2) : ""}`;
}),
)
});
winston.addColors({
info: "green",
main: "gray",
debug: "magenta",
warn: "yellow",
error: "red"
});
let serverCount = 0;
let shardCount = 0;
let clusterCount = 0;
let responseCount = 0;
let timeout;
process.on("message", (packet) => {
if (packet.data?.type === "getCount") {
process.send({
type: "process:msg",
data: {
type: "countResponse",
serverCount
}
});
}
});
function updateStats() {
return new Promise((resolve, reject) => {
pm2.list((err, list) => {
if (err) reject(err);
const clusters = list.filter((v) => v.name === "esmBot");
clusterCount = clusters.length;
const listener = (packet) => {
if (packet.data?.type === "serverCounts") {
clearTimeout(timeout);
serverCount += packet.data.guilds;
shardCount += packet.data.shards;
responseCount += 1;
if (responseCount >= clusterCount) {
resolve();
process.removeListener("message", listener);
} else {
timeout = setTimeout(() => {
reject();
process.removeListener("message", listener);
}, 5000);
}
}
};
timeout = setTimeout(() => {
reject();
process.removeListener("message", listener);
}, 5000);
process.on("message", listener);
process.send({
type: "process:msg",
data: {
type: "serverCounts"
}
});
});
});
}
async function dblPost() {
logger.main("Posting stats to Top.gg...");
serverCount = 0;
shardCount = 0;
clusterCount = 0;
responseCount = 0;
try {
//await updateStats();
await dbl.postStats({
serverCount,
shardCount
});
logger.main("Stats posted.");
} catch (e) {
logger.error(e);
}
}
setInterval(updateStats, 300000);
if (dbl) setInterval(dblPost, 1800000);
setTimeout(updateStats, 10000);
logger.info("Started esmBot management process.");

View file

@ -1,268 +0,0 @@
import { BaseServiceWorker } from "eris-fleet";
import * as logger from "../logger.js";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { Worker } from "worker_threads";
import { createRequire } from "module";
import { createServer } from "http";
import { request } from "undici";
import EventEmitter from "events";
// only requiring this to work around an issue regarding worker threads
const nodeRequire = createRequire(import.meta.url);
if (!process.env.API_TYPE || process.env.API_TYPE === "none") {
nodeRequire(`../../build/${process.env.DEBUG && process.env.DEBUG === "true" ? "Debug" : "Release"}/image.node`);
}
import ImageConnection from "../imageConnection.js";
class ImageWorker extends BaseServiceWorker {
constructor(setup) {
super(setup);
console.info = (str) => this.ipc.sendToAdmiral("info", str);
if (process.env.API_TYPE === "ws") {
this.connections = new Map();
this.servers = JSON.parse(fs.readFileSync(new URL("../../config/servers.json", import.meta.url), { encoding: "utf8" })).image;
this.nextID = 0;
} else if (process.env.API_TYPE === "azure") {
this.jobs = new Map();
this.webhook = createServer();
this.port = parseInt(process.env.WEBHOOK_PORT) || 3763;
}
this.begin().then(() => this.serviceReady());
}
async begin() {
// connect to image api if enabled
if (process.env.API_TYPE === "ws") {
for (const server of this.servers) {
try {
await this.connect(server.server, server.auth);
} catch (e) {
logger.error(e);
}
}
} else if (process.env.API_TYPE === "azure") {
this.webhook.on("request", async (req, res) => {
if (req.method !== "POST") {
res.statusCode = 405;
return res.end("405 Method Not Allowed");
}
if (process.env.AZURE_PASS && req.headers.authorization !== process.env.AZURE_PASS) {
res.statusCode = 401;
return res.end("401 Unauthorized");
}
const reqUrl = new URL(req.url, `http://${req.headers.host}`);
if (reqUrl.pathname === "/callback") {
try {
const chunks = [];
req.on("data", (data) => {
chunks.push(data);
});
req.once("end", () => {
if (this.jobs.has(req.headers["x-azure-id"])) {
try {
const error = JSON.parse(Buffer.concat(chunks).toString());
if (error.error) this.jobs.get(req.headers["x-azure-id"]).emit("error", new Error(error.message));
} catch {
// no-op
}
const contentType = req.headers["content-type"];
let type;
switch (contentType) {
case "image/gif":
type = "gif";
break;
case "image/png":
type = "png";
break;
case "image/jpeg":
type = "jpg";
break;
case "image/webp":
type = "webp";
break;
default:
type = contentType;
break;
}
this.jobs.get(req.headers["x-azure-id"]).emit("image", { buffer: Buffer.concat(chunks), type });
return res.end("OK");
} else {
res.statusCode = 409;
return res.end("409 Conflict");
}
});
} catch (e) {
logger.error("An error occurred while processing a webhook request: ", e);
res.statusCode = 500;
return res.end("500 Internal Server Error");
}
} else {
res.statusCode = 404;
return res.end("404 Not Found");
}
});
this.webhook.on("error", (e) => {
logger.error("An error occurred on the Azure webhook: ", e);
});
this.webhook.listen(this.port, () => {
logger.log(`Azure HTTP webhook listening on port ${this.port}`);
});
}
}
async repopulate() {
const data = await fs.promises.readFile(new URL("../../config/servers.json", import.meta.url), { encoding: "utf8" });
this.servers = JSON.parse(data).image;
return;
}
async getRunning() {
const statuses = [];
if (process.env.API_TYPE === "ws") {
for (const [address, connection] of this.connections) {
if (connection.conn.readyState !== 0 && connection.conn.readyState !== 1) {
continue;
}
statuses.push({
address,
runningJobs: connection.njobs,
max: connection.max
});
}
}
return statuses;
}
async chooseServer(ideal) {
if (ideal.length === 0) throw "No available servers";
const sorted = ideal.sort((a, b) => {
return a.load - b.load;
}).filter((e, i, array) => {
return !(e.load < array[0].load);
});
return sorted[0];
}
async getIdeal(object) {
const idealServers = [];
for (const [address, connection] of this.connections) {
if (connection.conn.readyState !== 0 && connection.conn.readyState !== 1) {
continue;
}
if (object.params.type && !connection.formats[object.cmd]?.includes(object.params.type)) continue;
idealServers.push({
addr: address,
load: connection.njobs / connection.max
});
}
const server = await this.chooseServer(idealServers);
return this.connections.get(server.addr);
}
async connect(server, auth) {
const connection = new ImageConnection(server, auth);
this.connections.set(server, connection);
}
async disconnect() {
for (const connection of this.connections.values()) {
connection.close();
}
this.connections.clear();
return;
}
waitForWorker(worker) {
return new Promise((resolve, reject) => {
worker.once("message", (data) => {
resolve({
buffer: Buffer.from([...data.buffer]),
type: data.fileExtension
});
});
worker.once("error", reject);
});
}
waitForAzure(event) {
return new Promise((resolve, reject) => {
event.once("image", (data) => {
resolve(data);
});
event.once("error", reject);
});
}
async run(object) {
if (process.env.API_TYPE === "ws") {
let num = this.nextID++;
if (num > 4294967295) num = this.nextID = 0;
for (let i = 0; i < 3; i++) {
const currentServer = await this.getIdeal(object);
try {
await currentServer.queue(num, object);
await currentServer.wait(num);
const output = await currentServer.getOutput(num);
return output;
} catch (e) {
if (i < 2 && e === "Request ended prematurely due to a closed connection") {
continue;
} else {
if (e === "No available servers" && i >= 2) throw "Request ended prematurely due to a closed connection";
throw e;
}
}
}
} else if (process.env.API_TYPE === "azure") {
object.callback = `${process.env.AZURE_CALLBACK_URL}:${this.port}/callback`;
const response = await request(`${process.env.AZURE_URL}/api/orchestrators/ImageOrchestrator`, { method: "POST", body: JSON.stringify(object) }).then(r => r.body.json());
const event = new EventEmitter();
this.jobs.set(response.id, event);
return await this.waitForAzure(event);
} else {
// Called from command (not using image API)
const worker = new Worker(path.join(path.dirname(fileURLToPath(import.meta.url)), "../image-runner.js"), {
workerData: object
});
return await this.waitForWorker(worker);
}
}
async handleCommand(data) {
try {
if (data.type === "run") {
const result = await this.run(data.obj);
return result;
} else if (data.type === "reload") {
await this.disconnect();
await this.repopulate();
let amount = 0;
for (const server of this.servers) {
try {
await this.connect(server.server, server.auth);
amount += 1;
} catch (e) {
logger.error(e);
}
}
return amount;
} else if (data.type === "stats") {
return await this.getRunning();
}
} catch (err) {
return { err: typeof err === "string" ? err : err.message };
}
}
shutdown(done) {
done();
}
}
export default ImageWorker;

View file

@ -1,7 +1,9 @@
import * as logger from "../utils/logger.js"; import * as logger from "../utils/logger.js";
import { readdir, lstat, rm, writeFile } from "fs/promises"; import { readdir, lstat, rm, writeFile, stat } from "fs/promises";
export async function upload(client, ipc, result, context, interaction = false) { let dirSizeCache;
export async function upload(client, result, context, interaction = false) {
const filename = `${Math.random().toString(36).substring(2, 15)}.${result.name.split(".")[1]}`; const filename = `${Math.random().toString(36).substring(2, 15)}.${result.name.split(".")[1]}`;
await writeFile(`${process.env.TEMPDIR}/${filename}`, result.file); await writeFile(`${process.env.TEMPDIR}/${filename}`, result.file);
const imageURL = `${process.env.TMP_DOMAIN || "https://tmp.projectlounge.pw"}/${filename}`; const imageURL = `${process.env.TMP_DOMAIN || "https://tmp.projectlounge.pw"}/${filename}`;
@ -34,13 +36,13 @@ export async function upload(client, ipc, result, context, interaction = false)
})); }));
} }
if (process.env.THRESHOLD) { if (process.env.THRESHOLD) {
const size = await ipc.centralStore.get("dirSizeCache") + result.file.length; const size = dirSizeCache + result.file.length;
await ipc.centralStore.set("dirSizeCache", size); dirSizeCache = size;
await removeOldImages(ipc, size); await removeOldImages(size);
} }
} }
export async function removeOldImages(ipc, size) { async function removeOldImages(size) {
if (size > process.env.THRESHOLD) { if (size > process.env.THRESHOLD) {
const files = (await readdir(process.env.TEMPDIR)).map((file) => { const files = (await readdir(process.env.TEMPDIR)).map((file) => {
return lstat(`${process.env.TEMPDIR}/${file}`).then((stats) => { return lstat(`${process.env.TEMPDIR}/${file}`).then((stats) => {
@ -67,6 +69,30 @@ export async function removeOldImages(ipc, size) {
const newSize = oldestFiles.reduce((a, b) => { const newSize = oldestFiles.reduce((a, b) => {
return a + b.size; return a + b.size;
}, 0); }, 0);
await ipc.centralStore.set("dirSizeCache", newSize); dirSizeCache = newSize;
} }
} }
export async function parseThreshold() {
const matched = process.env.THRESHOLD.match(/(\d+)([KMGT])/);
const sizes = {
K: 1024,
M: 1048576,
G: 1073741824,
T: 1099511627776
};
if (matched && matched[1] && matched[2]) {
process.env.THRESHOLD = matched[1] * sizes[matched[2]];
} else {
logger.error("Invalid THRESHOLD config.");
process.env.THRESHOLD = undefined;
}
const dirstat = (await readdir(process.env.TEMPDIR)).map((file) => {
return stat(`${process.env.TEMPDIR}/${file}`).then((stats) => stats.size);
});
const size = await Promise.all(dirstat);
const reduced = size.reduce((a, b) => {
return a + b;
}, 0);
dirSizeCache = reduced;
}