loading...

Creating a simple To-do list bot with Gears + Discord.js using TypeScript

enitoni profile image Enitoni ・9 min read

Introduction

Discord.js is a great way to create Discord bots that can do a lot of different things ranging from providing entertainment or utility. However, in any application structured code is desired.

Your typical basic Discord bot might look something like this:

import { Client } from "discord.js"
const client = new Client()


client.on("message", message => {
  if (message.content === "ping") {
    message.reply("Pong!")        
  }
})

client.login('token')

This works fine for something as simple and small as this. But when commands get added things can quickly get out of hand. You'll end up with a long list of if statements inside the message handler, which is a chore to maintain.

You may want more commands, different ways to match commands, and stateful logic.

Getting this kind of functionality is bound to need abstractions otherwise you end up with really hard to maintain code that does too much in one place.

Fortunately, I've made a library to help with this.

What is Gears?

Gears is a very simple lightweight library that acts as a framework for your bot. It handles command matching, command grouping, and stateful logic outside of commands.

It can work with anything but for simplicity in this tutorial it will work with Discord.js

You can read more about it and its documentation here.

Getting started

In this tutorial you will learn how to make a simple To-do list bot with Gears and Discord.js

Let's start by setting up a project. I will assume you know how Node works and how to set up a TypeScript project. This is how I usually structure my project:

- package.json
- tsconfig.json
- config.json
- src
  - index.ts

File structure is subjective, so you can do whatever you feel works best. This one is simplified greatly.

Installing Gears

Since Gears is generic, we'll need to install both Gears itself and the bindings for it, as well as Discord.js. It works by providing an adapter that handles the way Discord.js deals with messages.

Install the necessary libraries like so:
npm install @enitoni/gears @enitoni/gears-discordjs discord.js

All the dependencies have typings, so you won't need any separate @types dependencies.

Setting up

Inside index.ts start by importing what we'll need to get the bot running.

import { Bot } from "@enitoni/gears"
import { Adapter } from "@enitoni/gears-discordjs"
import { token } from "../config.json"

const adapter = new Adapter({ token })
const bot = new Bot({ adapter, commands: [] })

async function main() {
  await bot.start()
  console.log("Bot is running!")
}

main()

To import the token, make sure you have "resolveJsonModule": true in your tsconfig.json

Make sure you have a Discord bot created on Discord's developer portal, then add a token property in config.json with your token assigned. Keep it secret!

Verify that it's working by running it using your preferred method of running TypeScript, the easiest way is npx tsc and then node index.js

You should see Bot is running! in the console.
Great! Now we can move on to making the to-do list functionality.

Creating the To-do service

Services are used when you want to persist data, instead of purely reacting to incoming activity.

Let's create a service that can handle the To-do functionality that we want. Create a file called TodoService.ts inside src and set it up like so:

import { Service } from "@enitoni/gears-discordjs";

export class TodoService extends Service {
  public reminders: string[] = []
}

Now add methods to add and clear reminders:

export class TodoService extends Service {
  public reminders: string[] = []

  public add(reminder: string) {
    this.reminders.push(reminder)
  }

  public clear(index: number) {
    this.reminders.splice(index, 1)
  }
}

Very simple for now, but it'll get more complex later on. Add the service to your bot's service array:

const bot = new Bot({
  adapter,
  commands: [],
  services: [TodoService]
})

Creating the command group

Create a new file called todoGroup.ts inside src. This will be the group scoping all our todo commands.

import { CommandGroupBuilder } from "@enitoni/gears-discordjs"
import { matchPrefixes } from "@enitoni/gears"

export const todoGroup = new CommandGroupBuilder()
  .match(matchPrefixes("!todo"))
  .done()

Since we don't have any commands to add to it yet, just add it to the bot and then let's move on for now.

const bot = new Bot({
  adapter,
  commands: [todoGroup],
  services: [TodoService]
})

Adding reminders

Now for the fun stuff! Create a new file called addCommand.ts inside src and put this code inside of it:

import { CommandBuilder } from "@enitoni/gears-discordjs";
import { matchPrefixes } from "@enitoni/gears";
import { TodoService } from "./TodoService";

export const addCommand = new CommandBuilder()
  .match(matchPrefixes("add"))
  .use(context => {
    const { manager, message } = context
    const service = manager.getService(TodoService)
  })

Now that we can access the todo service within this command, let's add the functionality that will let us add a reminder.

export const addCommand = new CommandBuilder()
  .match(matchPrefixes("add"))
  .use(context => {
    const { manager, message } = context
    const service = manager.getService(TodoService)

    service.add(message.content)
    return message.channel.send("Your reminder has been added!")
  })
  .done()

