Skip to content

Commit 505e851

Browse files
committed
Refactor slowmode command to use Duration converter; Combine redis caches into one object; update and tidy related tests.
1 parent e159c2b commit 505e851

File tree

2 files changed

+55
-56
lines changed

2 files changed

+55
-56
lines changed

bot/exts/moderation/slowmode.py

Lines changed: 32 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import UTC, datetime, timedelta
1+
from datetime import datetime
22
from typing import Literal
33

44
from async_rediscache import RedisCache
@@ -9,7 +9,7 @@
99

1010
from bot.bot import Bot
1111
from bot.constants import Channels, Emojis, MODERATION_ROLES
12-
from bot.converters import DurationDelta
12+
from bot.converters import Duration, DurationDelta
1313
from bot.log import get_logger
1414
from bot.utils import time
1515
from bot.utils.time import TimestampFormats, discord_timestamp
@@ -30,11 +30,10 @@
3030
class Slowmode(Cog):
3131
"""Commands for getting and setting slowmode delays of text channels."""
3232

33-
# Stores the expiration timestamp in POSIX format for active slowmodes, keyed by channel ID.
34-
slowmode_expiration_cache = RedisCache()
35-
36-
# Stores the original slowmode interval by channel ID, allowing its restoration after temporary slowmode expires.
37-
original_slowmode_cache = RedisCache()
33+
# RedisCache[discord.channel.id : f"{delay}, {expiry}"]
34+
# `delay` is the slowmode delay assigned to the text channel.
35+
# `expiry` is a naïve ISO 8601 string which describes when the slowmode should be removed.
36+
slowmode_cache = RedisCache()
3837

3938
def __init__(self, bot: Bot) -> None:
4039
self.bot = bot
@@ -53,8 +52,8 @@ async def get_slowmode(self, ctx: Context, channel: MessageHolder) -> None:
5352
channel = ctx.channel
5453

