Skip to content

Commit

Permalink
Merge branch 'master' into update-logging-levels
Browse files Browse the repository at this point in the history
  • Loading branch information
lemonsaurus authored Mar 31, 2020
2 parents cc153e0 + 582ddbb commit bd0df4b
Show file tree
Hide file tree
Showing 12 changed files with 656 additions and 12 deletions.
1 change: 1 addition & 0 deletions bot/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
bot.load_extension("bot.cogs.token_remover")
bot.load_extension("bot.cogs.utils")
bot.load_extension("bot.cogs.watchchannels")
bot.load_extension("bot.cogs.webhook_remover")
bot.load_extension("bot.cogs.wolfram")

# Apply `message_edited_at` patch if discord.py did not yet release a bug fix.
Expand Down
35 changes: 33 additions & 2 deletions bot/cogs/error_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None:
Error handling emits a single error message in the invoking context `ctx` and a log message,
prioritised as follows:
1. If the name fails to match a command but matches a tag, the tag is invoked
1. If the name fails to match a command:
* If it matches shh+ or unshh+, the channel is silenced or unsilenced respectively.
Otherwise if it matches a tag, the tag is invoked
* If CommandNotFound is raised when invoking the tag (determined by the presence of the
`invoked_from_error_handler` attribute), this error is treated as being unexpected
and therefore sends an error message
Expand All @@ -48,9 +50,11 @@ async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None:
log.trace(f"Command {command} had its error already handled locally; ignoring.")
return

# Try to look for a tag with the command's name if the command isn't found.
if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"):
if await self.try_silence(ctx):
return
if ctx.channel.id != Channels.verification:
# Try to look for a tag with the command's name
await self.try_get_tag(ctx)
return # Exit early to avoid logging.
elif isinstance(e, errors.UserInputError):
Expand Down Expand Up @@ -89,6 +93,33 @@ async def get_help_command(self, command: t.Optional[Command]) -> t.Tuple:
else:
return self.bot.get_command("help")

async def try_silence(self, ctx: Context) -> bool:
"""
Attempt to invoke the silence or unsilence command if invoke with matches a pattern.
Respecting the checks if:
* invoked with `shh+` silence channel for amount of h's*2 with max of 15.
* invoked with `unshh+` unsilence channel
Return bool depending on success of command.
"""
command = ctx.invoked_with.lower()
silence_command = self.bot.get_command("silence")
ctx.invoked_from_error_handler = True
try:
if not await silence_command.can_run(ctx):
log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.")
return False
except errors.CommandError:
log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.")
return False
if command.startswith("shh"):
await ctx.invoke(silence_command, duration=min(command.count("h")*2, 15))
return True
elif command.startswith("unshh"):
await ctx.invoke(self.bot.get_command("unsilence"))
return True
return False

async def try_get_tag(self, ctx: Context) -> None:
"""
Attempt to display a tag by interpreting the command name as a tag name.
Expand Down
4 changes: 3 additions & 1 deletion bot/cogs/moderation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
from .infractions import Infractions
from .management import ModManagement
from .modlog import ModLog
from .silence import Silence
from .superstarify import Superstarify


def setup(bot: Bot) -> None:
"""Load the Infractions, ModManagement, ModLog, and Superstarify cogs."""
"""Load the Infractions, ModManagement, ModLog, Silence, and Superstarify cogs."""
bot.add_cog(Infractions(bot))
bot.add_cog(ModLog(bot))
bot.add_cog(ModManagement(bot))
bot.add_cog(Silence(bot))
bot.add_cog(Superstarify(bot))
159 changes: 159 additions & 0 deletions bot/cogs/moderation/silence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import asyncio
import logging
from contextlib import suppress
from typing import Optional

from discord import TextChannel
from discord.ext import commands, tasks
from discord.ext.commands import Context

from bot.bot import Bot
from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles
from bot.converters import HushDurationConverter
from bot.utils.checks import with_role_check

log = logging.getLogger(__name__)


class SilenceNotifier(tasks.Loop):
"""Loop notifier for posting notices to `alert_channel` containing added channels."""

def __init__(self, alert_channel: TextChannel):
super().__init__(self._notifier, seconds=1, minutes=0, hours=0, count=None, reconnect=True, loop=None)
self._silenced_channels = {}
self._alert_channel = alert_channel

def add_channel(self, channel: TextChannel) -> None:
"""Add channel to `_silenced_channels` and start loop if not launched."""
if not self._silenced_channels:
self.start()
log.info("Starting notifier loop.")
self._silenced_channels[channel] = self._current_loop

def remove_channel(self, channel: TextChannel) -> None:
"""Remove channel from `_silenced_channels` and stop loop if no channels remain."""
with suppress(KeyError):
del self._silenced_channels[channel]
if not self._silenced_channels:
self.stop()
log.info("Stopping notifier loop.")

async def _notifier(self) -> None:
"""Post notice of `_silenced_channels` with their silenced duration to `_alert_channel` periodically."""
# Wait for 15 minutes between notices with pause at start of loop.
if self._current_loop and not self._current_loop/60 % 15:
log.debug(
f"Sending notice with channels: "
f"{', '.join(f'#{channel} ({channel.id})' for channel in self._silenced_channels)}."
)
channels_text = ', '.join(
f"{channel.mention} for {(self._current_loop-start)//60} min"
for channel, start in self._silenced_channels.items()
)
await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}")


class Silence(commands.Cog):
"""Commands for stopping channel messages for `verified` role in a channel."""

def __init__(self, bot: Bot):
self.bot = bot
self.muted_channels = set()
self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars())
self._get_instance_vars_event = asyncio.Event()

async def _get_instance_vars(self) -> None:
"""Get instance variables after they're available to get from the guild."""
await self.bot.wait_until_guild_available()
guild = self.bot.get_guild(Guild.id)
self._verified_role = guild.get_role(Roles.verified)
self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts)
self._mod_log_channel = self.bot.get_channel(Channels.mod_log)
self.notifier = SilenceNotifier(self._mod_log_channel)
self._get_instance_vars_event.set()