Since matchPrefixes strips the prefix from the content in context, we can simply pass it to the add method on the service.

Now add this command to the group we created earlier.

export const todoGroup = new CommandGroupBuilder()
  .match(matchPrefixes("!todo"))
  .setCommands(addCommand)
  .done()

Listing reminders

Create a new file called listCommand.ts this will be the command that lists the reminders we've added.

import { CommandBuilder } from "@enitoni/gears-discordjs"
import { matchAlways } from "@enitoni/gears"
import { TodoService } from "./TodoService"

export const listCommand = new CommandBuilder()
  .match(matchAlways())
  .use(context => {
    const { manager, message } = context
    const service = manager.getService(TodoService)

    const reminders = service.reminders
      .map((reminder, index) => `${index}. ${reminder}`)
      .join("\n")

    return message.channel.send(reminders)
  })
  .done()

Now add it as the last command in the group.

export const todoGroup = new CommandGroupBuilder()
  .match(matchPrefixes("!todo"))
  .setCommands(addCommand, listCommand)
  .done()

This is important because since we used matchAlways it will always match, so therefore adding it to the end ensures other commands get priority before it.

And now it's time for a test run! Build and run your bot and when you do !todo add <your reminder> it should add your reminder.

Example of adding reminders using the bot

Cool. Now let's list the reminders by simply doing !todo

Example of listing the reminders

Clearing reminders

Now create a new file called clearCommand.ts and write a command that clears a reminder by number.

import { CommandBuilder } from "@enitoni/gears-discordjs"
import { matchPrefixes } from "@enitoni/gears"
import { TodoService } from "./TodoService"

export const clearCommand = new CommandBuilder()
  .match(matchPrefixes("clear"))
  .use(context => {
    const { manager, content, message } = context
    const service = manager.getService(TodoService)

    const index = Number(content) - 1

    if (isNaN(index) || index < 0 || index >= service.reminders.length) {
      return message.channel.send(
        `Specify a number between 1 and ${service.reminders.length}`
      )
    }

    service.clear(index)
    return message.channel.send("OK, the reminder was cleared.")
  })
  .done()

For convenience, add 1 to the index inside the list command so that the list starts at 1. Let's also make sure that when there are no reminders, it doesn't try to send an empty message.

export const listCommand = new CommandBuilder()
  .match(matchAlways())
  .use(context => {
    const { manager, message } = context
    const service = manager.getService(TodoService)

    if (service.reminders.length === 0) {
      return message.channel.send("You have no reminders.")
    }

    const reminders = service.reminders
      .map((reminder, index) => `${index + 1}. ${reminder}`)
      .join("\n")

    return message.channel.send(reminders)
  })
  .done()

Now add the clear command to the group and let's test it out!

export const todoGroup = new CommandGroupBuilder()
  .match(matchPrefixes("!todo"))
  .setCommands(addCommand, clearCommand, listCommand)
  .done()

Example of clearing a reminder

Making reminders user specific

Let's make reminders user specific, so that not everyone shares the same list of reminders.

Change the service so that it stores an object that we can store each user's list in:

import { User } from "discord.js"

export class TodoService extends Service {
  private reminders: Record<string, string[]> = {}

  public getOrCreateList(user: User) {
    const existingList = this.reminders[user.id]

    if (existingList) {
      return existingList
    }

    const newList: string[] = []
    this.reminders[user.id] = newList

    return newList
  }

  public add(user: User, reminder: string) {
    const list = this.getOrCreateList(user)
    list.push(reminder)
  }

  public clear(user: User, index: number) {
    const list = this.getOrCreateList(user)
    list.splice(index, 1)
  }
}

Now let's change all our commands to reflect this. Starting with the list command.

export const listCommand = new CommandBuilder()
  .match(matchAlways())
  .use(context => {
    const { manager, message } = context

    const service = manager.getService(TodoService)
    const list = service.getOrCreateList(message.author)

    if (list.length === 0) {
      return message.channel.send(`No reminders for ${message.author}.`)
    }

    const reminders = list
      .map((reminder, index) => `${index + 1}. ${reminder}`)
      .join("\n")

    return message.channel.send(reminders)
  })
  .done()

Then the add command.

export const addCommand = new CommandBuilder()
  .match(matchPrefixes("add"))
  .use(context => {
    const { manager, content, message } = context
    const service = manager.getService(TodoService)

    service.add(message.author, content)
    return message.channel.send("Your reminder has been added!")
  })
  .done()

