diff --git a/.gitignore b/.gitignore index 11e51f5..a205c5b 100644 --- a/.gitignore +++ b/.gitignore @@ -61,7 +61,6 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ -.tox/ .nox/ .coverage .coverage.* @@ -128,7 +127,6 @@ celerybeat.pid *.sage.py # Environments -.env .venv env/ venv/ diff --git a/main.py b/main.py index b98fe15..6eac06b 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,6 @@ import asyncio import logging import os -import tracemalloc import alaric from alaric import Cursor diff --git a/requirements.txt b/requirements.txt index 617a641..f293f7f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -50,7 +50,7 @@ tomli==2.0.1 typing_extensions==4.3.0 websockets==10.4 yarl==1.7.2 -zonis==1.2.5 +zonis==2.0.0 types-aiobotocore==2.11.2 aiobotocore==2.11.2 logoo==1.3.0 \ No newline at end of file diff --git a/suggestions/bot.py b/suggestions/bot.py index 6590be3..44177a0 100644 --- a/suggestions/bot.py +++ b/suggestions/bot.py @@ -10,7 +10,7 @@ import traceback from pathlib import Path from string import Template -from typing import Type, Optional +from typing import Type, Optional, Union import aiohttp import alaric @@ -54,7 +54,7 @@ class SuggestionsBot(commands.AutoShardedInteractionBot, BotBase): def __init__(self, *args, **kwargs): - self.version: str = "Public Release 3.22" + self.version: str = "Public Release 3.23" self.main_guild_id: int = 601219766258106399 self.legacy_beta_role_id: int = 995588041991274547 self.automated_beta_role_id: int = 998173237282361425 @@ -987,3 +987,21 @@ async def inner(): task_1 = asyncio.create_task(inner()) self.state.add_background_task(task_1) log.info("Setup status notifications") + + async def delete_message(self, *, message_id: int, channel_id: int): + await self._connection.http.delete_message(channel_id, message_id) + + async def try_fetch_icon_url(self, guild_id: int) -> Union[None, str]: + """Given an id and state, return either the guilds icon or None.""" + if guild_id not in self.state.guild_cache: + guild = await self.state.bot.fetch_guild(guild_id) + self.state.refresh_guild_cache(guild) + else: + guild = self.state.guild_cache.get_entry(guild_id) + + if not guild.icon: + # Update cache if we don't have it + guild = await self.state.bot.fetch_guild(guild_id) + self.state.refresh_guild_cache(guild) + + return None if not guild.icon else guild.icon.url diff --git a/suggestions/clunk2/edits.py b/suggestions/clunk2/edits.py index e172b69..d9f5852 100644 --- a/suggestions/clunk2/edits.py +++ b/suggestions/clunk2/edits.py @@ -3,6 +3,7 @@ import asyncio from typing import TYPE_CHECKING +import commons import disnake from logoo import Logger @@ -60,13 +61,14 @@ async def update_suggestion_message( channel_id=up_to_date_suggestion.channel_id, message_id=up_to_date_suggestion.message_id, ).edit(embed=await up_to_date_suggestion.as_embed(bot)) - except (disnake.HTTPException, disnake.NotFound): + except (disnake.HTTPException, disnake.NotFound) as e: logger.error( "Failed to update suggestion %s", suggestion.suggestion_id, extra_metadata={ "guild_id": suggestion.guild_id, "suggestion_id": suggestion.suggestion_id, + "traceback": commons.exception_as_string(e), }, ) diff --git a/suggestions/cogs/guild_config_cog.py b/suggestions/cogs/guild_config_cog.py index 4bc122f..5a137db 100644 --- a/suggestions/cogs/guild_config_cog.py +++ b/suggestions/cogs/guild_config_cog.py @@ -4,7 +4,6 @@ import cooldowns import disnake -from disnake import Guild from disnake.ext import commands from logoo import Logger @@ -118,6 +117,7 @@ async def queue_channel( interaction.guild_id, self.state ) try: + # BT-21 doesn't apply here as we are sending not fetching message = await channel.send("This is a test message and can be ignored.") await message.delete() except disnake.Forbidden: @@ -222,7 +222,7 @@ async def get( guild_config: GuildConfig = await GuildConfig.from_id( interaction.guild_id, self.state ) - icon_url = await Guild.try_fetch_icon_url(interaction.guild_id, self.state) + icon_url = await self.bot.try_fetch_icon_url(interaction.guild_id) guild = self.state.guild_cache.get_entry(interaction.guild_id) embed: disnake.Embed = disnake.Embed( description=self.bot.get_locale( @@ -552,7 +552,7 @@ async def send_full_config(self, interaction: disnake.GuildCommandInteraction): locale_string, interaction ) - icon_url = await Guild.try_fetch_icon_url(interaction.guild_id, self.state) + icon_url = await self.bot.try_fetch_icon_url(interaction.guild_id) guild = self.state.guild_cache.get_entry(interaction.guild_id) embed: disnake.Embed = disnake.Embed( description=f"Configuration for {guild.name}\n\nSuggestions channel: {suggestions_channel}\n" diff --git a/suggestions/cogs/suggestion_cog.py b/suggestions/cogs/suggestion_cog.py index 98ab47c..92c2acf 100644 --- a/suggestions/cogs/suggestion_cog.py +++ b/suggestions/cogs/suggestion_cog.py @@ -5,8 +5,7 @@ import cooldowns import disnake from commons.caching import NonExistentEntry -from bot_base.wraps import WrappedChannel -from disnake import Guild, ButtonStyle +from disnake import ButtonStyle from disnake.ext import commands, components from logoo import Logger @@ -21,8 +20,7 @@ MissingQueueLogsChannel, ) from suggestions.interaction_handler import InteractionHandler -from suggestions.objects import Suggestion, GuildConfig, UserConfig, QueuedSuggestion - +from suggestions.objects import Suggestion, GuildConfig, QueuedSuggestion from suggestions.objects.suggestion import SuggestionState from suggestions.utility import r2 @@ -84,7 +82,10 @@ async def suggestion_up_vote( ) logger.debug( f"Member {member_id} modified their vote on {suggestion_id} to a up vote", - extra_metadata={"suggestion_id": suggestion_id}, + extra_metadata={ + "suggestion_id": suggestion_id, + "guild_id": inter.guild_id, + }, ) else: suggestion.up_voted_by.add(member_id) @@ -98,7 +99,10 @@ async def suggestion_up_vote( ) logger.debug( f"Member {member_id} up voted {suggestion_id}", - extra_metadata={"suggestion_id": suggestion_id}, + extra_metadata={ + "suggestion_id": suggestion_id, + "guild_id": inter.guild_id, + }, ) await update_suggestion_message(suggestion=suggestion, bot=self.bot) @@ -146,7 +150,10 @@ async def suggestion_down_vote( ) logger.debug( f"Member {member_id} modified their vote on {suggestion_id} to a down vote", - extra_metadata={"suggestion_id": suggestion_id}, + extra_metadata={ + "suggestion_id": suggestion_id, + "guild_id": inter.guild_id, + }, ) else: suggestion.down_voted_by.add(member_id) @@ -160,7 +167,10 @@ async def suggestion_down_vote( ) logger.debug( f"Member {member_id} down voted {suggestion_id}", - extra_metadata={"suggestion_id": suggestion_id}, + extra_metadata={ + "suggestion_id": suggestion_id, + "guild_id": inter.guild_id, + }, ) await update_suggestion_message(suggestion=suggestion, bot=self.bot) @@ -214,6 +224,8 @@ async def suggest( await interaction.response.defer(ephemeral=True) + suggestion: str = suggestion.replace("\\n", "\n") + guild_config: GuildConfig = await GuildConfig.from_id( interaction.guild_id, self.state ) @@ -302,7 +314,7 @@ async def suggest( ), ) - icon_url = await Guild.try_fetch_icon_url(interaction.guild_id, self.state) + icon_url = await self.bot.try_fetch_icon_url(interaction.guild_id) guild = self.state.guild_cache.get_entry(interaction.guild_id) suggestion: Suggestion = await Suggestion.new( suggestion=suggestion, @@ -314,9 +326,7 @@ async def suggest( ) await suggestion.setup_initial_messages( guild_config=guild_config, - interaction=interaction, - state=self.state, - bot=self.bot, + ih=await InteractionHandler.new_handler(interaction), cog=self, guild=guild, icon_url=icon_url, @@ -490,17 +500,9 @@ async def clear( suggestion_id, interaction.guild_id, self.state ) if suggestion.channel_id and suggestion.message_id: - try: - channel: WrappedChannel = await self.bot.get_or_fetch_channel( - suggestion.channel_id - ) - message: disnake.Message = await channel.fetch_message( - suggestion.message_id - ) - except disnake.HTTPException: - pass - else: - await message.delete() + await self.bot.delete_message( + message_id=suggestion.message_id, channel_id=suggestion.channel_id + ) await suggestion.mark_cleared_by(self.state, interaction.user.id, response) await interaction.send( diff --git a/suggestions/cogs/suggestion_queue_cog.py b/suggestions/cogs/suggestion_queue_cog.py index 5bbe3ab..1f38b44 100644 --- a/suggestions/cogs/suggestion_queue_cog.py +++ b/suggestions/cogs/suggestion_queue_cog.py @@ -49,18 +49,6 @@ async def virtual_reject_button( await InteractionHandler.new_handler(inter), pid ) - @components.button_listener() - async def accept_queued_suggestion(self, inter: disnake.MessageInteraction): - if inter.message is None: - raise ValueError("Unhandled exception, expected a message") - - await self.core.accept_queued_suggestion( - await InteractionHandler.new_handler(inter), - inter.message.id, - inter.message.channel.id, - self.accept_queued_suggestion, - ) - @commands.slash_command( dm_permission=False, default_member_permissions=disnake.Permissions(manage_guild=True), diff --git a/suggestions/core/suggestions_queue.py b/suggestions/core/suggestions_queue.py index 0bbab2a..ed526e5 100644 --- a/suggestions/core/suggestions_queue.py +++ b/suggestions/core/suggestions_queue.py @@ -12,7 +12,6 @@ from alaric.meta import Negate from alaric.projections import Projection, SHOW from commons.caching import NonExistentEntry, TimedCache -from disnake import Guild from suggestions.exceptions import ErrorHandled, MissingQueueLogsChannel from suggestions.interaction_handler import InteractionHandler @@ -129,13 +128,11 @@ async def resolve_queued_suggestion( suggestion = await queued_suggestion.convert_to_suggestion( self.bot.state ) - icon_url = await Guild.try_fetch_icon_url(guild_id, self.state) + icon_url = await self.bot.try_fetch_icon_url(guild_id) guild = await self.state.fetch_guild(guild_id) await suggestion.setup_initial_messages( guild_config=guild_config, - interaction=ih.interaction, - state=self.state, - bot=self.bot, + ih=ih, cog=self.bot.get_cog("SuggestionsCog"), guild=guild, icon_url=icon_url, @@ -162,7 +159,7 @@ async def resolve_queued_suggestion( user_config: UserConfig = await UserConfig.from_id( queued_suggestion.suggestion_author_id, self.bot.state ) - icon_url = await Guild.try_fetch_icon_url(guild_id, self.state) + icon_url = await self.bot.try_fetch_icon_url(guild_id) guild = self.state.guild_cache.get_entry(guild_id) if ( user_config.dm_messages_disabled @@ -192,11 +189,10 @@ async def resolve_queued_suggestion( await self.bot.state.suggestions_db.delete(suggestion) if suggestion.message_id is not None: - channel: disnake.TextChannel = await self.state.fetch_channel( - suggestion.channel_id + await self.bot.delete_message( + message_id=suggestion.message_id, + channel_id=suggestion.channel_id, ) - message = await channel.fetch_message(suggestion.message_id) - await message.delete() # Re-raise for the bot handler raise @@ -236,7 +232,7 @@ async def info(self, ih: InteractionHandler): count: int = await self.queued_suggestions_db.count( AQ(AND(EQ("guild_id", guild_id), EQ("still_in_queue", True))) ) - icon_url = await Guild.try_fetch_icon_url(guild_id, self.state) + icon_url = await self.bot.try_fetch_icon_url(guild_id) guild = self.state.guild_cache.get_entry(guild_id) embed = disnake.Embed( title="Queue Info", @@ -320,51 +316,3 @@ async def view( ], ) self.paginator_objects.add_entry(pid, paginator) - - async def accept_queued_suggestion( - self, ih: InteractionHandler, message_id: int, channel_id: int, button - ): - current_suggestion: QueuedSuggestion = await QueuedSuggestion.from_message_id( - message_id, channel_id, self.state - ) - if current_suggestion.is_resolved: - return await ih.send(translation_key="QUEUE_INNER_ALREADY_RESOLVED") - - # By here we need to do something about resolving it - suggestion: None = None - guild_id = ih.interaction.guild_id - try: - await paginator.remove_current_page() - suggestion: Suggestion = await current_suggestion.resolve( - was_approved=True, - state=self.bot.state, - resolved_by=ih.interaction.author.id, - ) - guild_config: GuildConfig = await GuildConfig.from_id(guild_id, self.state) - icon_url = await Guild.try_fetch_icon_url(guild_id, self.state) - guild = self.state.guild_cache.get_entry(guild_id) - await suggestion.setup_initial_messages( - guild_config=guild_config, - interaction=ih.interaction, - state=self.state, - bot=self.bot, - cog=self.bot.get_cog("SuggestionsCog"), - guild=guild, - icon_url=icon_url, - comes_from_queue=True, - ) - except: - # Throw it back in the queue on error - current_suggestion.resolved_by = None - current_suggestion.resolved_at = None - current_suggestion.still_in_queue = True - await self.bot.state.queued_suggestions_db.update( - current_suggestion, current_suggestion - ) - - if suggestion is not None: - await self.bot.state.suggestions_db.delete(suggestion) - - raise - - await ih.send(translation_key="PAGINATION_INNER_QUEUE_ACCEPTED") diff --git a/suggestions/locales/en_GB.json b/suggestions/locales/en_GB.json index ad9470c..8945a16 100644 --- a/suggestions/locales/en_GB.json +++ b/suggestions/locales/en_GB.json @@ -57,6 +57,7 @@ "SUGGEST_INNER_SENT_TO_QUEUE": "Your suggestion has been sent to the queue for processing.", "SUGGEST_INNER_NO_ANONYMOUS_SUGGESTIONS": "Your guild does not allow anonymous suggestions.", "SUGGEST_INNER_NO_IMAGES_IN_SUGGESTIONS": "Your guild does not allow images in suggestions.", + "SUGGEST_INNER_PING_AUTHOR_IN_THREAD": "Hey <@$AUTHOR_ID>, I have created this thread for you to discuss your suggestion in.", "CONFIG_ANONYMOUS_ENABLE_INNER_SUCCESS": "I have enabled anonymous suggestions for this guild.", "CONFIG_ANONYMOUS_DISABLE_INNER_SUCCESS": "I have disabled anonymous suggestions for this guild.", "SUGGESTION_OBJECT_LOCK_THREAD": "Locking this thread as the suggestion has reached a resolution.", diff --git a/suggestions/main.py b/suggestions/main.py index 0f8c30c..f42dbbe 100644 --- a/suggestions/main.py +++ b/suggestions/main.py @@ -307,7 +307,7 @@ async def shutdown( await bot.graceful_shutdown() - async def graceful_shutdown(bot: SuggestionsBot, signame): + async def graceful_shutdown(bot: SuggestionsBot, _): await bot.graceful_shutdown() # https://github.com/gearbot/GearBot/blob/live/GearBot/GearBot.py diff --git a/suggestions/objects/suggestion.py b/suggestions/objects/suggestion.py index 5b75382..bdc2f84 100644 --- a/suggestions/objects/suggestion.py +++ b/suggestions/objects/suggestion.py @@ -9,12 +9,13 @@ from alaric.comparison import EQ from alaric.logical import AND from bot_base.wraps import WrappedChannel -from disnake import Embed, Guild +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.interaction_handler import InteractionHandler from suggestions.low_level import MessageEditing from suggestions.objects import UserConfig, GuildConfig @@ -457,7 +458,7 @@ async def _as_resolved_embed( else: embed.set_footer(text=f"sID: {self.suggestion_id}") - icon_url = await Guild.try_fetch_icon_url(self.guild_id, bot.state) + icon_url = await bot.try_fetch_icon_url(self.guild_id) guild = bot.state.guild_cache.get_entry(self.guild_id) embed.set_author(name=guild.name, icon_url=icon_url) @@ -485,7 +486,6 @@ async def mark_approved_by( state.remove_sid_from_cache(self.guild_id, self.suggestion_id) await state.suggestions_db.update(self, self) - await self.try_notify_user_of_decision(state.bot) async def mark_rejected_by( self, @@ -502,7 +502,6 @@ async def mark_rejected_by( state.remove_sid_from_cache(self.guild_id, self.suggestion_id) await state.suggestions_db.update(self, self) - await self.try_notify_user_of_decision(state.bot) async def mark_cleared_by( self, @@ -541,6 +540,11 @@ async def try_delete( ------- bool Whether or not deleting succeeded + + Notes + ----- + BT-21 doesn't apply to this as we also want to check + if the message itself has already been deleted or not via fetch """ try: channel: WrappedChannel = await bot.get_or_fetch_channel(self.channel_id) @@ -648,7 +652,7 @@ async def try_notify_user_of_decision(self, bot: SuggestionsBot): return user = await bot.get_or_fetch_user(self.suggestion_author_id) - icon_url = await Guild.try_fetch_icon_url(self.guild_id, bot.state) + icon_url = await bot.try_fetch_icon_url(self.guild_id) guild = bot.state.guild_cache.get_entry(self.guild_id) text = "approved" if self.state == SuggestionState.approved else "rejected" resolved_by_text = ( @@ -684,14 +688,27 @@ async def try_notify_user_of_decision(self, bot: SuggestionsBot): }, ) - async def create_thread(self, message: disnake.Message): + async def create_thread(self, message: disnake.Message, *, ih: InteractionHandler): """Create a thread for this suggestion""" if self.state != SuggestionState.pending: raise ValueError( "Cannot create a thread for suggestions which aren't pending." ) - await message.create_thread(name=f"Thread for suggestion {self.suggestion_id}") + thread = await message.create_thread( + name=f"Thread for suggestion {self.suggestion_id}" + ) + try: + await thread.send( + ih.bot.get_localized_string( + "SUGGEST_INNER_PING_AUTHOR_IN_THREAD", + ih, + extras={"AUTHOR_ID": self.suggestion_author_id}, + ) + ) + except: + # I'd consider it fine if the bot can't send this message + pass async def update_vote_count( self, @@ -769,7 +786,6 @@ async def edit_message_after_finalization( else: # Move the suggestion to the logs channel await self.save_reaction_results(bot, interaction) - await self.try_delete(bot, interaction) channel: WrappedChannel = await bot.get_or_fetch_channel( guild_config.log_channel_id ) @@ -783,6 +799,11 @@ async def edit_message_after_finalization( "Missing permissions to send in configured log channel" ] ) + + # Only delete the original suggestion if we actually + # managed to send the log message to the new channel + await self.try_delete(bot, interaction) + self.message_id = message.id self.channel_id = channel.id await state.suggestions_db.upsert(self, self) @@ -909,20 +930,22 @@ async def resolve( interaction=interaction, guild_config=guild_config, ) + await self.try_notify_user_of_decision(state.bot) async def setup_initial_messages( self, *, guild_config: GuildConfig, - bot: SuggestionsBot, cog, - state: State, - interaction: disnake.GuildCommandInteraction | disnake.MessageInteraction, guild: disnake.Guild, icon_url, + ih: InteractionHandler, comes_from_queue=False, ): """Encapsulates creation logic to save code re-use""" + interaction = ih.interaction + bot = ih.bot + state = ih.bot.state try: channel: WrappedChannel = await bot.get_or_fetch_channel( guild_config.suggestions_channel_id @@ -964,7 +987,7 @@ async def setup_initial_messages( if guild_config.threads_for_suggestions: try: - await self.create_thread(message) + await self.create_thread(message, ih=ih) except disnake.HTTPException: logger.debug( "Failed to create a thread on suggestion %s", diff --git a/suggestions/utility/r2.py b/suggestions/utility/r2.py index f48c775..5f61b27 100644 --- a/suggestions/utility/r2.py +++ b/suggestions/utility/r2.py @@ -29,15 +29,15 @@ async def upload_file_to_r2( ) as client: mimetype_guessed, _ = mimetypes.guess_type(file_name) accepted_mimetypes: dict[str, set[str]] = { - "image/jpeg": {".jpeg", ".jpg"}, - "image/png": {".png"}, - "image/gif": {".gif"}, - "video/mp3": {".mp3"}, - "video/mp4": {".mp4"}, - "video/mpeg": {".mpeg"}, - "video/webm": {".webm"}, - "image/webp": {".webp"}, - "audio/webp": {".weba"}, + "image/jpeg": {"jpeg", "jpg"}, + "image/png": {"png"}, + "image/gif": {"gif"}, + "video/mp3": {"mp3"}, + "video/mp4": {"mp4"}, + "video/mpeg": {"mpeg"}, + "video/webm": {"webm"}, + "image/webp": {"webp"}, + "audio/webp": {"weba"}, } file_names = accepted_mimetypes.get(mimetype_guessed) if file_names is None: diff --git a/suggestions/zonis_routes.py b/suggestions/zonis_routes.py index 4ca947b..bc48dcb 100644 --- a/suggestions/zonis_routes.py +++ b/suggestions/zonis_routes.py @@ -6,9 +6,8 @@ from typing import TYPE_CHECKING, Literal import disnake -from zonis import client +import zonis -from suggestions.scheduler import exception_aware_scheduler if TYPE_CHECKING: from suggestions import SuggestionsBot @@ -19,7 +18,7 @@ class ZonisRoutes: def __init__(self, bot: SuggestionsBot): self.bot: SuggestionsBot = bot - self.client: client.Client = client.Client( + self.client: zonis.Client = zonis.Client( url=bot.garven.ws_url, identifier=str(bot.cluster_id), secret_key=os.environ["ZONIS_SECRET_KEY"], @@ -37,25 +36,20 @@ def __init__(self, bot: SuggestionsBot): ) async def start(self): - self.client.load_routes() - await exception_aware_scheduler( - self.client._connect, - retry_count=100, # Just over 4 hours of retries - sleep_between_tries=150, # 2.5 minutes between each - ) + await self.client.start() - @client.route() + @zonis.route() async def guild_count(self): return len(self.bot.guild_ids) - @client.route() + @zonis.route() async def cluster_status(self): data = {"shards": {}} for shard_id, shard_info in self.bot.shards.items(): data["shards"][shard_id] = { - "latency": shard_info.latency - if not math.isnan(shard_info.latency) - else None, + "latency": ( + shard_info.latency if not math.isnan(shard_info.latency) else None + ), "is_currently_up": not shard_info.is_closed(), } @@ -66,7 +60,7 @@ async def cluster_status(self): return data - @client.route() + @zonis.route() async def cluster_ws_status( self, ) -> dict[str, dict[Literal["ws", "keepalive"], str]]: @@ -85,7 +79,7 @@ async def cluster_ws_status( return data - @client.route() + @zonis.route() async def share_with_devs(self, title, description, sender): channel: disnake.TextChannel = await self.bot.get_or_fetch_channel( # type: ignore 602332642456764426 @@ -96,7 +90,7 @@ async def share_with_devs(self, title, description, sender): embed.set_footer(text=f"Sender: {sender}") await channel.send(embed=embed) - @client.route() + @zonis.route() async def cached_item_count(self) -> dict[str, int]: state = self.bot.state stats = self.bot.stats @@ -110,7 +104,7 @@ async def cached_item_count(self) -> dict[str, int]: "stats.cluster_guild_cache": len(stats.cluster_guild_cache), "stats.member_stats_cache": len(stats.member_stats_cache), "suggestions_queue_cog.paginator_objects": len( - suggestions_queue_cog.paginator_objects # noqa + suggestions_queue_cog.core.paginator_objects # noqa ), } diff --git a/tests/test_interaction_handler.py b/tests/test_interaction_handler.py index e24a515..d87d8f4 100644 --- a/tests/test_interaction_handler.py +++ b/tests/test_interaction_handler.py @@ -1,7 +1,6 @@ from unittest.mock import AsyncMock, call import pytest -from commons.caching import NonExistentEntry from suggestions import SuggestionsBot from suggestions.exceptions import ConflictingHandlerInformation