WIP Big refactor, using Mongoose now, refactor Plugins & Commands.

master
Auri 2021-03-11 23:51:28 -08:00
parent 4dfe0b836e
commit ba5b154fad
22 changed files with 2432 additions and 1203 deletions

6
nodemon.json Executable file
View File

@ -0,0 +1,6 @@
{
"watch": [ "src" ],
"ext": ".ts,.js,.tsx,.json",
"ignore": [],
"exec": "ts-node --project ./tsconfig.json ./src/Main.ts -- --verbose"
}

2175
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,24 +4,33 @@
"description": "A simple discord bot for tracking user levels.",
"main": "build/Main.js",
"scripts": {
"start": "node ./build/Main.js",
"dev": "nodemon .",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Nicole Collings (Aurailus)",
"author": "Auri Collings",
"license": "UNLICENSED",
"dependencies": {
"@types/node": "^12.12.22",
"ansi-colors": "^4.1.1",
"bufferutil": "^4.0.1",
"discord.js": "^11.5.1",
"bufferutil": "^4.0.3",
"discord.js": "^12.5.1",
"file-api": "^0.10.4",
"filereader": "^0.10.3",
"form-data": "^2.5.1",
"image-to-base64": "^2.0.1",
"jimp": "^0.6.8",
"form-data": "^4.0.0",
"image-to-base64": "^2.1.1",
"jimp": "^0.16.1",
"lowdb": "^1.0.0",
"request": "^2.88.0",
"tsc": "^1.20150623.0",
"mongodb": "^3.6.4",
"mongoose": "^5.12.0",
"request": "^2.88.2",
"toml": "^3.0.0",
"tslib": "^2.1.0",
"xmlhttprequest": "^1.8.0"
},
"devDependencies": {
"@types/mongodb": "^3.6.7",
"@types/node": "^12.20.1",
"log4js": "^6.3.0",
"nodemon": "^2.0.7",
"ts-node": "^9.1.1",
"typescript": "^4.1.5"
}
}

View File

