From f8e35303d108e70bd148184be3f256d90476c721 Mon Sep 17 00:00:00 2001 From: Chris Sebok Date: Sun, 28 Jun 2020 11:41:10 +0100 Subject: [PATCH 1/5] Temp safety commit - to be overridden --- app/actions/actionHandlerMessage.js | 16 ++ app/actions/actionMessage.js | 24 --- app/actions/actionMessage.spec.js | 54 ------ app/actions/handlers/genericActionHandler.js | 19 +++ app/actions/messageResolverBase.js | 37 +++++ app/actions/messageResolverBase.spec.js | 65 ++++++++ .../notesActionPersistenceHandler.spec.js | 6 +- app/bot.js | 36 ++-- app/container.js | 9 +- app/services/appConfig/appConfig.js | 4 +- app/services/appConfig/appConfig.spec.js | 8 +- .../discord/discordChatListener.js | 75 +++++++++ .../discord/discordChatListener.spec.js | 154 ++++++++++++++++++ .../discord/discordChatListenerConfig.js | 16 +- .../discord/discordChatListenerConfig.spec.js | 80 ++++++++- .../discord/discordMessageResolver.js | 11 ++ .../discord/discordMessageResolver.spec.js | 95 +++++++++++ .../db/repositories/notesRepository.js | 7 +- config.json | 18 +- index.js | 1 + 20 files changed, 619 insertions(+), 116 deletions(-) create mode 100644 app/actions/actionHandlerMessage.js delete mode 100644 app/actions/actionMessage.js delete mode 100644 app/actions/actionMessage.spec.js create mode 100644 app/actions/handlers/genericActionHandler.js create mode 100644 app/actions/messageResolverBase.js create mode 100644 app/actions/messageResolverBase.spec.js create mode 100644 app/services/chatListeners/discord/discordChatListener.js create mode 100644 app/services/chatListeners/discord/discordChatListener.spec.js create mode 100644 app/services/chatListeners/discord/discordMessageResolver.js create mode 100644 app/services/chatListeners/discord/discordMessageResolver.spec.js diff --git a/app/actions/actionHandlerMessage.js b/app/actions/actionHandlerMessage.js new file mode 100644 index 0000000..f87c063 --- /dev/null +++ b/app/actions/actionHandlerMessage.js @@ -0,0 +1,16 @@ +'use strict'; + +// This class acts as an interface between the chatListeners and the actionHandlers +module.exports = class ActionHandlerMessage { + constructor() { + this.command = ""; + this.data = ""; + this.isBangCommand = false; + this.server = null; + + this.userId = 0; + this.channelID = 0 + this.nick = ""; + this.timestamp = null; + } +} 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/genericActionHandler.js b/app/actions/handlers/genericActionHandler.js new file mode 100644 index 0000000..7971cdd --- /dev/null +++ b/app/actions/handlers/genericActionHandler.js @@ -0,0 +1,19 @@ +'use strict'; + +const ActionHandlerBase = require("@actions/actionHandlerBase"); + +module.exports = class GenericActionHandler extends ActionHandlerBase { + constructor({ logger }) { + super(logger); + } + + async handle(actionHandlerMessage) { + if (!actionHandlerMessage) { + return; + } + + // TODO: This action handler is called whenever a bang command is not found for a message received by any server. + // Do we need to log here? + this.logger.log(`${this.logPrefix}Generic action handler received message`); + } +}; diff --git a/app/actions/messageResolverBase.js b/app/actions/messageResolverBase.js new file mode 100644 index 0000000..e7892a5 --- /dev/null +++ b/app/actions/messageResolverBase.js @@ -0,0 +1,37 @@ +'use strict'; + +const NotImplemented = require("@errors/notImplemented"); +const ActionHandlerMessage = require("@actions/actionHandlerMessage"); + +module.exports = class MessageResolverBase { + resolve() { + // Must be overridden and call resolveChatMessage() + 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.isBang = 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.isBang = true; + action.command = matches[1].toLowerCase(); + if (matches[2]) { + action.data = matches[2]; + } + } + + return action; + } +} diff --git a/app/actions/messageResolverBase.spec.js b/app/actions/messageResolverBase.spec.js new file mode 100644 index 0000000..e23dd42 --- /dev/null +++ b/app/actions/messageResolverBase.spec.js @@ -0,0 +1,65 @@ +'use strict'; + +const MessageResolverBase = require("./messageResolverBase"); +var theoretically = require("jasmine-theories"); + +describe("actionMessage", () => { + 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 isBang to true when bang found in message", () => { + const message = "!mycommand"; + const action = new MessageResolverBase().resolveChatMessage(message); + expect(action.isBang).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 isBang to false when no bang command found in message", () => { + const message = "jeff is a lovely person"; + const action = new MessageResolverBase().resolveChatMessage(message); + expect(action.isBang).toBeFalse(); + }); +}); diff --git a/app/actions/persistenceHandlers/notesActionPersistenceHandler.spec.js b/app/actions/persistenceHandlers/notesActionPersistenceHandler.spec.js index 2bf4579..40c512b 100644 --- a/app/actions/persistenceHandlers/notesActionPersistenceHandler.spec.js +++ b/app/actions/persistenceHandlers/notesActionPersistenceHandler.spec.js @@ -51,7 +51,7 @@ describe("notesActionPersistenceHandler", () => { 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 +66,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 +97,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/bot.js b/app/bot.js index 004febc..543f9eb 100644 --- a/app/bot.js +++ b/app/bot.js @@ -1,6 +1,6 @@ 'use strict'; -const DiscordMessage = require("./actions/actionMessage"); +const DiscordMessage = require("./actions/messageResolver"); module.exports = class Bot { constructor({ @@ -22,25 +22,37 @@ module.exports = class Bot { async init() { this.logger.log(`${this.logPrefix}Initialising bot`); - await this.initDiscord(); - this.logger.log(`${this.logPrefix}Initialisation complete`); + await this.initDiscord() + .then(async () => { + 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 { + // 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); - } + //try { + this.client.login(this.discordToken) + .then(() => { + console.log("resolve1"); + // console.log(a); + + resolve(true); + this.logger.log(`${this.logPrefix}Logged into Discord2`); + }) + .catch(e => { + console.log("ERROR1"); + reject(e); + }); + // resolve(true); + // } catch (e) { + // console.log("ERROR3"); + + // } }); } diff --git a/app/container.js b/app/container.js index ebf44e4..cbfe052 100644 --- a/app/container.js +++ b/app/container.js @@ -16,9 +16,11 @@ const DiscordChatListenerConfig = require('@chatListeners/discord/discordChatLis const DbAdapter = require('@dbAdapters/mariaDbAdapter'); const Logger = require('@services/logging/logger'); // Actions +const MessageResolver = require("@actions/messageResolver"); const HelpActionHandler = require("@actions/handlers/helpActionHandler"); const NotesActionHandler = require("@actions/handlers/notesActionHandler"); const QuoteActionHandler = require("@actions/handlers/quoteActionHandler"); +const GenericActionHandler = require("@actions/handlers/genericActionHandler"); // Action Persistence Handlers const NotesActionPersistenceHandler = require("@actions/persistenceHandlers/notesActionPersistenceHandler"); // DB Repositories @@ -60,19 +62,22 @@ container.register({ notesRepository: ioc.asClass(NotesRepository), // Register Actions - TODO: Register automatically + genericActionHandler: ioc.asClass(GenericActionHandler), helpAction: ioc.asClass(HelpActionHandler), notesAction: ioc.asClass(NotesActionHandler), quoteAction: ioc.asClass(QuoteActionHandler), + // Actions + messageResolver: ioc.asClass(MessageResolver), // Add all of the above actions into the below returned array - helpActionActions: ioc.asFunction(function () { + helpActionActions: ioc.asFunction(() => { 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 () { + actions: ioc.asFunction(() => { return container.cradle.helpActionActions .concat([container.cradle.helpAction]); }, { lifetime: Lifetime.SINGLETON }) diff --git a/app/services/appConfig/appConfig.js b/app/services/appConfig/appConfig.js index 295015b..4b0bdc0 100644 --- a/app/services/appConfig/appConfig.js +++ b/app/services/appConfig/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/services/appConfig/appConfig.spec.js index 41b525b..56ad9f0 100644 --- a/app/services/appConfig/appConfig.spec.js +++ b/app/services/appConfig/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/services/chatListeners/discord/discordChatListener.js b/app/services/chatListeners/discord/discordChatListener.js new file mode 100644 index 0000000..44ab93e --- /dev/null +++ b/app/services/chatListeners/discord/discordChatListener.js @@ -0,0 +1,75 @@ +'use strict'; + +module.exports = class DiscordChatListener { + constructor({ logger, discordChatListenerConfig, discordClient, discordMessageResolver, genericActionHandler }) { + this.logger = logger; + this.config = discordChatListenerConfig; + this.client = discordClient; + this.messageResolver = discordMessageResolver; + this.genericActionHandler = genericActionHandler; + this.logPrefix = `[${this.constructor.name}] `; + } + + async init() { + this.logger.log(`${this.logPrefix}Initialising - logging in to Discord`); + + await this.client + .login(this.config.token) + .then(() => { + this.logger.log(`${this.logPrefix}Logged in to Discord`); + this.logger.log(`${this.logPrefix}Registering message listener`); + + this.client.on("message", msg => this.messageHandler(msg)); + + this.logger.log(`${this.logPrefix}Registered message listener`); + }); + + this.logger.log(`${this.logPrefix}Initialised successfully`); + } + + messageHandler(discordMessage) { + if (!discordMessage || !(typeof discordMessage === "object")) { + return; + } + const content = discordMessage.content; + + this.logger.log(`${this.logPrefix}Received content '${content}'`); + const message = this.messageResolver.resolve(discordMessage); + + if (!message.isBangCommand) { + this.genericActionHandler.handle(message); + } + + return "jeff"; + } + // 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 ActionMessage(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/services/chatListeners/discord/discordChatListener.spec.js b/app/services/chatListeners/discord/discordChatListener.spec.js new file mode 100644 index 0000000..dec73d1 --- /dev/null +++ b/app/services/chatListeners/discord/discordChatListener.spec.js @@ -0,0 +1,154 @@ +'use strict'; + +const Discord = require("discord.js"); +const DiscordChatListener = require("./discordChatListener"); +const DiscordMessageResolver = require("@chatListeners/discord/discordMessageResolver"); +const ActionHandlerMessage = require("@actions/actionHandlerMessage"); +const faker = require('faker'); +var theoretically = require("jasmine-theories"); + +describe("discordChatListener", () => { + let discordClient; + let logger; + let discordChatListenerConfig; + + beforeEach(() => { + discordClient = new Discord.Client(); + spyOn(discordClient, "login") + .and.resolveTo(Promise.resolve()); + logger = jasmine.createSpyObj("logger", ["log"]); + discordChatListenerConfig = { + "token": faker.lorem.word() + }; + }); + + it("sets logger property from logger injection", () => { + // Arrange + + // Act + const listener = new DiscordChatListener({ logger, discordChatListenerConfig }); + + // Assert + expect(listener.logger).toBe(logger); + }); + + it("sets config property from discordChatListenerConfig", () => { + // Arrange + // Act + const listener = new DiscordChatListener({ discordChatListenerConfig }); + + // Assert + expect(listener.config).toBe(discordChatListenerConfig); + }); + + it("sets discord property from discord injection", () => { + // Arrange + + // Act + const listener = new DiscordChatListener({ discordClient, discordChatListenerConfig }); + + // Assert + expect(listener.client).toBe(discordClient); + }); + + // init() + it("init() 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("init() resolves when client.login() resolves", async () => { + // Arrange + const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig }); + + // Act & Assert + await expectAsync(listener.init()).toBeResolved(); + }); + + it("init() registers message handler callback when client.login() resolves", async () => { + // Arrange + const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig }); + spyOn(discordClient, "on"); + + // Act + await listener.init(); + + 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 = new Discord.Client(); + spyOn(discordClient, "login") + .and.rejectWith("login error"); + spyOn(discordClient, "on"); + const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig }); + + // Act + await expectAsync(listener.init()).toBeRejected(); + expect(discordClient.on).toHaveBeenCalledTimes(0); + }); + + // messageHandler() + theoretically.it("returns without error when msg is '%s'", [null, "", " ", undefined], (insertedValue) => { + // Arrange + const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig }); + + // Act + const actualResult = listener.messageHandler(insertedValue); + + // Assert + expect(actualResult).toBe(undefined); + }); + + it("resolves discord message with discordMessageResolver", () => { + // Arrange + const discordMessageResolver = jasmine.createSpyObj("discordMessageResolver", { + resolve: new ActionHandlerMessage() + }); + + const discordMessage = jasmine.createSpyObj("discordMessage", ["reply"]); + + const genericActionHandler = jasmine.createSpyObj("genericActionHandler", ["handle"]) + + const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig, discordMessageResolver, genericActionHandler }); + + // Act + listener.messageHandler(discordMessage); + + // Assert + expect(discordMessageResolver.resolve).toHaveBeenCalledWith(discordMessage); + }); + + it("passes resolve result to generic action handler if isBangCommand is false", () => { + // Arrange + const resolvedMessage = new ActionHandlerMessage(); + resolvedMessage.isBangCommand = false; + + const discordMessageResolver = jasmine.createSpyObj("discordMessageResolver", { + resolve: resolvedMessage + }); + + const discordMessage = jasmine.createSpyObj("discordMessage", ["reply"]); + + const genericActionHandler = jasmine.createSpyObj("genericActionHandler", ["handle"]) + + const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig, discordMessageResolver, genericActionHandler }); + + // Act + listener.messageHandler(discordMessage); + + // Assert + expect(genericActionHandler.handle).toHaveBeenCalledWith(resolvedMessage); + }); + + // TODO Complete messageHandler tests +}); diff --git a/app/services/chatListeners/discord/discordChatListenerConfig.js b/app/services/chatListeners/discord/discordChatListenerConfig.js index 87324f7..9b93091 100644 --- a/app/services/chatListeners/discord/discordChatListenerConfig.js +++ b/app/services/chatListeners/discord/discordChatListenerConfig.js @@ -2,12 +2,20 @@ module.exports = class DiscordChatListenerConfig { constructor({ appConfig }) { - if (appConfig.discord) { - this.token = appConfig.discord.key; + // 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; } - // Defaults - this.token = null; + // Inject found config settings + this.token = config.settings.key; + this.enabled = config.enabled; } } diff --git a/app/services/chatListeners/discord/discordChatListenerConfig.spec.js b/app/services/chatListeners/discord/discordChatListenerConfig.spec.js index 413e769..80b6fb8 100644 --- a/app/services/chatListeners/discord/discordChatListenerConfig.spec.js +++ b/app/services/chatListeners/discord/discordChatListenerConfig.spec.js @@ -1,13 +1,23 @@ 'use strict'; const DiscordChatListenerConfig = require("./discordChatListenerConfig"); +var theoretically = require("jasmine-theories"); +const faker = require('faker'); describe("discordChatListenerConfig", function () { - it("maps injected appConfig.discord.key property to .token", () => { + it("maps injected appConfig.bot.chatListeners[discord].key property to .token", () => { // Arrange + const expectedKey = faker.lorem.word(); const appConfig = { - discord: { - key: "mytestkey" + bot: { + chatListeners: [ + { + name: "discord", + settings: { + key: expectedKey + } + } + ] } }; @@ -15,17 +25,71 @@ describe("discordChatListenerConfig", function () { const actualResult = new DiscordChatListenerConfig({ appConfig }); // Assert - expect(actualResult.token).toBe(appConfig.discord.key); + expect(actualResult.token).toBe(expectedKey); }); - it("returns null .token when discord property is not defined in config file", () => { + it("maps injected appConfig.bot.chatListeners[discord].enabled property to .enabled", () => { // Arrange - const appConfig = {}; + const expectedEnabled = faker.random.boolean(); + const appConfig = { + bot: { + chatListeners: [ + { + name: "discord", + enabled: expectedEnabled, + settings: { + key: "not_tested" + } + } + ] + } + }; // Act - const actualResult = new DiscordChatListenerConfig({ appConfig }) + const actualResult = new DiscordChatListenerConfig({ appConfig }); // Assert - expect(actualResult.token).toBe(null); + 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/services/chatListeners/discord/discordMessageResolver.js b/app/services/chatListeners/discord/discordMessageResolver.js new file mode 100644 index 0000000..a01430a --- /dev/null +++ b/app/services/chatListeners/discord/discordMessageResolver.js @@ -0,0 +1,11 @@ +'use strict'; + +const MessageResolverBase = require("@actions/messageResolverBase"); + +module.exports = class DiscordMessageResolver extends MessageResolverBase { + resolve(discordMessage) { + // const message = super.resolveChatMessage(discordMessage.content); + // TODO: Append discord specific message content + // return message; + } +} diff --git a/app/services/chatListeners/discord/discordMessageResolver.spec.js b/app/services/chatListeners/discord/discordMessageResolver.spec.js new file mode 100644 index 0000000..80b6fb8 --- /dev/null +++ b/app/services/chatListeners/discord/discordMessageResolver.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/services/db/repositories/notesRepository.js b/app/services/db/repositories/notesRepository.js index 2448006..a482e91 100644 --- a/app/services/db/repositories/notesRepository.js +++ b/app/services/db/repositories/notesRepository.js @@ -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,10 +45,10 @@ CREATE TABLE IF NOT EXISTS ${this.tableName} ( this.tableCreated = true; } - async insertNote(timestamp, userID, channelID, nick, message) { + async insertNote(timestamp, userID, channelID, nick, message, server) { 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] + 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] ); } 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..98ee3f2 100644 --- a/index.js +++ b/index.js @@ -11,5 +11,6 @@ const Container = require('@root/container'); .init() .catch((e) => { Container.cradle.logger.log("A fatal error occurred:\n", e); + process.exit(); }); })(); From 5cb8499de974c20584639828851da016ebf88743 Mon Sep 17 00:00:00 2001 From: Chris Sebok Date: Sat, 11 Jul 2020 17:26:26 +0100 Subject: [PATCH 2/5] Significant rename and refactor of most classes - Everything is now implemented except the new ActionHandlerResolver - Need to refactor ActionHandlers to accept new generic ActionHandlerMessage - Implemented new DiscordMessageResolver - Reorganised container, though there is still more work to do; many elements can now be auto-loaded - Added / updated numerous module-alias aliases --- .../actionHandlerBase.js | 0 .../actionHandlerMessage.js | 9 +- app/actionHandlers/actionHandlerResolver.js | 9 + .../actionHandlerResolver.spec.js | 13 ++ .../generic}/genericActionHandler.js | 2 +- .../help}/helpActionHandler.js | 2 +- .../notes}/notesActionHandler.js | 2 +- .../quote}/quoteActionHandler.js | 2 +- .../notes}/notesActionPersistenceHandler.js | 0 .../notesActionPersistenceHandler.spec.js | 2 +- app/bot.js | 94 --------- .../discord/discordChatListener.js | 34 ++-- .../discord/discordChatListener.spec.js | 188 ++++++++++++++++++ .../discord/discordChatListenerConfig.js | 0 .../discord/discordChatListenerConfig.spec.js | 10 +- .../discord/discordMessageResolver.js | 35 ++++ .../discord/discordMessageResolver.spec.js | 80 ++++++++ .../messageResolverBase.js | 6 +- .../messageResolverBase.spec.js | 2 +- .../appConfig => config/app}/appConfig.js | 0 .../app}/appConfig.spec.js | 0 app/{ => config/bot}/botConfig.js | 0 app/{ => config/bot}/botConfig.spec.js | 0 app/{services => config}/db/dbConfig.js | 0 app/{services => config}/db/dbConfig.spec.js | 0 app/container.js | 85 +++++--- app/services/bot/bot.js | 97 +++++++++ app/{ => services/bot}/bot.spec.js | 0 .../discord/discordChatListener.spec.js | 154 -------------- .../discord/discordMessageResolver.js | 11 - .../discord/discordMessageResolver.spec.js | 95 --------- .../db/{ => adapters}/dbAdapterBase.js | 0 .../db/{ => adapters}/dbAdapterBase.spec.js | 0 .../adapters/{ => mariaDb}/mariaDbAdapter.js | 2 +- .../{ => mariaDb}/mariaDbAdapter.spec.js | 2 +- .../db/{ => repositories}/dbRepositoryBase.js | 0 .../{ => notes}/notesRepository.js | 8 +- index.js | 2 +- package.json | 6 +- 39 files changed, 530 insertions(+), 422 deletions(-) rename app/{actions => actionHandlers}/actionHandlerBase.js (100%) rename app/{actions => actionHandlers}/actionHandlerMessage.js (55%) create mode 100644 app/actionHandlers/actionHandlerResolver.js create mode 100644 app/actionHandlers/actionHandlerResolver.spec.js rename app/{actions/handlers => actionHandlers/generic}/genericActionHandler.js (86%) rename app/{actions/handlers => actionHandlers/help}/helpActionHandler.js (85%) rename app/{actions/handlers => actionHandlers/notes}/notesActionHandler.js (92%) rename app/{actions/handlers => actionHandlers/quote}/quoteActionHandler.js (94%) rename app/{actions/persistenceHandlers => actionPersistenceHandlers/notes}/notesActionPersistenceHandler.js (100%) rename app/{actions/persistenceHandlers => actionPersistenceHandlers/notes}/notesActionPersistenceHandler.spec.js (97%) delete mode 100644 app/bot.js rename app/{services => }/chatListeners/discord/discordChatListener.js (61%) create mode 100644 app/chatListeners/discord/discordChatListener.spec.js rename app/{services => }/chatListeners/discord/discordChatListenerConfig.js (100%) rename app/{services => }/chatListeners/discord/discordChatListenerConfig.spec.js (92%) create mode 100644 app/chatListeners/discord/discordMessageResolver.js create mode 100644 app/chatListeners/discord/discordMessageResolver.spec.js rename app/{actions => chatListeners}/messageResolverBase.js (81%) rename app/{actions => chatListeners}/messageResolverBase.spec.js (98%) rename app/{services/appConfig => config/app}/appConfig.js (100%) rename app/{services/appConfig => config/app}/appConfig.spec.js (100%) rename app/{ => config/bot}/botConfig.js (100%) rename app/{ => config/bot}/botConfig.spec.js (100%) rename app/{services => config}/db/dbConfig.js (100%) rename app/{services => config}/db/dbConfig.spec.js (100%) create mode 100644 app/services/bot/bot.js rename app/{ => services/bot}/bot.spec.js (100%) delete mode 100644 app/services/chatListeners/discord/discordChatListener.spec.js delete mode 100644 app/services/chatListeners/discord/discordMessageResolver.js delete mode 100644 app/services/chatListeners/discord/discordMessageResolver.spec.js rename app/services/db/{ => adapters}/dbAdapterBase.js (100%) rename app/services/db/{ => adapters}/dbAdapterBase.spec.js (100%) rename app/services/db/adapters/{ => mariaDb}/mariaDbAdapter.js (96%) rename app/services/db/adapters/{ => mariaDb}/mariaDbAdapter.spec.js (98%) rename app/services/db/{ => repositories}/dbRepositoryBase.js (100%) rename app/services/db/repositories/{ => notes}/notesRepository.js (93%) diff --git a/app/actions/actionHandlerBase.js b/app/actionHandlers/actionHandlerBase.js similarity index 100% rename from app/actions/actionHandlerBase.js rename to app/actionHandlers/actionHandlerBase.js diff --git a/app/actions/actionHandlerMessage.js b/app/actionHandlers/actionHandlerMessage.js similarity index 55% rename from app/actions/actionHandlerMessage.js rename to app/actionHandlers/actionHandlerMessage.js index f87c063..30eab19 100644 --- a/app/actions/actionHandlerMessage.js +++ b/app/actionHandlers/actionHandlerMessage.js @@ -1,15 +1,20 @@ 'use strict'; -// This class acts as an interface between the chatListeners and the actionHandlers 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.userId = 0; - this.channelID = 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..463bfa4 --- /dev/null +++ b/app/actionHandlers/actionHandlerResolver.js @@ -0,0 +1,9 @@ +'use strict'; + +const ActionHandlerMessage = require("@actionHandlers/actionHandlerMessage"); + +module.exports = class ActionHandlerResolver { + async resolve(actionHandlerMessage) { + throw Error("Implement the ActionHandlerResolver!"); + } +} diff --git a/app/actionHandlers/actionHandlerResolver.spec.js b/app/actionHandlers/actionHandlerResolver.spec.js new file mode 100644 index 0000000..211dd0a --- /dev/null +++ b/app/actionHandlers/actionHandlerResolver.spec.js @@ -0,0 +1,13 @@ +'use strict'; + +var theoretically = require("jasmine-theories"); + +describe("actionHandlerResolver", () => { + + // 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(""); + // }); + +}); diff --git a/app/actions/handlers/genericActionHandler.js b/app/actionHandlers/generic/genericActionHandler.js similarity index 86% rename from app/actions/handlers/genericActionHandler.js rename to app/actionHandlers/generic/genericActionHandler.js index 7971cdd..f4502f7 100644 --- a/app/actions/handlers/genericActionHandler.js +++ b/app/actionHandlers/generic/genericActionHandler.js @@ -1,6 +1,6 @@ 'use strict'; -const ActionHandlerBase = require("@actions/actionHandlerBase"); +const ActionHandlerBase = require("@actionHandlers/actionHandlerBase"); module.exports = class GenericActionHandler extends ActionHandlerBase { constructor({ logger }) { diff --git a/app/actions/handlers/helpActionHandler.js b/app/actionHandlers/help/helpActionHandler.js similarity index 85% rename from app/actions/handlers/helpActionHandler.js rename to app/actionHandlers/help/helpActionHandler.js index 0e92f4a..365b261 100644 --- a/app/actions/handlers/helpActionHandler.js +++ b/app/actionHandlers/help/helpActionHandler.js @@ -1,6 +1,6 @@ 'use strict'; -const ActionHandlerBase = require("@actions/actionHandlerBase"); +const ActionHandlerBase = require("@actionHandlers/actionHandlerBase"); module.exports = class HelpActionHandler extends ActionHandlerBase { constructor({ logger, helpActionActions }) { diff --git a/app/actions/handlers/notesActionHandler.js b/app/actionHandlers/notes/notesActionHandler.js similarity index 92% rename from app/actions/handlers/notesActionHandler.js rename to app/actionHandlers/notes/notesActionHandler.js index 5324bbb..32c459b 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 }) { diff --git a/app/actions/handlers/quoteActionHandler.js b/app/actionHandlers/quote/quoteActionHandler.js similarity index 94% rename from app/actions/handlers/quoteActionHandler.js rename to app/actionHandlers/quote/quoteActionHandler.js index 4f35cb8..b0424bd 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 }) { diff --git a/app/actions/persistenceHandlers/notesActionPersistenceHandler.js b/app/actionPersistenceHandlers/notes/notesActionPersistenceHandler.js similarity index 100% rename from app/actions/persistenceHandlers/notesActionPersistenceHandler.js rename to app/actionPersistenceHandlers/notes/notesActionPersistenceHandler.js diff --git a/app/actions/persistenceHandlers/notesActionPersistenceHandler.spec.js b/app/actionPersistenceHandlers/notes/notesActionPersistenceHandler.spec.js similarity index 97% rename from app/actions/persistenceHandlers/notesActionPersistenceHandler.spec.js rename to app/actionPersistenceHandlers/notes/notesActionPersistenceHandler.spec.js index 40c512b..a236eb7 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", () => { diff --git a/app/bot.js b/app/bot.js deleted file mode 100644 index 543f9eb..0000000 --- a/app/bot.js +++ /dev/null @@ -1,94 +0,0 @@ -'use strict'; - -const DiscordMessage = require("./actions/messageResolver"); - -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() - .then(async () => { - 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(); - - //try { - this.client.login(this.discordToken) - .then(() => { - console.log("resolve1"); - // console.log(a); - - resolve(true); - this.logger.log(`${this.logPrefix}Logged into Discord2`); - }) - .catch(e => { - console.log("ERROR1"); - reject(e); - }); - // resolve(true); - // } catch (e) { - // console.log("ERROR3"); - - // } - }); - } - - 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/services/chatListeners/discord/discordChatListener.js b/app/chatListeners/discord/discordChatListener.js similarity index 61% rename from app/services/chatListeners/discord/discordChatListener.js rename to app/chatListeners/discord/discordChatListener.js index 44ab93e..50299a9 100644 --- a/app/services/chatListeners/discord/discordChatListener.js +++ b/app/chatListeners/discord/discordChatListener.js @@ -1,47 +1,53 @@ 'use strict'; module.exports = class DiscordChatListener { - constructor({ logger, discordChatListenerConfig, discordClient, discordMessageResolver, genericActionHandler }) { + constructor({ logger, actionHandlerResolver, discordChatListenerConfig, discordClient, discordMessageResolver }) { this.logger = logger; this.config = discordChatListenerConfig; this.client = discordClient; this.messageResolver = discordMessageResolver; - this.genericActionHandler = genericActionHandler; + this.actionHandlerResolver = actionHandlerResolver; this.logPrefix = `[${this.constructor.name}] `; } async init() { - this.logger.log(`${this.logPrefix}Initialising - logging in to Discord`); + this.logger.log(`${this.logPrefix}Initialising - logging in`); await this.client .login(this.config.token) .then(() => { - this.logger.log(`${this.logPrefix}Logged in to Discord`); + this.logger.log(`${this.logPrefix}Logged in`); this.logger.log(`${this.logPrefix}Registering message listener`); - this.client.on("message", msg => this.messageHandler(msg)); + 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}Registered message listener`); + this.logger.log(`${this.logPrefix}Message listener registered`); }); this.logger.log(`${this.logPrefix}Initialised successfully`); } - messageHandler(discordMessage) { + async handleMessage(discordMessage) { if (!discordMessage || !(typeof discordMessage === "object")) { return; } - const content = discordMessage.content; - this.logger.log(`${this.logPrefix}Received content '${content}'`); - const message = this.messageResolver.resolve(discordMessage); + this.logger.log(`${this.logPrefix}Received content '${discordMessage.content}'`); - if (!message.isBangCommand) { - this.genericActionHandler.handle(message); - } + const actionHandlerMessage = await this.messageResolver.resolve(discordMessage); + + const actionHandler = await this.actionHandlerResolver.resolve(actionHandlerMessage); - return "jeff"; + const reply = await actionHandler.handle(actionHandlerMessage); + + discordMessage.reply(reply); } + // async listenHandler(msg) { // const content = msg.content; // if (!content || !(content[0] === "!")) { diff --git a/app/chatListeners/discord/discordChatListener.spec.js b/app/chatListeners/discord/discordChatListener.spec.js new file mode 100644 index 0000000..317fc60 --- /dev/null +++ b/app/chatListeners/discord/discordChatListener.spec.js @@ -0,0 +1,188 @@ +'use strict'; + +const DiscordChatListener = require("./discordChatListener"); +const faker = require('faker'); +var theoretically = require("jasmine-theories"); + +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 logger property from logger injection", () => { + // Act + const listener = new DiscordChatListener({ logger, discordChatListenerConfig }); + + // Assert + expect(listener.logger).toBe(logger); + }); + + 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()", () => { + let logger; + let actionHandlerResolver; + let discordMessageResolver; + let discordMessage; + + beforeEach(() => { + logger = jasmine.createSpyObj("logger", ["log"]); + actionHandlerResolver = jasmine.createSpyObj("actionHandlerResolver", { + resolve: jasmine.createSpyObj("actionHandler", ["handle"]) + }); + discordMessageResolver = jasmine.createSpyObj("discordMessageResolver", ["resolve"]); + discordMessage = jasmine.createSpyObj("discordMessage", ["reply"]); + }); + + theoretically.it("returns without error when discordMessage is '%s'", [null, "", " ", undefined], async (insertedValue) => { + // Arrange + const listener = new DiscordChatListener({ logger }); + + // Act + const actualResult = await listener.handleMessage(insertedValue); + + // Assert + expect(actualResult).toBe(undefined); + }); + + it("resolves discord message to ActionHandlerMessage", async () => { + // Arrange + const listener = new DiscordChatListener({ logger, actionHandlerResolver, discordMessageResolver }); + + // Act + await listener.handleMessage(discordMessage); + + // Assert + expect(discordMessageResolver.resolve).toHaveBeenCalledWith(discordMessage); + }); + + it("resolves ActionHandler with ActionHandlerMessage", async () => { + // Arrange + const actionHandlerMessage = jasmine.createSpy("actionHandlerMessage"); + discordMessageResolver = jasmine.createSpyObj("discordMessageResolver", { + resolve: actionHandlerMessage + }); + + const listener = new DiscordChatListener({ logger, actionHandlerResolver, discordMessageResolver }); + + // Act + await listener.handleMessage(discordMessage); + + // Assert + expect(actionHandlerResolver.resolve).toHaveBeenCalledWith(actionHandlerMessage); + }); + + it("asynchronously handles message with resolved ActionHandler's handle() method", async () => { + // Arrange + const actionHandlerMessage = jasmine.createSpy("actionHandlerMessage"); + discordMessageResolver = jasmine.createSpyObj("discordMessageResolver", { + resolve: actionHandlerMessage + }); + const actionHandler = jasmine.createSpyObj("actionHandler", { + handle: async () => { + Promise.resolve(); + } + }); + actionHandlerResolver = jasmine.createSpyObj("actionHandlerResolver", { + resolve: actionHandler + }); + + const listener = new DiscordChatListener({ logger, actionHandlerResolver, discordMessageResolver }); + + // Act + await listener.handleMessage(discordMessage); + + // Assert + expect(actionHandler.handle).toHaveBeenCalledWith(actionHandlerMessage); + }); + + it("replies to the discordMessage 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 DiscordChatListener({ logger, actionHandlerResolver, discordMessageResolver }); + + // Act + await listener.handleMessage(discordMessage); + + // Assert + expect(discordMessage.reply).toHaveBeenCalledWith(expectedReply); + }); +}); diff --git a/app/services/chatListeners/discord/discordChatListenerConfig.js b/app/chatListeners/discord/discordChatListenerConfig.js similarity index 100% rename from app/services/chatListeners/discord/discordChatListenerConfig.js rename to app/chatListeners/discord/discordChatListenerConfig.js diff --git a/app/services/chatListeners/discord/discordChatListenerConfig.spec.js b/app/chatListeners/discord/discordChatListenerConfig.spec.js similarity index 92% rename from app/services/chatListeners/discord/discordChatListenerConfig.spec.js rename to app/chatListeners/discord/discordChatListenerConfig.spec.js index 80b6fb8..f78dc00 100644 --- a/app/services/chatListeners/discord/discordChatListenerConfig.spec.js +++ b/app/chatListeners/discord/discordChatListenerConfig.spec.js @@ -86,10 +86,10 @@ describe("discordChatListenerConfig", function () { }; 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 }); + // Act + const actualValue = new DiscordChatListenerConfig({ appConfig: insertedValue }); - // Assert - expect(actualValue.enabled).toBe(false); - }); + // 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..d01c122 --- /dev/null +++ b/app/chatListeners/discord/discordMessageResolver.js @@ -0,0 +1,35 @@ +'use strict'; + +const MessageResolverBase = require("@chatListeners/messageResolverBase"); +const ActionHandlerMessage = require("@actionHandlers/actionHandlerMessage"); + +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; + } + 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..e6644ff --- /dev/null +++ b/app/chatListeners/discord/discordMessageResolver.spec.js @@ -0,0 +1,80 @@ +'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 discordMessage = jasmine.createSpyObj("discordMessage", null, { + content: faker.lorem.sentences(), + author: { + id: expectedUserId, + username: expectedUsername + }, + 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); + }); +}); diff --git a/app/actions/messageResolverBase.js b/app/chatListeners/messageResolverBase.js similarity index 81% rename from app/actions/messageResolverBase.js rename to app/chatListeners/messageResolverBase.js index e7892a5..aafe0aa 100644 --- a/app/actions/messageResolverBase.js +++ b/app/chatListeners/messageResolverBase.js @@ -1,11 +1,11 @@ 'use strict'; const NotImplemented = require("@errors/notImplemented"); -const ActionHandlerMessage = require("@actions/actionHandlerMessage"); +const ActionHandlerMessage = require("@actionHandlers/actionHandlerMessage"); module.exports = class MessageResolverBase { - resolve() { - // Must be overridden and call resolveChatMessage() + async resolve() { + // Must be overridden and must call resolveChatMessage(inputMessage.) throw NotImplemented(); } diff --git a/app/actions/messageResolverBase.spec.js b/app/chatListeners/messageResolverBase.spec.js similarity index 98% rename from app/actions/messageResolverBase.spec.js rename to app/chatListeners/messageResolverBase.spec.js index e23dd42..65186fd 100644 --- a/app/actions/messageResolverBase.spec.js +++ b/app/chatListeners/messageResolverBase.spec.js @@ -3,7 +3,7 @@ const MessageResolverBase = require("./messageResolverBase"); var theoretically = require("jasmine-theories"); -describe("actionMessage", () => { +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"); diff --git a/app/services/appConfig/appConfig.js b/app/config/app/appConfig.js similarity index 100% rename from app/services/appConfig/appConfig.js rename to app/config/app/appConfig.js diff --git a/app/services/appConfig/appConfig.spec.js b/app/config/app/appConfig.spec.js similarity index 100% rename from app/services/appConfig/appConfig.spec.js rename to app/config/app/appConfig.spec.js 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 cbfe052..084d098 100644 --- a/app/container.js +++ b/app/container.js @@ -5,26 +5,35 @@ 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 AppConfig = require('@config/app/appConfig'); +const BotConfig = require('@config/bot/botConfig'); +const DbConfig = require('@config/db/dbConfig'); + +// Chat Listener objects - one set per server +const DiscordChatListener = require('@chatListeners/discord/discordChatListener'); const DiscordChatListenerConfig = require('@chatListeners/discord/discordChatListenerConfig'); +const DiscordMessageResolver = require("@chatListeners/discord/discordMessageResolver"); // Services -const DbAdapter = require('@dbAdapters/mariaDbAdapter'); +const DbAdapter = require('@dbAdapters/mariaDb/mariaDbAdapter'); const Logger = require('@services/logging/logger'); + // Actions -const MessageResolver = require("@actions/messageResolver"); -const HelpActionHandler = require("@actions/handlers/helpActionHandler"); -const NotesActionHandler = require("@actions/handlers/notesActionHandler"); -const QuoteActionHandler = require("@actions/handlers/quoteActionHandler"); -const GenericActionHandler = require("@actions/handlers/genericActionHandler"); +const HelpActionHandler = require("@actionHandlers/help/helpActionHandler"); +const NotesActionHandler = require("@actionHandlers/notes/notesActionHandler"); +const QuoteActionHandler = require("@actionHandlers/quote/quoteActionHandler"); +const GenericActionHandler = require("@actionHandlers/generic/genericActionHandler"); + +// Resolvers +const ActionHandlerResolver = require("@actionHandlers/actionHandlerResolver"); + // Action Persistence Handlers -const NotesActionPersistenceHandler = require("@actions/persistenceHandlers/notesActionPersistenceHandler"); +const NotesActionPersistenceHandler = require("@actionPersistenceHandlers/notes/notesActionPersistenceHandler"); + // DB Repositories -const NotesRepository = require("@dbRepositories/notesRepository"); +const NotesRepository = require("@dbRepositories/notes/notesRepository"); // 3rd party const MySQL = require("mysql2/promise"); @@ -40,26 +49,34 @@ console.log("[Root] Registering services"); container.register({ // Bootstrap bot: ioc.asClass(Bot, { lifetime: Lifetime.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 mySql: ioc.asValue(MySQL), - discord: ioc.asValue(Discord), + discordClient: ioc.asFunction(() => new Discord.Client()), + + // Chat Listener classes + discordChatListener: ioc.asClass(DiscordChatListener), + discordChatListenerConfig: ioc.asClass(DiscordChatListenerConfig), + discordMessageResolver: ioc.asClass(DiscordMessageResolver), // Register Action persistence handlers - TODO: Register automatically - notesActionPersistenceHandler: ioc.asClass(NotesActionPersistenceHandler, { lifetime: Lifetime.SINGLETON }), + notesActionPersistenceHandler: ioc.asClass(NotesActionPersistenceHandler), // 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), + notesRepository: ioc.asClass(NotesRepository, { lifetime: Lifetime.SINGLETON }), // Register Actions - TODO: Register automatically genericActionHandler: ioc.asClass(GenericActionHandler), @@ -67,21 +84,31 @@ container.register({ notesAction: ioc.asClass(NotesActionHandler), quoteAction: ioc.asClass(QuoteActionHandler), - // Actions - messageResolver: ioc.asClass(MessageResolver), + // Resolvers + actionHandlerResolver: ioc.asClass(ActionHandlerResolver), + discordMessageResolver: ioc.asClass(DiscordMessageResolver), + // Add all of the above actions into the below returned array - helpActionActions: ioc.asFunction(() => { - return [ - container.cradle.notesAction, - container.cradle.quoteAction - ]; - }, { lifetime: Lifetime.SINGLETON }), + // helpActionActions: ioc.asFunction(() => { + // 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(() => { - return container.cradle.helpActionActions - .concat([container.cradle.helpAction]); - }, { lifetime: Lifetime.SINGLETON }) + // actions: ioc.asFunction(() => { + // return container.cradle.helpActionActions + // .concat([container.cradle.helpAction]); + // }, { lifetime: Lifetime.SINGLETON }) }); + +// Auto loading +// container.loadModules(['@actionHandlers/*/*ActionHandler.js', 'repositories/**/*.js'], { +// resolverOptions: { +// injectionMode: InjectionMode.CLASSIC +// } +// }) + 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..5ca2718 --- /dev/null +++ b/app/services/bot/bot.js @@ -0,0 +1,97 @@ +'use strict'; + +module.exports = class Bot { + constructor({ + // TODO: Inject all chat listeners via a function... + discordChatListener, + // 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; + this.discordChatListener = discordChatListener; + } + + async init() { + this.logger.log(`${this.logPrefix}Initialising bot`); + // TODO: Call init() on each loaded chat listener + + await this.discordChatListener.init(); + // await this.initDiscord() + // .then(async () => { + // 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(); + + // //try { + // this.client.login(this.discordToken) + // .then(() => { + // console.log("resolve1"); + // // console.log(a); + + // resolve(true); + // this.logger.log(`${this.logPrefix}Logged into Discord2`); + // }) + // .catch(e => { + // console.log("ERROR1"); + // reject(e); + // }); + // // resolve(true); + // // } catch (e) { + // // console.log("ERROR3"); + + // // } + // }); + // } + + // 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/services/bot/bot.spec.js similarity index 100% rename from app/bot.spec.js rename to app/services/bot/bot.spec.js diff --git a/app/services/chatListeners/discord/discordChatListener.spec.js b/app/services/chatListeners/discord/discordChatListener.spec.js deleted file mode 100644 index dec73d1..0000000 --- a/app/services/chatListeners/discord/discordChatListener.spec.js +++ /dev/null @@ -1,154 +0,0 @@ -'use strict'; - -const Discord = require("discord.js"); -const DiscordChatListener = require("./discordChatListener"); -const DiscordMessageResolver = require("@chatListeners/discord/discordMessageResolver"); -const ActionHandlerMessage = require("@actions/actionHandlerMessage"); -const faker = require('faker'); -var theoretically = require("jasmine-theories"); - -describe("discordChatListener", () => { - let discordClient; - let logger; - let discordChatListenerConfig; - - beforeEach(() => { - discordClient = new Discord.Client(); - spyOn(discordClient, "login") - .and.resolveTo(Promise.resolve()); - logger = jasmine.createSpyObj("logger", ["log"]); - discordChatListenerConfig = { - "token": faker.lorem.word() - }; - }); - - it("sets logger property from logger injection", () => { - // Arrange - - // Act - const listener = new DiscordChatListener({ logger, discordChatListenerConfig }); - - // Assert - expect(listener.logger).toBe(logger); - }); - - it("sets config property from discordChatListenerConfig", () => { - // Arrange - // Act - const listener = new DiscordChatListener({ discordChatListenerConfig }); - - // Assert - expect(listener.config).toBe(discordChatListenerConfig); - }); - - it("sets discord property from discord injection", () => { - // Arrange - - // Act - const listener = new DiscordChatListener({ discordClient, discordChatListenerConfig }); - - // Assert - expect(listener.client).toBe(discordClient); - }); - - // init() - it("init() 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("init() resolves when client.login() resolves", async () => { - // Arrange - const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig }); - - // Act & Assert - await expectAsync(listener.init()).toBeResolved(); - }); - - it("init() registers message handler callback when client.login() resolves", async () => { - // Arrange - const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig }); - spyOn(discordClient, "on"); - - // Act - await listener.init(); - - 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 = new Discord.Client(); - spyOn(discordClient, "login") - .and.rejectWith("login error"); - spyOn(discordClient, "on"); - const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig }); - - // Act - await expectAsync(listener.init()).toBeRejected(); - expect(discordClient.on).toHaveBeenCalledTimes(0); - }); - - // messageHandler() - theoretically.it("returns without error when msg is '%s'", [null, "", " ", undefined], (insertedValue) => { - // Arrange - const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig }); - - // Act - const actualResult = listener.messageHandler(insertedValue); - - // Assert - expect(actualResult).toBe(undefined); - }); - - it("resolves discord message with discordMessageResolver", () => { - // Arrange - const discordMessageResolver = jasmine.createSpyObj("discordMessageResolver", { - resolve: new ActionHandlerMessage() - }); - - const discordMessage = jasmine.createSpyObj("discordMessage", ["reply"]); - - const genericActionHandler = jasmine.createSpyObj("genericActionHandler", ["handle"]) - - const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig, discordMessageResolver, genericActionHandler }); - - // Act - listener.messageHandler(discordMessage); - - // Assert - expect(discordMessageResolver.resolve).toHaveBeenCalledWith(discordMessage); - }); - - it("passes resolve result to generic action handler if isBangCommand is false", () => { - // Arrange - const resolvedMessage = new ActionHandlerMessage(); - resolvedMessage.isBangCommand = false; - - const discordMessageResolver = jasmine.createSpyObj("discordMessageResolver", { - resolve: resolvedMessage - }); - - const discordMessage = jasmine.createSpyObj("discordMessage", ["reply"]); - - const genericActionHandler = jasmine.createSpyObj("genericActionHandler", ["handle"]) - - const listener = new DiscordChatListener({ logger, discordClient, discordChatListenerConfig, discordMessageResolver, genericActionHandler }); - - // Act - listener.messageHandler(discordMessage); - - // Assert - expect(genericActionHandler.handle).toHaveBeenCalledWith(resolvedMessage); - }); - - // TODO Complete messageHandler tests -}); diff --git a/app/services/chatListeners/discord/discordMessageResolver.js b/app/services/chatListeners/discord/discordMessageResolver.js deleted file mode 100644 index a01430a..0000000 --- a/app/services/chatListeners/discord/discordMessageResolver.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; - -const MessageResolverBase = require("@actions/messageResolverBase"); - -module.exports = class DiscordMessageResolver extends MessageResolverBase { - resolve(discordMessage) { - // const message = super.resolveChatMessage(discordMessage.content); - // TODO: Append discord specific message content - // return message; - } -} diff --git a/app/services/chatListeners/discord/discordMessageResolver.spec.js b/app/services/chatListeners/discord/discordMessageResolver.spec.js deleted file mode 100644 index 80b6fb8..0000000 --- a/app/services/chatListeners/discord/discordMessageResolver.spec.js +++ /dev/null @@ -1,95 +0,0 @@ -'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/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 93% rename from app/services/db/repositories/notesRepository.js rename to app/services/db/repositories/notes/notesRepository.js index a482e91..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 }) { @@ -46,21 +46,21 @@ CREATE TABLE IF NOT EXISTS ${this.tableName} ( } async insertNote(timestamp, userID, channelID, nick, message, server) { - this.init(); + 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/index.js b/index.js index 98ee3f2..8093a6f 100644 --- a/index.js +++ b/index.js @@ -10,7 +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" } } From ae18867d84e70b472a50235e2dda4771602f3054 Mon Sep 17 00:00:00 2001 From: Chris Sebok Date: Sun, 12 Jul 2020 13:12:14 +0100 Subject: [PATCH 3/5] Finished off multi-server support - Currently only supports discord, but adding a new listener now means simply creating three new files in the corresponding chatListener directory - IoC still needs automating - this should be much simpler for most registrations with the new directory structure - All Action Handlers have now been updated to use the latest message format --- app/actionHandlers/actionHandlerBase.js | 10 +- app/actionHandlers/actionHandlerMessage.js | 1 + app/actionHandlers/actionHandlerResolver.js | 28 ++- .../actionHandlerResolver.spec.js | 135 +++++++++++++- .../generic/genericActionHandler.js | 22 ++- app/actionHandlers/help/helpActionHandler.js | 6 +- .../notes/notesActionHandler.js | 19 +- .../quote/quoteActionHandler.js | 8 +- .../notes/notesActionPersistenceHandler.js | 4 +- .../notesActionPersistenceHandler.spec.js | 5 +- app/chatListeners/chatListenerBase.js | 42 +++++ app/chatListeners/chatListenerBase.spec.js | 175 ++++++++++++++++++ .../discord/discordChatListener.js | 57 +----- .../discord/discordChatListener.spec.js | 101 ++-------- .../discord/discordMessageResolver.js | 2 +- .../discord/discordMessageResolver.spec.js | 5 +- app/chatListeners/messageResolverBase.js | 6 +- app/chatListeners/messageResolverBase.spec.js | 8 +- app/container.js | 24 +-- app/services/bot/bot.js | 73 -------- 20 files changed, 464 insertions(+), 267 deletions(-) create mode 100644 app/chatListeners/chatListenerBase.js create mode 100644 app/chatListeners/chatListenerBase.spec.js diff --git a/app/actionHandlers/actionHandlerBase.js b/app/actionHandlers/actionHandlerBase.js index d4f7bba..2f15d45 100644 --- a/app/actionHandlers/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 index 30eab19..8c88b27 100644 --- a/app/actionHandlers/actionHandlerMessage.js +++ b/app/actionHandlers/actionHandlerMessage.js @@ -12,6 +12,7 @@ module.exports = class ActionHandlerMessage { // Remaining properties are overridden by each *MessageResolver this.server = null; + this.isBot = false; this.userId = 0; this.channelId = 0 diff --git a/app/actionHandlers/actionHandlerResolver.js b/app/actionHandlers/actionHandlerResolver.js index 463bfa4..1539887 100644 --- a/app/actionHandlers/actionHandlerResolver.js +++ b/app/actionHandlers/actionHandlerResolver.js @@ -1,9 +1,31 @@ 'use strict'; -const ActionHandlerMessage = require("@actionHandlers/actionHandlerMessage"); - module.exports = class ActionHandlerResolver { + constructor({ logger, actions }) { + this.actions = actions; + this.logger = logger; + this.logPrefix = `[${this.constructor.name}] `; + } + async resolve(actionHandlerMessage) { - throw Error("Implement the ActionHandlerResolver!"); + 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 index 211dd0a..8e8995f 100644 --- a/app/actionHandlers/actionHandlerResolver.spec.js +++ b/app/actionHandlers/actionHandlerResolver.spec.js @@ -1,13 +1,136 @@ 'use strict'; -var theoretically = require("jasmine-theories"); +const ActionHandlerResolver = require("@actionHandlers/actionHandlerResolver"); +const faker = require('faker'); describe("actionHandlerResolver", () => { + let logger; - // 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(""); - // }); + 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 index f4502f7..39d26e9 100644 --- a/app/actionHandlers/generic/genericActionHandler.js +++ b/app/actionHandlers/generic/genericActionHandler.js @@ -4,7 +4,7 @@ const ActionHandlerBase = require("@actionHandlers/actionHandlerBase"); module.exports = class GenericActionHandler extends ActionHandlerBase { constructor({ logger }) { - super(logger); + super(logger, "generic"); } async handle(actionHandlerMessage) { @@ -12,8 +12,22 @@ module.exports = class GenericActionHandler extends ActionHandlerBase { return; } - // TODO: This action handler is called whenever a bang command is not found for a message received by any server. - // Do we need to log here? - this.logger.log(`${this.logPrefix}Generic action handler received message`); + // 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 index 365b261..a174f6c 100644 --- a/app/actionHandlers/help/helpActionHandler.js +++ b/app/actionHandlers/help/helpActionHandler.js @@ -9,11 +9,11 @@ module.exports = class HelpActionHandler extends ActionHandlerBase { this.help = "`!help` to show this message."; } - async handle(action) { - if (!action) { + async handle(actionHandlerMessage) { + if (!actionHandlerMessage) { return; } - let helpText = this.actions.map((action) => action.help).join("\r\n"); + let helpText = this.actions.map(a => a.help).join("\r\n"); return helpText; } }; diff --git a/app/actionHandlers/notes/notesActionHandler.js b/app/actionHandlers/notes/notesActionHandler.js index 32c459b..91bbcff 100644 --- a/app/actionHandlers/notes/notesActionHandler.js +++ b/app/actionHandlers/notes/notesActionHandler.js @@ -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/actionHandlers/quote/quoteActionHandler.js b/app/actionHandlers/quote/quoteActionHandler.js index b0424bd..f403128 100644 --- a/app/actionHandlers/quote/quoteActionHandler.js +++ b/app/actionHandlers/quote/quoteActionHandler.js @@ -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/actionPersistenceHandlers/notes/notesActionPersistenceHandler.js b/app/actionPersistenceHandlers/notes/notesActionPersistenceHandler.js index 3fcfced..2f5880f 100644 --- a/app/actionPersistenceHandlers/notes/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/actionPersistenceHandlers/notes/notesActionPersistenceHandler.spec.js b/app/actionPersistenceHandlers/notes/notesActionPersistenceHandler.spec.js index a236eb7..38e5115 100644 --- a/app/actionPersistenceHandlers/notes/notesActionPersistenceHandler.spec.js +++ b/app/actionPersistenceHandlers/notes/notesActionPersistenceHandler.spec.js @@ -38,15 +38,16 @@ 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 () => { 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 index 50299a9..d50e067 100644 --- a/app/chatListeners/discord/discordChatListener.js +++ b/app/chatListeners/discord/discordChatListener.js @@ -1,13 +1,13 @@ 'use strict'; -module.exports = class DiscordChatListener { +const ChatListenerBase = require("@chatListeners/chatListenerBase"); + +module.exports = class DiscordChatListener extends ChatListenerBase { constructor({ logger, actionHandlerResolver, discordChatListenerConfig, discordClient, discordMessageResolver }) { - this.logger = logger; + super(logger, actionHandlerResolver, discordMessageResolver); + this.config = discordChatListenerConfig; this.client = discordClient; - this.messageResolver = discordMessageResolver; - this.actionHandlerResolver = actionHandlerResolver; - this.logPrefix = `[${this.constructor.name}] `; } async init() { @@ -33,49 +33,10 @@ module.exports = class DiscordChatListener { } async handleMessage(discordMessage) { - if (!discordMessage || !(typeof discordMessage === "object")) { - return; - } - - this.logger.log(`${this.logPrefix}Received content '${discordMessage.content}'`); - - const actionHandlerMessage = await this.messageResolver.resolve(discordMessage); - - const actionHandler = await this.actionHandlerResolver.resolve(actionHandlerMessage); - - const reply = await actionHandler.handle(actionHandlerMessage); - - discordMessage.reply(reply); + await super.handleMessage(discordMessage); } - // 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 ActionMessage(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); - // } - // } - // } - // } - // } + async replyAction(discordMessage, replyText) { + discordMessage.reply(replyText); + } } diff --git a/app/chatListeners/discord/discordChatListener.spec.js b/app/chatListeners/discord/discordChatListener.spec.js index 317fc60..183d03c 100644 --- a/app/chatListeners/discord/discordChatListener.spec.js +++ b/app/chatListeners/discord/discordChatListener.spec.js @@ -1,8 +1,8 @@ 'use strict'; const DiscordChatListener = require("./discordChatListener"); +const ChatListenerBase = require("@chatListeners/chatListenerBase"); const faker = require('faker'); -var theoretically = require("jasmine-theories"); describe("discordChatListener init()", () => { let discordClient; @@ -20,14 +20,6 @@ describe("discordChatListener init()", () => { }); }); - it("sets logger property from logger injection", () => { - // Act - const listener = new DiscordChatListener({ logger, discordChatListenerConfig }); - - // Assert - expect(listener.logger).toBe(logger); - }); - it("sets config property from discordChatListenerConfig injection", () => { // Act const listener = new DiscordChatListener({ discordChatListenerConfig }); @@ -92,97 +84,34 @@ describe("discordChatListener init()", () => { }); describe("discordChatListener handleMessage()", () => { - let logger; - let actionHandlerResolver; - let discordMessageResolver; - let discordMessage; - - beforeEach(() => { - logger = jasmine.createSpyObj("logger", ["log"]); - actionHandlerResolver = jasmine.createSpyObj("actionHandlerResolver", { - resolve: jasmine.createSpyObj("actionHandler", ["handle"]) - }); - discordMessageResolver = jasmine.createSpyObj("discordMessageResolver", ["resolve"]); - discordMessage = jasmine.createSpyObj("discordMessage", ["reply"]); - }); - - theoretically.it("returns without error when discordMessage is '%s'", [null, "", " ", undefined], async (insertedValue) => { + 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 }); - - // Act - const actualResult = await listener.handleMessage(insertedValue); - - // Assert - expect(actualResult).toBe(undefined); - }); - - it("resolves discord message to ActionHandlerMessage", async () => { - // Arrange - const listener = new DiscordChatListener({ logger, actionHandlerResolver, discordMessageResolver }); + spyOn(ChatListenerBase.prototype, "handleMessage"); // Act await listener.handleMessage(discordMessage); // Assert - expect(discordMessageResolver.resolve).toHaveBeenCalledWith(discordMessage); + expect(ChatListenerBase.prototype.handleMessage).toHaveBeenCalledWith(discordMessage); }); +}); - it("resolves ActionHandler with ActionHandlerMessage", async () => { - // Arrange - const actionHandlerMessage = jasmine.createSpy("actionHandlerMessage"); - discordMessageResolver = jasmine.createSpyObj("discordMessageResolver", { - resolve: actionHandlerMessage - }); - - const listener = new DiscordChatListener({ logger, actionHandlerResolver, discordMessageResolver }); - - // Act - await listener.handleMessage(discordMessage); - - // Assert - expect(actionHandlerResolver.resolve).toHaveBeenCalledWith(actionHandlerMessage); - }); - - it("asynchronously handles message with resolved ActionHandler's handle() method", async () => { - // Arrange - const actionHandlerMessage = jasmine.createSpy("actionHandlerMessage"); - discordMessageResolver = jasmine.createSpyObj("discordMessageResolver", { - resolve: actionHandlerMessage - }); - const actionHandler = jasmine.createSpyObj("actionHandler", { - handle: async () => { - Promise.resolve(); - } - }); - actionHandlerResolver = jasmine.createSpyObj("actionHandlerResolver", { - resolve: actionHandler - }); - - const listener = new DiscordChatListener({ logger, actionHandlerResolver, discordMessageResolver }); - - // Act - await listener.handleMessage(discordMessage); - // Assert - expect(actionHandler.handle).toHaveBeenCalledWith(actionHandlerMessage); - }); - - it("replies to the discordMessage with the ActionHandler's response", async () => { +describe("discordChatListener replyAction()", () => { + it("replies to the discordMessage with the specified replyText", async () => { // Arrange - const expectedReply = faker.lorem.sentences(); - actionHandlerResolver = jasmine.createSpyObj("actionHandlerResolver", { - resolve: jasmine.createSpyObj("actionHandler", { - handle: expectedReply - }) - }); - - const listener = new DiscordChatListener({ logger, actionHandlerResolver, discordMessageResolver }); + 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.handleMessage(discordMessage); + await listener.replyAction(discordMessage, replyText); // Assert - expect(discordMessage.reply).toHaveBeenCalledWith(expectedReply); + expect(discordMessage.reply).toHaveBeenCalledWith(replyText); }); }); diff --git a/app/chatListeners/discord/discordMessageResolver.js b/app/chatListeners/discord/discordMessageResolver.js index d01c122..ecb5239 100644 --- a/app/chatListeners/discord/discordMessageResolver.js +++ b/app/chatListeners/discord/discordMessageResolver.js @@ -1,7 +1,6 @@ 'use strict'; const MessageResolverBase = require("@chatListeners/messageResolverBase"); -const ActionHandlerMessage = require("@actionHandlers/actionHandlerMessage"); module.exports = class DiscordMessageResolver extends MessageResolverBase { constructor({ logger }) { @@ -25,6 +24,7 @@ module.exports = class DiscordMessageResolver extends MessageResolverBase { 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; diff --git a/app/chatListeners/discord/discordMessageResolver.spec.js b/app/chatListeners/discord/discordMessageResolver.spec.js index e6644ff..5721cf8 100644 --- a/app/chatListeners/discord/discordMessageResolver.spec.js +++ b/app/chatListeners/discord/discordMessageResolver.spec.js @@ -52,12 +52,14 @@ describe("discordMessageResolver", function () { 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 + username: expectedUsername, + bot: expectedIsBot }, channel: { id: expectedChannelId @@ -76,5 +78,6 @@ describe("discordMessageResolver", function () { 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 index aafe0aa..054c70a 100644 --- a/app/chatListeners/messageResolverBase.js +++ b/app/chatListeners/messageResolverBase.js @@ -6,7 +6,7 @@ const ActionHandlerMessage = require("@actionHandlers/actionHandlerMessage"); module.exports = class MessageResolverBase { async resolve() { // Must be overridden and must call resolveChatMessage(inputMessage.) - throw NotImplemented(); + throw NotImplemented; } resolveChatMessage(chatMessage) { @@ -18,14 +18,14 @@ module.exports = class MessageResolverBase { const action = new ActionHandlerMessage(); action.command = ""; action.data = ""; - action.isBang = false; + 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.isBang = true; + action.isBangCommand = true; action.command = matches[1].toLowerCase(); if (matches[2]) { action.data = matches[2]; diff --git a/app/chatListeners/messageResolverBase.spec.js b/app/chatListeners/messageResolverBase.spec.js index 65186fd..1a14b15 100644 --- a/app/chatListeners/messageResolverBase.spec.js +++ b/app/chatListeners/messageResolverBase.spec.js @@ -21,10 +21,10 @@ describe("messageResolverBase", () => { expect(action.command).toBe("mycommand"); }); - it("sets isBang to true when bang found in message", () => { + it("sets isBangCommand to true when bang found in message", () => { const message = "!mycommand"; const action = new MessageResolverBase().resolveChatMessage(message); - expect(action.isBang).toBeTrue(); + expect(action.isBangCommand).toBeTrue(); }); it("sets command to lowercase command string when command found", () => { @@ -57,9 +57,9 @@ describe("messageResolverBase", () => { expect(action.data).toBe(message); }); - it("sets isBang to false when no bang command found in 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.isBang).toBeFalse(); + expect(action.isBangCommand).toBeFalse(); }); }); diff --git a/app/container.js b/app/container.js index 084d098..9f88ace 100644 --- a/app/container.js +++ b/app/container.js @@ -79,27 +79,27 @@ container.register({ notesRepository: ioc.asClass(NotesRepository, { lifetime: Lifetime.SINGLETON }), // Register Actions - TODO: Register automatically - genericActionHandler: ioc.asClass(GenericActionHandler), helpAction: ioc.asClass(HelpActionHandler), notesAction: ioc.asClass(NotesActionHandler), quoteAction: ioc.asClass(QuoteActionHandler), + genericActionHandler: ioc.asClass(GenericActionHandler), // Resolvers actionHandlerResolver: ioc.asClass(ActionHandlerResolver), - discordMessageResolver: ioc.asClass(DiscordMessageResolver), // Add all of the above actions into the below returned array - // helpActionActions: ioc.asFunction(() => { - // return [ - // container.cradle.notesAction, - // container.cradle.quoteAction - // ]; - // }, { lifetime: Lifetime.SINGLETON }), + helpActionActions: ioc.asFunction(() => { + return [ + container.cradle.notesAction, + container.cradle.quoteAction, + container.cradle.genericActionHandler + ]; + }), // Also include the help action. Do not inject this registration into any actions as you will create a cyclic dependency - // actions: ioc.asFunction(() => { - // return container.cradle.helpActionActions - // .concat([container.cradle.helpAction]); - // }, { lifetime: Lifetime.SINGLETON }) + actions: ioc.asFunction(() => { + return container.cradle.helpActionActions + .concat([container.cradle.helpAction]); + }) }); // Auto loading diff --git a/app/services/bot/bot.js b/app/services/bot/bot.js index 5ca2718..eb73d46 100644 --- a/app/services/bot/bot.js +++ b/app/services/bot/bot.js @@ -4,8 +4,6 @@ module.exports = class Bot { constructor({ // TODO: Inject all chat listeners via a function... discordChatListener, - // actions, - // discord, botConfig, logger }) { @@ -14,9 +12,6 @@ module.exports = class Bot { 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; this.discordChatListener = discordChatListener; } @@ -25,73 +20,5 @@ module.exports = class Bot { // TODO: Call init() on each loaded chat listener await this.discordChatListener.init(); - // await this.initDiscord() - // .then(async () => { - // 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(); - - // //try { - // this.client.login(this.discordToken) - // .then(() => { - // console.log("resolve1"); - // // console.log(a); - - // resolve(true); - // this.logger.log(`${this.logPrefix}Logged into Discord2`); - // }) - // .catch(e => { - // console.log("ERROR1"); - // reject(e); - // }); - // // resolve(true); - // // } catch (e) { - // // console.log("ERROR3"); - - // // } - // }); - // } - - // 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); - // } - // } - // } - // } - // } }; From 25cb35bdbd154236a214f474ce296e65c2ee0efc Mon Sep 17 00:00:00 2001 From: Chris Sebok Date: Mon, 13 Jul 2020 19:31:07 +0100 Subject: [PATCH 4/5] Implemented auto module loading in the container for actions, chat listeners and repositories --- app/actionHandlers/help/helpActionHandler.js | 4 +- app/container.js | 106 +++++++++---------- app/services/bot/bot.js | 1 + 3 files changed, 54 insertions(+), 57 deletions(-) diff --git a/app/actionHandlers/help/helpActionHandler.js b/app/actionHandlers/help/helpActionHandler.js index a174f6c..9ba110d 100644 --- a/app/actionHandlers/help/helpActionHandler.js +++ b/app/actionHandlers/help/helpActionHandler.js @@ -3,9 +3,9 @@ const ActionHandlerBase = require("@actionHandlers/actionHandlerBase"); module.exports = class HelpActionHandler extends ActionHandlerBase { - constructor({ logger, helpActionActions }) { + constructor({ logger, helpActions }) { super(logger, "help"); - this.actions = helpActionActions; + this.actions = helpActions; this.help = "`!help` to show this message."; } diff --git a/app/container.js b/app/container.js index 9f88ace..7b178cd 100644 --- a/app/container.js +++ b/app/container.js @@ -6,49 +6,66 @@ const Lifetime = ioc.Lifetime; // App const Bot = require('@services/bot/bot'); + // Config const AppConfig = require('@config/app/appConfig'); const BotConfig = require('@config/bot/botConfig'); const DbConfig = require('@config/db/dbConfig'); -// Chat Listener objects - one set per server -const DiscordChatListener = require('@chatListeners/discord/discordChatListener'); -const DiscordChatListenerConfig = require('@chatListeners/discord/discordChatListenerConfig'); -const DiscordMessageResolver = require("@chatListeners/discord/discordMessageResolver"); - // Services const DbAdapter = require('@dbAdapters/mariaDb/mariaDbAdapter'); const Logger = require('@services/logging/logger'); -// Actions -const HelpActionHandler = require("@actionHandlers/help/helpActionHandler"); -const NotesActionHandler = require("@actionHandlers/notes/notesActionHandler"); -const QuoteActionHandler = require("@actionHandlers/quote/quoteActionHandler"); -const GenericActionHandler = require("@actionHandlers/generic/genericActionHandler"); - // Resolvers const ActionHandlerResolver = require("@actionHandlers/actionHandlerResolver"); -// Action Persistence Handlers -const NotesActionPersistenceHandler = require("@actionPersistenceHandlers/notes/notesActionPersistenceHandler"); - -// DB Repositories -const NotesRepository = require("@dbRepositories/notes/notesRepository"); - // 3rd party const MySQL = require("mysql2/promise"); 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 repositoriesGlob = 'app/services/db/repositories/*/*Repository.js'; + +container.loadModules([actionHandlersGlob, actionPersistenceHandlersGlob], { + resolverOptions: { + register: ioc.asClass + } +}); + +// Auto load chat listener files +container.loadModules([ + `${chatListenersGlobSuffix}*ChatListener.js`, + `${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"), @@ -60,55 +77,34 @@ container.register({ // Logging logger: ioc.asClass(Logger), - // 3rd Party + // 3rd Party dependencies mySql: ioc.asValue(MySQL), discordClient: ioc.asFunction(() => new Discord.Client()), - // Chat Listener classes - discordChatListener: ioc.asClass(DiscordChatListener), - discordChatListenerConfig: ioc.asClass(DiscordChatListenerConfig), - discordMessageResolver: ioc.asClass(DiscordMessageResolver), - - // Register Action persistence handlers - TODO: Register automatically - notesActionPersistenceHandler: ioc.asClass(NotesActionPersistenceHandler), - - // 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, { lifetime: Lifetime.SINGLETON }), - - // Register Actions - TODO: Register automatically - helpAction: ioc.asClass(HelpActionHandler), - notesAction: ioc.asClass(NotesActionHandler), - quoteAction: ioc.asClass(QuoteActionHandler), - genericActionHandler: ioc.asClass(GenericActionHandler), + // Database adapter. Swap out if you want to implement + // another database provider. + dbAdapter: ioc.asClass(DbAdapter).singleton(), // Resolvers actionHandlerResolver: ioc.asClass(ActionHandlerResolver), // Add all of the above actions into the below returned array - helpActionActions: ioc.asFunction(() => { - return [ - container.cradle.notesAction, - container.cradle.quoteAction, - container.cradle.genericActionHandler - ]; + helpActions: ioc.asFunction(() => { + return ioc + .listModules(actionHandlersGlob) + .filter(a => a.name !== 'helpActionHandler') + .map(a => container.resolve(a.name)); }), - // Also include the help action. Do not inject this registration into any actions as you will create a cyclic dependency + + // 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 container.cradle.helpActionActions - .concat([container.cradle.helpAction]); + return ioc + .listModules(actionHandlersGlob) + .map(a => container.resolve(a.name)); }) }); -// Auto loading -// container.loadModules(['@actionHandlers/*/*ActionHandler.js', 'repositories/**/*.js'], { -// resolverOptions: { -// injectionMode: InjectionMode.CLASSIC -// } -// }) - 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 index eb73d46..43d9b3e 100644 --- a/app/services/bot/bot.js +++ b/app/services/bot/bot.js @@ -4,6 +4,7 @@ module.exports = class Bot { constructor({ // TODO: Inject all chat listeners via a function... discordChatListener, + // chatListeners, botConfig, logger }) { From 994f6c76c75581609e194d0d54aeeb9fa51b9da6 Mon Sep 17 00:00:00 2001 From: Chris Sebok Date: Mon, 13 Jul 2020 19:48:22 +0100 Subject: [PATCH 5/5] Added dynamic initialisation of all registered chat listeners in bot.js --- app/container.js | 10 +++++++++- app/services/bot/bot.js | 15 +++++---------- app/services/bot/bot.spec.js | 32 +++++++++++++++++++++++++++++--- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/app/container.js b/app/container.js index 7b178cd..6530e9d 100644 --- a/app/container.js +++ b/app/container.js @@ -36,6 +36,7 @@ console.log("[Root] Registering services"); 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], { @@ -46,7 +47,7 @@ container.loadModules([actionHandlersGlob, actionPersistenceHandlersGlob], { // Auto load chat listener files container.loadModules([ - `${chatListenersGlobSuffix}*ChatListener.js`, + chatListenersGlob, `${chatListenersGlobSuffix}*ChatListenerConfig.js`, `${chatListenersGlobSuffix}*MessageResolver.js` ], { @@ -88,6 +89,13 @@ container.register({ // Resolvers actionHandlerResolver: ioc.asClass(ActionHandlerResolver), + // 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 helpActions: ioc.asFunction(() => { return ioc diff --git a/app/services/bot/bot.js b/app/services/bot/bot.js index 43d9b3e..65433cd 100644 --- a/app/services/bot/bot.js +++ b/app/services/bot/bot.js @@ -1,25 +1,20 @@ 'use strict'; module.exports = class Bot { - constructor({ - // TODO: Inject all chat listeners via a function... - discordChatListener, - // chatListeners, - botConfig, - logger - }) { + 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.discordChatListener = discordChatListener; + this.chatListeners = chatListeners; } async init() { this.logger.log(`${this.logPrefix}Initialising bot`); - // TODO: Call init() on each loaded chat listener - await this.discordChatListener.init(); + 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 index d77e602..0177b38 100644 --- a/app/services/bot/bot.spec.js +++ b/app/services/bot/bot.spec.js @@ -1,8 +1,34 @@ 'use strict'; -describe("Bot Tests", function () { - it("contains spec with an expectation", function () { - expect(true).toBe(true); +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); }); });