From ec5ed87c49b1edfa0a50f431cf4f7399846f4f8e Mon Sep 17 00:00:00 2001 From: Trivernis Date: Sun, 10 Mar 2019 18:54:12 +0100 Subject: [PATCH] Generic Database Connection - you can now select one type of database either sqlite or postgres in the config.json (see README) - fixed bugs in generic SQL Statements - added generic database class - changed all sql scripts to generic type - added settings table to guild to manage settings of the guild - added extended database class for the guild with predefined sql statements - added music command volume to change the volume (role dj) - added music command quality to change the musics quality (role owner) --- CHANGELOG.md | 5 + README.md | 8 ++ bot.js | 44 +++--- commands/MusicCommands/index.js | 74 +++++++--- commands/MusicCommands/template.yaml | 25 ++++ commands/ServerUtilityCommands/index.js | 23 +-- commands/UtilityCommands/index.js | 3 +- lib/database/index.js | 177 ++++++++++++++++++++++++ lib/guilding.js | 102 -------------- lib/guilds/index.js | 166 ++++++++++++++++++++++ lib/music/index.js | 37 ++--- lib/utils/genericSql.js | 67 +++++++-- lib/web/index.js | 24 ++-- package.json | 7 +- 14 files changed, 567 insertions(+), 195 deletions(-) create mode 100644 lib/database/index.js delete mode 100644 lib/guilding.js create mode 100644 lib/guilds/index.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b22563a..c90f22a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,12 +18,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 ### Added - state lib with `EventRouter` and `EventGroup` and `Event` classes - Subclasses of EventRouter for client events groupes `Client`, `Channel`, `Message` and `Guild` - 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 ## [0.11.0-beta] - 2019-03-03 ### Changed diff --git a/README.md b/README.md index 7be1884..f15e00c 100644 --- a/README.md +++ b/README.md @@ -48,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 } } ``` diff --git a/bot.js b/bot.js index d2135b8..88d2927 100644 --- a/bot.js +++ b/bot.js @@ -2,11 +2,11 @@ const Discord = require("discord.js"), fs = require('fs-extra'), logging = require('./lib/utils/logging'), msgLib = require('./lib/message'), - guilding = require('./lib/guilding'), + guilding = require('./lib/guilds'), utils = require('./lib/utils'), config = require('./config.json'), args = require('args-parser')(process.argv), - sqliteAsync = require('./lib/utils/sqliteAsync'), + dblib = require('./lib/database'), authToken = args.token || config.api.botToken, prefix = args.prefix || config.prefix || '~', gamepresence = args.game || config.presence; @@ -112,13 +112,14 @@ 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]) + ])); this.logger.debug('Loading Presences...'); await this.loadPresences(); } @@ -145,33 +146,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 +243,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]; diff --git a/commands/MusicCommands/index.js b/commands/MusicCommands/index.js index c805577..7283379 100644 --- a/commands/MusicCommands/index.js +++ b/commands/MusicCommands/index.js @@ -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; } } @@ -249,14 +256,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 +276,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 +287,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 +300,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,7 +346,9 @@ class MusicCommandModule extends cmdLib.CommandModule { .registerCommand(toggleRepeat) .registerCommand(saveMedia) .registerCommand(deleteMedia) - .registerCommand(savedMedia); + .registerCommand(savedMedia) + .registerCommand(volume) + .registerCommand(quality); } } diff --git a/commands/MusicCommands/template.yaml b/commands/MusicCommands/template.yaml index 8f89469..4728d0c 100644 --- a/commands/MusicCommands/template.yaml +++ b/commands/MusicCommands/template.yaml @@ -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. diff --git a/commands/ServerUtilityCommands/index.js b/commands/ServerUtilityCommands/index.js index 78b5620..c3ef5fe 100644 --- a/commands/ServerUtilityCommands/index.js +++ b/commands/ServerUtilityCommands/index.js @@ -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); diff --git a/commands/UtilityCommands/index.js b/commands/UtilityCommands/index.js index 6e624c3..4e9ed9d 100644 --- a/commands/UtilityCommands/index.js +++ b/commands/UtilityCommands/index.js @@ -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}\``; }) ); diff --git a/lib/database/index.js b/lib/database/index.js new file mode 100644 index 0000000..f3e30c2 --- /dev/null +++ b/lib/database/index.js @@ -0,0 +1,177 @@ +const genericSql = require('../utils/genericSql'), + logging = require('../utils/logging'), + config = require('../../config.json'); + +class Database { + /** + * Creates a new database. + * @param name {String} - the name of the database. + */ + constructor(name) { + this.name = name; + this._logger = new logging.Logger(`Database@${name}`); + this._dbType = config.database? config.database : 'sqlite'; + if (this._dbType === 'sqlite') + this.database = new (require('../utils/sqliteAsync')).Database(`./data/${this.name}.db`); + else if (this._dbType === 'postgresql') + this.database = new (require('pg')).Pool({ + user: config.databaseConnection.user, + host: config.databaseConnection.host, + database: config.databaseConnection.database, + password: config.databaseConnection.password, + port: config.databaseConnection.port + }); + this.sql = new genericSql.GenericSql(this._dbType); + } + + /** + * Initializes the database. + * @returns {Promise} + */ + 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} + * @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} + */ + 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} + */ + 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} + */ + 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} + */ + 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} + * @returns {Promise} + */ + 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} - the seperate values + * @returns {Promise} + */ + async all(sql, values) { + this._logger.debug(`Running SQL "${sql}" with values ${values}`); + if (this._dbType === 'sqlite') { + return await this.database.all(sql, values); + } else if (this._dbType === 'postgresql') { + await this.database.query(`SET search_path TO ${this.name.replace(/\W/g, '')};`); + return (await this.database.query({ + text: sql, + values: values + })).rows; + } + } + + /** + * Closes the connection to the database. + */ + close() { + if (this._dbType === 'sqlite') + this.database.close(); + else if (this._dbType === 'postgresql') + this.database.release(); + } +} + +Object.assign(exports, { + Column: genericSql.Column, + Database: Database +}); diff --git a/lib/guilding.js b/lib/guilding.js deleted file mode 100644 index 0199ec4..0000000 --- a/lib/guilding.js +++ /dev/null @@ -1,102 +0,0 @@ -const music = require('./music'), - utils = require('./utils'), - config = require('../config.json'), - sqliteAsync = require('./utils/sqliteAsync'), - logging = require('./utils/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} - */ - 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 -}); diff --git a/lib/guilds/index.js b/lib/guilds/index.js new file mode 100644 index 0000000..baf3f24 --- /dev/null +++ b/lib/guilds/index.js @@ -0,0 +1,166 @@ +const music = require('../music'), + utils = require('../utils'), + config = require('../../config.json'), + dblib = require('../database'), + logging = require('../utils/logging'), + fs = require('fs-extra'), + dataDir = config.dataPath || './data'; + +/** + * GuildDatabase class has abstraction for some sql statements. + */ +class GuildDatabase extends dblib.Database { + + /** + * Constructor. + * @param name + */ + constructor(name) { + super(name); + } + + /** + * Creates all tables needed in the guilds Database. + */ + async createTables() { + let sql = this.sql; + await this.run(sql.createTableIfNotExists('playlists', [ + sql.templates.idcolumn, + new dblib.Column('name', sql.types.getVarchar(32), [sql.constraints.unique, sql.constraints.notNull]), + new dblib.Column('url', sql.types.getVarchar(255), [sql.constraints.notNull]) + ])); + this._logger.silly('Created Table playlists.'); + await this.run(sql.createTableIfNotExists('commands', [ + sql.templates.idcolumn, + new dblib.Column('name', sql.types.getVarchar(32), [sql.constraints.unique, sql.constraints.notNull]), + new dblib.Column('command', sql.types.getVarchar(255), [sql.constraints.notNull]) + ])); + this._logger.silly('Created Table commands.'); + await this.run(sql.createTableIfNotExists('settings', [ + sql.templates.idcolumn, + new dblib.Column('key', sql.types.getVarchar(32), [sql.constraints.unique, sql.constraints.notNull]), + new dblib.Column('value', sql.types.getVarchar(32), []) + ])); + this._logger.silly('Created Table settings.'); + } + + /** + * 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} + */ + 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} + */ + 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} + */ + 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} + */ + 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 +}); diff --git a/lib/music/index.js b/lib/music/index.js index b877e36..114b9af 100644 --- a/lib/music/index.js +++ b/lib/music/index.js @@ -1,6 +1,5 @@ const ytdl = require("ytdl-core"), ypi = require('youtube-playlist-info'), - yttl = require('get-youtube-title'), config = require('../../config.json'), utils = require('../utils/index.js'), logging = require('../utils/logging'), @@ -20,10 +19,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; } /** @@ -151,12 +151,13 @@ 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)}); @@ -164,11 +165,9 @@ class MusicPlayer { this.queue.push(this.current); this.disp = this.conn.playStream(ytdl(url, - {filter: 'audioonly', quality: this.quality, liveBuffer: config.music.livePuffer || 20000}), + {filter: 'audioonly', quality: this.quality, liveBuffer: this.liveBuffer}), {volume: this.volume}); - this.disp.on('debug', (dbgmsg) => this._logger.silly(dbgmsg)); - this.disp.on('error', (err) => { this._logger.error(err.message); this._logger.debug(err.stack); @@ -201,21 +200,12 @@ class MusicPlayer { /** * Gets the name of the YouTube Video at url - * TODO: ytdl.getInfo * @param url {String} - * @returns {Promise<>} + * @returns {String} */ - getVideoName(url) { - return new Promise((resolve, reject) => { - yttl(utils.YouTube.getVideoIdFromUrl(url), (err, title) => { - if (err) { - this._logger.debug(JSON.stringify(err)); - reject(err); - } else { - resolve(title); - } - }); - }); + async getVideoName(url) { + let info = await ytdl.getBasicInfo(url); + return info.title; } /** @@ -224,12 +214,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."); - } } /** diff --git a/lib/utils/genericSql.js b/lib/utils/genericSql.js index c92ac4d..7d5025f 100644 --- a/lib/utils/genericSql.js +++ b/lib/utils/genericSql.js @@ -85,6 +85,16 @@ class GenericTypes { } } + 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} @@ -117,6 +127,22 @@ class GenericSql { 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 '?'; + } } /** @@ -180,7 +206,7 @@ class GenericSql { switch (this.database) { case 'postgresql': case 'sqlite': - return `COUNT(${colname})`; + return `COUNT(${colname}) count`; } } @@ -287,10 +313,12 @@ class GenericSql { * 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} - conditions for the update row selection (WHERE ... [OR ...][AND ...] + * @param conditions {Array|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': @@ -302,16 +330,37 @@ class GenericSql { * 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} - the rows to select - * @param conditions {Array} - conditions for the row selection (WHERE ... [OR ...][AND ...] - * @param operations {Array} - operations on the selected rows + * @param colnames {Array|String} - the rows to select + * @param conditions {Array|String} - conditions for the row selection (WHERE ... [OR ...][AND ...] + * @param operations {Array|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} - 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 `SELECT${distinct? ' ' + distinct : ''} ${colnames.join(' ')} FROM ${table} ${conditions.join(' ')} ${operations.join(' ')}`; + return `DELETE FROM ${table} ${conditions.join(' ')}`; } } } @@ -327,6 +376,8 @@ class Column { this.name = name; this.type = type; this.constraints = constraints || []; + if (!(constraints instanceof Array)) + this.constraints = [constraints]; } /** @@ -338,12 +389,12 @@ class Column { } get sql() { - return `${this.name} ${this.type} ${this.constraints.join(',')}`; + return `${this.name} ${this.type} ${this.constraints.join(' ')}`; } } Object.assign(exports, { GenericSql: GenericSql, - GenericTypes: GenericSql, + GenericTypes: GenericTypes, Column: Column }); diff --git a/lib/web/index.js b/lib/web/index.js index c774637..ed3b9ca 100644 --- a/lib/web/index.js +++ b/lib/web/index.js @@ -8,6 +8,7 @@ const express = require('express'), 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'), @@ -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]), diff --git a/package.json b/package.json index 4d7f224..1d12fc5 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,14 @@ "express-session": "1.15.6", "ffmpeg-binaries": "4.0.0", "fs-extra": "7.0.1", - "get-youtube-title": "1.0.0", "graphql": "14.1.1", "js-md5": "0.7.3", "js-sha512": "0.8.0", "js-yaml": "latest", "node-fetch": "^2.3.0", - "node-sass": "4.11.0", "node-opus": "0.3.1", + "node-sass": "4.11.0", + "pg": "^7.8.2", "promise-waterfall": "0.1.0", "pug": "2.0.3", "sqlite3": "4.0.6", @@ -44,7 +44,8 @@ "sinon": "7.2.6", "eslint-plugin-graphql": "3.0.3", "eslint": "5.15.0", - "eslint-plugin-promise": "4.0.1" + "eslint-plugin-promise": "4.0.1", + "opusscript": "0.0.6" }, "eslintConfig": { "parserOptions": {