Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions am_bot/cogs/quarantine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions am_bot/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@
INVITE_HELP_TEXT_CHANNEL_ID = 372253844953890817
QUARANTINE_HONEYPOT_CHANNEL_ID = 1453143051332616223
QUARANTINE_LOG_CHANNEL_ID = 1458280538518323303
QUARANTINE_CHANNEL_ID = 1103968893548101673
221 changes: 221 additions & 0 deletions tests/test_quarantine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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