initial update

This commit is contained in:
murm 2023-03-15 10:09:09 -04:00
parent 3272429cf6
commit db9b70bf66
280 changed files with 11772 additions and 11966 deletions

View file

@ -1,49 +1,49 @@
// this is a method to wait for someone to rejoin a voice channel
import { EventEmitter } from "events";
import { random } from "./misc.js";
class AwaitRejoin extends EventEmitter {
constructor(channel, anyone, memberID) {
super();
this.member = memberID;
this.anyone = anyone;
this.channel = channel;
this.rejoined = false;
this.ended = false;
this.bot = channel.client;
this.listener = (member, newChannel) => this.verify(member, newChannel);
this.bot.on("voiceChannelJoin", this.listener);
this.bot.on("voiceChannelSwitch", this.listener);
this.stopTimeout = setTimeout(() => this.stop(), 10000);
this.checkInterval = setInterval(() => this.verify({ id: memberID }, channel, true), 1000);
}
verify(member, channel, checked) {
if (this.channel.id === channel.id) {
if ((this.member === member.id && this.channel.voiceMembers.has(member.id)) || (this.anyone && !checked)) {
clearTimeout(this.stopTimeout);
this.rejoined = true;
this.stop(member);
return true;
} else if (this.anyone && (!checked || this.channel.voiceMembers.size > 1)) {
clearTimeout(this.stopTimeout);
this.rejoined = true;
this.stop(random(this.channel.voiceMembers.filter((i) => i.id !== this.bot.user.id && !i.bot)));
return true;
}
} else {
return false;
}
}
stop(member) {
if (this.ended) return;
this.ended = true;
clearInterval(this.checkInterval);
this.bot.removeListener("voiceChannelJoin", this.listener);
this.bot.removeListener("voiceChannelSwitch", this.listener);
this.emit("end", this.rejoined, member);
}
}
// this is a method to wait for someone to rejoin a voice channel
import { EventEmitter } from "events";
import { random } from "./misc.js";
class AwaitRejoin extends EventEmitter {
constructor(channel, anyone, memberID) {
super();
this.member = memberID;
this.anyone = anyone;
this.channel = channel;
this.rejoined = false;
this.ended = false;
this.bot = channel.client;
this.listener = (member, newChannel) => this.verify(member, newChannel);
this.bot.on("voiceChannelJoin", this.listener);
this.bot.on("voiceChannelSwitch", this.listener);
this.stopTimeout = setTimeout(() => this.stop(), 10000);
this.checkInterval = setInterval(() => this.verify({ id: memberID }, channel, true), 1000);
}
verify(member, channel, checked) {
if (this.channel.id === channel.id) {
if ((this.member === member.id && this.channel.voiceMembers.has(member.id)) || (this.anyone && !checked)) {
clearTimeout(this.stopTimeout);
this.rejoined = true;
this.stop(member);
return true;
} else if (this.anyone && (!checked || this.channel.voiceMembers.size > 1)) {
clearTimeout(this.stopTimeout);
this.rejoined = true;
this.stop(random(this.channel.voiceMembers.filter((i) => i.id !== this.bot.user.id && !i.bot)));
return true;
}
} else {
return false;
}
}
stop(member) {
if (this.ended) return;
this.ended = true;
clearInterval(this.checkInterval);
this.bot.removeListener("voiceChannelJoin", this.listener);
this.bot.removeListener("voiceChannelSwitch", this.listener);
this.emit("end", this.rejoined, member);
}
}
export default AwaitRejoin;

View file

@ -1,39 +1,39 @@
export const commands = new Map();
export const messageCommands = new Map();
export const paths = new Map();
export const aliases = new Map();
export const info = new Map();
export const sounds = new Map();
export const categories = new Map();
class TimedMap extends Map {
constructor(time, values) {
super(values);
this.time = time;
}
set(key, value) {
super.set(key, value);
setTimeout(() => {
if (super.has(key)) super.delete(key);
}, this.time);
}
}
export const runningCommands = new TimedMap(5000);
export const selectedImages = new TimedMap(180000);
class Cache extends Map {
constructor(values) {
super(values);
this.maxValues = 2048;
}
set(key, value) {
super.set(key, value);
if (this.size > this.maxValues) this.delete(this.keys().next().value);
}
}
export const prefixCache = new Cache();
export const disabledCache = new Cache();
export const commands = new Map();
export const messageCommands = new Map();
export const paths = new Map();
export const aliases = new Map();
export const info = new Map();
export const sounds = new Map();
export const categories = new Map();
class TimedMap extends Map {
constructor(time, values) {
super(values);
this.time = time;
}
set(key, value) {
super.set(key, value);
setTimeout(() => {
if (super.has(key)) super.delete(key);
}, this.time);
}
}
export const runningCommands = new TimedMap(5000);
export const selectedImages = new TimedMap(180000);
class Cache extends Map {
constructor(values) {
super(values);
this.maxValues = 2048;
}
set(key, value) {
super.set(key, value);
if (this.size > this.maxValues) this.delete(this.keys().next().value);
}
}
export const prefixCache = new Cache();
export const disabledCache = new Cache();
export const disabledCmdCache = new Cache();

View file

@ -1,33 +1,33 @@
import { config } from "dotenv";
config();
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DB
});
(async () => {
const guilds = (await pool.query("SELECT * FROM guilds")).rows;
console.log("Migrating tags...");
try {
await pool.query("CREATE TABLE tags ( guild_id VARCHAR(30) NOT NULL, name text NOT NULL, content text NOT NULL, author VARCHAR(30) NOT NULL, UNIQUE(guild_id, name) )");
} catch (e) {
console.error(`Skipping table creation due to error: ${e}`);
}
for (const guild of guilds) {
for (const [name, value] of Object.entries(guild.tags)) {
if ((await pool.query("SELECT * FROM tags WHERE guild_id = $1 AND name = $2", [guild.guild_id, name])).rows.length !== 0) {
await pool.query("UPDATE tags SET content = $1, author = $2 WHERE guild_id = $3 AND name = $4", [value.content, value.author, guild.guild_id, name]);
} else {
await pool.query("INSERT INTO tags (guild_id, name, content, author) VALUES ($1, $2, $3, $4)", [guild.guild_id, name, value.content, value.author]);
}
console.log(`Migrated tag ${name} in guild ${guild.guild_id}`);
}
}
console.log("Migrating disabled commands...");
for (const guild of guilds) {
await pool.query("UPDATE guilds SET disabled_commands = $1 WHERE guild_id = $2", [guild.tags_disabled ? ["tags"] : [], guild.guild_id]);
console.log(`Migrated disabled commands in guild ${guild.guild_id}`);
}
console.log("Done!");
return process.exit(0);
import { config } from "dotenv";
config();
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DB
});
(async () => {
const guilds = (await pool.query("SELECT * FROM guilds")).rows;
console.log("Migrating tags...");
try {
await pool.query("CREATE TABLE tags ( guild_id VARCHAR(30) NOT NULL, name text NOT NULL, content text NOT NULL, author VARCHAR(30) NOT NULL, UNIQUE(guild_id, name) )");
} catch (e) {
console.error(`Skipping table creation due to error: ${e}`);
}
for (const guild of guilds) {
for (const [name, value] of Object.entries(guild.tags)) {
if ((await pool.query("SELECT * FROM tags WHERE guild_id = $1 AND name = $2", [guild.guild_id, name])).rows.length !== 0) {
await pool.query("UPDATE tags SET content = $1, author = $2 WHERE guild_id = $3 AND name = $4", [value.content, value.author, guild.guild_id, name]);
} else {
await pool.query("INSERT INTO tags (guild_id, name, content, author) VALUES ($1, $2, $3, $4)", [guild.guild_id, name, value.content, value.author]);
}
console.log(`Migrated tag ${name} in guild ${guild.guild_id}`);
}
}
console.log("Migrating disabled commands...");
for (const guild of guilds) {
await pool.query("UPDATE guilds SET disabled_commands = $1 WHERE guild_id = $2", [guild.tags_disabled ? ["tags"] : [], guild.guild_id]);
console.log(`Migrated disabled commands in guild ${guild.guild_id}`);
}
console.log("Done!");
return process.exit(0);
})();

View file

@ -1,20 +1,20 @@
// wrapper for the database drivers in ./database/
import { config } from "dotenv";
config();
let db = null;
if (process.env.DB) {
const dbtype = process.env.DB.split("://")[0];
try {
db = await import(`./database/${dbtype}.js`);
} catch (error) {
if (error.code == "ERR_MODULE_NOT_FOUND") {
console.error(`DB config option has unknown database type '${dbtype}'`);
process.exit(1);
}
throw error;
}
}
export default db;
// wrapper for the database drivers in ./database/
import { config } from "dotenv";
config();
let db = null;
if (process.env.DB) {
const dbtype = process.env.DB.split("://")[0];
try {
db = await import(`./database/${dbtype}.js`);
} catch (error) {
if (error.code == "ERR_MODULE_NOT_FOUND") {
console.error(`DB config option has unknown database type '${dbtype}'`);
process.exit(1);
}
throw error;
}
}
export default db;

View file

@ -1,187 +1,187 @@
import { prefixCache, disabledCmdCache, disabledCache, commands, messageCommands } from "../collections.js";
import Postgres from "postgres";
const sql = Postgres(process.env.DB, {
onnotice: () => {}
});
const settingsSchema = `
CREATE TABLE IF NOT EXISTS settings (
id smallint PRIMARY KEY,
version integer NOT NULL, CHECK(id = 1)
);
`;
const schema = `
ALTER TABLE settings ADD COLUMN broadcast text;
CREATE TABLE guilds (
guild_id VARCHAR(30) NOT NULL PRIMARY KEY,
prefix VARCHAR(15) NOT NULL,
disabled text ARRAY NOT NULL,
disabled_commands text ARRAY NOT NULL
);
CREATE TABLE counts (
command VARCHAR NOT NULL PRIMARY KEY,
count integer NOT NULL
);
CREATE TABLE tags (
guild_id VARCHAR(30) NOT NULL,
name text NOT NULL,
content text NOT NULL,
author VARCHAR(30) NOT NULL,
UNIQUE(guild_id, name)
);
`;
const updates = [
"", // reserved
"CREATE TABLE IF NOT EXISTS settings ( id smallint PRIMARY KEY, version integer NOT NULL, CHECK(id = 1) );\nALTER TABLE guilds ADD COLUMN accessed timestamp;",
"ALTER TABLE guilds DROP COLUMN accessed",
"ALTER TABLE settings ADD COLUMN IF NOT EXISTS broadcast text"
];
export async function setup() {
const existingCommands = (await sql`SELECT command FROM counts`).map(x => x.command);
const commandNames = [...commands.keys(), ...messageCommands.keys()];
for (const command of existingCommands) {
if (!commandNames.includes(command)) {
await sql`DELETE FROM counts WHERE command = ${command}`;
}
}
for (const command of commandNames) {
if (!existingCommands.includes(command)) {
await sql`INSERT INTO counts ${sql({ command, count: 0 }, "command", "count")}`;
}
}
}
export async function upgrade(logger) {
try {
await sql.begin(async (sql) => {
await sql.unsafe(settingsSchema);
let version;
const settingsrow = (await sql`SELECT version FROM settings WHERE id = 1`);
if (settingsrow.length == 0) {
version = 0;
} else {
version = settingsrow[0].version;
}
const latestVersion = updates.length - 1;
if (version === 0) {
logger.info("Initializing PostgreSQL database...");
await sql.unsafe(schema);
} else if (version < latestVersion) {
logger.info(`Migrating PostgreSQL database, which is currently at version ${version}...`);
while (version < latestVersion) {
version++;
logger.info(`Running version ${version} update script...`);
await sql.unsafe(updates[version]);
}
} else if (version > latestVersion) {
throw new Error(`PostgreSQL database is at version ${version}, but this version of the bot only supports up to version ${latestVersion}.`);
} else {
return;
}
await sql`INSERT INTO settings ${sql({ id: 1, version: latestVersion })} ON CONFLICT (id) DO UPDATE SET version = ${latestVersion}`;
});
} catch (e) {
logger.error(`PostgreSQL migration failed: ${e}`);
logger.error("Unable to start the bot, quitting now.");
return 1;
}
}
export async function getGuild(query) {
let guild;
await sql.begin(async (sql) => {
guild = (await sql`SELECT * FROM guilds WHERE guild_id = ${query}`)[0];
if (guild == undefined) {
guild = { guild_id: query, prefix: process.env.PREFIX, disabled: [], disabled_commands: [] };
await sql`INSERT INTO guilds ${sql(guild)}`;
}
});
return guild;
}
export async function setPrefix(prefix, guild) {
await sql`UPDATE guilds SET prefix = ${prefix} WHERE guild_id = ${guild.id}`;
prefixCache.set(guild.id, prefix);
}
export async function getTag(guild, tag) {
const tagResult = await sql`SELECT * FROM tags WHERE guild_id = ${guild} AND name = ${tag}`;
return tagResult[0] ? { content: tagResult[0].content, author: tagResult[0].author } : undefined;
}
export async function getTags(guild) {
const tagArray = await sql`SELECT * FROM tags WHERE guild_id = ${guild}`;
const tags = {};
for (const tag of tagArray) {
tags[tag.name] = { content: tag.content, author: tag.author };
}
return tags;
}
export async function setTag(name, content, guild) {
await sql`INSERT INTO tags ${sql({ guild_id: guild.id, name, content: content.content, author: content.author }, "guild_id", "name", "content", "author")}`;
}
export async function editTag(name, content, guild) {
await sql`UPDATE tags SET content = ${content.content}, author = ${content.author} WHERE guild_id = ${guild.id} AND name = ${name}`;
}
export async function removeTag(name, guild) {
await sql`DELETE FROM tags WHERE guild_id = ${guild.id} AND name = ${name}`;
}
export async function setBroadcast(msg) {
await sql`UPDATE settings SET broadcast = ${msg} WHERE id = 1`;
}
export async function getBroadcast() {
const result = await sql`SELECT broadcast FROM settings WHERE id = 1`;
return result[0].broadcast;
}
export async function disableCommand(guild, command) {
const guildDB = await this.getGuild(guild);
await sql`UPDATE guilds SET disabled_commands = ${(guildDB.disabled_commands ? [...guildDB.disabled_commands, command] : [command]).filter((v) => !!v)} WHERE guild_id = ${guild}`;
disabledCmdCache.set(guild, guildDB.disabled_commands ? [...guildDB.disabled_commands, command] : [command].filter((v) => !!v));
}
export async function enableCommand(guild, command) {
const guildDB = await this.getGuild(guild);
const newDisabled = guildDB.disabled_commands ? guildDB.disabled_commands.filter(item => item !== command) : [];
await sql`UPDATE guilds SET disabled_commands = ${newDisabled} WHERE guild_id = ${guild}`;
disabledCmdCache.set(guild, newDisabled);
}
export async function disableChannel(channel) {
const guildDB = await this.getGuild(channel.guildID);
await sql`UPDATE guilds SET disabled_commands = ${[...guildDB.disabled, channel.id]} WHERE guild_id = ${channel.guildID}`;
disabledCache.set(channel.guildID, [...guildDB.disabled, channel.id]);
}
export async function enableChannel(channel) {
const guildDB = await this.getGuild(channel.guildID);
const newDisabled = guildDB.disabled.filter(item => item !== channel.id);
await sql`UPDATE guilds SET disabled_commands = ${newDisabled} WHERE guild_id = ${channel.guildID}`;
disabledCache.set(channel.guildID, newDisabled);
}
export async function getCounts() {
const counts = await sql`SELECT * FROM counts`;
const countObject = {};
for (const { command, count } of counts) {
countObject[command] = count;
}
return countObject;
}
export async function addCount(command) {
await sql`INSERT INTO counts ${sql({ command, count: 1 }, "command", "count")} ON CONFLICT (command) DO UPDATE SET count = counts.count + 1 WHERE counts.command = ${command}`;
}
export async function stop() {
await sql.end();
}
import { prefixCache, disabledCmdCache, disabledCache, commands, messageCommands } from "../collections.js";
import Postgres from "postgres";
const sql = Postgres(process.env.DB, {
onnotice: () => {}
});
const settingsSchema = `
CREATE TABLE IF NOT EXISTS settings (
id smallint PRIMARY KEY,
version integer NOT NULL, CHECK(id = 1)
);
`;
const schema = `
ALTER TABLE settings ADD COLUMN broadcast text;
CREATE TABLE guilds (
guild_id VARCHAR(30) NOT NULL PRIMARY KEY,
prefix VARCHAR(15) NOT NULL,
disabled text ARRAY NOT NULL,
disabled_commands text ARRAY NOT NULL
);
CREATE TABLE counts (
command VARCHAR NOT NULL PRIMARY KEY,
count integer NOT NULL
);
CREATE TABLE tags (
guild_id VARCHAR(30) NOT NULL,
name text NOT NULL,
content text NOT NULL,
author VARCHAR(30) NOT NULL,
UNIQUE(guild_id, name)
);
`;
const updates = [
"", // reserved
"CREATE TABLE IF NOT EXISTS settings ( id smallint PRIMARY KEY, version integer NOT NULL, CHECK(id = 1) );\nALTER TABLE guilds ADD COLUMN accessed timestamp;",
"ALTER TABLE guilds DROP COLUMN accessed",
"ALTER TABLE settings ADD COLUMN IF NOT EXISTS broadcast text"
];
export async function setup() {
const existingCommands = (await sql`SELECT command FROM counts`).map(x => x.command);
const commandNames = [...commands.keys(), ...messageCommands.keys()];
for (const command of existingCommands) {
if (!commandNames.includes(command)) {
await sql`DELETE FROM counts WHERE command = ${command}`;
}
}
for (const command of commandNames) {
if (!existingCommands.includes(command)) {
await sql`INSERT INTO counts ${sql({ command, count: 0 }, "command", "count")}`;
}
}
}
export async function upgrade(logger) {
try {
await sql.begin(async (sql) => {
await sql.unsafe(settingsSchema);
let version;
const settingsrow = (await sql`SELECT version FROM settings WHERE id = 1`);
if (settingsrow.length == 0) {
version = 0;
} else {
version = settingsrow[0].version;
}
const latestVersion = updates.length - 1;
if (version === 0) {
logger.info("Initializing PostgreSQL database...");
await sql.unsafe(schema);
} else if (version < latestVersion) {
logger.info(`Migrating PostgreSQL database, which is currently at version ${version}...`);
while (version < latestVersion) {
version++;
logger.info(`Running version ${version} update script...`);
await sql.unsafe(updates[version]);
}
} else if (version > latestVersion) {
throw new Error(`PostgreSQL database is at version ${version}, but this version of the bot only supports up to version ${latestVersion}.`);
} else {
return;
}
await sql`INSERT INTO settings ${sql({ id: 1, version: latestVersion })} ON CONFLICT (id) DO UPDATE SET version = ${latestVersion}`;
});
} catch (e) {
logger.error(`PostgreSQL migration failed: ${e}`);
logger.error("Unable to start the bot, quitting now.");
return 1;
}
}
export async function getGuild(query) {
let guild;
await sql.begin(async (sql) => {
guild = (await sql`SELECT * FROM guilds WHERE guild_id = ${query}`)[0];
if (guild == undefined) {
guild = { guild_id: query, prefix: process.env.PREFIX, disabled: [], disabled_commands: [] };
await sql`INSERT INTO guilds ${sql(guild)}`;
}
});
return guild;
}
export async function setPrefix(prefix, guild) {
await sql`UPDATE guilds SET prefix = ${prefix} WHERE guild_id = ${guild.id}`;
prefixCache.set(guild.id, prefix);
}
export async function getTag(guild, tag) {
const tagResult = await sql`SELECT * FROM tags WHERE guild_id = ${guild} AND name = ${tag}`;
return tagResult[0] ? { content: tagResult[0].content, author: tagResult[0].author } : undefined;
}
export async function getTags(guild) {
const tagArray = await sql`SELECT * FROM tags WHERE guild_id = ${guild}`;
const tags = {};
for (const tag of tagArray) {
tags[tag.name] = { content: tag.content, author: tag.author };
}
return tags;
}
export async function setTag(name, content, guild) {
await sql`INSERT INTO tags ${sql({ guild_id: guild.id, name, content: content.content, author: content.author }, "guild_id", "name", "content", "author")}`;
}
export async function editTag(name, content, guild) {
await sql`UPDATE tags SET content = ${content.content}, author = ${content.author} WHERE guild_id = ${guild.id} AND name = ${name}`;
}
export async function removeTag(name, guild) {
await sql`DELETE FROM tags WHERE guild_id = ${guild.id} AND name = ${name}`;
}
export async function setBroadcast(msg) {
await sql`UPDATE settings SET broadcast = ${msg} WHERE id = 1`;
}
export async function getBroadcast() {
const result = await sql`SELECT broadcast FROM settings WHERE id = 1`;
return result[0].broadcast;
}
export async function disableCommand(guild, command) {
const guildDB = await this.getGuild(guild);
await sql`UPDATE guilds SET disabled_commands = ${(guildDB.disabled_commands ? [...guildDB.disabled_commands, command] : [command]).filter((v) => !!v)} WHERE guild_id = ${guild}`;
disabledCmdCache.set(guild, guildDB.disabled_commands ? [...guildDB.disabled_commands, command] : [command].filter((v) => !!v));
}
export async function enableCommand(guild, command) {
const guildDB = await this.getGuild(guild);
const newDisabled = guildDB.disabled_commands ? guildDB.disabled_commands.filter(item => item !== command) : [];
await sql`UPDATE guilds SET disabled_commands = ${newDisabled} WHERE guild_id = ${guild}`;
disabledCmdCache.set(guild, newDisabled);
}
export async function disableChannel(channel) {
const guildDB = await this.getGuild(channel.guildID);
await sql`UPDATE guilds SET disabled_commands = ${[...guildDB.disabled, channel.id]} WHERE guild_id = ${channel.guildID}`;
disabledCache.set(channel.guildID, [...guildDB.disabled, channel.id]);
}
export async function enableChannel(channel) {
const guildDB = await this.getGuild(channel.guildID);
const newDisabled = guildDB.disabled.filter(item => item !== channel.id);
await sql`UPDATE guilds SET disabled_commands = ${newDisabled} WHERE guild_id = ${channel.guildID}`;
disabledCache.set(channel.guildID, newDisabled);
}
export async function getCounts() {
const counts = await sql`SELECT * FROM counts`;
const countObject = {};
for (const { command, count } of counts) {
countObject[command] = count;
}
return countObject;
}
export async function addCount(command) {
await sql`INSERT INTO counts ${sql({ command, count: 1 }, "command", "count")} ON CONFLICT (command) DO UPDATE SET count = counts.count + 1 WHERE counts.command = ${command}`;
}
export async function stop() {
await sql.end();
}

