diff options
| author | 2018-07-30 14:17:35 +0200 | |
|---|---|---|
| committer | 2018-07-30 14:17:35 +0200 | |
| commit | 04130c7392a187d5bb7bf9aece6f4786205d6263 (patch) | |
| tree | 2121eaeea49971cd120bb4df5ed7a6b84a855c66 | |
| parent | WIP (diff) | |
| parent | Removing the python-levenshtein package because it significantly slows down b... (diff) | |
Okay, this should be ready to MR now.
| -rw-r--r-- | Pipfile | 1 | ||||
| -rw-r--r-- | Pipfile.lock | 59 | ||||
| -rw-r--r-- | bot/__main__.py | 1 | ||||
| -rw-r--r-- | bot/cogs/antispam.py | 161 | ||||
| -rw-r--r-- | bot/cogs/clean.py | 1 | ||||
| -rw-r--r-- | bot/cogs/events.py | 59 | ||||
| -rw-r--r-- | bot/cogs/filtering.py | 77 | ||||
| -rw-r--r-- | bot/constants.py | 20 | ||||
| -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 | 58 |
20 files changed, 663 insertions, 74 deletions
@@ -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 b39fd24d0..d6cfc5d5b 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -44,6 +44,7 @@ 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") 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 index 85c9ec781..da0a5a9f2 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -169,6 +169,7 @@ class Clean: # 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 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 index 1ef797445..d9cb2bb82 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -1,11 +1,14 @@ import logging import re -from discord import Message +from discord import Colour, Message from discord.ext.commands import Bot from bot.cogs.modlog import ModLog -from bot.constants import Channels, Colours, Filter, Icons +from bot.constants import ( + Channels, Colours, DEBUG_MODE, + Filter, Icons +) log = logging.getLogger(__name__) @@ -84,7 +87,9 @@ class Filtering: and not msg.author.bot # Author not a bot ) - filter_message = not msg.author.bot and msg.channel.id == Channels.modlog # for testing + # 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: @@ -95,53 +100,33 @@ class Filtering: triggered = await _filter["function"](msg.content) if triggered: - - # If a filter is triggered, we should automod it. + 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": - log.debug( - f"The {filter_name} filter was triggered " - f"by {msg.author.name} in {msg.channel.name} with " - f"the following message:\n{msg.content}." - ) - - # Replace this with actual automod - await self.bot.get_channel(msg.channel.id).send( - content=f"The **{filter_name}** filter triggered!" - ) - - # If a watchlist triggers, we should send a mod alert. - elif _filter["type"] == "watchlist": - await self._mod_alert(filter_name, msg) + await msg.delete() break # We don't want multiple filters to trigger - async def _auto_mod(self, filter_name: str, msg: Message): - """ - Removes a message and sends a - """ - - async def _mod_alert(self, watchlist_name: str, msg: Message): - """ - Send a mod alert into the #mod-alert channel. - - Ping staff so they can take action. - """ - - message = ( - f"The {watchlist_name} watchlist was triggered " - f"by {msg.author.name} in {msg.channel.name} with " - f"the following message:\n{msg.content}." - ) - - log.debug(message) - - # Send pretty modlog embed to mod-alerts - await self.mod_log.send_log_message( - Icons., Colours.soft_red, "Watchlist triggered!", - message, msg.author.avatar_url_as(static_format="png"), - ping_everyone=True - ) - @staticmethod async def _has_watchlist_words(text: str) -> bool: """ diff --git a/bot/constants.py b/bot/constants.py index ee9e8b4a2..54d37b3f0 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 @@ -200,6 +200,7 @@ class Filter(metaclass=YAMLGetter): watch_words: bool watch_tokens: bool + ping_everyone: bool guild_invite_whitelist: List[str] vanity_url_whitelist: List[str] domain_blacklist: List[str] @@ -250,10 +251,12 @@ class Icons(metaclass=YAMLGetter): 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 @@ -300,6 +303,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 @@ -320,6 +324,7 @@ class Roles(metaclass=YAMLGetter): devops: int jammer: int moderator: int + muted: int owner: int verified: int muted: int @@ -389,6 +394,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 eaff5d56d..2beb4edbf 100644 --- a/config-default.yml +++ b/config-default.yml @@ -59,6 +59,9 @@ style: 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 @@ -80,6 +83,7 @@ guild: help_5: 454941769734422538 helpers: 385474242440986624 message_log: &MESSAGE_LOG 467752170159079424 + mod_alerts: 473092532147060736 modlog: &MODLOG 282638479504965634 off_topic_0: 291284109232308226 off_topic_1: 463035241142026251 @@ -99,7 +103,7 @@ guild: devops: &DEVOPS_ROLE 409416496733880320 jammer: 423054537079783434 moderator: &MOD_ROLE 267629731250176001 - muted: 277914926603829249 + muted: &MUTED_ROLE 277914926603829249 owner: &OWNER_ROLE 267627879762755584 verified: 352427296948486144 helpers: 267630620367257601 @@ -115,6 +119,8 @@ filter: 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 @@ -226,3 +232,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: 6 + + links: + interval: 10 + max: 4 + + mentions: + interval: 10 + max: 5 + + newlines: + interval: 10 + max: 100 + + role_mentions: + interval: 10 + max: 3 |