aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Pipfile2
-rw-r--r--Pipfile.lock110
-rw-r--r--bot/__init__.py7
-rw-r--r--bot/__main__.py1
-rw-r--r--bot/bot.py14
-rw-r--r--bot/cogs/antimalware.py12
-rw-r--r--bot/cogs/bot.py2
-rw-r--r--bot/cogs/clean.py2
-rw-r--r--bot/cogs/defcon.py2
-rw-r--r--bot/cogs/error_handler.py39
-rw-r--r--bot/cogs/eval.py2
-rw-r--r--bot/cogs/extensions.py8
-rw-r--r--bot/cogs/help.py713
-rw-r--r--bot/cogs/help_channels.py259
-rw-r--r--bot/cogs/information.py8
-rw-r--r--bot/cogs/moderation/infractions.py20
-rw-r--r--bot/cogs/moderation/management.py2
-rw-r--r--bot/cogs/moderation/scheduler.py26
-rw-r--r--bot/cogs/moderation/superstarify.py2
-rw-r--r--bot/cogs/moderation/utils.py30
-rw-r--r--bot/cogs/off_topic_names.py2
-rw-r--r--bot/cogs/python_news.py234
-rw-r--r--bot/cogs/reddit.py2
-rw-r--r--bot/cogs/reminders.py4
-rw-r--r--bot/cogs/site.py2
-rw-r--r--bot/cogs/snekbox.py12
-rw-r--r--bot/cogs/sync/syncers.py3
-rw-r--r--bot/cogs/tags.py2
-rw-r--r--bot/cogs/utils.py54
-rw-r--r--bot/cogs/verification.py15
-rw-r--r--bot/cogs/watchchannels/bigbrother.py2
-rw-r--r--bot/cogs/watchchannels/talentpool.py4
-rw-r--r--bot/cogs/wolfram.py8
-rw-r--r--bot/constants.py17
-rw-r--r--bot/decorators.py91
-rw-r--r--bot/pagination.py2
-rw-r--r--bot/resources/tags/free.md5
-rw-r--r--bot/resources/tags/mutability.md37
-rw-r--r--config-default.yml36
-rw-r--r--tests/bot/cogs/sync/test_base.py3
-rw-r--r--tests/bot/cogs/test_cogs.py4
-rw-r--r--tests/bot/cogs/test_information.py6
-rw-r--r--tests/bot/cogs/test_snekbox.py5
-rw-r--r--tests/bot/test_decorators.py147
-rw-r--r--tests/helpers.py23
45 files changed, 1195 insertions, 786 deletions
diff --git a/Pipfile b/Pipfile
index e7fb61957..14c9ef926 100644
--- a/Pipfile
+++ b/Pipfile
@@ -21,6 +21,8 @@ sentry-sdk = "~=0.14"
coloredlogs = "~=14.0"
colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"}
statsd = "~=3.3"
+feedparser = "~=5.2"
+beautifulsoup4 = "~=4.9"
[dev-packages]
coverage = "~=5.0"
diff --git a/Pipfile.lock b/Pipfile.lock
index 19e03bda4..4e7050a13 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "10636aef5a07f17bd00608df2cc5214fcbfe3de4745cdeea7a076b871754620a"
+ "sha256": "64620e7e825c74fd3010821fb30843b19f5dafb2b5a1f6eafedc0a5febd99b69"
},
"pipfile-spec": 6,
"requires": {
@@ -91,6 +91,7 @@
"sha256:a4bbe77fd30670455c5296242967a123ec28c37e9702a8a81bd2f20a4baf0368",
"sha256:d4e96ac9b0c3a6d3f0caae2e4124e6055c5dcafde8e2f831ff194c104f0775a0"
],
+ "index": "pypi",
"version": "==4.9.0"
},
"certifi": {
@@ -179,6 +180,15 @@
],
"version": "==0.16"
},
+ "feedparser": {
+ "hashes": [
+ "sha256:bd030652c2d08532c034c27fcd7c85868e7fa3cb2b17f230a44a6bbc92519bf9",
+ "sha256:cd2485472e41471632ed3029d44033ee420ad0b57111db95c240c9160a85831c",
+ "sha256:ce875495c90ebd74b179855449040003a1beb40cd13d5f037a0654251e260b02"
+ ],
+ "index": "pypi",
+ "version": "==5.2.1"
+ },
"fuzzywuzzy": {
"hashes": [
"sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8",
@@ -189,10 +199,10 @@
},
"humanfriendly": {
"hashes": [
- "sha256:25c2108a45cfd1e8fbe9cdb30b825d34ef5d5675c8e11e4775c9aedbfb0bdee2",
- "sha256:3a831920e40e55ad49adb64c9179ed50c604cabca72cd300e7bd5b51310e4ebb"
+ "sha256:bf52ec91244819c780341a3438d5d7b09f431d3f113a475147ac9b7b167a3d12",
+ "sha256:e78960b31198511f45fd455534ae7645a6207d33e512d2e842c766d15d9c8080"
],
- "version": "==8.1"
+ "version": "==8.2"
},
"idna": {
"hashes": [
@@ -210,10 +220,10 @@
},
"jinja2": {
"hashes": [
- "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250",
- "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49"
+ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
+ "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
],
- "version": "==2.11.1"
+ "version": "==2.11.2"
},
"lxml": {
"hashes": [
@@ -527,10 +537,10 @@
},
"urllib3": {
"hashes": [
- "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
- "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
+ "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527",
+ "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"
],
- "version": "==1.25.8"
+ "version": "==1.25.9"
},
"websockets": {
"hashes": [
@@ -606,40 +616,40 @@
},
"coverage": {
"hashes": [
- "sha256:03f630aba2b9b0d69871c2e8d23a69b7fe94a1e2f5f10df5049c0df99db639a0",
- "sha256:046a1a742e66d065d16fb564a26c2a15867f17695e7f3d358d7b1ad8a61bca30",
- "sha256:0a907199566269e1cfa304325cc3b45c72ae341fbb3253ddde19fa820ded7a8b",
- "sha256:165a48268bfb5a77e2d9dbb80de7ea917332a79c7adb747bd005b3a07ff8caf0",
- "sha256:1b60a95fc995649464e0cd48cecc8288bac5f4198f21d04b8229dc4097d76823",
- "sha256:1f66cf263ec77af5b8fe14ef14c5e46e2eb4a795ac495ad7c03adc72ae43fafe",
- "sha256:2e08c32cbede4a29e2a701822291ae2bc9b5220a971bba9d1e7615312efd3037",
- "sha256:3844c3dab800ca8536f75ae89f3cf566848a3eb2af4d9f7b1103b4f4f7a5dad6",
- "sha256:408ce64078398b2ee2ec08199ea3fcf382828d2f8a19c5a5ba2946fe5ddc6c31",
- "sha256:443be7602c790960b9514567917af538cac7807a7c0c0727c4d2bbd4014920fd",
- "sha256:4482f69e0701139d0f2c44f3c395d1d1d37abd81bfafbf9b6efbe2542679d892",
- "sha256:4a8a259bf990044351baf69d3b23e575699dd60b18460c71e81dc565f5819ac1",
- "sha256:513e6526e0082c59a984448f4104c9bf346c2da9961779ede1fc458e8e8a1f78",
- "sha256:5f587dfd83cb669933186661a351ad6fc7166273bc3e3a1531ec5c783d997aac",
- "sha256:62061e87071497951155cbccee487980524d7abea647a1b2a6eb6b9647df9006",
- "sha256:641e329e7f2c01531c45c687efcec8aeca2a78a4ff26d49184dce3d53fc35014",
- "sha256:65a7e00c00472cd0f59ae09d2fb8a8aaae7f4a0cf54b2b74f3138d9f9ceb9cb2",
- "sha256:6ad6ca45e9e92c05295f638e78cd42bfaaf8ee07878c9ed73e93190b26c125f7",
- "sha256:73aa6e86034dad9f00f4bbf5a666a889d17d79db73bc5af04abd6c20a014d9c8",
- "sha256:7c9762f80a25d8d0e4ab3cb1af5d9dffbddb3ee5d21c43e3474c84bf5ff941f7",
- "sha256:85596aa5d9aac1bf39fe39d9fa1051b0f00823982a1de5766e35d495b4a36ca9",
- "sha256:86a0ea78fd851b313b2e712266f663e13b6bc78c2fb260b079e8b67d970474b1",
- "sha256:8a620767b8209f3446197c0e29ba895d75a1e272a36af0786ec70fe7834e4307",
- "sha256:922fb9ef2c67c3ab20e22948dcfd783397e4c043a5c5fa5ff5e9df5529074b0a",
- "sha256:9fad78c13e71546a76c2f8789623eec8e499f8d2d799f4b4547162ce0a4df435",
- "sha256:a37c6233b28e5bc340054cf6170e7090a4e85069513320275a4dc929144dccf0",
- "sha256:c3fc325ce4cbf902d05a80daa47b645d07e796a80682c1c5800d6ac5045193e5",
- "sha256:cda33311cb9fb9323958a69499a667bd728a39a7aa4718d7622597a44c4f1441",
- "sha256:db1d4e38c9b15be1521722e946ee24f6db95b189d1447fa9ff18dd16ba89f732",
- "sha256:eda55e6e9ea258f5e4add23bcf33dc53b2c319e70806e180aecbff8d90ea24de",
- "sha256:f372cdbb240e09ee855735b9d85e7f50730dcfb6296b74b95a3e5dea0615c4c1"
- ],
- "index": "pypi",
- "version": "==5.0.4"
+ "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a",
+ "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355",
+ "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65",
+ "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7",
+ "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9",
+ "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1",
+ "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0",
+ "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55",
+ "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c",
+ "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6",
+ "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef",
+ "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019",
+ "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e",
+ "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0",
+ "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf",
+ "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24",
+ "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2",
+ "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c",
+ "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4",
+ "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0",
+ "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd",
+ "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04",
+ "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e",
+ "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730",
+ "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2",
+ "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768",
+ "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796",
+ "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7",
+ "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a",
+ "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489",
+ "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052"
+ ],
+ "index": "pypi",
+ "version": "==5.1"
},
"distlib": {
"hashes": [
@@ -671,11 +681,11 @@
},
"flake8-annotations": {
"hashes": [
- "sha256:a38b44d01abd480586a92a02a2b0a36231ec42dcc5e114de78fa5db016d8d3f9",
- "sha256:d5b0e8704e4e7728b352fa1464e23539ff2341ba11cc153b536fa2cf921ee659"
+ "sha256:9091d920406a7ff10e401e0dd1baa396d1d7d2e3d101a9beecf815f5894ad554",
+ "sha256:f59fdceb8c8f380a20aed20e1ba8a57bde05935958166c52be2249f113f7ab75"
],
"index": "pypi",
- "version": "==2.0.1"
+ "version": "==2.1.0"
},
"flake8-bugbear": {
"hashes": [
@@ -836,10 +846,10 @@
},
"virtualenv": {
"hashes": [
- "sha256:00cfe8605fb97f5a59d52baab78e6070e72c12ca64f51151695407cc0eb8a431",
- "sha256:c8364ec469084046c779c9a11ae6340094e8a0bf1d844330fc55c1cefe67c172"
+ "sha256:5021396e8f03d0d002a770da90e31e61159684db2859d0ba4850fbea752aa675",
+ "sha256:ac53ade75ca189bc97b6c1d9ec0f1a50efe33cbf178ae09452dcd9fd309013c1"
],
- "version": "==20.0.17"
+ "version": "==20.0.18"
}
}
}
diff --git a/bot/__init__.py b/bot/__init__.py
index 2dd4af225..d63086fe2 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -1,3 +1,4 @@
+import asyncio
import logging
import os
import sys
@@ -58,4 +59,10 @@ coloredlogs.install(logger=root_log, stream=sys.stdout)
logging.getLogger("discord").setLevel(logging.WARNING)
logging.getLogger("websockets").setLevel(logging.WARNING)
+logging.getLogger("chardet").setLevel(logging.WARNING)
logging.getLogger(__name__)
+
+
+# On Windows, the selector event loop is required for aiodns.
+if os.name == "nt":
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
diff --git a/bot/__main__.py b/bot/__main__.py
index 3aa36bfc0..aa1d1aee8 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -51,6 +51,7 @@ bot.load_extension("bot.cogs.eval")
bot.load_extension("bot.cogs.information")
bot.load_extension("bot.cogs.jams")
bot.load_extension("bot.cogs.moderation")
+bot.load_extension("bot.cogs.python_news")
bot.load_extension("bot.cogs.off_topic_names")
bot.load_extension("bot.cogs.reddit")
bot.load_extension("bot.cogs.reminders")
diff --git a/bot/bot.py b/bot/bot.py
index 6dd5ba896..a85a22aa9 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -7,6 +7,7 @@ from typing import Optional
import aiohttp
import discord
from discord.ext import commands
+from sentry_sdk import push_scope
from bot import DEBUG_MODE, api, constants
from bot.async_stats import AsyncStatsClient
@@ -75,7 +76,7 @@ class Bot(commands.Bot):
await self._resolver.close()
if self.stats._transport:
- await self.stats._transport.close()
+ self.stats._transport.close()
async def login(self, *args, **kwargs) -> None:
"""Re-create the connector and set up sessions before logging into Discord."""
@@ -155,3 +156,14 @@ class Bot(commands.Bot):
gateway event before giving up and thus not populating the cache for unavailable guilds.
"""
await self._guild_available.wait()
+
+ async def on_error(self, event: str, *args, **kwargs) -> None:
+ """Log errors raised in event listeners rather than printing them to stderr."""
+ self.stats.incr(f"errors.event.{event}")
+
+ with push_scope() as scope:
+ scope.set_tag("event", event)
+ scope.set_extra("args", args)
+ scope.set_extra("kwargs", kwargs)
+
+ log.exception(f"Unhandled exception in {event}.")
diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py
index 79bf486a4..66b5073e8 100644
--- a/bot/cogs/antimalware.py
+++ b/bot/cogs/antimalware.py
@@ -38,6 +38,18 @@ class AntiMalware(Cog):
"It looks like you tried to attach a Python file - "
f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}"
)
+ elif ".txt" in extensions_blocked:
+ # Work around Discord AutoConversion of messages longer than 2000 chars to .txt
+ cmd_channel = self.bot.get_channel(Channels.bot_commands)
+ embed.description = (
+ "**Uh-oh!** It looks like your message got zapped by our spam filter. "
+ "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n"
+ "• If you attempted to send a message longer than 2000 characters, try shortening your message "
+ "to fit within the character limit or use a pasting service (see below) \n\n"
+ "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in "
+ f"{cmd_channel.mention} for more information) or use a pasting service like: "
+ f"\n\n{URLs.site_schema}{URLs.site_paste}"
+ )
elif extensions_blocked:
whitelisted_types = ', '.join(AntiMalwareConfig.whitelist)
meta_channel = self.bot.get_channel(Channels.meta)
diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py
index a6929b431..f6aea51c5 100644
--- a/bot/cogs/bot.py
+++ b/bot/cogs/bot.py
@@ -41,7 +41,7 @@ class BotCog(Cog, name="Bot"):
@with_role(Roles.verified)
async def botinfo_group(self, ctx: Context) -> None:
"""Bot informational commands."""
- await ctx.invoke(self.bot.get_command("help"), "bot")
+ await ctx.send_help(ctx.command)
@botinfo_group.command(name='about', aliases=('info',), hidden=True)
@with_role(Roles.verified)
diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py
index 5cdf0b048..b5d9132cb 100644
--- a/bot/cogs/clean.py
+++ b/bot/cogs/clean.py
@@ -180,7 +180,7 @@ class Clean(Cog):
@with_role(*MODERATION_ROLES)
async def clean_group(self, ctx: Context) -> None:
"""Commands for cleaning messages in channels."""
- await ctx.invoke(self.bot.get_command("help"), "clean")
+ await ctx.send_help(ctx.command)
@clean_group.command(name="user", aliases=["users"])
@with_role(*MODERATION_ROLES)
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index 56fca002a..25b0a6ad5 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -122,7 +122,7 @@ class Defcon(Cog):
@with_role(Roles.admins, Roles.owners)
async def defcon_group(self, ctx: Context) -> None:
"""Check the DEFCON status or run a subcommand."""
- await ctx.invoke(self.bot.get_command("help"), "defcon")
+ await ctx.send_help(ctx.command)
async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None:
"""Providing a structured way to do an defcon action."""
diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py
index dae283c6a..23d1eed82 100644
--- a/bot/cogs/error_handler.py
+++ b/bot/cogs/error_handler.py
@@ -2,14 +2,14 @@ import contextlib
import logging
import typing as t
-from discord.ext.commands import Cog, Command, Context, errors
+from discord.ext.commands import Cog, Context, errors
from sentry_sdk import push_scope
from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Channels
from bot.converters import TagNameConverter
-from bot.decorators import InChannelCheckFailure
+from bot.decorators import InWhitelistCheckFailure
log = logging.getLogger(__name__)
@@ -79,19 +79,13 @@ class ErrorHandler(Cog):
f"{e.__class__.__name__}: {e}"
)
- async def get_help_command(self, command: t.Optional[Command]) -> t.Tuple:
- """Return the help command invocation args to display help for `command`."""
- parent = None
- if command is not None:
- parent = command.parent
-
- # Retrieve the help command for the invoked command.
- if parent and command:
- return self.bot.get_command("help"), parent.name, command.name
- elif command:
- return self.bot.get_command("help"), command.name
- else:
- return self.bot.get_command("help")
+ @staticmethod
+ def get_help_command(ctx: Context) -> t.Coroutine:
+ """Return a prepared `help` command invocation coroutine."""
+ if ctx.command:
+ return ctx.send_help(ctx.command)
+
+ return ctx.send_help()
async def try_silence(self, ctx: Context) -> bool:
"""
@@ -165,20 +159,19 @@ class ErrorHandler(Cog):
* ArgumentParsingError: send an error message
* Other: send an error message and the help command
"""
- # TODO: use ctx.send_help() once PR #519 is merged.
- help_command = await self.get_help_command(ctx.command)
+ prepared_help_command = self.get_help_command(ctx)
if isinstance(e, errors.MissingRequiredArgument):
await ctx.send(f"Missing required argument `{e.param.name}`.")
- await ctx.invoke(*help_command)
+ await prepared_help_command
self.bot.stats.incr("errors.missing_required_argument")
elif isinstance(e, errors.TooManyArguments):
await ctx.send(f"Too many arguments provided.")
- await ctx.invoke(*help_command)
+ await prepared_help_command
self.bot.stats.incr("errors.too_many_arguments")
elif isinstance(e, errors.BadArgument):
await ctx.send(f"Bad argument: {e}\n")
- await ctx.invoke(*help_command)
+ await prepared_help_command
self.bot.stats.incr("errors.bad_argument")
elif isinstance(e, errors.BadUnionArgument):
await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```")
@@ -188,7 +181,7 @@ class ErrorHandler(Cog):
self.bot.stats.incr("errors.argument_parsing_error")
else:
await ctx.send("Something about your input seems off. Check the arguments:")
- await ctx.invoke(*help_command)
+ await prepared_help_command
self.bot.stats.incr("errors.other_user_input_error")
@staticmethod
@@ -202,7 +195,7 @@ class ErrorHandler(Cog):
* BotMissingRole
* BotMissingAnyRole
* NoPrivateMessage
- * InChannelCheckFailure
+ * InWhitelistCheckFailure
"""
bot_missing_errors = (
errors.BotMissingPermissions,
@@ -215,7 +208,7 @@ class ErrorHandler(Cog):
await ctx.send(
f"Sorry, it looks like I don't have the permissions or roles I need to do that."
)
- elif isinstance(e, (InChannelCheckFailure, errors.NoPrivateMessage)):
+ elif isinstance(e, (InWhitelistCheckFailure, errors.NoPrivateMessage)):
ctx.bot.stats.incr("errors.wrong_channel_or_dm_error")
await ctx.send(e)
diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py
index 52136fc8d..eb8bfb1cf 100644
--- a/bot/cogs/eval.py
+++ b/bot/cogs/eval.py
@@ -178,7 +178,7 @@ async def func(): # (None,) -> Any
async def internal_group(self, ctx: Context) -> None:
"""Internal commands. Top secret!"""
if not ctx.invoked_subcommand:
- await ctx.invoke(self.bot.get_command("help"), "internal")
+ await ctx.send_help(ctx.command)
@internal_group.command(name='eval', aliases=('e',))
@with_role(Roles.admins, Roles.owners)
diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py
index fb6cd9aa3..365f198ff 100644
--- a/bot/cogs/extensions.py
+++ b/bot/cogs/extensions.py
@@ -65,7 +65,7 @@ class Extensions(commands.Cog):
@group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True)
async def extensions_group(self, ctx: Context) -> None:
"""Load, unload, reload, and list loaded extensions."""
- await ctx.invoke(self.bot.get_command("help"), "extensions")
+ await ctx.send_help(ctx.command)
@extensions_group.command(name="load", aliases=("l",))
async def load_command(self, ctx: Context, *extensions: Extension) -> None:
@@ -75,7 +75,7 @@ class Extensions(commands.Cog):
If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded.
""" # noqa: W605
if not extensions:
- await ctx.invoke(self.bot.get_command("help"), "extensions load")
+ await ctx.send_help(ctx.command)
return
if "*" in extensions or "**" in extensions:
@@ -92,7 +92,7 @@ class Extensions(commands.Cog):
If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded.
""" # noqa: W605
if not extensions:
- await ctx.invoke(self.bot.get_command("help"), "extensions unload")
+ await ctx.send_help(ctx.command)
return
blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions))
@@ -118,7 +118,7 @@ class Extensions(commands.Cog):
If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded.
""" # noqa: W605
if not extensions:
- await ctx.invoke(self.bot.get_command("help"), "extensions reload")
+ await ctx.send_help(ctx.command)
return
if "**" in extensions:
diff --git a/bot/cogs/help.py b/bot/cogs/help.py
index 744722220..542f19139 100644
--- a/bot/cogs/help.py
+++ b/bot/cogs/help.py
@@ -1,34 +1,48 @@
-import asyncio
import itertools
+import logging
+from asyncio import TimeoutError
from collections import namedtuple
from contextlib import suppress
-from typing import Union
+from typing import List, Union
-from discord import Colour, Embed, HTTPException, Message, Reaction, User
-from discord.ext import commands
-from discord.ext.commands import CheckFailure, Cog as DiscordCog, Command, Context
+from discord import Colour, Embed, Member, Message, NotFound, Reaction, User
+from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand
from fuzzywuzzy import fuzz, process
from bot import constants
-from bot.bot import Bot
from bot.constants import Channels, Emojis, STAFF_ROLES
from bot.decorators import redirect_output
-from bot.pagination import (
- FIRST_EMOJI, LAST_EMOJI,
- LEFT_EMOJI, LinePaginator, RIGHT_EMOJI,
-)
+from bot.pagination import LinePaginator
+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
-REACTIONS = {
- FIRST_EMOJI: 'first',
- LEFT_EMOJI: 'back',
- RIGHT_EMOJI: 'next',
- LAST_EMOJI: 'end',
- DELETE_EMOJI: 'stop',
-}
+ await message.add_reaction(DELETE_EMOJI)
-Cog = namedtuple('Cog', ['name', 'description', 'commands'])
+ try:
+ await bot.wait_for("reaction_add", check=check, timeout=300)
+ await message.delete()
+ except TimeoutError:
+ await message.remove_reaction(DELETE_EMOJI, bot.user)
+ except NotFound:
+ pass
class HelpQueryNotFound(ValueError):
@@ -46,22 +60,9 @@ class HelpQueryNotFound(ValueError):
self.possible_matches = possible_matches
-class HelpSession:
+class CustomHelpCommand(HelpCommand):
"""
- An interactive session for bot and command help output.
-
- Expected attributes include:
- * title: str
- The title of the help message.
- * query: Union[discord.ext.commands.Bot, discord.ext.commands.Command]
- * description: str
- The description of the query.
- * pages: list[str]
- A list of the help content split into manageable pages.
- * message: `discord.Message`
- The message object that's showing the help contents.
- * destination: `discord.abc.Messageable`
- Where the help message is to be sent to.
+ An interactive instance for the bot help command.
Cogs can be grouped into custom categories. All cogs with the same category will be displayed
under a single category name in the help output. Custom categories are defined inside the cogs
@@ -70,499 +71,299 @@ class HelpSession:
the regular description (class docstring) of the first cog found in the category.
"""
- def __init__(
- self,
- ctx: Context,
- *command,
- cleanup: bool = False,
- only_can_run: bool = True,
- show_hidden: bool = False,
- max_lines: int = 15
- ):
- """Creates an instance of the HelpSession class."""
- self._ctx = ctx
- self._bot = ctx.bot
- self.title = "Command Help"
-
- # set the query details for the session
- if command:
- query_str = ' '.join(command)
- self.query = self._get_query(query_str)
- self.description = self.query.description or self.query.help
- else:
- self.query = ctx.bot
- self.description = self.query.description
- self.author = ctx.author
- self.destination = ctx.channel
-
- # set the config for the session
- self._cleanup = cleanup
- self._only_can_run = only_can_run
- self._show_hidden = show_hidden
- self._max_lines = max_lines
-
- # init session states
- self._pages = None
- self._current_page = 0
- self.message = None
- self._timeout_task = None
- self.reset_timeout()
-
- def _get_query(self, query: str) -> Union[Command, Cog]:
+ def __init__(self):
+ super().__init__(command_attrs={"help": "Shows help for bot commands"})
+
+ @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES)
+ async def command_callback(self, ctx: Context, *, command: str = None) -> None:
"""Attempts to match the provided query with a valid command or cog."""
- command = self._bot.get_command(query)
- if command:
- return command
+ # the only reason we need to tamper with this is because d.py does not support "categories",
+ # so we need to deal with them ourselves.
+
+ bot = ctx.bot
+
+ if command is None:
+ # quick and easy, send bot help if command is none
+ mapping = self.get_bot_mapping()
+ await self.send_bot_help(mapping)
+ return
- # Find all cog categories that match.
cog_matches = []
description = None
- for cog in self._bot.cogs.values():
- if hasattr(cog, "category") and cog.category == query:
+ for cog in bot.cogs.values():
+ if hasattr(cog, "category") and cog.category == command:
cog_matches.append(cog)
if hasattr(cog, "category_description"):
description = cog.category_description
- # Try to search by cog name if no categories match.
- if not cog_matches:
- cog = self._bot.cogs.get(query)
-
- # Don't consider it a match if the cog has a category.
- if cog and not hasattr(cog, "category"):
- cog_matches = [cog]
-
if cog_matches:
- cog = cog_matches[0]
- cmds = (cog.get_commands() for cog in cog_matches) # Commands of all cogs
-
- return Cog(
- name=cog.category if hasattr(cog, "category") else cog.qualified_name,
- description=description or cog.description,
- commands=tuple(itertools.chain.from_iterable(cmds)) # Flatten the list
- )
-
- self._handle_not_found(query)
-
- def _handle_not_found(self, query: str) -> None:
- """
- Handles when a query does not match a valid command or cog.
-
- Will pass on possible close matches along with the `HelpQueryNotFound` exception.
- """
- # Combine command and cog names
- choices = list(self._bot.all_commands) + list(self._bot.cogs)
-
- result = process.extractBests(query, choices, scorer=fuzz.ratio, score_cutoff=90)
-
- raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result))
-
- async def timeout(self, seconds: int = 30) -> None:
- """Waits for a set number of seconds, then stops the help session."""
- await asyncio.sleep(seconds)
- await self.stop()
-
- def reset_timeout(self) -> None:
- """Cancels the original timeout task and sets it again from the start."""
- # cancel original if it exists
- if self._timeout_task:
- if not self._timeout_task.cancelled():
- self._timeout_task.cancel()
-
- # recreate the timeout task
- self._timeout_task = self._bot.loop.create_task(self.timeout())
-
- async def on_reaction_add(self, reaction: Reaction, user: User) -> None:
- """Event handler for when reactions are added on the help message."""
- # ensure it was the relevant session message
- if reaction.message.id != self.message.id:
- return
-
- # ensure it was the session author who reacted
- if user.id != self.author.id:
- return
-
- emoji = str(reaction.emoji)
-
- # check if valid action
- if emoji not in REACTIONS:
+ category = Category(name=command, description=description, cogs=cog_matches)
+ await self.send_category_help(category)
return
- self.reset_timeout()
-
- # Run relevant action method
- action = getattr(self, f'do_{REACTIONS[emoji]}', None)
- if action:
- await action()
-
- # remove the added reaction to prep for re-use
- with suppress(HTTPException):
- await self.message.remove_reaction(reaction, user)
-
- async def on_message_delete(self, message: Message) -> None:
- """Closes the help session when the help message is deleted."""
- if message.id == self.message.id:
- await self.stop()
-
- async def prepare(self) -> None:
- """Sets up the help session pages, events, message and reactions."""
- # create paginated content
- await self.build_pages()
-
- # setup listeners
- self._bot.add_listener(self.on_reaction_add)
- self._bot.add_listener(self.on_message_delete)
-
- # Send the help message
- await self.update_page()
- self.add_reactions()
-
- def add_reactions(self) -> None:
- """Adds the relevant reactions to the help message based on if pagination is required."""
- # if paginating
- if len(self._pages) > 1:
- for reaction in REACTIONS:
- self._bot.loop.create_task(self.message.add_reaction(reaction))
+ # it's either a cog, group, command or subcommand; let the parent class deal with it
+ await super().command_callback(ctx, command=command)
- # if single-page
- else:
- self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI))
-
- def _category_key(self, cmd: Command) -> str:
+ async def get_all_help_choices(self) -> set:
"""
- Returns a cog name of a given command for use as a key for `sorted` and `groupby`.
+ Get all the possible options for getting help in the bot.
- A zero width space is used as a prefix for results with no cogs to force them last in ordering.
- """
- if cmd.cog:
- try:
- if cmd.cog.category:
- return f'**{cmd.cog.category}**'
- except AttributeError:
- pass
-
- return f'**{cmd.cog_name}**'
- else:
- return "**\u200bNo Category:**"
+ This will only display commands the author has permission to run.
- def _get_command_params(self, cmd: Command) -> str:
- """
- Returns the command usage signature.
+ These include:
+ - Category names
+ - Cog names
+ - Group command names (and aliases)
+ - Command names (and aliases)
+ - Subcommand names (with parent group and aliases for subcommand, but not including aliases for group)
- This is a custom implementation of `command.signature` in order to format the command
- signature without aliases.
+ Options and choices are case sensitive.
"""
- results = []
- for name, param in cmd.clean_params.items():
-
- # if argument has a default value
- if param.default is not param.empty:
-
- if isinstance(param.default, str):
- show_default = param.default
- else:
- show_default = param.default is not None
-
- # if default is not an empty string or None
- if show_default:
- results.append(f'[{name}={param.default}]')
- else:
- results.append(f'[{name}]')
-
- # if variable length argument
- elif param.kind == param.VAR_POSITIONAL:
- results.append(f'[{name}...]')
-
- # if required
+ # first get all commands including subcommands and full command name aliases
+ choices = set()
+ for command in await self.filter_commands(self.context.bot.walk_commands()):
+ # the the command or group name
+ choices.add(str(command))
+
+ if isinstance(command, Command):
+ # all aliases if it's just a command
+ choices.update(command.aliases)
else:
- results.append(f'<{name}>')
+ # otherwise we need to add the parent name in
+ choices.update(f"{command.full_parent_name} {alias}" for alias in command.aliases)
- return f"{cmd.name} {' '.join(results)}"
+ # all cog names
+ choices.update(self.context.bot.cogs)
- async def build_pages(self) -> None:
- """Builds the list of content pages to be paginated through in the help message, as a list of str."""
- # Use LinePaginator to restrict embed line height
- paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines)
+ # all category names
+ choices.update(cog.category for cog in self.context.bot.cogs.values() if hasattr(cog, "category"))
+ return choices
- prefix = constants.Bot.prefix
-
- # show signature if query is a command
- if isinstance(self.query, commands.Command):
- signature = self._get_command_params(self.query)
- parent = self.query.full_parent_name + ' ' if self.query.parent else ''
- paginator.add_line(f'**```{prefix}{parent}{signature}```**')
-
- # show command aliases
- aliases = ', '.join(f'`{a}`' for a in self.query.aliases)
- if aliases:
- paginator.add_line(f'**Can also use:** {aliases}\n')
-
- if not await self.query.can_run(self._ctx):
- paginator.add_line('***You cannot run this command.***\n')
-
- # show name if query is a cog
- if isinstance(self.query, Cog):
- paginator.add_line(f'**{self.query.name}**')
+ async def command_not_found(self, string: str) -> "HelpQueryNotFound":
+ """
+ Handles when a query does not match a valid command, group, cog or category.
- if self.description:
- paginator.add_line(f'*{self.description}*')
+ Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches.
+ """
+ choices = await self.get_all_help_choices()
+ result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=60)
- # list all children commands of the queried object
- if isinstance(self.query, (commands.GroupMixin, Cog)):
+ return HelpQueryNotFound(f'Query "{string}" not found.', dict(result))
- # remove hidden commands if session is not wanting hiddens
- if not self._show_hidden:
- filtered = [c for c in self.query.commands if not c.hidden]
- else:
- filtered = self.query.commands
+ async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound":
+ """
+ Redirects the error to `command_not_found`.
- # if after filter there are no commands, finish up
- if not filtered:
- self._pages = paginator.pages
- return
+ `command_not_found` deals with searching and getting best choices for both commands and subcommands.
+ """
+ return await self.command_not_found(f"{command.qualified_name} {string}")
- # set category to Commands if cog
- if isinstance(self.query, Cog):
- grouped = (('**Commands:**', self.query.commands),)
+ async def send_error_message(self, error: HelpQueryNotFound) -> None:
+ """Send the error message to the channel."""
+ embed = Embed(colour=Colour.red(), title=str(error))
- # set category to Subcommands if command
- elif isinstance(self.query, commands.Command):
- grouped = (('**Subcommands:**', self.query.commands),)
+ if getattr(error, "possible_matches", None):
+ matches = "\n".join(f"`{match}`" for match in error.possible_matches)
+ embed.description = f"**Did you mean:**\n{matches}"
- # don't show prefix for subcommands
- prefix = ''
+ await self.context.send(embed=embed)
- # otherwise sort and organise all commands into categories
- else:
- cat_sort = sorted(filtered, key=self._category_key)
- grouped = itertools.groupby(cat_sort, key=self._category_key)
+ async def command_formatting(self, command: Command) -> Embed:
+ """
+ Takes a command and turns it into an embed.
- # process each category
- for category, cmds in grouped:
- cmds = sorted(cmds, key=lambda c: c.name)
+ It will add an author, command signature + help, aliases and a note if the user can't run the command.
+ """
+ embed = Embed()
+ embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)
- # if there are no commands, skip category
- if len(cmds) == 0:
- continue
+ parent = command.full_parent_name
- cat_cmds = []
+ name = str(command) if not parent else f"{parent} {command.name}"
+ command_details = f"**```{PREFIX}{name} {command.signature}```**\n"
- # format details for each child command
- for command in cmds:
+ # show command aliases
+ aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases)
+ if aliases:
+ command_details += f"**Can also use:** {aliases}\n\n"
- # skip if hidden and hide if session is set to
- if command.hidden and not self._show_hidden:
- continue
+ # check if the user is allowed to run this command
+ if not await command.can_run(self.context):
+ command_details += "***You cannot run this command.***\n\n"
- # see if the user can run the command
- strikeout = ''
+ command_details += f"*{command.help or 'No details provided.'}*\n"
+ embed.description = command_details
- # Patch to make the !help command work outside of #bot-commands again
- # This probably needs a proper rewrite, but this will make it work in
- # the mean time.
- try:
- can_run = await command.can_run(self._ctx)
- except CheckFailure:
- can_run = False
+ return embed
- if not can_run:
- # skip if we don't show commands they can't run
- if self._only_can_run:
- continue
- strikeout = '~~'
+ async def send_command_help(self, command: Command) -> None:
+ """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)
- signature = self._get_command_params(command)
- info = f"{strikeout}**`{prefix}{signature}`**{strikeout}"
+ @staticmethod
+ def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]:
+ """
+ Formats the prefix, command name and signature, and short doc for an iterable of commands.
- # handle if the command has no docstring
- if command.short_doc:
- cat_cmds.append(f'{info}\n*{command.short_doc}*')
- else:
- cat_cmds.append(f'{info}\n*No details provided.*')
+ return_as_list is helpful for passing these command details into the paginator as a list of command details.
+ """
+ details = []
+ for command in commands_:
+ signature = f" {command.signature}" if command.signature else ""
+ details.append(
+ f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n*{command.short_doc or 'No details provided'}*"
+ )
+ if return_as_list:
+ return details
+ else:
+ return "".join(details)
- # state var for if the category should be added next
- print_cat = 1
- new_page = True
+ async def send_group_help(self, group: Group) -> None:
+ """Sends help for a group command."""
+ subcommands = group.commands
- for details in cat_cmds:
+ if len(subcommands) == 0:
+ # no subcommands, just treat it like a regular command
+ await self.send_command_help(group)
+ return
- # keep details together, paginating early if it won't fit
- lines_adding = len(details.split('\n')) + print_cat
- if paginator._linecount + lines_adding > self._max_lines:
- paginator._linecount = 0
- new_page = True
- paginator.close_page()
+ # remove commands that the user can't run and are hidden, and sort by name
+ commands_ = await self.filter_commands(subcommands, sort=True)
- # new page so print category title again
- print_cat = 1
+ embed = await self.command_formatting(group)
- if print_cat:
- if new_page:
- paginator.add_line('')
- paginator.add_line(category)
- print_cat = 0
+ command_details = self.get_commands_brief_details(commands_)
+ if command_details:
+ embed.description += f"\n**Subcommands:**\n{command_details}"
- paginator.add_line(details)
+ message = await self.context.send(embed=embed)
+ await help_cleanup(self.context.bot, self.context.author, message)
- # save organised pages to session
- self._pages = paginator.pages
+ async def send_cog_help(self, cog: Cog) -> None:
+ """Send help for a cog."""
+ # sort commands by name, and remove any the user cant run or are hidden.
+ commands_ = await self.filter_commands(cog.get_commands(), sort=True)
- def embed_page(self, page_number: int = 0) -> Embed:
- """Returns an Embed with the requested page formatted within."""
embed = Embed()
+ embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)
+ embed.description = f"**{cog.qualified_name}**\n*{cog.description}*"
- # if command or cog, add query to title for pages other than first
- if isinstance(self.query, (commands.Command, Cog)) and page_number > 0:
- title = f'Command Help | "{self.query.name}"'
- else:
- title = self.title
-
- embed.set_author(name=title, icon_url=constants.Icons.questionmark)
- embed.description = self._pages[page_number]
+ command_details = self.get_commands_brief_details(commands_)
+ if command_details:
+ embed.description += f"\n\n**Commands:**\n{command_details}"
- # add page counter to footer if paginating
- page_count = len(self._pages)
- if page_count > 1:
- embed.set_footer(text=f'Page {self._current_page+1} / {page_count}')
+ message = await self.context.send(embed=embed)
+ await help_cleanup(self.context.bot, self.context.author, message)
- return embed
-
- async def update_page(self, page_number: int = 0) -> None:
- """Sends the intial message, or changes the existing one to the given page number."""
- self._current_page = page_number
- embed_page = self.embed_page(page_number)
+ @staticmethod
+ def _category_key(command: Command) -> str:
+ """
+ Returns a cog name of a given command for use as a key for `sorted` and `groupby`.
- if not self.message:
- self.message = await self.destination.send(embed=embed_page)
+ A zero width space is used as a prefix for results with no cogs to force them last in ordering.
+ """
+ if command.cog:
+ with suppress(AttributeError):
+ if command.cog.category:
+ return f"**{command.cog.category}**"
+ return f"**{command.cog_name}**"
else:
- await self.message.edit(embed=embed_page)
+ return "**\u200bNo Category:**"
- @classmethod
- async def start(cls, ctx: Context, *command, **options) -> "HelpSession":
+ async def send_category_help(self, category: Category) -> None:
"""
- Create and begin a help session based on the given command context.
-
- Available options kwargs:
- * cleanup: Optional[bool]
- Set to `True` to have the message deleted on session end. Defaults to `False`.
- * only_can_run: Optional[bool]
- Set to `True` to hide commands the user can't run. Defaults to `False`.
- * show_hidden: Optional[bool]
- Set to `True` to include hidden commands. Defaults to `False`.
- * max_lines: Optional[int]
- Sets the max number of lines the paginator will add to a single page. Defaults to 20.
+ Sends help for a bot category.
+
+ This sends a brief help for all commands in all cogs registered to the category.
"""
- session = cls(ctx, *command, **options)
- await session.prepare()
+ embed = Embed()
+ embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)
- return session
+ all_commands = []
+ for cog in category.cogs:
+ all_commands.extend(cog.get_commands())
- async def stop(self) -> None:
- """Stops the help session, removes event listeners and attempts to delete the help message."""
- self._bot.remove_listener(self.on_reaction_add)
- self._bot.remove_listener(self.on_message_delete)
+ filtered_commands = await self.filter_commands(all_commands, sort=True)
- # ignore if permission issue, or the message doesn't exist
- with suppress(HTTPException, AttributeError):
- if self._cleanup:
- await self.message.delete()
- else:
- await self.message.clear_reactions()
+ command_detail_lines = self.get_commands_brief_details(filtered_commands, return_as_list=True)
+ description = f"**{category.name}**\n*{category.description}*"
- @property
- def is_first_page(self) -> bool:
- """Check if session is currently showing the first page."""
- return self._current_page == 0
+ if command_detail_lines:
+ description += "\n\n**Commands:**"
- @property
- def is_last_page(self) -> bool:
- """Check if the session is currently showing the last page."""
- return self._current_page == (len(self._pages)-1)
+ await LinePaginator.paginate(
+ command_detail_lines,
+ self.context,
+ embed,
+ prefix=description,
+ max_lines=COMMANDS_PER_PAGE,
+ max_size=2040,
+ )
- async def do_first(self) -> None:
- """Event that is called when the user requests the first page."""
- if not self.is_first_page:
- await self.update_page(0)
+ async def send_bot_help(self, mapping: dict) -> None:
+ """Sends help for all bot commands and cogs."""
+ bot = self.context.bot
- async def do_back(self) -> None:
- """Event that is called when the user requests the previous page."""
- if not self.is_first_page:
- await self.update_page(self._current_page-1)
-
- async def do_next(self) -> None:
- """Event that is called when the user requests the next page."""
- if not self.is_last_page:
- await self.update_page(self._current_page+1)
+ embed = Embed()
+ embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)
+
+ filter_commands = await self.filter_commands(bot.commands, sort=True, key=self._category_key)
+
+ cog_or_category_pages = []
+
+ for cog_or_category, _commands in itertools.groupby(filter_commands, key=self._category_key):
+ sorted_commands = sorted(_commands, key=lambda c: c.name)
+
+ if len(sorted_commands) == 0:
+ continue
+
+ command_detail_lines = self.get_commands_brief_details(sorted_commands, return_as_list=True)
+
+ # Split cogs or categories which have too many commands to fit in one page.
+ # The length of commands is included for later use when aggregating into pages for the paginator.
+ for index in range(0, len(sorted_commands), COMMANDS_PER_PAGE):
+ truncated_lines = command_detail_lines[index:index + COMMANDS_PER_PAGE]
+ joined_lines = "".join(truncated_lines)
+ cog_or_category_pages.append((f"**{cog_or_category}**{joined_lines}", len(truncated_lines)))
+
+ pages = []
+ counter = 0
+ page = ""
+ for page_details, length in cog_or_category_pages:
+ counter += length
+ if counter > COMMANDS_PER_PAGE:
+ # force a new page on paginator even if it falls short of the max pages
+ # since we still want to group categories/cogs.
+ counter = length
+ pages.append(page)
+ page = f"{page_details}\n\n"
+ else:
+ page += f"{page_details}\n\n"
- async def do_end(self) -> None:
- """Event that is called when the user requests the last page."""
- if not self.is_last_page:
- await self.update_page(len(self._pages)-1)
+ if page:
+ # add any remaining command help that didn't get added in the last iteration above.
+ pages.append(page)
- async def do_stop(self) -> None:
- """Event that is called when the user requests to stop the help session."""
- await self.message.delete()
+ await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2040)
-class Help(DiscordCog):
+class Help(Cog):
"""Custom Embed Pagination Help feature."""
- @commands.command('help')
- @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES)
- async def new_help(self, ctx: Context, *commands) -> None:
- """Shows Command Help."""
- try:
- await HelpSession.start(ctx, *commands)
- except HelpQueryNotFound as error:
- embed = Embed()
- embed.colour = Colour.red()
- embed.title = str(error)
-
- if error.possible_matches:
- matches = '\n'.join(error.possible_matches.keys())
- embed.description = f'**Did you mean:**\n`{matches}`'
-
- await ctx.send(embed=embed)
-
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+ self.old_help_command = bot.help_command
+ bot.help_command = CustomHelpCommand()
+ bot.help_command.cog = self
-def unload(bot: Bot) -> None:
- """
- Reinstates the original help command.
-
- This is run if the cog raises an exception on load, or if the extension is unloaded.
- """
- bot.remove_command('help')
- bot.add_command(bot._old_help)
+ def cog_unload(self) -> None:
+ """Reset the help command when the cog is unloaded."""
+ self.bot.help_command = self.old_help_command
def setup(bot: Bot) -> None:
- """
- The setup for the help extension.
-
- This is called automatically on `bot.load_extension` being run.
-
- Stores the original help command instance on the `bot._old_help` attribute for later
- reinstatement, before removing it from the command registry so the new help command can be
- loaded successfully.
-
- If an exception is raised during the loading of the cog, `unload` will be called in order to
- reinstate the original help command.
- """
- bot._old_help = bot.get_command('help')
- bot.remove_command('help')
-
- try:
- bot.add_cog(Help())
- except Exception:
- unload(bot)
- raise
-
-
-def teardown(bot: Bot) -> None:
- """
- The teardown for the help extension.
-
- This is called automatically on `bot.unload_extension` being run.
-
- Calls `unload` in order to reinstate the original help command.
- """
- unload(bot)
+ """Load the Help cog."""
+ bot.add_cog(Help(bot))
+ log.info("Cog loaded: Help")
diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py
index 36ad6a7a3..1bd1f9d68 100644
--- a/bot/cogs/help_channels.py
+++ b/bot/cogs/help_channels.py
@@ -10,6 +10,7 @@ from datetime import datetime
from pathlib import Path
import discord
+import discord.abc
from discord.ext import commands
from bot import constants
@@ -21,6 +22,7 @@ log = logging.getLogger(__name__)
ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/"
MAX_CHANNELS_PER_CATEGORY = 50
+EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help,)
AVAILABLE_TOPIC = """
This channel is available. Feel free to ask a question in order to claim this channel!
@@ -39,8 +41,9 @@ channels in the Help: Available category.
AVAILABLE_MSG = f"""
This help channel is now **available**, which means that you can claim it by simply typing your \
question into it. Once claimed, the channel will move into the **Python Help: Occupied** category, \
-and will be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes. When \
-that happens, it will be set to **dormant** and moved into the **Help: Dormant** category.
+and will be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes or \
+is closed manually with `!close`. When that happens, it will be set to **dormant** and moved into \
+the **Help: Dormant** category.
You may claim a new channel once every {constants.HelpChannels.claim_minutes} minutes. If you \
currently cannot send a message in this channel, it means you are on cooldown and need to wait.
@@ -62,9 +65,12 @@ through our guide for [asking a good question]({ASKING_GUIDE_URL}).
"""
AVAILABLE_EMOJI = "✅"
-IN_USE_EMOJI = "⌛"
+IN_USE_ANSWERED_EMOJI = "⌛"
+IN_USE_UNANSWERED_EMOJI = "⏳"
NAME_SEPARATOR = "|"
+CoroutineFunc = t.Callable[..., t.Coroutine]
+
class TaskData(t.NamedTuple):
"""Data for a scheduled task."""
@@ -88,12 +94,15 @@ class HelpChannels(Scheduler, commands.Cog):
* If there are no more dormant channels, the bot will automatically create a new one
* If there are no dormant channels to move, helpers will be notified (see `notify()`)
* When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG`
+ * User can only claim a channel at an interval `constants.HelpChannels.claim_minutes`
+ * To keep track of cooldowns, user which claimed a channel will have a temporary role
In Use Category
* Contains all channels which are occupied by someone needing help
* Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle
* Command can prematurely mark a channel as dormant
+ * Channel claimant is allowed to use the command
* Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist`
* When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent
@@ -132,7 +141,14 @@ class HelpChannels(Scheduler, commands.Cog):
self.init_task = self.bot.loop.create_task(self.init_cog())
# Stats
- self.claim_times = {}
+
+ # This dictionary maps a help channel to the time it was claimed
+ self.claim_times: t.Dict[int, datetime] = {}
+
+ # This dictionary maps a help channel to whether it has had any
+ # activity other than the original claimant. True being no other
+ # activity and False being other activity.
+ self.unanswered: t.Dict[int, bool] = {}
def cog_unload(self) -> None:
"""Cancel the init task and scheduled tasks when the cog unloads."""
@@ -209,8 +225,8 @@ class HelpChannels(Scheduler, commands.Cog):
return role_check
- @commands.command(name="dormant", aliases=["close"], enabled=False)
- async def dormant_command(self, ctx: commands.Context) -> None:
+ @commands.command(name="close", aliases=["dormant", "solved"], enabled=False)
+ async def close_command(self, ctx: commands.Context) -> None:
"""
Make the current in-use help channel dormant.
@@ -218,18 +234,15 @@ class HelpChannels(Scheduler, commands.Cog):
delete the message that invoked this,
and reset the send permissions cooldown for the user who started the session.
"""
- log.trace("dormant command invoked; checking if the channel is in-use.")
+ log.trace("close command invoked; checking if the channel is in-use.")
if ctx.channel.category == self.in_use_category:
if await self.dormant_check(ctx):
with suppress(KeyError):
del self.help_channel_claimants[ctx.channel]
- with suppress(discord.errors.NotFound):
- log.trace("Deleting dormant invokation message.")
- await ctx.message.delete()
-
- with suppress(discord.errors.HTTPException, discord.errors.NotFound):
- await self.reset_claimant_send_permission(ctx.channel)
+ await self.remove_cooldown_role(ctx.author)
+ # Ignore missing task when cooldown has passed but the channel still isn't dormant.
+ self.cancel_task(ctx.author.id, ignore_missing=True)
await self.move_to_dormant(ctx.channel, "command")
self.cancel_task(ctx.channel.id)
@@ -267,19 +280,23 @@ class HelpChannels(Scheduler, commands.Cog):
log.trace(f"The clean name for `{channel}` is `{name}`")
except ValueError:
# If, for some reason, the channel name does not contain "help-" fall back gracefully
- log.info(f"Can't get clean name as `{channel}` does not follow the `{prefix}` naming convention.")
+ log.info(f"Can't get clean name because `{channel}` isn't prefixed by `{prefix}`.")
name = channel.name
return name
@staticmethod
- def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]:
+ def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool:
+ """Check if a channel should be excluded from the help channel system."""
+ return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS
+
+ def get_category_channels(self, category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]:
"""Yield the text channels of the `category` in an unsorted manner."""
log.trace(f"Getting text channels in the category '{category}' ({category.id}).")
# This is faster than using category.channels because the latter sorts them.
- for channel in category.guild.channels:
- if channel.category_id == category.id and isinstance(channel, discord.TextChannel):
+ for channel in self.bot.get_guild(constants.Guild.id).channels:
+ if channel.category_id == category.id and not self.is_excluded_channel(channel):
yield channel
@staticmethod
@@ -397,7 +414,7 @@ class HelpChannels(Scheduler, commands.Cog):
# The ready event wasn't used because channels could change categories between the time
# the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet).
# This may confuse users. So would potentially long delays for the cog to become ready.
- self.dormant_command.enabled = True
+ self.close_command.enabled = True
await self.init_available()
@@ -416,6 +433,11 @@ class HelpChannels(Scheduler, commands.Cog):
self.bot.stats.gauge("help.total.available", total_available)
self.bot.stats.gauge("help.total.dormant", total_dormant)
+ @staticmethod
+ def is_claimant(member: discord.Member) -> bool:
+ """Return True if `member` has the 'Help Cooldown' role."""
+ return any(constants.Roles.help_cooldown == role.id for role in member.roles)
+
def is_dormant_message(self, message: t.Optional[discord.Message]) -> bool:
"""Return True if the contents of the `message` match `DORMANT_MSG`."""
if not message or not message.embeds:
@@ -424,6 +446,12 @@ class HelpChannels(Scheduler, commands.Cog):
embed = message.embeds[0]
return message.author == self.bot.user and embed.description.strip() == DORMANT_MSG.strip()
+ @staticmethod
+ def is_in_category(channel: discord.TextChannel, category_id: int) -> bool:
+ """Return True if `channel` is within a category with `category_id`."""
+ actual_category = getattr(channel, "category", None)
+ return actual_category is not None and actual_category.id == category_id
+
async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None:
"""
Make the `channel` dormant if idle or schedule the move if still active.
@@ -457,6 +485,45 @@ class HelpChannels(Scheduler, commands.Cog):
self.schedule_task(channel.id, data)
+ async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None:
+ """
+ Move the `channel` to the bottom position of `category` and edit channel attributes.
+
+ To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current
+ positions of the other channels in the category as-is. This should make sure that the channel
+ really ends up at the bottom of the category.
+
+ If `options` are provided, the channel will be edited after the move is completed. This is the
+ same order of operations that `discord.TextChannel.edit` uses. For information on available
+ options, see the documention on `discord.TextChannel.edit`. While possible, position-related
+ options should be avoided, as it may interfere with the category move we perform.
+ """
+ # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had.
+ category = await self.try_get_channel(category_id)
+
+ payload = [{"id": c.id, "position": c.position} for c in category.channels]
+
+ # Calculate the bottom position based on the current highest position in the category. If the
+ # category is currently empty, we simply use the current position of the channel to avoid making
+ # unnecessary changes to positions in the guild.
+ bottom_position = payload[-1]["position"] + 1 if payload else channel.position
+
+ payload.append(
+ {
+ "id": channel.id,
+ "position": bottom_position,
+ "parent_id": category.id,
+ "lock_permissions": True,
+ }
+ )
+
+ # We use d.py's method to ensure our request is processed by d.py's rate limit manager
+ await self.bot.http.bulk_channel_update(category.guild.id, payload)
+
+ # Now that the channel is moved, we can edit the other attributes
+ if options:
+ await channel.edit(**options)
+
async def move_to_available(self) -> None:
"""Make a channel available."""
log.trace("Making a channel available.")
@@ -468,18 +535,13 @@ class HelpChannels(Scheduler, commands.Cog):
log.trace(f"Moving #{channel} ({channel.id}) to the Available category.")
- await channel.edit(
+ await self.move_to_bottom_position(
+ channel=channel,
+ category_id=constants.Categories.help_available,
name=f"{AVAILABLE_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}",
- category=self.available_category,
- sync_permissions=True,
topic=AVAILABLE_TOPIC,
)
- log.trace(
- f"Ensuring that all channels in `{self.available_category}` have "
- f"synchronized permissions after moving `{channel}` into it."
- )
- await self.ensure_permissions_synchronization(self.available_category)
self.report_stats()
async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None:
@@ -490,10 +552,10 @@ class HelpChannels(Scheduler, commands.Cog):
"""
log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.")
- await channel.edit(
+ await self.move_to_bottom_position(
+ channel=channel,
+ category_id=constants.Categories.help_dormant,
name=self.get_clean_channel_name(channel),
- category=self.dormant_category,
- sync_permissions=True,
topic=DORMANT_TOPIC,
)
@@ -504,6 +566,12 @@ class HelpChannels(Scheduler, commands.Cog):
in_use_time = datetime.now() - claimed
self.bot.stats.timing("help.in_use_time", in_use_time)
+ if channel.id in self.unanswered:
+ if self.unanswered[channel.id]:
+ self.bot.stats.incr("help.sessions.unanswered")
+ else:
+ self.bot.stats.incr("help.sessions.answered")
+
log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.")
log.trace(f"Sending dormant message for #{channel} ({channel.id}).")
@@ -518,10 +586,10 @@ class HelpChannels(Scheduler, commands.Cog):
"""Make a channel in-use and schedule it to be made dormant."""
log.info(f"Moving #{channel} ({channel.id}) to the In Use category.")
- await channel.edit(
- name=f"{IN_USE_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}",
- category=self.in_use_category,
- sync_permissions=True,
+ await self.move_to_bottom_position(
+ channel=channel,
+ category_id=constants.Categories.help_in_use,
+ name=f"{IN_USE_UNANSWERED_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}",
topic=IN_USE_TOPIC,
)
@@ -578,6 +646,28 @@ class HelpChannels(Scheduler, commands.Cog):
# Handle it here cause this feature isn't critical for the functionality of the system.
log.exception("Failed to send notification about lack of dormant channels!")
+ async def check_for_answer(self, message: discord.Message) -> None:
+ """Checks for whether new content in a help channel comes from non-claimants."""
+ channel = message.channel
+
+ # Confirm the channel is an in use help channel
+ if self.is_in_category(channel, constants.Categories.help_in_use):
+ log.trace(f"Checking if #{channel} ({channel.id}) has been answered.")
+
+ # Check if there is an entry in unanswered (does not persist across restarts)
+ if channel.id in self.unanswered:
+ claimant_id = self.help_channel_claimants[channel].id
+
+ # Check the message did not come from the claimant
+ if claimant_id != message.author.id:
+ # Mark the channel as answered
+ self.unanswered[channel.id] = False
+
+ # Change the emoji in the channel name to signify activity
+ log.trace(f"#{channel} ({channel.id}) has been answered; changing its emoji")
+ name = self.get_clean_channel_name(channel)
+ await channel.edit(name=f"{IN_USE_ANSWERED_EMOJI}{NAME_SEPARATOR}{name}")
+
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
"""Move an available channel to the In Use category and replace it with a dormant one."""
@@ -585,8 +675,11 @@ class HelpChannels(Scheduler, commands.Cog):
return # Ignore messages sent by bots.
channel = message.channel
- if channel.category and channel.category.id != constants.Categories.help_available:
- return # Ignore messages outside the Available category.
+
+ await self.check_for_answer(message)
+
+ if not self.is_in_category(channel, constants.Categories.help_available) or self.is_excluded_channel(channel):
+ return # Ignore messages outside the Available category or in excluded channels.
log.trace("Waiting for the cog to be ready before processing messages.")
await self.ready.wait()
@@ -595,13 +688,14 @@ class HelpChannels(Scheduler, commands.Cog):
async with self.on_message_lock:
log.trace(f"on_message lock acquired for {message.id}.")
- if channel.category and channel.category.id != constants.Categories.help_available:
+ if not self.is_in_category(channel, constants.Categories.help_available):
log.debug(
f"Message {message.id} will not make #{channel} ({channel.id}) in-use "
f"because another message in the channel already triggered that."
)
return
+ log.info(f"Channel #{channel} was claimed by `{message.author.id}`.")
await self.move_to_in_use(channel)
await self.revoke_send_permissions(message.author)
# Add user with channel for dormant check.
@@ -610,6 +704,7 @@ class HelpChannels(Scheduler, commands.Cog):
self.bot.stats.incr("help.claimed")
self.claim_times[channel.id] = datetime.now()
+ self.unanswered[channel.id] = True
log.trace(f"Releasing on_message lock for {message.id}.")
@@ -618,67 +713,49 @@ class HelpChannels(Scheduler, commands.Cog):
# be put in the queue.
await self.move_to_available()
- @staticmethod
- async def ensure_permissions_synchronization(category: discord.CategoryChannel) -> None:
- """
- Ensure that all channels in the `category` have their permissions synchronized.
-
- This method mitigates an issue we have yet to find the cause for: Every so often, a channel in the
- `Help: Available` category gets in a state in which it will no longer synchronizes its permissions
- with the category. To prevent that, we iterate over the channels in the category and edit the channels
- that are observed to be in such a state. If no "out of sync" channels are observed, this method will
- not make API calls and should be fairly inexpensive to run.
- """
- for channel in category.channels:
- if not channel.permissions_synced:
- log.info(f"The permissions of channel `{channel}` were out of sync with category `{category}`.")
- await channel.edit(sync_permissions=True)
-
- async def update_category_permissions(
- self, category: discord.CategoryChannel, member: discord.Member, **permissions
- ) -> None:
- """
- Update the permissions of the given `member` for the given `category` with `permissions` passed.
-
- After updating the permissions for the member in the category, this helper function will call the
- `ensure_permissions_synchronization` method to ensure that all channels are still synchronizing their
- permissions with the category. It's currently unknown why some channels get "out of sync", but this
- hopefully mitigates the issue.
- """
- log.trace(f"Updating permissions for `{member}` in `{category}` with {permissions}.")
- await category.set_permissions(member, **permissions)
-
- log.trace(f"Ensuring that all channels in `{category}` are synchronized after permissions update.")
- await self.ensure_permissions_synchronization(category)
-
async def reset_send_permissions(self) -> None:
- """Reset send permissions for members with it set to False in the Available category."""
+ """Reset send permissions in the Available category for claimants."""
log.trace("Resetting send permissions in the Available category.")
+ guild = self.bot.get_guild(constants.Guild.id)
- for member, overwrite in self.available_category.overwrites.items():
- if isinstance(member, discord.Member) and overwrite.send_messages is False:
- log.trace(f"Resetting send permissions for {member} ({member.id}).")
+ # TODO: replace with a persistent cache cause checking every member is quite slow
+ for member in guild.members:
+ if self.is_claimant(member):
+ await self.remove_cooldown_role(member)
- # We don't use the permissions helper function here as we may have to reset multiple overwrites
- # and we don't want to enforce the permissions synchronization in each iteration.
- await self.available_category.set_permissions(member, overwrite=None)
+ async def add_cooldown_role(self, member: discord.Member) -> None:
+ """Add the help cooldown role to `member`."""
+ log.trace(f"Adding cooldown role for {member} ({member.id}).")
+ await self._change_cooldown_role(member, member.add_roles)
- log.trace(f"Ensuring channels in `Help: Available` are synchronized after permissions reset.")
- await self.ensure_permissions_synchronization(self.available_category)
+ async def remove_cooldown_role(self, member: discord.Member) -> None:
+ """Remove the help cooldown role from `member`."""
+ log.trace(f"Removing cooldown role for {member} ({member.id}).")
+ await self._change_cooldown_role(member, member.remove_roles)
- async def reset_claimant_send_permission(self, channel: discord.TextChannel) -> None:
- """Reset send permissions in the Available category for the help `channel` claimant."""
- log.trace(f"Attempting to find claimant for #{channel.name} ({channel.id}).")
- try:
- member = self.help_channel_claimants[channel]
- except KeyError:
- log.trace(f"Channel #{channel.name} ({channel.id}) not in claimant cache, permissions unchanged.")
+ async def _change_cooldown_role(self, member: discord.Member, coro_func: CoroutineFunc) -> None:
+ """
+ Change `member`'s cooldown role via awaiting `coro_func` and handle errors.
+
+ `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`.
+ """
+ guild = self.bot.get_guild(constants.Guild.id)
+ role = guild.get_role(constants.Roles.help_cooldown)
+ if role is None:
+ log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!")
return
- log.trace(f"Resetting send permissions for {member} ({member.id}).")
- await self.update_category_permissions(self.available_category, member, overwrite=None)
- # Ignore missing task when claim cooldown has passed but the channel still isn't dormant.
- self.cancel_task(member.id, ignore_missing=True)
+ try:
+ await coro_func(role)
+ except discord.NotFound:
+ log.debug(f"Failed to change role for {member} ({member.id}): member not found")
+ except discord.Forbidden:
+ log.debug(
+ f"Forbidden to change role for {member} ({member.id}); "
+ f"possibly due to role hierarchy"
+ )
+ except discord.HTTPException as e:
+ log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}")
async def revoke_send_permissions(self, member: discord.Member) -> None:
"""
@@ -691,14 +768,14 @@ class HelpChannels(Scheduler, commands.Cog):
f"Revoking {member}'s ({member.id}) send message permissions in the Available category."
)
- await self.update_category_permissions(self.available_category, member, send_messages=False)
+ await self.add_cooldown_role(member)
# Cancel the existing task, if any.
# Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner).
self.cancel_task(member.id, ignore_missing=True)
timeout = constants.HelpChannels.claim_minutes * 60
- callback = self.update_category_permissions(self.available_category, member, overwrite=None)
+ callback = self.remove_cooldown_role(member)
log.trace(f"Scheduling {member}'s ({member.id}) send message permissions to be reinstated.")
self.schedule_task(member.id, TaskData(timeout, callback))
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
index 7921a4932..ef2f308ca 100644
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -12,7 +12,7 @@ from discord.utils import escape_markdown
from bot import constants
from bot.bot import Bot
-from bot.decorators import InChannelCheckFailure, in_channel, with_role
+from bot.decorators import InWhitelistCheckFailure, in_whitelist, with_role
from bot.pagination import LinePaginator
from bot.utils.checks import cooldown_with_role_bypass, with_role_check
from bot.utils.time import time_since
@@ -152,7 +152,7 @@ class Information(Cog):
# Non-staff may only do this in #bot-commands
if not with_role_check(ctx, *constants.STAFF_ROLES):
if not ctx.channel.id == constants.Channels.bot_commands:
- raise InChannelCheckFailure(constants.Channels.bot_commands)
+ raise InWhitelistCheckFailure(constants.Channels.bot_commands)
embed = await self.create_user_embed(ctx, user)
@@ -206,7 +206,7 @@ class Information(Cog):
description="\n\n".join(description)
)
- embed.set_thumbnail(url=user.avatar_url_as(format="png"))
+ embed.set_thumbnail(url=user.avatar_url_as(static_format="png"))
embed.colour = user.top_role.colour if roles else Colour.blurple()
return embed
@@ -331,7 +331,7 @@ class Information(Cog):
@cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES)
@group(invoke_without_command=True)
- @in_channel(constants.Channels.bot_commands, bypass_roles=constants.STAFF_ROLES)
+ @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."""
# I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling
diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py
index efa19f59e..e62a36c43 100644
--- a/bot/cogs/moderation/infractions.py
+++ b/bot/cogs/moderation/infractions.py
@@ -199,7 +199,7 @@ class Infractions(InfractionScheduler, commands.Cog):
async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None:
"""Apply a mute infraction with kwargs passed to `post_infraction`."""
- if await utils.has_active_infraction(ctx, user, "mute"):
+ if await utils.get_active_infraction(ctx, user, "mute"):
return
infraction = await utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs)
@@ -235,8 +235,22 @@ class Infractions(InfractionScheduler, commands.Cog):
Will also remove the banned user from the Big Brother watch list if applicable.
"""
- if await utils.has_active_infraction(ctx, user, "ban"):
- return
+ # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active
+ is_temporary = kwargs.get("expires_at") is not None
+ active_infraction = await utils.get_active_infraction(ctx, user, "ban", is_temporary)
+
+ if active_infraction:
+ if is_temporary:
+ log.trace("Tempban ignored as it cannot overwrite an active ban.")
+ return
+
+ if active_infraction.get('expires_at') is None:
+ log.trace("Permaban already exists, notify.")
+ await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).")
+ return
+
+ log.trace("Old tempban is being replaced by new permaban.")
+ await self.pardon_infraction(ctx, "ban", user, is_temporary)
infraction = await utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs)
if infraction is None:
diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py
index 250a24247..edfdfd9e2 100644
--- a/bot/cogs/moderation/management.py
+++ b/bot/cogs/moderation/management.py
@@ -43,7 +43,7 @@ class ModManagement(commands.Cog):
@commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True)
async def infraction_group(self, ctx: Context) -> None:
"""Infraction manipulation commands."""
- await ctx.invoke(self.bot.get_command("help"), "infraction")
+ await ctx.send_help(ctx.command)
@infraction_group.command(name='edit')
async def infraction_edit(
diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py
index 917697be9..dc42bee2e 100644
--- a/bot/cogs/moderation/scheduler.py
+++ b/bot/cogs/moderation/scheduler.py
@@ -190,8 +190,19 @@ class InfractionScheduler(Scheduler):
log.info(f"Applied {infr_type} infraction #{id_} to {user}.")
- async def pardon_infraction(self, ctx: Context, infr_type: str, user: UserSnowflake) -> None:
- """Prematurely end an infraction for a user and log the action in the mod log."""
+ async def pardon_infraction(
+ self,
+ ctx: Context,
+ infr_type: str,
+ user: UserSnowflake,
+ send_msg: bool = True
+ ) -> None:
+ """
+ Prematurely end an infraction for a user and log the action in the mod log.
+
+ If `send_msg` is True, then a pardoning confirmation message will be sent to
+ the context channel. Otherwise, no such message will be sent.
+ """
log.trace(f"Pardoning {infr_type} infraction for {user}.")
# Check the current active infraction
@@ -276,11 +287,12 @@ class InfractionScheduler(Scheduler):
log.info(f"Pardoned {infr_type} infraction #{id_} for {user}.")
# Send a confirmation message to the invoking context.
- log.trace(f"Sending infraction #{id_} pardon confirmation message.")
- await ctx.send(
- f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. "
- f"{log_text.get('Failure', '')}"
- )
+ if send_msg:
+ log.trace(f"Sending infraction #{id_} pardon confirmation message.")
+ await ctx.send(
+ f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. "
+ f"{log_text.get('Failure', '')}"
+ )
# Send a log message to the mod log.
await self.mod_log.send_log_message(
diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py
index ca3dc4202..29855c325 100644
--- a/bot/cogs/moderation/superstarify.py
+++ b/bot/cogs/moderation/superstarify.py
@@ -130,7 +130,7 @@ class Superstarify(InfractionScheduler, Cog):
An optional reason can be provided. If no reason is given, the original name will be shown
in a generated reason.
"""
- if await utils.has_active_infraction(ctx, member, "superstar"):
+ if await utils.get_active_infraction(ctx, member, "superstar"):
return
# Post the infraction to the API
diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py
index 3598f3b1f..e4e0f1ec2 100644
--- a/bot/cogs/moderation/utils.py
+++ b/bot/cogs/moderation/utils.py
@@ -97,8 +97,19 @@ async def post_infraction(
return
-async def has_active_infraction(ctx: Context, user: UserSnowflake, infr_type: str) -> bool:
- """Checks if a user already has an active infraction of the given type."""
+async def get_active_infraction(
+ ctx: Context,
+ user: UserSnowflake,
+ infr_type: str,
+ send_msg: bool = True
+) -> t.Optional[dict]:
+ """
+ Retrieves an active infraction of the given type for the user.
+
+ If `send_msg` is True and the user has an active infraction matching the `infr_type` parameter,
+ then a message for the moderator will be sent to the context channel letting them know.
+ Otherwise, no message will be sent.
+ """
log.trace(f"Checking if {user} has active infractions of type {infr_type}.")
active_infractions = await ctx.bot.api_client.get(
@@ -110,15 +121,16 @@ async def has_active_infraction(ctx: Context, user: UserSnowflake, infr_type: st
}
)
if active_infractions:
- log.trace(f"{user} has active infractions of type {infr_type}.")
- await ctx.send(
- f":x: According to my records, this user already has a {infr_type} infraction. "
- f"See infraction **#{active_infractions[0]['id']}**."
- )
- return True
+ # Checks to see if the moderator should be told there is an active infraction
+ if send_msg:
+ log.trace(f"{user} has active infractions of type {infr_type}.")
+ await ctx.send(
+ f":x: According to my records, this user already has a {infr_type} infraction. "
+ f"See infraction **#{active_infractions[0]['id']}**."
+ )
+ return active_infractions[0]
else:
log.trace(f"{user} does not have active infractions of type {infr_type}.")
- return False
async def notify_infraction(
diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py
index 81511f99d..201579a0b 100644
--- a/bot/cogs/off_topic_names.py
+++ b/bot/cogs/off_topic_names.py
@@ -97,7 +97,7 @@ class OffTopicNames(Cog):
@with_role(*MODERATION_ROLES)
async def otname_group(self, ctx: Context) -> None:
"""Add or list items from the off-topic channel name rotation."""
- await ctx.invoke(self.bot.get_command("help"), "otname")
+ await ctx.send_help(ctx.command)
@otname_group.command(name='add', aliases=('a',))
@with_role(*MODERATION_ROLES)
diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py
new file mode 100644
index 000000000..57ce61638
--- /dev/null
+++ b/bot/cogs/python_news.py
@@ -0,0 +1,234 @@
+import logging
+import typing as t
+from datetime import date, datetime
+
+import discord
+import feedparser
+from bs4 import BeautifulSoup
+from discord.ext.commands import Cog
+from discord.ext.tasks import loop
+
+from bot import constants
+from bot.bot import Bot
+
+PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/"
+
+RECENT_THREADS_TEMPLATE = "https://mail.python.org/archives/list/{name}@python.org/recent-threads"
+THREAD_TEMPLATE_URL = "https://mail.python.org/archives/api/list/{name}@python.org/thread/{id}/"
+MAILMAN_PROFILE_URL = "https://mail.python.org/archives/users/{id}/"
+THREAD_URL = "https://mail.python.org/archives/list/{list}@python.org/thread/{id}/"
+
+AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png"
+
+log = logging.getLogger(__name__)
+
+
+class PythonNews(Cog):
+ """Post new PEPs and Python News to `#python-news`."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.webhook_names = {}
+ self.webhook: t.Optional[discord.Webhook] = None
+
+ self.bot.loop.create_task(self.get_webhook_names())
+ self.bot.loop.create_task(self.get_webhook_and_channel())
+
+ async def start_tasks(self) -> None:
+ """Start the tasks for fetching new PEPs and mailing list messages."""
+ self.fetch_new_media.start()
+
+ @loop(minutes=20)
+ async def fetch_new_media(self) -> None:
+ """Fetch new mailing list messages and then new PEPs."""
+ await self.post_maillist_news()
+ await self.post_pep_news()
+
+ async def sync_maillists(self) -> None:
+ """Sync currently in-use maillists with API."""
+ # Wait until guild is available to avoid running before everything is ready
+ await self.bot.wait_until_guild_available()
+
+ response = await self.bot.api_client.get("bot/bot-settings/news")
+ for mail in constants.PythonNews.mail_lists:
+ if mail not in response["data"]:
+ response["data"][mail] = []
+
+ # Because we are handling PEPs differently, we don't include it to mail lists
+ if "pep" not in response["data"]:
+ response["data"]["pep"] = []
+
+ await self.bot.api_client.put("bot/bot-settings/news", json=response)
+
+ async def get_webhook_names(self) -> None:
+ """Get webhook author names from maillist API."""
+ await self.bot.wait_until_guild_available()
+
+ async with self.bot.http_session.get("https://mail.python.org/archives/api/lists") as resp:
+ lists = await resp.json()
+
+ for mail in lists:
+ if mail["name"].split("@")[0] in constants.PythonNews.mail_lists:
+ self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"]
+
+ async def post_pep_news(self) -> None:
+ """Fetch new PEPs and when they don't have announcement in #python-news, create it."""
+ # Wait until everything is ready and http_session available
+ await self.bot.wait_until_guild_available()
+ await self.sync_maillists()
+
+ async with self.bot.http_session.get(PEPS_RSS_URL) as resp:
+ data = feedparser.parse(await resp.text("utf-8"))
+
+ news_listing = await self.bot.api_client.get("bot/bot-settings/news")
+ payload = news_listing.copy()
+ pep_numbers = news_listing["data"]["pep"]
+
+ # Reverse entries to send oldest first
+ data["entries"].reverse()
+ for new in data["entries"]:
+ try:
+ new_datetime = datetime.strptime(new["published"], "%a, %d %b %Y %X %Z")
+ except ValueError:
+ log.warning(f"Wrong datetime format passed in PEP new: {new['published']}")
+ continue
+ pep_nr = new["title"].split(":")[0].split()[1]
+ if (
+ pep_nr in pep_numbers
+ or new_datetime.date() < date.today()
+ ):
+ continue
+
+ msg = await self.send_webhook(
+ title=new["title"],
+ description=new["summary"],
+ timestamp=new_datetime,
+ url=new["link"],
+ webhook_profile_name=data["feed"]["title"],
+ footer=data["feed"]["title"]
+ )
+ payload["data"]["pep"].append(pep_nr)
+
+ if msg.channel.is_news():
+ log.trace("Publishing PEP annnouncement because it was in a news channel")
+ await msg.publish()
+
+ # Apply new sent news to DB to avoid duplicate sending
+ await self.bot.api_client.put("bot/bot-settings/news", json=payload)
+
+ async def post_maillist_news(self) -> None:
+ """Send new maillist threads to #python-news that is listed in configuration."""
+ await self.bot.wait_until_guild_available()
+ await self.sync_maillists()
+ existing_news = await self.bot.api_client.get("bot/bot-settings/news")
+ payload = existing_news.copy()
+
+ for maillist in constants.PythonNews.mail_lists:
+ async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp:
+ recents = BeautifulSoup(await resp.text(), features="lxml")
+
+ # When a <p> element is present in the response then the mailing list
+ # has not had any activity during the current month, so therefore it
+ # can be ignored.
+ if recents.p:
+ continue
+
+ for thread in recents.html.body.div.find_all("a", href=True):
+ # We want only these threads that have identifiers
+ if "latest" in thread["href"]:
+ continue
+
+ thread_information, email_information = await self.get_thread_and_first_mail(
+ maillist, thread["href"].split("/")[-2]
+ )
+
+ try:
+ new_date = datetime.strptime(email_information["date"], "%Y-%m-%dT%X%z")
+ except ValueError:
+ log.warning(f"Invalid datetime from Thread email: {email_information['date']}")
+ continue
+
+ if (
+ thread_information["thread_id"] in existing_news["data"][maillist]
+ or new_date.date() < date.today()
+ ):
+ continue
+
+ content = email_information["content"]
+ link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist)
+ msg = await self.send_webhook(
+ title=thread_information["subject"],
+ description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content,
+ timestamp=new_date,
+ url=link,
+ author=f"{email_information['sender_name']} ({email_information['sender']['address']})",
+ author_url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]),
+ webhook_profile_name=self.webhook_names[maillist],
+ footer=f"Posted to {self.webhook_names[maillist]}"
+ )
+ payload["data"][maillist].append(thread_information["thread_id"])
+
+ if msg.channel.is_news():
+ log.trace("Publishing mailing list message because it was in a news channel")
+ await msg.publish()
+
+ await self.bot.api_client.put("bot/bot-settings/news", json=payload)
+
+ async def send_webhook(self,
+ title: str,
+ description: str,
+ timestamp: datetime,
+ url: str,
+ webhook_profile_name: str,
+ footer: str,
+ author: t.Optional[str] = None,
+ author_url: t.Optional[str] = None,
+ ) -> discord.Message:
+ """Send webhook entry and return sent message."""
+ embed = discord.Embed(
+ title=title,
+ description=description,
+ timestamp=timestamp,
+ url=url,
+ colour=constants.Colours.soft_green
+ )
+ if author and author_url:
+ embed.set_author(
+ name=author,
+ url=author_url
+ )
+ embed.set_footer(text=footer, icon_url=AVATAR_URL)
+
+ return await self.webhook.send(
+ embed=embed,
+ username=webhook_profile_name,
+ avatar_url=AVATAR_URL,
+ wait=True
+ )
+
+ async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]:
+ """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`."""
+ async with self.bot.http_session.get(
+ THREAD_TEMPLATE_URL.format(name=maillist, id=thread_identifier)
+ ) as resp:
+ thread_information = await resp.json()
+
+ async with self.bot.http_session.get(thread_information["starting_email"]) as resp:
+ email_information = await resp.json()
+ return thread_information, email_information
+
+ async def get_webhook_and_channel(self) -> None:
+ """Storage #python-news channel Webhook and `TextChannel` to `News.webhook` and `channel`."""
+ await self.bot.wait_until_guild_available()
+ self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook)
+
+ await self.start_tasks()
+
+ def cog_unload(self) -> None:
+ """Stop news posting tasks on cog unload."""
+ self.fetch_new_media.cancel()
+
+
+def setup(bot: Bot) -> None:
+ """Add `News` cog."""
+ bot.add_cog(PythonNews(bot))
diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py
index 5a7fa100f..5f2aec7a5 100644
--- a/bot/cogs/reddit.py
+++ b/bot/cogs/reddit.py
@@ -245,7 +245,7 @@ class Reddit(Cog):
@group(name="reddit", invoke_without_command=True)
async def reddit_group(self, ctx: Context) -> None:
"""View the top posts from various subreddits."""
- await ctx.invoke(self.bot.get_command("help"), "reddit")
+ await ctx.send_help(ctx.command)
@reddit_group.command(name="top")
async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py
index 24c279357..c242d2920 100644
--- a/bot/cogs/reminders.py
+++ b/bot/cogs/reminders.py
@@ -158,7 +158,7 @@ class Reminders(Scheduler, Cog):
)
await self._delete_reminder(reminder["id"])
- @group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True)
+ @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True)
async def remind_group(self, ctx: Context, expiration: Duration, *, content: str) -> None:
"""Commands for managing your reminders."""
await ctx.invoke(self.new_reminder, expiration=expiration, content=content)
@@ -281,7 +281,7 @@ class Reminders(Scheduler, Cog):
@remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True)
async def edit_reminder_group(self, ctx: Context) -> None:
"""Commands for modifying your current reminders."""
- await ctx.invoke(self.bot.get_command("help"), "reminders", "edit")
+ await ctx.send_help(ctx.command)
@edit_reminder_group.command(name="duration", aliases=("time",))
async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None:
diff --git a/bot/cogs/site.py b/bot/cogs/site.py
index 853e29568..7fc2a9c34 100644
--- a/bot/cogs/site.py
+++ b/bot/cogs/site.py
@@ -21,7 +21,7 @@ class Site(Cog):
@group(name="site", aliases=("s",), invoke_without_command=True)
async def site_group(self, ctx: Context) -> None:
"""Commands for getting info about our website."""
- await ctx.invoke(self.bot.get_command("help"), "site")
+ await ctx.send_help(ctx.command)
@site_group.command(name="home", aliases=("about",))
async def site_main(self, ctx: Context) -> None:
diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py
index 315383b12..c2782b9c8 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/cogs/snekbox.py
@@ -12,8 +12,8 @@ from discord import HTTPException, Message, NotFound, Reaction, User
from discord.ext.commands import Cog, Context, command, guild_only
from bot.bot import Bot
-from bot.constants import Channels, Roles, URLs
-from bot.decorators import in_channel
+from bot.constants import Categories, Channels, Roles, URLs
+from bot.decorators import in_whitelist
from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
@@ -38,6 +38,10 @@ RAW_CODE_REGEX = re.compile(
)
MAX_PASTE_LEN = 1000
+
+# `!eval` command whitelists
+EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric)
+EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use)
EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners)
SIGKILL = 9
@@ -265,7 +269,7 @@ class Snekbox(Cog):
@command(name="eval", aliases=("e",))
@guild_only()
- @in_channel(Channels.bot_commands, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES)
+ @in_whitelist(channels=EVAL_CHANNELS, categories=EVAL_CATEGORIES, roles=EVAL_ROLES)
async def eval_command(self, ctx: Context, *, code: str = None) -> None:
"""
Run Python code and get the results.
@@ -285,7 +289,7 @@ class Snekbox(Cog):
return
if not code: # None or empty string
- await ctx.invoke(self.bot.get_command("help"), "eval")
+ await ctx.send_help(ctx.command)
return
log.info(f"Received code from {ctx.author} for evaluation:\n{code}")
diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py
index 003bf3727..e55bf27fd 100644
--- a/bot/cogs/sync/syncers.py
+++ b/bot/cogs/sync/syncers.py
@@ -1,4 +1,5 @@
import abc
+import asyncio
import logging
import typing as t
from collections import namedtuple
@@ -122,7 +123,7 @@ class Syncer(abc.ABC):
check=partial(self._reaction_check, author, message),
timeout=constants.Sync.confirm_timeout
)
- except TimeoutError:
+ except asyncio.TimeoutError:
# reaction will remain none thus sync will be aborted in the finally block below.
log.debug(f"The {self.name} syncer confirmation prompt timed out.")
diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
index 9ba33d7e0..a813ffff5 100644
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -43,7 +43,7 @@ class Tags(Cog):
tag = {
"title": tag_title,
"embed": {
- "description": file.read_text()
+ "description": file.read_text(encoding="utf-8")
}
}
cache[tag_title] = tag
diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py
index 3ed471bbf..6b59d37c8 100644
--- a/bot/cogs/utils.py
+++ b/bot/cogs/utils.py
@@ -2,19 +2,16 @@ import difflib
import logging
import re
import unicodedata
-from asyncio import TimeoutError, sleep
from email.parser import HeaderParser
from io import StringIO
from typing import Tuple, Union
-from dateutil import relativedelta
-from discord import Colour, Embed, Message, Role
+from discord import Colour, Embed
from discord.ext.commands import BadArgument, Cog, Context, command
from bot.bot import Bot
-from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES
-from bot.decorators import in_channel, with_role
-from bot.utils.time import humanize_delta
+from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES
+from bot.decorators import in_whitelist, with_role
log = logging.getLogger(__name__)
@@ -58,7 +55,7 @@ class Utils(Cog):
if pep_number.isdigit():
pep_number = int(pep_number)
else:
- await ctx.invoke(self.bot.get_command("help"), "pep")
+ await ctx.send_help(ctx.command)
return
# Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs.
@@ -118,7 +115,7 @@ class Utils(Cog):
await ctx.message.channel.send(embed=pep_embed)
@command()
- @in_channel(Channels.bot_commands, bypass_roles=STAFF_ROLES)
+ @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES)
async def charinfo(self, ctx: Context, *, characters: str) -> None:
"""Shows you information on up to 25 unicode characters."""
match = re.match(r"<(a?):(\w+):(\d+)>", characters)
@@ -162,47 +159,6 @@ class Utils(Cog):
await ctx.send(embed=embed)
@command()
- @with_role(*MODERATION_ROLES)
- async def mention(self, ctx: Context, *, role: Role) -> None:
- """Set a role to be mentionable for a limited time."""
- if role.mentionable:
- await ctx.send(f"{role} is already mentionable!")
- return
-
- await role.edit(reason=f"Role unlocked by {ctx.author}", mentionable=True)
-
- human_time = humanize_delta(relativedelta.relativedelta(seconds=Mention.message_timeout))
- await ctx.send(
- f"{role} has been made mentionable. I will reset it in {human_time}, or when someone mentions this role."
- )
-
- def check(m: Message) -> bool:
- """Checks that the message contains the role mention."""
- return role in m.role_mentions
-
- try:
- msg = await self.bot.wait_for("message", check=check, timeout=Mention.message_timeout)
- except TimeoutError:
- await role.edit(mentionable=False, reason="Automatic role lock - timeout.")
- await ctx.send(f"{ctx.author.mention}, you took too long. I have reset {role} to be unmentionable.")
- return
-
- if any(r.id in MODERATION_ROLES for r in msg.author.roles):
- await sleep(Mention.reset_delay)
- await role.edit(mentionable=False, reason=f"Automatic role lock by {msg.author}")
- await ctx.send(
- f"{ctx.author.mention}, I have reset {role} to be unmentionable as "
- f"{msg.author if msg.author != ctx.author else 'you'} sent a message mentioning it."
- )
- return
-
- await role.edit(mentionable=False, reason=f"Automatic role lock - unauthorised use by {msg.author}")
- await ctx.send(
- f"{ctx.author.mention}, I have reset {role} to be unmentionable "
- f"as I detected unauthorised use by {msg.author} (ID: {msg.author.id})."
- )
-
- @command()
async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None:
"""
Show the Zen of Python.
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
index b0a493e68..77e8b5706 100644
--- a/bot/cogs/verification.py
+++ b/bot/cogs/verification.py
@@ -9,7 +9,7 @@ from discord.ext.commands import Cog, Context, command
from bot import constants
from bot.bot import Bot
from bot.cogs.moderation import ModLog
-from bot.decorators import InChannelCheckFailure, in_channel, without_role
+from bot.decorators import InWhitelistCheckFailure, in_whitelist, without_role
from bot.utils.checks import without_role_check
log = logging.getLogger(__name__)
@@ -40,7 +40,7 @@ else:
PERIODIC_PING = (
f"@everyone To verify that you have read our rules, please type `{constants.Bot.prefix}accept`."
" If you encounter any problems during the verification process, "
- f"ping the <@&{constants.Roles.admins}> role in this channel."
+ f"send a direct message to a staff member."
)
BOT_MESSAGE_DELETE_DELAY = 10
@@ -92,7 +92,6 @@ class Verification(Cog):
text=embed_text,
thumbnail=message.author.avatar_url_as(static_format="png"),
channel_id=constants.Channels.mod_alerts,
- ping_everyone=constants.Filter.ping_everyone,
)
ctx: Context = await self.bot.get_context(message)
@@ -122,7 +121,7 @@ class Verification(Cog):
@command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True)
@without_role(constants.Roles.verified)
- @in_channel(constants.Channels.verification)
+ @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.")
@@ -138,7 +137,7 @@ class Verification(Cog):
await ctx.message.delete()
@command(name='subscribe')
- @in_channel(constants.Channels.bot_commands)
+ @in_whitelist(channels=(constants.Channels.bot_commands,))
async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
"""Subscribe to announcement notifications by assigning yourself the role."""
has_role = False
@@ -162,7 +161,7 @@ class Verification(Cog):
)
@command(name='unsubscribe')
- @in_channel(constants.Channels.bot_commands)
+ @in_whitelist(channels=(constants.Channels.bot_commands,))
async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
"""Unsubscribe from announcement notifications by removing the role from yourself."""
has_role = False
@@ -187,8 +186,8 @@ class Verification(Cog):
# This cannot be static (must have a __func__ attribute).
async def cog_command_error(self, ctx: Context, error: Exception) -> None:
- """Check for & ignore any InChannelCheckFailure."""
- if isinstance(error, InChannelCheckFailure):
+ """Check for & ignore any InWhitelistCheckFailure."""
+ if isinstance(error, InWhitelistCheckFailure):
error.handled = True
@staticmethod
diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py
index 903c87f85..e4fb173e0 100644
--- a/bot/cogs/watchchannels/bigbrother.py
+++ b/bot/cogs/watchchannels/bigbrother.py
@@ -30,7 +30,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
@with_role(*MODERATION_ROLES)
async def bigbrother_group(self, ctx: Context) -> None:
"""Monitors users by relaying their messages to the Big Brother watch channel."""
- await ctx.invoke(self.bot.get_command("help"), "bigbrother")
+ await ctx.send_help(ctx.command)
@bigbrother_group.command(name='watched', aliases=('all', 'list'))
@with_role(*MODERATION_ROLES)
diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py
index ad0c51fa6..9a85c68c2 100644
--- a/bot/cogs/watchchannels/talentpool.py
+++ b/bot/cogs/watchchannels/talentpool.py
@@ -34,7 +34,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
@with_role(*MODERATION_ROLES)
async def nomination_group(self, ctx: Context) -> None:
"""Highlights the activity of helper nominees by relaying their messages to the talent pool channel."""
- await ctx.invoke(self.bot.get_command("help"), "talentpool")
+ await ctx.send_help(ctx.command)
@nomination_group.command(name='watched', aliases=('all', 'list'))
@with_role(*MODERATION_ROLES)
@@ -173,7 +173,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
@with_role(*MODERATION_ROLES)
async def nomination_edit_group(self, ctx: Context) -> None:
"""Commands to edit nominations."""
- await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit")
+ await ctx.send_help(ctx.command)
@nomination_edit_group.command(name='reason')
@with_role(*MODERATION_ROLES)
diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py
index 5d6b4630b..e6cae3bb8 100644
--- a/bot/cogs/wolfram.py
+++ b/bot/cogs/wolfram.py
@@ -60,6 +60,14 @@ def custom_cooldown(*ignore: List[int]) -> Callable:
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):
diff --git a/bot/constants.py b/bot/constants.py
index 2add028e7..fd280e9de 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -383,6 +383,7 @@ class Channels(metaclass=YAMLGetter):
dev_log: int
esoteric: int
helpers: int
+ how_to_get_help: int
message_log: int
meta: int
mod_alerts: int
@@ -421,6 +422,7 @@ class Roles(metaclass=YAMLGetter):
announcements: int
contributors: int
core_developers: int
+ help_cooldown: int
helpers: int
jammers: int
moderators: int
@@ -548,13 +550,6 @@ class HelpChannels(metaclass=YAMLGetter):
notify_roles: List[int]
-class Mention(metaclass=YAMLGetter):
- section = 'mention'
-
- message_timeout: int
- reset_delay: int
-
-
class RedirectOutput(metaclass=YAMLGetter):
section = 'redirect_output'
@@ -569,6 +564,14 @@ class Sync(metaclass=YAMLGetter):
max_diff: int
+class PythonNews(metaclass=YAMLGetter):
+ section = 'python_news'
+
+ mail_lists: List[str]
+ channel: int
+ webhook: int
+
+
class Event(Enum):
"""
Event names. This does not include every event (for example, raw
diff --git a/bot/decorators.py b/bot/decorators.py
index 2d18eaa6a..306f0830c 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -1,9 +1,9 @@
import logging
import random
-from asyncio import Lock, sleep
+from asyncio import Lock, create_task, sleep
from contextlib import suppress
from functools import wraps
-from typing import Callable, Container, Union
+from typing import Callable, Container, Optional, Union
from weakref import WeakValueDictionary
from discord import Colour, Embed, Member
@@ -11,54 +11,79 @@ from discord.errors import NotFound
from discord.ext import commands
from discord.ext.commands import CheckFailure, Cog, Context
-from bot.constants import ERROR_REPLIES, RedirectOutput
+from bot.constants import Channels, ERROR_REPLIES, RedirectOutput
from bot.utils.checks import with_role_check, without_role_check
log = logging.getLogger(__name__)
-class InChannelCheckFailure(CheckFailure):
- """Raised when a check fails for a message being sent in a whitelisted channel."""
+class InWhitelistCheckFailure(CheckFailure):
+ """Raised when the `in_whitelist` check fails."""
- def __init__(self, *channels: int):
- self.channels = channels
- channels_str = ', '.join(f"<#{c_id}>" for c_id in channels)
+ def __init__(self, redirect_channel: Optional[int]) -> None:
+ self.redirect_channel = redirect_channel
- super().__init__(f"Sorry, but you may only use this command within {channels_str}.")
+ if redirect_channel:
+ redirect_message = f" here. Please use the <#{redirect_channel}> channel instead"
+ else:
+ redirect_message = ""
+ error_message = f"You are not allowed to use that command{redirect_message}."
+
+ super().__init__(error_message)
+
+
+def in_whitelist(
+ *,
+ channels: Container[int] = (),
+ categories: Container[int] = (),
+ roles: Container[int] = (),
+ redirect: Optional[int] = Channels.bot_commands,
-def in_channel(
- *channels: int,
- hidden_channels: Container[int] = None,
- bypass_roles: Container[int] = None
) -> Callable:
"""
- Checks that the message is in a whitelisted channel or optionally has a bypass role.
+ Check if a command was issued in a whitelisted context.
+
+ The whitelists that can be provided are:
+
+ - `channels`: a container with channel ids for whitelisted channels
+ - `categories`: a container with category ids for whitelisted categories
+ - `roles`: a container with with role ids for whitelisted roles
- Hidden channels are channels which will not be displayed in the InChannelCheckFailure error
- message.
+ If the command was invoked in a context that was not whitelisted, the member is either
+ redirected to the `redirect` channel that was passed (default: #bot-commands) or simply
+ told that they're not allowed to use this particular command (if `None` was passed).
"""
- hidden_channels = hidden_channels or []
- bypass_roles = bypass_roles or []
+ if redirect and redirect not in channels:
+ # It does not make sense for the channel whitelist to not contain the redirection
+ # channel (if applicable). That's why we add the redirection channel to the `channels`
+ # container if it's not already in it. As we allow any container type to be passed,
+ # we first create a tuple in order to safely add the redirection channel.
+ #
+ # Note: It's possible for the redirect channel to be in a whitelisted category, but
+ # there's no easy way to check that and as a channel can easily be moved in and out of
+ # categories, it's probably not wise to rely on its category in any case.
+ channels = tuple(channels) + (redirect,)
def predicate(ctx: Context) -> bool:
- """In-channel checker predicate."""
- if ctx.channel.id in channels or ctx.channel.id in hidden_channels:
- log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The command was used in a whitelisted channel.")
+ """Check if a command was issued in a whitelisted context."""
+ if channels and ctx.channel.id in channels:
+ log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a whitelisted channel.")
return True
- if bypass_roles:
- if any(r.id in bypass_roles for r in ctx.author.roles):
- log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The command was not used in a whitelisted channel, "
- f"but the author had a role to bypass the in_channel check.")
- return True
+ # Only check the category id if we have a category whitelist and the channel has a `category_id`
+ if categories and hasattr(ctx.channel, "category_id") and ctx.channel.category_id in categories:
+ log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a whitelisted category.")
+ return True
- log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The in_channel check failed.")
+ # Only check the roles whitelist if we have one and ensure the author's roles attribute returns
+ # an iterable to prevent breakage in DM channels (for if we ever decide to enable commands there).
+ if roles and any(r.id in roles for r in getattr(ctx.author, "roles", ())):
+ log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they have a whitelisted role.")
+ return True
- raise InChannelCheckFailure(*channels)
+ log.trace(f"{ctx.author} may not use the `{ctx.command.name}` command within this context.")
+ raise InWhitelistCheckFailure(redirect)
return commands.check(predicate)
@@ -137,13 +162,12 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non
log.trace(f"Redirecting output of {ctx.author}'s command '{ctx.command.name}' to {redirect_channel.name}")
ctx.channel = redirect_channel
await ctx.channel.send(f"Here's the output of your command, {ctx.author.mention}")
- await func(self, ctx, *args, **kwargs)
+ create_task(func(self, ctx, *args, **kwargs))
message = await old_channel.send(
f"Hey, {ctx.author.mention}, you can find the output of your command here: "
f"{redirect_channel.mention}"
)
-
if RedirectOutput.delete_invocation:
await sleep(RedirectOutput.delete_delay)
@@ -154,6 +178,7 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non
with suppress(NotFound):
await ctx.message.delete()
log.trace("Redirect output: Deleted invocation message")
+
return inner
return wrap
diff --git a/bot/pagination.py b/bot/pagination.py
index 90c8f849c..b0c4b70e2 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -102,7 +102,7 @@ class LinePaginator(Paginator):
timeout: int = 300,
footer_text: str = None,
url: str = None,
- exception_on_empty_embed: bool = False
+ exception_on_empty_embed: bool = False,
) -> t.Optional[discord.Message]:
"""
Use a paginator and set of reactions to provide pagination over a set of lines.
diff --git a/bot/resources/tags/free.md b/bot/resources/tags/free.md
new file mode 100644
index 000000000..582cca9da
--- /dev/null
+++ b/bot/resources/tags/free.md
@@ -0,0 +1,5 @@
+**We have a new help channel system!**
+
+We recently moved to a new help channel system. You can now use any channel in the **<#691405807388196926>** category to ask your question.
+
+For more information, check out [our website](https://pythondiscord.com/pages/resources/guides/help-channels/).
diff --git a/bot/resources/tags/mutability.md b/bot/resources/tags/mutability.md
new file mode 100644
index 000000000..bde9b5e7e
--- /dev/null
+++ b/bot/resources/tags/mutability.md
@@ -0,0 +1,37 @@
+**Mutable vs immutable objects**
+
+Imagine that you want to make all letters in a string upper case. Conveniently, strings have an `.upper()` method.
+
+You might think that this would work:
+```python
+>>> greeting = "hello"
+>>> greeting.upper()
+'HELLO'
+>>> greeting
+'hello'
+```
+
+`greeting` didn't change. Why is that so?
+
+That's because strings in Python are _immutable_. You can't change them, you can only pass around existing strings or create new ones.
+
+```python
+>>> greeting = "hello"
+>>> greeting = greeting.upper()
+>>> greeting
+'HELLO'
+```
+
+`greeting.upper()` creates and returns a new string which is like the old one, but with all the letters turned to upper case.
+
+`int`, `float`, `complex`, `tuple`, `frozenset` are other examples of immutable data types in Python.
+
+Mutable data types like `list`, on the other hand, can be changed in-place:
+```python
+>>> my_list = [1, 2, 3]
+>>> my_list.append(4)
+>>> my_list
+[1, 2, 3, 4]
+```
+
+Other examples of mutable data types in Python are `dict` and `set`. Instances of user-defined classes are also mutable.
diff --git a/config-default.yml b/config-default.yml
index 4cd61ce10..83ea59016 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -122,6 +122,7 @@ guild:
channels:
announcements: 354619224620138496
user_event_announcements: &USER_EVENT_A 592000283102674944
+ python_news: &PYNEWS_CHANNEL 704372456592506880
# Development
dev_contrib: &DEV_CONTRIB 635950537262759947
@@ -132,6 +133,9 @@ guild:
meta: 429409067623251969
python_discussion: 267624335836053506
+ # Python Help: Available
+ how_to_get_help: 704250143020417084
+
# Logs
attachment_log: &ATTACH_LOG 649243850006855680
message_log: &MESSAGE_LOG 467752170159079424
@@ -201,6 +205,7 @@ guild:
roles:
announcements: 463658397560995840
contributors: 295488872404484098
+ help_cooldown: 699189276025421825
muted: &MUTED_ROLE 277914926603829249
partners: 323426753857191936
python_community: &PY_COMMUNITY_ROLE 458226413825294336
@@ -231,11 +236,12 @@ guild:
- *HELPERS_ROLE
webhooks:
- talent_pool: 569145364800602132
- big_brother: 569133704568373283
- reddit: 635408384794951680
- duck_pond: 637821475327311927
- dev_log: 680501655111729222
+ talent_pool: 569145364800602132
+ big_brother: 569133704568373283
+ reddit: 635408384794951680
+ duck_pond: 637821475327311927
+ dev_log: 680501655111729222
+ python_news: &PYNEWS_WEBHOOK 704381182279942324
filter:
@@ -259,7 +265,8 @@ filter:
guild_invite_whitelist:
- 280033776820813825 # Functional Programming
- 267624335836053506 # Python Discord
- - 440186186024222721 # Python Discord: ModLog Emojis
+ - 440186186024222721 # Python Discord: Emojis 1
+ - 578587418123304970 # Python Discord: Emojis 2
- 273944235143593984 # STEM
- 348658686962696195 # RLBot
- 531221516914917387 # Pallets
@@ -276,6 +283,11 @@ filter:
- 336642139381301249 # discord.py
- 405403391410438165 # Sentdex
- 172018499005317120 # The Coding Den
+ - 666560367173828639 # PyWeek
+ - 702724176489873509 # Microsoft Python
+ - 81384788765712384 # Discord API
+ - 613425648685547541 # Discord Developers
+ - 185590609631903755 # Blender Hub
domain_blacklist:
- pornhub.com
@@ -475,7 +487,6 @@ anti_malware:
- '.mp3'
- '.wav'
- '.ogg'
- - '.md'
reddit:
@@ -504,9 +515,6 @@ free:
cooldown_rate: 1
cooldown_per: 60.0
-mention:
- message_timeout: 300
- reset_delay: 5
help_channels:
enable: true
@@ -569,5 +577,13 @@ duck_pond:
- *DUCKY_MAUL
- *DUCKY_SANTA
+python_news:
+ mail_lists:
+ - 'python-ideas'
+ - 'python-announce-list'
+ - 'pypi-announce'
+ channel: *PYNEWS_CHANNEL
+ webhook: *PYNEWS_WEBHOOK
+
config:
required_keys: ['bot.token']
diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py
index 6ee9dfda6..70aea2bab 100644
--- a/tests/bot/cogs/sync/test_base.py
+++ b/tests/bot/cogs/sync/test_base.py
@@ -1,3 +1,4 @@
+import asyncio
import unittest
from unittest import mock
@@ -211,7 +212,7 @@ class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase):
subtests = (
(constants.Emojis.check_mark, True, None),
("InVaLiD", False, None),
- (None, False, TimeoutError),
+ (None, False, asyncio.TimeoutError),
)
for emoji, ret_val, side_effect in subtests:
diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py
index 39f6492cb..fdda59a8f 100644
--- a/tests/bot/cogs/test_cogs.py
+++ b/tests/bot/cogs/test_cogs.py
@@ -31,7 +31,7 @@ class CommandNameTests(unittest.TestCase):
def walk_modules() -> t.Iterator[ModuleType]:
"""Yield imported modules from the bot.cogs subpackage."""
def on_error(name: str) -> t.NoReturn:
- raise ImportError(name=name)
+ raise ImportError(name=name) # pragma: no cover
# The mock prevents asyncio.get_event_loop() from being called.
with mock.patch("discord.ext.tasks.loop"):
@@ -71,7 +71,7 @@ class CommandNameTests(unittest.TestCase):
for name in self.get_qualified_names(cmd):
with self.subTest(cmd=func_name, name=name):
- if name in all_names:
+ if name in all_names: # pragma: no cover
conflicts = ", ".join(all_names.get(name, ""))
self.fail(
f"Name '{name}' of the command {func_name} conflicts with {conflicts}."
diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py
index 3c26374f5..b5f928dd6 100644
--- a/tests/bot/cogs/test_information.py
+++ b/tests/bot/cogs/test_information.py
@@ -7,7 +7,7 @@ import discord
from bot import constants
from bot.cogs import information
-from bot.decorators import InChannelCheckFailure
+from bot.decorators import InWhitelistCheckFailure
from tests import helpers
@@ -485,7 +485,7 @@ class UserEmbedTests(unittest.TestCase):
user.avatar_url_as.return_value = "avatar url"
embed = asyncio.run(self.cog.create_user_embed(ctx, user))
- user.avatar_url_as.assert_called_once_with(format="png")
+ user.avatar_url_as.assert_called_once_with(static_format="png")
self.assertEqual(embed.thumbnail.url, "avatar url")
@@ -525,7 +525,7 @@ class UserCommandTests(unittest.TestCase):
ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100))
msg = "Sorry, but you may only use this command within <#50>."
- with self.assertRaises(InChannelCheckFailure, msg=msg):
+ with self.assertRaises(InWhitelistCheckFailure, msg=msg):
asyncio.run(self.cog.user_info.callback(self.cog, ctx))
@unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock)
diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py
index 1dec0ccaf..8490b02ca 100644
--- a/tests/bot/cogs/test_snekbox.py
+++ b/tests/bot/cogs/test_snekbox.py
@@ -208,10 +208,9 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
async def test_eval_command_call_help(self):
"""Test if the eval command call the help command if no code is provided."""
- ctx = MockContext()
- ctx.invoke = AsyncMock()
+ ctx = MockContext(command="sentinel")
await self.cog.eval_command.callback(self.cog, ctx=ctx, code='')
- ctx.invoke.assert_called_once_with(self.bot.get_command("help"), "eval")
+ ctx.send_help.assert_called_once_with("sentinel")
async def test_send_eval(self):
"""Test the send_eval function."""
diff --git a/tests/bot/test_decorators.py b/tests/bot/test_decorators.py
new file mode 100644
index 000000000..a17dd3e16
--- /dev/null
+++ b/tests/bot/test_decorators.py
@@ -0,0 +1,147 @@
+import collections
+import unittest
+import unittest.mock
+
+from bot import constants
+from bot.decorators import InWhitelistCheckFailure, in_whitelist
+from tests import helpers
+
+
+InWhitelistTestCase = collections.namedtuple("WhitelistedContextTestCase", ("kwargs", "ctx", "description"))
+
+
+class InWhitelistTests(unittest.TestCase):
+ """Tests for the `in_whitelist` check."""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up helpers that only need to be defined once."""
+ cls.bot_commands = helpers.MockTextChannel(id=123456789, category_id=123456)
+ cls.help_channel = helpers.MockTextChannel(id=987654321, category_id=987654)
+ cls.non_whitelisted_channel = helpers.MockTextChannel(id=666666)
+ cls.dm_channel = helpers.MockDMChannel()
+
+ cls.non_staff_member = helpers.MockMember()
+ cls.staff_role = helpers.MockRole(id=121212)
+ cls.staff_member = helpers.MockMember(roles=(cls.staff_role,))
+
+ cls.channels = (cls.bot_commands.id,)
+ cls.categories = (cls.help_channel.category_id,)
+ cls.roles = (cls.staff_role.id,)
+
+ def test_predicate_returns_true_for_whitelisted_context(self):
+ """The predicate should return `True` if a whitelisted context was passed to it."""
+ test_cases = (
+ InWhitelistTestCase(
+ kwargs={"channels": self.channels},
+ ctx=helpers.MockContext(channel=self.bot_commands, author=self.non_staff_member),
+ description="In whitelisted channels by members without whitelisted roles",
+ ),
+ InWhitelistTestCase(
+ kwargs={"redirect": self.bot_commands.id},
+ ctx=helpers.MockContext(channel=self.bot_commands, author=self.non_staff_member),
+ description="`redirect` should be implicitly added to `channels`",
+ ),
+ InWhitelistTestCase(
+ kwargs={"categories": self.categories},
+ ctx=helpers.MockContext(channel=self.help_channel, author=self.non_staff_member),
+ description="Whitelisted category without whitelisted role",
+ ),
+ InWhitelistTestCase(
+ kwargs={"roles": self.roles},
+ ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.staff_member),
+ description="Whitelisted role outside of whitelisted channel/category"
+ ),
+ InWhitelistTestCase(
+ kwargs={
+ "channels": self.channels,
+ "categories": self.categories,
+ "roles": self.roles,
+ "redirect": self.bot_commands,
+ },
+ ctx=helpers.MockContext(channel=self.help_channel, author=self.staff_member),
+ description="Case with all whitelist kwargs used",
+ ),
+ )
+
+ for test_case in test_cases:
+ # patch `commands.check` with a no-op lambda that just returns the predicate passed to it
+ # so we can test the predicate that was generated from the specified kwargs.
+ with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate):
+ predicate = in_whitelist(**test_case.kwargs)
+
+ with self.subTest(test_description=test_case.description):
+ self.assertTrue(predicate(test_case.ctx))
+
+ def test_predicate_raises_exception_for_non_whitelisted_context(self):
+ """The predicate should raise `InWhitelistCheckFailure` for a non-whitelisted context."""
+ test_cases = (
+ # Failing check with explicit `redirect`
+ InWhitelistTestCase(
+ kwargs={
+ "categories": self.categories,
+ "channels": self.channels,
+ "roles": self.roles,
+ "redirect": self.bot_commands.id,
+ },
+ ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member),
+ description="Failing check with an explicit redirect channel",
+ ),
+
+ # Failing check with implicit `redirect`
+ InWhitelistTestCase(
+ kwargs={
+ "categories": self.categories,
+ "channels": self.channels,
+ "roles": self.roles,
+ },
+ ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member),
+ description="Failing check with an implicit redirect channel",
+ ),
+
+ # Failing check without `redirect`
+ InWhitelistTestCase(
+ kwargs={
+ "categories": self.categories,
+ "channels": self.channels,
+ "roles": self.roles,
+ "redirect": None,
+ },
+ ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member),
+ description="Failing check without a redirect channel",
+ ),
+
+ # Command issued in DM channel
+ InWhitelistTestCase(
+ kwargs={
+ "categories": self.categories,
+ "channels": self.channels,
+ "roles": self.roles,
+ "redirect": None,
+ },
+ ctx=helpers.MockContext(channel=self.dm_channel, author=self.dm_channel.me),
+ description="Commands issued in DM channel should be rejected",
+ ),
+ )
+
+ for test_case in test_cases:
+ if "redirect" not in test_case.kwargs or test_case.kwargs["redirect"] is not None:
+ # There are two cases in which we have a redirect channel:
+ # 1. No redirect channel was passed; the default value of `bot_commands` is used
+ # 2. An explicit `redirect` is set that is "not None"
+ redirect_channel = test_case.kwargs.get("redirect", constants.Channels.bot_commands)
+ redirect_message = f" here. Please use the <#{redirect_channel}> channel instead"
+ else:
+ # If an explicit `None` was passed for `redirect`, there is no redirect channel
+ redirect_message = ""
+
+ exception_message = f"You are not allowed to use that command{redirect_message}."
+
+ # patch `commands.check` with a no-op lambda that just returns the predicate passed to it
+ # so we can test the predicate that was generated from the specified kwargs.
+ with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate):
+ predicate = in_whitelist(**test_case.kwargs)
+
+ with self.subTest(test_description=test_case.description):
+ with self.assertRaisesRegex(InWhitelistCheckFailure, exception_message):
+ predicate(test_case.ctx)
diff --git a/tests/helpers.py b/tests/helpers.py
index 8e13f0f28..2b79a6c2a 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -315,7 +315,7 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
"""
spec_set = channel_instance
- def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None:
+ def __init__(self, **kwargs) -> None:
default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()}
super().__init__(**collections.ChainMap(kwargs, default_kwargs))
@@ -323,6 +323,27 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
self.mention = f"#{self.name}"
+# Create data for the DMChannel instance
+state = unittest.mock.MagicMock()
+me = unittest.mock.MagicMock()
+dm_channel_data = {"id": 1, "recipients": [unittest.mock.MagicMock()]}
+dm_channel_instance = discord.DMChannel(me=me, state=state, data=dm_channel_data)
+
+
+class MockDMChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
+ """
+ A MagicMock subclass to mock TextChannel objects.
+
+ Instances of this class will follow the specifications of `discord.TextChannel` instances. For
+ more information, see the `MockGuild` docstring.
+ """
+ spec_set = dm_channel_instance
+
+ def __init__(self, **kwargs) -> None:
+ default_kwargs = {'id': next(self.discord_id), 'recipient': MockUser(), "me": MockUser()}
+ super().__init__(**collections.ChainMap(kwargs, default_kwargs))
+
+
# Create a Message instance to get a realistic MagicMock of `discord.Message`
message_data = {
'id': 1,