View file

@ -1,194 +1,194 @@
import { commands, messageCommands, disabledCache, disabledCmdCache, prefixCache } from "../collections.js";
import sqlite3 from "better-sqlite3";
const connection = sqlite3(process.env.DB.replace("sqlite://", ""));
const schema = `
CREATE TABLE guilds (
guild_id VARCHAR(30) NOT NULL PRIMARY KEY,
prefix VARCHAR(15) NOT NULL,
disabled text NOT NULL,
disabled_commands text NOT NULL
);
CREATE TABLE counts (
command VARCHAR NOT NULL PRIMARY KEY,
count integer NOT NULL
);
CREATE TABLE tags (
guild_id VARCHAR(30) NOT NULL,
name text NOT NULL,
content text NOT NULL,
author VARCHAR(30) NOT NULL,
UNIQUE(guild_id, name)
);
CREATE TABLE settings (
id smallint PRIMARY KEY,
broadcast VARCHAR,
CHECK(id = 1)
);
INSERT INTO settings (id) VALUES (1);
`;
const updates = [
"", // reserved
"ALTER TABLE guilds ADD COLUMN accessed int",
"ALTER TABLE guilds DROP COLUMN accessed",
`CREATE TABLE settings (
id smallint PRIMARY KEY,
broadcast VARCHAR,
CHECK(id = 1)
);
INSERT INTO settings (id) VALUES (1);`,
];
export async function setup() {
const existingCommands = connection.prepare("SELECT command FROM counts").all().map(x => x.command);
const commandNames = [...commands.keys(), ...messageCommands.keys()];
for (const command of existingCommands) {
if (!commandNames.includes(command)) {
connection.prepare("DELETE FROM counts WHERE command = ?").run(command);
}
}
for (const command of commandNames) {
if (!existingCommands.includes(command)) {
connection.prepare("INSERT INTO counts (command, count) VALUES (?, ?)").run(command, 0);
}
}
}
export async function stop() {
connection.close();
}
export async function upgrade(logger) {
connection.exec("BEGIN TRANSACTION");
try {
let version = connection.pragma("user_version", { simple: true });
const latestVersion = updates.length - 1;
if (version == 0) {
logger.info("Initializing SQLite database...");
connection.exec(schema);
} else if (version < latestVersion) {
logger.info(`Migrating SQLite database at ${process.env.DB}, which is currently at version ${version}...`);
while (version < latestVersion) {
version++;
logger.info(`Running version ${version} update script...`);
connection.exec(updates[version]);
}
} else if (version > latestVersion) {
throw new Error(`SQLite database is at version ${version}, but this version of the bot only supports up to version ${latestVersion}.`);
} else {
return;
}
connection.pragma(`user_version = ${latestVersion}`); // prepared statements don't seem to work here
} catch (e) {
logger.error(`SQLite migration failed: ${e}`);
connection.exec("ROLLBACK");
logger.error("Unable to start the bot, quitting now.");
return 1;
}
connection.exec("COMMIT");
}
export async function addCount(command) {
connection.prepare("UPDATE counts SET count = count + 1 WHERE command = ?").run(command);
}
export async function getCounts() {
const counts = connection.prepare("SELECT * FROM counts").all();
const countObject = {};
for (const { command, count } of counts) {
countObject[command] = count;
}
return countObject;
}
export async function disableCommand(guild, command) {
const guildDB = await this.getGuild(guild);
connection.prepare("UPDATE guilds SET disabled_commands = ? WHERE guild_id = ?").run(JSON.stringify((guildDB.disabledCommands ? [...JSON.parse(guildDB.disabledCommands), command] : [command]).filter((v) => !!v)), guild);
disabledCmdCache.set(guild, guildDB.disabled_commands ? [...JSON.parse(guildDB.disabledCommands), command] : [command].filter((v) => !!v));
}
export async function enableCommand(guild, command) {
const guildDB = await this.getGuild(guild);
const newDisabled = guildDB.disabledCommands ? JSON.parse(guildDB.disabledCommands).filter(item => item !== command) : [];
connection.prepare("UPDATE guilds SET disabled_commands = ? WHERE guild_id = ?").run(JSON.stringify(newDisabled), guild);
disabledCmdCache.set(guild, newDisabled);
}
export async function disableChannel(channel) {
const guildDB = await this.getGuild(channel.guildID);
connection.prepare("UPDATE guilds SET disabled = ? WHERE guild_id = ?").run(JSON.stringify([...JSON.parse(guildDB.disabled), channel.id]), channel.guildID);
disabledCache.set(channel.guildID, [...JSON.parse(guildDB.disabled), channel.id]);
}
export async function enableChannel(channel) {
const guildDB = await this.getGuild(channel.guildID);
const newDisabled = JSON.parse(guildDB.disabled).filter(item => item !== channel.id);
connection.prepare("UPDATE guilds SET disabled = ? WHERE guild_id = ?").run(JSON.stringify(newDisabled), channel.guildID);
disabledCache.set(channel.guildID, newDisabled);
}
export async function getTag(guild, tag) {
const tagResult = connection.prepare("SELECT * FROM tags WHERE guild_id = ? AND name = ?").get(guild, tag);
return tagResult ? { content: tagResult.content, author: tagResult.author } : undefined;
}
export async function getTags(guild) {
const tagArray = connection.prepare("SELECT * FROM tags WHERE guild_id = ?").all(guild);
const tags = {};
if (!tagArray) return [];
for (const tag of tagArray) {
tags[tag.name] = { content: tag.content, author: tag.author };
}
return tags;
}
export async function setTag(name, content, guild) {
const tag = {
id: guild.id,
name: name,
content: content.content,
author: content.author
};
connection.prepare("INSERT INTO tags (guild_id, name, content, author) VALUES (@id, @name, @content, @author)").run(tag);
}
export async function removeTag(name, guild) {
connection.prepare("DELETE FROM tags WHERE guild_id = ? AND name = ?").run(guild.id, name);
}
export async function editTag(name, content, guild) {
connection.prepare("UPDATE tags SET content = ?, author = ? WHERE guild_id = ? AND name = ?").run(content.content, content.author, guild.id, name);
}
export async function setBroadcast(msg) {
connection.prepare("UPDATE settings SET broadcast = ? WHERE id = 1").run(msg);
}
export async function getBroadcast() {
const result = connection.prepare("SELECT broadcast FROM settings WHERE id = 1").all();
return result[0].broadcast;
}
export async function setPrefix(prefix, guild) {
connection.prepare("UPDATE guilds SET prefix = ? WHERE guild_id = ?").run(prefix, guild.id);
prefixCache.set(guild.id, prefix);
}
export async function getGuild(query) {
let guild;
connection.transaction(() => {
guild = connection.prepare("SELECT * FROM guilds WHERE guild_id = ?").get(query);
if (!guild) {
guild = {
id: query,
prefix: process.env.PREFIX,
disabled: "[]",
disabledCommands: "[]"
};
connection.prepare("INSERT INTO guilds (guild_id, prefix, disabled, disabled_commands) VALUES (@id, @prefix, @disabled, @disabledCommands)").run(guild);
}
})();
return guild;
}
import { commands, messageCommands, disabledCache, disabledCmdCache, prefixCache } from "../collections.js";
import sqlite3 from "better-sqlite3";
const connection = sqlite3(process.env.DB.replace("sqlite://", ""));
const schema = `
CREATE TABLE guilds (
guild_id VARCHAR(30) NOT NULL PRIMARY KEY,
prefix VARCHAR(15) NOT NULL,
disabled text NOT NULL,
disabled_commands text NOT NULL
);
CREATE TABLE counts (
command VARCHAR NOT NULL PRIMARY KEY,
count integer NOT NULL
);
CREATE TABLE tags (
guild_id VARCHAR(30) NOT NULL,
name text NOT NULL,
content text NOT NULL,
author VARCHAR(30) NOT NULL,
UNIQUE(guild_id, name)
);
CREATE TABLE settings (
id smallint PRIMARY KEY,
broadcast VARCHAR,
CHECK(id = 1)
);
INSERT INTO settings (id) VALUES (1);
`;
const updates = [
"", // reserved
"ALTER TABLE guilds ADD COLUMN accessed int",
"ALTER TABLE guilds DROP COLUMN accessed",
`CREATE TABLE settings (
id smallint PRIMARY KEY,
broadcast VARCHAR,
CHECK(id = 1)
);
INSERT INTO settings (id) VALUES (1);`,
];
export async function setup() {
const existingCommands = connection.prepare("SELECT command FROM counts").all().map(x => x.command);
const commandNames = [...commands.keys(), ...messageCommands.keys()];
for (const command of existingCommands) {
if (!commandNames.includes(command)) {
connection.prepare("DELETE FROM counts WHERE command = ?").run(command);
}
}
for (const command of commandNames) {
if (!existingCommands.includes(command)) {
connection.prepare("INSERT INTO counts (command, count) VALUES (?, ?)").run(command, 0);
}
}
}
export async function stop() {
connection.close();
}
export async function upgrade(logger) {
connection.exec("BEGIN TRANSACTION");
try {
let version = connection.pragma("user_version", { simple: true });
const latestVersion = updates.length - 1;
if (version == 0) {
logger.info("Initializing SQLite database...");
connection.exec(schema);
} else if (version < latestVersion) {
logger.info(`Migrating SQLite database at ${process.env.DB}, which is currently at version ${version}...`);
while (version < latestVersion) {
version++;
logger.info(`Running version ${version} update script...`);
connection.exec(updates[version]);
}
} else if (version > latestVersion) {
throw new Error(`SQLite database is at version ${version}, but this version of the bot only supports up to version ${latestVersion}.`);
} else {
return;
}
connection.pragma(`user_version = ${latestVersion}`); // prepared statements don't seem to work here
} catch (e) {
logger.error(`SQLite migration failed: ${e}`);
connection.exec("ROLLBACK");
logger.error("Unable to start the bot, quitting now.");
return 1;
}
connection.exec("COMMIT");
}
export async function addCount(command) {
connection.prepare("UPDATE counts SET count = count + 1 WHERE command = ?").run(command);
}
export async function getCounts() {
const counts = connection.prepare("SELECT * FROM counts").all();
const countObject = {};
for (const { command, count } of counts) {
countObject[command] = count;
}
return countObject;
}
export async function disableCommand(guild, command) {
const guildDB = await this.getGuild(guild);
connection.prepare("UPDATE guilds SET disabled_commands = ? WHERE guild_id = ?").run(JSON.stringify((guildDB.disabledCommands ? [...JSON.parse(guildDB.disabledCommands), command] : [command]).filter((v) => !!v)), guild);
disabledCmdCache.set(guild, guildDB.disabled_commands ? [...JSON.parse(guildDB.disabledCommands), command] : [command].filter((v) => !!v));
}
export async function enableCommand(guild, command) {
const guildDB = await this.getGuild(guild);
const newDisabled = guildDB.disabledCommands ? JSON.parse(guildDB.disabledCommands).filter(item => item !== command) : [];
connection.prepare("UPDATE guilds SET disabled_commands = ? WHERE guild_id = ?").run(JSON.stringify(newDisabled), guild);
disabledCmdCache.set(guild, newDisabled);
}
export async function disableChannel(channel) {
const guildDB = await this.getGuild(channel.guildID);
connection.prepare("UPDATE guilds SET disabled = ? WHERE guild_id = ?").run(JSON.stringify([...JSON.parse(guildDB.disabled), channel.id]), channel.guildID);
disabledCache.set(channel.guildID, [...JSON.parse(guildDB.disabled), channel.id]);
}
export async function enableChannel(channel) {
const guildDB = await this.getGuild(channel.guildID);
const newDisabled = JSON.parse(guildDB.disabled).filter(item => item !== channel.id);
connection.prepare("UPDATE guilds SET disabled = ? WHERE guild_id = ?").run(JSON.stringify(newDisabled), channel.guildID);
disabledCache.set(channel.guildID, newDisabled);
}
export async function getTag(guild, tag) {
const tagResult = connection.prepare("SELECT * FROM tags WHERE guild_id = ? AND name = ?").get(guild, tag);
return tagResult ? { content: tagResult.content, author: tagResult.author } : undefined;
}
export async function getTags(guild) {
const tagArray = connection.prepare("SELECT * FROM tags WHERE guild_id = ?").all(guild);
const tags = {};
if (!tagArray) return [];
for (const tag of tagArray) {
tags[tag.name] = { content: tag.content, author: tag.author };
}
return tags;
}
export async function setTag(name, content, guild) {
const tag = {
id: guild.id,
name: name,
content: content.content,
author: content.author
};
connection.prepare("INSERT INTO tags (guild_id, name, content, author) VALUES (@id, @name, @content, @author)").run(tag);
}
export async function removeTag(name, guild) {
connection.prepare("DELETE FROM tags WHERE guild_id = ? AND name = ?").run(guild.id, name);
}
export async function editTag(name, content, guild) {
connection.prepare("UPDATE tags SET content = ?, author = ? WHERE guild_id = ? AND name = ?").run(content.content, content.author, guild.id, name);
}
export async function setBroadcast(msg) {
connection.prepare("UPDATE settings SET broadcast = ? WHERE id = 1").run(msg);
}
export async function getBroadcast() {
const result = connection.prepare("SELECT broadcast FROM settings WHERE id = 1").all();
return result[0].broadcast;
}
export async function setPrefix(prefix, guild) {
connection.prepare("UPDATE guilds SET prefix = ? WHERE guild_id = ?").run(prefix, guild.id);
prefixCache.set(guild.id, prefix);
}
export async function getGuild(query) {
let guild;
connection.transaction(() => {
guild = connection.prepare("SELECT * FROM guilds WHERE guild_id = ?").get(query);
if (!guild) {
guild = {
id: query,
prefix: process.env.PREFIX,
disabled: "[]",
disabledCommands: "[]"
};
connection.prepare("INSERT INTO guilds (guild_id, prefix, disabled, disabled_commands) VALUES (@id, @prefix, @disabled, @disabledCommands)").run(guild);
}
})();
return guild;
}

