Merge pull request #110 from Trivernis/develop

Develop
master
Trivernis 5 years ago committed by GitHub
commit ce821ae088
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -4,7 +4,43 @@ 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.11.0] - 2019-03-03
## [Unreleased]
### Fixed
- bug where the bot counts itself when calculating needed votes to skip/stop music
- bug on the `ExtendedRichEmbed` where `addField` and `setDescription` throws an error when the value is null or undefined
- bug on `AnilistApiCommands` where the `RichCharacterInfo` uses a nonexistent function of the `ExtendedRichEmbed`
- bug on`AnilistApi` where the `.gql` files couldn't be found.
- Typo in changelog
- bug on `~np` message that causes the player to crash
- database handler using release on pooled client
### Changed
- name of MiscCommands module from `TemplateCommandModule` to `MiscCommandModule`
- moved everything in `lib` to subfolders with the same name as the files and renamed the files to `index.js`
- renamed libfolders to lowercase and removed the lib suffix
- moved commands outside of `lib`
- switched from opusscript to node-opus for voice
- all hard coded sql statements to generic sql generation
- MusicPlayer to extend the default EventEmitter
- MessageHandler to accept instances of Response and redirect events to it
### Added
- Utility classes for generic SQL Statements
- logging of unrejected promises
- database class for database abstraction (lib/database)
- config entry for `database` with supported values `postgresql` or `sqlite`
- config entry for `databaseConnection` for postgresql (`user`, `host`, `password`, `database`, `port`)
- table `settings` to each guild to store guild specific settings
- table `messages` to main database where messages are stored for statistical analysis and bug handling
- ExtendedEventEmitter class in lib/utils/extended-events.js
- Response object that allows the registration of events for messages
- Handling of error event for every VoiceConnection
### Removed
- `~volume` command because volume can't be controlled anymore
- volume functions and properties from the MusicPlayer
## [0.11.0-beta] - 2019-03-03
### Changed
- template Files to name `template.yaml`
- loading template file form CommandModule property `templateFile` to loading the `template.yaml` file from the `_templateDir` property (still supporting loading form templateFile)
@ -19,12 +55,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Removed
- `ExtendedRichEmbed.addNonemptyField` because the overide of `.addField` does the same
## [0.10.1] - 2019-03-03
## [0.10.1]-beta - 2019-03-03
### Changed
- Bugfix on RichEmbed not returning itself on addField and setDescription because of method overide
- AniList CommandModule bug fix on `~alCharacter` not returning voice actor names
## [0.10.0] - 2019-03-03
## [0.10.0-beta] - 2019-03-03
### Added
- AniList api commands powered by [AniList.co](https://www.anilist.co)
- MessageHandler - handles all incoming messages, parses the syntax, executes the syntax and handles rate limits

@ -3,6 +3,14 @@ discordbot [![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blu
A bot that does the discord thing.
Installation
---
You can easily install everything with npm `npm i`. If you run into an error see the [discord.js installation guide](https://github.com/discordjs/discord.js#installation) or open an issue. If you run into an error with `ffmpeg-binaries` try using nodejs `v10.15.0`
Running
---
`node bot.node [--token=<DiscordBotToken>] [--ytapi=<GoogleApiKey>] [--owner=<DiscordTag>] [--prefix=<Char>] [--game=<String>] [-i=<Boolen>]`
The arguments are optional because the token and youtube-api-key that the bot needs to run can also be defined in the config.json in the bot's directory:
@ -40,6 +48,14 @@ The arguments are optional because the token and youtube-api-key that the bot ne
"commandSettings": {
"maxSequenceParallel": 5, // the maximum number of commands executed in parallel
"maxSequenceSerial": 10 // the maximum number of commands executed in serial
},
"database": "postgres or sqlite", // choose one
"databaseConnection": {
"user": "USERNAME",
"host": "HOSTNAME OR IP",
"password": "DATABASE USERPASSWORD",
"database": "BOT DATABASE NAME", // the database needs to exist
"port": 5432 // the port of the database server
}
}
```

@ -1,12 +1,12 @@
const Discord = require("discord.js"),
fs = require('fs-extra'),
logging = require('./lib/logging'),
msgLib = require('./lib/MessageLib'),
guilding = require('./lib/guilding'),
logging = require('./lib/utils/logging'),
msgLib = require('./lib/message'),
guilding = require('./lib/guilds'),
utils = require('./lib/utils'),
config = require('./config.json'),
args = require('args-parser')(process.argv),
sqliteAsync = require('./lib/sqliteAsync'),
dblib = require('./lib/database'),
authToken = args.token || config.api.botToken,
prefix = args.prefix || config.prefix || '~',
gamepresence = args.game || config.presence;
@ -69,24 +69,24 @@ class Bot {
if (config.webinterface && config.webinterface.enabled)
await this.initializeWebserver();
this.logger.verbose('Registering commands');
await this.messageHandler.registerCommandModule(require('./lib/commands/AnilistApiCommands').module, {});
await this.messageHandler.registerCommandModule(require('./lib/commands/UtilityCommands').module, {
await this.messageHandler.registerCommandModule(require('./commands/AnilistApiCommands').module, {});
await this.messageHandler.registerCommandModule(require('./commands/UtilityCommands').module, {
bot: this,
config: config
});
await this.messageHandler.registerCommandModule(require('./lib/commands/InfoCommands').module, {
await this.messageHandler.registerCommandModule(require('./commands/InfoCommands').module, {
client: this.client,
messageHandler: this.messageHandler
});
await this.messageHandler.registerCommandModule(require('./lib/commands/MusicCommands').module, {
await this.messageHandler.registerCommandModule(require('./commands/MusicCommands').module, {
getGuildHandler: async (g) => await this.getGuildHandler(g)
});
await this.messageHandler.registerCommandModule(require('./lib/commands/ServerUtilityCommands').module, {
await this.messageHandler.registerCommandModule(require('./commands/ServerUtilityCommands').module, {
getGuildHandler: async (g) => await this.getGuildHandler(g),
messageHandler: this.messageHandler,
config: config
});
await this.messageHandler.registerCommandModule(require('./lib/commands/MiscCommands').module, {});
await this.messageHandler.registerCommandModule(require('./commands/MiscCommands').module, {});
this.registerEvents();
}
@ -112,13 +112,22 @@ class Bot {
this.logger.debug('Checking for ./data/ existence');
await fs.ensureDir('./data');
this.logger.verbose('Connecting to main database');
this.maindb = new sqliteAsync.Database('./data/main.db');
await this.maindb.init();
await this.maindb.run(`${utils.sql.tableExistCreate} presences (
${utils.sql.pkIdSerial},
text VARCHAR(255) UNIQUE NOT NULL
)`);
this.maindb = new dblib.Database('main');
await this.maindb.initDatabase();
let sql = this.maindb.sql;
await this.maindb.run(sql.createTableIfNotExists('presences', [
sql.templates.idcolumn,
new dblib.Column('text', sql.types.getVarchar(255),
[sql.constraints.unique, sql.constraints.notNull])
]));
await this.maindb.run(sql.createTableIfNotExists('messages', [
sql.templates.idcolumn,
new dblib.Column('server', sql.types.getVarchar(255)),
new dblib.Column('channel', sql.types.getVarchar(255)),
new dblib.Column('username', sql.types.getVarchar(255), [sql.constraints.notNull]),
new dblib.Column('message', sql.types.text),
new dblib.Column('timestamp', sql.types.datetime, [sql.constraints.notNull, sql.default('NOW()')])
]));
this.logger.debug('Loading Presences...');
await this.loadPresences();
}
@ -128,7 +137,7 @@ class Bot {
*/
async initializeWebserver() {
this.logger.verbose('Importing weblib');
weblib = require('./lib/WebLib');
weblib = require('./lib/web');
this.logger.verbose('Creating WebServer');
this.webServer = new weblib.WebServer(config.webinterface.port || 8080);
this.logger.debug('Setting Reference Objects to webserver');
@ -145,33 +154,37 @@ class Bot {
/**
* If a data/presences.txt exists, it is read and each line is put into the presences array.
* Each line is also stored in the main.db database. After the file is completely read, it get's deleted.
* Each line is also stored in the dbot-main.db database. After the file is completely read, it get's deleted.
* Then the data is read from the database and if the presence doesn't exist in the presences array, it get's
* pushed in there. If the presences.txt file does not exist, the data is just read from the database. In the end
* a rotator is created that rotates the presence every configured duration.
*/
async loadPresences() {
let sql = this.maindb.sql;
if (await fs.pathExists('./data/presences.txt')) {
let lineReader = require('readline').createInterface({
input: require('fs').createReadStream('./data/presences.txt')
});
lineReader.on('line', (line) => {
this.maindb.run('INSERT INTO presences (text) VALUES (?)', [line], (err) => {
if (err)
this.logger.warn(err.message);
});
this.presences.push(line);
this.maindb.begin();
lineReader.on('line', async (line) => {
try {
await this.maindb.query(sql.insert('presences', {text: sql.parameter(1)}), [line]);
this.presences.push(line);
} catch (err) {
this.logger.warn(err.message);
this.logger.debug(err.stack);
}
});
await this.maindb.commit();
this.rotator = this.client.setInterval(() => this.rotatePresence(),
config.presence_duration || 360000);
await fs.unlink('./data/presences.txt');
let rows = await this.maindb.all('SELECT text FROM presences');
let rows = await this.maindb.all(sql.select('presences', false, ['text']));
for (let row of rows)
if (!(row[0] in this.presences))
this.presences.push(row.text);
} else {
let rows = await this.maindb.all('SELECT text FROM presences');
let rows = await this.maindb.all(sql.select('presences', false, ['text']));
for (let row of rows)
this.presences.push(row.text);
this.rotator = this.client.setInterval(() => this.rotatePresence(),
@ -238,6 +251,7 @@ class Bot {
if (!this.guildHandlers[guild.id]) {
let newGuildHandler = new guilding.GuildHandler(guild);
await newGuildHandler.initDatabase();
await newGuildHandler.applySettings();
this.guildHandlers[guild.id] = newGuildHandler;
}
return this.guildHandlers[guild.id];
@ -248,10 +262,17 @@ class Bot {
// Executing the main function
if (typeof require !== 'undefined' && require.main === module) {
let logger = new logging.Logger('MAIN-init');
process.on('unhandledRejection', err => {
// Will print "unhandledRejection err is not defined"
logger.warn(err.message);
logger.debug(err.stack);
});
logger.info("Starting up... ");
logger.debug('Calling constructor...');
let discordBot = new Bot();
logger.debug('Initializing services...');
discordBot.initServices().then(() => {
logger.debug('Starting Bot...');
discordBot.start().catch((err) => { //eslint-disable-line promise/no-nesting

@ -1,5 +1,5 @@
/* template index.js. Doesn't implement actual commands */
const cmdLib = require('../../CommandLib'); // required for command objects
const cmdLib = require('../../lib/command'); // required for command objects
/**
* A description what the command module includes and why. Doesn't need to list commands but explains

@ -1,5 +1,5 @@
const cmdLib = require('../../CommandLib'),
anilistApi = require('../../api/AnilistApi');
const cmdLib = require('../../lib/command'),
anilistApi = require('../../lib/api/AniListApi');
/**
* The AniList commands are all commands that interact with the anilist api.
@ -144,7 +144,7 @@ class RichCharacterInfo extends cmdLib.ExtendedRichEmbed {
.replace(/~!.*?!~/g, '')
.replace(/\n\n\n/g, ''));
if (characterInfo.media && characterInfo.media.edges)
this.addNonemptyField(
this.addField(
'Media Appeareance',
characterInfo.media.edges.map(x => {
let media = x.node;

@ -1,6 +1,6 @@
const cmdLib = require('../../CommandLib'),
const cmdLib = require('../../lib/command'),
fsx = require('fs-extra'),
utils = require('../../utils');
utils = require('../../lib/utils');
/**
* Info commands provide information about the bot. These informations are

@ -1,5 +1,5 @@
/* template index.js. Doesn't implement actual commands */
const cmdLib = require('../../CommandLib');
const cmdLib = require('../../lib/command');
/**
* Several commands that are that special that they can't be included in any other module.
@ -15,7 +15,7 @@ function delay(seconds) {
});
}
class TemplateCommandModule extends cmdLib.CommandModule {
class MiscCommandModule extends cmdLib.CommandModule {
constructor() {
super(cmdLib.CommandScopes.Global);
@ -78,5 +78,5 @@ class TemplateCommandModule extends cmdLib.CommandModule {
Object.assign(exports, {
module: TemplateCommandModule
module: MiscCommandModule
});

@ -1,6 +1,6 @@
const cmdLib = require('../../CommandLib'),
utils = require('../../utils'),
config = require('../../../config');
const cmdLib = require('../../lib/command'),
utils = require('../../lib/utils'),
config = require('../../config');
function checkPermission(msg, rolePerm) {
if (!rolePerm || ['all', 'any', 'everyone'].includes(rolePerm))
@ -42,9 +42,9 @@ class MusicCommandModule extends cmdLib.CommandModule {
async _connectAndPlay(gh, vc, url, next) {
if (!gh.musicPlayer.connected) {
await gh.musicPlayer.connect(vc);
await gh.musicPlayer.playYouTube(url, next);
return await gh.musicPlayer.playYouTube(url, next);
} else {
await gh.musicPlayer.playYouTube(url, next);
return await gh.musicPlayer.playYouTube(url, next);
}
}
@ -68,17 +68,24 @@ class MusicCommandModule extends cmdLib.CommandModule {
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]);
let row = await gh.db.get(gh.db.sql.select('playlists', false, ['url'],
gh.db.sql.where('name', '=', gh.db.sql.parameter(1))), [url]);
if (!row) {
this._logger.debug('Got invalid url for play command.');
return t.response.url_invalid;
} else {
await this._connectAndPlay(gh, vc, row.url, n);
return t.response.success;
let songcount = await this._connectAndPlay(gh, vc, row.url, n);
if (songcount)
return `Added ${songcount} songs to the queue.`;
else
return t.response.success;
}
} else {
await this._connectAndPlay(gh, vc, url, n);
return t.response.success;
let songcount = await this._connectAndPlay(gh, vc, url, n);
if (songcount)
return `Added ${songcount} songs to the queue.`;
else
return t.response.success;
}
}
@ -117,7 +124,7 @@ class MusicCommandModule extends cmdLib.CommandModule {
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);
let neededVotes = Math.ceil((vc.members.size - 1) / 2);
if (neededVotes <= votes.count || checkPermission(m, 'dj')) {
this._logger.debug(`Vote passed. ${votes.count} out of ${neededVotes} for stop or permission granted`);
@ -167,7 +174,7 @@ class MusicCommandModule extends cmdLib.CommandModule {
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);
let neededVotes = Math.ceil((vc.members.size - 1) / 2);
if (neededVotes <= votes.count || checkPermission(m, 'dj')) {
this._logger.debug(`Vote passed. ${votes.count} out of ${neededVotes} for skip or permission granted`);
@ -221,6 +228,28 @@ class MusicCommandModule extends cmdLib.CommandModule {
.setColor(0x00aaff);
else
return this.template.media_current.response.not_playing;
}, async (response) => {
let message = response.message;
let gh = await this._getGuildHandler(message.guild);
if (message.editable && gh.musicPlayer) {
let next = (song) => {
message.edit('', new cmdLib.ExtendedRichEmbed('Now playing:')
.setDescription(`[${song.title}](${song.url})`)
.setImage(utils.YouTube.getVideoThumbnailUrlFromUrl(song.url))
.setColor(0x00aaff));
if (message.id !== message.channel.lastMessageID) {
gh.musicPlayer.removeListener('next', next);
message.delete();
}
};
gh.musicPlayer.on('next', next);
gh.musicPlayer.on('stop', () => {
gh.musicPlayer.off('next', next);
message.delete();
});
response.on('delete', () => gh.musicPlayer.off('next', next));
}
})
);
@ -249,14 +278,15 @@ class MusicCommandModule extends cmdLib.CommandModule {
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]);
let row = await gh.db.get(gh.db.sql.select('playlists', false,
[gh.db.sql.count('*')], gh.db.sql.where('name', '=', gh.db.sql.parameter(1))), [saveName]);
if (!row || Number(row.count) === 0)
await gh.db.run(gh.db.sql.insert('playlists',
{name: gh.db.sql.parameter(1), url: gh.db.sql.parameter(2)}), [saveName, k.url]);
else
await gh.db.run('UPDATE playlists SET url = ? WHERE name = ?',
[k.url, saveName]);
await gh.db.run(gh.db.sql.update('playlists',
{url: gh.db.sql.parameter(1)},
gh.db.sql.where('name', '=', gh.db.sql.parameter(2))), [k.url, saveName]);
return `Saved song/playlist as ${saveName}`;
})
);
@ -268,7 +298,7 @@ class MusicCommandModule extends cmdLib.CommandModule {
if (!s) {
return this.template.delete_media.response.no_name;
} else {
await gh.db.run('DELETE FROM playlists WHERE name = ?', [s]);
await gh.db.run(gh.db.sql.delete('playlists', gh.db.sql.where('name', '=', gh.db.sql.parameter(1))), [s]);
return `Deleted ${s} from saved media`;
}
})
@ -279,7 +309,7 @@ class MusicCommandModule extends cmdLib.CommandModule {
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');
let rows = await gh.db.all(gh.db.sql.select('playlists', false, ['name', 'url']));
for (let row of rows)
response += `[${row.name}](${row.url})\n`;
@ -292,6 +322,36 @@ class MusicCommandModule extends cmdLib.CommandModule {
})
);
let volume = new cmdLib.Command(
this.template.volume,
new cmdLib.Answer(async (m, k) => {
let volume = Number(k.volume);
if (volume && volume <= 100 && volume >= 0) {
let gh = await this._getGuildHandler(m.guild);
gh.musicPlayer.setVolume(Math.round(volume)/100);
await gh.db.setSetting('musicPlayerVolume', Math.round(volume)/100);
return `Set music volume to **${volume}**`;
} else {
return this.template.volume.response.invalid;
}
})
);
let quality = new cmdLib.Command(
this.template.quality,
new cmdLib.Answer(async (m, k) => {
let allowed = ['highest', 'lowest', 'highestaudio', 'lowestaudio'];
if (allowed.includes(k.quality)) {
let gh = await this._getGuildHandler(m.guild);
gh.musicPlayer.quality = k.quality;
await gh.db.setSetting('musicPlayerQuality', k.quality);
return `Set music quality to **${k.quality}**`;
} else {
return this.template.quality.response.invalid;
}
})
);
// register commands
commandHandler
.registerCommand(play)
@ -308,10 +368,12 @@ class MusicCommandModule extends cmdLib.CommandModule {
.registerCommand(toggleRepeat)
.registerCommand(saveMedia)
.registerCommand(deleteMedia)
.registerCommand(savedMedia);
.registerCommand(savedMedia)
.registerCommand(volume)
.registerCommand(quality);
}
}
Object.assign(exports, {
'module': MusicCommandModule
module: MusicCommandModule
});

@ -17,7 +17,7 @@ play:
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.
You need to provide an URL to a YouTube video or Playlist.
no_voicechannel: >
You need to join a VoiceChannel to request media playback.
@ -37,7 +37,7 @@ play_next:
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.
You need to provide an URL to a YouTube video or Playlist.
no_voicechannel: >
You need to join a VoiceChannel to request media playback.
@ -168,3 +168,28 @@ saved_media:
response:
no_saved: >
There are no saved YouTube URLs :(
volume:
<<: *METADATA
name: volume
permission: dj
args:
- volume
description: >
Sets the volume of the Music Player.
response:
invalid: >
The value you entered is an invalid volume.
quality:
<<: *METADATA
name: quality
permission: owner
args:
- quality
description: >
Sets the quality of the music of the Music Player.
The setting will be applied on the next song.
response:
invalid: >
You entered an invalid quality value.

@ -1,4 +1,4 @@
const cmdLib = require('../../CommandLib');
const cmdLib = require('../../lib/command');
/**
* This command module includes utility commands for the server.
@ -66,14 +66,15 @@ class ServerUtilityCommandModule extends cmdLib.CommandModule {
} 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)]);
let sql = gh.db.sql;
let row = await gh.db.get(sql.select('commands', false, [sql.count('*')],
sql.where('name', '=', sql.parameter(1))), [k.name]);
if (!row || Number(row.count) === 0)
await gh.db.run(sql.insert('commands', {name: sql.parameter(1), command: sql.parameter(2)}),
[k.name, JSON.stringify(sequence)]);
else
await await gh.db
.run('UPDATE commands SET command = ? WHERE name = ?', [JSON.stringify(sequence), k.name]);
await gh.db.run(sql.update('commands', {command: sql.parameter(1)}, sql.where('name', '=', sql.parameter(2))),
[JSON.stringify(sequence), k.name]);
}
})
);
@ -82,7 +83,7 @@ class ServerUtilityCommandModule extends cmdLib.CommandModule {
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]);
await gh.db.run(gh.db.sql.delete('commands', gh.db.sql.where('name', '=', gh.db.sql.parameter(1)), ), [k.name]);
return `Deleted command ${k.name}`;
})
);
@ -93,7 +94,7 @@ class ServerUtilityCommandModule extends cmdLib.CommandModule {
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');
let rows = await gh.db.all(gh.db.sql.select('commands', ['name', 'command']));
if (rows.length === 0)
return this.template.saved_cmd.response.no_commands;
else
@ -107,8 +108,8 @@ class ServerUtilityCommandModule extends cmdLib.CommandModule {
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]);
let row = await gh.db.get(gh.db.sql.select('commands',false, ['command'],
gh.db.sql.where('name', '=', gh.db.sql.parameter(1))), [k.name]);
if (row)
await this._messageHandler
.executeCommandSequence(JSON.parse(row.command), m);

@ -1,4 +1,4 @@
const cmdLib = require('../../CommandLib');
const cmdLib = require('../../lib/command');
/**
* Utility commands are all commands that allow the user to control the behaviour of the
@ -24,12 +24,13 @@ class UtilityCommandModule extends cmdLib.CommandModule {
async register(commandHandler) {
await this._loadTemplate();
let sql = this._bot.maindb.sql;
let addPresence = new cmdLib.Command(
this.template.add_presence,
new cmdLib.Answer(async (m, k, s) => {
this._bot.presences.push(s);
await this._bot.maindb.run('INSERT INTO presences (text) VALUES (?)', [s]);
await this._bot.maindb.run(sql.insert('presences', {text: sql.parameter(1)}), [s]);
return `Added Presence \`${s}\``;
})
);

