diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e966e..beecd2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,10 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 -- 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) @@ -30,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 ## [0.11.0-beta] - 2019-03-03 ### Changed diff --git a/commands/MusicCommands/index.js b/commands/MusicCommands/index.js index 7283379..1244a07 100644 --- a/commands/MusicCommands/index.js +++ b/commands/MusicCommands/index.js @@ -228,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.off('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)); + } }) ); diff --git a/lib/command/index.js b/lib/command/index.js index c7dcd0b..45fd34f 100644 --- a/lib/command/index.js +++ b/lib/command/index.js @@ -3,22 +3,42 @@ const Discord = require('discord.js'), fsx = require('fs-extra'), 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} */ 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} + * @returns {Response | Promise} */ 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; } } diff --git a/lib/guilds/index.js b/lib/guilds/index.js index baf3f24..9b55a78 100644 --- a/lib/guilds/index.js +++ b/lib/guilds/index.js @@ -44,6 +44,39 @@ class GuildDatabase extends dblib.Database { 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} + */ + 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 diff --git a/lib/message/index.js b/lib/message/index.js index e95bf1f..ea61aba 100644 --- a/lib/message/index.js +++ b/lib/message/index.js @@ -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); + } } /** diff --git a/lib/music/index.js b/lib/music/index.js index 5c7cde5..b7eff4b 100644 --- a/lib/music/index.js +++ b/lib/music/index.js @@ -2,7 +2,8 @@ const ytdl = require("ytdl-core"), ypi = require('youtube-playlist-info'), yttl = require('get-youtube-title'), config = require('../../config.json'), - utils = require('../utils/index.js'), + utils = require('../utils'), + xevents = require('../utils/extended-events'), logging = require('../utils/logging'), ytapiKey = config.api.youTubeApiKey; @@ -10,8 +11,13 @@ const ytdl = require("ytdl-core"), * 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 = []; @@ -46,6 +52,7 @@ class MusicPlayer { let connection = await this.voiceChannel.join(); this._logger.info(`Connected to Voicechannel ${this.voiceChannel.name}`); this.conn = connection; + this.emit('connected'); } /** @@ -56,6 +63,7 @@ class MusicPlayer { this.repeat = value; if (this.current) this.queue.push(this.current); + this.emit('listenOnRepeat', this.repeat); } /** @@ -182,6 +190,7 @@ class MusicPlayer { this.current = this.queue.shift(); if (this.repeat) // listen on repeat this.queue.push(this.current); + this.emit('next', this.current); this.playYouTube(this.current.url).catch((err) => this._logger.warn(err.message)); } else { this.stop(); @@ -280,6 +289,7 @@ class MusicPlayer { } catch (error) { this._logger.verbose(JSON.stringify(error)); } + this.emit('stop'); } /** @@ -303,6 +313,7 @@ class MusicPlayer { this.stop(); } } + this.emit('skip', this.current); } /** @@ -318,6 +329,7 @@ class MusicPlayer { */ shuffle() { this.queue = utils.shuffleArray(this.queue); + this.emit('shuffle'); } /** @@ -325,6 +337,7 @@ class MusicPlayer { */ clear() { this.queue = []; + this.emit('clear'); } } diff --git a/lib/state/EventGroups.js b/lib/state/EventGroups.js deleted file mode 100644 index ca22fe2..0000000 --- a/lib/state/EventGroups.js +++ /dev/null @@ -1,163 +0,0 @@ -let stateLib = require("index.js"); - -class DiscordGuildEvents extends EventGroup { - - constructor(client) { - super(); - this._registerClientEvents(client); - } - - /** - * Registeres the client events to the EventGroup - * @param client {Discord.Client} - * @private - */ - _registerClientEvents(client) { - this.registerEvent(new stateLib.Event('clientUserGuildSettingsUpdate')) - .registerEvent(new stateLib.Event('clientUserSettingsUpdate')) - .registerEvent(new stateLib.Event('emojiCreate')) - .registerEvent(new stateLib.Event('emojiDelete')) - .registerEvent(new stateLib.Event('emojiUpdate')) - .registerEvent(new stateLib.Event('guildBanAdd')) - .registerEvent(new stateLib.Event('guildBanRemove')) - .registerEvent(new stateLib.Event('guildCreate')) - .registerEvent(new stateLib.Event('guildDelete')) - .registerEvent(new stateLib.Event('guildMemberAdd')) - .registerEvent(new stateLib.Event('guildMemberAvailable')) - .registerEvent(new stateLib.Event('guildMemberRemove')) - .registerEvent(new stateLib.Event('guildMemberChunk')) - .registerEvent(new stateLib.Event('guildMemberSpeaking')) - .registerEvent(new stateLib.Event('guildMemberUpdate')) - .registerEvent(new stateLib.Event('guildUnavailable')) - .registerEvent(new stateLib.Event('guildUpdate')) - .registerEvent(new stateLib.Event('presenceUpdate')) - .registerEvent(new stateLib.Event('roleCreate')) - .registerEvent(new stateLib.Event('roleDelete')) - .registerEvent(new stateLib.Event('roleUpdate')) - .registerEvent(new stateLib.Event('userNoteUpdate')) - .registerEvent(new stateLib.Event('userUpdate')) - .registerEvent(new stateLib.Event('voiceStateUpdate')); - - client.on('clientUserGuildSettingsUpdate', (...o) => this.events.clientUserGuildSettingsUpdate.fire(o)); - client.on('clientUserSettingsUpdate', (...o) => this.events.clientUserSettingsUpdate.fire(o)); - client.on('emojiCreate', (...o) => this.events.emojiCreate.fire(o)); - client.on('emojiDelete', (...o) => this.events.emojiDelete.fire(o)); - client.on('emojiUpdate', (...o) => this.events.emojiUpdate.fire(o)); - client.on('guildBanAdd', (...o) => this.events.guildBanAdd.fire(o)); - client.on('guildBanRemove', (...o) => this.events.guildBanRemove.fire(o)); - client.on('guildCreate', (...o) => this.events.guildCreate.fire(o)); - client.on('guildDelete', (...o) => this.events.guildDelete.fire(o)); - client.on('guildMemberAdd', (...o) => this.events.guildMemberAdd.fire(o)); - client.on('guildMemberAvailable', (...o) => this.events.guildMemberAvailable.fire(o)); - client.on('guildMemberRemove', (...o) => this.events.guildMemberRemove.fire(o)); - client.on('guildMemberChunk', (...o) => this.events.guildMemberChunk.fire(o)); - client.on('guildMemberSpeaking', (...o) => this.events.guildMemberSpeaking.fire(o)); - client.on('guildMemberUpdate', (...o) => this.events.guildMemberUpdate.fire(o)); - client.on('guildUnavailable', (...o) => this.events.guildUnavailable.fire(o)); - client.on('guildUpdate', (...o) => this.events.guildUpdate.fire(o)); - client.on('presenceUpdate', (...o) => this.events.presenceUpdate.fire(o)); - client.on('roleCreate', (...o) => this.events.roleCreate.fire(o)); - client.on('roleDelete', (...o) => this.events.roleDelete.fire(o)); - client.on('roleUpdate', (...o) => this.events.roleUpdate.fire(o)); - client.on('userNoteUpdate', (...o) => this.events.userNoteUpdate.fire(o)); - client.on('userUpdate', (...o) => this.events.userUpdate.fire(o)); - client.on('voiceStateUpdate', (...o) => this.events.voiceStateUpdate.fire(o)); - - - } -} - -class DiscordMessageEvents extends stateLib.EventGroup { - - constructor(client) { - super(); - this._registerMessageEvents(client); - } - - /** - * Registeres all client message events - * @param client {Discord.Client} - * @private - */ - _registerMessageEvents(client) { - this.registerEvent(new stateLib.Event('messageDelete')) - .registerEvent(new stateLib.Event('messageDeleteBulk')) - .registerEvent(new stateLib.Event('messageReactionAdd')) - .registerEvent(new stateLib.Event('messageReactionRemove')) - .registerEvent(new stateLib.Event('messageReactionRemoveAll')) - .registerEvent(new stateLib.Event('messageUpdate')) - .registerEvent(new stateLib.Event('message')); - - client.on('messageDelete', (...o) => this.events.messageDelete.fire(o)); - client.on('messageDeleteBulk', (...o) => this.events.messageDeleteBulk.fire(o)); - client.on('messageReactionAdd', (...o) => this.events.messageReactionAdd.fire(o)); - client.on('messageReactionRemove', (...o) => this.events.messageReactionRemove.fire(o)); - client.on('messageReactionRemoveAll', (...o) => this.events.messageReactionRemoveAll.fire(o)); - client.on('messageUpdate', (...o) => this.events.messageUpdate.fire(o)); - client.on('message', (...o) => this.events.message.fire(o)); - } -} - -class DiscordChannelEvents extends stateLib.EventGroup { - - constructor(client) { - super(); - this._registerChannelEvents(client); - } - - /** - * Registers all events for discord channels. - * @param client {Discord.Client} - * @private - */ - _registerChannelEvents(client) { - this.registerEvent(new stateLib.Event('channelCreate')) - .registerEvent(new stateLib.Event('channelDelete')) - .registerEvent(new stateLib.Event('channelPinsUpdate')) - .registerEvent(new stateLib.Event('channelUpdate')) - .registerEvent(new stateLib.Event('typingStart')) - .registerEvent(new stateLib.Event('typingStop')); - - client.on('channelCreate', (...o) => this.events.channelCreate.fire(o)); - client.on('channelDelete', (...o) => this.events.channelDelete.fire(o)); - client.on('channelPinsUpdate', (...o) => this.events.channelPinsUpdate.fire(o)); - client.on('channelUpdate', (...o) => this.events.channelUpdate.fire(o)); - client.on('typingStart', (...o) => this.events.typingStart.fire(o)); - client.on('typingStop', (...o) => this.events.typingStop.fire(o)); - } - -} - -class DiscordClientEvents extends stateLib.EventGroup { - - constructor(client) { - super(); - this._registerClientEvents(client); - } - - /** - * Registers Discord client events - * @param client {Discord.Client} - * @private - */ - _registerClientEvents(client) { - this.registerEvent(new stateLib.Event('debug')) - .registerEvent(new stateLib.Event('warn')) - .registerEvent(new stateLib.Event('error')) - .registerEvent(new stateLib.Event('ready')) - .registerEvent(new stateLib.Event('resume')) - .registerEvent(new stateLib.Event('disconnect')) - .registerEvent(new stateLib.Event('reconnecting')) - .registerEvent(new stateLib.Event('rateLimit')); - - client.on('debug', (...o) => this.events.debug.fire(o)); - client.on('warn', (...o) => this.events.warn.fire(o)); - client.on('error', (...o) => this.events.error.fire(o)); - client.on('ready', (...o) => this.events.ready.fire(o)); - client.on('resume', (...o) => this.events.resume.fire(o)); - client.on('disconnect', (...o) => this.events.disconnect.fire(o)); - client.on('reconnecting', (...o) => this.events.reconnecting.fire(o)); - client.on('rateLimit', (...o) => this.events.rateLimit.fire(o)); - client.on('presenceUpdate', (...o) => this.events.presenceUpdate.fire(o)); - } -} diff --git a/lib/state/index.js b/lib/state/index.js deleted file mode 100644 index 3226cc3..0000000 --- a/lib/state/index.js +++ /dev/null @@ -1,99 +0,0 @@ -const logging = require('../utils/logging'); - -class EventRouter { - - constructor() { - this._logger = new logging.Logger(this); - this.eventGroups = {}; - } - - /** - * Fires an event of an event group with event data. - * @param eventGroup {String} - * @param eventName {String} - * @param eventData {Object} - */ - fireEvent(eventGroup, eventName, eventData) { - if (this.eventGroups[eventGroup] instanceof EventGroup) - this.eventGroups[eventGroup].fireEvent(eventName, eventData); - return this; - } - - /** - * Adds an EventRoute to the EventRouter - * @param group {EventGroup} - */ - registerEventGroup(group) { - this.eventGroups[group.name] = name; - } - -} - -class EventGroup { - - /** - * Creates a new EventGroup with the given name. - * @param [name] {String} - */ - constructor(name) { - this._logger = new logging.Logger(this); - this.name = name || this.constructor.name; - this.events = {}; - } - - fireEvent(eventName, eventData) { - if (this.events[eventName] instanceof Event) - this.events[eventName].fire(eventData); - return this; - } - - /** - * Registeres an Event to the EventGroup - * @param event {Event} - */ - registerEvent(event) { - this.events[event.name] = event; - } -} - -class Event { - - /** - * Creates a new Event with the given name. - * @param name - */ - constructor(name) { - this._logger = new logging.Logger(this); - this.name = name; - this.handlers = []; - } - - /** - * Adds an event handler to the Event - * @param handler {Function} - */ - addHandler(handler) { - this.handlers.push(handler); - return this; - } - - /** - * Fires the event with the given data. - * @param data {Object} - */ - fire(data) { - for (let handler in this.handlers) - try { - handler(data); - } catch (err) { - this._logger.verbose(err.message); - this._logger.silly(err.stack); - } - } -} - -Object.assign(exports, { - EventRouter: EventRouter, - EventGroup: EventGroup, - Event: Event -}); diff --git a/lib/utils/extended-events.js b/lib/utils/extended-events.js new file mode 100644 index 0000000..fc15186 --- /dev/null +++ b/lib/utils/extended-events.js @@ -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[]} + */ + 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, +}); diff --git a/lib/utils/genericSql.js b/lib/utils/genericSql.js index 7d5025f..607fe1f 100644 --- a/lib/utils/genericSql.js +++ b/lib/utils/genericSql.js @@ -265,6 +265,19 @@ class GenericSql { } } + /** + * 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} diff --git a/lib/utils/index.js b/lib/utils/index.js index a2354c3..14e2fb5 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -129,6 +129,18 @@ async function resolveNestedPromise (promise) { /* Classes */ +class Enum { + /** + * Constructor. + * @param symbols {Array} + */ + 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 }); diff --git a/lib/web/graphql-schema.js b/lib/web/graphql-schema.js new file mode 100644 index 0000000..95e484e --- /dev/null +++ b/lib/web/graphql-schema.js @@ -0,0 +1,145 @@ +const graphql = require('graphql'), + fs = require('fs'); + +class BotGraphql { + constructor(bot) { + this.schema = graphql.buildSchema(fs.readFileSync('.lib/web/schema.graphqls', 'utf-8')); + this.root = { + Query: new Query(bot) + }; + } +} + +/** + * Easyer managing of page info + */ +class PageInfo { + /** + * constructor. + * @param total {Number} - the total number of entries + * @param perPage {Number} - the number of entries per page + * @param currentPage {Number} - the current page's index + * @param lastPage {Number} - the index of the last page + * @param hasNext {Boolean} - is there a next page? + */ + constructor(total, perPage, currentPage, lastPage, hasNext) { + this.total = total; + this.perPage = perPage; + this.currentPage = currentPage; + this.lastPage = lastPage; + this.hasNext = hasNext; + } +} + +/** + * Generic edge + */ +class Edge { + /** + * contructor. + * @param node {Object} - the node belonging to the edge + * @param edgeProps {Object} - additional properties of the edge + */ + constructor(node, edgeProps) { + this.node = node; + Object.assign(this, edgeProps); + } +} + +/** + * Generic connection + */ +class Connection { + /** + * constructor. + * @param edges {Array} - the edges of the connection + * @param pageInfo {PageInfo} - page info for the connection + */ + constructor(edges, pageInfo) { + this.edges = edges; + this.nodes = this.edges.map(x => x.node); + this.pageInfo = pageInfo; + } +} + +/** + * Manages pagination + */ +class Paginator { + /** + * constructor. + * @param edges {Array} - the edges for the pages + * @param perPage {Number} - the number of entries per page + */ + constructor(edges, perPage) { + this._entries = edges; + this.perPage = perPage; + } + + /** + * Get the specific page + * @param page {Number} - the page's number + * @param [perPage] {Number} - the number of entries per page + * @returns {Connection} + */ + getPage(page, perPage) { + perPage = perPage || this.perPage; + let startIndex = (page - 1) * perPage; + let endIndex = startIndex + perPage; + let lastPage = Math.ceil(this._entries.length / perPage); + return new Connection( + this._entries.slice(startIndex, endIndex) || [], + new PageInfo(this._entries.length, perPage, page, lastPage, page !== lastPage) + ); + } + + /** + * Updates the entries of the Paginator. + * @param entries + */ + updateEntries(entries) { + this._entries = entries; + } +} + +class MusicPlayer { + constructor(guildHandler) { + + } +} + +class Guild { + constructor(guild) { + + } +} + +class Client { + constructor(bot) { + this._bot = bot; + this._client = bot.client; + this.guildPaginator = new Paginator() + } + + _getGuildEdges() { + let guildHandlerPaginator = Array.from(this._client.guilds.values()).map(x => new Edge( + + )); + return Array.from(this._client.guilds.values()).map(x => new Edge( + new Guild(x), + { + musicPlayer: new MusicPlayer(this._bot.getGuildHandler(x)), + new Connection( + bot.getGuildHandler(x).savedMedia + ) + } + )); + } +} + +class Query { + constructor(bot) { + this._bot = bot; + this.client = new Client(bot); + } +} diff --git a/lib/web/graphql-types.js b/lib/web/graphql-types.js new file mode 100644 index 0000000..8545849 --- /dev/null +++ b/lib/web/graphql-types.js @@ -0,0 +1,440 @@ +const graphql = require('graphql'); + +let pageInfoType = new graphql.GraphQLObjectType({ + name: 'PageInfo', + fields: { + total: { + type: graphql.assertNonNullType(graphql.GraphQLInt), + description: 'total number of pages' + }, + perPage: { + type: graphql.assertNonNullType(graphql.GraphQLInt), + description: 'number of entries per page' + }, + currentPage: { + type: graphql.assertNonNullType(graphql.GraphQLInt), + description: 'current page' + }, + lastPage: { + type: graphql.assertNonNullType(graphql.GraphQLInt), + description: 'last page' + }, + hasNextPage: { + type: graphql.assertNonNullType(graphql.GraphQLBoolean), + description: 'does the connection have a next page' + } + } +}); + +let mediaEntryType = new graphql.GraphQLObjectType({ + + name: 'MediaEntry', + fields: { + id: { + type: graphql.GraphQLID, + description: 'id of the media entry' + }, + url: { + type: graphql.GraphQLString, + description: 'url to the YouTube video' + }, + name: { + type: graphql.GraphQLString, + description: 'title of the YouTube video' + }, + thumbnail: { + type: graphql.GraphQLString, + description: 'thumbnail of the YouTube video' + } + } +}); + +let mediaEntryEdgeType = new graphql.GraphQLObjectType({ + name: 'MediaEntryEdge', + fields: { + id: { + type: graphql.GraphQLID, + description: 'the connection id' + }, + position: { + type: graphql.GraphQLInt, + description: 'position in the queue' + }, + node: { + type: mediaEntryType, + description: 'the media entry node' + } + } +}); + +let mediaEntryConnectionType = new graphql.GraphQLObjectType({ + name: 'MediaEntryConnection', + fields: { + edges: { + type: graphql.GraphQLList(mediaEntryEdgeType) + }, + nodes: { + type: graphql.GraphQLList(mediaEntryType) + }, + pageInfo: { + type: graphql.assertNonNullType(pageInfoType), + description: 'pagination information' + } + } +}); + +let musicPlayerType = new graphql.GraphQLObjectType({ + + name: 'MusicPlayer', + fields: { + queue: { + type: new graphql.GraphQLList(mediaEntryConnectionType), + description: 'media entries in the music queue' + }, + queueCount: { + type: graphql.GraphQLInt, + description: 'number of media entries in the queue' + }, + songStartTime: { + type: graphql.GraphQLString + }, + playing: { + type: graphql.GraphQLBoolean + }, + volume: { + type: graphql.GraphQLFloat + }, + repeat: { + type: graphql.GraphQLBoolean + }, + currentSong: { + type: mediaEntryType + }, + quality: { + type: graphql.GraphQLString + }, + voiceChannel: { + type: graphql.GraphQLString + }, + connected: { + type: graphql.GraphQLBoolean + }, + paused: { + type: graphql.GraphQLBoolean + } + } +}); + +let presenceType = new graphql.GraphQLObjectType({ + + name: 'Presence', + fields: { + game: { + type: graphql.GraphQLString + }, + status: { + type: graphql.GraphQLString + } + } +}); + +let userType = new graphql.GraphQLObjectType({ + + name: 'User', + fields: { + id: { + type: graphql.GraphQLID + }, + discordId: { + type: graphql.GraphQLID + }, + name: { + type: graphql.GraphQLString + }, + avatar: { + type: graphql.GraphQLString + }, + bot: { + type: graphql.GraphQLBoolean + }, + tag: { + type: graphql.GraphQLString + }, + presence: { + type: presenceType + } + } +}); + +let guildMemberType = new graphql.GraphQLObjectType({ + name: 'GuildMember', + fields: { + id: { + type: graphql.assertNonNullType(graphql.GraphQLID), + description: 'id of the guild member' + }, + discordId: { + type: graphql.GraphQLID + }, + user: { + type: userType, + description: 'the user instance of the guild member' + }, + nickname: { + type: graphql.GraphQLString, + description: 'the nickname of the guild member' + }, + roles: { + type: graphql.GraphQLList(roleType), + description: 'the roles of the guild member' + }, + highestRole: { + type: roleType, + description: 'the highest role of the guild member' + } + } +}); + +let userRoleEdgeType = new graphql.GraphQLObjectType({ + name: 'userRoleEdge', + fields: { + id: { + type: graphql.GraphQLID, + description: 'the connection id' + }, + node: { + type: guildMemberType, + description: 'guild member edge of the role' + }, + isHighest: { + type: graphql.GraphQLBoolean, + description: 'is the role the highest of the guild member' + } + } +}); + +let userRoleConnectionType = new graphql.GraphQLObjectType({ + name: 'UserRoleConnection', + fields: { + edges: { + type: graphql.GraphQLList(userRoleEdgeType) + }, + nodes: { + type: graphql.GraphQLList(userType) + }, + pageInfoType: { + type: graphql.assertNonNullType(pageInfoType), + description: 'pagination information' + } + } +}); + +let roleType = new graphql.GraphQLObjectType({ + + name: 'Role', + fields: { + id: { + type: graphql.GraphQLID + }, + discordId: { + type: graphql.GraphQLID + }, + name: { + type: graphql.GraphQLString + }, + color: { + type: graphql.GraphQLString + }, + members: { + type: userRoleConnectionType + } + } +}); + +let userGuildConnectionType = new graphql.GraphQLObjectType({ + name: 'UserGuildConnection', + fields: { + edges: { + type: graphql.GraphQLList(guildMemberType) + }, + nodes: { + type: graphql.GraphQLList(userType) + }, + pageInfoType: { + type: graphql.assertNonNullType(pageInfoType), + description: 'pagination information' + } + } +}); + +let guildType = new graphql.GraphQLObjectType({ + + name: 'Guild', + fields: { + id: { + type: graphql.GraphQLID + }, + discordId: { + type: graphql.GraphQLID + }, + name: { + type: graphql.GraphQLString + }, + owner: { + type: guildMemberType + }, + members: { + type: userGuildConnectionType + }, + memberCount: { + type: graphql.GraphQLInt + }, + roles: { + type: graphql.GraphQLList(roleType), + description: 'the roles of the guild' + }, + icon: { + type: graphql.GraphQLString + } + } +}); + +let guildEdgeType = new graphql.GraphQLObjectType({ + name: 'GuildEdge', + fields: { + id: { + type: graphql.GraphQLID, + description: 'id of the connection' + }, + node: { + type: guildType + }, + musicPlayer: { + type: musicPlayerType, + description: 'guilds music player' + }, + savedMedia: { + type: mediaEntryConnectionType, + description: 'saved media entries' + } + } +}); + +let guildConnectionType = new graphql.GraphQLObjectType({ + name: 'GuildConnection', + edges: { + type: guildEdgeType + }, + nodes: { + type: graphql.GraphQLList(guildType) + }, + pageInfo: { + type: pageInfoType, + description: 'pagination information' + } +}); + +let clientType = new graphql.GraphQLObjectType({ + name: 'Client', + fields: { + guilds: { + type: [guildType] + }, + guildCount: { + type: graphql.GraphQLInt + }, + voiceConnectionCount: { + type: graphql.GraphQLInt + }, + user: { + type: userType + }, + ping: { + type: graphql.GraphQLFloat + }, + status: { + type: graphql.GraphQLInt + }, + uptime: { + type: graphql.GraphQLInt + } + } +}); + +let logLevelEnum = new graphql.GraphQLEnumType({ + name: 'LogLevel', + description: 'log levels of log entries', + values: { + SILLY: { + value: 'silly' + }, + DEBUG: { + value: 'debug' + }, + VERBOSE: { + value: 'verbose' + }, + INFO: { + value: 'info' + }, + WARN: { + value: 'warn' + }, + ERROR: { + value: 'error' + } + } +}); + +let logEntryType = new graphql.GraphQLObjectType({ + name: 'LogEntry', + fields: { + id: { + type: graphql.assertNonNullType(graphql.GraphQLID), + description: 'id of the log entry' + }, + message: { + type: graphql.GraphQLString, + description: 'log entry content' + }, + level: { + type: logLevelEnum, + description: 'log level of the log entry' + }, + timestamp: { + type: graphql.GraphQLString, + description: 'timestamp of the log entry' + }, + module: { + type: graphql.GraphQLString, + description: 'module that logged the entry' + } + } +}); + +const queryType = new graphql.GraphQLObjectType({ + + name: 'Query', + fields: { + client: { + type: clientType, + description: 'client instance of the bot' + }, + presences: { + type: graphql.assertNonNullType(graphql.GraphQLList(presenceType)), + description: 'presences of the bot' + }, + prefix: { + type: graphql.GraphQLString, + description: 'prefix of the bot' + }, + logs: { + type: graphql.GraphQLList(logEntryType), + description: 'log entries of the bot' + } + } +}); + +Object.assign(exports, { + queryType: queryType +}); diff --git a/lib/web/schema.graphqls b/lib/web/schema.graphqls new file mode 100644 index 0000000..437dc31 --- /dev/null +++ b/lib/web/schema.graphqls @@ -0,0 +1,429 @@ +enum LogLevel { + SILLY + DEBUG + VERBOSE + INFO + WARN + ERROR +} + +type PageInfo { + + # the total number of entries on all pages + total: Int + + # the number of entries per page + perPage: Int + + # the current page + currentPage: Int + + # the last page + lastPage: Int + + # If there is a next page + hasNextPage: Boolean +} + +type MediaEntry { + + # the id of the media entry + id: ID! + + # the url to the YouTube video + url: String! + + # the title of the YouTube video + name: String! + + # the url of the YouTube video's thumbnail + thumbnail: String! +} + +type MediaEntryEdge { + + # the id of the edge + id: ID! + + node: MediaEntry + + # the position of the entry in the queue + position: Int +} + +type MediaEntryConnection { + + edges: [MediaEntryEdge] + + nodes: [MediaEntry] + + # the pagination information + pageInfo: PageInfo +} + +type MusicPlayer { + + # the content of the music players queue + # + # Arguments + # id: get the media entry by id + # page: get the page by number + # perPage: the number of entries per page + queue( + id: ID, + page: Int, + perPage: Int + ): MediaEntryConnection + + # the current position in the song + songPosition: Int + + # if the music player is currently playing + playing: Boolean! + + # the volume of the music player + volume: Float + + # if the music player plays on repeat + repeat: Boolean + + # the currently playing song + currentSong: MediaEntry + + # the quality of the music that is played (YouTube quality) + quality: String + + # the name of the voice channel the music player is playing in + voiceChannel: String + + # if the music player is connected to a voice channel + connected: Boolean! + + # if the music player is paused + paused: Boolean! +} + +type User { + + # the id of the user + id: ID! + + # the discord id of the user + discordId: String + + # the name of the user + name: String! + + # the url of the users avatar + avatar: String + + # if the user is a bot + bot: Boolean + + # the discord tag of the user + tag: String! + + # the current presence of the user + presence: Presence +} + +type Role { + + # the id of the role + id: ID! + + # the discord id of the role + discordId: String + + # the name of the role + name: String + + # the color of the role + color: String +} + +type GuildMemberRoleEdge { + + # the id of the edge + id: ID! + + node: GuildMember + + # if the role is the highest of the guild member + isHighest: Boolean +} + +type GuildMemberRoleConnection { + + edges: [GuildMemberRoleEdge] + + nodes: [GuildMember] + + # the pagination information + pageInfo: PageInfo +} + +type GuildRoleEdge { + + # the id of the edge + id: ID! + + node: Role + + # the members in the role + # + # Arguments + # id: get the member by id + # page: get the page by number + # perPage: the number of entries per page + members( + id: ID, + page: Int, + perPage: Int + ): GuildMemberRoleConnection +} + +type GuildRoleConnection{ + + edges: [GuildRoleEdge] + + nodes: [Role] + + # the pagination information + pageInfo: PageInfo +} + +type GuildMember { + + # the id of the guild member + id: ID! + + # the discord id of the guild member + discordId: String + + # the user associated with the guild member + user: User + + # the nickname of the guild member + nickname: String + + # the roles of the guild member + roles( + first: Int = 10, + offset: Int = 0, + id: String + ): [Role] + + # the highest role of the guild member + highestRole: Role +} + +type GuildMemberEdge { + + # the id of the edge + id: ID! + + node: GuildMember + + # if the guild member is the server owner + isOwner: Boolean +} + +type GuildMemberConnection{ + + edges: [GuildMemberEdge] + + nodes: [GuildMember] + + # the pagination information + pageInfo: PageInfo +} + +type Guild { + + # the id of the guild + id: ID! + + # the discord id of the guild + discordId: ID + + # the guild's name + name: String + + # the owner of the guild + owner: GuildMember + + # the members in the guild + # + # Arguments + # id: get the member by id + # page: get the page by number + # perPage: the number of entries per page + members( + id: ID, + page: Int, + perPage: Int + ): GuildMemberConnection + + # the roles of the guild + # + # Arguments + # id: get the role by id + # page: get the page by number + # perPage: the number of entries per page + roles( + id: ID, + page: Int, + perPage: Int + ): GuildRoleConnection + + # the url of the guild icon + icon: String +} + +type GuildEdge { + + # the id of the edge + id: ID! + + node: Guild + + # the music player associated with the guild + musicPlayer: MusicPlayer + + # the saved media of the guild + savedMedia: mediaEntryConnection +} + +type GuildConnection { + + edges: [GuildEdge] + + nodes: [Guild] + + # the pagination information + pageInfo: PageInfo +} + +type Client { + + # the guilds the client has joined + # + # Arguments + # id: get the guild by id + # page: get the page by number + # perPage: the number of entries per page + guilds ( + id: ID, + page: Int, + perPage: Int + ): GuildConnection + + # the number of voice connections + voiceConnectionCount: Int + + # the bot user + user: User + + # the current average ping + ping: Float + + # the websocket status + status: Int + + # the total uptime + uptime: Int +} + +type LogEntry { + + # the id of the log entry + id: ID! + + # the message of the log entry + message: String + + # the level of the log entry + level: Level + + # the timestamp of the log entry + timestamp: String + + # the module that created the log entry + module: String +} + +type Query { + + # The bots client + client: Client + + # the presences in the presence rotation + presences: [String]! + + # the prefix for commands + prefix: String + + # The log entries generated in the current runtime. + # + # Arguments + # first: the number of entries to get + # offset: the offset of the entries + # id: get the log entry by id + # last: oposite of first - the latest entries + # level: filter by loglevel + logs( + first: Int, + offset: Int = 0, + id: String, + last: Int = 10, + level: LogLevel + ): [LogEntry] +} + +type Mutation { + + # adds media to the queue + # + # Arguments + # guildId: the id of the guild + # url: the url to the media YouTube video + addMediaToQueue( + guildId: ID!, + url: String! + ): MusicPlayer + + # removes media from the queue + # + # Arguments + # guildId: the id of the guild + # entryId: the id of the media entry to remove + removeMediaFromQueue( + guildId: ID!, + entryId: ID! + ): MusicPlayer + + # skips to the next song + # + # Arguments + # guildId: the id of the guild + skipSong(guildId: ID!): MusicPlayer + + # toggles between pause and play + # + # Arguments + # guildId: the id of the guild + togglePause(guildId: ID!): MusicPlayer + + # toggles repeat + # + # Arguments + # guildId: the id of the guild + toggleRepeat(guildId: ID!): MusicPlayer + + # stops the music + # + # Arguments + # guildId: the id of the guild + stopMusic(guildId: ID!): MusicPlayer +}