aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Chris G <[email protected]>2019-10-04 22:20:53 -0600
committerGravatar GitHub <[email protected]>2019-10-04 22:20:53 -0600
commit7a5cec5c9c2b73448c7a09b0f390690dded0c0a9 (patch)
tree7faf72b382111ecb579ef6d95911c6c0c294c3d1
parentMerge branch 'add-role-info-command' of github.com:python-discord/bot into ad... (diff)
parentMerge pull request #490 from python-discord/bb-previous-reason (diff)
Merge branch 'master' into add-role-info-command
-rw-r--r--bot/cogs/alias.py5
-rw-r--r--bot/cogs/doc.py8
-rw-r--r--bot/cogs/moderation.py47
-rw-r--r--bot/cogs/reddit.py39
-rw-r--r--bot/cogs/site.py26
-rw-r--r--bot/cogs/tags.py33
-rw-r--r--bot/cogs/watchchannels/bigbrother.py22
-rw-r--r--bot/cogs/watchchannels/talentpool.py19
-rw-r--r--bot/cogs/watchchannels/watchchannel.py2
-rw-r--r--bot/converters.py46
-rw-r--r--tests/test_converters.py78
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))