diff options
author | 2019-09-16 21:37:49 +0200 | |
---|---|---|
committer | 2019-09-16 21:37:49 +0200 | |
commit | 2b4b0a55f76e1ed63ab6b3f9b6caf76f95c1cccd (patch) | |
tree | 07309446427c5f5ed4e88a3b29f5d188c6869dfc | |
parent | Implement `!otn search`. Closes #408. (diff) | |
parent | Update discord.py version to 1.2.3 (#433) (diff) |
Merge branch 'master' into otn-search-command
47 files changed, 485 insertions, 208 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/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 e980de364..7b97881fd 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -7,10 +7,9 @@ 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, @@ -95,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: @@ -113,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: @@ -131,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 ( @@ -152,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 ] @@ -211,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.""" 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 8f5f9c2e5..cb8a03374 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -4,9 +4,9 @@ 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 @@ -38,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. @@ -70,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) 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/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 c9fc3b954..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 @@ -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_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_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 |