View file

@ -1,126 +1,126 @@
import { paths, commands, messageCommands, info, sounds, categories, aliases as _aliases } from "./collections.js";
import { log } from "./logger.js";
import { readFileSync } from "fs";
const { blacklist } = JSON.parse(readFileSync(new URL("../config/commands.json", import.meta.url)));
let queryValue = 0;
// load command into memory
export async function load(client, command, slashReload = false) {
const { default: props } = await import(`${command}?v=${queryValue}`);
queryValue++;
const commandArray = command.split("/");
let commandName = commandArray[commandArray.length - 1].split(".")[0];
const category = commandArray[commandArray.length - 2];
if (blacklist.includes(commandName)) {
log("warn", `Skipped loading blacklisted command ${command}...`);
return;
}
if (category === "message") {
const nameStringArray = commandName.split("-");
for (const index of nameStringArray.keys()) {
nameStringArray[index] = nameStringArray[index].charAt(0).toUpperCase() + nameStringArray[index].slice(1);
}
commandName = nameStringArray.join(" ");
}
props.init();
paths.set(commandName, command);
const commandInfo = {
category: category,
description: props.description,
aliases: props.aliases,
params: props.arguments,
flags: props.flags,
slashAllowed: props.slashAllowed,
directAllowed: props.directAllowed,
adminOnly: props.adminOnly,
type: 1
};
if (category === "message") {
messageCommands.set(commandName, props);
commandInfo.type = 3;
} else {
commands.set(commandName, props);
}
if (slashReload && props.slashAllowed) {
await send(client);
}
if (Object.getPrototypeOf(props).name === "SoundboardCommand") sounds.set(commandName, props.file);
info.set(commandName, commandInfo);
const categoryCommands = categories.get(category);
categories.set(category, categoryCommands ? [...categoryCommands, commandName] : [commandName]);
if (props.aliases) {
for (const alias of props.aliases) {
_aliases.set(alias, commandName);
paths.set(alias, command);
}
}
return commandName;
}
export function update() {
const commandArray = [];
const privateCommandArray = [];
const merged = new Map([...commands, ...messageCommands]);
for (const [name, command] of merged.entries()) {
let cmdInfo = info.get(name);
if (command.postInit) {
const cmd = command.postInit();
cmdInfo = {
category: cmdInfo.category,
description: cmd.description,
aliases: cmd.aliases,
params: cmd.arguments,
flags: cmd.flags,
slashAllowed: cmd.slashAllowed,
directAllowed: cmd.directAllowed,
adminOnly: cmd.adminOnly,
type: cmdInfo.type
};
info.set(name, cmdInfo);
}
if (cmdInfo?.type === 3) {
(cmdInfo.adminOnly ? privateCommandArray : commandArray).push({
name: name,
type: cmdInfo.type,
dm_permission: cmdInfo.directAllowed
});
} else if (cmdInfo?.slashAllowed) {
(cmdInfo.adminOnly ? privateCommandArray : commandArray).push({
name,
type: cmdInfo.type,
description: cmdInfo.description,
options: cmdInfo.flags,
dm_permission: cmdInfo.directAllowed
});
}
}
return {
main: commandArray,
private: privateCommandArray
};
}
export async function send(bot) {
const commandArray = update();
log("info", "Sending application command data to Discord...");
let cmdArray = commandArray.main;
if (process.env.ADMIN_SERVER && process.env.ADMIN_SERVER !== "") {
await bot.application.bulkEditGuildCommands(process.env.ADMIN_SERVER, commandArray.private);
} else {
cmdArray = [...commandArray.main, ...commandArray.private];
}
await bot.application.bulkEditGlobalCommands(cmdArray);
import { paths, commands, messageCommands, info, sounds, categories, aliases as _aliases } from "./collections.js";
import { log } from "./logger.js";
import { readFileSync } from "fs";
const { blacklist } = JSON.parse(readFileSync(new URL("../config/commands.json", import.meta.url)));
let queryValue = 0;
// load command into memory
export async function load(client, command, slashReload = false) {
const { default: props } = await import(`${command}?v=${queryValue}`);
queryValue++;
const commandArray = command.split("/");
let commandName = commandArray[commandArray.length - 1].split(".")[0];
const category = commandArray[commandArray.length - 2];
if (blacklist.includes(commandName)) {
log("warn", `Skipped loading blacklisted command ${command}...`);
return;
}
if (category === "message") {
const nameStringArray = commandName.split("-");
for (const index of nameStringArray.keys()) {
nameStringArray[index] = nameStringArray[index].charAt(0).toUpperCase() + nameStringArray[index].slice(1);
}
commandName = nameStringArray.join(" ");
}
props.init();
paths.set(commandName, command);
const commandInfo = {
category: category,
description: props.description,
aliases: props.aliases,
params: props.arguments,
flags: props.flags,
slashAllowed: props.slashAllowed,
directAllowed: props.directAllowed,
adminOnly: props.adminOnly,
type: 1
};
if (category === "message") {
messageCommands.set(commandName, props);
commandInfo.type = 3;
} else {
commands.set(commandName, props);
}
if (slashReload && props.slashAllowed) {
await send(client);
}
if (Object.getPrototypeOf(props).name === "SoundboardCommand") sounds.set(commandName, props.file);
info.set(commandName, commandInfo);
const categoryCommands = categories.get(category);
categories.set(category, categoryCommands ? [...categoryCommands, commandName] : [commandName]);
if (props.aliases) {
for (const alias of props.aliases) {
_aliases.set(alias, commandName);
paths.set(alias, command);
}
}
return commandName;
}
export function update() {
const commandArray = [];
const privateCommandArray = [];
const merged = new Map([...commands, ...messageCommands]);
for (const [name, command] of merged.entries()) {
let cmdInfo = info.get(name);
if (command.postInit) {
const cmd = command.postInit();
cmdInfo = {
category: cmdInfo.category,
description: cmd.description,
aliases: cmd.aliases,
params: cmd.arguments,
flags: cmd.flags,
slashAllowed: cmd.slashAllowed,
directAllowed: cmd.directAllowed,
adminOnly: cmd.adminOnly,
type: cmdInfo.type
};
info.set(name, cmdInfo);
}
if (cmdInfo?.type === 3) {
(cmdInfo.adminOnly ? privateCommandArray : commandArray).push({
name: name,
type: cmdInfo.type,
dm_permission: cmdInfo.directAllowed
});
} else if (cmdInfo?.slashAllowed) {
(cmdInfo.adminOnly ? privateCommandArray : commandArray).push({
name,
type: cmdInfo.type,
description: cmdInfo.description,
options: cmdInfo.flags,
dm_permission: cmdInfo.directAllowed
});
}
}
return {
main: commandArray,
private: privateCommandArray
};
}
export async function send(bot) {
const commandArray = update();
log("info", "Sending application command data to Discord...");
let cmdArray = commandArray.main;
if (process.env.ADMIN_SERVER && process.env.ADMIN_SERVER !== "") {
await bot.application.bulkEditGuildCommands(process.env.ADMIN_SERVER, commandArray.private);
} else {
cmdArray = [...commandArray.main, ...commandArray.private];
}
await bot.application.bulkEditGlobalCommands(cmdArray);
}

View file

@ -1,69 +1,69 @@
import { commands, info } from "./collections.js";
import { promises } from "fs";
export 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"]
};
export let categories = categoryTemplate;
export let generated = false;
export function generateList() {
categories = categoryTemplate;
for (const [command] of commands) {
const category = info.get(command).category;
const description = info.get(command).description;
const params = info.get(command).params;
if (category === "tags") {
const subCommands = info.get(command).flags;
categories.tags.push(`**tags** ${params.default} - ${description}`);
for (const subCommand of subCommands) {
categories.tags.push(`**tags ${subCommand.name}**${params[subCommand.name] ? ` ${params[subCommand.name].join(" ")}` : ""} - ${subCommand.description}`);
}
} else {
if (!categories[category]) categories[category] = [];
categories[category].push(`**${command}**${params ? ` ${params.join(" ")}` : ""} - ${description}`);
}
}
generated = true;
}
export async function createPage(output) {
let template = `# <img src="https://raw.githubusercontent.com/esmBot/esmBot/master/docs/assets/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.
**Want to help support esmBot's development? Consider donating on Patreon!** https://patreon.com/TheEssem
`;
template += "\n## Table of Contents\n";
for (const category of Object.keys(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(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 categories[category]) {
if (command.startsWith(">")) {
template += `${command}\n`;
} else {
template += `+ ${command}\n`;
}
}
}
await promises.writeFile(output, template);
import { commands, info } from "./collections.js";
import { promises } from "fs";
export 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"]
};
export let categories = categoryTemplate;
export let generated = false;
export function generateList() {
categories = categoryTemplate;
for (const [command] of commands) {
const category = info.get(command).category;
const description = info.get(command).description;
const params = info.get(command).params;
if (category === "tags") {
const subCommands = info.get(command).flags;
categories.tags.push(`**tags** ${params.default} - ${description}`);
for (const subCommand of subCommands) {
categories.tags.push(`**tags ${subCommand.name}**${params[subCommand.name] ? ` ${params[subCommand.name].join(" ")}` : ""} - ${subCommand.description}`);
}
} else {
if (!categories[category]) categories[category] = [];
categories[category].push(`**${command}**${params ? ` ${params.join(" ")}` : ""} - ${description}`);
}
}
generated = true;
}
export async function createPage(output) {
let template = `# <img src="https://raw.githubusercontent.com/esmBot/esmBot/master/docs/assets/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.
**Want to help support esmBot's development? Consider donating on Patreon!** https://patreon.com/TheEssem
`;
template += "\n## Table of Contents\n";
for (const category of Object.keys(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(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 categories[category]) {
if (command.startsWith(">")) {
template += `${command}\n`;
} else {
template += `+ ${command}\n`;
}
}
}
await promises.writeFile(output, template);
}

View file

@ -1,73 +1,73 @@
import { createRequire } from "module";
import { isMainThread, parentPort, workerData } from "worker_threads";
import { request } from "undici";
import path from "path";
import { fileURLToPath } from "url";
const nodeRequire = createRequire(import.meta.url);
const relPath = `../build/${process.env.DEBUG && process.env.DEBUG === "true" ? "Debug" : "Release"}/image.node`;
const img = nodeRequire(relPath);
const enumMap = {
"forget": 0,
"northwest": 1,
"north": 2,
"northeast": 3,
"west": 4,
"center": 5,
"east": 6,
"southwest": 7,
"south": 8,
"southeast": 9
};
export default function run(object) {
return new Promise((resolve, reject) => {
// If the image has a path, it must also have a type
let promise = Promise.resolve();
if (object.path) {
if (object.params.type !== "image/gif" && object.onlyGIF) resolve({
buffer: Buffer.alloc(0),
fileExtension: "nogif"
});
promise = request(object.path).then(res => res.body.arrayBuffer()).then(buf => Buffer.from(buf));
}
// Convert from a MIME type (e.g. "image/png") to something the image processor understands (e.g. "png").
// Don't set `type` directly on the object we are passed as it will be read afterwards.
// If no image type is given (say, the command generates its own image), make it a PNG.
const fileExtension = object.params.type ? object.params.type.split("/")[1] : "png";
promise.then(buf => {
if (buf) object.params.data = buf;
const objectWithFixedType = Object.assign({}, object.params, { type: fileExtension });
if (objectWithFixedType.gravity) {
if (isNaN(objectWithFixedType.gravity)) {
objectWithFixedType.gravity = enumMap[objectWithFixedType.gravity];
}
}
objectWithFixedType.basePath = path.join(path.dirname(fileURLToPath(import.meta.url)), "../");
try {
const result = img.image(object.cmd, objectWithFixedType);
const returnObject = {
buffer: result.data,
fileExtension: result.type
};
resolve(returnObject);
} catch (e) {
reject(e);
}
});
});
}
if (!isMainThread) {
run(workerData)
.then(returnObject => {
parentPort.postMessage(returnObject);
process.exit();
})
.catch(err => {
// turn promise rejection into normal error
throw err;
});
}
import { createRequire } from "module";
import { isMainThread, parentPort, workerData } from "worker_threads";
import { request } from "undici";
import path from "path";
import { fileURLToPath } from "url";
const nodeRequire = createRequire(import.meta.url);
const relPath = `../build/${process.env.DEBUG && process.env.DEBUG === "true" ? "Debug" : "Release"}/image.node`;
const img = nodeRequire(relPath);
const enumMap = {
"forget": 0,
"northwest": 1,
"north": 2,
"northeast": 3,
"west": 4,
"center": 5,
"east": 6,
"southwest": 7,
"south": 8,
"southeast": 9
};
export default function run(object) {
return new Promise((resolve, reject) => {
// If the image has a path, it must also have a type
let promise = Promise.resolve();
if (object.path) {
if (object.params.type !== "image/gif" && object.onlyGIF) resolve({
buffer: Buffer.alloc(0),
fileExtension: "nogif"
});
promise = request(object.path).then(res => res.body.arrayBuffer()).then(buf => Buffer.from(buf));
}
// Convert from a MIME type (e.g. "image/png") to something the image processor understands (e.g. "png").
// Don't set `type` directly on the object we are passed as it will be read afterwards.
// If no image type is given (say, the command generates its own image), make it a PNG.
const fileExtension = object.params.type ? object.params.type.split("/")[1] : "png";
promise.then(buf => {
if (buf) object.params.data = buf;
const objectWithFixedType = Object.assign({}, object.params, { type: fileExtension });
if (objectWithFixedType.gravity) {
if (isNaN(objectWithFixedType.gravity)) {
objectWithFixedType.gravity = enumMap[objectWithFixedType.gravity];
}
}
objectWithFixedType.basePath = path.join(path.dirname(fileURLToPath(import.meta.url)), "../");
try {
const result = img.image(object.cmd, objectWithFixedType);
const returnObject = {
buffer: result.data,
fileExtension: result.type
};
resolve(returnObject);
} catch (e) {
reject(e);
}
});
});
}
if (!isMainThread) {
run(workerData)
.then(returnObject => {
parentPort.postMessage(returnObject);
process.exit();
})
.catch(err => {
// turn promise rejection into normal error
throw err;
});
}

View file

@ -1,170 +1,170 @@
import { request } from "undici";
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 * 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"];
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 async function getType(image, extraReturnTypes) {
if (!image.startsWith("http")) {
const imageType = await fileTypeFromFile(image);
if (imageType && formats.includes(imageType.mime)) {
return imageType.mime;
}
return undefined;
}
let type;
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 3000);
try {
const imageRequest = await request(image, {
signal: controller.signal,
method: "HEAD"
});
clearTimeout(timeout);
const size = imageRequest.headers["content-range"] ? imageRequest.headers["content-range"].split("/")[1] : imageRequest.headers["content-length"];
if (parseInt(size) > 26214400 && extraReturnTypes) { // 25 MB
type = "large";
return type;
}
const typeHeader = imageRequest.headers["content-type"];
if (typeHeader) {
type = typeHeader;
} else {
const timeout = setTimeout(() => {
controller.abort();
}, 3000);
const bufRequest = await request(image, {
signal: controller.signal,
headers: {
range: "bytes=0-1023"
}
});
clearTimeout(timeout);
const imageBuffer = await bufRequest.body.arrayBuffer();
const imageType = await fileTypeFromBuffer(imageBuffer);
if (imageType && formats.includes(imageType.mime)) {
type = imageType.mime;
}
}
} catch (error) {
if (error.name === "AbortError") {
throw Error("Timed out");
} else {
throw error;
}
} finally {
clearTimeout(timeout);
}
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);
}
import { request } from "undici";
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 * 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"];
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 async function getType(image, extraReturnTypes) {
if (!image.startsWith("http")) {
const imageType = await fileTypeFromFile(image);
if (imageType && formats.includes(imageType.mime)) {
return imageType.mime;
}
return undefined;
}
let type;
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 3000);
try {
const imageRequest = await request(image, {
signal: controller.signal,
method: "HEAD"
});
clearTimeout(timeout);
const size = imageRequest.headers["content-range"] ? imageRequest.headers["content-range"].split("/")[1] : imageRequest.headers["content-length"];
if (parseInt(size) > 26214400 && extraReturnTypes) { // 25 MB
type = "large";
return type;
}
const typeHeader = imageRequest.headers["content-type"];
if (typeHeader) {
type = typeHeader;
} else {
const timeout = setTimeout(() => {
controller.abort();
}, 3000);
const bufRequest = await request(image, {
signal: controller.signal,
headers: {
range: "bytes=0-1023"
}
});
clearTimeout(timeout);
const imageBuffer = await bufRequest.body.arrayBuffer();
const imageType = await fileTypeFromBuffer(imageBuffer);
if (imageType && formats.includes(imageType.mime)) {
type = imageType.mime;
}
}
} catch (error) {
if (error.name === "AbortError") {
throw Error("Timed out");
} else {
throw error;
}
} finally {
clearTimeout(timeout);
}
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

