Merge pull request #51 from Trivernis/develop

Develop - Beta Release 0.10.0
pull/110/head^2 v0.10.0
Trivernis 6 years ago committed by GitHub
commit e930d0ef61
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,33 @@
# 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).
## [0.10.0] - 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

@ -36,6 +36,10 @@ The arguments are optional because the token and youtube-api-key that the bot ne
"keyFile": "PATH TO YOUR SSL KEY FILE", "keyFile": "PATH TO YOUR SSL KEY FILE",
"certFile": "PATH TO YOUR SSL CERTIFICATE 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
} }
} }
``` ```
@ -63,6 +67,7 @@ At the moment the bot can...
- [x] ...log stuff in a database - [x] ...log stuff in a database
- [x] ...execute multiple commands as a sequence - [x] ...execute multiple commands as a sequence
- [x] ...save command sequences with a given name - [x] ...save command sequences with a given name
- [x] ...query AniList
- [ ] ...transform into a cow - [ ] ...transform into a cow
Presences Presences
@ -76,15 +81,19 @@ Command Sequences
A command sequence is a single message with several commands seperated by a semicolon. 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. 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 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]`. 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. 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]`. 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 Ideas
--- ---
- command replies saved in file (server specific file and global file) - command replies saved in file (server specific file and global file)
- reddit api - reddit api
- anilist api
- othercoolstuff api - othercoolstuff api

303
bot.js

