Yummers is a Discord bot written in Typescript with the Discord.js framework that I coded up in about five days. At least that is how long it took to implement the basic features. I have plans to add more features…
- Users can set their birthday down to the hour and the minute. They do not have to enter the year since that is calculated on the fly. They can also set their timezone so they get alerted at the proper time.
- Each user must set their birthday in a server for the birthday announcement and role to work in that server. This protects the user’s privacy.
- Server moderators can set a specific channel for birthday announcements. They can also designate a special birthday role. Yummers will make announcements and also assign and remove roles as necessary.
- There is a command to view upcoming birthdays within the next year (or any year).
- There is a command to check if there are any users with the same birthday as you on the current server.
Technical details#
I had not touched Discord.js in a while so it was like making a completely new bot. After looking at the code for a command and event handler I found on Github, I wrote an extremely simplified version that would work for my use case. I wanted to reduce the number of dependencies and also just implement my solution.
The database used is PostgreSQL and the database driver used is Prisma. This was my first time using Prisma so it was a bit of a learning curve. Since it’s an ORM, it doesn’t support all the Postgres features as it has to maintain compatibility with other databases like MySQL, MSSQL and SQLite. So in some cases, I had to write raw queries which had me diving into the SQL documentation. More specifically, I wanted to perform an INSERT INTO SELECT
statement with many values and Prisma doesn’t support this.
The design of the database went through multiple iterations. When I was implementing the birthday announcements and role setting, I ran into issues. The problem was I wanted to find users whose birthdays fell into the window I was scanning but were also in the guild I was currently checking. However, each user can be in multiple guilds. At that time, I had a user table and a guild table. I could have used a nested SELECT
query but I decided to normalize the relationship and make a new guildUser table that would simplify the many-to-many relationship into two one-to-many relationships.
This decision would end up having positive privacy implications for the user as it meant they had to explicitly set their birthday on a server so that the relevant guildUser record could be created. Yummers queries for records in the guildUser table and eagerly loads the associated user and guild records if necessary.
One other major problem with querying is the storage format of the birthdays. It doesn’t make sense to store the year since it can change when you calculate when the upcoming birthdays are. So this led me to create my own ISO 8601 date string which is only eight characters long. I call it the Yummers format.
- 2 characters for the month
- 2 for the day
- 2 for the hour
- 2 for the minute
When the user sets their birthday, they can also specify a timezone. This is converted into a UTC offset in minutes. Based on this offset, their original date is converted to UTC in Yummers format. Both the offset and date strings are stored in the database, albeit in different fields. The offset is stored so the original date can be calculated when displaying birthday information. Although the Yummers format is a simplification of the ISO format, Yummers strings can still be sorted lexicographically which makes date comparisons much easier.
However, there remains one big issue. The year part of an ISO date string is significant because the year is assumed to never wrap around. This means a simple memcmp
can always be used. This is also why the date and offset are stored separately. String comparison doesn’t work with negative numbers and the offset can be from -11:59 to +11:59.
Because the Yummers format does not store the year, special handling is required when the window of date comparison crosses over the years.
- Window start:
12010000
-> December 1st 12 AM - Window end:
01120000
-> Janurary 12th 12 AM
If we have the Yummers string 12020000
, representing December 2nd 12 AM, it falls into the window we are testing for but is larger than the window end string. So in this case we must split the query into two parts.
The first window goes from December 1st to December 31st (end of the current year) and the second goes from January 1st (start of next year) to January 12th. If the Yummers string is in either window then it is in the combined window.
There’s quite a bit of date handling going on here which is why I ended up using the Luxon library. As the recommended successor of Moment.js, it serves its role well. It was pretty simple to learn while providing the required functionality which is every developer’s dream when they try out a new library.
About deployment, the biggest headache I had was figuring out why Postgres wouldn’t accept my connection URL. On my development machine which runs Arch (Arch is the best), I was able to set a connection URL without a password since I was using a Postgres role with the same name as my username. When I tried the same thing on my Raspberry Pi which was running Debian, the authentication kept on failing. After much flailing around, I set a password for the role and provided that password in the URL and it magically worked. I don’t know why you can’t get away without setting a password, especially if the bot is hosted on the same machine as the database, but at this point, I’m just glad that it works.
Reply by Email