@ -1,171 +1,171 @@
import { request } from "undici";
import WebSocket from "ws";
import * as logger from "./logger.js";
import { setTimeout } from "timers/promises";
const Rerror = 0x01;
const Tqueue = 0x02;
const Rqueue = 0x03;
const Tcancel = 0x04;
const Rcancel = 0x05;
const Twait = 0x06;
const Rwait = 0x07;
const Rinit = 0x08;
class ImageConnection {
constructor(host, auth, tls = false) {
this.requests = new Map();
if (!host.includes(":")) {
host += ":3762";
}
this.host = host;
this.auth = auth;
this.tag = 0;
this.disconnected = false;
this.formats = {};
this.wsproto = null;
if (tls) {
this.wsproto = "wss";
} else {
this.wsproto = "ws";
}
this.sockurl = `${this.wsproto}://${host}/sock`;
const headers = {};
if (auth) {
headers.Authentication = auth;
}
this.conn = new WebSocket(this.sockurl, { headers });
let httpproto;
if (tls) {
httpproto = "https";
} else {
httpproto = "http";
}
this.httpurl = `${httpproto}://${host}`;
this.conn.on("message", (msg) => this.onMessage(msg));
this.conn.once("error", (err) => this.onError(err));
this.conn.once("close", () => this.onClose());
}
async onMessage(msg) {
const op = msg.readUint8(0);
if (op === Rinit) {
this.formats = JSON.parse(msg.toString("utf8", 7));
return;
}
const tag = msg.readUint16LE(1);
const promise = this.requests.get(tag);
if (!promise) {
logger.error(`Received response for unknown request ${tag}`);
return;
}
this.requests.delete(tag);
if (op === Rerror) {
promise.reject(new Error(msg.slice(3, msg.length).toString()));
return;
}
promise.resolve();
}
onError(e) {
logger.error(e.toString());
}
async onClose() {
for (const [tag, obj] of this.requests.entries()) {
obj.reject("Request ended prematurely due to a closed connection");
this.requests.delete(tag);
}
if (!this.disconnected) {
logger.warn(`Lost connection to ${this.host}, attempting to reconnect in 5 seconds...`);
await setTimeout(5000);
this.conn = new WebSocket(this.sockurl, {
headers: {
"Authentication": this.auth
}
});
this.conn.on("message", (msg) => this.onMessage(msg));
this.conn.once("error", (err) => this.onError(err));
this.conn.once("close", () => this.onClose());
}
this.disconnected = false;
}
close() {
this.disconnected = true;
this.conn.close();
}
queue(jobid, jobobj) {
const str = JSON.stringify(jobobj);
const buf = Buffer.alloc(8);
buf.writeBigUint64LE(jobid);
return this.do(Tqueue, jobid, Buffer.concat([buf, Buffer.from(str)]));
}
wait(jobid) {
const buf = Buffer.alloc(8);
buf.writeBigUint64LE(jobid);
return this.do(Twait, jobid, buf);
}
cancel(jobid) {
const buf = Buffer.alloc(8);
buf.writeBigUint64LE(jobid);
return this.do(Tcancel, jobid, buf);
}
async getOutput(jobid) {
const req = await request(`${this.httpurl}/image?id=${jobid}`, {
headers: {
authentication: this.auth || undefined
}
});
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;
}
return { buffer: Buffer.from(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) {
const buf = Buffer.alloc(1 + 2);
let tag = this.tag++;
if (tag > 65535) tag = this.tag = 0;
buf.writeUint8(op);
buf.writeUint16LE(tag, 1);
this.conn.send(Buffer.concat([buf, data]));
const promise = new Promise((resolve, reject) => {
this.requests.set(tag, { resolve, reject, id, op });
});
return promise;
}
}
export default ImageConnection;
import { request } from "undici";
import WebSocket from "ws";
import * as logger from "./logger.js";
import { setTimeout } from "timers/promises";
const Rerror = 0x01;
const Tqueue = 0x02;
const Rqueue = 0x03;
const Tcancel = 0x04;
const Rcancel = 0x05;
const Twait = 0x06;
const Rwait = 0x07;
const Rinit = 0x08;
class ImageConnection {
constructor(host, auth, tls = false) {
this.requests = new Map();
if (!host.includes(":")) {
host += ":3762";
}
this.host = host;
this.auth = auth;
this.tag = 0;
this.disconnected = false;
this.formats = {};
this.wsproto = null;
if (tls) {
this.wsproto = "wss";
} else {
this.wsproto = "ws";
}
this.sockurl = `${this.wsproto}://${host}/sock`;
const headers = {};
if (auth) {
headers.Authentication = auth;
}
this.conn = new WebSocket(this.sockurl, { headers });
let httpproto;
if (tls) {
httpproto = "https";
} else {
httpproto = "http";
}
this.httpurl = `${httpproto}://${host}`;
this.conn.on("message", (msg) => this.onMessage(msg));
this.conn.once("error", (err) => this.onError(err));
this.conn.once("close", () => this.onClose());
}
async onMessage(msg) {
const op = msg.readUint8(0);
if (op === Rinit) {
this.formats = JSON.parse(msg.toString("utf8", 7));
return;
}
const tag = msg.readUint16LE(1);
const promise = this.requests.get(tag);
if (!promise) {
logger.error(`Received response for unknown request ${tag}`);
return;
}
this.requests.delete(tag);
if (op === Rerror) {
promise.reject(new Error(msg.slice(3, msg.length).toString()));
return;
}
promise.resolve();
}
onError(e) {
logger.error(e.toString());
}
async onClose() {
for (const [tag, obj] of this.requests.entries()) {
obj.reject("Request ended prematurely due to a closed connection");
this.requests.delete(tag);
}
if (!this.disconnected) {
logger.warn(`Lost connection to ${this.host}, attempting to reconnect in 5 seconds...`);
await setTimeout(5000);
this.conn = new WebSocket(this.sockurl, {
headers: {
"Authentication": this.auth
}
});
this.conn.on("message", (msg) => this.onMessage(msg));
this.conn.once("error", (err) => this.onError(err));
this.conn.once("close", () => this.onClose());
}
this.disconnected = false;
}
close() {
this.disconnected = true;
this.conn.close();
}
queue(jobid, jobobj) {
const str = JSON.stringify(jobobj);
const buf = Buffer.alloc(8);
buf.writeBigUint64LE(jobid);
return this.do(Tqueue, jobid, Buffer.concat([buf, Buffer.from(str)]));
}
wait(jobid) {
const buf = Buffer.alloc(8);
buf.writeBigUint64LE(jobid);
return this.do(Twait, jobid, buf);
}
cancel(jobid) {
const buf = Buffer.alloc(8);
buf.writeBigUint64LE(jobid);
return this.do(Tcancel, jobid, buf);
}
async getOutput(jobid) {
const req = await request(`${this.httpurl}/image?id=${jobid}`, {
headers: {
authentication: this.auth || undefined
}
});
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;
}
return { buffer: Buffer.from(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) {
const buf = Buffer.alloc(1 + 2);
let tag = this.tag++;
if (tag > 65535) tag = this.tag = 0;
buf.writeUint8(op);
buf.writeUint16LE(tag, 1);
this.conn.send(Buffer.concat([buf, data]));
const promise = new Promise((resolve, reject) => {
this.requests.set(tag, { resolve, reject, id, op });
});
return promise;
}
}
export default ImageConnection;

View file

@ -1,189 +1,190 @@
import { request } from "undici";
import { getType } from "./image.js";
const tenorURLs = [
"tenor.com",
"www.tenor.com"
];
const giphyURLs = [
"giphy.com",
"www.giphy.com",
"i.giphy.com"
];
const giphyMediaURLs = [ // there could be more of these
"media.giphy.com",
"media0.giphy.com",
"media1.giphy.com",
"media2.giphy.com",
"media3.giphy.com",
"media4.giphy.com"
];
const imgurURLs = [
"imgur.com",
"www.imgur.com",
"i.imgur.com"
];
const gfycatURLs = [
"gfycat.com",
"www.gfycat.com",
"thumbs.gfycat.com",
"giant.gfycat.com"
];
const combined = [...tenorURLs, ...giphyURLs, ...giphyMediaURLs, ...imgurURLs, ...gfycatURLs];
const imageFormats = ["image/jpeg", "image/png", "image/webp", "image/gif", "large"];
const videoFormats = ["video/mp4", "video/webm", "video/mov"];
// gets the proper image paths
const getImage = async (image, image2, video, extraReturnTypes, gifv = false, type = null, link = false) => {
try {
const fileNameSplit = new URL(image).pathname.split("/");
const fileName = fileNameSplit[fileNameSplit.length - 1];
const fileNameNoExtension = fileName.slice(0, fileName.lastIndexOf("."));
const payload = {
url: image2,
path: image,
name: fileNameNoExtension
};
const host = new URL(image2).host;
if (gifv || (link && combined.includes(host))) {
if (tenorURLs.includes(host)) {
// Tenor doesn't let us access a raw GIF without going through their API,
// so we use that if there's a key in the config
if (process.env.TENOR !== "") {
let id;
if (image2.includes("tenor.com/view/")) {
id = image2.split("-").pop();
} else if (image2.endsWith(".gif")) {
const redirect = (await request(image2, { method: "HEAD" })).headers.location;
id = redirect.split("-").pop();
}
const data = await request(`https://tenor.googleapis.com/v2/posts?ids=${id}&media_filter=gif&limit=1&client_key=esmBot%20${process.env.ESMBOT_VER}&key=${process.env.TENOR}`);
if (data.statusCode === 429) {
if (extraReturnTypes) {
payload.type = "tenorlimit";
return payload;
} else {
return;
}
}
const json = await data.body.json();
if (json.error) throw Error(json.error.message);
payload.path = json.results[0].media_formats.gif.url;
}
} else if (giphyURLs.includes(host)) {
// Can result in an HTML page instead of a GIF
payload.path = `https://media0.giphy.com/media/${image2.split("/")[4].split("-").pop()}/giphy.gif`;
} else if (giphyMediaURLs.includes(host)) {
payload.path = `https://media0.giphy.com/media/${image2.split("/")[4]}/giphy.gif`;
} else if (imgurURLs.includes(host)) {
// Seems that Imgur has a possibility of making GIFs static
payload.path = image.replace(".mp4", ".gif");
} else if (gfycatURLs.includes(host)) {
// iirc Gfycat also seems to sometimes make GIFs static
if (link) {
const data = await request(`https://api.gfycat.com/v1/gfycats/${image.split("/").pop().split(".mp4")[0]}`);
const json = await data.body.json();
if (json.errorMessage) throw Error(json.errorMessage);
payload.path = json.gfyItem.gifUrl;
} else {
payload.path = `https://thumbs.gfycat.com/${image.split("/").pop().split(".mp4")[0]}-size_restricted.gif`;
}
}
payload.type = "image/gif";
} else if (video) {
payload.type = type ?? await getType(payload.path, extraReturnTypes);
if (!payload.type || (!videoFormats.includes(payload.type) && !imageFormats.includes(payload.type))) return;
} else {
payload.type = type ?? await getType(payload.path, extraReturnTypes);
if (!payload.type || !imageFormats.includes(payload.type)) return;
}
return payload;
} catch (error) {
if (error.name === "AbortError") {
throw Error("Timed out");
} else {
throw error;
}
}
};
const checkImages = async (message, extraReturnTypes, video, sticker) => {
let type;
if (sticker && message.stickerItems) {
type = message.stickerItems[0];
} else {
// first check the embeds
if (message.embeds.length !== 0) {
// embeds can vary in types, we check for tenor gifs first
if (message.embeds[0].type === "gifv") {
type = await getImage(message.embeds[0].video.url, message.embeds[0].url, video, extraReturnTypes, true);
// then we check for other image types
} else if ((message.embeds[0].type === "video" || message.embeds[0].type === "image") && message.embeds[0].thumbnail) {
type = await getImage(message.embeds[0].thumbnail.proxyURL, message.embeds[0].thumbnail.url, video, extraReturnTypes);
// finally we check both possible image fields for "generic" embeds
} else if (message.embeds[0].type === "rich" || message.embeds[0].type === "article") {
if (message.embeds[0].thumbnail) {
type = await getImage(message.embeds[0].thumbnail.proxyURL, message.embeds[0].thumbnail.url, video, extraReturnTypes);
} else if (message.embeds[0].image) {
type = await getImage(message.embeds[0].image.proxyURL, message.embeds[0].image.url, video, extraReturnTypes);
}
}
// then check the attachments
} else if (message.attachments.size !== 0 && message.attachments.first().width) {
type = await getImage(message.attachments.first().proxyURL, message.attachments.first().url, video);
}
}
// if the return value exists then return it
return type ?? false;
};
// this checks for the latest message containing an image and returns the url of the image
export default async (client, cmdMessage, interaction, options, extraReturnTypes = false, video = false, sticker = false, singleMessage = false) => {
// we start by determining whether or not we're dealing with an interaction or a message
if (interaction) {
// we can get a raw attachment or a URL in the interaction itself
if (options) {
if (options.image) {
const attachment = interaction.data.resolved.attachments.get(options.image);
const result = await getImage(attachment.proxyURL, attachment.url, video, attachment.contentType);
if (result !== false) return result;
} else if (options.link) {
const result = await getImage(options.link, options.link, video, extraReturnTypes, false, null, true);
if (result !== false) return result;
}
}
}
if (cmdMessage) {
// check if the message is a reply to another message
if (cmdMessage.messageReference && !singleMessage) {
const replyMessage = await client.rest.channels.getMessage(cmdMessage.messageReference.channelID, cmdMessage.messageReference.messageID).catch(() => undefined);
if (replyMessage) {
const replyResult = await checkImages(replyMessage, extraReturnTypes, video, sticker);
if (replyResult !== false) return replyResult;
}
}
// then we check the current message
const result = await checkImages(cmdMessage, extraReturnTypes, video, sticker);
if (result !== false) return result;
}
if (!singleMessage) {
// if there aren't any replies or interaction attachments then iterate over the last few messages in the channel
try {
const channel = (interaction ? interaction : cmdMessage).channel ?? await client.rest.channels.get((interaction ? interaction : cmdMessage).channelID);
const messages = await channel.getMessages();
// iterate over each message
for (const message of messages) {
const result = await checkImages(message, extraReturnTypes, video, sticker);
if (result === false) {
continue;
} else {
return result;
}
}
} catch {
// no-op
}
}
};
import { request } from "undici";
import { getType } from "./image.js";
const tenorURLs = [
"tenor.com",
"www.tenor.com"
];
const giphyURLs = [
"giphy.com",
"www.giphy.com",
"i.giphy.com"
];
const giphyMediaURLs = [ // there could be more of these
"media.giphy.com",
"media0.giphy.com",
"media1.giphy.com",
"media2.giphy.com",
"media3.giphy.com",
"media4.giphy.com"
];
const imgurURLs = [
"imgur.com",
"www.imgur.com",
"i.imgur.com"
];
const gfycatURLs = [
"gfycat.com",
"www.gfycat.com",
"thumbs.gfycat.com",
"giant.gfycat.com"
];
const combined = [...tenorURLs, ...giphyURLs, ...giphyMediaURLs, ...imgurURLs, ...gfycatURLs];
const imageFormats = ["image/jpeg", "image/png", "image/webp", "image/gif", "large"];
const videoFormats = ["video/mp4", "video/webm", "video/mov"];
// gets the proper image paths
const getImage = async (image, image2, video, extraReturnTypes, gifv = false, type = null, link = false) => {
try {
const fileNameSplit = new URL(image).pathname.split("/");
const fileName = fileNameSplit[fileNameSplit.length - 1];
const fileNameNoExtension = fileName.slice(0, fileName.lastIndexOf("."));
const payload = {
url: image2,
path: image,
name: fileNameNoExtension
};
const host = new URL(image2).host;
if (gifv || (link && combined.includes(host))) {
if (tenorURLs.includes(host)) {
// Tenor doesn't let us access a raw GIF without going through their API,
// so we use that if there's a key in the config
if (process.env.TENOR !== "") {
let id;
if (image2.includes("tenor.com/view/")) {
id = image2.split("-").pop();
} else if (image2.endsWith(".gif")) {
const redirect = (await request(image2, { method: "HEAD" })).headers.location;
id = redirect.split("-").pop();
}
const data = await request(`https://tenor.googleapis.com/v2/posts?ids=${id}&media_filter=gif&limit=1&client_key=esmBot%20${process.env.ESMBOT_VER}&key=${process.env.TENOR}`);
if (data.statusCode === 429) {
if (extraReturnTypes) {
payload.type = "tenorlimit";
return payload;
} else {
return;
}
}
const json = await data.body.json();
if (json.error) throw Error(json.error.message);
payload.path = json.results[0].media_formats.gif.url;
}
} else if (giphyURLs.includes(host)) {
// Can result in an HTML page instead of a GIF
payload.path = `https://media0.giphy.com/media/${image2.split("/")[4].split("-").pop()}/giphy.gif`;
} else if (giphyMediaURLs.includes(host)) {
payload.path = `https://media0.giphy.com/media/${image2.split("/")[4]}/giphy.gif`;
} else if (imgurURLs.includes(host)) {
// Seems that Imgur has a possibility of making GIFs static
payload.path = image.replace(".mp4", ".gif");
} else if (gfycatURLs.includes(host)) {
// iirc Gfycat also seems to sometimes make GIFs static
if (link) {
const data = await request(`https://api.gfycat.com/v1/gfycats/${image.split("/").pop().split(".mp4")[0]}`);
const json = await data.body.json();
if (json.errorMessage) throw Error(json.errorMessage);
payload.path = json.gfyItem.gifUrl;
} else {
payload.path = `https://thumbs.gfycat.com/${image.split("/").pop().split(".mp4")[0]}-size_restricted.gif`;
}
}
payload.type = "image/gif";
} else if (video) {
payload.type = type ?? await getType(payload.path, extraReturnTypes);
if (!payload.type || (!videoFormats.includes(payload.type) && !imageFormats.includes(payload.type))) return;
} else {
payload.type = type ?? await getType(payload.path, extraReturnTypes);
if (!payload.type || !imageFormats.includes(payload.type)) return;
}
return payload;
} catch (error) {
if (error.name === "AbortError") {
throw Error("Timed out");
} else {
throw error;
}
}
};
const urlFromMxc = async (mxcUri) => {
const stripped = mxcUri.replace("mxc://", "")
return process.env.MATRIX_BASEURL+"/_matrix/media/r0/download/"+stripped
}
const checkImages = async (message, extraReturnTypes, video, sticker) => {
let type;
// console.log(message)
if (typeof message.content.info !== undefined) {
if (message.content.msgtype == "m.image") {
const url = await urlFromMxc(message.content.url)
const fileNameNoExtension = message.content.body.slice(0, message.content.body.lastIndexOf("."));
type = {name: fileNameNoExtension, path: url, url: url, type: message.content.info.mimetype}
}
}
// // first check the embeds
// if (message.embeds.length !== 0) {
// // embeds can vary in types, we check for tenor gifs first
// if (message.embeds[0].type === "gifv") {
// type = await getImage(message.embeds[0].video.url, message.embeds[0].url, video, extraReturnTypes, true);
// // then we check for other image types
// } else if ((message.embeds[0].type === "video" || message.embeds[0].type === "image") && message.embeds[0].thumbnail) {
// type = await getImage(message.embeds[0].thumbnail.proxyURL, message.embeds[0].thumbnail.url, video, extraReturnTypes);
// // finally we check both possible image fields for "generic" embeds
// } else if (message.embeds[0].type === "rich" || message.embeds[0].type === "article") {
// if (message.embeds[0].thumbnail) {
// type = await getImage(message.embeds[0].thumbnail.proxyURL, message.embeds[0].thumbnail.url, video, extraReturnTypes);
// } else if (message.embeds[0].image) {
// type = await getImage(message.embeds[0].image.proxyURL, message.embeds[0].image.url, video, extraReturnTypes);
// }
// }
// // then check the attachments
// } else if (message.attachments.size !== 0 && message.attachments.first().width) {
// type = await getImage(message.attachments.first().proxyURL, message.attachments.first().url, video);
// }
// if the return value exists then return it
return type ?? false;
};
// this checks for the latest message containing an image and returns the url of the image
export default async (client, cmdMessage, interaction, options, extraReturnTypes = false, video = false, sticker = false, singleMessage = false) => {
// we start by determining whether or not we're dealing with an interaction or a message
if (cmdMessage) {
// console.log(cmdMessage)
// let channel = await client.getRoom(cmdMessage.room_id);
// console.log(channel)
// check if the message is a reply to another message
// console.log(cmdMessage.content['m.relates_to'])
if (cmdMessage.content['m.relates_to'] !== undefined) {
const replyMessage = await client.fetchRoomEvent(cmdMessage.room_id, cmdMessage.content['m.relates_to']['m.in_reply_to'].event_id)
// console.log(replyMessage)
if (replyMessage) {
const replyResult = await checkImages(replyMessage, extraReturnTypes, video, sticker);
if (replyResult !== false) return replyResult;
}
}
// then we check the current message
const result = await checkImages(cmdMessage, extraReturnTypes, video, sticker);
if (result !== false) return result;
}
// if (!singleMessage) {
// // if there aren't any replies or interaction attachments then iterate over the last few messages in the channel
// try {
// const channel = (interaction ? interaction : cmdMessage).channel ?? await client.rest.channels.get((interaction ? interaction : cmdMessage).channelID);
// const messages = await channel.getMessages();
// // iterate over each message
// for (const message of messages) {
// const result = await checkImages(message, extraReturnTypes, video, sticker);
// if (result === false) {
// continue;
// } else {
// return result;
// }
// }
// } catch {
// // no-op
// }
// }
};

View file

@ -1,44 +1,44 @@
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 warn(...args) { return log("warn", ...args); }
export function debug(...args) { return log("debug", ...args); }
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 warn(...args) { return log("warn", ...args); }
export function debug(...args) { return log("debug", ...args); }

View file

@ -1,163 +1,164 @@
import util from "util";
import fs from "fs";
import pm2 from "pm2";
import { config } from "dotenv";
import db from "./database.js";
// 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
export function random(array) {
if (!array || array.length < 1) return null;
return array[Math.floor(Math.random() * array.length)];
}
const optionalReplace = (token) => {
return token === undefined || token === "" ? "" : (token === "true" || token === "false" ? token : "<redacted>");
};
// clean(text) to clean message of any private info or mentions
export function clean(text) {
if (typeof text !== "string")
text = util.inspect(text, { depth: 1 });
text = text
.replaceAll("`", `\`${String.fromCharCode(8203)}`)
.replaceAll("@", `@${String.fromCharCode(8203)}`);
let { parsed } = config();
if (!parsed) parsed = process.env;
const imageServers = JSON.parse(fs.readFileSync(new URL("../config/servers.json", import.meta.url), { encoding: "utf8" })).image;
if (imageServers?.length !== 0) {
for (const { server, auth } of imageServers) {
text = text.replaceAll(server, optionalReplace(server));
text = text.replaceAll(auth, optionalReplace(auth));
}
}
for (const env of Object.keys(parsed)) {
text = text.replaceAll(parsed[env], optionalReplace(parsed[env]));
}
return text;
}
// textEncode(string) to encode characters for image processing
export function textEncode(string) {
return string.replaceAll("&", "&amp;").replaceAll(">", "&gt;").replaceAll("<", "&lt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;").replaceAll("\\n", "\n").replaceAll("\\:", ":").replaceAll("\\,", ",");
}
// set activity (a.k.a. the gamer code)
export async function activityChanger(bot) {
if (!broadcast) {
await bot.editStatus("dnd", [{
type: 0,
name: random(messages) + (types.classic ? ` | @${bot.user.username} help` : "")
}]);
}
setTimeout(() => activityChanger(bot), 900000);
}
export async function checkBroadcast(bot) {
if (!db) {
return;
}
const message = await db.getBroadcast();
if (message) {
startBroadcast(bot, message);
}
}
export function startBroadcast(bot, message) {
bot.editStatus("dnd", [{
type: 0,
name: message + (types.classic ? ` | @${bot.user.username} help` : "")
}]);
broadcast = true;
}
export function endBroadcast(bot) {
bot.editStatus("dnd", [{
type: 0,
name: random(messages) + (types.classic ? ` | @${bot.user.username} help` : "")
}]);
broadcast = false;
}
export function getServers(bot) {
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.list((err, list) => {
if (err) {
reject(err);
return;
}
const managerProc = list.filter((v) => v.name === "esmBot-manager")[0];
pm2.sendDataToProcessId(managerProc.pm_id, {
id: managerProc.pm_id,
type: "process:msg",
data: {
type: "getCount"
},
topic: true
}, (err) => {
if (err) reject(err);
});
});
} else {
resolve(bot.guilds.size);
}
});
}
// copied from eris
export function cleanMessage(message, content) {
let cleanContent = content && content.replace(/<a?(:\w+:)[0-9]+>/g, "$1") || "";
const author = message.author ?? message.member ?? message.user;
let authorName = author.username;
if (message.member?.nick) {
authorName = message.member.nick;
}
cleanContent = cleanContent.replace(new RegExp(`<@!?${author.id}>`, "g"), `@${authorName}`);
if (message.mentions) {
for (const mention of message.mentions.members) {
if (mention.nick) {
cleanContent = cleanContent.replace(new RegExp(`<@!?${mention.id}>`, "g"), `@${mention.nick}`);
}
cleanContent = cleanContent.replace(new RegExp(`<@!?${mention.id}>`, "g"), `@${mention.username}`);
}
if (message.guildID && message.mentions.roles) {
for (const roleID of message.mentions.roles) {
const role = message.guild.roles.get(roleID);
const roleName = role ? role.name : "deleted-role";
cleanContent = cleanContent.replace(new RegExp(`<@&${roleID}>`, "g"), `@${roleName}`);
}
}
for (const id of message.mentions.channels) {
const channel = message.client.getChannel(id);
if (channel && channel.name && channel.mention) {
cleanContent = cleanContent.replace(channel.mention, `#${channel.name}`);
}
}
}
return textEncode(cleanContent);
import util from "util";
import fs from "fs";
import pm2 from "pm2";
import { config } from "dotenv";
import db from "./database.js";
// 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
export function random(array) {
if (!array || array.length < 1) return null;
return array[Math.floor(Math.random() * array.length)];
}
const optionalReplace = (token) => {
return token === undefined || token === "" ? "" : (token === "true" || token === "false" ? token : "<redacted>");
};
// clean(text) to clean message of any private info or mentions
export function clean(text) {
if (typeof text !== "string")
text = util.inspect(text, { depth: 1 });
text = text
.replaceAll("`", `\`${String.fromCharCode(8203)}`)
.replaceAll("@", `@${String.fromCharCode(8203)}`);
let { parsed } = config();
if (!parsed) parsed = process.env;
const imageServers = JSON.parse(fs.readFileSync(new URL("../config/servers.json", import.meta.url), { encoding: "utf8" })).image;
if (imageServers?.length !== 0) {
for (const { server, auth } of imageServers) {
text = text.replaceAll(server, optionalReplace(server));
text = text.replaceAll(auth, optionalReplace(auth));
}
}
for (const env of Object.keys(parsed)) {
text = text.replaceAll(parsed[env], optionalReplace(parsed[env]));
}
return text;
}
// textEncode(string) to encode characters for image processing
export function textEncode(string) {
return string.replaceAll("&", "&amp;").replaceAll(">", "&gt;").replaceAll("<", "&lt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;").replaceAll("\\n", "\n").replaceAll("\\:", ":").replaceAll("\\,", ",");
}
// set activity (a.k.a. the gamer code)
export async function activityChanger(bot) {
if (!broadcast) {
await bot.editStatus("dnd", [{
type: 0,
name: random(messages) + (types.classic ? ` | @${bot.user.username} help` : "")
}]);
}
setTimeout(() => activityChanger(bot), 900000);
}
export async function checkBroadcast(bot) {
if (!db) {
return;
}
const message = await db.getBroadcast();
if (message) {
startBroadcast(bot, message);
}
}
export function startBroadcast(bot, message) {
bot.editStatus("dnd", [{
type: 0,
name: message + (types.classic ? ` | @${bot.user.username} help` : "")
}]);
broadcast = true;
}
export function endBroadcast(bot) {
bot.editStatus("dnd", [{
type: 0,
name: random(messages) + (types.classic ? ` | @${bot.user.username} help` : "")
}]);
broadcast = false;
}
export function getServers(bot) {
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.list((err, list) => {
if (err) {
reject(err);
return;
}
const managerProc = list.filter((v) => v.name === "esmBot-manager")[0];
pm2.sendDataToProcessId(managerProc.pm_id, {
id: managerProc.pm_id,
type: "process:msg",
data: {
type: "getCount"
},
topic: true
}, (err) => {
if (err) reject(err);
});
});
} else {
resolve(bot.guilds.size);
}
});
}
// copied from eris
export function cleanMessage(message, content) {
let cleanContent = content && content.replace(/<a?(:\w+:)[0-9]+>/g, "$1") || "";
// TODO: see if I need to fuck with this
// const author = message.author ?? message.member ?? message.user;
// let authorName = author.username;
// if (message.member?.nick) {
// authorName = message.member.nick;
// }
// cleanContent = cleanContent.replace(new RegExp(`<@!?${author.id}>`, "g"), `@${authorName}`);
// if (message.mentions) {
// for (const mention of message.mentions.members) {
// if (mention.nick) {
// cleanContent = cleanContent.replace(new RegExp(`<@!?${mention.id}>`, "g"), `@${mention.nick}`);
// }
// cleanContent = cleanContent.replace(new RegExp(`<@!?${mention.id}>`, "g"), `@${mention.username}`);
// }
// if (message.guildID && message.mentions.roles) {
// for (const roleID of message.mentions.roles) {
// const role = message.guild.roles.get(roleID);
// const roleName = role ? role.name : "deleted-role";
// cleanContent = cleanContent.replace(new RegExp(`<@&${roleID}>`, "g"), `@${roleName}`);
// }
// }
// for (const id of message.mentions.channels) {
// const channel = message.client.getChannel(id);
// if (channel && channel.name && channel.mention) {
// cleanContent = cleanContent.replace(channel.mention, `#${channel.name}`);
// }
// }
// }
return textEncode(cleanContent);
}