@commands.command(aliases=("hush",))
async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None:
"""
Silence the current channel for `duration` minutes or `forever`.
Duration is capped at 15 minutes, passing forever makes the silence indefinite.
Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start.
"""
await self._get_instance_vars_event.wait()
log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.")
if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration):
await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.")
return
if duration is None:
await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.")
return

await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).")
await asyncio.sleep(duration*60)
log.info(f"Unsilencing channel after set delay.")
await ctx.invoke(self.unsilence)

@commands.command(aliases=("unhush",))
async def unsilence(self, ctx: Context) -> None:
"""
Unsilence the current channel.
If the channel was silenced indefinitely, notifications for the channel will stop.
"""
await self._get_instance_vars_event.wait()
log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.")
if await self._unsilence(ctx.channel):
await ctx.send(f"{Emojis.check_mark} unsilenced current channel.")

async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool:
"""
Silence `channel` for `self._verified_role`.
If `persistent` is `True` add `channel` to notifier.
`duration` is only used for logging; if None is passed `persistent` should be True to not log None.
Return `True` if channel permissions were changed, `False` otherwise.
"""
current_overwrite = channel.overwrites_for(self._verified_role)
if current_overwrite.send_messages is False:
log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.")
return False
await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=False))
self.muted_channels.add(channel)
if persistent:
log.info(f"Silenced #{channel} ({channel.id}) indefinitely.")
self.notifier.add_channel(channel)
return True

log.info(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).")
return True

async def _unsilence(self, channel: TextChannel) -> bool:
"""
Unsilence `channel`.
Check if `channel` is silenced through a `PermissionOverwrite`,
if it is unsilence it and remove it from the notifier.
Return `True` if channel permissions were changed, `False` otherwise.
"""
current_overwrite = channel.overwrites_for(self._verified_role)
if current_overwrite.send_messages is False:
await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None))
log.info(f"Unsilenced channel #{channel} ({channel.id}).")
self.notifier.remove_channel(channel)
self.muted_channels.discard(channel)
return True
log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.")
return False

def cog_unload(self) -> None:
"""Send alert with silenced channels on unload."""
if self.muted_channels:
channels_string = ''.join(channel.mention for channel in self.muted_channels)
message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}"
asyncio.create_task(self._mod_alerts_channel.send(message))

# This cannot be static (must have a __func__ attribute).
def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
return with_role_check(ctx, *MODERATION_ROLES)
22 changes: 21 additions & 1 deletion bot/cogs/snekbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ async def continue_eval(self, ctx: Context, response: Message) -> Optional[str]:
timeout=10
)

code = new_message.content.split(' ', maxsplit=1)[1]
code = await self.get_code(new_message)
await ctx.message.clear_reactions()
with contextlib.suppress(HTTPException):
await response.delete()
Expand All @@ -243,6 +243,26 @@ async def continue_eval(self, ctx: Context, response: Message) -> Optional[str]:

return code

async def get_code(self, message: Message) -> Optional[str]:
"""
Return the code from `message` to be evaluated.
If the message is an invocation of the eval command, return the first argument or None if it
doesn't exist. Otherwise, return the full content of the message.
"""
log.trace(f"Getting context for message {message.id}.")
new_ctx = await self.bot.get_context(message)

