diff options
| author | 2020-08-23 16:18:28 +0200 | |
|---|---|---|
| committer | 2020-08-23 16:18:28 +0200 | |
| commit | f97a5a62ab2f8e9af8a426b1ac6d9bf4896a75da (patch) | |
| tree | cdffe126e6f953775b07edfb572eab0931bfef75 | |
| parent | Disable raw commands (diff) | |
| parent | Merge branch 'master' into joseph/enhanced-user-command (diff) | |
Merge pull request #1105 from python-discord/joseph/enhanced-user-command
Enhanced user command
| -rw-r--r-- | bot/cogs/information.py | 113 | ||||
| -rw-r--r-- | bot/constants.py | 11 | ||||
| -rw-r--r-- | config-default.yml | 11 | ||||
| -rw-r--r-- | tests/bot/cogs/test_information.py | 87 | 
4 files changed, 154 insertions, 68 deletions
| diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 2d87866fb..55ecb2836 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -4,9 +4,9 @@ import pprint  import textwrap  from collections import Counter, defaultdict  from string import Template -from typing import Any, Mapping, Optional, Union +from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils +from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils  from discord.abc import GuildChannel  from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group  from discord.utils import escape_markdown @@ -20,6 +20,12 @@ from bot.utils.time import time_since  log = logging.getLogger(__name__) +STATUS_EMOTES = { +    Status.offline: constants.Emojis.status_offline, +    Status.dnd: constants.Emojis.status_dnd, +    Status.idle: constants.Emojis.status_idle +} +  class Information(Cog):      """A cog with commands for generating embeds with server info, such as server stats and user info.""" @@ -211,53 +217,88 @@ class Information(Cog):          # Custom status          custom_status = ''          for activity in user.activities: -            # Check activity.state for None value if user has a custom status set -            # This guards against a custom status with an emoji but no text, which will cause -            # escape_markdown to raise an exception -            # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class -            if activity.name == 'Custom Status' and activity.state: -                state = escape_markdown(activity.state) -                custom_status = f'Status: {state}\n' +            if isinstance(activity, CustomActivity): +                state = "" + +                if activity.name: +                    state = escape_markdown(activity.name) + +                emoji = "" +                if activity.emoji: +                    # If an emoji is unicode use the emoji, else write the emote like :abc: +                    if not activity.emoji.id: +                        emoji += activity.emoji.name + " " +                    else: +                        emoji += f"`:{activity.emoji.name}:` " + +                custom_status = f'Status: {emoji}{state}\n'          name = str(user)          if user.nick:              name = f"{user.nick} ({name})" +        badges = [] + +        for badge, is_set in user.public_flags: +            if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)): +                badges.append(emoji) +          joined = time_since(user.joined_at, max_units=3)          roles = ", ".join(role.mention for role in user.roles[1:]) -        description = [ -            textwrap.dedent(f""" -                **User Information** -                Created: {created} -                Profile: {user.mention} -                ID: {user.id} -                {custom_status} -                **Member Information** -                Joined: {joined} -                Roles: {roles or None} -            """).strip() +        desktop_status = STATUS_EMOTES.get(user.desktop_status, constants.Emojis.status_online) +        web_status = STATUS_EMOTES.get(user.web_status, constants.Emojis.status_online) +        mobile_status = STATUS_EMOTES.get(user.mobile_status, constants.Emojis.status_online) + +        fields = [ +            ( +                "User information", +                textwrap.dedent(f""" +                    Created: {created} +                    Profile: {user.mention} +                    ID: {user.id} +                    {custom_status} +                """).strip() +            ), +            ( +                "Member information", +                textwrap.dedent(f""" +                    Joined: {joined} +                    Roles: {roles or None} +                """).strip() +            ), +            ( +                "Status", +                textwrap.dedent(f""" +                    {desktop_status} Desktop +                    {web_status} Web +                    {mobile_status} Mobile +                """).strip() +            )          ]          # Show more verbose output in moderation channels for infractions and nominations          if ctx.channel.id in constants.MODERATION_CHANNELS: -            description.append(await self.expanded_user_infraction_counts(user)) -            description.append(await self.user_nomination_counts(user)) +            fields.append(await self.expanded_user_infraction_counts(user)) +            fields.append(await self.user_nomination_counts(user))          else: -            description.append(await self.basic_user_infraction_counts(user)) +            fields.append(await self.basic_user_infraction_counts(user))          # Let's build the embed now          embed = Embed(              title=name, -            description="\n\n".join(description) +            description=" ".join(badges)          ) +        for field_name, field_content in fields: +            embed.add_field(name=field_name, value=field_content, inline=False) +          embed.set_thumbnail(url=user.avatar_url_as(static_format="png"))          embed.colour = user.top_role.colour if roles else Colour.blurple()          return embed -    async def basic_user_infraction_counts(self, member: Member) -> str: +    async def basic_user_infraction_counts(self, member: Member) -> Tuple[str, str]:          """Gets the total and active infraction counts for the given `member`."""          infractions = await self.bot.api_client.get(              'bot/infractions', @@ -270,11 +311,11 @@ class Information(Cog):          total_infractions = len(infractions)          active_infractions = sum(infraction['active'] for infraction in infractions) -        infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" +        infraction_output = f"Total: {total_infractions}\nActive: {active_infractions}" -        return infraction_output +        return "Infractions", infraction_output -    async def expanded_user_infraction_counts(self, member: Member) -> str: +    async def expanded_user_infraction_counts(self, member: Member) -> Tuple[str, str]:          """          Gets expanded infraction counts for the given `member`. @@ -288,9 +329,9 @@ class Information(Cog):              }          ) -        infraction_output = ["**Infractions**"] +        infraction_output = []          if not infractions: -            infraction_output.append("This user has never received an infraction.") +            infraction_output.append("No infractions")          else:              # Count infractions split by `type` and `active` status for this user              infraction_types = set() @@ -313,9 +354,9 @@ class Information(Cog):                  infraction_output.append(line) -        return "\n".join(infraction_output) +        return "Infractions", "\n".join(infraction_output) -    async def user_nomination_counts(self, member: Member) -> str: +    async def user_nomination_counts(self, member: Member) -> Tuple[str, str]:          """Gets the active and historical nomination counts for the given `member`."""          nominations = await self.bot.api_client.get(              'bot/nominations', @@ -324,21 +365,21 @@ class Information(Cog):              }          ) -        output = ["**Nominations**"] +        output = []          if not nominations: -            output.append("This user has never been nominated.") +            output.append("No nominations")          else:              count = len(nominations)              is_currently_nominated = any(nomination["active"] for nomination in nominations)              nomination_noun = "nomination" if count == 1 else "nominations"              if is_currently_nominated: -                output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") +                output.append(f"This user is **currently** nominated\n({count} {nomination_noun} in total)")              else:                  output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") -        return "\n".join(output) +        return "Nominations", "\n".join(output)      def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str:          """Format a mapping to be readable to a human.""" diff --git a/bot/constants.py b/bot/constants.py index d01dcb0fc..f3db80279 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -268,6 +268,17 @@ class Emojis(metaclass=YAMLGetter):      status_idle: str      status_dnd: str +    badge_staff: str +    badge_partner: str +    badge_hypesquad: str +    badge_bug_hunter: str +    badge_hypesquad_bravery: str +    badge_hypesquad_brilliance: str +    badge_hypesquad_balance: str +    badge_early_supporter: str +    badge_bug_hunter_level_2: str +    badge_verified_bot_developer: str +      incident_actioned: str      incident_unactioned: str      incident_investigating: str diff --git a/config-default.yml b/config-default.yml index e3ba9fb05..8c0092e76 100644 --- a/config-default.yml +++ b/config-default.yml @@ -38,6 +38,17 @@ style:          status_dnd:     "<:status_dnd:470326272082313216>"          status_offline: "<:status_offline:470326266537705472>" +        badge_staff: "<:discord_staff:743882896498098226>" +        badge_partner: "<:partner:743882897131569323>" +        badge_hypesquad: "<:hypesquad_events:743882896892362873>" +        badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>" +        badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>" +        badge_hypesquad_brilliance: "<:hypesquad_brilliance:743882896938631248>" +        badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>" +        badge_early_supporter: "<:early_supporter:743882896909140058>" +        badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>" +        badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>" +          incident_actioned:      "<:incident_actioned:719645530128646266>"          incident_unactioned:    "<:incident_unactioned:719645583245180960>"          incident_investigating: "<:incident_investigating:719645658671480924>" diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 79c0e0ad3..77b0ddf17 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -215,10 +215,10 @@ class UserInfractionHelperMethodTests(unittest.TestCase):              with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines):                  self.bot.api_client.get.return_value = api_response -                expected_output = "\n".join(default_header + expected_lines) +                expected_output = "\n".join(expected_lines)                  actual_output = asyncio.run(method(self.member)) -                self.assertEqual(expected_output, actual_output) +                self.assertEqual((default_header, expected_output), actual_output)      def test_basic_user_infraction_counts_returns_correct_strings(self):          """The method should correctly list both the total and active number of non-hidden infractions.""" @@ -249,7 +249,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):              },          ) -        header = ["**Infractions**"] +        header = "Infractions"          self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) @@ -258,7 +258,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          test_values = (              {                  "api response": [], -                "expected_lines": ["This user has never received an infraction."], +                "expected_lines": ["No infractions"],              },              # Shows non-hidden inactive infraction as expected              { @@ -304,7 +304,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):              },          ) -        header = ["**Infractions**"] +        header = "Infractions"          self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) @@ -313,15 +313,15 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          test_values = (              {                  "api response": [], -                "expected_lines": ["This user has never been nominated."], +                "expected_lines": ["No nominations"],              },              {                  "api response": [{'active': True}], -                "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], +                "expected_lines": ["This user is **currently** nominated", "(1 nomination in total)"],              },              {                  "api response": [{'active': True}, {'active': False}], -                "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], +                "expected_lines": ["This user is **currently** nominated", "(2 nominations in total)"],              },              {                  "api response": [{'active': False}], @@ -334,7 +334,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          ) -        header = ["**Nominations**"] +        header = "Nominations"          self._method_subtests(self.cog.user_nomination_counts, test_values, header) @@ -350,7 +350,10 @@ class UserEmbedTests(unittest.TestCase):          self.bot.api_client.get = unittest.mock.AsyncMock()          self.cog = information.Information(self.bot) -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self):          """The embed should use the string representation of the user if they don't have a nick."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -362,7 +365,10 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(embed.title, "Mr. Hemlock") -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_uses_nick_in_title_if_available(self):          """The embed should use the nick if it's available."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -374,7 +380,10 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_ignores_everyone_role(self):          """Created `!user` embeds should not contain mention of the @everyone-role."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -386,8 +395,8 @@ class UserEmbedTests(unittest.TestCase):          embed = asyncio.run(self.cog.create_user_embed(ctx, user)) -        self.assertIn("&Admins", embed.description) -        self.assertNotIn("&Everyone", embed.description) +        self.assertIn("&Admins", embed.fields[1].value) +        self.assertNotIn("&Everyone", embed.fields[1].value)      @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock)      @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) @@ -398,8 +407,8 @@ class UserEmbedTests(unittest.TestCase):          moderators_role = helpers.MockRole(name='Moderators')          moderators_role.colour = 100 -        infraction_counts.return_value = "expanded infractions info" -        nomination_counts.return_value = "nomination info" +        infraction_counts.return_value = ("Infractions", "expanded infractions info") +        nomination_counts.return_value = ("Nominations", "nomination info")          user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role)          embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -409,20 +418,19 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(              textwrap.dedent(f""" -                **User Information**                  Created: {"1 year ago"}                  Profile: {user.mention}                  ID: {user.id} +            """).strip(), +            embed.fields[0].value +        ) -                **Member Information** +        self.assertEqual( +            textwrap.dedent(f"""                  Joined: {"1 year ago"}                  Roles: &Moderators - -                expanded infractions info - -                nomination info              """).strip(), -            embed.description +            embed.fields[1].value          )      @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) @@ -433,7 +441,7 @@ class UserEmbedTests(unittest.TestCase):          moderators_role = helpers.MockRole(name='Moderators')          moderators_role.colour = 100 -        infraction_counts.return_value = "basic infractions info" +        infraction_counts.return_value = ("Infractions", "basic infractions info")          user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role)          embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -442,21 +450,30 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(              textwrap.dedent(f""" -                **User Information**                  Created: {"1 year ago"}                  Profile: {user.mention}                  ID: {user.id} +            """).strip(), +            embed.fields[0].value +        ) -                **Member Information** +        self.assertEqual( +            textwrap.dedent(f"""                  Joined: {"1 year ago"}                  Roles: &Moderators - -                basic infractions info              """).strip(), -            embed.description +            embed.fields[1].value +        ) + +        self.assertEqual( +            "basic infractions info", +            embed.fields[3].value          ) -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self):          """The embed should be created with the colour of the top role, if a top role is available."""          ctx = helpers.MockContext() @@ -469,7 +486,10 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self):          """The embed should be created with a blurple colour if the user has no assigned roles."""          ctx = helpers.MockContext() @@ -479,7 +499,10 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(embed.colour, discord.Colour.blurple()) -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self):          """The embed thumbnail should be set to the user's avatar in `png` format."""          ctx = helpers.MockContext() | 
