aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Chris G <[email protected]>2019-10-19 20:12:59 -0600
committerGravatar GitHub <[email protected]>2019-10-19 20:12:59 -0600
commit046bb4badf459fa318070f824234af17688c256f (patch)
treeaad3b2129a11acd364b29f4fa0497d645fcd6369
parentBugfix - ensure .py attachment is prioritized over other non-whitelisted (diff)
parentPluralize "infractions" as necessary. (#545) (diff)
Merge branch 'master' into antimalware-cog
-rw-r--r--.gitignore3
-rw-r--r--bot/cogs/alias.py6
-rw-r--r--bot/cogs/antispam.py2
-rw-r--r--bot/cogs/defcon.py5
-rw-r--r--bot/cogs/doc.py3
-rw-r--r--bot/cogs/filtering.py2
-rw-r--r--bot/cogs/information.py86
-rw-r--r--bot/cogs/moderation/infractions.py12
-rw-r--r--bot/cogs/moderation/modlog.py30
-rw-r--r--bot/cogs/moderation/superstarify.py4
-rw-r--r--bot/cogs/off_topic_names.py48
-rw-r--r--bot/cogs/site.py6
-rw-r--r--bot/cogs/snekbox.py12
-rw-r--r--bot/cogs/utils.py132
-rw-r--r--bot/cogs/verification.py38
-rw-r--r--bot/constants.py7
-rw-r--r--bot/utils/checks.py48
-rw-r--r--bot/utils/time.py19
-rw-r--r--config-default.yml4
-rw-r--r--docker-compose.yml2
-rw-r--r--tests/helpers.py4
-rw-r--r--tests/utils/test_time.py62
22 files changed, 428 insertions, 107 deletions
diff --git a/.gitignore b/.gitignore
index 261fa179f..a191523b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -116,3 +116,6 @@ config.yml
# JUnit XML reports from pytest
junit.xml
+
+# Mac OS .DS_Store, which is a file that stores custom attributes of its containing folder
+.DS_Store
diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py
index 6648805e9..5190c559b 100644
--- a/bot/cogs/alias.py
+++ b/bot/cogs/alias.py
@@ -79,10 +79,10 @@ class Alias (Cog):
"""Alias for invoking <prefix>site faq."""
await self.invoke(ctx, "site faq")
- @command(name="rules", hidden=True)
- async def site_rules_alias(self, ctx: Context) -> None:
+ @command(name="rules", aliases=("rule",), hidden=True)
+ async def site_rules_alias(self, ctx: Context, *rules: int) -> None:
"""Alias for invoking <prefix>site rules."""
- await self.invoke(ctx, "site rules")
+ await self.invoke(ctx, "site rules", *rules)
@command(name="reload", hidden=True)
async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None:
diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py
index 1b394048a..1340eb608 100644
--- a/bot/cogs/antispam.py
+++ b/bot/cogs/antispam.py
@@ -59,7 +59,7 @@ class DeletionContext:
async def upload_messages(self, actor_id: int, modlog: ModLog) -> None:
"""Method that takes care of uploading the queue and posting modlog alert."""
- triggered_by_users = ", ".join(f"{m.display_name}#{m.discriminator} (`{m.id}`)" for m in self.members.values())
+ triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values())
mod_alert_message = (
f"**Triggered by:** {triggered_by_users}\n"
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index 70e101baa..38a0915e5 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -90,8 +90,7 @@ class Defcon(Cog):
await member.kick(reason="DEFCON active, user is too new")
message = (
- f"{member.name}#{member.discriminator} (`{member.id}`) "
- f"was denied entry because their account is too new."
+ f"{member} (`{member.id}`) was denied entry because their account is too new."
)
if not message_sent:
@@ -254,7 +253,7 @@ class Defcon(Cog):
`change` string may be one of the following: ('enabled', 'disabled', 'updated')
"""
- log_msg = f"**Staffer:** {actor.name}#{actor.discriminator} (`{actor.id}`)\n"
+ log_msg = f"**Staffer:** {actor} (`{actor.id}`)\n"
if change.lower() == "enabled":
icon = Icons.defcon_enabled
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index a13464bff..65cabe46f 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -336,8 +336,7 @@ class Doc(commands.Cog):
await self.bot.api_client.post('bot/documentation-links', json=body)
log.info(
- f"User @{ctx.author.name}#{ctx.author.discriminator} ({ctx.author.id}) "
- "added a new documentation package:\n"
+ f"User @{ctx.author} ({ctx.author.id}) added a new documentation package:\n"
f"Package name: {package_name}\n"
f"Base url: {base_url}\n"
f"Inventory URL: {inventory_url}"
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
index 265ae5160..1d1d74e74 100644
--- a/bot/cogs/filtering.py
+++ b/bot/cogs/filtering.py
@@ -186,7 +186,7 @@ class Filtering(Cog):
message = (
f"The {filter_name} {_filter['type']} was triggered "
- f"by **{msg.author.name}#{msg.author.discriminator}** "
+ f"by **{msg.author}** "
f"(`{msg.author.id}`) {channel_str} with [the "
f"following message]({msg.jump_url}):\n\n"
f"{msg.content}"
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
index 1afb37103..3a7ba0444 100644
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -1,14 +1,18 @@
import colorsys
import logging
+import pprint
import textwrap
import typing
+from typing import Any, Mapping, Optional
+import discord
from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils
-from discord.ext.commands import Bot, Cog, Context, command
+from discord.ext import commands
+from discord.ext.commands import Bot, BucketType, Cog, Context, command, group
from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES
-from bot.decorators import InChannelCheckFailure, with_role
-from bot.utils.checks import with_role_check
+from bot.decorators import InChannelCheckFailure, in_channel, with_role
+from bot.utils.checks import cooldown_with_role_bypass, with_role_check
from bot.utils.time import time_since
log = logging.getLogger(__name__)
@@ -229,6 +233,82 @@ class Information(Cog):
await ctx.send(embed=embed)
+ def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str:
+ """Format a mapping to be readable to a human."""
+ # sorting is technically superfluous but nice if you want to look for a specific field
+ fields = sorted(mapping.items(), key=lambda item: item[0])
+
+ if field_width is None:
+ field_width = len(max(mapping.keys(), key=len))
+
+ out = ''
+
+ for key, val in fields:
+ if isinstance(val, dict):
+ # if we have dicts inside dicts we want to apply the same treatment to the inner dictionaries
+ inner_width = int(field_width * 1.6)
+ val = '\n' + self.format_fields(val, field_width=inner_width)
+
+ elif isinstance(val, str):
+ # split up text since it might be long
+ text = textwrap.fill(val, width=100, replace_whitespace=False)
+
+ # indent it, I guess you could do this with `wrap` and `join` but this is nicer
+ val = textwrap.indent(text, ' ' * (field_width + len(': ')))
+
+ # the first line is already indented so we `str.lstrip` it
+ val = val.lstrip()
+
+ if key == 'color':
+ # makes the base 10 representation of a hex number readable to humans
+ val = hex(val)
+
+ out += '{0:>{width}}: {1}\n'.format(key, val, width=field_width)
+
+ # remove trailing whitespace
+ return out.rstrip()
+
+ @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=STAFF_ROLES)
+ @group(invoke_without_command=True)
+ @in_channel(Channels.bot, bypass_roles=STAFF_ROLES)
+ async def raw(self, ctx: Context, *, message: discord.Message, json: bool = False) -> None:
+ """Shows information about the raw API response."""
+ # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling
+ # doing this extra request is also much easier than trying to convert everything back into a dictionary again
+ raw_data = await ctx.bot.http.get_message(message.channel.id, message.id)
+
+ paginator = commands.Paginator()
+
+ def add_content(title: str, content: str) -> None:
+ paginator.add_line(f'== {title} ==\n')
+ # replace backticks as it breaks out of code blocks. Spaces seemed to be the most reasonable solution.
+ # we hope it's not close to 2000
+ paginator.add_line(content.replace('```', '`` `'))
+ paginator.close_page()
+
+ if message.content:
+ add_content('Raw message', message.content)
+
+ transformer = pprint.pformat if json else self.format_fields
+ for field_name in ('embeds', 'attachments'):
+ data = raw_data[field_name]
+
+ if not data:
+ continue
+
+ total = len(data)
+ for current, item in enumerate(data, start=1):
+ title = f'Raw {field_name} ({current}/{total})'
+ add_content(title, transformer(item))
+
+ for page in paginator.pages:
+ await ctx.send(page)
+
+ @raw.command()
+ async def json(self, ctx: Context, message: discord.Message) -> None:
+ """Shows information about the raw API response in a copy-pasteable Python format."""
+ await ctx.invoke(self.raw, message=message, json=True)
+
def setup(bot: Bot) -> None:
"""Information cog load."""
diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py
index 592ead60f..f2ae7b95d 100644
--- a/bot/cogs/moderation/infractions.py
+++ b/bot/cogs/moderation/infractions.py
@@ -2,6 +2,7 @@ import logging
import textwrap
import typing as t
from datetime import datetime
+from gettext import ngettext
import dateutil.parser
import discord
@@ -436,7 +437,13 @@ class Infractions(Scheduler, commands.Cog):
# Default values for the confirmation message and mod log.
confirm_msg = f":ok_hand: applied"
- expiry_msg = f" until {expiry}" if expiry else " permanently"
+
+ # Specifying an expiry for a note or warning makes no sense.
+ if infr_type in ("note", "warning"):
+ expiry_msg = ""
+ else:
+ expiry_msg = f" until {expiry}" if expiry else " permanently"
+
dm_result = ""
dm_log_text = ""
expiry_log_text = f"Expires: {expiry}" if expiry else ""
@@ -463,7 +470,8 @@ class Infractions(Scheduler, commands.Cog):
"bot/infractions",
params={"user__id": str(user.id)}
)
- end_msg = f" ({len(infractions)} infractions total)"
+ total = len(infractions)
+ end_msg = f" ({total} infraction{ngettext('', 's', total)} total)"
# Execute the necessary actions to apply the infraction on Discord.
if action_coro:
diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py
index 118503517..88f2b6c67 100644
--- a/bot/cogs/moderation/modlog.py
+++ b/bot/cogs/moderation/modlog.py
@@ -363,7 +363,7 @@ class ModLog(Cog, name="ModLog"):
await self.send_log_message(
Icons.user_ban, Colours.soft_red,
- "User banned", f"{member.name}#{member.discriminator} (`{member.id}`)",
+ "User banned", f"{member} (`{member.id}`)",
thumbnail=member.avatar_url_as(static_format="png"),
channel_id=Channels.userlog
)
@@ -374,7 +374,7 @@ class ModLog(Cog, name="ModLog"):
if member.guild.id != GuildConstant.id:
return
- message = f"{member.name}#{member.discriminator} (`{member.id}`)"
+ message = f"{member} (`{member.id}`)"
now = datetime.utcnow()
difference = abs(relativedelta(now, member.created_at))
@@ -402,7 +402,7 @@ class ModLog(Cog, name="ModLog"):
await self.send_log_message(
Icons.sign_out, Colours.soft_red,
- "User left", f"{member.name}#{member.discriminator} (`{member.id}`)",
+ "User left", f"{member} (`{member.id}`)",
thumbnail=member.avatar_url_as(static_format="png"),
channel_id=Channels.userlog
)
@@ -419,7 +419,7 @@ class ModLog(Cog, name="ModLog"):
await self.send_log_message(
Icons.user_unban, Colour.blurple(),
- "User unbanned", f"{member.name}#{member.discriminator} (`{member.id}`)",
+ "User unbanned", f"{member} (`{member.id}`)",
thumbnail=member.avatar_url_as(static_format="png"),
channel_id=Channels.modlog
)
@@ -511,7 +511,7 @@ class ModLog(Cog, name="ModLog"):
for item in sorted(changes):
message += f"{Emojis.bullet} {item}\n"
- message = f"**{after.name}#{after.discriminator}** (`{after.id}`)\n{message}"
+ message = f"**{after}** (`{after.id}`)\n{message}"
await self.send_log_message(
Icons.user_update, Colour.blurple(),
@@ -540,14 +540,14 @@ class ModLog(Cog, name="ModLog"):
if channel.category:
response = (
- f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Author:** {author} (`{author.id}`)\n"
f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
"\n"
)
else:
response = (
- f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Author:** {author} (`{author.id}`)\n"
f"**Channel:** #{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
"\n"
@@ -638,7 +638,7 @@ class ModLog(Cog, name="ModLog"):
if channel.category:
before_response = (
- f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Author:** {author} (`{author.id}`)\n"
f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{before.id}`\n"
"\n"
@@ -646,7 +646,7 @@ class ModLog(Cog, name="ModLog"):
)
after_response = (
- f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Author:** {author} (`{author.id}`)\n"
f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{before.id}`\n"
"\n"
@@ -654,7 +654,7 @@ class ModLog(Cog, name="ModLog"):
)
else:
before_response = (
- f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Author:** {author} (`{author.id}`)\n"
f"**Channel:** #{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{before.id}`\n"
"\n"
@@ -662,7 +662,7 @@ class ModLog(Cog, name="ModLog"):
)
after_response = (
- f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Author:** {author} (`{author.id}`)\n"
f"**Channel:** #{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{before.id}`\n"
"\n"
@@ -721,7 +721,7 @@ class ModLog(Cog, name="ModLog"):
if channel.category:
before_response = (
- f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Author:** {author} (`{author.id}`)\n"
f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
"\n"
@@ -729,7 +729,7 @@ class ModLog(Cog, name="ModLog"):
)
after_response = (
- f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Author:** {author} (`{author.id}`)\n"
f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
"\n"
@@ -737,7 +737,7 @@ class ModLog(Cog, name="ModLog"):
)
else:
before_response = (
- f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Author:** {author} (`{author.id}`)\n"
f"**Channel:** #{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
"\n"
@@ -745,7 +745,7 @@ class ModLog(Cog, name="ModLog"):
)
after_response = (
- f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Author:** {author} (`{author.id}`)\n"
f"**Channel:** #{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
"\n"
diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py
index ccc6395d9..82f8621fc 100644
--- a/bot/cogs/moderation/superstarify.py
+++ b/bot/cogs/moderation/superstarify.py
@@ -129,7 +129,7 @@ class Superstarify(Cog):
# Log to the mod_log channel
log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.")
mod_log_message = (
- f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n"
+ f"**{member}** (`{member.id}`)\n\n"
f"Superstarified member potentially tried to escape the prison.\n"
f"Restored enforced nickname: `{forced_nick}`\n"
f"Superstardom ends: **{end_timestamp_human}**"
@@ -183,7 +183,7 @@ class Superstarify(Cog):
# Log to the mod_log channel
log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.")
mod_log_message = (
- f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n"
+ f"**{member}** (`{member.id}`)\n\n"
f"Superstarified by **{ctx.author.name}**\n"
f"Old nickname: `{member.display_name}`\n"
f"New nickname: `{forced_nick}`\n"
diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py
index 2977e4ebb..1f9fb0b4f 100644
--- a/bot/cogs/off_topic_names.py
+++ b/bot/cogs/off_topic_names.py
@@ -98,15 +98,42 @@ class OffTopicNames(Cog):
@otname_group.command(name='add', aliases=('a',))
@with_role(*MODERATION_ROLES)
async def add_command(self, ctx: Context, *names: OffTopicName) -> None:
- """Adds a new off-topic name to the rotation."""
+ """
+ Adds a new off-topic name to the rotation.
+
+ The name is not added if it is too similar to an existing name.
+ """
# Chain multiple words to a single one
name = "-".join(names)
- await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name})
- log.info(
- f"{ctx.author.name}#{ctx.author.discriminator}"
- f" added the off-topic channel name '{name}"
- )
+ existing_names = await self.bot.api_client.get('bot/off-topic-channel-names')
+ close_match = difflib.get_close_matches(name, existing_names, n=1, cutoff=0.8)
+
+ if close_match:
+ match = close_match[0]
+ log.info(
+ f"{ctx.author} tried to add channel name '{name}' but it was too similar to '{match}'"
+ )
+ await ctx.send(
+ f":x: The channel name `{name}` is too similar to `{match}`, and thus was not added. "
+ "Use `!otn forceadd` to override this check."
+ )
+ else:
+ await self._add_name(ctx, name)
+
+ @otname_group.command(name='forceadd', aliases=('fa',))
+ @with_role(*MODERATION_ROLES)
+ async def force_add_command(self, ctx: Context, *names: OffTopicName) -> None:
+ """Forcefully adds a new off-topic name to the rotation."""
+ # Chain multiple words to a single one
+ name = "-".join(names)
+ await self._add_name(ctx, name)
+
+ async def _add_name(self, ctx: Context, name: str) -> None:
+ """Adds an off-topic channel name to the site storage."""
+ await self.bot.api_client.post('bot/off-topic-channel-names', params={'name': name})
+
+ log.info(f"{ctx.author} added the off-topic channel name '{name}'")
await ctx.send(f":ok_hand: Added `{name}` to the names list.")
@otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd'))
@@ -115,12 +142,9 @@ class OffTopicNames(Cog):
"""Removes a off-topic name from the rotation."""
# Chain multiple words to a single one
name = "-".join(names)
-
await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}')
- log.info(
- f"{ctx.author.name}#{ctx.author.discriminator}"
- f" deleted the off-topic channel name '{name}"
- )
+
+ log.info(f"{ctx.author} deleted the off-topic channel name '{name}'")
await ctx.send(f":ok_hand: Removed `{name}` from the names list.")
@otname_group.command(name='list', aliases=('l',))
@@ -152,7 +176,7 @@ class OffTopicNames(Cog):
close_matches = difflib.get_close_matches(query, result, n=10, cutoff=0.70)
lines = sorted(f"• {name}" for name in in_matches.union(close_matches))
embed = Embed(
- title=f"Query results",
+ title="Query results",
colour=Colour.blue()
)
diff --git a/bot/cogs/site.py b/bot/cogs/site.py
index c3bdf85e4..d95359159 100644
--- a/bot/cogs/site.py
+++ b/bot/cogs/site.py
@@ -126,15 +126,15 @@ class Site(Cog):
invalid_indices = tuple(
pick
for pick in rules
- if pick < 0 or pick >= len(full_rules)
+ if pick < 1 or pick > len(full_rules)
)
if invalid_indices:
indices = ', '.join(map(str, invalid_indices))
- await ctx.send(f":x: Invalid rule indices {indices}")
+ await ctx.send(f":x: Invalid rule indices: {indices}")
return
- final_rules = tuple(f"**{pick}.** {full_rules[pick]}" for pick in rules)
+ final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules)
await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3)
diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py
index 81185cf3e..c0390cb1e 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/cogs/snekbox.py
@@ -178,7 +178,7 @@ class Snekbox(Cog):
if ctx.author.id in self.jobs:
await ctx.send(
f"{ctx.author.mention} You've already got a job running - "
- f"please wait for it to finish!"
+ "please wait for it to finish!"
)
return
@@ -186,10 +186,7 @@ class Snekbox(Cog):
await ctx.invoke(self.bot.get_command("help"), "eval")
return
- log.info(
- f"Received code from {ctx.author.name}#{ctx.author.discriminator} "
- f"for evaluation:\n{code}"
- )
+ log.info(f"Received code from {ctx.author} for evaluation:\n{code}")
self.jobs[ctx.author.id] = datetime.datetime.now()
code = self.prepare_input(code)
@@ -213,10 +210,7 @@ class Snekbox(Cog):
wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot)
)
- log.info(
- f"{ctx.author.name}#{ctx.author.discriminator}'s job had a return code of "
- f"{results['returncode']}"
- )
+ log.info(f"{ctx.author}'s job had a return code of {results['returncode']}")
finally:
del self.jobs[ctx.author.id]
diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py
index b6cecdc7c..793fe4c1a 100644
--- a/bot/cogs/utils.py
+++ b/bot/cogs/utils.py
@@ -1,15 +1,18 @@
import logging
import re
import unicodedata
+from asyncio import TimeoutError, sleep
from email.parser import HeaderParser
from io import StringIO
from typing import Tuple
-from discord import Colour, Embed
+from dateutil import relativedelta
+from discord import Colour, Embed, Message, Role
from discord.ext.commands import Bot, Cog, Context, command
-from bot.constants import Channels, STAFF_ROLES
-from bot.decorators import in_channel
+from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES
+from bot.decorators import in_channel, with_role
+from bot.utils.time import humanize_delta
log = logging.getLogger(__name__)
@@ -32,56 +35,58 @@ class Utils(Cog):
await ctx.invoke(self.bot.get_command("help"), "pep")
return
- # Newer PEPs are written in RST instead of txt
- if pep_number > 542:
- pep_url = f"{self.base_github_pep_url}{pep_number:04}.rst"
- else:
- pep_url = f"{self.base_github_pep_url}{pep_number:04}.txt"
-
- # Attempt to fetch the PEP
- log.trace(f"Requesting PEP {pep_number} with {pep_url}")
- response = await self.bot.http_session.get(pep_url)
-
- if response.status == 200:
- log.trace("PEP found")
+ possible_extensions = ['.txt', '.rst']
+ found_pep = False
+ for extension in possible_extensions:
+ # Attempt to fetch the PEP
+ pep_url = f"{self.base_github_pep_url}{pep_number:04}{extension}"
+ log.trace(f"Requesting PEP {pep_number} with {pep_url}")
+ response = await self.bot.http_session.get(pep_url)
- pep_content = await response.text()
+ if response.status == 200:
+ log.trace("PEP found")
+ found_pep = True
- # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179
- pep_header = HeaderParser().parse(StringIO(pep_content))
+ pep_content = await response.text()
- # Assemble the embed
- pep_embed = Embed(
- title=f"**PEP {pep_number} - {pep_header['Title']}**",
- description=f"[Link]({self.base_pep_url}{pep_number:04})",
- )
-
- pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png")
+ # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179
+ pep_header = HeaderParser().parse(StringIO(pep_content))
- # Add the interesting information
- if "Status" in pep_header:
- pep_embed.add_field(name="Status", value=pep_header["Status"])
- if "Python-Version" in pep_header:
- pep_embed.add_field(name="Python-Version", value=pep_header["Python-Version"])
- if "Created" in pep_header:
- pep_embed.add_field(name="Created", value=pep_header["Created"])
- if "Type" in pep_header:
- pep_embed.add_field(name="Type", value=pep_header["Type"])
+ # Assemble the embed
+ pep_embed = Embed(
+ title=f"**PEP {pep_number} - {pep_header['Title']}**",
+ description=f"[Link]({self.base_pep_url}{pep_number:04})",
+ )
- elif response.status == 404:
+ pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png")
+
+ # Add the interesting information
+ if "Status" in pep_header:
+ pep_embed.add_field(name="Status", value=pep_header["Status"])
+ if "Python-Version" in pep_header:
+ pep_embed.add_field(name="Python-Version", value=pep_header["Python-Version"])
+ if "Created" in pep_header:
+ pep_embed.add_field(name="Created", value=pep_header["Created"])
+ if "Type" in pep_header:
+ pep_embed.add_field(name="Type", value=pep_header["Type"])
+
+ elif response.status != 404:
+ # any response except 200 and 404 is expected
+ found_pep = True # actually not, but it's easier to display this way
+ log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: "
+ f"{response.status}.\n{response.text}")
+
+ error_message = "Unexpected HTTP error during PEP search. Please let us know."
+ pep_embed = Embed(title="Unexpected error", description=error_message)
+ pep_embed.colour = Colour.red()
+ break
+
+ if not found_pep:
log.trace("PEP was not found")
not_found = f"PEP {pep_number} does not exist."
pep_embed = Embed(title="PEP not found", description=not_found)
pep_embed.colour = Colour.red()
- else:
- log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: "
- f"{response.status}.\n{response.text}")
-
- error_message = "Unexpected HTTP error during PEP search. Please let us know."
- pep_embed = Embed(title="Unexpected error", description=error_message)
- pep_embed.colour = Colour.red()
-
await ctx.message.channel.send(embed=pep_embed)
@command()
@@ -128,6 +133,47 @@ class Utils(Cog):
await ctx.send(embed=embed)
+ @command()
+ @with_role(*MODERATION_ROLES)
+ async def mention(self, ctx: Context, *, role: Role) -> None:
+ """Set a role to be mentionable for a limited time."""
+ if role.mentionable:
+ await ctx.send(f"{role} is already mentionable!")
+ return
+
+ await role.edit(reason=f"Role unlocked by {ctx.author}", mentionable=True)
+
+ human_time = humanize_delta(relativedelta.relativedelta(seconds=Mention.message_timeout))
+ await ctx.send(
+ f"{role} has been made mentionable. I will reset it in {human_time}, or when someone mentions this role."
+ )
+
+ def check(m: Message) -> bool:
+ """Checks that the message contains the role mention."""
+ return role in m.role_mentions
+
+ try:
+ msg = await self.bot.wait_for("message", check=check, timeout=Mention.message_timeout)
+ except TimeoutError:
+ await role.edit(mentionable=False, reason="Automatic role lock - timeout.")
+ await ctx.send(f"{ctx.author.mention}, you took too long. I have reset {role} to be unmentionable.")
+ return
+
+ if any(r.id in MODERATION_ROLES for r in msg.author.roles):
+ await sleep(Mention.reset_delay)
+ await role.edit(mentionable=False, reason=f"Automatic role lock by {msg.author}")
+ await ctx.send(
+ f"{ctx.author.mention}, I have reset {role} to be unmentionable as "
+ f"{msg.author if msg.author != ctx.author else 'you'} sent a message mentioning it."
+ )
+ return
+
+ await role.edit(mentionable=False, reason=f"Automatic role lock - unauthorised use by {msg.author}")
+ await ctx.send(
+ f"{ctx.author.mention}, I have reset {role} to be unmentionable "
+ f"as I detected unauthorised use by {msg.author} (ID: {msg.author.id})."
+ )
+
def setup(bot: Bot) -> None:
"""Utils cog load."""
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
index acd7a7865..5b115deaa 100644
--- a/bot/cogs/verification.py
+++ b/bot/cogs/verification.py
@@ -1,10 +1,12 @@
import logging
+from datetime import datetime
from discord import Message, NotFound, Object
+from discord.ext import tasks
from discord.ext.commands import Bot, Cog, Context, command
from bot.cogs.moderation import ModLog
-from bot.constants import Channels, Event, Roles
+from bot.constants import Bot as BotConfig, Channels, Event, Roles
from bot.decorators import InChannelCheckFailure, in_channel, without_role
log = logging.getLogger(__name__)
@@ -27,12 +29,18 @@ from time to time, you can send `!subscribe` to <#{Channels.bot}> at any time to
If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to <#{Channels.bot}>.
"""
+PERIODIC_PING = (
+ f"@everyone To verify that you have read our rules, please type `{BotConfig.prefix}accept`."
+ f" Ping <@&{Roles.admin}> if you encounter any problems during the verification process."
+)
+
class Verification(Cog):
"""User verification and role self-management."""
def __init__(self, bot: Bot):
self.bot = bot
+ self.periodic_ping.start()
@property
def mod_log(self) -> ModLog:
@@ -155,6 +163,34 @@ class Verification(Cog):
else:
return True
+ @tasks.loop(hours=12)
+ async def periodic_ping(self) -> None:
+ """Every week, mention @everyone to remind them to verify."""
+ messages = self.bot.get_channel(Channels.verification).history(limit=10)
+ need_to_post = True # True if a new message needs to be sent.
+
+ async for message in messages:
+ if message.author == self.bot.user and message.content == PERIODIC_PING:
+ delta = datetime.utcnow() - message.created_at # Time since last message.
+ if delta.days >= 7: # Message is older than a week.
+ await message.delete()
+ else:
+ need_to_post = False
+
+ break
+
+ if need_to_post:
+ await self.bot.get_channel(Channels.verification).send(PERIODIC_PING)
+
+ @periodic_ping.before_loop
+ async def before_ping(self) -> None:
+ """Only start the loop when the bot is ready."""
+ await self.bot.wait_until_ready()
+
+ def cog_unload(self) -> None:
+ """Cancel the periodic ping task when the cog is unloaded."""
+ self.periodic_ping.cancel()
+
def setup(bot: Bot) -> None:
"""Verification cog load."""
diff --git a/bot/constants.py b/bot/constants.py
index 13f25e4f8..4beae84e9 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -482,6 +482,13 @@ class Free(metaclass=YAMLGetter):
cooldown_per: float
+class Mention(metaclass=YAMLGetter):
+ section = 'mention'
+
+ message_timeout: int
+ reset_delay: int
+
+
class RedirectOutput(metaclass=YAMLGetter):
section = 'redirect_output'
diff --git a/bot/utils/checks.py b/bot/utils/checks.py
index 19f64ff9f..ad892e512 100644
--- a/bot/utils/checks.py
+++ b/bot/utils/checks.py
@@ -1,6 +1,8 @@
+import datetime
import logging
+from typing import Callable, Iterable
-from discord.ext.commands import Context
+from discord.ext.commands import BucketType, Cog, Command, CommandOnCooldown, Context, Cooldown, CooldownMapping
log = logging.getLogger(__name__)
@@ -42,3 +44,47 @@ def in_channel_check(ctx: Context, channel_id: int) -> bool:
log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
f"The result of the in_channel check was {check}.")
return check
+
+
+def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketType.default, *,
+ bypass_roles: Iterable[int]) -> Callable:
+ """
+ Applies a cooldown to a command, but allows members with certain roles to be ignored.
+
+ NOTE: this replaces the `Command.before_invoke` callback, which *might* introduce problems in the future.
+ """
+ # make it a set so lookup is hash based
+ bypass = set(bypass_roles)
+
+ # this handles the actual cooldown logic
+ buckets = CooldownMapping(Cooldown(rate, per, type))
+
+ # will be called after the command has been parse but before it has been invoked, ensures that
+ # the cooldown won't be updated if the user screws up their input to the command
+ async def predicate(cog: Cog, ctx: Context) -> None:
+ nonlocal bypass, buckets
+
+ if any(role.id in bypass for role in ctx.author.roles):
+ return
+
+ # cooldown logic, taken from discord.py internals
+ current = ctx.message.created_at.replace(tzinfo=datetime.timezone.utc).timestamp()
+ bucket = buckets.get_bucket(ctx.message)
+ retry_after = bucket.update_rate_limit(current)
+ if retry_after:
+ raise CommandOnCooldown(bucket, retry_after)
+
+ def wrapper(command: Command) -> Command:
+ # NOTE: this could be changed if a subclass of Command were to be used. I didn't see the need for it
+ # so I just made it raise an error when the decorator is applied before the actual command object exists.
+ #
+ # if the `before_invoke` detail is ever a problem then I can quickly just swap over.
+ if not isinstance(command, Command):
+ raise TypeError('Decorator `cooldown_with_role_bypass` must be applied after the command decorator. '
+ 'This means it has to be above the command decorator in the code.')
+
+ command._before_invoke = predicate
+
+ return command
+
+ return wrapper
diff --git a/bot/utils/time.py b/bot/utils/time.py
index da28f2c76..2aea2c099 100644
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -1,5 +1,6 @@
import asyncio
import datetime
+from typing import Optional
import dateutil.parser
from dateutil.relativedelta import relativedelta
@@ -34,6 +35,9 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:
precision specifies the smallest unit of time to include (e.g. "seconds", "minutes").
max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).
"""
+ if max_units <= 0:
+ raise ValueError("max_units must be positive")
+
units = (
("years", delta.years),
("months", delta.months),
@@ -83,15 +87,20 @@ def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max
return f"{humanized} ago"
-def parse_rfc1123(time_str: str) -> datetime.datetime:
+def parse_rfc1123(stamp: str) -> datetime.datetime:
"""Parse RFC1123 time string into datetime."""
- return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc)
+ return datetime.datetime.strptime(stamp, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc)
# Hey, this could actually be used in the off_topic_names and reddit cogs :)
-async def wait_until(time: datetime.datetime) -> None:
- """Wait until a given time."""
- delay = time - datetime.datetime.utcnow()
+async def wait_until(time: datetime.datetime, start: Optional[datetime.datetime] = None) -> None:
+ """
+ Wait until a given time.
+
+ :param time: A datetime.datetime object to wait until.
+ :param start: The start from which to calculate the waiting duration. Defaults to UTC time.
+ """
+ delay = time - (start or datetime.datetime.utcnow())
delay_seconds = delay.total_seconds()
# Incorporate a small delay so we don't rapid-fire the event due to time precision errors
diff --git a/config-default.yml b/config-default.yml
index 071478206..197743296 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -369,6 +369,10 @@ free:
cooldown_rate: 1
cooldown_per: 60.0
+mention:
+ message_timeout: 300
+ reset_delay: 5
+
redirect_output:
delete_invocation: true
delete_delay: 15
diff --git a/docker-compose.yml b/docker-compose.yml
index 9684a3c62..f79fdba58 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,7 +6,7 @@ version: "3.7"
services:
postgres:
- image: postgres:11-alpine
+ image: postgres:12-alpine
environment:
POSTGRES_DB: pysite
POSTGRES_PASSWORD: pysite
diff --git a/tests/helpers.py b/tests/helpers.py
index 2908294f7..25059fa3a 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -7,6 +7,10 @@ __all__ = ('AsyncMock', 'async_test')
# TODO: Remove me on 3.8
+# Allows you to mock a coroutine. Since the default `__call__` of `MagicMock`
+# is not a coroutine, trying to mock a coroutine with it will result in errors
+# as the default `__call__` is not awaitable. Use this class for monkeypatching
+# coroutines instead.
class AsyncMock(MagicMock):
async def __call__(self, *args, **kwargs):
return super(AsyncMock, self).__call__(*args, **kwargs)
diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py
new file mode 100644
index 000000000..4baa6395c
--- /dev/null
+++ b/tests/utils/test_time.py
@@ -0,0 +1,62 @@
+import asyncio
+from datetime import datetime, timezone
+from unittest.mock import patch
+
+import pytest
+from dateutil.relativedelta import relativedelta
+
+from bot.utils import time
+from tests.helpers import AsyncMock
+
+
+ ('delta', 'precision', 'max_units', 'expected'),
+ (
+ (relativedelta(days=2), 'seconds', 1, '2 days'),
+ (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'),
+ (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'),
+ (relativedelta(days=2, hours=2), 'days', 2, '2 days'),
+
+ # Does not abort for unknown units, as the unit name is checked
+ # against the attribute of the relativedelta instance.
+ (relativedelta(days=2, hours=2), 'elephants', 2, '2 days and 2 hours'),
+
+ # Very high maximum units, but it only ever iterates over
+ # each value the relativedelta might have.
+ (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'),
+ )
+)
+def test_humanize_delta(
+ delta: relativedelta,
+ precision: str,
+ max_units: int,
+ expected: str
+):
+ assert time.humanize_delta(delta, precision, max_units) == expected
+
+
[email protected]('max_units', (-1, 0))
+def test_humanize_delta_raises_for_invalid_max_units(max_units: int):
+ with pytest.raises(ValueError, match='max_units must be positive'):
+ time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units)
+
+
+ ('stamp', 'expected'),
+ (
+ ('Sun, 15 Sep 2019 12:00:00 GMT', datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)),
+ )
+)
+def test_parse_rfc1123(stamp: str, expected: str):
+ assert time.parse_rfc1123(stamp) == expected
+
+
+@patch('asyncio.sleep', new_callable=AsyncMock)
+def test_wait_until(sleep_patch):
+ start = datetime(2019, 1, 1, 0, 0)
+ then = datetime(2019, 1, 1, 0, 10)
+
+ # No return value
+ assert asyncio.run(time.wait_until(then, start)) is None
+
+ sleep_patch.assert_called_once_with(10 * 60)