diff --git a/src/valentina/cogs/developer.py b/src/valentina/cogs/developer.py index c840e46f..c8afa35b 100644 --- a/src/valentina/cogs/developer.py +++ b/src/valentina/cogs/developer.py @@ -7,6 +7,7 @@ import aiofiles import discord import inflect +import semver from discord.commands import Option from discord.ext import commands from loguru import logger @@ -15,9 +16,15 @@ from valentina.constants import MAX_CHARACTER_COUNT, EmbedColor from valentina.models.bot import Valentina from valentina.models.db_tables import Character, CharacterClass, RollProbability, VampireClan +from valentina.utils.changelog_parser import ChangelogParser from valentina.utils.converters import ValidCharacterClass from valentina.utils.helpers import fetch_random_name -from valentina.utils.options import select_aws_object_from_guild, select_char_class +from valentina.utils.options import ( + select_aws_object_from_guild, + select_changelog_version_1, + select_changelog_version_2, + select_char_class, +) from valentina.views import confirm_action, present_embed p = inflect.engine() @@ -236,6 +243,74 @@ async def delete_developer_characters( await confirmation_response_msg + @guild.command(description="Repost the changelog (run in #changelog)") + @commands.is_owner() + @commands.guild_only() + async def repost_changelog( + self, + ctx: discord.ApplicationContext, + oldest_version: Option(str, autocomplete=select_changelog_version_1, required=True), + newest_version: Option(str, autocomplete=select_changelog_version_2, required=True), + ) -> None: + """Post the changelog.""" + if semver.compare(oldest_version, newest_version) > 0: + raise commands.BadArgument( + f"Oldest version `{oldest_version}` is newer than newest version `{newest_version}`" + ) + + changelog_channel = self.bot.guild_svc.fetch_changelog_channel(ctx.guild) + if not changelog_channel: + await ctx.respond( + embed=discord.Embed( + title="Can not post changelog", + description="No changelog channel set", + color=EmbedColor.ERROR.value, + ) + ) + return + + # Grab the changelog + changelog = ChangelogParser( + self.bot, + oldest_version, + newest_version, + exclude_categories=[ + "docs", + "refactor", + "style", + "test", + "chore", + "perf", + "ci", + "build", + ], + ) + if not changelog.has_updates(): + await ctx.respond( + embed=discord.Embed( + title="Can not post changelog", + description="No updates found which pass the exclude list", + color=EmbedColor.ERROR.value, + ) + ) + return + + # Update the last posted version in guild settings + updates = {"changelog_posted_version": newest_version} + self.bot.guild_svc.update_or_add(guild=ctx.guild, updates=updates) + + # Post the changelog + embed = changelog.get_embed_personality() + await changelog_channel.send(embed=embed) + + await ctx.respond( + embed=discord.Embed( + description=f"Changelog reposted and settings`[changelog_posted_version]` updated to `{newest_version}`", + color=EmbedColor.SUCCESS.value, + ), + ephemeral=True, + ) + @guild.command(name="purge_cache", description="Purge this guild's cache") @commands.guild_only() @commands.is_owner() diff --git a/src/valentina/cogs/misc.py b/src/valentina/cogs/misc.py index fe2b9045..a6c39128 100644 --- a/src/valentina/cogs/misc.py +++ b/src/valentina/cogs/misc.py @@ -1,18 +1,18 @@ # mypy: disable-error-code="valid-type" """Miscellaneous commands.""" - import random -from pathlib import Path import discord +import semver from discord.commands import Option from discord.ext import commands -from loguru import logger from valentina.constants import SPACER, EmbedColor from valentina.models import Statistics from valentina.models.bot import Valentina from valentina.models.db_tables import Character, Macro +from valentina.utils.changelog_parser import ChangelogParser +from valentina.utils.options import select_changelog_version_1, select_changelog_version_2 class Misc(commands.Cog): @@ -84,53 +84,27 @@ async def user_info( await ctx.respond(embed=embed, ephemeral=hidden) - @commands.slash_command(description="Display the bot's changelog") - async def changelog( + @commands.slash_command(name="changelog", description="Display the bot's changelog") + async def post_changelog( self, - ctx: commands.Context, + ctx: discord.ApplicationContext, + oldest_version: Option(str, autocomplete=select_changelog_version_1, required=True), + newest_version: Option(str, autocomplete=select_changelog_version_2, required=True), hidden: Option( bool, - description="Make the changelog only visible to you (default true).", + description="Make the response only visible to you (default true).", default=True, ), ) -> None: - """Display the bot's changelog. - - Args: - ctx (commands.Context): The context of the command. - hidden (Option[bool]): Whether to make the changelog only visible to you (default true). - """ - # Determine the path to the changelog file - path = Path(__file__).parent / "../../../CHANGELOG.md" - if not path.exists(): - logger.error(f"Changelog file not found at {path}") - raise FileNotFoundError - - changelog = path.read_text() - - # Use paginator to split the changelog into pages - paginator = discord.ext.commands.Paginator(prefix="", suffix="", max_size=800) - for line in changelog.split("\n"): - paginator.add_line(line) - - # Create embeds for each page of the changelog - pages_to_send = [ - discord.Embed( - title="Valentina Changelog", - description=page, - url="https://github.com/natelandau/valentina/releases", - ).set_thumbnail(url=ctx.bot.user.display_avatar) - for page in paginator.pages - ] - - show_buttons = len(pages_to_send) > 1 - paginator = discord.ext.pages.Paginator( # type: ignore [assignment] - pages=pages_to_send, # type: ignore [arg-type] - author_check=False, - show_disabled=show_buttons, - show_indicator=show_buttons, - ) - await paginator.respond(ctx.interaction, ephemeral=hidden) # type: ignore + """Post the changelog.""" + if semver.compare(oldest_version, newest_version) > 0: + raise commands.BadArgument( + f"Oldest version `{oldest_version}` is newer than newest version `{newest_version}`" + ) + + changelog = ChangelogParser(self.bot, oldest_version, newest_version) + embed = changelog.get_embed() + await ctx.respond(embed=embed, ephemeral=hidden) @commands.slash_command(name="coinflip", help="Flip a coin") async def coinflip(self, ctx: discord.ApplicationContext) -> None: diff --git a/src/valentina/constants.py b/src/valentina/constants.py index 852d8528..fd9bbb34 100644 --- a/src/valentina/constants.py +++ b/src/valentina/constants.py @@ -352,35 +352,45 @@ class XPMultiplier(Enum): "lusty liberator freeing you from virtue, only to imprison you in vice", "siren who serenades you into peril", "black widow with a kiss that's fatal", + "fiery femme fatale who leaves you burned but begging for more", "enchanting empress who rules your most forbidden thoughts", "vixen who leaves a trail of destruction", "sublime seductress who dances you to the edge of reason", "irresistible icon who redefines your sense of sin and salvation" "enchantress who captivates you in her web of deceit", + "sultry Silver Fang who leads you into a world of primal passion", "seductress with eyes that promise ecstasy and chaos", "dazzling temptress with daggers in her eyes", "spellbinding witch who makes you forget your name", "goddess who gives pleasure but exacts a price", "alluring angel with a devilish twist", - "bot who helps you play White Wolf's TTRPGs", + "trusted bot who helps you play White Wolf's TTRPGs", "succubus who will yet have your heart", "maid servant here to serve your deepest desires", "guardian angel who watches over you", - "Lasombra enigma who makes darkness your newfound comfort", + "steadfast Silent Strider who journeys through the Umbra on your behalf", + "trustworthy Thaumaturge who crafts potent rituals for your adventures", + "Lasombra who makes darkness your newfound comfort", + "seductive Toreador who makes eternity seem too short", + "enigmatic Tremere who binds you in a blood bond you can't resist", + "charismatic Ventrue who rules your heart with an iron fist", + "shadowy Nosferatu who lurks in the dark corners of your fantasies", + "haunting Wraith who whispers sweet nothings from the Shadowlands", + "resilient Hunter who makes you question who's really being hunted", "Tzimisce alchemist who shapes flesh and mind into a twisted masterpiece", "Giovanni necromancer who invites you to a banquet with your ancestors", "Assamite assassin who turns the thrill of the hunt into a deadly romance", "Caitiff outcast who makes you see the allure in being a pariah", - "Malkavian seer who unravels the tapestry of your sanity with whispers of prophecies" - "Brujah revolutionary who ignites a riot in your soul and a burning need for rebellion" - "Tremere warlock who binds your fate with arcane secrets too irresistible to ignore" - "Toreador muse who crafts a masterpiece out of your every emotion, leaving you entranced" - "Gangrel shape-shifter who lures you into the untamed wilderness of your darkest desires" - "Ravnos trickster who casts illusions that make you question the very fabric of your reality" - "Sabbat crusader who drags you into a nightmarish baptism of blood and fire, challenging your very essence" - "Ventrue aristocrat who ensnares you in a web of high-stakes politics, making you question your loyalties" - "Hunter zealot who stalks the shadows of your mind, making you question your beliefs" - "Mage sorcerer who weaves a tapestry of cosmic mysteries, entrancing your logical faculties" - "Mystic oracle who plunges you into ethereal visions, making you question the tangible world" + "Malkavian seer who unravels the tapestry of your sanity with whispers of prophecies", + "Brujah revolutionary who ignites a riot in your soul and a burning need for rebellion", + "Tremere warlock who binds your fate with arcane secrets too irresistible to ignore", + "Toreador muse who crafts a masterpiece out of your every emotion, leaving you entranced", + "Gangrel shape-shifter who lures you into the untamed wilderness of your darkest desires", + "Ravnos trickster who casts illusions that make you question the very fabric of your reality", + "Sabbat crusader who drags you into a nightmarish baptism of blood and fire, challenging your very essence", + "Ventrue aristocrat who ensnares you in a web of high-stakes politics, making you question your loyalties", + "Hunter zealot who stalks the shadows of your mind, making you question your beliefs", + "enigmatic sorcerer weaving a tapestry of cosmic mysteries, entrancing your logical faculties", + "mystic oracle who plunges you into ethereal visions, making you question the tangible world", "servant who feasts on your vulnerabilities, creating an insatiable need for servitude", ] diff --git a/src/valentina/models/bot.py b/src/valentina/models/bot.py index ff839bc5..7cc0196e 100644 --- a/src/valentina/models/bot.py +++ b/src/valentina/models/bot.py @@ -35,7 +35,7 @@ def __init__(self, parent_dir: Path, config: dict, version: str, *args: Any, **k # Create in-memory caches self.db_svc = DatabaseService(DATABASE) - self.guild_svc = GuildService() + self.guild_svc = GuildService(bot=self) self.char_svc = CharacterService() self.campaign_svc = CampaignService() self.trait_svc = TraitService() diff --git a/src/valentina/models/guilds.py b/src/valentina/models/guilds.py index 4bb5127a..a5b21935 100644 --- a/src/valentina/models/guilds.py +++ b/src/valentina/models/guilds.py @@ -26,9 +26,11 @@ class GuildService: """Manage guilds in the database. Guilds are created on bot connect.""" - def __init__(self) -> None: + def __init__(self, bot: commands.Bot) -> None: + self.bot: commands.Bot = bot self.settings_cache: dict[int, dict[str, str | int | bool]] = {} self.roll_result_thumbs: dict[int, dict[str, list[str]]] = {} + self.changelog_versions_cache: list[str] = [] def _message_to_embed( self, message: str, ctx: discord.ApplicationContext @@ -95,7 +97,7 @@ def add_roll_result_thumb( Returns: None """ - ctx.bot.user_svc.fetch_user(ctx) # type: ignore [attr-defined] # it really is defined + self.bot.user_svc.fetch_user(ctx) # type: ignore [attr-defined] # it really is defined self.roll_result_thumbs.pop(ctx.guild.id, None) @@ -167,6 +169,13 @@ async def channel_update_or_add( return channel_object + def fetch_changelog_versions(self) -> list[str]: + """Fetch a list of versions from the changelog.""" + if not self.changelog_versions_cache: + self.changelog_versions_cache = ChangelogParser(self.bot).list_of_versions() + + return self.changelog_versions_cache + def fetch_audit_log_channel(self, guild: discord.Guild) -> discord.TextChannel | None: """Retrieve the audit log channel for the guild from the settings. @@ -350,9 +359,9 @@ async def post_changelog(self, guild: discord.Guild, bot: commands.Bot) -> None: settings = self.fetch_guild_settings(guild) last_posted_version = cast(str, settings.get("changelog_posted_version", None)) - # If no last posted version, get the second latest version + # If no version has been posted yet in the guild, get the second latest version if not last_posted_version: - last_posted_version = ChangelogParser(bot).list_of_versions()[1] + last_posted_version = self.fetch_changelog_versions()[1] # Check if there are any updates to post if semver.compare(last_posted_version, db_version) == 0: @@ -385,8 +394,8 @@ async def post_changelog(self, guild: discord.Guild, bot: commands.Bot) -> None: logger.debug(f"CHANGELOG: Post changelog to {guild.name}") # Update the last posted version in guild settings - settings["changelog_posted_version"] = db_version - self.update_or_add(guild=guild, updates=settings) + updates = {"changelog_posted_version": db_version} + self.update_or_add(guild=guild, updates=updates) def purge_cache( self, @@ -407,10 +416,12 @@ def purge_cache( if ctx or guild: self.settings_cache.pop(guild.id, None) self.roll_result_thumbs.pop(guild.id, None) + self.changelog_versions_cache = [] logger.debug(f"CACHE: Purge guild cache for '{guild.name}'") else: self.settings_cache = {} self.roll_result_thumbs = {} + self.changelog_versions_cache = [] logger.debug("CACHE: Purge all guild caches") async def send_to_audit_log( diff --git a/src/valentina/utils/changelog_parser.py b/src/valentina/utils/changelog_parser.py index 7d817f33..e0fc87c9 100644 --- a/src/valentina/utils/changelog_parser.py +++ b/src/valentina/utils/changelog_parser.py @@ -22,6 +22,18 @@ def __init__( ): self.path = CHANGELOG_PATH self.bot = bot + self.all_categories = [ + "feat", + "fix", + "docs", + "refactor", + "style", + "test", + "chore", + "perf", + "ci", + "build", + ] self.exclude_categories = exclude_categories self.oldest_version = ( (oldest_version if self.__check_version_schema(oldest_version) else None) @@ -35,6 +47,8 @@ def __init__( ) self.full_changelog = self.__get_changelog() self.changelog_dict = self.__parse_changelog() + # Clean changelog_dict of excluded categories and empty versions + self.__clean_changelog() def __check_version_schema(self, version: str) -> bool: """Check if the version string is in the correct format.""" @@ -60,22 +74,7 @@ def __parse_changelog(self) -> dict[str, dict[str, str | list[str]]]: # Prepare compiled regular expressions version_re = re.compile(r"## v(\d+\.\d+\.\d+)") date_re = re.compile(r"\((\d{4}-\d{2}-\d{2})\)") - full_category_list = [ - "feat", - "fix", - "docs", - "refactor", - "style", - "test", - "chore", - "perf", - "ci", - "build", - ] - categories = [ - category for category in full_category_list if category not in self.exclude_categories - ] - category_re = re.compile(rf"### ({'|'.join(categories)})", re.I) + category_re = re.compile(rf"### ({'|'.join(self.all_categories)})", re.I) # Initialize the changelog dictionary changelog_dict: dict[str, dict[str, str | list[str]]] = {} @@ -123,21 +122,46 @@ def __parse_changelog(self) -> dict[str, dict[str, str | list[str]]]: return changelog_dict - def has_updates(self) -> bool: - """Check if there are any meaningful updates in the changelog other than the date. + def __clean_changelog(self) -> None: + """Clean up the changelog dictionary by removing excluded categories and empty versions. - This function modifies `self.changelog_dict` to remove any versions that have only one item (i.e., only a date and no other changes). + This function modifies `self.changelog_dict` to remove any versions that have only one item + (i.e., only a date and no other changes). It also removes categories that are in the exclusion list. Returns: - bool: True if there are meaningful updates, False otherwise. + None """ - # List to store keys for removal + from rich import print + + print(self.changelog_dict) + + # Remove excluded categories + categories_to_remove: dict[str, list[str]] = { + key: [category for category in value if category in self.exclude_categories] + for key, value in self.changelog_dict.items() + } + + for key, categories in categories_to_remove.items(): + for category in categories: + self.changelog_dict[key].pop(category) + + # Identify keys for removal keys_to_remove = [key for key, version in self.changelog_dict.items() if len(version) <= 1] # Remove the identified keys for key in keys_to_remove: self.changelog_dict.pop(key) + print(self.changelog_dict) + + def has_updates(self) -> bool: + """Check if there are any meaningful updates in the changelog other than the date. + + This function modifies `self.changelog_dict` to remove any versions that have only one item (i.e., only a date and no other changes). + + Returns: + bool: True if there are meaningful updates, False otherwise. + """ # Return False if the dictionary is empty; True otherwise return bool(self.changelog_dict) @@ -149,7 +173,7 @@ def list_of_versions(self) -> list[str]: """ return list(self.changelog_dict.keys()) - def get_embed_basic(self) -> discord.Embed: + def get_embed(self) -> discord.Embed: """Generate an embed for the changelog. Returns: @@ -157,6 +181,8 @@ def get_embed_basic(self) -> discord.Embed: """ description = "" + description = "## Valentina Noir Changelog\n" + # Loop through each version in the changelog for version, data in self.changelog_dict.items(): # Add the version header @@ -175,10 +201,13 @@ def get_embed_basic(self) -> discord.Embed: for entry in entries: description += f"{entry}\n" + description += "\n\n----\n" + description += "View the [full changelog on Github](https://github.com/natelandau/valentina/releases)\n" + embed = discord.Embed( - title="Valentina Changelog", description=description, color=EmbedColor.INFO.value + description=description, + color=EmbedColor.INFO.value, ) - embed.set_footer(text="For more information, type /changelog") embed.set_thumbnail(url=self.bot.user.display_avatar.url) return embed @@ -189,14 +218,18 @@ def get_embed_personality(self) -> discord.Embed: Returns: discord.Embed: The changelog embed. """ - print("alive") # Create and populate the embed description - description = f"Valentina, your {random.choice(['honored','admired','distinguished','celebrated','hallowed','prestigious','acclaimed','favorite','friendly neighborhood','prized', 'treasured', 'number one','esteemed','venerated','revered','feared'])} {random.choice(BOT_DESCRIPTIONS)} has {random.choice(['been granted new powers', 'leveled up','spent experience points','gained new abilities','been bitten by a radioactive spider', 'spent willpower points', 'been updated','squashed bugs and gained new features',])}!\n\n" + description = ( + "Valentina, your " + + f"{random.choice(['honored','admired','distinguished','celebrated','hallowed','prestigious','acclaimed','favorite','friendly neighborhood','prized', 'treasured', 'number one','esteemed','venerated','revered','feared'])} " + + f"{random.choice(BOT_DESCRIPTIONS)}, " + + f"has {random.choice(['been granted new powers', 'leveled up','spent experience points','gained new abilities','been bitten by a radioactive spider', 'spent willpower points', 'been updated','squashed bugs and gained new features',])}!\n" + ) # Loop through each version in the changelog for version, data in self.changelog_dict.items(): # Add the version header - description += f"**On {data['date']} I was updated to `v{version}`**\n" + description += f"\n### On {data['date']} I was updated to `v{version}`\n" # Add each category for category, entries in data.items(): @@ -211,8 +244,11 @@ def get_embed_personality(self) -> discord.Embed: for entry in entries: description += f"{entry}\n" + description += "\n----\n" + description += "- Run `/changelog` to view specific versions\n" + description += "- View my [full changelog on Github](https://github.com/natelandau/valentina/releases)\n" + embed = discord.Embed(description=description, color=EmbedColor.INFO.value) - embed.set_footer(text="For more information, type /changelog") embed.set_thumbnail(url=self.bot.user.display_avatar.url) return embed diff --git a/src/valentina/utils/options.py b/src/valentina/utils/options.py index dc51a776..2c0b2e32 100644 --- a/src/valentina/utils/options.py +++ b/src/valentina/utils/options.py @@ -1,11 +1,14 @@ """Reusable autocomplete options for cogs and commands.""" +from typing import cast + import discord from discord.commands import OptionChoice from loguru import logger from peewee import DoesNotExist from valentina.constants import MAX_OPTION_LIST_SIZE +from valentina.models.bot import Valentina from valentina.models.db_tables import Character, CharacterClass, TraitCategory, VampireClan from valentina.utils import errors from valentina.utils.helpers import truncate_string @@ -13,6 +16,26 @@ MAX_OPTION_LENGTH = 99 +async def select_changelog_version_1(ctx: discord.AutocompleteContext) -> list[str]: + """Populate the autocomplete for the version option.""" + bot = cast(Valentina, ctx.bot) + possible_versions = bot.guild_svc.fetch_changelog_versions() + + return [version for version in possible_versions if version.startswith(ctx.value)][ + :MAX_OPTION_LIST_SIZE + ] + + +async def select_changelog_version_2(ctx: discord.AutocompleteContext) -> list[str]: + """Populate the autocomplete for the version option.""" + bot = cast(Valentina, ctx.bot) + possible_versions = bot.guild_svc.fetch_changelog_versions() + + return [version for version in possible_versions if version.startswith(ctx.value)][ + :MAX_OPTION_LIST_SIZE + ] + + async def select_chapter(ctx: discord.AutocompleteContext) -> list[str]: """Populate the autocomplete for the chapter option. @@ -28,9 +51,10 @@ async def select_chapter(ctx: discord.AutocompleteContext) -> list[str]: Returns: list[str]: A list of strings representing the chapters. """ + bot = cast(Valentina, ctx.bot) try: # Fetch the active campaign - campaign = ctx.bot.campaign_svc.fetch_active(ctx) # type: ignore [attr-defined] + campaign = bot.campaign_svc.fetch_active(ctx) except errors.NoActiveCampaignError: return ["No active campaign"] @@ -38,7 +62,7 @@ async def select_chapter(ctx: discord.AutocompleteContext) -> list[str]: return [ f"{chapter.chapter_number}: {chapter.name}" for chapter in sorted( - ctx.bot.campaign_svc.fetch_all_chapters(campaign=campaign), # type: ignore [attr-defined] + bot.campaign_svc.fetch_all_chapters(campaign=campaign), key=lambda c: c.chapter_number, ) if chapter.name.lower().startswith(ctx.options["chapter"].lower()) @@ -88,9 +112,10 @@ async def select_char_trait(ctx: discord.AutocompleteContext) -> list[str]: Returns: list[str]: A list of available common and custom trait names. """ + bot = cast(Valentina, ctx.bot) # Fetch the active character try: - character = ctx.bot.user_svc.fetch_active_character(ctx) # type: ignore [attr-defined] + character = bot.user_svc.fetch_active_character(ctx) except errors.NoActiveCharacterError: return ["No active character"] @@ -118,9 +143,10 @@ async def select_char_trait_two(ctx: discord.AutocompleteContext) -> list[str]: Returns: list[str]: A list of available common and custom trait names. """ + bot = cast(Valentina, ctx.bot) # Fetch the active character try: - character = ctx.bot.user_svc.fetch_active_character(ctx) # type: ignore [attr-defined] + character = bot.user_svc.fetch_active_character(ctx) except errors.NoActiveCharacterError: return ["No active character"] @@ -146,9 +172,10 @@ async def select_campaign(ctx: discord.AutocompleteContext) -> list[str]: Returns: list[str]: A list of available campaign names. """ + bot = cast(Valentina, ctx.bot) return [ c.name - for c in ctx.bot.campaign_svc.fetch_all(ctx) # type: ignore [attr-defined] + for c in bot.campaign_svc.fetch_all(ctx) if c.name.lower().startswith(ctx.options["campaign"].lower()) ][:MAX_OPTION_LIST_SIZE] @@ -167,9 +194,10 @@ async def select_custom_section(ctx: discord.AutocompleteContext) -> list[Option list[OptionChoice]: A list of option choices for discord selection containing title and id pairs. """ + bot = cast(Valentina, ctx.bot) try: # Fetch active character - character = ctx.bot.user_svc.fetch_active_character(ctx) # type: ignore [attr-defined] + character = bot.user_svc.fetch_active_character(ctx) except errors.NoActiveCharacterError: # Return descriptive OptionChoice in case of absence return [OptionChoice("No active character", "")] @@ -210,9 +238,10 @@ async def select_custom_trait(ctx: discord.AutocompleteContext) -> list[str]: List[str]: List containing names of filtered traits up to a predefined limit. If no character is active, return a list with 'No active character'. """ + bot = cast(Valentina, ctx.bot) # Attempt to fetch the active character try: - character = ctx.bot.user_svc.fetch_active_character(ctx) # type: ignore [attr-defined] + character = bot.user_svc.fetch_active_character(ctx) except errors.NoActiveCharacterError: return ["No active character"] @@ -258,9 +287,12 @@ async def select_country(ctx: discord.AutocompleteContext) -> list[OptionChoice] async def select_aws_object_from_guild(ctx: discord.AutocompleteContext) -> list[OptionChoice]: """Populate the autocomplete list for the aws_object option based on the user's input.""" + bot = cast(Valentina, ctx.bot) guild_prefix = f"{ctx.interaction.guild.id}/" - return [OptionChoice(x.strip(guild_prefix), x) for x in ctx.bot.aws_svc.list_objects(guild_prefix)][:MAX_OPTION_LIST_SIZE] # type: ignore [attr-defined] + return [OptionChoice(x.strip(guild_prefix), x) for x in bot.aws_svc.list_objects(guild_prefix)][ + :MAX_OPTION_LIST_SIZE + ] async def select_macro(ctx: discord.AutocompleteContext) -> list[OptionChoice]: @@ -275,12 +307,11 @@ async def select_macro(ctx: discord.AutocompleteContext) -> list[OptionChoice]: Returns: list[OptionChoice]: A list of OptionChoice objects to populate the select list. """ + bot = cast(Valentina, ctx.bot) # Filter macros based on user input filtered_macros = [ macro - for macro in ctx.bot.macro_svc.fetch_macros( # type: ignore [attr-defined] - ctx.interaction.guild.id, ctx.interaction.user.id - ) + for macro in bot.macro_svc.fetch_macros(ctx.interaction.guild.id, ctx.interaction.user.id) if macro.name.lower().startswith(ctx.options["macro"].lower()) ] @@ -310,16 +341,17 @@ async def select_note(ctx: discord.AutocompleteContext) -> list[str]: Returns: list[str]: A list of note IDs and names for the autocomplete list. """ + bot = cast(Valentina, ctx.bot) try: # Fetch the active campaign - campaign = ctx.bot.campaign_svc.fetch_active(ctx) # type: ignore [attr-defined] + campaign = bot.campaign_svc.fetch_active(ctx) except errors.NoActiveCampaignError: return ["No active campaign"] # Fetch and filter notes notes = [ f"{note.id}: {note.name}" - for note in ctx.bot.campaign_svc.fetch_all_notes(campaign) # type: ignore [attr-defined] + for note in bot.campaign_svc.fetch_all_notes(campaign) if note.name.lower().startswith(ctx.options["note"].lower()) ][:MAX_OPTION_LIST_SIZE] @@ -338,15 +370,16 @@ async def select_npc(ctx: discord.AutocompleteContext) -> list[str]: Returns: list[str]: A list of NPC names for the autocomplete list. """ + bot = cast(Valentina, ctx.bot) try: - campaign = ctx.bot.campaign_svc.fetch_active(ctx) # type: ignore [attr-defined] + campaign = bot.campaign_svc.fetch_active(ctx) except errors.NoActiveCampaignError: return ["No active campaign"] # Fetch and filter NPCs npcs = [ npc.name - for npc in ctx.bot.campaign_svc.fetch_all_npcs(campaign=campaign) # type: ignore [attr-defined] + for npc in bot.campaign_svc.fetch_all_npcs(campaign=campaign) if npc.name.lower().startswith(ctx.options["npc"].lower()) ][:MAX_OPTION_LIST_SIZE] @@ -364,10 +397,11 @@ async def select_player_character(ctx: discord.AutocompleteContext) -> list[Opti Returns: list[OptionChoice]: A list of OptionChoice objects for the autocomplete list. """ + bot = cast(Valentina, ctx.bot) # Prepare character data all_chars = [ (f"{character.name}", character.id) - for character in ctx.bot.user_svc.fetch_alive_characters(ctx) # type: ignore [attr-defined] + for character in bot.user_svc.fetch_alive_characters(ctx) ] # Perform case-insensitive search @@ -394,9 +428,10 @@ async def select_storyteller_character(ctx: discord.AutocompleteContext) -> list Returns: list[OptionChoice]: A list of OptionChoice objects for the autocomplete list. """ + bot = cast(Valentina, ctx.bot) all_chars = [ (f"{character.name}", character.id) - for character in ctx.bot.char_svc.fetch_all_storyteller_characters(ctx) # type: ignore [attr-defined] + for character in bot.char_svc.fetch_all_storyteller_characters(ctx) ] # Perform case-insensitive search @@ -424,17 +459,18 @@ async def select_any_character(ctx: discord.AutocompleteContext) -> list[OptionC Returns: list[OptionChoice]: A list of OptionChoice objects for the autocomplete list. """ + bot = cast(Valentina, ctx.bot) # Initialize options list options = [] # Fetch all characters storyteller_chars = [ (f"{character.full_name} ({character.char_class.name})", character.id) - for character in ctx.bot.char_svc.fetch_all_storyteller_characters(ctx) # type: ignore [attr-defined] + for character in bot.char_svc.fetch_all_storyteller_characters(ctx) ] player_chars = [ (f"{character.name}", character.id) - for character in ctx.bot.char_svc.fetch_all_player_characters(ctx) # type: ignore [attr-defined] + for character in bot.char_svc.fetch_all_player_characters(ctx) ] # Combine both lists @@ -473,11 +509,11 @@ async def select_any_player_character(ctx: discord.AutocompleteContext) -> list[ Returns: list[OptionChoice]: A list of OptionChoice objects for the autocomplete list. """ + bot = cast(Valentina, ctx.bot) # Fetch and prepare player characters - all_chars = [ (f"{character.name} [Owned by: {character.owned_by.username}]", character.id) - for character in ctx.bot.char_svc.fetch_all_player_characters(ctx) # type: ignore [attr-defined] + for character in bot.char_svc.fetch_all_player_characters(ctx) ] # Perform case-insensitive search diff --git a/tests/test_guilds.py b/tests/test_guilds.py index 13825591..e76495f5 100644 --- a/tests/test_guilds.py +++ b/tests/test_guilds.py @@ -3,6 +3,7 @@ import discord import pytest from dirty_equals import IsPartialDict +from discord.ext import commands from rich.console import Console from valentina.constants import GUILD_DEFAULTS @@ -14,11 +15,18 @@ c = Console() +def local_mock_bot(mocker): + """A mock of a discord.Bot object.""" + mock_bot = mocker.MagicMock() + mock_bot.__class__ = commands.Bot + return mock_bot + + @pytest.mark.usefixtures("mock_db") class TestGuildService: """Test the GuildService class.""" - guild_svc = GuildService() + guild_svc = GuildService(bot=local_mock_bot) def test_update_or_add(self, mocker): """Test GuildService.update_or_add().""" @@ -103,12 +111,14 @@ def test_purge_cache_one(self, mock_ctx): """ # Populate the cache self.guild_svc.settings_cache = {1: {"a": "b"}, 2: {"c": "d"}} + self.guild_svc.changelog_versions_cache = ["a", "b", "c"] # Purge the cache self.guild_svc.purge_cache(mock_ctx) # Confirm the cache was purged assert self.guild_svc.settings_cache == {2: {"c": "d"}} + assert self.guild_svc.changelog_versions_cache == [] def test_purge_cache_two(self): """Test GuildService.purge_cache(). @@ -119,9 +129,11 @@ def test_purge_cache_two(self): """ # Populate the cache self.guild_svc.settings_cache = {1: {"a": "b"}, 2: {"c": "d"}} + self.guild_svc.changelog_versions_cache = ["a", "b", "c"] # Purge the cache self.guild_svc.purge_cache() # Confirm the cache was purged assert self.guild_svc.settings_cache == {} + assert self.guild_svc.changelog_versions_cache == []