diff options
| author | 2019-10-08 12:32:52 +0200 | |
|---|---|---|
| committer | 2019-10-08 12:32:52 +0200 | |
| commit | 54063b1f8fe1c90c3e0893cdea99051e969c13ec (patch) | |
| tree | 377cb0bd50e85a265a56726a368c8a6419743ded | |
| parent | Only allow members currently in the guild to be warned (diff) | |
| parent | Merge pull request #498 from fiskenslakt/feat/attachment-spam-threshold (diff) | |
Merge branch 'master' into moderation-cleanup
Diffstat (limited to '')
| -rw-r--r-- | bot/cogs/doc.py | 3 | ||||
| -rw-r--r-- | bot/cogs/information.py | 50 | ||||
| -rw-r--r-- | bot/cogs/reddit.py | 39 | ||||
| -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-- | config-default.yml | 2 | ||||
| -rw-r--r-- | tests/cogs/test_information.py | 48 | ||||
| -rw-r--r-- | tests/test_converters.py | 78 | 
9 files changed, 286 insertions, 19 deletions
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index c9e6b3b91..0c5a8fce3 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -207,6 +207,9 @@ class Doc(commands.Cog):          symbol_heading = soup.find(id=symbol_id)          signature_buffer = [] +        if symbol_heading is None: +            return None +          # Traverse the tags of the signature header and ignore any          # unwanted symbols from it. Add all of it to a temporary buffer.          for tag in symbol_heading.strings: 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/reddit.py b/bot/cogs/reddit.py index 63a57c5c6..6880aab85 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -21,6 +21,7 @@ class Reddit(Cog):      HEADERS = {"User-Agent": "Discord Bot: PythonDiscord (https://pythondiscord.com/)"}      URL = "https://www.reddit.com" +    MAX_FETCH_RETRIES = 3      def __init__(self, bot: Bot):          self.bot = bot @@ -42,16 +43,23 @@ class Reddit(Cog):          if params is None:              params = {} -        response = await self.bot.http_session.get( -            url=f"{self.URL}/{route}.json", -            headers=self.HEADERS, -            params=params -        ) +        url = f"{self.URL}/{route}.json" +        for _ in range(self.MAX_FETCH_RETRIES): +            response = await self.bot.http_session.get( +                url=url, +                headers=self.HEADERS, +                params=params +            ) +            if response.status == 200 and response.content_type == 'application/json': +                # Got appropriate response - process and return. +                content = await response.json() +                posts = content["data"]["children"] +                return posts[:amount] -        content = await response.json() -        posts = content["data"]["children"] +            await asyncio.sleep(3) -        return posts[:amount] +        log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") +        return list()  # Failed to get appropriate response within allowed number of retries.      async def send_top_posts(          self, channel: TextChannel, subreddit: Subreddit, content: str = None, time: str = "all" @@ -62,13 +70,14 @@ class Reddit(Cog):          embed.description = ""          # Get the posts -        posts = await self.fetch_posts( -            route=f"{subreddit}/top", -            amount=5, -            params={ -                "t": time -            } -        ) +        async with channel.typing(): +            posts = await self.fetch_posts( +                route=f"{subreddit}/top", +                amount=5, +                params={ +                    "t": time +                } +            )          if not posts:              embed.title = random.choice(ERROR_REPLIES) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index bfbb86a97..c516508ca 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -68,7 +68,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/config-default.yml b/config-default.yml index 38b26f64f..0dac9bf9f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -282,7 +282,7 @@ anti_spam:      rules:          attachments:              interval: 10 -            max: 3 +            max: 9          burst:              interval: 10 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))  |