aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Leon Sandøy <[email protected]>2019-01-12 22:58:45 +0100
committerGravatar GitHub <[email protected]>2019-01-12 22:58:45 +0100
commit70711340d4d34077d54f41170d3b159b498ee73f (patch)
tree709f784a859f55aac925861f9cbb839fda63e2f3
parentChange log.error to log.exception to avoid hiding traceback (diff)
parentMerge pull request #272 from python-discord/edit-log-timestamp (diff)
Merge branch 'master' into master
-rw-r--r--bot/cogs/alias.py2
-rw-r--r--bot/cogs/antispam.py35
-rw-r--r--bot/cogs/bigbrother.py62
-rw-r--r--bot/cogs/events.py32
-rw-r--r--bot/cogs/help.py4
-rw-r--r--bot/cogs/modlog.py50
-rw-r--r--bot/cogs/token_remover.py5
-rw-r--r--config-default.yml3
8 files changed, 141 insertions, 52 deletions
diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py
index 2ce4a51e3..0b848c773 100644
--- a/bot/cogs/alias.py
+++ b/bot/cogs/alias.py
@@ -71,7 +71,7 @@ class Alias:
@command(name="watch", hidden=True)
async def bigbrother_watch_alias(
- self, ctx, user: User, *, reason: str = None
+ self, ctx, user: User, *, reason: str
):
"""
Alias for invoking <prefix>bigbrother watch user [text_channel].
diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py
index d5b72718c..800700a50 100644
--- a/bot/cogs/antispam.py
+++ b/bot/cogs/antispam.py
@@ -1,21 +1,18 @@
-import asyncio
import logging
-import textwrap
from datetime import datetime, timedelta
from typing import List
-from dateutil.relativedelta import relativedelta
from discord import Colour, Member, Message, Object, TextChannel
from discord.ext.commands import Bot
from bot import rules
+from bot.cogs.moderation import Moderation
from bot.cogs.modlog import ModLog
from bot.constants import (
AntiSpam as AntiSpamConfig, Channels,
Colours, DEBUG_MODE, Event,
Guild as GuildConfig, Icons, Roles,
)
-from bot.utils.time import humanize_delta
log = logging.getLogger(__name__)
@@ -44,7 +41,7 @@ WHITELISTED_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
class AntiSpam:
def __init__(self, bot: Bot):
self.bot = bot
- self.muted_role = None
+ self._muted_role = Object(Roles.muted)
@property
def mod_log(self) -> ModLog:
@@ -110,8 +107,6 @@ class AntiSpam:
# 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_message = (
f"**Triggered by:** {member.display_name}#{member.discriminator} (`{member.id}`)\n"
@@ -133,7 +128,8 @@ class AntiSpam:
mod_alert_message += f"{content}"
- await self.mod_log.send_log_message(
+ # Return the mod log message Context that we can use to post the infraction
+ mod_log_ctx = await self.mod_log.send_log_message(
icon_url=Icons.filtering,
colour=Colour(Colours.soft_red),
title=f"Spam detected!",
@@ -143,27 +139,8 @@ class AntiSpam:
ping_everyone=AntiSpamConfig.ping_everyone
)
- 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}.
- """)
-
- await self.mod_log.send_log_message(
- icon_url=Icons.user_mute, colour=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 self.mod_log.send_log_message(
- icon_url=Icons.user_mute, colour=Colour(Colours.soft_green),
- title="User unmuted",
- text=f"Was muted by `AntiSpam` cog for {human_duration}."
- )
+ # Run a tempmute
+ await mod_log_ctx.invoke(Moderation.tempmute, member, f"{remove_role_after}S", reason=reason)
async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]):
# Is deletion of offending messages actually enabled?
diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py
index 29b13f038..70916cd7b 100644
--- a/bot/cogs/bigbrother.py
+++ b/bot/cogs/bigbrother.py
@@ -2,8 +2,10 @@ import asyncio
import logging
import re
from collections import defaultdict, deque
+from time import strptime, struct_time
from typing import List, Union
+from aiohttp import ClientError
from discord import Color, Embed, Guild, Member, Message, TextChannel, User
from discord.ext.commands import Bot, Context, group
@@ -26,9 +28,11 @@ class BigBrother:
def __init__(self, bot: Bot):
self.bot = bot
self.watched_users = {} # { user_id: log_channel_id }
+ self.watch_reasons = {} # { user_id: watch_reason }
self.channel_queues = defaultdict(lambda: defaultdict(deque)) # { user_id: { channel_id: queue(messages) }
self.last_log = [None, None, 0] # [user_id, channel_id, message_count]
self.consuming = False
+ self.infraction_watch_prefix = "bb watch: " # Please do not change or we won't be able to find old reasons
self.bot.loop.create_task(self.get_watched_users())
@@ -62,6 +66,42 @@ class BigBrother:
data = await response.json()
self.update_cache(data)
+ async def get_watch_reason(self, user_id: int) -> str:
+ """ Fetches and returns the latest watch reason for a user using the infraction API """
+
+ re_bb_watch = rf"^{self.infraction_watch_prefix}"
+ user_id = str(user_id)
+
+ try:
+ response = await self.bot.http_session.get(
+ URLs.site_infractions_user_type.format(
+ user_id=user_id,
+ infraction_type="note",
+ ),
+ params={"search": re_bb_watch, "hidden": "True", "active": "False"},
+ headers=self.HEADERS
+ )
+ infraction_list = await response.json()
+ except ClientError:
+ log.exception(f"Failed to retrieve bb watch reason for {user_id}.")
+ return "(error retrieving bb reason)"
+
+ if infraction_list:
+ latest_reason_infraction = max(infraction_list, key=self._parse_infraction_time)
+ latest_reason = latest_reason_infraction['reason'][len(self.infraction_watch_prefix):]
+ log.trace(f"The latest bb watch reason for {user_id}: {latest_reason}")
+ return latest_reason
+
+ log.trace(f"No bb watch reason found for {user_id}; returning default string")
+ return "(no reason specified)"
+
+ @staticmethod
+ def _parse_infraction_time(infraction: str) -> struct_time:
+ """Takes RFC1123 date_time string and returns time object for sorting purposes"""
+
+ date_string = infraction["inserted_at"]
+ return strptime(date_string, "%a, %d %b %Y %H:%M:%S %Z")
+
async def on_member_ban(self, guild: Guild, user: Union[User, Member]):
if guild.id == GuildConfig.id and user.id in self.watched_users:
url = f"{URLs.site_bigbrother_api}?user_id={user.id}"
@@ -70,6 +110,7 @@ class BigBrother:
async with self.bot.http_session.delete(url, headers=self.HEADERS) as response:
del self.watched_users[user.id]
del self.channel_queues[user.id]
+ del self.watch_reasons[user.id]
if response.status == 204:
await channel.send(
f"{Emojis.bb_message}:hammer: {user} got banned, so "
@@ -139,10 +180,17 @@ class BigBrother:
# Send header if user/channel are different or if message limit exceeded.
if message.author.id != last_user or message.channel.id != last_channel or msg_count > limit:
+ # Retrieve watch reason from API if it's not already in the cache
+ if message.author.id not in self.watch_reasons:
+ log.trace(f"No watch reason for {message.author.id} found in cache; retrieving from API")
+ user_watch_reason = await self.get_watch_reason(message.author.id)
+ self.watch_reasons[message.author.id] = user_watch_reason
+
self.last_log = [message.author.id, message.channel.id, 0]
embed = Embed(description=f"{message.author.mention} in [#{message.channel.name}]({message.jump_url})")
embed.set_author(name=message.author.nick or message.author.name, icon_url=message.author.avatar_url)
+ embed.set_footer(text=f"Watch reason: {self.watch_reasons[message.author.id]}")
await destination.send(embed=embed)
@staticmethod
@@ -246,15 +294,15 @@ class BigBrother:
)
else:
self.watched_users[user.id] = channel
+ self.watch_reasons[user.id] = reason
+ # Add a note (shadow warning) with the reason for watching
+ reason = f"{self.infraction_watch_prefix}{reason}"
+ await post_infraction(ctx, user, type="warning", reason=reason, hidden=True)
else:
data = await response.json()
- reason = data.get('error_message', "no message provided")
- await ctx.send(f":x: the API returned an error: {reason}")
-
- # Add a note (shadow warning) with the reason for watching
- reason = "bb watch: " + reason # Prepend for situational awareness
- await post_infraction(ctx, user, type="warning", reason=reason, hidden=True)
+ error_reason = data.get('error_message', "no message provided")
+ await ctx.send(f":x: the API returned an error: {error_reason}")
@bigbrother_group.command(name='unwatch', aliases=('uw',))
@with_role(Roles.owner, Roles.admin, Roles.moderator)
@@ -270,6 +318,8 @@ class BigBrother:
del self.watched_users[user.id]
if user.id in self.channel_queues:
del self.channel_queues[user.id]
+ if user.id in self.watch_reasons:
+ del self.watch_reasons[user.id]
else:
log.warning(f"user {user.id} was unwatched but was not found in the cache")
diff --git a/bot/cogs/events.py b/bot/cogs/events.py
index edfc6e579..f0baecd4b 100644
--- a/bot/cogs/events.py
+++ b/bot/cogs/events.py
@@ -25,6 +25,7 @@ class Events:
def __init__(self, bot: Bot):
self.bot = bot
+ self.headers = {"X-API-KEY": Keys.site_api}
@property
def mod_log(self) -> ModLog:
@@ -103,6 +104,29 @@ class Events:
resp = await response.json()
return resp["data"]
+ async def has_active_mute(self, user_id: str) -> bool:
+ """
+ Check whether a user has any active mute infractions
+ """
+
+ response = await self.bot.http_session.get(
+ URLs.site_infractions_user.format(
+ user_id=user_id
+ ),
+ params={"hidden": "True"},
+ headers=self.headers
+ )
+ infraction_list = await response.json()
+
+ # Check for active mute infractions
+ if not infraction_list:
+ # Short circuit
+ return False
+
+ return any(
+ infraction["active"] for infraction in infraction_list if infraction["type"].lower() == "mute"
+ )
+
async def on_command_error(self, ctx: Context, e: CommandError):
command = ctx.command
parent = None
@@ -236,6 +260,14 @@ class Events:
for role in RESTORE_ROLES:
if role in old_roles:
+ # Check for mute roles that were not able to be removed and skip if present
+ if role == str(Roles.muted) and not await self.has_active_mute(str(member.id)):
+ log.debug(
+ f"User {member.id} has no active mute infraction, "
+ "their leftover muted role will not be persisted"
+ )
+ continue
+
new_roles.append(Object(int(role)))
for role in new_roles:
diff --git a/bot/cogs/help.py b/bot/cogs/help.py
index c82a25417..ded068123 100644
--- a/bot/cogs/help.py
+++ b/bot/cogs/help.py
@@ -6,10 +6,10 @@ from contextlib import suppress
from discord import Colour, Embed, HTTPException
from discord.ext import commands
+from discord.ext.commands import CheckFailure
from fuzzywuzzy import fuzz, process
from bot import constants
-from bot.decorators import InChannelCheckFailure
from bot.pagination import (
DELETE_EMOJI, FIRST_EMOJI, LAST_EMOJI,
LEFT_EMOJI, LinePaginator, RIGHT_EMOJI,
@@ -435,7 +435,7 @@ class HelpSession:
# the mean time.
try:
can_run = await command.can_run(self._ctx)
- except InChannelCheckFailure:
+ except CheckFailure:
can_run = False
if not can_run:
diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py
index c96838a54..55611c5e4 100644
--- a/bot/cogs/modlog.py
+++ b/bot/cogs/modlog.py
@@ -104,9 +104,19 @@ class ModLog:
self._ignored[event].append(item)
async def send_log_message(
- self, icon_url: Optional[str], colour: Colour, title: Optional[str], text: str,
- thumbnail: str = None, channel_id: int = Channels.modlog, ping_everyone: bool = False,
- files: List[File] = None, content: str = None, additional_embeds: List[Embed] = None,
+ self,
+ icon_url: Optional[str],
+ colour: Colour,
+ title: Optional[str],
+ text: str,
+ thumbnail: Optional[str] = None,
+ channel_id: int = Channels.modlog,
+ ping_everyone: bool = False,
+ files: Optional[List[File]] = None,
+ content: Optional[str] = None,
+ additional_embeds: Optional[List[Embed]] = None,
+ timestamp_override: Optional[datetime.datetime] = None,
+ footer_override: Optional[str] = None,
):
embed = Embed(description=text)
@@ -114,9 +124,13 @@ class ModLog:
embed.set_author(name=title, icon_url=icon_url)
embed.colour = colour
- embed.timestamp = datetime.datetime.utcnow()
- if thumbnail is not None:
+ embed.timestamp = timestamp_override or datetime.datetime.utcnow()
+
+ if footer_override:
+ embed.set_footer(text=footer_override)
+
+ if thumbnail:
embed.set_thumbnail(url=thumbnail)
if ping_everyone:
@@ -126,14 +140,15 @@ class ModLog:
content = "@everyone"
channel = self.bot.get_channel(channel_id)
-
- await channel.send(content=content, embed=embed, files=files)
+ log_message = await channel.send(content=content, embed=embed, files=files)
if additional_embeds:
await channel.send("With the following embed(s):")
for additional_embed in additional_embeds:
await channel.send(embed=additional_embed)
+ return await self.bot.get_context(log_message) # Optionally return for use with antispam
+
async def on_guild_channel_create(self, channel: GUILD_CHANNEL):
if channel.guild.id != GuildConstant.id:
return
@@ -675,14 +690,27 @@ class ModLog:
f"{after.clean_content}"
)
+ if before.edited_at:
+ # Message was previously edited, to assist with self-bot detection, use the edited_at
+ # datetime as the baseline and create a human-readable delta between this edit event
+ # and the last time the message was edited
+ timestamp = before.edited_at
+ delta = humanize_delta(relativedelta(after.edited_at, before.edited_at))
+ footer = f"Last edited {delta} ago"
+ else:
+ # Message was not previously edited, use the created_at datetime as the baseline, no
+ # delta calculation needed
+ timestamp = before.created_at
+ footer = None
+
await self.send_log_message(
- Icons.message_edit, Colour.blurple(), "Message edited (Before)",
- before_response, channel_id=Channels.message_log
+ Icons.message_edit, Colour.blurple(), "Message edited (Before)", before_response,
+ channel_id=Channels.message_log, timestamp_override=timestamp, footer_override=footer
)
await self.send_log_message(
- Icons.message_edit, Colour.blurple(), "Message edited (After)",
- after_response, channel_id=Channels.message_log
+ Icons.message_edit, Colour.blurple(), "Message edited (After)", after_response,
+ channel_id=Channels.message_log, timestamp_override=after.edited_at
)
async def on_raw_message_edit(self, event: RawMessageUpdateEvent):
diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py
index 8277513a7..c1a0e18ba 100644
--- a/bot/cogs/token_remover.py
+++ b/bot/cogs/token_remover.py
@@ -16,8 +16,9 @@ log = logging.getLogger(__name__)
DELETION_MESSAGE_TEMPLATE = (
"Hey {mention}! I noticed you posted a seemingly valid Discord API "
- "token in your message and have removed your message to prevent abuse. "
- "We recommend regenerating your token regardless, which you can do here: "
+ "token in your message and have removed your message. "
+ "We **strongly recommend** regenerating your token as it's probably "
+ "been compromised. You can do that here: "
"<https://discordapp.com/developers/applications/me>\n"
"Feel free to re-post it with the token removed. "
"If you believe this was a mistake, please let us know!"
diff --git a/config-default.yml b/config-default.yml
index f462b8199..bb49a46e1 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -140,7 +140,7 @@ filter:
filter_zalgo: false
filter_invites: true
filter_domains: true
- filter_rich_embeds: true
+ filter_rich_embeds: false
watch_words: true
watch_tokens: true
@@ -243,6 +243,7 @@ urls:
site_infractions_type: !JOIN [*SCHEMA, *API, "/bot/infractions/type/{infraction_type}"]
site_infractions_by_id: !JOIN [*SCHEMA, *API, "/bot/infractions/id/{infraction_id}"]
site_infractions_user_type_current: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}/current"]
+ site_infractions_user_type: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}"]
site_logs_api: !JOIN [*SCHEMA, *API, "/bot/logs"]
site_logs_view: !JOIN [*SCHEMA, *DOMAIN, "/bot/logs"]
site_names_api: !JOIN [*SCHEMA, *API, "/bot/snake_names"]