diff options
author | 2019-10-04 22:23:47 -0600 | |
---|---|---|
committer | 2019-10-04 22:23:47 -0600 | |
commit | 220fe0ff09675e7225b1993d7ad4b81b0eccf14e (patch) | |
tree | 6210057fb1be05cd376336552f74844d0b8c52cb | |
parent | Fix error when symbol_id cannot be found in doc HTML (diff) | |
parent | Merge pull request #441 from python-discord/add-role-info-command (diff) |
Merge branch 'master' into doc-fix
-rw-r--r-- | bot/cogs/information.py | 50 | ||||
-rw-r--r-- | bot/cogs/watchchannels/bigbrother.py | 22 | ||||
-rw-r--r-- | bot/cogs/watchchannels/talentpool.py | 19 | ||||
-rw-r--r-- | bot/converters.py | 44 | ||||
-rw-r--r-- | tests/cogs/test_information.py | 48 | ||||
-rw-r--r-- | tests/test_converters.py | 78 |
6 files changed, 258 insertions, 3 deletions
diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 60aec6219..1afb37103 100644 --- 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 @@ class Information(Cog): 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.""" diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index e191c2dbc..3eba9862f 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -70,7 +70,27 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): if response is not None: self.watched_users[user.id] = response - await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother.") + msg = f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother." + + history = await self.bot.api_client.get( + self.api_endpoint, + params={ + "user__id": str(user.id), + "active": "false", + 'type': 'watch', + 'ordering': '-inserted_at' + } + ) + + if len(history) > 1: + total = f"({len(history) // 2} previous infractions in total)" + end_reason = history[0]["reason"] + start_reason = f"Watched: {history[1]['reason']}" + msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" + else: + msg = ":x: Failed to post the infraction: response was empty." + + await ctx.send(msg) @bigbrother_group.command(name='unwatch', aliases=('uw',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 4a23902d5..176c6f760 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -93,7 +93,24 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): resp.raise_for_status() self.watched_users[user.id] = response_data - await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel") + msg = f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel" + + history = await self.bot.api_client.get( + self.api_endpoint, + params={ + "user__id": str(user.id), + "active": "false", + "ordering": "-inserted_at" + } + ) + + if history: + total = f"({len(history)} previous nominations in total)" + start_reason = f"Watched: {history[0]['reason']}" + end_reason = f"Unwatched: {history[0]['end_reason']}" + msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" + + await ctx.send(msg) @nomination_group.command(name='history', aliases=('info', 'search')) @with_role(Roles.owner, Roles.admin, Roles.moderator) diff --git a/bot/converters.py b/bot/converters.py index 6d6453486..cf0496541 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -4,6 +4,8 @@ from datetime import datetime from ssl import CertificateError from typing import Union +import dateutil.parser +import dateutil.tz import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta @@ -215,3 +217,45 @@ class Duration(Converter): now = datetime.utcnow() return now + delta + + +class ISODateTime(Converter): + """Converts an ISO-8601 datetime string into a datetime.datetime.""" + + async def convert(self, ctx: Context, datetime_string: str) -> datetime: + """ + Converts a ISO-8601 `datetime_string` into a `datetime.datetime` object. + + The converter is flexible in the formats it accepts, as it uses the `isoparse` method of + `dateutil.parser`. In general, it accepts datetime strings that start with a date, + optionally followed by a time. Specifying a timezone offset in the datetime string is + supported, but the `datetime` object will be converted to UTC and will be returned without + `tzinfo` as a timezone-unaware `datetime` object. + + See: https://dateutil.readthedocs.io/en/stable/parser.html#dateutil.parser.isoparse + + Formats that are guaranteed to be valid by our tests are: + + - `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` + - `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` + - `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` + - `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` + - `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` + - `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` + - `YYYY-mm-dd` + - `YYYY-mm` + - `YYYY` + + Note: ISO-8601 specifies a `T` as the separator between the date and the time part of the + datetime string. The converter accepts both a `T` and a single space character. + """ + try: + dt = dateutil.parser.isoparse(datetime_string) + except ValueError: + raise BadArgument(f"`{datetime_string}` is not a valid ISO-8601 datetime string") + + if dt.tzinfo: + dt = dt.astimezone(dateutil.tz.UTC) + dt = dt.replace(tzinfo=None) + + return dt diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py index 85b2d092e..184bd2595 100644 --- a/tests/cogs/test_information.py +++ b/tests/cogs/test_information.py @@ -8,6 +8,8 @@ import pytest 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') diff --git a/tests/test_converters.py b/tests/test_converters.py index 35fc5d88e..f69995ec6 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -8,6 +8,7 @@ from discord.ext.commands import BadArgument from bot.converters import ( Duration, + ISODateTime, TagContentConverter, TagNameConverter, ValidPythonIdentifier, @@ -184,3 +185,80 @@ def test_duration_converter_for_invalid(duration: str): converter = Duration() with pytest.raises(BadArgument, match=f'`{duration}` is not a valid duration string.'): asyncio.run(converter.convert(None, duration)) + + + ("datetime_string", "expected_dt"), + ( + + # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` + ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` + ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` + ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` + ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` + ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` + ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)), + ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)), + + # `YYYY-mm-dd` + ('2019-04-01', datetime.datetime(2019, 4, 1)), + + # `YYYY-mm` + ('2019-02-01', datetime.datetime(2019, 2, 1)), + + # `YYYY` + ('2025', datetime.datetime(2025, 1, 1)), + ), +) +def test_isodatetime_converter_for_valid(datetime_string: str, expected_dt: datetime.datetime): + converter = ISODateTime() + converted_dt = asyncio.run(converter.convert(None, datetime_string)) + assert converted_dt.tzinfo is None + assert converted_dt == expected_dt + + + ("datetime_string"), + ( + # Make sure it doesn't interfere with the Duration converter + ('1Y'), + ('1d'), + ('1H'), + + # Check if it fails when only providing the optional time part + ('10:10:10'), + ('10:00'), + + # Invalid date format + ('19-01-01'), + + # Other non-valid strings + ('fisk the tag master'), + ), +) +def test_isodatetime_converter_for_invalid(datetime_string: str): + converter = ISODateTime() + with pytest.raises( + BadArgument, + match=f"`{datetime_string}` is not a valid ISO-8601 datetime string", + ): + asyncio.run(converter.convert(None, datetime_string)) |