From 85be801005dedeead26dab345760d7e847dba678 Mon Sep 17 00:00:00 2001 From: jslay88 Date: Tue, 6 Jan 2026 21:32:49 -0700 Subject: [PATCH] Add forum post creation for quarantined members - Add QUARANTINE_CHANNEL_ID constant for forum channel - Implement _setup_quarantine_post method to create forum threads - Integrate forum post creation into quarantine flow - Add comprehensive tests for forum post functionality including error cases --- am_bot/cogs/quarantine.py | 56 ++++++++++ am_bot/constants.py | 1 + tests/test_quarantine.py | 221 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 278 insertions(+) diff --git a/am_bot/cogs/quarantine.py b/am_bot/cogs/quarantine.py index d33a5d7..e3cba7e 100644 --- a/am_bot/cogs/quarantine.py +++ b/am_bot/cogs/quarantine.py @@ -11,6 +11,7 @@ from discord.ext import commands from am_bot.constants import ( + QUARANTINE_CHANNEL_ID, QUARANTINE_HONEYPOT_CHANNEL_ID, QUARANTINE_LOG_CHANNEL_ID, QUARANTINE_ROLE_ID, @@ -302,6 +303,53 @@ async def _log_quarantine( except Exception as e: logger.error(f"Failed to send quarantine log: {e}") + async def _setup_quarantine_post( + self, member: discord.Member, guild: discord.Guild, reason: str + ) -> discord.Thread | None: + """Setup the quarantine post for the member in the forum channel.""" + try: + quarantine_channel = guild.get_channel(QUARANTINE_CHANNEL_ID) + if quarantine_channel is None: + logger.error( + f"Quarantine channel {QUARANTINE_CHANNEL_ID} not found" + ) + return None + + # Verify it's a forum channel + if not isinstance(quarantine_channel, discord.ForumChannel): + logger.error( + f"Quarantine channel {QUARANTINE_CHANNEL_ID} " + "is not a forum channel" + ) + return None + + thread = await quarantine_channel.create_thread( + name=f"Quarantine - {member.name}", + content=( + f"Quarantined for {reason}. <@{member.id}>, " + f"please provide a reason to <@&{STAFF_ROLE_ID}> " + "to release you from quarantine." + ), + ) + logger.info(f"Created quarantine thread {thread.id} for {member}") + return thread + except discord.errors.Forbidden: + logger.error( + f"Failed to create quarantine thread for {member}: " + "insufficient permissions" + ) + except discord.errors.HTTPException as e: + logger.error( + f"HTTP error creating quarantine thread for " f"{member}: {e}" + ) + except Exception as e: + logger.error( + f"Unexpected error creating quarantine thread for " + f"{member}: {e}", + exc_info=True, + ) + return None + async def _handle_quarantine( self, message: discord.Message, reason: str ) -> None: @@ -326,6 +374,9 @@ async def _handle_quarantine( deleted_count = await self._purge_member_messages(member, guild) + # Create quarantine forum post + await self._setup_quarantine_post(member, guild, reason) + # Clear their message history from memory if member.id in self.message_history: del self.message_history[member.id] @@ -429,6 +480,11 @@ async def quarantine_command( member, interaction.guild ) + # Create quarantine forum post + await self._setup_quarantine_post( + member, interaction.guild, full_reason + ) + # Clear their message history from memory if member.id in self.message_history: del self.message_history[member.id] diff --git a/am_bot/constants.py b/am_bot/constants.py index bbbfd09..32ae770 100644 --- a/am_bot/constants.py +++ b/am_bot/constants.py @@ -18,3 +18,4 @@ INVITE_HELP_TEXT_CHANNEL_ID = 372253844953890817 QUARANTINE_HONEYPOT_CHANNEL_ID = 1453143051332616223 QUARANTINE_LOG_CHANNEL_ID = 1458280538518323303 +QUARANTINE_CHANNEL_ID = 1103968893548101673 diff --git a/tests/test_quarantine.py b/tests/test_quarantine.py index 5200dd0..a55ecdf 100644 --- a/tests/test_quarantine.py +++ b/tests/test_quarantine.py @@ -905,3 +905,224 @@ async def test_quarantine_command_purges_messages( # Check followup reports correct message count call_args = mock_interaction.followup.send.call_args assert "5 messages" in call_args[0][0] + + @pytest.mark.asyncio + async def test_setup_quarantine_post_success(self, cog): + """Test successful quarantine post creation.""" + from unittest.mock import AsyncMock, create_autospec + + with ( + patch("am_bot.cogs.quarantine.QUARANTINE_CHANNEL_ID", 12345), + patch("am_bot.cogs.quarantine.STAFF_ROLE_ID", 98765), + ): + member = make_mock_member(user_id=11111, name="TestUser") + guild = make_mock_guild() + + # Create a mock ForumChannel using create_autospec + # to pass isinstance + forum_channel = create_autospec( + discord.ForumChannel, spec_set=True, instance=True + ) + forum_channel.id = 12345 + forum_channel.name = "quarantine-forum" + forum_channel.create_thread = AsyncMock() + + # Create a mock thread to return + mock_thread = MagicMock() + mock_thread.id = 99999 + forum_channel.create_thread.return_value = mock_thread + + guild.get_channel.return_value = forum_channel + + result = await cog._setup_quarantine_post( + member, guild, "Test reason" + ) + + assert result == mock_thread + forum_channel.create_thread.assert_called_once() + call_kwargs = forum_channel.create_thread.call_args[1] + assert call_kwargs["name"] == "Quarantine - TestUser" + assert "Quarantined for Test reason" in call_kwargs["content"] + assert f"<@{member.id}>" in call_kwargs["content"] + assert "<@&98765>" in call_kwargs["content"] + + @pytest.mark.asyncio + async def test_setup_quarantine_post_channel_not_found(self, cog): + """Test quarantine post when channel doesn't exist.""" + with ( + patch("am_bot.cogs.quarantine.QUARANTINE_CHANNEL_ID", 12345), + patch("am_bot.cogs.quarantine.logger.error") as mock_log_error, + ): + member = make_mock_member() + guild = make_mock_guild() + guild.get_channel.return_value = None + + result = await cog._setup_quarantine_post( + member, guild, "Test reason" + ) + + assert result is None + mock_log_error.assert_called_once() + assert "not found" in str(mock_log_error.call_args) + + @pytest.mark.asyncio + async def test_setup_quarantine_post_not_forum_channel(self, cog): + """Test quarantine post when channel is not a ForumChannel.""" + with ( + patch("am_bot.cogs.quarantine.QUARANTINE_CHANNEL_ID", 12345), + patch("am_bot.cogs.quarantine.logger.error") as mock_log_error, + ): + member = make_mock_member() + guild = make_mock_guild() + + # Create a regular text channel (not a forum channel) + # Using spec=discord.TextChannel ensures isinstance returns False + text_channel = make_mock_channel(channel_id=12345) + guild.get_channel.return_value = text_channel + + result = await cog._setup_quarantine_post( + member, guild, "Test reason" + ) + + assert result is None + mock_log_error.assert_called_once() + assert "is not a forum channel" in str(mock_log_error.call_args) + + @pytest.mark.asyncio + async def test_setup_quarantine_post_forbidden(self, cog): + """Test quarantine post when bot lacks permissions.""" + from unittest.mock import AsyncMock, create_autospec + + with ( + patch("am_bot.cogs.quarantine.QUARANTINE_CHANNEL_ID", 12345), + patch("am_bot.cogs.quarantine.STAFF_ROLE_ID", 98765), + patch("am_bot.cogs.quarantine.logger.error") as mock_log_error, + ): + member = make_mock_member(name="TestUser") + guild = make_mock_guild() + + # Create a mock ForumChannel using create_autospec + forum_channel = create_autospec( + discord.ForumChannel, spec_set=True, instance=True + ) + forum_channel.id = 12345 + forum_channel.create_thread = AsyncMock() + forum_channel.create_thread.side_effect = discord.errors.Forbidden( + MagicMock(), "Missing permissions" + ) + + guild.get_channel.return_value = forum_channel + + result = await cog._setup_quarantine_post( + member, guild, "Test reason" + ) + + assert result is None + mock_log_error.assert_called_once() + assert "insufficient permissions" in str(mock_log_error.call_args) + + @pytest.mark.asyncio + async def test_setup_quarantine_post_http_exception(self, cog): + """Test quarantine post when HTTP exception occurs.""" + from unittest.mock import AsyncMock, create_autospec + + with ( + patch("am_bot.cogs.quarantine.QUARANTINE_CHANNEL_ID", 12345), + patch("am_bot.cogs.quarantine.STAFF_ROLE_ID", 98765), + patch("am_bot.cogs.quarantine.logger.error") as mock_log_error, + ): + member = make_mock_member(name="TestUser") + guild = make_mock_guild() + + # Create a mock ForumChannel using create_autospec + forum_channel = create_autospec( + discord.ForumChannel, spec_set=True, instance=True + ) + forum_channel.id = 12345 + forum_channel.create_thread = AsyncMock() + forum_channel.create_thread.side_effect = ( + discord.errors.HTTPException( + MagicMock(status=500), "Internal Server Error" + ) + ) + + guild.get_channel.return_value = forum_channel + + result = await cog._setup_quarantine_post( + member, guild, "Test reason" + ) + + assert result is None + mock_log_error.assert_called_once() + assert "HTTP error" in str(mock_log_error.call_args) + + @pytest.mark.asyncio + async def test_setup_quarantine_post_general_exception(self, cog): + """Test quarantine post when unexpected exception occurs.""" + from unittest.mock import AsyncMock, create_autospec + + with ( + patch("am_bot.cogs.quarantine.QUARANTINE_CHANNEL_ID", 12345), + patch("am_bot.cogs.quarantine.STAFF_ROLE_ID", 98765), + patch("am_bot.cogs.quarantine.logger.error") as mock_log_error, + ): + member = make_mock_member(name="TestUser") + guild = make_mock_guild() + + # Create a mock ForumChannel using create_autospec + forum_channel = create_autospec( + discord.ForumChannel, spec_set=True, instance=True + ) + forum_channel.id = 12345 + forum_channel.create_thread = AsyncMock() + forum_channel.create_thread.side_effect = ValueError( + "Unexpected error" + ) + + guild.get_channel.return_value = forum_channel + + result = await cog._setup_quarantine_post( + member, guild, "Test reason" + ) + + assert result is None + mock_log_error.assert_called_once() + assert "Unexpected error" in str(mock_log_error.call_args) + + @pytest.mark.asyncio + async def test_setup_quarantine_post_verifies_content(self, cog): + """Test that quarantine post content includes all required elements.""" + from unittest.mock import AsyncMock, create_autospec + + with ( + patch("am_bot.cogs.quarantine.QUARANTINE_CHANNEL_ID", 12345), + patch("am_bot.cogs.quarantine.STAFF_ROLE_ID", 98765), + ): + member = make_mock_member(user_id=11111, name="BadUser") + guild = make_mock_guild() + reason = "Cross-channel spam detected" + + # Create a mock ForumChannel using create_autospec + forum_channel = create_autospec( + discord.ForumChannel, spec_set=True, instance=True + ) + forum_channel.id = 12345 + forum_channel.create_thread = AsyncMock() + + mock_thread = MagicMock() + mock_thread.id = 99999 + forum_channel.create_thread.return_value = mock_thread + + guild.get_channel.return_value = forum_channel + + await cog._setup_quarantine_post(member, guild, reason) + + # Verify the content includes all required parts + call_kwargs = forum_channel.create_thread.call_args[1] + content = call_kwargs["content"] + + assert "Quarantined for Cross-channel spam detected" in content + assert "<@11111>" in content # Member mention + assert "<@&98765>" in content # Staff role mention + assert "please provide a reason" in content + assert "to release you from quarantine" in content