From d6bdc4926cee8c3e0ad94cf531347926a9536b7c Mon Sep 17 00:00:00 2001 From: Leander Furumo Date: Sat, 24 Oct 2026 18:14:43 +0200 Subject: [PATCH] use timezone-aware datetimes everywhere --- src/cogs/anime.py | 4 ++- src/cogs/aurora.py | 8 ++--- src/cogs/birthday.py | 60 +++++++++++++++++++++++---------- src/cogs/cs2_bingo.py | 7 ++-- src/cogs/funreplies.py | 7 ++-- src/cogs/galtinn.py | 2 ++ src/cogs/misc.py | 10 ++++-- src/cogs/repeated_messages.py | 8 ++--- src/cogs/starboard_scraper.py | 18 +++++++++- src/cogs/ten_man.py | 7 ++-- src/cogs/utils/discord_utils.py | 16 ++++----- src/cogs/utils/misc_utils.py | 17 ++++++++++ 12 files changed, 115 insertions(+), 49 deletions(-) diff --git a/src/cogs/anime.py b/src/cogs/anime.py index 744b0f2..e68756c 100644 --- a/src/cogs/anime.py +++ b/src/cogs/anime.py @@ -6,6 +6,7 @@ from discord import app_commands from discord.ext import commands from cogs.utils import embed_templates +from cogs.utils import misc_utils class Anime(commands.Cog): @@ -300,7 +301,8 @@ class Anime(commands.Cog): year, month, day = date.get("year"), date.get("month"), date.get("day") if year and month and day: - return discord.utils.format_dt(datetime(year, month, day), style="d") + date = misc_utils.localize(datetime(year, month, day), self.bot.timezone) + return discord.utils.format_dt(date, style="d") return f"{day or '?'}.{month or '?'}.{year or '?'}" def construct_release_schedule_string(self, start_date: dict, end_date: dict) -> str: diff --git a/src/cogs/aurora.py b/src/cogs/aurora.py index 4fa6a83..ae12e82 100644 --- a/src/cogs/aurora.py +++ b/src/cogs/aurora.py @@ -21,7 +21,7 @@ class Aurora(commands.Cog): self.bot = bot - self.notified = datetime(2000, 9, 11) # Used to prevent spamming aurora alerts + self.notified = datetime(2000, 9, 11, tzinfo=timezone.utc) # Used to prevent spamming aurora alerts self.aurora_alarm.start() def cog_unload(self): @@ -46,7 +46,7 @@ class Aurora(commands.Cog): self.bot.logger.warning("Failed to get forecast data") return None - now = datetime.now(timezone.utc) + now = discord.utils.utcnow() valid_sightings = [] for interval in data["shortIntervals"]: start = datetime.fromisoformat(interval["start"]) @@ -82,7 +82,7 @@ class Aurora(commands.Cog): await self.bot.wait_until_ready() # Make sure we can fetch the #general channel - if datetime.now() - self.notified < timedelta(hours=12): + if discord.utils.utcnow() - self.notified < timedelta(hours=12): self.bot.logger.info( "aurora_alarm: Not sending aurora alert because it has been less than 12 hours since last alert" ) @@ -121,7 +121,7 @@ class Aurora(commands.Cog): content="SJANSE FOR NORDLYS AKKURAT NÅ PÅ NEUF! Se ut vinduet istedenfor på den dumme skjermen din", embed=embed, ) - self.notified = datetime.now() # Update the last notification time + self.notified = discord.utils.utcnow() # Update the last notification time async def setup(bot: commands.Bot): diff --git a/src/cogs/birthday.py b/src/cogs/birthday.py index 976aaf1..5e24b75 100644 --- a/src/cogs/birthday.py +++ b/src/cogs/birthday.py @@ -61,19 +61,23 @@ class Birthday(commands.Cog): self.bot.logger.info("Checking for birthdays") - birthdays = await self.bot.db.fetchall(""" + today = self.local_today() + birthdays = await self.bot.db.fetchall( + """ SELECT * FROM birthdays WHERE ( - EXTRACT(MONTH FROM birthday) = EXTRACT(MONTH FROM current_date) - AND EXTRACT(DAY FROM birthday) = EXTRACT(DAY FROM current_date) + EXTRACT(MONTH FROM birthday) = EXTRACT(MONTH FROM %(today)s) + AND EXTRACT(DAY FROM birthday) = EXTRACT(DAY FROM %(today)s) ) OR ( -- Celebrate 29 February birthdays on 28 February in years that aren't leap years to_char(birthday, 'MM-DD') = '02-29' - AND to_char(current_date, 'MM-DD') = '02-28' - AND to_char(current_date + 1, 'MM-DD') = '03-01' + AND to_char(%(today)s, 'MM-DD') = '02-28' + AND to_char(%(today)s + 1, 'MM-DD') = '03-01' ); - """) + """, + {"today": today.date()}, + ) self.bot.logger.info(f"Found the following birthdays: {birthdays}") @@ -93,7 +97,7 @@ class Birthday(commands.Cog): continue date = datetime(birthday[1].year, birthday[1].month, birthday[1].day) - years_old = relativedelta(datetime.now(), date).years + years_old = relativedelta(today, date).years # Is it inefficent to define a list of greetings per iteration? Yes # Am I hoping python somehow will automatically optimize my code? Yes @@ -144,6 +148,16 @@ class Birthday(commands.Cog): self.bot.logger.info("Unloading cog") self.birthday_check.cancel() + def local_today(self) -> datetime: + """ + Returns + ---------- + (datetime): Midnight today in the bot's time zone, as a naive datetime to compare with birthdays + """ + + now = discord.utils.utcnow().astimezone(self.bot.timezone) + return datetime(now.year, now.month, now.day) + async def fetch_user_birthday(self, user_id: int) -> datetime | None: """ Fetch the birthday of a user in the database @@ -177,18 +191,19 @@ class Birthday(commands.Cog): results = await self.bot.db.fetchone( """ - SELECT *, CAST(birthday + ((EXTRACT(YEAR FROM AGE(birthday)) + 1) * interval '1' YEAR) AS DATE) AS next_bday + SELECT *, + CAST(birthday + ((EXTRACT(YEAR FROM AGE(%s, birthday)) + 1) * interval '1' YEAR) AS DATE) AS next_bday FROM birthdays WHERE discord_id = %s """, - (user_id,), + (self.local_today().date(), user_id), ) if results: birthday = results[2] return datetime(birthday.year, birthday.month, birthday.day) else: # This will never happen unless a database error occurs. Keeping the type checker happy - return datetime.now() + return self.local_today() async def fetch_next_birthdays(self) -> list[tuple[int, datetime, datetime]]: """ @@ -199,11 +214,15 @@ class Birthday(commands.Cog): (list[tuple[int, datetime, datetime]]): List of tuples containing the user ID, date of birth and next birthday """ - results = await self.bot.db.fetchall(""" - SELECT *, CAST(birthday + ((EXTRACT(YEAR FROM AGE(birthday)) + 1) * interval '1' YEAR) AS DATE) AS next_bday + results = await self.bot.db.fetchall( + """ + SELECT *, + CAST(birthday + ((EXTRACT(YEAR FROM AGE(%s, birthday)) + 1) * interval '1' YEAR) AS DATE) AS next_bday FROM birthdays ORDER BY next_bday ASC - """) + """, + (self.local_today().date(),), + ) # Convert date objects to datetime objects birthdays = [] @@ -268,7 +287,7 @@ class Birthday(commands.Cog): ephemeral=True, ) - if date > datetime.now(): + if date > self.local_today(): return await interaction.response.send_message( embed=embed_templates.error_warning("Du kan ikke sette en fremtidig dato!"), ephemeral=True, @@ -276,6 +295,7 @@ class Birthday(commands.Cog): await self.set_user_birthday(interaction.user.id, date) + date = misc_utils.localize(date, self.bot.timezone) embed = embed_templates.success(f"Bursdag satt til {discord.utils.format_dt(date, style='D')}") await interaction.response.send_message(embed=embed) @@ -322,13 +342,14 @@ class Birthday(commands.Cog): ) # Current age information + today = self.local_today() birthday = datetime(user.year, user.month, user.day) - days_old = (datetime.now() - birthday).days - years_old = relativedelta(datetime.now(), birthday).years + days_old = (today - birthday).days + years_old = relativedelta(today, birthday).years # Next birthday information next_birthday = await self.fetch_user_next_birthday(bruker.id) - next_birthday_days = (next_birthday.date() - datetime.now().date()).days + next_birthday_days = (next_birthday - today).days # Japanese era japanese_era = EraDate.from_date(birthday).strftime("%-K%-y年") @@ -336,7 +357,9 @@ class Birthday(commands.Cog): embed = discord.Embed(description=bruker.mention, color=bruker.color) embed.set_thumbnail(url=bruker.display_avatar) embed.set_author(name=bruker.name, icon_url=bruker.display_avatar) - embed.add_field(name="Bursdag", value=discord.utils.format_dt(birthday, style="D")) + embed.add_field( + name="Bursdag", value=discord.utils.format_dt(misc_utils.localize(birthday, self.bot.timezone), style="D") + ) embed.add_field(name="Hvor gammel?", value=f"{years_old} år\n({days_old} dager)", inline=False) embed.add_field(name="Weeb age", value=japanese_era, inline=False) embed.add_field( @@ -378,6 +401,7 @@ class Birthday(commands.Cog): except (discord.NotFound, discord.HTTPException): continue + next_birthday = misc_utils.localize(next_birthday, self.bot.timezone) timestamp = discord.utils.format_dt(next_birthday, style="D") relative_timestamp = discord.utils.format_dt(next_birthday, style="R") birthday_strings.append(f"* {discord_user.name} - {timestamp} ({relative_timestamp})") diff --git a/src/cogs/cs2_bingo.py b/src/cogs/cs2_bingo.py index a556055..5413bb6 100644 --- a/src/cogs/cs2_bingo.py +++ b/src/cogs/cs2_bingo.py @@ -1,7 +1,6 @@ import asyncio import json import random -from datetime import datetime from datetime import timedelta from io import BytesIO from typing import override @@ -46,7 +45,7 @@ class CS2Bingo(commands.Cog): """ if lobby := self.active_lobbies.get(str(interaction.user.id), None): - if lobby.ends < datetime.now(): + if lobby.ends < discord.utils.utcnow(): self.active_lobbies.pop(str(interaction.user.id)) else: return await interaction.response.send_message( @@ -54,7 +53,7 @@ class CS2Bingo(commands.Cog): ) for lobby in self.active_lobbies.values(): - if lobby.ends > datetime.now() and any(p.id == interaction.user.id for p in lobby.players): + if lobby.ends > discord.utils.utcnow() and any(p.id == interaction.user.id for p in lobby.players): return await interaction.response.send_message( embed=embed_templates.error_warning("Du er allerede i en lobby") ) @@ -62,7 +61,7 @@ class CS2Bingo(commands.Cog): self.active_lobbies[str(interaction.user.id)] = Lobby( host=interaction.user, players=[interaction.user], - ends=datetime.now() + timedelta(minutes=10), + ends=discord.utils.utcnow() + timedelta(minutes=10), kicked_players=[], ) diff --git a/src/cogs/funreplies.py b/src/cogs/funreplies.py index fb4e524..0df8065 100644 --- a/src/cogs/funreplies.py +++ b/src/cogs/funreplies.py @@ -1,6 +1,7 @@ import random import re from datetime import datetime +from datetime import timezone import discord from discord.ext import commands @@ -21,7 +22,7 @@ class FunReplies(commands.Cog): # This is kind of a shitty way to do it but I'm too lazy to implement anything good right now self.cooldown_seconds = 600 initial_datetime = datetime( - 2000, 9, 11 + 2000, 9, 11, tzinfo=timezone.utc ) # Set initial datetime far in the past to allow triggering right after boot self.previous_invokations = { "olof palme": initial_datetime, @@ -101,12 +102,12 @@ class FunReplies(commands.Cog): bool: Whether or not the reply was triggered """ - if (datetime.now() - self.previous_invokations[cooldown_key]).total_seconds() < self.cooldown_seconds: + if (discord.utils.utcnow() - self.previous_invokations[cooldown_key]).total_seconds() < self.cooldown_seconds: return False if re.search(regex_match, message.content, flags=regex_flags) and random.randint(1, 100) <= trigger_chance: await message.reply(reply) - self.previous_invokations[cooldown_key] = datetime.now() + self.previous_invokations[cooldown_key] = discord.utils.utcnow() return True return False diff --git a/src/cogs/galtinn.py b/src/cogs/galtinn.py index cfbd51a..52a3013 100644 --- a/src/cogs/galtinn.py +++ b/src/cogs/galtinn.py @@ -5,6 +5,7 @@ from discord import app_commands from discord.ext import commands from cogs.utils import embed_templates +from cogs.utils import misc_utils class Galtinn(commands.Cog): @@ -113,6 +114,7 @@ class Galtinn(commands.Cog): membership_status = "Ja" if last_membership["is_valid"] else "Nei" if last_membership["membership_type"] != "lifelong": expiration_date = datetime.strptime(last_membership["end_date"], "%Y-%m-%d") + expiration_date = misc_utils.localize(expiration_date, self.bot.timezone) expires = discord.utils.format_dt(expiration_date, "D") time_left = discord.utils.format_dt(expiration_date, "R") diff --git a/src/cogs/misc.py b/src/cogs/misc.py index 8b1a209..692363f 100644 --- a/src/cogs/misc.py +++ b/src/cogs/misc.py @@ -14,6 +14,7 @@ from PIL import ImageFont from cogs.utils import discord_utils from cogs.utils import embed_templates +from cogs.utils import misc_utils class Misc(commands.Cog): @@ -214,7 +215,7 @@ class Misc(commands.Cog): """ land = land.upper() - år = datetime.now().year if not år else int(år) + år = discord.utils.utcnow().astimezone(self.bot.timezone).year if not år else int(år) async with self.bot.http_session.get(f"https://date.nager.at/api/v3/publicholidays/{år}/{land}") as response: if response.status not in (200, 204): @@ -231,7 +232,8 @@ class Misc(commands.Cog): # Construct output holiday_str = "" for day in data: - date = discord.utils.format_dt(datetime.strptime(day["date"], "%Y-%m-%d"), style="D") + date = datetime.strptime(day["date"], "%Y-%m-%d") + date = discord.utils.format_dt(misc_utils.localize(date, self.bot.timezone), style="D") holiday_str += f'* **{date}**: {day["localName"]}\n' embed = discord.Embed( @@ -358,6 +360,10 @@ class Misc(commands.Cog): parsed_time = dateparser.parse( tekst, languages=["nb", "nn", "en"], + settings={ + "TIMEZONE": str(self.bot.timezone) if self.bot.timezone else "local", + "RETURN_AS_TIMEZONE_AWARE": True, + }, ) if not parsed_time: embed = embed_templates.error_warning("Forstod ikke tidspunktet. Prøv å skrive det på en annen måte.") diff --git a/src/cogs/repeated_messages.py b/src/cogs/repeated_messages.py index 02c1b40..82ed5a6 100644 --- a/src/cogs/repeated_messages.py +++ b/src/cogs/repeated_messages.py @@ -1,5 +1,4 @@ import random -from datetime import datetime import discord from discord.ext import commands @@ -41,7 +40,7 @@ class RepeatedMessages(commands.Cog): Sends a message on Friday at 00:00 """ - if datetime.now().weekday() != 4: + if discord.utils.utcnow().astimezone(self.bot.timezone).weekday() != 4: return if not (channel := self.get_general_channel()): @@ -73,7 +72,7 @@ class RepeatedMessages(commands.Cog): """ Sends a message on Monday at 00:00 """ - if datetime.now().weekday() != 0: + if discord.utils.utcnow().astimezone(self.bot.timezone).weekday() != 0: return if not (channel := self.get_general_channel()): return @@ -90,7 +89,8 @@ class RepeatedMessages(commands.Cog): Sends a message on New Year's Day at 00:00 """ - if datetime.now().month != 1 or datetime.now().day != 1: + today = discord.utils.utcnow().astimezone(self.bot.timezone) + if today.month != 1 or today.day != 1: return if not (channel := self.get_general_channel()): diff --git a/src/cogs/starboard_scraper.py b/src/cogs/starboard_scraper.py index 7b8d844..1ff20d0 100644 --- a/src/cogs/starboard_scraper.py +++ b/src/cogs/starboard_scraper.py @@ -50,10 +50,26 @@ class StarboardScraper(commands.Cog): CREATE TABLE IF NOT EXISTS starboard_scraper_state ( id INTEGER PRIMARY KEY DEFAULT 1, last_scraped_message_id BIGINT, - last_scrape_time TIMESTAMP, + last_scrape_time TIMESTAMPTZ, CHECK (id = 1) ) """) + await self.bot.db.execute(""" + DO $$ + BEGIN + IF ( + SELECT data_type + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'starboard_scraper_state' + AND column_name = 'last_scrape_time' + ) = 'timestamp without time zone' THEN + ALTER TABLE starboard_scraper_state + ALTER COLUMN last_scrape_time TYPE TIMESTAMPTZ + USING last_scrape_time AT TIME ZONE 'Europe/Oslo'; + END IF; + END $$; + """) async def get_last_scraped_message_id(self): """ diff --git a/src/cogs/ten_man.py b/src/cogs/ten_man.py index cb8297f..de52fa6 100644 --- a/src/cogs/ten_man.py +++ b/src/cogs/ten_man.py @@ -6,7 +6,6 @@ Below you will see a lot of stupid shit. Pls ignore """ import random -from datetime import datetime from datetime import timedelta from typing import override @@ -47,7 +46,7 @@ class TenMan(commands.Cog): """ if lobby := self.active_lobbies.get(str(interaction.user.id), None): - if lobby.ends < datetime.now(): + if lobby.ends < discord.utils.utcnow(): self.active_lobbies.pop(str(interaction.user.id)) else: return await interaction.response.send_message( @@ -57,7 +56,7 @@ class TenMan(commands.Cog): ) for lobby in self.active_lobbies.values(): - if lobby.ends > datetime.now() and any(p.id == interaction.user.id for p in lobby.players): + if lobby.ends > discord.utils.utcnow() and any(p.id == interaction.user.id for p in lobby.players): return await interaction.response.send_message( embed=embed_templates.error_warning( "Du er allerede i en lobby", @@ -67,7 +66,7 @@ class TenMan(commands.Cog): self.active_lobbies[str(interaction.user.id)] = Lobby( host=interaction.user, players=[interaction.user], - ends=datetime.now() + timedelta(minutes=30), + ends=discord.utils.utcnow() + timedelta(minutes=30), kicked_players=[], ) diff --git a/src/cogs/utils/discord_utils.py b/src/cogs/utils/discord_utils.py index 7e6a49c..4e9d181 100644 --- a/src/cogs/utils/discord_utils.py +++ b/src/cogs/utils/discord_utils.py @@ -191,7 +191,7 @@ class Lobby: class LobbyView(discord.ui.View): def __init__(self, lobby: Lobby, bot: commands.Bot): - lobby_end = (lobby.ends - datetime.now()).total_seconds() + lobby_end = (lobby.ends - discord.utils.utcnow()).total_seconds() super().__init__(timeout=lobby_end) self.lobby = lobby @@ -210,7 +210,7 @@ class LobbyView(discord.ui.View): pass async def end_lobby(self): - self.lobby.ends = datetime.now() + self.lobby.ends = discord.utils.utcnow() for item in self.children: item.disabled = True @@ -225,7 +225,7 @@ class LobbyView(discord.ui.View): @discord.ui.button(label="Bli med", style=discord.ButtonStyle.blurple) async def join_lobby(self, interaction: discord.Interaction, button: discord.ui.Button): - if datetime.now() > self.lobby.ends: + if discord.utils.utcnow() > self.lobby.ends: embed = embed_templates.error_warning("Lobbyen har allerede startet") return await interaction.response.send_message(embed=embed, ephemeral=True, delete_after=10) @@ -289,7 +289,7 @@ class LobbyView(discord.ui.View): embed = embed_templates.error_warning("Bare hosten kan slette lobbyen") return await interaction.response.send_message(embed=embed, ephemeral=True, delete_after=10) - self.lobby.ends = datetime.now() + self.lobby.ends = discord.utils.utcnow() await interaction.message.delete() embed = embed_templates.success("Lobbyen har blitt slettet") @@ -418,7 +418,7 @@ class TempVoiceHelper: continue if len(channel.members) == 0: - self.temp_vc_channels[channel]["no_members_since"] = datetime.now() + self.temp_vc_channels[channel]["no_members_since"] = discord.utils.utcnow() self.bot.logger.info(f"Temporary voice channel {channel} has no members. Will be deleted in 1 minutes") else: self.temp_vc_channels[channel]["no_members_since"] = None @@ -434,7 +434,7 @@ class TempVoiceHelper: if not data["no_members_since"]: continue - if (datetime.now() - data["no_members_since"]).total_seconds() >= 60: + if (discord.utils.utcnow() - data["no_members_since"]).total_seconds() >= 60: try: await channel.delete(reason="tempvoice kanal inaktiv i 1 minutt") except discord.NotFound: @@ -470,7 +470,7 @@ class TempVoiceHelper: await self.forget(channel_id) continue - now = datetime.now() + now = discord.utils.utcnow() self.temp_vc_channels[channel] = {"created": now, "no_members_since": None if channel.members else now} restored += 1 @@ -514,7 +514,7 @@ class TempVoiceHelper: self.bot.logger.info(f"Created temporary voice channel {channel} in {interaction.guild}") - now = datetime.now() + now = discord.utils.utcnow() self.temp_vc_channels[channel] = {"created": now, "no_members_since": now} await self.remember(channel) diff --git a/src/cogs/utils/misc_utils.py b/src/cogs/utils/misc_utils.py index 705642d..92730bb 100644 --- a/src/cogs/utils/misc_utils.py +++ b/src/cogs/utils/misc_utils.py @@ -16,6 +16,23 @@ from PIL import ImageFont MIDNIGHT = datetime.time(hour=0, minute=0, tzinfo=ZoneInfo("Europe/Oslo")) +def localize(dt: datetime.datetime, tz: datetime.tzinfo | None) -> datetime.datetime: + """ + Attach a time zone to a naive datetime without changing its wall time + + Parameters + ---------- + dt (datetime.datetime): The naive datetime + tz (datetime.tzinfo | None): The time zone. None means the system time zone + + Returns + ---------- + (datetime.datetime): The aware datetime + """ + + return dt.replace(tzinfo=tz) if tz else dt.astimezone() + + # The exceptions discord.py's tasks.loop retries instead of stopping on RETRIED_TASK_EXCEPTIONS = ( OSError,