8

Working with TypeScript, Dependency Injection, and Discord Bots

 5 years ago
source link: https://www.tuicool.com/articles/rABr6zU
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Types and testable codeare two of the most effective ways of avoiding bugs, especially as code changes over time. We can apply these two techniques to JavaScript development by leveraging TypeScript and the dependency injection (DI) design pattern, respectively.

In this TypeScript tutorial, we won’t cover TypeScript basics directly, except for compilation. Instead, we will simply demonstrate TypeScript best practices as we walk through how to make a Discord bot from scratch, hook up tests and DI, and create a sample service. We will be using:

  • Node.js
  • TypeScript
  • Discord.js, a wrapper for the Discord API
  • InversifyJS, a dependency injection framework
  • Testing libraries: Mocha, Chai, and ts-mockito
  • Bonus: Mongoose and MongoDB, in order to write an integration test

Setting Up Your Node.js Project

First, let’s create a new directory called typescript-bot . Then, enter it and create a new Node.js project by running:

npm init

Note: You could also use yarn for that, but let’s stick to npm for brevity.

This will open an interactive wizard, which will set up the package.json file. You can safely just press Enter for all questions (or provide some information if you want). Then, let’s install our dependencies and dev dependencies (those which are only needed for the tests).

npm i --save typescript discord.js inversify dotenv @types/node reflect-metadata
npm i --save-dev chai mocha ts-mockito ts-node @types/chai @types/mocha

Then, replace generated "scripts" section in package.json with:

"scripts": {
  "start": "node src/index.js",
  "watch": "tsc -p tsconfig.json -w",
  "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\""
},

The double quotes around tests/**/*.spec.ts are needed to find files recursively. (Note: Syntax may vary for developers using Windows.)

The start script will be used to start the bot, the watch  script  to compile the TypeScript code, and test to run the tests.

Now, our package.json file should look like this:

{
  "name": "typescript-bot",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "dependencies": {
    "@types/node": "^11.9.4",
    "discord.js": "^11.4.2",
    "dotenv": "^6.2.0",
    "inversify": "^5.0.1",
    "reflect-metadata": "^0.1.13",
    "typescript": "^3.3.3"
  },
  "devDependencies": {
    "@types/chai": "^4.1.7",
    "@types/mocha": "^5.2.6",
    "chai": "^4.2.0",
    "mocha": "^5.2.0",
    "ts-mockito": "^2.3.1",
    "ts-node": "^8.0.3"
  },
  "scripts": {
    "start": "node src/index.js",
    "watch": "tsc -p tsconfig.json -w",
    "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\""
  },
  "author": "",
  "license": "ISC"
}

Creating a New Application in the Discord Apps Dashboard

In order to interact with the Discord API, we need a token. To generate such a token, we need to register an app in the Discord Developer Dashboard. In order to do that, you need to create a Discord account and go to https://discordapp.com/developers/applications/ . Then, click the New Application button:

toptal-blog-image-1558423148995-2bf3d5c9257967ba6f0577cb39ee6658.png

Choose a name and click Create . Then, click BotAdd Bot , and you are done. Let’s add the bot to a server. But don’t close this page yet, we’ll need to copy a token soon.

Add Your Discord Bot to Your Server

In order to test our bot, we need a Discord server. You can use an existing server or create a new one. To do this, copy the bot’s CLIENT_ID and use it as part of this special authorization URL:

https://discordapp.com/oauth2/authorize?client_id=<CLIENT_ID>&scope=bot

When you hit this URL in a browser, a form appears where you can choose the server where the bot should be added.

toptal-blog-image-1558423300352-d5482e68e9ef572e7e089a7f49d0648f.png

After you add the bot to your server, you should see a message like the above.

Creating the .env File

We need some way to save the token in our app. In order to do that, we’re going to use the dotenv package. First, get the token from Discord Application Dashboard ( BotClick to Reveal Token ):

toptal-blog-image-1558423254145-215de358d6f1bdc2ded56e97024b939a.png

Now, create a .env file, then copy and paste the token here:

TOKEN=paste.the.token.here

If you use Git, then this file should be placed in .gitignore , so that the token is not compromised. Also, create a .env.example file, so that it is known that TOKEN needs defining:

TOKEN=

Compiling TypeScript

In order to compile TypeScript, you can use the npm run watch command. Alternatively, if you use PHPStorm (or another IDE), just use its file watcher from its TypeScript plugin and let your IDE handle compilation. Let’s test our setup by creating a src/index.ts file with the contents:

console.log('Hello')

Also, let’s create a tsconfig.json file like below. InversifyJS requires experimentalDecorators , emitDecoratorMetadata , es6 , and reflect-metadata :

{
  "compilerOptions": {
    "module": "commonjs",
    "moduleResolution": "node",
    "target": "es2016",
    "lib": [
      "es6",
      "dom"
    ],
    "sourceMap": true,
    "types": [
      // add node as an option
      "node",
      "reflect-metadata"
    ],
    "typeRoots": [
      // add path to @types
      "node_modules/@types"
    ],
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "resolveJsonModule": true
  },
  "exclude": [
    "node_modules"
  ]
}

If the file watcher works properly, it should generate a src/index.js file, and running npm start should result in:

> node src/index.js
Hello

Creating a Bot Class

Now, let’s finally start using TypeScript’s most useful feature: types. Go ahead and create the following src/bot.ts file:

import {Client, Message} from "discord.js";
export class Bot {
  public listen(): Promise<string> {
    let client = new Client();
    client.on('message', (message: Message) => {});
    return client.login('token should be here');
  }
}

Now, we can see what we need here: a token! Are we going to just copy-paste it here, or load the value straight from the environment?

Neither. Instead, let’s write more maintainable, extendable, and testable code by injecting the token using our dependency injection framework of choice, InversifyJS.

Also, we can see that the Client dependency is hardcoded. We are going to inject this as well.

Configuring the Dependency Injection Container

A dependency injection container is an object that knows how to instantiate other objects. Typically, we define dependencies for each class, and DI container takes care of resolving them.

InversifyJS recommends putting dependencies in an inversify.config.ts file, so let’s go ahead and add our DI container there:

import "reflect-metadata";
import {Container} from "inversify";
import {TYPES} from "./types";
import {Bot} from "./bot";
import {Client} from "discord.js";

let container = new Container();

container.bind<Bot>(TYPES.Bot).to(Bot).inSingletonScope();
container.bind<Client>(TYPES.Client).toConstantValue(new Client());
container.bind<string>(TYPES.Token).toConstantValue(process.env.TOKEN);

export default container;

Also, the InversifyJS docs recommend creating a types.ts file, and listing each type we are going to use, together with a related Symbol . This is quite inconvenient, but it ensures that there are no naming collisions as our app grows. Each Symbol is a unique identifier, even when its description parameter is the same (the parameter is only for debugging purposes).

export const TYPES = {
  Bot: Symbol("Bot"),
  Client: Symbol("Client"),
  Token: Symbol("Token"),
};

Without using Symbol s, here is how it looks when a naming collision happens:

Error: Ambiguous match found for serviceIdentifier: MessageResponder
Registered bindings:
 MessageResponder
 MessageResponder

At this point, it’s even more inconvenient to sort out which MessageResponder should be used, especially if our DI container grows large. Using Symbol s takes care of that, and we do not have come up with strange string literals in the case of having two classes with the same name.

Using the Container in the Discord Bot App

Now, let’s modify our Bot class to use the container. We need to add @injectable and @inject() annotations to do that. Here is the new Bot class:

import {Client, Message} from "discord.js";
import {inject, injectable} from "inversify";
import {TYPES} from "./types";
import {MessageResponder} from "./services/message-responder";

@injectable()
export class Bot {
  private client: Client;
  private readonly token: string;

  constructor(
    @inject(TYPES.Client) client: Client,
    @inject(TYPES.Token) token: string
  ) {
    this.client = client;
    this.token = token;
  }

  public listen(): Promise < string > {
    this.client.on('message', (message: Message) => {
      console.log("Message received! Contents: ", message.content);
    });

    return this.client.login(this.token);
  }
}

Finally, let’s instantiate our bot in the index.ts file:

require('dotenv').config(); // Recommended way of loading dotenv
import container from "./inversify.config";
import {TYPES} from "./types";
import {Bot} from "./bot";
let bot = container.get<Bot>(TYPES.Bot);
bot.listen().then(() => {
  console.log('Logged in!')
}).catch((error) => {
  console.log('Oh no! ', error)
});

Now, start the bot and have it added to your server. Then, if you type a message in the server channel, it should appear in the logs on the command line like so:

> node src/index.js

Logged in!
Message received! Contents:  Test

Finally, we have the foundations set up: TypeScript types and a dependency injection container inside our bot.

Implementing Business Logic

Let’s go straight to the core of what this article is about: creating a testable codebase. In short, our code should implement best practices (like SOLID ), not hide dependencies, not use static methods.

Also, it should not introduce side effects when run, and be easily mockable .

For the sake of simplicity, our bot will do just one thing: It will search incoming messages, and if one contains the word “ping,” we’ll use one of the available Discord bot commands to have the bot respond with “pong!” to that user.

In order to show how to inject custom objects into the Bot object and unit-test them, we will create two classes: PingFinder and MessageResponder . We’ll inject MessageResponder into the Bot class, and PingFinder into MessageResponder .

Here is the src/services/ping-finder.ts file:

import {injectable} from "inversify";

@injectable()
export class PingFinder {

  private regexp = 'ping';

  public isPing(stringToSearch: string): boolean {
    return stringToSearch.search(this.regexp) >= 0;
  }
}

We then inject that class into the src/services/message-responder.ts file:

import {Message} from "discord.js";
import {PingFinder} from "./ping-finder";
import {inject, injectable} from "inversify";
import {TYPES} from "../types";

@injectable()
export class MessageResponder {
  private pingFinder: PingFinder;

  constructor(
    @inject(TYPES.PingFinder) pingFinder: PingFinder
  ) {
    this.pingFinder = pingFinder;
  }

  handle(message: Message): Promise<Message> {
    if (this.pingFinder.isPing(message.content)) {
      return message.reply('pong!');
    }

    return Promise.reject();
  }
}

Lastly, here is a modified Bot class, which uses the MessageResponder class:

import {Client, Message} from "discord.js";
import {inject, injectable} from "inversify";
import {TYPES} from "./types";
import {MessageResponder} from "./services/message-responder";

@injectable()
export class Bot {
  private client: Client;
  private readonly token: string;
  private messageResponder: MessageResponder;

  constructor(
    @inject(TYPES.Client) client: Client,
    @inject(TYPES.Token) token: string,
    @inject(TYPES.MessageResponder) messageResponder: MessageResponder) {
    this.client = client;
    this.token = token;
    this.messageResponder = messageResponder;
  }

  public listen(): Promise<string> {
    this.client.on('message', (message: Message) => {
      if (message.author.bot) {
        console.log('Ignoring bot message!')
        return;
      }

      console.log("Message received! Contents: ", message.content);

      this.messageResponder.handle(message).then(() => {
        console.log("Response sent!");
      }).catch(() => {
        console.log("Response not sent.")
      })
    });

    return this.client.login(this.token);
  }
}

In that state, the app will fail to run because there are no definitions for the MessageResponder and PingFinder classes. Let’s add the following to the inversify.config.ts file:

container.bind<MessageResponder>(TYPES.MessageResponder).to(MessageResponder).inSingletonScope();
container.bind<PingFinder>(TYPES.PingFinder).to(PingFinder).inSingletonScope();

Also, we are going to add type symbols to types.ts :

MessageResponder: Symbol("MessageResponder"),
PingFinder: Symbol("PingFinder"),

Now, after restarting our app, the bot should respond to every message that contains “ping”:

toptal-blog-image-1558423177160-fb0d676f8d7347582cfa036b7e8b8da4.png

And here is how it looks in the logs:

> node src/index.js

Logged in!
Message received! Contents:  some message
Response not sent.
Message received! Contents:  message with ping
Ignoring bot message!
Response sent!

Creating Unit Tests

Now that we have dependencies properly injected, writing unit tests is easy. We are going to use Chai and ts-mockito for that; however, there are many other test runners and mocking libraries you could use.

The mocking syntax in ts-mockito is quite verbose, but also easy to understand. Here’s how to set up the MessageResponder service and inject the PingFinder mock into it:

let mockedPingFinderClass = mock(PingFinder);
let mockedPingFinderInstance = instance(mockedPingFinderClass);

let service = new MessageResponder(mockedPingFinderInstance);

Now that we have mocks set up, we can define what the result of isPing() calls should be and verify reply() calls. The point is that in unit tests, we define the result of the isPing() call: true or false . It doesn’t matter what the message content is, so in tests we just use "Non-empty string" .

when(mockedPingFinderClass.isPing("Non-empty string")).thenReturn(true);
await service.handle(mockedMessageInstance)
verify(mockedMessageClass.reply('pong!')).once();

Here’s how the whole test suite could look like:

import "reflect-metadata";
import 'mocha';
import {expect} from 'chai';
import {PingFinder} from "../../../src/services/ping-finder";
import {MessageResponder} from "../../../src/services/message-responder";
import {instance, mock, verify, when} from "ts-mockito";
import {Message} from "discord.js";

describe('MessageResponder', () => {
  let mockedPingFinderClass: PingFinder;
  let mockedPingFinderInstance: PingFinder;
  let mockedMessageClass: Message;
  let mockedMessageInstance: Message;

  let service: MessageResponder;

  beforeEach(() => {
    mockedPingFinderClass = mock(PingFinder);
    mockedPingFinderInstance = instance(mockedPingFinderClass);
    mockedMessageClass = mock(Message);
    mockedMessageInstance = instance(mockedMessageClass);
    setMessageContents();

    service = new MessageResponder(mockedPingFinderInstance);
  })

  it('should reply', async () => {
    whenIsPingThenReturn(true);

    await service.handle(mockedMessageInstance);

    verify(mockedMessageClass.reply('pong!')).once();
  })

  it('should not reply', async () => {
    whenIsPingThenReturn(false);

    await service.handle(mockedMessageInstance).then(() => {
      // Successful promise is unexpected, so we fail the test
      expect.fail('Unexpected promise');
    }).catch(() => {
	 // Rejected promise is expected, so nothing happens here
    });

    verify(mockedMessageClass.reply('pong!')).never();
  })

  function setMessageContents() {
    mockedMessageInstance.content = "Non-empty string";
  }

  function whenIsPingThenReturn(result: boolean) {
    when(mockedPingFinderClass.isPing("Non-empty string")).thenReturn(result);
  }
});

The tests for PingFinder are quite trivial since there are no dependencies to be mocked. Here is an example test case:

describe('PingFinder', () => {
  let service: PingFinder;
  beforeEach(() => {
    service = new PingFinder();
  })

  it('should find "ping" in the string', () => {
    expect(service.isPing("ping")).to.be.true
  })
});

Creating Integration Tests

Apart from unit tests, we can also write integration tests. The main difference is that the dependencies in those tests are not mocked. However, there are some dependencies which should not be tested, like external API connections. In that case, we can create mocks and rebind them to the container, so that the mock is injected instead. Here’s an example of how to do that:

import container from "../../inversify.config";
import {TYPES} from "../../src/types";
// ...

describe('Bot', () => {
  let discordMock: Client;
  let discordInstance: Client;
  let bot: Bot;

  beforeEach(() => {
    discordMock = mock(Client);
    discordInstance = instance(discordMock);
    container.rebind<Client>(TYPES.Client)
      .toConstantValue(discordInstance);
    bot = container.get<Bot>(TYPES.Bot);
  });

  // Test cases here

});

This brings us to the end of our Discord bot tutorial. Congratulations, you built it cleanly, with TypeScript and DI in place from the start! This TypeScript dependency injection example is a pattern you can add to your repertoire for use with any project.

TypeScript and Dependency Injection: Not Just for Discord Bot Development

Bringing the object-oriented world ofTypeScript into JavaScript is a great enhancement, whether we’re working on front-end or back-end code. Just using types alone allows us to avoid many bugs. Having dependency injection in TypeScript pushes even more object-oriented best practices onto JavaScript-based development.

Of course, because of the limitations of the language, it will never be as easy and natural as in statically typed languages. But one thing is for sure: TypeScript, unit tests, and dependency injection allow us to write more readable, loosely-coupled, and maintainable code—no matter what kind of app we’re developing.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK