NotTDBot

published on July 19, 2023 - 10 minute read

Node.js JavaScript Discord.js node:fs Docker Docker Compose


hero image for NotTDBot

A Discord bot used to assist in organizing online inter-varsity debating competitions, which was actively used on 100+ Discord servers for 50+ universities across the globe.


🎯Project Goals

In my undergraduate, I participated in my university’s debate society. The two main formats of debate in Canada are British Parliamentary and Canadian Parliamentary. British Parliamentary consist of 8 speakers delivering 7-minute speeches with 1 minute of “protected” time at the beginning and end of their speech where the opposition speakers are not allowed to offer a question. At the high school level speeches are 5 minutes long with 30 seconds of protected time. The second format is Canadian Parliamentary which consists of 4 speakers but speakers now have the choice to deliver a 7-minute speech, a 6-minute speech, a 10-minute speech, a 3-minute speech, or a 4-minute speech depending on their speaker position in the debate with varying “protected” time.

This can get confusing, but conventionally the adjudicators of each debate round would keep track of time and deliver “time signals” where an adjudicator would bang the table to indicate that the speech has entered/exited protected time.

This system works for the most part when debates occur in person, but over the COVID-19 pandemic debates were forced to be held online; mainly on Zoom or Discord.

A clear problem of debating online was: “How does an adjudicator efficiently indicate time signals now?” Typing in chat wouldn’t be so easy: debaters would have to keep their eye on the chat while delivering a speech, and some adjudicators felt that typing “protected time” would break their note-taking flow. Interrupting a speech by unmuting your microphone also has its challenge: if the speaker wasn’t paying attention they may mistake an interruption as a signal that the opponent was offering a question.

In March 2020, a tournament called Lord Discord Cup was held. A member of the community named Keeghan Lucas made a game-changing Discord bot named the TDBot (referencing the Tournament Directory role) that would automate time signals for debaters.

I then realized a massive opportunity: why should this be restricted to a single tournament? Expanding this technology could truly help organize tournaments by managing participants’ roles, creating timers that could pause, resume, and stop, and adding functionality to accommodate a variety of speech lengths and time signals would help universities all over the country, and eventually the globe. So that’s what I did. Except, I wanted to distinguish my product, so I named it NotTDBot for clarity.


⚙️How it works

When NotTDBot is added to the Discord server it subscribes to each Discord text channel and listens for entries that start with the + symbol. If the + symbol is detected, NotTDBot offers a couple of commands:

Timing

NotTDBot’s bread and butter is to time a speech.

  • +start creates a 7-minute speech timer
  • +pause pauses the current timer
  • +resume resumes the current timer
  • +end ends the current timer

+start comes with a few presets:

  • +start {10} creates a 10-minute speech timer
  • +start {8} creates an 8-minute speech timer
  • +start {6} creates a 6-minute speech timer
  • +start {7} creates a 7-minute speech timer
  • +start {4} creates a 4-minute speech timer
  • +start {3} creates a 3-minute speech timer

Why +resume and not +start again? Initially, I foresaw an issue where there would be some debate rounds where no one understood how to use the NotTDBot. I envisioned that an adjudicator or tournament organizer would be able to start a timer for another room, thereby becoming the owner of multiple concurrent timers.

I developed an indexing system for managing timers that would follow the following use case:

  1. Bob starts a timer with +start
  2. Bob needs to start a concurrent timer, and uses +start again
  3. Bob needs to pause his first timer and uses +pause {0} to indicate which timer to pause
  4. Bob needs to restart his first timer and uses +resume {0}
  5. Bob needs to end his second timer early, and uses +end {1}
  6. Bob wants to see which timers are still active, so he uses +timers and sees his first timer running

This indexing system become a legacy feature because the main use case became users owning at most one timer.

You can also create custom timers:

Terminal window
+start CUSTOM {7} {0.5} {6.5}

