Repository: python-discord/bot

Issue:
Implement a !role command
Currently, we have a `!roles` command, which lists out all the roles. However, it would also be useful to have a `!role <role>` command, to get more info on a certain role.

**Implementation details**
- Ability to get info on multiple roles? `!role <role_1> <role_2>`
- Info that would be helpful:
    - Role ID
    - Role Name
    - Role Color as hex/hsv
    - Is role mentionable
    - Number of members with the role? (Blacklist certain high volume, easily checked ones like `@Developers`)
- Restrict to core developers and moderator+



Discussion:

Resolving patch:
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -1,7 +1,9 @@
+import colorsys
 import logging
 import textwrap
+import typing
 
-from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel
+from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils
 from discord.ext.commands import Bot, Cog, Context, command
 
 from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES
@@ -42,6 +44,52 @@ async def roles_info(self, ctx: Context) -> None:
 
         await ctx.send(embed=embed)
 
+    @with_role(*MODERATION_ROLES)
+    @command(name="role")
+    async def role_info(self, ctx: Context, *roles: typing.Union[Role, str]) -> None:
+        """
+        Return information on a role or list of roles.
+
+        To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks.
+        """
+        parsed_roles = []
+
+        for role_name in roles:
+            if isinstance(role_name, Role):
+                # Role conversion has already succeeded
+                parsed_roles.append(role_name)
+                continue
+
+            role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles)
+
+            if not role:
+                await ctx.send(f":x: Could not convert `{role_name}` to a role")
+                continue
+
+            parsed_roles.append(role)
+
+        for role in parsed_roles:
+            embed = Embed(
+                title=f"{role.name} info",
+                colour=role.colour,
+            )
+
+            embed.add_field(name="ID", value=role.id, inline=True)
+
+            embed.add_field(name="Colour (RGB)", value=f"#{role.colour.value:0>6x}", inline=True)
+
+            h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb())
+
+            embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v}", inline=True)
+
+            embed.add_field(name="Member count", value=len(role.members), inline=True)
+
+            embed.add_field(name="Position", value=role.position)
+
+            embed.add_field(name="Permission code", value=role.permissions.value, inline=True)
+
+            await ctx.send(embed=embed)
+
     @command(name="server", aliases=["server_info", "guild", "guild_info"])
     async def server_info(self, ctx: Context) -> None:
         """Returns an embed full of server information."""


Test patch:
diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py
--- a/tests/cogs/test_information.py
+++ b/tests/cogs/test_information.py
@@ -8,6 +8,8 @@
 from discord import (
     CategoryChannel,
     Colour,
+    Permissions,
+    Role,
     TextChannel,
     VoiceChannel,
 )
@@ -66,6 +68,52 @@ def test_roles_info_command(cog, ctx):
     assert embed.footer.text == "Total roles: 1"
 
 
+def test_role_info_command(cog, ctx):
+    dummy_role = MagicMock(spec=Role)
+    dummy_role.name = "Dummy"
+    dummy_role.colour = Colour.blurple()
+    dummy_role.id = 112233445566778899
+    dummy_role.position = 10
+    dummy_role.permissions = Permissions(0)
+    dummy_role.members = [ctx.author]
+
+    admin_role = MagicMock(spec=Role)
+    admin_role.name = "Admin"
+    admin_role.colour = Colour.red()
+    admin_role.id = 998877665544332211
+    admin_role.position = 3
+    admin_role.permissions = Permissions(0)
+    admin_role.members = [ctx.author]
+
+    ctx.guild.roles = [dummy_role, admin_role]
+
+    cog.role_info.can_run = AsyncMock()
+    cog.role_info.can_run.return_value = True
+
+    coroutine = cog.role_info.callback(cog, ctx, dummy_role, admin_role)
+
+    assert asyncio.run(coroutine) is None
+
+    assert ctx.send.call_count == 2
+
+    (_, dummy_kwargs), (_, admin_kwargs) = ctx.send.call_args_list
+
+    dummy_embed = dummy_kwargs["embed"]
+    admin_embed = admin_kwargs["embed"]
+
+    assert dummy_embed.title == "Dummy info"
+    assert dummy_embed.colour == Colour.blurple()
+
+    assert dummy_embed.fields[0].value == str(dummy_role.id)
+    assert dummy_embed.fields[1].value == f"#{dummy_role.colour.value:0>6x}"
+    assert dummy_embed.fields[2].value == "0.63 0.48 218"
+    assert dummy_embed.fields[3].value == "1"
+    assert dummy_embed.fields[4].value == "10"
+    assert dummy_embed.fields[5].value == "0"
+
+    assert admin_embed.title == "Admin info"
+    assert admin_embed.colour == Colour.red()
+
 # There is no argument passed in here that we can use to test,
 # so the return value would change constantly.
 @patch('bot.cogs.information.time_since')