@ -1,95 +1,166 @@
const c = require('ansi-colors');
const low = require('lowdb');
const FileSync = require('lowdb/adapters/FileSync');
// const low = require('lowdb');
// const FileSync = require('lowdb/adapters/FileSync');
// import { MongoClient, Db } from 'mongodb';
import Mongoose from 'mongoose';
import * as Discord from 'discord.js';
import {getFatalCallback} from './Main';
import {BotConf} from "./BotConf";
import {BotStorage} from "./BotStorage";
import {Database} from "./Database";
// import {BotConf} from './BotConf';
// import {BotStorage} from './BotStorage';
// import {Database} from './Database';
import {Command} from "./Commands/Command";
import {ChatChannels} from "./Modules/ChatChannels";
import {Leveller} from "./Modules/Leveller";
import LevelPlugin from './Plugin/Level/LevelPlugin';
import VoiceChatPlugin from './Plugin/VoiceChat/VoiceChatPlugin';
import {Help} from "./Commands/Help";
import {Level} from "./Commands/Level";
import {Haystack} from "./Commands/Haystack";
import {Leaderboard} from "./Commands/Leaderboard";
// import {ChatChannels} from './Modules/ChatChannels';
export class Bot {
config: BotConf;
client: Discord.Client;
storage: BotStorage;
import { Command, CommandFn } from './Commands/Command';
import Help from './Commands/Help';
// import {Level} from './Commands/Level';
// import {Haystack} from './Commands/Haystack';
// import {Leaderboard} from './Commands/Leaderboard';
chatChannels: ChatChannels;
leveller: Leveller;
import log4js from 'log4js';
commands: Command[] = [];
const logger = log4js.getLogger();
constructor(config: BotConf) {
this.config = config;
this.client = new Discord.Client();
this.storage = new BotStorage(config);
export interface BotConfig {
auth: {
discord: string;
mongo_url: string;
mongo_db: string;
};
const adapter = new FileSync('./data/db.json');
this.storage.db = new Database(low(adapter));
options: {
status: string
prefix: string;
delete_triggers: boolean;
}
connect(): Promise<Bot> {
return new Promise((resolve, reject) => {
this.client.login(this.config.token);
this.client.on('ready', () => {
console.log(`Successfully connected as ${c.cyan(this.client.user.tag)}.`);
console.log(`Version 1.0.1`);
this.client.user.setActivity(this.config.playing_tag.message, {type: this.config.playing_tag.type});
this.client.user.setStatus('online');
resolve(this);
});
this.client.on('error', (error: Error) => {
reject(error);
});
this.client.on('message', (msg) => {
for (let command of this.commands) {
if (msg.content.substr(0, command.prefix.length).toLowerCase() == command.prefix.toLowerCase()) {
command.exec(msg);
return;
}
plugin: {
level?: {
please_and_thank_you: boolean;
message: {
cooldown: number;
min_length: number;
},
levels: {
[num: string]: {
name: string;
experience: number;
role: number;
}
})
});
}
bindFunctions(): Promise<Bot> {
return new Promise((resolve, reject) => {
try {
this.chatChannels = new ChatChannels(this.client, this.storage);
this.leveller = new Leveller(this.client, this.storage);
this.commands.push(new Help(this.client, this.storage));
this.commands.push(new Level(this.client, this.storage));
this.commands.push(new Haystack(this.client, this.storage));
this.commands.push(new Leaderboard(this.client, this.storage));
resolve(this);
}
catch (e) { reject(e); }
});
}
async shutDown() {
try {
await this.chatChannels.cleanup();
await this.leveller.cleanup();
console.log(`Shut down gracefully.`);
}
catch (e) {
getFatalCallback("Shutdown")(e);
}
}
}
export default class Bot {
private config: BotConfig;
private client: Discord.Client;
// private db: ;
// storage: BotStorage;
// chatChannels: ChatChannels;
// leveller: Leveller;
private plugins: any[] = [];
private commands: { [command: string]: Command | CommandFn } = {};
constructor(config: BotConfig) {
this.config = config;
this.client = new Discord.Client();
// this.storage = new BotStorage(config);
// const adapter = new FileSync('./data/db.json');
// this.storage.db = new Database(low(adapter));
}
/**
* Initializes the connection to discord, and binds a shutdown handler.
*
* @returns the bot, once initialization is complete.
*/
async init(): Promise<this> {
await this.connect();
await this.bind();
process.on('SIGINT', () => this.onInterrupt().then(() => process.exit()));
return this;
}
/**
* Attempts to connect the client to discord, and sets its status to online.
*
* @returns a promise indicating the success state of the connection.
*/
private async connect() {
await new Promise((resolve, reject) => {
Mongoose.connect(this.config.auth.mongo_url, { useNewUrlParser: true, useUnifiedTopology: true });
Mongoose.set('useFindAndModify', false);
Mongoose.connection.on('error', reject);
Mongoose.connection.once('open', resolve);
});
await new Promise((resolve, reject) => {
this.client.login(this.config.auth.discord);
this.client.on('error', reject);
this.client.once('ready', () => {
const user = this.client.user as Discord.ClientUser;
user.setPresence({
status: 'online',
activity: { name: this.config.options.status, type: 'CUSTOM_STATUS' }
});
logger.info('Successfully connected as %s.', user.tag);
resolve(this);
});
});
}
/**
* Binds plugins to the bot.
*/
private bind() {
this.plugins.push(new LevelPlugin(this.config as any, this.client, this.commands));
this.plugins.push(new VoiceChatPlugin(this.config as any, this.client));
this.commands.help = Help;
// this.chatChannels = new ChatChannels(this.client, this.storage);
// this.leveller = new Leveller(this.client, this.storage);
// this.commands.push(new Haystack(this.client, this.storage));
// this.commands.push(new Leaderboard(this.client, this.storage));
this.client.on('message', (msg) => {
if (!msg.content.startsWith(this.config.options.prefix + ' ')) return;
const full = msg.content.substr(this.config.options.prefix.length + 1).trim();
const command = full.substr(0, full.indexOf(' ') === -1 ? full.length : full.indexOf(' ')).toLowerCase().trimLeft();
const args = command.substr(command.length).trimLeft().split(' ');
const cmd = this.commands[command];
if (typeof cmd === 'function') cmd(msg, command, args);
else if (typeof cmd === 'object') cmd.trigger(msg, command, args);
});
}
/**
* Performs cleanup activities. Bound to SIGINT after the bot has been set up.
*/
private async onInterrupt() {
try {
await Promise.all(this.plugins.map(async p => p.cleanup?.()));
logger.info('Shut down successfully.');
}
catch (e) {
logger.fatal('Error shutting down k9:\n%s', e);
}
}
}

View File

@ -1,15 +0,0 @@
import * as Discord from 'discord.js';
export class BotConf {
token: string;
playing_tag: {
type: Discord.ActivityType,
message: string
};
command_prefix: string;
delete_triggers: boolean;
xp_properties: {
level_base_cost: number,
level_multiplier: number
};
}

View File

@ -1,22 +0,0 @@
import * as Discord from 'discord.js';
import {BotConf} from "./BotConf";
import {GuildData} from "./GuildData";
import {Database} from "./Database";
export class BotStorage {
db: Database;
conf: BotConf;
guildData: {[key: string]: GuildData} = {};
constructor(conf: BotConf) {
this.conf = conf;
}
getGuild(guild: Discord.Guild): GuildData {
if (!this.guildData[guild.id]) {
this.guildData[guild.id] = new GuildData(guild);
}
return this.guildData[guild.id];
}
}

View File

@ -1,48 +1,7 @@
import * as Discord from 'discord.js';
import {BotStorage} from "../BotStorage";
export type CommandFn = (msg: Discord.Message, command: string, args: string[]) => void;
export class Command {
client: Discord.Client;
storage: BotStorage;
prefix: string;
constructor(client: Discord.Client, storage: BotStorage) {
this.client = client;
this.storage = storage;
this.prefix = storage.conf.command_prefix + " ";
}
exec(msg: Discord.Message) {/*Virtual Method*/}
deleteTrigger(msg: Discord.Message) {
if (this.storage.conf.delete_triggers) {
msg.delete().catch((e) => this.sendErrorMessage(msg, e));
}
}
sendErrorMessage(msg: Discord.Message, e: Discord.DiscordAPIError | string) {
const embed = new Discord.RichEmbed()
.setAuthor("Error", "https://i.imgur.com/qSHm1lQ.png")
.setColor("#D60058")
.setFooter(`Requested by ${(msg.member) ? msg.member.displayName : msg.author.username}`, msg.author.avatarURL)
.setTimestamp();
if (typeof e == "string") {
embed.setDescription(e);
}
else {
embed.setDescription(`An unknown error occured: ${e.message}`)
switch (e.message) {
case "Missing Permissions": {
embed.setDescription(`\`delete_triggers\` is set to true, but the bot does not have the \`Manage Messages\` permission.`);
}
case "Cannot execute action on a DM channel": {
return;
}
}
}
msg.channel.send({embed}).catch((e) => {/*Missing send message permissions for the channel*/});
}
export interface Command {
trigger: CommandFn;
}

View File

@ -1,113 +0,0 @@
const requestPost = require('request');
const requestGet = require('request').defaults({encoding: null});
import * as Discord from 'discord.js';
import {Command} from "./Command"
import {BotStorage} from "../BotStorage";
export class Haystack extends Command {
constructor(client: Discord.Client, storage: BotStorage) {
super(client, storage);
this.prefix += "haystack";
}
exec(msg: Discord.Message) {
if (msg.attachments.size == 0) {
const embed = new Discord.RichEmbed()
.setAuthor("Haystack", "https://i.imgur.com/qSHm1lQ.png")
.setColor("#D60058")
.setDescription(`Please Attach an image to use Haystack.`)
.setFooter(`Requested by ${(msg.member) ? msg.member.displayName : msg.author.username}`, msg.author.avatarURL)
.setTimestamp();
msg.channel.send(embed).catch((e) => {/*Missing permissions to send to channel*/});
return;
}
const embed = new Discord.RichEmbed()
.setAuthor("Haystack", "https://i.imgur.com/pPObkMW.png")
.setColor("#7189D8")
.setDescription(`Preparing...`)
.setFooter(`Requested by ${(msg.member) ? msg.member.displayName : msg.author.username}`, msg.author.avatarURL)
.setTimestamp();
msg.channel.send(embed).then(newMsg => {
const errEmbed = new Discord.RichEmbed()
.setAuthor("Haystack", "https://i.imgur.com/qSHm1lQ.png")
.setColor("#D60058")
.setFooter(`Requested by ${(msg.member) ? msg.member.displayName : msg.author.username}`, msg.author.avatarURL)
.setTimestamp();
if (Array.isArray(newMsg)) {
errEmbed.setDescription(`Internal error: [Array.isArray]`);
(newMsg as any as Discord.Message).edit(errEmbed);
return;
}
const imageUrl = msg.attachments.first().url;
requestGet.get(imageUrl, (err, response, body) => {
if (err) {
errEmbed.setDescription(`There was an error getting the image: ${err}`);
(newMsg as any as Discord.Message).edit(errEmbed);
return;
}
if (response.statusCode == 200) {
const newEmbed = new Discord.RichEmbed()
.setAuthor("Haystack", "https://i.imgur.com/pPObkMW.png")
.setColor("#7189D8")
.setDescription(`Processing Image...`)
.setFooter(`Requested by ${(msg.member) ? msg.member.displayName : msg.author.username}`, msg.author.avatarURL)
.setTimestamp();
(newMsg as any as Discord.Message).edit(newEmbed);
requestPost.post({
url: "https://api.haystack.ai/api/image/analyze?output=json&apikey=c91b373cc011946774767cf7220d7f64&model=age&model=gender&model=attractiveness",
body: new Buffer(body)
}, (err, response, body) => {
if (err) {
errEmbed.setDescription(`There was an error processing the image: ${err}`);
(newMsg as any as Discord.Message).edit(errEmbed);
return;
}
if (response.statusCode == 200) {
let json: any = JSON.parse(body);
if (json.people.length == 0) {
errEmbed.setDescription(`Haystack can't find any people in the image.`);
(newMsg as any as Discord.Message).edit(errEmbed);
return;
}
if (json.people.length > 1) {
errEmbed.setDescription(`Haystack found multiple people in the image.\nThe \`k9 haystack\` command only supports one.`);
(newMsg as any as Discord.Message).edit(errEmbed);
return;
}
let person: any = json.people[0];
let gender = (person.gender.gender == "female") ? "Female" : "Male";
newEmbed.setDescription(`Powered by [Haystack.ai](https://haystack.ai)`);
newEmbed.addField("Age", person.age);
newEmbed.addField("Gender", `${gender} (${Math.round(person.gender.confidence * 100)}%)`);
newEmbed.addField("Attractiveness", `${Math.round(person.attractiveness * 100) / 100} / 10`);
newEmbed.setImage(imageUrl);
}
else {
errEmbed.setDescription(`[${response.statusCode}] ${body}`);
(newMsg as any as Discord.Message).edit(errEmbed);
return;
}
});
}
});
});
}
}

View File

@ -1,31 +1,19 @@
import * as Discord from 'discord.js';
import {Command} from "./Command"
import {BotStorage} from "../BotStorage";
export default function Help(msg: Discord.Message) {
msg.channel.send({
embed: new Discord.MessageEmbed()
.setAuthor('K9 Help', 'https://cdn.discordapp.com/avatars/613569990297255938/5c6883f8b8f324fe38cf5d1a8361339a.webp?size=64')
.setColor('#EE86ED')
.setDescription(
'Hi, I\'m k9! I\'m a user level tracking bot made by Auri#1311. ' +
'I assign users levels and automagically grants users roles once they reach certain level thresholds. ' +
'I also have a few simple commands available to interact with me.')
.setFooter(`Requested by ${(msg.member) ? msg.member.displayName : msg.author.username}`, msg.author.avatarURL({ size: 32 })!)
.setTimestamp()
export class Help extends Command {
constructor(client: Discord.Client, storage: BotStorage) {
super(client, storage);
this.prefix += "help";
}
exec(msg: Discord.Message) {
const embed = new Discord.RichEmbed()
.setAuthor("K9 Help", "https://cdn.discordapp.com/avatars/613569990297255938/13a0f7a3818feaa9cbc173f54b30eb9c.png?size=128")
.setColor("#EE86ED")
.setDescription(
`Hi, I'm k9! I'm a user level tracking bot made by Aurailus#4014. ` +
`I assign users levels and automagically grants users roles once they reach certain level thresholds. ` +
`I also have a few simple commands available to interact with me.`)
.setFooter(`Requested by ${(msg.member) ? msg.member.displayName : msg.author.username}`, msg.author.avatarURL)
.setTimestamp()
.addField("`k9 help`", `Sends this message.`)
.addField("`k9 level`", `Displays your level, XP, and rank.`)
.addField("`k9 leaderboard`", `Shows the top ranked users in the current server.`)
.addField("`k9 haystack`", `Runs an attached image through haystack.ai to determine various statistics.`)
msg.channel.send({embed}).catch((e) => {/*Missing send message permissions for the channel*/});
super.deleteTrigger(msg);
}
.addField('`k9 help`', 'Sends this message.')
.addField('`k9 level`', 'Displays your level, XP, and rank.')
.addField('`k9 leaderboard`', 'Shows the top ranked users in the current server.')
}).catch(_ => { /* Missing send permissions. */ });
}

View File

@ -1,48 +0,0 @@
import * as Discord from 'discord.js';
import {Command} from "./Command"
import {BotStorage} from "../BotStorage";
import {BotLevelRoles, Database, DBServer, DBUser} from "../Database";
export class Leaderboard extends Command {
constructor(client: Discord.Client, storage: BotStorage) {
super(client, storage);
this.prefix += "leaderboard";
}
exec(msg: Discord.Message) {
if (!msg.guild) {
super.sendErrorMessage(msg, "This command must be called from within a server.");
return;
}
let server: DBServer = this.storage.db.getServer(msg.guild);
// let user: DBUser = server.getUser(msg.member);
let users: DBUser[] = this.storage.db.getServer(msg.guild).getTopUsers();
const embed = new Discord.RichEmbed()
.setAuthor("Leaderboard", "https://i.imgur.com/LaPvO6n.png")
.setColor("#FFAC38")
.setDescription(`The most active members in ${msg.guild.name}.`)
.setFooter(`Requested by ${msg.member.displayName}`, msg.author.avatarURL)
.setTimestamp()
.addBlankField();
for (let i = 0; i < users.length; i++) {
const user = msg.guild.members.get(users[i].id);
let name = "Removed";
if (user) name = user.displayName;
if (name.length >= 20) name = name.substr(0, 18) + "...";
embed.addField(
`${i < 3 ? "**" : ""}${i + 1}) ${name}${i < 3 ? "**" : ""}`,
`Level ${users[i].level}${Math.floor(users[i].totalXP)} XP`, true);
}
embed.addBlankField();
msg.channel.send({embed}).catch(e => {/*Missing send message permissions for the channel*/});
super.deleteTrigger(msg);
}
}

View File

@ -1,48 +0,0 @@
import * as Discord from 'discord.js';
import {Command} from "./Command"
import {BotStorage} from "../BotStorage";
import {BotLevelRoles, Database, DBServer, DBUser} from "../Database";
export class Level extends Command {
constructor(client: Discord.Client, storage: BotStorage) {
super(client, storage);
this.prefix += "level";
}
exec(msg: Discord.Message) {
if (!msg.guild) {
super.sendErrorMessage(msg, "This command must be called from within a server.");
return;
}
let server: DBServer = this.storage.db.getServer(msg.guild);
let user: DBUser = server.getUser(msg.member);
const cost = (this.storage.conf.xp_properties.level_base_cost + Math.pow(user.level, this.storage.conf.xp_properties.level_multiplier));
let currentRole = -1;
const roles = server.getLevelRolesTable();
for (let role in roles) {
let num = parseInt(role);
if (num <= user.level && num > currentRole) currentRole = num;
}
let role = (currentRole == -1) ? "Potato" : msg.guild.roles.find(r => r.id == roles[currentRole]).name;
const embed = new Discord.RichEmbed()
.setAuthor("My Level", "https://i.imgur.com/Nqyb94h.png")
.setColor("#15B5A6")
.setDescription(`Statistics for ${msg.member.displayName} in ${msg.guild.name}.`)
.setFooter(`Requested by ${msg.member.displayName}`, msg.author.avatarURL)
.setTimestamp()
.addField(`Level`, `${user.level}`, true)
.addField(`XP`, `${Math.floor(user.levelXP)} / ${Math.ceil(cost)}`, true)
.addField(`Rank`, `${role}`, true)
msg.channel.send({embed}).catch(e => {/*Missing send message permissions for the channel*/});
super.deleteTrigger(msg);
}
}

View File

@ -1,82 +0,0 @@
import * as Discord from 'discord.js';
export class Database {
db: any; //LowDB
constructor(db: any) {
this.db = db;
this.db.defaults({ servers: [] }).write();
}
getServer(guild: Discord.Guild): DBServer {
let server: any = this.db.get('servers').find({id: guild.id});
if (!server.value()) {
const serverTable = {
id: guild.id,
levelRoles: {},
users: []
}
this.db.get('servers').push(serverTable).write();
return new DBServer(this.db, this.db.get('servers').find({id: guild.id}));
}
return new DBServer(this.db, server);
}
}
export class DBServer {
id: string;
server: any;
db: any;
constructor(db: any, server: any /*LowDB result*/) {
this.id = server.value().id;
this.db = db;
this.server = server;
}
getLevelRolesTable() : BotLevelRoles {
return this.server.get('levelRoles').value();
}
getUser(member: Discord.GuildMember) : DBUser {
let user: any = this.server.get('users').find({id: member.id});
if (!user.value()) {
const userTable: DBUser = {
id: member.id,
lastInstigated: Date.now() - 60 * 1000,
lastPosted: Date.now() - 60 * 1000,
level: 0,
levelXP: 0,
totalXP: 0,
messages: 0
}
this.server.get('users').push(userTable).write();
return this.server.get('users').find({id: member.id});
}
return user.value();
}
getTopUsers() : DBUser[] {
return this.server.get('users').sortBy('totalXP').reverse().take(9).value();
}
pushUser(user: DBUser) {
this.server.get('users').find({id: user.id}).assign(user).write();
}
}
export interface BotLevelRoles {[key: string]: string}
export interface DBUser {
id: string,
lastInstigated: number,
lastPosted: number,
level: number,
levelXP: number,
totalXP: number,
messages: number
}

View File

@ -1,10 +0,0 @@
import * as Discord from 'discord.js';
export class GuildData {
guild: Discord.Guild;
chatChannels: {[key: string]: Discord.Snowflake} = {};
constructor(guild: Discord.Guild) {
this.guild = guild;
}
}

View File

@ -1,38 +1,18 @@
const fs = require('fs').promises;
const c = require('ansi-colors');
import { promises as fs } from 'fs';
import * as Discord from 'discord.js';
import log4js from 'log4js';
import { parse } from 'toml';
import Bot, { BotConfig } from "./Bot";
import {BotConf} from "./BotConf";
import {Bot} from "./Bot";
const logger = log4js.getLogger();
logger.level = 'debug';
export function getFatalCallback(prefix: string, exit: boolean = true) {
return function(err: Error) {
console.error(c.bgRed.bold.white(`[${prefix}] A fatal error has occured:\n${err.toString()}.\n`));
if (exit) process.exit(0);
(async () => {
try {
const conf = parse((await fs.readFile('./data/conf.toml')).toString()) as BotConfig;
await new Bot(conf).init();
}
}
function start() {
fs.access("./data").then(() => {
return fs.access("./data/conf.json");
}).then(() => {
return fs.readFile("./data/conf.json");
}).then((resp: Buffer) => {
try {
const conf: BotConf = JSON.parse(resp.toString());
let bot = new Bot(conf);
return bot.connect();
}
catch(e) { getFatalCallback("Conf Parsing")(e); }
}).then((bot: Bot) => {
return bot.bindFunctions();
}).then((bot: Bot) => {
process.on('SIGINT', async () => {
await bot.shutDown();
process.exit();
});
}).catch(getFatalCallback("Main.ts"));
}
start();
catch (e) {
logger.fatal('Error initializing k9:\n%s', e);
}
})();

View File

@ -1,86 +0,0 @@
import * as Discord from 'discord.js';
import {BotStorage} from "../BotStorage";
import {GuildData} from "../GuildData";
export class ChatChannels {
client: Discord.Client;
storage: BotStorage;
PREFIX = "**Temporary discussion for ";
SUFFIX = ".**"
constructor(client: Discord.Client, storage: BotStorage) {
this.client = client;
this.storage = storage;
client.on("voiceStateUpdate", (om, nm) => this.voiceStateUpdate(om, nm));
this.client.guilds.forEach((guild, guildKey) => {
let guildData = this.storage.getGuild(guild);
guild.channels.forEach((channel, channelKey) => {
if (channel.type == "voice") {
if ((channel as Discord.VoiceChannel).members.size >= 1 && (channel as Discord.VoiceChannel).name != "afk") {
this.createChatChannel(channel as Discord.VoiceChannel, guildData);
}
}
});
})
}
voiceStateUpdate(oldMember: Discord.GuildMember, member: Discord.GuildMember): void {
let guild: GuildData = this.storage.getGuild(member.guild);
if (member.voiceChannel == oldMember.voiceChannel) return;
if (member.voiceChannel && member.voiceChannel.parent) {
if (member.voiceChannel.members.size == 1) {
this.createChatChannel(member.voiceChannel, guild);
}
}
if (oldMember.voiceChannel && oldMember.voiceChannel.parent) {
if (oldMember.voiceChannel.members.size == 0) {
let channelId = guild.chatChannels[oldMember.voiceChannelID];
if (oldMember.client.channels.get(channelId)) oldMember.client.channels.get(channelId).delete()
.catch((e) => {/*Channel was already deleted*/})
.finally(() => {
delete guild.chatChannels[oldMember.voiceChannelID];
});
}
}
}
createChatChannel(voiceChannel: Discord.VoiceChannel, guild: GuildData) {
let channelName = voiceChannel.name.replace(/[\W_]+/g,"-") + "-chat";
voiceChannel.guild.createChannel(channelName, {
type: `text`,
topic: `${this.PREFIX}<#${voiceChannel.id}>${this.SUFFIX}`,
parent: voiceChannel.parent
}).then((channel: Discord.TextChannel) => {
guild.chatChannels[voiceChannel.id] = channel.id;
const embed = new Discord.RichEmbed()
.setAuthor(channelName, "https://i.imgur.com/vitVUtr.png")
.setColor("#EE86ED")
.setDescription(
`This is a temporary discussion channel for ${channelName}!\n` +
`This channel will be automatically deleted when everybody leaves the voice channel.\n`)
.setTimestamp()
channel.send({embed}).catch((e) => {/*Missing send message permissions for the channel*/});
}).catch((e) => {/*Channel was deleted before the promise callback*/});
}
async cleanup() {
for (let key in this.storage.guildData) {
let guild: GuildData = this.storage.guildData[key];
for (let key in guild.chatChannels) {
let chat = guild.chatChannels[key];
await guild.guild.channels.get(chat).delete().catch(e => {/*Channel was already deleted*/});
}
}
}
}

View File

@ -1,208 +0,0 @@
const fs = require('fs');
import * as Discord from 'discord.js';
import {BotStorage} from "../BotStorage";
import {GuildData} from "../GuildData";
import {LevelImageBuilder} from "./LevelImageBuilder"
import {BotLevelRoles, Database, DBServer, DBUser} from "../Database";
export class Leveller {
client: Discord.Client;
storage: BotStorage;
imgBuilder: LevelImageBuilder;
checkVAInterval: any;
constructor(client: Discord.Client, storage: BotStorage) {
this.client = client;
this.storage = storage;
this.imgBuilder = new LevelImageBuilder();
client.on("message", (msg) => this.onMessage(msg));
this.checkVAInterval = setInterval(this.checkVoiceActivity.bind(this), 5*1000*60);
}
checkVoiceActivity(): void {
for (let guildKey in this.storage.guildData) {
let guild = this.storage.guildData[guildKey];
for (let activeVoiceChannel in guild.chatChannels) {
let channel = guild.guild.channels.get(activeVoiceChannel) as Discord.VoiceChannel;
let undeafenedUsers: number = 0;
channel.members.forEach((member, key) => {
if (!member.selfDeaf) undeafenedUsers++;
});
if (undeafenedUsers >= 2) {
channel.members.forEach((member, key) => {
if (!member.selfDeaf && !member.selfMute) {
let chatChannel = (guild.guild.channels.get(guild.chatChannels[activeVoiceChannel]) as Discord.TextChannel);
let server: DBServer = this.storage.db.getServer(guild.guild);
let user: DBUser = server.getUser(member);
let xp = Math.round(Math.random() + 0.3);
user.levelXP += xp;
user.totalXP += xp;
const cost = (this.storage.conf.xp_properties.level_base_cost + Math.pow(user.level, this.storage.conf.xp_properties.level_multiplier));
if (user.levelXP >= cost) {
user.level++;
user.levelXP -= cost;
if (chatChannel) {
this.imgBuilder.generate(member.displayName, user.level, member.id).then(image => {
chatChannel.send("", {file: image as any}).then(() => {
fs.unlinkSync(image);
});
});
}
let currentRole = -1;
let previousRole = -1;
const roles: BotLevelRoles = server.getLevelRolesTable();
for (let role in roles) {
let num = parseInt(role);
if (num <= user.level && num > currentRole) currentRole = num;
if (num <= user.level - 1 && num > previousRole) previousRole = num;
}
if (currentRole != previousRole) {
if (previousRole != -1) member.removeRole(member.guild.roles.find(r => r.id == roles[previousRole]));
if (currentRole != -1) member.addRole(member.guild.roles.find(r => r.id == roles[currentRole]), 'Update user level role.');
}
}
server.pushUser(user);
}
});
}
}
}
}
onMessage(msg: Discord.Message): void {
// Don't allow the bot itself to gain levels.
if (msg.author.id == this.client.user.id) return;
// Don't count bot commands.
if (msg.content.substr(0, this.storage.conf.command_prefix.length).toLowerCase() == this.storage.conf.command_prefix) return;
// Enforce minimum content requirements for XP gain
// Don't count messages less than N characters and without a space.
if (msg.content.length < 6 || msg.content.split(" ").length - 1 < 1) return;
// Don't count DM conversations
if (!msg.guild) return;
const guild = this.storage.getGuild(msg.guild);
let server: DBServer = this.storage.db.getServer(msg.guild);
let user: DBUser = server.getUser(msg.member);
const time = Date.now();
user.messages++;
let xp = Math.round(Math.random() + Math.min(msg.content.length / 70, 3.0) * 100) / 100;
let thankedTheDog = false;
if (msg.content.toLowerCase().substr(0, 8) == "good dog") {
// Thank the dog
msg.channel.fetchMessages({ limit: 2 }).then(messages => {
let lastMsg = messages.last();
if (lastMsg.author.id == this.client.user.id && lastMsg.attachments.first()) {
let filename = lastMsg.attachments.first().filename;
let user = filename.substr(0, filename.length - 4);
if (user == msg.member.id) {
msg.reply("woof!");
xp += Math.random() * 6;
thankedTheDog = true;
}
}
});
}
if (/good.(bo[i|y]|g[u|i]rl)/gi.test(msg.content.toLowerCase())) {
// Make dog sad
msg.channel.fetchMessages({ limit: 2 }).then(messages => {
let lastMsg = messages.last();
if (lastMsg.author.id == this.client.user.id && lastMsg.attachments.first()) {
let filename = lastMsg.attachments.first().filename;
let user = filename.substr(0, filename.length - 4);
if (user == msg.member.id) {
msg.reply("I'm enby tho :(");
return;
}
}
});
}
for (let voice in guild.chatChannels) {
if (guild.chatChannels[voice] == msg.channel.id) {
xp /= 3;
}
}
// Ignore the first message in a while to prevent single spam messages gaining XP
if (!thankedTheDog && (!user.lastInstigated || time - user.lastInstigated >= 300 * 1000)) {
user.lastInstigated = time;
//Don't score the message if it is less than 30 chars long (a "useless" message)
if (msg.content.length < 30) {
server.pushUser(user);
return;
}
}
// Only count messages every 30 seconds
if (thankedTheDog || (time - user.lastPosted >= 30 * 1000)) {
user.lastPosted = time;
user.lastInstigated = time;
user.levelXP += xp;
user.totalXP += xp;
const cost = (this.storage.conf.xp_properties.level_base_cost + Math.pow(user.level, this.storage.conf.xp_properties.level_multiplier));
if (user.levelXP >= cost) {
user.level++;
user.levelXP -= cost;
this.imgBuilder.generate(msg.member.displayName, user.level, msg.author.id).then(image => {
msg.channel.send("", {file: image as any}).then(() => {
fs.unlinkSync(image);
});
});
let currentRole = -1;
let previousRole = -1;
const roles: BotLevelRoles = server.getLevelRolesTable();
for (let role in roles) {
let num = parseInt(role);
if (num <= user.level && num > currentRole) currentRole = num;
if (num <= user.level - 1 && num > previousRole) previousRole = num;
}
if (currentRole != previousRole) {
if (previousRole != -1) msg.member.removeRole(msg.guild.roles.find(r => r.id == roles[previousRole]));
if (currentRole != -1) msg.member.addRole(msg.guild.roles.find(r => r.id == roles[currentRole]), 'Update user level role.');
}
}
}
server.pushUser(user);
}
async cleanup() {
clearInterval(this.checkVAInterval);
}
}

View File

@ -0,0 +1,40 @@
import * as Discord from 'discord.js';
import { LevelPluginGuild, LevelPluginUser } from './LevelPlugin';
export default class LevelCommand {
constructor(private roles: { name: string, experience: number, total_experience: number, role: number }[]) {}
async trigger(msg: Discord.Message) {
if (!msg.guild) return;
const guild = await LevelPluginGuild.findOne({ id: msg.guild.id });
if (!guild) return;
const users = (await LevelPluginUser.find({ guild_id: guild._id }).sort({ level: 'desc' }).limit(12)).filter(u => u.id);
const embed = new Discord.MessageEmbed()
.setAuthor("Leaderboard", "https://i.imgur.com/LaPvO6n.png")
.setColor("#FFAC38")
.setDescription(`The most active members in ${msg.guild.name}.`)
.setFooter(`Requested by ${msg.member!.displayName}`, msg.author.avatarURL({ size: 32 })!)
.setTimestamp();
for (let i = 0; i < users.length; i++) {
try {
let name = (await msg.guild.members.fetch(users[i].id ?? "0")).displayName;
let currentRole = null;
for (let role of this.roles) {
if (role.total_experience < users[i].experience) currentRole = role;
else break;
}
if (name.length >= 20) name = name.substr(0, 18) + "...";
embed.addField(
`${i < 3 ? "**" : ""}${i + 1}) ${name}${i < 3 ? "**" : ""}`,
`Level ${currentRole?.name ?? 'Potato'}${Math.floor(users[i].experience ?? 0)} XP`, true);
}
catch (e) {}
}
msg.channel.send({ embed }).catch(_ => { /* Missing send permissions. */ });
}
}

View File

@ -0,0 +1,36 @@
import * as Discord from 'discord.js';
import { LevelPluginGuild, LevelPluginUser } from './LevelPlugin';
export default class LevelCommand {
constructor(private roles: { name: string, experience: number, total_experience: number, role: number }[]) {}
async trigger(msg: Discord.Message) {
if (!msg.guild) return;
const guild = await LevelPluginGuild.findOne({ id: msg.guild.id });
if (!guild) return;
const user = (await LevelPluginUser.findOne({ guild_id: guild._id, id: msg.author.id })) ??
{ experience: 0, level: 0 };
let currentRole = null;
for (let role of this.roles) {
if (role.total_experience < user.experience) currentRole = role;
else break;
}
const nextRole = currentRole ? this.roles[this.roles.indexOf(currentRole) + 1] : this.roles[0];
msg.channel.send({
embed: new Discord.MessageEmbed()
.setAuthor("My Level", "https://i.imgur.com/Nqyb94h.png")
.setColor("#15B5A6")
.setDescription(`Statistics for ${msg.member!.displayName} in ${msg.guild!.name}.`)
.setFooter(`Requested by ${msg.member!.displayName}`, msg.author.avatarURL({ size: 16 })!)
.setTimestamp()
.addField(`Level`, `${user.level}`, true)
.addField(`Experience`, `${Math.floor(user.experience - (currentRole?.total_experience ?? 0))} ` +
(nextRole ? '/ ' + nextRole.experience : ''), true)
.addField(`Rank`, `${currentRole ? currentRole.name : 'Potato'}`, true)
}).catch(_ => { /* Missing send permissions. */ });
}
}

237
src/Plugin/Level/LevelPlugin.ts Executable file
View File

@ -0,0 +1,237 @@
import * as Mongoose from 'mongoose';
import * as Discord from 'discord.js';
import { BotConfig } from '../../Bot';
import { Command, CommandFn } from '../../Commands/Command';
import LevelCommand from './LevelCommand';
import LeaderboardCommand from './LeaderboardCommand';
interface LevelPluginConfig {
please_and_thank_you: boolean;
message: {
cooldown: number;
min_length: number;
}
levels: {
[key: string]: {
name: string;
experience: number;
role: number;
}
}
}
// import {GuildData} from "../GuildData";
// import {LevelImageBuilder} from "./LevelImageBuilder"
// import {BotLevelRoles, Database, DBServer, DBUser} from "../Database";
const levelPluginGuildSchema = new Mongoose.Schema({
id: String
});
interface ILevelPluginGuild extends Mongoose.Document {
id: string;
}
export const LevelPluginGuild = Mongoose.model<ILevelPluginGuild>('LevelPluginGuild', levelPluginGuildSchema);
const levelPluginUserSchema = new Mongoose.Schema({
guild_id: String,
id: String,
level: Number,
experience: Number,
totalMessages: Number,
lastInteracted: Number
});
interface ILevelPluginUser extends Mongoose.Document {
guild_id: ILevelPluginGuild['_id'];
id: string;
level: number;
experience: number;
totalMessages: number;
lastInteracted: number;
}
export const LevelPluginUser = Mongoose.model<ILevelPluginUser>('LevelPluginUser', levelPluginUserSchema);
export default class LevelPlugin {
private roles: { name: string, experience: number, total_experience: number, role: number }[] = [];
// storage: BotStorage;
// imgBuilder: LevelImageBuilder;
// checkVAInterval: any;
constructor(private config: BotConfig & { plugin: { level: LevelPluginConfig } },
private client: Discord.Client, commands: { [command: string]: Command | CommandFn }) {
// this.storage = storage;
// this.imgBuilder = new LevelImageBuilder();
let total_experience = 0;
Object.keys(this.config.plugin.level.levels).map(m => parseInt(m)).sort((a, b) => a - b).forEach(n => {
const role = this.config.plugin.level.levels[n.toString()];
total_experience += role.experience;
this.roles.push({ ...role, total_experience });
});
client.on('message', this.onMessage);
commands.level = new LevelCommand(this.roles);
commands.leaderboard = new LeaderboardCommand(this.roles);
// this.checkVAInterval = setInterval(this.checkVoiceActivity.bind(this), 5*1000*60);
}
private onMessage = async (msg: Discord.Message) => {
if (msg.author.id === this.client.user!.id) return;
if (msg.content.substr(0, this.config.options.prefix.length + 1).toLowerCase() == this.config.options.prefix + ' ') return;
// Completely ignore messages that are less than N characters and without a space.
if (msg.content.length < this.config.plugin.level.message.min_length || msg.content.split(' ').length - 1 < 1) return;
// Ignore DM conversations.
if (!msg.guild) return;
const { _id: guild_id } = await LevelPluginGuild.findOneAndUpdate({ id: msg.guild.id },
{ $setOnInsert: { id: msg.guild.id } },
{ upsert: true, new: true });
const user = await LevelPluginUser.findOneAndUpdate({ id: msg.author.id, guild_id }, {
$setOnInsert: {
guild_id: guild_id,
id: msg.author.id,
experience: 0,
level: 0,
},
$inc: { totalMessages: 1 }
}, { upsert: true, new: true });
let experience = Math.round(Math.random() + Math.min(msg.content.length / 70, 3.0) * 100) / 100;
let thanked = false;
// Allow people to thank the dog... allow the dog to feel emotion.
if (this.config.plugin.level.please_and_thank_you && msg.content.toLowerCase().startsWith('good')) {
const lastMsg = (await msg.channel.messages.fetch({ limit: 2 })).last();
if (lastMsg && lastMsg.author.id !== this.client.user!.id) {
if (/good.(bo[i|y]|g[u|i]rl)/gi.test(msg.content.toLowerCase())) {
msg.reply("I'm enby tho :(");
return;
}
else if (msg.content.toLowerCase().startsWith('good dog') &&
(lastMsg.attachments.first()?.name || '').substring(0, msg.member!.id.length) === msg.member!.id) {
msg.reply('Woof!');
experience += Math.random() * 6;
thanked = true;
}
}
}
// Ignore messages that are too recent.
if (!thanked && (Date.now() - user.lastInteracted < this.config.plugin.level.message.cooldown * 1000)) return;
await LevelPluginUser.findOneAndUpdate({ id: msg.author.id, guild_id }, {
$inc: { experience },
$set: { lastInteracted: Date.now() }
}, { new: true });
// for (let voice in guild.chatChannels) {
// if (guild.chatChannels[voice] == msg.channel.id) {
// xp /= 3;
// }
// }
// const cost = (this.storage.conf.xp_properties.level_base_cost + Math.pow(user.level, this.storage.conf.xp_properties.level_multiplier));
// if (user.levelXP >= cost) {
// user.level++;
// user.levelXP -= cost;
// this.imgBuilder.generate(msg.member.displayName, user.level, msg.author.id).then(image => {
// msg.channel.send("", {file: image as any}).then(() => {
// fs.unlinkSync(image);
// });
// });
// let currentRole = -1;
// let previousRole = -1;
// const roles: BotLevelRoles = server.getLevelRolesTable();
// for (let role in roles) {
// let num = parseInt(role);
// if (num <= user.level && num > currentRole) currentRole = num;
// if (num <= user.level - 1 && num > previousRole) previousRole = num;
// }
// if (currentRole != previousRole) {
// if (previousRole != -1) msg.member.removeRole(msg.guild.roles.find(r => r.id == roles[previousRole]));
// if (currentRole != -1) msg.member.addRole(msg.guild.roles.find(r => r.id == roles[currentRole]), 'Update user level role.');
// }
// }
}
// checkVoiceActivity(): void {
// for (let guildKey in this.storage.guildData) {
// let guild = this.storage.guildData[guildKey];
// for (let activeVoiceChannel in guild.chatChannels) {
// let channel = guild.guild.channels.get(activeVoiceChannel) as Discord.VoiceChannel;
// let undeafenedUsers: number = 0;
// channel.members.forEach((member, key) => {
// if (!member.selfDeaf) undeafenedUsers++;
// });
// if (undeafenedUsers >= 2) {
// channel.members.forEach((member, key) => {
// if (!member.selfDeaf && !member.selfMute) {
// let chatChannel = (guild.guild.channels.get(guild.chatChannels[activeVoiceChannel]) as Discord.TextChannel);
// let server: DBServer = this.storage.db.getServer(guild.guild);
// let user: DBUser = server.getUser(member);
// let xp = Math.round(Math.random() + 0.3);
// user.levelXP += xp;
// user.totalXP += xp;
// const cost = (this.storage.conf.xp_properties.level_base_cost + Math.pow(user.level, this.storage.conf.xp_properties.level_multiplier));
// if (user.levelXP >= cost) {
// user.level++;
// user.levelXP -= cost;
// if (chatChannel) {
// this.imgBuilder.generate(member.displayName, user.level, member.id).then(image => {
// chatChannel.send("", {file: image as any}).then(() => {
// fs.unlinkSync(image);
// });
// });
// }
// let currentRole = -1;
// let previousRole = -1;
// const roles: BotLevelRoles = server.getLevelRolesTable();
// for (let role in roles) {
// let num = parseInt(role);
// if (num <= user.level && num > currentRole) currentRole = num;
// if (num <= user.level - 1 && num > previousRole) previousRole = num;
// }
// if (currentRole != previousRole) {
// if (previousRole != -1) member.removeRole(member.guild.roles.find(r => r.id == roles[previousRole]));
// if (currentRole != -1) member.addRole(member.guild.roles.find(r => r.id == roles[currentRole]), 'Update user level role.');
// }
// }
// server.pushUser(user);
// }
// });
// }
// }
// }
// }
}

View File

@ -0,0 +1,80 @@
import * as Discord from 'discord.js';
import { BotConfig } from '../../Bot';
interface VoiceChatPluginConfig {
description: {
prefix?: string,
suffix?: string
}
channel: {
prefix?: string,
suffix?: string
}
}
export default class VoiceChatPlugin {
private channels: { [guild: string]: { [voice_id: string]: string } } = {};
private description_prefix = "**Temporary discussion for ";
private description_suffix = ".**"
private channel_prefix = "";
private channel_suffix = "-chat"
constructor(config: BotConfig & { plugin: { voice_chat: VoiceChatPluginConfig } }, private client: Discord.Client) {
client.on("voiceStateUpdate", this.onVoiceStateUpdate);
if (config.plugin?.voice_chat?.description?.prefix) this.description_prefix = config.plugin.voice_chat.description.prefix;
if (config.plugin?.voice_chat?.description?.suffix) this.description_suffix = config.plugin.voice_chat.description.suffix;
if (config.plugin?.voice_chat?.channel?.prefix) this.channel_prefix = config.plugin.voice_chat.channel.prefix;
if (config.plugin?.voice_chat?.channel?.suffix) this.channel_suffix = config.plugin.voice_chat.channel.suffix;
}
onVoiceStateUpdate = (oldState: Discord.VoiceState, newState: Discord.VoiceState) => {
if (oldState.channelID == newState.channelID) return;
if (oldState.channelID != null) {
let channel = oldState.guild.channels.resolve(oldState.channelID);
if (!channel) return;
if (channel.members.size == 0 && this.channels[channel.guild.id]) {
oldState.guild.channels.resolve(this.channels[channel.guild.id][channel.id])?.delete();
delete this.channels[channel.guild.id][channel.id];
}
}
if (newState.channelID != null) {
let channel = newState.guild.channels.resolve(newState.channelID);
if (!channel) return;
if (!this.channels[channel.guild.id]?.[channel.id] && channel.members.size == 1 && channel.parent) this.createChatChannel(channel as any);
}
}
createChatChannel(voice: Discord.VoiceChannel) {
let channelName = this.channel_prefix + voice.name.replace(/[\W_]+/g,"-").replace(/-+/g, "-") + this.channel_suffix;
voice.guild.channels.create(channelName, {
type: `text`,
topic: `${this.description_prefix}<#${voice.id}>${this.description_suffix}`,
parent: voice.parent ?? undefined
}).then(channel => {
if (!this.channels[channel.guild.id]) this.channels[channel.guild.id] = {};
this.channels[channel.guild.id][voice.id] = channel.id;
channel.send({
embed: new Discord.MessageEmbed()
.setAuthor(channelName, "https://i.imgur.com/vitVUtr.png")
.setColor("#EE86ED")
.setDescription(
`This is a temporary discussion channel for #${voice.name}!\n` +
`This channel will be automatically deleted when everybody leaves the voice channel.\n`)
.setTimestamp()
}).catch(_ => { /* Missing send permissions. */ });
}).catch(_ => { /* Channel was removed. */ });
}
async cleanup() {
await Promise.all(Object.entries(this.channels).map(async ([g, channels]) => {
let guild = await this.client.guilds.fetch(g);
return await Promise.all(Object.values(channels).map(async channel =>
(await guild.channels.resolve(channel))?.delete()));
}));
}
}

View File

@ -1,16 +1,22 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"esModuleInterop": true,
"moduleResolution": "node",
"rootDirs": ["./src/",],
"typeRoots": ["./node_modules/@types/"],
"outDir": "./build",
"alwaysStrict": true
},
"include": [
"./src/*",
"./src/**/*"
]
"compilerOptions": {
"strict": true,
"alwaysStrict": true,
"target": "es6",
"module": "commonjs",
"esModuleInterop": true,
"moduleResolution": "node",
"outDir": "./build",
"typeRoots": [ "./node_modules/@types/" ],
"noEmitHelpers": true,
"importHelpers": true,
"removeComments": true,
"noUnusedLocals": true,
"noImplicitReturns": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
}
}