diff --git a/bot/constants.py b/bot/constants.py index bf53c4b438..77332f444e 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -47,6 +47,7 @@ class EnvConfig( class _Channels(EnvConfig, env_prefix="channels_"): + general: int = 123123123123 algos_and_data_structs: int = 650401909852864553 bot_commands: int = 267659945086812160 community_meta: int = 267659945086812160 @@ -312,6 +313,7 @@ class _Reddit(EnvConfig, env_prefix="reddit_"): # Whitelisted channels WHITELISTED_CHANNELS = ( + Channels.general, Channels.bot_commands, Channels.sir_lancebot_playground, Channels.off_topic_0, diff --git a/bot/exts/holidays/valentines/be_my_valentine.py b/bot/exts/holidays/valentines/be_my_valentine.py index 8167979478..3b071e20fb 100644 --- a/bot/exts/holidays/valentines/be_my_valentine.py +++ b/bot/exts/holidays/valentines/be_my_valentine.py @@ -41,21 +41,39 @@ async def lovefest_role(self, ctx: commands.Context) -> None: """ raise MovedCommandError(MOVED_COMMAND) - @commands.cooldown(1, 1800, commands.BucketType.user) + # @commands.cooldown(1, 1800, commands.BucketType.user) @commands.group(name="bemyvalentine", invoke_without_command=True) async def send_valentine( - self, ctx: commands.Context, user: discord.Member, *, valentine_type: str | None = None + self, ctx: commands.Context, user: discord.Member, privacy_type: str | None = None, anon: str | None = None, valentine_type: str | None = None ) -> None: """ Send a valentine to a specified user with the lovefest role. - syntax: .bemyvalentine [user] [p/poem/c/compliment/or you can type your own valentine message] + syntax: .bemyvalentine [user] [public/private] [anon/signed] [p/poem/c/compliment/or you can type your own valentine message] (optional) - example: .bemyvalentine Iceman#6508 p (sends a poem to Iceman) - example: .bemyvalentine Iceman Hey I love you, wanna hang around ? (sends the custom message to Iceman) + example: .bemyvalentine Iceman#6508 private anon p (sends an anonymous private poem through DM to Iceman) + example: .bemyvalentine Iceman public signed Hey I love you, wanna hang around ? (sends the custom message publicly and signed to Iceman in the current channel) NOTE : AVOID TAGGING THE USER MOST OF THE TIMES.JUST TRIM THE '@' when using this command. """ + + + if anon.lower() == "anon": + # Delete the message containing the command right after it was sent to enforce anonymity. + try: + await ctx.message.delete() + except discord.Forbidden: + await ctx.send("I can't delete your message! Please check my permissions.") + + + if anon not in ["anon", "signed"]: + # Anonymity type wrongfully specified. + raise commands.UserInputError( + f"Specify if you want the message to be anonymous or not!" + ) + + + if ctx.guild is None: # This command should only be used in the server raise commands.UserInputError("You are supposed to use this command in the server.") @@ -64,6 +82,13 @@ async def send_valentine( raise commands.UserInputError( f"You cannot send a valentine to {user} as they do not have the lovefest role!" ) + + if privacy_type not in ["public", "private"]: + # Privacy type wrongfully specified. + raise commands.UserInputError( + f"Specify if you want the message to be sent privately or publicly!" + ) + if user == ctx.author: # Well a user can't valentine himself/herself. @@ -73,12 +98,33 @@ async def send_valentine( channel = self.bot.get_channel(Channels.sir_lancebot_playground) valentine, title = self.valentine_check(valentine_type) - embed = discord.Embed( - title=f"{emoji_1} {title} {user.display_name} {emoji_2}", - description=f"{valentine} \n **{emoji_2}From {ctx.author}{emoji_1}**", - color=Colours.pink - ) - await channel.send(user.mention, embed=embed) + if anon.lower() == "anon": + embed = discord.Embed( + title=f"{emoji_1} {title} {user.display_name} {emoji_2}", + description=f"{valentine} \n **{emoji_2}From an anonymous admirer{emoji_1}**", + color=Colours.pink + ) + + else: + embed = discord.Embed( + title=f"{emoji_1} {title} {user.display_name} {emoji_2}", + description=f"{valentine} \n **{emoji_2}From {ctx.author}{emoji_1}**", + color=Colours.pink + ) + + if privacy_type.lower() == "private": + # Send the message privately if "private" was speicified + try: + await user.send(embed=embed) + await ctx.author.send(f"Your valentine has been **privately** delivered to {user.display_name}!") + except discord.Forbidden: + await ctx.send(f"I couldn't send a private message to {user.display_name}. They may have DMs disabled.") + else: + # Send the message publicly if "public" was speicified + try: + await ctx.send(user.mention, embed=embed) + except discord.Forbidden: + await ctx.send(f"I couldn't send a private message to {user.display_name}. They may have DMs disabled.") @commands.cooldown(1, 1800, commands.BucketType.user) @send_valentine.command(name="secret") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/exts/__init__.py b/tests/exts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/exts/holidays/__init__.py b/tests/exts/holidays/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/exts/holidays/valentines/__init__.py b/tests/exts/holidays/valentines/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/exts/holidays/valentines/test_bemyvalentine.py b/tests/exts/holidays/valentines/test_bemyvalentine.py new file mode 100644 index 0000000000..9a5d00804a --- /dev/null +++ b/tests/exts/holidays/valentines/test_bemyvalentine.py @@ -0,0 +1,323 @@ + +import pytest +import discord +# Import AsyncMock and MagicMock for creating fake asynchronous and regular objects for testing +from unittest.mock import AsyncMock, MagicMock +from discord.ext import commands +from bot.constants import Roles + +# Import the BeMyValentine cog (the part of the bot that handles sending valentines) to be tested +from bot.exts.holidays.valentines.be_my_valentine import BeMyValentine + + + +@pytest.mark.asyncio +async def test_send_valentine_user_without_lovefest_role(): + """Test that sending a valentine to a user without the lovefest role raises UserInputError.""" + # Create a fake bot instance using MagicMock + bot = MagicMock() + # Instantiate the BeMyValentine cog with the fake bot + cog = BeMyValentine(bot) + + # Create a fake command context (ctx) using MagicMock + ctx = MagicMock() + # Set the delete method on the message to an AsyncMock since deletion is asynchronous + ctx.message.delete = AsyncMock() + # Create a fake command invoker (author) for the context + ctx.author = MagicMock() + # Create a fake user object representing the intended recipient + user = MagicMock() + # Simulate that the user does not have any roles by assigning an empty list + user.roles = [] # User does not have the lovefest role + + # Assert that a UserInputError is raised when the command is called with a user missing the lovefest role + with pytest.raises(commands.UserInputError, match="You cannot send a valentine to .* as they do not have the lovefest role!"): + # Call the command callback with parameters for a public, signed valentine and expect an error due to missing role + await cog.send_valentine.callback(cog, ctx, user, privacy_type="public", anon="signed", valentine_type="p") + + # Verify that the command did not attempt to delete the message since the process failed early + ctx.message.delete.assert_not_called() # No message should be deleted in this case + + + +@pytest.mark.asyncio +async def test_send_valentine_self_valentine(): + """Test that a user cannot send a valentine to themselves.""" + # Create a fake bot instance + bot = MagicMock() + # Instantiate the BeMyValentine cog with the fake bot + cog = BeMyValentine(bot) + + # Create a fake command context + ctx = MagicMock() + # Create a fake author for the command context + ctx.author = MagicMock() + # Set the delete method on the message to an AsyncMock + ctx.message.delete = AsyncMock() + # Use the same object for the user to simulate a self-send scenario + user = ctx.author # Self-send + + # Simulate that the user has the lovefest role by adding a role with the correct ID + user.roles = [MagicMock(id=Roles.lovefest)] + + # Assert that sending a valentine to oneself raises a UserInputError with the expected message + with pytest.raises(commands.UserInputError, match="Come on, you can't send a valentine to yourself"): + # Call the command callback; self-send should trigger the error before any message is sent + await cog.send_valentine.callback(cog, ctx, user, privacy_type="public", anon="signed", valentine_type="p") + + # Ensure that no attempt is made to delete the command message since the error occurs before deletion + ctx.message.delete.assert_not_called() # No need to delete the message in this case + + + +@pytest.mark.asyncio +async def test_send_valentine_invalid_privacy_type(): + """Test that an invalid privacy type raises UserInputError.""" + # Create a fake bot instance + bot = MagicMock() + # Instantiate the BeMyValentine cog with the fake bot + cog = BeMyValentine(bot) + + # Create a fake command context + ctx = MagicMock() + # Create a fake author for the command context + ctx.author = MagicMock() + # Create a fake recipient user + user = MagicMock() + # Simulate that the user has the lovefest role + user.roles = [MagicMock(id=Roles.lovefest)] # User has lovefest role + + # Assert that using an invalid privacy type (here, "invalid") raises a UserInputError with the correct message + with pytest.raises(commands.UserInputError, match="Specify if you want the message to be sent privately or publicly!"): + # Call the command callback with an invalid privacy type to trigger the error + await cog.send_valentine.callback(cog, ctx, user, privacy_type="invalid", anon="signed", valentine_type="p") + + + +@pytest.mark.asyncio +async def test_send_valentine_invalid_anon_type(): + """Test that an invalid anonymity type raises UserInputError.""" + # Create a fake bot instance + bot = MagicMock() + # Instantiate the BeMyValentine cog with the fake bot + cog = BeMyValentine(bot) + + # Create a fake command context + ctx = MagicMock() + # Create a fake author for the command context + ctx.author = MagicMock() + # Create a fake recipient user + user = MagicMock() + # Simulate that the user has the lovefest role + user.roles = [MagicMock(id=Roles.lovefest)] # User has lovefest role + + # Assert that an invalid anonymity type (here, "invalid") causes a UserInputError with the expected message + with pytest.raises(commands.UserInputError, match="Specify if you want the message to be anonymous or not!"): + # Call the command callback with an invalid anonymity type to trigger the error + await cog.send_valentine.callback(cog, ctx, user, privacy_type="public", anon="invalid", valentine_type="p") + + +@pytest.mark.asyncio +async def test_send_valentine_public_signed(): + """Test that a public, signed valentine is sent successfully.""" + # Create a fake bot instance + bot = MagicMock() + # Instantiate the BeMyValentine cog with the fake bot + cog = BeMyValentine(bot) + + # Create a fake command context + ctx = MagicMock() + # Create a fake author for the command context + ctx.author = MagicMock() + # Set the delete method on the command message as an AsyncMock (should not be used in this case) + ctx.message.delete = AsyncMock() + # Set the send method on the context as an AsyncMock since the command sends a message publicly + ctx.send = AsyncMock() + # Create a fake recipient user object + user = MagicMock() + # Simulate that the recipient user has the lovefest role + user.roles = [MagicMock(id=Roles.lovefest)] # User has lovefest role + # Define a display name for the recipient (used in the message embed) + user.display_name = "Recipient" + + # Stub the random_emoji method to return fixed emojis for predictable output during testing + cog.random_emoji = MagicMock(return_value=("💖", "💕")) + # Stub the valentine_check method to return a sample valentine message and title + cog.valentine_check = MagicMock(return_value=("A lovely poem", "A poem dedicated to")) + + # Call the send_valentine callback with valid parameters for a public, signed valentine + await cog.send_valentine.callback(cog, ctx, user, privacy_type="public", anon="signed", valentine_type="p") + + # Assert that the context's send method was called to send the valentine publicly + ctx.send.assert_awaited() # Message should be sent publicly + # Assert that the command message was not deleted since deletion is only required for anonymous messages + ctx.message.delete.assert_not_called() # No need to delete the message in this case + + + +@pytest.mark.asyncio +async def test_send_valentine_private_anon(): + """Test that a private, anonymous valentine is sent successfully.""" + # Create a fake bot instance + bot = MagicMock() + # Instantiate the BeMyValentine cog with the fake bot + cog = BeMyValentine(bot) + + # Create a fake command context + ctx = MagicMock() + # Create a fake author for the command context + ctx.author = MagicMock() + # Set the delete method on the command message as an AsyncMock for potential deletion + ctx.message.delete = AsyncMock() + # Set the send method on the author to simulate sending a DM confirmation back to the command invoker + ctx.author.send = AsyncMock() + # Create a fake recipient user object + user = MagicMock() + # Simulate that the recipient has the lovefest role + user.roles = [MagicMock(id=Roles.lovefest)] # User has lovefest role + # Define a display name for the recipient used in messages + user.display_name = "Recipient" + # Set the recipient's send method as an AsyncMock to simulate sending the DM valentine + user.send = AsyncMock() + + # Stub the random_emoji method to return fixed emojis for predictable testing output + cog.random_emoji = MagicMock(return_value=("💖", "💕")) + # Stub the valentine_check method to return a sample valentine message and title + cog.valentine_check = MagicMock(return_value=("A lovely poem", "A poem dedicated to")) + + # Call the send_valentine callback with parameters for a private, anonymous valentine + await cog.send_valentine.callback(cog, ctx, user, privacy_type="private", anon="anon", valentine_type="p") + + # Assert that the recipient's send method was awaited, indicating a DM was sent + user.send.assert_awaited() # DM should be sent + # Assert that a confirmation DM was sent to the command invoker with the correct message + ctx.author.send.assert_awaited_with(f"Your valentine has been **privately** delivered to {user.display_name}!") + # Assert that the original command message was deleted to maintain anonymity + ctx.message.delete.assert_awaited() # Original command message should be deleted + + +@pytest.mark.asyncio +async def test_send_valentine_private_anon_dm_disabled(): + """Test that an error is raised when sending a private valentine to a user with DMs disabled.""" + # Create a fake bot instance + bot = MagicMock() + # Instantiate the BeMyValentine cog with the fake bot + cog = BeMyValentine(bot) + + # Create a fake command context + ctx = MagicMock() + # Create a fake author for the command context + ctx.author = MagicMock() + # Set the delete method on the command message as an AsyncMock for potential deletion + ctx.message.delete = AsyncMock() + # Set the send method on the context as an AsyncMock to simulate sending error messages publicly + ctx.send = AsyncMock() + # Create a fake recipient user object + user = MagicMock() + # Simulate that the recipient has the lovefest role + user.roles = [MagicMock(id=Roles.lovefest)] # User has lovefest role + # Define a display name for the recipient + user.display_name = "Recipient" + + # Create a fake discord.Forbidden exception to simulate a scenario where the recipient's DMs are disabled + forbidden_exception = discord.Forbidden(response=MagicMock(), message="Forbidden") + # Set the recipient's send method to raise the Forbidden exception when called + user.send = AsyncMock(side_effect=forbidden_exception) + + # Stub the random_emoji method to return fixed emojis for testing + cog.random_emoji = MagicMock(return_value=("💖", "💕")) + # Stub the valentine_check method to return a sample valentine message and title + cog.valentine_check = MagicMock(return_value=("A lovely poem", "A poem dedicated to")) + + # Call the send_valentine callback with parameters for a private, anonymous valentine where the recipient's DMs are disabled + await cog.send_valentine.callback(cog, ctx, user, privacy_type="private", anon="anon", valentine_type="p") + + # Assert that the context's send method was awaited with a message indicating that the DM could not be delivered + ctx.send.assert_awaited_with(f"I couldn't send a private message to {user.display_name}. They may have DMs disabled.") + # Assert that the original command message was deleted even in the error scenario + ctx.message.delete.assert_awaited() # Original command message should be deleted + + + +@pytest.mark.asyncio +async def test_send_valentine_dm_channel(): + """Test that using the command in a DM (ctx.guild is None) raises UserInputError.""" + bot = MagicMock() + cog = BeMyValentine(bot) + + ctx = MagicMock() + ctx.guild = None # Simulate a DM channel (no guild) + ctx.message.delete = AsyncMock() + ctx.author = MagicMock() + + user = MagicMock() + user.roles = [MagicMock(id=Roles.lovefest)] + user.display_name = "Recipient" + + with pytest.raises(commands.UserInputError, match="You are supposed to use this command in the server."): + await cog.send_valentine.callback(cog, ctx, user, privacy_type="public", anon="signed", valentine_type="p") + + + +@pytest.mark.asyncio +async def test_send_valentine_deletion_failure(): + """ + Test that if deleting the command message fails (raises discord.Forbidden), + the bot sends an error message and continues processing. + """ + bot = MagicMock() + cog = BeMyValentine(bot) + + ctx = MagicMock() + # Simulate deletion failure by having the delete method raise a Forbidden exception + ctx.message.delete = AsyncMock(side_effect=discord.Forbidden(response=MagicMock(), message="Forbidden")) + ctx.author = MagicMock() + ctx.author.send = AsyncMock() + ctx.send = AsyncMock() # Used to send the error message for deletion failure + ctx.guild = MagicMock() # Ensure the command is executed in a guild + + user = MagicMock() + user.roles = [MagicMock(id=Roles.lovefest)] + user.display_name = "Recipient" + user.send = AsyncMock() + + cog.random_emoji = MagicMock(return_value=("💖", "💕")) + cog.valentine_check = MagicMock(return_value=("A lovely poem", "A poem dedicated to")) + + await cog.send_valentine.callback(cog, ctx, user, privacy_type="private", anon="anon", valentine_type="p") + + ctx.message.delete.assert_awaited() # Confirm deletion was attempted + # Confirm that an error message was sent due to deletion failure + ctx.send.assert_any_await("I can't delete your message! Please check my permissions.") + user.send.assert_awaited() # Confirm that a DM was sent to the recipient + ctx.author.send.assert_awaited_with(f"Your valentine has been **privately** delivered to {user.display_name}!") + + +@pytest.mark.asyncio +async def test_send_valentine_public_send_failure(): + """ + Test that if sending a public message fails (raises discord.Forbidden), + the bot catches the exception and sends a fallback error message. + """ + bot = MagicMock() + cog = BeMyValentine(bot) + + ctx = MagicMock() + ctx.author = MagicMock() + ctx.message.delete = AsyncMock() # Not used since anon is "signed" + ctx.send = AsyncMock() + # Simulate failure on the first call to ctx.send and then success on the fallback call + ctx.send.side_effect = [discord.Forbidden(response=MagicMock(), message="Forbidden"), None] + ctx.guild = MagicMock() + + user = MagicMock() + user.roles = [MagicMock(id=Roles.lovefest)] + user.display_name = "Recipient" + + cog.random_emoji = MagicMock(return_value=("💖", "💕")) + cog.valentine_check = MagicMock(return_value=("A lovely poem", "A poem dedicated to")) + + await cog.send_valentine.callback(cog, ctx, user, privacy_type="public", anon="signed", valentine_type="p") + + # Confirm that the fallback error message was sent after the public message send failed + ctx.send.assert_any_await(f"I couldn't send a private message to {user.display_name}. They may have DMs disabled.")