if new_ctx.command is self.eval_command:
log.trace(f"Message {message.id} invokes eval command.")
split = message.content.split(maxsplit=1)
code = split[1] if len(split) > 1 else None
else:
log.trace(f"Message {message.id} does not invoke eval command.")
code = message.content

return code

@command(name="eval", aliases=("e",))
@guild_only()
@in_channel(Channels.bot_commands, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES)
Expand Down
21 changes: 20 additions & 1 deletion bot/cogs/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
Namespaces are one honking great idea -- let's do more of those!
"""

ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png"


class Utils(Cog):
"""A selection of utilities which don't have a clear category."""
Expand All @@ -59,6 +61,10 @@ async def pep_command(self, ctx: Context, pep_number: str) -> None:
await ctx.invoke(self.bot.get_command("help"), "pep")
return

# Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs.
if pep_number == 0:
return await self.send_pep_zero(ctx)

possible_extensions = ['.txt', '.rst']
found_pep = False
for extension in possible_extensions:
Expand All @@ -82,7 +88,7 @@ async def pep_command(self, ctx: Context, pep_number: str) -> None:
description=f"[Link]({self.base_pep_url}{pep_number:04})",
)

pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png")
pep_embed.set_thumbnail(url=ICON_URL)

# Add the interesting information
fields_to_check = ("Status", "Python-Version", "Created", "Type")
Expand Down Expand Up @@ -278,6 +284,19 @@ async def vote(self, ctx: Context, title: str, *options: str) -> None:
for reaction in options:
await message.add_reaction(reaction)

async def send_pep_zero(self, ctx: Context) -> None:
"""Send information about PEP 0."""
pep_embed = Embed(
title=f"**PEP 0 - Index of Python Enhancement Proposals (PEPs)**",
description=f"[Link](https://www.python.org/dev/peps/)"
)
pep_embed.set_thumbnail(url=ICON_URL)
pep_embed.add_field(name="Status", value="Active")
pep_embed.add_field(name="Created", value="13-Jul-2000")
pep_embed.add_field(name="Type", value="Informational")

await ctx.send(embed=pep_embed)


def setup(bot: Bot) -> None:
"""Load the Utils cog."""
Expand Down
72 changes: 72 additions & 0 deletions bot/cogs/webhook_remover.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import logging
import re

from discord import Colour, Message
from discord.ext.commands import Cog

from bot.bot import Bot
from bot.cogs.moderation.modlog import ModLog
from bot.constants import Channels, Colours, Event, Icons

WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discordapp\.com/api/webhooks/\d+/)\S+/?", re.I)

ALERT_MESSAGE_TEMPLATE = (
"{user}, looks like you posted a Discord webhook URL. Therefore, your "
"message has been removed. Your webhook may have been **compromised** so "
"please re-create the webhook **immediately**. If you believe this was "
"mistake, please let us know."
)

log = logging.getLogger(__name__)


class WebhookRemover(Cog):
"""Scan messages to detect Discord webhooks links."""

def __init__(self, bot: Bot):
self.bot = bot

@property
def mod_log(self) -> ModLog:
"""Get current instance of `ModLog`."""
return self.bot.get_cog("ModLog")

async def delete_and_respond(self, msg: Message, redacted_url: str) -> None:
"""Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`."""
# Don't log this, due internal delete, not by user. Will make different entry.
self.mod_log.ignore(Event.message_delete, msg.id)
await msg.delete()
await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention))

message = (
f"{msg.author} (`{msg.author.id}`) posted a Discord webhook URL "
f"to #{msg.channel}. Webhook URL was `{redacted_url}`"
)
log.debug(message)

# Send entry to moderation alerts.
await self.mod_log.send_log_message(
icon_url=Icons.token_removed,
colour=Colour(Colours.soft_red),
title="Discord webhook URL removed!",
text=message,
thumbnail=msg.author.avatar_url_as(static_format="png"),
channel_id=Channels.mod_alerts
)

@Cog.listener()
async def on_message(self, msg: Message) -> None:
"""Check if a Discord webhook URL is in `message`."""
matches = WEBHOOK_URL_RE.search(msg.content)
if matches:
await self.delete_and_respond(msg, matches[1] + "xxx")

@Cog.listener()
async def on_message_edit(self, before: Message, after: Message) -> None:
"""Check if a Discord webhook URL is in the edited message `after`."""
await self.on_message(after)


def setup(bot: Bot) -> None:
"""Load `WebhookRemover` cog."""
bot.add_cog(WebhookRemover(bot))
Loading

0 comments on commit bd0df4b

Please sign in to comment.