View file

@ -1,39 +1,39 @@
// oceanic doesn't come with a method to wait for interactions by default, so we make our own
import { EventEmitter } from "events";
class InteractionCollector extends EventEmitter {
constructor(client, message, type, timeout = 120000) {
super();
this.message = message;
this.type = type;
this.ended = false;
this.bot = client;
this.timeout = timeout;
this.listener = async (interaction) => {
await this.verify(interaction);
};
this.bot.on("interactionCreate", this.listener);
this.end = setTimeout(() => this.stop("time"), timeout);
}
async verify(interaction) {
if (!(interaction instanceof this.type)) return false;
if (this.message.id !== interaction.message.id) return false;
this.emit("interaction", interaction);
return true;
}
extend() {
clearTimeout(this.end);
this.end = setTimeout(() => this.stop("time"), this.timeout);
}
stop(reason) {
if (this.ended) return;
this.ended = true;
this.bot.removeListener("interactionCreate", this.listener);
this.emit("end", this.collected, reason);
}
}
export default InteractionCollector;
// oceanic doesn't come with a method to wait for interactions by default, so we make our own
import { EventEmitter } from "events";
class InteractionCollector extends EventEmitter {
constructor(client, message, type, timeout = 120000) {
super();
this.message = message;
this.type = type;
this.ended = false;
this.bot = client;
this.timeout = timeout;
this.listener = async (interaction) => {
await this.verify(interaction);
};
this.bot.on("interactionCreate", this.listener);
this.end = setTimeout(() => this.stop("time"), timeout);
}
async verify(interaction) {
if (!(interaction instanceof this.type)) return false;
if (this.message.id !== interaction.message.id) return false;
this.emit("interaction", interaction);
return true;
}
extend() {
clearTimeout(this.end);
this.end = setTimeout(() => this.stop("time"), this.timeout);
}
stop(reason) {
if (this.ended) return;
this.ended = true;
this.bot.removeListener("interactionCreate", this.listener);
this.emit("end", this.collected, reason);
}
}
export default InteractionCollector;

