diff --git a/library_modifications.md b/library_modifications.md deleted file mode 100644 index 823b548..0000000 --- a/library_modifications.md +++ /dev/null @@ -1,71 +0,0 @@ -We use a custom fork of `Disnake` with some added features + fixes as detailed here. - -This mainly exists such that if we wish to upgrade from say, `2.5.x` -> `2.6.x` we know exactly what things require porting. - ---- - -## Features - -- `Interaction.deferred_without_send` -> `bool` - - Hooks into the `Interaction` object to see if a given interaction has been differed without a follow-up send occurring. We do this as we need to clean up after the bot in the case an error occurs otherwise our users will simply get "Interaction failed to respond" which is not ideal - - Implementation details: - - New variable on `Interaction` -> `self.has_been_followed_up: bool = False` - - Set to `True` in `Interaction.send` after message is sent - - New variable on `InteractionResponse` -> `self.has_been_deferred: bool = False` - - Set to `True` in `InteractionResponse.defer` - - New property - - https://paste.disnake.dev/?id=1660196042475314 -- Show gateway session on initial startup - - Logs session related information to console with log level `WARNING` to avoid being suppressed or needing special handling to show. (Anything lower is suppressed bot side.) - - Implementation details: - - After `data: gateway.GatewayBot = await self.request(Route("GET", "/gateway/bot"))` in `get_bot_gateway` - - https://paste.disnake.dev/?id=1660196180710978 -- `disnake.Guild.icon.url` erroring when `icon` is `None` - - Due to the lack of guild intents, using `disnake.Guild.icon.url` requires guarding to ensure we can actually use the icon. A similar port will also likely be applied to `disnake.User` sooner or later. - - Fixed by adding a new method `disnake.Guild.try_fetch_icon_url` - - *I consider this one a feature due to the nature of the fix.* - - Related issues: https://github.com/suggestionsbot/suggestions-bot-rewrite/issues/3 - - https://workbin.dev/?id=1676113402913198 -- Added `__eq__` to `disnake.Embed` - - This is used primarily within tests - - This includes `__eq__` on `EmbedProxy` otherwise they compare as False - - Note: This now returns `None` on disnake `2.8.0` and older -- Heavily modified guild caching in relation to - - Introduced the variable `guild_ids` - - Disabled the guild cache even with enabled guild intents - - Change `state._add_guild_from_data` to not create and cache a guild object - - See commit - -## Fixes - -- Process crashes due to `Intents.none()` with relation to interactions - - `discord.py`, and by extension `disnake` are built around the idea of at-least having the guilds intent. In this case, a lack of guild objects results in a reproducible process crash for the bot handling the interaction. - - Implementation details: - - Prematurely return from `disnake.abc._fill_overwrites` before it hits `everyone_id = self.guild.id` - - https://paste.disnake.dev/?id=1660197346583275 - - *Note, the fix detailed here and the fix applied to `disnake` itself are different. We don't care for overwrites at this time, and we have not rebased against upstream since the relevant patches were applied.* -- `AttributeError`'s on `discord.Object` objects due to `Intents.none()` - - On suggestions with un-cached guilds, if they have a thread it can result in an error as the existence check for `self.guild` will be truthy even with a `disnake.Object` instance, despite requiring `disnake.Guild` as subsequent usage after existence checks call `disnake.Guild.get_thread` - - Implementation details: - - Modify `disnake.Message.thread` to the following - - https://paste.disnake.dev/?id=1660197950019675 - - Related issues: https://github.com/DisnakeDev/disnake/issues/699 -- Removed the `MessageContentPrefixWarning` as it would trip due to inheritance of the bot base -- `AttributeError`'s on `discord.Object` where `discord.Guild` was expected. - - This is the same as "`AttributeError`'s on `discord.Object` objects due to `Intents.none()`" - - Implementation details - - Fixed by https://paste.disnake.dev/?id=1660730340161476 - - Related issue: https://github.com/DisnakeDev/disnake/issues/712 -- Move thread caching onto `Message` - - Due to our intents, threads on messages were not being cached correctly which meant features didnt work - - Fix: Move caching of message.thread onto the message itself - - -## Notes - -On startup even with `Intents.none()` we still receive partial guilds, noted as unavailable (or something like that). This allows for partial cache hits and explains the inconsistencies of bug reproduction as we require a guild who invited the bot during runtime as the first reproduction step. - -Versions are bumped to overcome some CI caching issues. - -We don't follow or publish to upstream, a shame I know. Security issues however (such as process crashing) are noted exceptions and dealt with on a case by case basis. - diff --git a/main.py b/main.py index b961573..f4d0a43 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ from dotenv import load_dotenv from logoo import PrimaryLogger + import suggestions load_dotenv() @@ -33,7 +34,7 @@ httpx_logger.setLevel(logging.WARNING) logoo_logger = logging.getLogger("logoo") -logoo_logger.setLevel(logging.DEBUG) +logoo_logger.setLevel(logging.INFO) suggestions_logger = logging.getLogger("suggestions") suggestions_logger.setLevel(logging.DEBUG) diff --git a/requirements.txt b/requirements.txt index 3cf163d..2e61dab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,14 +4,13 @@ alaric==1.2.0 anyio==4.2.0 async-timeout==4.0.2 attrs==21.4.0 -Bot-Base==1.7.1 Brotli==1.0.9 causar==0.2.0 cchardet==2.1.7 certifi==2023.11.17 cffi==1.15.0 charset-normalizer==2.0.12 -disnake @ git+https://github.com/suggestionsbot/disnake.git@22a572afd139144c0e2de49c7692a73ab74d8a3d +disnake==2.9.2 disnake-ext-components @ git+https://github.com/suggestionsbot/disnake-ext-components.git@91689ed74ffee73f631453a39e548af9b824826d dnspython==2.2.1 exceptiongroup==1.2.0 @@ -43,7 +42,7 @@ pytest==7.1.3 pytest-asyncio==0.19.0 python-dotenv==0.20.0 sentinels==1.0.0 -skelmis-commons==1.1.0 +skelmis-commons==1.2.1 sniffio==1.3.0 tomli==2.0.1 typing_extensions==4.3.0 diff --git a/suggestions/bot.py b/suggestions/bot.py index 0a3f2fa..a43c6ec 100644 --- a/suggestions/bot.py +++ b/suggestions/bot.py @@ -10,18 +10,19 @@ import traceback from pathlib import Path from string import Template -from typing import Type, Optional, Union +from typing import Type, Optional, Union, Any import aiohttp import alaric import commons import disnake +import humanize from alaric import Cursor -from bot_base.wraps import WrappedChannel from cooldowns import CallableOnCooldown -from disnake import Locale, LocalizationKeyError +from disnake import Locale, LocalizationKeyError, Thread +from disnake.abc import PrivateChannel, GuildChannel from disnake.ext import commands -from bot_base import BotBase, BotContext, PrefixNotFound +from disnake.state import AutoShardedConnectionState from logoo import Logger from suggestions import State, Colors, Emojis, ErrorCode, Garven @@ -45,6 +46,7 @@ ) from suggestions.http_error_parser import try_parse_http_error from suggestions.interaction_handler import InteractionHandler +from suggestions.low_level import PatchedConnectionState from suggestions.objects import Error, GuildConfig, UserConfig from suggestions.stats import Stats, StatsEnum from suggestions.database import SuggestionsMongoManager @@ -54,14 +56,17 @@ logger = Logger(__name__) -class SuggestionsBot(commands.AutoShardedInteractionBot, BotBase): +class SuggestionsBot(commands.AutoShardedInteractionBot): def __init__(self, *args, **kwargs): - self.version: str = "Public Release 3.24" + self.version: str = "Public Release 3.25" self.main_guild_id: int = 601219766258106399 self.legacy_beta_role_id: int = 995588041991274547 self.automated_beta_role_id: int = 998173237282361425 self.beta_channel_id: int = 995622792294830080 self.base_website_url: str = "https://suggestions.gg" + self._uptime: datetime.datetime = datetime.datetime.now( + tz=datetime.timezone.utc + ) self.is_prod: bool = True if os.environ.get("PROD", None) else False @@ -107,8 +112,6 @@ def __init__(self, *args, **kwargs): super().__init__( *args, **kwargs, - leave_db=True, - do_command_stats=False, activity=disnake.Activity( name="suggestions", type=disnake.ActivityType.watching, @@ -121,6 +124,19 @@ def __init__(self, *args, **kwargs): self.zonis: ZonisRoutes = ZonisRoutes(self) + # This exists on the basis we have patched state + self.guild_ids: set[int] = self._connection.guild_ids + + def _get_state(self, **options: Any) -> AutoShardedConnectionState: + return PatchedConnectionState( + **options, + dispatch=self.dispatch, + handlers=self._handlers, + hooks=self._hooks, + http=self.http, + loop=self.loop, + ) + async def launch_shard( self, _gateway: str, shard_id: int, *, initial: bool = False ) -> None: @@ -134,9 +150,15 @@ async def before_identify_hook( # gateway-proxy return - async def get_or_fetch_channel(self, channel_id: int) -> WrappedChannel: + async def get_or_fetch_channel( + self, channel_id: int + ) -> Union[GuildChannel, PrivateChannel, Thread]: try: - return await super().get_or_fetch_channel(channel_id) + channel = self.get_channel(channel_id) + if channel is None: + channel = await self.fetch_channel(channel_id) + + return channel except disnake.NotFound as e: raise ConfiguredChannelNoLongerExists from e @@ -150,6 +172,17 @@ async def dispatch_initial_ready(self): log.info("Startup took: %s", self.get_uptime()) await self.suggestion_emojis.populate_emojis() + @property + def uptime(self) -> datetime.datetime: + """Returns when the bot was initialized.""" + return self._uptime + + def get_uptime(self) -> str: + """Returns a human readable string for the bots uptime.""" + return humanize.precisedelta( + self.uptime - datetime.datetime.now(tz=datetime.timezone.utc) + ) + async def on_resumed(self): if self.gc_lock.locked(): return @@ -199,53 +232,6 @@ def error_embed( return embed - async def process_commands(self, message: disnake.Message): - try: - prefix = await self.get_guild_prefix(message.guild.id) - prefix = self.get_case_insensitive_prefix(message.content, prefix) - except (AttributeError, PrefixNotFound): - prefix = self.get_case_insensitive_prefix( - message.content, self.DEFAULT_PREFIX - ) - - as_args: list[str] = message.content.split(" ") - command_to_invoke: str = as_args[0] - if not command_to_invoke.startswith(prefix): - # Not our prefix - return - - command_to_invoke = command_to_invoke[len(prefix) :] - - if command_to_invoke in self.old_prefixed_commands: - embed: disnake.Embed = disnake.Embed( - title="Maintenance mode", - description="Sadly this command is in maintenance mode.\n" - # "You can read more about how this affects you [here]()", - "You can read more about how this affects you by following our announcements channel.", - colour=disnake.Color.from_rgb(255, 148, 148), - ) - return await message.channel.send(embed=embed) - - elif command_to_invoke in self.converted_prefix_commands: - embed: disnake.Embed = disnake.Embed( - description="We are moving with the times, as such this command is now a slash command.\n" - "You can read more about how this affects you as well as ensuring you can " - # "use the bots commands [here]()", - "use the bots commands by following our announcements channel.", - colour=disnake.Color.magenta(), - ) - return await message.channel.send(embed=embed) - - ctx = await self.get_context(message, cls=BotContext) - if ctx.command: - log.debug( - "Attempting to invoke command %s for User(id=%s)", - ctx.command.qualified_name, - ctx.author.id, - ) - - await self.invoke(ctx) - async def _push_slash_error_stats( self, interaction: disnake.ApplicationCommandInteraction | disnake.MessageInteraction, @@ -307,6 +293,15 @@ async def on_slash_command_error( return if isinstance(exception, UnhandledError): + logger.critical( + "An unhandled exception occurred", + extra_metadata={ + "error_id": error.id, + "author_id": error.user_id, + "guild_id": error.guild_id, + "traceback": commons.exception_as_string(exception), + }, + ) return await interaction.send( embed=self.error_embed( "Something went wrong", @@ -464,11 +459,15 @@ async def on_slash_command_error( return await interaction.send( embed=self.error_embed( "Command failed", - "Your suggestion content was too long, please limit it to 1000 characters or less.", + "Your suggestion content was too long, please limit it to 1000 characters or less.\n\n" + "I have attached a file containing your suggestion content to save rewriting it entirely.", error_code=ErrorCode.SUGGESTION_CONTENT_TOO_LONG, error=error, ), ephemeral=True, + file=disnake.File( + io.StringIO(exception.suggestion_text), filename="suggestion.txt" + ), ) elif isinstance(exception, InvalidGuildConfigOption): @@ -622,9 +621,7 @@ async def on_slash_command_error( ih: InteractionHandler = await InteractionHandler.fetch_handler( interaction.id, self ) - if interaction.deferred_without_send or ( - ih is not None and not ih.has_sent_something - ): + if ih is not None and not ih.has_sent_something: gid = interaction.guild_id if interaction.guild_id else None # Fix "Bot is thinking" hanging on edge cases... await interaction.send( @@ -717,14 +714,18 @@ async def process_watch_for_shutdown(): ) items = await cursor.execute() if not items: - await self.sleep_with_condition(15, lambda: self.state.is_closing) + await commons.sleep_with_condition( + 15, lambda: self.state.is_closing + ) continue entry = items[0] if not entry or ( entry and self.cluster_id in entry["responded_clusters"] ): - await self.sleep_with_condition(15, lambda: self.state.is_closing) + await commons.sleep_with_condition( + 15, lambda: self.state.is_closing + ) continue # We need to respond @@ -766,7 +767,7 @@ async def process_update_bot_listings(): try: total_guilds = await self.garven.get_total_guilds() except PartialResponse: - await self.sleep_with_condition( + await commons.sleep_with_condition( time_between_updates.total_seconds(), lambda: self.state.is_closing, ) @@ -787,7 +788,7 @@ async def process_update_bot_listings(): logger.warning("%s", r.text) log.debug("Updated bot listings") - await self.sleep_with_condition( + await commons.sleep_with_condition( time_between_updates.total_seconds(), lambda: self.state.is_closing, ) @@ -935,13 +936,17 @@ async def inner(): ): pass - await self.sleep_with_condition(60, lambda: self.state.is_closing) + await commons.sleep_with_condition( + 60, lambda: self.state.is_closing + ) except (aiohttp.ClientConnectorError, ConnectionRefusedError): log.warning("push_status failed to connect, retrying in 10 seconds") logger.warning( "push_status failed to connect, retrying in 10 seconds" ) - await self.sleep_with_condition(10, lambda: self.state.is_closing) + await commons.sleep_with_condition( + 10, lambda: self.state.is_closing + ) except Exception as e: if not self.is_prod: log.error("Borked it") diff --git a/suggestions/cogs/suggestion_cog.py b/suggestions/cogs/suggestion_cog.py index a891a2f..95199a9 100644 --- a/suggestions/cogs/suggestion_cog.py +++ b/suggestions/cogs/suggestion_cog.py @@ -223,7 +223,7 @@ async def suggest( anonymously: {{SUGGEST_ARG_ANONYMOUSLY}} """ if len(suggestion) > 1000: - raise SuggestionTooLong + raise SuggestionTooLong(suggestion) await interaction.response.defer(ephemeral=True) diff --git a/suggestions/cogs/suggestion_notes_cog.py b/suggestions/cogs/suggestion_notes_cog.py index bd5cd23..82a90ad 100644 --- a/suggestions/cogs/suggestion_notes_cog.py +++ b/suggestions/cogs/suggestion_notes_cog.py @@ -41,6 +41,7 @@ async def add( suggestion_id: str {{NOTES_ADD_ARG_SUGGESTION_ID}} note: str {{NOTES_ADD_ARG_NOTE}} """ + note: str = note.replace("\\n", "\n") await self.core.modify_note_on_suggestions( await InteractionHandler.new_handler(interaction), suggestion_id, note ) diff --git a/suggestions/cogs/view_voters_cog.py b/suggestions/cogs/view_voters_cog.py index 5f7571b..d8d1bda 100644 --- a/suggestions/cogs/view_voters_cog.py +++ b/suggestions/cogs/view_voters_cog.py @@ -6,13 +6,14 @@ import disnake from commons.caching import NonExistentEntry from disnake.ext import commands -from bot_base.paginators.disnake_paginator import DisnakePaginator from logoo import Logger from suggestions import Colors from suggestions.cooldown_bucket import InteractionBucket +from suggestions.interaction_handler import InteractionHandler from suggestions.objects import Suggestion from suggestions.objects.suggestion import SuggestionState +from suggestions.utility import DisnakePaginator if TYPE_CHECKING: from alaric import Document @@ -96,7 +97,11 @@ async def display_data( bot=self.bot, locale=interaction.locale, ) - await vp.start(interaction=interaction) + await vp.start( + await InteractionHandler.new_handler( + interaction, i_just_want_an_instance=True + ) + ) @commands.message_command(name="View voters") @cooldowns.cooldown(1, 3, bucket=InteractionBucket.author) diff --git a/suggestions/colors.py b/suggestions/colors.py index 7e8fa8f..29b6b0c 100644 --- a/suggestions/colors.py +++ b/suggestions/colors.py @@ -1,5 +1,7 @@ import disnake -from bot_base.paginators.disnake_paginator import DisnakePaginator + +from suggestions.interaction_handler import InteractionHandler +from suggestions.utility import DisnakePaginator class Colors: @@ -49,4 +51,8 @@ async def format_page(items, page_count): ) paginator.format_page = format_page - await paginator.start(interaction=interaction) + await paginator.start( + await InteractionHandler.new_handler( + interaction, i_just_want_an_instance=True + ) + ) diff --git a/suggestions/database.py b/suggestions/database.py index 8f9cd77..9dcdf85 100644 --- a/suggestions/database.py +++ b/suggestions/database.py @@ -1,5 +1,5 @@ from alaric import Document -from bot_base.db import MongoManager +from motor.motor_asyncio import AsyncIOMotorClient from suggestions.objects import ( Suggestion, @@ -11,9 +11,12 @@ from suggestions.objects.stats import MemberStats -class SuggestionsMongoManager(MongoManager): +class SuggestionsMongoManager: def __init__(self, connection_url): - super().__init__(connection_url=connection_url, database_name="suggestions_bot") + self.database_name = "suggestions_bot" + + self.__mongo = AsyncIOMotorClient(connection_url) + self.db = self.__mongo[self.database_name] self.suggestions: Document = Document( self.db, "suggestions", converter=Suggestion diff --git a/suggestions/exceptions.py b/suggestions/exceptions.py index 39b4a84..e3e78f2 100644 --- a/suggestions/exceptions.py +++ b/suggestions/exceptions.py @@ -38,6 +38,9 @@ class SuggestionNotFound(disnake.DiscordException): class SuggestionTooLong(disnake.DiscordException): """The suggestion content was too long.""" + def __init__(self, suggestion_text: str): + self.suggestion_text = suggestion_text + class InvalidGuildConfigOption(disnake.DiscordException): """The provided guild config choice doesn't exist.""" @@ -70,7 +73,7 @@ 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.""" diff --git a/suggestions/low_level/__init__.py b/suggestions/low_level/__init__.py index 311f9fd..8a21380 100644 --- a/suggestions/low_level/__init__.py +++ b/suggestions/low_level/__init__.py @@ -1 +1,2 @@ from .message_editing import MessageEditing +from .disnake_state import PatchedConnectionState diff --git a/suggestions/low_level/disnake_state.py b/suggestions/low_level/disnake_state.py new file mode 100644 index 0000000..638f847 --- /dev/null +++ b/suggestions/low_level/disnake_state.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import typing + +import disnake.state + +if typing.TYPE_CHECKING: + from disnake.types import gateway + + +class PatchedConnectionState(disnake.state.AutoShardedConnectionState): + """We patch some things into state in order to be able to + pip install disnake + without completely breaking expected functionality. + + Ideally this moves completely out of patches but for now alas. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.guild_ids: set[int] = set() + + def parse_guild_create(self, data: gateway.GuildCreateEvent) -> None: + self.guild_ids.add(int(data["id"])) + + def parse_guild_delete(self, data: gateway.GuildDeleteEvent) -> None: + self.guild_ids.discard(int(data["id"])) + + def parse_guild_update(self, data: gateway.GuildUpdateEvent) -> None: + return + + def parse_guild_role_create(self, data: gateway.GuildRoleCreateEvent) -> None: + return + + def parse_guild_role_delete(self, data: gateway.GuildRoleDeleteEvent) -> None: + return + # Removing this event parsing as we ripped out the guild cache + + def parse_guild_role_update(self, data: gateway.GuildRoleUpdateEvent) -> None: + return + # Removing this event parsing as we ripped out the guild cache + + def parse_guild_scheduled_event_create( + self, data: gateway.GuildScheduledEventCreateEvent + ) -> None: + return + # Removing this event parsing as we ripped out the guild cache + + def parse_guild_scheduled_event_update( + self, data: gateway.GuildScheduledEventUpdateEvent + ) -> None: + return + # Removing this event parsing as we ripped out the guild cache + + def parse_guild_scheduled_event_delete( + self, data: gateway.GuildScheduledEventDeleteEvent + ) -> None: + return + # Removing this event parsing as we ripped out the guild cache + + def parse_guild_scheduled_event_user_add( + self, data: gateway.GuildScheduledEventUserAddEvent + ) -> None: + return + # Removing this event parsing as we ripped out the guild cache + + def parse_guild_scheduled_event_user_remove( + self, data: gateway.GuildScheduledEventUserRemoveEvent + ) -> None: + return + # Removing this event parsing as we ripped out the guild cache + + def parse_guild_members_chunk(self, data: gateway.GuildMembersChunkEvent) -> None: + return + # Removing this event parsing as we ripped out the guild cache + + def _add_guild_from_data(self, data): + # Unsure if this is still needed + return None # noqa diff --git a/suggestions/low_level/message_editing.py b/suggestions/low_level/message_editing.py index 15cadfc..9697787 100644 --- a/suggestions/low_level/message_editing.py +++ b/suggestions/low_level/message_editing.py @@ -104,6 +104,7 @@ async def edit(self, content: Optional[str] = MISSING, **fields: Any) -> None: "allowed_mentions": MISSING, "view": MISSING, "components": MISSING, + "flags": MISSING, } data = {**data, **fields} diff --git a/suggestions/main.py b/suggestions/main.py index f42dbbe..5e71964 100644 --- a/suggestions/main.py +++ b/suggestions/main.py @@ -13,9 +13,10 @@ import disnake from disnake import Locale from disnake.ext import commands -from bot_base.paginators.disnake_paginator import DisnakePaginator from suggestions import SuggestionsBot +from suggestions.interaction_handler import InteractionHandler +from suggestions.utility import DisnakePaginator from suggestions.cooldown_bucket import InteractionBucket @@ -50,10 +51,6 @@ async def create_bot(database_wrapper=None) -> SuggestionsBot: bot = SuggestionsBot( intents=intents, - command_prefix="s.", - case_insensitive=True, - strip_after_prefix=True, - load_builtin_commands=True, chunk_guilds_at_startup=False, database_wrapper=database_wrapper, member_cache_flags=disnake.MemberCacheFlags.none(), @@ -248,7 +245,9 @@ async def format_page(code, page_number): try_ephemeral=True, ) paginator.format_page = format_page - await paginator.start(interaction=ctx) + await paginator.start( + await InteractionHandler.new_handler(ctx, i_just_want_an_instance=True) + ) @bot.slash_command( dm_permission=False, diff --git a/suggestions/objects/suggestion.py b/suggestions/objects/suggestion.py index d7ed7f3..bf98692 100644 --- a/suggestions/objects/suggestion.py +++ b/suggestions/objects/suggestion.py @@ -4,11 +4,11 @@ from enum import Enum from typing import TYPE_CHECKING, Literal, Union, Optional, cast +import commons import disnake from alaric import AQ 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 @@ -358,6 +358,11 @@ async def new( ) await state.suggestions_db.insert(suggestion) state.add_sid_to_cache(guild_id, suggestion_id) + + logger.debug( + "Created new suggestion", + extra_metadata={**suggestion.as_dict(), "suggestion_id": suggestion_id}, + ) return suggestion def as_filter(self) -> dict: @@ -389,6 +394,8 @@ def as_dict(self) -> dict: if self.message_id: data["message_id"] = self.message_id + + if self.channel_id: data["channel_id"] = self.channel_id if self.uses_views_for_votes: @@ -462,6 +469,7 @@ async def _as_resolved_embed( resolved_by_text = ( "Anonymous" if self.anonymous_resolution else f"<@{self.resolved_by}>" ) + embed = Embed( description=f"{results}\n\n**Suggestion**\n{self.suggestion}\n\n" f"**Submitter**\n{submitter}\n\n" @@ -489,6 +497,11 @@ async def _as_resolved_embed( if self.image_url: embed.set_image(self.image_url) + if self.note: + note_desc = f"**Moderator note**\n{self.note}" + # TODO Resolve BT-44 and add moderator back + embed.description += note_desc + return embed async def mark_approved_by( @@ -567,7 +580,7 @@ async def try_delete( if the message itself has already been deleted or not via fetch """ try: - channel: WrappedChannel = await bot.get_or_fetch_channel(self.channel_id) + channel = await bot.get_or_fetch_channel(self.channel_id) message: disnake.Message = await channel.fetch_message(self.message_id) except disnake.HTTPException: if silently: @@ -607,7 +620,7 @@ async def save_reaction_results( return None try: - channel: WrappedChannel = await bot.get_or_fetch_channel(self.channel_id) + channel = await bot.get_or_fetch_channel(self.channel_id) message: disnake.Message = await channel.fetch_message(self.message_id) except disnake.HTTPException: await interaction.send( @@ -789,7 +802,7 @@ async def edit_message_after_finalization( if guild_config.keep_logs: await self.save_reaction_results(bot, interaction) # In place suggestion edit - channel: WrappedChannel = await bot.get_or_fetch_channel(self.channel_id) + channel = await bot.get_or_fetch_channel(self.channel_id) message: disnake.Message = await channel.fetch_message(self.message_id) try: @@ -814,9 +827,7 @@ async def edit_message_after_finalization( else: # Move the suggestion to the logs channel await self.save_reaction_results(bot, interaction) - channel: WrappedChannel = await bot.get_or_fetch_channel( - guild_config.log_channel_id - ) + channel = await bot.get_or_fetch_channel(guild_config.log_channel_id) try: message: disnake.Message = await channel.send( embed=await self.as_embed(bot) @@ -852,7 +863,17 @@ async def archive_thread_if_required( ) return - channel: WrappedChannel = await bot.get_or_fetch_channel(self.channel_id) + if self.channel_id is None: + # I don't know why this is none tbh + logger.critical( + "Suggestion channel id was none", + extra_metadata={**self.as_dict(), "suggestion_id": self.suggestion_id}, + ) + + # Don't hard crash so we can hopefully keep going + return + + channel = await bot.get_or_fetch_channel(self.channel_id) message: disnake.Message = await channel.fetch_message(self.message_id) if not message.thread: # Suggestion has no created thread @@ -975,7 +996,7 @@ async def setup_initial_messages( bot = ih.bot state = ih.bot.state try: - channel: WrappedChannel = await bot.get_or_fetch_channel( + channel = await bot.get_or_fetch_channel( guild_config.suggestions_channel_id ) channel: disnake.TextChannel = cast(disnake.TextChannel, channel) @@ -1008,6 +1029,17 @@ async def setup_initial_messages( state.remove_sid_from_cache(interaction.guild_id, self.suggestion_id) await state.suggestions_db.delete(self.as_filter()) raise e + except Exception as e: + logger.critical( + "Error creating the initial message for a suggestion", + extra_metadata={ + "suggestion_id": self.suggestion_id, + "traceback": commons.exception_as_string(e), + }, + ) + state.remove_sid_from_cache(interaction.guild_id, self.suggestion_id) + await state.suggestions_db.delete(self.as_filter()) + raise e self.message_id = message.id self.channel_id = channel.id diff --git a/suggestions/stats.py b/suggestions/stats.py index f060116..fa7c195 100644 --- a/suggestions/stats.py +++ b/suggestions/stats.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional, Type import alaric +import commons from alaric import Cursor, AQ from alaric.comparison import EQ from commons.caching import TimedCache @@ -207,7 +208,7 @@ async def load(self): async def push_inter_stats(self): while not self.state.is_closing: - await self.bot.sleep_with_condition( + await commons.sleep_with_condition( datetime.timedelta(hours=1).total_seconds(), lambda: self.state.is_closing, ) @@ -243,7 +244,7 @@ async def push_stats(self): "Cluster %s now sees %s guilds", self.bot.cluster_id, current_count ) - await self.bot.sleep_with_condition(5 * 60, lambda: self.state.is_closing) + await commons.sleep_with_condition(5 * 60, lambda: self.state.is_closing) def increment_event_type(self, event_type: str): # We only want interactions for now diff --git a/suggestions/utility/__init__.py b/suggestions/utility/__init__.py index dc69cb9..4452e15 100644 --- a/suggestions/utility/__init__.py +++ b/suggestions/utility/__init__.py @@ -1 +1,2 @@ from .error_wrapper import wrap_with_error_handler +from .disnake_paginator import DisnakePaginator diff --git a/suggestions/utility/disnake_paginator.py b/suggestions/utility/disnake_paginator.py new file mode 100644 index 0000000..a567370 --- /dev/null +++ b/suggestions/utility/disnake_paginator.py @@ -0,0 +1,302 @@ +from typing import List, Union, TypeVar, Optional, Callable + +import disnake +from disnake.ext import commands + +from suggestions.interaction_handler import InteractionHandler + +# Inspired by https://github.com/nextcord/nextcord-ext-menus + +T = TypeVar("T") + + +class PaginationView(disnake.ui.View): + FIRST_PAGE = "\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\ufe0f" + PREVIOUS_PAGE = "\N{BLACK LEFT-POINTING TRIANGLE}\ufe0f" + NEXT_PAGE = "\N{BLACK RIGHT-POINTING TRIANGLE}\ufe0f" + LAST_PAGE = "\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\ufe0f" + STOP = "\N{BLACK SQUARE FOR STOP}\ufe0f" + + def __init__( + self, + author_id: int, + paginator: "DisnakePaginator", + *, + timeout: Optional[float] = 180, + ): + super().__init__(timeout=timeout) + self.author_id: int = author_id + self._paginator: "DisnakePaginator" = paginator + + # Default to disabled, we change them later anyway if actually required. + self.first_page_button = disnake.ui.Button(label=self.FIRST_PAGE, disabled=True) + self.previous_page_button = disnake.ui.Button( + label=self.PREVIOUS_PAGE, disabled=True + ) + self.next_page_button = disnake.ui.Button(label=self.NEXT_PAGE, disabled=True) + self.last_page_button = disnake.ui.Button(label=self.LAST_PAGE, disabled=True) + self.stop_button = disnake.ui.Button(label=self.STOP, disabled=True) + + self.first_page_button.callback = self._paginator.go_to_first_page + self.previous_page_button.callback = self._paginator.go_to_previous_page + self.next_page_button.callback = self._paginator.go_to_next_page + self.last_page_button.callback = self._paginator.go_to_last_page + self.stop_button.callback = self._paginator.stop_pages + + self.add_item(self.first_page_button) + self.add_item(self.previous_page_button) + self.add_item(self.next_page_button) + self.add_item(self.last_page_button) + self.add_item(self.stop_button) + + async def interaction_check(self, interaction: disnake.MessageInteraction) -> bool: + return interaction.user.id == self.author_id + + async def on_timeout(self) -> None: + self.stop() + await self._paginator.stop() + + +class DisnakePaginator: + def __init__( + self, + items_per_page: int, + input_data: List[T], + *, + try_ephemeral: bool = True, + delete_buttons_on_stop: bool = False, + page_formatter: Optional[Callable] = None, + ): + """ + A simplistic paginator built for Disnake. + + Parameters + ---------- + items_per_page: int + How many items to show per page. + input_data: List[Any] + The data to be paginated. + try_ephemeral: bool + Whether or not to try send the interaction + as ephemeral. Defaults to ``True`` + delete_buttons_on_stop: bool + When the paginator is stopped, should + the buttons be deleted? Defaults to ``False`` + which merely disables them. + page_formatter: Callable + An inline formatter to save the need to + subclass/override ``format_page`` + """ + self._current_page_index = 0 + self._items_per_page: int = items_per_page + self.__input_data: List[T] = input_data + self._try_ephemeral: bool = try_ephemeral + self._delete_buttons_on_stop: bool = delete_buttons_on_stop + self._inline_format_page: Optional[Callable] = page_formatter + + if items_per_page <= 0: + raise ValueError("items_per_page must be 1 or higher.") + + if self._items_per_page == 1: + self._paged_data: List[T] = self.__input_data + + else: + self._paged_data: List[List[T]] = [ + self.__input_data[i : i + self._items_per_page] + for i in range(0, len(self.__input_data), self._items_per_page) + ] + + self._is_done: bool = False + self._message: Optional[disnake.Message] = None + self._pagination_view: Optional[PaginationView] = None + + @property + def current_page(self) -> int: + """The current page for this paginator.""" + return self._current_page_index + 1 + + @current_page.setter + def current_page(self, value) -> None: + if value > self.total_pages: + raise ValueError( + "Cannot change current page to a page bigger then this paginator." + ) + + self._current_page_index = value - 1 + + @property + def total_pages(self) -> int: + "How many pages exist in this paginator." + return len(self._paged_data) + + @property + def requires_pagination(self) -> bool: + """Does this paginator have more then 1 page.""" + return len(self._paged_data) != 1 + + @property + def has_prior_page(self) -> bool: + """Can we move backwards pagination wide.""" + return self.current_page != 1 + + @property + def has_next_page(self) -> bool: + """Can we move forward pagination wise.""" + return self.current_page != self.total_pages + + async def start(self, ih: InteractionHandler): + """ + Start paginating this paginator. + """ + first_page: Union[str, disnake.Embed] = await self.format_page( + self._paged_data[self._current_page_index], self.current_page + ) + + send_kwargs = {} + if isinstance(first_page, disnake.Embed): + send_kwargs["embed"] = first_page + else: + send_kwargs["content"] = first_page + + interaction = ih.interaction + self._pagination_view = PaginationView(interaction.user.id, self) + if ih.has_sent_something: + self._message = await interaction.original_message() + if self.requires_pagination: + await self._message.edit(**send_kwargs, view=self._pagination_view) + + else: + await self._message.edit(**send_kwargs) + + else: + ih.has_sent_something = True + if self.requires_pagination: + await interaction.send( + **send_kwargs, + ephemeral=self._try_ephemeral, + view=self._pagination_view, + ) + + else: + await interaction.send( + **send_kwargs, + ephemeral=self._try_ephemeral, + ) + + self._message = await interaction.original_message() + + await self._set_buttons() + + async def stop(self): + """Stop paginating this paginator.""" + self._is_done = True + await self._set_buttons() + + async def _set_buttons(self) -> disnake.Message: + """Sets buttons based on current page.""" + if not self.requires_pagination: + # No pagination required + return await self._message.edit(view=None) + + if self._is_done: + # Disable all buttons + if self._delete_buttons_on_stop: + return await self._message.edit(view=None) + + self._pagination_view.stop_button.disabled = True + self._pagination_view.next_page_button.disabled = True + self._pagination_view.last_page_button.disabled = True + self._pagination_view.first_page_button.disabled = True + self._pagination_view.previous_page_button.disabled = True + return await self._message.edit(view=self._pagination_view) + + # Toggle buttons + if self.has_prior_page: + self._pagination_view.first_page_button.disabled = False + self._pagination_view.previous_page_button.disabled = False + else: + # Cannot go backwards + self._pagination_view.first_page_button.disabled = True + self._pagination_view.previous_page_button.disabled = True + + if self.has_next_page: + self._pagination_view.next_page_button.disabled = False + self._pagination_view.last_page_button.disabled = False + else: + self._pagination_view.next_page_button.disabled = True + self._pagination_view.last_page_button.disabled = True + + self._pagination_view.stop_button.disabled = False + + return await self._message.edit(view=self._pagination_view) + + async def show_page(self, page_number: int): + """ + Change to the given page. + + Parameters + ---------- + page_number: int + The page you wish to see. + + Raises + ------ + ValueError + Page number is too big for this paginator. + """ + self.current_page = page_number + page: Union[str, disnake.Embed] = await self.format_page( + self._paged_data[self._current_page_index], self.current_page + ) + if isinstance(page, disnake.Embed): + await self._message.edit(embed=page) + else: + await self._message.edit(content=page) + await self._set_buttons() + + async def go_to_first_page(self, interaction: disnake.MessageInteraction): + """Paginate to the first page.""" + await interaction.response.defer() + await self.show_page(1) + + async def go_to_previous_page(self, interaction: disnake.Interaction): + """Paginate to the previous viewable page.""" + await interaction.response.defer() + await self.show_page(self.current_page - 1) + + async def go_to_next_page(self, interaction: disnake.Interaction): + """Paginate to the next viewable page.""" + await interaction.response.defer() + await self.show_page(self.current_page + 1) + + async def go_to_last_page(self, interaction: disnake.Interaction): + """Paginate to the last viewable page.""" + await interaction.response.defer() + await self.show_page(self.total_pages) + + async def stop_pages(self, interaction: disnake.Interaction): + """Stop paginating this paginator.""" + await interaction.response.defer() + await self.stop() + + async def format_page( + self, page_items: Union[T, List[T]], page_number: int + ) -> Union[str, disnake.Embed]: + """Given the page items, format them how you wish. + + Calls the inline formatter if not overridden, + otherwise returns ``page_items`` as a string. + + Parameters + ---------- + page_items: Union[T, List[T]] + The items for this page. + If ``items_per_page`` is ``1`` then this + will be a singular item. + page_number: int + This pages number. + """ + if self._inline_format_page: + return self._inline_format_page(self, page_items, page_number) + + return str(page_items) diff --git a/tests/test_bot.py b/tests/test_bot.py index 3ad6f13..cd09044 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -9,7 +9,6 @@ async def test_cogs_loaded(causar: Causar): bot: SuggestionsBot = cast(SuggestionsBot, causar.bot) cog_names = [ - "Internal", "GuildConfigCog", "HelpGuildCog", "SuggestionsCog", diff --git a/tests/test_disnake.py b/tests/test_disnake.py deleted file mode 100644 index 0c58c03..0000000 --- a/tests/test_disnake.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Tests library modifications to ensure changes work""" - -from unittest.mock import Mock - -import disnake - - -# TODO We want to remove this in favour of interaction handlers in BT-39 -def test_deferred_without_send(): - a = { - "id": "1", - "token": "", - "version": 1, - "application_id": 1, - "channel_id": 1, - "locale": "EN_US", - "type": 2, - } - inter = disnake.Interaction(data=a, state=Mock()) - assert hasattr(inter, "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)