diff options
| -rw-r--r-- | Pipfile | 2 | ||||
| -rw-r--r-- | Pipfile.lock | 110 | ||||
| -rw-r--r-- | bot/__init__.py | 7 | ||||
| -rw-r--r-- | bot/__main__.py | 1 | ||||
| -rw-r--r-- | bot/bot.py | 14 | ||||
| -rw-r--r-- | bot/cogs/antimalware.py | 12 | ||||
| -rw-r--r-- | bot/cogs/error_handler.py | 6 | ||||
| -rw-r--r-- | bot/cogs/help_channels.py | 201 | ||||
| -rw-r--r-- | bot/cogs/information.py | 8 | ||||
| -rw-r--r-- | bot/cogs/moderation/infractions.py | 20 | ||||
| -rw-r--r-- | bot/cogs/moderation/scheduler.py | 26 | ||||
| -rw-r--r-- | bot/cogs/moderation/superstarify.py | 2 | ||||
| -rw-r--r-- | bot/cogs/moderation/utils.py | 30 | ||||
| -rw-r--r-- | bot/cogs/python_news.py | 234 | ||||
| -rw-r--r-- | bot/cogs/reminders.py | 2 | ||||
| -rw-r--r-- | bot/cogs/snekbox.py | 10 | ||||
| -rw-r--r-- | bot/cogs/utils.py | 50 | ||||
| -rw-r--r-- | bot/cogs/verification.py | 12 | ||||
| -rw-r--r-- | bot/constants.py | 17 | ||||
| -rw-r--r-- | bot/decorators.py | 85 | ||||
| -rw-r--r-- | bot/resources/tags/free.md | 5 | ||||
| -rw-r--r-- | config-default.yml | 35 | ||||
| -rw-r--r-- | tests/bot/cogs/test_cogs.py | 4 | ||||
| -rw-r--r-- | tests/bot/cogs/test_information.py | 6 | ||||
| -rw-r--r-- | tests/bot/test_decorators.py | 147 | ||||
| -rw-r--r-- | tests/helpers.py | 23 |
26 files changed, 800 insertions, 269 deletions
@@ -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/error_handler.py b/bot/cogs/error_handler.py index dae283c6a..b2f4c59f6 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -9,7 +9,7 @@ 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__) @@ -202,7 +202,7 @@ class ErrorHandler(Cog): * BotMissingRole * BotMissingAnyRole * NoPrivateMessage - * InChannelCheckFailure + * InWhitelistCheckFailure """ bot_missing_errors = ( errors.BotMissingPermissions, @@ -215,7 +215,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/help_channels.py b/bot/cogs/help_channels.py index a61f30deb..b714a1642 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. @@ -66,6 +69,8 @@ IN_USE_ANSWERED_EMOJI = "⌛" IN_USE_UNANSWERED_EMOJI = "⏳" NAME_SEPARATOR = "|" +CoroutineFunc = t.Callable[..., t.Coroutine] + class TaskData(t.NamedTuple): """Data for a scheduled task.""" @@ -89,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 @@ -217,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"], enabled=False) + async def close_command(self, ctx: commands.Context) -> None: """ Make the current in-use help channel dormant. @@ -226,14 +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.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) @@ -271,18 +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 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 self.bot.get_guild(constants.Guild.id).channels: - if channel.category_id == category.id and isinstance(channel, discord.TextChannel): + if channel.category_id == category.id and not self.is_excluded_channel(channel): yield channel @staticmethod @@ -400,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() @@ -419,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: @@ -466,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.") @@ -477,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: @@ -499,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, ) @@ -533,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( + 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)}", - category=self.in_use_category, - sync_permissions=True, topic=IN_USE_TOPIC, ) @@ -596,10 +649,11 @@ class HelpChannels(Scheduler, commands.Cog): 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 - log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") # 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 @@ -624,8 +678,8 @@ class HelpChannels(Scheduler, commands.Cog): await self.check_for_answer(message) - if not self.is_in_category(channel, constants.Categories.help_available): - return # Ignore messages outside the Available category. + 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() @@ -641,6 +695,7 @@ class HelpChannels(Scheduler, commands.Cog): ) 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. @@ -658,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: """ @@ -731,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/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/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/reminders.py b/bot/cogs/reminders.py index 24c279357..8b6457cbb 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) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 315383b12..8d4688114 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. diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 15a3e9e8c..0b1436f3a 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -2,21 +2,18 @@ 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 Dict, 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 discord.ext.tasks import loop from bot.bot import Bot -from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES +from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES from bot.decorators import in_channel, with_role from bot.utils.cache import async_cache -from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -73,7 +70,7 @@ class Utils(Cog): self.refresh_peps_urls.start() @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) @@ -117,47 +114,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..388b7a338 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__) @@ -122,7 +122,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 +138,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 +162,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 +187,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/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..2ee5879f2 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -3,7 +3,7 @@ import random from asyncio import Lock, 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: - Hidden channels are channels which will not be displayed in the InChannelCheckFailure error - message. + - `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 + + 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) 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/config-default.yml b/config-default.yml index f2b0bfa9f..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 @@ -503,9 +515,6 @@ free: cooldown_rate: 1 cooldown_per: 60.0 -mention: - message_timeout: 300 - reset_delay: 5 help_channels: enable: true @@ -568,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/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/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, |