diff options
| -rw-r--r-- | .gitlab-ci.yml | 8 | ||||
| -rw-r--r-- | Pipfile | 1 | ||||
| -rw-r--r-- | Pipfile.lock | 59 | ||||
| -rw-r--r-- | bot/__main__.py | 19 | ||||
| -rw-r--r-- | bot/cogs/antispam.py | 161 | ||||
| -rw-r--r-- | bot/cogs/clean.py | 309 | ||||
| -rw-r--r-- | bot/cogs/defcon.py | 16 | ||||
| -rw-r--r-- | bot/cogs/events.py | 59 | ||||
| -rw-r--r-- | bot/cogs/filtering.py | 232 | ||||
| -rw-r--r-- | bot/cogs/information.py | 126 | ||||
| -rw-r--r-- | bot/cogs/moderation.py | 2 | ||||
| -rw-r--r-- | bot/cogs/modlog.py | 31 | ||||
| -rw-r--r-- | bot/cogs/off_topic_names.py | 26 | ||||
| -rw-r--r-- | bot/cogs/token_remover.py | 6 | ||||
| -rw-r--r-- | bot/cogs/verification.py | 6 | ||||
| -rw-r--r-- | bot/constants.py | 68 | ||||
| -rw-r--r-- | bot/rules/__init__.py | 12 | ||||
| -rw-r--r-- | bot/rules/attachments.py | 30 | ||||
| -rw-r--r-- | bot/rules/burst.py | 27 | ||||
| -rw-r--r-- | bot/rules/burst_shared.py | 22 | ||||
| -rw-r--r-- | bot/rules/chars.py | 28 | ||||
| -rw-r--r-- | bot/rules/discord_emojis.py | 35 | ||||
| -rw-r--r-- | bot/rules/duplicates.py | 31 | ||||
| -rw-r--r-- | bot/rules/links.py | 31 | ||||
| -rw-r--r-- | bot/rules/mentions.py | 28 | ||||
| -rw-r--r-- | bot/rules/newlines.py | 28 | ||||
| -rw-r--r-- | bot/rules/role_mentions.py | 28 | ||||
| -rw-r--r-- | config-default.yml | 221 |
28 files changed, 1542 insertions, 108 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 88ab5d927..3edfb2bf8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,13 @@ image: pythondiscord/bot-ci:latest +variables: + PIPENV_CACHE_DIR: "$CI_PROJECT_DIR/pipenv-cache" + +cache: + paths: + - "$CI_PROJECT_DIR/pipenv-cache" + - "$CI_PROJECT_DIR/.venv" + stages: - test - build @@ -18,7 +18,6 @@ lxml = "*" pyyaml = "*" yarl = "==1.1.1" fuzzywuzzy = "*" -python-levenshtein = "*" pillow = "*" aio-pika = "*" python-dateutil = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 864eb574a..8b43235bb 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c7d1bad1549c322484f6751447115ded9299df039cb6321bcc1a1fa1359481dc" + "sha256": "9c22a342245c638b196b519a8afb8a2c66410d76283746cfdd89f19ff7dce94c" }, "pipfile-spec": 6, "requires": { @@ -74,11 +74,13 @@ }, "beautifulsoup4": { "hashes": [ - "sha256:11a9a27b7d3bddc6d86f59fb76afb70e921a25ac2d6cc55b40d072bd68435a76", - "sha256:7015e76bf32f1f574636c4288399a6de66ce08fb7b2457f628a8d70c0fbabb11", - "sha256:808b6ac932dccb0a4126558f7dfdcf41710dd44a4ef497a0bb59a77f9f078e89" + "sha256:2545357585a6cc7d050d3c43a86eba2c0b91b9e7ac8a3965e64a6ead6a1a9a3d", + "sha256:272081ad78c5495ba67083a0e50920163701fa6fe67fbb5eefeb21b5dd88c40b", + "sha256:4ddc90ad88bccc005a71d8ef32f7b1cd8f935475cd561c4122b2f87de45d28ab", + "sha256:5a3d659840960a4107047b6328d6d4cdaaee69939bf11adc07466a1856c99a80", + "sha256:bd43a3b26d2886acd63070c43da821b60dea603eb6d45bab0294aac6129adbfa" ], - "version": "==4.6.0" + "version": "==4.6.1" }, "certifi": { "hashes": [ @@ -247,18 +249,30 @@ "sha256:026449b64e559226cdb8e6d8c931b5965d8fc90ec18ebbb0baa04c5b36503c72", "sha256:03dbb224ee196ef30ed2156d41b579143e1efeb422974719a5392fc035e4f574", "sha256:03eb0e04f929c102ae24bc436bf1c0c60a4e63b07ebd388e84d8b219df3e6acd", + "sha256:087b0551ce2d19b3f092f2b5f071a065f7379e748867d070b29999cc83db15e3", + "sha256:091a0656688d85fd6e10f49a73fa3ab9b37dbfcb2151f5a3ab17f8b879f467ee", + "sha256:0f3e2d0a9966161b7dfd06d147f901d72c3a88ea1a833359b92193b8e1f68e1c", + "sha256:114398d0e073b93e1d7da5b5ab92ff4b83c0180625c8031911425e51f4365d2e", "sha256:1be66b9a89e367e7d20d6cae419794997921fe105090fafd86ef39e20a3baab2", + "sha256:1c5e93c40d4ce8cb133d3b105a869be6fa767e703f6eb1003eb4b90583e08a59", "sha256:1e977a3ed998a599bda5021fb2c2889060617627d3ae228297a529a082a3cd5c", "sha256:22cf3406d135cfcc13ec6228ade774c8461e125c940e80455f500638429be273", "sha256:24adccf1e834f82718c7fc8e3ec1093738da95144b8b1e44c99d5fc7d3e9c554", "sha256:2a3e362c97a5e6a259ee9cd66553292a1f8928a5bdfa3622fdb1501570834612", + "sha256:3518f9fc666cbc58a5c1f48a6a23e9e6ceef69665eab43cdad5144de9383e72c", + "sha256:3709339f4619e8c9b00f53079e40b964f43c5af61fb89a923fe24437167298bb", "sha256:3832e26ecbc9d8a500821e3a1d3765bda99d04ae29ffbb2efba49f5f788dc934", + "sha256:452d159024faf37cc080537df308e8fa0026076eb38eb75185d96ed9642bd6d7", "sha256:4fd1f0c2dc02aaec729d91c92cd85a2df0289d88e9f68d1e8faba750bb9c4786", "sha256:4fda62030f2c515b6e2e673c57caa55cb04026a81968f3128aae10fc28e5cc27", "sha256:5044d75a68b49ce36a813c82d8201384207112d5d81643937fc758c05302f05b", "sha256:522184556921512ec484cb93bd84e0bab915d0ac5a372d49571c241a7f73db62", "sha256:5914cff11f3e920626da48e564be6818831713a3087586302444b9c70e8552d9", + "sha256:653d48fe46378f40e3c2b892be88d8440efbb2c9df78559da44c63ad5ecb4142", "sha256:6661a7908d68c4a133e03dac8178287aa20a99f841ea90beeb98a233ae3fd710", + "sha256:6735a7e560df6f0deb78246a6fe056cf2ae392ba2dc060ea8a6f2535aec924f1", + "sha256:6d26a475a19cb294225738f5c974b3a24599438a67a30ed2d25638f012668026", + "sha256:791f07fe13937e65285f9ef30664ddf0e10a0230bdb236751fa0ca67725740dd", "sha256:79258a8df3e309a54c7ef2ef4a59bb8e28f7e4a8992a3ad17c24b1889ced44f3", "sha256:7d74c20b8f1c3e99d3f781d3b8ff5abfefdd7363d61e23bdeba9992ff32cc4b4", "sha256:81918afeafc16ba5d9d0d4e9445905f21aac969a4ebb6f2bff4b9886da100f4b", @@ -266,12 +280,20 @@ "sha256:84d5d31200b11b3c76fab853b89ac898bf2d05c8b3da07c1fcc23feb06359d6e", "sha256:989981db57abffb52026b114c9a1f114c7142860a6d30a352d28f8cbf186500b", "sha256:a3d7511d3fad1618a82299aab71a5fceee5c015653a77ffea75ced9ef917e71a", + "sha256:a4a6ac01b8c2f9d2d83719f193e6dea493e18445ce5bfd743d739174daa974d9", + "sha256:acb90eb6c7ed6526551a78211d84c81e33082a35642ff5fe57489abc14e6bf6e", "sha256:b3ef168d4d6fd4fa6685aef7c91400f59f7ab1c0da734541f7031699741fb23f", "sha256:c1c5792b6e74bbf2af0f8e892272c2a6c48efa895903211f11b8342e03129fea", "sha256:c5dcb5a56aebb8a8f2585042b2f5c496d7624f0bcfe248f0cc33ceb2fd8d39e7", + "sha256:d16f90810106822833a19bdb24c7cb766959acf791ca0edf5edfec674d55c8ee", + "sha256:dcdc9cd9880027688007ff8f7c8e7ae6f24e81fae33bfd18d1e691e7bda4855f", + "sha256:e2807aad4565d8de15391a9548f97818a14ef32624015c7bf3095171e314445e", "sha256:e2bed4a04e2ca1050bb5f00865cf2f83c0b92fd62454d9244f690fcd842e27a4", "sha256:e87a527c06319428007e8c30511e1f0ce035cb7f14bb4793b003ed532c3b9333", + "sha256:ebcfc33a6c34984086451e230253bc33727bd17b4cdc4b39ec03032c3a6fc9e9", "sha256:f63e420180cbe22ff6e32558b612e75f50616fc111c5e095a4631946c782e109", + "sha256:f7717eb360d40e7598c30cc44b33d98f79c468d9279379b66c1e28c568e0bf47", + "sha256:f8582e1ab155302ea9ef1235441a0214919f4f79c4c7c21833ce9eec58181781", "sha256:f8b3d413c5a8f84b12cd4c5df1d8e211777c9852c6be3ee9c094b626644d3eab" ], "index": "pypi", @@ -313,6 +335,11 @@ "pyparsing": { "hashes": [ "sha256:0832bcf47acd283788593e7a0f542407bd9550a55a8a8435214a1960e04bcb04", + "sha256:281683241b25fe9b80ec9d66017485f6deff1af5cde372469134b56ca8447a07", + "sha256:8f1e18d3fd36c6795bb7e02a39fd05c611ffc2596c1e0d995d34d67630426c18", + "sha256:9e8143a3e15c13713506886badd96ca4b579a87fbdf49e550dbfc057d6cb218e", + "sha256:b8b3117ed9bdf45e14dcc89345ce638ec7e0e29b2b579fa1ecf32ce45ebac8a5", + "sha256:e4d45427c6e20a59bf4f88c639dcc03ce30d193112047f94012102f235853a58", "sha256:fee43f17a9c4087e7ed1605bd6df994c6173c1e977d7ade7b651292fab2bd010" ], "version": "==2.2.0" @@ -330,16 +357,8 @@ "sha256:a292e22c5e03105a05a746ade6209d43db1c4c763b91c75c8486e81d10904d85", "sha256:e3636824d35ba6a15fc39f573588cba63cf46322a5dc86fb2f280229077e9fbe" ], - "markers": "python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.2.*'", "version": "==0.1.9" }, - "python-levenshtein": { - "hashes": [ - "sha256:033a11de5e3d19ea25c9302d11224e1a1898fe5abd23c61c7c360c25195e3eb1" - ], - "index": "pypi", - "version": "==0.12.0" - }, "pytz": { "hashes": [ "sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053", @@ -404,7 +423,6 @@ "sha256:68ca7ff70785cbe1e7bccc71a48b5b6d965d79ca50629606c7861a21b206d9dd", "sha256:9de47f375baf1ea07cdb3436ff39d7a9c76042c10a769c52353ec46e4e8fc3b9" ], - "markers": "python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.2.*'", "version": "==1.1.0" }, "sympy": { @@ -419,7 +437,6 @@ "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" ], - "markers": "python_version != '3.0.*' and python_version < '4' and python_version != '3.3.*' and python_version != '3.2.*' and python_version >= '2.6' and python_version != '3.1.*'", "version": "==1.23" }, "websockets": { @@ -592,6 +609,11 @@ "pyparsing": { "hashes": [ "sha256:0832bcf47acd283788593e7a0f542407bd9550a55a8a8435214a1960e04bcb04", + "sha256:281683241b25fe9b80ec9d66017485f6deff1af5cde372469134b56ca8447a07", + "sha256:8f1e18d3fd36c6795bb7e02a39fd05c611ffc2596c1e0d995d34d67630426c18", + "sha256:9e8143a3e15c13713506886badd96ca4b579a87fbdf49e550dbfc057d6cb218e", + "sha256:b8b3117ed9bdf45e14dcc89345ce638ec7e0e29b2b579fa1ecf32ce45ebac8a5", + "sha256:e4d45427c6e20a59bf4f88c639dcc03ce30d193112047f94012102f235853a58", "sha256:fee43f17a9c4087e7ed1605bd6df994c6173c1e977d7ade7b651292fab2bd010" ], "version": "==2.2.0" @@ -622,11 +644,11 @@ }, "safety": { "hashes": [ - "sha256:32d41b8bbd736db749aa2162de6c0bb11c2113c7bc0357476491f96cd5d58299", - "sha256:34227360409ffb1bc2657e5b6ff3472a32d72b917617cd3d2914ddf078c263b9" + "sha256:2689fe629bafe9450796d36578aa112820ff65038578aee004f60b9db1ba4ae8", + "sha256:cd04e57ff8cf8984ff2cb11973e1d5469dae681e25d4edfccb1ef08cc107b2c0" ], "index": "pypi", - "version": "==1.8.2" + "version": "==1.8.3" }, "six": { "hashes": [ @@ -640,7 +662,6 @@ "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" ], - "markers": "python_version != '3.0.*' and python_version < '4' and python_version != '3.3.*' and python_version != '3.2.*' and python_version >= '2.6' and python_version != '3.1.*'", "version": "==1.23" } } diff --git a/bot/__main__.py b/bot/__main__.py index b9e6001ac..c5c8b8909 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -5,7 +5,7 @@ from aiohttp import AsyncResolver, ClientSession, TCPConnector from discord import Game from discord.ext.commands import Bot, when_mentioned_or -from bot.constants import Bot as BotConfig # , ClickUp +from bot.constants import Bot as BotConfig, DEBUG_MODE from bot.utils.service_discovery import wait_for_rmq @@ -38,28 +38,30 @@ else: # Internal/debug bot.load_extension("bot.cogs.logging") -bot.load_extension("bot.cogs.modlog") bot.load_extension("bot.cogs.security") bot.load_extension("bot.cogs.events") +bot.load_extension("bot.cogs.filtering") # Commands, etc +bot.load_extension("bot.cogs.antispam") bot.load_extension("bot.cogs.bigbrother") bot.load_extension("bot.cogs.bot") +bot.load_extension("bot.cogs.clean") bot.load_extension("bot.cogs.cogs") -# Local setups usually don't have the clickup key set, -# and loading the cog would simply spam errors in the console. -# if ClickUp.key is not None: -# bot.load_extension("bot.cogs.clickup") -# else: -# log.info("`CLICKUP_KEY` not set in the environment, not loading the ClickUp cog.") +# Only load this in production +if not DEBUG_MODE: + bot.load_extension("bot.cogs.modlog") + bot.load_extension("bot.cogs.verification") +# Feature cogs bot.load_extension("bot.cogs.deployment") bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.doc") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.fun") bot.load_extension("bot.cogs.hiphopify") +bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.moderation") bot.load_extension("bot.cogs.off_topic_names") bot.load_extension("bot.cogs.snakes") @@ -67,7 +69,6 @@ bot.load_extension("bot.cogs.snekbox") bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") -bot.load_extension("bot.cogs.verification") if has_rmq: bot.load_extension("bot.cogs.rmq") diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py new file mode 100644 index 000000000..04b030889 --- /dev/null +++ b/bot/cogs/antispam.py @@ -0,0 +1,161 @@ +import asyncio +import logging +import textwrap +from datetime import datetime, timedelta +from typing import Dict, List + +from dateutil.relativedelta import relativedelta +from discord import Member, Message, Object, TextChannel +from discord.ext.commands import Bot + +from bot import rules +from bot.constants import AntiSpam as AntiSpamConfig, Channels, Colours, Guild as GuildConfig, Icons +from bot.utils.time import humanize as humanize_delta + + +log = logging.getLogger(__name__) + +RULE_FUNCTION_MAPPING = { + 'attachments': rules.apply_attachments, + 'burst': rules.apply_burst, + 'burst_shared': rules.apply_burst_shared, + 'chars': rules.apply_chars, + 'discord_emojis': rules.apply_discord_emojis, + 'duplicates': rules.apply_duplicates, + 'links': rules.apply_links, + 'mentions': rules.apply_mentions, + 'newlines': rules.apply_newlines, + 'role_mentions': rules.apply_role_mentions +} + + +class AntiSpam: + def __init__(self, bot: Bot): + self.bot = bot + self.muted_role = None + + async def on_ready(self): + role_id = AntiSpamConfig.punishment['role_id'] + self.muted_role = Object(role_id) + + async def on_message(self, message: Message): + if message.guild.id != GuildConfig.id or message.author.bot: + return + + # Fetch the rule configuration with the highest rule interval. + max_interval_config = max( + AntiSpamConfig.rules.values(), + key=lambda config: config['interval'] + ) + max_interval = max_interval_config['interval'] + + # 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) + ] + + for rule_name in AntiSpamConfig.rules: + rule_config = AntiSpamConfig.rules[rule_name] + rule_function = RULE_FUNCTION_MAPPING[rule_name] + + # Create a list of messages that were sent in the interval that the rule cares about. + latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval']) + messages_for_rule = [ + msg for msg in relevant_messages if msg.created_at > latest_interesting_stamp + ] + result = await rule_function(message, messages_for_rule, rule_config) + + # If the rule returns `None`, that means the message didn't violate it. + # If it doesn't, it returns a tuple in the form `(str, Iterable[discord.Member])` + # which contains the reason for why the message violated the rule and + # an iterable of all members that violated the rule. + if result is not None: + reason, members, relevant_messages = result + full_reason = f"`{rule_name}` rule: {reason}" + for member in members: + + # Fire it off as a background task to ensure + # that the sleep doesn't block further tasks + self.bot.loop.create_task( + self.punish(message, member, rule_config, full_reason) + ) + + await self.maybe_delete_messages(message.channel, relevant_messages) + break + + async def punish(self, msg: Message, member: Member, rule_config: Dict[str, int], reason: str): + # Sanity check to ensure we're not lagging behind + if self.muted_role not in member.roles: + remove_role_after = AntiSpamConfig.punishment['remove_after'] + duration_delta = relativedelta(seconds=remove_role_after) + human_duration = humanize_delta(duration_delta) + + mod_alert_channel = self.bot.get_channel(Channels.mod_alerts) + if mod_alert_channel is not None: + await mod_alert_channel.send( + f"<:messagefiltered:473092874289020929> Spam detected in {msg.channel.mention}. " + f"See the message and mod log for further details." + ) + else: + log.warning( + "Tried logging spam event to the mod-alerts channel, but it could not be found." + ) + + await member.add_roles(self.muted_role, reason=reason) + description = textwrap.dedent(f""" + **Channel**: {msg.channel.mention} + **User**: {msg.author.mention} (`{msg.author.id}`) + **Reason**: {reason} + Role will be removed after {human_duration}. + """) + + modlog = self.bot.get_cog('ModLog') + await modlog.send_log_message( + icon_url=Icons.user_mute, colour=Colours.soft_red, + title="User muted", text=description + ) + + await asyncio.sleep(remove_role_after) + await member.remove_roles(self.muted_role, reason="AntiSpam mute expired") + + await modlog.send_log_message( + icon_url=Icons.user_mute, colour=Colours.soft_green, + title="User unmuted", + text=f"Was muted by `AntiSpam` cog for {human_duration}." + ) + + async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]): + # Is deletion of offending messages actually enabled? + if AntiSpamConfig.clean_offending: + + # If we have more than one message, we can use bulk delete. + if len(messages) > 1: + await channel.delete_messages(messages) + + # Otherwise, the bulk delete endpoint will throw up. + # Delete the message directly instead. + else: + await messages[0].delete() + + +def validate_config(): + for name, config in AntiSpamConfig.rules.items(): + if name not in RULE_FUNCTION_MAPPING: + raise ValueError( + f"Unrecognized antispam rule `{name}`. " + f"Valid rules are: {', '.join(RULE_FUNCTION_MAPPING)}" + ) + + for required_key in ('interval', 'max'): + if required_key not in config: + raise ValueError( + f"`{required_key}` is required but was not " + f"set in rule `{name}`'s configuration." + ) + + +def setup(bot: Bot): + validate_config() + bot.add_cog(AntiSpam(bot)) + log.info("Cog loaded: AntiSpam") diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py new file mode 100644 index 000000000..da0a5a9f2 --- /dev/null +++ b/bot/cogs/clean.py @@ -0,0 +1,309 @@ +import logging +import random +import re +from typing import Optional + +from aiohttp.client_exceptions import ClientResponseError +from discord import Colour, Embed, Message, User +from discord.ext.commands import Bot, Context, group + +from bot.cogs.modlog import ModLog +from bot.constants import ( + Channels, CleanMessages, Colours, Icons, + Keys, NEGATIVE_REPLIES, Roles, URLs +) +from bot.decorators import with_role + +log = logging.getLogger(__name__) + + +class Clean: + """ + A cog that allows messages to be deleted in + bulk, while applying various filters. + + You can delete messages sent by a specific user, + messages sent by bots, all messages, or messages + that match a specific regular expression. + + The deleted messages are saved and uploaded + to the database via an API endpoint, and a URL is + returned which can be used to view the messages + in the Discord dark theme style. + """ + + def __init__(self, bot: Bot): + self.bot = bot + self.headers = {"X-API-KEY": Keys.site_api} + self.cleaning = False + + @property + def mod_log(self) -> ModLog: + return self.bot.get_cog("ModLog") + + async def _upload_log(self, log_data: list) -> str: + """ + Uploads the log data to the database via + an API endpoint for uploading logs. + + Returns a URL that can be used to view the log. + """ + + response = await self.bot.http_session.post( + URLs.site_clean_api, + headers=self.headers, + json={"log_data": log_data} + ) + + try: + data = await response.json() + log_id = data["log_id"] + except (KeyError, ClientResponseError): + log.debug( + "API returned an unexpected result:\n" + f"{response.text}" + ) + return + + return f"{URLs.site_clean_logs}/{log_id}" + + async def _clean_messages( + self, amount: int, ctx: Context, + bots_only: bool = False, user: User = None, + regex: Optional[str] = None + ): + """ + A helper function that does the actual message cleaning. + + :param bots_only: Set this to True if you only want to delete bot messages. + :param user: Specify a user and it will only delete messages by this user. + :param regular_expression: Specify a regular expression and it will only + delete messages that match this. + """ + + def predicate_bots_only(message: Message) -> bool: + """ + Returns true if the message was sent by a bot + """ + + return message.author.bot + + def predicate_specific_user(message: Message) -> bool: + """ + Return True if the message was sent by the + user provided in the _clean_messages call. + """ + + return message.author == user + + def predicate_regex(message: Message): + """ + Returns True if the regex provided in the + _clean_messages matches the message content + or any embed attributes the message may have. + """ + + content = [message.content] + + # Add the content for all embed attributes + for embed in message.embeds: + content.append(embed.title) + content.append(embed.description) + content.append(embed.footer.text) + content.append(embed.author.name) + for field in embed.fields: + content.append(field.name) + content.append(field.value) + + # Get rid of empty attributes and turn it into a string + content = [attr for attr in content if attr] + content = "\n".join(content) + + # Now let's see if there's a regex match + if not content: + return False + else: + return bool(re.search(regex.lower(), content.lower())) + + # Is this an acceptable amount of messages to clean? + if amount > CleanMessages.message_limit: + embed = Embed( + color=Colour(Colours.soft_red), + title=random.choice(NEGATIVE_REPLIES), + description=f"You cannot clean more than {CleanMessages.message_limit} messages." + ) + await ctx.send(embed=embed) + return + + # Are we already performing a clean? + if self.cleaning: + embed = Embed( + color=Colour(Colours.soft_red), + title=random.choice(NEGATIVE_REPLIES), + description="Please wait for the currently ongoing clean operation to complete." + ) + await ctx.send(embed=embed) + return + + # Set up the correct predicate + if bots_only: + predicate = predicate_bots_only # Delete messages from bots + elif user: + predicate = predicate_specific_user # Delete messages from specific user + elif regex: + predicate = predicate_regex # Delete messages that match regex + else: + predicate = None # Delete all messages + + # Look through the history and retrieve message data + message_log = [] + message_ids = [] + self.cleaning = True + invocation_deleted = False + + async for message in ctx.channel.history(limit=amount): + + # If at any point the cancel command is invoked, we should stop. + if not self.cleaning: + return + + # Always start by deleting the invocation + if not invocation_deleted: + self.mod_log.ignore_message_deletion(message.id) + await message.delete() + invocation_deleted = True + continue + + # If the message passes predicate, let's save it. + if predicate is None or predicate(message): + author = f"{message.author.name}#{message.author.discriminator}" + + # message.author may return either a User or a Member. Users don't have roles. + if type(message.author) is User: + role_id = Roles.developer + else: + role_id = message.author.top_role.id + + content = message.content + embeds = [embed.to_dict() for embed in message.embeds] + attachments = ["<Attachment>" for _ in message.attachments] + + message_ids.append(message.id) + message_log.append({ + "content": content, + "author": author, + "user_id": str(message.author.id), + "role_id": str(role_id), + "timestamp": message.created_at.strftime("%D %H:%M"), + "attachments": attachments, + "embeds": embeds, + }) + + self.cleaning = False + + # We should ignore the ID's we stored, so we don't get mod-log spam. + self.mod_log.ignore_message_deletion(*message_ids) + + # Use bulk delete to actually do the cleaning. It's far faster. + await ctx.channel.purge( + limit=amount, + check=predicate + ) + + # Reverse the list to restore chronological order + if message_log: + message_log = list(reversed(message_log)) + upload_log = await self._upload_log(message_log) + else: + # Can't build an embed, nothing to clean! + embed = Embed( + color=Colour(Colours.soft_red), + description="No matching messages could be found." + ) + await ctx.send(embed=embed, delete_after=10) + return + + # Build the embed and send it + print(upload_log) + message = ( + f"**{len(message_ids)}** messages deleted in <#{ctx.channel.id}> by **{ctx.author.name}**\n\n" + f"A log of the deleted messages can be found [here]({upload_log})." + ) + + await self.mod_log.send_log_message( + icon_url=Icons.message_bulk_delete, + colour=Colour(Colours.soft_red), + title="Bulk message delete", + text=message, + channel_id=Channels.modlog, + ) + + @group(invoke_without_command=True, name="clean", hidden=True) + @with_role(Roles.moderator, Roles.admin, Roles.owner) + async def clean_group(self, ctx: Context): + """ + Commands for cleaning messages in channels + """ + + await ctx.invoke(self.bot.get_command("help"), "clean") + + @clean_group.command(name="user", aliases=["users"]) + @with_role(Roles.moderator, Roles.admin, Roles.owner) + async def clean_user(self, ctx: Context, user: User, amount: int = 10): + """ + Delete messages posted by the provided user, + and stop cleaning after traversing `amount` messages. + """ + + await self._clean_messages(amount, ctx, user=user) + + @clean_group.command(name="all", aliases=["everything"]) + @with_role(Roles.moderator, Roles.admin, Roles.owner) + async def clean_all(self, ctx: Context, amount: int = 10): + """ + Delete all messages, regardless of poster, + and stop cleaning after traversing `amount` messages. + """ + + await self._clean_messages(amount, ctx) + + @clean_group.command(name="bots", aliases=["bot"]) + @with_role(Roles.moderator, Roles.admin, Roles.owner) + async def clean_bots(self, ctx: Context, amount: int = 10): + """ + Delete all messages posted by a bot, + and stop cleaning after traversing `amount` messages. + """ + + await self._clean_messages(amount, ctx, bots_only=True) + + @clean_group.command(name="regex", aliases=["word", "expression"]) + @with_role(Roles.moderator, Roles.admin, Roles.owner) + async def clean_regex(self, ctx: Context, regex, amount: int = 10): + """ + Delete all messages that match a certain regex, + and stop cleaning after traversing `amount` messages. + """ + + await self._clean_messages(amount, ctx, regex=regex) + + @clean_group.command(name="stop", aliases=["cancel", "abort"]) + @with_role(Roles.moderator, Roles.admin, Roles.owner) + async def clean_cancel(self, ctx: Context): + """ + If there is an ongoing cleaning process, + attempt to immediately cancel it. + """ + + self.cleaning = False + + embed = Embed( + color=Colour.blurple(), + description="Clean interrupted." + ) + await ctx.send(embed=embed, delete_after=10) + + +def setup(bot): + bot.add_cog(Clean(bot)) + log.info("Cog loaded: Clean") diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 8ca59b058..beb05ba46 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -36,7 +36,7 @@ class Defcon: self.headers = {"X-API-KEY": Keys.site_api} @property - def modlog(self) -> ModLog: + def mod_log(self) -> ModLog: return self.bot.get_cog("ModLog") async def on_ready(self): @@ -92,7 +92,7 @@ class Defcon: if not message_sent: message = f"{message}\n\nUnable to send rejection message via DM; they probably have DMs disabled." - await self.modlog.send_log_message( + await self.mod_log.send_log_message( Icons.defcon_denied, COLOUR_RED, "Entry denied", message, member.avatar_url_as(static_format="png") ) @@ -133,7 +133,7 @@ class Defcon: f"```py\n{e}\n```" ) - await self.modlog.send_log_message( + await self.mod_log.send_log_message( Icons.defcon_enabled, COLOUR_GREEN, "DEFCON enabled", f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" f"**Days:** {self.days.days}\n\n" @@ -144,7 +144,7 @@ class Defcon: else: await ctx.send(f"{Emojis.defcon_enabled} DEFCON enabled.") - await self.modlog.send_log_message( + await self.mod_log.send_log_message( Icons.defcon_enabled, COLOUR_GREEN, "DEFCON enabled", f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" f"**Days:** {self.days.days}\n\n" @@ -176,7 +176,7 @@ class Defcon: f"```py\n{e}\n```" ) - await self.modlog.send_log_message( + await self.mod_log.send_log_message( Icons.defcon_disabled, COLOUR_RED, "DEFCON disabled", f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" "**There was a problem updating the site** - This setting may be reverted when the bot is " @@ -186,7 +186,7 @@ class Defcon: else: await ctx.send(f"{Emojis.defcon_disabled} DEFCON disabled.") - await self.modlog.send_log_message( + await self.mod_log.send_log_message( Icons.defcon_disabled, COLOUR_RED, "DEFCON disabled", f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)" ) @@ -233,7 +233,7 @@ class Defcon: f"```py\n{e}\n```" ) - await self.modlog.send_log_message( + await self.mod_log.send_log_message( Icons.defcon_updated, Colour.blurple(), "DEFCON updated", f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" f"**Days:** {self.days.days}\n\n" @@ -246,7 +246,7 @@ class Defcon: f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {days} days old to join to the server" ) - await self.modlog.send_log_message( + await self.mod_log.send_log_message( Icons.defcon_updated, Colour.blurple(), "DEFCON updated", f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" f"**Days:** {self.days.days}" diff --git a/bot/cogs/events.py b/bot/cogs/events.py index a7111b8a0..0b9b75a00 100644 --- a/bot/cogs/events.py +++ b/bot/cogs/events.py @@ -1,20 +1,24 @@ import logging -from discord import Embed, Member +from discord import Colour, Embed, Member, Object from discord.ext.commands import ( BadArgument, Bot, BotMissingPermissions, CommandError, CommandInvokeError, Context, NoPrivateMessage, UserInputError ) +from bot.cogs.modlog import ModLog from bot.constants import ( - Channels, DEBUG_MODE, Guild, - Keys, Roles, URLs + Channels, Colours, DEBUG_MODE, + Guild, Icons, Keys, + Roles, URLs ) from bot.utils import chunks log = logging.getLogger(__name__) +RESTORE_ROLES = (str(Roles.muted), str(Roles.announcements)) + class Events: """No commands, just event handlers.""" @@ -22,6 +26,10 @@ class Events: def __init__(self, bot: Bot): self.bot = bot + @property + def mod_log(self) -> ModLog: + return self.bot.get_cog("ModLog") + async def send_updated_users(self, *users, replace_all=False): users = list(filter(lambda user: str(Roles.verified) in user["roles"], users)) @@ -85,6 +93,16 @@ class Events: log.exception(f"Failed to delete {len(users)} users") return {} + async def get_user(self, user_id): + response = await self.bot.http_session.get( + url=URLs.site_user_api, + params={"user_id": user_id}, + headers={"X-API-Key": Keys.site_api} + ) + + resp = await response.json() + return resp["data"] + async def on_command_error(self, ctx: Context, e: CommandError): command = ctx.command parent = None @@ -194,6 +212,29 @@ class Events: async def on_member_join(self, member: Member): role_ids = [str(r.id) for r in member.roles] # type: List[str] + new_roles = [] + + try: + user_objs = await self.get_user(str(member.id)) + except Exception as e: + log.exception("Failed to persist roles") + + await self.mod_log.send_log_message( + Icons.crown_red, Colour(Colours.soft_red), "Failed to persist roles", + f"```py\n{e}\n```", + member.avatar_url_as(static_format="png") + ) + else: + if user_objs: + old_roles = user_objs[0].get("roles", []) + + for role in RESTORE_ROLES: + if role in old_roles: + new_roles.append(Object(int(role))) + + for role in new_roles: + if str(role) not in role_ids: + role_ids.append(str(role.id)) changes = await self.send_updated_users({ "avatar": member.avatar_url_as(format="png"), @@ -205,6 +246,18 @@ class Events: log.debug(f"User {member.id} joined; changes: {changes}") + if new_roles: + await member.add_roles( + *new_roles, + reason="Roles restored" + ) + + await self.mod_log.send_log_message( + Icons.crown_blurple, Colour.blurple(), "Roles restored", + f"Restored {len(new_roles)} roles", + member.avatar_url_as(static_format="png") + ) + async def on_member_remove(self, member: Member): changes = await self.send_delete_users({ "user_id": str(member.id) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py new file mode 100644 index 000000000..89735e57c --- /dev/null +++ b/bot/cogs/filtering.py @@ -0,0 +1,232 @@ +import logging +import re + +from discord import Colour, Member, Message +from discord.ext.commands import Bot + +from bot.cogs.modlog import ModLog +from bot.constants import ( + Channels, Colours, DEBUG_MODE, + Filter, Icons +) + +log = logging.getLogger(__name__) + +INVITE_RE = ( + r"(?:discord(?:[\.,]|dot)gg|" # Could be discord.gg/ + r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/ + r"discordapp(?:[\.,]|dot)com(?:\/|slash)invite|" # or discordapp.com/invite/ + r"discord(?:[\.,]|dot)me|" # or discord.me + r"discord(?:[\.,]|dot)io" # or discord.io. + r")(?:[\/]|slash)" # / or 'slash' + r"([a-zA-Z0-9]+)" # the invite code itself +) + +URL_RE = "(https?://[^\s]+)" +ZALGO_RE = r"[\u0300-\u036F\u0489]" + + +class Filtering: + """ + Filtering out invites, blacklisting domains, + and warning us of certain regular expressions + """ + + def __init__(self, bot: Bot): + self.bot = bot + + self.filters = { + "filter_zalgo": { + "enabled": Filter.filter_zalgo, + "function": self._has_zalgo, + "type": "filter" + }, + "filter_invites": { + "enabled": Filter.filter_invites, + "function": self._has_invites, + "type": "filter" + }, + "filter_domains": { + "enabled": Filter.filter_domains, + "function": self._has_urls, + "type": "filter" + }, + "watch_words": { + "enabled": Filter.watch_words, + "function": self._has_watchlist_words, + "type": "watchlist" + }, + "watch_tokens": { + "enabled": Filter.watch_tokens, + "function": self._has_watchlist_tokens, + "type": "watchlist" + }, + } + + @property + def mod_log(self) -> ModLog: + return self.bot.get_cog("ModLog") + + async def on_message(self, msg: Message): + await self._filter_message(msg) + + async def on_message_edit(self, _: Message, after: Message): + await self._filter_message(after) + + async def _filter_message(self, msg: Message): + """ + Whenever a message is sent or edited, + run it through our filters to see if it + violates any of our rules, and then respond + accordingly. + """ + + # Should we filter this message? + role_whitelisted = False + + if type(msg.author) is Member: # Only Member has roles, not User. + for role in msg.author.roles: + if role.id in Filter.role_whitelist: + role_whitelisted = True + + filter_message = ( + msg.channel.id not in Filter.channel_whitelist # Channel not in whitelist + and not role_whitelisted # Role not in whitelist + and not msg.author.bot # Author not a bot + ) + + # If we're running the bot locally, ignore role whitelist and only listen to #dev-test + if DEBUG_MODE: + filter_message = not msg.author.bot and msg.channel.id == Channels.devtest + + # If none of the above, we can start filtering. + if filter_message: + for filter_name, _filter in self.filters.items(): + + # Is this specific filter enabled in the config? + if _filter["enabled"]: + triggered = await _filter["function"](msg.content) + + if triggered: + message = ( + f"The {filter_name} {_filter['type']} was triggered " + f"by **{msg.author.name}#{msg.author.discriminator}** in " + f"<#{msg.channel.id}> with the following message:\n\n" + f"{msg.content}" + ) + + log.debug(message) + log.debug(Channels.mod_alerts) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title=f"{_filter['type'].title()} triggered!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=Filter.ping_everyone, + ) + + # If this is a filter (not a watchlist), we should delete the message. + if _filter["type"] == "filter": + await msg.delete() + + break # We don't want multiple filters to trigger + + @staticmethod + async def _has_watchlist_words(text: str) -> bool: + """ + Returns True if the text contains + one of the regular expressions from the + word_watchlist in our filter config. + + Only matches words with boundaries before + and after the expression. + """ + + for expression in Filter.word_watchlist: + if re.search(fr"\b{expression}\b", text, re.IGNORECASE): + return True + + return False + + @staticmethod + async def _has_watchlist_tokens(text: str) -> bool: + """ + Returns True if the text contains + one of the regular expressions from the + token_watchlist in our filter config. + + This will match the expression even if it + does not have boundaries before and after + """ + + for expression in Filter.token_watchlist: + if re.search(fr"{expression}", text, re.IGNORECASE): + return True + + return False + + @staticmethod + async def _has_urls(text: str) -> bool: + """ + Returns True if the text contains one of + the blacklisted URLs from the config file. + """ + + if not re.search(URL_RE, text, re.IGNORECASE): + return False + + text = text.lower() + + for url in Filter.domain_blacklist: + if url.lower() in text: + return True + + return False + + @staticmethod + async def _has_zalgo(text: str) -> bool: + """ + Returns True if the text contains zalgo characters. + + Zalgo range is \u0300 – \u036F and \u0489. + """ + + return bool(re.search(ZALGO_RE, text)) + + @staticmethod + async def _has_invites(text: str) -> bool: + """ + Returns True if the text contains an invite which + is not on the guild_invite_whitelist in config.yml. + + Also catches a lot of common ways to try to cheat the system. + """ + + # Remove spaces to prevent cases like + # d i s c o r d . c o m / i n v i t e / p y t h o n + text = text.replace(" ", "") + + # Remove backslashes to prevent escape character aroundfuckery like + # discord\.gg/gdudes-pony-farm + text = text.replace("\\", "") + + invites = re.findall(INVITE_RE, text, re.IGNORECASE) + for invite in invites: + + filter_invite = ( + invite not in Filter.guild_invite_whitelist + and invite.lower() not in Filter.vanity_url_whitelist + ) + + if filter_invite: + return True + return False + + +def setup(bot: Bot): + bot.add_cog(Filtering(bot)) + log.info("Cog loaded: Filtering") diff --git a/bot/cogs/information.py b/bot/cogs/information.py new file mode 100644 index 000000000..f61b130a6 --- /dev/null +++ b/bot/cogs/information.py @@ -0,0 +1,126 @@ +import logging +import textwrap +from datetime import datetime + +from dateutil.relativedelta import relativedelta +from discord import CategoryChannel, Colour, Embed, TextChannel, VoiceChannel +from discord.ext.commands import Bot, Context, command + +from bot.constants import Emojis +from bot.utils.time import humanize + +log = logging.getLogger(__name__) + + +class Information: + """ + A cog with commands for generating embeds with + server information, such as server statistics + and user information. + """ + + def __init__(self, bot: Bot): + self.bot = bot + + @command(name="roles") + async def roles_info(self, ctx: Context): + """ + Returns a list of all roles and their + corresponding IDs. + """ + + # Sort the roles alphabetically and remove the @everyone role + roles = sorted(ctx.guild.roles, key=lambda role: role.name) + roles = [role for role in roles if role.name != "@everyone"] + + # Build a string + role_string = "" + for role in roles: + role_string += f"`{role.id}` - {role.mention}\n" + + # Build an embed + embed = Embed( + title="Role information", + colour=Colour.blurple(), + description=role_string + ) + + embed.set_footer(text=f"Total roles: {len(roles)}") + + await ctx.send(embed=embed) + + @command(name="server", aliases=["server_info", "guild", "guild_info"]) + async def server_info(self, ctx: Context): + """ + Returns an embed full of + server information. + """ + + now = datetime.now() + created_delta = relativedelta(now, ctx.guild.created_at) + created = humanize(created_delta, accuracy="days") + features = ", ".join(ctx.guild.features) + region = ctx.guild.region + + # How many of each type of channel? + roles = len(ctx.guild.roles) + channels = ctx.guild.channels + text_channels = 0 + category_channels = 0 + voice_channels = 0 + for channel in channels: + if type(channel) == TextChannel: + text_channels += 1 + elif type(channel) == CategoryChannel: + category_channels += 1 + elif type(channel) == VoiceChannel: + voice_channels += 1 + + # How many of each user status? + member_count = ctx.guild.member_count + members = ctx.guild.members + online = 0 + dnd = 0 + idle = 0 + offline = 0 + for member in members: + if str(member.status) == "online": + online += 1 + elif str(member.status) == "offline": + offline += 1 + elif str(member.status) == "idle": + idle += 1 + elif str(member.status) == "dnd": + dnd += 1 + + embed = Embed( + colour=Colour.blurple(), + description=textwrap.dedent(f""" + **Server information** + Created: {created} ago + Voice region: {region} + Features: {features} + + **Counts** + Members: {member_count} + Roles: {roles} + Text: {text_channels} + Voice: {voice_channels} + Channel categories: {category_channels} + + **Members** + {Emojis.status_online} {online} + {Emojis.status_idle} {idle} + {Emojis.status_dnd} {dnd} + {Emojis.status_offline} {offline} + """) + ) + + embed.set_thumbnail(url=ctx.guild.icon_url) + + await ctx.send(embed=embed) + + +def setup(bot): + bot.add_cog(Information(bot)) + log.info("Cog loaded: Information") diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 245f17fda..0a0bbae53 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -663,7 +663,7 @@ def parse_rfc1123(time_str): def _silent_exception(future): try: future.exception() - except Exception: + except Exception: # noqa: S110 pass diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index 87cea2b5a..b5a73d6e0 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -13,16 +13,13 @@ from discord import ( from discord.abc import GuildChannel from discord.ext.commands import Bot -from bot.constants import Channels, Emojis, Icons +from bot.constants import Channels, Colours, Emojis, Icons from bot.constants import Guild as GuildConstant from bot.utils.time import humanize log = logging.getLogger(__name__) -BULLET_POINT = "\u2022" -COLOUR_RED = Colour(0xcd6d6d) -COLOUR_GREEN = Colour(0x68c290) GUILD_CHANNEL = Union[CategoryChannel, TextChannel, VoiceChannel] CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) @@ -92,7 +89,7 @@ class ModLog: else: message = f"{channel.name} (`{channel.id}`)" - await self.send_log_message(Icons.hash_green, COLOUR_GREEN, title, message) + await self.send_log_message(Icons.hash_green, Colour(Colours.soft_green), title, message) async def on_guild_channel_delete(self, channel: GUILD_CHANNEL): if channel.guild.id != GuildConstant.id: @@ -111,7 +108,7 @@ class ModLog: message = f"{channel.name} (`{channel.id}`)" await self.send_log_message( - Icons.hash_red, COLOUR_RED, + Icons.hash_red, Colour(Colours.soft_red), title, message ) @@ -157,7 +154,7 @@ class ModLog: message = "" for item in sorted(changes): - message += f"{BULLET_POINT} {item}\n" + message += f"{Emojis.bullet} {item}\n" if after.category: message = f"**{after.category}/#{after.name} (`{after.id}`)**\n{message}" @@ -174,7 +171,7 @@ class ModLog: return await self.send_log_message( - Icons.crown_green, COLOUR_GREEN, + Icons.crown_green, Colour(Colours.soft_green), "Role created", f"`{role.id}`" ) @@ -183,7 +180,7 @@ class ModLog: return await self.send_log_message( - Icons.crown_red, COLOUR_RED, + Icons.crown_red, Colour(Colours.soft_red), "Role removed", f"{role.name} (`{role.id}`)" ) @@ -229,7 +226,7 @@ class ModLog: message = "" for item in sorted(changes): - message += f"{BULLET_POINT} {item}\n" + message += f"{Emojis.bullet} {item}\n" message = f"**{after.name}** (`{after.id}`)\n{message}" @@ -277,7 +274,7 @@ class ModLog: message = "" for item in sorted(changes): - message += f"{BULLET_POINT} {item}\n" + message += f"{Emojis.bullet} {item}\n" message = f"**{after.name}** (`{after.id}`)\n{message}" @@ -292,7 +289,7 @@ class ModLog: return await self.send_log_message( - Icons.user_ban, COLOUR_RED, + Icons.user_ban, Colour(Colours.soft_red), "User banned", f"{member.name}#{member.discriminator} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png") ) @@ -312,7 +309,7 @@ class ModLog: message = f"{Emojis.new} {message}" await self.send_log_message( - Icons.sign_in, COLOUR_GREEN, + Icons.sign_in, Colour(Colours.soft_green), "User joined", message, thumbnail=member.avatar_url_as(static_format="png") ) @@ -322,7 +319,7 @@ class ModLog: return await self.send_log_message( - Icons.sign_out, COLOUR_RED, + Icons.sign_out, Colour(Colours.soft_red), "User left", f"{member.name}#{member.discriminator} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png") ) @@ -410,7 +407,7 @@ class ModLog: message = "" for item in sorted(changes): - message += f"{BULLET_POINT} {item}\n" + message += f"{Emojis.bullet} {item}\n" message = f"**{after.name}#{after.discriminator}** (`{after.id}`)\n{message}" @@ -489,7 +486,7 @@ class ModLog: response = f"**Attachments:** {len(message.attachments)}\n" + response await self.send_log_message( - Icons.message_delete, COLOUR_RED, + Icons.message_delete, Colours.soft_red, "Message deleted", response, channel_id=Channels.message_log @@ -528,7 +525,7 @@ class ModLog: ) await self.send_log_message( - Icons.message_delete, COLOUR_RED, + Icons.message_delete, Colour(Colours.soft_red), "Message deleted", response, channel_id=Channels.message_log diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index f089e0b5a..bcf3148f8 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -111,6 +111,32 @@ class OffTopicNames: error_reason = response.get('message', "No reason provided.") await ctx.send(f":warning: got non-200 from the API: {error_reason}") + @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def delete_command(self, ctx, name: OffTopicName): + """Removes a off-topic name from the rotation.""" + + result = await self.bot.http_session.delete( + URLs.site_off_topic_names_api, + headers=self.headers, + params={'name': name} + ) + + response = await result.json() + + if result.status == 200: + if response['deleted'] == 0: + await ctx.send(f":warning: No name matching `{name}` was found in the database.") + else: + log.info( + f"{ctx.author.name}#{ctx.author.discriminator}" + f" deleted the off-topic channel name '{name}" + ) + await ctx.send(":ok_hand:") + else: + error_reason = response.get('message', "No reason provided.") + await ctx.send(f":warning: got non-200 from the API: {error_reason}") + @otname_group.command(name='list', aliases=('l',)) @with_role(Roles.owner, Roles.admin, Roles.moderator) async def list_command(self, ctx): diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index c8621118b..74bc0d9b2 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -40,10 +40,10 @@ class TokenRemover: def __init__(self, bot: Bot): self.bot = bot - self.modlog = None + self.mod_log = None async def on_ready(self): - self.modlog = self.bot.get_channel(Channels.modlog) + self.mod_log = self.bot.get_channel(Channels.modlog) async def on_message(self, msg: Message): if msg.author.bot: @@ -61,7 +61,7 @@ class TokenRemover: if self.is_valid_user_id(user_id) and self.is_valid_timestamp(creation_timestamp): await msg.delete() await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) - await self.modlog.send( + await self.mod_log.send( ":key2::mute: censored a seemingly valid token sent by " f"{msg.author} (`{msg.author.id}`) in {msg.channel.mention}, token was " f"`{user_id}.{creation_timestamp}.{'x' * len(hmac)}`" diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index b0667fdd0..84912e947 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -37,7 +37,7 @@ class Verification: self.bot = bot @property - def modlog(self) -> ModLog: + def mod_log(self) -> ModLog: return self.bot.get_cog("ModLog") async def on_message(self, message: Message): @@ -90,7 +90,7 @@ class Verification: log.trace(f"Deleting the message posted by {ctx.author}.") try: - self.modlog.ignore_message_deletion(ctx.message.id) + self.mod_log.ignore_message_deletion(ctx.message.id) await ctx.message.delete() except NotFound: log.trace("No message found, it must have been deleted by another bot.") @@ -110,7 +110,7 @@ class Verification: break if has_role: - await ctx.send( + return await ctx.send( f"{ctx.author.mention} You're already subscribed!", ) diff --git a/bot/constants.py b/bot/constants.py index 205b09111..ab426a7ab 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -14,7 +14,7 @@ import logging import os from collections.abc import Mapping from pathlib import Path -from typing import List +from typing import Dict, List import yaml from yaml.constructor import ConstructorError @@ -191,6 +191,26 @@ class Bot(metaclass=YAMLGetter): token: str +class Filter(metaclass=YAMLGetter): + section = "filter" + + filter_zalgo: bool + filter_invites: bool + filter_domains: bool + watch_words: bool + watch_tokens: bool + + ping_everyone: bool + guild_invite_whitelist: List[str] + vanity_url_whitelist: List[str] + domain_blacklist: List[str] + word_watchlist: List[str] + token_watchlist: List[str] + + channel_whitelist: List[int] + role_whitelist: List[int] + + class Cooldowns(metaclass=YAMLGetter): section = "bot" subsection = "cooldowns" @@ -198,8 +218,16 @@ class Cooldowns(metaclass=YAMLGetter): tags: int +class Colours(metaclass=YAMLGetter): + section = "style" + subsection = "colours" + + soft_red: int + soft_green: int + + class Emojis(metaclass=YAMLGetter): - section = "bot" + section = "style" subsection = "emojis" defcon_disabled: str # noqa: E704 @@ -209,23 +237,32 @@ class Emojis(metaclass=YAMLGetter): green_chevron: str red_chevron: str white_chevron: str + lemoneye2: str + status_online: str + status_offline: str + status_idle: str + status_dnd: str + + bullet: str new: str pencil: str class Icons(metaclass=YAMLGetter): - section = "bot" + section = "style" subsection = "icons" crown_blurple: str crown_green: str crown_red: str - defcon_denied: str # noqa: E704 + defcon_denied: str # noqa: E704 defcon_disabled: str # noqa: E704 - defcon_enabled: str # noqa: E704 - defcon_updated: str # noqa: E704 + defcon_enabled: str # noqa: E704 + defcon_updated: str # noqa: E704 + + filtering: str guild_update: str @@ -245,6 +282,13 @@ class Icons(metaclass=YAMLGetter): user_update: str +class CleanMessages(metaclass=YAMLGetter): + section = "bot" + subsection = "clean" + + message_limit: int + + class Channels(metaclass=YAMLGetter): section = "guild" subsection = "channels" @@ -265,6 +309,7 @@ class Channels(metaclass=YAMLGetter): help_5: int helpers: int message_log: int + mod_alerts: int modlog: int off_topic_1: int off_topic_2: int @@ -281,9 +326,11 @@ class Roles(metaclass=YAMLGetter): announcements: int champion: int contributor: int + developer: int devops: int jammer: int moderator: int + muted: int owner: int verified: int muted: int @@ -332,6 +379,8 @@ class URLs(metaclass=YAMLGetter): omdb: str site: str site_facts_api: str + site_clean_api: str + site_clean_logs: str site_hiphopify_api: str site_idioms_api: str site_names_api: str @@ -351,6 +400,13 @@ class URLs(metaclass=YAMLGetter): paste_service: str +class AntiSpam(metaclass=YAMLGetter): + section = 'anti_spam' + + punishment: Dict[str, Dict[str, int]] + rules: Dict[str, Dict[str, int]] + + # Debug mode DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False diff --git a/bot/rules/__init__.py b/bot/rules/__init__.py new file mode 100644 index 000000000..a01ceae73 --- /dev/null +++ b/bot/rules/__init__.py @@ -0,0 +1,12 @@ +# flake8: noqa + +from .attachments import apply as apply_attachments +from .burst import apply as apply_burst +from .burst_shared import apply as apply_burst_shared +from .chars import apply as apply_chars +from .discord_emojis import apply as apply_discord_emojis +from .duplicates import apply as apply_duplicates +from .links import apply as apply_links +from .mentions import apply as apply_mentions +from .newlines import apply as apply_newlines +from .role_mentions import apply as apply_role_mentions diff --git a/bot/rules/attachments.py b/bot/rules/attachments.py new file mode 100644 index 000000000..47b927101 --- /dev/null +++ b/bot/rules/attachments.py @@ -0,0 +1,30 @@ +"""Detects total attachments exceeding the limit sent by a single user.""" + +from typing import Dict, Iterable, List, Optional, Tuple + +from discord import Member, Message + + +async def apply( + last_message: Message, + recent_messages: List[Message], + config: Dict[str, int] +) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: + + relevant_messages = tuple( + msg + for msg in recent_messages + if ( + msg.author == last_message.author + and len(msg.attachments) > 0 + ) + ) + total_recent_attachments = sum(len(msg.attachments) for msg in relevant_messages) + + if total_recent_attachments > config['max']: + return ( + f"sent {total_recent_attachments} attachments in {config['max']}s", + (last_message.author,), + relevant_messages + ) + return None diff --git a/bot/rules/burst.py b/bot/rules/burst.py new file mode 100644 index 000000000..80c79be60 --- /dev/null +++ b/bot/rules/burst.py @@ -0,0 +1,27 @@ +"""Detects repeated messages sent by a single user.""" + +from typing import Dict, Iterable, List, Optional, Tuple + +from discord import Member, Message + + +async def apply( + last_message: Message, + recent_messages: List[Message], + config: Dict[str, int] +) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: + + relevant_messages = tuple( + msg + for msg in recent_messages + if msg.author == last_message.author + ) + total_relevant = len(relevant_messages) + + if total_relevant > config['max']: + return ( + f"sent {total_relevant} messages in {config['interval']}s", + (last_message.author,), + relevant_messages + ) + return None diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py new file mode 100644 index 000000000..2cb7b5200 --- /dev/null +++ b/bot/rules/burst_shared.py @@ -0,0 +1,22 @@ +"""Detects repeated messages sent by multiple users.""" + +from typing import Dict, Iterable, List, Optional, Tuple + +from discord import Member, Message + + +async def apply( + last_message: Message, + recent_messages: List[Message], + config: Dict[str, int] +) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: + + total_recent = len(recent_messages) + + if total_recent > config['max']: + return ( + f"sent {total_recent} messages in {config['interval']}s", + set(msg.author for msg in recent_messages), + recent_messages + ) + return None diff --git a/bot/rules/chars.py b/bot/rules/chars.py new file mode 100644 index 000000000..d05e3cd83 --- /dev/null +++ b/bot/rules/chars.py @@ -0,0 +1,28 @@ +"""Detects total message char count exceeding the limit sent by a single user.""" + +from typing import Dict, Iterable, List, Optional, Tuple + +from discord import Member, Message + + +async def apply( + last_message: Message, + recent_messages: List[Message], + config: Dict[str, int] +) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: + + relevant_messages = tuple( + msg + for msg in recent_messages + if msg.author == last_message.author + ) + + total_recent_chars = sum(len(msg.content) for msg in relevant_messages) + + if total_recent_chars > config['max']: + return ( + f"sent {total_recent_chars} characters in {config['interval']}s", + (last_message.author,), + relevant_messages + ) + return None diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py new file mode 100644 index 000000000..e4f957ddb --- /dev/null +++ b/bot/rules/discord_emojis.py @@ -0,0 +1,35 @@ +"""Detects total Discord emojis (excluding Unicode emojis) exceeding the limit sent by a single user.""" + +import re +from typing import Dict, Iterable, List, Optional, Tuple + +from discord import Member, Message + + +DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>") + + +async def apply( + last_message: Message, + recent_messages: List[Message], + config: Dict[str, int] +) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: + + relevant_messages = tuple( + msg + for msg in recent_messages + if msg.author == last_message.author + ) + + total_emojis = sum( + len(DISCORD_EMOJI_RE.findall(msg.content)) + for msg in relevant_messages + ) + + if total_emojis > config['max']: + return ( + f"sent {total_emojis} emojis in {config['interval']}s", + (last_message.author,), + relevant_messages + ) + return None diff --git a/bot/rules/duplicates.py b/bot/rules/duplicates.py new file mode 100644 index 000000000..763fc9983 --- /dev/null +++ b/bot/rules/duplicates.py @@ -0,0 +1,31 @@ +"""Detects duplicated messages sent by a single user.""" + +from typing import Dict, Iterable, List, Optional, Tuple + +from discord import Member, Message + + +async def apply( + last_message: Message, + recent_messages: List[Message], + config: Dict[str, int] +) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: + + relevant_messages = tuple( + msg + for msg in recent_messages + if ( + msg.author == last_message.author + and msg.content == last_message.content + ) + ) + + total_duplicated = len(relevant_messages) + + if total_duplicated > config['max']: + return ( + f"sent {total_duplicated} duplicated messages in {config['interval']}s", + (last_message.author,), + relevant_messages + ) + return None diff --git a/bot/rules/links.py b/bot/rules/links.py new file mode 100644 index 000000000..dfeb38c61 --- /dev/null +++ b/bot/rules/links.py @@ -0,0 +1,31 @@ +"""Detects total links exceeding the limit sent by a single user.""" + +import re +from typing import Dict, Iterable, List, Optional, Tuple + +from discord import Member, Message + + +LINK_RE = re.compile(r"(https?://[^\s]+)") + + +async def apply( + last_message: Message, + recent_messages: List[Message], + config: Dict[str, int] +) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: + + relevant_messages = tuple( + msg + for msg in recent_messages + if msg.author == last_message.author + ) + total_links = sum(len(LINK_RE.findall(msg.content)) for msg in relevant_messages) + + if total_links > config['max']: + return ( + f"sent {total_links} links in {config['interval']}s", + (last_message.author,), + relevant_messages + ) + return None diff --git a/bot/rules/mentions.py b/bot/rules/mentions.py new file mode 100644 index 000000000..45c47b6ba --- /dev/null +++ b/bot/rules/mentions.py @@ -0,0 +1,28 @@ +"""Detects total mentions exceeding the limit sent by a single user.""" + +from typing import Dict, Iterable, List, Optional, Tuple + +from discord import Member, Message + + +async def apply( + last_message: Message, + recent_messages: List[Message], + config: Dict[str, int] +) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: + + relevant_messages = tuple( + msg + for msg in recent_messages + if msg.author == last_message.author + ) + + total_recent_mentions = sum(len(msg.mentions) for msg in relevant_messages) + + if total_recent_mentions > config['max']: + return ( + f"sent {total_recent_mentions} mentions in {config['interval']}s", + (last_message.author,), + relevant_messages + ) + return None diff --git a/bot/rules/newlines.py b/bot/rules/newlines.py new file mode 100644 index 000000000..a6a1a52d0 --- /dev/null +++ b/bot/rules/newlines.py @@ -0,0 +1,28 @@ +"""Detects total newlines exceeding the set limit sent by a single user.""" + +from typing import Dict, Iterable, List, Optional, Tuple + +from discord import Member, Message + + +async def apply( + last_message: Message, + recent_messages: List[Message], + config: Dict[str, int] +) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: + + relevant_messages = tuple( + msg + for msg in recent_messages + if msg.author == last_message.author + ) + + total_recent_newlines = sum(msg.content.count('\n') for msg in relevant_messages) + + if total_recent_newlines > config['max']: + return ( + f"sent {total_recent_newlines} newlines in {config['interval']}s", + (last_message.author,), + relevant_messages + ) + return None diff --git a/bot/rules/role_mentions.py b/bot/rules/role_mentions.py new file mode 100644 index 000000000..2177a73b5 --- /dev/null +++ b/bot/rules/role_mentions.py @@ -0,0 +1,28 @@ +"""Detects total role mentions exceeding the limit sent by a single user.""" + +from typing import Dict, Iterable, List, Optional, Tuple + +from discord import Member, Message + + +async def apply( + last_message: Message, + recent_messages: List[Message], + config: Dict[str, int] +) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: + + relevant_messages = tuple( + msg + for msg in recent_messages + if msg.author == last_message.author + ) + + total_recent_mentions = sum(len(msg.role_mentions) for msg in relevant_messages) + + if total_recent_mentions > config['max']: + return ( + f"sent {total_recent_mentions} role mentions in {config['interval']}s", + (last_message.author,), + relevant_messages + ) + return None diff --git a/config-default.yml b/config-default.yml index ee3e6a74e..0519244b0 100644 --- a/config-default.yml +++ b/config-default.yml @@ -6,6 +6,16 @@ bot: # Per channel, per tag. tags: 60 + clean: + # Maximum number of messages to traverse for clean commands + message_limit: 10000 + + +style: + colours: + soft_red: 0xcd6d6d + soft_green: 0x68c290 + emojis: defcon_disabled: "<:defcondisabled:470326273952972810>" defcon_enabled: "<:defconenabled:470326274213150730>" @@ -16,6 +26,12 @@ bot: white_chevron: "<:whitechevron:418110396973711363>" lemoneye2: "<:lemoneye2:435193765582340098>" + status_online: "<:status_online:470326272351010816>" + status_idle: "<:status_idle:470326266625785866>" + status_dnd: "<:status_dnd:470326272082313216>" + status_offline: "<:status_offline:470326266537705472>" + + bullet: "\u2022" pencil: "\u270F" new: "\U0001F195" @@ -29,6 +45,8 @@ bot: defcon_enabled: "https://cdn.discordapp.com/emojis/470326274213150730.png" defcon_updated: "https://cdn.discordapp.com/emojis/472472638342561793.png" + filtering: "https://cdn.discordapp.com/emojis/472472638594482195.png" + guild_update: "https://cdn.discordapp.com/emojis/469954765141442561.png" hash_blurple: "https://cdn.discordapp.com/emojis/469950142942806017.png" @@ -46,48 +64,123 @@ bot: user_unban: "https://cdn.discordapp.com/emojis/469952898692808704.png" user_update: "https://cdn.discordapp.com/emojis/469952898684551168.png" + user_mute: "https://cdn.discordapp.com/emojis/472472640100106250.png" + user_unmute: "https://cdn.discordapp.com/emojis/472472640100106250.png" + guild: id: 267624335836053506 channels: - admins: &ADMINS 365960823622991872 - announcements: 354619224620138496 - big_brother_logs: 468507907357409333 - bot: 267659945086812160 - checkpoint_test: 422077681434099723 - devalerts: 460181980097675264 - devlog: 409308876241108992 - devtest: 414574275865870337 - help_0: 303906576991780866 - help_1: 303906556754395136 - help_2: 303906514266226689 - help_3: 439702951246692352 - help_4: 451312046647148554 - help_5: 454941769734422538 - helpers: 385474242440986624 - message_log: &MESSAGE_LOG 467752170159079424 - modlog: &MODLOG 282638479504965634 - off_topic_0: 291284109232308226 - off_topic_1: 463035241142026251 - off_topic_2: 463035268514185226 - python: 267624335836053506 - verification: 352442727016693763 + admins: &ADMINS 365960823622991872 + announcements: 354619224620138496 + big_brother_logs: &BBLOGS 468507907357409333 + bot: 267659945086812160 + checkpoint_test: 422077681434099723 + devalerts: 460181980097675264 + devlog: &DEVLOG 409308876241108992 + devtest: &DEVTEST 414574275865870337 + help_0: 303906576991780866 + help_1: 303906556754395136 + help_2: 303906514266226689 + help_3: 439702951246692352 + help_4: 451312046647148554 + help_5: 454941769734422538 + helpers: 385474242440986624 + message_log: &MESSAGE_LOG 467752170159079424 + mod_alerts: 473092532147060736 + modlog: &MODLOG 282638479504965634 + off_topic_0: 291284109232308226 + off_topic_1: 463035241142026251 + off_topic_2: 463035268514185226 + python: 267624335836053506 + staff_lounge: &STAFF_LOUNGE 464905259261755392 + verification: 352442727016693763 ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG] roles: - admin: 267628507062992896 - announcements: 463658397560995840 - champion: 430492892331769857 - contributor: 295488872404484098 - devops: 409416496733880320 - jammer: 423054537079783434 - moderator: 267629731250176001 - owner: 267627879762755584 - verified: 352427296948486144 - helpers: 267630620367257601 - muted: 277914926603829249 + admin: &ADMIN_ROLE 267628507062992896 + announcements: 463658397560995840 + champion: 430492892331769857 + contributor: 295488872404484098 + developer: 352427296948486144 + devops: &DEVOPS_ROLE 409416496733880320 + jammer: 423054537079783434 + moderator: &MOD_ROLE 267629731250176001 + muted: &MUTED_ROLE 277914926603829249 + owner: &OWNER_ROLE 267627879762755584 + verified: 352427296948486144 + helpers: 267630620367257601 + + +filter: + + # What do we filter? + filter_zalgo: true + filter_invites: true + filter_domains: true + watch_words: true + watch_tokens: true + + # Filter configuration + ping_everyone: true # Ping @everyone when we send a mod-alert? + + guild_invite_whitelist: + - vywQPxd # Code Monkeys + - kWJYurV # Functional Programming + - 010z0Kw1A9ql5c1Qe # Programming: Meme Edition + - XBGetGp # STEM + + vanity_url_whitelist: + - python # Python Discord + + domain_blacklist: + - pornhub.com + - liveleak.com + + word_watchlist: + - goo+ks* + - ky+s+ + - gh?[ae]+y+s* + - ki+ke+s* + - beane?r*s* + - coo+ns* + - nig+lets* + - slant-eyes* + - towe?l-?head+s* + - chi*n+k+s* + - spick*s* + - kill* +(?:yo)?urself+ + - jew+s* + - suicide + - rape + - (?:re+)?tar+d+(?:ed)? + - cunts* + + token_watchlist: + - fa+g+s* + - 卐 + - 卍 + - cuck + - nigg+(?:e*r+|a+h+?|u+h+)s? + - fag+o+t+s* + + # Censor doesn't apply to these + channel_whitelist: + - *ADMINS + - *MODLOG + - *MESSAGE_LOG + - *DEVLOG + - *BBLOGS + - *STAFF_LOUNGE + - *DEVTEST + + role_whitelist: + - *ADMIN_ROLE + - *MOD_ROLE + - *OWNER_ROLE + - *DEVOPS_ROLE keys: @@ -117,10 +210,17 @@ urls: site_schema: &SCHEMA "https://" site_bigbrother_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/bigbrother"] + site_clean_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/clean"] + site_clean_logs: !JOIN [*SCHEMA, *DOMAIN, "/bot/clean_logs"] site_docs_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/docs"] site_facts_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_facts"] site_hiphopify_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/hiphopify"] site_idioms_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_idioms"] + site_infractions: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions"] + site_infractions_user: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/user/{user_id}"] + site_infractions_type: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/type/{infraction_type}"] + site_infractions_by_id: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/id/{infraction_id}"] + site_infractions_user_type_current: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/user/{user_id}/{infraction_type}/current"] site_names_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_names"] site_off_topic_names_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/off-topic-names"] site_quiz_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_quiz"] @@ -129,11 +229,6 @@ urls: site_tags_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/tags"] site_user_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/users"] site_user_complete_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/users/complete"] - site_infractions: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions"] - site_infractions_user: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/user/{user_id}"] - site_infractions_type: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/type/{infraction_type}"] - site_infractions_by_id: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/id/{infraction_id}"] - site_infractions_user_type_current: !JOIN [*SCHEMA, *DOMAIN, "/bot/infractions/user/{user_id}/{infraction_type}/current"] # Env vars deploy: !ENV "DEPLOY_URL" @@ -144,3 +239,53 @@ urls: gitlab_bot_repo: "https://gitlab.com/python-discord/projects/bot" omdb: "http://omdbapi.com" paste_service: "https://paste.pydis.com/{key}" + + +anti_spam: + # Clean messages that violate a rule. + clean_offending: true + + punishment: + role_id: *MUTED_ROLE + remove_after: 600 + + rules: + attachments: + interval: 10 + max: 3 + + burst: + interval: 10 + max: 7 + + burst_shared: + interval: 10 + max: 20 + + chars: + interval: 5 + max: 3_000 + + duplicates: + interval: 10 + max: 3 + + discord_emojis: + interval: 10 + max: 20 + + links: + interval: 10 + max: 4 + + mentions: + interval: 10 + max: 5 + + newlines: + interval: 10 + max: 100 + + role_mentions: + interval: 10 + max: 3 |