diff options
author | 2019-10-04 22:20:53 -0600 | |
---|---|---|
committer | 2019-10-04 22:20:53 -0600 | |
commit | 7a5cec5c9c2b73448c7a09b0f390690dded0c0a9 (patch) | |
tree | 7faf72b382111ecb579ef6d95911c6c0c294c3d1 | |
parent | Merge branch 'add-role-info-command' of github.com:python-discord/bot into ad... (diff) | |
parent | Merge pull request #490 from python-discord/bb-previous-reason (diff) |
Merge branch 'master' into add-role-info-command
-rw-r--r-- | bot/cogs/alias.py | 5 | ||||
-rw-r--r-- | bot/cogs/doc.py | 8 | ||||
-rw-r--r-- | bot/cogs/moderation.py | 47 | ||||
-rw-r--r-- | bot/cogs/reddit.py | 39 | ||||
-rw-r--r-- | bot/cogs/site.py | 26 | ||||
-rw-r--r-- | bot/cogs/tags.py | 33 | ||||
-rw-r--r-- | bot/cogs/watchchannels/bigbrother.py | 22 | ||||
-rw-r--r-- | bot/cogs/watchchannels/talentpool.py | 19 | ||||
-rw-r--r-- | bot/cogs/watchchannels/watchchannel.py | 2 | ||||
-rw-r--r-- | bot/converters.py | 46 | ||||
-rw-r--r-- | tests/test_converters.py | 78 |
11 files changed, 288 insertions, 37 deletions
diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 80ff37983..0f49a400c 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -53,6 +53,11 @@ class Alias (Cog): """Alias for invoking <prefix>site resources.""" await self.invoke(ctx, "site resources") + @command(name="tools", hidden=True) + async def site_tools_alias(self, ctx: Context) -> None: + """Alias for invoking <prefix>site tools.""" + await self.invoke(ctx, "site tools") + @command(name="watch", hidden=True) async def bigbrother_watch_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: """Alias for invoking <prefix>bigbrother watch [user] [reason].""" diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index e5c51748f..c9e6b3b91 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -261,7 +261,7 @@ class Doc(commands.Cog): @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: """Lookup documentation for Python symbols.""" - await ctx.invoke(self.get_command) + await ctx.invoke(self.get_command, symbol) @docs_group.command(name='get', aliases=('g',)) async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: @@ -319,9 +319,9 @@ class Doc(commands.Cog): Example: !docs set \ - discord \ - https://discordpy.readthedocs.io/en/rewrite/ \ - https://discordpy.readthedocs.io/en/rewrite/objects.inv + python \ + https://docs.python.org/3/ \ + https://docs.python.org/3/objects.inv """ body = { 'package': package_name, diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index b596f36e6..5aa873a47 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -80,6 +80,43 @@ class Moderation(Scheduler, Cog): if infraction["expires_at"] is not None: self.schedule_task(self.bot.loop, infraction["id"], infraction) + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Reapply active mute infractions for returning members.""" + active_mutes = await self.bot.api_client.get( + 'bot/infractions', + params={'user__id': str(member.id), 'type': 'mute', 'active': 'true'} + ) + if not active_mutes: + return + + # assume a single mute because of restrictions elsewhere + mute = active_mutes[0] + + # transform expiration to delay in seconds + expiration_datetime = datetime.fromisoformat(mute["expires_at"][:-1]) + delay = expiration_datetime - datetime.utcnow() + delay_seconds = delay.total_seconds() + + # if under a minute or in the past + if delay_seconds < 60: + log.debug(f"Marking infraction {mute['id']} as inactive (expired).") + await self._deactivate_infraction(mute) + self.cancel_task(mute["id"]) + + # Notify the user that they've been unmuted. + await self.notify_pardon( + user=member, + title="You have been unmuted.", + content="You may now send messages in the server.", + icon_url=Icons.user_unmute + ) + return + + # allowing modlog since this is a passive action that should be logged + await member.add_roles(self._muted_role, reason=f"Re-applying active mute: {mute['id']}") + log.debug(f"User {member.id} has been re-muted on rejoin.") + # region: Permanent infractions @with_role(*MODERATION_ROLES) @@ -955,6 +992,11 @@ class Moderation(Scheduler, Cog): user_id = infraction_object["user"] infraction_type = infraction_object["type"] + await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction_object['id']), + json={"active": False} + ) + if infraction_type == "mute": member: Member = guild.get_member(user_id) if member: @@ -970,11 +1012,6 @@ class Moderation(Scheduler, Cog): except NotFound: log.info(f"Tried to unban user `{user_id}`, but Discord does not have an active ban registered.") - await self.bot.api_client.patch( - 'bot/infractions/' + str(infraction_object['id']), - json={"active": False} - ) - def _infraction_to_string(self, infraction_object: Dict[str, Union[str, int, bool]]) -> str: """Convert the infraction object to a string representation.""" actor_id = infraction_object["actor"] 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/site.py b/bot/cogs/site.py index 4a423faa9..c3bdf85e4 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -44,17 +44,29 @@ class Site(Cog): async def site_resources(self, ctx: Context) -> None: """Info about the site's Resources page.""" learning_url = f"{PAGES_URL}/resources" - tools_url = f"{PAGES_URL}/tools" - embed = Embed(title="Resources & Tools") - embed.set_footer(text=f"{learning_url} | {tools_url}") + embed = Embed(title="Resources") + embed.set_footer(text=f"{learning_url}") embed.colour = Colour.blurple() embed.description = ( f"The [Resources page]({learning_url}) on our website contains a " - "list of hand-selected goodies that we regularly recommend " - f"to both beginners and experts. The [Tools page]({tools_url}) " - "contains a couple of the most popular tools for programming in " - "Python." + "list of hand-selected learning resources that we regularly recommend " + f"to both beginners and experts." + ) + + await ctx.send(embed=embed) + + @site_group.command(name="tools") + async def site_tools(self, ctx: Context) -> None: + """Info about the site's Tools page.""" + tools_url = f"{PAGES_URL}/tools" + + embed = Embed(title="Tools") + embed.set_footer(text=f"{tools_url}") + embed.colour = Colour.blurple() + embed.description = ( + f"The [Tools page]({tools_url}) on our website contains a " + f"couple of the most popular tools for programming in Python." ) await ctx.send(embed=embed) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index b9dd3595e..cd70e783a 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -86,7 +86,7 @@ class Tags(Cog): max_lines=15 ) - @tags_group.command(name='set', aliases=('add', 'edit', 's')) + @tags_group.command(name='set', aliases=('add', 's')) @with_role(*MODERATION_ROLES) async def set_command( self, @@ -95,7 +95,7 @@ class Tags(Cog): *, tag_content: TagContentConverter, ) -> None: - """Create a new tag or update an existing one.""" + """Create a new tag.""" body = { 'title': tag_name.lower().strip(), 'embed': { @@ -116,6 +116,35 @@ class Tags(Cog): colour=Colour.blurple() )) + @tags_group.command(name='edit', aliases=('e', )) + @with_role(*MODERATION_ROLES) + async def edit_command( + self, + ctx: Context, + tag_name: TagNameConverter, + *, + tag_content: TagContentConverter, + ) -> None: + """Edit an existing tag.""" + body = { + 'embed': { + 'title': tag_name, + 'description': tag_content + } + } + + await self.bot.api_client.patch(f'bot/tags/{tag_name}', json=body) + + log.debug(f"{ctx.author} successfully edited the following tag in our database: \n" + f"tag_name: {tag_name}\n" + f"tag_content: '{tag_content}'\n") + + await ctx.send(embed=Embed( + title="Tag successfully edited", + description=f"**{tag_name}** edited in the database.", + colour=Colour.blurple() + )) + @tags_group.command(name='delete', aliases=('remove', 'rm', 'd')) @with_role(Roles.admin, Roles.owner) async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None: 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/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index ce8014d69..760e012eb 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -335,7 +335,7 @@ class WatchChannel(metaclass=CogABCMeta): def cog_unload(self) -> None: """Takes care of unloading the cog and canceling the consumption task.""" self.log.trace(f"Unloading the cog") - if not self._consume_task.done(): + if self._consume_task and not self._consume_task.done(): self._consume_task.cancel() try: self._consume_task.result() diff --git a/bot/converters.py b/bot/converters.py index 339da7b60..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 @@ -49,7 +51,7 @@ class ValidURL(Converter): async with ctx.bot.http_session.get(url) as resp: if resp.status != 200: raise BadArgument( - f"HTTP GET on `{url}` returned status `{resp.status_code}`, expected 200" + f"HTTP GET on `{url}` returned status `{resp.status}`, expected 200" ) except CertificateError: if url.startswith('https'): @@ -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/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)) |