From b065b2493b6adeb066aec1976eccde4a3bdbec2e Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sat, 22 Oct 2022 13:56:25 +0100 Subject: Fix tests --- tests/bot/exts/recruitment/talentpool/test_review.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) (limited to 'tests') diff --git a/tests/bot/exts/recruitment/talentpool/test_review.py b/tests/bot/exts/recruitment/talentpool/test_review.py index ed9b66e12..295b0e221 100644 --- a/tests/bot/exts/recruitment/talentpool/test_review.py +++ b/tests/bot/exts/recruitment/talentpool/test_review.py @@ -1,6 +1,6 @@ import unittest from datetime import datetime, timedelta, timezone -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from bot.exts.recruitment.talentpool import _review from tests.helpers import MockBot, MockMember, MockMessage, MockTextChannel @@ -65,6 +65,7 @@ class ReviewerTests(unittest.IsolatedAsyncioTestCase): MockMessage(author=self.bot_user, content="Not a review", created_at=not_too_recent), MockMessage(author=self.bot_user, content="Not a review", created_at=not_too_recent), ], + not_too_recent.timestamp(), True, ), @@ -75,6 +76,7 @@ class ReviewerTests(unittest.IsolatedAsyncioTestCase): MockMessage(author=self.bot_user, content="Zig for Helper!", created_at=not_too_recent), MockMessage(author=self.bot_user, content="Scaleios for Helper!", created_at=not_too_recent), ], + not_too_recent.timestamp(), False, ), @@ -83,6 +85,7 @@ class ReviewerTests(unittest.IsolatedAsyncioTestCase): [ MockMessage(author=self.bot_user, content="Chrisjl for Helper!", created_at=too_recent), ], + too_recent.timestamp(), False, ), @@ -94,18 +97,25 @@ class ReviewerTests(unittest.IsolatedAsyncioTestCase): MockMessage(author=self.bot_user, content="wookie for Helper!", created_at=not_too_recent), MockMessage(author=self.bot_user, content="Not a review", created_at=not_too_recent), ], + not_too_recent.timestamp(), True, ), # No messages, so ready. - ([], True), + ([], None, True), ) - for messages, expected in cases: + for messages, last_review_timestamp, expected in cases: with self.subTest(messages=messages, expected=expected): self.voting_channel.history = AsyncIterator(messages) + + cache_get_mock = AsyncMock(return_value=last_review_timestamp) + self.reviewer.status_cache.get = cache_get_mock + res = await self.reviewer.is_ready_for_review() + self.assertIs(res, expected) + cache_get_mock.assert_called_with("last_vote_date") @patch("bot.exts.recruitment.talentpool._review.MIN_NOMINATION_TIME", timedelta(days=7)) async def test_get_user_for_review(self): -- cgit v1.2.3 From 1cff5bf589a848576d3d1f4a9c1ab71633406caf Mon Sep 17 00:00:00 2001 From: Ibrahim2750mi Date: Tue, 14 Feb 2023 21:08:09 +0530 Subject: Update tests for `/tag` as of migration to slash commands --- bot/exts/backend/error_handler.py | 22 +++++++++----- tests/bot/exts/backend/test_error_handler.py | 44 ++++++++++++++-------------- tests/helpers.py | 20 +++++++++++++ 3 files changed, 56 insertions(+), 30 deletions(-) (limited to 'tests') diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index cc2b5ef56..561bf8068 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -1,7 +1,8 @@ import copy import difflib +import typing as t -from discord import Embed +from discord import Embed, Interaction from discord.ext.commands import ChannelNotFound, Cog, Context, TextChannelConverter, VoiceChannelConverter, errors from pydis_core.site_api import ResponseCodeError from sentry_sdk import push_scope @@ -21,6 +22,10 @@ class ErrorHandler(Cog): def __init__(self, bot: Bot): self.bot = bot + @staticmethod + async def _can_run(_: Interaction) -> bool: + return False + def _get_error_embed(self, title: str, body: str) -> Embed: """Return an embed that contains the exception.""" return Embed( @@ -159,7 +164,7 @@ class ErrorHandler(Cog): return True return False - async def try_get_tag(self, ctx: Context) -> None: + async def try_get_tag(self, interaction: Interaction, can_run: t.Callable[[Interaction], bool] = False) -> None: """ Attempt to display a tag by interpreting the command name as a tag name. @@ -168,27 +173,28 @@ class ErrorHandler(Cog): the context to prevent infinite recursion in the case of a CommandNotFound exception. """ tags_get_command = self.bot.get_command("tags get") + tags_get_command.can_run = can_run if can_run else self._can_run if not tags_get_command: log.debug("Not attempting to parse message as a tag as could not find `tags get` command.") return - ctx.invoked_from_error_handler = True + interaction.invoked_from_error_handler = True log_msg = "Cancelling attempt to fall back to a tag due to failed checks." try: - if not await tags_get_command.can_run(ctx): + if not await tags_get_command.can_run(interaction): log.debug(log_msg) return except errors.CommandError as tag_error: log.debug(log_msg) - await self.on_command_error(ctx, tag_error) + await self.on_command_error(interaction, tag_error) return - if await ctx.invoke(tags_get_command, argument_string=ctx.message.content): + if await interaction.invoke(tags_get_command, tag_name=interaction.message.content): return - if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): - await self.send_command_suggestion(ctx, ctx.invoked_with) + if not any(role.id in MODERATION_ROLES for role in interaction.user.roles): + await self.send_command_suggestion(interaction, interaction.invoked_with) async def try_run_eval(self, ctx: Context) -> bool: """ diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index adb0252a5..83bc3c4a1 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -9,7 +9,7 @@ from bot.exts.backend import error_handler from bot.exts.info.tags import Tags from bot.exts.moderation.silence import Silence from bot.utils.checks import InWhitelistCheckFailure -from tests.helpers import MockBot, MockContext, MockGuild, MockRole, MockTextChannel, MockVoiceChannel +from tests.helpers import MockBot, MockContext, MockGuild, MockInteraction, MockRole, MockTextChannel, MockVoiceChannel class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): @@ -331,7 +331,7 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() - self.ctx = MockContext() + self.interaction = MockInteraction() self.tag = Tags(self.bot) self.cog = error_handler.ErrorHandler(self.bot) self.bot.get_command.return_value = self.tag.get_command @@ -339,57 +339,57 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): async def test_try_get_tag_get_command(self): """Should call `Bot.get_command` with `tags get` argument.""" self.bot.get_command.reset_mock() - await self.cog.try_get_tag(self.ctx) + await self.cog.try_get_tag(self.interaction) self.bot.get_command.assert_called_once_with("tags get") async def test_try_get_tag_invoked_from_error_handler(self): - """`self.ctx` should have `invoked_from_error_handler` `True`.""" - self.ctx.invoked_from_error_handler = False - await self.cog.try_get_tag(self.ctx) - self.assertTrue(self.ctx.invoked_from_error_handler) + """`self.interaction` should have `invoked_from_error_handler` `True`.""" + self.interaction.invoked_from_error_handler = False + await self.cog.try_get_tag(self.interaction) + self.assertTrue(self.interaction.invoked_from_error_handler) async def test_try_get_tag_no_permissions(self): """Test how to handle checks failing.""" self.tag.get_command.can_run = AsyncMock(return_value=False) - self.ctx.invoked_with = "foo" - self.assertIsNone(await self.cog.try_get_tag(self.ctx)) + self.interaction.invoked_with = "foo" + self.assertIsNone(await self.cog.try_get_tag(self.interaction, AsyncMock(return_value=False))) async def test_try_get_tag_command_error(self): """Should call `on_command_error` when `CommandError` raised.""" err = errors.CommandError() self.tag.get_command.can_run = AsyncMock(side_effect=err) self.cog.on_command_error = AsyncMock() - self.assertIsNone(await self.cog.try_get_tag(self.ctx)) - self.cog.on_command_error.assert_awaited_once_with(self.ctx, err) + self.assertIsNone(await self.cog.try_get_tag(self.interaction, AsyncMock(side_effect=err))) + self.cog.on_command_error.assert_awaited_once_with(self.interaction, err) async def test_dont_call_suggestion_tag_sent(self): """Should never call command suggestion if tag is already sent.""" - self.ctx.message = MagicMock(content="foo") - self.ctx.invoke = AsyncMock(return_value=True) + self.interaction.message = MagicMock(content="foo") + self.interaction.invoke = AsyncMock(return_value=True) self.cog.send_command_suggestion = AsyncMock() - await self.cog.try_get_tag(self.ctx) + await self.cog.try_get_tag(self.interaction, AsyncMock()) self.cog.send_command_suggestion.assert_not_awaited() @patch("bot.exts.backend.error_handler.MODERATION_ROLES", new=[1234]) async def test_dont_call_suggestion_if_user_mod(self): """Should not call command suggestion if user is a mod.""" - self.ctx.invoked_with = "foo" - self.ctx.invoke = AsyncMock(return_value=False) - self.ctx.author.roles = [MockRole(id=1234)] + self.interaction.invoked_with = "foo" + self.interaction.invoke = AsyncMock(return_value=False) + self.interaction.user.roles = [MockRole(id=1234)] self.cog.send_command_suggestion = AsyncMock() - await self.cog.try_get_tag(self.ctx) + await self.cog.try_get_tag(self.interaction, AsyncMock()) self.cog.send_command_suggestion.assert_not_awaited() async def test_call_suggestion(self): """Should call command suggestion if user is not a mod.""" - self.ctx.invoked_with = "foo" - self.ctx.invoke = AsyncMock(return_value=False) + self.interaction.invoked_with = "foo" + self.interaction.invoke = AsyncMock(return_value=False) self.cog.send_command_suggestion = AsyncMock() - await self.cog.try_get_tag(self.ctx) - self.cog.send_command_suggestion.assert_awaited_once_with(self.ctx, "foo") + await self.cog.try_get_tag(self.interaction, AsyncMock()) + self.cog.send_command_suggestion.assert_awaited_once_with(self.interaction, "foo") class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase): diff --git a/tests/helpers.py b/tests/helpers.py index 4b980ac21..2d20b4d07 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -479,6 +479,26 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): self.invoked_from_error_handler = kwargs.get('invoked_from_error_handler', False) +class MockInteraction(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Interaction objects. + + Instances of this class will follow the specifications of `discord.Interaction` + instances. For more information, see the `MockGuild` docstring. + """ + # spec_set = context_instance + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.me = kwargs.get('me', MockMember()) + self.client = kwargs.get('client', MockBot()) + self.guild = kwargs.get('guild', MockGuild()) + self.user = kwargs.get('user', MockMember()) + self.channel = kwargs.get('channel', MockTextChannel()) + self.message = kwargs.get('message', MockMessage()) + self.invoked_from_error_handler = kwargs.get('invoked_from_error_handler', False) + + attachment_instance = discord.Attachment(data=unittest.mock.MagicMock(id=1), state=unittest.mock.MagicMock()) -- cgit v1.2.3 From 9b98dfe78bb226e26a8d9cb6e8a0e8f8504286dd Mon Sep 17 00:00:00 2001 From: Ibrahim Date: Thu, 23 Feb 2023 04:08:57 +0530 Subject: Implement all reviews + Remove commented code + Remove unecessarily syncting the bot + Handle direct tag commads + 3.10 type hinting in concerned functions + Add `MockInteractionMessage` + Fix tests for `try_get_tag` --- bot/exts/backend/error_handler.py | 40 ++++++++----- bot/exts/info/tags.py | 90 +++++++++++++++++++--------- bot/pagination.py | 4 +- bot/utils/messages.py | 2 +- tests/bot/exts/backend/test_error_handler.py | 50 ++++++++-------- tests/helpers.py | 11 +++- 6 files changed, 125 insertions(+), 72 deletions(-) (limited to 'tests') diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 561bf8068..6561f84e4 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -2,7 +2,7 @@ import copy import difflib import typing as t -from discord import Embed, Interaction +from discord import Embed, Interaction, utils from discord.ext.commands import ChannelNotFound, Cog, Context, TextChannelConverter, VoiceChannelConverter, errors from pydis_core.site_api import ResponseCodeError from sentry_sdk import push_scope @@ -23,8 +23,19 @@ class ErrorHandler(Cog): self.bot = bot @staticmethod - async def _can_run(_: Interaction) -> bool: - return False + async def _can_run(ctx: Context) -> bool: + """ + Add checks for the `get_command_ctx` function here. + + Use discord.utils to run the checks. + """ + checks = [] + predicates = checks + if not predicates: + # Since we have no checks, then we just return True. + return True + + return await utils.async_all(predicate(ctx) for predicate in predicates) def _get_error_embed(self, title: str, body: str) -> Embed: """Return an embed that contains the exception.""" @@ -164,7 +175,7 @@ class ErrorHandler(Cog): return True return False - async def try_get_tag(self, interaction: Interaction, can_run: t.Callable[[Interaction], bool] = False) -> None: + async def try_get_tag(self, ctx: Context, can_run: t.Callable[[Interaction], bool] = False) -> None: """ Attempt to display a tag by interpreting the command name as a tag name. @@ -172,29 +183,30 @@ class ErrorHandler(Cog): by `on_command_error`, but the `invoked_from_error_handler` attribute will be added to the context to prevent infinite recursion in the case of a CommandNotFound exception. """ - tags_get_command = self.bot.get_command("tags get") - tags_get_command.can_run = can_run if can_run else self._can_run - if not tags_get_command: - log.debug("Not attempting to parse message as a tag as could not find `tags get` command.") + tags_cog = self.bot.get_cog("Tags") + if not tags_cog: + log.debug("Not attempting to parse message as a tag as could not find `Tags` cog.") return + tags_get_command = tags_cog.get_command_ctx + can_run = can_run if can_run else self._can_run - interaction.invoked_from_error_handler = True + ctx.invoked_from_error_handler = True log_msg = "Cancelling attempt to fall back to a tag due to failed checks." try: - if not await tags_get_command.can_run(interaction): + if not await can_run(ctx): log.debug(log_msg) return except errors.CommandError as tag_error: log.debug(log_msg) - await self.on_command_error(interaction, tag_error) + await self.on_command_error(ctx, tag_error) return - if await interaction.invoke(tags_get_command, tag_name=interaction.message.content): + if await tags_get_command(ctx, ctx.message.content): return - if not any(role.id in MODERATION_ROLES for role in interaction.user.roles): - await self.send_command_suggestion(interaction, interaction.invoked_with) + if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): + await self.send_command_suggestion(ctx, ctx.invoked_with) async def try_run_eval(self, ctx: Context) -> bool: """ diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 25c51def9..60f730586 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -8,7 +8,7 @@ from typing import Literal, NamedTuple, Optional, Union import discord import frontmatter -from discord import Embed, Member, app_commands +from discord import Embed, Interaction, Member, app_commands from discord.ext.commands import Cog from bot import constants @@ -140,15 +140,8 @@ class Tags(Cog): self.bot = bot self.tags: dict[TagIdentifier, Tag] = {} self.initialize_tags() - self.bot.tree.copy_global_to(guild=discord.Object(id=GUILD_ID)) tag_group = app_commands.Group(name="tag", description="...") - # search_tag = app_commands.Group(name="search", description="...", parent=tag_group) - - @Cog.listener() - async def on_ready(self) -> None: - """Called when the cog is ready.""" - await self.bot.tree.sync(guild=discord.Object(id=GUILD_ID)) def initialize_tags(self) -> None: """Load all tags from resources into `self.tags`.""" @@ -195,7 +188,8 @@ class Tags(Cog): async def get_tag_embed( self, - interaction: discord.Interaction, + author: discord.Member, + channel: discord.TextChannel | discord.Thread, tag_identifier: TagIdentifier, ) -> Optional[Union[Embed, Literal[COOLDOWN.obj]]]: """ @@ -206,7 +200,7 @@ class Tags(Cog): filtered_tags = [ (ident, tag) for ident, tag in self.get_fuzzy_matches(tag_identifier)[:10] - if tag.accessible_by(interaction.user) + if tag.accessible_by(author) ] # Try exact match, includes checking through alt names @@ -225,10 +219,10 @@ class Tags(Cog): tag = filtered_tags[0][1] if tag is not None: - if tag.on_cooldown_in(interaction.channel): + if tag.on_cooldown_in(channel): log.debug(f"Tag {str(tag_identifier)!r} is on cooldown.") return COOLDOWN.obj - tag.set_cooldown_for(interaction.channel) + tag.set_cooldown_for(channel) self.bot.stats.incr( f"tags.usages" @@ -243,7 +237,7 @@ class Tags(Cog): suggested_tags_text = "\n".join( f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier}" for identifier, tag in filtered_tags - if not tag.on_cooldown_in(interaction.channel) + if not tag.on_cooldown_in(channel) ) return Embed( title="Did you mean ...", @@ -292,8 +286,37 @@ class Tags(Cog): if identifier.group == group and tag.accessible_by(user) ) + async def get_command_ctx( + self, + ctx: discord.Context, + name: str + ) -> bool: + """Made specifically for `error_handler.py`, See `get_command` for more info.""" + identifier = TagIdentifier.from_string(name) + + if identifier.group is None: + # Try to find accessible tags from a group matching the identifier's name. + if group_tags := self.accessible_tags_in_group(identifier.name, ctx.author): + await LinePaginator.paginate( + group_tags, ctx, Embed(title=f"Tags under *{identifier.name}*"), **self.PAGINATOR_DEFAULTS + ) + return True + + embed = await self.get_tag_embed(ctx.author, ctx.channel, identifier) + if embed is None: + return False + + if embed is not COOLDOWN.obj: + + await wait_for_deletion( + await ctx.send(embed=embed), + (ctx.author.id,) + ) + # A valid tag was found and was either sent, or is on cooldown + return True + @tag_group.command(name="get") - async def get_command(self, interaction: discord.Interaction, *, tag_name: Optional[str]) -> bool: + async def get_command(self, interaction: Interaction, *, name: Optional[str]) -> bool: """ If a single argument matching a group name is given, list all accessible tags from that group Otherwise display the tag if one was found for the given arguments, or try to display suggestions for that name. @@ -303,7 +326,7 @@ class Tags(Cog): Returns True if a message was sent, or if the tag is on cooldown. Returns False if no message was sent. """ # noqa: D205, D415 - if not tag_name: + if not name: if self.tags: await LinePaginator.paginate( self.accessible_tags(interaction.user), @@ -314,7 +337,7 @@ class Tags(Cog): await interaction.response.send_message(embed=Embed(description="**There are no tags!**")) return True - identifier = TagIdentifier.from_string(tag_name) + identifier = TagIdentifier.from_string(name) if identifier.group is None: # Try to find accessible tags from a group matching the identifier's name. @@ -324,33 +347,43 @@ class Tags(Cog): ) return True - embed = await self.get_tag_embed(interaction, identifier) + embed = await self.get_tag_embed(interaction.user, interaction.channel, identifier) + ephemeral = False if embed is None: - return False - - if embed is not COOLDOWN.obj: + description = f"**There are no tags matching the name {name!r}!**" + embed = Embed(description=description) + ephemeral = True + elif embed is COOLDOWN.obj: + description = f"Tag {name!r} is on cooldown." + embed = Embed(description=description) + ephemeral = True + + await interaction.response.send_message(embed=embed, ephemeral=ephemeral) + if not ephemeral: await wait_for_deletion( - await interaction.response.send_message(embed=embed), + await interaction.original_response(), (interaction.user.id,) ) + # A valid tag was found and was either sent, or is on cooldown return True - @get_command.autocomplete("tag_name") - async def tag_name_autocomplete( + @get_command.autocomplete("name") + async def name_autocomplete( self, - interaction: discord.Interaction, + interaction: Interaction, current: str ) -> list[app_commands.Choice[str]]: """Autocompleter for `/tag get` command.""" - tag_names = [tag.name for tag in self.tags.keys()] - return [ + names = [tag.name for tag in self.tags.keys()] + choices = [ app_commands.Choice(name=tag, value=tag) - for tag in tag_names if current.lower() in tag + for tag in names if current.lower() in tag ] + return choices[:25] if len(choices) > 25 else choices @tag_group.command(name="list") - async def list_command(self, interaction: discord.Interaction) -> bool: + async def list_command(self, interaction: Interaction) -> bool: """Lists all accessible tags.""" if self.tags: await LinePaginator.paginate( @@ -367,4 +400,3 @@ class Tags(Cog): async def setup(bot: Bot) -> None: """Load the Tags cog.""" await bot.add_cog(Tags(bot)) - await bot.tree.sync(guild=discord.Object(id=GUILD_ID)) diff --git a/bot/pagination.py b/bot/pagination.py index 1c63a4768..c39ce211b 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -190,8 +190,8 @@ class LinePaginator(Paginator): @classmethod async def paginate( cls, - lines: t.List[str], - ctx: t.Union[Context, discord.Interaction], + lines: list[str], + ctx: Context | discord.Interaction, embed: discord.Embed, prefix: str = "", suffix: str = "", diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 27f2eac97..f6bdceaef 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -58,7 +58,7 @@ def reaction_check( async def wait_for_deletion( - message: discord.Message, + message: discord.Message | discord.InteractionMessage, user_ids: Sequence[int], deletion_emojis: Sequence[str] = (Emojis.trashcan,), timeout: float = 60 * 5, diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index 83bc3c4a1..14e7a4125 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -9,7 +9,7 @@ from bot.exts.backend import error_handler from bot.exts.info.tags import Tags from bot.exts.moderation.silence import Silence from bot.utils.checks import InWhitelistCheckFailure -from tests.helpers import MockBot, MockContext, MockGuild, MockInteraction, MockRole, MockTextChannel, MockVoiceChannel +from tests.helpers import MockBot, MockContext, MockGuild, MockRole, MockTextChannel, MockVoiceChannel class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): @@ -331,65 +331,65 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() - self.interaction = MockInteraction() + self.ctx = MockContext() self.tag = Tags(self.bot) self.cog = error_handler.ErrorHandler(self.bot) - self.bot.get_command.return_value = self.tag.get_command + self.bot.get_cog.return_value = self.tag async def test_try_get_tag_get_command(self): """Should call `Bot.get_command` with `tags get` argument.""" - self.bot.get_command.reset_mock() - await self.cog.try_get_tag(self.interaction) - self.bot.get_command.assert_called_once_with("tags get") + self.bot.get_cog.reset_mock() + await self.cog.try_get_tag(self.ctx) + self.bot.get_cog.assert_called_once_with("Tags") async def test_try_get_tag_invoked_from_error_handler(self): - """`self.interaction` should have `invoked_from_error_handler` `True`.""" - self.interaction.invoked_from_error_handler = False - await self.cog.try_get_tag(self.interaction) - self.assertTrue(self.interaction.invoked_from_error_handler) + """`self.ctx` should have `invoked_from_error_handler` `True`.""" + self.ctx.invoked_from_error_handler = False + await self.cog.try_get_tag(self.ctx) + self.assertTrue(self.ctx.invoked_from_error_handler) async def test_try_get_tag_no_permissions(self): """Test how to handle checks failing.""" self.tag.get_command.can_run = AsyncMock(return_value=False) - self.interaction.invoked_with = "foo" - self.assertIsNone(await self.cog.try_get_tag(self.interaction, AsyncMock(return_value=False))) + self.ctx.invoked_with = "foo" + self.assertIsNone(await self.cog.try_get_tag(self.ctx, AsyncMock(return_value=False))) async def test_try_get_tag_command_error(self): """Should call `on_command_error` when `CommandError` raised.""" err = errors.CommandError() self.tag.get_command.can_run = AsyncMock(side_effect=err) self.cog.on_command_error = AsyncMock() - self.assertIsNone(await self.cog.try_get_tag(self.interaction, AsyncMock(side_effect=err))) - self.cog.on_command_error.assert_awaited_once_with(self.interaction, err) + self.assertIsNone(await self.cog.try_get_tag(self.ctx, AsyncMock(side_effect=err))) + self.cog.on_command_error.assert_awaited_once_with(self.ctx, err) async def test_dont_call_suggestion_tag_sent(self): """Should never call command suggestion if tag is already sent.""" - self.interaction.message = MagicMock(content="foo") - self.interaction.invoke = AsyncMock(return_value=True) + self.ctx.message = MagicMock(content="foo") + self.tag.get_command_ctx = AsyncMock(return_value=True) self.cog.send_command_suggestion = AsyncMock() - await self.cog.try_get_tag(self.interaction, AsyncMock()) + await self.cog.try_get_tag(self.ctx) self.cog.send_command_suggestion.assert_not_awaited() @patch("bot.exts.backend.error_handler.MODERATION_ROLES", new=[1234]) async def test_dont_call_suggestion_if_user_mod(self): """Should not call command suggestion if user is a mod.""" - self.interaction.invoked_with = "foo" - self.interaction.invoke = AsyncMock(return_value=False) - self.interaction.user.roles = [MockRole(id=1234)] + self.ctx.invoked_with = "foo" + self.ctx.invoke = AsyncMock(return_value=False) + self.ctx.author.roles = [MockRole(id=1234)] self.cog.send_command_suggestion = AsyncMock() - await self.cog.try_get_tag(self.interaction, AsyncMock()) + await self.cog.try_get_tag(self.ctx) self.cog.send_command_suggestion.assert_not_awaited() async def test_call_suggestion(self): """Should call command suggestion if user is not a mod.""" - self.interaction.invoked_with = "foo" - self.interaction.invoke = AsyncMock(return_value=False) + self.ctx.invoked_with = "foo" + self.ctx.invoke = AsyncMock(return_value=False) self.cog.send_command_suggestion = AsyncMock() - await self.cog.try_get_tag(self.interaction, AsyncMock()) - self.cog.send_command_suggestion.assert_awaited_once_with(self.interaction, "foo") + await self.cog.try_get_tag(self.ctx) + self.cog.send_command_suggestion.assert_awaited_once_with(self.ctx, "foo") class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase): diff --git a/tests/helpers.py b/tests/helpers.py index 2d20b4d07..0d955b521 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -486,7 +486,6 @@ class MockInteraction(CustomMockMixin, unittest.mock.MagicMock): Instances of this class will follow the specifications of `discord.Interaction` instances. For more information, see the `MockGuild` docstring. """ - # spec_set = context_instance def __init__(self, **kwargs) -> None: super().__init__(**kwargs) @@ -550,6 +549,16 @@ class MockMessage(CustomMockMixin, unittest.mock.MagicMock): self.channel = kwargs.get('channel', MockTextChannel()) +class MockInteractionMessage(MockMessage): + """ + A MagicMock subclass to mock InteractionMessage objects. + + Instances of this class will follow the specifications of `discord.InteractionMessage` instances. For more + information, see the `MockGuild` docstring. + """ + pass + + emoji_data = {'require_colons': True, 'managed': True, 'id': 1, 'name': 'hyperlemon'} emoji_instance = discord.Emoji(guild=MockGuild(), state=unittest.mock.MagicMock(), data=emoji_data) -- cgit v1.2.3 From 71d8d5b8204a8b918d3def3731dcadbc7f21558b Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sat, 25 Feb 2023 13:02:25 +0000 Subject: Fix timeit commands with backticks after command name --- bot/exts/backend/error_handler.py | 19 +++++++++++++------ tests/bot/exts/backend/test_error_handler.py | 6 +++--- 2 files changed, 16 insertions(+), 9 deletions(-) (limited to 'tests') diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index cc2b5ef56..07248df5b 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -66,7 +66,7 @@ class ErrorHandler(Cog): if isinstance(e, errors.CommandNotFound) and not getattr(ctx, "invoked_from_error_handler", False): if await self.try_silence(ctx): return - if await self.try_run_eval(ctx): + if await self.try_run_fixed_codeblock(ctx): return await self.try_get_tag(ctx) # Try to look for a tag with the command's name elif isinstance(e, errors.UserInputError): @@ -190,9 +190,9 @@ class ErrorHandler(Cog): if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): await self.send_command_suggestion(ctx, ctx.invoked_with) - async def try_run_eval(self, ctx: Context) -> bool: + async def try_run_fixed_codeblock(self, ctx: Context) -> bool: """ - Attempt to run eval command with backticks directly after command. + Attempt to run eval or timeit command with triple backticks directly after command. For example: !eval```print("hi")``` @@ -204,11 +204,18 @@ class ErrorHandler(Cog): msg.content = command + " " + sep + end new_ctx = await self.bot.get_context(msg) - eval_command = self.bot.get_command("eval") - if eval_command is None or new_ctx.command != eval_command: + if new_ctx.command is None: return False - log.debug("Running fixed eval command.") + allowed_commands = [ + self.bot.get_command("eval"), + self.bot.get_command("timeit"), + ] + + if new_ctx.command not in allowed_commands: + return False + + log.debug("Running %r command with fixed codeblock.", new_ctx.command.qualified_name) new_ctx.invoked_from_error_handler = True await self.bot.invoke(new_ctx) diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index adb0252a5..092de0556 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -47,7 +47,7 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): ) self.cog.try_silence = AsyncMock() self.cog.try_get_tag = AsyncMock() - self.cog.try_run_eval = AsyncMock(return_value=False) + self.cog.try_run_fixed_codeblock = AsyncMock(return_value=False) for case in test_cases: with self.subTest(try_silence_return=case["try_silence_return"], try_get_tag=case["called_try_get_tag"]): @@ -75,7 +75,7 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): self.cog.try_silence = AsyncMock() self.cog.try_get_tag = AsyncMock() - self.cog.try_run_eval = AsyncMock() + self.cog.try_run_fixed_codeblock = AsyncMock() error = errors.CommandNotFound() @@ -83,7 +83,7 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): self.cog.try_silence.assert_not_awaited() self.cog.try_get_tag.assert_not_awaited() - self.cog.try_run_eval.assert_not_awaited() + self.cog.try_run_fixed_codeblock.assert_not_awaited() self.ctx.send.assert_not_awaited() async def test_error_handler_user_input_error(self): -- cgit v1.2.3 From 03614c313341497e61c45bbb2a364b969d2bb163 Mon Sep 17 00:00:00 2001 From: Ibrahim Date: Sun, 26 Feb 2023 17:44:47 +0530 Subject: Implement reviews + used both `discord.User` and `discord.Member` in typehinting as `InteractionResponse.user` returns `discord.User` object + removed `ErrorHandler()._can_run` + edited `try_get_tag` to use `bot.can_run` + removed `/tag list` + change `/tag get ` to `/tag ` + remove redundant `GUILD_ID` in `tags.py` + using `discord.abc.Messageable` because `ctx.channel` returns that instead of `Channel` Object --- bot/exts/backend/error_handler.py | 50 ++++++++++------------------ bot/exts/info/tags.py | 36 +++++--------------- tests/bot/exts/backend/test_error_handler.py | 10 +++--- 3 files changed, 32 insertions(+), 64 deletions(-) (limited to 'tests') diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 839d882de..e274e337a 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -1,8 +1,7 @@ import copy import difflib -import typing as t -from discord import Embed, Interaction, utils +from discord import Embed, Member from discord.ext.commands import ChannelNotFound, Cog, Context, TextChannelConverter, VoiceChannelConverter, errors from pydis_core.site_api import ResponseCodeError from sentry_sdk import push_scope @@ -22,22 +21,6 @@ class ErrorHandler(Cog): def __init__(self, bot: Bot): self.bot = bot - @staticmethod - async def _can_run(ctx: Context) -> bool: - """ - Add checks for the `get_command_ctx` function here. - - The command code style is copied from discord.ext.commands.Command.can_run itself. - Append checks in the checks list. - """ - checks = [] - predicates = checks - if not predicates: - # Since we have no checks, then we just return True. - return True - - return await utils.async_all(predicate(ctx) for predicate in predicates) - def _get_error_embed(self, title: str, body: str) -> Embed: """Return an embed that contains the exception.""" return Embed( @@ -176,7 +159,7 @@ class ErrorHandler(Cog): return True return False - async def try_get_tag(self, ctx: Context, can_run: t.Callable[[Interaction], bool] = False) -> None: + async def try_get_tag(self, ctx: Context) -> None: """ Attempt to display a tag by interpreting the command name as a tag name. @@ -189,25 +172,28 @@ class ErrorHandler(Cog): log.debug("Not attempting to parse message as a tag as could not find `Tags` cog.") return tags_get_command = tags_cog.get_command_ctx - can_run = can_run if can_run else self._can_run - ctx.invoked_from_error_handler = True + maybe_tag_name = ctx.invoked_with + if not maybe_tag_name or not isinstance(ctx.author, Member): + return - log_msg = "Cancelling attempt to fall back to a tag due to failed checks." + ctx.invoked_from_error_handler = True try: - if not await can_run(ctx): - log.debug(log_msg) + if not await self.bot.can_run(ctx): + log.debug("Cancelling attempt to fall back to a tag due to failed checks.") return - except errors.CommandError as tag_error: - log.debug(log_msg) - await self.on_command_error(ctx, tag_error) - return - if await tags_get_command(ctx, ctx.message.content): - return + if await tags_get_command(ctx, maybe_tag_name): + return - if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): - await self.send_command_suggestion(ctx, ctx.invoked_with) + if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): + await self.send_command_suggestion(ctx, maybe_tag_name) + except Exception as err: + log.debug("Error while attempting to invoke tag fallback.") + if isinstance(err, errors.CommandError): + await self.on_command_error(ctx, err) + else: + await self.on_command_error(ctx, errors.CommandInvokeError(err)) async def try_run_eval(self, ctx: Context) -> bool: """ diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 60f730586..0c244ff37 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -8,8 +8,8 @@ from typing import Literal, NamedTuple, Optional, Union import discord import frontmatter -from discord import Embed, Interaction, Member, app_commands -from discord.ext.commands import Cog +from discord import Embed, Interaction, Member, User, app_commands +from discord.ext.commands import Cog, Context from bot import constants from bot.bot import Bot @@ -27,8 +27,6 @@ TEST_CHANNELS = ( REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE) FOOTER_TEXT = f"To show a tag, type {constants.Bot.prefix}tags ." -GUILD_ID = constants.Guild.id - class COOLDOWN(enum.Enum): """Sentinel value to signal that a tag is on cooldown.""" @@ -93,7 +91,7 @@ class Tag: embed.description = self.content return embed - def accessible_by(self, member: discord.Member) -> bool: + def accessible_by(self, member: Member | User) -> bool: """Check whether `member` can access the tag.""" return bool( not self._restricted_to @@ -141,8 +139,6 @@ class Tags(Cog): self.tags: dict[TagIdentifier, Tag] = {} self.initialize_tags() - tag_group = app_commands.Group(name="tag", description="...") - def initialize_tags(self) -> None: """Load all tags from resources into `self.tags`.""" base_path = Path("bot", "resources", "tags") @@ -188,8 +184,8 @@ class Tags(Cog): async def get_tag_embed( self, - author: discord.Member, - channel: discord.TextChannel | discord.Thread, + author: Member | User, + channel: discord.abc.Messageable, tag_identifier: TagIdentifier, ) -> Optional[Union[Embed, Literal[COOLDOWN.obj]]]: """ @@ -244,7 +240,7 @@ class Tags(Cog): description=suggested_tags_text ) - def accessible_tags(self, user: Member) -> list[str]: + def accessible_tags(self, user: Member | User) -> list[str]: """Return a formatted list of tags that are accessible by `user`; groups first, and alphabetically sorted.""" def tag_sort_key(tag_item: tuple[TagIdentifier, Tag]) -> str: group, name = tag_item[0] @@ -278,7 +274,7 @@ class Tags(Cog): return result_lines - def accessible_tags_in_group(self, group: str, user: discord.Member) -> list[str]: + def accessible_tags_in_group(self, group: str, user: Member | User) -> list[str]: """Return a formatted list of tags in `group`, that are accessible by `user`.""" return sorted( f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier}" @@ -288,7 +284,7 @@ class Tags(Cog): async def get_command_ctx( self, - ctx: discord.Context, + ctx: Context, name: str ) -> bool: """Made specifically for `error_handler.py`, See `get_command` for more info.""" @@ -315,7 +311,7 @@ class Tags(Cog): # A valid tag was found and was either sent, or is on cooldown return True - @tag_group.command(name="get") + @app_commands.command(name="tag") async def get_command(self, interaction: Interaction, *, name: Optional[str]) -> bool: """ If a single argument matching a group name is given, list all accessible tags from that group @@ -382,20 +378,6 @@ class Tags(Cog): ] return choices[:25] if len(choices) > 25 else choices - @tag_group.command(name="list") - async def list_command(self, interaction: Interaction) -> bool: - """Lists all accessible tags.""" - if self.tags: - await LinePaginator.paginate( - self.accessible_tags(interaction.user), - interaction, - Embed(title="Available tags"), - **self.PAGINATOR_DEFAULTS, - ) - else: - await interaction.response.send_message(embed=Embed(description="**There are no tags!**")) - return True - async def setup(bot: Bot) -> None: """Load the Tags cog.""" diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index 14e7a4125..533eaeda6 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -350,16 +350,16 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): async def test_try_get_tag_no_permissions(self): """Test how to handle checks failing.""" - self.tag.get_command.can_run = AsyncMock(return_value=False) + self.bot.can_run = AsyncMock(return_value=False) self.ctx.invoked_with = "foo" - self.assertIsNone(await self.cog.try_get_tag(self.ctx, AsyncMock(return_value=False))) + self.assertIsNone(await self.cog.try_get_tag(self.ctx)) async def test_try_get_tag_command_error(self): """Should call `on_command_error` when `CommandError` raised.""" err = errors.CommandError() - self.tag.get_command.can_run = AsyncMock(side_effect=err) + self.bot.can_run = AsyncMock(side_effect=err) self.cog.on_command_error = AsyncMock() - self.assertIsNone(await self.cog.try_get_tag(self.ctx, AsyncMock(side_effect=err))) + self.assertIsNone(await self.cog.try_get_tag(self.ctx)) self.cog.on_command_error.assert_awaited_once_with(self.ctx, err) async def test_dont_call_suggestion_tag_sent(self): @@ -385,7 +385,7 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): async def test_call_suggestion(self): """Should call command suggestion if user is not a mod.""" self.ctx.invoked_with = "foo" - self.ctx.invoke = AsyncMock(return_value=False) + self.tag.get_command_ctx = AsyncMock(return_value=False) self.cog.send_command_suggestion = AsyncMock() await self.cog.try_get_tag(self.ctx) -- cgit v1.2.3 From bec7980bf02246c7572a0a20acf6768337535613 Mon Sep 17 00:00:00 2001 From: shtlrs Date: Tue, 28 Feb 2023 16:16:46 +0100 Subject: add the `flags` key to the member_data dictionary The value 2 represents the `COMPLETED_ONBOARDING` flag, found here https://discord.com/developers/docs/resources/guild#guild-member-object-guild-member-flags --- tests/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 0d955b521..1a71f210a 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -222,7 +222,7 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): # Create a Member instance to get a realistic Mock of `discord.Member` -member_data = {'user': 'lemon', 'roles': [1]} +member_data = {'user': 'lemon', 'roles': [1], 'flags': 2} state_mock = unittest.mock.MagicMock() member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock) -- cgit v1.2.3 From bf8f8f4c1f9522a942c88ca69a2d48427d2bbc28 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Mar 2023 21:00:53 +0000 Subject: Bump markdownify from 0.6.1 to 0.11.6 (#2429) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: wookie184 --- bot/exts/info/doc/_markdown.py | 12 ++++++++++-- poetry.lock | 12 ++++++------ pyproject.toml | 6 +----- tests/bot/exts/info/doc/test_parsing.py | 23 +++++++++++++++++++++++ 4 files changed, 40 insertions(+), 13 deletions(-) (limited to 'tests') diff --git a/bot/exts/info/doc/_markdown.py b/bot/exts/info/doc/_markdown.py index 1b7d8232b..315adda66 100644 --- a/bot/exts/info/doc/_markdown.py +++ b/bot/exts/info/doc/_markdown.py @@ -1,10 +1,14 @@ +import re from urllib.parse import urljoin +import markdownify from bs4.element import PageElement -from markdownify import MarkdownConverter +# See https://github.com/matthewwithanm/python-markdownify/issues/31 +markdownify.whitespace_re = re.compile(r"[\r\n\s\t ]+") -class DocMarkdownConverter(MarkdownConverter): + +class DocMarkdownConverter(markdownify.MarkdownConverter): """Subclass markdownify's MarkdownCoverter to provide custom conversion methods.""" def __init__(self, *, page_url: str, **options): @@ -56,3 +60,7 @@ class DocMarkdownConverter(MarkdownConverter): if parent is not None and parent.name == "li": return f"{text}\n" return super().convert_p(el, text, convert_as_inline) + + def convert_hr(self, el: PageElement, text: str, convert_as_inline: bool) -> str: + """Ignore `hr` tag.""" + return "" diff --git a/poetry.lock b/poetry.lock index 8a718ab82..de777c828 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1041,19 +1041,19 @@ source = ["Cython (>=0.29.7)"] [[package]] name = "markdownify" -version = "0.6.1" +version = "0.11.6" description = "Convert HTML to markdown." category = "main" optional = false python-versions = "*" files = [ - {file = "markdownify-0.6.1-py3-none-any.whl", hash = "sha256:7489fd5c601536996a376c4afbcd1dd034db7690af807120681461e82fbc0acc"}, - {file = "markdownify-0.6.1.tar.gz", hash = "sha256:31d7c13ac2ada8bfc7535a25fee6622ca720e1b5f2d4a9cbc429d167c21f886d"}, + {file = "markdownify-0.11.6-py3-none-any.whl", hash = "sha256:ba35fe289d5e9073bcd7d2cad629278fe25f1a93741fcdc0bfb4f009076d8324"}, + {file = "markdownify-0.11.6.tar.gz", hash = "sha256:009b240e0c9f4c8eaf1d085625dcd4011e12f0f8cec55dedf9ea6f7655e49bfe"}, ] [package.dependencies] -beautifulsoup4 = "*" -six = "*" +beautifulsoup4 = ">=4.9,<5" +six = ">=1.15,<2" [[package]] name = "mccabe" @@ -2317,4 +2317,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "3.10.*" -content-hash = "68bfdf2115a5242df097155a2660a1c0276cf25b4785bdb761580bd35b77383c" +content-hash = "4b3549e9e47535d1fea6015a0f7ebf056a42e4d27e766583ccd8b59ebe8297d6" diff --git a/pyproject.toml b/pyproject.toml index 71981e8d0..11e99ecbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,11 +22,7 @@ deepdiff = "6.2.1" emoji = "2.2.0" feedparser = "6.0.10" lxml = "4.9.1" - -# Must be kept on this version unless doc command output is fixed -# See https://github.com/python-discord/bot/pull/2156 -markdownify = "0.6.1" - +markdownify = "0.11.6" more-itertools = "9.0.0" python-dateutil = "2.8.2" python-frontmatter = "1.0.0" diff --git a/tests/bot/exts/info/doc/test_parsing.py b/tests/bot/exts/info/doc/test_parsing.py index 1663d8491..d2105a53c 100644 --- a/tests/bot/exts/info/doc/test_parsing.py +++ b/tests/bot/exts/info/doc/test_parsing.py @@ -1,6 +1,7 @@ from unittest import TestCase from bot.exts.info.doc import _parsing as parsing +from bot.exts.info.doc._markdown import DocMarkdownConverter class SignatureSplitter(TestCase): @@ -64,3 +65,25 @@ class SignatureSplitter(TestCase): for input_string, expected_output in test_cases: with self.subTest(input_string=input_string): self.assertEqual(list(parsing._split_parameters(input_string)), expected_output) + + +class MarkdownConverterTest(TestCase): + def test_hr_removed(self): + test_cases = ( + ('
', ""), + ("
", ""), + ) + self._run_tests(test_cases) + + def test_whitespace_removed(self): + test_cases = ( + ("lines\nof\ntext", "lines of text"), + ("lines\n\nof\n\ntext", "lines of text"), + ) + self._run_tests(test_cases) + + def _run_tests(self, test_cases: tuple[tuple[str, str], ...]): + for input_string, expected_output in test_cases: + with self.subTest(input_string=input_string): + d = DocMarkdownConverter(page_url="https://example.com") + self.assertEqual(d.convert(input_string), expected_output) -- cgit v1.2.3