From d46b5972cb0c84fb671bc7a459774c67441f4151 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:49:06 -0800 Subject: [PATCH 1/5] Refactor several extensions with codex --- Pipfile | 2 +- techsupport_bot/commands/burn.py | 232 +++++- techsupport_bot/commands/duck.py | 750 +++++++++++------- techsupport_bot/commands/echo.py | 134 ++-- techsupport_bot/commands/google.py | 368 ++++++--- techsupport_bot/commands/hangman.py | 626 ++++++++++----- techsupport_bot/commands/hug.py | 202 +++-- techsupport_bot/commands/translate.py | 138 +++- techsupport_bot/commands/weather.py | 197 +++-- techsupport_bot/core/databases.py | 2 +- .../commands_tests/test_extensions_burn.py | 123 +++ .../commands_tests/test_extensions_conch.py | 59 -- .../commands_tests/test_extensions_duck.py | 164 ++++ .../commands_tests/test_extensions_echo.py | 68 ++ .../commands_tests/test_extensions_emoji.py | 352 -------- .../commands_tests/test_extensions_google.py | 153 ++++ .../commands_tests/test_extensions_hangman.py | 241 ++++++ .../commands_tests/test_extensions_hug.py | 170 ++-- .../commands_tests/test_extensions_lenny.py | 58 -- .../commands_tests/test_extensions_linter.py | 190 ----- .../commands_tests/test_extensions_mock.py | 196 ----- .../commands_tests/test_extensions_roll.py | 105 --- .../test_extensions_translate.py | 114 +++ .../commands_tests/test_extensions_weather.py | 132 +++ .../commands_tests/test_extensions_wyr.py | 174 ---- 25 files changed, 2894 insertions(+), 2056 deletions(-) create mode 100644 techsupport_bot/tests/commands_tests/test_extensions_burn.py delete mode 100644 techsupport_bot/tests/commands_tests/test_extensions_conch.py create mode 100644 techsupport_bot/tests/commands_tests/test_extensions_duck.py create mode 100644 techsupport_bot/tests/commands_tests/test_extensions_echo.py delete mode 100644 techsupport_bot/tests/commands_tests/test_extensions_emoji.py create mode 100644 techsupport_bot/tests/commands_tests/test_extensions_google.py create mode 100644 techsupport_bot/tests/commands_tests/test_extensions_hangman.py delete mode 100644 techsupport_bot/tests/commands_tests/test_extensions_lenny.py delete mode 100644 techsupport_bot/tests/commands_tests/test_extensions_linter.py delete mode 100644 techsupport_bot/tests/commands_tests/test_extensions_mock.py delete mode 100644 techsupport_bot/tests/commands_tests/test_extensions_roll.py create mode 100644 techsupport_bot/tests/commands_tests/test_extensions_translate.py create mode 100644 techsupport_bot/tests/commands_tests/test_extensions_weather.py delete mode 100644 techsupport_bot/tests/commands_tests/test_extensions_wyr.py diff --git a/Pipfile b/Pipfile index e8c32ec4a..100a2ee13 100644 --- a/Pipfile +++ b/Pipfile @@ -37,4 +37,4 @@ pyyaml = "==6.0.3" unidecode = "==1.4.0" [requires] -python_version = "3.11" +python_version = "3.13" diff --git a/techsupport_bot/commands/burn.py b/techsupport_bot/commands/burn.py index df61b9d75..8fe898c67 100644 --- a/techsupport_bot/commands/burn.py +++ b/techsupport_bot/commands/burn.py @@ -11,7 +11,7 @@ import discord from core import auxiliary, cogs -from discord.ext import commands +from discord import app_commands if TYPE_CHECKING: import bot @@ -42,81 +42,229 @@ class Burn(cogs.BaseCog): "Was that message a hot pan? BECAUSE IT BURNS!", ] + def __init__(self: Self, bot: bot.TechSupportBot) -> None: + """Initializes burn command handlers and registers context menu command. + + Args: + bot (bot.TechSupportBot): The bot instance + """ + super().__init__(bot=bot) + self.ctx_menu = app_commands.ContextMenu( + name="Declare Burn", + callback=self.burn_from_message, + extras={"module": "burn"}, + ) + if getattr(self.bot, "tree", None): + self.bot.tree.add_command(self.ctx_menu) + async def handle_burn( self: Self, - ctx: commands.Context, - user: discord.Member, + interaction: discord.Interaction, + user: discord.Member | discord.User, message: discord.Message, ) -> None: - """The core logic to handle the burn command + """The core logic to handle burn execution. Args: - ctx (commands.Context): The context in which the command was run in - user (discord.Member): The user that was called in the burn command + interaction (discord.Interaction): The interaction that called this command + user (discord.Member | discord.User): The user that was called in the burn command message (discord.Message): The message to react to. Will be None if no message could be found """ if not message: - await auxiliary.send_deny_embed( - message="I could not a find a message to reply to", channel=ctx.channel + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(build_burn_not_found_message()) ) return await auxiliary.add_list_of_reactions( - message=message, reactions=["🔥", "🚒", "👨‍🚒"] + message=message, reactions=build_burn_reactions() ) + phrase_pool = normalize_phrase_pool(self.PHRASES) + invalid_phrase_message = validate_phrase_pool(phrase_pool) + if invalid_phrase_message: + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(invalid_phrase_message) + ) + return + + phrase_index = choose_phrase_index(phrase_pool) + burn_description = build_burn_description(phrase_pool, phrase_index) + embed = auxiliary.generate_basic_embed( title="Burn Alert!", - description=f"🔥🔥🔥 {random.choice(self.PHRASES)} 🔥🔥🔥", + description=burn_description, color=discord.Color.red(), ) - await ctx.send(embed=embed, content=auxiliary.construct_mention_string([user])) + await interaction.followup.send( + embed=embed, content=auxiliary.construct_mention_string([user]) + ) async def burn_command( - self: Self, ctx: commands.Context, user_to_match: discord.Member + self: Self, + interaction: discord.Interaction, + user_to_match: discord.Member | discord.User, ) -> None: - """This the core logic of the burn command - This is a command and should be accessed via discord + """The core logic of the slash burn command. Args: - ctx (commands.Context): The context in which the command was run - user_to_match (discord.Member): The user in which to burn + interaction (discord.Interaction): The interaction in which the command was run + user_to_match (discord.Member | discord.User): The user in which to burn """ - if ctx.message.reference is None: - prefix = await self.bot.get_prefix(ctx.message) - message = await auxiliary.search_channel_for_message( - channel=ctx.channel, prefix=prefix, member_to_match=user_to_match - ) - else: - message = ctx.message.reference.resolved + prefix = self.bot.guild_configs[str(interaction.guild.id)].command_prefix + + message = await auxiliary.search_channel_for_message( + channel=interaction.channel, + prefix=prefix, + member_to_match=user_to_match, + ) - await self.handle_burn(ctx, user_to_match, message) + await self.handle_burn(interaction, user_to_match, message) - @auxiliary.with_typing - @commands.guild_only() - @commands.command( - brief="Declares a BURN!", - description="Declares mentioned user's message as a BURN!", - usage="@user", + @app_commands.command( + name="burn", + description="Declares a user's message as a burn", + extras={"module": "burn"}, ) async def burn( - self: Self, ctx: commands.Context, user_to_match: discord.Member = None + self: Self, interaction: discord.Interaction, user_to_match: discord.Member ) -> None: - """The only purpose of this function is to accept input from discord + """The slash command entry point for burn. Args: - ctx (commands.Context): The context in which the command was run + interaction (discord.Interaction): The interaction that called this command user_to_match (discord.Member): The user in which to burn """ - if user_to_match is None: - if ctx.message.reference is None: - await auxiliary.send_deny_embed( - message="You need to mention someone to declare a burn.", - channel=ctx.channel, - ) - return + await interaction.response.defer(ephemeral=False) + await self.burn_command(interaction, user_to_match) + + async def burn_from_message( + self: Self, interaction: discord.Interaction, message: discord.Message + ) -> None: + """Context menu callback to declare a burn on a selected message. + + Args: + interaction (discord.Interaction): The interaction that called this command + message (discord.Message): The selected message for the context menu command + """ + await interaction.response.defer(ephemeral=False) + + target_author_id = resolve_burn_target_for_context_menu( + message_author_id=getattr(message.author, "id", 0), + interaction_user_id=interaction.user.id, + ) + if target_author_id == interaction.user.id: + target_user = interaction.user + else: + target_user = message.author + + await self.handle_burn(interaction, target_user, message) + + +def build_burn_reactions() -> list[str]: + """Builds the ordered list of burn reactions. + + Returns: + list[str]: The emoji reactions to add to the target message + """ + reactions = ["🔥", "🚒"] + reactions.append("👨‍🚒") + return reactions + + +def normalize_phrase_pool(phrases: list[str]) -> list[str]: + """Normalizes and deduplicates burn phrases. + + Args: + phrases (list[str]): Raw phrases to normalize - user_to_match = ctx.message.reference.resolved.author + Returns: + list[str]: A deduplicated list of clean phrases + """ + normalized_phrases = [] + seen = set() + for phrase in phrases: + cleaned_phrase = phrase.strip() + if len(cleaned_phrase) == 0: + continue + if cleaned_phrase in seen: + continue + seen.add(cleaned_phrase) + normalized_phrases.append(cleaned_phrase) + + return normalized_phrases + + +def validate_phrase_pool(phrases: list[str]) -> str | None: + """Validates that there are usable phrases to render. + + Args: + phrases (list[str]): The normalized phrase list + + Returns: + str | None: A deny message if invalid, otherwise None + """ + if len(phrases) == 0: + return "There are no burn phrases configured" + + return None + + +def choose_phrase_index(phrases: list[str]) -> int: + """Chooses a random index from a phrase pool. + + Args: + phrases (list[str]): The available phrase pool + + Raises: + ValueError: Raised if an empty list is supplied + + Returns: + int: The index of the chosen phrase + """ + if len(phrases) == 0: + raise ValueError("phrase list cannot be empty") + + return random.randint(0, len(phrases) - 1) + + +def build_burn_description(phrases: list[str], chosen_index: int) -> str: + """Builds the burn embed description from phrase data. + + Args: + phrases (list[str]): The available phrase pool + chosen_index (int): The index selected by the phrase chooser + + Returns: + str: A formatted burn phrase description + """ + phrase = phrases[chosen_index] + return f"🔥🔥🔥 {phrase} 🔥🔥🔥" + + +def build_burn_not_found_message() -> str: + """Builds the deny message for missing target messages. + + Returns: + str: A user-facing deny message + """ + return "I could not find a message to reply to" + + +def resolve_burn_target_for_context_menu( + message_author_id: int, interaction_user_id: int +) -> int: + """Resolves the target user id for context menu burn calls. + + Args: + message_author_id (int): The selected message author id + interaction_user_id (int): The user id of the command invoker + + Returns: + int: The chosen target user id + """ + if message_author_id <= 0: + return interaction_user_id - await self.burn_command(ctx, user_to_match) + return message_author_id diff --git a/techsupport_bot/commands/duck.py b/techsupport_bot/commands/duck.py index f7433e5e3..4c366476e 100644 --- a/techsupport_bot/commands/duck.py +++ b/techsupport_bot/commands/duck.py @@ -15,7 +15,7 @@ from botlogging import LogContext, LogLevel from core import auxiliary, cogs, extensionconfig, moderation from discord import Color as embed_colors -from discord.ext import commands +from discord import app_commands if TYPE_CHECKING: import bot @@ -104,6 +104,178 @@ async def setup(bot: bot.TechSupportBot) -> None: bot.add_extension_config("duck", config) +def compute_duration_values(raw_duration: datetime.timedelta) -> tuple[int, float]: + """Computes integer and exact duration values used by duck game output. + + Args: + raw_duration (datetime.timedelta): The elapsed time since duck spawn + + Returns: + tuple[int, float]: Whole-second duration and exact duration with microseconds + """ + duration_seconds = raw_duration.seconds + duration_exact = float(f"{raw_duration.seconds}.{raw_duration.microseconds}") + return duration_seconds, duration_exact + + +def build_winner_footer( + duration_exact: float, previous_personal: float, global_record: float | None +) -> tuple[str, bool]: + """Builds winner embed footer text and speed-record update state. + + Args: + duration_exact (float): The exact completion time for the winner + previous_personal (float): The winner's previous personal best + global_record (float | None): The current global best in the guild + + Returns: + tuple[str, bool]: Footer text and whether personal record should be updated + """ + if previous_personal == -1 or duration_exact < previous_personal: + footer_text = f"New personal record: {duration_exact} seconds." + if global_record is None or duration_exact < global_record: + footer_text += "\nNew global record!" + if global_record is not None: + footer_text += f" Previous global record: {global_record} seconds" + return footer_text, True + + return f"Exact time: {duration_exact} seconds.", False + + +def build_stats_footer(speed_record: float, global_record: float | None) -> str: + """Builds footer text for /duck stats response. + + Args: + speed_record (float): User speed record + global_record (float | None): Guild global speed record + + Returns: + str: The final footer text + """ + footer_text = f"Speed record: {speed_record} seconds" + if global_record is not None and speed_record == global_record: + footer_text += "\nYou hold the current global record!" + return footer_text + + +def chunk_duck_users( + duck_users: list[object], items_per_page: int = 3 +) -> list[list[object]]: + """Chunks duck user records for paginated leaderboard embeds. + + Args: + duck_users (list[object]): Ordered duck user records + items_per_page (int, optional): Max rows per page. Defaults to 3. + + Returns: + list[list[object]]: Chunked records in display order + """ + chunks = [] + current_chunk = [] + + for duck_user in duck_users: + current_chunk.append(duck_user) + if len(current_chunk) == items_per_page: + chunks.append(current_chunk) + current_chunk = [] + + if len(current_chunk) > 0: + chunks.append(current_chunk) + + return chunks + + +def build_not_participated_message() -> str: + """Builds the default message for users without duck records. + + Returns: + str: A user-facing deny message + """ + return "You have not participated in the duck hunt yet." + + +def build_manipulation_disabled_message() -> str: + """Builds the message for manipulation-disabled servers. + + Returns: + str: A user-facing deny message + """ + return "This command is disabled in this server" + + +def validate_donation_target( + invoker_id: int, target_id: int, target_is_bot: bool +) -> str | None: + """Validates the target for duck donation commands. + + Args: + invoker_id (int): The invoking user ID + target_id (int): The donation target user ID + target_is_bot (bool): Whether the target user is a bot + + Returns: + str | None: A deny message when invalid, otherwise None + """ + if target_is_bot: + return "The only ducks I accept are plated with gold!" + + if invoker_id == target_id: + return "You can't donate a duck to yourself" + + return None + + +def validate_duck_inventory(befriend_count: int, action_name: str) -> str | None: + """Validates inventory for duck manipulation actions. + + Args: + befriend_count (int): Current number of befriended ducks + action_name (str): Action verb used in user-facing text + + Returns: + str | None: A deny message when inventory is insufficient, otherwise None + """ + if befriend_count > 0: + return None + + return f"You have no ducks to {action_name}." + + +def can_spawn_duck(invoker_id: int, allowed_ids: list[int]) -> bool: + """Determines if a user is allowed to spawn a duck. + + Args: + invoker_id (int): The invoking user ID + allowed_ids (list[int]): Configured user IDs allowed to spawn ducks + + Returns: + bool: True if invoker may spawn a duck, otherwise False + """ + normalized_ids = {int(user_id) for user_id in allowed_ids} + return invoker_id in normalized_ids + + +def build_spawn_permission_denial() -> str: + """Builds spawn permission deny message. + + Returns: + str: A user-facing deny message + """ + return "It looks like you don't have permissions to spawn a duck" + + +def build_random_choice_weights(success_rate: int) -> tuple[int, int]: + """Builds weighted success and failure values for duck chance checks. + + Args: + success_rate (int): Percent chance for success + + Returns: + tuple[int, int]: Success and failure weights + """ + return success_rate, 100 - success_rate + + class DuckHunt(cogs.LoopCog): """Class for the actual duck commands @@ -247,7 +419,7 @@ async def handle_winner( winner: discord.Member, guild: discord.Guild, action: str, - raw_duration: datetime.datetime, + raw_duration: datetime.timedelta, channel: discord.abc.Messageable, ) -> None: """This is a function to update the database based on a winner @@ -256,7 +428,7 @@ async def handle_winner( winner (discord.Member): A discord.Member object for the winner guild (discord.Guild): A discord.Guild object for the guild the winner is a part of action (str): A string, either "befriended" or "killed", depending on the action - raw_duration (datetime.datetime): A datetime object of the time since the duck spawned + raw_duration (datetime.timedelta): Time elapsed since duck spawn channel (discord.abc.Messageable): The channel in which the duck game happened in """ @@ -269,10 +441,7 @@ async def handle_winner( channel=log_channel, ) - duration_seconds = raw_duration.seconds - duration_exact = float( - str(raw_duration.seconds) + "." + str(raw_duration.microseconds) - ) + duration_seconds, duration_exact = compute_duration_values(raw_duration) duck_user = await self.get_duck_user(winner.id, guild.id) if not duck_user: @@ -281,7 +450,7 @@ async def handle_winner( guild_id=str(guild.id), befriend_count=0, kill_count=0, - speed_record=80.0, + speed_record=-1.0, ) await duck_user.create() @@ -307,15 +476,13 @@ async def handle_winner( url=self.BEFRIEND_URL if action == "befriended" else self.KILL_URL ) global_record = await self.get_global_record(guild.id) - footer_string = "" - if duration_exact < duck_user.speed_record: - footer_string += f"New personal record: {duration_exact} seconds." - if duration_exact < global_record: - footer_string += "\nNew global record!" - footer_string += f" Previous global record: {global_record} seconds" + footer_string, should_update_personal = build_winner_footer( + duration_exact=duration_exact, + previous_personal=duck_user.speed_record, + global_record=global_record, + ) + if should_update_personal: await duck_user.update(speed_record=duration_exact).apply() - else: - footer_string += f"Exact time: {duration_exact} seconds." embed.set_footer(text=footer_string) await channel.send(embed=embed) @@ -461,227 +628,209 @@ async def get_global_record(self: Self, guild_id: int) -> float: self.bot.models.DuckUser.guild_id == str(guild_id) ).gino.all() - speed_records = [record.speed_record for record in query] + speed_records = [ + record.speed_record for record in query if record.speed_record != -1 + ] if not speed_records: return None return float(min(speed_records, key=float)) - @commands.group( - brief="Executes a duck command", - description="Executes a duck command", + duck_group: app_commands.Group = app_commands.Group( + name="duck", + description="Command Group for Duck Hunt", + extras={"module": "duck"}, ) - async def duck(self: Self, ctx: commands.Context) -> None: - """The bare .duck command. This does nothing but generate the help message - Args: - ctx (commands.Context): The context in which the command was run in - """ - return - - @auxiliary.with_typing - @commands.guild_only() - @duck.command( - brief="Get duck stats", + @duck_group.command( + name="stats", description="Gets duck friendships and kills for yourself or another user", - usage="@user (defaults to yourself)", + extras={"module": "duck"}, ) async def stats( - self: Self, ctx: commands.Context, *, user: discord.Member = None + self: Self, interaction: discord.Interaction, user: discord.Member = None ) -> None: - """Discord command for getting duck stats for a given user + """Gets duck stats for a given user. Args: - ctx (commands.Context): The context in which the command was run + interaction (discord.Interaction): The interaction in which the command was run user (discord.Member, optional): The member to lookup stats for. - Defaults to ctx.message.author. + Defaults to the invoking user. """ + await interaction.response.defer(ephemeral=False) if not user: - user = ctx.message.author + user = interaction.user if user.bot: - await auxiliary.send_deny_embed( - message="If it looks like a duck, quacks like a duck, it's a duck!", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "If it looks like a duck, quacks like a duck, it's a duck!" + ), + ephemeral=True, ) return - duck_user = await self.get_duck_user(user.id, ctx.guild.id) + duck_user = await self.get_duck_user(user.id, interaction.guild.id) if not duck_user: - await auxiliary.send_deny_embed( - message="That user has not partcipated in the duck hunt", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "That user has not partcipated in the duck hunt" + ), + ephemeral=True, ) return + global_record = await self.get_global_record(interaction.guild.id) embed = discord.Embed(title="Duck Stats", description=user.mention) embed.color = embed_colors.green() embed.add_field(name="Friends", value=duck_user.befriend_count) embed.add_field(name="Kills", value=duck_user.kill_count) - footer_string = f"Speed record: {str(duck_user.speed_record)} seconds" - if duck_user.speed_record == await self.get_global_record(ctx.guild.id): - footer_string += "\nYou hold the current global record!" - embed.set_footer(text=footer_string) + embed.set_footer(text=build_stats_footer(duck_user.speed_record, global_record)) embed.set_thumbnail(url=self.DUCK_PIC_URL) - await ctx.send(embed=embed) + await interaction.followup.send(embed=embed) - @auxiliary.with_typing - @commands.guild_only() - @duck.command( - brief="Get duck friendship scores", + @duck_group.command( + name="friends", description="Gets duck friendship scores for all users", + extras={"module": "duck"}, ) - async def friends(self: Self, ctx: commands.Context) -> None: - """Discord commands to view high scores for befriended ducks + async def friends(self: Self, interaction: discord.Interaction) -> None: + """Views high scores for befriended ducks. Args: - ctx (commands.Context): The context in which the command was run + interaction (discord.Interaction): The interaction in which the command was run """ + await interaction.response.defer(ephemeral=False) duck_users = ( await self.bot.models.DuckUser.query.order_by( -self.bot.models.DuckUser.befriend_count ) .where(self.bot.models.DuckUser.befriend_count > 0) - .where(self.bot.models.DuckUser.guild_id == str(ctx.guild.id)) + .where(self.bot.models.DuckUser.guild_id == str(interaction.guild.id)) .gino.all() ) if not duck_users: - await auxiliary.send_deny_embed( - message="It appears nobody has befriended any ducks", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "It appears nobody has befriended any ducks" + ), + ephemeral=True, ) return - field_counter = 1 + global_record = await self.get_global_record(interaction.guild.id) embeds = [] - for index, duck_user in enumerate(duck_users): - embed = ( - discord.Embed( - title="Duck Friendships", - description=( - "Global speed record: " - f" {str(await self.get_global_record(ctx.guild.id))} seconds" - ), - ) - if field_counter == 1 - else embed + for chunk in chunk_duck_users(duck_users): + embed = discord.Embed( + title="Duck Friendships", + description=f"Global speed record: {global_record} seconds", ) - embed.set_thumbnail(url=self.DUCK_PIC_URL) embed.color = embed_colors.green() + for duck_user in chunk: + embed.add_field( + name=await self.get_user_text(duck_user, interaction.guild), + value=f"Friends: `{duck_user.befriend_count}`", + inline=False, + ) + embeds.append(embed) - embed.add_field( - name=await self.get_user_text(duck_user, ctx.guild), - value=f"Friends: `{duck_user.befriend_count}`", - inline=False, - ) - if field_counter == 3 or index == len(duck_users) - 1: - embeds.append(embed) - field_counter = 1 - else: - field_counter += 1 - - await ui.PaginateView().send(ctx.channel, ctx.author, embeds) + await ui.PaginateView().send( + interaction.channel, interaction.user, embeds, interaction + ) - @auxiliary.with_typing - @commands.guild_only() - @duck.command( - brief="Get the record holder", - description="Gets the current speed record holder, and their time", + @duck_group.command( + name="record", + description="Gets the current speed record holder and their time", + extras={"module": "duck"}, ) - async def record(self: Self, ctx: commands.Context) -> None: - """This outputs an embed shows the current speed record holder and their time - This is a command and should be run via discord + async def record(self: Self, interaction: discord.Interaction) -> None: + """Shows the current speed record holder and time. Args: - ctx (commands.Context): The context in which the command was run + interaction (discord.Interaction): The interaction in which the command was run """ - - record_time = await self.get_global_record(ctx.guild.id) + await interaction.response.defer(ephemeral=False) + record_time = await self.get_global_record(interaction.guild.id) if record_time is None: - await auxiliary.send_deny_embed( - message="It appears nobody has partcipated in the duck hunt", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "It appears nobody has partcipated in the duck hunt" + ), + ephemeral=True, ) return record_user_entry = ( await self.bot.models.DuckUser.query.where( self.bot.models.DuckUser.speed_record == record_time ) - .where(self.bot.models.DuckUser.guild_id == str(ctx.guild.id)) + .where(self.bot.models.DuckUser.guild_id == str(interaction.guild.id)) .gino.first() ) embed = discord.Embed(title="Duck Speed Record") embed.color = embed_colors.green() - embed.add_field(name="Time", value=f"{str(record_time)} seconds") + embed.add_field(name="Time", value=f"{record_time} seconds") embed.add_field( name="Record Holder", - value=await self.get_user_text(record_user_entry, ctx.guild), + value=await self.get_user_text(record_user_entry, interaction.guild), ) embed.set_thumbnail(url=self.DUCK_PIC_URL) - await ctx.send(embed=embed) + await interaction.followup.send(embed=embed) - @auxiliary.with_typing - @commands.guild_only() - @duck.command( - brief="Get duck kill scores", + @duck_group.command( + name="killers", description="Gets duck kill scores for all users", + extras={"module": "duck"}, ) - async def killers(self: Self, ctx: commands.Context) -> None: - """Discord command to view high scores for killed ducks + async def killers(self: Self, interaction: discord.Interaction) -> None: + """Views high scores for killed ducks. Args: - ctx (commands.Context): The context in which the command was run + interaction (discord.Interaction): The interaction in which the command was run """ + await interaction.response.defer(ephemeral=False) duck_users = ( await self.bot.models.DuckUser.query.order_by( -self.bot.models.DuckUser.kill_count ) .where(self.bot.models.DuckUser.kill_count > 0) - .where(self.bot.models.DuckUser.guild_id == str(ctx.guild.id)) + .where(self.bot.models.DuckUser.guild_id == str(interaction.guild.id)) .gino.all() ) if not duck_users: - await auxiliary.send_deny_embed( - message="It appears nobody has killed any ducks", channel=ctx.channel + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "It appears nobody has killed any ducks" + ), + ephemeral=True, ) return - field_counter = 1 + global_record = await self.get_global_record(interaction.guild.id) embeds = [] - for index, duck_user in enumerate(duck_users): - embed = ( - discord.Embed( - title="Duck Kills", - description=( - "Global speed record: " - f" {str(await self.get_global_record(ctx.guild.id))} seconds" - ), - ) - if field_counter == 1 - else embed + for chunk in chunk_duck_users(duck_users): + embed = discord.Embed( + title="Duck Kills", + description=f"Global speed record: {global_record} seconds", ) - embed.set_thumbnail(url=self.DUCK_PIC_URL) embed.color = embed_colors.green() + for duck_user in chunk: + embed.add_field( + name=await self.get_user_text(duck_user, interaction.guild), + value=f"Kills: `{duck_user.kill_count}`", + inline=False, + ) + embeds.append(embed) - embed.add_field( - name=await self.get_user_text(duck_user, ctx.guild), - value=f"Kills: `{duck_user.kill_count}`", - inline=False, - ) - if field_counter == 3 or index == len(duck_users) - 1: - embeds.append(embed) - field_counter = 1 - else: - field_counter += 1 - - await ui.PaginateView().send(ctx.channel, ctx.author, embeds) + await ui.PaginateView().send( + interaction.channel, interaction.user, embeds, interaction + ) async def get_user_text( self: Self, duck_user: bot.models.DuckUser, guild: discord.Guild @@ -708,251 +857,292 @@ async def get_user_text( return f"`{display_name}` (`{user_object.name}`)" - @auxiliary.with_typing - @commands.guild_only() - @duck.command( - brief="Releases a duck into the wild", + @duck_group.command( + name="release", description="Returns a befriended duck to its natural habitat", + extras={"module": "duck"}, ) - async def release(self: Self, ctx: commands.Context) -> None: - """Releases a duck into the wild, a duck will spawn in the channel this command is run from - This is a discord command + async def release(self: Self, interaction: discord.Interaction) -> None: + """Releases a duck into the wild and spawns one in-channel. Args: - ctx (commands.Context): The context in which the command was run + interaction (discord.Interaction): The interaction in which the command was run """ - config = self.bot.guild_configs[str(ctx.guild.id)] + await interaction.response.defer(ephemeral=False) + config = self.bot.guild_configs[str(interaction.guild.id)] if not config.extensions.duck.allow_manipulation.value: - await auxiliary.send_deny_embed( - channel=ctx.channel, message="This command is disabled in this server" + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + build_manipulation_disabled_message() + ), + ephemeral=True, ) return - duck_user = await self.get_duck_user(ctx.author.id, ctx.guild.id) - + duck_user = await self.get_duck_user(interaction.user.id, interaction.guild.id) if not duck_user: - await auxiliary.send_deny_embed( - message="You have not participated in the duck hunt yet.", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(build_not_participated_message()), + ephemeral=True, ) return - if not duck_user or duck_user.befriend_count == 0: - await auxiliary.send_deny_embed( - message="You have no ducks to release.", channel=ctx.channel + missing_inventory = validate_duck_inventory(duck_user.befriend_count, "release") + if missing_inventory: + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(missing_inventory), + ephemeral=True, ) return await duck_user.update(befriend_count=duck_user.befriend_count - 1).apply() - await auxiliary.send_confirm_embed( - message=f"Fly safe! You have {duck_user.befriend_count} ducks left.", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_confirm_embed( + f"Fly safe! You have {duck_user.befriend_count} ducks left." + ) ) - await self.execute(config, ctx.guild, ctx.channel, banned_user=ctx.author) + await self.execute( + config, interaction.guild, interaction.channel, interaction.user + ) - @auxiliary.with_typing - @commands.guild_only() - @duck.command( - brief="Kills a caputred duck", - description=( - "Adds a duck to your kill count. Why would you even want to do that?!" - ), + @duck_group.command( + name="kill", + description="Adds a duck to your kill count", + extras={"module": "duck"}, ) - async def kill(self: Self, ctx: commands.Context) -> None: - """Kills a friended duck and adds it to your kills. - Has a chance of failure - This is a discord command + async def kill(self: Self, interaction: discord.Interaction) -> None: + """Kills a befriended duck and adds it to your kills. Args: - ctx (commands.Context): The context in which the command was run + interaction (discord.Interaction): The interaction in which the command was run """ - config = self.bot.guild_configs[str(ctx.guild.id)] + await interaction.response.defer(ephemeral=False) + config = self.bot.guild_configs[str(interaction.guild.id)] if not config.extensions.duck.allow_manipulation.value: - await auxiliary.send_deny_embed( - channel=ctx.channel, message="This command is disabled in this server" + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + build_manipulation_disabled_message() + ), + ephemeral=True, ) return - duck_user = await self.get_duck_user(ctx.author.id, ctx.guild.id) - + duck_user = await self.get_duck_user(interaction.user.id, interaction.guild.id) if not duck_user: - await auxiliary.send_deny_embed( - message="You have not participated in the duck hunt yet.", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(build_not_participated_message()), + ephemeral=True, ) return - if duck_user.befriend_count == 0: - await auxiliary.send_deny_embed( - message="You have no ducks to kill.", channel=ctx.channel + missing_inventory = validate_duck_inventory(duck_user.befriend_count, "kill") + if missing_inventory: + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(missing_inventory), + ephemeral=True, ) return await duck_user.update(befriend_count=duck_user.befriend_count - 1).apply() - - passed = self.random_choice(config) - if not passed: - await auxiliary.send_deny_embed( - message="The duck got away before you could kill it.", - channel=ctx.channel, + if not self.random_choice(config): + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "The duck got away before you could kill it." + ), + ephemeral=True, ) return await duck_user.update(kill_count=duck_user.kill_count + 1).apply() - await auxiliary.send_confirm_embed( - message=f"You monster! You have {duck_user.befriend_count} ducks " - + f"left and {duck_user.kill_count} kills to your name.", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_confirm_embed( + f"You monster! You have {duck_user.befriend_count} ducks left and " + f"{duck_user.kill_count} kills to your name." + ) ) - @auxiliary.with_typing - @commands.guild_only() - @duck.command( - brief="Donates a duck to someone", + @duck_group.command( + name="donate", description="Gives someone the gift of a live duck", - usage="[user]", + extras={"module": "duck"}, ) - async def donate(self: Self, ctx: commands.Context, user: discord.Member) -> None: - """Donates a befriended duck to a given user. Duck count will be subtracted from invoker - This has a chance of failure - This is a discord command + async def donate( + self: Self, interaction: discord.Interaction, user: discord.Member + ) -> None: + """Donates a befriended duck to a given user. Args: - ctx (commands.Context): The context in which the command was run + interaction (discord.Interaction): The interaction in which the command was run user (discord.Member): The user to donate a duck to """ - config = self.bot.guild_configs[str(ctx.guild.id)] + await interaction.response.defer(ephemeral=False) + config = self.bot.guild_configs[str(interaction.guild.id)] if not config.extensions.duck.allow_manipulation.value: - await auxiliary.send_deny_embed( - channel=ctx.channel, message="This command is disabled in this server" + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + build_manipulation_disabled_message() + ), + ephemeral=True, ) return - if user.bot: - await auxiliary.send_deny_embed( - message="The only ducks I accept are plated with gold!", - channel=ctx.channel, - ) - return - if user.id == ctx.author.id: - await auxiliary.send_deny_embed( - message="You can't donate a duck to yourself", channel=ctx.channel + target_invalid = validate_donation_target( + interaction.user.id, user.id, user.bot + ) + if target_invalid: + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(target_invalid), ephemeral=True ) return - duck_user = await self.get_duck_user(ctx.author.id, ctx.guild.id) + duck_user = await self.get_duck_user(interaction.user.id, interaction.guild.id) if not duck_user: - await auxiliary.send_deny_embed( - message="You have not participated in the duck hunt yet.", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(build_not_participated_message()), + ephemeral=True, ) return - if not duck_user or duck_user.befriend_count == 0: - await auxiliary.send_deny_embed( - message="You have no ducks to donate.", channel=ctx.channel + missing_inventory = validate_duck_inventory(duck_user.befriend_count, "donate") + if missing_inventory: + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(missing_inventory), + ephemeral=True, ) return - recipee = await self.get_duck_user(user.id, ctx.guild.id) - if not recipee: - await auxiliary.send_deny_embed( - message=f"{user.mention} has not participated in the duck hunt yet.", - channel=ctx.channel, + + recipient = await self.get_duck_user(user.id, interaction.guild.id) + if not recipient: + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + f"{user.mention} has not participated in the duck hunt yet." + ), + ephemeral=True, ) return await duck_user.update(befriend_count=duck_user.befriend_count - 1).apply() - - passed = self.random_choice(config) - if not passed: - await auxiliary.send_deny_embed( - message="The duck got away before you could donate it.", - channel=ctx.channel, + if not self.random_choice(config): + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "The duck got away before you could donate it." + ), + ephemeral=True, ) return - await recipee.update(befriend_count=recipee.befriend_count + 1).apply() - await auxiliary.send_confirm_embed( - message=f"You gave a duck to {user.mention}. You now " - + f"have {duck_user.befriend_count} ducks left.", - channel=ctx.channel, + await recipient.update(befriend_count=recipient.befriend_count + 1).apply() + await interaction.followup.send( + embed=auxiliary.prepare_confirm_embed( + f"You gave a duck to {user.mention}. You now have" + f" {duck_user.befriend_count} ducks left." + ) ) - @auxiliary.with_typing - @commands.has_permissions(administrator=True) - @commands.guild_only() - @duck.command( - brief="Resets someones duck counts", - description="Deletes the database entry of the target", - usage="[user]", + @app_commands.checks.has_permissions(administrator=True) + @duck_group.command( + name="reset", + description="Deletes the duck database entry of the target", + extras={"module": "duck"}, ) - async def reset(self: Self, ctx: commands.Context, user: discord.Member) -> None: - """Admin only command to delete a database entry of a given user - This is a discord command + async def reset( + self: Self, interaction: discord.Interaction, user: discord.Member + ) -> None: + """Admin command to delete duck stats for a user. Args: - ctx (commands.Context): The context in which the command was run + interaction (discord.Interaction): The interaction in which the command was run user (discord.Member): The user to reset """ + await interaction.response.defer(ephemeral=False) if user.bot: - await auxiliary.send_deny_embed( - message="You leave my ducks alone!", channel=ctx.channel + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed("You leave my ducks alone!"), + ephemeral=True, ) return - duck_user = await self.get_duck_user(user.id, ctx.guild.id) + duck_user = await self.get_duck_user(user.id, interaction.guild.id) if not duck_user: - await auxiliary.send_deny_embed( - message="The user has not participated in the duck hunt yet.", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "The user has not participated in the duck hunt yet." + ), + ephemeral=True, ) return view = ui.Confirm() await view.send( message=f"Are you sure you want to reset {user.mention}s duck stats?", - channel=ctx.channel, - author=ctx.author, + channel=interaction.channel, + author=interaction.user, + interaction=interaction, ) await view.wait() if view.value is ui.ConfirmResponse.TIMEOUT: return if view.value is ui.ConfirmResponse.DENIED: - await auxiliary.send_deny_embed( - message=f"{user.mention}s duck stats were NOT reset.", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + f"{user.mention}s duck stats were NOT reset." + ), + ephemeral=True, ) return await duck_user.delete() - await auxiliary.send_confirm_embed( - message=f"Successfully reset {user.mention}s duck stats!", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_confirm_embed( + f"Successfully reset {user.mention}s duck stats!" + ) ) - @auxiliary.with_typing - @commands.guild_only() - @duck.command( - brief="Spawns a duck on command", - description="Will spawn a duck with the command", + @duck_group.command( + name="spawn", + description="Spawns a duck on command", + extras={"module": "duck"}, ) - async def spawn(self: Self, ctx: commands.Context) -> None: - """A debug focused command to force spawn a duck in any channel + async def spawn(self: Self, interaction: discord.Interaction) -> None: + """Force spawns a duck in current channel if allowed by config. Args: - ctx (commands.Context): The context in which the command was run + interaction (discord.Interaction): The interaction in which the command was run """ - config = self.bot.guild_configs[str(ctx.guild.id)] + await interaction.response.defer(ephemeral=False) + config = self.bot.guild_configs[str(interaction.guild.id)] spawn_user = config.extensions.duck.spawn_user.value - for person in spawn_user: - if ctx.author.id == int(person): - await self.execute(config, ctx.guild, ctx.channel) - return - await auxiliary.send_deny_embed( - message="It looks like you don't have permissions to spawn a duck", - channel=ctx.channel, + + if not can_spawn_duck(interaction.user.id, spawn_user): + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(build_spawn_permission_denial()), + ephemeral=True, + ) + return + + await interaction.followup.send( + embed=auxiliary.prepare_confirm_embed("Duck spawned successfully."), + ephemeral=True, + ) + asyncio.create_task( + self.execute(config, interaction.guild, interaction.channel) + ) + + def random_choice(self: Self, config: munch.Munch) -> bool: + """Picks true/false randomly based on configured success rate. + + Args: + config (munch.Munch): The config for the guild + + Returns: + bool: Whether the random choice should succeed or not + """ + weights = build_random_choice_weights(config.extensions.duck.success_rate.value) + choice_ = random.choice( + random.choices([True, False], weights=weights, k=100000) ) + return choice_ def random_choice(self: Self, config: munch.Munch) -> bool: """A function to pick true or false randomly based on the success_rate in the config diff --git a/techsupport_bot/commands/echo.py b/techsupport_bot/commands/echo.py index 92f585774..d38e920c2 100644 --- a/techsupport_bot/commands/echo.py +++ b/techsupport_bot/commands/echo.py @@ -4,20 +4,22 @@ MessageEcho This file contains 2 commands: - .echo user - .echo channel + /echo user + /echo channel """ from __future__ import annotations from typing import TYPE_CHECKING, Self +import discord from core import auxiliary, cogs -from discord.ext import commands +from discord import app_commands from functions import logger as function_logger if TYPE_CHECKING: import bot + import munch async def setup(bot: bot.TechSupportBot) -> None: @@ -34,51 +36,44 @@ class MessageEcho(cogs.BaseCog): The class that holds the echo commands """ - @commands.check(auxiliary.bot_admin_check_context) - @commands.group( - brief="Executes an echo bot command", description="Executes an echo bot command" + echo_group: app_commands.Group = app_commands.Group( + name="echo", + description="Command Group for Echo Commands", + extras={"module": "echo"}, ) - async def echo(self: Self, ctx: commands.Context) -> None: - """The bare .echo command. This does nothing but generate the help message - Args: - ctx (commands.Context): The context in which the command was run in - """ - return - - @auxiliary.with_typing - @echo.command( + @app_commands.check(auxiliary.bot_admin_check_interaction) + @echo_group.command( name="channel", description="Echos a message to a channel", - usage="[channel-id] [message]", + extras={ + "brief": "Echos a message to a channel", + "usage": "#channel [message]", + "module": "echo", + }, ) async def echo_channel( - self: Self, ctx: commands.Context, channel_id: int, *, message: str + self: Self, + interaction: discord.Interaction, + channel: discord.TextChannel, + message: str, ) -> None: """Sends a message to a specified channel. - This is a command and should be accessed via Discord. - Args: - ctx (commands.Context): the context object for the calling message - channel_id (int): the ID of the channel to send the echoed message + interaction (discord.Interaction): The interaction that called this command + channel (discord.TextChannel): The channel to send the echoed message to message (str): the message to echo """ - channel = self.bot.get_channel(channel_id) - if not channel: - await auxiliary.send_deny_embed( - message="I couldn't find that channel", channel=ctx.channel - ) - return - + await interaction.response.defer(ephemeral=True) sent_message = await channel.send(content=message) - - await auxiliary.send_confirm_embed(message="Message sent", channel=ctx.channel) + await interaction.followup.send( + embed=auxiliary.prepare_confirm_embed("Message sent"), ephemeral=True + ) config = self.bot.guild_configs[str(channel.guild.id)] - - # Don't allow logging if extension is disabled - if "logger" not in config.enabled_extensions: + logging_payload = build_echo_channel_log_payload(config, message) + if not logging_payload: return target_logging_channel = await function_logger.pre_log_checks( @@ -90,38 +85,73 @@ async def echo_channel( await function_logger.send_message( self.bot, sent_message, - ctx.author, + interaction.user, channel, target_logging_channel, - content_override=message, - special_flags=["Echo command"], + content_override=logging_payload["content_override"], + special_flags=logging_payload["special_flags"], ) - @auxiliary.with_typing - @echo.command( + @app_commands.check(auxiliary.bot_admin_check_interaction) + @echo_group.command( name="user", description="Echos a message to a user", - usage="[user-id] [message]", + extras={ + "brief": "Echos a message to a user", + "usage": "@user [message]", + "module": "echo", + }, ) async def echo_user( - self: Self, ctx: commands.Context, user_id: int, *, message: str + self: Self, interaction: discord.Interaction, user: discord.User, message: str ) -> None: """Sends a message to a specified user. - This is a command and should be accessed via Discord. - Args: - ctx (commands.Context): the context object for the calling message - user_id (int): the ID of the user to send the echoed message + interaction (discord.Interaction): The interaction that called this command + user (discord.User): The user to send the echoed message to message (str): the message to echo """ - user = await self.bot.fetch_user(int(user_id)) - if not user: - await auxiliary.send_deny_embed( - message="I couldn't find that user", channel=ctx.channel - ) - return - + await interaction.response.defer(ephemeral=True) await user.send(content=message) + await interaction.followup.send( + embed=auxiliary.prepare_confirm_embed("Message sent"), ephemeral=True + ) + + +def normalize_echo_message(message: str) -> str: + """Normalizes user supplied message text for consistent logging payloads. + + Args: + message (str): The message provided to the echo command + + Returns: + str: A non-empty message string suitable for log output + """ + normalized_message = message.strip() + if len(normalized_message) == 0: + return "No content" + return normalized_message + + +def build_echo_channel_log_payload( + config: munch.Munch, message: str +) -> dict[str, str | list[str]] | None: + """Builds the payload needed by the logger extension for /echo channel. - await auxiliary.send_confirm_embed(message="Message sent", channel=ctx.channel) + Args: + config (munch.Munch): The guild config for the channel where the echo happened + message (str): The message that was echoed + + Returns: + dict[str, str | list[str]] | None: A prepared payload for logger calls. + Returns None when the logger extension is disabled. + """ + enabled_extensions = set(config.enabled_extensions) + if "logger" not in enabled_extensions: + return None + + return { + "content_override": normalize_echo_message(message), + "special_flags": ["Echo command"], + } diff --git a/techsupport_bot/commands/google.py b/techsupport_bot/commands/google.py index 259e222b3..179bac403 100644 --- a/techsupport_bot/commands/google.py +++ b/techsupport_bot/commands/google.py @@ -4,10 +4,11 @@ from typing import TYPE_CHECKING, Self +import discord import munch import ui from core import auxiliary, cogs, extensionconfig -from discord.ext import commands +from discord import app_commands if TYPE_CHECKING: import bot @@ -44,6 +45,167 @@ async def setup(bot: bot.TechSupportBot) -> None: bot.add_extension_config("google", config) +def build_google_search_params( + cse_key: str, api_key: str, query: str +) -> dict[str, str]: + """Builds request params for google web search. + + Args: + cse_key (str): Google custom search engine key + api_key (str): Google API key + query (str): Search query + + Returns: + dict[str, str]: Prepared parameters for the Google search API + """ + return {"cx": cse_key, "q": query, "key": api_key} + + +def build_google_image_params(cse_key: str, api_key: str, query: str) -> dict[str, str]: + """Builds request params for google image search. + + Args: + cse_key (str): Google custom search engine key + api_key (str): Google API key + query (str): Search query + + Returns: + dict[str, str]: Prepared parameters for the Google image API + """ + params = build_google_search_params(cse_key, api_key, query) + params["searchType"] = "image" + return params + + +def build_youtube_params(api_key: str, query: str) -> dict[str, str]: + """Builds request params for youtube search. + + Args: + api_key (str): Google API key + query (str): Search query + + Returns: + dict[str, str]: Prepared parameters for the YouTube search API + """ + return {"q": query, "key": api_key, "type": "video"} + + +def extract_google_search_fields( + items: list[munch.Munch], query: str +) -> list[tuple[str, str]]: + """Extracts link/snippet field data from Google search items. + + Args: + items (list[munch.Munch]): Search response items from Google API + query (str): The original query for fallback values + + Returns: + list[tuple[str, str]]: Tuple list of link/snippet field values + """ + fields = [] + for item in items: + link = item.get("link") + if not link: + continue + snippet = item.get("snippet", f"No details available for {query}") + cleaned_snippet = snippet.replace("\n", "") + fields.append((link, cleaned_snippet)) + + return fields + + +def chunk_search_fields( + fields: list[tuple[str, str]], max_per_page: int +) -> list[list[tuple[str, str]]]: + """Splits search field tuples into embed-sized chunks. + + Args: + fields (list[tuple[str, str]]): Link/snippet tuple list + max_per_page (int): Maximum number of fields per chunk + + Returns: + list[list[tuple[str, str]]]: Chunked fields for paginated embeds + """ + chunks = [] + normalized_max = max(1, max_per_page) + current_chunk = [] + + for field in fields: + current_chunk.append(field) + if len(current_chunk) == normalized_max: + chunks.append(current_chunk) + current_chunk = [] + + if len(current_chunk) > 0: + chunks.append(current_chunk) + + return chunks + + +def extract_image_links(items: list[munch.Munch]) -> list[str]: + """Extracts valid image links from Google image response items. + + Args: + items (list[munch.Munch]): Image search response items + + Returns: + list[str]: Valid image links + """ + links = [] + for item in items: + link = item.get("link") + if not link: + continue + links.append(link) + + return links + + +def extract_youtube_links(items: list[munch.Munch]) -> list[str]: + """Extracts valid youtube links from YouTube response items. + + Args: + items (list[munch.Munch]): YouTube search response items + + Returns: + list[str]: Valid youtube links built from video IDs + """ + links = [] + for item in items: + video_id = item.get("id", {}).get("videoId") + if not video_id: + continue + links.append(f"http://youtu.be/{video_id}") + + return links + + +def build_no_results_message(kind: str, query: str) -> str: + """Builds no-result messages based on search kind. + + Args: + kind (str): Search type, one of search/image/video + query (str): Original query + + Returns: + str: User-facing no-result message + """ + if kind == "image": + return f"No image search results found for: *{query}*" + if kind == "video": + return f"No video results found for: *{query}*" + return f"No search results found for: *{query}*" + + +def build_google_parse_error_message() -> str: + """Builds a standardized parsing failure message. + + Returns: + str: User-facing parse error message + """ + return "I had an issue processing Google's response... try again later!" + + class Googler(cogs.BaseCog): """Class for the google extension for the discord bot. @@ -77,163 +239,145 @@ async def get_items( response = await self.bot.http_functions.http_call( "get", url, params=data, use_cache=True ) - return response.get("items") + return response.get("items", []) - @commands.group( - aliases=["g", "G"], - brief="Executes a Google command", - description="Executes a Google command", + google_group: app_commands.Group = app_commands.Group( + name="google", + description="Command Group for Google Search", + extras={"module": "google"}, ) - async def google(self: Self, ctx: commands.Context) -> None: - """The bare .g/G command. This does nothing but generate the help message - - Args: - ctx (commands.Context): The context in which the command was run in - """ - return - @auxiliary.with_typing - @commands.guild_only() - @google.command( - aliases=["s", "S"], - brief="Searches Google", + @google_group.command( + name="search", description="Returns the top Google search result", - usage="[query]", + extras={"module": "google"}, ) - async def search(self: Self, ctx: commands.Context, *, query: str) -> None: + async def search(self: Self, interaction: discord.Interaction, query: str) -> None: """The entry point for the URL search command Args: - ctx (commands.Context): The context in which the command was run in + interaction (discord.Interaction): The interaction in which the command was run in query (str): The user inputted string to query google for """ - data = { - "cx": self.bot.file_config.api.api_keys.google_cse, - "q": query, - "key": self.bot.file_config.api.api_keys.google, - } + await interaction.response.defer(ephemeral=False) + data = build_google_search_params( + cse_key=self.bot.file_config.api.api_keys.google_cse, + api_key=self.bot.file_config.api.api_keys.google, + query=query, + ) items = await self.get_items(self.GOOGLE_URL, data) if not items: - await auxiliary.send_deny_embed( - message=f"No search results found for: *{query}*", channel=ctx.channel + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + build_no_results_message("search", query) + ), + ephemeral=True, ) return - config = self.bot.guild_configs[str(ctx.guild.id)] + config = self.bot.guild_configs[str(interaction.guild.id)] + fields = extract_google_search_fields(items, query) + if not fields: + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(build_google_parse_error_message()), + ephemeral=True, + ) + return - embed = None + chunks = chunk_search_fields( + fields, config.extensions.google.max_responses.value + ) embeds = [] - if not getattr(ctx, "image_search", None): - field_counter = 1 - for index, item in enumerate(items): - link = item.get("link") - snippet = item.get("snippet", "
").replace("\n", "") - embed = ( - auxiliary.generate_basic_embed( - title=f"Results for {query}", url=self.ICON_URL - ) - if field_counter == 1 - else embed - ) - + for chunk in chunks: + embed = auxiliary.generate_basic_embed( + title=f"Results for {query}", url=self.ICON_URL + ) + for link, snippet in chunk: embed.add_field(name=link, value=snippet, inline=False) - if ( - field_counter == config.extensions.google.max_responses.value - or index == len(items) - 1 - ): - embeds.append(embed) - field_counter = 1 - else: - field_counter += 1 - - await ui.PaginateView().send(ctx.channel, ctx.author, embeds) - - @auxiliary.with_typing - @commands.guild_only() - @google.command( - aliases=["i", "is", "I", "IS"], - brief="Searches Google Images", + embeds.append(embed) + + await ui.PaginateView().send( + interaction.channel, interaction.user, embeds, interaction + ) + + @google_group.command( + name="images", description="Returns the top Google Images search result", - usage="[query]", + extras={"module": "google"}, ) - async def images(self: Self, ctx: commands.Context, *, query: str) -> None: + async def images(self: Self, interaction: discord.Interaction, query: str) -> None: """The entry point for the image search command Args: - ctx (commands.Context): The context in which the command was run in + interaction (discord.Interaction): The interaction in which the command was run in query (str): The user inputted string to query google for """ - data = { - "cx": self.bot.file_config.api.api_keys.google_cse, - "q": query, - "key": self.bot.file_config.api.api_keys.google, - "searchType": "image", - } + await interaction.response.defer(ephemeral=False) + data = build_google_image_params( + cse_key=self.bot.file_config.api.api_keys.google_cse, + api_key=self.bot.file_config.api.api_keys.google, + query=query, + ) items = await self.get_items(self.GOOGLE_URL, data) if not items: - await auxiliary.send_deny_embed( - message=f"No image search results found for: *{query}*", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + build_no_results_message("image", query) + ), + ephemeral=True, ) return - embeds = [] - for item in items: - link = item.get("link") - if not link: - await auxiliary.send_deny_embed( - message=( - "I had an issue processing Google's response... try again" - " later!" - ), - channel=ctx.channel, - ) - return - embeds.append(link) - - await ui.PaginateView().send(ctx.channel, ctx.author, embeds) - - @auxiliary.with_typing - @commands.guild_only() - @commands.command( - aliases=["yt", "YT"], - brief="Searches YouTube", + links = extract_image_links(items) + if not links: + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(build_google_parse_error_message()), + ephemeral=True, + ) + return + + await ui.PaginateView().send( + interaction.channel, interaction.user, links, interaction + ) + + @app_commands.command( + name="youtube", description="Returns the top YouTube search result", - usage="[query]", + extras={"module": "google"}, ) - async def youtube(self: Self, ctx: commands.Context, *, query: str) -> None: + async def youtube(self: Self, interaction: discord.Interaction, query: str) -> None: """The entry point for the youtube search command Args: - ctx (commands.Context): The context in which the command was run in + interaction (discord.Interaction): The interaction in which the command was run in query (str): The user inputted string to query google for """ + await interaction.response.defer(ephemeral=False) items = await self.get_items( self.YOUTUBE_URL, - data={ - "q": query, - "key": self.bot.file_config.api.api_keys.google, - "type": "video", - }, + data=build_youtube_params(self.bot.file_config.api.api_keys.google, query), ) if not items: - await auxiliary.send_deny_embed( - message=f"No video results found for: *{query}*", channel=ctx.channel + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + build_no_results_message("video", query) + ), + ephemeral=True, ) return - video_id = items[0].get("id", {}).get("videoId") - link = f"http://youtu.be/{video_id}" - - links = [] - for item in items: - video_id = item.get("id", {}).get("videoId") - link = f"http://youtu.be/{video_id}" if video_id else None - if link: - links.append(link) + links = extract_youtube_links(items) + if not links: + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(build_google_parse_error_message()), + ephemeral=True, + ) + return - await ui.PaginateView().send(ctx.channel, ctx.author, links) + await ui.PaginateView().send( + interaction.channel, interaction.user, links, interaction + ) diff --git a/techsupport_bot/commands/hangman.py b/techsupport_bot/commands/hangman.py index aafef1522..1e4b97c35 100644 --- a/techsupport_bot/commands/hangman.py +++ b/techsupport_bot/commands/hangman.py @@ -16,6 +16,9 @@ import bot +MAX_START_WORD_LENGTH = 84 + + async def setup(bot: bot.TechSupportBot) -> None: """Loading the HangMan plugin into the bot @@ -262,48 +265,240 @@ def add_guesses(self: Self, num_guesses: int) -> None: self.max_guesses += num_guesses -async def can_stop_game(ctx: commands.Context) -> bool: +def normalize_secret_word(word: str) -> str: + """Normalizes a word used for new game creation. + + Args: + word (str): The raw word supplied by a user + + Returns: + str: The normalized lower-case word without surrounding whitespace """ - Checks if a user has the ability to stop the running game + return word.strip().lower() + + +def validate_start_word_input( + word: str, max_length: int = MAX_START_WORD_LENGTH +) -> str | None: + """Checks a start word for length and alphabetical requirements. Args: - ctx (commands.Context): The context in which the stop command was run + word (str): The potential secret word for a new game + max_length (int, optional): The max allowed word length. Defaults to 84. - Raises: - AttributeError: The Hangman game could not be found - CommandError: No admin roles have been defined in the config - MissingAnyRole: The doesn't have the admin roles needed to stop the game + Returns: + str | None: A deny reason if invalid, otherwise None + """ + normalized_word = normalize_secret_word(word) + + if len(normalized_word) == 0: + return "A word must be provided" + + if len(normalized_word) > max_length: + return f"The word must be {max_length} characters or fewer" + + if not normalized_word.isalpha() or "_" in normalized_word: + return "The word can only contain letters" + + return None + + +def validate_letter_guess_input(letter: str) -> str | None: + """Validates a single-letter guess. + + Args: + letter (str): The guessed letter + + Returns: + str | None: A deny reason if invalid, otherwise None + """ + normalized_letter = letter.strip() + + if len(normalized_letter) != 1: + return "You can only guess a single letter" + + if not normalized_letter.isalpha(): + return "You can only guess alphabetic letters" + + return None + + +def validate_solve_guess_input( + word: str, max_length: int = MAX_START_WORD_LENGTH +) -> str | None: + """Validates a full-word solve guess. + + Args: + word (str): The proposed full-word answer + max_length (int, optional): The max allowed guess length. Defaults to 84. Returns: - bool: True the user can stop the game, False they cannot + str | None: A deny reason if invalid, otherwise None """ - cog = ctx.bot.get_cog("HangmanCog") - if not cog: - raise AttributeError("could not find hangman cog when checking game states") + normalized_word = normalize_secret_word(word) - game_data = cog.games.get(ctx.channel.id) - if not game_data: - return True + if len(normalized_word) == 0: + return "A word must be provided" - user = game_data.get("user") - if getattr(user, "id", 0) == ctx.author.id: - return True + if len(normalized_word) > max_length: + return f"The guessed word must be {max_length} characters or fewer" - config = ctx.bot.guild_configs[str(ctx.guild.id)] - roles = [] - for role_name in config.extensions.hangman.hangman_roles.value: - role = discord.utils.get(ctx.guild.roles, name=role_name) - if not role: - continue - roles.append(role) + if not normalized_word.isalpha(): + return "The guessed word can only contain letters" - if not roles: - raise commands.CommandError("no hangman admin roles found") + return None - if not any(role in ctx.author.roles for role in roles): - raise commands.MissingAnyRole(roles) - return True +def decide_start_conflict(caller_id: int, owner_id: int) -> str: + """Determines what action should happen when a game already exists. + + Args: + caller_id (int): The user id of the command caller + owner_id (int): The user id of the existing game owner + + Returns: + str: "confirm-overwrite" when caller is owner, otherwise "deny" + """ + if caller_id == owner_id: + return "confirm-overwrite" + + return "deny" + + +def evaluate_solve_attempt(secret_word: str, guess_word: str) -> bool: + """Evaluates whether a full-word solve attempt is correct. + + Args: + secret_word (str): The secret game word + guess_word (str): The user submitted guess + + Returns: + bool: True when the guess matches the secret word, otherwise False + """ + normalized_secret = normalize_secret_word(secret_word) + normalized_guess = normalize_secret_word(guess_word) + return normalized_secret == normalized_guess + + +def build_letter_guess_result(letter: str, correct: bool) -> str: + """Builds a user-facing result message for a single-letter guess. + + Args: + letter (str): The guessed letter + correct (bool): Whether the guess was found in the word + + Returns: + str: The status message for the guess + """ + normalized_letter = letter.strip().lower() + if correct: + return f"Found `{normalized_letter}`" + + return f"Letter `{normalized_letter}` not in word" + + +def build_solve_result(guess_word: str, correct: bool) -> str: + """Builds a user-facing result message for a full-word solve guess. + + Args: + guess_word (str): The guessed word + correct (bool): Whether the full-word guess is correct + + Returns: + str: The status message for the guess + """ + normalized_word = normalize_secret_word(guess_word) + if correct: + return f"`{normalized_word}` is correct" + + return f"`{normalized_word}` is not the word" + + +def build_add_guesses_result(number_of_guesses: int, remaining_guesses: int) -> str: + """Builds a user-facing result message for adding more guesses. + + Args: + number_of_guesses (int): Number of guesses that were added + remaining_guesses (int): Remaining guesses after update + + Returns: + str: A formatted status message + """ + return ( + f"{number_of_guesses} guesses have been added! " + f"Total guesses remaining: {remaining_guesses}" + ) + + +def build_stop_permission_denial( + caller_id: int, + owner_id: int | None, + configured_role_names: list[str], + caller_role_names: list[str], +) -> str | None: + """Determines if a non-owner is allowed to stop a running game. + + Args: + caller_id (int): The user id of the command caller + owner_id (int | None): The owner id for the active game + configured_role_names (list[str]): Role names from hangman config + caller_role_names (list[str]): Role names that caller currently has + + Returns: + str | None: Deny reason if caller cannot stop the game, otherwise None + """ + if owner_id is not None and caller_id == owner_id: + return None + + filtered_config_roles = { + name.strip().lower() for name in configured_role_names if len(name.strip()) > 0 + } + if len(filtered_config_roles) == 0: + return "No hangman admin roles are configured" + + normalized_user_roles = { + name.strip().lower() for name in caller_role_names if len(name.strip()) > 0 + } + + if filtered_config_roles.isdisjoint(normalized_user_roles): + return "You are not allowed to stop this game" + + return None + + +def build_game_display_data( + game: HangmanGame, owner: discord.Member +) -> dict[str, object]: + """Builds display fields for a hangman embed. + + Args: + game (HangmanGame): The hangman game to render + owner (discord.Member): The user that started the game + + Returns: + dict[str, object]: String fields and style data for embed rendering + """ + help_text = "Use /hangman commands to keep playing" + display_data = { + "title": f"`{game.draw_word_state()}`", + "description": f"{help_text}\n\n```{game.draw_hang_state()}```", + "remaining_guesses": game.remaining_guesses(), + "guessed_letters": ", ".join(sorted(game.guesses)) or "None", + } + + if game.failed: + display_data["color"] = discord.Color.red() + display_data["footer"] = f"Game over! The word was `{game.word}`!" + return display_data + + if game.finished: + display_data["color"] = discord.Color.green() + display_data["footer"] = "Word guessed! Nice job!" + return display_data + + display_data["color"] = discord.Color.gold() + display_data["footer"] = f"Game started by {owner}" + return display_data class HangmanCog(cogs.BaseCog): @@ -322,18 +517,6 @@ def __init__(self: Self, bot: commands.Bot) -> None: super().__init__(bot) self.games = {} - @commands.guild_only() - @commands.group( - name="hangman", description="Runs a hangman command", aliases=["hm"] - ) - async def hangman(self: Self, ctx: commands.Context) -> None: - """The bare .hangman command. This does nothing but generate the help message - - Args: - ctx (commands.Context): The context in which the command was run in - """ - return - hangman_app_group: app_commands.Group = app_commands.Group( name="hangman", description="Command Group for the Hangman Extension" ) @@ -352,22 +535,21 @@ async def start_game( interaction (discord.Interaction): The interaction object from Discord. word (str): The word to start the Hangman game with. """ - # Ensure only the command's author can see this interaction - await interaction.response.defer(ephemeral=True) + await interaction.response.defer(ephemeral=False) - # Check if the provided word is too long - if len(word) >= 85: - await interaction.followup.send( - "The word must be less than 256 characters.", ephemeral=True - ) + invalid_word_message = validate_start_word_input(word) + if invalid_word_message: + await interaction.followup.send(invalid_word_message) return - # Check if a game is already active in the channel + normalized_word = normalize_secret_word(word) + game_data = self.games.get(interaction.channel_id) if game_data: - # Check if the game owner wants to overwrite the current game - user = game_data.get("user") - if user.id == interaction.user.id: + owner = game_data.get("user") + conflict_action = decide_start_conflict(interaction.user.id, owner.id) + + if conflict_action == "confirm-overwrite": view = ui.Confirm() await view.send( message="There is a current game in progress. Do you want to end it?", @@ -379,28 +561,23 @@ async def start_game( ui.ConfirmResponse.TIMEOUT, ui.ConfirmResponse.DENIED, ]: - await interaction.followup.send( - "The current game was not ended.", ephemeral=True - ) + await interaction.followup.send("The current game was not ended.") return - # Remove the existing game del self.games[interaction.channel_id] else: await interaction.followup.send( - "A game is already in progress for this channel.", ephemeral=True + "A game is already in progress for this channel." ) return - # Validate the provided word try: - game = HangmanGame(word=word.lower()) - except ValueError as e: - await interaction.followup.send(f"Invalid word: {e}", ephemeral=True) + game = HangmanGame(word=normalized_word) + except ValueError as exception: + await interaction.followup.send(f"Invalid word: {exception}") return - # Create and send the initial game embed - embed = await self.generate_game_embed(interaction, game, interaction.user) + embed = await self.generate_game_embed(game, interaction.user) message = await interaction.channel.send(embed=embed) self.games[interaction.channel_id] = { "user": interaction.user, @@ -410,143 +587,182 @@ async def start_game( } await interaction.followup.send( - "The Hangman game has started with a hidden word!", ephemeral=True + "The Hangman game has started with a hidden word!" ) - @hangman.command( + @hangman_app_group.command( name="guess", description="Guesses a letter for the current hangman game", - usage="[letter]", + extras={"module": "hangman"}, ) - async def guess(self: Self, ctx: commands.Context, letter: str) -> None: - """Discord command to guess a letter in a running hangman game + async def guess(self: Self, interaction: discord.Interaction, letter: str) -> None: + """Guesses a letter in a running hangman game. Args: - ctx (commands.Context): The context in which the command was run in - letter (str): The letter the user is trying to guess + interaction (discord.Interaction): The interaction that called this command + letter (str): The guessed letter """ - game_data = self.games.get(ctx.channel.id) + await interaction.response.defer(ephemeral=False) + + game_data = self.games.get(interaction.channel.id) if not game_data: - await auxiliary.send_deny_embed( - message="There is no game in progress for this channel", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "There is no game in progress for this channel" + ) ) return - if ctx.author == game_data.get("user"): - await auxiliary.send_deny_embed( - message="You cannot guess letters because you started this game!", - channel=ctx.channel, + if interaction.user == game_data.get("user"): + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "You cannot guess letters because you started this game!" + ) ) return - if len(letter) > 1 or not letter.isalpha(): - await auxiliary.send_deny_embed( - message="You can only guess a letter", channel=ctx.channel + invalid_guess_message = validate_letter_guess_input(letter) + if invalid_guess_message: + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(invalid_guess_message) ) return game = game_data.get("game") - if game.guessed(letter): - await auxiliary.send_deny_embed( - message="That letter has already been guessed", channel=ctx.channel + normalized_letter = letter.strip().lower() + if game.guessed(normalized_letter): + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "That letter has already been guessed" + ) ) return - correct = game.guess(letter) - embed = await self.generate_game_embed(ctx, game, game_data.get("user")) + correct = game.guess(normalized_letter) + embed = await self.generate_game_embed(game, game_data.get("user")) + message = game_data.get("message") + await message.edit(embed=embed) + + content = build_letter_guess_result(normalized_letter, correct) + if game.finished: + content = f"{content} - game finished! The word was {game.word}" + del self.games[interaction.channel.id] + await interaction.followup.send(content=content) + + @hangman_app_group.command( + name="solve", + description="Guesses the full word for the current hangman game", + extras={"module": "hangman"}, + ) + async def solve(self: Self, interaction: discord.Interaction, word: str) -> None: + """Attempts to solve a running hangman game with a full-word guess. + + Args: + interaction (discord.Interaction): The interaction that called this command + word (str): The full-word guess + """ + await interaction.response.defer(ephemeral=False) + + game_data = self.games.get(interaction.channel.id) + if not game_data: + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "There is no game in progress for this channel" + ) + ) + return + + if interaction.user == game_data.get("user"): + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "You cannot guess the word because you started this game!" + ) + ) + return + + invalid_guess_message = validate_solve_guess_input(word) + if invalid_guess_message: + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(invalid_guess_message) + ) + return + + game = game_data.get("game") + normalized_word = normalize_secret_word(word) + correct = evaluate_solve_attempt(game.word, normalized_word) + + if correct: + for letter in game.word: + game.guesses.add(letter.lower()) + else: + game.step += 1 + + embed = await self.generate_game_embed(game, game_data.get("user")) message = game_data.get("message") await message.edit(embed=embed) - content = f"Found `{letter}`" if correct else f"Letter `{letter}` not in word" + content = build_solve_result(normalized_word, correct) if game.finished: content = f"{content} - game finished! The word was {game.word}" - del self.games[ctx.channel.id] - await ctx.send(content=content) + del self.games[interaction.channel.id] + await interaction.followup.send(content=content) async def generate_game_embed( self: Self, - ctx_or_interaction: discord.Interaction | commands.Context, game: HangmanGame, owner: discord.Member, ) -> discord.Embed: - """ - Generates an embed representing the current state of the Hangman game. + """Generates an embed representing the current state of the Hangman game. Args: - ctx_or_interaction (discord.Interaction | commands.Context): - The context or interaction used to generate the embed, which provides - information about the user and the message. - game (HangmanGame): The current instance of the Hangman game, used to - retrieve game state, including word state, remaining guesses, and the - hangman drawing. + game (HangmanGame): The current game to render owner (discord.Member): The owner of the game Returns: - discord.Embed: An embed displaying the current game state, including - the hangman drawing, word state, remaining guesses, guessed letters, - and the footer indicating the game status and creator. + discord.Embed: The embed for the current game state """ - hangman_drawing = game.draw_hang_state() - hangman_word = game.draw_word_state() - # Determine the guild ID - guild_id = None - if isinstance(ctx_or_interaction, commands.Context): - guild_id = ctx_or_interaction.guild.id if ctx_or_interaction.guild else None - elif isinstance(ctx_or_interaction, discord.Interaction): - guild_id = ctx_or_interaction.guild_id - - # Fetch the prefix manually since get_prefix expects a Message - if guild_id and str(guild_id) in self.bot.guild_configs: - prefix = self.bot.guild_configs[str(guild_id)].command_prefix - else: - prefix = self.file_config.bot_config.default_prefix + display_data = build_game_display_data(game, owner) embed = discord.Embed( - title=f"`{hangman_word}`", - description=( - f"Type `{prefix}help hangman` for more info\n\n" - f"```{hangman_drawing}```" - ), + title=display_data["title"], + description=display_data["description"], + color=display_data["color"], ) - if game.failed: - embed.color = discord.Color.red() - footer_text = f"Game over! The word was `{game.word}`!" - elif game.finished: - embed.color = discord.Color.green() - footer_text = "Word guessed! Nice job!" - else: - embed.color = discord.Color.gold() + if not game.finished: embed.add_field( - name=f"Remaining Guesses {str(game.remaining_guesses())}", + name=f"Remaining Guesses {str(display_data['remaining_guesses'])}", value="\u200b", inline=False, ) embed.add_field( name="Guessed Letters", - value=", ".join(game.guesses) or "None", + value=display_data["guessed_letters"], inline=False, ) - footer_text = f"Game started by {owner}" - - embed.set_footer(text=footer_text) + embed.set_footer(text=display_data["footer"]) return embed - @hangman.command(name="redraw", description="Redraws the current hangman game") - async def redraw(self: Self, ctx: commands.Context) -> None: - """A discord command to make a new embed with a new drawing of the running hangman game - This redraws the hangman game being played in the current channel + @hangman_app_group.command( + name="redraw", + description="Redraws the current hangman game", + extras={"module": "hangman"}, + ) + async def redraw(self: Self, interaction: discord.Interaction) -> None: + """Makes a new embed with a new drawing of the running hangman game. Args: - ctx (commands.Context): The context in which the command was in + interaction (discord.Interaction): The interaction in which the command was used """ - game_data = self.games.get(ctx.channel.id) + await interaction.response.defer(ephemeral=False) + + game_data = self.games.get(interaction.channel.id) if not game_data: - await auxiliary.send_deny_embed( - message="There is no game in progress for this channel", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "There is no game in progress for this channel" + ) ) return @@ -557,104 +773,128 @@ async def redraw(self: Self, ctx: commands.Context) -> None: pass embed = await self.generate_game_embed( - ctx, game_data.get("game"), game_data.get("user") + game_data.get("game"), game_data.get("user") ) - new_message = await ctx.send(embed=embed) + new_message = await interaction.channel.send(embed=embed) game_data["message"] = new_message - @commands.check(can_stop_game) - @hangman.command(name="stop", description="Stops the current channel game") - async def stop(self: Self, ctx: commands.Context) -> None: - """Checks if a user can stop the hangman game in the current channel - If they can, the game is stopped + await interaction.followup.send("The game board has been redrawn") + + @hangman_app_group.command( + name="stop", + description="Stops the current channel game", + extras={"module": "hangman"}, + ) + async def stop(self: Self, interaction: discord.Interaction) -> None: + """Stops the running hangman game in the current channel. Args: - ctx (commands.Context): The context in which the command was run in + interaction (discord.Interaction): The interaction in which stop was run """ - game_data = self.games.get(ctx.channel.id) + await interaction.response.defer(ephemeral=False) + + game_data = self.games.get(interaction.channel.id) if not game_data: - await auxiliary.send_deny_embed( - message="There is no game in progress for this channel", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "There is no game in progress for this channel" + ) + ) + return + + game_owner = game_data.get("user") + config = self.bot.guild_configs[str(interaction.guild.id)] + configured_roles = config.extensions.hangman.hangman_roles.value + caller_roles = [role.name for role in getattr(interaction.user, "roles", [])] + + deny_reason = build_stop_permission_denial( + caller_id=interaction.user.id, + owner_id=getattr(game_owner, "id", None), + configured_role_names=configured_roles, + caller_role_names=caller_roles, + ) + if deny_reason: + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed(deny_reason) ) return view = ui.Confirm() await view.send( message="Are you sure you want to end the current game?", - channel=ctx.channel, - author=ctx.author, + channel=interaction.channel, + author=interaction.user, ) await view.wait() if view.value is ui.ConfirmResponse.TIMEOUT: return if view.value is ui.ConfirmResponse.DENIED: - await auxiliary.send_deny_embed( - "The current game was not ended", channel=ctx.channel + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed("The current game was not ended") ) return game = game_data.get("game") word = getattr(game, "word", "???") - del self.games[ctx.channel.id] - await auxiliary.send_confirm_embed( - message=f"That game is now finished. The word was: `{word}`", - channel=ctx.channel, + del self.games[interaction.channel.id] + await interaction.followup.send( + embed=auxiliary.prepare_confirm_embed( + f"That game is now finished. The word was: `{word}`" + ) ) - @hangman.command( - name="add_guesses", + @hangman_app_group.command( + name="add-guesses", description="Allows the creator of the game to give more guesses", - usage="[number_of_guesses]", + extras={"module": "hangman"}, ) async def add_guesses( - self: Self, ctx: commands.Context, number_of_guesses: int + self: Self, interaction: discord.Interaction, number_of_guesses: int ) -> None: - """Discord command to allow the game creator to add more guesses. + """Allows the game creator to add more guesses. Args: - ctx (commands.Context): The context in which the command was run. - number_of_guesses (int): The number of guesses to add. + interaction (discord.Interaction): The interaction in which the command was run + number_of_guesses (int): The number of guesses to add """ + await interaction.response.defer(ephemeral=False) + if number_of_guesses <= 0: - await auxiliary.send_deny_embed( - message="The number of guesses must be a positive integer.", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "The number of guesses must be a positive integer." + ) ) return - game_data = self.games.get(ctx.channel.id) + game_data = self.games.get(interaction.channel.id) if not game_data: - await auxiliary.send_deny_embed( - message="There is no game in progress for this channel", - channel=ctx.channel, + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "There is no game in progress for this channel" + ) ) return - # Ensure only the creator of the game can add guesses game_author = game_data.get("user") - if ctx.author.id != game_author.id: - await auxiliary.send_deny_embed( - message="Only the creator of the game can add more guesses.", - channel=ctx.channel, + if interaction.user.id != game_author.id: + await interaction.followup.send( + embed=auxiliary.prepare_deny_embed( + "Only the creator of the game can add more guesses." + ) ) return game = game_data.get("game") - - # Add the new guesses game.add_guesses(number_of_guesses) - # Notify the channel - await ctx.send( - content=( - f"{number_of_guesses} guesses have been added! " - f"Total guesses remaining: {game.remaining_guesses()}" - ) - ) - - # Update the game embed - embed = await self.generate_game_embed(ctx, game, game_data.get("user")) + embed = await self.generate_game_embed(game, game_data.get("user")) message = game_data.get("message") await message.edit(embed=embed) + + await interaction.followup.send( + content=build_add_guesses_result( + number_of_guesses, game.remaining_guesses() + ) + ) diff --git a/techsupport_bot/commands/hug.py b/techsupport_bot/commands/hug.py index 2c6f6df57..8a3c6a347 100644 --- a/techsupport_bot/commands/hug.py +++ b/techsupport_bot/commands/hug.py @@ -7,7 +7,7 @@ import discord from core import auxiliary, cogs -from discord.ext import commands +from discord import app_commands if TYPE_CHECKING: import bot @@ -22,6 +22,89 @@ async def setup(bot: bot.TechSupportBot) -> None: await bot.add_cog(Hugger(bot=bot)) +def is_valid_hug_target(author_id: int, target_id: int) -> bool: + """Checks whether a hug target is valid for the invoking user. + + Args: + author_id (int): The ID of the user sending the hug + target_id (int): The ID of the target user + + Returns: + bool: True if the hug can proceed, False otherwise + """ + return author_id != target_id + + +def normalize_hug_templates(templates: list[str]) -> list[str]: + """Normalizes hug templates by trimming and removing empty items. + + Args: + templates (list[str]): Raw hug phrase templates + + Returns: + list[str]: Cleaned and usable templates + """ + normalized_templates = [] + for template in templates: + normalized_template = template.strip() + if len(normalized_template) == 0: + continue + normalized_templates.append(normalized_template) + return normalized_templates + + +def pick_hug_template(templates: list[str]) -> str | None: + """Picks a hug template from a normalized template list. + + Args: + templates (list[str]): Normalized hug templates + + Returns: + str | None: Picked template if available, otherwise None + """ + if len(templates) == 0: + return None + return random.choice(templates) + + +def build_hug_phrase(template: str, author_mention: str, target_mention: str) -> str: + """Builds the final hug phrase from a template and mentions. + + Args: + template (str): The selected hug template + author_mention (str): Mention string of the user giving the hug + target_mention (str): Mention string of the user receiving the hug + + Returns: + str: The fully formatted hug phrase + """ + return template.format( + user_giving_hug=author_mention, + user_to_hug=target_mention, + ) + + +def build_hug_failure_message() -> str: + """Builds the deny message for invalid hug actions. + + Returns: + str: A user-facing deny message + """ + return "Let's be serious" + + +def build_hug_embed_data(hug_text: str) -> dict[str, str]: + """Builds display data used to generate a hug embed. + + Args: + hug_text (str): The generated hug phrase + + Returns: + dict[str, str]: Embed title and description values + """ + return {"title": "You've been hugged!", "description": hug_text} + + class Hugger(cogs.BaseCog): """Class to make the hug command. @@ -48,7 +131,7 @@ class Hugger(cogs.BaseCog): "{user_giving_hug} tackles {user_to_hug} to the ground with a giant glomp", "{user_giving_hug} hugs {user_to_hug} and gives them Eskimo kisses", ( - "{user_giving_hug} grabs {user_to_hug} ,pulls them close," + "{user_giving_hug} grabs {user_to_hug}, pulls them close," " giving them three hearty raps on the back" ), "{user_giving_hug} hugs {user_to_hug} and rubs their back slowly", @@ -61,96 +144,87 @@ class Hugger(cogs.BaseCog): + "a5/Noto_Emoji_Oreo_1f917.svg/768px-Noto_Emoji_Oreo_1f917.svg.png" ) - @auxiliary.with_typing - @commands.guild_only() - @commands.command( + def __init__(self: Self, bot: bot.TechSupportBot) -> None: + """Initializes hug commands and registers the user context menu. + + Args: + bot (bot.TechSupportBot): The bot instance + """ + super().__init__(bot=bot) + self.user_context_menu = app_commands.ContextMenu( + name="Hug User", + callback=self.hug_user_context, + extras={"module": "hug"}, + ) + if getattr(self.bot, "tree", None): + self.bot.tree.add_command(self.user_context_menu) + + @app_commands.command( name="hug", - brief="Hugs a user", description="Hugs a mentioned user using an embed", - usage="@user", + extras={"module": "hug"}, ) async def hug( - self: Self, ctx: commands.Context, user_to_hug: discord.Member = None + self: Self, interaction: discord.Interaction, user_to_hug: discord.Member ) -> None: - """The .hug discord command function + """Slash command to hug another user. Args: - ctx (commands.Context): The context in which the command was run in + interaction (discord.Interaction): The interaction in which the command was run user_to_hug (discord.Member): The user to hug """ - if user_to_hug is None: - # check if the message is a reply - if ctx.message.reference is None: - await auxiliary.send_deny_embed( - message="You need to mention someone to hug", channel=ctx.channel - ) - return - - user_to_hug = ctx.message.reference.resolved.author - - await self.hug_command(ctx, user_to_hug) - - def check_hug_eligibility( - self: Self, - author: discord.Member, - user_to_hug: discord.Member, - ) -> bool: - """Checks to see if the hug is allowed - Checks to see if the author and target match - - Args: - author (discord.Member): The author of the hug command - user_to_hug (discord.Member): The user to hug + await self.hug_command_base(interaction, user_to_hug) - Returns: - bool: True if the command should proceed, false if it shouldn't - """ - if user_to_hug == author: - return False - return True - - def generate_hug_phrase( - self: Self, author: discord.Member, user_to_hug: discord.Member - ) -> str: - """Generates a hug phrase from the HUGS_SELECTION variable + async def hug_user_context( + self: Self, interaction: discord.Interaction, user_to_hug: discord.Member + ) -> None: + """User context menu entry point for hugging. Args: - author (discord.Member): The author of the hug command + interaction (discord.Interaction): The interaction in which the command was run user_to_hug (discord.Member): The user to hug - - Returns: - str: The filled in hug str """ - hug_text = random.choice(self.HUGS_SELECTION).format( - user_giving_hug=author.mention, - user_to_hug=user_to_hug.mention, - ) - return hug_text + await self.hug_command_base(interaction, user_to_hug) - async def hug_command( - self: Self, ctx: commands.Context, user_to_hug: discord.Member + async def hug_command_base( + self: Self, interaction: discord.Interaction, user_to_hug: discord.Member ) -> None: - """The main logic for the hug command + """Shared processor for slash and context-menu hug commands. Args: - ctx (commands.Context): The context in which the command was run in + interaction (discord.Interaction): The interaction that triggered this command user_to_hug (discord.Member): The user to hug """ - if not self.check_hug_eligibility(ctx.author, user_to_hug): - await auxiliary.send_deny_embed( - message="Let's be serious", channel=ctx.channel + if not is_valid_hug_target(interaction.user.id, user_to_hug.id): + await interaction.response.send_message( + embed=auxiliary.prepare_deny_embed(build_hug_failure_message()), + ephemeral=True, + ) + return + + templates = normalize_hug_templates(self.HUGS_SELECTION) + template = pick_hug_template(templates) + if not template: + await interaction.response.send_message( + embed=auxiliary.prepare_deny_embed(build_hug_failure_message()), + ephemeral=True, ) return - hug_text = self.generate_hug_phrase(ctx.author, user_to_hug) + hug_text = build_hug_phrase( + template=template, + author_mention=interaction.user.mention, + target_mention=user_to_hug.mention, + ) + embed_data = build_hug_embed_data(hug_text) embed = auxiliary.generate_basic_embed( - title="You've been hugged!", - description=hug_text, + title=embed_data["title"], + description=embed_data["description"], color=discord.Color.blurple(), url=self.ICON_URL, ) - await ctx.send( + await interaction.response.send_message( embed=embed, content=auxiliary.construct_mention_string([user_to_hug]) ) diff --git a/techsupport_bot/commands/translate.py b/techsupport_bot/commands/translate.py index 36e72eb86..059dae1a6 100644 --- a/techsupport_bot/commands/translate.py +++ b/techsupport_bot/commands/translate.py @@ -4,8 +4,9 @@ from typing import TYPE_CHECKING, Self +import discord from core import auxiliary, cogs -from discord.ext import commands +from discord import app_commands if TYPE_CHECKING: import bot @@ -30,35 +31,144 @@ class Translator(cogs.BaseCog): API_URL: str = "https://api.mymemory.translated.net/get?q={}&langpair={}|{}" - @auxiliary.with_typing - @commands.command( - brief="Translates a message", + @app_commands.command( + name="translate", description="Translates a given input message to another language", - usage=( - '"[message (in quotes)]" [src language code (en)] [dest language code (es)]' - ), + extras={"module": "translate"}, ) async def translate( - self: Self, ctx: commands.Context, message: str, src: str, dest: str + self: Self, + interaction: discord.Interaction, + message: str, + src: str, + dest: str, ) -> None: """Translates user input into another language Args: - ctx (commands.Context): The context generated by running this command + interaction (discord.Interaction): The interaction generated by running this command message (str): The string to translate src (str): The language the message is currently in dest (str): The target language to translate to """ + normalized_message, normalized_src, normalized_dest = ( + normalize_translation_input(message, src, dest) + ) + invalid_input_message = validate_translation_inputs( + normalized_message, normalized_src, normalized_dest + ) + if invalid_input_message: + await interaction.response.send_message( + embed=auxiliary.prepare_deny_embed(invalid_input_message) + ) + return + + url = build_translate_url( + self.API_URL, normalized_message, normalized_src, normalized_dest + ) response = await self.bot.http_functions.http_call( "get", - self.API_URL.format(message, src, dest), + url, + use_app_error=True, ) - translated = response.get("responseData", {}).get("translatedText") + translated = extract_translated_text(response) if not translated: - await auxiliary.send_deny_embed( - message="I could not translate your message", channel=ctx.channel + await interaction.response.send_message( + embed=auxiliary.prepare_deny_embed(build_translation_failure_message()) ) return - await auxiliary.send_confirm_embed(message=translated, channel=ctx.channel) + await interaction.response.send_message( + embed=auxiliary.prepare_confirm_embed(translated) + ) + + +def normalize_translation_input( + message: str, src: str, dest: str +) -> tuple[str, str, str]: + """Normalizes translation command arguments. + + Args: + message (str): The message to translate + src (str): The source language code + dest (str): The destination language code + + Returns: + tuple[str, str, str]: The normalized message, source, and destination inputs + """ + normalized_message = message.strip() + normalized_src = src.strip().lower() + normalized_dest = dest.strip().lower() + + return normalized_message, normalized_src, normalized_dest + + +def validate_translation_inputs(message: str, src: str, dest: str) -> str | None: + """Validates translation inputs and returns a user-facing deny message if invalid. + + Args: + message (str): The message to translate + src (str): The source language code + dest (str): The destination language code + + Returns: + str | None: Deny message if invalid, otherwise None + """ + if len(message) == 0: + return "You need to provide a message to translate" + + if len(src) == 0: + return "You need to provide a source language code" + + if len(dest) == 0: + return "You need to provide a destination language code" + + return None + + +def build_translate_url( + api_url_template: str, message: str, src: str, dest: str +) -> str: + """Builds the final translation API URL from normalized input arguments. + + Args: + api_url_template (str): The API URL template for translation requests + message (str): The message to translate + src (str): The source language code + dest (str): The destination language code + + Returns: + str: The final URL to call for translation + """ + final_url = api_url_template.format(message, src, dest) + return final_url + + +def extract_translated_text(response: dict) -> str | None: + """Extracts translated text from translation API responses. + + Args: + response (dict): The API response payload + + Returns: + str | None: The translated text if present, otherwise None + """ + response_data = response.get("responseData") + if not response_data: + return None + + translated_text = response_data.get("translatedText") + if not translated_text: + return None + + return str(translated_text) + + +def build_translation_failure_message() -> str: + """Builds the standard failure message for translation attempts. + + Returns: + str: A user-facing failure message + """ + return "I could not translate your message" diff --git a/techsupport_bot/commands/weather.py b/techsupport_bot/commands/weather.py index fbfa310bd..c4cd43917 100644 --- a/techsupport_bot/commands/weather.py +++ b/techsupport_bot/commands/weather.py @@ -7,7 +7,7 @@ import discord import munch from core import auxiliary, cogs -from discord.ext import commands +from discord import app_commands if TYPE_CHECKING: import bot @@ -36,38 +36,16 @@ async def setup(bot: bot.TechSupportBot) -> None: class Weather(cogs.BaseCog): """Class to set up the weather extension for the discord bot.""" - def get_url(self: Self, args: list[str]) -> str: - """Generates the url to fill in API keys and data - - Args: - args (list[str]): The list of arguments passed by the user - - Returns: - str: The API url formatted and ready to be called - """ - filtered_args = filter(bool, args) - searches = ",".join(map(str, filtered_args)) - url = "http://api.openweathermap.org/data/2.5/weather" - filled_url = ( - f"{url}?q={searches}&units=imperial&appid" - f"={self.bot.file_config.api.api_keys.open_weather}" - ) - return filled_url - - @auxiliary.with_typing - @commands.command( - name="we", - aliases=["weather", "wea"], - brief="Searches for the weather", + @app_commands.command( + name="weather", description=( - "Returns the weather for a given area (this API sucks; I'm sorry in" - " advance)" + "Returns the weather for a given area with both Fahrenheit and Celsius" ), - usage="[city/town] [state-code] [country-code]", + extras={"module": "weather"}, ) async def weather( self: Self, - ctx: commands.Context, + interaction: discord.Interaction, city_name: str, state_code: str = None, country_code: str = None, @@ -76,26 +54,32 @@ async def weather( and sends a message to discord Args: - ctx (commands.Context): The context generated by running this command + interaction (discord.Interaction): The interaction generated by running this command city_name (str): For the API, the name of the city to get weather for state_code (str, optional): For the API, if applicable, the state code to search for. Defaults to None. country_code (str, optional): For the API, if needed you can add a country code to search for. Defaults to None. """ + query = build_weather_query(city_name, state_code, country_code) + url = build_weather_url( + query=query, + api_key=self.bot.file_config.api.api_keys.open_weather, + ) response = await self.bot.http_functions.http_call( - "get", self.get_url([city_name, state_code, country_code]) + "get", url, use_app_error=True ) embed = self.generate_embed(munch.munchify(response)) if not embed: - await auxiliary.send_deny_embed( - message="I could not find the weather from your search", - channel=ctx.channel, + await interaction.response.send_message( + embed=auxiliary.prepare_deny_embed( + "I could not find the weather from your search" + ) ) return - await ctx.send(embed=embed) + await interaction.response.send_message(embed=embed) def generate_embed(self: Self, response: munch.Munch) -> discord.Embed | None: """Creates an embed filled with weather data: @@ -111,38 +95,121 @@ def generate_embed(self: Self, response: munch.Munch) -> discord.Embed | None: Returns: discord.Embed | None: Either the formatted embed, or nothing if the API failed """ - try: - embed = discord.Embed( - title=f"Weather for {response.name} ({response.sys.country})" - ) + weather_fields = extract_weather_fields(response) + if not weather_fields: + return None - descriptions = ", ".join( - weather.description for weather in response.weather - ) - embed.add_field(name="Description", value=descriptions, inline=False) - - embed.add_field( - name="Temp (F)", - value=( - f"{int(response.main.temp)} (feels like" - f" {int(response.main.feels_like)})" - ), - inline=False, - ) - embed.add_field(name="Low (F)", value=int(response.main.temp_min)) - embed.add_field( - name="High (F)", - value=int(response.main.temp_max), - ) - embed.add_field(name="Humidity", value=f"{int(response.main.humidity)} %") - embed.set_thumbnail( - url=( - "https://www.iconarchive.com/download/i76758" - "/pixelkit/flat-jewels/Weather.512.png" - ) + embed = discord.Embed(title=weather_fields["title"]) + embed.add_field( + name="Description", value=weather_fields["description"], inline=False + ) + embed.add_field(name="Temp", value=weather_fields["temp"], inline=False) + embed.add_field(name="Low", value=weather_fields["low"]) + embed.add_field(name="High", value=weather_fields["high"]) + embed.add_field(name="Humidity", value=weather_fields["humidity"]) + embed.set_thumbnail( + url=( + "https://www.iconarchive.com/download/i76758" + "/pixelkit/flat-jewels/Weather.512.png" ) - embed.color = discord.Color.blurple() - except AttributeError: - embed = None + ) + embed.color = discord.Color.blurple() return embed + + +def build_weather_query( + city_name: str, state_code: str | None = None, country_code: str | None = None +) -> str: + """Builds a weather search query from city/state/country parts. + + Args: + city_name (str): The city or town name + state_code (str | None, optional): The state code. Defaults to None. + country_code (str | None, optional): The country code. Defaults to None. + + Returns: + str: The cleaned search query used for OpenWeather q parameter + """ + raw_parts = [city_name, state_code, country_code] + cleaned_parts = [] + for part in raw_parts: + if part is None: + continue + cleaned_part = str(part).strip() + if len(cleaned_part) == 0: + continue + cleaned_parts.append(cleaned_part) + + return ",".join(cleaned_parts) + + +def build_weather_url(query: str, api_key: str, units: str = "imperial") -> str: + """Builds an OpenWeather API URL. + + Args: + query (str): The q query value for OpenWeather + api_key (str): The OpenWeather API key + units (str, optional): OpenWeather units value. Defaults to "imperial". + + Returns: + str: The full API URL + """ + base_url = "http://api.openweathermap.org/data/2.5/weather" + return f"{base_url}?q={query}&units={units}&appid={api_key}" + + +def fahrenheit_to_celsius(temperature_f: float) -> float: + """Converts Fahrenheit to Celsius. + + Args: + temperature_f (float): Temperature in Fahrenheit + + Returns: + float: Temperature in Celsius + """ + return (temperature_f - 32) * (5 / 9) + + +def format_dual_temperature(temperature_f: float) -> str: + """Formats a temperature in Fahrenheit and Celsius. + + Args: + temperature_f (float): Temperature in Fahrenheit + + Returns: + str: Formatted output in `F (C)` shape + """ + celsius = fahrenheit_to_celsius(float(temperature_f)) + return f"{int(round(temperature_f))}°F ({int(round(celsius))}°C)" + + +def extract_weather_fields(response: munch.Munch) -> dict[str, str] | None: + """Extracts weather display fields from API response data. + + Args: + response (munch.Munch): The OpenWeather response payload + + Returns: + dict[str, str] | None: Render-ready weather fields, or None if payload is invalid + """ + try: + title = f"Weather for {response.name} ({response.sys.country})" + descriptions = ", ".join(weather.description for weather in response.weather) + + current = format_dual_temperature(float(response.main.temp)) + feels_like = format_dual_temperature(float(response.main.feels_like)) + low = format_dual_temperature(float(response.main.temp_min)) + high = format_dual_temperature(float(response.main.temp_max)) + humidity = f"{int(response.main.humidity)} %" + except (AttributeError, KeyError, TypeError, ValueError): + return None + + return { + "title": title, + "description": descriptions, + "temp": f"{current} (feels like {feels_like})", + "low": low, + "high": high, + "humidity": humidity, + } diff --git a/techsupport_bot/core/databases.py b/techsupport_bot/core/databases.py index db90914e5..ece5fbfe8 100644 --- a/techsupport_bot/core/databases.py +++ b/techsupport_bot/core/databases.py @@ -110,7 +110,7 @@ class DuckUser(bot.db.Model): updated: datetime.datetime = bot.db.Column( bot.db.DateTime, default=datetime.datetime.utcnow ) - speed_record: float = bot.db.Column(bot.db.Float, default=80.0) + speed_record: float = bot.db.Column(bot.db.Float, default=-1.0) class Factoid(bot.db.Model): """The postgres table for factoids diff --git a/techsupport_bot/tests/commands_tests/test_extensions_burn.py b/techsupport_bot/tests/commands_tests/test_extensions_burn.py new file mode 100644 index 000000000..252c19f7b --- /dev/null +++ b/techsupport_bot/tests/commands_tests/test_extensions_burn.py @@ -0,0 +1,123 @@ +""" +This is a file to test the extensions/burn.py file +This contains tests for all extracted helper functions +""" + +from __future__ import annotations + +from typing import Self +from unittest.mock import patch + +import pytest +from commands import burn + + +class Test_BuildBurnReactions: + """Tests for build_burn_reactions""" + + def test_reaction_list(self: Self) -> None: + """Ensures reaction list is in the expected order""" + # Step 1 - Call the function + reactions = burn.build_burn_reactions() + + # Step 2 - Assert that everything works + assert reactions == ["🔥", "🚒", "👨‍🚒"] + + +class Test_NormalizePhrasePool: + """Tests for normalize_phrase_pool""" + + def test_normalize_phrase_pool(self: Self) -> None: + """Ensures phrase pool is trimmed, deduplicated, and cleaned""" + # Step 1 - Call the function + normalized = burn.normalize_phrase_pool( + [" test phrase ", "", "test phrase", "another phrase", " "] + ) + + # Step 2 - Assert that everything works + assert normalized == ["test phrase", "another phrase"] + + +class Test_ValidatePhrasePool: + """Tests for validate_phrase_pool""" + + def test_empty_phrase_pool(self: Self) -> None: + """Ensures empty phrase pools are rejected""" + # Step 1 - Call the function + error_message = burn.validate_phrase_pool([]) + + # Step 2 - Assert that everything works + assert error_message == "There are no burn phrases configured" + + def test_populated_phrase_pool(self: Self) -> None: + """Ensures non-empty phrase pools pass validation""" + # Step 1 - Call the function + error_message = burn.validate_phrase_pool(["value"]) + + # Step 2 - Assert that everything works + assert error_message is None + + +class Test_ChoosePhraseIndex: + """Tests for choose_phrase_index""" + + def test_choose_phrase_index(self: Self) -> None: + """Ensures a selected index is returned from the phrase chooser""" + # Step 1 - Call the function + with patch("commands.burn.random.randint", return_value=1): + index = burn.choose_phrase_index(["a", "b", "c"]) + + # Step 2 - Assert that everything works + assert index == 1 + + def test_choose_phrase_index_empty(self: Self) -> None: + """Ensures a ValueError is raised with no phrase values""" + # Step 1 / 2 - Assert that everything works + with pytest.raises(ValueError): + burn.choose_phrase_index([]) + + +class Test_BuildBurnDescription: + """Tests for build_burn_description""" + + def test_build_burn_description(self: Self) -> None: + """Ensures phrase wrapper formatting is correct""" + # Step 1 - Call the function + description = burn.build_burn_description( + ["Sick BURN!", "BURN ALERT!"], chosen_index=1 + ) + + # Step 2 - Assert that everything works + assert description == "🔥🔥🔥 BURN ALERT! 🔥🔥🔥" + + +class Test_BuildBurnNotFoundMessage: + """Tests for build_burn_not_found_message""" + + def test_build_burn_not_found_message(self: Self) -> None: + """Ensures not-found message content matches expectation""" + # Step 1 - Call the function + output = burn.build_burn_not_found_message() + + # Step 2 - Assert that everything works + assert output == "I could not find a message to reply to" + + +class Test_ResolveBurnTargetForContextMenu: + """Tests for resolve_burn_target_for_context_menu""" + + def test_resolve_author(self: Self) -> None: + """Ensures valid message author IDs are selected""" + # Step 1 - Call the function + target = burn.resolve_burn_target_for_context_menu(10, 20) + + # Step 2 - Assert that everything works + assert target == 10 + + def test_resolve_invoker_on_invalid_author(self: Self) -> None: + """Ensures invalid message author IDs use the invoker as fallback""" + # Step 1 - Call the function + target = burn.resolve_burn_target_for_context_menu(0, 20) + + # Step 2 - Assert that everything works + assert target == 20 diff --git a/techsupport_bot/tests/commands_tests/test_extensions_conch.py b/techsupport_bot/tests/commands_tests/test_extensions_conch.py deleted file mode 100644 index 5161187c7..000000000 --- a/techsupport_bot/tests/commands_tests/test_extensions_conch.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -This is a file to test the extensions/conch.py file -This contains 3 tests -""" - -from __future__ import annotations - -from typing import Self - -from hypothesis import given -from hypothesis.strategies import text -from tests import config_for_tests - - -class Test_FormatQuestion: - """Tests to test the format_question function""" - - @given(text()) - def test_format_question(self: Self, question: str) -> None: - """Property test to ensure the question is cropped correcty, never altered, - and always ends in a question mark - - Args: - question (str): The randomly generated question to format - """ - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - new_question = discord_env.conch.format_question(question) - - # Step 3 - Assert that everything works - assert new_question.endswith("?") - assert len(new_question) <= 256 - assert new_question[:-1] in question - assert len(question) >= len(new_question) - 1 - - def test_format_question_no_mark(self: Self) -> None: - """Test to ensure that format question adds a question mark if needed""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - new_question = discord_env.conch.format_question("This is a question") - - # Step 3 - Assert that everything works - assert new_question == "This is a question?" - - def test_format_question_yes_mark(self: Self) -> None: - """Test to ensure that the format question doesn't add a - question mark when the question ends with a question mark""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - new_question = discord_env.conch.format_question("This is a question?") - - # Step 3 - Assert that everything works - assert new_question == "This is a question?" diff --git a/techsupport_bot/tests/commands_tests/test_extensions_duck.py b/techsupport_bot/tests/commands_tests/test_extensions_duck.py new file mode 100644 index 000000000..69b44dd12 --- /dev/null +++ b/techsupport_bot/tests/commands_tests/test_extensions_duck.py @@ -0,0 +1,164 @@ +""" +This is a file to test the extensions/duck.py file +This contains helper function tests for duck.py +""" + +from __future__ import annotations + +import datetime +from typing import Self + +from commands import duck + + +class Test_ComputeDurationValues: + """Tests for compute_duration_values""" + + def test_compute_duration_values(self: Self) -> None: + """Ensures second and exact durations are computed""" + # Step 1 - Setup env + duration = datetime.timedelta(seconds=12, microseconds=345678) + + # Step 2 - Call the function + duration_seconds, duration_exact = duck.compute_duration_values(duration) + + # Step 3 - Assert that everything works + assert duration_seconds == 12 + assert duration_exact == 12.345678 + + +class Test_BuildWinnerFooter: + """Tests for build_winner_footer""" + + def test_new_personal_and_global(self: Self) -> None: + """Ensures footer text includes personal and global notices""" + # Step 1 - Call the function + footer_text, update_personal = duck.build_winner_footer(5.2, 7.1, 6.0) + + # Step 2 - Assert that everything works + assert "New personal record" in footer_text + assert "New global record" in footer_text + assert update_personal + + def test_regular_time(self: Self) -> None: + """Ensures non-record runs return exact-time footer""" + # Step 1 - Call the function + footer_text, update_personal = duck.build_winner_footer(8.0, 7.1, 6.0) + + # Step 2 - Assert that everything works + assert footer_text == "Exact time: 8.0 seconds." + assert not update_personal + + def test_first_record_from_sentinel(self: Self) -> None: + """Ensures a -1 personal record sentinel is treated as no existing record""" + # Step 1 - Call the function + footer_text, update_personal = duck.build_winner_footer(8.0, -1.0, None) + + # Step 2 - Assert that everything works + assert "New personal record" in footer_text + assert update_personal + + +class Test_BuildStatsFooter: + """Tests for build_stats_footer""" + + def test_with_global_record(self: Self) -> None: + """Ensures global record holder text is included when applicable""" + # Step 1 - Call the function + footer_text = duck.build_stats_footer(2.5, 2.5) + + # Step 2 - Assert that everything works + assert "Speed record: 2.5 seconds" in footer_text + assert "You hold the current global record!" in footer_text + + def test_without_global_record(self: Self) -> None: + """Ensures only speed text appears when global record differs""" + # Step 1 - Call the function + footer_text = duck.build_stats_footer(2.5, 1.0) + + # Step 2 - Assert that everything works + assert footer_text == "Speed record: 2.5 seconds" + + +class Test_ChunkDuckUsers: + """Tests for chunk_duck_users""" + + def test_chunk_duck_users(self: Self) -> None: + """Ensures user records are split into pages by limit""" + # Step 1 - Call the function + chunks = duck.chunk_duck_users([1, 2, 3, 4, 5], items_per_page=3) + + # Step 2 - Assert that everything works + assert chunks == [[1, 2, 3], [4, 5]] + + +class Test_BuildMessages: + """Tests for standardized duck helper messages""" + + def test_build_not_participated_message(self: Self) -> None: + """Ensures not-participated message is stable""" + # Step 1 - Call the function + message = duck.build_not_participated_message() + + # Step 2 - Assert that everything works + assert message == "You have not participated in the duck hunt yet." + + def test_build_manipulation_disabled_message(self: Self) -> None: + """Ensures manipulation-disabled message is stable""" + # Step 1 - Call the function + message = duck.build_manipulation_disabled_message() + + # Step 2 - Assert that everything works + assert message == "This command is disabled in this server" + + def test_build_spawn_permission_denial(self: Self) -> None: + """Ensures spawn deny message is stable""" + # Step 1 - Call the function + message = duck.build_spawn_permission_denial() + + # Step 2 - Assert that everything works + assert message == "It looks like you don't have permissions to spawn a duck" + + +class Test_ValidationHelpers: + """Tests for duck validation helper functions""" + + def test_validate_donation_target(self: Self) -> None: + """Ensures bot and self donation restrictions are enforced""" + # Step 1 - Call the function + bot_target = duck.validate_donation_target(1, 2, True) + self_target = duck.validate_donation_target(1, 1, False) + valid_target = duck.validate_donation_target(1, 2, False) + + # Step 2 - Assert that everything works + assert bot_target == "The only ducks I accept are plated with gold!" + assert self_target == "You can't donate a duck to yourself" + assert valid_target is None + + def test_validate_duck_inventory(self: Self) -> None: + """Ensures inventory checks are action-specific""" + # Step 1 - Call the function + invalid_inventory = duck.validate_duck_inventory(0, "release") + valid_inventory = duck.validate_duck_inventory(2, "release") + + # Step 2 - Assert that everything works + assert invalid_inventory == "You have no ducks to release." + assert valid_inventory is None + + def test_can_spawn_duck(self: Self) -> None: + """Ensures spawn permission lookup supports int/string IDs""" + # Step 1 - Call the function + allowed = duck.can_spawn_duck(25, [25, "30"]) + denied = duck.can_spawn_duck(99, [25, "30"]) + + # Step 2 - Assert that everything works + assert allowed + assert not denied + + def test_build_random_choice_weights(self: Self) -> None: + """Ensures success/failure weights sum correctly""" + # Step 1 - Call the function + weights = duck.build_random_choice_weights(70) + + # Step 2 - Assert that everything works + assert weights == (70, 30) diff --git a/techsupport_bot/tests/commands_tests/test_extensions_echo.py b/techsupport_bot/tests/commands_tests/test_extensions_echo.py new file mode 100644 index 000000000..bb0cc7cdd --- /dev/null +++ b/techsupport_bot/tests/commands_tests/test_extensions_echo.py @@ -0,0 +1,68 @@ +""" +This is a file to test the extensions/echo.py file +This contains 5 tests +""" + +from __future__ import annotations + +from typing import Self + +import munch +from commands import echo + + +class Test_NormalizeEchoMessage: + """A set of tests for normalize_echo_message""" + + def test_keeps_regular_content(self: Self) -> None: + """A test to ensure content without extra spaces is preserved""" + # Step 1 - Call the function + result = echo.normalize_echo_message("hello world") + + # Step 2 - Assert that everything works + assert result == "hello world" + + def test_trims_outer_whitespace(self: Self) -> None: + """A test to ensure leading and trailing whitespace are removed""" + # Step 1 - Call the function + result = echo.normalize_echo_message(" hello world ") + + # Step 2 - Assert that everything works + assert result == "hello world" + + def test_returns_no_content_for_blank(self: Self) -> None: + """A test to ensure blank content is normalized to fallback text""" + # Step 1 - Call the function + result = echo.normalize_echo_message(" ") + + # Step 2 - Assert that everything works + assert result == "No content" + + +class Test_BuildEchoChannelLogPayload: + """A set of tests for build_echo_channel_log_payload""" + + def test_disabled_logger_returns_none(self: Self) -> None: + """A test to ensure no payload is built if logger is disabled""" + # Step 1 - Setup env + config = munch.munchify({"enabled_extensions": ["echo"]}) + + # Step 2 - Call the function + result = echo.build_echo_channel_log_payload(config, "hello world") + + # Step 3 - Assert that everything works + assert result is None + + def test_enabled_logger_returns_payload(self: Self) -> None: + """A test to ensure payload is built correctly when logger is enabled""" + # Step 1 - Setup env + config = munch.munchify({"enabled_extensions": ["logger", "echo"]}) + + # Step 2 - Call the function + result = echo.build_echo_channel_log_payload(config, " hello world ") + + # Step 3 - Assert that everything works + assert result == { + "content_override": "hello world", + "special_flags": ["Echo command"], + } diff --git a/techsupport_bot/tests/commands_tests/test_extensions_emoji.py b/techsupport_bot/tests/commands_tests/test_extensions_emoji.py deleted file mode 100644 index 2f4a01f7c..000000000 --- a/techsupport_bot/tests/commands_tests/test_extensions_emoji.py +++ /dev/null @@ -1,352 +0,0 @@ -""" -This is a file to test the extensions/emoji.py file -This contains 14 tests -""" - -from __future__ import annotations - -import importlib -from typing import Self -from unittest.mock import AsyncMock, MagicMock - -import pytest -from core import auxiliary -from tests import config_for_tests - - -class Test_EmojiFromChar: - """A class to test the emoji_from_char method""" - - def test_lowercase_letter(self: Self) -> None: - """A test to ensure that a lowercase letter returns correctly""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - char = discord_env.emoji.emoji_from_char("a") - - # Step 3 - Assert that everything works - assert char == "🇦" - - def test_uppercase_letter(self: Self) -> None: - """A test to ensure that a uppsercase letter returns correctly""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - char = discord_env.emoji.emoji_from_char("A") - - # Step 3 - Assert that everything works - assert char == "🇦" - - def test_number(self: Self) -> None: - """A test to ensure that a number returns correctly""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - char = discord_env.emoji.emoji_from_char("1") - - # Step 3 - Assert that everything works - assert char == "1️⃣" - - def test_question_mark(self: Self) -> None: - """A test to ensure that a uppsercase letter returns correctly""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - char = discord_env.emoji.emoji_from_char("?") - - # Step 3 - Assert that everything works - assert char == "❓" - - def test_invalid(self: Self) -> None: - """A test to ensure that a uppsercase letter returns correctly""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - char = discord_env.emoji.emoji_from_char("]") - - # Step 3 - Assert that everything works - assert char is None - - -class Test_CheckIfAllUnique: - """A class to test the check_if_all_unique method""" - - def test_unique(self: Self) -> None: - """Test to ensure that a unique string is detected""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - response = discord_env.emoji.check_if_all_unique("abcde") - - # Step 3 - Assert that everything works - assert response - - def test_non_unique(self: Self) -> None: - """Test to ensure that a non-unique string is detected""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - response = discord_env.emoji.check_if_all_unique("abcade") - - # Step 3 - Assert that everything works - assert not response - - -class Test_GenerateEmojiString: - """A class to test the generate_emoji_string method""" - - def test_only_emoji(self: Self) -> None: - """Test to ensure that only_emoji works when true""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - response = discord_env.emoji.generate_emoji_string("abcd!@#$1234", True) - - # Step 3 - Assert that everything works - assert len(response) == 9 - assert response == ["🇦", "🇧", "🇨", "🇩", "❗", "1️⃣", "2️⃣", "3️⃣", "4️⃣"] - - def test_non_emoji(self: Self) -> None: - """Test to ensure that only_emoji works when false""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - response = discord_env.emoji.generate_emoji_string("abcd!@#$1234", False) - - # Step 3 - Assert that everything works - assert len(response) == 12 - assert response == [ - "🇦", - "🇧", - "🇨", - "🇩", - "❗", - "@", - "#", - "$", - "1️⃣", - "2️⃣", - "3️⃣", - "4️⃣", - ] - - -class Test_EmojiCommands: - """A class to test the emoji_commands method""" - - @pytest.mark.asyncio - async def test_empty_string(self: Self) -> None: - """Test to ensure that an error is thrown on a empty response""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - discord_env.emoji.generate_emoji_string = MagicMock(return_value=[]) - auxiliary.send_deny_embed = AsyncMock() - - # Step 2 - Call the function - await discord_env.emoji.emoji_commands(discord_env.context, "abcde", False) - - # Step 3 - Assert that everything works - auxiliary.send_deny_embed.assert_called_once_with( - message="I can't get any emoji letters from your message!", - channel=discord_env.channel, - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_find_no_message(self: Self) -> None: - """Test to ensure that if no message could be found, an error will occur""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - discord_env.emoji.generate_emoji_string = MagicMock(return_value=["1"]) - auxiliary.search_channel_for_message = AsyncMock(return_value=None) - auxiliary.send_deny_embed = AsyncMock() - - # Step 2 - Call the function - await discord_env.emoji.emoji_commands( - discord_env.context, "abcde", False, "Fake discord user" - ) - - # Step 3 - Assert that everything works - auxiliary.send_deny_embed.assert_called_once_with( - message="No valid messages found to react to!", channel=discord_env.channel - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_unique_error(self: Self) -> None: - """Test to ensure that if the string is not unique, an error will occur""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - discord_env.emoji.generate_emoji_string = MagicMock(return_value=["1"]) - discord_env.emoji.check_if_all_unique = MagicMock(return_value=False) - auxiliary.send_deny_embed = AsyncMock() - - # Step 2 - Call the function - await discord_env.emoji.emoji_commands(discord_env.context, "abcde", True) - - # Step 3 - Assert that everything works - auxiliary.send_deny_embed.assert_called_once_with( - message="Invalid message! Make sure there are no repeat characters!", - channel=discord_env.channel, - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_confirm_with_proper_call(self: Self) -> None: - """Test that send_confirm_embed is being called with the proper string""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - discord_env.emoji.generate_emoji_string = MagicMock(return_value=["1", "2"]) - auxiliary.send_confirm_embed = AsyncMock() - - # Step 2 - Call the function - await discord_env.emoji.emoji_commands(discord_env.context, "abcde", False) - - # Step 3 - Assert that everything works - auxiliary.send_confirm_embed.assert_called_once_with( - message="1 2", channel=discord_env.channel - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_proper_reactions(self: Self) -> None: - """Test that send_confirm_embed is being called with the proper string""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - discord_env.emoji.generate_emoji_string = MagicMock(return_value=["1", "2"]) - auxiliary.search_channel_for_message = AsyncMock( - return_value=discord_env.message_person1_noprefix_1 - ) - auxiliary.add_list_of_reactions = AsyncMock() - - # Step 2 - Call the function - await discord_env.emoji.emoji_commands( - discord_env.context, "abcde", True, "Fake discord user" - ) - - # Step 3 - Assert that everything works - auxiliary.add_list_of_reactions.assert_called_once_with( - message=discord_env.message_person1_noprefix_1, reactions=["1", "2"] - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_reaction_count_to_twenty(self: Self) -> None: - """Test that will test from 0 to 20 reactions""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - discord_env.emoji.generate_emoji_string = MagicMock(return_value=["1"] * 20) - auxiliary.search_channel_for_message = AsyncMock( - return_value=discord_env.message_person1_noprefix_1 - ) - - auxiliary.send_deny_embed = AsyncMock() - - # Step 2 - Call the function - await discord_env.emoji.emoji_commands( - discord_env.context, "abcde", True, "Fake discord user" - ) - - # Step 3 - Assert that everything works - assert discord_env.message_person1_noprefix_1.reactions == ["1"] * 20 - auxiliary.send_deny_embed.assert_not_called() - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_reaction_count_to_twentyone(self: Self) -> None: - """Test that will test from 0 to 21 reactions""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - discord_env.emoji.generate_emoji_string = MagicMock(return_value=["1"] * 21) - auxiliary.search_channel_for_message = AsyncMock( - return_value=discord_env.message_person1_noprefix_1 - ) - - auxiliary.send_deny_embed = AsyncMock() - - # Step 2 - Call the function - await discord_env.emoji.emoji_commands( - discord_env.context, "abcde", True, "Fake discord user" - ) - - # Step 3 - Assert that everything works - assert discord_env.message_person1_noprefix_1.reactions == [] - auxiliary.send_deny_embed.assert_called_once_with( - message="Reaction Count too many", - channel=discord_env.channel, - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_reaction_count_one_to_twenty(self: Self) -> None: - """Test that will test from 1 to 20 reactions""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - discord_env.emoji.generate_emoji_string = MagicMock(return_value=["1"] * 19) - auxiliary.search_channel_for_message = AsyncMock( - return_value=discord_env.message_reaction1 - ) - - auxiliary.send_deny_embed = AsyncMock() - - # Step 2 - Call the function - await discord_env.emoji.emoji_commands( - discord_env.context, "abcde", True, "Fake discord user" - ) - - # Step 3 - Assert that everything works - assert discord_env.message_reaction1.reactions == [1] + (["1"] * 19) - auxiliary.send_deny_embed.assert_not_called() - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_reaction_count_one_to_twentyone(self: Self) -> None: - """Test that will test from 1 to 21 reactions""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - discord_env.emoji.generate_emoji_string = MagicMock(return_value=["1"] * 20) - auxiliary.search_channel_for_message = AsyncMock( - return_value=discord_env.message_reaction1 - ) - - auxiliary.send_deny_embed = AsyncMock() - - # Step 2 - Call the function - await discord_env.emoji.emoji_commands( - discord_env.context, "abcde", True, "Fake discord user" - ) - - # Step 3 - Assert that everything works - assert discord_env.message_reaction1.reactions == [1] - auxiliary.send_deny_embed.assert_called_once_with( - message="Reaction Count too many", - channel=discord_env.channel, - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) diff --git a/techsupport_bot/tests/commands_tests/test_extensions_google.py b/techsupport_bot/tests/commands_tests/test_extensions_google.py new file mode 100644 index 000000000..997d0538a --- /dev/null +++ b/techsupport_bot/tests/commands_tests/test_extensions_google.py @@ -0,0 +1,153 @@ +""" +This is a file to test the extensions/google.py file +This contains tests for helper functions in google.py +""" + +from __future__ import annotations + +from typing import Self + +import munch +from commands import google + + +class Test_BuildParamHelpers: + """Tests for parameter builder helpers""" + + def test_build_google_search_params(self: Self) -> None: + """Ensures Google search params are generated correctly""" + # Step 1 - Call the function + params = google.build_google_search_params("CSE", "KEY", "query") + + # Step 2 - Assert that everything works + assert params == {"cx": "CSE", "q": "query", "key": "KEY"} + + def test_build_google_image_params(self: Self) -> None: + """Ensures Google image params include image search type""" + # Step 1 - Call the function + params = google.build_google_image_params("CSE", "KEY", "query") + + # Step 2 - Assert that everything works + assert params == { + "cx": "CSE", + "q": "query", + "key": "KEY", + "searchType": "image", + } + + def test_build_youtube_params(self: Self) -> None: + """Ensures YouTube params are generated correctly""" + # Step 1 - Call the function + params = google.build_youtube_params("KEY", "query") + + # Step 2 - Assert that everything works + assert params == {"q": "query", "key": "KEY", "type": "video"} + + +class Test_ExtractionHelpers: + """Tests for response extraction helpers""" + + def test_extract_google_search_fields(self: Self) -> None: + """Ensures links/snippets are extracted and cleaned""" + # Step 1 - Setup env + items = [ + munch.munchify( + {"link": "https://example.com/1", "snippet": "line1\nline2"} + ), + munch.munchify({"link": "https://example.com/2"}), + munch.munchify({"snippet": "missing link"}), + ] + + # Step 2 - Call the function + fields = google.extract_google_search_fields(items, "query") + + # Step 3 - Assert that everything works + assert fields == [ + ("https://example.com/1", "line1line2"), + ("https://example.com/2", "No details available for query"), + ] + + def test_extract_image_links(self: Self) -> None: + """Ensures malformed image results are skipped""" + # Step 1 - Setup env + items = [ + munch.munchify({"link": "https://img.example.com/1.jpg"}), + munch.munchify({}), + munch.munchify({"link": "https://img.example.com/2.jpg"}), + ] + + # Step 2 - Call the function + links = google.extract_image_links(items) + + # Step 3 - Assert that everything works + assert links == [ + "https://img.example.com/1.jpg", + "https://img.example.com/2.jpg", + ] + + def test_extract_youtube_links(self: Self) -> None: + """Ensures malformed youtube results are skipped""" + # Step 1 - Setup env + items = [ + munch.munchify({"id": {"videoId": "abc"}}), + munch.munchify({"id": {}}), + munch.munchify({"id": {"videoId": "xyz"}}), + ] + + # Step 2 - Call the function + links = google.extract_youtube_links(items) + + # Step 3 - Assert that everything works + assert links == ["http://youtu.be/abc", "http://youtu.be/xyz"] + + +class Test_ChunkSearchFields: + """Tests for chunk_search_fields""" + + def test_chunk_search_fields(self: Self) -> None: + """Ensures fields are chunked by max size""" + # Step 1 - Setup env + fields = [("a", "1"), ("b", "2"), ("c", "3"), ("d", "4")] + + # Step 2 - Call the function + chunks = google.chunk_search_fields(fields, 2) + + # Step 3 - Assert that everything works + assert chunks == [[("a", "1"), ("b", "2")], [("c", "3"), ("d", "4")]] + + def test_chunk_search_fields_minimum_size(self: Self) -> None: + """Ensures non-positive max size is normalized to one item per chunk""" + # Step 1 - Setup env + fields = [("a", "1"), ("b", "2")] + + # Step 2 - Call the function + chunks = google.chunk_search_fields(fields, 0) + + # Step 3 - Assert that everything works + assert chunks == [[("a", "1")], [("b", "2")]] + + +class Test_MessageHelpers: + """Tests for no-results and parse error message builders""" + + def test_build_no_results_message(self: Self) -> None: + """Ensures search-kind-specific no-result messages are returned""" + # Step 1 - Call the function + default_message = google.build_no_results_message("search", "abc") + image_message = google.build_no_results_message("image", "abc") + video_message = google.build_no_results_message("video", "abc") + + # Step 2 - Assert that everything works + assert default_message == "No search results found for: *abc*" + assert image_message == "No image search results found for: *abc*" + assert video_message == "No video results found for: *abc*" + + def test_build_google_parse_error_message(self: Self) -> None: + """Ensures parse error message text is stable""" + # Step 1 - Call the function + message = google.build_google_parse_error_message() + + # Step 2 - Assert that everything works + assert ( + message == "I had an issue processing Google's response... try again later!" + ) diff --git a/techsupport_bot/tests/commands_tests/test_extensions_hangman.py b/techsupport_bot/tests/commands_tests/test_extensions_hangman.py new file mode 100644 index 000000000..f0fe1c4e1 --- /dev/null +++ b/techsupport_bot/tests/commands_tests/test_extensions_hangman.py @@ -0,0 +1,241 @@ +""" +This is a file to test the extensions/hangman.py file +This contains tests for all extracted helper functions +""" + +from __future__ import annotations + +from typing import Self + +import discord +from commands import hangman + + +class FakeOwner: + """A simple fake owner object with a deterministic string output""" + + def __str__(self: Self) -> str: + return "owner-user" + + +class Test_NormalizeSecretWord: + """Tests for normalize_secret_word""" + + def test_normalize_secret_word(self: Self) -> None: + """Ensures the start word is stripped and lowercased""" + # Step 1 - Call the function + output = hangman.normalize_secret_word(" HeLLo ") + + # Step 2 - Assert that everything works + assert output == "hello" + + +class Test_ValidateStartWordInput: + """Tests for validate_start_word_input""" + + def test_empty_word(self: Self) -> None: + """Ensures empty start words are rejected""" + # Step 1 - Call the function + output = hangman.validate_start_word_input(" ") + + # Step 2 - Assert that everything works + assert output == "A word must be provided" + + def test_long_word(self: Self) -> None: + """Ensures overlong start words are rejected""" + # Step 1 - Call the function + output = hangman.validate_start_word_input("a" * 85) + + # Step 2 - Assert that everything works + assert output == "The word must be 84 characters or fewer" + + def test_non_alpha_word(self: Self) -> None: + """Ensures non-alphabetical start words are rejected""" + # Step 1 - Call the function + output = hangman.validate_start_word_input("hello123") + + # Step 2 - Assert that everything works + assert output == "The word can only contain letters" + + def test_valid_word(self: Self) -> None: + """Ensures valid start words pass""" + # Step 1 - Call the function + output = hangman.validate_start_word_input("Banana") + + # Step 2 - Assert that everything works + assert output is None + + +class Test_ValidateLetterGuessInput: + """Tests for validate_letter_guess_input""" + + def test_multiple_characters(self: Self) -> None: + """Ensures multi-character letters are rejected""" + # Step 1 - Call the function + output = hangman.validate_letter_guess_input("ab") + + # Step 2 - Assert that everything works + assert output == "You can only guess a single letter" + + def test_non_alpha_character(self: Self) -> None: + """Ensures non-alphabetical letters are rejected""" + # Step 1 - Call the function + output = hangman.validate_letter_guess_input("1") + + # Step 2 - Assert that everything works + assert output == "You can only guess alphabetic letters" + + def test_valid_letter(self: Self) -> None: + """Ensures single alphabetical letters pass""" + # Step 1 - Call the function + output = hangman.validate_letter_guess_input("a") + + # Step 2 - Assert that everything works + assert output is None + + +class Test_ValidateSolveGuessInput: + """Tests for validate_solve_guess_input""" + + def test_non_alpha_solve_guess(self: Self) -> None: + """Ensures non-alphabetical solve guesses are rejected""" + # Step 1 - Call the function + output = hangman.validate_solve_guess_input("word123") + + # Step 2 - Assert that everything works + assert output == "The guessed word can only contain letters" + + def test_valid_solve_guess(self: Self) -> None: + """Ensures alphabetical solve guesses pass""" + # Step 1 - Call the function + output = hangman.validate_solve_guess_input("Word") + + # Step 2 - Assert that everything works + assert output is None + + +class Test_GameControlHelpers: + """Tests for game control helper functions""" + + def test_decide_start_conflict_owner(self: Self) -> None: + """Ensures owners can choose to overwrite""" + # Step 1 - Call the function + output = hangman.decide_start_conflict(caller_id=1, owner_id=1) + + # Step 2 - Assert that everything works + assert output == "confirm-overwrite" + + def test_decide_start_conflict_non_owner(self: Self) -> None: + """Ensures non-owners are denied overwrite""" + # Step 1 - Call the function + output = hangman.decide_start_conflict(caller_id=1, owner_id=2) + + # Step 2 - Assert that everything works + assert output == "deny" + + def test_evaluate_solve_attempt(self: Self) -> None: + """Ensures solve attempts are case-insensitive""" + # Step 1 - Call the function + output = hangman.evaluate_solve_attempt("Banana", "banana") + + # Step 2 - Assert that everything works + assert output + + def test_build_letter_guess_result(self: Self) -> None: + """Ensures letter result text renders for misses""" + # Step 1 - Call the function + output = hangman.build_letter_guess_result("A", False) + + # Step 2 - Assert that everything works + assert output == "Letter `a` not in word" + + def test_build_solve_result(self: Self) -> None: + """Ensures solve result text renders for hits""" + # Step 1 - Call the function + output = hangman.build_solve_result(" APPLE ", True) + + # Step 2 - Assert that everything works + assert output == "`apple` is correct" + + def test_build_add_guesses_result(self: Self) -> None: + """Ensures add-guesses result text is formatted""" + # Step 1 - Call the function + output = hangman.build_add_guesses_result(2, 5) + + # Step 2 - Assert that everything works + assert output == "2 guesses have been added! Total guesses remaining: 5" + + +class Test_StopPermission: + """Tests for build_stop_permission_denial""" + + def test_owner_allowed(self: Self) -> None: + """Ensures game owners can always stop their own game""" + # Step 1 - Call the function + output = hangman.build_stop_permission_denial( + caller_id=1, + owner_id=1, + configured_role_names=[], + caller_role_names=[], + ) + + # Step 2 - Assert that everything works + assert output is None + + def test_no_configured_roles_denied(self: Self) -> None: + """Ensures non-owners are denied when no admin roles are configured""" + # Step 1 - Call the function + output = hangman.build_stop_permission_denial( + caller_id=2, + owner_id=1, + configured_role_names=[], + caller_role_names=["Moderator"], + ) + + # Step 2 - Assert that everything works + assert output == "No hangman admin roles are configured" + + def test_matching_role_allowed(self: Self) -> None: + """Ensures configured role match grants stop permission""" + # Step 1 - Call the function + output = hangman.build_stop_permission_denial( + caller_id=2, + owner_id=1, + configured_role_names=["Moderator"], + caller_role_names=["moderator"], + ) + + # Step 2 - Assert that everything works + assert output is None + + +class Test_BuildGameDisplayData: + """Tests for build_game_display_data""" + + def test_running_game_payload(self: Self) -> None: + """Ensures running games include guess and color details""" + # Step 1 - Setup env + game = hangman.HangmanGame("apple") + game.guesses.add("a") + + # Step 2 - Call the function + output = hangman.build_game_display_data(game, FakeOwner()) + + # Step 3 - Assert that everything works + assert output["color"] == discord.Color.gold() + assert output["remaining_guesses"] == 6 + assert output["guessed_letters"] == "a" + assert str(output["footer"]).startswith("Game started by") + + def test_failed_game_payload(self: Self) -> None: + """Ensures failed games return fail coloring and footer""" + # Step 1 - Setup env + game = hangman.HangmanGame("apple") + game.step = game.max_guesses + + # Step 2 - Call the function + output = hangman.build_game_display_data(game, FakeOwner()) + + # Step 3 - Assert that everything works + assert output["color"] == discord.Color.red() + assert output["footer"] == "Game over! The word was `apple`!" diff --git a/techsupport_bot/tests/commands_tests/test_extensions_hug.py b/techsupport_bot/tests/commands_tests/test_extensions_hug.py index c1084368d..8ce0ab442 100644 --- a/techsupport_bot/tests/commands_tests/test_extensions_hug.py +++ b/techsupport_bot/tests/commands_tests/test_extensions_hug.py @@ -1,132 +1,106 @@ """ This is a file to test the extensions/hug.py file -This contains 5 tests +This contains helper function tests for hug.py """ from __future__ import annotations -import importlib from typing import Self -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import patch -import pytest from commands import hug -from core import auxiliary -from tests import config_for_tests, helpers -def setup_local_extension(bot: helpers.MockBot = None) -> hug.Hugger: - """A simple function to setup an instance of the hug extension +class Test_IsValidHugTarget: + """Tests for is_valid_hug_target""" - Args: - bot (helpers.MockBot, optional): A fake bot object. Should be used if using a - fake_discord_env in the test. Defaults to None. + def test_valid_target(self: Self) -> None: + """Ensures different author and target IDs are valid""" + # Step 1 - Call the function + result = hug.is_valid_hug_target(1, 2) - Returns: - hug.Hugger: The instance of the Hugger class - """ - with patch("asyncio.create_task", return_value=None): - return hug.Hugger(bot) + # Step 2 - Assert that everything works + assert result + def test_invalid_target(self: Self) -> None: + """Ensures identical author and target IDs are invalid""" + # Step 1 - Call the function + result = hug.is_valid_hug_target(1, 1) -class Test_CheckEligibility: - """A set of tests to test split_nicely""" + # Step 2 - Assert that everything works + assert not result - def test_eligible(self: Self) -> None: - """A test to ensure that when 2 different members are passed, True is returned""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - hugger = setup_local_extension(discord_env.bot) - # Step 2 - Call the function - result = hugger.check_hug_eligibility( - author=discord_env.person1, user_to_hug=discord_env.person2 +class Test_NormalizeHugTemplates: + """Tests for normalize_hug_templates""" + + def test_normalize_hug_templates(self: Self) -> None: + """Ensures blank templates are removed and values are trimmed""" + # Step 1 - Call the function + normalized = hug.normalize_hug_templates( + [" one ", "", "two", " ", "three "] ) - # Step 3 - Assert that everything works - assert result is True + # Step 2 - Assert that everything works + assert normalized == ["one", "two", "three"] - def test_ineligible(self: Self) -> None: - """A test to ensure that when the same person is passed twice, False is returned""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - hugger = setup_local_extension(discord_env.bot) - # Step 2 - Call the function - result = hugger.check_hug_eligibility( - author=discord_env.person1, user_to_hug=discord_env.person1 - ) +class Test_PickHugTemplate: + """Tests for pick_hug_template""" + + def test_pick_hug_template(self: Self) -> None: + """Ensures a template can be selected from a non-empty list""" + # Step 1 - Call the function + with patch("commands.hug.random.choice", return_value="selected"): + selected = hug.pick_hug_template(["one", "two"]) + + # Step 2 - Assert that everything works + assert selected == "selected" - # Step 3 - Assert that everything works - assert result is False + def test_pick_hug_template_empty(self: Self) -> None: + """Ensures empty template lists return no selected template""" + # Step 1 - Call the function + selected = hug.pick_hug_template([]) + # Step 2 - Assert that everything works + assert selected is None -class Test_GeneratePhrase: - """A set of tests to test generate_hug_phrase""" - def test_string_generation(self: Self) -> None: - """A test to ensure that string generation is working correctly""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - hugger = setup_local_extension(discord_env.bot) - hugger.HUGS_SELECTION = ["{user_giving_hug} squeezes {user_to_hug} to death"] +class Test_BuildHugPhrase: + """Tests for build_hug_phrase""" - # Step 2 - Call the function - output = hugger.generate_hug_phrase( - author=discord_env.person1, user_to_hug=discord_env.person2 + def test_build_hug_phrase(self: Self) -> None: + """Ensures hug phrase placeholders are formatted correctly""" + # Step 1 - Call the function + phrase = hug.build_hug_phrase( + "{user_giving_hug} hugs {user_to_hug}", + "<@1>", + "<@2>", ) - # Step 3 - Assert that everything works - assert output == "<@1> squeezes <@2> to death" + # Step 2 - Assert that everything works + assert phrase == "<@1> hugs <@2>" -class Test_HugCommand: - """A set of tests to test hug_command""" +class Test_BuildHugFailureMessage: + """Tests for build_hug_failure_message""" - @pytest.mark.asyncio - async def test_failure(self: Self) -> None: - """A test to ensure that nothing is called when the eligiblity is False""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - hugger = setup_local_extension(discord_env.bot) - hugger.check_hug_eligibility = MagicMock(return_value=False) - auxiliary.send_deny_embed = AsyncMock() - discord_env.context.send = AsyncMock() + def test_build_hug_failure_message(self: Self) -> None: + """Ensures failure message text is stable""" + # Step 1 - Call the function + message = hug.build_hug_failure_message() - # Step 2 - Call the function - await hugger.hug_command(discord_env.context, discord_env.person2) + # Step 2 - Assert that everything works + assert message == "Let's be serious" - # Step 3 - Assert that everything works - auxiliary.send_deny_embed.assert_called_once_with( - message="Let's be serious", - channel=discord_env.context.channel, - ) - discord_env.context.send.assert_not_called() - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_success(self: Self) -> None: - """A test to ensure that send is properly called""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - hugger = setup_local_extension(discord_env.bot) - hugger.check_hug_eligibility = MagicMock(return_value=True) - hugger.generate_hug_phrase = MagicMock() - auxiliary.send_deny_embed = AsyncMock() - auxiliary.generate_basic_embed = MagicMock(return_value="Embed") - auxiliary.construct_mention_string = MagicMock(return_value="String") - discord_env.context.send = AsyncMock() - - # Step 2 - Call the function - await hugger.hug_command(discord_env.context, discord_env.person2) - - # Step 3 - Assert that everything works - auxiliary.send_deny_embed.assert_not_called() - discord_env.context.send.assert_called_once_with( - embed="Embed", content="String" - ) - # Step 4 - Cleanup - importlib.reload(auxiliary) +class Test_BuildHugEmbedData: + """Tests for build_hug_embed_data""" + + def test_build_hug_embed_data(self: Self) -> None: + """Ensures embed title/description payload is formed correctly""" + # Step 1 - Call the function + payload = hug.build_hug_embed_data("hug text") + + # Step 2 - Assert that everything works + assert payload == {"title": "You've been hugged!", "description": "hug text"} diff --git a/techsupport_bot/tests/commands_tests/test_extensions_lenny.py b/techsupport_bot/tests/commands_tests/test_extensions_lenny.py deleted file mode 100644 index 1625220bc..000000000 --- a/techsupport_bot/tests/commands_tests/test_extensions_lenny.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -This is a file to test the extensions/lenny.py file -This contains 2 tests -""" - -from __future__ import annotations - -from typing import Self -from unittest.mock import AsyncMock, patch - -import pytest -from commands import lenny -from tests import config_for_tests, helpers - - -def setup_local_extension(bot: helpers.MockBot = None) -> lenny.Lenny: - """A simple function to setup an instance of the htd extension - - Args: - bot (helpers.MockBot, optional): A fake bot object. Should be used if using a - fake_discord_env in the test. Defaults to None. - - Returns: - lenny.Lenny: The instance of the htd class - """ - with patch("asyncio.create_task", return_value=None): - return lenny.Lenny(bot) - - -class Test_Lenny: - """A class to house all tests for lenny""" - - def test_line_length(self: Self) -> None: - """A test to ensure we never exceed the 2000 allowed characters""" - # Step 1 - Setup env - lenny_test = setup_local_extension() - - # Step 2 - Call the function - faces = lenny_test.LENNYS_SELECTION - - # Step 3 - Assert that everything works - for face in faces: - assert len(face) <= 2000 - - @pytest.mark.asyncio - async def test_lenny_command(self: Self) -> None: - """A test to ensure that the lenny command calls send""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - lenny_test = setup_local_extension(discord_env.bot) - discord_env.channel.send = AsyncMock() - lenny_test.LENNYS_SELECTION = ["test"] - - # Step 2 - Call the function - await lenny_test.lenny_command(discord_env.channel) - - # Step 3 - Assert that everything works - discord_env.channel.send.assert_called_once_with(content="test") diff --git a/techsupport_bot/tests/commands_tests/test_extensions_linter.py b/techsupport_bot/tests/commands_tests/test_extensions_linter.py deleted file mode 100644 index ff8efd86e..000000000 --- a/techsupport_bot/tests/commands_tests/test_extensions_linter.py +++ /dev/null @@ -1,190 +0,0 @@ -""" -This is a file to test the extensions/linter.py file -This contains 9 tests -""" - -from __future__ import annotations - -import importlib -import json -from typing import Self -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from commands import linter -from core import auxiliary -from tests import config_for_tests, helpers - - -def setup_local_extension(bot: helpers.MockBot = None) -> linter.Lint: - """A simple function to setup an instance of the linter extension - - Args: - bot (helpers.MockBot, optional): A fake bot object. Should be used if using a - fake_discord_env in the test. Defaults to None. - - Returns: - linter.Lint: The instance of the Lint class - """ - with patch("asyncio.create_task", return_value=None): - return linter.Lint(bot) - - -class Test_CheckSyntax: - """A set of tests to test check_syntax""" - - @pytest.mark.asyncio - async def test_no_error(self: Self) -> None: - """A test to ensure that nothing is retuend when there is no error""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - linter_test = setup_local_extension(discord_env.bot) - auxiliary.get_json_from_attachments = AsyncMock() - - # Step 2 - Call the function - result = await linter_test.check_syntax(discord_env.message_person1_attachments) - - # Step 3 - Assert that everything works - assert result is None - - @pytest.mark.asyncio - async def test_yes_error(self: Self) -> None: - """A test to ensure that something is returned when there is an error""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - linter_test = setup_local_extension(discord_env.bot) - auxiliary.get_json_from_attachments = AsyncMock( - side_effect=json.JSONDecodeError("1", "1", 1) - ) - - # Step 2 - Call the function - result = await linter_test.check_syntax(discord_env.message_person1_attachments) - - # Step 3 - Assert that everything works - assert result is not None - - -class Test_LintCommand: - """A set of tests to set lint_command""" - - @pytest.mark.asyncio - async def test_failed_valid_attachments(self: Self) -> None: - """A test to ensure deny embed is called when invalid attachments""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - linter_test = setup_local_extension(discord_env.bot) - auxiliary.send_deny_embed = AsyncMock() - linter_test.check_valid_attachments = MagicMock(return_value=False) - discord_env.context.message = discord_env.message_person1_attachments - - # Step 2 - Call the function - await linter_test.lint_command(discord_env.context) - - # Step 3 - Assert that everything works - auxiliary.send_deny_embed.assert_called_once_with( - message="You need to attach a single .json file", - channel=discord_env.context.channel, - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_failed_check_syntax(self: Self) -> None: - """A test to ensure deny embed is called when invalid attachments""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - linter_test = setup_local_extension(discord_env.bot) - auxiliary.send_deny_embed = AsyncMock() - linter_test.check_valid_attachments = MagicMock(return_value=True) - linter_test.check_syntax = AsyncMock(return_value="error") - discord_env.context.message = discord_env.message_person1_attachments - - # Step 2 - Call the function - await linter_test.lint_command(discord_env.context) - - # Step 3 - Assert that everything works - auxiliary.send_deny_embed.assert_called_once_with( - message="Invalid syntax!\nError thrown: `error`", - channel=discord_env.context.channel, - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_success(self: Self) -> None: - """A test to ensure confirm embed is called if everything is good""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - linter_test = setup_local_extension(discord_env.bot) - auxiliary.send_confirm_embed = AsyncMock() - linter_test.check_valid_attachments = MagicMock(return_value=True) - linter_test.check_syntax = AsyncMock(return_value=None) - discord_env.context.message = discord_env.message_person1_attachments - - # Step 2 - Call the function - await linter_test.lint_command(discord_env.context) - - # Step 3 - Assert that everything works - auxiliary.send_confirm_embed.assert_called_once_with( - message="Syntax is OK", - channel=discord_env.context.channel, - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - -class Test_CheckAttachments: - """A set of tests to test check_valid_attachments""" - - def test_no_attachments(self: Self) -> None: - """A test to ensure that False is returned if run without attachments""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - linter_test = setup_local_extension(discord_env.bot) - - # Step 2 - Call the function - result = linter_test.check_valid_attachments([]) - - # Step 3 - Assert that everything works - assert not result - - def test_two_attachments(self: Self) -> None: - """A test to ensure that False is returned if run with more than 1 attachment""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - linter_test = setup_local_extension(discord_env.bot) - - # Step 2 - Call the function - result = linter_test.check_valid_attachments( - [discord_env.json_attachment, discord_env.json_attachment] - ) - - # Step 3 - Assert that everything works - assert not result - - def test_non_json(self: Self) -> None: - """A test to ensure that False is returned if run without json attachments""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - linter_test = setup_local_extension(discord_env.bot) - - # Step 2 - Call the function - result = linter_test.check_valid_attachments([discord_env.png_attachment]) - - # Step 3 - Assert that everything works - assert not result - - def test_one_json(self: Self) -> None: - """A test to ensure that True is returned if run with 1 json attachment""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - linter_test = setup_local_extension(discord_env.bot) - - # Step 2 - Call the function - result = linter_test.check_valid_attachments([discord_env.json_attachment]) - - # Step 3 - Assert that everything works - assert result diff --git a/techsupport_bot/tests/commands_tests/test_extensions_mock.py b/techsupport_bot/tests/commands_tests/test_extensions_mock.py deleted file mode 100644 index 6952d5960..000000000 --- a/techsupport_bot/tests/commands_tests/test_extensions_mock.py +++ /dev/null @@ -1,196 +0,0 @@ -""" -This is a file to test the extensions/mock.py file -This contains 8 tests -""" - -from __future__ import annotations - -import importlib -from typing import Self -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from commands import mock -from core import auxiliary -from hypothesis import given -from hypothesis.strategies import text -from tests import config_for_tests, helpers - - -def setup_local_extension(bot: helpers.MockBot = None) -> mock.Mocker: - """A simple function to setup an instance of the mock extension - - Args: - bot (helpers.MockBot, optional): A fake bot object. Should be used if using a - fake_discord_env in the test. Defaults to None. - - Returns: - mock.Mocker: The instance of the Mocker class - """ - with patch("asyncio.create_task", return_value=None): - return mock.Mocker(bot) - - -class Test_MockCommand: - """A set of tests to test mock_command""" - - @pytest.mark.asyncio - async def test_no_message(self: Self) -> None: - """A test to ensure that when no message is found, deny_embed is called""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - mocker = setup_local_extension(discord_env.bot) - mocker.get_user_to_mock = MagicMock(return_value="username") - mocker.generate_mock_message = AsyncMock(return_value=None) - auxiliary.send_deny_embed = AsyncMock() - - # Step 2 - Call the function - await mocker.mock_command( - ctx=discord_env.context, input_user=discord_env.person2 - ) - - # Step 3 - Assert that everything works - auxiliary.send_deny_embed.assert_called_once_with( - message="No message found for user username", - channel=discord_env.context.channel, - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_send_call(self: Self) -> None: - """A test to ensure that ctx.send is called correctly""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - mocker = setup_local_extension(discord_env.bot) - mocker.get_user_to_mock = MagicMock() - mocker.generate_mock_message = AsyncMock(return_value="message") - auxiliary.send_deny_embed = AsyncMock() - auxiliary.generate_basic_embed = MagicMock(return_value="embed") - discord_env.context.send = AsyncMock() - - # Step 2 - Call the function - await mocker.mock_command( - ctx=discord_env.context, input_user=discord_env.person2 - ) - - # Step 3 - Assert that everything works - discord_env.context.send.assert_called_once_with(embed="embed") - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - -class Test_GenerateMockMessage: - """A set of tests to test generate_mock_message""" - - @pytest.mark.asyncio - async def test_no_message_found(self: Self) -> None: - """A test to ensure that when no message is found, None is returned""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - mocker = setup_local_extension(discord_env.bot) - auxiliary.search_channel_for_message = AsyncMock(return_value=None) - - # Step 2 - Call the function - result = await mocker.generate_mock_message( - channel=discord_env.channel, - user=discord_env.person2, - prefix=config_for_tests.PREFIX, - ) - - # Step 3 - Assert that everything works - assert result is None - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_message_found(self: Self) -> None: - """A test to ensure that when a message is found, prepare_mock_message is called""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - mocker = setup_local_extension(discord_env.bot) - mocker.prepare_mock_message = MagicMock() - auxiliary.search_channel_for_message = AsyncMock( - return_value=discord_env.message_person1_noprefix_1 - ) - - # Step 2 - Call the function - await mocker.generate_mock_message( - channel=discord_env.channel, - user=discord_env.person2, - prefix=config_for_tests.PREFIX, - ) - - # Step 3 - Assert that everything works - mocker.prepare_mock_message.assert_called_once_with( - discord_env.message_person1_noprefix_1.clean_content - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - -class Test_GetUser: - """A set of tests to test get_user_to_mock""" - - def test_with_bot(self: Self) -> None: - """A test to ensure that when a bot is mocked, the calling user is mocked instead""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - mocker = setup_local_extension(discord_env.bot) - - # Step 2 - Call the function - result = mocker.get_user_to_mock( - ctx=discord_env.context, input_user=discord_env.person3_bot - ) - - # Step 3 - Assert that everything works - assert result == discord_env.person1 - - def test_without_bot(self: Self) -> None: - """A test to ensure that when not a bot is mocked, the same user is returned""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - mocker = setup_local_extension(discord_env.bot) - - # Step 2 - Call the function - result = mocker.get_user_to_mock( - ctx=discord_env.context, input_user=discord_env.person1 - ) - - # Step 3 - Assert that everything works - assert result == discord_env.person1 - - -class Test_PrepareMockMessage: - """A set of tests to test prepare_mock_message""" - - def test_with_set_string(self: Self) -> None: - """A test to ensure that upper/lower works""" - # Step 1 - Setup env - mocker = setup_local_extension() - - # Step 2 - Call the function - result = mocker.prepare_mock_message(message="abcd") - - # Step 3 - Assert that everything works - assert result == "AbCd" - - @given(text()) - def test_with_random_string(self: Self, input_message: str) -> None: - """A property test to ensure that mocked message isn't getting smaller - - Args: - input_message (str): A random message to to prepare_mock_message with - """ - # Step 1 - Setup env - mocker = setup_local_extension() - - # Step 2 - Call the function - result = mocker.prepare_mock_message(message=input_message) - - # Step 3 - Assert that everything works - assert len(result) >= len(input_message) diff --git a/techsupport_bot/tests/commands_tests/test_extensions_roll.py b/techsupport_bot/tests/commands_tests/test_extensions_roll.py deleted file mode 100644 index 456553fa2..000000000 --- a/techsupport_bot/tests/commands_tests/test_extensions_roll.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -This is a file to test the extensions/roll.py file -This contains 3 tests -""" - -from __future__ import annotations - -import importlib -from typing import Self -from unittest.mock import AsyncMock, MagicMock, patch - -import discord -import pytest -from commands import roll -from core import auxiliary -from hypothesis import given -from hypothesis.strategies import integers -from tests import config_for_tests, helpers - - -def setup_local_extension(bot: helpers.MockBot = None) -> roll.Roller: - """A simple function to setup an instance of the roll extension - - Args: - bot (helpers.MockBot, optional): A fake bot object. Should be used if using a - fake_discord_env in the test. Defaults to None. - - Returns: - roll.Roller: The instance of the Roller class - """ - with patch("asyncio.create_task", return_value=None): - return roll.Roller(bot) - - -class Test_RollCommand: - """A set of tests to test roll_command""" - - @pytest.mark.asyncio - async def test_generate_embed(self: Self) -> None: - """A test to ensure that generate_basic_embed is called correctly""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - roller = setup_local_extension(discord_env.bot) - roller.get_roll_number = MagicMock(return_value=5) - auxiliary.generate_basic_embed = MagicMock() - discord_env.context.send = AsyncMock() - - # Step 2 - Call the function - await roller.roll_command(ctx=discord_env.context, min_value=1, max_value=10) - - # Step 3 - Assert that everything works - auxiliary.generate_basic_embed.assert_called_once_with( - title="RNG Roller", - description="You rolled a 5", - color=discord.Color.gold(), - url=roller.ICON_URL, - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_roll_calls_send(self: Self) -> None: - """A test to ensure that ctx.send is called correctly""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - roller = setup_local_extension(discord_env.bot) - roller.get_roll_number = MagicMock(return_value=5) - auxiliary.generate_basic_embed = MagicMock(return_value="embed") - discord_env.context.send = AsyncMock() - - # Step 2 - Call the function - await roller.roll_command(ctx=discord_env.context, min_value=1, max_value=10) - - # Step 3 - Assert that everything works - discord_env.context.send.assert_called_once_with(embed="embed") - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - -class Test_RandomNumber: - """A single test to test get_roll_number""" - - @given(integers(), integers()) - def test_random_numbers(self: Self, min_value: int, max_value: int) -> None: - """A property test to ensure that random number doesn't return anything unexpected - - Args: - min_value (int): A random int to text roll bounds with - max_value (int): Another random int to test roll bounds with - """ - # Step 1 - Setup env - roller = setup_local_extension() - if min_value > max_value: - temp = min_value - max_value = min_value - min_value = temp - - # Step 2 - Call the function - result = roller.get_roll_number(min_value=min_value, max_value=max_value) - - # Step 3 - Assert that everything works - assert isinstance(result, int) - assert min_value <= result <= max_value diff --git a/techsupport_bot/tests/commands_tests/test_extensions_translate.py b/techsupport_bot/tests/commands_tests/test_extensions_translate.py new file mode 100644 index 000000000..53238191c --- /dev/null +++ b/techsupport_bot/tests/commands_tests/test_extensions_translate.py @@ -0,0 +1,114 @@ +""" +This is a file to test the extensions/translate.py file +This contains tests for helper functions in translate.py +""" + +from __future__ import annotations + +from typing import Self + +from commands import translate + + +class Test_NormalizeTranslationInput: + """Tests for normalize_translation_input""" + + def test_normalization(self: Self) -> None: + """Ensures message whitespace is trimmed and language codes are lowercased""" + # Step 1 - Call the function + normalized_message, normalized_src, normalized_dest = ( + translate.normalize_translation_input(" Hello world ", " EN ", " ES ") + ) + + # Step 2 - Assert that everything works + assert normalized_message == "Hello world" + assert normalized_src == "en" + assert normalized_dest == "es" + + +class Test_ValidateTranslationInputs: + """Tests for validate_translation_inputs""" + + def test_missing_message(self: Self) -> None: + """Ensures missing message input is rejected""" + # Step 1 - Call the function + error_message = translate.validate_translation_inputs("", "en", "es") + + # Step 2 - Assert that everything works + assert error_message == "You need to provide a message to translate" + + def test_missing_source(self: Self) -> None: + """Ensures missing source code is rejected""" + # Step 1 - Call the function + error_message = translate.validate_translation_inputs("hello", "", "es") + + # Step 2 - Assert that everything works + assert error_message == "You need to provide a source language code" + + def test_missing_dest(self: Self) -> None: + """Ensures missing destination code is rejected""" + # Step 1 - Call the function + error_message = translate.validate_translation_inputs("hello", "en", "") + + # Step 2 - Assert that everything works + assert error_message == "You need to provide a destination language code" + + def test_valid_inputs(self: Self) -> None: + """Ensures valid translation values pass validation""" + # Step 1 - Call the function + error_message = translate.validate_translation_inputs("hello", "en", "es") + + # Step 2 - Assert that everything works + assert error_message is None + + +class Test_BuildTranslateUrl: + """Tests for build_translate_url""" + + def test_build_translate_url(self: Self) -> None: + """Ensures URL template is formatted correctly""" + # Step 1 - Call the function + url = translate.build_translate_url( + "https://example.com?q={}&langpair={}|{}", "hello", "en", "es" + ) + + # Step 2 - Assert that everything works + assert url == "https://example.com?q=hello&langpair=en|es" + + +class Test_ExtractTranslatedText: + """Tests for extract_translated_text""" + + def test_valid_translated_text(self: Self) -> None: + """Ensures translated text is returned when response shape is valid""" + # Step 1 - Setup env + response = {"responseData": {"translatedText": "hola"}} + + # Step 2 - Call the function + translated = translate.extract_translated_text(response) + + # Step 3 - Assert that everything works + assert translated == "hola" + + def test_invalid_translated_text(self: Self) -> None: + """Ensures None is returned for malformed responses""" + # Step 1 - Setup env + response = {"responseData": {}} + + # Step 2 - Call the function + translated = translate.extract_translated_text(response) + + # Step 3 - Assert that everything works + assert translated is None + + +class Test_BuildTranslationFailureMessage: + """Tests for build_translation_failure_message""" + + def test_build_translation_failure_message(self: Self) -> None: + """Ensures translation failure text is consistent""" + # Step 1 - Call the function + message = translate.build_translation_failure_message() + + # Step 2 - Assert that everything works + assert message == "I could not translate your message" diff --git a/techsupport_bot/tests/commands_tests/test_extensions_weather.py b/techsupport_bot/tests/commands_tests/test_extensions_weather.py new file mode 100644 index 000000000..378d3d90a --- /dev/null +++ b/techsupport_bot/tests/commands_tests/test_extensions_weather.py @@ -0,0 +1,132 @@ +""" +This is a file to test the extensions/weather.py file +This contains tests for helper functions in weather.py +""" + +from __future__ import annotations + +from typing import Self + +import munch +from commands import weather + + +class Test_BuildWeatherQuery: + """Tests for build_weather_query""" + + def test_city_only(self: Self) -> None: + """Ensures city-only query formatting works""" + # Step 1 - Call the function + query = weather.build_weather_query("Austin") + + # Step 2 - Assert that everything works + assert query == "Austin" + + def test_three_parts(self: Self) -> None: + """Ensures city/state/country formatting works""" + # Step 1 - Call the function + query = weather.build_weather_query("Austin", "TX", "US") + + # Step 2 - Assert that everything works + assert query == "Austin,TX,US" + + def test_ignores_empty_parts(self: Self) -> None: + """Ensures blank optional parts are removed from the query""" + # Step 1 - Call the function + query = weather.build_weather_query("Austin", " ", None) + + # Step 2 - Assert that everything works + assert query == "Austin" + + +class Test_BuildWeatherUrl: + """Tests for build_weather_url""" + + def test_weather_url(self: Self) -> None: + """Ensures API URL includes query, units, and key""" + # Step 1 - Call the function + url = weather.build_weather_url("Austin,TX,US", "ABC123") + + # Step 2 - Assert that everything works + assert url == ( + "http://api.openweathermap.org/data/2.5/weather?" + "q=Austin,TX,US&units=imperial&appid=ABC123" + ) + + +class Test_FahrenheitToCelsius: + """Tests for fahrenheit_to_celsius""" + + def test_freezing_point(self: Self) -> None: + """Ensures 32F converts to 0C""" + # Step 1 - Call the function + output = weather.fahrenheit_to_celsius(32) + + # Step 2 - Assert that everything works + assert output == 0 + + def test_boiling_point(self: Self) -> None: + """Ensures 212F converts to 100C""" + # Step 1 - Call the function + output = weather.fahrenheit_to_celsius(212) + + # Step 2 - Assert that everything works + assert output == 100 + + +class Test_FormatDualTemperature: + """Tests for format_dual_temperature""" + + def test_format_dual_temperature(self: Self) -> None: + """Ensures dual-format temperature text renders correctly""" + # Step 1 - Call the function + output = weather.format_dual_temperature(68) + + # Step 2 - Assert that everything works + assert output == "68°F (20°C)" + + +class Test_ExtractWeatherFields: + """Tests for extract_weather_fields""" + + def test_valid_response(self: Self) -> None: + """Ensures a valid response is converted to render-ready fields""" + # Step 1 - Setup env + response = munch.munchify( + { + "name": "Austin", + "sys": {"country": "US"}, + "weather": [{"description": "clear sky"}, {"description": "dry"}], + "main": { + "temp": 68, + "feels_like": 65, + "temp_min": 60, + "temp_max": 75, + "humidity": 45, + }, + } + ) + + # Step 2 - Call the function + fields = weather.extract_weather_fields(response) + + # Step 3 - Assert that everything works + assert fields == { + "title": "Weather for Austin (US)", + "description": "clear sky, dry", + "temp": "68°F (20°C) (feels like 65°F (18°C))", + "low": "60°F (16°C)", + "high": "75°F (24°C)", + "humidity": "45 %", + } + + def test_invalid_response(self: Self) -> None: + """Ensures malformed responses return None""" + # Step 1 - Setup env + response = munch.munchify({"name": "Austin"}) + + # Step 2 - Call the function + fields = weather.extract_weather_fields(response) + + # Step 3 - Assert that everything works + assert fields is None diff --git a/techsupport_bot/tests/commands_tests/test_extensions_wyr.py b/techsupport_bot/tests/commands_tests/test_extensions_wyr.py deleted file mode 100644 index d16490399..000000000 --- a/techsupport_bot/tests/commands_tests/test_extensions_wyr.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -This is a file to test the extensions/wyr.py file -This contains 7 tests -""" - -from __future__ import annotations - -import importlib -import random -from typing import Self -from unittest.mock import AsyncMock, MagicMock, mock_open, patch - -import discord -import pytest -from commands import wyr -from core import auxiliary -from tests import config_for_tests, helpers - - -def setup_local_extension(bot: helpers.MockBot = None) -> wyr.WouldYouRather: - """A simple function to setup an instance of the wyr extension - - Args: - bot (helpers.MockBot, optional): A fake bot object. Should be used if using a - fake_discord_env in the test. Defaults to None. - - Returns: - wyr.WouldYouRather: The instance of the WouldYouRather class - """ - with patch("asyncio.create_task", return_value=None): - return wyr.WouldYouRather(bot) - - -class Test_Preconfig: - """A test to test the preconfig function""" - - @pytest.mark.asyncio - async def test_preconfig(self: Self) -> None: - """A test to ensure that preconfig sets the last variable correctly""" - # Step 1 - Setup env - wyr_test = setup_local_extension() - - # Step 2 - Call the function - await wyr_test.preconfig() - - # Step 3 - Assert that everything works - assert wyr_test.last is None - - -class Test_WYR_Command: - """A set of tests to test the wyr command function""" - - @pytest.mark.asyncio - async def test_wyr_command_embed(self: Self) -> None: - """A test to ensure that the embed is generated correctly""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - wyr_test = setup_local_extension(discord_env.bot) - wyr_test.get_question = MagicMock(return_value='"real" || "question"') - auxiliary.generate_basic_embed = MagicMock(return_value="embed") - discord_env.context.send = AsyncMock() - - # Step 2 - Call the function - await wyr_test.wyr_command(discord_env.context) - - # Step 3 - Assert that everything works - auxiliary.generate_basic_embed.assert_called_once_with( - title="Would you rather...", - description="Real, or question?", - color=discord.Color.blurple(), - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_wyr_command_send(self: Self) -> None: - """A test to ensure that the send command is called with the generated embed""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - wyr_test = setup_local_extension(discord_env.bot) - wyr_test.get_question = MagicMock(return_value="Real question") - auxiliary.generate_basic_embed = MagicMock(return_value="embed") - discord_env.context.send = AsyncMock() - - # Step 2 - Call the function - await wyr_test.wyr_command(discord_env.context) - - # Step 3 - Assert that everything works - discord_env.context.send.assert_called_once_with(embed="embed") - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - -class Test_Get_Question: - """A set of tests to test the get_question function - - Attributes: - sample_resource (str): A set of same questions for doing unit tests - """ - - sample_resource: str = '"q1o1" || "q1o2"\n"q2o1" || "q2o2"' - - def test_any_question(self: Self) -> None: - """Ensure that get_question gets any question""" - # Step 1 - Setup env - wyr_test = setup_local_extension() - wyr_test.last = None - - # Step 2 - Call the function - with patch("builtins.open", mock_open(read_data=self.sample_resource)): - question = wyr_test.get_question() - - # Step 3 - Assert that everything works - assert isinstance(question, str) - assert question != "" - - def test_resource_read(self: Self) -> None: - """A test to ensure that the resource file is parsed correctly""" - # Step 1 - Setup env - wyr_test = setup_local_extension() - wyr_test.last = None - - # Step 2 - Call the function - with patch("builtins.open", mock_open(read_data=self.sample_resource)): - question = wyr_test.get_question() - - # Step 3 - Assert that everything works - assert question in ['"q1o1" || "q1o2"', '"q2o1" || "q2o2"'] - - # Step 4 - Cleanup - importlib.reload(random) - - def test_non_repeat_question(self: Self) -> None: - """A test to ensure that a random question can never occur twice""" - # Step 1 - Setup env - wyr_test = setup_local_extension() - wyr_test.last = '"q1o1" || "q1o2"' - - # Step 2 - Call the function - with patch("builtins.open", mock_open(read_data=self.sample_resource)): - question = wyr_test.get_question() - - # Step 3 - Assert that everything works - assert question == '"q2o1" || "q2o2"' - - # Step 4 - Cleanup - importlib.reload(random) - - def test_last_set(self: Self) -> None: - """Ensure that the last variable is properly set""" - # Step 1 - Setup env - wyr_test = setup_local_extension() - wyr_test.last = None - - # Step 2 - Call the function - with patch("builtins.open", mock_open(read_data=self.sample_resource)): - question = wyr_test.get_question() - - # Step 3 - Assert that everything works - assert wyr_test.last is question - - def test_create_question_string(self: Self) -> None: - """Ensure that the string is properly turned into - a question""" - # Step 1 - Setup env - wyr_test = setup_local_extension() - - # Step 2 - Call the function - resource_string = wyr_test.create_question_string('"q1o1" || "q1o2"') - - # Step 3 - Assert that everything works - assert resource_string == "Q1o1, or q1o2?" From 826b02208741941347a77a3ef4dd79eaf1842c5d Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:50:56 -0800 Subject: [PATCH 2/5] Undo pipfile change --- Pipfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pipfile b/Pipfile index 100a2ee13..e8c32ec4a 100644 --- a/Pipfile +++ b/Pipfile @@ -37,4 +37,4 @@ pyyaml = "==6.0.3" unidecode = "==1.4.0" [requires] -python_version = "3.13" +python_version = "3.11" From ddad36b9a1cb6735d5d4801666467fd62aa8fac0 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:57:37 -0800 Subject: [PATCH 3/5] Remove duplicate function --- techsupport_bot/commands/duck.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/techsupport_bot/commands/duck.py b/techsupport_bot/commands/duck.py index 4c366476e..232cd52a4 100644 --- a/techsupport_bot/commands/duck.py +++ b/techsupport_bot/commands/duck.py @@ -1143,25 +1143,3 @@ def random_choice(self: Self, config: munch.Munch) -> bool: random.choices([True, False], weights=weights, k=100000) ) return choice_ - - def random_choice(self: Self, config: munch.Munch) -> bool: - """A function to pick true or false randomly based on the success_rate in the config - - Args: - config (munch.Munch): The config for the guild - - Returns: - bool: Whether the random choice should succeed or not - """ - - weights = ( - config.extensions.duck.success_rate.value, - 100 - config.extensions.duck.success_rate.value, - ) - - # Check to see if random failure - choice_ = random.choice( - random.choices([True, False], weights=weights, k=100000) - ) - - return choice_ From b2fa647b5487240e46f0e607d22a768e69d04baf Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:04:27 -0800 Subject: [PATCH 4/5] Fix some docstrings --- techsupport_bot/commands/burn.py | 8 +++----- techsupport_bot/commands/duck.py | 1 + techsupport_bot/commands/echo.py | 3 +++ techsupport_bot/commands/google.py | 1 + techsupport_bot/commands/hug.py | 8 +++----- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/techsupport_bot/commands/burn.py b/techsupport_bot/commands/burn.py index 8fe898c67..a572fbf0a 100644 --- a/techsupport_bot/commands/burn.py +++ b/techsupport_bot/commands/burn.py @@ -31,6 +31,9 @@ class Burn(cogs.BaseCog): Attributes: PHRASES (list[str]): The list of phrases to pick from + + Args: + bot (bot.TechSupportBot): The bot instance """ PHRASES: list[str] = [ @@ -43,11 +46,6 @@ class Burn(cogs.BaseCog): ] def __init__(self: Self, bot: bot.TechSupportBot) -> None: - """Initializes burn command handlers and registers context menu command. - - Args: - bot (bot.TechSupportBot): The bot instance - """ super().__init__(bot=bot) self.ctx_menu = app_commands.ContextMenu( name="Declare Burn", diff --git a/techsupport_bot/commands/duck.py b/techsupport_bot/commands/duck.py index 232cd52a4..02f29daab 100644 --- a/techsupport_bot/commands/duck.py +++ b/techsupport_bot/commands/duck.py @@ -285,6 +285,7 @@ class DuckHunt(cogs.LoopCog): KILL_URL (str): The picture for the kill target ON_START (bool): ??? CHANNELS_KEY (str): The config item for the channels that the duck hunt should run + duck_group (app_commands.Group): The group for the /duck commands """ DUCK_PIC_URL: str = ( diff --git a/techsupport_bot/commands/echo.py b/techsupport_bot/commands/echo.py index d38e920c2..eddf9583a 100644 --- a/techsupport_bot/commands/echo.py +++ b/techsupport_bot/commands/echo.py @@ -34,6 +34,9 @@ async def setup(bot: bot.TechSupportBot) -> None: class MessageEcho(cogs.BaseCog): """ The class that holds the echo commands + + Attributes: + ehco_group (app_commands.Group): The group for the /echo commands """ echo_group: app_commands.Group = app_commands.Group( diff --git a/techsupport_bot/commands/google.py b/techsupport_bot/commands/google.py index 179bac403..52c026da2 100644 --- a/techsupport_bot/commands/google.py +++ b/techsupport_bot/commands/google.py @@ -213,6 +213,7 @@ class Googler(cogs.BaseCog): GOOGLE_URL (str): The API URL for google search YOUTUBE_URL (str): The API URL for youtube search ICON_URL (str): The google icon + google_group (app_commands.Group): The group for the /google commands """ GOOGLE_URL: str = "https://www.googleapis.com/customsearch/v1" diff --git a/techsupport_bot/commands/hug.py b/techsupport_bot/commands/hug.py index 8a3c6a347..62f534578 100644 --- a/techsupport_bot/commands/hug.py +++ b/techsupport_bot/commands/hug.py @@ -112,6 +112,9 @@ class Hugger(cogs.BaseCog): HUGS_SELECTION (list[str]): The list of hug phrases to display ICON_URL (str): The icon to use when hugging + Args: + bot (bot.TechSupportBot): The bot instance + """ HUGS_SELECTION: list[str] = [ @@ -145,11 +148,6 @@ class Hugger(cogs.BaseCog): ) def __init__(self: Self, bot: bot.TechSupportBot) -> None: - """Initializes hug commands and registers the user context menu. - - Args: - bot (bot.TechSupportBot): The bot instance - """ super().__init__(bot=bot) self.user_context_menu = app_commands.ContextMenu( name="Hug User", From 19ed19ab403cd3009be12fd3e3883adf53dceebb Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:06:41 -0800 Subject: [PATCH 5/5] Fix some docstrings the second --- techsupport_bot/commands/echo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/techsupport_bot/commands/echo.py b/techsupport_bot/commands/echo.py index eddf9583a..003541679 100644 --- a/techsupport_bot/commands/echo.py +++ b/techsupport_bot/commands/echo.py @@ -36,7 +36,7 @@ class MessageEcho(cogs.BaseCog): The class that holds the echo commands Attributes: - ehco_group (app_commands.Group): The group for the /echo commands + echo_group (app_commands.Group): The group for the /echo commands """ echo_group: app_commands.Group = app_commands.Group(