diff options
51 files changed, 573 insertions, 213 deletions
@@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -discord-py = {git = "https://github.com/Rapptz/discord.py.git",extras = ["voice"],ref = "860d6a9ace8248dfeec18b8b159e7b757d9f56bb",editable = true} +discord-py = "~=1.2" aiodns = "*" logmatic-python = "*" aiohttp = "*" diff --git a/Pipfile.lock b/Pipfile.lock index f655943b4..3c98e2b93 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "987e3fc1840e8050f159daa9c23a2c67bd18d17914d4295eb469a42c778daa10" + "sha256": "c1933af105f88f5f2541b1796b92f91d1fcf7a1a947abfe1d8edb016710a56df" }, "pipfile-spec": 6, "requires": { @@ -34,31 +34,31 @@ }, "aiohttp": { "hashes": [ - "sha256:0419705a36b43c0ac6f15469f9c2a08cad5c939d78bd12a5c23ea167c8253b2b", - "sha256:1812fc4bc6ac1bde007daa05d2d0f61199324e0cc893b11523e646595047ca08", - "sha256:2214b5c0153f45256d5d52d1e0cafe53f9905ed035a142191727a5fb620c03dd", - "sha256:275909137f0c92c61ba6bb1af856a522d5546f1de8ea01e4e726321c697754ac", - "sha256:3983611922b561868428ea1e7269e757803713f55b53502423decc509fef1650", - "sha256:51afec6ffa50a9da4cdef188971a802beb1ca8e8edb40fa429e5e529db3475fa", - "sha256:589f2ec8a101a0f340453ee6945bdfea8e1cd84c8d88e5be08716c34c0799d95", - "sha256:789820ddc65e1f5e71516adaca2e9022498fa5a837c79ba9c692a9f8f916c330", - "sha256:7a968a0bdaaf9abacc260911775611c9a602214a23aeb846f2eb2eeaa350c4dc", - "sha256:7aeefbed253f59ea39e70c5848de42ed85cb941165357fc7e87ab5d8f1f9592b", - "sha256:7b2eb55c66512405103485bd7d285a839d53e7fdc261ab20e5bcc51d7aaff5de", - "sha256:87bc95d3d333bb689c8d755b4a9d7095a2356108002149523dfc8e607d5d32a4", - "sha256:9d80e40db208e29168d3723d1440ecbb06054d349c5ece6a2c5a611490830dd7", - "sha256:a1b442195c2a77d33e4dbee67c9877ccbdd3a1f686f91eb479a9577ed8cc326b", - "sha256:ab3d769413b322d6092f169f316f7b21cd261a7589f7e31db779d5731b0480d8", - "sha256:b066d3dec5d0f5aee6e34e5765095dc3d6d78ef9839640141a2b20816a0642bd", - "sha256:b24e7845ae8de3e388ef4bcfcf7f96b05f52c8e633b33cf8003a6b1d726fc7c2", - "sha256:c59a953c3f8524a7c86eaeaef5bf702555be12f5668f6384149fe4bb75c52698", - "sha256:cf2cc6c2c10d242790412bea7ccf73726a9a44b4c4b073d2699ef3b48971fd95", - "sha256:e0c9c8d4150ae904f308ff27b35446990d2b1dfc944702a21925937e937394c6", - "sha256:f1839db4c2b08a9c8f9788112644f8a8557e8e0ecc77b07091afabb941dc55d0", - "sha256:f3df52362be39908f9c028a65490fae0475e4898b43a03d8aa29d1e765b45e07" + "sha256:00d198585474299c9c3b4f1d5de1a576cc230d562abc5e4a0e81d71a20a6ca55", + "sha256:0155af66de8c21b8dba4992aaeeabf55503caefae00067a3b1139f86d0ec50ed", + "sha256:09654a9eca62d1bd6d64aa44db2498f60a5c1e0ac4750953fdd79d5c88955e10", + "sha256:199f1d106e2b44b6dacdf6f9245493c7d716b01d0b7fbe1959318ba4dc64d1f5", + "sha256:296f30dedc9f4b9e7a301e5cc963012264112d78a1d3094cd83ef148fdf33ca1", + "sha256:368ed312550bd663ce84dc4b032a962fcb3c7cae099dbbd48663afc305e3b939", + "sha256:40d7ea570b88db017c51392349cf99b7aefaaddd19d2c78368aeb0bddde9d390", + "sha256:629102a193162e37102c50713e2e31dc9a2fe7ac5e481da83e5bb3c0cee700aa", + "sha256:6d5ec9b8948c3d957e75ea14d41e9330e1ac3fed24ec53766c780f82805140dc", + "sha256:87331d1d6810214085a50749160196391a712a13336cd02ce1c3ea3d05bcf8d5", + "sha256:9a02a04bbe581c8605ac423ba3a74999ec9d8bce7ae37977a3d38680f5780b6d", + "sha256:9c4c83f4fa1938377da32bc2d59379025ceeee8e24b89f72fcbccd8ca22dc9bf", + "sha256:9cddaff94c0135ee627213ac6ca6d05724bfe6e7a356e5e09ec57bd3249510f6", + "sha256:a25237abf327530d9561ef751eef9511ab56fd9431023ca6f4803f1994104d72", + "sha256:a5cbd7157b0e383738b8e29d6e556fde8726823dae0e348952a61742b21aeb12", + "sha256:a97a516e02b726e089cffcde2eea0d3258450389bbac48cbe89e0f0b6e7b0366", + "sha256:acc89b29b5f4e2332d65cd1b7d10c609a75b88ef8925d487a611ca788432dfa4", + "sha256:b05bd85cc99b06740aad3629c2585bda7b83bd86e080b44ba47faf905fdf1300", + "sha256:c2bec436a2b5dafe5eaeb297c03711074d46b6eb236d002c13c42f25c4a8ce9d", + "sha256:cc619d974c8c11fe84527e4b5e1c07238799a8c29ea1c1285149170524ba9303", + "sha256:d4392defd4648badaa42b3e101080ae3313e8f4787cb517efd3f5b8157eaefd6", + "sha256:e1c3c582ee11af7f63a34a46f0448fca58e59889396ffdae1f482085061a2889" ], "index": "pypi", - "version": "==3.4.4" + "version": "==3.5.4" }, "aiormq": { "hashes": [ @@ -167,12 +167,11 @@ "version": "==4.0.7" }, "discord-py": { - "editable": true, - "extras": [ - "voice" + "hashes": [ + "sha256:4684733fa137cc7def18087ae935af615212e423e3dbbe3e84ef01d7ae8ed17d" ], - "git": "https://github.com/Rapptz/discord.py.git", - "ref": "860d6a9ace8248dfeec18b8b159e7b757d9f56bb" + "index": "pypi", + "version": "==1.2.3" }, "docutils": { "hashes": [ @@ -375,7 +374,8 @@ }, "pycparser": { "hashes": [ - "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" + "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3", + "sha256:d51e69d7e2bda15beda04993a07d49598a09de7651375270ca60e234d10b7343" ], "version": "==2.19" }, @@ -386,42 +386,6 @@ ], "version": "==2.4.2" }, - "pynacl": { - "hashes": [ - "sha256:04e30e5bdeeb2d5b34107f28cd2f5bbfdc6c616f3be88fc6f53582ff1669eeca", - "sha256:0bfa0d94d2be6874e40f896e0a67e290749151e7de767c5aefbad1121cad7512", - "sha256:11aa4e141b2456ce5cecc19c130e970793fa3a2c2e6fbb8ad65b28f35aa9e6b6", - "sha256:13bdc1fe084ff9ac7653ae5a924cae03bf4bb07c6667c9eb5b6eb3c570220776", - "sha256:14339dc233e7a9dda80a3800e64e7ff89d0878ba23360eea24f1af1b13772cac", - "sha256:1d33e775fab3f383167afb20b9927aaf4961b953d76eeb271a5703a6d756b65b", - "sha256:2a42b2399d0428619e58dac7734838102d35f6dcdee149e0088823629bf99fbb", - "sha256:2dce05ac8b3c37b9e2f65eab56c544885607394753e9613fd159d5e2045c2d98", - "sha256:63cfccdc6217edcaa48369191ae4dca0c390af3c74f23c619e954973035948cd", - "sha256:6453b0dae593163ffc6db6f9c9c1597d35c650598e2c39c0590d1757207a1ac2", - "sha256:73a5a96fb5fbf2215beee2353a128d382dbca83f5341f0d3c750877a236569ef", - "sha256:8abb4ef79161a5f58848b30ab6fb98d8c466da21fdd65558ce1d7afc02c70b5f", - "sha256:8ac1167195b32a8755de06efd5b2d2fe76fc864517dab66aaf65662cc59e1988", - "sha256:8f505f42f659012794414fa57c498404e64db78f1d98dfd40e318c569f3c783b", - "sha256:9c8a06556918ee8e3ab48c65574f318f5a0a4d31437fc135da7ee9d4f9080415", - "sha256:a1e25fc5650cf64f01c9e435033e53a4aca9de30eb9929d099f3bb078e18f8f2", - "sha256:be71cd5fce04061e1f3d39597f93619c80cdd3558a6c9ba99a546f144a8d8101", - "sha256:c5b1a7a680218dee9da0f1b5e24072c46b3c275d35712bc1d505b85bb03441c0", - "sha256:cb785db1a9468841a1265c9215c60fe5d7af2fb1b209e3316a152704607fc582", - "sha256:cf6877124ae6a0698404e169b3ba534542cfbc43f939d46b927d956daf0a373a", - "sha256:d0eb5b2795b7ee2cbcfcadacbe95a13afbda048a262bd369da9904fecb568975", - "sha256:d3a934e2b9f20abac009d5b6951067cfb5486889cb913192b4d8288b216842f1", - "sha256:d795f506bcc9463efb5ebb0f65ed77921dcc9e0a50499dedd89f208445de9ecb", - "sha256:d8aaf7e5d6b0e0ef7d6dbf7abeb75085713d0100b4eb1a4e4e857de76d77ac45", - "sha256:de2aaca8386cf4d70f1796352f2346f48ddb0bed61dc43a3ce773ba12e064031", - "sha256:e0d38fa0a75f65f556fb912f2c6790d1fa29b7dd27a1d9cc5591b281321eaaa9", - "sha256:eb2acabbd487a46b38540a819ef67e477a674481f84a82a7ba2234b9ba46f752", - "sha256:eeee629828d0eb4f6d98ac41e9a3a6461d114d1d0aa111a8931c049359298da0", - "sha256:f5836463a3c0cca300295b229b6c7003c415a9d11f8f9288ddbd728e2746524c", - "sha256:f5ce9e26d25eb0b2d96f3ef0ad70e1d3ae89b5d60255c462252a3e456a48c053", - "sha256:fabf73d5d0286f9e078774f3435601d2735c94ce9e514ac4fb945701edead7e4" - ], - "version": "==1.2.1" - }, "pyparsing": { "hashes": [ "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", diff --git a/bot/__main__.py b/bot/__main__.py index b1a6a5fcd..f25693734 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -2,10 +2,11 @@ import asyncio import logging import socket +import discord from aiohttp import AsyncResolver, ClientSession, TCPConnector -from discord import Game from discord.ext.commands import Bot, when_mentioned_or +from bot import patches from bot.api import APIClient, APILoggingHandler from bot.constants import Bot as BotConfig, DEBUG_MODE @@ -14,9 +15,9 @@ log = logging.getLogger('bot') bot = Bot( command_prefix=when_mentioned_or(BotConfig.prefix), - activity=Game(name="Commands: !help"), + activity=discord.Game(name="Commands: !help"), case_insensitive=True, - max_messages=10_000 + max_messages=10_000, ) # Global aiohttp session for all cogs @@ -71,6 +72,11 @@ bot.load_extension("bot.cogs.utils") bot.load_extension("bot.cogs.watchchannels") bot.load_extension("bot.cogs.wolfram") +# Apply `message_edited_at` patch if discord.py did not yet release a bug fix. +if not hasattr(discord.message.Message, '_handle_edited_timestamp'): + patches.message_edited_at.apply_patch() + bot.run(BotConfig.token) -bot.http_session.close() # Close the aiohttp session when the bot finishes running +# This calls a coroutine, so it doesn't do anything at the moment. +# bot.http_session.close() # Close the aiohttp session when the bot finishes running diff --git a/bot/api.py b/bot/api.py index 9a0ebaa26..3acde242e 100644 --- a/bot/api.py +++ b/bot/api.py @@ -124,7 +124,7 @@ class APILoggingHandler(logging.StreamHandler): # 1. Do not log anything below `DEBUG`. This is only applicable # for the monkeypatched `TRACE` logging level, which has a # lower numeric value than `DEBUG`. - record.levelno > logging.DEBUG + record.levelno >= logging.DEBUG # 2. Ignore logging messages which are sent by this logging # handler itself. This is required because if we were to # not ignore messages emitted by this handler, we would diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index a44c47331..3d0c9d826 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -4,7 +4,7 @@ from typing import Union from discord import Colour, Embed, Member, User from discord.ext.commands import ( - Command, Context, clean_content, command, group + Cog, Command, Context, clean_content, command, group ) from bot.cogs.watchchannels.watchchannel import proxy_user @@ -14,7 +14,7 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) -class Alias: +class Alias(Cog): """ Aliases for more used commands """ diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 69367b40b..7b97881fd 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -1,15 +1,15 @@ import asyncio import logging +from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime, timedelta from operator import itemgetter from typing import Dict, Iterable, List, Set from discord import Colour, Member, Message, NotFound, Object, TextChannel -from discord.ext.commands import Bot +from discord.ext.commands import Bot, Cog from bot import rules -from bot.cogs.moderation import Moderation from bot.cogs.modlog import ModLog from bot.constants import ( AntiSpam as AntiSpamConfig, Channels, @@ -94,7 +94,7 @@ class DeletionContext: ) -class AntiSpam: +class AntiSpam(Cog): """Cog that controls our anti-spam measures.""" def __init__(self, bot: Bot, validation_errors: bool) -> None: @@ -112,6 +112,7 @@ class AntiSpam: """Allows for easy access of the ModLog cog.""" return self.bot.get_cog("ModLog") + @Cog.listener() async def on_ready(self): """Unloads the cog and alerts admins if configuration validation failed.""" if self.validation_errors: @@ -130,6 +131,7 @@ class AntiSpam: self.bot.remove_cog(self.__class__.__name__) return + @Cog.listener() async def on_message(self, message: Message) -> None: """Applies the antispam rules to each received message.""" if ( @@ -151,7 +153,7 @@ class AntiSpam: # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls. earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval) relevant_messages = [ - msg async for msg in message.channel.history(after=earliest_relevant_at, reverse=False) + msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False) if not msg.author.bot ] @@ -210,7 +212,12 @@ class AntiSpam: # Since we're going to invoke the tempmute command directly, we need to manually call the converter. dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") - await context.invoke(Moderation.tempmute, member, dt_remove_role_after, reason=reason) + await context.invoke( + self.bot.get_command('tempmute'), + member, + dt_remove_role_after, + reason=reason + ) async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None: """Cleans the messages if cleaning is configured.""" @@ -245,16 +252,17 @@ class AntiSpam: await deletion_context.upload_messages(self.bot.user.id, self.mod_log) -def validate_config() -> bool: +def validate_config(rules: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: """Validates the antispam configs.""" validation_errors = {} - for name, config in AntiSpamConfig.rules.items(): + for name, config in rules.items(): if name not in RULE_FUNCTION_MAPPING: log.error( f"Unrecognized antispam rule `{name}`. " f"Valid rules are: {', '.join(RULE_FUNCTION_MAPPING)}" ) validation_errors[name] = f"`{name}` is not recognized as an antispam rule." + continue for required_key in ('interval', 'max'): if required_key not in config: log.error( diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 4a0f208f4..e88b1d9b5 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -4,7 +4,7 @@ import re import time from discord import Embed, Message, RawMessageUpdateEvent -from discord.ext.commands import Bot, Context, command, group +from discord.ext.commands import Bot, Cog, Context, command, group from bot.constants import ( Channels, Guild, MODERATION_ROLES, @@ -16,7 +16,7 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) -class Bot: +class Bot(Cog): """ Bot information commands """ @@ -48,14 +48,14 @@ class Bot: @group(invoke_without_command=True, name="bot", hidden=True) @with_role(Roles.verified) - async def bot_group(self, ctx: Context): + async def botinfo_group(self, ctx: Context): """ Bot informational commands """ await ctx.invoke(self.bot.get_command("help"), "bot") - @bot_group.command(name='about', aliases=('info',), hidden=True) + @botinfo_group.command(name='about', aliases=('info',), hidden=True) @with_role(Roles.verified) async def about_command(self, ctx: Context): """ @@ -236,6 +236,7 @@ class Bot: return msg.content[:3] in not_backticks + @Cog.listener() async def on_message(self, msg: Message): """ Detect poorly formatted Python code and send the user @@ -357,6 +358,7 @@ class Bot: f"The message that was posted was:\n\n{msg.content}\n\n" ) + @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent): if ( # Checks to see if the message was called out by the bot @@ -370,14 +372,14 @@ class Bot: # Retrieve channel and message objects for use later channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - user_message = await channel.get_message(payload.message_id) + user_message = await channel.fetch_message(payload.message_id) # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) # If the message is fixed, delete the bot message and the entry from the id dictionary if has_fixed_codeblock is None: - bot_message = await channel.get_message(self.codeblock_message_ids[payload.message_id]) + bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) await bot_message.delete() del self.codeblock_message_ids[payload.message_id] log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 1f3e1caa9..20c24dafc 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -4,7 +4,7 @@ import re from typing import Optional from discord import Colour, Embed, Message, User -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group from bot.cogs.modlog import ModLog from bot.constants import ( @@ -16,7 +16,7 @@ from bot.decorators import with_role log = logging.getLogger(__name__) -class Clean: +class Clean(Cog): """ A cog that allows messages to be deleted in bulk, while applying various filters. diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py index ebdbf5ad8..ec497b966 100644 --- a/bot/cogs/cogs.py +++ b/bot/cogs/cogs.py @@ -2,7 +2,7 @@ import logging import os from discord import Colour, Embed -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group from bot.constants import ( Emojis, MODERATION_ROLES, Roles, URLs @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) KEEP_LOADED = ["bot.cogs.cogs", "bot.cogs.modlog"] -class Cogs: +class Cogs(Cog): """ Cog management commands """ @@ -60,7 +60,7 @@ class Cogs: embed.set_author( name="Python Bot (Cogs)", - url=URLs.gitlab_bot_repo, + url=URLs.github_bot_repo, icon_url=URLs.bot_avatar ) @@ -113,7 +113,7 @@ class Cogs: embed.set_author( name="Python Bot (Cogs)", - url=URLs.gitlab_bot_repo, + url=URLs.github_bot_repo, icon_url=URLs.bot_avatar ) @@ -168,7 +168,7 @@ class Cogs: embed.set_author( name="Python Bot (Cogs)", - url=URLs.gitlab_bot_repo, + url=URLs.github_bot_repo, icon_url=URLs.bot_avatar ) @@ -269,7 +269,7 @@ class Cogs: embed.colour = Colour.blurple() embed.set_author( name="Python Bot (Cogs)", - url=URLs.gitlab_bot_repo, + url=URLs.github_bot_repo, icon_url=URLs.bot_avatar ) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index c67fa2807..8fab00712 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -2,10 +2,10 @@ import logging from datetime import datetime, timedelta from discord import Colour, Embed, Member -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group from bot.cogs.modlog import ModLog -from bot.constants import Channels, Colours, Emojis, Event, Icons, Keys, Roles +from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles from bot.decorators import with_role log = logging.getLogger(__name__) @@ -24,21 +24,23 @@ will be resolved soon. In the meantime, please feel free to peruse the resources BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" -class Defcon: +class Defcon(Cog): """Time-sensitive server defense mechanisms""" days = None # type: timedelta enabled = False # type: bool def __init__(self, bot: Bot): self.bot = bot + self.channel = None self.days = timedelta(days=0) - self.headers = {"X-API-KEY": Keys.site_api} @property def mod_log(self) -> ModLog: return self.bot.get_cog("ModLog") + @Cog.listener() async def on_ready(self): + self.channel = await self.bot.fetch_channel(Channels.defcon) try: response = await self.bot.api_client.get('bot/bot-settings/defcon') data = response['data'] @@ -62,6 +64,7 @@ class Defcon: await self.update_channel_topic() + @Cog.listener() async def on_member_join(self, member: Member): if self.enabled and self.days.days > 0: now = datetime.utcnow() @@ -278,8 +281,7 @@ class Defcon: new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Disabled)" self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) - defcon_channel = self.bot.guilds[0].get_channel(Channels.defcon) - await defcon_channel.edit(topic=new_topic) + await self.channel.edit(topic=new_topic) def setup(bot: Bot): diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index aa49b0c25..ebf2c1d65 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -120,12 +120,13 @@ class InventoryURL(commands.Converter): return url -class Doc: +class Doc(commands.Cog): def __init__(self, bot): self.base_urls = {} self.bot = bot self.inventories = {} + @commands.Cog.listener() async def on_ready(self): await self.refresh_inventory() diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index cfcba6f26..e2d8c3a8f 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -14,7 +14,7 @@ from discord.ext.commands import ( NoPrivateMessage, UserInputError, ) -from discord.ext.commands import Bot, Context +from discord.ext.commands import Bot, Cog, Context from bot.api import ResponseCodeError from bot.constants import Channels @@ -23,12 +23,13 @@ from bot.decorators import InChannelCheckFailure log = logging.getLogger(__name__) -class ErrorHandler: +class ErrorHandler(Cog): """Handles errors emitted from commands.""" def __init__(self, bot: Bot): self.bot = bot + @Cog.listener() async def on_command_error(self, ctx: Context, e: CommandError): command = ctx.command parent = None diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 8e97a35a2..c52c04df1 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -8,7 +8,7 @@ import traceback from io import StringIO import discord -from discord.ext.commands import Bot, group +from discord.ext.commands import Bot, Cog, group from bot.constants import Roles from bot.decorators import with_role @@ -17,7 +17,7 @@ from bot.interpreter import Interpreter log = logging.getLogger(__name__) -class CodeEval: +class CodeEval(Cog): """ Owner and admin feature that evaluates code and returns the result to the channel. diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 418297fc4..dc4de7ff1 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -5,7 +5,7 @@ from typing import Optional, Union import discord.errors from dateutil.relativedelta import relativedelta from discord import Colour, DMChannel, Member, Message, TextChannel -from discord.ext.commands import Bot +from discord.ext.commands import Bot, Cog from bot.cogs.modlog import ModLog from bot.constants import ( @@ -29,7 +29,7 @@ URL_RE = r"(https?://[^\s]+)" ZALGO_RE = r"[\u0300-\u036F\u0489]" -class Filtering: +class Filtering(Cog): """ Filtering out invites, blacklisting domains, and warning us of certain regular expressions @@ -59,7 +59,7 @@ class Filtering: "user_notification": Filter.notify_user_invites, "notification_msg": ( f"Per Rule 10, your invite link has been removed. {_staff_mistake_str}\n\n" - r"Our server rules can be found here: <https://pythondiscord.com/about/rules>" + r"Our server rules can be found here: <https://pythondiscord.com/pages/rules>" ) }, "filter_domains": { @@ -96,14 +96,16 @@ class Filtering: def mod_log(self) -> ModLog: return self.bot.get_cog("ModLog") + @Cog.listener() async def on_message(self, msg: Message): await self._filter_message(msg) + @Cog.listener() async def on_message_edit(self, before: Message, after: Message): if not before.edited_at: delta = relativedelta(after.edited_at, before.created_at).microseconds else: - delta = None + delta = relativedelta(after.edited_at, before.edited_at).microseconds await self._filter_message(after, delta) async def _filter_message(self, msg: Message, delta: Optional[int] = None): @@ -142,7 +144,7 @@ class Filtering: # If the edit delta is less than 0.001 seconds, then we're probably dealing # with a double filter trigger. if delta is not None and delta < 100: - return + continue # Does the filter only need the message content or the full message? if _filter["content_only"]: diff --git a/bot/cogs/free.py b/bot/cogs/free.py index fd6009bb8..92a9ca041 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -2,7 +2,7 @@ import logging from datetime import datetime from discord import Colour, Embed, Member, utils -from discord.ext.commands import Context, command +from discord.ext.commands import Cog, Context, command from bot.constants import Categories, Channels, Free, STAFF_ROLES from bot.decorators import redirect_output @@ -15,7 +15,7 @@ RATE = Free.cooldown_rate PER = Free.cooldown_per -class Free: +class Free(Cog): """Tries to figure out which help channels are free.""" PYTHON_HELP_ID = Categories.python_help diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 20ed08f07..31e729003 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -6,7 +6,7 @@ from contextlib import suppress from discord import Colour, Embed, HTTPException from discord.ext import commands -from discord.ext.commands import CheckFailure +from discord.ext.commands import CheckFailure, Cog as DiscordCog from fuzzywuzzy import fuzz, process from bot import constants @@ -107,7 +107,7 @@ class HelpSession: self.query = ctx.bot self.description = self.query.description self.author = ctx.author - self.destination = ctx.author if ctx.bot.pm_help else ctx.channel + self.destination = ctx.channel # set the config for the session self._cleanup = cleanup @@ -649,7 +649,7 @@ class HelpSession: await self.message.delete() -class Help: +class Help(DiscordCog): """ Custom Embed Pagination Help feature """ diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 320750a24..c4aff73b8 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -2,11 +2,9 @@ import logging import textwrap from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel -from discord.ext.commands import Bot, Context, command +from discord.ext.commands import Bot, Cog, Context, command -from bot.constants import ( - Channels, Emojis, Keys, MODERATION_ROLES, STAFF_ROLES -) +from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES from bot.decorators import InChannelCheckFailure, with_role from bot.utils.checks import with_role_check from bot.utils.time import time_since @@ -14,7 +12,7 @@ from bot.utils.time import time_since log = logging.getLogger(__name__) -class Information: +class Information(Cog): """ A cog with commands for generating embeds with server information, such as server statistics @@ -23,7 +21,6 @@ class Information: def __init__(self, bot: Bot): self.bot = bot - self.headers = {"X-API-Key": Keys.site_api} @with_role(*MODERATION_ROLES) @command(name="roles") diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index bca1fb607..dd14111ce 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -10,7 +10,7 @@ from bot.decorators import with_role log = logging.getLogger(__name__) -class CodeJams: +class CodeJams(commands.Cog): """ Manages the code-jam related parts of our server """ diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index 6b8462f3b..64bbed46e 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -1,7 +1,7 @@ import logging from discord import Embed -from discord.ext.commands import Bot +from discord.ext.commands import Bot, Cog from bot.constants import Channels, DEBUG_MODE @@ -9,7 +9,7 @@ from bot.constants import Channels, DEBUG_MODE log = logging.getLogger(__name__) -class Logging: +class Logging(Cog): """ Debug logging module """ @@ -17,14 +17,18 @@ class Logging: def __init__(self, bot: Bot): self.bot = bot + @Cog.listener() async def on_ready(self): log.info("Bot connected!") embed = Embed(description="Connected!") embed.set_author( name="Python Bot", - url="https://gitlab.com/discord-python/projects/bot", - icon_url="https://gitlab.com/python-discord/branding/raw/master/logos/logo_circle/logo_circle.png" + url="https://github.com/python-discord/bot", + icon_url=( + "https://raw.githubusercontent.com/" + "python-discord/branding/master/logos/logo_circle/logo_circle_large.png" + ) ) if not DEBUG_MODE: diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index fb791c933..c631dd69d 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -8,7 +8,7 @@ from discord import ( Colour, Embed, Forbidden, Guild, HTTPException, Member, NotFound, Object, User ) from discord.ext.commands import ( - BadArgument, BadUnionArgument, Bot, Context, command, group + BadArgument, BadUnionArgument, Bot, Cog, Context, command, group ) from bot import constants @@ -28,7 +28,7 @@ INFRACTION_ICONS = { "Kick": Icons.sign_out, "Ban": Icons.user_ban } -RULES_URL = "https://pythondiscord.com/about/rules" +RULES_URL = "https://pythondiscord.com/pages/rules" APPEALABLE_INFRACTIONS = ("Ban", "Mute") @@ -46,7 +46,7 @@ def proxy_user(user_id: str) -> Object: UserTypes = Union[Member, User, proxy_user] -class Moderation(Scheduler): +class Moderation(Scheduler, Cog): """ Server moderation tools. """ @@ -60,6 +60,7 @@ class Moderation(Scheduler): def mod_log(self) -> ModLog: return self.bot.get_cog("ModLog") + @Cog.listener() async def on_ready(self): # Schedule expiration for previous infractions infractions = await self.bot.api_client.get( @@ -1348,7 +1349,7 @@ class Moderation(Scheduler): """ # sometimes `user` is a `discord.Object`, so let's make it a proper user. - user = await self.bot.get_user_info(user.id) + user = await self.bot.fetch_user(user.id) try: await user.send(embed=embed) @@ -1374,13 +1375,15 @@ class Moderation(Scheduler): # endregion - async def __error(self, ctx: Context, error) -> None: + @staticmethod + async def cog_command_error(ctx: Context, error) -> None: if isinstance(error, BadUnionArgument): if User in error.converters: await ctx.send(str(error.errors[0])) error.handled = True - async def respect_role_hierarchy(self, ctx: Context, target: UserTypes, infr_type: str) -> bool: + @staticmethod + async def respect_role_hierarchy(ctx: Context, target: UserTypes, infr_type: str) -> bool: """ Check if the highest role of the invoking member is greater than that of the target member. If this check fails, a warning is sent to the invoking ctx. diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 808ba667b..978646f46 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -11,7 +11,7 @@ from discord import ( RawMessageUpdateEvent, Role, TextChannel, User, VoiceChannel ) from discord.abc import GuildChannel -from discord.ext.commands import Bot +from discord.ext.commands import Bot, Cog from bot.constants import ( Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs @@ -24,11 +24,11 @@ GUILD_CHANNEL = Union[CategoryChannel, TextChannel, VoiceChannel] CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") -MEMBER_CHANGES_SUPPRESSED = ("activity", "status") +MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status") ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") -class ModLog: +class ModLog(Cog, name="ModLog"): """ Logging for server events and staff actions """ @@ -122,6 +122,7 @@ class ModLog: return await self.bot.get_context(log_message) # Optionally return for use with antispam + @Cog.listener() async def on_guild_channel_create(self, channel: GUILD_CHANNEL): if channel.guild.id != GuildConstant.id: return @@ -146,6 +147,7 @@ class ModLog: await self.send_log_message(Icons.hash_green, Colour(Colours.soft_green), title, message) + @Cog.listener() async def on_guild_channel_delete(self, channel: GUILD_CHANNEL): if channel.guild.id != GuildConstant.id: return @@ -167,6 +169,7 @@ class ModLog: title, message ) + @Cog.listener() async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel): if before.guild.id != GuildConstant.id: return @@ -225,6 +228,7 @@ class ModLog: "Channel updated", message ) + @Cog.listener() async def on_guild_role_create(self, role: Role): if role.guild.id != GuildConstant.id: return @@ -234,6 +238,7 @@ class ModLog: "Role created", f"`{role.id}`" ) + @Cog.listener() async def on_guild_role_delete(self, role: Role): if role.guild.id != GuildConstant.id: return @@ -243,6 +248,7 @@ class ModLog: "Role removed", f"{role.name} (`{role.id}`)" ) + @Cog.listener() async def on_guild_role_update(self, before: Role, after: Role): if before.guild.id != GuildConstant.id: return @@ -294,6 +300,7 @@ class ModLog: "Role updated", message ) + @Cog.listener() async def on_guild_update(self, before: Guild, after: Guild): if before.id != GuildConstant.id: return @@ -343,6 +350,7 @@ class ModLog: thumbnail=after.icon_url_as(format="png") ) + @Cog.listener() async def on_member_ban(self, guild: Guild, member: Union[Member, User]): if guild.id != GuildConstant.id: return @@ -358,6 +366,7 @@ class ModLog: channel_id=Channels.modlog ) + @Cog.listener() async def on_member_join(self, member: Member): if member.guild.id != GuildConstant.id: return @@ -378,6 +387,7 @@ class ModLog: channel_id=Channels.userlog ) + @Cog.listener() async def on_member_remove(self, member: Member): if member.guild.id != GuildConstant.id: return @@ -393,6 +403,7 @@ class ModLog: channel_id=Channels.userlog ) + @Cog.listener() async def on_member_unban(self, guild: Guild, member: User): if guild.id != GuildConstant.id: return @@ -408,6 +419,7 @@ class ModLog: channel_id=Channels.modlog ) + @Cog.listener() async def on_member_update(self, before: Member, after: Member): if before.guild.id != GuildConstant.id: return @@ -497,6 +509,7 @@ class ModLog: channel_id=Channels.userlog ) + @Cog.listener() async def on_message_delete(self, message: Message): channel = message.channel author = message.author @@ -551,6 +564,7 @@ class ModLog: channel_id=Channels.message_log ) + @Cog.listener() async def on_raw_message_delete(self, event: RawMessageDeleteEvent): if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.ignored: return @@ -590,6 +604,7 @@ class ModLog: channel_id=Channels.message_log ) + @Cog.listener() async def on_message_edit(self, before: Message, after: Message): if ( not before.guild @@ -663,10 +678,11 @@ class ModLog: channel_id=Channels.message_log, timestamp_override=after.edited_at ) + @Cog.listener() async def on_raw_message_edit(self, event: RawMessageUpdateEvent): try: channel = self.bot.get_channel(int(event.data["channel_id"])) - message = await channel.get_message(event.message_id) + message = await channel.fetch_message(event.message_id) except NotFound: # Was deleted before we got the event return diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 5a61425be..cb8a03374 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -1,11 +1,12 @@ import asyncio +import difflib import logging from datetime import datetime, timedelta from discord import Colour, Embed -from discord.ext.commands import BadArgument, Bot, Context, Converter, group +from discord.ext.commands import BadArgument, Bot, Cog, Context, Converter, group -from bot.constants import Channels, Keys, MODERATION_ROLES +from bot.constants import Channels, MODERATION_ROLES from bot.decorators import with_role from bot.pagination import LinePaginator @@ -37,7 +38,7 @@ class OffTopicName(Converter): return argument.translate(table) -async def update_names(bot: Bot, headers: dict): +async def update_names(bot: Bot): """ The background updater task that performs a channel name update daily. @@ -69,21 +70,21 @@ async def update_names(bot: Bot, headers: dict): ) -class OffTopicNames: +class OffTopicNames(Cog): """Commands related to managing the off-topic category channel names.""" def __init__(self, bot: Bot): self.bot = bot - self.headers = {"X-API-KEY": Keys.site_api} self.updater_task = None - def __cleanup(self): + def cog_unload(self): if self.updater_task is not None: self.updater_task.cancel() + @Cog.listener() async def on_ready(self): if self.updater_task is None: - coro = update_names(self.bot, self.headers) + coro = update_names(self.bot) self.updater_task = self.bot.loop.create_task(coro) @group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True) @@ -141,6 +142,27 @@ class OffTopicNames: embed.description = "Hmmm, seems like there's nothing here yet." await ctx.send(embed=embed) + @otname_group.command(name='search', aliases=('s',)) + @with_role(*MODERATION_ROLES) + async def search_command(self, ctx, *, query: str): + """ + Search for an off-topic name. + """ + + result = await self.bot.api_client.get('bot/off-topic-channel-names') + matches = difflib.get_close_matches(query, result, n=10, cutoff=0.35) + lines = sorted(f"• {name}" for name in matches) + embed = Embed( + title=f"Query results", + colour=Colour.blue() + ) + + if matches: + await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) + else: + embed.description = "Nothing found." + await ctx.send(embed=embed) + def setup(bot: Bot): bot.add_cog(OffTopicNames(bot)) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index b5bd26e3d..4c561b7e8 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -5,7 +5,7 @@ import textwrap from datetime import datetime, timedelta from discord import Colour, Embed, TextChannel -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES from bot.converters import Subreddit @@ -15,7 +15,7 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) -class Reddit: +class Reddit(Cog): """ Track subreddit posts and show detailed statistics about them. """ @@ -279,8 +279,9 @@ class Reddit: max_lines=15 ) + @Cog.listener() async def on_ready(self): - self.reddit_channel = self.bot.get_channel(Channels.reddit) + self.reddit_channel = await self.bot.fetch_channel(Channels.reddit) if self.reddit_channel is not None: if self.new_posts_task is None: diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 03ea00de8..c6ae984ea 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -7,7 +7,7 @@ from operator import itemgetter from dateutil.relativedelta import relativedelta from discord import Colour, Embed -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group from bot.constants import Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import ExpirationDate @@ -22,12 +22,13 @@ WHITELISTED_CHANNELS = (Channels.bot,) MAXIMUM_REMINDERS = 5 -class Reminders(Scheduler): +class Reminders(Scheduler, Cog): def __init__(self, bot: Bot): self.bot = bot super().__init__() + @Cog.listener() async def on_ready(self): # Get all the current reminders for re-scheduling response = await self.bot.api_client.get( diff --git a/bot/cogs/security.py b/bot/cogs/security.py index 9523766af..e02e91530 100644 --- a/bot/cogs/security.py +++ b/bot/cogs/security.py @@ -1,11 +1,11 @@ import logging -from discord.ext.commands import Bot, Context, NoPrivateMessage +from discord.ext.commands import Bot, Cog, Context, NoPrivateMessage log = logging.getLogger(__name__) -class Security: +class Security(Cog): """ Security-related helpers """ diff --git a/bot/cogs/site.py b/bot/cogs/site.py index b5e63fb41..4d5b2e811 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -1,7 +1,7 @@ import logging from discord import Colour, Embed -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group from bot.constants import Channels, STAFF_ROLES, URLs from bot.decorators import redirect_output @@ -12,7 +12,7 @@ log = logging.getLogger(__name__) PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages" -class Site: +class Site(Cog): """Commands for linking to different parts of the site.""" def __init__(self, bot: Bot): @@ -46,15 +46,18 @@ class Site: async def site_resources(self, ctx: Context): """Info about the site's Resources page.""" - url = f"{PAGES_URL}/resources" + learning_url = f"{PAGES_URL}/resources" + tools_url = f"{PAGES_URL}/tools" - embed = Embed(title="Resources") - embed.set_footer(text=url) + embed = Embed(title="Resources & Tools") + embed.set_footer(text=f"{learning_url} | {tools_url}") embed.colour = Colour.blurple() embed.description = ( - f"The [Resources page]({url}) on our website contains a " + f"The [Resources page]({learning_url}) on our website contains a " "list of hand-selected goodies that we regularly recommend " - "to both beginners and experts." + f"to both beginners and experts. The [Tools page]({tools_url}) " + "contains a couple of the most popular tools for programming in " + "Python." ) await ctx.send(embed=embed) @@ -111,7 +114,7 @@ class Site: # Rules were not submitted. Return the default description. rules_embed.description = ( "The rules and guidelines that apply to this community can be found on" - " our [rules page](https://pythondiscord.com/about/rules). We expect" + f" our [rules page]({PAGES_URL}/rules). We expect" " all members of the community to have read and understood these." ) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index c8705ac6f..d36c0795d 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -5,7 +5,7 @@ import textwrap from signal import Signals from typing import Optional, Tuple -from discord.ext.commands import Bot, Context, command, guild_only +from discord.ext.commands import Bot, Cog, Context, command, guild_only from bot.constants import Channels, STAFF_ROLES, URLs from bot.decorators import in_channel @@ -36,7 +36,7 @@ RAW_CODE_REGEX = re.compile( MAX_PASTE_LEN = 1000 -class Snekbox: +class Snekbox(Cog): """ Safe evaluation of Python code using Snekbox """ diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py index cccd91304..e9743a2f5 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/superstarify/__init__.py @@ -4,7 +4,7 @@ from datetime import datetime from discord import Colour, Embed, Member from discord.errors import Forbidden -from discord.ext.commands import Bot, Context, command +from discord.ext.commands import Bot, Cog, Context, command from bot.cogs.moderation import Moderation from bot.cogs.modlog import ModLog @@ -15,10 +15,10 @@ from bot.decorators import with_role from bot.utils.moderation import post_infraction log = logging.getLogger(__name__) -NICKNAME_POLICY_URL = "https://pythondiscord.com/about/rules#nickname-policy" +NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#wiki-toc-nickname-policy" -class Superstarify: +class Superstarify(Cog): """ A set of commands to moderate terrible nicknames. """ @@ -34,6 +34,7 @@ class Superstarify: def modlog(self) -> ModLog: return self.bot.get_cog("ModLog") + @Cog.listener() async def on_member_update(self, before: Member, after: Member): """ This event will trigger when someone changes their name. @@ -91,6 +92,7 @@ class Superstarify: "to DM them, and a discord.errors.Forbidden error was incurred." ) + @Cog.listener() async def on_member_join(self, member: Member): """ This event will trigger when someone (re)joins the server. diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index ec6c5f447..9a3a48bba 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -3,7 +3,7 @@ from typing import Callable, Iterable from discord import Guild, Member, Role from discord.ext import commands -from discord.ext.commands import Bot, Context +from discord.ext.commands import Bot, Cog, Context from bot import constants from bot.api import ResponseCodeError @@ -12,7 +12,7 @@ from bot.cogs.sync import syncers log = logging.getLogger(__name__) -class Sync: +class Sync(Cog): """Captures relevant events and sends them to the site.""" # The server to synchronize events on. @@ -29,6 +29,7 @@ class Sync: def __init__(self, bot: Bot) -> None: self.bot = bot + @Cog.listener() async def on_ready(self) -> None: """Syncs the roles/users of the guild with the database.""" guild = self.bot.get_guild(self.SYNC_SERVER_ID) @@ -47,6 +48,7 @@ class Sync: f"deleted `{total_deleted}`." ) + @Cog.listener() async def on_guild_role_create(self, role: Role) -> None: """Adds newly create role to the database table over the API.""" await self.bot.api_client.post( @@ -60,10 +62,12 @@ class Sync: } ) + @Cog.listener() async def on_guild_role_delete(self, role: Role) -> None: """Deletes role from the database when it's deleted from the guild.""" await self.bot.api_client.delete(f'bot/roles/{role.id}') + @Cog.listener() async def on_guild_role_update(self, before: Role, after: Role) -> None: """Syncs role with the database if any of the stored attributes were updated.""" if ( @@ -83,6 +87,7 @@ class Sync: } ) + @Cog.listener() async def on_member_join(self, member: Member) -> None: """ Adds a new user or updates existing user to the database when a member joins the guild. @@ -118,6 +123,7 @@ class Sync: # If we got `404`, the user is new. Create them. await self.bot.api_client.post('bot/users', json=packed) + @Cog.listener() async def on_member_remove(self, member: Member) -> None: """Updates the user information when a member leaves the guild.""" await self.bot.api_client.put( @@ -132,6 +138,7 @@ class Sync: } ) + @Cog.listener() async def on_member_update(self, before: Member, after: Member) -> None: """Updates the user information if any of relevant attributes have changed.""" if ( diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 7b1003148..8e9ba5da3 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -2,9 +2,9 @@ import logging import time from discord import Colour, Embed -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group -from bot.constants import Channels, Cooldowns, Keys, MODERATION_ROLES, Roles +from bot.constants import Channels, Cooldowns, MODERATION_ROLES, Roles from bot.converters import TagContentConverter, TagNameConverter from bot.decorators import with_role from bot.pagination import LinePaginator @@ -19,7 +19,7 @@ TEST_CHANNELS = ( ) -class Tags: +class Tags(Cog): """ Save new tags and fetch existing tags. """ @@ -27,7 +27,6 @@ class Tags: def __init__(self, bot: Bot): self.bot = bot self.tag_cooldowns = {} - self.headers = {"Authorization": f"Token {Keys.site_api}"} @group(name='tags', aliases=('tag', 't'), hidden=True, invoke_without_command=True) async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None): @@ -82,7 +81,7 @@ class Tags: "time": time.time(), "channel": ctx.channel.id } - await ctx.send(embed=Embed.from_data(tag['embed'])) + await ctx.send(embed=Embed.from_dict(tag['embed'])) else: tags = await self.bot.api_client.get('bot/tags') diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index b2c4cd522..64bf126d6 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -6,7 +6,7 @@ import struct from datetime import datetime from discord import Colour, Message -from discord.ext.commands import Bot +from discord.ext.commands import Bot, Cog from discord.utils import snowflake_time from bot.cogs.modlog import ModLog @@ -34,7 +34,7 @@ TOKEN_RE = re.compile( ) -class TokenRemover: +class TokenRemover(Cog): """Scans messages for potential discord.py bot tokens and removes them.""" def __init__(self, bot: Bot): @@ -44,6 +44,7 @@ class TokenRemover: def mod_log(self) -> ModLog: return self.bot.get_cog("ModLog") + @Cog.listener() async def on_message(self, msg: Message): if msg.author.bot: return diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 98208723a..08e77a24e 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -5,7 +5,7 @@ from email.parser import HeaderParser from io import StringIO from discord import Colour, Embed -from discord.ext.commands import AutoShardedBot, Context, command +from discord.ext.commands import AutoShardedBot, Cog, Context, command from bot.constants import Channels, STAFF_ROLES from bot.decorators import in_channel @@ -13,7 +13,7 @@ from bot.decorators import in_channel log = logging.getLogger(__name__) -class Utils: +class Utils(Cog): """ A selection of utilities which don't have a clear category. """ diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 6b42c9213..c42d4d67e 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,7 +1,7 @@ import logging from discord import Message, NotFound, Object -from discord.ext.commands import Bot, Context, command +from discord.ext.commands import Bot, Cog, Context, command from bot.cogs.modlog import ModLog from bot.constants import Channels, Event, Roles @@ -14,8 +14,8 @@ Hello! Welcome to the server, and thanks for verifying yourself! For your records, these are the documents you accepted: -`1)` Our rules, here: <https://pythondiscord.com/about/rules> -`2)` Our privacy policy, here: <https://pythondiscord.com/about/privacy> - you can find information on how to have \ +`1)` Our rules, here: <https://pythondiscord.com/pages/rules> +`2)` Our privacy policy, here: <https://pythondiscord.com/pages/privacy> - you can find information on how to have \ your information removed here as well. Feel free to review them at any point! @@ -28,7 +28,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! """ -class Verification: +class Verification(Cog): """ User verification and role self-management """ @@ -40,6 +40,7 @@ class Verification: def mod_log(self) -> ModLog: return self.bot.get_cog("ModLog") + @Cog.listener() async def on_message(self, message: Message): if message.author.bot: return # They're a bot, ignore @@ -152,13 +153,13 @@ class Verification: ) @staticmethod - async def __error(ctx: Context, error): + async def cog_command_error(ctx: Context, error): if isinstance(error, InChannelCheckFailure): # Do nothing; just ignore this error error.handled = True @staticmethod - def __global_check(ctx: Context): + def bot_check(ctx: Context): """ Block any command within the verification channel that is not !accept. """ diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index e7b3d70bc..338b6c4ad 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -3,7 +3,7 @@ from collections import ChainMap from typing import Union from discord import User -from discord.ext.commands import Context, group +from discord.ext.commands import Cog, Context, group from bot.constants import Channels, Roles, Webhooks from bot.decorators import with_role @@ -13,7 +13,7 @@ from .watchchannel import WatchChannel, proxy_user log = logging.getLogger(__name__) -class BigBrother(WatchChannel): +class BigBrother(WatchChannel, Cog, name="Big Brother"): """Monitors users by relaying their messages to a watch channel to assist with moderation.""" def __init__(self, bot) -> None: diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 47d207d05..4452d7a59 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -4,7 +4,7 @@ from collections import ChainMap from typing import Union from discord import Color, Embed, Member, User -from discord.ext.commands import Context, group +from discord.ext.commands import Cog, Context, group from bot.api import ResponseCodeError from bot.constants import Channels, Guild, Roles, Webhooks @@ -16,7 +16,7 @@ log = logging.getLogger(__name__) STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers # <- In constants after the merge? -class TalentPool(WatchChannel): +class TalentPool(WatchChannel, Cog, name="Talentpool"): """Relays messages of helper candidates to a watch channel to observe them.""" def __init__(self, bot) -> None: diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 3a24e3f21..c34b0d5bb 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -3,20 +3,20 @@ import datetime import logging import re import textwrap -from abc import ABC, abstractmethod +from abc import abstractmethod from collections import defaultdict, deque from dataclasses import dataclass from typing import Optional import discord -from discord import Color, Embed, Message, Object, errors -from discord.ext.commands import BadArgument, Bot, Context +from discord import Color, Embed, HTTPException, Message, Object, errors +from discord.ext.commands import BadArgument, Bot, Cog, Context from bot.api import ResponseCodeError from bot.cogs.modlog import ModLog from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons from bot.pagination import LinePaginator -from bot.utils import messages +from bot.utils import CogABCMeta, messages from bot.utils.time import time_since log = logging.getLogger(__name__) @@ -47,7 +47,7 @@ class MessageHistory: message_count: int = 0 -class WatchChannel(ABC): +class WatchChannel(metaclass=CogABCMeta): """ABC with functionality for relaying users' messages to a certain channel.""" @abstractmethod @@ -98,21 +98,14 @@ class WatchChannel(ABC): """Starts the watch channel by getting the channel, webhook, and user cache ready.""" await self.bot.wait_until_ready() - # After updating d.py, this block can be replaced by `fetch_channel` with a try-except - for attempt in range(1, self.retries+1): - self.channel = self.bot.get_channel(self.destination) - if self.channel is None: - if attempt < self.retries: - await asyncio.sleep(self.retry_delay) - else: - break - else: - self.log.error(f"Failed to retrieve the text channel with id {self.destination}") + try: + self.channel = await self.bot.fetch_channel(self.destination) + except HTTPException: + self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`") - # `get_webhook_info` has been renamed to `fetch_webhook` in newer versions of d.py try: - self.webhook = await self.bot.get_webhook_info(self.webhook_id) - except (discord.HTTPException, discord.NotFound, discord.Forbidden): + self.webhook = await self.bot.fetch_webhook(self.webhook_id) + except discord.HTTPException: self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") if self.channel is None or self.webhook is None: @@ -169,6 +162,7 @@ class WatchChannel(ABC): return True + @Cog.listener() async def on_message(self, msg: Message) -> None: """Queues up messages sent by watched users.""" if msg.author.id in self.watched_users: diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py index 7dd613083..e88efa033 100644 --- a/bot/cogs/wolfram.py +++ b/bot/cogs/wolfram.py @@ -7,7 +7,7 @@ import discord from dateutil.relativedelta import relativedelta from discord import Embed from discord.ext import commands -from discord.ext.commands import BucketType, Context, check, group +from discord.ext.commands import BucketType, Cog, Context, check, group from bot.constants import Colours, STAFF_ROLES, Wolfram from bot.pagination import ImagePaginator @@ -163,7 +163,7 @@ async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]: return pages -class Wolfram: +class Wolfram(Cog): """ Commands for interacting with the Wolfram|Alpha API. """ diff --git a/bot/constants.py b/bot/constants.py index 4e14a85a8..d5b73bd1d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -412,7 +412,7 @@ class URLs(metaclass=YAMLGetter): # Misc endpoints bot_avatar: str deploy: str - gitlab_bot_repo: str + github_bot_repo: str status: str # Site endpoints diff --git a/bot/converters.py b/bot/converters.py index 30ea7ca0f..4bd9aba13 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -71,7 +71,7 @@ class InfractionSearchQuery(Converter): async def convert(ctx, arg): try: maybe_snowflake = arg.strip("<@!>") - return await ctx.bot.get_user_info(maybe_snowflake) + return await ctx.bot.fetch_user(maybe_snowflake) except (discord.NotFound, discord.HTTPException): return arg diff --git a/bot/patches/__init__.py b/bot/patches/__init__.py new file mode 100644 index 000000000..fd38ea8cf --- /dev/null +++ b/bot/patches/__init__.py @@ -0,0 +1,6 @@ +"""Subpackage that contains patches for discord.py""" +from . import message_edited_at + +__all__ = [ + message_edited_at, +] diff --git a/bot/patches/message_edited_at.py b/bot/patches/message_edited_at.py new file mode 100644 index 000000000..528373a9b --- /dev/null +++ b/bot/patches/message_edited_at.py @@ -0,0 +1,32 @@ +""" +# message_edited_at patch + +Date: 2019-09-16 +Author: Scragly +Added by: Ves Zappa + +Due to a bug in our current version of discord.py (1.2.3), the edited_at timestamp of +`discord.Messages` are not being handled correctly. This patch fixes that until a new +release of discord.py is released (and we've updated to it). +""" +import logging + +from discord import message, utils + +log = logging.getLogger(__name__) + + +def _handle_edited_timestamp(self, value): + """Helper function that takes care of parsing the edited timestamp.""" + self._edited_timestamp = utils.parse_time(value) + + +def apply_patch(): + """Applies the `edited_at` patch to the `discord.message.Message` class.""" + message.Message._handle_edited_timestamp = _handle_edited_timestamp + message.Message._HANDLERS['edited_timestamp'] = message.Message._handle_edited_timestamp + log.info("Patch applied: message_edited_at") + + +if __name__ == "__main__": + apply_patch() diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 4c99d50e8..d5ae0a7c5 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,3 +1,12 @@ +from abc import ABCMeta + +from discord.ext.commands import CogMeta + + +class CogABCMeta(CogMeta, ABCMeta): + """Metaclass for ABCs meant to be implemented as Cogs.""" + pass + class CaseInsensitiveDict(dict): """ diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index ded6401b0..f03865013 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -1,13 +1,15 @@ import asyncio import contextlib import logging -from abc import ABC, abstractmethod +from abc import abstractmethod from typing import Dict +from bot.utils import CogABCMeta + log = logging.getLogger(__name__) -class Scheduler(ABC): +class Scheduler(metaclass=CogABCMeta): def __init__(self): diff --git a/config-default.yml b/config-default.yml index bae4b16c0..fd83e69a4 100644 --- a/config-default.yml +++ b/config-default.yml @@ -95,7 +95,7 @@ guild: bot: 267659945086812160 checkpoint_test: 422077681434099723 defcon: 464469101889454091 - devlog: &DEVLOG 409308876241108992 + devlog: &DEVLOG 622895325144940554 devtest: &DEVTEST 414574275865870337 help_0: 303906576991780866 help_1: 303906556754395136 @@ -234,7 +234,7 @@ keys: urls: # PyDis site vars - site: &DOMAIN "django.pythondiscord.com" + site: &DOMAIN "pythondiscord.com" site_api: &API !JOIN ["api.", *DOMAIN] site_paste: &PASTE !JOIN ["paste.", *DOMAIN] site_staff: &STAFF !JOIN ["staff.", *DOMAIN] @@ -273,7 +273,7 @@ urls: # Misc URLs bot_avatar: "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png" - gitlab_bot_repo: "https://gitlab.com/python-discord/projects/bot" + github_bot_repo: "https://github.com/python-discord/bot" anti_spam: # Clean messages that violate a rule. diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py new file mode 100644 index 000000000..67900b275 --- /dev/null +++ b/tests/cogs/test_antispam.py @@ -0,0 +1,30 @@ +import pytest + +from bot.cogs import antispam + + +def test_default_antispam_config_is_valid(): + validation_errors = antispam.validate_config() + assert not validation_errors + + + ('config', 'expected'), + ( + ( + {'invalid-rule': {}}, + {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} + ), + ( + {'burst': {'interval': 10}}, + {'burst': "Key `max` is required but not set for rule `burst`"} + ), + ( + {'burst': {'max': 10}}, + {'burst': "Key `interval` is required but not set for rule `burst`"} + ) + ) +) +def test_invalid_antispam_config_returns_validation_errors(config, expected): + validation_errors = antispam.validate_config(config) + assert validation_errors == expected diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py new file mode 100644 index 000000000..1efb460fe --- /dev/null +++ b/tests/cogs/test_security.py @@ -0,0 +1,54 @@ +import logging +from unittest.mock import MagicMock + +import pytest +from discord.ext.commands import NoPrivateMessage + +from bot.cogs import security + + +def cog(): + bot = MagicMock() + return security.Security(bot) + + +def context(): + return MagicMock() + + +def test_check_additions(cog): + cog.bot.check.assert_any_call(cog.check_on_guild) + cog.bot.check.assert_any_call(cog.check_not_bot) + + +def test_check_not_bot_for_humans(cog, context): + context.author.bot = False + assert cog.check_not_bot(context) + + +def test_check_not_bot_for_robots(cog, context): + context.author.bot = True + assert not cog.check_not_bot(context) + + +def test_check_on_guild_outside_of_guild(cog, context): + context.guild = None + + with pytest.raises(NoPrivateMessage, match="This command cannot be used in private messages."): + cog.check_on_guild(context) + + +def test_check_on_guild_on_guild(cog, context): + context.guild = "lemon's lemonade stand" + assert cog.check_on_guild(context) + + +def test_security_cog_load(caplog): + bot = MagicMock() + security.setup(bot) + bot.add_cog.assert_called_once() + [record] = caplog.records + assert record.message == "Cog loaded: Security" + assert record.levelno == logging.INFO diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py new file mode 100644 index 000000000..9d46b3a05 --- /dev/null +++ b/tests/cogs/test_token_remover.py @@ -0,0 +1,133 @@ +import asyncio +from unittest.mock import MagicMock + +import pytest +from discord import Colour + +from bot.cogs.token_remover import ( + DELETION_MESSAGE_TEMPLATE, + TokenRemover, + setup as setup_cog, +) +from bot.constants import Channels, Colours, Event, Icons +from tests.helpers import AsyncMock + + +def token_remover(): + bot = MagicMock() + bot.get_cog.return_value = MagicMock() + bot.get_cog.return_value.send_log_message = AsyncMock() + return TokenRemover(bot=bot) + + +def message(): + message = MagicMock() + message.author.__str__.return_value = 'lemon' + message.author.bot = False + message.author.avatar_url_as.return_value = 'picture-lemon.png' + message.author.id = 42 + message.author.mention = '@lemon' + message.channel.send = AsyncMock() + message.channel.mention = '#lemonade-stand' + message.content = '' + message.delete = AsyncMock() + message.id = 555 + return message + + + ('content', 'expected'), + ( + ('MTIz', True), # 123 + ('YWJj', False), # abc + ) +) +def test_is_valid_user_id(content: str, expected: bool): + assert TokenRemover.is_valid_user_id(content) is expected + + + ('content', 'expected'), + ( + ('DN9r_A', True), # stolen from dapi, thanks to the author of the 'token' tag! + ('MTIz', False), # 123 + ) +) +def test_is_valid_timestamp(content: str, expected: bool): + assert TokenRemover.is_valid_timestamp(content) is expected + + +def test_mod_log_property(token_remover): + token_remover.bot.get_cog.return_value = 'lemon' + assert token_remover.mod_log == 'lemon' + token_remover.bot.get_cog.assert_called_once_with('ModLog') + + +def test_ignores_bot_messages(token_remover, message): + message.author.bot = True + coroutine = token_remover.on_message(message) + assert asyncio.run(coroutine) is None + + [email protected]('content', ('', 'lemon wins')) +def test_ignores_messages_without_tokens(token_remover, message, content): + message.content = content + coroutine = token_remover.on_message(message) + assert asyncio.run(coroutine) is None + + [email protected]('content', ('foo.bar.baz', 'x.y.')) +def test_ignores_invalid_tokens(token_remover, message, content): + message.content = content + coroutine = token_remover.on_message(message) + assert asyncio.run(coroutine) is None + + + 'content, censored_token', + ( + ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), + ) +) +def test_censors_valid_tokens( + token_remover, message, content, censored_token, caplog +): + message.content = content + coroutine = token_remover.on_message(message) + assert asyncio.run(coroutine) is None # still no rval + + # asyncio logs some stuff about its reactor, discard it + [_, record] = caplog.records + assert record.message == ( + "Censored a seemingly valid token sent by lemon (`42`) in #lemonade-stand, " + f"token was `{censored_token}`" + ) + + message.delete.assert_called_once_with() + message.channel.send.assert_called_once_with( + DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') + ) + token_remover.bot.get_cog.assert_called_with('ModLog') + message.author.avatar_url_as.assert_called_once_with(static_format='png') + + mod_log = token_remover.bot.get_cog.return_value + mod_log.ignore.assert_called_once_with(Event.message_delete, message.id) + mod_log.send_log_message.assert_called_once_with( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Token removed!", + text=record.message, + thumbnail='picture-lemon.png', + channel_id=Channels.mod_alerts + ) + + +def test_setup(caplog): + bot = MagicMock() + setup_cog(bot) + [record] = caplog.records + + bot.add_cog.assert_called_once() + assert record.message == "Cog loaded: TokenRemover" diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 000000000..57c6fcc1a --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,10 @@ +from unittest.mock import MagicMock + + +__all__ = ('AsyncMock',) + + +# TODO: Remove me on 3.8 +class AsyncMock(MagicMock): + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 000000000..11d6541ae --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,29 @@ +from unittest import TestCase + +import pytest + +from bot import pagination + + +class LinePaginatorTests(TestCase): + def setUp(self): + self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) + + def test_add_line_raises_on_too_long_lines(self): + message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" + with pytest.raises(RuntimeError, match=message): + self.paginator.add_line('x' * self.paginator.max_size) + + def test_add_line_works_on_small_lines(self): + self.paginator.add_line('x' * (self.paginator.max_size - 3)) + + +class ImagePaginatorTests(TestCase): + def setUp(self): + self.paginator = pagination.ImagePaginator() + + def test_add_image_appends_image(self): + image = 'lemon' + self.paginator.add_image(image) + + assert self.paginator.images == [image] diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 000000000..2b17aea64 --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,18 @@ +import json +import mimetypes +from pathlib import Path +from urllib.parse import urlparse + + +def test_stars_valid(): + """Validates that `bot/resources/stars.json` contains valid images.""" + + path = Path('bot', 'resources', 'stars.json') + content = path.read_text() + data = json.loads(content) + + for url in data.values(): + assert urlparse(url).scheme == 'https' + + mimetype, _ = mimetypes.guess_type(url) + assert mimetype in ('image/jpeg', 'image/png') @@ -1,6 +1,6 @@ [flake8] max-line-length=120 -application_import_names=bot +application_import_names=bot,tests exclude=.cache,.venv ignore=B311,W503,E226,S311,T000 import-order-style=pycharm |