aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Leon Sandøy <[email protected]>2018-07-30 14:17:35 +0200
committerGravatar Leon Sandøy <[email protected]>2018-07-30 14:17:35 +0200
commit04130c7392a187d5bb7bf9aece6f4786205d6263 (patch)
tree2121eaeea49971cd120bb4df5ed7a6b84a855c66
parentWIP (diff)
parentRemoving the python-levenshtein package because it significantly slows down b... (diff)
Okay, this should be ready to MR now.
-rw-r--r--Pipfile1
-rw-r--r--Pipfile.lock59
-rw-r--r--bot/__main__.py1
-rw-r--r--bot/cogs/antispam.py161
-rw-r--r--bot/cogs/clean.py1
-rw-r--r--bot/cogs/events.py59
-rw-r--r--bot/cogs/filtering.py77
-rw-r--r--bot/constants.py20
-rw-r--r--bot/rules/__init__.py12
-rw-r--r--bot/rules/attachments.py30
-rw-r--r--bot/rules/burst.py27
-rw-r--r--bot/rules/burst_shared.py22
-rw-r--r--bot/rules/chars.py28
-rw-r--r--bot/rules/discord_emojis.py35
-rw-r--r--bot/rules/duplicates.py31
-rw-r--r--bot/rules/links.py31
-rw-r--r--bot/rules/mentions.py28
-rw-r--r--bot/rules/newlines.py28
-rw-r--r--bot/rules/role_mentions.py28
-rw-r--r--config-default.yml58
20 files changed, 663 insertions, 74 deletions
diff --git a/Pipfile b/Pipfile
index d94234b22..b6f7aeb6f 100644
--- a/Pipfile
+++ b/Pipfile
@@ -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