aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/__init__.py10
-rw-r--r--bot/bot.py41
-rw-r--r--bot/command.py18
-rw-r--r--bot/constants.py31
-rw-r--r--bot/exts/backend/alias.py70
-rw-r--r--bot/exts/filters/antimalware.py4
-rw-r--r--bot/exts/filters/antispam.py9
-rw-r--r--bot/exts/filters/filtering.py14
-rw-r--r--bot/exts/help_channels.py4
-rw-r--r--bot/exts/info/doc.py4
-rw-r--r--bot/exts/info/help.py38
-rw-r--r--bot/exts/info/information.py117
-rw-r--r--bot/exts/info/reddit.py3
-rw-r--r--bot/exts/info/site.py10
-rw-r--r--bot/exts/info/tags.py4
-rw-r--r--bot/exts/info/wolfram.py280
-rw-r--r--bot/exts/moderation/defcon.py16
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py6
-rw-r--r--bot/exts/moderation/infraction/_utils.py2
-rw-r--r--bot/exts/moderation/modlog.py4
-rw-r--r--bot/exts/moderation/verification.py596
-rw-r--r--bot/exts/moderation/watchchannels/_watchchannel.py10
-rw-r--r--bot/exts/moderation/watchchannels/bigbrother.py6
-rw-r--r--bot/exts/moderation/watchchannels/talentpool.py60
-rw-r--r--bot/exts/utils/bot.py4
-rw-r--r--bot/exts/utils/eval.py25
-rw-r--r--bot/exts/utils/extensions.py2
-rw-r--r--bot/exts/utils/reminders.py26
-rw-r--r--bot/exts/utils/snekbox.py17
-rw-r--r--bot/pagination.py164
-rw-r--r--bot/resources/tags/ask.md9
-rw-r--r--bot/rules/__init__.py1
-rw-r--r--bot/rules/burst_shared.py11
-rw-r--r--bot/rules/discord_emojis.py4
-rw-r--r--bot/rules/everyone_ping.py41
-rw-r--r--bot/utils/__init__.py19
-rw-r--r--bot/utils/helpers.py23
-rw-r--r--bot/utils/messages.py13
-rw-r--r--bot/utils/services.py54
-rw-r--r--config-default.yml59
-rw-r--r--tests/bot/exts/filters/test_antimalware.py22
-rw-r--r--tests/bot/exts/info/test_information.py87
-rw-r--r--tests/bot/exts/test_cogs.py1
-rw-r--r--tests/bot/exts/utils/test_snekbox.py40
-rw-r--r--tests/bot/test_pagination.py15
-rw-r--r--tests/bot/utils/test_services.py74
46 files changed, 1257 insertions, 811 deletions
diff --git a/bot/__init__.py b/bot/__init__.py
index d63086fe2..3ee70c4e9 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -2,10 +2,14 @@ import asyncio
import logging
import os
import sys
+from functools import partial, partialmethod
from logging import Logger, handlers
from pathlib import Path
import coloredlogs
+from discord.ext import commands
+
+from bot.command import Command
TRACE_LEVEL = logging.TRACE = 5
logging.addLevelName(TRACE_LEVEL, "TRACE")
@@ -66,3 +70,9 @@ logging.getLogger(__name__)
# On Windows, the selector event loop is required for aiodns.
if os.name == "nt":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+
+
+# Monkey-patch discord.py decorators to use the Command subclass which supports root aliases.
+# Must be patched before any cogs are added.
+commands.command = partial(commands.command, cls=Command)
+commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command)
diff --git a/bot/bot.py b/bot/bot.py
index 756449293..d25074fd9 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -130,6 +130,26 @@ class Bot(commands.Bot):
super().add_cog(cog)
log.info(f"Cog loaded: {cog.qualified_name}")
+ def add_command(self, command: commands.Command) -> None:
+ """Add `command` as normal and then add its root aliases to the bot."""
+ super().add_command(command)
+ self._add_root_aliases(command)
+
+ def remove_command(self, name: str) -> Optional[commands.Command]:
+ """
+ Remove a command/alias as normal and then remove its root aliases from the bot.
+
+ Individual root aliases cannot be removed by this function.
+ To remove them, either remove the entire command or manually edit `bot.all_commands`.
+ """
+ command = super().remove_command(name)
+ if command is None:
+ # Even if it's a root alias, there's no way to get the Bot instance to remove the alias.
+ return
+
+ self._remove_root_aliases(command)
+ return command
+
def clear(self) -> None:
"""
Clears the internal state of the bot and recreates the connector and sessions.
@@ -235,3 +255,24 @@ class Bot(commands.Bot):
scope.set_extra("kwargs", kwargs)
log.exception(f"Unhandled exception in {event}.")
+
+ def _add_root_aliases(self, command: commands.Command) -> None:
+ """Recursively add root aliases for `command` and any of its subcommands."""
+ if isinstance(command, commands.Group):
+ for subcommand in command.commands:
+ self._add_root_aliases(subcommand)
+
+ for alias in getattr(command, "root_aliases", ()):
+ if alias in self.all_commands:
+ raise commands.CommandRegistrationError(alias, alias_conflict=True)
+
+ self.all_commands[alias] = command
+
+ def _remove_root_aliases(self, command: commands.Command) -> None:
+ """Recursively remove root aliases for `command` and any of its subcommands."""
+ if isinstance(command, commands.Group):
+ for subcommand in command.commands:
+ self._remove_root_aliases(subcommand)
+
+ for alias in getattr(command, "root_aliases", ()):
+ self.all_commands.pop(alias, None)
diff --git a/bot/command.py b/bot/command.py
new file mode 100644
index 000000000..0fb900f7b
--- /dev/null
+++ b/bot/command.py
@@ -0,0 +1,18 @@
+from discord.ext import commands
+
+
+class Command(commands.Command):
+ """
+ A `discord.ext.commands.Command` subclass which supports root aliases.
+
+ A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as
+ top-level commands rather than being aliases of the command's group. It's stored as an attribute
+ also named `root_aliases`.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.root_aliases = kwargs.get("root_aliases", [])
+
+ if not isinstance(self.root_aliases, (list, tuple)):
+ raise TypeError("Root aliases of a command must be a list or a tuple of strings.")
diff --git a/bot/constants.py b/bot/constants.py
index d01dcb0fc..17f14fec0 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -268,6 +268,17 @@ class Emojis(metaclass=YAMLGetter):
status_idle: str
status_dnd: str
+ badge_staff: str
+ badge_partner: str
+ badge_hypesquad: str
+ badge_bug_hunter: str
+ badge_hypesquad_bravery: str
+ badge_hypesquad_brilliance: str
+ badge_hypesquad_balance: str
+ badge_early_supporter: str
+ badge_bug_hunter_level_2: str
+ badge_verified_bot_developer: str
+
incident_actioned: str
incident_unactioned: str
incident_investigating: str
@@ -450,6 +461,7 @@ class Roles(metaclass=YAMLGetter):
partners: int
python_community: int
team_leaders: int
+ unverified: int
verified: int # This is the Developers role on PyDis, here named verified for readability reasons.
@@ -457,6 +469,7 @@ class Guild(metaclass=YAMLGetter):
section = "guild"
id: int
+ invite: str # Discord invite, gets embedded in chat
moderation_channels: List[int]
moderation_roles: List[int]
modlog_blacklist: List[int]
@@ -503,14 +516,6 @@ class Reddit(metaclass=YAMLGetter):
secret: Optional[str]
-class Wolfram(metaclass=YAMLGetter):
- section = "wolfram"
-
- user_limit_day: int
- guild_limit_day: int
- key: Optional[str]
-
-
class AntiSpam(metaclass=YAMLGetter):
section = 'anti_spam'
@@ -575,6 +580,16 @@ class PythonNews(metaclass=YAMLGetter):
webhook: int
+class Verification(metaclass=YAMLGetter):
+ section = "verification"
+
+ unverified_after: int
+ kicked_after: int
+ reminder_frequency: int
+ bot_message_delete_delay: int
+ kick_confirmation_threshold: float
+
+
class Event(Enum):
"""
Event names. This does not include every event (for example, raw
diff --git a/bot/exts/backend/alias.py b/bot/exts/backend/alias.py
index 77867b933..c6ba8d6f3 100644
--- a/bot/exts/backend/alias.py
+++ b/bot/exts/backend/alias.py
@@ -3,13 +3,12 @@ import logging
from discord import Colour, Embed
from discord.ext.commands import (
- Cog, Command, Context, Greedy,
+ Cog, Command, Context,
clean_content, command, group,
)
from bot.bot import Bot
-from bot.converters import FetchedMember, TagNameConverter
-from bot.exts.utils.extensions import Extension
+from bot.converters import TagNameConverter
from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
@@ -51,56 +50,6 @@ class Alias (Cog):
ctx, embed, empty=False, max_lines=20
)
- @command(name="resources", aliases=("resource",), hidden=True)
- async def site_resources_alias(self, ctx: Context) -> None:
- """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: FetchedMember, *, reason: str) -> None:
- """Alias for invoking <prefix>bigbrother watch [user] [reason]."""
- await self.invoke(ctx, "bigbrother watch", user, reason=reason)
-
- @command(name="unwatch", hidden=True)
- async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
- """Alias for invoking <prefix>bigbrother unwatch [user] [reason]."""
- await self.invoke(ctx, "bigbrother unwatch", user, reason=reason)
-
- @command(name="home", hidden=True)
- async def site_home_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>site home."""
- await self.invoke(ctx, "site home")
-
- @command(name="faq", hidden=True)
- async def site_faq_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>site faq."""
- await self.invoke(ctx, "site faq")
-
- @command(name="rules", aliases=("rule",), hidden=True)
- async def site_rules_alias(self, ctx: Context, rules: Greedy[int], *_: str) -> None:
- """Alias for invoking <prefix>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:
- """Alias for invoking <prefix>extensions reload [extensions...]."""
- await self.invoke(ctx, "extensions reload", *extensions)
-
- @command(name="defon", hidden=True)
- async def defcon_enable_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>defcon enable."""
- await self.invoke(ctx, "defcon enable")
-
- @command(name="defoff", hidden=True)
- async def defcon_disable_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>defcon disable."""
- await self.invoke(ctx, "defcon disable")
-
@command(name="exception", hidden=True)
async def tags_get_traceback_alias(self, ctx: Context) -> None:
"""Alias for invoking <prefix>tags get traceback."""
@@ -132,21 +81,6 @@ class Alias (Cog):
"""Alias for invoking <prefix>docs get [symbol]."""
await self.invoke(ctx, "docs get", symbol)
- @command(name="nominate", hidden=True)
- async def nomination_add_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
- """Alias for invoking <prefix>talentpool add [user] [reason]."""
- await self.invoke(ctx, "talentpool add", user, reason=reason)
-
- @command(name="unnominate", hidden=True)
- async def nomination_end_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
- """Alias for invoking <prefix>nomination end [user] [reason]."""
- await self.invoke(ctx, "nomination end", user, reason=reason)
-
- @command(name="nominees", hidden=True)
- async def nominees_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>tp watched."""
- await self.invoke(ctx, "talentpool watched")
-
def setup(bot: Bot) -> None:
"""Load the Alias cog."""
diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py
index c76bd2c60..7894ec48f 100644
--- a/bot/exts/filters/antimalware.py
+++ b/bot/exts/filters/antimalware.py
@@ -55,6 +55,10 @@ class AntiMalware(Cog):
if not message.attachments or not message.guild:
return
+ # Ignore webhook and bot messages
+ if message.webhook_id or message.author.bot:
+ return
+
# Check if user is staff, if is, return
# Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance
if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles):
diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py
index 3c5f13ebf..2e7e32d9a 100644
--- a/bot/exts/filters/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -27,14 +27,18 @@ log = logging.getLogger(__name__)
RULE_FUNCTION_MAPPING = {
'attachments': rules.apply_attachments,
'burst': rules.apply_burst,
- 'burst_shared': rules.apply_burst_shared,
+ # burst shared is temporarily disabled due to a bug
+ # 'burst_shared': rules.apply_burst_shared,
'chars': rules.apply_chars,
'discord_emojis': rules.apply_discord_emojis,
'duplicates': rules.apply_duplicates,
'links': rules.apply_links,
'mentions': rules.apply_mentions,
'newlines': rules.apply_newlines,
- 'role_mentions': rules.apply_role_mentions
+ 'role_mentions': rules.apply_role_mentions,
+ # the everyone filter is temporarily disabled until
+ # it has been improved.
+ # 'everyone_ping': rules.apply_everyone_ping,
}
@@ -219,7 +223,6 @@ class AntiSpam(Cog):
# Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes
context = await self.bot.get_context(msg)
context.author = self.bot.user
- context.message.author = self.bot.user
# Since we're going to invoke the tempmute command directly, we need to manually call the converter.
dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S")
diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py
index 2ae476d8a..2751ed7f6 100644
--- a/bot/exts/filters/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -11,6 +11,7 @@ from discord import Colour, HTTPException, Member, Message, NotFound, TextChanne
from discord.ext.commands import Cog
from discord.utils import escape_markdown
+from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import (
Channels, Colours,
@@ -301,9 +302,16 @@ class Filtering(Cog):
'delete_date': delete_date
}
- await self.bot.api_client.post('bot/offensive-messages', json=data)
- self.schedule_msg_delete(data)
- log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}")
+ try:
+ await self.bot.api_client.post('bot/offensive-messages', json=data)
+ except ResponseCodeError as e:
+ if e.status == 400 and "already exists" in e.response_json.get("id", [""])[0]:
+ log.debug(f"Offensive message {msg.id} already exists.")
+ else:
+ log.error(f"Offensive message {msg.id} failed to post: {e}")
+ else:
+ self.schedule_msg_delete(data)
+ log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}")
if is_private:
channel_str = "via DM"
diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py
index 57094751e..0f9cac89e 100644
--- a/bot/exts/help_channels.py
+++ b/bot/exts/help_channels.py
@@ -36,7 +36,7 @@ the **Help: Dormant** category.
Try to write the best question you can by providing a detailed description and telling us what \
you've tried already. For more information on asking a good question, \
-check out our guide on [asking good questions]({ASKING_GUIDE_URL}).
+check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**.
"""
DORMANT_MSG = f"""
@@ -47,7 +47,7 @@ channel until it becomes available again.
If your question wasn't answered yet, you can claim a new help channel from the \
**Help: Available** category by simply asking your question again. Consider rephrasing the \
question to maximize your chance of getting a good answer. If you're not sure how, have a look \
-through our guide for [asking a good question]({ASKING_GUIDE_URL}).
+through our guide for **[asking a good question]({ASKING_GUIDE_URL})**.
"""
CoroutineFunc = t.Callable[..., t.Coroutine]
diff --git a/bot/exts/info/doc.py b/bot/exts/info/doc.py
index 204cffb37..30c793c75 100644
--- a/bot/exts/info/doc.py
+++ b/bot/exts/info/doc.py
@@ -23,6 +23,7 @@ from bot.constants import MODERATION_ROLES, RedirectOutput
from bot.converters import ValidPythonIdentifier, ValidURL
from bot.decorators import with_role
from bot.pagination import LinePaginator
+from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
@@ -391,7 +392,8 @@ class Doc(commands.Cog):
await error_message.delete(delay=NOT_FOUND_DELETE_DELAY)
await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY)
else:
- await ctx.send(embed=doc_embed)
+ msg = await ctx.send(embed=doc_embed)
+ await wait_for_deletion(msg, (ctx.author.id,), client=self.bot)
@docs_group.command(name='set', aliases=('s',))
@with_role(*MODERATION_ROLES)
diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py
index 3d1d6fd10..99d503f5c 100644
--- a/bot/exts/info/help.py
+++ b/bot/exts/info/help.py
@@ -1,50 +1,28 @@
import itertools
import logging
-from asyncio import TimeoutError
from collections import namedtuple
from contextlib import suppress
from typing import List, Union
-from discord import Colour, Embed, Member, Message, NotFound, Reaction, User
+from discord import Colour, Embed
from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand
from fuzzywuzzy import fuzz, process
from fuzzywuzzy.utils import full_process
from bot import constants
-from bot.constants import Channels, Emojis, STAFF_ROLES
+from bot.constants import Channels, STAFF_ROLES
from bot.decorators import redirect_output
from bot.pagination import LinePaginator
+from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
COMMANDS_PER_PAGE = 8
-DELETE_EMOJI = Emojis.trashcan
PREFIX = constants.Bot.prefix
Category = namedtuple("Category", ["name", "description", "cogs"])
-async def help_cleanup(bot: Bot, author: Member, message: Message) -> None:
- """
- Runs the cleanup for the help command.
-
- Adds the :trashcan: reaction that, when clicked, will delete the help message.
- After a 300 second timeout, the reaction will be removed.
- """
- def check(reaction: Reaction, user: User) -> bool:
- """Checks the reaction is :trashcan:, the author is original author and messages are the same."""
- return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id
-
- await message.add_reaction(DELETE_EMOJI)
-
- with suppress(NotFound):
- try:
- await bot.wait_for("reaction_add", check=check, timeout=300)
- await message.delete()
- except TimeoutError:
- await message.remove_reaction(DELETE_EMOJI, bot.user)
-
-
class HelpQueryNotFound(ValueError):
"""
Raised when a HelpSession Query doesn't match a command or cog.
@@ -189,7 +167,9 @@ class CustomHelpCommand(HelpCommand):
command_details = f"**```{PREFIX}{name} {command.signature}```**\n"
# show command aliases
- aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases)
+ aliases = [f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases]
+ aliases += [f"`{alias}`" for alias in getattr(command, "root_aliases", ())]
+ aliases = ", ".join(sorted(aliases))
if aliases:
command_details += f"**Can also use:** {aliases}\n\n"
@@ -206,7 +186,7 @@ class CustomHelpCommand(HelpCommand):
"""Send help for a single command."""
embed = await self.command_formatting(command)
message = await self.context.send(embed=embed)
- await help_cleanup(self.context.bot, self.context.author, message)
+ await wait_for_deletion(message, (self.context.author.id,), self.context.bot)
@staticmethod
def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]:
@@ -245,7 +225,7 @@ class CustomHelpCommand(HelpCommand):
embed.description += f"\n**Subcommands:**\n{command_details}"
message = await self.context.send(embed=embed)
- await help_cleanup(self.context.bot, self.context.author, message)
+ await wait_for_deletion(message, (self.context.author.id,), self.context.bot)
async def send_cog_help(self, cog: Cog) -> None:
"""Send help for a cog."""
@@ -261,7 +241,7 @@ class CustomHelpCommand(HelpCommand):
embed.description += f"\n\n**Commands:**\n{command_details}"
message = await self.context.send(embed=embed)
- await help_cleanup(self.context.bot, self.context.author, message)
+ await wait_for_deletion(message, (self.context.author.id,), self.context.bot)
@staticmethod
def _category_key(command: Command) -> str:
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index 8982196d1..55ecb2836 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -4,9 +4,9 @@ import pprint
import textwrap
from collections import Counter, defaultdict
from string import Template
-from typing import Any, Mapping, Optional, Union
+from typing import Any, Mapping, Optional, Tuple, Union
-from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils
+from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils
from discord.abc import GuildChannel
from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group
from discord.utils import escape_markdown
@@ -20,6 +20,12 @@ from bot.utils.time import time_since
log = logging.getLogger(__name__)
+STATUS_EMOTES = {
+ Status.offline: constants.Emojis.status_offline,
+ Status.dnd: constants.Emojis.status_dnd,
+ Status.idle: constants.Emojis.status_idle
+}
+
class Information(Cog):
"""A cog with commands for generating embeds with server info, such as server stats and user info."""
@@ -211,53 +217,88 @@ class Information(Cog):
# Custom status
custom_status = ''
for activity in user.activities:
- # Check activity.state for None value if user has a custom status set
- # This guards against a custom status with an emoji but no text, which will cause
- # escape_markdown to raise an exception
- # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class
- if activity.name == 'Custom Status' and activity.state:
- state = escape_markdown(activity.state)
- custom_status = f'Status: {state}\n'
+ if isinstance(activity, CustomActivity):
+ state = ""
+
+ if activity.name:
+ state = escape_markdown(activity.name)
+
+ emoji = ""
+ if activity.emoji:
+ # If an emoji is unicode use the emoji, else write the emote like :abc:
+ if not activity.emoji.id:
+ emoji += activity.emoji.name + " "
+ else:
+ emoji += f"`:{activity.emoji.name}:` "
+
+ custom_status = f'Status: {emoji}{state}\n'
name = str(user)
if user.nick:
name = f"{user.nick} ({name})"
+ badges = []
+
+ for badge, is_set in user.public_flags:
+ if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)):
+ badges.append(emoji)
+
joined = time_since(user.joined_at, max_units=3)
roles = ", ".join(role.mention for role in user.roles[1:])
- description = [
- textwrap.dedent(f"""
- **User Information**
- Created: {created}
- Profile: {user.mention}
- ID: {user.id}
- {custom_status}
- **Member Information**
- Joined: {joined}
- Roles: {roles or None}
- """).strip()
+ desktop_status = STATUS_EMOTES.get(user.desktop_status, constants.Emojis.status_online)
+ web_status = STATUS_EMOTES.get(user.web_status, constants.Emojis.status_online)
+ mobile_status = STATUS_EMOTES.get(user.mobile_status, constants.Emojis.status_online)
+
+ fields = [
+ (
+ "User information",
+ textwrap.dedent(f"""
+ Created: {created}
+ Profile: {user.mention}
+ ID: {user.id}
+ {custom_status}
+ """).strip()
+ ),
+ (
+ "Member information",
+ textwrap.dedent(f"""
+ Joined: {joined}
+ Roles: {roles or None}
+ """).strip()
+ ),
+ (
+ "Status",
+ textwrap.dedent(f"""
+ {desktop_status} Desktop
+ {web_status} Web
+ {mobile_status} Mobile
+ """).strip()
+ )
]
# Show more verbose output in moderation channels for infractions and nominations
if ctx.channel.id in constants.MODERATION_CHANNELS:
- description.append(await self.expanded_user_infraction_counts(user))
- description.append(await self.user_nomination_counts(user))
+ fields.append(await self.expanded_user_infraction_counts(user))
+ fields.append(await self.user_nomination_counts(user))
else:
- description.append(await self.basic_user_infraction_counts(user))
+ fields.append(await self.basic_user_infraction_counts(user))
# Let's build the embed now
embed = Embed(
title=name,
- description="\n\n".join(description)
+ description=" ".join(badges)
)
+ for field_name, field_content in fields:
+ embed.add_field(name=field_name, value=field_content, inline=False)
+
embed.set_thumbnail(url=user.avatar_url_as(static_format="png"))
embed.colour = user.top_role.colour if roles else Colour.blurple()
return embed
- async def basic_user_infraction_counts(self, member: Member) -> str:
+ async def basic_user_infraction_counts(self, member: Member) -> Tuple[str, str]:
"""Gets the total and active infraction counts for the given `member`."""
infractions = await self.bot.api_client.get(
'bot/infractions',
@@ -270,11 +311,11 @@ class Information(Cog):
total_infractions = len(infractions)
active_infractions = sum(infraction['active'] for infraction in infractions)
- infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}"
+ infraction_output = f"Total: {total_infractions}\nActive: {active_infractions}"
- return infraction_output
+ return "Infractions", infraction_output
- async def expanded_user_infraction_counts(self, member: Member) -> str:
+ async def expanded_user_infraction_counts(self, member: Member) -> Tuple[str, str]:
"""
Gets expanded infraction counts for the given `member`.
@@ -288,9 +329,9 @@ class Information(Cog):
}
)
- infraction_output = ["**Infractions**"]
+ infraction_output = []
if not infractions:
- infraction_output.append("This user has never received an infraction.")
+ infraction_output.append("No infractions")
else:
# Count infractions split by `type` and `active` status for this user
infraction_types = set()
@@ -313,9 +354,9 @@ class Information(Cog):
infraction_output.append(line)
- return "\n".join(infraction_output)
+ return "Infractions", "\n".join(infraction_output)
- async def user_nomination_counts(self, member: Member) -> str:
+ async def user_nomination_counts(self, member: Member) -> Tuple[str, str]:
"""Gets the active and historical nomination counts for the given `member`."""
nominations = await self.bot.api_client.get(
'bot/nominations',
@@ -324,21 +365,21 @@ class Information(Cog):
}
)
- output = ["**Nominations**"]
+ output = []
if not nominations:
- output.append("This user has never been nominated.")
+ output.append("No nominations")
else:
count = len(nominations)
is_currently_nominated = any(nomination["active"] for nomination in nominations)
nomination_noun = "nomination" if count == 1 else "nominations"
if is_currently_nominated:
- output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).")
+ output.append(f"This user is **currently** nominated\n({count} {nomination_noun} in total)")
else:
output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.")
- return "\n".join(output)
+ return "Nominations", "\n".join(output)
def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str:
"""Format a mapping to be readable to a human."""
@@ -376,7 +417,7 @@ class Information(Cog):
return out.rstrip()
@cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES)
- @group(invoke_without_command=True)
+ @group(invoke_without_command=True, enabled=False)
@in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES)
async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None:
"""Shows information about the raw API response."""
@@ -411,7 +452,7 @@ class Information(Cog):
for page in paginator.pages:
await ctx.send(page)
- @raw.command()
+ @raw.command(enabled=False)
async def json(self, ctx: Context, message: Message) -> None:
"""Shows information about the raw API response in a copy-pasteable Python format."""
await ctx.invoke(self.raw, message=message, json=True)
diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py
index d853ab2ea..5d9e2c20b 100644
--- a/bot/exts/info/reddit.py
+++ b/bot/exts/info/reddit.py
@@ -10,6 +10,7 @@ from aiohttp import BasicAuth, ClientError
from discord import Colour, Embed, TextChannel
from discord.ext.commands import Cog, Context, group
from discord.ext.tasks import loop
+from discord.utils import escape_markdown
from bot.bot import Bot
from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks
@@ -187,6 +188,8 @@ class Reddit(Cog):
author = data["author"]
title = textwrap.shorten(data["title"], width=64, placeholder="...")
+ # Normal brackets interfere with Markdown.
+ title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌")
link = self.URL + data["permalink"]
embed.description += (
diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py
index ac29daa1d..2d3a3d9f3 100644
--- a/bot/exts/info/site.py
+++ b/bot/exts/info/site.py
@@ -23,7 +23,7 @@ class Site(Cog):
"""Commands for getting info about our website."""
await ctx.send_help(ctx.command)
- @site_group.command(name="home", aliases=("about",))
+ @site_group.command(name="home", aliases=("about",), root_aliases=("home",))
async def site_main(self, ctx: Context) -> None:
"""Info about the website itself."""
url = f"{URLs.site_schema}{URLs.site}/"
@@ -40,7 +40,7 @@ class Site(Cog):
await ctx.send(embed=embed)
- @site_group.command(name="resources")
+ @site_group.command(name="resources", root_aliases=("resources", "resource"))
async def site_resources(self, ctx: Context) -> None:
"""Info about the site's Resources page."""
learning_url = f"{PAGES_URL}/resources"
@@ -56,7 +56,7 @@ class Site(Cog):
await ctx.send(embed=embed)
- @site_group.command(name="tools")
+ @site_group.command(name="tools", root_aliases=("tools",))
async def site_tools(self, ctx: Context) -> None:
"""Info about the site's Tools page."""
tools_url = f"{PAGES_URL}/resources/tools"
@@ -87,7 +87,7 @@ class Site(Cog):
await ctx.send(embed=embed)
- @site_group.command(name="faq")
+ @site_group.command(name="faq", root_aliases=("faq",))
async def site_faq(self, ctx: Context) -> None:
"""Info about the site's FAQ page."""
url = f"{PAGES_URL}/frequently-asked-questions"
@@ -104,7 +104,7 @@ class Site(Cog):
await ctx.send(embed=embed)
- @site_group.command(aliases=['r', 'rule'], name='rules')
+ @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule"))
async def site_rules(self, ctx: Context, *rules: int) -> None:
"""Provides a link to all rules or, if specified, displays specific rule(s)."""
rules_embed = Embed(title='Rules', color=Colour.blurple())
diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py
index 3d76c5c08..d01647312 100644
--- a/bot/exts/info/tags.py
+++ b/bot/exts/info/tags.py
@@ -236,7 +236,7 @@ class Tags(Cog):
await wait_for_deletion(
await ctx.send(embed=Embed.from_dict(tag['embed'])),
[ctx.author.id],
- client=self.bot
+ self.bot
)
elif founds and len(tag_name) >= 3:
await wait_for_deletion(
@@ -247,7 +247,7 @@ class Tags(Cog):
)
),
[ctx.author.id],
- client=self.bot
+ self.bot
)
else:
diff --git a/bot/exts/info/wolfram.py b/bot/exts/info/wolfram.py
deleted file mode 100644
index e6cae3bb8..000000000
--- a/bot/exts/info/wolfram.py
+++ /dev/null
@@ -1,280 +0,0 @@
-import logging
-from io import BytesIO
-from typing import Callable, List, Optional, Tuple
-from urllib import parse
-
-import discord
-from dateutil.relativedelta import relativedelta
-from discord import Embed
-from discord.ext import commands
-from discord.ext.commands import BucketType, Cog, Context, check, group
-
-from bot.bot import Bot
-from bot.constants import Colours, STAFF_ROLES, Wolfram
-from bot.pagination import ImagePaginator
-from bot.utils.time import humanize_delta
-
-log = logging.getLogger(__name__)
-
-APPID = Wolfram.key
-DEFAULT_OUTPUT_FORMAT = "JSON"
-QUERY = "http://api.wolframalpha.com/v2/{request}?{data}"
-WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1"
-
-MAX_PODS = 20
-
-# Allows for 10 wolfram calls pr user pr day
-usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60*60*24, BucketType.user)
-
-# Allows for max api requests / days in month per day for the entire guild (Temporary)
-guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60*24, BucketType.guild)
-
-
-async def send_embed(
- ctx: Context,
- message_txt: str,
- colour: int = Colours.soft_red,
- footer: str = None,
- img_url: str = None,
- f: discord.File = None
-) -> None:
- """Generate & send a response embed with Wolfram as the author."""
- embed = Embed(colour=colour)
- embed.description = message_txt
- embed.set_author(name="Wolfram Alpha",
- icon_url=WOLF_IMAGE,
- url="https://www.wolframalpha.com/")
- if footer:
- embed.set_footer(text=footer)
-
- if img_url:
- embed.set_image(url=img_url)
-
- await ctx.send(embed=embed, file=f)
-
-
-def custom_cooldown(*ignore: List[int]) -> Callable:
- """
- Implement per-user and per-guild cooldowns for requests to the Wolfram API.
-
- A list of roles may be provided to ignore the per-user cooldown
- """
- async def predicate(ctx: Context) -> bool:
- if ctx.invoked_with == 'help':
- # if the invoked command is help we don't want to increase the ratelimits since it's not actually
- # invoking the command/making a request, so instead just check if the user/guild are on cooldown.
- guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown
- if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored
- return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0
- return guild_cooldown
-
- user_bucket = usercd.get_bucket(ctx.message)
-
- if all(role.id not in ignore for role in ctx.author.roles):
- user_rate = user_bucket.update_rate_limit()
-
- if user_rate:
- # Can't use api; cause: member limit
- delta = relativedelta(seconds=int(user_rate))
- cooldown = humanize_delta(delta)
- message = (
- "You've used up your limit for Wolfram|Alpha requests.\n"
- f"Cooldown: {cooldown}"
- )
- await send_embed(ctx, message)
- return False
-
- guild_bucket = guildcd.get_bucket(ctx.message)
- guild_rate = guild_bucket.update_rate_limit()
-
- # Repr has a token attribute to read requests left
- log.debug(guild_bucket)
-
- if guild_rate:
- # Can't use api; cause: guild limit
- message = (
- "The max limit of requests for the server has been reached for today.\n"
- f"Cooldown: {int(guild_rate)}"
- )
- await send_embed(ctx, message)
- return False
-
- return True
- return check(predicate)
-
-
-async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]:
- """Get the Wolfram API pod pages for the provided query."""
- async with ctx.channel.typing():
- url_str = parse.urlencode({
- "input": query,
- "appid": APPID,
- "output": DEFAULT_OUTPUT_FORMAT,
- "format": "image,plaintext"
- })
- request_url = QUERY.format(request="query", data=url_str)
-
- async with bot.http_session.get(request_url) as response:
- json = await response.json(content_type='text/plain')
-
- result = json["queryresult"]
-
- if result["error"]:
- # API key not set up correctly
- if result["error"]["msg"] == "Invalid appid":
- message = "Wolfram API key is invalid or missing."
- log.warning(
- "API key seems to be missing, or invalid when "
- f"processing a wolfram request: {url_str}, Response: {json}"
- )
- await send_embed(ctx, message)
- return
-
- message = "Something went wrong internally with your request, please notify staff!"
- log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}")
- await send_embed(ctx, message)
- return
-
- if not result["success"]:
- message = f"I couldn't find anything for {query}."
- await send_embed(ctx, message)
- return
-
- if not result["numpods"]:
- message = "Could not find any results."
- await send_embed(ctx, message)
- return
-
- pods = result["pods"]
- pages = []
- for pod in pods[:MAX_PODS]:
- subs = pod.get("subpods")
-
- for sub in subs:
- title = sub.get("title") or sub.get("plaintext") or sub.get("id", "")
- img = sub["img"]["src"]
- pages.append((title, img))
- return pages
-
-
-class Wolfram(Cog):
- """Commands for interacting with the Wolfram|Alpha API."""
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True)
- @custom_cooldown(*STAFF_ROLES)
- async def wolfram_command(self, ctx: Context, *, query: str) -> None:
- """Requests all answers on a single image, sends an image of all related pods."""
- url_str = parse.urlencode({
- "i": query,
- "appid": APPID,
- })
- query = QUERY.format(request="simple", data=url_str)
-
- # Give feedback that the bot is working.
- async with ctx.channel.typing():
- async with self.bot.http_session.get(query) as response:
- status = response.status
- image_bytes = await response.read()
-
- f = discord.File(BytesIO(image_bytes), filename="image.png")
- image_url = "attachment://image.png"
-
- if status == 501:
- message = "Failed to get response"
- footer = ""
- color = Colours.soft_red
- elif status == 400:
- message = "No input found"
- footer = ""
- color = Colours.soft_red
- elif status == 403:
- message = "Wolfram API key is invalid or missing."
- footer = ""
- color = Colours.soft_red
- else:
- message = ""
- footer = "View original for a bigger picture."
- color = Colours.soft_orange
-
- # Sends a "blank" embed if no request is received, unsure how to fix
- await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f)
-
- @wolfram_command.command(name="page", aliases=("pa", "p"))
- @custom_cooldown(*STAFF_ROLES)
- async def wolfram_page_command(self, ctx: Context, *, query: str) -> None:
- """
- Requests a drawn image of given query.
-
- Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc.
- """
- pages = await get_pod_pages(ctx, self.bot, query)
-
- if not pages:
- return
-
- embed = Embed()
- embed.set_author(name="Wolfram Alpha",
- icon_url=WOLF_IMAGE,
- url="https://www.wolframalpha.com/")
- embed.colour = Colours.soft_orange
-
- await ImagePaginator.paginate(pages, ctx, embed)
-
- @wolfram_command.command(name="cut", aliases=("c",))
- @custom_cooldown(*STAFF_ROLES)
- async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None:
- """
- Requests a drawn image of given query.
-
- Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc.
- """
- pages = await get_pod_pages(ctx, self.bot, query)
-
- if not pages:
- return
-
- if len(pages) >= 2:
- page = pages[1]
- else:
- page = pages[0]
-
- await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1])
-
- @wolfram_command.command(name="short", aliases=("sh", "s"))
- @custom_cooldown(*STAFF_ROLES)
- async def wolfram_short_command(self, ctx: Context, *, query: str) -> None:
- """Requests an answer to a simple question."""
- url_str = parse.urlencode({
- "i": query,
- "appid": APPID,
- })
- query = QUERY.format(request="result", data=url_str)
-
- # Give feedback that the bot is working.
- async with ctx.channel.typing():
- async with self.bot.http_session.get(query) as response:
- status = response.status
- response_text = await response.text()
-
- if status == 501:
- message = "Failed to get response"
- color = Colours.soft_red
- elif status == 400:
- message = "No input found"
- color = Colours.soft_red
- elif response_text == "Error 1: Invalid appid":
- message = "Wolfram API key is invalid or missing."
- color = Colours.soft_red
- else:
- message = response_text
- color = Colours.soft_orange
-
- await send_embed(ctx, message, color)
-
-
-def setup(bot: Bot) -> None:
- """Load the Wolfram cog."""
- bot.add_cog(Wolfram(bot))
diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py
index b75a4dcfe..6e4008777 100644
--- a/bot/exts/moderation/defcon.py
+++ b/bot/exts/moderation/defcon.py
@@ -9,7 +9,7 @@ from discord import Colour, Embed, Member
from discord.ext.commands import Cog, Context, group
from bot.bot import Bot
-from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles
+from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles
from bot.decorators import with_role
from bot.exts.moderation.modlog import ModLog
@@ -119,7 +119,7 @@ class Defcon(Cog):
)
@group(name='defcon', aliases=('dc',), invoke_without_command=True)
- @with_role(Roles.admins, Roles.owners)
+ @with_role(*MODERATION_ROLES)
async def defcon_group(self, ctx: Context) -> None:
"""Check the DEFCON status or run a subcommand."""
await ctx.send_help(ctx.command)
@@ -162,8 +162,8 @@ class Defcon(Cog):
self.bot.stats.gauge("defcon.threshold", days)
- @defcon_group.command(name='enable', aliases=('on', 'e'))
- @with_role(Roles.admins, Roles.owners)
+ @defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",))
+ @with_role(*MODERATION_ROLES)
async def enable_command(self, ctx: Context) -> None:
"""
Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!
@@ -175,8 +175,8 @@ class Defcon(Cog):
await self._defcon_action(ctx, days=0, action=Action.ENABLED)
await self.update_channel_topic()
- @defcon_group.command(name='disable', aliases=('off', 'd'))
- @with_role(Roles.admins, Roles.owners)
+ @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",))
+ @with_role(*MODERATION_ROLES)
async def disable_command(self, ctx: Context) -> None:
"""Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!"""
self.enabled = False
@@ -184,7 +184,7 @@ class Defcon(Cog):
await self.update_channel_topic()
@defcon_group.command(name='status', aliases=('s',))
- @with_role(Roles.admins, Roles.owners)
+ @with_role(*MODERATION_ROLES)
async def status_command(self, ctx: Context) -> None:
"""Check the current status of DEFCON mode."""
embed = Embed(
@@ -196,7 +196,7 @@ class Defcon(Cog):
await ctx.send(embed=embed)
@defcon_group.command(name='days')
- @with_role(Roles.admins, Roles.owners)
+ @with_role(*MODERATION_ROLES)
async def days_command(self, ctx: Context, days: int) -> None:
"""Set how old an account must be to join the server, in days, with DEFCON mode enabled."""
self.days = timedelta(days=days)
diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index da0babcfc..cf48ef2ac 100644
--- a/bot/exts/moderation/infraction/_scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -161,6 +161,7 @@ class InfractionScheduler:
self.schedule_expiration(infraction)
except discord.HTTPException as e:
# Accordingly display that applying the infraction failed.
+ # Don't use ctx.message.author; antispam only patches ctx.author.
confirm_msg = ":x: failed to apply"
expiry_msg = ""
log_content = ctx.author.mention
@@ -190,6 +191,7 @@ class InfractionScheduler:
await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.")
# Send a log message to the mod log.
+ # Don't use ctx.message.author for the actor; antispam only patches ctx.author.
log.trace(f"Sending apply mod log for infraction #{id_}.")
await self.mod_log.send_log_message(
icon_url=icon,
@@ -198,7 +200,7 @@ class InfractionScheduler:
thumbnail=user.avatar_url_as(static_format="png"),
text=textwrap.dedent(f"""
Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}{dm_log_text}{expiry_log_text}
+ Actor: {ctx.author}{dm_log_text}{expiry_log_text}
Reason: {reason}
"""),
content=log_content,
@@ -242,7 +244,7 @@ class InfractionScheduler:
log_text = await self.deactivate_infraction(response[0], send_log=False)
log_text["Member"] = f"{user.mention}(`{user.id}`)"
- log_text["Actor"] = str(ctx.message.author)
+ log_text["Actor"] = str(ctx.author)
log_content = None
id_ = response[0]['id']
footer = f"ID: {id_}"
diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py
index fb55287b6..f21272102 100644
--- a/bot/exts/moderation/infraction/_utils.py
+++ b/bot/exts/moderation/infraction/_utils.py
@@ -70,7 +70,7 @@ async def post_infraction(
log.trace(f"Posting {infr_type} infraction for {user} to the API.")
payload = {
- "actor": ctx.message.author.id,
+ "actor": ctx.author.id, # Don't use ctx.message.author; antispam only patches ctx.author.
"hidden": hidden,
"reason": reason,
"type": infr_type,
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index c86f04b9d..b0d9b5b2b 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -120,6 +120,10 @@ class ModLog(Cog, name="ModLog"):
else:
content = "@everyone"
+ # Truncate content to 2000 characters and append an ellipsis.
+ if content and len(content) > 2000:
+ content = content[:2000 - 3] + "..."
+
channel = self.bot.get_channel(channel_id)
log_message = await channel.send(
content=content,
diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py
index 0db3e800d..53fa0730b 100644
--- a/bot/exts/moderation/verification.py
+++ b/bot/exts/moderation/verification.py
@@ -1,19 +1,36 @@
+import asyncio
import logging
+import typing as t
from contextlib import suppress
+from datetime import datetime, timedelta
-from discord import Colour, Forbidden, Message, NotFound, Object
-from discord.ext.commands import Cog, Context, command
+import discord
+from discord.ext import tasks
+from discord.ext.commands import Cog, Context, command, group
+from discord.utils import snowflake_time
from bot import constants
from bot.bot import Bot
-from bot.decorators import in_whitelist, without_role
+from bot.decorators import in_whitelist, with_role, without_role
from bot.exts.moderation.modlog import ModLog
from bot.utils.checks import InWhitelistCheckFailure, without_role_check
+from bot.utils.redis_cache import RedisCache
log = logging.getLogger(__name__)
-WELCOME_MESSAGE = f"""
-Hello! Welcome to the server, and thanks for verifying yourself!
+# Sent via DMs once user joins the guild
+ON_JOIN_MESSAGE = f"""
+Hello! Welcome to Python Discord!
+
+As a new user, you have read-only access to a few select channels to give you a taste of what our server is like.
+
+In order to see the rest of the channels and to send messages, you first have to accept our rules. To do so, \
+please visit <#{constants.Channels.verification}>. Thank you!
+"""
+
+# Sent via DMs once user verifies
+VERIFIED_MESSAGE = f"""
+Thanks for verifying yourself!
For your records, these are the documents you accepted:
@@ -32,29 +49,471 @@ If you'd like to unsubscribe from the announcement notifications, simply send `!
<#{constants.Channels.bot_commands}>.
"""
-BOT_MESSAGE_DELETE_DELAY = 10
+# Sent via DMs to users kicked for failing to verify
+KICKED_MESSAGE = f"""
+Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \
+within `{constants.Verification.kicked_after}` days. If this was an accident, please feel free to join us again!
+
+{constants.Guild.invite}
+"""
+
+# Sent periodically in the verification channel
+REMINDER_MESSAGE = f"""
+<@&{constants.Roles.unverified}>
+
+Welcome to Python Discord! Please read the documents mentioned above and type `!accept` to gain permissions \
+to send messages in the community!
+
+You will be kicked if you don't verify within `{constants.Verification.kicked_after}` days.
+""".strip()
+
+# An async function taking a Member param
+Request = t.Callable[[discord.Member], t.Awaitable]
+
+
+class StopExecution(Exception):
+ """Signals that a task should halt immediately & alert admins."""
+
+ def __init__(self, reason: discord.HTTPException) -> None:
+ super().__init__()
+ self.reason = reason
+
+
+class Limit(t.NamedTuple):
+ """Composition over config for throttling requests."""
+
+ batch_size: int # Amount of requests after which to pause
+ sleep_secs: int # Sleep this many seconds after each batch
+
+
+def mention_role(role_id: int) -> discord.AllowedMentions:
+ """Construct an allowed mentions instance that allows pinging `role_id`."""
+ return discord.AllowedMentions(roles=[discord.Object(role_id)])
+
+
+def is_verified(member: discord.Member) -> bool:
+ """
+ Check whether `member` is considered verified.
+
+ Members are considered verified if they have at least 1 role other than
+ the default role (@everyone) and the @Unverified role.
+ """
+ unverified_roles = {
+ member.guild.get_role(constants.Roles.unverified),
+ member.guild.default_role,
+ }
+ return len(set(member.roles) - unverified_roles) > 0
class Verification(Cog):
- """User verification and role self-management."""
+ """
+ User verification and role management.
+
+ There are two internal tasks in this cog:
+
+ * `update_unverified_members`
+ * Unverified members are given the @Unverified role after configured `unverified_after` days
+ * Unverified members are kicked after configured `kicked_after` days
+ * `ping_unverified`
+ * Periodically ping the @Unverified role in the verification channel
+
+ Statistics are collected in the 'verification.' namespace.
- def __init__(self, bot: Bot):
+ Moderators+ can use the `verification` command group to start or stop both internal
+ tasks, if necessary. Settings are persisted in Redis across sessions.
+
+ Additionally, this cog offers the !accept, !subscribe and !unsubscribe commands,
+ and keeps the verification channel clean by deleting messages.
+ """
+
+ # Persist task settings & last sent `REMINDER_MESSAGE` id
+ # RedisCache[
+ # "tasks_running": int (0 or 1),
+ # "last_reminder": int (discord.Message.id),
+ # ]
+ task_cache = RedisCache()
+
+ def __init__(self, bot: Bot) -> None:
+ """Start internal tasks."""
self.bot = bot
+ self.bot.loop.create_task(self._maybe_start_tasks())
+
+ def cog_unload(self) -> None:
+ """
+ Cancel internal tasks.
+
+ This is necessary, as tasks are not automatically cancelled on cog unload.
+ """
+ self._stop_tasks(gracefully=False)
@property
def mod_log(self) -> ModLog:
"""Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
+ async def _maybe_start_tasks(self) -> None:
+ """
+ Poll Redis to check whether internal tasks should start.
+
+ Redis must be interfaced with from an async function.
+ """
+ log.trace("Checking whether background tasks should begin")
+ setting: t.Optional[int] = await self.task_cache.get("tasks_running") # This can be None if never set
+
+ if setting:
+ log.trace("Background tasks will be started")
+ self.update_unverified_members.start()
+ self.ping_unverified.start()
+
+ def _stop_tasks(self, *, gracefully: bool) -> None:
+ """
+ Stop the update users & ping @Unverified tasks.
+
+ If `gracefully` is True, the tasks will be able to finish their current iteration.
+ Otherwise, they are cancelled immediately.
+ """
+ log.info(f"Stopping internal tasks ({gracefully=})")
+ if gracefully:
+ self.update_unverified_members.stop()
+ self.ping_unverified.stop()
+ else:
+ self.update_unverified_members.cancel()
+ self.ping_unverified.cancel()
+
+ # region: automatically update unverified users
+
+ async def _verify_kick(self, n_members: int) -> bool:
+ """
+ Determine whether `n_members` is a reasonable amount of members to kick.
+
+ First, `n_members` is checked against the size of the PyDis guild. If `n_members` are
+ more than the configured `kick_confirmation_threshold` of the guild, the operation
+ must be confirmed by staff in #core-dev. Otherwise, the operation is seen as safe.
+ """
+ log.debug(f"Checking whether {n_members} members are safe to kick")
+
+ await self.bot.wait_until_guild_available() # Ensure cache is populated before we grab the guild
+ pydis = self.bot.get_guild(constants.Guild.id)
+
+ percentage = n_members / len(pydis.members)
+ if percentage < constants.Verification.kick_confirmation_threshold:
+ log.debug(f"Kicking {percentage:.2%} of the guild's population is seen as safe")
+ return True
+
+ # Since `n_members` is a suspiciously large number, we will ask for confirmation
+ log.debug("Amount of users is too large, requesting staff confirmation")
+
+ core_dev_channel = pydis.get_channel(constants.Channels.dev_core)
+ core_dev_ping = f"<@&{constants.Roles.core_developers}>"
+
+ confirmation_msg = await core_dev_channel.send(
+ f"{core_dev_ping} Verification determined that `{n_members}` members should be kicked as they haven't "
+ f"verified in `{constants.Verification.kicked_after}` days. This is `{percentage:.2%}` of the guild's "
+ f"population. Proceed?",
+ allowed_mentions=mention_role(constants.Roles.core_developers),
+ )
+
+ options = (constants.Emojis.incident_actioned, constants.Emojis.incident_unactioned)
+ for option in options:
+ await confirmation_msg.add_reaction(option)
+
+ core_dev_ids = [member.id for member in pydis.get_role(constants.Roles.core_developers).members]
+
+ def check(reaction: discord.Reaction, user: discord.User) -> bool:
+ """Check whether `reaction` is a valid reaction to `confirmation_msg`."""
+ return (
+ reaction.message.id == confirmation_msg.id # Reacted to `confirmation_msg`
+ and str(reaction.emoji) in options # With one of `options`
+ and user.id in core_dev_ids # By a core developer
+ )
+
+ timeout = 60 * 5 # Seconds, i.e. 5 minutes
+ try:
+ choice, _ = await self.bot.wait_for("reaction_add", check=check, timeout=timeout)
+ except asyncio.TimeoutError:
+ log.debug("Staff prompt not answered, aborting operation")
+ return False
+ finally:
+ with suppress(discord.HTTPException):
+ await confirmation_msg.clear_reactions()
+
+ result = str(choice) == constants.Emojis.incident_actioned
+ log.debug(f"Received answer: {choice}, result: {result}")
+
+ # Edit the prompt message to reflect the final choice
+ if result is True:
+ result_msg = f":ok_hand: {core_dev_ping} Request to kick `{n_members}` members was authorized!"
+ else:
+ result_msg = f":warning: {core_dev_ping} Request to kick `{n_members}` members was denied!"
+
+ with suppress(discord.HTTPException):
+ await confirmation_msg.edit(content=result_msg)
+
+ return result
+
+ async def _alert_admins(self, exception: discord.HTTPException) -> None:
+ """
+ Ping @Admins with information about `exception`.
+
+ This is used when a critical `exception` caused a verification task to abort.
+ """
+ await self.bot.wait_until_guild_available()
+ log.info(f"Sending admin alert regarding exception: {exception}")
+
+ admins_channel = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.admins)
+ ping = f"<@&{constants.Roles.admins}>"
+
+ await admins_channel.send(
+ f"{ping} Aborted updating unverified users due to the following exception:\n"
+ f"```{exception}```\n"
+ f"Internal tasks will be stopped.",
+ allowed_mentions=mention_role(constants.Roles.admins),
+ )
+
+ async def _send_requests(self, members: t.Collection[discord.Member], request: Request, limit: Limit) -> int:
+ """
+ Pass `members` one by one to `request` handling Discord exceptions.
+
+ This coroutine serves as a generic `request` executor for kicking members and adding
+ roles, as it allows us to define the error handling logic in one place only.
+
+ Any `request` has the ability to completely abort the execution by raising `StopExecution`.
+ In such a case, the @Admins will be alerted of the reason attribute.
+
+ To avoid rate-limits, pass a `limit` configuring the batch size and the amount of seconds
+ to sleep between batches.
+
+ Returns the amount of successful requests. Failed requests are logged at info level.
+ """
+ log.info(f"Sending {len(members)} requests")
+ n_success, bad_statuses = 0, set()
+
+ for progress, member in enumerate(members, start=1):
+ if is_verified(member): # Member could have verified in the meantime
+ continue
+ try:
+ await request(member)
+ except StopExecution as stop_execution:
+ await self._alert_admins(stop_execution.reason)
+ await self.task_cache.set("tasks_running", 0)
+ self._stop_tasks(gracefully=True) # Gracefully finish current iteration, then stop
+ break
+ except discord.HTTPException as http_exc:
+ bad_statuses.add(http_exc.status)
+ else:
+ n_success += 1
+
+ if progress % limit.batch_size == 0:
+ log.trace(f"Processed {progress} requests, pausing for {limit.sleep_secs} seconds")
+ await asyncio.sleep(limit.sleep_secs)
+
+ if bad_statuses:
+ log.info(f"Failed to send {len(members) - n_success} requests due to following statuses: {bad_statuses}")
+
+ return n_success
+
+ async def _kick_members(self, members: t.Collection[discord.Member]) -> int:
+ """
+ Kick `members` from the PyDis guild.
+
+ Due to strict ratelimits on sending messages (120 requests / 60 secs), we sleep for a second
+ after each 2 requests to allow breathing room for other features.
+
+ Note that this is a potentially destructive operation. Returns the amount of successful requests.
+ """
+ log.info(f"Kicking {len(members)} members (not verified after {constants.Verification.kicked_after} days)")
+
+ async def kick_request(member: discord.Member) -> None:
+ """Send `KICKED_MESSAGE` to `member` and kick them from the guild."""
+ try:
+ await member.send(KICKED_MESSAGE)
+ except discord.Forbidden as exc_403:
+ log.trace(f"DM dispatch failed on 403 error with code: {exc_403.code}")
+ if exc_403.code != 50_007: # 403 raised for any other reason than disabled DMs
+ raise StopExecution(reason=exc_403)
+ await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days")
+
+ n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1))
+ self.bot.stats.incr("verification.kicked", count=n_kicked)
+
+ return n_kicked
+
+ async def _give_role(self, members: t.Collection[discord.Member], role: discord.Role) -> int:
+ """
+ Give `role` to all `members`.
+
+ We pause for a second after batches of 25 requests to ensure ratelimits aren't exceeded.
+
+ Returns the amount of successful requests.
+ """
+ log.info(
+ f"Assigning {role} role to {len(members)} members (not verified "
+ f"after {constants.Verification.unverified_after} days)"
+ )
+
+ async def role_request(member: discord.Member) -> None:
+ """Add `role` to `member`."""
+ await member.add_roles(role, reason=f"Not verified after {constants.Verification.unverified_after} days")
+
+ return await self._send_requests(members, role_request, Limit(batch_size=25, sleep_secs=1))
+
+ async def _check_members(self) -> t.Tuple[t.Set[discord.Member], t.Set[discord.Member]]:
+ """
+ Check in on the verification status of PyDis members.
+
+ This coroutine finds two sets of users:
+ * Not verified after configured `unverified_after` days, should be given the @Unverified role
+ * Not verified after configured `kicked_after` days, should be kicked from the guild
+
+ These sets are always disjoint, i.e. share no common members.
+ """
+ await self.bot.wait_until_guild_available() # Ensure cache is ready
+ pydis = self.bot.get_guild(constants.Guild.id)
+
+ unverified = pydis.get_role(constants.Roles.unverified)
+ current_dt = datetime.utcnow() # Discord timestamps are UTC
+
+ # Users to be given the @Unverified role, and those to be kicked, these should be entirely disjoint
+ for_role, for_kick = set(), set()
+
+ log.debug("Checking verification status of guild members")
+ for member in pydis.members:
+
+ # Skip verified members, bots, and members for which we do not know their join date,
+ # this should be extremely rare but docs mention that it can happen
+ if is_verified(member) or member.bot or member.joined_at is None:
+ continue
+
+ # At this point, we know that `member` is an unverified user, and we will decide what
+ # to do with them based on time passed since their join date
+ since_join = current_dt - member.joined_at
+
+ if since_join > timedelta(days=constants.Verification.kicked_after):
+ for_kick.add(member) # User should be removed from the guild
+
+ elif (
+ since_join > timedelta(days=constants.Verification.unverified_after)
+ and unverified not in member.roles
+ ):
+ for_role.add(member) # User should be given the @Unverified role
+
+ log.debug(f"Found {len(for_role)} users for {unverified} role, {len(for_kick)} users to be kicked")
+ return for_role, for_kick
+
+ @tasks.loop(minutes=30)
+ async def update_unverified_members(self) -> None:
+ """
+ Periodically call `_check_members` and update unverified members accordingly.
+
+ After each run, a summary will be sent to the modlog channel. If a suspiciously high
+ amount of members to be kicked is found, the operation is guarded by `_verify_kick`.
+ """
+ log.info("Updating unverified guild members")
+
+ await self.bot.wait_until_guild_available()
+ unverified = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.unverified)
+
+ for_role, for_kick = await self._check_members()
+
+ if not for_role:
+ role_report = f"Found no users to be assigned the {unverified.mention} role."
+ else:
+ n_roles = await self._give_role(for_role, unverified)
+ role_report = f"Assigned {unverified.mention} role to `{n_roles}`/`{len(for_role)}` members."
+
+ if not for_kick:
+ kick_report = "Found no users to be kicked."
+ elif not await self._verify_kick(len(for_kick)):
+ kick_report = f"Not authorized to kick `{len(for_kick)}` members."
+ else:
+ n_kicks = await self._kick_members(for_kick)
+ kick_report = f"Kicked `{n_kicks}`/`{len(for_kick)}` members from the guild."
+
+ await self.mod_log.send_log_message(
+ icon_url=self.bot.user.avatar_url,
+ colour=discord.Colour.blurple(),
+ title="Verification system",
+ text=f"{kick_report}\n{role_report}",
+ )
+
+ # endregion
+ # region: periodically ping @Unverified
+
+ @tasks.loop(hours=constants.Verification.reminder_frequency)
+ async def ping_unverified(self) -> None:
+ """
+ Delete latest `REMINDER_MESSAGE` and send it again.
+
+ This utilizes RedisCache to persist the latest reminder message id.
+ """
+ await self.bot.wait_until_guild_available()
+ verification = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.verification)
+
+ last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder")
+
+ if last_reminder is not None:
+ log.trace(f"Found verification reminder message in cache, deleting: {last_reminder}")
+
+ with suppress(discord.HTTPException): # If something goes wrong, just ignore it
+ await self.bot.http.delete_message(verification.id, last_reminder)
+
+ log.trace("Sending verification reminder")
+ new_reminder = await verification.send(
+ REMINDER_MESSAGE, allowed_mentions=mention_role(constants.Roles.unverified),
+ )
+
+ await self.task_cache.set("last_reminder", new_reminder.id)
+
+ @ping_unverified.before_loop
+ async def _before_first_ping(self) -> None:
+ """
+ Sleep until `REMINDER_MESSAGE` should be sent again.
+
+ If latest reminder is not cached, exit instantly. Otherwise, wait wait until the
+ configured `reminder_frequency` has passed.
+ """
+ last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder")
+
+ if last_reminder is None:
+ log.trace("Latest verification reminder message not cached, task will not wait")
+ return
+
+ # Convert cached message id into a timestamp
+ time_since = datetime.utcnow() - snowflake_time(last_reminder)
+ log.trace(f"Time since latest verification reminder: {time_since}")
+
+ to_sleep = timedelta(hours=constants.Verification.reminder_frequency) - time_since
+ log.trace(f"Time to sleep until next ping: {to_sleep}")
+
+ # Delta can be negative if `reminder_frequency` has already passed
+ secs = max(to_sleep.total_seconds(), 0)
+ await asyncio.sleep(secs)
+
+ # endregion
+ # region: listeners
+
@Cog.listener()
- async def on_message(self, message: Message) -> None:
+ async def on_member_join(self, member: discord.Member) -> None:
+ """Attempt to send initial direct message to each new member."""
+ if member.guild.id != constants.Guild.id:
+ return # Only listen for PyDis events
+
+ log.trace(f"Sending on join message to new member: {member.id}")
+ with suppress(discord.Forbidden):
+ await member.send(ON_JOIN_MESSAGE)
+
+ @Cog.listener()
+ async def on_message(self, message: discord.Message) -> None:
"""Check new message event for messages to the checkpoint channel & process."""
if message.channel.id != constants.Channels.verification:
return # Only listen for #checkpoint messages
+ if message.content == REMINDER_MESSAGE:
+ return # Ignore bots own verification reminder
+
if message.author.bot:
# They're a bot, delete their message after the delay.
- await message.delete(delay=BOT_MESSAGE_DELETE_DELAY)
+ await message.delete(delay=constants.Verification.bot_message_delete_delay)
return
# if a user mentions a role or guild member
@@ -74,7 +533,7 @@ class Verification(Cog):
# Send pretty mod log embed to mod-alerts
await self.mod_log.send_log_message(
icon_url=constants.Icons.filtering,
- colour=Colour(constants.Colours.soft_red),
+ colour=discord.Colour(constants.Colours.soft_red),
title=f"User/Role mentioned in {message.channel.name}",
text=embed_text,
thumbnail=message.author.avatar_url_as(static_format="png"),
@@ -103,23 +562,117 @@ class Verification(Cog):
)
log.trace(f"Deleting the message posted by {ctx.author}")
- with suppress(NotFound):
+ with suppress(discord.NotFound):
await ctx.message.delete()
+ # endregion
+ # region: task management commands
+
+ @with_role(*constants.MODERATION_ROLES)
+ @group(name="verification")
+ async def verification_group(self, ctx: Context) -> None:
+ """Manage internal verification tasks."""
+ if ctx.invoked_subcommand is None:
+ await ctx.send_help(ctx.command)
+
+ @verification_group.command(name="status")
+ async def status_cmd(self, ctx: Context) -> None:
+ """Check whether verification tasks are running."""
+ log.trace("Checking status of verification tasks")
+
+ if self.update_unverified_members.is_running():
+ update_status = f"{constants.Emojis.incident_actioned} Member update task is running."
+ else:
+ update_status = f"{constants.Emojis.incident_unactioned} Member update task is **not** running."
+
+ mention = f"<@&{constants.Roles.unverified}>"
+ if self.ping_unverified.is_running():
+ ping_status = f"{constants.Emojis.incident_actioned} Ping {mention} task is running."
+ else:
+ ping_status = f"{constants.Emojis.incident_unactioned} Ping {mention} task is **not** running."
+
+ embed = discord.Embed(
+ title="Verification system",
+ description=f"{update_status}\n{ping_status}",
+ colour=discord.Colour.blurple(),
+ )
+ await ctx.send(embed=embed)
+
+ @verification_group.command(name="start")
+ async def start_cmd(self, ctx: Context) -> None:
+ """Start verification tasks if they are not already running."""
+ log.info("Starting verification tasks")
+
+ if not self.update_unverified_members.is_running():
+ self.update_unverified_members.start()
+
+ if not self.ping_unverified.is_running():
+ self.ping_unverified.start()
+
+ await self.task_cache.set("tasks_running", 1)
+
+ colour = discord.Colour.blurple()
+ await ctx.send(embed=discord.Embed(title="Verification system", description="Done. :ok_hand:", colour=colour))
+
+ @verification_group.command(name="stop", aliases=["kill"])
+ async def stop_cmd(self, ctx: Context) -> None:
+ """Stop verification tasks."""
+ log.info("Stopping verification tasks")
+
+ self._stop_tasks(gracefully=False)
+ await self.task_cache.set("tasks_running", 0)
+
+ colour = discord.Colour.blurple()
+ await ctx.send(embed=discord.Embed(title="Verification system", description="Tasks canceled.", colour=colour))
+
+ # endregion
+ # region: accept and subscribe commands
+
+ def _bump_verified_stats(self, verified_member: discord.Member) -> None:
+ """
+ Increment verification stats for `verified_member`.
+
+ Each member falls into one of the three categories:
+ * Verified within 24 hours after joining
+ * Does not have @Unverified role yet
+ * Does have @Unverified role
+
+ Stats for member kicking are handled separately.
+ """
+ if verified_member.joined_at is None: # Docs mention this can happen
+ return
+
+ if (datetime.utcnow() - verified_member.joined_at) < timedelta(hours=24):
+ category = "accepted_on_day_one"
+ elif constants.Roles.unverified not in [role.id for role in verified_member.roles]:
+ category = "accepted_before_unverified"
+ else:
+ category = "accepted_after_unverified"
+
+ log.trace(f"Bumping verification stats in category: {category}")
+ self.bot.stats.incr(f"verification.{category}")
+
@command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True)
@without_role(constants.Roles.verified)
@in_whitelist(channels=(constants.Channels.verification,))
async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
"""Accept our rules and gain access to the rest of the server."""
log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.")
- await ctx.author.add_roles(Object(constants.Roles.verified), reason="Accepted the rules")
+ await ctx.author.add_roles(discord.Object(constants.Roles.verified), reason="Accepted the rules")
+
+ self._bump_verified_stats(ctx.author) # This checks for @Unverified so make sure it's not yet removed
+
+ if constants.Roles.unverified in [role.id for role in ctx.author.roles]:
+ log.debug(f"Removing Unverified role from: {ctx.author}")
+ await ctx.author.remove_roles(discord.Object(constants.Roles.unverified))
+
try:
- await ctx.author.send(WELCOME_MESSAGE)
- except Forbidden:
+ await ctx.author.send(VERIFIED_MESSAGE)
+ except discord.Forbidden:
log.info(f"Sending welcome message failed for {ctx.author}.")
finally:
log.trace(f"Deleting accept message by {ctx.author}.")
- with suppress(NotFound):
+ with suppress(discord.NotFound):
self.mod_log.ignore(constants.Event.message_delete, ctx.message.id)
await ctx.message.delete()
@@ -139,7 +692,7 @@ class Verification(Cog):
return
log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.")
- await ctx.author.add_roles(Object(constants.Roles.announcements), reason="Subscribed to announcements")
+ await ctx.author.add_roles(discord.Object(constants.Roles.announcements), reason="Subscribed to announcements")
log.trace(f"Deleting the message posted by {ctx.author}.")
@@ -163,7 +716,9 @@ class Verification(Cog):
return
log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.")
- await ctx.author.remove_roles(Object(constants.Roles.announcements), reason="Unsubscribed from announcements")
+ await ctx.author.remove_roles(
+ discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements"
+ )
log.trace(f"Deleting the message posted by {ctx.author}.")
@@ -171,6 +726,9 @@ class Verification(Cog):
f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications."
)
+ # endregion
+ # region: miscellaneous
+
# This cannot be static (must have a __func__ attribute).
async def cog_command_error(self, ctx: Context, error: Exception) -> None:
"""Check for & ignore any InWhitelistCheckFailure."""
@@ -185,6 +743,8 @@ class Verification(Cog):
else:
return True
+ # endregion
+
def setup(bot: Bot) -> None:
"""Load the Verification cog."""
diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py
index 013d3ee03..7118dee02 100644
--- a/bot/exts/moderation/watchchannels/_watchchannel.py
+++ b/bot/exts/moderation/watchchannels/_watchchannel.py
@@ -15,6 +15,8 @@ from discord.ext.commands import Cog, Context
from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons
+from bot.exts.filters.token_remover import TokenRemover
+from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE
from bot.exts.moderation.modlog import ModLog
from bot.pagination import LinePaginator
from bot.utils import CogABCMeta, messages
@@ -226,14 +228,16 @@ class WatchChannel(metaclass=CogABCMeta):
await self.send_header(msg)
- cleaned_content = msg.clean_content
-
- if cleaned_content:
+ if TokenRemover.find_token_in_message(msg) or WEBHOOK_URL_RE.search(msg.content):
+ cleaned_content = "Content is censored because it contains a bot or webhook token."
+ elif cleaned_content := msg.clean_content:
# Put all non-media URLs in a code block to prevent embeds
media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")}
for url in URL_RE.findall(cleaned_content):
if url not in media_urls:
cleaned_content = cleaned_content.replace(url, f"`{url}`")
+
+ if cleaned_content:
await self.webhook_send(
cleaned_content,
username=msg.author.display_name,
diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py
index bfba19820..d7127b5c4 100644
--- a/bot/exts/moderation/watchchannels/bigbrother.py
+++ b/bot/exts/moderation/watchchannels/bigbrother.py
@@ -59,7 +59,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
"""
await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache)
- @bigbrother_group.command(name='watch', aliases=('w',))
+ @bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',))
@with_role(*MODERATION_ROLES)
async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""
@@ -70,7 +70,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
"""
await self.apply_watch(ctx, user, reason)
- @bigbrother_group.command(name='unwatch', aliases=('uw',))
+ @bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',))
@with_role(*MODERATION_ROLES)
async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""Stop relaying messages by the given `user`."""
@@ -131,8 +131,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
active_watches = await self.bot.api_client.get(
self.api_endpoint,
params=ChainMap(
+ {"user__id": str(user.id)},
self.api_default_params,
- {"user__id": str(user.id)}
)
)
if active_watches:
diff --git a/bot/exts/moderation/watchchannels/talentpool.py b/bot/exts/moderation/watchchannels/talentpool.py
index f65f9d664..3724e94e6 100644
--- a/bot/exts/moderation/watchchannels/talentpool.py
+++ b/bot/exts/moderation/watchchannels/talentpool.py
@@ -1,8 +1,9 @@
import logging
import textwrap
from collections import ChainMap
+from typing import Union
-from discord import Color, Embed, Member
+from discord import Color, Embed, Member, User
from discord.ext.commands import Cog, Context, group
from bot.api import ResponseCodeError
@@ -36,7 +37,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
"""Highlights the activity of helper nominees by relaying their messages to the talent pool channel."""
await ctx.send_help(ctx.command)
- @nomination_group.command(name='watched', aliases=('all', 'list'))
+ @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",))
@with_role(*MODERATION_ROLES)
async def watched_command(
self, ctx: Context, oldest_first: bool = False, update_cache: bool = True
@@ -62,7 +63,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
"""
await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache)
- @nomination_group.command(name='watch', aliases=('w', 'add', 'a'))
+ @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",))
@with_role(*STAFF_ROLES)
async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""
@@ -156,7 +157,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
max_size=1000
)
- @nomination_group.command(name='unwatch', aliases=('end', ))
+ @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",))
@with_role(*MODERATION_ROLES)
async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""
@@ -164,25 +165,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
Providing a `reason` is required.
"""
- active_nomination = await self.bot.api_client.get(
- self.api_endpoint,
- params=ChainMap(
- self.api_default_params,
- {"user__id": str(user.id)}
- )
- )
-
- if not active_nomination:
+ if await self.unwatch(user.id, reason):
+ await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed")
+ else:
await ctx.send(":x: The specified user does not have an active nomination")
- return
-
- [nomination] = active_nomination
- await self.bot.api_client.patch(
- f"{self.api_endpoint}/{nomination['id']}",
- json={'end_reason': reason, 'active': False}
- )
- await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed")
- self._remove_user(user.id)
@nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True)
@with_role(*MODERATION_ROLES)
@@ -220,6 +206,36 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
await ctx.send(f":white_check_mark: Updated the {field} of the nomination!")
+ @Cog.listener()
+ async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None:
+ """Remove `user` from the talent pool after they are banned."""
+ await self.unwatch(user.id, "User was banned.")
+
+ async def unwatch(self, user_id: int, reason: str) -> bool:
+ """End the active nomination of a user with the given reason and return True on success."""
+ active_nomination = await self.bot.api_client.get(
+ self.api_endpoint,
+ params=ChainMap(
+ {"user__id": str(user_id)},
+ self.api_default_params,
+ )
+ )
+
+ if not active_nomination:
+ log.debug(f"No active nominate exists for {user_id=}")
+ return False
+
+ log.info(f"Ending nomination: {user_id=} {reason=}")
+
+ nomination = active_nomination[0]
+ await self.bot.api_client.patch(
+ f"{self.api_endpoint}/{nomination['id']}",
+ json={'end_reason': reason, 'active': False}
+ )
+ self._remove_user(user_id)
+
+ return True
+
def _nomination_to_string(self, nomination_object: dict) -> str:
"""Creates a string representation of a nomination."""
guild = self.bot.get_guild(Guild.id)
diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py
index 866fd2b68..66f340a99 100644
--- a/bot/exts/utils/bot.py
+++ b/bot/exts/utils/bot.py
@@ -11,6 +11,7 @@ from bot.bot import Bot
from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs
from bot.decorators import with_role
from bot.exts.filters.token_remover import TokenRemover
+from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE
from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
@@ -240,6 +241,7 @@ class BotCog(Cog, name="Bot"):
and not msg.author.bot
and len(msg.content.splitlines()) > 3
and not TokenRemover.find_token_in_message(msg)
+ and not WEBHOOK_URL_RE.search(msg.content)
)
if parse_codeblock: # no token in the msg
@@ -337,7 +339,7 @@ class BotCog(Cog, name="Bot"):
self.codeblock_message_ids[msg.id] = bot_message.id
self.bot.loop.create_task(
- wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot)
+ wait_for_deletion(bot_message, (msg.author.id,), self.bot)
)
else:
return
diff --git a/bot/exts/utils/eval.py b/bot/exts/utils/eval.py
index eb8bfb1cf..23e5998d8 100644
--- a/bot/exts/utils/eval.py
+++ b/bot/exts/utils/eval.py
@@ -15,6 +15,7 @@ from bot.bot import Bot
from bot.constants import Roles
from bot.decorators import with_role
from bot.interpreter import Interpreter
+from bot.utils import find_nth_occurrence, send_to_paste_service
log = logging.getLogger(__name__)
@@ -171,6 +172,30 @@ async def func(): # (None,) -> Any
res = traceback.format_exc()
out, embed = self._format(code, res)
+ out = out.rstrip("\n") # Strip empty lines from output
+
+ # Truncate output to max 15 lines or 1500 characters
+ newline_truncate_index = find_nth_occurrence(out, "\n", 15)
+
+ if newline_truncate_index is None or newline_truncate_index > 1500:
+ truncate_index = 1500
+ else:
+ truncate_index = newline_truncate_index
+
+ if len(out) > truncate_index:
+ paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py")
+ if paste_link is not None:
+ paste_text = f"full contents at {paste_link}"
+ else:
+ paste_text = "failed to upload contents to paste service."
+
+ await ctx.send(
+ f"```py\n{out[:truncate_index]}\n```"
+ f"... response truncated; {paste_text}",
+ embed=embed
+ )
+ return
+
await ctx.send(f"```py\n{out}```", embed=embed)
@group(name='internal', aliases=('int',))
diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py
index 65b5c3630..123f356e8 100644
--- a/bot/exts/utils/extensions.py
+++ b/bot/exts/utils/extensions.py
@@ -119,7 +119,7 @@ class Extensions(commands.Cog):
await ctx.send(msg)
- @extensions_group.command(name="reload", aliases=("r",))
+ @extensions_group.command(name="reload", aliases=("r",), root_aliases=("reload",))
async def reload_command(self, ctx: Context, *extensions: Extension) -> None:
r"""
Reload extensions given their fully qualified or unqualified names.
diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py
index 670493bcf..08bce2153 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -12,10 +12,10 @@ from dateutil.relativedelta import relativedelta
from discord.ext.commands import Cog, Context, Greedy, group
from bot.bot import Bot
-from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES
+from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES
from bot.converters import Duration
from bot.pagination import LinePaginator
-from bot.utils.checks import without_role_check
+from bot.utils.checks import with_role_check, without_role_check
from bot.utils.messages import send_denial
from bot.utils.scheduling import Scheduler
from bot.utils.time import humanize_delta
@@ -396,6 +396,8 @@ class Reminders(Cog):
async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None:
"""Edits a reminder with the given payload, then sends a confirmation message."""
+ if not await self._can_modify(ctx, id_):
+ return
reminder = await self._edit_reminder(id_, payload)
# Parse the reminder expiration back into a datetime
@@ -413,6 +415,8 @@ class Reminders(Cog):
@remind_group.command("delete", aliases=("remove", "cancel"))
async def delete_reminder(self, ctx: Context, id_: int) -> None:
"""Delete one of your active reminders."""
+ if not await self._can_modify(ctx, id_):
+ return
await self._delete_reminder(id_)
await self._send_confirmation(
ctx,
@@ -421,6 +425,24 @@ class Reminders(Cog):
delivery_dt=None,
)
+ async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int]) -> bool:
+ """
+ Check whether the reminder can be modified by the ctx author.
+
+ The check passes when the user is an admin, or if they created the reminder.
+ """
+ if with_role_check(ctx, Roles.admins):
+ return True
+
+ api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}")
+ if not api_response["author"] == ctx.author.id:
+ log.debug(f"{ctx.author} is not the reminder author and does not pass the check.")
+ await send_denial(ctx, "You can't modify reminders of other users!")
+ return False
+
+ log.debug(f"{ctx.author} is the reminder author and passes the check.")
+ return True
+
def setup(bot: Bot) -> None:
"""Load the Reminders cog."""
diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py
index 52c8b6f88..03bf454ac 100644
--- a/bot/exts/utils/snekbox.py
+++ b/bot/exts/utils/snekbox.py
@@ -14,6 +14,7 @@ from discord.ext.commands import Cog, Context, command, guild_only
from bot.bot import Bot
from bot.constants import Categories, Channels, Roles, URLs
from bot.decorators import in_whitelist
+from bot.utils import send_to_paste_service
from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
@@ -71,17 +72,7 @@ class Snekbox(Cog):
if len(output) > MAX_PASTE_LEN:
log.info("Full output is too long to upload")
return "too long to upload"
-
- url = URLs.paste_service.format(key="documents")
- try:
- async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp:
- data = await resp.json()
-
- if "key" in data:
- return URLs.paste_service.format(key=data["key"])
- except Exception:
- # 400 (Bad Request) means there are too many characters
- log.exception("Failed to upload full output to paste service!")
+ return await send_to_paste_service(self.bot.http_session, output, extension="txt")
@staticmethod
def prepare_input(code: str) -> str:
@@ -220,9 +211,7 @@ class Snekbox(Cog):
response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.")
else:
response = await ctx.send(msg)
- self.bot.loop.create_task(
- wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot)
- )
+ self.bot.loop.create_task(wait_for_deletion(response, (ctx.author.id,), ctx.bot))
log.info(f"{ctx.author}'s job had a return code of {results['returncode']}")
return response
diff --git a/bot/pagination.py b/bot/pagination.py
index bab98cacf..182b2fa76 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -374,167 +374,3 @@ class LinePaginator(Paginator):
log.debug("Ending pagination and clearing reactions.")
with suppress(discord.NotFound):
await message.clear_reactions()
-
-
-class ImagePaginator(Paginator):
- """
- Helper class that paginates images for embeds in messages.
-
- Close resemblance to LinePaginator, except focuses on images over text.
-
- Refer to ImagePaginator.paginate for documentation on how to use.
- """
-
- def __init__(self, prefix: str = "", suffix: str = ""):
- super().__init__(prefix, suffix)
- self._current_page = [prefix]
- self.images = []
- self._pages = []
- self._count = 0
-
- def add_line(self, line: str = '', *, empty: bool = False) -> None:
- """Adds a line to each page."""
- if line:
- self._count = len(line)
- else:
- self._count = 0
- self._current_page.append(line)
- self.close_page()
-
- def add_image(self, image: str = None) -> None:
- """Adds an image to a page."""
- self.images.append(image)
-
- @classmethod
- async def paginate(
- cls,
- pages: t.List[t.Tuple[str, str]],
- ctx: Context, embed: discord.Embed,
- prefix: str = "",
- suffix: str = "",
- timeout: int = 300,
- exception_on_empty_embed: bool = False
- ) -> t.Optional[discord.Message]:
- """
- Use a paginator and set of reactions to provide pagination over a set of title/image pairs.
-
- The reactions are used to switch page, or to finish with pagination.
-
- When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may
- be used to change page, or to remove pagination from the message.
-
- Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds).
-
- Example:
- >>> embed = discord.Embed()
- >>> embed.set_author(name="Some Operation", url=url, icon_url=icon)
- >>> await ImagePaginator.paginate(pages, ctx, embed)
- """
- def check_event(reaction_: discord.Reaction, member: discord.Member) -> bool:
- """Checks each reaction added, if it matches our conditions pass the wait_for."""
- return all((
- # Reaction is on the same message sent
- reaction_.message.id == message.id,
- # The reaction is part of the navigation menu
- str(reaction_.emoji) in PAGINATION_EMOJI,
- # The reactor is not a bot
- not member.bot
- ))
-
- paginator = cls(prefix=prefix, suffix=suffix)
- current_page = 0
-
- if not pages:
- if exception_on_empty_embed:
- log.exception("Pagination asked for empty image list")
- raise EmptyPaginatorEmbed("No images to paginate")
-
- log.debug("No images to add to paginator, adding '(no images to display)' message")
- pages.append(("(no images to display)", ""))
-
- for text, image_url in pages:
- paginator.add_line(text)
- paginator.add_image(image_url)
-
- embed.description = paginator.pages[current_page]
- image = paginator.images[current_page]
-
- if image:
- embed.set_image(url=image)
-
- if len(paginator.pages) <= 1:
- return await ctx.send(embed=embed)
-
- embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}")
- message = await ctx.send(embed=embed)
-
- for emoji in PAGINATION_EMOJI:
- await message.add_reaction(emoji)
-
- while True:
- # Start waiting for reactions
- try:
- reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=check_event)
- except asyncio.TimeoutError:
- log.debug("Timed out waiting for a reaction")
- break # We're done, no reactions for the last 5 minutes
-
- # Deletes the users reaction
- await message.remove_reaction(reaction.emoji, user)
-
- # Delete reaction press - [:trashcan:]
- if str(reaction.emoji) == DELETE_EMOJI:
- log.debug("Got delete reaction")
- return await message.delete()
-
- # First reaction press - [:track_previous:]
- if reaction.emoji == FIRST_EMOJI:
- if current_page == 0:
- log.debug("Got first page reaction, but we're on the first page - ignoring")
- continue
-
- current_page = 0
- reaction_type = "first"
-
- # Last reaction press - [:track_next:]
- if reaction.emoji == LAST_EMOJI:
- if current_page >= len(paginator.pages) - 1:
- log.debug("Got last page reaction, but we're on the last page - ignoring")
- continue
-
- current_page = len(paginator.pages) - 1
- reaction_type = "last"
-
- # Previous reaction press - [:arrow_left: ]
- if reaction.emoji == LEFT_EMOJI:
- if current_page <= 0:
- log.debug("Got previous page reaction, but we're on the first page - ignoring")
- continue
-
- current_page -= 1
- reaction_type = "previous"
-
- # Next reaction press - [:arrow_right:]
- if reaction.emoji == RIGHT_EMOJI:
- if current_page >= len(paginator.pages) - 1:
- log.debug("Got next page reaction, but we're on the last page - ignoring")
- continue
-
- current_page += 1
- reaction_type = "next"
-
- # Magic happens here, after page and reaction_type is set
- embed.description = paginator.pages[current_page]
-
- image = paginator.images[current_page]
- if image:
- embed.set_image(url=image)
-
- embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}")
- log.debug(f"Got {reaction_type} page reaction - changing to page {current_page + 1}/{len(paginator.pages)}")
-
- await message.edit(embed=embed)
-
- log.debug("Ending pagination and clearing reactions.")
- with suppress(discord.NotFound):
- await message.clear_reactions()
diff --git a/bot/resources/tags/ask.md b/bot/resources/tags/ask.md
deleted file mode 100644
index e2c2a88f6..000000000
--- a/bot/resources/tags/ask.md
+++ /dev/null
@@ -1,9 +0,0 @@
-Asking good questions will yield a much higher chance of a quick response:
-
-• Don't ask to ask your question, just go ahead and tell us your problem.
-• Don't ask if anyone is knowledgeable in some area, filtering serves no purpose.
-• Try to solve the problem on your own first, we're not going to write code for you.
-• Show us the code you've tried and any errors or unexpected results it's giving.
-• Be patient while we're helping you.
-
-You can find a much more detailed explanation [on our website](https://pythondiscord.com/pages/asking-good-questions/).
diff --git a/bot/rules/__init__.py b/bot/rules/__init__.py
index a01ceae73..8a69cadee 100644
--- a/bot/rules/__init__.py
+++ b/bot/rules/__init__.py
@@ -10,3 +10,4 @@ from .links import apply as apply_links
from .mentions import apply as apply_mentions
from .newlines import apply as apply_newlines
from .role_mentions import apply as apply_role_mentions
+from .everyone_ping import apply as apply_everyone_ping
diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py
index bbe9271b3..0e66df69c 100644
--- a/bot/rules/burst_shared.py
+++ b/bot/rules/burst_shared.py
@@ -2,11 +2,20 @@ from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
+from bot.constants import Channels
+
async def apply(
last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
- """Detects repeated messages sent by multiple users."""
+ """
+ Detects repeated messages sent by multiple users.
+
+ This filter never triggers in the verification channel.
+ """
+ if last_message.channel.id == Channels.verification:
+ return
+
total_recent = len(recent_messages)
if total_recent > config['max']:
diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py
index 5bab514f2..6e47f0197 100644
--- a/bot/rules/discord_emojis.py
+++ b/bot/rules/discord_emojis.py
@@ -5,6 +5,7 @@ from discord import Member, Message
DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>")
+CODE_BLOCK_RE = re.compile(r"```.*?```", flags=re.DOTALL)
async def apply(
@@ -17,8 +18,9 @@ async def apply(
if msg.author == last_message.author
)
+ # Get rid of code blocks in the message before searching for emojis.
total_emojis = sum(
- len(DISCORD_EMOJI_RE.findall(msg.content))
+ len(DISCORD_EMOJI_RE.findall(CODE_BLOCK_RE.sub("", msg.content)))
for msg in relevant_messages
)
diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py
new file mode 100644
index 000000000..89d9fe570
--- /dev/null
+++ b/bot/rules/everyone_ping.py
@@ -0,0 +1,41 @@
+import random
+import re
+from typing import Dict, Iterable, List, Optional, Tuple
+
+from discord import Embed, Member, Message
+
+from bot.constants import Colours, Guild, NEGATIVE_REPLIES
+
+# Generate regex for checking for pings:
+guild_id = Guild.id
+EVERYONE_RE_INLINE_CODE = re.compile(rf"^(?!`).*@everyone.*(?!`)$|^(?!`).*<@&{guild_id}>.*(?!`)$")
+EVERYONE_RE_MULTILINE_CODE = re.compile(rf"^(?!```).*@everyone.*(?!```)$|^(?!```).*<@&{guild_id}>.*(?!```)$")
+
+
+async def apply(
+ last_message: Message,
+ recent_messages: List[Message],
+ config: Dict[str, int],
+) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
+ """Detects if a user has sent an '@everyone' ping."""
+ relevant_messages = tuple(msg for msg in recent_messages if msg.author == last_message.author)
+
+ everyone_messages_count = 0
+ for msg in relevant_messages:
+ num_everyone_pings_inline = len(re.findall(EVERYONE_RE_INLINE_CODE, msg.content))
+ num_everyone_pings_multiline = len(re.findall(EVERYONE_RE_MULTILINE_CODE, msg.content))
+ if num_everyone_pings_inline and num_everyone_pings_multiline:
+ everyone_messages_count += 1
+
+ if everyone_messages_count > config["max"]:
+ # Send the channel an embed giving the user more info:
+ embed_text = f"Please don't try to ping {last_message.guild.member_count:,} people."
+ embed = Embed(title=random.choice(NEGATIVE_REPLIES), description=embed_text, colour=Colours.soft_red)
+ await last_message.channel.send(embed=embed)
+
+ return (
+ "pinged the everyone role",
+ (last_message.author,),
+ relevant_messages,
+ )
+ return None
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index 5a6e1811b..3e93fcb06 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -1,18 +1,5 @@
-from abc import ABCMeta
-
-from discord.ext.commands import CogMeta
-
+from bot.utils.helpers import CogABCMeta, find_nth_occurrence, pad_base64
from bot.utils.redis_cache import RedisCache
+from bot.utils.services import send_to_paste_service
-__all__ = ['RedisCache', 'CogABCMeta']
-
-
-class CogABCMeta(CogMeta, ABCMeta):
- """Metaclass for ABCs meant to be implemented as Cogs."""
-
- pass
-
-
-def pad_base64(data: str) -> str:
- """Return base64 `data` with padding characters to ensure its length is a multiple of 4."""
- return data + "=" * (-len(data) % 4)
+__all__ = ['RedisCache', 'CogABCMeta', 'find_nth_occurrence', 'pad_base64', 'send_to_paste_service']
diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py
new file mode 100644
index 000000000..d9b60af07
--- /dev/null
+++ b/bot/utils/helpers.py
@@ -0,0 +1,23 @@
+from abc import ABCMeta
+from typing import Optional
+
+from discord.ext.commands import CogMeta
+
+
+class CogABCMeta(CogMeta, ABCMeta):
+ """Metaclass for ABCs meant to be implemented as Cogs."""
+
+
+def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]:
+ """Return index of `n`th occurrence of `substring` in `string`, or None if not found."""
+ index = 0
+ for _ in range(n):
+ index = string.find(substring, index+1)
+ if index == -1:
+ return None
+ return index
+
+
+def pad_base64(data: str) -> str:
+ """Return base64 `data` with padding characters to ensure its length is a multiple of 4."""
+ return data + "=" * (-len(data) % 4)
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
index 670289941..aa8f17f75 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -19,25 +19,20 @@ log = logging.getLogger(__name__)
async def wait_for_deletion(
message: Message,
user_ids: Sequence[Snowflake],
+ client: Client,
deletion_emojis: Sequence[str] = (Emojis.trashcan,),
timeout: float = 60 * 5,
attach_emojis: bool = True,
- client: Optional[Client] = None
) -> None:
"""
Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message.
An `attach_emojis` bool may be specified to determine whether to attach the given
- `deletion_emojis` to the message in the given `context`
-
- A `client` instance may be optionally specified, otherwise client will be taken from the
- guild of the message.
+ `deletion_emojis` to the message in the given `context`.
"""
- if message.guild is None and client is None:
+ if message.guild is None:
raise ValueError("Message must be sent on a guild")
- bot = client or message.guild.me
-
if attach_emojis:
for emoji in deletion_emojis:
await message.add_reaction(emoji)
@@ -51,7 +46,7 @@ async def wait_for_deletion(
)
with contextlib.suppress(asyncio.TimeoutError):
- await bot.wait_for('reaction_add', check=check, timeout=timeout)
+ await client.wait_for('reaction_add', check=check, timeout=timeout)
await message.delete()
diff --git a/bot/utils/services.py b/bot/utils/services.py
new file mode 100644
index 000000000..087b9f969
--- /dev/null
+++ b/bot/utils/services.py
@@ -0,0 +1,54 @@
+import logging
+from typing import Optional
+
+from aiohttp import ClientConnectorError, ClientSession
+
+from bot.constants import URLs
+
+log = logging.getLogger(__name__)
+
+FAILED_REQUEST_ATTEMPTS = 3
+
+
+async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]:
+ """
+ Upload `contents` to the paste service.
+
+ `http_session` should be the current running ClientSession from aiohttp
+ `extension` is added to the output URL
+
+ When an error occurs, `None` is returned, otherwise the generated URL with the suffix.
+ """
+ extension = extension and f".{extension}"
+ log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.")
+ paste_url = URLs.paste_service.format(key="documents")
+ for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1):
+ try:
+ async with http_session.post(paste_url, data=contents) as response:
+ response_json = await response.json()
+ except ClientConnectorError:
+ log.warning(
+ f"Failed to connect to paste service at url {paste_url}, "
+ f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
+ )
+ continue
+ except Exception:
+ log.exception(
+ f"An unexpected error has occurred during handling of the request, "
+ f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
+ )
+ continue
+
+ if "message" in response_json:
+ log.warning(
+ f"Paste service returned error {response_json['message']} with status code {response.status}, "
+ f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
+ )
+ continue
+ elif "key" in response_json:
+ log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.")
+ return URLs.paste_service.format(key=response_json['key']) + extension
+ log.warning(
+ f"Got unexpected JSON response from paste service: {response_json}\n"
+ f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
+ )
diff --git a/config-default.yml b/config-default.yml
index e3ba9fb05..58651f548 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -38,6 +38,17 @@ style:
status_dnd: "<:status_dnd:470326272082313216>"
status_offline: "<:status_offline:470326266537705472>"
+ badge_staff: "<:discord_staff:743882896498098226>"
+ badge_partner: "<:partner:748666453242413136>"
+ badge_hypesquad: "<:hypesquad_events:743882896892362873>"
+ badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>"
+ badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>"
+ badge_hypesquad_brilliance: "<:hypesquad_brilliance:743882896938631248>"
+ badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>"
+ badge_early_supporter: "<:early_supporter:743882896909140058>"
+ badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>"
+ badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>"
+
incident_actioned: "<:incident_actioned:719645530128646266>"
incident_unactioned: "<:incident_unactioned:719645583245180960>"
incident_investigating: "<:incident_investigating:719645658671480924>"
@@ -65,9 +76,10 @@ style:
ducky_maul: &DUCKY_MAUL 640137724958867467
ducky_santa: &DUCKY_SANTA 655360331002019870
- upvotes: "<:upvotes:638729835245731840>"
- comments: "<:comments:638729835073765387>"
- user: "<:user:638729835442602003>"
+ # emotes used for #reddit
+ upvotes: "<:reddit_upvotes:755845219890757644>"
+ comments: "<:reddit_comments:755845255001014384>"
+ user: "<:reddit_users:755845303822974997>"
icons:
crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png"
@@ -123,6 +135,7 @@ style:
guild:
id: 267624335836053506
+ invite: "https://discord.gg/python"
categories:
help_available: 691405807388196926
@@ -225,8 +238,8 @@ guild:
partners: 323426753857191936
python_community: &PY_COMMUNITY_ROLE 458226413825294336
- # This is the Developers role on PyDis, here named verified for readability reasons
- verified: 352427296948486144
+ unverified: 739794855945044069
+ verified: 352427296948486144 # @Developers on PyDis
# Staff
admins: &ADMINS_ROLE 267628507062992896
@@ -341,9 +354,13 @@ anti_spam:
interval: 10
max: 7
- burst_shared:
- interval: 10
- max: 20
+ # Burst shared it (temporarily) disabled to prevent
+ # the bug that triggers multiple infractions/DMs per
+ # user. It also tends to catch a lot of innocent users
+ # now that we're so big.
+ # burst_shared:
+ # interval: 10
+ # max: 20
chars:
interval: 5
@@ -374,6 +391,12 @@ anti_spam:
interval: 10
max: 3
+ # The everyone ping filter is temporarily disabled
+ # until we've fixed a couple of bugs.
+ # everyone_ping:
+ # interval: 10
+ # max: 0
+
reddit:
subreddits:
@@ -382,13 +405,6 @@ reddit:
secret: !ENV "REDDIT_SECRET"
-wolfram:
- # Max requests per day.
- user_limit_day: 10
- guild_limit_day: 67
- key: !ENV "WOLFRAM_API_KEY"
-
-
big_brother:
log_delay: 15
header_message_limit: 15
@@ -475,5 +491,18 @@ python_news:
channel: *PYNEWS_CHANNEL
webhook: *PYNEWS_WEBHOOK
+
+verification:
+ unverified_after: 3 # Days after which non-Developers receive the @Unverified role
+ kicked_after: 30 # Days after which non-Developers get kicked from the guild
+ reminder_frequency: 28 # Hours between @Unverified pings
+ bot_message_delete_delay: 10 # Seconds before deleting bots response in #verification
+
+ # Number in range [0, 1] determining the percentage of unverified users that are safe
+ # to be kicked from the guild in one batch, any larger amount will require staff confirmation,
+ # set this to 0 to require explicit approval for batches of any size
+ kick_confirmation_threshold: 0.01 # 1%
+
+
config:
required_keys: ['bot.token']
diff --git a/tests/bot/exts/filters/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py
index 960894e5c..3393c6cdc 100644
--- a/tests/bot/exts/filters/test_antimalware.py
+++ b/tests/bot/exts/filters/test_antimalware.py
@@ -23,6 +23,8 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):
}
self.cog = antimalware.AntiMalware(self.bot)
self.message = MockMessage()
+ self.message.webhook_id = None
+ self.message.author.bot = None
self.whitelist = [".first", ".second", ".third"]
async def test_message_with_allowed_attachment(self):
@@ -48,6 +50,26 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):
self.message.delete.assert_not_called()
+ async def test_webhook_message_with_illegal_extension(self):
+ """A webhook message containing an illegal extension should be ignored."""
+ attachment = MockAttachment(filename="python.disallowed")
+ self.message.webhook_id = 697140105563078727
+ self.message.attachments = [attachment]
+
+ await self.cog.on_message(self.message)
+
+ self.message.delete.assert_not_called()
+
+ async def test_bot_message_with_illegal_extension(self):
+ """A bot message containing an illegal extension should be ignored."""
+ attachment = MockAttachment(filename="python.disallowed")
+ self.message.author.bot = 409107086526644234
+ self.message.attachments = [attachment]
+
+ await self.cog.on_message(self.message)
+
+ self.message.delete.assert_not_called()
+
async def test_message_with_illegal_extension_gets_deleted(self):
"""A message containing an illegal extension should send an embed."""
attachment = MockAttachment(filename="python.disallowed")
diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py
index be47d42ef..ba8d5d608 100644
--- a/tests/bot/exts/info/test_information.py
+++ b/tests/bot/exts/info/test_information.py
@@ -215,10 +215,10 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines):
self.bot.api_client.get.return_value = api_response
- expected_output = "\n".join(default_header + expected_lines)
+ expected_output = "\n".join(expected_lines)
actual_output = asyncio.run(method(self.member))
- self.assertEqual(expected_output, actual_output)
+ self.assertEqual((default_header, expected_output), actual_output)
def test_basic_user_infraction_counts_returns_correct_strings(self):
"""The method should correctly list both the total and active number of non-hidden infractions."""
@@ -249,7 +249,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
},
)
- header = ["**Infractions**"]
+ header = "Infractions"
self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header)
@@ -258,7 +258,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
test_values = (
{
"api response": [],
- "expected_lines": ["This user has never received an infraction."],
+ "expected_lines": ["No infractions"],
},
# Shows non-hidden inactive infraction as expected
{
@@ -304,7 +304,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
},
)
- header = ["**Infractions**"]
+ header = "Infractions"
self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header)
@@ -313,15 +313,15 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
test_values = (
{
"api response": [],
- "expected_lines": ["This user has never been nominated."],
+ "expected_lines": ["No nominations"],
},
{
"api response": [{'active': True}],
- "expected_lines": ["This user is **currently** nominated (1 nomination in total)."],
+ "expected_lines": ["This user is **currently** nominated", "(1 nomination in total)"],
},
{
"api response": [{'active': True}, {'active': False}],
- "expected_lines": ["This user is **currently** nominated (2 nominations in total)."],
+ "expected_lines": ["This user is **currently** nominated", "(2 nominations in total)"],
},
{
"api response": [{'active': False}],
@@ -334,7 +334,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
)
- header = ["**Nominations**"]
+ header = "Nominations"
self._method_subtests(self.cog.user_nomination_counts, test_values, header)
@@ -350,7 +350,10 @@ class UserEmbedTests(unittest.TestCase):
self.bot.api_client.get = unittest.mock.AsyncMock()
self.cog = information.Information(self.bot)
- @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=""))
+ @unittest.mock.patch(
+ f"{COG_PATH}.basic_user_infraction_counts",
+ new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
+ )
def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self):
"""The embed should use the string representation of the user if they don't have a nick."""
ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))
@@ -362,7 +365,10 @@ class UserEmbedTests(unittest.TestCase):
self.assertEqual(embed.title, "Mr. Hemlock")
- @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=""))
+ @unittest.mock.patch(
+ f"{COG_PATH}.basic_user_infraction_counts",
+ new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
+ )
def test_create_user_embed_uses_nick_in_title_if_available(self):
"""The embed should use the nick if it's available."""
ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))
@@ -374,7 +380,10 @@ class UserEmbedTests(unittest.TestCase):
self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)")
- @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=""))
+ @unittest.mock.patch(
+ f"{COG_PATH}.basic_user_infraction_counts",
+ new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
+ )
def test_create_user_embed_ignores_everyone_role(self):
"""Created `!user` embeds should not contain mention of the @everyone-role."""
ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))
@@ -386,8 +395,8 @@ class UserEmbedTests(unittest.TestCase):
embed = asyncio.run(self.cog.create_user_embed(ctx, user))
- self.assertIn("&Admins", embed.description)
- self.assertNotIn("&Everyone", embed.description)
+ self.assertIn("&Admins", embed.fields[1].value)
+ self.assertNotIn("&Everyone", embed.fields[1].value)
@unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock)
@unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock)
@@ -398,8 +407,8 @@ class UserEmbedTests(unittest.TestCase):
moderators_role = helpers.MockRole(name='Moderators')
moderators_role.colour = 100
- infraction_counts.return_value = "expanded infractions info"
- nomination_counts.return_value = "nomination info"
+ infraction_counts.return_value = ("Infractions", "expanded infractions info")
+ nomination_counts.return_value = ("Nominations", "nomination info")
user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role)
embed = asyncio.run(self.cog.create_user_embed(ctx, user))
@@ -409,20 +418,19 @@ class UserEmbedTests(unittest.TestCase):
self.assertEqual(
textwrap.dedent(f"""
- **User Information**
Created: {"1 year ago"}
Profile: {user.mention}
ID: {user.id}
+ """).strip(),
+ embed.fields[0].value
+ )
- **Member Information**
+ self.assertEqual(
+ textwrap.dedent(f"""
Joined: {"1 year ago"}
Roles: &Moderators
-
- expanded infractions info
-
- nomination info
""").strip(),
- embed.description
+ embed.fields[1].value
)
@unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock)
@@ -433,7 +441,7 @@ class UserEmbedTests(unittest.TestCase):
moderators_role = helpers.MockRole(name='Moderators')
moderators_role.colour = 100
- infraction_counts.return_value = "basic infractions info"
+ infraction_counts.return_value = ("Infractions", "basic infractions info")
user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role)
embed = asyncio.run(self.cog.create_user_embed(ctx, user))
@@ -442,21 +450,30 @@ class UserEmbedTests(unittest.TestCase):
self.assertEqual(
textwrap.dedent(f"""
- **User Information**
Created: {"1 year ago"}
Profile: {user.mention}
ID: {user.id}
+ """).strip(),
+ embed.fields[0].value
+ )
- **Member Information**
+ self.assertEqual(
+ textwrap.dedent(f"""
Joined: {"1 year ago"}
Roles: &Moderators
-
- basic infractions info
""").strip(),
- embed.description
+ embed.fields[1].value
+ )
+
+ self.assertEqual(
+ "basic infractions info",
+ embed.fields[3].value
)
- @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=""))
+ @unittest.mock.patch(
+ f"{COG_PATH}.basic_user_infraction_counts",
+ new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
+ )
def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self):
"""The embed should be created with the colour of the top role, if a top role is available."""
ctx = helpers.MockContext()
@@ -469,7 +486,10 @@ class UserEmbedTests(unittest.TestCase):
self.assertEqual(embed.colour, discord.Colour(moderators_role.colour))
- @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=""))
+ @unittest.mock.patch(
+ f"{COG_PATH}.basic_user_infraction_counts",
+ new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
+ )
def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self):
"""The embed should be created with a blurple colour if the user has no assigned roles."""
ctx = helpers.MockContext()
@@ -479,7 +499,10 @@ class UserEmbedTests(unittest.TestCase):
self.assertEqual(embed.colour, discord.Colour.blurple())
- @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=""))
+ @unittest.mock.patch(
+ f"{COG_PATH}.basic_user_infraction_counts",
+ new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
+ )
def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self):
"""The embed thumbnail should be set to the user's avatar in `png` format."""
ctx = helpers.MockContext()
diff --git a/tests/bot/exts/test_cogs.py b/tests/bot/exts/test_cogs.py
index 775c40722..f8e120262 100644
--- a/tests/bot/exts/test_cogs.py
+++ b/tests/bot/exts/test_cogs.py
@@ -54,6 +54,7 @@ class CommandNameTests(unittest.TestCase):
"""Return a list of all qualified names, including aliases, for the `command`."""
names = [f"{command.full_parent_name} {alias}".strip() for alias in command.aliases]
names.append(command.qualified_name)
+ names += getattr(command, "root_aliases", [])
return names
diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py
index f7b861035..c272a4756 100644
--- a/tests/bot/exts/utils/test_snekbox.py
+++ b/tests/bot/exts/utils/test_snekbox.py
@@ -1,5 +1,4 @@
import asyncio
-import logging
import unittest
from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch
@@ -39,43 +38,14 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1))
self.assertEqual(result, "too long to upload")
- async def test_upload_output(self):
+ @patch("bot.exts.utils.snekbox.send_to_paste_service")
+ async def test_upload_output(self, mock_paste_util):
"""Upload the eval output to the URLs.paste_service.format(key="documents") endpoint."""
- key = "MarkDiamond"
- resp = MagicMock()
- resp.json = AsyncMock(return_value={"key": key})
-
- context_manager = MagicMock()
- context_manager.__aenter__.return_value = resp
- self.bot.http_session.post.return_value = context_manager
-
- self.assertEqual(
- await self.cog.upload_output("My awesome output"),
- constants.URLs.paste_service.format(key=key)
- )
- self.bot.http_session.post.assert_called_with(
- constants.URLs.paste_service.format(key="documents"),
- data="My awesome output",
- raise_for_status=True
+ await self.cog.upload_output("Test output.")
+ mock_paste_util.assert_called_once_with(
+ self.bot.http_session, "Test output.", extension="txt"
)
- async def test_upload_output_gracefully_fallback_if_exception_during_request(self):
- """Output upload gracefully fallback if the upload fail."""
- resp = MagicMock()
- resp.json = AsyncMock(side_effect=Exception)
-
- context_manager = MagicMock()
- context_manager.__aenter__.return_value = resp
- self.bot.http_session.post.return_value = context_manager
-
- log = logging.getLogger("bot.exts.utils.snekbox")
- with self.assertLogs(logger=log, level='ERROR'):
- await self.cog.upload_output('My awesome output!')
-
- async def test_upload_output_gracefully_fallback_if_no_key_in_response(self):
- """Output upload gracefully fallback if there is no key entry in the response body."""
- self.assertEqual((await self.cog.upload_output('My awesome output!')), None)
-
def test_prepare_input(self):
cases = (
('print("Hello world!")', 'print("Hello world!")', 'non-formatted'),
diff --git a/tests/bot/test_pagination.py b/tests/bot/test_pagination.py
index ce880d457..630f2516d 100644
--- a/tests/bot/test_pagination.py
+++ b/tests/bot/test_pagination.py
@@ -44,18 +44,3 @@ class LinePaginatorTests(TestCase):
self.paginator.add_line('x' * (self.paginator.scale_to_size + 1))
# Note: item at index 1 is the truncated line, index 0 is prefix
self.assertEqual(self.paginator._current_page[1], 'x' * self.paginator.scale_to_size)
-
-
-class ImagePaginatorTests(TestCase):
- """Tests functionality of the `ImagePaginator`."""
-
- def setUp(self):
- """Create a paginator for the test method."""
- self.paginator = pagination.ImagePaginator()
-
- def test_add_image_appends_image(self):
- """`add_image` appends the image to the image list."""
- image = 'lemon'
- self.paginator.add_image(image)
-
- assert self.paginator.images == [image]
diff --git a/tests/bot/utils/test_services.py b/tests/bot/utils/test_services.py
new file mode 100644
index 000000000..5e0855704
--- /dev/null
+++ b/tests/bot/utils/test_services.py
@@ -0,0 +1,74 @@
+import logging
+import unittest
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+
+from aiohttp import ClientConnectorError
+
+from bot.utils.services import FAILED_REQUEST_ATTEMPTS, send_to_paste_service
+
+
+class PasteTests(unittest.IsolatedAsyncioTestCase):
+ def setUp(self) -> None:
+ self.http_session = MagicMock()
+
+ @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}")
+ async def test_url_and_sent_contents(self):
+ """Correct url was used and post was called with expected data."""
+ response = MagicMock(
+ json=AsyncMock(return_value={"key": ""})
+ )
+ self.http_session.post().__aenter__.return_value = response
+ self.http_session.post.reset_mock()
+ await send_to_paste_service(self.http_session, "Content")
+ self.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content")
+
+ @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}")
+ async def test_paste_returns_correct_url_on_success(self):
+ """Url with specified extension is returned on successful requests."""
+ key = "paste_key"
+ test_cases = (
+ (f"https://paste_service.com/{key}.txt", "txt"),
+ (f"https://paste_service.com/{key}.py", "py"),
+ (f"https://paste_service.com/{key}", ""),
+ )
+ response = MagicMock(
+ json=AsyncMock(return_value={"key": key})
+ )
+ self.http_session.post().__aenter__.return_value = response
+
+ for expected_output, extension in test_cases:
+ with self.subTest(msg=f"Send contents with extension {repr(extension)}"):
+ self.assertEqual(
+ await send_to_paste_service(self.http_session, "", extension=extension),
+ expected_output
+ )
+
+ async def test_request_repeated_on_json_errors(self):
+ """Json with error message and invalid json are handled as errors and requests repeated."""
+ test_cases = ({"message": "error"}, {"unexpected_key": None}, {})
+ self.http_session.post().__aenter__.return_value = response = MagicMock()
+ self.http_session.post.reset_mock()
+
+ for error_json in test_cases:
+ with self.subTest(error_json=error_json):
+ response.json = AsyncMock(return_value=error_json)
+ result = await send_to_paste_service(self.http_session, "")
+ self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
+ self.assertIsNone(result)
+
+ self.http_session.post.reset_mock()
+
+ async def test_request_repeated_on_connection_errors(self):
+ """Requests are repeated in the case of connection errors."""
+ self.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock()))
+ result = await send_to_paste_service(self.http_session, "")
+ self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
+ self.assertIsNone(result)
+
+ async def test_general_error_handled_and_request_repeated(self):
+ """All `Exception`s are handled, logged and request repeated."""
+ self.http_session.post = MagicMock(side_effect=Exception)
+ result = await send_to_paste_service(self.http_session, "")
+ self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
+ self.assertLogs("bot.utils", logging.ERROR)
+ self.assertIsNone(result)