Skip to content

Commit 913b1d5

Browse files
mbaruhkosayoda
andauthored
Move to timezone aware datetimes (#1895)
* Move to timezone aware datetimes With the shift of the discord.py library to timezone aware datetimes, this commit changes datetimes throughout the bot to be in the UTC timezone accordingly. This has several advantages: - There's no need to discard the TZ every time the datetime of a Discord object is fetched. - Using TZ aware datetimes reduces the likelihood of silently adding bugs into the codebase (can't compare an aware datetime with a naive one). - Our DB already stores datetimes in UTC, but we've been discarding the TZ so far whenever we read from it. Specific places in the codebase continue using naive datetimes, mainly for UI purposes (for examples embed footers use naive datetimes to display local time). * Improve ISODateTime converter documentation Co-authored-by: Kieran Siek <[email protected]>
1 parent b9fb7c2 commit 913b1d5

File tree

19 files changed

+122
-113
lines changed

19 files changed

+122
-113
lines changed

bot/converters.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import re
44
import typing as t
5-
from datetime import datetime
5+
from datetime import datetime, timezone
66
from ssl import CertificateError
77

88
import dateutil.parser
@@ -11,7 +11,7 @@
1111
from aiohttp import ClientConnectorError
1212
from dateutil.relativedelta import relativedelta
1313
from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, MemberConverter, UserConverter
14-
from discord.utils import DISCORD_EPOCH, escape_markdown, snowflake_time
14+
from discord.utils import escape_markdown, snowflake_time
1515

1616
from bot import exts
1717
from bot.api import ResponseCodeError
@@ -28,7 +28,7 @@
2828

2929
log = get_logger(__name__)
3030

31-
DISCORD_EPOCH_DT = datetime.utcfromtimestamp(DISCORD_EPOCH / 1000)
31+
DISCORD_EPOCH_DT = snowflake_time(0)
3232
RE_USER_MENTION = re.compile(r"<@!?([0-9]+)>$")
3333

3434

@@ -273,14 +273,14 @@ async def convert(self, ctx: Context, arg: str) -> int:
273273
snowflake = int(arg)
274274

275275
try:
276-
time = snowflake_time(snowflake).replace(tzinfo=None)
276+
time = snowflake_time(snowflake)
277277
except (OverflowError, OSError) as e:
278278
# Not sure if this can ever even happen, but let's be safe.
279279
raise BadArgument(f"{error}: {e}")
280280

281281
if time < DISCORD_EPOCH_DT:
282282
raise BadArgument(f"{error}: timestamp is before the Discord epoch.")
283-
elif (datetime.utcnow() - time).days < -1:
283+
elif (datetime.now(timezone.utc) - time).days < -1:
284284
raise BadArgument(f"{error}: timestamp is too far into the future.")
285285

286286
return snowflake
@@ -387,7 +387,7 @@ async def convert(self, ctx: Context, duration: str) -> datetime:
387387
The converter supports the same symbols for each unit of time as its parent class.
388388
"""
389389
delta = await super().convert(ctx, duration)
390-
now = datetime.utcnow()
390+
now = datetime.now(timezone.utc)
391391

392392
try:
393393
return now + delta
@@ -443,8 +443,8 @@ async def convert(self, ctx: Context, datetime_string: str) -> datetime:
443443
The converter is flexible in the formats it accepts, as it uses the `isoparse` method of
444444
`dateutil.parser`. In general, it accepts datetime strings that start with a date,
445445
optionally followed by a time. Specifying a timezone offset in the datetime string is
446-
supported, but the `datetime` object will be converted to UTC and will be returned without
447-
`tzinfo` as a timezone-unaware `datetime` object.
446+
supported, but the `datetime` object will be converted to UTC. If no timezone is specified, the datetime will
447+
be assumed to be in UTC already. In all cases, the returned object will have the UTC timezone.
448448
449449
See: https://dateutil.readthedocs.io/en/stable/parser.html#dateutil.parser.isoparse
450450
@@ -470,7 +470,8 @@ async def convert(self, ctx: Context, datetime_string: str) -> datetime:
470470

471471
if dt.tzinfo:
472472
dt = dt.astimezone(dateutil.tz.UTC)
473-
dt = dt.replace(tzinfo=None)
473+
else: # Without a timezone, assume it represents UTC.
474+
dt = dt.replace(tzinfo=dateutil.tz.UTC)
474475

475476
return dt
476477

bot/exts/filters/antispam.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
from collections import defaultdict
33
from collections.abc import Mapping
44
from dataclasses import dataclass, field
5-
from datetime import datetime, timedelta
5+
from datetime import timedelta
66
from itertools import takewhile
77
from operator import attrgetter, itemgetter
88
from typing import Dict, Iterable, List, Set
99

10+
import arrow
1011
from discord import Colour, Member, Message, NotFound, Object, TextChannel
1112
from discord.ext.commands import Cog
1213

@@ -177,21 +178,17 @@ async def on_message(self, message: Message) -> None:
177178

178179
self.cache.append(message)
179180

180-
earliest_relevant_at = datetime.utcnow() - timedelta(seconds=self.max_interval)
181-
relevant_messages = list(
182-
takewhile(lambda msg: msg.created_at.replace(tzinfo=None) > earliest_relevant_at, self.cache)
183-
)
181+
earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.max_interval)
182+
relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, self.cache))
184183

185184
for rule_name in AntiSpamConfig.rules:
186185
rule_config = AntiSpamConfig.rules[rule_name]
187186
rule_function = RULE_FUNCTION_MAPPING[rule_name]
188187

189188
# Create a list of messages that were sent in the interval that the rule cares about.
190-
latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval'])
189+
latest_interesting_stamp = arrow.utcnow() - timedelta(seconds=rule_config['interval'])
191190
messages_for_rule = list(
192-
takewhile(
193-
lambda msg: msg.created_at.replace(tzinfo=None) > latest_interesting_stamp, relevant_messages
194-
)
191+
takewhile(lambda msg: msg.created_at > latest_interesting_stamp, relevant_messages)
195192
)
196193

197194
result = await rule_function(message, messages_for_rule, rule_config)

bot/exts/filters/filtering.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import asyncio
22
import re
3-
from datetime import datetime, timedelta
3+
from datetime import timedelta
44
from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union
55

6-
import dateutil
6+
import arrow
7+
import dateutil.parser
78
import discord.errors
89
import regex
910
from async_rediscache import RedisCache
@@ -192,8 +193,8 @@ def get_name_matches(self, name: str) -> List[re.Match]:
192193
async def check_send_alert(self, member: Member) -> bool:
193194
"""When there is less than 3 days after last alert, return `False`, otherwise `True`."""
194195
if last_alert := await self.name_alerts.get(member.id):
195-
last_alert = datetime.utcfromtimestamp(last_alert)
196-
if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert:
196+
last_alert = arrow.get(last_alert)
197+
if arrow.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert:
197198
log.trace(f"Last alert was too recent for {member}'s nickname.")
198199
return False
199200

@@ -227,7 +228,7 @@ async def check_bad_words_in_name(self, member: Member) -> None:
227228
)
228229

229230
# Update time when alert sent
230-
await self.name_alerts.set(member.id, datetime.utcnow().timestamp())
231+
await self.name_alerts.set(member.id, arrow.utcnow().timestamp())
231232

232233
async def filter_eval(self, result: str, msg: Message) -> bool:
233234
"""
@@ -603,25 +604,25 @@ async def notify_member(self, filtered_member: Member, reason: str, channel: Tex
603604

604605
def schedule_msg_delete(self, msg: dict) -> None:
605606
"""Delete an offensive message once its deletion date is reached."""
606-
delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None)
607+
delete_at = dateutil.parser.isoparse(msg['delete_date'])
607608
self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg))
608609

609610
async def reschedule_offensive_msg_deletion(self) -> None:
610611
"""Get all the pending message deletion from the API and reschedule them."""
611612
await self.bot.wait_until_ready()
612613
response = await self.bot.api_client.get('bot/offensive-messages',)
613614

614-
now = datetime.utcnow()
615+
now = arrow.utcnow()
615616

616617
for msg in response:
617-
delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None)
618+
delete_at = dateutil.parser.isoparse(msg['delete_date'])
618619

619620
if delete_at < now:
620621
await self.delete_offensive_msg(msg)
621622
else:
622623
self.schedule_msg_delete(msg)
623624

624-
async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None:
625+
async def delete_offensive_msg(self, msg: Mapping[str, int]) -> None:
625626
"""Delete an offensive message, and then delete it from the db."""
626627
try:
627628
channel = self.bot.get_channel(msg['channel_id'])

bot/exts/fun/off_topic_names.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import difflib
2-
from datetime import datetime, timedelta
2+
from datetime import timedelta
33

4+
import arrow
45
from discord import Colour, Embed
56
from discord.ext.commands import Cog, Context, group, has_any_role
67
from discord.utils import sleep_until
@@ -22,9 +23,9 @@ async def update_names(bot: Bot) -> None:
2223
while True:
2324
# Since we truncate the compute timedelta to seconds, we add one second to ensure
2425
# we go past midnight in the `seconds_to_sleep` set below.
25-
today_at_midnight = datetime.utcnow().replace(microsecond=0, second=0, minute=0, hour=0)
26+
today_at_midnight = arrow.utcnow().replace(microsecond=0, second=0, minute=0, hour=0)
2627
next_midnight = today_at_midnight + timedelta(days=1)
27-
await sleep_until(next_midnight)
28+
await sleep_until(next_midnight.datetime)
2829

2930
try:
3031
channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get(

bot/exts/moderation/defcon.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from enum import Enum
55
from typing import Optional, Union
66

7+
import arrow
78
from aioredis import RedisError
89
from async_rediscache import RedisCache
910
from dateutil.relativedelta import relativedelta
@@ -109,9 +110,9 @@ async def _sync_settings(self) -> None:
109110
async def on_member_join(self, member: Member) -> None:
110111
"""Check newly joining users to see if they meet the account age threshold."""
111112
if self.threshold:
112-
now = datetime.utcnow()
113+
now = arrow.utcnow()
113114

114-
if now - member.created_at.replace(tzinfo=None) < relativedelta_to_timedelta(self.threshold):
115+
if now - member.created_at < relativedelta_to_timedelta(self.threshold):
115116
log.info(f"Rejecting user {member}: Account is too new")
116117

117118
message_sent = False
@@ -254,7 +255,8 @@ async def _update_threshold(
254255

255256
expiry_message = ""
256257
if expiry:
257-
expiry_message = f" for the next {humanize_delta(relativedelta(expiry, datetime.utcnow()), max_units=2)}"
258+
activity_duration = relativedelta(expiry, arrow.utcnow().datetime)
259+
expiry_message = f" for the next {humanize_delta(activity_duration, max_units=2)}"
258260

259261
if self.threshold:
260262
channel_message = (

bot/exts/moderation/infraction/_scheduler.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import textwrap
22
import typing as t
33
from abc import abstractmethod
4-
from datetime import datetime
54
from gettext import ngettext
65

6+
import arrow
77
import dateutil.parser
88
import discord
99
from discord.ext.commands import Context
@@ -67,7 +67,7 @@ async def reschedule_infractions(self, supported_infractions: t.Container[str])
6767
# We make sure to fire this
6868
if to_schedule:
6969
next_reschedule_point = max(
70-
dateutil.parser.isoparse(infr["expires_at"]).replace(tzinfo=None) for infr in to_schedule
70+
dateutil.parser.isoparse(infr["expires_at"]) for infr in to_schedule
7171
)
7272
log.trace("Will reschedule remaining infractions at %s", next_reschedule_point)
7373

@@ -83,8 +83,8 @@ async def reapply_infraction(
8383
"""Reapply an infraction if it's still active or deactivate it if less than 60 sec left."""
8484
if infraction["expires_at"] is not None:
8585
# Calculate the time remaining, in seconds, for the mute.
86-
expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None)
87-
delta = (expiry - datetime.utcnow()).total_seconds()
86+
expiry = dateutil.parser.isoparse(infraction["expires_at"])
87+
delta = (expiry - arrow.utcnow()).total_seconds()
8888
else:
8989
# If the infraction is permanent, it is not possible to get the time remaining.
9090
delta = None
@@ -382,7 +382,7 @@ async def deactivate_infraction(
382382

383383
log.info(f"Marking infraction #{id_} as inactive (expired).")
384384

385-
expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None
385+
expiry = dateutil.parser.isoparse(expiry) if expiry else None
386386
created = time.format_infraction_with_duration(inserted_at, expiry)
387387

388388
log_content = None
@@ -503,5 +503,5 @@ def schedule_expiration(self, infraction: _utils.Infraction) -> None:
503503
At the time of expiration, the infraction is marked as inactive on the website and the
504504
expiration task is cancelled.
505505
"""
506-
expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None)
506+
expiry = dateutil.parser.isoparse(infraction["expires_at"])
507507
self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction))

bot/exts/moderation/infraction/management.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ def infraction_to_string(self, infraction: t.Dict[str, t.Any]) -> str:
315315
duration = "*Permanent*"
316316
else:
317317
date_from = datetime.fromtimestamp(float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1)))
318-
date_to = dateutil.parser.isoparse(expires_at).replace(tzinfo=None)
318+
date_to = dateutil.parser.isoparse(expires_at)
319319
duration = humanize_delta(relativedelta(date_to, date_from))
320320

321321
lines = textwrap.dedent(f"""

bot/exts/moderation/modlog.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import difflib
33
import itertools
44
import typing as t
5-
from datetime import datetime
5+
from datetime import datetime, timezone
66
from itertools import zip_longest
77

88
import discord
@@ -58,7 +58,7 @@ async def upload_log(
5858
'bot/deleted-messages',
5959
json={
6060
'actor': actor_id,
61-
'creation': datetime.utcnow().isoformat(),
61+
'creation': datetime.now(timezone.utc).isoformat(),
6262
'deletedmessage_set': [
6363
{
6464
'id': message.id,
@@ -404,8 +404,8 @@ async def on_member_join(self, member: discord.Member) -> None:
404404
if member.guild.id != GuildConstant.id:
405405
return
406406

407-
now = datetime.utcnow()
408-
difference = abs(relativedelta(now, member.created_at.replace(tzinfo=None)))
407+
now = datetime.now(timezone.utc)
408+
difference = abs(relativedelta(now, member.created_at))
409409

410410
message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference)
411411

bot/exts/moderation/modpings.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import datetime
22

3+
import arrow
34
from async_rediscache import RedisCache
45
from dateutil.parser import isoparse
56
from discord import Embed, Member
@@ -57,7 +58,7 @@ async def reschedule_roles(self) -> None:
5758
if mod.id not in pings_off:
5859
await self.reapply_role(mod)
5960
else:
60-
expiry = isoparse(pings_off[mod.id]).replace(tzinfo=None)
61+
expiry = isoparse(pings_off[mod.id])
6162
self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod))
6263

6364
async def reapply_role(self, mod: Member) -> None:
@@ -92,7 +93,7 @@ async def off_command(self, ctx: Context, duration: Expiry) -> None:
9293
9394
The duration cannot be longer than 30 days.
9495
"""
95-
delta = duration - datetime.datetime.utcnow()
96+
delta = duration - arrow.utcnow()
9697
if delta > datetime.timedelta(days=30):
9798
await ctx.send(":x: Cannot remove the role for longer than 30 days.")
9899
return

bot/exts/moderation/voice_gate.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import asyncio
22
from contextlib import suppress
3-
from datetime import datetime, timedelta
3+
from datetime import timedelta
44

5+
import arrow
56
import discord
67
from async_rediscache import RedisCache
78
from discord import Colour, Member, VoiceState
@@ -166,8 +167,7 @@ async def voice_verify(self, ctx: Context, *_) -> None:
166167

167168
checks = {
168169
"joined_at": (
169-
ctx.author.joined_at.replace(tzinfo=None) > datetime.utcnow()
170-
- timedelta(days=GateConf.minimum_days_member)
170+
ctx.author.joined_at > arrow.utcnow() - timedelta(days=GateConf.minimum_days_member)
171171
),
172172
"total_messages": data["total_messages"] < GateConf.minimum_messages,
173173
"voice_banned": data["voice_banned"],

0 commit comments

Comments
 (0)