View file

@ -1,187 +1,187 @@
import InteractionCollector from "./awaitinteractions.js";
import { ComponentInteraction } from "oceanic.js";
export default async (client, info, pages, timeout = 120000) => {
const options = info.type === "classic" ? {
messageReference: {
channelID: info.message.channelID,
messageID: info.message.id,
guildID: info.message.guildID,
failIfNotExists: false
},
allowedMentions: {
repliedUser: false
}
} : {};
let page = 0;
const components = {
components: [{
type: 1,
components: [
{
type: 2,
label: "Back",
emoji: {
id: null,
name: "◀"
},
style: 1,
customID: "back"
},
{
type: 2,
label: "Forward",
emoji: {
id: null,
name: "▶"
},
style: 1,
customID: "forward"
},
{
type: 2,
label: "Jump",
emoji: {
id: null,
name: "🔢"
},
style: 1,
customID: "jump"
},
{
type: 2,
label: "Delete",
emoji: {
id: null,
name: "🗑"
},
style: 4,
customID: "delete"
}
]
}]
};
let currentPage;
if (info.type === "classic") {
currentPage = await client.rest.channels.createMessage(info.message.channelID, Object.assign(pages[page], options, pages.length > 1 ? components : {}));
} else {
currentPage = await info.interaction[info.interaction.acknowledged ? "editOriginal" : "createMessage"](Object.assign(pages[page], pages.length > 1 ? components : {}));
if (!currentPage) currentPage = await info.interaction.getOriginal();
}
if (pages.length > 1) {
const interactionCollector = new InteractionCollector(client, currentPage, ComponentInteraction, timeout);
interactionCollector.on("interaction", async (interaction) => {
if ((interaction.member ?? interaction.user).id === info.author.id) {
switch (interaction.data.customID) {
case "back":
await interaction.deferUpdate();
page = page > 0 ? --page : pages.length - 1;
currentPage = await currentPage.edit(Object.assign(pages[page], options));
interactionCollector.extend();
break;
case "forward":
await interaction.deferUpdate();
page = page + 1 < pages.length ? ++page : 0;
currentPage = await currentPage.edit(Object.assign(pages[page], options));
interactionCollector.extend();
break;
case "jump":
await interaction.deferUpdate();
var newComponents = JSON.parse(JSON.stringify(components));
for (const index of newComponents.components[0].components.keys()) {
newComponents.components[0].components[index].disabled = true;
}
currentPage = await currentPage.edit(newComponents);
interactionCollector.extend();
var jumpComponents = {
components: [{
type: 1,
components: [{
type: 3,
customID: "seekDropdown",
placeholder: "Page Number",
options: []
}]
}]
};
for (let i = 0; i < pages.length && i < 25; i++) {
const payload = {
label: i + 1,
value: i
};
jumpComponents.components[0].components[0].options[i] = payload;
}
var promise;
if (info.type === "classic") {
promise = client.rest.channels.createMessage(info.message.channelID, Object.assign({ content: "What page do you want to jump to?" }, {
messageReference: {
channelID: currentPage.channelID,
messageID: currentPage.id,
guildID: currentPage.guildID,
failIfNotExists: false
},
allowedMentions: {
repliedUser: false
}
}, jumpComponents));
} else {
promise = info.interaction.createFollowup(Object.assign({ content: "What page do you want to jump to?" }, jumpComponents));
}
promise.then(askMessage => {
const dropdownCollector = new InteractionCollector(client, askMessage, ComponentInteraction, timeout);
let ended = false;
dropdownCollector.on("interaction", async (response) => {
if (response.data.customID !== "seekDropdown") return;
try {
await askMessage.delete();
} catch {
// no-op
}
page = Number(response.data.values.raw[0]);
currentPage = await currentPage.edit(Object.assign(pages[page], options, components));
ended = true;
dropdownCollector.stop();
});
dropdownCollector.once("end", async () => {
if (ended) return;
try {
await askMessage.delete();
} catch {
// no-op
}
currentPage = await currentPage.edit(Object.assign(pages[page], options, components));
});
}).catch(error => {
throw error;
});
break;
case "delete":
await interaction.deferUpdate();
interactionCollector.emit("end", true);
try {
await currentPage.delete();
} catch {
// no-op
}
return;
default:
break;
}
}
});
interactionCollector.once("end", async (deleted = false) => {
interactionCollector.removeAllListeners("interaction");
if (!deleted) {
for (const index of components.components[0].components.keys()) {
components.components[0].components[index].disabled = true;
}
try {
await currentPage.edit(components);
} catch {
// no-op
}
}
});
}
};
import InteractionCollector from "./awaitinteractions.js";
import { ComponentInteraction } from "oceanic.js";
export default async (client, info, pages, timeout = 120000) => {
const options = info.type === "classic" ? {
messageReference: {
channelID: info.message.channelID,
messageID: info.message.id,
guildID: info.message.guildID,
failIfNotExists: false
},
allowedMentions: {
repliedUser: false
}
} : {};
let page = 0;
const components = {
components: [{
type: 1,
components: [
{
type: 2,
label: "Back",
emoji: {
id: null,
name: "◀"
},
style: 1,
customID: "back"
},
{
type: 2,
label: "Forward",
emoji: {
id: null,
name: "▶"
},
style: 1,
customID: "forward"
},
{
type: 2,
label: "Jump",
emoji: {
id: null,
name: "🔢"
},
style: 1,
customID: "jump"
},
{
type: 2,
label: "Delete",
emoji: {
id: null,
name: "🗑"
},
style: 4,
customID: "delete"
}
]
}]
};
let currentPage;
if (info.type === "classic") {
currentPage = await client.rest.channels.createMessage(info.message.channelID, Object.assign(pages[page], options, pages.length > 1 ? components : {}));
} else {
currentPage = await info.interaction[info.interaction.acknowledged ? "editOriginal" : "createMessage"](Object.assign(pages[page], pages.length > 1 ? components : {}));
if (!currentPage) currentPage = await info.interaction.getOriginal();
}
if (pages.length > 1) {
const interactionCollector = new InteractionCollector(client, currentPage, ComponentInteraction, timeout);
interactionCollector.on("interaction", async (interaction) => {
if ((interaction.member ?? interaction.user).id === info.author.id) {
switch (interaction.data.customID) {
case "back":
await interaction.deferUpdate();
page = page > 0 ? --page : pages.length - 1;
currentPage = await currentPage.edit(Object.assign(pages[page], options));
interactionCollector.extend();
break;
case "forward":
await interaction.deferUpdate();
page = page + 1 < pages.length ? ++page : 0;
currentPage = await currentPage.edit(Object.assign(pages[page], options));
interactionCollector.extend();
break;
case "jump":
await interaction.deferUpdate();
var newComponents = JSON.parse(JSON.stringify(components));
for (const index of newComponents.components[0].components.keys()) {
newComponents.components[0].components[index].disabled = true;
}
currentPage = await currentPage.edit(newComponents);
interactionCollector.extend();
var jumpComponents = {
components: [{
type: 1,
components: [{
type: 3,
customID: "seekDropdown",
placeholder: "Page Number",
options: []
}]
}]
};
for (let i = 0; i < pages.length && i < 25; i++) {
const payload = {
label: i + 1,
value: i
};
jumpComponents.components[0].components[0].options[i] = payload;
}
var promise;
if (info.type === "classic") {
promise = client.rest.channels.createMessage(info.message.channelID, Object.assign({ content: "What page do you want to jump to?" }, {
messageReference: {
channelID: currentPage.channelID,
messageID: currentPage.id,
guildID: currentPage.guildID,
failIfNotExists: false
},
allowedMentions: {
repliedUser: false
}
}, jumpComponents));
} else {
promise = info.interaction.createFollowup(Object.assign({ content: "What page do you want to jump to?" }, jumpComponents));
}
promise.then(askMessage => {
const dropdownCollector = new InteractionCollector(client, askMessage, ComponentInteraction, timeout);
let ended = false;
dropdownCollector.on("interaction", async (response) => {
if (response.data.customID !== "seekDropdown") return;
try {
await askMessage.delete();
} catch {
// no-op
}
page = Number(response.data.values.raw[0]);
currentPage = await currentPage.edit(Object.assign(pages[page], options, components));
ended = true;
dropdownCollector.stop();
});
dropdownCollector.once("end", async () => {
if (ended) return;
try {
await askMessage.delete();
} catch {
// no-op
}
currentPage = await currentPage.edit(Object.assign(pages[page], options, components));
});
}).catch(error => {
throw error;
});
break;
case "delete":
await interaction.deferUpdate();
interactionCollector.emit("end", true);
try {
await currentPage.delete();
} catch {
// no-op
}
return;
default:
break;
}
}
});
interactionCollector.once("end", async (deleted = false) => {
interactionCollector.removeAllListeners("interaction");
if (!deleted) {
for (const index of components.components[0].components.keys()) {
components.components[0].components[index].disabled = true;
}
try {
await currentPage.edit(components);
} catch {
// no-op
}
}
});
}
};

View file

@ -1,112 +1,112 @@
export default (input) => {
if (typeof input === "string") input = input.split(/\s+/g);
const args = { _: [] };
let curr = null;
let concated = "";
for (let i = 0; i < input.length; i++) {
const a = input[i];
if ((a.startsWith("--") || a.startsWith("—")) && !curr) {
if (a.includes("=")) {
const [arg, value] = (a.startsWith("--") ? a.slice(2).split("=") : a.slice(1).split("="));
let ended = true;
if (arg !== "_") {
if (value.startsWith("\"")) {
if (value.endsWith("\"")) {
args[arg] = value.slice(1).slice(0, -1);
} else {
args[arg] = `${value.slice(1)} `;
ended = false;
}
} else if (value.endsWith("\"")) {
args[arg] += a.slice(0, -1);
} else if (value !== "") {
args[arg] = value;
} else {
args[arg] = true;
}
if (args[arg] === "true") {
args[arg] = true;
} else if (args[arg] === "false") {
args[arg] = false;
}
if (!ended) curr = arg;
}
} else {
args[a.slice(2)] = true;
}
} else if (curr) {
if (a.endsWith("\"")) {
args[curr] += a.slice(0, -1);
curr = null;
} else {
args[curr] += `${a} `;
}
} else {
if (concated !== "") {
concated += `${a} `;
} else {
args._.push(a);
}
}
}
if (curr && args[curr] == "") {
args[curr] = true;
}
return args;
};
// /*
// Format:
// [{name: "verbose", type: "bool"}, {name: "username", type: "string"}]
// */
// export default (input, format) => {
// let results = {};
// let text = input.split(' ').slice(1).join(' ');
// format.forEach(element => {
// if(element.pos !== undefined) return;
// switch (element.type) {
// case "bool":
// res = text.match(`--${element.name}[ |=](.*?)($| )`);
// if(res) {
// text = text.replace(res[0], "");
// results[element.name] = (res[1].toLowerCase() == "true");
// } else {
// res = text.match(`--${element.name}`);
// if(res) text = text.replace(res[0], "");
// results[element.name] = (res != null);
// }
// break;
// case "string":
// res = text.match(`--${element.name}[ |=](.*?)($| )`);
// if(res) text = text.replace(res[0], "");
// results[element.name] = (res ? res[1].replace('\\','') : null);
// break;
// case "int":
// res = text.match(`--${element.name}[ |=](.*?)($| )`);
// if(res) text = text.replace(res[0], "");
// results[element.name] = (res ? parseInt(res[1]) : null);
// break;
// case "float":
// res = text.match(`--${element.name}[ |=](.*?)($| )`);
// if(res) text = text.replace(res[0], "");
// results[element.name] = (res ? parseFloat(res[1]) : null);
// break;
// default:
// throw Error("unknown type");
// break;
// }
// });
// let s = text.split(' ');
// results._ = text;
// format.forEach(element => {
// if(element.pos === undefined) return;
// if(element.pos <= s.length) {
// results[element.name] = s[element.pos];
// } else {
// results[element.name] = null;
// }
// })
// return results;
// }
export default (input) => {
if (typeof input === "string") input = input.split(/\s+/g);
const args = { _: [] };
let curr = null;
let concated = "";
for (let i = 0; i < input.length; i++) {
const a = input[i];
if ((a.startsWith("--") || a.startsWith("—")) && !curr) {
if (a.includes("=")) {
const [arg, value] = (a.startsWith("--") ? a.slice(2).split("=") : a.slice(1).split("="));
let ended = true;
if (arg !== "_") {
if (value.startsWith("\"")) {
if (value.endsWith("\"")) {
args[arg] = value.slice(1).slice(0, -1);
} else {
args[arg] = `${value.slice(1)} `;
ended = false;
}
} else if (value.endsWith("\"")) {
args[arg] += a.slice(0, -1);
} else if (value !== "") {
args[arg] = value;
} else {
args[arg] = true;
}
if (args[arg] === "true") {
args[arg] = true;
} else if (args[arg] === "false") {
args[arg] = false;
}
if (!ended) curr = arg;
}
} else {
args[a.slice(2)] = true;
}
} else if (curr) {
if (a.endsWith("\"")) {
args[curr] += a.slice(0, -1);
curr = null;
} else {
args[curr] += `${a} `;
}
} else {
if (concated !== "") {
concated += `${a} `;
} else {
args._.push(a);
}
}
}
if (curr && args[curr] == "") {
args[curr] = true;
}
return args;
};
// /*
// Format:
// [{name: "verbose", type: "bool"}, {name: "username", type: "string"}]
// */
// export default (input, format) => {
// let results = {};
// let text = input.split(' ').slice(1).join(' ');
// format.forEach(element => {
// if(element.pos !== undefined) return;
// switch (element.type) {
// case "bool":
// res = text.match(`--${element.name}[ |=](.*?)($| )`);
// if(res) {
// text = text.replace(res[0], "");
// results[element.name] = (res[1].toLowerCase() == "true");
// } else {
// res = text.match(`--${element.name}`);
// if(res) text = text.replace(res[0], "");
// results[element.name] = (res != null);
// }
// break;
// case "string":
// res = text.match(`--${element.name}[ |=](.*?)($| )`);
// if(res) text = text.replace(res[0], "");
// results[element.name] = (res ? res[1].replace('\\','') : null);
// break;
// case "int":
// res = text.match(`--${element.name}[ |=](.*?)($| )`);
// if(res) text = text.replace(res[0], "");
// results[element.name] = (res ? parseInt(res[1]) : null);
// break;
// case "float":
// res = text.match(`--${element.name}[ |=](.*?)($| )`);
// if(res) text = text.replace(res[0], "");
// results[element.name] = (res ? parseFloat(res[1]) : null);
// break;
// default:
// throw Error("unknown type");
// break;
// }
// });
// let s = text.split(' ');
// results._ = text;
// format.forEach(element => {
// if(element.pos === undefined) return;
// if(element.pos <= s.length) {
// results[element.name] = s[element.pos];
// } else {
// results[element.name] = null;
// }
// })
// return results;
// }

View file

