From dcec7c0a65a08874342b6783e73914b1f65ceb6d Mon Sep 17 00:00:00 2001 From: skelmis Date: Sat, 18 May 2024 18:03:02 +1200 Subject: [PATCH] feat: allow resolving queued suggestions with notes BT-22 --- suggestions/cogs/suggestion_cog.py | 77 +----------- suggestions/core/__init__.py | 1 + suggestions/core/suggestion_resolution.py | 139 ++++++++++++++++++++++ suggestions/objects/queued_suggestion.py | 74 +++++++++++- suggestions/objects/suggestion.py | 2 +- suggestions/state.py | 24 +++- 6 files changed, 242 insertions(+), 75 deletions(-) create mode 100644 suggestions/core/suggestion_resolution.py diff --git a/suggestions/cogs/suggestion_cog.py b/suggestions/cogs/suggestion_cog.py index 222d5e5..3ef2981 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, @@ -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( @@ -371,41 +372,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 +402,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/core/__init__.py b/suggestions/core/__init__.py index 70da987..50ddb1f 100644 --- a/suggestions/core/__init__.py +++ b/suggestions/core/__init__.py @@ -1,3 +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/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/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 5e786a2..d7ed7f3 100644 --- a/suggestions/objects/suggestion.py +++ b/suggestions/objects/suggestion.py @@ -434,7 +434,7 @@ async def as_embed(self, bot: SuggestionsBot) -> Embed: if self.note: note_desc = f"\n\n**Moderator note**\n{self.note}" - # TODO Resolve BT-44 and add this back + # TODO Resolve BT-44 and add moderator back embed.description += note_desc if self.uses_views_for_votes: 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")),