Merge branch 'develop' into dependency-updates

pull/70/head
Trivernis 5 years ago committed by GitHub
commit 6cf3b8661d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,89 @@
# Changelog
All notable changes to the discord bot will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- bug where the bot counts itself when calculating needed votes to skip/stop music
- bug on the `ExtendedRichEmbed` where `addField` and `setDescription` throws an error when the value is null or undefined
- bug on `AnilistApiCommands` where the `RichCharacterInfo` uses a nonexistent function of the `ExtendedRichEmbed`
- bug on`AnilistApi` where the `.gql` files couldn't be found.
- Typo in changelog
- bug on `~np` message that causes the player to crash
### Changed
- name of MiscCommands module from `TemplateCommandModule` to `MiscCommandModule`
- moved everything in `lib` to subfolders with the same name as the files and renamed the files to `index.js`
- renamed libfolders to lowercase and removed the lib suffix
- moved commands outside of `lib`
- switched from opusscript to node-opus for voice
- all hard coded sql statements to generic sql generation
- MusicPlayer to extend the default EventEmitter
- MessageHandler to accept instances of Response and redirect events to it
- switched to `ytdl-core-discord` for youtube audio playback
### Added
- Utility classes for generic SQL Statements
- logging of unrejected promises
- database class for database abstraction (lib/database)
- config entry for `database` with supported values `postgresql` or `sqlite`
- config entry for `databaseConnection` for postgresql (`user`, `host`, `password`, `database`, `port`)
- table `settings` to each guild to store guild specific settings
- table `messages` to main database where messages are stored for statistical analysis and bug handling
- ExtendedEventEmitter class in lib/utils/extended-events.js
- Response object that allows the registration of events for messages
- Handling of error event for every VoiceConnection
### Removed
- `~volume` command because volume can't be controlled anymore
- volume functions and properties from the MusicPlayer
## [0.11.0-beta] - 2019-03-03
### Changed
- template Files to name `template.yaml`
- loading template file form CommandModule property `templateFile` to loading the `template.yaml` file from the `_templateDir` property (still supporting loading form templateFile)
- ExtendedRichEmbed checks if fields are empty again after replacing values
### Added
- `.template` to commands as a template for a command module with help comments
- *METADATA* property to `template.yaml` files that is used as an anchor for shared command metadata (like `category`)
- `CommandModule` **Misc** with command that are not really fitting into any other module
- option to query this CHANGELOG with `_changes [version]` and `_versions` in the `CommandModule` **Info**
### Removed
- `ExtendedRichEmbed.addNonemptyField` because the overide of `.addField` does the same
## [0.10.1]-beta - 2019-03-03
### Changed
- Bugfix on RichEmbed not returning itself on addField and setDescription because of method overide
- AniList CommandModule bug fix on `~alCharacter` not returning voice actor names
## [0.10.0-beta] - 2019-03-03
### Added
- AniList api commands powered by [AniList.co](https://www.anilist.co)
- MessageHandler - handles all incoming messages, parses the syntax, executes the syntax and handles rate limits
- CommandHandler - handles all single commands, checks command Permission and executes the command
- Command - represents a single command with the necessary metadata and answer instance
- Answer - represents a commands answer with own syntax parsing (can be overwritten)
- CommandModule - represents a single module of a command with the initialization and registring of command to the command handler. Each module owns an instance of the logger
- ExtendedRichEmbed - extends the functinality of the default discord.js RichEmbed with auto cropping of too long field values, functions to add an Object with fields that are not empty and automatic timestamp addition
### Changed
- Command Syntax now orients more on linux/unix style with `&&` and `;`
- GuildHandler now doesn't handle commands anymore
- the default logger is now a wrapper around the winston.js logger that loggs the current module's name
- all commands are now defined in the lib/commands folder with a folder for each command that contains a `index.js` and a `CommandTemplate.yaml`.
- Rate Limits now only affect commands
- Music commands `~skip` and `~stop` now are votable when the user doesn't have the role *dj* or *botcommander*
- renamed the lib/music to lib/MusicLib and the DJ class to MusicHandler class
- renamed the lib/weblib to lib/WebLib
- changed graphql schema to fit the new internal names
- changed interface to fit the new graphql schema
- changed module export definition to `Object.assign(exports, {...})` at the end of the module file
- added section `commandSettings` to config.js file
- added module information to webinterface log
### Removed
- removed lib/cmd because all functionalities are now adapted to the MessageHandler and CommadnHandlers

@ -3,6 +3,14 @@ discordbot [![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blu
A bot that does the discord thing.
Installation
---
You can easily install everything with npm `npm i`. If you run into an error see the [discord.js installation guide](https://github.com/discordjs/discord.js#installation) or open an issue. If you run into an error with `ffmpeg-binaries` try using nodejs `v10.15.0`
Running
---
`node bot.node [--token=<DiscordBotToken>] [--ytapi=<GoogleApiKey>] [--owner=<DiscordTag>] [--prefix=<Char>] [--game=<String>] [-i=<Boolen>]`
The arguments are optional because the token and youtube-api-key that the bot needs to run can also be defined in the config.json in the bot's directory:
@ -36,6 +44,18 @@ The arguments are optional because the token and youtube-api-key that the bot ne
"keyFile": "PATH TO YOUR SSL KEY FILE",
"certFile": "PATH TO YOUR SSL CERTIFICATE FILE"
}
},
"commandSettings": {
"maxSequenceParallel": 5, // the maximum number of commands executed in parallel
"maxSequenceSerial": 10 // the maximum number of commands executed in serial
},
"database": "postgres or sqlite", // choose one
"databaseConnection": {
"user": "USERNAME",
"host": "HOSTNAME OR IP",
"password": "DATABASE USERPASSWORD",
"database": "BOT DATABASE NAME", // the database needs to exist
"port": 5432 // the port of the database server
}
}
```
@ -63,6 +83,7 @@ At the moment the bot can...
- [x] ...log stuff in a database
- [x] ...execute multiple commands as a sequence
- [x] ...save command sequences with a given name
- [x] ...query AniList
- [ ] ...transform into a cow
Presences
@ -76,15 +97,19 @@ Command Sequences
A command sequence is a single message with several commands seperated by a semicolon.
In a sequence the command can be ommitted if it is the same as the previous one.
That means you can add several videos to the queue and shuffle it afterwards with the sequence
`~play [video1]; [video2]; [video3]; ~shuffle`.
`~play [video1] && ~play [video2]; ~play [video3] && ~shuffle`.
A command sequence can be saved with `~savecmd [sequence] [commandname]`.
In this case the semicolon must be escaped with a backslash so it won't get interpreted as a seperate command.
A command sequence can be saved with `~savecmd [commandname] [sequence]`.
In this case the semicolon must be escaped with a backslash so it won't get interpreted as a seperate command. You can also escape sequences with `~play "whatever &&; you want"` (doublequotes). Command sequences with `&&` are executed in serial while command sequences with `;` are executed in parallel.
A saved command can be executed with `~execute [commandname]`.
References
---
You can test a running version of the bot. [Invite bot server](https://discordapp.com/oauth2/authorize?client_id=374703138575351809&scope=bot&permissions=1983380544)
Ideas
---
- command replies saved in file (server specific file and global file)
- reddit api
- anilist api
- othercoolstuff api

390
bot.js

@ -1,44 +1,47 @@
const Discord = require("discord.js"),
fs = require('fs-extra'),
logger = require('./lib/logging').getLogger(),
cmd = require("./lib/cmd"),
guilding = require('./lib/guilding'),
logging = require('./lib/utils/logging'),
msgLib = require('./lib/message'),
guilding = require('./lib/guilds'),
utils = require('./lib/utils'),
config = require('./config.json'),
args = require('args-parser')(process.argv),
waterfall = require('promise-waterfall'),
sqliteAsync = require('./lib/sqliteAsync'),
globcommands = require('./commands/globalcommands.json'),
dblib = require('./lib/database'),
authToken = args.token || config.api.botToken,
prefix = args.prefix || config.prefix || '~',
gamepresence = args.game || config.presence;
let weblib = null;
/**
* The Bot class handles the initialization and Mangagement of the Discord bot and
* is the main class.
*/
class Bot {
constructor() {
this.client = new Discord.Client({autoReconnect: true});
this.mention = false;
this.logger = new logging.Logger(this);
this.rotator = null;
this.maindb = null;
this.presences = [];
this.guildHandlers = [];
this.userRates = {};
this.messageHandler = new msgLib.MessageHandler(this.client);
this.guildHandlers = {};
logger.verbose('Verifying config');
this.logger.verbose('Verifying config');
let configVerifyer = new utils.ConfigVerifyer(config, [
"api.botToken", "api.youTubeApiKey"
"api.botToken", "api.youTubeApiKey",
"commandSettings.maxSequenceParallel",
"commandSettings.maxSequenceSerial"
]);
if (!configVerifyer.verifyConfig(logger))
if (!configVerifyer.verifyConfig(this.logger))
if (!args.i) {
logger.info('Invalid config. Exiting');
logger.flush().then(() => {
this.logger.info('Invalid config. Exiting');
this.logger.flush().then(() => {
process.exit(1);
});
}
cmd.setLogger(logger);
guilding.setLogger(logger);
}
/**
@ -46,27 +49,45 @@ class Bot {
* @returns {Promise<void>}
*/
async initServices() {
logger.verbose('Registering cleanup function');
this.logger.verbose('Registering cleanup function');
utils.Cleanup(() => {
for (let gh in Object.values(this.guildHandlers))
if (gh instanceof guilding.GuildHandler)
gh.destroy();
this.client.destroy().then(() => {
logger.debug('destroyed client');
this.logger.debug('destroyed client');
}).catch((err) => {
logger.error(err.message);
logger.debug(err.stack);
this.logger.error(err.message);
this.logger.debug(err.stack);
});
this.maindb.close();
});
await this.initializeDatabase();
if (config.webservice && config.webservice.enabled)
if (config.webinterface && config.webinterface.enabled)
await this.initializeWebserver();
logger.verbose('Registering commands');
this.registerCommands();
this.registerCallbacks();
cmd.init(prefix);
this.logger.verbose('Registering commands');
await this.messageHandler.registerCommandModule(require('./commands/AnilistApiCommands').module, {});
await this.messageHandler.registerCommandModule(require('./commands/UtilityCommands').module, {
bot: this,
config: config
});
await this.messageHandler.registerCommandModule(require('./commands/InfoCommands').module, {
client: this.client,
messageHandler: this.messageHandler
});
await this.messageHandler.registerCommandModule(require('./commands/MusicCommands').module, {
getGuildHandler: async (g) => await this.getGuildHandler(g)
});
await this.messageHandler.registerCommandModule(require('./commands/ServerUtilityCommands').module, {
getGuildHandler: async (g) => await this.getGuildHandler(g),
messageHandler: this.messageHandler,
config: config
});
await this.messageHandler.registerCommandModule(require('./commands/MiscCommands').module, {});
this.registerEvents();
}
/**
@ -75,10 +96,11 @@ class Bot {
*/
async start() {
await this.client.login(authToken);
logger.debug("Logged in");
this.logger.debug("Logged in");
if (this.webServer) {
this.webServer.start();
logger.info(`WebServer runing on port ${this.webServer.port}`);
this.logger.info(`WebServer runing on port ${this.webServer.port}`);
}
}
@ -87,16 +109,26 @@ class Bot {
* @returns {Promise<void>}
*/
async initializeDatabase() {
logger.debug('Checking for ./data/ existence');
this.logger.debug('Checking for ./data/ existence');
await fs.ensureDir('./data');
logger.verbose('Connecting to main database');
this.maindb = new sqliteAsync.Database('./data/main.db');
await this.maindb.init();
await this.maindb.run(`${utils.sql.tableExistCreate} presences (
${utils.sql.pkIdSerial},
text VARCHAR(255) UNIQUE NOT NULL
)`);
logger.debug('Loading Presences...');
this.logger.verbose('Connecting to main database');
this.maindb = new dblib.Database('main');
await this.maindb.initDatabase();
let sql = this.maindb.sql;
await this.maindb.run(sql.createTableIfNotExists('presences', [
sql.templates.idcolumn,
new dblib.Column('text', sql.types.getVarchar(255),
[sql.constraints.unique, sql.constraints.notNull])
]));
await this.maindb.run(sql.createTableIfNotExists('messages', [
sql.templates.idcolumn,
new dblib.Column('server', sql.types.getVarchar(255)),
new dblib.Column('channel', sql.types.getVarchar(255)),
new dblib.Column('username', sql.types.getVarchar(255), [sql.constraints.notNull]),
new dblib.Column('message', sql.types.text),
new dblib.Column('timestamp', sql.types.datetime, [sql.constraints.notNull, sql.default('NOW()')])
]));
this.logger.debug('Loading Presences...');
await this.loadPresences();
}
@ -104,52 +136,55 @@ class Bot {
* initializes the api webserver
*/
async initializeWebserver() {
logger.verbose('Importing weblib');
weblib = require('./lib/weblib');
weblib.setLogger(logger);
logger.verbose('Creating WebServer');
this.webServer = new weblib.WebServer(config.webservice.port || 8080);
logger.debug('Setting Reference Objects to webserver');
this.logger.verbose('Importing weblib');
weblib = require('./lib/web');
this.logger.verbose('Creating WebServer');
this.webServer = new weblib.WebServer(config.webinterface.port || 8080);
this.logger.debug('Setting Reference Objects to webserver');
await this.webServer.setReferenceObjects({
client: this.client,
presences: this.presences,
maindb: this.maindb,
prefix: prefix,
getGuildHandler: (guild) => this.getGuildHandler(guild, prefix),
getGuildHandler: async (g) => await this.getGuildHandler(g),
guildHandlers: this.guildHandlers
});
}
/**
* If a data/presences.txt exists, it is read and each line is put into the presences array.
* Each line is also stored in the main.db database. After the file is completely read, it get's deleted.
* Each line is also stored in the dbot-main.db database. After the file is completely read, it get's deleted.
* Then the data is read from the database and if the presence doesn't exist in the presences array, it get's
* pushed in there. If the presences.txt file does not exist, the data is just read from the database. In the end
* a rotator is created that rotates the presence every configured duration.
*/
async loadPresences() {
let sql = this.maindb.sql;
if (await fs.pathExists('./data/presences.txt')) {
let lineReader = require('readline').createInterface({
input: require('fs').createReadStream('./data/presences.txt')
});
lineReader.on('line', (line) => {
this.maindb.run('INSERT INTO presences (text) VALUES (?)', [line], (err) => {
if (err)
logger.warn(err.message);
});
this.presences.push(line);
this.maindb.begin();
lineReader.on('line', async (line) => {
try {
await this.maindb.query(sql.insert('presences', {text: sql.parameter(1)}), [line]);
this.presences.push(line);
} catch (err) {
this.logger.warn(err.message);
this.logger.debug(err.stack);
}
});
await this.maindb.commit();
this.rotator = this.client.setInterval(() => this.rotatePresence(),
config.presence_duration || 360000);
await fs.unlink('./data/presences.txt');
let rows = await this.maindb.all('SELECT text FROM presences');
let rows = await this.maindb.all(sql.select('presences', false, ['text']));
for (let row of rows)
if (!(row[0] in this.presences))
this.presences.push(row.text);
} else {
let rows = await this.maindb.all('SELECT text FROM presences');
let rows = await this.maindb.all(sql.select('presences', false, ['text']));
for (let row of rows)
this.presences.push(row.text);
this.rotator = this.client.setInterval(() => this.rotatePresence(),
@ -157,115 +192,6 @@ class Bot {
}
}
/**
* registeres global commands
*/
registerCommands() {
// useless test command
cmd.createGlobalCommand(prefix, globcommands.utils.say, (msg, argv, args) => {
return args.join(' ');
});
// adds a presence that will be saved in the presence file and added to the rotation
cmd.createGlobalCommand(prefix, globcommands.utils.addpresence, async (msg, argv, args) => {
let p = args.join(' ');
this.presences.push(p);
await this.maindb.run('INSERT INTO presences (text) VALUES (?)', [p]);
return `Added Presence \`${p}\``;
});
// shuts down the bot after destroying the client
cmd.createGlobalCommand(prefix, globcommands.utils.shutdown, async (msg) => {
try {
await msg.reply('Shutting down...');
logger.debug('Destroying client...');
} catch (err) {
logger.error(err.message);
logger.debug(err.stack);
}
try {
await this.client.destroy();
logger.debug('Exiting server...');
} catch (err) {
logger.error(err.message);
logger.debug(err.stack);
}
try {
await this.webServer.stop();
logger.debug(`Exiting Process...`);
process.exit(0);
} catch (err) {
logger.error(err.message);
logger.debug(err.stack);
}
});
// forces a presence rotation
cmd.createGlobalCommand(prefix, globcommands.utils.rotate, () => {
try {
this.client.clearInterval(this.rotator);
this.rotatePresence();
this.rotator = this.client.setInterval(() => this.rotatePresence(), config.presence_duration);
} catch (error) {
logger.warn(error.message);
}
});
// ping command that returns the ping attribute of the client
cmd.createGlobalCommand(prefix, globcommands.info.ping, () => {
return `Current average ping: \`${this.client.ping} ms\``;
});
// returns the time the bot is running
cmd.createGlobalCommand(prefix, globcommands.info.uptime, () => {
let uptime = utils.getSplitDuration(this.client.uptime);
return new Discord.RichEmbed().setDescription(`
**${uptime.days}** days
**${uptime.hours}** hours
**${uptime.minutes}** minutes
**${uptime.seconds}** seconds
**${uptime.milliseconds}** milliseconds
`).setTitle('Uptime');
});
// returns the numbe of guilds, the bot has joined
cmd.createGlobalCommand(prefix, globcommands.info.guilds, () => {
return `Number of guilds: \`${this.client.guilds.size}\``;
});
cmd.createGlobalCommand(prefix, globcommands.utils.createUser, (msg, argv) => {
return new Promise((resolve, reject) => {
if (msg.guild) {
resolve("It's not save here! Try again via PM.");
} else if (argv.username && argv.scope) {
logger.debug(`Creating user entry ${argv.username}, scope: ${argv.scope}`);
this.webServer.createUser(argv.username, argv.password, argv.scope, false).then((token) => {
resolve(`Created entry
username: ${argv.username},
scope: ${argv.scope},
token: ${token}
`);
}).catch((err) => reject(err.message));
}
});
});
cmd.createGlobalCommand(prefix, globcommands.info.about, () => {
return new Discord.RichEmbed()
.setTitle('About')
.setDescription(globcommands.info.about.response.about_creator)
.addField('Icon', globcommands.info.about.response.about_icon);
});
cmd.createGlobalCommand(prefix, globcommands.utils.bugreport, () => {
return new Discord.RichEmbed()
.setTitle('Where to report a bug?')
.setDescription(globcommands.utils.bugreport.response.bug_report);
});
}
/**
* changes the presence of the bot by using one stored in the presences array
*/
@ -276,119 +202,85 @@ class Bot {
this.client.user.setPresence({
game: {name: `${gamepresence} | ${pr}`, type: "PLAYING"},
status: 'online'
}).then(() => logger.debug(`Presence rotation to ${pr}`))
.catch((err) => logger.warn(err.message));
}).then(() => this.logger.debug(`Presence rotation to ${pr}`))
.catch((err) => this.logger.warn(err.message));
}
/**
* Registeres callbacks for client events message and ready
*/
registerCallbacks() {
registerEvents() {
this.client.on('error', (err) => {
logger.error(err.message);
logger.debug(err.stack);
this.logger.error(err.message);
this.logger.debug(err.stack);
});
this.client.on('ready', () => {
logger.info(`logged in as ${this.client.user.tag}!`);
this.logger.info(`logged in as ${this.client.user.tag}!`);
this.client.user.setPresence({
game: {
name: gamepresence, type: "PLAYING"
}, status: 'online'
})
.catch((err) => {
if (err)
logger.warn(err.message);
});
});
this.client.on('message', async (msg) => {
try {
if (msg.author === this.client.user) {
logger.verbose(`ME: ${msg.content}`);
return;
}
if (this.checkRate(msg.author.tag)) {
logger.verbose(`<${msg.author.tag}>: ${msg.content}`);
if (!msg.guild) {
let reply = cmd.parseMessage(msg);
await this.answerMessage(msg, reply);
} else {
let gh = await this.getGuildHandler(msg.guild, prefix);
await gh.handleMessage(msg);
}
if (((Date.now() - this.userRates[msg.author.tag].last)/1000) > (config.rateLimitTime || 10))
this.userRates[msg.author.tag].count = 0;
else
this.userRates[msg.author.tag].count++;
this.userRates[msg.author.tag].last = Date.now();
this.userRates[msg.author.tag].reached = false;
} else if (!this.userRates[msg.author.tag].reached) {
logger.verbose(`${msg.author.tag} reached it's rate limit.`);
this.userRates[msg.author.tag].reached = true;
}
} catch (err) {
logger.error(err.message);
logger.debug(err.stack);
}
}).catch((err) => {
if (err)
this.logger.warn(err.message);
});
});
this.client.on('voiceStateUpdate', async (oldMember, newMember) => {
let gh = await this.getGuildHandler(newMember.guild, prefix);
let gh = await this.getGuildHandler(newMember.guild);
if (newMember.user === this.client.user) {
if (newMember.voiceChannel)
gh.dj.updateChannel(newMember.voiceChannel);
gh.musicPlayer.updateChannel(newMember.voiceChannel);
} else {
if (oldMember.voiceChannel === gh.dj.voiceChannel || newMember.voiceChannel === gh.dj.voiceChannel)
gh.dj.checkListeners();
if (oldMember.voiceChannel === gh.musicPlayer.voiceChannel || newMember.voiceChannel === gh.musicPlayer.voiceChannel)
gh.musicPlayer.checkListeners();
}
});
}
/**
* Returns true if the user has not reached it's rate limit.
* @param usertag
* @returns {boolean}
*/
checkRate(usertag) {
if (!this.userRates[usertag])
this.userRates[usertag] = {last: Date.now(), count: 0};
return ((Date.now() - this.userRates[usertag].last)/1000) > (config.rateLimitTime || 10) ||
this.userRates[usertag].count < (config.rateLimitCount || 5);
}
/**
* Sends the answer recieved from the commands callback.
* Handles the sending differently depending on the type of the callback return
* @param msg
* @param answer
*/
async answerMessage(msg, answer) {
if (answer instanceof Discord.RichEmbed) {
(this.mention) ? msg.reply('', answer) : msg.channel.send('', answer);
} else if (answer instanceof Promise) {
let resolvedAnswer = await answer;
await this.answerMessage(msg, resolvedAnswer);
} else if (answer instanceof Array) {
await waterfall(answer.map((x) => async () => await this.answerMessage(msg, x))); // execute each after another
} else if ({}.toString.call(answer) === '[object Function]') {
await this.answerMessage(msg, answer());
} else if (answer) {
(this.mention) ? msg.reply(answer) : msg.channel.send(answer);
}
this.client.on('message', async (msg) => {
try {
let sql = this.maindb.sql;
let server = null;
let channel = null;
let user = msg.author.tag;
let message = msg.content;
if (msg.guild) {
server = msg.guild.name;
channel = msg.channel.name;
await this.maindb.run(sql.insert('messages', {
server: sql.parameter(1),
channel: sql.parameter(2),
username: sql.parameter(3),
message: sql.parameter(4)
}), [server, channel, user, message]);
} else {
await this.maindb.run(sql.insert('messages', {
channel: sql.parameter(1),
username: sql.parameter(2),
message: sql.parameter(3)
}), ['PRIVATE', user, message]);
}
} catch (err) {
this.logger.warn(err.message);
this.logger.debug(err.stack);
}
});
}
/**
* Returns the guild handler by id, creates one if it doesn't exist and returns it then
* @param guild
* @param prefix
* @param guild {Guild}
* @returns {*}
*/
async getGuildHandler(guild, prefix) {
async getGuildHandler(guild) {
if (!this.guildHandlers[guild.id]) {
let newGuildHandler = new guilding.GuildHandler(guild, prefix);
let newGuildHandler = new guilding.GuildHandler(guild);
await newGuildHandler.initDatabase();
await newGuildHandler.applySettings();
this.guildHandlers[guild.id] = newGuildHandler;
}
return this.guildHandlers[guild.id];
@ -398,10 +290,18 @@ class Bot {
// Executing the main function
if (typeof require !== 'undefined' && require.main === module) {
logger.info("Starting up... "); // log the current date so that the logfile is better to read.
let logger = new logging.Logger('MAIN-init');
process.on('unhandledRejection', err => {
// Will print "unhandledRejection err is not defined"
logger.warn(err.message);
logger.debug(err.stack);
});
logger.info("Starting up... ");
logger.debug('Calling constructor...');
let discordBot = new Bot();
logger.debug('Initializing services...');
discordBot.initServices().then(() => {
logger.debug('Starting Bot...');
discordBot.start().catch((err) => { //eslint-disable-line promise/no-nesting

@ -0,0 +1,45 @@
/* template index.js. Doesn't implement actual commands */
const cmdLib = require('../../lib/command'); // required for command objects
/**
* A description what the command module includes and why. Doesn't need to list commands but explains
* category of the defined commands aswell as the scope.
*/
class TemplateCommandModule extends cmdLib.CommandModule {
/**
* @param opts {Object} properties: --- define the properties the opts object needs aswell as the type
* bot - the instance of the bot
*/
constructor(opts) {
super(cmdLib.CommandScopes.Global); // call constructor of superclass with the scope of the module
this._templateDir = __dirname; // define the current directory as directory for the template.yaml file
this._bot = opts.bot; // define opts attributes as private properties of the module class
}
/**
* Defines and registers commands to the commandHandler.
* @param commandHandler {CommandHandler}
*/
async register(commandHandler) {
await this._loadTemplate(); // loads the template file to the property this.template.
let templateCommand = new cmdLib.Command( // create a new instance of Command
this.template.template_command, // pass the template to the constructor
new cmdLib.Answer(() => { // pass a new instance of Answer to the constructor
/* Command Logic */
return this.template.response.not_implemented; // this command just returns the answer not_implemented
})
);
// register the commands on the commandHandler
commandHandler.registerCommand(templateCommand); // register the command to the handler
}
}
// set the export properties
Object.assign(exports, {
module: TemplateCommandModule // Export the commandModule as module property. This is the default.
});

@ -0,0 +1,16 @@
# see yaml references (learnxinyminutes.com/docs/yaml/)
METADATA: &METADATA
category: template # [optional if defined in commands]
permission: all # [optional if defined in commands]
template_command:
<<: *METADATA # include the predefined metadata for the command
name: templateCommand # [required] the name of the command for execution
usage: _templateCommand [templateArg] # [optional] overides the default help that generates from name and args
permission: owner # [optional if in METADATA] overiedes the metadata value for permission
description: > # [required] the description entry for the command help.
A template for a command
response: # [optional] predefine responses that can be used in the command logic
not_implemented: >
This command is not implemented.

@ -0,0 +1,305 @@
const cmdLib = require('../../lib/command'),
anilistApi = require('../../lib/api/AniListApi');
/**
* The AniList commands are all commands that interact with the anilist api.
*/
/**
* Returns a string for a name.
* @param nameNode {String} The AniList name node in format {first, last, native}
*/
function getNameString(nameNode) {
let name = '';
if (nameNode.first)
name = nameNode.first;
if (nameNode.last)
name += ' ' + nameNode.last;
if (name.length === 0)
name = nameNode.native;
return name;
}
class RichMediaInfo extends cmdLib.ExtendedRichEmbed {
/**
* Creates a rich embed with info for AniListApi Media.
* @param mediaInfo
*/
constructor(mediaInfo) {
super(mediaInfo.title.romaji);
this.setThumbnail(mediaInfo.coverImage.large || mediaInfo.coverImage.medium)
.setURL(mediaInfo.siteUrl)
.setColor(mediaInfo.coverImage.color)
.setFooter('Powered by AniList.co');
if (mediaInfo.description)
this.setDescription(mediaInfo.description
.replace(/<\/?.*?>/g, '')
.replace(/~!.*?!~/g, '')
.replace(/\n\n\n/g, ''));
let fields = {
'Genres': mediaInfo.genres? mediaInfo.genres.join(' ') : null,
'Studios': mediaInfo.studios? mediaInfo.studios.studioList.map(x => `[${x.name}](${x.siteUrl})`) : null,
'Scoring': mediaInfo.averageScore? `**AverageScore**: ${mediaInfo.averageScore}\n**Favourites:** ${mediaInfo.favourites}`: null,
'Episodes': mediaInfo.episodes,
'Volumes': mediaInfo.volumes,
'Chapters': mediaInfo.chapters,
'Duration': null,
'Season': mediaInfo.season,
'Status': mediaInfo.status,
'Format': mediaInfo.format
};
if (mediaInfo.duration)
fields['Episode Duration'] = `${mediaInfo.duration} min`;
if (mediaInfo.startDate && mediaInfo.startDate.day)
fields['Start Date'] = `${mediaInfo.startDate.day}.${mediaInfo.startDate.month}.${mediaInfo.startDate.year}`;
if (mediaInfo.nextAiringEpisode) {
let epInfo = mediaInfo.nextAiringEpisode;
fields['Next Episode'] = `**Episode** ${epInfo.episode}\n**Airing at:** ${new Date(epInfo.airingAt * 1000).toUTCString()}`;
}
if (mediaInfo.endDate && mediaInfo.endDate.day)
fields['End Date'] = `${mediaInfo.endDate.day}.${mediaInfo.endDate.month}.${mediaInfo.endDate.year}`;
this.addStaffInfo(mediaInfo);
this.addFields(fields);
}
addStaffInfo(mediaInfo) {
let fields = {};
if (mediaInfo.staff && mediaInfo.staff.edges) {
let staffContent = mediaInfo.staff.edges.map((x) => {
let url = x.node.siteUrl;
let name = getNameString(x.node.name);
return `**${x.role}:** [${name}](${url})`;
});
let staffFieldValue = staffContent.join('\n');
if (staffFieldValue.length > 1024) {
let staffValues = [];
let currentValue = '';
for (let staffLine of staffContent) {
let concatValue = currentValue + '\n' + staffLine;
if (concatValue.length > 1024) {
staffValues.push(currentValue);
currentValue = staffLine;
} else {
currentValue = concatValue;
}
}
staffValues.push(currentValue);
for (let i = 0; i < staffValues.length; i++)
fields[`Staff part ${i + 1}`] = staffValues[i];
} else {
fields['Staff'] = staffFieldValue;
}
}
this.addFields(fields);
}
}
class RichStaffInfo extends cmdLib.ExtendedRichEmbed {
/**
* A Rich Embed with informatin about an AniList staff member.
* @param staffInfo
*/
constructor(staffInfo) {
super(getNameString(staffInfo.name));
this.setThumbnail(staffInfo.image.large || staffInfo.image.medium)
.setURL(staffInfo.siteUrl);
let fields = {
'Language': staffInfo.language
};
if (staffInfo.staffMedia && staffInfo.staffMedia.edges)
fields['Staff Media Roles (first 10)'] = staffInfo.staffMedia.edges.map(x => {
let node = x.node;
let title = node.title.romaji;
let url = node.siteUrl;
return `[**${title}**](${url}): ${x.staffRole}`;
}).join('\n');
if (staffInfo.characters && staffInfo.characters.nodes)
fields['Staff Character Roles (first 10)'] = staffInfo.characters.nodes.map(x => {
let name = getNameString(x.name);
let url = x.siteUrl;
return `[${name}](${url})`;
}).join('\n');
this.addFields(fields);
}
}
class RichCharacterInfo extends cmdLib.ExtendedRichEmbed {
/**
* A RichEmbed with information about an AniList character.
* @param characterInfo {Object}
*/
constructor(characterInfo) {
super(getNameString(characterInfo.name));
this.setURL(characterInfo.siteUrl)
.setThumbnail(characterInfo.image.large || characterInfo.image.medium);
if (characterInfo.description)
this.setDescription(characterInfo.description
.replace(/<\/?.*?>/g, '')
.replace(/~!.*?!~/g, '')
.replace(/\n\n\n/g, ''));
if (characterInfo.media && characterInfo.media.edges)
this.addField(
'Media Appeareance',
characterInfo.media.edges.map(x => {
let media = x.node;
let informationString = `**[${media.title.romaji}](${media.siteUrl})**: ${x.characterRole}`;
if (x.voiceActors && x.voiceActors.length > 0)
informationString += ` voice by ${x.voiceActors.map(y => {
return `[${getNameString(y.name)}](${y.siteUrl})`;
}).join(', ')}`;
return informationString;
}).join('\n')
);
}
}
// -- initialize -- //
/**
* Implementing the AniList commands module.
*/
class AniListCommandModule extends cmdLib.CommandModule {
constructor() {
super(cmdLib.CommandScopes.Global);
this._templateDir = __dirname;
this.template = null;
}
async register(commandHandler) {
await this._loadTemplate();
let animeSearch = new cmdLib.Command(
this.template.anime_search,
new cmdLib.Answer(async (m, k, s) => {
try {
let animeData = {};
if (/^\d+$/.test(s))
animeData = await anilistApi.getAnimeById(s, false, true);
else
animeData = await anilistApi.searchAnimeByName(s, false, true);
this._logger.silly(`Anime Query returned ${JSON.stringify(animeData)}`);
return new RichMediaInfo(animeData);
} catch (err) {
if (err.message) {
this._logger.verbose(err.message);
this._logger.silly(err.stack);
} else if (err.errors) {
this._logger.silly(`Graphql Errors ${JSON.stringify(err.errors)}`);
}
return this.template.anime_search.response.not_found;
}
})
);
let animeStaffSearch = new cmdLib.Command(
this.template.anime_staff_search,
new cmdLib.Answer(async (m, k, s) => {
try {
let animeData = {};
if (/^\d+$/.test(s))
animeData = await anilistApi.getAnimeById(s, true, false);
else
animeData = await anilistApi.searchAnimeByName(s, true, false);
this._logger.silly(`Anime Query returned ${JSON.stringify(animeData)}`);
return new RichMediaInfo(animeData);
} catch (err) {
if (err.message) {
this._logger.verbose(err.message);
this._logger.silly(err.stack);
} else if (err.errors) {
this._logger.silly(`Graphql Errors ${JSON.stringify(err.errors)}`);
}
return this.template.anime_staff_search.response.not_found;
}
})
);
let mangaSearch = new cmdLib.Command(
this.template.manga_search,
new cmdLib.Answer(async (m, k, s) => {
try {
let mangaData = {};
if (/^\d+$/.test(s))
mangaData = await anilistApi.getMangaById(s, true, true);
else
mangaData= await anilistApi.searchMangaByName(s, true, true);
this._logger.silly(`Manga Query returned ${JSON.stringify(mangaData)}`);
return new RichMediaInfo(mangaData);
} catch (err) {
if (err.message) {
this._logger.verbose(err.message);
this._logger.silly(err.stack);
} else if (err.errors) {
this._logger.silly(`Graphql Errors ${JSON.stringify(err.errors)}`);
}
return this.template.manga_search.response.not_found;
}
})
);
let staffSearch = new cmdLib.Command(
this.template.staff_search,
new cmdLib.Answer(async (m, k, s) => {
try {
let staffData = {};
if (/^\d+$/.test(s))
staffData = await anilistApi.getStaffById(s);
else
staffData = await anilistApi.searchStaffByName(s);
this._logger.silly(`Staff Query returned ${JSON.stringify(staffData)}`);
return new RichStaffInfo(staffData);
} catch (err) {
if (err.message) {
this._logger.verbose(err.message);
this._logger.silly(err.stack);
} else if (err.errors) {
this._logger.silly(`Graphql Errors ${JSON.stringify(err.errors)}`);
}
return this.template.staff_search.response.not_found;
}
})
);
let characterSearch = new cmdLib.Command(
this.template.character_search,
new cmdLib.Answer(async (m, k, s) => {
try {
let characterData = {};
if (/^\d+$/.test(s))
characterData = await anilistApi.getCharacterById(s);
else
characterData = await anilistApi.searchCharacterByName(s);
this._logger.silly(`Character Query returned ${JSON.stringify(characterData)}`);
return new RichCharacterInfo(characterData);
} catch (err) {
if (err.message) {
this._logger.verbose(err.message);
this._logger.silly(err.stack);
} else if (err.errors) {
this._logger.silly(`Graphql Errors ${JSON.stringify(err.errors)}`);
}
return this.template.character_search.response.not_found;
}
})
);
// registering commands
commandHandler
.registerCommand(animeSearch)
.registerCommand(mangaSearch)
.registerCommand(staffSearch)
.registerCommand(animeStaffSearch)
.registerCommand(characterSearch);
}
}
Object.assign(exports, {
'module': AniListCommandModule
});

@ -0,0 +1,57 @@
METADATA: &METADATA
category: AniList
permission: all
anime_search:
<<: *METADATA
name: alAnime
usage: alAnime [search query]
description: >
Searches [AniList.co](https://anilist.co) for the anime *title* or *id* and returns information about
it if there is a result. The staff members are not included because the message would grow too big.
response:
not_found: >
I couldn't find the anime you were searching for :(
anime_staff_search:
<<: *METADATA
name: alAnimeStaff
usage: alAnimeStaff [search query]
description: >
Searches [AniList.co](https://anilist.co) for the anime *title* or *id* and returns all staff members.
response:
not_found: >
I couldn't find the anime you were searching for :(
manga_search:
<<: *METADATA
name: alManga
usage: alManga [search query]
description: >
Searches [AniList.co](https://anilist.co) for the manga *title* or *id* and returns information about
it if there is a result.
response:
not_found: >
I couldn't find the manga you were searching for :(
staff_search:
<<: *METADATA
name: alStaff
usage: alStaff [search query]
description: >
Searches [AniList.co](https://anilist.co) for the staff member *name* or *id* and returns information about
the member aswell as roles in media.
response:
not_found: >
I couldn't find the staff member you were searching for :(
character_search:
<<: *METADATA
name: alCharacter
usage: alCharacter [search query]
description: >
Searches [AniList.co](https://anilist.co) for the character *name* or *id* and returns information about
the character aswell as media roles.
response:
not_found: >
I couldn't find the character member you were searching for :(

@ -0,0 +1,203 @@
const cmdLib = require('../../lib/command'),
fsx = require('fs-extra'),
utils = require('../../lib/utils');
/**
* Info commands provide information about the bot. These informations are
* not process specific but access the discord _client instance of the bot.
*/
class InfoCommandModule extends cmdLib.CommandModule {
/**
* @param opts {Object} properties:
* client - the instance of the discord client.
* messageHandler - the instance of the Message Handler
*/
constructor(opts) {
super(cmdLib.CommandScopes.Global);
this._templateDir = __dirname;
this._client = opts.client;
this._messageHandler = opts.messageHandler;
}
_createHelpEmbed(commands, msg, prefix, embedColor = 0xfff) {
let helpEmbed = new cmdLib.ExtendedRichEmbed('Commands')
.setDescription('Create a sequence of commands with `;` and `&&`.')
.setColor(embedColor);
let categories = [];
let catCommands = {};
Object.entries(commands).sort().forEach(([key, value]) => {
if (!categories.includes(value.category)) {
categories.push(value.category);
catCommands[value.category] = `\`${prefix}${key}\` \t`;
} else {
catCommands[value.category] += `\`${prefix}${key}\` \t`;
}
});
for (let cat of categories)
helpEmbed.addField(cat, catCommands[cat]);
helpEmbed.setFooter(prefix + 'help [command] for more info to each command');
this._logger.silly('Created help embed');
return helpEmbed;
}
async _loadChangelog() {
try {
let changelog = (await fsx.readFile('CHANGELOG.md', {encoding: 'utf-8'})).replace(/\r\n/g, '\n');
let entries = changelog.split(/\n## /);
let changes = {};
let latestVersion = null;
this._logger.debug(`Found ${entries.length} changelog entries`);
for (let entry of entries) {
let title = '';
let version = '';
let date = '';
let titleMatch = entry.match(/^.*?\n/g);
if (titleMatch && titleMatch.length > 0)
title = titleMatch[0].replace(/\n/, '');
let versionMatch = title.match(/\[.*?]/);
if (versionMatch && versionMatch.length > 0)
version = versionMatch[0].replace(/^\[|]$/g, '');
if (!latestVersion && version && version.length > 0)
latestVersion = version;
let dateMatch = title.match(/\d{4}-\d{2}-\d{2}/);
if (version && version.length > 0) {
changes[version] = {
date: date,
title: title,
segments: {}
};
if (dateMatch && dateMatch.length > 0)
date = dateMatch[0];
let segments = entry.replace(title.replace(/\n/, ''), '').split(/\n### /);
for (let segment of segments) {
let segmentTitle = '';
let titleMatch = segment.match(/^.*?\n/);
if (titleMatch && titleMatch.length > 0)
segmentTitle = titleMatch[0].replace(/\n/, '');
changes[version].segments[segmentTitle] = segment.replace(segmentTitle, '');
}
}
}
changes.latest = changes[latestVersion];
this._changes = changes;
} catch (err) {
this._logger.warn(err.message);
this._logger.debug(err.stack);
}
}
async register(commandHandler) {
await this._loadTemplate();
await this._loadChangelog();
let about = new cmdLib.Command(
this.template.about,
new cmdLib.Answer(() => {
return new cmdLib.ExtendedRichEmbed('About')
.setDescription(this.template.about.response.about_creator)
.addField('Icon', this.template.about.response.about_icon);
})
);
let ping = new cmdLib.Command(
this.template.ping,
new cmdLib.Answer(() => {
return `Current average ping: \`${this._client.ping} ms\``;
})
);
let uptime = new cmdLib.Command(
this.template.uptime,
new cmdLib.Answer(() => {
let uptime = utils.getSplitDuration(this._client.uptime);
return new cmdLib.ExtendedRichEmbed('Uptime').setDescription(`
**${uptime.days}** days
**${uptime.hours}** hours
**${uptime.minutes}** minutes
**${uptime.seconds}** seconds
**${uptime.milliseconds}** milliseconds
`).setTitle('Uptime');
})
);
let guilds = new cmdLib.Command(
this.template.guilds,
new cmdLib.Answer(() => {
return `Number of guilds: \`${this._client.guilds.size}\``;
})
);
let help = new cmdLib.Command(
this.template.help,
new cmdLib.Answer((m, k) => {
let globH = this._messageHandler.globalCmdHandler;
let scopeH = this._messageHandler.getScopeHandler(m);
if (k.command) {
k.command = k.command.replace(globH.prefix, '');
let commandInstance = globH.commands[k.command] || scopeH.commands[k.command];
return commandInstance.help.setColor(this.template.help.embed_color);
} else {
let commandObj = {...globH.commands, ...scopeH.commands};
return this._createHelpEmbed(commandObj, m, globH.prefix, this.template.help.embed_color);
}
})
);
let changes = new cmdLib.Command(
this.template.changes,
new cmdLib.Answer((m, k) => {
try {
if (!k.version)
return new cmdLib.ExtendedRichEmbed(this._changes.latest.title)
.addFields(this._changes.latest.segments)
.setColor(this.template.changes.embed_color)
.attachFile('CHANGELOG.md');
else
return new cmdLib.ExtendedRichEmbed(this._changes[k.version].title)
.addFields(this._changes[k.version].segments)
.setColor(this.template.changes.embed_color)
.attachFile('CHANGELOG.md');
} catch (err) {
this._logger.verbose(err.message);
this._logger.silly(err.stack);
return this.template.changes.response.not_found;
}
})
);
let versions = new cmdLib.Command(
this.template.versions,
new cmdLib.Answer(() => {
try {
return new cmdLib.ExtendedRichEmbed('CHANGELOG.md Versions')
.setDescription(Object.keys(this._changes).join('\n'))
.setColor(this.template.versions.embed_color);
} catch (err) {
this._logger.verbose(err.message);
this._logger.silly(err.stack);
return this.template.versions.response.not_found;
}
})
);
// register commands
commandHandler
.registerCommand(about)
.registerCommand(ping)
.registerCommand(uptime)
.registerCommand(guilds)
.registerCommand(help)
.registerCommand(changes)
.registerCommand(versions);
}
}
Object.assign(exports, {
'module': InfoCommandModule
});

@ -0,0 +1,65 @@
METADATA: &METADATA
category: Info
permission: all
about:
<<: *METADATA
name: about
description: >
Shows information about this Discord Bot.
response:
about_icon: |
This icon war created by [blackrose14344](https://www.deviantart.com/blackrose14344).
[Original](https://www.deviantart.com/blackrose14344/art/2B-Chi-B-685771489)
about_creator: |
This bot was created by Trivernis.
More about this bot [here](https://github.com/Trivernis/discordbot.js).
ping:
<<: *METADATA
name: ping
description: >
Answers with the current average ping of the bot.
uptime:
<<: *METADATA
name: uptime
description: >
Answers with the uptime of the bot.
guilds:
<<: *METADATA
name: guilds
description: >
Answers with the number of guilds the bot has joined
permission: owner
help:
<<: *METADATA
name: help
description: >
Shows help for bot ocmmands.
embed_color: 0xffffff
args:
- command
changes:
<<: *METADATA
name: changes
description: >
Shows the changes of the current release or a specific previous.
embed_color: 0xaabbcc
args:
- version
response:
not_found: >
I could not find the changelog for the version you were looking for.
versions:
<<: *METADATA
name: versions
description: >
Shows all versions present in the CHANGELOG.
embed_color: 0xaabbcc
response:
not_found: >
I could not find any versions.

@ -0,0 +1,82 @@
/* template index.js. Doesn't implement actual commands */
const cmdLib = require('../../lib/command');
/**
* Several commands that are that special that they can't be included in any other module.
*/
/**
* Async delay
* @param seconds {Number}
*/
function delay(seconds) {
return new Promise((resolve) => {
setTimeout(resolve, seconds * 1000);
});
}
class MiscCommandModule extends cmdLib.CommandModule {
constructor() {
super(cmdLib.CommandScopes.Global);
this._templateDir = __dirname;
}
/**
* Defines and registers commands to the commandHandler.
* @param commandHandler {CommandHandler}
*/
async register(commandHandler) {
await this._loadTemplate();
let sayCommand = new cmdLib.Command(
this.template.say,
new cmdLib.Answer((m, k, s) => {
return s.replace(/^"|"$/g, '');
})
);
let delayCommand = new cmdLib.Command(
this.template.delay,
new cmdLib.Answer(async (m, k) => {
this._logger.silly(`Delaying for ${k.seconds} seconds`);
await delay(k.seconds);
})
);
let chooseCommand = new cmdLib.Command(
this.template.choose,
new cmdLib.Answer(async (m, k, s) => {
let options = s.split(',').map(x => {
if (x) {
let strippedValue = x.replace(/^\s+|\s+$/, '');
if (strippedValue.length === 0)
return null;
else
return strippedValue;
} else {
return null;
}
}).filter(x => x);
if (options.length === 0) {
return this.template.choose.response.no_options;
} else {
this._logger.silly(`Choosing from ${options.join(', ')}`);
let item = options[Math.floor(Math.random() * options.length)];
return `I've chosen ${item.replace(/^"|"$|^\s+|\s+$/g, '')}`;
}
})
);
/* Register commands to handler */
commandHandler
.registerCommand(sayCommand)
.registerCommand(delayCommand)
.registerCommand(chooseCommand);
}
}
Object.assign(exports, {
module: MiscCommandModule
});

@ -0,0 +1,29 @@
METADATA: &METADATA
category: Misc
permission: all
say:
<<: *METADATA
name: say
usage: say [...message]
description: >
The bot says what you defined in the message argument
delay:
<<: *METADATA
name: delay
usage: delay
args:
- seconds
description: >
Set a delay in seconds. Useful for command sequences.
choose:
<<: *METADATA
name: choose
usage: choose [opt-1], [opt-2], ..., [opt-n]
description: >
Chooses randomly from one of the options
response:
no_options: >
You need to define options for me to choose from.

@ -0,0 +1,379 @@
const cmdLib = require('../../lib/command'),
utils = require('../../lib/utils'),
config = require('../../config');
function checkPermission(msg, rolePerm) {
if (!rolePerm || ['all', 'any', 'everyone'].includes(rolePerm))
return true;
if (config.owners.includes(msg.author.tag))
return true;
else if (msg.member && rolePerm && rolePerm !== 'owner' && msg.member.roles
.some(role => (role.name.toLowerCase() === rolePerm.toLowerCase() ||
role.name.toLowerCase() === 'botcommander')))
return true;
return false;
}
/**
* Music commands provide commands to control the bots music functions.
* These commands are for server music functionalities.
*/
class MusicCommandModule extends cmdLib.CommandModule {
/**
* @param opts {Object} properties:
* getGuildHandler - a function to get the guild handler for a guild.
*/
constructor(opts) {
super(cmdLib.CommandScopes.Guild);
this._templateDir = __dirname;
this._getGuildHandler = opts.getGuildHandler;
}
/**
* Connects to a voice-channel if not connected and plays the url
* @param gh {guilding.GuildHandler}
* @param vc {Discord.VoiceChannel}
* @param url {String} The url to the YouTube media
* @param next {Boolean} Should the song be played next
* @returns {Promise<void>}
* @private
*/
async _connectAndPlay(gh, vc, url, next) {
if (!gh.musicPlayer.connected) {
await gh.musicPlayer.connect(vc);
return await gh.musicPlayer.playYouTube(url, next);
} else {
return await gh.musicPlayer.playYouTube(url, next);
}
}
/**
* The play function for the music commands play and playnext
* @param m {Discord.Message}
* @param k {Object} kwargs
* @param s {String} argsString
* @param t {Object} template
* @param n {Boolean} play next
* @returns {Promise<*>}
* @private
*/
async _playFunction(m, k, s, t, n) {
let gh = await this._getGuildHandler(m.guild);
let vc = gh.musicPlayer.voiceChannel || m.member.voiceChannel;
let url = k['url'];
if (!vc)
return t.response.no_voicechannel;
if (!url)
return t.response.no_url;
if (!utils.YouTube.isValidEntityUrl(url)) {
url = s;
let row = await gh.db.get(gh.db.sql.select('playlists', false, ['url'],
gh.db.sql.where('name', '=', gh.db.sql.parameter(1))), [url]);
if (!row) {
this._logger.debug('Got invalid url for play command.');
return t.response.url_invalid;
} else {
let songcount = await this._connectAndPlay(gh, vc, row.url, n);
if (songcount)
return `Added ${songcount} songs to the queue.`;
else
return t.response.success;
}
} else {
let songcount = await this._connectAndPlay(gh, vc, url, n);
if (songcount)
return `Added ${songcount} songs to the queue.`;
else
return t.response.success;
}
}
async register(commandHandler) {
await this._loadTemplate();
let play = new cmdLib.Command(
this.template.play,
new cmdLib.Answer(async (m, k, s) => {
return await this._playFunction(m, k, s, this.template.play, false);
})
);
let playNext = new cmdLib.Command(
this.template.play_next,
new cmdLib.Answer(async (m, k, s) => {
return await this._playFunction(m, k, s, this.template.play_next, true);
})
);
let join = new cmdLib.Command(
this.template.join,
new cmdLib.Answer(async (m) => {
let gh = await this._getGuildHandler(m.guild);
if (m.member.voiceChannel)
await gh.musicPlayer.connect(m.member.voiceChannel);
else
return this.template.join.response.no_voicechannel;
})
);
let stop = new cmdLib.Command(
this.template.stop,
new cmdLib.Answer(async (m) => {
let gh = await this._getGuildHandler(m.guild);
let vc = gh.musicPlayer.voiceChannel || m.member.voiceChannel;
if (gh.musicPlayer.connected && vc) {
let votes = gh.updateCommandVote(stop.name, m.author.tag);
let neededVotes = Math.ceil((vc.members.size - 1) / 2);
if (neededVotes <= votes.count || checkPermission(m, 'dj')) {
this._logger.debug(`Vote passed. ${votes.count} out of ${neededVotes} for stop or permission granted`);
gh.musicPlayer.stop();
gh.resetCommandVote(stop.name);
return this.template.stop.success;
} else {
this._logger.silly(`Vote count increased. ${votes.count} out of ${neededVotes} for stop`);
return `${votes.count} out of ${neededVotes} needed voted to stop.`;
}
} else {
return this.template.stop.not_playing;
}
})
);
let pause = new cmdLib.Command(
this.template.pause,
new cmdLib.Answer(async (m) => {
let gh = await this._getGuildHandler(m.guild);
if (gh.musicPlayer.playing) {
gh.musicPlayer.pause();
return this.template.pause.response.success;
} else {
return this.template.pause.response.not_playing;
}
})
);
let resume = new cmdLib.Command(
this.template.resume,
new cmdLib.Answer(async (m) => {
let gh = await this._getGuildHandler(m.guild);
if (gh.musicPlayer.playing) {
gh.musicPlayer.resume();
return this.template.resume.response.success;
} else {
return this.template.resume.response.not_playing;
}
})
);
let skip = new cmdLib.Command(
this.template.skip,
new cmdLib.Answer(async (m) => {
let gh = await this._getGuildHandler(m.guild);
let vc = gh.musicPlayer.voiceChannel || m.member.voiceChannel;
if (gh.musicPlayer.playing && vc) {
let votes = gh.updateCommandVote(skip.name, m.author.tag);
let neededVotes = Math.ceil((vc.members.size - 1) / 2);
if (neededVotes <= votes.count || checkPermission(m, 'dj')) {
this._logger.debug(`Vote passed. ${votes.count} out of ${neededVotes} for skip or permission granted`);
gh.musicPlayer.skip();
gh.resetCommandVote(skip.name);
return this.template.skip.response.success;
} else {
this._logger.silly(`Vote count increased. ${votes.count} out of ${neededVotes} for skip`);
return `${votes.count} out of ${neededVotes} needed voted to skip.`;
}
} else {
return this.template.skip.response.not_playing;
}
})
);
let clear = new cmdLib.Command(
this.template.clear,
new cmdLib.Answer(async (m) => {
let gh = await this._getGuildHandler(m.guild);
gh.musicPlayer.clear();
return this.template.clear.response.success;
})
);
let mediaQueue = new cmdLib.Command(
this.template.media_queue,
new cmdLib.Answer(async (m) => {
let gh = await this._getGuildHandler(m.guild);
this._logger.debug(`Found ${gh.musicPlayer.queue.length} songs.`);
let description = '';
for (let i = 0; i < Math.min(gh.musicPlayer.queue.length, 9); i++) {
let entry = gh.musicPlayer.queue[i];
description += `[${entry.title}](${entry.url})\n`;
}
return new cmdLib.ExtendedRichEmbed(`${gh.musicPlayer.queue.length} songs in queue`)
.setDescription(description);
})
);
let mediaCurrent = new cmdLib.Command(
this.template.media_current,
new cmdLib.Answer(async (m) => {
let gh = await this._getGuildHandler(m.guild);
let song = gh.musicPlayer.song;
if (song)
return new cmdLib.ExtendedRichEmbed('Now playing:')
.setDescription(`[${song.title}](${song.url})`)
.setImage(utils.YouTube.getVideoThumbnailUrlFromUrl(song.url))
.setColor(0x00aaff);
else
return this.template.media_current.response.not_playing;
}, async (response) => {
let message = response.message;
let gh = await this._getGuildHandler(message.guild);
if (message.editable && gh.musicPlayer) {
let next = (song) => {
message.edit('', new cmdLib.ExtendedRichEmbed('Now playing:')
.setDescription(`[${song.title}](${song.url})`)
.setImage(utils.YouTube.getVideoThumbnailUrlFromUrl(song.url))
.setColor(0x00aaff));
if (message.id !== message.channel.lastMessageID) {
gh.musicPlayer.removeListener('next', next);
message.delete();
}
};
gh.musicPlayer.on('next', next);
gh.musicPlayer.on('stop', () => {
gh.musicPlayer.off('next', next);
message.delete();
});
response.on('delete', () => gh.musicPlayer.off('next', next));
}
})
);
let shuffle = new cmdLib.Command(
this.template.shuffle,
new cmdLib.Answer(async (m) => {
let gh = await this._getGuildHandler(m.guild);
gh.musicPlayer.shuffle();
return this.template.shuffle.response.success;
})
);
let toggleRepeat = new cmdLib.Command(
this.template.toggle_repeat,
new cmdLib.Answer(async (m) => {
let gh = await this._getGuildHandler(m.guild);
gh.musicPlayer.repeat = !gh.musicPlayer.repeat;
return gh.musicPlayer.repeat?
this.template.toggle_repeat.response.repeat_true :
this.template.toggle_repeat.response.repeat_false;
})
);
let saveMedia = new cmdLib.Command(
this.template.save_media,
new cmdLib.Answer(async (m, k, s) => {
let gh = await this._getGuildHandler(m.guild);
let saveName = s.replace(k.url + ' ', '');
let row = await gh.db.get(gh.db.sql.select('playlists', false,
[gh.db.sql.count('*')], gh.db.sql.where('name', '=', gh.db.sql.parameter(1))), [saveName]);
if (!row || Number(row.count) === 0)
await gh.db.run(gh.db.sql.insert('playlists',
{name: gh.db.sql.parameter(1), url: gh.db.sql.parameter(2)}), [saveName, k.url]);
else
await gh.db.run(gh.db.sql.update('playlists',
{url: gh.db.sql.parameter(1)},
gh.db.sql.where('name', '=', gh.db.sql.parameter(2))), [k.url, saveName]);
return `Saved song/playlist as ${saveName}`;
})
);
let deleteMedia = new cmdLib.Command(
this.template.delete_media,
new cmdLib.Answer(async (m, k, s) => {
let gh = await this._getGuildHandler(m.guild);
if (!s) {
return this.template.delete_media.response.no_name;
} else {
await gh.db.run(gh.db.sql.delete('playlists', gh.db.sql.where('name', '=', gh.db.sql.parameter(1))), [s]);
return `Deleted ${s} from saved media`;
}
})
);
let savedMedia = new cmdLib.Command(
this.template.saved_media,
new cmdLib.Answer(async (m) => {
let gh = await this._getGuildHandler(m.guild);
let response = '';
let rows = await gh.db.all(gh.db.sql.select('playlists', false, ['name', 'url']));
for (let row of rows)
response += `[${row.name}](${row.url})\n`;
if (rows.length === 0)
return this.template.saved_media.response.no_saved;
else
return new cmdLib.ExtendedRichEmbed('Saved Songs and Playlists')
.setDescription(response)
.setFooter(`Play a saved entry with play [Entryname]`);
})
);
/* TODO: Delete completely on release
let volume = new cmdLib.Command(
this.template.volume,
new cmdLib.Answer(async (m, k) => {
let volume = Number(k.volume);
if (volume && volume <= 100 && volume >= 0) {
let gh = await this._getGuildHandler(m.guild);
gh.musicPlayer.setVolume(Math.round(volume)/100);
await gh.db.setSetting('musicPlayerVolume', Math.round(volume)/100);
return `Set music volume to **${volume}**`;
} else {
return this.template.volume.response.invalid;
}
})
);*/
let quality = new cmdLib.Command(
this.template.quality,
new cmdLib.Answer(async (m, k) => {
let allowed = ['highest', 'lowest', 'highestaudio', 'lowestaudio'];
if (allowed.includes(k.quality)) {
let gh = await this._getGuildHandler(m.guild);
gh.musicPlayer.quality = k.quality;
await gh.db.setSetting('musicPlayerQuality', k.quality);
return `Set music quality to **${k.quality}**`;
} else {
return this.template.quality.response.invalid;
}
})
);
// register commands
commandHandler
.registerCommand(play)
.registerCommand(playNext)
.registerCommand(join)
.registerCommand(stop)
.registerCommand(pause)
.registerCommand(resume)
.registerCommand(skip)
.registerCommand(clear)
.registerCommand(mediaQueue)
.registerCommand(mediaCurrent)
.registerCommand(shuffle)
.registerCommand(toggleRepeat)
.registerCommand(saveMedia)
.registerCommand(deleteMedia)
.registerCommand(savedMedia)
.registerCommand(quality);
}
}
Object.assign(exports, {
module: MusicCommandModule
});

@ -0,0 +1,195 @@
METADATA: &METADATA
category: Music
permission: all
play:
<<: *METADATA
name: play
description: >
Adds the url to the YouTube video or YouTube playlist into the queue.
args:
- url
response:
success: >
Added URL to the media queue.
failure: >
Failed adding the URL to the media queue.
url_invalid: >
The URL you provided is not a valid YouTube video or Playlist URL.
no_url: >
You need to provide an URL to a YouTube video or Playlist.
no_voicechannel: >
You need to join a VoiceChannel to request media playback.
play_next:
<<: *METADATA
name: playnext
description: >
Adds the url to the YouTube video or YouTube playlist into the queue as
next playing song.
args:
- url
response:
success: >
Added URL as next media to the media queue.
failure: >
Failed adding the URL to the media queue.
url_invalid: >
The URL you provided is not a valid YouTube video or Playlist URL.
no_url: >
You need to provide an URL to a YouTube video or Playlist.
no_voicechannel: >
You need to join a VoiceChannel to request media playback.
join:
<<: *METADATA
name: join
description: >
Joins the VoiceChannel you are in.
response:
no_voicechannel: >
You need to join a VoiceChannel for me to join.
stop:
<<: *METADATA
name: stop
description: >
Stops the media playback and leaves the VoiceChannel.
response:
success: >
Stopped music playback.
not_playing: >
I'm not playing music at the moment. What do you want me to stop?
pause:
<<: *METADATA
name: pause
description: >
Pauses the media playback.
response:
success: >
Paused playback.
not_playing: >
I'm not playing music at the moment.
resume:
<<: *METADATA
name: resume
description: >
Resumes the media playback.
response:
success: >
Resumed playback.
not_playing: >
I'm not playing music at the moment.
skip:
<<: *METADATA
name: skip
description: >
Skips the currently playing song.
response:
success: >
Skipped to the next song.
not_playing: >
I'm not playing music at the moment.
clear:
<<: *METADATA
name: clear
description: >
Clears the media queue.
permission: dj
response:
success: >
The media queue has been cleared.
media_queue:
<<: *METADATA
name: queue
descriptions: >
Shows the next ten songs in the media queue.
media_current:
<<: *METADATA
name: np
description: >
Shows the currently playing song.
response:
not_playing: >
I'm not playing music at the moment.
shuffle:
<<: *METADATA
name: shuffle
description: >
Shuffles the media queue
response:
success: >
The queue has been shuffled.
toggle_repeat:
<<: *METADATA
name: repeat
description: >
Toggles listening o repeat.
response:
repeat_true: >
Listening on repeat now!
repeat_false: >
Not listening on repeat anymore.
save_media:
<<: *METADATA
name: savemedia
description: >
Saves the YouTube URL with a specific name.
permission: dj
args:
- url
usage: savemedia [url] [name...]
delete_media:
<<: *METADATA
name: deletemedia
description: >
Deletes a saved YouTube URL from saved media.
permission: dj
usage: deletemedia [name]
response:
no_name: >
You must provide a name for the media to delete.
saved_media:
<<: *METADATA
name: savedmedia
description: >
Shows all saved YouTube URLs.
response:
no_saved: >
There are no saved YouTube URLs :(
volume:
<<: *METADATA
name: volume
permission: dj
args:
- volume
description: >
Sets the volume of the Music Player.
response:
invalid: >
The value you entered is an invalid volume.
quality:
<<: *METADATA
name: quality
permission: owner
args:
- quality
description: >
Sets the quality of the music of the Music Player.
The setting will be applied on the next song.
response:
invalid: >
You entered an invalid quality value.

@ -0,0 +1,133 @@
const cmdLib = require('../../lib/command');
/**
* This command module includes utility commands for the server.
*/
class ServerUtilityCommandModule extends cmdLib.CommandModule {
/**
* @param opts {Object} properties:
* getGuildHandler - a function to get the guild handler for the guild
* messagehandler - the MessageHandler instance
* config - the config object
*/
constructor(opts) {
super(cmdLib.CommandScopes.Guild);
this._templateDir = __dirname;
this._messageHandler = opts.messageHandler;
this._getGuildHandler = opts.getGuildHandler;
this._config = opts.config;
}
/**
* Serializes a command sequence to string.
* @param sqArray
* @returns {*}
* @private
*/
_serializeCmdSequence(sqArray) {
this._logger.debug(sqArray);
return sqArray.map((x) => x.join(' && ')).join('; ');
}
/**
* Registers the utility commands.
* @param commandHandler
*/
async register(commandHandler) {
await this._loadTemplate();
let saveCmd = new cmdLib.Command(
this.template.save_cmd,
new cmdLib.Answer(async (m, k, s) => {
let gh = await this._getGuildHandler(m.guild);
let sequenceString = s
.replace(new RegExp(`^${k.name}\\s`), '')
.replace(/\\&/g, '&')
.replace(/\\;/g, ';');
let innerStrings = sequenceString.match(/'.+?'/g) || [];
for (let innerString of innerStrings)
sequenceString.replace(innerString, innerString
.replace(/&/g, '\\&'))
.replace(/;/g, '\\;');
sequenceString = sequenceString
.replace(/"/g, '')
.replace(/'/g, '"');
let sequence = this._messageHandler.parseSyntaxString(sequenceString);
let execCommand = this._config.prefix + this.template.execute.name;
let maxSqPar = this._config.commandSettings.maxSequenceParallel;
let maxSqSer = this._config.commandSettings.maxSequenceSerial;
if (sequenceString.includes(execCommand)) {
return this.template.save_cmd.response.no_recursion;
} else if (sequence.length > maxSqPar) {
return this.template.save_cmd.response.sequence_too_many_parallel;
} else if (sequence.find(x => x.length > maxSqSer)) {
return this.template.save_cmd.response.sequence_too_many_serial;
} else {
let sql = gh.db.sql;
let row = await gh.db.get(sql.select('commands', false, [sql.count('*')],
sql.where('name', '=', sql.parameter(1))), [k.name]);
if (!row || Number(row.count) === 0)
await gh.db.run(sql.insert('commands', {name: sql.parameter(1), command: sql.parameter(2)}),
[k.name, JSON.stringify(sequence)]);
else
await gh.db.run(sql.update('commands', {command: sql.parameter(1)}, sql.where('name', '=', sql.parameter(2))),
[JSON.stringify(sequence), k.name]);
}
})
);
let deleteCmd = new cmdLib.Command(
this.template.delete_cmd,
new cmdLib.Answer(async (m, k) => {
let gh = await this._getGuildHandler(m.guild);
await gh.db.run(gh.db.sql.delete('commands', gh.db.sql.where('name', '=', gh.db.sql.parameter(1)), ), [k.name]);
return `Deleted command ${k.name}`;
})
);
let savedCmd = new cmdLib.Command(
this.template.saved_cmd,
new cmdLib.Answer(async (m) => {
let gh = await this._getGuildHandler(m.guild);
let response = new cmdLib.ExtendedRichEmbed('Saved Commands')
.setFooter(`Execute a saved entry with ${this._config.prefix}execute [Entryname]`);
let rows = await gh.db.all(gh.db.sql.select('commands', ['name', 'command']));
if (rows.length === 0)
return this.template.saved_cmd.response.no_commands;
else
for (let row of rows)
response.addField(row.name, '`' + this._serializeCmdSequence(JSON.parse(row.command)) + '`');
return response;
})
);
let execute = new cmdLib.Command(
this.template.execute,
new cmdLib.Answer(async (m, k) => {
let gh = await this._getGuildHandler(m.guild);
let row = await gh.db.get(gh.db.sql.select('commands',false, ['command'],
gh.db.sql.where('name', '=', gh.db.sql.parameter(1))), [k.name]);
if (row)
await this._messageHandler
.executeCommandSequence(JSON.parse(row.command), m);
else
return this.template.execute.response.not_found;
})
);
// register commands
commandHandler
.registerCommand(saveCmd)
.registerCommand(deleteCmd)
.registerCommand(savedCmd)
.registerCommand(execute);
}
}
Object.assign(exports, {
'module': ServerUtilityCommandModule
});

@ -0,0 +1,50 @@
METADATA: &METADATA
category: Server Utility
permission: all
save_cmd:
<<: *METADATA
name: savecmd
description: >
Saves a sequence of commands under a new name.
permission: moderator
usage: savecmd [cmdname] [cmdsequence]
args:
- name
response:
no_recursion: >
You are **not** allowed to execute another saved command in this sequence.
This is to prevent recursion that could break the bot.
sequence_too_many_parallel: >
This sequence executes too many commands in parallel.
sequence_too_many_serial: >
This sequence executes too long serial chains.
delete_cmd:
<<: *METADATA
name: deletecmd
description: >
Deletes a saved command.
permission: moderator
args:
- name
saved_cmd:
<<: *METADATA
name: savedcmd
description: >
Lists all saved commands.
response:
no_commands: >
There are no saved commands.
execute:
<<: *METADATA
name: execute
description: >
Executes a saved command.
args:
- name
response:
not_found: >
The command to be executed couldn't be found.

@ -0,0 +1,111 @@
const cmdLib = require('../../lib/command');
/**
* Utility commands are all commands that allow the user to control the behaviour of the
* bot. Utility commands for example are allowed to:
* - manipulate the main database
* - manipulate the bot's presences
* - manipulate the process (e.g. shutdown)
*/
class UtilityCommandModule extends cmdLib.CommandModule {
/**
* @param opts {Object} properties:
* bot - the instance of the bot.
* config - the config object
*/
constructor(opts) {
super(cmdLib.CommandScopes.User);
this._templateDir = __dirname;
this._bot = opts.bot;
this._config = opts.config;
}
async register(commandHandler) {
await this._loadTemplate();
let sql = this._bot.maindb.sql;
let addPresence = new cmdLib.Command(
this.template.add_presence,
new cmdLib.Answer(async (m, k, s) => {
this._bot.presences.push(s);
await this._bot.maindb.run(sql.insert('presences', {text: sql.parameter(1)}), [s]);
return `Added Presence \`${s}\``;
})
);
let rotatePresence = new cmdLib.Command(
this.template.rotate_presence,
new cmdLib.Answer(() => {
try {
this._bot.client.clearInterval(this._bot.rotator);
this._bot.rotatePresence();
this._bot.rotator = this._bot.client.setInterval(() => this._bot.rotatePresence(),
this._config.presence_duration);
} catch (error) {
this._logger.warn(error.message);
}
})
);
let shutdown = new cmdLib.Command(
this.template.shutdown,
new cmdLib.Answer(async (m) => {
try {
await m.reply('Shutting down...');
this._logger.debug('Destroying client...');
await this._bot.client.destroy();
} catch (err) {
this._logger.error(err.message);
this._logger.debug(err.stack);
}
try {
this._logger.debug('Exiting server...');
await this._bot.webServer.stop();
} catch (err) {
this._logger.error(err.message);
this._logger.debug(err.stack);
}
try {
this._logger.debug(`Exiting Process...`);
process.exit(0);
} catch (err) {
this._logger.error(err.message);
this._logger.debug(err.stack);
}
})
);
let createUser = new cmdLib.Command(
this.template.create_user,
new cmdLib.Answer(async (m, k) => {
if (k.username &&k.password && k.scope) {
this._logger.debug(`Creating user entry for ${k.username}`);
let token = await this._bot.webServer.createUser(
k.username, k.password, k.scope, false);
return `${k.username}'s token is ${token}`;
}
})
);
let bugReport = new cmdLib.Command(
this.template.bugreport,
new cmdLib.Answer(() => {
return new cmdLib.ExtendedRichEmbed(this.template.bugreport.response.title)
.setDescription(this.template.bugreport.response.bug_report);
})
);
// register commands
commandHandler.registerCommand(addPresence)
.registerCommand(rotatePresence)
.registerCommand(shutdown)
.registerCommand(createUser)
.registerCommand(bugReport);
}
}
Object.assign(exports, {
'module': UtilityCommandModule
});

@ -0,0 +1,44 @@
METADATA: &METADATA
category: Utility
permission: owner
shutdown:
<<: *METADATA
name: shutdown
description: >
Shuts down the bot.
add_presence:
<<: *METADATA
name: addpresence
description: >
Adds a Rich Presence to the bot.
usage: addpresence [presence]
rotate_presence:
<<: *METADATA
name: rotatepresence
description: >
Forces a presence rotation
create_user:
<<: *METADATA
name: createuser
description: >
Creates a user for the webinterface.
args:
- username
- password
- scope
bugreport:
<<: *METADATA
name: bug
permission: all
description: >
Get information about where to report bugs.
response:
title: >
You want to report a bug?
bug_report: >
Please report your bugs [here](https://github.com/Trivernis/discordbot.js/issues)

@ -1,87 +0,0 @@
{
"utils": {
"help": {
"name": "help",
"permission": "all",
"description": "Shows this help command.",
"category": "Utility",
"args": [
"command"
]
},
"say": {
"name": "say",
"permission": "all",
"description": "Says something. ~say [String].",
"category": "Utility"
},
"addpresence": {
"name": "addpresence",
"permission": "owner",
"description": "Adds a presence to presences.",
"category": "Utility"
},
"shutdown": {
"name": "shutdown",
"description": "Shuts the bot down.",
"permission": "owner",
"category": "Utility"
},
"rotate": {
"name": "rotate",
"description": "Forces a presence rotation",
"permission": "owner",
"category": "Utility"
},
"createUser": {
"name": "createUser",
"permission": "owner",
"description": "Creates a new user for the webinterface.",
"category": "Utility",
"args": [
"username",
"password",
"scope"
]
},
"bugreport": {
"name": "bug",
"permission": "all",
"description": "Get info about how to report a bug",
"category": "Utility",
"response": {
"bug_report": "Please report your bugs [here](https://github.com/Trivernis/discordbot.js/issues)"
}
}
},
"info": {
"about": {
"name": "about",
"permission": "all",
"description": "Shows information about this bot.",
"category": "Info",
"response": {
"about_icon": "This icon war created by [blackrose14344](https://www.deviantart.com/blackrose14344). \n [Original](https://www.deviantart.com/blackrose14344/art/2B-Chi-B-685771489)",
"about_creator": "This bot was created by Trivernis. More about this bot [here](https://github.com/Trivernis/discordbot.js)."
}
},
"ping": {
"name": "ping",
"permission": "owner",
"description": "Answers with the current average ping of the bot.",
"category": "Info"
},
"uptime": {
"name": "uptime",
"permission": "owner",
"description": "Answers with the current uptime of the bot.",
"category": "Info"
},
"guilds": {
"name": "guilds",
"permission": "owner",
"description": "Answers with the number of guilds the bot has joined",
"category": "Info"
}
}
}

@ -1,203 +0,0 @@
{
"utils": {
"roles": {
"name": "roles",
"permission": "all",
"description": "Shows the roles used for commands on the server.",
"category": "Utility"
},
"savecmd": {
"name": "savecmd",
"permission": "moderator",
"description": "Saves a sequence of commands under a new name. ~save [cmdsequence] [cmdname]. Semicoli must be escaped with \\ (Backslash)",
"category": "Utility",
"response": {
"no_recursion": "You are **not** allowed to execute another saved command in this sequence. This is a safety measure to avoid endlessly recursive calls.",
"sequence_too_long": "This command sequence is too long!"
}
},
"savedcmd": {
"name": "savedcmd",
"permission": "all",
"description": "Displays the saved commands.",
"category": "Utility",
"response": {
"no_commands": "There are no saved commands."
}
},
"deletecmd": {
"name": "deletecmd",
"permission": "moderator",
"description": "Delete a saved command.",
"args": [
"cmdname"
],
"category": "Utility"
},
"execute": {
"name": "execute",
"permission": "all",
"args": [
"cmdname"
],
"description": "Execute saved commands.",
"category": "Utility",
"response": {
"not_found": "This command could not be found."
}
}
},
"music": {
"play": {
"name": "play",
"permission": "all",
"args": [
"url"
],
"description": "Adds the url to the YouTube video/playlist into the queue.",
"category": "Music",
"response": {
"success": "Added Song/Playlist to the queue.",
"failure": "Failed adding Song/Playlist to the queue.",
"url_invalid": "This is not a valid url!",
"no_url": "I need an url to a video to play!",
"no_voicechannel": "You need to join a voicechannel to do that!"
}
},
"playnext": {
"name": "playnext",
"permission": "all",
"args": [
"url"
],
"description": "Adds the url to the YouTube video as next song to the queue.",
"category": "Music",
"response": {
"success": "Added Song as next Song to the queue.",
"failure": "Failed adding Song as next Song to the queue.",
"url_invalid": "This is not a valid url!",
"no_url": "I need an url to a video to play",
"no_voicechannel": "You need to join a voicechannel to do that!"
}
},
"join": {
"name": "join",
"permission": "all",
"description": "Joins the VC you are in.",
"category": "Music",
"response": {
"not_connected": "You are not connected to a Voice Channel."
}
},
"stop": {
"name": "stop",
"permission": "dj",
"description": "Stops playing music and leaves.",
"category": "Music",
"response": {
"success": "Stopping now...",
"not_playing": "I'm not playing music at the moment."
}
},
"pause": {
"name": "pause",
"permission": "all",
"description": "Pauses playing.",
"category": "Music",
"response": {
"success": "Pausing playback.",
"not_playing": "I'm not playing music at the moment."
}
},
"resume": {
"name": "resume",
"permission": "all",
"description": "Resumes playing.",
"category": "Music",
"response": {
"success": "Resuming playback.",
"not_playing": "I'm not playing music at the moment."
}
},
"skip": {
"name": "skip",
"permission": "dj",
"description": "Skips the current song.",
"category": "Music",
"response": {
"success": "Skipping to the next song.",
"not_playing": "I'm not playing music at the moment."
}
},
"clear": {
"name": "clear",
"permission": "dj",
"description": "Clears the queue.",
"category": "Music",
"response": {
"success": "The Queue has been cleared."
}
},
"playlist": {
"name": "queue",
"permission": "all",
"description": "Shows the next ten songs.",
"category": "Music"
},
"current": {
"name": "np",
"permission": "all",
"description": "Shows the currently playing song.",
"category": "Music",
"response": {
"not_playing": "I'm not playing music at the moment."
}
},
"shuffle": {
"name": "shuffle",
"permission": "all",
"description": "Shuffles the playlist.",
"category": "Music",
"response": {
"success": "The Queue has been shuffled."
}
},
"repeat": {
"name": "repeat",
"permission": "all",
"description": "Toggle listening on repeat.",
"category": "Music",
"response": {
"repeat_true": "Listening on repeat now!",
"repeat_false": "Not listening on repeat anymore."
}
},
"savemedia": {
"name": "savemedia",
"permission": "dj",
"args": [
"url"
],
"description": "Saves the YouTube song/playlist with a specific name",
"category": "Music"
},
"savedmedia": {
"name": "savedmedia",
"permission": "all",
"description": "Prints out all saved playlists and songs.",
"category": "Music",
"response": {
"no_saved": "There are no saved songs/playlists :("
}
},
"deletemedia": {
"name": "deletemedia",
"permission": "dj",
"description": "Deletes a saved media entry. ~deletemedia [name]",
"category": "Music",
"response": {
"no_name": "You must provide a name for the media that shall be deleted."
}
}
}
}

@ -0,0 +1,22 @@
query AnimeData($name: String, $id: Int, $withStaff: Boolean = false, $withMoreData: Boolean = true) {
Media (id: $id, search: $name, type: ANIME) {
...mediaMetadata
...mediaAdditionalMetadata @include(if: $withMoreData)
...staffFields @include(if: $withStaff)
season @include(if: $withMoreData)
episodes @include(if: $withMoreData)
duration @include(if: $withMoreData)
studios(isMain: true) @include(if: $withMoreData) {
studioList: nodes {
id
name
siteUrl
}
}
nextAiringEpisode @include(if: $withMoreData) {
id
airingAt
episode
}
}
}

@ -0,0 +1,27 @@
query ($name: String, $id: Int) {
Character(search: $name, id: $id) {
id
name {
first
last
native
}
description
image {
large
medium
}
siteUrl
media {
edges {
characterRole
voiceActors(language: JAPANESE) {
...staffMetadata
}
node {
...mediaMetadata
}
}
}
}
}

@ -0,0 +1,63 @@
mediaMetadata: |
fragment mediaMetadata on Media {
id
siteUrl
title {
romaji
english
native
}
coverImage {
large
medium
color
}
}
mediaAdditionalMetadata: |
fragment mediaAdditionalMetadata on Media {
status
description(asHtml: false)
format
genres
averageScore
favourites
startDate {
year
month
day
}
endDate {
year
month
day
}
}
staffFields: |
fragment staffFields on Media {
staff {
edges {
node {
...staffMetadata
}
role
}
}
}
staffMetadata: |
fragment staffMetadata on Staff {
id
name {
first
last
native
}
image {
large
medium
}
language
siteUrl
}

@ -0,0 +1,9 @@
query MangaData($name: String, $id: Int, $withStaff: Boolean = false, $withMoreData: Boolean = true) {
Media (id: $id, search: $name, type: MANGA) {
...mediaMetadata
...mediaAdditionalMetadata @include(if: $withMoreData)
...staffFields @include(if: $withStaff)
chapters @include(if: $withMoreData)
volumes @include(if: $withMoreData)
}
}

@ -0,0 +1,57 @@
query StaffData($name: String, $id: Int) {
Staff(id: $id, search: $name) {
id
name {
first
last
native
}
language
image {
large
medium
}
staffMedia(page: 0, perPage: 10) {
edges {
node {
id
title {
romaji
english
native
}
siteUrl
}
characters {
id
name {
first
last
}
siteUrl
image {
large
medium
}
}
staffRole
}
}
characters(page: 0, perPage: 10) {
nodes {
id
name {
first
last
}
siteUrl
image {
large
medium
}
}
}
description(asHtml: false)
siteUrl
}
}

@ -0,0 +1,177 @@
const fetch = require('node-fetch'),
fsx = require('fs-extra'),
yaml = require('js-yaml'),
queryPath = __dirname + '/graphql',
alApiEndpoint = 'https://graphql.anilist.co';
async function getFragments() {
let fragments = await fsx.readFile(`${queryPath}/Fragments.yaml`, {encoding: 'utf-8'});
return yaml.safeLoad(fragments);
}
/**
* Return a graphql query read from a file from a configured path.
* @param name
* @returns {Promise<String>}
*/
async function getGraphqlQuery(name) {
let query = await fsx.readFile(`${queryPath}/${name}.gql`, {encoding: 'utf-8'});
let fragments = await getFragments();
for (let [key, value] of Object.entries(fragments))
if (query.includes(`...${key}`))
query += '\n' + value;
return query;
}
/**
* Post a query read from a file to the configured graphql endpoint and return the data.
* @param queryName
* @param queryVariables
* @returns {Promise<JSON>}
*/
function postGraphqlQuery(queryName, queryVariables) {
return new Promise(async (resolve, reject) => {
fetch(alApiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
query: (await getGraphqlQuery(queryName)),
variables: queryVariables
})
}).then(async (response) => {
let json = await response.json();
return response.ok ? json: Promise.reject(json);
}).then((data) => resolve(data.data)).catch((err) => reject(err));
});
}
/**
* Get an anime by id.
* @param id {Number}
* @param withStaff {Boolean} Include Staff information?
* @param withMetadata {Boolean} Include Metadata?
* @returns {Promise<JSON>}
*/
async function getAnimeById(id, withStaff, withMoreData) {
let data = await postGraphqlQuery('AnimeQuery',
{id: id, withStaff: withStaff, withMoreData: withMoreData});
if (data && data.Media)
return data.Media;
else
return null;
}
/**
* Get a manga by id.
* @param id {Number}
* @param withStaff {Boolean} Include Staff information?
* @param withMoreData {Boolean} Include Metadata?
* @returns {Promise<JSON>}
*/
async function getMangaById(id, withStaff, withMoreData) {
let data = await postGraphqlQuery('MangaQuery',
{id: id, withStaff: withStaff, withMoreData: withMoreData});
if (data && data.Media)
return data.Media;
else
return null;
}
/**
* Returns a staff member by id.
* @param id {Number}
* @returns {Promise<*>}
*/
async function getStaffById(id) {
let data = await postGraphqlQuery('StaffQuery', {id: id});
if (data && data.Staff)
return data.Staff;
else
return null;
}
/**
* Returns a character by id.
* @param id {Number}
* @returns {Promise<*>}
*/
async function getCharacterById(id) {
let data = await postGraphqlQuery('CharacterQuery', {id: id});
if (data && data.Character)
return data.Character;
else
return null;
}
/**
* Search for an anime by name and get it by id.
* @param name {String}
* @param withStaff {Boolean} Include Staff information?
* @param withMoreData {Boolean} Include Metadata?
* @returns {Promise<*>}
*/
async function searchAnimeByName(name, withStaff, withMoreData) {
let data = await postGraphqlQuery('AnimeQuery',
{name: name, withStaff: withStaff, withMoreData: withMoreData});
if (data && data.Media)
return data.Media;
else
return null;
}
/**
* Search for a manga by name and get it by id.
* @param name {String}
* @param withStaff {Boolean} Include Staff information?
* @param withMoreData {Boolean} Include Metadata?
* @returns {Promise<*>}
*/
async function searchMangaByName(name, withStaff, withMoreData) {
let data = await postGraphqlQuery('MangaQuery',
{name: name, withStaff: withStaff, withMoreData: withMoreData});
if (data && data.Media)
return data.Media;
else
return null;
}
/**
* Search for a staff member by name and get information.
* @param name {String} The name of the staff member
* @returns {Promise<*>}
*/
async function searchStaffByName(name) {
let data = await postGraphqlQuery('StaffQuery', {name: name});
if (data && data.Staff)
return data.Staff;
else
return null;
}
/**
* Seach for a character by name and get information.
* @param name {String} Character Name
* @returns {Promise<*>}
*/
async function searchCharacterByName(name) {
let data = await postGraphqlQuery('CharacterQuery', {name: name});
if (data && data.Character)
return data.Character;
else
return null;
}
// exports
Object.assign(exports, {
getAnimeById: getAnimeById,
getMangaById: getMangaById,
getStaffById: getStaffById,
getCharacterById: getCharacterById,
searchAnimeByName: searchAnimeByName,
searchMangaByName: searchMangaByName,
searchStaffByName: searchStaffByName,
searchCharacterByName: searchCharacterByName
});

@ -1,325 +0,0 @@
/* Module definition */
/* Variable Definition */
const Discord = require('discord.js'),
args = require('args-parser')(process.argv),
config = require('../config.json'),
gcmdTempl = require('../commands/globalcommands'),
scmdTempl = require('../commands/servercommands');
let logger = require('winston'),
globCommands = {};
/**
* @type {Servant}
*/
exports.Servant = class {
constructor(prefix) {
this.commands = {};
this.prefix = prefix;
// show all commands (except the owner commands if the user is not an owner)
this.createCommand(gcmdTempl.utils.help, (msg, kwargs) => {
if (kwargs.command) {
let cmd = kwargs.command;
let allCommands = {...globCommands, ...this.commands};
if (cmd.charAt(0) !== prefix)
cmd = this.prefix + cmd;
if (allCommands[cmd])
return new Discord.RichEmbed()
.setTitle(`Help for ${cmd}`)
.addField('Usage', `\`${cmd} [${allCommands[cmd].args.join('] [')}]\``.replace('[]', ''))
.addField('Description', allCommands[cmd].description)
.addField('Permission Role', allCommands[cmd].role || 'all');
else
return 'Command not found :(';
} else {
let helpEmbed = new Discord.RichEmbed()
.setTitle('Commands')
.setDescription('Create a sequence of commands with `;` (semicolon).')
.setTimestamp();
let globHelp = '';
Object.entries(globCommands).sort().forEach(([key, value]) => {
if (value.role !== 'owner' || checkPermission(msg, 'owner'))
globHelp += `\`${key}\` \t`;
});
helpEmbed.addField('Global Commands', globHelp);
let categories = [];
let catCommands = {};
Object.entries(this.commands).sort().forEach(([key, value]) => {
if (value.role !== 'owner' || checkPermission(msg, 'owner'))
if (!categories.includes(value.category)) {
categories.push(value.category);
catCommands[value.category] = `\`${key}\` \t`;
} else {
catCommands[value.category] += `\`${key}\` \t`;
}
});
for (let cat of categories)
helpEmbed.addField(cat, catCommands[cat]);
helpEmbed.setFooter(prefix + 'help [command] for more info to each command');
return helpEmbed;
}
});
// show all roles that are used by commands
this.createCommand(scmdTempl.utils.roles, () => {
let roles = [];
Object.values(globCommands).concat(Object.values(this.commands)).sort().forEach((value) => {
roles.push(value.role || 'all');
});
return `**Roles**\n${[...new Set(roles)].join('\n')}`;
});
}
/**
* Creates a command entry in the private commands dict
* @param template
* @param call
*/
createCommand(template, call) {
if (!template.name) {
logger.debug(`Name of command template is null or undef. Failed creating command.`);
return;
}
this.commands[this.prefix + template.name] = {
'args': template.args || [],
'description': template.description,
'callback': call,
'role': template.permission,
'category': template.category || 'Other'
};
logger.debug(`Created server command: ${this.prefix + template.name}, args: ${template.args}`);
}
/**
* Removes a command
* @param command
* @deprecated Why would you want to remove a command?
*/
removeCommand(command) {
delete this.commands[command];
}
/**
* Processes the command
* @param msg
* @param globResult
* @param content
* @param returnFunction Boolean if the return value should be a function.
* @param fallback
* @returns {*}
*/
processCommand(msg, globResult, content, returnFunction, fallback) {
let command = (content.match(/^.\w+/) || [])[0];
if (!command || !this.commands[command])
if (fallback && !globResult) {
command = fallback;
content = `${fallback} ${content}`;
} else {
return globResult;
}
let cmd = this.commands[command];
if (!checkPermission(msg, cmd.role))
return 'No Permission';
logger.debug(`Permission <${cmd.role || 'all'}> granted for command ${command} for user <${msg.author.tag}>`);
let argvars = content.match(/(?<= )\S+/g) || [];
let kwargs = {};
let nLength = Math.min(cmd.args.length, argvars.length);
for (let i = 0; i < nLength; i++)
kwargs[cmd.args[i]] = argvars[i];
let argv = argvars.slice(nLength);
logger.debug(`Executing callback for command: ${command}, kwargs: ${kwargs}, argv: ${argv}`);
try {
let locResult = returnFunction? () => cmd.callback(msg, kwargs, argv) : cmd.callback(msg, kwargs, argv);
return locResult || globResult;
} catch (err) {
logger.error(err.message);
return `The command \`${command}\` has thrown an error.`;
}
}
/**
* Parses the message and executes the command callback for the found command entry in the commands dict
* @param msg
* @returns {*}
*/
parseCommand(msg) {
let globResult = parseGlobalCommand(msg);
logger.debug(`Global command result is ${globResult}`);
let content = msg.content;
let commands = content.split(/(?<!\\);/).map(x => x.replace(/^ +/, ''));
if (commands.length === 1) {
return this.processCommand(msg, globResult, content);
} else if (commands.length < (config.maxCmdSequenceLength || 5)) {
let answers = [];
let previousCommand = (commands[0].match(/^.\w+/) || [])[0];
for (let i = 0; i < commands.length; i++) {
answers.push(this.processCommand(msg, globResult[i], commands[i],
true, previousCommand)); // return function to avoid "race conditions"
let commandMatch = (commands[i].match(/^.\w+/) || [])[0];
previousCommand = this.commands[commandMatch]? commandMatch : previousCommand;
}
return answers;
} else {
return 'This command sequence is too long!';
}
}
};
/**
* Getting the logger
* @param {Object} newLogger
*/
exports.setLogger = function (newLogger) {
logger = newLogger;
};
/**
* Creates a global command that can be executed in every channel.
* @param prefix
* @param template
* @param call
*/
exports.createGlobalCommand = function (prefix, template, call) {
if (!template.name) {
logger.debug(`Name of command template is null or undef. Failed to create command.`);
return;
}
globCommands[prefix + template.name] = {
'args': template.args || [],
'description': template.description,
'callback': call,
'role': template.permission,
'name': template.name,
'category': template.category || 'Other'
};
logger.debug(`Created global command: ${prefix + template.name}, args: ${template.args}`);
};
/**
* Parses a message for a global command
* @param msg
* @returns {boolean|*}
*/
exports.parseMessage = function (msg) {
return parseGlobalCommand(msg);
};
/**
* Initializes the module by creating a help command
*/
exports.init = function (prefix) {
logger.verbose("Created help command");
this.createGlobalCommand((prefix || config.prefix), gcmdTempl.utils.help, (msg, kwargs) => {
if (kwargs.command) {
let cmd = kwargs.command;
if (cmd.charAt(0) !== prefix)
cmd = prefix + cmd;
if (globCommands[cmd])
return new Discord.RichEmbed()
.setTitle(`Help for ${cmd}`)
.addField('Usage', `\`${cmd} [${globCommands[cmd].args.join('] [')}]\``.replace('[]', ''))
.addField('Description', globCommands[cmd].description)
.addField('Permission Role', globCommands[cmd].role || 'all');
} else {
let helpEmbed = new Discord.RichEmbed()
.setTitle('Global Commands')
.setDescription('Create a sequence of commands with `;` (semicolon).')
.setTimestamp();
let description = '';
Object.entries(globCommands).sort().forEach(([key, value]) => {
if (value.role === 'owner' && checkPermission(msg, 'owner'))
description += `\`${key}\` \t`;
else if (value.role !== 'owner')
description += `\`${key}\` \t`;
});
helpEmbed.setFooter(prefix + 'help [command] for more info to each command');
helpEmbed.setDescription(description);
return helpEmbed;
}
});
};
function processCommand(cmd, msg, content, returnFunction) {
let argvars = content.match(/(?<= )\S+/g) || [];
let kwargs = {};
let nLength = Math.min(cmd.args.length, argvars.length);
for (let i = 0; i < nLength; i++)
kwargs[cmd.args[i]] = argvars[i];
let argv = argvars.slice(nLength);
logger.debug(`Executing callback for command: ${cmd.name}, kwargs: ${JSON.stringify(kwargs)}, argv: ${argv}`);
return returnFunction? () => cmd.callback(msg, kwargs, argv) : cmd.callback(msg, kwargs, argv);
}
/**
* Parses the message by calling the assigned function for the command with arguments
* @param msg
*/
function parseGlobalCommand(msg) {
let content = msg.content;
let commands = content.split(/(?<!\\);/).map(x => x.replace(/^ +/, ''));
if (commands.length === 1) {
let command = (content.match(/^.\w+/) || [])[0];
if (!command || !globCommands[command])
return false;
let cmd = globCommands[command];
if (!checkPermission(msg, cmd.role))
return false;
logger.debug(`Permission <${cmd.role}> granted for command ${command} for user <${msg.author.tag}>`);
return processCommand(cmd, msg, content);
} else if (commands.length < (config.maxCmdSequenceLength || 5)) {
let answers = [];
let previousCommand = '';
for (let commandPart of commands) {
let command = (commandPart.match(/^.\w+/) || [])[0] || previousCommand;
previousCommand = globCommands[command]? command : previousCommand;
if (!commandPart || !globCommands[command]) {
commandPart = `${previousCommand} ${commandPart}`;
command = previousCommand;
}
if (command && globCommands[command]) {
let cmd = globCommands[command];
if (checkPermission(msg, cmd.role)) {
logger.debug(`Permission <${cmd.role}> granted for command ${command} for user <${msg.author.tag}>`);
answers.push(processCommand(cmd, msg, commandPart,
true)); // return an function to avoid "race conditions"
} else {
answers.push(false);
}
} else {
answers.push(false);
}
}
return answers;
} else {
return 'This command sequence is too long!';
}
}
/**
* @param msg
* @param rolePerm {String}
* @returns {boolean}
*/
function checkPermission(msg, rolePerm) {
if (!rolePerm || ['all', 'any', 'everyone'].includes(rolePerm))
return true;
if (msg.author.tag === args.owner || config.owners.includes(msg.author.tag))
return true;
else
if (msg.member && rolePerm && rolePerm !== 'owner' && msg.member.roles
.some(role => (role.name.toLowerCase() === rolePerm.toLowerCase() || role.name.toLowerCase() === 'botcommander')))
return true;
return false;
}

@ -0,0 +1,318 @@
const Discord = require('discord.js'),
yaml = require('js-yaml'),
fsx = require('fs-extra'),
logging = require('../utils/logging'),
config = require('../../config.json'),
xevents = require('../utils/extended-events'),
utils = require('../utils');
const scopes = new utils.Enum([
'Global',
'User',
'Guild'
]);
/**
* The answer message object that is used for easyer access to events.
*/
class Response extends xevents.ExtendedEventEmitter {
/**
* Constructor.
* @param content
*/
constructor(content) {
super();
this.content = content;
this.message = null;
}
}
class Answer {
/**
* Creates an new Answer object with _func as answer logic.
* @param func {function} - the function to evaluate the answer
* @param [onSent] {function} - executed when the response was sent
*/
constructor(func, onSent) {
this._func = func;
this.listeners = onSent? {sent: onSent} : {};
this.lastResponse = null;
}
/**
* Evaluates the answer string for the answer object.
* If the logic function returns a promise all nested promises get resolved.
* @param message
* @param kwargs
* @param argsString
* @returns {Promise<Response>}
*/
async evaluate(message, kwargs, argsString) {
let result = this._func(message, kwargs, argsString);
if (result instanceof Promise)
return this._getResponseInstance(await utils.resolveNestedPromise(result));
else
return this._getResponseInstance(result);
}
/**
* Returns a response instance with listeners attached if defined.
* @param responseContent
* @returns {Response}
* @private
*/
_getResponseInstance(responseContent) {
this.lastResponse = new Response(responseContent);
if (this.listeners)
this.lastResponse.addListeners(this.listeners);
return this.lastResponse;
}
}
class Command {
/**
* Creates a new command object where the answer function needs
* to be implemented for it to work.
* @param template {JSON:{}}
* @param answer {Answer}
*/
constructor(template, answer) {
this.name = template.name;
this.prefix = '';
this.description = template.description;
this.args = template.args || [];
this.permission = template.permission;
this.category = template.category || 'Other';
this.usage = template.usage ||
`${this.name} [${this.args.join('][')}]`.replace('[]', '');
this.answObj = answer;
if (!template.name)
throw new Error("Template doesn't define a name.");
}
/**
* This method is meant to be replaced by logic.
* @abstract
* @param message {Discord.Message}
* @param kwargs {JSON}
* @param argsString {String} The raw argument string.
* @returns {Response}
*/
async answer(message, kwargs, argsString) {
return await this.answObj.evaluate(message, kwargs, argsString);
}
/**
* Returns rich help embed for this command.
* @returns {*|Discord.RichEmbed}
*/
get help() {
return new ExtendedRichEmbed(`Help for ${this.name}`)
.addFields({
'Usage': `\`${this.prefix}${this.usage}\``,
'Description': this.description,
'Permission Role': this.permission
});
}
}
class CommandHandler {
/**
* Initializes the CommandHandler
* @param prefix {String} The prefix of all commands.
* @param scope {Number} A scope from the CommandScopes (scopes)
*/
constructor(prefix, scope) {
this.prefix = prefix;
this.scope = scope;
this.commands = {};
this._logger = new logging.Logger(`${this.constructor.name}@${Object.keys(scopes)[this.scope]}`);
}
/**
* Handles the command and responds to the message.
* @param commandMessage {String}
* @param message {Discord.Message}
* @returns {Response | Promise<Response>}
*/
handleCommand(commandMessage, message) {
this._logger.debug(`Handling command ${commandMessage}`);
let commandName = commandMessage.match(/^\S+/);
if (commandName.length > 0)
commandName = commandName[0];
this._logger.silly(`Command name is ${commandName}`);
if (commandName.indexOf(this.prefix) >= 0) {
commandName = commandName.replace(this.prefix, '');
let argsString = commandMessage.replace(/^\S+/, '');
argsString = argsString
.replace(/^\s+/, '') // leading whitespace
.replace(/\s+$/, ''); // trailing whitespace
let args = argsString.match(/\S+/g);
let command = this.commands[commandName];
if (command && this._checkPermission(message, command.permission)) {
this._logger.silly(`Permission ${command.permission} granted for command ${commandName}`);
let kwargs = {};
if (args)
for (let i = 0; i < Math.min(command.args.length, args.length); i++)
kwargs[command.args[i]] = args[i];
return command.answer(message, kwargs, argsString);
} else if (command) {
this._logger.silly(`Permission ${command.permission} denied for command ${commandName}`);
return new Response("You don't have permission for this command");
} else {
this._logger.silly(`Command ${commandName} not found.`);
return null;
}
} else {
this._logger.silly(`No prefix found in command ${commandName}`);
return null;
}
}
/**
* Registers the command so that the handler can use it.
* @param command {Command}
*/
registerCommand(command) {
command.prefix = this.prefix;
this.commands[command.name] = command;
this._logger.debug(`Registered ${command.name} on handler`);
return this;
}
/**
* Checks if the author of the message has the given permission
* @param msg {Discord.Message}
* @param rolePerm {String} Permission String
* @returns {boolean}
* @private
*/
_checkPermission(msg, rolePerm) {
if (!rolePerm || ['all', 'any', 'everyone'].includes(rolePerm))
return true;
if (config.owners.includes(msg.author.tag))
return true;
else if (msg.member && rolePerm && rolePerm !== 'owner' && msg.member.roles
.some(role => (role.name.toLowerCase() === rolePerm.toLowerCase() ||
role.name.toLowerCase() === 'botcommander')))
return true;
return false;
}
}
/**
* @abstract
*/
class CommandModule {
/**
* Initializes a CommandModule instance.
* @param scope
*/
constructor(scope) {
this.scope = scope;
this._logger = new logging.Logger(this);
}
/**
* Loads a template for the module from the this._templateDir directory.
* Loads the template from this.templateFile if the attribute exists.
* @param dir {String} Overides the this._templateDir with this directory.
* @returns {Promise<void>}
* @private
*/
async _loadTemplate(dir) {
if (!this.templateFile)
this.templateFile = (dir || this._templateDir) + '/template.yaml';
let templateString = await fsx.readFile(this.templateFile, {encoding: 'utf-8'});
this._logger.silly(`Loaded Template file ${this.templateFile}`);
this.template = yaml.safeLoad(templateString);
}
/**
* Registering commands after loading a template
* @param commandHandler {CommandHandler}
* @returns {Promise<void>}
*/
async register(commandHandler) { // eslint-disable-line no-unused-vars
}
}
class ExtendedRichEmbed extends Discord.RichEmbed {
/**
* Constructor that automatically set's the Title and Timestamp.
* @param title {String}
*/
constructor(title) {
super();
this.setTitle(title);
this.setTimestamp();
}
/**
* Adds the fields defined in the fields JSON
* @param fields {JSON}
* @returns {ExtendedRichEmbed}
*/
addFields(fields) {
for (let [name, value] of Object.entries(fields))
this.addField(name, value);
return this;
}
/**
* Sets the description by shortening the value string to a fitting length for discord.
* @param value
*/
setDescription(value) {
if (value) {
let croppedValue = value;
if (value.substring)
croppedValue = value.substring(0, 1024);
if (croppedValue.length < value.length && croppedValue.replace)
croppedValue = croppedValue.replace(/\n.*$/g, '');
if (croppedValue && croppedValue.replace
&& croppedValue.replace(/\n/g, '').length > 0)
super.setDescription(croppedValue);
}
return this;
}
/**
* Sets the field by shortening the value stirn to a fitting length for discord.
* @param name
* @param value
*/
addField(name, value) {
if (name && value) {
let croppedValue = value;
if (value.substring)
croppedValue = value.substring(0, 1024);
if (croppedValue && croppedValue.length < value.length && croppedValue.replace)
croppedValue = croppedValue.replace(/\n.*$/g, '');
if (croppedValue && croppedValue.replace
&& croppedValue.replace(/\n/g, '').length > 0 && name.replace(/\n/g, '').length > 0)
super.addField(name, croppedValue);
}
return this;
}
}
// -- exports -- //
Object.assign(exports, {
Answer: Answer,
Command: Command,
CommandHandler: CommandHandler,
CommandModule: CommandModule,
ExtendedRichEmbed: ExtendedRichEmbed,
CommandScopes: scopes
});

@ -0,0 +1,177 @@
const genericSql = require('../utils/genericSql'),
logging = require('../utils/logging'),
config = require('../../config.json');
class Database {
/**
* Creates a new database.
* @param name {String} - the name of the database.
*/
constructor(name) {
this.name = name;
this._logger = new logging.Logger(`Database@${name}`);
this._dbType = config.database? config.database : 'sqlite';
if (this._dbType === 'sqlite')
this.database = new (require('../utils/sqliteAsync')).Database(`./data/${this.name}.db`);
else if (this._dbType === 'postgresql')
this.database = new (require('pg')).Pool({
user: config.databaseConnection.user,
host: config.databaseConnection.host,
database: config.databaseConnection.database,
password: config.databaseConnection.password,
port: config.databaseConnection.port
});
this.sql = new genericSql.GenericSql(this._dbType);
}
/**
* Initializes the database.
* @returns {Promise<void>}
*/
async initDatabase() {
if (this._dbType === 'sqlite') {
await this.database.init();
} else if (this._dbType === 'postgresql') {
await this.database.connect();
await this.begin();
await this.database.query(`CREATE SCHEMA IF NOT EXISTS ${this.name.replace(/\W/g, '')}`);
await this.database.query(`SET search_path TO ${this.name.replace(/\W/g, '')}`);
await this.commit();
}
this._logger.verbose(`Connected to ${this._dbType} database ${this.name}`);
}
/**
* Run a sql statement with seperate values and no return.
* Autocommit.
* @param sql {String}
* @param [values] {Array<String|Number>}
* @returns {Promise<*>}
*/
async run(sql, values) {
this._logger.debug(`Running SQL "${sql}" with values ${values}`);
if (this._dbType === 'sqlite')
await this.database.run(sql, values);
else if (this._dbType === 'postgresql')
try {
await this.begin();
await this.database.query(sql, values);
await this.commit();
} catch (err) {
this._logger.error(err.message);
this._logger.verbose(err.stack);
await this.rollback();
}
}
/**
* Begin. Part of Postgresqls BEGIN / COMMIT / ROLLBACK
* @returns {Promise<void>}
*/
async begin() {
if (this._dbType === 'postgresql') {
await this.database.query('BEGIN');
await this.database.query(`SET search_path TO ${this.name.replace(/\W/g, '')};`);
}
}
/**
* Add a query to the current changes. No autocommit (except on sqlite).
* @param sql
* @param values
* @returns {Promise<void>}
*/
async query(sql, values) {
if (this._dbType === 'sqlite') {
await this.run(sql, values);
} else if (this._dbType === 'postgresql') {
await this.database.query(sql, values);
this._logger.debug(`Running SQL "${sql}" with values ${values}`);
}
}
/**
* Commit. Part of Postgresqls BEGIN / COMMIT / ROLLBACK.
* Writes data to the database, ROLLBACK on error. (has no effect on sqlite)
* @returns {Promise<void>}
*/
async commit() {
if (this._dbType === 'postgresql')
try {
await this.database.query('COMMIT');
} catch (err) {
await this.database.query('ROLLBACK');
this._logger.error(err.message);
this._logger.verbose(err.stack);
}
}
/**
* Rollback. Part of Postgresqls BEGIN / COMMIT / ROLLBACK.
* Reverts changes done in the current commit. (has no effect on sqlite)
* @returns {Promise<void>}
*/
async rollback() {
if (this._dbType === 'postgresql')
this.database.query('ROLLBACK');
}
/**
* Run a sql statement with seperate values and first result row as return.
* @param sql {String} - the sql statement with escaped values ($1, $2... for postgres, ? for sqlite)
* @param [values] {Array<String|Number>}
* @returns {Promise<void>}
*/
async get(sql, values) {
this._logger.debug(`Running SQL "${sql}" with values ${values}`);
let result = null;
if (this._dbType === 'sqlite') {
result = await this.database.get(sql, values);
} else if (this._dbType === 'postgresql') {
await this.database.query(`SET search_path TO ${this.name.replace(/\W/g, '')};`);
result = (await this.database.query({
text: sql,
values: values
})).rows;
}
if (result instanceof Array && result.length > 0)
return result[0];
else
return result;
}
/**
* Run a sql statement with seperate values and all result rows as return.
* @param sql {String} - the sql statement with escaped values ($1, $2... for postgres, ? for sqlite)
* @param [values] {Array<String|Number>} - the seperate values
* @returns {Promise<void>}
*/
async all(sql, values) {
this._logger.debug(`Running SQL "${sql}" with values ${values}`);
if (this._dbType === 'sqlite') {
return await this.database.all(sql, values);
} else if (this._dbType === 'postgresql') {
await this.database.query(`SET search_path TO ${this.name.replace(/\W/g, '')};`);
return (await this.database.query({
text: sql,
values: values
})).rows;
}
}
/**
* Closes the connection to the database.
*/
close() {
if (this._dbType === 'sqlite')
this.database.close();
else if (this._dbType === 'postgresql')
this.database.release();
}
}
Object.assign(exports, {
Column: genericSql.Column,
Database: Database
});

@ -1,354 +0,0 @@
const cmd = require('./cmd'),
music = require('./music'),
utils = require('./utils'),
config = require('../config.json'),
sqliteAsync = require('./sqliteAsync'),
fs = require('fs-extra'),
servercmd = require('../commands/servercommands'),
Discord = require('discord.js'),
waterfall = require('promise-waterfall'),
dataDir = config.dataPath || './data';
let logger = require('winston');
exports.setLogger = function (newLogger) {
logger = newLogger;
music.setLogger(logger);
cmd.setLogger(logger);
};
/**
* Server-Specific commands, music and more
* @type {GuildHandler}
*/
exports.GuildHandler = class {
constructor(guild, prefix) {
this.guild = guild;
this.dj = null;
this.mention = false;
this.prefix = prefix || config.prefix;
this.servant = new cmd.Servant(this.prefix);
}
async initDatabase() {
await fs.ensureDir(dataDir + '/gdb');
this.db = new sqliteAsync.Database(`${dataDir}/gdb/${this.guild}.db`);
await this.db.init();
logger.debug(`Connected to the database for ${this.guild}`);
await this.createTables();
// register commands
this.registerCommands();
}
/**
* Destroys the guild handler
*/
destroy() {
this.dj.stop();
this.db.close();
}
/**
* Creates all tables needed in the Database.
* These are at the moment:
* messages - logs all messages send on the server
* playlists - save playlists to play them later
*/
async createTables() {
await this.db.run(`${utils.sql.tableExistCreate} messages (
${utils.sql.pkIdSerial},
creation_timestamp DATETIME NOT NULL,
author VARCHAR(128) NOT NULL,
author_name VARCHAR(128),
content TEXT NOT NULL
)`);
await this.db.run(`${utils.sql.tableExistCreate} playlists (
${utils.sql.pkIdSerial},
name VARCHAR(32) UNIQUE NOT NULL,
url VARCHAR(255) NOT NULL
)`);
await this.db.run(`${utils.sql.tableExistCreate} commands (
${utils.sql.pkIdSerial},
name VARCHAR(32) UNIQUE NOT NULL,
command VARCHAR(255) NOT NULL
)`);
}
/**
* Answers a message via mention if mentioning is active or with just sending it to the same channel.
* @param msg
* @param answer
*/
async answerMessage(msg, answer) {
if (answer instanceof Promise || answer)
if (answer instanceof Discord.RichEmbed) {
(this.mention) ? msg.reply('', answer) : msg.channel.send('', answer);
} else if (answer instanceof Promise) {
let resolvedAnswer = await answer;
await this.answerMessage(msg, resolvedAnswer);
} else if (answer instanceof Array) {
await waterfall(answer.map((x) => async () => await this.answerMessage(msg, x)));
} else if ({}.toString.call(answer) === '[object Function]') { // check if the answer is of type function
await this.answerMessage(msg, answer());
} else {
(this.mention) ? msg.reply(answer) : msg.channel.send(answer);
}
}
/**
* handles the message by letting the servant parse the command. Depending on the message setting it
* replies or just sends the answer.
* @param msg
*/
async handleMessage(msg) {
if (this.db)
await this.db.run(
'INSERT INTO messages (author, creation_timestamp, author_name, content) values (?, ?, ?, ?)',
[msg.author.id, msg.createdTimestamp, msg.author.username, msg.content]
);
await this.answerMessage(msg, this.servant.parseCommand(msg));
}
/**
* Connect to a voice-channel if not connected and play the url
* @param vc
* @param url
* @param next
*/
async connectAndPlay(vc, url, next) {
if (!this.dj.connected) {
await this.dj.connect(vc);
await this.dj.playYouTube(url, next);
} else {
await this.dj.playYouTube(url, next);
}
}
/**
* registers all music commands and initializes a dj
*/
registerCommands() {
this.dj = new music.DJ();
let playCb = async (msg, kwargs, argv, template, next) => {
let vc = this.dj.voiceChannel || msg.member.voiceChannel;
let url = kwargs['url'];
if (!vc)
return template.response.no_voicechannel;
if (!url)
return template.response.no_url;
if (!utils.YouTube.isValidEntityUrl(url)) {
if (argv && argv.length > 0)
url += ' ' + argv.join(' '); // join to get the whole expression behind the command
let row = await this.db.get('SELECT url FROM playlists WHERE name = ?', [url]);
if (!row) {
logger.debug('Got invalid url for play command.');
return template.response.url_invalid;
} else {
await this.connectAndPlay(vc, row.url, next);
return template.response.success;
}
} else {
await this.connectAndPlay(vc, url, next);
return template.response.success;
}
};
// play command
this.servant.createCommand(servercmd.music.play, async (msg, kwargs, argv) => {
return await playCb(msg, kwargs, argv, servercmd.music.play, false);
});
// playnext command
this.servant.createCommand(servercmd.music.playnext, async (msg, kwargs, argv) => {
return await playCb(msg, kwargs, argv, servercmd.music.playnext, true);
});
// join command
this.servant.createCommand(servercmd.music.join, (msg) => {
if (msg.member.voiceChannel)
this.dj.connect(msg.member.voiceChannel);
else
return servercmd.music.join.response.not_connected;
});
// stop command
this.servant.createCommand(servercmd.music.stop, () => {
if (this.dj.connected) {
this.dj.stop();
return servercmd.music.stop.response.success;
} else {
return servercmd.music.stop.response.not_playing;
}
});
// pause command
this.servant.createCommand(servercmd.music.pause, () => {
if (this.dj.playing) {
this.dj.pause();
return servercmd.music.pause.response.success;
} else {
return servercmd.music.pause.response.not_playing;
}
});
// resume command
this.servant.createCommand(servercmd.music.resume, () => {
if (this.dj.playing) {
this.dj.resume();
return servercmd.music.resume.response.success;
} else {
return servercmd.music.resume.response.not_playing;
}
});
// skip command
this.servant.createCommand(servercmd.music.skip, () => {
if (this.dj.playing) {
this.dj.skip();
return servercmd.music.skip.response.success;
} else {
return servercmd.music.skip.response.not_playing;
}
});
// clear command
this.servant.createCommand(servercmd.music.clear, () => {
this.dj.clear();
return servercmd.music.clear.response.success;
});
// playlist command
this.servant.createCommand(servercmd.music.playlist, () => {
logger.debug(`found ${this.dj.queue.length} songs`);
let describtion = '';
for (let i = 0; i < Math.min(this.dj.queue.length, 9); i++) {
let entry = this.dj.queue[i];
describtion += `[${entry.title}](${entry.url})\n`;
}
return new Discord.RichEmbed()
.setTitle(`${this.dj.queue.length} songs in queue`)
.setDescription(describtion);
});
// np command
this.servant.createCommand(servercmd.music.current, () => {
let song = this.dj.song;
if (song)
return new Discord.RichEmbed()
.setTitle('Now playing:')
.setDescription(`[${song.title}](${song.url})`)
.setImage(utils.YouTube.getVideoThumbnailUrlFromUrl(song.url))
.setColor(0x00aaff);
else
return servercmd.music.current.response.not_playing;
});
// shuffle command
this.servant.createCommand(servercmd.music.shuffle, () => {
this.dj.shuffle();
return servercmd.music.shuffle.response.success;
});
// repeat command
this.servant.createCommand(servercmd.music.repeat, () => {
if (this.dj) {
this.dj.repeat = !this.dj.repeat;
if (this.dj.repeat)
return servercmd.music.repeat.response.repeat_true;
else
return servercmd.music.repeat.response.repeat_false;
}
});
// saves playlists and videos
this.servant.createCommand(servercmd.music.savemedia, async (msg, kwargs, argv) => {
let saveName = argv.join(' ');
let row = await this.db.get('SELECT COUNT(*) count FROM playlists WHERE name = ?', [saveName]);
if (!row || row.count === 0)
await this.db.run('INSERT INTO playlists (name, url) VALUES (?, ?)', [saveName, kwargs.url]);
else
await this.db.run('UPDATE playlists SET url = ? WHERE name = ?', [kwargs.url, saveName]);
return `Saved song/playlist as ${saveName}`;
});
// savedmedia command - prints out saved playlists and videos
this.servant.createCommand(servercmd.music.savedmedia, async () => {
let response = '';
let rows = await this.db.all('SELECT name, url FROM playlists');
for (let row of rows)
response += `[${row.name}](${row.url})\n`;
if (rows.length === 0)
return servercmd.music.savedmedia.response.no_saved;
else
return new Discord.RichEmbed()
.setTitle('Saved Songs and Playlists')
.setDescription(response)
.setFooter(`Play a saved entry with ${this.prefix}play [Entryname]`)
.setTimestamp();
});
this.servant.createCommand(servercmd.music.deletemedia, async (msg, kwargs, argv) => {
let saveName = argv.join(' ');
if (!saveName) {
return servercmd.music.deletemedia.response.no_name;
} else {
await this.db.run('DELETE FROM playlists WHERE name = ?', [saveName]);
return `Deleted ${saveName} from saved media`;
}
});
// savecmd - saves a command sequence with a name
this.servant.createCommand(servercmd.utils.savecmd, async (msg, kwargs, argv) => {
let saveName = argv.pop();
let cmdsequence = argv.join(' ').replace(/\\/g, '');
if (argv.includes(this.prefix + servercmd.utils.execute.name)) {
return servercmd.utils.savecmd.response.no_recursion;
} else if (cmdsequence.split(';').length < (config.maxCmdSequenceLength || 5)){
let row = await this.db.get('SELECT COUNT(*) count FROM commands WHERE name = ?', [saveName]);
if (!row || row.count === 0)
await this.db.run('INSERT INTO commands (name, command) VALUES (?, ?)', [saveName, cmdsequence]);
else
await this.db.run('UPDATE commands SET sequence = ? WHERE name = ?', [cmdsequence, saveName]);
return `saved command sequence as ${saveName}`;
} else {
return servercmd.utils.savecmd.response.sequence_too_long;
}
});
// savedcmd - prints saved commands
this.servant.createCommand(servercmd.utils.savedcmd, async () => {
let response = new Discord.RichEmbed()
.setTitle('Saved Commands')
.setFooter(`Execute a saved entry with ${this.prefix}execute [Entryname]`)
.setTimestamp();
let rows = await this.db.all('SELECT name, command FROM commands');
if (rows.length === 0)
return servercmd.utils.savedcmd.response.no_commands;
else
for (let row of rows)
response.addField(row.name, '`' + row.command + '`');
return response;
});
// deletecmd - deletes a command from saved commands
this.servant.createCommand(servercmd.utils.deletecmd, async (msg, kwargs) => {
await this.db.run('DELETE FROM commands WHERE name = ?', [kwargs.cmdname]);
return `Deleted command ${kwargs.cmdname}`;
});
// execute - executes a saved command
this.servant.createCommand(servercmd.utils.execute, async (msg, kwargs) => {
let row = await this.db.get('SELECT command FROM commands WHERE name = ?', [kwargs.cmdname]);
if (row) {
msg.content = row.command;
await this.handleMessage(msg);
} else {
return servercmd.utils.execute.response.not_found;
}
});
}
};

@ -0,0 +1,198 @@
const music = require('../music'),
utils = require('../utils'),
config = require('../../config.json'),
dblib = require('../database'),
logging = require('../utils/logging'),
fs = require('fs-extra'),
dataDir = config.dataPath || './data';
/**
* GuildDatabase class has abstraction for some sql statements.
*/
class GuildDatabase extends dblib.Database {
/**
* Constructor.
* @param name
*/
constructor(name) {
super(name);
}
/**
* Creates all tables needed in the guilds Database.
*/
async createTables() {
let sql = this.sql;
await this.run(sql.createTableIfNotExists('playlists', [
sql.templates.idcolumn,
new dblib.Column('name', sql.types.getVarchar(32), [sql.constraints.unique, sql.constraints.notNull]),
new dblib.Column('url', sql.types.getVarchar(255), [sql.constraints.notNull])
]));
this._logger.silly('Created Table playlists.');
await this.run(sql.createTableIfNotExists('commands', [
sql.templates.idcolumn,
new dblib.Column('name', sql.types.getVarchar(32), [sql.constraints.unique, sql.constraints.notNull]),
new dblib.Column('command', sql.types.getVarchar(255), [sql.constraints.notNull])
]));
this._logger.silly('Created Table commands.');
await this.run(sql.createTableIfNotExists('settings', [
sql.templates.idcolumn,
new dblib.Column('key', sql.types.getVarchar(32), [sql.constraints.unique, sql.constraints.notNull]),
new dblib.Column('value', sql.types.getVarchar(32), [])
]));
this._logger.silly('Created Table settings.');
}
/**
* Returns the value of the column where the key has the value keyvalue
* @param table {String} - the table name
* @param column {String} - the name of the column
* @param keyname {String} - the name of the key
* @param keyvalue {*} - the value of the key
* @returns {Promise<*>}
*/
async getSingleValue(table, column, keyname, keyvalue) {
let result = await this.get(this.sql.select(table, false, column,
this.sql.where(this.sql.parameter(1), '=', this.sql.parameter(2))),
[keyname, keyvalue]);
if (result)
return result[column];
else
return null;
}
/**
* Returns either the whole table or a limited version
* @param tablename
* @param limit
* @returns {Promise<void>}
*/
async getTableContent(tablename, limit) {
if (limit)
return await this.all(this.sql.select(tablename, false, ['*'], [], [
this.sql.limit(limit)
]));
else
return await this.all(this.sql.select(tablename, false, ['*'], [], []));
}
/**
* Get the value of a setting
* @param name
* @returns {Promise<*>}
*/
async getSetting(name) {
let result = await this.get(this.sql.select('settings', false, 'value',
this.sql.where('key', '=', this.sql.parameter(1))), [name]);
if (result)
return result.value;
else
return null;
}
/**
* Get all settings as object.
* @returns {Promise<void>}
*/
async getSettings() {
let rows = await this.all(this.sql.select('settings', false, ['key', 'value'], [], []));
let retObj = {};
if (rows)
for (let row of rows)
retObj[row.key] = row.value;
return retObj;
}
/**
* Insert or update a setting parameter in the settings database.
* @param name
* @param value
* @returns {Promise<void>}
*/
async setSetting(name, value) {
let row = await this.get(this.sql.select('settings', false, [this.sql.count('*')],
this.sql.where('key', '=', this.sql.parameter(1))), [name]);
if (!row || Number(row.count) === 0)
await this.run(this.sql.insert('settings', {key: this.sql.parameter(1), value: this.sql.parameter(2)}),
[name, value]);
else
await this.run(this.sql.update('settings', {value: this.sql.parameter(1)},
this.sql.where('key', '=', this.sql.parameter(2))), [value, name]);
}
}
/**
* The Guild Handler handles guild settings and data.
* @type {GuildHandler}
*/
class GuildHandler {
constructor(guild) {
this.guild = guild;
this._logger = new logging.Logger(`${this.constructor.name}@${this.guild}`);
this.musicPlayer = new music.MusicPlayer(null);
this._logger.silly('Initialized Guild Handler');
this._votes = {};
this.settings = {};
}
/**
* Initializes the database
* @returns {Promise<void>}
*/
async initDatabase() {
this._logger.silly('Initializing Database');
this.db = new GuildDatabase(`guild_${this.guild.name.replace(/\s/g, '_').replace(/\W/g, '')}`);
await this.db.initDatabase();
this._logger.debug(`Connected to the database for ${this.guild}`);
this._logger.debug('Creating Databases');
await this.db.createTables();
}
/**
* Applies all relevant guild settings.
* @returns {Promise<void>}
*/
async applySettings() {
this.settings = await this.db.getSettings();
this.musicPlayer.quality = this.settings.musicPlayerQuality || 'lowest';
}
/**
* Destroys the guild handler
*/
destroy() {
this._logger.debug('Ending musicPlayer');
this.musicPlayer.stop();
this._logger.debug('Ending Database');
this.db.close();
}
/**
* Sets the vote counter for a command up and adds the user.
* @param command {String}
* @param user {String}
*/
updateCommandVote(command, user) {
if (!this._votes[command])
this._votes[command] = {count: 0, users: []};
if (!this._votes[command].users.includes(user)) {
this._votes[command].count++;
this._votes[command].users.push(user);
}
return this._votes[command];
}
/**
* Resets the vote counter and voted users for a command.
* @param command {String}
*/
resetCommandVote(command) {
this._votes[command] = {count: 0, users: []};
}
}
Object.assign(exports, {
GuildHandler: GuildHandler
});

@ -1,62 +0,0 @@
/* eslint-disable no-unused-vars */
const winston = require('winston'),
DailyRotateFile = require('winston-daily-rotate-file'),
args = require('args-parser')(process.argv),
fileLoggingFormat = winston.format.printf(info => {
return `${info.timestamp} ${info.level.toUpperCase()}: ${JSON.stringify(info.message)}`; // the logging format for files
}),
consoleLoggingFormat = winston.format.printf(info => {
return `${info.timestamp} [${info.level}] ${JSON.stringify(info.message)}`; //the logging format for the console
}),
loggingFullFormat = winston.format.combine(
winston.format.splat(),
winston.format.timestamp({
format: 'YY-MM-DD HH:mm:ss.SSS'
}),
winston.format.json()
),
logger = winston.createLogger({
level: winston.config.npm.levels, // logs with npm levels
format: loggingFullFormat,
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.splat(),
winston.format.timestamp({
format: 'YY-MM-DD HH:mm:ss.SSS'
}),
consoleLoggingFormat
),
level: args.loglevel || 'info'
}),
new winston.transports.File({
level: 'debug',
filename: './.log/latest.log',
options: {flags: 'w'} // overwrites the file on restart
}),
new DailyRotateFile({
level: 'verbose',
filename: './.log/%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '32m',
maxFiles: '30d',
json: true
})
]
});
/**
* A function to return the logger that has been created after appending an exception handler
* @returns {Object}
*/
exports.getLogger = function () {
logger.exceptions.handle(
new winston.transports.File({
filename: './.log/exceptions.log'
})
);
return logger;
};

@ -0,0 +1,238 @@
const cmdLib = require('../command'),
config = require('../../config.json'),
Discord = require('discord.js'),
logging = require('../utils/logging'),
promiseWaterfall = require('promise-waterfall');
/* eslint no-useless-escape: 0 */
class MessageHandler {
/**
* Message Handler to handle messages. Listens on the
* _client message event.
* @param client {Discord.Client}
*/
constructor (client) {
this.discordClient = client;
this.logger = new logging.Logger(this);
this.globalCmdHandler = new cmdLib.CommandHandler(config.prefix,
cmdLib.CommandScopes.Global);
this.userCmdHandler = new cmdLib.CommandHandler(config.prefix,
cmdLib.CommandScopes.User);
this.guildCmdHandler = new cmdLib.CommandHandler(config.prefix,
cmdLib.CommandScopes.Guild);
this.userRates = {};
this.registeredResponses = {};
this._registerEvents();
}
/**
* Returns the handler fitting the scope
* @param scope {Number}
* @returns {cmdLib.CommandHandler}
*/
getHandler(scope) {
switch (scope) {
case cmdLib.CommandScopes.Global:
return this.globalCmdHandler;
case cmdLib.CommandScopes.Guild:
return this.guildCmdHandler;
case cmdLib.CommandScopes.User:
return this.userCmdHandler;
}
}
/**
* Registers a command module to a command handler.
* @param CommandModule {cmdLib.CommandModule}
* @param options {Object} Options passed to the module constructor.
* @returns {Promise<void>}
*/
async registerCommandModule(CommandModule, options) {
this.logger.info(`Registering command module ${CommandModule.name}...`);
let cmdModule = new CommandModule(options);
await cmdModule.register(this.getHandler(cmdModule.scope));
}
/**
* Parses a string to a command sequence Array.
* Workaround to not reveal the private parseSyntax function.
* @param synStr {String}
*/
parseSyntaxString(synStr) {
return this._parseSyntax({content: synStr});
}
/**
* Registering event handlers.
* @private
*/
_registerEvents() {
this.logger.debug('Registering message events...');
this.discordClient.on('message', async (msg) => {
this.logger.verbose(`<${msg.guild? msg.channel.name+'@'+msg.guild.name : 'PRIVATE'}> ${msg.author.tag}: ${msg.content}`);
if (msg.author !== this.discordClient.user
&& this._checkPrefixStart(msg.content)
&& !this._checkRateReached(msg.author)) {
let sequence = this._parseSyntax(msg);
this.logger.debug(`Syntax parsing returned: ${JSON.stringify(sequence)}`);
await this.executeCommandSequence(sequence, msg);
this.logger.debug('Executed command sequence');
}
});
this.discordClient.on('messageReactionAdd', (messageReaction, user) => {
let responseInstance = this.registeredResponses[messageReaction.message];
if (responseInstance)
responseInstance.emit('reactionAdd', messageReaction, user);
});
this.discordClient.on('messageReactionRemove', (messageReaction, user) => {
let responseInstance = this.registeredResponses[messageReaction.message];
if (responseInstance)
responseInstance.emit('reactionRemove', messageReaction, user);
});
this.discordClient.on('messageReactionRemoveAll', (message) => {
let responseInstance = this.registeredResponses[message];
if (responseInstance)
responseInstance.emit('reactionRemoveAll', message);
});
this.discordClient.on('messageDelete', (message) => {
let responseInstance = this.registeredResponses[message];
if (responseInstance) {
responseInstance.on('delete', message);
delete this.registeredResponses[message];
}
});
}
/**
* Parses the syntax of a message into a command array.
* @param message
* @returns {Array<Array<String>>}
* @private
*/
_parseSyntax(message) {
this.logger.silly('Parsing command sequence...');
let commandSequence = [];
let content = message.content;
let strings = content.match(/".+?"/g) || [];
for (let string of strings)
content = content.replace(string, string // escape all special chars
.replace(/;/g, '\\;')
.replace(/&/g, '\\&'));
let independentCommands = content // independent command sequende with ;
.split(/(?<!\\);/g)
.map(x => x.replace(/^ +/, ''));
for (let indepCommand of independentCommands)
commandSequence.push(indepCommand
.split(/(?<!\\)&&/g) // dependend sequence with && (like unix)
.map(x => x.replace(/^ +/, ''))
);
return commandSequence;
}
/**
* Executes a sequence of commands
*/
async executeCommandSequence(cmdSequence, message) {
this.logger.silly(`Executing command sequence: ${JSON.stringify(cmdSequence)} ...`);
let scopeCmdHandler = this.getScopeHandler(message);
await Promise.all(cmdSequence.map((sq) => promiseWaterfall(sq.map((cmd) => async () => {
try {
this.logger.silly(`Executing command ${cmd}`);
let globalResult = await this.globalCmdHandler.handleCommand(cmd, message);
let scopeResult = await scopeCmdHandler.handleCommand(cmd, message);
this.logger.silly(`globalResult: ${globalResult}, scopeResult: ${scopeResult}`);
if (scopeResult)
await this._answerMessage(message, scopeResult);
else if (globalResult)
await this._answerMessage(message, globalResult);
} catch (err) {
this.logger.verbose(err.message);
this.logger.silly(err.stack);
}
}))));
}
/**
* Returns two commandHandlers for the messages scope.
* @param message
* @private
*/
getScopeHandler(message) {
if (message && message.guild)
return this.guildCmdHandler;
else
return this.userCmdHandler;
}
/**
* Answers
* @param message {Discord.Message}
* @param response {Response}
* @private
*/
async _answerMessage(message, response) {
this.logger.debug(`Sending answer ${response.content}`);
if (response && response.content) {
let responseMessage = null;
if (response.content instanceof Discord.RichEmbed)
responseMessage = await message.channel.send('', response.content);
else
responseMessage = await message.channel.send(response.content);
if (response.hasListeners)
this.registeredResponses[responseMessage] = response;
response.message = responseMessage;
response.emit('sent', response);
}
}
/**
* Checks if the messageString beginns with a command prefix.
* @param msgString {String}
* @private
*/
_checkPrefixStart(msgString) {
let p1 = this.globalCmdHandler.prefix;
let p2 = this.guildCmdHandler.prefix;
let p3 = this.userCmdHandler.prefix;
return (
new RegExp(`^\\s*?${p1}`).test(msgString) ||
new RegExp(`^\\s*?${p2}`).test(msgString) ||
new RegExp(`^\\s*?${p3}`).test(msgString));
}
/**
* Checks if the user has reached the command rate limit and updates it.
* @param user {Discord.User}
* @returns {boolean}
* @private
*/
_checkRateReached(user) {
if (!this.userRates[user.tag])
this.userRates[user.tag] = {last: 0, count: 0};
let userEntry = this.userRates[user.tag];
let reached = ((Date.now() - userEntry.last)/1000) < (config.rateLimitTime || 10)
&& userEntry.count > (config.rateLimitCount || 5);
if (((Date.now() - userEntry.last)/1000) > (config.rateLimitTime || 10))
this.userRates[user.tag].count = 0;
this.userRates[user.tag].last = Date.now();
this.userRates[user.tag].count++;
return reached;
}
}
Object.assign(exports, {
MessageHandler: MessageHandler
});

@ -1,31 +1,35 @@
const ytdl = require("ytdl-core"),
const ytdl = require("ytdl-core-discord"),
ypi = require('youtube-playlist-info'),
yttl = require('get-youtube-title'),
args = require('args-parser')(process.argv),
config = require('../config.json'),
utils = require('./utils.js'),
ytapiKey = args.ytapi || config.api.youTubeApiKey;
/* Variable Definition */
let logger = require('winston');
config = require('../../config.json'),
utils = require('../utils'),
xevents = require('../utils/extended-events'),
logging = require('../utils/logging'),
ytapiKey = config.api.youTubeApiKey;
/* Function Definition */
exports.setLogger = function (newLogger) {
logger = newLogger;
};
exports.DJ = class {
/**
* The Music Player class is used to handle music playing tasks on Discord Servers (Guilds).
* @type {MusicPlayer}
*/
class MusicPlayer extends xevents.ExtendedEventEmitter {
/**
* Constructor
* @param [voiceChannel] {Discord.VoiceChannel}
*/
constructor(voiceChannel) {
super();
this.conn = null;
this.disp = null;
this.queue = [];
this.playing = false;
this.current = null;
this.repeat = false;
this.volume = 0.5;
this.voiceChannel = voiceChannel;
this.quality = 'lowest';
this.exitTimeout = null;
this._logger = new logging.Logger(this);
this._logger.silly('Initialized Music Player');
config.music ? this.quality = config.music.quality || 'lowest' : this.quality = 'lowest';
config.music ? this.liveBuffer = config.music.liveBuffer || 10000 : 10000;
}
/**
@ -43,21 +47,27 @@ exports.DJ = class {
}
else if (voiceChannel)
this.voiceChannel = voiceChannel;
logger.verbose(`Connecting to voiceChannel ${this.voiceChannel.name}`);
this._logger.verbose(`Connecting to voiceChannel ${this.voiceChannel.name}`);
let connection = await this.voiceChannel.join();
logger.info(`Connected to Voicechannel ${this.voiceChannel.name}`);
connection.on('error', (err) => {
this._logger.error(err.message);
this._logger.debug(err.stack);
});
this._logger.info(`Connected to Voicechannel ${this.voiceChannel.name}`);
this.conn = connection;
this.emit('connected');
}
/**
* Defining setter for listenOnRepeat to include the current song into the repeating loop.
* @param value
* @param value {Boolean}
*/
set listenOnRepeat(value) {
this.repeat = value;
if (this.current)
this.queue.push(this.current);
this.emit('listenOnRepeat', this.repeat);
}
/**
@ -74,19 +84,19 @@ exports.DJ = class {
/**
* Updates the channel e.g. when the bot is moved to another channel.
* @param voiceChannel
* @param voiceChannel {Discord.VoiceChannel}
*/
updateChannel(voiceChannel) {
if (voiceChannel) {
this.voiceChannel = voiceChannel;
logger.debug(`Updated voiceChannel to ${this.voiceChannel.name}`);
this._logger.debug(`Updated voiceChannel to ${this.voiceChannel.name}`);
}
}
/**
* Plays a file for the given filename.
* TODO: Implement queue
* @param filename
* @param filename {String}
* @todo
*/
playFile(filename) {
@ -94,7 +104,7 @@ exports.DJ = class {
this.disp = this.conn.playFile(filename);
this.playing = true;
} else {
logger.warn("Not connected to a voicechannel. Connection now.");
this._logger.warn("Not connected to a voicechannel. Connection now.");
this.connect(this.voiceChannel).then(() => {
this.playFile(filename);
});
@ -109,13 +119,13 @@ exports.DJ = class {
if (this.exitTimeout) {
clearTimeout(this.exitTimeout);
this.exitTimeout = null;
logger.debug(`Cleared exit timout for ${this.voiceChannel.name}`);
this._logger.debug(`Cleared exit timout for ${this.voiceChannel.name}`);
}
if (this.connected && this.voiceChannel.members.size === 1) {
logger.debug(`Set exit timout for ${this.voiceChannel.name}`);
this._logger.debug(`Set exit timout for ${this.voiceChannel.name}`);
this.exitTimeout = setTimeout(() => {
if (this.connected && this.voiceChannel.members.size === 1)
logger.verbose(`Exiting ${this.voiceChannel.name}`);
this._logger.verbose(`Exiting ${this.voiceChannel.name}`);
this.stop();
}, config.music.timeout || 300000);
}
@ -125,28 +135,28 @@ exports.DJ = class {
* Plays the url of the current song if there is no song playing or puts it in the queue.
* If the url is a playlist, the videos of the playlist are fetched and put
* in the queue. For each song the title is saved in the queue too.
* @param url
* @param playnext
* @param url {String}
* @param [playnext] {Boolean}
*/
async playYouTube(url, playnext) {
let plist = utils.YouTube.getPlaylistIdFromUrl(url);
if (plist) {
logger.debug(`Adding playlist ${plist} to queue`);
this._logger.debug(`Adding playlist ${plist} to queue`);
let playlistItems = await ypi(ytapiKey, plist);
let firstSong = utils.YouTube.getVideoUrlFromId(playlistItems.shift().resourceId.videoId);
let firstSongTitle = null;
try {
firstSongTitle = await this.getVideoName(firstSong);
} catch(err) {
} catch (err) {
if (err.message !== 'Not found') {
logger.warn(err.message);
logger.debug(err.stack);
this._logger.warn(err.message);
this._logger.debug(err.stack);
}
}
if (this.repeat)
this.queue.push({'url': firstSong, 'title': firstSongTitle});
this.playYouTube(firstSong).catch((err) => logger.warn(err.message));
this.playYouTube(firstSong).catch((err) => this._logger.warn(err.message));
for (let item of playlistItems) {
let vurl = utils.YouTube.getVideoUrlFromId(item.resourceId.videoId);
@ -154,37 +164,48 @@ exports.DJ = class {
this.queue.push({'url': vurl, 'title': await this.getVideoName(vurl)}); //eslint-disable-line no-await-in-loop
} catch (err) {
if (err.message !== 'Not found') {
logger.warn(err.message);
logger.debug(err.stack);
this._logger.verbose(err.message);
this._logger.silly(err.stack);
}
}
}
logger.debug(`Added ${playlistItems.length} songs to the queue`);
this._logger.debug(`Added ${playlistItems.length} songs to the queue`);
return playlistItems.length;
} else if (!this.playing || !this.disp) {
logger.debug(`Playing ${url}`);
this._logger.debug(`Playing ${url}`);
this.current = ({'url': url, 'title': await this.getVideoName(url)});
this.disp = this.conn.playStream(ytdl(url,
{filter: 'audioonly', quality: this.quality, liveBuffer: config.music.livePuffer || 20000}),
{volume: this.volume});
this.disp.on('end', (reason) => { // end event triggers the next song to play when the reason is not stop
if (reason !== 'stop') {
this.playing = false;
if (this.repeat)
this.queue.push(this.current);
let toggleNext = () => {
if (this.queue.length > 0) {
this.current = this.queue.shift();
this.emit('next', this.current);
this.playYouTube(this.current.url).catch((err) => this._logger.warn(err.message));
} else {
this.stop();
this.current = null;
if (this.queue.length > 0) {
this.current = this.queue.shift();
if (this.repeat) // listen on repeat
this.queue.push(this.current);
this.playYouTube(this.current.url).catch((err) => logger.warn(err.message));
} else {
this.stop();
}
this.playing = false;
}
});
this.playing = true;
};
try {
this.disp = this.conn.playOpusStream(await ytdl(url,
{filter: 'audioonly', quality: this.quality, liveBuffer: this.liveBuffer}));
this.disp.on('error', (err) => {
this._logger.error(err.message);
this._logger.debug(err.stack);
});
this.disp.on('end', (reason) => { // end event triggers the next song to play when the reason is not stop
if (reason !== 'stop')
toggleNext();
});
this.playing = true;
} catch (err) {
this._logger.verbose(err.message);
this._logger.silly(err.stack);
toggleNext();
}
} else {
logger.debug(`Added ${url} to the queue`);
this._logger.debug(`Added ${url} to the queue`);
if (playnext)
this.queue.unshift({'url': url, 'title': await this.getVideoName(url)});
else
@ -195,14 +216,14 @@ exports.DJ = class {
/**
* Gets the name of the YouTube Video at url
* @param url
* @returns {Promise<>}
* @param url {String}
* @returns {Promise}
*/
getVideoName(url) {
return new Promise((resolve, reject) => {
yttl(utils.YouTube.getVideoIdFromUrl(url), (err, title) => {
if (err) {
logger.debug(JSON.stringify(err));
this._logger.debug(JSON.stringify(err));
reject(err);
} else {
resolve(title);
@ -211,29 +232,15 @@ exports.DJ = class {
});
}
/**
* Sets the volume of the dispatcher to the given value
* @param percentage
*/
setVolume(percentage) {
logger.verbose(`Setting volume to ${percentage}`);
if (this.disp !== null) {
this.volume = percentage;
this.disp.setVolume(percentage);
} else {
logger.warn("No dispatcher found.");
}
}
/**
* Pauses if a dispatcher exists
*/
pause() {
logger.verbose("Pausing music...");
this._logger.verbose("Pausing music...");
if (this.disp !== null)
this.disp.pause();
else
logger.warn("No dispatcher found");
else
this._logger.warn("No dispatcher found");
}
@ -241,11 +248,11 @@ exports.DJ = class {
* Resumes if a dispatcher exists
*/
resume() {
logger.verbose("Resuming music...");
this._logger.verbose("Resuming music...");
if (this.disp !== null)
this.disp.resume();
else
logger.warn("No dispatcher found");
else
this._logger.warn("No dispatcher found");
}
@ -257,26 +264,27 @@ exports.DJ = class {
this.playing = false;
this.queue = [];
this.current = null;
logger.verbose("Stopping music...");
this._logger.verbose("Stopping music...");
try {
if (this.disp) {
this.disp.end('stop');
this.disp = null;
logger.debug("Ended dispatcher");
this._logger.debug("Ended dispatcher");
}
if (this.conn) {
this.conn.disconnect();
this.conn = null;
logger.debug("Ended connection");
this._logger.debug("Ended connection");
}
if (this.voiceChannel) {
this.voiceChannel.leave();
logger.debug("Left VoiceChannel");
logger.info(`Disconnected from Voicechannel ${this.voiceChannel.name}`);
this._logger.debug("Left VoiceChannel");
this._logger.info(`Disconnected from Voicechannel ${this.voiceChannel.name}`);
}
} catch (error) {
logger.verbose(JSON.stringify(error));
this._logger.verbose(JSON.stringify(error));
}
this.emit('stop');
}
/**
@ -285,7 +293,7 @@ exports.DJ = class {
* It tries to play the next song with playYouTube
*/
skip() {
logger.debug("Skipping song");
this._logger.debug("Skipping song");
if (this.disp !== null) {
this.disp.end();
} else {
@ -293,13 +301,14 @@ exports.DJ = class {
if (this.queue.length > 0) {
this.current = this.queue.shift();
this.playYouTube(this.current.url).catch((err) => {
logger.error(err.message);
logger.debug(err.stack);
this._logger.error(err.message);
this._logger.debug(err.stack);
});
} else {
this.stop();
}
}
this.emit('skip', this.current);
}
/**
@ -315,6 +324,7 @@ exports.DJ = class {
*/
shuffle() {
this.queue = utils.shuffleArray(this.queue);
this.emit('shuffle');
}
/**
@ -322,5 +332,10 @@ exports.DJ = class {
*/
clear() {
this.queue = [];
this.emit('clear');
}
};
}
Object.assign(exports, {
MusicPlayer: MusicPlayer
});

@ -0,0 +1,76 @@
const logging = require('../utils/logging'),
EventEmitter = require('events');
/**
* Extends the event emitter with some useful features.
*/
class ExtendedEventEmitter extends EventEmitter {
/**
* Constructor.
* @param [name] {String}
*/
constructor(name) {
super();
this._logger = new logging.Logger(`${name}-${this.constructor.name}`);
this._registerDefault();
}
/**
* Registeres the error event to the logger so it won't crash the bot.
* @private
*/
_registerDefault() {
this.on('error', (err) => {
this._logger.error(err.message);
this._logger.debug(err.stack);
});
}
/**
* Adds an object of events with listeners to the bot.
* @param eventListenerObject
* @returns {ExtendedEventEmitter}
*/
addListeners(eventListenerObject) {
for (let [event, listener] of Object.entries(eventListenerObject))
this.on(event, listener);
return this;
}
/**
* Returns all registered events.
* @returns {*|Array<string | symbol>|string[]}
*/
get events() {
return this.eventNames();
}
/**
* Wrapper around getMaxListeners function
* @returns {*|number}
*/
get maxListeners() {
return this.getMaxListeners();
}
/**
* Wrapper around setMaxListeners function.
* @param n
* @returns {this | this | Cluster | *}
*/
set maxListeners(n) {
return this.setMaxListeners(n);
}
/**
* Returns if the emitter has additional listeners apart from the error listener.
*/
get hasListeners() {
return this.events.count > 1;
}
}
Object.assign(exports, {
ExtendedEventEmitter: ExtendedEventEmitter,
});

@ -0,0 +1,413 @@
/**
* Returns types based on the database.
*/
class GenericTypes {
/**
* Constructor.
* @param database {String}
*/
constructor(database) {
this.database = database;
}
get null() {
switch(this.database) {
case 'postgresql':
case 'sqlite':
default:
return 'NULL';
}
}
get integer() {
switch(this.database) {
case 'postgresql':
case 'sqlite':
default:
return 'INTEGER';
}
}
get real() {
switch(this.database) {
case 'sqlite':
return 'REAL';
case 'postgresql':
default:
return 'FLOAT';
}
}
get text() {
switch (this.database) {
case 'postgresql':
case 'sqlite':
default:
return 'TEXT';
}
}
get varchar() {
switch (this.database) {
case 'postgresql':
case 'sqlite':
default:
return 'VARCHAR';
}
}
get date() {
switch (this.database) {
case 'postgresql':
case 'sqlite':
default:
return 'DATE';
}
}
get datetime() {
switch (this.database) {
case 'postgresql':
return 'TIMESTAMP';
case 'sqlite':
default:
return 'DATETIME';
}
}
get serial() {
switch (this.database) {
case 'sqlite':
return 'INTEGER AUTOINCREMENT NOT NULL';
case 'postgresql':
default:
return 'SERIAL';
}
}
get serialPK() {
switch (this.database) {
case 'sqlite':
return 'INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE NOT NULL';
case 'postgresql':
default:
return 'SERIAL PRIMARY KEY UNIQUE';
}
}
/**
* Returns the VARCHAR type with the specified length.
* @param length {Number}
*/
getVarchar(length) {
return `${this.varchar}(${length})`;
}
}
/**
* Returns sql statements based on the database.
*/
class GenericSql {
/**
* Constructor.
* @param database {String}
*/
constructor(database) {
this.database = database;
this.types = new GenericTypes(database);
this.constraints = {
primaryKey: 'PRIMARY KEY',
notNull: 'NOT NULL',
unique: 'UNIQUE',
like: 'LIKE',
exists: 'EXISTS',
and: 'AND',
or: 'OR',
in: 'IN',
any: 'ANY',
all: 'ALL'
};
this.templates = {
idcolumn: new Column('id', this.types.serialPK, [])
};
}
/**
* Returns a value placeholder for the specified number.
* @param number {Number} - the variables position.
*/
parameter(number) {
switch (this.database) {
case 'postgresql':
return `$${number}`;
case 'sqlite':
return '?';
}
}
/**
* A sum selector - calculates the sum of all values of the column
* @param colname {String} - the name of the column where the sum is selected.
* @returns {string}
*/
sum(colname) {
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `SUM(${colname})`;
}
}
/**
* A avg selector - selects the average
* @param colname {String} - the name of the column where the avg value is selected.
* @returns {string}
*/
avg(colname) {
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `AVG(${colname})`;
}
}
/**
* A min selector - selects the minimum
* @param colname {String} - the name of the column where the min value is selected.
* @returns {string}
*/
min(colname) {
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `MIN(${colname})`;
}
}
/**
* A max selector - selects the maximum
* @param colname {String} - the name of the column where the max value is selected.
* @returns {string}
*/
max(colname) {
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `MAX(${colname})`;
}
}
/**
* A count selector - counts the results
* @param colname {String} - the name of the column to be counted.
* @returns {string}
*/
count(colname) {
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `COUNT(${colname}) count`;
}
}
/**
* A default constraint
* @param expression {String} - the expression to generate the default value.
* @returns {string}
*/
default(expression) {
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `DEFAULT ${expression}`;
}
}
/**
* A where statement
* @param row {String} - the row
* @param operator {String} - the comparison operator
* @param comparator {String} the value or row to compare to
*/
and(row, operator, comparator) {
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `AND ${row} ${operator} ${comparator}`;
}
}
/**
* A or statement
* @param row {String} - the row
* @param operator {String} - the comparison operator
* @param comparator {String} the value or row to compare to
*/
or(row, operator, comparator) {
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `OR ${row} ${operator} ${comparator}`;
}
}
/**
* A where statement
* @param row {String} - the row
* @param operator {String} - the comparison operator
* @param comparator {String} the value or row to compare to
*/
where(row, operator, comparator) {
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `WHERE ${row} ${operator} ${comparator}`;
}
}
/**
* A limit statement.
* @param count {Number} - the number of rows to return
* @returns {string}
*/
limit(count) {
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `LIMIT ${count}`;
}
}
/**
* Create Table statement
* @param table {String}
* @param rows {Array<Column>}
* @returns {string}
*/
createTable(table, rows) {
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `CREATE TABLE ${table} (${rows.map(x => x.sql).join(',')})`;
}
}
/**
* Create Table if it doesn't exist statement
* @param table {String}
* @param columns {Array<Column>}
* @returns {string}
*/
createTableIfNotExists(table, columns) {
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `CREATE TABLE IF NOT EXISTS ${table} (${columns.map(x => x.sql).join(',')})`;
}
}
/**
* Insert into the table.
* @param table {String} - the table name
* @param colValueObj {Object} - an object with keys as columnnames and values as columnvalues
* @returns {string}
*/
insert(table, colValueObj) {
let rownames = Object.keys(colValueObj);
let values = Object.values(colValueObj);
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `INSERT INTO ${table} (${rownames.join(',')}) values (${values.join(',')})`;
}
}
/**
* Updates the table with the rowValueObject.
* @param table {String} - the table name
* @param colValueObj {Object} - an object with keys as columnnames and values as columnvalues
* @param conditions {Array<String>|String} - conditions for the update row selection (WHERE ... [OR ...][AND ...]
* @returns {string}
*/
update(table, colValueObj, conditions) {
if (!(conditions instanceof Array))
conditions = [conditions];
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `UPDATE ${table} SET ${Object.entries(colValueObj).map(x => `${x[0]} = ${x[1]}`).join(',')} ${conditions.join(' ')}`;
}
}
/**
* Selects from a table
* @param table {String} - the tablename
* @param distinct {String|boolean} - should distinct values be selected? If yes provide distinct keyword.
* @param colnames {Array<String>|String} - the rows to select
* @param conditions {Array<String>|String} - conditions for the row selection (WHERE ... [OR ...][AND ...]
* @param operations {Array<String>|String} - operations on the selected rows
* @returns {String}
*/
select(table, distinct, colnames, conditions, operations) {
if (!(colnames instanceof Array))
colnames = [colnames];
if (!(conditions instanceof Array))
conditions = [conditions];
if (!(operations instanceof Array))
operations = [operations];
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `SELECT${distinct? ' ' + distinct : ''} ${colnames.join(', ')} FROM ${table} ${conditions.join(' ')} ${operations.join(' ')}`;
}
}
/**
* Deletes from a table
* @param table {String} - the table name
* @param conditions {Array<String>|String} - conditions for the row selection (WHERE ... [OR ...][AND ...]
*/
delete(table, conditions) {
if (!(conditions instanceof Array))
conditions = [conditions];
switch (this.database) {
case 'postgresql':
case 'sqlite':
return `DELETE FROM ${table} ${conditions.join(' ')}`;
}
}
}
class Column {
/**
* Create a column for usage in the generic sql statements
* @param name {String}
* @param [type] {String}
* @param [constraints] {Array<String>}
*/
constructor(name, type, constraints) {
this.name = name;
this.type = type;
this.constraints = constraints || [];
if (!(constraints instanceof Array))
this.constraints = [constraints];
}
/**
* Sets the datatype of the row.
* @param constraint {String}
*/
addConstraint(constraint) {
this.constraints.push(constraint);
}
get sql() {
return `${this.name} ${this.type} ${this.constraints.join(' ')}`;
}
}
Object.assign(exports, {
GenericSql: GenericSql,
GenericTypes: GenericTypes,
Column: Column
});

@ -11,7 +11,7 @@ function noOp() {
* @param {String} filename The name of the file.
* @return {String} A string that represents the file-extension.
*/
exports.getExtension = function (filename) {
function getFileExtension (filename) {
if (!filename)
return null;
try {
@ -24,7 +24,7 @@ exports.getExtension = function (filename) {
console.error(error);
return null;
}
};
}
/**
* Walks the path to the objects attribute and returns the value.
@ -32,7 +32,7 @@ exports.getExtension = function (filename) {
* @param attributePath
* @returns {undefined/Object}
*/
exports.objectDeepFind = function (object, attributePath) {
function objectDeepFind (object, attributePath) {
let current = object,
paths = attributePath.split('.');
for (let path of paths)
@ -42,12 +42,12 @@ exports.objectDeepFind = function (object, attributePath) {
return undefined;
return current;
};
}
/**
* Shuffles an array with Fisher-Yates Shuffle
* @param array
* @returns {Array}#
* @returns {Array}
*/
exports.shuffleArray = function(array) {
let currentIndex = array.length, temporaryValue, randomIndex;
@ -74,7 +74,7 @@ exports.shuffleArray = function(array) {
* @constructor
* @author CanyonCasa & Pier-Luc Gendreau on StackOverflow
*/
exports.Cleanup = function Cleanup(callback) {
function Cleanup(callback) {
// attach user callback to the process event emitter
// if no callback, it will still exit gracefully on Ctrl-C
@ -98,9 +98,9 @@ exports.Cleanup = function Cleanup(callback) {
console.log(e.stack);
process.exit(99);
});
};
}
exports.getSplitDuration = function (duration) {
function getSplitDuration (duration) {
let dur = duration;
let retObj = {};
retObj.milliseconds = dur % 1000;
@ -113,11 +113,35 @@ exports.getSplitDuration = function (duration) {
dur = Math.floor(dur / 24);
retObj.days = dur;
return retObj;
};
}
/**
* Resolves a nested promise by resolving it iterative.
* @param promise
* @returns {Promise<*>}
*/
async function resolveNestedPromise (promise) {
let result = await promise;
while (result instanceof Promise)
result = await result; // eslint-disable-line no-await-in-loop
return result;
}
/* Classes */
exports.YouTube = class {
class Enum {
/**
* Constructor.
* @param symbols {Array<String>}
*/
constructor(symbols) {
for (let symbol in symbols)
this[symbol] = symbols;
Object.freeze(this);
}
}
class YouTube {
/**
* returns if an url is a valid youtube url (without checking for an entity id)
* @param url
@ -208,9 +232,9 @@ exports.YouTube = class {
let id = exports.YouTube.getVideoIdFromUrl(url);
return id? `https://i3.ytimg.com/vi/${id}/maxresdefault.jpg` : null;
}
};
}
exports.ConfigVerifyer = class {
class ConfigVerifyer {
/**
* @param confObj
* @param required {Array} the attributes that are required for the bot to work
@ -243,7 +267,7 @@ exports.ConfigVerifyer = class {
logger.error(`Missing required Attributes ${this.missingAttributes.join(', ')}`);
}
};
}
exports.sql = {
tableExistCreate: 'CREATE TABLE IF NOT EXISTS',
@ -258,3 +282,15 @@ exports.logLevels = {
'warn': 3,
'error:': 4
};
Object.assign(exports, {
resolveNestedPromise: resolveNestedPromise,
YouTube: YouTube,
ConfigVerifyer: ConfigVerifyer,
getSplitDuration: getSplitDuration,
getExtension: getFileExtension,
getFileExtension: getFileExtension,
objectDeepFind: objectDeepFind,
Cleanup: Cleanup,
Enum: Enum
});

@ -0,0 +1,122 @@
/* eslint-disable no-unused-vars */
const winston = require('winston'),
DailyRotateFile = require('winston-daily-rotate-file'),
args = require('args-parser')(process.argv);
/**
* Set console format to simple string format
* @type {Format}
*/
const consoleLoggingFormat = winston.format.printf(info => {
return `${info.timestamp} {${info.module || info.m || 'DEFAULT'}} [${info.level}] ${JSON.stringify(info.message)}`; //the logging format for the console
});
/**
* Set full format to combination of formats
* @type {Format}
*/
const loggingFullFormat = winston.format.combine(
winston.format.timestamp({
format: 'YY-MM-DD HH:mm:ss.SSS'
}),
winston.format.json()
);
/**
* Define all transports used.
* @type {any[]}
*/
let transports = [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.splat(),
winston.format.timestamp({
format: 'YY-MM-DD HH:mm:ss.SSS'
}),
winston.format.label({label: ''}),
consoleLoggingFormat
),
level: args.loglevel || 'info'
}),
new winston.transports.File({
level: 'debug',
filename: './.log/latest.log',
options: {flags: 'w'} // overwrites the file on restart
}),
new DailyRotateFile({
level: 'verbose',
filename: './.log/%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '32m',
maxFiles: '30d',
json: true
})
];
/**
* Define the logger
* @type {winston.Logger}
*/
let logger = winston.createLogger({
level: winston.config.npm.levels,
format: loggingFullFormat,
transports: transports
});
// Define exception handling
logger.exceptions.handle(
new winston.transports.File({
filename: './.log/exceptions.log'
})
);
class ModuleLogger {
constructor(moduleInstance) {
this.logger = logger;
if (moduleInstance.constructor)
switch (moduleInstance.constructor.name) {
case 'String':
this.logName = moduleInstance;
break;
case 'Number':
this.logName = moduleInstance.toString();
break;
default:
this.logName = moduleInstance.constructor.name;
}
else
this.logName = moduleInstance.toString();
}
silly(msg, meta) {
logger.silly(msg, {module: this.logName, ...meta});
}
debug(msg, meta) {
logger.debug(msg, {module: this.logName, ...meta});
}
verbose(msg, meta) {
logger.verbose(msg, {module: this.logName, ...meta});
}
info(msg, meta) {
logger.info(msg, {module: this.logName, ...meta});
}
warn(msg, meta) {
logger.warn(msg, {module: this.logName, ...meta});
}
error(msg, meta) {
logger.error(msg, {module: this.logName, ...meta});
}
}
Object.assign(exports, {
logger: logger,
Logger: ModuleLogger
});

@ -4,29 +4,29 @@ const express = require('express'),
compression = require('compression'),
md5 = require('js-md5'),
sha512 = require('js-sha512'),
logging = require('../utils/logging'),
fs = require('fs'),
session = require('express-session'),
SQLiteStore = require('connect-sqlite3')(session),
dblib = require('../../lib/database'),
bodyParser = require('body-parser'),
compileSass = require('express-compile-sass'),
config = require('../config.json'),
utils = require('../lib/utils');
let logger = require('winston');
exports.setLogger = function (newLogger) {
logger = newLogger;
};
config = require('../../config.json'),
utils = require('../utils');
exports.WebServer = class {
constructor(port) {
this.app = express();
this.server = null;
this.port = port;
this.schema = buildSchema(fs.readFileSync('./web/graphql/schema.graphql', 'utf-8'));
this.schema = buildSchema(fs.readFileSync('./web/api/graphql/schema.gql', 'utf-8'));
this.root = {};
this._logger = new logging.Logger(this);
}
/**
* Configures express by setting properties and middleware.
*/
configureExpress() {
this.app.set('view engine', 'pug');
this.app.set('trust proxy', 1);
@ -38,7 +38,7 @@ exports.WebServer = class {
this.app.use(require('cors')());
this.app.use(session({
store: new SQLiteStore({dir: './data', db: 'sessions.db'}),
secret: config.webservice.sessionSecret,
secret: config.webinterface.sessionSecret,
resave: false,
saveUninitialized: true,
cookie: {secure: 'auto'},
@ -63,9 +63,13 @@ exports.WebServer = class {
if (!req.body.username || !req.body.password) {
res.render('login', {msg: 'Please enter username and password.'});
} else {
let user = await this.maindb.get('SELECT * FROM users WHERE username = ? AND password = ?', [req.body.username, req.body.password]);
let sql = this.maindb.sql;
let user = await this.maindb.get(sql.select('users', false, '*', [
sql.where('username', '=', sql.parameter(1)),
sql.and('password', '=', sql.parameter(2))
]), [req.body.username, req.body.password]);
if (!user) {
logger.debug(`User ${req.body.username} failed to authenticate`);
this._logger.debug(`User ${req.body.username} failed to authenticate`);
res.render('login', {msg: 'Login failed!'});
} else {
req.session.user = user;
@ -86,7 +90,7 @@ exports.WebServer = class {
this.app.use('/graphql', graphqlHTTP({
schema: this.schema,
rootValue: this.root,
graphiql: config.webservice.graphiql || false
graphiql: config.webinterface.graphiql || false
}));
}
@ -95,19 +99,19 @@ exports.WebServer = class {
*/
start() {
this.configureExpress();
if (config.webservice.https && config.webservice.https.enabled) {
if (config.webinterface.https && config.webinterface.https.enabled) {
let sslKey = null;
let sslCert = null;
if (config.webservice.https.keyFile)
sslKey = fs.readFileSync(config.webservice.https.keyFile, 'utf-8');
if (config.webservice.https.certFile)
sslCert = fs.readFileSync(config.webservice.https.certFile, 'utf-8');
if (config.webinterface.https.keyFile)
sslKey = fs.readFileSync(config.webinterface.https.keyFile, 'utf-8');
if (config.webinterface.https.certFile)
sslCert = fs.readFileSync(config.webinterface.https.certFile, 'utf-8');
if (sslKey && sslCert) {
logger.verbose('Creating https server.');
this._logger.verbose('Creating https server.');
this.server = require('https').createServer({key: sslKey, cert: sslCert}, this.app);
} else {
logger.warn('Key or certificate file not found. Fallback to http server.');
this._logger.warn('Key or certificate file not found. Fallback to http server.');
this.server = require('http').createServer(this.app);
}
} else {
@ -151,13 +155,14 @@ exports.WebServer = class {
*/
async setReferenceObjects(objects) {
this.maindb = objects.maindb;
await this.maindb.run(`${utils.sql.tableExistCreate} users (
${utils.sql.pkIdSerial},
username VARCHAR(32) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
scope INTEGER NOT NULL DEFAULT 0
)`);
let sql = this.maindb.sql;
await this.maindb.run(sql.createTableIfNotExists('users', [
sql.templates.idcolumn,
new dblib.Column('username', sql.types.getVarchar(32), [sql.constraints.unique, sql.constraints.notNull]),
new dblib.Column('password', sql.types.getVarchar(255), [sql.constraints.notNull]),
new dblib.Column('token', sql.types.getVarchar(255), [sql.constraints.unique, sql.constraints.notNull]),
new dblib.Column('scope', sql.types.integer, [sql.constraints.notNull, sql.default(0)])
]));
this.root = {
client: {
guilds: async (args) => {
@ -172,7 +177,7 @@ exports.WebServer = class {
.slice(args.offset, args.offset + args.first)
.map(async (x) => new Guild(x, await objects.getGuildHandler(x))));
} catch (err) {
logger.error(err.stack);
this._logger.error(err.stack);
return null;
}
@ -197,8 +202,8 @@ exports.WebServer = class {
return dcGuilds.filter((x) => {
let gh = objects.guildHandlers[x.id];
if (gh)
if (gh.dj)
return gh.dj.playing;
if (gh.musicPlayer)
return gh.musicPlayer.playing;
else
return false;
else
@ -269,16 +274,16 @@ function generateUUID(input) {
}
/**
* Used for graphql attribute access to the lib/music/DJ
* Used for graphql attribute access to the lib/music/MusicPlayer
*/
class DJ {
constructor(musicDj) {
this.dj = musicDj;
this.quality = musicDj.quality;
class MusicPlayer {
constructor(musicPlayer) {
this.musicPlayer = musicPlayer;
this.quality = musicPlayer.quality;
}
queue(args) {
let queue = this.dj.queue.map((x) => {
let queue = this.musicPlayer.queue.map((x) => {
return {
id: generateID(['Media', x.url]),
name: x.title,
@ -294,35 +299,35 @@ class DJ {
}
get playing() {
return this.dj.playing;
return this.musicPlayer.playing;
}
get connected() {
return this.dj.connected;
return this.musicPlayer.connected;
}
get paused() {
return this.dj.disp? this.dj.disp.paused : false;
return this.musicPlayer.disp? this.musicPlayer.disp.paused : false;
}
get queueCount() {
return this.dj.queue.length;
return this.musicPlayer.queue.length;
}
get songStartTime() {
return this.dj.disp.player.streamingData.startTime;
return this.musicPlayer.disp.player.streamingData.startTime;
}
get volume() {
return this.dj.volume;
return this.musicPlayer.volume;
}
get repeat() {
return this.dj.repeat;
return this.musicPlayer.repeat;
}
get currentSong() {
let x = this.dj.current;
let x = this.musicPlayer.current;
return {
id: generateID(['Media', x.url]),
name: x.title,
@ -332,7 +337,7 @@ class DJ {
}
get voiceChannel() {
return this.dj.voiceChannel.name;
return this.musicPlayer.voiceChannel.name;
}
}
@ -355,13 +360,13 @@ class Guild {
this.ready = guildHandler.ready;
this.prSaved = null;
this.guildHandler = guildHandler;
this.dj = this.guildHandler.dj ? new DJ(this.guildHandler.dj) : null;
this.musicPlayer = this.guildHandler.musicPlayer ? new MusicPlayer(this.guildHandler.musicPlayer) : null;
}
async querySaved() {
if (this.guildHandler.db) {
let saved = [];
let rows = await this.guildHandler.db.all('SELECT * FROM playlists');
let rows = await this.guildHandler.db.all(this.guildHandler.db.sql.select('playlists', false, '*'));
for (let row of rows)
saved.push({
id: generateID(['Media', row.url]),
@ -473,5 +478,6 @@ class LogEntry {
this.message = entry.message;
this.timestamp = entry.timestamp;
this.level = entry.level;
this.module = entry.module || entry.m || 'DEFAULT';
}
}

@ -1,6 +1,6 @@
{
"name": "discordbot",
"version": "1.0.0",
"version": "0.11.0",
"scripts": {
"start": "node bot.js",
"test": "mocha --exit",
@ -23,15 +23,18 @@
"graphql": "14.2.1",
"js-md5": "0.7.3",
"js-sha512": "0.8.0",
"js-yaml": "latest",
"node-fetch": "^2.3.0",
"node-opus": "0.3.1",
"node-sass": "4.11.0",
"opusscript": "0.0.6",
"pg": "^7.8.2",
"promise-waterfall": "0.1.0",
"pug": "2.0.3",
"sqlite3": "4.0.6",
"winston": "3.2.1",
"winston-daily-rotate-file": "3.8.0",
"youtube-playlist-info": "1.1.2",
"ytdl-core": "0.29.1"
"ytdl-core-discord": "^1.0.3"
},
"devDependencies": {
"assert": "1.4.1",
@ -43,10 +46,12 @@
"eslint-plugin-graphql": "3.0.3",
"eslint": "5.16.0",
"eslint-plugin-promise": "4.1.1"
"opusscript": "0.0.6"
},
"eslintConfig": {
"parserOptions": {
"ecmaVersion": 2018
"ecmaVersion": 2018,
"sourceType": "module"
},
"env": {
"node": true,

@ -3,7 +3,6 @@ const mockobjects = require('./mockobjects.js'),
sinon = require('sinon'),
assert = require('assert'),
rewire = require('rewire');
let Discord = require("discord.js");
mockobjects.mockLogger = {
error: () => {},
@ -14,7 +13,7 @@ mockobjects.mockLogger = {
};
describe('lib/utils', function() {
const utils = require('../lib/utils.js');
const utils = require('../lib/utils/index.js');
describe('#getSplitDuration', function() {
it('returns an object from milliseconds', function() {
@ -29,7 +28,7 @@ describe('lib/utils', function() {
it('returns the correct extension for a filename', function(done) {
assert(utils.getExtension('test.txt') === '.txt');
assert(utils.getExtension('test.tar.gz') === '.gz');
assert(utils.getExtension('../lib/utils.js') === '.js');
assert(utils.getExtension('../lib/index.js') === '.js');
assert(utils.getExtension('.gitignore') === '.gitignore');
done();
});
@ -201,10 +200,10 @@ describe('lib/music', function() {
"api": {}
});
describe('#DJ', function () {
describe('#MusicPlayer', function () {
it('connects to a VoiceChannel', function (done) {
let dj = new music.DJ(mockobjects.mockVoicechannel);
let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(()=> {
assert(dj.connected);
done();
@ -212,7 +211,7 @@ describe('lib/music', function() {
});
it('listens on Repeat', function () {
let dj = new music.DJ(mockobjects.mockVoicechannel);
let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.current = {'url': '', 'title': ''};
dj.listenOnRepeat = true;
@ -222,7 +221,7 @@ describe('lib/music', function() {
it('plays Files', function (done) {
let dj = new music.DJ(mockobjects.mockVoicechannel);
let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => {
dj.playFile();
assert(dj.playing);
@ -231,7 +230,7 @@ describe('lib/music', function() {
});
it('plays YouTube urls', function (done) {
let dj = new music.DJ(mockobjects.mockVoicechannel);
let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => {
dj.playYouTube('http://www.youtube.com/watch?v=ABCDEFGHIJK');
setTimeout(() => {
@ -242,7 +241,7 @@ describe('lib/music', function() {
});
it('gets the video name', function (done) {
let dj = new music.DJ(mockobjects.mockVoicechannel);
let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.getVideoName('http://www.youtube.com/watch?v=ABCDEFGHIJK').then((name) => {
assert(name === 'test');
done();
@ -250,7 +249,7 @@ describe('lib/music', function() {
});
it('sets the volume', function(done) {
let dj = new music.DJ(mockobjects.mockVoicechannel);
let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => {
dj.playFile();
dj.setVolume(100);
@ -260,7 +259,7 @@ describe('lib/music', function() {
});
it('pauses playback', function(done) {
let dj = new music.DJ(mockobjects.mockVoicechannel);
let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => {
dj.playFile();
dj.pause();
@ -269,7 +268,7 @@ describe('lib/music', function() {
});
it('resumes playback', function(done) {
let dj = new music.DJ(mockobjects.mockVoicechannel);
let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => {
dj.playFile();
dj.resume();
@ -278,7 +277,7 @@ describe('lib/music', function() {
});
it('stops playback', function(done) {
let dj = new music.DJ(mockobjects.mockVoicechannel);
let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => {
dj.playFile();
assert(dj.playing);
@ -289,7 +288,7 @@ describe('lib/music', function() {
});
it('skips songs', function(done) {
let dj = new music.DJ(mockobjects.mockVoicechannel);
let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => {
dj.playYouTube('http://www.youtube.com/watch?v=ABCDEFGHIJK');
dj.playYouTube('http://www.youtube.com/watch?v=ABCDEFGHIJK');
@ -302,7 +301,7 @@ describe('lib/music', function() {
});
it('returns a playlist', function(done) {
let dj = new music.DJ(mockobjects.mockVoicechannel);
let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => {
dj.queue = [{
'title': 'title',
@ -315,7 +314,7 @@ describe('lib/music', function() {
});
it('clears the queue', function(done) {
let dj = new music.DJ(mockobjects.mockVoicechannel);
let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => {
dj.queue = [{
'title': 'title',
@ -330,163 +329,63 @@ describe('lib/music', function() {
});
});
describe('lib/cmd', function() {
const cmd = rewire('../lib/cmd');
cmd.__set__("logger", mockobjects.mockLogger);
describe('lib/command', function() {
let cmdLib = require('../lib/command');
describe('#Servant', function() {
describe('Answer', function() {
it('creates commands', function() {
let servant = new cmd.Servant('');
servant.createCommand(mockobjects.mockCommand, mockobjects.mockCommand.textReply);
assert(servant.commands['test']);
servant.createCommand(mockobjects.mockCommand, mockobjects.mockCommand.promiseReply);
assert(servant.commands['test']);
servant.createCommand(mockobjects.mockCommand, mockobjects.mockCommand.richEmbedReply);
assert(servant.commands['test']);
it('evaluates synchronous', async function() {
let answer = new cmdLib.Answer(() => 'RESPONSE');
assert((await answer.evaluate({}, {}, {})) === 'RESPONSE');
});
it('removes commands', function() {
let servant = new cmd.Servant('');
servant.createCommand(mockobjects.mockCommand, mockobjects.mockCommand.textReply);
assert(servant.commands['test']);
servant.removeCommand('test');
assert(!servant.commands['test']);
});
it('parses commands', function() {
let spy = sinon.spy();
let servant = new cmd.Servant('');
servant.createCommand(mockobjects.mockCommand, spy);
assert(servant.commands['test']);
assert(!spy.called);
servant.parseCommand({
content: 'test',
author: {
tag: undefined
}
it('evaluates asynchronous', async function() {
let answer = new cmdLib.Answer(async () => {
return 'RESPONSE';
});
assert(spy.called);
});
assert((await answer.evaluate({}, {}, {})) === 'RESPONSE');
})
});
});
describe('lib/guilding', function*() { // deactivated because of problems with sqlite3 and rewire
const guilding = rewire('../lib/guilding');
const servercommands = require('../commands/servercommands');
guilding.__set__("sqliteAsync", null);
guilding.__set__("fs-extra", {
ensureDir: async() => {
return true;
}
describe('Command', function() {
it('answers with Answer objects', async function() {
let cmd = new cmdLib.Command({
name: 'TEST',
prefix: '',
description: 'TESTDESCRIPTION',
permission: 'TESTPERM',
usage: 'TESTUSAGE'
},new cmdLib.Answer(() => 'RESPONSE'));
assert((await cmd.answer({}, {}, {})) === 'RESPONSE');
});
it('generates help for itself', function() {
let cmd = new cmdLib.Command({
name: 'TEST',
prefix: '',
description: 'TESTDESCRIPTION',
permission: 'TESTPERM',
usage: 'TESTUSAGE'
},new cmdLib.Answer(() => 'RESPONSE'));
assert(cmd.help);
})
});
guilding.setLogger(mockobjects.mockLogger);
describe('#GuildHandler', function() {
it('initializes', function() {
let gh = new guilding.GuildHandler('test', '');
gh.db = new mockobjects.MockDatabase('', ()=>{});
gh.createTables();
gh.registerMusicCommands();
gh.ready = true;
assert(gh.ready);
});
it('destroyes itself', function() {
let gh = new guilding.GuildHandler('test', '');
gh.db = new mockobjects.MockDatabase('', ()=>{});
gh.createTables();
gh.registerMusicCommands();
gh.ready = true;
gh.destroy();
assert(!gh.dj.conn);
});
it('answers messages', function() {
let gh = new guilding.GuildHandler('test', '');
gh.db = new mockobjects.MockDatabase('', ()=>{});
gh.createTables();
gh.registerMusicCommands();
gh.ready = true;
let msgSpy = sinon.spy();
gh.answerMessage({
content: 'test',
author: {
tag: undefined
},
reply: msgSpy,
channel: {
send: msgSpy
}
}, 'Answer');
assert(msgSpy.called);
});
it('handles messages', function() {
let gh = new guilding.GuildHandler('test', '~');
gh.db = new mockobjects.MockDatabase('', ()=>{});
gh.ready = true;
let cbSpy = sinon.spy();
gh.servant.createCommand(mockobjects.mockCommand, cbSpy);
assert(gh.servant.commands['~test']);
gh.handleMessage({
content: '~test',
author: {
tag: undefined
}});
assert(cbSpy.called);
});
it('connects and plays', function(done) {
const music = rewire('../lib/music');
const Readable = require('stream').Readable;
music.__set__("logger", mockobjects.mockLogger);
music.__set__("yttl", (id, cb) => {
cb(null, 'test');
});
music.__set__('ytdl', () => {
let s = new Readable();
s._read = () => {};
s.push('chunkofdataabc');
s.push(null);
return s;
});
let gh = new guilding.GuildHandler('test', '~');
gh.db = new mockobjects.MockDatabase('', ()=>{});
gh.ready = true;
gh.dj = new music.DJ(mockobjects.mockVoicechannel);
gh.connectAndPlay(mockobjects.mockVoicechannel, 'test', false).then(() => {
done();
});
});
it('handles all servercommands', function() {
let gh = new guilding.GuildHandler('test', '~');
gh.db = new mockobjects.MockDatabase('', ()=>{});
gh.registerMusicCommands();
gh.ready = true;
let msgSpy = sinon.spy();
let msg = {
content: 'test',
author: {
tag: undefined
},
reply: msgSpy,
channel: {
send: msgSpy
}
};
for (let category of Object.keys(servercommands))
for (let command of Object.keys(servercommands[category])) {
msg.content = '~' + command;
gh.handleMessage(msg);
}
});
assert(msgSpy.called);
});
describe('lib/message', function() {
let msgLib = require('../lib/message');
describe('MessageHandler', function() {
it ('parses a command syntax', function() {
let msgHandler = new msgLib.MessageHandler({
on: () => {}
});
let parsedSyntax = msgHandler.parseSyntaxString('_help cmd&& _ping; _uptime');
assert(parsedSyntax.length === 2);
assert(parsedSyntax[0].length === 2);
assert(parsedSyntax[1].length === 1);
});
});
});

@ -26,7 +26,7 @@ type GuildMember {
roles(first: Int = 10, offset: Int = 0, id: String): [Role]
highestRole: Role
}
type DJ {
type MusicPlayer {
queue(first: Int = 10, offset: Int = 0, id: String): [MediaEntry]
queueCount: Int!
songStartTime: String
@ -44,7 +44,7 @@ type Guild {
discordId: String
name: String
owner: GuildMember
dj: DJ
musicPlayer: MusicPlayer
members(first: Int = 10, offset: Int = 0, id: String): [GuildMember]
memberCount: Int!
roles(first: Int = 10, offset: Int = 0, id: String): [Role]
@ -73,6 +73,7 @@ type LogEntry {
message: String
level: String
timestamp: String
module: String
}
type Query {
client: Client

@ -50,30 +50,30 @@ head
span.label.text-right Member Count:
span#guild-memberCount.text-left
.space
h3.cell DJ
h3.cell MusicPlayer
.cell
span.label.text-right State:
span#guild-djStatus.text-left
span#guild-mpStatus.text-left
.cell
span.label.text-right Repeat:
span#dj-repeat.text-left
span#mp-repeat.text-left
.cell
span.label.text-right Voice Channel:
span#dj-voiceChannel.text-left
#dj-songinfo.listContainer(style='display: none')
span#mp-voiceChannel.text-left
#mp-songinfo.listContainer(style='display: none')
a#songinfo-container
span#dj-songname
img#dj-songImg(src='' alt='')
#dj-songProgress(style='display:none')
span#dj-songCurrentTS
#dj-queue-container
span#mp-songname
img#mp-songImg(src='' alt='')
#mp-songProgress(style='display:none')
span#mp-songCurrentTS
#mp-queue-container
span.cell.label(id='Queue Song count')
span#dj-queueCount
span#mp-queueCount
| Songs in Queue
span.cell
| Next
span#dj-queueDisplayCount 0
span#mp-queueDisplayCount 0
| Songs:
#dj-songQueue
#mp-songQueue
script.
startUpdating();

@ -247,7 +247,7 @@ div.cell > *
#guild-nameAndIcon
width: 50%
#dj-songinfo
#mp-songinfo
background-color: $cBackgroundVariant
border-radius: 20px
overflow-x: hidden
@ -259,15 +259,15 @@ div.cell > *
padding: 10px
width: calc(100% - 20px)
#dj-queue-container
#mp-queue-container
display: grid
padding: 0 5px 5px
#dj-songname
#mp-songname
font-weight: bold
font-size: 120%
#dj-songImg
#mp-songImg
align-self: center
width: 80%
height: auto
@ -281,6 +281,6 @@ div.cell > *
#guildinfo:hover
overflow-y: auto
#dj-songQueue
#mp-songQueue
display: grid
max-height: 100%

@ -2,8 +2,8 @@ $cPrimary: #fff
$cPrimaryVariant: #4c10a5
$cSecondary: #c889f5
$cSecondaryVariant: #740bce
$cBackground: #77f
$cBackgroundVariant: #55b
$cBackground: #1f1f2f
$cBackgroundVariant: #3f3f55
$cSurface: #fff
$cSurfaceVariant: #000
$cError: #f59289
@ -28,4 +28,4 @@ $cInfo: #890
$cWarn: #a60
$cError: #a00
$fNormal: Ubuntu, sans-serif
$fNormal: Ubuntu, sans-serif

@ -60,7 +60,7 @@ function queryGuilds() {
guilds {
id
name
dj {
musicPlayer {
playing
}
}
@ -71,8 +71,8 @@ function queryGuilds() {
if ($(`option[value=${guild.id}]`).length === 0) {
let option = document.createElement('option');
option.setAttribute('value', guild.id);
if (guild.dj)
option.innerText = guild.dj.playing? guild.name + ' 🎶' : guild.name;
if (guild.musicPlayer)
option.innerText = guild.musicPlayer.playing? guild.name + ' 🎶' : guild.name;
let guildSelect = document.querySelector('#guild-select');
guildSelect.appendChild(option);
}
@ -118,7 +118,7 @@ function queryGuildStatus(guildId) {
let query = `{
client {
guilds(id: "${guildId}") {
dj {
musicPlayer {
playing
connected
repeat
@ -140,33 +140,32 @@ function queryGuildStatus(guildId) {
}
}
}
config
}`;
postQuery(query).then((res) => {
let guild = res.data.client.guilds[0];
document.querySelector('#dj-repeat').innerText = guild.dj.repeat? 'on': 'off';
document.querySelector('#guild-djStatus').innerText = guild.dj.connected? 'connected' : 'disconnected';
if (guild.dj.connected) {
let songinfoContainer = $('#dj-songinfo');
document.querySelector('#mp-repeat').innerText = guild.musicPlayer.repeat? 'on': 'off';
document.querySelector('#guild-mpStatus').innerText = guild.musicPlayer.connected? 'connected' : 'disconnected';
if (guild.musicPlayer.connected) {
let songinfoContainer = $('#mp-songinfo');
songinfoContainer.show();
document.querySelector('#guild-djStatus').innerText = guild.dj.playing? 'playing' : 'connected';
document.querySelector('#dj-voiceChannel').innerText = guild.dj.voiceChannel;
document.querySelector('#guild-mpStatus').innerText = guild.musicPlayer.playing? 'playing' : 'connected';
document.querySelector('#mp-voiceChannel').innerText = guild.musicPlayer.voiceChannel;
if (guild.dj.playing) {
if (guild.musicPlayer.playing) {
if (songinfoContainer.is(':hidden'))
songinfoContainer.show();
document.querySelector('#guild-djStatus').innerText = guild.dj.paused? 'paused' : 'playing';
document.querySelector('#songinfo-container').setAttribute('href', guild.dj.currentSong.url);
document.querySelector('#dj-songname').innerText = guild.dj.currentSong.name;
document.querySelector('#dj-songImg').setAttribute('src', guild.dj.currentSong.thumbnail.replace('maxresdefault', 'mqdefault'));
let songSd = getSplitDuration(Date.now() - guild.dj.songStartTime);
document.querySelector('#dj-songCurrentTS').innerText = `${songSd.minutes}:${songSd.seconds.toString().padStart(2, '0')}`;
document.querySelector('#dj-songCurrentTS').setAttribute('start-ts', guild.dj.songStartTime);
document.querySelector('#dj-queueCount').innerText = guild.dj.queueCount;
let songContainer = document.querySelector('#dj-songQueue');
document.querySelector('#guild-mpStatus').innerText = guild.musicPlayer.paused? 'paused' : 'playing';
document.querySelector('#songinfo-container').setAttribute('href', guild.musicPlayer.currentSong.url);
document.querySelector('#mp-songname').innerText = guild.musicPlayer.currentSong.name;
document.querySelector('#mp-songImg').setAttribute('src', guild.musicPlayer.currentSong.thumbnail.replace('maxresdefault', 'mqdefault'));
let songSd = getSplitDuration(Date.now() - guild.musicPlayer.songStartTime);
document.querySelector('#mp-songCurrentTS').innerText = `${songSd.minutes}:${songSd.seconds.toString().padStart(2, '0')}`;
document.querySelector('#mp-songCurrentTS').setAttribute('start-ts', guild.musicPlayer.songStartTime);
document.querySelector('#mp-queueCount').innerText = guild.musicPlayer.queueCount;
let songContainer = document.querySelector('#mp-songQueue');
$('.songEntry').remove();
for (let song of guild.dj.queue) {
for (let song of guild.musicPlayer.queue) {
let songEntry = document.createElement('a');
songEntry.setAttribute('href', song.url);
songEntry.setAttribute('class', 'songEntry');
@ -179,14 +178,14 @@ function queryGuildStatus(guildId) {
songEntry.appendChild(nameEntry);
songContainer.appendChild(songEntry);
}
document.querySelector('#dj-queueDisplayCount').innerText = document.querySelectorAll('.songEntry').length;
document.querySelector('#mp-queueDisplayCount').innerText = document.querySelectorAll('.songEntry').length;
} else {
if (songinfoContainer.is(':not(:hidden)'))
songinfoContainer.hide();
}
} else {
$('#dj-songinfo').hide();
document.querySelector('#dj-voiceChannel').innerText = 'None';
$('#mp-songinfo').hide();
document.querySelector('#mp-voiceChannel').innerText = 'None';
}
});
}
@ -241,6 +240,7 @@ function queryLogs(count) {
level
message
timestamp
module
}
}`;
postQuery(query).then((res) => {
@ -257,6 +257,10 @@ function queryLogs(count) {
lvlSpan.innerText = logEntry.level;
lvlSpan.setAttribute('class', 'text-left');
infoDiv.appendChild(lvlSpan);
let moduleSpan = document.createElement('span');
moduleSpan.innerText = logEntry.module;
moduleSpan.setAttribute('class', 'text-left');
infoDiv.appendChild(moduleSpan);
let tsSpan = document.createElement('span');
tsSpan.setAttribute('timestamp', logEntry.timestamp);
tsSpan.innerText = moment(logEntry.timestamp, 'YY-MM-DD-HH-mm-ss').format('MMM Do HH:mm:ss');
@ -302,7 +306,7 @@ function startUpdating() {
queryGuild(guildId);
});
setInterval(() => {
let songSd = getSplitDuration(Date.now() - $('#dj-songCurrentTS').attr('start-ts'));
document.querySelector('#dj-songCurrentTS').innerText = `${songSd.minutes}:${songSd.seconds.toString().padStart(2, '0')}`;
let songSd = getSplitDuration(Date.now() - $('#mp-songCurrentTS').attr('start-ts'));
document.querySelector('#mp-songCurrentTS').innerText = `${songSd.minutes}:${songSd.seconds.toString().padStart(2, '0')}`;
}, 500);
}

Loading…
Cancel
Save