Repository: python-discord/bot

Issue:
Extended user command
I'd like to propose some additions to !user.

### Public flags

Discord have made the flags that a user has (e.g. staff, partner, hypesquad, etc.) available to bots through the `public_flags` parameter of a user object. We get this data since we now use discord.py 1.4.0a, see [`discord.PublicUserFlags`](https://discordpy.readthedocs.io/en/latest/api.html#discord.PublicUserFlags).

```py
In [7]: print(author.public_flags.partner)
True
```

Using this we can have emojis in the `!user` command which mimic the desktop client and show the icons for each flag.

### Device status

We could represent what devices a user is currently online on using the `web_status`, `desktop_status` and `mobile_status` of the user object.

```py
In [3]: print(author.web_status)
   ...: print(author.desktop_status)
   ...: print(author.mobile_status)
   ...: 
offline
online
online
```

If we do not want all devices I'd like to propose adding some indication of the status of a user in the `!user` command.

I'd be happy to implement these features.


Discussion:
Something like this would be nice:
![image](https://user-images.githubusercontent.com/41782385/87447212-e3be9480-c62c-11ea-8df7-4333f89bf0f2.png)
Not a big fan of the specific emojis I tried out though. If you go through with this, please update the custom status code as well. In my screenshot above I just did a quick check for any `CustomActivity` in `user.activities`, then set it as the status

Resolving patch:
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -4,9 +4,9 @@
 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 @@
 
 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 @@ async def create_user_embed(self, ctx: Context, user: Member) -> Embed:
         # 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 @@ async def basic_user_infraction_counts(self, member: Member) -> str:
         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 @@ async def expanded_user_infraction_counts(self, member: Member) -> str:
             }
         )
 
-        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 @@ async def expanded_user_infraction_counts(self, member: Member) -> str:
 
                 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 @@ async def user_nomination_counts(self, member: Member) -> str:
             }
         )
 
-        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
--- 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


Test patch:
diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py
--- a/tests/bot/cogs/test_information.py
+++ b/tests/bot/cogs/test_information.py
@@ -215,10 +215,10 @@ def _method_subtests(self, method, test_values, default_header):
             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 @@ def test_basic_user_infraction_counts_returns_correct_strings(self):
             },
         )
 
-        header = ["**Infractions**"]
+        header = "Infractions"
 
         self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header)
 
@@ -258,7 +258,7 @@ def test_expanded_user_infraction_counts_returns_correct_strings(self):
         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 @@ def test_expanded_user_infraction_counts_returns_correct_strings(self):
             },
         )
 
-        header = ["**Infractions**"]
+        header = "Infractions"
 
         self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header)
 
@@ -313,15 +313,15 @@ def test_user_nomination_counts_returns_correct_strings(self):
         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 @@ def test_user_nomination_counts_returns_correct_strings(self):
 
         )
 
-        header = ["**Nominations**"]
+        header = "Nominations"
 
         self._method_subtests(self.cog.user_nomination_counts, test_values, header)
 
@@ -350,7 +350,10 @@ def setUp(self):
         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 @@ def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_i
 
         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 @@ def test_create_user_embed_uses_nick_in_title_if_available(self):
 
         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 @@ def test_create_user_embed_ignores_everyone_role(self):
 
         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 @@ def test_create_user_embed_expanded_information_in_moderation_channels(self, nom
         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 @@ def test_create_user_embed_expanded_information_in_moderation_channels(self, nom
 
         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 @@ def test_create_user_embed_basic_information_outside_of_moderation_channels(self
         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 @@ def test_create_user_embed_basic_information_outside_of_moderation_channels(self
 
         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 @@ def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self):
 
         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 @@ def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self):
 
         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()