diff --git a/app/actions/actionHandlerBase.js b/app/actionHandlers/actionHandlerBase.js similarity index 52% rename from app/actions/actionHandlerBase.js rename to app/actionHandlers/actionHandlerBase.js index d4f7bba..2f15d45 100644 --- a/app/actions/actionHandlerBase.js +++ b/app/actionHandlers/actionHandlerBase.js @@ -3,15 +3,11 @@ const NotImplemented = require("@errors/notImplemented"); module.exports = class ActionHandlerBase { - constructor(logger, command) { - this.command = command; + constructor(logger, name) { + this.name = name; this.logger = logger; this.logPrefix = `[${this.constructor.name}] `; - this.logger.log(`${this.logPrefix}Initialising '!${command}' action handler`); - } - - isMatch(action) { - return (this.command && this.command === action.command); + this.logger.log(`${this.logPrefix}Initialising '!${name}' action handler`); } async handle() { diff --git a/app/actionHandlers/actionHandlerMessage.js b/app/actionHandlers/actionHandlerMessage.js new file mode 100644 index 0000000..8c88b27 --- /dev/null +++ b/app/actionHandlers/actionHandlerMessage.js @@ -0,0 +1,22 @@ +'use strict'; + +module.exports = class ActionHandlerMessage { + /** + * This class acts as an interface between the chatListeners and the actionHandlers. + * @constructor + */ + constructor() { + this.command = ""; + this.data = ""; + this.isBangCommand = false; + + // Remaining properties are overridden by each *MessageResolver + this.server = null; + this.isBot = false; + + this.userId = 0; + this.channelId = 0 + this.nick = ""; + this.timestamp = null; + } +} diff --git a/app/actionHandlers/actionHandlerResolver.js b/app/actionHandlers/actionHandlerResolver.js new file mode 100644 index 0000000..1539887 --- /dev/null +++ b/app/actionHandlers/actionHandlerResolver.js @@ -0,0 +1,31 @@ +'use strict'; + +module.exports = class ActionHandlerResolver { + constructor({ logger, actions }) { + this.actions = actions; + this.logger = logger; + this.logPrefix = `[${this.constructor.name}] `; + } + + async resolve(actionHandlerMessage) { + const m = actionHandlerMessage; + this.logger.log(`${this.logPrefix}Received isBangCommand: '${m.isBangCommand}'; command: '${m.command}'; data: '${m.data}'`); + if (m.isBangCommand === true && m.command === "generic") { + throw new Error("The generic action handler cannot be resolved with a bang command"); + } + + let resolvedAction = this.actions.find(a => a.name === (m.command)); + + if (resolvedAction) { + return resolvedAction; + } + + resolvedAction = this.actions.find(a => a.name === "generic"); + + if (!resolvedAction) { + throw new Error("The generic action handler was not found"); + } + + return resolvedAction; + } +} diff --git a/app/actionHandlers/actionHandlerResolver.spec.js b/app/actionHandlers/actionHandlerResolver.spec.js new file mode 100644 index 0000000..8e8995f --- /dev/null +++ b/app/actionHandlers/actionHandlerResolver.spec.js @@ -0,0 +1,136 @@ +'use strict'; + +const ActionHandlerResolver = require("@actionHandlers/actionHandlerResolver"); +const faker = require('faker'); + +describe("actionHandlerResolver", () => { + let logger; + + beforeEach(() => { + logger = jasmine.createSpyObj("logger", ["log"]); + }); + + it("maps injected actions to actions property", () => { + // Arrange + const actions = [ + {}, {} + ]; + + // Act + const resolver = new ActionHandlerResolver({ logger, actions }); + + // Assert + expect(resolver.actions).toBe(actions); + }); + + it("maps injected logger to logger property", () => { + // Act + const resolver = new ActionHandlerResolver({ logger }); + + // Assert + expect(resolver.logger).toBe(logger); + }); + + it("resolve returns generic action handler if isBangCommand is false", async () => { + // Arrange + const actionHandlerMessage = jasmine.createSpyObj("actionHandlerMessage", null, { + isBangCommand: false, + command: "generic" + }); + const genericActionHandler = jasmine.createSpyObj("genericActionHandler", null, { + name: "generic" + }); + const actions = [ + genericActionHandler + ]; + const resolver = new ActionHandlerResolver({ logger, actions }); + + // Act + const resolvedAction = await resolver.resolve(actionHandlerMessage) + + // Assert + expect(resolvedAction).toBe(genericActionHandler); + }); + + it("resolve returns action handler specified in command if isBangCommand is true", async () => { + // Arrange + const actionHandlerMessage = jasmine.createSpyObj("actionHandlerMessage", null, { + isBangCommand: true, + command: "notes" + }); + const notesActionHandler = jasmine.createSpyObj("notesActionHandler", null, { + name: "notes" + }); + + const actions = [ + notesActionHandler + ]; + const resolver = new ActionHandlerResolver({ logger, actions }); + + // Act + const resolvedAction = await resolver.resolve(actionHandlerMessage) + + // Assert + expect(resolvedAction).toBe(notesActionHandler); + }); + + it("resolve returns rejected promise if isBangCommand is true and command is generic", async () => { + // Arrange + const actionHandlerMessage = jasmine.createSpyObj("actionHandlerMessage", null, { + isBangCommand: true, + command: "generic" + }); + const genericActionHandler = jasmine.createSpyObj("genericActionHandler", null, { + name: "generic" + }); + const actions = [ + genericActionHandler + ]; + const resolver = new ActionHandlerResolver({ logger, actions }); + + // Act & Assert + await expectAsync(resolver.resolve(actionHandlerMessage)) + .toBeRejectedWithError("The generic action handler cannot be resolved with a bang command"); + }); + + it("resolve returns generic action handler when isBangCommand is true and action handler does not exist", async () => { + // Arrange + const actionHandlerMessage = jasmine.createSpyObj("actionHandlerMessage", null, { + isBangCommand: true, + command: "notavalidaction" + }); + const genericActionHandler = jasmine.createSpyObj("genericActionHandler", null, { + name: "generic" + }); + const actions = [ + genericActionHandler + ]; + const resolver = new ActionHandlerResolver({ logger, actions }); + + // Act + const resolvedAction = await resolver.resolve(actionHandlerMessage) + + // Assert + expect(resolvedAction).toBe(genericActionHandler); + }); + + it("resolve rejects promise if generic action handler is not found", async () => { + // Arrange + const actionHandlerMessage = jasmine.createSpyObj("actionHandlerMessage", null, { + isBangCommand: true, + command: faker.random.word() + }); + const anotherActionHandler = jasmine.createSpyObj("anotherActionHandler", null, { + name: "notgeneric" + }); + const actions = [ + anotherActionHandler + ]; + const resolver = new ActionHandlerResolver({ logger, actions }); + + // Act & Assert + await expectAsync(resolver.resolve(actionHandlerMessage)) + .toBeRejectedWithError("The generic action handler was not found"); + }); + +}); diff --git a/app/actionHandlers/generic/genericActionHandler.js b/app/actionHandlers/generic/genericActionHandler.js new file mode 100644 index 0000000..39d26e9 --- /dev/null +++ b/app/actionHandlers/generic/genericActionHandler.js @@ -0,0 +1,33 @@ +'use strict'; + +const ActionHandlerBase = require("@actionHandlers/actionHandlerBase"); + +module.exports = class GenericActionHandler extends ActionHandlerBase { + constructor({ logger }) { + super(logger, "generic"); + } + + async handle(actionHandlerMessage) { + if (!actionHandlerMessage) { + return; + } + + // This action handler is called whenever a bang command is not found for a message received by any server. + + // This is intended be used to log all chat messages from all + // listeners. + // This feature should be enabled or disabled via config. + + // Do not uncomment the below line unless you want every single chat message from every server to be logged. + + // This could potentially be used to redirect chat from all + // servers to a central monitoring destination i.e. if + // a streamer pushes their stream to mixer, twitch and + // YouTube, but monitor chat in one place. + + // this.logger.log(`${this.logPrefix}Generic action handler received message: '${actionHandlerMessage.data}'`); + + // DO NOT RETURN ANYTHING FROM THIS HANDLER! + // If you do, the bot will go into recursive meltdown + } +}; diff --git a/app/actionHandlers/help/helpActionHandler.js b/app/actionHandlers/help/helpActionHandler.js new file mode 100644 index 0000000..9ba110d --- /dev/null +++ b/app/actionHandlers/help/helpActionHandler.js @@ -0,0 +1,19 @@ +'use strict'; + +const ActionHandlerBase = require("@actionHandlers/actionHandlerBase"); + +module.exports = class HelpActionHandler extends ActionHandlerBase { + constructor({ logger, helpActions }) { + super(logger, "help"); + this.actions = helpActions; + this.help = "`!help` to show this message."; + } + + async handle(actionHandlerMessage) { + if (!actionHandlerMessage) { + return; + } + let helpText = this.actions.map(a => a.help).join("\r\n"); + return helpText; + } +}; diff --git a/app/actions/handlers/notesActionHandler.js b/app/actionHandlers/notes/notesActionHandler.js similarity index 55% rename from app/actions/handlers/notesActionHandler.js rename to app/actionHandlers/notes/notesActionHandler.js index 5324bbb..91bbcff 100644 --- a/app/actions/handlers/notesActionHandler.js +++ b/app/actionHandlers/notes/notesActionHandler.js @@ -1,6 +1,6 @@ 'use strict'; -const ActionHandlerBase = require("@actions/actionHandlerBase"); +const ActionHandlerBase = require("@actionHandlers/actionHandlerBase"); module.exports = class NotesActionHandler extends ActionHandlerBase { constructor({ logger, notesActionPersistenceHandler }) { @@ -9,20 +9,23 @@ module.exports = class NotesActionHandler extends ActionHandlerBase { this.help = "`!notes [message]` records a note."; } - async handle(action, msg) { - if (!action || !msg) { + async handle(actionHandlerMessage) { + if (!actionHandlerMessage) { return; } - const userID = msg.author.id; - const channelID = msg.channel.id; - const nick = msg.author.username; - const timestamp = msg.createdAt; - if (!action.data) { + const userID = actionHandlerMessage.userId; + const channelID = actionHandlerMessage.channelId; + const nick = actionHandlerMessage.nick; + const timestamp = actionHandlerMessage.timestamp; + const data = actionHandlerMessage.data; + const server = actionHandlerMessage.server; + + if (!data) { return "I can't record an empty note! " + this.help; } try { - await this.persistenceHandler.insertNote(timestamp, userID, channelID, nick, action.data); + await this.persistenceHandler.insertNote(timestamp, userID, channelID, nick, data, server); return "thanks, I've recorded that for you."; } catch (e) { console.error(e); diff --git a/app/actions/handlers/quoteActionHandler.js b/app/actionHandlers/quote/quoteActionHandler.js similarity index 83% rename from app/actions/handlers/quoteActionHandler.js rename to app/actionHandlers/quote/quoteActionHandler.js index 4f35cb8..f403128 100644 --- a/app/actions/handlers/quoteActionHandler.js +++ b/app/actionHandlers/quote/quoteActionHandler.js @@ -1,6 +1,6 @@ 'use strict'; -const ActionHandlerBase = require("@actions/actionHandlerBase"); +const ActionHandlerBase = require("@actionHandlers/actionHandlerBase"); module.exports = class QuoteActionHandler extends ActionHandlerBase { constructor({ logger, notesActionPersistenceHandler }) { @@ -9,12 +9,12 @@ module.exports = class QuoteActionHandler extends ActionHandlerBase { this.help = "`!quote ` finds a note (if `search` is omitted, I'll just find a random note)."; } - async handle(action) { - if (!action) { + async handle(actionHandlerMessage) { + if (!actionHandlerMessage) { return; } - if (!action.data) { + if (!actionHandlerMessage.data) { try { const [rows] = await this.persistenceHandler.getRandomNote(); if (rows.length) { @@ -28,7 +28,7 @@ module.exports = class QuoteActionHandler extends ActionHandlerBase { } } else { try { - const [rows] = await this.persistenceHandler.getRandomNoteByContent(action.data); + const [rows] = await this.persistenceHandler.getRandomNoteByContent(actionHandlerMessage.data); if (rows.length) { return `\`${rows[0]["nick"]}\`: \`\`\`${rows[0].message}\`\`\``; } else { diff --git a/app/actions/persistenceHandlers/notesActionPersistenceHandler.js b/app/actionPersistenceHandlers/notes/notesActionPersistenceHandler.js similarity index 88% rename from app/actions/persistenceHandlers/notesActionPersistenceHandler.js rename to app/actionPersistenceHandlers/notes/notesActionPersistenceHandler.js index 3fcfced..2f5880f 100644 --- a/app/actions/persistenceHandlers/notesActionPersistenceHandler.js +++ b/app/actionPersistenceHandlers/notes/notesActionPersistenceHandler.js @@ -9,9 +9,9 @@ module.exports = class NotesActionPersistenceHandler { this.logger.log(`${this.logPrefix}Initialising action persistence handler`); } - async insertNote(timestamp, userID, channelID, nick, message) { + async insertNote(timestamp, userID, channelID, nick, message, server) { this.logger.log(`${this.logPrefix}Inserting new note`); - return await this.repository.insertNote(timestamp, userID, channelID, nick, message); + return await this.repository.insertNote(timestamp, userID, channelID, nick, message, server); } async getRandomNote() { diff --git a/app/actions/persistenceHandlers/notesActionPersistenceHandler.spec.js b/app/actionPersistenceHandlers/notes/notesActionPersistenceHandler.spec.js similarity index 92% rename from app/actions/persistenceHandlers/notesActionPersistenceHandler.spec.js rename to app/actionPersistenceHandlers/notes/notesActionPersistenceHandler.spec.js index 2bf4579..38e5115 100644 --- a/app/actions/persistenceHandlers/notesActionPersistenceHandler.spec.js +++ b/app/actionPersistenceHandlers/notes/notesActionPersistenceHandler.spec.js @@ -1,6 +1,6 @@ 'use strict'; -const NotesActionPersistenceHandler = require("@actions/persistenceHandlers/notesActionPersistenceHandler"); +const NotesActionPersistenceHandler = require("@actionPersistenceHandlers/notes/notesActionPersistenceHandler"); const faker = require('faker'); describe("notesActionPersistenceHandler", () => { @@ -38,20 +38,21 @@ describe("notesActionPersistenceHandler", () => { const channelID = faker.random.number(); const nick = faker.userName; const message = faker.lorem.sentence(); + const server = faker.lorem.word(); const handler = new NotesActionPersistenceHandler({ logger, notesRepository }); // Act - await handler.insertNote(timestamp, userID, channelID, nick, message); + await handler.insertNote(timestamp, userID, channelID, nick, message, server); // Assert expect(notesRepository.insertNote) - .toHaveBeenCalledWith(timestamp, userID, channelID, nick, message); + .toHaveBeenCalledWith(timestamp, userID, channelID, nick, message, server); }); it("insertNote returns repository.insertNote as result", async () => { // Arrange - const expectedResult = faker.random.objectElement(); + const expectedResult = {}; var notesRepository = jasmine.createSpyObj("notesRepository", ["insertNote"]) notesRepository.insertNote.and.returnValue(expectedResult); @@ -66,7 +67,7 @@ describe("notesActionPersistenceHandler", () => { ////////////////// it("getRandomNote returns repository.getRandomNote as result", async () => { // Arrange - const expectedResult = faker.random.objectElement(); + const expectedResult = {}; var notesRepository = jasmine.createSpyObj("notesRepository", ["getRandomNote"]) notesRepository.getRandomNote.and.returnValue(expectedResult); @@ -97,7 +98,7 @@ describe("notesActionPersistenceHandler", () => { it("getRandomNoteByContent returns repository.getRandomNoteByContent as result", async () => { // Arrange - const expectedResult = faker.random.objectElement(); + const expectedResult = {}; var notesRepository = jasmine.createSpyObj("notesRepository", ["getRandomNoteByContent"]) notesRepository.getRandomNoteByContent.and.returnValue(expectedResult); diff --git a/app/actions/actionMessage.js b/app/actions/actionMessage.js deleted file mode 100644 index a75370d..0000000 --- a/app/actions/actionMessage.js +++ /dev/null @@ -1,24 +0,0 @@ -'use strict'; - -module.exports = class ActionMessage { - constructor(message) { - if (!message || message.replace(/\s/g, '').length <= 0) { - throw new Error("'message' is required"); - } - - // Set defaults - this.command = ""; - this.data = ""; - - // Slice up message string to make parameters - const matches = /^!([a-z]+)(?:\s+(.*))?$/gi.exec(message); - if (!matches || matches.length <= 0) { - return; - } - - this.command = matches[1].toLowerCase(); - if (matches[2]) { - this.data = matches[2]; - } - } -}; diff --git a/app/actions/actionMessage.spec.js b/app/actions/actionMessage.spec.js deleted file mode 100644 index 4872bd8..0000000 --- a/app/actions/actionMessage.spec.js +++ /dev/null @@ -1,54 +0,0 @@ -'use strict'; - -const ActionMessage = require("./actionMessage"); -var theoretically = require("jasmine-theories"); - -describe("actionMessage", function () { - theoretically.it("throws error if message is '%s' (not a string)", [null, "", " ", undefined], function (insertedValue) { - expect(function () { - new ActionMessage(insertedValue); - }).toThrow(); - }); - - it("sets command to empty string when command not found", function () { - const message = "this is a message without a command"; - const action = new ActionMessage(message); - expect(action.command).toBe(""); - }); - - it("sets data to empty string when command not found", function () { - const message = "this is a message without a command"; - const action = new ActionMessage(message); - expect(action.data).toBe(""); - }); - - it("sets command to passed command string without bang when command found", function () { - const message = "!mycommand"; - const action = new ActionMessage(message); - expect(action.command).toBe("mycommand"); - }); - - it("sets command to lowercase command string when command found", function () { - const message = "!MYCommanD"; - const action = new ActionMessage(message); - expect(action.command).toBe("mycommand"); - }); - - it("strips spaces from command string when command found", function () { - const message = "!mycommand "; - const action = new ActionMessage(message); - expect(action.command).toBe("mycommand"); - }); - - it("sets data to empty string when command found without data", function () { - const message = "!commandwithoutdata"; - const action = new ActionMessage(message); - expect(action.data).toBe(""); - }); - - it("sets data to expected data string when command found with data", function () { - const message = "!jeffscommand jeff is a"; - const action = new ActionMessage(message); - expect(action.data).toBe("jeff is a"); - }); -}); diff --git a/app/actions/handlers/helpActionHandler.js b/app/actions/handlers/helpActionHandler.js deleted file mode 100644 index 0e92f4a..0000000 --- a/app/actions/handlers/helpActionHandler.js +++ /dev/null @@ -1,19 +0,0 @@ -'use strict'; - -const ActionHandlerBase = require("@actions/actionHandlerBase"); - -module.exports = class HelpActionHandler extends ActionHandlerBase { - constructor({ logger, helpActionActions }) { - super(logger, "help"); - this.actions = helpActionActions; - this.help = "`!help` to show this message."; - } - - async handle(action) { - if (!action) { - return; - } - let helpText = this.actions.map((action) => action.help).join("\r\n"); - return helpText; - } -}; diff --git a/app/bot.js b/app/bot.js deleted file mode 100644 index 004febc..0000000 --- a/app/bot.js +++ /dev/null @@ -1,82 +0,0 @@ -'use strict'; - -const DiscordMessage = require("./actions/actionMessage"); - -module.exports = class Bot { - constructor({ - discordChatListenerConfig, - actions, - discord, - botConfig, - logger - }) { - this.logPrefix = `[${this.constructor.name}] `; - this.logger = logger; - this.logger.log(`${this.logPrefix}*** Welcome to ${botConfig.name} v${botConfig.version}! ***`); - this.logger.log(`${this.logPrefix}*** ${botConfig.description} ***`); - - this.actions = actions; - this.discordToken = discordChatListenerConfig.token; - this.discord = discord; - } - - async init() { - this.logger.log(`${this.logPrefix}Initialising bot`); - await this.initDiscord(); - this.logger.log(`${this.logPrefix}Initialisation complete`); - await this.listen(); - } - - async initDiscord() { - this.logger.log(`${this.logPrefix}Logging in to Discord`); - return new Promise((resolve, reject) => { - try { - this.client = new this.discord.Client(); - this.client.once("ready", () => { - this.logger.log(`${this.logPrefix}Logged into Discord`); - resolve(true); - }); - - this.client.login(this.discordToken); - } catch (e) { - reject(e); - } - }); - } - - async listen() { - this.logger.log(`${this.logPrefix}Listening for commands`); - this.client.on("message", this.listenHandler.bind(this)); - } - - async listenHandler(msg) { - const content = msg.content; - if (!content || !(content[0] === "!")) { - return; - } - this.logger.log(`${this.logPrefix}Bang command found in message '${content}'`); - - const action = new DiscordMessage(content); - if (action && action.command) { - let reply = ""; - const handler = this.actions.filter((x) => x.isMatch(action)); - - this.logger.log(`${this.logPrefix}Received command '${msg.content}' from '${msg.author.username}'`); - - if (!handler || !handler.length) { - reply = "I don't recognise that command."; - } else { - try { - reply = await handler[0].handle(action, msg); - } catch (e) { - reply = `Halt! An error occurred: ${e.toString()}`; - } finally { - this.logger.log(`${this.logPrefix}Reply set to '${reply}'`); - if (reply) { - msg.reply(reply); - } - } - } - } - } -}; diff --git a/app/bot.spec.js b/app/bot.spec.js deleted file mode 100644 index d77e602..0000000 --- a/app/bot.spec.js +++ /dev/null @@ -1,8 +0,0 @@ -'use strict'; - -describe("Bot Tests", function () { - it("contains spec with an expectation", function () { - expect(true).toBe(true); - }); -}); - diff --git a/app/chatListeners/chatListenerBase.js b/app/chatListeners/chatListenerBase.js new file mode 100644 index 0000000..cf44abd --- /dev/null +++ b/app/chatListeners/chatListenerBase.js @@ -0,0 +1,42 @@ +'use strict'; + +const NotImplemented = require("@errors/notImplemented"); + +module.exports = class ChatListenerBase { + constructor(logger, actionHandlerResolver, messageResolver) { + this.logger = logger; + this.actionHandlerResolver = actionHandlerResolver; + this.messageResolver = messageResolver; + this.logPrefix = `[${this.constructor.name}] `; + } + + async init() { + throw NotImplemented; + } + + async handleMessage(chatListenerMessage) { + if (!chatListenerMessage || !(typeof chatListenerMessage === "object")) { + return; + } + + const actionHandlerMessage = await this.messageResolver.resolve(chatListenerMessage); + + // Do not attempt to process any bot messages + if (actionHandlerMessage.isBot === true) { + return; + } + + const actionHandler = await this.actionHandlerResolver.resolve(actionHandlerMessage); + + const reply = await actionHandler.handle(actionHandlerMessage); + if (reply && reply.trimEnd().length > 0) { + await this.replyAction(chatListenerMessage, reply); + } + } + + // Child classes must implement: + // async replyAction(chatListenerMessage, replyText) + async replyAction() { + throw NotImplemented; + } +} diff --git a/app/chatListeners/chatListenerBase.spec.js b/app/chatListeners/chatListenerBase.spec.js new file mode 100644 index 0000000..5255fb7 --- /dev/null +++ b/app/chatListeners/chatListenerBase.spec.js @@ -0,0 +1,175 @@ +'use strict'; + +const ChatListenerBase = require("./chatListenerBase"); +const NotImplemented = require("@errors/notImplemented"); +const faker = require('faker'); +var theoretically = require("jasmine-theories"); + +describe("chatListenerBase init()", () => { + let logger; + let actionHandlerResolver; + let messageResolver; + + beforeEach(() => { + logger = jasmine.createSpyObj("logger", ["log"]); + actionHandlerResolver = jasmine.createSpyObj("actionHandlerResolver", ["resolve"]); + messageResolver = jasmine.createSpyObj("messageResolver", ["resolve"]); + }); + + it("sets logger property from logger injection", () => { + // Act + const listener = new ChatListenerBase(logger); + + // Assert + expect(listener.logger).toBe(logger); + }); + + it("sets actionHandlerResolver property from actionHandlerResolver injection", () => { + // Act + const listener = new ChatListenerBase(logger, actionHandlerResolver); + + // Assert + expect(listener.actionHandlerResolver).toBe(actionHandlerResolver); + }); + + it("sets messageResolver property from messageResolver injection", () => { + // Act + const listener = new ChatListenerBase(logger, actionHandlerResolver, messageResolver); + + // Assert + expect(listener.messageResolver).toBe(messageResolver); + }); + + it("init() throws NotImplemented error when not overridden", async () => { + // Arrange + const listener = new ChatListenerBase(logger, actionHandlerResolver, messageResolver); + + // Act & Assert + await expectAsync(listener.init()).toBeRejectedWith(NotImplemented); + }); +}); + +describe("chatListenerBase handleMessage()", () => { + let logger; + let actionHandlerResolver; + let messageResolver; + let chatListenerMessage; + let actionHandlerMessage; + + beforeEach(() => { + logger = jasmine.createSpyObj("logger", ["log"]); + actionHandlerResolver = jasmine.createSpyObj("actionHandlerResolver", { + resolve: jasmine.createSpyObj("actionHandler", ["handle"]) + }); + actionHandlerMessage = jasmine.createSpyObj("actionHandlerMessage", null, { + isBot: false + }); + messageResolver = jasmine.createSpyObj("messageResolver", { + resolve: actionHandlerMessage + }); + chatListenerMessage = jasmine.createSpyObj("chatListenerMessage", ["reply"]); + }); + + theoretically.it("returns without error when discordMessage is '%s'", [null, "", " ", undefined], async (insertedValue) => { + // Arrange + const listener = new ChatListenerBase(logger); + + // Act + const actualResult = await listener.handleMessage(insertedValue); + + // Assert + expect(actualResult).toBe(undefined); + }); + + it("resolves chat listener message to ActionHandlerMessage", async () => { + // Arrange + const listener = new ChatListenerBase(logger, actionHandlerResolver, messageResolver); + + // Act + await listener.handleMessage(chatListenerMessage); + + // Assert + expect(messageResolver.resolve).toHaveBeenCalledWith(chatListenerMessage); + }); + + it("does not attempt to resolve ActionHandler if ActionHandlerMessage.isBot = true", async () => { + // Arrange + actionHandlerMessage = jasmine.createSpyObj("actionHandlerMessage", null, { + isBot: true + }); + messageResolver = jasmine.createSpyObj("messageResolver", { + resolve: actionHandlerMessage + }); + + const listener = new ChatListenerBase(logger, actionHandlerResolver, messageResolver); + + // Act + await listener.handleMessage(chatListenerMessage); + + // Assert + expect(actionHandlerResolver.resolve).toHaveBeenCalledTimes(0); + }); + + it("resolves ActionHandler with ActionHandlerMessage", async () => { + // Arrange + const listener = new ChatListenerBase(logger, actionHandlerResolver, messageResolver); + + // Act + await listener.handleMessage(chatListenerMessage); + + // Assert + expect(actionHandlerResolver.resolve).toHaveBeenCalledWith(actionHandlerMessage); + }); + + it("asynchronously handles message with resolved ActionHandler's handle() method", async () => { + // Arrange + const actionHandler = jasmine.createSpyObj("actionHandler", ["handle"]); + actionHandlerResolver = jasmine.createSpyObj("actionHandlerResolver", { + resolve: actionHandler + }); + + const listener = new ChatListenerBase(logger, actionHandlerResolver, messageResolver); + + // Act + await listener.handleMessage(chatListenerMessage); + + // Assert + expect(actionHandler.handle).toHaveBeenCalledWith(actionHandlerMessage); + }); + + it("calls the replyAction() method with the ActionHandler's response", async () => { + // Arrange + const expectedReply = faker.lorem.sentences(); + actionHandlerResolver = jasmine.createSpyObj("actionHandlerResolver", { + resolve: jasmine.createSpyObj("actionHandler", { + handle: expectedReply + }) + }); + + const listener = new ChatListenerBase(logger, actionHandlerResolver, messageResolver); + spyOn(listener, "replyAction"); + + // Act + await listener.handleMessage(chatListenerMessage); + + // Assert + expect(listener.replyAction).toHaveBeenCalledWith(chatListenerMessage, expectedReply); + }); + + theoretically.it("does not reply when action handler returns '%s'", [null, "", " ", undefined], async (insertedValue) => { + // Arrange + actionHandlerResolver = jasmine.createSpyObj("actionHandlerResolver", { + resolve: jasmine.createSpyObj("actionHandler", { + handle: insertedValue + }) + }); + + const listener = new ChatListenerBase(logger, actionHandlerResolver, messageResolver); + + // Act + await listener.handleMessage(chatListenerMessage); + + // Assert + expect(chatListenerMessage.reply).toHaveBeenCalledTimes(0); + }); +}); diff --git a/app/chatListeners/discord/discordChatListener.js b/app/chatListeners/discord/discordChatListener.js new file mode 100644 index 0000000..d50e067 --- /dev/null +++ b/app/chatListeners/discord/discordChatListener.js @@ -0,0 +1,42 @@ +'use strict'; + +const ChatListenerBase = require("@chatListeners/chatListenerBase"); + +module.exports = class DiscordChatListener extends ChatListenerBase { + constructor({ logger, actionHandlerResolver, discordChatListenerConfig, discordClient, discordMessageResolver }) { + super(logger, actionHandlerResolver, discordMessageResolver); + + this.config = discordChatListenerConfig; + this.client = discordClient; + } + + async init() { + this.logger.log(`${this.logPrefix}Initialising - logging in`); + + await this.client + .login(this.config.token) + .then(() => { + this.logger.log(`${this.logPrefix}Logged in`); + this.logger.log(`${this.logPrefix}Registering message listener`); + + this.client.on("message", async (msg) => { + await this.handleMessage(msg) + .catch((e) => { + this.logger.log(`${this.logPrefix}Discord message handling error:\n`, e); + }); + }); + + this.logger.log(`${this.logPrefix}Message listener registered`); + }); + + this.logger.log(`${this.logPrefix}Initialised successfully`); + } + + async handleMessage(discordMessage) { + await super.handleMessage(discordMessage); + } + + async replyAction(discordMessage, replyText) { + discordMessage.reply(replyText); + } +} diff --git a/app/chatListeners/discord/discordChatListener.spec.js b/app/chatListeners/discord/discordChatListener.spec.js new file mode 100644 index 0000000..183d03c --- /dev/null +++ b/app/chatListeners/discord/discordChatListener.spec.js @@ -0,0 +1,117 @@ +'use strict'; + +const DiscordChatListener = require("./discordChatListener"); +const ChatListenerBase = require("@chatListeners/chatListenerBase"); +const faker = require('faker'); + +describe("discordChatListener init()", () => { + let discordClient; + let logger; + let discordChatListenerConfig; + + beforeEach(() => { + discordClient = jasmine.createSpyObj("discordClient", { + login: Promise.resolve(), + on: null + }); + logger = jasmine.createSpyObj("logger", ["log"]); + discordChatListenerConfig = jasmine.createSpyObj("discordChatListenerConfig", { + token: faker.lorem.word() + }); + }); + + it("sets config property from discordChatListenerConfig injection", () => { + // Act + const listener = new DiscordChatListener({ discordChatListenerConfig }); + + // Assert + expect(listener.config).toBe(discordChatListenerConfig); + }); + + it("sets client property from discordClient injection", () => { + // Act + const listener = new DiscordChatListener({ discordClient, discordChatListenerConfig }); + + // Assert + expect(listener.client).toBe(discordClient); + }); + + // init() + it("passes token to discordClient.login", async () => { + // Arrange + const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig }); + + // Act + await listener.init(); + + // Assert + expect(discordClient.login).toHaveBeenCalledWith(discordChatListenerConfig.token); + }); + + it("resolves when client.login() resolves", async () => { + // Arrange + const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig }); + + // Act & Assert + await expectAsync(listener.init()).toBeResolved(); + }); + + it("registers message handler callback when client.login() resolves", async () => { + // Arrange + const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig }); + + // Act + await listener.init(); + + // Assert + expect(discordClient.on).toHaveBeenCalledTimes(1); + expect(discordClient.on) + .toHaveBeenCalledWith("message", jasmine.anything()); + }); + + it("init() bubbles reject and does not register message handler callback when client.login() rejected", async () => { + // Arrange + discordClient = jasmine.createSpyObj("discordClient", { + login: Promise.reject("login error"), + on: null + }); + const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig }); + + // Act & Assert + await expectAsync(listener.init()).toBeRejected(); + expect(discordClient.on).toHaveBeenCalledTimes(0); + }); +}); + +describe("discordChatListener handleMessage()", () => { + it("calls super class handleMessage asynchronously", async () => { + // Arrange + const logger = jasmine.createSpyObj("logger", ["log"]); + const discordMessage = jasmine.createSpyObj("discordMessage", ["reply"]); + const listener = new DiscordChatListener({ logger }); + spyOn(ChatListenerBase.prototype, "handleMessage"); + + // Act + await listener.handleMessage(discordMessage); + + // Assert + expect(ChatListenerBase.prototype.handleMessage).toHaveBeenCalledWith(discordMessage); + }); +}); + + +describe("discordChatListener replyAction()", () => { + it("replies to the discordMessage with the specified replyText", async () => { + // Arrange + const logger = jasmine.createSpyObj("logger", ["log"]); + const discordMessage = jasmine.createSpyObj("discordMessage", ["reply"]); + const listener = new DiscordChatListener({ logger }); + const replyText = faker.lorem.sentences(); + + // Act + await listener.replyAction(discordMessage, replyText); + + // Assert + expect(discordMessage.reply).toHaveBeenCalledWith(replyText); + }); +}); diff --git a/app/chatListeners/discord/discordChatListenerConfig.js b/app/chatListeners/discord/discordChatListenerConfig.js new file mode 100644 index 0000000..9b93091 --- /dev/null +++ b/app/chatListeners/discord/discordChatListenerConfig.js @@ -0,0 +1,21 @@ +'use strict'; + +module.exports = class DiscordChatListenerConfig { + constructor({ appConfig }) { + // Defaults + this.token = null; + this.enabled = false; + + if (!appConfig || !appConfig.bot || !appConfig.bot.chatListeners || appConfig.bot.chatListeners.length <= 0) { + return; + } + const config = appConfig.bot.chatListeners.find(x => x.name == "discord"); + if (!config || !config.settings) { + return; + } + + // Inject found config settings + this.token = config.settings.key; + this.enabled = config.enabled; + } +} diff --git a/app/chatListeners/discord/discordChatListenerConfig.spec.js b/app/chatListeners/discord/discordChatListenerConfig.spec.js new file mode 100644 index 0000000..f78dc00 --- /dev/null +++ b/app/chatListeners/discord/discordChatListenerConfig.spec.js @@ -0,0 +1,95 @@ +'use strict'; + +const DiscordChatListenerConfig = require("./discordChatListenerConfig"); +var theoretically = require("jasmine-theories"); +const faker = require('faker'); + +describe("discordChatListenerConfig", function () { + it("maps injected appConfig.bot.chatListeners[discord].key property to .token", () => { + // Arrange + const expectedKey = faker.lorem.word(); + const appConfig = { + bot: { + chatListeners: [ + { + name: "discord", + settings: { + key: expectedKey + } + } + ] + } + }; + + // Act + const actualResult = new DiscordChatListenerConfig({ appConfig }); + + // Assert + expect(actualResult.token).toBe(expectedKey); + }); + + it("maps injected appConfig.bot.chatListeners[discord].enabled property to .enabled", () => { + // Arrange + const expectedEnabled = faker.random.boolean(); + const appConfig = { + bot: { + chatListeners: [ + { + name: "discord", + enabled: expectedEnabled, + settings: { + key: "not_tested" + } + } + ] + } + }; + + // Act + const actualResult = new DiscordChatListenerConfig({ appConfig }); + + // Assert + expect(actualResult.enabled).toBe(expectedEnabled); + }); + + // Config theory helper objects + const confEmptyBot = { + bot: {} + }; + const confNullChatListeners = { + bot: { + chatListeners: null + } + }; + const confEmptyChatListeners = { + bot: { + chatListeners: [] + } + }; + const confNoDiscordChatListener = { + bot: { + chatListeners: [ + { + name: "notdiscord" + } + ] + } + }; + const confNoSettings = { + bot: { + chatListeners: [ + { + name: "discord" + } + ] + } + }; + + theoretically.it("sets enabled to false when appConfig is '%s'", [null, "", " ", undefined, {}, confEmptyBot, confNullChatListeners, confEmptyChatListeners, confNoDiscordChatListener, confNoSettings], (insertedValue) => { + // Act + const actualValue = new DiscordChatListenerConfig({ appConfig: insertedValue }); + + // Assert + expect(actualValue.enabled).toBe(false); + }); +}); diff --git a/app/chatListeners/discord/discordMessageResolver.js b/app/chatListeners/discord/discordMessageResolver.js new file mode 100644 index 0000000..ecb5239 --- /dev/null +++ b/app/chatListeners/discord/discordMessageResolver.js @@ -0,0 +1,35 @@ +'use strict'; + +const MessageResolverBase = require("@chatListeners/messageResolverBase"); + +module.exports = class DiscordMessageResolver extends MessageResolverBase { + constructor({ logger }) { + super(); + this.logger = logger; + this.logPrefix = `[${this.constructor.name}] `; + } + + /** + * Resolves a discord message to an @class ActionHandlerMessage + */ + async resolve(discordMessage) { + const message = super.resolveChatMessage(discordMessage.content); + + // Append discord specific message content + message.server = "discord"; + + // Ugh, null coalesce plsthxadmin! + if (discordMessage) { + message.timestamp = discordMessage.createdAt; + if (discordMessage.author) { + message.userId = discordMessage.author.id; + message.nick = discordMessage.author.username; + message.isBot = discordMessage.author.bot; + } + if (discordMessage.channel) { + message.channelId = discordMessage.channel.id; + } + } + return message; + } +} diff --git a/app/chatListeners/discord/discordMessageResolver.spec.js b/app/chatListeners/discord/discordMessageResolver.spec.js new file mode 100644 index 0000000..5721cf8 --- /dev/null +++ b/app/chatListeners/discord/discordMessageResolver.spec.js @@ -0,0 +1,83 @@ +'use strict'; + +const faker = require('faker'); +const MessageResolverBase = require("@chatListeners/messageResolverBase"); +const DiscordMessageResolver = require("@chatListeners/discord/discordMessageResolver"); + +describe("discordMessageResolver", function () { + let logger; + + beforeEach(() => { + logger = jasmine.createSpyObj("logger", ["log"]); + }); + + it("resolves generic properties with super.resolveChatMessage()", async () => { + // Arrange + const expectedContent = faker.lorem.sentences(); + const discordMessage = jasmine.createSpyObj("discordMessage", null, { + content: expectedContent + }); + spyOn(MessageResolverBase.prototype, "resolveChatMessage") + .and.returnValue({}); + + const discordMessageResolver = new DiscordMessageResolver({ logger }); + + // Act + await discordMessageResolver.resolve(discordMessage); + + // Assert + expect(discordMessageResolver.resolveChatMessage).toHaveBeenCalledWith(expectedContent); + }); + + it("returns super.resolveChatMessage() as result", async () => { + // Arrange + const discordMessage = jasmine.createSpy("discordMessage"); + const expectedResult = jasmine.createSpy("actionHandlerMessage"); + spyOn(MessageResolverBase.prototype, "resolveChatMessage") + .and.returnValue(expectedResult); + + const discordMessageResolver = new DiscordMessageResolver({ logger }); + + // Act + const result = await discordMessageResolver.resolve(discordMessage); + + // Assert + expect(result).toBe(expectedResult) + }); + + it("appends all expected discord message properties return object", async () => { + // Arrange + const expectedUserId = faker.random.number(); + const expectedChannelId = faker.random.number(); + const expectedUsername = faker.internet.userName(); + const expectedTimestamp = faker.date.recent(); + const expectedServer = "discord"; + const expectedIsBot = faker.random.boolean(); + + const discordMessage = jasmine.createSpyObj("discordMessage", null, { + content: faker.lorem.sentences(), + author: { + id: expectedUserId, + username: expectedUsername, + bot: expectedIsBot + }, + channel: { + id: expectedChannelId + }, + createdAt: expectedTimestamp + }); + + const discordMessageResolver = new DiscordMessageResolver({ logger }); + + // Act + const result = await discordMessageResolver.resolve(discordMessage); + + // Assert + expect(result.userId).toBe(expectedUserId); + expect(result.channelId).toBe(expectedChannelId); + expect(result.nick).toBe(expectedUsername); + expect(result.timestamp).toBe(expectedTimestamp); + expect(result.server).toBe(expectedServer); + expect(result.isBot).toBe(expectedIsBot); + }); +}); diff --git a/app/chatListeners/messageResolverBase.js b/app/chatListeners/messageResolverBase.js new file mode 100644 index 0000000..054c70a --- /dev/null +++ b/app/chatListeners/messageResolverBase.js @@ -0,0 +1,37 @@ +'use strict'; + +const NotImplemented = require("@errors/notImplemented"); +const ActionHandlerMessage = require("@actionHandlers/actionHandlerMessage"); + +module.exports = class MessageResolverBase { + async resolve() { + // Must be overridden and must call resolveChatMessage(inputMessage.) + throw NotImplemented; + } + + resolveChatMessage(chatMessage) { + if (!chatMessage || chatMessage.replace(/\s/g, '').length <= 0) { + throw new Error("'message' is required"); + } + + // Set defaults + const action = new ActionHandlerMessage(); + action.command = ""; + action.data = ""; + action.isBangCommand = false; + + // Slice up message string to make parameters + const matches = /^!([a-z]+)(?:\s+(.*))?$/gi.exec(chatMessage); + if (!matches || matches.length <= 0) { + action.data = chatMessage; + } else { + action.isBangCommand = true; + action.command = matches[1].toLowerCase(); + if (matches[2]) { + action.data = matches[2]; + } + } + + return action; + } +} diff --git a/app/chatListeners/messageResolverBase.spec.js b/app/chatListeners/messageResolverBase.spec.js new file mode 100644 index 0000000..1a14b15 --- /dev/null +++ b/app/chatListeners/messageResolverBase.spec.js @@ -0,0 +1,65 @@ +'use strict'; + +const MessageResolverBase = require("./messageResolverBase"); +var theoretically = require("jasmine-theories"); + +describe("messageResolverBase", () => { + theoretically.it("throws error if message is '%s' (not a string)", [null, "", " ", undefined], (insertedValue) => { + const resolver = new MessageResolverBase(); + expect(() => { resolver.resolveChatMessage(insertedValue) }).toThrowError("'message' is required"); + }); + + it("sets command to empty string when command not found", () => { + const message = "this is a message without a command"; + const action = new MessageResolverBase().resolveChatMessage(message); + expect(action.command).toBe(""); + }); + + it("sets command to passed command string without bang when command found", () => { + const message = "!mycommand"; + const action = new MessageResolverBase().resolveChatMessage(message); + expect(action.command).toBe("mycommand"); + }); + + it("sets isBangCommand to true when bang found in message", () => { + const message = "!mycommand"; + const action = new MessageResolverBase().resolveChatMessage(message); + expect(action.isBangCommand).toBeTrue(); + }); + + it("sets command to lowercase command string when command found", () => { + const message = "!MYCommanD"; + const action = new MessageResolverBase().resolveChatMessage(message); + expect(action.command).toBe("mycommand"); + }); + + it("strips spaces from command string when command found", () => { + const message = "!mycommand "; + const action = new MessageResolverBase().resolveChatMessage(message); + expect(action.command).toBe("mycommand"); + }); + + it("sets data to empty string when command found without data", () => { + const message = "!commandwithoutdata"; + const action = new MessageResolverBase().resolveChatMessage(message); + expect(action.data).toBe(""); + }); + + it("sets data to expected data string when command found with data", () => { + const message = "!jeffscommand jeff is a"; + const action = new MessageResolverBase().resolveChatMessage(message); + expect(action.data).toBe("jeff is a"); + }); + + it("sets data to full input message when no bang command found", () => { + const message = "jeff is a lovely person"; + const action = new MessageResolverBase().resolveChatMessage(message); + expect(action.data).toBe(message); + }); + + it("sets isBangCommand to false when no bang command found in message", () => { + const message = "jeff is a lovely person"; + const action = new MessageResolverBase().resolveChatMessage(message); + expect(action.isBangCommand).toBeFalse(); + }); +}); diff --git a/app/services/appConfig/appConfig.js b/app/config/app/appConfig.js similarity index 79% rename from app/services/appConfig/appConfig.js rename to app/config/app/appConfig.js index 295015b..4b0bdc0 100644 --- a/app/services/appConfig/appConfig.js +++ b/app/config/app/appConfig.js @@ -8,7 +8,9 @@ module.exports = ({ configFilePath, environment }) => { throw new Error("environment not found"); } // Override secrets with specific env settings - config.discord.key = environment.BOT_DISCORD_KEY; + const discord = config.bot.chatListeners.find(x => x.name == "discord"); + discord.settings.key = environment.BOT_DISCORD_KEY; + config.database.name = environment.BOT_DB_NAME; config.database.server = environment.BOT_DB_SERVER; config.database.user = environment.BOT_DB_USER; diff --git a/app/services/appConfig/appConfig.spec.js b/app/config/app/appConfig.spec.js similarity index 93% rename from app/services/appConfig/appConfig.spec.js rename to app/config/app/appConfig.spec.js index 41b525b..56ad9f0 100644 --- a/app/services/appConfig/appConfig.spec.js +++ b/app/config/app/appConfig.spec.js @@ -27,13 +27,19 @@ describe("appConfig", function () { // Sensitive info mapping it("sets environment BOT_DISCORD_KEY to discord.key", function () { + // Arrange const expectedValue = faker.lorem.word(); const configFilePath = "config.json"; const environment = { BOT_DISCORD_KEY: expectedValue }; + + // Act const appConfig = AppConfig({ configFilePath, environment }); - expect(appConfig.discord.key).toBe(expectedValue); + + // Assert + const discordEntry = appConfig.bot.chatListeners.find(x => x.name === "discord"); + expect(discordEntry.settings.key).toBe(expectedValue); }); it("sets environment BOT_DB_NAME to database.name", function () { diff --git a/app/botConfig.js b/app/config/bot/botConfig.js similarity index 100% rename from app/botConfig.js rename to app/config/bot/botConfig.js diff --git a/app/botConfig.spec.js b/app/config/bot/botConfig.spec.js similarity index 100% rename from app/botConfig.spec.js rename to app/config/bot/botConfig.spec.js diff --git a/app/services/db/dbConfig.js b/app/config/db/dbConfig.js similarity index 100% rename from app/services/db/dbConfig.js rename to app/config/db/dbConfig.js diff --git a/app/services/db/dbConfig.spec.js b/app/config/db/dbConfig.spec.js similarity index 100% rename from app/services/db/dbConfig.spec.js rename to app/config/db/dbConfig.spec.js diff --git a/app/container.js b/app/container.js index ebf44e4..6530e9d 100644 --- a/app/container.js +++ b/app/container.js @@ -5,24 +5,19 @@ const ioc = require('awilix'); const Lifetime = ioc.Lifetime; // App -const Bot = require('@root/bot'); +const Bot = require('@services/bot/bot'); + // Config -const Config = require('@services/appConfig/appConfig'); -const BotConfig = require('@root/botConfig'); -const DbConfig = require('@services/db/dbConfig'); -const DiscordChatListenerConfig = require('@chatListeners/discord/discordChatListenerConfig'); +const AppConfig = require('@config/app/appConfig'); +const BotConfig = require('@config/bot/botConfig'); +const DbConfig = require('@config/db/dbConfig'); // Services -const DbAdapter = require('@dbAdapters/mariaDbAdapter'); +const DbAdapter = require('@dbAdapters/mariaDb/mariaDbAdapter'); const Logger = require('@services/logging/logger'); -// Actions -const HelpActionHandler = require("@actions/handlers/helpActionHandler"); -const NotesActionHandler = require("@actions/handlers/notesActionHandler"); -const QuoteActionHandler = require("@actions/handlers/quoteActionHandler"); -// Action Persistence Handlers -const NotesActionPersistenceHandler = require("@actions/persistenceHandlers/notesActionPersistenceHandler"); -// DB Repositories -const NotesRepository = require("@dbRepositories/notesRepository"); + +// Resolvers +const ActionHandlerResolver = require("@actionHandlers/actionHandlerResolver"); // 3rd party const MySQL = require("mysql2/promise"); @@ -30,53 +25,94 @@ const Discord = require("discord.js"); // IoC container - these are the only references to console.log() that should exist in the application console.log("[Root] Creating IoC container"); + const container = ioc.createContainer({ injectionMode: ioc.InjectionMode.PROXY -}) +}); + console.log("[Root] Registering services"); +// Auto load actions & persistence handlers +const actionHandlersGlob = 'app/actionHandlers/*/*ActionHandler.js'; +const actionPersistenceHandlersGlob = 'app/actionPersistenceHandlers/*/*ActionPersistenceHandler.js'; +const chatListenersGlobSuffix = 'app/chatListeners/*/'; +const chatListenersGlob = `${chatListenersGlobSuffix}*ChatListener.js`; +const repositoriesGlob = 'app/services/db/repositories/*/*Repository.js'; + +container.loadModules([actionHandlersGlob, actionPersistenceHandlersGlob], { + resolverOptions: { + register: ioc.asClass + } +}); + +// Auto load chat listener files +container.loadModules([ + chatListenersGlob, + `${chatListenersGlobSuffix}*ChatListenerConfig.js`, + `${chatListenersGlobSuffix}*MessageResolver.js` +], { + resolverOptions: { + register: ioc.asClass + } +}); + +// Auto load repositories (singletons) +container.loadModules([repositoriesGlob], { + resolverOptions: { + register: ioc.asClass, + lifetime: Lifetime.SINGLETON + } +}); + container.register({ // Bootstrap - bot: ioc.asClass(Bot, { lifetime: Lifetime.SINGLETON }), + bot: ioc.asClass(Bot).singleton(), + // Config configFilePath: ioc.asValue("config.json"), environment: ioc.asValue(process.env), - appConfig: ioc.asFunction(Config), + appConfig: ioc.asFunction(AppConfig), dbConfig: ioc.asClass(DbConfig), - discordChatListenerConfig: ioc.asClass(DiscordChatListenerConfig), botConfig: ioc.asClass(BotConfig), + // Logging logger: ioc.asClass(Logger), - // 3rd Party + + // 3rd Party dependencies mySql: ioc.asValue(MySQL), - discord: ioc.asValue(Discord), + discordClient: ioc.asFunction(() => new Discord.Client()), - // Register Action persistence handlers - TODO: Register automatically - notesActionPersistenceHandler: ioc.asClass(NotesActionPersistenceHandler, { lifetime: Lifetime.SINGLETON }), + // Database adapter. Swap out if you want to implement + // another database provider. + dbAdapter: ioc.asClass(DbAdapter).singleton(), - // Register database and repositories - dbAdapter: ioc.asClass(DbAdapter, { lifetime: Lifetime.SINGLETON }), - // TODO: Register repos automatically. Note that these do not need to be singletons. - notesRepository: ioc.asClass(NotesRepository), + // Resolvers + actionHandlerResolver: ioc.asClass(ActionHandlerResolver), - // Register Actions - TODO: Register automatically - helpAction: ioc.asClass(HelpActionHandler), - notesAction: ioc.asClass(NotesActionHandler), - quoteAction: ioc.asClass(QuoteActionHandler), + // Chat Listeners + chatListeners: ioc.asFunction(() => { + return ioc + .listModules(chatListenersGlob) + .map(a => container.resolve(a.name)); + }), // Add all of the above actions into the below returned array - helpActionActions: ioc.asFunction(function () { - return [ - container.cradle.notesAction, - container.cradle.quoteAction - ]; - }, { lifetime: Lifetime.SINGLETON }), - // Also include the help action. Do not inject this registration into any actions as you will create a cyclic dependency - actions: ioc.asFunction(function () { - return container.cradle.helpActionActions - .concat([container.cradle.helpAction]); - }, { lifetime: Lifetime.SINGLETON }) + helpActions: ioc.asFunction(() => { + return ioc + .listModules(actionHandlersGlob) + .filter(a => a.name !== 'helpActionHandler') + .map(a => container.resolve(a.name)); + }), + + // Register all actions as an array. + // N.B. Do not inject this registration into any actions as you will create a cyclic dependency + actions: ioc.asFunction(() => { + return ioc + .listModules(actionHandlersGlob) + .map(a => container.resolve(a.name)); + }) }); + container.cradle.logger.log("[Root] All services registered"); module.exports = container; diff --git a/app/services/bot/bot.js b/app/services/bot/bot.js new file mode 100644 index 0000000..65433cd --- /dev/null +++ b/app/services/bot/bot.js @@ -0,0 +1,20 @@ +'use strict'; + +module.exports = class Bot { + constructor({ logger, botConfig, chatListeners }) { + this.logPrefix = `[${this.constructor.name}] `; + this.logger = logger; + this.logger.log(`${this.logPrefix}*** Welcome to ${botConfig.name} v${botConfig.version}! ***`); + this.logger.log(`${this.logPrefix}*** ${botConfig.description} ***`); + + this.chatListeners = chatListeners; + } + + async init() { + this.logger.log(`${this.logPrefix}Initialising bot`); + + for (const listener of this.chatListeners) { + await listener.init(); + } + } +}; diff --git a/app/services/bot/bot.spec.js b/app/services/bot/bot.spec.js new file mode 100644 index 0000000..0177b38 --- /dev/null +++ b/app/services/bot/bot.spec.js @@ -0,0 +1,34 @@ +'use strict'; + +const Bot = require("./bot"); + +describe("bot init()", function () { + let logger; + let botConfig; + let chatListeners; + + beforeEach(() => { + logger = jasmine.createSpyObj("logger", ["log"]); + botConfig = jasmine.createSpyObj("botConfig", null, { + name: null, + description: null, + version: null + }); + chatListeners = jasmine.createSpy("chatListeners"); + }); + + it("calls init() for each chatListener", async () => { + // Arrange + const chatListener = jasmine.createSpyObj("chatListeners", ["init"]); + chatListeners = [chatListener, chatListener]; + console.log(chatListeners); + const bot = new Bot({ logger, botConfig, chatListeners}); + + // Act + await bot.init(); + + // Assert + expect(chatListener.init).toHaveBeenCalledTimes(2); + }); +}); + diff --git a/app/services/chatListeners/discord/discordChatListenerConfig.js b/app/services/chatListeners/discord/discordChatListenerConfig.js deleted file mode 100644 index 87324f7..0000000 --- a/app/services/chatListeners/discord/discordChatListenerConfig.js +++ /dev/null @@ -1,13 +0,0 @@ -'use strict'; - -module.exports = class DiscordChatListenerConfig { - constructor({ appConfig }) { - if (appConfig.discord) { - this.token = appConfig.discord.key; - return; - } - - // Defaults - this.token = null; - } -} diff --git a/app/services/chatListeners/discord/discordChatListenerConfig.spec.js b/app/services/chatListeners/discord/discordChatListenerConfig.spec.js deleted file mode 100644 index 413e769..0000000 --- a/app/services/chatListeners/discord/discordChatListenerConfig.spec.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict'; - -const DiscordChatListenerConfig = require("./discordChatListenerConfig"); - -describe("discordChatListenerConfig", function () { - it("maps injected appConfig.discord.key property to .token", () => { - // Arrange - const appConfig = { - discord: { - key: "mytestkey" - } - }; - - // Act - const actualResult = new DiscordChatListenerConfig({ appConfig }); - - // Assert - expect(actualResult.token).toBe(appConfig.discord.key); - }); - - it("returns null .token when discord property is not defined in config file", () => { - // Arrange - const appConfig = {}; - - // Act - const actualResult = new DiscordChatListenerConfig({ appConfig }) - - // Assert - expect(actualResult.token).toBe(null); - }); -}); diff --git a/app/services/db/dbAdapterBase.js b/app/services/db/adapters/dbAdapterBase.js similarity index 100% rename from app/services/db/dbAdapterBase.js rename to app/services/db/adapters/dbAdapterBase.js diff --git a/app/services/db/dbAdapterBase.spec.js b/app/services/db/adapters/dbAdapterBase.spec.js similarity index 100% rename from app/services/db/dbAdapterBase.spec.js rename to app/services/db/adapters/dbAdapterBase.spec.js diff --git a/app/services/db/adapters/mariaDbAdapter.js b/app/services/db/adapters/mariaDb/mariaDbAdapter.js similarity index 96% rename from app/services/db/adapters/mariaDbAdapter.js rename to app/services/db/adapters/mariaDb/mariaDbAdapter.js index 43b5305..bf2f79e 100644 --- a/app/services/db/adapters/mariaDbAdapter.js +++ b/app/services/db/adapters/mariaDb/mariaDbAdapter.js @@ -1,6 +1,6 @@ 'use strict'; -const DbAdapterBase = require("@services/db/dbAdapterBase"); +const DbAdapterBase = require("@dbAdapters/dbAdapterBase"); module.exports = class MariaDbAdapter extends DbAdapterBase { constructor({ mySql, dbConfig, logger }) { diff --git a/app/services/db/adapters/mariaDbAdapter.spec.js b/app/services/db/adapters/mariaDb/mariaDbAdapter.spec.js similarity index 98% rename from app/services/db/adapters/mariaDbAdapter.spec.js rename to app/services/db/adapters/mariaDb/mariaDbAdapter.spec.js index 8da2801..83dec03 100644 --- a/app/services/db/adapters/mariaDbAdapter.spec.js +++ b/app/services/db/adapters/mariaDb/mariaDbAdapter.spec.js @@ -1,6 +1,6 @@ 'use strict'; -const MariaDbAdapter = require("@dbAdapters/mariaDbAdapter"); +const MariaDbAdapter = require("@dbAdapters/mariaDb/mariaDbAdapter"); var theoretically = require("jasmine-theories"); describe("mariaDbAdapter", () => { diff --git a/app/services/db/dbRepositoryBase.js b/app/services/db/repositories/dbRepositoryBase.js similarity index 100% rename from app/services/db/dbRepositoryBase.js rename to app/services/db/repositories/dbRepositoryBase.js diff --git a/app/services/db/repositories/notesRepository.js b/app/services/db/repositories/notes/notesRepository.js similarity index 82% rename from app/services/db/repositories/notesRepository.js rename to app/services/db/repositories/notes/notesRepository.js index 2448006..328e876 100644 --- a/app/services/db/repositories/notesRepository.js +++ b/app/services/db/repositories/notes/notesRepository.js @@ -1,6 +1,6 @@ 'use strict'; -const DbRepositoryBase = require("@services/db/dbRepositoryBase"); +const DbRepositoryBase = require("@dbRepositories/dbRepositoryBase"); module.exports = class NotesRepository extends DbRepositoryBase { constructor({ dbAdapter, logger }) { @@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS ${this.tableName} ( user_id BIGINT(8), channel_id BIGINT(8), nick VARCHAR(255) NOT NULL, + server VARCHAR(255) NOT NULL, message LONGTEXT NOT NULL );`); if (resultHeader.warningStatus === 0) { @@ -44,22 +45,22 @@ CREATE TABLE IF NOT EXISTS ${this.tableName} ( this.tableCreated = true; } - async insertNote(timestamp, userID, channelID, nick, message) { - this.init(); - return await this.dbAdapter.connection.query(`INSERT INTO ${this.tableName} (timestamp, user_id, channel_id, nick, message) VALUES (?, ?, ?, ?, ?);`, - [timestamp, userID, channelID, nick, message] + async insertNote(timestamp, userID, channelID, nick, message, server) { + await this.init(); + return await this.dbAdapter.connection.query(`INSERT INTO ${this.tableName} (timestamp, user_id, channel_id, nick, message, server) VALUES (?, ?, ?, ?, ?, ?);`, + [timestamp, userID, channelID, nick, message, server] ); } async getRandomNote() { - this.init(); + await this.init(); return await this.dbAdapter.connection.query( `SELECT nick, message FROM ${this.tableName} ORDER BY RAND() LIMIT 1;` ); } async getRandomNoteByContent(message) { - this.init(); + await this.init(); return await this.dbAdapter.connection.query( `SELECT nick, message FROM ${this.tableName} WHERE message LIKE ? ORDER BY RAND() LIMIT 1;`, [`%${message}%`] diff --git a/config.json b/config.json index 177bbcd..0b668a8 100644 --- a/config.json +++ b/config.json @@ -2,10 +2,20 @@ "bot": { "name": null, "version": null, - "description": null - }, - "discord": { - "key": null + "description": null, + "chatListeners": [ + { + "name": "discord", + "enabled": true, + "enabledActions": [ + "notes", + "quote" + ], + "settings": { + "key": null + } + } + ] }, "database": { "name": null, diff --git a/index.js b/index.js index e8efc03..8093a6f 100644 --- a/index.js +++ b/index.js @@ -10,6 +10,7 @@ const Container = require('@root/container'); await Container.cradle.bot .init() .catch((e) => { - Container.cradle.logger.log("A fatal error occurred:\n", e); + Container.cradle.logger.log("Halt! A fatal error occurred:\n", e); + process.exit(); }); })(); diff --git a/package.json b/package.json index fc9b72a..a98da3f 100644 --- a/package.json +++ b/package.json @@ -35,11 +35,13 @@ }, "_moduleAliases": { "@root": "app", - "@actions": "app/actions", + "@actionHandlers": "app/actionHandlers", + "@actionPersistenceHandlers": "app/actionPersistenceHandlers", "@services": "app/services", "@errors": "app/errors", + "@config": "app/config", "@dbRepositories": "app/services/db/repositories", "@dbAdapters": "app/services/db/adapters", - "@chatListeners": "app/services/chatListeners" + "@chatListeners": "app/chatListeners" } }