diff options
| author | 2020-07-15 11:50:20 +0800 | |
|---|---|---|
| committer | 2020-07-15 11:50:20 +0800 | |
| commit | caf5be2070c66b117edea5a91a2486a535f483c4 (patch) | |
| tree | 7887036a74a9816f9588c265500407157e430c7a | |
| parent | Merge pull request #1044 from python-discord/talentpool-oldest (diff) | |
| parent | Merge branch 'master' into dm_relay (diff) | |
Merge pull request #1041 from python-discord/dm_relay
Relay all DMs sent to the bot to #dm_log
| -rw-r--r-- | bot/__main__.py | 1 | ||||
| -rw-r--r-- | bot/cogs/dm_relay.py | 106 | ||||
| -rw-r--r-- | bot/cogs/duck_pond.py | 34 | ||||
| -rw-r--r-- | bot/cogs/python_news.py | 72 | ||||
| -rw-r--r-- | bot/constants.py | 3 | ||||
| -rw-r--r-- | bot/utils/webhooks.py | 34 | ||||
| -rw-r--r-- | config-default.yml | 4 | ||||
| -rw-r--r-- | tests/bot/cogs/test_duck_pond.py | 51 |
8 files changed, 198 insertions, 107 deletions
diff --git a/bot/__main__.py b/bot/__main__.py index 37e62c2f1..49388455a 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -54,6 +54,7 @@ bot.load_extension("bot.cogs.verification") # Feature cogs bot.load_extension("bot.cogs.alias") bot.load_extension("bot.cogs.defcon") +bot.load_extension("bot.cogs.dm_relay") bot.load_extension("bot.cogs.duck_pond") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.information") diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py new file mode 100644 index 000000000..f62d6105e --- /dev/null +++ b/bot/cogs/dm_relay.py @@ -0,0 +1,106 @@ +import logging + +import discord +from discord import Color +from discord.ext import commands +from discord.ext.commands import Cog + +from bot import constants +from bot.bot import Bot +from bot.utils.checks import in_whitelist_check, with_role_check +from bot.utils.messages import send_attachments +from bot.utils.webhooks import send_webhook + +log = logging.getLogger(__name__) + + +class DMRelay(Cog): + """Relay direct messages to and from the bot.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.webhook_id = constants.Webhooks.dm_log + self.webhook = None + self.bot.loop.create_task(self.fetch_webhook()) + + @commands.command(aliases=("reply",)) + async def send_dm(self, ctx: commands.Context, member: discord.Member, *, message: str) -> None: + """ + Allows you to send a DM to a user from the bot. + + A `member` must be provided. + + This feature should be used extremely sparingly. Use ModMail if you need to have a serious + conversation with a user. This is just for responding to extraordinary DMs, having a little + fun with users, and telling people they are DMing the wrong bot. + + NOTE: This feature will be removed if it is overused. + """ + try: + await member.send(message) + await ctx.message.add_reaction("✅") + return + + except discord.errors.Forbidden: + log.debug("User has disabled DMs.") + await ctx.message.add_reaction("❌") + + async def fetch_webhook(self) -> None: + """Fetches the webhook object, so we can post to it.""" + await self.bot.wait_until_guild_available() + + try: + self.webhook = await self.bot.fetch_webhook(self.webhook_id) + except discord.HTTPException: + log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + + @Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Relays the message's content and attachments to the dm_log channel.""" + # Only relay DMs from humans + if message.author.bot or message.guild or self.webhook is None: + return + + if message.clean_content: + await send_webhook( + webhook=self.webhook, + content=message.clean_content, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + + # Handle any attachments + if message.attachments: + try: + await send_attachments(message, self.webhook) + except (discord.errors.Forbidden, discord.errors.NotFound): + e = discord.Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await send_webhook( + webhook=self.webhook, + embed=e, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + except discord.HTTPException: + log.exception("Failed to send an attachment to the webhook") + + def cog_check(self, ctx: commands.Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + checks = [ + with_role_check(ctx, *constants.MODERATION_ROLES), + in_whitelist_check( + ctx, + channels=[constants.Channels.dm_log], + redirect=None, + fail_silently=True, + ) + ] + return all(checks) + + +def setup(bot: Bot) -> None: + """Load the DMRelay cog.""" + bot.add_cog(DMRelay(bot)) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 5b6a7fd62..7021069fa 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, Union +from typing import Union import discord from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors @@ -7,7 +7,8 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot -from bot.utils.messages import send_attachments, sub_clyde +from bot.utils.messages import send_attachments +from bot.utils.webhooks import send_webhook log = logging.getLogger(__name__) @@ -18,6 +19,7 @@ class DuckPond(Cog): def __init__(self, bot: Bot): self.bot = bot self.webhook_id = constants.Webhooks.duck_pond + self.webhook = None self.bot.loop.create_task(self.fetch_webhook()) async def fetch_webhook(self) -> None: @@ -47,24 +49,6 @@ class DuckPond(Cog): return True return False - async def send_webhook( - self, - content: Optional[str] = None, - username: Optional[str] = None, - avatar_url: Optional[str] = None, - embed: Optional[Embed] = None, - ) -> None: - """Send a webhook to the duck_pond channel.""" - try: - await self.webhook.send( - content=content, - username=sub_clyde(username), - avatar_url=avatar_url, - embed=embed - ) - except discord.HTTPException: - log.exception("Failed to send a message to the Duck Pool webhook") - async def count_ducks(self, message: Message) -> int: """ Count the number of ducks in the reactions of a specific message. @@ -94,10 +78,9 @@ class DuckPond(Cog): async def relay_message(self, message: Message) -> None: """Relays the message's content and attachments to the duck pond channel.""" - clean_content = message.clean_content - - if clean_content: - await self.send_webhook( + if message.clean_content: + await send_webhook( + webhook=self.webhook, content=message.clean_content, username=message.author.display_name, avatar_url=message.author.avatar_url @@ -111,7 +94,8 @@ class DuckPond(Cog): description=":x: **This message contained an attachment, but it could not be retrieved**", color=Color.red() ) - await self.send_webhook( + await send_webhook( + webhook=self.webhook, embed=e, username=message.author.display_name, avatar_url=message.author.avatar_url diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py index adefd5c7c..0ab5738a4 100644 --- a/bot/cogs/python_news.py +++ b/bot/cogs/python_news.py @@ -10,7 +10,7 @@ from discord.ext.tasks import loop from bot import constants from bot.bot import Bot -from bot.utils.messages import sub_clyde +from bot.utils.webhooks import send_webhook PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" @@ -100,13 +100,21 @@ class PythonNews(Cog): ): continue - msg = await self.send_webhook( + # Build an embed and send a webhook + embed = discord.Embed( title=new["title"], description=new["summary"], timestamp=new_datetime, url=new["link"], - webhook_profile_name=data["feed"]["title"], - footer=data["feed"]["title"] + colour=constants.Colours.soft_green + ) + embed.set_footer(text=data["feed"]["title"], icon_url=AVATAR_URL) + msg = await send_webhook( + webhook=self.webhook, + username=data["feed"]["title"], + embed=embed, + avatar_url=AVATAR_URL, + wait=True, ) payload["data"]["pep"].append(pep_nr) @@ -161,15 +169,29 @@ class PythonNews(Cog): content = email_information["content"] link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) - msg = await self.send_webhook( + + # Build an embed and send a message to the webhook + embed = discord.Embed( title=thread_information["subject"], description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, timestamp=new_date, url=link, - author=f"{email_information['sender_name']} ({email_information['sender']['address']})", - author_url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), - webhook_profile_name=self.webhook_names[maillist], - footer=f"Posted to {self.webhook_names[maillist]}" + colour=constants.Colours.soft_green + ) + embed.set_author( + name=f"{email_information['sender_name']} ({email_information['sender']['address']})", + url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), + ) + embed.set_footer( + text=f"Posted to {self.webhook_names[maillist]}", + icon_url=AVATAR_URL, + ) + msg = await send_webhook( + webhook=self.webhook, + username=self.webhook_names[maillist], + embed=embed, + avatar_url=AVATAR_URL, + wait=True, ) payload["data"][maillist].append(thread_information["thread_id"]) @@ -182,38 +204,6 @@ class PythonNews(Cog): await self.bot.api_client.put("bot/bot-settings/news", json=payload) - async def send_webhook(self, - title: str, - description: str, - timestamp: datetime, - url: str, - webhook_profile_name: str, - footer: str, - author: t.Optional[str] = None, - author_url: t.Optional[str] = None, - ) -> discord.Message: - """Send webhook entry and return sent message.""" - embed = discord.Embed( - title=title, - description=description, - timestamp=timestamp, - url=url, - colour=constants.Colours.soft_green - ) - if author and author_url: - embed.set_author( - name=author, - url=author_url - ) - embed.set_footer(text=footer, icon_url=AVATAR_URL) - - return await self.webhook.send( - embed=embed, - username=sub_clyde(webhook_profile_name), - avatar_url=AVATAR_URL, - wait=True - ) - async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]: """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`.""" async with self.bot.http_session.get( diff --git a/bot/constants.py b/bot/constants.py index a1b392c82..778bc093c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -395,6 +395,7 @@ class Channels(metaclass=YAMLGetter): dev_contrib: int dev_core: int dev_log: int + dm_log: int esoteric: int helpers: int how_to_get_help: int @@ -427,6 +428,7 @@ class Webhooks(metaclass=YAMLGetter): reddit: int duck_pond: int dev_log: int + dm_log: int class Roles(metaclass=YAMLGetter): @@ -460,6 +462,7 @@ class Guild(metaclass=YAMLGetter): staff_channels: List[int] staff_roles: List[int] + class Keys(metaclass=YAMLGetter): section = "keys" diff --git a/bot/utils/webhooks.py b/bot/utils/webhooks.py new file mode 100644 index 000000000..66f82ec66 --- /dev/null +++ b/bot/utils/webhooks.py @@ -0,0 +1,34 @@ +import logging +from typing import Optional + +import discord +from discord import Embed + +from bot.utils.messages import sub_clyde + +log = logging.getLogger(__name__) + + +async def send_webhook( + webhook: discord.Webhook, + content: Optional[str] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + embed: Optional[Embed] = None, + wait: Optional[bool] = False +) -> discord.Message: + """ + Send a message using the provided webhook. + + This uses sub_clyde() and tries for an HTTPException to ensure it doesn't crash. + """ + try: + return await webhook.send( + content=content, + username=sub_clyde(username), + avatar_url=avatar_url, + embed=embed, + wait=wait, + ) + except discord.HTTPException: + log.exception("Failed to send a message to the webhook!") diff --git a/config-default.yml b/config-default.yml index 19d79fa76..f2eb17b89 100644 --- a/config-default.yml +++ b/config-default.yml @@ -150,6 +150,7 @@ guild: mod_log: &MOD_LOG 282638479504965634 user_log: 528976905546760203 voice_log: 640292421988646961 + dm_log: 653713721625018428 # Off-topic off_topic_0: 291284109232308226 @@ -251,10 +252,9 @@ guild: duck_pond: 637821475327311927 dev_log: 680501655111729222 python_news: &PYNEWS_WEBHOOK 704381182279942324 - + dm_log: 654567640664244225 filter: - # What do we filter? filter_zalgo: false filter_invites: true diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index a8c0107c6..cfe10aebf 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -129,38 +129,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): ): self.assertEqual(expected_return, actual_return) - def test_send_webhook_correctly_passes_on_arguments(self): - """The `send_webhook` method should pass the arguments to the webhook correctly.""" - self.cog.webhook = helpers.MockAsyncWebhook() - - content = "fake content" - username = "fake username" - avatar_url = "fake avatar_url" - embed = "fake embed" - - asyncio.run(self.cog.send_webhook(content, username, avatar_url, embed)) - - self.cog.webhook.send.assert_called_once_with( - content=content, - username=username, - avatar_url=avatar_url, - embed=embed - ) - - def test_send_webhook_logs_when_sending_message_fails(self): - """The `send_webhook` method should catch a `discord.HTTPException` and log accordingly.""" - self.cog.webhook = helpers.MockAsyncWebhook() - self.cog.webhook.send.side_effect = discord.HTTPException(response=MagicMock(), message="Something failed.") - - log = logging.getLogger('bot.cogs.duck_pond') - with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: - asyncio.run(self.cog.send_webhook()) - - self.assertEqual(len(log_watcher.records), 1) - - record = log_watcher.records[0] - self.assertEqual(record.levelno, logging.ERROR) - def _get_reaction( self, emoji: typing.Union[str, helpers.MockEmoji], @@ -280,16 +248,20 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): async def test_relay_message_correctly_relays_content_and_attachments(self): """The `relay_message` method should correctly relay message content and attachments.""" - send_webhook_path = f"{MODULE_PATH}.DuckPond.send_webhook" + send_webhook_path = f"{MODULE_PATH}.send_webhook" send_attachments_path = f"{MODULE_PATH}.send_attachments" + author = MagicMock( + display_name="x", + avatar_url="https://" + ) self.cog.webhook = helpers.MockAsyncWebhook() test_values = ( - (helpers.MockMessage(clean_content="", attachments=[]), False, False), - (helpers.MockMessage(clean_content="message", attachments=[]), True, False), - (helpers.MockMessage(clean_content="", attachments=["attachment"]), False, True), - (helpers.MockMessage(clean_content="message", attachments=["attachment"]), True, True), + (helpers.MockMessage(author=author, clean_content="", attachments=[]), False, False), + (helpers.MockMessage(author=author, clean_content="message", attachments=[]), True, False), + (helpers.MockMessage(author=author, clean_content="", attachments=["attachment"]), False, True), + (helpers.MockMessage(author=author, clean_content="message", attachments=["attachment"]), True, True), ) for message, expect_webhook_call, expect_attachment_call in test_values: @@ -314,14 +286,14 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): for side_effect in side_effects: # pragma: no cover send_attachments.side_effect = side_effect - with patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock) as send_webhook: + with patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) as send_webhook: with self.subTest(side_effect=type(side_effect).__name__): with self.assertNotLogs(logger=log, level=logging.ERROR): await self.cog.relay_message(message) self.assertEqual(send_webhook.call_count, 2) - @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock) + @patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): """The `relay_message` method should handle irretrievable attachments.""" @@ -337,6 +309,7 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): await self.cog.relay_message(message) send_webhook.assert_called_once_with( + webhook=self.cog.webhook, content=message.clean_content, username=message.author.display_name, avatar_url=message.author.avatar_url |