And finally the clear command.

export const clearCommand = new CommandBuilder()
  .match(matchPrefixes("clear"))
  .use(context => {
    const { manager, content, message } = context
    const service = manager.getService(TodoService)

    const index = Number(content) - 1
    const list = service.getOrCreateList(message.author)

    if (isNaN(index) || index < 0 || index >= list.length) {
      return message.channel.send(
        `Specify a number between 1 and ${list.length}`
      )
    }

    service.clear(message.author, index)
    return message.channel.send("OK, the reminder was cleared.")
  })
  .done()

Now when we add reminders, they should only apply to the user who added them. Test it out!

User specific reminders example

Persisting reminders

Finally, let's make it so reminders persist even if the bot goes offline. For now we'll use this very simple class that just stores some JSON in the file system. For a proper storage solution, a database is probably a better choice.

Create a file called "JSONStorage.ts"

import { promises as fs } from "fs"
import * as path from "path"

const DIR = "storage"

export class JSONStorage<T> {
  public data: T
  private path: string

  constructor(name: string, defaultValue: T) {
    this.path = path.join(DIR, name)
    this.data = defaultValue
  }

  public async restore() {
    try {
      const readData = await fs.readFile(this.path, "utf8")
      const parsedData = JSON.parse(readData)

      this.data = parsedData
    } catch (e) {
      await this.save(this.data)
    }
  }

  public async save(data: T) {
    this.data = data
    const stringifiedData = JSON.stringify(data)
    await fs.writeFile(this.path, stringifiedData)
  }
}

Now we need to modify our service so that it uses this class to restore and save the reminders.

import { JSONStorage } from "./JSONStorage"

const storage = new JSONStorage<Record<string, string[]>>("reminders.json", {})

export class TodoService extends Service {
  public async getOrCreateList(user: User) {
    const existingList = storage.data[user.id]

    if (existingList) {
      return existingList
    }

    const newList: string[] = []
    await storage.save({ ...storage.data, [user.id]: [] })

    return newList
  }

  public async add(user: User, reminder: string) {
    const list = await this.getOrCreateList(user)
    list.push(reminder)

    await storage.save({ ...storage.data, [user.id]: list })
  }

  public async clear(user: User, index: number) {
    const list = await this.getOrCreateList(user)
    list.splice(index, 1)

    await storage.save({ ...storage.data, [user.id]: list })
  }

  public async serviceDidInitialize() {
    await storage.restore()
  }
}

Note the serviceDidInitialize method. This is a hook automatically called by Gears when the service is ready. You should put any initialization logic inside here, it only gets called once.

Now that we added async logic to our service, we'll need to update our commands to reflect this.

export const addCommand = new CommandBuilder()
  .match(matchPrefixes("add"))
  .use(async context => {
    const { manager, content, message } = context
    const service = manager.getService(TodoService)

    await service.add(message.author, content)
    return message.channel.send("Your reminder has been added!")
  })
  .done()

export const listCommand = new CommandBuilder()
  .match(matchAlways())
  .use(async context => {
    const { manager, message } = context

    const service = manager.getService(TodoService)
    const list = await service.getOrCreateList(message.author)

    if (list.length === 0) {
      return message.channel.send(`No reminders for ${message.author}.`)
    }

    const reminders = list
      .map((reminder, index) => `${index + 1}. ${reminder}`)
      .join("\n")

    return message.channel.send(reminders)
  })
  .done()


export const clearCommand = new CommandBuilder()
  .match(matchPrefixes("clear"))
  .use(async context => {
    const { manager, content, message } = context
    const service = manager.getService(TodoService)

    const index = Number(content) - 1
    const list = await service.getOrCreateList(message.author)

    if (isNaN(index) || index < 0 || index >= list.length) {
      return message.channel.send(
        `Specify a number between 1 and ${list.length}`
      )
    }

    service.clear(message.author, index)
    return message.channel.send("OK, the reminder was cleared.")
  })
  .done()

And that's it! Check that it's working by running your bot once again. Make sure you have a storage folder in the root of your project so that the file can be created.

Example of persistence working

Wrapping up

That's it! We're done!

You should now have a basic understanding of how to create Discord bots with Gears. This example is very simple, but just try playing around with it and see what you can come up with!

And don't forget to check out the Gears website for documentation and more in-depth guides

Feel free to also check out the repository for this example here

Posted on by:

enitoni profile

Enitoni

@enitoni

I'm a full-stack developer. TypeScript is my religion.

Discussion

markdown guide
 

Played with this lib and can recommend it to everyone who wants to build bots of any kind:)