5554
humanized_delay = time.humanize_delta(seconds=channel.slowmode_delay)
56-
if await self.slowmode_expiration_cache.contains(channel.id):
57-
expiration_time = await self.slowmode_expiration_cache.get(channel.id)
55+
if await self.slowmode_cache.contains(channel.id):
56+
expiration_time = await self.slowmode_cache.get(channel.id).split(", ")[1]
5857
expiration_timestamp = discord_timestamp(expiration_time, TimestampFormats.RELATIVE)
5958
await ctx.send(
6059
f"The slowmode delay for {channel.mention} is {humanized_delay} and expires in {expiration_timestamp}."
@@ -68,12 +67,12 @@ async def set_slowmode(
6867
ctx: Context,
6968
channel: MessageHolder,
7069
delay: DurationDelta | Literal["0s", "0seconds"],
71-
duration: DurationDelta | None = None
70+
expiry: Duration | None = None
7271
) -> None:
7372
"""
7473
Set the slowmode delay for a text channel.
7574
76-
Supports temporary slowmodes with the `duration` argument that automatically
75+
Supports temporary slowmodes with the `expiry` argument that automatically
7776
revert to the original delay after expiration.
7877
"""
7978
# Use the channel this command was invoked in if one was not given
@@ -100,32 +99,31 @@ async def set_slowmode(
10099
)
101100
return
102101

103-
if duration is not None:
104-
slowmode_duration = time.relativedelta_to_timedelta(duration).total_seconds()
105-
humanized_duration = time.humanize_delta(duration)
106-
107-
expiration_time = datetime.now(tz=UTC) + timedelta(seconds=slowmode_duration)
108-
expiration_timestamp = discord_timestamp(expiration_time, TimestampFormats.RELATIVE)
102+
if expiry is not None:
103+
humanized_expiry = time.humanize_delta(expiry)
104+
expiration_timestamp = discord_timestamp(expiry, TimestampFormats.RELATIVE)
109105

110-
# Only update original_slowmode_cache if the last slowmode was not temporary.
111-
if not await self.slowmode_expiration_cache.contains(channel.id):
112-
await self.original_slowmode_cache.set(channel.id, channel.slowmode_delay)
113-
await self.slowmode_expiration_cache.set(channel.id, expiration_time.timestamp())
106+
# Only cache the original slowmode delay if there is not already an ongoing temporary slowmode.
107+
if not await self.slowmode_cache.contains(channel.id):
108+
await self.slowmode_cache.set(channel.id, f"{channel.slowmode_delay}, {expiry}")
109+
else:
110+
cached_delay = await self.slowmode_cache.get(channel.id)
111+
await self.slowmode_cache.set(channel.id, f"{cached_delay}, {expiry}")
112+
self.scheduler.cancel(channel.id)
114113

115-
self.scheduler.schedule_at(expiration_time, channel.id, self._revert_slowmode(channel.id))
114+
self.scheduler.schedule_at(expiry, channel.id, self._revert_slowmode(channel.id))
116115
log.info(
117116
f"{ctx.author} set the slowmode delay for #{channel} to"
118-
f"{humanized_delay} which expires in {humanized_duration}."
117+
f"{humanized_delay} which expires in {humanized_expiry}."
119118
)
120119
await channel.edit(slowmode_delay=slowmode_delay)
121120
await ctx.send(
122121
f"{Emojis.check_mark} The slowmode delay for {channel.mention}"
123122
f" is now {humanized_delay} and expires in {expiration_timestamp}."
124123
)
125124
else:
126-
if await self.slowmode_expiration_cache.contains(channel.id):
127-
await self.slowmode_expiration_cache.delete(channel.id)
128-
await self.original_slowmode_cache.delete(channel.id)
125+
if await self.slowmode_cache.contains(channel.id):
126+
await self.slowmode_cache.delete(channel.id)
129127
self.scheduler.cancel(channel.id)
130128

131129
log.info(f"{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.")
@@ -139,33 +137,33 @@ async def set_slowmode(
139137

140138
async def _reschedule(self) -> None:
141139
log.trace("Rescheduling the expiration of temporary slowmodes from cache.")
142-
for channel_id, expiration in await self.slowmode_expiration_cache.items():
143-
expiration_datetime = datetime.fromtimestamp(expiration, tz=UTC)
140+
for channel_id, cached_data in await self.slowmode_cache.items():
141+
expiration = cached_data.split(", ")[1]
142+
expiration_datetime = datetime.fromisoformat(expiration)
144143
channel = self.bot.get_channel(channel_id)
145144
log.info(f"Rescheduling slowmode expiration for #{channel} ({channel_id}).")
146145
self.scheduler.schedule_at(expiration_datetime, channel_id, self._revert_slowmode(channel_id))
147146

148147
async def _revert_slowmode(self, channel_id: int) -> None:
149-
original_slowmode = await self.original_slowmode_cache.get(channel_id)
148+
cached_data = await self.slowmode_cache.get(channel_id)
149+
original_slowmode = int(cached_data.split(", ")[0])
150150
slowmode_delay = time.humanize_delta(seconds=original_slowmode)
151151
channel = self.bot.get_channel(channel_id)
152152
log.info(f"Slowmode in #{channel} ({channel.id}) has expired and has reverted to {slowmode_delay}.")
153153
await channel.edit(slowmode_delay=original_slowmode)
154154
await channel.send(
155155
f"{Emojis.check_mark} A previously applied slowmode has expired and has been reverted to {slowmode_delay}."
156156
)
157-
await self.slowmode_expiration_cache.delete(channel.id)
158-
await self.original_slowmode_cache.delete(channel.id)
157+
await self.slowmode_cache.delete(channel.id)
159158

160159
@slowmode_group.command(name="reset", aliases=["r"])
161160
async def reset_slowmode(self, ctx: Context, channel: MessageHolder) -> None:
162161
"""Reset the slowmode delay for a text channel to 0 seconds."""
163162
await self.set_slowmode(ctx, channel, relativedelta(seconds=0))
164163
if channel is None:
165164
channel = ctx.channel
166-
if await self.slowmode_expiration_cache.contains(channel.id):
167-
await self.slowmode_expiration_cache.delete(channel.id)
168-
await self.original_slowmode_cache.delete(channel.id)
165+
if await self.slowmode_cache.contains(channel.id):
166+
await self.slowmode_cache.delete(channel.id)
169167
self.scheduler.cancel(channel.id)
170168

171169
async def cog_check(self, ctx: Context) -> bool:

tests/bot/exts/moderation/test_slowmode.py

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,11 @@ async def test_reset_slowmode_sets_delay_to_zero(self) -> None:
9898
)
9999

100100
@mock.patch("bot.exts.moderation.slowmode.datetime")
101-
async def test_set_slowmode_with_duration(self, mock_datetime) -> None:
102-
"""Set slowmode with a duration"""
103-
mock_datetime.now.return_value = datetime.datetime(2025, 6, 2, 12, 0, 0, tzinfo=datetime.UTC)
101+
async def test_set_slowmode_with_expiry(self, mock_datetime) -> None:
102+
"""Set slowmode with an expiry"""
103+
fixed_datetime = datetime.datetime(2025, 6, 2, 12, 0, 0, tzinfo=datetime.UTC)
104+
mock_datetime.now.return_value = fixed_datetime
105+
104106
test_cases = (
105107
("python-general", 6, 6000, f"{Emojis.check_mark} The slowmode delay for #python-general is now 6 seconds"
106108
" and expires in <t:1748871600:R>."),
@@ -109,11 +111,11 @@ async def test_set_slowmode_with_duration(self, mock_datetime) -> None:
109111
("changelog", 12, 7200, f"{Emojis.check_mark} The slowmode delay for #changelog is now 12 seconds and"
110112
" expires in <t:1748872800:R>.")
111113
)
112-
for channel_name, seconds, duration, result_msg in test_cases:
114+
for channel_name, seconds, expiry, result_msg in test_cases:
113115
with self.subTest(
114116
channel_mention=channel_name,
115117
seconds=seconds,
116-
duration=duration,
118+
expiry=expiry,
117119
result_msg=result_msg
118120
):
119121
text_channel = MockTextChannel(name=channel_name, slowmode_delay=0)
@@ -122,36 +124,39 @@ async def test_set_slowmode_with_duration(self, mock_datetime) -> None:
122124
self.ctx,
123125
text_channel,
124126
relativedelta(seconds=seconds),
125-
duration=relativedelta(seconds=duration)
127+
fixed_datetime + relativedelta(seconds=expiry)
126128
)
127129
text_channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds))
128130
self.ctx.send.assert_called_once_with(result_msg)
129131
self.ctx.reset_mock()
130132

131-
@mock.patch("bot.exts.moderation.slowmode.datetime", wraps=datetime.datetime)
132-
async def test_callback_scheduled(self, mock_datetime, ):
133+
async def test_callback_scheduled(self):
133134
"""Schedule slowmode to be reverted"""
134-
mock_now = datetime.datetime(2025, 6, 2, 12, 0, 0, tzinfo=datetime.UTC)
135-
mock_datetime.now.return_value = mock_now
136135
self.cog.scheduler=mock.MagicMock(wraps=self.cog.scheduler)
137136

138137
text_channel = MockTextChannel(name="python-general", slowmode_delay=2, id=123)
138+
expiry = datetime.datetime.now(tz=datetime.UTC) + relativedelta(seconds=10)
139139
await self.cog.set_slowmode(
140140
self.cog,
141141
self.ctx,
142142
text_channel,
143143
relativedelta(seconds=4),
144-
relativedelta(seconds=10))
144+
expiry
145+
)
145146

146-
args = (mock_now+relativedelta(seconds=10), text_channel.id, mock.ANY)
147+
args = (expiry, text_channel.id, mock.ANY)
147148
self.cog.scheduler.schedule_at.assert_called_once_with(*args)
148149

149150
async def test_revert_slowmode_callback(self) -> None:
150151
"""Check that the slowmode is reverted"""
151152
text_channel = MockTextChannel(name="python-general", slowmode_delay=2, id=123)
152153
self.bot.get_channel = mock.MagicMock(return_value=text_channel)
153154
await self.cog.set_slowmode(
154-
self.cog, self.ctx, text_channel, relativedelta(seconds=4), relativedelta(seconds=10)
155+
self.cog,
156+
self.ctx,
157+
text_channel,
158+
relativedelta(seconds=4),
159+
datetime.datetime.now(tz=datetime.UTC) + relativedelta(seconds=10)
155160
)
156161
await self.cog._revert_slowmode(text_channel.id)
157162
text_channel.edit.assert_awaited_with(slowmode_delay=2)
@@ -177,23 +182,19 @@ async def test_reschedule_upon_reload(self) -> None:
177182

178183
self.cog._reschedule.assert_called()
179184

180-
@mock.patch("bot.exts.moderation.slowmode.datetime", wraps=datetime.datetime)
181-
async def test_reschedules_slowmodes(self, mock_datetime) -> None:
185+
async def test_reschedules_slowmodes(self) -> None:
182186
"""Slowmodes are loaded from cache at cog reload and scheduled to be reverted."""
183-
mock_datetime.now.return_value = datetime.datetime(2025, 6, 2, 12, 0, 0, tzinfo=datetime.UTC)
184-
mock_now = datetime.datetime(2025, 6, 2, 12, 0, 0, tzinfo=datetime.UTC)
185187

188+
now = datetime.datetime.now(tz=datetime.UTC)
186189
channels = {}
187190
slowmodes = (
188-
(123, (mock_now - datetime.timedelta(10)).timestamp(), 2), # expiration in the past
189-
(456, (mock_now + datetime.timedelta(20)).timestamp(), 4), # expiration in the future
191+
(123, (now - datetime.timedelta(minutes=10)), 2), # expiration in the past
192+
(456, (now + datetime.timedelta(minutes=20)), 4), # expiration in the future
190193
)
191-
192194
for channel_id, expiration_datetime, delay in slowmodes:
193195
channel = MockTextChannel(slowmode_delay=delay, id=channel_id)
194196
channels[channel_id] = channel
195-
await self.cog.slowmode_expiration_cache.set(channel_id, expiration_datetime)
196-
await self.cog.original_slowmode_cache.set(channel_id, delay)
197+
await self.cog.slowmode_cache.set(channel_id, f"{delay}, {expiration_datetime}")
197198

198199
self.bot.get_channel = mock.MagicMock(side_effect=lambda channel_id: channels.get(channel_id))
199200
await self.cog.cog_unload()

0 commit comments

Comments
 (0)