In the above example, the total length of the timer is 7 minutes, the first signal that “protected” time has ended will be at 0.5 minutes (30 seconds) and the last signal that “protected” time has started again will be at 6.5 minutes (6 min & 30 sec).

Polls

Debaters love democracy, so it became common to vote on things.

You could use the +poll command to make a poll:

Terminal window
+poll {Poll Title} [Option 1] [Option 2] [Option 3]

example poll

The most common use case was voting on how “you wanted to participate in a practice” so I made a preset:

Terminal window
+poll DEBATE

Which would generate the following poll:

debate poll

Developer Tools

I also made some tools for myself:

  • +dev terminate a kill switch to shut off the bot
  • +dev servers return a list of all servers the bot is on
  • +dev wake returns a message if the bot is active
  • +dev commands returns the list of commands available
  • +dev announce {some announcement} send a direct message to all administrators of the servers the bot is added to
  • +dev admins return a list of all administrators of the servers the bot is added to

🔍Taking a look under the hood

There were two main languages dominating the online documentation for creating Discord bots: JavaScript, and Python.

You may or may not know, I’m a JavaScript defender so the choice was obvious. (Actually, there was more support for JavaScript development at the time)

Initiating the bot

This process may have changed as the Discord API has evolved, but when I was originally developing this program it was necessary to instantiate a Discord.Client with a Partial for the bot to handle events correctly for uncached messages (that is, messages that were made before the bot was turned on). (source)

Instantiating the bot looked something like:

Terminal window
const bot = new Discord.Client({ partials: ['MESSAGE', 'CHANNEL', 'REACTION'] });

The further setup would be to bind a commands key of the bot to a Discord.Collection, which is essentially a utility class for the JavaScript Map structure with additional functions. (source)

1
bot.commands = new Discord.Collection()

Processing Commands

As I mentioned in how it works, the bot basically monitors channel activity for messages that begin with the + character. Initially, the prefix was ! but there were many jukebox bots and admin bots that used ! so I changed it to the uncommon + to avoid conflicts. These days Discord requires commands to start with /, but I don’t intend to update NotTDBot to handle this change.

The entry point JavaScript file bot.js had the following logic:

  • load bot commands from modules
  • monitor for chat messages
    • check if incoming message has a + prefix and matches a loaded command
      • if yes, execute command module
  • login bot with a provided Discord application token
Load Commands

The project directory is organized similarly to:

Terminal window
[project]
├── bot.js
└── assets
  └── scripts
    └── commands
      ├── Command.js
      ├── Timer.js
      └── Poll.js

This file structure is convenient for the loading commands step. If each module in /.../commands exports a single class that has an execute function, then we can load the classes with the node:fs library similar to:

1
const files = fs
2
.readdirSync("./assets/scripts/commands/")
3
.filter((file) => file.endsWith(".js"))
4
for (const file of files) {
5
const command = require(`./assets/scripts/commands/${file}`)
6
7
bot.commands.set(command.name.toLowerCase(), new command(bot))
8
}
Command Class

There is a single Command class template that other commands extended from which enables the command loading seen above:

1
class Command {
2
constructor(bot, options = {}) {
3
this.bot = bot
4
this.name = options.name || "No name provided."
5
this.aliases = options.aliases || []
6
this.description = options.description || "No description provided."
7
this.category = options.category || "Misc."
8
this.usage = options.usage || "No usage provided."
9
this.presets = options.presets || "No presets assigned."
10
}
11
12
async execute(message, args) {
13
throw new Error(`Command ${this.name} doesn't have execute function.`)
14
}
15
}

As you can see, details about the specific command can be passed from the options parameter, where the bot itself is passed from the bot parameter so that we can access the bot Managers like guilds (servers), users, and messages for servers the bot is on.

New commands can extend Command:

1
class Poll extends Command {
2
constructor(...args) {
3
super(...args, {
4
name: "poll",
5
description:
6
'Creates a poll with custom questions and answers.\n Your question and options can be as long as you want. Maximum poll of 20 options\nIf you have no option, a "yes" and "no" poll will be generated',
7
category: "Poll",
8
usage: "!poll {question} [option1] [option2]",
9
presets:
10
"DEBATE - Creates a poll with the question: What would you like to do for today's meeting?. with options: Anything, Debate, Judge, Vibe.",
11
})
12
}
13
async execute(message, args) {
14
// do some command
15
}
16
}
17
18
const command = new Poll(bot)
Timer

JavaScript has a built-in setTimeout method, but unfortunately, it’s not so easy to pause a timeout. You can clearTimeout in JavaScript but you have to do some type of wrapper for keeping track of the elapsed and remaining time:

1
class Timer {
2
constructor(message, poiOpen, poiClose, speechLength) {
3
this.message = message
4
this.poiOpen = poiOpen
5
this.poiClose = poiClose
6
this.speechLength = speechLength
7
this.running = false
8
this.count = 0
9
10
// this is relating to the indexing system
11
if (!timers[this.message.author.id]) {
12
this.id = 0
13
} else {
14
this.id = timers[this.message.author.id].length
15
}
16
}
17
setId(id) {
18
this.id = id
19
}
20
start() {
21
this.running = true
22
this.tick()
23
}
24
pause() {
25
this.running = false
26
}
27
stop() {
28
this.running = false
29
removeTimer(this.message, this.id)
30
}
31
tick() {
32
if (this.running) {
33
if (this.count == this.poiOpen * 60) {
34
signal(this.message, "OPEN")
35
} else if (this.count == this.poiClose * 60) {
36
signal(this.message, "CLOSED")
37
} else if (this.count == this.speechLength * 60) {
38
start_grace(this.message)
39
this.stop()
40
}
41
this.count++
42
setTimeout(() => this.tick(), 1000, this)
43
}
44
}
45
}

Most of these class functions are self-explanatory, but I want to touch on a big design choice:

“What is even going on with the tick function??”

The tick function follows the following logic:

  • execute tick when start is executed
  • check if the timer is still running
    • check if any protected time signals need to be sent
    • check if the speech is over
    • increase the count (the elapsed seconds)
    • start a setTimeout that executes a recursive tick call after 1 second

All timer signals are sent to the user through the Discord reply function of the Message class:

1
message.reply("Timer started!: 7 Minute Speech")

The indexing system worked by holding an object of user id keys that would be bound to an array of their current timers:

Terminal window
type Timers = {
[user_id]: Timer[]
}
Poll

The logic when receiving a +poll command goes as follows:

  • check whether there are more than 1 arguments
    • if yes, check if the command is calling for a preset poll template (i.e., +poll DEBATE)
    • check whether the second argument is correctly wrapped in a curly bracket - if yes, use the contents of the bracket as the question - if there is a valid question, parse the command arguments for answers options wrapped in square brackets

NotTDBot utilized an embedded message to send a poll:

1
function poll_response(message, question, answer_options) {
2
let final_options = "\n"
3
let index = 97
4
let emoji_prefix = ":regional_indicator_"
5
for (var i = 0; i < answer_options.length; i++) {
6
let temp =
7
answer_options[i].charAt(0).toUpperCase() +
8
answer_options[i].substring(1)
9
final_options += "\n"
10
final_options +=
11
emoji_prefix + String.fromCharCode(index) + ":" + ": " + temp + "\n"
12
index++
13
}
14
message.channel
15
.send({
16
embed: {
17
title: "Poll: " + final_options,
18
color: 3447003,
19
description: final_options,
20
},
21
})
22
.then((sent) => {
23
// Responding to poll logic
24
})
25
}

Where letters is an array of alphabet chars ['A', 'B', ..., 'Z']

A user is expected to “react” to the poll question with an emoji to indicate their answer. To make it easier for users, I implemented some logic to have NotTDBot react to its own message with the available options:

1
// Responding to poll logic
2
3
for (var i = 0; i < answer_options.length; i++) {
4
sent.react(letters[i])
5
}
Monitor Messages