@ -1,240 +1,240 @@
import pm2 from "pm2";
import winston from "winston";
// load config from .env file
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import { readFileSync } from "fs";
import { createServer } from "http";
import { config } from "dotenv";
config({ path: resolve(dirname(fileURLToPath(import.meta.url)), "../../.env") });
// oceanic client used for getting shard counts
import { Client } from "oceanic.js";
import database from "../database.js";
import { cpus } from "os";
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() {
serverCount = 0;
shardCount = 0;
clusterCount = 0;
responseCount = 0;
return new Promise((resolve, reject) => {
pm2.list((err, list) => {
if (err) reject(err);
const clusters = list.filter((v) => v.name.includes("esmBot-proc"));
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"
}
});
});
});
}
if (process.env.METRICS && process.env.METRICS !== "") {
const servers = [];
if (process.env.API_TYPE === "ws") {
const imageHosts = JSON.parse(readFileSync(new URL("../../config/servers.json", import.meta.url), { encoding: "utf8" })).image;
for (let { server } of imageHosts) {
if (!server.includes(":")) {
server += ":3762";
}
servers.push(server);
}
}
const httpServer = createServer(async (req, res) => {
if (req.method !== "GET") {
res.statusCode = 405;
return res.end("GET only");
}
res.write(`# HELP esmbot_command_count Number of times a command has been run
# TYPE esmbot_command_count counter
# HELP esmbot_server_count Number of servers/guilds the bot is in
# TYPE esmbot_server_count gauge
# HELP esmbot_shard_count Number of shards the bot has
# TYPE esmbot_shard_count gauge
`);
if (database) {
const counts = await database.getCounts();
for (const [i, w] of Object.entries(counts)) {
res.write(`esmbot_command_count{command="${i}"} ${w}\n`);
}
}
res.write(`esmbot_server_count ${serverCount}\n`);
res.write(`esmbot_shard_count ${shardCount}\n`);
res.end();
});
httpServer.listen(process.env.METRICS, () => {
logger.log("info", `Serving metrics at ${process.env.METRICS}`);
});
}
setInterval(updateStats, 300000);
setTimeout(updateStats, 10000);
logger.info("Started esmBot management process.");
// from eris-fleet
function calcShards(shards, procs) {
if (procs < 2) return [shards];
const length = shards.length;
const r = [];
let i = 0;
let size;
if (length % procs === 0) {
size = Math.floor(length / procs);
while (i < length) {
r.push(shards.slice(i, (i += size)));
}
} else {
while (i < length) {
size = Math.ceil((length - i) / procs--);
r.push(shards.slice(i, (i += size)));
}
}
return r;
}
(async function init() {
logger.main("Getting gateway connection data...");
const client = new Client({
auth: `Bot ${process.env.TOKEN}`,
gateway: {
concurrency: "auto",
maxShards: "auto",
presence: {
status: "idle",
activities: [{
type: 0,
name: "Starting esmBot..."
}]
},
intents: []
}
});
const connectionData = await client.rest.getBotGateway();
const cpuAmount = cpus().length;
const procAmount = Math.min(connectionData.shards, cpuAmount);
logger.main(`Obtained data, connecting with ${connectionData.shards} shard(s) across ${procAmount} process(es)...`);
const lastShard = connectionData.shards - 1;
const shardArray = [];
for (let i = 0; i <= lastShard; i++) {
shardArray.push(i);
}
const shardArrays = calcShards(shardArray, procAmount);
for (let i = 0; i < shardArrays.length; i++) {
await awaitStart(i, shardArrays);
}
})();
function awaitStart(i, shardArrays) {
return new Promise((resolve) => {
pm2.start({
name: `esmBot-proc${i}`,
script: "app.js",
autorestart: true,
exp_backoff_restart_delay: 1000,
wait_ready: true,
listen_timeout: 60000,
watch: false,
exec_mode: "cluster",
instances: 1,
env: {
"SHARDS": JSON.stringify(shardArrays)
}
}, (err) => {
if (err) {
logger.error(`Failed to start esmBot process ${i}: ${err}`);
process.exit(0);
} else {
logger.info(`Started esmBot process ${i}.`);
resolve();
}
});
});
import pm2 from "pm2";
import winston from "winston";
// load config from .env file
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import { readFileSync } from "fs";
import { createServer } from "http";
import { config } from "dotenv";
config({ path: resolve(dirname(fileURLToPath(import.meta.url)), "../../.env") });
// oceanic client used for getting shard counts
import { Client } from "oceanic.js";
import database from "../database.js";
import { cpus } from "os";
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() {
serverCount = 0;
shardCount = 0;
clusterCount = 0;
responseCount = 0;
return new Promise((resolve, reject) => {
pm2.list((err, list) => {
if (err) reject(err);
const clusters = list.filter((v) => v.name.includes("esmBot-proc"));
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"
}
});
});
});
}
if (process.env.METRICS && process.env.METRICS !== "") {
const servers = [];
if (process.env.API_TYPE === "ws") {
const imageHosts = JSON.parse(readFileSync(new URL("../../config/servers.json", import.meta.url), { encoding: "utf8" })).image;
for (let { server } of imageHosts) {
if (!server.includes(":")) {
server += ":3762";
}
servers.push(server);
}
}
const httpServer = createServer(async (req, res) => {
if (req.method !== "GET") {
res.statusCode = 405;
return res.end("GET only");
}
res.write(`# HELP esmbot_command_count Number of times a command has been run
# TYPE esmbot_command_count counter
# HELP esmbot_server_count Number of servers/guilds the bot is in
# TYPE esmbot_server_count gauge
# HELP esmbot_shard_count Number of shards the bot has
# TYPE esmbot_shard_count gauge
`);
if (database) {
const counts = await database.getCounts();
for (const [i, w] of Object.entries(counts)) {
res.write(`esmbot_command_count{command="${i}"} ${w}\n`);
}
}
res.write(`esmbot_server_count ${serverCount}\n`);
res.write(`esmbot_shard_count ${shardCount}\n`);
res.end();
});
httpServer.listen(process.env.METRICS, () => {
logger.log("info", `Serving metrics at ${process.env.METRICS}`);
});
}
setInterval(updateStats, 300000);
setTimeout(updateStats, 10000);
logger.info("Started esmBot management process.");
// from eris-fleet
function calcShards(shards, procs) {
if (procs < 2) return [shards];
const length = shards.length;
const r = [];
let i = 0;
let size;
if (length % procs === 0) {
size = Math.floor(length / procs);
while (i < length) {
r.push(shards.slice(i, (i += size)));
}
} else {
while (i < length) {
size = Math.ceil((length - i) / procs--);
r.push(shards.slice(i, (i += size)));
}
}
return r;
}
(async function init() {
logger.main("Getting gateway connection data...");
const client = new Client({
auth: `Bot ${process.env.TOKEN}`,
gateway: {
concurrency: "auto",
maxShards: "auto",
presence: {
status: "idle",
activities: [{
type: 0,
name: "Starting esmBot..."
}]
},
intents: []
}
});
const connectionData = await client.rest.getBotGateway();
const cpuAmount = cpus().length;
const procAmount = Math.min(connectionData.shards, cpuAmount);
logger.main(`Obtained data, connecting with ${connectionData.shards} shard(s) across ${procAmount} process(es)...`);
const lastShard = connectionData.shards - 1;
const shardArray = [];
for (let i = 0; i <= lastShard; i++) {
shardArray.push(i);
}
const shardArrays = calcShards(shardArray, procAmount);
for (let i = 0; i < shardArrays.length; i++) {
await awaitStart(i, shardArrays);
}
})();
function awaitStart(i, shardArrays) {
return new Promise((resolve) => {
pm2.start({
name: `esmBot-proc${i}`,
script: "app.js",
autorestart: true,
exp_backoff_restart_delay: 1000,
wait_ready: true,
listen_timeout: 60000,
watch: false,
exec_mode: "cluster",
instances: 1,
env: {
"SHARDS": JSON.stringify(shardArrays)
}
}, (err) => {
if (err) {
logger.error(`Failed to start esmBot process ${i}: ${err}`);
process.exit(0);
} else {
logger.info(`Started esmBot process ${i}.`);
resolve();
}
});
});
}

View file