@ -1,44 +1,47 @@
const Discord = require("discord.js"), const Discord = require("discord.js"),
fs = require('fs-extra'), fs = require('fs-extra'),
logger = require('./lib/logging').getLogger(), logging = require('./lib/logging'),
cmd = require("./lib/cmd"), msgLib = require('./lib/MessageLib'),
guilding = require('./lib/guilding'), guilding = require('./lib/guilding'),
utils = require('./lib/utils'), utils = require('./lib/utils'),
config = require('./config.json'), config = require('./config.json'),
args = require('args-parser')(process.argv), args = require('args-parser')(process.argv),
waterfall = require('promise-waterfall'),
sqliteAsync = require('./lib/sqliteAsync'), sqliteAsync = require('./lib/sqliteAsync'),
globcommands = require('./commands/globalcommands.json'),
authToken = args.token || config.api.botToken, authToken = args.token || config.api.botToken,
prefix = args.prefix || config.prefix || '~', prefix = args.prefix || config.prefix || '~',
gamepresence = args.game || config.presence; gamepresence = args.game || config.presence;
let weblib = null; let weblib = null;
/**
* The Bot class handles the initialization and Mangagement of the Discord bot and
* is the main class.
*/
class Bot { class Bot {
constructor() { constructor() {
this.client = new Discord.Client({autoReconnect: true}); this.client = new Discord.Client({autoReconnect: true});
this.mention = false; this.logger = new logging.Logger(this);
this.rotator = null; this.rotator = null;
this.maindb = null; this.maindb = null;
this.presences = []; this.presences = [];
this.guildHandlers = []; this.messageHandler = new msgLib.MessageHandler(this.client);
this.userRates = {}; this.guildHandlers = {};
logger.verbose('Verifying config'); this.logger.verbose('Verifying config');
let configVerifyer = new utils.ConfigVerifyer(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) { if (!args.i) {
logger.info('Invalid config. Exiting'); this.logger.info('Invalid config. Exiting');
logger.flush().then(() => { this.logger.flush().then(() => {
process.exit(1); process.exit(1);
}); });
} }
cmd.setLogger(logger);
guilding.setLogger(logger);
} }
/** /**
@ -46,27 +49,44 @@ class Bot {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async initServices() { async initServices() {
logger.verbose('Registering cleanup function'); this.logger.verbose('Registering cleanup function');
utils.Cleanup(() => { utils.Cleanup(() => {
for (let gh in Object.values(this.guildHandlers)) for (let gh in Object.values(this.guildHandlers))
if (gh instanceof guilding.GuildHandler) if (gh instanceof guilding.GuildHandler)
gh.destroy(); gh.destroy();
this.client.destroy().then(() => { this.client.destroy().then(() => {
logger.debug('destroyed client'); this.logger.debug('destroyed client');
}).catch((err) => { }).catch((err) => {
logger.error(err.message); this.logger.error(err.message);
logger.debug(err.stack); this.logger.debug(err.stack);
}); });
this.maindb.close(); this.maindb.close();
}); });
await this.initializeDatabase(); await this.initializeDatabase();
if (config.webservice && config.webservice.enabled)
if (config.webinterface && config.webinterface.enabled)
await this.initializeWebserver(); await this.initializeWebserver();
logger.verbose('Registering commands'); this.logger.verbose('Registering commands');
this.registerCommands(); await this.messageHandler.registerCommandModule(require('./lib/commands/AnilistApiCommands').module, {});
this.registerCallbacks(); await this.messageHandler.registerCommandModule(require('./lib/commands/UtilityCommands').module, {
cmd.init(prefix); bot: this,
config: config
});
await this.messageHandler.registerCommandModule(require('./lib/commands/InfoCommands').module, {
client: this.client,
messageHandler: this.messageHandler
});
await this.messageHandler.registerCommandModule(require('./lib/commands/MusicCommands').module, {
getGuildHandler: async (g) => await this.getGuildHandler(g)
});
await this.messageHandler.registerCommandModule(require('./lib/commands/ServerUtilityCommands').module, {
getGuildHandler: async (g) => await this.getGuildHandler(g),
messageHandler: this.messageHandler,
config: config
});
this.registerEvents();
} }
/** /**
@ -75,10 +95,11 @@ class Bot {
*/ */
async start() { async start() {
await this.client.login(authToken); await this.client.login(authToken);
logger.debug("Logged in"); this.logger.debug("Logged in");
if (this.webServer) { if (this.webServer) {
this.webServer.start(); this.webServer.start();
logger.info(`WebServer runing on port ${this.webServer.port}`); this.logger.info(`WebServer runing on port ${this.webServer.port}`);
} }
} }
@ -87,16 +108,17 @@ class Bot {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async initializeDatabase() { async initializeDatabase() {
logger.debug('Checking for ./data/ existence'); this.logger.debug('Checking for ./data/ existence');
await fs.ensureDir('./data'); await fs.ensureDir('./data');
logger.verbose('Connecting to main database'); this.logger.verbose('Connecting to main database');
this.maindb = new sqliteAsync.Database('./data/main.db'); this.maindb = new sqliteAsync.Database('./data/main.db');
await this.maindb.init(); await this.maindb.init();
await this.maindb.run(`${utils.sql.tableExistCreate} presences ( await this.maindb.run(`${utils.sql.tableExistCreate} presences (
${utils.sql.pkIdSerial}, ${utils.sql.pkIdSerial},
text VARCHAR(255) UNIQUE NOT NULL text VARCHAR(255) UNIQUE NOT NULL
)`); )`);
logger.debug('Loading Presences...'); this.logger.debug('Loading Presences...');
await this.loadPresences(); await this.loadPresences();
} }
@ -104,19 +126,18 @@ class Bot {
* initializes the api webserver * initializes the api webserver
*/ */
async initializeWebserver() { async initializeWebserver() {
logger.verbose('Importing weblib'); this.logger.verbose('Importing weblib');
weblib = require('./lib/weblib'); weblib = require('./lib/WebLib');
weblib.setLogger(logger); this.logger.verbose('Creating WebServer');
logger.verbose('Creating WebServer'); this.webServer = new weblib.WebServer(config.webinterface.port || 8080);
this.webServer = new weblib.WebServer(config.webservice.port || 8080); this.logger.debug('Setting Reference Objects to webserver');
logger.debug('Setting Reference Objects to webserver');
await this.webServer.setReferenceObjects({ await this.webServer.setReferenceObjects({
client: this.client, client: this.client,
presences: this.presences, presences: this.presences,
maindb: this.maindb, maindb: this.maindb,
prefix: prefix, prefix: prefix,
getGuildHandler: (guild) => this.getGuildHandler(guild, prefix), getGuildHandler: async (g) => await this.getGuildHandler(g),
guildHandlers: this.guildHandlers guildHandlers: this.guildHandlers
}); });
} }
@ -136,7 +157,7 @@ class Bot {
lineReader.on('line', (line) => { lineReader.on('line', (line) => {
this.maindb.run('INSERT INTO presences (text) VALUES (?)', [line], (err) => { this.maindb.run('INSERT INTO presences (text) VALUES (?)', [line], (err) => {
if (err) if (err)
logger.warn(err.message); this.logger.warn(err.message);
}); });
this.presences.push(line); this.presences.push(line);
@ -157,115 +178,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 * changes the presence of the bot by using one stored in the presences array
*/ */
@ -276,118 +188,54 @@ class Bot {
this.client.user.setPresence({ this.client.user.setPresence({
game: {name: `${gamepresence} | ${pr}`, type: "PLAYING"}, game: {name: `${gamepresence} | ${pr}`, type: "PLAYING"},
status: 'online' status: 'online'
}).then(() => logger.debug(`Presence rotation to ${pr}`)) }).then(() => this.logger.debug(`Presence rotation to ${pr}`))
.catch((err) => logger.warn(err.message)); .catch((err) => this.logger.warn(err.message));
} }
/** /**
* Registeres callbacks for client events message and ready * Registeres callbacks for client events message and ready
*/ */
registerCallbacks() { registerEvents() {
this.client.on('error', (err) => { this.client.on('error', (err) => {
logger.error(err.message); this.logger.error(err.message);
logger.debug(err.stack); this.logger.debug(err.stack);
}); });
this.client.on('ready', () => { 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({ this.client.user.setPresence({
game: { game: {
name: gamepresence, type: "PLAYING" name: gamepresence, type: "PLAYING"
}, status: 'online' }, status: 'online'
}) }).catch((err) => {
.catch((err) => {
if (err) if (err)
logger.warn(err.message); this.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);
}
});
this.client.on('voiceStateUpdate', async (oldMember, newMember) => { 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.user === this.client.user) {
if (newMember.voiceChannel) if (newMember.voiceChannel)
gh.dj.updateChannel(newMember.voiceChannel); gh.musicPlayer.updateChannel(newMember.voiceChannel);
} else { } else {
if (oldMember.voiceChannel === gh.dj.voiceChannel || newMember.voiceChannel === gh.dj.voiceChannel) if (oldMember.voiceChannel === gh.musicPlayer.voiceChannel || newMember.voiceChannel === gh.musicPlayer.voiceChannel)
gh.dj.checkListeners(); 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);
}
}
/** /**
* Returns the guild handler by id, creates one if it doesn't exist and returns it then * Returns the guild handler by id, creates one if it doesn't exist and returns it then
* @param guild * @param guild {Guild}
* @param prefix
* @returns {*} * @returns {*}
*/ */
async getGuildHandler(guild, prefix) { async getGuildHandler(guild) {
if (!this.guildHandlers[guild.id]) { if (!this.guildHandlers[guild.id]) {
let newGuildHandler = new guilding.GuildHandler(guild, prefix); let newGuildHandler = new guilding.GuildHandler(guild);
await newGuildHandler.initDatabase(); await newGuildHandler.initDatabase();
this.guildHandlers[guild.id] = newGuildHandler; this.guildHandlers[guild.id] = newGuildHandler;
} }
@ -398,7 +246,8 @@ class Bot {
// Executing the main function // Executing the main function
if (typeof require !== 'undefined' && require.main === module) { 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');
logger.info("Starting up... ");
logger.debug('Calling constructor...'); logger.debug('Calling constructor...');
let discordBot = new Bot(); let discordBot = new Bot();
logger.debug('Initializing services...'); logger.debug('Initializing services...');

@ -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,282 @@
const Discord = require('discord.js'),
yaml = require('js-yaml'),
fsx = require('fs-extra'),
logging = require('./logging'),
config = require('../config.json'),
utils = require('./utils');
const scopes = {
'Global': 0,
'User': 1,
'Guild': 2
};
class Answer {
/**
* Creates an new Answer object with _func as answer logic.
* @param func
*/
constructor(func) {
this._func = func;
}
/**
* 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<*>}
*/
async evaluate(message, kwargs, argsString) {
let result = this._func(message, kwargs, argsString);
if (result instanceof Promise)
return await utils.resolveNestedPromise(result);
else
return result;
}
}
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 {String}
*/
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 {Boolean | String | Promise<String|Discord.RichEmbed>}
*/
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 "You don't have permission for this command";
} else {
this._logger.silly(`Command ${commandName} not found.`);
return false;
}
} else {
this._logger.silly(`No prefix found in command ${commandName}`);
return false;
}
}
/**
* 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 object property templateFile or the given argument file.
* @returns {Promise<void>}
* @private
*/
async _loadTemplate(file) {
let templateString = await fsx.readFile(this.templateFile || file, {encoding: 'utf-8'});
this._logger.silly(`Loaded Template file ${this.templateFile || file}`);
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 a Field when a name is given or adds a blank Field otherwise
* @param name {String}
* @param content {String}
* @returns {ExtendedRichEmbed}
*/
addNonemptyField(name, content) {
if (name && name.length > 0 && content && content.length > 0)
this.addField(name, content);
return this;
}
/**
* Adds the fields defined in the fields JSON
* @param fields {JSON}
* @returns {ExtendedRichEmbed}
*/
addFields(fields) {
for (let [name, value] of Object.entries(fields))
this.addNonemptyField(name, value);
return this;
}
/**
* Sets the description by shortening the value string to a fitting length for discord.
* @param value
*/
setDescription(value) {
let croppedValue = value;
if (value.substring)
croppedValue = value.substring(0, 1024);
if (croppedValue.length < value.length)
croppedValue = croppedValue.replace(/\n.*$/g, '');
super.setDescription(croppedValue);
}
/**
* Sets the field by shortening the value stirn to a fitting length for discord.
* @param name
* @param value
*/
addField(name, value) {
let croppedValue = value;
if (value.substring)
croppedValue = value.substring(0, 1024);
if (croppedValue.length < value.length)
croppedValue = croppedValue.replace(/\n.*$/g, '');
super.addField(name, croppedValue);
}
}
// -- exports -- //
Object.assign(exports, {
Answer: Answer,
Command: Command,
CommandHandler: CommandHandler,
CommandModule: CommandModule,
ExtendedRichEmbed: ExtendedRichEmbed,
CommandScopes: scopes
});

@ -0,0 +1,201 @@
const cmdLib = require('./CommandLib'),
config = require('../config.json'),
Discord = require('discord.js'),
logging = require('./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._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 event...');
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');
}
});
}
/**
* 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)
this._answerMessage(message, scopeResult);
else if (globalResult)
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 answer {String | Discord.RichEmbed}
* @private
*/
_answerMessage(message, answer) {
this.logger.debug(`Sending answer ${answer}`);
if (answer)
if (answer instanceof Discord.RichEmbed)
message.channel.send('', answer);
else
message.channel.send(answer);
}
/**
* 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,20 +1,16 @@
const ytdl = require("ytdl-core"), const ytdl = require("ytdl-core"),
ypi = require('youtube-playlist-info'), ypi = require('youtube-playlist-info'),
yttl = require('get-youtube-title'), yttl = require('get-youtube-title'),
args = require('args-parser')(process.argv),
config = require('../config.json'), config = require('../config.json'),
utils = require('./utils.js'), utils = require('./utils.js'),
ytapiKey = args.ytapi || config.api.youTubeApiKey; logging = require('./logging'),
/* Variable Definition */ ytapiKey = config.api.youTubeApiKey;
let logger = require('winston');
/* Function Definition */ /**
* The Music Player class is used to handle music playing tasks on Discord Servers (Guilds).
exports.setLogger = function (newLogger) { * @type {MusicPlayer}
logger = newLogger; */
}; class MusicPlayer {
exports.DJ = class {
constructor(voiceChannel) { constructor(voiceChannel) {
this.conn = null; this.conn = null;
this.disp = null; this.disp = null;
@ -26,6 +22,8 @@ exports.DJ = class {
this.voiceChannel = voiceChannel; this.voiceChannel = voiceChannel;
this.quality = 'lowest'; this.quality = 'lowest';
this.exitTimeout = null; this.exitTimeout = null;
this._logger = new logging.Logger(this);
this._logger.silly('Initialized Music Player');
} }
/** /**
@ -43,21 +41,20 @@ exports.DJ = class {
} }
else if (voiceChannel) else if (voiceChannel)
this.voiceChannel = 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(); let connection = await this.voiceChannel.join();
logger.info(`Connected to Voicechannel ${this.voiceChannel.name}`); this._logger.info(`Connected to Voicechannel ${this.voiceChannel.name}`);
this.conn = connection; this.conn = connection;
} }
/** /**
* Defining setter for listenOnRepeat to include the current song into the repeating loop. * Defining setter for listenOnRepeat to include the current song into the repeating loop.
* @param value * @param value {Boolean}
*/ */
set listenOnRepeat(value) { set listenOnRepeat(value) {
this.repeat = value; this.repeat = value;
if (this.current) if (this.current)
this.queue.push(this.current); this.queue.push(this.current);
} }
/** /**
@ -74,19 +71,19 @@ exports.DJ = class {
/** /**
* Updates the channel e.g. when the bot is moved to another channel. * Updates the channel e.g. when the bot is moved to another channel.
* @param voiceChannel * @param voiceChannel {Discord.VoiceChannel}
*/ */
updateChannel(voiceChannel) { updateChannel(voiceChannel) {
if (voiceChannel) { if (voiceChannel) {
this.voiceChannel = 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. * Plays a file for the given filename.
* TODO: Implement queue * TODO: Implement queue
* @param filename * @param filename {String}
* @todo * @todo
*/ */
playFile(filename) { playFile(filename) {
@ -94,7 +91,7 @@ exports.DJ = class {
this.disp = this.conn.playFile(filename); this.disp = this.conn.playFile(filename);
this.playing = true; this.playing = true;
} else { } 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.connect(this.voiceChannel).then(() => {
this.playFile(filename); this.playFile(filename);
}); });
@ -109,13 +106,13 @@ exports.DJ = class {
if (this.exitTimeout) { if (this.exitTimeout) {
clearTimeout(this.exitTimeout); clearTimeout(this.exitTimeout);
this.exitTimeout = null; 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) { 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(() => { this.exitTimeout = setTimeout(() => {
if (this.connected && this.voiceChannel.members.size === 1) if (this.connected && this.voiceChannel.members.size === 1)
logger.verbose(`Exiting ${this.voiceChannel.name}`); this._logger.verbose(`Exiting ${this.voiceChannel.name}`);
this.stop(); this.stop();
}, config.music.timeout || 300000); }, config.music.timeout || 300000);
} }
@ -125,13 +122,13 @@ exports.DJ = class {
* Plays the url of the current song if there is no song playing or puts it in the queue. * 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 * 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. * in the queue. For each song the title is saved in the queue too.
* @param url * @param url {String}
* @param playnext * @param playnext {Boolean}
*/ */
async playYouTube(url, playnext) { async playYouTube(url, playnext) {
let plist = utils.YouTube.getPlaylistIdFromUrl(url); let plist = utils.YouTube.getPlaylistIdFromUrl(url);
if (plist) { if (plist) {
logger.debug(`Adding playlist ${plist} to queue`); this._logger.debug(`Adding playlist ${plist} to queue`);
let playlistItems = await ypi(ytapiKey, plist); let playlistItems = await ypi(ytapiKey, plist);
let firstSong = utils.YouTube.getVideoUrlFromId(playlistItems.shift().resourceId.videoId); let firstSong = utils.YouTube.getVideoUrlFromId(playlistItems.shift().resourceId.videoId);
let firstSongTitle = null; let firstSongTitle = null;
@ -139,14 +136,14 @@ exports.DJ = class {
firstSongTitle = await this.getVideoName(firstSong); firstSongTitle = await this.getVideoName(firstSong);
} catch(err) { } catch(err) {
if (err.message !== 'Not found') { if (err.message !== 'Not found') {
logger.warn(err.message); this._logger.warn(err.message);
logger.debug(err.stack); this._logger.debug(err.stack);
} }
} }
if (this.repeat) if (this.repeat)
this.queue.push({'url': firstSong, 'title': firstSongTitle}); 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) { for (let item of playlistItems) {
let vurl = utils.YouTube.getVideoUrlFromId(item.resourceId.videoId); let vurl = utils.YouTube.getVideoUrlFromId(item.resourceId.videoId);
@ -154,14 +151,14 @@ exports.DJ = class {
this.queue.push({'url': vurl, 'title': await this.getVideoName(vurl)}); //eslint-disable-line no-await-in-loop this.queue.push({'url': vurl, 'title': await this.getVideoName(vurl)}); //eslint-disable-line no-await-in-loop
} catch (err) { } catch (err) {
if (err.message !== 'Not found') { if (err.message !== 'Not found') {
logger.warn(err.message); this._logger.warn(err.message);
logger.debug(err.stack); this._logger.debug(err.stack);
} }
} }
} }
logger.debug(`Added ${playlistItems.length} songs to the queue`); this._logger.debug(`Added ${playlistItems.length} songs to the queue`);
} else if (!this.playing || !this.disp) { } 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.current = ({'url': url, 'title': await this.getVideoName(url)});
this.disp = this.conn.playStream(ytdl(url, this.disp = this.conn.playStream(ytdl(url,
@ -176,7 +173,7 @@ exports.DJ = class {
this.current = this.queue.shift(); this.current = this.queue.shift();
if (this.repeat) // listen on repeat if (this.repeat) // listen on repeat
this.queue.push(this.current); this.queue.push(this.current);
this.playYouTube(this.current.url).catch((err) => logger.warn(err.message)); this.playYouTube(this.current.url).catch((err) => this._logger.warn(err.message));
} else { } else {
this.stop(); this.stop();
} }
@ -184,7 +181,7 @@ exports.DJ = class {
}); });
this.playing = true; this.playing = true;
} else { } else {
logger.debug(`Added ${url} to the queue`); this._logger.debug(`Added ${url} to the queue`);
if (playnext) if (playnext)
this.queue.unshift({'url': url, 'title': await this.getVideoName(url)}); this.queue.unshift({'url': url, 'title': await this.getVideoName(url)});
else else
@ -195,14 +192,14 @@ exports.DJ = class {
/** /**
* Gets the name of the YouTube Video at url * Gets the name of the YouTube Video at url
* @param url * @param url {String}
* @returns {Promise<>} * @returns {Promise<>}
*/ */
getVideoName(url) { getVideoName(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
yttl(utils.YouTube.getVideoIdFromUrl(url), (err, title) => { yttl(utils.YouTube.getVideoIdFromUrl(url), (err, title) => {
if (err) { if (err) {
logger.debug(JSON.stringify(err)); this._logger.debug(JSON.stringify(err));
reject(err); reject(err);
} else { } else {
resolve(title); resolve(title);
@ -213,15 +210,15 @@ exports.DJ = class {
/** /**
* Sets the volume of the dispatcher to the given value * Sets the volume of the dispatcher to the given value
* @param percentage * @param percentage {Number}
*/ */
setVolume(percentage) { setVolume(percentage) {
logger.verbose(`Setting volume to ${percentage}`); this._logger.verbose(`Setting volume to ${percentage}`);
if (this.disp !== null) { if (this.disp !== null) {
this.volume = percentage; this.volume = percentage;
this.disp.setVolume(percentage); this.disp.setVolume(percentage);
} else { } else {
logger.warn("No dispatcher found."); this._logger.warn("No dispatcher found.");
} }
} }
@ -229,11 +226,11 @@ exports.DJ = class {
* Pauses if a dispatcher exists * Pauses if a dispatcher exists
*/ */
pause() { pause() {
logger.verbose("Pausing music..."); this._logger.verbose("Pausing music...");
if (this.disp !== null) if (this.disp !== null)
this.disp.pause(); this.disp.pause();
else else
logger.warn("No dispatcher found"); this._logger.warn("No dispatcher found");
} }
@ -241,11 +238,11 @@ exports.DJ = class {
* Resumes if a dispatcher exists * Resumes if a dispatcher exists
*/ */
resume() { resume() {
logger.verbose("Resuming music..."); this._logger.verbose("Resuming music...");
if (this.disp !== null) if (this.disp !== null)
this.disp.resume(); this.disp.resume();
else else
logger.warn("No dispatcher found"); this._logger.warn("No dispatcher found");
} }
@ -257,25 +254,25 @@ exports.DJ = class {
this.playing = false; this.playing = false;
this.queue = []; this.queue = [];
this.current = null; this.current = null;
logger.verbose("Stopping music..."); this._logger.verbose("Stopping music...");
try { try {
if (this.disp) { if (this.disp) {
this.disp.end('stop'); this.disp.end('stop');
this.disp = null; this.disp = null;
logger.debug("Ended dispatcher"); this._logger.debug("Ended dispatcher");
} }
if (this.conn) { if (this.conn) {
this.conn.disconnect(); this.conn.disconnect();
this.conn = null; this.conn = null;
logger.debug("Ended connection"); this._logger.debug("Ended connection");
} }
if (this.voiceChannel) { if (this.voiceChannel) {
this.voiceChannel.leave(); this.voiceChannel.leave();
logger.debug("Left VoiceChannel"); this._logger.debug("Left VoiceChannel");
logger.info(`Disconnected from Voicechannel ${this.voiceChannel.name}`); this._logger.info(`Disconnected from Voicechannel ${this.voiceChannel.name}`);
} }
} catch (error) { } catch (error) {
logger.verbose(JSON.stringify(error)); this._logger.verbose(JSON.stringify(error));
} }
} }
@ -285,7 +282,7 @@ exports.DJ = class {
* It tries to play the next song with playYouTube * It tries to play the next song with playYouTube
*/ */
skip() { skip() {
logger.debug("Skipping song"); this._logger.debug("Skipping song");
if (this.disp !== null) { if (this.disp !== null) {
this.disp.end(); this.disp.end();
} else { } else {
@ -293,8 +290,8 @@ exports.DJ = class {
if (this.queue.length > 0) { if (this.queue.length > 0) {
this.current = this.queue.shift(); this.current = this.queue.shift();
this.playYouTube(this.current.url).catch((err) => { this.playYouTube(this.current.url).catch((err) => {
logger.error(err.message); this._logger.error(err.message);
logger.debug(err.stack); this._logger.debug(err.stack);
}); });
} else { } else {
this.stop(); this.stop();
@ -323,4 +320,8 @@ exports.DJ = class {
clear() { clear() {
this.queue = []; this.queue = [];
} }
}; }
Object.assign(exports, {
MusicPlayer: MusicPlayer
});

@ -4,6 +4,7 @@ const express = require('express'),
compression = require('compression'), compression = require('compression'),
md5 = require('js-md5'), md5 = require('js-md5'),
sha512 = require('js-sha512'), sha512 = require('js-sha512'),
logging = require('./logging'),
fs = require('fs'), fs = require('fs'),
session = require('express-session'), session = require('express-session'),
SQLiteStore = require('connect-sqlite3')(session), SQLiteStore = require('connect-sqlite3')(session),
@ -12,21 +13,19 @@ const express = require('express'),
config = require('../config.json'), config = require('../config.json'),
utils = require('../lib/utils'); utils = require('../lib/utils');
let logger = require('winston');
exports.setLogger = function (newLogger) {
logger = newLogger;
};
exports.WebServer = class { exports.WebServer = class {
constructor(port) { constructor(port) {
this.app = express(); this.app = express();
this.server = null; this.server = null;
this.port = port; this.port = port;
this.schema = buildSchema(fs.readFileSync('./web/graphql/schema.graphql', 'utf-8')); this.schema = buildSchema(fs.readFileSync('./lib/api/graphql/schema.gql', 'utf-8'));
this.root = {}; this.root = {};
this._logger = new logging.Logger(this);
} }
/**
* Configures express by setting properties and middleware.
*/
configureExpress() { configureExpress() {
this.app.set('view engine', 'pug'); this.app.set('view engine', 'pug');
this.app.set('trust proxy', 1); this.app.set('trust proxy', 1);
@ -38,7 +37,7 @@ exports.WebServer = class {
this.app.use(require('cors')()); this.app.use(require('cors')());
this.app.use(session({ this.app.use(session({
store: new SQLiteStore({dir: './data', db: 'sessions.db'}), store: new SQLiteStore({dir: './data', db: 'sessions.db'}),
secret: config.webservice.sessionSecret, secret: config.webinterface.sessionSecret,
resave: false, resave: false,
saveUninitialized: true, saveUninitialized: true,
cookie: {secure: 'auto'}, cookie: {secure: 'auto'},
@ -65,7 +64,7 @@ exports.WebServer = class {
} else { } else {
let user = await this.maindb.get('SELECT * FROM users WHERE username = ? AND password = ?', [req.body.username, req.body.password]); let user = await this.maindb.get('SELECT * FROM users WHERE username = ? AND password = ?', [req.body.username, req.body.password]);
if (!user) { 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!'}); res.render('login', {msg: 'Login failed!'});
} else { } else {
req.session.user = user; req.session.user = user;
@ -86,7 +85,7 @@ exports.WebServer = class {
this.app.use('/graphql', graphqlHTTP({ this.app.use('/graphql', graphqlHTTP({
schema: this.schema, schema: this.schema,
rootValue: this.root, rootValue: this.root,
graphiql: config.webservice.graphiql || false graphiql: config.webinterface.graphiql || false
})); }));
} }
@ -95,19 +94,19 @@ exports.WebServer = class {
*/ */
start() { start() {
this.configureExpress(); this.configureExpress();
if (config.webservice.https && config.webservice.https.enabled) { if (config.webinterface.https && config.webinterface.https.enabled) {
let sslKey = null; let sslKey = null;
let sslCert = null; let sslCert = null;
if (config.webservice.https.keyFile) if (config.webinterface.https.keyFile)
sslKey = fs.readFileSync(config.webservice.https.keyFile, 'utf-8'); sslKey = fs.readFileSync(config.webinterface.https.keyFile, 'utf-8');
if (config.webservice.https.certFile) if (config.webinterface.https.certFile)
sslCert = fs.readFileSync(config.webservice.https.certFile, 'utf-8'); sslCert = fs.readFileSync(config.webinterface.https.certFile, 'utf-8');
if (sslKey && sslCert) { 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); this.server = require('https').createServer({key: sslKey, cert: sslCert}, this.app);
} else { } 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); this.server = require('http').createServer(this.app);
} }
} else { } else {
@ -172,7 +171,7 @@ exports.WebServer = class {
.slice(args.offset, args.offset + args.first) .slice(args.offset, args.offset + args.first)
.map(async (x) => new Guild(x, await objects.getGuildHandler(x)))); .map(async (x) => new Guild(x, await objects.getGuildHandler(x))));
} catch (err) { } catch (err) {
logger.error(err.stack); this._logger.error(err.stack);
return null; return null;
} }
@ -197,8 +196,8 @@ exports.WebServer = class {
return dcGuilds.filter((x) => { return dcGuilds.filter((x) => {
let gh = objects.guildHandlers[x.id]; let gh = objects.guildHandlers[x.id];
if (gh) if (gh)
if (gh.dj) if (gh.musicPlayer)
return gh.dj.playing; return gh.musicPlayer.playing;
else else
return false; return false;
else else
@ -269,16 +268,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 { class MusicPlayer {
constructor(musicDj) { constructor(musicPlayer) {
this.dj = musicDj; this.musicPlayer = musicPlayer;
this.quality = musicDj.quality; this.quality = musicPlayer.quality;
} }
queue(args) { queue(args) {
let queue = this.dj.queue.map((x) => { let queue = this.musicPlayer.queue.map((x) => {
return { return {
id: generateID(['Media', x.url]), id: generateID(['Media', x.url]),
name: x.title, name: x.title,
@ -294,35 +293,35 @@ class DJ {
} }
get playing() { get playing() {
return this.dj.playing; return this.musicPlayer.playing;
} }
get connected() { get connected() {
return this.dj.connected; return this.musicPlayer.connected;
} }
get paused() { get paused() {
return this.dj.disp? this.dj.disp.paused : false; return this.musicPlayer.disp? this.musicPlayer.disp.paused : false;
} }
get queueCount() { get queueCount() {
return this.dj.queue.length; return this.musicPlayer.queue.length;
} }
get songStartTime() { get songStartTime() {
return this.dj.disp.player.streamingData.startTime; return this.musicPlayer.disp.player.streamingData.startTime;
} }
get volume() { get volume() {
return this.dj.volume; return this.musicPlayer.volume;
} }
get repeat() { get repeat() {
return this.dj.repeat; return this.musicPlayer.repeat;
} }
get currentSong() { get currentSong() {
let x = this.dj.current; let x = this.musicPlayer.current;
return { return {
id: generateID(['Media', x.url]), id: generateID(['Media', x.url]),
name: x.title, name: x.title,
@ -332,7 +331,7 @@ class DJ {
} }
get voiceChannel() { get voiceChannel() {
return this.dj.voiceChannel.name; return this.musicPlayer.voiceChannel.name;
} }
} }
@ -355,7 +354,7 @@ class Guild {
this.ready = guildHandler.ready; this.ready = guildHandler.ready;
this.prSaved = null; this.prSaved = null;
this.guildHandler = guildHandler; 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() { async querySaved() {
@ -473,5 +472,6 @@ class LogEntry {
this.message = entry.message; this.message = entry.message;
this.timestamp = entry.timestamp; this.timestamp = entry.timestamp;
this.level = entry.level; this.level = entry.level;
this.module = entry.module || entry.m || 'DEFAULT';
} }
} }

@ -0,0 +1,177 @@
const fetch = require('node-fetch'),
fsx = require('fs-extra'),
yaml = require('js-yaml'),
queryPath = './lib/api/graphql/AnilistApi',
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
});

@ -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
}
}

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

@ -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,58 @@
anime_search:
name: alAnime
permission: all
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.
category: AniList
response:
not_found: >
I couldn't find the anime you were searching for :(
anime_staff_search:
name: alAnimeStaff
permission: all
usage: alAnimeStaff [search query]
description: >
Searches [AniList.co](https://anilist.co) for the anime *title* or *id* and returns all staff members.
category: AniList
response:
not_found: >
I couldn't find the anime you were searching for :(
manga_search:
name: alManga
permission: all
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.
category: AniList
response:
not_found: >
I couldn't find the manga you were searching for :(
staff_search:
name: alStaff
permission: all
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.
category: AniList
response:
not_found: >
I couldn't find the staff member you were searching for :(
character_search:
name: alCharacter
permission: all
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.
category: AniList
response:
not_found: >
I couldn't find the character member you were searching for :(

@ -0,0 +1,306 @@
const cmdLib = require('../../CommandLib'),
anilistApi = require('../../api/AnilistApi'),
location = './lib/commands/AnilistApiCommands';
/**
* 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.addNonemptyField(
'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.templateFile = location + '/AniListCommandsTemplate.yaml';
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,44 @@
about:
name: about
description: >
Shows information about this Discord Bot.
permission: all
category: Info
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:
name: ping
description: >
Answers with the current average ping of the bot.
permission: all
category: Info
uptime:
name: uptime
description: >
Answers with the uptime of the bot.
permission: all
category: Info
guilds:
name: guilds
description: >
Answers with the number of guilds the bot has joined
permission: owner
category: Info
help:
name: help
description: >
Shows help for bot ocmmands.
permission: all
category: Info
embed_color: 0xffffff
args:
- command

@ -0,0 +1,114 @@
const cmdLib = require('../../CommandLib'),
utils = require('../../utils'),
location = './lib/commands/InfoCommands';
/**
* 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.templateFile = location + '/InfoCommandsTemplate.yaml';
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 register(commandHandler) {
await this._loadTemplate();
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);
}
})
);
// register commands
commandHandler
.registerCommand(about)
.registerCommand(ping)
.registerCommand(uptime)
.registerCommand(guilds)
.registerCommand(help);
}
}
Object.assign(exports, {
'module': InfoCommandModule
});

@ -0,0 +1,178 @@
play:
name: play
description: >
Adds the url to the YouTube video or YouTube playlist into the queue.
permission: all
category: Music
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 viceo or Playlist.
no_voicechannel: >
You need to join a VoiceChannel to request media playback.
play_next:
name: playnext
description: >
Adds the url to the YouTube video or YouTube playlist into the queue as
next playing song.
permission: all
category: Music
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 viceo or Playlist.
no_voicechannel: >
You need to join a VoiceChannel to request media playback.
join:
name: join
description: >
Joins the VoiceChannel you are in.
permission: all
category: Music
response:
no_voicechannel: >
You need to join a VoiceChannel for me to join.
stop:
name: stop
description: >
Stops the media playback and leaves the VoiceChannel.
permission: all
category: Music
response:
success: >
Stopped music playback.
not_playing: >
I'm not playing music at the moment. What do you want me to stop?
pause:
name: pause
description: >
Pauses the media playback.
permission: all
category: Music
response:
success: >
Paused playback.
not_playing: >
I'm not playing music at the moment.
resume:
name: resume
description: >
Resumes the media playback.
permission: all
category: Music
response:
success: >
Resumed playback.
not_playing: >
I'm not playing music at the moment.
skip:
name: skip
description: >
Skips the currently playing song.
permission: all
category: Music
response:
success: >
Skipped to the next song.
not_playing: >
I'm not playing music at the moment.
clear:
name: clear
description: >
Clears the media queue.
permission: musicPlayer
category: Music
response:
success: >
The media queue has been cleared.
media_queue:
name: queue
descriptions: >
Shows the next ten songs in the media queue.
permission: all
category: Music
media_current:
name: np
description: >
Shows the currently playing song.
permission: all
category: Music
response:
not_playing: >
I'm not playing music at the moment.
shuffle:
name: shuffle
description: >
Shuffles the media queue
permission: all
category: Music
response:
success: >
The queue has been shuffled.
toggle_repeat:
name: repeat
description: >
Toggles listening o repeat.
permission: all
category: Music
response:
repeat_true: >
Listening on repeat now!
repeat_false: >
Not listening on repeat anymore.
save_media:
name: savemedia
description: >
Saves the YouTube URL with a specific name.
permission: dj
category: Music
args:
- url
usage: savemedia [url] [name...]
delete_media:
name: deletemedia
description: >
Deletes a saved YouTube URL from saved media.
permission: dj
category: Music
usage: deletemedia [name]
response:
no_name: >
You must provide a name for the media to delete.
saved_media:
name: savedmedia
description: >
Shows all saved YouTube URLs.
permission: all
category: Music
response:
no_saved: >
There are no saved YouTube URLs :(

@ -0,0 +1,318 @@
const cmdLib = require('../../CommandLib'),
utils = require('../../utils'),
config = require('../../../config'),
location = './lib/commands/MusicCommands';
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.templateFile = location + '/MusicCommandsTemplate.yaml';
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);
await gh.musicPlayer.playYouTube(url, next);
} else {
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('SELECT url FROM playlists WHERE name = ?', [url]);
if (!row) {
this._logger.debug('Got invalid url for play command.');
return t.response.url_invalid;
} else {
await this._connectAndPlay(gh, vc, row.url, n);
return t.response.success;
}
} else {
await this._connectAndPlay(gh, vc, url, n);
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/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/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;
})
);
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('SELECT COUNT(*) count FROM playlists WHERE name = ?', [saveName]);
if (!row || row.count === 0)
await gh.db.run('INSERT INTO playlists (name, url) VALUES (?, ?)',
[saveName, k.url]);
else
await gh.db.run('UPDATE playlists SET url = ? WHERE name = ?',
[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('DELETE FROM playlists WHERE name = ?', [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('SELECT name, url FROM playlists');
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]`);
})
);
// 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);
}
}
Object.assign(exports, {
'module': MusicCommandModule
});

@ -0,0 +1,48 @@
save_cmd:
name: savecmd
description: >
Saves a sequence of commands under a new name.
permission: moderator
category: Server Utility
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:
name: deletecmd
description: >
Deletes a saved command.
permission: moderator
category: Server Utility
args:
- name
saved_cmd:
name: savedcmd
description: >
Lists all saved commands.
category: Server Utility
permission: all
response:
no_commands: >
There are no saved commands.
execute:
name: execute
description: >
Executes a saved command.
permission: all
category: Server Utility
args:
- name
response:
not_found: >
The command to be executed couldn't be found.

@ -0,0 +1,133 @@
const cmdLib = require('../../CommandLib'),
location = './lib/commands/ServerUtilityCommands';
/**
* 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.templateFile = location + '/ServerUtilityCommandsTemplate.yaml';
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 row = await gh.db
.get('SELECT COUNT(*) count FROM commands WHERE name = ?', [k.name]);
if (!row || row.count === 0)
await gh.db
.run('INSERT INTO commands (name, command) VALUES (?, ?)', [k.name, JSON.stringify(sequence)]);
else
await await gh.db
.run('UPDATE commands SET command = ? WHERE name = ?', [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('DELETE FROM commands WHERE name = ?', [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('SELECT name, command FROM commands');
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('SELECT command FROM commands WHERE name = ?', [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,44 @@
shutdown:
name: shutdown
description: >
Shuts down the bot.
permission: owner
category: Utility
add_presence:
name: addpresence
description: >
Adds a Rich Presence to the bot.
permission: owner
category: Utility
usage: addpresence [presence]
rotate_presence:
name: rotatepresence
description: >
Forces a presence rotation
permission: owner
category: Utility
create_user:
name: createuser
description: >
Creates a user for the webinterface.
permission: owner
category: Utility
args:
- username
- password
- scope
bugreport:
name: bug
description: >
Get information about where to report bugs.
permission: all
category: Utility
response:
title: >
You want to report a bug?
bug_report: >
Please report your bugs [here](https://github.com/Trivernis/discordbot.js/issues)

@ -0,0 +1,111 @@
const cmdLib = require('../../CommandLib'),
location = './lib/commands/UtilityCommands';
/**
* 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.templateFile = location + '/UtilityCommandsTemplate.yaml';
this._bot = opts.bot;
this._config = opts.config;
}
async register(commandHandler) {
await this._loadTemplate();
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('INSERT INTO presences (text) VALUES (?)', [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
});

@ -1,49 +1,46 @@
const cmd = require('./cmd'), const music = require('./MusicLib'),
music = require('./music'),
utils = require('./utils'), utils = require('./utils'),
config = require('../config.json'), config = require('../config.json'),
sqliteAsync = require('./sqliteAsync'), sqliteAsync = require('./sqliteAsync'),
logging = require('./logging'),
fs = require('fs-extra'), fs = require('fs-extra'),
servercmd = require('../commands/servercommands'),
Discord = require('discord.js'),
waterfall = require('promise-waterfall'),
dataDir = config.dataPath || './data'; 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 * The Guild Handler handles guild settings and data.
* @type {GuildHandler} * @type {GuildHandler}
*/ */
exports.GuildHandler = class { class GuildHandler {
constructor(guild, prefix) {
constructor(guild) {
this.guild = guild; this.guild = guild;
this.dj = null; this._logger = new logging.Logger(`${this.constructor.name}@${this.guild}`);
this.mention = false; this.musicPlayer = new music.MusicPlayer(null);
this.prefix = prefix || config.prefix; this._logger.silly('Initialized Guild Handler');
this.servant = new cmd.Servant(this.prefix); this._votes = {};
} }
/**
* Initializes the database
* @returns {Promise<void>}
*/
async initDatabase() { async initDatabase() {
this._logger.silly('Initializing Database');
await fs.ensureDir(dataDir + '/gdb'); await fs.ensureDir(dataDir + '/gdb');
this.db = new sqliteAsync.Database(`${dataDir}/gdb/${this.guild}.db`); this.db = new sqliteAsync.Database(`${dataDir}/gdb/${this.guild}.db`);
await this.db.init(); await this.db.init();
logger.debug(`Connected to the database for ${this.guild}`); this._logger.debug(`Connected to the database for ${this.guild}`);
await this.createTables(); this._logger.debug('Creating Databases');
// register commands await this._createTables();
this.registerCommands();
} }
/** /**
* Destroys the guild handler * Destroys the guild handler
*/ */
destroy() { destroy() {
this.dj.stop(); this._logger.debug('Ending musicPlayer');
this.musicPlayer.stop();
this._logger.debug('Ending Database');
this.db.close(); this.db.close();
} }
@ -53,7 +50,7 @@ exports.GuildHandler = class {
* messages - logs all messages send on the server * messages - logs all messages send on the server
* playlists - save playlists to play them later * playlists - save playlists to play them later
*/ */
async createTables() { async _createTables() {
await this.db.run(`${utils.sql.tableExistCreate} messages ( await this.db.run(`${utils.sql.tableExistCreate} messages (
${utils.sql.pkIdSerial}, ${utils.sql.pkIdSerial},
creation_timestamp DATETIME NOT NULL, creation_timestamp DATETIME NOT NULL,
@ -61,294 +58,45 @@ exports.GuildHandler = class {
author_name VARCHAR(128), author_name VARCHAR(128),
content TEXT NOT NULL content TEXT NOT NULL
)`); )`);
this._logger.silly('Created Table messages');
await this.db.run(`${utils.sql.tableExistCreate} playlists ( await this.db.run(`${utils.sql.tableExistCreate} playlists (
${utils.sql.pkIdSerial}, ${utils.sql.pkIdSerial},
name VARCHAR(32) UNIQUE NOT NULL, name VARCHAR(32) UNIQUE NOT NULL,
url VARCHAR(255) NOT NULL url VARCHAR(255) NOT NULL
)`); )`);
this._logger.silly('Created Table playlists');
await this.db.run(`${utils.sql.tableExistCreate} commands ( await this.db.run(`${utils.sql.tableExistCreate} commands (
${utils.sql.pkIdSerial}, ${utils.sql.pkIdSerial},
name VARCHAR(32) UNIQUE NOT NULL, name VARCHAR(32) UNIQUE NOT NULL,
command VARCHAR(255) NOT NULL command VARCHAR(255) NOT NULL
)`); )`);
this._logger.silly('Created Table commands');
} }
/** /**
* Answers a message via mention if mentioning is active or with just sending it to the same channel. * Sets the vote counter for a command up and adds the user.
* @param msg * @param command {String}
* @param answer * @param user {String}
*/
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) { updateCommandVote(command, user) {
if (!this.dj.connected) { if (!this._votes[command])
await this.dj.connect(vc); this._votes[command] = {count: 0, users: []};
await this.dj.playYouTube(url, next); if (!this._votes[command].users.includes(user)) {
} else { this._votes[command].count++;
await this.dj.playYouTube(url, next); this._votes[command].users.push(user);
} }
return this._votes[command];
} }
/** /**
* registers all music commands and initializes a dj * Resets the vote counter and voted users for a command.
* @param command {String}
*/ */
registerCommands() { resetCommandVote(command) {
this.dj = new music.DJ(); this._votes[command] = {count: 0, users: []};
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 Object.assign(exports, {
this.servant.createCommand(servercmd.utils.deletecmd, async (msg, kwargs) => { GuildHandler: GuildHandler
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;
}
});
}
};

@ -1,25 +1,31 @@
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
const winston = require('winston'), const winston = require('winston'),
DailyRotateFile = require('winston-daily-rotate-file'), DailyRotateFile = require('winston-daily-rotate-file'),
args = require('args-parser')(process.argv), 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 * Set console format to simple string format
}), * @type {Format}
consoleLoggingFormat = winston.format.printf(info => { */
return `${info.timestamp} [${info.level}] ${JSON.stringify(info.message)}`; //the logging format for the console 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
loggingFullFormat = winston.format.combine( });
winston.format.splat(),
/**
* Set full format to combination of formats
* @type {Format}
*/
const loggingFullFormat = winston.format.combine(
winston.format.timestamp({ winston.format.timestamp({
format: 'YY-MM-DD HH:mm:ss.SSS' format: 'YY-MM-DD HH:mm:ss.SSS'
}), }),
winston.format.json() winston.format.json()
), );
logger = winston.createLogger({ /**
level: winston.config.npm.levels, // logs with npm levels * Define all transports used.
format: loggingFullFormat, * @type {any[]}
transports: [ */
let transports = [
new winston.transports.Console({ new winston.transports.Console({
format: winston.format.combine( format: winston.format.combine(
winston.format.colorize(), winston.format.colorize(),
@ -27,6 +33,7 @@ const winston = require('winston'),
winston.format.timestamp({ winston.format.timestamp({
format: 'YY-MM-DD HH:mm:ss.SSS' format: 'YY-MM-DD HH:mm:ss.SSS'
}), }),
winston.format.label({label: ''}),
consoleLoggingFormat consoleLoggingFormat
), ),
level: args.loglevel || 'info' level: args.loglevel || 'info'
@ -45,18 +52,71 @@ const winston = require('winston'),
maxFiles: '30d', maxFiles: '30d',
json: true json: true
}) })
] ];
});
/** /**
* A function to return the logger that has been created after appending an exception handler * Define the logger
* @returns {Object} * @type {winston.Logger}
*/ */
exports.getLogger = function () { let logger = winston.createLogger({
level: winston.config.npm.levels,
format: loggingFullFormat,
transports: transports
});
// Define exception handling
logger.exceptions.handle( logger.exceptions.handle(
new winston.transports.File({ new winston.transports.File({
filename: './.log/exceptions.log' filename: './.log/exceptions.log'
}) })
); );
return logger;
}; 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
});

@ -11,7 +11,7 @@ function noOp() {
* @param {String} filename The name of the file. * @param {String} filename The name of the file.
* @return {String} A string that represents the file-extension. * @return {String} A string that represents the file-extension.
*/ */
exports.getExtension = function (filename) { function getFileExtension (filename) {
if (!filename) if (!filename)
return null; return null;
try { try {
@ -24,7 +24,7 @@ exports.getExtension = function (filename) {
console.error(error); console.error(error);
return null; return null;
} }
}; }
/** /**
* Walks the path to the objects attribute and returns the value. * Walks the path to the objects attribute and returns the value.
@ -32,7 +32,7 @@ exports.getExtension = function (filename) {
* @param attributePath * @param attributePath
* @returns {undefined/Object} * @returns {undefined/Object}
*/ */
exports.objectDeepFind = function (object, attributePath) { function objectDeepFind (object, attributePath) {
let current = object, let current = object,
paths = attributePath.split('.'); paths = attributePath.split('.');
for (let path of paths) for (let path of paths)
@ -42,7 +42,7 @@ exports.objectDeepFind = function (object, attributePath) {
return undefined; return undefined;
return current; return current;
}; }
/** /**
* Shuffles an array with Fisher-Yates Shuffle * Shuffles an array with Fisher-Yates Shuffle
@ -74,7 +74,7 @@ exports.shuffleArray = function(array) {
* @constructor * @constructor
* @author CanyonCasa & Pier-Luc Gendreau on StackOverflow * @author CanyonCasa & Pier-Luc Gendreau on StackOverflow
*/ */
exports.Cleanup = function Cleanup(callback) { function Cleanup(callback) {
// attach user callback to the process event emitter // attach user callback to the process event emitter
// if no callback, it will still exit gracefully on Ctrl-C // if no callback, it will still exit gracefully on Ctrl-C
@ -98,9 +98,9 @@ exports.Cleanup = function Cleanup(callback) {
console.log(e.stack); console.log(e.stack);
process.exit(99); process.exit(99);
}); });
}; }
exports.getSplitDuration = function (duration) { function getSplitDuration (duration) {
let dur = duration; let dur = duration;
let retObj = {}; let retObj = {};
retObj.milliseconds = dur % 1000; retObj.milliseconds = dur % 1000;
@ -113,11 +113,23 @@ exports.getSplitDuration = function (duration) {
dur = Math.floor(dur / 24); dur = Math.floor(dur / 24);
retObj.days = dur; retObj.days = dur;
return retObj; 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 */ /* Classes */
exports.YouTube = class { class YouTube {
/** /**
* returns if an url is a valid youtube url (without checking for an entity id) * returns if an url is a valid youtube url (without checking for an entity id)
* @param url * @param url
@ -208,9 +220,9 @@ exports.YouTube = class {
let id = exports.YouTube.getVideoIdFromUrl(url); let id = exports.YouTube.getVideoIdFromUrl(url);
return id? `https://i3.ytimg.com/vi/${id}/maxresdefault.jpg` : null; return id? `https://i3.ytimg.com/vi/${id}/maxresdefault.jpg` : null;
} }
}; }
exports.ConfigVerifyer = class { class ConfigVerifyer {
/** /**
* @param confObj * @param confObj
* @param required {Array} the attributes that are required for the bot to work * @param required {Array} the attributes that are required for the bot to work
@ -243,7 +255,7 @@ exports.ConfigVerifyer = class {
logger.error(`Missing required Attributes ${this.missingAttributes.join(', ')}`); logger.error(`Missing required Attributes ${this.missingAttributes.join(', ')}`);
} }
}; }
exports.sql = { exports.sql = {
tableExistCreate: 'CREATE TABLE IF NOT EXISTS', tableExistCreate: 'CREATE TABLE IF NOT EXISTS',
@ -258,3 +270,14 @@ exports.logLevels = {
'warn': 3, 'warn': 3,
'error:': 4 'error:': 4
}; };
Object.assign(exports, {
resolveNestedPromise: resolveNestedPromise,
YouTube: YouTube,
ConfigVerifyer: ConfigVerifyer,
getSplitDuration: getSplitDuration,
getExtension: getFileExtension,
getFileExtension: getFileExtension,
objectDeepFind: objectDeepFind,
Cleanup: Cleanup
});

@ -1,6 +1,6 @@
{ {
"name": "discordbot", "name": "discordbot",
"version": "1.0.0", "version": "0.9.1",
"scripts": { "scripts": {
"start": "node bot.js", "start": "node bot.js",
"test": "mocha --exit", "test": "mocha --exit",
@ -23,30 +23,33 @@
"graphql": "14.1.1", "graphql": "14.1.1",
"js-md5": "0.7.3", "js-md5": "0.7.3",
"js-sha512": "0.8.0", "js-sha512": "0.8.0",
"node-fetch": "^2.3.0",
"node-sass": "4.11.0", "node-sass": "4.11.0",
"opusscript": "0.0.6", "opusscript": "0.0.6",
"promise-waterfall": "0.1.0", "promise-waterfall": "0.1.0",
"pug": "2.0.3", "pug": "2.0.3",
"sqlite3": "4.0.6", "sqlite3": "4.0.6",
"winston": "3.2.1", "winston": "3.2.1",
"winston-daily-rotate-file": "3.6.0", "winston-daily-rotate-file": "3.8.0",
"youtube-playlist-info": "1.1.2", "youtube-playlist-info": "1.1.2",
"ytdl-core": "0.29.1" "ytdl-core": "0.29.1",
"js-yaml": "latest"
}, },
"devDependencies": { "devDependencies": {
"assert": "1.4.1", "assert": "1.4.1",
"chai": "4.2.0", "chai": "4.2.0",
"mocha": "5.2.0", "mocha": "6.0.2",
"nyc": "13.2.0", "nyc": "13.3.0",
"rewire": "4.0.1", "rewire": "4.0.1",
"sinon": "7.2.3", "sinon": "7.2.6",
"eslint-plugin-graphql": "3.0.3", "eslint-plugin-graphql": "3.0.3",
"eslint": "5.13.0", "eslint": "5.15.0",
"eslint-plugin-promise": "4.0.1" "eslint-plugin-promise": "4.0.1"
}, },
"eslintConfig": { "eslintConfig": {
"parserOptions": { "parserOptions": {
"ecmaVersion": 2018 "ecmaVersion": 2018,
"sourceType": "module"
}, },
"env": { "env": {
"node": true, "node": true,

@ -3,7 +3,6 @@ const mockobjects = require('./mockobjects.js'),
sinon = require('sinon'), sinon = require('sinon'),
assert = require('assert'), assert = require('assert'),
rewire = require('rewire'); rewire = require('rewire');
let Discord = require("discord.js");
mockobjects.mockLogger = { mockobjects.mockLogger = {
error: () => {}, error: () => {},
@ -180,7 +179,7 @@ describe('lib/utils', function() {
describe('lib/music', function() { describe('lib/music', function() {
const music = rewire('../lib/music'); const music = rewire('../lib/MusicLib');
const Readable = require('stream').Readable; const Readable = require('stream').Readable;
music.__set__("logger", mockobjects.mockLogger); music.__set__("logger", mockobjects.mockLogger);
@ -201,10 +200,10 @@ describe('lib/music', function() {
"api": {} "api": {}
}); });
describe('#DJ', function () { describe('#MusicPlayer', function () {
it('connects to a VoiceChannel', function (done) { it('connects to a VoiceChannel', function (done) {
let dj = new music.DJ(mockobjects.mockVoicechannel); let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(()=> { dj.connect().then(()=> {
assert(dj.connected); assert(dj.connected);
done(); done();
@ -212,7 +211,7 @@ describe('lib/music', function() {
}); });
it('listens on Repeat', 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.current = {'url': '', 'title': ''};
dj.listenOnRepeat = true; dj.listenOnRepeat = true;
@ -222,7 +221,7 @@ describe('lib/music', function() {
it('plays Files', function (done) { it('plays Files', function (done) {
let dj = new music.DJ(mockobjects.mockVoicechannel); let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => { dj.connect().then(() => {
dj.playFile(); dj.playFile();
assert(dj.playing); assert(dj.playing);
@ -231,7 +230,7 @@ describe('lib/music', function() {
}); });
it('plays YouTube urls', function (done) { it('plays YouTube urls', function (done) {
let dj = new music.DJ(mockobjects.mockVoicechannel); let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => { dj.connect().then(() => {
dj.playYouTube('http://www.youtube.com/watch?v=ABCDEFGHIJK'); dj.playYouTube('http://www.youtube.com/watch?v=ABCDEFGHIJK');
setTimeout(() => { setTimeout(() => {
@ -242,7 +241,7 @@ describe('lib/music', function() {
}); });
it('gets the video name', function (done) { 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) => { dj.getVideoName('http://www.youtube.com/watch?v=ABCDEFGHIJK').then((name) => {
assert(name === 'test'); assert(name === 'test');
done(); done();
@ -250,7 +249,7 @@ describe('lib/music', function() {
}); });
it('sets the volume', function(done) { it('sets the volume', function(done) {
let dj = new music.DJ(mockobjects.mockVoicechannel); let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => { dj.connect().then(() => {
dj.playFile(); dj.playFile();
dj.setVolume(100); dj.setVolume(100);
@ -260,7 +259,7 @@ describe('lib/music', function() {
}); });
it('pauses playback', function(done) { it('pauses playback', function(done) {
let dj = new music.DJ(mockobjects.mockVoicechannel); let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => { dj.connect().then(() => {
dj.playFile(); dj.playFile();
dj.pause(); dj.pause();
@ -269,7 +268,7 @@ describe('lib/music', function() {
}); });
it('resumes playback', function(done) { it('resumes playback', function(done) {
let dj = new music.DJ(mockobjects.mockVoicechannel); let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => { dj.connect().then(() => {
dj.playFile(); dj.playFile();
dj.resume(); dj.resume();
@ -278,7 +277,7 @@ describe('lib/music', function() {
}); });
it('stops playback', function(done) { it('stops playback', function(done) {
let dj = new music.DJ(mockobjects.mockVoicechannel); let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => { dj.connect().then(() => {
dj.playFile(); dj.playFile();
assert(dj.playing); assert(dj.playing);
@ -289,7 +288,7 @@ describe('lib/music', function() {
}); });
it('skips songs', function(done) { it('skips songs', function(done) {
let dj = new music.DJ(mockobjects.mockVoicechannel); let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => { dj.connect().then(() => {
dj.playYouTube('http://www.youtube.com/watch?v=ABCDEFGHIJK'); dj.playYouTube('http://www.youtube.com/watch?v=ABCDEFGHIJK');
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) { it('returns a playlist', function(done) {
let dj = new music.DJ(mockobjects.mockVoicechannel); let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => { dj.connect().then(() => {
dj.queue = [{ dj.queue = [{
'title': 'title', 'title': 'title',
@ -315,7 +314,7 @@ describe('lib/music', function() {
}); });
it('clears the queue', function(done) { it('clears the queue', function(done) {
let dj = new music.DJ(mockobjects.mockVoicechannel); let dj = new music.MusicPlayer(mockobjects.mockVoicechannel);
dj.connect().then(() => { dj.connect().then(() => {
dj.queue = [{ dj.queue = [{
'title': 'title', 'title': 'title',
@ -330,163 +329,63 @@ describe('lib/music', function() {
}); });
}); });
describe('lib/cmd', function() { describe('lib/CommandLib', function() {
const cmd = rewire('../lib/cmd'); let cmdLib = require('../lib/CommandLib');
cmd.__set__("logger", mockobjects.mockLogger);
describe('#Servant', function() { describe('Answer', function() {
it('creates commands', function() { it('evaluates synchronous', async function() {
let servant = new cmd.Servant(''); let answer = new cmdLib.Answer(() => 'RESPONSE');
servant.createCommand(mockobjects.mockCommand, mockobjects.mockCommand.textReply); assert((await answer.evaluate({}, {}, {})) === 'RESPONSE');
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('removes commands', function() { it('evaluates asynchronous', async function() {
let servant = new cmd.Servant(''); let answer = new cmdLib.Answer(async () => {
servant.createCommand(mockobjects.mockCommand, mockobjects.mockCommand.textReply); return 'RESPONSE';
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
}
});
assert(spy.called);
});
}); });
assert((await answer.evaluate({}, {}, {})) === 'RESPONSE');
})
}); });
describe('lib/guilding', function*() { // deactivated because of problems with sqlite3 and rewire describe('Command', function() {
const guilding = rewire('../lib/guilding');
const servercommands = require('../commands/servercommands');
guilding.__set__("sqliteAsync", null);
guilding.__set__("fs-extra", {
ensureDir: async() => {
return true;
}
});
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); it('answers with Answer objects', async function() {
music.__set__("yttl", (id, cb) => { let cmd = new cmdLib.Command({
cb(null, 'test'); name: 'TEST',
}); prefix: '',
music.__set__('ytdl', () => { description: 'TESTDESCRIPTION',
let s = new Readable(); permission: 'TESTPERM',
s._read = () => {}; usage: 'TESTUSAGE'
s.push('chunkofdataabc'); },new cmdLib.Answer(() => 'RESPONSE'));
s.push(null); assert((await cmd.answer({}, {}, {})) === 'RESPONSE');
return s;
}); });
let gh = new guilding.GuildHandler('test', '~');
gh.db = new mockobjects.MockDatabase('', ()=>{}); it('generates help for itself', function() {
gh.ready = true; let cmd = new cmdLib.Command({
gh.dj = new music.DJ(mockobjects.mockVoicechannel); name: 'TEST',
gh.connectAndPlay(mockobjects.mockVoicechannel, 'test', false).then(() => { prefix: '',
done(); description: 'TESTDESCRIPTION',
permission: 'TESTPERM',
usage: 'TESTUSAGE'
},new cmdLib.Answer(() => 'RESPONSE'));
assert(cmd.help);
})
}); });
}); });
it('handles all servercommands', function() { describe('lib/MessageLib', function() {
let gh = new guilding.GuildHandler('test', '~'); let msgLib = require('../lib/MessageLib');
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);
}
describe('MessageHandler', function() {
assert(msgSpy.called); 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);
}); });
}); });
}); });

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

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

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

Loading…
Cancel
Save