@ -1,7 +1,7 @@
const fetch = require('node-fetch'),
fsx = require('fs-extra'),
yaml = require('js-yaml'),
queryPath = './lib/api/graphql/AnilistApi',
queryPath = __dirname + '/graphql',
alApiEndpoint = 'https://graphql.anilist.co';
async function getFragments() {

@ -1,24 +1,44 @@
const Discord = require('discord.js'),
yaml = require('js-yaml'),
fsx = require('fs-extra'),
logging = require('./logging'),
config = require('../config.json'),
utils = require('./utils');
logging = require('../utils/logging'),
config = require('../../config.json'),
xevents = require('../utils/extended-events'),
utils = require('../utils');
const scopes = {
'Global': 0,
'User': 1,
'Guild': 2
};
const scopes = new utils.Enum([
'Global',
'User',
'Guild'
]);
/**
* The answer message object that is used for easyer access to events.
*/
class Response extends xevents.ExtendedEventEmitter {
/**
* Constructor.
* @param content
*/
constructor(content) {
super();
this.content = content;
this.message = null;
}
}
class Answer {
/**
* Creates an new Answer object with _func as answer logic.
* @param func
* @param func {function} - the function to evaluate the answer
* @param [onSent] {function} - executed when the response was sent
*/
constructor(func) {
constructor(func, onSent) {
this._func = func;
this.listeners = onSent? {sent: onSent} : {};
this.lastResponse = null;
}
/**
@ -27,14 +47,28 @@ class Answer {
* @param message
* @param kwargs
* @param argsString
* @returns {Promise<*>}
* @returns {Promise<Response>}
*/
async evaluate(message, kwargs, argsString) {
let result = this._func(message, kwargs, argsString);
if (result instanceof Promise)
return await utils.resolveNestedPromise(result);
return this._getResponseInstance(await utils.resolveNestedPromise(result));
else
return result;
return this._getResponseInstance(result);
}
/**
* Returns a response instance with listeners attached if defined.
* @param responseContent
* @returns {Response}
* @private
*/
_getResponseInstance(responseContent) {
this.lastResponse = new Response(responseContent);
if (this.listeners)
this.lastResponse.addListeners(this.listeners);
return this.lastResponse;
}
}
@ -66,7 +100,7 @@ class Command {
* @param message {Discord.Message}
* @param kwargs {JSON}
* @param argsString {String} The raw argument string.
* @returns {String}
* @returns {Response}
*/
async answer(message, kwargs, argsString) {
return await this.answObj.evaluate(message, kwargs, argsString);
@ -104,7 +138,7 @@ class CommandHandler {
* Handles the command and responds to the message.
* @param commandMessage {String}
* @param message {Discord.Message}
* @returns {Boolean | String | Promise<String|Discord.RichEmbed>}
* @returns {Response | Promise<Response>}
*/
handleCommand(commandMessage, message) {
this._logger.debug(`Handling command ${commandMessage}`);
@ -129,14 +163,14 @@ class CommandHandler {
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";
return new Response("You don't have permission for this command");
} else {
this._logger.silly(`Command ${commandName} not found.`);
return false;
return null;
}
} else {
this._logger.silly(`No prefix found in command ${commandName}`);
return false;
return null;
}
}
@ -239,13 +273,16 @@ class ExtendedRichEmbed extends Discord.RichEmbed {
* @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, '');
if (croppedValue && croppedValue.replace(/\n/g, '').length > 0)
super.setDescription(croppedValue);
if (value) {
let croppedValue = value;
if (value.substring)
croppedValue = value.substring(0, 1024);
if (croppedValue.length < value.length && croppedValue.replace)
croppedValue = croppedValue.replace(/\n.*$/g, '');
if (croppedValue && croppedValue.replace
&& croppedValue.replace(/\n/g, '').length > 0)
super.setDescription(croppedValue);
}
return this;
}
@ -255,15 +292,16 @@ class ExtendedRichEmbed extends Discord.RichEmbed {
* @param value
*/
addField(name, value) {
let croppedValue = value;
if (value.substring)
croppedValue = value.substring(0, 1024);
if (croppedValue.length < value.length)
if (name && value) {
let croppedValue = value;
if (value.substring)
croppedValue = value.substring(0, 1024);
if (croppedValue && croppedValue.length < value.length && croppedValue.replace)
croppedValue = croppedValue.replace(/\n.*$/g, '');
if (name && croppedValue
&& croppedValue.replace(/\n/g, '').length > 0 && name.replace(/\n/g, '').length > 0)
super.addField(name, croppedValue);
if (croppedValue && croppedValue.replace
&& croppedValue.replace(/\n/g, '').length > 0 && name.replace(/\n/g, '').length > 0)
super.addField(name, croppedValue);
}
return this;
}
}

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

@ -1,102 +0,0 @@
const music = require('./MusicLib'),
utils = require('./utils'),
config = require('../config.json'),
sqliteAsync = require('./sqliteAsync'),
logging = require('./logging'),
fs = require('fs-extra'),
dataDir = config.dataPath || './data';
/**
* The Guild Handler handles guild settings and data.
* @type {GuildHandler}
*/
class GuildHandler {
constructor(guild) {
this.guild = guild;
this._logger = new logging.Logger(`${this.constructor.name}@${this.guild}`);
this.musicPlayer = new music.MusicPlayer(null);
this._logger.silly('Initialized Guild Handler');
this._votes = {};
}
/**
* Initializes the database
* @returns {Promise<void>}
*/
async initDatabase() {
this._logger.silly('Initializing Database');
await fs.ensureDir(dataDir + '/gdb');
this.db = new sqliteAsync.Database(`${dataDir}/gdb/${this.guild}.db`);
await this.db.init();
this._logger.debug(`Connected to the database for ${this.guild}`);
this._logger.debug('Creating Databases');
await this._createTables();
}
/**
* Destroys the guild handler
*/
destroy() {
this._logger.debug('Ending musicPlayer');
this.musicPlayer.stop();
this._logger.debug('Ending Database');
this.db.close();
}
/**
* Creates all tables needed in the Database.
* These are at the moment:
* messages - logs all messages send on the server
* playlists - save playlists to play them later
*/
async _createTables() {
await this.db.run(`${utils.sql.tableExistCreate} messages (
${utils.sql.pkIdSerial},
creation_timestamp DATETIME NOT NULL,
author VARCHAR(128) NOT NULL,
author_name VARCHAR(128),
content TEXT NOT NULL
)`);
this._logger.silly('Created Table messages');
await this.db.run(`${utils.sql.tableExistCreate} playlists (
${utils.sql.pkIdSerial},
name VARCHAR(32) UNIQUE NOT NULL,
url VARCHAR(255) NOT NULL
)`);
this._logger.silly('Created Table playlists');
await this.db.run(`${utils.sql.tableExistCreate} commands (
${utils.sql.pkIdSerial},
name VARCHAR(32) UNIQUE NOT NULL,
command VARCHAR(255) NOT NULL
)`);
this._logger.silly('Created Table commands');
}
/**
* Sets the vote counter for a command up and adds the user.
* @param command {String}
* @param user {String}
*/
updateCommandVote(command, user) {
if (!this._votes[command])
this._votes[command] = {count: 0, users: []};
if (!this._votes[command].users.includes(user)) {
this._votes[command].count++;
this._votes[command].users.push(user);
}
return this._votes[command];
}
/**
* Resets the vote counter and voted users for a command.
* @param command {String}
*/
resetCommandVote(command) {
this._votes[command] = {count: 0, users: []};
}
}
Object.assign(exports, {
GuildHandler: GuildHandler
});

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

@ -1,7 +1,7 @@
const cmdLib = require('./CommandLib'),
config = require('../config.json'),
const cmdLib = require('../command'),
config = require('../../config.json'),
Discord = require('discord.js'),
logging = require('./logging'),
logging = require('../utils/logging'),
promiseWaterfall = require('promise-waterfall');
/* eslint no-useless-escape: 0 */
@ -23,6 +23,7 @@ class MessageHandler {
this.guildCmdHandler = new cmdLib.CommandHandler(config.prefix,
cmdLib.CommandScopes.Guild);
this.userRates = {};
this.registeredResponses = {};
this._registerEvents();
}
@ -68,7 +69,8 @@ class MessageHandler {
* @private
*/
_registerEvents() {
this.logger.debug('Registering message event...');
this.logger.debug('Registering message events...');
this.discordClient.on('message', async (msg) => {
this.logger.verbose(`<${msg.guild? msg.channel.name+'@'+msg.guild.name : 'PRIVATE'}> ${msg.author.tag}: ${msg.content}`);
if (msg.author !== this.discordClient.user
@ -81,6 +83,32 @@ class MessageHandler {
this.logger.debug('Executed command sequence');
}
});
this.discordClient.on('messageReactionAdd', (messageReaction, user) => {
let responseInstance = this.registeredResponses[messageReaction.message];
if (responseInstance)
responseInstance.emit('reactionAdd', messageReaction, user);
});
this.discordClient.on('messageReactionRemove', (messageReaction, user) => {
let responseInstance = this.registeredResponses[messageReaction.message];
if (responseInstance)
responseInstance.emit('reactionRemove', messageReaction, user);
});
this.discordClient.on('messageReactionRemoveAll', (message) => {
let responseInstance = this.registeredResponses[message];
if (responseInstance)
responseInstance.emit('reactionRemoveAll', message);
});
this.discordClient.on('messageDelete', (message) => {
let responseInstance = this.registeredResponses[message];
if (responseInstance) {
responseInstance.on('delete', message);
delete this.registeredResponses[message];
}
});
}
/**
@ -124,9 +152,9 @@ class MessageHandler {
this.logger.silly(`globalResult: ${globalResult}, scopeResult: ${scopeResult}`);
if (scopeResult)
this._answerMessage(message, scopeResult);
await this._answerMessage(message, scopeResult);
else if (globalResult)
this._answerMessage(message, globalResult);
await this._answerMessage(message, globalResult);
} catch (err) {
this.logger.verbose(err.message);
this.logger.silly(err.stack);
@ -149,16 +177,25 @@ class MessageHandler {
/**
* Answers
* @param message {Discord.Message}
* @param answer {String | Discord.RichEmbed}
* @param response {Response}
* @private
*/
_answerMessage(message, answer) {
this.logger.debug(`Sending answer ${answer}`);
if (answer)
if (answer instanceof Discord.RichEmbed)
message.channel.send('', answer);
async _answerMessage(message, response) {
this.logger.debug(`Sending answer ${response.content}`);
if (response && response.content) {
let responseMessage = null;
if (response.content instanceof Discord.RichEmbed)
responseMessage = await message.channel.send('', response.content);
else
message.channel.send(answer);
responseMessage = await message.channel.send(response.content);
if (response.hasListeners)
this.registeredResponses[responseMessage] = response;
response.message = responseMessage;
response.emit('sent', response);
}
}
/**

@ -1,17 +1,23 @@
const ytdl = require("ytdl-core"),
ypi = require('youtube-playlist-info'),
yttl = require('get-youtube-title'),
config = require('../config.json'),
utils = require('./utils.js'),
logging = require('./logging'),
config = require('../../config.json'),
utils = require('../utils'),
xevents = require('../utils/extended-events'),
logging = require('../utils/logging'),
ytapiKey = config.api.youTubeApiKey;
/**
* The Music Player class is used to handle music playing tasks on Discord Servers (Guilds).
* @type {MusicPlayer}
*/
class MusicPlayer {
class MusicPlayer extends xevents.ExtendedEventEmitter {
/**
* Constructor
* @param [voiceChannel] {Discord.VoiceChannel}
*/
constructor(voiceChannel) {
super();
this.conn = null;
this.disp = null;
this.queue = [];
@ -20,10 +26,11 @@ class MusicPlayer {
this.repeat = false;
this.volume = 0.5;
this.voiceChannel = voiceChannel;
this.quality = 'lowest';
this.exitTimeout = null;
this._logger = new logging.Logger(this);
this._logger.silly('Initialized Music Player');
config.music ? this.quality = config.music.quality || 'lowest' : this.quality = 'lowest';
config.music ? this.liveBuffer = config.music.liveBuffer || 10000 : 10000;
}
/**
@ -43,8 +50,14 @@ class MusicPlayer {
this.voiceChannel = voiceChannel;
this._logger.verbose(`Connecting to voiceChannel ${this.voiceChannel.name}`);
let connection = await this.voiceChannel.join();
connection.on('error', (err) => {
this._logger.error(err.message);
this._logger.debug(err.stack);
});
this._logger.info(`Connected to Voicechannel ${this.voiceChannel.name}`);
this.conn = connection;
this.emit('connected');
}
/**
@ -55,6 +68,7 @@ class MusicPlayer {
this.repeat = value;
if (this.current)
this.queue.push(this.current);
this.emit('listenOnRepeat', this.repeat);
}
/**
@ -123,7 +137,7 @@ class MusicPlayer {
* If the url is a playlist, the videos of the playlist are fetched and put
* in the queue. For each song the title is saved in the queue too.
* @param url {String}
* @param playnext {Boolean}
* @param [playnext] {Boolean}
*/
async playYouTube(url, playnext) {
let plist = utils.YouTube.getPlaylistIdFromUrl(url);
@ -134,7 +148,7 @@ class MusicPlayer {
let firstSongTitle = null;
try {
firstSongTitle = await this.getVideoName(firstSong);
} catch(err) {
} catch (err) {
if (err.message !== 'Not found') {
this._logger.warn(err.message);
this._logger.debug(err.stack);
@ -151,35 +165,47 @@ class MusicPlayer {
this.queue.push({'url': vurl, 'title': await this.getVideoName(vurl)}); //eslint-disable-line no-await-in-loop
} catch (err) {
if (err.message !== 'Not found') {
this._logger.warn(err.message);
this._logger.debug(err.stack);
this._logger.verbose(err.message);
this._logger.silly(err.stack);
}
}
}
this._logger.debug(`Added ${playlistItems.length} songs to the queue`);
return playlistItems.length;
} else if (!this.playing || !this.disp) {
this._logger.debug(`Playing ${url}`);
this.current = ({'url': url, 'title': await this.getVideoName(url)});
this.disp = this.conn.playStream(ytdl(url,
{filter: 'audioonly', quality: this.quality, liveBuffer: config.music.livePuffer || 20000}),
{volume: this.volume});
this.disp.on('end', (reason) => { // end event triggers the next song to play when the reason is not stop
if (reason !== 'stop') {
this.playing = false;
if (this.repeat)
this.queue.push(this.current);
let toggleNext = () => {
if (this.queue.length > 0) {
this.disp = null;
this.current = this.queue.shift();
this.emit('next', this.current);
this.playYouTube(this.current.url).catch((err) => this._logger.warn(err.message));
} else {
this.stop();
this.current = null;
if (this.queue.length > 0) {
this.current = this.queue.shift();
if (this.repeat) // listen on repeat
this.queue.push(this.current);
this.playYouTube(this.current.url).catch((err) => this._logger.warn(err.message));
} else {
this.stop();
}
this.playing = false;
}
});
this.playing = true;
};
try {
this.disp = this.conn.playStream(await ytdl(url,
{filter: 'audioonly', quality: this.quality, liveBuffer: this.liveBuffer}, {volume: this.volume}));
this.disp.on('error', (err) => {
this._logger.error(err.message);
this._logger.debug(err.stack);
});
this.disp.on('end', (reason) => { // end event triggers the next song to play when the reason is not stop
if (reason !== 'stop')
toggleNext();
});
this.playing = true;
} catch (err) {
this._logger.verbose(err.message);
this._logger.silly(err.stack);
toggleNext();
}
} else {
this._logger.debug(`Added ${url} to the queue`);
if (playnext)
@ -193,7 +219,7 @@ class MusicPlayer {
/**
* Gets the name of the YouTube Video at url
* @param url {String}
* @returns {Promise<>}
* @returns {Promise}
*/
getVideoName(url) {
return new Promise((resolve, reject) => {
@ -214,12 +240,9 @@ class MusicPlayer {
*/
setVolume(percentage) {
this._logger.verbose(`Setting volume to ${percentage}`);
if (this.disp !== null) {
this.volume = percentage;
this.volume = percentage;
if (this.disp !== null)
this.disp.setVolume(percentage);
} else {
this._logger.warn("No dispatcher found.");
}
}
/**
@ -229,7 +252,7 @@ class MusicPlayer {
this._logger.verbose("Pausing music...");
if (this.disp !== null)
this.disp.pause();
else
else
this._logger.warn("No dispatcher found");
}
@ -241,7 +264,7 @@ class MusicPlayer {
this._logger.verbose("Resuming music...");
if (this.disp !== null)
this.disp.resume();
else
else
this._logger.warn("No dispatcher found");
}
@ -274,6 +297,7 @@ class MusicPlayer {
} catch (error) {
this._logger.verbose(JSON.stringify(error));
}
this.emit('stop');
}
/**
@ -284,7 +308,9 @@ class MusicPlayer {
skip() {
this._logger.debug("Skipping song");
if (this.disp !== null) {
this.disp.end();
let disp = this.disp;
this.disp = null;
disp.end();
} else {
this.playing = false;
if (this.queue.length > 0) {
@ -297,6 +323,7 @@ class MusicPlayer {
this.stop();
}
}
this.emit('skip', this.current);
}
/**
@ -312,6 +339,7 @@ class MusicPlayer {
*/
shuffle() {
this.queue = utils.shuffleArray(this.queue);
this.emit('shuffle');
}
/**
@ -319,6 +347,7 @@ class MusicPlayer {
*/
clear() {
this.queue = [];
this.emit('clear');
}
}

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

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

@ -47,7 +47,7 @@ function objectDeepFind (object, attributePath) {
/**
* Shuffles an array with Fisher-Yates Shuffle
* @param array
* @returns {Array}#
* @returns {Array}
*/
exports.shuffleArray = function(array) {
let currentIndex = array.length, temporaryValue, randomIndex;
@ -129,6 +129,18 @@ async function resolveNestedPromise (promise) {
/* Classes */
class Enum {
/**
* Constructor.
* @param symbols {Array<String>}
*/
constructor(symbols) {
for (let symbol in symbols)
this[symbol] = symbols;
Object.freeze(this);
}
}
class YouTube {
/**
* returns if an url is a valid youtube url (without checking for an entity id)
@ -279,5 +291,6 @@ Object.assign(exports, {
getExtension: getFileExtension,
getFileExtension: getFileExtension,
objectDeepFind: objectDeepFind,
Cleanup: Cleanup
Cleanup: Cleanup,
Enum: Enum
});

@ -4,21 +4,22 @@ const express = require('express'),
compression = require('compression'),
md5 = require('js-md5'),
sha512 = require('js-sha512'),
logging = require('./logging'),
logging = require('../utils/logging'),
fs = require('fs'),
session = require('express-session'),
SQLiteStore = require('connect-sqlite3')(session),
dblib = require('../../lib/database'),
bodyParser = require('body-parser'),
compileSass = require('express-compile-sass'),
config = require('../config.json'),
utils = require('../lib/utils');
config = require('../../config.json'),
utils = require('../utils');
exports.WebServer = class {
constructor(port) {
this.app = express();
this.server = null;
this.port = port;
this.schema = buildSchema(fs.readFileSync('./lib/api/graphql/schema.gql', 'utf-8'));
this.schema = buildSchema(fs.readFileSync('./web/api/graphql/schema.gql', 'utf-8'));
this.root = {};
this._logger = new logging.Logger(this);
}
@ -62,7 +63,11 @@ exports.WebServer = class {
if (!req.body.username || !req.body.password) {
res.render('login', {msg: 'Please enter username and password.'});
} else {
let user = await this.maindb.get('SELECT * FROM users WHERE username = ? AND password = ?', [req.body.username, req.body.password]);
let sql = this.maindb.sql;
let user = await this.maindb.get(sql.select('users', false, '*', [
sql.where('username', '=', sql.parameter(1)),
sql.and('password', '=', sql.parameter(2))
]), [req.body.username, req.body.password]);
if (!user) {
this._logger.debug(`User ${req.body.username} failed to authenticate`);
res.render('login', {msg: 'Login failed!'});
@ -150,13 +155,14 @@ exports.WebServer = class {
*/
async setReferenceObjects(objects) {
this.maindb = objects.maindb;
await this.maindb.run(`${utils.sql.tableExistCreate} users (
${utils.sql.pkIdSerial},
username VARCHAR(32) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
scope INTEGER NOT NULL DEFAULT 0
)`);
let sql = this.maindb.sql;
await this.maindb.run(sql.createTableIfNotExists('users', [
sql.templates.idcolumn,
new dblib.Column('username', sql.types.getVarchar(32), [sql.constraints.unique, sql.constraints.notNull]),
new dblib.Column('password', sql.types.getVarchar(255), [sql.constraints.notNull]),
new dblib.Column('token', sql.types.getVarchar(255), [sql.constraints.unique, sql.constraints.notNull]),
new dblib.Column('scope', sql.types.integer, [sql.constraints.notNull, sql.default(0)])
]));
this.root = {
client: {
guilds: async (args) => {
@ -360,7 +366,7 @@ class Guild {
async querySaved() {
if (this.guildHandler.db) {
let saved = [];
let rows = await this.guildHandler.db.all('SELECT * FROM playlists');
let rows = await this.guildHandler.db.all(this.guildHandler.db.sql.select('playlists', false, '*'));
for (let row of rows)
saved.push({
id: generateID(['Media', row.url]),

@ -8,43 +8,47 @@
},
"dependencies": {
"args-parser": "1.1.0",
"body-parser": "1.18.3",
"compression": "1.7.3",
"body-parser": "1.19.0",
"compression": "1.7.4",
"connect-sqlite3": "0.9.11",
"cors": "2.8.5",
"discord.js": "11.4.2",
"discord.js": "^11.5.1",
"express": "4.16.4",
"discord.js": "11.5.1",
"express": "4.17.1",
"express-compile-sass": "4.0.0",
"express-graphql": "0.7.1",
"express-session": "1.15.6",
"express-graphql": "0.9.0",
"express-session": "1.16.2",
"ffmpeg-binaries": "4.0.0",
"fs-extra": "7.0.1",
"fs-extra": "8.1.0",
"get-youtube-title": "1.0.0",
"graphql": "14.1.1",
"graphql": "14.5.8",
"js-md5": "0.7.3",
"js-sha512": "0.8.0",
"node-fetch": "^2.3.0",
"node-sass": "4.11.0",
"opusscript": "0.0.6",
"js-yaml": "latest",
"node-fetch": "2.6.0",
"node-opus": "0.3.2",
"node-sass": "4.12.0",
"pg": "7.12.1",
"promise-waterfall": "0.1.0",
"pug": "2.0.3",
"sqlite3": "4.0.6",
"pug": "2.0.4",
"sqlite3": "4.1.0",
"winston": "3.2.1",
"winston-daily-rotate-file": "3.8.0",
"winston-daily-rotate-file": "4.2.1",
"youtube-playlist-info": "1.1.2",
"ytdl-core": "0.29.1",
"js-yaml": "latest"
"ytdl-core": "^1.0.0",
},
"devDependencies": {
"assert": "1.4.1",
"assert": "2.0.0",
"chai": "4.2.0",
"mocha": "6.0.2",
"nyc": "13.3.0",
"mocha": "6.2.1",
"nyc": "14.1.1",
"rewire": "4.0.1",
"sinon": "7.2.6",
"eslint-plugin-graphql": "3.0.3",
"eslint": "5.15.0",
"eslint-plugin-promise": "4.0.1"
"sinon": "7.5.0",
"eslint-plugin-graphql": "3.1.0",
"eslint": "6.5.1",
"eslint-plugin-promise": "4.2.1",
"opusscript": "0.0.7"
},
"eslintConfig": {
"parserOptions": {

@ -13,7 +13,7 @@ mockobjects.mockLogger = {
};
describe('lib/utils', function() {
const utils = require('../lib/utils.js');
const utils = require('../lib/utils/index.js');
describe('#getSplitDuration', function() {
it('returns an object from milliseconds', function() {
@ -28,7 +28,7 @@ describe('lib/utils', function() {
it('returns the correct extension for a filename', function(done) {
assert(utils.getExtension('test.txt') === '.txt');
assert(utils.getExtension('test.tar.gz') === '.gz');
assert(utils.getExtension('../lib/utils.js') === '.js');
assert(utils.getExtension('../lib/index.js') === '.js');
assert(utils.getExtension('.gitignore') === '.gitignore');
done();
});
@ -179,7 +179,7 @@ describe('lib/utils', function() {
describe('lib/music', function() {
const music = rewire('../lib/MusicLib');
const music = rewire('../lib/music');
const Readable = require('stream').Readable;
music.__set__("logger", mockobjects.mockLogger);
@ -329,8 +329,8 @@ describe('lib/music', function() {
});
});
describe('lib/CommandLib', function() {
let cmdLib = require('../lib/CommandLib');
describe('lib/command', function() {
let cmdLib = require('../lib/command');
describe('Answer', function() {
@ -373,8 +373,8 @@ describe('lib/CommandLib', function() {
});
});
describe('lib/MessageLib', function() {
let msgLib = require('../lib/MessageLib');
describe('lib/message', function() {
let msgLib = require('../lib/message');
describe('MessageHandler', function() {
it ('parses a command syntax', function() {

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

Loading…
Cancel
Save