Discord.Client has a message listener which can be used to check for the prefix I talked about in processing commands. We can then split the command from the arguments and do a simple if ... else statement to execute the correct command:

1
bot.on("message", async (message) => {
2
if (message.content.charAt(0) != prefix) return
3
let args = message.content.substring(prefix.length).split(" ")
4
let command = args[0]
5
6
if (
7
command == "start" ||
8
command == "pause" ||
9
command == "resume" ||
10
command == "timers" ||
11
command == "end"
12
) {
13
bot.commands.get("timer").execute(message, args)
14
}
15
})
Deployment

The deployment was simple:

  1. make an application on https://discord.com/developers/applications
  2. retrieve your application token

app token

  1. use Discord.Client login

    1
    bot.login(SECRET_APP_TOKEN)
  2. retrieve your client id

client id

  1. generate an invitation link for your bot using https://discordapi.com/permissions.html and your client id

  2. deploy the bot on a server. I used Linode and Docker to containerize the bot with a simple Dockerfile:

    Terminal window
    FROM node:16.16.0
    WORKDIR /app
    COPY package*.json ./
    RUN npm install
    COPY . .
    CMD ["node", "bot.js"]

Start using NotTDBot and my takeaways

Well, you can’t, really. Discord has a policy that bots cannot be added to more than 100 servers unless the bot is verified.

When NotTDBot approached 100 servers I went to do the verification process, when I was notified my bot was flagged for inorganic growth:

Inorganic growth

At the beginning of online debate, there weren’t too many tech-savvy debate members, so only a few members in the community would be responsible for creating servers for their club and debate events. I.e., my club would ask me to create the club’s Discord server and also create the club’s debate event Discord server.

So, because there were too many common moderators in multiple servers, my bot was flagged as inorganic growth.

When I tried to appeal this or find some workaround, Discord support suggested I remove my bot from a significant amount of servers until the warning went away:

Terminal window
// Feb, 2021
Hey Eyas,
Thanks for reaching out!
Regarding your query with the verification of your server, it would be best to reach out to our Community team by heading over to
[https://dis.gd/cprog](https://dis.gd/cprog).
About the verification of your bot, though, it appears that a significant number of the servers this bot is in are owned by the same user(s).
This is considered inorganic and disqualifies a bot for verification.
We would encourage you to review the servers your bot is in to determine how this happened and to apply in the future once this is no longer the case.
Note that this status cannot be contested.
We can't lift this error for you, nor can we share the specifics on how many of your bot's servers can be owned by the same user or set of users.
If you're unsure how to proceed, I'd recommend running the Get Current User Guilds command for your bot, then using Get Guild to determine owner_ids and search for commonalities.
[https://discord.com/developers/docs/resources/user#get-current-user-guilds](https://discord.com/developers/docs/resources/user#get-current-user-guilds) [https://discord.com/developers/docs/resources/guild#get-guild](https://discord.com/developers/docs/resources/guild#get-guild)
You can either choose to leave some of the servers leading to this error or continue letting your bot grow to minimize the portion of your bot's server ownership represented by non-unique users.
The commands to act on the above endpoints and to make your bot leave a given server to vary by library, so if you're unsure, we'd encourage you to review the documentation for your codebase.
You can also consider joining the Discord Developers server at [https://discord.gg/discord-developers](https://discord.gg/discord-developers) to seek further advice from our community.
If you have any other questions or concerns, please don't hesitate to let me know and I'd be happy to help further.
Best,
[Discord Support Member]

While I was dealing with this roadblock, NotTDBot couldn’t be added to any new servers. It soon became more convenient to use an alternative technology that was rapidly developing like Tabtastic or Hear! Hear!, and soon NotTDBot became obsolete.

While NotTDBot didn’t achieve world dominance, I did learn a great deal about product development, and a surprising amount of business (supply and demand babyy!!)


Credits