diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 559d010..3c751f4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,23 @@ ## Summary + +## Documentation changes + +- XXX + +## Minor changes + +- XXX + +## Bug fixes + +- XXX + +## Technical changes + +- XXX + + ## Checklist diff --git a/.gitignore b/.gitignore index a205c5b..84eda93 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ examples/dev.py docs/_build test/ *.prof +profile.json +profile.html suggestions/telemetry/error_telemetry_tracebacks suggestions/telemetry/forbidden_errors diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..800bb96 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,48 @@ +# Security Policy + +## Supported Versions + +The latest master branch is actively maintained and any security patches will be applied to that branch. + +Older versions will not have patches back ported. + +## Reporting a Vulnerability + +### TLDR + +We recommend opening a security advisory on GitHub, as per the [documentation](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability). + +Alternatively, reach out to the maintainers via discord (`@skelmis`). + +--- + +### Your research + +We ask that anyone conducting testing: +- Makes every effort to avoid impacting other users of our systems +- Avoids any activities that disrupt, degrade or interrupt our services or may compromise other user data. This includes things such as spam, brute forcing, DoS, etc +- Keeps vulnerability information private until we have had the ability to roll out fixes + + +### Our commitment + +If you meet the expectations laid out, we commit to: +- Acknowledge any reports and keeping you informed of how we are tracking on fixes +- Acting in good faith when interacting with you +- Recognising your contribution via means such as security advisories on the affected services and/or CVE's + + +We will aim to fix any issues ASAP, however as we are not a dedicated resource this may not always be possible. As such, we aim for full resolution to all acknowledged issues within a 90-day period. If this is not possible, we will enter discussions with you as to the reason for delays. + + +### Report details + +At a minimum, your report should contain: +- The affected service +- A description of the vulnerability +- Complete reproduction steps + +You may include other items to your report as you please. Some examples may be: +- The perceived impact +- The perceived likelihood of exploitation +- A list of users to credit for the disclosure diff --git a/main.py b/main.py index 6eac06b..b961573 100644 --- a/main.py +++ b/main.py @@ -79,6 +79,7 @@ async def run_bot(): TOKEN = os.environ["PROD_TOKEN"] if bot.is_prod else os.environ["TOKEN"] log.info("About to start SuggestionsBot | %s", bot.version) + log.info("We are in prod" if bot.is_prod else "We are launching in non-prod") await bot.start(TOKEN) diff --git a/suggestions/bot.py b/suggestions/bot.py index 450f8d5..0a3f2fa 100644 --- a/suggestions/bot.py +++ b/suggestions/bot.py @@ -41,6 +41,7 @@ MissingQueueLogsChannel, MissingPermissionsToAccessQueueChannel, InvalidFileType, + SuggestionSecurityViolation, ) from suggestions.http_error_parser import try_parse_http_error from suggestions.interaction_handler import InteractionHandler @@ -55,7 +56,7 @@ class SuggestionsBot(commands.AutoShardedInteractionBot, BotBase): def __init__(self, *args, **kwargs): - self.version: str = "Public Release 3.23" + self.version: str = "Public Release 3.24" self.main_guild_id: int = 601219766258106399 self.legacy_beta_role_id: int = 995588041991274547 self.automated_beta_role_id: int = 998173237282361425 @@ -415,6 +416,26 @@ async def on_slash_command_error( ) ) + elif isinstance(exception, SuggestionSecurityViolation): + logger.critical( + "User %s looked up a suggestion from a different guild", + interaction.author.id, + extra_metadata={ + "guild_id": interaction.guild_id, + "suggestion_id": exception.suggestion_id, + "author_id": interaction.author.id, + }, + ) + return await interaction.send( + embed=self.error_embed( + "Command failed", + exception.user_facing_message, + error_code=ErrorCode.SUGGESTION_NOT_FOUND, + error=error, + ), + ephemeral=True, + ) + elif isinstance(exception, commands.MissingPermissions): perms = ",".join(i for i in exception.missing_permissions) return await interaction.send( @@ -500,7 +521,8 @@ async def on_slash_command_error( embed=self.error_embed( "Configuration Error", "I cannot find your configured channel for this command.\n" - "Please ask an administrator to reconfigure one.", + "Please ask an administrator to reconfigure one.\n" + "This can be done using: `/config channel`", error_code=ErrorCode.CONFIGURED_CHANNEL_NO_LONGER_EXISTS, error=error, ), @@ -660,7 +682,6 @@ async def load(self): await self.stats.load() await self.update_bot_listings() await self.push_status() - await self.update_dev_channel() await self.watch_for_shutdown_request() await self.load_cogs() await self.zonis.start() @@ -724,65 +745,6 @@ async def process_watch_for_shutdown(): process_watch_for_shutdown.__task = task_1 state.add_background_task(task_1) - async def update_dev_channel(self): - if not self.is_prod: - log.info("Not watching for debug info as not on prod") - return - - if not self.is_primary_cluster: - log.info("Not watching for debug info as not primary cluster") - return - - state: State = self.state - - async def process_watch_for_shutdown(): - await self.wait_until_ready() - log.debug("Started tracking bot latency") - - while not state.is_closing: - # Update once an hour - await self.sleep_with_condition( - datetime.timedelta(minutes=5).total_seconds(), - lambda: self.state.is_closing, - ) - - await self.garven.notify_devs( - title=f"WS latency as follows", - description=f"Timestamped for {datetime.datetime.utcnow().isoformat()}", - sender=f"N/A", - ) - - data = await self.garven.get_bot_ws_latency() - shard_data = data["shards"] - for i in range(0, 75, 5): - description = io.StringIO() - for o in range(0, 6): - shard = str(i + o) - try: - description.write( - f"**Shard {shard}**\nWS latency: `{shard_data[shard]['ws']}`\n" - f"Keep Alive latency: `{shard_data[shard]['keepalive']}`\n\n" - ) - except KeyError: - # My lazy way of not doing env checks n math right - continue - - if description.getvalue(): - await self.garven.notify_devs( - title=f"WS latency", - description=description.getvalue(), - sender=f"Partial response: {data['partial_response']}", - ) - - await self.sleep_with_condition( - datetime.timedelta(hours=1).total_seconds(), - lambda: self.state.is_closing, - ) - - task_1 = asyncio.create_task(process_watch_for_shutdown()) - process_watch_for_shutdown.__task = task_1 - state.add_background_task(task_1) - async def update_bot_listings(self) -> None: """Updates the bot lists with current stats.""" if not self.is_prod: @@ -869,7 +831,15 @@ def get_locale(self, key: str, locale: Locale) -> str: return values[str(locale)] except KeyError: # Default to known translations if not set - return values.get("en-GB", values["en-US"]) + value = values.get("en-GB") + if value is None: + value = values["en-US"] + logger.critical( + "Missing translation in en-GB file", + extra_metadata={"translation_key": key}, + ) + + return value @staticmethod def inject_locale_values( diff --git a/suggestions/clunk2/edits.py b/suggestions/clunk2/edits.py index d9f5852..170f1f7 100644 --- a/suggestions/clunk2/edits.py +++ b/suggestions/clunk2/edits.py @@ -54,7 +54,7 @@ async def update_suggestion_message( # We do this to avoid a race condition where the suggestion may have # had a value modified between when it was added to the edit queue # and the time at which it was actually edited - up_to_date_suggestion = await bot.state.suggestions_db.find(suggestion) + up_to_date_suggestion: Suggestion = await bot.state.suggestions_db.find(suggestion) try: await MessageEditing( bot, @@ -62,7 +62,7 @@ async def update_suggestion_message( message_id=up_to_date_suggestion.message_id, ).edit(embed=await up_to_date_suggestion.as_embed(bot)) except (disnake.HTTPException, disnake.NotFound) as e: - logger.error( + logger.debug( "Failed to update suggestion %s", suggestion.suggestion_id, extra_metadata={ diff --git a/suggestions/cogs/blacklist_cog.py b/suggestions/cogs/blacklist_cog.py index af4b8b6..67859d8 100644 --- a/suggestions/cogs/blacklist_cog.py +++ b/suggestions/cogs/blacklist_cog.py @@ -95,6 +95,12 @@ async def remove( ephemeral=True, ) + if user_id is None and suggestion_id is None: + return await interaction.send( + "Either a suggestion_id or user_id is required.", + ephemeral=True, + ) + if suggestion_id: suggestion: Suggestion = await Suggestion.from_id( suggestion_id, interaction.guild_id, self.state diff --git a/suggestions/cogs/guild_config_cog.py b/suggestions/cogs/guild_config_cog.py index 5a137db..c5139b8 100644 --- a/suggestions/cogs/guild_config_cog.py +++ b/suggestions/cogs/guild_config_cog.py @@ -211,6 +211,7 @@ async def get( "Using channel queue", "Queue channel", "Queue rejection channel", + "Ping on suggestion thread creation", ], default=None, ), @@ -244,6 +245,16 @@ async def get( ) embed.description += log_channel + elif config == "Ping on suggestion thread creation": + locale_string = ( + "CONFIG_GET_INNER_USES_THREAD_PINGS_SET" + if guild_config.ping_on_thread_creation + else "CONFIG_GET_INNER_USES_THREAD_PINGS_NOT_SET" + ) + text = self.bot.get_localized_string(locale_string, interaction) + + embed.description += text + elif config == "Queue channel": log_channel = ( self.bot.get_locale( @@ -536,6 +547,15 @@ async def send_full_config(self, interaction: disnake.GuildCommandInteraction): "CONFIG_GET_INNER_AUTO_ARCHIVE_THREADS_MESSAGE", interaction.locale ).format(auto_archive_threads_text) + ping_on_thread_creation = ( + "CONFIG_GET_INNER_USES_THREAD_PINGS_SET" + if guild_config.ping_on_thread_creation + else "CONFIG_GET_INNER_USES_THREAD_PINGS_NOT_SET" + ) + ping_on_thread_creation = self.bot.get_localized_string( + ping_on_thread_creation, interaction + ) + locale_string = ( "CONFIG_GET_INNER_SUGGESTIONS_QUEUE_SET" if guild_config.uses_suggestion_queue @@ -560,7 +580,7 @@ async def send_full_config(self, interaction: disnake.GuildCommandInteraction): f"Suggestion threads: {threads}\nKeep Logs: {keep_logs}\nAnonymous suggestions: {anon}\n" f"Automatic thread archiving: {auto_archive_threads}\nSuggestions queue: {suggestions_queue}\n" f"Channel queue: {physical_queue}\nImages in suggestions: {images}\n" - f"Anonymous resolutions: {anonymous_resolutions}\n" + f"Anonymous resolutions: {anonymous_resolutions}\nPings in new suggestion threads: {ping_on_thread_creation}\n" f"Queue channel: {queue_channel}\nQueue rejection channel: {queue_rejection_channel}", color=self.bot.colors.embed_color, timestamp=self.bot.state.now, @@ -673,6 +693,46 @@ async def thread_disable(self, interaction: disnake.GuildCommandInteraction): self.stats.type.GUILD_THREAD_DISABLE, ) + @config.sub_command_group() + async def ping_on_thread_creation( + self, interaction: disnake.GuildCommandInteraction + ): + pass + + @ping_on_thread_creation.sub_command(name="enable") + async def ping_on_thread_creation_enable( + self, interaction: disnake.GuildCommandInteraction + ): + """Enable pings when a thread is created on a suggestion.""" + await self.modify_guild_config( + interaction, + "ping_on_thread_creation", + True, + self.bot.get_locale( + "CONFIG_PING_ON_THREAD_CREATION_ENABLE_INNER_MESSAGE", + interaction.locale, + ), + "Enabled pings on new suggestion threads for guild %s", + self.stats.type.GUILD_PING_ON_THREAD_CREATE_ENABLE, + ) + + @ping_on_thread_creation.sub_command(name="disable") + async def ping_on_thread_creation_disable( + self, interaction: disnake.GuildCommandInteraction + ): + """Disable pings when a thread is created on a suggestion.""" + await self.modify_guild_config( + interaction, + "ping_on_thread_creation", + False, + self.bot.get_locale( + "CONFIG_PING_ON_THREAD_CREATION_DISABLE_INNER_MESSAGE", + interaction.locale, + ), + "Disabled pings on new suggestion threads for guild %s", + self.stats.type.GUILD_PING_ON_THREAD_CREATE_DISABLE, + ) + @config.sub_command_group() async def keeplogs(self, interaction: disnake.GuildCommandInteraction): pass diff --git a/suggestions/cogs/suggestion_cog.py b/suggestions/cogs/suggestion_cog.py index 222d5e5..a891a2f 100644 --- a/suggestions/cogs/suggestion_cog.py +++ b/suggestions/cogs/suggestion_cog.py @@ -12,7 +12,7 @@ from suggestions import checks, Stats from suggestions.clunk2 import update_suggestion_message from suggestions.cooldown_bucket import InteractionBucket -from suggestions.core import SuggestionsQueue +from suggestions.core import SuggestionsQueue, SuggestionsResolutionCore from suggestions.exceptions import ( SuggestionTooLong, ErrorHandled, @@ -22,7 +22,7 @@ from suggestions.interaction_handler import InteractionHandler from suggestions.objects import Suggestion, GuildConfig, QueuedSuggestion from suggestions.objects.suggestion import SuggestionState -from suggestions.utility import r2 +from suggestions.utility import r2, wrap_with_error_handler if TYPE_CHECKING: from alaric import Document @@ -39,6 +39,7 @@ def __init__(self, bot: SuggestionsBot): self.suggestions_db: Document = self.bot.db.suggestions self.qs_core: SuggestionsQueue = SuggestionsQueue(bot) + self.resolution_core: SuggestionsResolutionCore = SuggestionsResolutionCore(bot) @components.button_listener() async def suggestion_up_vote( @@ -176,6 +177,7 @@ async def suggestion_down_vote( await update_suggestion_message(suggestion=suggestion, bot=self.bot) @components.button_listener() + @wrap_with_error_handler() async def queue_approve(self, inter: disnake.MessageInteraction): ih = await InteractionHandler.new_handler(inter) qs = await QueuedSuggestion.from_message_id( @@ -187,6 +189,7 @@ async def queue_approve(self, inter: disnake.MessageInteraction): await ih.send(translation_key="PAGINATION_INNER_QUEUE_ACCEPTED") @components.button_listener() + @wrap_with_error_handler() async def queue_reject(self, inter: disnake.MessageInteraction): ih = await InteractionHandler.new_handler(inter) qs = await QueuedSuggestion.from_message_id( @@ -371,41 +374,8 @@ async def approve( suggestion_id: str {{APPROVE_ARG_SUGGESTION_ID}} response: str {{APPROVE_ARG_RESPONSE}} """ - guild_config: GuildConfig = await GuildConfig.from_id( - interaction.guild_id, self.state - ) - await interaction.response.defer(ephemeral=True) - suggestion: Suggestion = await Suggestion.from_id( - suggestion_id, interaction.guild_id, self.state - ) - await suggestion.resolve( - guild_config=guild_config, - state=self.state, - interaction=interaction, - resolution_note=response, - resolution_type=SuggestionState.approved, - bot=self.bot, - ) - - await interaction.send( - self.bot.get_locale("APPROVE_INNER_MESSAGE", interaction.locale).format( - suggestion_id - ), - ephemeral=True, - ) - logger.debug( - f"User {interaction.author.id} approved suggestion " - f"{suggestion.suggestion_id} in guild {interaction.guild_id}", - extra_metadata={ - "author_id": interaction.author.id, - "guild_id": interaction.guild_id, - "suggestion_id": suggestion.suggestion_id, - }, - ) - await self.stats.log_stats( - interaction.author.id, - interaction.guild_id, - self.stats.type.APPROVE, + await self.resolution_core.approve( + await InteractionHandler.new_handler(interaction), suggestion_id, response ) @approve.autocomplete("suggestion_id") @@ -434,41 +404,8 @@ async def reject( suggestion_id: str {{REJECT_ARG_SUGGESTION_ID}} response: str {{REJECT_ARG_RESPONSE}} """ - guild_config: GuildConfig = await GuildConfig.from_id( - interaction.guild_id, self.state - ) - await interaction.response.defer(ephemeral=True) - suggestion: Suggestion = await Suggestion.from_id( - suggestion_id, interaction.guild_id, self.state - ) - await suggestion.resolve( - guild_config=guild_config, - state=self.state, - interaction=interaction, - resolution_note=response, - resolution_type=SuggestionState.rejected, - bot=self.bot, - ) - - await interaction.send( - self.bot.get_locale("REJECT_INNER_MESSAGE", interaction.locale).format( - suggestion_id - ), - ephemeral=True, - ) - logger.debug( - f"User {interaction.author} rejected suggestion {suggestion.suggestion_id} " - f"in guild {interaction.guild_id}", - extra_metadata={ - "author_id": interaction.author.id, - "guild_id": interaction.guild_id, - "suggestion_id": suggestion.suggestion_id, - }, - ) - await self.stats.log_stats( - interaction.author.id, - interaction.guild_id, - self.stats.type.REJECT, + await self.resolution_core.reject( + await InteractionHandler.new_handler(interaction), suggestion_id, response ) @reject.autocomplete("suggestion_id") diff --git a/suggestions/cogs/suggestion_notes_cog.py b/suggestions/cogs/suggestion_notes_cog.py new file mode 100644 index 0000000..bd5cd23 --- /dev/null +++ b/suggestions/cogs/suggestion_notes_cog.py @@ -0,0 +1,99 @@ +import cooldowns +import disnake +from commons.caching import NonExistentEntry +from disnake.ext import commands +from logoo import Logger + +from suggestions.cooldown_bucket import InteractionBucket +from suggestions.core import SuggestionsNotesCore +from suggestions.interaction_handler import InteractionHandler + +logger: Logger = Logger(__name__) + + +class SuggestionNotesCog(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.state = bot.state + self.core: SuggestionsNotesCore = SuggestionsNotesCore(bot) + + @commands.slash_command( + dm_permission=False, + default_member_permissions=disnake.Permissions(manage_guild=True), + ) + @cooldowns.cooldown(1, 3, bucket=InteractionBucket.author) + async def notes(self, interaction: disnake.GuildCommandInteraction): + """{{NOTES}}""" + pass + + @notes.sub_command() + async def add( + self, + interaction: disnake.GuildCommandInteraction, + suggestion_id: str = commands.Param(), + note: str = commands.Param(), + ): + """ + {{NOTES_ADD}} + + Parameters + ---------- + suggestion_id: str {{NOTES_ADD_ARG_SUGGESTION_ID}} + note: str {{NOTES_ADD_ARG_NOTE}} + """ + await self.core.modify_note_on_suggestions( + await InteractionHandler.new_handler(interaction), suggestion_id, note + ) + + @notes.sub_command() + async def remove( + self, + interaction: disnake.GuildCommandInteraction, + suggestion_id: str = commands.Param(), + ): + """ + {{NOTES_REMOVE}} + + Parameters + ---------- + suggestion_id: str {{NOTES_REMOVE_ARG_SUGGESTION_ID}} + """ + await self.core.modify_note_on_suggestions( + await InteractionHandler.new_handler(interaction), suggestion_id, None + ) + + @add.autocomplete("suggestion_id") + @remove.autocomplete("suggestion_id") + async def get_sid_for( + self, + interaction: disnake.ApplicationCommandInteraction, + user_input: str, + ): + try: + values: list[str] = self.state.autocomplete_cache.get_entry( + interaction.guild_id + ) + except NonExistentEntry: + values: list[str] = await self.state.populate_sid_cache( + interaction.guild_id + ) + else: + if not values: + logger.debug( + f"Values was found, but empty in guild {interaction.guild_id} thus populating", + extra_metadata={"guild_id": interaction.guild_id}, + ) + values: list[str] = await self.state.populate_sid_cache( + interaction.guild_id + ) + + possible_choices = [v for v in values if user_input.lower() in v.lower()] + + if len(possible_choices) > 25: + return [] + + return possible_choices + + +def setup(bot): + bot.add_cog(SuggestionNotesCog(bot)) diff --git a/suggestions/cogs/user_config_cog.py b/suggestions/cogs/user_config_cog.py index 2af1423..00513e8 100644 --- a/suggestions/cogs/user_config_cog.py +++ b/suggestions/cogs/user_config_cog.py @@ -88,6 +88,87 @@ async def view(self, interaction: disnake.CommandInteraction): self.stats.type.MEMBER_DM_VIEW, ) + @commands.slash_command() + @cooldowns.cooldown(1, 3, bucket=InteractionBucket.author) + async def user_config(self, interaction: disnake.CommandInteraction): + pass + + @user_config.sub_command_group() + async def ping_on_thread_creation(self, interaction: disnake.CommandInteraction): + pass + + @ping_on_thread_creation.sub_command(name="enable") + async def ping_on_thread_creation_enable( + self, interaction: disnake.CommandInteraction + ): + """Enable pings when a thread is created on a suggestion.""" + user_config: UserConfig = await UserConfig.from_id( + interaction.author.id, self.state + ) + user_config.ping_on_thread_creation = True + await self.bot.db.user_configs.upsert(user_config, user_config) + await interaction.send( + "I have enabled pings on thread creation for you.", ephemeral=True + ) + logger.debug( + "Enabled pings on thread creation for member %s", + interaction.author.id, + extra_metadata={"author_id": interaction.author.id}, + ) + await self.stats.log_stats( + interaction.author.id, + interaction.guild_id, + self.stats.type.MEMBER_PING_ON_THREAD_CREATE_ENABLE, + ) + + @ping_on_thread_creation.sub_command(name="disable") + async def ping_on_thread_creation_disable( + self, interaction: disnake.CommandInteraction + ): + """Disable pings when a thread is created on a suggestion.""" + user_config: UserConfig = await UserConfig.from_id( + interaction.author.id, self.state + ) + user_config.ping_on_thread_creation = False + await self.bot.db.user_configs.upsert(user_config, user_config) + await interaction.send( + "I have disabled pings on thread creation for you.", ephemeral=True + ) + logger.debug( + "Disabled pings on thread creation for member %s", + interaction.author.id, + extra_metadata={"author_id": interaction.author.id}, + ) + await self.stats.log_stats( + interaction.author.id, + interaction.guild_id, + self.stats.type.MEMBER_PING_ON_THREAD_CREATE_DISABLE, + ) + + @ping_on_thread_creation.sub_command(name="view") + async def ping_on_thread_creation_view( + self, interaction: disnake.CommandInteraction + ): + """View your current ping configuration.""" + user_config: UserConfig = await UserConfig.from_id( + interaction.author.id, self.state + ) + text = "will" if user_config.ping_on_thread_creation else "will not" + await interaction.send( + f"I {text} ping you on when a new thread is created for a suggestion.", + ephemeral=True, + ) + logger.debug( + "User %s viewed their ping configuration", + interaction.author.id, + extra_metadata={"author_id": interaction.author.id}, + ) + await self.stats.log_stats( + interaction.author.id, + interaction.guild_id, + self.stats.type.MEMBER_PING_ON_THREAD_CREATE_VIEW, + ) + def setup(bot): bot.add_cog(UserConfigCog(bot)) diff --git a/suggestions/core/__init__.py b/suggestions/core/__init__.py index 035c03d..50ddb1f 100644 --- a/suggestions/core/__init__.py +++ b/suggestions/core/__init__.py @@ -1 +1,4 @@ +from .base import BaseCore from .suggestions_queue import SuggestionsQueue +from .suggestion_notes import SuggestionsNotesCore +from .suggestion_resolution import SuggestionsResolutionCore diff --git a/suggestions/core/base.py b/suggestions/core/base.py new file mode 100644 index 0000000..f7c6536 --- /dev/null +++ b/suggestions/core/base.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import typing + +if typing.TYPE_CHECKING: + from suggestions import SuggestionsBot + + +class BaseCore: + def __init__(self, bot: SuggestionsBot): + self.bot: SuggestionsBot = bot diff --git a/suggestions/core/suggestion_notes.py b/suggestions/core/suggestion_notes.py new file mode 100644 index 0000000..d4d363b --- /dev/null +++ b/suggestions/core/suggestion_notes.py @@ -0,0 +1,96 @@ +import disnake +from disnake import Embed +from logoo import Logger + +from suggestions import Colors +from suggestions.core import BaseCore +from suggestions.objects import Suggestion, UserConfig, GuildConfig +from suggestions.interaction_handler import InteractionHandler + +logger: Logger = Logger(__name__) + + +class SuggestionsNotesCore(BaseCore): + async def modify_note_on_suggestions( + self, ih: InteractionHandler, suggestion_id: str, note: str | None + ): + """Given a note, override the current note for a suggestion. + + This one method handles both adding and removing notes. + """ + suggestion: Suggestion = await Suggestion.from_id( + suggestion_id, guild_id=ih.interaction.guild_id, state=ih.bot.state + ) + suggestion.note = note + suggestion.note_added_by = ( + ih.interaction.author.id if note is not None else None + ) + await ih.bot.db.suggestions.upsert(suggestion, suggestion) + + # We should now update the suggestions message + await suggestion.edit_suggestion_message(ih) + + # We should tell the user a change has occurred + suggestion_author_id: int = suggestion.suggestion_author_id + user_config: UserConfig = await UserConfig.from_id( + suggestion_author_id, ih.bot.state + ) + if user_config.dm_messages_disabled: + logger.debug( + "Not dm'ing %s for a note changed on suggestion %s as they have dm's disabled", + suggestion_author_id, + suggestion_id, + extra_metadata={ + "guild_id": ih.interaction.guild_id, + "suggestion_id": suggestion_id, + "author_id": suggestion_author_id, + }, + ) + return + + guild_config: GuildConfig = await GuildConfig.from_id( + ih.interaction.guild_id, ih.bot.state + ) + if guild_config.dm_messages_disabled: + logger.debug( + "Not dm'ing %s for a note changed on suggestion %s as the guilds has dm's disabled", + ih.interaction.author.id, + suggestion_id, + extra_metadata={ + "guild_id": ih.interaction.guild_id, + "suggestion_id": suggestion_id, + }, + ) + return + + jump_url = ( + f"https://discord.com/channels/{ih.interaction.guild_id}/" + f"{suggestion.channel_id}/{suggestion.message_id}" + ) + embed: Embed = Embed( + description=ih.bot.get_localized_string( + "NOTE_INNER_CHANGE_MADE_DESCRIPTION", + ih, + guild_config=guild_config, + extras={"JUMP": jump_url}, + ), + colour=Colors.embed_color, + timestamp=ih.bot.state.now, + ) + embed.set_footer( + text=ih.bot.get_localized_string( + "NOTE_INNER_CHANGE_MADE_FOOTER", + ih, + guild_config=guild_config, + extras={"GUILD_ID": ih.interaction.guild_id, "SID": suggestion_id}, + ) + ) + + # Guild is always set here because icon_url populates it + icon_url = await self.bot.try_fetch_icon_url(ih.interaction.guild_id) + guild = ih.bot.state.guild_cache.get_entry(ih.interaction.guild_id) + embed.set_author(name=guild.name, icon_url=icon_url) + user: disnake.User = await ih.bot.fetch_user(suggestion_author_id) + await user.send(embed=embed) + + await ih.send(ih.bot.get_localized_string("NOTE_INNER_RESPONSE", ih)) diff --git a/suggestions/core/suggestion_resolution.py b/suggestions/core/suggestion_resolution.py new file mode 100644 index 0000000..61b61fd --- /dev/null +++ b/suggestions/core/suggestion_resolution.py @@ -0,0 +1,139 @@ +from alaric.comparison import EQ +from logoo import Logger + +from suggestions.core import BaseCore, SuggestionsQueue +from suggestions.interaction_handler import InteractionHandler +from suggestions.objects import GuildConfig, Suggestion, QueuedSuggestion +from suggestions.objects.suggestion import SuggestionState + +logger = Logger(__name__) + + +class SuggestionsResolutionCore(BaseCore): + def __init__(self, bot): + super().__init__(bot) + self.qs_core: SuggestionsQueue = SuggestionsQueue(bot) + + async def approve( + self, ih: InteractionHandler, suggestion_id: str, response: str | None = None + ): + exists = await ih.bot.db.suggestions.count(EQ("_id", suggestion_id)) + if exists == 0: + await self.approve_queued_suggestion(ih, suggestion_id, response) + else: + await self.approve_suggestion(ih, suggestion_id, response) + + async def approve_queued_suggestion( + self, ih: InteractionHandler, suggestion_id: str, response: str | None = None + ): + qs = await QueuedSuggestion.from_id( + suggestion_id, ih.interaction.guild_id, ih.bot.state + ) + qs.resolution_note = response + await ih.bot.db.queued_suggestions.update(qs, qs) + + await self.qs_core.resolve_queued_suggestion( + ih, queued_suggestion=qs, was_approved=True + ) + await ih.send(translation_key="PAGINATION_INNER_QUEUE_ACCEPTED") + + async def approve_suggestion( + self, ih: InteractionHandler, suggestion_id: str, response: str | None + ): + guild_config: GuildConfig = await GuildConfig.from_id( + ih.interaction.guild_id, ih.bot.state + ) + suggestion: Suggestion = await Suggestion.from_id( + suggestion_id, ih.interaction.guild_id, ih.bot.state + ) + await suggestion.resolve( + guild_config=guild_config, + state=ih.bot.state, + interaction=ih.interaction, + resolution_note=response, + resolution_type=SuggestionState.approved, + bot=self.bot, + ) + + await ih.send( + self.bot.get_locale("APPROVE_INNER_MESSAGE", ih.interaction.locale).format( + suggestion_id + ), + ) + logger.debug( + f"User {ih.interaction.author.id} approved suggestion " + f"{suggestion.suggestion_id} in guild {ih.interaction.guild_id}", + extra_metadata={ + "author_id": ih.interaction.author.id, + "guild_id": ih.interaction.guild_id, + "suggestion_id": suggestion.suggestion_id, + }, + ) + await ih.bot.stats.log_stats( + ih.interaction.author.id, + ih.interaction.guild_id, + ih.bot.stats.type.APPROVE, + ) + + async def reject( + self, ih: InteractionHandler, suggestion_id: str, response: str | None = None + ): + exists = await ih.bot.db.suggestions.count(EQ("_id", suggestion_id)) + if exists == 0: + await self.reject_queued_suggestion(ih, suggestion_id, response) + else: + await self.reject_suggestion(ih, suggestion_id, response) + + async def reject_queued_suggestion( + self, ih: InteractionHandler, suggestion_id: str, response: str | None = None + ): + qs = await QueuedSuggestion.from_id( + suggestion_id, ih.interaction.guild_id, ih.bot.state + ) + qs.resolution_note = response + qs.resolved_by = ih.interaction.author.id + await ih.bot.db.queued_suggestions.update(qs, qs) + + await self.qs_core.resolve_queued_suggestion( + ih, queued_suggestion=qs, was_approved=False + ) + await ih.send(translation_key="PAGINATION_INNER_QUEUE_REJECTED") + + async def reject_suggestion( + self, ih: InteractionHandler, suggestion_id: str, response: str | None + ): + interaction = ih.interaction + guild_config: GuildConfig = await GuildConfig.from_id( + interaction.guild_id, ih.bot.state + ) + suggestion: Suggestion = await Suggestion.from_id( + suggestion_id, interaction.guild_id, ih.bot.state + ) + await suggestion.resolve( + guild_config=guild_config, + state=ih.bot.state, + interaction=interaction, + resolution_note=response, + resolution_type=SuggestionState.rejected, + bot=self.bot, + ) + + await ih.send( + self.bot.get_locale("REJECT_INNER_MESSAGE", interaction.locale).format( + suggestion_id + ), + ) + logger.debug( + f"User {interaction.author} rejected suggestion {suggestion.suggestion_id} " + f"in guild {interaction.guild_id}", + extra_metadata={ + "author_id": interaction.author.id, + "guild_id": interaction.guild_id, + "suggestion_id": suggestion.suggestion_id, + }, + ) + await ih.bot.stats.log_stats( + interaction.author.id, + interaction.guild_id, + ih.bot.stats.type.REJECT, + ) diff --git a/suggestions/core/suggestions_queue.py b/suggestions/core/suggestions_queue.py index ed526e5..04e52f9 100644 --- a/suggestions/core/suggestions_queue.py +++ b/suggestions/core/suggestions_queue.py @@ -17,6 +17,7 @@ from suggestions.interaction_handler import InteractionHandler from suggestions.objects import GuildConfig, UserConfig, QueuedSuggestion from suggestions.qs_paginator import QueuedSuggestionsPaginator +from suggestions.utility import wrap_with_error_handler if TYPE_CHECKING: from alaric import Document @@ -26,20 +27,6 @@ log = logging.getLogger(__name__) -def wrap_with_error_handler(): - def decorator(func: Callable): - @functools.wraps(func) - async def wrapper(*args, **kwargs): - try: - return await func(*args, **kwargs) - except Exception as e: - await args[0].bot.on_slash_command_error(args[1].interaction, e) - - return wrapper - - return decorator - - class SuggestionsQueue: """ Approach to suggestions queue. diff --git a/suggestions/exceptions.py b/suggestions/exceptions.py index 0098375..39b4a84 100644 --- a/suggestions/exceptions.py +++ b/suggestions/exceptions.py @@ -69,3 +69,18 @@ class ConflictingHandlerInformation(disnake.DiscordException): class InvalidFileType(disnake.DiscordException): """The file you attempted to upload is not allowed.""" + + +class SuggestionSecurityViolation(disnake.DiscordException): + """A security violation occurred.""" + + def __init__( + self, + message: str | None = None, + *, + user_facing_message: str, + sid: str, + ): + self.suggestion_id: str = sid + self.user_facing_message = user_facing_message + self.message = message if message is not None else self.__doc__ diff --git a/suggestions/locales/de.json b/suggestions/locales/de.json new file mode 100644 index 0000000..429efd2 --- /dev/null +++ b/suggestions/locales/de.json @@ -0,0 +1,126 @@ +{ + "SUGGEST_NAME": "vorschlagen", + "SUGGEST_DESCRIPTION": "Erstellen Sie einen neuen Vorschlag.", + "SUGGEST_ARG_SUGGESTION_NAME": "vorschlag", + "SUGGEST_ARG_SUGGESTION_DESCRIPTION": "Ihr Vorschlag.", + "SUGGEST_ARG_IMAGE_NAME": "bild", + "SUGGEST_ARG_ANONYMOUSLY_NAME": "anonym", + "SUGGEST_ARG_ANONYMOUSLY_DESCRIPTION": "Senden Sie Ihren Vorschlag anonym ein.", + "APPROVE_NAME": "genehmigen", + "APPROVE_DESCRIPTION": "Genehmigen Sie einen Vorschlag", + "APPROVE_ARG_SUGGESTION_ID_NAME": "vorschlags-id", + "APPROVE_ARG_SUGGESTION_ID_DESCRIPTION": "Die sID, die Sie genehmigen möchten", + "APPROVE_ARG_RESPONSE_NAME": "antwort", + "APPROVE_ARG_RESPONSE_DESCRIPTION": "Eine optionale Antwort, die dem Vorschlag hinzugefügt werden soll", + "REJECT_NAME": "ablehnen", + "REJECT_DESCRIPTION": "Lehnen Sie einen Vorschlag ab", + "REJECT_ARG_SUGGESTION_ID_NAME": "vorschlags-id", + "REJECT_ARG_SUGGESTION_ID_DESCRIPTION": "Die sID, die Sie ablehnen möchten", + "REJECT_ARG_RESPONSE_NAME": "antwort", + "REJECT_ARG_RESPONSE_DESCRIPTION": "Eine optionale Antwort, die dem Vorschlag hinzugefügt werden soll", + "CLEAR_NAME": "löschen", + "CLEAR_DESCRIPTION": "Entfernen Sie einen Vorschlag und alle zugehörigen Nachrichten.", + "CLEAR_ARG_SUGGESTION_ID_NAME": "vorschlags-id", + "CLEAR_ARG_SUGGESTION_ID_DESCRIPTION": "Die sID, die Sie ablehnen möchten", + "CLEAR_ARG_RESPONSE_NAME": "antwort", + "CLEAR_ARG_RESPONSE_DESCRIPTION": "Eine optionale Antwort, die dem Vorschlag hinzugefügt werden soll", + "STATS_NAME": "statistiken", + "STATS_DESCRIPTION": "Holen Sie sich Bot-Statistiken!", + "INFO_NAME": "info", + "INFO_DESCRIPTION": "Bot-Informationen anzeigen.", + "INFO_ARG_SUPPORT_NAME": "support", + "PING_NAME": "ping", + "PING_DESCRIPTION": "Pong!", + "SUGGESTION_UP_VOTE_INNER_NO_MORE_CASTING": "Sie können auf diesen Vorschlag nicht mehr abstimmen.", + "SUGGESTION_UP_VOTE_INNER_ALREADY_VOTED": "Sie haben diesen Vorschlag bereits positiv bewertet.", + "SUGGESTION_UP_VOTE_INNER_MODIFIED_VOTE": "Ich habe Ihre Bewertung von einer negativen zu einer positiven für diesen Vorschlag geändert.\nDer Vorschlag wird in Kürze aktualisiert.", + "SUGGESTION_UP_VOTE_INNER_REGISTERED_VOTE": "Danke!\nIch habe Ihre positive Bewertung registriert.", + "SUGGESTION_DOWN_VOTE_INNER_NO_MORE_CASTING": "Sie können auf diesen Vorschlag nicht mehr abstimmen.", + "SUGGESTION_DOWN_VOTE_INNER_ALREADY_VOTED": "Sie haben diesen Vorschlag bereits negativ bewertet.", + "SUGGESTION_DOWN_VOTE_INNER_MODIFIED_VOTE": "Ich habe Ihre Bewertung von einer positiven zu einer negativen für diesen Vorschlag geändert.\nDer Vorschlag wird in Kürze aktualisiert.", + "SUGGESTION_DOWN_VOTE_INNER_REGISTERED_VOTE": "Danke!\nIch habe Ihre negative Bewertung registriert.", + "VIEW_VOTERS_INNER_TITLE_PREFIX": "Wähler", + "VIEW_UP_VOTERS_INNER_TITLE_PREFIX": "Positive Bewertungen", + "VIEW_DOWN_VOTERS_INNER_TITLE_PREFIX": "Negative Bewertungen", + "DISPLAY_DATA_INNER_OLD_SUGGESTION_TYPE": "Vorschläge mit Reaktionen werden von diesem Befehl nicht unterstützt.", + "DISPLAY_DATA_INNER_NO_VOTERS": "Es gibt keine Wähler, die ich Ihnen zeigen kann.", + "VOTER_PAGINATOR_INNER_EMBED_TITLE": "{} für Vorschlag `{}`", + "VOTER_PAGINATOR_INNER_EMBED_FOOTER": "Seite {} von {}", + "APPROVE_INNER_MESSAGE": "Sie haben **{}** genehmigt.", + "REJECT_INNER_MESSAGE": "Sie haben **{}** abgelehnt.", + "CLEAR_INNER_MESSAGE": "Ich habe `{}` für Sie gelöscht.", + "SUGGEST_INNER_SUGGESTION_SENT": "Hey, {}. Ihr Vorschlag wurde an {} gesendet, um darüber abgestimmt zu werden!\n\nBitte warten Sie, bis er von einem Mitarbeiter genehmigt oder abgelehnt wird.\n\nIhre vorschlags-id (sID) zur Referenz lautet **{}**.", + "SUGGEST_INNER_SUGGESTION_SENT_FOOTER": "Gilden-ID: {} | sID: {}", + "SUGGEST_INNER_THANKS": "Vielen Dank für Ihren Vorschlag!", + "SUGGEST_INNER_SENT_TO_QUEUE": "Ihr Vorschlag wurde zur Bearbeitung in die Warteschlange gestellt.", + "SUGGEST_INNER_NO_ANONYMOUS_SUGGESTIONS": "Ihre Gilde erlaubt keine anonymen Vorschläge.", + "SUGGEST_INNER_NO_IMAGES_IN_SUGGESTIONS": "Ihre Gilde erlaubt keine Bilder in Vorschlägen.", + "SUGGEST_INNER_PING_AUTHOR_IN_THREAD": "Hey <@$AUTHOR_ID>, ich habe diesen Thread für Sie erstellt, um Ihren Vorschlag zu diskutieren.", + "CONFIG_ANONYMOUS_ENABLE_INNER_SUCCESS": "Ich habe anonyme Vorschläge für diese Gilde aktiviert.", + "CONFIG_ANONYMOUS_DISABLE_INNER_SUCCESS": "Ich habe anonyme Vorschläge für diese Gilde deaktiviert.", + "SUGGESTION_OBJECT_LOCK_THREAD": "Diesen Thread sperren, da der Vorschlag eine Lösung gefunden hat.", + "CONFIG_CHANNEL_INNER_MESSAGE": "Ich habe den Vorschlagskanal dieser Gilde auf {} gesetzt.", + "CONFIG_LOGS_INNER_MESSAGE": "Ich habe den Log-Kanal dieser Gilde auf {} gesetzt.", + "CONFIG_GET_INNER_BASE_EMBED_DESCRIPTION": "Konfiguration für {}\n", + "CONFIG_GET_INNER_PARTIAL_LOG_CHANNEL_SET": "Log-Kanal: <#{}>", + "CONFIG_GET_INNER_PARTIAL_LOG_CHANNEL_NOT_SET": "Nicht festgelegt", + "CONFIG_GET_INNER_PARTIAL_SUGGESTION_CHANNEL_SET": "Vorschlagskanal: <#{}>", + "CONFIG_GET_INNER_PARTIAL_SUGGESTION_CHANNEL_NOT_SET": "Nicht festgelegt", + "CONFIG_GET_INNER_PARTIAL_DM_RESPONSES_SET": "werden", + "CONFIG_GET_INNER_PARTIAL_DM_RESPONSES_NOT_SET": "werden nicht", + "CONFIG_GET_INNER_PARTIAL_DM_RESPONSES_MESSAGE": "Dm-Antworten: Ich {} Benutzern auf Aktionen wie Vorschlag DM.", + "CONFIG_GET_INNER_PARTIAL_THREADS_SET": "werden", + "CONFIG_GET_INNER_PARTIAL_THREADS_NOT_SET": "werden nicht", + "CONFIG_GET_INNER_PARTIAL_THREADS_MESSAGE": "Ich {} Threads für neue Vorschläge erstellen", + "CONFIG_GET_INNER_KEEP_LOGS_SET": "Vorschlagsprotokolle werden in Ihrem Vorschlagskanal aufbewahrt.", + "CONFIG_GET_INNER_KEEP_LOGS_NOT_SET": "Vorschlagsprotokolle werden in Ihrem Log-Kanal aufbewahrt.", + "CONFIG_GET_INNER_ANONYMOUS_SUGGESTIONS_SET": "können", + "CONFIG_GET_INNER_ANONYMOUS_SUGGESTIONS_NOT_SET": "können nicht", + "CONFIG_GET_INNER_ANONYMOUS_SUGGESTIONS_MESSAGE": "Diese Gilde {} anonyme Vorschläge haben.", + "CONFIG_GET_INNER_AUTO_ARCHIVE_THREADS_SET": "werden", + "CONFIG_GET_INNER_AUTO_ARCHIVE_THREADS_NOT_SET": "werden nicht", + "CONFIG_GET_INNER_AUTO_ARCHIVE_THREADS_MESSAGE": "Ich {} automatisch Threads erstellen, die für Vorschläge erstellt wurden, archivieren.", + "CONFIG_DM_ENABLE_INNER_MESSAGE": "Ich habe DM-Nachrichten für diese Gilde aktiviert.", + "CONFIG_DM_DISABLE_INNER_MESSAGE": "Ich habe DM-Nachrichten für diese Gilde deaktiviert.", + "CONFIG_THREAD_ENABLE_INNER_MESSAGE": "Ich habe Threads für neue Vorschläge für diese Gilde aktiviert.", + "CONFIG_THREAD_DISABLE_INNER_MESSAGE": "Ich habe die Thread-Erstellung für neue Vorschläge für diese Gilde deaktiviert.", + "CONFIG_KEEPLOGS_ENABLE_INNER_MESSAGE": "Vorschläge bleiben jetzt in Ihrem Vorschlagskanal, anstatt in die Logs zu gehen.", + "CONFIG_KEEPLOGS_DISABLE_INNER_MESSAGE": "Vorschläge werden jetzt in Ihren Log-Kanal verschoben, wenn sie fertig sind.", + "CONFIG_AUTO_ARCHIVE_THREADS_ENABLE_INNER_MESSAGE": "Automatisch erstellte Threads für Vorschläge werden jetzt beim Abschluss des Vorschlags archiviert.", + "CONFIG_AUTO_ARCHIVE_THREADS_DISABLE_INNER_MESSAGE": "Automatisch erstellte Threads für Vorschläge werden beim Abschluss des Vorschlags nicht mehr archiviert.", + "CONFIG_SUGGESTIONS_QUEUE_ENABLE_INNER_MESSAGE": "Alle neuen Vorschläge werden in Ihre Vorschlagswarteschlange gesendet.", + "CONFIG_SUGGESTIONS_QUEUE_DISABLE_INNER_MESSAGE": "Alle neuen Vorschläge werden direkt an Ihren Vorschlagskanal gesendet.", + "CONFIG_ANONYMOUS_RESOLUTION_ENABLE_INNER_MESSAGE": "Alle weiteren Vorschläge zeigen nicht den Moderator an, der sie gelöst hat.", + "CONFIG_ANONYMOUS_RESOLUTION_DISABLE_INNER_MESSAGE": "Alle weiteren Vorschläge zeigen den Moderator an, der sie gelöst hat.", + "CONFIG_GET_INNER_SUGGESTIONS_QUEUE_SET": "In Benutzung.", + "CONFIG_GET_INNER_SUGGESTIONS_QUEUE_NOT_SET": "Nicht in Benutzung.", + "CONFIG_GET_INNER_SUGGESTIONS_QUEUE_MESSAGE": "Vorschlagswarteschlange ist $TEXT", + "CONFIG_GET_INNER_ANONYMOUS_RESOLUTION_NOT_SET": "Vorschlagende Benutzer werden angezeigt, wer ihre Vorschläge gelöst hat.", + "CONFIG_GET_INNER_ANONYMOUS_RESOLUTION_SET": "Vorschlagende Benutzer werden nicht angezeigt, wer ihre Vorschläge gelöst hat.", + "PAGINATION_INNER_SESSION_EXPIRED": "Diese Paginierungssitzung ist abgelaufen. Bitte starten Sie eine neue mit `/queue view`", + "PAGINATION_INNER_NEXT_ITEM": "Anzeigen des nächsten Elements in der Warteschlange.", + "PAGINATION_INNER_PREVIOUS_ITEM": "Anzeigen des vorherigen Elements in der Warteschlange.", + "PAGINATION_INNER_QUEUE_EXPIRED": "Diese Warteschlange ist abgelaufen.", + "PAGINATION_INNER_QUEUE_CANCELLED": "Ich habe diese Warteschlange für Sie abgebrochen.", + "PAGINATION_INNER_QUEUE_ACCEPTED": "Ich habe diesen Vorschlag aus der Warteschlange akzeptiert.", + "PAGINATION_INNER_QUEUE_REJECTED": "Ich habe diesen Vorschlag aus der Warteschlange entfernt.", + "QUEUE_VIEW_INNER_NOTHING_QUEUED": "Ihre Gilde hat keine Vorschläge in der Warteschlange.", + "QUEUE_VIEW_INNER_PRIOR_QUEUE": "Diese Vorschläge wurden in die Warteschlange gestellt, bevor Ihre Gilde die Vorschlagswarteschlange deaktiviert hat.", + "QUEUE_INNER_USER_REJECTED": "Ihr vorgemerkter Vorschlag wurde abgelehnt. Zur Referenz habe ich den abgelehnten Vorschlag unten eingeschlossen.", + "CONFIG_GET_INNER_IMAGES_IN_SUGGESTIONS_SET": "können", + "CONFIG_GET_INNER_IMAGES_IN_SUGGESTIONS_NOT_SET": "können nicht", + "CONFIG_GET_INNER_IMAGES_IN_SUGGESTIONS_MESSAGE": "Diese Gilde {} Bilder in Vorschlägen haben.", + "CONFIG_SUGGESTIONS_IMAGES_ENABLE_INNER_MESSAGE": "Alle neuen Vorschläge können Bilder enthalten.", + "CONFIG_SUGGESTIONS_IMAGES_DISABLE_INNER_MESSAGE": "Alle neuen Vorschläge können keine Bilder enthalten.", + "VIEW_VOTERS_INNER_EMBED_TITLE": "Wähler anzeigen", + "USER_BLOCKLIST_ADD_NAME": "hinzufügen", + "USER_BLOCKLIST_ADD_DESCRIPTION": "Entfernen Sie die Möglichkeit eines Benutzers, Vorschläge zu erstellen.", + "USER_BLOCKLIST_REMOVE_NAME": "entfernen", + "USER_BLOCKLIST_REMOVE_DESCRIPTION": "Fügen Sie einem Benutzer wieder die Möglichkeit hinzu, Vorschläge zu erstellen.", + "SUGGESTION_ID_NAME": "vorschlags-id", + "SUGGESTION_ID_DESCRIPTION": "Die vorschlags-id, auf die Sie sich beziehen möchten.", + "USER_ID_NAME": "benutzer-id", + "USER_ID_DESCRIPTION": "Die Discord-ID des Benutzers.", + "VIEW_VOTERS_CLEARED_SUGGESTION": "Kann einen gelöschten Vorschlag nicht anzeigen." + } + \ No newline at end of file diff --git a/suggestions/locales/en_GB.json b/suggestions/locales/en_GB.json index 8945a16..7c1c30a 100644 --- a/suggestions/locales/en_GB.json +++ b/suggestions/locales/en_GB.json @@ -22,7 +22,7 @@ "CLEAR_NAME": "clear", "CLEAR_DESCRIPTION": "Remove a suggestion and any associated messages.", "CLEAR_ARG_SUGGESTION_ID_NAME": "suggestion_id", - "CLEAR_ARG_SUGGESTION_ID_DESCRIPTION": "The sID you wish to reject", + "CLEAR_ARG_SUGGESTION_ID_DESCRIPTION": "The sID you wish to clear", "CLEAR_ARG_RESPONSE_NAME": "response", "CLEAR_ARG_RESPONSE_DESCRIPTION": "An optional response to add to the suggestion", "STATS_NAME": "stats", @@ -86,6 +86,8 @@ "CONFIG_DM_DISABLE_INNER_MESSAGE": "I have disabled DM messages for this guild.", "CONFIG_THREAD_ENABLE_INNER_MESSAGE": "I have enabled threads on new suggestions for this guild.", "CONFIG_THREAD_DISABLE_INNER_MESSAGE": "I have disabled thread creation on new suggestions for this guild.", + "CONFIG_PING_ON_THREAD_CREATION_ENABLE_INNER_MESSAGE": "I have enabled pings on new suggestion threads for this guild.", + "CONFIG_PING_ON_THREAD_CREATION_DISABLE_INNER_MESSAGE": "I have disabled pings on new suggestion threads for this guild.", "CONFIG_KEEPLOGS_ENABLE_INNER_MESSAGE": "Suggestions will now stay in your suggestions channel instead of going to logs.", "CONFIG_KEEPLOGS_DISABLE_INNER_MESSAGE": "Suggestions will now be moved to your logs channel when finished.", "CONFIG_AUTO_ARCHIVE_THREADS_ENABLE_INNER_MESSAGE": "Automatically created threads for suggestions will now be archived upon suggestion resolution.", @@ -123,5 +125,33 @@ "SUGGESTION_ID_DESCRIPTION": "The suggestions ID you wish to reference.", "USER_ID_NAME": "user_id", "USER_ID_DESCRIPTION": "The users discord id.", - "VIEW_VOTERS_CLEARED_SUGGESTION": "Cannot view a cleared suggestion." + "VIEW_VOTERS_CLEARED_SUGGESTION": "Cannot view a cleared suggestion.", + "NOTE_INNER_CHANGE_MADE_DESCRIPTION": "A moderator has modified a note on your suggestion.\n\nYou can review the note (or lack thereof) here: $JUMP", + "NOTE_INNER_CHANGE_MADE_FOOTER": "Guild ID: $GUILD_ID | sID: $SID", + "NOTE_INNER_RESPONSE": "Your change has been made to the suggestion.", + "NOTES_NAME": "note", + "NOTES_ADD_NAME": "add", + "NOTES_ADD_DESCRIPTION": "Add a note to an existing suggestion.", + "NOTES_ADD_ARG_SUGGESTION_ID_NAME": "suggestion_id", + "NOTES_ADD_ARG_SUGGESTION_ID_DESCRIPTION": "The sID you wish to add a note to", + "NOTES_ADD_ARG_NOTE_NAME": "note", + "NOTES_ADD_ARG_NOTE_DESCRIPTION": "The note you wish to add", + "NOTES_REMOVE_NAME": "remove", + "NOTES_REMOVE_DESCRIPTION": "Remove a note from a suggestion", + "NOTES_REMOVE_ARG_SUGGESTION_ID_NAME": "suggestion_id", + "NOTES_REMOVE_ARG_SUGGESTION_ID_DESCRIPTION": "The sID you wish to remove a note from", + "CONFIG_GET_INNER_USES_PHYSICAL_QUEUE_SET": "This guild uses a channel based suggestions queue.", + "CONFIG_GET_INNER_USES_PHYSICAL_QUEUE_NOT_SET": "This guild uses a virtual suggestions queue.", + "CONFIG_GET_INNER_USES_THREAD_PINGS_SET": "This guild pings users in threads created for suggestions.", + "CONFIG_GET_INNER_USES_THREAD_PINGS_NOT_SET": "This guild does not ping users in threads created for suggestions.", + "CONFIG_USE_PHYSICAL_QUEUE_ENABLE_INNER_MESSAGE": "If the suggestions queue is enabled, all new suggestions will go to a channel queue.", + "CONFIG_USE_PHYSICAL_QUEUE_DISABLE_INNER_MESSAGE": "If the suggestions queue is enabled, all new suggestions will go to a virtual queue.", + "CONFIG_QUEUE_CHANNEL_INNER_MESSAGE": "I have set this guilds suggestion queue channel to $CHANNEL", + "CONFIG_QUEUE_LOG_CHANNEL_INNER_MESSAGE": "I have set this guilds suggestion queue channel to {}", + "CONFIG_QUEUE_CHANNEL_INNER_MESSAGE_REMOVED": "I have disabled sending rejected queue suggestions to a channel.", + "CONFIG_GET_INNER_PARTIAL_QUEUE_LOG_CHANNEL_SET": "Queue channel: <#{}>", + "CONFIG_GET_INNER_PARTIAL_QUEUE_LOG_CHANNEL_NOT_SET": "Not set", + "CONFIG_GET_INNER_PARTIAL_QUEUE_REJECTION_LOG_CHANNEL_SET": "Queue rejection channel: <#{}>", + "CONFIG_GET_INNER_PARTIAL_QUEUE_REJECTION_LOG_CHANNEL_NOT_SET": "Not set", + "QUEUE_INNER_ALREADY_RESOLVED": "This queued suggestion has already been resolved." } diff --git a/suggestions/locales/en_US.json b/suggestions/locales/en_US.json index 52b457e..76fd314 100644 --- a/suggestions/locales/en_US.json +++ b/suggestions/locales/en_US.json @@ -22,7 +22,7 @@ "CLEAR_NAME": "clear", "CLEAR_DESCRIPTION": "Remove a suggestion and any associated messages.", "CLEAR_ARG_SUGGESTION_ID_NAME": "suggestion_id", - "CLEAR_ARG_SUGGESTION_ID_DESCRIPTION": "The sID you wish to reject", + "CLEAR_ARG_SUGGESTION_ID_DESCRIPTION": "The sID you wish to clear", "CLEAR_ARG_RESPONSE_NAME": "response", "CLEAR_ARG_RESPONSE_DESCRIPTION": "An optional response to add to the suggestion", "STATS_NAME": "stats", diff --git a/suggestions/locales/pt_BR.json b/suggestions/locales/pt_BR.json index 000f344..fb0b273 100644 --- a/suggestions/locales/pt_BR.json +++ b/suggestions/locales/pt_BR.json @@ -57,6 +57,7 @@ "SUGGEST_INNER_SENT_TO_QUEUE": "Sua sugestão foi enviada à fila para processamento.", "SUGGEST_INNER_NO_ANONYMOUS_SUGGESTIONS": "Seu servidor não permite sugestões anônimas.", "SUGGEST_INNER_NO_IMAGES_IN_SUGGESTIONS": "Seu servidor não permite imagens em sugestões.", + "SUGGEST_INNER_PING_AUTHOR_IN_THREAD": "Ei, <@$AUTHOR_ID>, criei este tópico para você discutir sua sugestão nele.", "CONFIG_ANONYMOUS_ENABLE_INNER_SUCCESS": "Habilitei sugestões anônimas para este servidor.", "CONFIG_ANONYMOUS_DISABLE_INNER_SUCCESS": "Desabilitei sugestões anônimas para este servidor.", "SUGGESTION_OBJECT_LOCK_THREAD": "Trancando este tópico já que a sugestão chegou a uma resolução.", @@ -64,7 +65,7 @@ "CONFIG_LOGS_INNER_MESSAGE": "Configurei o canal de registro de sugestões deste servidor como {}", "CONFIG_QUEUE_CHANNEL_INNER_MESSAGE": "Configurei o canal da fila de sugestões deste servidor como $CHANNEL", "CONFIG_QUEUE_LOG_CHANNEL_INNER_MESSAGE": "Configurei o canal da fila de sugestões deste servidor como {}", - "CONFIG_QUEUE_CHANNEL_INNER_MESSAGE_REMOVED": "Desabilitei o envio de sugestões rejeitadas na fila para um canal.", + "CONFIG_QUEUE_CHANNEL_INNER_MESSAGE_REMOVED": "Desabilitei o envio de sugestões rejeitadas na fila a um canal.", "CONFIG_GET_INNER_BASE_EMBED_DESCRIPTION": "Configuração para {}\n", "CONFIG_GET_INNER_PARTIAL_LOG_CHANNEL_SET": "Canal de registro de sugestões: <#{}>", "CONFIG_GET_INNER_PARTIAL_LOG_CHANNEL_NOT_SET": "Não configurado", @@ -129,9 +130,28 @@ "USER_BLOCKLIST_REMOVE_NAME": "remover", "USER_BLOCKLIST_REMOVE_DESCRIPTION": "Readicionar a habilidade de um usuário de criar sugestões.", "SUGGESTION_ID_NAME": "id_da_sugestão", - "SUGGESTION_ID_DESCRIPTION": "O ID de sugestão que você deseja referenciar.", - "USER_ID_NAME": "id_de_usuário", + "SUGGESTION_ID_DESCRIPTION": "O ID da sugestão que você deseja referenciar.", + "USER_ID_NAME": "id_do_usuário", "USER_ID_DESCRIPTION": "O ID do usuário no Discord.", - "CONFIG_USE_PHYSICAL_QUEUE_ENABLE_INNER_MESSAGE": "Se a fila de sugestões estiver habilitada, todas as novas sugestões irão a uma fila de canal.", - "CONFIG_USE_PHYSICAL_QUEUE_DISABLE_INNER_MESSAGE": "Se a fila de sugestões estiver habilitada, todas as novas sugestões irão a uma fila virtual." + "CONFIG_USE_PHYSICAL_QUEUE_ENABLE_INNER_MESSAGE": "Se a fila de sugestões estiver habilitada, todas as novas sugestões irão a uma fila canal.", + "CONFIG_USE_PHYSICAL_QUEUE_DISABLE_INNER_MESSAGE": "Se a fila de sugestões estiver habilitada, todas as novas sugestões irão a uma fila virtual.", + "VIEW_VOTERS_CLEARED_SUGGESTION": "Não posso ver uma sugestão removida.", + "NOTE_INNER_CHANGE_MADE_DESCRIPTION": "Um moderador modificou uma nota em sua sugestão.\n\nVocê pode rever a nota (ou a falta dela) aqui: $JUMP", + "NOTE_INNER_CHANGE_MADE_FOOTER": "ID do Servidor: $GUILD_ID | sID: $SID", + "NOTE_INNER_RESPONSE": "Sua mudança foi feita na sugestão.", + "NOTES_NAME": "nota", + "NOTES_ADD_NAME": "adicionar", + "NOTES_ADD_DESCRIPTION": "Adicione uma nota a uma sugestão existente.", + "NOTES_ADD_ARG_SUGGESTION_ID_NAME": "id_da_sugestão", + "NOTES_ADD_ARG_SUGGESTION_ID_DESCRIPTION": "O ID da sugestão a que você deseja adicionar uma nota", + "NOTES_ADD_ARG_NOTE_NAME": "nota", + "NOTES_ADD_ARG_NOTE_DESCRIPTION": "A nota que você deseja adicionar", + "NOTES_REMOVE_NAME": "remover", + "NOTES_REMOVE_DESCRIPTION": "Remova uma nota de uma sugestão", + "NOTES_REMOVE_ARG_SUGGESTION_ID_NAME": "id_da_sugestão", + "NOTES_REMOVE_ARG_SUGGESTION_ID_DESCRIPTION": "O ID da sugestão de que você deseja remover uma nota", + "CONFIG_GET_INNER_USES_PHYSICAL_QUEUE_SET": "Este servidor usa uma fila de sugestões na base de canal.", + "CONFIG_GET_INNER_USES_PHYSICAL_QUEUE_NOT_SET": "Este servidor usa uma fila virtual de sugestões.", + "CONFIG_GET_INNER_USES_THREAD_PINGS_SET": "Este servidor menciona usuários em tópicos criados para sugestões.", + "CONFIG_GET_INNER_USES_THREAD_PINGS_NOT_SET": "Este servidor não menciona usuários em tópicos criados para sugestões." } diff --git a/suggestions/objects/guild_config.py b/suggestions/objects/guild_config.py index 0ec6b55..2c73eff 100644 --- a/suggestions/objects/guild_config.py +++ b/suggestions/objects/guild_config.py @@ -31,6 +31,7 @@ def __init__( can_have_images_in_suggestions: bool = True, anonymous_resolutions: bool = False, blocked_users: Optional[list[int]] = None, + ping_on_thread_creation: bool = True, **kwargs, ): self._id: int = _id @@ -42,8 +43,9 @@ def __init__( self.dm_messages_disabled: bool = dm_messages_disabled self.anonymous_resolutions: bool = anonymous_resolutions self.uses_suggestion_queue: bool = uses_suggestion_queue - self.virtual_suggestion_queue: bool = virtual_suggestion_queue self.threads_for_suggestions: bool = threads_for_suggestions + self.ping_on_thread_creation: bool = ping_on_thread_creation + self.virtual_suggestion_queue: bool = virtual_suggestion_queue self.suggestions_channel_id: Optional[int] = suggestions_channel_id self.can_have_anonymous_suggestions: bool = can_have_anonymous_suggestions self.can_have_images_in_suggestions: bool = can_have_images_in_suggestions @@ -108,6 +110,7 @@ def as_dict(self) -> Dict: "auto_archive_threads": self.auto_archive_threads, "dm_messages_disabled": self.dm_messages_disabled, "suggestions_channel_id": self.suggestions_channel_id, + "ping_on_thread_creation": self.ping_on_thread_creation, "uses_suggestion_queue": self.uses_suggestion_queue, "anonymous_resolutions": self.anonymous_resolutions, "virtual_suggestion_queue": self.virtual_suggestion_queue, diff --git a/suggestions/objects/queued_suggestion.py b/suggestions/objects/queued_suggestion.py index a84606d..0cced03 100644 --- a/suggestions/objects/queued_suggestion.py +++ b/suggestions/objects/queued_suggestion.py @@ -9,7 +9,11 @@ from disnake import Embed from logoo import Logger -from suggestions.exceptions import UnhandledError, SuggestionNotFound +from suggestions.exceptions import ( + UnhandledError, + SuggestionNotFound, + SuggestionSecurityViolation, +) from suggestions.objects import Suggestion if TYPE_CHECKING: @@ -104,6 +108,53 @@ async def from_message_id( f"This message does not look like a suggestions message." ) + # A backport for BT-22 + if not suggestion._id: + suggestion._id = state.get_new_suggestion_id() + await state.queued_suggestions_db.upsert(suggestion, suggestion) + + return suggestion + + @classmethod + async def from_id( + cls, suggestion_id: str, guild_id: int, state: State + ) -> QueuedSuggestion: + """Returns a valid QueuedSuggestion instance from an id. + + Parameters + ---------- + suggestion_id: str + The suggestion we want + guild_id: int + The guild its meant to be in. + Secures against cross guild privileged escalation + state: State + Internal state to marshall data + + Returns + ------- + QueuedSuggestion + The valid suggestion + + Raises + ------ + SuggestionNotFound + No suggestion found with that id + """ + suggestion: Optional[QueuedSuggestion] = await state.queued_suggestions_db.find( + AQ(EQ("_id", suggestion_id)) + ) + if not suggestion: + raise SuggestionNotFound( + f"No queued suggestion found with the id {suggestion_id} in this guild" + ) + + if suggestion.guild_id != guild_id: + raise SuggestionSecurityViolation( + sid=suggestion_id, + user_facing_message=f"No queued suggestion found with the id {suggestion_id} in this guild", + ) + return suggestion @classmethod @@ -139,6 +190,7 @@ async def new( Suggestion A valid suggestion. """ + _id = state.get_new_suggestion_id() suggestion: QueuedSuggestion = QueuedSuggestion( guild_id=guild_id, suggestion=suggestion, @@ -146,6 +198,7 @@ async def new( created_at=state.now, image_url=image_url, is_anonymous=is_anonymous, + _id=_id, ) await state.queued_suggestions_db.insert(suggestion) @@ -206,14 +259,26 @@ async def as_embed(self, bot: SuggestionsBot) -> Embed: timestamp=self.created_at, ) if not self.is_anonymous: + id_section = "" + if self._id and isinstance(self._id, str): + # If longer then 8 it's a database generated id + # and shouldn't be considered for this purpose + id_section = f" ID {self._id}" + embed.set_thumbnail(user.display_avatar) embed.set_footer( - text=f"Queued suggestion | Submitter ID: {self.suggestion_author_id}" + text=f"Queued suggestion{id_section} | Submitter ID: {self.suggestion_author_id}" ) if self.image_url: embed.set_image(self.image_url) + if self.resolution_note and self.resolved_by is not None: + # Means it's been rejected so we should show it + note_desc = f"\n\n**Moderator note**\n{self.resolution_note}" + # TODO Resolve BT-44 and add moderator back + embed.description += note_desc + return embed async def convert_to_suggestion(self, state: State) -> Suggestion: @@ -238,6 +303,11 @@ async def convert_to_suggestion(self, state: State) -> Suggestion: is_anonymous=self.is_anonymous, ) self.related_suggestion_id = suggestion.suggestion_id + + # Resolution notes default to the suggestion notes + if self.resolution_note: + suggestion.note = self.resolution_note + await state.queued_suggestions_db.update(self, self) return suggestion diff --git a/suggestions/objects/suggestion.py b/suggestions/objects/suggestion.py index 45225f8..d7ed7f3 100644 --- a/suggestions/objects/suggestion.py +++ b/suggestions/objects/suggestion.py @@ -9,12 +9,17 @@ from alaric.comparison import EQ from alaric.logical import AND from bot_base.wraps import WrappedChannel +from commons.caching import NonExistentEntry from disnake import Embed from disnake.ext import commands from logoo import Logger from suggestions import ErrorCode -from suggestions.exceptions import ErrorHandled, SuggestionNotFound +from suggestions.exceptions import ( + ErrorHandled, + SuggestionNotFound, + SuggestionSecurityViolation, +) from suggestions.interaction_handler import InteractionHandler from suggestions.low_level import MessageEditing from suggestions.objects import UserConfig, GuildConfig @@ -69,6 +74,8 @@ def __init__( SuggestionState, ], *, + note: Optional[str] = None, + note_added_by: Optional[int] = None, total_up_votes: Optional[int] = None, total_down_votes: Optional[int] = None, up_voted_by: Optional[list[int]] = None, @@ -103,6 +110,12 @@ def __init__( Other Parameters ---------------- + note: Optional[str] + A note to add to the suggestion embed + note_added_by: Optional[int] + Who added the note. + + Should be marked as hidden if not shown. resolved_by: Optional[int] Who changed the final state of this suggestion resolution_note: Optional[str] @@ -172,6 +185,8 @@ def __init__( self.image_url: Optional[str] = image_url self.is_anonymous: bool = is_anonymous self.anonymous_resolution: Optional[bool] = anonymous_resolution + self.note: Optional[str] = note + self.note_added_by: Optional[int] = note_added_by @property def total_up_votes(self) -> Optional[int]: @@ -286,13 +301,9 @@ async def from_id( ) if suggestion.guild_id != guild_id: - logger.critical( - "Someone in guild %s looked up a suggestion not from their guild", - guild_id, - extra_metadata={"guild_id": guild_id, "suggestion_id": suggestion_id}, - ) - raise SuggestionNotFound( - f"No suggestion found with the id {suggestion_id} in this guild" + raise SuggestionSecurityViolation( + sid=suggestion_id, + user_facing_message=f"No suggestion found with the id {suggestion_id} in this guild", ) return suggestion @@ -365,6 +376,10 @@ def as_dict(self) -> dict: "anonymous_resolution": self.anonymous_resolution, } + if self.note: + data["note"] = self.note + data["note_added_by"] = self.note_added_by + if self.resolved_by: data["resolved_by"] = self.resolved_by data["resolution_note"] = self.resolution_note @@ -417,6 +432,11 @@ async def as_embed(self, bot: SuggestionsBot) -> Embed: if self.image_url: embed.set_image(self.image_url) + if self.note: + note_desc = f"\n\n**Moderator note**\n{self.note}" + # TODO Resolve BT-44 and add moderator back + embed.description += note_desc + if self.uses_views_for_votes: results = ( f"**Results so far**\n{await bot.suggestion_emojis.default_up_vote()}: **{self.total_up_votes}**\n" @@ -698,10 +718,27 @@ async def create_thread(self, message: disnake.Message, *, ih: InteractionHandle thread = await message.create_thread( name=f"Thread for suggestion {self.suggestion_id}" ) + logger.debug( + f"Created a thread for suggestion {self.suggestion_id}", + extra_metadata={"suggestion_id": self.suggestion_id}, + ) if self.is_anonymous: - # Dont expose the anon author + # Don't expose the anon author + return + + guild_config: GuildConfig = await GuildConfig.from_id( + ih.interaction.guild_id, ih.bot.state + ) + if not guild_config.ping_on_thread_creation: + return + + user_config: UserConfig = await UserConfig.from_id( + ih.interaction.author.id, ih.bot.state + ) + if not user_config.ping_on_thread_creation: return + # TODO The ones for guilds as well try: await thread.send( ih.bot.get_localized_string( @@ -714,36 +751,23 @@ async def create_thread(self, message: disnake.Message, *, ih: InteractionHandle # I'd consider it fine if the bot can't send this message pass - async def update_vote_count( + async def edit_suggestion_message( self, - bot: SuggestionsBot, - interaction: disnake.Interaction, + ih: InteractionHandler, ): - if self.channel_id is None or self.message_id is None: - logger.error( - "update_vote_count received a null value for SID %s, " - "channel_id=%s, message_id=%s", - self.channel_id, - self.message_id, - extra_metadata={ - "suggestion_id": self.suggestion_id, - "author_id": self.suggestion_author_id, - }, - ) - return - + """A generic method to edit a suggestion message to the new values.""" + bot = ih.bot try: await MessageEditing( bot, channel_id=self.channel_id, message_id=self.message_id ).edit(embed=await self.as_embed(bot)) except (disnake.HTTPException, disnake.NotFound): - await interaction.send( + await ih.send( embed=bot.error_embed( "Command failed", "Looks like this suggestion was deleted.", footer_text=f"Error code {ErrorCode.SUGGESTION_MESSAGE_DELETED.value}", ), - ephemeral=True, ) raise ErrorHandled diff --git a/suggestions/objects/user_config.py b/suggestions/objects/user_config.py index 4c647a0..2f419de 100644 --- a/suggestions/objects/user_config.py +++ b/suggestions/objects/user_config.py @@ -16,9 +16,15 @@ class UserConfig: """Generic global user configuration""" - def __init__(self, _id: int, dm_messages_disabled: bool = False): + def __init__( + self, + _id: int, + dm_messages_disabled: bool = False, + ping_on_thread_creation: bool = True, + ): self._id: int = _id self.dm_messages_disabled: bool = dm_messages_disabled + self.ping_on_thread_creation: bool = ping_on_thread_creation @classmethod async def from_id(cls, user_id: int, state: State): @@ -58,7 +64,11 @@ def user_id(self) -> int: return self._id def as_dict(self) -> Dict: - return {"_id": self.user_id, "dm_messages_disabled": self.dm_messages_disabled} + return { + "_id": self.user_id, + "dm_messages_disabled": self.dm_messages_disabled, + "ping_on_thread_creation": self.ping_on_thread_creation, + } def as_filter(self) -> Dict: return {"_id": self.user_id} diff --git a/suggestions/state.py b/suggestions/state.py index 86705cc..294d9b5 100644 --- a/suggestions/state.py +++ b/suggestions/state.py @@ -10,7 +10,7 @@ import disnake from alaric import AQ -from alaric.comparison import EQ +from alaric.comparison import EQ, Exists from alaric.logical import AND from alaric.meta import Negate from alaric.projections import PROJECTION, SHOW @@ -177,6 +177,19 @@ async def populate_sid_cache(self, guild_id: int) -> list: try_convert=False, ) data: List[str] = [d["_id"] for d in data] + + queued_data: List[Dict] = await self.database.queued_suggestions.find_many( + AQ( + AND(EQ("guild_id", guild_id), Negate(Exists("resolved_at"))), + ), + projections=PROJECTION(SHOW("_id")), + try_convert=False, + ) + queued_data: List[str] = [ + d["_id"] for d in queued_data if isinstance(d["_id"], str) + ] + data.extend(queued_data) + self.autocomplete_cache.add_entry(guild_id, data, override=True) logger.debug( "Populated sid cache for guild %s", @@ -263,6 +276,15 @@ async def load(self): for entry in suggestion_ids: self.existing_suggestion_ids.add(entry["_id"]) + suggestion_ids: List[Dict] = await self.queued_suggestions_db.get_all( + {}, + projections=PROJECTION(SHOW("_id")), + try_convert=False, + ) + for entry in suggestion_ids: + if isinstance(entry["_id"], str) and len(entry["_id"]) == 8: + self.existing_suggestion_ids.add(entry["_id"]) + error_ids: List[Dict] = await self.bot.db.error_tracking.get_all( {}, projections=PROJECTION(SHOW("_id")), diff --git a/suggestions/stats.py b/suggestions/stats.py index b3c5d75..f060116 100644 --- a/suggestions/stats.py +++ b/suggestions/stats.py @@ -2,7 +2,6 @@ import asyncio import datetime -import logging from enum import Enum from typing import TYPE_CHECKING, Optional, Type @@ -10,6 +9,7 @@ from alaric import Cursor, AQ from alaric.comparison import EQ from commons.caching import TimedCache +from logoo import Logger from suggestions.objects.stats import MemberStats, MemberCommandStats @@ -17,7 +17,7 @@ from suggestions import State, SuggestionsBot from suggestions.database import SuggestionsMongoManager -log = logging.getLogger(__name__) +log = Logger(__name__) class StatsEnum(Enum): @@ -30,6 +30,9 @@ class StatsEnum(Enum): MEMBER_DM_VIEW = "member_dm_view" MEMBER_DM_ENABLE = "member_dm_enable" MEMBER_DM_DISABLE = "member_dm_disable" + MEMBER_PING_ON_THREAD_CREATE_ENABLE = "member_ping_on_thread_create_enable" + MEMBER_PING_ON_THREAD_CREATE_DISABLE = "member_ping_on_thread_create_disable" + MEMBER_PING_ON_THREAD_CREATE_VIEW = "member_ping_on_thread_create_view" GUILD_CONFIG_LOG_CHANNEL = "guild_config_log_channel" GUILD_CONFIG_QUEUE_CHANNEL = "guild_config_queue_channel" GUILD_CONFIG_REJECTED_QUEUE_CHANNEL = "guild_config_rejected_queue_channel" @@ -39,6 +42,8 @@ class StatsEnum(Enum): GUILD_DM_DISABLE = "guild_dm_disable" GUILD_THREAD_ENABLE = "guild_thread_enable" GUILD_THREAD_DISABLE = "guild_thread_disable" + GUILD_PING_ON_THREAD_CREATE_ENABLE = "guild_ping_on_thread_create_enable" + GUILD_PING_ON_THREAD_CREATE_DISABLE = "guild_ping_on_thread_create_disable" GUILD_AUTO_ARCHIVE_THREADS_ENABLE = "guild_auto_archive_threads_enable" GUILD_AUTO_ARCHIVE_THREADS_DISABLE = "guild_auto_archive_threads_disable" GUILD_SUGGESTIONS_QUEUE_ENABLE = "guild_suggestions_queue_enable" diff --git a/suggestions/utility/__init__.py b/suggestions/utility/__init__.py index e69de29..dc69cb9 100644 --- a/suggestions/utility/__init__.py +++ b/suggestions/utility/__init__.py @@ -0,0 +1 @@ +from .error_wrapper import wrap_with_error_handler diff --git a/suggestions/utility/error_wrapper.py b/suggestions/utility/error_wrapper.py new file mode 100644 index 0000000..9f45713 --- /dev/null +++ b/suggestions/utility/error_wrapper.py @@ -0,0 +1,23 @@ +import functools +from typing import Callable + +from suggestions.interaction_handler import InteractionHandler + + +def wrap_with_error_handler(): + def decorator(func: Callable): + @functools.wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except Exception as e: + inter = ( + args[1].interaction + if isinstance(args[1], InteractionHandler) + else args[1] + ) + await args[0].bot.on_slash_command_error(inter, e) + + return wrapper + + return decorator diff --git a/tests/test_bot.py b/tests/test_bot.py index 9dc4f01..3ad6f13 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -18,6 +18,7 @@ async def test_cogs_loaded(causar: Causar): "SuggestionsMessageCommands", "SuggestionsQueueCog", "BlacklistCog", + "SuggestionNotesCog", ] assert len(bot.cogs) == len(cog_names) for cog_name in cog_names: diff --git a/tests/test_disnake.py b/tests/test_disnake.py index d0d9611..0c58c03 100644 --- a/tests/test_disnake.py +++ b/tests/test_disnake.py @@ -5,6 +5,7 @@ import disnake +# TODO We want to remove this in favour of interaction handlers in BT-39 def test_deferred_without_send(): a = { "id": "1", @@ -20,7 +21,3 @@ def test_deferred_without_send(): assert isinstance(inter.deferred_without_send, bool) assert hasattr(inter, "has_been_followed_up") assert isinstance(inter.has_been_followed_up, bool) - - -def test_try_fetch_icon_url(): - assert hasattr(disnake.Guild, "try_fetch_icon_url")