Repository: gpauloski/3pseatBot

Issue:
Rules cooldown begins at start of last event
### Describe the problem.

The cooldown should start from the end of the last event.

### (Optional) How did you install and run 3pseatBot?

_No response_

### (Optional) What version of 3pseatBot are you using?

_No response_

Discussion:

Patch:
diff --git a/setup.cfg b/setup.cfg
index 1efa137..370272a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = threepseat
-version = 2.1.4
+version = 2.1.5
 description = 3pseatBot: a Discord Bot
 long_description = file: README.md
 long_description_content_type = text/markdown
diff --git a/threepseat/__init__.py b/threepseat/__init__.py
index e096b60..3f3e5bc 100644
--- a/threepseat/__init__.py
+++ b/threepseat/__init__.py
@@ -1,3 +1,3 @@
 from __future__ import annotations
 
-__version__ = '2.1.4'
+__version__ = '2.1.5'
diff --git a/threepseat/ext/extension.py b/threepseat/ext/extension.py
index 13c92a2..bf91365 100644
--- a/threepseat/ext/extension.py
+++ b/threepseat/ext/extension.py
@@ -10,7 +10,7 @@ class CommandGroupExtension(app_commands.Group):
     async def post_init(self, bot: discord.ext.commands.Bot) -> None:
         """Complete post initialization using a bot.
 
-        This method should typically be called once after the extension
+        This method should only be called once after the extension
         and the bot/client have been initialized.
 
         Subclasses can use this method to launch background tasks that need
diff --git a/threepseat/ext/rules/commands.py b/threepseat/ext/rules/commands.py
index debda36..5d013a9 100644
--- a/threepseat/ext/rules/commands.py
+++ b/threepseat/ext/rules/commands.py
@@ -55,6 +55,26 @@ class RulesCommands(CommandGroupExtension):
     async def post_init(self, bot: discord.ext.commands.Bot) -> None:
         """Add the message listener to the bot and spawn the event starter."""
         bot.add_listener(self.on_message, 'on_message')
+
+        # Scan database for guilds that should be in an event
+        for config in self.database.get_configs():
+            time_since_last_event = time.time() - config.last_event
+            event_time_remain = (
+                config.last_event + (config.event_duration * 60)
+            ) - time.time()
+            # Add 60 second buffer to not restart event if bot restarts
+            # within a minute of the event ending. Probably not needed but if
+            # the bot gets into a thrashing state of restarting it may be good
+            in_event = (
+                config.event_duration * 60
+            ) > time_since_last_event and event_time_remain > 60
+            if config.enabled and in_event and config.last_event > 0:
+                guild = bot.get_guild(config.guild_id)
+                if guild is None:
+                    continue
+                duration = config.event_duration - (time_since_last_event / 60)
+                await self.start_event(guild, duration, resume=True)
+
         self._event_starter_task = self.event_starter(bot)
         self._event_starter_task.start()
 
@@ -166,6 +186,7 @@ class RulesCommands(CommandGroupExtension):
         self,
         guild: discord.Guild,
         duration: float | None = None,
+        resume: bool = False,
     ) -> None:
         """Start a rules enforcement event for the guild."""
         config = self.database.get_config(guild.id)
@@ -185,14 +206,16 @@ class RulesCommands(CommandGroupExtension):
         duration_readable = readable_timedelta(minutes=duration)
         prefixes = readable_sequence(split_strings(config.prefixes), 'or')
 
-        await channel.send(
-            f'3pseat mode is starting for {duration_readable}! '
-            f'All messages with text must start with {prefixes}.',
-        )
+        if not resume:
+            await channel.send(
+                f'3pseat mode is starting for {duration_readable}! '
+                f'All messages with text must start with {prefixes}.',
+            )
 
+        action = 'resuming' if resume else 'starting'
         logger.info(
-            f'starting rules event for {guild.name} ({guild.id}) for '
-            f'{duration}',
+            f'{action} rules event for {guild.name} ({guild.id}) for '
+            f'{duration_readable}',
         )
         self.event_handlers[guild.id] = asyncio.create_task(
             self.stop_event(
@@ -202,9 +225,10 @@ class RulesCommands(CommandGroupExtension):
             ),
         )
 
-        # Starting event success so update last_event time
-        config = config._replace(last_event=time.time())
-        self.database.update_config(config)
+        if not resume:
+            # Starting new event success so update last_event time
+            config = config._replace(last_event=time.time())
+            self.database.update_config(config)
 
     async def stop_event(
         self,
@@ -246,9 +270,12 @@ class RulesCommands(CommandGroupExtension):
                 if guild is None:
                     continue
                 # Skip if still in cooldown period
-                last_event_delta = time.time() - config.last_event
+                last_event_delta = time.time() - (
+                    config.last_event + (config.event_duration * 60)
+                )
                 if (
                     # Check > 0 in case current time is before last_event
+                    # + event_duration (e.g., if event_duration was shortened)
                     0 < last_event_delta < config.event_cooldown * 60
                     and config.last_event > 0
                 ):
@@ -347,6 +374,7 @@ class RulesCommands(CommandGroupExtension):
                 '%-d %B %Y at %-I:%M:%S %p',
             )
         )
+        event_cooldown = readable_timedelta(minutes=config.event_cooldown)
         event_duration = readable_timedelta(minutes=config.event_duration)
         timeout_duration = readable_timedelta(minutes=config.timeout_duration)
         prefixes = readable_sequence(split_strings(config.prefixes), 'or')
@@ -355,6 +383,7 @@ class RulesCommands(CommandGroupExtension):
             f'Legacy 3pseat mode is **{enabled}**.\n'
             f'- *Expected events per day*: {config.event_expectancy}\n'
             f'- *Event duration*: {event_duration}\n'
+            f'- *Event cooldown*: {event_cooldown}\n'
             f'- *Max offenses before timeout*: {config.max_offenses}\n'
             f'- *Timeout duration*: {timeout_duration}\n'
             f'- *Prefix pattern*: {prefixes}\n'


Test patch:
diff --git a/tests/ext/rules/commands_test.py b/tests/ext/rules/commands_test.py
index 1d63cf7..e3696db 100644
--- a/tests/ext/rules/commands_test.py
+++ b/tests/ext/rules/commands_test.py
@@ -172,6 +172,25 @@ async def test_start_event(commands) -> None:
         commands.event_handlers[guild.id].cancel()
 
 
+@pytest.mark.asyncio
+async def test_resume_event(commands) -> None:
+    guild = MockGuild('guild', GUILD_CONFIG.guild_id)
+    channel = MockChannel('channel')
+
+    with (
+        mock.patch(
+            'threepseat.ext.rules.commands.primary_channel',
+            return_value=channel,
+        ),
+        mock.patch.object(channel, 'send', mock.AsyncMock()) as mock_send,
+    ):
+        await commands.start_event(guild, resume=True)
+        assert guild.id in commands.event_handlers
+        commands.event_handlers[guild.id].cancel()
+        # Resume should not send event starting message
+        assert mock_send.await_count == 0
+
+
 @pytest.mark.asyncio
 async def test_stop_event(commands) -> None:
     guild = MockGuild('guild', GUILD_CONFIG.guild_id)
@@ -219,6 +238,7 @@ async def test_event_starter(commands) -> None:
         config_orig = commands.database.get_config(GUILD_CONFIG.guild_id)
         config_new = config_orig._replace(
             event_cooldown=5,
+            event_duration=0,
             last_event=time.time(),
         )
         commands.database.update_config(config_new)
@@ -524,3 +544,48 @@ async def test_on_error(commands, caplog) -> None:
     caplog.set_level(logging.ERROR)
     await commands.on_error(interaction, app_commands.AppCommandError('test1'))
     assert any(['test1' in record.message for record in caplog.records])
+
+
+@pytest.mark.asyncio
+async def test_resume_events(commands) -> None:
+    config = GuildConfig(
+        guild_id=100,
+        enabled=1,
+        event_expectancy=0.5,
+        event_duration=60,
+        event_cooldown=5.0,
+        last_event=time.time(),
+        max_offenses=3,
+        timeout_duration=300,
+        prefixes='3pseat, 3pfeet',
+    )
+    guild = MockGuild('guild', config.guild_id)
+    client = MockClient(MockUser('bot', 42))
+    commands.database.update_config(config)
+
+    with (
+        mock.patch.object(client, 'get_guild', return_value=guild),
+        mock.patch.object(client, 'add_listener'),
+        mock.patch.object(
+            commands,
+            'start_event',
+            mock.AsyncMock(),
+        ) as mock_start,
+    ):
+        await commands.post_init(client)
+        assert mock_start.await_count == 1
+    commands._event_starter_task.cancel()
+
+    # Retest with guild not found
+    with (
+        mock.patch.object(client, 'get_guild', return_value=None),
+        mock.patch.object(client, 'add_listener'),
+        mock.patch.object(
+            commands,
+            'start_event',
+            mock.AsyncMock(),
+        ) as mock_start,
+    ):
+        await commands.post_init(client)
+        assert mock_start.await_count == 0
+    commands._event_starter_task.cancel()