I made a bot to track your timezones and alert users who ping you about their time and timezone (if they have set one) compared to yours.
Some of the things in this README will not be reflected in the file it's referencing. This is because I realised some things after finishing everything. The changes will be updated soon. :D
Below, I go through my entire process of creating this bot. From start to finish. I hope you enjoy your read! :D
Using the power of on_message
listeners, we can measure how active a channel's chat is. As a baseline, I have chosen to use 20 messages per minute (1 message every 3 seconds) as a benchmark for an active chat. We first start with a task loop and an on_message
listener to count the number of messages in a minute:
from discord.ext import commands
class Timezones(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.pool = bot.pool
self.messages_per_minute = 0
@commands.Cog.listener('on_message')
async def message_counter(self, message):
self.messages_per_minute += 1
Now, this doesn't just count messages in a channel, but it counts every message sent that the bot can "see". This includes:
We can easily include in our cog message listener to ignore messages from private messages, since those would have also counted towards the total, and below is how we do this.
from discord.ext import commands
class Timezones(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.pool = bot.pool
self.messages_per_minute = 0
@commands.Cog.listener('on_message')
async def message_counter(self, message):
if not message.guild:
return
self.messages_per_minute += 1
The guild
attribute of our message object can either be in two types: a Guild
instance or a NoneType
. We can check for this NoneType
with an if not
check, and by returning if it's true, any private messages do not pass this first layer and get kicked out immediately.
However, messages in guilds pass this check, and of course, all messages in all channels in all guilds is not what we want to happen. So to counter this, we need to centralise the scope of our search, and we can do this with an SQL table. So for this, we can create a messages
table to count all messages in all channes in all servers and measure activity that way.
CREATE TABLE IF NOT EXISTS messages (
guild_id INTEGER NOT NULL UNIQUE,
channel_id INTEGER NOT NULL UNIQUE,
count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (guild_id, channel_id)
);
As you can see, we have:
guild_id
column for a guild IDchannel_id
column for a channel IDcount
to represent the number of messages sent in that channelThis is a lot more reliable than the scope of our previous search, and the PRIMARY KEY
will be very helpful for upserting into the table with one SQL transaction. We've also given count
a default, meaning we don't need to specify a number when inserting.
We can now include in our message listener to update our database based on message activity, as follows.
from discord import Message
from discord.ext import commands
class Timezones(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.pool = bot.pool
@commands.Cog.listener('on_message')
async def message_counter(self, message: Message):
if not message.guild:
return
async with self.pool.acquire() as conn:
await conn.execute("""INSERT INTO messages (guild_id, channel_id) VALUES (?, ?)
ON CONFLICT (guild_id, channel_id) DO SET count = count + 1"""
(message.guild.id, message.channel.id)
)
Using Rapptz's asqlite wrapper, we acquire a connection from the connection pool and use that to run our SQL transaction. In the transaction, we try to insert a new row with the guild and channel IDs of the Message
object, and when this fails due to our PRIMARY KEY
that only lets us have one copy of the data, we can update the row blocking us from inserting and raise the count by 1.
This SQL table change also means we can remove that variable.
In case you didn't notice, we never reset our message counters, so effectively, all we've done is just "all messages sent from here, here and here, since this time". We need to reset our counts every minute and we can do this by deleting all the rows in the table. The reason we delete the rows from the table instead of resetting them all to zero, is that if a channel is deleted or a guild is deleted, the rows will still remain there, creating "ghost logs" of channels / guilds that don't exist.
We can make use of discord.py's tasks
module, which lets us loop in the background and clear the database every minute, as follows.
from discord.ext import tasks
@tasks.loop(minutes = 1)
async def clear_db(self):
async with self.pool.acquire() as conn:
await conn.execute("DELETE FROM messages")
And integrating with the rest of the code:
from discord import Message
from discord.ext import commands, tasks
class Timezones(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.pool = bot.pool
@commands.Cog.listener('on_message')
async def message_counter(self, message: Message):
if not message.guild:
return
async with self.pool.acquire() as conn:
await conn.execute("""INSERT INTO messages (guild_id, channel_id) VALUES (?, ?)
ON CONFLICT (guild_id, channel_id) DO SET count = count + 1"""
(message.guild.id, message.channel.id)
)
@tasks.loop(minutes = 1)
async def clear_db(self):
async with self.pool.acquire() as conn:
await conn.execute("DELETE FROM messages")
Just in case the database is empty and no messages are sent for over an hour, we can add an extra query after deleting all the messages to see how many changes we've made to the database. Using the changes()
function in SQLite, we can do exactly that, and this can go neatly under the "delete all messages" line.
@tasks.loop(minutes = 1)
async def clear_db(self):
async with self.pool.acquire() as conn:
await conn.execute("DELETE FROM messages")
req = await conn.execute("SELECT changes()")
row = await conn.fetchone()
changes = row['changes()']
if changes == 0:
self.clear_db.stop()
If there are no changes then stop the loop and don't keep clearing the database. The changes() function returns the number of times these operations have been performed on the transaction directly before:
Because we've just run a "delete all records" statement, the only actions that the function can count are deleting records, so we can get the number of records we've deleted from the database. If that number is 0, then stop the loop.
We can use regex (regular expressions) on the message content to check if any users have been pinged in the message. When this happens, we can get the user from the ID in the mention and then get the user from the message object's guild by performing first, a get request (getting the information from cache) and if that returns None
, perform a fetch request (using an API call) and if that raises any errors, just stop the command there.
import re
mentions = re.findall("<@!?([0-9]+)>", message.content)
try:
user = await message.guild.get_member(mentions[0][2:-1]) or self.bot.fetch_user(mentions[0][2:-1])
except:
return
We don't need an error check for list indexing on an empty list because the unscoped except
section catches that for us.
Again, we take the first mention found in this list (although we could combine the final embed into a group of embeds or a paginator for each user) and then find the user associated with the ID in that mention.
I use a get-then-fetch for the user as a central point for an ID and a mention, but the mention is literally already given to you and the same with the ID, which was a bit of an oversight and definitely something I should have cut out, but I'll do it later. :trollface:
To create the embed with the timezone information for the message author and the person mentioned, we must query the database for timezone data twice: once for us and once for the person mentioned. However, instead of searching for us first, we'll search for us second, and the reason for that is because if they don't have timezone data, telling us what the time is becomes pretty pointless. If they don't have timezone data, we can stop the command there and not waste time trying to get our own timezone info.
async with self.pool.acquire() as conn:
req = await conn.execute("SELECT * FROM timezones WHERE user_id = ?", (user.id,))
their_timezone_data = await req.fetchone()
if not their_timezone_data:
return
If they do end up having timezone data, we can then try to get our own.
async with self.pool.acquire() as conn:
...
req = await conn.execute("SELECT * FROM timezones WHERE user_id = ?", (message.author.id,))
your_timezone_data = await req.fetchone()
if not your_timezone_data:
return
Before the timezone data retrieval from the database, we can create an embed to start with.
embed = discord.Embed(
title = "Their Timezone",
description = "Just to let you know:\n",
color = discord.Color.dark_embed()
)
We end the embed's description with a new line character because we'll be adding onto it with each piece of data we retrieve, provided we receive both of them.
Upon receiving the new data, we can add it to the embed like this. Below is for the author's timezone info:
your_tz = your_timezone_data['utc_diff']
your_datetime = dt.now() + td(hours = your_tz)
embed.description += f"- {user.mention}'s time is {utils.format_dt(your_datetime, style = 'f')} and their timezone is `UTC{f'+{your_tz}' if your_tz > 0 else your_tz}`\n"
We can then add two fields to the embed, one for mentioning the time difference and another for mentioning "extra information", which is basically how to set your own timezone.
embed.add_field(
name = "Time Difference",
value = f"You are {abs(your_tz - their_tz)} hours ahead of {user.mention}." if your_tz - their_tz > 0 else f"{user.mention} is {abs(your_tz - their_tz)} hours ahead of you."
)
embed.add_field(
name = "Extra Information",
value = f"\nIf you want to set your timezone, use `/timezone set`.\nIf you want to remove your timezone, use `/timezone remove`."
)
Then, send a reply and return if any exceptions arise.
try:
await message.reply(embed = embed)
except:
pass
We can use pass
instead of return
because it's the end of the command anyway.
Now that we have a way to measure channel activity, we need to add the main feature of the bot: displaying other user's timezones when they get mentioned.
Firstly, we need a new database table to hold our user IDs and the user's timezone, which we do as follows.
CREATE TABLE IF NOT EXISTS timezones (
user_id INTEGER NOT NULL UNIQUE,
utc_diff INTEGER NOT NULL DEFAULT 0
);
This table will have a unique user ID and the user's corresponding timezone. But asking people for their timezone feels like it will (and probably does) end up deprecating UX (user-experience), so for a more user-friendly experience, we can ask them the time instead, which is:
We'll use slash commands for this, due to their (what I'll call) sectioned arguments. In prefix commands, all arguments (without quotation marks) are interpreted as only one "word", so think of it like the whole message content has been split by spaces. To get more than one word, you can either:
However, in slash commands, you can have more than one word in an argument by standard. It's also quite UI-friendly, being a part of Discord itself, instead of sending messages. This is personally why I'll use slash commands for this.
The slash command is as follows.
from discord import Interaction, app_commands
from discord.ext import commands
class Timezones(commands.Cog):
...
timezone = app_commands.Group(name = 'timezone', description = 'A bunch of commands about timezones.')
@timezone.command(name = 'set')
async def set_timezone(self, interaction: Interaction, given_time: str):
...
To centralise our timezone commands, we can use an app commands group, which means our commands will be ran as /timezone something
. For the set
command, it gets run as /timezone set <given_time>
.
But using arguments with a _
in them doesn't sound very practical if we're caring about UX like I was mentioning earlier. Luckily for us, we can include a decorator that lets us rename an argument in code to a different name on the Discord side of things, which is what we can do to rename "given_time" to just "time".
from discord import Interaction, app_commands
from discord.ext import commands
class Timezones(commands.Cog):
...
timezone = app_commands.Group(name = 'timezone', description = 'A bunch of commands about timezones.')
@timezone.command(name = 'set')
@app_commands.rename(given_time = 'time')
async def set_timezone(self, interaction: Interaction, given_time: str):
...
And for clarity's sake, we'll add a description for given_time
.
from discord import Interaction, app_commands
from discord.ext import commands
class Timezones(commands.Cog):
...
timezone = app_commands.Group(name = 'timezone', description = 'A bunch of commands about timezones.')
@timezone.command(name = 'set')
@app_commands.rename(given_time = 'time')
@app_commands.describe(given_time = 'The time for you now, given in HH:MM format.')
async def set_timezone(self, interaction: Interaction, given_time: str):
...
To get the time in the time
argument of our slash command, we can use regex. It's a simple [0-9][0-9]:[0-9][0-9]
and works perfectly fine for obtaining a proper time.
import re
time = re.findall('[0-9][0-9]:[0-9][0-9]')[0]
re.findall(...)
returns a list of matches. We only want the first match, so we can index that list by using [0]
.
However, a problem arises when there isn't a match at all. In this case, we would be indexing an empty list, which would raise an IndexError
and stop the command from running entirely.
We can catch this exception using a try-except
block around the indexing.
try:
time = re.findall('[0-9][0-9]:[0-9][0-9]')[0]
except IndexError:
await interaction.response.send_message(
"You've given an invalid time format! Use the format `HH:MM` when you run this command.",
ephemeral = True
)
return
We try to match the given string with regex and then get the first match (in case they did something like 12:45:11
), and when that fails, we notify the user they gave an incorrect time format and stop the command there.
Currently, users can input whatever time they feel like. 99:99
is as valid a time as 37:18
. We can split our regex match by the :
character, which separates the string into the hours part and the minutes part.
hours, minutes = time.split(':')
This is very easy. And then to check that the time is valid:
if int(hours) not in range(24):
await interaction.response.send_message(
"You've given an invalid time format! Use the format `HH:MM` when you run this command.",
ephemeral = True
)
return
We can take advantage of the fact that Python ranges typically start at 0 (when not specified) and end before the number given. This means we can do range(24)
, which returns the numbers 0 to 23 (the only valid numbers for times - 24 becomes 0 again).
We need to call int()
on the values because:
If the hours part fails these checks, we can alert the user and stop the command there.
Next, we need to handle the minutes. Differing timezones means that the hours will be different, not the minutes. This is one way we can tell if somebody is telling the truth or not. We can check if the minutes given match up to the minutes at the time the command was run, like this:
from datetime import datetime as dt
if int(minutes) != dt.now().minute:
await interaction.response.send_message(
"You've given an invalid time format! Use the format `HH:MM` when you run this command.",
ephemeral = True
)
return
dt.now()
gives us the current datetime (date and time) of right now (or at that point in time when the command was run). This datetime.datetime
has an attribute called minute
which gives us the minutes part of the datetime now. Since the minutes don't change, everywhere in the world will have the same minutes.
Say you run the command and dt.now().minute
ends up being 1 minute ago. This means the command would bounce, and you'd have to write it again, right? Well, we can change it from being exactly those minutes to being approximately those minutes. Call it a 5-minute range, which we can check like this:
mins_now = dt.now().minute
if not mins_now - 5 <= int(minutes) <= mins_now + 5:
await interaction.response.send_message(
"You've given an invalid time format! Use the format `HH:MM` when you run this command.",
ephemeral = True
)
return
We store the minutes now as a variable to avoid multiple calls (and for cleanliness), then check if the minutes given are within 5 minutes before and 5 minutes after.
Turns out the 5-minute range could carry over from say 12:58pm as the lower end and 13:06pm as the upper end, but this wouldn't be reflected in code as in code, the values would be from 58 to 68, which makes no sense. Because of this reason, I'm gonna revert those changes and keep it the way I had before.
When setting the timezone, we can watch out for any human mistakes with a confirmation menu. This is standard if you use discord.py
but I'll explain it regardless.
class ConfirmTimezone(ui.View):
def __init__(self, user: discord.Member, timezone: int, formatted_timezone: str):
super().__init__(timeout = 25)
self.user = user
self.timezone = timezone
self.formatted_timezone = formatted_timezone
async def disable_items(self):
for item in self.children:
item.disabled = True
async def on_timeout(self) -> None:
await self.disable_items()
await self.message.edit(
embed = discord.Embed(
title = "Timed Out",
description = '~~' + self.message.embeds[0].description + '~~',
color = discord.Color.red()
)
)
@ui.button(label = 'Yes', style = BS.green)
async def yes(self, interaction: Interaction, _):
async with self.cog.pool.acquire() as conn:
await conn.execute("INSERT INTO timezones (user_id, utc_diff) VALUES (?, ?)", (self.user.id, self.timezone))
await interaction.response.edit_message(
ephemeral = True, embed = discord.Embed(
title = "Confirmed Action",
description = f"Your timezone has now been set to **UTC{self.formatted_timezone}**.",
color = discord.Color.green()
)
)
self.stop()
@ui.button(label = 'No', style = BS.red)
async def no(self, interaction: Interaction, _):
await interaction.response.edit_message(
ephemeral = True, embed = discord.Embed(
title = "Cancelled Action",
description = "Your timezone has not been set.",
color = discord.Color.red()
)
)
self.stop()
Basically, we have a view with two buttons: "Yes" in green, and "No" in red. They do exactly what you think they will do - one confirms the action; the other cancels it. When we confirm, we insert into the database a new record containing the person's user ID and UTC offset.
Now that you have your timezone, say you move across the world from Newfoundland, Canada to London, UK. You have now gone from UTC-6 to UTC+1, but the bot won't reflect those changes. This is Discord after all. You've probably already been doxxed before.
For this, we need a timezone removing command. And we can do exactly that.
In SQL, we can remove timezones with the following transaction:
DELETE FROM messages;
And in Python, it's the same:
async with self.pool.acquire() as conn:
await conn.execute("DELETE FROM messages")
async with self.pool.acquire() as conn:
await conn.execute("DELETE FROM timezones WHERE user_id = ?", (interaction.user.id,))
await interaction.response.send_message(
ephemeral = True, embed = discord.Embed(
description = "Your timezone has been removed.",
color = discord.Color.green()
)
)
We first check for a row in our database that has our user's timezone attached to them.
from discord import app_commands, Interaction
from discord.ext import commands
class Timezones(commands.Cog):
...
@timezone.command(name = 'remove', description = 'Remove your timezone.')
async def remove_timezone(self, interaction: Interaction):
async with self.pool.acquire() as conn:
req = await conn.execute("SELECT * FROM timezones WHERE user_id = ?", (interaction.user.id,))
row = await req.fetchone()
If they don't have one, notify the user and stop the command there.
if not row:
await interaction.response.send_message(
ephemeral = True, embed = discord.Embed(
description = "You don't have a timezone set.",
color = discord.Color.red()
)
)
return
If they do have a timezone in the database, delete it and notify the user it was successful.
async with self.pool.acquire() as conn:
...
await conn.execute("DELETE FROM timezones WHERE user_id = ?", (interaction.user.id,))
await interaction.response.send_message(
ephemeral = True, embed = discord.Embed(
description = "Your timezone has been removed.",
color = discord.Color.green()
)
)
For syncing commands, I'll use this sync command, made by me:
# in main.py file
import traceback
@bot.command()
async def sync(ctx):
try:
synced = await bot.tree.sync()
except:
embed = discord.Embed(
name = "Oh No!",
description = "Looks like something went wrong. Take a peek below.",
color = discord.Color.red()
)
embed.add_field(
name = "Error",
value = f"```py\n{traceback.format_exc()}\n```"
)
else:
embed = discord.Embed(
name = "Success!",
description = f"Successfully synced {len(synced)} commands.",
color = discord.Color.green()
)
embed.add_field(
name = "Commands Synced",
value = "\n".join([f'- {cmd.name}' for cmd in synced])
)
finally:
await ctx.reply(embed = embed)
That's literally all the steps you need. Below are dependencies for if you want to host this bot yourself, including all the SQL table creation statements and all the libraries used in this gist.
This took me around 4 hours, and I'm glad you got all this way through my project. It means a lot to me that you got this far, and I hope you enjoy my future writings on future projects. (There's a few cooking up right now!)
Before you use this bot, you need to run a few things.
First are the following SQL statements. These create the databases the bot is going to be using.
Note: this bot runs using SQLite.
The first creates the timezones
table:
CREATE TABLE IF NOT EXISTS timezones (
user_id INTEGER NOT NULL UNIQUE,
utc_diff INTEGER NOT NULL DEFAULT 0
);
The second creates the guilds
table:
CREATE TABLE IF NOT EXISTS guilds (
user_id INTEGER NOT NULL,
guild_id INTEGER NOT NULL,
PRIMARY KEY (user_id, guild_id)
);
The third creates the messages
table, used for counting messages:
CREATE TABLE IF NOT EXISTS messages (
guild_id INTEGER NOT NULL UNIQUE,
channel_id INTEGER NOT NULL UNIQUE,
count INTEGER NOT NULL DEFAULT 0
);
(The guilds table is necessary for performing lookups and paginating the results.)
If you're too lazy to run the create statements yourself, (I'm looking at you, Qoyyuum) then just run the setup db.py
file and it'll:
Nicely done! :thumbsup:
Next, you need to install these libraries:
discord.py
asqlite
This can also easily be done by pip installing the requirements or via pipenv:
pipenv install
pip install -r requirements.txt
Similarly, you can run this with Docker Compose
docker compose up -d --build
Discord bot obviously needs a token. You can set this up with a simple .env
file with TOKEN
value in
TOKEN=<INSERT YOUR TOKEN HERE>
As always, NEVER SHARE YOUR TOKEN WITH ANYONE
This can also easily be done by pip install
ing the requirements or via pipenv:
pipenv install
pip install -r requirements.txt
Similarly, you can run this with Docker Compose
docker compose up -d --build
Discord bot obviously needs a token. You can set this up with a simple .env
file with a TOKEN
value in, or just use a .txt
file and read from that.
(Make sure you have your file with the token in listed in your .gitignore!)
TOKEN = <INSERT YOUR TOKEN HERE>
As always, NEVER SHARE YOUR TOKEN WITH ANYONE.