@ -1,270 +1,270 @@
import * as logger from "./logger.js";
import fs from "fs";
import format from "format-duration";
import { Shoukaku, Connectors } from "shoukaku";
import { setTimeout } from "timers/promises";
export const players = new Map();
export const queues = new Map();
export const skipVotes = new Map();
export let manager;
export let nodes = JSON.parse(fs.readFileSync(new URL("../config/servers.json", import.meta.url), { encoding: "utf8" })).lava;
export let connected = false;
export function connect(client) {
manager = new Shoukaku(new Connectors.OceanicJS(client), nodes, { moveOnDisconnect: true, resume: true, reconnectInterval: 500, reconnectTries: 1 });
manager.on("error", (node, error) => {
logger.error(`An error occurred on Lavalink node ${node}: ${error}`);
});
manager.on("debug", (node, info) => {
logger.debug(`Debug event from Lavalink node ${node}: ${info}`);
});
manager.once("ready", () => {
logger.log(`Successfully connected to ${manager.nodes.size} Lavalink node(s).`);
connected = true;
});
}
export async function reload(client) {
if (!manager) connect(client);
const activeNodes = manager.nodes;
const json = await fs.promises.readFile(new URL("../config/servers.json", import.meta.url), { encoding: "utf8" });
nodes = JSON.parse(json).lava;
const names = nodes.map((a) => a.name);
for (const name in activeNodes) {
if (!names.includes(name)) {
manager.removeNode(name);
}
}
for (const node of nodes) {
if (!activeNodes.has(node.name)) {
manager.addNode(node);
}
}
if (!manager.nodes.size) connected = false;
return manager.nodes.size;
}
export async function play(client, sound, options, music = false) {
if (!connected) return { content: "I'm not connected to any audio servers!", flags: 64 };
if (!manager) return { content: "The sound commands are still starting up!", flags: 64 };
if (!options.channel.guild) return { content: "This command only works in servers!", flags: 64 };
if (!options.member.voiceState) return { content: "You need to be in a voice channel first!", flags: 64 };
if (!options.channel.guild.permissionsOf(client.user.id.toString()).has("CONNECT")) return { content: "I can't join this voice channel!", flags: 64 };
const voiceChannel = options.channel.guild.channels.get(options.member.voiceState.channelID) ?? await client.rest.channels.get(options.member.voiceState.channelID);
if (!voiceChannel.permissionsOf(client.user.id.toString()).has("CONNECT")) return { content: "I don't have permission to join this voice channel!", flags: 64 };
if (!music && manager.players.has(options.channel.guildID)) return { content: "I can't play a sound effect while other audio is playing!", flags: 64 };
const node = manager.getNode();
if (!music && !nodes.filter(obj => obj.name === node.name)[0].local) {
sound = sound.replace(/\.\//, "https://raw.githubusercontent.com/esmBot/esmBot/master/");
}
let response;
try {
response = await node.rest.resolve(sound);
if (!response) return { content: "🔊 I couldn't get a response from the audio server.", flags: 64 };
if (response.loadType === "NO_MATCHES" || response.loadType === "LOAD_FAILED") return { content: "I couldn't find that song!", flags: 64 };
} catch (e) {
logger.error(e);
return { content: "🔊 Hmmm, seems that all of the audio servers are down. Try again in a bit.", flags: 64 };
}
const oldQueue = queues.get(voiceChannel.guildID);
if (!response.tracks || response.tracks.length === 0) return { content: "I couldn't find that song!", flags: 64 };
if (process.env.YT_DISABLED === "true" && response.tracks[0].info.sourceName === "youtube") return "YouTube playback is disabled on this instance.";
if (music) {
const sortedTracks = response.tracks.map((val) => { return val.track; });
const playlistTracks = response.playlistInfo.selectedTrack ? sortedTracks : [sortedTracks[0]];
queues.set(voiceChannel.guildID, oldQueue ? [...oldQueue, ...playlistTracks] : playlistTracks);
}
const playerMeta = players.get(options.channel.guildID);
let player;
if (node.players.has(voiceChannel.guildID)) {
player = node.players.get(voiceChannel.guildID);
} else if (playerMeta?.player) {
const storedState = playerMeta?.player?.connection.state;
if (storedState && storedState === 1) {
player = playerMeta?.player;
}
}
const connection = player ?? await node.joinChannel({
guildId: voiceChannel.guildID,
channelId: voiceChannel.id,
shardId: voiceChannel.guild.shard.id,
deaf: true
});
if (oldQueue?.length && music) {
return `Your ${response.playlistInfo.name ? "playlist" : "tune"} \`${response.playlistInfo.name ? response.playlistInfo.name.trim() : (response.tracks[0].info.title !== "" ? response.tracks[0].info.title.trim() : "(blank)")}\` has been added to the queue!`;
} else {
nextSong(client, options, connection, response.tracks[0].track, response.tracks[0].info, music, voiceChannel, playerMeta?.host ?? options.member.id, playerMeta?.loop ?? false, playerMeta?.shuffle ?? false);
return;
}
}
export async function nextSong(client, options, connection, track, info, music, voiceChannel, host, loop = false, shuffle = false, lastTrack = null) {
skipVotes.delete(voiceChannel.guildID);
const parts = Math.floor((0 / info.length) * 10);
let playingMessage;
if (music && lastTrack === track && players.has(voiceChannel.guildID)) {
playingMessage = players.get(voiceChannel.guildID).playMessage;
} else {
try {
const content = !music ? { content: "🔊 Playing sound..." } : {
embeds: [{
color: 16711680,
author: {
name: "Now Playing",
iconURL: client.user.avatarURL()
},
fields: [{
name: " Title",
value: info.title?.trim() !== "" ? info.title : "(blank)"
},
{
name: "🎤 Artist",
value: info.author?.trim() !== "" ? info.author : "(blank)"
},
{
name: "💬 Channel",
value: voiceChannel.name
},
{
name: "🌐 Node",
value: connection.node?.name ?? "Unknown"
},
{
name: `${"▬".repeat(parts)}🔘${"▬".repeat(10 - parts)}`,
value: `0:00/${info.isStream ? "∞" : format(info.length)}`
}]
}]
};
if (options.type === "classic") {
playingMessage = await client.rest.channels.createMessage(options.channel.id, content);
} else {
if ((Date.now() - options.interaction.createdAt) >= 900000) { // discord interactions are only valid for 15 minutes
playingMessage = await client.rest.channels.createMessage(options.channel.id, content);
} else if (lastTrack && lastTrack !== track) {
playingMessage = await options.interaction.createFollowup(content);
} else {
playingMessage = await options.interaction[options.interaction.acknowledged ? "editOriginal" : "createMessage"](content);
if (!playingMessage) playingMessage = await options.interaction.getOriginal();
}
}
} catch {
// no-op
}
}
connection.removeAllListeners("exception");
connection.removeAllListeners("stuck");
connection.removeAllListeners("end");
connection.setVolume(0.70);
connection.playTrack({ track });
players.set(voiceChannel.guildID, { player: connection, type: music ? "music" : "sound", host, voiceChannel, originalChannel: options.channel, loop, shuffle, playMessage: playingMessage });
connection.once("exception", (exception) => errHandle(exception, client, connection, playingMessage, voiceChannel, options));
connection.on("stuck", () => {
const nodeName = manager.getNode().name;
connection.move(nodeName);
connection.resume();
});
connection.on("end", async (data) => {
if (data.reason === "REPLACED") return;
let queue = queues.get(voiceChannel.guildID);
const player = players.get(voiceChannel.guildID);
if (player && process.env.STAYVC === "true") {
player.type = "idle";
players.set(voiceChannel.guildID, player);
}
let newQueue;
if (player?.shuffle) {
if (player.loop) {
queue.push(queue.shift());
} else {
queue = queue.slice(1);
}
queue.unshift(queue.splice(Math.floor(Math.random() * queue.length), 1)[0]);
newQueue = queue;
} else if (player?.loop) {
queue.push(queue.shift());
newQueue = queue;
} else {
newQueue = queue ? queue.slice(1) : [];
}
queues.set(voiceChannel.guildID, newQueue);
if (newQueue.length !== 0) {
const newTrack = await connection.node.rest.decode(newQueue[0]);
nextSong(client, options, connection, newQueue[0], newTrack, music, voiceChannel, host, player.loop, player.shuffle, track);
try {
if (options.type === "classic") {
if (newQueue[0] !== track && playingMessage.channel.messages.has(playingMessage.id)) await playingMessage.delete();
if (newQueue[0] !== track && player.playMessage.channel.messages.has(player.playMessage.id)) await player.playMessage.delete();
}
} catch {
// no-op
}
} else if (process.env.STAYVC !== "true") {
await setTimeout(400);
connection.node.leaveChannel(voiceChannel.guildID);
players.delete(voiceChannel.guildID);
queues.delete(voiceChannel.guildID);
skipVotes.delete(voiceChannel.guildID);
try {
const content = `🔊 The voice channel session in \`${voiceChannel.name}\` has ended.`;
if (options.type === "classic") {
await client.rest.channels.createMessage(options.channel.id, { content });
} else {
if ((Date.now() - options.interaction.createdAt) >= 900000) {
await client.rest.channels.createMessage(options.channel.id, { content });
} else {
await options.interaction.createFollowup({ content });
}
}
} catch {
// no-op
}
}
if (options.type === "classic") {
try {
if (playingMessage.channel.messages.has(playingMessage.id)) await playingMessage.delete();
if (player?.playMessage.channel.messages.has(player.playMessage.id)) await player.playMessage.delete();
} catch {
// no-op
}
}
});
}
export async function errHandle(exception, client, connection, playingMessage, voiceChannel, options, closed) {
try {
if (playingMessage.channel.messages.has(playingMessage.id)) await playingMessage.delete();
const playMessage = players.get(voiceChannel.guildID).playMessage;
if (playMessage.channel.messages.has(playMessage.id)) await playMessage.delete();
} catch {
// no-op
}
players.delete(voiceChannel.guildID);
queues.delete(voiceChannel.guildID);
skipVotes.delete(voiceChannel.guildID);
logger.error(exception);
try {
connection.node.leaveChannel(voiceChannel.guildID);
} catch {
// no-op
}
connection.removeAllListeners("exception");
connection.removeAllListeners("stuck");
connection.removeAllListeners("end");
try {
const content = closed ? `🔊 I got disconnected by Discord and tried to reconnect; however, I got this error instead:\n\`\`\`${exception}\`\`\`` : `🔊 Looks like there was an error regarding sound playback:\n\`\`\`${exception.type}: ${exception.error}\`\`\``;
if (options.type === "classic") {
await client.rest.channels.createMessage(playingMessage.channel.id, { content });
} else {
if ((Date.now() - options.interaction.createdAt) >= 900000) {
await client.rest.channels.createMessage(options.channel.id, { content });
} else {
await options.interaction.createFollowup({ content });
}
}
} catch {
// no-op
}
import * as logger from "./logger.js";
import fs from "fs";
import format from "format-duration";
import { Shoukaku, Connectors } from "shoukaku";
import { setTimeout } from "timers/promises";
export const players = new Map();
export const queues = new Map();
export const skipVotes = new Map();
export let manager;
export let nodes = JSON.parse(fs.readFileSync(new URL("../config/servers.json", import.meta.url), { encoding: "utf8" })).lava;
export let connected = false;
export function connect(client) {
manager = new Shoukaku(new Connectors.OceanicJS(client), nodes, { moveOnDisconnect: true, resume: true, reconnectInterval: 500, reconnectTries: 1 });
manager.on("error", (node, error) => {
logger.error(`An error occurred on Lavalink node ${node}: ${error}`);
});
manager.on("debug", (node, info) => {
logger.debug(`Debug event from Lavalink node ${node}: ${info}`);
});
manager.once("ready", () => {
logger.log(`Successfully connected to ${manager.nodes.size} Lavalink node(s).`);
connected = true;
});
}
export async function reload(client) {
if (!manager) connect(client);
const activeNodes = manager.nodes;
const json = await fs.promises.readFile(new URL("../config/servers.json", import.meta.url), { encoding: "utf8" });
nodes = JSON.parse(json).lava;
const names = nodes.map((a) => a.name);
for (const name in activeNodes) {
if (!names.includes(name)) {
manager.removeNode(name);
}
}
for (const node of nodes) {
if (!activeNodes.has(node.name)) {
manager.addNode(node);
}
}
if (!manager.nodes.size) connected = false;
return manager.nodes.size;
}
export async function play(client, sound, options, music = false) {
if (!connected) return { content: "I'm not connected to any audio servers!", flags: 64 };
if (!manager) return { content: "The sound commands are still starting up!", flags: 64 };
if (!options.channel.guild) return { content: "This command only works in servers!", flags: 64 };
if (!options.member.voiceState) return { content: "You need to be in a voice channel first!", flags: 64 };
if (!options.channel.guild.permissionsOf(client.user.id.toString()).has("CONNECT")) return { content: "I can't join this voice channel!", flags: 64 };
const voiceChannel = options.channel.guild.channels.get(options.member.voiceState.channelID) ?? await client.rest.channels.get(options.member.voiceState.channelID);
if (!voiceChannel.permissionsOf(client.user.id.toString()).has("CONNECT")) return { content: "I don't have permission to join this voice channel!", flags: 64 };
if (!music && manager.players.has(options.channel.guildID)) return { content: "I can't play a sound effect while other audio is playing!", flags: 64 };
const node = manager.getNode();
if (!music && !nodes.filter(obj => obj.name === node.name)[0].local) {
sound = sound.replace(/\.\//, "https://raw.githubusercontent.com/esmBot/esmBot/master/");
}
let response;
try {
response = await node.rest.resolve(sound);
if (!response) return { content: "🔊 I couldn't get a response from the audio server.", flags: 64 };
if (response.loadType === "NO_MATCHES" || response.loadType === "LOAD_FAILED") return { content: "I couldn't find that song!", flags: 64 };
} catch (e) {
logger.error(e);
return { content: "🔊 Hmmm, seems that all of the audio servers are down. Try again in a bit.", flags: 64 };
}
const oldQueue = queues.get(voiceChannel.guildID);
if (!response.tracks || response.tracks.length === 0) return { content: "I couldn't find that song!", flags: 64 };
if (process.env.YT_DISABLED === "true" && response.tracks[0].info.sourceName === "youtube") return "YouTube playback is disabled on this instance.";
if (music) {
const sortedTracks = response.tracks.map((val) => { return val.track; });
const playlistTracks = response.playlistInfo.selectedTrack ? sortedTracks : [sortedTracks[0]];
queues.set(voiceChannel.guildID, oldQueue ? [...oldQueue, ...playlistTracks] : playlistTracks);
}
const playerMeta = players.get(options.channel.guildID);
let player;
if (node.players.has(voiceChannel.guildID)) {
player = node.players.get(voiceChannel.guildID);
} else if (playerMeta?.player) {
const storedState = playerMeta?.player?.connection.state;
if (storedState && storedState === 1) {
player = playerMeta?.player;
}
}
const connection = player ?? await node.joinChannel({
guildId: voiceChannel.guildID,
channelId: voiceChannel.id,
shardId: voiceChannel.guild.shard.id,
deaf: true
});
if (oldQueue?.length && music) {
return `Your ${response.playlistInfo.name ? "playlist" : "tune"} \`${response.playlistInfo.name ? response.playlistInfo.name.trim() : (response.tracks[0].info.title !== "" ? response.tracks[0].info.title.trim() : "(blank)")}\` has been added to the queue!`;
} else {
nextSong(client, options, connection, response.tracks[0].track, response.tracks[0].info, music, voiceChannel, playerMeta?.host ?? options.member.id, playerMeta?.loop ?? false, playerMeta?.shuffle ?? false);
return;
}
}
export async function nextSong(client, options, connection, track, info, music, voiceChannel, host, loop = false, shuffle = false, lastTrack = null) {
skipVotes.delete(voiceChannel.guildID);
const parts = Math.floor((0 / info.length) * 10);
let playingMessage;
if (music && lastTrack === track && players.has(voiceChannel.guildID)) {
playingMessage = players.get(voiceChannel.guildID).playMessage;
} else {
try {
const content = !music ? { content: "🔊 Playing sound..." } : {
embeds: [{
color: 16711680,
author: {
name: "Now Playing",
iconURL: client.user.avatarURL()
},
fields: [{
name: " Title",
value: info.title?.trim() !== "" ? info.title : "(blank)"
},
{
name: "🎤 Artist",
value: info.author?.trim() !== "" ? info.author : "(blank)"
},
{
name: "💬 Channel",
value: voiceChannel.name
},
{
name: "🌐 Node",
value: connection.node?.name ?? "Unknown"
},
{
name: `${"▬".repeat(parts)}🔘${"▬".repeat(10 - parts)}`,
value: `0:00/${info.isStream ? "∞" : format(info.length)}`
}]
}]
};
if (options.type === "classic") {
playingMessage = await client.rest.channels.createMessage(options.channel.id, content);
} else {
if ((Date.now() - options.interaction.createdAt) >= 900000) { // discord interactions are only valid for 15 minutes
playingMessage = await client.rest.channels.createMessage(options.channel.id, content);
} else if (lastTrack && lastTrack !== track) {
playingMessage = await options.interaction.createFollowup(content);
} else {
playingMessage = await options.interaction[options.interaction.acknowledged ? "editOriginal" : "createMessage"](content);
if (!playingMessage) playingMessage = await options.interaction.getOriginal();
}
}
} catch {
// no-op
}
}
connection.removeAllListeners("exception");
connection.removeAllListeners("stuck");
connection.removeAllListeners("end");
connection.setVolume(0.70);
connection.playTrack({ track });
players.set(voiceChannel.guildID, { player: connection, type: music ? "music" : "sound", host, voiceChannel, originalChannel: options.channel, loop, shuffle, playMessage: playingMessage });
connection.once("exception", (exception) => errHandle(exception, client, connection, playingMessage, voiceChannel, options));
connection.on("stuck", () => {
const nodeName = manager.getNode().name;
connection.move(nodeName);
connection.resume();
});
connection.on("end", async (data) => {
if (data.reason === "REPLACED") return;
let queue = queues.get(voiceChannel.guildID);
const player = players.get(voiceChannel.guildID);
if (player && process.env.STAYVC === "true") {
player.type = "idle";
players.set(voiceChannel.guildID, player);
}
let newQueue;
if (player?.shuffle) {
if (player.loop) {
queue.push(queue.shift());
} else {
queue = queue.slice(1);
}
queue.unshift(queue.splice(Math.floor(Math.random() * queue.length), 1)[0]);
newQueue = queue;
} else if (player?.loop) {
queue.push(queue.shift());
newQueue = queue;
} else {
newQueue = queue ? queue.slice(1) : [];
}
queues.set(voiceChannel.guildID, newQueue);
if (newQueue.length !== 0) {
const newTrack = await connection.node.rest.decode(newQueue[0]);
nextSong(client, options, connection, newQueue[0], newTrack, music, voiceChannel, host, player.loop, player.shuffle, track);
try {
if (options.type === "classic") {
if (newQueue[0] !== track && playingMessage.channel.messages.has(playingMessage.id)) await playingMessage.delete();
if (newQueue[0] !== track && player.playMessage.channel.messages.has(player.playMessage.id)) await player.playMessage.delete();
}
} catch {
// no-op
}
} else if (process.env.STAYVC !== "true") {
await setTimeout(400);
connection.node.leaveChannel(voiceChannel.guildID);
players.delete(voiceChannel.guildID);
queues.delete(voiceChannel.guildID);
skipVotes.delete(voiceChannel.guildID);
try {
const content = `🔊 The voice channel session in \`${voiceChannel.name}\` has ended.`;
if (options.type === "classic") {
await client.rest.channels.createMessage(options.channel.id, { content });
} else {
if ((Date.now() - options.interaction.createdAt) >= 900000) {
await client.rest.channels.createMessage(options.channel.id, { content });
} else {
await options.interaction.createFollowup({ content });
}
}
} catch {
// no-op
}
}
if (options.type === "classic") {
try {
if (playingMessage.channel.messages.has(playingMessage.id)) await playingMessage.delete();
if (player?.playMessage.channel.messages.has(player.playMessage.id)) await player.playMessage.delete();
} catch {
// no-op
}
}
});
}
export async function errHandle(exception, client, connection, playingMessage, voiceChannel, options, closed) {
try {
if (playingMessage.channel.messages.has(playingMessage.id)) await playingMessage.delete();
const playMessage = players.get(voiceChannel.guildID).playMessage;
if (playMessage.channel.messages.has(playMessage.id)) await playMessage.delete();
} catch {
// no-op
}
players.delete(voiceChannel.guildID);
queues.delete(voiceChannel.guildID);
skipVotes.delete(voiceChannel.guildID);
logger.error(exception);
try {
connection.node.leaveChannel(voiceChannel.guildID);
} catch {
// no-op
}
connection.removeAllListeners("exception");
connection.removeAllListeners("stuck");
connection.removeAllListeners("end");
try {
const content = closed ? `🔊 I got disconnected by Discord and tried to reconnect; however, I got this error instead:\n\`\`\`${exception}\`\`\`` : `🔊 Looks like there was an error regarding sound playback:\n\`\`\`${exception.type}: ${exception.error}\`\`\``;
if (options.type === "classic") {
await client.rest.channels.createMessage(playingMessage.channel.id, { content });
} else {
if ((Date.now() - options.interaction.createdAt) >= 900000) {
await client.rest.channels.createMessage(options.channel.id, { content });
} else {
await options.interaction.createFollowup({ content });
}
}
} catch {
// no-op
}
}

View file

@ -1,98 +1,98 @@
import * as logger from "../utils/logger.js";
import { readdir, lstat, rm, writeFile, stat } from "fs/promises";
let dirSizeCache;
export async function upload(client, result, context, interaction = false) {
const filename = `${Math.random().toString(36).substring(2, 15)}.${result.name.split(".")[1]}`;
await writeFile(`${process.env.TEMPDIR}/${filename}`, result.contents);
const imageURL = `${process.env.TMP_DOMAIN || "https://tmp.esmbot.net"}/${filename}`;
const payload = {
embeds: [{
color: 16711680,
title: "Here's your image!",
url: imageURL,
image: {
url: imageURL
},
footer: {
text: "The result image was more than 8MB in size, so it was uploaded to an external site instead."
},
}]
};
if (interaction) {
await context[context.acknowledged ? "editOriginal" : "createMessage"](payload);
} else {
await client.rest.channels.createMessage(context.channelID, Object.assign(payload, {
messageReference: {
channelID: context.channelID,
messageID: context.id,
guildID: context.guildID ?? undefined,
failIfNotExists: false
},
allowedMentions: {
repliedUser: false
}
}));
}
if (process.env.THRESHOLD) {
const size = dirSizeCache + result.contents.length;
dirSizeCache = size;
await removeOldImages(size);
}
}
async function removeOldImages(size) {
if (size > process.env.THRESHOLD) {
const files = (await readdir(process.env.TEMPDIR)).map((file) => {
return lstat(`${process.env.TEMPDIR}/${file}`).then((stats) => {
if (stats.isSymbolicLink()) return;
return {
name: file,
size: stats.size,
ctime: stats.ctime
};
});
});
const resolvedFiles = await Promise.all(files);
const oldestFiles = resolvedFiles.filter(Boolean).sort((a, b) => a.ctime - b.ctime);
do {
if (!oldestFiles[0]) break;
await rm(`${process.env.TEMPDIR}/${oldestFiles[0].name}`);
logger.log(`Removed oldest image file: ${oldestFiles[0].name}`);
size -= oldestFiles[0].size;
oldestFiles.shift();
} while (size > process.env.THRESHOLD);
const newSize = oldestFiles.reduce((a, b) => {
return a + b.size;
}, 0);
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;
import * as logger from "../utils/logger.js";
import { readdir, lstat, rm, writeFile, stat } from "fs/promises";
let dirSizeCache;
export async function upload(client, result, context, interaction = false) {
const filename = `${Math.random().toString(36).substring(2, 15)}.${result.name.split(".")[1]}`;
await writeFile(`${process.env.TEMPDIR}/${filename}`, result.contents);
const imageURL = `${process.env.TMP_DOMAIN || "https://tmp.esmbot.net"}/${filename}`;
const payload = {
embeds: [{
color: 16711680,
title: "Here's your image!",
url: imageURL,
image: {
url: imageURL
},
footer: {
text: "The result image was more than 8MB in size, so it was uploaded to an external site instead."
},
}]
};
if (interaction) {
await context[context.acknowledged ? "editOriginal" : "createMessage"](payload);
} else {
await client.rest.channels.createMessage(context.channelID, Object.assign(payload, {
messageReference: {
channelID: context.channelID,
messageID: context.id,
guildID: context.guildID ?? undefined,
failIfNotExists: false
},
allowedMentions: {
repliedUser: false
}
}));
}
if (process.env.THRESHOLD) {
const size = dirSizeCache + result.contents.length;
dirSizeCache = size;
await removeOldImages(size);
}
}
async function removeOldImages(size) {
if (size > process.env.THRESHOLD) {
const files = (await readdir(process.env.TEMPDIR)).map((file) => {
return lstat(`${process.env.TEMPDIR}/${file}`).then((stats) => {
if (stats.isSymbolicLink()) return;
return {
name: file,
size: stats.size,
ctime: stats.ctime
};
});
});
const resolvedFiles = await Promise.all(files);
const oldestFiles = resolvedFiles.filter(Boolean).sort((a, b) => a.ctime - b.ctime);
do {
if (!oldestFiles[0]) break;
await rm(`${process.env.TEMPDIR}/${oldestFiles[0].name}`);
logger.log(`Removed oldest image file: ${oldestFiles[0].name}`);
size -= oldestFiles[0].size;
oldestFiles.shift();
} while (size > process.env.THRESHOLD);
const newSize = oldestFiles.reduce((a, b) => {
return a + b.size;
}, 0);
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;
}

View file

@ -1,16 +1,16 @@
export default (string) => {
const protocolAndDomainRE = /^(?:\w+:)?\/\/(\S+)$/;
const domainRE = /^[^\s.]+\.\S{2,}$/;
const match = string.match(protocolAndDomainRE);
if (!match) {
return false;
}
const everythingAfterProtocol = match[1];
if (!everythingAfterProtocol) {
return false;
}
if (domainRE.test(everythingAfterProtocol)) {
return true;
}
return false;
};
export default (string) => {
const protocolAndDomainRE = /^(?:\w+:)?\/\/(\S+)$/;
const domainRE = /^[^\s.]+\.\S{2,}$/;
const match = string.match(protocolAndDomainRE);
if (!match) {
return false;
}
const everythingAfterProtocol = match[1];
if (!everythingAfterProtocol) {
return false;
}
if (domainRE.test(everythingAfterProtocol)) {
return true;
}
return false;
};

View file

@ -1,12 +1,12 @@
export default (str) => {
var regexString = ".{1,15}([\\s\u200B]+|$)|[^\\s\u200B]+?([\\s\u200B]+|$)";
var re = new RegExp(regexString, "g");
var lines = str.match(re) || [];
var result = lines.map((line) => {
if (line.slice(-1) === "\n") {
line = line.slice(0, line.length - 1);
}
return line;
}).join("\n");
return result;
export default (str) => {
var regexString = ".{1,15}([\\s\u200B]+|$)|[^\\s\u200B]+?([\\s\u200B]+|$)";
var re = new RegExp(regexString, "g");
var lines = str.match(re) || [];
var result = lines.map((line) => {
if (line.slice(-1) === "\n") {
line = line.slice(0, line.length - 1);
}
return line;
}).join("\n");
return result;
};