diff options
-rw-r--r-- | .github/dependabot.yml | 7 | ||||
-rw-r--r-- | .github/workflows/sentry_release.yml | 2 | ||||
-rw-r--r-- | bot/constants.py | 1 | ||||
-rw-r--r-- | bot/converters.py | 39 | ||||
-rw-r--r-- | bot/exts/filtering/filtering.py | 5 | ||||
-rw-r--r-- | bot/exts/info/code_snippets.py | 78 | ||||
-rw-r--r-- | bot/exts/moderation/infraction/infractions.py | 4 | ||||
-rw-r--r-- | bot/exts/moderation/infraction/management.py | 2 | ||||
-rw-r--r-- | bot/exts/recruitment/talentpool/_cog.py | 20 | ||||
-rw-r--r-- | bot/exts/utils/reminders.py | 159 | ||||
-rw-r--r-- | bot/exts/utils/snekbox/__init__.py | 4 | ||||
-rw-r--r-- | bot/exts/utils/snekbox/_cog.py | 51 | ||||
-rw-r--r-- | bot/exts/utils/snekbox/_eval.py | 9 | ||||
-rw-r--r-- | bot/exts/utils/utils.py | 74 | ||||
-rw-r--r-- | bot/resources/tags/kindling-projects.md | 2 | ||||
-rw-r--r-- | tests/bot/exts/utils/snekbox/test_snekbox.py | 38 | ||||
-rw-r--r-- | tests/bot/exts/utils/test_utils.py | 94 |
17 files changed, 449 insertions, 140 deletions
diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2f9f77909..665c591b1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,9 +5,10 @@ updates: schedule: interval: "daily" ignore: - update-types: - - sem-ver:patch - - sem-ver:minor + - dependency-name: "*" + update-types: + - version-update:semver-patch + - version-update:semver-minor - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/sentry_release.yml b/.github/workflows/sentry_release.yml index cead62d55..ec96e5c2f 100644 --- a/.github/workflows/sentry_release.yml +++ b/.github/workflows/sentry_release.yml @@ -12,7 +12,7 @@ jobs: uses: actions/checkout@v4 - name: Create a Sentry.io release - uses: getsentry/action-release@v1 + uses: getsentry/action-release@v3 env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: python-discord diff --git a/bot/constants.py b/bot/constants.py index bcf649a9b..13c165e86 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -160,6 +160,7 @@ class _Roles(EnvConfig, env_prefix="roles_"): mod_team: int = 267629731250176001 owners: int = 267627879762755584 project_leads: int = 815701647526330398 + founders: int = 1069394343867199590 # Code Jam jammers: int = 737249140966162473 diff --git a/bot/converters.py b/bot/converters.py index c04158d4d..ecfcb1b4d 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -13,10 +13,8 @@ from discord.ext.commands import BadArgument, Context, Converter, IDConverter, M from discord.utils import snowflake_time from pydis_core.site_api import ResponseCodeError from pydis_core.utils import unqualify -from pydis_core.utils.regex import DISCORD_INVITE from bot import exts, instance as bot_instance -from bot.constants import URLs from bot.errors import InvalidInfractionError from bot.exts.info.doc import _inventory_parser from bot.log import get_logger @@ -31,42 +29,6 @@ DISCORD_EPOCH_DT = snowflake_time(0) RE_USER_MENTION = re.compile(r"<@!?([0-9]+)>$") -class ValidDiscordServerInvite(Converter): - """ - A converter that validates whether a given string is a valid Discord server invite. - - Raises 'BadArgument' if: - - The string is not a valid Discord server invite. - - The string is valid, but is an invite for a group DM. - - The string is valid, but is expired. - - Returns a (partial) guild object if: - - The string is a valid vanity - - The string is a full invite URI - - The string contains the invite code (the stuff after discord.gg/) - - See the Discord API docs for documentation on the guild object: - https://discord.com/developers/docs/resources/guild#guild-object - """ - - async def convert(self, ctx: Context, server_invite: str) -> dict: - """Check whether the string is a valid Discord server invite.""" - invite_code = DISCORD_INVITE.match(server_invite) - if invite_code: - response = await ctx.bot.http_session.get( - f"{URLs.discord_invite_api}/{invite_code.group('invite')}" - ) - if response.status != 404: - invite_data = await response.json() - return invite_data.get("guild") - - id_converter = IDConverter() - if id_converter._get_id_match(server_invite): - raise BadArgument("Guild IDs are not supported, only invites.") - - raise BadArgument("This does not appear to be a valid Discord server invite.") - - class Extension(Converter): """ Fully qualify the name of an extension and ensure it exists. @@ -466,7 +428,6 @@ class Infraction(Converter): if t.TYPE_CHECKING: - ValidDiscordServerInvite = dict ValidFilterListType = str Extension = str PackageName = str diff --git a/bot/exts/filtering/filtering.py b/bot/exts/filtering/filtering.py index 01eba6afc..97b194d65 100644 --- a/bot/exts/filtering/filtering.py +++ b/bot/exts/filtering/filtering.py @@ -978,7 +978,10 @@ class Filtering(Cog): result_actions = None if actions: result_actions = reduce(ActionSettings.union, actions) - + # If the action is a ban, mods don't want to be pinged. + if infr_action := result_actions.get("infraction_and_notification"): + if infr_action.infraction_type == Infraction.BAN: + result_actions.pop("mentions", None) return result_actions, messages, triggers async def _send_alert(self, ctx: FilterContext, triggered_filters: dict[FilterList, Iterable[str]]) -> None: diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index a44b0c475..eba15e825 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -38,6 +38,13 @@ BITBUCKET_RE = re.compile( r"/(?P<file_path>[^#>]+)(\?[^#>]+)?(#lines-(?P<start_line>\d+)(:(?P<end_line>\d+))?)" ) +PYDIS_PASTEBIN_RE = re.compile( + r"https://paste\.(?:pythondiscord\.com|pydis\.wtf)/(?P<paste_id>[a-zA-Z0-9]+)" + r"#(?P<selections>(?:\d+L\d+-L\d+)(?:,\d+L\d+-L\d+)*)" +) + +PASTEBIN_LINE_SELECTION_RE = re.compile(r"(\d+)L(\d+)-L(\d+)") + class CodeSnippets(Cog): """ @@ -54,7 +61,8 @@ class CodeSnippets(Cog): (GITHUB_RE, self._fetch_github_snippet), (GITHUB_GIST_RE, self._fetch_github_gist_snippet), (GITLAB_RE, self._fetch_gitlab_snippet), - (BITBUCKET_RE, self._fetch_bitbucket_snippet) + (BITBUCKET_RE, self._fetch_bitbucket_snippet), + (PYDIS_PASTEBIN_RE, self._fetch_pastebin_snippets), ] async def _fetch_response(self, url: str, response_format: str, **kwargs) -> Any: @@ -170,7 +178,40 @@ class CodeSnippets(Cog): ) return self._snippet_to_codeblock(file_contents, file_path, start_line, end_line) - def _snippet_to_codeblock(self, file_contents: str, file_path: str, start_line: str, end_line: str) -> str: + async def _fetch_pastebin_snippets(self, paste_id: str, selections: str) -> list[str]: + """Fetches snippets from paste.pythondiscord.com.""" + paste_data = await self._fetch_response( + f"https://paste.pythondiscord.com/api/v1/paste/{paste_id}", + "json" + ) + + snippets = [] + for match in PASTEBIN_LINE_SELECTION_RE.finditer(selections): + file_num, start, end = match.groups() + file_num = int(file_num) - 1 + + file = paste_data["files"][file_num] + file_name = file.get("name") or f"file {file_num + 1}" + snippet = self._snippet_to_codeblock( + file["content"], + file_name, + start, + end, + language=file["lexer"], + ) + + snippets.append(snippet) + + return snippets + + def _snippet_to_codeblock( + self, + file_contents: str, + file_path: str, + start_line: str, + end_line: str|None, + language: str|None = None + ) -> str: """ Given the entire file contents and target lines, creates a code block. @@ -203,15 +244,16 @@ class CodeSnippets(Cog): required = "\n".join(split_file_contents[start_line - 1:end_line]) required = textwrap.dedent(required).rstrip().replace("`", "`\u200b") - # Extracts the code language and checks whether it's a "valid" language - language = file_path.split("/")[-1].split(".")[-1] - trimmed_language = language.replace("-", "").replace("+", "").replace("_", "") - is_valid_language = trimmed_language.isalnum() - if not is_valid_language: - language = "" + if language is None: + # Extracts the code language and checks whether it's a "valid" language + language = file_path.split("/")[-1].split(".")[-1] + trimmed_language = language.replace("-", "").replace("+", "").replace("_", "") + is_valid_language = trimmed_language.isalnum() + if not is_valid_language: + language = "" - if language == "pyi": - language = "py" + if language == "pyi": + language = "py" # Adds a label showing the file path to the snippet if start_line == end_line: @@ -231,8 +273,7 @@ class CodeSnippets(Cog): for pattern, handler in self.pattern_handlers: for match in pattern.finditer(content): try: - snippet = await handler(**match.groupdict()) - all_snippets.append((match.start(), snippet)) + result = await handler(**match.groupdict()) except ClientResponseError as error: error_message = error.message log.log( @@ -241,8 +282,17 @@ class CodeSnippets(Cog): f"{error_message} for GET {error.request_info.real_url.human_repr()}" ) - # Sorts the list of snippets by their match index and joins them into a single message - return "\n".join(x[1] for x in sorted(all_snippets)) + if isinstance(result, list): + # The handler returned multiple snippets (currently only possible with our pastebin) + all_snippets.extend((match.start(), snippet) for snippet in result) + else: + all_snippets.append((match.start(), result)) + + # Sort the list of snippets by ONLY their match index + all_snippets.sort(key=lambda item: item[0]) + + # Join them into a single message + return "\n".join(x[1] for x in all_snippets) @Cog.listener() async def on_message(self, message: discord.Message) -> None: diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index a09568b4f..efe70d021 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -102,7 +102,7 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, duration_or_expiry=duration_or_expiry) - @command(aliases=("cban", "purgeban", "pban")) + @command(aliases=("clban", "purgeban", "pban")) @ensure_future_timestamp(timestamp_arg=3) async def cleanban( self, @@ -154,7 +154,7 @@ class Infractions(InfractionScheduler, commands.Cog): ctx.send = send await infr_manage_cog.infraction_append(ctx, infraction, None, reason=f"[Clean log]({log_url})") - @command() + @command(aliases=("cpban",)) async def compban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: """Same as cleanban, but specifically with the ban reason and duration used for compromised accounts.""" await self.cleanban(ctx, user, duration=(arrow.utcnow() + COMP_BAN_DURATION).datetime, reason=COMP_BAN_REASON) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index a3b18002b..6936c978b 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -237,7 +237,7 @@ class ModManagement(commands.Cog): capped, duration = _utils.cap_timeout_duration(expiry) if capped: await _utils.notify_timeout_cap(self.bot, ctx, user) - await user.edit(reason=reason, timed_out_until=expiry) + await user.edit(reason=reason, timed_out_until=duration) log_text += f""" Previous expiry: {time.until_expiration(infraction['expires_at'])} diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index ecd966c4f..95626ffe1 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -137,13 +137,13 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send_help(ctx.command) @nomination_group.group(name="autoreview", aliases=("ar",), invoke_without_command=True) - @has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES, Roles.founders) async def nomination_autoreview_group(self, ctx: Context) -> None: """Commands for enabling or disabling autoreview.""" await ctx.send_help(ctx.command) @nomination_autoreview_group.command(name="enable", aliases=("on",)) - @has_any_role(Roles.admins) + @has_any_role(Roles.admins, Roles.founders) @commands.max_concurrency(1) async def autoreview_enable(self, ctx: Context) -> None: """ @@ -167,7 +167,7 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send(":white_check_mark: Autoreview enabled.") @nomination_autoreview_group.command(name="disable", aliases=("off",)) - @has_any_role(Roles.admins) + @has_any_role(Roles.admins, Roles.founders) @commands.max_concurrency(1) async def autoreview_disable(self, ctx: Context) -> None: """Disable automatic posting of reviews.""" @@ -183,7 +183,7 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send(":white_check_mark: Autoreview disabled.") @nomination_autoreview_group.command(name="status") - @has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES, Roles.founders) async def autoreview_status(self, ctx: Context) -> None: """Show whether automatic posting of reviews is enabled or disabled.""" if await self.autoreview_enabled(): @@ -246,7 +246,7 @@ class TalentPool(Cog, name="Talentpool"): aliases=("nominated", "nominees"), invoke_without_command=True ) - @has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES, Roles.founders) async def list_group( self, ctx: Context, @@ -553,7 +553,7 @@ class TalentPool(Cog, name="Talentpool"): await self.maybe_relay_update(user.id, thread_update) @nomination_group.command(name="history", aliases=("info", "search")) - @has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES, Roles.founders) async def history_command(self, ctx: Context, user: MemberOrUser) -> None: """Shows the specified user's nomination history.""" result = await self.api.get_nominations(user.id, ordering="-active,-inserted_at") @@ -577,7 +577,7 @@ class TalentPool(Cog, name="Talentpool"): ) @nomination_group.command(name="end", aliases=("unwatch", "unnominate"), root_aliases=("unnominate",)) - @has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES, Roles.founders) async def end_nomination_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: """ Ends the active nomination of the specified user with the given reason. @@ -769,7 +769,7 @@ class TalentPool(Cog, name="Talentpool"): await self.maybe_relay_update(nomination.user_id, thread_update) @nomination_edit_group.command(name="end_reason") - @has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES, Roles.founders) async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: """Edits the unnominate reason for the nomination with the given `id`.""" if len(reason) > REASON_MAX_CHARS: @@ -792,7 +792,7 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send(f":white_check_mark: Updated the nomination end reason for <@{nomination.user_id}>.") @nomination_group.command(aliases=("gr",)) - @has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES, Roles.founders) async def get_review(self, ctx: Context, user_id: int) -> None: """Get the user's review as a markdown file.""" nominations = await self.api.get_nominations(user_id, active=True) @@ -808,7 +808,7 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send(files=[review_file, nominations_file]) @nomination_group.command(aliases=("review",)) - @has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES, Roles.founders) async def post_review(self, ctx: Context, user_id: int) -> None: """Post the automatic review for the user ahead of time.""" nominations = await self.api.get_nominations(user_id, active=True) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index ccc5ac75a..4dabf31a9 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -39,6 +39,10 @@ LOCK_NAMESPACE = "reminder" WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 REMINDER_EDIT_CONFIRMATION_TIMEOUT = 60 +REMINDER_MENTION_BUTTON_TIMEOUT = 5*60 +# The number of mentions that can be sent when a reminder arrives is limited by +# the 2000-character message limit. +MAXIMUM_REMINDER_MENTION_OPT_INS = 80 Mentionable = discord.Member | discord.Role ReminderMention = UnambiguousUser | discord.Role @@ -75,6 +79,137 @@ class ModifyReminderConfirmationView(discord.ui.View): self.stop() +class OptInReminderMentionView(discord.ui.View): + """A button to opt-in to get notified of someone else's reminder.""" + + def __init__(self, cog: "Reminders", reminder: dict, expiration: Duration): + super().__init__() + + self.cog = cog + self.reminder = reminder + + self.timeout = min( + (expiration - datetime.now(UTC)).total_seconds(), + REMINDER_MENTION_BUTTON_TIMEOUT + ) + + async def get_embed( + self, + message: str = "Click on the button to add yourself to the list of mentions." + ) -> discord.Embed: + """Return an embed to show the button together with.""" + description = "The following user(s) will be notified when the reminder arrives:\n" + description += " ".join([ + mentionable.mention async for mentionable in self.cog.get_mentionables( + [self.reminder["author"]] + self.reminder["mentions"] + ) + ]) + + if message: + description += f"\n\n{message}" + + return discord.Embed(description=description) + + @discord.ui.button(emoji="🔔", label="Notify me", style=discord.ButtonStyle.green) + async def button_callback(self, interaction: Interaction, button: discord.ui.Button) -> None: + """The button callback.""" + # This is required in case the reminder was edited/deleted between + # creation and the opt-in button click. + try: + api_response = await self.cog.bot.api_client.get(f"bot/reminders/{self.reminder['id']}") + except ResponseCodeError as e: + await self.handle_api_error(interaction, button, e) + return + + self.reminder = api_response + + # Check whether the user should be added. + if interaction.user.id == self.reminder["author"]: + await interaction.response.send_message( + "As the author of that reminder, you will already be notified when the reminder arrives.", + ephemeral=True, + ) + return + + if interaction.user.id in self.reminder["mentions"]: + await interaction.response.send_message( + "You are already in the list of mentions for that reminder.", + ephemeral=True, + delete_after=5, + ) + return + + if len(self.reminder["mentions"]) >= MAXIMUM_REMINDER_MENTION_OPT_INS: + await interaction.response.send_message( + "Sorry, this reminder has reached the maximum number of allowed mentions.", + ephemeral=True, + delete_after=5, + ) + await self.disable(interaction, button, "Maximum number of allowed mentions reached!") + return + + # Add the user to the list of mentions. + try: + api_response = await self.cog.add_mention_opt_in(self.reminder, interaction.user.id) + except ResponseCodeError as e: + await self.handle_api_error(interaction, button, e) + return + + self.reminder = api_response + + # Confirm that it was successful. + await interaction.response.send_message( + "You were successfully added to the list of mentions for that reminder.", + ephemeral=True, + delete_after=5, + ) + + # Update the embed to show the new list of mentions. + await interaction.message.edit(embed=await self.get_embed()) + + async def handle_api_error( + self, + interaction: Interaction, + button: discord.ui.Button, + error: ResponseCodeError + ) -> None: + """Handle a ResponseCodeError from the API responsibly.""" + log.trace(f"API returned {error.status} for reminder #{self.reminder['id']}.") + + if error.status == 404: + # This might happen if the reminder was edited to arrive before the + # button was initially scheduled to timeout. + await interaction.response.send_message( + "This reminder was either deleted or has already arrived.", + ephemeral=True, + delete_after=5, + ) + # Don't delete the whole interaction message here or the user will + # see the above response message seemingly without context. + await self.disable(interaction, button) + + else: + await interaction.response.send_message( + "Sorry, an unexpected error occurred when performing this operation.\n" + "Please create your own reminder instead.", + ephemeral=True, + delete_after=5, + ) + await self.disable( + interaction, + button, + "An unexpected error occurred when attempting to add users." + ) + + async def disable(self, interaction: Interaction, button: discord.ui.Button, reason: str = "") -> None: + """Disable the button and add an optional reason to the original interaction message.""" + button.disabled = True + await interaction.message.edit( + embed=await self.get_embed(reason), + view=self, + ) + + class Reminders(Cog): """Provide in-channel reminder functionality.""" @@ -208,6 +343,18 @@ class Reminders(Cog): self.schedule_reminder(reminder) @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True) + async def add_mention_opt_in(self, reminder: dict, user_id: int) -> dict: + """Add an opt-in user to a reminder's mentions and return the edited reminder.""" + if user_id in reminder["mentions"] or user_id == reminder["author"]: + return reminder + + reminder["mentions"].append(user_id) + reminder = await self._edit_reminder(reminder["id"], {"mentions": reminder["mentions"]}) + + await self._reschedule_reminder(reminder) + return reminder + + @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True) async def send_reminder(self, reminder: dict, expected_time: time.Timestamp | None = None) -> None: """Send the reminder.""" is_valid, channel = self.ensure_valid_reminder(reminder) @@ -360,19 +507,19 @@ class Reminders(Cog): ) formatted_time = time.discord_timestamp(expiration, time.TimestampFormats.DAY_TIME) - mention_string = f"Your reminder will arrive on {formatted_time}" - - if mentions: - mention_string += f" and will mention {len(mentions)} other(s)" - mention_string += "!" + success_message = f"Your reminder will arrive on {formatted_time}!" # Confirm to the user that it worked. await self._send_confirmation( ctx, - on_success=mention_string, + on_success=success_message, reminder_id=reminder["id"] ) + # Add a button for others to also get notified. + view = OptInReminderMentionView(self, reminder, expiration) + await ctx.send(embed=await view.get_embed(), view=view, delete_after=view.timeout) + self.schedule_reminder(reminder) @remind_group.command(name="list") diff --git a/bot/exts/utils/snekbox/__init__.py b/bot/exts/utils/snekbox/__init__.py index 92bf366be..fa91d0d6f 100644 --- a/bot/exts/utils/snekbox/__init__.py +++ b/bot/exts/utils/snekbox/__init__.py @@ -1,8 +1,8 @@ from bot.bot import Bot -from bot.exts.utils.snekbox._cog import CodeblockConverter, Snekbox +from bot.exts.utils.snekbox._cog import CodeblockConverter, Snekbox, SupportedPythonVersions from bot.exts.utils.snekbox._eval import EvalJob, EvalResult -__all__ = ("CodeblockConverter", "EvalJob", "EvalResult", "Snekbox") +__all__ = ("CodeblockConverter", "EvalJob", "EvalResult", "Snekbox", "SupportedPythonVersions") async def setup(bot: Bot) -> None: diff --git a/bot/exts/utils/snekbox/_cog.py b/bot/exts/utils/snekbox/_cog.py index 39f61c6e2..7ff21d2e6 100644 --- a/bot/exts/utils/snekbox/_cog.py +++ b/bot/exts/utils/snekbox/_cog.py @@ -87,7 +87,7 @@ SNEKBOX_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Ro REDO_EMOJI = "\U0001f501" # :repeat: REDO_TIMEOUT = 30 -SupportedPythonVersions = Literal["3.12", "3.13", "3.13t"] +SupportedPythonVersions = Literal["3.13", "3.13t", "3.14"] class FilteredFiles(NamedTuple): allowed: list[FileAttachment] @@ -569,7 +569,29 @@ class Snekbox(Cog): break log.info(f"Re-evaluating code from message {ctx.message.id}:\n{job}") - @command(name="eval", aliases=("e",), usage="[python_version] <code, ...>") + @command( + name="eval", + aliases=("e",), + usage="[python_version] <code, ...>", + help=f""" + Run Python code and get the results. + + This command supports multiple lines of code, including formatted code blocks. + Code can be re-evaluated by editing the original message within 10 seconds and + clicking the reaction that subsequently appears. + + The starting working directory `/home`, is a writeable temporary file system. + Files created, excluding names with leading underscores, will be uploaded in the response. + + If multiple codeblocks are in a message, all of them will be joined and evaluated, + ignoring the text outside them. + + The currently supported versions are {", ".join(get_args(SupportedPythonVersions))}. + + We've done our best to make this sandboxed, but do let us know if you manage to find an + issue with it! + """ + ) @guild_only() @redirect_output( destination_channel=Channels.bot_commands, @@ -585,26 +607,9 @@ class Snekbox(Cog): *, code: CodeblockConverter ) -> None: - """ - Run Python code and get the results. - - This command supports multiple lines of code, including formatted code blocks. - Code can be re-evaluated by editing the original message within 10 seconds and - clicking the reaction that subsequently appears. - - The starting working directory `/home`, is a writeable temporary file system. - Files created, excluding names with leading underscores, will be uploaded in the response. - - If multiple codeblocks are in a message, all of them will be joined and evaluated, - ignoring the text outside them. - - The currently supported verisons are 3.12, 3.13, and 3.13t. - - We've done our best to make this sandboxed, but do let us know if you manage to find an - issue with it! - """ + """Run Python code and get the results.""" code: list[str] - python_version = python_version or "3.12" + python_version = python_version or get_args(SupportedPythonVersions)[0] job = EvalJob.from_code("\n".join(code)).as_version(python_version) await self.run_job(ctx, job) @@ -634,13 +639,13 @@ class Snekbox(Cog): If multiple formatted codeblocks are provided, the first one will be the setup code, which will not be timed. The remaining codeblocks will be joined together and timed. - The currently supported verisons are 3.12, 3.13, and 3.13t. + The currently supported verisons are 3.13, 3.13t, and 3.14. We've done our best to make this sandboxed, but do let us know if you manage to find an issue with it! """ code: list[str] - python_version = python_version or "3.12" + python_version = python_version or "3.13" args = self.prepare_timeit_input(code) job = EvalJob(args, version=python_version, name="timeit") diff --git a/bot/exts/utils/snekbox/_eval.py b/bot/exts/utils/snekbox/_eval.py index ac67d1ed7..6136b6a81 100644 --- a/bot/exts/utils/snekbox/_eval.py +++ b/bot/exts/utils/snekbox/_eval.py @@ -26,7 +26,7 @@ class EvalJob: args: list[str] files: list[FileAttachment] = field(default_factory=list) name: str = "eval" - version: SupportedPythonVersions = "3.12" + version: SupportedPythonVersions = "3.13" @classmethod def from_code(cls, code: str, path: str = "main.py") -> EvalJob: @@ -144,7 +144,12 @@ class EvalResult: def get_status_message(self, job: EvalJob) -> str: """Return a user-friendly message corresponding to the process's return code.""" - version_text = job.version.replace("t", " [free threaded](<https://docs.python.org/3.13/whatsnew/3.13.html#free-threaded-cpython>)") + if job.version == "3.13t": + version_text = job.version.replace("t", " [free threaded](<https://docs.python.org/3.13/whatsnew/3.13.html#free-threaded-cpython>)") + elif job.version == "3.14": + version_text = "3.14 [pre-release](<https://docs.python.org/3.14/whatsnew/3.14.html#development>)" + else: + version_text = job.version msg = f"Your {version_text} {job.name} job" if self.returncode is None: diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 2faf06fee..68019b143 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -16,7 +16,7 @@ from bot.utils import messages, time log = get_logger(__name__) -ZEN_OF_PYTHON = """\ +ZEN_OF_PYTHON = """ Beautiful is better than ugly. Explicit is better than implicit. Simple is better than complex. @@ -36,7 +36,7 @@ Although never is often better than *right* now. If the implementation is hard to explain, it's a bad idea. If the implementation is easy to explain, it may be a good idea. Namespaces are one honking great idea -- let's do more of those! -""" +""".strip() LEADS_AND_COMMUNITY = (Roles.project_leads, Roles.domain_leads, Roles.partners, Roles.python_community) @@ -88,16 +88,14 @@ class Utils(Cog): async def zen( self, ctx: Context, - zen_rule_index: int | None, - *, - search_value: str | None = None + search_value: str | None, ) -> None: """ Show the Zen of Python. Without any arguments, the full Zen will be produced. - If zen_rule_index is provided, the line with that index will be produced. - If only a string is provided, the line which matches best will be produced. + If an index or a slice is provided, the corresponding lines will be produced. + Otherwise, the line which matches best will be produced. """ embed = Embed( colour=Colour.og_blurple(), @@ -105,24 +103,62 @@ class Utils(Cog): description=ZEN_OF_PYTHON ) - if zen_rule_index is None and search_value is None: + if search_value is None: embed.title += ", by Tim Peters" await ctx.send(embed=embed) return zen_lines = ZEN_OF_PYTHON.splitlines() - # Prioritize passing the zen rule index - if zen_rule_index is not None: - - upper_bound = len(zen_lines) - 1 - lower_bound = -1 * len(zen_lines) - if not (lower_bound <= zen_rule_index <= upper_bound): - raise BadArgument(f"Please provide an index between {lower_bound} and {upper_bound}.") - - embed.title += f" (line {zen_rule_index % len(zen_lines)}):" - embed.description = zen_lines[zen_rule_index] - await ctx.send(embed=embed) + # Prioritize checking for an index or slice + match = re.match( + r"(?P<index>-?\d++(?!:))|(?P<start>(?:-\d+)|\d*):(?:(?P<end>(?:-\d+)|\d*)(?::(?P<step>(?:-\d+)|\d*))?)?", + search_value.split(" ")[0], + ) + if match: + if match.group("index"): + index = int(match.group("index")) + if not (-19 <= index <= 18): + raise BadArgument("Please provide an index between -19 and 18.") + embed.title += f" (line {index % 19}):" + embed.description = zen_lines[index] + await ctx.send(embed=embed) + return + + start_index = int(match.group("start")) if match.group("start") else None + end_index = int(match.group("end")) if match.group("end") else None + step_size = int(match.group("step")) if match.group("step") else 1 + + if step_size == 0: + raise BadArgument("Step size must not be 0.") + + lines = zen_lines[start_index:end_index:step_size] + if not lines: + raise BadArgument("Slice returned 0 lines.") + + if len(lines) == 1: + embed.title += f" (line {zen_lines.index(lines[0])}):" + embed.description = lines[0] + await ctx.send(embed=embed) + elif lines == zen_lines: + embed.title += ", by Tim Peters" + await ctx.send(embed=embed) + elif len(lines) == 19: + embed.title += f" (step size {step_size}):" + embed.description = "\n".join(lines) + await ctx.send(embed=embed) + else: + if step_size != 1: + step_message = f", step size {step_size}" + else: + step_message = "" + first_position = zen_lines.index(lines[0]) + second_position = zen_lines.index(lines[-1]) + if first_position > second_position: + (first_position, second_position) = (second_position, first_position) + embed.title += f" (lines {first_position}-{second_position}{step_message}):" + embed.description = "\n".join(lines) + await ctx.send(embed=embed) return # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead diff --git a/bot/resources/tags/kindling-projects.md b/bot/resources/tags/kindling-projects.md index 00ab95513..3317c6a2f 100644 --- a/bot/resources/tags/kindling-projects.md +++ b/bot/resources/tags/kindling-projects.md @@ -2,4 +2,4 @@ embed: title: "Kindling Projects" --- -The [Kindling projects page](https://nedbatchelder.com/text/kindling.html) on Ned Batchelder's website contains a list of projects and ideas programmers can tackle to build their skills and knowledge. +The [Kindling projects page](https://nedbatchelder.com/text/kindling.html) contains a list of projects and ideas programmers can tackle to build their skills and knowledge. diff --git a/tests/bot/exts/utils/snekbox/test_snekbox.py b/tests/bot/exts/utils/snekbox/test_snekbox.py index 9cfd75df8..69262bf61 100644 --- a/tests/bot/exts/utils/snekbox/test_snekbox.py +++ b/tests/bot/exts/utils/snekbox/test_snekbox.py @@ -1,6 +1,7 @@ import asyncio import unittest from base64 import b64encode +from typing import get_args from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch from discord import AllowedMentions @@ -10,7 +11,7 @@ from pydis_core.utils.paste_service import MAX_PASTE_SIZE from bot import constants from bot.errors import LockedResourceError from bot.exts.utils import snekbox -from bot.exts.utils.snekbox import EvalJob, EvalResult, Snekbox +from bot.exts.utils.snekbox import EvalJob, EvalResult, Snekbox, SupportedPythonVersions from bot.exts.utils.snekbox._io import FileAttachment from tests.helpers import MockBot, MockContext, MockMember, MockMessage, MockReaction, MockUser @@ -21,6 +22,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.bot = MockBot() self.cog = Snekbox(bot=self.bot) self.job = EvalJob.from_code("import random") + self.default_version = get_args(SupportedPythonVersions)[0] @staticmethod def code_args(code: str) -> tuple[EvalJob]: @@ -35,7 +37,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): context_manager = MagicMock() context_manager.__aenter__.return_value = resp self.bot.http_session.post.return_value = context_manager - py_version = "3.12" + py_version = self.default_version job = EvalJob.from_code("import random").as_version(py_version) self.assertEqual(await self.cog.post_job(job), EvalResult("Hi", 137)) @@ -104,9 +106,13 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): def test_eval_result_message(self): """EvalResult.get_message(), should return message.""" cases = ( - ("ERROR", None, ("Your 3.12 eval job has failed", "ERROR", "")), - ("", 128 + snekbox._eval.SIGKILL, ("Your 3.12 eval job timed out or ran out of memory", "", "")), - ("", 255, ("Your 3.12 eval job has failed", "A fatal NsJail error occurred", "")) + ("ERROR", None, (f"Your {self.default_version} eval job has failed", "ERROR", "")), + ( + "", + 128 + snekbox._eval.SIGKILL, + (f"Your {self.default_version} eval job timed out or ran out of memory", "", "") + ), + ("", 255, (f"Your {self.default_version} eval job has failed", "A fatal NsJail error occurred", "")) ) for stdout, returncode, expected in cases: exp_msg, exp_err, exp_files_err = expected @@ -178,8 +184,8 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): mock_signals.return_value.name = "SIGTEST" result = EvalResult(stdout="", returncode=127) self.assertEqual( - result.get_status_message(EvalJob([], version="3.12")), - "Your 3.12 eval job has completed with return code 127 (SIGTEST)" + result.get_status_message(EvalJob([])), + f"Your {self.default_version} eval job has completed with return code 127 (SIGTEST)" ) def test_eval_result_status_emoji(self): @@ -253,7 +259,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.cog.send_job = AsyncMock(return_value=response) self.cog.continue_job = AsyncMock(return_value=None) - await self.cog.eval_command(self.cog, ctx=ctx, python_version="3.12", code=["MyAwesomeCode"]) + await self.cog.eval_command(self.cog, ctx=ctx, python_version=self.default_version, code=["MyAwesomeCode"]) job = EvalJob.from_code("MyAwesomeCode") self.cog.send_job.assert_called_once_with(ctx, job) self.cog.continue_job.assert_called_once_with(ctx, response, "eval") @@ -267,7 +273,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.cog.continue_job = AsyncMock() self.cog.continue_job.side_effect = (EvalJob.from_code("MyAwesomeFormattedCode"), None) - await self.cog.eval_command(self.cog, ctx=ctx, python_version="3.12", code=["MyAwesomeCode"]) + await self.cog.eval_command(self.cog, ctx=ctx, python_version=self.default_version, code=["MyAwesomeCode"]) expected_job = EvalJob.from_code("MyAwesomeFormattedCode") self.cog.send_job.assert_called_with(ctx, expected_job) @@ -311,7 +317,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): ctx.send.assert_called_once() self.assertEqual( ctx.send.call_args.args[0], - ":warning: Your 3.12 eval job has completed " + f":warning: Your {self.default_version} eval job has completed " "with return code 0.\n\n```ansi\n[No output]\n```" ) allowed_mentions = ctx.send.call_args.kwargs["allowed_mentions"] @@ -335,13 +341,13 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=(False, [])) self.bot.get_cog.return_value = mocked_filter_cog - job = EvalJob.from_code("MyAwesomeCode").as_version("3.12") + job = EvalJob.from_code("MyAwesomeCode").as_version(self.default_version) await self.cog.send_job(ctx, job), ctx.send.assert_called_once() self.assertEqual( ctx.send.call_args.args[0], - ":white_check_mark: Your 3.12 eval job " + f":white_check_mark: Your {self.default_version} eval job " "has completed with return code 0." "\n\n```ansi\nWay too long beard\n```\nFull output: lookatmybeard.com" ) @@ -362,13 +368,13 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=(False, [])) self.bot.get_cog.return_value = mocked_filter_cog - job = EvalJob.from_code("MyAwesomeCode").as_version("3.12") + job = EvalJob.from_code("MyAwesomeCode").as_version(self.default_version) await self.cog.send_job(ctx, job), ctx.send.assert_called_once() self.assertEqual( ctx.send.call_args.args[0], - ":x: Your 3.12 eval job has completed with return code 127." + f":x: Your {self.default_version} eval job has completed with return code 127." "\n\n```ansi\nERROR\n```" ) @@ -395,13 +401,13 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=(False, disallowed_exts)) self.bot.get_cog.return_value = mocked_filter_cog - job = EvalJob.from_code("MyAwesomeCode").as_version("3.12") + job = EvalJob.from_code("MyAwesomeCode").as_version(self.default_version) await self.cog.send_job(ctx, job), ctx.send.assert_called_once() res = ctx.send.call_args.args[0] self.assertTrue( - res.startswith(":white_check_mark: Your 3.12 eval job has completed with return code 0.") + res.startswith(f":white_check_mark: Your {self.default_version} eval job has completed with return code 0.") ) self.assertIn("Files with disallowed extensions can't be uploaded: **.disallowed, .disallowed2, ...**", res) diff --git a/tests/bot/exts/utils/test_utils.py b/tests/bot/exts/utils/test_utils.py new file mode 100644 index 000000000..9b8ea4ade --- /dev/null +++ b/tests/bot/exts/utils/test_utils.py @@ -0,0 +1,94 @@ +import unittest + +from discord import Colour, Embed +from discord.ext.commands import BadArgument + +from bot.exts.utils.utils import Utils, ZEN_OF_PYTHON +from tests.helpers import MockBot, MockContext + + +class ZenTests(unittest.IsolatedAsyncioTestCase): + """ Tests for the `!zen` command. """ + + + def setUp(self): + self.bot = MockBot() + self.cog = Utils(self.bot) + self.ctx = MockContext() + + self.zen_list = ZEN_OF_PYTHON.splitlines() + self.template_embed = Embed(colour=Colour.og_blurple(), title="The Zen of Python", description=ZEN_OF_PYTHON) + + + + async def test_zen_without_arguments(self): + """ Tests if the `!zen` command reacts properly to no arguments. """ + self.template_embed.title += ", by Tim Peters" + + + await self.cog.zen.callback(self.cog,self.ctx, search_value = None) + self.ctx.send.assert_called_once_with(embed=self.template_embed) + + async def test_zen_with_valid_index(self): + """ Tests if the `!zen` command reacts properly to a valid index as an argument. """ + expected_results = { + 0: ("The Zen of Python (line 0):", "Beautiful is better than ugly."), + 10: ("The Zen of Python (line 10):", "Unless explicitly silenced."), + 18: ("The Zen of Python (line 18):", "Namespaces are one honking great idea -- let's do more of those!"), + -1: ("The Zen of Python (line 18):", "Namespaces are one honking great idea -- let's do more of those!"), + -10: ("The Zen of Python (line 9):", "Errors should never pass silently."), + -19: ("The Zen of Python (line 0):", "Beautiful is better than ugly.") + + } + + for index, (title, description) in expected_results.items(): + self.template_embed.title = title + self.template_embed.description = description + ctx = MockContext() + with self.subTest(index = index, expected_title=title, expected_description = description): + await self.cog.zen.callback(self.cog, ctx, search_value = str(index)) + ctx.send.assert_called_once_with(embed = self.template_embed) + + + + async def test_zen_with_invalid_index(self): + """ Tests if the `!zen` command reacts properly to an out-of-bounds index as an argument. """ + # Negative index + with self.subTest(index = -20), self.assertRaises(BadArgument): + await self.cog.zen.callback(self.cog, self.ctx, search_value="-20") + + # Positive index + with self.subTest(index = len(ZEN_OF_PYTHON)), self.assertRaises(BadArgument): + await self.cog.zen.callback(self.cog, self.ctx, search_value=str(len(ZEN_OF_PYTHON))) + + async def test_zen_with_valid_slices(self): + """ Tests if the `!zen` command reacts properly to valid slices for indexing as an argument. """ + + expected_results = { + "0:19": ("The Zen of Python, by Tim Peters", "\n".join(self.zen_list)), + ":": ("The Zen of Python, by Tim Peters", "\n".join(self.zen_list)), + "::": ("The Zen of Python, by Tim Peters", "\n".join(self.zen_list)), + "1:": ("The Zen of Python (lines 1-18):", "\n".join(self.zen_list[1:])), + "-2:-1": ("The Zen of Python (line 17):", self.zen_list[17]), + "0:-1": ("The Zen of Python (lines 0-17):", "\n".join(self.zen_list[0:-1])), + "10:13": ("The Zen of Python (lines 10-12):", "\n".join(self.zen_list[10:13])), + "::-1": ("The Zen of Python (step size -1):", "\n".join(self.zen_list[::-1])), + "10:5:-1": ("The Zen of Python (lines 6-10, step size -1):", "\n".join(self.zen_list[10:5:-1])), + } + + for input_slice, (title, description) in expected_results.items(): + self.template_embed.title = title + self.template_embed.description = description + + ctx = MockContext() + with self.subTest(input_slice=input_slice, expected_title=title, expected_description=description): + await self.cog.zen.callback(self.cog, ctx, search_value=input_slice) + ctx.send.assert_called_once_with(embed = self.template_embed) + + async def test_zen_with_invalid_slices(self): + """ Tests if the `!zen` command reacts properly to invalid slices for indexing as an argument. """ + slices= ["19:18", "10:9", "-1:-2", "0:-100", "::0", "1:2:-1", "-5:-4:-1"] + + for input_slice in slices: + with self.subTest(input_slice = input_slice), self.assertRaises(BadArgument): + await self.cog.zen.callback(self.cog, self.ctx, search_value=input_slice) |